Skip to main content

roxlap_core/
dda_sprite.rs

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