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/// PF.10 — everything `cull_bin_upload`'s result depends on besides the
775/// registry contents (float fields compared bitwise). Paired with the
776/// "registry changed" invalidation (`last_cull = None` in every mutating
777/// method): when the key matches the previous frame's, the cull, the
778/// binning, and all four buffer uploads are skipped — the buffers already
779/// hold exactly this frame's data.
780#[derive(Clone, Copy, PartialEq)]
781struct CullKey {
782 frustum: [u32; 15],
783 screen: [u32; 4],
784}
785
786impl CullKey {
787 fn new(f: &ViewFrustum, screen_w: u32, screen_h: u32, tile_size: u32, lod_px: f32) -> Self {
788 let b = |v: f32| v.to_bits();
789 Self {
790 frustum: [
791 b(f.pos[0]),
792 b(f.pos[1]),
793 b(f.pos[2]),
794 b(f.right[0]),
795 b(f.right[1]),
796 b(f.right[2]),
797 b(f.down[0]),
798 b(f.down[1]),
799 b(f.down[2]),
800 b(f.forward[0]),
801 b(f.forward[1]),
802 b(f.forward[2]),
803 b(f.half_w),
804 b(f.half_h),
805 b(f.far),
806 ],
807 screen: [screen_w, screen_h, tile_size, lod_px.to_bits()],
808 }
809 }
810}
811
812/// PF.10 — reusable cull/bin workspace (was 6+ fresh `Vec`s per frame).
813#[derive(Default)]
814struct CullScratch {
815 visible: Vec<SpriteInstanceGpu>,
816 boxes: Vec<[i32; 4]>,
817 colmul: Vec<u32>,
818 counts: Vec<u32>,
819 tile_ranges: Vec<u32>,
820 tile_instances: Vec<u32>,
821 cursor: Vec<u32>,
822}
823
824/// Build one CPU cull record from a user [`SpriteInstance`]: pack the
825/// transform, seed the bounding sphere from the chain's finest model, and
826/// start `colmul` at identity. Shared by the full
827/// [`SpriteRegistryResident::upload`] and the incremental
828/// [`SpriteRegistryResident::append_instances`].
829fn make_cull(registry: &SpriteModelRegistry, i: &SpriteInstance) -> CullInstance {
830 let model_radius = registry.model(i.model_id).bound_radius();
831 CullInstance {
832 gpu: SpriteInstanceGpu {
833 inv_rot0: i.transform.inv_rot[0],
834 inv_rot1: i.transform.inv_rot[1],
835 inv_rot2: i.transform.inv_rot[2],
836 pos: i.transform.pos,
837 model_id: i.model_id, // placeholder; cull rewrites per frame
838 material: u32::from(i.material),
839 alpha_mul: f32::from(i.alpha_mul) / 255.0,
840 flags: i.flags,
841 tint: i.tint,
842 },
843 chain_id: i.model_id,
844 center: i.transform.pos,
845 radius: model_radius * i.transform.max_scale,
846 model_radius,
847 max_scale: i.transform.max_scale,
848 colmul: identity_colmul(),
849 }
850}
851
852/// Allocate the `instances` capacity buffer (`STORAGE | COPY_DST`) sized
853/// for `cap` records (≥1). Left uninitialised — `cull_bin_upload`
854/// rewrites it (offset 0) each frame, and `append_instances` seeds the
855/// live records after a grow.
856fn instances_buffer(device: &wgpu::Device, cap: u32) -> wgpu::Buffer {
857 device.create_buffer(&wgpu::BufferDescriptor {
858 label: Some("roxlap-gpu sprite_reg.instances"),
859 size: u64::from(cap.max(1)) * std::mem::size_of::<SpriteInstanceGpu>() as u64,
860 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
861 mapped_at_creation: false,
862 })
863}
864
865/// One sprite instance: a model reference + world pose.
866#[derive(Debug, Clone, Copy)]
867pub struct SpriteInstance {
868 /// LOD-chain id from [`SpriteModelRegistry::add`] / `add_lod` —
869 /// which model this instance draws. The per-frame cull substitutes
870 /// the distance-picked concrete mip entry.
871 pub model_id: u32,
872 /// World pose: inverse model→world rotation/scale + position (see
873 /// [`SpriteInstanceTransform::from_sprite`]).
874 pub transform: SpriteInstanceTransform,
875 /// Voxel-material id (TV stage): indexes the renderer's global material
876 /// palette for this instance's opacity + blend mode. `0` (the default)
877 /// is opaque, so an unset instance renders unchanged.
878 pub material: u8,
879 /// Per-instance alpha multiplier (TV stage), `0..=255` (`255` =
880 /// unscaled, the default).
881 pub alpha_mul: u8,
882 /// XS.4 — sprite shadow flags (`roxlap_formats::sprite` bits 4/5:
883 /// `NO_SHADOW_CAST` / `NO_SHADOW_RECEIVE`). `0` (default) ⇒ casts +
884 /// receives. Only honoured when the device is sprite-shadow capable.
885 pub flags: u32,
886 /// Per-instance RGB tint, packed `0x00RRGGBB` (white `0x00FF_FFFF` = no-op).
887 pub tint: u32,
888}
889
890impl SpriteInstance {
891 /// A model reference + pose with the default opaque material
892 /// (`material = 0`, `alpha_mul = 255`), shadows on (`flags = 0`), and no
893 /// tint (`0x00FF_FFFF`).
894 #[must_use]
895 pub fn new(model_id: u32, transform: SpriteInstanceTransform) -> Self {
896 Self {
897 model_id,
898 transform,
899 material: 0,
900 alpha_mul: 255,
901 flags: 0,
902 tint: 0x00FF_FFFF,
903 }
904 }
905}
906
907/// GPU per-model metadata: where this model's data starts in the
908/// shared registry buffers + its dims/pivot. Mirrors `ModelMeta` in
909/// the shader (std430, 48 bytes).
910#[repr(C)]
911#[derive(Clone, Copy, Pod, Zeroable, Debug)]
912struct SpriteModelMeta {
913 occupancy_offset: u32,
914 colors_offset: u32,
915 color_offsets_offset: u32,
916 occ_words_per_col: u32,
917 dims: [u32; 3],
918 /// TV.3 — 1 if this model has per-voxel materials (`materials_vox` is
919 /// populated for it); 0 ⇒ use the instance's uniform material.
920 has_vox_materials: u32,
921 pivot: [f32; 3],
922 /// GPU.10.4 — world size of one voxel of this (mip) entry.
923 voxel_world_size: f32,
924}
925
926/// GPU per-instance record. Mirrors `Instance` in the shader (std430,
927/// 80 bytes): inverse rotation columns + position + model id + the TV
928/// material id and per-instance alpha multiplier.
929#[repr(C)]
930#[derive(Clone, Copy, Pod, Zeroable, Debug)]
931struct SpriteInstanceGpu {
932 inv_rot0: [f32; 4],
933 inv_rot1: [f32; 4],
934 inv_rot2: [f32; 4],
935 pos: [f32; 3],
936 model_id: u32,
937 /// TV: material id into the global palette (binding 12).
938 material: u32,
939 /// TV: per-instance alpha multiplier, normalised to `0..=1`.
940 alpha_mul: f32,
941 /// XS.4 — sprite shadow flags (mirror of `roxlap_formats::sprite` bits 4/5):
942 /// bit4 = NO_SHADOW_CAST, bit5 = NO_SHADOW_RECEIVE. `0` ⇒ casts + receives.
943 flags: u32,
944 /// Per-instance RGB tint, packed `0x00RRGGBB` (white `0x00FF_FFFF` = no-op).
945 tint: u32,
946}
947
948/// Invert a 3×3 matrix given as basis columns `[c0, c1, c2]`,
949/// returning the inverse as columns. For an orthonormal basis this is
950/// the transpose; the general path covers rotation + non-unit scale.
951#[must_use]
952fn mat3_inverse(cols: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
953 let [a, b, c] = cols; // columns
954 // Determinant via scalar triple product a · (b × c).
955 let cross = |u: [f32; 3], v: [f32; 3]| {
956 [
957 u[1] * v[2] - u[2] * v[1],
958 u[2] * v[0] - u[0] * v[2],
959 u[0] * v[1] - u[1] * v[0],
960 ]
961 };
962 let bc = cross(b, c);
963 let ca = cross(c, a);
964 let ab = cross(a, b);
965 let det = a[0] * bc[0] + a[1] * bc[1] + a[2] * bc[2];
966 let inv_det = if det.abs() < 1e-12 { 0.0 } else { 1.0 / det };
967 // Inverse rows are (b×c, c×a, a×b)/det; return as columns of the
968 // inverse, i.e. transpose of those rows.
969 [
970 [bc[0] * inv_det, ca[0] * inv_det, ab[0] * inv_det],
971 [bc[1] * inv_det, ca[1] * inv_det, ab[1] * inv_det],
972 [bc[2] * inv_det, ca[2] * inv_det, ab[2] * inv_det],
973 ]
974}
975
976/// GPU-resident registry + instances: every model's occupancy /
977/// colours / offsets concatenated into shared storage buffers, a
978/// per-model metadata table, and a capacity-sized instance buffer
979/// rewritten each frame with the frustum-visible subset (GPU.10.2).
980/// One bind group serves all models (same approach as the multi-grid
981/// scene).
982pub struct SpriteRegistryResident {
983 /// Concatenated per-model occupancy bitmaps (1 bit per voxel,
984 /// 32 per u32 word, z innermost within a column); each model's
985 /// region starts at its `model_meta` `occupancy_offset`.
986 pub occupancy: wgpu::Buffer,
987 /// Concatenated packed voxel colours, one u32 per solid voxel
988 /// (blue bits 0-7, green 8-15, red 16-23; the high byte is carried
989 /// through but unread — sprite shading comes from the per-instance
990 /// `kv6colmul` table). Rank-indexed via [`Self::color_offsets`].
991 pub colors: wgpu::Buffer,
992 /// Per-voxel surface-normal index, concatenated across models in the
993 /// same layout as [`colors`](Self::colors). The shader indexes the
994 /// per-instance `kv6colmul` table by it.
995 pub dirs: wgpu::Buffer,
996 /// Per-voxel material id (TV.3), same layout as [`colors`](Self::colors)
997 /// (one u32 per voxel). `0` for models without per-voxel materials; the
998 /// per-model `has_vox_materials` flag in `model_meta` says whether to use
999 /// it (else the shader falls back to the instance's uniform material).
1000 pub materials_vox: wgpu::Buffer,
1001 /// Concatenated per-model `cols + 1` prefix tables: column
1002 /// `(x, y)`'s colours span
1003 /// `colors[offsets[col] .. offsets[col + 1]]` (offsets are local
1004 /// to the model's colour block).
1005 pub color_offsets: wgpu::Buffer,
1006 /// Per-model metadata table (std430, 48 B each): buffer offsets,
1007 /// dims, pivot, per-voxel-materials flag, and the mip entry's
1008 /// `voxel_world_size`. Indexed by the instance's culled `model_id`.
1009 pub model_meta: wgpu::Buffer,
1010 /// Holds up to `instance_capacity` instances; the visible subset
1011 /// is packed into `[0, count)` each frame by [`Self::cull_bin_upload`].
1012 pub instances: wgpu::Buffer,
1013 /// Allocation size of [`Self::instances`] in records (grown
1014 /// power-of-2-style by `append_instances`); the per-frame visible
1015 /// count is at most this.
1016 pub instance_capacity: u32,
1017 /// Per-visible-instance `kv6colmul[256]` tables, packed in the same
1018 /// order as the `instances` buffer each frame (two u32 per u64
1019 /// entry: lanes 0|1 then 2|3). Sized `instance_capacity * 256 * 2`
1020 /// u32; rewritten by [`Self::cull_bin_upload`].
1021 pub colmul: wgpu::Buffer,
1022 colmul_cap: u32,
1023 /// GPU.10.3 — per-tile `(offset, count)` into `tile_instances`,
1024 /// flat `2 * tiles_x * tiles_y` u32s. Grown to fit the screen.
1025 pub tile_ranges: wgpu::Buffer,
1026 tile_ranges_cap: u32,
1027 /// GPU.10.3 — flat list of visible-instance indices grouped by
1028 /// tile. Grown to fit the per-frame total.
1029 pub tile_instances: wgpu::Buffer,
1030 tile_instances_cap: u32,
1031 /// CPU cull records (full set), with precomputed bounding spheres.
1032 cull: Vec<CullInstance>,
1033 /// GPU.10.4 — LOD chains: `chains[chain_id]` = entry ids, finest
1034 /// first. The cull picks a level by distance and writes its entry
1035 /// id into the packed instance's `model_id`.
1036 chains: Vec<Vec<u32>>,
1037 /// GPU.12 incremental — CPU mirror of the GPU `model_meta` table, one
1038 /// per concrete entry. [`Self::update_model`] reads the fixed
1039 /// occupancy/color_offsets bases from here and rewrites the changed
1040 /// `colors_offset` on a relocation.
1041 meta: Vec<SpriteModelMeta>,
1042 /// GPU.12 incremental — per-entry placement of `colors`/`dirs` in the
1043 /// shared buffers (drives both; same offsets/ranks). Lets an edit
1044 /// re-upload one model's data without touching the others.
1045 colors_alloc: ColorsAllocator,
1046 /// PF.10 — the (frustum, screen) key + result of the last
1047 /// `cull_bin_upload`; `None` after any registry mutation. A matching
1048 /// key skips the whole cull/bin/upload (buffers already current).
1049 last_cull: Option<(CullKey, (u32, u32, u32))>,
1050 /// PF.10 — true once ANY per-instance colmul table was set. While
1051 /// false every table is identity, so the 2 KiB-per-visible-instance
1052 /// rebuild + upload is skipped; the buffer is identity-filled lazily
1053 /// instead (`colmul_identity`).
1054 any_colmul: bool,
1055 /// PF.10 — whether the whole `colmul` buffer currently holds the
1056 /// identity pattern (reset on growth).
1057 colmul_identity: bool,
1058 /// PF.10 — reusable cull/bin workspace.
1059 scratch: CullScratch,
1060 /// Per-entry word length of the dims-fixed `occupancy` and
1061 /// `color_offsets` arrays, kept so [`Self::update_model`] can assert a
1062 /// carve never changed dims (which would invalidate the in-place
1063 /// writes — growing dims is out of scope, handled by a full re-upload).
1064 occ_lens: Vec<u32>,
1065 coloff_lens: Vec<u32>,
1066 /// Used / allocated words of the tightly-concatenated `occupancy`
1067 /// buffer. `add_model` bump-appends at `occ_used`; when it would pass
1068 /// `occ_cap` the buffer is grown (with slack) and rebuilt from the
1069 /// registry. (`colors`/`dirs` track theirs in [`ColorsAllocator`].)
1070 occ_used: u32,
1071 occ_cap: u32,
1072 /// Used / allocated words of the tightly-concatenated `color_offsets`
1073 /// buffer — same growth scheme as `occ_*`.
1074 coloff_used: u32,
1075 coloff_cap: u32,
1076 /// Allocated record count of the `model_meta` buffer; `add_model`
1077 /// grows it (with slack) when the entry count passes it.
1078 meta_cap: u32,
1079 /// Per-entry tombstone: `true` once its model was removed
1080 /// ([`Self::remove_model`]). Dead entries keep their `meta` slot (so
1081 /// entry ids — and the caller's `chain_id`s — stay stable) but their
1082 /// colours are freed for reuse and they contribute nothing to a
1083 /// repack / [`Self::compact`]. Parallel to `meta`.
1084 dead: Vec<bool>,
1085}
1086
1087/// Which tightly-concatenated registry buffer [`SpriteRegistryResident::
1088/// sync_concat`] is operating on.
1089#[derive(Clone, Copy)]
1090enum ConcatBuf {
1091 Occupancy,
1092 ColorOffsets,
1093}
1094
1095/// The model's source array for a given [`ConcatBuf`] — a free fn (not a
1096/// closure) so the returned borrow keeps `m`'s lifetime.
1097fn concat_data(m: &SpriteModel, which: ConcatBuf) -> &[u32] {
1098 match which {
1099 ConcatBuf::Occupancy => &m.occupancy,
1100 ConcatBuf::ColorOffsets => &m.color_offsets,
1101 }
1102}
1103
1104impl SpriteRegistryResident {
1105 /// Concatenate `registry`'s models into shared buffers and prepare
1106 /// `instances` for per-frame culling. Model-relative indices stay
1107 /// as built; the shader adds each model's base offset from the
1108 /// metadata table.
1109 #[must_use]
1110 pub fn upload(
1111 device: &wgpu::Device,
1112 registry: &SpriteModelRegistry,
1113 instances: &[SpriteInstance],
1114 ) -> Self {
1115 // `occupancy` + `color_offsets` are dims-fixed → tightly
1116 // concatenated (never grow on a carve). `colors` + `dirs` are
1117 // variable → laid out by the suballocator with per-slot slack so
1118 // an incremental edit can rewrite one model in place.
1119 let entry_lens: Vec<u32> = registry
1120 .entries
1121 .iter()
1122 .map(|m| m.colors.len() as u32)
1123 .collect();
1124 let colors_alloc = ColorsAllocator::new(&entry_lens);
1125 let cap_total = colors_alloc.cap_total();
1126
1127 let mut all_occ: Vec<u32> = Vec::new();
1128 let mut all_offsets: Vec<u32> = Vec::new();
1129 let mut all_colors: Vec<u32> = vec![0; cap_total as usize];
1130 let mut all_dirs: Vec<u32> = vec![0; cap_total as usize];
1131 let mut all_materials: Vec<u32> = vec![0; cap_total as usize];
1132 let mut meta: Vec<SpriteModelMeta> = Vec::with_capacity(registry.entries.len());
1133 let mut occ_lens: Vec<u32> = Vec::with_capacity(registry.entries.len());
1134 let mut coloff_lens: Vec<u32> = Vec::with_capacity(registry.entries.len());
1135
1136 // One meta + placed data per concrete (mip-level) entry.
1137 for (e, m) in registry.entries.iter().enumerate() {
1138 let slot = colors_alloc.slot(e);
1139 meta.push(SpriteModelMeta {
1140 occupancy_offset: all_occ.len() as u32,
1141 colors_offset: slot.off,
1142 color_offsets_offset: all_offsets.len() as u32,
1143 occ_words_per_col: m.occ_words_per_col,
1144 dims: m.dims,
1145 has_vox_materials: u32::from(!m.materials.is_empty()),
1146 pivot: m.pivot,
1147 voxel_world_size: m.voxel_world_size,
1148 });
1149 occ_lens.push(m.occupancy.len() as u32);
1150 coloff_lens.push(m.color_offsets.len() as u32);
1151 all_occ.extend_from_slice(&m.occupancy);
1152 all_offsets.extend_from_slice(&m.color_offsets);
1153 let off = slot.off as usize;
1154 all_colors[off..off + m.colors.len()].copy_from_slice(&m.colors);
1155 all_dirs[off..off + m.dirs.len()].copy_from_slice(&m.dirs);
1156 for (i, &mat) in m.materials.iter().enumerate() {
1157 all_materials[off + i] = u32::from(mat);
1158 }
1159 }
1160
1161 // Per-instance cull records: sphere centred at the instance
1162 // position, radius from the chain's finest (mip-0) model.
1163 // `colmul` starts at identity (unshaded) until the facade sets
1164 // per-instance lighting via `set_instance_colmul`.
1165 let cull: Vec<CullInstance> = instances.iter().map(|i| make_cull(registry, i)).collect();
1166
1167 // Capacity buffer (COPY_DST so cull can rewrite it each frame),
1168 // seeded with the full set so frame 0 is valid pre-cull.
1169 let seed: Vec<SpriteInstanceGpu> = cull.iter().map(|c| c.gpu).collect();
1170 let instances_buf = {
1171 use wgpu::util::DeviceExt;
1172 let one = [SpriteInstanceGpu::zeroed()];
1173 let src: &[SpriteInstanceGpu] = if seed.is_empty() { &one } else { &seed };
1174 device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1175 label: Some("roxlap-gpu sprite_reg.instances"),
1176 contents: bytemuck::cast_slice(src),
1177 usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
1178 })
1179 };
1180
1181 let tile_ranges = storage_dst_u32(device, "roxlap-gpu sprite_reg.tile_ranges", 1);
1182 let tile_instances = storage_dst_u32(device, "roxlap-gpu sprite_reg.tile_instances", 1);
1183 // colmul: 256 entries × 2 u32 per visible instance. Sized to the
1184 // full instance set (worst case all visible); rewritten per frame.
1185 let colmul_cap = (cull.len() as u32).max(1) * 256 * 2;
1186 let colmul = storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", colmul_cap);
1187 Self {
1188 occupancy: storage_dst_u32_cap(
1189 device,
1190 "roxlap-gpu sprite_reg.occupancy",
1191 &all_occ,
1192 all_occ.len() as u32,
1193 ),
1194 colors: storage_dst_u32_cap(
1195 device,
1196 "roxlap-gpu sprite_reg.colors",
1197 &all_colors,
1198 cap_total,
1199 ),
1200 dirs: storage_dst_u32_cap(device, "roxlap-gpu sprite_reg.dirs", &all_dirs, cap_total),
1201 materials_vox: storage_dst_u32_cap(
1202 device,
1203 "roxlap-gpu sprite_reg.materials_vox",
1204 &all_materials,
1205 cap_total,
1206 ),
1207 color_offsets: storage_dst_u32_cap(
1208 device,
1209 "roxlap-gpu sprite_reg.color_offsets",
1210 &all_offsets,
1211 all_offsets.len() as u32,
1212 ),
1213 model_meta: storage_dst_pod(device, "roxlap-gpu sprite_reg.model_meta", &meta),
1214 instances: instances_buf,
1215 instance_capacity: cull.len() as u32,
1216 colmul,
1217 colmul_cap,
1218 tile_ranges,
1219 tile_ranges_cap: 1,
1220 tile_instances,
1221 tile_instances_cap: 1,
1222 cull,
1223 chains: registry.chains.clone(),
1224 last_cull: None,
1225 any_colmul: false,
1226 colmul_identity: false,
1227 scratch: CullScratch::default(),
1228 occ_used: all_occ.len() as u32,
1229 occ_cap: all_occ.len() as u32,
1230 coloff_used: all_offsets.len() as u32,
1231 coloff_cap: all_offsets.len() as u32,
1232 meta_cap: meta.len() as u32,
1233 dead: vec![false; meta.len()],
1234 meta,
1235 colors_alloc,
1236 occ_lens,
1237 coloff_lens,
1238 }
1239 }
1240
1241 /// Number of resident instances (the cull set length).
1242 #[must_use]
1243 pub fn instance_count(&self) -> usize {
1244 self.cull.len()
1245 }
1246
1247 /// Append new instances **without** re-uploading any model volume —
1248 /// the incremental counterpart to [`Self::upload`], for streaming
1249 /// spawns (asteroids, projectiles, …). Returns the index of the first
1250 /// appended instance; the block occupies `[base, base + N)`.
1251 ///
1252 /// The model volumes are untouched, so every appended instance must
1253 /// reference a `model_id` (LOD chain) that was already present in the
1254 /// `registry` passed to [`Self::upload`]. Registering a *new* model
1255 /// still requires a full [`Self::upload`] (its voxels must be laid
1256 /// into the shared buffers). `registry` here is only read for the new
1257 /// instances' bound-sphere radii and must be the resident one.
1258 ///
1259 /// The `instances` GPU buffer is only *grown* here (power-of-two,
1260 /// amortised O(1)); its contents are **not** written. [`Self::
1261 /// cull_bin_upload`] rewrites the whole visible range from `cull` every
1262 /// frame before the sprite pass reads it — exactly as for the static
1263 /// instances — so appending only needs to extend `cull` and ensure
1264 /// capacity. Writing the buffer here too caused a mid-frame
1265 /// write-while-in-flight hazard on some drivers (a stray full-screen
1266 /// flash on append). `colmul` likewise grows lazily in
1267 /// `cull_bin_upload`. After a removal the capacity is not shrunk.
1268 pub fn append_instances(
1269 &mut self,
1270 device: &wgpu::Device,
1271 registry: &SpriteModelRegistry,
1272 instances: &[SpriteInstance],
1273 ) -> u32 {
1274 let base = self.cull.len() as u32;
1275 if instances.is_empty() {
1276 return base;
1277 }
1278 self.last_cull = None; // PF.10 — instance set changed
1279 for i in instances {
1280 debug_assert!(
1281 (i.model_id as usize) < self.chains.len(),
1282 "append_instances: model_id {} not resident (run upload to register new models)",
1283 i.model_id
1284 );
1285 self.cull.push(make_cull(registry, i));
1286 }
1287 let need = self.cull.len() as u32;
1288 if need > self.instance_capacity {
1289 // Grow power-of-two and recreate the buffer (the next frame's
1290 // bind group picks up the new handle). No seed write — the
1291 // per-frame cull_bin_upload populates it.
1292 self.instance_capacity = need.next_power_of_two();
1293 self.instances = instances_buffer(device, self.instance_capacity);
1294 }
1295 base
1296 }
1297
1298 /// Remove the instance at `index` by swap-remove — O(1), no GPU work
1299 /// (the next [`Self::cull_bin_upload`] repacks the visible set from
1300 /// the shrunk cull list). Capacity is retained for reuse.
1301 ///
1302 /// Returns `Some(old_last)` when a different instance was moved into
1303 /// `index` to fill the hole (its index changed from `old_last` to
1304 /// `index` — callers holding instance handles must fix up that one),
1305 /// or `None` if `index` was the last element or out of range. Because
1306 /// this reorders, any [`Self::set_instance_colmul`] table set by
1307 /// position should be re-applied after a removal.
1308 pub fn remove_instance(&mut self, index: usize) -> Option<usize> {
1309 if index >= self.cull.len() {
1310 return None;
1311 }
1312 self.last_cull = None; // PF.10 — instance set changed
1313 let last = self.cull.len() - 1;
1314 self.cull.swap_remove(index);
1315 (index != last).then_some(last)
1316 }
1317
1318 /// Set the per-instance `kv6colmul[256]` lighting tables (voxlap's
1319 /// `update_reflects` output), in the same order/length as the
1320 /// instances passed to [`Self::upload`]. The next
1321 /// [`Self::cull_bin_upload`] packs the visible subset to the GPU.
1322 /// Instances beyond `tables.len()` keep their previous tables.
1323 pub fn set_instance_colmul(&mut self, tables: &[[u64; 256]]) {
1324 // PF.10 — leaves the identity fast path for good: from here on the
1325 // per-visible tables are rebuilt + uploaded each cull.
1326 self.any_colmul = true;
1327 self.last_cull = None;
1328 for (ci, t) in self.cull.iter_mut().zip(tables) {
1329 ci.colmul.copy_from_slice(t);
1330 }
1331 }
1332
1333 /// Refresh instance poses in place from `instances` — for animated
1334 /// sprites (e.g. KFA limbs re-posed each frame) — **without** any
1335 /// model-volume re-upload. `instances` must match the set passed to
1336 /// [`Self::upload`] in length + order; each keeps its `model_id`
1337 /// (LOD chain) so only the transform + cull centre change. No GPU
1338 /// write happens here: the next [`Self::cull_bin_upload`] re-uploads
1339 /// the packed visible subset, as it already does every frame.
1340 pub fn update_transforms(&mut self, instances: &[SpriteInstance]) {
1341 debug_assert_eq!(
1342 instances.len(),
1343 self.cull.len(),
1344 "update_transforms instance count must match upload"
1345 );
1346 self.last_cull = None; // PF.10 — poses changed
1347 for (ci, inst) in self.cull.iter_mut().zip(instances) {
1348 ci.gpu.inv_rot0 = inst.transform.inv_rot[0];
1349 ci.gpu.inv_rot1 = inst.transform.inv_rot[1];
1350 ci.gpu.inv_rot2 = inst.transform.inv_rot[2];
1351 ci.gpu.pos = inst.transform.pos;
1352 // TV: material id + alpha multiplier ride the same coalesced
1353 // update as the pose (set via the facade's per-instance setters).
1354 ci.gpu.material = u32::from(inst.material);
1355 ci.gpu.alpha_mul = f32::from(inst.alpha_mul) / 255.0;
1356 // XS.4 shadow flags + per-instance RGB tint also ride this flush,
1357 // so `set_dyn_instance_tint` (and any flag change) takes effect.
1358 ci.gpu.flags = inst.flags;
1359 ci.gpu.tint = inst.tint;
1360 // Bounding sphere follows the pivot and rescales with the
1361 // pose's longest basis column (PS.1 — scaled particles
1362 // must not under-cull); the chain is unchanged.
1363 ci.center = inst.transform.pos;
1364 ci.max_scale = inst.transform.max_scale;
1365 ci.radius = ci.model_radius * inst.transform.max_scale;
1366 }
1367 }
1368
1369 /// Repoint instance `idx` at a different LOD chain — the per-frame
1370 /// **flipbook** step for animated voxel clips (VCL.2). The instance's
1371 /// transform / colmul are untouched; only which model's volume it
1372 /// draws changes. The new chain's volume must already be resident
1373 /// (uploaded via [`Self::add_model`] / [`Self::upload`]); `registry`
1374 /// is the one those uploads used (so the bounding radius reseeds from
1375 /// the new model). Like [`Self::update_transforms`], this is a CPU-side
1376 /// rewrite — the next [`Self::cull_bin_upload`] re-uploads the packed
1377 /// visible subset, so it costs nothing extra on the GPU. No-op if `idx`
1378 /// is out of range.
1379 ///
1380 /// All frames of a clip share the same `dims`, so a flipbook swap
1381 /// leaves the bounding radius unchanged; reseeding it anyway keeps the
1382 /// method correct for arbitrary chain swaps.
1383 pub fn set_instance_model(
1384 &mut self,
1385 registry: &SpriteModelRegistry,
1386 idx: usize,
1387 chain_id: u32,
1388 ) {
1389 self.last_cull = None; // PF.10 — model binding changed
1390 // Guard `chain_id` (the `cull.get_mut` below only covers `idx`): a
1391 // public caller could pass an out-of-range / tombstoned chain, which
1392 // `registry.model` would index-panic on.
1393 let Some(model_radius) = registry
1394 .model_checked(chain_id)
1395 .map(SpriteModel::bound_radius)
1396 else {
1397 return;
1398 };
1399 let Some(ci) = self.cull.get_mut(idx) else {
1400 return;
1401 };
1402 ci.chain_id = chain_id;
1403 ci.gpu.model_id = chain_id; // placeholder; cull rewrites to the LOD entry
1404 ci.model_radius = model_radius;
1405 ci.radius = model_radius * ci.max_scale;
1406 }
1407
1408 /// GPU.12 incremental — re-upload only the entries of LOD chain
1409 /// `chain_id` after an in-place edit (carve / recolour) of its model,
1410 /// **without** rebuilding the whole registry. `registry` must be the
1411 /// same registry uploaded (same entry ids), with chain `chain_id`'s
1412 /// entries already edited (`model_mut` + `rebuild_lod`).
1413 ///
1414 /// For each entry: occupancy + color_offsets are dims-fixed, so they
1415 /// are written in place; colors + dirs (variable, parallel) go through
1416 /// the suballocator — written in place when they fit the slack,
1417 /// relocated (with a `model_meta` rewrite) when they outgrow it, and
1418 /// only when the buffer tail overflows are colors/dirs grown + the
1419 /// whole registry repacked. Instances / cull / colmul are untouched
1420 /// (a carve never moves an instance or grows its bounds) — that is the
1421 /// win over [`Self::upload`].
1422 ///
1423 /// # Panics (debug)
1424 /// If an entry's dims changed (occupancy / color_offsets length), which
1425 /// the in-place path can't absorb — growing dims needs a full
1426 /// re-upload via [`Self::upload`].
1427 pub fn update_model(
1428 &mut self,
1429 device: &wgpu::Device,
1430 queue: &wgpu::Queue,
1431 registry: &SpriteModelRegistry,
1432 chain_id: u32,
1433 ) {
1434 self.last_cull = None; // PF.10 — model volume changed
1435 let entries = self.chains[chain_id as usize].clone();
1436 let mut grew = false;
1437 for &e in &entries {
1438 let e = e as usize;
1439 let m = ®istry.entries[e];
1440
1441 // Dims-fixed arrays: assert unchanged, then write in place.
1442 debug_assert_eq!(
1443 m.occupancy.len() as u32,
1444 self.occ_lens[e],
1445 "update_model: entry {e} occupancy length changed (dims grew?)"
1446 );
1447 debug_assert_eq!(
1448 m.color_offsets.len() as u32,
1449 self.coloff_lens[e],
1450 "update_model: entry {e} color_offsets length changed (dims grew?)"
1451 );
1452 queue.write_buffer(
1453 &self.occupancy,
1454 u64::from(self.meta[e].occupancy_offset) * 4,
1455 bytemuck::cast_slice(&m.occupancy),
1456 );
1457 queue.write_buffer(
1458 &self.color_offsets,
1459 u64::from(self.meta[e].color_offsets_offset) * 4,
1460 bytemuck::cast_slice(&m.color_offsets),
1461 );
1462
1463 // Variable colors/dirs via the suballocator.
1464 let new_len = m.colors.len() as u32;
1465 match self.colors_alloc.place(e, new_len) {
1466 Some(off) => {
1467 queue.write_buffer(
1468 &self.colors,
1469 u64::from(off) * 4,
1470 bytemuck::cast_slice(&m.colors),
1471 );
1472 queue.write_buffer(
1473 &self.dirs,
1474 u64::from(off) * 4,
1475 bytemuck::cast_slice(&m.dirs),
1476 );
1477 let mats: Vec<u32> = m.materials.iter().map(|&x| u32::from(x)).collect();
1478 queue.write_buffer(
1479 &self.materials_vox,
1480 u64::from(off) * 4,
1481 bytemuck::cast_slice(&mats),
1482 );
1483 if self.meta[e].colors_offset != off {
1484 // Relocated — rewrite this entry's meta record.
1485 self.meta[e].colors_offset = off;
1486 queue.write_buffer(
1487 &self.model_meta,
1488 (e * std::mem::size_of::<SpriteModelMeta>()) as u64,
1489 bytemuck::bytes_of(&self.meta[e]),
1490 );
1491 }
1492 }
1493 None => grew = true,
1494 }
1495 }
1496
1497 // Buffer overflow on at least one entry → grow colors/dirs and
1498 // repack the WHOLE registry (rare; offsets for every entry move).
1499 if grew {
1500 self.grow_and_repack(device, queue, registry);
1501 }
1502 }
1503
1504 /// Grow the `colors`/`dirs` buffers and repack every entry compactly
1505 /// (with fresh slack) when an [`Self::update_model`] edit overflowed
1506 /// the buffer tail. Recreates both buffers (the next frame's bind
1507 /// group picks up the new handles) and rewrites every `model_meta`
1508 /// `colors_offset`. O(registry) but rare — logged so a growth burst
1509 /// is visible.
1510 fn grow_and_repack(
1511 &mut self,
1512 device: &wgpu::Device,
1513 queue: &wgpu::Queue,
1514 registry: &SpriteModelRegistry,
1515 ) {
1516 self.repack_colors_dirs(device, registry);
1517 // Every entry's colors_offset moved → rewrite the whole meta table.
1518 queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1519 }
1520
1521 /// Repack `colors`/`dirs` compactly (with fresh slack) from the full
1522 /// `registry`, recreating both buffers and updating every CPU
1523 /// `meta[e].colors_offset`. Does **not** touch the GPU `model_meta`
1524 /// buffer — the caller writes it ([`Self::grow_and_repack`] writes the
1525 /// whole table; [`Self::add_model`] writes it once after all entries
1526 /// are placed). O(registry) but rare — logged so a growth burst is
1527 /// visible.
1528 fn repack_colors_dirs(&mut self, device: &wgpu::Device, registry: &SpriteModelRegistry) {
1529 // Dead (removed) entries collapse to 0 length so they reclaim no
1530 // space; live entries keep their colours.
1531 let new_lens: Vec<u32> = registry
1532 .entries
1533 .iter()
1534 .enumerate()
1535 .map(|(e, m)| {
1536 if self.dead[e] {
1537 0
1538 } else {
1539 m.colors.len() as u32
1540 }
1541 })
1542 .collect();
1543 self.colors_alloc.repack(&new_lens);
1544 let cap_total = self.colors_alloc.cap_total();
1545
1546 let mut all_colors = vec![0u32; cap_total as usize];
1547 let mut all_dirs = vec![0u32; cap_total as usize];
1548 let mut all_materials = vec![0u32; cap_total as usize];
1549 for (e, m) in registry.entries.iter().enumerate() {
1550 if self.dead[e] {
1551 self.meta[e].colors_offset = 0;
1552 continue;
1553 }
1554 let off = self.colors_alloc.slot(e).off as usize;
1555 all_colors[off..off + m.colors.len()].copy_from_slice(&m.colors);
1556 all_dirs[off..off + m.dirs.len()].copy_from_slice(&m.dirs);
1557 for (i, &mat) in m.materials.iter().enumerate() {
1558 all_materials[off + i] = u32::from(mat);
1559 }
1560 self.meta[e].colors_offset = off as u32;
1561 }
1562 self.colors = storage_dst_u32_cap(
1563 device,
1564 "roxlap-gpu sprite_reg.colors",
1565 &all_colors,
1566 cap_total,
1567 );
1568 self.dirs = storage_dst_u32_cap(device, "roxlap-gpu sprite_reg.dirs", &all_dirs, cap_total);
1569 self.materials_vox = storage_dst_u32_cap(
1570 device,
1571 "roxlap-gpu sprite_reg.materials_vox",
1572 &all_materials,
1573 cap_total,
1574 );
1575 eprintln!(
1576 "roxlap-gpu: sprite registry colors/dirs/materials grew + repacked to {cap_total} words"
1577 );
1578 }
1579
1580 /// Append a new model (its full LOD chain) to the resident registry
1581 /// **without** re-uploading the existing models' volumes — the
1582 /// incremental counterpart to a full [`Self::upload`], for streaming
1583 /// in new geometry (unique asteroids, generated meshes).
1584 ///
1585 /// Contract (mirrors [`Self::update_model`]): the caller owns the
1586 /// `SpriteModelRegistry`, has just appended this chain to it (e.g. via
1587 /// [`SpriteModelRegistry::add_lod`]), and passes the resulting
1588 /// `chain_id`. The chain's entries must be the registry's newest (ids
1589 /// `>= ` the resident entry count) — entries are append-only.
1590 ///
1591 /// The large `colors`/`dirs`/`occupancy`/`color_offsets` buffers carry
1592 /// slack and bump-append the new entries in place; a buffer that
1593 /// overflows is grown (with slack) and rebuilt once from the registry
1594 /// (amortised O(1) per add). The small `model_meta` table is rewritten
1595 /// each call. After this, [`Self::append_instances`] can reference the
1596 /// new `chain_id`.
1597 pub fn add_model(
1598 &mut self,
1599 device: &wgpu::Device,
1600 queue: &wgpu::Queue,
1601 registry: &SpriteModelRegistry,
1602 chain_id: u32,
1603 ) {
1604 self.last_cull = None; // PF.10 — chain set changed
1605 let entries = registry.chains[chain_id as usize].clone();
1606 debug_assert_eq!(
1607 chain_id as usize,
1608 self.chains.len(),
1609 "add_model: chains must be appended in order"
1610 );
1611
1612 // CPU bookkeeping: assign each new entry a tight occ/coloff offset
1613 // and an allocator slot for colors/dirs. `need_colors_grow` marks
1614 // a slot that didn't fit → a colors/dirs repack below.
1615 let mut need_colors_grow = false;
1616 for &e in &entries {
1617 let e = e as usize;
1618 debug_assert_eq!(
1619 e,
1620 self.meta.len(),
1621 "add_model: entries must be appended in order"
1622 );
1623 let m = ®istry.entries[e];
1624 let occ_off = self.occ_used;
1625 let coloff_off = self.coloff_used;
1626 self.occ_used += m.occupancy.len() as u32;
1627 self.coloff_used += m.color_offsets.len() as u32;
1628 let colors_off = match self.colors_alloc.push(m.colors.len() as u32) {
1629 Some(off) => off,
1630 None => {
1631 need_colors_grow = true;
1632 0 // placeholder; repack assigns the real offset
1633 }
1634 };
1635 self.meta.push(SpriteModelMeta {
1636 occupancy_offset: occ_off,
1637 colors_offset: colors_off,
1638 color_offsets_offset: coloff_off,
1639 occ_words_per_col: m.occ_words_per_col,
1640 dims: m.dims,
1641 has_vox_materials: u32::from(!m.materials.is_empty()),
1642 pivot: m.pivot,
1643 voxel_world_size: m.voxel_world_size,
1644 });
1645 self.occ_lens.push(m.occupancy.len() as u32);
1646 self.coloff_lens.push(m.color_offsets.len() as u32);
1647 self.dead.push(false);
1648 }
1649 self.chains.push(entries.clone());
1650
1651 // occupancy + color_offsets: grow+rebuild on overflow, else write
1652 // the new tails in place.
1653 self.sync_concat(device, queue, registry, &entries, ConcatBuf::Occupancy);
1654 self.sync_concat(device, queue, registry, &entries, ConcatBuf::ColorOffsets);
1655
1656 // colors/dirs: repack on overflow (rebuilds both + every CPU
1657 // colors_offset), else write the new entries at their slots.
1658 if need_colors_grow {
1659 self.repack_colors_dirs(device, registry);
1660 } else {
1661 for &e in &entries {
1662 let e = e as usize;
1663 let m = ®istry.entries[e];
1664 let off = u64::from(self.meta[e].colors_offset) * 4;
1665 queue.write_buffer(&self.colors, off, bytemuck::cast_slice(&m.colors));
1666 queue.write_buffer(&self.dirs, off, bytemuck::cast_slice(&m.dirs));
1667 let mats: Vec<u32> = m.materials.iter().map(|&x| u32::from(x)).collect();
1668 queue.write_buffer(&self.materials_vox, off, bytemuck::cast_slice(&mats));
1669 }
1670 }
1671
1672 // model_meta: grow the record buffer if needed, then rewrite the
1673 // whole (small) table — covers both new records and any
1674 // colors_offset relocations from a repack.
1675 let count = self.meta.len() as u32;
1676 if count > self.meta_cap {
1677 self.meta_cap = grow_records(count);
1678 self.model_meta = storage_dst_pod_cap(
1679 device,
1680 "roxlap-gpu sprite_reg.model_meta",
1681 &self.meta,
1682 self.meta_cap,
1683 );
1684 } else {
1685 queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1686 }
1687 }
1688
1689 /// Sync one tightly-concatenated buffer (`occupancy` or
1690 /// `color_offsets`) after `add_model` appended `new_entries`: if the
1691 /// used length now exceeds capacity, grow (with slack) and rebuild the
1692 /// whole buffer from the registry; otherwise write just the appended
1693 /// tails at their offsets.
1694 fn sync_concat(
1695 &mut self,
1696 device: &wgpu::Device,
1697 queue: &wgpu::Queue,
1698 registry: &SpriteModelRegistry,
1699 new_entries: &[u32],
1700 which: ConcatBuf,
1701 ) {
1702 let (used, cap) = match which {
1703 ConcatBuf::Occupancy => (self.occ_used, self.occ_cap),
1704 ConcatBuf::ColorOffsets => (self.coloff_used, self.coloff_cap),
1705 };
1706 if used > cap {
1707 // The bump layout overflowed: rebuild through the COMPACTOR,
1708 // which re-packs live entries tightly AND rewrites their
1709 // meta offsets (`add_model` re-uploads the whole model_meta
1710 // table right after this, so the recomputed offsets reach
1711 // the GPU for free) — and reclaiming tombstone holes may
1712 // absorb the growth outright.
1713 //
1714 // The previous code here rebuilt tightly but kept the STALE
1715 // bump offsets in `meta`: after any `remove_model` hole,
1716 // every live model behind it read its volume at a shifted
1717 // offset — permanent "black stripe" corruption, repaired
1718 // per-model only by an `update_model` rewrite. Root-caused
1719 // by the roxlap-game-demo author (0.27.0).
1720 self.compact_concat(device, registry, which);
1721 } else {
1722 let target = match which {
1723 ConcatBuf::Occupancy => &self.occupancy,
1724 ConcatBuf::ColorOffsets => &self.color_offsets,
1725 };
1726 for &e in new_entries {
1727 let e = e as usize;
1728 let off = match which {
1729 ConcatBuf::Occupancy => self.meta[e].occupancy_offset,
1730 ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset,
1731 };
1732 queue.write_buffer(
1733 target,
1734 u64::from(off) * 4,
1735 bytemuck::cast_slice(concat_data(®istry.entries[e], which)),
1736 );
1737 }
1738 }
1739 }
1740
1741 /// Number of removed-but-not-yet-compacted models (tombstoned chains).
1742 /// A caller streams `add_model` / `remove_model` and calls
1743 /// [`Self::compact`] once this (relative to [`Self::live_model_count`])
1744 /// crosses a threshold.
1745 #[must_use]
1746 pub fn dead_model_count(&self) -> usize {
1747 self.chains.iter().filter(|c| c.is_empty()).count()
1748 }
1749
1750 /// Number of live (non-removed) models.
1751 #[must_use]
1752 pub fn live_model_count(&self) -> usize {
1753 self.chains.iter().filter(|c| !c.is_empty()).count()
1754 }
1755
1756 /// Remove a model (tombstone its LOD chain) — the counterpart to
1757 /// [`Self::add_model`]. O(chain length): marks the chain's entries
1758 /// dead and frees their `colors`/`dirs` slots for reuse by a later
1759 /// `add_model`. The `occupancy` / `color_offsets` holes are **not**
1760 /// reclaimed until [`Self::compact`]; entry ids (and the caller's other
1761 /// `chain_id`s) stay stable.
1762 ///
1763 /// Instances of the removed chain are **not** dropped here — they
1764 /// linger in the cull set but draw as nothing (skipped in
1765 /// [`Self::cull_bin_upload`]); the caller removes them via
1766 /// [`Self::remove_instance`] when convenient. A no-op if `chain_id` is
1767 /// out of range or already removed.
1768 pub fn remove_model(&mut self, chain_id: u32) {
1769 let Some(entries) = self.chains.get(chain_id as usize).cloned() else {
1770 return;
1771 };
1772 if entries.is_empty() {
1773 return; // already removed
1774 }
1775 self.last_cull = None; // PF.10 — tombstone changes visibility
1776 for &e in &entries {
1777 let e = e as usize;
1778 self.dead[e] = true;
1779 self.colors_alloc.free(e);
1780 }
1781 self.chains[chain_id as usize] = Vec::new(); // tombstone
1782 }
1783
1784 /// Reclaim the holes left by [`Self::remove_model`]: rebuild the shared
1785 /// volume buffers from the live entries only, dropping every dead
1786 /// entry's data. Entry ids and `chain_id`s are preserved (dead entries
1787 /// keep a zero-length `meta` tombstone), so the caller's handles stay
1788 /// valid and no remap is needed.
1789 ///
1790 /// `registry` must be the resident one (entry ids 1:1, as for
1791 /// [`Self::add_model`] / [`Self::update_model`]). O(live volume) —
1792 /// call it when [`Self::dead_model_count`] is high, not every frame.
1793 pub fn compact(
1794 &mut self,
1795 device: &wgpu::Device,
1796 queue: &wgpu::Queue,
1797 registry: &SpriteModelRegistry,
1798 ) {
1799 self.last_cull = None; // PF.10 — entry ids / chains renumbered
1800 // occupancy + color_offsets: re-pack live entries tightly, rewrite
1801 // each live entry's meta offset, zero the dead ones.
1802 self.compact_concat(device, registry, ConcatBuf::Occupancy);
1803 self.compact_concat(device, registry, ConcatBuf::ColorOffsets);
1804 // colors/dirs: the dead-aware repack already drops dead entries.
1805 self.repack_colors_dirs(device, registry);
1806 // model_meta: rewrite the (unchanged-length) table with the new
1807 // offsets. Buffer count didn't change, so no grow needed.
1808 queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1809 }
1810
1811 /// Rebuild one tightly-concatenated buffer from live entries only
1812 /// (used by [`Self::compact`]): assign each live entry a fresh tight
1813 /// offset, zero dead entries' offset, and recreate the buffer with
1814 /// slack.
1815 fn compact_concat(
1816 &mut self,
1817 device: &wgpu::Device,
1818 registry: &SpriteModelRegistry,
1819 which: ConcatBuf,
1820 ) {
1821 let mut all: Vec<u32> = Vec::new();
1822 for e in 0..self.meta.len() {
1823 if self.dead[e] {
1824 match which {
1825 ConcatBuf::Occupancy => self.meta[e].occupancy_offset = 0,
1826 ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = 0,
1827 }
1828 continue;
1829 }
1830 let off = all.len() as u32;
1831 match which {
1832 ConcatBuf::Occupancy => self.meta[e].occupancy_offset = off,
1833 ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = off,
1834 }
1835 all.extend_from_slice(concat_data(®istry.entries[e], which));
1836 }
1837 let used = all.len() as u32;
1838 let cap = grow_words(used);
1839 let (label, buf) = match which {
1840 ConcatBuf::Occupancy => ("roxlap-gpu sprite_reg.occupancy", &mut self.occupancy),
1841 ConcatBuf::ColorOffsets => (
1842 "roxlap-gpu sprite_reg.color_offsets",
1843 &mut self.color_offsets,
1844 ),
1845 };
1846 *buf = storage_dst_u32_cap(device, label, &all, cap);
1847 match which {
1848 ConcatBuf::Occupancy => {
1849 self.occ_used = used;
1850 self.occ_cap = cap;
1851 }
1852 ConcatBuf::ColorOffsets => {
1853 self.coloff_used = used;
1854 self.coloff_cap = cap;
1855 }
1856 }
1857 }
1858
1859 /// GPU.10.3 — frustum-cull, pack the visible subset into the
1860 /// instance buffer, then bin those instances into screen tiles:
1861 /// project each visible bounding sphere to a screen AABB and append
1862 /// its (visible) index to every overlapped tile. Uploads the
1863 /// instance buffer + `tile_ranges` (per-tile offset/count) +
1864 /// `tile_instances` (flat grouped indices), growing the tile
1865 /// buffers as needed. Returns `(visible_count, tiles_x, tiles_y)`.
1866 #[allow(clippy::too_many_arguments)]
1867 pub fn cull_bin_upload(
1868 &mut self,
1869 device: &wgpu::Device,
1870 queue: &wgpu::Queue,
1871 f: &ViewFrustum,
1872 screen_w: u32,
1873 screen_h: u32,
1874 tile_size: u32,
1875 lod_px: f32,
1876 ) -> (u32, u32, u32) {
1877 let tiles_x = screen_w.div_ceil(tile_size).max(1);
1878 let tiles_y = screen_h.div_ceil(tile_size).max(1);
1879 let n_tiles = (tiles_x * tiles_y) as usize;
1880
1881 // PF.10 — nothing changed since the last cull (same registry
1882 // state, same view, same screen): the four buffers already hold
1883 // exactly this frame's data — skip the whole cull/bin/upload.
1884 let key = CullKey::new(f, screen_w, screen_h, tile_size, lod_px);
1885 if let Some((k, res)) = self.last_cull {
1886 if k == key {
1887 return res;
1888 }
1889 }
1890
1891 let nw = (1.0 + f.half_w * f.half_w).sqrt();
1892 let nh = (1.0 + f.half_h * f.half_h).sqrt();
1893 let cx = screen_w as f32 * 0.5;
1894 let cy = screen_h as f32 * 0.5;
1895 let px_per_world = cx / f.half_w; // isotropic: == cy/half_h
1896 let ts = tile_size as f32;
1897 let tx_max = tiles_x as i32 - 1;
1898 let ty_max = tiles_y as i32 - 1;
1899
1900 // PF.10 — reused workspace (was 6+ fresh Vecs per frame).
1901 let scratch = &mut self.scratch;
1902 let visible = &mut scratch.visible;
1903 visible.clear();
1904 // Per-visible tile AABB (tx0, tx1, ty0, ty1) for the bin pass.
1905 let boxes = &mut scratch.boxes;
1906 boxes.clear();
1907 // Per-visible kv6colmul tables, flattened to two u32 per u64
1908 // entry (lanes 0|1, then 2|3), packed in visible order so the
1909 // shader indexes `colmul[inst_idx*512 + dir*2 + {0,1}]`. PF.10 —
1910 // built ONLY once a non-identity table exists (`any_colmul`);
1911 // until then the buffer holds a lazily-written identity fill and
1912 // the ~2 KiB-per-visible-instance rebuild + upload is skipped.
1913 let visible_colmul = &mut scratch.colmul;
1914 visible_colmul.clear();
1915 let counts = &mut scratch.counts;
1916 counts.clear();
1917 counts.resize(n_tiles, 0u32);
1918 let pack_colmul = self.any_colmul;
1919
1920 for ci in &self.cull {
1921 // Skip instances of a removed model (tombstoned chain) — they
1922 // linger in `cull` until the caller drops them, but draw as
1923 // nothing.
1924 if self.chains[ci.chain_id as usize].is_empty() {
1925 continue;
1926 }
1927 let rel = [
1928 ci.center[0] - f.pos[0],
1929 ci.center[1] - f.pos[1],
1930 ci.center[2] - f.pos[2],
1931 ];
1932 let z = dot3(rel, f.forward);
1933 let r = ci.radius;
1934 if z + r < 0.0 || z - r > f.far {
1935 continue; // behind / beyond far
1936 }
1937 let x = dot3(rel, f.right);
1938 if (x - f.half_w * z) > r * nw || (-x - f.half_w * z) > r * nw {
1939 continue; // right / left
1940 }
1941 let y = dot3(rel, f.down);
1942 if (y - f.half_h * z) > r * nh || (-y - f.half_h * z) > r * nh {
1943 continue; // bottom / top
1944 }
1945
1946 // Visible: project the sphere to a screen AABB → tile range.
1947 let (tx0, tx1, ty0, ty1) = if z > 1e-3 {
1948 let sx = cx + (x / z) * px_per_world;
1949 let sy = cy + (y / z) * px_per_world;
1950 let sr = (r / z) * px_per_world;
1951 (
1952 (((sx - sr) / ts).floor() as i32).clamp(0, tx_max),
1953 (((sx + sr) / ts).floor() as i32).clamp(0, tx_max),
1954 (((sy - sr) / ts).floor() as i32).clamp(0, ty_max),
1955 (((sy + sr) / ts).floor() as i32).clamp(0, ty_max),
1956 )
1957 } else {
1958 (0, tx_max, 0, ty_max)
1959 };
1960 // GPU.10.4 — pick the LOD level by projected voxel size:
1961 // choose the coarsest level whose voxel still covers at
1962 // least `lod_px` screen pixels, i.e. step up once a mip-0
1963 // voxel would be smaller than that. `lod_px = 1` is the
1964 // natural "don't go sub-pixel" threshold; larger values
1965 // force LOD in closer (tuning/inspection).
1966 let chain = &self.chains[ci.chain_id as usize];
1967 let level = if z > 1e-3 && chain.len() > 1 {
1968 // Mip-0 voxel screen size; a scaled instance's voxels
1969 // are `max_scale`× larger in world, so it holds the
1970 // fine mip proportionally longer (PS.1).
1971 let voxel_px = px_per_world * ci.max_scale / z;
1972 ((lod_px / voxel_px).log2().ceil().max(0.0) as usize).min(chain.len() - 1)
1973 } else {
1974 0
1975 };
1976 let mut g = ci.gpu;
1977 g.model_id = chain[level];
1978 visible.push(g);
1979 boxes.push([tx0, tx1, ty0, ty1]);
1980 if pack_colmul {
1981 for &w in ci.colmul.iter() {
1982 visible_colmul.push((w & 0xffff_ffff) as u32);
1983 visible_colmul.push((w >> 32) as u32);
1984 }
1985 }
1986 for ty in ty0..=ty1 {
1987 for tx in tx0..=tx1 {
1988 counts[(ty * tiles_x as i32 + tx) as usize] += 1;
1989 }
1990 }
1991 }
1992
1993 if visible.is_empty() {
1994 let res = (0, tiles_x, tiles_y);
1995 self.last_cull = Some((key, res));
1996 return res;
1997 }
1998
1999 // Prefix-sum counts → per-tile offsets; build the flat grouped
2000 // index list.
2001 let tile_ranges = &mut scratch.tile_ranges;
2002 tile_ranges.clear();
2003 tile_ranges.resize(n_tiles * 2, 0u32);
2004 let mut running = 0u32;
2005 for t in 0..n_tiles {
2006 tile_ranges[2 * t] = running; // offset
2007 tile_ranges[2 * t + 1] = counts[t]; // count
2008 running += counts[t];
2009 }
2010 let total = running as usize;
2011 let tile_instances = &mut scratch.tile_instances;
2012 tile_instances.clear();
2013 tile_instances.resize(total.max(1), 0u32);
2014 let cursor = &mut scratch.cursor;
2015 cursor.clear();
2016 cursor.extend((0..n_tiles).map(|t| tile_ranges[2 * t]));
2017 for (vis_idx, b) in boxes.iter().enumerate() {
2018 for ty in b[2]..=b[3] {
2019 for tx in b[0]..=b[1] {
2020 let t = (ty * tiles_x as i32 + tx) as usize;
2021 tile_instances[cursor[t] as usize] = vis_idx as u32;
2022 cursor[t] += 1;
2023 }
2024 }
2025 }
2026
2027 // Upload: instances + (grown) tile buffers. Grow a tile buffer
2028 // only when this frame needs more than its capacity (wgpu has
2029 // no Clone on Buffer, so we replace the field in place).
2030 queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(visible));
2031 let need_ranges = tile_ranges.len() as u32;
2032 if need_ranges > self.tile_ranges_cap {
2033 self.tile_ranges_cap = need_ranges.next_power_of_two();
2034 self.tile_ranges = storage_dst_u32(
2035 device,
2036 "roxlap-gpu sprite_reg.tile_ranges",
2037 self.tile_ranges_cap,
2038 );
2039 }
2040 let need_inst = tile_instances.len() as u32;
2041 if need_inst > self.tile_instances_cap {
2042 self.tile_instances_cap = need_inst.next_power_of_two();
2043 self.tile_instances = storage_dst_u32(
2044 device,
2045 "roxlap-gpu sprite_reg.tile_instances",
2046 self.tile_instances_cap,
2047 );
2048 }
2049 queue.write_buffer(&self.tile_ranges, 0, bytemuck::cast_slice(tile_ranges));
2050 queue.write_buffer(
2051 &self.tile_instances,
2052 0,
2053 bytemuck::cast_slice(tile_instances),
2054 );
2055 if pack_colmul {
2056 let need_colmul = visible_colmul.len() as u32;
2057 if need_colmul > self.colmul_cap {
2058 self.colmul_cap = need_colmul.next_power_of_two();
2059 self.colmul =
2060 storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", self.colmul_cap);
2061 self.colmul_identity = false;
2062 }
2063 queue.write_buffer(&self.colmul, 0, bytemuck::cast_slice(visible_colmul));
2064 } else {
2065 // PF.10 — identity fast path: every table is identity, so the
2066 // buffer content is a constant repeating pattern. (Re)fill it
2067 // only on first use / growth; per-frame upload skipped.
2068 let need_colmul = visible.len() as u32 * 512;
2069 if need_colmul > self.colmul_cap {
2070 self.colmul_cap = need_colmul.next_power_of_two();
2071 self.colmul =
2072 storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", self.colmul_cap);
2073 self.colmul_identity = false;
2074 }
2075 if !self.colmul_identity {
2076 let w = identity_colmul()[0];
2077 let (lo, hi) = ((w & 0xffff_ffff) as u32, (w >> 32) as u32);
2078 let fill: Vec<u32> = (0..self.colmul_cap)
2079 .map(|i| if i & 1 == 0 { lo } else { hi })
2080 .collect();
2081 queue.write_buffer(&self.colmul, 0, bytemuck::cast_slice(&fill));
2082 self.colmul_identity = true;
2083 }
2084 }
2085
2086 let res = (visible.len() as u32, tiles_x, tiles_y);
2087 self.last_cull = Some((key, res));
2088 res
2089 }
2090}
2091
2092/// GPU.12 incremental — per-entry placement of one model's `colors`
2093/// (and the parallel `dirs`) within the shared registry buffers: a
2094/// `[off, off+cap)` word window holding `len` live words. `cap >= len`
2095/// gives slack so a carve that *grows* the surface-voxel count can be
2096/// rewritten in place without relocating.
2097#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2098struct ColorSlot {
2099 off: u32,
2100 cap: u32,
2101 len: u32,
2102}
2103
2104/// First-fit suballocator over the parallel `colors`/`dirs` buffers
2105/// (same offsets/ranks → one allocator drives both). Each registry
2106/// entry owns a [`ColorSlot`]; growth past a slot's `cap` relocates it
2107/// (freeing the old block) via the free list or a bump tail, and only
2108/// when the tail would exceed `cap_total` does the caller grow + repack
2109/// the whole buffer. Pure (no GPU) so it unit-tests on its own.
2110#[derive(Debug, Default)]
2111struct ColorsAllocator {
2112 /// Per-entry slot, indexed by entry id.
2113 slots: Vec<ColorSlot>,
2114 /// Freed `(off, cap)` blocks available for first-fit reuse.
2115 free: Vec<(u32, u32)>,
2116 /// Next bump-allocation position (words).
2117 tail: u32,
2118 /// Total buffer capacity in words.
2119 cap_total: u32,
2120}
2121
2122/// Slack-padded capacity for a `len`-word array: +25% + 16 words, so a
2123/// few extra surface voxels from a carve fit without relocating.
2124fn slot_cap(len: u32) -> u32 {
2125 len + len / 4 + 16
2126}
2127
2128/// Slack capacity (words) for a grown concatenated buffer: +50% + 256, so
2129/// a burst of `add_model` calls bump-appends rather than re-growing every
2130/// time. Matches [`ColorsAllocator`]'s `cap_total` headroom.
2131fn grow_words(used: u32) -> u32 {
2132 used + used / 2 + 256
2133}
2134
2135/// Slack capacity (records) for a grown `model_meta` buffer: +50% + 8.
2136fn grow_records(count: u32) -> u32 {
2137 count + count / 2 + 8
2138}
2139
2140impl ColorsAllocator {
2141 /// Lay every entry out contiguously (with per-slot slack) and add a
2142 /// global tail headroom so early growth bump-allocates rather than
2143 /// repacks.
2144 fn new(entry_lens: &[u32]) -> Self {
2145 let mut a = Self::default();
2146 a.repack(entry_lens);
2147 a
2148 }
2149
2150 fn slot(&self, entry: usize) -> ColorSlot {
2151 self.slots[entry]
2152 }
2153
2154 fn cap_total(&self) -> u32 {
2155 self.cap_total
2156 }
2157
2158 /// Repack ALL entries compactly to fit `new_lens`, resetting the
2159 /// free list + tail and choosing a fresh `cap_total` with headroom.
2160 /// Used at initial build and on a buffer grow.
2161 fn repack(&mut self, new_lens: &[u32]) {
2162 self.free.clear();
2163 let mut off = 0u32;
2164 let mut slots = Vec::with_capacity(new_lens.len());
2165 for &len in new_lens {
2166 // A 0-length (dead / removed) entry takes no space — keeps a
2167 // tombstone slot so entry ids stay positional.
2168 let cap = if len == 0 { 0 } else { slot_cap(len) };
2169 slots.push(ColorSlot { off, cap, len });
2170 off += cap;
2171 }
2172 self.slots = slots;
2173 self.tail = off;
2174 // Global headroom: +50% + 256 words.
2175 self.cap_total = off + off / 2 + 256;
2176 }
2177
2178 /// Place `new_len` words for `entry`. Returns `Some(off)` with the
2179 /// (possibly relocated) slot offset, or `None` if the buffer must
2180 /// grow + repack. On relocation the old block is pushed to the free
2181 /// list; an in-place fit returns the unchanged offset.
2182 fn place(&mut self, entry: usize, new_len: u32) -> Option<u32> {
2183 let cur = self.slots[entry];
2184 if new_len <= cur.cap {
2185 self.slots[entry] = ColorSlot {
2186 len: new_len,
2187 ..cur
2188 };
2189 return Some(cur.off);
2190 }
2191 let old = (cur.off, cur.cap);
2192 // First-fit a freed block big enough for the live data.
2193 if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
2194 let (off, cap) = self.free.remove(i);
2195 self.free.push(old);
2196 self.slots[entry] = ColorSlot {
2197 off,
2198 cap,
2199 len: new_len,
2200 };
2201 return Some(off);
2202 }
2203 // Bump the tail if there's room.
2204 let want = slot_cap(new_len);
2205 if self.tail + want <= self.cap_total {
2206 let off = self.tail;
2207 self.tail += want;
2208 self.free.push(old);
2209 self.slots[entry] = ColorSlot {
2210 off,
2211 cap: want,
2212 len: new_len,
2213 };
2214 return Some(off);
2215 }
2216 None
2217 }
2218
2219 /// Append a slot for a brand-new entry of `new_len` words (used by
2220 /// [`SpriteRegistryResident::add_model`]). Returns `Some(off)` placed
2221 /// via the free list or the bump tail, or `None` if the buffer must
2222 /// grow + repack — in which case **no** slot is pushed (the caller's
2223 /// repack rebuilds every slot from scratch).
2224 fn push(&mut self, new_len: u32) -> Option<u32> {
2225 if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
2226 let (off, cap) = self.free.remove(i);
2227 self.slots.push(ColorSlot {
2228 off,
2229 cap,
2230 len: new_len,
2231 });
2232 return Some(off);
2233 }
2234 let want = slot_cap(new_len);
2235 if self.tail + want <= self.cap_total {
2236 let off = self.tail;
2237 self.tail += want;
2238 self.slots.push(ColorSlot {
2239 off,
2240 cap: want,
2241 len: new_len,
2242 });
2243 return Some(off);
2244 }
2245 None
2246 }
2247
2248 /// Free `entry`'s slot back to the pool ([`SpriteRegistryResident::
2249 /// remove_model`]). Its `(off, cap)` block joins the free list for
2250 /// first-fit reuse by a later [`Self::push`]; the slot is zeroed so a
2251 /// repack treats it as a 0-length tombstone.
2252 fn free(&mut self, entry: usize) {
2253 let s = self.slots[entry];
2254 if s.cap > 0 {
2255 self.free.push((s.off, s.cap));
2256 }
2257 self.slots[entry] = ColorSlot {
2258 off: 0,
2259 cap: 0,
2260 len: 0,
2261 };
2262 }
2263}
2264
2265/// Create a STORAGE buffer of u32s; pads empty input (wgpu rejects
2266/// zero-sized storage bindings).
2267#[allow(dead_code)]
2268fn storage_u32(device: &wgpu::Device, label: &str, data: &[u32]) -> wgpu::Buffer {
2269 use wgpu::util::DeviceExt;
2270 let bytes: &[u8] = if data.is_empty() {
2271 bytemuck::cast_slice(&[0u32])
2272 } else {
2273 bytemuck::cast_slice(data)
2274 };
2275 device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2276 label: Some(label),
2277 contents: bytes,
2278 usage: wgpu::BufferUsages::STORAGE,
2279 })
2280}
2281
2282/// Create an uninitialised `STORAGE | COPY_DST` `u32` buffer of `cap`
2283/// words (≥1). Written each frame via `queue.write_buffer`.
2284fn storage_dst_u32(device: &wgpu::Device, label: &str, cap: u32) -> wgpu::Buffer {
2285 device.create_buffer(&wgpu::BufferDescriptor {
2286 label: Some(label),
2287 size: u64::from(cap.max(1)) * 4,
2288 // COPY_SRC so test/debug harnesses can read the contents back
2289 // (PF.10's cull gate does); free at runtime.
2290 usage: wgpu::BufferUsages::STORAGE
2291 | wgpu::BufferUsages::COPY_DST
2292 | wgpu::BufferUsages::COPY_SRC,
2293 mapped_at_creation: false,
2294 })
2295}
2296
2297/// Create a `STORAGE | COPY_DST` `u32` buffer of `cap` words (≥ data
2298/// length, ≥ 1), initialised with `data` at offset 0 and the tail left
2299/// zeroed. Unlike [`storage_u32`] (STORAGE-only, exact-size) this both
2300/// reserves spare capacity and is `COPY_DST`, so the incremental
2301/// [`SpriteRegistryResident::update_model`] can `write_buffer` a growing
2302/// `colors`/`dirs` array in place. Filled via `mapped_at_creation` so no
2303/// queue is needed at upload time.
2304fn storage_dst_u32_cap(device: &wgpu::Device, label: &str, data: &[u32], cap: u32) -> wgpu::Buffer {
2305 let cap = cap.max(data.len() as u32).max(1);
2306 let buf = device.create_buffer(&wgpu::BufferDescriptor {
2307 label: Some(label),
2308 size: u64::from(cap) * 4,
2309 usage: wgpu::BufferUsages::STORAGE
2310 | wgpu::BufferUsages::COPY_DST
2311 | wgpu::BufferUsages::COPY_SRC,
2312 mapped_at_creation: true,
2313 });
2314 if !data.is_empty() {
2315 buf.slice(..(data.len() as u64 * 4))
2316 .get_mapped_range_mut()
2317 .copy_from_slice(bytemuck::cast_slice(data));
2318 }
2319 buf.unmap();
2320 buf
2321}
2322
2323/// Create a `STORAGE | COPY_DST` buffer of Pod records, exact-size
2324/// (≥ 1, zero-padded), so individual records can be rewritten in place
2325/// by [`SpriteRegistryResident::update_model`] on a relocation. The
2326/// record *count* never changes on an incremental edit (no model is
2327/// added/removed), so no slack is needed here.
2328fn storage_dst_pod<T: Pod + Zeroable>(
2329 device: &wgpu::Device,
2330 label: &str,
2331 data: &[T],
2332) -> wgpu::Buffer {
2333 let one = [T::zeroed()];
2334 let src: &[T] = if data.is_empty() { &one } else { data };
2335 let buf = device.create_buffer(&wgpu::BufferDescriptor {
2336 label: Some(label),
2337 size: std::mem::size_of_val(src) as u64,
2338 usage: wgpu::BufferUsages::STORAGE
2339 | wgpu::BufferUsages::COPY_DST
2340 | wgpu::BufferUsages::COPY_SRC,
2341 mapped_at_creation: true,
2342 });
2343 buf.slice(..)
2344 .get_mapped_range_mut()
2345 .copy_from_slice(bytemuck::cast_slice(src));
2346 buf.unmap();
2347 buf
2348}
2349
2350/// Create a `STORAGE | COPY_DST` Pod buffer holding `cap` records
2351/// (≥ `data.len()`, ≥ 1), initialised with `data` at record 0 and the
2352/// tail zeroed. The slack lets [`SpriteRegistryResident::add_model`] grow
2353/// the `model_meta` table without re-growing on every add.
2354fn storage_dst_pod_cap<T: Pod + Zeroable>(
2355 device: &wgpu::Device,
2356 label: &str,
2357 data: &[T],
2358 cap: u32,
2359) -> wgpu::Buffer {
2360 let rec = std::mem::size_of::<T>() as u64;
2361 let cap = u64::from(cap.max(data.len() as u32).max(1));
2362 let buf = device.create_buffer(&wgpu::BufferDescriptor {
2363 label: Some(label),
2364 size: cap * rec,
2365 usage: wgpu::BufferUsages::STORAGE
2366 | wgpu::BufferUsages::COPY_DST
2367 | wgpu::BufferUsages::COPY_SRC,
2368 mapped_at_creation: true,
2369 });
2370 if !data.is_empty() {
2371 buf.slice(..(data.len() as u64 * rec))
2372 .get_mapped_range_mut()
2373 .copy_from_slice(bytemuck::cast_slice(data));
2374 }
2375 buf.unmap();
2376 buf
2377}
2378
2379/// Create a STORAGE buffer of Pod records; pads empty input with one
2380/// zeroed `T`.
2381#[allow(dead_code)]
2382fn storage_pod<T: Pod + Zeroable>(device: &wgpu::Device, label: &str, data: &[T]) -> wgpu::Buffer {
2383 use wgpu::util::DeviceExt;
2384 let one = [T::zeroed()];
2385 let src: &[T] = if data.is_empty() { &one } else { data };
2386 device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2387 label: Some(label),
2388 contents: bytemuck::cast_slice(src),
2389 usage: wgpu::BufferUsages::STORAGE,
2390 })
2391}
2392
2393#[cfg(test)]
2394mod tests {
2395 use super::*;
2396 use roxlap_formats::kv6::{Kv6, Voxel};
2397
2398 /// 2×1 kv6: column (0,0) has voxels at z=5 (red) and z=1 (green)
2399 /// stored OUT of z-order; column (1,0) has one voxel at z=3.
2400 fn kv6_unsorted() -> Kv6 {
2401 let mk = |z, col| Voxel {
2402 col,
2403 z,
2404 vis: 0,
2405 dir: 0,
2406 };
2407 Kv6 {
2408 xsiz: 2,
2409 ysiz: 1,
2410 zsiz: 8,
2411 xpiv: 0.0,
2412 ypiv: 0.0,
2413 zpiv: 0.0,
2414 voxels: vec![mk(5, 0xAA), mk(1, 0xBB), mk(3, 0xCC)],
2415 xlen: vec![2, 1],
2416 ylen: vec![vec![2], vec![1]],
2417 palette: None,
2418 }
2419 }
2420
2421 #[test]
2422 fn occupancy_bits_set_at_voxel_z() {
2423 let m = build_sprite_model(&kv6_unsorted());
2424 assert_eq!(m.dims, [2, 1, 8]);
2425 assert_eq!(m.occ_words_per_col, 1); // ceil(8/32)
2426 // col 0: bits 1 and 5; col 1: bit 3.
2427 assert_eq!(m.occupancy[0], (1 << 1) | (1 << 5));
2428 assert_eq!(m.occupancy[1], 1 << 3);
2429 }
2430
2431 #[test]
2432 fn colors_are_ascending_z_for_rank_lookup() {
2433 let m = build_sprite_model(&kv6_unsorted());
2434 // col 0 sorted ascending z ⇒ z=1 (green 0xBB) before z=5 (0xAA).
2435 assert_eq!(m.color_offsets, vec![0, 2, 3]);
2436 assert_eq!(&m.colors, &[0xBB, 0xAA, 0xCC]);
2437 }
2438
2439 #[test]
2440 fn identity_basis_inverts_to_identity() {
2441 let inv = mat3_inverse([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
2442 assert_eq!(inv, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
2443 }
2444
2445 #[test]
2446 fn fork_is_independent_of_parent() {
2447 let mut reg = SpriteModelRegistry::new();
2448 let base = reg.add(build_sprite_model(&kv6_unsorted()));
2449 let forked = reg.fork(base);
2450 assert_ne!(base, forked);
2451 // Recolour only the fork.
2452 reg.model_mut(forked).recolor(|_| 0x11);
2453 // Parent colours untouched; fork fully overwritten.
2454 assert_eq!(®.model(base).colors, &[0xBB, 0xAA, 0xCC]);
2455 assert_eq!(®.model(forked).colors, &[0x11, 0x11, 0x11]);
2456 }
2457
2458 #[test]
2459 fn remove_frees_chain_data_keeps_ids_stable() {
2460 let mut reg = SpriteModelRegistry::new();
2461 let a = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2462 let b = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2463 let len_before = reg.len();
2464 assert!(reg.is_live(a) && reg.is_live(b));
2465
2466 reg.remove(a);
2467 // Chain `a` is tombstoned (its entries are freed to empty models;
2468 // they're unreachable via `model()` now — that's the tombstone).
2469 assert!(!reg.is_live(a));
2470 // `b` is untouched and still live; `len()` (next id) is unchanged.
2471 assert!(reg.is_live(b));
2472 assert_eq!(®.model(b).colors, &[0xBB, 0xAA, 0xCC]);
2473 assert_eq!(reg.len(), len_before);
2474
2475 // A later add mints a fresh id past the tombstone (no slot reuse).
2476 let c = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2477 assert_eq!(c, len_before as u32);
2478 assert!(reg.is_live(c));
2479 // `b`'s id stayed valid across the remove + add round-trip.
2480 assert_eq!(®.model(b).colors, &[0xBB, 0xAA, 0xCC]);
2481 }
2482
2483 #[test]
2484 fn model_checked_guards_out_of_range_and_tombstoned() {
2485 // The guard `set_instance_model` relies on: `model()` would
2486 // index-panic on these, `model_checked` returns `None`.
2487 let mut reg = SpriteModelRegistry::new();
2488 let a = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2489 assert!(reg.model_checked(a).is_some());
2490 assert!(reg.model_checked(9999).is_none(), "out of range → None");
2491 reg.remove(a);
2492 assert!(reg.model_checked(a).is_none(), "tombstoned chain → None");
2493 }
2494
2495 #[test]
2496 fn remove_is_idempotent_and_bounds_safe() {
2497 let mut reg = SpriteModelRegistry::new();
2498 let a = reg.add(build_sprite_model(&kv6_unsorted()));
2499 reg.remove(a);
2500 reg.remove(a); // already removed → no-op, no panic
2501 reg.remove(999); // out of range → no-op
2502 assert!(!reg.is_live(a));
2503 assert!(!reg.is_live(999));
2504 }
2505
2506 #[test]
2507 fn registry_gpu_structs_have_expected_sizes() {
2508 assert_eq!(std::mem::size_of::<SpriteModelMeta>(), 48);
2509 // TV — grew 64 → 80 with the per-instance material id + alpha_mul
2510 // (+ 8 bytes pad to keep the 16-byte std430 stride).
2511 assert_eq!(std::mem::size_of::<SpriteInstanceGpu>(), 80);
2512 }
2513
2514 #[test]
2515 fn add_lod_builds_halving_mip_chain() {
2516 let mut reg = SpriteModelRegistry::new();
2517 // 8×8×8 single voxel-filled column model would be ideal, but
2518 // kv6_unsorted is 2×1×8 → mips: 2×1×8 → 1×1×4 → 1×1×2 → 1×1×1.
2519 let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2520 let m0 = reg.model(id);
2521 assert_eq!(m0.dims, [2, 1, 8]);
2522 assert!((m0.voxel_world_size - 1.0).abs() < 1e-6);
2523 }
2524
2525 /// kv6 from explicit voxels, ordered x-major/y-inner to match
2526 /// `build_sprite_model`'s column walk.
2527 fn kv6_from(xsiz: u32, ysiz: u32, zsiz: u32, voxels: &[(u32, u32, u16, u32)]) -> Kv6 {
2528 let mut ylen = vec![vec![0u16; ysiz as usize]; xsiz as usize];
2529 let mut flat = Vec::new();
2530 for x in 0..xsiz {
2531 for y in 0..ysiz {
2532 let mut col: Vec<(u16, u32)> = voxels
2533 .iter()
2534 .filter(|(vx, vy, _, _)| *vx == x && *vy == y)
2535 .map(|(_, _, z, c)| (*z, *c))
2536 .collect();
2537 col.sort_by_key(|(z, _)| *z);
2538 ylen[x as usize][y as usize] = col.len() as u16;
2539 for (z, c) in col {
2540 flat.push(Voxel {
2541 col: c,
2542 z,
2543 vis: 0,
2544 dir: 0,
2545 });
2546 }
2547 }
2548 }
2549 let xlen = ylen
2550 .iter()
2551 .map(|c| c.iter().map(|&v| u32::from(v)).sum())
2552 .collect();
2553 Kv6 {
2554 xsiz,
2555 ysiz,
2556 zsiz,
2557 xpiv: 0.0,
2558 ypiv: 0.0,
2559 zpiv: 0.0,
2560 voxels: flat,
2561 xlen,
2562 ylen,
2563 palette: None,
2564 }
2565 }
2566
2567 fn offsets_consistent(m: &SpriteModel) -> bool {
2568 let cols = (m.dims[0] * m.dims[1]) as usize;
2569 if m.color_offsets.len() != cols + 1 {
2570 return false;
2571 }
2572 // Monotonic non-decreasing + last == colors.len + each column's
2573 // span == its solid-voxel count.
2574 for w in m.color_offsets.windows(2) {
2575 if w[1] < w[0] {
2576 return false;
2577 }
2578 }
2579 m.color_offsets[cols] as usize == m.colors.len()
2580 }
2581
2582 #[test]
2583 fn carve_two_layers_keeps_offsets_consistent() {
2584 // Mirror the demo's carve: columns with voxels at varied z,
2585 // some sharing z=0/z=1, some not.
2586 let kv6 = kv6_from(
2587 3,
2588 2,
2589 8,
2590 &[
2591 (0, 0, 0, 0xA0),
2592 (0, 0, 1, 0xA1),
2593 (0, 0, 5, 0xA5),
2594 (1, 0, 1, 0xB1),
2595 (2, 1, 0, 0xC0),
2596 (2, 1, 3, 0xC3),
2597 ],
2598 );
2599 let mut m = build_sprite_model(&kv6);
2600 assert!(offsets_consistent(&m));
2601 for z in 0..2u32 {
2602 for y in 0..m.dims[1] {
2603 for x in 0..m.dims[0] {
2604 m.set_voxel(x, y, z, None);
2605 }
2606 }
2607 assert!(offsets_consistent(&m), "inconsistent after carving z={z}");
2608 // downsample must not panic on the carved model.
2609 let _ = m.downsample();
2610 }
2611 }
2612
2613 #[test]
2614 fn set_voxel_inserts_replaces_and_clears() {
2615 // col 0 starts with z=1 (0xBB), z=5 (0xAA); col 1 with z=3 (0xCC).
2616 let mut m = build_sprite_model(&kv6_unsorted());
2617
2618 // Insert z=3 into col 0 (between z=1 and z=5) → rank 1.
2619 assert!(m.set_voxel(0, 0, 3, Some(0x55)));
2620 assert_eq!(m.occupancy[0], (1 << 1) | (1 << 3) | (1 << 5));
2621 // col 0 colours ascending z: 0xBB(z1), 0x55(z3), 0xAA(z5).
2622 assert_eq!(m.color_offsets, vec![0, 3, 4]);
2623 assert_eq!(&m.colors, &[0xBB, 0x55, 0xAA, 0xCC]);
2624
2625 // Replace z=3 in place (no offset shift).
2626 assert!(m.set_voxel(0, 0, 3, Some(0x66)));
2627 assert_eq!(&m.colors, &[0xBB, 0x66, 0xAA, 0xCC]);
2628 assert_eq!(m.color_offsets, vec![0, 3, 4]);
2629
2630 // Clear z=1 (rank 0) from col 0.
2631 assert!(m.set_voxel(0, 0, 1, None));
2632 assert_eq!(m.occupancy[0], (1 << 3) | (1 << 5));
2633 assert_eq!(m.color_offsets, vec![0, 2, 3]);
2634 assert_eq!(&m.colors, &[0x66, 0xAA, 0xCC]);
2635
2636 // No-ops: clear an empty voxel, edit out of bounds.
2637 assert!(!m.set_voxel(0, 0, 2, None));
2638 assert!(!m.set_voxel(9, 0, 0, Some(1)));
2639 }
2640
2641 #[test]
2642 fn rebuild_lod_refreshes_coarse_levels_from_mip0() {
2643 let mut reg = SpriteModelRegistry::new();
2644 let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 3);
2645 // Recolour mip-0 only via model_mut, then rebuild the ladder.
2646 reg.model_mut(id).recolor(|_| 0x0000_2000);
2647 reg.rebuild_lod(id);
2648 // The mip-1 average of all-0x2000 voxels is still 0x2000.
2649 let lvl1_entry = reg.chains[id as usize][1] as usize;
2650 assert!(reg.entries[lvl1_entry]
2651 .colors
2652 .iter()
2653 .all(|&c| c == 0x0000_2000));
2654 }
2655
2656 // ---- GPU.12 incremental: colors/dirs suballocator -----------------
2657
2658 /// Every slot fits its data, has slack, doesn't overlap the next, and
2659 /// the buffer reserves tail headroom past the last slot.
2660 fn alloc_invariants(a: &ColorsAllocator, lens: &[u32]) {
2661 let mut prev_end = 0u32;
2662 for (e, &len) in lens.iter().enumerate() {
2663 let s = a.slot(e);
2664 assert_eq!(s.len, len, "slot {e} len");
2665 assert!(s.cap >= s.len, "slot {e} cap >= len");
2666 // In a freshly repacked layout slots are in entry order.
2667 assert!(s.off >= prev_end, "slot {e} overlaps previous");
2668 assert!(s.off + s.cap <= a.cap_total(), "slot {e} past cap_total");
2669 prev_end = s.off + s.cap;
2670 }
2671 assert!(a.cap_total() >= prev_end, "tail headroom");
2672 }
2673
2674 #[test]
2675 fn allocator_new_lays_out_with_slack_and_headroom() {
2676 let lens = [10u32, 0, 64, 7];
2677 let a = ColorsAllocator::new(&lens);
2678 alloc_invariants(&a, &lens);
2679 // Slack: a 64-word slot has cap > 64 so a small carve-grow fits.
2680 assert!(a.slot(2).cap > 64);
2681 // Headroom past the bump tail for early growth.
2682 assert!(a.cap_total() > a.slot(3).off + a.slot(3).cap);
2683 }
2684
2685 #[test]
2686 fn allocator_place_in_place_when_within_cap() {
2687 let mut a = ColorsAllocator::new(&[10, 20]);
2688 let off0 = a.slot(0).off;
2689 let cap0 = a.slot(0).cap;
2690 // Shrink: still the same slot.
2691 assert_eq!(a.place(0, 5), Some(off0));
2692 assert_eq!(a.slot(0).len, 5);
2693 assert_eq!(a.slot(0).cap, cap0);
2694 // Grow within slack: same offset, no relocation.
2695 assert_eq!(a.place(0, cap0), Some(off0));
2696 assert_eq!(a.slot(0).off, off0);
2697 assert!(a.free.is_empty(), "no relocation should free anything");
2698 }
2699
2700 #[test]
2701 fn allocator_place_relocates_to_tail_and_frees_old() {
2702 let mut a = ColorsAllocator::new(&[10, 20]);
2703 let old0 = (a.slot(0).off, a.slot(0).cap);
2704 let tail_before = a.tail;
2705 // Overgrow entry 0 past its cap → relocate to the bump tail.
2706 let new_len = a.slot(0).cap + 5;
2707 let off = a.place(0, new_len).expect("fits in headroom");
2708 assert_eq!(off, tail_before, "relocated to old tail");
2709 assert_eq!(a.slot(0).off, off);
2710 assert_eq!(a.slot(0).len, new_len);
2711 assert!(a.free.contains(&old0), "old slot freed");
2712 }
2713
2714 #[test]
2715 fn allocator_reuses_freed_block_first_fit() {
2716 // Entry 0 has a large slot; entry 1 a tiny one, so growing 1 must
2717 // relocate (it can't fit in place) and lands in 0's freed block.
2718 let mut a = ColorsAllocator::new(&[10, 2]);
2719 let old0 = (a.slot(0).off, a.slot(0).cap);
2720 // Relocate entry 0 to the tail, freeing its original block.
2721 let _ = a.place(0, a.slot(0).cap + 5).unwrap();
2722 assert!(a.free.contains(&old0));
2723 // Grow entry 1 past its (tiny) cap but ≤ the freed block's cap →
2724 // first-fit reuses that block rather than bumping the tail.
2725 let new1 = a.slot(1).cap + 1;
2726 assert!(new1 <= old0.1, "freed block big enough");
2727 let off = a.place(1, new1).expect("reuses freed block");
2728 assert_eq!(off, old0.0, "first-fit reused the freed slot offset");
2729 assert!(!a.free.contains(&old0), "freed block consumed");
2730 }
2731
2732 #[test]
2733 fn allocator_signals_grow_then_repack_restores() {
2734 let mut a = ColorsAllocator::new(&[8, 8]);
2735 // Force overflow: ask for far more than cap_total.
2736 let huge = a.cap_total() + 100;
2737 assert_eq!(a.place(0, huge), None, "overflow must signal grow");
2738 // Repack with the new lengths compacts + grows the buffer.
2739 a.repack(&[huge, 8]);
2740 alloc_invariants(&a, &[huge, 8]);
2741 assert!(a.cap_total() > huge);
2742 // After repack the entry now fits in place.
2743 assert_eq!(a.place(0, huge), Some(a.slot(0).off));
2744 }
2745
2746 /// Drive the allocator like a real carve loop (mirroring
2747 /// `update_model`): one model's colour count drifts up and down
2748 /// across many edits while two neighbours stay put. Growth is
2749 /// absorbed in place / via the free list / by the bump tail, and on
2750 /// the rare overflow we repack (as `update_model` does). After every
2751 /// edit the live `[off, off+len)` windows must stay disjoint.
2752 #[test]
2753 fn allocator_carve_loop_keeps_live_windows_disjoint() {
2754 let mut a = ColorsAllocator::new(&[40, 12, 40]);
2755 let mut lens = [40u32, 12, 40];
2756 // A deterministic up/down walk of entry 1's length, incl. a jump
2757 // that forces at least one grow+repack.
2758 let walk = [13u32, 30, 60, 18, 9, 80, 80, 25, 200, 7];
2759 let mut grew = false;
2760 for &len in &walk {
2761 lens[1] = len;
2762 // Entry 1 re-placed; on overflow, repack the whole set.
2763 if a.place(1, len).is_none() {
2764 grew = true;
2765 a.repack(&lens);
2766 } else {
2767 // Neighbours fit in place every time.
2768 assert_eq!(a.place(0, 40), Some(a.slot(0).off));
2769 assert_eq!(a.place(2, 40), Some(a.slot(2).off));
2770 }
2771 assert_eq!(a.slot(1).len, len);
2772
2773 // No two entries' live windows overlap.
2774 let mut wins: Vec<(u32, u32)> =
2775 (0..3).map(|e| (a.slot(e).off, a.slot(e).len)).collect();
2776 wins.sort_by_key(|w| w.0);
2777 for pair in wins.windows(2) {
2778 let (o0, l0) = pair[0];
2779 let (o1, _) = pair[1];
2780 assert!(o0 + l0 <= o1, "live windows overlap: {pair:?}");
2781 }
2782 }
2783 assert!(grew, "the 200-word jump should have forced a repack");
2784 }
2785
2786 // --- incremental instance path (device-backed; skips w/o adapter) ---
2787
2788 fn headless() -> Option<crate::HeadlessGpu> {
2789 match crate::HeadlessGpu::new_blocking(crate::GpuRendererSettings::default()) {
2790 Ok(h) => Some(h),
2791 Err(e) => {
2792 eprintln!("[skip] no GPU adapter reachable: {e}");
2793 None
2794 }
2795 }
2796 }
2797
2798 fn one_model_registry() -> (SpriteModelRegistry, u32) {
2799 let mut reg = SpriteModelRegistry::new();
2800 let id = reg.add(build_sprite_model(&kv6_unsorted()));
2801 (reg, id)
2802 }
2803
2804 fn inst(model_id: u32, pos: [f32; 3]) -> SpriteInstance {
2805 use roxlap_formats::sprite::Sprite;
2806 SpriteInstance::new(
2807 model_id,
2808 SpriteInstanceTransform::from_sprite(&Sprite::axis_aligned(kv6_unsorted(), pos)),
2809 )
2810 }
2811
2812 /// PS.1 — a scaled basis grows the cull sphere with the pose: the
2813 /// transform keeps the longest basis column, and `make_cull` seeds
2814 /// `radius = model.bound_radius() × max_scale`, so scaled-up
2815 /// instances (particles) no longer under-cull at screen edges.
2816 #[test]
2817 fn scaled_basis_scales_cull_radius() {
2818 use roxlap_formats::sprite::Sprite;
2819
2820 let mut reg = SpriteModelRegistry::new();
2821 let chain = reg.add(build_sprite_model(&kv6_unsorted()));
2822 let model_r = reg.model(chain).bound_radius();
2823
2824 let scaled = |k: f32, pos: [f32; 3]| {
2825 let mut s = Sprite::axis_aligned(kv6_unsorted(), pos);
2826 for a in 0..3 {
2827 s.s[a] *= k;
2828 s.h[a] *= k;
2829 s.f[a] *= k;
2830 }
2831 SpriteInstanceTransform::from_sprite(&s)
2832 };
2833
2834 // Unit basis: max_scale 1, radius = the model's (float-exact).
2835 let unit = inst(chain, [0.0; 3]);
2836 assert_eq!(unit.transform.max_scale, 1.0);
2837 assert_eq!(make_cull(®, &unit).radius, model_r);
2838
2839 // 2× uniform scale doubles both.
2840 let xf2 = scaled(2.0, [0.0; 3]);
2841 assert!((xf2.max_scale - 2.0).abs() < 1e-6);
2842 let big = SpriteInstance::new(chain, xf2);
2843 assert!((make_cull(®, &big).radius - 2.0 * model_r).abs() < 1e-4);
2844
2845 // Anisotropic scale takes the longest column.
2846 let mut s = Sprite::axis_aligned(kv6_unsorted(), [0.0; 3]);
2847 for a in 0..3 {
2848 s.h[a] *= 3.0;
2849 s.f[a] *= 0.5;
2850 }
2851 let xf = SpriteInstanceTransform::from_sprite(&s);
2852 assert!((xf.max_scale - 3.0).abs() < 1e-6);
2853 }
2854
2855 #[test]
2856 fn append_grows_count_and_capacity_pow2() {
2857 let Some(h) = headless() else { return };
2858 let (reg, m) = one_model_registry();
2859 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(m, [0.0; 3])]);
2860 assert_eq!(res.instance_count(), 1);
2861 assert_eq!(res.instance_capacity, 1);
2862
2863 // Append 4 → count 5, capacity grows to next_pow2(5) = 8.
2864 let more: Vec<_> = (1..=4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
2865 let base = res.append_instances(&h.device, ®, &more);
2866 assert_eq!(base, 1, "first appended index follows the seed instance");
2867 assert_eq!(res.instance_count(), 5);
2868 assert_eq!(res.instance_capacity, 8, "power-of-two growth");
2869
2870 // A second append that still fits keeps the same capacity (no realloc).
2871 let base2 = res.append_instances(&h.device, ®, &[inst(m, [9.0, 0.0, 0.0])]);
2872 assert_eq!(base2, 5);
2873 assert_eq!(res.instance_count(), 6);
2874 assert_eq!(res.instance_capacity, 8, "fits existing capacity, no grow");
2875 }
2876
2877 #[test]
2878 fn append_empty_is_noop() {
2879 let Some(h) = headless() else { return };
2880 let (reg, m) = one_model_registry();
2881 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(m, [0.0; 3])]);
2882 let base = res.append_instances(&h.device, ®, &[]);
2883 assert_eq!(base, 1);
2884 assert_eq!(res.instance_count(), 1);
2885 assert_eq!(res.instance_capacity, 1);
2886 }
2887
2888 /// Read `words` u32s back from a GPU buffer (needs COPY_SRC).
2889 fn read_u32(h: &crate::HeadlessGpu, buf: &wgpu::Buffer, words: u64) -> Vec<u32> {
2890 let bytes = words * 4;
2891 let staging = h.device.create_buffer(&wgpu::BufferDescriptor {
2892 label: Some("readback"),
2893 size: bytes,
2894 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2895 mapped_at_creation: false,
2896 });
2897 let mut enc = h
2898 .device
2899 .create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
2900 enc.copy_buffer_to_buffer(buf, 0, &staging, 0, bytes);
2901 h.queue.submit(std::iter::once(enc.finish()));
2902 let slice = staging.slice(..);
2903 let (tx, rx) = std::sync::mpsc::channel();
2904 slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
2905 h.device.poll(wgpu::PollType::wait_indefinitely()).ok();
2906 rx.recv().unwrap().unwrap();
2907 let data = slice.get_mapped_range();
2908 let out = bytemuck::cast_slice::<u8, u32>(&data).to_vec();
2909 drop(data);
2910 staging.unmap();
2911 out
2912 }
2913
2914 /// A second distinct model so add_model has real new geometry to lay
2915 /// down (different dims + colours from `kv6_unsorted`).
2916 fn kv6_other() -> Kv6 {
2917 let mk = |z, col| Voxel {
2918 col,
2919 z,
2920 vis: 0,
2921 dir: 0,
2922 };
2923 Kv6 {
2924 xsiz: 1,
2925 ysiz: 1,
2926 zsiz: 4,
2927 xpiv: 0.0,
2928 ypiv: 0.0,
2929 zpiv: 0.0,
2930 voxels: vec![mk(0, 0x11), mk(2, 0x22)],
2931 xlen: vec![2],
2932 ylen: vec![vec![2]],
2933 palette: None,
2934 }
2935 }
2936
2937 /// add_model lays the new model's volume on the GPU at the offsets its
2938 /// meta record claims — verified by reading the shared buffers back
2939 /// and matching each entry against its source SpriteModel.
2940 #[test]
2941 fn add_model_uploads_new_volume_incrementally() {
2942 let Some(h) = headless() else { return };
2943
2944 // Residency starts with model A only.
2945 let mut reg = SpriteModelRegistry::new();
2946 let a = reg.add(build_sprite_model(&kv6_unsorted()));
2947 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(a, [0.0; 3])]);
2948 assert_eq!(res.chains.len(), 1);
2949 let entries_before = res.meta.len();
2950
2951 // Append model B (single-level) to the registry, then sync it.
2952 let b = reg.add(build_sprite_model(&kv6_other()));
2953 res.add_model(&h.device, &h.queue, ®, b);
2954 assert_eq!(res.chains.len(), 2);
2955 assert_eq!(res.meta.len(), entries_before + 1, "one new entry");
2956
2957 // Read the shared buffers back and check EVERY entry's data sits
2958 // where its meta record points — both the pre-existing A and the
2959 // newly streamed B.
2960 let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
2961 let coloff = read_u32(&h, &res.color_offsets, u64::from(res.coloff_cap));
2962 let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
2963 for (e, m) in reg.entries.iter().enumerate() {
2964 let meta = res.meta[e];
2965 let oo = meta.occupancy_offset as usize;
2966 assert_eq!(
2967 &occ[oo..oo + m.occupancy.len()],
2968 &m.occupancy[..],
2969 "occ entry {e}"
2970 );
2971 let co = meta.color_offsets_offset as usize;
2972 assert_eq!(
2973 &coloff[co..co + m.color_offsets.len()],
2974 &m.color_offsets[..],
2975 "color_offsets entry {e}"
2976 );
2977 let cc = meta.colors_offset as usize;
2978 assert_eq!(
2979 &cols[cc..cc + m.colors.len()],
2980 &m.colors[..],
2981 "colors entry {e}"
2982 );
2983 }
2984
2985 // And an instance of the freshly-added model can now be appended.
2986 let base = res.append_instances(&h.device, ®, &[inst(b, [5.0, 0.0, 0.0])]);
2987 assert_eq!(base, 1);
2988 assert_eq!(res.instance_count(), 2);
2989 }
2990
2991 /// Adding many small models forces the volume buffers to grow + rebuild
2992 /// at least once; every entry must still read back correctly across the
2993 /// grow boundary.
2994 #[test]
2995 fn add_model_survives_buffer_growth() {
2996 let Some(h) = headless() else { return };
2997 let mut reg = SpriteModelRegistry::new();
2998 let a = reg.add(build_sprite_model(&kv6_unsorted()));
2999 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(a, [0.0; 3])]);
3000 let occ_cap0 = res.occ_cap;
3001
3002 // 40 adds — occupancy starts exact-sized (cap == used), so the very
3003 // first add overflows and grows; later ones ride the slack.
3004 for _ in 0..40 {
3005 let id = reg.add(build_sprite_model(&kv6_other()));
3006 res.add_model(&h.device, &h.queue, ®, id);
3007 }
3008 assert_eq!(res.chains.len(), 41);
3009 assert!(res.occ_cap > occ_cap0, "occupancy buffer grew");
3010
3011 let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3012 let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3013 for (e, m) in reg.entries.iter().enumerate() {
3014 let meta = res.meta[e];
3015 let oo = meta.occupancy_offset as usize;
3016 assert_eq!(
3017 &occ[oo..oo + m.occupancy.len()],
3018 &m.occupancy[..],
3019 "occ entry {e}"
3020 );
3021 let cc = meta.colors_offset as usize;
3022 assert_eq!(
3023 &cols[cc..cc + m.colors.len()],
3024 &m.colors[..],
3025 "colors entry {e}"
3026 );
3027 }
3028 }
3029
3030 /// Regression (downstream report, 0.27.0): a `remove_model` hole
3031 /// followed by an occupancy-overflow `add_model` desynced every live
3032 /// entry behind the hole — the grow path rebuilt the buffer tightly
3033 /// (tombstoned entries contribute nothing) but kept the STALE bump
3034 /// offsets in `meta`, so those models read shifted occupancy words
3035 /// ("black stripe planes") until an `update_model` happened to
3036 /// rewrite them at the stale offset. The overflow path now routes
3037 /// through `compact_concat`, which recomputes the offsets it
3038 /// uploads.
3039 #[test]
3040 fn growth_after_remove_keeps_offsets_in_sync() {
3041 let Some(h) = headless() else { return };
3042 let mut reg = SpriteModelRegistry::new();
3043 // Three models; the middle one becomes the hole.
3044 let a = reg.add(build_sprite_model(&kv6_unsorted()));
3045 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(a, [0.0; 3])]);
3046 let b = reg.add(build_sprite_model(&kv6_other()));
3047 res.add_model(&h.device, &h.queue, ®, b);
3048 let c = reg.add(build_sprite_model(&kv6_other()));
3049 res.add_model(&h.device, &h.queue, ®, c);
3050 let _ = c;
3051
3052 // Tombstone the middle chain: resident hole + zero-length
3053 // registry entry (exactly the facade's remove path).
3054 res.remove_model(b);
3055 reg.remove(b);
3056
3057 // Keep adding until occupancy overflows — the grow/rebuild path.
3058 let cap_before = res.occ_cap;
3059 let mut guard = 0;
3060 while res.occ_cap == cap_before {
3061 let id = reg.add(build_sprite_model(&kv6_other()));
3062 res.add_model(&h.device, &h.queue, ®, id);
3063 guard += 1;
3064 assert!(guard < 10_000, "growth never triggered");
3065 }
3066
3067 // Every LIVE entry's meta offset must point at its actual data
3068 // in the rebuilt buffers.
3069 let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3070 let coloff = read_u32(&h, &res.color_offsets, u64::from(res.coloff_cap));
3071 for (e, m) in reg.entries.iter().enumerate() {
3072 if res.dead[e] {
3073 continue;
3074 }
3075 let meta = res.meta[e];
3076 let oo = meta.occupancy_offset as usize;
3077 assert_eq!(
3078 &occ[oo..oo + m.occupancy.len()],
3079 &m.occupancy[..],
3080 "occ entry {e} reads at its meta offset"
3081 );
3082 let co = meta.color_offsets_offset as usize;
3083 assert_eq!(
3084 &coloff[co..co + m.color_offsets.len()],
3085 &m.color_offsets[..],
3086 "color_offsets entry {e}"
3087 );
3088 }
3089 }
3090
3091 /// VCL.2 — a decoded voxel clip's frames register as a flipbook of LOD
3092 /// chains, and `set_instance_model` flips which frame an instance
3093 /// draws. The cull state it updates is exactly what
3094 /// `cull_bin_upload` packs into the GPU instance buffer each frame, so
3095 /// TV.3 (clip wiring): `sprite_model_from_clip_frame_with_materials`
3096 /// classifies a clip frame's voxels into a per-voxel `materials` array
3097 /// (parallel to `colors`) by colour; an empty map leaves it empty (the
3098 /// all-opaque clip), identical to `sprite_model_from_clip_frame`.
3099 #[test]
3100 fn clip_frame_with_materials_classifies_by_color() {
3101 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
3102
3103 let dims = [1u32, 1, 4];
3104 let owpc = dims[2].div_ceil(32).max(1) as usize; // 1
3105 let glass = 0x80AA_BBCC;
3106 let stone = 0x8011_2233;
3107 let frame = VoxelFrame {
3108 occupancy: {
3109 let mut occ = vec![0u32; owpc];
3110 occ[0] |= (1 << 0) | (1 << 1);
3111 occ
3112 },
3113 colors: vec![stone, glass], // ascending z: z=0 stone, z=1 glass
3114 color_offsets: vec![0, 2],
3115 };
3116 let clip = VoxelClip::from_frames(
3117 dims,
3118 [0.5, 0.5, 2.0],
3119 1.0,
3120 LoopMode::Loop,
3121 &[frame],
3122 &[],
3123 33,
3124 0,
3125 );
3126 let decoded = clip.decode().expect("decode");
3127
3128 // Map only the glass colour → material 2; stone stays opaque (0).
3129 let m = sprite_model_from_clip_frame_with_materials(&decoded, 0, &[(Rgb(0x00AA_BBCC), 2)]);
3130 assert_eq!(
3131 m.materials.len(),
3132 m.colors.len(),
3133 "materials parallel to colors"
3134 );
3135 // `colors` is in popcount-rank (ascending z) order: stone then glass.
3136 assert_eq!(
3137 m.materials,
3138 vec![0u8, 2u8],
3139 "stone opaque, glass material 2"
3140 );
3141
3142 // Empty map ⇒ no per-voxel materials, identical to the plain builder.
3143 let plain = sprite_model_from_clip_frame(&decoded, 0);
3144 let plain_mat = sprite_model_from_clip_frame_with_materials(&decoded, 0, &[]);
3145 assert!(plain.materials.is_empty());
3146 assert!(plain_mat.materials.is_empty());
3147 assert_eq!(plain.colors, plain_mat.colors);
3148 }
3149
3150 /// TV.3 (streaming-clip refresh path): `build_sprite_model_with_materials`
3151 /// — the builder behind `GpuBackend::update_sprite_model_with_materials`,
3152 /// which a streaming clip re-runs each frame — classifies a kv6's voxels
3153 /// into a per-voxel `materials` array (popcount-rank order) by colour.
3154 #[test]
3155 fn build_with_materials_classifies_by_color() {
3156 let glass = 0x80AA_BBCC;
3157 let stone = 0x8011_2233;
3158 // One column (x=0,y=0), two voxels: z=0 stone, z=1 glass.
3159 let kv6 = kv6_from(1, 1, 4, &[(0, 0, 0, stone), (0, 0, 1, glass)]);
3160
3161 let m = build_sprite_model_with_materials(&kv6, &[(Rgb(0x00AA_BBCC), 2)]);
3162 assert_eq!(
3163 m.materials.len(),
3164 m.colors.len(),
3165 "materials parallel to colors"
3166 );
3167 assert_eq!(
3168 m.materials,
3169 vec![0u8, 2u8],
3170 "stone opaque, glass material 2"
3171 );
3172
3173 // Empty map ⇒ no per-voxel materials, identical to `build_sprite_model`.
3174 let plain = build_sprite_model(&kv6);
3175 let plain_mat = build_sprite_model_with_materials(&kv6, &[]);
3176 assert!(plain.materials.is_empty());
3177 assert!(plain_mat.materials.is_empty());
3178 assert_eq!(plain.colors, plain_mat.colors);
3179 }
3180
3181 /// flipping `chain_id` redirects the rendered instance to the new
3182 /// frame's resident volume.
3183 #[test]
3184 fn voxel_clip_flipbook_set_instance_model() {
3185 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
3186 let Some(h) = headless() else { return };
3187
3188 // Two distinct frames of a 1×1×4 clip: frame 0 has a voxel at z=0;
3189 // frame 1 adds z=1 — different occupancy + a longer colour run.
3190 let dims = [1u32, 1, 4];
3191 let owpc = dims[2].div_ceil(32).max(1) as usize; // 1
3192 let mk_frame = |zs: &[u32], cols: &[u32]| -> VoxelFrame {
3193 let mut occ = vec![0u32; owpc];
3194 for &z in zs {
3195 occ[(z >> 5) as usize] |= 1u32 << (z & 31);
3196 }
3197 VoxelFrame {
3198 occupancy: occ,
3199 colors: cols.to_vec(),
3200 color_offsets: vec![0, cols.len() as u32],
3201 }
3202 };
3203 let f0 = mk_frame(&[0], &[0x8011_2233]);
3204 let f1 = mk_frame(&[0, 1], &[0x8011_2233, 0x80AA_BBCC]);
3205 let clip = VoxelClip::from_frames(
3206 dims,
3207 [0.5, 0.5, 2.0],
3208 1.0,
3209 LoopMode::Loop,
3210 &[f0, f1],
3211 &[],
3212 33,
3213 0,
3214 );
3215 let decoded = clip.decode().expect("decode");
3216
3217 // Each frame → a single-level chain; both volumes resident + distinct.
3218 let mut reg = SpriteModelRegistry::new();
3219 let c0 = reg.add(sprite_model_from_clip_frame(&decoded, 0));
3220 let c1 = reg.add(sprite_model_from_clip_frame(&decoded, 1));
3221 assert_eq!(reg.model(c0).colors.len(), 1);
3222 assert_eq!(reg.model(c1).colors.len(), 2);
3223
3224 // One instance, in front of the test frustum, drawing frame 0.
3225 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(c0, [0.0, 0.0, 5.0])]);
3226 assert_eq!(res.cull[0].chain_id, c0);
3227
3228 // Flip to frame 1: the cull now draws chain c1 (radius reseeded).
3229 res.set_instance_model(®, 0, c1);
3230 assert_eq!(res.cull[0].chain_id, c1);
3231 assert_eq!(res.cull[0].radius, reg.model(c1).bound_radius());
3232
3233 // The next cull packs the new chain into the GPU instance buffer
3234 // (visible, no panic).
3235 let f = test_frustum();
3236 let (visible, _, _) = res.cull_bin_upload(&h.device, &h.queue, &f, 64, 64, 16, 1.0);
3237 assert_eq!(visible, 1);
3238
3239 // …and back to frame 0.
3240 res.set_instance_model(®, 0, c0);
3241 assert_eq!(res.cull[0].chain_id, c0);
3242
3243 // Out-of-range index is a safe no-op.
3244 res.set_instance_model(®, 99, c1);
3245 assert_eq!(res.cull[0].chain_id, c0);
3246 }
3247
3248 fn test_frustum() -> ViewFrustum {
3249 ViewFrustum {
3250 pos: [0.0, 0.0, 0.0],
3251 right: [1.0, 0.0, 0.0],
3252 down: [0.0, 1.0, 0.0],
3253 forward: [0.0, 0.0, 1.0],
3254 half_w: 1.0,
3255 half_h: 1.0,
3256 far: 10_000.0,
3257 }
3258 }
3259
3260 #[test]
3261 fn remove_model_tombstones_frees_and_reuses() {
3262 let Some(h) = headless() else { return };
3263 // Residency with models A and B, one instance each.
3264 let mut reg = SpriteModelRegistry::new();
3265 let a = reg.add(build_sprite_model(&kv6_unsorted()));
3266 let b = reg.add(build_sprite_model(&kv6_other()));
3267 let mut res = SpriteRegistryResident::upload(
3268 &h.device,
3269 ®,
3270 &[inst(a, [0.0; 3]), inst(b, [1.0, 0.0, 0.0])],
3271 );
3272 assert_eq!(res.live_model_count(), 2);
3273 assert_eq!(res.dead_model_count(), 0);
3274
3275 // Remove B → tombstoned, its colours freed into the pool.
3276 res.remove_model(b);
3277 assert_eq!(res.live_model_count(), 1);
3278 assert_eq!(res.dead_model_count(), 1);
3279 assert_eq!(res.dead.iter().filter(|&&d| d).count(), 1, "one entry dead");
3280 assert!(!res.colors_alloc.free.is_empty(), "B's colour slot freed");
3281
3282 // Adding C reuses the freed slot (free-list first-fit).
3283 let c = reg.add(build_sprite_model(&kv6_other()));
3284 res.add_model(&h.device, &h.queue, ®, c);
3285 assert_eq!(res.live_model_count(), 2);
3286
3287 // A and C read back correctly; B is dead (skipped).
3288 let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3289 for e in [a as usize, c as usize] {
3290 let m = ®.entries[e];
3291 let cc = res.meta[e].colors_offset as usize;
3292 assert_eq!(
3293 &cols[cc..cc + m.colors.len()],
3294 &m.colors[..],
3295 "colors entry {e}"
3296 );
3297 }
3298
3299 // The lingering instance of removed B is skipped without panic.
3300 let f = test_frustum();
3301 let _ = res.cull_bin_upload(&h.device, &h.queue, &f, 64, 64, 16, 1.0);
3302 }
3303
3304 #[test]
3305 fn compact_reclaims_holes_keeps_ids_stable() {
3306 let Some(h) = headless() else { return };
3307 let mut reg = SpriteModelRegistry::new();
3308 let a = reg.add(build_sprite_model(&kv6_unsorted()));
3309 let b = reg.add(build_sprite_model(&kv6_other()));
3310 let c = reg.add(build_sprite_model(&kv6_other()));
3311 let mut res = SpriteRegistryResident::upload(
3312 &h.device,
3313 ®,
3314 &[inst(a, [0.0; 3]), inst(b, [1.0; 3]), inst(c, [2.0; 3])],
3315 );
3316 let occ_used_full = res.occ_used;
3317
3318 // Remove the middle model, then compact.
3319 res.remove_model(b);
3320 res.compact(&h.device, &h.queue, ®);
3321
3322 // Holes reclaimed: occupancy now only covers A + C.
3323 let live_occ: u32 = [a, c]
3324 .iter()
3325 .map(|&e| reg.entries[e as usize].occupancy.len() as u32)
3326 .sum();
3327 assert_eq!(res.occ_used, live_occ);
3328 assert!(res.occ_used < occ_used_full, "compaction shrank occupancy");
3329 // Dead entry keeps a zeroed tombstone; ids unchanged.
3330 assert_eq!(res.meta[b as usize].occupancy_offset, 0);
3331 assert_eq!(res.live_model_count(), 2);
3332 assert_eq!(res.dead_model_count(), 1);
3333
3334 // Live entries read back correctly at their new offsets.
3335 let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3336 let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3337 for &e in &[a as usize, c as usize] {
3338 let m = ®.entries[e];
3339 let oo = res.meta[e].occupancy_offset as usize;
3340 assert_eq!(
3341 &occ[oo..oo + m.occupancy.len()],
3342 &m.occupancy[..],
3343 "occ {e}"
3344 );
3345 let cc = res.meta[e].colors_offset as usize;
3346 assert_eq!(&cols[cc..cc + m.colors.len()], &m.colors[..], "cols {e}");
3347 }
3348
3349 // Chain ids still valid: C's chain still resolves; B's is empty.
3350 assert!(!res.chains[c as usize].is_empty());
3351 assert!(res.chains[b as usize].is_empty());
3352 }
3353
3354 #[test]
3355 fn remove_swap_semantics_and_capacity_retained() {
3356 let Some(h) = headless() else { return };
3357 let (reg, m) = one_model_registry();
3358 let seed: Vec<_> = (0..4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
3359 let mut res = SpriteRegistryResident::upload(&h.device, ®, &seed);
3360 assert_eq!(res.instance_count(), 4);
3361 let cap = res.instance_capacity;
3362
3363 // Remove a middle element → the previous last (idx 3) moved into it.
3364 assert_eq!(res.remove_instance(1), Some(3));
3365 assert_eq!(res.instance_count(), 3);
3366
3367 // Remove the current last (idx 2) → nothing moved.
3368 assert_eq!(res.remove_instance(2), None);
3369 assert_eq!(res.instance_count(), 2);
3370
3371 // Out of range → None.
3372 assert_eq!(res.remove_instance(99), None);
3373 assert_eq!(res.instance_count(), 2);
3374
3375 // Capacity is retained for reuse (no shrink).
3376 assert_eq!(res.instance_capacity, cap);
3377 }
3378}