Praxis · M3 · ticket #208 · PRs #269, #271, #274 and #276

How a swaying crowd casts shadows

In Praxis a shadow is a ray that meets a triangle in a TLAS, so whatever casts one has to be in the TLAS in the shape it is drawn in. A skinned character changes shape in every frame. Ticket #208 builds the crowd Reference scene: 100 figures that the GPU bends in every frame, a stand-in for skinning. It then gives them shadows one way, form (b): 16 rigid shadow proxies per figure, moved in the dynamic TLAS. This guide follows the work in order: the bend pass, the Bend component, the scene, the proxies, and then what form (b) costs against the crowd's Budget. Three live demos bend one figure, sway the crowd, and set a figure's shadow beside its proxies'.

Provenance. #208 landed in four parts, all merged on 2026-10-23: part 1, PR #269, the bend pass, as 5c570f7; part 2, PR #271, the Bend component, as d12da41; part 3, PR #274, the scene, as b8cdb10; and part 4, PR #276, the shadow proxies and the report, as 0743418, which completed the ticket. Code is quoted from 0743418. The measurements are the M4 Pro bench of 2026-10-23 that part 4 reports in docs/performance.md (chapter 6), taken at 0743418. Since then PR #277 (bd7b988) has moved every shadow ray out of the lit pass into a shadow mask pass, and #277 and PR #272 (2059535) have re-recorded the Baselines; chapter 6 gives the crowd Baseline at 69e02dc. After that, #209 built and timed form (a) in PRs #289 (66b5b57) and #290 (71918ac), #290 re-recorded the Baselines again, and on 2026-10-26 the project's owner picked form (b), the proxies, for M7 (the end of chapter 6).

Chapter 1 · ADR 0020 · M3's exit criterion 8

Why a crowd

ADR 0020 gives Praxis one kind of shadow: one hard ray per pixel per light, traced against the TLAS. There are no shadow maps to draw a moving character into. A character's shadow is right only if the triangles a ray can meet are in the pose the character is drawn in, and a skinned character's pose changes in every frame. M3 has to show that a crowd of them can afford that.

Skinning moves every vertex of a character from its bones in every frame. The world-design pre-mortem put the problem in numbers, under "Skinned characters in a crowd": "A skinned character casts a correct shadow only if its BLAS follows its pose each frame, and wgpu-core rebuilds every BLAS in full". A rebuild makes the tree again from the triangles; wgpu-core 30 has no refit, which would keep the tree and only move its bounds. And "a hundred 5,120-triangle BLAS take 7.8 to 9.3 ms of GPU to rebuild", measured, which is 65 to 78% of ADR 0013's 12 ms for a whole frame.

What rebuilding every figure's BLAS would cost. The pre-mortem's measured range, solid to its low end and outlined to its high end, in red as it passes a Budget, against the two Budgets as dashed lines: 1.0 ms of GPU for the crowd's acceleration-structure update, and ADR 0013's 12 ms of GPU for a whole frame. Drawn to scale, 0 to 14 ms.

The pre-mortem named two escape hatches, neither of them measured then: "rigid per-bone proxies (capsules or bone-segment meshes) as Instances in the dynamic TLAS, inside its 10k cap but with proxy-shaped shadows; or BLAS refit through wgpu-hal, which ADR 0007 permits for a subsystem past wgpu's safe API". The project's owner kept the measurement in M3 (item 14 of a decision of 2026-10-19 on issue #17, which tracks architecture decisions and milestone order) and gave it a Budget: "at most 1.0 ms of GPU and 0.5 ms of CPU per frame for the crowd's acceleration-structure update". The milestone makes it an exit criterion:

"Crowd. On crowd, 100 meshes of 5,120 triangles deformed each frame, BLAS refit through wgpu-hal (Metal) and rigid per-bone proxies in the dynamic TLAS are both timed. At least one form updates within 1.0 ms of GPU and 0.5 ms of CPU per frame, or ADR 0020 reopens for skinned crowds. The owner picks M7's form."

So the work is two tickets on one scene. #208 builds the scene and form (b), the proxies. #209 builds form (a), refit, and puts both forms' figures to the owner.

Form (a): refitForm (b): shadow proxies
What follows the poseeach bent Mesh's own BLAS, refit in place to its new vertices16 rigid shadow proxies per figure, moved as Moving Instances
Whose shape casts the shadowthe figure's own triangles, as drawnthe proxies' ellipsoids, a little thinner than the figure
Throughwgpu-hal, Metal only, in one documented unsafe block on the bench pathwgpu's safe API: the dynamic TLAS, rebuilt in every frame since #204
Ticket#209#208, this guide
The two forms M3 times, as the pre-mortem and item 14 of the decision on issue #17 describe them. Both run on the same scene against the same Budget.

Four parts

#208 was split for the size ceiling into four PRs that merge in order, each rebased on the one before:

PartPRBranchWhat it adds
1#269, merged as 5c570f7feat/bend-passthe bend pass: Meshes bent on the GPU each frame (chapter 2)
2#271, merged as d12da41feat/bend-componentthe Bend component: a curvature set each Tick, blended between Ticks (chapter 3)
3#274, merged as b8cdb10feat/crowd-scenethe crowd scene: 100 swaying figures, which cast no shadow yet (chapter 4)
4#276, merged as 0743418feat/crowd-proxiesper-bone shadow proxies and the cost report; completes #208 (chapters 5 and 6)
The split, from the comment on #208 that plans it.
Chapter 2 · PR #269

The bend pass

Praxis has no skinning until M7. To measure a crowd now, M3 needs Meshes whose vertices the GPU rewrites in every frame, as skinning would. The bend pass is that stand-in. Each frame it reads a rest Mesh, which never changes, and writes a bent Mesh of the same vertices, its middle curved into an arc.

Each bend in Frame data names its two Meshes by where their vertices start in the vertex Arena and how many they hold, and gives the bend itself: an azimuth, the direction the middle leans towards, and a curvature, the radians the middle turns through per metre of its height. The rest Mesh stands with its +Y axis up its middle from y = 0. The main pass needs nothing new: it pulls the bent Mesh's vertices from the Arena as it pulls any Mesh's.

/// One bend as the pass reads it: where its rest Mesh's vertices and its
/// bent Mesh's start in the vertex Arena, how many each holds, and the bend
/// (`Bend`), its curvature blended between the two newest Ticks.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct BendData {
    pub rest_first_vertex: u32,
    pub first_vertex: u32,
    pub vertex_count: u32,
    /// The direction the middle bends towards, about +Y from +X towards +Z,
    /// in radians.
    pub azimuth: f32,
    /// Radians the middle turns through per metre of its height.
    pub curvature: f32,
}
A bend as the GPU reads it, from src/render/bend_pass.rs: five 4-byte fields, 20 bytes a bend.

Where a vertex goes

Call the curvature κ and the azimuth ψ. At rest height h, the middle has turned through θ = κh, from +Y towards the horizontal direction toward = (cos ψ, 0, sin ψ). A line that turns at a constant rate per metre is a circular arc, of radius 1/κ, and the chord of an arc meets the arc's first direction at half the angle the arc turns through. So the middle's point at height h sits at the end of a chord leaning θ/2 from upright, h · sin(θ/2) / (θ/2) long. A vertex keeps its horizontal offset (x, 0, z) from the middle, turns with the middle at its height, and lands at the chord's end plus its turned offset:

θ = κ h toward = (cos ψ, 0, sin ψ) middle(h) = h · sinc(θ/2) · (sin(θ/2) · toward + cos(θ/2) · Y) q = ((Y × toward) · sin(θ/2), cos(θ/2)) the turn by θ about Y × toward bent vertex = middle(h) + rotate(q, (x, 0, z))
let height = vertex.position[1];
// At this height the middle has turned through twice the half angle,
// from +Y towards the azimuth, about +Y × toward. The arc's chord from
// the foot runs at the half angle, its length the height times
// sin(half) / half.
let half_angle = 0.5 * bend.curvature * height;
let toward = vec3<f32>(cos(bend.azimuth), 0.0, sin(bend.azimuth));
let rotation = vec4<f32>(
    vec3<f32>(toward.z, 0.0, -toward.x) * sin(half_angle),
    cos(half_angle)
);
let chord = height * sinc(half_angle);
let middle = chord * (vec3<f32>(0.0, cos(half_angle), 0.0) + toward * sin(half_angle));
let offset = vec3<f32>(vertex.position[0], 0.0, vertex.position[2]);
let position = middle + rotate(rotation, offset);
The vertex's new place, from bend_vertices in src/render/shaders/bend.wgsl. (toward.z, 0, −toward.x) is +Y × toward, the axis the middle turns about, and rotation is the quaternion that turns by θ about it.
Shadow proxies:
Culling box:
The top has turned, θ = κ · 1.8 m
Chord from the foot to the top
The top of the middle, sideways
Room left to the box's side
the bent figure the rest Mesh the middle the chord, at θ/2 shadow proxies culling box
Live demo. This page's model of bend.wgsl, in the plane of the bend: one crowd figure cut through its middle, its outline bent point by point with the shader's chord and sinc. The ellipses are chapter 5's shadow proxies, placed as bone_transform places them, and the dashed box is chapter 4's culling box, 0.80 m each side of the middle and 1.8 m tall. Simplified: the azimuth only turns the plane the figure bends in, so it is left out.

sin x / x near zero

Where a figure stands nearly straight, θ/2 is tiny, and sin(θ/2) / (θ/2) divides one small number by another. Metal compiles shaders with fast math unless told otherwise, and wgpu does not tell it otherwise. Under fast math, sin is accurate only to within an absolute error, at most 2⁻¹³ for angles from −π to π, not to a share of the result, so dividing by a small angle would magnify it. So below 0.5 rad the pass takes the Taylor series of sinc instead. Its first term left out, x⁸ / 9!, is under 1.1 × 10⁻⁸ there. The crowd never leaves the series: its largest half angle is 0.5 × 0.35 × 1.8 = 0.315 rad (this guide's arithmetic).

// sin(angle) / angle, sinc, by its Taylor series below 0.5 radians, whose
// first term left out, angle⁸ / 9!, is under 1.1e-8 there: Metal compiles
// with fast math, which leaves sin an absolute error that dividing by a
// small angle would magnify.
fn sinc(angle: f32) -> f32 {
    let squared = angle * angle;
    let series = 1.0 - squared / 6.0 * (1.0 - squared / 20.0 * (1.0 - squared / 42.0));
    return select(sin(angle) / angle, series, abs(angle) < 0.5);
}
sinc, from bend.wgsl. The nested form is 1 − x²/6 + x⁴/120 − x⁶/5040, the series' first four terms.

The tangent frame turns too

Each cooked vertex carries its normal, tangent and bitangent sign as one QTangent, a quaternion in four Snorm16 values (ADR 0018). The pass turns it by the same rotation, with a quaternion product, then stores it as the cooker stores one: its w made positive and kept at least QTANGENT_SMALLEST_W, 1/32767, one Snorm16 step, so it never rounds to zero, and then given the bitangent sign. The constant is shared with the cooker (src/assets/cooked.rs) and declared into the WGSL.

// The QTangent turns with it, stored as the cooker stores one: its w
// made positive and kept off zero, then given the bitangent sign
// (ADR 0018).
let stored = unpack_qtangent(vertex);
let bitangent_sign = select(1.0, -1.0, stored.w < 0.0);
var turned = quaternion_product(rotation, normalize(stored) * bitangent_sign);
turned *= select(1.0, -1.0, turned.w < 0.0);
if turned.w < QTANGENT_SMALLEST_W {
    let axis_length = sqrt(1.0 - QTANGENT_SMALLEST_W * QTANGENT_SMALLEST_W);
    turned = vec4<f32>(normalize(turned.xyz) * axis_length, QTANGENT_SMALLEST_W);
}
turned *= bitangent_sign;
vertex.qtangent = array<u32, 2>(pack2x16snorm(turned.xy), pack2x16snorm(turned.zw));
vertices[bend.first_vertex + invocation.x] = vertex;
The QTangent's turn, the end of bend_vertices. The last line writes the vertex into the bent Mesh's range of the Arena.

Dispatch, order and timing

  • Dispatch. A workgroup bends 64 vertices (BEND_WORKGROUP_SIZE), one per invocation, and each bend gets a row of workgroups: the dispatch is (⌈longest / 64⌉, bends, 1), and an invocation past its bend's vertex count returns.
  • Capacity. BEND_CAPACITY is 4,096 bends, so the bends' buffer is 4,096 × 20 = 81,920 bytes, 80 KiB, made at that size and never grown. Each frame's bends reach it through frame staging, with the frame's other uploads.
  • Order. The pass is the first pass recorded into the frame's encoder, after the frame's uploads, the bends among them, and before culling phase one, so no pass reads a vertex before it is bent. The dynamic TLAS's build is not in that encoder: since #204 it has a command buffer of its own, submitted first.
  • Timing. bend_pass is the second of the timed passes, after dynamic_tlas_build: nine at 0743418, eleven since #277, and twelve since PR #290 (71918ac) added blas_refit after the bend pass. A frame that bends nothing records no pass, so the pass's timestamp slots still hold an earlier frame's values. The readback then reads its time as zero and starts the frame's span at culling phase one (the bent flag in src/render/readback.rs). PR #291 (c5a12eb) has since made the span run from the earliest start to the latest end of the passes it covers, as Metal can start the Froxel pass before culling phase one.

Its own command buffer, submitted first:

  1. dynamic TLAS build

The frame's encoder, in order:

  1. bend pass
  2. culling, phase one
  3. froxel pass
  4. depth pre-pass
  5. depth pyramid pass
  6. culling, phase two
  7. lit pass
  8. tonemapping pass
A frame's GPU work in the order it is recorded at 0743418, which is also the order of TIMED_PASSES in src/render/renderer.rs. Of these, the depth pre-pass and the lit pass pull vertices, both after the bend. Since #277, with shadow rays on, phase two's depth pass and the shadow mask pass run between culling phase two and the lit pass, and the lit pass traces no rays; phase two's depth pass pulls vertices too, after the bend.

Where nothing bends, the pass costs nothing to run: PR #269's Baselines show 0 µs and no heap allocations for it in every scene. Its memory is there regardless, 82,032 bytes of GPU memory in every scene: the 81,920-byte buffer, plus a begin and an end timestamp more in the readback's resolve buffer and in each of its six slots (16 + 6 × 16 bytes).

The test

the_bend_pass_bends_each_mesh_from_its_rest_mesh loads five Meshes of 67 vertices, more than a workgroup and not a multiple of one, with tangent frames turned every way and both bitangent signs. Three are bent from the first: straight, at curvature 0, which must move no vertex at all; forward, 0.35 rad/m towards azimuth 2.2; and back, −0.6 rad/m towards −0.4. Each bent vertex is checked against an f64 oracle that sums the bent middle's direction in small steps (4,096 in #269, 1,024 since part 3), within 10⁻⁵ m, and each tangent frame against the oracle's turn, within 2 × 10⁻⁴. On the M4 Pro, at #269, the worst errors were 2.3 × 10⁻⁷ m and 5.2 × 10⁻⁵. The first Mesh and the one after the bent three keep their vertices byte for byte, and the pass's GPU time is read back within the frame's. Each of these mutations fails it:

  • the chord without sin(x) / x;
  • the quaternion product swapped;
  • the bend axis flipped;
  • the w sign kept negative;
  • the dispatch rounded down.
Chapter 3 · PR #271 · ADR 0022

Game state between Ticks

How far a figure bends is game state, like where it stands. The fixed schedule sets it once per Tick, every 1/64 s, and a frame, which lands between two Ticks, draws a blend of the two newest values (ADR 0022). Part 2 gives a bend's curvature the treatment World transforms already get.

/// Bends its Renderable's Mesh on the GPU each frame from `rest`, a Mesh of
/// the same vertices whose +Y axis runs up its middle from y = 0: a stand-in
/// for skinning (#208). The middle bends into a circular arc towards the
/// azimuth, and every vertex turns with the middle at its height. The bent
/// Mesh is the Renderable's own, since the GPU writes its vertices, and its
/// bounds must hold every shape it bends to, since the culling pass tests
/// them. Game state: the fixed schedule sets the curvature each Tick, after
/// `record_previous_curvatures` keeps the last, and frames draw the blend of
/// the two (ADR 0022); the azimuth is drawn as set. A bend spawns with its
/// previous curvature equal to its curvature.
#[derive(Component, Clone, Copy)]
pub struct Bend {
    pub rest: MeshHandle,
    /// The direction the middle bends towards, about +Y from +X towards +Z,
    /// in radians.
    pub azimuth: f32,
    /// Radians the middle turns through per metre of its height, after the
    /// newest Tick and after the one before.
    pub curvature: f32,
    pub previous_curvature: f32,
}

impl Bend {
    /// The curvature a frame draws, `blend_weight` of the way from the
    /// previous Tick's to the newest Tick's.
    pub fn drawn_curvature(&self, blend_weight: f32) -> f32 {
        self.previous_curvature + (self.curvature - self.previous_curvature) * blend_weight
    }
}
The component, from src/components.rs. It is pure data, as every component is: the GPU buffer it feeds belongs to the Renderer.

Each Tick, a writer sets only curvature. A new system, record_previous_curvatures, copies it into previous_curvature at the start of every Tick, right after record_previous_world_transforms does the same for World transforms, so no writer has to remember to. The azimuth is drawn as set, not blended.

/// Starts a Tick, as `record_previous_world_transforms` does for World
/// transforms: each bend's curvature becomes its previous one, before the
/// Tick sets it.
pub fn record_previous_curvatures(mut bends: Query<&mut Bend>) {
    for mut bend in &mut bends {
        bend.previous_curvature = bend.curvature;
    }
}

// In simulation_schedules, src/state.rs: the fixed schedule, chained in order.
fixed_schedule.add_systems(
    (
        record_previous_world_transforms,
        record_previous_curvatures,
        (spawn_helmet, despawn_helmet)
            .chain()
            .run_if(resource_exists::<HelmetAssets>),
        spin,
        sway,
        clear_just_pressed,
        reset_teleported,
        propagate_world_transforms,
    )
        .chain(),
);
The system and where it runs, from src/transform/mod.rs and src/state.rs. sway, the crowd's system (chapter 4), sets each figure's curvature after it.

A State test, between_ticks_a_bend_is_drawn_at_the_blend_of_its_two_newest_tick_curvatures, sets a curvature that counts Ticks and runs 200 frames at 144, 60, 30 and 90 Hz in turn, which run 0 to 3 Ticks each. Every frame draws the curvature at the analytic blend. Taking record_previous_curvatures out of the fixed schedule fails it.

Prepare's list

Prepare turns every Renderable that has a Bend into one BendData in Frame data, its curvature blended by the frame's blend weight. Three rules keep the list safe to hand to the GPU:

  • Both Meshes loaded, with as many vertices. Otherwise the bend is warned about and skipped.
  • No races. One dispatch runs every bend at once, writing each bent Mesh while it reads each rest Mesh. So of two bends of one Mesh, one is kept, and a bend whose rest Mesh is a Mesh that some bend writes, its own included, is dropped; one warning counts the dropped. These checks run in every build, not only under debug assertions.
  • A fixed order and a fixed capacity. The list is sorted by where each bent Mesh's vertices start in the vertex Arena, so it does not depend on how the ECS World stores the entities. Past BEND_CAPACITY, the bends of the Meshes highest in the Arena are left out, and those Meshes keep the shape they last took. Frame stats count them every frame, as bends_left_out, and they are warned about once while it lasts.
Renderable's MeshBent fromNewest curvatureIn Frame data
secondrest1.0listed second, drawn at 0.4
firstrest0.6listed first, drawn at 0.3
first, againrest0.6dropped: a second bend of one Mesh
longer, 4 verticesrest, 30.4not bent: the vertex counts differ
MeshHandle(99), never loadedrest0.4not bent: not in the pool
ownown0.4dropped: bent from a Mesh a bend writes, its own
thirdsecond0.4dropped: second is written by a bend
Seven Renderables, two bends: each_bend_reaches_frame_data_from_its_rest_mesh_blended_between_ticks, in src/render/prepare.rs. Every bend's previous curvature is 0.2 and the blend weight 0.25, so 0.6 is drawn at 0.3 and 1.0 at 0.4. Frame data lists first before second, in vertex-Arena order, though they were spawned the other way round; the log holds two warnings that a Mesh is not bent and one that counts three dropped. PR #271 showed it failing without the sort, the blend, the vertex-count check, the dedup or the rest-Mesh check.

A second test loads one bend more than the pass holds, 4,097, and checks over three frames that the highest is left out, counted in every frame and warned about once; warning in every frame fails it.

Chapter 4 · PR #274

The crowd scene

crowd is the Reference scene that exit criterion 8 names: 100 figures of 5,120 triangles, swaying in the fixed schedule, each bent by the bend pass in every frame. Part 3 builds it with no shadows from the figures at all; part 4 adds them.

The figures stand in ten rows of ten, 1.6 m apart, on a 24 m ground plane, under the sun alone: 100,000 lux shining along (0.5, −1, −0.3), and no point lights. The camera looks from (0, 11, 20) at (0, 0.6, −0.5), down about 27° at the crowd's middle, "so every figure stays in view and none hides another whole". Frames advance at 60 Hz.

One figure, 101 Meshes

A figure is a unit sphere of 40 segments around and 65 from pole to pole, stretched to 0.25 m in radius (0.5 m across) and 1.8 m tall, and standing on its lowest point: 40 × (2 × 65 − 2) = 5,120 triangles in 59 Meshlets, and 2,704 vertices after the cook. A bent Mesh must be its Renderable's own, since the GPU writes its vertices, so each figure loads the cooked figure once more. That makes 100 bent Meshes, plus one more load as the rest Mesh they are all bent from: 101 copies in the vertex Arena.

The sway

Each Tick, the sway system sets every figure's curvature for the end of the running Tick, from the Tick count, so no rounding accumulates:

κ = 0.35 · sin(2π t / 2.5 s + phase) t = (Tick + 1) / 64 s

At 0.35 rad/m, the top of a 1.8 m figure has turned 0.63 rad, 36°, once every 2.5 s each way. A figure's phase and the azimuth it sways towards are one angle: its index times the golden angle, π(3 − √5), about 137.5°, wrapped to one turn, "so no two neighbours sway alike".

impl Sway {
    /// Its curvature `seconds` after Tick 0.
    fn curvature(&self, seconds: f64) -> f32 {
        let tau = std::f64::consts::TAU;
        let angle = (tau * seconds / CROWD_SWAY_PERIOD + self.phase).rem_euclid(tau);
        (CROWD_SWAY_CURVATURE * angle.sin()) as f32
    }
}

/// Sets each swaying figure's curvature for the end of the running Tick,
/// from the Tick count, so no rounding accumulates over Ticks, and places
/// its bones' shadow proxies on its bent middle.
pub(crate) fn sway(
    fixed_time: Res<FixedTime>,
    mut figures: Query<(&Sway, &mut Bend, &Children)>,
    mut bones: Query<(&Bone, &mut Transform)>,
) {
    let seconds = (fixed_time.tick + 1) as f64 * TICK_SECONDS;
    for (sway, mut bend, children) in &mut figures {
        bend.curvature = sway.curvature(seconds);
        for &child in children {
            if let Ok((bone, mut transform)) = bones.get_mut(child) {
                *transform = bone_transform(bone.0, &bend);
            }
        }
    }
}
The sway, from src/scenes/mod.rs. spawn_crowd gives figure i the phase (i × golden angle) wrapped to 2π, and the same angle as its Bend's azimuth. The inner loop is part 4's: it places the shadow proxies of chapter 5.
Phase and azimuth:
a figure from above, upright, 0.25 m in radius the line it sways along where the top of its middle leans
Live demo. The 100 figures from above, Tick by Tick over one 2.5 s period (160 Ticks), as sway sets them: each line runs from a figure's foot to the point above which the top of its middle leans, (1 − cos κh) / κ towards its azimuth, h = 1.8 m. Frames draw a blend between Ticks; the demo draws the Ticks themselves. With all alike, every figure takes figure 0's phase and azimuth, 0, and the crowd leans as one.

Drawn, not traced

A BLAS is built once, when its Mesh loads, from the vertices the Mesh has then. For a bent Mesh that is the rest shape, so its BLAS would cast an upright figure's shadow wherever the figure leaned. Part 3 therefore loads a bent Mesh drawn alone, through a new MeshUse, which the Mesh pool's new load_for takes; load keeps its old meaning, drawn and traced.

/// What a Mesh is loaded for: the main pass draws it from its Meshlets, rays
/// trace it through its BLAS, or both.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MeshUse {
    DrawnAndTraced,
    /// Drawn alone: a Mesh the GPU bends, whose BLAS, built from its rest
    /// shape, would cast another shape's shadow (#208).
    Drawn,
    /// Traced alone: a shadow proxy, which no pass draws, so it keeps no
    /// Meshlets and needs no Material (#208).
    Traced,
}
What a Mesh is loaded for, from src/assets/pools.rs. AssetLoader::bent_mesh loads with Drawn. Part 4 adds Traced, which AssetLoader::shadow_proxy loads with (chapter 5).

A Mesh loaded MeshUse::Drawn gets no BLAS, and it skips its index stream too, since only a BLAS build reads it: 6.2 MB of the 16 MiB index Arena for this scene. With nothing of theirs in either TLAS, part 3's figures cast no shadow at all.

A box that holds every bend

The culling pass tests each Instance against its Mesh's culling box, which the Mesh's Blob holds (ADR 0018). The rest shape's box would cut swaying figures at the screen's edge and behind occluders. So part 3 lets a procedural Mesh add a box that its cooked bounds must hold, and the figure adds the box of every shape it sways to. The added box is part of the Mesh's Cook key, so changing it cooks the Mesh again. At the most curvature, the top of the middle reaches (1 − cos κh) / κ sideways, and the figure lies within its radius of its middle:

half-width = 0.25 + (1 − cos(0.35 × 1.8)) / 0.35 = 0.25 + 0.549 = 0.80 m height: 0 to 1.8 m
/// The box that holds a crowd figure however it bends: its middle's top
/// reaches (1 − cos κh) / κ sideways at the most curvature κ, h its height,
/// and the figure lies within its radius of its middle; no point rises
/// above the figure's height.
#[cfg(feature = "cook")]
fn crowd_figure_bounds() -> [[f32; 3]; 2] {
    let height = f64::from(CROWD_FIGURE_HEIGHT);
    let sideways = (1.0 - (CROWD_SWAY_CURVATURE * height).cos()) / CROWD_SWAY_CURVATURE;
    let reach = (f64::from(CROWD_FIGURE_RADIUS) + sideways) as f32;
    [[-reach, 0.0, -reach], [reach, CROWD_FIGURE_HEIGHT, reach]]
}
The culling box, from src/scenes/mod.rs, cooked into the figure's Blob.

"No point rises above the figure's height" needed a test, and the review caught the first one, which bent the figure at the most curvature alone. Bending lowers the top of the middle, but it lifts the side of each ring that faces away from the bend. a_crowd_figure_bends_within_its_bounds now bends every vertex of the figure with the bend pass's f64 oracle, by eight curvatures from 0.002 to 0.35 rad/m, towards two azimuths, one along a column of vertices and one between two, and every vertex lands in the box. In the PR's words: "Sideways, the most curvature reaches furthest; upward, the ring below the top rises highest near 0.011 rad/m, 0.93 mm under the box's top. A box without the sway's reach fails it, and so does one 1 mm lower at the top."

Why so small a curvature: the ring next to the top pole sits d = 1.2 cm from the middle, at h = 1.799 m. Its far side rises by about dκh, while the middle at its height sinks by about κ²h³/6, and the difference peaks at κ = 3d/h², about 0.011 rad/m. Its height there comes out 0.93 mm under the box's top. This is this guide's arithmetic, and it reproduces the PR's figures.

What part 3 measures

Every frame draws 101 Instances and 512,002 triangles, 100 × 5,120 plus the ground's 2, so culling drops no figure, and two captures from a directory with no assets are byte-identical. The scene has a Baseline and no Golden. In its Baseline at b8cdb10, its bend pass bends 100 × 2,704 vertices in 28.8 µs of GPU a frame (28.8 µs again in 0743418's, 29.2 µs in 69e02dc's and 27.2 µs in 71918ac's), and its uploads are the 100 bends, 2,000 bytes, beside the uniforms.

Chapter 5 · part 4 · form (b)

Shadow proxies

A shadow proxy is a Mesh that rays trace and no pass draws, standing in for part of a Renderable whose own BLAS would not hold the shape it is drawn in. Part 4 cuts each figure's middle into 16 bones and gives each bone a proxy: a rigid ellipsoid that the sway moves with the bend, so that the dynamic TLAS, which #204 rebuilds in every frame, carries the figure's shadow.

One Mesh, 1,600 Instances

The proxy Mesh is a unit sphere of 12 segments around and 8 from pole to pole, 168 triangles, loaded MeshUse::Traced: it gets a BLAS, but no Meshlets and no Material. Every proxy is an Instance of that one Mesh, placed by its own transform. Each is a child entity of its figure that carries PreviousWorldTransform, and that makes it a Moving Instance in the dynamic TLAS: 1,600 proxies, 16% of its 10,000 entries.

for bone in 0..CROWD_BONES {
    world.spawn((
        bone_transform(bone, &bend),
        ChildOf(figure_entity),
        MeshRef(proxy),
        Bone(bone),
        PreviousWorldTransform::default(),
    ));
}
A figure's 16 proxies, spawned in spawn_crowd, src/scenes/mod.rs. proxy is the one Mesh that AssetLoader::shadow_proxy loaded.

No pass may draw a proxy, and none does. A proxy's Mesh has no Meshlets, and the culling pass, which builds the frame's draw, now leaves out any Instance whose Mesh has none:

// A shadow proxy's Mesh has no Meshlets: rays trace it, no pass
// draws it.
drawn = drawn && mesh.meshlet_count != 0u;
if drawn {
    counts = vec2<u32>(mesh.meshlet_count, 3u * mesh.triangle_count);
}
The skip, in src/render/shaders/culling.wgsl. Proxies are neither drawn nor counted: with them, the frame still draws 101 Instances and 512,002 triangles.

Placed each Tick

Each Tick, right after it sets a figure's curvature, sway places the figure's 16 proxies with bone_transform (chapter 4's code). A bone is 1.8 m / 16 = 0.1125 m long, and its proxy is centred on the bent middle halfway up the bone, turned as the middle turns there. Where the middle is comes from bent_middle, the Rust twin of the shader's math, in f64: the same half angle, the same chord. So the proxies follow the shape the GPU draws.

/// Where bone `bone`'s shadow proxy sits in a figure bent by `bend`:
/// centred on the bent middle halfway up the bone and turned as the middle
/// turns there, stretched over the bone, short of the poles, and to
/// `CROWD_PROXY_WIDTH` of the figure's width there, a spheroid's.
fn bone_transform(bone: u32, bend: &Bend) -> Transform {
    let figure_height = f64::from(CROWD_FIGURE_HEIGHT);
    let length = figure_height / f64::from(CROWD_BONES);
    let height = (f64::from(bone) + 0.5) * length;
    let (centre, turn) = bent_middle(height, bend);
    let from_equator = 2.0 * height / figure_height - 1.0;
    let radius = f64::from(CROWD_FIGURE_RADIUS) * (1.0 - from_equator * from_equator).sqrt();
    let across = CROWD_PROXY_WIDTH * radius;
    let to_pole = height.min(figure_height - height);
    let along = (0.5 * CROWD_PROXY_LENGTH * length).min(CROWD_PROXY_WIDTH * to_pole);
    Transform {
        translation: centre,
        rotation: turn.as_quat(),
        scale: glam::dvec3(across, along, across).as_vec3(),
    }
}

/// Where `bend` takes the point of the middle `height` up it, and how it
/// turns the middle there, as the bend pass bends a vertex there
/// (src/render/shaders/bend.wgsl): the arc's chord from the foot runs at
/// half the angle the middle turns through, its length the height times
/// sin(half) / half.
fn bent_middle(height: f64, bend: &Bend) -> (glam::DVec3, glam::DQuat) {
    let half_angle = 0.5 * f64::from(bend.curvature) * height;
    let azimuth = f64::from(bend.azimuth);
    let toward = glam::dvec3(azimuth.cos(), 0.0, azimuth.sin());
    let axis = glam::dvec3(toward.z, 0.0, -toward.x);
    let turn = glam::DQuat::from_axis_angle(axis, 2.0 * half_angle);
    let chord = if half_angle == 0.0 {
        height
    } else {
        height * half_angle.sin() / half_angle
    };
    (
        chord * (glam::DVec3::Y * half_angle.cos() + toward * half_angle.sin()),
        turn,
    )
}
Placing a proxy, from src/scenes/mod.rs. CROWD_PROXY_WIDTH is 0.85 and CROWD_PROXY_LENGTH 1.5. On the CPU, in f64, bent_middle divides by the half angle directly, with 0 as its only special case.

Across, a proxy reaches 0.85 of the figure's radius at its height, so it stays inside the figure's surface. Along, it reaches 0.75 of a bone each way, 1.5 bones in all, so neighbours overlap, but never further than 0.85 of the distance to the nearer pole. Two tests hold that:

  • each_bones_shadow_proxy_sits_on_the_bent_middle: unbent, and bent either way to the sway's most curvature, every proxy's centre lies within 10⁻⁷ m of the oracle's middle, whose own error with 1,024 steps is about 3 × 10⁻⁸ m, and its turned axes within 10⁻⁶.
  • each_bones_shadow_proxy_lies_inside_its_figure: unbent, each of 17 × 32 points on every proxy lies inside the figure.

Why the end proxies stop short

A lit point on a figure sends its shadow ray outwards, towards the sun, and a ray that leaves a convex shape on its lit side does not enter it again. So as long as a figure's proxies lie inside it, "a figure's shadow rays leave it clear of its own proxies", as the code puts it. The first version gave every proxy 0.75 of a bone each way. The top proxy, centred at 1.744 m, then reached 2.8 cm past the head, the lit top of every figure traced into it, and a capture showed a dark spot of its own shadow on every figure's top. Stopping at 0.85 of the distance to the pole shortens the two end proxies from 0.084 m to 0.048 m each way and leaves the other fourteen as they were (this guide's arithmetic from the constants).

View:
End proxies:
the figure's shadow the proxies' shadow the middle, seen from above
Show:

Live demo. This page's model of one crowd figure under the crowd's sun, which shines along (0.5, −1, −0.3). From above: the figure's shadow is the union of the shadows of its rings, each a disc the bend moves and turns whole, and the proxies' shadow the union of their ellipsoids' shadows. From the side: the head, cut through its middle in the plane of the bend, with the sun at the crowd's elevation, 59.8°, turned into that plane. Each dot is a point of the outline, and each short line its ray towards the sun. Simplified: the proxies are drawn as smooth ellipsoids, not as their 168 triangles, and the side view's rays stay in one plane.

What a proxy shadow looks like, then: a chain of overlapping ellipsoids, a little thinner than the figure, at 0.85 of its radius. That is the price form (b) pays in shape. Chapter 6 gives its price in time.

Chapter 6 · docs/performance.md

What it costs

On the M4 Pro, form (b) updated within the GPU half of the crowd's Budget with room to spare, and sat at the CPU half, in part 4's bench at 0743418. Tracing sun rays against 1,600 proxies then cost the lit pass far more than the update itself, a cost outside the update's Budget. #277 has since moved those rays into a compute pass, and main's Baseline GPU frame is about a quarter of part 4's (the end of this chapter). (Since #299, merged on 2026-10-26 as 800c0d2, it is about a fifth.)

The figures are part 4's report in docs/performance.md, measured at 0743418, before #277: macOS 27.0.1, wgpu 30.0.1 on Metal, release builds, praxis bench <dir> crowd at 2560 × 1440 with 4x MSAA, 248 warm-up and 1,800 measured frames, in eight runs that alternate the scene without proxies (part 3) and with them (part 4). Other work on the machine held the load average at 5.4 to 10.3, so the CPU p95s are noisy; the update's GPU medians vary much less, from 521.5 to 543.6 µs over the five runs below.

The update against its Budget

The update is timed as #204 times the dynamic TLAS. On the CPU it is the build's recording plus its own submit (dynamic_tlas_cpu_microseconds), and on the GPU the build between its markers (dynamic_tlas_build). Five runs with the proxies:

The crowd's update in five runs, with the proxies, at 0743418, M4 Pro, release, 2560 × 1440. Each bar is solid to the run's median and outlined to its p95, in red where it passes the Budget, the dashed line: 500 µs of CPU, 1,000 µs of GPU. Both drawn to one scale, 0 to 1,300 µs.
  • GPU: within the Budget. The build takes 522 to 544 µs at the median and under 600 µs at p95, about half the 1.0 ms. On the orrery, the same build of 5 entries takes about 175 µs (#204's section of the same file).
  • CPU: at the Budget. Medians of 428 to 538 µs straddle the 0.5 ms; p95s run 588 to 1,282 µs on the loaded machine. On the orrery, 5 entries cost about 200 µs, most of it wgpu walking all 10,000 slots at every build, about 12 ns a slot. The 1,600 entries add the rest, and wgpu-core allocates a Vec for every entry of every build (tlas_instance_to_bytes, from the notes #204 left on issue #207).

Beyond the update

The quietest run of each build at 0743418, before #277, medians:

Per frameWithout proxies, part 3With proxies, part 4Change
CPU frame243.5 µs938.2 µs+694.7 µs
GPU frame7,414.6 µs12,860.9 µs+5,446.3 µs
Lit pass6,468.6 µs11,339.0 µs+4,870.4 µs
Uploaded2,192 B322,192 B+320,000 B
Heap allocations2952,034+1,739
The crowd without and with its proxies: the quietest run of each build, medians, M4 Pro, release, 2560 × 1440, 4x MSAA. The changes are this guide's subtractions; the counters match the two Baselines, b8cdb10's and 0743418's. Main's have been re-recorded since; the end of this chapter gives them.
  • CPU frame, +695 µs. The update, plus placing 1,600 proxies every Tick, propagating their World transforms and writing their Instances. In one frame that ran two Ticks, the update took 465 µs (356 recording, 109 submitting); each Tick's sway placed the proxies in 21 to 22 µs and propagated their World transforms in 37 to 39 µs; prepare wrote their Instances in 38 µs; and the frame's own encoding and submit grew by 95 and 94 µs. #204 found that wgpu-core walks a TLAS's entries at submit for each pass that sets a bind group holding it, four passes at 0743418.
  • Lit pass, +4.9 ms. At 0743418 the lit pass traced the shadow rays. Without proxies the figures cast no shadow and the dynamic TLAS is empty, which the lit pass skipped (#204). With them, every lit pixel's sun ray that the static TLAS let through was traced against 1,600 proxies. That is shadow-ray cost, outside the update's Budget; form (a) traces 100 refitted BLASes of 5,120 triangles instead. The whole GPU frame, 12.9 ms, was then over ADR 0013's provisional 12 ms; exit criterion 8 judges the update alone.
  • Uploads, +320,000 B a frame. Each proxy's 128-byte Instance and its 72-byte TLAS entry: 1,600 × 200 bytes.
  • Heap allocations, 295 to 2,034. Most of the 1,739 more are wgpu-core's Vec per TLAS entry per build.
  • The bend pass stays at 28.8 µs of GPU a frame for 100 × 2,704 vertices, in both Baselines, b8cdb10's and 0743418's.

Since #277: the shadow mask

PR #277 (bd7b988) moved every shadow ray out of the lit pass. A compute pass now traces one ray per pixel towards each light that reaches it, from the pixel's nearest depth sample: against the static TLAS, then, where that finds nothing, against the dynamic TLAS unless it is empty. Each result is a bit of the shadow mask, which the lit pass reads; the lit pass has no ray query left. With shadow rays on, phase two's Instances first draw into depth in a pass of their own. #277 re-recorded the Baselines, and PR #272 (2059535) re-recorded them again; of crowd's counters, #272 changed only GPU memory. The crowd Baseline at 69e02dc beside 0743418's:

Per frame, median0743418: the lit pass traces69e02dc: the shadow mask
GPU frame12,860.9 µs3,293.8 µs
Lit pass11,339.0 µs1,688.4 µs
Shadow mask pass–1,175.4 µs
Phase two's depth pass–11.9 µs
Update, GPU543.6 µs605.9 µs
Update, CPU428.4 µs331.8 µs
CPU frame938.2 µs782.6 µs
Uploaded322,192 B322,192 B
Heap allocations2,0342,078
GPU memory420,022,659 B516,623,059 B
The crowd's M4 Pro Baseline, before and after #277: bench/baselines/apple-m4-pro/crowd.json at 0743418 and at 69e02dc. Baseline against Baseline, one run each, not an interleaved A/B. GPU memory grew by #277's 29,491,428 bytes (the mask, 8 bytes a pixel, its count and readback) and #272's 67,108,972; heap allocations by 44, for #277's two passes.
  • The GPU frame, 3.3 ms, is now well under ADR 0013's provisional 12 ms.
  • The update reads 605.9 µs of GPU (p95 668.7) and 331.8 µs of CPU (p95 415.6) in this one run, under both halves of its Budget. One run is not five: part 4's five, on a loaded machine, straddled the CPU half.
  • Re-recorded by PR #290 (71918ac), with a timed pass for #209's refit and 112 bytes more GPU memory for its timestamps, the crowd Baseline reads 3,287.0 µs of GPU frame and an update of 611.5 µs of GPU (p95 663.0) and 360.1 µs of CPU (p95 404.1): still about a quarter of 0743418's GPU frame, and under both halves of the Budget. (Since #299, merged on 2026-10-26 as 800c0d2, which counts the build from the end of the previous frame's last pass, the Baseline reads 2,679.3 µs of GPU frame, about a fifth of 0743418's, and an update of 382.5 µs of CPU, p95 447.1, and 0.0 µs of GPU, the build hidden behind that frame.)

What #209 will compare

Form (a) refits each figure's own BLAS through wgpu-hal on Metal, on the same scene and against the same Budget, and its shadows are the figures' own triangles. #208 leaves it two notes. The bend pass records in the frame's encoder, after the dynamic TLAS's own submission, so refit needs the bend moved ahead of the build. And a bend names ranges of the vertex Arena, not the Mesh whose BLAS a refit would update. The crowd-form Render switch, which CONTEXT.md already names, arrives with #209, when there is a second form to switch to. With both forms timed, the owner picks M7's; if neither updates within 1.0 ms of GPU and 0.5 ms of CPU, ADR 0020 reopens for skinned crowds.

Since then #209 has landed, in PR #289 (66b5b57) and PR #290 (71918ac). CrowdForm, the crowd-form Render switch, picks the form: Proxies, the default and this guide's form; Refit, through wgpu-hal on Metal only; or Rebuild, each BLAS rebuilt through wgpu, the shapes a refit must match. In the two update forms the frame's commands up to the bend pass go in a submission of their own, with the BLASes' refits or rebuilds, and the dynamic TLAS's build follows it. On the M4 Pro the proxies' update took 597 to 603 µs of GPU and 319 to 326 µs of CPU at the median, within both halves of the Budget, and the refit's 1.37 to 1.38 ms of GPU, over the GPU half. The proxies fit, so ADR 0020 does not reopen for skinned crowds, and on 2026-10-26 the owner picked them for M7. BLAS Refit Explained follows that work.

Reference

Reading list

Grouped by source, each with what it is good for here. Repo links are pinned to 0743418, the commit that merged PR #276, except the two later crowd Baselines, pinned to 69e02dc and 71918ac. The book sections were checked on 2026-10-23 against the table of contents and the chapter 26 PDF on realtimerendering.com, and against pbr-book.org.

This ticket

In the repo

The code

The APIs

Books

  • Akenine-Möller et al., Real-Time Rendering, 4th edition, chapter 4, Transforms (table of contents) §4.3, Quaternions, p. 76: the rotations the bend and the QTangent use; §4.4, Vertex Blending, p. 84: the skinning the bend stands in for
  • Real-Time Rendering, 4th edition, chapter 26, Real-Time Ray Tracing, free from realtimerendering.com §26.3, Top and Bottom Level Acceleration Structures: instance matrices updated each frame for lightweight animation, as form (b) moves its proxies; a rebuild for geometry that changes; and "a faster update of the data structure" when only vertex positions move, form (a)
  • Pharr, Jakob and Humphreys, Physically Based Rendering, 4th edition, §7.1.2, Object Instancing and Primitives in Motion one shape, many placements: 1,600 proxies of one Mesh
Reference

Glossary

Where CONTEXT.md defines a term, the definition here and in the tooltips is its own at 69e02dc, word for word. Since #209, MeshUse has a fourth use, Refitted: drawn and traced, a Mesh the GPU bends whose BLAS is updated each frame, built to allow updates and never compacted.

Bend
The GPU's stand-in for skinning until M7: each frame a compute pass rewrites a Renderable's own Mesh, the bent Mesh, from a rest Mesh of the same vertices, its middle curved into an arc by a curvature the fixed schedule sets each Tick.
Bend pass
The compute pass that bends Meshes on the GPU, first in each frame's encoder: a row of 64-vertex workgroups per bend (#269).
Rest Mesh
The Mesh a bend reads and never writes: its +Y axis runs up its middle from y = 0, and every Mesh bent from it holds as many vertices.
Bent Mesh
A Renderable's own Mesh, whose vertices the bend pass overwrites from its rest Mesh's each frame; the main pass draws it as it draws any Mesh.
Curvature
The radians a bent middle turns through per metre of its height. The crowd sways between −0.35 and 0.35.
Azimuth
The direction a bent middle leans towards: radians about +Y, from +X towards +Z.
Skinning
Deforming a Mesh by a skeleton: each vertex follows a weighted blend of a few bones' transforms, recomputed every frame. Real-Time Rendering calls it vertex blending.
Shadow proxy
A Mesh that rays trace and no pass draws, standing in for part of a Renderable whose own BLAS would not hold the shape it is drawn in, such as a bent figure's bone.
Bone
One of 16 equal lengths of a crowd figure's middle, 0.1125 m each, numbered from the foot. Each has a shadow proxy, placed every Tick.
MeshUse
What a Mesh is loaded for: drawn and traced, the default; drawn alone, a bent Mesh, which gets no BLAS; or traced alone, a shadow proxy, which gets no Meshlets and no Material (#208).
Culling box
The box a Mesh's Blob holds for culling (ADR 0018). The culling pass tests it, placed by the Instance's transform, against the view and the depth pyramid.
Index stream
A Mesh's cooked u32 indices in the index Arena. No pass draws from it; only a BLAS build reads it.
TLAS
Top-level acceleration structure: the GPU's search structure over Instances, each placing a BLAS with its transform. A ray walks it down to the triangles it meets.
BLAS
Bottom-level acceleration structure: the GPU's search structure over one Mesh's triangles, built when the Mesh loads, then compacted (#202). Since #208 a Mesh no ray traces gets none.
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, in a frame anchored where the scene loads (#202).
Dynamic TLAS
The top-level acceleration structure over the Moving Instances, at most 10,000 of them, those in the lowest slots, each tagged with its slot. It is rebuilt every frame, in the static TLAS's frame (#204).
Rebuild
Building an acceleration structure again from scratch, from its current contents. Through wgpu-core 30 it is the only way to update one.
Refit
Updating an acceleration structure in place after its contents move: the tree keeps its shape and only its bounds are recomputed. Cheaper than a rebuild, and the tree traces more slowly the further things have moved. wgpu-core 30 does not implement it.
wgpu-hal
wgpu's lower layer, one unsafe implementation per backend, beneath the safe API. ADR 0007 permits it for a subsystem that exceeds wgpu's safe API.
Moving Instance
An Instance whose entity the fixed schedule moves, so the entity carries its previous World transform. It is rewritten each frame and rebuilt into the dynamic TLAS; every other Instance is static.
PreviousWorldTransform
The component holding an entity's World transform after the previous Tick. An entity that carries it is drawn blended between Ticks, and its Instance is a Moving Instance (#204).
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.
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. The renderer pulls Renderables; entities never render themselves.
Mesh
An ordered list of Primitives that are rendered together.
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.
Arena
A GPU buffer of fixed capacity, created at startup and never grown, from which a Pool sub-allocates ranges; the Mesh pool's hold vertices, indices, and Meshlets with their vertices and triangles.
Blob
One cooked Asset's bytes, named by their content hash.
Cook key
The hash of everything that determines a Blob: what it cooks from, dependencies, settings and cooker versions. A changed Cook key means the Asset is cooked again.
QTangent
A vertex's normal, tangent and bitangent sign packed as one quaternion in four Snorm16 values, its w kept off zero so that its sign can carry the bitangent's (ADR 0018).
Snorm16
A signed 16-bit integer read as a number from −1 to 1, in steps of 1/32767.
sinc
sin(x) / x, which tends to 1 as x tends to 0. The bend pass takes its Taylor series below 0.5 rad.
Fast math
A compiler's licence to trade floating-point exactness for speed. Metal compiles shaders with it unless told otherwise, and its sin is then accurate only to within an absolute error, at most 2⁻¹³ from −π to π.
Workgroup
The group of invocations a compute dispatch runs together; the bend pass's holds 64, one per vertex.
Oracle
A slow, plainly correct computation that a test checks the fast one against. The bend's sums the bent middle's direction in small steps, in f64.
Golden angle
π(3 − √5) rad, about 137.5°: the turn that divides a full turn in the golden ratio. It is an irrational share of a turn, so no multiple of it lands exactly on another.
Tick
One fixed step of the simulation, 1/64 s; rendered frames draw a blend of the two newest Ticks.
Fixed schedule
The schedule that runs once per Tick and holds everything that changes game state: gameplay, physics, AI and Lua event reactions (ADR 0022).
Blend weight
How far a frame lies between the two newest Ticks, from 0 at the older to 1 at the newer. Prepare blends each moving entity's two World transforms, and each Bend's two curvatures, by it (ADR 0022).
Frame data
The per-frame collection of changed Instances, lights, camera and the Meshes the GPU bends that the renderer consumes in place of reading the ECS World. It lists no draws: the GPU culls Instances and builds its own.
Frame stats
What one frame cost, measured by the engine itself: CPU time per system and stage, GPU time per pass and of the dynamic TLAS's build, and the GPU frame total (that build plus the span from the start of the first pass to the end of the last), draw calls, Instances and triangles drawn, Instances the draw had no room for, Instances written, Renderables refused, lights the light buffer had no room for, lights the Froxel lists had no room for and lights the shadow mask had no bit for, the lit pixels and the point lights their Froxels list and whose range reaches them (where counted), static Instances left out of the static TLAS and Moving Instances left out of the dynamic TLAS, bends the bend pass had no room for, bytes uploaded, heap allocations, GPU memory, Arena bytes in use, dropped time and the readbacks of GPU stats it skipped.
ECS World
The container of entities, components and resources. A code concept, never the playable space.
Reference scene
A Scene paired with a fixed camera path and injected time, so that every run produces the same frames. Used for Goldens and Baselines.
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.
Golden
A committed image that a Reference scene must reproduce within tolerance.
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. The GPU memory Budget splits into one Budget per kind of GPU memory, such as the geometry Arenas, the texture pool and staging, which caps what that kind may hold (ADR 0017). Counters such as draw calls and bytes uploaded are held to their Baselines instead.
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. A compute pass traces it from the depth target; the lit pass reads it.
Render switches
The variants a Renderer is created with and keeps: whether Instances are culled, whether lights are culled into Froxels or all reach every pixel, whether shadow rays are traced, whether a pass counts each lit pixel's point lights, and the form the crowd scene's deforming meshes take in the TLAS. Their defaults are what ships; scene content such as ambient and background is not a switch.
M4 Pro
The Apple M4 Pro on which the project's Budgets are judged and its Baselines recorded; wgpu runs on Metal there.
Built from ticket #208 and its four parts, merged on 2026-10-23: PR #269 as 5c570f7; PR #271 as d12da41; PR #274 as b8cdb10; and PR #276 as 0743418. With the M3 milestone, the decision on issue #17, ADRs 0007, 0013, 0018, 0020 and 0022, the world-design pre-mortem, and part 4's section of docs/performance.md; for what changed since, PRs #277 and #291, the crowd Baselines at 69e02dc and 71918ac, and #209's PRs #289 and #290, with their section of docs/performance.md and the owner's decision of 2026-10-26. Where the guide does its own arithmetic, it says so. The demos are this page's models, built from the same formulas as the code, and say where they simplify.