roxlap_gpu/sprite_model.rs
1//! GPU.10 — KV6 sprite as a DDA-marchable voxel model.
2//!
3//! Unlike the GPU.9 splatter (one thread per voxel, screen-space
4//! squares, overdraw + atomic contention), a sprite model is a small
5//! voxel volume the precise ray-DDA marches one ray per pixel —
6//! crisp, correct occlusion, no overdraw. This is the GPU.10.0 single
7//! sprite; instancing + tiling + LOD come in later sub-substages.
8//!
9//! The volume reuses the chunk occupancy/colour scheme but sized to
10//! the KV6 bbox: per-column occupancy bitmask (`occ_words_per_col`
11//! u32s, `CHUNK_Z`-style 32-bits-per-word), a flat colour array in
12//! ascending-z order per column, and a `color_offsets` prefix table.
13//! The shader finds a voxel's colour by `offset[col] + popcount(bits
14//! below z)`, so colours MUST be ascending-z (we sort per column).
15
16#![allow(
17 clippy::cast_precision_loss,
18 clippy::cast_possible_truncation,
19 clippy::cast_possible_wrap,
20 clippy::cast_sign_loss,
21 clippy::many_single_char_names,
22 clippy::similar_names
23)]
24
25use bytemuck::{Pod, Zeroable};
26use roxlap_formats::color::Rgb;
27use roxlap_formats::kv6::Kv6;
28use roxlap_formats::material::material_for_color;
29use roxlap_formats::sprite::Sprite;
30use roxlap_formats::voxel_clip::{DecodedClip, VoxelFrame};
31
32/// CPU-built voxel volume for one KV6 model.
33#[derive(Debug, Clone)]
34pub struct SpriteModel {
35 /// Voxel extent `(mx, my, mz)`.
36 pub dims: [u32; 3],
37 /// `ceil(mz / 32)` — u32 words of occupancy per (x, y) column.
38 pub occ_words_per_col: u32,
39 /// KV6 pivot in model-local voxel space.
40 pub pivot: [f32; 3],
41 /// Per-column occupancy bitmask, `mx * my * occ_words_per_col`.
42 pub occupancy: Vec<u32>,
43 /// Voxel colours, ascending z within each column.
44 pub colors: Vec<u32>,
45 /// Per-voxel surface-normal index (`Kv6::Voxel::dir`, 0..256),
46 /// parallel to [`colors`](Self::colors). The GPU sprite shader uses
47 /// it to index the per-instance `kv6colmul` lighting table, matching
48 /// the CPU rasteriser's normal-based shading.
49 pub dirs: Vec<u32>,
50 /// Prefix sums: `color_offsets[col]` is the first colour index of
51 /// column `col`; length `mx * my + 1`.
52 pub color_offsets: Vec<u32>,
53 /// Per-voxel material id (TV.3), parallel to [`colors`](Self::colors).
54 /// **Empty** means the model has no per-voxel materials — every voxel
55 /// uses the instance's uniform material (the TV.1/TV.2 path). A non-empty
56 /// array gives mixed-material models (opaque frame + glass). Built by
57 /// [`build_sprite_model_with_materials`].
58 pub materials: Vec<u8>,
59 /// World-space size of one voxel of this model (GPU.10.4 LOD): 1.0
60 /// at mip-0, doubling each [`SpriteModel::downsample`]. The shader
61 /// divides the local ray by this so a coarse voxel spans the right
62 /// world extent and the march `t` stays in world units.
63 pub voxel_world_size: f32,
64}
65
66/// Build the DDA volume from a KV6. Columns are packed in
67/// `x + y*mx` order; each column's voxels are sorted ascending by z
68/// so the shader's popcount-rank colour lookup is correct.
69///
70/// # Panics
71/// If the KV6's `ylen` counters disagree with `voxels.len()` (a
72/// malformed model).
73#[must_use]
74pub fn build_sprite_model(kv6: &Kv6) -> SpriteModel {
75 build_sprite_model_inner(kv6, &[])
76}
77
78/// Build the DDA volume from a KV6, classifying each voxel into a per-voxel
79/// **material id** by colour (TV.3 mixed models) via `material_map`
80/// (`(rgb, material_id)` pairs; see
81/// [`material_for_color`]).
82/// An empty map produces a model with no per-voxel materials (identical to
83/// [`build_sprite_model`]).
84///
85/// # Panics
86/// As [`build_sprite_model`].
87#[must_use]
88pub fn build_sprite_model_with_materials(kv6: &Kv6, material_map: &[(Rgb, u8)]) -> SpriteModel {
89 build_sprite_model_inner(kv6, material_map)
90}
91
92fn build_sprite_model_inner(kv6: &Kv6, material_map: &[(Rgb, u8)]) -> SpriteModel {
93 let (mx, my, mz) = (kv6.xsiz, kv6.ysiz, kv6.zsiz);
94 let occ_words_per_col = mz.div_ceil(32).max(1);
95 let cols = (mx * my) as usize;
96 let want_mats = !material_map.is_empty();
97
98 let mut occupancy = vec![0u32; cols * occ_words_per_col as usize];
99 let mut color_offsets = vec![0u32; cols + 1];
100 let mut colors: Vec<u32> = Vec::with_capacity(kv6.voxels.len());
101 let mut dirs: Vec<u32> = Vec::with_capacity(kv6.voxels.len());
102 let mut materials: Vec<u8> = if want_mats {
103 Vec::with_capacity(kv6.voxels.len())
104 } else {
105 Vec::new()
106 };
107
108 // Pass 1 — consume voxels in KV6 storage order (x-outer / y-inner)
109 // into per-column buckets keyed by `col = x + y*mx`. Each entry is
110 // `(z, colour, normal-dir)`.
111 let mut buckets: Vec<Vec<(u16, u32, u8)>> = vec![Vec::new(); cols];
112 let mut voxel_iter = kv6.voxels.iter();
113 for x in 0..mx {
114 for y in 0..my {
115 let col = (x + y * mx) as usize;
116 let count = kv6.ylen[x as usize][y as usize];
117 for _ in 0..count {
118 let v = voxel_iter.next().expect("KV6 ylen / voxels.len mismatch");
119 buckets[col].push((v.z, v.col, v.dir));
120 }
121 }
122 }
123
124 // Pass 2 — emit in COLUMN-INDEX order so `color_offsets` is a true
125 // monotonic prefix sum (the shader indexes by `col` either way, but
126 // structural edits / mip rebuilds rely on monotonic offsets). Each
127 // column's voxels sorted ascending z for the popcount-rank lookup.
128 for (col, bucket) in buckets.iter_mut().enumerate() {
129 color_offsets[col] = colors.len() as u32;
130 bucket.sort_by_key(|(z, _, _)| *z);
131 for &(z, col_rgba, dir) in bucket.iter() {
132 let z = u32::from(z);
133 let base = col * occ_words_per_col as usize + (z >> 5) as usize;
134 occupancy[base] |= 1u32 << (z & 31);
135 colors.push(col_rgba);
136 dirs.push(u32::from(dir));
137 if want_mats {
138 materials.push(material_for_color(material_map, col_rgba));
139 }
140 }
141 }
142 color_offsets[cols] = colors.len() as u32;
143
144 SpriteModel {
145 dims: [mx, my, mz],
146 occ_words_per_col,
147 pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
148 occupancy,
149 color_offsets,
150 colors,
151 dirs,
152 materials,
153 voxel_world_size: 1.0,
154 }
155}
156
157/// Build a [`SpriteModel`] directly from a decoded voxel-clip frame
158/// (VCL.2). The [`VoxelFrame`] dense-column layout is byte-for-byte the
159/// [`SpriteModel`] layout that [`build_sprite_model`] produces, so this is
160/// a field move — no per-column bucket-sort. `dirs` is the frame's
161/// surface-normal LUT indices (from [`DecodedClip::dirs`]), parallel to
162/// `frame.colors`.
163///
164/// # Panics
165/// In debug, if `dirs.len() != frame.colors.len()` or the field shapes
166/// don't match `dims` (the same invariants [`build_sprite_model`] upholds).
167#[must_use]
168pub fn sprite_model_from_voxel_frame(
169 frame: &VoxelFrame,
170 dirs: &[u32],
171 dims: [u32; 3],
172 pivot: [f32; 3],
173 voxel_world_size: f32,
174) -> SpriteModel {
175 sprite_model_from_voxel_frame_with_materials(frame, dirs, dims, pivot, voxel_world_size, &[])
176}
177
178/// Like [`sprite_model_from_voxel_frame`] but classifies each voxel into a
179/// per-voxel **material id** by colour (TV.3 mixed models) via `material_map`
180/// (`(rgb, material_id)` pairs). An empty map produces a model with no
181/// per-voxel materials (identical to [`sprite_model_from_voxel_frame`]).
182///
183/// # Panics
184/// As [`sprite_model_from_voxel_frame`].
185#[must_use]
186pub fn sprite_model_from_voxel_frame_with_materials(
187 frame: &VoxelFrame,
188 dirs: &[u32],
189 dims: [u32; 3],
190 pivot: [f32; 3],
191 voxel_world_size: f32,
192 material_map: &[(Rgb, u8)],
193) -> SpriteModel {
194 let occ_words_per_col = dims[2].div_ceil(32).max(1);
195 let cols = (dims[0] * dims[1]) as usize;
196 debug_assert_eq!(frame.occupancy.len(), cols * occ_words_per_col as usize);
197 debug_assert_eq!(frame.color_offsets.len(), cols + 1);
198 debug_assert_eq!(dirs.len(), frame.colors.len());
199 // Per-voxel materials are parallel to `colors` (popcount-rank order), so
200 // classify the frame's colour run directly — no re-index needed.
201 let materials: Vec<u8> = if material_map.is_empty() {
202 Vec::new()
203 } else {
204 frame
205 .colors
206 .iter()
207 .map(|&c| material_for_color(material_map, c))
208 .collect()
209 };
210 SpriteModel {
211 dims,
212 occ_words_per_col,
213 pivot,
214 occupancy: frame.occupancy.clone(),
215 colors: frame.colors.clone(),
216 dirs: dirs.to_vec(),
217 color_offsets: frame.color_offsets.clone(),
218 materials,
219 voxel_world_size,
220 }
221}
222
223/// Build the [`SpriteModel`] for frame `frame` of a decoded clip — the
224/// per-frame model uploaded into a flipbook chain (VCL.2).
225///
226/// # Panics
227/// If `frame` is out of range, or the frame fails the layout invariants.
228#[must_use]
229pub fn sprite_model_from_clip_frame(clip: &DecodedClip, frame: usize) -> SpriteModel {
230 sprite_model_from_clip_frame_with_materials(clip, frame, &[])
231}
232
233/// Like [`sprite_model_from_clip_frame`] but classifies the frame's voxels
234/// into per-voxel material ids by colour (TV.3 mixed models) via
235/// `material_map`. An empty map is identical to [`sprite_model_from_clip_frame`].
236///
237/// # Panics
238/// If `frame` is out of range, or the frame fails the layout invariants.
239#[must_use]
240pub fn sprite_model_from_clip_frame_with_materials(
241 clip: &DecodedClip,
242 frame: usize,
243 material_map: &[(Rgb, u8)],
244) -> SpriteModel {
245 sprite_model_from_voxel_frame_with_materials(
246 &clip.frames[frame],
247 &clip.dirs[frame],
248 clip.dims,
249 clip.pivot,
250 clip.voxel_world_size,
251 material_map,
252 )
253}
254
255/// Per-instance transform consumed by the model-DDA shader: the
256/// inverse model→world rotation (so a world ray can be brought into
257/// model-local space) plus the instance's world position. Stored as
258/// three padded columns for std140/std430 (`mat3x3` 16-byte columns).
259#[repr(C)]
260#[derive(Clone, Copy, Pod, Zeroable, Debug)]
261pub struct SpriteInstanceTransform {
262 /// Inverse of `[s | h | f]`, column-major, each column padded to
263 /// `vec4`. `inv_rot * v = c0*v.x + c1*v.y + c2*v.z`.
264 pub inv_rot: [[f32; 4]; 3],
265 /// Instance world position (the KV6 pivot maps here).
266 pub pos: [f32; 3],
267 /// Longest model→world basis column length (PS.1) — `1.0` for the
268 /// orthonormal poses every pre-PS caller uses. The CPU cull
269 /// multiplies the model's unit-basis [`SpriteModel::bound_radius`]
270 /// by it (exact for rotation × uniform-or-per-axis scale; a
271 /// sheared basis can still exceed it, which nothing produces
272 /// today). Rides the former std430 pad slot, so the GPU layout is
273 /// unchanged.
274 pub max_scale: f32,
275}
276
277impl SpriteInstanceTransform {
278 /// Build from a sprite pose. `s/h/f` are the model→world basis
279 /// columns; we invert them so the shader can map world→local, and
280 /// keep the longest column length for cull-sphere / LOD scaling.
281 #[must_use]
282 pub fn from_sprite(sprite: &Sprite) -> Self {
283 let inv = mat3_inverse([sprite.s, sprite.h, sprite.f]);
284 let len = |c: [f32; 3]| (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt();
285 Self {
286 inv_rot: [
287 [inv[0][0], inv[0][1], inv[0][2], 0.0],
288 [inv[1][0], inv[1][1], inv[1][2], 0.0],
289 [inv[2][0], inv[2][1], inv[2][2], 0.0],
290 ],
291 pos: sprite.p,
292 max_scale: len(sprite.s).max(len(sprite.h)).max(len(sprite.f)),
293 }
294 }
295}
296
297/// A registry of sprite models. Instances reference a model by
298/// `model_id`, which is a **LOD chain** id: each chain holds one or
299/// more concrete mip levels (finest first; GPU.10.4), and the renderer
300/// picks the level per instance by distance. Identical KV6s are added
301/// once and shared by many instances. **Copy-on-modify**:
302/// [`Self::fork`] deep-copies a chain so edits to the fork leave the
303/// parent (and its instances) intact.
304#[derive(Debug, Clone, Default)]
305pub struct SpriteModelRegistry {
306 /// Concrete mip-level volumes (the GPU buffers concatenate these).
307 entries: Vec<SpriteModel>,
308 /// `chains[model_id]` = entry ids, finest (mip-0) first.
309 chains: Vec<Vec<u32>>,
310}
311
312impl SpriteModelRegistry {
313 /// An empty registry (no models, no chains) — equivalent to
314 /// [`Default::default`]. Populate via [`Self::add`] / [`Self::add_lod`].
315 #[must_use]
316 pub fn new() -> Self {
317 Self::default()
318 }
319
320 fn push_entry(&mut self, model: SpriteModel) -> u32 {
321 let id = self.entries.len() as u32;
322 self.entries.push(model);
323 id
324 }
325
326 /// Register a single-level (no-LOD) model; returns its `model_id`.
327 pub fn add(&mut self, model: SpriteModel) -> u32 {
328 let e = self.push_entry(model);
329 let id = self.chains.len() as u32;
330 self.chains.push(vec![e]);
331 id
332 }
333
334 /// Register a model with up to `max_levels` LOD mips (each a 2×
335 /// [`SpriteModel::downsample`] of the previous; stops early once a
336 /// level collapses to 1³). Returns its `model_id`.
337 pub fn add_lod(&mut self, model: SpriteModel, max_levels: u32) -> u32 {
338 let mut levels = vec![self.push_entry(model.clone())];
339 let mut cur = model;
340 for _ in 1..max_levels.max(1) {
341 if cur.dims == [1, 1, 1] {
342 break;
343 }
344 cur = cur.downsample();
345 levels.push(self.push_entry(cur.clone()));
346 }
347 let id = self.chains.len() as u32;
348 self.chains.push(levels);
349 id
350 }
351
352 /// Copy-on-modify: deep-copy every level of chain `parent` into new
353 /// entries + a new chain, and return its `model_id`. The fork owns
354 /// independent voxel data, so mutating it does not affect the
355 /// parent or any instance still pointing at it.
356 ///
357 /// # Panics
358 /// If `parent` is not a registered `model_id`.
359 pub fn fork(&mut self, parent: u32) -> u32 {
360 let src = self.chains[parent as usize].clone();
361 let levels: Vec<u32> = src
362 .iter()
363 .map(|&e| {
364 let copy = self.entries[e as usize].clone();
365 self.push_entry(copy)
366 })
367 .collect();
368 let id = self.chains.len() as u32;
369 self.chains.push(levels);
370 id
371 }
372
373 /// The finest (mip-0) model of chain `id`.
374 #[must_use]
375 pub fn model(&self, id: u32) -> &SpriteModel {
376 &self.entries[self.chains[id as usize][0] as usize]
377 }
378
379 /// Like [`Self::model`] but returns `None` for an out-of-range or
380 /// tombstoned (emptied) chain instead of panicking — the guarded form
381 /// for public primitives handed an arbitrary `chain_id`.
382 #[must_use]
383 pub fn model_checked(&self, id: u32) -> Option<&SpriteModel> {
384 let entry = *self.chains.get(id as usize)?.first()?;
385 self.entries.get(entry as usize)
386 }
387
388 /// Mutable access to the finest (mip-0) model for editing — the
389 /// copy-on-modify entry point (typically on a [`Self::fork`]).
390 /// After a *structural* edit (occupancy/dims), call
391 /// [`Self::rebuild_lod`] so the coarser mips match; a pure recolour
392 /// can use [`Self::recolor_chain`] instead.
393 pub fn model_mut(&mut self, id: u32) -> &mut SpriteModel {
394 let e = self.chains[id as usize][0] as usize;
395 &mut self.entries[e]
396 }
397
398 /// Recolour every LOD level of chain `id` (so a forked tint shows
399 /// at all distances).
400 pub fn recolor_chain(&mut self, id: u32, f: impl Fn(u32) -> u32 + Copy) {
401 for li in 0..self.chains[id as usize].len() {
402 let e = self.chains[id as usize][li] as usize;
403 self.entries[e].recolor(f);
404 }
405 }
406
407 /// Regenerate chain `id`'s coarser mip levels from its (possibly
408 /// just-edited) mip-0. Run after a structural edit via
409 /// [`Self::model_mut`] so the LOD ladder stays consistent. No-op
410 /// for a single-level (no-LOD) chain.
411 pub fn rebuild_lod(&mut self, id: u32) {
412 let levels = self.chains[id as usize].clone();
413 if levels.len() <= 1 {
414 return;
415 }
416 let mut cur = self.entries[levels[0] as usize].clone();
417 for &e in &levels[1..] {
418 cur = cur.downsample();
419 self.entries[e as usize] = cur.clone();
420 }
421 }
422
423 /// Free chain `chain_id`'s voxel data **in place**: replace each of
424 /// its LOD entries with [`SpriteModel::empty`] and clear the chain.
425 /// Entry ids and every other `model_id` are **preserved** (the chain
426 /// becomes empty, its entries become placeholders), so no id remap is
427 /// needed and the resident registry's entry alignment stays intact.
428 ///
429 /// This is safe to pair with the resident side because
430 /// [`SpriteRegistryResident::remove_model`] tombstones the same
431 /// entries (`dead[e]`) and [`compact`](SpriteRegistryResident::compact)
432 /// reads only live entries — so the resident never touches the empty
433 /// placeholders left here. Call `remove_model` (resident) **before**
434 /// this so those tombstones are set. No-op if `chain_id` is out of
435 /// range or already removed.
436 pub fn remove(&mut self, chain_id: u32) {
437 let Some(entries) = self.chains.get(chain_id as usize) else {
438 return;
439 };
440 // Clone the small id list so we can mutate `entries` while iterating.
441 let entries = entries.clone();
442 for e in entries {
443 self.entries[e as usize] = SpriteModel::empty();
444 }
445 self.chains[chain_id as usize] = Vec::new(); // tombstone (slot kept)
446 }
447
448 /// Whether `chain_id` is a live (registered, not [`removed`](Self::remove))
449 /// model. `false` for an out-of-range id or a tombstoned chain.
450 #[must_use]
451 pub fn is_live(&self, chain_id: u32) -> bool {
452 self.chains
453 .get(chain_id as usize)
454 .is_some_and(|c| !c.is_empty())
455 }
456
457 /// Number of LOD chains (distinct `model_id`s). Counts tombstoned
458 /// (removed) chains too — ids are never reused, so this is also the
459 /// next id that [`Self::add`] / [`Self::add_lod`] will mint.
460 #[must_use]
461 pub fn len(&self) -> usize {
462 self.chains.len()
463 }
464
465 /// `true` iff no chain was ever registered (`len() == 0`). Note a
466 /// registry whose every chain has been [`removed`](Self::remove) is
467 /// **not** empty by this test — tombstoned ids still count.
468 #[must_use]
469 pub fn is_empty(&self) -> bool {
470 self.chains.is_empty()
471 }
472}
473
474impl SpriteModel {
475 /// An empty (zero-voxel, zero-extent) placeholder model. Used by
476 /// [`SpriteModelRegistry::remove`] to free a removed chain's voxel
477 /// data while keeping its entry slot, so ids stay stable. Carries no
478 /// occupancy/colours; `color_offsets` is the single-element prefix
479 /// `[0]` (`cols + 1` with `cols == 0`), keeping the structural
480 /// invariant intact for any code that inspects it.
481 #[must_use]
482 pub fn empty() -> Self {
483 Self {
484 dims: [0, 0, 0],
485 occ_words_per_col: 1,
486 pivot: [0.0, 0.0, 0.0],
487 occupancy: Vec::new(),
488 colors: Vec::new(),
489 dirs: Vec::new(),
490 color_offsets: vec![0],
491 materials: Vec::new(),
492 voxel_world_size: 1.0,
493 }
494 }
495
496 /// Recolour every voxel via `f(old_rgba) -> new_rgba`. Structure
497 /// (occupancy / offsets) is untouched, so this is a cheap in-place
498 /// edit — handy on a [`SpriteModelRegistry::fork`] to make a tinted
499 /// variant. For structural edits, mutate the public occupancy /
500 /// colours / dims directly (via `model_mut`) then rebuild the LOD.
501 pub fn recolor(&mut self, f: impl Fn(u32) -> u32) {
502 for c in &mut self.colors {
503 *c = f(*c);
504 }
505 }
506
507 /// GPU.12 — structural edit of a single voxel within the model's
508 /// existing bounds. `Some(rgba)` sets/replaces the voxel at
509 /// `(x, y, z)`; `None` clears it. Maintains the ascending-z colour
510 /// invariant by inserting/removing at the voxel's popcount rank and
511 /// shifting the affected columns' `color_offsets`. Returns `true`
512 /// if the model changed. Out-of-bounds coordinates are ignored
513 /// (returns `false`) — growing `dims` is a separate concern.
514 ///
515 /// After editing, call [`SpriteModelRegistry::rebuild_lod`] to
516 /// refresh coarser mips, then re-upload via `set_sprite_instances`.
517 pub fn set_voxel(&mut self, x: u32, y: u32, z: u32, color: Option<u32>) -> bool {
518 if x >= self.dims[0] || y >= self.dims[1] || z >= self.dims[2] {
519 return false;
520 }
521 let owpc = self.occ_words_per_col as usize;
522 let cols = (self.dims[0] * self.dims[1]) as usize;
523 let col = (x + y * self.dims[0]) as usize;
524 let base = col * owpc;
525 let zw = (z >> 5) as usize;
526 let zb = z & 31;
527
528 // Rank = solid voxels strictly below z in this column.
529 let mut rank = 0usize;
530 for w in 0..zw {
531 rank += self.occupancy[base + w].count_ones() as usize;
532 }
533 let below_mask = if zb > 0 { (1u32 << zb) - 1 } else { 0 };
534 rank += (self.occupancy[base + zw] & below_mask).count_ones() as usize;
535 let idx = self.color_offsets[col] as usize + rank;
536 let was_set = (self.occupancy[base + zw] >> zb) & 1 == 1;
537
538 if let Some(rgba) = color {
539 if was_set {
540 self.colors[idx] = rgba; // replace in place (keeps dir)
541 } else {
542 self.occupancy[base + zw] |= 1u32 << zb;
543 self.colors.insert(idx, rgba);
544 // No normal supplied by this API — default to dir 0 (the
545 // sole caller, the carve hotkey, only ever clears).
546 self.dirs.insert(idx, 0);
547 if !self.materials.is_empty() {
548 self.materials.insert(idx, 0); // new voxel → opaque material
549 }
550 for c in &mut self.color_offsets[col + 1..=cols] {
551 *c += 1;
552 }
553 }
554 true
555 } else {
556 if !was_set {
557 return false;
558 }
559 self.occupancy[base + zw] &= !(1u32 << zb);
560 self.colors.remove(idx);
561 self.dirs.remove(idx);
562 if !self.materials.is_empty() {
563 self.materials.remove(idx);
564 }
565 for c in &mut self.color_offsets[col + 1..=cols] {
566 *c -= 1;
567 }
568 true
569 }
570 }
571
572 /// Radius of a bounding sphere centred at the instance position
573 /// (the pivot maps there): the farthest bbox corner from the
574 /// pivot, in **model units** (a unit basis). The cull multiplies
575 /// it by each instance's longest basis column
576 /// ([`SpriteInstanceTransform::max_scale`], PS.1), so scaled
577 /// instances stay conservatively bounded.
578 #[must_use]
579 pub fn bound_radius(&self) -> f32 {
580 let mut r2 = 0.0_f32;
581 for &cx in &[0.0, self.dims[0] as f32] {
582 for &cy in &[0.0, self.dims[1] as f32] {
583 for &cz in &[0.0, self.dims[2] as f32] {
584 let d = [cx - self.pivot[0], cy - self.pivot[1], cz - self.pivot[2]];
585 r2 = r2.max(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
586 }
587 }
588 }
589 r2.sqrt()
590 }
591
592 /// GPU.10.4 — 2× voxel downsample for the next LOD level. A coarse
593 /// voxel is solid if any of its 2×2×2 fine voxels is, coloured by
594 /// their per-channel average. Dims/pivot halve and
595 /// `voxel_world_size` doubles, so the coarse model occupies the
596 /// same world box at half the resolution (origin-corner aligned).
597 #[must_use]
598 #[allow(clippy::manual_checked_ops)] // `n > 0` guards 4 divisions, not one checked_div
599 pub fn downsample(&self) -> SpriteModel {
600 let [fx, fy, fz] = self.dims;
601 let fidx = |x: u32, y: u32, z: u32| (x + y * fx + z * fx * fy) as usize;
602
603 // Reconstruct dense fine voxels (solid flag + colour + normal + TV
604 // material).
605 let has_mats = !self.materials.is_empty();
606 let mut solid = vec![false; (fx * fy * fz) as usize];
607 let mut fine = vec![0u32; (fx * fy * fz) as usize];
608 let mut fine_dir = vec![0u32; (fx * fy * fz) as usize];
609 let mut fine_mat = vec![0u8; (fx * fy * fz) as usize];
610 for x in 0..fx {
611 for y in 0..fy {
612 let col = (x + y * fx) as usize;
613 let base = col * self.occ_words_per_col as usize;
614 let off = self.color_offsets[col] as usize;
615 let mut seen = 0usize;
616 for z in 0..fz {
617 let w = base + (z >> 5) as usize;
618 if (self.occupancy[w] >> (z & 31)) & 1 == 1 {
619 fine[fidx(x, y, z)] = self.colors[off + seen];
620 fine_dir[fidx(x, y, z)] = self.dirs[off + seen];
621 if has_mats {
622 fine_mat[fidx(x, y, z)] = self.materials[off + seen];
623 }
624 solid[fidx(x, y, z)] = true;
625 seen += 1;
626 }
627 }
628 }
629 }
630
631 let nx = fx.div_ceil(2).max(1);
632 let ny = fy.div_ceil(2).max(1);
633 let nz = fz.div_ceil(2).max(1);
634 let owpc = nz.div_ceil(32).max(1);
635 let cols = (nx * ny) as usize;
636 let mut occupancy = vec![0u32; cols * owpc as usize];
637 let mut color_offsets = vec![0u32; cols + 1];
638 let mut colors: Vec<u32> = Vec::new();
639 let mut dirs: Vec<u32> = Vec::new();
640 let mut materials: Vec<u8> = Vec::new();
641
642 // Emit in column-index order (`ccol = cx + cy*nx`), cy outer,
643 // so `color_offsets` is a monotonic prefix sum like build's.
644 for cy in 0..ny {
645 for cx in 0..nx {
646 let ccol = (cx + cy * nx) as usize;
647 color_offsets[ccol] = colors.len() as u32;
648 for cz in 0..nz {
649 let (mut a, mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32, 0u32);
650 // Normals + materials don't average meaningfully — keep
651 // the first solid child's `dir` / material for the coarse
652 // voxel.
653 let mut rep_dir = 0u32;
654 let mut rep_mat = 0u8;
655 for dz in 0..2 {
656 for dy in 0..2 {
657 for dx in 0..2 {
658 let (x, y, z) = (2 * cx + dx, 2 * cy + dy, 2 * cz + dz);
659 if x < fx && y < fy && z < fz && solid[fidx(x, y, z)] {
660 let c = fine[fidx(x, y, z)];
661 if n == 0 {
662 rep_dir = fine_dir[fidx(x, y, z)];
663 rep_mat = fine_mat[fidx(x, y, z)];
664 }
665 a += (c >> 24) & 0xff;
666 r += (c >> 16) & 0xff;
667 g += (c >> 8) & 0xff;
668 b += c & 0xff;
669 n += 1;
670 }
671 }
672 }
673 }
674 if n > 0 {
675 let avg = ((a / n) << 24) | ((r / n) << 16) | ((g / n) << 8) | (b / n);
676 let base = ccol * owpc as usize + (cz >> 5) as usize;
677 occupancy[base] |= 1u32 << (cz & 31);
678 colors.push(avg);
679 dirs.push(rep_dir);
680 if has_mats {
681 materials.push(rep_mat);
682 }
683 }
684 }
685 }
686 }
687 color_offsets[cols] = colors.len() as u32;
688
689 SpriteModel {
690 dims: [nx, ny, nz],
691 occ_words_per_col: owpc,
692 pivot: [
693 self.pivot[0] * 0.5,
694 self.pivot[1] * 0.5,
695 self.pivot[2] * 0.5,
696 ],
697 occupancy,
698 colors,
699 dirs,
700 color_offsets,
701 materials,
702 voxel_world_size: self.voxel_world_size * 2.0,
703 }
704 }
705}
706
707/// View frustum for CPU instance culling, in world space. Built each
708/// frame from the world camera. `half_w`/`half_h` are the tangents of
709/// the half-FOV (so the side planes are `|x| <= half_w * z` etc. in
710/// camera space).
711#[derive(Clone, Copy, Debug)]
712pub struct ViewFrustum {
713 /// Eye position, world voxel units.
714 pub pos: [f32; 3],
715 /// Unit basis toward screen-right (right-handed with `down`/`forward`).
716 pub right: [f32; 3],
717 /// Unit basis toward screen-down (+z is down in voxlap space).
718 pub down: [f32; 3],
719 /// Unit view direction; the near side of the frustum is the plane
720 /// `z = 0` in this camera space.
721 pub forward: [f32; 3],
722 /// `tan(fov_x / 2)`: a camera-space point is inside the side planes
723 /// when `|x| <= half_w * z`.
724 pub half_w: f32,
725 /// `tan(fov_y / 2)`: inside the top/bottom planes when
726 /// `|y| <= half_h * z`.
727 pub half_h: f32,
728 /// Far-plane distance along `forward`, world units — instances whose
729 /// bounding sphere lies wholly beyond it are culled.
730 pub far: f32,
731}
732
733/// CPU cull record: the GPU instance + its world bounding sphere.
734/// Not `Copy` — carries a boxed 256-entry `kv6colmul` table.
735#[derive(Clone)]
736struct CullInstance {
737 /// Instance transform + a placeholder `model_id`; the cull
738 /// overwrites `model_id` with the distance-chosen LOD entry.
739 gpu: SpriteInstanceGpu,
740 /// LOD chain this instance draws (the user-facing `model_id`).
741 chain_id: u32,
742 center: [f32; 3],
743 /// World-space bounding-sphere radius — the cached product
744 /// `model_radius × max_scale`, kept so the hot cull loop reads one
745 /// float (PS.1).
746 radius: f32,
747 /// The chain's unit-basis [`SpriteModel::bound_radius`], reseeded
748 /// by [`SpriteRegistryResident::set_instance_model`].
749 model_radius: f32,
750 /// Longest basis column of the current pose (PS.1) — scaled
751 /// instances (particles) grow/shrink `radius` and the LOD pick
752 /// with it.
753 max_scale: f32,
754 /// voxlap `kv6colmul[256]` — per-surface-normal colour modulation
755 /// for this instance's pose + lighting. Defaults to identity
756 /// (`0x0100` in every channel lane → unshaded) until the facade sets
757 /// it via [`SpriteRegistryResident::set_instance_colmul`]. Packed
758 /// into the `colmul` GPU buffer (in visible order) each frame.
759 colmul: Box<[u64; 256]>,
760}
761
762/// Identity `kv6colmul` table: every channel lane = `0x0100`, so the
763/// shader's `(rgb[c] << 8) * 0x0100 >> 16 == rgb[c]` — i.e. no shading.
764fn identity_colmul() -> Box<[u64; 256]> {
765 const LANE: u64 = 0x0100;
766 let w = LANE | (LANE << 16) | (LANE << 32) | (LANE << 48);
767 Box::new([w; 256])
768}
769
770fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
771 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
772}
773
774/// CA.4 — one clipped grid's cutaway volume for the sprite cull: the
775/// **footprint rule**. An instance whose origin, mapped into the grid's
776/// local voxel frame, lands inside the grid's XY chunk footprint with
777/// `z < z_clip` is hidden this frame — dropped from the visible set,
778/// which also stops it casting sprite shadows (the shadow pass marches
779/// the culled visible set). Instances outside the footprint are never
780/// affected. Built per frame by `SceneRenderer::render_scene` from the
781/// per-grid world transforms + the resident chunk AABBs.
782#[derive(Clone, Copy, PartialEq)]
783pub struct SpriteCutawayClip {
784 /// Grid world origin.
785 pub origin: [f32; 3],
786 /// World→local rotation rows: `local[i] = dot(inv_rows[i], w - origin)`
787 /// (= the grid's local→world rotation columns, reused as rows).
788 pub inv_rows: [[f32; 3]; 3],
789 /// `1 / voxel_world_size` — world offsets → voxel coords.
790 pub inv_vws: f32,
791 /// XY footprint `[lo, hi)` of the grid's resident chunks, voxel coords.
792 pub xy_lo: [f32; 2],
793 /// See [`Self::xy_lo`].
794 pub xy_hi: [f32; 2],
795 /// The clip plane ([`crate::GridWorldTransform::z_clip`]), voxel z.
796 pub z_clip: f32,
797}
798
799impl SpriteCutawayClip {
800 /// Whether the footprint rule hides a world-space point.
801 fn hides(&self, p: [f32; 3]) -> bool {
802 let rel = [
803 p[0] - self.origin[0],
804 p[1] - self.origin[1],
805 p[2] - self.origin[2],
806 ];
807 let x = dot3(self.inv_rows[0], rel) * self.inv_vws;
808 if x < self.xy_lo[0] || x >= self.xy_hi[0] {
809 return false;
810 }
811 let y = dot3(self.inv_rows[1], rel) * self.inv_vws;
812 if y < self.xy_lo[1] || y >= self.xy_hi[1] {
813 return false;
814 }
815 dot3(self.inv_rows[2], rel) * self.inv_vws < self.z_clip
816 }
817
818 /// Bitwise field dump feeding the [`CullKey`] clip fingerprint.
819 fn key_bits(&self) -> [u32; 18] {
820 let b = |v: f32| v.to_bits();
821 [
822 b(self.origin[0]),
823 b(self.origin[1]),
824 b(self.origin[2]),
825 b(self.inv_rows[0][0]),
826 b(self.inv_rows[0][1]),
827 b(self.inv_rows[0][2]),
828 b(self.inv_rows[1][0]),
829 b(self.inv_rows[1][1]),
830 b(self.inv_rows[1][2]),
831 b(self.inv_rows[2][0]),
832 b(self.inv_rows[2][1]),
833 b(self.inv_rows[2][2]),
834 b(self.inv_vws),
835 b(self.xy_lo[0]),
836 b(self.xy_lo[1]),
837 b(self.xy_hi[0]),
838 b(self.xy_hi[1]),
839 b(self.z_clip),
840 ]
841 }
842}
843
844/// CA.4 — FNV-1a fold of the frame's clip volumes for the [`CullKey`]:
845/// keeps the key `Copy` and allocation-free on the PF.10 skip path (a
846/// per-frame `Vec` of bit dumps would allocate just to compare-and-die
847/// on the very path whose purpose is skipping work). Seeded per-clip
848/// with the count so `[]` vs `[identity-ish]` can't alias trivially; a
849/// 64-bit collision mis-SKIPS one cull for one frame at worst.
850fn clips_fingerprint(clips: &[SpriteCutawayClip]) -> u64 {
851 const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
852 const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
853 let mut h = FNV_OFFSET ^ clips.len() as u64;
854 for c in clips {
855 for w in c.key_bits() {
856 h = (h ^ u64::from(w)).wrapping_mul(FNV_PRIME);
857 }
858 }
859 h
860}
861
862/// PF.10 — everything `cull_bin_upload`'s result depends on besides the
863/// registry contents (float fields compared bitwise). Paired with the
864/// "registry changed" invalidation (`last_cull = None` in every mutating
865/// method): when the key matches the previous frame's, the cull, the
866/// binning, and all four buffer uploads are skipped — the buffers already
867/// hold exactly this frame's data. CA.4 adds the cutaway-clip volumes
868/// (as an FNV-1a fingerprint — see [`clips_fingerprint`]) — moving a
869/// clip plane changes the visible set, so it must invalidate the cache
870/// like a camera move.
871#[derive(Clone, Copy, PartialEq)]
872struct CullKey {
873 frustum: [u32; 15],
874 screen: [u32; 4],
875 clips_fp: u64,
876 /// FW.4 — the fog-of-war mask version. Bumps when the mask changes,
877 /// so a sprite whose cell crossed the visible/hidden boundary re-culls
878 /// (the fog hide test below reads the current mask).
879 fog_version: u64,
880}
881
882impl CullKey {
883 fn new(
884 f: &ViewFrustum,
885 screen_w: u32,
886 screen_h: u32,
887 tile_size: u32,
888 lod_px: f32,
889 clips: &[SpriteCutawayClip],
890 fog_version: u64,
891 ) -> Self {
892 let b = |v: f32| v.to_bits();
893 Self {
894 fog_version,
895 frustum: [
896 b(f.pos[0]),
897 b(f.pos[1]),
898 b(f.pos[2]),
899 b(f.right[0]),
900 b(f.right[1]),
901 b(f.right[2]),
902 b(f.down[0]),
903 b(f.down[1]),
904 b(f.down[2]),
905 b(f.forward[0]),
906 b(f.forward[1]),
907 b(f.forward[2]),
908 b(f.half_w),
909 b(f.half_h),
910 b(f.far),
911 ],
912 screen: [screen_w, screen_h, tile_size, lod_px.to_bits()],
913 clips_fp: clips_fingerprint(clips),
914 }
915 }
916}
917
918/// PF.10 — reusable cull/bin workspace (was 6+ fresh `Vec`s per frame).
919#[derive(Default)]
920struct CullScratch {
921 visible: Vec<SpriteInstanceGpu>,
922 boxes: Vec<[i32; 4]>,
923 colmul: Vec<u32>,
924 counts: Vec<u32>,
925 tile_ranges: Vec<u32>,
926 tile_instances: Vec<u32>,
927 cursor: Vec<u32>,
928}
929
930/// Build one CPU cull record from a user [`SpriteInstance`]: pack the
931/// transform, seed the bounding sphere from the chain's finest model, and
932/// start `colmul` at identity. Shared by the full
933/// [`SpriteRegistryResident::upload`] and the incremental
934/// [`SpriteRegistryResident::append_instances`].
935fn make_cull(registry: &SpriteModelRegistry, i: &SpriteInstance) -> CullInstance {
936 let model_radius = registry.model(i.model_id).bound_radius();
937 CullInstance {
938 gpu: SpriteInstanceGpu {
939 inv_rot0: i.transform.inv_rot[0],
940 inv_rot1: i.transform.inv_rot[1],
941 inv_rot2: i.transform.inv_rot[2],
942 pos: i.transform.pos,
943 model_id: i.model_id, // placeholder; cull rewrites per frame
944 material: u32::from(i.material),
945 alpha_mul: f32::from(i.alpha_mul) / 255.0,
946 flags: i.flags,
947 tint: i.tint,
948 },
949 chain_id: i.model_id,
950 center: i.transform.pos,
951 radius: model_radius * i.transform.max_scale,
952 model_radius,
953 max_scale: i.transform.max_scale,
954 colmul: identity_colmul(),
955 }
956}
957
958/// Allocate the `instances` capacity buffer (`STORAGE | COPY_DST`) sized
959/// for `cap` records (≥1). Left uninitialised — `cull_bin_upload`
960/// rewrites it (offset 0) each frame, and `append_instances` seeds the
961/// live records after a grow.
962fn instances_buffer(device: &wgpu::Device, cap: u32) -> wgpu::Buffer {
963 device.create_buffer(&wgpu::BufferDescriptor {
964 label: Some("roxlap-gpu sprite_reg.instances"),
965 size: u64::from(cap.max(1)) * std::mem::size_of::<SpriteInstanceGpu>() as u64,
966 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
967 mapped_at_creation: false,
968 })
969}
970
971/// One sprite instance: a model reference + world pose.
972#[derive(Debug, Clone, Copy)]
973pub struct SpriteInstance {
974 /// LOD-chain id from [`SpriteModelRegistry::add`] / `add_lod` —
975 /// which model this instance draws. The per-frame cull substitutes
976 /// the distance-picked concrete mip entry.
977 pub model_id: u32,
978 /// World pose: inverse model→world rotation/scale + position (see
979 /// [`SpriteInstanceTransform::from_sprite`]).
980 pub transform: SpriteInstanceTransform,
981 /// Voxel-material id (TV stage): indexes the renderer's global material
982 /// palette for this instance's opacity + blend mode. `0` (the default)
983 /// is opaque, so an unset instance renders unchanged.
984 pub material: u8,
985 /// Per-instance alpha multiplier (TV stage), `0..=255` (`255` =
986 /// unscaled, the default).
987 pub alpha_mul: u8,
988 /// XS.4 — sprite shadow flags (`roxlap_formats::sprite` bits 4/5:
989 /// `NO_SHADOW_CAST` / `NO_SHADOW_RECEIVE`). `0` (default) ⇒ casts +
990 /// receives. Only honoured when the device is sprite-shadow capable.
991 pub flags: u32,
992 /// Per-instance RGB tint, packed `0x00RRGGBB` (white `0x00FF_FFFF` = no-op).
993 pub tint: u32,
994}
995
996impl SpriteInstance {
997 /// A model reference + pose with the default opaque material
998 /// (`material = 0`, `alpha_mul = 255`), shadows on (`flags = 0`), and no
999 /// tint (`0x00FF_FFFF`).
1000 #[must_use]
1001 pub fn new(model_id: u32, transform: SpriteInstanceTransform) -> Self {
1002 Self {
1003 model_id,
1004 transform,
1005 material: 0,
1006 alpha_mul: 255,
1007 flags: 0,
1008 tint: 0x00FF_FFFF,
1009 }
1010 }
1011}
1012
1013/// GPU per-model metadata: where this model's data starts in the
1014/// shared registry buffers + its dims/pivot. Mirrors `ModelMeta` in
1015/// the shader (std430, 48 bytes).
1016#[repr(C)]
1017#[derive(Clone, Copy, Pod, Zeroable, Debug)]
1018struct SpriteModelMeta {
1019 occupancy_offset: u32,
1020 colors_offset: u32,
1021 color_offsets_offset: u32,
1022 occ_words_per_col: u32,
1023 dims: [u32; 3],
1024 /// TV.3 — 1 if this model has per-voxel materials (`materials_vox` is
1025 /// populated for it); 0 ⇒ use the instance's uniform material.
1026 has_vox_materials: u32,
1027 pivot: [f32; 3],
1028 /// GPU.10.4 — world size of one voxel of this (mip) entry.
1029 voxel_world_size: f32,
1030}
1031
1032/// GPU per-instance record. Mirrors `Instance` in the shader (std430,
1033/// 80 bytes): inverse rotation columns + position + model id + the TV
1034/// material id and per-instance alpha multiplier.
1035#[repr(C)]
1036#[derive(Clone, Copy, Pod, Zeroable, Debug)]
1037struct SpriteInstanceGpu {
1038 inv_rot0: [f32; 4],
1039 inv_rot1: [f32; 4],
1040 inv_rot2: [f32; 4],
1041 pos: [f32; 3],
1042 model_id: u32,
1043 /// TV: material id into the global palette (binding 12).
1044 material: u32,
1045 /// TV: per-instance alpha multiplier, normalised to `0..=1`.
1046 alpha_mul: f32,
1047 /// XS.4 — sprite shadow flags (mirror of `roxlap_formats::sprite` bits 4/5):
1048 /// bit4 = NO_SHADOW_CAST, bit5 = NO_SHADOW_RECEIVE. `0` ⇒ casts + receives.
1049 flags: u32,
1050 /// Per-instance RGB tint, packed `0x00RRGGBB` (white `0x00FF_FFFF` = no-op).
1051 tint: u32,
1052}
1053
1054/// Invert a 3×3 matrix given as basis columns `[c0, c1, c2]`,
1055/// returning the inverse as columns. For an orthonormal basis this is
1056/// the transpose; the general path covers rotation + non-unit scale.
1057#[must_use]
1058fn mat3_inverse(cols: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
1059 let [a, b, c] = cols; // columns
1060 // Determinant via scalar triple product a · (b × c).
1061 let cross = |u: [f32; 3], v: [f32; 3]| {
1062 [
1063 u[1] * v[2] - u[2] * v[1],
1064 u[2] * v[0] - u[0] * v[2],
1065 u[0] * v[1] - u[1] * v[0],
1066 ]
1067 };
1068 let bc = cross(b, c);
1069 let ca = cross(c, a);
1070 let ab = cross(a, b);
1071 let det = a[0] * bc[0] + a[1] * bc[1] + a[2] * bc[2];
1072 let inv_det = if det.abs() < 1e-12 { 0.0 } else { 1.0 / det };
1073 // Inverse rows are (b×c, c×a, a×b)/det; return as columns of the
1074 // inverse, i.e. transpose of those rows.
1075 [
1076 [bc[0] * inv_det, ca[0] * inv_det, ab[0] * inv_det],
1077 [bc[1] * inv_det, ca[1] * inv_det, ab[1] * inv_det],
1078 [bc[2] * inv_det, ca[2] * inv_det, ab[2] * inv_det],
1079 ]
1080}
1081
1082/// GPU-resident registry + instances: every model's occupancy /
1083/// colours / offsets concatenated into shared storage buffers, a
1084/// per-model metadata table, and a capacity-sized instance buffer
1085/// rewritten each frame with the frustum-visible subset (GPU.10.2).
1086/// One bind group serves all models (same approach as the multi-grid
1087/// scene).
1088pub struct SpriteRegistryResident {
1089 /// Concatenated per-model occupancy bitmaps (1 bit per voxel,
1090 /// 32 per u32 word, z innermost within a column); each model's
1091 /// region starts at its `model_meta` `occupancy_offset`.
1092 pub occupancy: wgpu::Buffer,
1093 /// Concatenated packed voxel colours, one u32 per solid voxel
1094 /// (blue bits 0-7, green 8-15, red 16-23; the high byte is carried
1095 /// through but unread — sprite shading comes from the per-instance
1096 /// `kv6colmul` table). Rank-indexed via [`Self::color_offsets`].
1097 pub colors: wgpu::Buffer,
1098 /// Per-voxel surface-normal index, concatenated across models in the
1099 /// same layout as [`colors`](Self::colors). The shader indexes the
1100 /// per-instance `kv6colmul` table by it.
1101 pub dirs: wgpu::Buffer,
1102 /// Per-voxel material id (TV.3), same layout as [`colors`](Self::colors)
1103 /// (one u32 per voxel). `0` for models without per-voxel materials; the
1104 /// per-model `has_vox_materials` flag in `model_meta` says whether to use
1105 /// it (else the shader falls back to the instance's uniform material).
1106 pub materials_vox: wgpu::Buffer,
1107 /// Concatenated per-model `cols + 1` prefix tables: column
1108 /// `(x, y)`'s colours span
1109 /// `colors[offsets[col] .. offsets[col + 1]]` (offsets are local
1110 /// to the model's colour block).
1111 pub color_offsets: wgpu::Buffer,
1112 /// Per-model metadata table (std430, 48 B each): buffer offsets,
1113 /// dims, pivot, per-voxel-materials flag, and the mip entry's
1114 /// `voxel_world_size`. Indexed by the instance's culled `model_id`.
1115 pub model_meta: wgpu::Buffer,
1116 /// Holds up to `instance_capacity` instances; the visible subset
1117 /// is packed into `[0, count)` each frame by [`Self::cull_bin_upload`].
1118 pub instances: wgpu::Buffer,
1119 /// Allocation size of [`Self::instances`] in records (grown
1120 /// power-of-2-style by `append_instances`); the per-frame visible
1121 /// count is at most this.
1122 pub instance_capacity: u32,
1123 /// Per-visible-instance `kv6colmul[256]` tables, packed in the same
1124 /// order as the `instances` buffer each frame (two u32 per u64
1125 /// entry: lanes 0|1 then 2|3). Sized `instance_capacity * 256 * 2`
1126 /// u32; rewritten by [`Self::cull_bin_upload`].
1127 pub colmul: wgpu::Buffer,
1128 colmul_cap: u32,
1129 /// GPU.10.3 — per-tile `(offset, count)` into `tile_instances`,
1130 /// flat `2 * tiles_x * tiles_y` u32s. Grown to fit the screen.
1131 pub tile_ranges: wgpu::Buffer,
1132 tile_ranges_cap: u32,
1133 /// GPU.10.3 — flat list of visible-instance indices grouped by
1134 /// tile. Grown to fit the per-frame total.
1135 pub tile_instances: wgpu::Buffer,
1136 tile_instances_cap: u32,
1137 /// CPU cull records (full set), with precomputed bounding spheres.
1138 cull: Vec<CullInstance>,
1139 /// GPU.10.4 — LOD chains: `chains[chain_id]` = entry ids, finest
1140 /// first. The cull picks a level by distance and writes its entry
1141 /// id into the packed instance's `model_id`.
1142 chains: Vec<Vec<u32>>,
1143 /// GPU.12 incremental — CPU mirror of the GPU `model_meta` table, one
1144 /// per concrete entry. [`Self::update_model`] reads the fixed
1145 /// occupancy/color_offsets bases from here and rewrites the changed
1146 /// `colors_offset` on a relocation.
1147 meta: Vec<SpriteModelMeta>,
1148 /// GPU.12 incremental — per-entry placement of `colors`/`dirs` in the
1149 /// shared buffers (drives both; same offsets/ranks). Lets an edit
1150 /// re-upload one model's data without touching the others.
1151 colors_alloc: ColorsAllocator,
1152 /// PF.10 — the (frustum, screen) key + result of the last
1153 /// `cull_bin_upload`; `None` after any registry mutation. A matching
1154 /// key skips the whole cull/bin/upload (buffers already current).
1155 last_cull: Option<(CullKey, (u32, u32, u32))>,
1156 /// PF.10 — true once ANY per-instance colmul table was set. While
1157 /// false every table is identity, so the 2 KiB-per-visible-instance
1158 /// rebuild + upload is skipped; the buffer is identity-filled lazily
1159 /// instead (`colmul_identity`).
1160 any_colmul: bool,
1161 /// PF.10 — whether the whole `colmul` buffer currently holds the
1162 /// identity pattern (reset on growth).
1163 colmul_identity: bool,
1164 /// PF.10 — reusable cull/bin workspace.
1165 scratch: CullScratch,
1166 /// Per-entry word length of the dims-fixed `occupancy` and
1167 /// `color_offsets` arrays, kept so [`Self::update_model`] can assert a
1168 /// carve never changed dims (which would invalidate the in-place
1169 /// writes — growing dims is out of scope, handled by a full re-upload).
1170 occ_lens: Vec<u32>,
1171 coloff_lens: Vec<u32>,
1172 /// Used / allocated words of the tightly-concatenated `occupancy`
1173 /// buffer. `add_model` bump-appends at `occ_used`; when it would pass
1174 /// `occ_cap` the buffer is grown (with slack) and rebuilt from the
1175 /// registry. (`colors`/`dirs` track theirs in [`ColorsAllocator`].)
1176 occ_used: u32,
1177 occ_cap: u32,
1178 /// Used / allocated words of the tightly-concatenated `color_offsets`
1179 /// buffer — same growth scheme as `occ_*`.
1180 coloff_used: u32,
1181 coloff_cap: u32,
1182 /// Allocated record count of the `model_meta` buffer; `add_model`
1183 /// grows it (with slack) when the entry count passes it.
1184 meta_cap: u32,
1185 /// Per-entry tombstone: `true` once its model was removed
1186 /// ([`Self::remove_model`]). Dead entries keep their `meta` slot (so
1187 /// entry ids — and the caller's `chain_id`s — stay stable) but their
1188 /// colours are freed for reuse and they contribute nothing to a
1189 /// repack / [`Self::compact`]. Parallel to `meta`.
1190 dead: Vec<bool>,
1191}
1192
1193/// Which tightly-concatenated registry buffer [`SpriteRegistryResident::
1194/// sync_concat`] is operating on.
1195#[derive(Clone, Copy)]
1196enum ConcatBuf {
1197 Occupancy,
1198 ColorOffsets,
1199}
1200
1201/// The model's source array for a given [`ConcatBuf`] — a free fn (not a
1202/// closure) so the returned borrow keeps `m`'s lifetime.
1203fn concat_data(m: &SpriteModel, which: ConcatBuf) -> &[u32] {
1204 match which {
1205 ConcatBuf::Occupancy => &m.occupancy,
1206 ConcatBuf::ColorOffsets => &m.color_offsets,
1207 }
1208}
1209
1210impl SpriteRegistryResident {
1211 /// Concatenate `registry`'s models into shared buffers and prepare
1212 /// `instances` for per-frame culling. Model-relative indices stay
1213 /// as built; the shader adds each model's base offset from the
1214 /// metadata table.
1215 #[must_use]
1216 pub fn upload(
1217 device: &wgpu::Device,
1218 registry: &SpriteModelRegistry,
1219 instances: &[SpriteInstance],
1220 ) -> Self {
1221 // `occupancy` + `color_offsets` are dims-fixed → tightly
1222 // concatenated (never grow on a carve). `colors` + `dirs` are
1223 // variable → laid out by the suballocator with per-slot slack so
1224 // an incremental edit can rewrite one model in place.
1225 let entry_lens: Vec<u32> = registry
1226 .entries
1227 .iter()
1228 .map(|m| m.colors.len() as u32)
1229 .collect();
1230 let colors_alloc = ColorsAllocator::new(&entry_lens);
1231 let cap_total = colors_alloc.cap_total();
1232
1233 let mut all_occ: Vec<u32> = Vec::new();
1234 let mut all_offsets: Vec<u32> = Vec::new();
1235 let mut all_colors: Vec<u32> = vec![0; cap_total as usize];
1236 let mut all_dirs: Vec<u32> = vec![0; cap_total as usize];
1237 let mut all_materials: Vec<u32> = vec![0; cap_total as usize];
1238 let mut meta: Vec<SpriteModelMeta> = Vec::with_capacity(registry.entries.len());
1239 let mut occ_lens: Vec<u32> = Vec::with_capacity(registry.entries.len());
1240 let mut coloff_lens: Vec<u32> = Vec::with_capacity(registry.entries.len());
1241
1242 // One meta + placed data per concrete (mip-level) entry.
1243 for (e, m) in registry.entries.iter().enumerate() {
1244 let slot = colors_alloc.slot(e);
1245 meta.push(SpriteModelMeta {
1246 occupancy_offset: all_occ.len() as u32,
1247 colors_offset: slot.off,
1248 color_offsets_offset: all_offsets.len() as u32,
1249 occ_words_per_col: m.occ_words_per_col,
1250 dims: m.dims,
1251 has_vox_materials: u32::from(!m.materials.is_empty()),
1252 pivot: m.pivot,
1253 voxel_world_size: m.voxel_world_size,
1254 });
1255 occ_lens.push(m.occupancy.len() as u32);
1256 coloff_lens.push(m.color_offsets.len() as u32);
1257 all_occ.extend_from_slice(&m.occupancy);
1258 all_offsets.extend_from_slice(&m.color_offsets);
1259 let off = slot.off as usize;
1260 all_colors[off..off + m.colors.len()].copy_from_slice(&m.colors);
1261 all_dirs[off..off + m.dirs.len()].copy_from_slice(&m.dirs);
1262 for (i, &mat) in m.materials.iter().enumerate() {
1263 all_materials[off + i] = u32::from(mat);
1264 }
1265 }
1266
1267 // Per-instance cull records: sphere centred at the instance
1268 // position, radius from the chain's finest (mip-0) model.
1269 // `colmul` starts at identity (unshaded) until the facade sets
1270 // per-instance lighting via `set_instance_colmul`.
1271 let cull: Vec<CullInstance> = instances.iter().map(|i| make_cull(registry, i)).collect();
1272
1273 // Capacity buffer (COPY_DST so cull can rewrite it each frame),
1274 // seeded with the full set so frame 0 is valid pre-cull.
1275 let seed: Vec<SpriteInstanceGpu> = cull.iter().map(|c| c.gpu).collect();
1276 let instances_buf = {
1277 use wgpu::util::DeviceExt;
1278 let one = [SpriteInstanceGpu::zeroed()];
1279 let src: &[SpriteInstanceGpu] = if seed.is_empty() { &one } else { &seed };
1280 device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1281 label: Some("roxlap-gpu sprite_reg.instances"),
1282 contents: bytemuck::cast_slice(src),
1283 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
1284 })
1285 };
1286
1287 let tile_ranges = storage_dst_u32(device, "roxlap-gpu sprite_reg.tile_ranges", 1);
1288 let tile_instances = storage_dst_u32(device, "roxlap-gpu sprite_reg.tile_instances", 1);
1289 // colmul: 256 entries × 2 u32 per visible instance. Sized to the
1290 // full instance set (worst case all visible); rewritten per frame.
1291 let colmul_cap = (cull.len() as u32).max(1) * 256 * 2;
1292 let colmul = storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", colmul_cap);
1293 Self {
1294 occupancy: storage_dst_u32_cap(
1295 device,
1296 "roxlap-gpu sprite_reg.occupancy",
1297 &all_occ,
1298 all_occ.len() as u32,
1299 ),
1300 colors: storage_dst_u32_cap(
1301 device,
1302 "roxlap-gpu sprite_reg.colors",
1303 &all_colors,
1304 cap_total,
1305 ),
1306 dirs: storage_dst_u32_cap(device, "roxlap-gpu sprite_reg.dirs", &all_dirs, cap_total),
1307 materials_vox: storage_dst_u32_cap(
1308 device,
1309 "roxlap-gpu sprite_reg.materials_vox",
1310 &all_materials,
1311 cap_total,
1312 ),
1313 color_offsets: storage_dst_u32_cap(
1314 device,
1315 "roxlap-gpu sprite_reg.color_offsets",
1316 &all_offsets,
1317 all_offsets.len() as u32,
1318 ),
1319 model_meta: storage_dst_pod(device, "roxlap-gpu sprite_reg.model_meta", &meta),
1320 instances: instances_buf,
1321 instance_capacity: cull.len() as u32,
1322 colmul,
1323 colmul_cap,
1324 tile_ranges,
1325 tile_ranges_cap: 1,
1326 tile_instances,
1327 tile_instances_cap: 1,
1328 cull,
1329 chains: registry.chains.clone(),
1330 last_cull: None,
1331 any_colmul: false,
1332 colmul_identity: false,
1333 scratch: CullScratch::default(),
1334 occ_used: all_occ.len() as u32,
1335 occ_cap: all_occ.len() as u32,
1336 coloff_used: all_offsets.len() as u32,
1337 coloff_cap: all_offsets.len() as u32,
1338 meta_cap: meta.len() as u32,
1339 dead: vec![false; meta.len()],
1340 meta,
1341 colors_alloc,
1342 occ_lens,
1343 coloff_lens,
1344 }
1345 }
1346
1347 /// Number of resident instances (the cull set length).
1348 #[must_use]
1349 pub fn instance_count(&self) -> usize {
1350 self.cull.len()
1351 }
1352
1353 /// Append new instances **without** re-uploading any model volume —
1354 /// the incremental counterpart to [`Self::upload`], for streaming
1355 /// spawns (asteroids, projectiles, …). Returns the index of the first
1356 /// appended instance; the block occupies `[base, base + N)`.
1357 ///
1358 /// The model volumes are untouched, so every appended instance must
1359 /// reference a `model_id` (LOD chain) that was already present in the
1360 /// `registry` passed to [`Self::upload`]. Registering a *new* model
1361 /// still requires a full [`Self::upload`] (its voxels must be laid
1362 /// into the shared buffers). `registry` here is only read for the new
1363 /// instances' bound-sphere radii and must be the resident one.
1364 ///
1365 /// The `instances` GPU buffer is only *grown* here (power-of-two,
1366 /// amortised O(1)); its contents are **not** written. [`Self::
1367 /// cull_bin_upload`] rewrites the whole visible range from `cull` every
1368 /// frame before the sprite pass reads it — exactly as for the static
1369 /// instances — so appending only needs to extend `cull` and ensure
1370 /// capacity. Writing the buffer here too caused a mid-frame
1371 /// write-while-in-flight hazard on some drivers (a stray full-screen
1372 /// flash on append). `colmul` likewise grows lazily in
1373 /// `cull_bin_upload`. After a removal the capacity is not shrunk.
1374 pub fn append_instances(
1375 &mut self,
1376 device: &wgpu::Device,
1377 registry: &SpriteModelRegistry,
1378 instances: &[SpriteInstance],
1379 ) -> u32 {
1380 let base = self.cull.len() as u32;
1381 if instances.is_empty() {
1382 return base;
1383 }
1384 self.last_cull = None; // PF.10 — instance set changed
1385 for i in instances {
1386 debug_assert!(
1387 (i.model_id as usize) < self.chains.len(),
1388 "append_instances: model_id {} not resident (run upload to register new models)",
1389 i.model_id
1390 );
1391 self.cull.push(make_cull(registry, i));
1392 }
1393 let need = self.cull.len() as u32;
1394 if need > self.instance_capacity {
1395 // Grow power-of-two and recreate the buffer (the next frame's
1396 // bind group picks up the new handle). No seed write — the
1397 // per-frame cull_bin_upload populates it.
1398 self.instance_capacity = need.next_power_of_two();
1399 self.instances = instances_buffer(device, self.instance_capacity);
1400 }
1401 base
1402 }
1403
1404 /// Remove the instance at `index` by swap-remove — O(1), no GPU work
1405 /// (the next [`Self::cull_bin_upload`] repacks the visible set from
1406 /// the shrunk cull list). Capacity is retained for reuse.
1407 ///
1408 /// Returns `Some(old_last)` when a different instance was moved into
1409 /// `index` to fill the hole (its index changed from `old_last` to
1410 /// `index` — callers holding instance handles must fix up that one),
1411 /// or `None` if `index` was the last element or out of range. Because
1412 /// this reorders, any [`Self::set_instance_colmul`] table set by
1413 /// position should be re-applied after a removal.
1414 pub fn remove_instance(&mut self, index: usize) -> Option<usize> {
1415 if index >= self.cull.len() {
1416 return None;
1417 }
1418 self.last_cull = None; // PF.10 — instance set changed
1419 let last = self.cull.len() - 1;
1420 self.cull.swap_remove(index);
1421 (index != last).then_some(last)
1422 }
1423
1424 /// Set the per-instance `kv6colmul[256]` lighting tables (voxlap's
1425 /// `update_reflects` output), in the same order/length as the
1426 /// instances passed to [`Self::upload`]. The next
1427 /// [`Self::cull_bin_upload`] packs the visible subset to the GPU.
1428 /// Instances beyond `tables.len()` keep their previous tables.
1429 pub fn set_instance_colmul(&mut self, tables: &[[u64; 256]]) {
1430 // PF.10 — leaves the identity fast path for good: from here on the
1431 // per-visible tables are rebuilt + uploaded each cull.
1432 self.any_colmul = true;
1433 self.last_cull = None;
1434 for (ci, t) in self.cull.iter_mut().zip(tables) {
1435 ci.colmul.copy_from_slice(t);
1436 }
1437 }
1438
1439 /// Refresh instance poses in place from `instances` — for animated
1440 /// sprites (e.g. KFA limbs re-posed each frame) — **without** any
1441 /// model-volume re-upload. `instances` must match the set passed to
1442 /// [`Self::upload`] in length + order; each keeps its `model_id`
1443 /// (LOD chain) so only the transform + cull centre change. No GPU
1444 /// write happens here: the next [`Self::cull_bin_upload`] re-uploads
1445 /// the packed visible subset, as it already does every frame.
1446 pub fn update_transforms(&mut self, instances: &[SpriteInstance]) {
1447 debug_assert_eq!(
1448 instances.len(),
1449 self.cull.len(),
1450 "update_transforms instance count must match upload"
1451 );
1452 self.last_cull = None; // PF.10 — poses changed
1453 for (ci, inst) in self.cull.iter_mut().zip(instances) {
1454 ci.gpu.inv_rot0 = inst.transform.inv_rot[0];
1455 ci.gpu.inv_rot1 = inst.transform.inv_rot[1];
1456 ci.gpu.inv_rot2 = inst.transform.inv_rot[2];
1457 ci.gpu.pos = inst.transform.pos;
1458 // TV: material id + alpha multiplier ride the same coalesced
1459 // update as the pose (set via the facade's per-instance setters).
1460 ci.gpu.material = u32::from(inst.material);
1461 ci.gpu.alpha_mul = f32::from(inst.alpha_mul) / 255.0;
1462 // XS.4 shadow flags + per-instance RGB tint also ride this flush,
1463 // so `set_dyn_instance_tint` (and any flag change) takes effect.
1464 ci.gpu.flags = inst.flags;
1465 ci.gpu.tint = inst.tint;
1466 // Bounding sphere follows the pivot and rescales with the
1467 // pose's longest basis column (PS.1 — scaled particles
1468 // must not under-cull); the chain is unchanged.
1469 ci.center = inst.transform.pos;
1470 ci.max_scale = inst.transform.max_scale;
1471 ci.radius = ci.model_radius * inst.transform.max_scale;
1472 }
1473 }
1474
1475 /// Repoint instance `idx` at a different LOD chain — the per-frame
1476 /// **flipbook** step for animated voxel clips (VCL.2). The instance's
1477 /// transform / colmul are untouched; only which model's volume it
1478 /// draws changes. The new chain's volume must already be resident
1479 /// (uploaded via [`Self::add_model`] / [`Self::upload`]); `registry`
1480 /// is the one those uploads used (so the bounding radius reseeds from
1481 /// the new model). Like [`Self::update_transforms`], this is a CPU-side
1482 /// rewrite — the next [`Self::cull_bin_upload`] re-uploads the packed
1483 /// visible subset, so it costs nothing extra on the GPU. No-op if `idx`
1484 /// is out of range.
1485 ///
1486 /// All frames of a clip share the same `dims`, so a flipbook swap
1487 /// leaves the bounding radius unchanged; reseeding it anyway keeps the
1488 /// method correct for arbitrary chain swaps.
1489 pub fn set_instance_model(
1490 &mut self,
1491 registry: &SpriteModelRegistry,
1492 idx: usize,
1493 chain_id: u32,
1494 ) {
1495 self.last_cull = None; // PF.10 — model binding changed
1496 // Guard `chain_id` (the `cull.get_mut` below only covers `idx`): a
1497 // public caller could pass an out-of-range / tombstoned chain, which
1498 // `registry.model` would index-panic on.
1499 let Some(model_radius) = registry
1500 .model_checked(chain_id)
1501 .map(SpriteModel::bound_radius)
1502 else {
1503 return;
1504 };
1505 let Some(ci) = self.cull.get_mut(idx) else {
1506 return;
1507 };
1508 ci.chain_id = chain_id;
1509 ci.gpu.model_id = chain_id; // placeholder; cull rewrites to the LOD entry
1510 ci.model_radius = model_radius;
1511 ci.radius = model_radius * ci.max_scale;
1512 }
1513
1514 /// GPU.12 incremental — re-upload only the entries of LOD chain
1515 /// `chain_id` after an in-place edit (carve / recolour) of its model,
1516 /// **without** rebuilding the whole registry. `registry` must be the
1517 /// same registry uploaded (same entry ids), with chain `chain_id`'s
1518 /// entries already edited (`model_mut` + `rebuild_lod`).
1519 ///
1520 /// For each entry: occupancy + color_offsets are dims-fixed, so they
1521 /// are written in place; colors + dirs (variable, parallel) go through
1522 /// the suballocator — written in place when they fit the slack,
1523 /// relocated (with a `model_meta` rewrite) when they outgrow it, and
1524 /// only when the buffer tail overflows are colors/dirs grown + the
1525 /// whole registry repacked. Instances / cull / colmul are untouched
1526 /// (a carve never moves an instance or grows its bounds) — that is the
1527 /// win over [`Self::upload`].
1528 ///
1529 /// # Panics (debug)
1530 /// If an entry's dims changed (occupancy / color_offsets length), which
1531 /// the in-place path can't absorb — growing dims needs a full
1532 /// re-upload via [`Self::upload`].
1533 pub fn update_model(
1534 &mut self,
1535 device: &wgpu::Device,
1536 queue: &wgpu::Queue,
1537 registry: &SpriteModelRegistry,
1538 chain_id: u32,
1539 ) {
1540 self.last_cull = None; // PF.10 — model volume changed
1541 let entries = self.chains[chain_id as usize].clone();
1542 let mut grew = false;
1543 for &e in &entries {
1544 let e = e as usize;
1545 let m = ®istry.entries[e];
1546
1547 // Dims-fixed arrays: assert unchanged, then write in place.
1548 debug_assert_eq!(
1549 m.occupancy.len() as u32,
1550 self.occ_lens[e],
1551 "update_model: entry {e} occupancy length changed (dims grew?)"
1552 );
1553 debug_assert_eq!(
1554 m.color_offsets.len() as u32,
1555 self.coloff_lens[e],
1556 "update_model: entry {e} color_offsets length changed (dims grew?)"
1557 );
1558 queue.write_buffer(
1559 &self.occupancy,
1560 u64::from(self.meta[e].occupancy_offset) * 4,
1561 bytemuck::cast_slice(&m.occupancy),
1562 );
1563 queue.write_buffer(
1564 &self.color_offsets,
1565 u64::from(self.meta[e].color_offsets_offset) * 4,
1566 bytemuck::cast_slice(&m.color_offsets),
1567 );
1568
1569 // Variable colors/dirs via the suballocator.
1570 let new_len = m.colors.len() as u32;
1571 match self.colors_alloc.place(e, new_len) {
1572 Some(off) => {
1573 queue.write_buffer(
1574 &self.colors,
1575 u64::from(off) * 4,
1576 bytemuck::cast_slice(&m.colors),
1577 );
1578 queue.write_buffer(
1579 &self.dirs,
1580 u64::from(off) * 4,
1581 bytemuck::cast_slice(&m.dirs),
1582 );
1583 let mats: Vec<u32> = m.materials.iter().map(|&x| u32::from(x)).collect();
1584 queue.write_buffer(
1585 &self.materials_vox,
1586 u64::from(off) * 4,
1587 bytemuck::cast_slice(&mats),
1588 );
1589 if self.meta[e].colors_offset != off {
1590 // Relocated — rewrite this entry's meta record.
1591 self.meta[e].colors_offset = off;
1592 queue.write_buffer(
1593 &self.model_meta,
1594 (e * std::mem::size_of::<SpriteModelMeta>()) as u64,
1595 bytemuck::bytes_of(&self.meta[e]),
1596 );
1597 }
1598 }
1599 None => grew = true,
1600 }
1601 }
1602
1603 // Buffer overflow on at least one entry → grow colors/dirs and
1604 // repack the WHOLE registry (rare; offsets for every entry move).
1605 if grew {
1606 self.grow_and_repack(device, queue, registry);
1607 }
1608 }
1609
1610 /// Grow the `colors`/`dirs` buffers and repack every entry compactly
1611 /// (with fresh slack) when an [`Self::update_model`] edit overflowed
1612 /// the buffer tail. Recreates both buffers (the next frame's bind
1613 /// group picks up the new handles) and rewrites every `model_meta`
1614 /// `colors_offset`. O(registry) but rare — logged so a growth burst
1615 /// is visible.
1616 fn grow_and_repack(
1617 &mut self,
1618 device: &wgpu::Device,
1619 queue: &wgpu::Queue,
1620 registry: &SpriteModelRegistry,
1621 ) {
1622 self.repack_colors_dirs(device, registry);
1623 // Every entry's colors_offset moved → rewrite the whole meta table.
1624 queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1625 }
1626
1627 /// Repack `colors`/`dirs` compactly (with fresh slack) from the full
1628 /// `registry`, recreating both buffers and updating every CPU
1629 /// `meta[e].colors_offset`. Does **not** touch the GPU `model_meta`
1630 /// buffer — the caller writes it ([`Self::grow_and_repack`] writes the
1631 /// whole table; [`Self::add_model`] writes it once after all entries
1632 /// are placed). O(registry) but rare — logged so a growth burst is
1633 /// visible.
1634 fn repack_colors_dirs(&mut self, device: &wgpu::Device, registry: &SpriteModelRegistry) {
1635 // Dead (removed) entries collapse to 0 length so they reclaim no
1636 // space; live entries keep their colours.
1637 let new_lens: Vec<u32> = registry
1638 .entries
1639 .iter()
1640 .enumerate()
1641 .map(|(e, m)| {
1642 if self.dead[e] {
1643 0
1644 } else {
1645 m.colors.len() as u32
1646 }
1647 })
1648 .collect();
1649 self.colors_alloc.repack(&new_lens);
1650 let cap_total = self.colors_alloc.cap_total();
1651
1652 let mut all_colors = vec![0u32; cap_total as usize];
1653 let mut all_dirs = vec![0u32; cap_total as usize];
1654 let mut all_materials = vec![0u32; cap_total as usize];
1655 for (e, m) in registry.entries.iter().enumerate() {
1656 if self.dead[e] {
1657 self.meta[e].colors_offset = 0;
1658 continue;
1659 }
1660 let off = self.colors_alloc.slot(e).off as usize;
1661 all_colors[off..off + m.colors.len()].copy_from_slice(&m.colors);
1662 all_dirs[off..off + m.dirs.len()].copy_from_slice(&m.dirs);
1663 for (i, &mat) in m.materials.iter().enumerate() {
1664 all_materials[off + i] = u32::from(mat);
1665 }
1666 self.meta[e].colors_offset = off as u32;
1667 }
1668 self.colors = storage_dst_u32_cap(
1669 device,
1670 "roxlap-gpu sprite_reg.colors",
1671 &all_colors,
1672 cap_total,
1673 );
1674 self.dirs = storage_dst_u32_cap(device, "roxlap-gpu sprite_reg.dirs", &all_dirs, cap_total);
1675 self.materials_vox = storage_dst_u32_cap(
1676 device,
1677 "roxlap-gpu sprite_reg.materials_vox",
1678 &all_materials,
1679 cap_total,
1680 );
1681 eprintln!(
1682 "roxlap-gpu: sprite registry colors/dirs/materials grew + repacked to {cap_total} words"
1683 );
1684 }
1685
1686 /// Append a new model (its full LOD chain) to the resident registry
1687 /// **without** re-uploading the existing models' volumes — the
1688 /// incremental counterpart to a full [`Self::upload`], for streaming
1689 /// in new geometry (unique asteroids, generated meshes).
1690 ///
1691 /// Contract (mirrors [`Self::update_model`]): the caller owns the
1692 /// `SpriteModelRegistry`, has just appended this chain to it (e.g. via
1693 /// [`SpriteModelRegistry::add_lod`]), and passes the resulting
1694 /// `chain_id`. The chain's entries must be the registry's newest (ids
1695 /// `>= ` the resident entry count) — entries are append-only.
1696 ///
1697 /// The large `colors`/`dirs`/`occupancy`/`color_offsets` buffers carry
1698 /// slack and bump-append the new entries in place; a buffer that
1699 /// overflows is grown (with slack) and rebuilt once from the registry
1700 /// (amortised O(1) per add). The small `model_meta` table is rewritten
1701 /// each call. After this, [`Self::append_instances`] can reference the
1702 /// new `chain_id`.
1703 pub fn add_model(
1704 &mut self,
1705 device: &wgpu::Device,
1706 queue: &wgpu::Queue,
1707 registry: &SpriteModelRegistry,
1708 chain_id: u32,
1709 ) {
1710 self.last_cull = None; // PF.10 — chain set changed
1711 let entries = registry.chains[chain_id as usize].clone();
1712 debug_assert_eq!(
1713 chain_id as usize,
1714 self.chains.len(),
1715 "add_model: chains must be appended in order"
1716 );
1717
1718 // CPU bookkeeping: assign each new entry a tight occ/coloff offset
1719 // and an allocator slot for colors/dirs. `need_colors_grow` marks
1720 // a slot that didn't fit → a colors/dirs repack below.
1721 let mut need_colors_grow = false;
1722 for &e in &entries {
1723 let e = e as usize;
1724 debug_assert_eq!(
1725 e,
1726 self.meta.len(),
1727 "add_model: entries must be appended in order"
1728 );
1729 let m = ®istry.entries[e];
1730 let occ_off = self.occ_used;
1731 let coloff_off = self.coloff_used;
1732 self.occ_used += m.occupancy.len() as u32;
1733 self.coloff_used += m.color_offsets.len() as u32;
1734 let colors_off = match self.colors_alloc.push(m.colors.len() as u32) {
1735 Some(off) => off,
1736 None => {
1737 need_colors_grow = true;
1738 0 // placeholder; repack assigns the real offset
1739 }
1740 };
1741 self.meta.push(SpriteModelMeta {
1742 occupancy_offset: occ_off,
1743 colors_offset: colors_off,
1744 color_offsets_offset: coloff_off,
1745 occ_words_per_col: m.occ_words_per_col,
1746 dims: m.dims,
1747 has_vox_materials: u32::from(!m.materials.is_empty()),
1748 pivot: m.pivot,
1749 voxel_world_size: m.voxel_world_size,
1750 });
1751 self.occ_lens.push(m.occupancy.len() as u32);
1752 self.coloff_lens.push(m.color_offsets.len() as u32);
1753 self.dead.push(false);
1754 }
1755 self.chains.push(entries.clone());
1756
1757 // occupancy + color_offsets: grow+rebuild on overflow, else write
1758 // the new tails in place.
1759 self.sync_concat(device, queue, registry, &entries, ConcatBuf::Occupancy);
1760 self.sync_concat(device, queue, registry, &entries, ConcatBuf::ColorOffsets);
1761
1762 // colors/dirs: repack on overflow (rebuilds both + every CPU
1763 // colors_offset), else write the new entries at their slots.
1764 if need_colors_grow {
1765 self.repack_colors_dirs(device, registry);
1766 } else {
1767 for &e in &entries {
1768 let e = e as usize;
1769 let m = ®istry.entries[e];
1770 let off = u64::from(self.meta[e].colors_offset) * 4;
1771 queue.write_buffer(&self.colors, off, bytemuck::cast_slice(&m.colors));
1772 queue.write_buffer(&self.dirs, off, bytemuck::cast_slice(&m.dirs));
1773 let mats: Vec<u32> = m.materials.iter().map(|&x| u32::from(x)).collect();
1774 queue.write_buffer(&self.materials_vox, off, bytemuck::cast_slice(&mats));
1775 }
1776 }
1777
1778 // model_meta: grow the record buffer if needed, then rewrite the
1779 // whole (small) table — covers both new records and any
1780 // colors_offset relocations from a repack.
1781 let count = self.meta.len() as u32;
1782 if count > self.meta_cap {
1783 self.meta_cap = grow_records(count);
1784 self.model_meta = storage_dst_pod_cap(
1785 device,
1786 "roxlap-gpu sprite_reg.model_meta",
1787 &self.meta,
1788 self.meta_cap,
1789 );
1790 } else {
1791 queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1792 }
1793 }
1794
1795 /// Sync one tightly-concatenated buffer (`occupancy` or
1796 /// `color_offsets`) after `add_model` appended `new_entries`: if the
1797 /// used length now exceeds capacity, grow (with slack) and rebuild the
1798 /// whole buffer from the registry; otherwise write just the appended
1799 /// tails at their offsets.
1800 fn sync_concat(
1801 &mut self,
1802 device: &wgpu::Device,
1803 queue: &wgpu::Queue,
1804 registry: &SpriteModelRegistry,
1805 new_entries: &[u32],
1806 which: ConcatBuf,
1807 ) {
1808 let (used, cap) = match which {
1809 ConcatBuf::Occupancy => (self.occ_used, self.occ_cap),
1810 ConcatBuf::ColorOffsets => (self.coloff_used, self.coloff_cap),
1811 };
1812 if used > cap {
1813 // The bump layout overflowed: rebuild through the COMPACTOR,
1814 // which re-packs live entries tightly AND rewrites their
1815 // meta offsets (`add_model` re-uploads the whole model_meta
1816 // table right after this, so the recomputed offsets reach
1817 // the GPU for free) — and reclaiming tombstone holes may
1818 // absorb the growth outright.
1819 //
1820 // The previous code here rebuilt tightly but kept the STALE
1821 // bump offsets in `meta`: after any `remove_model` hole,
1822 // every live model behind it read its volume at a shifted
1823 // offset — permanent "black stripe" corruption, repaired
1824 // per-model only by an `update_model` rewrite. Root-caused
1825 // by the roxlap-game-demo author (0.27.0).
1826 self.compact_concat(device, registry, which);
1827 } else {
1828 let target = match which {
1829 ConcatBuf::Occupancy => &self.occupancy,
1830 ConcatBuf::ColorOffsets => &self.color_offsets,
1831 };
1832 for &e in new_entries {
1833 let e = e as usize;
1834 let off = match which {
1835 ConcatBuf::Occupancy => self.meta[e].occupancy_offset,
1836 ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset,
1837 };
1838 queue.write_buffer(
1839 target,
1840 u64::from(off) * 4,
1841 bytemuck::cast_slice(concat_data(®istry.entries[e], which)),
1842 );
1843 }
1844 }
1845 }
1846
1847 /// Number of removed-but-not-yet-compacted models (tombstoned chains).
1848 /// A caller streams `add_model` / `remove_model` and calls
1849 /// [`Self::compact`] once this (relative to [`Self::live_model_count`])
1850 /// crosses a threshold.
1851 #[must_use]
1852 pub fn dead_model_count(&self) -> usize {
1853 self.chains.iter().filter(|c| c.is_empty()).count()
1854 }
1855
1856 /// Number of live (non-removed) models.
1857 #[must_use]
1858 pub fn live_model_count(&self) -> usize {
1859 self.chains.iter().filter(|c| !c.is_empty()).count()
1860 }
1861
1862 /// Remove a model (tombstone its LOD chain) — the counterpart to
1863 /// [`Self::add_model`]. O(chain length): marks the chain's entries
1864 /// dead and frees their `colors`/`dirs` slots for reuse by a later
1865 /// `add_model`. The `occupancy` / `color_offsets` holes are **not**
1866 /// reclaimed until [`Self::compact`]; entry ids (and the caller's other
1867 /// `chain_id`s) stay stable.
1868 ///
1869 /// Instances of the removed chain are **not** dropped here — they
1870 /// linger in the cull set but draw as nothing (skipped in
1871 /// [`Self::cull_bin_upload`]); the caller removes them via
1872 /// [`Self::remove_instance`] when convenient. A no-op if `chain_id` is
1873 /// out of range or already removed.
1874 pub fn remove_model(&mut self, chain_id: u32) {
1875 let Some(entries) = self.chains.get(chain_id as usize).cloned() else {
1876 return;
1877 };
1878 if entries.is_empty() {
1879 return; // already removed
1880 }
1881 self.last_cull = None; // PF.10 — tombstone changes visibility
1882 for &e in &entries {
1883 let e = e as usize;
1884 self.dead[e] = true;
1885 self.colors_alloc.free(e);
1886 }
1887 self.chains[chain_id as usize] = Vec::new(); // tombstone
1888 }
1889
1890 /// Reclaim the holes left by [`Self::remove_model`]: rebuild the shared
1891 /// volume buffers from the live entries only, dropping every dead
1892 /// entry's data. Entry ids and `chain_id`s are preserved (dead entries
1893 /// keep a zero-length `meta` tombstone), so the caller's handles stay
1894 /// valid and no remap is needed.
1895 ///
1896 /// `registry` must be the resident one (entry ids 1:1, as for
1897 /// [`Self::add_model`] / [`Self::update_model`]). O(live volume) —
1898 /// call it when [`Self::dead_model_count`] is high, not every frame.
1899 pub fn compact(
1900 &mut self,
1901 device: &wgpu::Device,
1902 queue: &wgpu::Queue,
1903 registry: &SpriteModelRegistry,
1904 ) {
1905 self.last_cull = None; // PF.10 — entry ids / chains renumbered
1906 // occupancy + color_offsets: re-pack live entries tightly, rewrite
1907 // each live entry's meta offset, zero the dead ones.
1908 self.compact_concat(device, registry, ConcatBuf::Occupancy);
1909 self.compact_concat(device, registry, ConcatBuf::ColorOffsets);
1910 // colors/dirs: the dead-aware repack already drops dead entries.
1911 self.repack_colors_dirs(device, registry);
1912 // model_meta: rewrite the (unchanged-length) table with the new
1913 // offsets. Buffer count didn't change, so no grow needed.
1914 queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1915 }
1916
1917 /// Rebuild one tightly-concatenated buffer from live entries only
1918 /// (used by [`Self::compact`]): assign each live entry a fresh tight
1919 /// offset, zero dead entries' offset, and recreate the buffer with
1920 /// slack.
1921 fn compact_concat(
1922 &mut self,
1923 device: &wgpu::Device,
1924 registry: &SpriteModelRegistry,
1925 which: ConcatBuf,
1926 ) {
1927 let mut all: Vec<u32> = Vec::new();
1928 for e in 0..self.meta.len() {
1929 if self.dead[e] {
1930 match which {
1931 ConcatBuf::Occupancy => self.meta[e].occupancy_offset = 0,
1932 ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = 0,
1933 }
1934 continue;
1935 }
1936 let off = all.len() as u32;
1937 match which {
1938 ConcatBuf::Occupancy => self.meta[e].occupancy_offset = off,
1939 ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = off,
1940 }
1941 all.extend_from_slice(concat_data(®istry.entries[e], which));
1942 }
1943 let used = all.len() as u32;
1944 let cap = grow_words(used);
1945 let (label, buf) = match which {
1946 ConcatBuf::Occupancy => ("roxlap-gpu sprite_reg.occupancy", &mut self.occupancy),
1947 ConcatBuf::ColorOffsets => (
1948 "roxlap-gpu sprite_reg.color_offsets",
1949 &mut self.color_offsets,
1950 ),
1951 };
1952 *buf = storage_dst_u32_cap(device, label, &all, cap);
1953 match which {
1954 ConcatBuf::Occupancy => {
1955 self.occ_used = used;
1956 self.occ_cap = cap;
1957 }
1958 ConcatBuf::ColorOffsets => {
1959 self.coloff_used = used;
1960 self.coloff_cap = cap;
1961 }
1962 }
1963 }
1964
1965 /// GPU.10.3 — frustum-cull, pack the visible subset into the
1966 /// instance buffer, then bin those instances into screen tiles:
1967 /// project each visible bounding sphere to a screen AABB and append
1968 /// its (visible) index to every overlapped tile. Uploads the
1969 /// instance buffer + `tile_ranges` (per-tile offset/count) +
1970 /// `tile_instances` (flat grouped indices), growing the tile
1971 /// buffers as needed. Returns `(visible_count, tiles_x, tiles_y)`.
1972 #[allow(clippy::too_many_arguments)]
1973 pub fn cull_bin_upload(
1974 &mut self,
1975 device: &wgpu::Device,
1976 queue: &wgpu::Queue,
1977 f: &ViewFrustum,
1978 screen_w: u32,
1979 screen_h: u32,
1980 tile_size: u32,
1981 lod_px: f32,
1982 // CA.4 — per-frame cutaway volumes; an instance inside any of
1983 // them is hidden (footprint rule). Empty ⇒ pre-CA behaviour.
1984 clips: &[SpriteCutawayClip],
1985 // FW.4 — fog-of-war hide test on an instance's world centre
1986 // (decision 8): `Some(f)` where `f(center)` is true hides the
1987 // sprite (Memory / Unseen cell). `None` ⇒ no fog. `fog_version`
1988 // keys the cull skip cache so a mask change re-culls.
1989 fog_hidden: Option<&dyn Fn([f32; 3]) -> bool>,
1990 fog_version: u64,
1991 ) -> (u32, u32, u32) {
1992 let tiles_x = screen_w.div_ceil(tile_size).max(1);
1993 let tiles_y = screen_h.div_ceil(tile_size).max(1);
1994 let n_tiles = (tiles_x * tiles_y) as usize;
1995
1996 // PF.10 — nothing changed since the last cull (same registry
1997 // state, same view, same screen): the four buffers already hold
1998 // exactly this frame's data — skip the whole cull/bin/upload.
1999 let key = CullKey::new(f, screen_w, screen_h, tile_size, lod_px, clips, fog_version);
2000 if let Some((k, res)) = self.last_cull {
2001 if k == key {
2002 return res;
2003 }
2004 }
2005
2006 let nw = (1.0 + f.half_w * f.half_w).sqrt();
2007 let nh = (1.0 + f.half_h * f.half_h).sqrt();
2008 let cx = screen_w as f32 * 0.5;
2009 let cy = screen_h as f32 * 0.5;
2010 let px_per_world = cx / f.half_w; // isotropic: == cy/half_h
2011 let ts = tile_size as f32;
2012 let tx_max = tiles_x as i32 - 1;
2013 let ty_max = tiles_y as i32 - 1;
2014
2015 // PF.10 — reused workspace (was 6+ fresh Vecs per frame).
2016 let scratch = &mut self.scratch;
2017 let visible = &mut scratch.visible;
2018 visible.clear();
2019 // Per-visible tile AABB (tx0, tx1, ty0, ty1) for the bin pass.
2020 let boxes = &mut scratch.boxes;
2021 boxes.clear();
2022 // Per-visible kv6colmul tables, flattened to two u32 per u64
2023 // entry (lanes 0|1, then 2|3), packed in visible order so the
2024 // shader indexes `colmul[inst_idx*512 + dir*2 + {0,1}]`. PF.10 —
2025 // built ONLY once a non-identity table exists (`any_colmul`);
2026 // until then the buffer holds a lazily-written identity fill and
2027 // the ~2 KiB-per-visible-instance rebuild + upload is skipped.
2028 let visible_colmul = &mut scratch.colmul;
2029 visible_colmul.clear();
2030 let counts = &mut scratch.counts;
2031 counts.clear();
2032 counts.resize(n_tiles, 0u32);
2033 let pack_colmul = self.any_colmul;
2034
2035 for ci in &self.cull {
2036 // Skip instances of a removed model (tombstoned chain) — they
2037 // linger in `cull` until the caller drops them, but draw as
2038 // nothing.
2039 if self.chains[ci.chain_id as usize].is_empty() {
2040 continue;
2041 }
2042 // CA.4 — cutaway footprint rule: hidden instances drop out
2043 // of the visible set (and with it, sprite shadow casting).
2044 if clips.iter().any(|c| c.hides(ci.center)) {
2045 continue;
2046 }
2047 // FW.4 — fog-of-war hide: a sprite over a Memory / Unseen cell
2048 // of the fog grid is not currently known to be there, so it
2049 // drops out of the visible set too (decision 8).
2050 if fog_hidden.is_some_and(|f| f(ci.center)) {
2051 continue;
2052 }
2053 let rel = [
2054 ci.center[0] - f.pos[0],
2055 ci.center[1] - f.pos[1],
2056 ci.center[2] - f.pos[2],
2057 ];
2058 let z = dot3(rel, f.forward);
2059 let r = ci.radius;
2060 if z + r < 0.0 || z - r > f.far {
2061 continue; // behind / beyond far
2062 }
2063 let x = dot3(rel, f.right);
2064 if (x - f.half_w * z) > r * nw || (-x - f.half_w * z) > r * nw {
2065 continue; // right / left
2066 }
2067 let y = dot3(rel, f.down);
2068 if (y - f.half_h * z) > r * nh || (-y - f.half_h * z) > r * nh {
2069 continue; // bottom / top
2070 }
2071
2072 // Visible: project the sphere to a screen AABB → tile range.
2073 let (tx0, tx1, ty0, ty1) = if z > 1e-3 {
2074 let sx = cx + (x / z) * px_per_world;
2075 let sy = cy + (y / z) * px_per_world;
2076 let sr = (r / z) * px_per_world;
2077 (
2078 (((sx - sr) / ts).floor() as i32).clamp(0, tx_max),
2079 (((sx + sr) / ts).floor() as i32).clamp(0, tx_max),
2080 (((sy - sr) / ts).floor() as i32).clamp(0, ty_max),
2081 (((sy + sr) / ts).floor() as i32).clamp(0, ty_max),
2082 )
2083 } else {
2084 (0, tx_max, 0, ty_max)
2085 };
2086 // GPU.10.4 — pick the LOD level by projected voxel size:
2087 // choose the coarsest level whose voxel still covers at
2088 // least `lod_px` screen pixels, i.e. step up once a mip-0
2089 // voxel would be smaller than that. `lod_px = 1` is the
2090 // natural "don't go sub-pixel" threshold; larger values
2091 // force LOD in closer (tuning/inspection).
2092 let chain = &self.chains[ci.chain_id as usize];
2093 let level = if z > 1e-3 && chain.len() > 1 {
2094 // Mip-0 voxel screen size; a scaled instance's voxels
2095 // are `max_scale`× larger in world, so it holds the
2096 // fine mip proportionally longer (PS.1).
2097 let voxel_px = px_per_world * ci.max_scale / z;
2098 ((lod_px / voxel_px).log2().ceil().max(0.0) as usize).min(chain.len() - 1)
2099 } else {
2100 0
2101 };
2102 let mut g = ci.gpu;
2103 g.model_id = chain[level];
2104 visible.push(g);
2105 boxes.push([tx0, tx1, ty0, ty1]);
2106 if pack_colmul {
2107 for &w in ci.colmul.iter() {
2108 visible_colmul.push((w & 0xffff_ffff) as u32);
2109 visible_colmul.push((w >> 32) as u32);
2110 }
2111 }
2112 for ty in ty0..=ty1 {
2113 for tx in tx0..=tx1 {
2114 counts[(ty * tiles_x as i32 + tx) as usize] += 1;
2115 }
2116 }
2117 }
2118
2119 if visible.is_empty() {
2120 let res = (0, tiles_x, tiles_y);
2121 self.last_cull = Some((key, res));
2122 return res;
2123 }
2124
2125 // Prefix-sum counts → per-tile offsets; build the flat grouped
2126 // index list.
2127 let tile_ranges = &mut scratch.tile_ranges;
2128 tile_ranges.clear();
2129 tile_ranges.resize(n_tiles * 2, 0u32);
2130 let mut running = 0u32;
2131 for t in 0..n_tiles {
2132 tile_ranges[2 * t] = running; // offset
2133 tile_ranges[2 * t + 1] = counts[t]; // count
2134 running += counts[t];
2135 }
2136 let total = running as usize;
2137 let tile_instances = &mut scratch.tile_instances;
2138 tile_instances.clear();
2139 tile_instances.resize(total.max(1), 0u32);
2140 let cursor = &mut scratch.cursor;
2141 cursor.clear();
2142 cursor.extend((0..n_tiles).map(|t| tile_ranges[2 * t]));
2143 for (vis_idx, b) in boxes.iter().enumerate() {
2144 for ty in b[2]..=b[3] {
2145 for tx in b[0]..=b[1] {
2146 let t = (ty * tiles_x as i32 + tx) as usize;
2147 tile_instances[cursor[t] as usize] = vis_idx as u32;
2148 cursor[t] += 1;
2149 }
2150 }
2151 }
2152
2153 // Upload: instances + (grown) tile buffers. Grow a tile buffer
2154 // only when this frame needs more than its capacity (wgpu has
2155 // no Clone on Buffer, so we replace the field in place).
2156 queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(visible));
2157 let need_ranges = tile_ranges.len() as u32;
2158 if need_ranges > self.tile_ranges_cap {
2159 self.tile_ranges_cap = need_ranges.next_power_of_two();
2160 self.tile_ranges = storage_dst_u32(
2161 device,
2162 "roxlap-gpu sprite_reg.tile_ranges",
2163 self.tile_ranges_cap,
2164 );
2165 }
2166 let need_inst = tile_instances.len() as u32;
2167 if need_inst > self.tile_instances_cap {
2168 self.tile_instances_cap = need_inst.next_power_of_two();
2169 self.tile_instances = storage_dst_u32(
2170 device,
2171 "roxlap-gpu sprite_reg.tile_instances",
2172 self.tile_instances_cap,
2173 );
2174 }
2175 queue.write_buffer(&self.tile_ranges, 0, bytemuck::cast_slice(tile_ranges));
2176 queue.write_buffer(
2177 &self.tile_instances,
2178 0,
2179 bytemuck::cast_slice(tile_instances),
2180 );
2181 if pack_colmul {
2182 let need_colmul = visible_colmul.len() as u32;
2183 if need_colmul > self.colmul_cap {
2184 self.colmul_cap = need_colmul.next_power_of_two();
2185 self.colmul =
2186 storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", self.colmul_cap);
2187 self.colmul_identity = false;
2188 }
2189 queue.write_buffer(&self.colmul, 0, bytemuck::cast_slice(visible_colmul));
2190 } else {
2191 // PF.10 — identity fast path: every table is identity, so the
2192 // buffer content is a constant repeating pattern. (Re)fill it
2193 // only on first use / growth; per-frame upload skipped.
2194 let need_colmul = visible.len() as u32 * 512;
2195 if need_colmul > self.colmul_cap {
2196 self.colmul_cap = need_colmul.next_power_of_two();
2197 self.colmul =
2198 storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", self.colmul_cap);
2199 self.colmul_identity = false;
2200 }
2201 if !self.colmul_identity {
2202 let w = identity_colmul()[0];
2203 let (lo, hi) = ((w & 0xffff_ffff) as u32, (w >> 32) as u32);
2204 let fill: Vec<u32> = (0..self.colmul_cap)
2205 .map(|i| if i & 1 == 0 { lo } else { hi })
2206 .collect();
2207 queue.write_buffer(&self.colmul, 0, bytemuck::cast_slice(&fill));
2208 self.colmul_identity = true;
2209 }
2210 }
2211
2212 let res = (visible.len() as u32, tiles_x, tiles_y);
2213 self.last_cull = Some((key, res));
2214 res
2215 }
2216}
2217
2218/// GPU.12 incremental — per-entry placement of one model's `colors`
2219/// (and the parallel `dirs`) within the shared registry buffers: a
2220/// `[off, off+cap)` word window holding `len` live words. `cap >= len`
2221/// gives slack so a carve that *grows* the surface-voxel count can be
2222/// rewritten in place without relocating.
2223#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2224struct ColorSlot {
2225 off: u32,
2226 cap: u32,
2227 len: u32,
2228}
2229
2230/// First-fit suballocator over the parallel `colors`/`dirs` buffers
2231/// (same offsets/ranks → one allocator drives both). Each registry
2232/// entry owns a [`ColorSlot`]; growth past a slot's `cap` relocates it
2233/// (freeing the old block) via the free list or a bump tail, and only
2234/// when the tail would exceed `cap_total` does the caller grow + repack
2235/// the whole buffer. Pure (no GPU) so it unit-tests on its own.
2236#[derive(Debug, Default)]
2237struct ColorsAllocator {
2238 /// Per-entry slot, indexed by entry id.
2239 slots: Vec<ColorSlot>,
2240 /// Freed `(off, cap)` blocks available for first-fit reuse.
2241 free: Vec<(u32, u32)>,
2242 /// Next bump-allocation position (words).
2243 tail: u32,
2244 /// Total buffer capacity in words.
2245 cap_total: u32,
2246}
2247
2248/// Slack-padded capacity for a `len`-word array: +25% + 16 words, so a
2249/// few extra surface voxels from a carve fit without relocating.
2250fn slot_cap(len: u32) -> u32 {
2251 len + len / 4 + 16
2252}
2253
2254/// Slack capacity (words) for a grown concatenated buffer: +50% + 256, so
2255/// a burst of `add_model` calls bump-appends rather than re-growing every
2256/// time. Matches [`ColorsAllocator`]'s `cap_total` headroom.
2257fn grow_words(used: u32) -> u32 {
2258 used + used / 2 + 256
2259}
2260
2261/// Slack capacity (records) for a grown `model_meta` buffer: +50% + 8.
2262fn grow_records(count: u32) -> u32 {
2263 count + count / 2 + 8
2264}
2265
2266impl ColorsAllocator {
2267 /// Lay every entry out contiguously (with per-slot slack) and add a
2268 /// global tail headroom so early growth bump-allocates rather than
2269 /// repacks.
2270 fn new(entry_lens: &[u32]) -> Self {
2271 let mut a = Self::default();
2272 a.repack(entry_lens);
2273 a
2274 }
2275
2276 fn slot(&self, entry: usize) -> ColorSlot {
2277 self.slots[entry]
2278 }
2279
2280 fn cap_total(&self) -> u32 {
2281 self.cap_total
2282 }
2283
2284 /// Repack ALL entries compactly to fit `new_lens`, resetting the
2285 /// free list + tail and choosing a fresh `cap_total` with headroom.
2286 /// Used at initial build and on a buffer grow.
2287 fn repack(&mut self, new_lens: &[u32]) {
2288 self.free.clear();
2289 let mut off = 0u32;
2290 let mut slots = Vec::with_capacity(new_lens.len());
2291 for &len in new_lens {
2292 // A 0-length (dead / removed) entry takes no space — keeps a
2293 // tombstone slot so entry ids stay positional.
2294 let cap = if len == 0 { 0 } else { slot_cap(len) };
2295 slots.push(ColorSlot { off, cap, len });
2296 off += cap;
2297 }
2298 self.slots = slots;
2299 self.tail = off;
2300 // Global headroom: +50% + 256 words.
2301 self.cap_total = off + off / 2 + 256;
2302 }
2303
2304 /// Place `new_len` words for `entry`. Returns `Some(off)` with the
2305 /// (possibly relocated) slot offset, or `None` if the buffer must
2306 /// grow + repack. On relocation the old block is pushed to the free
2307 /// list; an in-place fit returns the unchanged offset.
2308 fn place(&mut self, entry: usize, new_len: u32) -> Option<u32> {
2309 let cur = self.slots[entry];
2310 if new_len <= cur.cap {
2311 self.slots[entry] = ColorSlot {
2312 len: new_len,
2313 ..cur
2314 };
2315 return Some(cur.off);
2316 }
2317 let old = (cur.off, cur.cap);
2318 // First-fit a freed block big enough for the live data.
2319 if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
2320 let (off, cap) = self.free.remove(i);
2321 self.free.push(old);
2322 self.slots[entry] = ColorSlot {
2323 off,
2324 cap,
2325 len: new_len,
2326 };
2327 return Some(off);
2328 }
2329 // Bump the tail if there's room.
2330 let want = slot_cap(new_len);
2331 if self.tail + want <= self.cap_total {
2332 let off = self.tail;
2333 self.tail += want;
2334 self.free.push(old);
2335 self.slots[entry] = ColorSlot {
2336 off,
2337 cap: want,
2338 len: new_len,
2339 };
2340 return Some(off);
2341 }
2342 None
2343 }
2344
2345 /// Append a slot for a brand-new entry of `new_len` words (used by
2346 /// [`SpriteRegistryResident::add_model`]). Returns `Some(off)` placed
2347 /// via the free list or the bump tail, or `None` if the buffer must
2348 /// grow + repack — in which case **no** slot is pushed (the caller's
2349 /// repack rebuilds every slot from scratch).
2350 fn push(&mut self, new_len: u32) -> Option<u32> {
2351 if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
2352 let (off, cap) = self.free.remove(i);
2353 self.slots.push(ColorSlot {
2354 off,
2355 cap,
2356 len: new_len,
2357 });
2358 return Some(off);
2359 }
2360 let want = slot_cap(new_len);
2361 if self.tail + want <= self.cap_total {
2362 let off = self.tail;
2363 self.tail += want;
2364 self.slots.push(ColorSlot {
2365 off,
2366 cap: want,
2367 len: new_len,
2368 });
2369 return Some(off);
2370 }
2371 None
2372 }
2373
2374 /// Free `entry`'s slot back to the pool ([`SpriteRegistryResident::
2375 /// remove_model`]). Its `(off, cap)` block joins the free list for
2376 /// first-fit reuse by a later [`Self::push`]; the slot is zeroed so a
2377 /// repack treats it as a 0-length tombstone.
2378 fn free(&mut self, entry: usize) {
2379 let s = self.slots[entry];
2380 if s.cap > 0 {
2381 self.free.push((s.off, s.cap));
2382 }
2383 self.slots[entry] = ColorSlot {
2384 off: 0,
2385 cap: 0,
2386 len: 0,
2387 };
2388 }
2389}
2390
2391/// Create a STORAGE buffer of u32s; pads empty input (wgpu rejects
2392/// zero-sized storage bindings).
2393#[allow(dead_code)]
2394fn storage_u32(device: &wgpu::Device, label: &str, data: &[u32]) -> wgpu::Buffer {
2395 use wgpu::util::DeviceExt;
2396 let bytes: &[u8] = if data.is_empty() {
2397 bytemuck::cast_slice(&[0u32])
2398 } else {
2399 bytemuck::cast_slice(data)
2400 };
2401 device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2402 label: Some(label),
2403 contents: bytes,
2404 usage: wgpu::BufferUsages::STORAGE,
2405 })
2406}
2407
2408/// Create an uninitialised `STORAGE | COPY_DST` `u32` buffer of `cap`
2409/// words (≥1). Written each frame via `queue.write_buffer`.
2410fn storage_dst_u32(device: &wgpu::Device, label: &str, cap: u32) -> wgpu::Buffer {
2411 device.create_buffer(&wgpu::BufferDescriptor {
2412 label: Some(label),
2413 size: u64::from(cap.max(1)) * 4,
2414 // COPY_SRC so test/debug harnesses can read the contents back
2415 // (PF.10's cull gate does); free at runtime.
2416 usage: wgpu::BufferUsages::STORAGE
2417 | wgpu::BufferUsages::COPY_DST
2418 | wgpu::BufferUsages::COPY_SRC,
2419 mapped_at_creation: false,
2420 })
2421}
2422
2423/// Create a `STORAGE | COPY_DST` `u32` buffer of `cap` words (≥ data
2424/// length, ≥ 1), initialised with `data` at offset 0 and the tail left
2425/// zeroed. Unlike [`storage_u32`] (STORAGE-only, exact-size) this both
2426/// reserves spare capacity and is `COPY_DST`, so the incremental
2427/// [`SpriteRegistryResident::update_model`] can `write_buffer` a growing
2428/// `colors`/`dirs` array in place. Filled via `mapped_at_creation` so no
2429/// queue is needed at upload time.
2430fn storage_dst_u32_cap(device: &wgpu::Device, label: &str, data: &[u32], cap: u32) -> wgpu::Buffer {
2431 let cap = cap.max(data.len() as u32).max(1);
2432 let buf = device.create_buffer(&wgpu::BufferDescriptor {
2433 label: Some(label),
2434 size: u64::from(cap) * 4,
2435 usage: wgpu::BufferUsages::STORAGE
2436 | wgpu::BufferUsages::COPY_DST
2437 | wgpu::BufferUsages::COPY_SRC,
2438 mapped_at_creation: true,
2439 });
2440 if !data.is_empty() {
2441 buf.slice(..(data.len() as u64 * 4))
2442 .get_mapped_range_mut()
2443 .copy_from_slice(bytemuck::cast_slice(data));
2444 }
2445 buf.unmap();
2446 buf
2447}
2448
2449/// Create a `STORAGE | COPY_DST` buffer of Pod records, exact-size
2450/// (≥ 1, zero-padded), so individual records can be rewritten in place
2451/// by [`SpriteRegistryResident::update_model`] on a relocation. The
2452/// record *count* never changes on an incremental edit (no model is
2453/// added/removed), so no slack is needed here.
2454fn storage_dst_pod<T: Pod + Zeroable>(
2455 device: &wgpu::Device,
2456 label: &str,
2457 data: &[T],
2458) -> wgpu::Buffer {
2459 let one = [T::zeroed()];
2460 let src: &[T] = if data.is_empty() { &one } else { data };
2461 let buf = device.create_buffer(&wgpu::BufferDescriptor {
2462 label: Some(label),
2463 size: std::mem::size_of_val(src) as u64,
2464 usage: wgpu::BufferUsages::STORAGE
2465 | wgpu::BufferUsages::COPY_DST
2466 | wgpu::BufferUsages::COPY_SRC,
2467 mapped_at_creation: true,
2468 });
2469 buf.slice(..)
2470 .get_mapped_range_mut()
2471 .copy_from_slice(bytemuck::cast_slice(src));
2472 buf.unmap();
2473 buf
2474}
2475
2476/// Create a `STORAGE | COPY_DST` Pod buffer holding `cap` records
2477/// (≥ `data.len()`, ≥ 1), initialised with `data` at record 0 and the
2478/// tail zeroed. The slack lets [`SpriteRegistryResident::add_model`] grow
2479/// the `model_meta` table without re-growing on every add.
2480fn storage_dst_pod_cap<T: Pod + Zeroable>(
2481 device: &wgpu::Device,
2482 label: &str,
2483 data: &[T],
2484 cap: u32,
2485) -> wgpu::Buffer {
2486 let rec = std::mem::size_of::<T>() as u64;
2487 let cap = u64::from(cap.max(data.len() as u32).max(1));
2488 let buf = device.create_buffer(&wgpu::BufferDescriptor {
2489 label: Some(label),
2490 size: cap * rec,
2491 usage: wgpu::BufferUsages::STORAGE
2492 | wgpu::BufferUsages::COPY_DST
2493 | wgpu::BufferUsages::COPY_SRC,
2494 mapped_at_creation: true,
2495 });
2496 if !data.is_empty() {
2497 buf.slice(..(data.len() as u64 * rec))
2498 .get_mapped_range_mut()
2499 .copy_from_slice(bytemuck::cast_slice(data));
2500 }
2501 buf.unmap();
2502 buf
2503}
2504
2505/// Create a STORAGE buffer of Pod records; pads empty input with one
2506/// zeroed `T`.
2507#[allow(dead_code)]
2508fn storage_pod<T: Pod + Zeroable>(device: &wgpu::Device, label: &str, data: &[T]) -> wgpu::Buffer {
2509 use wgpu::util::DeviceExt;
2510 let one = [T::zeroed()];
2511 let src: &[T] = if data.is_empty() { &one } else { data };
2512 device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2513 label: Some(label),
2514 contents: bytemuck::cast_slice(src),
2515 usage: wgpu::BufferUsages::STORAGE,
2516 })
2517}
2518
2519#[cfg(test)]
2520mod tests {
2521 use super::*;
2522 use roxlap_formats::kv6::{Kv6, Voxel};
2523
2524 /// CA.4 — the footprint rule's point test: inside-footprint +
2525 /// above-plane hides; below the plane or outside the XY footprint
2526 /// never does; rotated / scaled grids test in THEIR voxel frame.
2527 #[test]
2528 fn cutaway_clip_footprint_rule() {
2529 let clip = SpriteCutawayClip {
2530 origin: [10.0, 0.0, 0.0],
2531 inv_rows: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
2532 inv_vws: 1.0,
2533 xy_lo: [0.0, 0.0],
2534 xy_hi: [128.0, 128.0],
2535 z_clip: 120.0,
2536 };
2537 assert!(clip.hides([20.0, 5.0, 50.0]), "inside + above plane");
2538 assert!(!clip.hides([20.0, 5.0, 150.0]), "below the plane");
2539 assert!(!clip.hides([20.0, 5.0, 120.0]), "on the plane = visible");
2540 assert!(!clip.hides([300.0, 5.0, 50.0]), "outside the footprint");
2541 assert!(!clip.hides([5.0, 5.0, 50.0]), "x < origin: outside");
2542
2543 // 90°-about-z grid (local x = world y, local y = −world x): the
2544 // footprint follows the ROTATED frame, not the world axes.
2545 let rot = SpriteCutawayClip {
2546 inv_rows: [[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
2547 ..clip
2548 };
2549 assert!(
2550 rot.hides([-20.0, 40.0, 50.0]),
2551 "local (40, 30): inside the rotated footprint"
2552 );
2553 assert!(
2554 !rot.hides([20.0, 40.0, 50.0]),
2555 "local (40, -10): outside the rotated footprint"
2556 );
2557 // A scaled grid (vws = 0.5 ⇒ inv_vws = 2): world offsets double
2558 // in voxel coords, so world x = 74 → voxel x = 128 (outside).
2559 let scaled = SpriteCutawayClip {
2560 inv_vws: 2.0,
2561 ..clip
2562 };
2563 assert!(scaled.hides([40.0, 5.0, 50.0]), "voxel (60, 10, 100) < 120");
2564 assert!(!scaled.hides([74.0, 5.0, 50.0]), "voxel x = 128: outside");
2565 }
2566
2567 /// 2×1 kv6: column (0,0) has voxels at z=5 (red) and z=1 (green)
2568 /// stored OUT of z-order; column (1,0) has one voxel at z=3.
2569 fn kv6_unsorted() -> Kv6 {
2570 let mk = |z, col| Voxel {
2571 col,
2572 z,
2573 vis: 0,
2574 dir: 0,
2575 };
2576 Kv6 {
2577 xsiz: 2,
2578 ysiz: 1,
2579 zsiz: 8,
2580 xpiv: 0.0,
2581 ypiv: 0.0,
2582 zpiv: 0.0,
2583 voxels: vec![mk(5, 0xAA), mk(1, 0xBB), mk(3, 0xCC)],
2584 xlen: vec![2, 1],
2585 ylen: vec![vec![2], vec![1]],
2586 palette: None,
2587 }
2588 }
2589
2590 #[test]
2591 fn occupancy_bits_set_at_voxel_z() {
2592 let m = build_sprite_model(&kv6_unsorted());
2593 assert_eq!(m.dims, [2, 1, 8]);
2594 assert_eq!(m.occ_words_per_col, 1); // ceil(8/32)
2595 // col 0: bits 1 and 5; col 1: bit 3.
2596 assert_eq!(m.occupancy[0], (1 << 1) | (1 << 5));
2597 assert_eq!(m.occupancy[1], 1 << 3);
2598 }
2599
2600 #[test]
2601 fn colors_are_ascending_z_for_rank_lookup() {
2602 let m = build_sprite_model(&kv6_unsorted());
2603 // col 0 sorted ascending z ⇒ z=1 (green 0xBB) before z=5 (0xAA).
2604 assert_eq!(m.color_offsets, vec![0, 2, 3]);
2605 assert_eq!(&m.colors, &[0xBB, 0xAA, 0xCC]);
2606 }
2607
2608 #[test]
2609 fn identity_basis_inverts_to_identity() {
2610 let inv = mat3_inverse([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
2611 assert_eq!(inv, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
2612 }
2613
2614 #[test]
2615 fn fork_is_independent_of_parent() {
2616 let mut reg = SpriteModelRegistry::new();
2617 let base = reg.add(build_sprite_model(&kv6_unsorted()));
2618 let forked = reg.fork(base);
2619 assert_ne!(base, forked);
2620 // Recolour only the fork.
2621 reg.model_mut(forked).recolor(|_| 0x11);
2622 // Parent colours untouched; fork fully overwritten.
2623 assert_eq!(®.model(base).colors, &[0xBB, 0xAA, 0xCC]);
2624 assert_eq!(®.model(forked).colors, &[0x11, 0x11, 0x11]);
2625 }
2626
2627 #[test]
2628 fn remove_frees_chain_data_keeps_ids_stable() {
2629 let mut reg = SpriteModelRegistry::new();
2630 let a = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2631 let b = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2632 let len_before = reg.len();
2633 assert!(reg.is_live(a) && reg.is_live(b));
2634
2635 reg.remove(a);
2636 // Chain `a` is tombstoned (its entries are freed to empty models;
2637 // they're unreachable via `model()` now — that's the tombstone).
2638 assert!(!reg.is_live(a));
2639 // `b` is untouched and still live; `len()` (next id) is unchanged.
2640 assert!(reg.is_live(b));
2641 assert_eq!(®.model(b).colors, &[0xBB, 0xAA, 0xCC]);
2642 assert_eq!(reg.len(), len_before);
2643
2644 // A later add mints a fresh id past the tombstone (no slot reuse).
2645 let c = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2646 assert_eq!(c, len_before as u32);
2647 assert!(reg.is_live(c));
2648 // `b`'s id stayed valid across the remove + add round-trip.
2649 assert_eq!(®.model(b).colors, &[0xBB, 0xAA, 0xCC]);
2650 }
2651
2652 #[test]
2653 fn model_checked_guards_out_of_range_and_tombstoned() {
2654 // The guard `set_instance_model` relies on: `model()` would
2655 // index-panic on these, `model_checked` returns `None`.
2656 let mut reg = SpriteModelRegistry::new();
2657 let a = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2658 assert!(reg.model_checked(a).is_some());
2659 assert!(reg.model_checked(9999).is_none(), "out of range → None");
2660 reg.remove(a);
2661 assert!(reg.model_checked(a).is_none(), "tombstoned chain → None");
2662 }
2663
2664 #[test]
2665 fn remove_is_idempotent_and_bounds_safe() {
2666 let mut reg = SpriteModelRegistry::new();
2667 let a = reg.add(build_sprite_model(&kv6_unsorted()));
2668 reg.remove(a);
2669 reg.remove(a); // already removed → no-op, no panic
2670 reg.remove(999); // out of range → no-op
2671 assert!(!reg.is_live(a));
2672 assert!(!reg.is_live(999));
2673 }
2674
2675 #[test]
2676 fn registry_gpu_structs_have_expected_sizes() {
2677 assert_eq!(std::mem::size_of::<SpriteModelMeta>(), 48);
2678 // TV — grew 64 → 80 with the per-instance material id + alpha_mul
2679 // (+ 8 bytes pad to keep the 16-byte std430 stride).
2680 assert_eq!(std::mem::size_of::<SpriteInstanceGpu>(), 80);
2681 }
2682
2683 #[test]
2684 fn add_lod_builds_halving_mip_chain() {
2685 let mut reg = SpriteModelRegistry::new();
2686 // 8×8×8 single voxel-filled column model would be ideal, but
2687 // kv6_unsorted is 2×1×8 → mips: 2×1×8 → 1×1×4 → 1×1×2 → 1×1×1.
2688 let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2689 let m0 = reg.model(id);
2690 assert_eq!(m0.dims, [2, 1, 8]);
2691 assert!((m0.voxel_world_size - 1.0).abs() < 1e-6);
2692 }
2693
2694 /// kv6 from explicit voxels, ordered x-major/y-inner to match
2695 /// `build_sprite_model`'s column walk.
2696 fn kv6_from(xsiz: u32, ysiz: u32, zsiz: u32, voxels: &[(u32, u32, u16, u32)]) -> Kv6 {
2697 let mut ylen = vec![vec![0u16; ysiz as usize]; xsiz as usize];
2698 let mut flat = Vec::new();
2699 for x in 0..xsiz {
2700 for y in 0..ysiz {
2701 let mut col: Vec<(u16, u32)> = voxels
2702 .iter()
2703 .filter(|(vx, vy, _, _)| *vx == x && *vy == y)
2704 .map(|(_, _, z, c)| (*z, *c))
2705 .collect();
2706 col.sort_by_key(|(z, _)| *z);
2707 ylen[x as usize][y as usize] = col.len() as u16;
2708 for (z, c) in col {
2709 flat.push(Voxel {
2710 col: c,
2711 z,
2712 vis: 0,
2713 dir: 0,
2714 });
2715 }
2716 }
2717 }
2718 let xlen = ylen
2719 .iter()
2720 .map(|c| c.iter().map(|&v| u32::from(v)).sum())
2721 .collect();
2722 Kv6 {
2723 xsiz,
2724 ysiz,
2725 zsiz,
2726 xpiv: 0.0,
2727 ypiv: 0.0,
2728 zpiv: 0.0,
2729 voxels: flat,
2730 xlen,
2731 ylen,
2732 palette: None,
2733 }
2734 }
2735
2736 fn offsets_consistent(m: &SpriteModel) -> bool {
2737 let cols = (m.dims[0] * m.dims[1]) as usize;
2738 if m.color_offsets.len() != cols + 1 {
2739 return false;
2740 }
2741 // Monotonic non-decreasing + last == colors.len + each column's
2742 // span == its solid-voxel count.
2743 for w in m.color_offsets.windows(2) {
2744 if w[1] < w[0] {
2745 return false;
2746 }
2747 }
2748 m.color_offsets[cols] as usize == m.colors.len()
2749 }
2750
2751 #[test]
2752 fn carve_two_layers_keeps_offsets_consistent() {
2753 // Mirror the demo's carve: columns with voxels at varied z,
2754 // some sharing z=0/z=1, some not.
2755 let kv6 = kv6_from(
2756 3,
2757 2,
2758 8,
2759 &[
2760 (0, 0, 0, 0xA0),
2761 (0, 0, 1, 0xA1),
2762 (0, 0, 5, 0xA5),
2763 (1, 0, 1, 0xB1),
2764 (2, 1, 0, 0xC0),
2765 (2, 1, 3, 0xC3),
2766 ],
2767 );
2768 let mut m = build_sprite_model(&kv6);
2769 assert!(offsets_consistent(&m));
2770 for z in 0..2u32 {
2771 for y in 0..m.dims[1] {
2772 for x in 0..m.dims[0] {
2773 m.set_voxel(x, y, z, None);
2774 }
2775 }
2776 assert!(offsets_consistent(&m), "inconsistent after carving z={z}");
2777 // downsample must not panic on the carved model.
2778 let _ = m.downsample();
2779 }
2780 }
2781
2782 #[test]
2783 fn set_voxel_inserts_replaces_and_clears() {
2784 // col 0 starts with z=1 (0xBB), z=5 (0xAA); col 1 with z=3 (0xCC).
2785 let mut m = build_sprite_model(&kv6_unsorted());
2786
2787 // Insert z=3 into col 0 (between z=1 and z=5) → rank 1.
2788 assert!(m.set_voxel(0, 0, 3, Some(0x55)));
2789 assert_eq!(m.occupancy[0], (1 << 1) | (1 << 3) | (1 << 5));
2790 // col 0 colours ascending z: 0xBB(z1), 0x55(z3), 0xAA(z5).
2791 assert_eq!(m.color_offsets, vec![0, 3, 4]);
2792 assert_eq!(&m.colors, &[0xBB, 0x55, 0xAA, 0xCC]);
2793
2794 // Replace z=3 in place (no offset shift).
2795 assert!(m.set_voxel(0, 0, 3, Some(0x66)));
2796 assert_eq!(&m.colors, &[0xBB, 0x66, 0xAA, 0xCC]);
2797 assert_eq!(m.color_offsets, vec![0, 3, 4]);
2798
2799 // Clear z=1 (rank 0) from col 0.
2800 assert!(m.set_voxel(0, 0, 1, None));
2801 assert_eq!(m.occupancy[0], (1 << 3) | (1 << 5));
2802 assert_eq!(m.color_offsets, vec![0, 2, 3]);
2803 assert_eq!(&m.colors, &[0x66, 0xAA, 0xCC]);
2804
2805 // No-ops: clear an empty voxel, edit out of bounds.
2806 assert!(!m.set_voxel(0, 0, 2, None));
2807 assert!(!m.set_voxel(9, 0, 0, Some(1)));
2808 }
2809
2810 #[test]
2811 fn rebuild_lod_refreshes_coarse_levels_from_mip0() {
2812 let mut reg = SpriteModelRegistry::new();
2813 let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 3);
2814 // Recolour mip-0 only via model_mut, then rebuild the ladder.
2815 reg.model_mut(id).recolor(|_| 0x0000_2000);
2816 reg.rebuild_lod(id);
2817 // The mip-1 average of all-0x2000 voxels is still 0x2000.
2818 let lvl1_entry = reg.chains[id as usize][1] as usize;
2819 assert!(reg.entries[lvl1_entry]
2820 .colors
2821 .iter()
2822 .all(|&c| c == 0x0000_2000));
2823 }
2824
2825 // ---- GPU.12 incremental: colors/dirs suballocator -----------------
2826
2827 /// Every slot fits its data, has slack, doesn't overlap the next, and
2828 /// the buffer reserves tail headroom past the last slot.
2829 fn alloc_invariants(a: &ColorsAllocator, lens: &[u32]) {
2830 let mut prev_end = 0u32;
2831 for (e, &len) in lens.iter().enumerate() {
2832 let s = a.slot(e);
2833 assert_eq!(s.len, len, "slot {e} len");
2834 assert!(s.cap >= s.len, "slot {e} cap >= len");
2835 // In a freshly repacked layout slots are in entry order.
2836 assert!(s.off >= prev_end, "slot {e} overlaps previous");
2837 assert!(s.off + s.cap <= a.cap_total(), "slot {e} past cap_total");
2838 prev_end = s.off + s.cap;
2839 }
2840 assert!(a.cap_total() >= prev_end, "tail headroom");
2841 }
2842
2843 #[test]
2844 fn allocator_new_lays_out_with_slack_and_headroom() {
2845 let lens = [10u32, 0, 64, 7];
2846 let a = ColorsAllocator::new(&lens);
2847 alloc_invariants(&a, &lens);
2848 // Slack: a 64-word slot has cap > 64 so a small carve-grow fits.
2849 assert!(a.slot(2).cap > 64);
2850 // Headroom past the bump tail for early growth.
2851 assert!(a.cap_total() > a.slot(3).off + a.slot(3).cap);
2852 }
2853
2854 #[test]
2855 fn allocator_place_in_place_when_within_cap() {
2856 let mut a = ColorsAllocator::new(&[10, 20]);
2857 let off0 = a.slot(0).off;
2858 let cap0 = a.slot(0).cap;
2859 // Shrink: still the same slot.
2860 assert_eq!(a.place(0, 5), Some(off0));
2861 assert_eq!(a.slot(0).len, 5);
2862 assert_eq!(a.slot(0).cap, cap0);
2863 // Grow within slack: same offset, no relocation.
2864 assert_eq!(a.place(0, cap0), Some(off0));
2865 assert_eq!(a.slot(0).off, off0);
2866 assert!(a.free.is_empty(), "no relocation should free anything");
2867 }
2868
2869 #[test]
2870 fn allocator_place_relocates_to_tail_and_frees_old() {
2871 let mut a = ColorsAllocator::new(&[10, 20]);
2872 let old0 = (a.slot(0).off, a.slot(0).cap);
2873 let tail_before = a.tail;
2874 // Overgrow entry 0 past its cap → relocate to the bump tail.
2875 let new_len = a.slot(0).cap + 5;
2876 let off = a.place(0, new_len).expect("fits in headroom");
2877 assert_eq!(off, tail_before, "relocated to old tail");
2878 assert_eq!(a.slot(0).off, off);
2879 assert_eq!(a.slot(0).len, new_len);
2880 assert!(a.free.contains(&old0), "old slot freed");
2881 }
2882
2883 #[test]
2884 fn allocator_reuses_freed_block_first_fit() {
2885 // Entry 0 has a large slot; entry 1 a tiny one, so growing 1 must
2886 // relocate (it can't fit in place) and lands in 0's freed block.
2887 let mut a = ColorsAllocator::new(&[10, 2]);
2888 let old0 = (a.slot(0).off, a.slot(0).cap);
2889 // Relocate entry 0 to the tail, freeing its original block.
2890 let _ = a.place(0, a.slot(0).cap + 5).unwrap();
2891 assert!(a.free.contains(&old0));
2892 // Grow entry 1 past its (tiny) cap but ≤ the freed block's cap →
2893 // first-fit reuses that block rather than bumping the tail.
2894 let new1 = a.slot(1).cap + 1;
2895 assert!(new1 <= old0.1, "freed block big enough");
2896 let off = a.place(1, new1).expect("reuses freed block");
2897 assert_eq!(off, old0.0, "first-fit reused the freed slot offset");
2898 assert!(!a.free.contains(&old0), "freed block consumed");
2899 }
2900
2901 #[test]
2902 fn allocator_signals_grow_then_repack_restores() {
2903 let mut a = ColorsAllocator::new(&[8, 8]);
2904 // Force overflow: ask for far more than cap_total.
2905 let huge = a.cap_total() + 100;
2906 assert_eq!(a.place(0, huge), None, "overflow must signal grow");
2907 // Repack with the new lengths compacts + grows the buffer.
2908 a.repack(&[huge, 8]);
2909 alloc_invariants(&a, &[huge, 8]);
2910 assert!(a.cap_total() > huge);
2911 // After repack the entry now fits in place.
2912 assert_eq!(a.place(0, huge), Some(a.slot(0).off));
2913 }
2914
2915 /// Drive the allocator like a real carve loop (mirroring
2916 /// `update_model`): one model's colour count drifts up and down
2917 /// across many edits while two neighbours stay put. Growth is
2918 /// absorbed in place / via the free list / by the bump tail, and on
2919 /// the rare overflow we repack (as `update_model` does). After every
2920 /// edit the live `[off, off+len)` windows must stay disjoint.
2921 #[test]
2922 fn allocator_carve_loop_keeps_live_windows_disjoint() {
2923 let mut a = ColorsAllocator::new(&[40, 12, 40]);
2924 let mut lens = [40u32, 12, 40];
2925 // A deterministic up/down walk of entry 1's length, incl. a jump
2926 // that forces at least one grow+repack.
2927 let walk = [13u32, 30, 60, 18, 9, 80, 80, 25, 200, 7];
2928 let mut grew = false;
2929 for &len in &walk {
2930 lens[1] = len;
2931 // Entry 1 re-placed; on overflow, repack the whole set.
2932 if a.place(1, len).is_none() {
2933 grew = true;
2934 a.repack(&lens);
2935 } else {
2936 // Neighbours fit in place every time.
2937 assert_eq!(a.place(0, 40), Some(a.slot(0).off));
2938 assert_eq!(a.place(2, 40), Some(a.slot(2).off));
2939 }
2940 assert_eq!(a.slot(1).len, len);
2941
2942 // No two entries' live windows overlap.
2943 let mut wins: Vec<(u32, u32)> =
2944 (0..3).map(|e| (a.slot(e).off, a.slot(e).len)).collect();
2945 wins.sort_by_key(|w| w.0);
2946 for pair in wins.windows(2) {
2947 let (o0, l0) = pair[0];
2948 let (o1, _) = pair[1];
2949 assert!(o0 + l0 <= o1, "live windows overlap: {pair:?}");
2950 }
2951 }
2952 assert!(grew, "the 200-word jump should have forced a repack");
2953 }
2954
2955 // --- incremental instance path (device-backed; skips w/o adapter) ---
2956
2957 fn headless() -> Option<crate::HeadlessGpu> {
2958 match crate::HeadlessGpu::new_blocking(crate::GpuRendererSettings::default()) {
2959 Ok(h) => Some(h),
2960 Err(e) => {
2961 eprintln!("[skip] no GPU adapter reachable: {e}");
2962 None
2963 }
2964 }
2965 }
2966
2967 fn one_model_registry() -> (SpriteModelRegistry, u32) {
2968 let mut reg = SpriteModelRegistry::new();
2969 let id = reg.add(build_sprite_model(&kv6_unsorted()));
2970 (reg, id)
2971 }
2972
2973 fn inst(model_id: u32, pos: [f32; 3]) -> SpriteInstance {
2974 use roxlap_formats::sprite::Sprite;
2975 SpriteInstance::new(
2976 model_id,
2977 SpriteInstanceTransform::from_sprite(&Sprite::axis_aligned(kv6_unsorted(), pos)),
2978 )
2979 }
2980
2981 /// PS.1 — a scaled basis grows the cull sphere with the pose: the
2982 /// transform keeps the longest basis column, and `make_cull` seeds
2983 /// `radius = model.bound_radius() × max_scale`, so scaled-up
2984 /// instances (particles) no longer under-cull at screen edges.
2985 #[test]
2986 fn scaled_basis_scales_cull_radius() {
2987 use roxlap_formats::sprite::Sprite;
2988
2989 let mut reg = SpriteModelRegistry::new();
2990 let chain = reg.add(build_sprite_model(&kv6_unsorted()));
2991 let model_r = reg.model(chain).bound_radius();
2992
2993 let scaled = |k: f32, pos: [f32; 3]| {
2994 let mut s = Sprite::axis_aligned(kv6_unsorted(), pos);
2995 for a in 0..3 {
2996 s.s[a] *= k;
2997 s.h[a] *= k;
2998 s.f[a] *= k;
2999 }
3000 SpriteInstanceTransform::from_sprite(&s)
3001 };
3002
3003 // Unit basis: max_scale 1, radius = the model's (float-exact).
3004 let unit = inst(chain, [0.0; 3]);
3005 assert_eq!(unit.transform.max_scale, 1.0);
3006 assert_eq!(make_cull(®, &unit).radius, model_r);
3007
3008 // 2× uniform scale doubles both.
3009 let xf2 = scaled(2.0, [0.0; 3]);
3010 assert!((xf2.max_scale - 2.0).abs() < 1e-6);
3011 let big = SpriteInstance::new(chain, xf2);
3012 assert!((make_cull(®, &big).radius - 2.0 * model_r).abs() < 1e-4);
3013
3014 // Anisotropic scale takes the longest column.
3015 let mut s = Sprite::axis_aligned(kv6_unsorted(), [0.0; 3]);
3016 for a in 0..3 {
3017 s.h[a] *= 3.0;
3018 s.f[a] *= 0.5;
3019 }
3020 let xf = SpriteInstanceTransform::from_sprite(&s);
3021 assert!((xf.max_scale - 3.0).abs() < 1e-6);
3022 }
3023
3024 #[test]
3025 fn append_grows_count_and_capacity_pow2() {
3026 let Some(h) = headless() else { return };
3027 let (reg, m) = one_model_registry();
3028 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(m, [0.0; 3])]);
3029 assert_eq!(res.instance_count(), 1);
3030 assert_eq!(res.instance_capacity, 1);
3031
3032 // Append 4 → count 5, capacity grows to next_pow2(5) = 8.
3033 let more: Vec<_> = (1..=4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
3034 let base = res.append_instances(&h.device, ®, &more);
3035 assert_eq!(base, 1, "first appended index follows the seed instance");
3036 assert_eq!(res.instance_count(), 5);
3037 assert_eq!(res.instance_capacity, 8, "power-of-two growth");
3038
3039 // A second append that still fits keeps the same capacity (no realloc).
3040 let base2 = res.append_instances(&h.device, ®, &[inst(m, [9.0, 0.0, 0.0])]);
3041 assert_eq!(base2, 5);
3042 assert_eq!(res.instance_count(), 6);
3043 assert_eq!(res.instance_capacity, 8, "fits existing capacity, no grow");
3044 }
3045
3046 #[test]
3047 fn append_empty_is_noop() {
3048 let Some(h) = headless() else { return };
3049 let (reg, m) = one_model_registry();
3050 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(m, [0.0; 3])]);
3051 let base = res.append_instances(&h.device, ®, &[]);
3052 assert_eq!(base, 1);
3053 assert_eq!(res.instance_count(), 1);
3054 assert_eq!(res.instance_capacity, 1);
3055 }
3056
3057 /// Read `words` u32s back from a GPU buffer (needs COPY_SRC).
3058 fn read_u32(h: &crate::HeadlessGpu, buf: &wgpu::Buffer, words: u64) -> Vec<u32> {
3059 let bytes = words * 4;
3060 let staging = h.device.create_buffer(&wgpu::BufferDescriptor {
3061 label: Some("readback"),
3062 size: bytes,
3063 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
3064 mapped_at_creation: false,
3065 });
3066 let mut enc = h
3067 .device
3068 .create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
3069 enc.copy_buffer_to_buffer(buf, 0, &staging, 0, bytes);
3070 h.queue.submit(std::iter::once(enc.finish()));
3071 let slice = staging.slice(..);
3072 let (tx, rx) = std::sync::mpsc::channel();
3073 slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
3074 h.device.poll(wgpu::PollType::wait_indefinitely()).ok();
3075 rx.recv().unwrap().unwrap();
3076 let data = slice.get_mapped_range();
3077 let out = bytemuck::cast_slice::<u8, u32>(&data).to_vec();
3078 drop(data);
3079 staging.unmap();
3080 out
3081 }
3082
3083 /// A second distinct model so add_model has real new geometry to lay
3084 /// down (different dims + colours from `kv6_unsorted`).
3085 fn kv6_other() -> Kv6 {
3086 let mk = |z, col| Voxel {
3087 col,
3088 z,
3089 vis: 0,
3090 dir: 0,
3091 };
3092 Kv6 {
3093 xsiz: 1,
3094 ysiz: 1,
3095 zsiz: 4,
3096 xpiv: 0.0,
3097 ypiv: 0.0,
3098 zpiv: 0.0,
3099 voxels: vec![mk(0, 0x11), mk(2, 0x22)],
3100 xlen: vec![2],
3101 ylen: vec![vec![2]],
3102 palette: None,
3103 }
3104 }
3105
3106 /// add_model lays the new model's volume on the GPU at the offsets its
3107 /// meta record claims — verified by reading the shared buffers back
3108 /// and matching each entry against its source SpriteModel.
3109 #[test]
3110 fn add_model_uploads_new_volume_incrementally() {
3111 let Some(h) = headless() else { return };
3112
3113 // Residency starts with model A only.
3114 let mut reg = SpriteModelRegistry::new();
3115 let a = reg.add(build_sprite_model(&kv6_unsorted()));
3116 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(a, [0.0; 3])]);
3117 assert_eq!(res.chains.len(), 1);
3118 let entries_before = res.meta.len();
3119
3120 // Append model B (single-level) to the registry, then sync it.
3121 let b = reg.add(build_sprite_model(&kv6_other()));
3122 res.add_model(&h.device, &h.queue, ®, b);
3123 assert_eq!(res.chains.len(), 2);
3124 assert_eq!(res.meta.len(), entries_before + 1, "one new entry");
3125
3126 // Read the shared buffers back and check EVERY entry's data sits
3127 // where its meta record points — both the pre-existing A and the
3128 // newly streamed B.
3129 let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3130 let coloff = read_u32(&h, &res.color_offsets, u64::from(res.coloff_cap));
3131 let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3132 for (e, m) in reg.entries.iter().enumerate() {
3133 let meta = res.meta[e];
3134 let oo = meta.occupancy_offset as usize;
3135 assert_eq!(
3136 &occ[oo..oo + m.occupancy.len()],
3137 &m.occupancy[..],
3138 "occ entry {e}"
3139 );
3140 let co = meta.color_offsets_offset as usize;
3141 assert_eq!(
3142 &coloff[co..co + m.color_offsets.len()],
3143 &m.color_offsets[..],
3144 "color_offsets entry {e}"
3145 );
3146 let cc = meta.colors_offset as usize;
3147 assert_eq!(
3148 &cols[cc..cc + m.colors.len()],
3149 &m.colors[..],
3150 "colors entry {e}"
3151 );
3152 }
3153
3154 // And an instance of the freshly-added model can now be appended.
3155 let base = res.append_instances(&h.device, ®, &[inst(b, [5.0, 0.0, 0.0])]);
3156 assert_eq!(base, 1);
3157 assert_eq!(res.instance_count(), 2);
3158 }
3159
3160 /// Adding many small models forces the volume buffers to grow + rebuild
3161 /// at least once; every entry must still read back correctly across the
3162 /// grow boundary.
3163 #[test]
3164 fn add_model_survives_buffer_growth() {
3165 let Some(h) = headless() else { return };
3166 let mut reg = SpriteModelRegistry::new();
3167 let a = reg.add(build_sprite_model(&kv6_unsorted()));
3168 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(a, [0.0; 3])]);
3169 let occ_cap0 = res.occ_cap;
3170
3171 // 40 adds — occupancy starts exact-sized (cap == used), so the very
3172 // first add overflows and grows; later ones ride the slack.
3173 for _ in 0..40 {
3174 let id = reg.add(build_sprite_model(&kv6_other()));
3175 res.add_model(&h.device, &h.queue, ®, id);
3176 }
3177 assert_eq!(res.chains.len(), 41);
3178 assert!(res.occ_cap > occ_cap0, "occupancy buffer grew");
3179
3180 let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3181 let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3182 for (e, m) in reg.entries.iter().enumerate() {
3183 let meta = res.meta[e];
3184 let oo = meta.occupancy_offset as usize;
3185 assert_eq!(
3186 &occ[oo..oo + m.occupancy.len()],
3187 &m.occupancy[..],
3188 "occ entry {e}"
3189 );
3190 let cc = meta.colors_offset as usize;
3191 assert_eq!(
3192 &cols[cc..cc + m.colors.len()],
3193 &m.colors[..],
3194 "colors entry {e}"
3195 );
3196 }
3197 }
3198
3199 /// Regression (downstream report, 0.27.0): a `remove_model` hole
3200 /// followed by an occupancy-overflow `add_model` desynced every live
3201 /// entry behind the hole — the grow path rebuilt the buffer tightly
3202 /// (tombstoned entries contribute nothing) but kept the STALE bump
3203 /// offsets in `meta`, so those models read shifted occupancy words
3204 /// ("black stripe planes") until an `update_model` happened to
3205 /// rewrite them at the stale offset. The overflow path now routes
3206 /// through `compact_concat`, which recomputes the offsets it
3207 /// uploads.
3208 #[test]
3209 fn growth_after_remove_keeps_offsets_in_sync() {
3210 let Some(h) = headless() else { return };
3211 let mut reg = SpriteModelRegistry::new();
3212 // Three models; the middle one becomes the hole.
3213 let a = reg.add(build_sprite_model(&kv6_unsorted()));
3214 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(a, [0.0; 3])]);
3215 let b = reg.add(build_sprite_model(&kv6_other()));
3216 res.add_model(&h.device, &h.queue, ®, b);
3217 let c = reg.add(build_sprite_model(&kv6_other()));
3218 res.add_model(&h.device, &h.queue, ®, c);
3219 let _ = c;
3220
3221 // Tombstone the middle chain: resident hole + zero-length
3222 // registry entry (exactly the facade's remove path).
3223 res.remove_model(b);
3224 reg.remove(b);
3225
3226 // Keep adding until occupancy overflows — the grow/rebuild path.
3227 let cap_before = res.occ_cap;
3228 let mut guard = 0;
3229 while res.occ_cap == cap_before {
3230 let id = reg.add(build_sprite_model(&kv6_other()));
3231 res.add_model(&h.device, &h.queue, ®, id);
3232 guard += 1;
3233 assert!(guard < 10_000, "growth never triggered");
3234 }
3235
3236 // Every LIVE entry's meta offset must point at its actual data
3237 // in the rebuilt buffers.
3238 let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3239 let coloff = read_u32(&h, &res.color_offsets, u64::from(res.coloff_cap));
3240 for (e, m) in reg.entries.iter().enumerate() {
3241 if res.dead[e] {
3242 continue;
3243 }
3244 let meta = res.meta[e];
3245 let oo = meta.occupancy_offset as usize;
3246 assert_eq!(
3247 &occ[oo..oo + m.occupancy.len()],
3248 &m.occupancy[..],
3249 "occ entry {e} reads at its meta offset"
3250 );
3251 let co = meta.color_offsets_offset as usize;
3252 assert_eq!(
3253 &coloff[co..co + m.color_offsets.len()],
3254 &m.color_offsets[..],
3255 "color_offsets entry {e}"
3256 );
3257 }
3258 }
3259
3260 /// VCL.2 — a decoded voxel clip's frames register as a flipbook of LOD
3261 /// chains, and `set_instance_model` flips which frame an instance
3262 /// draws. The cull state it updates is exactly what
3263 /// `cull_bin_upload` packs into the GPU instance buffer each frame, so
3264 /// TV.3 (clip wiring): `sprite_model_from_clip_frame_with_materials`
3265 /// classifies a clip frame's voxels into a per-voxel `materials` array
3266 /// (parallel to `colors`) by colour; an empty map leaves it empty (the
3267 /// all-opaque clip), identical to `sprite_model_from_clip_frame`.
3268 #[test]
3269 fn clip_frame_with_materials_classifies_by_color() {
3270 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
3271
3272 let dims = [1u32, 1, 4];
3273 let owpc = dims[2].div_ceil(32).max(1) as usize; // 1
3274 let glass = 0x80AA_BBCC;
3275 let stone = 0x8011_2233;
3276 let frame = VoxelFrame {
3277 occupancy: {
3278 let mut occ = vec![0u32; owpc];
3279 occ[0] |= (1 << 0) | (1 << 1);
3280 occ
3281 },
3282 colors: vec![stone, glass], // ascending z: z=0 stone, z=1 glass
3283 color_offsets: vec![0, 2],
3284 };
3285 let clip = VoxelClip::from_frames(
3286 dims,
3287 [0.5, 0.5, 2.0],
3288 1.0,
3289 LoopMode::Loop,
3290 &[frame],
3291 &[],
3292 33,
3293 0,
3294 );
3295 let decoded = clip.decode().expect("decode");
3296
3297 // Map only the glass colour → material 2; stone stays opaque (0).
3298 let m = sprite_model_from_clip_frame_with_materials(&decoded, 0, &[(Rgb(0x00AA_BBCC), 2)]);
3299 assert_eq!(
3300 m.materials.len(),
3301 m.colors.len(),
3302 "materials parallel to colors"
3303 );
3304 // `colors` is in popcount-rank (ascending z) order: stone then glass.
3305 assert_eq!(
3306 m.materials,
3307 vec![0u8, 2u8],
3308 "stone opaque, glass material 2"
3309 );
3310
3311 // Empty map ⇒ no per-voxel materials, identical to the plain builder.
3312 let plain = sprite_model_from_clip_frame(&decoded, 0);
3313 let plain_mat = sprite_model_from_clip_frame_with_materials(&decoded, 0, &[]);
3314 assert!(plain.materials.is_empty());
3315 assert!(plain_mat.materials.is_empty());
3316 assert_eq!(plain.colors, plain_mat.colors);
3317 }
3318
3319 /// TV.3 (streaming-clip refresh path): `build_sprite_model_with_materials`
3320 /// — the builder behind `GpuBackend::update_sprite_model_with_materials`,
3321 /// which a streaming clip re-runs each frame — classifies a kv6's voxels
3322 /// into a per-voxel `materials` array (popcount-rank order) by colour.
3323 #[test]
3324 fn build_with_materials_classifies_by_color() {
3325 let glass = 0x80AA_BBCC;
3326 let stone = 0x8011_2233;
3327 // One column (x=0,y=0), two voxels: z=0 stone, z=1 glass.
3328 let kv6 = kv6_from(1, 1, 4, &[(0, 0, 0, stone), (0, 0, 1, glass)]);
3329
3330 let m = build_sprite_model_with_materials(&kv6, &[(Rgb(0x00AA_BBCC), 2)]);
3331 assert_eq!(
3332 m.materials.len(),
3333 m.colors.len(),
3334 "materials parallel to colors"
3335 );
3336 assert_eq!(
3337 m.materials,
3338 vec![0u8, 2u8],
3339 "stone opaque, glass material 2"
3340 );
3341
3342 // Empty map ⇒ no per-voxel materials, identical to `build_sprite_model`.
3343 let plain = build_sprite_model(&kv6);
3344 let plain_mat = build_sprite_model_with_materials(&kv6, &[]);
3345 assert!(plain.materials.is_empty());
3346 assert!(plain_mat.materials.is_empty());
3347 assert_eq!(plain.colors, plain_mat.colors);
3348 }
3349
3350 /// flipping `chain_id` redirects the rendered instance to the new
3351 /// frame's resident volume.
3352 #[test]
3353 fn voxel_clip_flipbook_set_instance_model() {
3354 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
3355 let Some(h) = headless() else { return };
3356
3357 // Two distinct frames of a 1×1×4 clip: frame 0 has a voxel at z=0;
3358 // frame 1 adds z=1 — different occupancy + a longer colour run.
3359 let dims = [1u32, 1, 4];
3360 let owpc = dims[2].div_ceil(32).max(1) as usize; // 1
3361 let mk_frame = |zs: &[u32], cols: &[u32]| -> VoxelFrame {
3362 let mut occ = vec![0u32; owpc];
3363 for &z in zs {
3364 occ[(z >> 5) as usize] |= 1u32 << (z & 31);
3365 }
3366 VoxelFrame {
3367 occupancy: occ,
3368 colors: cols.to_vec(),
3369 color_offsets: vec![0, cols.len() as u32],
3370 }
3371 };
3372 let f0 = mk_frame(&[0], &[0x8011_2233]);
3373 let f1 = mk_frame(&[0, 1], &[0x8011_2233, 0x80AA_BBCC]);
3374 let clip = VoxelClip::from_frames(
3375 dims,
3376 [0.5, 0.5, 2.0],
3377 1.0,
3378 LoopMode::Loop,
3379 &[f0, f1],
3380 &[],
3381 33,
3382 0,
3383 );
3384 let decoded = clip.decode().expect("decode");
3385
3386 // Each frame → a single-level chain; both volumes resident + distinct.
3387 let mut reg = SpriteModelRegistry::new();
3388 let c0 = reg.add(sprite_model_from_clip_frame(&decoded, 0));
3389 let c1 = reg.add(sprite_model_from_clip_frame(&decoded, 1));
3390 assert_eq!(reg.model(c0).colors.len(), 1);
3391 assert_eq!(reg.model(c1).colors.len(), 2);
3392
3393 // One instance, in front of the test frustum, drawing frame 0.
3394 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(c0, [0.0, 0.0, 5.0])]);
3395 assert_eq!(res.cull[0].chain_id, c0);
3396
3397 // Flip to frame 1: the cull now draws chain c1 (radius reseeded).
3398 res.set_instance_model(®, 0, c1);
3399 assert_eq!(res.cull[0].chain_id, c1);
3400 assert_eq!(res.cull[0].radius, reg.model(c1).bound_radius());
3401
3402 // The next cull packs the new chain into the GPU instance buffer
3403 // (visible, no panic).
3404 let f = test_frustum();
3405 let (visible, _, _) =
3406 res.cull_bin_upload(&h.device, &h.queue, &f, 64, 64, 16, 1.0, &[], None, 0);
3407 assert_eq!(visible, 1);
3408
3409 // …and back to frame 0.
3410 res.set_instance_model(®, 0, c0);
3411 assert_eq!(res.cull[0].chain_id, c0);
3412
3413 // Out-of-range index is a safe no-op.
3414 res.set_instance_model(®, 99, c1);
3415 assert_eq!(res.cull[0].chain_id, c0);
3416 }
3417
3418 fn test_frustum() -> ViewFrustum {
3419 ViewFrustum {
3420 pos: [0.0, 0.0, 0.0],
3421 right: [1.0, 0.0, 0.0],
3422 down: [0.0, 1.0, 0.0],
3423 forward: [0.0, 0.0, 1.0],
3424 half_w: 1.0,
3425 half_h: 1.0,
3426 far: 10_000.0,
3427 }
3428 }
3429
3430 #[test]
3431 fn remove_model_tombstones_frees_and_reuses() {
3432 let Some(h) = headless() else { return };
3433 // Residency with models A and B, one instance each.
3434 let mut reg = SpriteModelRegistry::new();
3435 let a = reg.add(build_sprite_model(&kv6_unsorted()));
3436 let b = reg.add(build_sprite_model(&kv6_other()));
3437 let mut res = SpriteRegistryResident::upload(
3438 &h.device,
3439 ®,
3440 &[inst(a, [0.0; 3]), inst(b, [1.0, 0.0, 0.0])],
3441 );
3442 assert_eq!(res.live_model_count(), 2);
3443 assert_eq!(res.dead_model_count(), 0);
3444
3445 // Remove B → tombstoned, its colours freed into the pool.
3446 res.remove_model(b);
3447 assert_eq!(res.live_model_count(), 1);
3448 assert_eq!(res.dead_model_count(), 1);
3449 assert_eq!(res.dead.iter().filter(|&&d| d).count(), 1, "one entry dead");
3450 assert!(!res.colors_alloc.free.is_empty(), "B's colour slot freed");
3451
3452 // Adding C reuses the freed slot (free-list first-fit).
3453 let c = reg.add(build_sprite_model(&kv6_other()));
3454 res.add_model(&h.device, &h.queue, ®, c);
3455 assert_eq!(res.live_model_count(), 2);
3456
3457 // A and C read back correctly; B is dead (skipped).
3458 let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3459 for e in [a as usize, c as usize] {
3460 let m = ®.entries[e];
3461 let cc = res.meta[e].colors_offset as usize;
3462 assert_eq!(
3463 &cols[cc..cc + m.colors.len()],
3464 &m.colors[..],
3465 "colors entry {e}"
3466 );
3467 }
3468
3469 // The lingering instance of removed B is skipped without panic.
3470 let f = test_frustum();
3471 let _ = res.cull_bin_upload(&h.device, &h.queue, &f, 64, 64, 16, 1.0, &[], None, 0);
3472 }
3473
3474 #[test]
3475 fn compact_reclaims_holes_keeps_ids_stable() {
3476 let Some(h) = headless() else { return };
3477 let mut reg = SpriteModelRegistry::new();
3478 let a = reg.add(build_sprite_model(&kv6_unsorted()));
3479 let b = reg.add(build_sprite_model(&kv6_other()));
3480 let c = reg.add(build_sprite_model(&kv6_other()));
3481 let mut res = SpriteRegistryResident::upload(
3482 &h.device,
3483 ®,
3484 &[inst(a, [0.0; 3]), inst(b, [1.0; 3]), inst(c, [2.0; 3])],
3485 );
3486 let occ_used_full = res.occ_used;
3487
3488 // Remove the middle model, then compact.
3489 res.remove_model(b);
3490 res.compact(&h.device, &h.queue, ®);
3491
3492 // Holes reclaimed: occupancy now only covers A + C.
3493 let live_occ: u32 = [a, c]
3494 .iter()
3495 .map(|&e| reg.entries[e as usize].occupancy.len() as u32)
3496 .sum();
3497 assert_eq!(res.occ_used, live_occ);
3498 assert!(res.occ_used < occ_used_full, "compaction shrank occupancy");
3499 // Dead entry keeps a zeroed tombstone; ids unchanged.
3500 assert_eq!(res.meta[b as usize].occupancy_offset, 0);
3501 assert_eq!(res.live_model_count(), 2);
3502 assert_eq!(res.dead_model_count(), 1);
3503
3504 // Live entries read back correctly at their new offsets.
3505 let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3506 let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3507 for &e in &[a as usize, c as usize] {
3508 let m = ®.entries[e];
3509 let oo = res.meta[e].occupancy_offset as usize;
3510 assert_eq!(
3511 &occ[oo..oo + m.occupancy.len()],
3512 &m.occupancy[..],
3513 "occ {e}"
3514 );
3515 let cc = res.meta[e].colors_offset as usize;
3516 assert_eq!(&cols[cc..cc + m.colors.len()], &m.colors[..], "cols {e}");
3517 }
3518
3519 // Chain ids still valid: C's chain still resolves; B's is empty.
3520 assert!(!res.chains[c as usize].is_empty());
3521 assert!(res.chains[b as usize].is_empty());
3522 }
3523
3524 #[test]
3525 fn remove_swap_semantics_and_capacity_retained() {
3526 let Some(h) = headless() else { return };
3527 let (reg, m) = one_model_registry();
3528 let seed: Vec<_> = (0..4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
3529 let mut res = SpriteRegistryResident::upload(&h.device, ®, &seed);
3530 assert_eq!(res.instance_count(), 4);
3531 let cap = res.instance_capacity;
3532
3533 // Remove a middle element → the previous last (idx 3) moved into it.
3534 assert_eq!(res.remove_instance(1), Some(3));
3535 assert_eq!(res.instance_count(), 3);
3536
3537 // Remove the current last (idx 2) → nothing moved.
3538 assert_eq!(res.remove_instance(2), None);
3539 assert_eq!(res.instance_count(), 2);
3540
3541 // Out of range → None.
3542 assert_eq!(res.remove_instance(99), None);
3543 assert_eq!(res.instance_count(), 2);
3544
3545 // Capacity is retained for reuse (no shrink).
3546 assert_eq!(res.instance_capacity, cap);
3547 }
3548}