Praxis · M3 · ticket #201 · PRs #261, #262, #263

How a pixel finds the lights that reach it

Ticket #201 took the lights out of a uniform that held four point lights and put them in a light buffer of 4,096. It also stopped shading every light at every pixel. A compute pass now cuts the view into 18,432 Froxels and lists, for each one, the point lights whose range reaches into it; the lit pass shades each pixel with its Froxel's list. This guide follows a light from the buffer into the lists and on to a pixel, with live demos of the depth slices and of the test that decides which Froxels list a light.

Provenance. #201 merged on 2026-10-23 as three PRs: #261, the light buffer (7ee4ed6); #262, the Froxel pass (2b12b77); and #263, the lit pass's lookup (1d9fde2). Code is quoted and described as it stands on main at 69e02dc, which has moved on since: shadow rays (#258, #265, #277) and a count of each lit pixel's point lights (#272) now sit beside the lists. Chapter 7's measurements are the PRs' own.

Chapter 1 · the problem

Why cull lights at all

Until #201 the lit pass shaded every light it had at every pixel, and it had at most five: one directional light and four point lights, held in a uniform. M3's dense scene, town, will hold 1,024 point lights. Each point light stops at its range, so most of them add nothing to most pixels. Culling finds, for each part of the view, the lights that can reach it, and a pixel shades only those.

On main before #201, the light uniform held one directional light, and prepare left out any others with a warning; it held four point lights, and prepare dropped the rest without a word (src/render/uniforms.rs and src/render/prepare.rs at 064d2ba). Ticket #201 replaced the uniform's slots with a light buffer of fixed capacity, and shading every light everywhere with Froxel lists.

Town (#206) holds the sun and 1,024 point lights. At 2560 × 1440, every point light at every pixel would be 3,686,400 × 1,024, about 3.8 billion light evaluations a frame, by this guide's arithmetic. The M3 spec, #188, asks for Froxels "so that a scene can hold 1,024 point lights instead of four".

Why a skipped light changes nothing

Since #199 a point light falls off with the square of the distance, inside KHR_lights_punctual's range window:

attenuation = max(min(1 − (distance / range)⁴, 1), 0) / distance²

The window falls to 0 at the range and stays 0 past it (point_light_attenuation in shading.wgsl). A light therefore adds exactly zero to any pixel beyond its range, and leaving it out there changes nothing. The spec puts it this way: each light "reaches exactly zero at its range and Froxel culling drops nothing it contributes" (#188, user story 22).

That holds only if culling never leaves out a light whose range does reach a pixel. A test that sometimes keeps a light it could have left out costs time, never the image, unless a Froxel's list or a pixel's shadow mask runs out of room (chapter 4). Such a test is conservative, and chapter 4 shows where Praxis's keeps lights it need not.

  1. 1 · PrepareEvery directional light, then every point light, into Frame data, 4,096 at mostchapter 2
  2. 2 · UploadFrame data's lights into the light buffer, through the frame's stagingchapter 2
  3. 3 · Froxel passRight after the first culling dispatch: 18,432 lists of point lightschapters 3 to 5
  4. 4 · DepthThe depth pre-pass, the depth pyramid and culling's second phase, as before; since #277, the shadow mask passunchanged by #201
  5. 5 · Lit passEvery directional light, then the point lights in the pixel's Froxel listchapter 6
  6. 6 · ReadbackLights the lists had no room for, into Frame stats a few frames laterchapter 5
What #201 built, in the order a frame runs it. The Froxel pass (src/render/froxel_pass.rs, with the shader parts froxel_grid.wgsl and froxel.wgsl) is new, and the light buffer replaces the light uniform's slots. The Froxel pass is recorded after the first culling dispatch so that ADR 0019's gate counts it (renderer.rs). The gate is the opaque main pass's GPU time from that dispatch to the end of the lit pass, with shadow rays off; if its median on a Reference scene at 1440p passes 6 ms, the ADR is reopened.
Chapter 2 · PR #261

The light buffer

Prepare now lists every light in Frame data, directional lights first, and the frame's upload writes them into one storage buffer of 4,096 lights, where the GPU reads them. The buffer is created at that size and never grows. Lights past its capacity are left out, counted and warned about.

Each light takes 48 bytes, laid out as LightData in src/render/uniforms.rs, with a WGSL twin in common.wgsl. A directional light has no position, so its offset carries the direction it shines along. Intensity is in the units ADR 0023 fixes: a point light's colour times its candela, a directional light's colour times its lux.

BytesFieldA point lightA directional light
0–11anchor, 3 × i32its 64 m grid cell0
12–15range, f32in metres0
16–27offset, 3 × f32its position within the cellthe direction it shines along
28–31padding
32–43intensity, 3 × f32colour × candelacolour × lux
44–47padding
One light in the light buffer. The counts travel apart, in LightUniform, 32 bytes: the scene's ambient illuminance, then how many directional lights and how many point lights the buffer holds.

Positions as an Anchor and an offset

ADR 0014 lets no World position reach the GPU in f32. Prepare splits a point light's position into an Anchor and an f32 offset from the cell's corner, as it splits an Instance's, and the shader composes it relative to the camera:

position = (anchor − camera anchor) × 64 m + (offset − camera offset)

The integer difference is exact, so the position composed in f32 is relative to the camera, and rounds in proportion to its distance from the camera rather than from the World origin (camera_relative_position, common.wgsl). Prepare's test places a point light at (100,000.25, 2, −64.5) and expects the Anchor (1562, 0, −2) and the offset (32.25, 2, 63.5). Before #201, prepare subtracted the camera's position from each point light on the CPU instead.

Capacity and overflow

prepare_lights fills Frame data with every directional light, then every point light, each blended between its two newest Ticks, up to LIGHT_CAPACITY, 4,096. Directional lights take their places first, so a full buffer leaves out point lights before the sun. Whatever it leaves out, it counts in Frame stats as lights_overflowed. It warns when lights first overflow, "Lights past the light buffer's capacity are not drawn", and again only after a frame in which all of them fit.

A test spawns one directional light and 4,097 point lights. The buffer holds the directional light and 4,095 point lights, the two left out are counted every frame, and the warning appears once over three frames; once two lights are despawned, all fit and the count returns to 0.

The capacity is provisional until M4's split of the 8 GB Budget sizes it. At 48 bytes a light the buffer is 196,608 bytes. A frame uploads only the lights it holds: when #261 merged, far-coplanar's frames uploaded 368 bytes, the camera's 96, the light uniform's 32 and the sun and four point lights' 240 (#261's counters).

Chapter 3 · PR #262

Froxels

A Froxel is a cell of the view frustum, bounded on screen and in depth, that holds the list of lights reaching it. Praxis cuts the screen into 32 × 18 screen rectangles and each rectangle's view into 32 slices of depth: 18,432 Froxels. The slices grow with distance, three to each doubling, so that a Froxel near the camera and one far away have the same shape.

Screen rectangles

The rectangles, which other engines call tiles, are equal in normalised device coordinates, 32 across and 18 down: 80 pixels square at 2560 × 1440, and 20 at the Goldens' 640 × 360. A rectangle's four sides are planes through the camera, which chapter 4 tests against.

Slices

Depth here is the distance along the view axis, the value a point's clip w holds. Slice 0 runs from the camera to 1 m. Slices 1 to 30 follow at three to each doubling of depth: 1 to 1.26 m, 1.26 to 1.59 m, 1.59 to 2 m, and on to 812.7 to 1,024 m. Slice 31 runs from 1,024 m to infinity.

start(0) = 0 start(s) = 2^((s − 1) / 3) m for s = 1 … 31 slice(d) = floor(3 · log₂ d) + 1, clamped to 0 … 31

Why exponential: a rectangle's height grows in proportion to the distance, and at Praxis's 45° vertical field of view a row of rectangles is about 4.6% of the distance tall. A slice spans the same ratio of depths wherever it is, 21/3 ≈ 1.26, so its depth is about 26% of the distance where it starts. Every Froxel from 1 m to 1,024 m therefore has the same shape, only larger with distance (this guide's arithmetic). Olsson, Billeter and Assarsson give the reasons (2012, pp. 3–4): slices equal in NDC depth come out very thin near the camera and very long far away, slices equal in view depth the reverse, and exponential spacing keeps them self-similar. They choose the ratio that makes their cells, which they call clusters, "as cubical as possible". Praxis's Froxels are deeper than that, about five times as deep as they are wide; cubes would take about 15 slices to each doubling at this rectangle size, five times as many lists (this guide's arithmetic).

The ends are special. Exponential slices shrink without end towards the camera, so slice 0 takes everything nearer than 1 m. Avalanche's default slicing put 16 slices between 0.1 m and 50,000 m and used them poorly; it moved the far end to 500 m, with a separate system for distant lights, and made the first slice 0.1 to 5.0 m (Persson 2013, pp. 12–13). At the far end Praxis has no far plane to stop at: depth is reversed with an infinite far plane (ADR 0015), so the last slice runs to infinity and every visible point has a Froxel.

Show depth to:
lists the lightlists it, though its range never entersslice boundaries and rows of rectanglesthe light's range
The 32 slices, one width each, as slices 1 to 30 are on a doubling scale
Depth the range covers
Slices it reaches
Froxels of this column that list it

Live demo. The view from the side, true to scale: the camera at the left, the 45° field of view opening to the right, the 18 rows of rectangles as lines through the camera, and the slice boundaries upright. Drag the light, or use the sliders. Shaded cells are the Froxels of one column of rectangles whose lists hold the light, by the Froxel pass's own test ported to this page; hatched ones hold it though its range never enters them (chapter 4). Below, the 32 slices are drawn at one width each, with those the light's depth range reaches shaded: slices 1 to 30 are equal on a doubling scale, while slice 0 reaches down to the camera and slice 31 out to infinity. Simplified: the light sits in the upright plane through the view axis, where the column's own sides do not matter, and the page computes in f64.

Why Froxels, and not tiles or bins

ADR 0019 weighed two other ways to cut light lists, both in the research note on the main pass (docs/research/visibility-and-gpu-driven-main-pass.md, rows 2 and 3):

  • Tiled culling, as in Forward+: after the depth pre-pass, a compute pass lists each tile's lights between the nearest and farthest opaque depth in the tile (Harada, McKee and Yang 2012, p. 2). A tile across a depth discontinuity takes in the gap, and its list grows (Persson 2013, p. 28); transparent surfaces in front of the opaque depth need lists of their own (ADR 0019; Persson, p. 5).
  • Z-binning, as at Activision: a bit mask of lights per tile, and a table of the lowest and highest light index in each depth bin, intersected per pixel when it shades (Drobot 2017, p. 30).
  • Froxels: each with its own list, built once against bounds fixed in advance, which do not depend on the depth buffer. One set of lists serves the opaque pass now and the transparent pass in M6 (ADR 0019). id Tech 6 gives the same reasons: its structure is "independent from depth buffer", and for transparent surfaces there is "no need for extra passes or work" (Sousa and Geffroy 2016, p. 4).
Engine or paperOn screenIn depthBuilt
Olsson, Billeter and Assarsson, 2012tiles, for example 32 × 32 pixelsexponential, the cells as cubical as possibleon the GPU, in OpenGL and CUDA
Avalanche Studios (Persson, 2013)64 × 64-pixel tiles16 exponential slices; 0.1 to 5.0 m first, 500 m laston the CPU
Doom (Sousa and Geffroy, 2016)a 16 × 8 grid24 slices, exponential (the slides call it logarithmic)on the CPU, one job per slice
Praxis, #20132 × 18 screen rectangles, 80 pixels at 1440p32 slices: under 1 m, three per doubling, 1,024 m to infinityon the GPU, in the Froxel pass
Same idea, other numbers. Olsson et al. pp. 4 and 6; Persson pp. 12–13; Sousa and Geffroy pp. 5 and 7; Praxis's froxel_pass.rs.
Chapter 4 · PR #262

The sphere test

For each Froxel and each point light, the pass asks whether the light's range, a sphere around it, reaches into the Froxel. Six planes bound a Froxel: the four sides of its rectangle, which pass through the camera, and the near and far depths of its slice. The test drops a light only when its sphere lies wholly outside one of the six. That never drops a light that reaches the Froxel, and it sometimes keeps one that does not.

Sides in clip space

The camera sits at the origin of every position the shaders see, and view_proj holds no translation (ADR 0014). A rectangle side at normalised x = a is where clip x / clip w = a, which is clip x − a · clip w = 0: a plane through the camera. With row₀ to row₃ the rows of view_proj, clip x = row₀ · p and clip w = row₃ · p, so the plane's normal is row₀ − a · row₃, and clip x − a · clip w divided by the normal's length is a point's signed distance from the plane. A sphere lies wholly outside a side only when its centre is farther than its range on the outside, so the pass keeps a light when all of these hold:

lowest x side: clip x − lowest.x · w ≥ −range · |row₀ − lowest.x · row₃| highest x side: highest.x · w − clip x ≥ −range · |highest.x · row₃ − row₀| lowest y side: clip y − lowest.y · w ≥ −range · |row₁ − lowest.y · row₃| highest y side: highest.y · w − clip y ≥ −range · |highest.y · row₃ − row₁| the slice: w + range ≥ slice start, and w − range ≤ slice end (but in the last slice)

Here clip x, clip y and w are the light's centre transformed by view_proj, and w is its depth along the view axis. The four lengths differ from rectangle to rectangle, so each invocation computes its own once. A slice's planes stand square to the view axis, so for them w is already the distance. build_froxel_lists writes it this way (froxel.wgsl):

let clip_x = dot(rows[0].xyz, centre);
let clip_y = dot(rows[1].xyz, centre);
let depth = dot(rows[3].xyz, centre);
let inside_sides = vec4<f32>(
    clip_x - lowest.x * depth,
    highest.x * depth - clip_x,
    clip_y - lowest.y * depth,
    highest.y * depth - clip_y
);
// Its range reaches into the Froxel unless it lies wholly outside a
// side or beyond the slice: conservative, as a sphere may miss a
// corner that it reaches past both sides of.
if all(inside_sides >= -point.range * normal_lengths)
    && depth + point.range >= nearest
    && (last_slice || depth - point.range <= farthest) {
The test as the shader writes it, lines 39 to 53 of froxel.wgsl on main at 69e02dc. centre is the light composed relative to the camera from its Anchor and offset; rows is view_proj transposed; normal_lengths holds the four sides' normal lengths.

Conservative, and what a corner costs

Each plane test is exact on its own; together they are not. A sphere near a Froxel's corner can reach past two sides' planes and still miss the edge where they meet, and the pass then lists a light whose range never enters the Froxel. That is a false positive. It costs the lit pass one light evaluated to zero at every pixel of that Froxel, and the image cannot change unless something runs out of room. A false positive takes one of the 127 places in the Froxel's list, so where more than 127 lights pass the Froxel's test, it can push a later light that does reach the Froxel off the end of the list, and that light then lights nothing there (chapter 5). Since #277, with shadow rays on, it also takes a bit: the shadow mask gives a pixel 59 bits, the directional lights' first, then the entries of its Froxel's list in order, whether or not each entry's light reaches the pixel; so in a list of more than 58 point lights beside one directional light, a false positive can push a later light that does reach the pixel past the bits, and that light then casts no shadow there. Frame stats count such lights (Shadow Rays Explained, chapter 10). The opposite error, a light whose range reaches a Froxel but is missing from its list, would leave pixels without its light; chapter 6's tests guard against it.

A plane through the camera shows on screen as a rectangle edge, and a sphere in front of the camera is wholly on one side of the plane exactly when its outline on screen is wholly on one side of the edge. So the rectangles that list a light are those under its outline's bounding box, and the false positives are the box's corners that the outline misses (this guide's reading of the test). The outline is not a circle (Persson 2013, p. 33): a sphere wholly in front of the camera projects to an ellipse, stretched towards the screen's edges. The demo draws it.

Place the light:
lists the light, and its range reaches inlists it, though its range never reaches inthe range's outline on screen
Rectangles that list it
Of those, rectangles its range reaches
False positives
Slices, in each of those rectangles

Live demo. The screen at 16:9, cut into the pass's 32 × 18 screen rectangles, and a point light in front of the camera. Drag the light across the screen, set its depth and range with the sliders, or pick a placement. Shaded rectangles list it by the pass's four side tests, ported to this page; hatched ones list it though its range reaches no pixel in them, at any depth. The outline is the range's exact edge on screen, and the dot is where the light's centre projects, which is not the outline's centre. Simplified: the camera looks straight ahead; the range is held below the depth, so the sphere stays in front of the camera; and the page counts rectangles over every slice at once, though the pass lists per Froxel.

Tighter tests exist. Persson calls sphere against six planes too loose for tight lists, "sphere vs. six planes won't do", and Avalanche shrinks the sphere slice by slice and row by row to each plane it crosses (Persson 2013, pp. 31 and 34–35). id Tech 6 refines in clip space, where a cell is an axis-aligned box (Sousa and Geffroy 2016, p. 6). Praxis's test is the simple one.

Chapter 5 · PR #262

Building the lists

The Froxel pass is recorded right after the frame's first culling dispatch, though on Metal, since it reads nothing culling writes, the two can run side by side, and under load the Froxel pass now and then starts first (#291). It runs one workgroup per rectangle and, in each, one invocation per slice: one invocation per Froxel. Each walks the point lights in buffer order and writes the ones that pass into its own list. No two invocations share a list, so the lists need no atomics, and each comes out in light order.

The dispatch and the list

dispatch_workgroups(32, 18, 1) launches 576 workgroups of 32 invocations, 18,432 in all. An invocation's slice is its index within its workgroup, and its rectangle is the workgroup's position. Its list sits at a fixed place in one buffer, an array of FroxelList structs, a count and 127 light indices each (froxel_index, froxel_grid.wgsl; FroxelList, common.wgsl):

list number = (row × 32 + column) × 32 + slice the list = froxel_lists[list number], 128 u32s
One Froxel's list: 128 u32s, 512 bytes 3 1 3 4 … 0 1 2 3 127 count indices into the light buffer, in light order left as they are: the count says how many to read light 0 is the sun, a directional light; point lights start at index 1
One Froxel's list, as the pass writes it and the lit pass reads it: a count, then up to 127 indices into the light buffer, in light order. In this example the buffer holds the sun first, and the Froxel lists point lights 1, 3 and 4. The 18,432 lists take 9,437,184 bytes, 9 MiB, created at that size and never grown; Frame stats count them in GPU memory.

Light order

The lit pass adds up the lights' luminance in f32, and f32 addition rounds after every step, so the same terms added in another order can differ in the last bit:

(1 + 3×10⁻⁸) + 3×10⁻⁸ = 1 in f32 1 + (3×10⁻⁸ + 3×10⁻⁸) = 1.00000012 in f32

A last-bit difference can tip a pixel to the next 8-bit code. Forward+ gathers each tile's lights with local atomic operations (Harada et al. 2012, p. 2), so a list's order there depends on which thread gets in first. Each of Praxis's invocations walks the lights in buffer order, which is also brute force's order. With or without lists, the lit pass adds the same nonzero terms in the same order, and a light a list leaves out would have added exactly zero (chapter 1).

Capacity and overflow

A list holds 127 lights, and with its count it is 512 bytes; the capacity is provisional until M4's split of the 8 GB Budget. An invocation keeps counting past 127, stores the smaller of its count and 127, and adds the rest to a single counter, the first field of the frame's LightCounts, with one atomic add, only if its list overflowed. Integer addition gives the same total in any order, so the count is exact. A full list keeps the first 127 lights in light order; the others light nothing in that Froxel.

The counter is cleared before the pass and copied into the frame's slot of the GPU stats readback after it. A few frames later the CPU reads it back with the frame's draw counts and pass times: it becomes Frame stats' froxel_lights_overflowed, and the first frame read back with an overflow logs "The Froxel lists are full: lights past a list's capacity do not light its Froxel", once, until a frame has room for all (readback.rs).

The work it does

Every invocation tests every point light, so the pass makes 18,432 sphere tests for each point light: 73,728 for the four in most Reference scenes, and 18,874,368 for town's 1,024 (this guide's arithmetic). It is timed as froxel_pass, right after culling's first phase; chapter 7 has its measurements.

Chapter 6 · PR #263

The lookup, the switch and the proof

The lit pass finds a pixel's Froxel from the pixel itself: its place on the target gives the screen rectangle, and its depth along the view axis the slice. It shades every directional light, which are never culled, then the point lights in that one list. A Render switch replaces the list with every point light, brute force, which stays as the reference the lists must match.

The lookup

// The point lights the pixel's Froxel lists, in light order, or, with
// FROXEL_LIGHTS off, every point light: brute force. [...]
var froxel = 0u;
// [...]
var point_light_count = light.point_light_count;
if FROXEL_LIGHTS {
    let view_depth = (camera.view_proj * vec4<f32>(in.camera_relative_position, 1.0)).w;
    let size = vec2<f32>(textureDimensions(shadow_mask));
    froxel = froxel_at(in.clip_position.xy, size, view_depth);
    // [...]
    point_light_count = froxel_lists[froxel].light_count;
}
for (var entry = 0u; entry < point_light_count; entry++) {
    var index = light.directional_light_count + entry;
    if FROXEL_LIGHTS {
        index = froxel_lists[froxel].lights[entry];
    }
    let point = lights[index];
    let position = point_light_position(point, camera.anchor, camera.offset);
    // [...] the shadow mask's bit for this light, then point_light_luminance
}
The lit pass's point-light loop in fs_main, lines 162 to 198 of main.wgsl on main at 69e02dc, after the directional lights; the lines marked [...] read the shadow mask, which shadow rays added after #201. Off, the switch makes the loop run over every point light, in buffer order.

froxel_at takes the pixel's position in pixels from the target's top left corner, the target's size, which the lit pass reads from the shadow mask, a target of the same size, and the pixel's view depth, clip w of its camera-relative position. Its rectangle is floor((x / width, 1 − y / height) × (32, 18)), clamped to the grid and counted from the bottom, as normalised device coordinates count; its slice comes from the view depth (froxel_grid.wgsl). At #263 the lookup started from the pixel's clip-space position instead; the shadow mask pass (#277) finds its Froxels with froxel_at too.

The switch

FROXEL_LIGHTS is a WGSL override, set when the pipeline is created from the froxel_lights field of the Render switches. The default, on, is what ships; the tests take it off to compare the lists with brute force. Off, the lit pass shades every point light at every pixel, as before #201, and reads none of the Froxel pass's lists. That is brute force.

How it is proven

  • The pass against a CPU oracle, each_froxel_lists_the_point_lights_whose_range_reaches_into_its_bounds in renderer.rs. A camera about 122 km from the origin, turned, sees the sun, 300 point lights scattered from 5 m behind it to 80 m ahead, one in ten of them 1 to 5 km ahead and reaching five times as far, and a group of 132 at one spot 7.3 m ahead, five more than a list holds. The CPU assigns every light to every Froxel in f64, from side planes it unprojects itself rather than from view_proj's rows, and leaves out any light within 1 mm of a boundary, plus 1 cm for each kilometre of distance, so that no verdict is a tie. Every one of the 18,432 lists must hold exactly its lights, in light order, up to 127; Frame stats must count every light past a list's capacity; the warning must appear; and no buffer may grow.
  • Nine lights, nine_point_lights_each_light_the_surface_under_them. A white matte quad fills the view, and nine point lights hang 0.1 m in front of it in a 3 × 3 grid 0.5 m apart, each reaching 0.2 m. Each lights the quad under it, and the quad stays dark between them: five more point lights than the old uniform held. Lit through Froxel lists, the image must equal brute force's, byte for byte.
  • Captures of every Reference scene, in tests/goldens.rs: on main, nine scenes and the helmet, all but crowd. Each is captured at 640 × 360 with Froxel lists and with brute force, both with shadow rays off, and the two must match within the Goldens' tolerance: no channel more than 1 step apart. Since #298, town is compared at eight frames along its camera path, from the street at frame 59 to the circle at frame 1,800, and every other scene at the Golden's frame, 59. The helmet joins where its asset is fetched. #201 changed none of the Goldens.

Red-capable

A test that cannot fail proves nothing, so the work on #201 put an error into the sphere test on purpose. On a slice's near side the test ignored the light's range, so a light dropped out of the slices beyond its centre that its range still reached, and surfaces just behind a light, as the camera sees them, lost its light. In PR #263's run, seven of the nine Reference scenes of the time failed the comparison with brute force:

ScenePixels more than 1 step offLargest difference, steps
shadows193,710168
helmet45,767217
normal-tangent37,633240
uv-grid34,52793
pbr-sweep30,720241
orrery8,235101
minification2,701118
far-coplanarpassed
texture-wallpassed
The comparison with the deliberate error, on the M4 Pro at 640 × 360, 230,400 pixels a capture. PR #263 gives every count, but the largest difference only for shadows and pbr-sweep; the other five come from the same run's log, which is not published. texture-wall has no point lights, and far-coplanar's four light no pixel it shows (chapter 7), so the error could not show in either.
Chapter 7 · PRs #261 to #263

What it costs, and what comes next

The PRs measured #201 on the M4 Pro at 2560 × 1440: the median of three interleaved rounds' medians. The Froxel pass takes 5.7 to 7.0 µs a frame with one to four point lights, and 26.3 µs on texture-wall, which has none. Where the point lights reach none of the view, the lists save real time: on far-coplanar the lit pass fell from 511.1 to 314.5 µs. Everywhere else the lit pass grew, by about 9 to 73 µs, and the project's owner accepted that cost.

SceneFroxel pass, µsLit pass, before → after #201, µsGPU frame, µs
far-coplanar6.6511.1 → 314.5−193.8
helmet7.0641.5 → 714.6+78.0
minification5.7431.7 → 460.6+36.0
normal-tangent7.0400.9 → 440.5+47.1
orrery6.6277.6 → 286.8+21.2
pbr-sweep6.6375.3 → 404.8+32.5
shadows5.7377.0 → 440.8+78.4
texture-wall26.3926.8 → 965.6±300 between rounds
uv-grid6.7374.2 → 406.6+37.8
GPU times on the M4 Pro at 2560 × 1440, each the median of three interleaved rounds' medians: the Froxel pass from #262; the lit pass, from main before #201 to #263, and the GPU frame's change over the same span, from #263. texture-wall, which has no point lights, has the pass's slowest time; no PR explains it.

far-coplanar shows the gain because its point lights reach nothing on screen. Its camera hangs 300 m above the origin and looks down about 8.5°, so the nearest floor it sees is about 500 m from the origin, while its four point lights stand 2 m up beside the origin and reach 20 m (this guide's arithmetic, from src/scenes/mod.rs). No Froxel lists them, so the lit pass shades the sun alone, where brute force evaluated four lights at every pixel, each to zero.

In the other scenes measured, each point light reaches all the scene shows (four in helmet, normal-tangent, orrery, pbr-sweep and uv-grid, one in minification and shadows, none in texture-wall), so each lit pixel's list holds every point light and saves nothing, and the lookup is pure cost. #263 splits the lit pass's growth: reading lights from a storage buffer and composing each point light from its Anchor per pixel (#261, +5 to +39 µs), and looking up the Froxel (#263, +7 to +38 µs). Town's 1,024 point lights are what the lists are for.

What came next

  • town (#206) merged in three parts, #272, #273 and #275 (69e02dc): a procedural town of 244,256 Instances and 1,024 lamps beside the sun, then 10,000 pigeons circling over it, 254,256 Instances in all (Town Explained). Its Froxel pass makes 18,874,368 sphere tests a frame (this guide's arithmetic) and took 0.52 ms at 2560 × 1440 in #275's M4 Pro Baseline, where no list overflowed. #272 added a light count pass, behind the count_point_lights Render switch, that counts each lit pixel's listed point lights and those whose range reaches it.
  • Shadow rays: the sun's (#258) merged after #201, and the point lights' (#265) trace one ray to each light in a pixel's Froxel list. Since #277 a compute pass traces them into a shadow mask, which the lit pass reads; it finds each pixel's Froxel with froxel_at too.
  • Transparency (M6): a sorted forward pass after the opaque one, sharing the Froxel lists, MSAA and the opaque depth (ADR 0019).
  • Capacities: 4,096 lights and 127 a list are provisional until M4's split of the 8 GB Budget.
Reference

Reading list

Grouped by source, each with what it is good for here. Repo links are pinned to main at 064d2ba, the commit #201 branches from; the papers and talks are linked at the pages cited.

This ticket, in the repo

  • Ticket #201 the ask, its acceptance criteria and the merge order with #203
  • #261, #262 and #263 the three PRs: what each part did, its tests, its measurements and the accepted cost
  • #188, the M3 spec user stories 19, 20 and 22: 1,024 point lights, brute force as the reference, the range window's exact zero
  • ADR 0019 clustered forward; Froxels over tiled 2.5D culling and Z-binning; transparency shares the lists
  • ADR 0014 Anchor and offset, composed relative to the camera
  • ADR 0015 reversed depth with no far plane, hence the last slice to infinity
  • ADR 0023 candela and lux in the light buffer
  • The research note on the main pass rows 2 and 3, tiled and clustered shading, with their sources and costs
  • #206 and #205 town's 1,024 lights, and shadow rays for every light in a list

Papers and talks

Books

  • Akenine-Möller, Haines, Hoffman, Pesce, Iwanicki and Hillaire, Real-Time Rendering, 4th ed. ch. 20, "Efficient Shading": §20.3 "Tiled Shading", p. 892, and §20.4 "Clustered Shading", p. 898 (table of contents)
  • Lengyel, Foundations of Game Engine Development, Vol. 2: Rendering §6.3.5 "Frustum Plane Extraction", §9.4.2 "Sphere Visibility", §9.5 "Light Culling", and §8.2 "Extent Optimization" (table of contents)
Reference

Glossary

Froxel
A cell of the view frustum, bounded in screen space and depth, that holds the list of lights reaching it.
Screen rectangle
One of the 32 × 18 equal parts of the screen; its sides are planes through the camera.
Slice
One of a screen rectangle's 32 depth ranges: under 1 m, then three per doubling, the last to infinity.
Light buffer
One storage buffer of every light, directional first, 4,096 at most, 48 bytes each.
Froxel list
A count and up to 127 light indices, in light order: 512 bytes per Froxel.
Froxel pass
The compute pass that builds every list: a workgroup per screen rectangle, an invocation per slice.
Range window
KHR_lights_punctual's factor that takes a point light to exactly zero at its range.
Conservative
A test that may keep a light needlessly but never drops one that counts.
False positive
A light listed in a Froxel its range never enters; it costs time, and can change the image only by pushing a light past the list's 127 places or the shadow mask's 59 bits.
Brute force
Every point light at every pixel: the froxel_lights Render switch off.
Render switches
The variants a Renderer is created with and keeps; their defaults are what ships.
Normalised device coordinates
Clip x and y over clip w: −1 to 1 across and up the screen.
Clip w
With Praxis's projection, a point's distance along the view axis.
Anchor
The integer coordinates of a World position's 64 m grid cell; the GPU adds an f32 offset.
Instance
One Renderable's data on the GPU, positioned as an Anchor plus an f32 offset.
Frame data
The per-frame Instances, lights and camera the renderer consumes in place of the ECS World.
Frame stats
What one frame cost, measured by the engine, overflows of the light buffer and lists included.
Tick
One fixed step of the simulation, 1/64 s; frames draw a blend of the two newest.
Budget
A numeric limit on one metric of a Reference scene that a change must not regress.
Workgroup, invocation
A group of compute-shader runs that execute together, and one run of the shader.
Atomic add
An addition no other invocation can interleave with; integer totals come out the same in any order.
override
A WGSL constant fixed when a pipeline is created, by the pipeline or by the shader's default.
Oracle
An independent computation of the right answer that a test compares with.
Forward+
Forward shading with per-tile light lists, bounded by each tile's opaque depth.
Z-binning
Per-tile light masks and per-depth-bin light index ranges, intersected per pixel.
Golden
A committed image that a Reference scene must reproduce within tolerance.
Reference scene
A Scene with a fixed camera path and injected time, the same frames every run.
Built from ticket #201, its branch feat/froxel-light-lists at 39cd376, and the PRs it merged as, #261, #262 and #263, with code quoted and checked on main at 69e02dc; with ADRs 0014, 0015, 0019 and 0023, the M3 spec (#188) and the research note on the main pass. The timings and the deliberate-error run are the PRs' own, on the M4 Pro. Where the guide does its own arithmetic, it says so. The demos port the Froxel pass's slice and side tests to this page in f64, beside exact answers they compute themselves, and say where they simplify.