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    /// std430 padding; always 0.
264    pub _pad4: [u32; 3],
265}
266
267/// Sentinel chunk_idx written into empty slot_chunk_idx entries.
268/// Real chunk indices never use `i32::MIN`, so the shader can
269/// distinguish empty slots from collisions via a single equality
270/// check.
271pub const SLOT_EMPTY_SENTINEL: [i32; 3] = [i32::MIN, i32::MIN, i32::MIN];
272
273/// GPU-resident storage for an entire scene's grids.
274pub struct GpuSceneResident {
275    /// Number of grids uploaded (= `SceneUpload::grid_count()`); the
276    /// shader's grid loop bound and the length of [`Self::static_meta`].
277    pub grid_count: u32,
278    /// Concatenated per-slot occupancy, split into up to
279    /// [`MAX_OCC_PAGES`] storage bindings so no single binding
280    /// exceeds the device's `max_storage_buffer_binding_size`. The
281    /// vec is always exactly `MAX_OCC_PAGES` long — pages past
282    /// `occupancy_num_pages` are 1-word dummies kept only so the
283    /// bind group has a buffer for every layout entry. Page p holds
284    /// the global word range `[p*occupancy_page_words,
285    /// (p+1)*occupancy_page_words)`; `occupancy_page_words` is a
286    /// whole number of chunk slots so no slot straddles a boundary.
287    pub occupancy_pages: Vec<wgpu::Buffer>,
288    /// Words per occupancy page (a multiple of `occ_words_per_slot`).
289    pub occupancy_page_words: u32,
290    /// Number of real (non-dummy) pages in `occupancy_pages`.
291    pub occupancy_num_pages: u32,
292    /// Binding 2 — concatenated per-slot `color_offsets` prefix tables
293    /// (all grids, all mips). Grid `g`'s region starts at
294    /// `static_meta[g].color_offsets_offset` words.
295    pub all_color_offsets: wgpu::Buffer,
296    /// Binding 3 — concatenated packed voxel colours (all grids).
297    /// Each word is the voxlap wire format the shader unpacks: blue in
298    /// bits 0-7, green 8-15, red 16-23, and a **brightness** byte (not
299    /// alpha) in bits 24-31 with `0x80` = neutral.
300    pub all_colors: wgpu::Buffer,
301    /// Binding 4 — per-slot colour base table: word `meta_id` of grid
302    /// `g`'s region holds the slot's colour start (in words, relative
303    /// to `static_meta[g].colors_offset`) = `meta_id × colour stride`.
304    pub all_chunk_colors_base: wgpu::Buffer,
305    /// Binding 5 — per-grid chunk-occupancy bitmaps (one bit per pool
306    /// slot: set iff the slot holds a non-empty chunk). The outer DDA
307    /// tests it to skip whole 128×128×256 chunks per step.
308    pub all_chunk_occupancy: wgpu::Buffer,
309    /// GPU.7 — per-slot chunk_idx for identity verification in the
310    /// shader. Stored as `vec3<i32>` with std430 16-byte stride
311    /// (each entry is `[i32; 4]` on the host: x, y, z, _pad).
312    pub all_slot_chunk_idx: wgpu::Buffer,
313    /// Binding 6 — the [`GridStaticMeta`] array, one element per grid.
314    /// Mostly upload-time constant; the live chunk-AABB fields are
315    /// patched in place on `refresh_chunk` / `evict_chunk`.
316    pub grid_static_meta: wgpu::Buffer,
317    /// Total bytes allocated across all the resident storage buffers,
318    /// for VRAM accounting (see [`Self::resident_bytes`]).
319    pub total_bytes: u64,
320    /// Cached static metadata for the host's frame-loop work.
321    pub static_meta: Vec<GridStaticMeta>,
322    /// CPU shadow of the per-grid chunk-occupancy bitmap. Each entry
323    /// is the u32 word at `chunk_occupancy_offset + (mi >> 5)`.
324    /// `refresh_chunk` / `evict_chunk` flip the right bit + write
325    /// the affected word back to the GPU.
326    pub(crate) chunk_occupancy_shadow: Vec<Vec<u32>>,
327    /// GPU.13.1 — CPU mirror of each grid's chunk-occupancy pyramid
328    /// (outer Vec: grid; middle: level 1.. — level 0 IS
329    /// [`Self::chunk_occupancy_shadow`]). Ancestor re-OR on
330    /// refresh/evict reads children from here instead of the GPU.
331    pub(crate) chunk_occ_pyramid_shadow: Vec<Vec<Vec<u32>>>,
332    /// CPU shadow of `slot_chunk_idx`. Indexed `[scene_idx][slot]`
333    /// → `[i32; 4]` (vec3 + pad). Host uses this to detect "slot is
334    /// holding a different chunk than expected" + as the eviction
335    /// origin.
336    pub(crate) slot_chunk_idx_shadow: Vec<Vec<[i32; 4]>>,
337    /// Per-grid colour stride in u32 words (the adaptive
338    /// [`COLORS_PER_CHUNK_WORDS`]-or-larger value chosen at upload to
339    /// fit the grid's densest chunk). `refresh_chunk` reads it so a
340    /// streamed re-upload addresses colours with the same stride the
341    /// initial upload used.
342    pub(crate) colors_stride_shadow: Vec<u32>,
343    /// PF.12.c — CPU mirror of each installed slot's per-mip
344    /// `color_offsets` tables (`offsets_words_per_slot` words, the exact
345    /// content of the GPU window). [`Self::refresh_chunk_partial`] reads
346    /// it to (a) place a dirty column's colours at the resident offset
347    /// and (b) verify the column's colour COUNT is unchanged — a count
348    /// change reflows the packed colour block and forces the full-path
349    /// fallback. ~87 KB per 128² chunk; dropped on evict.
350    pub(crate) color_offsets_shadow: Vec<std::collections::HashMap<usize, Vec<u32>>>,
351}
352
353impl GpuSceneResident {
354    /// Pack + upload `info`. Each grid is uploaded as a contiguous
355    /// slab inside the shared storage buffers; per-grid offsets
356    /// live in `grid_static_meta`. The grid count is bounded only by
357    /// the device's storage-buffer limits (per-grid cameras + metadata
358    /// are runtime-sized storage arrays, not a fixed shader array).
359    pub fn upload(device: &wgpu::Device, info: &SceneUpload) -> Self {
360        let grid_count = info.grid_count();
361
362        let mut all_occupancy: Vec<u32> = Vec::new();
363        let mut all_color_offsets: Vec<u32> = Vec::new();
364        let mut all_colors: Vec<u32> = Vec::new();
365        let mut all_chunk_colors_base: Vec<u32> = Vec::new();
366        let mut all_chunk_occupancy: Vec<u32> = Vec::new();
367        let mut all_slot_chunk_idx: Vec<i32> = Vec::new();
368        let mut static_meta: Vec<GridStaticMeta> = Vec::with_capacity(info.grids.len());
369        let mut chunk_occupancy_shadow: Vec<Vec<u32>> = Vec::with_capacity(info.grids.len());
370        let mut chunk_occ_pyramid_shadow: Vec<Vec<Vec<u32>>> = Vec::with_capacity(info.grids.len());
371        let mut slot_chunk_idx_shadow: Vec<Vec<[i32; 4]>> = Vec::with_capacity(info.grids.len());
372        let mut color_offsets_shadow: Vec<std::collections::HashMap<usize, Vec<u32>>> =
373            Vec::with_capacity(info.grids.len());
374        // Per-grid colour stride (words/slot) — adaptive to the grid's
375        // densest chunk (see the in-loop derivation). `refresh_chunk`
376        // reads it back so streamed re-uploads use the same stride.
377        let mut grid_colors_strides: Vec<u32> = Vec::with_capacity(info.grids.len());
378
379        for grid in &info.grids {
380            let vsid = grid.vsid;
381            // GPU.11 — per-slot strides span the whole mip ladder.
382            let layout = MipLayout::for_vsid(vsid);
383            let occ_words_per_slot = layout.occ_words_per_slot as usize;
384            let offsets_words_per_slot = layout.offsets_words_per_slot as usize;
385            // Per-slot colour stride. The fixed-stride layout gives every
386            // slot — present or not — the same capacity, so streaming /
387            // re-bake can write a fresh chunk's colours into any slot.
388            // [`COLORS_PER_CHUNK_WORDS`] is sized for sparse terrain
389            // chunks (~36 k colours); a *fully dense* chunk (the cave
390            // demo's single 128×128×256 chunk carries ~207 k colours
391            // across its mip ladder) needs more, or its colours truncate
392            // and the chunk's high-`y` columns render black. Grow the
393            // stride to the grid's densest chunk, floored at the default
394            // so denser chunks that stream in later still fit the common
395            // case. Per-grid: a sparse grid keeps the small stride; only
396            // a grid that actually holds dense chunks pays for the
397            // bigger one.
398            let max_chunk_colors = grid
399                .chunks
400                .iter()
401                .map(|(_, c)| c.mips.iter().map(|m| m.colors.len()).sum::<usize>())
402                .max()
403                .unwrap_or(0);
404            let colors_stride = (COLORS_PER_CHUNK_WORDS as usize).max(max_chunk_colors);
405            grid_colors_strides.push(colors_stride as u32);
406
407            // Validate pool_dims are powers of 2 — required for the
408            // shader's `chunk_idx & (pool_dims - 1)` modular slot
409            // indexing.
410            assert!(
411                grid.pool_dims[0].is_power_of_two()
412                    && grid.pool_dims[1].is_power_of_two()
413                    && grid.pool_dims[2].is_power_of_two(),
414                "scene grid: pool_dims {:?} must all be powers of 2",
415                grid.pool_dims,
416            );
417            let pool_x = grid.pool_dims[0] as usize;
418            let pool_y = grid.pool_dims[1] as usize;
419            let pool_z = grid.pool_dims[2] as usize;
420            let total_slots = pool_x * pool_y * pool_z;
421
422            let mut grid_occupancy = vec![0u32; total_slots * occ_words_per_slot];
423            let mut grid_color_offsets = vec![0u32; total_slots * offsets_words_per_slot];
424            let mut grid_colors = vec![0u32; total_slots * colors_stride];
425            let mut grid_chunk_colors_base = vec![0u32; total_slots];
426            for i in 0..total_slots {
427                grid_chunk_colors_base[i] = (i * colors_stride) as u32;
428            }
429            let mut grid_chunk_occupancy = vec![0u32; total_slots.div_ceil(32)];
430            // slot_chunk_idx: vec3<i32> per slot, std430 stride = 16
431            // bytes (4 u32 words: x, y, z, _pad). Initialise every
432            // slot to the empty sentinel; populated slots overwrite
433            // with the actual chunk_idx below.
434            let mut grid_offsets_shadow: std::collections::HashMap<usize, Vec<u32>> =
435                std::collections::HashMap::new();
436            let mut grid_slot_chunk_idx: Vec<[i32; 4]> = Vec::with_capacity(total_slots);
437            for _ in 0..total_slots {
438                grid_slot_chunk_idx.push([
439                    SLOT_EMPTY_SENTINEL[0],
440                    SLOT_EMPTY_SENTINEL[1],
441                    SLOT_EMPTY_SENTINEL[2],
442                    0,
443                ]);
444            }
445
446            let mask_x = (grid.pool_dims[0] - 1) as i32;
447            let mask_y = (grid.pool_dims[1] - 1) as i32;
448            let mask_z = (grid.pool_dims[2] - 1) as i32;
449            let chunks_per_layer = pool_x * pool_y;
450
451            for (chunk_idx, chunk) in &grid.chunks {
452                assert_eq!(chunk.vsid, vsid, "scene grid: chunk vsid mismatch");
453                let sx = (chunk_idx[0] & mask_x) as usize;
454                let sy = (chunk_idx[1] & mask_y) as usize;
455                let sz = (chunk_idx[2] & mask_z) as usize;
456                let slot_idx = sx + sy * pool_x + sz * chunks_per_layer;
457
458                // GPU.11 — write each mip at its within-slot offset.
459                // occupancy + color_offsets land in per-mip sub-blocks
460                // (mip-0 first, so its data is byte-identical to the
461                // pre-mip layout); colours of every mip concatenate
462                // into the slot's fixed COLORS_PER_CHUNK_WORDS block in
463                // level order, indexed by each chunk's own absolute
464                // `color_offsets`.
465                let occ_start = slot_idx * occ_words_per_slot;
466                let off_start = slot_idx * offsets_words_per_slot;
467                let col_start = slot_idx * colors_stride;
468                let mut color_cursor = 0usize;
469                for (m, mip) in chunk.mips.iter().enumerate() {
470                    let occ_dst = occ_start + layout.mip_occ_rel[m] as usize;
471                    grid_occupancy[occ_dst..occ_dst + mip.occupancy.len()]
472                        .copy_from_slice(&mip.occupancy);
473                    // Solid bitmap immediately follows the textured one.
474                    let solid_dst = occ_dst + mip.occupancy.len();
475                    grid_occupancy[solid_dst..solid_dst + mip.solid_occupancy.len()]
476                        .copy_from_slice(&mip.solid_occupancy);
477                    let coff_dst = off_start + layout.mip_coff_rel[m] as usize;
478                    grid_color_offsets[coff_dst..coff_dst + mip.color_offsets.len()]
479                        .copy_from_slice(&mip.color_offsets);
480
481                    let remaining = colors_stride.saturating_sub(color_cursor);
482                    let n = mip.colors.len().min(remaining);
483                    if n < mip.colors.len() {
484                        eprintln!(
485                            "roxlap-gpu SceneUpload: scene grid chunk {chunk_idx:?} mip {m} \
486                             colours overflow COLORS_PER_CHUNK_WORDS ({colors_stride}); \
487                             truncating",
488                        );
489                    }
490                    grid_colors[col_start + color_cursor..col_start + color_cursor + n]
491                        .copy_from_slice(&mip.colors[..n]);
492                    color_cursor += n;
493                }
494
495                if !chunk.mips[0].colors.is_empty() {
496                    grid_chunk_occupancy[slot_idx >> 5] |= 1u32 << (slot_idx & 31);
497                }
498                grid_slot_chunk_idx[slot_idx] = [chunk_idx[0], chunk_idx[1], chunk_idx[2], 0];
499                // PF.12.c — mirror the slot's color_offsets window.
500                grid_offsets_shadow.insert(
501                    slot_idx,
502                    grid_color_offsets[off_start..off_start + offsets_words_per_slot].to_vec(),
503                );
504            }
505
506            // Slot_chunk_idx storage offset: each entry is 4 u32
507            // words (vec3 padded to 16 bytes in std430).
508            let slot_chunk_idx_offset = u32::try_from(all_slot_chunk_idx.len()).expect("fits");
509            // GPU.13.0 — occupied chunk-AABB for the outer-DDA early-out.
510            let (aabb_min, aabb_max) = aabb_of_slots(&grid_slot_chunk_idx);
511            // GPU.13.1 — chunk-occupancy pyramid: levels 1.. appended
512            // right after this grid's L0 words, offsets recorded per
513            // level (word offsets are into `all_chunk_occupancy`).
514            let chunk_occupancy_offset = u32::try_from(all_chunk_occupancy.len()).expect("fits");
515            let pyramid = build_occ_pyramid(grid.pool_dims, &grid_chunk_occupancy);
516            let mut chunk_occ_mip_off = [0u32; 4];
517            {
518                let mut cursor = all_chunk_occupancy.len() + grid_chunk_occupancy.len();
519                for (i, level) in pyramid.iter().enumerate() {
520                    chunk_occ_mip_off[i] = u32::try_from(cursor).expect("fits");
521                    cursor += level.len();
522                }
523            }
524            let meta = GridStaticMeta {
525                occupancy_offset: u32::try_from(all_occupancy.len()).expect("fits"),
526                color_offsets_offset: u32::try_from(all_color_offsets.len()).expect("fits"),
527                colors_offset: u32::try_from(all_colors.len()).expect("fits"),
528                chunk_colors_base_offset: u32::try_from(all_chunk_colors_base.len()).expect("fits"),
529                chunk_occupancy_offset,
530                slot_chunk_idx_offset,
531                vsid,
532                total_slots: total_slots as u32,
533                pool_dims: grid.pool_dims,
534                _pad0: 0,
535                occ_words_per_slot: layout.occ_words_per_slot,
536                offsets_words_per_slot: layout.offsets_words_per_slot,
537                mip_count: layout.mip_count,
538                _pad1: 0,
539                mip_occ_rel: layout.mip_occ_rel,
540                mip_coff_rel: layout.mip_coff_rel,
541                aabb_min,
542                _pad2: 0,
543                aabb_max,
544                _pad3: 0,
545                chunk_occ_mip_off,
546                chunk_occ_levels: u32::try_from(pyramid.len()).expect("small"),
547                _pad4: [0; 3],
548            };
549
550            chunk_occupancy_shadow.push(grid_chunk_occupancy.clone());
551            chunk_occ_pyramid_shadow.push(pyramid.clone());
552            slot_chunk_idx_shadow.push(grid_slot_chunk_idx.clone());
553            color_offsets_shadow.push(grid_offsets_shadow);
554
555            all_occupancy.extend_from_slice(&grid_occupancy);
556            all_color_offsets.extend_from_slice(&grid_color_offsets);
557            all_colors.extend_from_slice(&grid_colors);
558            all_chunk_colors_base.extend_from_slice(&grid_chunk_colors_base);
559            all_chunk_occupancy.extend_from_slice(&grid_chunk_occupancy);
560            for level in &pyramid {
561                all_chunk_occupancy.extend_from_slice(level);
562            }
563            for entry in &grid_slot_chunk_idx {
564                all_slot_chunk_idx.extend_from_slice(entry);
565            }
566            static_meta.push(meta);
567        }
568
569        // Pad an empty scene's storage buffers — wgpu rejects
570        // zero-size storage bindings.
571        if all_occupancy.is_empty() {
572            all_occupancy.push(0);
573        }
574        if all_color_offsets.is_empty() {
575            all_color_offsets.push(0);
576        }
577        if all_colors.is_empty() {
578            all_colors.push(0);
579        }
580        if all_chunk_colors_base.is_empty() {
581            all_chunk_colors_base.push(0);
582        }
583        if all_chunk_occupancy.is_empty() {
584            all_chunk_occupancy.push(0);
585        }
586        if all_slot_chunk_idx.is_empty() {
587            // 4 zeros = single padded vec3<i32>. wgpu rejects
588            // zero-sized storage bindings.
589            all_slot_chunk_idx.extend_from_slice(&[0; 4]);
590        }
591        if static_meta.is_empty() {
592            static_meta.push(GridStaticMeta::zeroed());
593        }
594
595        let occupancy_bytes = (all_occupancy.len() * 4) as u64;
596        let color_offsets_bytes = (all_color_offsets.len() * 4) as u64;
597        let colors_bytes = (all_colors.len() * 4) as u64;
598        let chunk_colors_base_bytes = (all_chunk_colors_base.len() * 4) as u64;
599        let chunk_occupancy_bytes = (all_chunk_occupancy.len() * 4) as u64;
600        let slot_chunk_idx_bytes = (all_slot_chunk_idx.len() * 4) as u64;
601        let static_meta_bytes = (static_meta.len() * std::mem::size_of::<GridStaticMeta>()) as u64;
602        let total_bytes = occupancy_bytes
603            + color_offsets_bytes
604            + colors_bytes
605            + chunk_colors_base_bytes
606            + chunk_occupancy_bytes
607            + slot_chunk_idx_bytes
608            + static_meta_bytes;
609
610        // Split the concatenated occupancy across storage pages so no
611        // single binding exceeds the device limit. Page size is a
612        // whole number of chunk slots (slot-aligned) so no per-slot
613        // refresh write ever straddles two pages.
614        // GPU.11 — page alignment is now the whole-ladder per-slot
615        // occupancy stride so a slot (all its mips) never straddles a
616        // page boundary.
617        let slot_align_words = info
618            .grids
619            .iter()
620            .map(|g| u64::from(MipLayout::for_vsid(g.vsid).occ_words_per_slot))
621            .max()
622            .unwrap_or(1)
623            .max(1);
624        let (occupancy_pages, occupancy_page_words, occupancy_num_pages) =
625            split_occupancy_pages(device, &all_occupancy, slot_align_words);
626        let all_color_offsets =
627            create_storage(device, "roxlap-gpu scene.color_offsets", &all_color_offsets);
628        let all_colors = create_storage(device, "roxlap-gpu scene.colors", &all_colors);
629        let all_chunk_colors_base = create_storage(
630            device,
631            "roxlap-gpu scene.chunk_colors_base",
632            &all_chunk_colors_base,
633        );
634        let all_chunk_occupancy = create_storage(
635            device,
636            "roxlap-gpu scene.chunk_occupancy",
637            &all_chunk_occupancy,
638        );
639        // GPU.7 slot identity verification buffer. i32 storage.
640        let all_slot_chunk_idx_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
641            label: Some("roxlap-gpu scene.slot_chunk_idx"),
642            contents: bytemuck::cast_slice(&all_slot_chunk_idx),
643            usage: wgpu::BufferUsages::STORAGE
644                | wgpu::BufferUsages::COPY_DST
645                | wgpu::BufferUsages::COPY_SRC,
646        });
647        let grid_static_meta = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
648            label: Some("roxlap-gpu scene.grid_static_meta"),
649            contents: bytemuck::cast_slice(&static_meta),
650            // GPU.13.0 — COPY_DST so the live chunk-AABB can be patched
651            // into a grid's meta on refresh_chunk / evict_chunk.
652            usage: wgpu::BufferUsages::STORAGE
653                | wgpu::BufferUsages::COPY_DST
654                | wgpu::BufferUsages::COPY_SRC,
655        });
656
657        Self {
658            grid_count,
659            occupancy_pages,
660            occupancy_page_words,
661            occupancy_num_pages,
662            all_color_offsets,
663            all_colors,
664            all_chunk_colors_base,
665            all_chunk_occupancy,
666            all_slot_chunk_idx: all_slot_chunk_idx_buf,
667            grid_static_meta,
668            total_bytes,
669            static_meta,
670            chunk_occupancy_shadow,
671            chunk_occ_pyramid_shadow,
672            slot_chunk_idx_shadow,
673            color_offsets_shadow,
674            colors_stride_shadow: grid_colors_strides,
675        }
676    }
677
678    /// GPU memory held by the scene's storage buffers, in bytes —
679    /// [`Self::total_bytes`] as computed at upload time (in-place
680    /// refreshes never change it).
681    pub fn resident_bytes(&self) -> u64 {
682        self.total_bytes
683    }
684
685    /// Install or refresh a chunk in its modular pool slot. GPU.7
686    /// generalises GPU.6's in-place refresh: any chunk_idx maps to
687    /// a slot via `chunk_idx & (pool_dims - 1)`. The previous
688    /// occupant (if a different chunk) is silently replaced — the
689    /// host is responsible for guaranteeing that the pool is sized
690    /// large enough that two simultaneously-resident chunks never
691    /// collide on the same slot.
692    pub fn refresh_chunk(
693        &mut self,
694        queue: &wgpu::Queue,
695        scene_idx: usize,
696        chunk_idx: [i32; 3],
697        chunk: &ChunkUpload,
698    ) -> RefreshOutcome {
699        let Some(meta) = self.static_meta.get(scene_idx).copied() else {
700            return RefreshOutcome::SceneIdxOob;
701        };
702        let slot_idx = modular_slot_idx(chunk_idx, meta.pool_dims);
703
704        // GPU.11 — the per-slot strides span the full mip ladder; the
705        // resident's layout was built from the same `MipLayout`.
706        let layout = MipLayout::for_vsid(meta.vsid);
707        let occ_words_per_slot = layout.occ_words_per_slot as usize;
708        let offsets_words_per_slot = layout.offsets_words_per_slot as usize;
709        // Same adaptive stride the initial upload chose for this grid.
710        let colors_stride = self
711            .colors_stride_shadow
712            .get(scene_idx)
713            .map_or(COLORS_PER_CHUNK_WORDS as usize, |&s| s as usize);
714
715        assert_eq!(
716            chunk.mips.len() as u32,
717            layout.mip_count,
718            "refresh_chunk: mip count mismatch (chunk {} vs grid {})",
719            chunk.mips.len(),
720            layout.mip_count,
721        );
722
723        // ---- occupancy ----
724        // Route each mip's write to its page. Page size is slot-
725        // aligned (see `split_occupancy_pages`) so the whole slot's
726        // occupancy ladder lands in a single page.
727        let slot_occ_base = meta.occupancy_offset as usize + slot_idx * occ_words_per_slot;
728        let page_words = self.occupancy_page_words as usize;
729        let page = slot_occ_base / page_words;
730        let slot_local_word = slot_occ_base % page_words;
731        debug_assert!(
732            slot_local_word + occ_words_per_slot <= page_words,
733            "occupancy slot straddles a page boundary — page size not slot-aligned",
734        );
735        let off_slot_base = meta.color_offsets_offset as usize + slot_idx * offsets_words_per_slot;
736        let col_slot_base = meta.colors_offset as usize + slot_idx * colors_stride;
737
738        let mut outcome = RefreshOutcome::Ok;
739        let mut color_cursor = 0usize;
740        for (m, mip) in chunk.mips.iter().enumerate() {
741            // occupancy (textured) then solid, back-to-back.
742            let local = slot_local_word + layout.mip_occ_rel[m] as usize;
743            queue.write_buffer(
744                &self.occupancy_pages[page],
745                (local * 4) as u64,
746                bytemuck::cast_slice(&mip.occupancy),
747            );
748            queue.write_buffer(
749                &self.occupancy_pages[page],
750                ((local + mip.occupancy.len()) * 4) as u64,
751                bytemuck::cast_slice(&mip.solid_occupancy),
752            );
753            // color_offsets
754            let coff = off_slot_base + layout.mip_coff_rel[m] as usize;
755            queue.write_buffer(
756                &self.all_color_offsets,
757                (coff * 4) as u64,
758                bytemuck::cast_slice(&mip.color_offsets),
759            );
760            // colours (concatenated per slot, truncate to stride)
761            let remaining = colors_stride.saturating_sub(color_cursor);
762            let n = mip.colors.len().min(remaining);
763            if n < mip.colors.len() {
764                eprintln!(
765                    "roxlap-gpu refresh_chunk: scene_idx={scene_idx} chunk_idx={chunk_idx:?} \
766                     mip {m} colours overflow stride {colors_stride}; truncating",
767                );
768                outcome = RefreshOutcome::ColorsTruncated;
769            }
770            if n > 0 {
771                queue.write_buffer(
772                    &self.all_colors,
773                    ((col_slot_base + color_cursor) * 4) as u64,
774                    bytemuck::cast_slice(&mip.colors[..n]),
775                );
776            }
777            color_cursor += n;
778        }
779
780        // ---- chunk_occupancy bit ----
781        self.set_chunk_occupancy_bit(
782            queue,
783            scene_idx,
784            &meta,
785            slot_idx,
786            !chunk.mips[0].colors.is_empty(),
787        );
788
789        // ---- slot_chunk_idx (identity for the shader) ----
790        self.set_slot_chunk_idx(queue, scene_idx, &meta, slot_idx, chunk_idx);
791
792        // ---- PF.12.c — mirror the slot's color_offsets window ----
793        // (`refresh_chunk_partial` verifies counts + places colours
794        // against it). Rebuilt exactly as the GPU windows were written.
795        let mut window = vec![0u32; offsets_words_per_slot];
796        for (m, mip) in chunk.mips.iter().enumerate() {
797            let coff = layout.mip_coff_rel[m] as usize;
798            window[coff..coff + mip.color_offsets.len()].copy_from_slice(&mip.color_offsets);
799        }
800        self.color_offsets_shadow[scene_idx].insert(slot_idx, window);
801
802        // ---- GPU.13.0 grid-AABB early-out box ----
803        self.sync_aabb(queue, scene_idx);
804
805        outcome
806    }
807
808    /// Evict a chunk's slot — clear its `chunk_occupancy` bit and
809    /// reset `slot_chunk_idx` to the empty sentinel. Used by the
810    /// host when a chunk disappears from the CPU-side `Grid::chunks`
811    /// (e.g. streaming eviction past `r_evict`).
812    ///
813    /// Returns `false` if `scene_idx` is past `grid_count` (no-op);
814    /// `true` otherwise.
815    /// PF.12.c — partial refresh: re-derive + re-upload ONLY the columns
816    /// inside the inclusive chunk-local mip-0 column rect `[x0..=x1] ×
817    /// [y0..=y1]` (pre-padded by the caller with the edit's ±1 adjacency
818    /// reach), for every mip. Requires the slot to already hold
819    /// `chunk_idx` with a mirrored offsets table, and every dirty
820    /// column's colour COUNT to be unchanged (a count change reflows the
821    /// packed colour block). Returns `false` — with **nothing written**
822    /// — when any precondition fails; the caller falls back to the full
823    /// [`Self::refresh_chunk`] path.
824    ///
825    /// The count-stable case is the streaming bake tracker's per-frame
826    /// path (brightness-byte rewrites) and recolour edits: those now
827    /// upload a few KB instead of decompressing + rewriting the whole
828    /// ~1–2 MB chunk ladder.
829    #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
830    pub fn refresh_chunk_partial(
831        &mut self,
832        queue: &wgpu::Queue,
833        scene_idx: usize,
834        chunk_idx: [i32; 3],
835        vxl: &roxlap_formats::vxl::Vxl,
836        x0: i32,
837        y0: i32,
838        x1: i32,
839        y1: i32,
840    ) -> bool {
841        let Some(meta) = self.static_meta.get(scene_idx).copied() else {
842            return false;
843        };
844        let layout = MipLayout::for_vsid(meta.vsid);
845        if vxl.mip_count() < layout.mip_count {
846            return false;
847        }
848        let slot_idx = modular_slot_idx(chunk_idx, meta.pool_dims);
849        // The slot must currently hold THIS chunk (modular pools reuse
850        // slots; a partial write over another chunk's data = garbage).
851        let held = self.slot_chunk_idx_shadow[scene_idx][slot_idx];
852        if held[0] != chunk_idx[0] || held[1] != chunk_idx[1] || held[2] != chunk_idx[2] {
853            return false;
854        }
855        let Some(offs_shadow) = self.color_offsets_shadow[scene_idx].get(&slot_idx) else {
856            return false;
857        };
858        let colors_stride = self
859            .colors_stride_shadow
860            .get(scene_idx)
861            .map_or(COLORS_PER_CHUNK_WORDS as usize, |&s| s as usize);
862
863        // Phase 1 — recompute every dirty column per mip into row-run
864        // buffers (rows are contiguous in both the occupancy layout and
865        // the packed colour block), verifying colour counts. NOTHING is
866        // written until the whole extent verifies.
867        struct RowRun {
868            /// Textured-occupancy word offset within the slot.
869            occ_word: usize,
870            /// Solid block sits `block_words` after the textured one.
871            block_words: usize,
872            occ: Vec<u32>,
873            solid: Vec<u32>,
874            /// Colour word offset within the slot's colour block.
875            color_word: usize,
876            colors: Vec<u32>,
877        }
878        let mut runs: Vec<RowRun> = Vec::new();
879        for m in 0..layout.mip_count {
880            let vsid_m = (meta.vsid >> m).max(1) as i32;
881            let cz_m = crate::decompress::CHUNK_Z >> m;
882            let wpc = occ_words_per_column_for_mip(m) as usize;
883            let block_words = (vsid_m as usize) * (vsid_m as usize) * wpc;
884            let rx0 = (x0 >> m).clamp(0, vsid_m - 1);
885            let ry0 = (y0 >> m).clamp(0, vsid_m - 1);
886            let rx1 = (x1 >> m).clamp(0, vsid_m - 1);
887            let ry1 = (y1 >> m).clamp(0, vsid_m - 1);
888            let coff_base = layout.mip_coff_rel[m as usize] as usize;
889            for y in ry0..=ry1 {
890                let row_col0 = (y * vsid_m + rx0) as usize;
891                let n_cols = (rx1 - rx0 + 1) as usize;
892                let mut occ = vec![0u32; n_cols * wpc];
893                let mut solid = vec![0u32; n_cols * wpc];
894                let mut colors: Vec<u32> = Vec::new();
895                for i in 0..n_cols {
896                    let col_idx = row_col0 + i;
897                    let slab = vxl.column_data_for_mip(m, col_idx);
898                    let before = colors.len();
899                    // vsid=1 / (0,0) → the column scratch windows index
900                    // from word 0 of the per-column slices.
901                    crate::decompress::decompress_column(
902                        slab,
903                        0,
904                        0,
905                        1,
906                        cz_m,
907                        wpc as u32,
908                        &mut occ[i * wpc..(i + 1) * wpc],
909                        &mut solid[i * wpc..(i + 1) * wpc],
910                        &mut colors,
911                    );
912                    // Count stability vs the mirrored offsets table.
913                    let old_count = offs_shadow[coff_base + col_idx + 1]
914                        .saturating_sub(offs_shadow[coff_base + col_idx])
915                        as usize;
916                    if colors.len() - before != old_count {
917                        return false; // reflow → full path
918                    }
919                }
920                let color_word = offs_shadow[coff_base + row_col0] as usize;
921                if color_word + colors.len() > colors_stride {
922                    return false; // stride overflow → full path handles
923                }
924                runs.push(RowRun {
925                    occ_word: layout.mip_occ_rel[m as usize] as usize + row_col0 * wpc,
926                    block_words,
927                    occ,
928                    solid,
929                    color_word,
930                    colors,
931                });
932            }
933        }
934
935        // Phase 2 — verified: write the row runs.
936        let occ_words_per_slot = layout.occ_words_per_slot as usize;
937        let slot_occ_base = meta.occupancy_offset as usize + slot_idx * occ_words_per_slot;
938        let page_words = self.occupancy_page_words as usize;
939        let page = slot_occ_base / page_words;
940        let slot_local_word = slot_occ_base % page_words;
941        let col_slot_base = meta.colors_offset as usize + slot_idx * colors_stride;
942        for run in &runs {
943            let tex = slot_local_word + run.occ_word;
944            queue.write_buffer(
945                &self.occupancy_pages[page],
946                (tex * 4) as u64,
947                bytemuck::cast_slice(&run.occ),
948            );
949            queue.write_buffer(
950                &self.occupancy_pages[page],
951                ((tex + run.block_words) * 4) as u64,
952                bytemuck::cast_slice(&run.solid),
953            );
954            if !run.colors.is_empty() {
955                queue.write_buffer(
956                    &self.all_colors,
957                    ((col_slot_base + run.color_word) * 4) as u64,
958                    bytemuck::cast_slice(&run.colors),
959                );
960            }
961        }
962        // Counts unchanged ⇒ offsets, chunk-occupancy bit, AABB and the
963        // mirrors all stay valid untouched.
964        true
965    }
966
967    /// Evict `chunk_idx` from grid `scene_idx`: clear the slot's
968    /// chunk-occupancy bit, stamp [`SLOT_EMPTY_SENTINEL`] into its
969    /// `slot_chunk_idx` entry, and shrink the grid's chunk-AABB if the
970    /// box tightened. The bulk voxel data is left in place — the
971    /// cleared occupancy bit + sentinel already make the shader treat
972    /// the slot as empty. A no-op (returning `true`) when the slot
973    /// meanwhile holds a *different* chunk, so a stale evict can never
974    /// wipe a newer occupant. Returns `false` only for an out-of-range
975    /// `scene_idx`.
976    pub fn evict_chunk(
977        &mut self,
978        queue: &wgpu::Queue,
979        scene_idx: usize,
980        chunk_idx: [i32; 3],
981    ) -> bool {
982        let Some(meta) = self.static_meta.get(scene_idx).copied() else {
983            return false;
984        };
985        let slot_idx = modular_slot_idx(chunk_idx, meta.pool_dims);
986        // Only evict if this slot still claims to hold `chunk_idx`.
987        // Otherwise we'd be wiping out a different (newer) chunk
988        // that happens to share the slot.
989        let shadow_entry = self.slot_chunk_idx_shadow[scene_idx][slot_idx];
990        if shadow_entry[0] != chunk_idx[0]
991            || shadow_entry[1] != chunk_idx[1]
992            || shadow_entry[2] != chunk_idx[2]
993        {
994            return true;
995        }
996        self.set_chunk_occupancy_bit(queue, scene_idx, &meta, slot_idx, false);
997        self.set_slot_chunk_idx(queue, scene_idx, &meta, slot_idx, SLOT_EMPTY_SENTINEL);
998        // PF.12.c — drop the evicted slot's offsets mirror.
999        self.color_offsets_shadow[scene_idx].remove(&slot_idx);
1000        // GPU.13.0 — eviction may shrink the occupied box; recompute.
1001        self.sync_aabb(queue, scene_idx);
1002        true
1003    }
1004
1005    fn set_chunk_occupancy_bit(
1006        &mut self,
1007        queue: &wgpu::Queue,
1008        scene_idx: usize,
1009        meta: &GridStaticMeta,
1010        slot_idx: usize,
1011        new_bit: bool,
1012    ) {
1013        let word_idx = slot_idx >> 5;
1014        let bit = slot_idx & 31;
1015        let shadow = &mut self.chunk_occupancy_shadow[scene_idx][word_idx];
1016        let was_bit = (*shadow >> bit) & 1 == 1;
1017        if new_bit == was_bit {
1018            return;
1019        }
1020        if new_bit {
1021            *shadow |= 1u32 << bit;
1022        } else {
1023            *shadow &= !(1u32 << bit);
1024        }
1025        let global_word_idx = meta.chunk_occupancy_offset as usize + word_idx;
1026        queue.write_buffer(
1027            &self.all_chunk_occupancy,
1028            (global_word_idx * 4) as u64,
1029            bytemuck::bytes_of(shadow),
1030        );
1031
1032        // GPU.13.1 — re-OR the pyramid ancestors of the touched slot.
1033        // Setting a bit can only turn ancestors ON; clearing one can
1034        // only turn them OFF — either way, an unchanged ancestor means
1035        // every level above it is unchanged too, so stop early.
1036        let pool = meta.pool_dims;
1037        let slot = [
1038            (slot_idx as u32) % pool[0],
1039            ((slot_idx as u32) / pool[0]) % pool[1],
1040            (slot_idx as u32) / (pool[0] * pool[1]),
1041        ];
1042        for l in 1..=meta.chunk_occ_levels {
1043            let cell = [slot[0] >> l, slot[1] >> l, slot[2] >> l];
1044            let bit = {
1045                let child: &[u32] = if l == 1 {
1046                    &self.chunk_occupancy_shadow[scene_idx]
1047                } else {
1048                    &self.chunk_occ_pyramid_shadow[scene_idx][(l - 2) as usize]
1049                };
1050                occ_cell_from_children(pool, l, cell, child)
1051            };
1052            let d = occ_level_dims(pool, l);
1053            let idx = (cell[0] + cell[1] * d[0] + cell[2] * d[0] * d[1]) as usize;
1054            let word = &mut self.chunk_occ_pyramid_shadow[scene_idx][(l - 1) as usize][idx >> 5];
1055            let was = (*word >> (idx & 31)) & 1 == 1;
1056            if bit == was {
1057                break;
1058            }
1059            if bit {
1060                *word |= 1u32 << (idx & 31);
1061            } else {
1062                *word &= !(1u32 << (idx & 31));
1063            }
1064            let global = meta.chunk_occ_mip_off[(l - 1) as usize] as usize + (idx >> 5);
1065            let word_copy = *word;
1066            queue.write_buffer(
1067                &self.all_chunk_occupancy,
1068                (global * 4) as u64,
1069                bytemuck::bytes_of(&word_copy),
1070            );
1071        }
1072    }
1073
1074    /// Read-only view of the chunk-occupancy pyramid shadows (per
1075    /// grid, per level above L0) — the CPU mirror the incremental
1076    /// maintenance updates; exposed for integration tests to assert
1077    /// the re-OR bookkeeping (rendering itself only reads the GPU
1078    /// copy).
1079    #[must_use]
1080    pub fn chunk_occ_pyramid_shadow(&self) -> &[Vec<Vec<u32>>] {
1081        &self.chunk_occ_pyramid_shadow
1082    }
1083
1084    fn set_slot_chunk_idx(
1085        &mut self,
1086        queue: &wgpu::Queue,
1087        scene_idx: usize,
1088        meta: &GridStaticMeta,
1089        slot_idx: usize,
1090        chunk_idx: [i32; 3],
1091    ) {
1092        let entry = [chunk_idx[0], chunk_idx[1], chunk_idx[2], 0];
1093        self.slot_chunk_idx_shadow[scene_idx][slot_idx] = entry;
1094        let global_word_idx = meta.slot_chunk_idx_offset as usize + slot_idx * 4;
1095        queue.write_buffer(
1096            &self.all_slot_chunk_idx,
1097            (global_word_idx * 4) as u64,
1098            bytemuck::cast_slice(&entry),
1099        );
1100    }
1101
1102    /// GPU.13.0 — recompute the grid's occupied chunk-AABB from its
1103    /// `slot_chunk_idx` shadow and, if it changed, patch the grid's
1104    /// [`GridStaticMeta`] on the GPU. Cheap: scans `total_slots`
1105    /// entries and writes 144 bytes only when the box actually moves
1106    /// (steady-state re-bakes leave it unchanged → no GPU write).
1107    /// Called after every install/eviction so streaming grids keep a
1108    /// tight, always-conservative early-out box.
1109    fn sync_aabb(&mut self, queue: &wgpu::Queue, scene_idx: usize) {
1110        let (aabb_min, aabb_max) = aabb_of_slots(&self.slot_chunk_idx_shadow[scene_idx]);
1111        let meta = &mut self.static_meta[scene_idx];
1112        if meta.aabb_min == aabb_min && meta.aabb_max == aabb_max {
1113            return;
1114        }
1115        meta.aabb_min = aabb_min;
1116        meta.aabb_max = aabb_max;
1117        let off = (scene_idx * std::mem::size_of::<GridStaticMeta>()) as u64;
1118        queue.write_buffer(&self.grid_static_meta, off, bytemuck::bytes_of(meta));
1119    }
1120}
1121
1122/// GPU.13.0 — inclusive chunk-AABB over a grid's `slot_chunk_idx`
1123/// shadow, skipping the [`SLOT_EMPTY_SENTINEL`] entries. Returns the
1124/// inverted sentinel box (`min = i32::MAX`, `max = i32::MIN`) when no
1125/// slot is occupied, which makes the shader's `aabb_passed` early-out
1126/// fire for every ray (an empty grid renders nothing).
1127/// GPU.13.1 — cells per axis of chunk-occupancy pyramid level `l`
1128/// (`l >= 1`): the pool box halves per level, floored at 1.
1129fn occ_level_dims(pool: [u32; 3], l: u32) -> [u32; 3] {
1130    [
1131        (pool[0] >> l).max(1),
1132        (pool[1] >> l).max(1),
1133        (pool[2] >> l).max(1),
1134    ]
1135}
1136
1137/// u32 words holding level `l`'s bits.
1138fn occ_level_words(pool: [u32; 3], l: u32) -> usize {
1139    let d = occ_level_dims(pool, l);
1140    (d[0] * d[1] * d[2]).div_ceil(32) as usize
1141}
1142
1143/// Pyramid levels above L0: enough that the largest pool axis
1144/// reaches one cell, capped by the meta's 4 offset slots.
1145fn occ_pyramid_levels(pool: [u32; 3]) -> u32 {
1146    let max_dim = pool[0].max(pool[1]).max(pool[2]).max(1);
1147    max_dim.ilog2().min(4)
1148}
1149
1150/// One level-`l` cell's bit, recomputed as the OR of its (up to
1151/// 2×2×2) children in level `l - 1` (level 0 = the per-slot bitmap).
1152fn occ_cell_from_children(pool: [u32; 3], l: u32, cell: [u32; 3], child_words: &[u32]) -> bool {
1153    let cd = if l == 1 {
1154        pool
1155    } else {
1156        occ_level_dims(pool, l - 1)
1157    };
1158    for dz in 0..2u32 {
1159        for dy in 0..2u32 {
1160            for dx in 0..2u32 {
1161                let c = [cell[0] * 2 + dx, cell[1] * 2 + dy, cell[2] * 2 + dz];
1162                if c[0] >= cd[0] || c[1] >= cd[1] || c[2] >= cd[2] {
1163                    continue;
1164                }
1165                let idx = (c[0] + c[1] * cd[0] + c[2] * cd[0] * cd[1]) as usize;
1166                if (child_words[idx >> 5] >> (idx & 31)) & 1 == 1 {
1167                    return true;
1168                }
1169            }
1170        }
1171    }
1172    false
1173}
1174
1175/// Build the whole pyramid (levels 1..=`occ_pyramid_levels`) from the
1176/// L0 per-slot bitmap. Returns one word-vec per level.
1177fn build_occ_pyramid(pool: [u32; 3], l0: &[u32]) -> Vec<Vec<u32>> {
1178    let levels = occ_pyramid_levels(pool);
1179    let mut out: Vec<Vec<u32>> = Vec::with_capacity(levels as usize);
1180    for l in 1..=levels {
1181        let d = occ_level_dims(pool, l);
1182        let child: &[u32] = if l == 1 { l0 } else { &out[(l - 2) as usize] };
1183        let mut words = vec![0u32; occ_level_words(pool, l)];
1184        for z in 0..d[2] {
1185            for y in 0..d[1] {
1186                for x in 0..d[0] {
1187                    if occ_cell_from_children(pool, l, [x, y, z], child) {
1188                        let idx = (x + y * d[0] + z * d[0] * d[1]) as usize;
1189                        words[idx >> 5] |= 1 << (idx & 31);
1190                    }
1191                }
1192            }
1193        }
1194        out.push(words);
1195    }
1196    out
1197}
1198
1199fn aabb_of_slots(slots: &[[i32; 4]]) -> ([i32; 3], [i32; 3]) {
1200    let mut min = [i32::MAX; 3];
1201    let mut max = [i32::MIN; 3];
1202    for e in slots {
1203        if e[0] == SLOT_EMPTY_SENTINEL[0]
1204            && e[1] == SLOT_EMPTY_SENTINEL[1]
1205            && e[2] == SLOT_EMPTY_SENTINEL[2]
1206        {
1207            continue;
1208        }
1209        for k in 0..3 {
1210            if e[k] < min[k] {
1211                min[k] = e[k];
1212            }
1213            if e[k] > max[k] {
1214                max[k] = e[k];
1215            }
1216        }
1217    }
1218    (min, max)
1219}
1220
1221/// Modular slot index for `chunk_idx` given the grid's
1222/// power-of-2 `pool_dims`. Negative `chunk_idx` components map via
1223/// two's-complement bitwise AND, matching the shader's
1224/// `chunk_idx & (pool_dims - 1)`.
1225#[must_use]
1226pub fn modular_slot_idx(chunk_idx: [i32; 3], pool_dims: [u32; 3]) -> usize {
1227    let mask_x = (pool_dims[0] - 1) as i32;
1228    let mask_y = (pool_dims[1] - 1) as i32;
1229    let mask_z = (pool_dims[2] - 1) as i32;
1230    let sx = (chunk_idx[0] & mask_x) as usize;
1231    let sy = (chunk_idx[1] & mask_y) as usize;
1232    let sz = (chunk_idx[2] & mask_z) as usize;
1233    sx + sy * (pool_dims[0] as usize) + sz * (pool_dims[0] as usize) * (pool_dims[1] as usize)
1234}
1235
1236/// Outcome of `GpuSceneResident::refresh_chunk`. Most callers
1237/// can ignore the result; `ColorsTruncated` indicates the chunk's
1238/// colour data overflowed the per-slot stride and was clipped.
1239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1240pub enum RefreshOutcome {
1241    /// The chunk was installed/refreshed in full — every mip's
1242    /// occupancy, offsets, and colours are resident.
1243    Ok,
1244    /// The chunk's colour count exceeded `COLORS_PER_CHUNK_WORDS`;
1245    /// the GPU sees the first `stride` colours. Bump
1246    /// `COLORS_PER_CHUNK_WORDS` for content that hits this.
1247    ColorsTruncated,
1248    /// Retained for ABI compatibility; the GPU.7 modular pool no
1249    /// longer rejects chunks by bbox.
1250    ChunkOutOfBbox,
1251    /// `scene_idx` is past `grid_count`. Programming error.
1252    SceneIdxOob,
1253}
1254
1255fn create_storage(device: &wgpu::Device, label: &str, data: &[u32]) -> wgpu::Buffer {
1256    // GPU.6: include COPY_DST so `refresh_chunk` can `queue.write_buffer`
1257    // into existing slots without rebuilding the resident.
1258    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1259        label: Some(label),
1260        contents: bytemuck::cast_slice(data),
1261        usage: wgpu::BufferUsages::STORAGE
1262            | wgpu::BufferUsages::COPY_DST
1263            | wgpu::BufferUsages::COPY_SRC,
1264    })
1265}
1266
1267/// Split the concatenated occupancy words into up to
1268/// [`MAX_OCC_PAGES`] storage buffers, each no larger than the
1269/// device's `max_storage_buffer_binding_size`, then pad the page
1270/// list with 1-word dummy buffers so the returned vec is always
1271/// exactly `MAX_OCC_PAGES` long (one buffer per bind-group entry).
1272///
1273/// `slot_align_words` is the per-slot occupancy stride: page size is
1274/// rounded down to a multiple of it so no chunk slot — and therefore
1275/// no per-slot `refresh_chunk` write — straddles a page boundary.
1276/// Returns `(pages, page_words, num_pages)`.
1277fn split_occupancy_pages(
1278    device: &wgpu::Device,
1279    words: &[u32],
1280    slot_align_words: u64,
1281) -> (Vec<wgpu::Buffer>, u32, u32) {
1282    let total_words = words.len() as u64;
1283    // wgpu 29 widened `max_storage_buffer_binding_size` to `u64`.
1284    let limit_words = device.limits().max_storage_buffer_binding_size / 4;
1285    // Largest slot-aligned page that fits one binding (≥ 1 slot).
1286    let page_slots = (limit_words / slot_align_words).max(1);
1287    let mut page_words = page_slots.saturating_mul(slot_align_words);
1288    // A tiny scene (or the empty-scene 1-word pad) isn't slot-aligned;
1289    // cap the page at the data length so we don't allocate emptiness.
1290    page_words = page_words.min(total_words.max(1));
1291    let num_pages = total_words.div_ceil(page_words);
1292    assert!(
1293        num_pages as usize <= MAX_OCC_PAGES,
1294        "occupancy needs {num_pages} pages (>{MAX_OCC_PAGES}) at this device's \
1295         {limit_words}-word binding limit; shrink the streaming pool or raise MAX_OCC_PAGES",
1296    );
1297
1298    let mut pages: Vec<wgpu::Buffer> = Vec::with_capacity(MAX_OCC_PAGES);
1299    let page_words_usize = page_words as usize;
1300    for p in 0..num_pages as usize {
1301        let start = p * page_words_usize;
1302        let end = ((p + 1) * page_words_usize).min(words.len());
1303        pages.push(create_storage(
1304            device,
1305            &format!("roxlap-gpu scene.occupancy.page{p}"),
1306            &words[start..end],
1307        ));
1308    }
1309    // Dummy 1-word buffers for the unused bindings.
1310    while pages.len() < MAX_OCC_PAGES {
1311        pages.push(create_storage(
1312            device,
1313            "roxlap-gpu scene.occupancy.page_dummy",
1314            &[0u32],
1315        ));
1316    }
1317    (
1318        pages,
1319        u32::try_from(page_words).expect("page_words fits u32"),
1320        num_pages as u32,
1321    )
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326    use super::*;
1327
1328    #[test]
1329    fn grid_static_meta_matches_wgsl_std430_size() {
1330        // scene_dda.wgsl's GridStaticMeta is read as
1331        // array<GridStaticMeta>; the std430 array stride must equal
1332        // the Rust size_of or wgpu rejects the binding.
1333        // Concretely: 8 u32 (32) + vec3+pad (16) + 4 u32 (16) +
1334        // 2*[u32;6] (48) = 112, then GPU.13.0 adds two vec3<i32>+pad
1335        // (aabb_min, aabb_max) = 32 → 144, and GPU.13.1 adds
1336        // [u32;4] pyramid offsets + levels + [u32;3] pad = 32 → 176.
1337        assert_eq!(std::mem::size_of::<GridStaticMeta>(), 176);
1338        assert_eq!(std::mem::align_of::<GridStaticMeta>(), 4);
1339    }
1340
1341    /// GPU.13.1 — the pyramid built from an L0 bitmap must equal a
1342    /// brute-force OR over every level's block, and incremental
1343    /// child recompute must agree with the builder.
1344    #[test]
1345    fn occ_pyramid_matches_brute_force() {
1346        let pool = [8u32, 8, 4];
1347        // A scattered pattern: bits at slots (0,0,0), (7,7,3), (3,4,1).
1348        let mut l0 = vec![0u32; (8 * 8 * 4) / 32];
1349        for slot in [(0u32, 0u32, 0u32), (7, 7, 3), (3, 4, 1)] {
1350            let idx = (slot.0 + slot.1 * 8 + slot.2 * 64) as usize;
1351            l0[idx >> 5] |= 1 << (idx & 31);
1352        }
1353        let pyr = build_occ_pyramid(pool, &l0);
1354        assert_eq!(pyr.len(), 3, "log2(8) levels above L0");
1355        for l in 1..=3u32 {
1356            let d = occ_level_dims(pool, l);
1357            for z in 0..d[2] {
1358                for y in 0..d[1] {
1359                    for x in 0..d[0] {
1360                        // Brute force: OR of every L0 slot inside the
1361                        // 2^l block (clamped by the pool).
1362                        let mut expect = false;
1363                        for sz in (z << l)..(((z + 1) << l).min(pool[2])) {
1364                            for sy in (y << l)..(((y + 1) << l).min(pool[1])) {
1365                                for sx in (x << l)..(((x + 1) << l).min(pool[0])) {
1366                                    let i = (sx + sy * 8 + sz * 64) as usize;
1367                                    expect |= (l0[i >> 5] >> (i & 31)) & 1 == 1;
1368                                }
1369                            }
1370                        }
1371                        let idx = (x + y * d[0] + z * d[0] * d[1]) as usize;
1372                        let got = (pyr[(l - 1) as usize][idx >> 5] >> (idx & 31)) & 1 == 1;
1373                        assert_eq!(got, expect, "level {l} cell ({x},{y},{z})");
1374                        // Incremental recompute path agrees.
1375                        let child: &[u32] = if l == 1 { &l0 } else { &pyr[(l - 2) as usize] };
1376                        assert_eq!(occ_cell_from_children(pool, l, [x, y, z], child), expect);
1377                    }
1378                }
1379            }
1380        }
1381        // Top level is a single cell and it is occupied.
1382        assert_eq!(pyr[2].len(), 1);
1383        assert_eq!(pyr[2][0] & 1, 1);
1384        // An empty L0 yields an all-empty pyramid.
1385        let empty = build_occ_pyramid(pool, &[0u32; 8]);
1386        assert!(empty.iter().all(|lvl| lvl.iter().all(|&w| w == 0)));
1387    }
1388
1389    #[test]
1390    fn mip_layout_offsets_accumulate() {
1391        // vsid=128 → 6 mips. Relative offsets are cumulative; mip-0
1392        // sits at 0 so mip-0 reads are byte-identical to pre-mip.
1393        let l = MipLayout::for_vsid(128);
1394        assert_eq!(l.mip_count, 6);
1395        assert_eq!(l.mip_occ_rel[0], 0);
1396        assert_eq!(l.mip_coff_rel[0], 0);
1397
1398        // Recompute the strides independently and compare. Each mip
1399        // stores TWO occupancy bitmaps (textured + solid) back-to-back.
1400        let mut occ = 0u32;
1401        let mut coff = 0u32;
1402        for m in 0..6u32 {
1403            assert_eq!(l.mip_occ_rel[m as usize], occ, "occ rel mip {m}");
1404            assert_eq!(l.mip_coff_rel[m as usize], coff, "coff rel mip {m}");
1405            let v = 128u32 >> m;
1406            occ += 2 * v * v * occ_words_per_column_for_mip(m);
1407            coff += v * v + 1;
1408        }
1409        assert_eq!(l.occ_words_per_slot, occ);
1410        assert_eq!(l.offsets_words_per_slot, coff);
1411
1412        // mip-0 occupancy stride is 2 × the historical vsid²·8 (tex +
1413        // solid bitmaps).
1414        assert_eq!(l.mip_occ_rel[1], 2 * 128 * 128 * 8);
1415        // The whole ladder is only ~1/7 larger than mip-0 alone
1416        // (geometric 1 + 1/8 + 1/64 + …) — here on the doubled base.
1417        assert!(l.occ_words_per_slot < 2 * 128 * 128 * 8 * 5 / 4);
1418    }
1419}