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::sprite::{Sprite, SPRITE_FLAG_INVISIBLE, SPRITE_FLAG_NO_Z};
24use roxlap_formats::voxel_clip::{DecodedClip, VoxelFrame};
25
26use crate::camera_math::CameraState;
27use crate::dda::{dda_setup, intersect_aabb, min_axis, pixel_ray, shade};
28use crate::opticast::OpticastSettings;
29use crate::raster_target::RasterTarget;
30
31/// Near-plane parameter: voxels nearer than this (camera-forward) are
32/// dropped, keeping the pinhole divide finite.
33const NEAR_Z: f32 = 1.0;
34
35/// Force a packed voxel colour to full brightness for the flat-lit
36/// clean-room sprite path. KV6 / voxel-clip colours carry voxlap's
37/// `dir`/shading slot in the high byte (some `0x80`, some `0x00`), not
38/// the 0..128 brightness [`shade`] expects, so a raw value can render
39/// black; we render every sprite voxel at its authored RGB.
40#[inline]
41fn full_bright(col: u32) -> u32 {
42    (col & 0x00ff_ffff) | 0x8000_0000
43}
44
45/// Dense occupancy + colour grid for one sprite frame, plus its pivot —
46/// the decoded form the per-pixel raycaster marches. Built once from a
47/// [`Kv6`] ([`SpriteDense::from_kv6`]) or a voxel-clip [`VoxelFrame`]
48/// ([`SpriteDense::from_voxel_frame`]); the latter lets an animated clip
49/// cache every frame's grid up front instead of rebuilding per frame.
50///
51/// Both sources store only **surface** voxels (a from-air ray's first
52/// hit is the visible surface), so the grid is the visible hull.
53pub struct SpriteDense {
54    dims: [i32; 3],
55    occ: Vec<bool>,
56    col: Vec<u32>,
57    pivot: [f32; 3],
58}
59
60impl SpriteDense {
61    /// Decode a [`Kv6`]'s surface-voxel run tables into a dense grid.
62    #[must_use]
63    #[allow(clippy::cast_possible_wrap)]
64    pub fn from_kv6(kv6: &Kv6) -> Self {
65        let dims = [kv6.xsiz as i32, kv6.ysiz as i32, kv6.zsiz as i32];
66        let n = (dims[0].max(0) * dims[1].max(0) * dims[2].max(0)) as usize;
67        let mut occ = vec![false; n];
68        let mut col = vec![0u32; n];
69        let mut vi = 0usize;
70        for x in 0..kv6.xsiz as usize {
71            for y in 0..kv6.ysiz as usize {
72                let cnt = usize::from(kv6.ylen[x][y]);
73                for _ in 0..cnt {
74                    let v = kv6.voxels[vi];
75                    vi += 1;
76                    let z = i32::from(v.z);
77                    if z >= 0 && z < dims[2] {
78                        let idx = ((x as i32 * dims[1] + y as i32) * dims[2] + z) as usize;
79                        occ[idx] = true;
80                        col[idx] = full_bright(v.col);
81                    }
82                }
83            }
84        }
85        Self {
86            dims,
87            occ,
88            col,
89            pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
90        }
91    }
92
93    /// Decode a voxel-clip [`VoxelFrame`] (dense-column layout) into the
94    /// dense grid, given the clip's `dims` + `pivot`. The frame's columns
95    /// are `col = x + y*dims[0]`, each a per-column occupancy bitmask with
96    /// an ascending-z colour run — walked here into the raycaster's
97    /// `(x·my + y)·mz + z` grid.
98    #[must_use]
99    #[allow(clippy::cast_possible_wrap)]
100    pub fn from_voxel_frame(frame: &VoxelFrame, dims: [u32; 3], pivot: [f32; 3]) -> Self {
101        let (mx, my, mz) = (dims[0], dims[1], dims[2]);
102        let owpc = mz.div_ceil(32).max(1) as usize;
103        let n = (mx * my * mz) as usize;
104        let mut occ = vec![false; n];
105        let mut col = vec![0u32; n];
106        for col_idx in 0..(mx * my) as usize {
107            let x = col_idx as u32 % mx;
108            let y = col_idx as u32 / mx;
109            let run_start = frame.color_offsets[col_idx] as usize;
110            let mut k = 0usize;
111            for z in 0..mz {
112                let word = frame.occupancy[col_idx * owpc + (z >> 5) as usize];
113                if (word >> (z & 31)) & 1 != 0 {
114                    let idx = (((x * my + y) * mz) + z) as usize;
115                    occ[idx] = true;
116                    col[idx] = full_bright(frame.colors[run_start + k]);
117                    k += 1;
118                }
119            }
120        }
121        Self {
122            dims: [mx as i32, my as i32, mz as i32],
123            occ,
124            col,
125            pivot,
126        }
127    }
128
129    #[inline]
130    #[allow(clippy::cast_sign_loss)]
131    fn at(&self, c: [i32; 3]) -> Option<u32> {
132        let idx = ((c[0] * self.dims[1] + c[1]) * self.dims[2] + c[2]) as usize;
133        self.occ[idx].then(|| self.col[idx])
134    }
135}
136
137/// Inverse of the column-matrix `[s | h | f]` (the sprite basis), or
138/// `None` if degenerate. Maps a world delta into local voxel space.
139fn invert_basis(s: [f32; 3], h: [f32; 3], f: [f32; 3]) -> Option<[[f32; 3]; 3]> {
140    let det = s[0] * (h[1] * f[2] - f[1] * h[2]) - h[0] * (s[1] * f[2] - f[1] * s[2])
141        + f[0] * (s[1] * h[2] - h[1] * s[2]);
142    if det.abs() < 1e-12 {
143        return None;
144    }
145    let inv = 1.0 / det;
146    Some([
147        [
148            (h[1] * f[2] - f[1] * h[2]) * inv,
149            -(h[0] * f[2] - f[0] * h[2]) * inv,
150            (h[0] * f[1] - f[0] * h[1]) * inv,
151        ],
152        [
153            -(s[1] * f[2] - f[1] * s[2]) * inv,
154            (s[0] * f[2] - f[0] * s[2]) * inv,
155            -(s[0] * f[1] - f[0] * s[1]) * inv,
156        ],
157        [
158            (s[1] * h[2] - h[1] * s[2]) * inv,
159            -(s[0] * h[2] - h[0] * s[2]) * inv,
160            (s[0] * h[1] - h[0] * s[1]) * inv,
161        ],
162    ])
163}
164
165#[inline]
166fn mat_apply(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
167    [
168        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
169        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
170        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
171    ]
172}
173
174/// Cast one ray (already in the sprite's local voxel space) into the
175/// dense KV6 and return `(colour, t)` of the first solid voxel — `t` is
176/// the world-units ray parameter (shared with the world ray).
177#[allow(clippy::cast_possible_truncation)]
178fn cast_local(dense: &SpriteDense, origin: [f32; 3], dir: [f32; 3]) -> Option<(u32, f32)> {
179    #[allow(clippy::cast_precision_loss)]
180    let hi = [
181        dense.dims[0] as f32,
182        dense.dims[1] as f32,
183        dense.dims[2] as f32,
184    ];
185    let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
186    let start = t0 + 1e-4;
187    let p = [
188        origin[0] + dir[0] * start,
189        origin[1] + dir[1] * start,
190        origin[2] + dir[2] * start,
191    ];
192    let mut cell = [
193        (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
194        (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
195        (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
196    ];
197    let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
198    let mut t_curr = t0;
199    let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
200    for _ in 0..max_steps {
201        if cell[0] < 0
202            || cell[0] >= dense.dims[0]
203            || cell[1] < 0
204            || cell[1] >= dense.dims[1]
205            || cell[2] < 0
206            || cell[2] >= dense.dims[2]
207            || t_curr > t1
208        {
209            return None;
210        }
211        if let Some(color) = dense.at(cell) {
212            return Some((color, t_curr));
213        }
214        let axis = min_axis(t_max);
215        t_curr = t_max[axis];
216        cell[axis] += step[axis];
217        t_max[axis] += t_delta[axis];
218    }
219    None
220}
221
222/// Draw one KV6 [`Sprite`] into `(fb, zb)` by per-pixel ray casting,
223/// depth-compositing against whatever the terrain pass already wrote.
224/// Returns the number of pixels written.
225///
226/// `cam` / `settings` are the **same** per-frame projection the DDA
227/// terrain pass used (build via [`crate::camera_math::derive`]), so
228/// sprite and terrain share one pinhole and z convention. `pitch_pixels`
229/// is the framebuffer row stride. Honours `SPRITE_FLAG_INVISIBLE`
230/// (skip) and `SPRITE_FLAG_NO_Z` (write without the depth test).
231#[allow(
232    clippy::too_many_arguments,
233    clippy::cast_possible_truncation,
234    clippy::cast_sign_loss
235)]
236#[must_use]
237pub fn draw_sprite_dda(
238    fb: &mut [u32],
239    zb: &mut [f32],
240    pitch_pixels: usize,
241    width: u32,
242    height: u32,
243    cam: &CameraState,
244    settings: &OpticastSettings,
245    sprite: &Sprite,
246) -> u32 {
247    if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
248        return 0;
249    }
250    // Decodes the KV6 to a dense grid each call (the per-frame cost an
251    // animated clip avoids via [`ClipFlipbook`]'s cached grids).
252    let dense = SpriteDense::from_kv6(&sprite.kv6);
253    draw_sprite_dense(
254        fb,
255        zb,
256        pitch_pixels,
257        width,
258        height,
259        cam,
260        settings,
261        &dense,
262        sprite.p,
263        sprite.s,
264        sprite.h,
265        sprite.f,
266        sprite.flags,
267    )
268}
269
270/// Draw a pre-decoded [`SpriteDense`] at a world pose — the generalised
271/// core of [`draw_sprite_dda`], shared by the KV6 path and animated
272/// [`ClipFlipbook`] frames. `pos` is the world pivot; `s`/`h`/`f` are the
273/// model→world basis columns (local +x/+y/+z); `flags` honours
274/// [`SPRITE_FLAG_INVISIBLE`] / [`SPRITE_FLAG_NO_Z`]. Returns pixels written.
275#[allow(
276    clippy::too_many_arguments,
277    clippy::cast_possible_truncation,
278    clippy::cast_sign_loss
279)]
280#[must_use]
281pub fn draw_sprite_dense(
282    fb: &mut [u32],
283    zb: &mut [f32],
284    pitch_pixels: usize,
285    width: u32,
286    height: u32,
287    cam: &CameraState,
288    settings: &OpticastSettings,
289    dense: &SpriteDense,
290    pos: [f32; 3],
291    s: [f32; 3],
292    h: [f32; 3],
293    f: [f32; 3],
294    flags: u32,
295) -> u32 {
296    if flags & SPRITE_FLAG_INVISIBLE != 0 || dense.occ.is_empty() {
297        return 0;
298    }
299    let Some(minv) = invert_basis(s, h, f) else {
300        return 0;
301    };
302    let pivot = dense.pivot;
303    let no_z = flags & SPRITE_FLAG_NO_Z != 0;
304
305    // Screen bounding box from the 8 corners of the local voxel box.
306    let Some(rect) = project_screen_rect(dense, pos, s, h, f, cam, settings, width, height) else {
307        return 0;
308    };
309
310    debug_assert_eq!(fb.len(), zb.len());
311    let target = RasterTarget::new(fb, zb);
312    let mut written = 0u32;
313    for py in rect.1..rect.3 {
314        let row = py as usize * pitch_pixels;
315        for px in rect.0..rect.2 {
316            let (origin, dir) = pixel_ray(cam, settings, px, py);
317            // World ray → sprite-local voxel space.
318            let rel = [origin[0] - pos[0], origin[1] - pos[1], origin[2] - pos[2]];
319            let ol = mat_apply(&minv, rel);
320            let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
321            let dir_local = mat_apply(&minv, dir);
322            let Some((color, t)) = cast_local(dense, origin_local, dir_local) else {
323                continue;
324            };
325            let fwd_dot =
326                dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
327            let depth = t * fwd_dot;
328            if depth < NEAR_Z {
329                continue;
330            }
331            let lit = shade(color, 0);
332            let idx = row + px as usize;
333            // SAFETY: idx in-bounds for the rect within (width, height);
334            // single-threaded writer.
335            let wrote = unsafe {
336                if no_z {
337                    target.write_color(idx, lit);
338                    target.write_depth(idx, depth);
339                    true
340                } else {
341                    target.z_test_write(idx, lit, depth)
342                }
343            };
344            written += u32::from(wrote);
345        }
346    }
347    written
348}
349
350/// Project the sprite's local voxel AABB to a clamped screen rectangle
351/// `(x0, y0, x1, y1)` (half-open). `None` if it can't appear; falls back
352/// to the full viewport when the box straddles the near plane (rare).
353#[allow(
354    clippy::cast_possible_truncation,
355    clippy::cast_sign_loss,
356    clippy::cast_precision_loss
357)]
358fn project_screen_rect(
359    dense: &SpriteDense,
360    pos: [f32; 3],
361    s: [f32; 3],
362    h: [f32; 3],
363    f: [f32; 3],
364    cam: &CameraState,
365    settings: &OpticastSettings,
366    width: u32,
367    height: u32,
368) -> Option<(u32, u32, u32, u32)> {
369    let (xs, ys, zs) = (
370        dense.dims[0] as f32,
371        dense.dims[1] as f32,
372        dense.dims[2] as f32,
373    );
374    let (xp, yp, zp) = (dense.pivot[0], dense.pivot[1], dense.pivot[2]);
375    let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
376    let mut all_front = true;
377    for &cx in &[0.0, xs] {
378        for &cy in &[0.0, ys] {
379            for &cz in &[0.0, zs] {
380                // Local → world via the sprite basis about the pivot.
381                let lx = cx - xp;
382                let ly = cy - yp;
383                let lz = cz - zp;
384                let world = [
385                    pos[0] + lx * s[0] + ly * h[0] + lz * f[0],
386                    pos[1] + lx * s[1] + ly * h[1] + lz * f[1],
387                    pos[2] + lx * s[2] + ly * h[2] + lz * f[2],
388                ];
389                let rel = [
390                    world[0] - cam.pos[0],
391                    world[1] - cam.pos[1],
392                    world[2] - cam.pos[2],
393                ];
394                let cz_cam =
395                    rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
396                if cz_cam < NEAR_Z {
397                    all_front = false;
398                    continue;
399                }
400                let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
401                let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
402                let sx = settings.hx + cx_cam / cz_cam * settings.hz;
403                let sy = settings.hy + cy_cam / cz_cam * settings.hz;
404                x0 = x0.min(sx);
405                y0 = y0.min(sy);
406                x1 = x1.max(sx);
407                y1 = y1.max(sy);
408            }
409        }
410    }
411    let (w, h) = (width as f32, height as f32);
412    let (rx0, ry0, rx1, ry1) = if all_front {
413        (
414            (x0 - 1.0).max(0.0),
415            (y0 - 1.0).max(0.0),
416            (x1 + 1.0).min(w),
417            (y1 + 1.0).min(h),
418        )
419    } else {
420        // Straddles the near plane → scan the whole viewport.
421        (0.0, 0.0, w, h)
422    };
423    if rx0 >= rx1 || ry0 >= ry1 {
424        return None;
425    }
426    Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
427}
428
429/// CPU-side decoded animated voxel clip: every frame's [`SpriteDense`]
430/// is cached at construction, so per-frame playback is a grid **select**
431/// — not the per-frame voxel-volume decode [`draw_sprite_dda`] pays each
432/// call. The CPU counterpart to the GPU flipbook (VCL.2). Build once from
433/// a [`DecodedClip`], then [`draw_frame`](ClipFlipbook::draw_frame) the
434/// active frame each render.
435pub struct ClipFlipbook {
436    frames: Vec<SpriteDense>,
437}
438
439impl ClipFlipbook {
440    /// An empty flipbook (no frames) — a tombstone for a removed clip;
441    /// [`draw_frame`](Self::draw_frame) always draws nothing.
442    #[must_use]
443    pub fn empty() -> Self {
444        Self { frames: Vec::new() }
445    }
446
447    /// Decode + cache every frame of `clip` (one [`SpriteDense`] each).
448    #[must_use]
449    pub fn from_decoded(clip: &DecodedClip) -> Self {
450        let frames = clip
451            .frames
452            .iter()
453            .map(|frame| SpriteDense::from_voxel_frame(frame, clip.dims, clip.pivot))
454            .collect();
455        Self { frames }
456    }
457
458    #[must_use]
459    pub fn frame_count(&self) -> usize {
460        self.frames.len()
461    }
462
463    /// Borrow frame `frame`'s cached dense grid, if in range.
464    #[must_use]
465    pub fn frame(&self, frame: usize) -> Option<&SpriteDense> {
466        self.frames.get(frame)
467    }
468
469    /// Replace one frame's cached dense grid in place — the CPU side of an
470    /// editor's single-frame edit (no re-decode of the other frames).
471    /// Returns `false` if `frame` is out of range.
472    pub fn set_frame(&mut self, frame: usize, dense: SpriteDense) -> bool {
473        match self.frames.get_mut(frame) {
474            Some(slot) => {
475                *slot = dense;
476                true
477            }
478            None => false,
479        }
480    }
481
482    /// Draw frame `frame` at a world pose via [`draw_sprite_dense`] —
483    /// `pos` is the world pivot, `s`/`h`/`f` the model→world basis columns.
484    /// Returns pixels written (0 if `frame` is out of range).
485    #[allow(clippy::too_many_arguments)]
486    #[must_use]
487    pub fn draw_frame(
488        &self,
489        fb: &mut [u32],
490        zb: &mut [f32],
491        pitch_pixels: usize,
492        width: u32,
493        height: u32,
494        cam: &CameraState,
495        settings: &OpticastSettings,
496        frame: usize,
497        pos: [f32; 3],
498        s: [f32; 3],
499        h: [f32; 3],
500        f: [f32; 3],
501        flags: u32,
502    ) -> u32 {
503        let Some(dense) = self.frames.get(frame) else {
504            return 0;
505        };
506        draw_sprite_dense(
507            fb,
508            zb,
509            pitch_pixels,
510            width,
511            height,
512            cam,
513            settings,
514            dense,
515            pos,
516            s,
517            h,
518            f,
519            flags,
520        )
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::camera_math;
528    use crate::Camera;
529    use roxlap_formats::kv6::Kv6;
530    use roxlap_formats::sprite::Sprite;
531    use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
532
533    fn settings(w: u32, h: u32) -> OpticastSettings {
534        OpticastSettings::for_oracle_framebuffer(w, h)
535    }
536
537    /// Camera at the origin looking down +y at a sprite ahead.
538    fn cam_looking_y() -> Camera {
539        Camera {
540            pos: [0.0, 0.0, 0.0],
541            right: [1.0, 0.0, 0.0],
542            down: [0.0, 0.0, 1.0],
543            forward: [0.0, 1.0, 0.0],
544        }
545    }
546
547    /// Build a [`VoxelFrame`] from a dense `fill(x,y,z) -> Option<color>`.
548    fn clip_frame(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
549        let owpc = dims[2].div_ceil(32).max(1) as usize;
550        let cols = (dims[0] * dims[1]) as usize;
551        let mut occupancy = vec![0u32; cols * owpc];
552        let mut color_offsets = vec![0u32; cols + 1];
553        let mut colors = Vec::new();
554        for y in 0..dims[1] {
555            for x in 0..dims[0] {
556                let col = (x + y * dims[0]) as usize;
557                color_offsets[col] = colors.len() as u32;
558                for z in 0..dims[2] {
559                    if let Some(c) = fill(x, y, z) {
560                        occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
561                        colors.push(c);
562                    }
563                }
564            }
565        }
566        color_offsets[cols] = colors.len() as u32;
567        VoxelFrame {
568            occupancy,
569            colors,
570            color_offsets,
571        }
572    }
573
574    /// A cached [`ClipFlipbook`] draws distinct frames distinctly — the
575    /// CPU flipbook select. Frame 0 fills the bottom half (red), frame 1
576    /// the top half (green); rendered at the same pose they cover
577    /// different screen pixels in different colours.
578    #[test]
579    fn clip_flipbook_frames_render_differently() {
580        let dims = [8u32, 8, 8];
581        let f0 = clip_frame(dims, |_x, _y, z| (z < 4).then_some(0x00FF_0000)); // red, low z
582        let f1 = clip_frame(dims, |_x, _y, z| (z >= 4).then_some(0x0000_FF00)); // green, high z
583        let clip = VoxelClip::from_frames(
584            dims,
585            [4.0, 4.0, 4.0],
586            1.0,
587            LoopMode::Loop,
588            &[f0, f1],
589            &[],
590            33,
591            0,
592        );
593        let decoded = clip.decode().expect("decode");
594        let book = ClipFlipbook::from_decoded(&decoded);
595        assert_eq!(book.frame_count(), 2);
596        assert!(book.frame(0).is_some() && book.frame(2).is_none());
597
598        let (w, h) = (64u32, 64u32);
599        let n = (w * h) as usize;
600        let cam = cam_looking_y();
601        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
602        let cfg = settings(w, h);
603        let pose = [0.0, 40.0, 0.0];
604        let (s, hh, f) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
605
606        let render = |frame: usize| -> Vec<u32> {
607            let mut fb = vec![0u32; n];
608            let mut zb = vec![f32::INFINITY; n];
609            let wrote = book.draw_frame(
610                &mut fb, &mut zb, w as usize, w, h, &cs, &cfg, frame, pose, s, hh, f, 0,
611            );
612            assert!(wrote > 0, "frame {frame} should draw some pixels");
613            fb
614        };
615        let fb0 = render(0);
616        let fb1 = render(1);
617        assert_ne!(fb0, fb1, "distinct frames must render distinct pixels");
618        // Each frame shows its own channel: red present in frame 0, green
619        // present in frame 1.
620        assert!(fb0.iter().any(|&p| (p & 0x00FF_0000) != 0));
621        assert!(fb1.iter().any(|&p| (p & 0x0000_FF00) != 0));
622        // Out-of-range frame draws nothing.
623        let mut fb = vec![0u32; n];
624        let mut zb = vec![f32::INFINITY; n];
625        assert_eq!(
626            book.draw_frame(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, 9, pose, s, hh, f, 0),
627            0
628        );
629    }
630
631    #[test]
632    fn clip_flipbook_set_frame_replaces_one_frame() {
633        // The single-frame edit primitive: replace frame 0's dense with
634        // frame 1's content, in place. Out-of-range → false.
635        let dims = [8u32, 8, 8];
636        let f0 = clip_frame(dims, |_, _, z| (z < 4).then_some(0x00FF_0000)); // red
637        let f1 = clip_frame(dims, |_, _, z| (z >= 4).then_some(0x0000_FF00)); // green
638        let clip =
639            VoxelClip::from_frames(dims, [4.0; 3], 1.0, LoopMode::Loop, &[f0, f1], &[], 33, 0);
640        let decoded = clip.decode().unwrap();
641        let mut book = ClipFlipbook::from_decoded(&decoded);
642
643        let (w, h) = (64u32, 64u32);
644        let n = (w * h) as usize;
645        let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
646        let cfg = settings(w, h);
647        let render0 = |b: &ClipFlipbook| -> Vec<u32> {
648            let mut fb = vec![0u32; n];
649            let mut zb = vec![f32::INFINITY; n];
650            let _ = b.draw_frame(
651                &mut fb,
652                &mut zb,
653                w as usize,
654                w,
655                h,
656                &cs,
657                &cfg,
658                0,
659                [0.0, 40.0, 0.0],
660                [1.0, 0.0, 0.0],
661                [0.0, 1.0, 0.0],
662                [0.0, 0.0, 1.0],
663                0,
664            );
665            fb
666        };
667
668        let before = render0(&book);
669        assert!(
670            before.iter().any(|&p| (p & 0x00FF_0000) != 0),
671            "frame 0 is red"
672        );
673
674        // Replace frame 0 with frame 1's dense.
675        let replacement = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
676        assert!(book.set_frame(0, replacement));
677        let extra = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
678        assert!(!book.set_frame(9, extra), "out-of-range set_frame is false");
679
680        let after = render0(&book);
681        assert!(
682            after.iter().any(|&p| (p & 0x0000_FF00) != 0),
683            "frame 0 now green"
684        );
685        assert_ne!(before, after);
686    }
687
688    /// A solid cube sprite in front of the camera is drawn, with the
689    /// cube colour (shaded) and a sensible centre depth.
690    #[test]
691    fn cube_sprite_renders() {
692        let kv6 = Kv6::solid_cube(8, 0x80_C0_40_20);
693        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
694        let (w, h) = (64u32, 64u32);
695        let n = (w * h) as usize;
696        let mut fb = vec![0u32; n];
697        let mut zb = vec![f32::INFINITY; n];
698        let cam = cam_looking_y();
699        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
700        let wrote = draw_sprite_dda(
701            &mut fb,
702            &mut zb,
703            w as usize,
704            w,
705            h,
706            &cs,
707            &settings(w, h),
708            &sprite,
709        );
710
711        assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
712        let centre = (h / 2 * w + w / 2) as usize;
713        assert_eq!(
714            fb[centre] & 0x00ff_ffff,
715            0x00_C0_40_20,
716            "got {:08x}",
717            fb[centre]
718        );
719        // Pivot at world y=40, cube spans y in [36,44] → near face ~36.
720        assert!(
721            (zb[centre] - 36.0).abs() < 3.0,
722            "centre depth {} not ≈ 36",
723            zb[centre]
724        );
725    }
726
727    /// A KV6 whose voxel colours store a `0x00` high byte (voxlap's
728    /// unused `dir` slot, e.g. `sprite_meltsphere.kv6`) must still
729    /// render its authored RGB, not black — the brightness byte is
730    /// normalised to full on decode.
731    #[test]
732    fn zero_high_byte_sprite_not_black() {
733        let kv6 = Kv6::solid_cube(8, 0x00_C0_40_20);
734        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
735        let (w, h) = (64u32, 64u32);
736        let n = (w * h) as usize;
737        let mut fb = vec![0u32; n];
738        let mut zb = vec![f32::INFINITY; n];
739        let cam = cam_looking_y();
740        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
741        let wrote = draw_sprite_dda(
742            &mut fb,
743            &mut zb,
744            w as usize,
745            w,
746            h,
747            &cs,
748            &settings(w, h),
749            &sprite,
750        );
751        assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
752        let centre = (h / 2 * w + w / 2) as usize;
753        assert_eq!(
754            fb[centre] & 0x00ff_ffff,
755            0x00_C0_40_20,
756            "zero-high-byte sprite rendered as {:08x} (black bug)",
757            fb[centre]
758        );
759    }
760
761    /// A sprite occludes / is occluded by the z-buffer: a nearer
762    /// pre-filled depth blocks the sprite; a farther one lets it win.
763    #[test]
764    fn sprite_respects_zbuffer() {
765        let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
766        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
767        let (w, h) = (32u32, 32u32);
768        let n = (w * h) as usize;
769        let cam = cam_looking_y();
770        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
771        let centre = (h / 2 * w + w / 2) as usize;
772
773        // Terrain in front (depth 10 < ~36) → sprite blocked at centre.
774        let mut fb = vec![0u32; n];
775        let mut zb = vec![f32::INFINITY; n];
776        fb[centre] = 0x80_11_22_33;
777        zb[centre] = 10.0;
778        let _ = draw_sprite_dda(
779            &mut fb,
780            &mut zb,
781            w as usize,
782            w,
783            h,
784            &cs,
785            &settings(w, h),
786            &sprite,
787        );
788        assert_eq!(
789            fb[centre], 0x80_11_22_33,
790            "near terrain must occlude sprite"
791        );
792
793        // Terrain behind (depth 100) → sprite wins.
794        let mut fb2 = vec![0u32; n];
795        let mut zb2 = vec![f32::INFINITY; n];
796        fb2[centre] = 0x80_11_22_33;
797        zb2[centre] = 100.0;
798        let _ = draw_sprite_dda(
799            &mut fb2,
800            &mut zb2,
801            w as usize,
802            w,
803            h,
804            &cs,
805            &settings(w, h),
806            &sprite,
807        );
808        assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
809        assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
810    }
811
812    /// The covered screen rect (min/max px,py) of whatever the sprite
813    /// painted — used to compare an axis-aligned vs a rotated pose.
814    fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
815        let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
816        for py in 0..h {
817            for px in 0..w {
818                if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
819                    x0 = x0.min(px);
820                    y0 = y0.min(py);
821                    x1 = x1.max(px);
822                    y1 = y1.max(py);
823                }
824            }
825        }
826        (x0, y0, x1, y1)
827    }
828
829    /// A non-cube box drawn axis-aligned vs. drawn with a per-instance
830    /// transform that swaps its long axis onto the screen's other axis
831    /// flips the silhouette's aspect ratio. Pins that the `s/h/f` basis
832    /// (the path `DynSpriteTransform` feeds) actually reorients the model.
833    #[test]
834    fn posed_basis_reorients_silhouette() {
835        // Wide-in-local-x, short-in-local-z box → appears wide on screen
836        // (screen-x = world-x via `right`, screen-y = world-z via `down`).
837        let kv6 = Kv6::solid_box(16, 4, 4, 0x80_C0_40_20);
838        let (w, h) = (64u32, 64u32);
839        let n = (w * h) as usize;
840        let cam = cam_looking_y();
841        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
842
843        // Axis-aligned: wide silhouette.
844        let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
845        let mut fb = vec![0u32; n];
846        let mut zb = vec![f32::INFINITY; n];
847        let _ = draw_sprite_dda(
848            &mut fb,
849            &mut zb,
850            w as usize,
851            w,
852            h,
853            &cs,
854            &settings(w, h),
855            &aa,
856        );
857        let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
858        let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
859        assert!(
860            aa_wide > 4,
861            "axis-aligned box should be wider than tall (got w-h={aa_wide})"
862        );
863
864        // Posed: map local +x onto world +z and local +z onto world +x
865        // (det = -1 ≠ 0). Same box now reads tall on screen.
866        let mut posed = aa.clone();
867        posed.s = [0.0, 0.0, 1.0]; // local +x ↦ world +z (screen down)
868        posed.h = [0.0, 1.0, 0.0]; // local +y ↦ world +y (depth)
869        posed.f = [1.0, 0.0, 0.0]; // local +z ↦ world +x (screen right)
870        let mut fb2 = vec![0u32; n];
871        let mut zb2 = vec![f32::INFINITY; n];
872        let _ = draw_sprite_dda(
873            &mut fb2,
874            &mut zb2,
875            w as usize,
876            w,
877            h,
878            &cs,
879            &settings(w, h),
880            &posed,
881        );
882        let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
883        let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
884        assert!(
885            posed_tall > 4,
886            "posed box should be taller than wide (got h-w={posed_tall})"
887        );
888    }
889
890    /// A degenerate (singular) basis — `det == 0` — makes the sprite
891    /// silently skip rather than panic (the `DynSpriteTransform` guard).
892    #[test]
893    fn degenerate_basis_draws_nothing() {
894        let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
895        let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
896        sprite.f = sprite.s; // two equal columns → det 0
897        let (w, h) = (32u32, 32u32);
898        let n = (w * h) as usize;
899        let mut fb = vec![0u32; n];
900        let mut zb = vec![f32::INFINITY; n];
901        let cam = cam_looking_y();
902        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
903        let wrote = draw_sprite_dda(
904            &mut fb,
905            &mut zb,
906            w as usize,
907            w,
908            h,
909            &cs,
910            &settings(w, h),
911            &sprite,
912        );
913        assert_eq!(wrote, 0, "singular basis must skip, not panic");
914    }
915
916    /// An invisible sprite draws nothing.
917    #[test]
918    fn invisible_sprite_skipped() {
919        let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
920        let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
921        sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
922        let (w, h) = (32u32, 32u32);
923        let n = (w * h) as usize;
924        let mut fb = vec![0u32; n];
925        let mut zb = vec![f32::INFINITY; n];
926        let cam = cam_looking_y();
927        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
928        let wrote = draw_sprite_dda(
929            &mut fb,
930            &mut zb,
931            w as usize,
932            w,
933            h,
934            &cs,
935            &settings(w, h),
936            &sprite,
937        );
938        assert_eq!(wrote, 0);
939    }
940}