Praxis · M3 · ticket #198 · PRs #240, #241, #242

The farthest depth, at every scale

A field guide to the depth pyramid that Praxis builds every frame from its 4x MSAA depth pre-pass. Each texel of the pyramid holds the minimum depth over its share of the screen, which under reverse-Z is the farthest. Phase two of the culling pass reads it to cull Instances hidden behind what phase one drew. The guide covers how the pyramid is sized and reduced, why it keeps the minimum, what it costs on the M4 Pro, and how it is tested.

Provenance. Ticket #198 is closed and its three parts are merged: PR #240 (9775d24) builds the pyramid, PR #241 (7be466e) runs the culling pass in two phases, and PR #242 (fe08baa) culls against the pyramid. The guide describes main at fe08baa, when no Reference scene hid an Instance, so the pyramid cost GPU time and culled nothing. Since then, PRs #273 and #275 have built town, with 254,256 Instances (Town Explained), and later PRs have added passes around the pyramid pass and changed how the lit pass lights and shadows (Froxel Lights Explained, Shadow Rays Explained). The pyramid's shader and phase two's test of it are unchanged on main at 270c5e4, and the pass still runs between the depth pre-pass and phase two.

Chapter 1

Why a pyramid

Phase two of the culling pass asks one question of each Instance in view: does its box lie wholly behind what phase one drew? Phase one has already drawn the Instances in view that were visible the frame before into the depth pre-pass. For each Instance, phase two projects the eight corners of its Mesh bounds, which gives a screen rectangle and the box's nearest depth. The box is hidden if even its nearest point lies behind the farthest depth drawn anywhere under that rectangle.

Answering from the depth target itself means reading every sample under the rectangle. The pre-pass draws at 4x MSAA, so a rectangle 200 pixels on a side covers 160,000 samples, by this guide's arithmetic, and town, the dense Reference scene of M3, the project's main-pass milestone, was planned with about 250k Instances; PRs #273 and #275 have since built it with 254,256.

A depth pyramid answers with four reads, whatever the rectangle's size. Each level halves the one below, and each texel holds the farthest depth over its share of the screen. Phase two picks the level whose texels are at least as large as the rectangle, so the rectangle touches one or two texels along each axis there, and reads those. Their minimum is never nearer than the farthest depth under the rectangle, because the texels cover all of it and more. The answer can be farther than the truth, which only ever keeps an Instance that could have been culled: the test is conservative.

Greene, Kass and Miller described the structure in 1993 as a Z pyramid, built by combining "four Z values at each level into one Z value at the next coarser level by choosing the farthest Z from the observer". Their basic test compared a polygon with the one entry whose region covered its whole bounding box, and they named its weakness: "a small polygon covering the center of the image will be compared to the Z value at the coarsest level of the pyramid." Reading up to four texels avoids it. A rectangle that straddles the centre still gets texels about its own size.

Square rectangle, pixels on a sideSamples under it at 4xPyramid loads
82564
6416,3844
5121,048,5764
Reads to bound the farthest depth under one rectangle, by this guide's arithmetic: s pixels on a side cover 4s² samples. Whenever it reads the pyramid, phase two issues four loads (culling.wgsl, lines 203 to 206), some of them of the same texel when the rectangle fits in one along an axis.
Chapter 2 · ADR 0015

Why the minimum

Praxis's depth is reverse-Z (ADR 0015). A point d metres in front of the camera lands at depth 0.1 / d: 1 at the near plane, 0.1 at 1 m, 0.001 at 100 m, and towards 0 at infinity (src/camera/mod.rs). The depth target clears to 0, the far plane, and the depth pre-pass keeps a fragment where its depth is greater, so each sample ends up holding the nearest surface drawn there.

A texel of the depth pyramid must bound the farthest surface under it, so that no box that shows through a gap is mistaken for hidden. With nearer greater, the farthest is the smallest depth. ADR 0015 puts it in one clause: "a Hi-Z pyramid keeps the minimum depth, which is the farthest". Where phase one drew nothing, depth stays 0, and no box in front of the camera lies behind 0, so open sky hides nothing.

The test then reads: a box is occluded if its nearest depth is less than that minimum, that is, if even its nearest point is farther than the farthest surface under it. Spec #188 states the property for M3: "The pyramid holds minimum reverse-Z depth, the farthest, so it is conservative."

An illustration, not engine data. Eight samples across one texel's share, seen from the side: six hold an occluder at depth 0.7, and two see past it to the cleared far plane, 0. A box whose nearest depth is 0.3 lies behind the occluder and shows through the gap. Against the minimum, 0.3 < 0 is false, so the box is kept. Against the maximum, 0.3 < 0.7 holds, so the box would be culled, and the gap would show the far plane where the box belongs.

PR #242 shows the maximum failing. Its renderer test hides one quad behind a nearer one and expects 5, 5, 4 and 4 Instances drawn over four frames. With a pyramid that keeps the maximum, in its levels and over the four texels read, it draws 5, 5, 3 and 3. #196's check renders every Reference scene with the culling Render switch on and off and requires the two images to match within the Goldens' tolerance, one 8-bit step per channel. (Since PR #298 it checks town at eight frames, and allows one differing pixel per frame on software Vulkan and none on Metal.) With a maximum-depth pyramid it fails on four scenes, on Metal: pbr-sweep by 25,247 pixels, orrery by 2,742, texture-wall by 29,519 and helmet by 28,774. With the minimum, every scene matches, culling on and off, and at 100 km.

Rákos's 2010 article on Hi-Z occlusion culling keeps the maximum, a "store maximum depth" policy for "a usual depth buffer setup", and notes the other case: "For a reverse directed depth buffer the 'store minimum depth' policy has to be used."

Chapter 3 · PR #240

Sizes and shares

RenderTargets creates the depth pyramid with the other targets whose size is the frame's. Level 0 is half the target's width and height, rounded down and at least 1, and the texture holds every level down to 1 × 1. Each level is the one below halved and rounded down, max(1, size >> level), as wgpu sizes any mip chain. At 2560 × 1440 that makes eleven levels: 1280 × 720, 640 × 360, 320 × 180, 160 × 90, 80 × 45, 40 × 22, 20 × 11, 10 × 5, 5 × 2, 2 × 1 and 1 × 1.

With sides halved and rounded down, some levels have an odd side, which raises the question of which texels of the level below a texel must cover. The pyramid answers with even shares. Each level's columns and rows divide the screen evenly, so texel x of a level n texels wide stands for x/n to (x+1)/n of the screen's width, its share of the screen. It holds the minimum of every texel of the level below whose share overlaps its own: for a level below m texels wide, from ⌊x·m/n⌋ to ⌈(x+1)·m/n⌉ − 1.

// The texels of a level `below_size` texels in size that `texel` of a level
// `size` overlaps, the rows and columns of each level sharing the screen
// evenly: from ⌊texel × below / size⌋ to ⌈(texel + 1) × below / size⌉ − 1.
// Two along an axis that halves exactly, three where the level below is odd.
fn overlapped_texels(texel: vec2<u32>, size: vec2<u32>, below_size: vec2<u32>) -> OverlappedTexels {
    return OverlappedTexels(
        texel * below_size / size,
        ((texel + 1u) * below_size + size - 1u) / size - 1u,
    );
}
src/render/shaders/depth_pyramid.wgsl, lines 15 to 24. Both of the pass's entry points take each axis's first and last texel below from it.

Where the level below halves exactly, m = 2n, the range is columns 2x and 2x + 1. Where it is odd, m = 2n + 1, each texel's share is 2 + 1/n columns of the level below wide, so every texel overlaps three of them, by this guide's arithmetic. A plain 2 × 2 reduction reads columns 2x and 2x + 1 everywhere. At 37 pixels across and 18 texels, it never reads column 36, and texel 8, whose share runs from 8/18 to 9/18 of the width, reads pixels 16 and 17 although its share also covers pixel 18.

That matters because phase two finds texels by the same even shares: a screen position u, from 0 to 1, picks texel ⌊u·n⌋. Under a plain reduction, pixels under a rectangle could go unread. If one of them showed the far plane through a gap, the test would see only the nearer depth around it and could cull a visible Instance. With texels covering two below and never three, PR #240's test finds 13 of level 0's 198 texels wrong.

The test resizes to 37 × 23, whose chain, 18 × 11, 9 × 5, 4 × 2, 2 × 1 and 1 × 1, reduces an odd side at levels 0, 1 and 2. The demo uses the same size and the same triangles.

Level:
Rule:

The depth target

Level 0

0 far, to 0.9 near the texel's share what it covers a plain 2×2 reads covered, left unread differs from Praxis's
    Live demo. A 37 × 23 depth target with four samples per pixel, drawn with the pass-level test's five triangles at depths 0.2, 0.7, 0.45, 0.9 and 0.3 over the cleared 0. The demo rasterizes them itself at Vulkan's standard 4x sample positions, and its counts match the test's on the GPU: the plain 2 × 2 gets 13 of level 0's 198 texels wrong, and the sample-0 read 9. Left, the level being reduced, with this level's shares over it; at level 0, each pixel shows its four samples, one per quadrant. Right, the level itself. Hover or tap a texel, or focus either picture and use the arrow keys.
    Chapter 4 · PRs #240, #242

    Every sample of level 0

    The depth pre-pass draws at 4x MSAA (ADR 0019), so the depth target holds four samples per pixel, each tested at its own point. Along a silhouette, some of a pixel's samples hold the near surface and others whatever lies behind it. Level 0 takes the minimum of all four samples of every pixel it covers, so one sample that sees past an edge pulls the texel to the farther depth. Reading sample 0 alone misses that: in the PR's test, 9 of level 0's 198 texels then differ.

    Phase two relies on it. It grows each rectangle by a pixel past where rasterization rounds vertices, "so every sample a covered pixel holds is in the texels read" (PR #242). The same PR adds that "reading fewer samples, or a 1x depth, would stop the test being conservative under MSAA."

    The pass binds the depth target as a texture_depth_multisampled_2d, which WGSL's textureLoad reads one sample at a time, by index, and the target gained TEXTURE_BINDING for it. A copy between a texture and a buffer needs a single-sampled texture, so the test reads the samples back with a compute kernel instead.

    // The minimum depth of the depth target's four samples at `pixel`, which
    // Rust asserts it has (SAMPLE_COUNT), loaded together: Metal's compiler
    // does not unroll a loop over them.
    fn pixel_minimum(pixel: vec2<u32>) -> f32 {
        return min(
            min(textureLoad(depth, pixel, 0), textureLoad(depth, pixel, 1)),
            min(textureLoad(depth, pixel, 2), textureLoad(depth, pixel, 3))
        );
    }
    depth_pyramid.wgsl, lines 26 to 34. In Rust, const _: () = assert!(SAMPLE_COUNT == 4); fails the build if the sample count changes (depth_pyramid_pass.rs, lines 16 to 18).

    The loads are written out because a loop cost more. In PR #240's words, "in a loop, Metal's compiler serialised the loads". It measured level 0 at 1440p on the M4 Pro written three ways.

    Loop over pixels and samples
    656 µs
    Loop over samples alone
    175 µs
    Explicit loads, as merged
    150 µs
    Level 0's GPU time at 2560 × 1440 on the M4 Pro, by how the sample loads are written (PR #240).

    Even written out, level 0 is most of the pass: about 150 to 220 µs of it at 1440p, depending on the depth. It reads every sample of the 4x depth, 58,982,400 bytes uncompressed at 1440p (ADR 0019), about 59 MB. Scenes with grazing ground planes, minification and far-coplanar, cost the most, which PR #240 reads as "consistent with the depth compressing worse there".

    At 2560 × 1440, level 0 is exactly half the target, so each of its texels reads 2 × 2 pixels, 16 samples. Where both sides of the target are odd, a level-0 texel reads 3 × 3 pixels, 36 samples, by this guide's arithmetic.

    Chapter 5 · PRs #240, #241

    One pass, a level per dispatch

    DepthPyramidPass holds two compute pipelines over two entry points of depth_pyramid.wgsl: reduce_depth for level 0, which reads the depth target, and reduce_level for every later level, which reads the level below. The PR's review kept them apart, since "WGSL has no generics, and a shared function would make each entry point use both bindings" (PR #240).

    Their bind group is DEPTH_PYRAMID_LEVEL, defined once in bind_groups.rs with three bindings: level, the level written, a write-only r32float storage texture; depth, the multisampled depth target; and level_below. Each pipeline's layout holds only the two bindings its entry point uses, which BindGroupDefinition::layout_of builds from a list of binding names, checked as bind_group checks them. The pass keeps one bind group per level, with that level's size.

    record opens one compute pass, labelled depth_pyramid_pass, and dispatches once per level, in order, over workgroups of 8 × 8 texels. At 1440p that is eleven dispatches.

    // Level 0 reduces the depth target, each later level the one below.
    for (index, (bind_group, size)) in self.levels.iter().enumerate() {
        pass.set_pipeline(if index == 0 {
            &self.reduce_depth
        } else {
            &self.reduce_level
        });
        pass.set_bind_group(DEPTH_PYRAMID_LEVEL.index, bind_group, &[]);
        pass.dispatch_workgroups(
            size.width.div_ceil(DEPTH_PYRAMID_TILE_SIZE),
            size.height.div_ceil(DEPTH_PYRAMID_TILE_SIZE),
            1,
        );
    }
    src/render/depth_pyramid_pass.rs, lines 110 to 123, indented less. DEPTH_PYRAMID_TILE_SIZE is 8.

    The Renderer records the pass between the depth pre-pass and phase two; PR #240 split MainPass::record into record_depth_prepass and record_lit_pass to make room for it. TIMED_PASSES lists the frame's five passes in order, each timed from its start to its end, and Frame stats report each one's GPU time. Later PRs added seven passes around these: on main (270c5e4), TIMED_PASSES lists twelve, with the pyramid pass still between the depth pre-pass and phase two.

    culling_phase_one

    compute
    • Appends the Instances in view that were visible the frame before.

    depth_prepass

    render
    • Draws them into the 4x depth, cleared to 0.
    • Stores the depth.

    depth_pyramid_pass

    compute
    • Reads every sample of the depth.
    • Writes the pyramid, a level per dispatch.

    culling_phase_two

    compute
    • Tests each Instance in view against the pyramid.
    • Appends those phase one did not draw and the pyramid does not hide.

    lit_pass

    render
    • Draws phase two's depth, then shades both phases at depth equal.
    • Loads the depth and discards it.
    The frame's passes, as TIMED_PASSES lists them (renderer.rs, lines 17 to 25; recorded on lines 343 to 382). At fe08baa, the frame-stats capture test expects exactly these, in this order, each with a non-zero time; on main it expects all twelve, with the three that pbr-sweep never runs, the dynamic TLAS's build, the bend pass and the BLAS refit, reading no time. Since PR #302 it also lets phase two's depth pass read no time in a frame where phase two draws nothing.

    The order sets two attachment choices. The pre-pass clears the depth to 0 and stores it, since the pyramid and the lit pass read it. The lit pass is the frame's last pass that draws depth, so it loads the depth and ends with StoreOp::Discard. Once a render pass holds a pipeline that writes depth, a store writes the whole target back, "even when phase two draws nothing": about 50 µs at 1440p on the M4 Pro. PR #241 measured pbr-sweep at 730 µs with Store against 679 µs. Since PR #277, with shadow rays on, as they ship, phase two's Instances draw into depth in a render pass of their own, phase_two_depth_pass, which loads the depth and stores it for the shadow mask pass; the lit pass then writes no depth, and still loads the depth and discards it. With shadow rays off, the lit pass draws phase two's depth as above.

    The pass asked nothing new of the Capability floor. It runs on compute shaders, which the floor already required for the culling pass (ADR 0021), and PR #240 changed neither ADR 0021 nor src/gpu/. The device is requested with exactly the floor, so the pass runs on what the floor guarantees. The raise #198 did need came in its second part: max_storage_buffers_per_shader_stage from 8 to 9, for the culling pass's visibility bits.

    Chapter 6 · PRs #240, #242

    Recreated on resize

    The pyramid's size follows the target's, so it lives with the targets. RenderTargets creates every target whose size is the frame's: the 4x depth, the 4x colour and the pyramid, and since PRs #251 and #277 the HDR target and the shadow mask too. Renderer::resize creates them all again. RenderTargets keeps two kinds of view of the pyramid: one of every level, which phase two reads and which PR #242 added, and one of each level alone, which the pyramid pass writes and reads a level at a time.

    Bind groups are snapshots, not pointers, as the module's comment says. After a resize, a bind group built for the old pyramid still holds the old textures. If the pyramid pass kept its own, it would go on reducing the old depth into the old pyramid, and phase two would read texels the frame never wrote. So each pass builds the bind groups that read targets in one function, called at creation and after every resize: DepthPyramidPass::bind_render_targets, and CullingPass::bind_render_targets for the culling pass, whose group 3 holds the pyramid.

    /// Matches a new target size, both non-zero.
    pub fn resize(&mut self, width: u32, height: u32) {
        // A pass whose bind groups read a target rebuilds them here, after
        // the render targets.
        self.render_targets = RenderTargets::new(&self.device, self.target_format, width, height);
        self.depth_pyramid_pass
            .bind_render_targets(&self.device, &self.render_targets);
        self.culling_pass
            .bind_render_targets(&self.device, &self.render_targets);
        self.aspect_ratio = width as f32 / height as f32;
    }
    src/render/renderer.rs, lines 247 to 257, indented less.

    Tests check both. The pass-level test creates the pass for 16 × 16 targets, then rebinds it to targets created again at 37 × 23; without the rebinding, 62 of level 0's 198 texels differ. PR #242's renderer test creates its Renderer at 24 × 24 and resizes it to 16 × 16 first. If resize skips rebinding the culling pass, or the pyramid pass, it draws 5, 5, 5 and 4 Instances where it should draw 5, 5, 4 and 4.

    The pyramid counts in gpu_memory_bytes through RenderTargets::gpu_memory_bytes, which counts every level of the texture behind level 0's view.

    TargetPyramidLevelsTexelsBytesWhere it appears
    2560 × 14401280 × 720 to 1 × 1111,228,7634,915,052M4 Pro bench, PRs #240 and #242
    640 × 360320 × 180 to 1 × 1976,763307,052CI's lavapipe bench, PR #240
    160 × 9080 × 45 to 1 × 174,76319,052the frame-stats capture test
    37 × 2318 × 11 to 1 × 152541,016the pass-level test
    The pyramid at the sizes the PRs use, at 4 bytes a texel. Figures the PRs and tests state, and this guide's arithmetic for the rest.
    Chapter 7 · PR #242

    How phase two reads it

    The pyramid's one reader is occluded, in culling.wgsl, which phase two calls for each Instance in view. This chapter follows only its use of the pyramid; the next guide covers the rest of the culling pass.

    1. Project the box. For each of the eight corners of the Instance's Mesh bounds in clip space, keep the greatest depth, z over w: the box's nearest point. Each corner is first moved towards the camera, in clip-space z and in w, by 1/16384 of the summed magnitudes of that coordinate over the box's centre and its three half-axes, plus 0.1 mm, past what f32 rounding could do to the vertices. A box with a corner at or behind the camera's plane is never occluded.
    2. Take the screen rectangle. The corners' x and y over w bound the screen rectangle. Phase two turns it into texture coordinates, rows top first, and grows it by half a level-0 texel on each side, about a pixel.
    3. Pick the level. With e the rectangle's larger side, measured in level-0 texels, the level is min(⌈log₂ max(e, 1)⌉, levels − 1). Its texels are at least 2level level-0 texels wide, so the rectangle spans at most two of them per axis, by this guide's reading.
    4. Find the texels. Phase two computes the level's size, max(1, level 0's size >> level), and turns the rectangle's corners into a first and last texel per axis. If rounding leaves more than two per axis, the box is not occluded.
    5. Read four texels, the corners of that block of one or two by one or two, and take their minimum.
    6. Compare. The box is occluded if its nearest depth is less than that minimum, and phase two culls it.

    The level rule has the form of the one on slide 67 of Wihlidal's GDC 2016 talk, min(ceil(log2(max(longestEdge, 1.0f))), levels - 1), and Rákos's 2010 article also reads four texels per test.

    let level = min(
        u32(ceil(log2(max(max(extent.x, extent.y), 1.0)))),
        textureNumLevels(depth_pyramid) - 1u
    );
    // 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));
    src/render/shaders/culling.wgsl, lines 190 to 197, indented less. extent is the grown rectangle in level-0 texels.

    Sizes computed, not queried

    Step 4 computes each level's size because lavapipe answered the query wrongly. On CI's lavapipe, Mesa 25.2.8, textureDimensions(.., level) gives every invocation that lavapipe runs together in one SIMD vector the size at the first one's level, which Mesa's source marks "FIXME: this needs to honor per-element lod"; the code comment above names the whole workgroup. Every Instance then read the 1 × 1 level's size, its rectangle collapsed to texel (0, 0), and the test culled two visible quads: the first CI run drew 5, 5, 2 and 4. Temporary probes on CI, since dropped, narrowed it down: loads through the all-levels view were right texel by texel, a copy of occluded run one invocation per workgroup got every step right, and inside the culling pass the queried size was 1 at every level. The fix "replaces one texture query with a shift", so the PR's timings, taken before it, stand. Level 0's size is still queried, with the same level in every invocation.

    Start from:
    Pyramid keeps:
    0 far, to 0.9 near screen rectangle grown by a pixel texels read

      Live demo. A 160 × 90 depth target, the frame-stats capture test's size, drawn with the same five triangles, and its pyramid of seven levels, 80 × 45 down to 1 × 1. The rectangle stands for an Instance's screen rectangle and the slider for its box's nearest depth, in hundredths: the demo sets both directly, where phase two derives them from the box's corners, and it leaves out the depth margin of step 1. From the rectangle on, it follows occluded: the pixel margin, the level, the computed size, the texels and the comparison. Drag the rectangle or its corner, use the sliders, or focus the picture and use the arrow keys, with Shift to resize. "The maximum" builds the pyramid and combines the four reads with the maximum, as PR #242's failing variant did.

      Phase one, the visibility bits, why phase two also tests the Instances phase one drew, and how the frame's draw grows across the two phases are the subject of the next guide, Instance Culling Explained.

      Chapter 8 · PRs #240, #241, #242

      How it is tested

      Ticket #198's architecture review asked for a pass-level test beside the capture tests: "the pyramid built from a read-back depth texture against a CPU minimum-reduction". It is each_depth_pyramid_texel_holds_the_minimum_depth_of_its_share_of_the_screen, in depth_pyramid_pass.rs:

      1. It creates the pass for 16 × 16 targets, then rebinds it to targets created again at 37 × 23, so that levels 0, 1 and 2 reduce an odd side.
      2. It draws five triangles into the depth target as the pre-pass draws, cleared to 0 and passing Greater: one at depth 0.2, three times the others' size, and four at 0.7, 0.45, 0.9 and 0.3.
      3. It records the pass, then reads back every sample of the 4x depth and every level with small compute kernels.
      4. It builds the expected pyramid on the CPU, from each pixel's minimum, a level at a time, and requires every texel of every level to be equal, with no tolerance.
      5. It checks that the expected pyramid is not all background.

      The CPU side is written another way. The shader computes each texel's first and last texel below; the CPU loops over every texel below and keeps those whose share overlaps, by an integer test, below_texel * size < (texel + 1) * below_size && (below_texel + 1) * size > texel * below_size. Two formulations that agree are better evidence than one copied into the test.

      Broken on purposeLevel-0 texels that differReported in
      Texels cover two texels below, never three13 of 198PRs #240, #241
      Level 0 reads sample 0 alone9 of 198PRs #240, #241
      Level 0 takes the maximum of a pixel's first two samples, then the minimum of that and the other two3 of 198PR #241
      The pass is not rebound to the new targets62 of 198PRs #240, #241
      The reduction keeps the maximum198 of 198PR #240
      The pyramid pass is recorded before the depth pre-pass62 of 198PR #240
      How the pass-level test fails, as the PRs report it. The last two rows come from PR #240's first form of the test, which ran in a Renderer. PR #241 moved it to the pass seam, drawing its own depth, because the depth target is undefined after a frame once the lit pass discards it. Run on the demo's own rasterization, the first four rows come out the same, by this guide's check.

      Around it, capture_reports_the_pbr_sweep_frame_stats expects the five passes in order, each with a non-zero time (on main, twelve, some of which read no time, as chapter 5 notes), and a closed-form gpu_memory_bytes that adds the 80 × 45 pyramid's seven levels, 19,052 B. PR #242's renderer test drives the pyramid through phase two and a resize (chapters 2 and 6), and #196's check compares every Reference scene with culling on and off. Goldens were unchanged on Metal through all three PRs, and CI checked software Vulkan.

      Chapter 9 · PRs #240, #242

      What it costs

      PR #240 benched main at 93355f1 against its branch on the M4 Pro at 2560 × 1440, in three interleaved rounds per binary with the machine's load average at 4 to 8, and reports the median of the three runs' medians.

      SceneGPU frame, main → PRdepth_pyramid_passGPU rise beyond the passCPU frame, main → PR
      far-coplanar593.0 → 838.6244.1+1.671.2 → 89.5
      helmet864.3 → 1,084.8210.1+10.489.8 → 106.2
      minification657.5 → 914.3258.2−1.569.8 → 104.0
      normal-tangent508.5 → 720.9183.5+28.970.1 → 94.0
      orrery365.0 → 560.3179.3+16.070.0 → 85.6
      pbr-sweep458.1 → 659.8185.0+16.867.3 → 83.0
      texture-wall14,150.6 → 14,525.2789.8n/a1,784.3 → 1,905.0
      uv-grid442.0 → 658.6196.5+20.269.5 → 86.2
      PR #240 on the M4 Pro, in µs. texture-wall spends GPU time outside its passes' timestamps (#183), so its pass lines do not add up: its pass reads 790 µs while its frame rises by 375 µs.
      • GPU: the frame rises by the pass's own time plus up to 29 µs, which PR #240 puts down to the extra pass boundary between the pre-pass and the lit pass. Level 0 is about 150 to 220 µs of the pass; the ten later levels cost about 40 µs together, mostly per-dispatch cost.
      • CPU: +15 to +34 µs a frame on every scene but texture-wall, for the compute pass, its eleven dispatches and its timestamps; by this guide's arithmetic from the table, texture-wall's CPU frame rose by 120.7 µs.
      • Heap allocations: from 138 to 194 a frame on every scene, 56 more. Recording an empty pass with its timestamps costs 4, level 0's dispatch 16, and each later dispatch about 3.6, from wgpu's per-dispatch tracking.
      • GPU memory: +4,915,164 B at 1440p: the pyramid's 1,228,763 R32Float texels, and 112 B for a fourth timed pass in the readback of GPU times (PR #241's split of the culling pass later made five). On CI's lavapipe, only gpu_memory_bytes, +307,164 B at 640 × 360, and heap allocations, from 146 to 196, moved. The Baselines were re-recorded on both.

      After all three parts, PR #242 measured the pass at 180.0 to 265.5 µs on every scene but texture-wall, and #198 as a whole at +232 to +307 µs of GPU over 93355f1 on those scenes, of which the pyramid is 180 to 266 µs. That went past the ticket's line, "must not regress: GPU frame total on today's scenes beyond the pyramid's measured cost", and the project's owner accepted it. The PR's recommendation put the whole at about 2.5% of the 12 ms Budget, paid back once town hides Instances. When #198 closed, the Reference scenes paid the cost and gained nothing; PRs #273 and #275 have since built town.

      Chapter 10

      What comes next

      • Instance Culling Explained, next in this series: phase one and its visibility bits, phase two's test in full, the frame's draw across the two phases, and the Capability floor's ninth storage buffer.
      • #206 builds town, the first Reference scene that hides Instances, about 1 × 1 km with building blocks as occluders. PRs #273 and #275 have since built it, and #206 closed on 2026-10-24 (Town Explained). Spec #188 asks for its camera path to run "at street level, then over the rooftops, with no cuts, so that occlusion changes exercise both Hi-Z phases".
      • Fewer dispatches, deferred on #17, the issue that holds the project's architecture decisions and milestone order: "one dispatch could write two levels, the second from the level two below (still conservative, though not equal to the chained minimum)", revived "when a profile names the pyramid's tail or the frame's heap allocations".
      • ADR 0019's gate times the opaque main pass from the first culling dispatch to the end of the lit pass with shadow rays off, the pyramid included. If a Reference scene's median at 1440p passes 6 ms of GPU, half of ADR 0013's 12 ms, options B and D of ADR 0019 are prototyped on that scene before anything changes.
      Reference

      Reading list

      Grouped by source, each with what it is good for here. Every link below resolved when the guide was written. Repo links are pinned to main at fe08baa.

      These PRs and their sources

      In the repo

      Code at fe08baa

      Hi-Z in the literature

      Specifications and APIs

      Books

      • Real-Time Rendering, 4th ed.: §19.7 Occlusion Culling, from p. 840, whose index puts hierarchical z-buffering on pp. 846–850, where both talks above are cited; §23.7 Depth Culling, Testing, and Buffering, from p. 1014, with the hardware's HiZ, zmax and zmin culling on p. 1015; §4.7 Projections, with reversed z on p. 100; §5.4 Aliasing and Antialiasing, with multisampling on pp. 139–142. Numbers from the book's contents, bibliography and index.
      • Foundations of Game Engine Development, Vol. 2, Rendering: §6.3.2 Infinite Projection Matrices and §6.3.3 Projected Depth Precision, for ADR 0015's projection; chapter 9, Visibility and Occlusion, with §9.4.3 Box Visibility, for the culling pass next. From the book's site.
      • Game Engine Architecture, 3rd ed.: §11.1 Foundations of Depth-Buffered Triangle Rasterization background on depth buffering, by the book's site
      Reference

      Glossary

      Depth pyramid
      ADR 0019's Hi-Z pyramid: an R32Float mip chain from half the target's size to 1 × 1, each texel the minimum depth, the farthest, over its share of the screen.
      Hi-Z
      Hierarchical Z: depth kept at several resolutions, so a few reads bound the depth under a region.
      Level
      One image of the pyramid; each is the one below halved, rounded down, at least 1.
      Texel
      One value of one level.
      Share of the screen
      What a texel stands for when its level divides the screen evenly: texel x of n covers x/n to (x+1)/n.
      Reverse-Z
      Depth 1 at the near plane, falling towards 0 at infinity; cleared to 0, nearer is greater (ADR 0015).
      Depth pre-pass
      The render pass that draws phase one's Instances into the 4x depth and stores it for the pyramid and the lit pass.
      4x MSAA, sample
      Four samples per pixel, each tested for coverage and depth at its own point.
      Conservative
      Never culls what is visible; its errors only keep Instances that could have been culled.
      Culling pass
      The compute pass that tests each Instance and builds the frame's draw.
      Phase one, phase two
      Phase one draws the Instances in view that were visible the frame before; phase two, after the pyramid, tests the Instances in view and draws those phase one did not.
      Mesh bounds
      The box that holds a Mesh's vertices; phase two projects its eight corners.
      Screen rectangle
      The rectangle on screen that holds a box's projected corners.
      RenderTargets
      Every target whose size is the frame's, the pyramid among them, created again on resize.
      Bind group
      The resources a pass binds; a snapshot, so it is rebuilt after every resize.
      Dispatch
      One run of a compute shader over a grid of workgroups; the pyramid pass makes one per level.
      Lit pass
      The shading pass, at depth equal; the frame's last depth pass, which discards the depth.
      StoreOp::Discard
      Throws an attachment's contents away at the end of a render pass instead of writing them back.
      Instance
      One Renderable's data on the GPU, in its slot of the Instance buffer.
      Frame stats
      What one frame cost, measured by the engine: GPU time per pass, heap allocations, GPU memory and more.
      Capability floor
      The GPU features and limits the engine requires to start.
      lavapipe
      Mesa's software Vulkan driver, with which CI renders Goldens and records counter Baselines.
      M4 Pro
      The machine on which Budgets are judged and Baselines recorded, with wgpu on Metal.
      Built from PR #240, PR #241 and PR #242, ticket #198 and its comments, with the code read at main fe08baa. Decisions come from ADRs 0013, 0015, 0019 and 0021 and spec #188. Measurements are the PRs'; where the guide does its own arithmetic, it says so. The demos rasterize the pass-level test's triangles in the page, at Vulkan's standard 4x sample positions, and follow the pass's reduction and phase two's level and texel rules; their captions name what they leave out.