Skip to main content

roxlap_core/
dda_sprite.rs

1//! Clean-room KV6 sprite raycaster for the DDA backend (Substage
2//! DDA.8).
3//!
4//! Renders KV6 sprites by **per-pixel ray casting**: for every screen
5//! pixel the sprite covers, transform the camera ray into the sprite's
6//! local voxel space, 3D-DDA through the KV6, and depth-composite the
7//! first solid voxel against the shared z-buffer. Clean-room (no voxlap
8//! code), the sprite counterpart to the terrain renderer in
9//! [`crate::dda`].
10//!
11//! **Depth parity.** Transforming the ray by the inverse sprite basis
12//! leaves the ray parameter unchanged in world units — a hit at local
13//! parameter `t` is at world point `cam.pos + dir·t` — so the
14//! perpendicular depth is `t · (dir·forward)`, exactly the convention
15//! [`crate::dda`] writes for terrain. Sprites therefore occlude and are
16//! occluded by DDA terrain correctly.
17//!
18//! Shading reads the KV6 voxel's baked brightness byte (high byte of
19//! the packed colour) via [`crate::dda::shade`] — the clean-room
20//! brightness model, not voxlap's `dir`-LUT reflection shading.
21
22use roxlap_formats::kv6::Kv6;
23use roxlap_formats::material::{material_for_color, BlendMode, MaterialTable};
24use roxlap_formats::sprite::{Sprite, SPRITE_FLAG_INVISIBLE, SPRITE_FLAG_NO_Z};
25use roxlap_formats::voxel_clip::{DecodedClip, VoxelFrame};
26
27use crate::camera_math::CameraState;
28use crate::dda::{dda_setup, intersect_aabb, min_axis, pixel_ray, shade};
29use crate::opticast::OpticastSettings;
30use crate::raster_target::RasterTarget;
31
32/// Near-plane parameter: voxels nearer than this (camera-forward) are
33/// dropped, keeping the pinhole divide finite.
34const NEAR_Z: f32 = 1.0;
35
36/// Force a packed voxel colour to full brightness for the flat-lit
37/// clean-room sprite path. KV6 / voxel-clip colours carry voxlap's
38/// `dir`/shading slot in the high byte (some `0x80`, some `0x00`), not
39/// the 0..128 brightness [`shade`] expects, so a raw value can render
40/// black; we render every sprite voxel at its authored RGB.
41#[inline]
42fn full_bright(col: u32) -> u32 {
43    (col & 0x00ff_ffff) | 0x8000_0000
44}
45
46/// Dense occupancy + colour grid for one sprite frame, plus its pivot —
47/// the decoded form the per-pixel raycaster marches. Built once from a
48/// [`Kv6`] ([`SpriteDense::from_kv6`]) or a voxel-clip [`VoxelFrame`]
49/// ([`SpriteDense::from_voxel_frame`]); the latter lets an animated clip
50/// cache every frame's grid up front instead of rebuilding per frame.
51///
52/// Both sources store only **surface** voxels (a from-air ray's first
53/// hit is the visible surface), so the grid is the visible hull.
54pub struct SpriteDense {
55    dims: [i32; 3],
56    occ: Vec<bool>,
57    col: Vec<u32>,
58    /// Per-voxel material id (TV stage), parallel to [`col`](Self::col) /
59    /// [`occ`](Self::occ) (same dense index). **Empty** means every voxel
60    /// uses the draw-time uniform material (the TV.1 path); a non-empty
61    /// array gives mixed-material models (opaque frame + glass, TV.3). Only
62    /// consulted on the [`draw_sprite_dense_shaded`] accumulate path.
63    mat: Vec<u8>,
64    pivot: [f32; 3],
65}
66
67impl SpriteDense {
68    /// Decode a [`Kv6`]'s surface-voxel run tables into a dense grid.
69    #[must_use]
70    #[allow(clippy::cast_possible_wrap)]
71    pub fn from_kv6(kv6: &Kv6) -> Self {
72        let dims = [kv6.xsiz as i32, kv6.ysiz as i32, kv6.zsiz as i32];
73        let n = (dims[0].max(0) * dims[1].max(0) * dims[2].max(0)) as usize;
74        let mut occ = vec![false; n];
75        let mut col = vec![0u32; n];
76        let mut vi = 0usize;
77        for x in 0..kv6.xsiz as usize {
78            for y in 0..kv6.ysiz as usize {
79                let cnt = usize::from(kv6.ylen[x][y]);
80                for _ in 0..cnt {
81                    let v = kv6.voxels[vi];
82                    vi += 1;
83                    let z = i32::from(v.z);
84                    if z >= 0 && z < dims[2] {
85                        let idx = ((x as i32 * dims[1] + y as i32) * dims[2] + z) as usize;
86                        occ[idx] = true;
87                        col[idx] = full_bright(v.col);
88                    }
89                }
90            }
91        }
92        Self {
93            dims,
94            occ,
95            col,
96            mat: Vec::new(),
97            pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
98        }
99    }
100
101    /// Like [`from_kv6`](Self::from_kv6) but classifies each voxel into a
102    /// material id by colour (TV.3 mixed models) via `material_map`
103    /// (`(rgb, material_id)` pairs; see
104    /// [`material_for_color`](roxlap_formats::material::material_for_color)).
105    /// The resulting per-voxel `mat` array is consulted by the
106    /// [`draw_sprite_dense_shaded`] accumulate path. An empty map yields the
107    /// same all-opaque (uniform) result as `from_kv6`.
108    #[must_use]
109    #[allow(clippy::cast_possible_wrap)]
110    pub fn from_kv6_with_materials(kv6: &Kv6, material_map: &[(u32, u8)]) -> Self {
111        let mut dense = Self::from_kv6(kv6);
112        if !material_map.is_empty() {
113            let n = dense.col.len();
114            let mut mat = vec![0u8; n];
115            for (idx, slot) in mat.iter_mut().enumerate() {
116                if dense.occ[idx] {
117                    *slot = material_for_color(material_map, dense.col[idx]);
118                }
119            }
120            dense.mat = mat;
121        }
122        dense
123    }
124
125    /// Decode a voxel-clip [`VoxelFrame`] (dense-column layout) into the
126    /// dense grid, given the clip's `dims` + `pivot`. The frame's columns
127    /// are `col = x + y*dims[0]`, each a per-column occupancy bitmask with
128    /// an ascending-z colour run — walked here into the raycaster's
129    /// `(x·my + y)·mz + z` grid.
130    #[must_use]
131    #[allow(clippy::cast_possible_wrap)]
132    pub fn from_voxel_frame(frame: &VoxelFrame, dims: [u32; 3], pivot: [f32; 3]) -> Self {
133        let (mx, my, mz) = (dims[0], dims[1], dims[2]);
134        let owpc = mz.div_ceil(32).max(1) as usize;
135        let n = (mx * my * mz) as usize;
136        let mut occ = vec![false; n];
137        let mut col = vec![0u32; n];
138        for col_idx in 0..(mx * my) as usize {
139            let x = col_idx as u32 % mx;
140            let y = col_idx as u32 / mx;
141            let run_start = frame.color_offsets[col_idx] as usize;
142            let mut k = 0usize;
143            for z in 0..mz {
144                let word = frame.occupancy[col_idx * owpc + (z >> 5) as usize];
145                if (word >> (z & 31)) & 1 != 0 {
146                    let idx = (((x * my + y) * mz) + z) as usize;
147                    occ[idx] = true;
148                    col[idx] = full_bright(frame.colors[run_start + k]);
149                    k += 1;
150                }
151            }
152        }
153        Self {
154            dims: [mx as i32, my as i32, mz as i32],
155            occ,
156            col,
157            mat: Vec::new(),
158            pivot,
159        }
160    }
161
162    /// Like [`from_voxel_frame`](Self::from_voxel_frame) but classifies each
163    /// voxel into a material id by colour (TV.3 mixed models) via
164    /// `material_map` — the clip analogue of
165    /// [`from_kv6_with_materials`](Self::from_kv6_with_materials). An empty
166    /// map yields the same all-opaque (uniform) result as `from_voxel_frame`.
167    #[must_use]
168    pub fn from_voxel_frame_with_materials(
169        frame: &VoxelFrame,
170        dims: [u32; 3],
171        pivot: [f32; 3],
172        material_map: &[(u32, u8)],
173    ) -> Self {
174        let mut dense = Self::from_voxel_frame(frame, dims, pivot);
175        if !material_map.is_empty() {
176            let n = dense.col.len();
177            let mut mat = vec![0u8; n];
178            for (idx, slot) in mat.iter_mut().enumerate() {
179                if dense.occ[idx] {
180                    *slot = material_for_color(material_map, dense.col[idx]);
181                }
182            }
183            dense.mat = mat;
184        }
185        dense
186    }
187
188    #[inline]
189    #[allow(clippy::cast_sign_loss)]
190    fn idx_of(&self, c: [i32; 3]) -> usize {
191        ((c[0] * self.dims[1] + c[1]) * self.dims[2] + c[2]) as usize
192    }
193
194    #[inline]
195    fn at(&self, c: [i32; 3]) -> Option<u32> {
196        let idx = self.idx_of(c);
197        self.occ[idx].then(|| self.col[idx])
198    }
199}
200
201/// Inverse of the column-matrix `[s | h | f]` (the sprite basis), or
202/// `None` if degenerate. Maps a world delta into local voxel space.
203fn invert_basis(s: [f32; 3], h: [f32; 3], f: [f32; 3]) -> Option<[[f32; 3]; 3]> {
204    let det = s[0] * (h[1] * f[2] - f[1] * h[2]) - h[0] * (s[1] * f[2] - f[1] * s[2])
205        + f[0] * (s[1] * h[2] - h[1] * s[2]);
206    if det.abs() < 1e-12 {
207        return None;
208    }
209    let inv = 1.0 / det;
210    Some([
211        [
212            (h[1] * f[2] - f[1] * h[2]) * inv,
213            -(h[0] * f[2] - f[0] * h[2]) * inv,
214            (h[0] * f[1] - f[0] * h[1]) * inv,
215        ],
216        [
217            -(s[1] * f[2] - f[1] * s[2]) * inv,
218            (s[0] * f[2] - f[0] * s[2]) * inv,
219            -(s[0] * f[1] - f[0] * s[1]) * inv,
220        ],
221        [
222            (s[1] * h[2] - h[1] * s[2]) * inv,
223            -(s[0] * h[2] - h[0] * s[2]) * inv,
224            (s[0] * h[1] - h[0] * s[1]) * inv,
225        ],
226    ])
227}
228
229#[inline]
230fn mat_apply(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
231    [
232        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
233        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
234        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
235    ]
236}
237
238/// Cast one ray (already in the sprite's local voxel space) into the
239/// dense KV6 and return `(colour, t)` of the first solid voxel — `t` is
240/// the world-units ray parameter (shared with the world ray).
241#[allow(clippy::cast_possible_truncation)]
242fn cast_local(dense: &SpriteDense, origin: [f32; 3], dir: [f32; 3]) -> Option<(u32, f32)> {
243    #[allow(clippy::cast_precision_loss)]
244    let hi = [
245        dense.dims[0] as f32,
246        dense.dims[1] as f32,
247        dense.dims[2] as f32,
248    ];
249    let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
250    let start = t0 + 1e-4;
251    let p = [
252        origin[0] + dir[0] * start,
253        origin[1] + dir[1] * start,
254        origin[2] + dir[2] * start,
255    ];
256    let mut cell = [
257        (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
258        (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
259        (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
260    ];
261    let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
262    let mut t_curr = t0;
263    let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
264    for _ in 0..max_steps {
265        if cell[0] < 0
266            || cell[0] >= dense.dims[0]
267            || cell[1] < 0
268            || cell[1] >= dense.dims[1]
269            || cell[2] < 0
270            || cell[2] >= dense.dims[2]
271            || t_curr > t1
272        {
273            return None;
274        }
275        if let Some(color) = dense.at(cell) {
276            return Some((color, t_curr));
277        }
278        let axis = min_axis(t_max);
279        t_curr = t_max[axis];
280        cell[axis] += step[axis];
281        t_max[axis] += t_delta[axis];
282    }
283    None
284}
285
286/// Material context for a translucent sprite draw (TV stage): the global
287/// [`MaterialTable`] plus this instance's uniform material id and per-frame
288/// alpha multiplier. Passed (as `Some`) to [`draw_sprite_dense_shaded`] /
289/// [`ClipFlipbook::draw_frame_shaded`] to enable front-to-back
290/// accumulate-and-continue compositing; `None` (or an all-opaque effective
291/// material) takes the existing first-hit opaque path byte-for-byte.
292#[derive(Clone, Copy)]
293pub struct SpriteShade<'a> {
294    /// Global voxel-material palette (per-voxel id → opacity + blend mode).
295    pub materials: &'a MaterialTable,
296    /// Uniform material id for every voxel of this sprite whose dense
297    /// per-voxel `mat` array is empty (the TV.1 whole-sprite material).
298    pub material: u8,
299    /// Per-instance opacity multiplier (`255` = unscaled), so an effect can
300    /// fade out by cheap per-frame updates without re-uploading the volume.
301    pub alpha_mul: u8,
302}
303
304/// Accumulated front-to-back composite for one ray through a sprite.
305struct LayerAccum {
306    /// Premultiplied accumulated colour, channels in `0..=~1` (additive may
307    /// exceed 1; clamped at pack time).
308    rgb: [f32; 3],
309    /// Remaining transmittance (starts 1.0, decays through `AlphaBlend`).
310    trans: f32,
311    /// The opaque/background hit that terminated the march, if any: its
312    /// already-shaded packed colour + world-ray parameter `t`. `None` if the
313    /// ray exited (or fully attenuated) without an opaque voxel — then the
314    /// background is whatever the framebuffer already holds (terrain/sky).
315    opaque: Option<(u32, f32)>,
316}
317
318/// Unpack a packed `0x..RRGGBB` colour to linear-ish `0..1` float channels
319/// (RGB only; the high byte is ignored here — sprite voxels are flat-lit).
320#[inline]
321fn rgb_to_f32(c: u32) -> [f32; 3] {
322    [
323        ((c >> 16) & 0xff) as f32 / 255.0,
324        ((c >> 8) & 0xff) as f32 / 255.0,
325        (c & 0xff) as f32 / 255.0,
326    ]
327}
328
329/// Repack `0..1` float channels (clamped) into `0x80RRGGBB` — the
330/// full-brightness packing the flat-lit sprite path writes.
331#[inline]
332#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
333fn f32_to_rgb(c: [f32; 3]) -> u32 {
334    let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
335    0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
336}
337
338/// Cast one ray (in sprite-local voxel space) accumulating translucent
339/// voxels front-to-back until an opaque voxel, transmittance exhaustion, or
340/// the `max_t` cutoff (the terrain depth, so the march stops at geometry it
341/// can't see past). `fwd_dot = dir·camera-forward` converts the ray
342/// parameter to perpendicular depth. Returns `None` if the ray contributes
343/// nothing (missed the box, or every voxel was clipped / behind terrain).
344#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
345fn cast_local_layers(
346    dense: &SpriteDense,
347    origin: [f32; 3],
348    dir: [f32; 3],
349    fwd_dot: f32,
350    max_t: f32,
351    shade_ctx: SpriteShade,
352) -> Option<LayerAccum> {
353    #[allow(clippy::cast_precision_loss)]
354    let hi = [
355        dense.dims[0] as f32,
356        dense.dims[1] as f32,
357        dense.dims[2] as f32,
358    ];
359    let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
360    let start = t0 + 1e-4;
361    let p = [
362        origin[0] + dir[0] * start,
363        origin[1] + dir[1] * start,
364        origin[2] + dir[2] * start,
365    ];
366    let mut cell = [
367        (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
368        (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
369        (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
370    ];
371    let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
372    let mut t_curr = t0;
373    let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
374
375    let mut acc = LayerAccum {
376        rgb: [0.0; 3],
377        trans: 1.0,
378        opaque: None,
379    };
380    let mut touched = false;
381    // Per-span compositing: a translucent voxel contributes one alpha layer
382    // only when the ray *enters* a contiguous solid run (the previous cell
383    // was air). Without this, a ray clipping the shared boundary between two
384    // adjacent surface voxels passes through both and double-composites a thin
385    // strip — the model reads as "diced" by a voxel grid. Treating each solid
386    // run as one surface makes a wall contribute exactly one alpha regardless
387    // of how many of its voxels the ray grazes. A run is also re-entered on a
388    // material change (TV.3: two adjacent translucent materials each count),
389    // and an opaque voxel stops the ray on every cell (the opaque core of a
390    // mixed model).
391    let mut prev_solid = false;
392    let mut prev_mat = 0u8;
393    // Local ray length per ray-parameter unit — converts a cell's `t` span to
394    // its path length in voxel units for the `Volumetric` Beer–Lambert weight.
395    let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
396
397    for _ in 0..max_steps {
398        if cell[0] < 0
399            || cell[0] >= dense.dims[0]
400            || cell[1] < 0
401            || cell[1] >= dense.dims[1]
402            || cell[2] < 0
403            || cell[2] >= dense.dims[2]
404            || t_curr > t1
405        {
406            break;
407        }
408        // Stop at the terrain depth: everything past it is occluded, and the
409        // already-drawn framebuffer pixel becomes the background.
410        let depth = t_curr * fwd_dot;
411        if depth >= max_t {
412            break;
413        }
414        // Exit `t` of the current cell — the next boundary crossing. Its span
415        // from `t_curr` is the ray's path through this cell (Volumetric).
416        let exit_axis = min_axis(t_max);
417        let t_exit = t_max[exit_axis];
418        let idx = dense.idx_of(cell);
419        let solid_here = dense.occ[idx];
420        if solid_here && depth >= NEAR_Z {
421            let mat_id = if dense.mat.is_empty() {
422                shade_ctx.material
423            } else {
424                dense.mat[idx]
425            };
426            let m = shade_ctx.materials.get(mat_id);
427            if m.is_opaque() {
428                acc.opaque = Some((shade(dense.col[idx], 0), t_curr));
429                touched = true;
430                break;
431            }
432            let a = f32::from(m.alpha) / 255.0 * (f32::from(shade_ctx.alpha_mul) / 255.0);
433            if m.mode == BlendMode::Volumetric {
434                // Per-cell Beer–Lambert: opacity weighted by traversed length
435                // (in voxel units), so a thin sliver contributes ≈0 and a
436                // filled volume thickens smoothly with depth. Always occludes.
437                let seg_len = (t_exit - t_curr).max(0.0) * dir_len;
438                let eff_a = 1.0 - (1.0 - a).powf(seg_len);
439                let lit = rgb_to_f32(shade(dense.col[idx], 0));
440                acc.rgb[0] += acc.trans * eff_a * lit[0];
441                acc.rgb[1] += acc.trans * eff_a * lit[1];
442                acc.rgb[2] += acc.trans * eff_a * lit[2];
443                acc.trans *= 1.0 - eff_a;
444                touched = true;
445                prev_mat = mat_id;
446                if acc.trans < 1.0 / 256.0 {
447                    break;
448                }
449            } else if !prev_solid || mat_id != prev_mat {
450                // AlphaBlend / Additive: one alpha layer per solid-run entry or
451                // material change (thickness-independent — shells, glass).
452                let lit = rgb_to_f32(shade(dense.col[idx], 0));
453                acc.rgb[0] += acc.trans * a * lit[0];
454                acc.rgb[1] += acc.trans * a * lit[1];
455                acc.rgb[2] += acc.trans * a * lit[2];
456                if m.mode == BlendMode::AlphaBlend {
457                    acc.trans *= 1.0 - a; // Additive glow does not occlude.
458                }
459                touched = true;
460                prev_mat = mat_id;
461                if acc.trans < 1.0 / 256.0 {
462                    break;
463                }
464            }
465        }
466        prev_solid = solid_here;
467        t_curr = t_exit;
468        cell[exit_axis] += step[exit_axis];
469        t_max[exit_axis] += t_delta[exit_axis];
470    }
471
472    touched.then_some(acc)
473}
474
475/// Draw one KV6 [`Sprite`] into `(fb, zb)` by per-pixel ray casting,
476/// depth-compositing against whatever the terrain pass already wrote.
477/// Returns the number of pixels written.
478///
479/// `cam` / `settings` are the **same** per-frame projection the DDA
480/// terrain pass used (build via [`crate::camera_math::derive`]), so
481/// sprite and terrain share one pinhole and z convention. `pitch_pixels`
482/// is the framebuffer row stride. Honours `SPRITE_FLAG_INVISIBLE`
483/// (skip) and `SPRITE_FLAG_NO_Z` (write without the depth test).
484#[allow(
485    clippy::too_many_arguments,
486    clippy::cast_possible_truncation,
487    clippy::cast_sign_loss
488)]
489#[must_use]
490pub fn draw_sprite_dda(
491    fb: &mut [u32],
492    zb: &mut [f32],
493    pitch_pixels: usize,
494    width: u32,
495    height: u32,
496    cam: &CameraState,
497    settings: &OpticastSettings,
498    sprite: &Sprite,
499) -> u32 {
500    if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
501        return 0;
502    }
503    draw_sprite_dda_shaded(
504        fb,
505        zb,
506        pitch_pixels,
507        width,
508        height,
509        cam,
510        settings,
511        sprite,
512        None,
513    )
514}
515
516/// Draw one KV6 [`Sprite`], optionally with a translucent material (TV
517/// stage) — the [`draw_sprite_dense_shaded`] counterpart of
518/// [`draw_sprite_dda`]. `shade_ctx == None` (or an opaque effective
519/// material) renders the sprite opaque, byte-for-byte unchanged.
520#[allow(clippy::too_many_arguments)]
521#[must_use]
522pub fn draw_sprite_dda_shaded(
523    fb: &mut [u32],
524    zb: &mut [f32],
525    pitch_pixels: usize,
526    width: u32,
527    height: u32,
528    cam: &CameraState,
529    settings: &OpticastSettings,
530    sprite: &Sprite,
531    shade_ctx: Option<SpriteShade>,
532) -> u32 {
533    if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
534        return 0;
535    }
536    // Decodes the KV6 to a dense grid each call (the per-frame cost an
537    // animated clip avoids via [`ClipFlipbook`]'s cached grids). A non-empty
538    // `material_map` classifies voxels into per-voxel materials (TV.3 mixed
539    // models); an empty one is the plain uniform-material decode.
540    let dense = if sprite.material_map.is_empty() {
541        SpriteDense::from_kv6(&sprite.kv6)
542    } else {
543        SpriteDense::from_kv6_with_materials(&sprite.kv6, &sprite.material_map)
544    };
545    draw_sprite_dense_shaded(
546        fb,
547        zb,
548        pitch_pixels,
549        width,
550        height,
551        cam,
552        settings,
553        &dense,
554        sprite.p,
555        sprite.s,
556        sprite.h,
557        sprite.f,
558        sprite.flags,
559        shade_ctx,
560    )
561}
562
563/// Draw a pre-decoded [`SpriteDense`] at a world pose — the generalised
564/// core of [`draw_sprite_dda`], shared by the KV6 path and animated
565/// [`ClipFlipbook`] frames. `pos` is the world pivot; `s`/`h`/`f` are the
566/// model→world basis columns (local +x/+y/+z); `flags` honours
567/// [`SPRITE_FLAG_INVISIBLE`] / [`SPRITE_FLAG_NO_Z`]. Returns pixels written.
568///
569/// Fully opaque (the existing first-hit path). For translucent sprites use
570/// [`draw_sprite_dense_shaded`].
571#[allow(clippy::too_many_arguments)]
572#[must_use]
573pub fn draw_sprite_dense(
574    fb: &mut [u32],
575    zb: &mut [f32],
576    pitch_pixels: usize,
577    width: u32,
578    height: u32,
579    cam: &CameraState,
580    settings: &OpticastSettings,
581    dense: &SpriteDense,
582    pos: [f32; 3],
583    s: [f32; 3],
584    h: [f32; 3],
585    f: [f32; 3],
586    flags: u32,
587) -> u32 {
588    draw_sprite_dense_shaded(
589        fb,
590        zb,
591        pitch_pixels,
592        width,
593        height,
594        cam,
595        settings,
596        dense,
597        pos,
598        s,
599        h,
600        f,
601        flags,
602        None,
603    )
604}
605
606/// Draw a pre-decoded [`SpriteDense`] at a world pose, optionally with a
607/// translucent material (TV stage). `shade_ctx`:
608/// - `None`, or a `Some` whose effective material is opaque ⇒ the existing
609///   first-hit, depth-tested opaque path, **byte-for-byte unchanged**.
610/// - `Some` with a translucent uniform material (or a non-empty per-voxel
611///   `mat` array) ⇒ front-to-back accumulate-and-continue: each ray marches
612///   through the sprite compositing `AlphaBlend`/`Additive` layers over what
613///   lies behind (terrain/sky already in the framebuffer, or an opaque voxel
614///   of the model). Opaque voxels write the model surface depth; purely
615///   translucent pixels composite over the framebuffer without touching the
616///   z-buffer (they do not occlude). See `PORTING-TRANSPARENCY.md`.
617#[allow(
618    clippy::too_many_arguments,
619    clippy::cast_possible_truncation,
620    clippy::cast_sign_loss
621)]
622#[must_use]
623pub fn draw_sprite_dense_shaded(
624    fb: &mut [u32],
625    zb: &mut [f32],
626    pitch_pixels: usize,
627    width: u32,
628    height: u32,
629    cam: &CameraState,
630    settings: &OpticastSettings,
631    dense: &SpriteDense,
632    pos: [f32; 3],
633    s: [f32; 3],
634    h: [f32; 3],
635    f: [f32; 3],
636    flags: u32,
637    shade_ctx: Option<SpriteShade>,
638) -> u32 {
639    if flags & SPRITE_FLAG_INVISIBLE != 0 || dense.occ.is_empty() {
640        return 0;
641    }
642    let Some(minv) = invert_basis(s, h, f) else {
643        return 0;
644    };
645    let pivot = dense.pivot;
646    let no_z = flags & SPRITE_FLAG_NO_Z != 0;
647
648    // Screen bounding box from the 8 corners of the local voxel box.
649    let Some(rect) = project_screen_rect(dense, pos, s, h, f, cam, settings, width, height) else {
650        return 0;
651    };
652
653    // Per-sprite gate: a sprite whose effective material is opaque (the
654    // common case, and every sprite while no translucent material is
655    // defined) takes the original loop unchanged — so the opaque world stays
656    // bit-identical. Only a genuinely translucent sprite runs the accumulate
657    // loop.
658    let layers =
659        shade_ctx.filter(|s| !dense.mat.is_empty() || !s.materials.get(s.material).is_opaque());
660
661    debug_assert_eq!(fb.len(), zb.len());
662    let target = RasterTarget::new(fb, zb);
663    let mut written = 0u32;
664    for py in rect.1..rect.3 {
665        let row = py as usize * pitch_pixels;
666        for px in rect.0..rect.2 {
667            let (origin, dir) = pixel_ray(cam, settings, px, py);
668            // World ray → sprite-local voxel space.
669            let rel = [origin[0] - pos[0], origin[1] - pos[1], origin[2] - pos[2]];
670            let ol = mat_apply(&minv, rel);
671            let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
672            let dir_local = mat_apply(&minv, dir);
673            let fwd_dot =
674                dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
675            let idx = row + px as usize;
676
677            if let Some(shade_ctx) = layers {
678                // ---- translucent: accumulate front-to-back ----
679                if fwd_dot <= 1e-6 {
680                    continue;
681                }
682                // Terrain/opaque depth cutoff (perpendicular distance, the
683                // same units `cast_local_layers` compares `t_curr·fwd_dot`
684                // against — NOT a ray parameter, since the CPU `dir` is
685                // unnormalised). SAFETY: idx in rect ⊂ (width,height).
686                let max_t = if no_z {
687                    f32::INFINITY
688                } else {
689                    unsafe { target.read_depth(idx) }
690                };
691                let Some(acc) =
692                    cast_local_layers(dense, origin_local, dir_local, fwd_dot, max_t, shade_ctx)
693                else {
694                    continue;
695                };
696                // SAFETY: idx in bounds; single-threaded writer.
697                let wrote = unsafe {
698                    match acc.opaque {
699                        Some((bg_color, t)) => {
700                            // Opaque model surface behind the translucent
701                            // layers: composite over it, write surface depth.
702                            let bg = rgb_to_f32(bg_color);
703                            let out = f32_to_rgb([
704                                acc.rgb[0] + acc.trans * bg[0],
705                                acc.rgb[1] + acc.trans * bg[1],
706                                acc.rgb[2] + acc.trans * bg[2],
707                            ]);
708                            let depth = t * fwd_dot;
709                            if no_z {
710                                target.write_color(idx, out);
711                                target.write_depth(idx, depth);
712                                true
713                            } else {
714                                target.z_test_write(idx, out, depth)
715                            }
716                        }
717                        None => {
718                            // Ray exited (or fully attenuated) with no opaque
719                            // model voxel: composite over the framebuffer
720                            // (terrain/sky). Translucent layers do not occlude,
721                            // so the z-buffer is left untouched.
722                            let bg = rgb_to_f32(target.read_color(idx));
723                            let out = f32_to_rgb([
724                                acc.rgb[0] + acc.trans * bg[0],
725                                acc.rgb[1] + acc.trans * bg[1],
726                                acc.rgb[2] + acc.trans * bg[2],
727                            ]);
728                            target.write_color(idx, out);
729                            true
730                        }
731                    }
732                };
733                written += u32::from(wrote);
734            } else {
735                // ---- opaque: original first-hit path (unchanged) ----
736                let Some((color, t)) = cast_local(dense, origin_local, dir_local) else {
737                    continue;
738                };
739                let depth = t * fwd_dot;
740                if depth < NEAR_Z {
741                    continue;
742                }
743                let lit = shade(color, 0);
744                // SAFETY: idx in-bounds for the rect within (width, height);
745                // single-threaded writer.
746                let wrote = unsafe {
747                    if no_z {
748                        target.write_color(idx, lit);
749                        target.write_depth(idx, depth);
750                        true
751                    } else {
752                        target.z_test_write(idx, lit, depth)
753                    }
754                };
755                written += u32::from(wrote);
756            }
757        }
758    }
759    written
760}
761
762/// Project the sprite's local voxel AABB to a clamped screen rectangle
763/// `(x0, y0, x1, y1)` (half-open). `None` if it can't appear; falls back
764/// to the full viewport when the box straddles the near plane (rare).
765#[allow(
766    clippy::cast_possible_truncation,
767    clippy::cast_sign_loss,
768    clippy::cast_precision_loss
769)]
770fn project_screen_rect(
771    dense: &SpriteDense,
772    pos: [f32; 3],
773    s: [f32; 3],
774    h: [f32; 3],
775    f: [f32; 3],
776    cam: &CameraState,
777    settings: &OpticastSettings,
778    width: u32,
779    height: u32,
780) -> Option<(u32, u32, u32, u32)> {
781    let (xs, ys, zs) = (
782        dense.dims[0] as f32,
783        dense.dims[1] as f32,
784        dense.dims[2] as f32,
785    );
786    let (xp, yp, zp) = (dense.pivot[0], dense.pivot[1], dense.pivot[2]);
787    let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
788    let mut all_front = true;
789    for &cx in &[0.0, xs] {
790        for &cy in &[0.0, ys] {
791            for &cz in &[0.0, zs] {
792                // Local → world via the sprite basis about the pivot.
793                let lx = cx - xp;
794                let ly = cy - yp;
795                let lz = cz - zp;
796                let world = [
797                    pos[0] + lx * s[0] + ly * h[0] + lz * f[0],
798                    pos[1] + lx * s[1] + ly * h[1] + lz * f[1],
799                    pos[2] + lx * s[2] + ly * h[2] + lz * f[2],
800                ];
801                let rel = [
802                    world[0] - cam.pos[0],
803                    world[1] - cam.pos[1],
804                    world[2] - cam.pos[2],
805                ];
806                let cz_cam =
807                    rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
808                if cz_cam < NEAR_Z {
809                    all_front = false;
810                    continue;
811                }
812                let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
813                let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
814                let sx = settings.hx + cx_cam / cz_cam * settings.hz;
815                let sy = settings.hy + cy_cam / cz_cam * settings.hz;
816                x0 = x0.min(sx);
817                y0 = y0.min(sy);
818                x1 = x1.max(sx);
819                y1 = y1.max(sy);
820            }
821        }
822    }
823    let (w, h) = (width as f32, height as f32);
824    let (rx0, ry0, rx1, ry1) = if all_front {
825        (
826            (x0 - 1.0).max(0.0),
827            (y0 - 1.0).max(0.0),
828            (x1 + 1.0).min(w),
829            (y1 + 1.0).min(h),
830        )
831    } else {
832        // Straddles the near plane → scan the whole viewport.
833        (0.0, 0.0, w, h)
834    };
835    if rx0 >= rx1 || ry0 >= ry1 {
836        return None;
837    }
838    Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
839}
840
841/// CPU-side decoded animated voxel clip: every frame's [`SpriteDense`]
842/// is cached at construction, so per-frame playback is a grid **select**
843/// — not the per-frame voxel-volume decode [`draw_sprite_dda`] pays each
844/// call. The CPU counterpart to the GPU flipbook (VCL.2). Build once from
845/// a [`DecodedClip`], then [`draw_frame`](ClipFlipbook::draw_frame) the
846/// active frame each render.
847pub struct ClipFlipbook {
848    frames: Vec<SpriteDense>,
849}
850
851impl ClipFlipbook {
852    /// An empty flipbook (no frames) — a tombstone for a removed clip;
853    /// [`draw_frame`](Self::draw_frame) always draws nothing.
854    #[must_use]
855    pub fn empty() -> Self {
856        Self { frames: Vec::new() }
857    }
858
859    /// Decode + cache every frame of `clip` (one [`SpriteDense`] each).
860    #[must_use]
861    pub fn from_decoded(clip: &DecodedClip) -> Self {
862        Self::from_decoded_with_materials(clip, &[])
863    }
864
865    /// Like [`from_decoded`](Self::from_decoded) but classifies every frame's
866    /// voxels into per-voxel material ids by colour (TV.3 mixed models) via
867    /// `material_map` — the clip analogue of
868    /// [`SpriteDense::from_kv6_with_materials`]. An empty map yields the same
869    /// all-opaque result as `from_decoded`.
870    #[must_use]
871    pub fn from_decoded_with_materials(clip: &DecodedClip, material_map: &[(u32, u8)]) -> Self {
872        let frames = clip
873            .frames
874            .iter()
875            .map(|frame| {
876                SpriteDense::from_voxel_frame_with_materials(
877                    frame,
878                    clip.dims,
879                    clip.pivot,
880                    material_map,
881                )
882            })
883            .collect();
884        Self { frames }
885    }
886
887    #[must_use]
888    pub fn frame_count(&self) -> usize {
889        self.frames.len()
890    }
891
892    /// Borrow frame `frame`'s cached dense grid, if in range.
893    #[must_use]
894    pub fn frame(&self, frame: usize) -> Option<&SpriteDense> {
895        self.frames.get(frame)
896    }
897
898    /// Replace one frame's cached dense grid in place — the CPU side of an
899    /// editor's single-frame edit (no re-decode of the other frames).
900    /// Returns `false` if `frame` is out of range.
901    pub fn set_frame(&mut self, frame: usize, dense: SpriteDense) -> bool {
902        match self.frames.get_mut(frame) {
903            Some(slot) => {
904                *slot = dense;
905                true
906            }
907            None => false,
908        }
909    }
910
911    /// Draw frame `frame` at a world pose via [`draw_sprite_dense`] —
912    /// `pos` is the world pivot, `s`/`h`/`f` the model→world basis columns.
913    /// Returns pixels written (0 if `frame` is out of range).
914    #[allow(clippy::too_many_arguments)]
915    #[must_use]
916    pub fn draw_frame(
917        &self,
918        fb: &mut [u32],
919        zb: &mut [f32],
920        pitch_pixels: usize,
921        width: u32,
922        height: u32,
923        cam: &CameraState,
924        settings: &OpticastSettings,
925        frame: usize,
926        pos: [f32; 3],
927        s: [f32; 3],
928        h: [f32; 3],
929        f: [f32; 3],
930        flags: u32,
931    ) -> u32 {
932        self.draw_frame_shaded(
933            fb,
934            zb,
935            pitch_pixels,
936            width,
937            height,
938            cam,
939            settings,
940            frame,
941            pos,
942            s,
943            h,
944            f,
945            flags,
946            None,
947        )
948    }
949
950    /// Draw frame `frame`, optionally with a translucent material (TV stage)
951    /// — the [`draw_sprite_dense_shaded`] counterpart of
952    /// [`draw_frame`](Self::draw_frame). `shade_ctx == None` (or an opaque
953    /// effective material) renders the frame opaque, unchanged.
954    #[allow(clippy::too_many_arguments)]
955    #[must_use]
956    pub fn draw_frame_shaded(
957        &self,
958        fb: &mut [u32],
959        zb: &mut [f32],
960        pitch_pixels: usize,
961        width: u32,
962        height: u32,
963        cam: &CameraState,
964        settings: &OpticastSettings,
965        frame: usize,
966        pos: [f32; 3],
967        s: [f32; 3],
968        h: [f32; 3],
969        f: [f32; 3],
970        flags: u32,
971        shade_ctx: Option<SpriteShade>,
972    ) -> u32 {
973        let Some(dense) = self.frames.get(frame) else {
974            return 0;
975        };
976        draw_sprite_dense_shaded(
977            fb,
978            zb,
979            pitch_pixels,
980            width,
981            height,
982            cam,
983            settings,
984            dense,
985            pos,
986            s,
987            h,
988            f,
989            flags,
990            shade_ctx,
991        )
992    }
993}
994
995#[cfg(test)]
996mod tests {
997    use super::*;
998    use crate::camera_math;
999    use crate::Camera;
1000    use roxlap_formats::kv6::Kv6;
1001    use roxlap_formats::material::{Material, MaterialTable};
1002    use roxlap_formats::sprite::Sprite;
1003    use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
1004
1005    fn settings(w: u32, h: u32) -> OpticastSettings {
1006        OpticastSettings::for_oracle_framebuffer(w, h)
1007    }
1008
1009    /// Camera at the origin looking down +y at a sprite ahead.
1010    fn cam_looking_y() -> Camera {
1011        Camera {
1012            pos: [0.0, 0.0, 0.0],
1013            right: [1.0, 0.0, 0.0],
1014            down: [0.0, 0.0, 1.0],
1015            forward: [0.0, 1.0, 0.0],
1016        }
1017    }
1018
1019    /// Build a [`VoxelFrame`] from a dense `fill(x,y,z) -> Option<color>`.
1020    fn clip_frame(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1021        let owpc = dims[2].div_ceil(32).max(1) as usize;
1022        let cols = (dims[0] * dims[1]) as usize;
1023        let mut occupancy = vec![0u32; cols * owpc];
1024        let mut color_offsets = vec![0u32; cols + 1];
1025        let mut colors = Vec::new();
1026        for y in 0..dims[1] {
1027            for x in 0..dims[0] {
1028                let col = (x + y * dims[0]) as usize;
1029                color_offsets[col] = colors.len() as u32;
1030                for z in 0..dims[2] {
1031                    if let Some(c) = fill(x, y, z) {
1032                        occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1033                        colors.push(c);
1034                    }
1035                }
1036            }
1037        }
1038        color_offsets[cols] = colors.len() as u32;
1039        VoxelFrame {
1040            occupancy,
1041            colors,
1042            color_offsets,
1043        }
1044    }
1045
1046    /// A cached [`ClipFlipbook`] draws distinct frames distinctly — the
1047    /// CPU flipbook select. Frame 0 fills the bottom half (red), frame 1
1048    /// the top half (green); rendered at the same pose they cover
1049    /// different screen pixels in different colours.
1050    #[test]
1051    fn clip_flipbook_frames_render_differently() {
1052        let dims = [8u32, 8, 8];
1053        let f0 = clip_frame(dims, |_x, _y, z| (z < 4).then_some(0x00FF_0000)); // red, low z
1054        let f1 = clip_frame(dims, |_x, _y, z| (z >= 4).then_some(0x0000_FF00)); // green, high z
1055        let clip = VoxelClip::from_frames(
1056            dims,
1057            [4.0, 4.0, 4.0],
1058            1.0,
1059            LoopMode::Loop,
1060            &[f0, f1],
1061            &[],
1062            33,
1063            0,
1064        );
1065        let decoded = clip.decode().expect("decode");
1066        let book = ClipFlipbook::from_decoded(&decoded);
1067        assert_eq!(book.frame_count(), 2);
1068        assert!(book.frame(0).is_some() && book.frame(2).is_none());
1069
1070        let (w, h) = (64u32, 64u32);
1071        let n = (w * h) as usize;
1072        let cam = cam_looking_y();
1073        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1074        let cfg = settings(w, h);
1075        let pose = [0.0, 40.0, 0.0];
1076        let (s, hh, f) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
1077
1078        let render = |frame: usize| -> Vec<u32> {
1079            let mut fb = vec![0u32; n];
1080            let mut zb = vec![f32::INFINITY; n];
1081            let wrote = book.draw_frame(
1082                &mut fb, &mut zb, w as usize, w, h, &cs, &cfg, frame, pose, s, hh, f, 0,
1083            );
1084            assert!(wrote > 0, "frame {frame} should draw some pixels");
1085            fb
1086        };
1087        let fb0 = render(0);
1088        let fb1 = render(1);
1089        assert_ne!(fb0, fb1, "distinct frames must render distinct pixels");
1090        // Each frame shows its own channel: red present in frame 0, green
1091        // present in frame 1.
1092        assert!(fb0.iter().any(|&p| (p & 0x00FF_0000) != 0));
1093        assert!(fb1.iter().any(|&p| (p & 0x0000_FF00) != 0));
1094        // Out-of-range frame draws nothing.
1095        let mut fb = vec![0u32; n];
1096        let mut zb = vec![f32::INFINITY; n];
1097        assert_eq!(
1098            book.draw_frame(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, 9, pose, s, hh, f, 0),
1099            0
1100        );
1101    }
1102
1103    #[test]
1104    fn clip_flipbook_set_frame_replaces_one_frame() {
1105        // The single-frame edit primitive: replace frame 0's dense with
1106        // frame 1's content, in place. Out-of-range → false.
1107        let dims = [8u32, 8, 8];
1108        let f0 = clip_frame(dims, |_, _, z| (z < 4).then_some(0x00FF_0000)); // red
1109        let f1 = clip_frame(dims, |_, _, z| (z >= 4).then_some(0x0000_FF00)); // green
1110        let clip =
1111            VoxelClip::from_frames(dims, [4.0; 3], 1.0, LoopMode::Loop, &[f0, f1], &[], 33, 0);
1112        let decoded = clip.decode().unwrap();
1113        let mut book = ClipFlipbook::from_decoded(&decoded);
1114
1115        let (w, h) = (64u32, 64u32);
1116        let n = (w * h) as usize;
1117        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1118        let cfg = settings(w, h);
1119        let render0 = |b: &ClipFlipbook| -> Vec<u32> {
1120            let mut fb = vec![0u32; n];
1121            let mut zb = vec![f32::INFINITY; n];
1122            let _ = b.draw_frame(
1123                &mut fb,
1124                &mut zb,
1125                w as usize,
1126                w,
1127                h,
1128                &cs,
1129                &cfg,
1130                0,
1131                [0.0, 40.0, 0.0],
1132                [1.0, 0.0, 0.0],
1133                [0.0, 1.0, 0.0],
1134                [0.0, 0.0, 1.0],
1135                0,
1136            );
1137            fb
1138        };
1139
1140        let before = render0(&book);
1141        assert!(
1142            before.iter().any(|&p| (p & 0x00FF_0000) != 0),
1143            "frame 0 is red"
1144        );
1145
1146        // Replace frame 0 with frame 1's dense.
1147        let replacement = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1148        assert!(book.set_frame(0, replacement));
1149        let extra = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1150        assert!(!book.set_frame(9, extra), "out-of-range set_frame is false");
1151
1152        let after = render0(&book);
1153        assert!(
1154            after.iter().any(|&p| (p & 0x0000_FF00) != 0),
1155            "frame 0 now green"
1156        );
1157        assert_ne!(before, after);
1158    }
1159
1160    /// A solid cube sprite in front of the camera is drawn, with the
1161    /// cube colour (shaded) and a sensible centre depth.
1162    #[test]
1163    fn cube_sprite_renders() {
1164        let kv6 = Kv6::solid_cube(8, 0x80_C0_40_20);
1165        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1166        let (w, h) = (64u32, 64u32);
1167        let n = (w * h) as usize;
1168        let mut fb = vec![0u32; n];
1169        let mut zb = vec![f32::INFINITY; n];
1170        let cam = cam_looking_y();
1171        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1172        let wrote = draw_sprite_dda(
1173            &mut fb,
1174            &mut zb,
1175            w as usize,
1176            w,
1177            h,
1178            &cs,
1179            &settings(w, h),
1180            &sprite,
1181        );
1182
1183        assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1184        let centre = (h / 2 * w + w / 2) as usize;
1185        assert_eq!(
1186            fb[centre] & 0x00ff_ffff,
1187            0x00_C0_40_20,
1188            "got {:08x}",
1189            fb[centre]
1190        );
1191        // Pivot at world y=40, cube spans y in [36,44] → near face ~36.
1192        assert!(
1193            (zb[centre] - 36.0).abs() < 3.0,
1194            "centre depth {} not ≈ 36",
1195            zb[centre]
1196        );
1197    }
1198
1199    /// A KV6 whose voxel colours store a `0x00` high byte (voxlap's
1200    /// unused `dir` slot, e.g. `sprite_meltsphere.kv6`) must still
1201    /// render its authored RGB, not black — the brightness byte is
1202    /// normalised to full on decode.
1203    #[test]
1204    fn zero_high_byte_sprite_not_black() {
1205        let kv6 = Kv6::solid_cube(8, 0x00_C0_40_20);
1206        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1207        let (w, h) = (64u32, 64u32);
1208        let n = (w * h) as usize;
1209        let mut fb = vec![0u32; n];
1210        let mut zb = vec![f32::INFINITY; n];
1211        let cam = cam_looking_y();
1212        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1213        let wrote = draw_sprite_dda(
1214            &mut fb,
1215            &mut zb,
1216            w as usize,
1217            w,
1218            h,
1219            &cs,
1220            &settings(w, h),
1221            &sprite,
1222        );
1223        assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1224        let centre = (h / 2 * w + w / 2) as usize;
1225        assert_eq!(
1226            fb[centre] & 0x00ff_ffff,
1227            0x00_C0_40_20,
1228            "zero-high-byte sprite rendered as {:08x} (black bug)",
1229            fb[centre]
1230        );
1231    }
1232
1233    /// A sprite occludes / is occluded by the z-buffer: a nearer
1234    /// pre-filled depth blocks the sprite; a farther one lets it win.
1235    #[test]
1236    fn sprite_respects_zbuffer() {
1237        let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1238        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1239        let (w, h) = (32u32, 32u32);
1240        let n = (w * h) as usize;
1241        let cam = cam_looking_y();
1242        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1243        let centre = (h / 2 * w + w / 2) as usize;
1244
1245        // Terrain in front (depth 10 < ~36) → sprite blocked at centre.
1246        let mut fb = vec![0u32; n];
1247        let mut zb = vec![f32::INFINITY; n];
1248        fb[centre] = 0x80_11_22_33;
1249        zb[centre] = 10.0;
1250        let _ = draw_sprite_dda(
1251            &mut fb,
1252            &mut zb,
1253            w as usize,
1254            w,
1255            h,
1256            &cs,
1257            &settings(w, h),
1258            &sprite,
1259        );
1260        assert_eq!(
1261            fb[centre], 0x80_11_22_33,
1262            "near terrain must occlude sprite"
1263        );
1264
1265        // Terrain behind (depth 100) → sprite wins.
1266        let mut fb2 = vec![0u32; n];
1267        let mut zb2 = vec![f32::INFINITY; n];
1268        fb2[centre] = 0x80_11_22_33;
1269        zb2[centre] = 100.0;
1270        let _ = draw_sprite_dda(
1271            &mut fb2,
1272            &mut zb2,
1273            w as usize,
1274            w,
1275            h,
1276            &cs,
1277            &settings(w, h),
1278            &sprite,
1279        );
1280        assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
1281        assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
1282    }
1283
1284    /// The covered screen rect (min/max px,py) of whatever the sprite
1285    /// painted — used to compare an axis-aligned vs a rotated pose.
1286    fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
1287        let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
1288        for py in 0..h {
1289            for px in 0..w {
1290                if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
1291                    x0 = x0.min(px);
1292                    y0 = y0.min(py);
1293                    x1 = x1.max(px);
1294                    y1 = y1.max(py);
1295                }
1296            }
1297        }
1298        (x0, y0, x1, y1)
1299    }
1300
1301    /// A non-cube box drawn axis-aligned vs. drawn with a per-instance
1302    /// transform that swaps its long axis onto the screen's other axis
1303    /// flips the silhouette's aspect ratio. Pins that the `s/h/f` basis
1304    /// (the path `DynSpriteTransform` feeds) actually reorients the model.
1305    #[test]
1306    fn posed_basis_reorients_silhouette() {
1307        // Wide-in-local-x, short-in-local-z box → appears wide on screen
1308        // (screen-x = world-x via `right`, screen-y = world-z via `down`).
1309        let kv6 = Kv6::solid_box(16, 4, 4, 0x80_C0_40_20);
1310        let (w, h) = (64u32, 64u32);
1311        let n = (w * h) as usize;
1312        let cam = cam_looking_y();
1313        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1314
1315        // Axis-aligned: wide silhouette.
1316        let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1317        let mut fb = vec![0u32; n];
1318        let mut zb = vec![f32::INFINITY; n];
1319        let _ = draw_sprite_dda(
1320            &mut fb,
1321            &mut zb,
1322            w as usize,
1323            w,
1324            h,
1325            &cs,
1326            &settings(w, h),
1327            &aa,
1328        );
1329        let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
1330        let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
1331        assert!(
1332            aa_wide > 4,
1333            "axis-aligned box should be wider than tall (got w-h={aa_wide})"
1334        );
1335
1336        // Posed: map local +x onto world +z and local +z onto world +x
1337        // (det = -1 ≠ 0). Same box now reads tall on screen.
1338        let mut posed = aa.clone();
1339        posed.s = [0.0, 0.0, 1.0]; // local +x ↦ world +z (screen down)
1340        posed.h = [0.0, 1.0, 0.0]; // local +y ↦ world +y (depth)
1341        posed.f = [1.0, 0.0, 0.0]; // local +z ↦ world +x (screen right)
1342        let mut fb2 = vec![0u32; n];
1343        let mut zb2 = vec![f32::INFINITY; n];
1344        let _ = draw_sprite_dda(
1345            &mut fb2,
1346            &mut zb2,
1347            w as usize,
1348            w,
1349            h,
1350            &cs,
1351            &settings(w, h),
1352            &posed,
1353        );
1354        let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
1355        let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
1356        assert!(
1357            posed_tall > 4,
1358            "posed box should be taller than wide (got h-w={posed_tall})"
1359        );
1360    }
1361
1362    /// A degenerate (singular) basis — `det == 0` — makes the sprite
1363    /// silently skip rather than panic (the `DynSpriteTransform` guard).
1364    #[test]
1365    fn degenerate_basis_draws_nothing() {
1366        let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1367        let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1368        sprite.f = sprite.s; // two equal columns → det 0
1369        let (w, h) = (32u32, 32u32);
1370        let n = (w * h) as usize;
1371        let mut fb = vec![0u32; n];
1372        let mut zb = vec![f32::INFINITY; n];
1373        let cam = cam_looking_y();
1374        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1375        let wrote = draw_sprite_dda(
1376            &mut fb,
1377            &mut zb,
1378            w as usize,
1379            w,
1380            h,
1381            &cs,
1382            &settings(w, h),
1383            &sprite,
1384        );
1385        assert_eq!(wrote, 0, "singular basis must skip, not panic");
1386    }
1387
1388    /// An invisible sprite draws nothing.
1389    #[test]
1390    fn invisible_sprite_skipped() {
1391        let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1392        let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1393        sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
1394        let (w, h) = (32u32, 32u32);
1395        let n = (w * h) as usize;
1396        let mut fb = vec![0u32; n];
1397        let mut zb = vec![f32::INFINITY; n];
1398        let cam = cam_looking_y();
1399        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1400        let wrote = draw_sprite_dda(
1401            &mut fb,
1402            &mut zb,
1403            w as usize,
1404            w,
1405            h,
1406            &cs,
1407            &settings(w, h),
1408            &sprite,
1409        );
1410        assert_eq!(wrote, 0);
1411    }
1412
1413    // ---------- TV.1a: translucent accumulate-and-continue path ----------
1414
1415    /// Draw a uniform-material 8³ cube (RGB `0xC0_40_20`) at world y=40 over
1416    /// a `bg`-filled framebuffer with z-buffer `zb_v`, using palette id 1 =
1417    /// `mat`. Returns `(centre_pixel, full_framebuffer)`.
1418    fn draw_cube_shaded(mat: Material, alpha_mul: u8, bg: u32, zb_v: f32) -> (u32, Vec<u32>) {
1419        let mut table = MaterialTable::new();
1420        table.set(1, mat);
1421        let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_C0_40_20));
1422        let (w, h) = (64u32, 64u32);
1423        let n = (w * h) as usize;
1424        let mut fb = vec![bg; n];
1425        let mut zb = vec![zb_v; n];
1426        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1427        let sh = SpriteShade {
1428            materials: &table,
1429            material: 1,
1430            alpha_mul,
1431        };
1432        let _ = draw_sprite_dense_shaded(
1433            &mut fb,
1434            &mut zb,
1435            w as usize,
1436            w,
1437            h,
1438            &cs,
1439            &settings(w, h),
1440            &dense,
1441            [0.0, 40.0, 0.0],
1442            [1.0, 0.0, 0.0],
1443            [0.0, 1.0, 0.0],
1444            [0.0, 0.0, 1.0],
1445            0,
1446            Some(sh),
1447        );
1448        (fb[(h / 2 * w + w / 2) as usize], fb)
1449    }
1450
1451    /// An additive sprite over a dark background brightens it (glow) — and
1452    /// never darkens any channel below the background.
1453    #[test]
1454    fn additive_sprite_brightens_background() {
1455        let bg = 0x80_20_20_20;
1456        let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, f32::INFINITY);
1457        let (cr, cg, cb) = ((centre >> 16) & 0xff, (centre >> 8) & 0xff, centre & 0xff);
1458        assert!(
1459            cr > 0x20 && cg > 0x20 && cb >= 0x20,
1460            "centre {centre:08x} should be brighter than bg"
1461        );
1462        // Red channel (sprite 0xC0) lifts the most.
1463        assert!(
1464            cr >= cg && cr >= cb,
1465            "additive of a red-dominant cube stays red-dominant"
1466        );
1467    }
1468
1469    /// An alpha-blend sprite composites *between* the background and its own
1470    /// colour — neither equal to the bare background nor the opaque colour.
1471    #[test]
1472    fn alpha_blend_sprite_between_bg_and_color() {
1473        let bg = 0x80_20_20_20;
1474        let (centre, _) = draw_cube_shaded(Material::alpha_blend(128), 255, bg, f32::INFINITY);
1475        let cr = (centre >> 16) & 0xff;
1476        assert!(
1477            cr > 0x20,
1478            "blended red must rise above bg 0x20 (got {cr:02x})"
1479        );
1480        assert!(
1481            cr < 0xC0,
1482            "blended red must stay below opaque 0xC0 (got {cr:02x})"
1483        );
1484        // Distinct from both endpoints.
1485        assert_ne!(centre & 0x00ff_ffff, bg & 0x00ff_ffff);
1486        assert_ne!(centre & 0x00ff_ffff, 0x00_C0_40_20);
1487    }
1488
1489    /// The per-instance `alpha_mul` scales opacity: a lower multiplier keeps
1490    /// more of the background (less of the sprite colour).
1491    #[test]
1492    fn alpha_mul_scales_opacity() {
1493        let bg = 0x80_20_20_20;
1494        let (full, _) = draw_cube_shaded(Material::alpha_blend(255), 255, bg, f32::INFINITY);
1495        let (faded, _) = draw_cube_shaded(Material::alpha_blend(255), 64, bg, f32::INFINITY);
1496        let r_full = (full >> 16) & 0xff;
1497        let r_faded = (faded >> 16) & 0xff;
1498        // Both lift red above bg, but the faded one stays closer to bg.
1499        assert!(
1500            r_full > r_faded,
1501            "alpha_mul=255 ({r_full:02x}) more opaque than 64 ({r_faded:02x})"
1502        );
1503        assert!(r_faded > 0x20, "even faded lifts above bg");
1504    }
1505
1506    /// A `SpriteShade` whose effective material is **opaque** (id 0) renders
1507    /// byte-for-byte identically to the plain opaque path — the per-sprite
1508    /// gate that keeps the opaque world unchanged.
1509    #[test]
1510    fn opaque_shade_ctx_matches_plain_path() {
1511        let table = MaterialTable::new();
1512        let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_C0_40_20));
1513        let (w, h) = (64u32, 64u32);
1514        let n = (w * h) as usize;
1515        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1516        let pose = (
1517            [0.0, 40.0, 0.0],
1518            [1.0, 0.0, 0.0],
1519            [0.0, 1.0, 0.0],
1520            [0.0, 0.0, 1.0],
1521        );
1522
1523        let mut fb_plain = vec![0u32; n];
1524        let mut zb_plain = vec![f32::INFINITY; n];
1525        let _ = draw_sprite_dense(
1526            &mut fb_plain,
1527            &mut zb_plain,
1528            w as usize,
1529            w,
1530            h,
1531            &cs,
1532            &settings(w, h),
1533            &dense,
1534            pose.0,
1535            pose.1,
1536            pose.2,
1537            pose.3,
1538            0,
1539        );
1540
1541        let mut fb_sh = vec![0u32; n];
1542        let mut zb_sh = vec![f32::INFINITY; n];
1543        let sh = SpriteShade {
1544            materials: &table,
1545            material: 0, // opaque
1546            alpha_mul: 255,
1547        };
1548        let _ = draw_sprite_dense_shaded(
1549            &mut fb_sh,
1550            &mut zb_sh,
1551            w as usize,
1552            w,
1553            h,
1554            &cs,
1555            &settings(w, h),
1556            &dense,
1557            pose.0,
1558            pose.1,
1559            pose.2,
1560            pose.3,
1561            0,
1562            Some(sh),
1563        );
1564
1565        assert_eq!(
1566            fb_plain, fb_sh,
1567            "opaque shade-ctx must match the plain path bit-for-bit"
1568        );
1569        assert_eq!(zb_plain, zb_sh, "opaque shade-ctx z-buffer must match too");
1570    }
1571
1572    /// A translucent (additive) sprite behind nearer terrain is occluded:
1573    /// the front depth (~36) is past the z-buffer cutoff (5), so the march
1574    /// stops before contributing and the background pixel is untouched.
1575    #[test]
1576    fn translucent_sprite_occluded_by_near_terrain() {
1577        let bg = 0x80_20_20_20;
1578        let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, 5.0);
1579        assert_eq!(
1580            centre, bg,
1581            "near terrain (z=5) must occlude the sprite at y≈36"
1582        );
1583    }
1584
1585    /// Per-span compositing: a translucent voxel contributes one alpha layer
1586    /// per contiguous solid run, so a 2-voxel-thick slab composites the same
1587    /// as a 1-voxel-thick one (adjacent voxels are not double-counted). This
1588    /// is the fix for the voxel-grid striping where a ray clipping a shared
1589    /// voxel boundary passed through two cells of one wall.
1590    #[test]
1591    fn per_span_thickness_independent() {
1592        fn centre(ysiz: u32) -> u32 {
1593            let mut table = MaterialTable::new();
1594            table.set(1, Material::alpha_blend(128));
1595            let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, ysiz, 8, 0x80_C0_40_20));
1596            let (w, h) = (64u32, 64u32);
1597            let n = (w * h) as usize;
1598            let mut fb = vec![0x80_10_10_10u32; n];
1599            let mut zb = vec![f32::INFINITY; n];
1600            let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1601            let sh = SpriteShade {
1602                materials: &table,
1603                material: 1,
1604                alpha_mul: 255,
1605            };
1606            let _ = draw_sprite_dense_shaded(
1607                &mut fb,
1608                &mut zb,
1609                w as usize,
1610                w,
1611                h,
1612                &cs,
1613                &settings(w, h),
1614                &dense,
1615                [0.0, 40.0, 0.0],
1616                [1.0, 0.0, 0.0],
1617                [0.0, 1.0, 0.0],
1618                [0.0, 0.0, 1.0],
1619                0,
1620                Some(sh),
1621            );
1622            fb[(h / 2 * w + w / 2) as usize] & 0x00ff_ffff
1623        }
1624        // A 2-deep box is solid through (surface-only of a 2-thick box is both
1625        // y-layers); per-span treats the straight-through ray's two adjacent
1626        // voxels as one surface → identical to the 1-deep slab.
1627        assert_eq!(
1628            centre(1),
1629            centre(2),
1630            "per-span: a 2-thick slab must match a 1-thick one (no double-count)"
1631        );
1632    }
1633
1634    /// Volumetric (Beer–Lambert) is the thickness-*dependent* counterpart of
1635    /// per-span: a deeper **filled** volume absorbs more, so its centre pixel
1636    /// sits closer to the volume colour (less background shows through) than a
1637    /// shallow one — the opposite of `per_span_thickness_independent`.
1638    #[test]
1639    fn volumetric_thickness_deepens_opacity() {
1640        // Centre-pixel red channel of a filled red box `depth` voxels deep,
1641        // Volumetric material, over a dark background.
1642        fn red_at(depth: u32) -> u32 {
1643            let mut table = MaterialTable::new();
1644            table.set(1, Material::volumetric(128));
1645            // FILLED box (every cell solid, interior kept) so the ray actually
1646            // traverses `depth` absorbing voxels. `from_fn` would cull the
1647            // interior to a hollow shell (front+back faces only) — no genuine
1648            // depth accumulation — so use `from_fn_keep_interior`.
1649            let kv6 =
1650                Kv6::from_fn_keep_interior(8, depth, 8, |_, _, _| Some(0x80_C0_20_20), |_| true);
1651            let dense = SpriteDense::from_kv6(&kv6);
1652            let (w, h) = (64u32, 64u32);
1653            let n = (w * h) as usize;
1654            let mut fb = vec![0x80_10_10_10u32; n];
1655            let mut zb = vec![f32::INFINITY; n];
1656            let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1657            let sh = SpriteShade {
1658                materials: &table,
1659                material: 1,
1660                alpha_mul: 255,
1661            };
1662            let _ = draw_sprite_dense_shaded(
1663                &mut fb,
1664                &mut zb,
1665                w as usize,
1666                w,
1667                h,
1668                &cs,
1669                &settings(w, h),
1670                &dense,
1671                [0.0, 40.0, 0.0],
1672                [1.0, 0.0, 0.0],
1673                [0.0, 1.0, 0.0],
1674                [0.0, 0.0, 1.0],
1675                0,
1676                Some(sh),
1677            );
1678            (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
1679        }
1680        let shallow = red_at(1);
1681        let deep = red_at(12);
1682        // Both lift red above the 0x10 background; the deeper volume absorbs
1683        // more of its own colour in, so its red is higher (more opaque).
1684        assert!(
1685            shallow > 0x10,
1686            "even a 1-deep volume tints (got {shallow:02x})"
1687        );
1688        assert!(
1689            deep > shallow,
1690            "deeper Volumetric volume is more opaque: deep {deep:02x} > shallow {shallow:02x}"
1691        );
1692    }
1693
1694    /// The demo scenario: an **opaque** backdrop sprite drawn first, then a
1695    /// **translucent** sprite in front of it sharing the buffer. The glass
1696    /// must composite over the backdrop colour (tint it), not leave it
1697    /// unchanged. Pins the CPU opaque-then-translucent interaction.
1698    #[test]
1699    fn translucent_sprite_tints_opaque_sprite_behind() {
1700        let mut table = MaterialTable::new();
1701        table.set(1, Material::alpha_blend(128));
1702        let (w, h) = (64u32, 64u32);
1703        let n = (w * h) as usize;
1704        let mut fb = vec![0x80_10_20_40u32; n]; // flat sky
1705        let mut zb = vec![f32::INFINITY; n];
1706        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1707        let cfg = settings(w, h);
1708        let id = [1.0, 0.0, 0.0];
1709        let up = [0.0, 1.0, 0.0];
1710        let fw = [0.0, 0.0, 1.0];
1711        let centre = (h / 2 * w + w / 2) as usize;
1712
1713        // Opaque red backdrop (material 0), far.
1714        let backdrop = SpriteDense::from_kv6(&Kv6::solid_cube(12, 0x80_FF_00_00));
1715        let sh_op = SpriteShade {
1716            materials: &table,
1717            material: 0,
1718            alpha_mul: 255,
1719        };
1720        let _ = draw_sprite_dense_shaded(
1721            &mut fb,
1722            &mut zb,
1723            w as usize,
1724            w,
1725            h,
1726            &cs,
1727            &cfg,
1728            &backdrop,
1729            [0.0, 80.0, 0.0],
1730            id,
1731            up,
1732            fw,
1733            0,
1734            Some(sh_op),
1735        );
1736        let after_backdrop = fb[centre];
1737        assert_eq!(
1738            after_backdrop & 0x00ff_ffff,
1739            0x00FF_0000,
1740            "backdrop red must be drawn first"
1741        );
1742
1743        // Cyan glass (material 1), nearer + overlapping.
1744        let glass = SpriteDense::from_kv6(&Kv6::solid_cube(12, 0x80_00_FF_FF));
1745        let sh_gl = SpriteShade {
1746            materials: &table,
1747            material: 1,
1748            alpha_mul: 255,
1749        };
1750        let wrote = draw_sprite_dense_shaded(
1751            &mut fb,
1752            &mut zb,
1753            w as usize,
1754            w,
1755            h,
1756            &cs,
1757            &cfg,
1758            &glass,
1759            [0.0, 40.0, 0.0],
1760            id,
1761            up,
1762            fw,
1763            0,
1764            Some(sh_gl),
1765        );
1766        let _ = wrote;
1767        let after_glass = fb[centre];
1768        assert_ne!(
1769            after_glass, after_backdrop,
1770            "glass must tint the backdrop (composite over it)"
1771        );
1772        // Cyan over red: red channel drops, blue/green rise.
1773        assert!(
1774            (after_glass >> 16) & 0xff < 0xFF,
1775            "glass should reduce the backdrop's red (got {after_glass:08x})"
1776        );
1777    }
1778
1779    /// TV.3: `from_kv6_with_materials` classifies voxels into per-voxel
1780    /// material ids by colour — mapped colour → its id, unmapped → 0.
1781    #[test]
1782    fn from_kv6_with_materials_classifies_by_color() {
1783        let col = 0x80_AA_BB_CC;
1784        let kv6 = Kv6::solid_cube(6, col);
1785        let dense = SpriteDense::from_kv6_with_materials(&kv6, &[(0x00AA_BBCC, 2)]);
1786        assert_eq!(
1787            dense.mat.len(),
1788            dense.col.len(),
1789            "per-voxel mat array sized"
1790        );
1791        let mut solids = 0;
1792        for idx in 0..dense.occ.len() {
1793            if dense.occ[idx] {
1794                assert_eq!(dense.mat[idx], 2, "mapped colour → material 2");
1795                solids += 1;
1796            }
1797        }
1798        assert!(solids > 0, "cube has solid voxels");
1799        // A map that doesn't include the cube's colour → all opaque (0).
1800        let dense0 = SpriteDense::from_kv6_with_materials(&kv6, &[(0x0012_3456, 5)]);
1801        assert!(
1802            dense0.mat.iter().all(|&m| m == 0),
1803            "unmapped colour → material 0"
1804        );
1805    }
1806
1807    /// TV.3: a model whose every voxel maps to the *same* material id renders
1808    /// identically to drawing it with that material as the instance's uniform
1809    /// material — the per-voxel path reduces to the uniform path when
1810    /// homogeneous (and overrides the instance's `material`).
1811    #[test]
1812    fn per_voxel_material_matches_uniform_when_homogeneous() {
1813        let mut table = MaterialTable::new();
1814        table.set(1, Material::alpha_blend(120));
1815        let col = 0x80_30_A0_F0;
1816        let kv6 = Kv6::solid_cube(10, col);
1817        let (w, h) = (64u32, 64u32);
1818        let n = (w * h) as usize;
1819        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1820        let cfg = settings(w, h);
1821        let (pos, s, hh, f) = (
1822            [0.0, 40.0, 0.0],
1823            [1.0, 0.0, 0.0],
1824            [0.0, 1.0, 0.0],
1825            [0.0, 0.0, 1.0],
1826        );
1827        let render = |dense: &SpriteDense, material: u8| -> Vec<u32> {
1828            let mut fb = vec![0x80_10_10_10u32; n];
1829            let mut zb = vec![f32::INFINITY; n];
1830            let sh = SpriteShade {
1831                materials: &table,
1832                material,
1833                alpha_mul: 255,
1834            };
1835            let _ = draw_sprite_dense_shaded(
1836                &mut fb,
1837                &mut zb,
1838                w as usize,
1839                w,
1840                h,
1841                &cs,
1842                &cfg,
1843                dense,
1844                pos,
1845                s,
1846                hh,
1847                f,
1848                0,
1849                Some(sh),
1850            );
1851            fb
1852        };
1853        // Per-voxel: every voxel → material 1; instance's uniform material is 0
1854        // (opaque) but the per-voxel id overrides it.
1855        let pv = render(
1856            &SpriteDense::from_kv6_with_materials(&kv6, &[(col & 0xff_ffff, 1)]),
1857            0,
1858        );
1859        // Uniform: no per-voxel data, instance material 1.
1860        let un = render(&SpriteDense::from_kv6(&kv6), 1);
1861        assert_eq!(pv, un, "homogeneous per-voxel material == uniform material");
1862        // And it's actually translucent (differs from the bare background).
1863        let centre = (h / 2 * w + w / 2) as usize;
1864        assert_ne!(pv[centre] & 0x00ff_ffff, 0x0010_1010, "translucent, not bg");
1865    }
1866
1867    /// TV.3 (clip wiring): a [`ClipFlipbook`] built with a colour→material map
1868    /// carries per-voxel materials on every cached frame (the clip analogue of
1869    /// `from_kv6_with_materials`); an empty map leaves them all-opaque, so the
1870    /// flipbook is byte-identical to `from_decoded`.
1871    #[test]
1872    fn clip_flipbook_with_materials_classifies_every_frame() {
1873        let dims = [6u32, 6, 6];
1874        let glass = 0x00AA_BBCC;
1875        let glass_lit = 0x80AA_BBCC;
1876        // Two distinct frames, both filled with the glass colour.
1877        let f0 = clip_frame(dims, |_x, _y, z| (z < 3).then_some(glass_lit));
1878        let f1 = clip_frame(dims, |_x, _y, z| (z >= 3).then_some(glass_lit));
1879        let clip = VoxelClip::from_frames(
1880            dims,
1881            [3.0, 3.0, 3.0],
1882            1.0,
1883            LoopMode::Loop,
1884            &[f0, f1],
1885            &[],
1886            33,
1887            0,
1888        );
1889        let decoded = clip.decode().expect("decode");
1890
1891        let book = ClipFlipbook::from_decoded_with_materials(&decoded, &[(glass, 2)]);
1892        assert_eq!(book.frame_count(), 2);
1893        for fr in 0..2 {
1894            let dense = book.frame(fr).expect("frame in range");
1895            assert_eq!(dense.mat.len(), dense.col.len(), "frame {fr} mat sized");
1896            let mut solids = 0;
1897            for idx in 0..dense.occ.len() {
1898                if dense.occ[idx] {
1899                    assert_eq!(dense.mat[idx], 2, "frame {fr}: glass → material 2");
1900                    solids += 1;
1901                }
1902            }
1903            assert!(solids > 0, "frame {fr} has solid voxels");
1904        }
1905
1906        // An empty map ⇒ no per-voxel materials, identical to `from_decoded`.
1907        let plain = ClipFlipbook::from_decoded(&decoded);
1908        let plain_mat = ClipFlipbook::from_decoded_with_materials(&decoded, &[]);
1909        for fr in 0..2 {
1910            assert!(plain.frame(fr).unwrap().mat.is_empty());
1911            assert!(plain_mat.frame(fr).unwrap().mat.is_empty());
1912        }
1913    }
1914}