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 let new_cap = grow_words(used);
1708 let all: Vec<u32> = registry
1709 .entries
1710 .iter()
1711 .flat_map(|m| concat_data(m, which).iter().copied())
1712 .collect();
1713 let label = match which {
1714 ConcatBuf::Occupancy => "roxlap-gpu sprite_reg.occupancy",
1715 ConcatBuf::ColorOffsets => "roxlap-gpu sprite_reg.color_offsets",
1716 };
1717 let buf = storage_dst_u32_cap(device, label, &all, new_cap);
1718 match which {
1719 ConcatBuf::Occupancy => {
1720 self.occupancy = buf;
1721 self.occ_cap = new_cap;
1722 }
1723 ConcatBuf::ColorOffsets => {
1724 self.color_offsets = buf;
1725 self.coloff_cap = new_cap;
1726 }
1727 }
1728 } else {
1729 let target = match which {
1730 ConcatBuf::Occupancy => &self.occupancy,
1731 ConcatBuf::ColorOffsets => &self.color_offsets,
1732 };
1733 for &e in new_entries {
1734 let e = e as usize;
1735 let off = match which {
1736 ConcatBuf::Occupancy => self.meta[e].occupancy_offset,
1737 ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset,
1738 };
1739 queue.write_buffer(
1740 target,
1741 u64::from(off) * 4,
1742 bytemuck::cast_slice(concat_data(®istry.entries[e], which)),
1743 );
1744 }
1745 }
1746 }
1747
1748 /// Number of removed-but-not-yet-compacted models (tombstoned chains).
1749 /// A caller streams `add_model` / `remove_model` and calls
1750 /// [`Self::compact`] once this (relative to [`Self::live_model_count`])
1751 /// crosses a threshold.
1752 #[must_use]
1753 pub fn dead_model_count(&self) -> usize {
1754 self.chains.iter().filter(|c| c.is_empty()).count()
1755 }
1756
1757 /// Number of live (non-removed) models.
1758 #[must_use]
1759 pub fn live_model_count(&self) -> usize {
1760 self.chains.iter().filter(|c| !c.is_empty()).count()
1761 }
1762
1763 /// Remove a model (tombstone its LOD chain) — the counterpart to
1764 /// [`Self::add_model`]. O(chain length): marks the chain's entries
1765 /// dead and frees their `colors`/`dirs` slots for reuse by a later
1766 /// `add_model`. The `occupancy` / `color_offsets` holes are **not**
1767 /// reclaimed until [`Self::compact`]; entry ids (and the caller's other
1768 /// `chain_id`s) stay stable.
1769 ///
1770 /// Instances of the removed chain are **not** dropped here — they
1771 /// linger in the cull set but draw as nothing (skipped in
1772 /// [`Self::cull_bin_upload`]); the caller removes them via
1773 /// [`Self::remove_instance`] when convenient. A no-op if `chain_id` is
1774 /// out of range or already removed.
1775 pub fn remove_model(&mut self, chain_id: u32) {
1776 let Some(entries) = self.chains.get(chain_id as usize).cloned() else {
1777 return;
1778 };
1779 if entries.is_empty() {
1780 return; // already removed
1781 }
1782 self.last_cull = None; // PF.10 — tombstone changes visibility
1783 for &e in &entries {
1784 let e = e as usize;
1785 self.dead[e] = true;
1786 self.colors_alloc.free(e);
1787 }
1788 self.chains[chain_id as usize] = Vec::new(); // tombstone
1789 }
1790
1791 /// Reclaim the holes left by [`Self::remove_model`]: rebuild the shared
1792 /// volume buffers from the live entries only, dropping every dead
1793 /// entry's data. Entry ids and `chain_id`s are preserved (dead entries
1794 /// keep a zero-length `meta` tombstone), so the caller's handles stay
1795 /// valid and no remap is needed.
1796 ///
1797 /// `registry` must be the resident one (entry ids 1:1, as for
1798 /// [`Self::add_model`] / [`Self::update_model`]). O(live volume) —
1799 /// call it when [`Self::dead_model_count`] is high, not every frame.
1800 pub fn compact(
1801 &mut self,
1802 device: &wgpu::Device,
1803 queue: &wgpu::Queue,
1804 registry: &SpriteModelRegistry,
1805 ) {
1806 self.last_cull = None; // PF.10 — entry ids / chains renumbered
1807 // occupancy + color_offsets: re-pack live entries tightly, rewrite
1808 // each live entry's meta offset, zero the dead ones.
1809 self.compact_concat(device, registry, ConcatBuf::Occupancy);
1810 self.compact_concat(device, registry, ConcatBuf::ColorOffsets);
1811 // colors/dirs: the dead-aware repack already drops dead entries.
1812 self.repack_colors_dirs(device, registry);
1813 // model_meta: rewrite the (unchanged-length) table with the new
1814 // offsets. Buffer count didn't change, so no grow needed.
1815 queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
1816 }
1817
1818 /// Rebuild one tightly-concatenated buffer from live entries only
1819 /// (used by [`Self::compact`]): assign each live entry a fresh tight
1820 /// offset, zero dead entries' offset, and recreate the buffer with
1821 /// slack.
1822 fn compact_concat(
1823 &mut self,
1824 device: &wgpu::Device,
1825 registry: &SpriteModelRegistry,
1826 which: ConcatBuf,
1827 ) {
1828 let mut all: Vec<u32> = Vec::new();
1829 for e in 0..self.meta.len() {
1830 if self.dead[e] {
1831 match which {
1832 ConcatBuf::Occupancy => self.meta[e].occupancy_offset = 0,
1833 ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = 0,
1834 }
1835 continue;
1836 }
1837 let off = all.len() as u32;
1838 match which {
1839 ConcatBuf::Occupancy => self.meta[e].occupancy_offset = off,
1840 ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = off,
1841 }
1842 all.extend_from_slice(concat_data(®istry.entries[e], which));
1843 }
1844 let used = all.len() as u32;
1845 let cap = grow_words(used);
1846 let (label, buf) = match which {
1847 ConcatBuf::Occupancy => ("roxlap-gpu sprite_reg.occupancy", &mut self.occupancy),
1848 ConcatBuf::ColorOffsets => (
1849 "roxlap-gpu sprite_reg.color_offsets",
1850 &mut self.color_offsets,
1851 ),
1852 };
1853 *buf = storage_dst_u32_cap(device, label, &all, cap);
1854 match which {
1855 ConcatBuf::Occupancy => {
1856 self.occ_used = used;
1857 self.occ_cap = cap;
1858 }
1859 ConcatBuf::ColorOffsets => {
1860 self.coloff_used = used;
1861 self.coloff_cap = cap;
1862 }
1863 }
1864 }
1865
1866 /// GPU.10.3 — frustum-cull, pack the visible subset into the
1867 /// instance buffer, then bin those instances into screen tiles:
1868 /// project each visible bounding sphere to a screen AABB and append
1869 /// its (visible) index to every overlapped tile. Uploads the
1870 /// instance buffer + `tile_ranges` (per-tile offset/count) +
1871 /// `tile_instances` (flat grouped indices), growing the tile
1872 /// buffers as needed. Returns `(visible_count, tiles_x, tiles_y)`.
1873 #[allow(clippy::too_many_arguments)]
1874 pub fn cull_bin_upload(
1875 &mut self,
1876 device: &wgpu::Device,
1877 queue: &wgpu::Queue,
1878 f: &ViewFrustum,
1879 screen_w: u32,
1880 screen_h: u32,
1881 tile_size: u32,
1882 lod_px: f32,
1883 ) -> (u32, u32, u32) {
1884 let tiles_x = screen_w.div_ceil(tile_size).max(1);
1885 let tiles_y = screen_h.div_ceil(tile_size).max(1);
1886 let n_tiles = (tiles_x * tiles_y) as usize;
1887
1888 // PF.10 — nothing changed since the last cull (same registry
1889 // state, same view, same screen): the four buffers already hold
1890 // exactly this frame's data — skip the whole cull/bin/upload.
1891 let key = CullKey::new(f, screen_w, screen_h, tile_size, lod_px);
1892 if let Some((k, res)) = self.last_cull {
1893 if k == key {
1894 return res;
1895 }
1896 }
1897
1898 let nw = (1.0 + f.half_w * f.half_w).sqrt();
1899 let nh = (1.0 + f.half_h * f.half_h).sqrt();
1900 let cx = screen_w as f32 * 0.5;
1901 let cy = screen_h as f32 * 0.5;
1902 let px_per_world = cx / f.half_w; // isotropic: == cy/half_h
1903 let ts = tile_size as f32;
1904 let tx_max = tiles_x as i32 - 1;
1905 let ty_max = tiles_y as i32 - 1;
1906
1907 // PF.10 — reused workspace (was 6+ fresh Vecs per frame).
1908 let scratch = &mut self.scratch;
1909 let visible = &mut scratch.visible;
1910 visible.clear();
1911 // Per-visible tile AABB (tx0, tx1, ty0, ty1) for the bin pass.
1912 let boxes = &mut scratch.boxes;
1913 boxes.clear();
1914 // Per-visible kv6colmul tables, flattened to two u32 per u64
1915 // entry (lanes 0|1, then 2|3), packed in visible order so the
1916 // shader indexes `colmul[inst_idx*512 + dir*2 + {0,1}]`. PF.10 —
1917 // built ONLY once a non-identity table exists (`any_colmul`);
1918 // until then the buffer holds a lazily-written identity fill and
1919 // the ~2 KiB-per-visible-instance rebuild + upload is skipped.
1920 let visible_colmul = &mut scratch.colmul;
1921 visible_colmul.clear();
1922 let counts = &mut scratch.counts;
1923 counts.clear();
1924 counts.resize(n_tiles, 0u32);
1925 let pack_colmul = self.any_colmul;
1926
1927 for ci in &self.cull {
1928 // Skip instances of a removed model (tombstoned chain) — they
1929 // linger in `cull` until the caller drops them, but draw as
1930 // nothing.
1931 if self.chains[ci.chain_id as usize].is_empty() {
1932 continue;
1933 }
1934 let rel = [
1935 ci.center[0] - f.pos[0],
1936 ci.center[1] - f.pos[1],
1937 ci.center[2] - f.pos[2],
1938 ];
1939 let z = dot3(rel, f.forward);
1940 let r = ci.radius;
1941 if z + r < 0.0 || z - r > f.far {
1942 continue; // behind / beyond far
1943 }
1944 let x = dot3(rel, f.right);
1945 if (x - f.half_w * z) > r * nw || (-x - f.half_w * z) > r * nw {
1946 continue; // right / left
1947 }
1948 let y = dot3(rel, f.down);
1949 if (y - f.half_h * z) > r * nh || (-y - f.half_h * z) > r * nh {
1950 continue; // bottom / top
1951 }
1952
1953 // Visible: project the sphere to a screen AABB → tile range.
1954 let (tx0, tx1, ty0, ty1) = if z > 1e-3 {
1955 let sx = cx + (x / z) * px_per_world;
1956 let sy = cy + (y / z) * px_per_world;
1957 let sr = (r / z) * px_per_world;
1958 (
1959 (((sx - sr) / ts).floor() as i32).clamp(0, tx_max),
1960 (((sx + sr) / ts).floor() as i32).clamp(0, tx_max),
1961 (((sy - sr) / ts).floor() as i32).clamp(0, ty_max),
1962 (((sy + sr) / ts).floor() as i32).clamp(0, ty_max),
1963 )
1964 } else {
1965 (0, tx_max, 0, ty_max)
1966 };
1967 // GPU.10.4 — pick the LOD level by projected voxel size:
1968 // choose the coarsest level whose voxel still covers at
1969 // least `lod_px` screen pixels, i.e. step up once a mip-0
1970 // voxel would be smaller than that. `lod_px = 1` is the
1971 // natural "don't go sub-pixel" threshold; larger values
1972 // force LOD in closer (tuning/inspection).
1973 let chain = &self.chains[ci.chain_id as usize];
1974 let level = if z > 1e-3 && chain.len() > 1 {
1975 // Mip-0 voxel screen size; a scaled instance's voxels
1976 // are `max_scale`× larger in world, so it holds the
1977 // fine mip proportionally longer (PS.1).
1978 let voxel_px = px_per_world * ci.max_scale / z;
1979 ((lod_px / voxel_px).log2().ceil().max(0.0) as usize).min(chain.len() - 1)
1980 } else {
1981 0
1982 };
1983 let mut g = ci.gpu;
1984 g.model_id = chain[level];
1985 visible.push(g);
1986 boxes.push([tx0, tx1, ty0, ty1]);
1987 if pack_colmul {
1988 for &w in ci.colmul.iter() {
1989 visible_colmul.push((w & 0xffff_ffff) as u32);
1990 visible_colmul.push((w >> 32) as u32);
1991 }
1992 }
1993 for ty in ty0..=ty1 {
1994 for tx in tx0..=tx1 {
1995 counts[(ty * tiles_x as i32 + tx) as usize] += 1;
1996 }
1997 }
1998 }
1999
2000 if visible.is_empty() {
2001 let res = (0, tiles_x, tiles_y);
2002 self.last_cull = Some((key, res));
2003 return res;
2004 }
2005
2006 // Prefix-sum counts → per-tile offsets; build the flat grouped
2007 // index list.
2008 let tile_ranges = &mut scratch.tile_ranges;
2009 tile_ranges.clear();
2010 tile_ranges.resize(n_tiles * 2, 0u32);
2011 let mut running = 0u32;
2012 for t in 0..n_tiles {
2013 tile_ranges[2 * t] = running; // offset
2014 tile_ranges[2 * t + 1] = counts[t]; // count
2015 running += counts[t];
2016 }
2017 let total = running as usize;
2018 let tile_instances = &mut scratch.tile_instances;
2019 tile_instances.clear();
2020 tile_instances.resize(total.max(1), 0u32);
2021 let cursor = &mut scratch.cursor;
2022 cursor.clear();
2023 cursor.extend((0..n_tiles).map(|t| tile_ranges[2 * t]));
2024 for (vis_idx, b) in boxes.iter().enumerate() {
2025 for ty in b[2]..=b[3] {
2026 for tx in b[0]..=b[1] {
2027 let t = (ty * tiles_x as i32 + tx) as usize;
2028 tile_instances[cursor[t] as usize] = vis_idx as u32;
2029 cursor[t] += 1;
2030 }
2031 }
2032 }
2033
2034 // Upload: instances + (grown) tile buffers. Grow a tile buffer
2035 // only when this frame needs more than its capacity (wgpu has
2036 // no Clone on Buffer, so we replace the field in place).
2037 queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(visible));
2038 let need_ranges = tile_ranges.len() as u32;
2039 if need_ranges > self.tile_ranges_cap {
2040 self.tile_ranges_cap = need_ranges.next_power_of_two();
2041 self.tile_ranges = storage_dst_u32(
2042 device,
2043 "roxlap-gpu sprite_reg.tile_ranges",
2044 self.tile_ranges_cap,
2045 );
2046 }
2047 let need_inst = tile_instances.len() as u32;
2048 if need_inst > self.tile_instances_cap {
2049 self.tile_instances_cap = need_inst.next_power_of_two();
2050 self.tile_instances = storage_dst_u32(
2051 device,
2052 "roxlap-gpu sprite_reg.tile_instances",
2053 self.tile_instances_cap,
2054 );
2055 }
2056 queue.write_buffer(&self.tile_ranges, 0, bytemuck::cast_slice(tile_ranges));
2057 queue.write_buffer(
2058 &self.tile_instances,
2059 0,
2060 bytemuck::cast_slice(tile_instances),
2061 );
2062 if pack_colmul {
2063 let need_colmul = visible_colmul.len() as u32;
2064 if need_colmul > self.colmul_cap {
2065 self.colmul_cap = need_colmul.next_power_of_two();
2066 self.colmul =
2067 storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", self.colmul_cap);
2068 self.colmul_identity = false;
2069 }
2070 queue.write_buffer(&self.colmul, 0, bytemuck::cast_slice(visible_colmul));
2071 } else {
2072 // PF.10 — identity fast path: every table is identity, so the
2073 // buffer content is a constant repeating pattern. (Re)fill it
2074 // only on first use / growth; per-frame upload skipped.
2075 let need_colmul = visible.len() as u32 * 512;
2076 if need_colmul > self.colmul_cap {
2077 self.colmul_cap = need_colmul.next_power_of_two();
2078 self.colmul =
2079 storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", self.colmul_cap);
2080 self.colmul_identity = false;
2081 }
2082 if !self.colmul_identity {
2083 let w = identity_colmul()[0];
2084 let (lo, hi) = ((w & 0xffff_ffff) as u32, (w >> 32) as u32);
2085 let fill: Vec<u32> = (0..self.colmul_cap)
2086 .map(|i| if i & 1 == 0 { lo } else { hi })
2087 .collect();
2088 queue.write_buffer(&self.colmul, 0, bytemuck::cast_slice(&fill));
2089 self.colmul_identity = true;
2090 }
2091 }
2092
2093 let res = (visible.len() as u32, tiles_x, tiles_y);
2094 self.last_cull = Some((key, res));
2095 res
2096 }
2097}
2098
2099/// GPU.12 incremental — per-entry placement of one model's `colors`
2100/// (and the parallel `dirs`) within the shared registry buffers: a
2101/// `[off, off+cap)` word window holding `len` live words. `cap >= len`
2102/// gives slack so a carve that *grows* the surface-voxel count can be
2103/// rewritten in place without relocating.
2104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2105struct ColorSlot {
2106 off: u32,
2107 cap: u32,
2108 len: u32,
2109}
2110
2111/// First-fit suballocator over the parallel `colors`/`dirs` buffers
2112/// (same offsets/ranks → one allocator drives both). Each registry
2113/// entry owns a [`ColorSlot`]; growth past a slot's `cap` relocates it
2114/// (freeing the old block) via the free list or a bump tail, and only
2115/// when the tail would exceed `cap_total` does the caller grow + repack
2116/// the whole buffer. Pure (no GPU) so it unit-tests on its own.
2117#[derive(Debug, Default)]
2118struct ColorsAllocator {
2119 /// Per-entry slot, indexed by entry id.
2120 slots: Vec<ColorSlot>,
2121 /// Freed `(off, cap)` blocks available for first-fit reuse.
2122 free: Vec<(u32, u32)>,
2123 /// Next bump-allocation position (words).
2124 tail: u32,
2125 /// Total buffer capacity in words.
2126 cap_total: u32,
2127}
2128
2129/// Slack-padded capacity for a `len`-word array: +25% + 16 words, so a
2130/// few extra surface voxels from a carve fit without relocating.
2131fn slot_cap(len: u32) -> u32 {
2132 len + len / 4 + 16
2133}
2134
2135/// Slack capacity (words) for a grown concatenated buffer: +50% + 256, so
2136/// a burst of `add_model` calls bump-appends rather than re-growing every
2137/// time. Matches [`ColorsAllocator`]'s `cap_total` headroom.
2138fn grow_words(used: u32) -> u32 {
2139 used + used / 2 + 256
2140}
2141
2142/// Slack capacity (records) for a grown `model_meta` buffer: +50% + 8.
2143fn grow_records(count: u32) -> u32 {
2144 count + count / 2 + 8
2145}
2146
2147impl ColorsAllocator {
2148 /// Lay every entry out contiguously (with per-slot slack) and add a
2149 /// global tail headroom so early growth bump-allocates rather than
2150 /// repacks.
2151 fn new(entry_lens: &[u32]) -> Self {
2152 let mut a = Self::default();
2153 a.repack(entry_lens);
2154 a
2155 }
2156
2157 fn slot(&self, entry: usize) -> ColorSlot {
2158 self.slots[entry]
2159 }
2160
2161 fn cap_total(&self) -> u32 {
2162 self.cap_total
2163 }
2164
2165 /// Repack ALL entries compactly to fit `new_lens`, resetting the
2166 /// free list + tail and choosing a fresh `cap_total` with headroom.
2167 /// Used at initial build and on a buffer grow.
2168 fn repack(&mut self, new_lens: &[u32]) {
2169 self.free.clear();
2170 let mut off = 0u32;
2171 let mut slots = Vec::with_capacity(new_lens.len());
2172 for &len in new_lens {
2173 // A 0-length (dead / removed) entry takes no space — keeps a
2174 // tombstone slot so entry ids stay positional.
2175 let cap = if len == 0 { 0 } else { slot_cap(len) };
2176 slots.push(ColorSlot { off, cap, len });
2177 off += cap;
2178 }
2179 self.slots = slots;
2180 self.tail = off;
2181 // Global headroom: +50% + 256 words.
2182 self.cap_total = off + off / 2 + 256;
2183 }
2184
2185 /// Place `new_len` words for `entry`. Returns `Some(off)` with the
2186 /// (possibly relocated) slot offset, or `None` if the buffer must
2187 /// grow + repack. On relocation the old block is pushed to the free
2188 /// list; an in-place fit returns the unchanged offset.
2189 fn place(&mut self, entry: usize, new_len: u32) -> Option<u32> {
2190 let cur = self.slots[entry];
2191 if new_len <= cur.cap {
2192 self.slots[entry] = ColorSlot {
2193 len: new_len,
2194 ..cur
2195 };
2196 return Some(cur.off);
2197 }
2198 let old = (cur.off, cur.cap);
2199 // First-fit a freed block big enough for the live data.
2200 if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
2201 let (off, cap) = self.free.remove(i);
2202 self.free.push(old);
2203 self.slots[entry] = ColorSlot {
2204 off,
2205 cap,
2206 len: new_len,
2207 };
2208 return Some(off);
2209 }
2210 // Bump the tail if there's room.
2211 let want = slot_cap(new_len);
2212 if self.tail + want <= self.cap_total {
2213 let off = self.tail;
2214 self.tail += want;
2215 self.free.push(old);
2216 self.slots[entry] = ColorSlot {
2217 off,
2218 cap: want,
2219 len: new_len,
2220 };
2221 return Some(off);
2222 }
2223 None
2224 }
2225
2226 /// Append a slot for a brand-new entry of `new_len` words (used by
2227 /// [`SpriteRegistryResident::add_model`]). Returns `Some(off)` placed
2228 /// via the free list or the bump tail, or `None` if the buffer must
2229 /// grow + repack — in which case **no** slot is pushed (the caller's
2230 /// repack rebuilds every slot from scratch).
2231 fn push(&mut self, new_len: u32) -> Option<u32> {
2232 if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
2233 let (off, cap) = self.free.remove(i);
2234 self.slots.push(ColorSlot {
2235 off,
2236 cap,
2237 len: new_len,
2238 });
2239 return Some(off);
2240 }
2241 let want = slot_cap(new_len);
2242 if self.tail + want <= self.cap_total {
2243 let off = self.tail;
2244 self.tail += want;
2245 self.slots.push(ColorSlot {
2246 off,
2247 cap: want,
2248 len: new_len,
2249 });
2250 return Some(off);
2251 }
2252 None
2253 }
2254
2255 /// Free `entry`'s slot back to the pool ([`SpriteRegistryResident::
2256 /// remove_model`]). Its `(off, cap)` block joins the free list for
2257 /// first-fit reuse by a later [`Self::push`]; the slot is zeroed so a
2258 /// repack treats it as a 0-length tombstone.
2259 fn free(&mut self, entry: usize) {
2260 let s = self.slots[entry];
2261 if s.cap > 0 {
2262 self.free.push((s.off, s.cap));
2263 }
2264 self.slots[entry] = ColorSlot {
2265 off: 0,
2266 cap: 0,
2267 len: 0,
2268 };
2269 }
2270}
2271
2272/// Create a STORAGE buffer of u32s; pads empty input (wgpu rejects
2273/// zero-sized storage bindings).
2274#[allow(dead_code)]
2275fn storage_u32(device: &wgpu::Device, label: &str, data: &[u32]) -> wgpu::Buffer {
2276 use wgpu::util::DeviceExt;
2277 let bytes: &[u8] = if data.is_empty() {
2278 bytemuck::cast_slice(&[0u32])
2279 } else {
2280 bytemuck::cast_slice(data)
2281 };
2282 device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2283 label: Some(label),
2284 contents: bytes,
2285 usage: wgpu::BufferUsages::STORAGE,
2286 })
2287}
2288
2289/// Create an uninitialised `STORAGE | COPY_DST` `u32` buffer of `cap`
2290/// words (≥1). Written each frame via `queue.write_buffer`.
2291fn storage_dst_u32(device: &wgpu::Device, label: &str, cap: u32) -> wgpu::Buffer {
2292 device.create_buffer(&wgpu::BufferDescriptor {
2293 label: Some(label),
2294 size: u64::from(cap.max(1)) * 4,
2295 // COPY_SRC so test/debug harnesses can read the contents back
2296 // (PF.10's cull gate does); free at runtime.
2297 usage: wgpu::BufferUsages::STORAGE
2298 | wgpu::BufferUsages::COPY_DST
2299 | wgpu::BufferUsages::COPY_SRC,
2300 mapped_at_creation: false,
2301 })
2302}
2303
2304/// Create a `STORAGE | COPY_DST` `u32` buffer of `cap` words (≥ data
2305/// length, ≥ 1), initialised with `data` at offset 0 and the tail left
2306/// zeroed. Unlike [`storage_u32`] (STORAGE-only, exact-size) this both
2307/// reserves spare capacity and is `COPY_DST`, so the incremental
2308/// [`SpriteRegistryResident::update_model`] can `write_buffer` a growing
2309/// `colors`/`dirs` array in place. Filled via `mapped_at_creation` so no
2310/// queue is needed at upload time.
2311fn storage_dst_u32_cap(device: &wgpu::Device, label: &str, data: &[u32], cap: u32) -> wgpu::Buffer {
2312 let cap = cap.max(data.len() as u32).max(1);
2313 let buf = device.create_buffer(&wgpu::BufferDescriptor {
2314 label: Some(label),
2315 size: u64::from(cap) * 4,
2316 usage: wgpu::BufferUsages::STORAGE
2317 | wgpu::BufferUsages::COPY_DST
2318 | wgpu::BufferUsages::COPY_SRC,
2319 mapped_at_creation: true,
2320 });
2321 if !data.is_empty() {
2322 buf.slice(..(data.len() as u64 * 4))
2323 .get_mapped_range_mut()
2324 .copy_from_slice(bytemuck::cast_slice(data));
2325 }
2326 buf.unmap();
2327 buf
2328}
2329
2330/// Create a `STORAGE | COPY_DST` buffer of Pod records, exact-size
2331/// (≥ 1, zero-padded), so individual records can be rewritten in place
2332/// by [`SpriteRegistryResident::update_model`] on a relocation. The
2333/// record *count* never changes on an incremental edit (no model is
2334/// added/removed), so no slack is needed here.
2335fn storage_dst_pod<T: Pod + Zeroable>(
2336 device: &wgpu::Device,
2337 label: &str,
2338 data: &[T],
2339) -> wgpu::Buffer {
2340 let one = [T::zeroed()];
2341 let src: &[T] = if data.is_empty() { &one } else { data };
2342 let buf = device.create_buffer(&wgpu::BufferDescriptor {
2343 label: Some(label),
2344 size: std::mem::size_of_val(src) as u64,
2345 usage: wgpu::BufferUsages::STORAGE
2346 | wgpu::BufferUsages::COPY_DST
2347 | wgpu::BufferUsages::COPY_SRC,
2348 mapped_at_creation: true,
2349 });
2350 buf.slice(..)
2351 .get_mapped_range_mut()
2352 .copy_from_slice(bytemuck::cast_slice(src));
2353 buf.unmap();
2354 buf
2355}
2356
2357/// Create a `STORAGE | COPY_DST` Pod buffer holding `cap` records
2358/// (≥ `data.len()`, ≥ 1), initialised with `data` at record 0 and the
2359/// tail zeroed. The slack lets [`SpriteRegistryResident::add_model`] grow
2360/// the `model_meta` table without re-growing on every add.
2361fn storage_dst_pod_cap<T: Pod + Zeroable>(
2362 device: &wgpu::Device,
2363 label: &str,
2364 data: &[T],
2365 cap: u32,
2366) -> wgpu::Buffer {
2367 let rec = std::mem::size_of::<T>() as u64;
2368 let cap = u64::from(cap.max(data.len() as u32).max(1));
2369 let buf = device.create_buffer(&wgpu::BufferDescriptor {
2370 label: Some(label),
2371 size: cap * rec,
2372 usage: wgpu::BufferUsages::STORAGE
2373 | wgpu::BufferUsages::COPY_DST
2374 | wgpu::BufferUsages::COPY_SRC,
2375 mapped_at_creation: true,
2376 });
2377 if !data.is_empty() {
2378 buf.slice(..(data.len() as u64 * rec))
2379 .get_mapped_range_mut()
2380 .copy_from_slice(bytemuck::cast_slice(data));
2381 }
2382 buf.unmap();
2383 buf
2384}
2385
2386/// Create a STORAGE buffer of Pod records; pads empty input with one
2387/// zeroed `T`.
2388#[allow(dead_code)]
2389fn storage_pod<T: Pod + Zeroable>(device: &wgpu::Device, label: &str, data: &[T]) -> wgpu::Buffer {
2390 use wgpu::util::DeviceExt;
2391 let one = [T::zeroed()];
2392 let src: &[T] = if data.is_empty() { &one } else { data };
2393 device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2394 label: Some(label),
2395 contents: bytemuck::cast_slice(src),
2396 usage: wgpu::BufferUsages::STORAGE,
2397 })
2398}
2399
2400#[cfg(test)]
2401mod tests {
2402 use super::*;
2403 use roxlap_formats::kv6::{Kv6, Voxel};
2404
2405 /// 2×1 kv6: column (0,0) has voxels at z=5 (red) and z=1 (green)
2406 /// stored OUT of z-order; column (1,0) has one voxel at z=3.
2407 fn kv6_unsorted() -> Kv6 {
2408 let mk = |z, col| Voxel {
2409 col,
2410 z,
2411 vis: 0,
2412 dir: 0,
2413 };
2414 Kv6 {
2415 xsiz: 2,
2416 ysiz: 1,
2417 zsiz: 8,
2418 xpiv: 0.0,
2419 ypiv: 0.0,
2420 zpiv: 0.0,
2421 voxels: vec![mk(5, 0xAA), mk(1, 0xBB), mk(3, 0xCC)],
2422 xlen: vec![2, 1],
2423 ylen: vec![vec![2], vec![1]],
2424 palette: None,
2425 }
2426 }
2427
2428 #[test]
2429 fn occupancy_bits_set_at_voxel_z() {
2430 let m = build_sprite_model(&kv6_unsorted());
2431 assert_eq!(m.dims, [2, 1, 8]);
2432 assert_eq!(m.occ_words_per_col, 1); // ceil(8/32)
2433 // col 0: bits 1 and 5; col 1: bit 3.
2434 assert_eq!(m.occupancy[0], (1 << 1) | (1 << 5));
2435 assert_eq!(m.occupancy[1], 1 << 3);
2436 }
2437
2438 #[test]
2439 fn colors_are_ascending_z_for_rank_lookup() {
2440 let m = build_sprite_model(&kv6_unsorted());
2441 // col 0 sorted ascending z ⇒ z=1 (green 0xBB) before z=5 (0xAA).
2442 assert_eq!(m.color_offsets, vec![0, 2, 3]);
2443 assert_eq!(&m.colors, &[0xBB, 0xAA, 0xCC]);
2444 }
2445
2446 #[test]
2447 fn identity_basis_inverts_to_identity() {
2448 let inv = mat3_inverse([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
2449 assert_eq!(inv, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
2450 }
2451
2452 #[test]
2453 fn fork_is_independent_of_parent() {
2454 let mut reg = SpriteModelRegistry::new();
2455 let base = reg.add(build_sprite_model(&kv6_unsorted()));
2456 let forked = reg.fork(base);
2457 assert_ne!(base, forked);
2458 // Recolour only the fork.
2459 reg.model_mut(forked).recolor(|_| 0x11);
2460 // Parent colours untouched; fork fully overwritten.
2461 assert_eq!(®.model(base).colors, &[0xBB, 0xAA, 0xCC]);
2462 assert_eq!(®.model(forked).colors, &[0x11, 0x11, 0x11]);
2463 }
2464
2465 #[test]
2466 fn remove_frees_chain_data_keeps_ids_stable() {
2467 let mut reg = SpriteModelRegistry::new();
2468 let a = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2469 let b = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2470 let len_before = reg.len();
2471 assert!(reg.is_live(a) && reg.is_live(b));
2472
2473 reg.remove(a);
2474 // Chain `a` is tombstoned (its entries are freed to empty models;
2475 // they're unreachable via `model()` now — that's the tombstone).
2476 assert!(!reg.is_live(a));
2477 // `b` is untouched and still live; `len()` (next id) is unchanged.
2478 assert!(reg.is_live(b));
2479 assert_eq!(®.model(b).colors, &[0xBB, 0xAA, 0xCC]);
2480 assert_eq!(reg.len(), len_before);
2481
2482 // A later add mints a fresh id past the tombstone (no slot reuse).
2483 let c = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2484 assert_eq!(c, len_before as u32);
2485 assert!(reg.is_live(c));
2486 // `b`'s id stayed valid across the remove + add round-trip.
2487 assert_eq!(®.model(b).colors, &[0xBB, 0xAA, 0xCC]);
2488 }
2489
2490 #[test]
2491 fn model_checked_guards_out_of_range_and_tombstoned() {
2492 // The guard `set_instance_model` relies on: `model()` would
2493 // index-panic on these, `model_checked` returns `None`.
2494 let mut reg = SpriteModelRegistry::new();
2495 let a = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2496 assert!(reg.model_checked(a).is_some());
2497 assert!(reg.model_checked(9999).is_none(), "out of range → None");
2498 reg.remove(a);
2499 assert!(reg.model_checked(a).is_none(), "tombstoned chain → None");
2500 }
2501
2502 #[test]
2503 fn remove_is_idempotent_and_bounds_safe() {
2504 let mut reg = SpriteModelRegistry::new();
2505 let a = reg.add(build_sprite_model(&kv6_unsorted()));
2506 reg.remove(a);
2507 reg.remove(a); // already removed → no-op, no panic
2508 reg.remove(999); // out of range → no-op
2509 assert!(!reg.is_live(a));
2510 assert!(!reg.is_live(999));
2511 }
2512
2513 #[test]
2514 fn registry_gpu_structs_have_expected_sizes() {
2515 assert_eq!(std::mem::size_of::<SpriteModelMeta>(), 48);
2516 // TV — grew 64 → 80 with the per-instance material id + alpha_mul
2517 // (+ 8 bytes pad to keep the 16-byte std430 stride).
2518 assert_eq!(std::mem::size_of::<SpriteInstanceGpu>(), 80);
2519 }
2520
2521 #[test]
2522 fn add_lod_builds_halving_mip_chain() {
2523 let mut reg = SpriteModelRegistry::new();
2524 // 8×8×8 single voxel-filled column model would be ideal, but
2525 // kv6_unsorted is 2×1×8 → mips: 2×1×8 → 1×1×4 → 1×1×2 → 1×1×1.
2526 let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
2527 let m0 = reg.model(id);
2528 assert_eq!(m0.dims, [2, 1, 8]);
2529 assert!((m0.voxel_world_size - 1.0).abs() < 1e-6);
2530 }
2531
2532 /// kv6 from explicit voxels, ordered x-major/y-inner to match
2533 /// `build_sprite_model`'s column walk.
2534 fn kv6_from(xsiz: u32, ysiz: u32, zsiz: u32, voxels: &[(u32, u32, u16, u32)]) -> Kv6 {
2535 let mut ylen = vec![vec![0u16; ysiz as usize]; xsiz as usize];
2536 let mut flat = Vec::new();
2537 for x in 0..xsiz {
2538 for y in 0..ysiz {
2539 let mut col: Vec<(u16, u32)> = voxels
2540 .iter()
2541 .filter(|(vx, vy, _, _)| *vx == x && *vy == y)
2542 .map(|(_, _, z, c)| (*z, *c))
2543 .collect();
2544 col.sort_by_key(|(z, _)| *z);
2545 ylen[x as usize][y as usize] = col.len() as u16;
2546 for (z, c) in col {
2547 flat.push(Voxel {
2548 col: c,
2549 z,
2550 vis: 0,
2551 dir: 0,
2552 });
2553 }
2554 }
2555 }
2556 let xlen = ylen
2557 .iter()
2558 .map(|c| c.iter().map(|&v| u32::from(v)).sum())
2559 .collect();
2560 Kv6 {
2561 xsiz,
2562 ysiz,
2563 zsiz,
2564 xpiv: 0.0,
2565 ypiv: 0.0,
2566 zpiv: 0.0,
2567 voxels: flat,
2568 xlen,
2569 ylen,
2570 palette: None,
2571 }
2572 }
2573
2574 fn offsets_consistent(m: &SpriteModel) -> bool {
2575 let cols = (m.dims[0] * m.dims[1]) as usize;
2576 if m.color_offsets.len() != cols + 1 {
2577 return false;
2578 }
2579 // Monotonic non-decreasing + last == colors.len + each column's
2580 // span == its solid-voxel count.
2581 for w in m.color_offsets.windows(2) {
2582 if w[1] < w[0] {
2583 return false;
2584 }
2585 }
2586 m.color_offsets[cols] as usize == m.colors.len()
2587 }
2588
2589 #[test]
2590 fn carve_two_layers_keeps_offsets_consistent() {
2591 // Mirror the demo's carve: columns with voxels at varied z,
2592 // some sharing z=0/z=1, some not.
2593 let kv6 = kv6_from(
2594 3,
2595 2,
2596 8,
2597 &[
2598 (0, 0, 0, 0xA0),
2599 (0, 0, 1, 0xA1),
2600 (0, 0, 5, 0xA5),
2601 (1, 0, 1, 0xB1),
2602 (2, 1, 0, 0xC0),
2603 (2, 1, 3, 0xC3),
2604 ],
2605 );
2606 let mut m = build_sprite_model(&kv6);
2607 assert!(offsets_consistent(&m));
2608 for z in 0..2u32 {
2609 for y in 0..m.dims[1] {
2610 for x in 0..m.dims[0] {
2611 m.set_voxel(x, y, z, None);
2612 }
2613 }
2614 assert!(offsets_consistent(&m), "inconsistent after carving z={z}");
2615 // downsample must not panic on the carved model.
2616 let _ = m.downsample();
2617 }
2618 }
2619
2620 #[test]
2621 fn set_voxel_inserts_replaces_and_clears() {
2622 // col 0 starts with z=1 (0xBB), z=5 (0xAA); col 1 with z=3 (0xCC).
2623 let mut m = build_sprite_model(&kv6_unsorted());
2624
2625 // Insert z=3 into col 0 (between z=1 and z=5) → rank 1.
2626 assert!(m.set_voxel(0, 0, 3, Some(0x55)));
2627 assert_eq!(m.occupancy[0], (1 << 1) | (1 << 3) | (1 << 5));
2628 // col 0 colours ascending z: 0xBB(z1), 0x55(z3), 0xAA(z5).
2629 assert_eq!(m.color_offsets, vec![0, 3, 4]);
2630 assert_eq!(&m.colors, &[0xBB, 0x55, 0xAA, 0xCC]);
2631
2632 // Replace z=3 in place (no offset shift).
2633 assert!(m.set_voxel(0, 0, 3, Some(0x66)));
2634 assert_eq!(&m.colors, &[0xBB, 0x66, 0xAA, 0xCC]);
2635 assert_eq!(m.color_offsets, vec![0, 3, 4]);
2636
2637 // Clear z=1 (rank 0) from col 0.
2638 assert!(m.set_voxel(0, 0, 1, None));
2639 assert_eq!(m.occupancy[0], (1 << 3) | (1 << 5));
2640 assert_eq!(m.color_offsets, vec![0, 2, 3]);
2641 assert_eq!(&m.colors, &[0x66, 0xAA, 0xCC]);
2642
2643 // No-ops: clear an empty voxel, edit out of bounds.
2644 assert!(!m.set_voxel(0, 0, 2, None));
2645 assert!(!m.set_voxel(9, 0, 0, Some(1)));
2646 }
2647
2648 #[test]
2649 fn rebuild_lod_refreshes_coarse_levels_from_mip0() {
2650 let mut reg = SpriteModelRegistry::new();
2651 let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 3);
2652 // Recolour mip-0 only via model_mut, then rebuild the ladder.
2653 reg.model_mut(id).recolor(|_| 0x0000_2000);
2654 reg.rebuild_lod(id);
2655 // The mip-1 average of all-0x2000 voxels is still 0x2000.
2656 let lvl1_entry = reg.chains[id as usize][1] as usize;
2657 assert!(reg.entries[lvl1_entry]
2658 .colors
2659 .iter()
2660 .all(|&c| c == 0x0000_2000));
2661 }
2662
2663 // ---- GPU.12 incremental: colors/dirs suballocator -----------------
2664
2665 /// Every slot fits its data, has slack, doesn't overlap the next, and
2666 /// the buffer reserves tail headroom past the last slot.
2667 fn alloc_invariants(a: &ColorsAllocator, lens: &[u32]) {
2668 let mut prev_end = 0u32;
2669 for (e, &len) in lens.iter().enumerate() {
2670 let s = a.slot(e);
2671 assert_eq!(s.len, len, "slot {e} len");
2672 assert!(s.cap >= s.len, "slot {e} cap >= len");
2673 // In a freshly repacked layout slots are in entry order.
2674 assert!(s.off >= prev_end, "slot {e} overlaps previous");
2675 assert!(s.off + s.cap <= a.cap_total(), "slot {e} past cap_total");
2676 prev_end = s.off + s.cap;
2677 }
2678 assert!(a.cap_total() >= prev_end, "tail headroom");
2679 }
2680
2681 #[test]
2682 fn allocator_new_lays_out_with_slack_and_headroom() {
2683 let lens = [10u32, 0, 64, 7];
2684 let a = ColorsAllocator::new(&lens);
2685 alloc_invariants(&a, &lens);
2686 // Slack: a 64-word slot has cap > 64 so a small carve-grow fits.
2687 assert!(a.slot(2).cap > 64);
2688 // Headroom past the bump tail for early growth.
2689 assert!(a.cap_total() > a.slot(3).off + a.slot(3).cap);
2690 }
2691
2692 #[test]
2693 fn allocator_place_in_place_when_within_cap() {
2694 let mut a = ColorsAllocator::new(&[10, 20]);
2695 let off0 = a.slot(0).off;
2696 let cap0 = a.slot(0).cap;
2697 // Shrink: still the same slot.
2698 assert_eq!(a.place(0, 5), Some(off0));
2699 assert_eq!(a.slot(0).len, 5);
2700 assert_eq!(a.slot(0).cap, cap0);
2701 // Grow within slack: same offset, no relocation.
2702 assert_eq!(a.place(0, cap0), Some(off0));
2703 assert_eq!(a.slot(0).off, off0);
2704 assert!(a.free.is_empty(), "no relocation should free anything");
2705 }
2706
2707 #[test]
2708 fn allocator_place_relocates_to_tail_and_frees_old() {
2709 let mut a = ColorsAllocator::new(&[10, 20]);
2710 let old0 = (a.slot(0).off, a.slot(0).cap);
2711 let tail_before = a.tail;
2712 // Overgrow entry 0 past its cap → relocate to the bump tail.
2713 let new_len = a.slot(0).cap + 5;
2714 let off = a.place(0, new_len).expect("fits in headroom");
2715 assert_eq!(off, tail_before, "relocated to old tail");
2716 assert_eq!(a.slot(0).off, off);
2717 assert_eq!(a.slot(0).len, new_len);
2718 assert!(a.free.contains(&old0), "old slot freed");
2719 }
2720
2721 #[test]
2722 fn allocator_reuses_freed_block_first_fit() {
2723 // Entry 0 has a large slot; entry 1 a tiny one, so growing 1 must
2724 // relocate (it can't fit in place) and lands in 0's freed block.
2725 let mut a = ColorsAllocator::new(&[10, 2]);
2726 let old0 = (a.slot(0).off, a.slot(0).cap);
2727 // Relocate entry 0 to the tail, freeing its original block.
2728 let _ = a.place(0, a.slot(0).cap + 5).unwrap();
2729 assert!(a.free.contains(&old0));
2730 // Grow entry 1 past its (tiny) cap but ≤ the freed block's cap →
2731 // first-fit reuses that block rather than bumping the tail.
2732 let new1 = a.slot(1).cap + 1;
2733 assert!(new1 <= old0.1, "freed block big enough");
2734 let off = a.place(1, new1).expect("reuses freed block");
2735 assert_eq!(off, old0.0, "first-fit reused the freed slot offset");
2736 assert!(!a.free.contains(&old0), "freed block consumed");
2737 }
2738
2739 #[test]
2740 fn allocator_signals_grow_then_repack_restores() {
2741 let mut a = ColorsAllocator::new(&[8, 8]);
2742 // Force overflow: ask for far more than cap_total.
2743 let huge = a.cap_total() + 100;
2744 assert_eq!(a.place(0, huge), None, "overflow must signal grow");
2745 // Repack with the new lengths compacts + grows the buffer.
2746 a.repack(&[huge, 8]);
2747 alloc_invariants(&a, &[huge, 8]);
2748 assert!(a.cap_total() > huge);
2749 // After repack the entry now fits in place.
2750 assert_eq!(a.place(0, huge), Some(a.slot(0).off));
2751 }
2752
2753 /// Drive the allocator like a real carve loop (mirroring
2754 /// `update_model`): one model's colour count drifts up and down
2755 /// across many edits while two neighbours stay put. Growth is
2756 /// absorbed in place / via the free list / by the bump tail, and on
2757 /// the rare overflow we repack (as `update_model` does). After every
2758 /// edit the live `[off, off+len)` windows must stay disjoint.
2759 #[test]
2760 fn allocator_carve_loop_keeps_live_windows_disjoint() {
2761 let mut a = ColorsAllocator::new(&[40, 12, 40]);
2762 let mut lens = [40u32, 12, 40];
2763 // A deterministic up/down walk of entry 1's length, incl. a jump
2764 // that forces at least one grow+repack.
2765 let walk = [13u32, 30, 60, 18, 9, 80, 80, 25, 200, 7];
2766 let mut grew = false;
2767 for &len in &walk {
2768 lens[1] = len;
2769 // Entry 1 re-placed; on overflow, repack the whole set.
2770 if a.place(1, len).is_none() {
2771 grew = true;
2772 a.repack(&lens);
2773 } else {
2774 // Neighbours fit in place every time.
2775 assert_eq!(a.place(0, 40), Some(a.slot(0).off));
2776 assert_eq!(a.place(2, 40), Some(a.slot(2).off));
2777 }
2778 assert_eq!(a.slot(1).len, len);
2779
2780 // No two entries' live windows overlap.
2781 let mut wins: Vec<(u32, u32)> =
2782 (0..3).map(|e| (a.slot(e).off, a.slot(e).len)).collect();
2783 wins.sort_by_key(|w| w.0);
2784 for pair in wins.windows(2) {
2785 let (o0, l0) = pair[0];
2786 let (o1, _) = pair[1];
2787 assert!(o0 + l0 <= o1, "live windows overlap: {pair:?}");
2788 }
2789 }
2790 assert!(grew, "the 200-word jump should have forced a repack");
2791 }
2792
2793 // --- incremental instance path (device-backed; skips w/o adapter) ---
2794
2795 fn headless() -> Option<crate::HeadlessGpu> {
2796 match crate::HeadlessGpu::new_blocking(crate::GpuRendererSettings::default()) {
2797 Ok(h) => Some(h),
2798 Err(e) => {
2799 eprintln!("[skip] no GPU adapter reachable: {e}");
2800 None
2801 }
2802 }
2803 }
2804
2805 fn one_model_registry() -> (SpriteModelRegistry, u32) {
2806 let mut reg = SpriteModelRegistry::new();
2807 let id = reg.add(build_sprite_model(&kv6_unsorted()));
2808 (reg, id)
2809 }
2810
2811 fn inst(model_id: u32, pos: [f32; 3]) -> SpriteInstance {
2812 use roxlap_formats::sprite::Sprite;
2813 SpriteInstance::new(
2814 model_id,
2815 SpriteInstanceTransform::from_sprite(&Sprite::axis_aligned(kv6_unsorted(), pos)),
2816 )
2817 }
2818
2819 /// PS.1 — a scaled basis grows the cull sphere with the pose: the
2820 /// transform keeps the longest basis column, and `make_cull` seeds
2821 /// `radius = model.bound_radius() × max_scale`, so scaled-up
2822 /// instances (particles) no longer under-cull at screen edges.
2823 #[test]
2824 fn scaled_basis_scales_cull_radius() {
2825 use roxlap_formats::sprite::Sprite;
2826
2827 let mut reg = SpriteModelRegistry::new();
2828 let chain = reg.add(build_sprite_model(&kv6_unsorted()));
2829 let model_r = reg.model(chain).bound_radius();
2830
2831 let scaled = |k: f32, pos: [f32; 3]| {
2832 let mut s = Sprite::axis_aligned(kv6_unsorted(), pos);
2833 for a in 0..3 {
2834 s.s[a] *= k;
2835 s.h[a] *= k;
2836 s.f[a] *= k;
2837 }
2838 SpriteInstanceTransform::from_sprite(&s)
2839 };
2840
2841 // Unit basis: max_scale 1, radius = the model's (float-exact).
2842 let unit = inst(chain, [0.0; 3]);
2843 assert_eq!(unit.transform.max_scale, 1.0);
2844 assert_eq!(make_cull(®, &unit).radius, model_r);
2845
2846 // 2× uniform scale doubles both.
2847 let xf2 = scaled(2.0, [0.0; 3]);
2848 assert!((xf2.max_scale - 2.0).abs() < 1e-6);
2849 let big = SpriteInstance::new(chain, xf2);
2850 assert!((make_cull(®, &big).radius - 2.0 * model_r).abs() < 1e-4);
2851
2852 // Anisotropic scale takes the longest column.
2853 let mut s = Sprite::axis_aligned(kv6_unsorted(), [0.0; 3]);
2854 for a in 0..3 {
2855 s.h[a] *= 3.0;
2856 s.f[a] *= 0.5;
2857 }
2858 let xf = SpriteInstanceTransform::from_sprite(&s);
2859 assert!((xf.max_scale - 3.0).abs() < 1e-6);
2860 }
2861
2862 #[test]
2863 fn append_grows_count_and_capacity_pow2() {
2864 let Some(h) = headless() else { return };
2865 let (reg, m) = one_model_registry();
2866 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(m, [0.0; 3])]);
2867 assert_eq!(res.instance_count(), 1);
2868 assert_eq!(res.instance_capacity, 1);
2869
2870 // Append 4 → count 5, capacity grows to next_pow2(5) = 8.
2871 let more: Vec<_> = (1..=4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
2872 let base = res.append_instances(&h.device, ®, &more);
2873 assert_eq!(base, 1, "first appended index follows the seed instance");
2874 assert_eq!(res.instance_count(), 5);
2875 assert_eq!(res.instance_capacity, 8, "power-of-two growth");
2876
2877 // A second append that still fits keeps the same capacity (no realloc).
2878 let base2 = res.append_instances(&h.device, ®, &[inst(m, [9.0, 0.0, 0.0])]);
2879 assert_eq!(base2, 5);
2880 assert_eq!(res.instance_count(), 6);
2881 assert_eq!(res.instance_capacity, 8, "fits existing capacity, no grow");
2882 }
2883
2884 #[test]
2885 fn append_empty_is_noop() {
2886 let Some(h) = headless() else { return };
2887 let (reg, m) = one_model_registry();
2888 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(m, [0.0; 3])]);
2889 let base = res.append_instances(&h.device, ®, &[]);
2890 assert_eq!(base, 1);
2891 assert_eq!(res.instance_count(), 1);
2892 assert_eq!(res.instance_capacity, 1);
2893 }
2894
2895 /// Read `words` u32s back from a GPU buffer (needs COPY_SRC).
2896 fn read_u32(h: &crate::HeadlessGpu, buf: &wgpu::Buffer, words: u64) -> Vec<u32> {
2897 let bytes = words * 4;
2898 let staging = h.device.create_buffer(&wgpu::BufferDescriptor {
2899 label: Some("readback"),
2900 size: bytes,
2901 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2902 mapped_at_creation: false,
2903 });
2904 let mut enc = h
2905 .device
2906 .create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
2907 enc.copy_buffer_to_buffer(buf, 0, &staging, 0, bytes);
2908 h.queue.submit(std::iter::once(enc.finish()));
2909 let slice = staging.slice(..);
2910 let (tx, rx) = std::sync::mpsc::channel();
2911 slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
2912 h.device.poll(wgpu::PollType::wait_indefinitely()).ok();
2913 rx.recv().unwrap().unwrap();
2914 let data = slice.get_mapped_range();
2915 let out = bytemuck::cast_slice::<u8, u32>(&data).to_vec();
2916 drop(data);
2917 staging.unmap();
2918 out
2919 }
2920
2921 /// A second distinct model so add_model has real new geometry to lay
2922 /// down (different dims + colours from `kv6_unsorted`).
2923 fn kv6_other() -> Kv6 {
2924 let mk = |z, col| Voxel {
2925 col,
2926 z,
2927 vis: 0,
2928 dir: 0,
2929 };
2930 Kv6 {
2931 xsiz: 1,
2932 ysiz: 1,
2933 zsiz: 4,
2934 xpiv: 0.0,
2935 ypiv: 0.0,
2936 zpiv: 0.0,
2937 voxels: vec![mk(0, 0x11), mk(2, 0x22)],
2938 xlen: vec![2],
2939 ylen: vec![vec![2]],
2940 palette: None,
2941 }
2942 }
2943
2944 /// add_model lays the new model's volume on the GPU at the offsets its
2945 /// meta record claims — verified by reading the shared buffers back
2946 /// and matching each entry against its source SpriteModel.
2947 #[test]
2948 fn add_model_uploads_new_volume_incrementally() {
2949 let Some(h) = headless() else { return };
2950
2951 // Residency starts with model A only.
2952 let mut reg = SpriteModelRegistry::new();
2953 let a = reg.add(build_sprite_model(&kv6_unsorted()));
2954 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(a, [0.0; 3])]);
2955 assert_eq!(res.chains.len(), 1);
2956 let entries_before = res.meta.len();
2957
2958 // Append model B (single-level) to the registry, then sync it.
2959 let b = reg.add(build_sprite_model(&kv6_other()));
2960 res.add_model(&h.device, &h.queue, ®, b);
2961 assert_eq!(res.chains.len(), 2);
2962 assert_eq!(res.meta.len(), entries_before + 1, "one new entry");
2963
2964 // Read the shared buffers back and check EVERY entry's data sits
2965 // where its meta record points — both the pre-existing A and the
2966 // newly streamed B.
2967 let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
2968 let coloff = read_u32(&h, &res.color_offsets, u64::from(res.coloff_cap));
2969 let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
2970 for (e, m) in reg.entries.iter().enumerate() {
2971 let meta = res.meta[e];
2972 let oo = meta.occupancy_offset as usize;
2973 assert_eq!(
2974 &occ[oo..oo + m.occupancy.len()],
2975 &m.occupancy[..],
2976 "occ entry {e}"
2977 );
2978 let co = meta.color_offsets_offset as usize;
2979 assert_eq!(
2980 &coloff[co..co + m.color_offsets.len()],
2981 &m.color_offsets[..],
2982 "color_offsets entry {e}"
2983 );
2984 let cc = meta.colors_offset as usize;
2985 assert_eq!(
2986 &cols[cc..cc + m.colors.len()],
2987 &m.colors[..],
2988 "colors entry {e}"
2989 );
2990 }
2991
2992 // And an instance of the freshly-added model can now be appended.
2993 let base = res.append_instances(&h.device, ®, &[inst(b, [5.0, 0.0, 0.0])]);
2994 assert_eq!(base, 1);
2995 assert_eq!(res.instance_count(), 2);
2996 }
2997
2998 /// Adding many small models forces the volume buffers to grow + rebuild
2999 /// at least once; every entry must still read back correctly across the
3000 /// grow boundary.
3001 #[test]
3002 fn add_model_survives_buffer_growth() {
3003 let Some(h) = headless() else { return };
3004 let mut reg = SpriteModelRegistry::new();
3005 let a = reg.add(build_sprite_model(&kv6_unsorted()));
3006 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(a, [0.0; 3])]);
3007 let occ_cap0 = res.occ_cap;
3008
3009 // 40 adds — occupancy starts exact-sized (cap == used), so the very
3010 // first add overflows and grows; later ones ride the slack.
3011 for _ in 0..40 {
3012 let id = reg.add(build_sprite_model(&kv6_other()));
3013 res.add_model(&h.device, &h.queue, ®, id);
3014 }
3015 assert_eq!(res.chains.len(), 41);
3016 assert!(res.occ_cap > occ_cap0, "occupancy buffer grew");
3017
3018 let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3019 let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3020 for (e, m) in reg.entries.iter().enumerate() {
3021 let meta = res.meta[e];
3022 let oo = meta.occupancy_offset as usize;
3023 assert_eq!(
3024 &occ[oo..oo + m.occupancy.len()],
3025 &m.occupancy[..],
3026 "occ entry {e}"
3027 );
3028 let cc = meta.colors_offset as usize;
3029 assert_eq!(
3030 &cols[cc..cc + m.colors.len()],
3031 &m.colors[..],
3032 "colors entry {e}"
3033 );
3034 }
3035 }
3036
3037 /// VCL.2 — a decoded voxel clip's frames register as a flipbook of LOD
3038 /// chains, and `set_instance_model` flips which frame an instance
3039 /// draws. The cull state it updates is exactly what
3040 /// `cull_bin_upload` packs into the GPU instance buffer each frame, so
3041 /// TV.3 (clip wiring): `sprite_model_from_clip_frame_with_materials`
3042 /// classifies a clip frame's voxels into a per-voxel `materials` array
3043 /// (parallel to `colors`) by colour; an empty map leaves it empty (the
3044 /// all-opaque clip), identical to `sprite_model_from_clip_frame`.
3045 #[test]
3046 fn clip_frame_with_materials_classifies_by_color() {
3047 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
3048
3049 let dims = [1u32, 1, 4];
3050 let owpc = dims[2].div_ceil(32).max(1) as usize; // 1
3051 let glass = 0x80AA_BBCC;
3052 let stone = 0x8011_2233;
3053 let frame = VoxelFrame {
3054 occupancy: {
3055 let mut occ = vec![0u32; owpc];
3056 occ[0] |= (1 << 0) | (1 << 1);
3057 occ
3058 },
3059 colors: vec![stone, glass], // ascending z: z=0 stone, z=1 glass
3060 color_offsets: vec![0, 2],
3061 };
3062 let clip = VoxelClip::from_frames(
3063 dims,
3064 [0.5, 0.5, 2.0],
3065 1.0,
3066 LoopMode::Loop,
3067 &[frame],
3068 &[],
3069 33,
3070 0,
3071 );
3072 let decoded = clip.decode().expect("decode");
3073
3074 // Map only the glass colour → material 2; stone stays opaque (0).
3075 let m = sprite_model_from_clip_frame_with_materials(&decoded, 0, &[(Rgb(0x00AA_BBCC), 2)]);
3076 assert_eq!(
3077 m.materials.len(),
3078 m.colors.len(),
3079 "materials parallel to colors"
3080 );
3081 // `colors` is in popcount-rank (ascending z) order: stone then glass.
3082 assert_eq!(
3083 m.materials,
3084 vec![0u8, 2u8],
3085 "stone opaque, glass material 2"
3086 );
3087
3088 // Empty map ⇒ no per-voxel materials, identical to the plain builder.
3089 let plain = sprite_model_from_clip_frame(&decoded, 0);
3090 let plain_mat = sprite_model_from_clip_frame_with_materials(&decoded, 0, &[]);
3091 assert!(plain.materials.is_empty());
3092 assert!(plain_mat.materials.is_empty());
3093 assert_eq!(plain.colors, plain_mat.colors);
3094 }
3095
3096 /// TV.3 (streaming-clip refresh path): `build_sprite_model_with_materials`
3097 /// — the builder behind `GpuBackend::update_sprite_model_with_materials`,
3098 /// which a streaming clip re-runs each frame — classifies a kv6's voxels
3099 /// into a per-voxel `materials` array (popcount-rank order) by colour.
3100 #[test]
3101 fn build_with_materials_classifies_by_color() {
3102 let glass = 0x80AA_BBCC;
3103 let stone = 0x8011_2233;
3104 // One column (x=0,y=0), two voxels: z=0 stone, z=1 glass.
3105 let kv6 = kv6_from(1, 1, 4, &[(0, 0, 0, stone), (0, 0, 1, glass)]);
3106
3107 let m = build_sprite_model_with_materials(&kv6, &[(Rgb(0x00AA_BBCC), 2)]);
3108 assert_eq!(
3109 m.materials.len(),
3110 m.colors.len(),
3111 "materials parallel to colors"
3112 );
3113 assert_eq!(
3114 m.materials,
3115 vec![0u8, 2u8],
3116 "stone opaque, glass material 2"
3117 );
3118
3119 // Empty map ⇒ no per-voxel materials, identical to `build_sprite_model`.
3120 let plain = build_sprite_model(&kv6);
3121 let plain_mat = build_sprite_model_with_materials(&kv6, &[]);
3122 assert!(plain.materials.is_empty());
3123 assert!(plain_mat.materials.is_empty());
3124 assert_eq!(plain.colors, plain_mat.colors);
3125 }
3126
3127 /// flipping `chain_id` redirects the rendered instance to the new
3128 /// frame's resident volume.
3129 #[test]
3130 fn voxel_clip_flipbook_set_instance_model() {
3131 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
3132 let Some(h) = headless() else { return };
3133
3134 // Two distinct frames of a 1×1×4 clip: frame 0 has a voxel at z=0;
3135 // frame 1 adds z=1 — different occupancy + a longer colour run.
3136 let dims = [1u32, 1, 4];
3137 let owpc = dims[2].div_ceil(32).max(1) as usize; // 1
3138 let mk_frame = |zs: &[u32], cols: &[u32]| -> VoxelFrame {
3139 let mut occ = vec![0u32; owpc];
3140 for &z in zs {
3141 occ[(z >> 5) as usize] |= 1u32 << (z & 31);
3142 }
3143 VoxelFrame {
3144 occupancy: occ,
3145 colors: cols.to_vec(),
3146 color_offsets: vec![0, cols.len() as u32],
3147 }
3148 };
3149 let f0 = mk_frame(&[0], &[0x8011_2233]);
3150 let f1 = mk_frame(&[0, 1], &[0x8011_2233, 0x80AA_BBCC]);
3151 let clip = VoxelClip::from_frames(
3152 dims,
3153 [0.5, 0.5, 2.0],
3154 1.0,
3155 LoopMode::Loop,
3156 &[f0, f1],
3157 &[],
3158 33,
3159 0,
3160 );
3161 let decoded = clip.decode().expect("decode");
3162
3163 // Each frame → a single-level chain; both volumes resident + distinct.
3164 let mut reg = SpriteModelRegistry::new();
3165 let c0 = reg.add(sprite_model_from_clip_frame(&decoded, 0));
3166 let c1 = reg.add(sprite_model_from_clip_frame(&decoded, 1));
3167 assert_eq!(reg.model(c0).colors.len(), 1);
3168 assert_eq!(reg.model(c1).colors.len(), 2);
3169
3170 // One instance, in front of the test frustum, drawing frame 0.
3171 let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(c0, [0.0, 0.0, 5.0])]);
3172 assert_eq!(res.cull[0].chain_id, c0);
3173
3174 // Flip to frame 1: the cull now draws chain c1 (radius reseeded).
3175 res.set_instance_model(®, 0, c1);
3176 assert_eq!(res.cull[0].chain_id, c1);
3177 assert_eq!(res.cull[0].radius, reg.model(c1).bound_radius());
3178
3179 // The next cull packs the new chain into the GPU instance buffer
3180 // (visible, no panic).
3181 let f = test_frustum();
3182 let (visible, _, _) = res.cull_bin_upload(&h.device, &h.queue, &f, 64, 64, 16, 1.0);
3183 assert_eq!(visible, 1);
3184
3185 // …and back to frame 0.
3186 res.set_instance_model(®, 0, c0);
3187 assert_eq!(res.cull[0].chain_id, c0);
3188
3189 // Out-of-range index is a safe no-op.
3190 res.set_instance_model(®, 99, c1);
3191 assert_eq!(res.cull[0].chain_id, c0);
3192 }
3193
3194 fn test_frustum() -> ViewFrustum {
3195 ViewFrustum {
3196 pos: [0.0, 0.0, 0.0],
3197 right: [1.0, 0.0, 0.0],
3198 down: [0.0, 1.0, 0.0],
3199 forward: [0.0, 0.0, 1.0],
3200 half_w: 1.0,
3201 half_h: 1.0,
3202 far: 10_000.0,
3203 }
3204 }
3205
3206 #[test]
3207 fn remove_model_tombstones_frees_and_reuses() {
3208 let Some(h) = headless() else { return };
3209 // Residency with models A and B, one instance each.
3210 let mut reg = SpriteModelRegistry::new();
3211 let a = reg.add(build_sprite_model(&kv6_unsorted()));
3212 let b = reg.add(build_sprite_model(&kv6_other()));
3213 let mut res = SpriteRegistryResident::upload(
3214 &h.device,
3215 ®,
3216 &[inst(a, [0.0; 3]), inst(b, [1.0, 0.0, 0.0])],
3217 );
3218 assert_eq!(res.live_model_count(), 2);
3219 assert_eq!(res.dead_model_count(), 0);
3220
3221 // Remove B → tombstoned, its colours freed into the pool.
3222 res.remove_model(b);
3223 assert_eq!(res.live_model_count(), 1);
3224 assert_eq!(res.dead_model_count(), 1);
3225 assert_eq!(res.dead.iter().filter(|&&d| d).count(), 1, "one entry dead");
3226 assert!(!res.colors_alloc.free.is_empty(), "B's colour slot freed");
3227
3228 // Adding C reuses the freed slot (free-list first-fit).
3229 let c = reg.add(build_sprite_model(&kv6_other()));
3230 res.add_model(&h.device, &h.queue, ®, c);
3231 assert_eq!(res.live_model_count(), 2);
3232
3233 // A and C read back correctly; B is dead (skipped).
3234 let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3235 for e in [a as usize, c as usize] {
3236 let m = ®.entries[e];
3237 let cc = res.meta[e].colors_offset as usize;
3238 assert_eq!(
3239 &cols[cc..cc + m.colors.len()],
3240 &m.colors[..],
3241 "colors entry {e}"
3242 );
3243 }
3244
3245 // The lingering instance of removed B is skipped without panic.
3246 let f = test_frustum();
3247 let _ = res.cull_bin_upload(&h.device, &h.queue, &f, 64, 64, 16, 1.0);
3248 }
3249
3250 #[test]
3251 fn compact_reclaims_holes_keeps_ids_stable() {
3252 let Some(h) = headless() else { return };
3253 let mut reg = SpriteModelRegistry::new();
3254 let a = reg.add(build_sprite_model(&kv6_unsorted()));
3255 let b = reg.add(build_sprite_model(&kv6_other()));
3256 let c = reg.add(build_sprite_model(&kv6_other()));
3257 let mut res = SpriteRegistryResident::upload(
3258 &h.device,
3259 ®,
3260 &[inst(a, [0.0; 3]), inst(b, [1.0; 3]), inst(c, [2.0; 3])],
3261 );
3262 let occ_used_full = res.occ_used;
3263
3264 // Remove the middle model, then compact.
3265 res.remove_model(b);
3266 res.compact(&h.device, &h.queue, ®);
3267
3268 // Holes reclaimed: occupancy now only covers A + C.
3269 let live_occ: u32 = [a, c]
3270 .iter()
3271 .map(|&e| reg.entries[e as usize].occupancy.len() as u32)
3272 .sum();
3273 assert_eq!(res.occ_used, live_occ);
3274 assert!(res.occ_used < occ_used_full, "compaction shrank occupancy");
3275 // Dead entry keeps a zeroed tombstone; ids unchanged.
3276 assert_eq!(res.meta[b as usize].occupancy_offset, 0);
3277 assert_eq!(res.live_model_count(), 2);
3278 assert_eq!(res.dead_model_count(), 1);
3279
3280 // Live entries read back correctly at their new offsets.
3281 let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
3282 let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
3283 for &e in &[a as usize, c as usize] {
3284 let m = ®.entries[e];
3285 let oo = res.meta[e].occupancy_offset as usize;
3286 assert_eq!(
3287 &occ[oo..oo + m.occupancy.len()],
3288 &m.occupancy[..],
3289 "occ {e}"
3290 );
3291 let cc = res.meta[e].colors_offset as usize;
3292 assert_eq!(&cols[cc..cc + m.colors.len()], &m.colors[..], "cols {e}");
3293 }
3294
3295 // Chain ids still valid: C's chain still resolves; B's is empty.
3296 assert!(!res.chains[c as usize].is_empty());
3297 assert!(res.chains[b as usize].is_empty());
3298 }
3299
3300 #[test]
3301 fn remove_swap_semantics_and_capacity_retained() {
3302 let Some(h) = headless() else { return };
3303 let (reg, m) = one_model_registry();
3304 let seed: Vec<_> = (0..4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
3305 let mut res = SpriteRegistryResident::upload(&h.device, ®, &seed);
3306 assert_eq!(res.instance_count(), 4);
3307 let cap = res.instance_capacity;
3308
3309 // Remove a middle element → the previous last (idx 3) moved into it.
3310 assert_eq!(res.remove_instance(1), Some(3));
3311 assert_eq!(res.instance_count(), 3);
3312
3313 // Remove the current last (idx 2) → nothing moved.
3314 assert_eq!(res.remove_instance(2), None);
3315 assert_eq!(res.instance_count(), 2);
3316
3317 // Out of range → None.
3318 assert_eq!(res.remove_instance(99), None);
3319 assert_eq!(res.instance_count(), 2);
3320
3321 // Capacity is retained for reuse (no shrink).
3322 assert_eq!(res.instance_capacity, cap);
3323 }
3324}