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 pub mip_count: u32,
48 pub occ_words_per_slot: u32,
49 pub offsets_words_per_slot: u32,
50 /// Within-slot u32 offset where mip `m`'s occupancy starts.
51 pub mip_occ_rel: [u32; MAX_GPU_MIPS],
52 /// Within-slot u32 offset where mip `m`'s color_offsets start.
53 pub mip_coff_rel: [u32; MAX_GPU_MIPS],
54}
55
56impl MipLayout {
57 #[must_use]
58 pub fn for_vsid(vsid: u32) -> Self {
59 let mip_count = gpu_mip_count(vsid);
60 let mut mip_occ_rel = [0u32; MAX_GPU_MIPS];
61 let mut mip_coff_rel = [0u32; MAX_GPU_MIPS];
62 let mut occ_acc = 0u32;
63 let mut coff_acc = 0u32;
64 for m in 0..mip_count {
65 mip_occ_rel[m as usize] = occ_acc;
66 mip_coff_rel[m as usize] = coff_acc;
67 let vsid_m = vsid >> m;
68 let cols = vsid_m * vsid_m;
69 // Each mip stores TWO bitmaps back-to-back: the textured
70 // occupancy then the solid occupancy (cliff-face fix). The
71 // shader reads solid at `tex_base + cols*occ_words_per_col`.
72 occ_acc += 2 * cols * occ_words_per_column_for_mip(m);
73 coff_acc += cols + 1;
74 }
75 Self {
76 mip_count,
77 occ_words_per_slot: occ_acc,
78 offsets_words_per_slot: coff_acc,
79 mip_occ_rel,
80 mip_coff_rel,
81 }
82 }
83}
84
85/// Per-chunk colour-slot stride, in u32 words (256 KiB). Each
86/// chunk's colour data lives at `meta_idx * COLORS_PER_CHUNK_WORDS`
87/// within its grid's colours range. Fixed-stride layout means
88/// every slot — present or absent at upload time — has the same
89/// capacity, so [`GpuSceneResident::refresh_chunk`] can always
90/// write new colour data into the slot when a chunk arrives via
91/// streaming or is re-baked.
92///
93/// 65536 u32s = 256 KiB. Scene-demo's densest ground-hills chunks
94/// run ~36 k colour entries (~144 KiB) — multiple textured voxels
95/// per column at slopes/cliffs; 256 KiB gives ~1.8× headroom.
96/// Memory cost on the demo's 32×32×1 static grid: 1024 slots ×
97/// 256 KiB = 256 MiB colours (~830 MiB resident scene total).
98/// Chunks past the cap truncate with a stderr warn; GPU.7
99/// sliding-window storage removes the cap entirely.
100pub const COLORS_PER_CHUNK_WORDS: u32 = 65536;
101
102/// Number of separate storage bindings the concatenated occupancy
103/// buffer is split ("paged") across. A single storage binding may
104/// not exceed the device's `max_storage_buffer_binding_size` — on
105/// strict drivers that's a hard 128 MiB (lavapipe), which the
106/// streaming demo's occupancy already reaches. Splitting into pages
107/// keeps every binding under the limit while preserving a single
108/// global word index in the shader (each page is a whole number of
109/// chunk slots, so no slot ever straddles a page boundary).
110///
111/// On GPUs with multi-GiB binding limits (NVK, native Vulkan) the
112/// whole buffer fits in page 0, the other bindings get a 1-word
113/// dummy, and the shader's page select is a single perfectly-
114/// predicted uniform branch → zero hot-loop cost. 4 pages covers
115/// 512 MiB of occupancy even on a 128 MiB-per-binding device.
116pub const MAX_OCC_PAGES: usize = 4;
117
118/// Per-grid runtime transform — voxlap-style (world → grid-local).
119/// `rotation` is column-major and encodes the inverse rotation
120/// applied to the world camera basis before passing it to that
121/// grid's marcher. Identity for the ground; non-trivial for the
122/// rotating ship.
123#[derive(Debug, Clone, Copy)]
124pub struct GridRuntimeTransform {
125 /// Grid-local position of the world origin = `-rotation⁻¹ ·
126 /// grid.position` for a `GridTransform { position, rotation }`.
127 /// The host computes this once per frame.
128 pub grid_origin_world: [f64; 3],
129 /// 3×3 inverse rotation (column-major).
130 pub world_to_grid_rotation: [[f32; 3]; 3],
131}
132
133impl Default for GridRuntimeTransform {
134 fn default() -> Self {
135 Self {
136 grid_origin_world: [0.0, 0.0, 0.0],
137 world_to_grid_rotation: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
138 }
139 }
140}
141
142/// CPU-side aggregation of every grid in a scene. Built once at
143/// startup; per-grid transforms are recomputed each frame and
144/// passed to `render_scene` separately.
145pub struct SceneUpload {
146 pub grids: Vec<GridUpload>,
147}
148
149impl SceneUpload {
150 #[must_use]
151 pub fn grid_count(&self) -> u32 {
152 u32::try_from(self.grids.len()).unwrap_or(u32::MAX)
153 }
154}
155
156/// Per-grid static metadata: offsets into the concatenated storage
157/// buffers + the grid's slot-pool dimensions. Uploaded once.
158///
159/// GPU.7 changes: `chunks_dims` and `origin_chunk` were dropped.
160/// The shader uses modular slot indexing
161/// (`chunk_idx & (pool_dims - 1)`) and verifies slot identity via
162/// `slot_chunk_idx[slot]`, so the upload-time bbox is no longer
163/// relevant to the shader.
164#[repr(C)]
165#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable, Debug)]
166pub struct GridStaticMeta {
167 /// `occupancy` u32-word offset where this grid's data starts.
168 pub occupancy_offset: u32,
169 pub color_offsets_offset: u32,
170 pub colors_offset: u32,
171 pub chunk_colors_base_offset: u32,
172 pub chunk_occupancy_offset: u32,
173 /// New in GPU.7: u32-word offset where this grid's
174 /// `slot_chunk_idx` array starts (one `vec3<i32>` per slot,
175 /// i.e. 3 u32 words each, plus 1 padding word for std430).
176 pub slot_chunk_idx_offset: u32,
177 pub vsid: u32,
178 pub total_slots: u32,
179 pub pool_dims: [u32; 3],
180 pub _pad0: u32,
181 /// GPU.11 — per-slot occupancy stride (sum over all mips).
182 /// `meta_id`'s occupancy slab starts at
183 /// `occupancy_offset + meta_id * occ_words_per_slot`.
184 pub occ_words_per_slot: u32,
185 /// GPU.11 — per-slot color_offsets stride (sum over all mips).
186 pub offsets_words_per_slot: u32,
187 /// GPU.11 — number of mip levels stored per slot.
188 pub mip_count: u32,
189 pub _pad1: u32,
190 /// GPU.11 — within-slot u32 offset where mip `m`'s occupancy
191 /// starts. `mip_occ_rel[0] == 0` so mip-0 reads are unchanged.
192 pub mip_occ_rel: [u32; MAX_GPU_MIPS],
193 /// GPU.11 — within-slot u32 offset where mip `m`'s color_offsets
194 /// start. `mip_coff_rel[0] == 0`.
195 pub mip_coff_rel: [u32; MAX_GPU_MIPS],
196 /// GPU.13.0 — occupied chunk-AABB (inclusive) in chunk-index space.
197 /// The outer DDA stops once `p_chunk` passes this box along the
198 /// ray's travel direction (no resident chunk can lie ahead). An
199 /// empty grid uses the inverted sentinel (`aabb_min = i32::MAX`,
200 /// `aabb_max = i32::MIN`) so every ray early-outs immediately.
201 /// Maintained live: [`GpuSceneResident::refresh_chunk`] /
202 /// [`GpuSceneResident::evict_chunk`] recompute + re-upload it.
203 pub aabb_min: [i32; 3],
204 pub _pad2: i32,
205 pub aabb_max: [i32; 3],
206 pub _pad3: i32,
207}
208
209/// Sentinel chunk_idx written into empty slot_chunk_idx entries.
210/// Real chunk indices never use `i32::MIN`, so the shader can
211/// distinguish empty slots from collisions via a single equality
212/// check.
213pub const SLOT_EMPTY_SENTINEL: [i32; 3] = [i32::MIN, i32::MIN, i32::MIN];
214
215/// GPU-resident storage for an entire scene's grids.
216pub struct GpuSceneResident {
217 pub grid_count: u32,
218 /// Concatenated per-slot occupancy, split into up to
219 /// [`MAX_OCC_PAGES`] storage bindings so no single binding
220 /// exceeds the device's `max_storage_buffer_binding_size`. The
221 /// vec is always exactly `MAX_OCC_PAGES` long — pages past
222 /// `occupancy_num_pages` are 1-word dummies kept only so the
223 /// bind group has a buffer for every layout entry. Page p holds
224 /// the global word range `[p*occupancy_page_words,
225 /// (p+1)*occupancy_page_words)`; `occupancy_page_words` is a
226 /// whole number of chunk slots so no slot straddles a boundary.
227 pub occupancy_pages: Vec<wgpu::Buffer>,
228 /// Words per occupancy page (a multiple of `occ_words_per_slot`).
229 pub occupancy_page_words: u32,
230 /// Number of real (non-dummy) pages in `occupancy_pages`.
231 pub occupancy_num_pages: u32,
232 pub all_color_offsets: wgpu::Buffer,
233 pub all_colors: wgpu::Buffer,
234 pub all_chunk_colors_base: wgpu::Buffer,
235 pub all_chunk_occupancy: wgpu::Buffer,
236 /// GPU.7 — per-slot chunk_idx for identity verification in the
237 /// shader. Stored as `vec3<i32>` with std430 16-byte stride
238 /// (each entry is `[i32; 4]` on the host: x, y, z, _pad).
239 pub all_slot_chunk_idx: wgpu::Buffer,
240 pub grid_static_meta: wgpu::Buffer,
241 pub total_bytes: u64,
242 /// Cached static metadata for the host's frame-loop work.
243 pub static_meta: Vec<GridStaticMeta>,
244 /// CPU shadow of the per-grid chunk-occupancy bitmap. Each entry
245 /// is the u32 word at `chunk_occupancy_offset + (mi >> 5)`.
246 /// `refresh_chunk` / `evict_chunk` flip the right bit + write
247 /// the affected word back to the GPU.
248 pub(crate) chunk_occupancy_shadow: Vec<Vec<u32>>,
249 /// CPU shadow of `slot_chunk_idx`. Indexed `[scene_idx][slot]`
250 /// → `[i32; 4]` (vec3 + pad). Host uses this to detect "slot is
251 /// holding a different chunk than expected" + as the eviction
252 /// origin.
253 pub(crate) slot_chunk_idx_shadow: Vec<Vec<[i32; 4]>>,
254 /// Per-grid colour stride in u32 words (the adaptive
255 /// [`COLORS_PER_CHUNK_WORDS`]-or-larger value chosen at upload to
256 /// fit the grid's densest chunk). `refresh_chunk` reads it so a
257 /// streamed re-upload addresses colours with the same stride the
258 /// initial upload used.
259 pub(crate) colors_stride_shadow: Vec<u32>,
260 /// PF.12.c — CPU mirror of each installed slot's per-mip
261 /// `color_offsets` tables (`offsets_words_per_slot` words, the exact
262 /// content of the GPU window). [`Self::refresh_chunk_partial`] reads
263 /// it to (a) place a dirty column's colours at the resident offset
264 /// and (b) verify the column's colour COUNT is unchanged — a count
265 /// change reflows the packed colour block and forces the full-path
266 /// fallback. ~87 KB per 128² chunk; dropped on evict.
267 pub(crate) color_offsets_shadow: Vec<std::collections::HashMap<usize, Vec<u32>>>,
268}
269
270impl GpuSceneResident {
271 /// Pack + upload `info`. Each grid is uploaded as a contiguous
272 /// slab inside the shared storage buffers; per-grid offsets
273 /// live in `grid_static_meta`. The grid count is bounded only by
274 /// the device's storage-buffer limits (per-grid cameras + metadata
275 /// are runtime-sized storage arrays, not a fixed shader array).
276 pub fn upload(device: &wgpu::Device, info: &SceneUpload) -> Self {
277 let grid_count = info.grid_count();
278
279 let mut all_occupancy: Vec<u32> = Vec::new();
280 let mut all_color_offsets: Vec<u32> = Vec::new();
281 let mut all_colors: Vec<u32> = Vec::new();
282 let mut all_chunk_colors_base: Vec<u32> = Vec::new();
283 let mut all_chunk_occupancy: Vec<u32> = Vec::new();
284 let mut all_slot_chunk_idx: Vec<i32> = Vec::new();
285 let mut static_meta: Vec<GridStaticMeta> = Vec::with_capacity(info.grids.len());
286 let mut chunk_occupancy_shadow: Vec<Vec<u32>> = Vec::with_capacity(info.grids.len());
287 let mut slot_chunk_idx_shadow: Vec<Vec<[i32; 4]>> = Vec::with_capacity(info.grids.len());
288 let mut color_offsets_shadow: Vec<std::collections::HashMap<usize, Vec<u32>>> =
289 Vec::with_capacity(info.grids.len());
290 // Per-grid colour stride (words/slot) — adaptive to the grid's
291 // densest chunk (see the in-loop derivation). `refresh_chunk`
292 // reads it back so streamed re-uploads use the same stride.
293 let mut grid_colors_strides: Vec<u32> = Vec::with_capacity(info.grids.len());
294
295 for grid in &info.grids {
296 let vsid = grid.vsid;
297 // GPU.11 — per-slot strides span the whole mip ladder.
298 let layout = MipLayout::for_vsid(vsid);
299 let occ_words_per_slot = layout.occ_words_per_slot as usize;
300 let offsets_words_per_slot = layout.offsets_words_per_slot as usize;
301 // Per-slot colour stride. The fixed-stride layout gives every
302 // slot — present or not — the same capacity, so streaming /
303 // re-bake can write a fresh chunk's colours into any slot.
304 // [`COLORS_PER_CHUNK_WORDS`] is sized for sparse terrain
305 // chunks (~36 k colours); a *fully dense* chunk (the cave
306 // demo's single 128×128×256 chunk carries ~207 k colours
307 // across its mip ladder) needs more, or its colours truncate
308 // and the chunk's high-`y` columns render black. Grow the
309 // stride to the grid's densest chunk, floored at the default
310 // so denser chunks that stream in later still fit the common
311 // case. Per-grid: a sparse grid keeps the small stride; only
312 // a grid that actually holds dense chunks pays for the
313 // bigger one.
314 let max_chunk_colors = grid
315 .chunks
316 .iter()
317 .map(|(_, c)| c.mips.iter().map(|m| m.colors.len()).sum::<usize>())
318 .max()
319 .unwrap_or(0);
320 let colors_stride = (COLORS_PER_CHUNK_WORDS as usize).max(max_chunk_colors);
321 grid_colors_strides.push(colors_stride as u32);
322
323 // Validate pool_dims are powers of 2 — required for the
324 // shader's `chunk_idx & (pool_dims - 1)` modular slot
325 // indexing.
326 assert!(
327 grid.pool_dims[0].is_power_of_two()
328 && grid.pool_dims[1].is_power_of_two()
329 && grid.pool_dims[2].is_power_of_two(),
330 "scene grid: pool_dims {:?} must all be powers of 2",
331 grid.pool_dims,
332 );
333 let pool_x = grid.pool_dims[0] as usize;
334 let pool_y = grid.pool_dims[1] as usize;
335 let pool_z = grid.pool_dims[2] as usize;
336 let total_slots = pool_x * pool_y * pool_z;
337
338 let mut grid_occupancy = vec![0u32; total_slots * occ_words_per_slot];
339 let mut grid_color_offsets = vec![0u32; total_slots * offsets_words_per_slot];
340 let mut grid_colors = vec![0u32; total_slots * colors_stride];
341 let mut grid_chunk_colors_base = vec![0u32; total_slots];
342 for i in 0..total_slots {
343 grid_chunk_colors_base[i] = (i * colors_stride) as u32;
344 }
345 let mut grid_chunk_occupancy = vec![0u32; total_slots.div_ceil(32)];
346 // slot_chunk_idx: vec3<i32> per slot, std430 stride = 16
347 // bytes (4 u32 words: x, y, z, _pad). Initialise every
348 // slot to the empty sentinel; populated slots overwrite
349 // with the actual chunk_idx below.
350 let mut grid_offsets_shadow: std::collections::HashMap<usize, Vec<u32>> =
351 std::collections::HashMap::new();
352 let mut grid_slot_chunk_idx: Vec<[i32; 4]> = Vec::with_capacity(total_slots);
353 for _ in 0..total_slots {
354 grid_slot_chunk_idx.push([
355 SLOT_EMPTY_SENTINEL[0],
356 SLOT_EMPTY_SENTINEL[1],
357 SLOT_EMPTY_SENTINEL[2],
358 0,
359 ]);
360 }
361
362 let mask_x = (grid.pool_dims[0] - 1) as i32;
363 let mask_y = (grid.pool_dims[1] - 1) as i32;
364 let mask_z = (grid.pool_dims[2] - 1) as i32;
365 let chunks_per_layer = pool_x * pool_y;
366
367 for (chunk_idx, chunk) in &grid.chunks {
368 assert_eq!(chunk.vsid, vsid, "scene grid: chunk vsid mismatch");
369 let sx = (chunk_idx[0] & mask_x) as usize;
370 let sy = (chunk_idx[1] & mask_y) as usize;
371 let sz = (chunk_idx[2] & mask_z) as usize;
372 let slot_idx = sx + sy * pool_x + sz * chunks_per_layer;
373
374 // GPU.11 — write each mip at its within-slot offset.
375 // occupancy + color_offsets land in per-mip sub-blocks
376 // (mip-0 first, so its data is byte-identical to the
377 // pre-mip layout); colours of every mip concatenate
378 // into the slot's fixed COLORS_PER_CHUNK_WORDS block in
379 // level order, indexed by each chunk's own absolute
380 // `color_offsets`.
381 let occ_start = slot_idx * occ_words_per_slot;
382 let off_start = slot_idx * offsets_words_per_slot;
383 let col_start = slot_idx * colors_stride;
384 let mut color_cursor = 0usize;
385 for (m, mip) in chunk.mips.iter().enumerate() {
386 let occ_dst = occ_start + layout.mip_occ_rel[m] as usize;
387 grid_occupancy[occ_dst..occ_dst + mip.occupancy.len()]
388 .copy_from_slice(&mip.occupancy);
389 // Solid bitmap immediately follows the textured one.
390 let solid_dst = occ_dst + mip.occupancy.len();
391 grid_occupancy[solid_dst..solid_dst + mip.solid_occupancy.len()]
392 .copy_from_slice(&mip.solid_occupancy);
393 let coff_dst = off_start + layout.mip_coff_rel[m] as usize;
394 grid_color_offsets[coff_dst..coff_dst + mip.color_offsets.len()]
395 .copy_from_slice(&mip.color_offsets);
396
397 let remaining = colors_stride.saturating_sub(color_cursor);
398 let n = mip.colors.len().min(remaining);
399 if n < mip.colors.len() {
400 eprintln!(
401 "roxlap-gpu SceneUpload: scene grid chunk {chunk_idx:?} mip {m} \
402 colours overflow COLORS_PER_CHUNK_WORDS ({colors_stride}); \
403 truncating",
404 );
405 }
406 grid_colors[col_start + color_cursor..col_start + color_cursor + n]
407 .copy_from_slice(&mip.colors[..n]);
408 color_cursor += n;
409 }
410
411 if !chunk.mips[0].colors.is_empty() {
412 grid_chunk_occupancy[slot_idx >> 5] |= 1u32 << (slot_idx & 31);
413 }
414 grid_slot_chunk_idx[slot_idx] = [chunk_idx[0], chunk_idx[1], chunk_idx[2], 0];
415 // PF.12.c — mirror the slot's color_offsets window.
416 grid_offsets_shadow.insert(
417 slot_idx,
418 grid_color_offsets[off_start..off_start + offsets_words_per_slot].to_vec(),
419 );
420 }
421
422 // Slot_chunk_idx storage offset: each entry is 4 u32
423 // words (vec3 padded to 16 bytes in std430).
424 let slot_chunk_idx_offset = u32::try_from(all_slot_chunk_idx.len()).expect("fits");
425 // GPU.13.0 — occupied chunk-AABB for the outer-DDA early-out.
426 let (aabb_min, aabb_max) = aabb_of_slots(&grid_slot_chunk_idx);
427 let meta = GridStaticMeta {
428 occupancy_offset: u32::try_from(all_occupancy.len()).expect("fits"),
429 color_offsets_offset: u32::try_from(all_color_offsets.len()).expect("fits"),
430 colors_offset: u32::try_from(all_colors.len()).expect("fits"),
431 chunk_colors_base_offset: u32::try_from(all_chunk_colors_base.len()).expect("fits"),
432 chunk_occupancy_offset: u32::try_from(all_chunk_occupancy.len()).expect("fits"),
433 slot_chunk_idx_offset,
434 vsid,
435 total_slots: total_slots as u32,
436 pool_dims: grid.pool_dims,
437 _pad0: 0,
438 occ_words_per_slot: layout.occ_words_per_slot,
439 offsets_words_per_slot: layout.offsets_words_per_slot,
440 mip_count: layout.mip_count,
441 _pad1: 0,
442 mip_occ_rel: layout.mip_occ_rel,
443 mip_coff_rel: layout.mip_coff_rel,
444 aabb_min,
445 _pad2: 0,
446 aabb_max,
447 _pad3: 0,
448 };
449
450 chunk_occupancy_shadow.push(grid_chunk_occupancy.clone());
451 slot_chunk_idx_shadow.push(grid_slot_chunk_idx.clone());
452 color_offsets_shadow.push(grid_offsets_shadow);
453
454 all_occupancy.extend_from_slice(&grid_occupancy);
455 all_color_offsets.extend_from_slice(&grid_color_offsets);
456 all_colors.extend_from_slice(&grid_colors);
457 all_chunk_colors_base.extend_from_slice(&grid_chunk_colors_base);
458 all_chunk_occupancy.extend_from_slice(&grid_chunk_occupancy);
459 for entry in &grid_slot_chunk_idx {
460 all_slot_chunk_idx.extend_from_slice(entry);
461 }
462 static_meta.push(meta);
463 }
464
465 // Pad an empty scene's storage buffers — wgpu rejects
466 // zero-size storage bindings.
467 if all_occupancy.is_empty() {
468 all_occupancy.push(0);
469 }
470 if all_color_offsets.is_empty() {
471 all_color_offsets.push(0);
472 }
473 if all_colors.is_empty() {
474 all_colors.push(0);
475 }
476 if all_chunk_colors_base.is_empty() {
477 all_chunk_colors_base.push(0);
478 }
479 if all_chunk_occupancy.is_empty() {
480 all_chunk_occupancy.push(0);
481 }
482 if all_slot_chunk_idx.is_empty() {
483 // 4 zeros = single padded vec3<i32>. wgpu rejects
484 // zero-sized storage bindings.
485 all_slot_chunk_idx.extend_from_slice(&[0; 4]);
486 }
487 if static_meta.is_empty() {
488 static_meta.push(GridStaticMeta::zeroed());
489 }
490
491 let occupancy_bytes = (all_occupancy.len() * 4) as u64;
492 let color_offsets_bytes = (all_color_offsets.len() * 4) as u64;
493 let colors_bytes = (all_colors.len() * 4) as u64;
494 let chunk_colors_base_bytes = (all_chunk_colors_base.len() * 4) as u64;
495 let chunk_occupancy_bytes = (all_chunk_occupancy.len() * 4) as u64;
496 let slot_chunk_idx_bytes = (all_slot_chunk_idx.len() * 4) as u64;
497 let static_meta_bytes = (static_meta.len() * std::mem::size_of::<GridStaticMeta>()) as u64;
498 let total_bytes = occupancy_bytes
499 + color_offsets_bytes
500 + colors_bytes
501 + chunk_colors_base_bytes
502 + chunk_occupancy_bytes
503 + slot_chunk_idx_bytes
504 + static_meta_bytes;
505
506 // Split the concatenated occupancy across storage pages so no
507 // single binding exceeds the device limit. Page size is a
508 // whole number of chunk slots (slot-aligned) so no per-slot
509 // refresh write ever straddles two pages.
510 // GPU.11 — page alignment is now the whole-ladder per-slot
511 // occupancy stride so a slot (all its mips) never straddles a
512 // page boundary.
513 let slot_align_words = info
514 .grids
515 .iter()
516 .map(|g| u64::from(MipLayout::for_vsid(g.vsid).occ_words_per_slot))
517 .max()
518 .unwrap_or(1)
519 .max(1);
520 let (occupancy_pages, occupancy_page_words, occupancy_num_pages) =
521 split_occupancy_pages(device, &all_occupancy, slot_align_words);
522 let all_color_offsets =
523 create_storage(device, "roxlap-gpu scene.color_offsets", &all_color_offsets);
524 let all_colors = create_storage(device, "roxlap-gpu scene.colors", &all_colors);
525 let all_chunk_colors_base = create_storage(
526 device,
527 "roxlap-gpu scene.chunk_colors_base",
528 &all_chunk_colors_base,
529 );
530 let all_chunk_occupancy = create_storage(
531 device,
532 "roxlap-gpu scene.chunk_occupancy",
533 &all_chunk_occupancy,
534 );
535 // GPU.7 slot identity verification buffer. i32 storage.
536 let all_slot_chunk_idx_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
537 label: Some("roxlap-gpu scene.slot_chunk_idx"),
538 contents: bytemuck::cast_slice(&all_slot_chunk_idx),
539 usage: wgpu::BufferUsages::STORAGE
540 | wgpu::BufferUsages::COPY_DST
541 | wgpu::BufferUsages::COPY_SRC,
542 });
543 let grid_static_meta = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
544 label: Some("roxlap-gpu scene.grid_static_meta"),
545 contents: bytemuck::cast_slice(&static_meta),
546 // GPU.13.0 — COPY_DST so the live chunk-AABB can be patched
547 // into a grid's meta on refresh_chunk / evict_chunk.
548 usage: wgpu::BufferUsages::STORAGE
549 | wgpu::BufferUsages::COPY_DST
550 | wgpu::BufferUsages::COPY_SRC,
551 });
552
553 Self {
554 grid_count,
555 occupancy_pages,
556 occupancy_page_words,
557 occupancy_num_pages,
558 all_color_offsets,
559 all_colors,
560 all_chunk_colors_base,
561 all_chunk_occupancy,
562 all_slot_chunk_idx: all_slot_chunk_idx_buf,
563 grid_static_meta,
564 total_bytes,
565 static_meta,
566 chunk_occupancy_shadow,
567 slot_chunk_idx_shadow,
568 color_offsets_shadow,
569 colors_stride_shadow: grid_colors_strides,
570 }
571 }
572
573 pub fn resident_bytes(&self) -> u64 {
574 self.total_bytes
575 }
576
577 /// Install or refresh a chunk in its modular pool slot. GPU.7
578 /// generalises GPU.6's in-place refresh: any chunk_idx maps to
579 /// a slot via `chunk_idx & (pool_dims - 1)`. The previous
580 /// occupant (if a different chunk) is silently replaced — the
581 /// host is responsible for guaranteeing that the pool is sized
582 /// large enough that two simultaneously-resident chunks never
583 /// collide on the same slot.
584 pub fn refresh_chunk(
585 &mut self,
586 queue: &wgpu::Queue,
587 scene_idx: usize,
588 chunk_idx: [i32; 3],
589 chunk: &ChunkUpload,
590 ) -> RefreshOutcome {
591 let Some(meta) = self.static_meta.get(scene_idx).copied() else {
592 return RefreshOutcome::SceneIdxOob;
593 };
594 let slot_idx = modular_slot_idx(chunk_idx, meta.pool_dims);
595
596 // GPU.11 — the per-slot strides span the full mip ladder; the
597 // resident's layout was built from the same `MipLayout`.
598 let layout = MipLayout::for_vsid(meta.vsid);
599 let occ_words_per_slot = layout.occ_words_per_slot as usize;
600 let offsets_words_per_slot = layout.offsets_words_per_slot as usize;
601 // Same adaptive stride the initial upload chose for this grid.
602 let colors_stride = self
603 .colors_stride_shadow
604 .get(scene_idx)
605 .map_or(COLORS_PER_CHUNK_WORDS as usize, |&s| s as usize);
606
607 assert_eq!(
608 chunk.mips.len() as u32,
609 layout.mip_count,
610 "refresh_chunk: mip count mismatch (chunk {} vs grid {})",
611 chunk.mips.len(),
612 layout.mip_count,
613 );
614
615 // ---- occupancy ----
616 // Route each mip's write to its page. Page size is slot-
617 // aligned (see `split_occupancy_pages`) so the whole slot's
618 // occupancy ladder lands in a single page.
619 let slot_occ_base = meta.occupancy_offset as usize + slot_idx * occ_words_per_slot;
620 let page_words = self.occupancy_page_words as usize;
621 let page = slot_occ_base / page_words;
622 let slot_local_word = slot_occ_base % page_words;
623 debug_assert!(
624 slot_local_word + occ_words_per_slot <= page_words,
625 "occupancy slot straddles a page boundary — page size not slot-aligned",
626 );
627 let off_slot_base = meta.color_offsets_offset as usize + slot_idx * offsets_words_per_slot;
628 let col_slot_base = meta.colors_offset as usize + slot_idx * colors_stride;
629
630 let mut outcome = RefreshOutcome::Ok;
631 let mut color_cursor = 0usize;
632 for (m, mip) in chunk.mips.iter().enumerate() {
633 // occupancy (textured) then solid, back-to-back.
634 let local = slot_local_word + layout.mip_occ_rel[m] as usize;
635 queue.write_buffer(
636 &self.occupancy_pages[page],
637 (local * 4) as u64,
638 bytemuck::cast_slice(&mip.occupancy),
639 );
640 queue.write_buffer(
641 &self.occupancy_pages[page],
642 ((local + mip.occupancy.len()) * 4) as u64,
643 bytemuck::cast_slice(&mip.solid_occupancy),
644 );
645 // color_offsets
646 let coff = off_slot_base + layout.mip_coff_rel[m] as usize;
647 queue.write_buffer(
648 &self.all_color_offsets,
649 (coff * 4) as u64,
650 bytemuck::cast_slice(&mip.color_offsets),
651 );
652 // colours (concatenated per slot, truncate to stride)
653 let remaining = colors_stride.saturating_sub(color_cursor);
654 let n = mip.colors.len().min(remaining);
655 if n < mip.colors.len() {
656 eprintln!(
657 "roxlap-gpu refresh_chunk: scene_idx={scene_idx} chunk_idx={chunk_idx:?} \
658 mip {m} colours overflow stride {colors_stride}; truncating",
659 );
660 outcome = RefreshOutcome::ColorsTruncated;
661 }
662 if n > 0 {
663 queue.write_buffer(
664 &self.all_colors,
665 ((col_slot_base + color_cursor) * 4) as u64,
666 bytemuck::cast_slice(&mip.colors[..n]),
667 );
668 }
669 color_cursor += n;
670 }
671
672 // ---- chunk_occupancy bit ----
673 self.set_chunk_occupancy_bit(
674 queue,
675 scene_idx,
676 &meta,
677 slot_idx,
678 !chunk.mips[0].colors.is_empty(),
679 );
680
681 // ---- slot_chunk_idx (identity for the shader) ----
682 self.set_slot_chunk_idx(queue, scene_idx, &meta, slot_idx, chunk_idx);
683
684 // ---- PF.12.c — mirror the slot's color_offsets window ----
685 // (`refresh_chunk_partial` verifies counts + places colours
686 // against it). Rebuilt exactly as the GPU windows were written.
687 let mut window = vec![0u32; offsets_words_per_slot];
688 for (m, mip) in chunk.mips.iter().enumerate() {
689 let coff = layout.mip_coff_rel[m] as usize;
690 window[coff..coff + mip.color_offsets.len()].copy_from_slice(&mip.color_offsets);
691 }
692 self.color_offsets_shadow[scene_idx].insert(slot_idx, window);
693
694 // ---- GPU.13.0 grid-AABB early-out box ----
695 self.sync_aabb(queue, scene_idx);
696
697 outcome
698 }
699
700 /// Evict a chunk's slot — clear its `chunk_occupancy` bit and
701 /// reset `slot_chunk_idx` to the empty sentinel. Used by the
702 /// host when a chunk disappears from the CPU-side `Grid::chunks`
703 /// (e.g. streaming eviction past `r_evict`).
704 ///
705 /// Returns `false` if `scene_idx` is past `grid_count` (no-op);
706 /// `true` otherwise.
707 /// PF.12.c — partial refresh: re-derive + re-upload ONLY the columns
708 /// inside the inclusive chunk-local mip-0 column rect `[x0..=x1] ×
709 /// [y0..=y1]` (pre-padded by the caller with the edit's ±1 adjacency
710 /// reach), for every mip. Requires the slot to already hold
711 /// `chunk_idx` with a mirrored offsets table, and every dirty
712 /// column's colour COUNT to be unchanged (a count change reflows the
713 /// packed colour block). Returns `false` — with **nothing written**
714 /// — when any precondition fails; the caller falls back to the full
715 /// [`Self::refresh_chunk`] path.
716 ///
717 /// The count-stable case is the streaming bake tracker's per-frame
718 /// path (brightness-byte rewrites) and recolour edits: those now
719 /// upload a few KB instead of decompressing + rewriting the whole
720 /// ~1–2 MB chunk ladder.
721 #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
722 pub fn refresh_chunk_partial(
723 &mut self,
724 queue: &wgpu::Queue,
725 scene_idx: usize,
726 chunk_idx: [i32; 3],
727 vxl: &roxlap_formats::vxl::Vxl,
728 x0: i32,
729 y0: i32,
730 x1: i32,
731 y1: i32,
732 ) -> bool {
733 let Some(meta) = self.static_meta.get(scene_idx).copied() else {
734 return false;
735 };
736 let layout = MipLayout::for_vsid(meta.vsid);
737 if vxl.mip_count() < layout.mip_count {
738 return false;
739 }
740 let slot_idx = modular_slot_idx(chunk_idx, meta.pool_dims);
741 // The slot must currently hold THIS chunk (modular pools reuse
742 // slots; a partial write over another chunk's data = garbage).
743 let held = self.slot_chunk_idx_shadow[scene_idx][slot_idx];
744 if held[0] != chunk_idx[0] || held[1] != chunk_idx[1] || held[2] != chunk_idx[2] {
745 return false;
746 }
747 let Some(offs_shadow) = self.color_offsets_shadow[scene_idx].get(&slot_idx) else {
748 return false;
749 };
750 let colors_stride = self
751 .colors_stride_shadow
752 .get(scene_idx)
753 .map_or(COLORS_PER_CHUNK_WORDS as usize, |&s| s as usize);
754
755 // Phase 1 — recompute every dirty column per mip into row-run
756 // buffers (rows are contiguous in both the occupancy layout and
757 // the packed colour block), verifying colour counts. NOTHING is
758 // written until the whole extent verifies.
759 struct RowRun {
760 /// Textured-occupancy word offset within the slot.
761 occ_word: usize,
762 /// Solid block sits `block_words` after the textured one.
763 block_words: usize,
764 occ: Vec<u32>,
765 solid: Vec<u32>,
766 /// Colour word offset within the slot's colour block.
767 color_word: usize,
768 colors: Vec<u32>,
769 }
770 let mut runs: Vec<RowRun> = Vec::new();
771 for m in 0..layout.mip_count {
772 let vsid_m = (meta.vsid >> m).max(1) as i32;
773 let cz_m = crate::decompress::CHUNK_Z >> m;
774 let wpc = occ_words_per_column_for_mip(m) as usize;
775 let block_words = (vsid_m as usize) * (vsid_m as usize) * wpc;
776 let rx0 = (x0 >> m).clamp(0, vsid_m - 1);
777 let ry0 = (y0 >> m).clamp(0, vsid_m - 1);
778 let rx1 = (x1 >> m).clamp(0, vsid_m - 1);
779 let ry1 = (y1 >> m).clamp(0, vsid_m - 1);
780 let coff_base = layout.mip_coff_rel[m as usize] as usize;
781 for y in ry0..=ry1 {
782 let row_col0 = (y * vsid_m + rx0) as usize;
783 let n_cols = (rx1 - rx0 + 1) as usize;
784 let mut occ = vec![0u32; n_cols * wpc];
785 let mut solid = vec![0u32; n_cols * wpc];
786 let mut colors: Vec<u32> = Vec::new();
787 for i in 0..n_cols {
788 let col_idx = row_col0 + i;
789 let slab = vxl.column_data_for_mip(m, col_idx);
790 let before = colors.len();
791 // vsid=1 / (0,0) → the column scratch windows index
792 // from word 0 of the per-column slices.
793 crate::decompress::decompress_column(
794 slab,
795 0,
796 0,
797 1,
798 cz_m,
799 wpc as u32,
800 &mut occ[i * wpc..(i + 1) * wpc],
801 &mut solid[i * wpc..(i + 1) * wpc],
802 &mut colors,
803 );
804 // Count stability vs the mirrored offsets table.
805 let old_count = offs_shadow[coff_base + col_idx + 1]
806 .saturating_sub(offs_shadow[coff_base + col_idx])
807 as usize;
808 if colors.len() - before != old_count {
809 return false; // reflow → full path
810 }
811 }
812 let color_word = offs_shadow[coff_base + row_col0] as usize;
813 if color_word + colors.len() > colors_stride {
814 return false; // stride overflow → full path handles
815 }
816 runs.push(RowRun {
817 occ_word: layout.mip_occ_rel[m as usize] as usize + row_col0 * wpc,
818 block_words,
819 occ,
820 solid,
821 color_word,
822 colors,
823 });
824 }
825 }
826
827 // Phase 2 — verified: write the row runs.
828 let occ_words_per_slot = layout.occ_words_per_slot as usize;
829 let slot_occ_base = meta.occupancy_offset as usize + slot_idx * occ_words_per_slot;
830 let page_words = self.occupancy_page_words as usize;
831 let page = slot_occ_base / page_words;
832 let slot_local_word = slot_occ_base % page_words;
833 let col_slot_base = meta.colors_offset as usize + slot_idx * colors_stride;
834 for run in &runs {
835 let tex = slot_local_word + run.occ_word;
836 queue.write_buffer(
837 &self.occupancy_pages[page],
838 (tex * 4) as u64,
839 bytemuck::cast_slice(&run.occ),
840 );
841 queue.write_buffer(
842 &self.occupancy_pages[page],
843 ((tex + run.block_words) * 4) as u64,
844 bytemuck::cast_slice(&run.solid),
845 );
846 if !run.colors.is_empty() {
847 queue.write_buffer(
848 &self.all_colors,
849 ((col_slot_base + run.color_word) * 4) as u64,
850 bytemuck::cast_slice(&run.colors),
851 );
852 }
853 }
854 // Counts unchanged ⇒ offsets, chunk-occupancy bit, AABB and the
855 // mirrors all stay valid untouched.
856 true
857 }
858
859 pub fn evict_chunk(
860 &mut self,
861 queue: &wgpu::Queue,
862 scene_idx: usize,
863 chunk_idx: [i32; 3],
864 ) -> bool {
865 let Some(meta) = self.static_meta.get(scene_idx).copied() else {
866 return false;
867 };
868 let slot_idx = modular_slot_idx(chunk_idx, meta.pool_dims);
869 // Only evict if this slot still claims to hold `chunk_idx`.
870 // Otherwise we'd be wiping out a different (newer) chunk
871 // that happens to share the slot.
872 let shadow_entry = self.slot_chunk_idx_shadow[scene_idx][slot_idx];
873 if shadow_entry[0] != chunk_idx[0]
874 || shadow_entry[1] != chunk_idx[1]
875 || shadow_entry[2] != chunk_idx[2]
876 {
877 return true;
878 }
879 self.set_chunk_occupancy_bit(queue, scene_idx, &meta, slot_idx, false);
880 self.set_slot_chunk_idx(queue, scene_idx, &meta, slot_idx, SLOT_EMPTY_SENTINEL);
881 // PF.12.c — drop the evicted slot's offsets mirror.
882 self.color_offsets_shadow[scene_idx].remove(&slot_idx);
883 // GPU.13.0 — eviction may shrink the occupied box; recompute.
884 self.sync_aabb(queue, scene_idx);
885 true
886 }
887
888 fn set_chunk_occupancy_bit(
889 &mut self,
890 queue: &wgpu::Queue,
891 scene_idx: usize,
892 meta: &GridStaticMeta,
893 slot_idx: usize,
894 new_bit: bool,
895 ) {
896 let word_idx = slot_idx >> 5;
897 let bit = slot_idx & 31;
898 let shadow = &mut self.chunk_occupancy_shadow[scene_idx][word_idx];
899 let was_bit = (*shadow >> bit) & 1 == 1;
900 if new_bit == was_bit {
901 return;
902 }
903 if new_bit {
904 *shadow |= 1u32 << bit;
905 } else {
906 *shadow &= !(1u32 << bit);
907 }
908 let global_word_idx = meta.chunk_occupancy_offset as usize + word_idx;
909 queue.write_buffer(
910 &self.all_chunk_occupancy,
911 (global_word_idx * 4) as u64,
912 bytemuck::bytes_of(shadow),
913 );
914 }
915
916 fn set_slot_chunk_idx(
917 &mut self,
918 queue: &wgpu::Queue,
919 scene_idx: usize,
920 meta: &GridStaticMeta,
921 slot_idx: usize,
922 chunk_idx: [i32; 3],
923 ) {
924 let entry = [chunk_idx[0], chunk_idx[1], chunk_idx[2], 0];
925 self.slot_chunk_idx_shadow[scene_idx][slot_idx] = entry;
926 let global_word_idx = meta.slot_chunk_idx_offset as usize + slot_idx * 4;
927 queue.write_buffer(
928 &self.all_slot_chunk_idx,
929 (global_word_idx * 4) as u64,
930 bytemuck::cast_slice(&entry),
931 );
932 }
933
934 /// GPU.13.0 — recompute the grid's occupied chunk-AABB from its
935 /// `slot_chunk_idx` shadow and, if it changed, patch the grid's
936 /// [`GridStaticMeta`] on the GPU. Cheap: scans `total_slots`
937 /// entries and writes 144 bytes only when the box actually moves
938 /// (steady-state re-bakes leave it unchanged → no GPU write).
939 /// Called after every install/eviction so streaming grids keep a
940 /// tight, always-conservative early-out box.
941 fn sync_aabb(&mut self, queue: &wgpu::Queue, scene_idx: usize) {
942 let (aabb_min, aabb_max) = aabb_of_slots(&self.slot_chunk_idx_shadow[scene_idx]);
943 let meta = &mut self.static_meta[scene_idx];
944 if meta.aabb_min == aabb_min && meta.aabb_max == aabb_max {
945 return;
946 }
947 meta.aabb_min = aabb_min;
948 meta.aabb_max = aabb_max;
949 let off = (scene_idx * std::mem::size_of::<GridStaticMeta>()) as u64;
950 queue.write_buffer(&self.grid_static_meta, off, bytemuck::bytes_of(meta));
951 }
952}
953
954/// GPU.13.0 — inclusive chunk-AABB over a grid's `slot_chunk_idx`
955/// shadow, skipping the [`SLOT_EMPTY_SENTINEL`] entries. Returns the
956/// inverted sentinel box (`min = i32::MAX`, `max = i32::MIN`) when no
957/// slot is occupied, which makes the shader's `aabb_passed` early-out
958/// fire for every ray (an empty grid renders nothing).
959fn aabb_of_slots(slots: &[[i32; 4]]) -> ([i32; 3], [i32; 3]) {
960 let mut min = [i32::MAX; 3];
961 let mut max = [i32::MIN; 3];
962 for e in slots {
963 if e[0] == SLOT_EMPTY_SENTINEL[0]
964 && e[1] == SLOT_EMPTY_SENTINEL[1]
965 && e[2] == SLOT_EMPTY_SENTINEL[2]
966 {
967 continue;
968 }
969 for k in 0..3 {
970 if e[k] < min[k] {
971 min[k] = e[k];
972 }
973 if e[k] > max[k] {
974 max[k] = e[k];
975 }
976 }
977 }
978 (min, max)
979}
980
981/// Modular slot index for `chunk_idx` given the grid's
982/// power-of-2 `pool_dims`. Negative `chunk_idx` components map via
983/// two's-complement bitwise AND, matching the shader's
984/// `chunk_idx & (pool_dims - 1)`.
985#[must_use]
986pub fn modular_slot_idx(chunk_idx: [i32; 3], pool_dims: [u32; 3]) -> usize {
987 let mask_x = (pool_dims[0] - 1) as i32;
988 let mask_y = (pool_dims[1] - 1) as i32;
989 let mask_z = (pool_dims[2] - 1) as i32;
990 let sx = (chunk_idx[0] & mask_x) as usize;
991 let sy = (chunk_idx[1] & mask_y) as usize;
992 let sz = (chunk_idx[2] & mask_z) as usize;
993 sx + sy * (pool_dims[0] as usize) + sz * (pool_dims[0] as usize) * (pool_dims[1] as usize)
994}
995
996/// Outcome of `GpuSceneResident::refresh_chunk`. Most callers
997/// can ignore the result; `ColorsTruncated` indicates the chunk's
998/// colour data overflowed the per-slot stride and was clipped.
999#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1000pub enum RefreshOutcome {
1001 Ok,
1002 /// The chunk's colour count exceeded `COLORS_PER_CHUNK_WORDS`;
1003 /// the GPU sees the first `stride` colours. Bump
1004 /// `COLORS_PER_CHUNK_WORDS` for content that hits this.
1005 ColorsTruncated,
1006 /// Retained for ABI compatibility; the GPU.7 modular pool no
1007 /// longer rejects chunks by bbox.
1008 ChunkOutOfBbox,
1009 /// `scene_idx` is past `grid_count`. Programming error.
1010 SceneIdxOob,
1011}
1012
1013fn create_storage(device: &wgpu::Device, label: &str, data: &[u32]) -> wgpu::Buffer {
1014 // GPU.6: include COPY_DST so `refresh_chunk` can `queue.write_buffer`
1015 // into existing slots without rebuilding the resident.
1016 device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1017 label: Some(label),
1018 contents: bytemuck::cast_slice(data),
1019 usage: wgpu::BufferUsages::STORAGE
1020 | wgpu::BufferUsages::COPY_DST
1021 | wgpu::BufferUsages::COPY_SRC,
1022 })
1023}
1024
1025/// Split the concatenated occupancy words into up to
1026/// [`MAX_OCC_PAGES`] storage buffers, each no larger than the
1027/// device's `max_storage_buffer_binding_size`, then pad the page
1028/// list with 1-word dummy buffers so the returned vec is always
1029/// exactly `MAX_OCC_PAGES` long (one buffer per bind-group entry).
1030///
1031/// `slot_align_words` is the per-slot occupancy stride: page size is
1032/// rounded down to a multiple of it so no chunk slot — and therefore
1033/// no per-slot `refresh_chunk` write — straddles a page boundary.
1034/// Returns `(pages, page_words, num_pages)`.
1035fn split_occupancy_pages(
1036 device: &wgpu::Device,
1037 words: &[u32],
1038 slot_align_words: u64,
1039) -> (Vec<wgpu::Buffer>, u32, u32) {
1040 let total_words = words.len() as u64;
1041 // wgpu 29 widened `max_storage_buffer_binding_size` to `u64`.
1042 let limit_words = device.limits().max_storage_buffer_binding_size / 4;
1043 // Largest slot-aligned page that fits one binding (≥ 1 slot).
1044 let page_slots = (limit_words / slot_align_words).max(1);
1045 let mut page_words = page_slots.saturating_mul(slot_align_words);
1046 // A tiny scene (or the empty-scene 1-word pad) isn't slot-aligned;
1047 // cap the page at the data length so we don't allocate emptiness.
1048 page_words = page_words.min(total_words.max(1));
1049 let num_pages = total_words.div_ceil(page_words);
1050 assert!(
1051 num_pages as usize <= MAX_OCC_PAGES,
1052 "occupancy needs {num_pages} pages (>{MAX_OCC_PAGES}) at this device's \
1053 {limit_words}-word binding limit; shrink the streaming pool or raise MAX_OCC_PAGES",
1054 );
1055
1056 let mut pages: Vec<wgpu::Buffer> = Vec::with_capacity(MAX_OCC_PAGES);
1057 let page_words_usize = page_words as usize;
1058 for p in 0..num_pages as usize {
1059 let start = p * page_words_usize;
1060 let end = ((p + 1) * page_words_usize).min(words.len());
1061 pages.push(create_storage(
1062 device,
1063 &format!("roxlap-gpu scene.occupancy.page{p}"),
1064 &words[start..end],
1065 ));
1066 }
1067 // Dummy 1-word buffers for the unused bindings.
1068 while pages.len() < MAX_OCC_PAGES {
1069 pages.push(create_storage(
1070 device,
1071 "roxlap-gpu scene.occupancy.page_dummy",
1072 &[0u32],
1073 ));
1074 }
1075 (
1076 pages,
1077 u32::try_from(page_words).expect("page_words fits u32"),
1078 num_pages as u32,
1079 )
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085
1086 #[test]
1087 fn grid_static_meta_matches_wgsl_std430_size() {
1088 // scene_dda.wgsl's GridStaticMeta is read as
1089 // array<GridStaticMeta>; the std430 array stride must equal
1090 // the Rust size_of or wgpu rejects the binding.
1091 // Concretely: 8 u32 (32) + vec3+pad (16) + 4 u32 (16) +
1092 // 2*[u32;6] (48) = 112, then GPU.13.0 adds two vec3<i32>+pad
1093 // (aabb_min, aabb_max) = 32 → 144 bytes.
1094 assert_eq!(std::mem::size_of::<GridStaticMeta>(), 144);
1095 assert_eq!(std::mem::align_of::<GridStaticMeta>(), 4);
1096 }
1097
1098 #[test]
1099 fn mip_layout_offsets_accumulate() {
1100 // vsid=128 → 6 mips. Relative offsets are cumulative; mip-0
1101 // sits at 0 so mip-0 reads are byte-identical to pre-mip.
1102 let l = MipLayout::for_vsid(128);
1103 assert_eq!(l.mip_count, 6);
1104 assert_eq!(l.mip_occ_rel[0], 0);
1105 assert_eq!(l.mip_coff_rel[0], 0);
1106
1107 // Recompute the strides independently and compare. Each mip
1108 // stores TWO occupancy bitmaps (textured + solid) back-to-back.
1109 let mut occ = 0u32;
1110 let mut coff = 0u32;
1111 for m in 0..6u32 {
1112 assert_eq!(l.mip_occ_rel[m as usize], occ, "occ rel mip {m}");
1113 assert_eq!(l.mip_coff_rel[m as usize], coff, "coff rel mip {m}");
1114 let v = 128u32 >> m;
1115 occ += 2 * v * v * occ_words_per_column_for_mip(m);
1116 coff += v * v + 1;
1117 }
1118 assert_eq!(l.occ_words_per_slot, occ);
1119 assert_eq!(l.offsets_words_per_slot, coff);
1120
1121 // mip-0 occupancy stride is 2 × the historical vsid²·8 (tex +
1122 // solid bitmaps).
1123 assert_eq!(l.mip_occ_rel[1], 2 * 128 * 128 * 8);
1124 // The whole ladder is only ~1/7 larger than mip-0 alone
1125 // (geometric 1 + 1/8 + 1/64 + …) — here on the doubled base.
1126 assert!(l.occ_words_per_slot < 2 * 128 * 128 * 8 * 5 / 4);
1127 }
1128}