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::{
25    Sprite, SPRITE_FLAG_INVISIBLE, SPRITE_FLAG_LIGHT_AMBIENT_ONLY, SPRITE_FLAG_LIGHT_WORLD_UP,
26    SPRITE_FLAG_NO_Z,
27};
28use roxlap_formats::voxel_clip::{DecodedClip, VoxelFrame};
29use roxlap_formats::Rgb;
30
31use std::sync::Arc;
32
33use crate::camera_math::CameraState;
34use crate::dda::{
35    dda_setup, intersect_aabb, min_axis, pixel_ray, shade, shade_dynamic, CpuLights, ShadowTester,
36    WorldOccluder, WorldShadow, WorldShadowCtx,
37};
38use crate::opticast::OpticastSettings;
39use crate::raster_target::RasterTarget;
40
41/// Near-plane parameter: voxels nearer than this (camera-forward) are
42/// dropped, keeping the pinhole divide finite.
43const NEAR_Z: f32 = 1.0;
44
45/// Force a packed voxel colour to full brightness for the flat-lit
46/// clean-room sprite path. KV6 / voxel-clip colours carry voxlap's
47/// `dir`/shading slot in the high byte (some `0x80`, some `0x00`), not
48/// the 0..128 brightness [`shade`] expects, so a raw value can render
49/// black; we render every sprite voxel at its authored RGB.
50#[inline]
51fn full_bright(col: u32) -> u32 {
52    (col & 0x00ff_ffff) | 0x8000_0000
53}
54
55/// Dense occupancy + colour grid for one sprite frame, plus its pivot —
56/// the decoded form the per-pixel raycaster marches. Built once from a
57/// [`Kv6`] ([`SpriteDense::from_kv6`]) or a voxel-clip [`VoxelFrame`]
58/// ([`SpriteDense::from_voxel_frame`]); the latter lets an animated clip
59/// cache every frame's grid up front instead of rebuilding per frame.
60///
61/// Both sources store only **surface** voxels (a from-air ray's first
62/// hit is the visible surface), so the grid is the visible hull.
63#[derive(Clone)]
64pub struct SpriteDense {
65    dims: [i32; 3],
66    occ: Vec<bool>,
67    col: Vec<u32>,
68    /// Per-voxel material id (TV stage), parallel to [`col`](Self::col) /
69    /// [`occ`](Self::occ) (same dense index). **Empty** means every voxel
70    /// uses the draw-time uniform material (the TV.1 path); a non-empty
71    /// array gives mixed-material models (opaque frame + glass, TV.3). Only
72    /// consulted on the [`draw_sprite_dense_shaded`] accumulate path.
73    mat: Vec<u8>,
74    pivot: [f32; 3],
75}
76
77impl SpriteDense {
78    /// Decode a [`Kv6`]'s surface-voxel run tables into a dense grid.
79    #[must_use]
80    #[allow(clippy::cast_possible_wrap)]
81    pub fn from_kv6(kv6: &Kv6) -> Self {
82        let dims = [kv6.xsiz as i32, kv6.ysiz as i32, kv6.zsiz as i32];
83        let n = (dims[0].max(0) * dims[1].max(0) * dims[2].max(0)) as usize;
84        let mut occ = vec![false; n];
85        let mut col = vec![0u32; n];
86        let mut vi = 0usize;
87        for x in 0..kv6.xsiz as usize {
88            for y in 0..kv6.ysiz as usize {
89                let cnt = usize::from(kv6.ylen[x][y]);
90                for _ in 0..cnt {
91                    let v = kv6.voxels[vi];
92                    vi += 1;
93                    let z = i32::from(v.z);
94                    if z >= 0 && z < dims[2] {
95                        let idx = ((x as i32 * dims[1] + y as i32) * dims[2] + z) as usize;
96                        occ[idx] = true;
97                        col[idx] = full_bright(v.col);
98                    }
99                }
100            }
101        }
102        Self {
103            dims,
104            occ,
105            col,
106            mat: Vec::new(),
107            pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
108        }
109    }
110
111    /// Like [`from_kv6`](Self::from_kv6) but classifies each voxel into a
112    /// material id by colour (TV.3 mixed models) via `material_map`
113    /// (`(rgb, material_id)` pairs; see
114    /// [`material_for_color`]).
115    /// The resulting per-voxel `mat` array is consulted by the
116    /// [`draw_sprite_dense_shaded`] accumulate path. An empty map yields the
117    /// same all-opaque (uniform) result as `from_kv6`.
118    #[must_use]
119    #[allow(clippy::cast_possible_wrap)]
120    pub fn from_kv6_with_materials(kv6: &Kv6, material_map: &[(Rgb, u8)]) -> Self {
121        let mut dense = Self::from_kv6(kv6);
122        if !material_map.is_empty() {
123            let n = dense.col.len();
124            let mut mat = vec![0u8; n];
125            for (idx, slot) in mat.iter_mut().enumerate() {
126                if dense.occ[idx] {
127                    *slot = material_for_color(material_map, dense.col[idx]);
128                }
129            }
130            dense.mat = mat;
131        }
132        dense
133    }
134
135    /// Decode a voxel-clip [`VoxelFrame`] (dense-column layout) into the
136    /// dense grid, given the clip's `dims` + `pivot`. The frame's columns
137    /// are `col = x + y*dims[0]`, each a per-column occupancy bitmask with
138    /// an ascending-z colour run — walked here into the raycaster's
139    /// `(x·my + y)·mz + z` grid.
140    #[must_use]
141    #[allow(clippy::cast_possible_wrap)]
142    pub fn from_voxel_frame(frame: &VoxelFrame, dims: [u32; 3], pivot: [f32; 3]) -> Self {
143        let (mx, my, mz) = (dims[0], dims[1], dims[2]);
144        let owpc = mz.div_ceil(32).max(1) as usize;
145        let n = (mx * my * mz) as usize;
146        let mut occ = vec![false; n];
147        let mut col = vec![0u32; n];
148        for col_idx in 0..(mx * my) as usize {
149            let x = col_idx as u32 % mx;
150            let y = col_idx as u32 / mx;
151            let run_start = frame.color_offsets[col_idx] as usize;
152            let mut k = 0usize;
153            for z in 0..mz {
154                let word = frame.occupancy[col_idx * owpc + (z >> 5) as usize];
155                if (word >> (z & 31)) & 1 != 0 {
156                    let idx = (((x * my + y) * mz) + z) as usize;
157                    occ[idx] = true;
158                    col[idx] = full_bright(frame.colors[run_start + k]);
159                    k += 1;
160                }
161            }
162        }
163        Self {
164            dims: [mx as i32, my as i32, mz as i32],
165            occ,
166            col,
167            mat: Vec::new(),
168            pivot,
169        }
170    }
171
172    /// Like [`from_voxel_frame`](Self::from_voxel_frame) but classifies each
173    /// voxel into a material id by colour (TV.3 mixed models) via
174    /// `material_map` — the clip analogue of
175    /// [`from_kv6_with_materials`](Self::from_kv6_with_materials). An empty
176    /// map yields the same all-opaque (uniform) result as `from_voxel_frame`.
177    #[must_use]
178    pub fn from_voxel_frame_with_materials(
179        frame: &VoxelFrame,
180        dims: [u32; 3],
181        pivot: [f32; 3],
182        material_map: &[(Rgb, u8)],
183    ) -> Self {
184        let mut dense = Self::from_voxel_frame(frame, dims, pivot);
185        if !material_map.is_empty() {
186            let n = dense.col.len();
187            let mut mat = vec![0u8; n];
188            for (idx, slot) in mat.iter_mut().enumerate() {
189                if dense.occ[idx] {
190                    *slot = material_for_color(material_map, dense.col[idx]);
191                }
192            }
193            dense.mat = mat;
194        }
195        dense
196    }
197
198    #[inline]
199    #[allow(clippy::cast_sign_loss)]
200    fn idx_of(&self, c: [i32; 3]) -> usize {
201        ((c[0] * self.dims[1] + c[1]) * self.dims[2] + c[2]) as usize
202    }
203
204    #[inline]
205    fn at(&self, c: [i32; 3]) -> Option<u32> {
206        let idx = self.idx_of(c);
207        self.occ[idx].then(|| self.col[idx])
208    }
209}
210
211/// Inverse of the column-matrix `[s | h | f]` (the sprite basis), or
212/// `None` if degenerate. Maps a world delta into local voxel space.
213fn invert_basis(s: [f32; 3], h: [f32; 3], f: [f32; 3]) -> Option<[[f32; 3]; 3]> {
214    let det = s[0] * (h[1] * f[2] - f[1] * h[2]) - h[0] * (s[1] * f[2] - f[1] * s[2])
215        + f[0] * (s[1] * h[2] - h[1] * s[2]);
216    if det.abs() < 1e-12 {
217        return None;
218    }
219    let inv = 1.0 / det;
220    Some([
221        [
222            (h[1] * f[2] - f[1] * h[2]) * inv,
223            -(h[0] * f[2] - f[0] * h[2]) * inv,
224            (h[0] * f[1] - f[0] * h[1]) * inv,
225        ],
226        [
227            -(s[1] * f[2] - f[1] * s[2]) * inv,
228            (s[0] * f[2] - f[0] * s[2]) * inv,
229            -(s[0] * f[1] - f[0] * s[1]) * inv,
230        ],
231        [
232            (s[1] * h[2] - h[1] * s[2]) * inv,
233            -(s[0] * h[2] - h[0] * s[2]) * inv,
234            (s[0] * h[1] - h[0] * s[1]) * inv,
235        ],
236    ])
237}
238
239#[inline]
240fn mat_apply(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
241    [
242        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
243        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
244        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
245    ]
246}
247
248/// Cast one ray (already in the sprite's local voxel space) into the
249/// dense KV6 and return `(colour, t)` of the first solid voxel — `t` is
250/// the world-units ray parameter (shared with the world ray).
251#[allow(clippy::cast_possible_truncation)]
252/// First solid voxel along the local ray. Returns `(color, t, normal_local,
253/// cell)`: `normal_local` is the **model-local** face normal of the hit
254/// (points back toward the ray; zero for the entry voxel, no face crossed) —
255/// the caller rotates it to world for dynamic lighting (DL.7); `cell` is the
256/// hit voxel for the flat-per-voxel world centre.
257fn cast_local(
258    dense: &SpriteDense,
259    origin: [f32; 3],
260    dir: [f32; 3],
261) -> Option<(u32, f32, [f32; 3], [i32; 3])> {
262    #[allow(clippy::cast_precision_loss)]
263    let hi = [
264        dense.dims[0] as f32,
265        dense.dims[1] as f32,
266        dense.dims[2] as f32,
267    ];
268    let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
269    let start = t0 + 1e-4;
270    let p = [
271        origin[0] + dir[0] * start,
272        origin[1] + dir[1] * start,
273        origin[2] + dir[2] * start,
274    ];
275    let mut cell = [
276        (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
277        (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
278        (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
279    ];
280    let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
281    let mut t_curr = t0;
282    // Face crossed to reach the current cell (model-local normal). The entry
283    // voxel (solid at t0, no step yet) has none → zero normal.
284    let mut normal = [0.0f32; 3];
285    let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
286    for _ in 0..max_steps {
287        if cell[0] < 0
288            || cell[0] >= dense.dims[0]
289            || cell[1] < 0
290            || cell[1] >= dense.dims[1]
291            || cell[2] < 0
292            || cell[2] >= dense.dims[2]
293            || t_curr > t1
294        {
295            return None;
296        }
297        if let Some(color) = dense.at(cell) {
298            return Some((color, t_curr, normal, cell));
299        }
300        let axis = min_axis(t_max);
301        t_curr = t_max[axis];
302        cell[axis] += step[axis];
303        t_max[axis] += t_delta[axis];
304        normal = [0.0; 3];
305        normal[axis] = -(step[axis] as f32);
306    }
307    None
308}
309
310/// XS.2 — one sprite volume in the scene shadow occluder: its decoded dense
311/// voxels + world pose, with the cached inverse instance basis for
312/// world→sprite-local transforms. PF.8 — the dense grid is shared
313/// (`Arc`) with the draw path's per-model cache instead of deep-cloned
314/// per occluder rebuild.
315struct SpriteOccEntry {
316    dense: Arc<SpriteDense>,
317    pos: [f32; 3],
318    pivot: [f32; 3],
319    minv: [[f32; 3]; 3],
320}
321
322/// XS.2 — a [`WorldOccluder`] over sprite volumes, so **sprites cast** hard
323/// shadows onto terrain and each other (and so a sprite-receive query also
324/// sees other sprites). Owns the decoded [`SpriteDense`] grids; populate with
325/// [`Self::push`].
326///
327/// A world-space shadow ray is transformed into each sprite's local frame and
328/// the dense occupancy is DDA-marched. Assumes orthonormal unit instance bases
329/// (as the sprite draw does); a non-uniform scale would skew the `max_t`
330/// distance bound. Empty ⇒ casts nothing.
331#[derive(Default)]
332pub struct SpriteOccluder {
333    entries: Vec<SpriteOccEntry>,
334}
335
336impl SpriteOccluder {
337    /// An empty occluder (casts nothing). Add sprite volumes with
338    /// [`push`](Self::push).
339    #[must_use]
340    pub fn new() -> Self {
341        Self::default()
342    }
343
344    /// Whether the occluder holds any sprite volumes.
345    #[must_use]
346    pub fn is_empty(&self) -> bool {
347        self.entries.is_empty()
348    }
349
350    /// Add a decoded sprite volume at a world pose (`pos` = world pivot;
351    /// `s`/`h`/`f` = model→world basis columns, the same pose the draw uses).
352    /// A degenerate (non-invertible) basis is skipped. PF.8 — takes the
353    /// dense grid by `Arc` (an occluder rebuild shares the draw path's
354    /// cached decodes instead of re-densifying every caster per frame).
355    pub fn push(
356        &mut self,
357        dense: Arc<SpriteDense>,
358        pos: [f32; 3],
359        s: [f32; 3],
360        h: [f32; 3],
361        f: [f32; 3],
362    ) {
363        let Some(minv) = invert_basis(s, h, f) else {
364            return;
365        };
366        let pivot = dense.pivot;
367        self.entries.push(SpriteOccEntry {
368            dense,
369            pos,
370            pivot,
371            minv,
372        });
373    }
374}
375
376impl WorldOccluder for SpriteOccluder {
377    fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
378        self.entries
379            .iter()
380            .any(|e| sprite_entry_occluded(e, origin, dir, max_t))
381    }
382}
383
384/// March one sprite entry's dense occupancy along a world-space ray; `true` if
385/// a solid voxel blocks it within `max_t` world units.
386#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
387fn sprite_entry_occluded(e: &SpriteOccEntry, ow: [f32; 3], dw: [f32; 3], max_t: f32) -> bool {
388    // World → sprite-local voxel space (same transform as the draw).
389    let rel = [ow[0] - e.pos[0], ow[1] - e.pos[1], ow[2] - e.pos[2]];
390    let ol = mat_apply(&e.minv, rel);
391    let origin = [ol[0] + e.pivot[0], ol[1] + e.pivot[1], ol[2] + e.pivot[2]];
392    let dir = mat_apply(&e.minv, dw);
393
394    let hi = [
395        e.dense.dims[0] as f32,
396        e.dense.dims[1] as f32,
397        e.dense.dims[2] as f32,
398    ];
399    let Some((t0, t1)) = intersect_aabb(origin, dir, [0.0; 3], hi) else {
400        return false;
401    };
402    let t_enter = t0.max(0.0);
403    let t_exit = t1.min(max_t);
404    if t_enter > t_exit {
405        return false;
406    }
407    let start = t_enter + 1e-4;
408    let p = [
409        origin[0] + dir[0] * start,
410        origin[1] + dir[1] * start,
411        origin[2] + dir[2] * start,
412    ];
413    let mut cell = [
414        (p[0].floor() as i32).clamp(0, e.dense.dims[0] - 1),
415        (p[1].floor() as i32).clamp(0, e.dense.dims[1] - 1),
416        (p[2].floor() as i32).clamp(0, e.dense.dims[2] - 1),
417    ];
418    let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
419    let mut t_curr = t_enter;
420    let max_steps = (e.dense.dims[0] + e.dense.dims[1] + e.dense.dims[2]) as usize + 8;
421    for _ in 0..max_steps {
422        if cell[0] < 0
423            || cell[0] >= e.dense.dims[0]
424            || cell[1] < 0
425            || cell[1] >= e.dense.dims[1]
426            || cell[2] < 0
427            || cell[2] >= e.dense.dims[2]
428            || t_curr > t_exit
429        {
430            return false;
431        }
432        if e.dense.occ[e.dense.idx_of(cell)] {
433            return true;
434        }
435        let a = min_axis(t_max);
436        t_curr = t_max[a];
437        cell[a] += step[a];
438        t_max[a] += t_delta[a];
439    }
440    false
441}
442
443/// Material context for a translucent sprite draw (TV stage): the global
444/// [`MaterialTable`] plus this instance's uniform material id and per-frame
445/// alpha multiplier. Passed (as `Some`) to [`draw_sprite_dense_shaded`] /
446/// [`ClipFlipbook::draw_frame_shaded`] to enable front-to-back
447/// accumulate-and-continue compositing; `None` (or an all-opaque effective
448/// material) takes the existing first-hit opaque path byte-for-byte.
449#[derive(Clone, Copy)]
450pub struct SpriteShade<'a> {
451    /// Global voxel-material palette (per-voxel id → opacity + blend mode).
452    pub materials: &'a MaterialTable,
453    /// Uniform material id for every voxel of this sprite whose dense
454    /// per-voxel `mat` array is empty (the TV.1 whole-sprite material).
455    pub material: u8,
456    /// Per-instance opacity multiplier (`255` = unscaled), so an effect can
457    /// fade out by cheap per-frame updates without re-uploading the volume.
458    pub alpha_mul: u8,
459    /// Per-instance RGB colour tint, packed `0x00RRGGBB` — each rendered
460    /// voxel's colour is multiplied by it. `0x00FF_FFFF` (white) is a no-op.
461    pub tint: u32,
462    /// DL.7 — world-space dynamic lights. When `enabled`, the opaque hit is
463    /// lit (sun + point lights + cel + ramp, flat per voxel) instead of the
464    /// baked `shade`. `CpuLights::default()` (disabled) ⇒ unchanged.
465    pub lights: CpuLights<'a>,
466    /// XS.2 — world-space scene occluder for **sprites receiving** hard
467    /// shadows: a lit sprite voxel marches a shadow ray (world space) against
468    /// this and is darkened where terrain / other sprites block the caster.
469    /// `None` (the default) ⇒ unshadowed sprites (the pre-XS.2 look).
470    pub shadow: Option<&'a dyn WorldOccluder>,
471}
472
473/// Accumulated front-to-back composite for one ray through a sprite.
474struct LayerAccum {
475    /// Premultiplied accumulated colour, channels in `0..=~1` (additive may
476    /// exceed 1; clamped at pack time).
477    rgb: [f32; 3],
478    /// Remaining transmittance (starts 1.0, decays through `AlphaBlend`).
479    trans: f32,
480    /// The opaque/background hit that terminated the march, if any: its
481    /// already-shaded packed colour + world-ray parameter `t`. `None` if the
482    /// ray exited (or fully attenuated) without an opaque voxel — then the
483    /// background is whatever the framebuffer already holds (terrain/sky).
484    opaque: Option<(u32, f32)>,
485}
486
487/// Per-instance RGB tint: multiply `color`'s RGB by `tint`'s (both packed,
488/// `tint`'s channels normalised by 255), preserving `color`'s high byte. White
489/// tint (`0x00FF_FFFF`) returns `color` unchanged. Mirrors the GPU `apply_tint`.
490#[inline]
491fn tint_packed(color: u32, tint: u32) -> u32 {
492    if tint & 0x00FF_FFFF == 0x00FF_FFFF {
493        return color;
494    }
495    let mul = |shift: u32| {
496        let c = (color >> shift) & 0xff;
497        let t = (tint >> shift) & 0xff;
498        ((c * t) / 255) & 0xff
499    };
500    (color & 0xff00_0000) | (mul(16) << 16) | (mul(8) << 8) | mul(0)
501}
502
503/// Unpack a packed `0x..RRGGBB` colour to linear-ish `0..1` float channels
504/// (RGB only; the high byte is ignored here — sprite voxels are flat-lit).
505#[inline]
506fn rgb_to_f32(c: u32) -> [f32; 3] {
507    [
508        ((c >> 16) & 0xff) as f32 / 255.0,
509        ((c >> 8) & 0xff) as f32 / 255.0,
510        (c & 0xff) as f32 / 255.0,
511    ]
512}
513
514/// Repack `0..1` float channels (clamped) into `0x80RRGGBB` — the
515/// full-brightness packing the flat-lit sprite path writes.
516#[inline]
517#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
518fn f32_to_rgb(c: [f32; 3]) -> u32 {
519    let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
520    0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
521}
522
523/// roxlap world up — voxlap z-down, so up is `-z`. Used by
524/// [`SpriteLightMode::WorldUp`] as a fixed shading normal.
525const SPRITE_WORLD_UP: [f32; 3] = [0.0, 0.0, -1.0];
526
527/// Per-instance billboard lighting mode (BB.2b), decoded from the sprite
528/// `flags` (bits 6/7). Controls the surface normal / direct-light handling at
529/// the sprite shade site so a camera-facing billboard needn't suffer the
530/// camera-dependent N·L of its (camera-tracking) face normal.
531#[derive(Clone, Copy, PartialEq, Eq)]
532pub enum SpriteLightMode {
533    /// The DDA hit-face normal (default; today's DL.7 look).
534    FaceNormal,
535    /// A fixed world-up normal (stable directional shading).
536    WorldUp,
537    /// Ambient only — no sun / point-light direct term (flat cutout).
538    AmbientOnly,
539    /// Full-bright / emissive — the voxel colour at full intensity, ignoring
540    /// lighting (glows: fire, spell auras). Encoded as **both** flag bits set.
541    FullBright,
542}
543
544impl SpriteLightMode {
545    /// Decode the mode from a sprite instance's `flags` word (bits
546    /// 6/7 — `SPRITE_FLAG_LIGHT_WORLD_UP` /
547    /// `SPRITE_FLAG_LIGHT_AMBIENT_ONLY`). Neither bit ⇒
548    /// [`FaceNormal`](Self::FaceNormal) (the default); both ⇒
549    /// [`FullBright`](Self::FullBright).
550    #[must_use]
551    pub fn from_flags(flags: u32) -> Self {
552        let world_up = flags & SPRITE_FLAG_LIGHT_WORLD_UP != 0;
553        let ambient_only = flags & SPRITE_FLAG_LIGHT_AMBIENT_ONLY != 0;
554        match (ambient_only, world_up) {
555            (true, true) => Self::FullBright, // both bits set
556            (true, false) => Self::AmbientOnly,
557            (false, true) => Self::WorldUp,
558            (false, false) => Self::FaceNormal,
559        }
560    }
561}
562
563/// Shade a sprite voxel under a [`SpriteLightMode`] (BB.2b): `FaceNormal` is
564/// the plain [`shade_dynamic`]; `WorldUp` swaps in a fixed world-up normal;
565/// `AmbientOnly` drops the sun + point lights (and stylization) for a flat
566/// ambient term. Shared by both sprite shade sites (opaque + translucent).
567fn shade_dynamic_mode(
568    mode: SpriteLightMode,
569    albedo: [f32; 3],
570    n_world: [f32; 3],
571    center: [f32; 3],
572    lights: &CpuLights<'_>,
573    tester: Option<&mut dyn ShadowTester>,
574) -> u32 {
575    match mode {
576        SpriteLightMode::FaceNormal => shade_dynamic(albedo, 1.0, n_world, center, lights, tester),
577        SpriteLightMode::WorldUp => {
578            shade_dynamic(albedo, 1.0, SPRITE_WORLD_UP, center, lights, tester)
579        }
580        SpriteLightMode::AmbientOnly => {
581            let mut amb = *lights;
582            amb.sun = false;
583            amb.points = &[];
584            amb.bands = 0; // smooth ambient (no cel ramp toward shadow_tint)
585            shade_dynamic(albedo, 1.0, n_world, center, &amb, None)
586        }
587        // Emissive: the voxel colour at full intensity, ignoring the rig.
588        SpriteLightMode::FullBright => f32_to_rgb(albedo),
589    }
590}
591
592/// Cast one ray (in sprite-local voxel space) accumulating translucent
593/// voxels front-to-back until an opaque voxel, transmittance exhaustion, or
594/// the `max_t` cutoff (the terrain depth, so the march stops at geometry it
595/// can't see past). `fwd_dot = dir·camera-forward` converts the ray
596/// parameter to perpendicular depth. Returns `None` if the ray contributes
597/// nothing (missed the box, or every voxel was clipped / behind terrain).
598#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
599fn cast_local_layers(
600    dense: &SpriteDense,
601    origin: [f32; 3],
602    dir: [f32; 3],
603    fwd_dot: f32,
604    max_t: f32,
605    shade_ctx: SpriteShade,
606    // Instance basis (s,h,f) + world position — only used to light each layer
607    // (rotate the model-local face normal + voxel centre to world). Ignored
608    // when the rig is disabled (then every layer is the baked `shade`).
609    s: [f32; 3],
610    h: [f32; 3],
611    f: [f32; 3],
612    pos: [f32; 3],
613    light_mode: SpriteLightMode,
614) -> Option<LayerAccum> {
615    #[allow(clippy::cast_precision_loss)]
616    let hi = [
617        dense.dims[0] as f32,
618        dense.dims[1] as f32,
619        dense.dims[2] as f32,
620    ];
621    let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
622    let start = t0 + 1e-4;
623    let p = [
624        origin[0] + dir[0] * start,
625        origin[1] + dir[1] * start,
626        origin[2] + dir[2] * start,
627    ];
628    let mut cell = [
629        (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
630        (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
631        (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
632    ];
633    let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
634    let mut t_curr = t0;
635    let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
636
637    let mut acc = LayerAccum {
638        rgb: [0.0; 3],
639        trans: 1.0,
640        opaque: None,
641    };
642    let mut touched = false;
643    // Per-span compositing: a translucent voxel contributes one alpha layer
644    // only when the ray *enters* a contiguous solid run (the previous cell
645    // was air). Without this, a ray clipping the shared boundary between two
646    // adjacent surface voxels passes through both and double-composites a thin
647    // strip — the model reads as "diced" by a voxel grid. Treating each solid
648    // run as one surface makes a wall contribute exactly one alpha regardless
649    // of how many of its voxels the ray grazes. A run is also re-entered on a
650    // material change (TV.3: two adjacent translucent materials each count),
651    // and an opaque voxel stops the ray on every cell (the opaque core of a
652    // mixed model).
653    let mut prev_solid = false;
654    let mut prev_mat = 0u8;
655    // Local ray length per ray-parameter unit — converts a cell's `t` span to
656    // its path length in voxel units for the `Volumetric` Beer–Lambert weight.
657    let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
658    // Model-local face normal of the cell currently being shaded (the face the
659    // ray crossed to enter it); zero for the AABB-entry voxel, matching
660    // `cast_local`. Updated after each step.
661    let mut normal = [0.0f32; 3];
662
663    // XS.0/XS.2 — per-layer dynamic lighting (mirror of the opaque path):
664    // rotate the model-local face normal + voxel centre to world via the
665    // instance basis and call `shade_dynamic` (flat per voxel). Disabled rig ⇒
666    // baked `shade` (byte-identical). XS.2 — when a scene occluder is present
667    // the layer also receives hard shadows (world-space query, identity ctx).
668    let lights = shade_ctx.lights;
669    let tint = shade_ctx.tint;
670    let mut tester = shade_ctx.shadow.map(|occ| WorldShadow {
671        ctx: WorldShadowCtx::identity(occ),
672    });
673    let mut shade_layer = |idx: usize, cell: [i32; 3], n_local: [f32; 3]| -> u32 {
674        if !lights.enabled {
675            return tint_packed(shade(dense.col[idx], 0), tint);
676        }
677        let to_world = |v: [f32; 3]| {
678            [
679                v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
680                v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
681                v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
682            ]
683        };
684        let n_world = to_world(n_local);
685        let rel = [
686            cell[0] as f32 + 0.5 - dense.pivot[0],
687            cell[1] as f32 + 0.5 - dense.pivot[1],
688            cell[2] as f32 + 0.5 - dense.pivot[2],
689        ];
690        let wc = to_world(rel);
691        let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
692        let albedo = [
693            ((dense.col[idx] >> 16) & 0xff) as f32 / 255.0,
694            ((dense.col[idx] >> 8) & 0xff) as f32 / 255.0,
695            (dense.col[idx] & 0xff) as f32 / 255.0,
696        ];
697        let t = tester.as_mut().map(|t| t as &mut dyn ShadowTester);
698        tint_packed(
699            shade_dynamic_mode(light_mode, albedo, n_world, center, &lights, t),
700            tint,
701        )
702    };
703
704    for _ in 0..max_steps {
705        if cell[0] < 0
706            || cell[0] >= dense.dims[0]
707            || cell[1] < 0
708            || cell[1] >= dense.dims[1]
709            || cell[2] < 0
710            || cell[2] >= dense.dims[2]
711            || t_curr > t1
712        {
713            break;
714        }
715        // Stop at the terrain depth: everything past it is occluded, and the
716        // already-drawn framebuffer pixel becomes the background.
717        let depth = t_curr * fwd_dot;
718        if depth >= max_t {
719            break;
720        }
721        // Exit `t` of the current cell — the next boundary crossing. Its span
722        // from `t_curr` is the ray's path through this cell (Volumetric).
723        let exit_axis = min_axis(t_max);
724        let t_exit = t_max[exit_axis];
725        let idx = dense.idx_of(cell);
726        let solid_here = dense.occ[idx];
727        if solid_here && depth >= NEAR_Z {
728            let mat_id = if dense.mat.is_empty() {
729                shade_ctx.material
730            } else {
731                dense.mat[idx]
732            };
733            let m = shade_ctx.materials.get(mat_id);
734            if m.is_opaque() {
735                acc.opaque = Some((shade_layer(idx, cell, normal), t_curr));
736                touched = true;
737                break;
738            }
739            let a = f32::from(m.alpha) / 255.0 * (f32::from(shade_ctx.alpha_mul) / 255.0);
740            if m.mode == BlendMode::Volumetric {
741                // Per-cell Beer–Lambert: opacity weighted by traversed length
742                // (in voxel units), so a thin sliver contributes ≈0 and a
743                // filled volume thickens smoothly with depth. Always occludes.
744                let seg_len = (t_exit - t_curr).max(0.0) * dir_len;
745                let eff_a = 1.0 - (1.0 - a).powf(seg_len);
746                let lit = rgb_to_f32(shade_layer(idx, cell, normal));
747                acc.rgb[0] += acc.trans * eff_a * lit[0];
748                acc.rgb[1] += acc.trans * eff_a * lit[1];
749                acc.rgb[2] += acc.trans * eff_a * lit[2];
750                acc.trans *= 1.0 - eff_a;
751                touched = true;
752                prev_mat = mat_id;
753                if acc.trans < 1.0 / 256.0 {
754                    break;
755                }
756            } else if !prev_solid || mat_id != prev_mat {
757                // AlphaBlend / Additive: one alpha layer per solid-run entry or
758                // material change (thickness-independent — shells, glass).
759                let lit = rgb_to_f32(shade_layer(idx, cell, normal));
760                acc.rgb[0] += acc.trans * a * lit[0];
761                acc.rgb[1] += acc.trans * a * lit[1];
762                acc.rgb[2] += acc.trans * a * lit[2];
763                if m.mode == BlendMode::AlphaBlend {
764                    acc.trans *= 1.0 - a; // Additive glow does not occlude.
765                }
766                touched = true;
767                prev_mat = mat_id;
768                if acc.trans < 1.0 / 256.0 {
769                    break;
770                }
771            }
772        }
773        prev_solid = solid_here;
774        t_curr = t_exit;
775        cell[exit_axis] += step[exit_axis];
776        t_max[exit_axis] += t_delta[exit_axis];
777        normal = [0.0; 3];
778        normal[exit_axis] = -(step[exit_axis] as f32);
779    }
780
781    touched.then_some(acc)
782}
783
784/// Draw one KV6 [`Sprite`] into `(fb, zb)` by per-pixel ray casting,
785/// depth-compositing against whatever the terrain pass already wrote.
786/// Returns the number of pixels written.
787///
788/// `cam` / `settings` are the **same** per-frame projection the DDA
789/// terrain pass used (build via [`crate::camera_math::derive`]), so
790/// sprite and terrain share one pinhole and z convention. `pitch_pixels`
791/// is the framebuffer row stride. Honours `SPRITE_FLAG_INVISIBLE`
792/// (skip) and `SPRITE_FLAG_NO_Z` (write without the depth test).
793#[allow(
794    clippy::too_many_arguments,
795    clippy::cast_possible_truncation,
796    clippy::cast_sign_loss
797)]
798#[must_use]
799pub fn draw_sprite_dda(
800    fb: &mut [u32],
801    zb: &mut [f32],
802    pitch_pixels: usize,
803    width: u32,
804    height: u32,
805    cam: &CameraState,
806    settings: &OpticastSettings,
807    sprite: &Sprite,
808) -> u32 {
809    if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
810        return 0;
811    }
812    draw_sprite_dda_shaded(
813        fb,
814        zb,
815        pitch_pixels,
816        width,
817        height,
818        cam,
819        settings,
820        sprite,
821        None,
822    )
823}
824
825/// Draw one KV6 [`Sprite`], optionally with a translucent material (TV
826/// stage) — the [`draw_sprite_dense_shaded`] counterpart of
827/// [`draw_sprite_dda`]. `shade_ctx == None` (or an opaque effective
828/// material) renders the sprite opaque, byte-for-byte unchanged.
829#[allow(clippy::too_many_arguments)]
830#[must_use]
831pub fn draw_sprite_dda_shaded(
832    fb: &mut [u32],
833    zb: &mut [f32],
834    pitch_pixels: usize,
835    width: u32,
836    height: u32,
837    cam: &CameraState,
838    settings: &OpticastSettings,
839    sprite: &Sprite,
840    shade_ctx: Option<SpriteShade>,
841) -> u32 {
842    if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
843        return 0;
844    }
845    // Decodes the KV6 to a dense grid each call (the per-frame cost an
846    // animated clip avoids via [`ClipFlipbook`]'s cached grids). A non-empty
847    // `material_map` classifies voxels into per-voxel materials (TV.3 mixed
848    // models); an empty one is the plain uniform-material decode.
849    let dense = if sprite.material_map.is_empty() {
850        SpriteDense::from_kv6(&sprite.kv6)
851    } else {
852        SpriteDense::from_kv6_with_materials(&sprite.kv6, &sprite.material_map)
853    };
854    draw_sprite_dense_shaded(
855        fb,
856        zb,
857        pitch_pixels,
858        width,
859        height,
860        cam,
861        settings,
862        &dense,
863        sprite.p,
864        sprite.s,
865        sprite.h,
866        sprite.f,
867        sprite.flags,
868        shade_ctx,
869    )
870}
871
872/// Draw a pre-decoded [`SpriteDense`] at a world pose — the generalised
873/// core of [`draw_sprite_dda`], shared by the KV6 path and animated
874/// [`ClipFlipbook`] frames. `pos` is the world pivot; `s`/`h`/`f` are the
875/// model→world basis columns (local +x/+y/+z); `flags` honours
876/// [`SPRITE_FLAG_INVISIBLE`] / [`SPRITE_FLAG_NO_Z`]. Returns pixels written.
877///
878/// Fully opaque (the existing first-hit path). For translucent sprites use
879/// [`draw_sprite_dense_shaded`].
880#[allow(clippy::too_many_arguments)]
881#[must_use]
882pub fn draw_sprite_dense(
883    fb: &mut [u32],
884    zb: &mut [f32],
885    pitch_pixels: usize,
886    width: u32,
887    height: u32,
888    cam: &CameraState,
889    settings: &OpticastSettings,
890    dense: &SpriteDense,
891    pos: [f32; 3],
892    s: [f32; 3],
893    h: [f32; 3],
894    f: [f32; 3],
895    flags: u32,
896) -> u32 {
897    draw_sprite_dense_shaded(
898        fb,
899        zb,
900        pitch_pixels,
901        width,
902        height,
903        cam,
904        settings,
905        dense,
906        pos,
907        s,
908        h,
909        f,
910        flags,
911        None,
912    )
913}
914
915/// Draw a pre-decoded [`SpriteDense`] at a world pose, optionally with a
916/// translucent material (TV stage). `shade_ctx`:
917/// - `None`, or a `Some` whose effective material is opaque ⇒ the existing
918///   first-hit, depth-tested opaque path, **byte-for-byte unchanged**.
919/// - `Some` with a translucent uniform material (or a non-empty per-voxel
920///   `mat` array) ⇒ front-to-back accumulate-and-continue: each ray marches
921///   through the sprite compositing `AlphaBlend`/`Additive` layers over what
922///   lies behind (terrain/sky already in the framebuffer, or an opaque voxel
923///   of the model). Opaque voxels write the model surface depth; purely
924///   translucent pixels composite over the framebuffer without touching the
925///   z-buffer (they do not occlude). See `PORTING-TRANSPARENCY.md`.
926#[allow(
927    clippy::too_many_arguments,
928    clippy::cast_possible_truncation,
929    clippy::cast_sign_loss
930)]
931#[must_use]
932pub fn draw_sprite_dense_shaded(
933    fb: &mut [u32],
934    zb: &mut [f32],
935    pitch_pixels: usize,
936    width: u32,
937    height: u32,
938    cam: &CameraState,
939    settings: &OpticastSettings,
940    dense: &SpriteDense,
941    pos: [f32; 3],
942    s: [f32; 3],
943    h: [f32; 3],
944    f: [f32; 3],
945    flags: u32,
946    shade_ctx: Option<SpriteShade>,
947) -> u32 {
948    if flags & SPRITE_FLAG_INVISIBLE != 0 || dense.occ.is_empty() {
949        return 0;
950    }
951    let Some(minv) = invert_basis(s, h, f) else {
952        return 0;
953    };
954    let pivot = dense.pivot;
955    let no_z = flags & SPRITE_FLAG_NO_Z != 0;
956    // BB.2b — per-instance billboard lighting mode (flags bits 6/7).
957    let light_mode = SpriteLightMode::from_flags(flags);
958
959    // Screen bounding box from the 8 corners of the local voxel box.
960    let Some(rect) = project_screen_rect(dense, pos, s, h, f, cam, settings, width, height) else {
961        return 0;
962    };
963
964    // Per-sprite gate: a sprite whose effective material is opaque (the
965    // common case, and every sprite while no translucent material is
966    // defined) takes the original loop unchanged — so the opaque world stays
967    // bit-identical. Only a genuinely translucent sprite runs the accumulate
968    // loop.
969    let layers =
970        shade_ctx.filter(|s| !dense.mat.is_empty() || !s.materials.get(s.material).is_opaque());
971
972    debug_assert_eq!(fb.len(), zb.len());
973    let target = RasterTarget::new(fb, zb);
974    // PF.8 — one row of the sprite's screen rect; rows are disjoint, every
975    // pixel reads/writes only its own index, so rows parallelise safely
976    // (the terrain DDA's `RasterTarget` band contract).
977    let draw_row = |py: u32| -> u32 {
978        let mut written = 0u32;
979        let row = py as usize * pitch_pixels;
980        for px in rect.0..rect.2 {
981            let (origin, dir) = pixel_ray(cam, settings, px, py);
982            // World ray → sprite-local voxel space.
983            let rel = [origin[0] - pos[0], origin[1] - pos[1], origin[2] - pos[2]];
984            let ol = mat_apply(&minv, rel);
985            let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
986            let dir_local = mat_apply(&minv, dir);
987            let fwd_dot =
988                dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
989            let idx = row + px as usize;
990
991            if let Some(shade_ctx) = layers {
992                // ---- translucent: accumulate front-to-back ----
993                if fwd_dot <= 1e-6 {
994                    continue;
995                }
996                // Terrain/opaque depth cutoff (perpendicular distance, the
997                // same units `cast_local_layers` compares `t_curr·fwd_dot`
998                // against — NOT a ray parameter, since the CPU `dir` is
999                // unnormalised). SAFETY: idx in rect ⊂ (width,height).
1000                let max_t = if no_z {
1001                    f32::INFINITY
1002                } else {
1003                    unsafe { target.read_depth(idx) }
1004                };
1005                let Some(acc) = cast_local_layers(
1006                    dense,
1007                    origin_local,
1008                    dir_local,
1009                    fwd_dot,
1010                    max_t,
1011                    shade_ctx,
1012                    s,
1013                    h,
1014                    f,
1015                    pos,
1016                    light_mode,
1017                ) else {
1018                    continue;
1019                };
1020                // SAFETY: idx in bounds; single-threaded writer.
1021                let wrote = unsafe {
1022                    match acc.opaque {
1023                        Some((bg_color, t)) => {
1024                            // Opaque model surface behind the translucent
1025                            // layers: composite over it, write surface depth.
1026                            let bg = rgb_to_f32(bg_color);
1027                            let out = f32_to_rgb([
1028                                acc.rgb[0] + acc.trans * bg[0],
1029                                acc.rgb[1] + acc.trans * bg[1],
1030                                acc.rgb[2] + acc.trans * bg[2],
1031                            ]);
1032                            let depth = t * fwd_dot;
1033                            if no_z {
1034                                target.write_color(idx, out);
1035                                target.write_depth(idx, depth);
1036                                true
1037                            } else {
1038                                target.z_test_write(idx, out, depth)
1039                            }
1040                        }
1041                        None => {
1042                            // Ray exited (or fully attenuated) with no opaque
1043                            // model voxel: composite over the framebuffer
1044                            // (terrain/sky). Translucent layers do not occlude,
1045                            // so the z-buffer is left untouched.
1046                            let bg = rgb_to_f32(target.read_color(idx));
1047                            let out = f32_to_rgb([
1048                                acc.rgb[0] + acc.trans * bg[0],
1049                                acc.rgb[1] + acc.trans * bg[1],
1050                                acc.rgb[2] + acc.trans * bg[2],
1051                            ]);
1052                            target.write_color(idx, out);
1053                            true
1054                        }
1055                    }
1056                };
1057                written += u32::from(wrote);
1058            } else {
1059                // ---- opaque: first-hit path ----
1060                let Some((color, t, n_local, cell)) = cast_local(dense, origin_local, dir_local)
1061                else {
1062                    continue;
1063                };
1064                let depth = t * fwd_dot;
1065                if depth < NEAR_Z {
1066                    continue;
1067                }
1068                // DL.7 — dynamic lighting when a rig is active (sun + point
1069                // lights + cel + ramp, flat per voxel); else the baked `shade`
1070                // (byte-identical). The model-local face normal + voxel centre
1071                // are rotated into world space via the instance basis (s,h,f).
1072                let dl = shade_ctx.map_or(CpuLights::default(), |s| s.lights);
1073                let lit = if dl.enabled {
1074                    let to_world = |v: [f32; 3]| {
1075                        [
1076                            v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
1077                            v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
1078                            v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
1079                        ]
1080                    };
1081                    let n_world = to_world(n_local);
1082                    let rel = [
1083                        cell[0] as f32 + 0.5 - pivot[0],
1084                        cell[1] as f32 + 0.5 - pivot[1],
1085                        cell[2] as f32 + 0.5 - pivot[2],
1086                    ];
1087                    let wc = to_world(rel);
1088                    let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
1089                    let albedo = [
1090                        ((color >> 16) & 0xff) as f32 / 255.0,
1091                        ((color >> 8) & 0xff) as f32 / 255.0,
1092                        (color & 0xff) as f32 / 255.0,
1093                    ];
1094                    // XS.2 — sprite receives shadows: a world-space query
1095                    // against the scene occluder (sprite shading is already in
1096                    // world space, so an identity transform). `None` ⇒ unshadowed.
1097                    let mut ws = shade_ctx.and_then(|s| s.shadow).map(|occ| WorldShadow {
1098                        ctx: WorldShadowCtx::identity(occ),
1099                    });
1100                    let tester = ws.as_mut().map(|t| t as &mut dyn ShadowTester);
1101                    shade_dynamic_mode(light_mode, albedo, n_world, center, &dl, tester)
1102                } else {
1103                    shade(color, 0)
1104                };
1105                // Per-instance RGB tint (white ⇒ no-op).
1106                let lit = tint_packed(lit, shade_ctx.map_or(0x00FF_FFFF, |s| s.tint));
1107                // SAFETY: idx in-bounds for the rect within (width, height);
1108                // single-threaded writer.
1109                let wrote = unsafe {
1110                    if no_z {
1111                        target.write_color(idx, lit);
1112                        target.write_depth(idx, depth);
1113                        true
1114                    } else {
1115                        target.z_test_write(idx, lit, depth)
1116                    }
1117                };
1118                written += u32::from(wrote);
1119            }
1120        }
1121        written
1122    };
1123    // PF.8 — the sprite pass was entirely single-threaded after the
1124    // rayon-parallel terrain; large footprints (a close-up character,
1125    // a big translucent volume) now use the same worker pool. Small
1126    // rects stay serial — per-sprite rayon overhead would dominate.
1127    let rows = rect.3.saturating_sub(rect.1) as usize;
1128    let cols = rect.2.saturating_sub(rect.0) as usize;
1129    const SPRITE_PAR_MIN_PIXELS: usize = 64 * 64;
1130    if rows >= 2 && rows * cols >= SPRITE_PAR_MIN_PIXELS {
1131        use rayon::prelude::*;
1132        (rect.1..rect.3).into_par_iter().map(draw_row).sum()
1133    } else {
1134        (rect.1..rect.3).map(draw_row).sum()
1135    }
1136}
1137
1138/// Project the sprite's local voxel AABB to a clamped screen rectangle
1139/// `(x0, y0, x1, y1)` (half-open). `None` if it can't appear; falls back
1140/// to the full viewport when the box straddles the near plane (rare).
1141#[allow(
1142    clippy::cast_possible_truncation,
1143    clippy::cast_sign_loss,
1144    clippy::cast_precision_loss
1145)]
1146fn project_screen_rect(
1147    dense: &SpriteDense,
1148    pos: [f32; 3],
1149    s: [f32; 3],
1150    h: [f32; 3],
1151    f: [f32; 3],
1152    cam: &CameraState,
1153    settings: &OpticastSettings,
1154    width: u32,
1155    height: u32,
1156) -> Option<(u32, u32, u32, u32)> {
1157    let (xs, ys, zs) = (
1158        dense.dims[0] as f32,
1159        dense.dims[1] as f32,
1160        dense.dims[2] as f32,
1161    );
1162    let (xp, yp, zp) = (dense.pivot[0], dense.pivot[1], dense.pivot[2]);
1163    let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
1164    let mut all_front = true;
1165    for &cx in &[0.0, xs] {
1166        for &cy in &[0.0, ys] {
1167            for &cz in &[0.0, zs] {
1168                // Local → world via the sprite basis about the pivot.
1169                let lx = cx - xp;
1170                let ly = cy - yp;
1171                let lz = cz - zp;
1172                let world = [
1173                    pos[0] + lx * s[0] + ly * h[0] + lz * f[0],
1174                    pos[1] + lx * s[1] + ly * h[1] + lz * f[1],
1175                    pos[2] + lx * s[2] + ly * h[2] + lz * f[2],
1176                ];
1177                let rel = [
1178                    world[0] - cam.pos[0],
1179                    world[1] - cam.pos[1],
1180                    world[2] - cam.pos[2],
1181                ];
1182                let cz_cam =
1183                    rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
1184                if cz_cam < NEAR_Z {
1185                    all_front = false;
1186                    continue;
1187                }
1188                let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
1189                let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
1190                let sx = settings.hx + cx_cam / cz_cam * settings.hz;
1191                let sy = settings.hy + cy_cam / cz_cam * settings.hz;
1192                x0 = x0.min(sx);
1193                y0 = y0.min(sy);
1194                x1 = x1.max(sx);
1195                y1 = y1.max(sy);
1196            }
1197        }
1198    }
1199    let (w, h) = (width as f32, height as f32);
1200    let (rx0, ry0, rx1, ry1) = if all_front {
1201        (
1202            (x0 - 1.0).max(0.0),
1203            (y0 - 1.0).max(0.0),
1204            (x1 + 1.0).min(w),
1205            (y1 + 1.0).min(h),
1206        )
1207    } else {
1208        // Straddles the near plane → scan the whole viewport.
1209        (0.0, 0.0, w, h)
1210    };
1211    if rx0 >= rx1 || ry0 >= ry1 {
1212        return None;
1213    }
1214    Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
1215}
1216
1217/// CPU-side decoded animated voxel clip: every frame's [`SpriteDense`]
1218/// is cached at construction, so per-frame playback is a grid **select**
1219/// — not the per-frame voxel-volume decode [`draw_sprite_dda`] pays each
1220/// call. The CPU counterpart to the GPU flipbook (VCL.2). Build once from
1221/// a [`DecodedClip`], then [`draw_frame`](ClipFlipbook::draw_frame) the
1222/// active frame each render.
1223pub struct ClipFlipbook {
1224    /// PF.8 — `Arc` per frame so the shadow occluder shares the cached
1225    /// decode instead of deep-cloning the current frame per rebuild.
1226    frames: Vec<Arc<SpriteDense>>,
1227}
1228
1229impl ClipFlipbook {
1230    /// An empty flipbook (no frames) — a tombstone for a removed clip;
1231    /// [`draw_frame`](Self::draw_frame) always draws nothing.
1232    #[must_use]
1233    pub fn empty() -> Self {
1234        Self { frames: Vec::new() }
1235    }
1236
1237    /// Decode + cache every frame of `clip` (one [`SpriteDense`] each).
1238    #[must_use]
1239    pub fn from_decoded(clip: &DecodedClip) -> Self {
1240        Self::from_decoded_with_materials(clip, &[])
1241    }
1242
1243    /// Like [`from_decoded`](Self::from_decoded) but classifies every frame's
1244    /// voxels into per-voxel material ids by colour (TV.3 mixed models) via
1245    /// `material_map` — the clip analogue of
1246    /// [`SpriteDense::from_kv6_with_materials`]. An empty map yields the same
1247    /// all-opaque result as `from_decoded`.
1248    #[must_use]
1249    pub fn from_decoded_with_materials(clip: &DecodedClip, material_map: &[(Rgb, u8)]) -> Self {
1250        let frames = clip
1251            .frames
1252            .iter()
1253            .map(|frame| {
1254                Arc::new(SpriteDense::from_voxel_frame_with_materials(
1255                    frame,
1256                    clip.dims,
1257                    clip.pivot,
1258                    material_map,
1259                ))
1260            })
1261            .collect();
1262        Self { frames }
1263    }
1264
1265    /// Number of cached frames. `0` for a tombstone from
1266    /// [`empty`](Self::empty).
1267    #[must_use]
1268    pub fn frame_count(&self) -> usize {
1269        self.frames.len()
1270    }
1271
1272    /// Borrow frame `frame`'s cached dense grid, if in range.
1273    #[must_use]
1274    pub fn frame(&self, frame: usize) -> Option<&SpriteDense> {
1275        self.frames.get(frame).map(Arc::as_ref)
1276    }
1277
1278    /// Share frame `frame`'s cached dense grid (PF.8) — a cheap refcount
1279    /// clone for the shadow occluder (was a deep clone per rebuild).
1280    #[must_use]
1281    pub fn frame_arc(&self, frame: usize) -> Option<Arc<SpriteDense>> {
1282        self.frames.get(frame).cloned()
1283    }
1284
1285    /// Replace one frame's cached dense grid in place — the CPU side of an
1286    /// editor's single-frame edit (no re-decode of the other frames).
1287    /// Returns `false` if `frame` is out of range.
1288    pub fn set_frame(&mut self, frame: usize, dense: SpriteDense) -> bool {
1289        match self.frames.get_mut(frame) {
1290            Some(slot) => {
1291                *slot = Arc::new(dense);
1292                true
1293            }
1294            None => false,
1295        }
1296    }
1297
1298    /// Draw frame `frame` at a world pose via [`draw_sprite_dense`] —
1299    /// `pos` is the world pivot, `s`/`h`/`f` the model→world basis columns.
1300    /// Returns pixels written (0 if `frame` is out of range).
1301    #[allow(clippy::too_many_arguments)]
1302    #[must_use]
1303    pub fn draw_frame(
1304        &self,
1305        fb: &mut [u32],
1306        zb: &mut [f32],
1307        pitch_pixels: usize,
1308        width: u32,
1309        height: u32,
1310        cam: &CameraState,
1311        settings: &OpticastSettings,
1312        frame: usize,
1313        pos: [f32; 3],
1314        s: [f32; 3],
1315        h: [f32; 3],
1316        f: [f32; 3],
1317        flags: u32,
1318    ) -> u32 {
1319        self.draw_frame_shaded(
1320            fb,
1321            zb,
1322            pitch_pixels,
1323            width,
1324            height,
1325            cam,
1326            settings,
1327            frame,
1328            pos,
1329            s,
1330            h,
1331            f,
1332            flags,
1333            None,
1334        )
1335    }
1336
1337    /// Draw frame `frame`, optionally with a translucent material (TV stage)
1338    /// — the [`draw_sprite_dense_shaded`] counterpart of
1339    /// [`draw_frame`](Self::draw_frame). `shade_ctx == None` (or an opaque
1340    /// effective material) renders the frame opaque, unchanged.
1341    #[allow(clippy::too_many_arguments)]
1342    #[must_use]
1343    pub fn draw_frame_shaded(
1344        &self,
1345        fb: &mut [u32],
1346        zb: &mut [f32],
1347        pitch_pixels: usize,
1348        width: u32,
1349        height: u32,
1350        cam: &CameraState,
1351        settings: &OpticastSettings,
1352        frame: usize,
1353        pos: [f32; 3],
1354        s: [f32; 3],
1355        h: [f32; 3],
1356        f: [f32; 3],
1357        flags: u32,
1358        shade_ctx: Option<SpriteShade>,
1359    ) -> u32 {
1360        let Some(dense) = self.frames.get(frame) else {
1361            return 0;
1362        };
1363        draw_sprite_dense_shaded(
1364            fb,
1365            zb,
1366            pitch_pixels,
1367            width,
1368            height,
1369            cam,
1370            settings,
1371            dense,
1372            pos,
1373            s,
1374            h,
1375            f,
1376            flags,
1377            shade_ctx,
1378        )
1379    }
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384    use super::*;
1385    use crate::camera_math;
1386    use crate::Camera;
1387    use roxlap_formats::kv6::Kv6;
1388    use roxlap_formats::material::{Material, MaterialTable};
1389    use roxlap_formats::VoxColor;
1390
1391    /// BB.2b — `WorldUp` lights a side-facing billboard as if it faced world
1392    /// up (the sun directly overhead); `AmbientOnly` drops the sun term.
1393    #[test]
1394    fn sprite_light_mode_world_up_and_ambient_only() {
1395        let lights = CpuLights {
1396            enabled: true,
1397            sun: true,
1398            sun_dir: [0.0, 0.0, -1.0], // toward the sun (world up, z-down)
1399            sun_color: [1.0, 1.0, 1.0],
1400            sun_intensity: 1.0,
1401            sun_casts_shadow: false,
1402            points: &[],
1403            ambient: [0.2, 0.2, 0.2],
1404            bands: 0,
1405            shadow_tint: [0.0; 3],
1406            shadow_strength: 0.0,
1407            shadow_bias: 0.0,
1408            shadow_max_dist: 0.0,
1409        };
1410        let a = [1.0, 1.0, 1.0];
1411        let c = [0.0, 0.0, 0.0];
1412        let g = |packed: u32| (packed >> 8) & 0xff; // green channel
1413        let up_n = [0.0, 0.0, -1.0];
1414        let side_n = [1.0, 0.0, 0.0];
1415        let face_up = g(shade_dynamic_mode(
1416            SpriteLightMode::FaceNormal,
1417            a,
1418            up_n,
1419            c,
1420            &lights,
1421            None,
1422        ));
1423        let face_side = g(shade_dynamic_mode(
1424            SpriteLightMode::FaceNormal,
1425            a,
1426            side_n,
1427            c,
1428            &lights,
1429            None,
1430        ));
1431        let amb = g(shade_dynamic_mode(
1432            SpriteLightMode::AmbientOnly,
1433            a,
1434            up_n,
1435            c,
1436            &lights,
1437            None,
1438        ));
1439        let world_up = g(shade_dynamic_mode(
1440            SpriteLightMode::WorldUp,
1441            a,
1442            side_n,
1443            c,
1444            &lights,
1445            None,
1446        ));
1447        assert!(
1448            face_up > face_side,
1449            "a sun-facing face is brighter than a side face"
1450        );
1451        assert!(amb < face_up, "ambient-only drops the sun term");
1452        assert_eq!(
1453            world_up, face_up,
1454            "world-up shades a side-facing billboard as if it faced up"
1455        );
1456        let full = g(shade_dynamic_mode(
1457            SpriteLightMode::FullBright,
1458            a,
1459            side_n,
1460            c,
1461            &lights,
1462            None,
1463        ));
1464        // Full-bright = the albedo at full intensity, ignoring the rig (so a
1465        // glow isn't dimmed by ambient like AmbientOnly is).
1466        assert_eq!(full, 255, "full-bright emits the colour at full intensity");
1467        assert!(full > amb, "full-bright glow is brighter than ambient-only");
1468    }
1469
1470    /// DL.7 — `cast_local` reports the hit's model-local face normal (used to
1471    /// light sprites/clips). A ray crossing air then a solid block via the
1472    /// z face gets a back-facing (-z) normal; the entry voxel (immediately
1473    /// solid) gets a zero normal.
1474    #[test]
1475    fn cast_local_reports_face_normal() {
1476        // Solid only at z >= 4 (air below), full in x/y.
1477        let kv6 = Kv6::from_fn(8, 8, 8, |_, _, z| {
1478            (z >= 4).then_some(VoxColor(0x80_C0_40_20))
1479        });
1480        let dense = SpriteDense::from_kv6(&kv6);
1481        // Ray from below, travelling +z: air (z<4) then the block's top face.
1482        let (_c, _t, n, cell) =
1483            cast_local(&dense, [4.0, 4.0, -5.0], [0.0, 0.0, 1.0]).expect("ray hits the block");
1484        assert_eq!(cell[2], 4, "first solid voxel is the z=4 surface");
1485        assert!(
1486            n[2] < -0.5 && n[0].abs() < 1e-6 && n[1].abs() < 1e-6,
1487            "z-crossing face normal points back toward the ray (-z): {n:?}",
1488        );
1489    }
1490    use roxlap_formats::sprite::Sprite;
1491    use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
1492
1493    fn settings(w: u32, h: u32) -> OpticastSettings {
1494        OpticastSettings::for_oracle_framebuffer(w, h)
1495    }
1496
1497    /// Camera at the origin looking down +y at a sprite ahead.
1498    fn cam_looking_y() -> Camera {
1499        Camera {
1500            pos: [0.0, 0.0, 0.0],
1501            right: [1.0, 0.0, 0.0],
1502            down: [0.0, 0.0, 1.0],
1503            forward: [0.0, 1.0, 0.0],
1504        }
1505    }
1506
1507    /// PS.1 — the CPU sprite DDA honours a **scaled** basis (voxlap
1508    /// heritage: `s/h/f` magnitude = scale). The same cube drawn with
1509    /// 2× / 0.5× columns covers roughly 4× / 0.25× the pixels of the
1510    /// unit pose — the parity the particle system's scale-over-life
1511    /// relies on. Loose bounds: perspective (the scaled cube's front
1512    /// face sits nearer/farther) skews the exact ratio.
1513    #[test]
1514    fn scaled_basis_scales_drawn_extent() {
1515        let kv6 = Kv6::from_fn(8, 8, 8, |_, _, _| Some(VoxColor(0x80_C0_40_20)));
1516        let (w, h) = (64u32, 64u32);
1517        let n = (w * h) as usize;
1518        let cam = cam_looking_y();
1519        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1520        let cfg = settings(w, h);
1521
1522        let px_at = |k: f32| -> u32 {
1523            let mut sprite = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1524            for a in 0..3 {
1525                sprite.s[a] *= k;
1526                sprite.h[a] *= k;
1527                sprite.f[a] *= k;
1528            }
1529            let mut fb = vec![0u32; n];
1530            let mut zb = vec![f32::INFINITY; n];
1531            draw_sprite_dda(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, &sprite)
1532        };
1533
1534        let (unit, double, half) = (px_at(1.0), px_at(2.0), px_at(0.5));
1535        assert!(unit > 0, "unit-scale cube must draw ({unit} px)");
1536        let r2 = f64::from(double) / f64::from(unit);
1537        let rh = f64::from(half) / f64::from(unit);
1538        assert!(
1539            (3.0..8.0).contains(&r2),
1540            "2× scale should roughly quadruple coverage: {unit} → {double} px (×{r2:.2})"
1541        );
1542        assert!(
1543            (0.08..0.5).contains(&rh),
1544            "0.5× scale should roughly quarter coverage: {unit} → {half} px (×{rh:.2})"
1545        );
1546    }
1547
1548    /// Build a [`VoxelFrame`] from a dense `fill(x,y,z) -> Option<color>`.
1549    fn clip_frame(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1550        let owpc = dims[2].div_ceil(32).max(1) as usize;
1551        let cols = (dims[0] * dims[1]) as usize;
1552        let mut occupancy = vec![0u32; cols * owpc];
1553        let mut color_offsets = vec![0u32; cols + 1];
1554        let mut colors = Vec::new();
1555        for y in 0..dims[1] {
1556            for x in 0..dims[0] {
1557                let col = (x + y * dims[0]) as usize;
1558                color_offsets[col] = colors.len() as u32;
1559                for z in 0..dims[2] {
1560                    if let Some(c) = fill(x, y, z) {
1561                        occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1562                        colors.push(c);
1563                    }
1564                }
1565            }
1566        }
1567        color_offsets[cols] = colors.len() as u32;
1568        VoxelFrame {
1569            occupancy,
1570            colors,
1571            color_offsets,
1572        }
1573    }
1574
1575    /// A cached [`ClipFlipbook`] draws distinct frames distinctly — the
1576    /// CPU flipbook select. Frame 0 fills the bottom half (red), frame 1
1577    /// the top half (green); rendered at the same pose they cover
1578    /// different screen pixels in different colours.
1579    #[test]
1580    fn clip_flipbook_frames_render_differently() {
1581        let dims = [8u32, 8, 8];
1582        let f0 = clip_frame(dims, |_x, _y, z| (z < 4).then_some(0x00FF_0000)); // red, low z
1583        let f1 = clip_frame(dims, |_x, _y, z| (z >= 4).then_some(0x0000_FF00)); // green, high z
1584        let clip = VoxelClip::from_frames(
1585            dims,
1586            [4.0, 4.0, 4.0],
1587            1.0,
1588            LoopMode::Loop,
1589            &[f0, f1],
1590            &[],
1591            33,
1592            0,
1593        );
1594        let decoded = clip.decode().expect("decode");
1595        let book = ClipFlipbook::from_decoded(&decoded);
1596        assert_eq!(book.frame_count(), 2);
1597        assert!(book.frame(0).is_some() && book.frame(2).is_none());
1598
1599        let (w, h) = (64u32, 64u32);
1600        let n = (w * h) as usize;
1601        let cam = cam_looking_y();
1602        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1603        let cfg = settings(w, h);
1604        let pose = [0.0, 40.0, 0.0];
1605        let (s, hh, f) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
1606
1607        let render = |frame: usize| -> Vec<u32> {
1608            let mut fb = vec![0u32; n];
1609            let mut zb = vec![f32::INFINITY; n];
1610            let wrote = book.draw_frame(
1611                &mut fb, &mut zb, w as usize, w, h, &cs, &cfg, frame, pose, s, hh, f, 0,
1612            );
1613            assert!(wrote > 0, "frame {frame} should draw some pixels");
1614            fb
1615        };
1616        let fb0 = render(0);
1617        let fb1 = render(1);
1618        assert_ne!(fb0, fb1, "distinct frames must render distinct pixels");
1619        // Each frame shows its own channel: red present in frame 0, green
1620        // present in frame 1.
1621        assert!(fb0.iter().any(|&p| (p & 0x00FF_0000) != 0));
1622        assert!(fb1.iter().any(|&p| (p & 0x0000_FF00) != 0));
1623        // Out-of-range frame draws nothing.
1624        let mut fb = vec![0u32; n];
1625        let mut zb = vec![f32::INFINITY; n];
1626        assert_eq!(
1627            book.draw_frame(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, 9, pose, s, hh, f, 0),
1628            0
1629        );
1630    }
1631
1632    #[test]
1633    fn clip_flipbook_set_frame_replaces_one_frame() {
1634        // The single-frame edit primitive: replace frame 0's dense with
1635        // frame 1's content, in place. Out-of-range → false.
1636        let dims = [8u32, 8, 8];
1637        let f0 = clip_frame(dims, |_, _, z| (z < 4).then_some(0x00FF_0000)); // red
1638        let f1 = clip_frame(dims, |_, _, z| (z >= 4).then_some(0x0000_FF00)); // green
1639        let clip =
1640            VoxelClip::from_frames(dims, [4.0; 3], 1.0, LoopMode::Loop, &[f0, f1], &[], 33, 0);
1641        let decoded = clip.decode().unwrap();
1642        let mut book = ClipFlipbook::from_decoded(&decoded);
1643
1644        let (w, h) = (64u32, 64u32);
1645        let n = (w * h) as usize;
1646        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1647        let cfg = settings(w, h);
1648        let render0 = |b: &ClipFlipbook| -> Vec<u32> {
1649            let mut fb = vec![0u32; n];
1650            let mut zb = vec![f32::INFINITY; n];
1651            let _ = b.draw_frame(
1652                &mut fb,
1653                &mut zb,
1654                w as usize,
1655                w,
1656                h,
1657                &cs,
1658                &cfg,
1659                0,
1660                [0.0, 40.0, 0.0],
1661                [1.0, 0.0, 0.0],
1662                [0.0, 1.0, 0.0],
1663                [0.0, 0.0, 1.0],
1664                0,
1665            );
1666            fb
1667        };
1668
1669        let before = render0(&book);
1670        assert!(
1671            before.iter().any(|&p| (p & 0x00FF_0000) != 0),
1672            "frame 0 is red"
1673        );
1674
1675        // Replace frame 0 with frame 1's dense.
1676        let replacement = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1677        assert!(book.set_frame(0, replacement));
1678        let extra = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1679        assert!(!book.set_frame(9, extra), "out-of-range set_frame is false");
1680
1681        let after = render0(&book);
1682        assert!(
1683            after.iter().any(|&p| (p & 0x0000_FF00) != 0),
1684            "frame 0 now green"
1685        );
1686        assert_ne!(before, after);
1687    }
1688
1689    /// A solid cube sprite in front of the camera is drawn, with the
1690    /// cube colour (shaded) and a sensible centre depth.
1691    #[test]
1692    fn cube_sprite_renders() {
1693        let kv6 = Kv6::solid_cube(8, VoxColor(0x80_C0_40_20));
1694        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1695        let (w, h) = (64u32, 64u32);
1696        let n = (w * h) as usize;
1697        let mut fb = vec![0u32; n];
1698        let mut zb = vec![f32::INFINITY; n];
1699        let cam = cam_looking_y();
1700        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1701        let wrote = draw_sprite_dda(
1702            &mut fb,
1703            &mut zb,
1704            w as usize,
1705            w,
1706            h,
1707            &cs,
1708            &settings(w, h),
1709            &sprite,
1710        );
1711
1712        assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1713        let centre = (h / 2 * w + w / 2) as usize;
1714        assert_eq!(
1715            fb[centre] & 0x00ff_ffff,
1716            0x00_C0_40_20,
1717            "got {:08x}",
1718            fb[centre]
1719        );
1720        // Pivot at world y=40, cube spans y in [36,44] → near face ~36.
1721        assert!(
1722            (zb[centre] - 36.0).abs() < 3.0,
1723            "centre depth {} not ≈ 36",
1724            zb[centre]
1725        );
1726    }
1727
1728    /// A KV6 whose voxel colours store a `0x00` high byte (voxlap's
1729    /// unused `dir` slot, e.g. `sprite_meltsphere.kv6`) must still
1730    /// render its authored RGB, not black — the brightness byte is
1731    /// normalised to full on decode.
1732    #[test]
1733    fn zero_high_byte_sprite_not_black() {
1734        let kv6 = Kv6::solid_cube(8, VoxColor(0x00_C0_40_20));
1735        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1736        let (w, h) = (64u32, 64u32);
1737        let n = (w * h) as usize;
1738        let mut fb = vec![0u32; n];
1739        let mut zb = vec![f32::INFINITY; n];
1740        let cam = cam_looking_y();
1741        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1742        let wrote = draw_sprite_dda(
1743            &mut fb,
1744            &mut zb,
1745            w as usize,
1746            w,
1747            h,
1748            &cs,
1749            &settings(w, h),
1750            &sprite,
1751        );
1752        assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1753        let centre = (h / 2 * w + w / 2) as usize;
1754        assert_eq!(
1755            fb[centre] & 0x00ff_ffff,
1756            0x00_C0_40_20,
1757            "zero-high-byte sprite rendered as {:08x} (black bug)",
1758            fb[centre]
1759        );
1760    }
1761
1762    /// A sprite occludes / is occluded by the z-buffer: a nearer
1763    /// pre-filled depth blocks the sprite; a farther one lets it win.
1764    #[test]
1765    fn sprite_respects_zbuffer() {
1766        let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1767        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1768        let (w, h) = (32u32, 32u32);
1769        let n = (w * h) as usize;
1770        let cam = cam_looking_y();
1771        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1772        let centre = (h / 2 * w + w / 2) as usize;
1773
1774        // Terrain in front (depth 10 < ~36) → sprite blocked at centre.
1775        let mut fb = vec![0u32; n];
1776        let mut zb = vec![f32::INFINITY; n];
1777        fb[centre] = 0x80_11_22_33;
1778        zb[centre] = 10.0;
1779        let _ = draw_sprite_dda(
1780            &mut fb,
1781            &mut zb,
1782            w as usize,
1783            w,
1784            h,
1785            &cs,
1786            &settings(w, h),
1787            &sprite,
1788        );
1789        assert_eq!(
1790            fb[centre], 0x80_11_22_33,
1791            "near terrain must occlude sprite"
1792        );
1793
1794        // Terrain behind (depth 100) → sprite wins.
1795        let mut fb2 = vec![0u32; n];
1796        let mut zb2 = vec![f32::INFINITY; n];
1797        fb2[centre] = 0x80_11_22_33;
1798        zb2[centre] = 100.0;
1799        let _ = draw_sprite_dda(
1800            &mut fb2,
1801            &mut zb2,
1802            w as usize,
1803            w,
1804            h,
1805            &cs,
1806            &settings(w, h),
1807            &sprite,
1808        );
1809        assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
1810        assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
1811    }
1812
1813    /// The covered screen rect (min/max px,py) of whatever the sprite
1814    /// painted — used to compare an axis-aligned vs a rotated pose.
1815    fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
1816        let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
1817        for py in 0..h {
1818            for px in 0..w {
1819                if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
1820                    x0 = x0.min(px);
1821                    y0 = y0.min(py);
1822                    x1 = x1.max(px);
1823                    y1 = y1.max(py);
1824                }
1825            }
1826        }
1827        (x0, y0, x1, y1)
1828    }
1829
1830    /// A non-cube box drawn axis-aligned vs. drawn with a per-instance
1831    /// transform that swaps its long axis onto the screen's other axis
1832    /// flips the silhouette's aspect ratio. Pins that the `s/h/f` basis
1833    /// (the path `DynSpriteTransform` feeds) actually reorients the model.
1834    #[test]
1835    fn posed_basis_reorients_silhouette() {
1836        // Wide-in-local-x, short-in-local-z box → appears wide on screen
1837        // (screen-x = world-x via `right`, screen-y = world-z via `down`).
1838        let kv6 = Kv6::solid_box(16, 4, 4, VoxColor(0x80_C0_40_20));
1839        let (w, h) = (64u32, 64u32);
1840        let n = (w * h) as usize;
1841        let cam = cam_looking_y();
1842        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1843
1844        // Axis-aligned: wide silhouette.
1845        let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1846        let mut fb = vec![0u32; n];
1847        let mut zb = vec![f32::INFINITY; n];
1848        let _ = draw_sprite_dda(
1849            &mut fb,
1850            &mut zb,
1851            w as usize,
1852            w,
1853            h,
1854            &cs,
1855            &settings(w, h),
1856            &aa,
1857        );
1858        let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
1859        let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
1860        assert!(
1861            aa_wide > 4,
1862            "axis-aligned box should be wider than tall (got w-h={aa_wide})"
1863        );
1864
1865        // Posed: map local +x onto world +z and local +z onto world +x
1866        // (det = -1 ≠ 0). Same box now reads tall on screen.
1867        let mut posed = aa.clone();
1868        posed.s = [0.0, 0.0, 1.0]; // local +x ↦ world +z (screen down)
1869        posed.h = [0.0, 1.0, 0.0]; // local +y ↦ world +y (depth)
1870        posed.f = [1.0, 0.0, 0.0]; // local +z ↦ world +x (screen right)
1871        let mut fb2 = vec![0u32; n];
1872        let mut zb2 = vec![f32::INFINITY; n];
1873        let _ = draw_sprite_dda(
1874            &mut fb2,
1875            &mut zb2,
1876            w as usize,
1877            w,
1878            h,
1879            &cs,
1880            &settings(w, h),
1881            &posed,
1882        );
1883        let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
1884        let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
1885        assert!(
1886            posed_tall > 4,
1887            "posed box should be taller than wide (got h-w={posed_tall})"
1888        );
1889    }
1890
1891    /// A degenerate (singular) basis — `det == 0` — makes the sprite
1892    /// silently skip rather than panic (the `DynSpriteTransform` guard).
1893    #[test]
1894    fn degenerate_basis_draws_nothing() {
1895        let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1896        let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1897        sprite.f = sprite.s; // two equal columns → det 0
1898        let (w, h) = (32u32, 32u32);
1899        let n = (w * h) as usize;
1900        let mut fb = vec![0u32; n];
1901        let mut zb = vec![f32::INFINITY; n];
1902        let cam = cam_looking_y();
1903        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1904        let wrote = draw_sprite_dda(
1905            &mut fb,
1906            &mut zb,
1907            w as usize,
1908            w,
1909            h,
1910            &cs,
1911            &settings(w, h),
1912            &sprite,
1913        );
1914        assert_eq!(wrote, 0, "singular basis must skip, not panic");
1915    }
1916
1917    /// An invisible sprite draws nothing.
1918    #[test]
1919    fn invisible_sprite_skipped() {
1920        let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1921        let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1922        sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
1923        let (w, h) = (32u32, 32u32);
1924        let n = (w * h) as usize;
1925        let mut fb = vec![0u32; n];
1926        let mut zb = vec![f32::INFINITY; n];
1927        let cam = cam_looking_y();
1928        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1929        let wrote = draw_sprite_dda(
1930            &mut fb,
1931            &mut zb,
1932            w as usize,
1933            w,
1934            h,
1935            &cs,
1936            &settings(w, h),
1937            &sprite,
1938        );
1939        assert_eq!(wrote, 0);
1940    }
1941
1942    // ---------- TV.1a: translucent accumulate-and-continue path ----------
1943
1944    /// Draw a uniform-material 8³ cube (RGB `0xC0_40_20`) at world y=40 over
1945    /// a `bg`-filled framebuffer with z-buffer `zb_v`, using palette id 1 =
1946    /// `mat`. Returns `(centre_pixel, full_framebuffer)`.
1947    fn draw_cube_shaded(mat: Material, alpha_mul: u8, bg: u32, zb_v: f32) -> (u32, Vec<u32>) {
1948        let mut table = MaterialTable::new();
1949        table.set(1, mat);
1950        let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
1951        let (w, h) = (64u32, 64u32);
1952        let n = (w * h) as usize;
1953        let mut fb = vec![bg; n];
1954        let mut zb = vec![zb_v; n];
1955        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1956        let sh = SpriteShade {
1957            materials: &table,
1958            lights: CpuLights::default(),
1959            material: 1,
1960            alpha_mul,
1961            tint: 0x00FF_FFFF,
1962            shadow: None,
1963        };
1964        let _ = draw_sprite_dense_shaded(
1965            &mut fb,
1966            &mut zb,
1967            w as usize,
1968            w,
1969            h,
1970            &cs,
1971            &settings(w, h),
1972            &dense,
1973            [0.0, 40.0, 0.0],
1974            [1.0, 0.0, 0.0],
1975            [0.0, 1.0, 0.0],
1976            [0.0, 0.0, 1.0],
1977            0,
1978            Some(sh),
1979        );
1980        (fb[(h / 2 * w + w / 2) as usize], fb)
1981    }
1982
1983    /// An additive sprite over a dark background brightens it (glow) — and
1984    /// never darkens any channel below the background.
1985    #[test]
1986    fn additive_sprite_brightens_background() {
1987        let bg = 0x80_20_20_20;
1988        let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, f32::INFINITY);
1989        let (cr, cg, cb) = ((centre >> 16) & 0xff, (centre >> 8) & 0xff, centre & 0xff);
1990        assert!(
1991            cr > 0x20 && cg > 0x20 && cb >= 0x20,
1992            "centre {centre:08x} should be brighter than bg"
1993        );
1994        // Red channel (sprite 0xC0) lifts the most.
1995        assert!(
1996            cr >= cg && cr >= cb,
1997            "additive of a red-dominant cube stays red-dominant"
1998        );
1999    }
2000
2001    /// An alpha-blend sprite composites *between* the background and its own
2002    /// colour — neither equal to the bare background nor the opaque colour.
2003    #[test]
2004    fn alpha_blend_sprite_between_bg_and_color() {
2005        let bg = 0x80_20_20_20;
2006        let (centre, _) = draw_cube_shaded(Material::alpha_blend(128), 255, bg, f32::INFINITY);
2007        let cr = (centre >> 16) & 0xff;
2008        assert!(
2009            cr > 0x20,
2010            "blended red must rise above bg 0x20 (got {cr:02x})"
2011        );
2012        assert!(
2013            cr < 0xC0,
2014            "blended red must stay below opaque 0xC0 (got {cr:02x})"
2015        );
2016        // Distinct from both endpoints.
2017        assert_ne!(centre & 0x00ff_ffff, bg & 0x00ff_ffff);
2018        assert_ne!(centre & 0x00ff_ffff, 0x00_C0_40_20);
2019    }
2020
2021    /// The per-instance `alpha_mul` scales opacity: a lower multiplier keeps
2022    /// more of the background (less of the sprite colour).
2023    #[test]
2024    fn alpha_mul_scales_opacity() {
2025        let bg = 0x80_20_20_20;
2026        let (full, _) = draw_cube_shaded(Material::alpha_blend(255), 255, bg, f32::INFINITY);
2027        let (faded, _) = draw_cube_shaded(Material::alpha_blend(255), 64, bg, f32::INFINITY);
2028        let r_full = (full >> 16) & 0xff;
2029        let r_faded = (faded >> 16) & 0xff;
2030        // Both lift red above bg, but the faded one stays closer to bg.
2031        assert!(
2032            r_full > r_faded,
2033            "alpha_mul=255 ({r_full:02x}) more opaque than 64 ({r_faded:02x})"
2034        );
2035        assert!(r_faded > 0x20, "even faded lifts above bg");
2036    }
2037
2038    /// A `SpriteShade` whose effective material is **opaque** (id 0) renders
2039    /// byte-for-byte identically to the plain opaque path — the per-sprite
2040    /// gate that keeps the opaque world unchanged.
2041    #[test]
2042    fn opaque_shade_ctx_matches_plain_path() {
2043        let table = MaterialTable::new();
2044        let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
2045        let (w, h) = (64u32, 64u32);
2046        let n = (w * h) as usize;
2047        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2048        let pose = (
2049            [0.0, 40.0, 0.0],
2050            [1.0, 0.0, 0.0],
2051            [0.0, 1.0, 0.0],
2052            [0.0, 0.0, 1.0],
2053        );
2054
2055        let mut fb_plain = vec![0u32; n];
2056        let mut zb_plain = vec![f32::INFINITY; n];
2057        let _ = draw_sprite_dense(
2058            &mut fb_plain,
2059            &mut zb_plain,
2060            w as usize,
2061            w,
2062            h,
2063            &cs,
2064            &settings(w, h),
2065            &dense,
2066            pose.0,
2067            pose.1,
2068            pose.2,
2069            pose.3,
2070            0,
2071        );
2072
2073        let mut fb_sh = vec![0u32; n];
2074        let mut zb_sh = vec![f32::INFINITY; n];
2075        let sh = SpriteShade {
2076            materials: &table,
2077            lights: CpuLights::default(),
2078            material: 0, // opaque
2079            alpha_mul: 255,
2080            tint: 0x00FF_FFFF,
2081            shadow: None,
2082        };
2083        let _ = draw_sprite_dense_shaded(
2084            &mut fb_sh,
2085            &mut zb_sh,
2086            w as usize,
2087            w,
2088            h,
2089            &cs,
2090            &settings(w, h),
2091            &dense,
2092            pose.0,
2093            pose.1,
2094            pose.2,
2095            pose.3,
2096            0,
2097            Some(sh),
2098        );
2099
2100        assert_eq!(
2101            fb_plain, fb_sh,
2102            "opaque shade-ctx must match the plain path bit-for-bit"
2103        );
2104        assert_eq!(zb_plain, zb_sh, "opaque shade-ctx z-buffer must match too");
2105    }
2106
2107    /// A translucent (additive) sprite behind nearer terrain is occluded:
2108    /// the front depth (~36) is past the z-buffer cutoff (5), so the march
2109    /// stops before contributing and the background pixel is untouched.
2110    #[test]
2111    fn translucent_sprite_occluded_by_near_terrain() {
2112        let bg = 0x80_20_20_20;
2113        let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, 5.0);
2114        assert_eq!(
2115            centre, bg,
2116            "near terrain (z=5) must occlude the sprite at y≈36"
2117        );
2118    }
2119
2120    /// Per-span compositing: a translucent voxel contributes one alpha layer
2121    /// per contiguous solid run, so a 2-voxel-thick slab composites the same
2122    /// as a 1-voxel-thick one (adjacent voxels are not double-counted). This
2123    /// is the fix for the voxel-grid striping where a ray clipping a shared
2124    /// voxel boundary passed through two cells of one wall.
2125    #[test]
2126    fn per_span_thickness_independent() {
2127        fn centre(ysiz: u32) -> u32 {
2128            let mut table = MaterialTable::new();
2129            table.set(1, Material::alpha_blend(128));
2130            let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, ysiz, 8, VoxColor(0x80_C0_40_20)));
2131            let (w, h) = (64u32, 64u32);
2132            let n = (w * h) as usize;
2133            let mut fb = vec![0x80_10_10_10u32; n];
2134            let mut zb = vec![f32::INFINITY; n];
2135            let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2136            let sh = SpriteShade {
2137                materials: &table,
2138                lights: CpuLights::default(),
2139                material: 1,
2140                alpha_mul: 255,
2141                tint: 0x00FF_FFFF,
2142                shadow: None,
2143            };
2144            let _ = draw_sprite_dense_shaded(
2145                &mut fb,
2146                &mut zb,
2147                w as usize,
2148                w,
2149                h,
2150                &cs,
2151                &settings(w, h),
2152                &dense,
2153                [0.0, 40.0, 0.0],
2154                [1.0, 0.0, 0.0],
2155                [0.0, 1.0, 0.0],
2156                [0.0, 0.0, 1.0],
2157                0,
2158                Some(sh),
2159            );
2160            fb[(h / 2 * w + w / 2) as usize] & 0x00ff_ffff
2161        }
2162        // A 2-deep box is solid through (surface-only of a 2-thick box is both
2163        // y-layers); per-span treats the straight-through ray's two adjacent
2164        // voxels as one surface → identical to the 1-deep slab.
2165        assert_eq!(
2166            centre(1),
2167            centre(2),
2168            "per-span: a 2-thick slab must match a 1-thick one (no double-count)"
2169        );
2170    }
2171
2172    /// Volumetric (Beer–Lambert) is the thickness-*dependent* counterpart of
2173    /// per-span: a deeper **filled** volume absorbs more, so its centre pixel
2174    /// sits closer to the volume colour (less background shows through) than a
2175    /// shallow one — the opposite of `per_span_thickness_independent`.
2176    #[test]
2177    fn volumetric_thickness_deepens_opacity() {
2178        // Centre-pixel red channel of a filled red box `depth` voxels deep,
2179        // Volumetric material, over a dark background.
2180        fn red_at(depth: u32) -> u32 {
2181            let mut table = MaterialTable::new();
2182            table.set(1, Material::volumetric(128));
2183            // FILLED box (every cell solid, interior kept) so the ray actually
2184            // traverses `depth` absorbing voxels. `from_fn` would cull the
2185            // interior to a hollow shell (front+back faces only) — no genuine
2186            // depth accumulation — so use `from_fn_keep_interior`.
2187            let kv6 = Kv6::from_fn_keep_interior(
2188                8,
2189                depth,
2190                8,
2191                |_, _, _| Some(VoxColor(0x80_C0_20_20)),
2192                |_| true,
2193            );
2194            let dense = SpriteDense::from_kv6(&kv6);
2195            let (w, h) = (64u32, 64u32);
2196            let n = (w * h) as usize;
2197            let mut fb = vec![0x80_10_10_10u32; n];
2198            let mut zb = vec![f32::INFINITY; n];
2199            let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2200            let sh = SpriteShade {
2201                materials: &table,
2202                lights: CpuLights::default(),
2203                material: 1,
2204                alpha_mul: 255,
2205                tint: 0x00FF_FFFF,
2206                shadow: None,
2207            };
2208            let _ = draw_sprite_dense_shaded(
2209                &mut fb,
2210                &mut zb,
2211                w as usize,
2212                w,
2213                h,
2214                &cs,
2215                &settings(w, h),
2216                &dense,
2217                [0.0, 40.0, 0.0],
2218                [1.0, 0.0, 0.0],
2219                [0.0, 1.0, 0.0],
2220                [0.0, 0.0, 1.0],
2221                0,
2222                Some(sh),
2223            );
2224            (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2225        }
2226        let shallow = red_at(1);
2227        let deep = red_at(12);
2228        // Both lift red above the 0x10 background; the deeper volume absorbs
2229        // more of its own colour in, so its red is higher (more opaque).
2230        assert!(
2231            shallow > 0x10,
2232            "even a 1-deep volume tints (got {shallow:02x})"
2233        );
2234        assert!(
2235            deep > shallow,
2236            "deeper Volumetric volume is more opaque: deep {deep:02x} > shallow {shallow:02x}"
2237        );
2238    }
2239
2240    /// XS.2 — the sprite occluder reports a world ray blocked by a sprite
2241    /// volume (the mechanism by which sprites **cast** shadows, and **receive**
2242    /// them from each other). A ray through the cube is occluded; one well to
2243    /// the side is not.
2244    #[test]
2245    fn sprite_occluder_blocks_ray_through_volume() {
2246        use crate::dda::WorldOccluder;
2247        // 8³ cube, identity pose at world origin; `from_fn` centres the pivot
2248        // (4,4,4), so the cube spans world ≈ [-4, 4]³.
2249        let dense = Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(
2250            8,
2251            VoxColor(0x80_FF_FF_FF),
2252        )));
2253        let mut occ = SpriteOccluder::new();
2254        occ.push(
2255            dense,
2256            [0.0, 0.0, 0.0],
2257            [1.0, 0.0, 0.0],
2258            [0.0, 1.0, 0.0],
2259            [0.0, 0.0, 1.0],
2260        );
2261        assert!(!occ.is_empty());
2262        // Up the z-axis through the cube centre (world x=y=0 ⇒ local 4,4).
2263        assert!(
2264            occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2265            "a ray through the cube must be occluded"
2266        );
2267        // Far to the side: never enters the cube's AABB.
2268        assert!(
2269            !occ.occluded_world([50.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2270            "a ray missing the cube must not be occluded"
2271        );
2272        // Beyond max_t: the cube is at ~50 units, cap at 10 ⇒ unreached.
2273        assert!(
2274            !occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 10.0),
2275            "max_t shorter than the distance to the cube ⇒ unoccluded"
2276        );
2277    }
2278
2279    /// XS.2 — a sprite **receives** a hard shadow: a blocker volume between the
2280    /// drawn sprite and the sun darkens it. The blocker lives only in the
2281    /// occluder (it isn't drawn), so the framebuffer difference is purely the
2282    /// shadow it casts on the visible sprite.
2283    #[test]
2284    fn sprite_receives_hard_shadow() {
2285        // Drawn target: a voxel sphere at world (0,40,0). A sphere (unlike a
2286        // face-on cube, whose visible face is the normal-less AABB-entry voxel)
2287        // gives real per-voxel normals, so its −y hemisphere is genuinely
2288        // sunlit (to-sun = −y, toward the camera). A blocker cube at (0,25,0)
2289        // between sphere and sun shadows that lit hemisphere.
2290        let target = SpriteDense::from_kv6(&Kv6::from_fn(16, 16, 16, |x, y, z| {
2291            let (dx, dy, dz) = (x as i32 - 8, y as i32 - 8, z as i32 - 8);
2292            (dx * dx + dy * dy + dz * dz <= 49).then_some(VoxColor(0x80_C0_C0_C0))
2293        }));
2294        let mut occ = SpriteOccluder::new();
2295        occ.push(
2296            Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(
2297                8,
2298                VoxColor(0x80_FF_FF_FF),
2299            ))),
2300            [0.0, 25.0, 0.0],
2301            [1.0, 0.0, 0.0],
2302            [0.0, 1.0, 0.0],
2303            [0.0, 0.0, 1.0],
2304        );
2305        let table = MaterialTable::new();
2306        let base = CpuLights {
2307            enabled: true,
2308            sun: true,
2309            sun_dir: [0.0, -1.0, 0.0], // to-sun: −y (toward the camera)
2310            sun_color: [1.0; 3],
2311            sun_intensity: 1.0,
2312            sun_casts_shadow: true,
2313            ambient: [0.3; 3],
2314            shadow_strength: 0.85,
2315            shadow_bias: 1.5,
2316            shadow_max_dist: 128.0,
2317            ..CpuLights::default()
2318        };
2319        let (w, h) = (64u32, 64u32);
2320        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2321        let sum_lum = |shadow: Option<&dyn crate::dda::WorldOccluder>| -> u64 {
2322            let n = (w * h) as usize;
2323            let mut fb = vec![0u32; n];
2324            let mut zb = vec![f32::INFINITY; n];
2325            let sh = SpriteShade {
2326                materials: &table,
2327                lights: base,
2328                material: 0,
2329                alpha_mul: 255,
2330                tint: 0x00FF_FFFF,
2331                shadow,
2332            };
2333            let _ = draw_sprite_dense_shaded(
2334                &mut fb,
2335                &mut zb,
2336                w as usize,
2337                w,
2338                h,
2339                &cs,
2340                &settings(w, h),
2341                &target,
2342                [0.0, 40.0, 0.0],
2343                [1.0, 0.0, 0.0],
2344                [0.0, 1.0, 0.0],
2345                [0.0, 0.0, 1.0],
2346                0,
2347                Some(sh),
2348            );
2349            fb.iter()
2350                .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
2351                .sum()
2352        };
2353        let lit = sum_lum(None);
2354        let shadowed = sum_lum(Some(&occ));
2355        assert!(
2356            shadowed < lit,
2357            "the blocker must shadow the drawn sprite: shadowed={shadowed} lit={lit}"
2358        );
2359    }
2360
2361    /// Per-instance RGB tint multiplies the sprite's colour: a red tint on a
2362    /// white cube zeroes green+blue and keeps red; a white tint is a no-op.
2363    #[test]
2364    fn sprite_rgb_tint_recolours() {
2365        let table = MaterialTable::new();
2366        let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF)));
2367        let (w, h) = (64u32, 64u32);
2368        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2369        let centre = |tint: u32| -> u32 {
2370            let n = (w * h) as usize;
2371            let mut fb = vec![0u32; n];
2372            let mut zb = vec![f32::INFINITY; n];
2373            let sh = SpriteShade {
2374                materials: &table,
2375                lights: CpuLights::default(),
2376                material: 0,
2377                alpha_mul: 255,
2378                tint,
2379                shadow: None,
2380            };
2381            let _ = draw_sprite_dense_shaded(
2382                &mut fb,
2383                &mut zb,
2384                w as usize,
2385                w,
2386                h,
2387                &cs,
2388                &settings(w, h),
2389                &dense,
2390                [0.0, 40.0, 0.0],
2391                [1.0, 0.0, 0.0],
2392                [0.0, 1.0, 0.0],
2393                [0.0, 0.0, 1.0],
2394                0,
2395                Some(sh),
2396            );
2397            fb[(h / 2 * w + w / 2) as usize]
2398        };
2399        let r = |p: u32| (p >> 16) & 0xff;
2400        let g = |p: u32| (p >> 8) & 0xff;
2401        let b = |p: u32| p & 0xff;
2402        let white = centre(0x00FF_FFFF);
2403        let red = centre(0x00FF_0000);
2404        assert!(
2405            g(white) > 180 && b(white) > 180 && r(white) > 180,
2406            "white tint must be a no-op: {white:#08x}"
2407        );
2408        assert!(
2409            r(red) > 180 && g(red) < 20 && b(red) < 20,
2410            "red tint zeroes green/blue, keeps red: {red:#08x}"
2411        );
2412    }
2413
2414    /// XS.0 — translucent sprite layers are now **lit** (dynamic-lighting rig),
2415    /// not flat-baked: with the rig enabled at a dim ambient (sun off), the
2416    /// accumulated layer colour is darker than the disabled (baked, full-
2417    /// brightness) path. Pins that `cast_local_layers` runs `shade_dynamic`.
2418    #[test]
2419    fn translucent_sprite_layers_are_lit() {
2420        fn center_red(lights: CpuLights) -> u32 {
2421            let mut table = MaterialTable::new();
2422            table.set(1, Material::alpha_blend(160));
2423            let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, 8, 8, VoxColor(0x80_E0_30_30)));
2424            let (w, h) = (64u32, 64u32);
2425            let n = (w * h) as usize;
2426            let mut fb = vec![0x80_10_10_10u32; n];
2427            let mut zb = vec![f32::INFINITY; n];
2428            let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2429            let sh = SpriteShade {
2430                materials: &table,
2431                lights,
2432                material: 1,
2433                alpha_mul: 255,
2434                tint: 0x00FF_FFFF,
2435                shadow: None,
2436            };
2437            let _ = draw_sprite_dense_shaded(
2438                &mut fb,
2439                &mut zb,
2440                w as usize,
2441                w,
2442                h,
2443                &cs,
2444                &settings(w, h),
2445                &dense,
2446                [0.0, 40.0, 0.0],
2447                [1.0, 0.0, 0.0],
2448                [0.0, 1.0, 0.0],
2449                [0.0, 0.0, 1.0],
2450                0,
2451                Some(sh),
2452            );
2453            (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2454        }
2455        let baked = center_red(CpuLights::default()); // disabled ⇒ full-brightness baked
2456        let dim = center_red(CpuLights {
2457            enabled: true,
2458            ambient: [0.3; 3], // sun off, dim ambient ⇒ the layer should darken
2459            ..CpuLights::default()
2460        });
2461        assert!(
2462            dim < baked,
2463            "lit translucent layer must respond to the rig (dim ambient darkens): dim={dim:#x} baked={baked:#x}",
2464        );
2465    }
2466
2467    /// The demo scenario: an **opaque** backdrop sprite drawn first, then a
2468    /// **translucent** sprite in front of it sharing the buffer. The glass
2469    /// must composite over the backdrop colour (tint it), not leave it
2470    /// unchanged. Pins the CPU opaque-then-translucent interaction.
2471    #[test]
2472    fn translucent_sprite_tints_opaque_sprite_behind() {
2473        let mut table = MaterialTable::new();
2474        table.set(1, Material::alpha_blend(128));
2475        let (w, h) = (64u32, 64u32);
2476        let n = (w * h) as usize;
2477        let mut fb = vec![0x80_10_20_40u32; n]; // flat sky
2478        let mut zb = vec![f32::INFINITY; n];
2479        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2480        let cfg = settings(w, h);
2481        let id = [1.0, 0.0, 0.0];
2482        let up = [0.0, 1.0, 0.0];
2483        let fw = [0.0, 0.0, 1.0];
2484        let centre = (h / 2 * w + w / 2) as usize;
2485
2486        // Opaque red backdrop (material 0), far.
2487        let backdrop = SpriteDense::from_kv6(&Kv6::solid_cube(12, VoxColor(0x80_FF_00_00)));
2488        let sh_op = SpriteShade {
2489            materials: &table,
2490            lights: CpuLights::default(),
2491            material: 0,
2492            alpha_mul: 255,
2493            tint: 0x00FF_FFFF,
2494            shadow: None,
2495        };
2496        let _ = draw_sprite_dense_shaded(
2497            &mut fb,
2498            &mut zb,
2499            w as usize,
2500            w,
2501            h,
2502            &cs,
2503            &cfg,
2504            &backdrop,
2505            [0.0, 80.0, 0.0],
2506            id,
2507            up,
2508            fw,
2509            0,
2510            Some(sh_op),
2511        );
2512        let after_backdrop = fb[centre];
2513        assert_eq!(
2514            after_backdrop & 0x00ff_ffff,
2515            0x00FF_0000,
2516            "backdrop red must be drawn first"
2517        );
2518
2519        // Cyan glass (material 1), nearer + overlapping.
2520        let glass = SpriteDense::from_kv6(&Kv6::solid_cube(12, VoxColor(0x80_00_FF_FF)));
2521        let sh_gl = SpriteShade {
2522            materials: &table,
2523            lights: CpuLights::default(),
2524            material: 1,
2525            alpha_mul: 255,
2526            tint: 0x00FF_FFFF,
2527            shadow: None,
2528        };
2529        let wrote = draw_sprite_dense_shaded(
2530            &mut fb,
2531            &mut zb,
2532            w as usize,
2533            w,
2534            h,
2535            &cs,
2536            &cfg,
2537            &glass,
2538            [0.0, 40.0, 0.0],
2539            id,
2540            up,
2541            fw,
2542            0,
2543            Some(sh_gl),
2544        );
2545        let _ = wrote;
2546        let after_glass = fb[centre];
2547        assert_ne!(
2548            after_glass, after_backdrop,
2549            "glass must tint the backdrop (composite over it)"
2550        );
2551        // Cyan over red: red channel drops, blue/green rise.
2552        assert!(
2553            (after_glass >> 16) & 0xff < 0xFF,
2554            "glass should reduce the backdrop's red (got {after_glass:08x})"
2555        );
2556    }
2557
2558    /// TV.3: `from_kv6_with_materials` classifies voxels into per-voxel
2559    /// material ids by colour — mapped colour → its id, unmapped → 0.
2560    #[test]
2561    fn from_kv6_with_materials_classifies_by_color() {
2562        let col = VoxColor(0x80_AA_BB_CC);
2563        let kv6 = Kv6::solid_cube(6, col);
2564        let dense = SpriteDense::from_kv6_with_materials(&kv6, &[(Rgb(0x00AA_BBCC), 2)]);
2565        assert_eq!(
2566            dense.mat.len(),
2567            dense.col.len(),
2568            "per-voxel mat array sized"
2569        );
2570        let mut solids = 0;
2571        for idx in 0..dense.occ.len() {
2572            if dense.occ[idx] {
2573                assert_eq!(dense.mat[idx], 2, "mapped colour → material 2");
2574                solids += 1;
2575            }
2576        }
2577        assert!(solids > 0, "cube has solid voxels");
2578        // A map that doesn't include the cube's colour → all opaque (0).
2579        let dense0 = SpriteDense::from_kv6_with_materials(&kv6, &[(Rgb(0x0012_3456), 5)]);
2580        assert!(
2581            dense0.mat.iter().all(|&m| m == 0),
2582            "unmapped colour → material 0"
2583        );
2584    }
2585
2586    /// TV.3: a model whose every voxel maps to the *same* material id renders
2587    /// identically to drawing it with that material as the instance's uniform
2588    /// material — the per-voxel path reduces to the uniform path when
2589    /// homogeneous (and overrides the instance's `material`).
2590    #[test]
2591    fn per_voxel_material_matches_uniform_when_homogeneous() {
2592        let mut table = MaterialTable::new();
2593        table.set(1, Material::alpha_blend(120));
2594        let col = VoxColor(0x80_30_A0_F0);
2595        let kv6 = Kv6::solid_cube(10, col);
2596        let (w, h) = (64u32, 64u32);
2597        let n = (w * h) as usize;
2598        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2599        let cfg = settings(w, h);
2600        let (pos, s, hh, f) = (
2601            [0.0, 40.0, 0.0],
2602            [1.0, 0.0, 0.0],
2603            [0.0, 1.0, 0.0],
2604            [0.0, 0.0, 1.0],
2605        );
2606        let render = |dense: &SpriteDense, material: u8| -> Vec<u32> {
2607            let mut fb = vec![0x80_10_10_10u32; n];
2608            let mut zb = vec![f32::INFINITY; n];
2609            let sh = SpriteShade {
2610                materials: &table,
2611                lights: CpuLights::default(),
2612                material,
2613                alpha_mul: 255,
2614                tint: 0x00FF_FFFF,
2615                shadow: None,
2616            };
2617            let _ = draw_sprite_dense_shaded(
2618                &mut fb,
2619                &mut zb,
2620                w as usize,
2621                w,
2622                h,
2623                &cs,
2624                &cfg,
2625                dense,
2626                pos,
2627                s,
2628                hh,
2629                f,
2630                0,
2631                Some(sh),
2632            );
2633            fb
2634        };
2635        // Per-voxel: every voxel → material 1; instance's uniform material is 0
2636        // (opaque) but the per-voxel id overrides it.
2637        let pv = render(
2638            &SpriteDense::from_kv6_with_materials(&kv6, &[(col.rgb_part(), 1)]),
2639            0,
2640        );
2641        // Uniform: no per-voxel data, instance material 1.
2642        let un = render(&SpriteDense::from_kv6(&kv6), 1);
2643        assert_eq!(pv, un, "homogeneous per-voxel material == uniform material");
2644        // And it's actually translucent (differs from the bare background).
2645        let centre = (h / 2 * w + w / 2) as usize;
2646        assert_ne!(pv[centre] & 0x00ff_ffff, 0x0010_1010, "translucent, not bg");
2647    }
2648
2649    /// TV.3 (clip wiring): a [`ClipFlipbook`] built with a colour→material map
2650    /// carries per-voxel materials on every cached frame (the clip analogue of
2651    /// `from_kv6_with_materials`); an empty map leaves them all-opaque, so the
2652    /// flipbook is byte-identical to `from_decoded`.
2653    #[test]
2654    fn clip_flipbook_with_materials_classifies_every_frame() {
2655        let dims = [6u32, 6, 6];
2656        let glass = Rgb(0x00AA_BBCC);
2657        let glass_lit = 0x80AA_BBCC;
2658        // Two distinct frames, both filled with the glass colour.
2659        let f0 = clip_frame(dims, |_x, _y, z| (z < 3).then_some(glass_lit));
2660        let f1 = clip_frame(dims, |_x, _y, z| (z >= 3).then_some(glass_lit));
2661        let clip = VoxelClip::from_frames(
2662            dims,
2663            [3.0, 3.0, 3.0],
2664            1.0,
2665            LoopMode::Loop,
2666            &[f0, f1],
2667            &[],
2668            33,
2669            0,
2670        );
2671        let decoded = clip.decode().expect("decode");
2672
2673        let book = ClipFlipbook::from_decoded_with_materials(&decoded, &[(glass, 2)]);
2674        assert_eq!(book.frame_count(), 2);
2675        for fr in 0..2 {
2676            let dense = book.frame(fr).expect("frame in range");
2677            assert_eq!(dense.mat.len(), dense.col.len(), "frame {fr} mat sized");
2678            let mut solids = 0;
2679            for idx in 0..dense.occ.len() {
2680                if dense.occ[idx] {
2681                    assert_eq!(dense.mat[idx], 2, "frame {fr}: glass → material 2");
2682                    solids += 1;
2683                }
2684            }
2685            assert!(solids > 0, "frame {fr} has solid voxels");
2686        }
2687
2688        // An empty map ⇒ no per-voxel materials, identical to `from_decoded`.
2689        let plain = ClipFlipbook::from_decoded(&decoded);
2690        let plain_mat = ClipFlipbook::from_decoded_with_materials(&decoded, &[]);
2691        for fr in 0..2 {
2692            assert!(plain.frame(fr).unwrap().mat.is_empty());
2693            assert!(plain_mat.frame(fr).unwrap().mat.is_empty());
2694        }
2695    }
2696}