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}