Skip to main content

roxlap_gpu/
scene.rs

1//! GPU.5 — multi-grid scene upload + shared storage layout.
2//!
3//! Concatenates every chunk of every grid into one set of storage
4//! buffers + a per-grid offsets table. Each grid keeps its own
5//! `vsid`, `chunks_dims`, `origin_chunk`, and runtime transform;
6//! the shader iterates grids 0..grid_count, transforms the world
7//! camera into each grid's local frame, runs that grid's outer-DDA
8//! over chunks, and tracks the closest hit across all grids.
9//!
10//! Why concatenate rather than one bind group per grid? wgpu's
11//! `MAX_BIND_GROUPS` default is 4; demos with 10+ grids
12//! (`roxlap-scene-demo` has ground + ship + 10 marker pillars =
13//! 12) need a single bind-group layout that scales.
14
15#![allow(
16    clippy::cast_sign_loss,
17    clippy::cast_lossless,
18    clippy::cast_possible_truncation,
19    clippy::cast_possible_wrap,
20    clippy::doc_markdown,
21    clippy::missing_panics_doc,
22    clippy::needless_range_loop,
23    clippy::pub_underscore_fields
24)]
25
26use bytemuck::Zeroable;
27use wgpu::util::DeviceExt;
28
29use crate::decompress::{gpu_mip_count, occ_words_per_column_for_mip, ChunkUpload};
30use crate::grid::GridUpload;
31
32/// GPU.11 — max mip levels the per-slot layout reserves room for in
33/// [`GridStaticMeta`]'s relative-offset tables. Matches
34/// [`crate::decompress::GPU_MAX_MIPS`]; the shader's `array<u32, N>`
35/// must use the same N.
36pub const MAX_GPU_MIPS: usize = 6;
37
38/// GPU.11 — per-slot occupancy/color-offset strides + per-mip
39/// within-slot relative offsets for a grid of side `vsid`. All
40/// chunks of a grid share these (uniform mip count by
41/// [`gpu_mip_count`]). `colors` keep their fixed
42/// [`COLORS_PER_CHUNK_WORDS`] stride; each mip's colours are
43/// concatenated within that block and indexed by the chunk's own
44/// (absolute) `color_offsets`.
45#[derive(Debug, Clone, Copy)]
46pub struct MipLayout {
47    /// Number of mip levels stored per slot = [`gpu_mip_count`]`(vsid)`,
48    /// always `1..=`[`MAX_GPU_MIPS`].
49    pub mip_count: u32,
50    /// Occupancy u32 words per chunk slot, summed over all mips — each
51    /// mip stores its textured **and** solid bitmap back-to-back, so
52    /// this is `Σ 2·(vsid>>m)²·occ_words_per_column_for_mip(m)`.
53    pub occ_words_per_slot: u32,
54    /// `color_offsets` u32 words per chunk slot, summed over all mips
55    /// (`Σ (vsid>>m)² + 1` — each mip keeps its own `cols + 1` prefix
56    /// table).
57    pub offsets_words_per_slot: u32,
58    /// Within-slot u32 offset where mip `m`'s occupancy starts.
59    pub mip_occ_rel: [u32; MAX_GPU_MIPS],
60    /// Within-slot u32 offset where mip `m`'s color_offsets start.
61    pub mip_coff_rel: [u32; MAX_GPU_MIPS],
62}
63
64impl MipLayout {
65    /// Compute the per-slot layout for a grid whose chunks are
66    /// `vsid × vsid × CHUNK_Z` voxels. Deterministic in `vsid`, so the
67    /// upload, [`GpuSceneResident::refresh_chunk`], and the shader all
68    /// derive identical strides independently.
69    #[must_use]
70    pub fn for_vsid(vsid: u32) -> Self {
71        let mip_count = gpu_mip_count(vsid);
72        let mut mip_occ_rel = [0u32; MAX_GPU_MIPS];
73        let mut mip_coff_rel = [0u32; MAX_GPU_MIPS];
74        let mut occ_acc = 0u32;
75        let mut coff_acc = 0u32;
76        for m in 0..mip_count {
77            mip_occ_rel[m as usize] = occ_acc;
78            mip_coff_rel[m as usize] = coff_acc;
79            let vsid_m = vsid >> m;
80            let cols = vsid_m * vsid_m;
81            // Each mip stores TWO bitmaps back-to-back: the textured
82            // occupancy then the solid occupancy (cliff-face fix). The
83            // shader reads solid at `tex_base + cols*occ_words_per_col`.
84            occ_acc += 2 * cols * occ_words_per_column_for_mip(m);
85            coff_acc += cols + 1;
86        }
87        Self {
88            mip_count,
89            occ_words_per_slot: occ_acc,
90            offsets_words_per_slot: coff_acc,
91            mip_occ_rel,
92            mip_coff_rel,
93        }
94    }
95}
96
97/// Per-chunk colour-slot stride, in u32 words (256 KiB). Each
98/// chunk's colour data lives at `meta_idx * COLORS_PER_CHUNK_WORDS`
99/// within its grid's colours range. Fixed-stride layout means
100/// every slot — present or absent at upload time — has the same
101/// capacity, so [`GpuSceneResident::refresh_chunk`] can always
102/// write new colour data into the slot when a chunk arrives via
103/// streaming or is re-baked.
104///
105/// 65536 u32s = 256 KiB. Scene-demo's densest ground-hills chunks
106/// run ~36 k colour entries (~144 KiB) — multiple textured voxels
107/// per column at slopes/cliffs; 256 KiB gives ~1.8× headroom.
108/// Memory cost on the demo's 32×32×1 static grid: 1024 slots ×
109/// 256 KiB = 256 MiB colours (~830 MiB resident scene total).
110/// Chunks past the cap truncate with a stderr warn; GPU.7
111/// sliding-window storage removes the cap entirely.
112pub const COLORS_PER_CHUNK_WORDS: u32 = 65536;
113
114/// Number of separate storage bindings the concatenated occupancy
115/// buffer is split ("paged") across. A single storage binding may
116/// not exceed the device's `max_storage_buffer_binding_size` — on
117/// strict drivers that's a hard 128 MiB (lavapipe), which the
118/// streaming demo's occupancy already reaches. Splitting into pages
119/// keeps every binding under the limit while preserving a single
120/// global word index in the shader (each page is a whole number of
121/// chunk slots, so no slot ever straddles a page boundary).
122///
123/// On GPUs with multi-GiB binding limits (NVK, native Vulkan) the
124/// whole buffer fits in page 0, the other bindings get a 1-word
125/// dummy, and the shader's page select is a single perfectly-
126/// predicted uniform branch → zero hot-loop cost. 4 pages covers
127/// 512 MiB of occupancy even on a 128 MiB-per-binding device.
128pub const MAX_OCC_PAGES: usize = 4;
129
130/// Per-grid runtime transform — voxlap-style (world → grid-local).
131/// `rotation` is column-major and encodes the inverse rotation
132/// applied to the world camera basis before passing it to that
133/// grid's marcher. Identity for the ground; non-trivial for the
134/// rotating ship.
135#[derive(Debug, Clone, Copy)]
136pub struct GridRuntimeTransform {
137    /// Grid-local position of the world origin = `-rotation⁻¹ ·
138    /// grid.position` for a `GridTransform { position, rotation }`.
139    /// The host computes this once per frame.
140    pub grid_origin_world: [f64; 3],
141    /// 3×3 inverse rotation (column-major).
142    pub world_to_grid_rotation: [[f32; 3]; 3],
143}
144
145impl Default for GridRuntimeTransform {
146    fn default() -> Self {
147        Self {
148            grid_origin_world: [0.0, 0.0, 0.0],
149            world_to_grid_rotation: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
150        }
151    }
152}
153
154/// CPU-side aggregation of every grid in a scene. Built once at
155/// startup; per-grid transforms are recomputed each frame and
156/// passed to `render_scene` separately.
157pub struct SceneUpload {
158    /// One [`GridUpload`] per scene grid, in the order the shader
159    /// iterates them; index = the `scene_idx` handed to
160    /// [`GpuSceneResident::refresh_chunk`] / `evict_chunk` and the
161    /// per-grid camera slot.
162    pub grids: Vec<GridUpload>,
163}
164
165impl SceneUpload {
166    /// Number of grids, saturated into `u32` (the type the shader's
167    /// `grid_count` uniform uses).
168    #[must_use]
169    pub fn grid_count(&self) -> u32 {
170        u32::try_from(self.grids.len()).unwrap_or(u32::MAX)
171    }
172}
173
174/// Per-grid static metadata: offsets into the concatenated storage
175/// buffers + the grid's slot-pool dimensions. Uploaded once.
176///
177/// GPU.7 changes: `chunks_dims` and `origin_chunk` were dropped.
178/// The shader uses modular slot indexing
179/// (`chunk_idx & (pool_dims - 1)`) and verifies slot identity via
180/// `slot_chunk_idx[slot]`, so the upload-time bbox is no longer
181/// relevant to the shader.
182#[repr(C)]
183#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable, Debug)]
184pub struct GridStaticMeta {
185    /// `occupancy` u32-word offset where this grid's data starts.
186    pub occupancy_offset: u32,
187    /// `all_color_offsets` (binding 2) u32-word offset where this
188    /// grid's per-slot colour-offset tables start; slot `meta_id`'s
189    /// window is `offsets_words_per_slot` words from
190    /// `color_offsets_offset + meta_id * offsets_words_per_slot`.
191    pub color_offsets_offset: u32,
192    /// `all_colors` (binding 3) u32-word offset where this grid's
193    /// packed voxel colours start (per-slot blocks of the grid's
194    /// colour stride).
195    pub colors_offset: u32,
196    /// `all_chunk_colors_base` (binding 4) u32-word offset of this
197    /// grid's per-slot table mapping `meta_id` → the slot's colour
198    /// base (in words, relative to `colors_offset`).
199    pub chunk_colors_base_offset: u32,
200    /// `all_chunk_occupancy` (binding 5) u32-word offset of this
201    /// grid's chunk-occupancy bitmap: bit `slot_idx & 31` of word
202    /// `slot_idx >> 5` is set iff the slot holds a non-empty chunk —
203    /// the outer DDA's whole-chunk skip test.
204    pub chunk_occupancy_offset: u32,
205    /// New in GPU.7: u32-word offset where this grid's
206    /// `slot_chunk_idx` array starts (one `vec3<i32>` per slot,
207    /// i.e. 3 u32 words each, plus 1 padding word for std430).
208    pub slot_chunk_idx_offset: u32,
209    /// Chunk XY extent in voxels (typically 128); the chunk's Z extent
210    /// is the fixed `CHUNK_Z = 256`.
211    pub vsid: u32,
212    /// Slot count in the modular pool
213    /// (`pool_dims.x · pool_dims.y · pool_dims.z`).
214    pub total_slots: u32,
215    /// GPU.7 slot-pool dimensions (each a power of 2). Chunk
216    /// `(chx, chy, chz)` lives in slot
217    /// `(chx & (pool_dims.x - 1), chy & …, chz & …)`.
218    pub pool_dims: [u32; 3],
219    /// std430 padding; always 0.
220    pub _pad0: u32,
221    /// GPU.11 — per-slot occupancy stride (sum over all mips).
222    /// `meta_id`'s occupancy slab starts at
223    /// `occupancy_offset + meta_id * occ_words_per_slot`.
224    pub occ_words_per_slot: u32,
225    /// GPU.11 — per-slot color_offsets stride (sum over all mips).
226    pub offsets_words_per_slot: u32,
227    /// GPU.11 — number of mip levels stored per slot.
228    pub mip_count: u32,
229    /// std430 padding; always 0.
230    pub _pad1: u32,
231    /// GPU.11 — within-slot u32 offset where mip `m`'s occupancy
232    /// starts. `mip_occ_rel[0] == 0` so mip-0 reads are unchanged.
233    pub mip_occ_rel: [u32; MAX_GPU_MIPS],
234    /// GPU.11 — within-slot u32 offset where mip `m`'s color_offsets
235    /// start. `mip_coff_rel[0] == 0`.
236    pub mip_coff_rel: [u32; MAX_GPU_MIPS],
237    /// GPU.13.0 — occupied chunk-AABB (inclusive) in chunk-index space.
238    /// The outer DDA stops once `p_chunk` passes this box along the
239    /// ray's travel direction (no resident chunk can lie ahead). An
240    /// empty grid uses the inverted sentinel (`aabb_min = i32::MAX`,
241    /// `aabb_max = i32::MIN`) so every ray early-outs immediately.
242    /// Maintained live: [`GpuSceneResident::refresh_chunk`] /
243    /// [`GpuSceneResident::evict_chunk`] recompute + re-upload it.
244    pub aabb_min: [i32; 3],
245    /// std430 `vec3<i32>` padding; always 0.
246    pub _pad2: i32,
247    /// Inclusive upper corner of the occupied chunk-AABB (chunk-index
248    /// space); `i32::MIN` sentinel when the grid holds no chunks. See
249    /// [`Self::aabb_min`].
250    pub aabb_max: [i32; 3],
251    /// std430 `vec3<i32>` padding; always 0.
252    pub _pad3: i32,
253    /// GPU.13.1 — `all_chunk_occupancy` u32-word offsets of this
254    /// grid's chunk-occupancy pyramid levels 1..=4 (entry `l - 1` =
255    /// level `l`; entries past [`Self::chunk_occ_levels`] are 0).
256    /// Level `l` has `max(pool_dims >> l, 1)` cells per axis; a set
257    /// bit means "some resident non-empty chunk maps into this
258    /// slot-block" — the outer DDA's read-free empty-block skip.
259    pub chunk_occ_mip_off: [u32; 4],
260    /// GPU.13.1 — pyramid levels stored above L0 (0 = no pyramid,
261    /// the single-chunk-pool degenerate case).
262    pub chunk_occ_levels: u32,
263    /// CA perf — grid-local SOLID voxel z-extent (inclusive) over all
264    /// resident chunks: the marchers clamp their entry fast-forward
265    /// box and ray caps to it, so a grid whose content occupies a
266    /// slice of its chunk stack (a ship spanning a chz boundary sits
267    /// in a 512-voxel-tall chunk AABB but is ~70 voxels of hull) is
268    /// entered AT the content and abandoned right past it. Inverted
269    /// sentinel (`lo = i32::MAX`, `hi = i32::MIN`) when the grid holds
270    /// no solid voxels. Maintained live alongside the chunk AABB
271    /// ([`GpuSceneResident::refresh_chunk`] / `evict_chunk`).
272    pub vox_z_lo: i32,
273    /// See [`Self::vox_z_lo`]; inclusive.
274    pub vox_z_hi: i32,
275    /// std430 padding; always 0.
276    pub _pad4: u32,
277}
278
279/// Sentinel chunk_idx written into empty slot_chunk_idx entries.
280/// Real chunk indices never use `i32::MIN`, so the shader can
281/// distinguish empty slots from collisions via a single equality
282/// check.
283pub const SLOT_EMPTY_SENTINEL: [i32; 3] = [i32::MIN, i32::MIN, i32::MIN];
284
285/// GPU-resident storage for an entire scene's grids.
286pub struct GpuSceneResident {
287    /// Number of grids uploaded (= `SceneUpload::grid_count()`); the
288    /// shader's grid loop bound and the length of [`Self::static_meta`].
289    pub grid_count: u32,
290    /// Concatenated per-slot occupancy, split into up to
291    /// [`MAX_OCC_PAGES`] storage bindings so no single binding
292    /// exceeds the device's `max_storage_buffer_binding_size`. The
293    /// vec is always exactly `MAX_OCC_PAGES` long — pages past
294    /// `occupancy_num_pages` are 1-word dummies kept only so the
295    /// bind group has a buffer for every layout entry. Page p holds
296    /// the global word range `[p*occupancy_page_words,
297    /// (p+1)*occupancy_page_words)`; `occupancy_page_words` is a
298    /// whole number of chunk slots so no slot straddles a boundary.
299    pub occupancy_pages: Vec<wgpu::Buffer>,
300    /// Words per occupancy page (a multiple of `occ_words_per_slot`).
301    pub occupancy_page_words: u32,
302    /// Number of real (non-dummy) pages in `occupancy_pages`.
303    pub occupancy_num_pages: u32,
304    /// Binding 2 — concatenated per-slot `color_offsets` prefix tables
305    /// (all grids, all mips). Grid `g`'s region starts at
306    /// `static_meta[g].color_offsets_offset` words.
307    pub all_color_offsets: wgpu::Buffer,
308    /// Binding 3 — concatenated packed voxel colours (all grids).
309    /// Each word is the voxlap wire format the shader unpacks: blue in
310    /// bits 0-7, green 8-15, red 16-23, and a **brightness** byte (not
311    /// alpha) in bits 24-31 with `0x80` = neutral.
312    pub all_colors: wgpu::Buffer,
313    /// Binding 4 — per-slot colour base table: word `meta_id` of grid
314    /// `g`'s region holds the slot's colour start (in words, relative
315    /// to `static_meta[g].colors_offset`) = `meta_id × colour stride`.
316    pub all_chunk_colors_base: wgpu::Buffer,
317    /// Binding 5 — per-grid chunk-occupancy bitmaps (one bit per pool
318    /// slot: set iff the slot holds a non-empty chunk). The outer DDA
319    /// tests it to skip whole 128×128×256 chunks per step.
320    pub all_chunk_occupancy: wgpu::Buffer,
321    /// GPU.7 — per-slot chunk_idx for identity verification in the
322    /// shader. Stored as `vec3<i32>` with std430 16-byte stride
323    /// (each entry is `[i32; 4]` on the host: x, y, z, _pad).
324    pub all_slot_chunk_idx: wgpu::Buffer,
325    /// Binding 6 — the [`GridStaticMeta`] array, one element per grid.
326    /// Mostly upload-time constant; the live chunk-AABB fields are
327    /// patched in place on `refresh_chunk` / `evict_chunk`.
328    pub grid_static_meta: wgpu::Buffer,
329    /// Total bytes allocated across all the resident storage buffers,
330    /// for VRAM accounting (see [`Self::resident_bytes`]).
331    pub total_bytes: u64,
332    /// Cached static metadata for the host's frame-loop work.
333    pub static_meta: Vec<GridStaticMeta>,
334    /// CPU shadow of the per-grid chunk-occupancy bitmap. Each entry
335    /// is the u32 word at `chunk_occupancy_offset + (mi >> 5)`.
336    /// `refresh_chunk` / `evict_chunk` flip the right bit + write
337    /// the affected word back to the GPU.
338    pub(crate) chunk_occupancy_shadow: Vec<Vec<u32>>,
339    /// GPU.13.1 — CPU mirror of each grid's chunk-occupancy pyramid
340    /// (outer Vec: grid; middle: level 1.. — level 0 IS
341    /// [`Self::chunk_occupancy_shadow`]). Ancestor re-OR on
342    /// refresh/evict reads children from here instead of the GPU.
343    pub(crate) chunk_occ_pyramid_shadow: Vec<Vec<Vec<u32>>>,
344    /// CPU shadow of `slot_chunk_idx`. Indexed `[scene_idx][slot]`
345    /// → `[i32; 4]` (vec3 + pad). Host uses this to detect "slot is
346    /// holding a different chunk than expected" + as the eviction
347    /// origin.
348    pub(crate) slot_chunk_idx_shadow: Vec<Vec<[i32; 4]>>,
349    /// Per-grid colour stride in u32 words (the adaptive
350    /// [`COLORS_PER_CHUNK_WORDS`]-or-larger value chosen at upload to
351    /// fit the grid's densest chunk). `refresh_chunk` reads it so a
352    /// streamed re-upload addresses colours with the same stride the
353    /// initial upload used.
354    pub(crate) colors_stride_shadow: Vec<u32>,
355    /// PF.12.c — CPU mirror of each installed slot's per-mip
356    /// `color_offsets` tables (`offsets_words_per_slot` words, the exact
357    /// content of the GPU window). [`Self::refresh_chunk_partial`] reads
358    /// it to (a) place a dirty column's colours at the resident offset
359    /// and (b) verify the column's colour COUNT is unchanged — a count
360    /// change reflows the packed colour block and forces the full-path
361    /// fallback. ~87 KB per 128² chunk; dropped on evict.
362    pub(crate) color_offsets_shadow: Vec<std::collections::HashMap<usize, Vec<u32>>>,
363    /// CA perf — CPU mirror of each slot's IN-CHUNK solid z-extent
364    /// (`[lo, hi]` inclusive; `[i32::MAX, i32::MIN]` for empty slots).
365    /// Combined with the slot's chunk-z on every refresh/evict to
366    /// recompute the grid's [`GridStaticMeta::vox_z_lo`]/`vox_z_hi`
367    /// marcher clamp (same maintenance path as the chunk AABB).
368    pub(crate) slot_z_ext_shadow: Vec<Vec<[i32; 2]>>,
369}
370
371impl GpuSceneResident {
372    /// Pack + upload `info`. Each grid is uploaded as a contiguous
373    /// slab inside the shared storage buffers; per-grid offsets
374    /// live in `grid_static_meta`. The grid count is bounded only by
375    /// the device's storage-buffer limits (per-grid cameras + metadata
376    /// are runtime-sized storage arrays, not a fixed shader array).
377    pub fn upload(device: &wgpu::Device, info: &SceneUpload) -> Self {
378        let grid_count = info.grid_count();
379
380        let mut all_occupancy: Vec<u32> = Vec::new();
381        let mut all_color_offsets: Vec<u32> = Vec::new();
382        let mut all_colors: Vec<u32> = Vec::new();
383        let mut all_chunk_colors_base: Vec<u32> = Vec::new();
384        let mut all_chunk_occupancy: Vec<u32> = Vec::new();
385        let mut all_slot_chunk_idx: Vec<i32> = Vec::new();
386        let mut static_meta: Vec<GridStaticMeta> = Vec::with_capacity(info.grids.len());
387        let mut chunk_occupancy_shadow: Vec<Vec<u32>> = Vec::with_capacity(info.grids.len());
388        let mut chunk_occ_pyramid_shadow: Vec<Vec<Vec<u32>>> = Vec::with_capacity(info.grids.len());
389        let mut slot_chunk_idx_shadow: Vec<Vec<[i32; 4]>> = Vec::with_capacity(info.grids.len());
390        let mut slot_z_ext_shadow: Vec<Vec<[i32; 2]>> = Vec::with_capacity(info.grids.len());
391        let mut color_offsets_shadow: Vec<std::collections::HashMap<usize, Vec<u32>>> =
392            Vec::with_capacity(info.grids.len());
393        // Per-grid colour stride (words/slot) — adaptive to the grid's
394        // densest chunk (see the in-loop derivation). `refresh_chunk`
395        // reads it back so streamed re-uploads use the same stride.
396        let mut grid_colors_strides: Vec<u32> = Vec::with_capacity(info.grids.len());
397
398        for grid in &info.grids {
399            let vsid = grid.vsid;
400            // GPU.11 — per-slot strides span the whole mip ladder.
401            let layout = MipLayout::for_vsid(vsid);
402            let occ_words_per_slot = layout.occ_words_per_slot as usize;
403            let offsets_words_per_slot = layout.offsets_words_per_slot as usize;
404            // Per-slot colour stride. The fixed-stride layout gives every
405            // slot — present or not — the same capacity, so streaming /
406            // re-bake can write a fresh chunk's colours into any slot.
407            // [`COLORS_PER_CHUNK_WORDS`] is sized for sparse terrain
408            // chunks (~36 k colours); a *fully dense* chunk (the cave
409            // demo's single 128×128×256 chunk carries ~207 k colours
410            // across its mip ladder) needs more, or its colours truncate
411            // and the chunk's high-`y` columns render black. Grow the
412            // stride to the grid's densest chunk, floored at the default
413            // so denser chunks that stream in later still fit the common
414            // case. Per-grid: a sparse grid keeps the small stride; only
415            // a grid that actually holds dense chunks pays for the
416            // bigger one.
417            let max_chunk_colors = grid
418                .chunks
419                .iter()
420                .map(|(_, c)| c.mips.iter().map(|m| m.colors.len()).sum::<usize>())
421                .max()
422                .unwrap_or(0);
423            let colors_stride = (COLORS_PER_CHUNK_WORDS as usize).max(max_chunk_colors);
424            grid_colors_strides.push(colors_stride as u32);
425
426            // Validate pool_dims are powers of 2 — required for the
427            // shader's `chunk_idx & (pool_dims - 1)` modular slot
428            // indexing.
429            assert!(
430                grid.pool_dims[0].is_power_of_two()
431                    && grid.pool_dims[1].is_power_of_two()
432                    && grid.pool_dims[2].is_power_of_two(),
433                "scene grid: pool_dims {:?} must all be powers of 2",
434                grid.pool_dims,
435            );
436            let pool_x = grid.pool_dims[0] as usize;
437            let pool_y = grid.pool_dims[1] as usize;
438            let pool_z = grid.pool_dims[2] as usize;
439            let total_slots = pool_x * pool_y * pool_z;
440
441            let mut grid_occupancy = vec![0u32; total_slots * occ_words_per_slot];
442            let mut grid_color_offsets = vec![0u32; total_slots * offsets_words_per_slot];
443            let mut grid_colors = vec![0u32; total_slots * colors_stride];
444            let mut grid_chunk_colors_base = vec![0u32; total_slots];
445            for i in 0..total_slots {
446                grid_chunk_colors_base[i] = (i * colors_stride) as u32;
447            }
448            let mut grid_chunk_occupancy = vec![0u32; total_slots.div_ceil(32)];
449            // slot_chunk_idx: vec3<i32> per slot, std430 stride = 16
450            // bytes (4 u32 words: x, y, z, _pad). Initialise every
451            // slot to the empty sentinel; populated slots overwrite
452            // with the actual chunk_idx below.
453            let mut grid_offsets_shadow: std::collections::HashMap<usize, Vec<u32>> =
454                std::collections::HashMap::new();
455            let mut grid_slot_chunk_idx: Vec<[i32; 4]> = Vec::with_capacity(total_slots);
456            for _ in 0..total_slots {
457                grid_slot_chunk_idx.push([
458                    SLOT_EMPTY_SENTINEL[0],
459                    SLOT_EMPTY_SENTINEL[1],
460                    SLOT_EMPTY_SENTINEL[2],
461                    0,
462                ]);
463            }
464            // CA perf — per-slot in-chunk solid z-extent (marcher clamp).
465            let mut grid_slot_z_ext: Vec<[i32; 2]> = vec![[i32::MAX, i32::MIN]; total_slots];
466
467            let mask_x = (grid.pool_dims[0] - 1) as i32;
468            let mask_y = (grid.pool_dims[1] - 1) as i32;
469            let mask_z = (grid.pool_dims[2] - 1) as i32;
470            let chunks_per_layer = pool_x * pool_y;
471
472            for (chunk_idx, chunk) in &grid.chunks {
473                assert_eq!(chunk.vsid, vsid, "scene grid: chunk vsid mismatch");
474                let sx = (chunk_idx[0] & mask_x) as usize;
475                let sy = (chunk_idx[1] & mask_y) as usize;
476                let sz = (chunk_idx[2] & mask_z) as usize;
477                let slot_idx = sx + sy * pool_x + sz * chunks_per_layer;
478
479                // GPU.11 — write each mip at its within-slot offset.
480                // occupancy + color_offsets land in per-mip sub-blocks
481                // (mip-0 first, so its data is byte-identical to the
482                // pre-mip layout); colours of every mip concatenate
483                // into the slot's fixed COLORS_PER_CHUNK_WORDS block in
484                // level order, indexed by each chunk's own absolute
485                // `color_offsets`.
486                let occ_start = slot_idx * occ_words_per_slot;
487                let off_start = slot_idx * offsets_words_per_slot;
488                let col_start = slot_idx * colors_stride;
489                let mut color_cursor = 0usize;
490                for (m, mip) in chunk.mips.iter().enumerate() {
491                    let occ_dst = occ_start + layout.mip_occ_rel[m] as usize;
492                    grid_occupancy[occ_dst..occ_dst + mip.occupancy.len()]
493                        .copy_from_slice(&mip.occupancy);
494                    // Solid bitmap immediately follows the textured one.
495                    let solid_dst = occ_dst + mip.occupancy.len();
496                    grid_occupancy[solid_dst..solid_dst + mip.solid_occupancy.len()]
497                        .copy_from_slice(&mip.solid_occupancy);
498                    let coff_dst = off_start + layout.mip_coff_rel[m] as usize;
499                    grid_color_offsets[coff_dst..coff_dst + mip.color_offsets.len()]
500                        .copy_from_slice(&mip.color_offsets);
501
502                    let remaining = colors_stride.saturating_sub(color_cursor);
503                    let n = mip.colors.len().min(remaining);
504                    if n < mip.colors.len() {
505                        eprintln!(
506                            "roxlap-gpu SceneUpload: scene grid chunk {chunk_idx:?} mip {m} \
507                             colours overflow COLORS_PER_CHUNK_WORDS ({colors_stride}); \
508                             truncating",
509                        );
510                    }
511                    grid_colors[col_start + color_cursor..col_start + color_cursor + n]
512                        .copy_from_slice(&mip.colors[..n]);
513                    color_cursor += n;
514                }
515
516                if !chunk.mips[0].colors.is_empty() {
517                    grid_chunk_occupancy[slot_idx >> 5] |= 1u32 << (slot_idx & 31);
518                }
519                grid_slot_chunk_idx[slot_idx] = [chunk_idx[0], chunk_idx[1], chunk_idx[2], 0];
520                // CA perf — the slot's in-chunk solid z-extent.
521                grid_slot_z_ext[slot_idx] = match crate::decompress::solid_z_extent(&chunk.mips[0])
522                {
523                    Some((lo, hi)) => [lo as i32, hi as i32],
524                    None => [i32::MAX, i32::MIN],
525                };
526                // PF.12.c — mirror the slot's color_offsets window.
527                grid_offsets_shadow.insert(
528                    slot_idx,
529                    grid_color_offsets[off_start..off_start + offsets_words_per_slot].to_vec(),
530                );
531            }
532
533            // Slot_chunk_idx storage offset: each entry is 4 u32
534            // words (vec3 padded to 16 bytes in std430).
535            let slot_chunk_idx_offset = u32::try_from(all_slot_chunk_idx.len()).expect("fits");
536            // GPU.13.0 — occupied chunk-AABB for the outer-DDA early-out.
537            let (aabb_min, aabb_max) = aabb_of_slots(&grid_slot_chunk_idx);
538            // CA perf — grid-local solid voxel z-extent (marcher clamp).
539            let (vox_z_lo, vox_z_hi) = vox_z_of_slots(&grid_slot_chunk_idx, &grid_slot_z_ext);
540            // GPU.13.1 — chunk-occupancy pyramid: levels 1.. appended
541            // right after this grid's L0 words, offsets recorded per
542            // level (word offsets are into `all_chunk_occupancy`).
543            let chunk_occupancy_offset = u32::try_from(all_chunk_occupancy.len()).expect("fits");
544            let pyramid = build_occ_pyramid(grid.pool_dims, &grid_chunk_occupancy);
545            let mut chunk_occ_mip_off = [0u32; 4];
546            {
547                let mut cursor = all_chunk_occupancy.len() + grid_chunk_occupancy.len();
548                for (i, level) in pyramid.iter().enumerate() {
549                    chunk_occ_mip_off[i] = u32::try_from(cursor).expect("fits");
550                    cursor += level.len();
551                }
552            }
553            let meta = GridStaticMeta {
554                occupancy_offset: u32::try_from(all_occupancy.len()).expect("fits"),
555                color_offsets_offset: u32::try_from(all_color_offsets.len()).expect("fits"),
556                colors_offset: u32::try_from(all_colors.len()).expect("fits"),
557                chunk_colors_base_offset: u32::try_from(all_chunk_colors_base.len()).expect("fits"),
558                chunk_occupancy_offset,
559                slot_chunk_idx_offset,
560                vsid,
561                total_slots: total_slots as u32,
562                pool_dims: grid.pool_dims,
563                _pad0: 0,
564                occ_words_per_slot: layout.occ_words_per_slot,
565                offsets_words_per_slot: layout.offsets_words_per_slot,
566                mip_count: layout.mip_count,
567                _pad1: 0,
568                mip_occ_rel: layout.mip_occ_rel,
569                mip_coff_rel: layout.mip_coff_rel,
570                aabb_min,
571                _pad2: 0,
572                aabb_max,
573                _pad3: 0,
574                chunk_occ_mip_off,
575                chunk_occ_levels: u32::try_from(pyramid.len()).expect("small"),
576                vox_z_lo,
577                vox_z_hi,
578                _pad4: 0,
579            };
580
581            chunk_occupancy_shadow.push(grid_chunk_occupancy.clone());
582            chunk_occ_pyramid_shadow.push(pyramid.clone());
583            slot_chunk_idx_shadow.push(grid_slot_chunk_idx.clone());
584            slot_z_ext_shadow.push(grid_slot_z_ext);
585            color_offsets_shadow.push(grid_offsets_shadow);
586
587            all_occupancy.extend_from_slice(&grid_occupancy);
588            all_color_offsets.extend_from_slice(&grid_color_offsets);
589            all_colors.extend_from_slice(&grid_colors);
590            all_chunk_colors_base.extend_from_slice(&grid_chunk_colors_base);
591            all_chunk_occupancy.extend_from_slice(&grid_chunk_occupancy);
592            for level in &pyramid {
593                all_chunk_occupancy.extend_from_slice(level);
594            }
595            for entry in &grid_slot_chunk_idx {
596                all_slot_chunk_idx.extend_from_slice(entry);
597            }
598            static_meta.push(meta);
599        }
600
601        // Pad an empty scene's storage buffers — wgpu rejects
602        // zero-size storage bindings.
603        if all_occupancy.is_empty() {
604            all_occupancy.push(0);
605        }
606        if all_color_offsets.is_empty() {
607            all_color_offsets.push(0);
608        }
609        if all_colors.is_empty() {
610            all_colors.push(0);
611        }
612        if all_chunk_colors_base.is_empty() {
613            all_chunk_colors_base.push(0);
614        }
615        if all_chunk_occupancy.is_empty() {
616            all_chunk_occupancy.push(0);
617        }
618        if all_slot_chunk_idx.is_empty() {
619            // 4 zeros = single padded vec3<i32>. wgpu rejects
620            // zero-sized storage bindings.
621            all_slot_chunk_idx.extend_from_slice(&[0; 4]);
622        }
623        if static_meta.is_empty() {
624            static_meta.push(GridStaticMeta::zeroed());
625        }
626
627        let occupancy_bytes = (all_occupancy.len() * 4) as u64;
628        let color_offsets_bytes = (all_color_offsets.len() * 4) as u64;
629        let colors_bytes = (all_colors.len() * 4) as u64;
630        let chunk_colors_base_bytes = (all_chunk_colors_base.len() * 4) as u64;
631        let chunk_occupancy_bytes = (all_chunk_occupancy.len() * 4) as u64;
632        let slot_chunk_idx_bytes = (all_slot_chunk_idx.len() * 4) as u64;
633        let static_meta_bytes = (static_meta.len() * std::mem::size_of::<GridStaticMeta>()) as u64;
634        let total_bytes = occupancy_bytes
635            + color_offsets_bytes
636            + colors_bytes
637            + chunk_colors_base_bytes
638            + chunk_occupancy_bytes
639            + slot_chunk_idx_bytes
640            + static_meta_bytes;
641
642        // Split the concatenated occupancy across storage pages so no
643        // single binding exceeds the device limit. Page size is a
644        // whole number of chunk slots (slot-aligned) so no per-slot
645        // refresh write ever straddles two pages.
646        // GPU.11 — page alignment is now the whole-ladder per-slot
647        // occupancy stride so a slot (all its mips) never straddles a
648        // page boundary.
649        let slot_align_words = info
650            .grids
651            .iter()
652            .map(|g| u64::from(MipLayout::for_vsid(g.vsid).occ_words_per_slot))
653            .max()
654            .unwrap_or(1)
655            .max(1);
656        let (occupancy_pages, occupancy_page_words, occupancy_num_pages) =
657            split_occupancy_pages(device, &all_occupancy, slot_align_words);
658        let all_color_offsets =
659            create_storage(device, "roxlap-gpu scene.color_offsets", &all_color_offsets);
660        let all_colors = create_storage(device, "roxlap-gpu scene.colors", &all_colors);
661        let all_chunk_colors_base = create_storage(
662            device,
663            "roxlap-gpu scene.chunk_colors_base",
664            &all_chunk_colors_base,
665        );
666        let all_chunk_occupancy = create_storage(
667            device,
668            "roxlap-gpu scene.chunk_occupancy",
669            &all_chunk_occupancy,
670        );
671        // GPU.7 slot identity verification buffer. i32 storage.
672        let all_slot_chunk_idx_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
673            label: Some("roxlap-gpu scene.slot_chunk_idx"),
674            contents: bytemuck::cast_slice(&all_slot_chunk_idx),
675            usage: wgpu::BufferUsages::STORAGE
676                | wgpu::BufferUsages::COPY_DST
677                | wgpu::BufferUsages::COPY_SRC,
678        });
679        let grid_static_meta = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
680            label: Some("roxlap-gpu scene.grid_static_meta"),
681            contents: bytemuck::cast_slice(&static_meta),
682            // GPU.13.0 — COPY_DST so the live chunk-AABB can be patched
683            // into a grid's meta on refresh_chunk / evict_chunk.
684            usage: wgpu::BufferUsages::STORAGE
685                | wgpu::BufferUsages::COPY_DST
686                | wgpu::BufferUsages::COPY_SRC,
687        });
688
689        Self {
690            grid_count,
691            occupancy_pages,
692            occupancy_page_words,
693            occupancy_num_pages,
694            all_color_offsets,
695            all_colors,
696            all_chunk_colors_base,
697            all_chunk_occupancy,
698            all_slot_chunk_idx: all_slot_chunk_idx_buf,
699            grid_static_meta,
700            total_bytes,
701            static_meta,
702            chunk_occupancy_shadow,
703            chunk_occ_pyramid_shadow,
704            slot_chunk_idx_shadow,
705            color_offsets_shadow,
706            colors_stride_shadow: grid_colors_strides,
707            slot_z_ext_shadow,
708        }
709    }
710
711    /// GPU memory held by the scene's storage buffers, in bytes —
712    /// [`Self::total_bytes`] as computed at upload time (in-place
713    /// refreshes never change it).
714    pub fn resident_bytes(&self) -> u64 {
715        self.total_bytes
716    }
717
718    /// Install or refresh a chunk in its modular pool slot. GPU.7
719    /// generalises GPU.6's in-place refresh: any chunk_idx maps to
720    /// a slot via `chunk_idx & (pool_dims - 1)`. The previous
721    /// occupant (if a different chunk) is silently replaced — the
722    /// host is responsible for guaranteeing that the pool is sized
723    /// large enough that two simultaneously-resident chunks never
724    /// collide on the same slot.
725    pub fn refresh_chunk(
726        &mut self,
727        queue: &wgpu::Queue,
728        scene_idx: usize,
729        chunk_idx: [i32; 3],
730        chunk: &ChunkUpload,
731    ) -> RefreshOutcome {
732        let Some(meta) = self.static_meta.get(scene_idx).copied() else {
733            return RefreshOutcome::SceneIdxOob;
734        };
735        let slot_idx = modular_slot_idx(chunk_idx, meta.pool_dims);
736
737        // GPU.11 — the per-slot strides span the full mip ladder; the
738        // resident's layout was built from the same `MipLayout`.
739        let layout = MipLayout::for_vsid(meta.vsid);
740        let occ_words_per_slot = layout.occ_words_per_slot as usize;
741        let offsets_words_per_slot = layout.offsets_words_per_slot as usize;
742        // Same adaptive stride the initial upload chose for this grid.
743        let colors_stride = self
744            .colors_stride_shadow
745            .get(scene_idx)
746            .map_or(COLORS_PER_CHUNK_WORDS as usize, |&s| s as usize);
747
748        assert_eq!(
749            chunk.mips.len() as u32,
750            layout.mip_count,
751            "refresh_chunk: mip count mismatch (chunk {} vs grid {})",
752            chunk.mips.len(),
753            layout.mip_count,
754        );
755
756        // ---- occupancy ----
757        // Route each mip's write to its page. Page size is slot-
758        // aligned (see `split_occupancy_pages`) so the whole slot's
759        // occupancy ladder lands in a single page.
760        let slot_occ_base = meta.occupancy_offset as usize + slot_idx * occ_words_per_slot;
761        let page_words = self.occupancy_page_words as usize;
762        let page = slot_occ_base / page_words;
763        let slot_local_word = slot_occ_base % page_words;
764        debug_assert!(
765            slot_local_word + occ_words_per_slot <= page_words,
766            "occupancy slot straddles a page boundary — page size not slot-aligned",
767        );
768        let off_slot_base = meta.color_offsets_offset as usize + slot_idx * offsets_words_per_slot;
769        let col_slot_base = meta.colors_offset as usize + slot_idx * colors_stride;
770
771        let mut outcome = RefreshOutcome::Ok;
772        let mut color_cursor = 0usize;
773        for (m, mip) in chunk.mips.iter().enumerate() {
774            // occupancy (textured) then solid, back-to-back.
775            let local = slot_local_word + layout.mip_occ_rel[m] as usize;
776            queue.write_buffer(
777                &self.occupancy_pages[page],
778                (local * 4) as u64,
779                bytemuck::cast_slice(&mip.occupancy),
780            );
781            queue.write_buffer(
782                &self.occupancy_pages[page],
783                ((local + mip.occupancy.len()) * 4) as u64,
784                bytemuck::cast_slice(&mip.solid_occupancy),
785            );
786            // color_offsets
787            let coff = off_slot_base + layout.mip_coff_rel[m] as usize;
788            queue.write_buffer(
789                &self.all_color_offsets,
790                (coff * 4) as u64,
791                bytemuck::cast_slice(&mip.color_offsets),
792            );
793            // colours (concatenated per slot, truncate to stride)
794            let remaining = colors_stride.saturating_sub(color_cursor);
795            let n = mip.colors.len().min(remaining);
796            if n < mip.colors.len() {
797                eprintln!(
798                    "roxlap-gpu refresh_chunk: scene_idx={scene_idx} chunk_idx={chunk_idx:?} \
799                     mip {m} colours overflow stride {colors_stride}; truncating",
800                );
801                outcome = RefreshOutcome::ColorsTruncated;
802            }
803            if n > 0 {
804                queue.write_buffer(
805                    &self.all_colors,
806                    ((col_slot_base + color_cursor) * 4) as u64,
807                    bytemuck::cast_slice(&mip.colors[..n]),
808                );
809            }
810            color_cursor += n;
811        }
812
813        // ---- chunk_occupancy bit ----
814        self.set_chunk_occupancy_bit(
815            queue,
816            scene_idx,
817            &meta,
818            slot_idx,
819            !chunk.mips[0].colors.is_empty(),
820        );
821
822        // ---- slot_chunk_idx (identity for the shader) ----
823        self.set_slot_chunk_idx(queue, scene_idx, &meta, slot_idx, chunk_idx);
824
825        // ---- PF.12.c — mirror the slot's color_offsets window ----
826        // (`refresh_chunk_partial` verifies counts + places colours
827        // against it). Rebuilt exactly as the GPU windows were written.
828        let mut window = vec![0u32; offsets_words_per_slot];
829        for (m, mip) in chunk.mips.iter().enumerate() {
830            let coff = layout.mip_coff_rel[m] as usize;
831            window[coff..coff + mip.color_offsets.len()].copy_from_slice(&mip.color_offsets);
832        }
833        self.color_offsets_shadow[scene_idx].insert(slot_idx, window);
834
835        // CA perf — the slot's in-chunk solid z-extent (the grid clamp
836        // recomputes inside sync_aabb below).
837        self.slot_z_ext_shadow[scene_idx][slot_idx] =
838            match crate::decompress::solid_z_extent(&chunk.mips[0]) {
839                Some((lo, hi)) => [lo as i32, hi as i32],
840                None => [i32::MAX, i32::MIN],
841            };
842
843        // ---- GPU.13.0 grid-AABB early-out box ----
844        self.sync_aabb(queue, scene_idx);
845
846        outcome
847    }
848
849    /// Evict a chunk's slot — clear its `chunk_occupancy` bit and
850    /// reset `slot_chunk_idx` to the empty sentinel. Used by the
851    /// host when a chunk disappears from the CPU-side `Grid::chunks`
852    /// (e.g. streaming eviction past `r_evict`).
853    ///
854    /// Returns `false` if `scene_idx` is past `grid_count` (no-op);
855    /// `true` otherwise.
856    /// PF.12.c — partial refresh: re-derive + re-upload ONLY the columns
857    /// inside the inclusive chunk-local mip-0 column rect `[x0..=x1] ×
858    /// [y0..=y1]` (pre-padded by the caller with the edit's ±1 adjacency
859    /// reach), for every mip. Requires the slot to already hold
860    /// `chunk_idx` with a mirrored offsets table, and every dirty
861    /// column's colour COUNT to be unchanged (a count change reflows the
862    /// packed colour block). Returns `false` — with **nothing written**
863    /// — when any precondition fails; the caller falls back to the full
864    /// [`Self::refresh_chunk`] path.
865    ///
866    /// The count-stable case is the streaming bake tracker's per-frame
867    /// path (brightness-byte rewrites) and recolour edits: those now
868    /// upload a few KB instead of decompressing + rewriting the whole
869    /// ~1–2 MB chunk ladder.
870    #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
871    pub fn refresh_chunk_partial(
872        &mut self,
873        queue: &wgpu::Queue,
874        scene_idx: usize,
875        chunk_idx: [i32; 3],
876        vxl: &roxlap_formats::vxl::Vxl,
877        x0: i32,
878        y0: i32,
879        x1: i32,
880        y1: i32,
881    ) -> bool {
882        let Some(meta) = self.static_meta.get(scene_idx).copied() else {
883            return false;
884        };
885        let layout = MipLayout::for_vsid(meta.vsid);
886        if vxl.mip_count() < layout.mip_count {
887            return false;
888        }
889        let slot_idx = modular_slot_idx(chunk_idx, meta.pool_dims);
890        // The slot must currently hold THIS chunk (modular pools reuse
891        // slots; a partial write over another chunk's data = garbage).
892        let held = self.slot_chunk_idx_shadow[scene_idx][slot_idx];
893        if held[0] != chunk_idx[0] || held[1] != chunk_idx[1] || held[2] != chunk_idx[2] {
894            return false;
895        }
896        let Some(offs_shadow) = self.color_offsets_shadow[scene_idx].get(&slot_idx) else {
897            return false;
898        };
899        let colors_stride = self
900            .colors_stride_shadow
901            .get(scene_idx)
902            .map_or(COLORS_PER_CHUNK_WORDS as usize, |&s| s as usize);
903
904        // Phase 1 — recompute every dirty column per mip into row-run
905        // buffers (rows are contiguous in both the occupancy layout and
906        // the packed colour block), verifying colour counts. NOTHING is
907        // written until the whole extent verifies.
908        struct RowRun {
909            /// Textured-occupancy word offset within the slot.
910            occ_word: usize,
911            /// Solid block sits `block_words` after the textured one.
912            block_words: usize,
913            occ: Vec<u32>,
914            solid: Vec<u32>,
915            /// Colour word offset within the slot's colour block.
916            color_word: usize,
917            colors: Vec<u32>,
918        }
919        let mut runs: Vec<RowRun> = Vec::new();
920        // CA perf — mip-0 solid z-extent of the re-derived columns, to
921        // WIDEN the slot's clamp below (widen-only is safe: an edit
922        // that removed the extremes leaves the clamp conservatively
923        // large; the next full refresh re-tightens it).
924        let mut dirty_z = [i32::MAX, i32::MIN];
925        for m in 0..layout.mip_count {
926            let vsid_m = (meta.vsid >> m).max(1) as i32;
927            let cz_m = crate::decompress::CHUNK_Z >> m;
928            let wpc = occ_words_per_column_for_mip(m) as usize;
929            let block_words = (vsid_m as usize) * (vsid_m as usize) * wpc;
930            let rx0 = (x0 >> m).clamp(0, vsid_m - 1);
931            let ry0 = (y0 >> m).clamp(0, vsid_m - 1);
932            let rx1 = (x1 >> m).clamp(0, vsid_m - 1);
933            let ry1 = (y1 >> m).clamp(0, vsid_m - 1);
934            let coff_base = layout.mip_coff_rel[m as usize] as usize;
935            for y in ry0..=ry1 {
936                let row_col0 = (y * vsid_m + rx0) as usize;
937                let n_cols = (rx1 - rx0 + 1) as usize;
938                let mut occ = vec![0u32; n_cols * wpc];
939                let mut solid = vec![0u32; n_cols * wpc];
940                let mut colors: Vec<u32> = Vec::new();
941                for i in 0..n_cols {
942                    let col_idx = row_col0 + i;
943                    let slab = vxl.column_data_for_mip(m, col_idx);
944                    let before = colors.len();
945                    // vsid=1 / (0,0) → the column scratch windows index
946                    // from word 0 of the per-column slices.
947                    crate::decompress::decompress_column(
948                        slab,
949                        0,
950                        0,
951                        1,
952                        cz_m,
953                        wpc as u32,
954                        &mut occ[i * wpc..(i + 1) * wpc],
955                        &mut solid[i * wpc..(i + 1) * wpc],
956                        &mut colors,
957                    );
958                    // Count stability vs the mirrored offsets table.
959                    let old_count = offs_shadow[coff_base + col_idx + 1]
960                        .saturating_sub(offs_shadow[coff_base + col_idx])
961                        as usize;
962                    if colors.len() - before != old_count {
963                        return false; // reflow → full path
964                    }
965                    // CA perf — fold this column's mip-0 solid extent.
966                    if m == 0 {
967                        for (k, &w) in solid[i * wpc..(i + 1) * wpc].iter().enumerate() {
968                            if w == 0 {
969                                continue;
970                            }
971                            let zb = (k as i32) * 32;
972                            dirty_z[0] = dirty_z[0].min(zb + w.trailing_zeros() as i32);
973                            dirty_z[1] = dirty_z[1].max(zb + 31 - w.leading_zeros() as i32);
974                        }
975                    }
976                }
977                let color_word = offs_shadow[coff_base + row_col0] as usize;
978                if color_word + colors.len() > colors_stride {
979                    return false; // stride overflow → full path handles
980                }
981                runs.push(RowRun {
982                    occ_word: layout.mip_occ_rel[m as usize] as usize + row_col0 * wpc,
983                    block_words,
984                    occ,
985                    solid,
986                    color_word,
987                    colors,
988                });
989            }
990        }
991
992        // Phase 2 — verified: write the row runs.
993        let occ_words_per_slot = layout.occ_words_per_slot as usize;
994        let slot_occ_base = meta.occupancy_offset as usize + slot_idx * occ_words_per_slot;
995        let page_words = self.occupancy_page_words as usize;
996        let page = slot_occ_base / page_words;
997        let slot_local_word = slot_occ_base % page_words;
998        let col_slot_base = meta.colors_offset as usize + slot_idx * colors_stride;
999        for run in &runs {
1000            let tex = slot_local_word + run.occ_word;
1001            queue.write_buffer(
1002                &self.occupancy_pages[page],
1003                (tex * 4) as u64,
1004                bytemuck::cast_slice(&run.occ),
1005            );
1006            queue.write_buffer(
1007                &self.occupancy_pages[page],
1008                ((tex + run.block_words) * 4) as u64,
1009                bytemuck::cast_slice(&run.solid),
1010            );
1011            if !run.colors.is_empty() {
1012                queue.write_buffer(
1013                    &self.all_colors,
1014                    ((col_slot_base + run.color_word) * 4) as u64,
1015                    bytemuck::cast_slice(&run.colors),
1016                );
1017            }
1018        }
1019        // Counts unchanged ⇒ offsets, chunk-occupancy bit, AABB and the
1020        // mirrors all stay valid untouched.
1021        // CA perf — widen the slot's solid z-extent by the re-derived
1022        // columns (never narrows; see `dirty_z`'s comment).
1023        if dirty_z[0] <= dirty_z[1] {
1024            let e = &mut self.slot_z_ext_shadow[scene_idx][slot_idx];
1025            let widened = dirty_z[0] < e[0] || dirty_z[1] > e[1];
1026            e[0] = e[0].min(dirty_z[0]);
1027            e[1] = e[1].max(dirty_z[1]);
1028            if widened {
1029                self.sync_aabb(queue, scene_idx);
1030            }
1031        }
1032        true
1033    }
1034
1035    /// Evict `chunk_idx` from grid `scene_idx`: clear the slot's
1036    /// chunk-occupancy bit, stamp [`SLOT_EMPTY_SENTINEL`] into its
1037    /// `slot_chunk_idx` entry, and shrink the grid's chunk-AABB if the
1038    /// box tightened. The bulk voxel data is left in place — the
1039    /// cleared occupancy bit + sentinel already make the shader treat
1040    /// the slot as empty. A no-op (returning `true`) when the slot
1041    /// meanwhile holds a *different* chunk, so a stale evict can never
1042    /// wipe a newer occupant. Returns `false` only for an out-of-range
1043    /// `scene_idx`.
1044    pub fn evict_chunk(
1045        &mut self,
1046        queue: &wgpu::Queue,
1047        scene_idx: usize,
1048        chunk_idx: [i32; 3],
1049    ) -> bool {
1050        let Some(meta) = self.static_meta.get(scene_idx).copied() else {
1051            return false;
1052        };
1053        let slot_idx = modular_slot_idx(chunk_idx, meta.pool_dims);
1054        // Only evict if this slot still claims to hold `chunk_idx`.
1055        // Otherwise we'd be wiping out a different (newer) chunk
1056        // that happens to share the slot.
1057        let shadow_entry = self.slot_chunk_idx_shadow[scene_idx][slot_idx];
1058        if shadow_entry[0] != chunk_idx[0]
1059            || shadow_entry[1] != chunk_idx[1]
1060            || shadow_entry[2] != chunk_idx[2]
1061        {
1062            return true;
1063        }
1064        self.set_chunk_occupancy_bit(queue, scene_idx, &meta, slot_idx, false);
1065        self.set_slot_chunk_idx(queue, scene_idx, &meta, slot_idx, SLOT_EMPTY_SENTINEL);
1066        // PF.12.c — drop the evicted slot's offsets mirror.
1067        self.color_offsets_shadow[scene_idx].remove(&slot_idx);
1068        // CA perf — the slot no longer contributes to the z-extent.
1069        self.slot_z_ext_shadow[scene_idx][slot_idx] = [i32::MAX, i32::MIN];
1070        // GPU.13.0 — eviction may shrink the occupied box; recompute.
1071        self.sync_aabb(queue, scene_idx);
1072        true
1073    }
1074
1075    fn set_chunk_occupancy_bit(
1076        &mut self,
1077        queue: &wgpu::Queue,
1078        scene_idx: usize,
1079        meta: &GridStaticMeta,
1080        slot_idx: usize,
1081        new_bit: bool,
1082    ) {
1083        let word_idx = slot_idx >> 5;
1084        let bit = slot_idx & 31;
1085        let shadow = &mut self.chunk_occupancy_shadow[scene_idx][word_idx];
1086        let was_bit = (*shadow >> bit) & 1 == 1;
1087        if new_bit == was_bit {
1088            return;
1089        }
1090        if new_bit {
1091            *shadow |= 1u32 << bit;
1092        } else {
1093            *shadow &= !(1u32 << bit);
1094        }
1095        let global_word_idx = meta.chunk_occupancy_offset as usize + word_idx;
1096        queue.write_buffer(
1097            &self.all_chunk_occupancy,
1098            (global_word_idx * 4) as u64,
1099            bytemuck::bytes_of(shadow),
1100        );
1101
1102        // GPU.13.1 — re-OR the pyramid ancestors of the touched slot.
1103        // Setting a bit can only turn ancestors ON; clearing one can
1104        // only turn them OFF — either way, an unchanged ancestor means
1105        // every level above it is unchanged too, so stop early.
1106        let pool = meta.pool_dims;
1107        let slot = [
1108            (slot_idx as u32) % pool[0],
1109            ((slot_idx as u32) / pool[0]) % pool[1],
1110            (slot_idx as u32) / (pool[0] * pool[1]),
1111        ];
1112        for l in 1..=meta.chunk_occ_levels {
1113            let cell = [slot[0] >> l, slot[1] >> l, slot[2] >> l];
1114            let bit = {
1115                let child: &[u32] = if l == 1 {
1116                    &self.chunk_occupancy_shadow[scene_idx]
1117                } else {
1118                    &self.chunk_occ_pyramid_shadow[scene_idx][(l - 2) as usize]
1119                };
1120                occ_cell_from_children(pool, l, cell, child)
1121            };
1122            let d = occ_level_dims(pool, l);
1123            let idx = (cell[0] + cell[1] * d[0] + cell[2] * d[0] * d[1]) as usize;
1124            let word = &mut self.chunk_occ_pyramid_shadow[scene_idx][(l - 1) as usize][idx >> 5];
1125            let was = (*word >> (idx & 31)) & 1 == 1;
1126            if bit == was {
1127                break;
1128            }
1129            if bit {
1130                *word |= 1u32 << (idx & 31);
1131            } else {
1132                *word &= !(1u32 << (idx & 31));
1133            }
1134            let global = meta.chunk_occ_mip_off[(l - 1) as usize] as usize + (idx >> 5);
1135            let word_copy = *word;
1136            queue.write_buffer(
1137                &self.all_chunk_occupancy,
1138                (global * 4) as u64,
1139                bytemuck::bytes_of(&word_copy),
1140            );
1141        }
1142    }
1143
1144    /// Read-only view of the chunk-occupancy pyramid shadows (per
1145    /// grid, per level above L0) — the CPU mirror the incremental
1146    /// maintenance updates; exposed for integration tests to assert
1147    /// the re-OR bookkeeping (rendering itself only reads the GPU
1148    /// copy).
1149    #[must_use]
1150    pub fn chunk_occ_pyramid_shadow(&self) -> &[Vec<Vec<u32>>] {
1151        &self.chunk_occ_pyramid_shadow
1152    }
1153
1154    fn set_slot_chunk_idx(
1155        &mut self,
1156        queue: &wgpu::Queue,
1157        scene_idx: usize,
1158        meta: &GridStaticMeta,
1159        slot_idx: usize,
1160        chunk_idx: [i32; 3],
1161    ) {
1162        let entry = [chunk_idx[0], chunk_idx[1], chunk_idx[2], 0];
1163        self.slot_chunk_idx_shadow[scene_idx][slot_idx] = entry;
1164        let global_word_idx = meta.slot_chunk_idx_offset as usize + slot_idx * 4;
1165        queue.write_buffer(
1166            &self.all_slot_chunk_idx,
1167            (global_word_idx * 4) as u64,
1168            bytemuck::cast_slice(&entry),
1169        );
1170    }
1171
1172    /// GPU.13.0 — recompute the grid's occupied chunk-AABB from its
1173    /// `slot_chunk_idx` shadow and, if it changed, patch the grid's
1174    /// [`GridStaticMeta`] on the GPU. Cheap: scans `total_slots`
1175    /// entries and writes 144 bytes only when the box actually moves
1176    /// (steady-state re-bakes leave it unchanged → no GPU write).
1177    /// Called after every install/eviction so streaming grids keep a
1178    /// tight, always-conservative early-out box.
1179    fn sync_aabb(&mut self, queue: &wgpu::Queue, scene_idx: usize) {
1180        let (aabb_min, aabb_max) = aabb_of_slots(&self.slot_chunk_idx_shadow[scene_idx]);
1181        // CA perf — the voxel z-extent clamp travels with the AABB.
1182        let (vox_z_lo, vox_z_hi) = vox_z_of_slots(
1183            &self.slot_chunk_idx_shadow[scene_idx],
1184            &self.slot_z_ext_shadow[scene_idx],
1185        );
1186        let meta = &mut self.static_meta[scene_idx];
1187        if meta.aabb_min == aabb_min
1188            && meta.aabb_max == aabb_max
1189            && meta.vox_z_lo == vox_z_lo
1190            && meta.vox_z_hi == vox_z_hi
1191        {
1192            return;
1193        }
1194        meta.aabb_min = aabb_min;
1195        meta.aabb_max = aabb_max;
1196        meta.vox_z_lo = vox_z_lo;
1197        meta.vox_z_hi = vox_z_hi;
1198        let off = (scene_idx * std::mem::size_of::<GridStaticMeta>()) as u64;
1199        queue.write_buffer(&self.grid_static_meta, off, bytemuck::bytes_of(meta));
1200    }
1201}
1202
1203/// CA perf — inclusive grid-local SOLID voxel z-extent over a grid's
1204/// occupied slots: each slot contributes `chunk_z · CHUNK_Z + its
1205/// in-chunk extent`. Inverted sentinel when nothing is solid — the
1206/// marcher's entry slab test then rejects every ray, matching the
1207/// empty chunk-AABB behaviour.
1208fn vox_z_of_slots(slots: &[[i32; 4]], exts: &[[i32; 2]]) -> (i32, i32) {
1209    let mut lo = i32::MAX;
1210    let mut hi = i32::MIN;
1211    for (s, e) in slots.iter().zip(exts.iter()) {
1212        if s[..3] == SLOT_EMPTY_SENTINEL[..3] || e[0] > e[1] {
1213            continue;
1214        }
1215        let base = s[2] * crate::decompress::CHUNK_Z as i32;
1216        lo = lo.min(base + e[0]);
1217        hi = hi.max(base + e[1]);
1218    }
1219    (lo, hi)
1220}
1221
1222/// GPU.13.0 — inclusive chunk-AABB over a grid's `slot_chunk_idx`
1223/// shadow, skipping the [`SLOT_EMPTY_SENTINEL`] entries. Returns the
1224/// inverted sentinel box (`min = i32::MAX`, `max = i32::MIN`) when no
1225/// slot is occupied, which makes the shader's `aabb_passed` early-out
1226/// fire for every ray (an empty grid renders nothing).
1227/// GPU.13.1 — cells per axis of chunk-occupancy pyramid level `l`
1228/// (`l >= 1`): the pool box halves per level, floored at 1.
1229fn occ_level_dims(pool: [u32; 3], l: u32) -> [u32; 3] {
1230    [
1231        (pool[0] >> l).max(1),
1232        (pool[1] >> l).max(1),
1233        (pool[2] >> l).max(1),
1234    ]
1235}
1236
1237/// u32 words holding level `l`'s bits.
1238fn occ_level_words(pool: [u32; 3], l: u32) -> usize {
1239    let d = occ_level_dims(pool, l);
1240    (d[0] * d[1] * d[2]).div_ceil(32) as usize
1241}
1242
1243/// Pyramid levels above L0: enough that the largest pool axis
1244/// reaches one cell, capped by the meta's 4 offset slots.
1245fn occ_pyramid_levels(pool: [u32; 3]) -> u32 {
1246    let max_dim = pool[0].max(pool[1]).max(pool[2]).max(1);
1247    max_dim.ilog2().min(4)
1248}
1249
1250/// One level-`l` cell's bit, recomputed as the OR of its (up to
1251/// 2×2×2) children in level `l - 1` (level 0 = the per-slot bitmap).
1252fn occ_cell_from_children(pool: [u32; 3], l: u32, cell: [u32; 3], child_words: &[u32]) -> bool {
1253    let cd = if l == 1 {
1254        pool
1255    } else {
1256        occ_level_dims(pool, l - 1)
1257    };
1258    for dz in 0..2u32 {
1259        for dy in 0..2u32 {
1260            for dx in 0..2u32 {
1261                let c = [cell[0] * 2 + dx, cell[1] * 2 + dy, cell[2] * 2 + dz];
1262                if c[0] >= cd[0] || c[1] >= cd[1] || c[2] >= cd[2] {
1263                    continue;
1264                }
1265                let idx = (c[0] + c[1] * cd[0] + c[2] * cd[0] * cd[1]) as usize;
1266                if (child_words[idx >> 5] >> (idx & 31)) & 1 == 1 {
1267                    return true;
1268                }
1269            }
1270        }
1271    }
1272    false
1273}
1274
1275/// Build the whole pyramid (levels 1..=`occ_pyramid_levels`) from the
1276/// L0 per-slot bitmap. Returns one word-vec per level.
1277fn build_occ_pyramid(pool: [u32; 3], l0: &[u32]) -> Vec<Vec<u32>> {
1278    let levels = occ_pyramid_levels(pool);
1279    let mut out: Vec<Vec<u32>> = Vec::with_capacity(levels as usize);
1280    for l in 1..=levels {
1281        let d = occ_level_dims(pool, l);
1282        let child: &[u32] = if l == 1 { l0 } else { &out[(l - 2) as usize] };
1283        let mut words = vec![0u32; occ_level_words(pool, l)];
1284        for z in 0..d[2] {
1285            for y in 0..d[1] {
1286                for x in 0..d[0] {
1287                    if occ_cell_from_children(pool, l, [x, y, z], child) {
1288                        let idx = (x + y * d[0] + z * d[0] * d[1]) as usize;
1289                        words[idx >> 5] |= 1 << (idx & 31);
1290                    }
1291                }
1292            }
1293        }
1294        out.push(words);
1295    }
1296    out
1297}
1298
1299fn aabb_of_slots(slots: &[[i32; 4]]) -> ([i32; 3], [i32; 3]) {
1300    let mut min = [i32::MAX; 3];
1301    let mut max = [i32::MIN; 3];
1302    for e in slots {
1303        if e[0] == SLOT_EMPTY_SENTINEL[0]
1304            && e[1] == SLOT_EMPTY_SENTINEL[1]
1305            && e[2] == SLOT_EMPTY_SENTINEL[2]
1306        {
1307            continue;
1308        }
1309        for k in 0..3 {
1310            if e[k] < min[k] {
1311                min[k] = e[k];
1312            }
1313            if e[k] > max[k] {
1314                max[k] = e[k];
1315            }
1316        }
1317    }
1318    (min, max)
1319}
1320
1321/// Modular slot index for `chunk_idx` given the grid's
1322/// power-of-2 `pool_dims`. Negative `chunk_idx` components map via
1323/// two's-complement bitwise AND, matching the shader's
1324/// `chunk_idx & (pool_dims - 1)`.
1325#[must_use]
1326pub fn modular_slot_idx(chunk_idx: [i32; 3], pool_dims: [u32; 3]) -> usize {
1327    let mask_x = (pool_dims[0] - 1) as i32;
1328    let mask_y = (pool_dims[1] - 1) as i32;
1329    let mask_z = (pool_dims[2] - 1) as i32;
1330    let sx = (chunk_idx[0] & mask_x) as usize;
1331    let sy = (chunk_idx[1] & mask_y) as usize;
1332    let sz = (chunk_idx[2] & mask_z) as usize;
1333    sx + sy * (pool_dims[0] as usize) + sz * (pool_dims[0] as usize) * (pool_dims[1] as usize)
1334}
1335
1336/// Outcome of `GpuSceneResident::refresh_chunk`. Most callers
1337/// can ignore the result; `ColorsTruncated` indicates the chunk's
1338/// colour data overflowed the per-slot stride and was clipped.
1339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1340pub enum RefreshOutcome {
1341    /// The chunk was installed/refreshed in full — every mip's
1342    /// occupancy, offsets, and colours are resident.
1343    Ok,
1344    /// The chunk's colour count exceeded `COLORS_PER_CHUNK_WORDS`;
1345    /// the GPU sees the first `stride` colours. Bump
1346    /// `COLORS_PER_CHUNK_WORDS` for content that hits this.
1347    ColorsTruncated,
1348    /// Retained for ABI compatibility; the GPU.7 modular pool no
1349    /// longer rejects chunks by bbox.
1350    ChunkOutOfBbox,
1351    /// `scene_idx` is past `grid_count`. Programming error.
1352    SceneIdxOob,
1353}
1354
1355fn create_storage(device: &wgpu::Device, label: &str, data: &[u32]) -> wgpu::Buffer {
1356    // GPU.6: include COPY_DST so `refresh_chunk` can `queue.write_buffer`
1357    // into existing slots without rebuilding the resident.
1358    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1359        label: Some(label),
1360        contents: bytemuck::cast_slice(data),
1361        usage: wgpu::BufferUsages::STORAGE
1362            | wgpu::BufferUsages::COPY_DST
1363            | wgpu::BufferUsages::COPY_SRC,
1364    })
1365}
1366
1367/// Split the concatenated occupancy words into up to
1368/// [`MAX_OCC_PAGES`] storage buffers, each no larger than the
1369/// device's `max_storage_buffer_binding_size`, then pad the page
1370/// list with 1-word dummy buffers so the returned vec is always
1371/// exactly `MAX_OCC_PAGES` long (one buffer per bind-group entry).
1372///
1373/// `slot_align_words` is the per-slot occupancy stride: page size is
1374/// rounded down to a multiple of it so no chunk slot — and therefore
1375/// no per-slot `refresh_chunk` write — straddles a page boundary.
1376/// Returns `(pages, page_words, num_pages)`.
1377fn split_occupancy_pages(
1378    device: &wgpu::Device,
1379    words: &[u32],
1380    slot_align_words: u64,
1381) -> (Vec<wgpu::Buffer>, u32, u32) {
1382    let total_words = words.len() as u64;
1383    // wgpu 29 widened `max_storage_buffer_binding_size` to `u64`.
1384    let limit_words = device.limits().max_storage_buffer_binding_size / 4;
1385    // Largest slot-aligned page that fits one binding (≥ 1 slot).
1386    let page_slots = (limit_words / slot_align_words).max(1);
1387    let mut page_words = page_slots.saturating_mul(slot_align_words);
1388    // A tiny scene (or the empty-scene 1-word pad) isn't slot-aligned;
1389    // cap the page at the data length so we don't allocate emptiness.
1390    page_words = page_words.min(total_words.max(1));
1391    let num_pages = total_words.div_ceil(page_words);
1392    assert!(
1393        num_pages as usize <= MAX_OCC_PAGES,
1394        "occupancy needs {num_pages} pages (>{MAX_OCC_PAGES}) at this device's \
1395         {limit_words}-word binding limit; shrink the streaming pool or raise MAX_OCC_PAGES",
1396    );
1397
1398    let mut pages: Vec<wgpu::Buffer> = Vec::with_capacity(MAX_OCC_PAGES);
1399    let page_words_usize = page_words as usize;
1400    for p in 0..num_pages as usize {
1401        let start = p * page_words_usize;
1402        let end = ((p + 1) * page_words_usize).min(words.len());
1403        pages.push(create_storage(
1404            device,
1405            &format!("roxlap-gpu scene.occupancy.page{p}"),
1406            &words[start..end],
1407        ));
1408    }
1409    // Dummy 1-word buffers for the unused bindings.
1410    while pages.len() < MAX_OCC_PAGES {
1411        pages.push(create_storage(
1412            device,
1413            "roxlap-gpu scene.occupancy.page_dummy",
1414            &[0u32],
1415        ));
1416    }
1417    (
1418        pages,
1419        u32::try_from(page_words).expect("page_words fits u32"),
1420        num_pages as u32,
1421    )
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426    use super::*;
1427
1428    #[test]
1429    fn grid_static_meta_matches_wgsl_std430_size() {
1430        // scene_dda.wgsl's GridStaticMeta is read as
1431        // array<GridStaticMeta>; the std430 array stride must equal
1432        // the Rust size_of or wgpu rejects the binding.
1433        // Concretely: 8 u32 (32) + vec3+pad (16) + 4 u32 (16) +
1434        // 2*[u32;6] (48) = 112, then GPU.13.0 adds two vec3<i32>+pad
1435        // (aabb_min, aabb_max) = 32 → 144, and GPU.13.1 adds
1436        // [u32;4] pyramid offsets + levels = 20; CA perf appends
1437        // vox_z_lo/hi + pad = 12 → 176.
1438        assert_eq!(std::mem::size_of::<GridStaticMeta>(), 176);
1439        assert_eq!(std::mem::align_of::<GridStaticMeta>(), 4);
1440        // The size matching is NECESSARY but not sufficient: WGSL
1441        // packs a bare `vec3<i32>` as 12 bytes and the next member
1442        // lands at ITS OWN alignment, so without the shaders'
1443        // `@size(16)` on aabb_min/aabb_max every tail field reads 4
1444        // bytes early while the total stride still agrees (this
1445        // silently disabled the GPU.13.1 pyramid — its level count
1446        // read `mip_off[3]`). Pin the host-side tail offsets the
1447        // annotated WGSL mirrors are written against.
1448        assert_eq!(std::mem::offset_of!(GridStaticMeta, aabb_min), 112);
1449        assert_eq!(std::mem::offset_of!(GridStaticMeta, aabb_max), 128);
1450        assert_eq!(std::mem::offset_of!(GridStaticMeta, chunk_occ_mip_off), 144);
1451        assert_eq!(std::mem::offset_of!(GridStaticMeta, chunk_occ_levels), 160);
1452        assert_eq!(std::mem::offset_of!(GridStaticMeta, vox_z_lo), 164);
1453        assert_eq!(std::mem::offset_of!(GridStaticMeta, vox_z_hi), 168);
1454    }
1455
1456    /// GPU.13.1 — the pyramid built from an L0 bitmap must equal a
1457    /// brute-force OR over every level's block, and incremental
1458    /// child recompute must agree with the builder.
1459    #[test]
1460    fn occ_pyramid_matches_brute_force() {
1461        let pool = [8u32, 8, 4];
1462        // A scattered pattern: bits at slots (0,0,0), (7,7,3), (3,4,1).
1463        let mut l0 = vec![0u32; (8 * 8 * 4) / 32];
1464        for slot in [(0u32, 0u32, 0u32), (7, 7, 3), (3, 4, 1)] {
1465            let idx = (slot.0 + slot.1 * 8 + slot.2 * 64) as usize;
1466            l0[idx >> 5] |= 1 << (idx & 31);
1467        }
1468        let pyr = build_occ_pyramid(pool, &l0);
1469        assert_eq!(pyr.len(), 3, "log2(8) levels above L0");
1470        for l in 1..=3u32 {
1471            let d = occ_level_dims(pool, l);
1472            for z in 0..d[2] {
1473                for y in 0..d[1] {
1474                    for x in 0..d[0] {
1475                        // Brute force: OR of every L0 slot inside the
1476                        // 2^l block (clamped by the pool).
1477                        let mut expect = false;
1478                        for sz in (z << l)..(((z + 1) << l).min(pool[2])) {
1479                            for sy in (y << l)..(((y + 1) << l).min(pool[1])) {
1480                                for sx in (x << l)..(((x + 1) << l).min(pool[0])) {
1481                                    let i = (sx + sy * 8 + sz * 64) as usize;
1482                                    expect |= (l0[i >> 5] >> (i & 31)) & 1 == 1;
1483                                }
1484                            }
1485                        }
1486                        let idx = (x + y * d[0] + z * d[0] * d[1]) as usize;
1487                        let got = (pyr[(l - 1) as usize][idx >> 5] >> (idx & 31)) & 1 == 1;
1488                        assert_eq!(got, expect, "level {l} cell ({x},{y},{z})");
1489                        // Incremental recompute path agrees.
1490                        let child: &[u32] = if l == 1 { &l0 } else { &pyr[(l - 2) as usize] };
1491                        assert_eq!(occ_cell_from_children(pool, l, [x, y, z], child), expect);
1492                    }
1493                }
1494            }
1495        }
1496        // Top level is a single cell and it is occupied.
1497        assert_eq!(pyr[2].len(), 1);
1498        assert_eq!(pyr[2][0] & 1, 1);
1499        // An empty L0 yields an all-empty pyramid.
1500        let empty = build_occ_pyramid(pool, &[0u32; 8]);
1501        assert!(empty.iter().all(|lvl| lvl.iter().all(|&w| w == 0)));
1502    }
1503
1504    #[test]
1505    fn mip_layout_offsets_accumulate() {
1506        // vsid=128 → 6 mips. Relative offsets are cumulative; mip-0
1507        // sits at 0 so mip-0 reads are byte-identical to pre-mip.
1508        let l = MipLayout::for_vsid(128);
1509        assert_eq!(l.mip_count, 6);
1510        assert_eq!(l.mip_occ_rel[0], 0);
1511        assert_eq!(l.mip_coff_rel[0], 0);
1512
1513        // Recompute the strides independently and compare. Each mip
1514        // stores TWO occupancy bitmaps (textured + solid) back-to-back.
1515        let mut occ = 0u32;
1516        let mut coff = 0u32;
1517        for m in 0..6u32 {
1518            assert_eq!(l.mip_occ_rel[m as usize], occ, "occ rel mip {m}");
1519            assert_eq!(l.mip_coff_rel[m as usize], coff, "coff rel mip {m}");
1520            let v = 128u32 >> m;
1521            occ += 2 * v * v * occ_words_per_column_for_mip(m);
1522            coff += v * v + 1;
1523        }
1524        assert_eq!(l.occ_words_per_slot, occ);
1525        assert_eq!(l.offsets_words_per_slot, coff);
1526
1527        // mip-0 occupancy stride is 2 × the historical vsid²·8 (tex +
1528        // solid bitmaps).
1529        assert_eq!(l.mip_occ_rel[1], 2 * 128 * 128 * 8);
1530        // The whole ladder is only ~1/7 larger than mip-0 alone
1531        // (geometric 1 + 1/8 + 1/64 + …) — here on the doubled base.
1532        assert!(l.occ_words_per_slot < 2 * 128 * 128 * 8 * 5 / 4);
1533    }
1534}