Praxis · M3 · ticket #209 · PRs #289 and #290, merged

Refitting a BLAS beneath wgpu

A figure the GPU bends every frame casts the right shadow only if its BLAS bends with it. #208 gave the crowd scene's figures shadows through rigid proxies. Part 1 of #209 gives each figure a BLAS of its own and refits it in every frame, which wgpu's safe API cannot do. So it reaches two layers down, into wgpu-hal, on Metal, in one main unsafe block and a few smaller ones, and then has to order the GPU work by hand, because Metal does not order a TLAS build after the BLASes it reads. Part 2 then times the refit with a marker Metal cannot run early, and the figures settle M7's form. This guide covers rebuild against refit, the crowd-form switch, wgpu's three layers, the unsafe block's invariants one at a time, the frame split in two submissions joined by a Metal shared event, the test that compares refit with rebuild, timing GPU work with markers, and the result. Three live demos refit a tree of boxes as a figure bends, run a frame's submissions with and without the order, and time a refit with three kinds of marker.

Provenance. #209 was split in two for the project's size ceiling, about 400 changed lines a pull request. Part 1, PR #289, merged on 2026-10-26 as 66b5b57: the switch, the refit, its order on Metal and its capture test (chapters 1 to 6, code quoted from 66b5b57). Part 2, PR #290, merged on 2026-10-26 as 71918ac and closed #209: the timing, the --crowd-form flag for praxis bench and capture, both forms in docs/performance.md, and the owner's decision, proxies for M7 (chapters 7 and 8, quoted from 0534eb6, its head when this guide was written; the commits after it re-recorded the Baselines and changed nothing under src/ or docs/). Since then, PR #291 has changed how the GPU frame spans its passes (chapter 7). wgpu's code is quoted from the 30.0.1 crates the lockfile pins.

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

Why the BLAS must follow the bend

A shadow ray in Praxis walks the TLAS down to a BLAS and meets the triangles it holds. A BLAS is built from the vertices its Mesh has when it is built. When the bend pass rewrites a figure's vertices, nothing changes in its BLAS, so the figure would cast the shadow of the shape it was built in.

There are two ways to bring a BLAS to new vertices. A rebuild makes the tree again from the triangles, as if they were new. A refit, called an update in Vulkan and wgpu, keeps the tree: which triangles share a leaf and how the leaves group stay as they were, and only the boxes are recomputed, from the leaves up, to hold the triangles where they are now.

The world-design pre-mortem priced the rebuild: "A hundred 5,120-triangle BLAS take 7.8 to 9.3 ms of GPU to rebuild (measured [...]), 65 to 78% of ADR 0013's 12 ms". The owner's Budget for the crowd's acceleration-structure update is 1.0 ms of GPU and 0.5 ms of CPU per frame (the decision on map #17, item 14). A refit does less work than a rebuild, which is why M3 times it. What it gives up:

  • The same triangles. A refit repeats its build's description: in the code's words, "the same buffers, offsets, counts, stride and formats". Only the vertex positions in those ranges may change.
  • Built to allow it. A refitted Mesh's BLAS is built with ALLOW_UPDATE, which wgpu-hal hands Metal as MTLAccelerationStructureUsage::Refit (wgpu-hal/src/metal/conv.rs:486-487). Apple's documentation warns that Metal then builds the structure "more conservatively, which can reduce its intersection performance".
  • Never compacted. Every other BLAS in Praxis is built with ALLOW_COMPACTION and compacted once built (#202). A refitted one stays as built.
  • Its index stream kept. #208 loaded the bent figures with no index stream, since only a BLAS build reads one: its hand-off counts 6.2 MB of the 16 MiB index Arena saved. A refit reads the indices again in every frame, so a refitted figure keeps its stream.
  • A tree chosen for another shape. The grouping was picked for the shape the BLAS was built in. As the triangles drift from it, the recomputed boxes grow and overlap, and a ray tests more of them. The crowd's figures are built upright and sway either way; the demo below shows the effect on a model.
  • Scratch memory. A refit works in a scratch region of the size Metal asks for its update (refitScratchBufferSize, read at wgpu-hal/src/metal/device.rs:2088). On the M4 Pro, Metal asks for none for these BLASes (chapter 7).

Refit: the rest shape's tree, boxes recomputed

Rebuild: a new tree for the bent shape

Built at:
Boxes at this level, summed perimeter, refit
The same, rebuild
All levels, refit over rebuild
the bent figure boxes at the chosen level the leaves' boxes, one or two edges each
Live demo. This page's model, not Metal's: one crowd figure's outline, 1.8 m tall and 0.25 m in radius, as 48 edges in the plane of its bend, bent with the bend pass's chord and sinc. A tree of boxes over the edges, at most two to a leaf, splits each box where its two halves' perimeters, each weighted by its edges, sum to the least: a greedy form of the surface area heuristic. For random lines in a plane, the chance that one through a box also crosses a box inside it is the ratio of their perimeters, as in space it is the ratio of surface areas, so summed box perimeter stands in here for how many boxes a ray tests. The left tree was built in the shape chosen under "Built at" and is refitted to the slider's curvature; the right is built again at every step. Metal does not publish its BLAS layout, so the demo shows the kind of effect, not its size on the M4 Pro. The slider runs past the crowd's 0.35 to 1.2 rad/m; even there the refitted tree stays within 8% of a rebuilt one at every level, and since a greedy build is not the best tree, the ratio can dip just below 1.

A refit costs less to make than a rebuild and traces worse the further its triangles have drifted. The crowd sways back and forth through the upright shape its BLASes are built in, so the drift never grows past the sway's widest lean, a curvature of 0.35 rad/m, where the model's refitted tree stays within 7% of a rebuilt one at every level. No figure isolates what the drift costs the shadow rays on the M4 Pro: part 2 reports no shadow mask time for the rebuild form, and the 50 µs more that the shadow mask pass took in the refit form than with the proxies compares 100 BLASes of 5,120 triangles with 1,600 proxies (chapter 8). What part 2 did measure is that the refits alone cost more than the whole Budget.

Chapter 2 · PR #289 · CrowdForm

Three crowd forms

Which way the figures reach the TLAS is a Render switch, RenderSwitches::crowd_form, with three values. The default, Proxies, is #208's form, unchanged. Refit is the new form. Rebuild is the same setup with each BLAS rebuilt through wgpu instead: it runs on any backend and gives the shapes a refit must match.

/// How the shapes the GPU bends Meshes to each frame reach the TLAS (ADR
/// 0020): the forms M3's `crowd` scene measures for M7 to pick from.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum CrowdForm {
    /// The bent Meshes have no BLAS: rigid shadow proxies, one per bone,
    /// cast their shadows as Moving Instances (#208).
    #[default]
    Proxies,
    /// Each bent Mesh's BLAS is refitted to its bent shape each frame
    /// through wgpu-hal, and its Instance is a Moving Instance. Metal only:
    /// wgpu-core rebuilds BLASes but never updates them.
    Refit,
    /// As `Refit`, each BLAS rebuilt through wgpu instead: what refit must
    /// match, on any backend.
    Rebuild,
}

impl CrowdForm {
    /// Why it cannot run on `backend`, if it cannot.
    pub fn unavailable_on(self, backend: wgpu::Backend) -> Option<&'static str> {
        (self == CrowdForm::Refit && backend != wgpu::Backend::Metal)
            .then_some("the crowd's refit form updates BLASes through wgpu-hal on Metal only")
    }
}
The switch's values, src/render/renderer.rs:85-108.
Proxies (default)RefitRebuild
What casts a figure's shadow16 rigid shadow proxies, one per bonethe figure's own triangles, through its own BLAS
The figure's Mesh, loaded asMeshUse::Drawn: no BLASMeshUse::Refitted: a BLAS built with ALLOW_UPDATE, uncompacted, index stream kept
In the dynamic TLASthe 1,600 proxies, as Moving Instancesthe 100 figures, as Moving Instances
Each frame, after the bend passnothing moreeach BLAS refitted through wgpu-haleach BLAS rebuilt through wgpu
BackendsanyMetal only; refused elsewhereany
What it is for#208's form, which shipsform (a), which #209 timesthe oracle refit is tested against
The three forms, from CrowdForm's docs, spawn_crowd_as in src/scenes/mod.rs and MeshPool::load_for in src/assets/pools.rs. 100 figures of 16 bones each make the 1,600 proxies (#208).

A fourth way to load a Mesh

#208 had three MeshUse values: drawn and traced, drawn alone, traced alone. Part 1 adds Refitted, "Drawn and traced, a Mesh the GPU bends whose BLAS is updated each frame from its bent shape: built to allow updates, never compacted". Being traced, it keeps its index stream. The load builds it with flags kept in one constant, which every refit repeats:

/// The flags a refitted Mesh's BLAS is built with, which its refits repeat.
pub const REFITTED_BLAS_FLAGS: wgpu::AccelerationStructureFlags =
    wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE
        .union(wgpu::AccelerationStructureFlags::ALLOW_UPDATE);
The flags, src/assets/pools.rs:44-47.
let refitted = mesh_use == MeshUse::Refitted;
let blas = self.device.create_blas(
    &wgpu::CreateBlasDescriptor {
        label: Some("Mesh BLAS"),
        flags: if refitted {
            REFITTED_BLAS_FLAGS
        } else {
            wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE
                | wgpu::AccelerationStructureFlags::ALLOW_COMPACTION
        },
        update_mode: wgpu::AccelerationStructureUpdateMode::Build,
    },
    // [...]
);
let mesh_blas = MeshBlas {
    blas,
    geometry,
    first_vertex: vertices.offset,
    first_index: indices.offset,
};
staging.build_blas(&self.blas_build_entry(&mesh_blas));

let blas_triangles = mesh.indices.len() as u64 / 3;
self.blases.push(Some(mesh_blas.blas.clone()));
if refitted {
    self.refitted_blases.push(mesh_blas);
} else {
    self.awaiting_compaction.push((handle, blas_triangles));
}
Building a refitted Mesh's BLAS, in MeshPool::load_for, src/assets/pools.rs:314-344, three lines elided. MeshBlas keeps the build's sizes and where its triangles lie in the Arenas, which is what each refit repeats. A refitted BLAS goes to refitted_blases instead of the compaction queue.

The figures become Moving Instances

The static TLAS is rebuilt only when its static Instances change, so it would keep each figure's box as loaded while the figure swayed out of it. In the update forms each figure therefore carries PreviousWorldTransform, which makes its Instance a Moving Instance: it joins the dynamic TLAS, which is rebuilt every frame over each BLAS's current bounds.

let Some(proxy) = proxy else {
    // A Moving Instance, so it joins the dynamic TLAS, rebuilt each
    // frame over its BLAS's bent bounds; the static TLAS would keep
    // the bounds of its load.
    figure_entity.insert((
        MeshRef(loader.refitted_mesh(figure)?),
        PreviousWorldTransform::default(),
    ));
    continue;
};
A figure in the update forms, in spawn_crowd_as, src/scenes/mod.rs:1005-1014. proxy is None in both update forms, so the figure gets no proxies and its own refitted Mesh.

Refused off Metal

State::new checks the switch before it makes the Renderer, and fails with the reason, so a capture or a bench asked for the refit form on Vulkan or DX12 stops with "Refit is unavailable: [...]". BlasRefit::new checks again and logs; the review kept that second check as defence in depth.

if let Some(reason) = switches.crowd_form.unavailable_on(context.backend()) {
    anyhow::bail!("{:?} is unavailable: {reason}", switches.crowd_form);
}
The refusal, src/state.rs:64-66. The scene's spawn follows the same switch (src/state.rs:112-115): spawn_crowd for Proxies, spawn_traced_crowd for the other two.

The default form draws and traces as before; the PR's bench step found every counter in every scene equal to its Baseline. It changes one thing for Proxies: the dynamic TLAS's command buffer is now recorded after the bend pass instead of before it (chapter 5).

Chapter 3 · ADR 0007 · wgpu 30.0.1

Three layers of wgpu

wgpu is layered. Praxis calls its safe API. Beneath it, wgpu-core validates every call, tracks each resource's state and lifetime and records the barriers between uses. Beneath that, wgpu-hal is one thin, unsafe implementation per backend. Metal's wgpu-hal can refit, as Vulkan's and DX12's can; wgpu-core never asks any of them to.

Praxis wgpu: the safe API build_acceleration_structures wgpu-core: validation, tracking, barriers mode: Build, always wgpu-hal, Metal: unsafe, one per backend Build | Update Metal: build & refit commands the refit as_hal guards as_hal_mut encoder one unsafe block Metal only
Where the refit goes. Every BLAS build through wgpu passes wgpu-core, which asks wgpu-hal for Build. The refit hands wgpu-hal's Metal encoder an Update directly, so wgpu-core never sees it.

Why wgpu-core cannot refit

wgpu has an update mode in its API, AccelerationStructureUpdateMode::PreferUpdate, but wgpu-core 30.0.1 treats it as a request it cannot meet. For a BLAS it logs and builds:

if blas.update_mode == wgt::AccelerationStructureUpdateMode::PreferUpdate {
    log::debug!("only rebuild implemented")
}
wgpu-core's answer, wgpu-core/src/command/ray_tracing.rs:1106-1108 in 30.0.1; the descriptor it then builds says mode: hal::AccelerationStructureBuildMode::Build (line 1126). TLASes get a warning instead: "only rebuild is implemented" (line 337). ADR 0020 records the same: "wgpu-core has no refit".

wgpu-hal's Metal backend implements both modes. For Update it calls Metal's refit, refitAccelerationStructure:descriptor:destination:scratchBuffer:scratchBufferOffset:, from the source structure into the destination, which for Praxis are the same BLAS (wgpu-hal/src/metal/command.rs:1864-1873). ADR 0007 allows exactly this: "using wgpu-hal only for a subsystem that exceeds wgpu's safe API".

The raw command encoder

To record wgpu-hal commands, Praxis asks a wgpu command encoder, which records a command buffer, for the wgpu-hal encoder beneath it, with CommandEncoder::as_hal_mut, which lends a &mut hal::metal::CommandEncoder to a closure. wgpu's docs set its terms: the raw encoder "must not be manually destroyed", "You must not end the command buffer; wgpu will do it when you call finish", and "The wgpu command encoder must not be interacted with in any way while recording is happening to the wgpu_hal or backend command encoder". wgpu-core enforces the last: "Mixing the wgpu encoding API with access to the raw hal encoder via as_hal_mut is not supported", and it tracks which API an encoder uses "to detect and reject invalid usage" (wgpu-core/src/command/mod.rs:625-627).

So the refits get an encoder of their own, labelled "BLAS Refit Encoder", which only ever sees wgpu-hal. wgpu finishes it into a command buffer like any other, and Praxis submits it right after the frame's first commands (chapter 5).

Guards and the snatch lock

A refit needs several wgpu-hal objects at once: the vertex Arena, the index Arena, the scratch buffer and every refitted BLAS. Each wgpu resource lends its wgpu-hal object through as_hal, which returns an as_hal guard. The guard of a buffer or a BLAS holds an Arc of the resource and a read of its device's snatch lock, the lock that stops a resource's raw object from being destroyed ("snatched") while someone reads it (wgpu-core/src/as_hal.rs:59-81). A thread may not hold it twice. In debug builds wgpu-core checks, and panics:

panic!(
    "thread '{name}' attempted to acquire a snatch lock recursively.\n\
 - Currently trying to acquire {new}\n\
 - Previously acquired {prev}",
);
Two guards at once, wgpu-core/src/snatch.rs:140-144, under debug_assertions.

So the refit cannot hold four guards at once. Instead, unguarded reads the object's address through each guard, then lets the guard go, lock and all, and keeps the reference:

/// The wgpu-hal object `guard` reads, kept past the guard: each guard
/// holds its device's snatch lock, which a thread must not take twice,
/// and a refit needs several objects at once.
///
/// # Safety
///
/// The resource the object belongs to is neither destroyed nor dropped
/// while the reference lives.
pub(super) unsafe fn unguarded<'a, T>(
    guard: Option<impl std::ops::Deref<Target = T>>,
) -> Option<&'a T> {
    // SAFETY: wgpu-core boxes the object in the resource, so its address
    // holds while the resource lives undestroyed, as the caller ensures.
    guard.map(|guard| unsafe { &*std::ptr::from_ref(&*guard) })
}
Keeping a reference past its guard, src/render/blas_refit.rs:258-272. The lifetime 'a is the caller's to choose, which is why the function is unsafe: nothing in the types ties the reference to the resource.

Letting the guard go also drops its Arc, so the guard no longer keeps the resource alive. Praxis does: the Mesh pool holds the Arenas, BlasRefit holds the scratch buffer, and the refit clones every BLAS handle into a local handles list for the length of the recording.

Who keeps the resources alive on the GPU

wgpu-core keeps a resource alive until the GPU has finished every command buffer that uses it, but it learns what a command buffer uses only from the commands recorded through it. The refit's command buffer was recorded through wgpu-hal, so wgpu-core knows nothing of it. What holds its resources is Metal itself: wgpu-hal's Metal backend makes its command buffers with retained references by default (retain_command_buffer_references: true, wgpu-hal/src/metal/mod.rs:383-393), and a Metal command buffer made that way retains every resource its commands name until it completes. The review caught the first SAFETY text crediting wgpu-core with this, and the PR corrected it.

Chapter 4 · src/render/blas_refit.rs:163-239

The unsafe block, invariant by invariant

An unsafe block tells the compiler "trust me"; its SAFETY comment says why. The ticket asked that the block state "every invariant it relies on". The PR's description lists seven; the merged comment has eight, adding the rule about the raw encoder from chapter 3. They are taken here in the code's order.

let descriptors: Vec<_> = entries
    .iter()
    .zip(&raw_blases)
    .zip(offsets.iter())
    .map(
        |((entries, raw_blas), &offset)| hal::BuildAccelerationStructureDescriptor {
            entries,
            mode: hal::AccelerationStructureBuildMode::Update,
            flags: REFITTED_BLAS_FLAGS,
            source_acceleration_structure: Some(*raw_blas),
            destination_acceleration_structure: *raw_blas,
            scratch_buffer: scratch,
            scratch_buffer_offset: offset,
        },
    )
    .collect();
encoder.as_hal_mut::<Metal, _, _>(|hal_encoder| match hal_encoder {
    Some(hal_encoder) => {
        hal_encoder.build_acceleration_structures(descriptors.len() as u32, descriptors)
    }
    None => refuse("the encoder is not Metal's"),
});
What the block does, its end, src/render/blas_refit.rs:217-238: one Update descriptor per refitted BLAS, the BLAS as its own source and destination, each with its own scratch offset, all recorded in one call on the raw encoder.
  1. Every handle is Metal's

    // - Every handle is Metal's: `new` refuses the refit off Metal.

    The block reinterprets wgpu objects as hal::metal types. BlasRefit::new returns None for Refit on any other backend, so the block never runs there. Each as_hal::<Metal>() also returns None for a foreign object, and the block then refuses and logs once instead of recording.

  2. Every wgpu-hal object outlives its use past its guard

    // - Every wgpu-hal object outlives its use past its guard
    //   (`metal::unguarded`): the Mesh pool, this refit and `blases`
    //   hold their handles throughout, nothing destroys them, and only
    //   the main thread calls into wgpu meanwhile (ADR 0016).

    This is the promise unguarded asks for (chapter 3). Its last clause matters because the snatch lock's job was to stop another thread destroying a resource mid-read; with the guards gone, the promise holds because only the main thread touches wgpu while the refit records.

  3. The encoder is wgpu-hal's alone

    // - The encoder is used through wgpu-hal alone, never ended here,
    //   and finished by wgpu; wgpu-core panics on a mix of the two.

    wgpu's own terms for as_hal_mut, from chapter 3. The refit's encoder records nothing through wgpu, and encoder.finish() ends it.

  4. The GPU's use ends before any object is freed

    // - The GPU's use ends before any object is freed: wgpu-core never
    //   sees what this command buffer uses, but wgpu-hal's Metal command
    //   buffers retain every resource their commands name until they
    //   complete (its `retain_command_buffer_references`, on by
    //   default).

    The CPU-side promise (invariant 2) covers recording. This one covers the GPU running the commands after the CPU has moved on: if a BLAS were dropped next frame while a refit of it still ran, Metal's retained reference would keep it.

  5. Each refit repeats its BLAS's build

    // - Each refit's descriptor is the one wgpu-core built the BLAS from
    //   (`MeshPool::load_for`), with its flags, which allow updates: the
    //   same buffers, offsets, counts, stride and formats.

    A refit is only defined over the triangles the structure was built from. metal::triangles rebuilds wgpu-hal's description of them from MeshBlas, the record load_for kept: the same vertex and index Arenas, first vertex, vertex count and format, the 28-byte CookedVertex stride, u32 indices from first_index, and the geometry's flags. REFITTED_BLAS_FLAGS is the one constant both the build and the refit read.

  6. Each refit has its own scratch region

    // - Each refit has its own scratch region, of the size Metal asks
    //   for its update, in a buffer any usage makes on Metal. The next
    //   frame's refits reuse the regions, and the frames before them may
    //   still run: Metal tracks hazards on wgpu-hal's buffers, scratch
    //   included, so they wait.

    All the refits are in one call and may run side by side, so no two share scratch. metal::scratch asks wgpu-hal for each BLAS's update_scratch_size, rounds it up to SCRATCH_ALIGNMENT, 256 bytes, "a margin over the one-byte alignment wgpu-hal reports for Metal", and lays the regions end to end in one buffer made at the first refit. A frame in flight may still be refitting when the next frame's refits are recorded into the same regions. Metal's hazard tracking sees the scratch buffer named in both and orders them. The buffer joins the Renderer's GPU memory through BlasRefit::gpu_memory_bytes.

  7. The bend comes first; the build waits

    // - The bend pass's writes to the vertex Arena come first in the
    //   queue, and Metal tracks hazards on wgpu-hal's buffers, so each
    //   refit reads the bent shapes; the TLAS build that reads the
    //   BLASes waits for this command buffer through `metal::Order`, or
    //   on the CPU.

    Two orders, of two kinds. Before the refit, Metal's hazard tracking is enough: the bend pass writes the vertex Arena and the refit names it. After the refit it is not: the TLAS build reads the BLASes without naming them. That half is chapter 5.

  8. wgpu-core never learns of the refit, and need not

    // - wgpu-core never learns of the refit, and needs not: it changes no
    //   BLAS's size, handle or build index, so the TLAS built next still
    //   validates against it, and wgpu-core's barriers are no-ops on
    //   Metal.

    wgpu-core keeps, per BLAS, the index of the build that last wrote it. A TLAS build fails if one of its BLASes was never built, and a pass that uses a TLAS fails if one of its BLASes was built after it (wgpu-core/src/command/ray_tracing.rs:495-534). A refit in place leaves each BLAS as wgpu-core last saw it, built at load, so the dynamic TLAS built after it still validates. And the barriers wgpu-core would have recorded around a write it knew of do nothing on Metal: wgpu-hal's place_acceleration_structure_barrier for Metal is an empty function (wgpu-hal/src/metal/command.rs:1877-1881). The module's own summary: "a refit changes only what the BLAS holds".

Chapter 5 · gfx-rs/wgpu#9215 · ADR 0016

Ordering on Metal

Metal orders GPU work that writes and reads the same resource, but only resources a command names. wgpu-hal 30.0.1 builds a TLAS from instance descriptors that name each BLAS by its resource ID, so Metal never sees the build read them, and nothing orders a TLAS build after a BLAS write. #244 found this, for BLAS builds and compaction at load (gfx-rs/wgpu#9215, "Metal BLAS -> TLAS builds need synchronisation", open). A refit each frame followed by the dynamic TLAS's build is the same pair.

#244 measured what a missing order does: "a TLAS built right after a BLAS build or compaction copy, with no wait between them, missed the BLAS in 3 to 23 of 80 iterations while other devices loaded the GPU" (its heads-up on #209). GPU Ordering Explained, volume 13, tells that story. The heads-up gave #209 two ways to order the pair:

  • A CPU wait. Submit the refit, then poll its submission. That is what compact_blases does at load, but every frame "it would stall the main thread on the GPU, against criterion 8's 0.5 ms of CPU".
  • wgpu-hal events. Metal's Queue::add_signal_event and add_wait_event can signal after the refit's command buffer and wait before the build's. In #9215's thread, one application had dropped this route because staging is queue-wide: whatever submit comes next takes a staged event, whether another thread makes it or wgpu-core makes one of its own, as it can when a buffer with pending writes is mapped. In Praxis only the main thread submits (ADR 0016), and Order::stage's SAFETY comment, below, states that nothing submits in between.

Part 1 takes the events, and keeps the CPU wait as a fallback.

The frame in two submissions

Until #289 the dynamic TLAS's build went in a command buffer submitted before the frame's, and the bend pass was recorded in the frame's encoder, after it. In the Proxies form the build's command buffer is still submitted first; only its recording now follows the bend pass. A refit must sit between the bend and the build, so in the update forms the frame is now cut after its bend pass. Everything recorded so far, the "head", goes in a submission of its own with the refits.

let timestamps = self.gpu_stats.begin_frame(&mut encoder, recorder);
// Bent Meshes take their shapes before any pass reads their vertices.
let bend_timestamps = timestamps.map(|timestamps| timestamps.compute_pass(BEND_PASS));
let bent = self
    .bend_pass
    .record(&mut encoder, &frame.bends, bend_timestamps);
let encoding = started.elapsed();
// Their BLASes take them too, in a submission of their own, which
// the dynamic TLAS's build, recorded after it, follows.
if let Some(blas_refit) = self.blas_refit.as_mut().filter(|_| bent) {
    let started = Instant::now();
    let head = std::mem::replace(&mut encoder, create_frame_encoder(&self.device));
    blas_refit.submit(&self.device, &self.queue, head, &self.mesh_pool);
    recorder.stage(BLAS_REFIT, started);
}
The cut, in Renderer::render, src/render/renderer.rs:543-557. The head holds what upload recorded (the static TLAS's rebuild when it has one, and the frame's copies), what gpu_stats.begin_frame records, and the bend pass. A fresh encoder takes the rest of the frame. In the Proxies form blas_refit is None and nothing is cut.
CPU, main thread, in order: stage signal n submit 1 stage wait n record build submit 2 submit 3 Submission 1 head uploads, bend pass refits wgpu-hal only signal n on completion Submission 2 internal wait wait n, release relay dynamic TLAS build starts: wait on relay Submission 3 the rest of the frame: culling ... tonemapping
One frame in the update forms on Metal. The signal is staged before submission 1 and rides on its last command buffer; the wait is staged after it, and wgpu-hal puts it on an internal command buffer at the head of submission 2. With strict event sync, that internal command buffer then releases the relay event every command buffer encoded since submission 1 waits on, the build's among them. Submission 3 is the frame's own, as before.

BlasRefit::submit does the cut's work: in the rebuild form it records the rebuilds into the head itself, through wgpu; in the refit form it submits the head and the refit command buffer together; either way it stages the signal before and the wait after:

pub fn submit(
    &mut self,
    device: &wgpu::Device,
    queue: &wgpu::Queue,
    mut head: wgpu::CommandEncoder,
    mesh_pool: &MeshPool,
) {
    if self.form == CrowdForm::Rebuild {
        mesh_pool.rebuild_refitted_blases(&mut head);
    }
    let head = head.finish();
    #[cfg(target_vendor = "apple")]
    if let Some(order) = &mut self.order {
        order.signal_from_next_submit(queue);
    }
    #[cfg(target_vendor = "apple")]
    let submission = if self.form == CrowdForm::Refit {
        let refits = self.record_refits(device, mesh_pool);
        queue.submit([head, refits])
    } else {
        queue.submit(std::iter::once(head))
    };
    #[cfg(not(target_vendor = "apple"))]
    let submission = queue.submit(std::iter::once(head));
    #[cfg(target_vendor = "apple")]
    if let Some(order) = &self.order {
        order.wait_in_next_submit(queue);
    }
    if self.waits_on_cpu {
        let wait = wgpu::PollType::Wait {
            submission_index: Some(submission),
            timeout: None,
        };
        if let Err(error) = device.poll(wait) {
            tracing::error!(%error, "waiting for BLAS updates failed");
        }
    }
}
Submitting the update, src/render/blas_refit.rs:82-119. The rebuild form on Metal is ordered the same way as the refit, since a rebuild through wgpu is also a BLAS write that Metal does not order the TLAS build after.

A shared event, staged for the next submit

A shared event is a Metal counter on the GPU: one command buffer signals it to a value as it completes, another waits until it reaches that value. metal::Order gets one from a wgpu-hal fence (raw_shared_event) and counts the values it has signalled, one more each frame.

wgpu-hal does not take an event per command buffer. add_signal_event and add_wait_event stage it for the queue, and "any add_wait_event call is consumed by whichever Queue::submit runs next on this queue" (wgpu-hal/src/metal/mod.rs:551-556). That is why submit stages the signal right before submission 1 and the wait right after it, and why Order::stage's SAFETY says "only the main thread submits (ADR 0016), and Renderer::render submits nothing between the update's submission and the dynamic TLAS's build". The signal is drained onto the submission's last command buffer, the refits' (since part 2, when the refit is timed, the marker's, which follows them; chapter 7), "after the submit's own completion signal, so a foreign API waiting on (event, value) observes the wgpu work as done".

Why strict event sync

A staged wait alone is not an order. By default wgpu-hal encodes it on an internal command buffer of its own, committed ahead of the submission's, and its docs say what that leaves open:

"By default, add_wait_event encodes the wait on a separate internal command buffer. Metal allows independent command buffers in a queue to overlap on the GPU, so a wait CB does not strictly gate subsequent user command buffers when those CBs share no Metal-tracked resources with it. Single-stream pipelines often serialize anyway because the GPU has no other concurrent work to fill the slot, but mixed workloads (decode + compute + render) can race."

Queue::enable_strict_event_sync's docs, wgpu-hal/src/metal/mod.rs:497-505

The dynamic TLAS's build shares no Metal-tracked resource with that internal command buffer, so Order::new turns on strict event sync. Then every command buffer, as it starts encoding, first waits on an internal relay event, at the value the next submit will release; and each submit's internal command buffer releases the relay only after the staged waits it carries (wgpu-hal/src/metal/mod.rs:507-514, and begin_encoding in command.rs). The build's command buffer is encoded after submission 1, so it is held until value n is signalled, which happens when the refits complete.

Strict mode has a price and no off switch: "one extra encodeWaitForEvent per command buffer plus one extra internal command buffer per submit", and "once enabled, the queue stays in strict mode for its lifetime". So BlasRefit makes an Order, and turns strict mode on, only in the update forms on Metal. Part 2 measured the price: nothing measurable (chapter 8).

pub(super) fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Option<Self> {
    // [SAFETY comment elided]
    let order = Self {
        device: device.clone(),
        fence: Some(unsafe { device.as_hal::<Metal>()?.create_fence().ok()? }),
        signalled: 0,
    };
    // Dropping `order` destroys the fence on the way out.
    order.fence.as_ref()?.raw_shared_event()?;
    let queue = unsafe { queue.as_hal::<Metal>()? };
    if let Err(error) = queue.enable_strict_event_sync() {
        tracing::error!(%error, "strict event sync not enabled");
        return None;
    }
    Some(order)
}
Making the order, src/render/blas_refit.rs:361-379, four comment lines elided. Each ? returns None, and the fence is destroyed by Order's Drop if it was made: the review's "fence leak on a failed Order::new" fix.

The fallback: wait on the CPU

If Metal makes no shared event, Order::new returns None and BlasRefit::new logs "no shared event: each frame waits on the CPU for its BLAS updates". Each frame then polls submission 1 to completion before recording the build, as compact_blases does at load. It is correct and slow: the main thread waits on the GPU in every frame.

Order:
Other GPU work:

head, then the rest of the frame refits dynamic TLAS build build reads BLASes mid-refit main thread waiting
Live demo. This page's model of one frame's submissions, on an unscaled time axis; the durations and their spread are invented, and so are the odds of a race. Without an order the build may start before the refits end. Strict sync without the event wait holds the build only until its own submission's internal command buffer runs, which usually happens to be after the refits; nothing makes it so, and the model lets other devices' load sometimes reverse it, though all ten of the PR's runs of that form passed. The shared event holds it until the refits complete, whatever else runs. The CPU wait is also correct, but the main thread stops. The measured counts are in chapter 6.
Chapter 6 · tests/crowd.rs

How the test knows

The ticket's acceptance criterion: "a capture test shows shadows from refitted BLASes matching those from a full rebuild". The rebuild form is the oracle: wgpu-core builds those BLASes from scratch, through the safe API, from the same bent vertices. If the refit reads the bent shapes and the build waits for it, the two forms trace the same triangles and cast the same shadows, pixel for pixel.

let rebuilt = capture(&context, CrowdForm::Rebuild).expect("capture failed");
let refitted: Vec<image::RgbaImage> = std::thread::scope(|scope| {
    let captures: Vec<_> = (0..DEVICES)
        .map(|_| {
            scope.spawn(|| {
                let context = gpu_context().expect("a GPU, as the first context found");
                capture(&context, CrowdForm::Refit).expect("capture failed")
            })
        })
        .collect();
    captures
        .into_iter()
        .map(|capture| capture.join().expect("capture thread panicked"))
        .collect()
});
for (device, refitted) in refitted.iter().enumerate() {
    let differing = rebuilt
        .pixels()
        .zip(refitted.pixels())
        .filter(|(rebuilt, refitted)| rebuilt != refitted)
        .count();
    assert_eq!(
        differing, 0,
        "device {device}: pixels differing from the rebuild"
    );
}
The comparison, tests/crowd.rs:61-86, in refitted_blases_cast_the_shadows_rebuilt_ones_cast. Each capture is the last frame praxis::capture renders of crowd, at the capture command's default 640 × 360. Off Metal the same test asks for the refit form and checks that it is refused as "unavailable".

Why four devices at once

A missing order shows only sometimes, "and more often under load (#244)", in the comment on DEVICES, which is 4. Each thread makes its own wgpu device and captures the refit form, so the four contend for one GPU, as the other devices did in #244's 80-iteration loop.

Red first

A test earns trust by failing when the thing it checks is broken. The PR reports five experiments:

What was changedDevicesRunsResult
The refit command buffer left out––red
The order left out820red in 19, 7,116 px differing
Strict event sync alone, no event wait–10green in 10, not relied on
As merged, with the order820green in 20
The CPU-wait fallback–5green in 5
The test's evidence, from PR #289's Verification. A dash is a figure the PR does not give. The two 20-run loops ran at 8 devices; the committed test runs 4. The full scripts/verify.sh passed in 988 s, 971 of them tests.

Passing is not a guarantee

Strict event sync with no event wait passed 10 runs of 10. It holds the build until its own submission's internal command buffer runs, and that usually happens to be after the refits; wgpu-hal's docs above say single-stream work "often" serializes so. The PR's words: "It happens to serialize the work but guarantees nothing, so this PR relies on the event wait."

Ten passes say little about a race. If a race lost one run in 20, ten clean runs in a row would still come up about 60% of the time (0.9510 ≈ 0.60, this guide's arithmetic). A test can show that an order is missing, as the 19 red runs did; it cannot show that one is present. What makes the merged form correct is the API's promise: the build's command buffer waits on the GPU until the event reaches a value only the refits' completion signals. The 20 green runs at 8 devices are the check that the promise is kept, not the reason to believe it.

The review

The local review found 24 items: 15 fixed, 4 declined, 5 deferred to part 2. Among the fixes: the SAFETY text that credited wgpu-core with keeping the refit's resources alive (chapter 3), the scratch-reuse and staged-events invariants, the fence leak in Order::new, and a refused refit that now logs once, with its real reason.

Chapter 7 · #209 part 2 · PR #290

Timing the refit with markers

Part 1 made the refit correct and timed only its CPU stage, blas_refit, which no bench figure counted. On the GPU, in the update forms the build was not bracketed, and the frame's span ran from the bend pass, holding the build and the gap between submissions. Part 2 times the refit on both processors, so it can be held to the Budget's 1.0 ms of GPU and 0.5 ms of CPU. The GPU half needs a marker, and the marker has to be built with care.

Where Metal reads the clock

Praxis asks wgpu for a timestamp at the start or the end of a pass. wgpu can also write one between commands, behind its native TIMESTAMP_QUERY_INSIDE_ENCODERS and TIMESTAMP_QUERY_INSIDE_PASSES features, but wgpu 30 documents those on Metal for AMD and Intel GPUs, not Apple's, so on the M4 Pro a pass's ends are all there is. On Metal, wgpu-hal 30.0.1 offers timestamps only where the device samples counters at stage boundaries (MTLCounterSamplingPoint::AtStageBoundary, wgpu-hal/src/metal/adapter.rs:676-681), and attaches the query set to the compute pass's encoder as a sample buffer with a start-of-encoder and an end-of-encoder index (command.rs:1700-1745). The clock is read where the encoder starts and ends on the GPU. #204, timing the dynamic TLAS's build, found what that implies (docs/performance.md, "Bracketing on Metal"):

  • An empty pass times nothing. "Empty compute passes write no timestamps". A marker has to dispatch real work.
  • An unrelated pass brackets nothing. "passes that touch no TLAS do not bracket the build, which Metal runs alongside them; markers that query the TLAS do." Metal orders work through hazard tracking, so a marker has to name a resource the timed work writes, or it may run beside that work rather than after it.

The dynamic TLAS's markers each run one ray query against the TLAS, on either side of its build. The refit needed a marker of its own.

The refit's marker reads the scratch

The refits are recorded through wgpu-hal alone, on an encoder that takes no wgpu call once wgpu-hal has recorded into it (chapter 3), so no wgpu pass, and no timestamp write, can go in their command buffer. #290 puts the marker in a third command buffer of the refit's submission: one invocation, mark_blas_refit, that reads the first word of the refits' scratch buffer and stores it where nothing reads it. Every refit names that buffer as its scratch, so Metal runs the marker once the refits are done, and the timestamp at the marker's start ends their time; the three-times check below shows it waiting. The regions are in fact empty on the M4 Pro: Metal asks for no scratch to refit these BLASes, so the buffer is its 256-byte minimum (this guide's check of the sizes Metal reports, on macOS 27.0.1).

// The marker after the BLAS refits: one invocation that reads their scratch
// and keeps what it read, so the GPU runs it once the refits are done, and a
// timestamp at its start ends their time (src/render/blas_refit.rs).
@compute @workgroup_size(1)
fn mark_blas_refit() {
    marker_result = refit_scratch[0];
}
The marker, src/render/shaders/blas_refit_marker.wgsl, whole, at 0534eb6. Its two bindings, refit_scratch (read-only storage) and marker_result, are declared once, in BLAS_REFIT_MARKERS (src/render/bind_groups.rs). RefitMarker::record (src/render/blas_refit.rs:424) records it into the encoder "BLAS Refit Marker Encoder" and dispatches one workgroup.

The marker writes only its start: marker_after gives it the refit's second query slot as beginning_of_pass_write_index and no end (src/render/readback.rs:463-469). Nothing writes a timestamp where the refits begin, so their time runs from the end of the bend pass, the last timed pass in the head command buffer before them, to the marker's start. That span includes what Metal spends between command buffers: docs/performance.md puts it at "some 15 to 25 µs of fixed cost".

Checking the timer: three times the work

A timer that measures the wrong thing still returns plausible numbers. So part 2 tested it with a local patch that recorded every refit three times. blas_refit read 3,332 and 3,333 µs against 1,127 and 1,131 once, "and the GPU frame grew by the same 2.2 ms: the marker waits for the refits". Three times the work read 2.96 times the time (3,332 / 1,127, this guide's arithmetic), and the frame grew by about the difference, 2,205 µs. A marker that ran beside the refits would have read the same small number whatever they did.

The frame's GPU time, submission by submission

In the refit form a frame's GPU work arrives in three submissions: the head, the refits and their marker; the dynamic TLAS's build, bracketed by its markers; and the rest of the frame. Between them the GPU can sit idle while the CPU records. frame_times, now a pure function with a unit test, sums the spans and leaves the gaps out:

let frame_time = if shape.blas_refitted {
    build
        + time(ticks[2 * bend_pass], ticks[2 * blas_refit + 1])
        + time(ticks[2 * (blas_refit + 1)], last_end)
} else {
    let first_pass = if shape.bent {
        bend_pass
    } else {
        blas_refit + 1
    };
    build + time(ticks[2 * first_pass], last_end)
};
The GPU frame, src/render/readback.rs:504-515 at 0534eb6. Since PR #291, merged on 2026-10-26 as c5a12eb, the rest of the frame, and a frame with no timed refit, span from the earliest start to the latest end of their timed passes (span), since Metal can start the Froxel pass before culling phase one; the build and the span from the bend pass to the marker are as quoted. build runs between the build's markers, from no earlier than the previous frame's shadow mask pass end (build_floor), since that marker can start while the frame before still traces rays. (Since #299, merged on 2026-10-26 as 800c0d2, build_floor is the end of the previous frame's last pass, as the build runs mostly alongside that frame.) Its doc: "the dynamic TLAS's build, plus the span of each submission's passes, so neither the waits between submissions nor what a submission records before its first pass counts".
  • The build counts once. In a frame whose refit went untimed, the span from the bend pass holds the build's submission, so the build is not added again: the review's fix, commit 5201f26, carried by FrameShape (src/render/renderer.rs:676).
  • The oracle stays untimed. The rebuild form times neither its rebuild nor its build: its rebuilds through wgpu "would fail the first marker's query", since wgpu-core checks that query against the BLASes of the last build.
  • The CPU side. The blas_refit stage, which records the refits and submits them with the head, now counts in dynamic_tlas_cpu_microseconds beside the build's recording and submit (src/bench/mod.rs). The head's submit is in it too, which the proxies form makes with the rest of the frame, so the refit form's CPU "reads a little high".
  • One Baseline change everywhere. The new timed pass adds two queries a frame, so the readback's buffers, and every scene's gpu_memory_bytes, grow by 112 B; PR #290 re-recorded every Baseline.
Marker:
Refits recorded:

head, bend pass, rest of the frame refits dynamic TLAS build span the GPU frame sums
Live demo. This page's model of one refit frame on a scaled time axis. The refits take the blas_refit readings of the three-times check, 1,127 and 3,332 µs, and the build 256 µs, inside form (a)'s measured 253 to 259; the head, the gaps, the marker and the rest of the frame are invented, the rest chosen so the sum lands near round 2's GPU frame, 4,121 µs. An empty marker writes no timestamp; one that touches nothing may start beside the refits, here 5 µs after the bend pass.
Chapter 8 · docs/performance.md · map #17

The result, and M7's form

The bench: Apple M4 Pro, wgpu 30.0.1 on Metal, release builds of part 2 on 66b5b57, the crowd scene at 2560×1440 with 4x MSAA, 248 warm-up and 1,800 measured frames, four rounds alternating the forms. docs/performance.md says "GPU medians agree within 1% across rounds"; in the table below they agree within 1.1%, apart from form (a)'s build, 253.2 to 259.2 µs, 2.4% apart (this guide's arithmetic). The update is the dynamic TLAS's build in both forms, plus the refits in form (a); the bend pass, which both forms run, is outside it, at 21 to 27 µs.

refits dynamic TLAS build CPU: recording and submits pale tip: the spread of the four rounds' medians; red line: the Budget
The update against its Budget, from the four rounds of docs/performance.md, "The crowd's two forms (2026-10-26, #209)". Each bar is solid to the lowest round's median and pale to the highest; the refit's split uses round 1's refits, 1,114.4 µs. The p95s are in the table below: the refit's CPU p95 passes 0.5 ms in every round.
Form, roundUpdate CPU µs, median (p95)Refit GPU µs, median (p95)Build GPU µs, median (p95)Update GPU µs
Proxies 1322.5 (466.1)—603.3 (670.5)603.3
Proxies 2318.6 (396.9)—597.5 (661.0)597.5
Proxies 3325.5 (452.5)—600.5 (667.8)600.5
Proxies 4325.0 (449.4)—596.8 (757.2)596.8
Refit 1450.5 (628.2)1,114.4 (1,284.9)253.2 (314.0)1,367.6
Refit 2394.2 (511.9)1,119.8 (1,267.5)257.3 (313.8)1,377.1
Refit 3393.0 (503.8)1,123.3 (1,268.2)259.2 (314.9)1,382.5
Refit 4418.2 (608.8)1,117.4 (1,302.9)256.9 (324.4)1,374.3
  • Proxies: within both. The build takes 597 to 603 µs, 60% of the GPU Budget; the CPU 319 to 326 µs at the median, its p95s 397 to 466.
  • Refit: over the GPU Budget. The refits alone take 1,114 to 1,123 µs for 100 BLASes of 5,120 triangles, 512,000 in all: "about 2.2 ns a triangle with some 15 to 25 µs of fixed cost" (the three-times check gives 2.15 ns a triangle: 2,205 µs more for 1,024,000 more triangles, this guide's arithmetic). The update takes 1.37 to 1.38 ms, over the Budget by 0.37 to 0.38 ms. The CPU is within it at the median, 393 to 451 µs, and over it at p95, 504 to 628.
  • A smaller build. Over 100 entries rather than 1,600 proxies, the build takes 253 to 259 µs. Part of the gap is strict event sync's doing, below, so form (b)'s build figure is an upper bound.

Strict event sync, measured

Chapter 5 quoted wgpu-hal's price for strict event sync: a wait per command buffer and a command buffer per submit. Part 2 forced it on in the proxies form, with a local patch, three rounds alternating. Every figure moved within noise or fell:

  • The update's CPU: 297–315 µs to 287–292.
  • The CPU frame: 727–756 µs to 711–723.
  • The GPU frame: 3,301–3,308 µs to 3,181–3,257.
  • The build: 593–610 µs to 483–520, because "its first marker no longer starts while the frame before still runs".

Beyond the update

Round 2, mediansProxiesRefit
CPU frame µs756.6590.2
GPU frame µs3,314.34,121.2
Shadow mask pass µs1,174.61,224.4
Uploaded B per frame322,19222,192
Heap allocations2,078861
GPU memory B516,623,171564,006,507
  • CPU frame. Form (a) places no proxies, so its CPU frame is lower, by 90 to 200 µs across the rounds.
  • Rays. They traverse 100 BLASes of 5,120 triangles rather than 1,600 proxies: the shadow mask pass takes 50 µs more.
  • Memory. Form (a)'s GPU memory reads 47.4 MB more. Its 100 BLASes, which form (b) does not have, are uncompacted (chapter 1), and Praxis counts them at the 94 B a triangle measured on the M4 Pro: 48.1 MB (512,000 × 94 B). Form (b)'s per-frame staging, which grows with the 1,600 Instances it writes each frame against form (a)'s 100, holds 0.7 MB more (both this guide's arithmetic), and the refits' scratch is one 256-byte buffer (chapter 7). The index streams form (a) keeps add nothing to this figure, which counts each Arena's whole buffer in both forms.
  • Shapes. At 1280×720 the two forms' images differ in 5,398 of 921,600 pixels, all along the shadows' outlines: the proxies' shadows are a little thinner.
  • The oracle. The rebuild form's rebuild and build are not timed; in one run its GPU frame spanned 9,303 µs, waits between submissions included, and its update's CPU took 709 µs.

The decision

PR #290 ended by asking the owner to pick M7's form, recommending the proxies. The owner picked them, as recorded on map #17:

"Decision (owner, 2026-10-26, in session): M7's crowd form is the per-bone proxies (form b). [...] ADR 0020 stays as is for skinned crowds; the refit form stays behind its Render switch as the shapes' reference."

Map #17, comment 6410351140

Why, in the recommendation's terms:

  • Proxies fit both Budgets with 40% of the GPU's to spare, run on every backend and need no unsafe. Their costs: the thinner shadows, 16 of the dynamic TLAS's 10,000 entries per figure, and the heap allocations wgpu-core makes per entry.
  • Refit is 0.37 ms over the GPU Budget, runs on Metal alone, and needs wgpu-hal plus strict event sync.
  • The refit stays. Behind its Render switch it is the reference for the shapes. tests/crowd.rs keeps its unsafe path and its order tested on Metal, and praxis bench <dir> --crowd-form refit still times it, though no Baseline holds its figures: PR #290 dropped the one it recorded, since verify benches each Baseline's name as a scene.
Reference

Reading list

Grouped by source, each with what it is good for here. Repo links are pinned to 66b5b57, the commit that merged PR #289, or to 0534eb6, PR #290's head when this guide was written, whose src/ and docs/ it merged unchanged; wgpu's to its v30.0.1 tag, the version the lockfile pins.

This ticket

In the repo

The code

wgpu 30.0.1

Metal and Vulkan

Earlier volumes

Reference

Glossary

Where CONTEXT.md defines a term, the definition here and in the tooltips is its own at 66b5b57, word for word; PR #290 changes none of them.

BLAS
Bottom-level acceleration structure: the GPU's search structure over one Mesh's triangles, built when a Mesh that rays trace loads. Most are then compacted (#202); a refitted Mesh's is built to allow updates and never compacted (#209).
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.
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).
Refit
Updating an acceleration structure in place after its triangles move: the tree keeps its shape and only its bounds are recomputed. The structure must be built to allow it, and its triangles keep their count and order. wgpu-core 30 does not implement it; Metal does, reached through wgpu-hal.
Rebuild
Building an acceleration structure again from scratch, from its current triangles. Through wgpu-core 30 it is the only way to update one.
Oracle
A slow, plainly correct computation that a test checks the fast one against. The refit's is a full rebuild of the same BLASes.
MeshUse
What a Mesh is loaded for: drawn and traced, the default; drawn alone, a bent Mesh with no BLAS; traced alone, a shadow proxy; or refitted, a bent Mesh drawn and traced through a BLAS updated each frame (#208, #209).
Index stream
A Mesh's cooked u32 indices in the index Arena. No pass draws from it; only a BLAS build reads it.
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 rewrites each bent Mesh's vertices from its rest Mesh's every frame, a stand-in for skinning (#269).
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.
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.
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.
wgpu-core
wgpu's middle layer, beneath its safe API: it validates each call, tracks each resource's state and lifetime, records the barriers between uses, and calls wgpu-hal.
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.
as_hal guard
What wgpu's as_hal returns: a value that keeps the resource alive and reads as the wgpu-hal object while it lives. The guard of a buffer, texture, texture view or acceleration structure also holds the snatch lock.
Snatch lock
wgpu-core's per-device lock over each resource's raw object, which destroying the resource snatches away. The as_hal guard of a buffer, texture, texture view or acceleration structure holds it for reading, a device's or queue's does not, and in debug builds a thread that takes it twice panics.
Command buffer
A recorded list of GPU commands, which a submission hands to the GPU. On Metal, an MTLCommandBuffer.
Submission
One call to Queue::submit: the command buffers it hands the GPU, in order, and the index the CPU can wait on.
Hazard tracking
Metal's automatic ordering of GPU work that writes and reads the same resource. It sees only the resources a command names; a TLAS names its BLASes by ID, so a TLAS build is not ordered after them (gfx-rs/wgpu#9215).
Marker
A compute pass of one invocation, recorded only to carry a timestamp. It reads or queries a resource the timed work writes, so Metal runs it in order with that work, before or after it as recorded, and the timestamp at its end or start brackets it (#204, #209).
Timestamp query
A GPU write of its clock into a slot of a query set; Praxis asks wgpu for one at the start or end of a pass. On Metal, wgpu-hal takes it as a counter sample at the boundary of the pass's encoder.
Shared event
MTLSharedEvent: a Metal counter that one command buffer can signal to a value and another wait for, on the GPU, with no CPU wait between them. Unlike a plain MTLEvent, it also reaches the CPU and other devices.
Strict event sync
A wgpu-hal mode of a Metal queue, on for its lifetime once enabled: every command buffer starts by waiting on an internal event that each submit signals after the waits staged for it.
Main thread
Under ADR 0016, the thread that runs the frame: only it submits GPU work, presents or polls.
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.
Built from ticket #209, its split, #244's heads-up and #208's hand-off, PR #289, merged on 2026-10-26 as 66b5b57, PR #290, merged on 2026-10-26 as 71918ac and quoted at 0534eb6, and the owner's decision on map #17; with ADRs 0007, 0016 and 0020, the world-design pre-mortem, docs/performance.md, and the wgpu, wgpu-core and wgpu-hal 30.0.1 sources. Where the guide does its own arithmetic, it says so. The demos are this page's models and say where they simplify; only the marker demo uses Praxis measurements, the refit readings of the three-times check, and it says which.