Praxis · M3 · tickets #194, #196, #198

Letting the GPU choose what to draw

A field guide to Praxis's culling pass, the compute pass that decides each frame which Instances to draw and writes the one indirect draw that holds them. It covers the frame's draw from #194, the frustum test and Render switches from #196, and the two-phase occlusion culling from #198, with what each costs on the M4 Pro.

Provenance. All seven PRs are merged: #229, #228 and #230 for #194; #232 and #239 for #196; #241 and #242 for #198, after #240 built the depth pyramid. The guide describes main at fe08baa, where no Reference scene hid an Instance, so occlusion culling culled nothing yet; town (#206), built since in PRs #272, #273 and #275 and covered in Town Explained, is the first scene that does. Later work has also added passes around the five in chapter 1, four more Render switches and room for twice as many packed indices, and, with shadow rays on, moved phase two's depth into a pass of its own; notes in the text say where, as of main at fd2ee9b. The depth pyramid has a guide of its own, Depth Pyramid Explained, which comes before this one.

Chapter 1

Why the GPU builds the draw

Until #194, the Renderer drew each Primitive of each Instance with a draw_indexed of its own. The helmet scene made 11 draw calls a frame, pbr-sweep 10 and texture-wall 100. Every one of those calls was recorded on the CPU, so the CPU's work grew with the number of Instances. M3's dense Reference scene, town (#206), planned about 250k Instances, and its median frame must draw at least 100,000 Instances and 5,000,000 triangles after culling.

ADR 0019 moves the choice to the GPU. A compute pass, the culling pass, reads every Instance slot in use, decides which Instances to draw, and writes their triangles into one index buffer, with the arguments of an indirect draw beside them. The render passes draw that buffer with draw_indexed_indirect, which reads its arguments from GPU memory when it runs. Frame data, the renderer's input each frame, therefore "lists no draws: the GPU culls Instances and builds its own", in the glossary's words. It carries the count of Instance slots in use, which the Renderer uploads only when it changes.

Three tickets built the choice:

  • #194 (PRs #229, #228, #230): the culling pass and the frame's draw. Every Instance in use is drawn.
  • #196 (PRs #232, #239): the frustum test, which skips Instances that lie wholly outside the view, and the Render switch that turns culling off.
  • #198 (PRs #240, #241, #242): two-phase occlusion culling against the depth pyramid, which skips Instances that lie wholly behind what is already drawn.

At fe08baa every frame ran five passes, in this order, and Frame stats timed each one separately, under its own name:

  1. computeculling_phase_oneDraws the Instances in view that were visible the frame before.
  2. renderdepth_prepassClears the 4x depth to 0 and draws phase one's Instances into it, depth only.
  3. computedepth_pyramid_passReduces that depth into the depth pyramid.
  4. computeculling_phase_twoTests every Instance in view against the pyramid, draws what phase one did not, and records what is visible.
  5. renderlit_passDraws phase two's Instances into depth, then shades both phases at depth equal.
The frame's passes, as Renderer::render records them and TIMED_PASSES names them. Chapters 2 to 5 build the culling pass with one phase; chapter 6 splits it in two. Later work has added passes around these five, among them the Froxel pass after phase one (#262), phase two's depth pass and the shadow mask pass between phase two and the lit pass when shadow rays are on (#277), and the tonemapping pass after the lit pass (#251); Froxel Lights Explained, Shadow Rays Explained and Tonemapping Pass Explained cover them.

Culling must never change the image. With culling on, a Reference scene must render what it renders with culling off, and chapter 5 shows the tests that check it. A test that removes an Instance only when the Instance cannot show is conservative, and both of Praxis's tests are.

Chapter 2 · PRs #229, #228, #230

One draw for every Instance

One draw can span every Instance because of how Meshes are cooked. Since #192 the cooker cuts each Primitive into Meshlets of at most 64 vertices and 124 triangles. On the GPU a Meshlet lists its vertices as indices into the vertex Arena, and its triangles as three one-byte corners into that list, in cooked order. #194 then came in three PRs, along the path the data takes:

  1. Tables at load (#229). When a Mesh loads, the Mesh pool writes an entry of the Mesh table at its Handle's index, with its Meshlets, its first triangle, its triangle count and its bounds. It fills three new Arenas: the Meshlets, each with its first vertex, first triangle, triangle count and its Primitive's Material ID; their vertices; and their triangles. The table holds 65,536 Meshes, and each new Arena takes the same provisional 16 MiB as the vertex and index Arenas.
  2. An Instance names its Mesh (#228). The Instance stays 128 bytes: four bytes that were padding now hold its Mesh, so the culling pass finds an Instance's Meshlets from its slot. A freed slot gets an Instance with no Mesh, NO_MESH (u32::MAX), written once at the end of the frame that frees it, and is taken again from the next frame.
  3. One indirect draw (#230). For each Instance it draws, the culling pass writes a Meshlet record per Meshlet and a packed index per triangle corner. The main pass then drew them all with one draw_indexed_indirect; chapter 6 counts the three a frame made at fe08baa.

A packed index holds a Meshlet record's number above six bits of vertex. Six bits count 64 vertices, a Meshlet's most, which leaves 26 bits for the record. The record names the Instance's slot, the Meshlet, and the Material ID to draw it with: the Instance's own where its Renderable overrides its Mesh's Materials, else the Meshlet's Primitive's. So each Primitive draws with its own Material unless its Instance overrides it, and the loader's warning about Meshes of mixed Materials is gone.

// The record in the bits above MESHLET_VERTEX_BITS, which is declared from
// Rust's Meshlet vertex limit (src/render/shaders/mod.rs), the vertex below.
fn pack_index(record: u32, vertex: u32) -> u32 {
    return (record << MESHLET_VERTEX_BITS) | vertex;
}

fn unpack_index(index: u32) -> PackedIndex {
    return PackedIndex(index >> MESHLET_VERTEX_BITS, index & ((1u << MESHLET_VERTEX_BITS) - 1u));
}
A packed index, and src/render/shaders/common.wgsl, lines 104 to 112 at fe08baa. MESHLET_VERTEX_BITS is the base-2 logarithm of MESHLET_MAX_VERTICES, 64, so 6. CullingPass::new asserts the record capacity stays below 226.

The draw has no base vertex, so a vertex's index is its packed index. The vertex shader unpacks it, reads the record, reads the Instance from the record's slot, finds the vertex through the Meshlet's vertex list, pulls it from the vertex Arena, and composes its position relative to the camera from the Anchors (ADR 0014).

The frame's draw has fixed capacities, provisional until milestone M4 splits the 8 GB Budget: at fe08baa, 224 packed indices, 64 MiB, enough for 5.6 million triangles against town's 5 million, and 220 Meshlet records, 12 MiB. (Since #272, merged as 2059535, it holds 225 packed indices, 128 MiB, the most one storage binding holds at the Capability floor, so that all of town, about 10 million triangles, fits when the Goldens test draws it with culling off.) A frame past either draws the Instances that fit, in slot order, and counts the rest as overflowed.

The draw's arguments and counts live in one small buffer, FrameDraw. Beside the indirect arguments it counts the Instances the draw holds and the Instances it had no room for. Each frame's copy reaches the CPU a few frames late, through a ring of buffers the CPU maps for reading, which #195 added; the main thread never waits on one while it renders (ADR 0016). Frame stats show instances_drawn, triangles_drawn (the draw's index count over 3) and instances_overflowed.

The Capability floor moved with it (ADR 0021). COMPUTE_SHADERS and INDIRECT_EXECUTION joined it, since wgpu-core refuses compute pipelines and indirect draws without them. INDIRECT_FIRST_INSTANCE left it: the one draw spans every Instance through packed indices and draws from first instance 0.

Chapter 3 · PR #230

Slot order

Each drawn Instance needs to know where its records and indices start: after those of every Instance drawn before it. The culling pass works that out with an exclusive scan, a running sum, in three dispatches:

  1. offset_slots runs a workgroup per block of 256 slots. Each invocation takes one slot's counts of Meshlet records and packed indices, zero where the slot is not drawn. A Hillis and Steele scan across the workgroup gives each slot the sum of the counts before it in its block, and the block's total goes after the slots' entries.
  2. offset_blocks runs one workgroup, which turns the blocks' totals into each block's start. At the Instance buffer's 219 slots there are 2,048 blocks, 8 to an invocation.
  3. append_meshlets runs a workgroup per slot, with an invocation per triangle of a Meshlet, 124 at most. Each drawn Instance writes its records and packed indices from its block's start plus its own offset.

Every position in the draw then follows from the slots' order and counts alone. PR #230's first version appended with atomics instead, so an Instance's place depended on when its workgroup reached the shared count. Metal captures of orrery then moved 64 to 97 pixels from run to run, and equal arguments no longer gave equal images.

The geometry was the same in every run: each Meshlet triangle mapped back to the cooked index stream, 2,208 of 2,208, corners in order. With textureSampleLevel(.., 0) forced, which names the mip level explicitly, orrery rendered bit-identical three runs out of three. What moved was implicit derivatives at triangle edges. WGSL computes these from neighbouring invocations in a 2×2 quad, and on the M4 Pro they depend on how the GPU batches a draw's triangles. Appending in slot order makes each frame's index stream the same on every run, and the image with it.

Append in:
SlotInstanceMeshletsOffset in its blockBlock's startFirst record

Live demo. Eight slots in two blocks of four, where Praxis's blocks hold 256, and only the Meshlet records counted; the pass scans packed indices alongside them. Slot order runs the scan: each slot's offset in its block, plus its block's start, gives its first record. Atomic order models appending through a shared atomic count, so each Instance's place depends on which workgroup gets there first, which differs from run to run. Slot 2 is freed (NO_MESH) and slot 5 culled, so both take NOT_DRAWN and add nothing. Each square is one record, labelled with its Instance's slot.

Slot order still moved one Golden. With all five of orrery's spheres in one draw, the M4 Pro batches their triangles differently than before, and 80 of the capture's 230,400 pixels moved by more than one step, at most 24, all on the grid texture's lines at triangle edges. Software Vulkan's Goldens did not move. The project's owner accepted the rewritten Metal Golden and accepted slot order as a rule: the project's review checklist, in CLAUDE.md, now says the GPU builds the frame's draw "in slot order within each culling phase, never in the order atomics land".

Atomics remain where order cannot matter. The draw's index count is an atomicMax, its Instance counts are atomicAdds, and phase two's visibility words, in chapter 6, are atomicOrs. Each ends at the same value whatever order the invocations run in.

Chapter 4 · PR #232

The frustum test

The view frustum is the region the camera sees: inside four side planes and beyond the near plane, 0.1 m ahead. It has no far plane. ADR 0015's reverse-Z projection puts a point d metres ahead at depth 0.1/d, 1 at the near plane and falling towards 0 at infinity, so nothing is too far to draw.

The test runs in clip space, where the vertex shader's outputs live before the GPU divides them by w. There the side planes are −w ≤ x ≤ w and −w ≤ y ≤ w, and the near plane is z ≤ w. Every point past the near plane lands at z ≥ 0, so there is no sixth plane to test.

Each Mesh carries cooked bounds, a box in its own space. clip_space_bounds composes the box's centre relative to the camera from the Anchors, as the vertex shader composes vertices (ADR 0014). It takes the box's three half axes, the Instance's rotation-scale columns times the half extents, into clip space as directions. A plane's distance is linear in clip space, so the farthest the box reaches inside a plane is its centre's distance plus each half axis's, taken whole. Where that is negative for some plane, the box lies wholly beyond it and is culled.

fn in_view_frustum(bounds: ClipBox) -> bool {
    let side_reach = side_plane_distances(bounds.centre)
        + abs(side_plane_distances(bounds.half_axes[0]))
        + abs(side_plane_distances(bounds.half_axes[1]))
        + abs(side_plane_distances(bounds.half_axes[2]));
    let near_reach = near_plane_distance(bounds.centre)
        + abs(near_plane_distance(bounds.half_axes[0]))
        + abs(near_plane_distance(bounds.half_axes[1]))
        + abs(near_plane_distance(bounds.half_axes[2]));
    return all(side_reach >= vec4<f32>(0.0)) && near_reach >= 0.0;
}
src/render/shaders/culling.wgsl, lines 131 to 141 at fe08baa. side_plane_distances returns w + x, w − x, w + y and w − y at once, and near_plane_distance returns w − z.

The test is exact for each plane alone. A box past the corner where two planes meet can be outside both without lying wholly beyond either, and the test keeps it. That costs a little drawing and never an Instance that shows, so the test is conservative.

A culled slot adds nothing to the scan. It takes the offsets a freed slot takes, NOT_DRAWN, all ones, past every capacity, and append_meshlets skips it before its capacity check. The Instances left draw in slot order, as before. The five planes are tested with neither a branch nor a loop; against a loop over an array of planes, that saved 0.5 to 0.7 µs on pbr-sweep and uv-grid.

drawndrawn, though outside the frustumculled
Render switch culling:

    Live demo. A top-down view, so two of the four side planes show, with the near plane; the other two work the same way in y. Drag the camera, or press elsewhere to turn it towards the pointer; with the picture focused, the arrow keys turn and move it. Tap a box to inspect it. Each box is an Instance's bounds, turned and scaled, and the test is in_view_frustum's, run on the same clip-space terms with a horizontal field of view of about 73°, which Praxis's 45° vertical one gives at 16:9. The near plane is drawn 60 units out so that it shows; Praxis's is 0.1 m. The thin box at the left end of the near plane, at the start, lies outside the frustum, and the test keeps it. With the switch off, every Instance in use is drawn.
    Chapter 5 · PRs #232, #239

    Switches and checks

    Culling arrived with a way to turn it off. RenderSwitches is one value, fixed when the Renderer is created, whose defaults are what ships, and its first switch, culling, is on by default. The glossary's Render switches also planned switches for Froxel light culling, shadow rays and the crowd scene's form in the TLAS; at fe08baa only culling existed in code. Four have joined it since: froxel_lights (#263) and shadow_rays (#258), which reach WGSL as overrides, as culling does, and count_point_lights (#272) and crowd_form (#289), which no shader reads. Capture and bench take switches; at fe08baa the window and the praxis commands passed the defaults.

    Culling reaches WGSL as an override constant, like every switch a shader reads. Every shader module declares one per such switch, generated from the switches' Rust definition and without a default, so a pipeline that uses a switch must set it. The culling pass's pipelines set them from the Renderer's switches, and each shader compiles with the value fixed, so the shipping pipeline pays for no branch:

    visible = !CULLING || in_view_frustum(bounds);
    culling.wgsl, line 236, in offset_slots. CULLING is declared by ShaderModule::assemble from RenderSwitches::constants.

    Culling off must match culling on

    The Goldens test captures every Reference scene twice, with culling on and off, and requires the two to match within the Goldens' tolerance, one step per channel. So culling removes no visible Instance on any scene. (Since #298, merged on 2026-10-26 as fd2ee9b, town takes the check at eight frames of its camera path, and on software Vulkan one pixel a frame may differ: at town's frame 1200, lavapipe resolves one sample differently depending on which triangles the draw holds, and probes showed that culling drops nothing visible there. Metal must still match.) The helmet scene has no Golden, since its source is fetched, and takes the check wherever it is fetched. PR #239 showed the check failing with a wrong plane test, the left plane missing its w term, so that it tests x ≥ 0:

    Scene, culling off against onPixels that differLargest channel difference
    pbr-sweep13,395166
    orrery5,178146
    texture-wall29,000138
    helmet19,726166

    A test that ignores the box's half axes and checks its centre alone passes this check, because no Reference scene has a visible Instance whose centre is off-screen. The pass-level test catches it.

    The pass-level test

    the_culling_pass_draws_the_instances_whose_bounds_reach_into_the_frustum drives the pass's inputs and reads its outputs. It places 1,000 Instances about a camera 100 km out along x and 70 km along z, turned and scaled unevenly, across four scan blocks, with every 97th slot freed. Its oracle is independent of the shader: each box's eight corners in f64, from World positions without Anchors, tested against the five clip planes. Instances within 10−3 of a plane are left out, so f32 rounding cannot tip a verdict.

    • Each plane alone must cull some Instance, so every plane is exercised.
    • With culling on, the draw must hold exactly the oracle's Instances, in slot order; with it off, every Instance in use.
    • Frame stats' instances_drawn must equal the oracle's count, a check #239 added. With culling ignored, it fails with 990 against 454.
    • It fails under each of ten broken plane tests, among them a dropped half axis, swapped x and y extents, a flipped near plane and ignored Anchors.

    Overflow, from the GPU's own count

    Before #239, the "frame's draw is full" warning came from the CPU's sums over every Instance in use. With culling those sums only bound the draw, so a frame whose visible Instances fit could still warn. The warning now comes from the read-back instances_overflowed, once until a frame read back has room for all; a skipped readback neither warns nor clears it.

    Its test renders 70,000 Instances into a draw that holds all but one. With culling off, the last Instance overflows and the frame warns. With culling on, the 69,998 off-screen Instances are culled, the two left draw, and nothing warns. The old sums would have warned there, with 210,000 indices against room for 209,997.

    Frame stats count what the frame drew and carry no count of Instances in use, so a frame's culled Instances are those in use less instances_drawn and instances_overflowed (and, since #276, less the crowd scene's shadow proxies, whose Mesh has no Meshlets, so that no pass draws them). At fe08baa every Reference scene drew all of its Instances in every frame: helmet 11, pbr-sweep 10, texture-wall 100, orrery 5, far-coplanar 2, and uv-grid, minification and normal-tangent 1 each. town, added since, does not: its M4 Pro Baseline's median frame draws 137,730 of its 254,256.

    Chapter 6 · PR #241

    Two phases

    The frustum test needs only the camera. Occlusion needs to know what lies in front, which the depth buffer records, and the depth buffer fills only as things are drawn. ADR 0019 answers with two phases that rely on frames being alike: most Instances visible in one frame are still visible in the next.

    1. Phase one reads one visibility bit per Instance slot and draws the Instances in view whose bit is set. The bits live in a 64 KiB buffer, zeroed when it is created, so no Instance counts as visible before the first frame.
    2. The depth pre-pass clears the 4x MSAA depth to 0, draws phase one's Instances into it with Greater, and stores it.
    3. The depth pyramid pass reduces that depth into the depth pyramid: each texel holds the minimum depth, the farthest, of every sample beneath its share of the screen. Depth Pyramid Explained shows how.
    4. Phase two tests every Instance in view against the pyramid, those phase one drew included, and records each slot's bit: set where the Instance is in view and not hidden. It draws those it finds visible that phase one did not draw.
    5. The lit pass draws phase two's Instances into depth, then shades both phases' at depth equal.
    visible = !CULLING || in_view_frustum(bounds);
    if PHASE_TWO {
        // Behind what phase one drew, it is not visible.
        visible = visible && !(CULLING && occluded(bounds));
        // Phase one's offsets for the slot, before this phase's replace them.
        drawn = CULLING && visible && all(instance_offsets[slot] == NOT_DRAWN);
    } else {
        drawn = visible && (!CULLING || visible_before(slot));
    }
    culling.wgsl, lines 236 to 244: each slot's decision in offset_slots. PHASE_TWO is an override each phase's pipelines set. Phase two then writes visible as the slot's bit for the next frame.

    Phase two tests the Instances phase one drew too, so the bits record what is visible now. An Instance that became hidden is drawn by phase one one last time, loses its bit, and leaves phase one in the next frame. Phase two writes the bits through workgroup atomics, so each workgroup writes only its own block's eight words.

    By these rules the image stays right. An Instance that shows this frame is either drawn by phase one, or tested by phase two against depth made only of Instances drawn this frame; if any part of it is nearer than that depth, phase two draws it. An Instance that phase one draws although it is now hidden costs some work, and the depth test hides it.

    The first frame has no bits. Phase one draws nothing, the pyramid is all 0, the far plane, nothing can lie behind it, and phase two draws everything in view. With culling off, phase one draws every Instance in use, and phase two draws none and sets every bit. When the view changes a lot, at a camera cut for example, last frame's bits predict little: phase one draws few Instances, the pyramid holds little, and phase two draws most of the view. That frame culls less and still renders right. Karis's Nanite deck says of the same scheme that it "only falls apart under extreme visibility changes".

    Phase two's depth, inside the lit pass

    The lit pass shades at depth equal: a sample is shaded only where the depth buffer holds exactly its own depth. Phase two's Instances are missing from the depth the pre-pass stored, so the lit pass first draws them into depth, with a pipeline made of the pre-pass's vertex stage and an empty fragment stage. Without that draw, an Instance that phase two adds fails depth equal and shows the background, as one of PR #241's failing tests showed. Drawing them in a pass of their own would load and store the 4x depth once more every frame. (Since #277, merged on 2026-10-23 as bd7b988, Praxis does so with shadow rays on, as it ships: phase two's depth pass draws them, loading and storing the 4x depth, so that the shadow mask pass can read every Instance's depth before the lit pass. With shadow rays off, the lit pass still draws them, as below. Shadow Rays Explained covers it.)

    The lit pass loads the depth and discards it at its end, since no later pass reads it. Storing it would cost about 50 µs at 1440p on the M4 Pro, 730 µs against 679 µs on pbr-sweep: once a pass holds a pipeline that writes depth, the whole target is written back, even when phase two draws nothing. (Since #272, merged as 2059535, one later pass can read it: a light count pass behind the count_point_lights Render switch, off by default, and with that switch on the lit pass stores the depth; Town Explained covers it.)

    self.bind(&mut lit_pass, bind_groups.iter().copied(), packed_indices);
    lit_pass.set_pipeline(&self.lit_pass_depth_pipeline);
    lit_pass.draw_indexed_indirect(frame_draw, PHASE_TWO_DRAW_OFFSET);
    lit_pass.set_pipeline(&self.lit_pipeline);
    lit_pass.draw_indexed_indirect(frame_draw, 0);
    src/render/main_pass.rs, lines 213 to 217: the lit pass's two draws, phase two's into depth, then both phases' at depth equal.

    One draw that grows

    Both phases append to the same index buffer. Phase one's indices start at index 0, and phase two's follow at phase one's index total, where phase one's sums say. FrameDraw holds two sets of indirect arguments over those indices, 56 bytes in all:

    BytesFieldRead by
    0 to 19draw: index count, instance count, first index, base vertex, first instancethe pre-pass, as phase one leaves it; the lit pass, with both phases
    20 to 27instances_drawn, instances_overflowedFrame stats, read back
    28 to 47phase_two_draw: the same five arguments, from phase one's index totalthe lit pass's depth draw
    48 to 55phase_two_offsets: where phase two's records and indices startphase two

    The byte offsets are this guide's arithmetic from the #[repr(C)] struct in culling_pass.rs, five 4-byte arguments per draw; PHASE_TWO_DRAW_OFFSET is its offset_of!. Phase one's offset_blocks writes its totals into phase_two_offsets; phase two's appends then raise draw's index count and fill phase_two_draw. Within each phase the draw is in slot order, so an Instance that enters the view sits after all of phase one's, for that one frame. One slot order across both phases was declined: phase two's Instances would need a third index list of 64 MiB, or gaps of degenerate triangles, and the order already repeats from run to run. Overflow works as before: once phase one fills the draw, phase two's Instances count as overflowed.

    That made three draw calls a frame at fe08baa, one in the pre-pass and two in the lit pass. There was one per Primitive per Instance before #194, one after PR #230, and two once #197's depth pre-pass landed (PR #233). Since #251, merged on 2026-10-22 as 32e4877, the tonemapping pass makes a fourth.

    The bits cost the Capability floor one limit. The culling pass's compute stage now binds nine storage buffers, so max_storage_buffers_per_shader_stage rose from 8 to 9 (ADR 0021). wgpu-hal 30.0.1 reports 29 on Metal, 262,144 on DX12 at Resource Binding Tier 3, and 48 on lavapipe (Mesa 25.2.8), whose 104 combined fragment output resources wgpu-hal divides among storage buffers, storage textures and colour attachments. Folding the bits into the per-slot offsets would have avoided the raise, at the price of mixing one frame's scratch with state kept between frames.

    drawn by phase onedrawn by phase twooccludedoutside the frustum
    Step:
    Inspect:
      SlotIn viewBit beforePhase onePhase twoBit after
      Live demo. Frames 0 to 2 have run; the view shows frame 2's phase two. Move the wall or turn the camera, with the sliders or by dragging the wall, then run the next frame: changes apply from that frame, as a game's do. The steps show one frame's passes in order, and the frame buttons show earlier frames. Simplified: the view is top-down, so the screen is one row of 64 columns with one sample each, and the pyramid is one-dimensional, 32 texels down to 1; each Instance's Mesh is its box. Once written, a depth cell is the more solid the nearer its depth, and empty at depth 0, the far plane. The rest follows occluded: the box's corners give its nearest depth and its columns, grown by a pixel, the level is where they span at most two texels, and the box is occluded where its nearest depth is less than those texels' minimum. The f32 rounding margins are left out.

      PR #241's test drives the same sequence through the Renderer with two Instances, the lower one out of view and then moved into it. In its first frame the draw holds 3 indices. In the second it holds 6, with phase two's draw starting at index 3 and holding 3. In the third, phase two draws none. Every frame's image equals culling off's. The test fails if the lit pass skips phase two's depth draw, if phase one ignores the bits, or if phase two never sets one.

      Chapter 7 · PR #242

      The occlusion test

      Phase two asks whether an Instance in view lies wholly behind what phase one drew. occluded in culling.wgsl answers from the same clip-space box as the frustum test, in five steps:

      1. Eight corners. It projects the box's corners. For a box in front of the camera, depth (z over w) and reach across the screen are both extreme at a corner, so the corners bound the whole box. A box with a corner at or behind the camera's plane is never occluded.
      2. The nearest depth, with margins. The box's nearest depth is its largest corner depth, since nearer is greater under reverse-Z. Each corner first moves towards the camera by 1/16384 of the box's clip-space terms plus 0.1 mm, past ADR 0014's worst case of 0.041 mm for composing the box and for composing its vertices, so f32 rounding cannot make the box look farther than its drawn triangles.
      3. A screen rectangle, grown by a pixel. The corners' x and y give a rectangle, in texture coordinates with rows top first. It grows by a pixel, half a level-0 texel, past where rasterisation rounds vertices, so every sample a covered pixel holds lies in the texels read.
      4. The level. It picks the pyramid level where the rectangle spans at most two texels per axis: the base-2 logarithm of the rectangle's larger side in level-0 texels, rounded up. If rounding still leaves it over more than two texels, the box is not occluded.
      5. Four texels. It loads the 2×2 texels the rectangle covers and takes their minimum, the farthest depth anything drawn reaches there. The box is occluded where its nearest depth is less than that.
      bold lines: level-2 texels faint lines: level-0 texels grown by a pixel the 2×2 texels read nearest depth: the largest of the 8 corners' depths occluded if it is less than the 4 texels' minimum
      the box's 8 projected corners and their rectanglegrown by a pixeltexels read
      Choosing the texels. A drawing to scale in level-0 texels, each 2×2 pixels. The grown rectangle runs from 2.1 to 5.9 texels across and 2.5 to 5.1 down, so its larger side is 3.8 texels, and ⌈log2 3.8⌉ = 2. At level 2, where a texel covers 4×4 level-0 texels, it spans two texels each way, and the test reads those four.

      Every step errs towards drawing, which keeps the test conservative. The pyramid keeps the farthest depth over every sample beneath a texel, the box's nearest corner stands for all of the box, the margins move it nearer, and the rectangle grows. If even the box's nearest point is farther than the farthest depth drawn anywhere it could cover, all of it lies behind what was drawn.

      // Each level halves the one below, rounded down, as a mip chain does:
      // computed, since lavapipe (Mesa 25.2.8) answers a size query whose
      // level varies across a workgroup's invocations with one invocation's.
      let size = max(level_zero_texels >> vec2<u32>(level), vec2<u32>(1u));
      let first = min(vec2<u32>(first_coordinates * vec2<f32>(size)), size - 1u);
      let last = min(vec2<u32>(last_coordinates * vec2<f32>(size)), size - 1u);
      if any(last > first + 1u) {
          return false;
      }
      let farthest = min(
          min(textureLoad(depth_pyramid, first, level).r, textureLoad(depth_pyramid, vec2<u32>(last.x, first.y), level).r),
          min(textureLoad(depth_pyramid, vec2<u32>(first.x, last.y), level).r, textureLoad(depth_pyramid, last, level).r)
      );
      return nearest_depth < farthest;
      culling.wgsl, lines 194 to 207, the end of occluded: the level's size, the texels, and the comparison.

      The evidence

      an_instance_hidden_behind_a_nearer_one_is_culled_by_phase_two_until_it_reappears creates its Renderer at 24×24 and resizes it to 16×16 first, so a pyramid or bind group left at the old size would cull wrongly. Quads are seen from the origin: a near one over the upper left quarter of the view; one wholly behind it, green; one partly behind it; one turned 60° to reach through it, its left edge 0.22 m in front and its right edge 0.22 m behind; and one elsewhere.

      FrameDrawn, culling onOffWhat happens
      055No bits: phase two draws all five against an empty pyramid.
      155Phase one draws all five, so phase two culls nothing; the hidden quad loses its bit.
      245Phase two culls the hidden quad. Pixel (4, 4) is the near quad's red.
      345The near quad moves behind the camera; phase two draws the hidden one again in that frame. Pixel (4, 4) is its green.

      Every frame's image equals culling off's. The test fails when the pyramid keeps the maximum ([5, 5, 3, 3]), when texture rows run upward ([5, 5, 5, 4]), when the test takes the farthest corner ([5, 5, 3, 4], the turned quad culled), and when resize leaves the culling pass or the pyramid pass bound to the old targets ([5, 5, 5, 4]).

      The ticket also asked for #196's culled-against-unculled check to fail with a pyramid that keeps the maximum depth, which is not conservative. On Metal it fails on four Reference scenes: pbr-sweep with 25,247 pixels differing (largest channel difference 166), orrery 2,742 (146), texture-wall 29,519 (130) and helmet 28,774 (166). PR #242 shows the images. With the minimum, every scene matches, culling on and off, and at 100 km.

      The margin's absolute 0.1 mm came from review: a margin relative to the box alone fell short within about 0.7 m of the camera.

      A bug only lavapipe had

      CI's first run of the new test on lavapipe, Mesa 25.2.8, drew [5, 5, 2, 4]: two visible quads culled. On lavapipe, textureDimensions(.., level) answers all the invocations it runs together in one SIMD vector with the size at the first one's level, a gap that Mesa's code marks with a FIXME. Every Instance read the 1×1 level's size, so its rectangle collapsed to texel (0, 0).

      Temporary probes in CI, dropped afterwards, narrowed it down. Loads through the view of every level read the right texels, texel by texel. A copy of occluded run one invocation per workgroup got every step right. Inside the culling pass, the queried size was 1 for every level. The fix, in the excerpt above, computes each level's size from level 0's, halved per level, rounded down and at least 1, as the mip chain makes it. It replaced one texture query with a shift, so PR #242's measurements, taken before it, stand.

      Chapter 8 · all three tickets

      What it costs

      Every figure here is a PR's own measurement on the M4 Pro at 2560×1440: medians, in µs, of its branch against main. Other work on the machine sometimes loaded it during these measurements; PRs #232, #241 and #242 give the load average, and PR #230 does not.

      The frame's draw (#194)

      SceneCPU frameMain pass aloneCulling pass alone
      helmet30 → 42356 → 360140
      pbr-sweep27 → 41166 → 17025
      orrery28 → 4480 → 8224
      texture-wall~2,000 → ~2,000443 → 44351
      • The main pass costs the same with one draw, even with texture-wall's 100 Materials mixed in it.
      • CPU rises 12 to 16 µs a frame: three dispatches and wgpu's validation of the indirect draw cost more than ten draws do. PR #230 expects that to pay off at town's scale.
      • Heap allocations rise by 39 a frame, 59 to 98 on pbr-sweep. GPU memory grows by 83,902,488 B: packed indices 67,108,864, records 12,582,912, slot offsets 4,210,688, and the slot count with the indirect arguments 24.

      The frustum test (#196)

      1 to 2 µs of GPU, under 1%, on scenes of 1 to 10 Instances, where offset_slots is a single workgroup and its latency shows. CPU medians moved within the load's noise, and heap allocations went from 98 to 99 a frame. The culling-off captures add about 7 s to the Goldens test.

      Two phases and the occlusion test (#198)

      SceneBefore #198After #241After #242Rise over beforedepth_pyramid_passculling_phase_two
      far-coplanar604.4862.2878.5+274.1245.818.3
      helmet882.11,174.71,189.1+307.0215.518.0
      minification682.2936.4964.6+282.4265.517.1
      normal-tangent536.0747.1774.7+238.6188.217.9
      orrery374.3590.5606.6+232.3180.017.0
      pbr-sweep469.7693.5722.5+252.8191.017.2
      texture-wall16,168.616,255.816,369.8+201.2486.736.4
      uv-grid452.5680.3710.7+258.2203.017.9

      GPU frame medians from PR #242: the median of three interleaved runs' medians each of 93355f1 (before #198), 7be466e (after #241) and #242's branch, at a load average of 6 to 10. The last two columns are #242's own times for those two passes. texture-wall spends GPU time outside its passes' timestamps (#183), so its pass times do not add up.

      • Over 93355f1, the light scenes' GPU frame rose by 232 to 307 µs. The pyramid pass is 180 to 266 µs of that, and phase two's pass about 17 µs.
      • The rest comes from passes now running one after another. The lit pass reads phase two's draw, so it waits for phase two, which waits for the pyramid; before, the lit pass's geometry work overlapped the pyramid, which nothing read. On helmet, #241 alone added 80 µs this way, and removing the lit pass's depth draw changed nothing.
      • The occlusion test itself adds little: short-circuiting its result changes the frame by under 10 µs.
      • #198 in all added 15 to 65 µs of CPU, at load averages that make those figures loose. Heap allocations went from 138 to 232 a frame, draw calls from 2 to 3, and GPU memory grew by 4,980,984 B: the pyramid 4,915,052, the bits 65,536, the frame draw 28 and the readback 368.

      The ticket said not to regress the GPU frame total "beyond the pyramid's measured cost", and #198 went past that line, so PR #242 put the cost to the owner. It recommended accepting it: most of the pyramid's cost is reading every sample of the 4x depth, 59 MB at 1440p, near the M4 Pro's bandwidth; the passes running in order is what two phases mean; the whole is about 2.5% of the 12 ms Budget; and it pays back once town hides Instances. The owner accepted on 2026-10-22. Until town arrived, the Reference scenes paid the cost and gained nothing.

      Chapter 9

      What comes next

      • #206 builds town: about 1 × 1 km, with building blocks as occluders, street props of 12 to 200 triangles, and about 250k Instances of 10 to 20 Meshes, tuned so the median frame draws at least 100,000 Instances and 5,000,000 triangles after culling. It is the first Reference scene that hides Instances, so the first where occlusion culling can earn its cost. (#206 has since built it, in PRs #272, #273 and #275: 244,256 Instances of 18 Meshes, and 10,000 birds. Its M4 Pro Baseline's median frame draws 137,730 Instances and 5,552,214 triangles after culling; Town Explained covers it.)
      • append_meshlets at scale. It runs a workgroup per slot, each walking its Mesh's Meshlets one after another. On helmet, 11 Instances of about 300 Meshlets each take 140 µs, with only 11 workgroups in flight on a 16-core GPU. town's 250k Instances would launch 250k workgroups of 124 invocations, most of them idle on small props. A comment in the shader names the fix, a pass per Instance for records and a pass per Meshlet for triangles, and #206 was to measure it on town before ADR 0019's gate was judged. (#206 closed on 2026-10-24 without that measurement; PR #275 timed town's culling phases one and two at 1.51 and 0.90 ms.)
      • ADR 0019's gate. The main-pass decision reopens if the opaque main pass takes more than 6 ms of GPU on a Reference scene at 1440p with 4x MSAA, as the median over its camera path, timed from the first culling dispatch to the end of the lit pass with shadow rays off. Culling per Meshlet waits for a profile that shows the pass raster-bound. (On 2026-10-26 the project's owner recorded on #207 that town breaches the gate, so a visibility pre-pass and a visibility buffer, ADR 0019's options B and D, are to be prototyped there.)
      • Benching the switches. #207 benches with switches on and off. A Measurement does not yet record which switches it ran with, and #207 can add that with the Baseline form it needs. (Since #299, merged on 2026-10-26 as 800c0d2, which closed #207, bench runs each scene with shadow rays on and again with them off, for ADR 0019's gate and shadow-ray time; a Measurement still names no switches.)
      • Deferred. ADR 0014 hands camera-relative composition from the vertex shader to the culling pass once draws are GPU-driven. Today the pass composes each box for its tests and the vertex shader still composes every vertex; handing the composed transform over needs a per-Instance buffer, another compute storage buffer. It waits, unscheduled, on the project's planning issue (#17).
      Reference

      Reading list

      Grouped by source, each with what it is good for here. Every link resolved when the guide was written; repo links are pinned to main at fe08baa. Slide decks are cited by PDF page, since none of the three prints slide numbers.

      These PRs and their sources

      In the repo

      GPU-driven rendering

      Occlusion culling

      • Greene, Kass and Miller, Hierarchical Z-Buffer Visibility, SIGGRAPH 1993 the z-pyramid this all descends from
      • Karis, Stubbe and Wihlidal, A Deep Dive into Nanite Virtualized Geometry, SIGGRAPH 2021 Advances PDF p. 17, testing the lowest mip where the rectangle is at most 4×4 pixels; p. 19, two-pass occlusion culling; p. 75, Nanite's own variant
      • NVIDIA, Two-Pass Culling in vk_lod_clusters why one test against last frame's Hi-Z leaves newly visible geometry missing for a frame
      • Kapoulkine, niagara's drawcull.comp.glsl an early and a late cull with a visibility flag per draw, a 2×2 minimum read, reverse-Z; MIT

      Scans and derivatives

      Books

      • Real-Time Rendering, 4th ed., ch. 19 Acceleration Algorithms: §19.2 Culling Techniques, §19.4 View Frustum Culling, §19.7 Occlusion Culling, with hierarchical z-buffering on pp. 846 to 850 by the index, and §19.8 Culling Systems. §3.8 The Pixel Shader for 2×2 quads and derivatives; §23.7 Depth Culling, Testing, and Buffering for the hardware's own coarse depth tests.
      • Foundations of Game Engine Development, Vol. 2: Rendering, ch. 9 Visibility and Occlusion: §9.3 Bounding Volumes and §9.4 Frustum Culling, with §9.4.3 Box Visibility. §6.3.2 Infinite Projection Matrices and §6.3.5 Frustum Plane Extraction for the projection behind reverse-Z.
      • Game Engine Architecture, 3rd ed., ch. 11 The Rendering Engine where culling sits in an engine's pipeline
      Reference

      Glossary

      Culling pass
      The compute pass that builds the frame's draw from the Instances, in two phases, in slot order.
      Frame's draw
      Packed indices, their Meshlet records, and the indirect arguments and counts in FrameDraw.
      Indirect draw
      A draw whose arguments the GPU reads from a buffer when it runs.
      Instance
      One Renderable's data on the GPU, in its slot of the Instance buffer.
      Slot
      An index into the Instance buffer, kept while a Renderable is one; a freed one is taken again from the next frame.
      Meshlet
      A small group of one Primitive's triangles: at most 64 vertices and 124 triangles.
      Meshlet record
      An Instance's slot, a Meshlet and the Material ID to draw it with.
      Packed index
      A record's number in the high 26 bits, a vertex of its Meshlet in the low six.
      NO_MESH, NOT_DRAWN
      A freed slot's Mesh reference; the offsets of a slot a phase does not draw.
      Exclusive scan
      For each element, the sum of every element before it: where each Instance's Meshlets start.
      Implicit derivatives
      Rates of change between neighbouring pixels, from 2×2 quads, that pick a mip level.
      Clip space
      Positions before the divide by w; the frustum is −w ≤ x, y ≤ w and z ≤ w.
      View frustum
      Inside four side planes and beyond the near plane; Praxis's has no far plane.
      Reverse-Z
      Depth 1 at the near plane, falling to 0 at infinity; nearer is greater.
      Conservative
      Never culling an Instance that would show.
      Render switches
      The variants a Renderer is created with and keeps; the first, culling, is on by default.
      Override constant
      A WGSL constant a pipeline fixes at creation, so a switch costs no branch.
      Visibility bit
      One bit per slot, set by phase two where the Instance was visible, read by the next phase one.
      Phase one, phase two
      The culling pass's runs before and after the depth pyramid.
      Depth pyramid
      ADR 0019's Hi-Z pyramid: each texel the farthest depth beneath it.
      Depth pre-pass, lit pass
      The main pass's first and last render passes: depth only, then shading at depth equal.
      Frame data
      What the renderer consumes each frame in place of the ECS World; it lists no draws.
      Frame stats
      What one frame cost, measured by the engine, among it the Instances drawn.
      Golden
      A committed image that a Reference scene must reproduce within tolerance.
      Capability floor
      The GPU features and limits the engine requires to start.
      Built from PRs #229, #228, #230, #232, #239, #241 and #242, with #240 and #233 for context, their tickets #194, #196, #198 and #206, and the code at main fe08baa. Decisions come from ADRs 0014, 0015, 0019 and 0021, the vocabulary from CONTEXT.md. Measurements are the PRs' own, on the M4 Pro; where the guide does its own arithmetic, it says so. The demos model the rules in culling.wgsl in 2D and run none of the engine's code.