Praxis · M3 · ticket #204 · PRs #264 and #266

How Moving Instances cast shadows

Until #204, only static Instances cast shadows. The orrery's planets move in every frame, so they were in no TLAS, and the shadow rays passed through them. Ticket #204 gives the Moving Instances an acceleration structure of their own, the dynamic TLAS, which the Renderer rebuilds from scratch in every frame from at most 10,000 of them. Each shadow ray is traced against it wherever the static TLAS lets the ray through. This guide explains why there are two structures rather than one, which 10,000 Instances the dynamic one keeps when more move, how each frame builds it, and how part 2 times that build. Two live demos show the cap and the two queries.

Provenance. #204 landed in two parts. Part 1, PR #264, merged as 79cdabb, builds the dynamic TLAS and traces shadow rays against it, in four commits: 258001a; 11e9388, with the review's fixes and the skip of an empty dynamic TLAS; a11bbd3, the M4 Pro Baselines; and 786f195, software Vulkan's Golden and Baselines. Part 2, PR #266, merged as 80227bc, times the build on the CPU and the GPU (chapter 7). The figures quoted from the work were verified during the work on #204, on 2026-10-23. The code is quoted as #264 merged it unless a caption names a later commit, and where later merges moved it, the text says so (re-checked against main 69e02dc on 2026-10-24). Chapter 8 gives part 1's measured cost.

Chapter 1 · ADR 0020

Why two TLAS

A TLAS is the GPU's search structure over Instances: each entry places one Mesh's BLAS with a transform, and a ray walks the tree down to the triangles it meets. When an Instance moves, its entry changes and the tree has to follow. Through wgpu-core 30, the only way to make it follow is to build the tree again from scratch, and the time that takes grows with the Instances it holds. So Praxis keeps two trees: a large one that rarely changes, and a small one that is rebuilt every frame.

An acceleration structure can be brought up to date in two ways. A rebuild makes a new tree from the current contents. A refit keeps the tree's shape and recomputes its bounds: it is cheaper, and the tree traces more slowly the further things have moved since it was built. wgpu-core 30 implements only the rebuild. Asking for an update with the update mode PreferUpdate still builds from scratch: wgpu-core passes Build to the backend and, for a TLAS, logs a warning that "only rebuild is implemented". The flag ALLOW_UPDATE only allows later updates, and wgpu-core never records one (wgpu-core 30.0.1, command/ray_tracing.rs; the GI survey, docs/research/global-illumination-and-lighting.md, links the lines from its row "BLAS and TLAS updates").

The survey also timed a TLAS rebuild on the M4 Pro, with a scratch program on Metal and wgpu 30.0.1:

One rebuild100,064 Instances10,064 Instances
CPU, recording the build and finishing the encoder4.8 to 6.4 ms0.5 to 0.75 ms
CPU, in submit1.1 to 1.4 ms0.14 to 0.17 ms
CPU in all5.9 to 7.8 ms0.64 to 0.92 ms
GPU3.7 to 4.8 ms0.8 to 1.1 ms
One TLAS rebuild on the M4 Pro, the GI survey's "TLAS rebuild" figures. The CPU records the build and finishes the encoder, then submits it; the GPU builds. The CPU totals are this guide's sums of the two rows above them. ADR 0020 quotes the smaller, 0.64 to 0.92 ms, as the dynamic TLAS's cost.
A rebuild's CPU time against a frame's. Each bar is solid to the low end of its range and outlined to the high end; the dashed line is ADR 0013's CPU Budget of 6 ms. Drawn to scale, 0 to 8 ms.

ADR 0013's Budget for a frame's CPU time is 6 ms. Rebuilding 100,064 Instances in every frame would spend about all of it, and more at the high end, before the game had simulated or prepared anything. Rebuilding 10,064 takes 0.64 to 0.92 ms of it.

Static and moving

Most of what an open world holds stands still, such as terrain, buildings and rocks, and in any one frame only a small share of it moves. ADR 0020 splits the TLAS along that line. The static TLAS holds every static Instance and is rebuilt only in a frame that changes one, as when a static Renderable spawns or despawns (#202). The dynamic TLAS holds the Moving Instances, is rebuilt in every frame, and has a fixed capacity of 10,000. ADR 0020 records it as "a dynamic TLAS of Moving Instances rebuilt each frame and capped at 10k Instances (0.64 to 0.92 ms of CPU on the M4 Pro, recording plus submit, measured). Shadow and probe rays query both."

The split has three consequences, each a chapter below. A ray needs a second query where the first finds nothing (chapter 5). A frame in which more than 10,000 Instances move needs a rule for which ones keep their place (chapter 3). And the capacity itself costs time in every build, filled or not (chapter 4).

Chapter 2 · ADR 0022

What makes an Instance a Moving Instance

Praxis already knew which entities move. The fixed schedule moves them in Ticks, frames draw them between their two newest Ticks, and for that each one keeps the World transform it had after the previous Tick. #204 takes that component as the definition: an Instance is a Moving Instance while its entity carries PreviousWorldTransform. No new component marks it.

The simulation and the frames run at different rates. ADR 0022 runs everything that changes game state in the fixed schedule, once per Tick of 1/64 s, and a frame lands somewhere between two Ticks. To draw an entity there, prepare blends its previous and current World transforms in f64 by the blend weight, how far the frame has come from the older Tick toward the newer. Only the entities the fixed schedule moves pay for that: they carry their previous World transform, and every other entity stores nothing extra.

/// The World transform an entity had after the previous Tick, kept by every
/// entity whose World transform the fixed schedule changes, children of a
/// moving entity included, so frames draw it blended; other entities store
/// nothing extra. `None` from its spawn until the next Tick starts, and
/// after a teleport: frames then draw its current World transform, so a
/// child of a moving parent runs up to a Tick ahead of the parent until then.
#[derive(Component, Copy, Clone, Debug, Default, PartialEq)]
pub struct PreviousWorldTransform(pub Option<glam::DAffine3>);
The component, from src/transform/mod.rs. ADR 0022's blend added it; #204 reads its presence as the classification. Its value is None for a while after a spawn or a teleport, and the Instance still counts as moving then: what counts is that the entity carries the component.

A blended entity is drawn somewhere new in every frame, between Ticks too, so prepare writes its Instance in every frame and flags the write as moving. That gives the dynamic TLAS its list without a search: a frame's Moving Instances are exactly its Instance writes flagged moving. FrameData, the Frame data that prepare hands the Renderer, gains one field, filled as the writes are made:

// In FramePreparer::prepare, for each Renderable.
// A blended Renderable moves every frame, between Ticks too.
if *rewrite_every_instance
    || is_new
    || current.is_changed()
    || previous.is_some()
    || mesh_ref.is_changed()
    || material_ref
        .as_ref()
        .is_some_and(|material_ref| material_ref.is_changed())
    || lost_material_handle_or_previous_transform.contains(&entity)
{
    let transform = current.drawn(previous, blend_weight);
    // … the Instance's rotation, Anchor and offset, from `transform`
    frame.write_instance(slot, InstanceData { … }, previous.is_some());
}

// In FrameData.
/// The Moving Instances the dynamic TLAS holds, at most
/// `DYNAMIC_TLAS_CAPACITY`, those in the lowest slots, in no order. Each
/// is written every frame, so each is among `instances`.
pub moving_instances: Vec<MovingInstance>,

fn write_instance(&mut self, slot: u32, instance: InstanceData, moving: bool) {
    let index = self.instances.len();
    self.instances.push(instance);
    if moving {
        self.moving_instances.push(MovingInstance {
            slot,
            instance: index,
        });
    }
    // … then queues the write, in the last one when `slot` follows it
    // and it is of the same kind
}
Where the list comes from, abridged from src/render/prepare.rs. previous is the entity's PreviousWorldTransform, if it has one: having it both forces the write and flags it as moving. A MovingInstance is a slot and an index into the frame's instances; it copies no Instance data.

Gaining and losing the component

An entity can gain the component or lose it in any frame, and its Instance then has to move from one TLAS to the other in that same frame.

  • Gaining it. From that frame on, prepare writes the Instance in every frame, flagged moving, and the dynamic TLAS lists it. The static TLAS clears the slot's entry when a moving write reaches it, and rebuilds (StaticTlas::update, since #202).
  • Losing it. Prepare now writes the Instance once more in the frame its entity loses the component, unflagged, so the static TLAS takes it. Losing PreviousWorldTransform joins losing a Material Handle among the changes that force a write.
// Those that lost their Material Handle now draw with their Mesh's
// Materials, and those that lost their previous World transform are
// static, so the static TLAS takes them.
let lost_material_handle_or_previous_transform: bevy_ecs::entity::EntityHashSet = world
    .removed::<MaterialRef>()
    .chain(world.removed::<PreviousWorldTransform>())
    .collect();
The losses that force a write, in FramePreparer::prepare. The set feeds the last line of the condition above.

Without that write, nothing would have told either TLAS. An Instance that stands still is written only when something about it changes, so the slot's last write would stay the one flagged moving. The static TLAS would keep the slot's entry clear, and the dynamic TLAS, which lists only the current frame's moving writes, would drop it. The Instance would cast no shadow until something else rewrote it. #235's review noted the gap; the figure follows one entity through it.

Prepare:
FrameThe entityPrepare writes its InstanceStatic TLASDynamic TLASIts shadow
1spawns, at restyes, statictakes itdoes not list itfrom the static TLAS
2gains PreviousWorldTransformyes, flagged movingclears its entrylists itfrom the dynamic TLAS
3moves between Ticksyes, flagged movingentry stays clearlists itfrom the dynamic TLAS
4loses it, at restyes, statictakes itdoes not list itfrom the static TLAS
5stays at restnokeeps itdoes not list itfrom the static TLAS
One entity's Instance, frame by frame, with and without the write that #204 adds in frame 4. Without it, the Instance drops out of the dynamic TLAS and never reaches the static one.

The test an_instance_is_moving_while_its_entity_carries_its_previous_world_transform, at FramePreparer, covers both directions. Without the removal check it fails in the frame the component is lost, because prepare writes no slot: left: [] right: [0].

Chapter 3 · decision on map #17, item 8

Ten thousand, kept by slot

The dynamic TLAS holds at most 10,000 Moving Instances. When more move, the rest are left out and cast no shadow. Which ones are left out matters less than that the choice holds still from frame to frame: an Instance should not lose its shadow because some other entity changed. So the lowest slots keep their place, because an Instance's slot changes only when it spawns or despawns.

The capacity is a constant, DYNAMIC_TLAS_CAPACITY = 10_000 in src/render/acceleration_structures.rs, provisional until milestone M4 splits the 8 GB GPU memory Budget. The owner's decision on map #17, item 8, set the policy: "Moving Instances form the dynamic TLAS, fixed at 10,000; past it they are left out (no shadows), counted in Frame stats and warned about." The architecture review chose which ones: "Past 10,000 Moving Instances, the lowest slots keep their place in the dynamic TLAS."

// Past the dynamic TLAS's capacity, the lowest slots keep their place.
let capacity = DYNAMIC_TLAS_CAPACITY as usize;
*moving_instances_left_out = frame.moving_instances.len().saturating_sub(capacity) as u32;
if *moving_instances_left_out > 0 {
    frame
        .moving_instances
        .select_nth_unstable_by_key(capacity, |moving| moving.slot);
    frame.moving_instances.truncate(capacity);
    if !*warned_of_moving_instances_left_out {
        tracing::warn!(
            left_out = *moving_instances_left_out,
            capacity,
            "Moving Instances left out of the dynamic TLAS, past its capacity: those in the highest slots cast no shadow"
        );
    }
}
*warned_of_moving_instances_left_out = *moving_instances_left_out > 0;
The cap, at the end of FramePreparer::prepare. select_nth_unstable_by_key moves the 10,000 lowest slots ahead of index 10,000, in no order, in O(n) time and with no full sort; truncate drops the rest. The warning fires once per episode: in the first frame that leaves Moving Instances out, and again only after a frame that left none out.

The count reaches Frame stats as moving_instances_left_out in every frame, through FramePreparer::moving_instances_left_out(). The Moving Instances left out are still drawn; they only cast no shadow. They stay out of the static TLAS as well, since their writes are still flagged moving.

Why not the first 10,000

The obvious rule keeps the first 10,000 that prepare meets. Prepare meets them in the ECS World's iteration order, and that order moves. bevy_ecs stores the entities of each archetype together, a table row each, and iterates table by table, row by row. An entity that spawns, or gains or loses a component stored in tables, bevy_ecs's default, is added at the end of its new table, and a removed entity's row is filled by its table's last entity (bevy_ecs 0.18.1, Table::allocate and Table::swap_remove_unchecked). So the order shifts whenever entities spawn, despawn or change components, even for an instant: a teleport inserts the Teleported marker and removes it within one Tick, which leaves the entity at the end of its table. Slots shift only on spawn and despawn: a Renderable keeps its slot for as long as it is one.

The test the_moving_instance_in_the_highest_slot_past_the_dynamic_tlas_capacity_is_left_out builds the case. It spawns 10,001 Moving Instances, so the one in slot 10,000 is left out, counted in each frame and warned about once. It despawns the one in slot 3, so all fit, then spawns a new one, which takes slot 3 again, since a slot freed in an earlier frame is taken before a fresh one. The ECS World now holds the newcomer last. Under the rule as shipped, slot 10,000 is still the one left out. Truncating in ECS World order instead, the red check, left out slot 3.

Keep:

The Instance buffer's slots: choose one to act on its Moving Instance

The ECS World's iteration order, entity · slot

    Frame
    Moving Instances
    In the dynamic TLAS, of 8
    Left out

    in the dynamic TLAS left out: drawn, but casts no shadow a free slot
    Live demo. The cap, scaled down from 10,000 to 8, in an Instance buffer of 16 slots. Each action is one frame: spawn a Moving Instance, or choose a slot and despawn or teleport its Moving Instance; prepare then lists every Moving Instance and keeps 8 by the chosen rule, and each slot says which ones joined or dropped out that frame. Slots are taken as prepare takes them: the slot freed most recently in an earlier frame, else a fresh one. The ECS World's order follows bevy_ecs 0.18.1's tables: a new or returning entity goes to the end, and a removed one's row takes the table's last entity. A teleport inserts and removes the Teleported marker in one Tick, so its entity leaves its table and comes back at the end. "Replay the test" runs the test's three frames, scaled down: 9 Moving Instances, slot 3's despawned, a new one spawned. Simplified: every Moving Instance shares one table, as in the test.
    Chapter 4 · DynamicTlas

    Building it every frame

    Each frame, the Renderer refills the dynamic TLAS from Frame data's list and records a build. Each entry places one Moving Instance's BLAS where the Instance stands, in the same coordinate frame as the static TLAS, so a ray composed once can be traced against both. The build's cost depends on the TLAS's capacity as well as on what it holds, which is why that capacity is 10,000 and not the static TLAS's 218.

    Created once, at its capacity

    DynamicTlas::new creates the TLAS once, with room for 10,000 entries, and the Renderer rebuilds it in place from then on. Its binding stays valid across rebuilds, so the scene-geometry bind group that holds it is built once.

    pub fn new(device: &wgpu::Device, backend: wgpu::Backend) -> Self {
        Self {
            tlas: device.create_tlas(&wgpu::CreateTlasDescriptor {
                label: Some("Dynamic TLAS"),
                max_instances: DYNAMIC_TLAS_CAPACITY,
                // Traced at every lit pixel, as the static TLAS is; ADR 0020's
                // per-frame cost was measured with this flag.
                flags: wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE,
                update_mode: wgpu::AccelerationStructureUpdateMode::Build,
            }),
            filled: None,
            entry_upload_bytes: match backend {
                // `MTLIndirectAccelerationStructureInstanceDescriptor`.
                wgpu::Backend::Metal => 72,
                // `VkAccelerationStructureInstanceKHR`,
                // `D3D12_RAYTRACING_INSTANCE_DESC`.
                _ => 64,
            },
        }
    }
    DynamicTlas::new, in src/render/acceleration_structures.rs. filled remembers how many entries the last build filled; entry_upload_bytes is what each entry costs to upload, below. As #264 merged it: since #266, new also takes the queue, builds the empty TLAS at once, and makes chapter 7's markers.

    PREFER_FAST_TRACE asks for a tree that traces fast, at the expense of build time. wgpu's documentation recommends it "if the geometry is unlikely to change", and its opposite, PREFER_FAST_BUILD, "if geometry is likely to change frequently" (wgpu-types 30.0.1, AccelerationStructureFlags). The dynamic TLAS changes in every frame and still takes the fast-trace flag: it is traced at every lit pixel, as the static TLAS is, and ADR 0020's per-frame figures were measured with it. Vulkan and DX12 receive the flag as their own prefer-fast-trace build flag; on Metal, wgpu-hal 30.0.1 sets no acceleration-structure usage for it, so on the M4 Pro both TLAS are built with Metal's default usage (metal/conv.rs).

    Filled from the list

    Each frame, DynamicTlas::update walks Frame data's list. Entry i gets the i-th listed Moving Instance: its Mesh's compacted BLAS from the Mesh pool, its transform as the rows of a 3×4 matrix, and its slot as the entry's custom data, as the static TLAS tags its entries. Entries that the previous frame filled beyond this frame's count are cleared. Then the build is recorded into the frame's encoder.

    pub fn update(
        &mut self,
        frame: &FrameData,
        mesh_pool: &MeshPool,
        anchor: [i32; 3],
        encoder: &mut wgpu::CommandEncoder,
    ) -> u64 {
        // Prepare lists no more than its capacity.
        let listed = frame
            .moving_instances
            .iter()
            .take(DYNAMIC_TLAS_CAPACITY as usize);
        let filled = listed.len();
        let previously_filled = self.filled.replace(filled);
        if filled == 0 && previously_filled == Some(0) {
            return 0;
        }
        let mut uploaded_entries = 0;
        for (index, moving) in listed.enumerate() {
            let instance = &frame.instances[moving.instance];
            let blas = mesh_pool.blas(MeshHandle(instance.mesh));
            uploaded_entries += u64::from(blas.is_some());
            self.tlas[index] = blas.map(|blas| tlas_instance(blas, instance, anchor, moving.slot));
        }
        for index in filled..previously_filled.unwrap_or(0) {
            self.tlas[index] = None;
        }
        encoder.build_acceleration_structures(std::iter::empty(), std::iter::once(&self.tlas));
        uploaded_entries * self.entry_upload_bytes
    }
    DynamicTlas::update. tlas_instance, which the static TLAS uses too, makes one entry: the BLAS, the transform from anchored_transform (below), the slot, and the cull mask 0xff that every ray carries. The return value, the bytes the build uploads, joins the frame's uploaded_bytes. #266 renamed it record: the same fill, with the build recorded into a command buffer of its own between two markers (chapter 7).

    The clearing loop matters. A Moving Instance that despawns leaves the list; without the loop, its entry would keep the Instance's last transform and go on casting its shadow from there. Chapter 5's test fails that way when stale entries are kept.

    When it is empty

    A scene in which nothing moves lists no Moving Instances, and rebuilding an empty TLAS in every frame would waste the build. Some build is still needed. At submit, wgpu-core's validation refuses a TLAS that was never built if a bind group in use holds it (UsedUnbuiltTlas), and the scene-geometry bind group always holds this one. So an empty dynamic TLAS is built only in its first frame, so that wgpu-core accepts the submissions that use it, and in the frame it empties, so that it no longer holds what last moved. In every other empty frame, update returns before it records anything. Since #266, DynamicTlas::new builds it empty once, at creation, so that chapter 7's markers never query it unbuilt.

    The frame both TLAS share

    A ray is composed once, in one coordinate frame, and traced against both structures, so both must place their Instances in that frame. The static TLAS's frame has its origin at the corner of the camera's Anchor in the first Frame data, and milestone M3 never moves it (#202). The dynamic TLAS takes the same Anchor. anchored_transform composes each translation from that corner in f64, from whole 64 m cells plus the Instance's f32 offset, so only the f32 result rounds (ADR 0014).

    /// `instance`'s transform as rows of the 3×4 matrix a TLAS Instance holds,
    /// its translation from `anchor`'s corner composed in f64, so only the f32
    /// result rounds (ADR 0014).
    fn anchored_transform(instance: &InstanceData, anchor: [i32; 3]) -> [f32; 12] {
        let [x_axis, y_axis, z_axis] = instance.rotation_scale;
        let row = |axis: usize| {
            let cells = i64::from(instance.anchor[axis]) - i64::from(anchor[axis]);
            let translation = cells as f64 * ANCHOR_CELL_SIZE + f64::from(instance.offset[axis]);
            [x_axis[axis], y_axis[axis], z_axis[axis], translation as f32]
        };
        bytemuck::cast([0, 1, 2].map(row))
    }
    
    // In Renderer::upload.
    // Shadow rays start in the static TLAS's frame, whose Anchor the
    // first Frame data's camera set, and the dynamic TLAS shares it.
    let tlas_anchor = self.static_tlas.anchor();
    recorder.uploaded_bytes +=
        self.dynamic_tlas
            .update(frame, &self.mesh_pool, tlas_anchor, &mut encoder);
    let camera = CameraUniform {
        tlas_anchor,
        dynamic_tlas_entries: self.dynamic_tlas.entries(),
        ..frame.camera
    };
    One Anchor for both. anchored_transform in acceleration_structures.rs, and Renderer::upload in renderer.rs: the camera uniform's tlas_anchor, from which the shader composes each ray's origin, and the dynamic TLAS take the same value. dynamic_tlas_entries is chapter 5's. Since #266, the build is recorded in Renderer::render instead, and the count is the length of Frame data's list.

    The Renderer test of chapter 5 checks the shared frame. After its first frame anchors the static TLAS, it gives the camera's position from the next Anchor cell over, the same place in other numbers, and the image must not change: it would, if the dynamic TLAS followed the camera's Anchor instead of the static TLAS's.

    The shader declares the new binding beside the old one. dynamic_tlas joins static_tlas in the scene-geometry bind group, group 2, visible to the fragment stage only (src/render/bind_groups.rs); since #277, to the compute stage only. The Capability floor already allowed two acceleration structures per shader stage with this pair in mind: max_acceleration_structures_per_shader_stage: 2, commented "ADR 0020's static and dynamic TLAS" (floor_limits in src/gpu/mod.rs).

    What each entry uploads

    wgpu-core converts each filled entry into the backend's own instance record on the CPU, and stages it for the build (tlas_instance_to_bytes in wgpu-hal 30.0.1). Frame stats' uploaded_bytes now counts those bytes.

    BackendIts instance recordBytes per entry
    MetalMTLIndirectAccelerationStructureInstanceDescriptor: a packed 4×3 float matrix, 48 B; options, mask, intersection-function offset and user ID, 4 B each; the BLAS's acceleration-structure ID, 8 B72
    VulkanVkAccelerationStructureInstanceKHR64
    DX12D3D12_RAYTRACING_INSTANCE_DESC64
    One entry's upload per build, from wgpu-hal 30.0.1's tlas_instance_to_bytes. The orrery's five spheres add 5 × 72 = 360 B to every frame's uploads on Metal.

    A test pins the orrery's whole upload per frame, the_orrerys_moving_instances_upload_their_dynamic_tlas_entries_every_frame in tests/frame_stats.rs: the 112-byte camera and 32-byte light uniforms, the sun and four point lights at 48 bytes each, the five 128-byte Instances, and their five entries, with none left out: 1,384 bytes on Metal, where main uploaded 1,024. The conversion also allocates: tlas_instance_to_bytes returns a Vec<u8> for each entry at each build, one heap allocation each, on the thread that finishes the encoder.

    What it takes in memory

    wgpu exposes no acceleration-structure sizes, so Praxis estimates them per backend from measurements (AccelerationStructureBytes; since #303, merged on 2026-10-26 as 4a7ab0b, per adapter: an NVIDIA GPU gets figures measured on an RTX 2070 SUPER, and every other adapter the M4 Pro's or lavapipe's below, as before). A TLAS is estimated per entry of its capacity, whatever it holds: 10,000 × 288.5 B = 2,885,000 B on the M4 Pro, and 10,000 × 240 B = 2,400,000 B on lavapipe, CI's software Vulkan. The estimate joins the frame's GPU memory beside the static TLAS's and the BLASes', in every scene (chapter 8).

    Why 10,000 and not more

    The capacity has a cost of its own. A wgpu 30 Tlas keeps one optional entry per slot of its capacity, and each build walks every one of them, filled or not. #202's notes timed it with 11 Instances: 0.8 ms to record and finish at 216 slots, and 4.3 ms at 218. The static TLAS, at 218, pays that only in the rare frames that rebuild it. The dynamic TLAS pays its walk in every frame, so its capacity stays at the 10,000 that ADR 0020 measured.

    let tlas = tlas.into_iter().map(|e| {
        let instances = e
            .instances
            .iter()
            .map(|instance: &Option<crate::TlasInstance>| {
                instance
                    .as_ref()
                    .map(|instance| wgc::ray_tracing::TlasInstance { … })
            });
        …
    });
    The walk, abridged from wgpu 30.0.1's build_acceleration_structures in wgpu/src/backend/wgpu_core.rs (MIT or Apache-2.0): every entry of the TLAS, filled or None, is mapped and handed to wgpu-core.
    Chapter 5 · main.wgsl

    Static first, then dynamic

    The lit pass still traces one shadow ray per pixel per directional light. It now asks the static TLAS first. Only where that finds nothing does it ask the dynamic TLAS the same question, with the same ray, and only if the dynamic TLAS holds anything. A pixel already in a static shadow never pays for the second query.

    // Whether either TLAS holds geometry within `distance` of the surface point
    // at `position`, relative to the camera, along `direction`, a unit vector:
    // one hard shadow ray (ADR 0020), traced against the static TLAS, then,
    // where that finds nothing, against the dynamic TLAS unless it is empty: a
    // query of an empty TLAS still costs the lit pass. It starts off the
    // surface along `normal`, its unit geometric normal, in the frame both TLAS
    // share, whose origin is the corner of the camera's `tlas_anchor`, composed
    // as an Instance's position is (ADR 0014).
    fn shadowed(position: vec3<f32>, normal: vec3<f32>, direction: vec3<f32>, distance: f32) -> bool {
        // … the origin, composed and offset as in #203
        // Opaque, so one rayQueryProceed completes a traversal, and any hit
        // shadows the point.
        let ray = RayDesc(
            RAY_FLAG_FORCE_OPAQUE | RAY_FLAG_TERMINATE_ON_FIRST_HIT,
            0xffu,
            0.0,
            distance,
            tlas_position + normal * offset,
            direction
        );
        var query: ray_query;
        rayQueryInitialize(&query, static_tlas, ray);
        rayQueryProceed(&query);
        let hidden = rayQueryGetCommittedIntersection(&query).kind != RAY_QUERY_INTERSECTION_NONE;
        if hidden || camera.dynamic_tlas_entries == 0u {
            return hidden;
        }
        rayQueryInitialize(&query, dynamic_tlas, ray);
        rayQueryProceed(&query);
        return rayQueryGetCommittedIntersection(&query).kind != RAY_QUERY_INTERSECTION_NONE;
    }
    shadowed, abridged from src/render/shaders/main.wgsl as #264 merged it. The RayDesc is built once; the same ray_query is initialised again for the second structure.

    Either order gives the same answer, since any hit shadows the point. The ray is opaque and stops at its first hit, so one rayQueryProceed completes each traversal, as in #203. Everything before these lines is unchanged: the origin, composed in the frame both TLAS share and offset along the geometric normal, and the test in fs_main that skips the ray where the light adds nothing. shadowed keeps its signature, so #205's point-light rays could call it as it is.

    Since then. #277 (bd7b988) moved the rays out of the lit pass. A compute pass, the shadow mask pass, traces them first, toward each light that reaches the surface, and keeps a bit per light for the lit pass, which makes no ray query any more. shadowed moved with them into src/render/shaders/shadow_mask.wgsl, its two queries and its skip unchanged.

    Where nothing moves

    Part 1's first commit, 258001a, sent every ray that the static TLAS let through on to the dynamic TLAS, even when it was empty. Measured against main, that empty query put the lit pass up by 452 to 2,255 µs on the scenes where nothing moves, so the second commit, 11e9388, skips it. The camera uniform now carries the dynamic TLAS's entry count, dynamic_tlas_entries, in the four bytes that padded tlas_anchor, so the uniform stays 112 bytes. Every pixel reads the same count, so in a frame with nothing moving, every pixel skips the second query alike. Chapter 8 gives both versions' costs, scene by scene.

    The orrery is the opposite case. It has no static Instances, so its static TLAS is empty, and every ray it traces makes both queries. The demo below counts them.

    static Instance Moving Instance lit shadowed by a static Instance shadowed by the Moving Instance the static query the dynamic query where a query hits
    Static Instances:
    Moving Instances:
    Rays traced
    Static TLAS queries
    Dynamic TLAS queries
    Shadowed

    Live demo. This page's model of shadowed, in two dimensions: a 20 m strip of ground, two static blocks standing on it and one moving sphere. Each of 40 evenly spaced points of the ground traces one ray toward the sun, except where a block stands, since no pixel sees the ground there (32 rays with both blocks standing): first against the static Instances, then, only where they let it through and only if a Moving Instance exists, against the Moving Instance. Beneath the ground, each point shows one square per query, the static TLAS's above the dynamic TLAS's. Drag along the drawing, or use the sliders, to move the ray, the sphere and the sun. Simplified: rays meet the shapes directly, standing in for each TLAS's walk down to triangles.

    The test

    A Renderer test, shadows_fall_from_both_tlas_and_shadow_rays_off_draw_none, extends #203's. A floor lies ahead of the camera, and a square behind the camera, out of view, hides a directional light from the floor's middle. Drawn as a Moving Instance, the square shadows the floor pixel for pixel as it does as a static Instance, also in a later frame whose camera Anchor differs, and its shadow goes in the frame that frees its slot. At 258001a, each wrong version was shown to fail it:

    • Tracing the static TLAS alone: "a Moving Instance shadowed otherwise than a static one".
    • Keeping stale entries: "a freed Moving Instance still shadows".

    11e9388 adds the Anchor check of chapter 4, whose message reads "the dynamic TLAS left the static TLAS's frame as the camera's Anchor changed". With the shadow-ray Render switch off, the frame still draws the unshadowed image.

    Chapter 6 · the orrery's Golden

    The orrery's first shadows

    #203's rays left the orrery unchanged, because all its Instances move. With #204 its spheres enter the dynamic TLAS, and its Golden changed by 73 pixels. None of them is a planet's shadow on another planet. They are speckles where each sphere shadows itself, near its terminator.

    The orrery Reference scene chains five spheres, each a child of the one before, each spinning about its own axis in the fixed schedule, so each circles its parent. All five carry PreviousWorldTransform, so all five are Moving Instances; there is no ground. At its Golden's frame, 640 × 360 on Metal, 73 pixels changed: 39 by one 8-bit step, and 34 by more, by 18 steps at most.

    The orrery's Metal Golden with #204: five textured spheres of falling size against a blue-grey background.
    Ring:
    The orrery's Metal Golden with #204, re-encoded as JPEG for this page, with the pixels that differ from main's Golden ringed: bold rings for the 34 that moved by more than one 8-bit step, fine rings for the 39 that moved by one. They lie along the edge of each sphere's lit side. The changes are too small to see in the image itself; this page found the 73 by comparing the two Goldens pixel by pixel.

    These speckles are the shadow terminator that #203 met on its static spheres. A sphere here is flat triangles shaded with normals interpolated from their vertices. Near the terminator, a triangle can already face away from the sun while the normals across it still face it: the shading lights the pixel, and the ray, which starts on that triangle, finds the sphere itself in the way. The previous guide, Shadow Rays Explained, has a demo of it in its chapter 5. The fix waits as a fog line on map #17, "Shadow terminator on coarse smooth-shaded meshes".

    Why no planet shadows another in the Golden

    The planets circle in one horizontal plane, and the sun stands about 60° above it. Each sphere's shadow leaves it along the sunlight, steeply downward. By this page's arithmetic from the scene's constants, under this sun a sphere's shadow reaches another sphere only if the other lies beyond the first, away from the sun, with their centres within about 1.16 times the sum of their radii. A sphere and its child always stay 1.6 of the parent's diameters apart, too far for that under any sun higher than about 32°. Spheres further apart in the chain swing closer to each other, and at some frames one does shadow another, but at the Golden's frame none lies in another's shadow.

    The orrery from the side: five spheres in one plane, sunlight about 60 degrees up, and each sphere's shadow falling below the plane. the orbit plane sunlight, about 60° up each shadow falls below the plane
    The orrery from the side, schematically. The spheres circle in one plane; the sunlight comes down at about 60°, so each sphere's shadow, shaded, leaves it steeply and passes below the spheres beyond it. Not the Golden's frame: the spheres are spread along the plane for clarity.

    The other Goldens

    Part 1 regenerates the orrery's two Goldens. Software Vulkan's, captured by CI's lavapipe, changed by 75 pixels, 34 of them by more than one 8-bit step and by 18 steps at most, much as Metal's did (786f195). The commits regenerate no other Golden: uv-grid moved by 4 pixels on Metal, each by one 8-bit step, from a change in the shader's generated code, which is within the Goldens' tolerance of one step, so its Golden was kept. The shadow images went to the milestone's visual check 3, on #205's PR, #265, together with #203's and #205's (ticket #204).

    Chapter 7 · PR #266

    Timing a build the GPU runs alongside

    Part 2 reports what the dynamic build costs per frame, on the CPU and on the GPU. The CPU side is a clock around recording and submitting. The GPU side is harder. Praxis times the GPU with timestamps written at the start and end of passes, and an acceleration-structure build is not a pass. A probe on the M4 Pro found how to bracket one, and PR #266, merged as 80227bc, builds on it.

    Ticket #204 asked for both: "The dynamic TLAS's CPU (recording plus submit) and GPU build per frame reported on the orrery; the 10,000-Instance figure is set on town (#207)." The milestone then holds the dynamic TLAS at 10,000 Moving Instances to CPU and GPU ceilings that the owner set from that first measurement, on 2026-10-26 (end of this chapter).

    A pair of timestamps brackets the build only if the work that writes them runs before and after it. The probe tried three kinds of marker on either side of a build, on 2026-10-23 with wgpu 30.0.1 on Metal, in a debug test build with the GPU idle between submissions, so its clocks were low; each figure is a median of 40 runs, 30 for the markers that do not read the TLAS.

    Markers:
    Ray queries of the TLAS before and after the build: the build falls between them. compute build ray query ray query 0.14 ms at 0, 2.26 ms at 10,000 the build time; schematic, not to scale

    Passes that read the TLAS with a trivial ray query, before and after the build: from the first pass's end to the second's start took 0.14 ms at 0 Instances and 2.26 ms at 10,000. The arrows mark the waits: the build waits for the pass that reads the old contents, and the second pass waits for the build.

    The probe's three kinds of marker, M4 Pro, Metal, wgpu 30.0.1. Schematic: the boxes' lengths are not to scale.

    Only the third kind brackets the build. A pass that reads the TLAS has to wait for a build that writes it, and the build has to wait for a pass that still reads the old contents, so the build falls between the two markers. Across sizes, the bracket grew with the Instances built:

    Instances builtMedianp10
    00.14 ms0.14 ms
    50.27 ms0.19 ms
    1000.40 ms0.28 ms
    1,0001.37 ms0.78 ms
    10,0002.26 ms2.21 ms
    From the first TLAS-reading marker's end to the second's start, around one build of each size: the probe's debug build at low clocks, 40 runs each. One marker dispatch alone took 7 to 18 µs. Ticket #204 records the medians at 0, 5, 1,000 and 10,000 Instances.

    The bracket holds more than the build: at 0 Instances it still measured 0.14 ms, at low clocks. The probe's figures show that the method works; the cost comes from part 2's own measurements, at the end of this chapter. And a marker that reads the TLAS fails wgpu-core's submit validation if the TLAS was never built (UsedUnbuiltTlas).

    How part 2 times it

    • A command buffer of its own. DynamicTlas::record, which replaced update, fills the entries as chapter 4 shows and records the build into its own command buffer. The Renderer records and finishes it in render, as the CPU stage dynamic_tlas, and submits it just before the frame's command buffer, as dynamic_tlas_submit. Their sum is ADR 0020's "recording plus submit", which bench records per frame as dynamic_tlas_cpu_microseconds.
    • Two markers. One-invocation compute passes that query the dynamic TLAS run on either side of the build, and its GPU time runs from the first marker's end to the second's start. A frame that builds nothing records no markers, and its build reads as no time. DynamicTlas::new builds the TLAS empty at creation, so a marker never queries it unbuilt.
    • The previous frame's rays. The first marker can run while the previous frame still traces rays against the TLAS, and the build waits for them. So the build starts at the later of the first marker's end and the end of the previous frame's rays: its lit pass's end as #266 merged, its shadow mask pass's end since #277. Without that, the orrery's build read 1.65 to 1.75 ms, the previous frame's whole lit pass; with it, 0.18 ms. (Since #299, merged on 2026-10-26 as 800c0d2, the build starts no earlier than the end of the previous frame's last pass, as it runs mostly alongside that frame, and in the orrery's Baseline it reads 0.)
    • The GPU frame total is the build plus the span of the frame's own passes, so it counts neither the wait between the two submissions nor what the frame records before its first pass.
    // The first timed pass, the dynamic TLAS's build, runs
    // in a submission of its own before the frame's. The
    // marker before it can run while the frame before
    // still traces rays against the TLAS, which the build
    // waits for, so it starts at the later of the two.
    let previous_shadow_ray_end = self
        .last_shadow_ray_end
        .filter(|&(previous, _)| previous + 1 == frame)
        .map_or(0, |(_, tick)| tick);
    let build = if slot.dynamic_tlas_built {
        time(ticks[0].max(previous_shadow_ray_end), ticks[1])
    } else {
        Duration::ZERO
    };
    // …
    // The build plus the span of the frame's passes, so
    // neither the wait between the two submissions nor
    // what the frame records before its first pass
    // counts.
    frame_time: build
        + time(ticks[2 * first_pass], ticks[QUERIES_PER_FRAME - 1]),
    Reading a frame's timestamps back, abridged from src/render/readback.rs at main 69e02dc. ticks[0] is the first marker's end and ticks[1] the second's start; last_shadow_ray_end holds the previous frame read back and the tick its shadow mask pass ended at.

    What submit adds

    Part of the CPU's share is paid at submit, whatever the build. Each pass that sets a bind group holding a TLAS adds one use of each such TLAS to its command buffer. At submit, wgpu-core walks each use's TLAS dependencies, one per Instance, and on Metal adds each to a new residency set for that command buffer (wgpu-core 30.0.1, command/pass.rs and command/ray_tracing.rs; wgpu-hal 30.0.1, metal/command.rs). At #204, the scene-geometry bind group held both TLAS and was set in four passes: culling phases one and two, the depth pre-pass and the lit pass. Part 2's measurement of recording plus submit, dynamic_tlas_cpu_microseconds, counts chapter 4's allocation per entry, made as the build's own encoder finishes, and the walks at the build's own submit, for the build and its two markers. It leaves out the four passes' walks, which happen at the frame's own submit, as docs/performance.md notes under "Not in the CPU figure".

    What it measured

    On the orrery, PR #266's three runs on the M4 Pro at 2560 × 1440 read medians of 197 to 203 µs of CPU for the build's recording, finish and its own submit, and 174 to 176 µs of GPU for the build. Timing it costs 27 µs of CPU frame and 73 heap allocations a frame (317 to 390), for its own command buffer, its own submit and the two markers.

    town sets the 10,000-Instance figure (#207). Measured on main 69e02dc, its committed M4 Pro Baseline fills the dynamic TLAS with 10,000 Moving Instances, 100 flocks of 100 birds. Recording plus submit takes 1.00 ms of CPU (median; p95 1.11), against ADR 0020's 0.64 to 0.92 ms, and the build reads 4.11 ms of GPU (p95 4.52), against 0.83 ms while the rays were still traced in the lit pass, before #277. The 4.11 ms may also count the previous frame's lit pass, which runs after that frame's shadow mask pass and likely shares the GPU with the build: unverified then, and handed to #207 to settle before the ceilings were set. (Settled on 2026-10-26, in the owner's decision on #207: the build's 4.1 ms ran almost wholly alongside the previous frame's lit and tonemapping passes. Timed from the end of that frame's last pass, it reads 48 µs, and run alone, with one frame in flight, it takes 665 µs. The owner decided that the build counts from the end of the previous frame's last pass, which PR #299, merged on 2026-10-26 as 800c0d2, put into the code, and set the ceilings at 10,000 Moving Instances to 1.1 ms of CPU, recording plus submit, and 1.0 ms of GPU build, both medians.) town's frames make 10,478 heap allocations, against 341 without the birds: nearly one per entry.

    Chapter 8 · PR #264's Baselines

    What it costs

    #204 adds work in three places: the CPU records and submits a build in every frame, the GPU runs it, and the lit pass makes a second query wherever the static TLAS lets a ray through and something moves. Its cost was measured as a difference, main at 0db5e46 against the branch, in release builds at 2560 × 1440 with 4x MSAA on the M4 Pro. The orrery, the one Reference scene where something moves, pays 812 µs more GPU time per frame. The scenes where nothing moves pay 43 to 413 µs more in the lit pass, and part 1's first version showed why they pay anything at all.

    An empty TLAS still costs its query

    258001a, part 1's first commit, queried the dynamic TLAS wherever the static TLAS let a ray through, empty or not. An A/B against main, in three rounds that alternated the two binaries scene by scene, put the lit pass up on every scene where nothing moves:

    SceneLit pass, 258001a against main
    uv-grid+452 µs
    pbr-sweep+486 µs
    normal-tangent+698 µs
    far-coplanar+989 µs
    minification+1,046 µs
    helmet+1,689 µs
    shadows+1,831 µs
    texture-wall+2,255 µs
    The empty dynamic TLAS's query, on the eight scenes with no Moving Instance: the lit pass's GPU time with 258001a minus main's, M4 Pro, release, 2560 × 1440. Every ray the static TLAS let through went on to query a TLAS that held nothing. PR #264 records the range, 452 to 2,255 µs.

    A ray query of an empty TLAS still costs the lit pass on Metal: here, 452 to 2,255 µs. So 11e9388 skips it. The camera uniform's last padding word now carries the dynamic TLAS's entry count, and shadowed returns before the second query while that count is 0 (chapter 5).

    With the skip

    The M4 Pro Baselines were then re-recorded on a quiet machine, keeping each scene's quietest of three runs (a11bbd3). Main against the branch, medians in µs:

    SceneGPU frame, changeLit pass changeGPU frame, main → #204
    orrery+812+8111,549 → 2,361
    shadows+304+3056,526 → 6,829
    far-coplanar+136+1323,339 → 3,475
    normal-tangent+116+1194,034 → 4,150
    minification+91+1103,255 → 3,346
    helmet+76+7910,333 → 10,409
    uv-grid+50+502,278 → 2,328
    pbr-sweep+35+433,710 → 3,745
    texture-wall−329, noise+41315,014 → 14,686
    Part 1's cost with the skip: main 0db5e46 against PR #264's re-recorded Baselines, M4 Pro, release, 2560 × 1440, 4x MSAA, medians in µs. Only the orrery has Moving Instances. texture-wall's GPU frame fell by noise while its lit pass rose.

    Where nothing moves, the skip took most of the cost away but not all of it: those scenes' lit passes still grew by 43 to 413 µs, and their GPU frames by 35 to 304 µs, all but texture-wall's, which fell by noise. The second query's code costs the lit pass even when it never runs. Removing it would need a lit pipeline without the dynamic query, chosen while nothing moves. That is noted and not done, since it would help only scenes with no Moving Instance.

    The orrery

    On the orrery the lit pass went from 870 to 1,682 µs, +811 µs: every sunlit planet pixel now traces the dynamic TLAS, and each of those rays starts inside the bounds of its own sphere's BLAS. A ray that starts inside a box always enters it, so each one walks down into its own sphere before it can leave. The CPU frame went from 137 to 291 µs, +154 µs, for the build in every frame. The uploads went from 1,024 to 1,384 bytes per frame, the five entries' 5 × 72, and the heap allocations from 273 to 317 per frame, the new ones inside wgpu-core.

    The CPU medians are the softer figures. They moved by up to ±240 µs between runs with the order of the scenes inside one bench process, so the GPU medians are the reliable ones. In PR #264's table, the other scenes' CPU frames moved by −238 to +7 µs, within that spread. Part 2 timed the build itself (chapter 7).

    Memory, on both backends

    Every scene's GPU memory grew by 2,885,000 B on the M4 Pro, the dynamic TLAS's 10,000 × 288.5 B, whatever it holds. On CI's lavapipe the same capacity adds 2,400,000 B, 10,000 × 240 B, and the orrery's uploads grow from 1,024 to 1,344 bytes per frame, its entries' 5 × 64 (786f195).

    Reference

    Reading list

    Grouped by source, each with what it is good for here. Repo links are pinned to #264's merge, 79cdabb, to #266's, 80227bc, or to main 69e02dc where the code has moved since. The books' chapter and section numbers were checked on 2026-10-23 against pbr-book.org and the chapter's PDF on realtimerendering.com.

    This ticket

    In the repo

    The APIs

    Books

    • Akenine-Möller et al., Real-Time Rendering, 4th edition, online chapter 26, Real-Time Ray Tracing, free from realtimerendering.com §26.3, Top and Bottom Level Acceleration Structures: the two levels, the 3×4 matrices a TLAS holds per instance, and updating versus rebuilding
    • Pharr, Jakob and Humphreys, Physically Based Rendering, 4th edition, chapter 7, Primitives and Intersection Acceleration: §7.3, Bounding Volume Hierarchies the tree inside every BLAS and TLAS, and how it is built; and §7.1.2, Object Instancing and Primitives in Motion instancing, the idea behind a TLAS entry
    Reference

    Glossary

    TLAS
    Top-level acceleration structure: the GPU's search structure over Instances, each placing a BLAS with its transform.
    BLAS
    Bottom-level acceleration structure: the GPU's search structure over one Mesh's triangles.
    Static TLAS
    The TLAS of every static Instance, rebuilt only in a frame that changes one (#202).
    Dynamic TLAS
    The TLAS of at most 10,000 Moving Instances, those in the lowest slots, rebuilt every frame (#204).
    Moving Instance
    An Instance whose entity the fixed schedule moves, so the entity carries its previous World transform.
    Instance
    One Renderable's data on the GPU, in a slot of the Instance buffer that the Renderable keeps while it is one.
    Slot
    An Instance's place in the Instance buffer; it changes only when its Renderable spawns or despawns.
    Rebuild
    Building an acceleration structure from scratch from its current contents; wgpu-core 30's only update.
    Refit
    Recomputing an acceleration structure's bounds in place, keeping its tree; not in wgpu-core 30.
    PREFER_FAST_TRACE
    The build flag that favours fast tracing over fast building; both TLAS take it, though on Metal wgpu-hal 30.0.1 passes nothing for it.
    Custom data
    A number a TLAS entry carries and a hit reports; Praxis stores the Instance's slot.
    Instance record
    A backend's own form of a TLAS entry: 72 B on Metal, 64 B on Vulkan and DX12.
    Tick
    One fixed step of the simulation, 1/64 s; rendered frames draw a blend of the two newest Ticks.
    Fixed schedule
    The schedule that runs once per Tick and holds everything that changes game state.
    Blend weight
    How far a frame lies between the two newest Ticks; prepare blends moving entities by it.
    PreviousWorldTransform
    The component holding an entity's World transform after the previous Tick; carrying it makes an Instance a Moving Instance.
    World transform
    Where an entity sits in a Game world: its Transform composed with every ancestor's, in f64.
    Anchor
    The integer coordinates of the 64 m grid cell that holds a World position.
    The TLAS frame
    The f32 frame both TLAS share, its origin at the corner of the camera's Anchor in the first Frame data.
    Frame data
    The per-frame collection of changed Instances, lights and camera that the renderer consumes in place of reading the ECS World.
    Frame stats
    What one frame cost, measured by the engine itself; among its counts, the Moving Instances left out of the dynamic TLAS.
    ECS World
    The container of entities, components and resources. A code concept, never the playable space.
    Archetype
    In bevy_ecs, the set of components an entity has; entities of one archetype share a table.
    select_nth_unstable
    Rust's partial sort: the n smallest elements first, in no order, in O(n) time.
    Ray query
    A ray traced inline from an ordinary shader: initialise, proceed, read the committed hit.
    Opaque
    Geometry whose every hit counts; one rayQueryProceed then completes a traversal.
    Terminator
    The line where a curved surface turns from facing a light to facing away.
    Fog line
    A line under "Not yet specified" on the map issue: known work, not yet a ticket.
    Timestamp query
    A GPU clock reading that a pass writes at its start or end.
    Residency set
    A set of resource allocations that Metal makes resident, GPU-accessible, for the command buffers it is attached to; wgpu makes one for each command buffer that builds or binds a TLAS with entries.
    Capability floor
    The GPU features and limits the engine requires to start. An adapter below it is refused with every gap named; nothing in it has a fallback.
    Budget
    A numeric limit on one metric of a Reference scene that a change must not regress.
    Reference scene
    A Scene paired with a fixed camera path and injected time, so that every run produces the same frames.
    Golden
    A committed image that a Reference scene must reproduce within tolerance.
    Built from ticket #204, PR #264, commits 258001a, 11e9388, a11bbd3 and 786f195, as verified during the work on #204, on 2026-10-23, and PR #266, re-checked against main 69e02dc on 2026-10-24, with ADRs 0014, 0020 and 0022 and the decisions on map #17. Where the guide does its own arithmetic, it says so. The demos are this page's models and say where they simplify. The image is the orrery's Metal Golden at 258001a, which #264's merge kept, re-encoded as JPEG; its 73 ringed pixels are this page's comparison of that Golden with main's at 0db5e46, which reproduces PR #264's counts: 73 changed, 34 by more than one step, 18 steps at most. The same comparison of software Vulkan's Goldens reproduces their 75, 34 and 18.