Rendering
This page is about what the renderer does between the start of a frame and the image on the display. It is written for the person who wants to know how the image is produced: which passes run, in what order, what they write, and which numbers are baked into them. If you are looking for the properties a script sets, the guide page Lighting and Materials has them, and we link back to it at the end of every section here.
We shade every opaque pixel in one forward pass. A deferred renderer would first write material properties into a multi-target G-buffer and shade from that later; we skip the G-buffer entirely, and the opaque pass samples the material, evaluates sunlight and ambient light, iterates the light list of the fragment’s cluster, and writes linear HDR radiance in one pass. That choice constrains everything else on this page: the lights are sorted into per-cluster buckets before shading starts, and the sun is resolved into a per-pixel visibility bit before the opaque shader reads it.
The numbers here are the ones the renderer ships with, and they are not script-exposed unless the guide page says so. Where a limit exists, we say what happens when a scene exceeds it.
A frame, pass by pass#
A forward renderer shades a pixel from inputs that do not exist at the start of the frame. To colour a pixel we need the lights that reach it, its sun visibility, and whether anything occludes it. All three come out of earlier work in the same frame, so a frame is a chain of passes that each prepare an input for a later one:
The numbering is the execution order, and the dependencies run one way: every pass in the shading group reads resources the geometry and shadow group has finished writing, the spot and point shadow maps included.
Light collection comes first because every later pass reads the collected light list. It keeps the first 512 point lights and the first 512 spot lights it encounters and drops the rest; the cutoff is by collection order, not by distance, intensity, or screen coverage.
Culling runs in two rounds around the depth prepass. The early cull picks a draw set, the early
prepass rasterizes depth only for that set, the Hi-Z build reduces that depth into a pyramid, and
the late cull retests against the pyramid the draws the early round could not reject; the late
prepass then fills in depth for the draws the late cull passed. The prepass depth has two consumers
named here: the Hi-Z build reduces it into the pyramid, and razor_build reads it to find which
pixels are visible at all.
Then the shadows: the Razor passes resolve the sun into one visibility bit per screen pixel, and the spot and point passes render depth maps for the local lights that cast. Opaque shading reads all of it. Sky fills the background, the MSAA resolve reduces the samples of the multisampled target to one colour per pixel, and GTAO, aerial perspective, transparency, and post-processing work on the resolved image.
For a script author this order shows up in the profiler rather than in an API: game.Profiler.Frame().render returns the cumulative GPU duration across all of these passes in
milliseconds, so a scene that adds shadow casters or transparent surfaces moves one number.
Depth precision and Reverse-Z#
The depth buffer the prepass fills is the most reused resource in the frame, and its precision is
not spread evenly over the view distance. A perspective projection divides clip coordinates by
linear view depth, so NDC depth is proportional to 1 / z_view. Half of the NDC depth range is
consumed between nearZ and 2 * nearZ, which is the first 2% of the view distance assuming farZ is 100 times nearZ; on a uniformly quantized depth buffer that is half of the representable
values. Far away the surviving values are coarse, distinct surfaces quantize to the same depth, and
which one wins the depth test changes from frame to frame as the camera moves, the artifact known as z-fighting.
The fix is to match the distribution of float precision to that curve. In IEEE-754 binary32 the spacing between adjacent representable values halves with every binade toward zero, so absolute resolution is finest near 0.0 and coarsest near 1.0. We therefore build a Reverse-Z projection that maps the near plane to z = 1.0 and the far plane to z = 0.0:
P_reverse_z = [ 1/(tan(fovY/2)*aspect) 0 0 0 ] [ 0 1/tan(fovY/2) 0 0 ] [ 0 0 nearZ/(farZ - nearZ) (nearZ*farZ)/(farZ - nearZ) ] [ 0 0 -1 0 ]In the third row near and far are swapped compared to the textbook matrix, and the fourth row still
negates view Z for the perspective divide. Paired with a 32-bit floating-point depth buffer
(D32F), the finely spaced end of the float range now covers the far distances, where the 1/z
mapping changes slowest. Because the two distributions oppose each other,
relative depth precision stays nearly constant across the view distance, and
distant surfaces have to be far closer together before
they quantize to the same value.
For a script author this makes the far plane cheap: with Reverse-Z the depth resolution at distance
is set by the float exponent rather than by farZ, so nearZ and farZ on CameraLens no longer
have to be traded off against each other to keep distant walls from z-fighting. nearZ still sets
the overall scale. The guide covers the camera component.
Clustered lighting#
A naive forward shader loops over every light in the scene for every fragment. That is O(fragments * lights), and a scene with hundreds of lamps spends most of its shading time on
lights that are nowhere near the pixel being shaded. Each fragment should instead test only the
short list of lights that can possibly reach it.
So we divide the view frustum into a grid of small volumes, called clusters, and record per cluster which lights intersect it. The grid is 16 tiles across, 9 tiles down, and 24 depth slices deep: 16 x 9 x 24 = 3456 clusters, small enough to rebuild every frame. The tile count is fixed, so the tile size follows the render target: at 1920 x 1080 a tile covers 120 x 120 pixels. A fragment computes its cluster index from its own screen position and depth, and loops over that cluster’s list only.
Contiguous GPU storage buffers hold the grid:
struct ClusterAABB { vec4 min_point; vec4 max_point;};struct ClusterLightGrid { uint offset; uint point_light_count; uint spot_light_count;};The first buffer holds 3456 ClusterAABB entries, the view-space minimum and maximum bounds of
each cluster in xyz with 1.0 in w. Assuming std430 packing a ClusterAABB is 32 bytes, so
that buffer is 3456 x 32 = 110,592 bytes, and it is rewritten only when the projection changes,
since the bounds are view-space. The second holds 3456 ClusterLightGrid headers of 12 bytes each,
41,472 bytes in all: where this cluster’s light indices start, how many point lights follow, and how
many spot lights follow them. The third is a flat uint array of light indices, LightIndices,
that every header points into. Nothing here is per-light-per-cluster storage: the headers are fixed
size, and the index array holds the sum of the per-cluster counts rather than clusters times lights.
Compute passes fill those buffers before any opaque geometry is drawn:
cluster_build_grid dispatches 3456 threads, one per cluster. A thread takes its grid coordinate (i, j, k), projects the corners of screen tile (i, j) into view-space rays, and intersects those
rays with the near and far planes of depth slice k. The slices are distributed exponentially
rather than evenly, because depth precision and light density both concentrate near the camera:
z_k = nearZ * (farZ / nearZ)^(k / 24)From the eight resulting corner points the thread computes an axis-aligned bounding box and writes it to the grid buffer. The box is the tight bound of those eight points, but the cluster is a frustum slice inscribed in it, so the box is the larger volume. The assignment that follows is conservative in one direction: it can include a light that reaches no pixel of the cluster, and it cannot exclude one that does.
cluster_assign_lights then walks the collected lights. Each light is reduced to one bounding
sphere: a point light to a sphere at its view-space position with radius Range, a spot light to a
sphere enclosing the cone described by Range and OuterAngle, which is again a bound and not the
cone itself. A sphere against an AABB is a clamp of the sphere centre into the box and one
squared-distance comparison, which is why the first pass stored AABBs instead of the eight corners.
Matching light indices gather into thread-group shared memory. Exactly one operation in this pass is
atomic: an atomic add on a global counter, which returns the base index of a contiguous block of LightIndices reserved for this cluster. Everything after it is an ordinary non-atomic store, the
indices into that block and the offset, point_light_count, and spot_light_count fields of the
cluster’s header, because one thread group owns one cluster and nothing else writes those addresses.
The allocation is atomic; the contents are not, and they become visible to the opaque pass through
the barrier between the passes.
The opaque shader then reads those buffers. The fragment shader derives its cluster index from gl_FragCoord and its view-space depth, reads both counts, and walks the list:
uint tile_x = uint(gl_FragCoord.x) / tile_size_x;uint tile_y = uint(gl_FragCoord.y) / tile_size_y;float z_view = -position_view_space.z;uint slice_z = uint(clamp(log(z_view / near_z) / log(far_z / near_z) * 24.0, 0.0, 23.0));uint cluster_index = tile_x + tile_y * 16u + slice_z * (16u * 9u);ClusterLightGrid grid = light_grid[cluster_index];for (uint i = 0u; i < grid.point_light_count; ++i) { uint light_idx = light_indices[grid.offset + i]; evaluate_point_light(lights[light_idx], fragment);}for (uint j = 0u; j < grid.spot_light_count; ++j) { uint light_idx = light_indices[grid.offset + grid.point_light_count + j]; evaluate_spot_light(lights[light_idx], fragment);}The slice index is a logarithm, inverting the exponential z_k distribution, and the clamp to [0, 23] puts a fragment nearer than nearZ or beyond farZ in the first or last slice instead of
indexing outside the buffer. The tile indices are not clamped, so they stay in range only if tile_size_x and tile_size_y cover the whole target, that is the render width and height divided
by 16 and 9 and rounded up. The point and spot lights of a cluster occupy one contiguous run, point
lights first, so the second loop starts at grid.offset + grid.point_light_count instead of needing
a second offset.
The practical consequence for a script author is that a light costs what its Range covers, not
what its Intensity is set to. Doubling Range multiplies the bounding sphere’s volume by eight
and the light is assigned to correspondingly more clusters, though not to exactly eight times as
many, because clusters grow with distance; raising Intensity leaves the sphere and the assignment
unchanged. The 512 point and 512 spot lights per frame are a hard ceiling, and lights past the 512th
in collection order are dropped rather than dimmed. The Light and SpotCone components are
described in the guide.
Sun shadows with Razor#
Sun shadows do not use the usual technique. A cascaded shadow map rasterizes the scene from the sun’s point of view into depth textures, and a pixel is in shadow if something in that texture is closer to the sun than it is. That works, but it brings two artifacts.
The first is perspective aliasing. Near the camera one screen pixel covers a tiny fraction of a shadow map texel, so the shadow edge becomes a staircase of texel-sized steps.
The second is bias tuning. A texel stores one depth, sampled at its centre, but the surface under it slopes across the texel footprint; comparing a fragment’s depth against that single quantized value makes a lit surface shadow itself in speckled bands, the artifact called shadow acne. Offsetting the comparison with slope and depth bias hides the acne, but an offset large enough to do so detaches the shadow from the base of the object that casts it, which is called peter-panning. No single bias removes both.
Razor removes both by reversing the direction of the test. Instead of testing fragments against a pre-rasterized depth texture, we test visible screen fragments against the planes of the shadow-casting triangles themselves, evaluated in floating point at the fragment’s own projected position rather than at a texel centre:
Cascade partitioning still happens, because the sun’s light-space extent has to be bounded per cascade. Four cascades cover the first 500 studs of view distance, split with a lambda of 0.95, which places the split distances 95 percent of the way from the uniform distribution toward the logarithmic one, and they live side by side in one light-space atlas:
Four 1024 x 1024 tiles fill a 2048 x 2048 atlas exactly, with no padding between them. The atlas
texels hold list heads rather than depth: assuming a head is the same 32-bit index as next_node_index, the head array is 2048 x 2048 x 4 bytes, 16 MiB.
razor_build is a compute pass over the depth buffer from the early prepass. For every visible
screen pixel that could receive sunlight it projects (x, y) into cascade space and files the
pixel under the light-space texel that contains the projected position. Each texel is the head of a
linked list of receiver pixels, and the nodes look like this:
struct ReceiverNode { uint screen_pixel_coord; float light_space_depth; uint next_node_index;};A node holds the encoded window coordinates of the receiver, its depth as projected from the sun,
and the index of the next receiver filed under the same texel, 12 bytes per visible receiver pixel
assuming tight packing. Nodes come from one contiguous buffer, and filing a pixel takes two atomic
operations: an atomic add on a node counter reserves the node’s slot, and an atomic exchange swaps
the texel’s head for that slot index and returns the previous head, which the thread writes into next_node_index. The exchange is a single read-modify-write, so two threads prepending to the same
texel cannot lose each other’s node; what it does not give is an order, and the list ends up in
whatever order the exchanges landed in. Each receiver is tested independently, so that order does
not change the shading result. razor_build and razor_trace are separate passes, so the node
field stores are visible to the traversal through the barrier between them.
razor_trace then transforms the shadow casters into light space and rasterizes
them over the atlas with conservative rasterization. Single-sample
rasterization produces a fragment only when the triangle covers the exact centre
of a texel; conservative rasterization in overestimate mode runs the fragment
stage for every texel the triangle touches at all, which is the guarantee we
need: an occluder that clips a texel’s corner without reaching its centre is
still tested against that texel’s receivers. We use the hardware feature where
the GPU has it. Where it does not, we fall back to a geometry shader dilation or
a vertex-pulled path, and those approximate the guarantee: they expand the
triangle by a fixed amount in light space, so a texel the expansion does not
reach can still be missed. Over-coverage in either path costs traversal and not
correctness, because a receiver outside the triangle fails the edge test below.
For every texel a caster touches, the shader walks that texel’s list of receivers. Each receiver takes an edge-function test evaluated in floating point at its own projected subpixel coordinates, and the triangle’s depth is interpolated at that same point. If the receiver’s depth is greater than the caster’s interpolated depth, plus a fixed 0.02 stud self-shadow slack and a 0.05 stud normal offset, the pixel is occluded. The result is written into a contiguous 1-bit per-pixel visibility buffer, one bit per screen pixel: at 1920 x 1080 that is 2,073,600 bits, 64,800 32-bit words, 259,200 bytes. Thirty-two pixels share a word, so the writes have to be atomic or masked per word, while the read in the opaque pass is one load and a shift.
Because the test evaluates the caster triangle’s plane at the receiver’s own position, there is no quantization error from a texel grid; what remains is floating-point error plus the two fixed slacks above. Acne needs no slope-scaled bias, and shadows stay attached to their casters, because 0.02 studs does not grow with the caster’s distance or slope. The edge resolves at screen-pixel granularity however close the camera gets, since the receiver list is keyed by screen pixel; the atlas texel size decides only how many receivers share a list.
Razor moves the cost from depth-map sampling to list traversal. Casters are still rasterized, but the per-texel work is proportional to the number of receivers filed under that texel, and a texel that collects a very large number of them would stall the pass. Each texel therefore caps how many receiver nodes it walks: past the cap traversal stops, the receivers it did not reach are left unshadowed, and the client logs a warning for the overflowing texel. Which receivers are dropped follows the prepend order, which is not deterministic, so an overflowing texel can drop a different set of pixels from frame to frame.
For a script author: sun shadows are hard-edged and either on or off per pixel, they reach 500
studs, and geometry beyond that is lit without a shadow test, so a shadow appears when its caster
comes inside that distance. Shadows.Mode, Shadows.Fill, and the ShadowOff tag that pulls an
entity out of every shadow pass are in the guide.
Local light shadows#
Point and spot lights keep depth-map shadows, because there is no camera-visible receiver set to reverse the test against: a point light shadows in every direction at once, and its receivers are not confined to the screen. Of the lights that survive collection, at most 4 point lights and 8 spot lights cast shadows, and the rest render unshadowed. That is at most 4 x 6 + 8 = 32 depth renders in a frame.
A shadowed point light renders six cube faces with Range as the far clipping plane; attenuation
reaches zero at Range, so geometry past the far plane cannot occlude anything that is still lit. A
shadowed spot light renders a single depth map over a perspective frustum whose field of view is
twice its OuterAngle, so the rim of the cone falls on the edge of the map and nothing inside the
cone projects outside it.
These are real depth maps, so the bias question from the previous section applies again, and both passes use a fixed 0.002 bias on the depth comparison. The value is constant: it does not scale with surface slope or with distance from the light, so a steep surface far from its light is the case it covers worst. It is not exposed, so the way to deal with an artifact from it is to move geometry rather than to tune a number.
For a script author the limits are the counts. A point light past the fourth in collection order
renders without a shadow and without an error, so tagging the lights that do not need one with LightShadowOff is how you select which four cast. The components are in the guide.
Ambient occlusion#
Ambient light in the shader is a broad term: sky irradiance arriving from every direction. A corner where two walls meet receives sky irradiance from only a fraction of the hemisphere, and the analytic ambient term does not account for that occlusion, so creases and contact points come out flat.
GTAO corrects that by estimating from the depth buffer how much sky each pixel can see. It runs at half resolution on each axis, a quarter of the full-resolution pixel count.
First we downsample the view-space depth buffer into a 5-level linear depth pyramid, so the occlusion shader can sample wide neighbourhoods without scattering its reads over the full-resolution buffer: a sample far from the pixel reads a coarse level, whose texels cover up to 16 x 16 full-resolution pixels at the coarsest of the five, assuming each level halves both axes.
Then the horizon search. For each half-resolution pixel it sweeps 3 angular slices, taking 3 steps per slice across the pyramid, and keeps the largest horizon elevation angle that neighbouring geometry subtends along each direction. Nine samples per pixel is a small budget, w hich the pyramid and the denoiser compensate for.
The third step is analytical integration: instead of counting occluded samples, it integrates the unoccluded hemispherical visibility between the positive and negative horizon angles in closed form, cosine-weighted by the surface normal. Last, a bilateral filter denoises the result, weighting neighbours by depth difference, so sampling noise smooths out while edges whose depth difference exceeds the filter’s range threshold stay unsmoothed.
The scalar it produces multiplies the ambient irradiance term and nothing else. Direct sunlight and local analytical lights are not multiplied by it: their occlusion already comes from Razor and the shadow maps, and applying the scalar again would darken contacts that have a real shadow.
For a script author, GTAO has no property to tune. What you can observe is that the scalar lies
between 0 and 1, so the pass can only reduce ambient light: a surface lit by a lamp keeps its
brightness in a crease, and turning ambient light off with Ambient.Source leaves nothing for the
scalar to multiply, which makes occlusion invisible. The ambient properties live in the guide.
Sky and atmosphere#
With Sky.Mode set to Atmosphere the sky is computed rather than sampled from a texture: Rayleigh
scattering for air molecules, Mie scattering for aerosols, and ozone absorption, over a planet of
radius 6360 km. Evaluating those integrals per pixel every frame would mean a ray march per pixel,
so they are precomputed into transmittance and multiple-scattering lookup tables, and those tables
rebuild whenever the sun moves.
The tables are read in three more places. Sun transmittance through the atmosphere attenuates and
reddens the direct sun term, which is why a low sun goes orange without anyone authoring a colour
ramp. Aerial perspective fogs distant geometry with the same model, in its own pass after shading.
And the sky pass projects the sky radiance into a second-order spherical harmonic every frame, which
is the dynamic half of the ambient term: Sky.Contribution sets how much of the ambient light comes
from that harmonic and how much from the flat Ambient.Tint colour.
When the sun drops below the horizon the planet blocks the sun ray, its transmittance goes to zero, and the direct sun term contributes nothing; the engine then skips sun shadow evaluation, since a term that is already zero cannot be occluded further.
Setting Sky.Mode to Color skips the sky pass and aerial perspective: the background clears to Sky.Color, and no harmonic is produced, so ambient light that follows the sky behaves as a flat
white sky instead.
For a script author this is one of the few places where a mode switch changes which passes run at
all, and the observable differences are the missing distance fog and the flat ambient. The full Lighting property table is in the guide.
Exposure and tone mapping#
Because the shaders work in physical units, the values carried through the pipeline are large: sun
illuminance at the top of the atmosphere is 120,000 lux. The largest finite value a 16-bit float
holds is 65,504, so 120,000 does not fit, and everything is pre-exposed by 1/1024 before it is
written, which puts full daylight at about 117. That factor is a power of two, so the scaling only
decrements the exponent and leaves every mantissa bit alone: ratios between light sources survive
exactly, apart from values small enough to fall into the fp16 subnormals. In these pre-exposed units
1.0 is roughly the luminance of full direct sunlight, which is also where the bloom threshold sits.
Getting from that radiance to a displayable image takes two steps: measuring how bright the scene is, and mapping brightness to pixels.
The first step is auto exposure, modelled on the eye. A compute pass reduces the scene colour to its log-average luminance each frame, converts that to an exposure value with the standard
EV = log2(L * 100 / 12.5)clamps it to [MinEV, MaxEV], and then moves the previous frame’s exposure toward the target rather
than jumping: 3 stops per second when brightening, 1 stop per second when darkening. The rates are
per second, so, assuming the per-frame step is scaled by frame time, four stops take about 1.3
seconds one way and 4 seconds the other at any frame rate. The asymmetry matches
the eye: vision recovers quickly on walking out of a cave, and stays dark for a
moment on walking into one.
The tone mapper then divides scene radiance by 1.2 * 2^EV and runs it through an ACES filmic
curve with a white point of 11.2. The white point is internal and not script-exposed; the two ends
of the adaptation range are. Bloom runs alongside: it extracts fragments whose luminance exceeds
1.0, full direct sunlight in pre-exposed units, at half resolution, blurs them across a multi-pass
Gaussian pyramid, and adds the blurred radiance back, which is how a Neon surface gets its halo.
For a script author, the absolute level washes out and ratios survive: auto exposure absorbs the
overall level, so a light twice as bright as its neighbours looks the same whether both are dim or
both are blinding, as long as the measured EV stays inside [MinEV, MaxEV]. The clamp is where
absolute values start to show again. Exposure.MinEV is the floor that stops a dark interior from
brightening, Exposure.MaxEV the ceiling that stops a bright exterior from dimming; both are in the guide.
Anti-aliasing#
Edge aliasing is reduced at two points in the frame.
Geometry is rendered into a multisampled target, and the MSAA resolve pass reduces those samples to one colour per pixel before the post chain starts. That handles coverage inside the frame, but an N-sample target stores N colour and N depth samples per pixel, so its memory scales linearly with the sample count, which is why multisampling is not the only anti-aliasing step.
The rest is SMAA, and it runs late: on the tone-mapped LDR colour buffer, after the image is already in display range. Values there are bounded, so one contrast threshold behaves the same at every exposure, which it would not on unbounded HDR values, and a post-resolve filter costs one full-resolution pass rather than another set of samples per pixel through the whole pipeline. Its passes chain into each other:
Edge detection finds high-contrast silhouettes from colour and luminance differences between adjacent pixels. Pattern recognition then traces along each detected edge to classify what shape it is part of, such as a diagonal step, a corner, or a T-junction, and samples a precomputed 2D area texture for the subpixel coverage weights that pattern implies. How far it traces is the quality setting: up to 4, 8, 16, or 32 steps for Low, Medium, High, and Ultra. Neighbourhood blending finally mixes adjacent pixel colours by those weights.
Two consequences a script author can see: the quality setting belongs to the player, not to the place, and the step count bounds how long an edge the filter can reconstruct, so long shallow edges come out smoother on some machines than others; and only pixels next to a detected edge are blended at all, so text and 2D interface elements are untouched assuming they are composited after post-processing, outside the buffer SMAA reads.
Transparency#
Sorting transparent surfaces back to front is the traditional answer, and it produces sort popping as the camera moves: two panes swap order, the sort flips, and the blend changes abruptly. Per-triangle sorting cannot resolve intersecting geometry at all, because no single order is correct along the intersection.
So transparency does not sort. Any surface with a Transparency strictly between 0 and 1 is
diverted out of the opaque pass into a weighted-blended order-independent transparency pass, which
accumulates all transparent fragments with weights and resolves them in one step. The accumulation
is a sum, and addition is commutative, so the order the fragments arrived in changes the result only
by floating-point rounding, and there is no sort popping.
That pass resolves after GTAO, which is why the occlusion pass reads opaque depth only, transparent
surfaces never reaching the prepass depth it samples, and before post-processing, so bloom and tone
mapping treat the composed image as one picture. The endpoints of the range are special: Transparency = 0 stays on the opaque path, and Transparency = 1 is culled from rendering
entirely rather than blended as a no-op.
For a script author the cost model is discontinuous at the endpoints: a barely-tinted window at 0.99 is a transparent surface and pays the full accumulation cost per fragment, while 1 is
dropped before draw submission and pays the visibility test alone. Materials can
also choose the path: the built-in Glass material selects the Transparent
pipeline class. Transparency and the material path are covered in the guide.