Praxis · M3 · ticket #206, closed · PRs #272, #273 and #275, merged

A town built to be measured

M3 judges the engine at the scale it is built for: ADR 0013's Budgets and ADR 0019's gate, measured on a dense Reference scene. Ticket #206 builds that scene. town is a procedural town of 1 × 1 km: 244,256 static Instances and 10,000 birds that are Moving Instances, with every random choice among them hashed from its place and what it decides, so every launch places the same town. This guide explains how the town is placed and what it is made of, the flocks among it, the camera path its figures are measured along, what culling draws in the street and round the town, the pass that counts each pixel's point lights and the trap that pass avoids, what 4x MSAA does for the wires, and what the town costs in its M4 Pro Baseline. Three live demos place one block's roof plant from the hash, follow the camera along its path, and show why a shader with side effects counts what it never draws.

Provenance. #206 landed in three stacked parts, all merged on top of main's #277, which traces the shadow rays in a compute pass of their own. Part 1, PR #272, merged as 2059535, adds the light count pass and doubles the frame's draw to 225 packed indices. Part 2, PR #273, merged as c8f7071, adds the town. Part 3, PR #275, merged as 69e02dc, adds the flocks, town's Baselines and CI's counter-only bench of town, --ci-counters. The project's owner accepted its visual check of town's first Goldens, and its merge closed #206 on 2026-10-24. The guide quotes main at 69e02dc. The figures were measured while #206 was built, on 2026-10-23 and 24; the Baseline, the Goldens and the birds' costs are on the code that merged, and the few taken before #277 say so. #207 judges the costs: on 2026-10-26 it measured ADR 0019's gate at 9.61 ms, a breach, and found that the dynamic TLAS's build runs almost wholly alongside the previous frame's passes, so about 4 ms of the 17.6 ms GPU frame given here is time already counted in the frame before (chapter 8).

Chapter 1 · ADRs 0013 and 0019

Why a dense scene

Until town, each Reference scene tested one feature on a few Instances. M3 has to answer two questions at the scale Praxis is built for: do ADR 0013's Budgets hold, and does ADR 0019's main pass stay under its gate? Both need a scene that loads the culling pass, the lit pass and the TLAS the way an open world will. Town is that scene, and code generates it, because the path that will fill real worlds with authored content, M4's Placements, does not exist yet.

The two questions are the first two of M3's exit criteria, and the third follows from them:

  • Dense scene. In the median frame of its camera path, at 2560 × 1440 with 4x MSAA, town must draw at least 100,000 Instances and 5,000,000 triangles after culling, and stay within ceilings of 12 ms on its GPU frame, 6 ms on its main-thread critical path and 8 GB of GPU memory. ADR 0013 made its Budgets provisional until the first Baseline of a Reference scene at target scale, and named M3's dense scene as that scene: the Budgets are re-decided against town's first Baseline.
  • ADR 0019's gate. The clustered forward main pass stays while its GPU time, from the first culling dispatch to the end of the lit pass with shadow rays off, has a median under 6 ms over the scene's camera path at 1440p with 4x MSAA. A breach blocks M3's exit until two other designs, ADR 0019's options B and D, are prototyped on town and the project's owner decides.
  • GPU-driven draws. Draw calls per pipeline and Hi-Z phase stay constant whatever the Instance count, and with culling off town must match culling on within Golden tolerance at frames spread along its path. That is why the frame's draw must hold the whole town (chapter 5).

Why generated

The engine's real content will arrive as Placements, Mesh Assets positioned inside Chunks of a streamed Game world, and M4 builds that path. M3's measurements cannot wait for it, and a generated scene needs nothing authored. Town's 19 Meshes are built by code at cook time (generators::meshes()), cooked into Blobs in the Cooked cache under Asset IDs derived from the scene's name, and loaded like any other Mesh. Its Instances are placed when the scene spawns, by fixed rules and by values hashed from where each stands (chapter 2).

Generating the scene also keeps it fixed. A Reference scene pairs a Scene with a fixed camera path and injected time so that every run produces the same frames, and town's hash gives its content the same guarantee: every launch places the same 244,256 static Instances in the same places, so its Goldens and Baselines always compare the same town.

Chapter 2 · mix and random

Placing 254,256 Instances the same way every time

Town is a grid of blocks, and every choice in it, from whether a block is a park to what stands on each cell of a roof, is a random value: a number from 0 up to 1, hashed from the block's column and row, what the value decides and which item it decides for. The same four parts always give the same number, whatever else is decided and in whatever order, so every launch places the same town.

The town is 1 × 1 km: 20 × 20 blocks on a 50 m pitch, with 12 m streets between them and along the edge. A block is a park with a 12% chance, and a park holds 5 × 5 trees and 4 benches. Every other block holds 4 buildings, each 16 m square and of 3 to 6 storeys of 3.2 m, with windows on its two street fronts and plant on its roof. Each block's 2.5 m sidewalks carry 4 street trees a side, and on some sides bollards, a bench or a bin; each side has 6 parking places, each holding a car with a 70% chance.

/// A well-mixed 64-bit value of `seed`: SplitMix64's output function
/// (Steele, Lea and Flood, "Fast Splittable Pseudorandom Number
/// Generators", OOPSLA 2014), its constants from Sebastiano Vigna's
/// splitmix64.c, which is in the public domain.
fn mix(seed: u64) -> u64 {
    let mut value = seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
    value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
    value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
    value ^ (value >> 31)
}

/// A value in [0, 1) from `parts`, the same for the same parts.
fn random(parts: [u64; 4]) -> f64 {
    let hash = parts.into_iter().fold(0, |hash, part| mix(hash ^ part));
    (hash >> 11) as f64 / (1u64 << 53) as f64
}

// In impl Block.
/// A value in [0, 1) for `choice` about `item` of the block, the same
/// on every launch.
fn random(&self, choice: Choice, item: u64) -> f64 {
    random([self.column.into(), self.row.into(), choice as u64, item])
}
The hash, from src/scenes/town.rs. A block calls the free function random with its own column and row; the flocks call it with theirs, on a grid of their own. Choice lists what a value decides (Park, Storeys, Plaster, RoofPlant, PlantYaw and the rest), "so that no two values for one place share a seed: each choice numbers its items apart".

mix comes from SplitMix64. As a generator, SplitMix64 adds a fixed odd constant, the paper's golden gamma, to its state at each step, and returns the state scrambled by two rounds of xor-shift and multiply and a final xor-shift; Vigna's splitmix64.c gives the constants. mix(seed) is one such step from the state seed, used as a hash, so nearby seeds give values that look unrelated. random folds its four parts through mix one at a time, each combined with the hash so far, and keeps the result's high bits, as many as an f64 holds exactly, as a fraction of 1.

The four parts say where and what: the block's column and row, the Choice, and an item number for which building, roof cell, side or car the value is about. A roof cell's item is its building's index shifted above the cell's (building << 8 | cell), so each of a block's roof cells has an item of its own. The shares then turn a value into a decision: a roof cell whose value is below 0.30 holds an air conditioner, one from 0.30 up to 0.52 a vent, and so on up to the 5% of cells that stay empty.

The lamps use the same idea across the whole town. Each of the 1,600 block corners gets a random rank, the corners are sorted by rank, and the 1,024 lowest get a lamp. Which corners win depends only on their ranks, so the order in which the blocks were visited does not matter.

Why no value may depend on another

The usual alternative is one stream of random numbers, consumed in placement order. Then each value depends on how many were drawn before it. One more bench in the first block, a loop that visits a block's sides in another order, or a park share moved from 12% would shift the stream under the values after it, and the town beyond the change would come out different, at least until the stream happened to fall back into step: new Goldens and new Baselines for a change that touched one thing, with no way to tell its own effect from the shuffle. A value hashed from its own parts depends on nothing else. A change to one choice moves only what that choice decides, and the town could be placed in parallel, or Chunk by Chunk, and come out the same.

One order does matter. A value hashes its choice by its position in Choice, so a new choice joins the end of the enum, as part 3's Flock and Bird did. Inserted earlier, it renumbers every choice after it, and every value of those choices changes.

Part 2 did exactly that, on purpose, to fix a shared seed. One Yaw choice used to turn a roof's plant, the street trees and the park trees, and the plant's items (building << 8 | cell) and the street trees' (side << 8 | tree) overlap: the plant on cells 0 to 3 of building n's roof turned exactly as side n's four street trees did. Splitting Yaw into PlantYaw, StreetTreeYaw and ParkTreeYaw gave each its own seeds, and inserting the two new choices before Car renumbered Car through Lamp. The roofs, which blocks are parks and the storeys stayed as they were; the street and park trees turned differently, the cars, bollards, benches and bins came out different and the lamps moved, and the static town went from 243,907 Instances to 244,256.

Values:
PlantShareThis block
Block
Storeys, buildings 0 to 3

Live demo. One block's four roofs seen from above, north up, each a 6 × 6 grid of 2.2 m cells, placed with this page's port of mix and random to JavaScript and place_roof's shares; each plant is drawn as a symbol, not to scale. Choose a block by its column and row: a park has no roofs. Select a cell, by pointer or with the arrow keys, to see the value behind it. The second mode is this page's model of the usual alternative, not anything in town.rs: one SplitMix64 stream per block, seeded from its column and row and drawn in placement order, in which each building takes its storeys and plaster, then each cell its plant and, if it holds one, its turn. Add a value before the roofs in each mode and compare.
Chapter 3 · 19 Meshes

What the town is made of

Town draws 19 Meshes, all built by code at cook time: a ground plane, and the rest from three shapes, boxes, cylinders that narrow into cones, and a tube for the wires. One of them, a unit box drawn with a Material per Instance, serves every slab, wall, window and rail, so one BLAS holds them all. The lamps hang their lights outside their fixtures, the wires are thinner than a pixel from a few tens of metres away, and 10,000 birds circle over the roofs in flocks: the town's Moving Instances.

Town at street level: an avenue runs away from the camera between plaster buildings with dark windows, cone-shaped street trees and red and blue cars parked along both sides, utility poles and wires on the left, and a water tank on a roof against a blue sky.
Town's Metal Golden, frame 59 of its camera path, at street level in the avenue: 640 × 360, from PR #275 on #277's shadow mask, birds included, re-encoded as JPEG for this page.

Three shapes

Apart from the ground, a plane the scenes module shares (ground_plane), the generators in town.rs build every Mesh from a few parts, merged into one Primitive per Material. cuboid makes a box with its own vertices on each face, so its edges stay sharp. cylinder makes a cone's frustum from a bottom radius to a top radius, smooth round its side and capped at each end that has a radius; a top radius of 0 makes a cone, as in tree crowns, a vent's cap and a water tank's roof. wire makes the wires.

/// A cone's frustum about +Y from radius `bottom` at the origin to `top`
/// at `height`, of `sides` faces, smooth, closed by a cap at each end
/// that has a radius.
fn cylinder(bottom: f32, top: f32, height: f32, sides: u32) -> Geometry {
    let mut geometry = Geometry::default();
    // Round from +X towards −Z, so each face winds counter-clockwise
    // seen from outside.
    let around = |side: u32| {
        let angle = TAU * side as f32 / sides as f32;
        vec3(angle.cos(), 0.0, -angle.sin())
    };
    for side in 0..=sides {
        let radial = around(side);
        let normal = (radial * height + Vec3::Y * (bottom - top)).normalize();
        // … a vertex at each end of the side, both with `normal`
    }
    for side in 0..sides {
        // … one triangle per face, and a second where the top has a radius
    }
    // … a cap at each end whose radius is above 0
    geometry
}
cylinder, abridged from the generators in src/scenes/town.rs. Each vertex of the side takes the normal of the slanted side: the radial direction scaled by the height, tilted up by how much the radius shrinks. Neighbouring faces share it, so the side shades smooth.

One box, many Materials

Slabs, buildings, windows and rails are all one Mesh, TownMesh::Box, "a unit cube on its base, scaled per Instance and drawn with its Instance's Material". The spawn gives each box a MaterialRef, which overrides the Mesh's own Materials, as a Renderable may: concrete or grass for the slabs, one of four plasters for the walls, glass for the windows and steel for the rails. Every other Mesh's Primitives name their own Materials. A BLAS is built per Mesh, so all the boxes share one, and the TLAS places it once per Instance, with that Instance's scale.

Windows

Each building has windows on its two street fronts, 4 columns per storey, each a box of 1.4 × 1.8 × 0.12 m centred on its storey. Half of each box's depth stands proud of the wall, so its glass is a surface of its own in front of the plaster rather than one in the same plane.

Roofs

Each roof has a railing of 20 posts a side and a rail along each side, and inside it a 6 × 6 grid of 2.2 m cells. Each cell's plant comes from a random value and the shares of chapter 2's demo: an air conditioner 30% of the time, a vent 22%, a solar panel 24%, a water tank 6%, a dish 7% and an aerial 6%, leaving 5% of cells empty. Each plant also takes a random turn about the vertical.

Streets

Along each side of a block, the sidewalk carries 4 street trees; 30% of sides have a row of bollards every 2 m, 30% a bench and 40% a bin. In the street, 6 parking places a side each hold a car with a 70% chance, red or blue. Utility poles stand every 25 m on one side of every street, in both directions, and each span between two poles holds 2 wires, 7.6 and 7.0 m up.

Lamps: the light outside the fixture

The 1,024 lamps stand at block corners, each with its arm over the street, and each holds one point light, hung 0.15 m below the lamp's head and so outside the lamp's Mesh. #205, which gave point lights their shadow rays, asked for that placement. Each point light traces its own shadow ray from the lit surface to the light, and a light inside its fixture's Mesh would meet the fixture on every ray: hidden from everything it lights.

Each light is warm white, (1.0, 0.85, 0.65), with a range of 18 m. Its intensity is 20 times the sun's illuminance, read in candela: a point light of I candela gives I / d2 lux at d metres, so at 4.5 m it gives about the sun's 100,000 lux, and nearly as much on the ground below, so its pool of light shows in daylight.

Wires: thinner than a pixel

A wire hangs from one pole to the next as a tube of 3 faces, 1.5 cm in radius, over 15 straight segments, sagging 0.6 m at mid-span along a parabola. A hanging cable's exact shape is a catenary; for a sag this small against its span, the parabola is the catenary's shallow-sag form. From a few tens of metres away, the wire is thinner than a pixel, which makes it town's test of 4x MSAA (chapter 7).

/// A wire hung from the origin to `span` along +X, sagging `sag` at
/// mid-span along a parabola, the catenary's shallow-sag form: a tube of
/// `sides` faces and `radius` over `segments` straight lengths.
fn wire(span: f32, sag: f32, radius: f32, segments: u32, sides: u32) -> Geometry {
    let mut geometry = Geometry::default();
    for segment in 0..=segments {
        let along = segment as f32 / segments as f32;
        let centre = vec3(span * along, -4.0 * sag * along * (1.0 - along), 0.0);
        let tangent = vec3(span, -4.0 * sag * (1.0 - 2.0 * along), 0.0).normalize();
        let normal = Vec3::Z.cross(tangent);
        for side in 0..=sides {
            let angle = TAU * side as f32 / sides as f32;
            let outward = normal * angle.cos() + Vec3::Z * angle.sin();
            geometry.positions.push((centre + outward * radius).into());
            geometry.normals.push(outward.into());
            geometry.uvs.push([side as f32 / sides as f32, along]);
        }
    }
    // … two triangles per face of each segment, joining each ring to the next
    geometry
}

// In meshes(): one span between poles.
wire(super::POLE_SPACING as f32, 0.6, 0.015, 15, 3)
wire, abridged. centre follows the parabola −4 · sag · u(1 − u) over the span's fraction u, lowest at mid-span, and tangent is its slope, so each ring of the tube stands square to the wire.

Flocks: the Moving Instances

Part 3 adds town's Moving Instances: 100 flocks on a 10 × 10 grid 100 m apart, each centre up to 20 m from its cell's middle along each axis, which keeps the whole flock inside its cell, and 30 to 55 m up, clear of every roof's plant, turning at 0.2 to 0.6 rad a second one way or the other. Each flock is 100 birds, 24-triangle pigeons of two boxes, a body and a pair of wings, placed 6 to 30 m from its centre, evenly over the ring's area, and up to 4 m above or below it, each facing the way it circles. All of it comes from the same hash, with the flock's column and row on its own grid and the choices Flock and Bird.

No new system moves them. A flock's centre is an entity with the existing Spin component, which the fixed schedule turns about +Y, setting each rotation from the Tick count so that no rounding accumulates. Each bird is the centre's child (ChildOf), so it circles as the centre turns. The centre and every bird carry PreviousWorldTransform, which ADR 0022 asks of every entity whose World transform the fixed schedule changes, children included. So frames draw them blended between their two newest Ticks, and each bird is a Moving Instance. There are exactly 10,000, the capacity of the dynamic TLAS, and a compile-time assertion keeps the flocks' count equal to it, so town always measures the dynamic TLAS full.

// In spawn_town, after the static Instances and the lamps' lights.
let bird = meshes[TownMesh::Bird as usize];
for flock in flocks() {
    let centre = world
        .spawn((
            Transform::from_translation(flock.centre),
            Spin {
                radians_per_second: flock.radians_per_second,
            },
            PreviousWorldTransform::default(),
        ))
        .id();
    world.spawn_batch(flock.birds.into_iter().map(|transform| {
        (
            transform,
            ChildOf(centre),
            MeshRef(bird),
            PreviousWorldTransform::default(),
        )
    }));
}

// In src/scenes/mod.rs, the system that turns every Spin.
/// Sets each spinning entity's rotation for the end of the running Tick, from
/// the Tick count, so no rounding accumulates over Ticks.
pub(crate) fn spin(fixed_time: Res<FixedTime>, mut spinning: Query<(&Spin, &mut Transform)>) {
    let seconds = (fixed_time.tick + 1) as f64 * TICK_SECONDS;
    for (spin, mut transform) in &mut spinning {
        let angle = (spin.radians_per_second * seconds).rem_euclid(std::f64::consts::TAU);
        transform.rotation = glam::Quat::from_rotation_y(angle as f32);
    }
}
The flocks, from part 3's spawn_town, and the Spin system they reuse. A centre has no MeshRef, so it is no Instance; its birds are. A bird's Transform places it about the centre, which its parent's spin carries round.

Why rigid flocks

A flock is rigid: its birds keep their places about the centre, and the whole ring turns. That keeps the simulation to almost nothing. Each Tick, spin sets 100 rotations, and the fixed schedule's last system, propagate_world_transforms, composes each bird's World transform from its parent's, as it does for any entity whose ancestor moved. The renderer cannot tell the difference. Prepare rewrites all 10,000 bird Instances every frame, 128 bytes each, and the dynamic TLAS is rebuilt every frame from 10,000 entries, each placed by its own transform, just as if every bird flew a path of its own. What town measures is the renderer's cost of 10,000 Moving Instances, not a flocking simulation's, and it measures that at the load M3's sixth exit criterion judges the dynamic TLAS at.

Town from about 34 m up on the rise: rooftops crowded with water tanks, air conditioners and solar panels stretch to the horizon on both sides of the avenue, and flocks of birds speckle the sky over the left-hand blocks. A crop of the same frame's sky: scattered small dark birds, each a body with a pair of wings, spread across it in loose groups.
Frame 500, on the rise, with the camera about 34 m up, level with the flocks, and below it a crop of its sky. From PR #275 on the shadow mask, captured at 1440p on Metal and scaled to 1280 × 720 there; the frame is re-encoded as JPEG for this page.

Seen from the street, the birds barely register: they change 35 of the Golden's 230,400 pixels, by up to 81 8-bit steps in a channel, on Metal and on software Vulkan alike. Their cost is in the dynamic TLAS and the rays traced against it, not in drawing. In full runs on the M4 Pro they add about 5 ms to the GPU frame, most of it the dynamic TLAS's build as the frame times it, and about 10,000 heap allocations a frame; #207 later found that the build ran almost wholly alongside the previous frame's lit and tonemapping passes, so the frame counted that time twice. Chapter 8 has the figures.

TownInstancesTriangles
Static, as part 2 places it244,2569,754,190
With part 3's flocks254,2569,994,190
Everything town places, as tests/frame_stats.rs pins it in closed form: with culling off, every frame draws exactly these, and none overflows the draw. The static TLAS holds 218 = 262,144 slots, and Moving Instances take slots too, so the town fits with 7,888 to spare.

What checks it

  • tests/goldens.rs: town joins the scenes captured there, so its Golden is checked, culling off against on, Froxel lights against brute force with shadow rays off, and the 100 km shift by whole 64 m cells, on Metal and on software Vulkan. When part 3 merged, the two comparisons ran at frame 59; since #298, merged on 2026-10-26 as fd2ee9b, they run at eight frames along town's path, from 59 to 1,800, and on software Vulkan the culling comparison allows one differing pixel a frame.
  • tests/frame_stats.rs: the closed-form counts above with culling off, so the frame's draw holds the whole town and nothing overflows; and after the first frame, the birds' 10,000 Instances written every frame, none of them left out of the dynamic TLAS, and the frame's uploads to the byte.
  • src/cook/mesh.rs's tests: every Reference scene's Mesh, town's included, cooks into valid Meshlets.
Chapter 4 · camera_at

The camera path

A Reference scene's figures are medians over its camera path, so the path decides what they measure. Town's path starts at eye level in a street, rises over the rooftops and circles over the town, 480 m from its middle and 260 m up, and its position and speed never jump. The bench measures frames 248 to 2047, and from frame 780 on the camera is on the circle, so most measured frames, and the median frame that the Budgets and the gate are judged on, look out over the town rather than down one street.

FramesPhaseThe camera
0 to 419StreetAt eye level in the avenue's right-hand lane at X = 0, from (2, 1.7, 445), moving north (−Z) at 4 m/s and looking straight ahead.
420 to 779RiseA cubic Hermite from the street's end, leaving at the street's speed, up and back over the rooftops to (0, 260, 480), arriving at rest. The gaze turns from far up the avenue to the town's middle.
780 onCircleRound the town's middle, 480 m out, inside the town's edge, and 260 m up, facing the middle, eased in over 120 frames to 0.0006 rad a frame.
The path's three phases, at 60 Hz, with positions in metres.
/// The camera's path: at street level up the avenue at X = 0, facing north
/// (−Z), for `STREET_FRAMES`; then for `RISE_FRAMES` up and back over the
/// rooftops to a circle round the town's middle, inside its edge, at
/// `ORBIT_HEIGHT`, turning to face its middle; then round the circle,
/// facing the middle. Its position and its speed change smoothly
/// throughout.
const STREET_FRAMES: u32 = 420;
const RISE_FRAMES: u32 = 360;
/// In the avenue's right-hand lane, at eye height, and moving at 4 m/s.
const STREET_START: glam::DVec3 = glam::DVec3::new(2.0, 1.7, 445.0);
const STREET_SPEED: f64 = 4.0 / 60.0;
const ORBIT_RADIUS: f64 = 480.0;
const ORBIT_HEIGHT: f64 = 260.0;
/// Radians a frame round the circle, reached over `ORBIT_EASE_FRAMES`.
const ORBIT_SPEED: f64 = 0.0006;
const ORBIT_EASE_FRAMES: f64 = 120.0;

/// The camera at `frame` of its path.
pub(crate) fn camera_at(frame: u32) -> Camera {
    let frame = f64::from(frame);
    let street_end = STREET_START - glam::DVec3::Z * (STREET_SPEED * f64::from(STREET_FRAMES));
    let orbit_frame = frame - f64::from(STREET_FRAMES + RISE_FRAMES);
    if frame < f64::from(STREET_FRAMES) {
        let position = STREET_START - glam::DVec3::Z * (STREET_SPEED * frame);
        Camera::looking_at(position, position - glam::DVec3::Z)
    } else if orbit_frame < 0.0 {
        // A cubic from the street's end, leaving at the street's speed, to
        // the circle's start, arriving at rest; the gaze turns from far up
        // the avenue to the town's middle.
        let progress = (frame - f64::from(STREET_FRAMES)) / f64::from(RISE_FRAMES);
        let orbit_start = glam::dvec3(0.0, ORBIT_HEIGHT, ORBIT_RADIUS);
        let leaving = -glam::DVec3::Z * (STREET_SPEED * f64::from(RISE_FRAMES));
        let squared = progress * progress;
        let cubed = squared * progress;
        let arrived = 3.0 * squared - 2.0 * cubed;
        let position = street_end * (1.0 - arrived)
            + leaving * (cubed - 2.0 * squared + progress)
            + orbit_start * arrived;
        let up_the_avenue = street_end - glam::DVec3::Z * TOWN_HALF_SIZE;
        Camera::looking_at(position, up_the_avenue * (1.0 - arrived))
    } else {
        // At rest on arrival, then at `ORBIT_SPEED`, with no jolt between.
        let eased = if orbit_frame < ORBIT_EASE_FRAMES {
            orbit_frame * orbit_frame / (2.0 * ORBIT_EASE_FRAMES)
        } else {
            orbit_frame - ORBIT_EASE_FRAMES / 2.0
        };
        let angle = ORBIT_SPEED * eased;
        let position = glam::dvec3(
            ORBIT_RADIUS * angle.sin(),
            ORBIT_HEIGHT,
            ORBIT_RADIUS * angle.cos(),
        );
        Camera::looking_at(position, glam::DVec3::ZERO)
    }
}
camera_at, from src/scenes/town.rs. TOWN_HALF_SIZE is half the town's side, so up_the_avenue lies 500 m up the avenue from the street's end, 83 m north of the town's middle. The circle's radius is 20 m short of that half side, so the whole circle lies over the town, inside its edge.

No cuts

The rise is a cubic Hermite curve: the cubic fixed by a position and a velocity at each end. With t its progress from 0 to 1, p0 the street's end, p1 the circle's start, and m0 the street's velocity times the rise's length in frames:

position(t) = p0 (2t³ − 3t² + 1) + m0 (t³ − 2t² + t) + p1 (3t² − 2t³) = p0 (1 − arrived) + m0 (t³ − 2t² + t) + p1 arrived, arrived = 3t² − 2t³ position'(0) = m0 position'(1) = 0

The fourth term of the Hermite form, the velocity at the far end, is zero, so the rise leaves at the street's speed and arrives at rest. The code names the weight of the circle's start arrived, since it grows from 0 to 1 as the camera arrives, and the gaze uses it too, to slide its target from far up the avenue to the town's middle. On the circle, the angle grows with the square of the frames for the first 120, so the speed rises evenly from rest to 0.0006 rad a frame, and then stays there. Position and speed are continuous from the first frame to the last, as the demo's speed plot shows.

Why the median frame is an orbit frame

The bench renders the frames before 248 to let allocations and the GPU's clocks settle, then measures frames 248 to 2047 at 2560 × 1440. The street ends at frame 419 and the rise at 779, so every measured frame from 780 to 2047 is on the circle, and those are most of the run. The median frame is therefore one round the town, and that is the frame M3's first exit criterion and ADR 0019's gate are judged on: the Baseline's median draws 137,730 of the town's 254,256 Instances, more than half. The Golden's frame, 59, is a street frame the bench's timings never include: town's Golden checks the image, and its Baseline the median. Every run also records the counters of frames 8 to 71, street frames too, so that runs of any length compare on them, and CI benches town on those alone (--ci-counters, at 640 × 360), since town is too dense for CI's software Vulkan to render its usual run in time.

From above, north up

From the side: height against north and south

Speed along the path, frame by frame: drag along it to move the camera

street rise circle, to frame 2047 the circle beyond frames the bench measures
Go to:
Phase
Position (x, y, z), m
Speed
The bench
Live demo. The path drawn from camera_at's formulas, ported to JavaScript, frame by frame from 0 to 2047, the last frame the bench measures; the circle continues past it, dotted. The side view's two axes have their own scales. Speeds are this page's differences between consecutive frames, at 60 Hz. Frames 59, 500, 1,059 and 1,859 are those whose counts chapters 5 and 6 report.
Chapter 5 · DrawCapacity

Culling at target scale

Each frame, the culling pass builds the frame's draw on the GPU from the Instances whose bounds reach into the view and that the Hi-Z pyramid does not find hidden. In the street it draws 5,608 of the town's 254,256 Instances; in the bench's median frame, round the town, it draws 137,730, clear of M3's floor of 100,000. The draw must also hold the whole town with culling off, which the Goldens compare with culling on, so part 1 doubled its capacity to 225 packed indices: 128 MiB, the most one storage binding holds at the Capability floor.

FrameInstances drawnTriangles drawn
59, street level, the Golden's5,608203,802
500, rising, level with the flocks77,9823,044,818
The median over the bench's frames, round the town137,7305,552,214
Any frame, culling off254,2569,994,190
1,059, round the town, before the birds136,6955,615,222
1,859, round the town, before the birds129,2845,307,120
What the culling pass draws, with culling on at 2560 × 1440 on the M4 Pro, birds included, except in the last two rows, counted on part 2's town before the birds, in a local build. The median is the Baseline's, over the bench's 1,800 measured frames: M3's first exit criterion asks at least 100,000 Instances and 5,000,000 triangles of it. The culling-off row is the whole town.

Why the whole town must fit

The draw is a list of packed indices, each a u32 holding a Meshlet record in its high bits and a vertex within that Meshlet in its low bits (ADR 0019), three to a triangle. The old capacity, 224 indices, holds 5.59 million triangles, and the whole town is 9.99 million with the flocks, so with culling off the old draw would overflow. The culling pass then ends the draw at the last Instance that fits, and Frame stats count the rest as Instances the draw had no room for. The image with culling off would lose them all and differ from the image with culling on. 225 indices hold 11.18 million triangles, room for the whole town.

/// What the frame's draw holds, provisional until M4's Budget split sizes it.
#[derive(Clone, Copy)]
pub struct DrawCapacity {
    pub meshlet_records: u32,
    pub packed_indices: u32,
}

impl DrawCapacity {
    /// 2^25 packed indices, 128 MiB, the most one storage binding holds at
    /// the Capability floor (ADR 0021), draw 11.2 million triangles: all of
    /// M3's dense scene with culling off, as its Goldens compare (#206).
    /// 2^20 Meshlet records, 12 MiB, are as many Meshlets of 10.7 triangles
    /// on average.
    pub const PROVISIONAL: DrawCapacity = DrawCapacity {
        meshlet_records: 1 << 20,
        packed_indices: 1 << 25,
    };
}
DrawCapacity::PROVISIONAL, from src/render/culling_pass.rs in part 1, which changed packed_indices from 1 << 24.
The draw's capacity against the whole town, in triangles, drawn to scale from 0 to 11.18 million. The dashed line is the town with the flocks, culling off: past the end of the old capacity, inside the new.

The ceiling: one storage binding

The culling pass writes the indices through one storage binding, and ADR 0021 keeps max_storage_buffer_binding_size at wgpu's default of 128 MiB, which is Vulkan's minimum and lavapipe's maxStorageBufferRange. lavapipe is CI's software Vulkan, which renders every Golden on that backend, so a floor above its limit would refuse it. 225 indices of 4 bytes fill the 128 MiB exactly: the draw cannot grow again without splitting it across bindings or raising the floor. Its size stays provisional until M4 splits the GPU memory Budget.

Chapter 6 · the light count pass

Lights per lit pixel, and the side-effect trap

Town's 1,024 point lights are culled into Froxels, and the lit pass shades each pixel with the lights its Froxel lists. To see what that costs, part 1 counts, at every lit pixel, the lights listed there and those whose range actually reaches the surface. The first attempt counted inside the lit pass and counted up to 4.7 times the pixels there were, because a shader with side effects runs for fragments that the depth test then throws away. The count now runs in a compute pass after the frame, from the depth the lit pass keeps.

Counting in the lit pass

The lit pass already walks each pixel's Froxel list, so the first attempt added atomics to its fragment shader. With 4x MSAA, a pixel on a triangle's edge runs the shader once for each triangle that covers part of it, so the count was kept to the fragment that covers the pixel's first sample, sample 0. On a 48 × 48 test of one surface, that counted exactly. On town, at 640 × 360 and counting a lattice of 1 pixel in 16, it counted 2.8 times as many pixels as the lattice holds with culling on, and 4.7 times with culling off.

Why: side effects move the depth test late

A GPU would rather test depth before it runs a fragment shader and skip the shader for fragments that fail, and it may do so whenever nothing could tell. A shader whose only outputs are its colours can be skipped for a rejected fragment, since those outputs would be thrown away. A shader with side effects, such as atomics or storage writes, cannot be skipped unseen: its writes would be missing. For such a shader the depth test comes after the shader, which is called late-Z. Every fragment the lit pass rasterises then runs the shader, atomics included, and only afterwards does the depth-equal test reject the hidden ones.

The Vulkan specification states the rule. It lists the depth test after fragment shading and moves it before only for a shader that declares the EarlyFragmentTests execution mode, and it lets an implementation skip a shader for a fragment that a later operation discards only where "the shader does not write to any storage resources". The counts were taken on the M4 Pro, through Metal, and show the same there: hidden fragments ran the counting shader. Apple GPUs render in tiles and normally work out which fragment is frontmost at each pixel before shading, so that only visible fragments shade, which is called hidden-surface removal. This guide found no Apple document that says what becomes of it for a shader with side effects; what the measurement shows is that it did not keep the hidden fragments' atomics from running. Town makes the overcount large, because its lit pass draws many hidden surfaces behind each visible one: everything both culling phases keep, and with culling off, everything.

the visible surface a hidden surface its fragment ran the shader rejected by the depth test
Shader:
Fragments rasterised
Fragment shader runs
This pixel counted

Live demo. One pixel of the lit pass, seen from the side: its view ray passes through every surface the pass draws at that pixel, nearest first. The depth pre-pass has already stored the nearest surface's depth, and the lit pass keeps only fragments at depth equal to it. Change the number of surfaces and the kind of shader to see which fragments run the shader and how often the pixel is counted. This page's model: one fragment per surface, at one sample.

The fix WGSL cannot reach on Metal

Standard WGSL has no way to force the test early, but naga, the shader translator wgpu uses, adds one: its @early_depth_test(force) attribute, the counterpart of SPIR-V's EarlyFragmentTests and GLSL's layout(early_fragment_tests). It needs Features::SHADER_EARLY_DEPTH_TEST, which wgpu 30 offers on Vulkan and GL only, and naga's Metal writer emits no [[early_fragment_tests]], Metal's own form of it. So on the M4 Pro, where the Budgets are judged, Metal has the fix, but a WGSL shader in wgpu 30 cannot ask for it.

Counting after the frame instead

So part 1 counts in a compute pass after the frame's others, the light count pass, behind a Render switch, count_point_lights, which praxis capture turns on and the shipping default leaves off. The lit pass normally discards its depth at the end (StoreOp::Discard); with the switch on, it stores it. One workgroup counts each 8 × 8 tile of pixels. Each invocation reads its pixel's depth at sample 0, and where the frame drew a surface, it rebuilds the surface's point, finds its Froxel from the sample's place in the pixel grid, the target's size and the view depth (froxel_at), counts the lights the Froxel lists, and, of those, the lights whose range contains the point. The workgroup sums its pixels' counts in workgroup memory, and one invocation adds each sum to the frame's light counts: one atomic add per count per workgroup. The result is exact on every backend, and the lit pass that ships is untouched.

// Where a pixel's first sample lies within it: WebGPU's standard 4x
// pattern puts sample 0 at (0.375, 0.125).
const FIRST_SAMPLE: vec2<f32> = vec2<f32>(0.375, 0.125);

var<workgroup> lit_pixels: atomic<u32>;
var<workgroup> listed_point_lights: atomic<u32>;
var<workgroup> reaching_point_lights: atomic<u32>;

// The point relative to the camera at clip-space position `clip`: the
// solution of view_proj's x, y and w rows, which hold no translation (ADR
// 0014), by Cramer's rule in cross products.
fn camera_relative_point(clip: vec4<f32>) -> vec3<f32> {
    let rows = transpose(camera.view_proj);
    let x_row = rows[0].xyz;
    let y_row = rows[1].xyz;
    let w_row = rows[3].xyz;
    let y_cross_w = cross(y_row, w_row);
    return (clip.x * y_cross_w + clip.y * cross(w_row, x_row) + clip.w * cross(x_row, y_row))
        / dot(x_row, y_cross_w);
}

@compute @workgroup_size(LIGHT_COUNT_TILE_SIZE, LIGHT_COUNT_TILE_SIZE)
fn count_point_lights(
    @builtin(global_invocation_id) pixel: vec3<u32>,
    @builtin(local_invocation_index) invocation: u32
) {
    let size = textureDimensions(depth);
    if all(pixel.xy < size) {
        // The near plane's distance over the view depth (ADR 0015), and 0
        // where the frame drew nothing; clip z is the near plane's distance.
        let pixel_depth = textureLoad(depth, pixel.xy, 0);
        if pixel_depth > 0.0 {
            let near = camera.view_proj[3].z;
            let view_depth = near / pixel_depth;
            let sample = vec2<f32>(pixel.xy) + FIRST_SAMPLE;
            let normalised = vec2<f32>(
                sample.x / f32(size.x) * 2.0 - 1.0,
                1.0 - sample.y / f32(size.y) * 2.0
            );
            let clip = vec4<f32>(normalised * view_depth, near, view_depth);
            let position = camera_relative_point(clip);
            let froxel = froxel_at(sample, vec2<f32>(size), view_depth);
            let listed = froxel_lists[froxel].light_count;
            var reaching = 0u;
            for (var entry = 0u; entry < listed; entry++) {
                let point = lights[froxel_lists[froxel].lights[entry]];
                let to_light = point_light_position(point, camera.anchor, camera.offset) - position;
                reaching += u32(dot(to_light, to_light) < point.range * point.range);
            }
            atomicAdd(&lit_pixels, 1u);
            atomicAdd(&listed_point_lights, listed);
            atomicAdd(&reaching_point_lights, reaching);
        }
    }
    workgroupBarrier();
    if invocation == 0u {
        atomicAdd(&light_counts.lit_pixels, atomicLoad(&lit_pixels));
        atomicAdd(&light_counts.listed_point_lights, atomicLoad(&listed_point_lights));
        atomicAdd(&light_counts.reaching_point_lights, atomicLoad(&reaching_point_lights));
    }
}
The light count pass's shader, src/render/shaders/light_count.wgsl, as part 1 merged it, without its opening comment. The depth target is multisampled, so textureLoad's last argument is the sample, 0; the atomics here count in workgroup memory, and only the last three lines touch the frame's light counts.

Rebuilding the surface point

Depth in Praxis is reversed, with no far plane (ADR 0015): the projection gives clip z = near and clip w = the view depth, so the depth stored is near / view depth, and 0 where nothing was drawn. The pass inverts it, view depth = near / depth. The sample's position in normalised device coordinates, times the view depth, gives clip x and y. The camera-relative point p then satisfies three linear equations, one for each row of view_proj that depends on it; positions reach the GPU relative to the camera, so no row holds a translation:

clip.x = x_row · p clip.y = y_row · p clip.w = w_row · p p = ( clip.x (y_row × w_row) + clip.y (w_row × x_row) + clip.w (x_row × y_row) ) / ( x_row · (y_row × w_row) )

That is Cramer's rule for three equations, written with cross products: the denominator is the determinant of the three rows, and each cross product is a column of their inverse times it. WGSL has no matrix inverse and the camera uniform carries none, so the shader solves the three equations directly. It rebuilds the point where sample 0 lies in the pixel, from WebGPU's standard 4x pattern, since that is the sample whose depth it read.

Listed and reaching

A Froxel lists every point light whose range reaches anywhere into its bounds, so a listed light can still miss a given point inside it. glTF's KHR_lights_punctual requires an engine to ignore a light beyond its range, so a light out of range of the point adds nothing there, though the lit pass still loops over it; the pass that fills the shadow mask skips it without tracing a ray. The light count pass counts both.

An oracle test, the_light_count_pass_counts_the_point_lights_each_lit_pixel_lists_and_is_reached_by, renders a white quad over the left half of a 48 × 48 view. The pass counts 1,152 lit pixels. With 3 lights in front reaching past the quad, it counts 3,456 listed and 3,456 reaching. With 1 light just behind the quad, inside its Froxels' slice of depth but out of range of the quad, the light is listed, 70 times in one run, and reaches none. Each wrong version was shown to fail: counting the background too (depth ≥ 0) gives 2,304 lit pixels, and ignoring range counts the 70 as reaching.

FramePoint lights listed per lit pixelReaching per lit pixel
59, street level1.090.64
500, rising, level with the flocks3.040.37
1,059, round the town, before the birds6.590.77
1,859, round the town, before the birds6.290.76
Lights per lit pixel, from the light count pass at 1440p on the M4 Pro, counted at every pixel's first sample; praxis capture prints them, with the lit pixels: 3,354,085 at frame 59. Frames 59 and 500 carry the birds; the last two rows were counted on part 2's town, before them.

Round the town, each lit pixel's list holds 6.59 lights on average at frame 1,059 and 6.29 at 1,859, and only 0.77 and 0.76 of them reach it. A Froxel is a fixed rectangle of the screen and a slice of depth, and the slices lengthen with each doubling of depth, so from the circle a far Froxel holds a large piece of the town and lists many of its lamps, most of which reach none of its pixels. The lit pass loops over every one of them, and so does the pass that fills the shadow mask, though it traces a ray only for the lights in range that the surface faces. The lights the mask had no bit for, which frame stats count, were none at frames 59 and 500. In the street the lists are short, 1.09 lights listed and 0.64 reaching, and on the rise they lengthen while fewer of their lights reach: 3.04 listed, 0.37 reaching.

Chapter 7 · ADR 0019's 4x MSAA

MSAA at the wires

A wire 1.5 cm in radius is thinner than a pixel from a few tens of metres away. With one sample per pixel, a pixel shows the wire only where the wire crosses that pixel's one sample, so a far wire breaks into dashes, and one far enough away disappears. With 4x MSAA each pixel tests coverage at four places and blends what they find, so a thin wire reaches more pixels, each partly covered, and stays continuous as long as it is thicker than the gaps between those places: for a level wire, the quarter of a pixel between one row of samples and the next. M3's exit asks for a one-sample render to fail the thin-geometry Golden, and town's wires make it fail.

PR #273 checked this before its rebase onto #277, on main 0743418, with a scratch patch that renders with one sample: SAMPLE_COUNT 1, the lit pass writing the HDR target directly instead of resolving into it, and depth read with one sample. Town's Golden then fails at 14,124 of its 230,400 pixels, by up to 231 8-bit steps in a channel. The difference runs along every edge in the frame, not only the wires, since every silhouette changes when its pixels lose their partial coverage.

Town's street-level frame, darkened, with every pixel that one sample changes by more than one 8-bit step in red: thin red outlines trace every building, window, tree, car, pole and wire.
One sample against the Golden, before #277: the one-sample render darkened, and in red the 14,124 pixels where it differs from part 2's Golden of the time by more than the Golden's tolerance of one 8-bit step, along every edge.

At the wires the change is larger than a softer edge. At 4x the wires run as faint lines, each pixel showing the share of its samples a wire covers, and break only in places: a pixel or two here and there, about 20 pixels at mid-span on the upper wire across the street, which is about 48 m away and about 0.2 of a pixel thick, and runs level there between two rows of samples, and gaps of up to 13 pixels in the next pair up the avenue, about 98 m away and about half as thick, near the foot of the crop below. With one sample they break into dashes, and of the pair across the street only short dashes are left, one of them alone against the sky.

Two enlarged crops of the same street corner side by side. On the left, at 4x MSAA, wires run from a utility pole as thin, almost unbroken lines, and two faint wires cross the street, the upper one with a gap in the middle. On the right, with one sample, the same wires are broken into separate dashes, and of the wires across the street only short dashes are left, one of them alone against the sky.
The wires, at 4x on the left and with one sample on the right: the same crop of the Golden's frame, enlarged.

This is the kind of image ADR 0019 chose MSAA for. A post-process pass such as SMAA 1x "cannot reconstruct sub-pixel geometry": it works from the finished image, in which a missed wire is absent. The main pass reads no screen-space history either, so no earlier frame can fill the gaps. 4x MSAA has to catch the wire in every frame, and town's Golden now fails where it does not.

Chapter 8 · the Baseline on the shadow mask, for #207

What it costs

Part 3's M4 Pro Baseline, re-recorded after the rebase onto #277, is a full run of 2,048 frames at 1440p, its medians taken over the 1,800 frames the bench measures. The median frame draws enough for M3's first exit criterion, but takes 17.6 ms on the GPU, 1.5 times the 12 ms Budget, and 16.5 ms on the CPU, 2.7 times the 6 ms; its 617 MB of GPU memory are well inside 8 GB. Before #277, with the shadow rays traced inside the lit pass, the same frame took 92.7 ms on the GPU, 86.9 of them in the lit pass. The birds add about 5 ms, most of it the dynamic TLAS's build as the frame times it, which #207 later found mostly overlaps the previous frame's passes: with the build timed from the end of that frame's last pass, the GPU frame is about 13.6 ms, still past the 12 ms Budget (see the table's note). #207 judges these figures, and M3's exit re-decides ADR 0013's Budgets against them. On main, #290 has since re-recorded this Baseline with the same counts, reading 18.1 ms on the GPU and 20.2 on the CPU. (Since #299, merged on 2026-10-26 as 800c0d2, it reads 13.6 ms on the GPU and 17.7 on the CPU, with the same counts.)

The median frame against its Budgets, each row to its own scale: from 0 to the p95 for the two times, and to the Budget for memory. The dashed line is the Budget, red marks a median past it, and the pale band reaches to the p95.
GPU, in the order the frame runsMedian (p95)
The dynamic TLAS's build4.11 ms (4.52)
Culling, phase one1.51 ms
Froxel pass0.52 ms
Depth pre-pass1.76 ms
Depth pyramid0.25 ms
Culling, phase two0.90 ms
Phase two's depth0.05 ms
Shadow mask pass4.66 ms (5.09)
Lit pass8.69 ms (9.25)
Tonemapping pass3.82 ms
GPU frame17.6 ms (18.5)
The median frame's GPU time, from bench/baselines/apple-m4-pro/town.json; the bend pass, with nothing to bend in town, is left out. The GPU frame is the dynamic TLAS's build plus the span from the start of the first pass to the end of the last (since #291, merged on 2026-10-26 as c5a12eb, from the earliest start to the latest end of the frame's passes, as Metal can start the Froxel pass before culling phase one). The passes' medians add up to 26.3 ms, more than the frame's 17.6, so on the M4 Pro some passes overlap. The build's timed span starts at the end of the previous frame's shadow mask pass, so it likely overlaps that frame's lit pass and reads high, the GPU frame with it. #207 confirmed it on 2026-10-26: the build ran almost wholly alongside the previous frame's lit and tonemapping passes. Timed from the end of that frame's last pass, it reads 48 µs; run alone, with one frame in flight, it takes 665 µs; and the GPU frame is about 13.6 ms. With the rays inside the lit pass, the build read 0.83 ms. (Since #299, merged on 2026-10-26 as 800c0d2, the build's timed span starts at the end of the previous frame's last pass, and in town's Baseline it reads 48.6 µs.)

The shadow mask

Until #277, the lit pass traced each pixel's shadow rays inline, and nearly all of town's GPU frame was the lit pass: 86.9 of 92.7 ms. #277 moved the rays into a compute pass of their own. Once both culling phases' Instances are in the depth target, phase two's drawn there by a depth pass of their own, it takes each pixel's nearest sample, rebuilds that surface's position from its depth and its face normal from the neighbouring pixels' depths, and goes through the pixel's lights in the order the lit pass sums them. A point light out of range is skipped, a light the surface faces away from is hidden with no ray, and any other gets one ray: against the static TLAS and, only where that finds nothing, the dynamic TLAS. A bit for each hidden light, up to 59 a pixel, goes into the shadow mask, which the lit pass reads; the lit pass has no ray queries left. ADR 0019, amended for it, records why: traced inline, the rays cost the M4 Pro's GPU frame 2.4 to 13.4 times what they cost from the mask, on every Reference scene but texture-wall, and a ray query anywhere in the lit pass's shader cost it 0.9 to 3.9 ms even where it never ran. In town the mask now takes 4.66 ms and the lit pass 8.69.

The mask changed town's Goldens too: part 2's at 7,548 pixels on Metal and 6,433 on software Vulkan beyond the Goldens' tolerance of one 8-bit step, by up to 232 in a channel, along geometry and shadow edges, since at an edge pixel every surface's fragment now reads the bits traced from the pixel's nearest sample (ADR 0019). ADR 0019's gate is still timed from the first culling dispatch to the end of the lit pass with shadow rays off, which a Baseline traced with rays on does not give: #207 measured it on 2026-10-26 at 9.61 ms median (p95 10.15), a breach, so tickets #296 and #297 will prototype options B and D on town, and M3 cannot exit until they are done and the project's owner decides. (Since #299, merged on 2026-10-26 as 800c0d2, bench runs each scene again with shadow rays off and records the gate in its Baseline: town's, from a later run, reads 9.82 ms, p95 10.48.)

What the birds cost

To see what the flocks cost, town was benched four times back to back on the shadow mask, alternating with and without the birds, while the machine's CPU was heavily loaded, so the runs' CPU times are left out. The birds add about 5 ms to the GPU frame, though few triangles: without them the median frame still draws 131,470 Instances and 5,402,310 triangles, against 137,730 and 5,552,214 with them.

Four full runs, alternatingWith the birdsWithout
GPU frame, median17.6 and 17.5 ms12.4 and 12.5 ms
Shadow mask pass, median4.64 and 4.64 ms3.97 and 4.00 ms
Lit pass, median8.69 and 8.70 ms7.78 and 7.85 ms
Heap allocations a frame10,478341
What the birds cost, at 1440p on the M4 Pro, on the shadow mask: two full runs with them and two without, back to back.

About 1.0 ms of the difference is the frame's own passes running longer; the rest is the dynamic TLAS's build, 4.1 ms as the frame times it, most of which, as the table's note says, overlaps the previous frame's passes; a frame with no Moving Instances does not run it. The shadow mask pass's share comes from its rays: every ray the static TLAS lets through is also traced against the dynamic TLAS, whose 10,000 entries the flocks spread over the whole town, while with no Moving Instances that second query is skipped. And nearly all of a frame's heap allocations are the birds': 10,478 with them, 341 without.

The CPU

The median CPU frame takes 16.5 ms, p95 17.4, against the 6 ms Budget, recorded with a load average of about 3 on the machine. Recording and submitting the dynamic TLAS's build take 1.00 ms of it, p95 1.11, above the 0.64 to 0.92 ms that ADR 0020 gives for its recording plus submit. An earlier, indicative measurement on part 2's first push put 12.4 ms of a 14 ms CPU frame in the frame's submit, and #204's hand-off to #207 explains why: at submit, wgpu-core walks the dependencies of each TLAS for every pass that sets a bind group holding it, one per Instance, and Metal adds each to a new residency set. The hand-off counted four such passes. Since #277, six set the group that holds both TLAS: both culling phases, the depth pre-pass, phase two's depth pass, the shadow mask pass and the lit pass.

Each frame uploads 2,049,344 bytes, nearly all of it for the birds: 10,000 Instances of 128 bytes and 10,000 Metal TLAS entries of 72. And each frame makes 10,478 heap allocations, nearly one per dynamic TLAS entry: wgpu-core converts every entry with wgpu-hal's tlas_instance_to_bytes, which returns a new Vec each time.

The culling phases

The two culling phases took 1.51 and 0.90 ms, each dispatching a workgroup per Instance slot in use, 254,256 of them, with an invocation per triangle of a Meshlet. The culling shader carries a comment from #194 for exactly this case: "a workgroup per Instance idles most invocations on small Meshes; split it into a pass per Instance and one per Meshlet when a profile of a dense scene names it". Town is that dense scene, and whether its profile names it is #207's call. (Since #299, merged on 2026-10-26 as 800c0d2, #207 is closed, and the pass still dispatches a workgroup per Instance.)

Chapter 9 · Reference

Reading list

Grouped by source, each with what it is good for here. Repo links are pinned to main at 69e02dc, part 3's merge, the code this guide quotes.

This ticket

In the repo

Decisions

  • ADR 0013 the reference hardware and the provisional Budgets town settles
  • ADR 0015 reversed depth with no far plane, which the light count pass inverts
  • ADR 0019 the main pass, its 4x MSAA, the packed indices and the gate, amended for the shadow mask
  • ADR 0020 the static and dynamic TLAS, and the hard shadow rays the shadow mask pass traces
  • ADR 0021 the Capability floor and its 128 MiB storage binding
  • ADR 0022 Ticks, the fixed schedule and PreviousWorldTransform, which the flocks carry

The hash

  • Steele, Lea and Flood, "Fast Splittable Pseudorandom Number Generators", OOPSLA 2014 SplitMix, the generator SplitMix64 comes from, and its golden gamma. Its own output function is MurmurHash3's finalizer; the one mix uses appears in it as mix64variant13, David Stafford's Mix13, which Java 8's SplittableRandom and Vigna's splitmix64.c use as their output function
  • Sebastiano Vigna, splitmix64.c, prng.di.unimi.it the public-domain reference implementation whose constants town uses

Depth tests and lights

Reference

Glossary

Reference scene
A Scene paired with a fixed camera path and injected time, so that every run produces the same frames.
Golden
A committed image that a Reference scene must reproduce within tolerance.
Baseline
The recorded measurements of a Reference scene on one adapter: the reference machine, where Budgets are judged against it, or CI's lavapipe, where only its counters are judged.
Budget
A numeric limit on one metric of a Reference scene (CPU ms, GPU ms, GPU memory, Input latency) that a change must not regress.
Instance
One Renderable's data on the GPU: its World transform, as an Anchor plus an f32 offset, its Mesh, and the Material ID that overrides its Mesh's Materials, if any, in a slot of the Instance buffer that the Renderable keeps while it is one.
Moving Instance
An Instance whose entity the fixed schedule moves, so the entity carries its previous World transform.
Renderable
An entity that has a Transform and a Mesh Handle, and optionally a Material Handle that overrides the Materials of its Mesh's Primitives.
Mesh
An ordered list of Primitives that are rendered together.
Primitive
One contiguous range of a Mesh's vertices and indices.
Meshlet
A small group of one Primitive's triangles, bounded in vertex and triangle count, that the GPU draws as a unit and can cull as one.
Material
The appearance of a Primitive: PBR factors plus one Layer per Channel.
Placement
One Mesh Asset positioned inside a Chunk.
Chunk
A fixed-size vertical column of the Game world, addressed by integer coordinates, that loads and unloads as a unit.
Blob
One cooked Asset's bytes, named by their content hash.
Cooked cache
The store of Blobs plus the index from Asset ID to Blob.
Asset ID
The stable name of an Asset, derived from its source file's GUID, or a procedural Reference scene's name.
Tick
One fixed step of the simulation, 1/64 s; rendered frames draw a blend of the two newest Ticks.
World transform
Where an entity sits in a Game world: its Transform composed with every ancestor's, in f64.
Froxel
A cell of the view frustum, bounded in screen space and depth, that holds the list of lights reaching it.
Frame stats
What one frame cost, measured by the engine itself.
Render switches
The variants a Renderer is created with and keeps.
Capability floor
The GPU features and limits the engine requires to start. An adapter below it is refused with every gap named; nothing in it has a fallback.
SplitMix64
Vigna's fixed-increment version of Steele, Lea and Flood's SplitMix generator: a state that grows by a fixed odd constant each step, returned scrambled; town hashes with one step.
Random value
In town.rs, a number from 0 up to 1 that random hashes from a column and row, a choice and an item.
Choice
What a random value decides, from a park to a bird; the value hashes it by its position in the enum.
Hermite curve
The cubic curve fixed by a position and a velocity at each of its two ends.
TLAS
Top-level acceleration structure: the GPU's search structure over Instances, each placing a BLAS with its transform.
BLAS
Bottom-level acceleration structure: the GPU's search structure over one Mesh's triangles.
Static TLAS
The top-level acceleration structure over every static Instance whose Mesh rays trace, each placing its Mesh's BLAS and tagged with its slot.
Dynamic TLAS
The top-level acceleration structure over the Moving Instances, at most 10,000 of them, rebuilt every frame; town's birds fill it.
Hi-Z pyramid
Ever smaller copies of the frame's depth, each texel the farthest beneath it, that culling tests bounds against.
Packed index
One u32 of the frame's draw: a Meshlet record in its high bits, a vertex within that Meshlet in its low bits.
Lit pass
The pass that shades each pixel at depth equal after the depth pre-pass, reading each light's shadow from the shadow mask.
Shadow mask
A bit per light at each pixel, set where that light is hidden from the surface the pixel's nearest sample shows, by the surface itself where it faces away or by what its shadow ray finds, in the order the lit pass sums the pixel's lights, up to 59 of them.
Late-Z
Testing depth after the fragment shader has run, as a GPU must for a shader with side effects that does not ask for the test early.
Hidden-surface removal
On a tile-based deferred GPU, working out the frontmost fragment at each pixel before shading, so only it shades.
Light count pass
Part 1's compute pass that counts, at each lit pixel, the point lights listed and those reaching it.
Listed, reaching
Lights a pixel's Froxel lists, and those of them whose range contains the pixel's surface point.
Cramer's rule
Solving linear equations as ratios of determinants; for three, with cross products and one dot product.
Built from ticket #206, now closed, and PRs #272, #273 and #275, merged as 2059535, c8f7071 and 69e02dc on top of #277, with figures measured while #206 was built, on 2026-10-23 and 24, and with ADRs 0013, 0015, 0019, 0020, 0021 and 0022, M3's exit criteria and town's M4 Pro Baseline on the shadow mask. Where the guide does its own arithmetic, it says so. The demos are this page's ports of town.rs's code to JavaScript, checked against the Rust, and they say where they model something else. The images are PR #273's and #275's, on the shadow mask except chapter 7's, from before it; the Golden and frame 500 are re-encoded as JPEG, and the rest are the PRs' PNGs.