Skip to main content

roxlap_scene/
render.rs

1//! Scene-level rendering — drives [`roxlap_core::opticast::opticast`]
2//! across the grids of a [`Scene`].
3//!
4//! Two entry points:
5//!
6//! - [`render_scene_composed`] (recommended for multi-grid scenes):
7//!   per grid, allocates a temporary framebuffer + zbuffer, runs
8//!   opticast into the temp, then merges into the shared output via
9//!   per-pixel min-z. Correctly composites overlapping grid output.
10//! - [`render_scene`] (single-grid trusting caller): writes every
11//!   grid directly into the shared rasterizer. For single-grid
12//!   scenes this matches a direct opticast call byte-for-byte; for
13//!   multi-grid it's last-grid-wins (sky writes from grid B
14//!   overwrite grid A's hits). Useful for tests / single-grid
15//!   sanity checks.
16//!
17//! ## S4B.2.e: Approach B multi-chunk dispatch
18//!
19//! Both APIs route per-grid rendering through
20//! [`crate::Grid::chunk_xy_backing`] → [`roxlap_core::ChunkGrid`] →
21//! [`roxlap_core::GridView::from_chunk_grid`] → [`opticast`].
22//! `opticast`'s prelude looks up the camera's chunk via
23//! [`roxlap_core::GridView::chunk_at_xy`]; the grouscan column-step
24//! swaps the active per-chunk `(slab_buf, column_offsets)` when
25//! rays cross a chunk-XY boundary. The combined-world stitch
26//! (Approach C, S4.0..S4.2) is no longer in the render path — the
27//! lighting bake still uses it until S4B.4 lands a per-chunk bake.
28//!
29//! Per-grid rotation (S5) and per-grid LOD (S6) plug in at the
30//! same dispatch point: rotate the world camera into grid-local
31//! before the chunk-grid lookup, then dispatch coarse / fine /
32//! billboard based on grid-camera distance.
33
34// `fb` / `zb` (framebuffer / zbuffer) and the `_fb` / `_zb` suffixes
35// throughout this module are voxlap-canonical pairs — drilling them
36// apart with longer names just hurts readability.
37#![allow(clippy::similar_names)]
38
39use glam::DVec3;
40use roxlap_core::opticast::{opticast, OpticastOutcome, OpticastSettings};
41use roxlap_core::rasterizer::ScratchPool;
42use roxlap_core::scalar_rasterizer::ScalarRasterizer;
43use roxlap_core::sky::Sky;
44use roxlap_core::Camera;
45
46use crate::{GridTransform, Scene, CHUNK_SIZE_XY};
47
48/// Sentinel colour stamped into a `render_sky = false` grid's
49/// temporary framebuffer wherever the rasterizer would have drawn
50/// sky. After opticast, [`render_scene_composed`] walks the temp
51/// buffer and resets `temp_zb` to [`f32::INFINITY`] for any pixel
52/// still carrying this value — those pixels then always lose
53/// [`compose_into`]'s min-z test and the underlying grid's sky
54/// (or another grid's hit) wins.
55///
56/// Alpha byte is `0x00`. Voxlap voxel slabs carry an alpha-encoded
57/// shade in `[0x00, 0x80]`, but a `0x00` alpha **with this exact
58/// RGB pattern** is exceedingly unlikely to occur on a real hit
59/// (the lit-voxel path produces alpha ≥ 0x40 in practice). Bit
60/// pattern is also visually distinct (cyan-ish neon) if anything
61/// ever leaks through to the screen, making the bug obvious.
62const SKY_MASK_SENTINEL: u32 = 0x00_DE_AD_BE;
63
64/// Project a world-space [`Camera`] into a grid's local frame:
65/// translate by `-transform.origin`, then apply
66/// `transform.rotation.inverse()` to the position and the
67/// orthonormal basis (`right` / `down` / `forward`).
68///
69/// Identity rotation collapses to pure translation, byte-identical
70/// to the pre-S5 path (`DQuat::IDENTITY * v == v`). For a rotated
71/// grid the rasterizer still sees an axis-aligned chunk grid —
72/// rotation is invisible below this layer per PORTING-SCENE.md § S5.
73///
74/// The basis is rotated as a free vector (no translation
75/// component); position is rotated about the grid origin.
76fn world_camera_to_grid_local(camera: &Camera, transform: &GridTransform) -> Camera {
77    let inv = transform.rotation.inverse();
78    let world_offset = DVec3::from_array(camera.pos) - transform.origin;
79    let local_pos = inv * world_offset;
80    let local_right = inv * DVec3::from_array(camera.right);
81    let local_down = inv * DVec3::from_array(camera.down);
82    let local_forward = inv * DVec3::from_array(camera.forward);
83    Camera {
84        pos: local_pos.to_array(),
85        right: local_right.to_array(),
86        down: local_down.to_array(),
87        forward: local_forward.to_array(),
88    }
89}
90
91/// Outcome of a [`render_scene`] / [`render_scene_composed`] call.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum RenderOutcome {
94    /// At least one grid produced a render.
95    Rendered {
96        /// Number of grids whose opticast pass returned
97        /// [`OpticastOutcome::Rendered`].
98        grids_drawn: usize,
99    },
100    /// No grid rendered. Either the scene was empty or every
101    /// per-grid opticast call returned
102    /// [`OpticastOutcome::SkippedCameraInSolid`].
103    Empty,
104}
105
106/// Render every grid in `scene` directly into `(fb, zb)` — no
107/// per-grid temp buffer, no compose merge. For multi-grid scenes
108/// this is last-grid-wins (later grids' opticast writes overwrite
109/// earlier grids' pixels indiscriminately, including sky), so it's
110/// only correct for single-grid scenes.
111///
112/// Use this when you have one grid and want the byte-stable
113/// matches-direct-opticast property — the test suite uses it as a
114/// sanity check that the combined-world stitch + render harness
115/// doesn't drift vs. a raw [`opticast`] call.
116///
117/// Caller pre-fills `fb` with the desired sky colour and `zb` with
118/// any value (typically `0.0` matching the per-chunk renderer's
119/// convention or `f32::INFINITY` for compose-friendly init); the
120/// rasterizer overwrites both per pixel that gets a hit.
121#[allow(clippy::too_many_arguments)]
122pub fn render_scene(
123    fb: &mut [u32],
124    zb: &mut [f32],
125    pitch_pixels: usize,
126    width: u32,
127    height: u32,
128    pool: &mut ScratchPool,
129    scene: &mut Scene,
130    camera: &Camera,
131    settings: &OpticastSettings,
132    sky: Option<&Sky>,
133) -> RenderOutcome {
134    debug_assert_eq!(fb.len(), zb.len());
135    let pixel_count = (width as usize) * (height as usize);
136    debug_assert_eq!(fb.len(), pixel_count);
137
138    let mut grids_drawn = 0usize;
139    for (_id, grid) in scene.grids_mut() {
140        // S4B.2.e: Approach B render path. World → grid-local
141        // camera transform doesn't need a voxel-offset adjustment
142        // anymore — Approach B's chunks live at their signed
143        // (chx, chy) indices and `chunk_at_xy` handles negative-
144        // index lookups natively.
145        //
146        // S5.0: per-grid arbitrary rotation. The local camera is
147        // built by `world_camera_to_grid_local` — translation +
148        // inverse-rotation of the basis. Identity rotation keeps
149        // this byte-identical to the pre-S5 translate-only form.
150        let Some(backing) = grid.chunk_xyz_backing() else {
151            // Empty grid (no populated chz=0 chunks) — skip.
152            continue;
153        };
154        let local_cam = world_camera_to_grid_local(camera, &grid.transform);
155        let cg = roxlap_core::ChunkGrid {
156            chunks: &backing.chunks,
157            origin_chunk_xy: backing.origin_chunk_xy,
158            origin_chunk_z: backing.origin_chunk_z,
159            chunks_x: backing.chunks_x,
160            chunks_y: backing.chunks_y,
161            chunks_z: backing.chunks_z,
162        };
163        let grid_view = roxlap_core::GridView::from_chunk_grid(&cg, CHUNK_SIZE_XY);
164        let outcome = {
165            let mut rasterizer = ScalarRasterizer::new(fb, zb, pitch_pixels, grid_view);
166            if let Some(sky_ref) = sky {
167                rasterizer = rasterizer.with_sky(sky_ref);
168            }
169            opticast(&mut rasterizer, pool, &local_cam, settings, grid_view)
170        };
171        if outcome == OpticastOutcome::Rendered {
172            grids_drawn += 1;
173        }
174    }
175    if grids_drawn == 0 {
176        RenderOutcome::Empty
177    } else {
178        RenderOutcome::Rendered { grids_drawn }
179    }
180}
181
182/// Per-pixel "min-z wins" merge of `(temp_fb, temp_zb)` into
183/// `(shared_fb, shared_zb)`.
184///
185/// Voxlap's z-buffer convention: `z` = perpendicular distance from
186/// camera; **smaller `z` = closer to camera**. This helper picks
187/// the closer pixel per slot. Sky pixels emerge with a large `z`
188/// (`scratch.skycast.dist`, set to `gxmax` or `i32::MAX` per
189/// `phase_startsky`) so they always lose to any hit's finite
190/// distance.
191///
192/// `temp_fb` / `temp_zb` are read-only inputs; both must have the
193/// same length as `shared_fb` / `shared_zb` (debug-asserted).
194pub fn compose_into(
195    shared_fb: &mut [u32],
196    shared_zb: &mut [f32],
197    temp_fb: &[u32],
198    temp_zb: &[f32],
199) {
200    debug_assert_eq!(shared_fb.len(), shared_zb.len());
201    debug_assert_eq!(shared_fb.len(), temp_fb.len());
202    debug_assert_eq!(shared_fb.len(), temp_zb.len());
203    for i in 0..shared_fb.len() {
204        if temp_zb[i] < shared_zb[i] {
205            shared_fb[i] = temp_fb[i];
206            shared_zb[i] = temp_zb[i];
207        }
208    }
209}
210
211/// Render every grid in `scene` with per-grid temporary buffers +
212/// z-buffer composition. The canonical multi-grid scene render
213/// path.
214///
215/// Algorithm:
216/// 1. Caller pre-fills `fb` with the desired sky colour and `zb`
217///    with [`f32::INFINITY`] (so any rendered pixel wins the
218///    initial composition).
219/// 2. For each grid, allocate a temporary `(temp_fb, temp_zb)` of
220///    the same size, pre-fill them with sky / `INFINITY`, and run
221///    [`opticast`] into them via a [`ScalarRasterizer`] over the
222///    temporary buffers AND the grid's combined-world view (S4.0).
223/// 3. Merge the temporary buffers into the shared `(fb, zb)` via
224///    [`compose_into`] — closer pixels (smaller `z`) win.
225///
226/// Pixel correctness across overlapping grids: sky pixels emerge
227/// with `z` = `gxmax` / `i32::MAX` (a very large value), so they
228/// always lose to any hit. Hits compete on actual perpendicular
229/// distance — the closer grid's surface is what gets composited.
230///
231/// `pitch_pixels` is the framebuffer's row stride in pixels (×4 for
232/// bytes). `width` × `height` must equal `fb.len()` /
233/// `zb.len()`. `sky` is the optional textured sky resource the
234/// rasterizer threads through to `phase_startsky`; `None` ⇒ solid
235/// `pool.skycast` fill.
236///
237/// **Heap allocation per call:** two `Vec` allocations per grid (a
238/// temp framebuffer and zbuffer). For repeated frame rendering an
239/// owned scratch struct that pre-allocates these is the obvious
240/// optimisation; deferred until profiling shows it matters.
241#[allow(clippy::too_many_arguments)]
242pub fn render_scene_composed(
243    fb: &mut [u32],
244    zb: &mut [f32],
245    pitch_pixels: usize,
246    width: u32,
247    height: u32,
248    pool: &mut ScratchPool,
249    scene: &mut Scene,
250    camera: &Camera,
251    settings: &OpticastSettings,
252    sky_color: u32,
253    sky: Option<&Sky>,
254) -> RenderOutcome {
255    debug_assert_eq!(fb.len(), zb.len());
256    let pixel_count = (width as usize) * (height as usize);
257    debug_assert_eq!(fb.len(), pixel_count);
258
259    let mut grids_drawn = 0usize;
260    let mut temp_fb = vec![sky_color; pixel_count];
261    let mut temp_zb = vec![f32::INFINITY; pixel_count];
262
263    for (_id, grid) in scene.grids_mut() {
264        // S4B.2.e: Approach B render path. See `render_scene`'s
265        // body for the camera transform + ChunkGrid construction
266        // commentary; the only difference is this writes to
267        // (temp_fb, temp_zb) and composes via `compose_into`.
268        // S5.0: per-grid rotation flows via the shared helper.
269        let Some(backing) = grid.chunk_xyz_backing() else {
270            continue;
271        };
272        // S5.2-followup: per-grid sky opt-out. Grids with
273        // `render_sky = false` (e.g. a rotating ship) must not
274        // contribute sky pixels — the grid-local sky lookup
275        // rotates with the grid and visibly fights the world's
276        // sky during compose. Implementation: stamp a sentinel
277        // colour into temp_fb everywhere the rasterizer would
278        // paint sky, then walk the buffer post-opticast and
279        // mark sentinel pixels as `INFINITY` in temp_zb so
280        // [`compose_into`]'s min-z test always drops them.
281        let owns_sky = grid.render_sky;
282        let local_sky_color = if owns_sky {
283            sky_color
284        } else {
285            SKY_MASK_SENTINEL
286        };
287        if !owns_sky {
288            // Override the pool's skycast colour just for this
289            // grid so the solid-fill sky path stamps the sentinel.
290            // Restored after the grid's compose. `dist = 0` mirrors
291            // the caller's typical setup; the rasterizer's prelude
292            // overwrites the dist field with `gxmax` or `i32::MAX`
293            // anyway, so the dist arg is cosmetic here.
294            pool.set_skycast(SKY_MASK_SENTINEL as i32, 0);
295        }
296
297        // Reset temp to sky / INFINITY so each grid starts fresh.
298        // The reset cost is O(pixels) per grid; for small grid counts
299        // this is negligible vs the opticast work.
300        temp_fb.fill(local_sky_color);
301        temp_zb.fill(f32::INFINITY);
302
303        let local_cam = world_camera_to_grid_local(camera, &grid.transform);
304        let cg = roxlap_core::ChunkGrid {
305            chunks: &backing.chunks,
306            origin_chunk_xy: backing.origin_chunk_xy,
307            origin_chunk_z: backing.origin_chunk_z,
308            chunks_x: backing.chunks_x,
309            chunks_y: backing.chunks_y,
310            chunks_z: backing.chunks_z,
311        };
312        let grid_view = roxlap_core::GridView::from_chunk_grid(&cg, CHUNK_SIZE_XY);
313
314        // S5.2-followup: per-grid mip-levels override. Small grids
315        // (e.g. a rotating ship) hit the axis-aligned cf-cancellation
316        // multi-mip artifact (see
317        // `[[project_axis_aligned_mip_beams]]`) when the rotation
318        // creates near-axis-aligned rays through deep mips. Capping
319        // mip_levels=1 for these grids costs negligible perf
320        // (their world footprint is small) and avoids the artifact.
321        let per_grid_settings;
322        let active_settings = match grid.mip_levels_override {
323            Some(cap) => {
324                let clamped = cap.clamp(1, settings.mip_levels);
325                per_grid_settings = OpticastSettings {
326                    mip_levels: clamped,
327                    ..*settings
328                };
329                &per_grid_settings
330            }
331            None => settings,
332        };
333
334        let outcome = {
335            let mut rasterizer =
336                ScalarRasterizer::new(&mut temp_fb, &mut temp_zb, pitch_pixels, grid_view);
337            // Sky texture is suppressed for `!owns_sky` grids so
338            // the textured-sky branch doesn't bypass the sentinel;
339            // only the solid-fill path stamps `skycast.col`.
340            if owns_sky {
341                if let Some(sky_ref) = sky {
342                    rasterizer = rasterizer.with_sky(sky_ref);
343                }
344            }
345            opticast(
346                &mut rasterizer,
347                pool,
348                &local_cam,
349                active_settings,
350                grid_view,
351            )
352        };
353
354        if !owns_sky {
355            // Mask sentinel pixels so compose drops them. One linear
356            // sweep of the temp framebuffer; sentinel pixels become
357            // `INFINITY` in temp_zb (= always lose min-z).
358            for (px, z) in temp_fb.iter().zip(temp_zb.iter_mut()) {
359                if *px == SKY_MASK_SENTINEL {
360                    *z = f32::INFINITY;
361                }
362            }
363            // Restore the pool's sky colour so subsequent grids
364            // (and the next frame) see the caller's value.
365            pool.set_skycast(sky_color as i32, 0);
366        }
367
368        if outcome == OpticastOutcome::Rendered {
369            compose_into(fb, zb, &temp_fb, &temp_zb);
370            grids_drawn += 1;
371        }
372    }
373
374    if grids_drawn == 0 {
375        RenderOutcome::Empty
376    } else {
377        RenderOutcome::Rendered { grids_drawn }
378    }
379}
380
381#[cfg(test)]
382#[allow(clippy::float_cmp)]
383mod tests {
384    use super::*;
385    use crate::{GridTransform, Scene, CHUNK_SIZE_XY};
386    use glam::{DVec3, IVec3};
387    use roxlap_core::opticast::{opticast as core_opticast, OpticastSettings};
388    use roxlap_core::rasterizer::ScratchPool;
389    use roxlap_core::scalar_rasterizer::ScalarRasterizer;
390    use roxlap_core::{Camera, Engine};
391
392    const XRES: u32 = 320;
393    const YRES: u32 = 200;
394
395    /// Build a single-grid scene at the given world origin with a
396    /// recognisable shape inside its chunk (0, 0, 0): a 16-voxel
397    /// box plus a 6-radius sphere. Returns `(scene, grid_id)`.
398    fn build_one_grid_scene(world_origin: DVec3) -> (Scene, crate::GridId) {
399        let mut scene = Scene::new();
400        let id = scene.add_grid(GridTransform::at(world_origin));
401        let grid = scene.grid_mut(id).unwrap();
402        // Box covering [40..56]³ in chunk-local coords.
403        grid.set_rect(
404            IVec3::new(40, 40, 40),
405            IVec3::new(55, 55, 55),
406            Some(0x80_88_88_88),
407        );
408        // Sphere at (80, 80, 80) radius 6.
409        grid.set_sphere(IVec3::new(80, 80, 80), 6, Some(0x80_22_aa_22));
410        (scene, id)
411    }
412
413    fn camera_at(pos: [f64; 3]) -> Camera {
414        // Look +y axis; voxlap z-down convention. Right-handed:
415        // right × down == forward.
416        Camera {
417            pos,
418            right: [-1.0, 0.0, 0.0],
419            down: [0.0, 0.0, 1.0],
420            forward: [0.0, 1.0, 0.0],
421        }
422    }
423
424    /// Spin up an engine + `ScratchPool` + framebuffers ready for
425    /// one `opticast` / `render_scene` pass. `pool_vsid` should
426    /// cover the largest grid's combined vsid.
427    fn render_setup(pool_vsid: u32) -> (Engine, ScratchPool, Vec<u32>, Vec<f32>) {
428        let engine = Engine::new();
429        let mut pool = ScratchPool::new(XRES, YRES, pool_vsid);
430        let sky = engine.sky_color();
431        let sky_col_i = i32::from_ne_bytes(sky.to_ne_bytes());
432        pool.set_skycast(sky_col_i, 0);
433        let fog_col_i = i32::from_ne_bytes(engine.fog_color().to_ne_bytes());
434        pool.set_fog(fog_col_i, engine.fog_max_scan_dist());
435        pool.set_treat_z_max_as_air(true);
436        let pixel_count = (XRES as usize) * (YRES as usize);
437        let framebuffer = vec![sky; pixel_count];
438        let zbuffer = vec![0.0f32; pixel_count];
439        (engine, pool, framebuffer, zbuffer)
440    }
441
442    /// Render `scene` via [`render_scene`] (single-grid no-compose
443    /// path) and return the resulting framebuffer.
444    fn render_via_scene(scene: &mut Scene, camera: &Camera) -> Vec<u32> {
445        let (_engine, mut pool, mut fb, mut zb) = render_setup(CHUNK_SIZE_XY);
446        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
447        let outcome = render_scene(
448            &mut fb,
449            &mut zb,
450            XRES as usize,
451            XRES,
452            YRES,
453            &mut pool,
454            scene,
455            camera,
456            &settings,
457            None,
458        );
459        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
460        fb
461    }
462
463    /// Render the same chunk as a direct opticast call, with the
464    /// camera already in grid-local frame. The reference output
465    /// for the round-trip test.
466    fn render_via_direct_opticast(scene: &Scene, local_camera: &Camera) -> Vec<u32> {
467        let (_engine, mut pool, mut fb, mut zb) = render_setup(CHUNK_SIZE_XY);
468        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
469        let grid = scene.grids().next().unwrap().1;
470        let chunk = grid.chunk(IVec3::ZERO).unwrap();
471        let grid_view = roxlap_core::GridView::from_single_vxl(chunk);
472        let mut rasterizer = ScalarRasterizer::new(&mut fb, &mut zb, XRES as usize, grid_view);
473        let _ = core_opticast(
474            &mut rasterizer,
475            &mut pool,
476            local_camera,
477            &settings,
478            grid_view,
479        );
480        drop(rasterizer);
481        fb
482    }
483
484    // ---- S5.0: world_camera_to_grid_local helper ----
485
486    /// Identity rotation: pos translates by `-origin`; basis is
487    /// untouched. This is the byte-identical-to-pre-S5 contract.
488    #[test]
489    fn world_camera_to_grid_local_identity_rotation_translates_pos_only() {
490        let camera = Camera {
491            pos: [110.0, 220.0, 330.0],
492            right: [1.0, 0.0, 0.0],
493            down: [0.0, 0.0, 1.0],
494            forward: [0.0, 1.0, 0.0],
495        };
496        let transform = GridTransform::at(DVec3::new(100.0, 200.0, 300.0));
497        let local = super::world_camera_to_grid_local(&camera, &transform);
498        // Basis must be bit-for-bit unchanged for the identity case.
499        assert_eq!(local.right, camera.right);
500        assert_eq!(local.down, camera.down);
501        assert_eq!(local.forward, camera.forward);
502        // Pos translates by `-origin`.
503        for (got, want) in local.pos.iter().zip([10.0, 20.0, 30.0].iter()) {
504            assert!((got - want).abs() < 1e-12, "pos got={got} want={want}");
505        }
506    }
507
508    /// 90° rotation about +Z: grid-local `+x` aligns with world `+y`.
509    /// World camera at `(0, 10, 0)` looking world `+y` lives in
510    /// grid-local at `(10, 0, 0)` looking grid-local `+x`.
511    #[test]
512    fn world_camera_to_grid_local_90deg_z_rotates_basis_and_pos() {
513        use glam::DQuat;
514        let camera = Camera {
515            pos: [0.0, 10.0, 0.0],
516            right: [1.0, 0.0, 0.0],
517            down: [0.0, 0.0, 1.0],
518            forward: [0.0, 1.0, 0.0],
519        };
520        let transform = GridTransform {
521            origin: DVec3::ZERO,
522            rotation: DQuat::from_rotation_z(std::f64::consts::FRAC_PI_2),
523        };
524        let local = super::world_camera_to_grid_local(&camera, &transform);
525        // World +y == grid-local +x.
526        let approx_eq =
527            |a: [f64; 3], b: [f64; 3]| a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-9);
528        assert!(
529            approx_eq(local.pos, [10.0, 0.0, 0.0]),
530            "pos={:?} expected ~(10, 0, 0)",
531            local.pos
532        );
533        // World +x (right) maps to grid-local -y.
534        assert!(
535            approx_eq(local.right, [0.0, -1.0, 0.0]),
536            "right={:?} expected ~(0, -1, 0)",
537            local.right
538        );
539        // World +z (down) is unchanged — it's the rotation axis.
540        assert!(
541            approx_eq(local.down, [0.0, 0.0, 1.0]),
542            "down={:?} expected ~(0, 0, 1)",
543            local.down
544        );
545        // World +y (forward) maps to grid-local +x.
546        assert!(
547            approx_eq(local.forward, [1.0, 0.0, 0.0]),
548            "forward={:?} expected ~(1, 0, 0)",
549            local.forward
550        );
551    }
552
553    /// Basis orthonormality + handedness both survive the
554    /// inverse-rotation transform. Property: any unit-quaternion
555    /// conjugation preserves the input basis's orthonormality AND
556    /// its handedness (rotations are orientation-preserving).
557    #[test]
558    fn world_camera_to_grid_local_preserves_basis_orthonormality() {
559        use glam::DQuat;
560        // Right-handed voxlap basis (`right × down == forward`):
561        // looking +y, right = -x makes the cross product land on +y.
562        let camera = Camera {
563            pos: [3.0, -5.0, 7.0],
564            right: [-1.0, 0.0, 0.0],
565            down: [0.0, 0.0, 1.0],
566            forward: [0.0, 1.0, 0.0],
567        };
568        let transform = GridTransform {
569            origin: DVec3::new(1.0, 2.0, 3.0),
570            rotation: DQuat::from_axis_angle(glam::DVec3::new(0.3, 0.8, 0.5).normalize(), 0.7),
571        };
572        let local = super::world_camera_to_grid_local(&camera, &transform);
573        let r = DVec3::from_array(local.right);
574        let d = DVec3::from_array(local.down);
575        let f = DVec3::from_array(local.forward);
576        // Norms ≈ 1.
577        for v in [r, d, f] {
578            assert!(
579                (v.length_squared() - 1.0).abs() < 1e-12,
580                "basis vec {v:?} not unit length"
581            );
582        }
583        // Orthogonality.
584        assert!(r.dot(d).abs() < 1e-12, "right·down = {}", r.dot(d));
585        assert!(r.dot(f).abs() < 1e-12, "right·forward = {}", r.dot(f));
586        assert!(d.dot(f).abs() < 1e-12, "down·forward = {}", d.dot(f));
587        // Right-handed: right × down == forward (voxlap convention).
588        let cross = r.cross(d);
589        assert!(
590            (cross - f).length() < 1e-12,
591            "right×down={cross:?} forward={f:?}"
592        );
593    }
594
595    // ---- S5.1: rotated-grid render correctness ----
596
597    /// Build a single-grid scene at the given transform with a
598    /// marker box near one corner of chunk (0, 0, 0). Returns the
599    /// scene and the marker colour. Picking a single chunk + small
600    /// box keeps the test compact while still exercising the gline
601    /// + grouscan path through the rotated frame.
602    fn build_one_grid_marker_scene(transform: GridTransform) -> (Scene, crate::GridId, u32) {
603        let mut scene = Scene::new();
604        let id = scene.add_grid(transform);
605        let grid = scene.grid_mut(id).unwrap();
606        // Bright marker box at chunk-local (40..56, 40..56, 40..56).
607        grid.set_rect(
608            IVec3::new(40, 40, 40),
609            IVec3::new(55, 55, 55),
610            Some(0x80_55_aa_22), // distinctive green
611        );
612        (scene, id, 0x80_55_aa_22)
613    }
614
615    /// Pin S5.1's central equivalence: rotating both the grid and the
616    /// camera by the SAME rotation around the grid's origin must
617    /// leave the rendered framebuffer unchanged — the grid-local
618    /// camera pose collapses to the same values in both scenarios.
619    ///
620    /// We use `DQuat::from_xyzw(0.0, 0.0, 1.0, 0.0)`, the
621    /// 180°-around-Z unit quaternion. This rotation acts on vectors
622    /// as `(x, y, z) → (-x, -y, z)`, which only multiplies f64
623    /// components by 0 or ±1 — bit-exact under glam's standard quat
624    /// conjugation formula. Other angles (e.g. 90°) would introduce
625    /// sub-1e-15 noise from sin/cos, breaking byte-identity at
626    /// chunk / voxel boundaries.
627    #[test]
628    fn s5_1_180deg_z_rotated_grid_byte_identical_to_axis_aligned() {
629        use glam::DQuat;
630        // Right-handed voxlap basis (right × down == forward).
631        let axis_aligned_camera = Camera {
632            pos: [40.0, -20.0, 50.0],
633            right: [-1.0, 0.0, 0.0],
634            down: [0.0, 0.0, 1.0],
635            forward: [0.0, 1.0, 0.0],
636        };
637        // R_z(180°): (x, y, z) → (-x, -y, z).
638        let rotated_camera = Camera {
639            pos: [-40.0, 20.0, 50.0],
640            right: [1.0, 0.0, 0.0],
641            down: [0.0, 0.0, 1.0],
642            forward: [0.0, -1.0, 0.0],
643        };
644        // Sanity: prove the exact-arithmetic rotation lands on the
645        // baseline. If glam ever changes its quat*vec formula in a
646        // way that loses exactness here, the next two assertions
647        // catch it before the framebuffer comparison.
648        let q = DQuat::from_xyzw(0.0, 0.0, 1.0, 0.0);
649        let rot_pos = q * DVec3::from_array(axis_aligned_camera.pos);
650        let rot_fwd = q * DVec3::from_array(axis_aligned_camera.forward);
651        assert_eq!(rot_pos.to_array(), rotated_camera.pos);
652        assert_eq!(rot_fwd.to_array(), rotated_camera.forward);
653
654        let (mut scene_a, _, _) = build_one_grid_marker_scene(GridTransform::identity());
655        let fb_a = render_via_scene(&mut scene_a, &axis_aligned_camera);
656
657        let (mut scene_b, _, _) = build_one_grid_marker_scene(GridTransform {
658            origin: DVec3::ZERO,
659            rotation: q,
660        });
661        let fb_b = render_via_scene(&mut scene_b, &rotated_camera);
662
663        assert_eq!(
664            fb_a, fb_b,
665            "rotating both grid and camera by R about the grid origin must leave the framebuffer unchanged"
666        );
667    }
668
669    /// 45° smoke test: rotated grid renders to something non-trivial
670    /// without panicking. No equivalence assertion (45° quat math is
671    /// approximate at f64 level; that path is exercised structurally,
672    /// not bit-exactly). Camera is placed at a fixed world pose where
673    /// — under the rotation — the marker box stays inside the view
674    /// frustum.
675    #[test]
676    fn s5_1_45deg_z_rotated_grid_renders_marker() {
677        use glam::DQuat;
678        let rotation = DQuat::from_rotation_z(std::f64::consts::FRAC_PI_4);
679        let (mut scene, _, marker) = build_one_grid_marker_scene(GridTransform {
680            origin: DVec3::ZERO,
681            rotation,
682        });
683
684        // World position of the marker's centre. Grid-local
685        // (47.5, 47.5, 47.5) → world `rotation * (47.5, 47.5, 47.5)`.
686        // R_z(45°): (47.5, 47.5, 47.5) → (0, 67.18, 47.5) (the x/y
687        // components combine into a single +y vector at √2 * 47.5).
688        let marker_world = rotation * DVec3::new(47.5, 47.5, 47.5);
689        // Camera 80 units south of the marker on the world Y axis,
690        // looking +y at the same z. RH basis.
691        let camera = Camera {
692            pos: [marker_world.x, marker_world.y - 80.0, marker_world.z],
693            right: [-1.0, 0.0, 0.0],
694            down: [0.0, 0.0, 1.0],
695            forward: [0.0, 1.0, 0.0],
696        };
697
698        let (_engine, mut pool, mut fb, mut zb) = render_setup(CHUNK_SIZE_XY);
699        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
700        let outcome = render_scene(
701            &mut fb,
702            &mut zb,
703            XRES as usize,
704            XRES,
705            YRES,
706            &mut pool,
707            &mut scene,
708            &camera,
709            &settings,
710            None,
711        );
712        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
713        let marker_count = fb.iter().filter(|&&p| p == marker).count();
714        assert!(
715            marker_count > 50,
716            "45°-rotated marker box should be visible — got {marker_count} marker pixels"
717        );
718    }
719
720    // ---- S5.2-followup: per-grid render_sky opt-out ----
721
722    /// Two-grid scene where grid B sits behind grid A along +y;
723    /// grid A is opaque only in the centre of the framebuffer, so
724    /// the camera's view through grid A is mostly "ray miss". When
725    /// `A.render_sky = false`, the pixels around A's silhouette
726    /// must remain whatever grid B (or the shared pre-fill)
727    /// painted — NOT A's grid-local sky colour. This pins the
728    /// sentinel-mask path: without it, A's sky would write into
729    /// the composed framebuffer wherever its sky-z happened to win
730    /// the min-z race with B's sky-z.
731    #[test]
732    fn render_sky_false_drops_grid_sky_pixels() {
733        use crate::{GridId, GridTransform};
734
735        // Grid B (far, sky owner) — a wide floor of distinct
736        // colour spanning chunk-local x/y so most rays land on it.
737        let mut scene = Scene::new();
738        let _b_id: GridId = scene.add_grid(GridTransform::at(DVec3::new(0.0, 600.0, 0.0)));
739        // Find grid B's id (HashMap iteration; we only just added
740        // one grid, so its id is whichever the iterator yields).
741        let b_id = scene.grids().next().unwrap().0;
742        scene.grid_mut(b_id).unwrap().set_rect(
743            IVec3::new(0, 0, 100),
744            IVec3::new(127, 127, 110),
745            Some(0x80_22_88_22), // green floor
746        );
747
748        // Grid A (near, sky disabled) — a SMALL marker box that
749        // covers only a fraction of the screen. Most pixels of A's
750        // local render are sky.
751        let a_id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
752        scene.grid_mut(a_id).unwrap().set_rect(
753            IVec3::new(60, 60, 60),
754            IVec3::new(67, 67, 67),
755            Some(0x80_aa_22_22), // red cube
756        );
757        scene.grid_mut(a_id).unwrap().render_sky = false;
758
759        let unique_sky: u32 = 0xFF_AB_CD_EF;
760        let (_engine, mut pool, _) = make_composed_pool(CHUNK_SIZE_XY);
761        let mut fb = vec![unique_sky; pixel_count(XRES, YRES)];
762        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
763        let camera = camera_at([64.0, 0.0, 100.0]);
764        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
765        let outcome = render_scene_composed(
766            &mut fb,
767            &mut zb,
768            XRES as usize,
769            XRES,
770            YRES,
771            &mut pool,
772            &mut scene,
773            &camera,
774            &settings,
775            unique_sky,
776            None,
777        );
778        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
779
780        // The sentinel must never appear in the composed output —
781        // every sentinel pixel must have been masked out before
782        // compose. If any leak through, the test catches it.
783        let leaked = fb
784            .iter()
785            .filter(|&&p| p == super::SKY_MASK_SENTINEL)
786            .count();
787        assert_eq!(
788            leaked, 0,
789            "SKY_MASK_SENTINEL leaked into composed framebuffer ({leaked} pixels)"
790        );
791        // Grid A's hit (red cube) must still render — render_sky=false
792        // only affects sky pixels, not hits.
793        let red_count = fb.iter().filter(|&&p| p == 0x80_aa_22_22).count();
794        assert!(
795            red_count > 0,
796            "red cube from sky-disabled grid A is missing — render_sky=false should only mask sky"
797        );
798        // Grid B's floor must be visible past grid A's silhouette
799        // (the sky-disabled grid doesn't hide B's render).
800        let green_count = fb.iter().filter(|&&p| p == 0x80_22_88_22).count();
801        assert!(
802            green_count > 0,
803            "grid B's floor invisible — grid A's masked sky may have overwritten it"
804        );
805    }
806
807    /// Identity-rotation, single-grid scene with `render_sky = false`
808    /// must produce a sentinel-free framebuffer. Sanity test for the
809    /// trivial 1-grid case (no second grid to compose against).
810    #[test]
811    fn render_sky_false_single_grid_no_sentinel_leak() {
812        let (mut scene, id, _) = build_one_grid_marker_scene(GridTransform::identity());
813        scene.grid_mut(id).unwrap().render_sky = false;
814        let unique_sky: u32 = 0xFF_12_34_56;
815        let (_engine, mut pool, _) = make_composed_pool(CHUNK_SIZE_XY);
816        let mut fb = vec![unique_sky; pixel_count(XRES, YRES)];
817        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
818        let camera = camera_at([64.0, 0.0, 64.0]);
819        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
820        let outcome = render_scene_composed(
821            &mut fb,
822            &mut zb,
823            XRES as usize,
824            XRES,
825            YRES,
826            &mut pool,
827            &mut scene,
828            &camera,
829            &settings,
830            unique_sky,
831            None,
832        );
833        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
834        let leaked = fb
835            .iter()
836            .filter(|&&p| p == super::SKY_MASK_SENTINEL)
837            .count();
838        assert_eq!(leaked, 0, "SKY_MASK_SENTINEL leaked ({leaked} pixels)");
839        // Pixels that would have been the grid's sky now show
840        // through to the pre-fill (unique_sky).
841        let prefill_count = fb.iter().filter(|&&p| p == unique_sky).count();
842        assert!(
843            prefill_count > 0,
844            "no pre-fill pixels survived — render_sky=false should leave non-hit pixels untouched"
845        );
846    }
847
848    #[test]
849    fn render_scene_at_origin_matches_direct_opticast() {
850        // Grid at world origin → grid-local camera == world camera.
851        // Single 1-chunk grid: combined view's bytes are byte-identical
852        // to the chunk's own column data (slng-walk equivalence), so
853        // render_scene must produce the same framebuffer as a direct
854        // opticast on the chunk.
855        let (mut scene, _) = build_one_grid_scene(DVec3::ZERO);
856        let cam = camera_at([64.0, 0.0, 64.0]);
857        let via_scene = render_via_scene(&mut scene, &cam);
858        let via_direct = render_via_direct_opticast(&scene, &cam);
859        assert_eq!(
860            via_scene, via_direct,
861            "render_scene with single 1-chunk grid at origin should match direct opticast"
862        );
863    }
864
865    #[test]
866    fn render_scene_translated_grid_matches_grid_local_opticast() {
867        // Grid at world (1000, 2000, 3000). World camera at
868        // (1064, 2000, 3064) — grid-local (64, 0, 64). render_scene
869        // should produce the same output as a direct opticast call
870        // with grid-local camera.
871        let world_origin = DVec3::new(1000.0, 2000.0, 3000.0);
872        let (mut scene, _) = build_one_grid_scene(world_origin);
873        let world_cam = camera_at([1064.0, 2000.0, 3064.0]);
874        let local_cam = camera_at([64.0, 0.0, 64.0]);
875        let via_scene = render_via_scene(&mut scene, &world_cam);
876        let via_direct = render_via_direct_opticast(&scene, &local_cam);
877        assert_eq!(
878            via_scene, via_direct,
879            "render_scene of translated grid should match opticast with grid-local camera"
880        );
881    }
882
883    #[test]
884    fn empty_scene_returns_empty_outcome() {
885        let mut scene = Scene::new();
886        let (_engine, mut pool, mut fb, mut zb) = render_setup(CHUNK_SIZE_XY);
887        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
888        let outcome = render_scene(
889            &mut fb,
890            &mut zb,
891            XRES as usize,
892            XRES,
893            YRES,
894            &mut pool,
895            &mut scene,
896            &camera_at([0.0, 0.0, 0.0]),
897            &settings,
898            None,
899        );
900        assert_eq!(outcome, RenderOutcome::Empty);
901    }
902
903    // ---- S3.1 / S4.0: render_scene_composed + 2-grid composition ----
904
905    /// Build a 2-grid scene with two distinguishable boxes placed
906    /// side-by-side in world space along the camera's right axis.
907    /// Each grid holds one chunk (`(0, 0, 0)`) containing a single
908    /// 16-voxel box with a uniquely-coloured surface so the
909    /// composited framebuffer is partitionable by colour.
910    fn build_two_grid_side_by_side() -> (Scene, u32, u32) {
911        let mut scene = Scene::new();
912        // Grid 0 at world (0, 200, 0): box centred chunk-local (64, 64, 100).
913        let g0 = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
914        scene.grid_mut(g0).unwrap().set_rect(
915            IVec3::new(56, 56, 92),
916            IVec3::new(71, 71, 107),
917            Some(0x80_88_22_22), // dark red
918        );
919        // Grid 1 at world (200, 200, 0): box centred chunk-local (64, 64, 100).
920        let _g1 = scene.add_grid(GridTransform::at(DVec3::new(200.0, 200.0, 0.0)));
921        // Borrow-checker dance: re-borrow grid 1 mutably.
922        let g1_id = scene
923            .grids()
924            .filter(|(id, _)| *id != g0)
925            .map(|(id, _)| id)
926            .next()
927            .unwrap();
928        scene.grid_mut(g1_id).unwrap().set_rect(
929            IVec3::new(56, 56, 92),
930            IVec3::new(71, 71, 107),
931            Some(0x80_22_22_88), // dark blue
932        );
933        (scene, 0x80_88_22_22, 0x80_22_22_88)
934    }
935
936    fn make_composed_pool(pool_vsid: u32) -> (Engine, ScratchPool, u32) {
937        let engine = Engine::new();
938        let mut pool = ScratchPool::new(XRES, YRES, pool_vsid);
939        let sky_color = engine.sky_color();
940        let sky_col_i = i32::from_ne_bytes(sky_color.to_ne_bytes());
941        pool.set_skycast(sky_col_i, 0);
942        let fog_col_i = i32::from_ne_bytes(engine.fog_color().to_ne_bytes());
943        pool.set_fog(fog_col_i, engine.fog_max_scan_dist());
944        pool.set_treat_z_max_as_air(true);
945        (engine, pool, sky_color)
946    }
947
948    fn pixel_count(width: u32, height: u32) -> usize {
949        (width as usize) * (height as usize)
950    }
951
952    #[test]
953    fn compose_into_takes_smaller_z() {
954        let mut shared_fb = vec![0xff_ff_ff_ff_u32; 4];
955        let mut shared_zb = vec![10.0f32; 4];
956        let temp_fb = [0xaa_aa_aa_aa, 0x11_22_33_44, 0x55_66_77_88, 0xde_ad_be_ef];
957        let temp_zb = [5.0f32, 20.0, 10.0, f32::INFINITY];
958        compose_into(&mut shared_fb, &mut shared_zb, &temp_fb, &temp_zb);
959        // i=0: 5 < 10 → take temp.
960        assert_eq!(shared_fb[0], 0xaa_aa_aa_aa);
961        assert_eq!(shared_zb[0], 5.0);
962        // i=1: 20 > 10 → keep shared.
963        assert_eq!(shared_fb[1], 0xff_ff_ff_ff);
964        assert_eq!(shared_zb[1], 10.0);
965        // i=2: 10 == 10 → keep shared (`<` not `<=`).
966        assert_eq!(shared_fb[2], 0xff_ff_ff_ff);
967        // i=3: INFINITY > 10 → keep shared.
968        assert_eq!(shared_fb[3], 0xff_ff_ff_ff);
969    }
970
971    #[test]
972    fn render_scene_composed_two_grids_both_visible() {
973        // Camera positioned to see both grids' boxes. Grid 0's box
974        // at world (~64, ~264, ~100); grid 1's box at world
975        // (~264, ~264, ~100). Camera at world (160, 100, 100)
976        // looking +y centres both in view.
977        let (mut scene, red, blue) = build_two_grid_side_by_side();
978        let (_engine, mut pool, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
979        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
980        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
981
982        let camera = camera_at([160.0, 100.0, 100.0]);
983        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
984        let outcome = render_scene_composed(
985            &mut fb,
986            &mut zb,
987            XRES as usize,
988            XRES,
989            YRES,
990            &mut pool,
991            &mut scene,
992            &camera,
993            &settings,
994            sky_color,
995            None,
996        );
997        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
998
999        // Both colours should appear somewhere in the framebuffer.
1000        let red_count = fb.iter().filter(|&&p| p == red).count();
1001        let blue_count = fb.iter().filter(|&&p| p == blue).count();
1002        assert!(
1003            red_count > 0,
1004            "no red pixels: grid 0 (red box) not visible after compose"
1005        );
1006        assert!(
1007            blue_count > 0,
1008            "no blue pixels: grid 1 (blue box) not visible after compose"
1009        );
1010    }
1011
1012    #[test]
1013    fn render_scene_composed_grid_a_in_front_of_grid_b() {
1014        // Two grids stacked along +y so grid A (closer) occludes
1015        // grid B (farther). After composition only grid A's colour
1016        // should appear on the overlap.
1017        let mut scene = Scene::new();
1018        let g_a = scene.add_grid(GridTransform::at(DVec3::new(0.0, 50.0, 0.0)));
1019        scene.grid_mut(g_a).unwrap().set_rect(
1020            IVec3::new(56, 56, 92),
1021            IVec3::new(71, 71, 107),
1022            Some(0x80_aa_00_00), // red
1023        );
1024        let _g_b = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
1025        let g_b_id = scene
1026            .grids()
1027            .filter(|(id, _)| *id != g_a)
1028            .map(|(id, _)| id)
1029            .next()
1030            .unwrap();
1031        scene.grid_mut(g_b_id).unwrap().set_rect(
1032            IVec3::new(56, 56, 92),
1033            IVec3::new(71, 71, 107),
1034            Some(0x80_00_00_aa), // blue
1035        );
1036
1037        let (_engine, mut pool, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1038        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1039        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1040
1041        // Camera at (64, -10, 100) looking +y — both boxes line up
1042        // along the camera's forward axis.
1043        let camera = camera_at([64.0, -10.0, 100.0]);
1044        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1045        let outcome = render_scene_composed(
1046            &mut fb,
1047            &mut zb,
1048            XRES as usize,
1049            XRES,
1050            YRES,
1051            &mut pool,
1052            &mut scene,
1053            &camera,
1054            &settings,
1055            sky_color,
1056            None,
1057        );
1058        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
1059
1060        // Red (closer grid) should be visible. Blue (farther grid)
1061        // may peek around the edges but the central pixels should
1062        // be red where both boxes project.
1063        let red_count = fb.iter().filter(|&&p| p == 0x80_aa_00_00).count();
1064        assert!(
1065            red_count > 0,
1066            "expected red pixels (closer box should win z-test)"
1067        );
1068
1069        // Reverse the registration order (force grid B drawn first)
1070        // and verify that's irrelevant — composition is commutative.
1071        let mut scene2 = Scene::new();
1072        let g_b2 = scene2.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
1073        scene2.grid_mut(g_b2).unwrap().set_rect(
1074            IVec3::new(56, 56, 92),
1075            IVec3::new(71, 71, 107),
1076            Some(0x80_00_00_aa),
1077        );
1078        let g_a2 = scene2.add_grid(GridTransform::at(DVec3::new(0.0, 50.0, 0.0)));
1079        scene2.grid_mut(g_a2).unwrap().set_rect(
1080            IVec3::new(56, 56, 92),
1081            IVec3::new(71, 71, 107),
1082            Some(0x80_aa_00_00),
1083        );
1084
1085        let mut fb2 = vec![sky_color; pixel_count(XRES, YRES)];
1086        let mut zb2 = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1087        let outcome2 = render_scene_composed(
1088            &mut fb2,
1089            &mut zb2,
1090            XRES as usize,
1091            XRES,
1092            YRES,
1093            &mut pool,
1094            &mut scene2,
1095            &camera,
1096            &settings,
1097            sky_color,
1098            None,
1099        );
1100        assert_eq!(outcome2, RenderOutcome::Rendered { grids_drawn: 2 });
1101        assert_eq!(
1102            fb, fb2,
1103            "composition should be order-independent — same scene in different add order should produce identical output"
1104        );
1105    }
1106
1107    #[test]
1108    fn render_scene_composed_empty_scene_returns_empty() {
1109        let mut scene = Scene::new();
1110        let (_engine, mut pool, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1111        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1112        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1113        let camera = camera_at([0.0, 0.0, 0.0]);
1114        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1115        let outcome = render_scene_composed(
1116            &mut fb,
1117            &mut zb,
1118            XRES as usize,
1119            XRES,
1120            YRES,
1121            &mut pool,
1122            &mut scene,
1123            &camera,
1124            &settings,
1125            sky_color,
1126            None,
1127        );
1128        assert_eq!(outcome, RenderOutcome::Empty);
1129        // fb should be unchanged (still all sky).
1130        assert!(fb.iter().all(|&p| p == sky_color));
1131    }
1132
1133    /// FNV-1a 64-bit hash. Same offset/prime as the
1134    /// `roxlap-oracle::fnv1a64` helper used by the wasm-render
1135    /// goldens; pinning a render hash here is the same flavour of
1136    /// regression catch.
1137    fn fnv1a64(data: &[u8]) -> u64 {
1138        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
1139        for &b in data {
1140            h ^= u64::from(b);
1141            h = h.wrapping_mul(0x0000_0100_0000_01b3);
1142        }
1143        h
1144    }
1145
1146    // ---- S4.0 cross-chunk smoke test ----
1147
1148    /// Two-chunk-wide grid: a recognisable shape spans the chunk
1149    /// boundary at `virtual_x = 128`. The render must not have a
1150    /// horizontal seam line at the boundary.
1151    #[test]
1152    fn render_scene_two_chunk_x_grid_no_seam() {
1153        let mut scene = Scene::new();
1154        let id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
1155        let g = scene.grid_mut(id).unwrap();
1156        // 100-voxel-tall stripe spanning x=[120..136] across the
1157        // x=128 chunk seam at z=200, y=[60..68]. After bake-free
1158        // render, every column in the stripe paints the same colour
1159        // at the same z; a seam at x=128 would show as missing
1160        // pixels in the column at virtual_x=128 / 129 / ...
1161        g.set_rect(
1162            IVec3::new(120, 60, 200),
1163            IVec3::new(136, 67, 215),
1164            Some(0x80_aa_55_22),
1165        );
1166        // Sanity: ensure both chunks were materialised.
1167        assert_eq!(g.chunk_count(), 2);
1168
1169        // Render with a camera positioned to look at the stripe
1170        // straight on. Stripe at world (120..136, 260..268, 200..215).
1171        // Camera at (128, 100, 207) looking +y centres on it.
1172        let (_engine, mut pool, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
1173        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1174        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1175        let camera = camera_at([128.0, 100.0, 207.0]);
1176        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1177        let outcome = render_scene_composed(
1178            &mut fb,
1179            &mut zb,
1180            XRES as usize,
1181            XRES,
1182            YRES,
1183            &mut pool,
1184            &mut scene,
1185            &camera,
1186            &settings,
1187            sky_color,
1188            None,
1189        );
1190        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1191
1192        // Stripe colour should appear in roughly the centre of the
1193        // framebuffer. A chunk-edge seam would manifest as a thin
1194        // sky-coloured vertical line splitting the stripe in two.
1195        let stripe = 0x80_aa_55_22;
1196        let stripe_count = fb.iter().filter(|&&p| p == stripe).count();
1197        assert!(
1198            stripe_count > 200,
1199            "stripe rendered too few pixels ({stripe_count}) — chunks may not be stitching"
1200        );
1201
1202        // Walk the centre row left-to-right looking for a sky-pixel
1203        // gap inside a stripe run. A gap 1+ pixels wide flags a
1204        // chunk-edge seam.
1205        let centre_y = (YRES / 2) as usize;
1206        let row_start = centre_y * (XRES as usize);
1207        let row = &fb[row_start..row_start + (XRES as usize)];
1208        let mut in_stripe = false;
1209        let mut seam_gaps = 0usize;
1210        for &px in row {
1211            if px == stripe {
1212                in_stripe = true;
1213            } else if in_stripe && px == sky_color {
1214                // Stripe ended; if we re-enter it on this row that's
1215                // a seam.
1216                if row.iter().skip_while(|&&p| p != px).any(|&p| p == stripe) {
1217                    // Look ahead for any further stripe pixel.
1218                    seam_gaps += 1;
1219                }
1220                in_stripe = false;
1221            }
1222        }
1223        // We allow seam_gaps to count the legitimate "stripe ended,
1224        // didn't restart" transition once; more than that means
1225        // multiple disjoint runs on the row → seam.
1226        assert!(
1227            seam_gaps <= 1,
1228            "centre row has {seam_gaps} disjoint stripe runs — expected 1 (chunk-edge seam suspected)"
1229        );
1230    }
1231
1232    /// Pin the byte-exact FNV-1a64 of a 2-chunk render. Catches
1233    /// any drift in the cross-chunk stitch / opticast path.
1234    /// S4B.5: regression test for the cf-halving + column-step
1235    /// interaction at chunk-vsid (=128) under multi-mip rendering.
1236    /// Prior to the fix in `grouscan.rs:phase_after_delete_kept_presync`
1237    /// the single-chunk column-step recomputed `ixy_sptr_col_idx`
1238    /// from `cy * vsid + cx`, mapping the post-remiporend mip-N
1239    /// sub-table index back into mip-0's range. The next mip
1240    /// transition then underflowed at
1241    /// `state.ixy_sptr_col_idx - mip_base_offsets[old_mip]`.
1242    ///
1243    /// `mip_scan_dist=32` chosen so the 3-mip depth ladder
1244    /// (32→64→128 PREC scan budgets) reaches the floor 36 voxels
1245    /// away at mip-1 or mip-2 — exercising the post-transition
1246    /// rendering path that was broken pre-fix.
1247    #[test]
1248    fn vxl_generate_mips_on_set_voxel_chunk_renders() {
1249        let mut grid = crate::Grid::new(GridTransform::identity());
1250        // Solid floor at z=100..254 across the entire chunk —
1251        // looks like the oracle test's terrain.
1252        grid.set_rect(
1253            IVec3::new(0, 0, 100),
1254            IVec3::new(127, 127, 254),
1255            Some(0x80_88_88_88),
1256        );
1257        let chunk = grid.chunks.get_mut(&IVec3::ZERO).unwrap();
1258        chunk.generate_mips(3);
1259        let (_engine, mut pool, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1260        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1261        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1262        let camera = camera_at([64.0, 0.0, 64.0]);
1263        let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1264        settings.mip_levels = 3;
1265        settings.mip_scan_dist = 32;
1266        let grid_view = roxlap_core::GridView::from_single_vxl(&chunk);
1267        let mut rasterizer = ScalarRasterizer::new(&mut fb, &mut zb, XRES as usize, grid_view);
1268        let _ = core_opticast(&mut rasterizer, &mut pool, &camera, &settings, grid_view);
1269        drop(rasterizer);
1270        let non_sky = fb.iter().filter(|&&p| p != sky_color).count();
1271        assert!(
1272            non_sky > 0,
1273            "Vxl::generate_mips on a set_voxel-built chunk should render to something non-sky (got {non_sky})"
1274        );
1275    }
1276
1277    /// Mip-0 preservation when mips are generated on the combined
1278    /// view but `mip_levels = 1` in the rasterizer's settings.
1279    /// Confirms `generate_mips` only APPENDS data — mip-0
1280    /// prefix is unchanged.
1281    #[test]
1282    fn render_with_mips_present_still_renders_mip0() {
1283        let mut scene = Scene::new();
1284        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
1285        scene.grid_mut(id).unwrap().set_rect(
1286            IVec3::new(40, 40, 40),
1287            IVec3::new(55, 55, 55),
1288            Some(0x80_88_88_88),
1289        );
1290        // S4B.4.a: force mip-1..mip-2 generation on the single
1291        // chunk directly (the Grid's combined-view cache API was
1292        // removed). The chunk's own Vxl::generate_mips builds its
1293        // own mip tables and the renderer happens to render through
1294        // them via Approach B's chunk_at_xy lookup.
1295        {
1296            let grid = scene.grid_mut(id).unwrap();
1297            let chunk = grid.chunks.get_mut(&IVec3::ZERO).unwrap();
1298            chunk.generate_mips(3);
1299        }
1300
1301        let (_engine, mut pool, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1302        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1303        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1304        let camera = camera_at([64.0, 0.0, 64.0]);
1305        // mip_scan_dist huge → renderer never transitions past mip-0
1306        // so this test pins mip-0 correctness only.
1307        let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1308        settings.mip_scan_dist = 100_000;
1309        let outcome = render_scene_composed(
1310            &mut fb,
1311            &mut zb,
1312            XRES as usize,
1313            XRES,
1314            YRES,
1315            &mut pool,
1316            &mut scene,
1317            &camera,
1318            &settings,
1319            sky_color,
1320            None,
1321        );
1322        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1323        let non_sky = fb.iter().filter(|&&p| p != sky_color).count();
1324        assert!(
1325            non_sky > 0,
1326            "render of single-grid scene with mips present rendered all-sky: mip-0 may be corrupted by generate_mips"
1327        );
1328    }
1329
1330    #[test]
1331    fn render_scene_two_chunk_x_grid_hash_is_stable() {
1332        // Frozen 2026-05-10 at S4.0 landing on x86_64.
1333        const GOLDEN: u64 = 0x215e_d66d_7359_4725;
1334        // Same scene shape as `render_scene_two_chunk_x_grid_no_seam`
1335        // — kept distinct so the hash assertion doesn't share its
1336        // setup with the structural seam check.
1337        let mut scene = Scene::new();
1338        let id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
1339        scene.grid_mut(id).unwrap().set_rect(
1340            IVec3::new(120, 60, 200),
1341            IVec3::new(136, 67, 215),
1342            Some(0x80_aa_55_22),
1343        );
1344        let (_engine, mut pool, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
1345        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1346        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1347        let camera = camera_at([128.0, 100.0, 207.0]);
1348        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1349        let outcome = render_scene_composed(
1350            &mut fb,
1351            &mut zb,
1352            XRES as usize,
1353            XRES,
1354            YRES,
1355            &mut pool,
1356            &mut scene,
1357            &camera,
1358            &settings,
1359            sky_color,
1360            None,
1361        );
1362        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1363
1364        let bytes: Vec<u8> = fb.iter().flat_map(|p| p.to_ne_bytes()).collect();
1365        let hash = fnv1a64(&bytes);
1366        if GOLDEN == SENTINEL {
1367            // First-run capture mode — print the hash so the
1368            // developer can paste it into GOLDEN above.
1369            eprintln!("render_scene_two_chunk_x_grid_hash_is_stable: capture hash = 0x{hash:016x}");
1370            panic!("GOLDEN is the SENTINEL placeholder — paste 0x{hash:016x} into GOLDEN above");
1371        }
1372        assert_eq!(
1373            hash, GOLDEN,
1374            "2-chunk render hash drifted: expected 0x{GOLDEN:016x}, got 0x{hash:016x}"
1375        );
1376    }
1377
1378    /// Sentinel for first-run hash capture in
1379    /// [`render_scene_two_chunk_x_grid_hash_is_stable`]. Replace
1380    /// `GOLDEN`'s definition with the printed value once captured.
1381    const SENTINEL: u64 = 0xDEAD_BEEF_DEAD_BEEF;
1382
1383    /// S4B.2.c.3: render a 2-chunk x-stripe scene via Approach B
1384    /// (multi-chunk GridView + direct opticast). Validates the full
1385    /// chain — `Grid::chunk_xy_backing` → `ChunkGrid` →
1386    /// `GridView::from_chunk_grid` → opticast prelude
1387    /// `recompute_camera_chunk` → `camera_chunk_air_gap` lookup →
1388    /// gline's chunk-aware seed → grouscan's chunk-swap column-step.
1389    ///
1390    /// Geometry: a floor in chunk (0, 0) at grid-local
1391    /// `(0..127, 0..127, 200..205)` plus a recognisable box in
1392    /// chunk (1, 0) at `(160..170, 50..60, 150..165)`. Camera in
1393    /// chunk (0, 0) looking +x so rays cross into chunk (1, 0) and
1394    /// must trigger the cross-chunk DDA. (Camera in chunk (1, 0) is
1395    /// blocked by the in_bounds_xy check that still uses the per-
1396    /// chunk vsid; full grid-AABB in_bounds gets revisited later.)
1397    ///
1398    /// Hash-pinned. Non-sky pixel count is the primary correctness
1399    /// signal — if the cross-chunk DDA were broken, only the floor
1400    /// in chunk (0, 0) would render.
1401    #[test]
1402    fn approach_b_renders_two_chunk_x_stripe_via_chunk_grid() {
1403        const SENTINEL_B: u64 = 0xDEAD_BEEF_DEAD_BEEF;
1404        // Frozen 2026-05-11 on x86_64 — Approach B's first
1405        // multi-chunk render (S4B.2.c.3). Refreeze when changing
1406        // the rasterizer; the cross-chunk DDA stays validated by
1407        // the `floor_count` + `box_count` assertions above.
1408        const GOLDEN_B: u64 = 0x5ee1_e81c_66a8_d1f1;
1409
1410        let mut scene = Scene::new();
1411        let id = scene.add_grid(GridTransform::identity());
1412        let g = scene.grid_mut(id).unwrap();
1413        // Floor across chunk (0, 0) so rays looking +x always have
1414        // a hit before they exit the grid AABB.
1415        g.set_rect(
1416            IVec3::new(0, 0, 200),
1417            IVec3::new(127, 127, 205),
1418            Some(0x80_44_44_aa),
1419        );
1420        // Recognisable box deep in chunk (1, 0) — only visible if
1421        // the cross-chunk DDA fires.
1422        g.set_rect(
1423            IVec3::new(160, 50, 150),
1424            IVec3::new(170, 60, 165),
1425            Some(0x80_aa_55_22),
1426        );
1427        assert_eq!(g.chunk_count(), 2);
1428
1429        // Build the multi-chunk GridView.
1430        let backing = g.chunk_xyz_backing().expect("at least one chunk populated");
1431        assert_eq!(backing.chunks_x, 2);
1432        assert_eq!(backing.chunks_y, 1);
1433        assert_eq!(backing.origin_chunk_xy, [0, 0]);
1434        let cg = roxlap_core::ChunkGrid {
1435            chunks: &backing.chunks,
1436            origin_chunk_xy: backing.origin_chunk_xy,
1437            origin_chunk_z: backing.origin_chunk_z,
1438            chunks_x: backing.chunks_x,
1439            chunks_y: backing.chunks_y,
1440            chunks_z: backing.chunks_z,
1441        };
1442        let grid_view = roxlap_core::GridView::from_chunk_grid(&cg, CHUNK_SIZE_XY);
1443
1444        // Camera in chunk (0, 0) looking +x toward chunk (1, 0).
1445        // Voxlap z-down basis: right × down == forward.
1446        let camera = Camera {
1447            pos: [10.0, 64.0, 160.0],
1448            right: [0.0, 1.0, 0.0],
1449            down: [0.0, 0.0, 1.0],
1450            forward: [1.0, 0.0, 0.0],
1451        };
1452        let (_engine, mut pool, mut fb, mut zb) = render_setup(2 * CHUNK_SIZE_XY);
1453        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1454        let mut rasterizer = ScalarRasterizer::new(&mut fb, &mut zb, XRES as usize, grid_view);
1455        let outcome = core_opticast(&mut rasterizer, &mut pool, &camera, &settings, grid_view);
1456        drop(rasterizer);
1457        assert_eq!(outcome, OpticastOutcome::Rendered);
1458
1459        // Hits BOTH the floor (chunk 0, 0) AND the box (chunk 1, 0).
1460        let floor_count = fb.iter().filter(|&&p| p == 0x80_44_44_aa).count();
1461        let box_count = fb.iter().filter(|&&p| p == 0x80_aa_55_22).count();
1462        assert!(
1463            floor_count > 1000,
1464            "floor not visible — only {floor_count} floor pixels (single-chunk path?)"
1465        );
1466        assert!(
1467            box_count > 50,
1468            "box in chunk (1, 0) not visible — only {box_count} box pixels — cross-chunk DDA may have failed to fire"
1469        );
1470
1471        // Hash-pin the output. Refreeze when changing the rasterizer.
1472        let bytes: Vec<u8> = fb.iter().flat_map(|p| p.to_ne_bytes()).collect();
1473        let hash = fnv1a64(&bytes);
1474        if GOLDEN_B == SENTINEL_B {
1475            eprintln!("approach_b_renders_two_chunk_x_stripe_via_chunk_grid: capture hash = 0x{hash:016x}");
1476            panic!(
1477                "GOLDEN_B is the SENTINEL placeholder — paste 0x{hash:016x} into GOLDEN_B above"
1478            );
1479        }
1480        assert_eq!(
1481            hash, GOLDEN_B,
1482            "Approach B 2-chunk render hash drifted: expected 0x{GOLDEN_B:016x}, got 0x{hash:016x}"
1483        );
1484    }
1485
1486    /// S4B.2.d: multi-chunk camera that sits **past** the first
1487    /// chunk's vsid (i.e., inside chunk (1, 0)). Validates that
1488    /// `recompute_in_bounds_xy` against the grid AABB recognises
1489    /// the camera as in-bounds — and that the seed path looks up
1490    /// chunk (1, 0)'s column via `chunk_at_xy(camera_chunk_idx)`
1491    /// instead of returning the OOB-XY bedrock placeholder.
1492    ///
1493    /// Scene shape: a 2-chunk x-stripe with a floor in chunk (1, 0)
1494    /// (where the camera sits) and the recognisable box in chunk
1495    /// (0, 0). The camera looks `-x` so rays cross from chunk
1496    /// (1, 0) back into chunk (0, 0). Tests the OTHER direction of
1497    /// cross-chunk DDA + the in-bounds AABB fix together.
1498    #[test]
1499    fn approach_b_camera_in_chunk_1_0_renders_neighbour() {
1500        let mut scene = Scene::new();
1501        let id = scene.add_grid(GridTransform::identity());
1502        let g = scene.grid_mut(id).unwrap();
1503        // Floor under the camera in chunk (1, 0).
1504        g.set_rect(
1505            IVec3::new(128, 0, 200),
1506            IVec3::new(255, 127, 205),
1507            Some(0x80_44_44_aa),
1508        );
1509        // Box deep in chunk (0, 0) — only visible if the camera in
1510        // chunk (1, 0) is recognised as in-bounds AND the
1511        // cross-chunk DDA fires westward.
1512        g.set_rect(
1513            IVec3::new(20, 50, 150),
1514            IVec3::new(30, 60, 165),
1515            Some(0x80_aa_55_22),
1516        );
1517        assert_eq!(g.chunk_count(), 2);
1518
1519        let backing = g.chunk_xyz_backing().expect("populated");
1520        let cg = roxlap_core::ChunkGrid {
1521            chunks: &backing.chunks,
1522            origin_chunk_xy: backing.origin_chunk_xy,
1523            origin_chunk_z: backing.origin_chunk_z,
1524            chunks_x: backing.chunks_x,
1525            chunks_y: backing.chunks_y,
1526            chunks_z: backing.chunks_z,
1527        };
1528        let grid_view = roxlap_core::GridView::from_chunk_grid(&cg, CHUNK_SIZE_XY);
1529        let (aabb_min, aabb_max) = grid_view.aabb_xy();
1530        assert_eq!(aabb_min, [0, 0]);
1531        assert_eq!(aabb_max, [256, 128]);
1532
1533        // Camera deep in chunk (1, 0): world (200, 64, 160). Past
1534        // the single-chunk vsid=128 OOB cutoff but inside the
1535        // multi-chunk AABB. Look -x toward chunk (0, 0).
1536        let camera = Camera {
1537            pos: [200.0, 64.0, 160.0],
1538            right: [0.0, -1.0, 0.0],
1539            down: [0.0, 0.0, 1.0],
1540            forward: [-1.0, 0.0, 0.0],
1541        };
1542        let (_engine, mut pool, mut fb, mut zb) = render_setup(2 * CHUNK_SIZE_XY);
1543        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1544        let mut rasterizer = ScalarRasterizer::new(&mut fb, &mut zb, XRES as usize, grid_view);
1545        let outcome = core_opticast(&mut rasterizer, &mut pool, &camera, &settings, grid_view);
1546        drop(rasterizer);
1547        assert_eq!(outcome, OpticastOutcome::Rendered);
1548
1549        let floor_count = fb.iter().filter(|&&p| p == 0x80_44_44_aa).count();
1550        let box_count = fb.iter().filter(|&&p| p == 0x80_aa_55_22).count();
1551        assert!(
1552            floor_count > 1000,
1553            "floor under camera in chunk (1, 0) not visible — only {floor_count} floor pixels — in_bounds_xy fix may not have taken effect"
1554        );
1555        assert!(
1556            box_count > 50,
1557            "box in chunk (0, 0) not visible — only {box_count} box pixels — westward cross-chunk DDA failed"
1558        );
1559    }
1560
1561    /// S4B.6.c: stacked-grid scaffold — camera in chz=1 (= world
1562    /// z=256..511) of a 2-chunk-tall grid should render its own
1563    /// chunk's terrain. Verifies cf seed + slab-byte reads + chunk-
1564    /// XY swaps all use world-z consistently.
1565    ///
1566    /// Cross-chunk look-down (= camera in chz=0 sees terrain in
1567    /// chz=1) needs cf z range extension at air-gap-lookup time;
1568    /// that's a follow-up to S4B.6.c.
1569    #[test]
1570    fn stacked_two_chunk_z_camera_in_chz1_sees_own_chunk_floor() {
1571        let mut scene = Scene::new();
1572        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
1573        let g = scene.grid_mut(id).unwrap();
1574        // chz=0: all-air (materialised so chunk_xyz_backing enumerates).
1575        g.ensure_chunk(IVec3::new(0, 0, 0));
1576        // chz=1: floor at local z=50 (= world z=306).
1577        g.set_rect(
1578            IVec3::new(60, 60, 306),
1579            IVec3::new(72, 72, 310),
1580            Some(0x80_33_66_99),
1581        );
1582        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
1583
1584        let (_engine, mut pool, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
1585        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1586        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1587        pool.set_treat_z_max_as_air(true);
1588        // Camera at world (66, 66, 280) — directly above the
1589        // floor at world z=306. Look STRAIGHT DOWN (z increases =
1590        // down in voxlap z-down).
1591        let camera = Camera {
1592            pos: [66.0, 66.0, 280.0],
1593            right: [1.0, 0.0, 0.0],
1594            down: [0.0, 1.0, 0.0],
1595            forward: [0.0, 0.0, 1.0],
1596        };
1597        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1598        let outcome = render_scene_composed(
1599            &mut fb,
1600            &mut zb,
1601            XRES as usize,
1602            XRES,
1603            YRES,
1604            &mut pool,
1605            &mut scene,
1606            &camera,
1607            &settings,
1608            sky_color,
1609            None,
1610        );
1611        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1612        let floor_count = fb.iter().filter(|&&p| p == 0x80_33_66_99).count();
1613        assert!(
1614            floor_count > 100,
1615            "camera at chz=1 with floor in same chunk should see it — got {floor_count} floor pixels"
1616        );
1617    }
1618
1619    /// S4B.6.e: cross-chunk look-down. Camera in chz=0's all-air
1620    /// chunk should see chz=1's floor below it. This was deferred
1621    /// from S4B.6.c because the cf seed's z range capped at the
1622    /// camera-chunk's bedrock (world z=255); S4B.6.e extends the
1623    /// air-gap walk in `camera_chunk_air_gap` to step into the
1624    /// next chunk down when the camera's column is all-air-bedrock,
1625    /// and the rasterizer routes state.column / slab_buf to the
1626    /// chunk holding the real floor via `seed_chunk_z`.
1627    #[test]
1628    fn stacked_two_chunk_z_camera_in_chz0_sees_chz1_floor() {
1629        let mut scene = Scene::new();
1630        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
1631        let g = scene.grid_mut(id).unwrap();
1632        // chz=0: all-air. Materialised so chunk_xyz_backing
1633        // enumerates it.
1634        g.ensure_chunk(IVec3::new(0, 0, 0));
1635        // chz=1: floor at world z=306..310 (= local z=50..54).
1636        g.set_rect(
1637            IVec3::new(60, 60, 306),
1638            IVec3::new(72, 72, 310),
1639            Some(0x80_77_aa_44),
1640        );
1641        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
1642
1643        let (_engine, mut pool, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
1644        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1645        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1646        pool.set_treat_z_max_as_air(true);
1647        // Camera at world (66, 66, 100) — in chz=0's all-air
1648        // chunk. Look STRAIGHT DOWN (z+) toward chz=1's floor at
1649        // world z=306.
1650        let camera = Camera {
1651            pos: [66.0, 66.0, 100.0],
1652            right: [1.0, 0.0, 0.0],
1653            down: [0.0, 1.0, 0.0],
1654            forward: [0.0, 0.0, 1.0],
1655        };
1656        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1657        let outcome = render_scene_composed(
1658            &mut fb,
1659            &mut zb,
1660            XRES as usize,
1661            XRES,
1662            YRES,
1663            &mut pool,
1664            &mut scene,
1665            &camera,
1666            &settings,
1667            sky_color,
1668            None,
1669        );
1670        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1671        let floor_count = fb.iter().filter(|&&p| p == 0x80_77_aa_44).count();
1672        assert!(
1673            floor_count > 50,
1674            "camera in chz=0 air-gap should see chz=1 floor via cross-chunk look-down — got {floor_count} floor pixels"
1675        );
1676    }
1677
1678    /// S4B.6.l KNOWN LIMITATION (pre-S4B.6.l fix): camera at chz=0
1679    /// with all-air-bedrock at the camera's own XY column
1680    /// (seed_chz=1 via cross-chunk look-down). A DIFFERENT XY column
1681    /// has chz=0 content (= a distant mountain entirely inside
1682    /// chz=0). State.current_chunk_z gets pinned to 1 at seed time
1683    /// and the chunk-XY swap reads chz=1 chunks across the DDA, so
1684    /// the chz=0 mountain is invisible. This test pins the limitation
1685    /// — it currently asserts mountain_count == 0 to fail loudly
1686    /// (= the limitation has been fixed) once cf-splitting at chz
1687    /// boundaries lands.
1688    #[test]
1689    #[ignore = "S4B.6.l: known limitation — needs cf-splitting at chz boundaries"]
1690    fn stacked_chz0_distant_mountain_visible_from_chz0_camera() {
1691        let mut scene = Scene::new();
1692        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
1693        let g = scene.grid_mut(id).unwrap();
1694        // chz=0 mountain at a column DISTANT from the camera —
1695        // entirely in chz=0 (world z=100..200), so chz=1 at the
1696        // same XY is all-air-bedrock.
1697        g.set_rect(
1698            IVec3::new(100, 100, 100),
1699            IVec3::new(124, 124, 200),
1700            Some(0x80_aa_55_22), // distinct brown
1701        );
1702        // chz=1 hills filling the floor at world z=336..360 across
1703        // the chunk EXCEPT a hole around the mountain XY (so the
1704        // mountain doesn't sit on a green tower).
1705        g.set_rect(
1706            IVec3::new(0, 0, 336),
1707            IVec3::new(128, 128, 360),
1708            Some(0x80_22_88_44),
1709        );
1710        g.set_rect(IVec3::new(100, 100, 336), IVec3::new(124, 124, 360), None);
1711        // Materialise chz=0 + chz=1 (chz=0 has the mountain; chz=1
1712        // has the hills).
1713        assert!(g.chunk(IVec3::new(0, 0, 0)).is_some());
1714        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
1715
1716        let (_engine, mut pool, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1717        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1718        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1719        pool.set_treat_z_max_as_air(true);
1720        // Camera at (40, 40, 60) — chz=0 air, FAR from the mountain
1721        // XY (100..124, 100..124). Yaw=π/4 (look toward +x+y =
1722        // mountain direction), pitch=0.72 rad (≈ 41° down) so the
1723        // ray bisecting the screen aims at the chz=0 mountain centre
1724        // ≈ (112, 112, 150).
1725        let (sy, cy) = (std::f64::consts::FRAC_PI_4).sin_cos();
1726        let (sp, cp) = 0.72_f64.sin_cos();
1727        let camera = Camera {
1728            pos: [40.0, 40.0, 60.0],
1729            right: [-sy, cy, 0.0],
1730            down: [-cy * sp, -sy * sp, cp],
1731            forward: [cy * cp, sy * cp, sp],
1732        };
1733        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1734        let outcome = render_scene_composed(
1735            &mut fb,
1736            &mut zb,
1737            XRES as usize,
1738            XRES,
1739            YRES,
1740            &mut pool,
1741            &mut scene,
1742            &camera,
1743            &settings,
1744            sky_color,
1745            None,
1746        );
1747        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1748        let mountain_count = fb.iter().filter(|&&p| p == 0x80_aa_55_22).count();
1749        let hill_count = fb.iter().filter(|&&p| p == 0x80_22_88_44).count();
1750        eprintln!("chz0-distant-mountain: mountain_chz0={mountain_count} hill_chz1={hill_count}");
1751        // chz=1 hills are reachable via seed-time cross-chunk
1752        // look-down.
1753        assert!(
1754            hill_count > 50,
1755            "expected chz=1 hills via cross-chunk look-down — got {hill_count}"
1756        );
1757        // The proper-fix assertion: chz=0 distant mountain SHOULD be
1758        // visible. Currently fails — pins the limitation.
1759        assert!(
1760            mountain_count > 50,
1761            "expected chz=0 distant mountain visible — got {mountain_count} (S4B.6.l limitation)"
1762        );
1763    }
1764
1765    /// S4B.6.h: mid-render chunk-Z handoff. Camera column has
1766    /// content in chz=0 (= a mountain at the camera's XY) so
1767    /// seed-time cross-chunk look-down does NOT fire — seed_chz=0.
1768    /// As rays DDA across the scene, they visit XY columns where
1769    /// chz=0 is all-air-bedrock. Mid-render handoff should swap
1770    /// state to chz=1's column at those XY positions and reveal
1771    /// hill content sitting under the camera's chz=0 layer.
1772    ///
1773    /// This is the "tall mountains breaching chunk-Z boundary"
1774    /// case the demo aims for.
1775    #[test]
1776    fn mid_render_handoff_reveals_chz1_hills_under_mountain_camera() {
1777        let mut scene = Scene::new();
1778        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
1779        let g = scene.grid_mut(id).unwrap();
1780        // chz=0: a small "mountain peak" at the camera's XY.
1781        // Mountain at world z=150..200 — solid block.
1782        g.set_rect(
1783            IVec3::new(60, 60, 150),
1784            IVec3::new(72, 72, 200),
1785            Some(0x80_88_44_22), // brown mountain
1786        );
1787        // chz=1: hills at world z=336..360 across the WHOLE chunk
1788        // (so DDA rays hit them when chz=0 is air).
1789        g.set_rect(
1790            IVec3::new(0, 0, 336),
1791            IVec3::new(128, 128, 360),
1792            Some(0x80_22_88_44), // green hills
1793        );
1794        // Carve a hole in chz=1's hill at the mountain's footprint
1795        // so the mountain doesn't appear to "float" on green.
1796        g.set_rect(IVec3::new(60, 60, 336), IVec3::new(72, 72, 360), None);
1797        assert!(g.chunk(IVec3::new(0, 0, 0)).is_some());
1798        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
1799
1800        let (_engine, mut pool, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
1801        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1802        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1803        pool.set_treat_z_max_as_air(true);
1804        // Camera at world (66, 66, 100) — directly above the
1805        // mountain peak (at z=150). Camera column has the
1806        // mountain in chz=0. Look straight down.
1807        let camera = Camera {
1808            pos: [66.0, 66.0, 100.0],
1809            right: [1.0, 0.0, 0.0],
1810            down: [0.0, 1.0, 0.0],
1811            forward: [0.0, 0.0, 1.0],
1812        };
1813        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1814        let outcome = render_scene_composed(
1815            &mut fb,
1816            &mut zb,
1817            XRES as usize,
1818            XRES,
1819            YRES,
1820            &mut pool,
1821            &mut scene,
1822            &camera,
1823            &settings,
1824            sky_color,
1825            None,
1826        );
1827        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1828        let mountain_count = fb.iter().filter(|&&p| p == 0x80_88_44_22).count();
1829        let hill_count = fb.iter().filter(|&&p| p == 0x80_22_88_44).count();
1830        // Verify the hills render at approximately the correct
1831        // world-z by sampling the z-buffer at hill pixels. Camera
1832        // at z=100 looking straight down; hills at world z=336.
1833        // Expected depth = 236 for directly-below pixels. If
1834        // state.z1 stays stuck at the mountain peak's z=150 the
1835        // hills would render with depth ≈ 50 → orders of magnitude
1836        // off.
1837        let mut hill_depths: Vec<f32> = fb
1838            .iter()
1839            .zip(zb.iter())
1840            .filter_map(|(&p, &d)| if p == 0x80_22_88_44 { Some(d) } else { None })
1841            .collect();
1842        hill_depths.sort_by(|a, b| a.partial_cmp(b).unwrap());
1843        let median_hill_depth = hill_depths[hill_depths.len() / 2];
1844        eprintln!(
1845            "mid-render handoff: mountain={mountain_count} hill={hill_count} median_hill_depth={median_hill_depth:.1}"
1846        );
1847        assert!(
1848            mountain_count > 50,
1849            "should see mountain peak via chz=0 — got {mountain_count} mountain pixels"
1850        );
1851        assert!(
1852            hill_count > 50,
1853            "should see chz=1 hills via mid-render handoff — got {hill_count} hill pixels"
1854        );
1855        assert!(
1856            (median_hill_depth - 236.0).abs() < 80.0,
1857            "hill median depth should be ≈236 (camera→z=336); got {median_hill_depth:.1} — state.z1 may be stale at the mountain peak's z"
1858        );
1859    }
1860
1861    /// S4B.6.g: cross-chunk look-down under multi-mip. Same scene
1862    /// as `stacked_two_chunk_z_camera_in_chz0_sees_chz1_floor` but
1863    /// with `mip_levels=2, mip_scan_dist=16` so the rasterizer
1864    /// transitions to mip-1 well within the chz=1 terrain. Locks in
1865    /// the slab_z_at mip-N offset fix (= `chunk_world_z_base >>
1866    /// gmipcnt`). Pre-fix produced a green / brown "wall in a circle
1867    /// around the camera" because mip-1 rendered the floor at
1868    /// world-z ≈ 178 instead of 306.
1869    #[test]
1870    fn stacked_two_chunk_z_camera_in_chz0_sees_chz1_floor_multi_mip() {
1871        let mut scene = Scene::new();
1872        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
1873        let g = scene.grid_mut(id).unwrap();
1874        g.ensure_chunk(IVec3::new(0, 0, 0));
1875        g.set_rect(
1876            IVec3::new(60, 60, 306),
1877            IVec3::new(72, 72, 310),
1878            Some(0x80_77_aa_44),
1879        );
1880        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
1881
1882        let (_engine, mut pool, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
1883        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1884        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1885        pool.set_treat_z_max_as_air(true);
1886        let camera = Camera {
1887            pos: [66.0, 66.0, 100.0],
1888            right: [1.0, 0.0, 0.0],
1889            down: [0.0, 1.0, 0.0],
1890            forward: [0.0, 0.0, 1.0],
1891        };
1892        let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1893        settings.mip_levels = 2;
1894        settings.mip_scan_dist = 16;
1895        let outcome = render_scene_composed(
1896            &mut fb,
1897            &mut zb,
1898            XRES as usize,
1899            XRES,
1900            YRES,
1901            &mut pool,
1902            &mut scene,
1903            &camera,
1904            &settings,
1905            sky_color,
1906            None,
1907        );
1908        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1909        let floor_count = fb.iter().filter(|&&p| p == 0x80_77_aa_44).count();
1910        assert!(
1911            floor_count > 50,
1912            "multi-mip cross-chunk look-down should still see chz=1 floor — got {floor_count} floor pixels"
1913        );
1914    }
1915
1916    /// S4B.6.d: 3-chunk-tall stack stresses the widened gylookup
1917    /// (`(chunks_z * 512) >> mip + 4` per mip). Pre-S4B.6.d, gylookup
1918    /// was hardcoded at `(512 >> mip) + 4`, which would OOB or alias
1919    /// for any z > 511. This test renders a floor at world z=562
1920    /// (= chz=2, local z=50) with the camera at world z=540, looking
1921    /// straight down. Multi-mip is on so we exercise the mip slide
1922    /// path in `phase_remiporend` that scales `advance` by chunks_z.
1923    #[test]
1924    fn stacked_three_chunk_z_camera_in_chz2_sees_own_chunk_floor_multi_mip() {
1925        let mut scene = Scene::new();
1926        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
1927        let g = scene.grid_mut(id).unwrap();
1928        // Materialise chz=0 + chz=1 so chunk_xyz_backing enumerates
1929        // the full stack.
1930        g.ensure_chunk(IVec3::new(0, 0, 0));
1931        g.ensure_chunk(IVec3::new(0, 0, 1));
1932        // chz=2: floor at world z=562..566 (= local z=50..54).
1933        g.set_rect(
1934            IVec3::new(60, 60, 562),
1935            IVec3::new(72, 72, 566),
1936            Some(0x80_aa_55_22),
1937        );
1938        assert!(g.chunk(IVec3::new(0, 0, 2)).is_some());
1939
1940        let (_engine, mut pool, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
1941        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1942        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1943        pool.set_treat_z_max_as_air(true);
1944        let camera = Camera {
1945            pos: [66.0, 66.0, 540.0],
1946            right: [1.0, 0.0, 0.0],
1947            down: [0.0, 1.0, 0.0],
1948            forward: [0.0, 0.0, 1.0],
1949        };
1950        // Multi-mip on to exercise the gylookup-slide path.
1951        let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1952        settings.mip_levels = 2;
1953        settings.mip_scan_dist = 16;
1954        let outcome = render_scene_composed(
1955            &mut fb,
1956            &mut zb,
1957            XRES as usize,
1958            XRES,
1959            YRES,
1960            &mut pool,
1961            &mut scene,
1962            &camera,
1963            &settings,
1964            sky_color,
1965            None,
1966        );
1967        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1968        let floor_count = fb.iter().filter(|&&p| p == 0x80_aa_55_22).count();
1969        assert!(
1970            floor_count > 100,
1971            "camera at chz=2 with floor in same chunk should see it — got {floor_count} floor pixels"
1972        );
1973    }
1974}