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