Praxis · M3 · ticket #202 · PRs #234, #235

Giving rays an index of the scene

A field guide to the two PRs that built Praxis's first acceleration structures: a compacted BLAS for every Mesh, and a static TLAS over every Instance that does not move. M3's shadow rays search them for whatever blocks the light. The guide covers what each structure holds, when it is built, and what it costs in memory and CPU time.

Provenance. Both PRs merged on 2026-10-20, #234 as 6f607e8 and #235 as 93355f1, each after re-recording its Baselines with the new estimates. The guide was written from their draft heads, f1fc071 and 1ca54c2, rebased onto main at 0b29a3c; their code merged as written there, and the code links point at the merges. No frame traced a ray until #258 (0db5e46, 2026-10-23), which traced directional lights' shadow rays from the lit pass. Later work changed parts of what this guide describes: #267 (470221e) made compaction wait for its copies, #277 (bd7b988) moved every shadow ray out of the lit pass into a compute pass that writes a shadow mask, since #274 (b8cdb10) only a Mesh that rays trace gets a BLAS, and #303 (4a7ab0b) picks the memory estimate's figures by adapter instead of by backend. Notes in the chapters below, checked against main at 270c5e4 on 2026-10-26, say where.

Chapter 1

Why rays need an index

ADR 0020 decides how Praxis will light a frame. Indirect light comes from ray-traced irradiance probes, and direct light from one hard shadow ray per pixel per light. A shadow ray asks whether anything lies between a shaded point and the light. Testing every triangle for every ray would not scale. The helmet's one Mesh alone has 23,358 triangles, and town, M3's dense Reference scene, plans about 250k Instances of 10 to 20 Meshes.

The usual answer is a bounding volume hierarchy, or BVH, a tree of nested boxes over the triangles. A ray tests a box before anything inside it and skips every box it misses, along with everything that box holds. A query then costs roughly logarithmic time in the number of triangles, where testing them all costs linear time.

GPUs build and search such trees themselves, behind the API's acceleration structures. The application hands over triangles and gets back an object it can build, copy and trace rays against. The APIs leave the form of the tree to the driver; Real-Time Rendering's ray-tracing chapter calls DXR's acceleration structure "mostly opaque to the user". wgpu 30 does not even have a call that returns how many bytes one takes, which chapter 4 deals with.

Rays will come from ray queries. An ordinary shader starts a query against a TLAS, steps it until it is done, and reads its committed hit, the closest one it accepted. wgpu offers them behind EXPERIMENTAL_RAY_QUERY, which the Capability floor has required since M1 (ADR 0021).

Search:
SearchBoxes testedTriangles tested
Brute force0197
Box tree4122

Live demo. Each short line stands in for a triangle; a flat picture keeps it readable, and the counting works the same way in 3D. Drag the dot, the shaded point, or the ring, the light, or use the sliders. Brute force tests every triangle against the ray. Box tree builds a BVH by splitting the triangles at the median along the wider side, up to four to a leaf, and tests each box before what it holds. Boxes the ray entered are outlined in blue, and boxes it tested and missed are dashed. Both searches find the same closest hit. The table counts the tests for this one ray; M3 will trace one per pixel per light.

Two lessons carry over to the real structures. A better-built tree sends a ray through fewer boxes, and spending build time on that is what PREFER_FAST_TRACE asks for. A tree also fits one arrangement of triangles, so when they move it must be refitted or rebuilt; chapter 6 covers which of the two wgpu 30 allows.

Chapter 2

Two levels

Ray-tracing APIs split the index in two. A bottom-level acceleration structure, or BLAS, covers one piece of geometry in its own local space; Praxis builds one per Mesh that rays trace. A top-level acceleration structure, or TLAS, holds a list of entries, and each entry points at a BLAS through a 3×4 transform that places it in the world. Metal calls the two levels primitive and instance acceleration structures.

wgpu calls an entry a TlasInstance, and Vulkan an instance. This guide says TLAS entry, as the PRs' code does, because in Praxis's glossary an Instance is one Renderable's data on the GPU. An entry holds a BLAS, the transform as three rows of four f32, 24 bits of custom data and an 8-bit mask.

Two levels make copies cheap. The helmet scene shows 11 Instances of one Mesh, so it builds one BLAS and points 11 entries at it. Moving or adding a copy changes entries and leaves every BLAS untouched.

A ray starts at the TLAS, whose boxes enclose the entries in world space. For each entry whose box it enters, the ray is moved into that entry's local space by the inverse of its transform, and the search continues down the entry's BLAS. A hit there is a hit on that entry. The query keeps the closest hit and reports the entry's custom data with it.

Seen from inside:

    Live demo. Six TLAS entries of three Meshes: entries 0, 2 and 5 share Mesh A's BLAS, and entries 1 and 4 share Mesh B's. Drag the dot or the ring, or use the sliders. Left, the world: solid boxes are the entries' boxes, dashed boxes group them, and boxes the ray entered are blue. Right, one entry's BLAS in its own space, with the ray as that entry sees it after the inverse transform; the arrows on the left show the same entry's local axes in the world. The numbered list is the order of work. Pick any entry the ray entered to see it from inside.
    Chapter 3 · PR #234

    One BLAS per Mesh, at load

    PR #234 gives each Mesh a BLAS when the Mesh loads. (Since #274, #276 and #289, each Mesh is loaded for a use: one the GPU bends and only draws gets no BLAS, a shadow proxy gets a BLAS but no Meshlets, and a refitted Mesh's BLAS allows updates and is never compacted. Crowd Shadows Explained and BLAS Refit Explained cover them.) MeshPool::load creates the BLAS and records its build, which reads the Mesh where it already lives, in its ranges of the vertex and index Arenas.

    A cooked vertex is 28 bytes: a position of three f32, a uv of two f32 and a qtangent of four i16. The build reads Float32x3 at a vertex stride of 28, so it sees each vertex's leading 12 bytes, the position, and nothing else. first_vertex is the Mesh's offset into the vertex Arena, counted in vertices, and wgpu multiplies it by the stride. first_index skips the indices before the Mesh's range. The cooked indices count from the Mesh's own first vertex, so they land on the right positions.

    The BLAS is created with PREFER_FAST_TRACE | ALLOW_COMPACTION. PREFER_FAST_TRACE asks the driver to spend build time on faster tracing, which suits geometry built once and traced every frame. ALLOW_COMPACTION permits the copy described below. The triangles are flagged OPAQUE, with no alpha test, so a hit counts as soon as the traversal finds it. Without the flag, a hit would reach the shader as a candidate and count only once the shader confirmed it with rayQueryConfirmIntersection, unless the ray's own flags force every BLAS opaque. wgpu-types' own doc for the flag says a BLAS without it has no hits, since naga has no candidate intersections yet; naga 30.0.1 has them.

    The build is recorded into the load encoder after the copies that fill the Arenas, "so it reads what they write" (LoadStaging::build_blas). Both Arenas gain the BLAS_INPUT usage for it. The vertex Arena becomes STORAGE | BLAS_INPUT, and the index Arena trades INDEX, which nothing used, for BLAS_INPUT. Arena::new adds COPY_DST to both, as to every Arena, for the copies that fill them.

    For the memory estimate in chapter 4, a BLAS counts its triangles from the Mesh's index range, mesh.indices.len() / 3. Meshlets play no part in it.

    // The position leads each vertex; the cooked indices count from the
    // Mesh's first vertex.
    staging.build_blas(&wgpu::BlasBuildEntry {
        blas: &blas,
        geometry: wgpu::BlasGeometries::TriangleGeometries(vec![wgpu::BlasTriangleGeometry {
            size: &geometry,
            vertex_buffer: &self.vertex_arena.buffer,
            first_vertex: vertices.offset,
            vertex_stride: size_of::<CookedVertex>() as u64,
            index_buffer: Some(&self.index_arena.buffer),
            first_index: Some(indices.offset),
            transform_buffer: None,
            transform_buffer_offset: None,
        }]),
    });
    src/assets/pools.rs, lines 241 to 255 at 6f607e8, the merge of #234, indented less. The build entry: the Mesh's ranges of both Arenas, the 28-byte stride, and no transform.

    Compaction, step by step

    A BLAS's exact size is known only once it is built. Compaction then copies it into a tighter allocation and frees the original. Metal splits it into writeCompactedSize and copyAndCompact, and Vulkan has a compacting copy mode. MeshPool::compact_blases runs it in wgpu 30:

    1. Create each BLAS with ALLOW_COMPACTION. Without it, wgpu refuses to compact.
    2. Build it. Renderer::upload now submits the staged loads, builds included, at its start; before PR #234 they went at the end of render.
    3. Call prepare_compaction_async on every new BLAS.
    4. Wait. device.poll(PollType::wait_indefinitely()) blocks the CPU until the GPU has finished the builds and everything submitted with them.
    5. Replace each BLAS that reports ready_for_compaction() with queue.compact_blas(&blas). One that is not ready keeps its uncompacted BLAS, and a warning is logged.
    6. Wait again, before any TLAS reads them. queue.compact_blas only records each copy into the queue's pending writes, which go to the GPU at the start of the next submission, and wgpu-core 30.0.1 records no barrier after it; on Metal, wgpu-hal's acceleration-structure barrier is empty anyway (gfx-rs/wgpu#9215). These PRs stopped at the step above, so a TLAS built in the next submission could read a BLAS before its copy finished. PR #267, merged on 2026-10-23 as 470221e, made compact_blases submit those writes and wait for them too; GPU Ordering Explained follows the race.
    7. Keep the copy for good. wgpu documents the returned BLAS as "more restricted than a normal BLAS because it may not be rebuilt or compacted."

    wgpu-types' own doc for ALLOW_COMPACTION names Blas::prepare_for_compaction and CommandEncoder::compact_blas. Neither exists in 30.0.1; the names above do.

    for &(handle, _) in &self.awaiting_compaction {
        self.blases[handle.0 as usize].prepare_compaction_async(|_| {});
    }
    if let Err(error) = self.device.poll(wgpu::PollType::wait_indefinitely()) {
        tracing::error!(%error, "waiting for BLAS builds failed");
    }
    for (handle, triangles) in self.awaiting_compaction.drain(..) {
        let blas = &mut self.blases[handle.0 as usize];
        if !blas.ready_for_compaction() {
            // lines 291 to 294: tracing::warn!(…, "BLAS not compacted: not ready after its build")
            continue;
        }
        *blas = queue.compact_blas(blas);
        self.uncompacted_blas_triangles -= triangles;
        self.compacted_blas_triangles += triangles;
    src/assets/pools.rs, lines 282 to 299 at 6f607e8, the merge of #234, indented less, with the warning on lines 291 to 294 shortened. The two triangle counts feed the memory estimate in chapter 4.

    Compaction needs each build finished, so the frame that completes a load now waits for every load submitted with it, Textures included. texture-wall shows it most. Its first frame went from 75.5–76.6 ms to 88.1–92.8 ms, "waiting for its 10,000 Textures' copies before compacting". M4's streaming takes this wait off the critical path, together with the static TLAS's rebuild.

    A build also needs a scratch buffer while it runs. wgpu 30 allocates one inside each build call, sized for everything that call builds, and the engine never sees it. Each Mesh's BLAS is its own call here. The PR's review raised batching them to share scratch, and the PR declined: town has 10 to 20 Meshes, and M4 reworks load submission.

    The test loads a Mesh made of one degenerate triangle, then the Mesh under test, a triangle around (0.8, 0). It builds a one-entry TLAS over that Mesh's compacted BLAS and traces rays down −Z with a ray query. The ray through (0.8, 0, 0) hits and returns the entry's custom data, 7; the rays through (0, 0, 0) and (0.8, 0.5, 0) miss. The PR shows the test failing three ways: with first_vertex 0, with first_index 0, and without ALLOW_COMPACTION.

    Chapter 4 · PR #234

    Counting bytes nobody reports

    Frame stats carry gpu_memory_bytes, the GPU memory the engine allocates, and the bench compares it with committed Baselines. wgpu 30 has no call that returns an acceleration structure's size. wgpu-core keeps the size crate-private, and its internal counters track acceleration structures only on DX12. On Vulkan, wgpu's allocator report lists the memory behind each one by name, which is how lavapipe's figures below were measured; on Metal, wgpu gives no allocator report. M3's decision was to count them anyway, from sizes measured once per backend and labelled as estimates, and to leave exact sizes to M4.

    AccelerationStructureBytes::estimate(backend) picks the figures. It returns lavapipe's on Vulkan, software or not, and the M4 Pro's on every other backend, as the Budgets' reference machine. (Since #303, merged on 2026-10-26 as 4a7ab0b, it takes the adapter instead of the backend: an NVIDIA GPU, on any backend, gets figures measured on an RTX 2070 SUPER, and every other adapter gets lavapipe's or the M4 Pro's as before.)

    BytesM4 Pro, Metallavapipe, Vulkan
    BLAS, per triangle as built94104
    BLAS, per triangle compacted45.5104
    TLAS, per entry of capacity288.5240 = 176 + 64
    Measured withMetal's currentAllocatedSize around creation, in the GI survey's scratch programwgpu's allocator report, in PR #231's CI probe
    The engine's estimates, in AccelerationStructureBytes and ADR 0017's ledger. lavapipe is Mesa 25.2.8.

    The M4 Pro's figures come from the GI survey's one-off scratch program, which read Metal's currentAllocatedSize before and after creating its structures. 199,098,368 bytes for 2,117,632 triangles gave 94 B per triangle, and 28,868,608 bytes for a TLAS of 100,064 entries gave 288.5. The survey states 45.5 after compact_blas without a raw byte count. The program is not in the repo, so committed code cannot re-run it.

    lavapipe's come from PR #231, a throwaway that ran in CI's software-Vulkan job and summed wgpu's allocator report by allocation name. From 2,048 triangles up, a BLAS took 104.0 B per triangle, and each compacted copy came out 56 B larger; 2 triangles took 248 B either way. A TLAS took 240.1 B per entry at 1,024 entries and 240.0 B at 100,064 and 262,144, made of 176 B of acceleration structure and 64 B of wgpu-core's buffer of entries.

    Open question. Whether the M4 Pro's 288.5 B includes wgpu-core's buffer of entries was never measured apart. On Metal that buffer holds a 72-byte MTLIndirectAccelerationStructureInstanceDescriptor per slot, not the 64 bytes it holds on Vulkan. wgpu-core creates the buffer with the TLAS, so a device-wide count around creation would take it in, and the code's comment says the figure includes it; the survey did not separate the two.

    PR #234 adds a Frame stat of its own, estimated_acceleration_structure_bytes, whose name says what it is, and adds it into gpu_memory_bytes. After a load, each BLAS counts at its compacted figure, or at its built figure if compaction failed. PR #235 adds the static TLAS to the same stat, at full capacity.

    SceneTriangles in its BLASesgpu_memory_bytes change
    helmet23,358+1,062,789 B
    pbr-sweep, orrery2,208+100,464 B
    normal-tangent7,774+353,717 B
    every quad scene2+91 B
    PR #234 on the M4 Pro. Each change is the scene's triangles × 45.5 B, the compacted figure. The helmet scene's 11 Instances share one Mesh, so one BLAS. On CI's lavapipe, far-coplanar's quad added 208 B, 2 × 104, and CI's bench job reported far-coplanar failing by exactly that.
    Mesh:

    Type a whole number from 1 to 268,435,456 (228), the most triangles one BLAS may have under the Capability floor's limits.

    Live demo. The engine's estimates for one BLAS: triangles × 94 B as built and × 45.5 B compacted on the M4 Pro, and × 104 B on lavapipe either way, rounded to whole bytes as AccelerationStructureBytes rounds them. They are estimates; wgpu 30 reports no size to check them against. The sphere is pbr-sweep's and orrery's, and the quad is every quad scene's. For scale, the static TLAS at capacity adds 75,628,544 B on the M4 Pro and 62,914,560 B on lavapipe to every scene.
    Chapter 5 · PR #235

    The static TLAS

    PR #235 gives the Renderer a StaticTlas, a TLAS over every Instance whose entity carries no PreviousWorldTransform. The glossary calls those Instances static and the rest Moving Instances. The TLAS is created in Renderer::new with 218 = 262,144 slots and updated at the end of Renderer::upload from that frame's Instance writes (since #258, first in the frame's encoder, so that the camera uniform can carry the load Anchor). Chapter 7 explains the size.

    Its entries are indexed by Instance slot, so the Instance in slot 7 gets entry 7. Each static Instance's entry comes from TlasInstance::new(blas, transform, custom_data, mask), given:

    • its Mesh's compacted BLAS, from PR #234;
    • its transform, the three rows of a 3×4 matrix measured from the load Anchor, below;
    • its slot, as the custom data;
    • 0xff, as the mask, commented "Every ray's cull mask".

    A freed slot's Instance names no Mesh (NO_MESH), so it has no BLAS and its entry is empty, None.

    A hit returns its entry's custom data as instance_custom_data, so a shader will know which Instance its ray struck. The field holds 24 bits, wgpu-core rejects larger values at build time, and 218 slots fit. Storing the slot has a reason the PRs do not state; what follows is this guide's reading of wgpu-core. wgpu-core uploads only the filled entries, so a hit's own instance index counts filled entries. That index drifts from the slot as soon as a lower slot is empty or holds a Moving Instance.

    The mask filters hits. A ray carries an 8-bit cull mask, and an entry counts only if the two share a set bit. With 0xff every ray sees every entry. Masks could later keep some geometry out of shadow rays and other geometry out of probe rays.

    A frame anchored at load

    ADR 0014 keeps f32 World positions off the GPU. f32 positions lose precision beyond 512 m from their origin, and Praxis's worlds must work at 10 by 10 km and more. Positions travel as an Anchor, the 64 m grid cell that holds them, plus an f32 offset from the cell's corner. Subsystems that compute in f32 "convert at their boundary into a local frame they own", and ADR 0014 lists ray-tracing acceleration structures among them. A TLAS transform is twelve f32, so the static TLAS owns such a frame.

    Its origin is the load Anchor, the corner of the camera's Anchor cell in the first Frame data the TLAS takes, and it is never re-anchored in M3. Each entry's translation is composed in f64 and rounded to f32 once, in anchored_transform:

    translation = (Instance Anchor − load Anchor) × 64 m + Instance offset

    The rows come out as [R | t], row-major as wgpu expects; Metal's layer transposes them into its own column-major record. The Instance's offset is itself an f32 from prepare, but it is under 64 m, so its own rounding is at most 2−19 m, about 1.9 µm, by this guide's arithmetic.

    Item 3 of the M3 decision on issue #17 anchors the static TLAS at load "so M1's 100 km check holds for shadows". The 100 km check renders each Reference scene that has a Golden, shifted 100 km from the World origin, and requires it to match that Golden; the helmet scene, whose source is fetched, has none. Placed by raw World position, entries 100 km out would step 7.81 mm in f32. Measured from the load Anchor, a scene within about 2 km of it steps at most 0.24 mm.

    That 0.24 mm is an upper bound. The f32 step at distance d is 2(⌊log2 d⌋ − 23) m: at most 0.122 mm below 2,048 m, and 0.244 mm from 2,048 to 4,095 m. A coordinate "about 2 km" out can pass 2,048 m once the up to 64 m between the camera and its cell's corner is added. For comparison, ADR 0014's bar for drawn positions is 0.043 mm, for a point 0.3 m from the camera at 1440p.

    Jump to:
    Load Anchor:
    one f32 measured from the load Anchorone f32 measured from the World origin
    Step, measured from the load Anchor
    0.122 mm
    Largest rounding error there
    0.061 mm
    Step as one f32 from the World origin
    7.81 mm
    Live demo. The f32 step, 2(⌊log2 d⌋ − 23) m, for a coordinate d metres from the load Anchor (blue), and for the same point stored as one f32 from the World origin (orange, dashed), with the load Anchor 100 km out as in the 100 km check. Both axes are logarithmic, and each stair is one power of two. Rounding to f32 is off by at most half a step. The marks show one Anchor cell, 64 m; ADR 0014's 512 m; about 2 km, where the step reaches decision item 3's bound at 2,048 m; and 100 km.

    PR #235's test runs the anchoring 100 km out. It puts the load Anchor at cell [1562, 0, −3], whose x is 1562 × 64 = 99,968 m, and writes three Instances: a static one "turned and doubled", a Moving one, and an unturned static one at x 5. Only the two static ones get entries, tagged with their slots, 0 and 2. The turned one's Anchor [1563, −1, −3] and offset [0.25, 63.5, 10.0] give a translation of (64.25, −0.5, 10), and its rows come out as [0, −2, 0, 64.25, 2, 0, 0, −0.5, 0, 0, 2, 10]. A ray at (64.25, 1.1, 10) hits it. A ray at (64.25, −2.1, 10), where a transposed rotation would have put it, misses.

    Chapter 6 · PR #235

    When it rebuilds

    wgpu 30 cannot update a TLAS in place. ALLOW_UPDATE exists, but its documentation reads "currently this is unimplemented and will build as normal", and wgpu-core always passes Build, although Metal's and Vulkan's layers below it implement updates. Every change to the static TLAS is therefore a full rebuild, so PR #235 rebuilds only when it must:

    • The first Frame data always builds it, even with no static Instance. wgpu refuses to submit a pass that binds an unbuilt TLAS, which #203's shadow rays will do. The orrery exposed the gap: its bodies all move, so until the PR's second commit its static TLAS was never built.
    • After that, a frame rebuilds it only if it writes a static Instance or frees one's slot. In the PR's words, a static Instance is written when it is "spawned, moved, or given another Mesh or Material". A despawn frees its slot, and a static Instance that turns Moving empties its entry the same way. The code rebuilds on any write of a static Instance, even one whose data did not change, such as the rewrite of every Instance after lost uploads. The review weighed rebuilding on a Material-only change and kept it, as "right and rare".
    • Nothing else does. A Moving Instance's write, freeing a Moving Instance's slot (its entry was already empty), a write past capacity, and a frame that writes no Instance, such as one where only the camera moves, all leave it alone.

    A Moving Instance is one whose entity carries PreviousWorldTransform, which stays on every entity the fixed schedule moves. When these PRs merged, only the orrery's five bodies carried it; town's 10,000 pigeons (#275) and the crowd's 1,600 shadow proxies (#276) have joined them since. Prepare flags each run of Instance writes with InstanceWrite::moving and splits runs where the flag changes, so the static TLAS can skip them. #204's dynamic TLAS will take them from there.

    Each rebuild costs about 4.3 to 4.7 ms of CPU at 218 slots on the M4 Pro, as PR #235 measured in scenes of 5 to 100 Instances (chapter 8).

    Live demo. Frame 0 is the first Frame data, which always builds. Each button adds an event to the next frame, and Run the frame closes it. A frame rebuilds when it writes a static Instance or frees a slot that held one. Moving Instances are rewritten every frame and never rebuild it. The scene starts with four static Instances and two Moving ones, and three frames already run. The CPU figure is PR #235's, at 218 slots on the M4 Pro.
    Chapter 7 · PR #235

    Why 218 slots

    A TLAS's capacity is fixed when it is created, as max_instances, and in wgpu 30 every rebuild walks all of it, filled or not:

    • Device::create_tlas allocates one None per slot, so a Tlas always holds a Vec as long as its capacity.
    • Recording a build maps every element of that Vec, and wgpu-core then resolves and collects every element into a new one.
    • When the encoder is processed, which the PR times as finish, wgpu-core converts each filled entry into the bytes the GPU reads. The build itself counts only the filled entries.
    • wgpu tracks the lowest unmodified slot, but wgpu-core 30.0.1 only passes it through and never builds part of a TLAS.

    Ticket #202's split comment measured the effect on the M4 Pro. Rebuilding a TLAS holding 11 Instances took 0.8 ms of record plus finish at 216 slots, 4.3 ms at 218 and 9.7 ms at 219.

    Measured points only, from #202's split comment: CPU time to record and finish one rebuild, M4 Pro, 11 Instances in the TLAS. No line joins them, because three points do not show how the cost grows in between.

    By this guide's arithmetic, that is about 16.4 ns per slot at 218. The GI survey timed a TLAS of 100,064 entries at 4.8 to 6.4 ms of record plus finish, about 48 to 64 ns per entry by the same arithmetic; it did not record whether that TLAS's capacity exceeded its entries. Empty slots look cheaper than filled ones, but at 218 they make up nearly all of the cost.

    So the static TLAS has 218 slots, sized for town's planned 250k Instances and no larger. At 219, the Instance buffer's size, every static spawn would cost about 10 ms of CPU even in an 11-Instance scene, by the PR's estimate from the 9.7 ms point.

    The TLAS is indexed by slot, so its capacity caps slot numbers. A static Instance in slot 262,144 or above is left out even when fewer Instances than that are static, and Moving Instances hold slots too. In prepare, "a slot freed in an earlier frame is taken again before a fresh one", so slot numbers grow only when no freed slot is waiting. All of town's about 250k Instances, its 10,000 Moving ones included, must fit below slot 262,144, which leaves 12,144 slots of headroom by this guide's arithmetic. As built in #273 and #275, town has 254,256 Instances, which leaves 7,888.

    Past capacity, a static Instance is left out, so it will cast no shadow. Frame stats count it in static_instances_left_out, shown on the counts line, and the log warns once each time the count rises from zero: "Static Instances left out of the static TLAS: their slots are past its capacity, so they cast no shadow". A test with a capacity of 2 writes static Instances to slots 0 to 3, then checks that 2 are left out, that freeing slot 3 leaves 1, and that one warning appears.

    Memory follows capacity as well. wgpu-core sizes both the TLAS and its buffer of entries by capacity, so the estimate counts the static TLAS full, whatever it holds.

    CapacityM4 Pro, 288.5 B per entrylavapipe, 240 B per entry
    218 = 262,144, the static TLAS75,628,544 B (72.125 MiB)62,914,560 B (60 MiB)
    219 = 524,288, for comparison151,257,088 B125,829,120 B
    Estimated bytes at capacity. The 218 row is what PR #235 adds to every scene's gpu_memory_bytes; between the two PRs' CI runs, every lavapipe scene moved by exactly 62,914,560 B.

    The capacity is provisional, like every GPU buffer M3 adds. M4's split of the 8 GB Budget sizes them all (spec #188, story 44).

    Chapter 8 · both PRs

    What it costs today

    Both PRs were benched with praxis bench on the M4 Pro at 2560×1440, as medians over 1,800 frames. Main ran before and after each branch, at a load average of 6 to 25, while other work on the machine held the GPU. Steady frames did not move:

    • GPU medians equal main's within 2 µs, after each part.
    • CPU medians are 38 to 43 µs after part 1 and 41 to 44 µs after part 2, within main's own spread of 39 to 51 µs across its two runs. texture-wall, which is load-sensitive (#183), measured 1,984 µs and then 2,053 µs, against main's 1,957 and 1,994.
    • Draw calls, uploaded bytes, Arena bytes in use and heap allocations (98 a frame in these runs, 138 in the Baselines both PRs recorded on main as of c77647f, after other PRs had added 40) are unchanged, so a steady frame does not rebuild the static TLAS, orrery's Moving Instances included.
    • gpu_memory_bytes grows by each scene's BLASes, as in chapter 4, and by a further 75,628,544 B in every scene for the static TLAS at capacity.

    The cost lands in two kinds of frame: one that rebuilds the static TLAS, and the first frame, where the load completes.

    SceneSteady frameStatic spawn's frameDespawn's frameFirst frame, main → PRState::new, main → PR
    texture-wall, 100 Instances, the largest16.9–17.821.8–22.422.1–22.375–80 → 95–97~1,380, unchanged
    helmet, 11 Instances of 23,358 triangles1.35–1.416.1–6.26.3–6.89.4–10.9 → 20.2–21.839–57 → 41–48
    orrery0.37–0.446.8–6.95.2–5.48.6–9.1 → 9.5–11.314–15, unchanged
    PR #235, both parts against main, in ms of wall time from update to the frame's completion. Medians of 7 per round, two interleaved rounds.
    • CPU of a static spawn or despawn: about 4.3 to 4.7 ms. Recording takes 3.5 to 3.7 ms in upload, finish 0.6 to 1.1 ms in encode, and submit under 0.1 ms. The PR puts all of it down to wgpu walking the 218 slots.
    • GPU: the rest of the frame's growth. On texture-wall and helmet it is 0.25 to 0.56 ms. On orrery's spawn it is about 2 ms, because these runs predate the first-build fix, so that spawn built orrery's TLAS for the first time. The build sits outside the passes' timestamps, so Frame stats' GPU total leaves it out.
    • First frame. In the PR's words, this is where the load completes. It now builds the BLASes, waits for them and compacts them (part 1), then builds the TLAS for the first time, which costs more than a rebuild. On helmet that is about 9 ms more than part 1 alone.

    Part 1 alone moved setup and the first frame as below. These were separate runs from the table above, so the main columns differ.

    SceneState::new, main → part 1First frame, main → part 1
    helmet40 → 41 (noise)9.4–10.3 → 11.5–12.0
    orrery14.2–14.8 → 14.6–14.88.7 → 9.5–10.1
    texture-wall1,405–1,454 → 1,411 (one outlier at 1,636)75.5–76.6 → 88.1–92.8
    PR #234, in ms: setup, and the first frame before the next one starts. Medians of 7, in two interleaved rounds against main.

    For scale, the GI survey's scratch program, whose figures spec #188 repeats, measured 4.8 to 6.4 ms of CPU to record and finish, plus 1.1 to 1.4 ms of submit, for a TLAS rebuild of 100,064 entries. ADR 0020 keeps a rebuild of that size, 6 to 8 ms of CPU, out of the frame, with the near field's radius and the move off the critical path both settled in M4. Until then a static spawn or despawn is a hitch, and spec #188 asks for it to be measured until M4 moves the rebuild.

    Goldens are unchanged on Metal and on CI's software Vulkan. Nothing renders differently, because nothing traces a ray until #258.

    Chapter 9

    What this unlocks

    • #203 traces the first rays. Every directional light gets one hard shadow ray per pixel from the lit pass, an inline ray query against the static TLAS from a normal-offset origin, skipped where N·L ≤ 0, with a render switch to turn shadow rays off. It adds the shadows Reference scene, a sphere over a plane checked against an analytic oracle, and the 100 km check must pass with shadows on every scene. PR #235's first-build fix exists for it. (#258, merged on 2026-10-23 as 0db5e46, traced them that way. Later that day #277, merged as bd7b988, moved every shadow ray into a compute pass that writes a shadow mask of up to 59 lights per pixel, which the lit pass reads; Shadow Rays Explained covers both.)
    • #204 adds the dynamic TLAS: the Moving Instances, rebuilt each frame in the static TLAS's load-anchored frame, with a fixed capacity of 10,000. Past it they are left out, counted and warned about. It starts from the writes that InstanceWrite::moving flags, and the orrery's planets begin to cast shadows. (As #264 built it, merged on 2026-10-23 as 79cdabb, prepare also lists each Moving Instance it writes, with its slot, in moving_instances, and the dynamic TLAS is built from that list; Dynamic TLAS Explained covers it.)
    • #205 adds point-light shadow rays, one per light in the pixel's Froxel list, against both TLAS.
    • #206 builds town: about 1 × 1 km within 2 km of its TLAS anchor, about 250k Instances of 10 to 20 Meshes, 10,000 of them Moving. It checks that 218 slots fit. #207 then records its Baselines and the dynamic TLAS's CPU and GPU ceilings. (#273 and #275 built it, with 254,256 Instances of 19 Meshes, below the 262,144 slots, and #275 recorded its first Baselines; PR #299, merged on 2026-10-26 as 800c0d2, re-recorded them and closed #207, with praxis compare holding the dynamic TLAS to the owner's ceilings, 1.1 ms of CPU and 1.0 ms of GPU. Town Explained covers it.)
    • #208 and #209 measure a crowd: 100 meshes of 5,120 triangles deformed on the GPU each frame, each BLAS refitted through wgpu-hal on Metal in one documented unsafe block on the bench path, against rigid per-bone proxies in the dynamic TLAS. The crowd's acceleration-structure update has a budget of at most 1.0 ms of GPU and 0.5 ms of CPU per frame, and the owner picks M7's form at M3's exit. (The owner picked the per-bone proxies on 2026-10-26: they update in 597 to 603 µs of GPU and 319 to 326 µs of CPU, where the refit, merged in #289 behind the crowd_form Render switch, took 1.37 to 1.38 ms of GPU, on Metal only. Crowd Shadows Explained and BLAS Refit Explained cover them.)
    • M4 sets the TLAS's near field and merged far proxies with Chunk streaming, re-anchors the static TLAS, takes its rebuild and compaction's wait off the frame's critical path, and replaces the estimates with exact sizes in its 8 GB split.
    • M5's probe GI will read the same TLAS as the shadow rays.
    Reference

    Reading list

    Grouped by source, each with what it is good for here. Every link below resolved when the guide was written. wgpu links are pinned to v30.0.1, and repo links to the PRs' merge commits or to main at 0b29a3c.

    These PRs and their sources

    In the repo

    wgpu 30.0.1

    Vulkan

    Metal

    Books

    • Real-Time Rendering, 4th ed.: ch. 19 Acceleration Algorithms, with BVHs in §19.1.1; ch. 22 Intersection Test Methods; ch. 4 Transforms, for 3×4 affine matrices
    • Real-Time Rendering, 4th ed., online ch. 26 Real-Time Ray Tracing §26.3 Top and Bottom Level Acceleration Structures; §26.4 Coherency, for grids, k-d trees and BVHs
    • Physically Based Rendering, 3rd ed.: §4.3 Bounding Volume Hierarchies, 4.3.1 to 4.3.5, and §4.1.2 on object instancing, the TLAS idea offline. 4th ed.: §7.3 and §7.1.2
    • Game Engine Architecture, 4th ed., by the book's site: §12.6 Lighting with Stochastic Ray Tracing and §14.3 The Collision Detection System marginal here; the 3rd edition's numbers were not checked
    Reference

    Glossary

    Acceleration structure
    A GPU search structure that finds what a ray hits without testing every triangle; driver-specific and opaque.
    BVH
    A tree of nested bounding boxes; a ray descends only into boxes it hits.
    BLAS
    Bottom-level acceleration structure: one Mesh's triangles, in the Mesh's own space.
    TLAS
    Top-level acceleration structure: entries that each place a BLAS by a transform.
    TLAS entry
    wgpu's TlasInstance: a BLAS, a 3×4 row-major transform, 24 bits of custom data, an 8-bit mask.
    Instance
    One Renderable's data on the GPU, in its slot of the Instance buffer.
    Moving Instance
    An Instance whose entity carries its previous World transform; every other Instance is static.
    Static TLAS
    The TLAS over static Instances, built from the first Frame data and rebuilt when they change.
    Dynamic TLAS
    The TLAS of Moving Instances that #204 adds, rebuilt every frame, capped at 10,000.
    Anchor
    The integer coordinates of the 64 m grid cell that holds a World position.
    Load Anchor
    The corner of the camera's Anchor cell in the first Frame data; the static TLAS's origin in M3.
    Compaction
    Copying a built BLAS into a tighter allocation once its size is known.
    Scratch buffer
    Temporary GPU memory for a build, allocated inside each wgpu build call.
    Ray query
    A ray traced inline from an ordinary shader, which loops over its candidate hits itself.
    PREFER_FAST_TRACE
    A build hint that spends build time on faster tracing.
    Refit, rebuild
    Recomputing a tree's boxes in place, or building it again; wgpu-core 30 only rebuilds.
    Instance mask
    An 8-bit tag per entry; a hit counts only if it shares a bit with the ray's cull mask.
    instance_custom_data
    The 24-bit value an entry returns with a hit; here, the Instance slot.
    Capacity
    The slots a TLAS is created with; in wgpu 30 every rebuild walks them all.
    Estimate
    Bytes counted from figures measured once per backend, since wgpu 30 reports no size.
    Capability floor
    The GPU features and limits the engine requires to start; ray queries are among them.
    Built from PR #234 and PR #235 at their draft heads, f1fc071 and 1ca54c2, rebased onto main at 0b29a3c (their code merged as written in 6f607e8 and 93355f1), and ticket #202 with its split comment. Decisions come from ADRs 0014, 0017, 0020 and 0021, spec #188 and the M3 decision on #17; the M4 Pro's sizes and timings from the GI survey note, and lavapipe's from PR #231's CI probe. Code facts were read at those commits and wgpu facts at v30.0.1; the notes on later work were checked against main at 270c5e4. Where the guide does its own arithmetic, it says so. The demos are toy 2D models of the ideas, not the engine's code.