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};
24
25use crate::camera_math::CameraState;
26use crate::dda::{dda_setup, intersect_aabb, min_axis, pixel_ray, shade};
27use crate::opticast::OpticastSettings;
28use crate::raster_target::RasterTarget;
29
30/// Near-plane parameter: voxels nearer than this (camera-forward) are
31/// dropped, keeping the pinhole divide finite.
32const NEAR_Z: f32 = 1.0;
33
34/// Dense occupancy + colour grid decoded from a KV6's surface-voxel run
35/// tables. KV6 stores only surface voxels (no interior), which is
36/// exactly what a from-air ray hits, so first-occupied is the visible
37/// surface.
38struct Kv6Dense {
39    dims: [i32; 3],
40    occ: Vec<bool>,
41    col: Vec<u32>,
42}
43
44impl Kv6Dense {
45    #[allow(clippy::cast_possible_wrap)]
46    fn build(kv6: &Kv6) -> Self {
47        let dims = [kv6.xsiz as i32, kv6.ysiz as i32, kv6.zsiz as i32];
48        let n = (dims[0].max(0) * dims[1].max(0) * dims[2].max(0)) as usize;
49        let mut occ = vec![false; n];
50        let mut col = vec![0u32; n];
51        let mut vi = 0usize;
52        for x in 0..kv6.xsiz as usize {
53            for y in 0..kv6.ysiz as usize {
54                let cnt = usize::from(kv6.ylen[x][y]);
55                for _ in 0..cnt {
56                    let v = kv6.voxels[vi];
57                    vi += 1;
58                    let z = i32::from(v.z);
59                    if z >= 0 && z < dims[2] {
60                        let idx = ((x as i32 * dims[1] + y as i32) * dims[2] + z) as usize;
61                        occ[idx] = true;
62                        // KV6's colour high byte is voxlap's `dir`/shading
63                        // slot, NOT the 0..128 brightness `shade` expects:
64                        // some models store `0x80` (coco), others `0x00`
65                        // (meltsphere → all-black under `shade`). Force full
66                        // brightness so both render at their authored RGB
67                        // (sprites are flat-lit in the clean-room path; the
68                        // voxlap `dir`-LUT reflection shading is not ported).
69                        col[idx] = (v.col & 0x00ff_ffff) | 0x8000_0000;
70                    }
71                }
72            }
73        }
74        Self { dims, occ, col }
75    }
76
77    #[inline]
78    #[allow(clippy::cast_sign_loss)]
79    fn at(&self, c: [i32; 3]) -> Option<u32> {
80        let idx = ((c[0] * self.dims[1] + c[1]) * self.dims[2] + c[2]) as usize;
81        self.occ[idx].then(|| self.col[idx])
82    }
83}
84
85/// Inverse of the column-matrix `[s | h | f]` (the sprite basis), or
86/// `None` if degenerate. Maps a world delta into local voxel space.
87fn invert_basis(s: [f32; 3], h: [f32; 3], f: [f32; 3]) -> Option<[[f32; 3]; 3]> {
88    let det = s[0] * (h[1] * f[2] - f[1] * h[2]) - h[0] * (s[1] * f[2] - f[1] * s[2])
89        + f[0] * (s[1] * h[2] - h[1] * s[2]);
90    if det.abs() < 1e-12 {
91        return None;
92    }
93    let inv = 1.0 / det;
94    Some([
95        [
96            (h[1] * f[2] - f[1] * h[2]) * inv,
97            -(h[0] * f[2] - f[0] * h[2]) * inv,
98            (h[0] * f[1] - f[0] * h[1]) * inv,
99        ],
100        [
101            -(s[1] * f[2] - f[1] * s[2]) * inv,
102            (s[0] * f[2] - f[0] * s[2]) * inv,
103            -(s[0] * f[1] - f[0] * s[1]) * inv,
104        ],
105        [
106            (s[1] * h[2] - h[1] * s[2]) * inv,
107            -(s[0] * h[2] - h[0] * s[2]) * inv,
108            (s[0] * h[1] - h[0] * s[1]) * inv,
109        ],
110    ])
111}
112
113#[inline]
114fn mat_apply(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
115    [
116        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
117        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
118        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
119    ]
120}
121
122/// Cast one ray (already in the sprite's local voxel space) into the
123/// dense KV6 and return `(colour, t)` of the first solid voxel — `t` is
124/// the world-units ray parameter (shared with the world ray).
125#[allow(clippy::cast_possible_truncation)]
126fn cast_local(dense: &Kv6Dense, origin: [f32; 3], dir: [f32; 3]) -> Option<(u32, f32)> {
127    #[allow(clippy::cast_precision_loss)]
128    let hi = [
129        dense.dims[0] as f32,
130        dense.dims[1] as f32,
131        dense.dims[2] as f32,
132    ];
133    let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
134    let start = t0 + 1e-4;
135    let p = [
136        origin[0] + dir[0] * start,
137        origin[1] + dir[1] * start,
138        origin[2] + dir[2] * start,
139    ];
140    let mut cell = [
141        (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
142        (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
143        (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
144    ];
145    let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
146    let mut t_curr = t0;
147    let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
148    for _ in 0..max_steps {
149        if cell[0] < 0
150            || cell[0] >= dense.dims[0]
151            || cell[1] < 0
152            || cell[1] >= dense.dims[1]
153            || cell[2] < 0
154            || cell[2] >= dense.dims[2]
155            || t_curr > t1
156        {
157            return None;
158        }
159        if let Some(color) = dense.at(cell) {
160            return Some((color, t_curr));
161        }
162        let axis = min_axis(t_max);
163        t_curr = t_max[axis];
164        cell[axis] += step[axis];
165        t_max[axis] += t_delta[axis];
166    }
167    None
168}
169
170/// Draw one KV6 [`Sprite`] into `(fb, zb)` by per-pixel ray casting,
171/// depth-compositing against whatever the terrain pass already wrote.
172/// Returns the number of pixels written.
173///
174/// `cam` / `settings` are the **same** per-frame projection the DDA
175/// terrain pass used (build via [`crate::camera_math::derive`]), so
176/// sprite and terrain share one pinhole and z convention. `pitch_pixels`
177/// is the framebuffer row stride. Honours `SPRITE_FLAG_INVISIBLE`
178/// (skip) and `SPRITE_FLAG_NO_Z` (write without the depth test).
179#[allow(
180    clippy::too_many_arguments,
181    clippy::cast_possible_truncation,
182    clippy::cast_sign_loss
183)]
184#[must_use]
185pub fn draw_sprite_dda(
186    fb: &mut [u32],
187    zb: &mut [f32],
188    pitch_pixels: usize,
189    width: u32,
190    height: u32,
191    cam: &CameraState,
192    settings: &OpticastSettings,
193    sprite: &Sprite,
194) -> u32 {
195    if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
196        return 0;
197    }
198    let dense = Kv6Dense::build(&sprite.kv6);
199    if dense.occ.is_empty() {
200        return 0;
201    }
202    let Some(minv) = invert_basis(sprite.s, sprite.h, sprite.f) else {
203        return 0;
204    };
205    let pivot = [sprite.kv6.xpiv, sprite.kv6.ypiv, sprite.kv6.zpiv];
206    let no_z = sprite.flags & SPRITE_FLAG_NO_Z != 0;
207
208    // Screen bounding box from the 8 corners of the local voxel box.
209    let Some(rect) = project_screen_rect(sprite, cam, settings, width, height) else {
210        return 0;
211    };
212
213    debug_assert_eq!(fb.len(), zb.len());
214    let target = RasterTarget::new(fb, zb);
215    let mut written = 0u32;
216    for py in rect.1..rect.3 {
217        let row = py as usize * pitch_pixels;
218        for px in rect.0..rect.2 {
219            let (origin, dir) = pixel_ray(cam, settings, px, py);
220            // World ray → sprite-local voxel space.
221            let rel = [
222                origin[0] - sprite.p[0],
223                origin[1] - sprite.p[1],
224                origin[2] - sprite.p[2],
225            ];
226            let ol = mat_apply(&minv, rel);
227            let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
228            let dir_local = mat_apply(&minv, dir);
229            let Some((color, t)) = cast_local(&dense, origin_local, dir_local) else {
230                continue;
231            };
232            let fwd_dot =
233                dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
234            let depth = t * fwd_dot;
235            if depth < NEAR_Z {
236                continue;
237            }
238            let lit = shade(color, 0);
239            let idx = row + px as usize;
240            // SAFETY: idx in-bounds for the rect within (width, height);
241            // single-threaded writer.
242            let wrote = unsafe {
243                if no_z {
244                    target.write_color(idx, lit);
245                    target.write_depth(idx, depth);
246                    true
247                } else {
248                    target.z_test_write(idx, lit, depth)
249                }
250            };
251            written += u32::from(wrote);
252        }
253    }
254    written
255}
256
257/// Project the sprite's local voxel AABB to a clamped screen rectangle
258/// `(x0, y0, x1, y1)` (half-open). `None` if it can't appear; falls back
259/// to the full viewport when the box straddles the near plane (rare).
260#[allow(
261    clippy::cast_possible_truncation,
262    clippy::cast_sign_loss,
263    clippy::cast_precision_loss
264)]
265fn project_screen_rect(
266    sprite: &Sprite,
267    cam: &CameraState,
268    settings: &OpticastSettings,
269    width: u32,
270    height: u32,
271) -> Option<(u32, u32, u32, u32)> {
272    let kv6 = &sprite.kv6;
273    let (xs, ys, zs) = (kv6.xsiz as f32, kv6.ysiz as f32, kv6.zsiz as f32);
274    let (xp, yp, zp) = (kv6.xpiv, kv6.ypiv, kv6.zpiv);
275    let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
276    let mut all_front = true;
277    for &cx in &[0.0, xs] {
278        for &cy in &[0.0, ys] {
279            for &cz in &[0.0, zs] {
280                // Local → world via the sprite basis about the pivot.
281                let lx = cx - xp;
282                let ly = cy - yp;
283                let lz = cz - zp;
284                let world = [
285                    sprite.p[0] + lx * sprite.s[0] + ly * sprite.h[0] + lz * sprite.f[0],
286                    sprite.p[1] + lx * sprite.s[1] + ly * sprite.h[1] + lz * sprite.f[1],
287                    sprite.p[2] + lx * sprite.s[2] + ly * sprite.h[2] + lz * sprite.f[2],
288                ];
289                let rel = [
290                    world[0] - cam.pos[0],
291                    world[1] - cam.pos[1],
292                    world[2] - cam.pos[2],
293                ];
294                let cz_cam =
295                    rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
296                if cz_cam < NEAR_Z {
297                    all_front = false;
298                    continue;
299                }
300                let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
301                let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
302                let sx = settings.hx + cx_cam / cz_cam * settings.hz;
303                let sy = settings.hy + cy_cam / cz_cam * settings.hz;
304                x0 = x0.min(sx);
305                y0 = y0.min(sy);
306                x1 = x1.max(sx);
307                y1 = y1.max(sy);
308            }
309        }
310    }
311    let (w, h) = (width as f32, height as f32);
312    let (rx0, ry0, rx1, ry1) = if all_front {
313        (
314            (x0 - 1.0).max(0.0),
315            (y0 - 1.0).max(0.0),
316            (x1 + 1.0).min(w),
317            (y1 + 1.0).min(h),
318        )
319    } else {
320        // Straddles the near plane → scan the whole viewport.
321        (0.0, 0.0, w, h)
322    };
323    if rx0 >= rx1 || ry0 >= ry1 {
324        return None;
325    }
326    Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::camera_math;
333    use crate::Camera;
334    use roxlap_formats::kv6::Kv6;
335    use roxlap_formats::sprite::Sprite;
336
337    fn settings(w: u32, h: u32) -> OpticastSettings {
338        OpticastSettings::for_oracle_framebuffer(w, h)
339    }
340
341    /// Camera at the origin looking down +y at a sprite ahead.
342    fn cam_looking_y() -> Camera {
343        Camera {
344            pos: [0.0, 0.0, 0.0],
345            right: [1.0, 0.0, 0.0],
346            down: [0.0, 0.0, 1.0],
347            forward: [0.0, 1.0, 0.0],
348        }
349    }
350
351    /// A solid cube sprite in front of the camera is drawn, with the
352    /// cube colour (shaded) and a sensible centre depth.
353    #[test]
354    fn cube_sprite_renders() {
355        let kv6 = Kv6::solid_cube(8, 0x80_C0_40_20);
356        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
357        let (w, h) = (64u32, 64u32);
358        let n = (w * h) as usize;
359        let mut fb = vec![0u32; n];
360        let mut zb = vec![f32::INFINITY; n];
361        let cam = cam_looking_y();
362        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
363        let wrote = draw_sprite_dda(
364            &mut fb,
365            &mut zb,
366            w as usize,
367            w,
368            h,
369            &cs,
370            &settings(w, h),
371            &sprite,
372        );
373
374        assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
375        let centre = (h / 2 * w + w / 2) as usize;
376        assert_eq!(
377            fb[centre] & 0x00ff_ffff,
378            0x00_C0_40_20,
379            "got {:08x}",
380            fb[centre]
381        );
382        // Pivot at world y=40, cube spans y in [36,44] → near face ~36.
383        assert!(
384            (zb[centre] - 36.0).abs() < 3.0,
385            "centre depth {} not ≈ 36",
386            zb[centre]
387        );
388    }
389
390    /// A KV6 whose voxel colours store a `0x00` high byte (voxlap's
391    /// unused `dir` slot, e.g. `sprite_meltsphere.kv6`) must still
392    /// render its authored RGB, not black — the brightness byte is
393    /// normalised to full on decode.
394    #[test]
395    fn zero_high_byte_sprite_not_black() {
396        let kv6 = Kv6::solid_cube(8, 0x00_C0_40_20);
397        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
398        let (w, h) = (64u32, 64u32);
399        let n = (w * h) as usize;
400        let mut fb = vec![0u32; n];
401        let mut zb = vec![f32::INFINITY; n];
402        let cam = cam_looking_y();
403        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
404        let wrote = draw_sprite_dda(
405            &mut fb,
406            &mut zb,
407            w as usize,
408            w,
409            h,
410            &cs,
411            &settings(w, h),
412            &sprite,
413        );
414        assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
415        let centre = (h / 2 * w + w / 2) as usize;
416        assert_eq!(
417            fb[centre] & 0x00ff_ffff,
418            0x00_C0_40_20,
419            "zero-high-byte sprite rendered as {:08x} (black bug)",
420            fb[centre]
421        );
422    }
423
424    /// A sprite occludes / is occluded by the z-buffer: a nearer
425    /// pre-filled depth blocks the sprite; a farther one lets it win.
426    #[test]
427    fn sprite_respects_zbuffer() {
428        let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
429        let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
430        let (w, h) = (32u32, 32u32);
431        let n = (w * h) as usize;
432        let cam = cam_looking_y();
433        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
434        let centre = (h / 2 * w + w / 2) as usize;
435
436        // Terrain in front (depth 10 < ~36) → sprite blocked at centre.
437        let mut fb = vec![0u32; n];
438        let mut zb = vec![f32::INFINITY; n];
439        fb[centre] = 0x80_11_22_33;
440        zb[centre] = 10.0;
441        let _ = draw_sprite_dda(
442            &mut fb,
443            &mut zb,
444            w as usize,
445            w,
446            h,
447            &cs,
448            &settings(w, h),
449            &sprite,
450        );
451        assert_eq!(
452            fb[centre], 0x80_11_22_33,
453            "near terrain must occlude sprite"
454        );
455
456        // Terrain behind (depth 100) → sprite wins.
457        let mut fb2 = vec![0u32; n];
458        let mut zb2 = vec![f32::INFINITY; n];
459        fb2[centre] = 0x80_11_22_33;
460        zb2[centre] = 100.0;
461        let _ = draw_sprite_dda(
462            &mut fb2,
463            &mut zb2,
464            w as usize,
465            w,
466            h,
467            &cs,
468            &settings(w, h),
469            &sprite,
470        );
471        assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
472        assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
473    }
474
475    /// The covered screen rect (min/max px,py) of whatever the sprite
476    /// painted — used to compare an axis-aligned vs a rotated pose.
477    fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
478        let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
479        for py in 0..h {
480            for px in 0..w {
481                if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
482                    x0 = x0.min(px);
483                    y0 = y0.min(py);
484                    x1 = x1.max(px);
485                    y1 = y1.max(py);
486                }
487            }
488        }
489        (x0, y0, x1, y1)
490    }
491
492    /// A non-cube box drawn axis-aligned vs. drawn with a per-instance
493    /// transform that swaps its long axis onto the screen's other axis
494    /// flips the silhouette's aspect ratio. Pins that the `s/h/f` basis
495    /// (the path `DynSpriteTransform` feeds) actually reorients the model.
496    #[test]
497    fn posed_basis_reorients_silhouette() {
498        // Wide-in-local-x, short-in-local-z box → appears wide on screen
499        // (screen-x = world-x via `right`, screen-y = world-z via `down`).
500        let kv6 = Kv6::solid_box(16, 4, 4, 0x80_C0_40_20);
501        let (w, h) = (64u32, 64u32);
502        let n = (w * h) as usize;
503        let cam = cam_looking_y();
504        let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
505
506        // Axis-aligned: wide silhouette.
507        let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
508        let mut fb = vec![0u32; n];
509        let mut zb = vec![f32::INFINITY; n];
510        let _ = draw_sprite_dda(
511            &mut fb,
512            &mut zb,
513            w as usize,
514            w,
515            h,
516            &cs,
517            &settings(w, h),
518            &aa,
519        );
520        let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
521        let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
522        assert!(
523            aa_wide > 4,
524            "axis-aligned box should be wider than tall (got w-h={aa_wide})"
525        );
526
527        // Posed: map local +x onto world +z and local +z onto world +x
528        // (det = -1 ≠ 0). Same box now reads tall on screen.
529        let mut posed = aa.clone();
530        posed.s = [0.0, 0.0, 1.0]; // local +x ↦ world +z (screen down)
531        posed.h = [0.0, 1.0, 0.0]; // local +y ↦ world +y (depth)
532        posed.f = [1.0, 0.0, 0.0]; // local +z ↦ world +x (screen right)
533        let mut fb2 = vec![0u32; n];
534        let mut zb2 = vec![f32::INFINITY; n];
535        let _ = draw_sprite_dda(
536            &mut fb2,
537            &mut zb2,
538            w as usize,
539            w,
540            h,
541            &cs,
542            &settings(w, h),
543            &posed,
544        );
545        let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
546        let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
547        assert!(
548            posed_tall > 4,
549            "posed box should be taller than wide (got h-w={posed_tall})"
550        );
551    }
552
553    /// A degenerate (singular) basis — `det == 0` — makes the sprite
554    /// silently skip rather than panic (the `DynSpriteTransform` guard).
555    #[test]
556    fn degenerate_basis_draws_nothing() {
557        let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
558        let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
559        sprite.f = sprite.s; // two equal columns → det 0
560        let (w, h) = (32u32, 32u32);
561        let n = (w * h) as usize;
562        let mut fb = vec![0u32; n];
563        let mut zb = vec![f32::INFINITY; n];
564        let cam = cam_looking_y();
565        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
566        let wrote = draw_sprite_dda(
567            &mut fb,
568            &mut zb,
569            w as usize,
570            w,
571            h,
572            &cs,
573            &settings(w, h),
574            &sprite,
575        );
576        assert_eq!(wrote, 0, "singular basis must skip, not panic");
577    }
578
579    /// An invisible sprite draws nothing.
580    #[test]
581    fn invisible_sprite_skipped() {
582        let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
583        let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
584        sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
585        let (w, h) = (32u32, 32u32);
586        let n = (w * h) as usize;
587        let mut fb = vec![0u32; n];
588        let mut zb = vec![f32::INFINITY; n];
589        let cam = cam_looking_y();
590        let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
591        let wrote = draw_sprite_dda(
592            &mut fb,
593            &mut zb,
594            w as usize,
595            w,
596            h,
597            &cs,
598            &settings(w, h),
599            &sprite,
600        );
601        assert_eq!(wrote, 0);
602    }
603}