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::dda::{render_dda_parallel, CpuLights, CpuPointLight, DdaEnv};
41use roxlap_core::opticast::OpticastSettings;
42use roxlap_core::sky::Sky;
43use roxlap_core::Camera;
44use roxlap_formats::material::MaterialTable;
45
46use crate::billboard::{self, BillboardCache, DEFAULT_RESOLUTION as BILLBOARD_RESOLUTION};
47use crate::chunks;
48use crate::lod::Lod;
49use crate::occluder::SceneOccluder;
50use crate::{GridId, GridTransform, Scene, CHUNK_SIZE_XY};
51use roxlap_core::{CompositeOccluder, WorldOccluder, WorldShadowCtx};
52use std::collections::HashMap;
53
54/// Sentinel colour stamped into a `render_sky = false` grid's
55/// temporary framebuffer wherever the rasterizer would have drawn
56/// sky. After opticast, [`render_scene_composed`] walks the temp
57/// buffer and resets `temp_zb` to [`f32::INFINITY`] for any pixel
58/// still carrying this value — those pixels then always lose
59/// [`compose_into`]'s min-z test and the underlying grid's sky
60/// (or another grid's hit) wins.
61///
62/// Alpha byte is `0x00`. Voxlap voxel slabs carry an alpha-encoded
63/// shade in `[0x00, 0x80]`, but a `0x00` alpha **with this exact
64/// RGB pattern** is exceedingly unlikely to occur on a real hit
65/// (the lit-voxel path produces alpha ≥ 0x40 in practice). Bit
66/// pattern is also visually distinct (cyan-ish neon) if anything
67/// ever leaks through to the screen, making the bug obvious.
68const SKY_MASK_SENTINEL: u32 = 0x00_DE_AD_BE;
69
70/// CPU fog + per-face shading config for the DDA backend, passed by
71/// value into the scene render entry points (replaces the old
72/// `&mut ScratchPool` parameter the voxlap path threaded fog through).
73///
74/// `max_scan_dist <= 0` disables fog (no distance blend). Otherwise the
75/// DDA renderer linearly ramps a hit's colour toward [`Self::color`]
76/// over `max_scan_dist` voxels. `side_shades` darkens each of the six
77/// voxel faces — `[x-, x+, y-, y+, z-, z+]`.
78#[derive(Debug, Clone, Copy, Default)]
79pub struct CpuFog {
80    /// Low-24-bit RGB fog colour.
81    pub color: u32,
82    /// Distance (voxels) at which fog is fully opaque; `<= 0` ⇒ fog OFF.
83    pub max_scan_dist: i32,
84    /// Per-face brightness reduction `[x-, x+, y-, y+, z-, z+]`.
85    pub side_shades: [i8; 6],
86}
87
88/// Project a world-space [`Camera`] into a grid's local frame:
89/// translate by `-transform.origin`, then apply
90/// `transform.rotation.inverse()` to the position and the
91/// orthonormal basis (`right` / `down` / `forward`).
92///
93/// Identity rotation collapses to pure translation, byte-identical
94/// to the pre-S5 path (`DQuat::IDENTITY * v == v`). For a rotated
95/// grid the rasterizer still sees an axis-aligned chunk grid —
96/// rotation is invisible below this layer per PORTING-SCENE.md § S5.
97///
98/// The basis is rotated as a free vector (no translation
99/// component); position is rotated about the grid origin.
100fn world_camera_to_grid_local(camera: &Camera, transform: &GridTransform) -> Camera {
101    let inv = transform.rotation.inverse();
102    let world_offset = DVec3::from_array(camera.pos) - transform.origin;
103    let local_pos = inv * world_offset;
104    let local_right = inv * DVec3::from_array(camera.right);
105    let local_down = inv * DVec3::from_array(camera.down);
106    let local_forward = inv * DVec3::from_array(camera.forward);
107    Camera {
108        pos: local_pos.to_array(),
109        right: local_right.to_array(),
110        down: local_down.to_array(),
111        forward: local_forward.to_array(),
112    }
113}
114
115/// CPU.1 — transform world-space dynamic lights into a grid's local frame
116/// (the same translate + inverse-rotation as [`world_camera_to_grid_local`]):
117/// point positions are points (origin-relative + inverse-rotated); the sun
118/// direction is a vector (inverse-rotated only). Point lights land in `scratch`
119/// so the returned [`CpuLights`] can borrow them for the grid's render.
120fn grid_local_lights<'a>(
121    world: &CpuLights<'_>,
122    transform: &GridTransform,
123    scratch: &'a mut Vec<CpuPointLight>,
124) -> CpuLights<'a> {
125    scratch.clear();
126    if !world.enabled {
127        return CpuLights::default();
128    }
129    let inv = transform.rotation.inverse();
130    #[allow(clippy::cast_possible_truncation)]
131    let sun_dir = if world.sun {
132        let d = inv
133            * DVec3::new(
134                f64::from(world.sun_dir[0]),
135                f64::from(world.sun_dir[1]),
136                f64::from(world.sun_dir[2]),
137            );
138        [d.x as f32, d.y as f32, d.z as f32]
139    } else {
140        [0.0; 3]
141    };
142    for p in world.points {
143        let lp = inv
144            * (DVec3::new(
145                f64::from(p.pos[0]),
146                f64::from(p.pos[1]),
147                f64::from(p.pos[2]),
148            ) - transform.origin);
149        // SL — the cone axis is a vector: inverse-rotate only (no origin).
150        let sd = inv
151            * DVec3::new(
152                f64::from(p.spot_dir[0]),
153                f64::from(p.spot_dir[1]),
154                f64::from(p.spot_dir[2]),
155            );
156        #[allow(clippy::cast_possible_truncation)]
157        scratch.push(CpuPointLight {
158            pos: [lp.x as f32, lp.y as f32, lp.z as f32],
159            color: p.color,
160            intensity: p.intensity,
161            radius: p.radius,
162            casts_shadow: p.casts_shadow,
163            spot_dir: [sd.x as f32, sd.y as f32, sd.z as f32],
164            cos_inner: p.cos_inner,
165            cos_outer: p.cos_outer,
166        });
167    }
168    CpuLights {
169        enabled: true,
170        sun: world.sun,
171        sun_dir,
172        sun_color: world.sun_color,
173        sun_intensity: world.sun_intensity,
174        sun_casts_shadow: world.sun_casts_shadow,
175        points: scratch.as_slice(),
176        ambient: world.ambient,
177        bands: world.bands,
178        shadow_tint: world.shadow_tint,
179        // CPU.2 — shadows: the rig is world-space here; shadow distances are
180        // grid-uniform (no scaling), so they carry through unchanged.
181        shadow_strength: world.shadow_strength,
182        shadow_bias: world.shadow_bias,
183        shadow_max_dist: world.shadow_max_dist,
184    }
185}
186
187/// Outcome of a [`render_scene`] / [`render_scene_composed`] call.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum RenderOutcome {
190    /// At least one grid produced a render.
191    Rendered {
192        /// Number of grids that were drawn.
193        grids_drawn: usize,
194    },
195    /// No grid rendered — the scene was empty (no populated grids).
196    Empty,
197}
198
199/// Render every grid in `scene` directly into `(fb, zb)` — no
200/// per-grid temp buffer, no compose merge. For multi-grid scenes
201/// this is last-grid-wins (later grids' opticast writes overwrite
202/// earlier grids' pixels indiscriminately, including sky), so it's
203/// only correct for single-grid scenes.
204///
205/// Use this when you have one grid and want the byte-stable
206/// PR.3: pick the cheapest `GridView` constructor that matches the
207/// grid's chunk layout.
208///
209/// Trivial-single-chunk grids (1 chunk at index `(0, 0, 0)`) bypass
210/// the multi-chunk rasterizer path: `GridView::from_single_vxl`
211/// leaves `chunk_grid = None`, so `phase_after_delete_kept_presync`
212/// takes the cheaper single-chunk branch instead of doing
213/// `chunk_at_xyz` + IVec2-equality + `Option::is_some` per
214/// column-step. Markers / pickups / small ships qualify.
215///
216/// Multi-chunk grids (ground, larger ships) fall through to
217/// `from_chunk_grid` with the supplied `ChunkGrid`.
218fn single_chunk_fast_path<'a>(
219    backing: &'a chunks::ChunkXyBacking<'a>,
220    cg: &'a roxlap_core::ChunkGrid<'a>,
221) -> roxlap_core::GridView<'a> {
222    if backing.chunks_x == 1
223        && backing.chunks_y == 1
224        && backing.chunks_z == 1
225        && backing.origin_chunk_xy == [0, 0]
226        && backing.origin_chunk_z == 0
227    {
228        // chunk_xyz_backing populates each `Vec<Option<GridView>>`
229        // slot via `GridView::from_single_vxl`, which leaves
230        // `chunk_grid = None`. Reuse that directly.
231        if let Some(single) = backing.chunks[0] {
232            return single;
233        }
234    }
235    roxlap_core::GridView::from_chunk_grid(cg, CHUNK_SIZE_XY)
236}
237
238/// matches-direct-opticast property — the test suite uses it as a
239/// sanity check that the combined-world stitch + render harness
240/// doesn't drift vs. a raw [`opticast`] call.
241///
242/// Caller pre-fills `fb` with the desired sky colour and `zb` with
243/// any value (typically `0.0` matching the per-chunk renderer's
244/// convention or `f32::INFINITY` for compose-friendly init); the
245/// rasterizer overwrites both per pixel that gets a hit.
246#[allow(clippy::too_many_arguments)]
247pub fn render_scene(
248    fb: &mut [u32],
249    zb: &mut [f32],
250    pitch_pixels: usize,
251    width: u32,
252    height: u32,
253    fog: CpuFog,
254    scene: &mut Scene,
255    camera: &Camera,
256    settings: &OpticastSettings,
257    sky: Option<&Sky>,
258) -> RenderOutcome {
259    debug_assert_eq!(fb.len(), zb.len());
260    let pixel_count = (width as usize) * (height as usize);
261    debug_assert_eq!(fb.len(), pixel_count);
262
263    let mut grids_drawn = 0usize;
264    for (_id, grid) in scene.grids_mut() {
265        // S4B.2.e: Approach B render path. World → grid-local
266        // camera transform doesn't need a voxel-offset adjustment
267        // anymore — Approach B's chunks live at their signed
268        // (chx, chy) indices and `chunk_at_xy` handles negative-
269        // index lookups natively.
270        //
271        // S5.0: per-grid arbitrary rotation. The local camera is
272        // built by `world_camera_to_grid_local` — translation +
273        // inverse-rotation of the basis. Identity rotation keeps
274        // this byte-identical to the pre-S5 translate-only form.
275        // DDA.7: refresh the cross-frame brick cache (needs `&mut grid`)
276        // before borrowing the grid immutably for `backing`.
277        let dda_mip = grid.ensure_dda_bricks(0);
278        let Some(backing) = grid.chunk_xyz_backing() else {
279            // Empty grid (no populated chz=0 chunks) — skip.
280            continue;
281        };
282        let local_cam = world_camera_to_grid_local(camera, &grid.transform);
283        let cg = roxlap_core::ChunkGrid {
284            chunks: &backing.chunks,
285            origin_chunk_xy: backing.origin_chunk_xy,
286            origin_chunk_z: backing.origin_chunk_z,
287            chunks_x: backing.chunks_x,
288            chunks_y: backing.chunks_y,
289            chunks_z: backing.chunks_z,
290        };
291        let grid_view = single_chunk_fast_path(&backing, &cg);
292        // DDA backend. The direct path doesn't pre-fill, so seed sky
293        // (black) + far depth here — DDA leaves misses untouched.
294        for px in fb.iter_mut() {
295            *px = 0;
296        }
297        for d in zb.iter_mut() {
298            *d = f32::INFINITY;
299        }
300        let fog_on = fog.max_scan_dist > 0;
301        #[allow(clippy::cast_precision_loss)]
302        let env = DdaEnv {
303            sky,
304            fog_color: if fog_on { fog.color } else { 0 },
305            fog_max_dist: if fog_on {
306                fog.max_scan_dist.max(1) as f32
307            } else {
308                0.0
309            },
310            side_shades: fog.side_shades,
311            // The direct (non-composed) path is opaque-only; terrain
312            // materials flow through render_scene_composed_with_materials.
313            materials: None,
314            terrain_materials: &[],
315            // The direct path is unlit (lighting flows through the composed
316            // path); keep it on the baked-byte shade.
317            lights: CpuLights::default(),
318            world_shadow: None,
319        };
320        render_dda_parallel(
321            &local_cam,
322            settings,
323            grid_view,
324            fb,
325            zb,
326            pitch_pixels,
327            &env,
328            &grid.dda_brick_cache,
329            dda_mip,
330        );
331        grids_drawn += 1;
332    }
333    if grids_drawn == 0 {
334        RenderOutcome::Empty
335    } else {
336        RenderOutcome::Rendered { grids_drawn }
337    }
338}
339
340/// Per-pixel "min-z wins" merge of `(temp_fb, temp_zb)` into
341/// `(shared_fb, shared_zb)`.
342///
343/// Voxlap's z-buffer convention: `z` = perpendicular distance from
344/// camera; **smaller `z` = closer to camera**. This helper picks
345/// the closer pixel per slot. Sky pixels emerge with a large `z`
346/// (`scratch.skycast.dist`, set to `gxmax` or `i32::MAX` per
347/// `phase_startsky`) so they always lose to any hit's finite
348/// distance.
349///
350/// `temp_fb` / `temp_zb` are read-only inputs; both must have the
351/// same length as `shared_fb` / `shared_zb` (debug-asserted).
352pub fn compose_into(
353    shared_fb: &mut [u32],
354    shared_zb: &mut [f32],
355    temp_fb: &[u32],
356    temp_zb: &[f32],
357) {
358    debug_assert_eq!(shared_fb.len(), shared_zb.len());
359    debug_assert_eq!(shared_fb.len(), temp_fb.len());
360    debug_assert_eq!(shared_fb.len(), temp_zb.len());
361    for i in 0..shared_fb.len() {
362        if temp_zb[i] < shared_zb[i] {
363            shared_fb[i] = temp_fb[i];
364            shared_zb[i] = temp_zb[i];
365        }
366    }
367}
368
369/// Half-open screen rectangle `[x0, x1) × [y0, y1)` a grid's
370/// projection is confined to — the scissor [`render_scene_composed`]
371/// uses to render and compose each grid only within its screen
372/// footprint instead of over the whole frame.
373#[derive(Clone, Copy, Debug)]
374struct ScreenRect {
375    x0: u32,
376    x1: u32,
377    y0: u32,
378    y1: u32,
379}
380
381impl ScreenRect {
382    fn is_empty(self) -> bool {
383        self.x0 >= self.x1 || self.y0 >= self.y1
384    }
385}
386
387/// Project a world-space bounding sphere `(centre, radius)` to a
388/// conservative screen rectangle under opticast's pinhole — focal `hz`,
389/// principal point `(hx, hy)`, ray for pixel `(px, py)` being
390/// `(px-hx)·right + (py-hy)·down + hz·forward` (camera_math). Returns:
391///
392/// - `Some(rect)` clamped to the viewport when the sphere is safely in
393///   front of the camera. The rect may be **empty** (sphere off to one
394///   side) → the grid can't appear, so the caller skips it entirely.
395/// - `None` when the camera is inside or near the sphere (forward-depth
396///   `z ≤ radius`), where a finite screen bound is unsafe → the caller
397///   must render the grid full-frame.
398///
399/// Conservative on purpose (never clips a pixel the full render would
400/// touch): the projected radius uses the over-estimate `hz·R/(z−R)`
401/// (exact is `hz·R/√(z²−R²)`) and pads by `anginc + 1`, matching the
402/// projection's `anginc` viewport padding.
403fn project_sphere_to_screen(
404    camera: &Camera,
405    centre: DVec3,
406    radius: f64,
407    settings: &OpticastSettings,
408) -> Option<ScreenRect> {
409    let d = centre - DVec3::from_array(camera.pos);
410    let z = d.dot(DVec3::from_array(camera.forward));
411    if z <= radius {
412        return None; // camera inside / in front of the sphere shell
413    }
414    let x = d.dot(DVec3::from_array(camera.right));
415    let y = d.dot(DVec3::from_array(camera.down));
416    let (hx, hy, hz) = (
417        f64::from(settings.hx),
418        f64::from(settings.hy),
419        f64::from(settings.hz),
420    );
421    let sr = hz * radius / (z - radius); // over-estimated screen radius
422    let sx = hx + x / z * hz;
423    let sy = hy + y / z * hz;
424    let pad = f64::from(settings.anginc) + 1.0;
425    let (xres, yres) = (f64::from(settings.xres), f64::from(settings.yres));
426    let clamp = |v: f64, hi: f64| v.clamp(0.0, hi);
427    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
428    Some(ScreenRect {
429        x0: clamp((sx - sr - pad).floor(), xres) as u32,
430        x1: clamp((sx + sr + pad).ceil(), xres) as u32,
431        y0: clamp((sy - sr - pad).floor(), yres) as u32,
432        y1: clamp((sy + sr + pad).ceil(), yres) as u32,
433    })
434}
435
436/// Fill each `rect` row of a `u32` buffer (row stride `pitch`) with
437/// `val` — the scissored analogue of `slice.fill(val)`.
438fn fill_rect_u32(buf: &mut [u32], pitch: usize, rect: ScreenRect, val: u32) {
439    for y in rect.y0..rect.y1 {
440        let row = y as usize * pitch;
441        buf[row + rect.x0 as usize..row + rect.x1 as usize].fill(val);
442    }
443}
444
445/// Fill each `rect` row of an `f32` buffer (row stride `pitch`) with `val`.
446fn fill_rect_f32(buf: &mut [f32], pitch: usize, rect: ScreenRect, val: f32) {
447    for y in rect.y0..rect.y1 {
448        let row = y as usize * pitch;
449        buf[row + rect.x0 as usize..row + rect.x1 as usize].fill(val);
450    }
451}
452
453/// Min-z compose `temp_*` into `fb`/`zb` over `rect` only — the
454/// scissored analogue of [`compose_into`]. A `temp` pixel wins where its
455/// `z` is strictly smaller than the destination's.
456fn compose_rect(
457    fb: &mut [u32],
458    zb: &mut [f32],
459    temp_fb: &[u32],
460    temp_zb: &[f32],
461    pitch: usize,
462    rect: ScreenRect,
463) {
464    for y in rect.y0..rect.y1 {
465        let row = y as usize * pitch;
466        for i in row + rect.x0 as usize..row + rect.x1 as usize {
467            if temp_zb[i] < zb[i] {
468                zb[i] = temp_zb[i];
469                fb[i] = temp_fb[i];
470            }
471        }
472    }
473}
474
475/// Render every grid in `scene` with per-grid temporary buffers +
476/// z-buffer composition. The canonical multi-grid scene render
477/// path.
478///
479/// Algorithm:
480/// 1. Caller pre-fills `fb` with the desired sky colour and `zb`
481///    with [`f32::INFINITY`] (so any rendered pixel wins the
482///    initial composition).
483/// 2. For each grid, allocate a temporary `(temp_fb, temp_zb)` of
484///    the same size, pre-fill them with sky / `INFINITY`, and run
485///    [`opticast`] into them via a [`ScalarRasterizer`] over the
486///    temporary buffers AND the grid's combined-world view (S4.0).
487/// 3. Merge the temporary buffers into the shared `(fb, zb)` via
488///    [`compose_into`] — closer pixels (smaller `z`) win.
489///
490/// Pixel correctness across overlapping grids: sky pixels emerge
491/// with `z` = `gxmax` / `i32::MAX` (a very large value), so they
492/// always lose to any hit. Hits compete on actual perpendicular
493/// distance — the closer grid's surface is what gets composited.
494///
495/// `pitch_pixels` is the framebuffer's row stride in pixels (×4 for
496/// bytes). `width` × `height` must equal `fb.len()` /
497/// `zb.len()`. `sky` is the optional textured sky resource the
498/// rasterizer threads through to `phase_startsky`; `None` ⇒ solid
499/// `pool.skycast` fill.
500///
501/// **Heap allocation per call:** two `Vec` allocations per grid (a
502/// temp framebuffer and zbuffer). For repeated frame rendering an
503/// owned scratch struct that pre-allocates these is the obvious
504/// optimisation; deferred until profiling shows it matters.
505#[allow(clippy::too_many_arguments)]
506pub fn render_scene_composed(
507    fb: &mut [u32],
508    zb: &mut [f32],
509    pitch_pixels: usize,
510    width: u32,
511    height: u32,
512    fog: CpuFog,
513    scene: &mut Scene,
514    camera: &Camera,
515    settings: &OpticastSettings,
516    sky_color: u32,
517    sky: Option<&Sky>,
518) -> RenderOutcome {
519    render_scene_composed_scissored(
520        fb,
521        zb,
522        pitch_pixels,
523        width,
524        height,
525        fog,
526        scene,
527        camera,
528        settings,
529        sky_color,
530        sky,
531        true,
532        None,
533        &[],
534        CpuLights::default(),
535        None,
536    )
537}
538
539/// [`render_scene_composed`] with TV terrain materials: `materials` is the
540/// global palette and `terrain_materials` the colour→material map; together
541/// they make matching-colour terrain voxels translucent (front-to-back
542/// composited). An empty map / `None` palette renders identically to
543/// [`render_scene_composed`].
544#[allow(clippy::too_many_arguments)]
545pub fn render_scene_composed_with_materials(
546    fb: &mut [u32],
547    zb: &mut [f32],
548    pitch_pixels: usize,
549    width: u32,
550    height: u32,
551    fog: CpuFog,
552    scene: &mut Scene,
553    camera: &Camera,
554    settings: &OpticastSettings,
555    sky_color: u32,
556    sky: Option<&Sky>,
557    materials: Option<&MaterialTable>,
558    terrain_materials: &[(u32, u8)],
559    lights: CpuLights<'_>,
560    // XS.2 — sprite-cast shadow occluder (so sprites darken terrain). `None` ⇒
561    // grids-only shadows.
562    sprite_occluder: Option<&dyn WorldOccluder>,
563) -> RenderOutcome {
564    render_scene_composed_scissored(
565        fb,
566        zb,
567        pitch_pixels,
568        width,
569        height,
570        fog,
571        scene,
572        camera,
573        settings,
574        sky_color,
575        sky,
576        true,
577        materials,
578        terrain_materials,
579        lights,
580        sprite_occluder,
581    )
582}
583
584/// Backing implementation of [`render_scene_composed`] with the
585/// per-grid screen-AABB scissor toggleable. `scissor = true` is the
586/// production path; the regression test renders the same scene with
587/// `false` (full-frame per grid, the pre-scissor behaviour) and asserts
588/// the framebuffer is byte-identical — the scissor must be a pure
589/// speed-up, never change a pixel.
590#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
591fn render_scene_composed_scissored(
592    fb: &mut [u32],
593    zb: &mut [f32],
594    pitch_pixels: usize,
595    width: u32,
596    height: u32,
597    fog: CpuFog,
598    scene: &mut Scene,
599    camera: &Camera,
600    settings: &OpticastSettings,
601    sky_color: u32,
602    sky: Option<&Sky>,
603    scissor: bool,
604    materials: Option<&MaterialTable>,
605    terrain_materials: &[(u32, u8)],
606    // CPU.1 — world-space dynamic lights, transformed per grid in the loop.
607    lights: CpuLights<'_>,
608    // XS.2 — world-space occluder for sprite volumes (so sprites cast shadows
609    // onto terrain). Composited with the per-frame grid occluder. `None` ⇒
610    // grids only.
611    sprite_occluder: Option<&dyn WorldOccluder>,
612) -> RenderOutcome {
613    debug_assert_eq!(fb.len(), zb.len());
614    let pixel_count = (width as usize) * (height as usize);
615    debug_assert_eq!(fb.len(), pixel_count);
616
617    let mut grids_drawn = 0usize;
618    let mut temp_fb = vec![sky_color; pixel_count];
619    let mut temp_zb = vec![f32::INFINITY; pixel_count];
620
621    // XS.1 — phase A (`&mut`): materialise the per-frame caches the render
622    // reads — DDA brick caches (Near/Mid) and Far-tier billboard impostors —
623    // and record each grid's effective DDA mip. Hoisting these out of the
624    // render loop lets phase B run over `&Scene` immutably, so the cross-grid
625    // shadow occluder (which also borrows the scene) can coexist with it.
626    let cam_world = DVec3::from_array(camera.pos);
627    let mut eff_mips: HashMap<GridId, u32> = HashMap::new();
628    for (id, grid) in scene.grids_mut() {
629        let lod = grid.select_lod(cam_world);
630        if lod == Lod::Far {
631            if !grid.chunks.is_empty() && grid.billboards.is_none() {
632                let cache = BillboardCache::build(grid, BILLBOARD_RESOLUTION);
633                grid.billboards = Some(cache);
634            }
635            continue; // Far blits an impostor; no brick cache / mip needed.
636        }
637        let req = match lod {
638            Lod::Mid => grid
639                .lod_thresholds
640                .mid_mip_levels
641                .map_or(0, |n| n.saturating_sub(1)),
642            Lod::Near | Lod::Far => 0,
643        };
644        eff_mips.insert(id, grid.ensure_dda_bricks(req));
645    }
646
647    // Reborrow immutably for phase B + the shadow occluder.
648    let scene: &Scene = scene;
649
650    // XS.1 — cross-grid hard shadows: build the world-space scene occluder
651    // once when shadows are actually active (a caster flagged + non-zero
652    // strength), so the shadow ray at a terrain hit tests every grid, not
653    // just the one it hit. `None` ⇒ the single-grid `SamplerShadow` path.
654    let shadows_on = lights.enabled
655        && lights.shadow_strength > 0.0
656        && (lights.sun_casts_shadow || lights.points.iter().any(|p| p.casts_shadow));
657    let grid_occ = shadows_on
658        .then(|| SceneOccluder::build(scene))
659        .filter(|o| !o.is_empty());
660    // XS.2 — combine the grid occluder with the sprite occluder (sprites cast
661    // onto terrain). `composite_store` backs the borrow when both are present.
662    let composite_store;
663    let active_occluder: Option<&dyn WorldOccluder> = if shadows_on {
664        match (grid_occ.as_ref(), sprite_occluder) {
665            (Some(g), Some(s)) => {
666                composite_store = CompositeOccluder { a: g, b: s };
667                Some(&composite_store)
668            }
669            (Some(g), None) => Some(g),
670            (None, Some(s)) => Some(s),
671            (None, None) => None,
672        }
673    } else {
674        None
675    };
676
677    for (grid_id, grid) in scene.grids() {
678        // S6.0/S6.1: per-grid LOD tier dispatch. The picker keys
679        // off the grid's `lod_thresholds` and the world-space
680        // camera. Default thresholds are `always_near` so every
681        // grid lands on `Lod::Near` and the framebuffer stays
682        // byte-identical to the pre-S6 path.
683        //
684        // S6.1: `Mid` applies the grid's `mid_mip_levels` /
685        // `mid_mip_scan_dist` overrides (if `Some`) on top of the
686        // base settings, biasing the grid into coarser mips. With
687        // both `None`, Mid renders identically to Near (graceful
688        // degrade — callers opt into the Mid plumbing via
689        // `LodThresholds::from_radius_with_mid_mip`).
690        //
691        // S6.3: `Far` skips the opticast path entirely — render
692        // dispatches into the billboard impostor blit (below). The
693        // LOD enum is computed before `chunk_xyz_backing` because
694        // the Far branch needs `&mut grid` for the lazy cache
695        // populate, which conflicts with the `&grid` lifetime
696        // backing's tied to.
697        let lod = grid.select_lod(DVec3::from_array(camera.pos));
698
699        if lod == Lod::Far {
700            // S6.3: Far-tier billboard blit. The impostor cache was built in
701            // phase A (above); this immutable pass only reads it.
702            //
703            // Empty grids have nothing to impostor; skip.
704            if grid.chunks.is_empty() {
705                continue;
706            }
707            // Grid bounds + world-space centre. Rotation preserves
708            // length, so `bounds.radius` is the world-space radius.
709            let bounds = billboard::grid_bounds(grid);
710            let centre_world = grid.transform.origin + grid.transform.rotation * bounds.centre;
711            // Query direction = unit vector from grid centre TO
712            // camera, in grid-local space (snapshots' `view_dir`s
713            // live in that frame).
714            let cam_pos = DVec3::from_array(camera.pos);
715            let centre_to_cam_world = cam_pos - centre_world;
716            let ctc_len = centre_to_cam_world.length();
717            if !ctc_len.is_finite() || ctc_len < 1e-9 {
718                // Camera essentially at grid centre — pick_nearest
719                // is ill-defined. Skip; a future frame at a
720                // resolvable pose will render normally.
721                continue;
722            }
723            let query_dir_world = centre_to_cam_world / ctc_len;
724            let query_dir_local = grid.transform.rotation.inverse() * query_dir_world;
725            // Cache was populated in phase A for non-empty Far grids; if it's
726            // somehow absent, skip (a future frame re-enters Far and builds).
727            let Some(cache) = grid.billboards.as_ref() else {
728                continue;
729            };
730            // pick_nearest only returns None for empty caches; the phase-A
731            // build produced a 26-snapshot cache so this resolves.
732            let Some(snapshot) = cache.pick_nearest(query_dir_local) else {
733                continue;
734            };
735            billboard::billboard_blit_into(
736                fb,
737                zb,
738                pitch_pixels,
739                width,
740                height,
741                snapshot,
742                centre_world,
743                bounds.radius,
744                camera,
745                settings,
746            );
747            grids_drawn += 1;
748            continue;
749        }
750
751        // S4B.2.e: Approach B render path. See `render_scene`'s
752        // body for the camera transform + ChunkGrid construction
753        // commentary; the only difference is this writes to
754        // (temp_fb, temp_zb) and composes via `compose_into`.
755        // S5.0: per-grid rotation flows via the shared helper.
756        //
757        // DDA.7: refresh the cross-frame brick cache (needs `&mut grid`)
758        // before the immutable `backing` borrow. Render mip by LOD tier:
759        // Near = full detail, Mid = coarser (clamped to built mips).
760        // Mid tier: coarsen by the grid's `mid_mip_levels` override
761        // (a level count → uniform DDA mip `n-1`). No override ⇒ mip
762        // 0, i.e. byte-identical to Near (the override is opt-in).
763        // Effective DDA mip: the brick cache was ensured in phase A; reuse the
764        // mip it resolved (Near/Mid grids are recorded; default 0 otherwise).
765        let dda_eff_mip = eff_mips.get(&grid_id).copied().unwrap_or(0);
766        let Some(backing) = grid.chunk_xyz_backing() else {
767            continue;
768        };
769
770        // Out-of-range early-out: skip the per-grid opticast pass
771        // when the grid's bounding sphere is entirely beyond
772        // `max_scan_dist`. Each opticast call walks ~width*height
773        // rays even when no ray reaches a voxel, so far-away marker
774        // pillars / pickups otherwise cost ~9 ms each at the bench
775        // pose. Safe: if the closest point of the sphere is past
776        // max_scan_dist, no ray can possibly reach the grid, so
777        // dropping the opticast pass is byte-identical.
778        //
779        // `grid_bounds` walks `grid.chunks.keys()`; for the ground's
780        // ~1024 chunks it costs ~10 µs amortised against the ~50 ms
781        // it might save by culling 4-of-5 markers in the live demo.
782        let bounds = billboard::grid_bounds(grid);
783        let centre_world = grid.transform.origin + grid.transform.rotation * bounds.centre;
784        let cam_pos = DVec3::from_array(camera.pos);
785        let dist_to_centre = (centre_world - cam_pos).length();
786        if dist_to_centre - bounds.radius > f64::from(settings.max_scan_dist) {
787            continue;
788        }
789
790        // Per-grid screen-space scissor: confine this grid's opticast +
791        // temp reset + compose to the vertical band its projection spans
792        // (full width), and skip the grid entirely when it projects fully
793        // off-screen on EITHER axis. `project_sphere_to_screen` is
794        // conservative (over-estimates the footprint), so the rendered
795        // pixels stay byte-identical to the full-frame path — only the
796        // work shrinks. The horizontal extent is used for the lateral
797        // cull but NOT to clip opticast: the radar's column-indexed
798        // `angstart` isn't reset per grid, so x-clipping reads stale
799        // entries at extreme poses (a crash reproduced at the `z=-19`
800        // pose — same angular-projection fragility that closed the
801        // cf-narrowing investigation). `None` (camera inside/near the
802        // sphere) renders full-frame. `scissor = false` disables it all
803        // for the regression test.
804        let full_rect = ScreenRect {
805            x0: 0,
806            x1: width,
807            y0: 0,
808            y1: height,
809        };
810        let rect = if scissor {
811            match project_sphere_to_screen(camera, centre_world, bounds.radius, settings) {
812                // Off-screen on either axis → the grid can't appear.
813                Some(r) if r.is_empty() => continue,
814                // Vertical band only (full width); lateral extent already
815                // served the cull above.
816                Some(r) => ScreenRect {
817                    x0: 0,
818                    x1: width,
819                    y0: r.y0,
820                    y1: r.y1,
821                },
822                None => full_rect,
823            }
824        } else {
825            full_rect
826        };
827
828        // S5.2-followup: per-grid sky opt-out. Grids with
829        // `render_sky = false` (e.g. a rotating ship) must not
830        // contribute sky pixels — the grid-local sky lookup
831        // rotates with the grid and visibly fights the world's
832        // sky during compose. Implementation: stamp a sentinel
833        // colour into temp_fb everywhere the rasterizer would
834        // paint sky, then walk the buffer post-opticast and
835        // mark sentinel pixels as `INFINITY` in temp_zb so
836        // [`compose_into`]'s min-z test always drops them.
837        let owns_sky = grid.render_sky;
838        let local_sky_color = if owns_sky {
839            sky_color
840        } else {
841            SKY_MASK_SENTINEL
842        };
843
844        // Reset temp to sky / INFINITY so each grid starts fresh —
845        // only within the grid's screen rect (opticast writes nothing
846        // outside it, and the rect-limited compose reads nothing there).
847        fill_rect_u32(&mut temp_fb, pitch_pixels, rect, local_sky_color);
848        fill_rect_f32(&mut temp_zb, pitch_pixels, rect, f32::INFINITY);
849
850        let local_cam = world_camera_to_grid_local(camera, &grid.transform);
851        let cg = roxlap_core::ChunkGrid {
852            chunks: &backing.chunks,
853            origin_chunk_xy: backing.origin_chunk_xy,
854            origin_chunk_z: backing.origin_chunk_z,
855            chunks_x: backing.chunks_x,
856            chunks_y: backing.chunks_y,
857            chunks_z: backing.chunks_z,
858        };
859        let grid_view = single_chunk_fast_path(&backing, &cg);
860
861        // Build the per-grid settings by layering three opt-in
862        // overrides on top of the caller's `settings`:
863        //
864        //   1. (S6.1) `lod_thresholds.mid_mip_levels` /
865        //      `mid_mip_scan_dist` — applied iff `lod == Mid`.
866        //      Biases the grid into coarser mips via the existing
867        //      multi-mip path. None ⇒ Mid degrades to Near's
868        //      settings (graceful).
869        //   2. (S5.2-followup) `Grid::mip_levels_override` — global
870        //      per-grid cap applied at ALL tiers. Preserves the
871        //      ship anti-axis-aligned-beam workaround through Mid
872        //      tier (so a rotating ship pinned at mip-0 stays at
873        //      mip-0 even when distant).
874        //
875        // Layer order: Mid overrides first, then global cap. Both
876        // mip_levels overrides are clamped to `[1, base.mip_levels]`
877        // since the base is the maximum the renderer can use
878        // (chunk's `chunk_mips`-min logic inside scalar_rasterizer
879        // applies further per-chunk).
880        let per_grid_settings;
881        let active_settings = {
882            let base_mip_levels = settings.mip_levels;
883            let base_mip_scan = settings.mip_scan_dist;
884            let lod_mip_levels = match lod {
885                Lod::Mid => grid.lod_thresholds.mid_mip_levels,
886                Lod::Near | Lod::Far => None,
887            };
888            let lod_mip_scan = match lod {
889                Lod::Mid => grid.lod_thresholds.mid_mip_scan_dist,
890                Lod::Near | Lod::Far => None,
891            };
892            let global_mip_cap = grid.mip_levels_override;
893            let needs_override =
894                lod_mip_levels.is_some() || lod_mip_scan.is_some() || global_mip_cap.is_some();
895            if needs_override {
896                // Resolve mip_levels: start with base, apply LOD
897                // override (clamped to base), then apply global cap.
898                let mut mip_levels =
899                    lod_mip_levels.map_or(base_mip_levels, |n| n.clamp(1, base_mip_levels));
900                if let Some(cap) = global_mip_cap {
901                    mip_levels = mip_levels.min(cap.clamp(1, base_mip_levels));
902                }
903                // Resolve mip_scan_dist: LOD override clamps to
904                // `min(base, override)` — the override only makes
905                // transitions kick in CLOSER, never farther. The
906                // renderer floors at 4 internally so we don't
907                // bottom-clamp here.
908                let mip_scan_dist = lod_mip_scan.map_or(base_mip_scan, |d| base_mip_scan.min(d));
909                per_grid_settings = OpticastSettings {
910                    mip_levels,
911                    mip_scan_dist,
912                    ..*settings
913                };
914                &per_grid_settings
915            } else {
916                settings
917            }
918        };
919
920        // Vertical scissor: restrict the render to the grid's screen
921        // y-band (full width). `rect` is always full-width here (see
922        // the rect computation), so this is the proven `y_start /
923        // y_end` strip path — byte-identical to the full frame when
924        // the band is `0..height`. The lateral cull happened above;
925        // a horizontal opticast scissor is unsafe (the radar's
926        // column-indexed `angstart` isn't reset per grid, so column
927        // clipping reads stale entries at extreme poses — see the
928        // `cpu-grid-scissor` memo).
929        let scissored = (*active_settings).with_y_range(rect.y0, rect.y1);
930        // DDA backend. temp_fb / temp_zb are already pre-filled with
931        // sky / INFINITY for this grid's rect, so a miss with no
932        // textured sky yields the correct solid sky.
933        //
934        // Fog is config-driven: on iff the caller set `max_scan_dist > 0`
935        // in `fog`. Off → no blend, so exact-colour tests and unfogged
936        // hosts are unaffected. Linear ramp toward the configured fog
937        // colour over `max_scan_dist`. Sky texture is suppressed for
938        // `!owns_sky` grids so the textured-sky branch doesn't bypass
939        // the sentinel.
940        let fog_on = fog.max_scan_dist > 0;
941        // CPU.1 — transform the world lights into this grid's local frame
942        // (point scratch lives for the grid's render below).
943        let mut light_scratch: Vec<CpuPointLight> = Vec::new();
944        let local_lights = grid_local_lights(&lights, &grid.transform, &mut light_scratch);
945        // XS.1 — cross-grid shadows: hand the shade the scene-wide occluder
946        // plus this grid's local→world transform, so a grid-local shadow ray
947        // is lifted to world space and tested against every grid. `cols[i]`
948        // is the world image of grid-local axis `i` (the rotation's columns).
949        let world_shadow = active_occluder.map(|occ| {
950            let r = grid.transform.rotation;
951            let col = |v: DVec3| {
952                let w = r * v;
953                [w.x as f32, w.y as f32, w.z as f32]
954            };
955            let o = grid.transform.origin;
956            WorldShadowCtx {
957                occluder: occ,
958                origin: [o.x as f32, o.y as f32, o.z as f32],
959                cols: [col(DVec3::X), col(DVec3::Y), col(DVec3::Z)],
960            }
961        });
962        #[allow(clippy::cast_precision_loss)]
963        let env = DdaEnv {
964            sky: if owns_sky { sky } else { None },
965            fog_color: if fog_on { fog.color } else { 0 },
966            fog_max_dist: if fog_on {
967                fog.max_scan_dist.max(1) as f32
968            } else {
969                0.0
970            },
971            side_shades: fog.side_shades,
972            materials,
973            terrain_materials,
974            lights: local_lights,
975            world_shadow,
976        };
977        // Effective render mip + brick cache were prepared above
978        // (DDA.6 uniform per-grid mip, DDA.7 cross-frame cache).
979        render_dda_parallel(
980            &local_cam,
981            &scissored,
982            grid_view,
983            &mut temp_fb,
984            &mut temp_zb,
985            pitch_pixels,
986            &env,
987            &grid.dda_brick_cache,
988            dda_eff_mip,
989        );
990
991        if !owns_sky {
992            // Mask sentinel pixels so compose drops them — only within
993            // the grid's rect (opticast wrote nothing outside it).
994            for y in rect.y0..rect.y1 {
995                let row = y as usize * pitch_pixels;
996                for i in row + rect.x0 as usize..row + rect.x1 as usize {
997                    if temp_fb[i] == SKY_MASK_SENTINEL {
998                        temp_zb[i] = f32::INFINITY;
999                    }
1000                }
1001            }
1002        }
1003
1004        compose_rect(fb, zb, &temp_fb, &temp_zb, pitch_pixels, rect);
1005        grids_drawn += 1;
1006    }
1007
1008    if grids_drawn == 0 {
1009        RenderOutcome::Empty
1010    } else {
1011        RenderOutcome::Rendered { grids_drawn }
1012    }
1013}
1014
1015#[cfg(test)]
1016#[allow(clippy::float_cmp)]
1017mod tests {
1018    use super::*;
1019    use crate::{GridTransform, Scene, CHUNK_SIZE_XY};
1020    use glam::{DVec3, IVec3};
1021    use roxlap_core::opticast::OpticastSettings;
1022    use roxlap_core::{Camera, Engine};
1023
1024    const XRES: u32 = 320;
1025    const YRES: u32 = 200;
1026
1027    /// Build a single-grid scene at the given world origin with a
1028    /// recognisable shape inside its chunk (0, 0, 0): a 16-voxel
1029    /// box plus a 6-radius sphere. Returns `(scene, grid_id)`.
1030    fn build_one_grid_scene(world_origin: DVec3) -> (Scene, crate::GridId) {
1031        let mut scene = Scene::new();
1032        let id = scene.add_grid(GridTransform::at(world_origin));
1033        let grid = scene.grid_mut(id).unwrap();
1034        // Box covering [40..56]³ in chunk-local coords.
1035        grid.set_rect(
1036            IVec3::new(40, 40, 40),
1037            IVec3::new(55, 55, 55),
1038            Some(0x80_88_88_88),
1039        );
1040        // Sphere at (80, 80, 80) radius 6.
1041        grid.set_sphere(IVec3::new(80, 80, 80), 6, Some(0x80_22_aa_22));
1042        (scene, id)
1043    }
1044
1045    fn camera_at(pos: [f64; 3]) -> Camera {
1046        // Look +y axis; voxlap z-down convention. Right-handed:
1047        // right × down == forward.
1048        Camera {
1049            pos,
1050            right: [-1.0, 0.0, 0.0],
1051            down: [0.0, 0.0, 1.0],
1052            forward: [0.0, 1.0, 0.0],
1053        }
1054    }
1055
1056    /// Spin up an engine + framebuffers ready for one `render_scene`
1057    /// pass. `_pool_vsid` is retained for call-site compatibility but
1058    /// the DDA backend needs no pre-sized scratch pool.
1059    fn render_setup(_pool_vsid: u32) -> (Engine, Vec<u32>, Vec<f32>) {
1060        let engine = Engine::new();
1061        let sky = engine.sky_color();
1062        let pixel_count = (XRES as usize) * (YRES as usize);
1063        let framebuffer = vec![sky; pixel_count];
1064        let zbuffer = vec![0.0f32; pixel_count];
1065        (engine, framebuffer, zbuffer)
1066    }
1067
1068    /// Render `scene` via [`render_scene`] (single-grid no-compose
1069    /// path) and return the resulting framebuffer.
1070    fn render_via_scene(scene: &mut Scene, camera: &Camera) -> Vec<u32> {
1071        let (_engine, mut fb, mut zb) = render_setup(CHUNK_SIZE_XY);
1072        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1073        let outcome = render_scene(
1074            &mut fb,
1075            &mut zb,
1076            XRES as usize,
1077            XRES,
1078            YRES,
1079            CpuFog::default(),
1080            scene,
1081            camera,
1082            &settings,
1083            None,
1084        );
1085        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1086        fb
1087    }
1088
1089    /// XS.1 — cross-grid hard shadows: a block in grid **B** casts a sun
1090    /// shadow onto the floor of grid **A**. Renders the two-grid scene with
1091    /// the sun shadow-casting vs not; the shadow only exists if the shadow
1092    /// ray from A's floor crossed into B, so the shadowed render must be
1093    /// strictly (and non-trivially) darker.
1094    #[test]
1095    fn cross_grid_sun_shadow_darkens_other_grid() {
1096        // Grid A: a wide floor at world z∈[60,62]. Grid B (same origin): a
1097        // 10-tall block at x∈[50,60]. Sun grazes from +x and above, so B's
1098        // shadow lands on A's floor at x≈[40,50] — visible to a straight-down
1099        // camera (B itself occludes only x∈[50,60]).
1100        let mut scene = Scene::new();
1101        let a = scene.add_grid(GridTransform::at(DVec3::ZERO));
1102        scene.grid_mut(a).unwrap().set_rect(
1103            IVec3::new(30, 30, 60),
1104            IVec3::new(90, 90, 62),
1105            Some(0x80_88_88_88),
1106        );
1107        let b = scene.add_grid(GridTransform::at(DVec3::ZERO));
1108        scene.grid_mut(b).unwrap().set_rect(
1109            IVec3::new(50, 50, 40),
1110            IVec3::new(60, 60, 50),
1111            Some(0x80_60_60_60),
1112        );
1113
1114        // Straight-down camera over the floor (voxlap z-down ⇒ forward +z).
1115        let cam = Camera {
1116            pos: [55.0, 55.0, 6.0],
1117            right: [1.0, 0.0, 0.0],
1118            down: [0.0, 1.0, 0.0],
1119            forward: [0.0, 0.0, 1.0],
1120        };
1121        let inv = 1.0f32 / 2.0f32.sqrt();
1122        let base = CpuLights {
1123            enabled: true,
1124            sun: true,
1125            sun_dir: [inv, 0.0, -inv], // to-sun: +x and up
1126            sun_color: [1.0; 3],
1127            sun_intensity: 1.0,
1128            ambient: [0.3; 3],
1129            shadow_strength: 0.85,
1130            shadow_bias: 1.5,
1131            shadow_max_dist: 128.0,
1132            ..CpuLights::default()
1133        };
1134        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1135        let mut sum_lum = |lights: CpuLights| -> u64 {
1136            let n = (XRES as usize) * (YRES as usize);
1137            let mut fb = vec![0u32; n];
1138            let mut zb = vec![f32::INFINITY; n];
1139            render_scene_composed_scissored(
1140                &mut fb,
1141                &mut zb,
1142                XRES as usize,
1143                XRES,
1144                YRES,
1145                CpuFog::default(),
1146                &mut scene,
1147                &cam,
1148                &settings,
1149                0x0011_2233,
1150                None,
1151                false,
1152                None,
1153                &[],
1154                lights,
1155                None,
1156            );
1157            fb.iter()
1158                .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
1159                .sum()
1160        };
1161        let lit = sum_lum(CpuLights {
1162            sun_casts_shadow: false,
1163            ..base
1164        });
1165        let shadowed = sum_lum(CpuLights {
1166            sun_casts_shadow: true,
1167            ..base
1168        });
1169        assert!(
1170            shadowed < lit,
1171            "B's shadow must darken A's floor: shadowed={shadowed} lit={lit}"
1172        );
1173        assert!(
1174            (lit - shadowed) * 200 > lit,
1175            "cross-grid shadow should remove >0.5% of total luminance: lit={lit} shadowed={shadowed}"
1176        );
1177    }
1178
1179    // ---- S5.0: world_camera_to_grid_local helper ----
1180
1181    /// Identity rotation: pos translates by `-origin`; basis is
1182    /// untouched. This is the byte-identical-to-pre-S5 contract.
1183    #[test]
1184    fn world_camera_to_grid_local_identity_rotation_translates_pos_only() {
1185        let camera = Camera {
1186            pos: [110.0, 220.0, 330.0],
1187            right: [1.0, 0.0, 0.0],
1188            down: [0.0, 0.0, 1.0],
1189            forward: [0.0, 1.0, 0.0],
1190        };
1191        let transform = GridTransform::at(DVec3::new(100.0, 200.0, 300.0));
1192        let local = super::world_camera_to_grid_local(&camera, &transform);
1193        // Basis must be bit-for-bit unchanged for the identity case.
1194        assert_eq!(local.right, camera.right);
1195        assert_eq!(local.down, camera.down);
1196        assert_eq!(local.forward, camera.forward);
1197        // Pos translates by `-origin`.
1198        for (got, want) in local.pos.iter().zip([10.0, 20.0, 30.0].iter()) {
1199            assert!((got - want).abs() < 1e-12, "pos got={got} want={want}");
1200        }
1201    }
1202
1203    /// 90° rotation about +Z: grid-local `+x` aligns with world `+y`.
1204    /// World camera at `(0, 10, 0)` looking world `+y` lives in
1205    /// grid-local at `(10, 0, 0)` looking grid-local `+x`.
1206    #[test]
1207    fn world_camera_to_grid_local_90deg_z_rotates_basis_and_pos() {
1208        use glam::DQuat;
1209        let camera = Camera {
1210            pos: [0.0, 10.0, 0.0],
1211            right: [1.0, 0.0, 0.0],
1212            down: [0.0, 0.0, 1.0],
1213            forward: [0.0, 1.0, 0.0],
1214        };
1215        let transform = GridTransform {
1216            origin: DVec3::ZERO,
1217            rotation: DQuat::from_rotation_z(std::f64::consts::FRAC_PI_2),
1218        };
1219        let local = super::world_camera_to_grid_local(&camera, &transform);
1220        // World +y == grid-local +x.
1221        let approx_eq =
1222            |a: [f64; 3], b: [f64; 3]| a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-9);
1223        assert!(
1224            approx_eq(local.pos, [10.0, 0.0, 0.0]),
1225            "pos={:?} expected ~(10, 0, 0)",
1226            local.pos
1227        );
1228        // World +x (right) maps to grid-local -y.
1229        assert!(
1230            approx_eq(local.right, [0.0, -1.0, 0.0]),
1231            "right={:?} expected ~(0, -1, 0)",
1232            local.right
1233        );
1234        // World +z (down) is unchanged — it's the rotation axis.
1235        assert!(
1236            approx_eq(local.down, [0.0, 0.0, 1.0]),
1237            "down={:?} expected ~(0, 0, 1)",
1238            local.down
1239        );
1240        // World +y (forward) maps to grid-local +x.
1241        assert!(
1242            approx_eq(local.forward, [1.0, 0.0, 0.0]),
1243            "forward={:?} expected ~(1, 0, 0)",
1244            local.forward
1245        );
1246    }
1247
1248    /// Basis orthonormality + handedness both survive the
1249    /// inverse-rotation transform. Property: any unit-quaternion
1250    /// conjugation preserves the input basis's orthonormality AND
1251    /// its handedness (rotations are orientation-preserving).
1252    #[test]
1253    fn world_camera_to_grid_local_preserves_basis_orthonormality() {
1254        use glam::DQuat;
1255        // Right-handed voxlap basis (`right × down == forward`):
1256        // looking +y, right = -x makes the cross product land on +y.
1257        let camera = Camera {
1258            pos: [3.0, -5.0, 7.0],
1259            right: [-1.0, 0.0, 0.0],
1260            down: [0.0, 0.0, 1.0],
1261            forward: [0.0, 1.0, 0.0],
1262        };
1263        let transform = GridTransform {
1264            origin: DVec3::new(1.0, 2.0, 3.0),
1265            rotation: DQuat::from_axis_angle(glam::DVec3::new(0.3, 0.8, 0.5).normalize(), 0.7),
1266        };
1267        let local = super::world_camera_to_grid_local(&camera, &transform);
1268        let r = DVec3::from_array(local.right);
1269        let d = DVec3::from_array(local.down);
1270        let f = DVec3::from_array(local.forward);
1271        // Norms ≈ 1.
1272        for v in [r, d, f] {
1273            assert!(
1274                (v.length_squared() - 1.0).abs() < 1e-12,
1275                "basis vec {v:?} not unit length"
1276            );
1277        }
1278        // Orthogonality.
1279        assert!(r.dot(d).abs() < 1e-12, "right·down = {}", r.dot(d));
1280        assert!(r.dot(f).abs() < 1e-12, "right·forward = {}", r.dot(f));
1281        assert!(d.dot(f).abs() < 1e-12, "down·forward = {}", d.dot(f));
1282        // Right-handed: right × down == forward (voxlap convention).
1283        let cross = r.cross(d);
1284        assert!(
1285            (cross - f).length() < 1e-12,
1286            "right×down={cross:?} forward={f:?}"
1287        );
1288    }
1289
1290    // ---- S5.1: rotated-grid render correctness ----
1291
1292    /// Build a single-grid scene at the given transform with a
1293    /// marker box near one corner of chunk (0, 0, 0). Returns the
1294    /// scene and the marker colour. Picking a single chunk + small
1295    /// box keeps the test compact while still exercising the gline
1296    /// + grouscan path through the rotated frame.
1297    fn build_one_grid_marker_scene(transform: GridTransform) -> (Scene, crate::GridId, u32) {
1298        let mut scene = Scene::new();
1299        let id = scene.add_grid(transform);
1300        let grid = scene.grid_mut(id).unwrap();
1301        // Bright marker box at chunk-local (40..56, 40..56, 40..56).
1302        grid.set_rect(
1303            IVec3::new(40, 40, 40),
1304            IVec3::new(55, 55, 55),
1305            Some(0x80_55_aa_22), // distinctive green
1306        );
1307        (scene, id, 0x80_55_aa_22)
1308    }
1309
1310    /// Pin S5.1's central equivalence: rotating both the grid and the
1311    /// camera by the SAME rotation around the grid's origin must
1312    /// leave the rendered framebuffer unchanged — the grid-local
1313    /// camera pose collapses to the same values in both scenarios.
1314    ///
1315    /// We use `DQuat::from_xyzw(0.0, 0.0, 1.0, 0.0)`, the
1316    /// 180°-around-Z unit quaternion. This rotation acts on vectors
1317    /// as `(x, y, z) → (-x, -y, z)`, which only multiplies f64
1318    /// components by 0 or ±1 — bit-exact under glam's standard quat
1319    /// conjugation formula. Other angles (e.g. 90°) would introduce
1320    /// sub-1e-15 noise from sin/cos, breaking byte-identity at
1321    /// chunk / voxel boundaries.
1322    #[test]
1323    fn s5_1_180deg_z_rotated_grid_byte_identical_to_axis_aligned() {
1324        use glam::DQuat;
1325        // Right-handed voxlap basis (right × down == forward).
1326        let axis_aligned_camera = Camera {
1327            pos: [40.0, -20.0, 50.0],
1328            right: [-1.0, 0.0, 0.0],
1329            down: [0.0, 0.0, 1.0],
1330            forward: [0.0, 1.0, 0.0],
1331        };
1332        // R_z(180°): (x, y, z) → (-x, -y, z).
1333        let rotated_camera = Camera {
1334            pos: [-40.0, 20.0, 50.0],
1335            right: [1.0, 0.0, 0.0],
1336            down: [0.0, 0.0, 1.0],
1337            forward: [0.0, -1.0, 0.0],
1338        };
1339        // Sanity: prove the exact-arithmetic rotation lands on the
1340        // baseline. If glam ever changes its quat*vec formula in a
1341        // way that loses exactness here, the next two assertions
1342        // catch it before the framebuffer comparison.
1343        let q = DQuat::from_xyzw(0.0, 0.0, 1.0, 0.0);
1344        let rot_pos = q * DVec3::from_array(axis_aligned_camera.pos);
1345        let rot_fwd = q * DVec3::from_array(axis_aligned_camera.forward);
1346        assert_eq!(rot_pos.to_array(), rotated_camera.pos);
1347        assert_eq!(rot_fwd.to_array(), rotated_camera.forward);
1348
1349        let (mut scene_a, _, _) = build_one_grid_marker_scene(GridTransform::identity());
1350        let fb_a = render_via_scene(&mut scene_a, &axis_aligned_camera);
1351
1352        let (mut scene_b, _, _) = build_one_grid_marker_scene(GridTransform {
1353            origin: DVec3::ZERO,
1354            rotation: q,
1355        });
1356        let fb_b = render_via_scene(&mut scene_b, &rotated_camera);
1357
1358        assert_eq!(
1359            fb_a, fb_b,
1360            "rotating both grid and camera by R about the grid origin must leave the framebuffer unchanged"
1361        );
1362    }
1363
1364    /// 45° smoke test: rotated grid renders to something non-trivial
1365    /// without panicking. No equivalence assertion (45° quat math is
1366    /// approximate at f64 level; that path is exercised structurally,
1367    /// not bit-exactly). Camera is placed at a fixed world pose where
1368    /// — under the rotation — the marker box stays inside the view
1369    /// frustum.
1370    #[test]
1371    fn s5_1_45deg_z_rotated_grid_renders_marker() {
1372        use glam::DQuat;
1373        let rotation = DQuat::from_rotation_z(std::f64::consts::FRAC_PI_4);
1374        let (mut scene, _, marker) = build_one_grid_marker_scene(GridTransform {
1375            origin: DVec3::ZERO,
1376            rotation,
1377        });
1378
1379        // World position of the marker's centre. Grid-local
1380        // (47.5, 47.5, 47.5) → world `rotation * (47.5, 47.5, 47.5)`.
1381        // R_z(45°): (47.5, 47.5, 47.5) → (0, 67.18, 47.5) (the x/y
1382        // components combine into a single +y vector at √2 * 47.5).
1383        let marker_world = rotation * DVec3::new(47.5, 47.5, 47.5);
1384        // Camera 80 units south of the marker on the world Y axis,
1385        // looking +y at the same z. RH basis.
1386        let camera = Camera {
1387            pos: [marker_world.x, marker_world.y - 80.0, marker_world.z],
1388            right: [-1.0, 0.0, 0.0],
1389            down: [0.0, 0.0, 1.0],
1390            forward: [0.0, 1.0, 0.0],
1391        };
1392
1393        let (_engine, mut fb, mut zb) = render_setup(CHUNK_SIZE_XY);
1394        let fog = CpuFog::default();
1395        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1396        let outcome = render_scene(
1397            &mut fb,
1398            &mut zb,
1399            XRES as usize,
1400            XRES,
1401            YRES,
1402            fog,
1403            &mut scene,
1404            &camera,
1405            &settings,
1406            None,
1407        );
1408        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1409        let marker_count = fb.iter().filter(|&&p| p == marker).count();
1410        assert!(
1411            marker_count > 50,
1412            "45°-rotated marker box should be visible — got {marker_count} marker pixels"
1413        );
1414    }
1415
1416    // ---- S5.2-followup: per-grid render_sky opt-out ----
1417
1418    /// Two-grid scene where grid B sits behind grid A along +y;
1419    /// grid A is opaque only in the centre of the framebuffer, so
1420    /// the camera's view through grid A is mostly "ray miss". When
1421    /// `A.render_sky = false`, the pixels around A's silhouette
1422    /// must remain whatever grid B (or the shared pre-fill)
1423    /// painted — NOT A's grid-local sky colour. This pins the
1424    /// sentinel-mask path: without it, A's sky would write into
1425    /// the composed framebuffer wherever its sky-z happened to win
1426    /// the min-z race with B's sky-z.
1427    #[test]
1428    fn render_sky_false_drops_grid_sky_pixels() {
1429        use crate::{GridId, GridTransform};
1430
1431        // Grid B (far, sky owner) — a wide floor of distinct
1432        // colour spanning chunk-local x/y so most rays land on it.
1433        let mut scene = Scene::new();
1434        let _b_id: GridId = scene.add_grid(GridTransform::at(DVec3::new(0.0, 600.0, 0.0)));
1435        // Find grid B's id (HashMap iteration; we only just added
1436        // one grid, so its id is whichever the iterator yields).
1437        let b_id = scene.grids().next().unwrap().0;
1438        scene.grid_mut(b_id).unwrap().set_rect(
1439            IVec3::new(0, 0, 100),
1440            IVec3::new(127, 127, 110),
1441            Some(0x80_22_88_22), // green floor
1442        );
1443
1444        // Grid A (near, sky disabled) — a SMALL marker box that
1445        // covers only a fraction of the screen. Most pixels of A's
1446        // local render are sky.
1447        let a_id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
1448        scene.grid_mut(a_id).unwrap().set_rect(
1449            IVec3::new(60, 60, 60),
1450            IVec3::new(67, 67, 67),
1451            Some(0x80_aa_22_22), // red cube
1452        );
1453        scene.grid_mut(a_id).unwrap().render_sky = false;
1454
1455        let unique_sky: u32 = 0xFF_AB_CD_EF;
1456        let (_engine, fog, _) = make_composed_pool(CHUNK_SIZE_XY);
1457        let mut fb = vec![unique_sky; pixel_count(XRES, YRES)];
1458        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1459        let camera = camera_at([64.0, 0.0, 100.0]);
1460        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1461        let outcome = render_scene_composed(
1462            &mut fb,
1463            &mut zb,
1464            XRES as usize,
1465            XRES,
1466            YRES,
1467            fog,
1468            &mut scene,
1469            &camera,
1470            &settings,
1471            unique_sky,
1472            None,
1473        );
1474        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
1475
1476        // The sentinel must never appear in the composed output —
1477        // every sentinel pixel must have been masked out before
1478        // compose. If any leak through, the test catches it.
1479        let leaked = fb
1480            .iter()
1481            .filter(|&&p| p == super::SKY_MASK_SENTINEL)
1482            .count();
1483        assert_eq!(
1484            leaked, 0,
1485            "SKY_MASK_SENTINEL leaked into composed framebuffer ({leaked} pixels)"
1486        );
1487        // Grid A's hit (red cube) must still render — render_sky=false
1488        // only affects sky pixels, not hits.
1489        let red_count = fb.iter().filter(|&&p| p == 0x80_aa_22_22).count();
1490        assert!(
1491            red_count > 0,
1492            "red cube from sky-disabled grid A is missing — render_sky=false should only mask sky"
1493        );
1494        // Grid B's floor must be visible past grid A's silhouette
1495        // (the sky-disabled grid doesn't hide B's render).
1496        let green_count = fb.iter().filter(|&&p| p == 0x80_22_88_22).count();
1497        assert!(
1498            green_count > 0,
1499            "grid B's floor invisible — grid A's masked sky may have overwritten it"
1500        );
1501    }
1502
1503    /// Identity-rotation, single-grid scene with `render_sky = false`
1504    /// must produce a sentinel-free framebuffer. Sanity test for the
1505    /// trivial 1-grid case (no second grid to compose against).
1506    #[test]
1507    fn render_sky_false_single_grid_no_sentinel_leak() {
1508        let (mut scene, id, _) = build_one_grid_marker_scene(GridTransform::identity());
1509        scene.grid_mut(id).unwrap().render_sky = false;
1510        let unique_sky: u32 = 0xFF_12_34_56;
1511        let (_engine, fog, _) = make_composed_pool(CHUNK_SIZE_XY);
1512        let mut fb = vec![unique_sky; pixel_count(XRES, YRES)];
1513        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1514        let camera = camera_at([64.0, 0.0, 64.0]);
1515        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1516        let outcome = render_scene_composed(
1517            &mut fb,
1518            &mut zb,
1519            XRES as usize,
1520            XRES,
1521            YRES,
1522            fog,
1523            &mut scene,
1524            &camera,
1525            &settings,
1526            unique_sky,
1527            None,
1528        );
1529        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1530        let leaked = fb
1531            .iter()
1532            .filter(|&&p| p == super::SKY_MASK_SENTINEL)
1533            .count();
1534        assert_eq!(leaked, 0, "SKY_MASK_SENTINEL leaked ({leaked} pixels)");
1535        // Pixels that would have been the grid's sky now show
1536        // through to the pre-fill (unique_sky).
1537        let prefill_count = fb.iter().filter(|&&p| p == unique_sky).count();
1538        assert!(
1539            prefill_count > 0,
1540            "no pre-fill pixels survived — render_sky=false should leave non-hit pixels untouched"
1541        );
1542    }
1543
1544    // DDA.9: `render_scene_at_origin_matches_direct_opticast` and
1545    // `render_scene_translated_grid_matches_grid_local_opticast` were
1546    // removed — they asserted the scene render byte-matches voxlap
1547    // `opticast`, which no longer holds now that the scene's CPU backend
1548    // is the DDA renderer (different, intentionally non-bit-exact). The
1549    // grid-local camera transform they also exercised is covered by the
1550    // `stacked_*` / two-grid composition tests below.
1551
1552    #[test]
1553    fn empty_scene_returns_empty_outcome() {
1554        let mut scene = Scene::new();
1555        let (_engine, mut fb, mut zb) = render_setup(CHUNK_SIZE_XY);
1556        let fog = CpuFog::default();
1557        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1558        let outcome = render_scene(
1559            &mut fb,
1560            &mut zb,
1561            XRES as usize,
1562            XRES,
1563            YRES,
1564            fog,
1565            &mut scene,
1566            &camera_at([0.0, 0.0, 0.0]),
1567            &settings,
1568            None,
1569        );
1570        assert_eq!(outcome, RenderOutcome::Empty);
1571    }
1572
1573    // ---- S3.1 / S4.0: render_scene_composed + 2-grid composition ----
1574
1575    /// Build a 2-grid scene with two distinguishable boxes placed
1576    /// side-by-side in world space along the camera's right axis.
1577    /// Each grid holds one chunk (`(0, 0, 0)`) containing a single
1578    /// 16-voxel box with a uniquely-coloured surface so the
1579    /// composited framebuffer is partitionable by colour.
1580    fn build_two_grid_side_by_side() -> (Scene, u32, u32) {
1581        let mut scene = Scene::new();
1582        // Grid 0 at world (0, 200, 0): box centred chunk-local (64, 64, 100).
1583        let g0 = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
1584        scene.grid_mut(g0).unwrap().set_rect(
1585            IVec3::new(56, 56, 92),
1586            IVec3::new(71, 71, 107),
1587            Some(0x80_88_22_22), // dark red
1588        );
1589        // Grid 1 at world (200, 200, 0): box centred chunk-local (64, 64, 100).
1590        let _g1 = scene.add_grid(GridTransform::at(DVec3::new(200.0, 200.0, 0.0)));
1591        // Borrow-checker dance: re-borrow grid 1 mutably.
1592        let g1_id = scene
1593            .grids()
1594            .filter(|(id, _)| *id != g0)
1595            .map(|(id, _)| id)
1596            .next()
1597            .unwrap();
1598        scene.grid_mut(g1_id).unwrap().set_rect(
1599            IVec3::new(56, 56, 92),
1600            IVec3::new(71, 71, 107),
1601            Some(0x80_22_22_88), // dark blue
1602        );
1603        (scene, 0x80_88_22_22, 0x80_22_22_88)
1604    }
1605
1606    /// Engine + default (off) fog config + sky colour for the
1607    /// composed-render tests. `_pool_vsid` retained for call-site
1608    /// compatibility; the DDA backend needs no scratch pool.
1609    fn make_composed_pool(_pool_vsid: u32) -> (Engine, CpuFog, u32) {
1610        let engine = Engine::new();
1611        let sky_color = engine.sky_color();
1612        (engine, CpuFog::default(), sky_color)
1613    }
1614
1615    fn pixel_count(width: u32, height: u32) -> usize {
1616        (width as usize) * (height as usize)
1617    }
1618
1619    #[test]
1620    fn compose_into_takes_smaller_z() {
1621        let mut shared_fb = vec![0xff_ff_ff_ff_u32; 4];
1622        let mut shared_zb = vec![10.0f32; 4];
1623        let temp_fb = [0xaa_aa_aa_aa, 0x11_22_33_44, 0x55_66_77_88, 0xde_ad_be_ef];
1624        let temp_zb = [5.0f32, 20.0, 10.0, f32::INFINITY];
1625        compose_into(&mut shared_fb, &mut shared_zb, &temp_fb, &temp_zb);
1626        // i=0: 5 < 10 → take temp.
1627        assert_eq!(shared_fb[0], 0xaa_aa_aa_aa);
1628        assert_eq!(shared_zb[0], 5.0);
1629        // i=1: 20 > 10 → keep shared.
1630        assert_eq!(shared_fb[1], 0xff_ff_ff_ff);
1631        assert_eq!(shared_zb[1], 10.0);
1632        // i=2: 10 == 10 → keep shared (`<` not `<=`).
1633        assert_eq!(shared_fb[2], 0xff_ff_ff_ff);
1634        // i=3: INFINITY > 10 → keep shared.
1635        assert_eq!(shared_fb[3], 0xff_ff_ff_ff);
1636    }
1637
1638    #[test]
1639    fn render_scene_composed_two_grids_both_visible() {
1640        // Camera positioned to see both grids' boxes. Grid 0's box
1641        // at world (~64, ~264, ~100); grid 1's box at world
1642        // (~264, ~264, ~100). Camera at world (160, 100, 100)
1643        // looking +y centres both in view.
1644        let (mut scene, red, blue) = build_two_grid_side_by_side();
1645        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1646        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1647        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1648
1649        let camera = camera_at([160.0, 100.0, 100.0]);
1650        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1651        let outcome = render_scene_composed(
1652            &mut fb,
1653            &mut zb,
1654            XRES as usize,
1655            XRES,
1656            YRES,
1657            fog,
1658            &mut scene,
1659            &camera,
1660            &settings,
1661            sky_color,
1662            None,
1663        );
1664        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
1665
1666        // Both colours should appear somewhere in the framebuffer.
1667        let red_count = fb.iter().filter(|&&p| p == red).count();
1668        let blue_count = fb.iter().filter(|&&p| p == blue).count();
1669        assert!(
1670            red_count > 0,
1671            "no red pixels: grid 0 (red box) not visible after compose"
1672        );
1673        assert!(
1674            blue_count > 0,
1675            "no blue pixels: grid 1 (blue box) not visible after compose"
1676        );
1677    }
1678
1679    /// The per-grid screen scissor (vertical band + lateral/vertical
1680    /// off-screen cull + rect-limited memory passes) must be a pure
1681    /// speed-up: rendering a multi-grid scene with it on
1682    /// (`render_scene_composed`) must produce a **byte-identical**
1683    /// framebuffer to rendering each grid full-frame
1684    /// (`scissor = false`). Includes a third grid placed off the left
1685    /// edge but within scan distance, so the lateral cull (scissor on)
1686    /// vs a sky-only full render (scissor off) must still agree pixel
1687    /// for pixel.
1688    #[test]
1689    fn scissor_render_is_byte_identical_to_full_frame() {
1690        let (mut scene, red, blue) = build_two_grid_side_by_side();
1691        // Third grid far to the +x side at the camera's depth: within
1692        // max_scan_dist (so the distance cull doesn't fire) but its box
1693        // projects off the left screen edge → screen-culled with the
1694        // scissor, sky-only when rendered full-frame.
1695        let g2 = scene.add_grid(GridTransform::at(DVec3::new(700.0, 130.0, 0.0)));
1696        let g2_id = scene
1697            .grids()
1698            .map(|(id, _)| id)
1699            .max_by_key(|id| id.raw())
1700            .unwrap();
1701        let _ = g2;
1702        scene.grid_mut(g2_id).unwrap().set_rect(
1703            IVec3::new(56, 56, 92),
1704            IVec3::new(71, 71, 107),
1705            Some(0x80_22_88_22), // green — must never appear (off-screen)
1706        );
1707
1708        let camera = camera_at([160.0, 100.0, 100.0]);
1709        let render = |scene: &mut Scene, scissor: bool| -> Vec<u32> {
1710            let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1711            let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1712            let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1713            let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1714            render_scene_composed_scissored(
1715                &mut fb,
1716                &mut zb,
1717                XRES as usize,
1718                XRES,
1719                YRES,
1720                fog,
1721                scene,
1722                &camera,
1723                &settings,
1724                sky_color,
1725                None,
1726                scissor,
1727                None,
1728                &[],
1729                CpuLights::default(),
1730                None,
1731            );
1732            fb
1733        };
1734
1735        let scissored = render(&mut scene, true);
1736        let full = render(&mut scene, false);
1737        assert_eq!(
1738            scissored, full,
1739            "the screen scissor changed the framebuffer — it must be a pure speed-up",
1740        );
1741        // Sanity: the scene actually drew content (not a vacuous all-sky
1742        // match), and the off-screen green grid never appears.
1743        assert!(scissored.iter().any(|&p| p == red || p == blue));
1744        assert!(
1745            !scissored.contains(&0x80_22_88_22),
1746            "off-screen grid leaked pixels",
1747        );
1748    }
1749
1750    #[test]
1751    fn render_scene_composed_grid_a_in_front_of_grid_b() {
1752        // Two grids stacked along +y so grid A (closer) occludes
1753        // grid B (farther). After composition only grid A's colour
1754        // should appear on the overlap.
1755        let mut scene = Scene::new();
1756        let g_a = scene.add_grid(GridTransform::at(DVec3::new(0.0, 50.0, 0.0)));
1757        scene.grid_mut(g_a).unwrap().set_rect(
1758            IVec3::new(56, 56, 92),
1759            IVec3::new(71, 71, 107),
1760            Some(0x80_aa_00_00), // red
1761        );
1762        let _g_b = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
1763        let g_b_id = scene
1764            .grids()
1765            .filter(|(id, _)| *id != g_a)
1766            .map(|(id, _)| id)
1767            .next()
1768            .unwrap();
1769        scene.grid_mut(g_b_id).unwrap().set_rect(
1770            IVec3::new(56, 56, 92),
1771            IVec3::new(71, 71, 107),
1772            Some(0x80_00_00_aa), // blue
1773        );
1774
1775        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1776        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1777        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1778
1779        // Camera at (64, -10, 100) looking +y — both boxes line up
1780        // along the camera's forward axis.
1781        let camera = camera_at([64.0, -10.0, 100.0]);
1782        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1783        let outcome = render_scene_composed(
1784            &mut fb,
1785            &mut zb,
1786            XRES as usize,
1787            XRES,
1788            YRES,
1789            fog,
1790            &mut scene,
1791            &camera,
1792            &settings,
1793            sky_color,
1794            None,
1795        );
1796        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
1797
1798        // Red (closer grid) should be visible. Blue (farther grid)
1799        // may peek around the edges but the central pixels should
1800        // be red where both boxes project.
1801        let red_count = fb.iter().filter(|&&p| p == 0x80_aa_00_00).count();
1802        assert!(
1803            red_count > 0,
1804            "expected red pixels (closer box should win z-test)"
1805        );
1806
1807        // Reverse the registration order (force grid B drawn first)
1808        // and verify that's irrelevant — composition is commutative.
1809        let mut scene2 = Scene::new();
1810        let g_b2 = scene2.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
1811        scene2.grid_mut(g_b2).unwrap().set_rect(
1812            IVec3::new(56, 56, 92),
1813            IVec3::new(71, 71, 107),
1814            Some(0x80_00_00_aa),
1815        );
1816        let g_a2 = scene2.add_grid(GridTransform::at(DVec3::new(0.0, 50.0, 0.0)));
1817        scene2.grid_mut(g_a2).unwrap().set_rect(
1818            IVec3::new(56, 56, 92),
1819            IVec3::new(71, 71, 107),
1820            Some(0x80_aa_00_00),
1821        );
1822
1823        let mut fb2 = vec![sky_color; pixel_count(XRES, YRES)];
1824        let mut zb2 = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1825        let outcome2 = render_scene_composed(
1826            &mut fb2,
1827            &mut zb2,
1828            XRES as usize,
1829            XRES,
1830            YRES,
1831            fog,
1832            &mut scene2,
1833            &camera,
1834            &settings,
1835            sky_color,
1836            None,
1837        );
1838        assert_eq!(outcome2, RenderOutcome::Rendered { grids_drawn: 2 });
1839        assert_eq!(
1840            fb, fb2,
1841            "composition should be order-independent — same scene in different add order should produce identical output"
1842        );
1843    }
1844
1845    // ---- S6.1: Mid-tier mip overrides ----
1846
1847    /// Build a multi-mip-friendly grid: solid floor spanning the
1848    /// whole chunk at z=100..254 + `generate_mips(3)`. This is the
1849    /// same setup `vxl_generate_mips_on_set_voxel_chunk_renders`
1850    /// uses and is known to render at `mip_levels = 3,
1851    /// mip_scan_dist = 32`.
1852    ///
1853    /// Returns `(scene, grid_id)`. The Mid test sets the camera
1854    /// inside the chunk so chunk-local rays reach the floor at
1855    /// short distances; that lets the Mid override use
1856    /// `mip_scan_dist = 16` without busting the ray budget
1857    /// (`mip_scan_dist * 2^(mip_levels-1) = 16 * 4 = 64` covers the
1858    /// distance from camera to floor).
1859    fn build_mip_visible_grid(world_origin: DVec3) -> (Scene, crate::GridId) {
1860        let mut scene = Scene::new();
1861        let id = scene.add_grid(GridTransform::at(world_origin));
1862        let grid = scene.grid_mut(id).unwrap();
1863        // Solid floor across the entire chunk at z=100..254.
1864        grid.set_rect(
1865            IVec3::new(0, 0, 100),
1866            IVec3::new(127, 127, 254),
1867            Some(0x80_88_88_88),
1868        );
1869        // Build the per-chunk mip ladder so `gmipnum` can grow past 1.
1870        grid.chunk_mut(IVec3::ZERO).unwrap().generate_mips(3);
1871        (scene, id)
1872    }
1873
1874    /// Render `scene` via composed path with `mip_levels = 3,
1875    /// mip_scan_dist = 32` — same values the working
1876    /// `vxl_generate_mips_on_set_voxel_chunk_renders` test uses.
1877    /// Returns the framebuffer.
1878    fn render_with_multi_mip(scene: &mut Scene, camera: &Camera) -> Vec<u32> {
1879        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1880        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1881        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1882        let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1883        settings.mip_levels = 3;
1884        settings.mip_scan_dist = 32;
1885        let outcome = render_scene_composed(
1886            &mut fb,
1887            &mut zb,
1888            XRES as usize,
1889            XRES,
1890            YRES,
1891            fog,
1892            scene,
1893            camera,
1894            &settings,
1895            sky_color,
1896            None,
1897        );
1898        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1899        fb
1900    }
1901
1902    // DDA.9: `s6_1_mid_overrides_produce_different_framebuffer_than_near`
1903    // was removed. It encoded voxlap's mip-*transition* semantics
1904    // (mid_mip_levels=Some(1) caps in-grid mip transitions, differing
1905    // from Near's mip0→1→2 distance ramp). The DDA renderer uses a
1906    // *uniform* per-grid mip (no in-grid transition), so Some(1) → mip 0
1907    // = identical to Near. DDA mip coarsening is covered by
1908    // `roxlap_core::dda` `mip_render_is_coarse_but_complete`; the LOD-Mid
1909    // wiring by `s6_1_mid_without_overrides_byte_identical_to_near`.
1910
1911    /// Mid tier with `mid_mip_levels = None` AND
1912    /// `mid_mip_scan_dist = None` must produce a byte-identical
1913    /// framebuffer to Near. This is the graceful-degrade contract
1914    /// — callers can opt into the Mid plumbing without committing
1915    /// to a mip override and stay byte-stable.
1916    #[test]
1917    fn s6_1_mid_without_overrides_byte_identical_to_near() {
1918        let camera = camera_at([64.0, 0.0, 64.0]);
1919
1920        // Scene A: default thresholds → Near.
1921        let (mut scene_a, _) = build_mip_visible_grid(DVec3::ZERO);
1922        let fb_near = render_with_multi_mip(&mut scene_a, &camera);
1923
1924        // Scene B: thresholds force Mid but no mip overrides set.
1925        let (mut scene_b, b_id) = build_mip_visible_grid(DVec3::ZERO);
1926        scene_b.grid_mut(b_id).unwrap().lod_thresholds = crate::LodThresholds {
1927            r_near: 0.0,
1928            r_mid: f64::INFINITY,
1929            mid_mip_levels: None,
1930            mid_mip_scan_dist: None,
1931        };
1932        let lod = scene_b
1933            .grid(b_id)
1934            .unwrap()
1935            .select_lod(DVec3::from_array(camera.pos));
1936        assert_eq!(lod, Lod::Mid);
1937        let fb_mid = render_with_multi_mip(&mut scene_b, &camera);
1938
1939        // Byte-identical: Mid with no overrides degrades cleanly.
1940        assert_eq!(
1941            fb_near, fb_mid,
1942            "Mid with both overrides=None must byte-match Near"
1943        );
1944    }
1945
1946    // DDA.9: `s6_1_global_mip_cap_survives_mid_tier` was removed. It
1947    // pinned voxlap's `mip_levels_override` global cap composing with the
1948    // Mid override — the ship anti-axis-aligned-beam workaround. The DDA
1949    // renderer has no axis-aligned mip beam (honest per-cell traversal),
1950    // so the workaround / global cap is obsolete and the DDA path doesn't
1951    // consult `mip_levels_override`.
1952
1953    // ---- S6.3: Far-tier billboard blit ----
1954
1955    /// Force Far tier via `r_near = 0, r_mid = 0`: any non-zero
1956    /// camera-to-grid distance lands on `Lod::Far`. Renders a small
1957    /// grid at world (0, 200, 0) with default-radius thresholds
1958    /// turned all-Far. The composed framebuffer must contain
1959    /// non-sky pixels from the impostor blit.
1960    #[test]
1961    fn s6_3_far_tier_blits_non_sky_pixels() {
1962        let (mut scene, id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
1963        scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
1964            r_near: 0.0,
1965            r_mid: 0.0,
1966            mid_mip_levels: None,
1967            mid_mip_scan_dist: None,
1968        };
1969
1970        let camera = camera_at([64.0, 0.0, 100.0]);
1971        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
1972        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
1973        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
1974        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1975        let outcome = render_scene_composed(
1976            &mut fb,
1977            &mut zb,
1978            XRES as usize,
1979            XRES,
1980            YRES,
1981            fog,
1982            &mut scene,
1983            &camera,
1984            &settings,
1985            sky_color,
1986            None,
1987        );
1988        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1989
1990        // Sanity: picker actually picked Far.
1991        let lod = scene
1992            .grid(id)
1993            .unwrap()
1994            .select_lod(DVec3::from_array(camera.pos));
1995        assert_eq!(lod, Lod::Far);
1996
1997        // Impostor must paint at least some non-sky pixels.
1998        let non_sky = fb.iter().filter(|&&p| p != sky_color).count();
1999        assert!(
2000            non_sky > 0,
2001            "Far-tier render produced no non-sky pixels — billboard blit not firing"
2002        );
2003    }
2004
2005    /// Lazy populate: cache starts `None`, becomes `Some` after the
2006    /// first Far render.
2007    #[test]
2008    fn s6_3_far_render_lazily_populates_cache() {
2009        let (mut scene, id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
2010        scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
2011            r_near: 0.0,
2012            r_mid: 0.0,
2013            mid_mip_levels: None,
2014            mid_mip_scan_dist: None,
2015        };
2016        assert!(scene.grid(id).unwrap().billboards.is_none());
2017
2018        let camera = camera_at([64.0, 0.0, 100.0]);
2019        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2020        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2021        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2022        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2023        let _ = render_scene_composed(
2024            &mut fb,
2025            &mut zb,
2026            XRES as usize,
2027            XRES,
2028            YRES,
2029            fog,
2030            &mut scene,
2031            &camera,
2032            &settings,
2033            sky_color,
2034            None,
2035        );
2036        let cache = scene
2037            .grid(id)
2038            .unwrap()
2039            .billboards
2040            .as_ref()
2041            .expect("Far render should have populated billboards");
2042        assert_eq!(cache.len(), 26);
2043    }
2044
2045    /// Edit invalidates the cache; a subsequent Far render rebuilds.
2046    #[test]
2047    fn s6_3_edit_invalidates_then_far_render_rebuilds() {
2048        let (mut scene, id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
2049        scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
2050            r_near: 0.0,
2051            r_mid: 0.0,
2052            mid_mip_levels: None,
2053            mid_mip_scan_dist: None,
2054        };
2055        let camera = camera_at([64.0, 0.0, 100.0]);
2056        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2057        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2058
2059        // First Far render → cache built.
2060        let mut fb1 = vec![sky_color; pixel_count(XRES, YRES)];
2061        let mut zb1 = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2062        let _ = render_scene_composed(
2063            &mut fb1,
2064            &mut zb1,
2065            XRES as usize,
2066            XRES,
2067            YRES,
2068            fog,
2069            &mut scene,
2070            &camera,
2071            &settings,
2072            sky_color,
2073            None,
2074        );
2075        assert!(scene.grid(id).unwrap().billboards.is_some());
2076
2077        // Edit invalidates.
2078        scene
2079            .grid_mut(id)
2080            .unwrap()
2081            .set_voxel(IVec3::new(70, 70, 70), Some(0x80_aa_aa_22));
2082        assert!(scene.grid(id).unwrap().billboards.is_none());
2083
2084        // Second Far render rebuilds.
2085        let mut fb2 = vec![sky_color; pixel_count(XRES, YRES)];
2086        let mut zb2 = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2087        let _ = render_scene_composed(
2088            &mut fb2,
2089            &mut zb2,
2090            XRES as usize,
2091            XRES,
2092            YRES,
2093            fog,
2094            &mut scene,
2095            &camera,
2096            &settings,
2097            sky_color,
2098            None,
2099        );
2100        assert!(scene.grid(id).unwrap().billboards.is_some());
2101    }
2102
2103    /// Hybrid scene: one Near grid + one Far grid. Both must render
2104    /// visibly; the Far grid via blit, the Near grid via opticast.
2105    /// Sanity check that the two paths cohabit one
2106    /// `render_scene_composed` call.
2107    #[test]
2108    fn s6_3_near_and_far_grids_in_same_scene() {
2109        let mut scene = Scene::new();
2110        // Grid A: stays Near (default thresholds). Solid box at
2111        // world (-30..-20, 190..210, 50..70).
2112        let a_id = scene.add_grid(GridTransform::at(DVec3::new(-100.0, 200.0, 0.0)));
2113        scene.grid_mut(a_id).unwrap().set_rect(
2114            IVec3::new(70, 0, 50),
2115            IVec3::new(85, 15, 70),
2116            Some(0x80_22_88_22), // green
2117        );
2118        // Grid B: forced Far. Box at world (~100, 200, 100).
2119        let b_id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 200.0, 0.0)));
2120        scene.grid_mut(b_id).unwrap().set_rect(
2121            IVec3::new(0, 0, 80),
2122            IVec3::new(20, 20, 110),
2123            Some(0x80_aa_22_22), // red
2124        );
2125        scene.grid_mut(b_id).unwrap().lod_thresholds = crate::LodThresholds {
2126            r_near: 0.0,
2127            r_mid: 0.0,
2128            mid_mip_levels: None,
2129            mid_mip_scan_dist: None,
2130        };
2131
2132        let camera = camera_at([0.0, 0.0, 80.0]);
2133        // Confirm A is Near, B is Far for this pose.
2134        assert_eq!(
2135            scene
2136                .grid(a_id)
2137                .unwrap()
2138                .select_lod(DVec3::from_array(camera.pos)),
2139            Lod::Near
2140        );
2141        assert_eq!(
2142            scene
2143                .grid(b_id)
2144                .unwrap()
2145                .select_lod(DVec3::from_array(camera.pos)),
2146            Lod::Far
2147        );
2148
2149        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2150        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2151        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2152        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2153        let outcome = render_scene_composed(
2154            &mut fb,
2155            &mut zb,
2156            XRES as usize,
2157            XRES,
2158            YRES,
2159            fog,
2160            &mut scene,
2161            &camera,
2162            &settings,
2163            sky_color,
2164            None,
2165        );
2166        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
2167
2168        // Each grid should contribute visible pixels.
2169        let non_sky = fb.iter().filter(|&&p| p != sky_color).count();
2170        assert!(
2171            non_sky > 20,
2172            "hybrid scene produced too few non-sky pixels ({non_sky}); one tier may have failed"
2173        );
2174    }
2175
2176    /// Empty grid at Far tier: skipped silently (no panic, no
2177    /// allocation), `billboards` stays `None`.
2178    #[test]
2179    fn s6_3_empty_grid_at_far_is_skipped() {
2180        let mut scene = Scene::new();
2181        let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 200.0, 0.0)));
2182        scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
2183            r_near: 0.0,
2184            r_mid: 0.0,
2185            mid_mip_levels: None,
2186            mid_mip_scan_dist: None,
2187        };
2188
2189        let camera = camera_at([0.0, 0.0, 100.0]);
2190        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2191        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2192        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2193        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2194        let outcome = render_scene_composed(
2195            &mut fb,
2196            &mut zb,
2197            XRES as usize,
2198            XRES,
2199            YRES,
2200            fog,
2201            &mut scene,
2202            &camera,
2203            &settings,
2204            sky_color,
2205            None,
2206        );
2207        // No grids contributed.
2208        assert_eq!(outcome, RenderOutcome::Empty);
2209        // Cache must NOT have been built for an empty grid.
2210        assert!(scene.grid(id).unwrap().billboards.is_none());
2211        // Framebuffer unchanged.
2212        assert!(fb.iter().all(|&p| p == sky_color));
2213    }
2214
2215    // ---- S6.0: LOD picker wired but every tier falls through to Near ----
2216
2217    /// Threshold-invariance: a grid rendered with the S6 derived
2218    /// thresholds (`from_radius` of the actual bounding sphere) must
2219    /// produce a framebuffer byte-identical to the same grid with
2220    /// default `always_near` thresholds, because S6.0 takes the
2221    /// `Near` arm of the match for all three tiers. This is the
2222    /// regression test for the S6.0 contract.
2223    #[test]
2224    fn render_scene_composed_lod_threshold_invariance() {
2225        // Scene A: default thresholds (always_near).
2226        let (mut scene_a, _a_id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
2227        let cam = camera_at([64.0, 0.0, 100.0]);
2228        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2229        let mut fb_a = vec![sky_color; pixel_count(XRES, YRES)];
2230        let mut zb_a = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2231        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2232        let outcome_a = render_scene_composed(
2233            &mut fb_a,
2234            &mut zb_a,
2235            XRES as usize,
2236            XRES,
2237            YRES,
2238            fog,
2239            &mut scene_a,
2240            &cam,
2241            &settings,
2242            sky_color,
2243            None,
2244        );
2245        assert_eq!(outcome_a, RenderOutcome::Rendered { grids_drawn: 1 });
2246
2247        // Scene B: thresholds derived from the grid's bounding
2248        // radius. At this camera distance the grid lands on Mid or
2249        // Far; if S6.0 ever stops falling through to Near, this test
2250        // catches the divergence.
2251        let (mut scene_b, b_id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
2252        let radius = scene_b.grid(b_id).unwrap().bounding_radius();
2253        assert!(
2254            radius > 0.0,
2255            "bounding_radius should be > 0 for a populated grid"
2256        );
2257        scene_b.grid_mut(b_id).unwrap().lod_thresholds = crate::LodThresholds::from_radius(radius);
2258        // Sanity: the camera is far enough that the picker no longer
2259        // returns Near (otherwise the invariance test would be vacuous).
2260        let lod = scene_b
2261            .grid(b_id)
2262            .unwrap()
2263            .select_lod(DVec3::from_array(cam.pos));
2264        assert_ne!(
2265            lod,
2266            Lod::Near,
2267            "camera should land in Mid or Far for derived thresholds — got {lod:?}",
2268        );
2269
2270        let mut fb_b = vec![sky_color; pixel_count(XRES, YRES)];
2271        let mut zb_b = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2272        let outcome_b = render_scene_composed(
2273            &mut fb_b,
2274            &mut zb_b,
2275            XRES as usize,
2276            XRES,
2277            YRES,
2278            fog,
2279            &mut scene_b,
2280            &cam,
2281            &settings,
2282            sky_color,
2283            None,
2284        );
2285        assert_eq!(outcome_b, RenderOutcome::Rendered { grids_drawn: 1 });
2286
2287        // Byte-identity is the S6.0 contract — Mid/Far still take
2288        // the Near arm.
2289        assert_eq!(
2290            fb_a, fb_b,
2291            "S6.0 framebuffer must be byte-identical regardless of LOD thresholds"
2292        );
2293    }
2294
2295    #[test]
2296    fn render_scene_composed_empty_scene_returns_empty() {
2297        let mut scene = Scene::new();
2298        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2299        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2300        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2301        let camera = camera_at([0.0, 0.0, 0.0]);
2302        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2303        let outcome = render_scene_composed(
2304            &mut fb,
2305            &mut zb,
2306            XRES as usize,
2307            XRES,
2308            YRES,
2309            fog,
2310            &mut scene,
2311            &camera,
2312            &settings,
2313            sky_color,
2314            None,
2315        );
2316        assert_eq!(outcome, RenderOutcome::Empty);
2317        // fb should be unchanged (still all sky).
2318        assert!(fb.iter().all(|&p| p == sky_color));
2319    }
2320
2321    /// FNV-1a 64-bit hash. Same offset/prime as the
2322    /// `roxlap-oracle::fnv1a64` helper used by the wasm-render
2323    /// goldens; pinning a render hash here is the same flavour of
2324    /// regression catch.
2325    fn fnv1a64(data: &[u8]) -> u64 {
2326        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
2327        for &b in data {
2328            h ^= u64::from(b);
2329            h = h.wrapping_mul(0x0000_0100_0000_01b3);
2330        }
2331        h
2332    }
2333
2334    // ---- S4.0 cross-chunk smoke test ----
2335
2336    /// Two-chunk-wide grid: a recognisable shape spans the chunk
2337    /// boundary at `virtual_x = 128`. The render must not have a
2338    /// horizontal seam line at the boundary.
2339    #[test]
2340    fn render_scene_two_chunk_x_grid_no_seam() {
2341        let mut scene = Scene::new();
2342        let id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
2343        let g = scene.grid_mut(id).unwrap();
2344        // 100-voxel-tall stripe spanning x=[120..136] across the
2345        // x=128 chunk seam at z=200, y=[60..68]. After bake-free
2346        // render, every column in the stripe paints the same colour
2347        // at the same z; a seam at x=128 would show as missing
2348        // pixels in the column at virtual_x=128 / 129 / ...
2349        g.set_rect(
2350            IVec3::new(120, 60, 200),
2351            IVec3::new(136, 67, 215),
2352            Some(0x80_aa_55_22),
2353        );
2354        // Sanity: ensure both chunks were materialised.
2355        assert_eq!(g.chunk_count(), 2);
2356
2357        // Render with a camera positioned to look at the stripe
2358        // straight on. Stripe at world (120..136, 260..268, 200..215).
2359        // Camera at (128, 100, 207) looking +y centres on it.
2360        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
2361        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2362        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2363        let camera = camera_at([128.0, 100.0, 207.0]);
2364        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2365        let outcome = render_scene_composed(
2366            &mut fb,
2367            &mut zb,
2368            XRES as usize,
2369            XRES,
2370            YRES,
2371            fog,
2372            &mut scene,
2373            &camera,
2374            &settings,
2375            sky_color,
2376            None,
2377        );
2378        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2379
2380        // Stripe colour should appear in roughly the centre of the
2381        // framebuffer. A chunk-edge seam would manifest as a thin
2382        // sky-coloured vertical line splitting the stripe in two.
2383        let stripe = 0x80_aa_55_22;
2384        let stripe_count = fb.iter().filter(|&&p| p == stripe).count();
2385        assert!(
2386            stripe_count > 200,
2387            "stripe rendered too few pixels ({stripe_count}) — chunks may not be stitching"
2388        );
2389
2390        // Walk the centre row left-to-right looking for a sky-pixel
2391        // gap inside a stripe run. A gap 1+ pixels wide flags a
2392        // chunk-edge seam.
2393        let centre_y = (YRES / 2) as usize;
2394        let row_start = centre_y * (XRES as usize);
2395        let row = &fb[row_start..row_start + (XRES as usize)];
2396        let mut in_stripe = false;
2397        let mut seam_gaps = 0usize;
2398        for &px in row {
2399            if px == stripe {
2400                in_stripe = true;
2401            } else if in_stripe && px == sky_color {
2402                // Stripe ended; if we re-enter it on this row that's
2403                // a seam.
2404                if row.iter().skip_while(|&&p| p != px).any(|&p| p == stripe) {
2405                    // Look ahead for any further stripe pixel.
2406                    seam_gaps += 1;
2407                }
2408                in_stripe = false;
2409            }
2410        }
2411        // We allow seam_gaps to count the legitimate "stripe ended,
2412        // didn't restart" transition once; more than that means
2413        // multiple disjoint runs on the row → seam.
2414        assert!(
2415            seam_gaps <= 1,
2416            "centre row has {seam_gaps} disjoint stripe runs — expected 1 (chunk-edge seam suspected)"
2417        );
2418    }
2419
2420    // DDA.9: the voxlap-era mip regression tests here
2421    // (`vxl_generate_mips_on_set_voxel_chunk_renders` + the byte-exact
2422    // 2-chunk opticast pin) were removed — they drove voxlap `opticast` +
2423    // `ScalarRasterizer` directly, a path no longer reachable from this
2424    // consumer crate. The DDA mip ladder + multi-mip render is covered by
2425    // `render_with_mips_present_still_renders_mip0` and the
2426    // `stacked_*_multi_mip` tests below.
2427
2428    /// Mip-0 preservation when mips are generated on the combined
2429    /// view but `mip_levels = 1` in the rasterizer's settings.
2430    /// Confirms `generate_mips` only APPENDS data — mip-0
2431    /// prefix is unchanged.
2432    #[test]
2433    fn render_with_mips_present_still_renders_mip0() {
2434        let mut scene = Scene::new();
2435        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2436        scene.grid_mut(id).unwrap().set_rect(
2437            IVec3::new(40, 40, 40),
2438            IVec3::new(55, 55, 55),
2439            Some(0x80_88_88_88),
2440        );
2441        // S4B.4.a: force mip-1..mip-2 generation on the single
2442        // chunk directly (the Grid's combined-view cache API was
2443        // removed). The chunk's own Vxl::generate_mips builds its
2444        // own mip tables and the renderer happens to render through
2445        // them via Approach B's chunk_at_xy lookup.
2446        {
2447            let grid = scene.grid_mut(id).unwrap();
2448            let chunk = grid.chunks.get_mut(&IVec3::ZERO).unwrap();
2449            chunk.generate_mips(3);
2450        }
2451
2452        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2453        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2454        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2455        let camera = camera_at([64.0, 0.0, 64.0]);
2456        // mip_scan_dist huge → renderer never transitions past mip-0
2457        // so this test pins mip-0 correctness only.
2458        let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2459        settings.mip_scan_dist = 100_000;
2460        let outcome = render_scene_composed(
2461            &mut fb,
2462            &mut zb,
2463            XRES as usize,
2464            XRES,
2465            YRES,
2466            fog,
2467            &mut scene,
2468            &camera,
2469            &settings,
2470            sky_color,
2471            None,
2472        );
2473        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2474        let non_sky = fb.iter().filter(|&&p| p != sky_color).count();
2475        assert!(
2476            non_sky > 0,
2477            "render of single-grid scene with mips present rendered all-sky: mip-0 may be corrupted by generate_mips"
2478        );
2479    }
2480
2481    #[test]
2482    fn render_scene_two_chunk_x_grid_hash_is_stable() {
2483        // Frozen 2026-05-10 at S4.0 landing on x86_64.
2484        // DDA.9: re-frozen to the DDA renderer's output (was the
2485        // voxlap-opticast golden 0x215e_d66d_7359_4725).
2486        const GOLDEN: u64 = 0x492e_c4bb_718f_d7e5;
2487        // Same scene shape as `render_scene_two_chunk_x_grid_no_seam`
2488        // — kept distinct so the hash assertion doesn't share its
2489        // setup with the structural seam check.
2490        let mut scene = Scene::new();
2491        let id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
2492        scene.grid_mut(id).unwrap().set_rect(
2493            IVec3::new(120, 60, 200),
2494            IVec3::new(136, 67, 215),
2495            Some(0x80_aa_55_22),
2496        );
2497        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
2498        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2499        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2500        let camera = camera_at([128.0, 100.0, 207.0]);
2501        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2502        let outcome = render_scene_composed(
2503            &mut fb,
2504            &mut zb,
2505            XRES as usize,
2506            XRES,
2507            YRES,
2508            fog,
2509            &mut scene,
2510            &camera,
2511            &settings,
2512            sky_color,
2513            None,
2514        );
2515        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2516
2517        let bytes: Vec<u8> = fb.iter().flat_map(|p| p.to_ne_bytes()).collect();
2518        let hash = fnv1a64(&bytes);
2519        if GOLDEN == SENTINEL {
2520            // First-run capture mode — print the hash so the
2521            // developer can paste it into GOLDEN above.
2522            eprintln!("render_scene_two_chunk_x_grid_hash_is_stable: capture hash = 0x{hash:016x}");
2523            panic!("GOLDEN is the SENTINEL placeholder — paste 0x{hash:016x} into GOLDEN above");
2524        }
2525        assert_eq!(
2526            hash, GOLDEN,
2527            "2-chunk render hash drifted: expected 0x{GOLDEN:016x}, got 0x{hash:016x}"
2528        );
2529    }
2530
2531    /// Sentinel for first-run hash capture in
2532    /// [`render_scene_two_chunk_x_grid_hash_is_stable`]. Replace
2533    /// `GOLDEN`'s definition with the printed value once captured.
2534    const SENTINEL: u64 = 0xDEAD_BEEF_DEAD_BEEF;
2535
2536    /// S4B.6.c: stacked-grid scaffold — camera in chz=1 (= world
2537    /// z=256..511) of a 2-chunk-tall grid should render its own
2538    /// chunk's terrain. Verifies cf seed + slab-byte reads + chunk-
2539    /// XY swaps all use world-z consistently.
2540    ///
2541    /// Cross-chunk look-down (= camera in chz=0 sees terrain in
2542    /// chz=1) needs cf z range extension at air-gap-lookup time;
2543    /// that's a follow-up to S4B.6.c.
2544    #[test]
2545    fn stacked_two_chunk_z_camera_in_chz1_sees_own_chunk_floor() {
2546        let mut scene = Scene::new();
2547        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2548        let g = scene.grid_mut(id).unwrap();
2549        // chz=0: all-air (materialised so chunk_xyz_backing enumerates).
2550        g.ensure_chunk(IVec3::new(0, 0, 0));
2551        // chz=1: floor at local z=50 (= world z=306).
2552        g.set_rect(
2553            IVec3::new(60, 60, 306),
2554            IVec3::new(72, 72, 310),
2555            Some(0x80_33_66_99),
2556        );
2557        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
2558
2559        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
2560        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2561        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2562        // Camera at world (66, 66, 280) — directly above the
2563        // floor at world z=306. Look STRAIGHT DOWN (z increases =
2564        // down in voxlap z-down).
2565        let camera = Camera {
2566            pos: [66.0, 66.0, 280.0],
2567            right: [1.0, 0.0, 0.0],
2568            down: [0.0, 1.0, 0.0],
2569            forward: [0.0, 0.0, 1.0],
2570        };
2571        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2572        let outcome = render_scene_composed(
2573            &mut fb,
2574            &mut zb,
2575            XRES as usize,
2576            XRES,
2577            YRES,
2578            fog,
2579            &mut scene,
2580            &camera,
2581            &settings,
2582            sky_color,
2583            None,
2584        );
2585        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2586        let floor_count = fb.iter().filter(|&&p| p == 0x80_33_66_99).count();
2587        assert!(
2588            floor_count > 100,
2589            "camera at chz=1 with floor in same chunk should see it — got {floor_count} floor pixels"
2590        );
2591    }
2592
2593    /// S4B.6.e: cross-chunk look-down. Camera in chz=0's all-air
2594    /// chunk should see chz=1's floor below it. This was deferred
2595    /// from S4B.6.c because the cf seed's z range capped at the
2596    /// camera-chunk's bedrock (world z=255); S4B.6.e extends the
2597    /// air-gap walk in `camera_chunk_air_gap` to step into the
2598    /// next chunk down when the camera's column is all-air-bedrock,
2599    /// and the rasterizer routes state.column / slab_buf to the
2600    /// chunk holding the real floor via `seed_chunk_z`.
2601    #[test]
2602    fn stacked_two_chunk_z_camera_in_chz0_sees_chz1_floor() {
2603        let mut scene = Scene::new();
2604        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2605        let g = scene.grid_mut(id).unwrap();
2606        // chz=0: all-air. Materialised so chunk_xyz_backing
2607        // enumerates it.
2608        g.ensure_chunk(IVec3::new(0, 0, 0));
2609        // chz=1: floor at world z=306..310 (= local z=50..54).
2610        g.set_rect(
2611            IVec3::new(60, 60, 306),
2612            IVec3::new(72, 72, 310),
2613            Some(0x80_77_aa_44),
2614        );
2615        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
2616
2617        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
2618        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2619        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2620        // Camera at world (66, 66, 100) — in chz=0's all-air
2621        // chunk. Look STRAIGHT DOWN (z+) toward chz=1's floor at
2622        // world z=306.
2623        let camera = Camera {
2624            pos: [66.0, 66.0, 100.0],
2625            right: [1.0, 0.0, 0.0],
2626            down: [0.0, 1.0, 0.0],
2627            forward: [0.0, 0.0, 1.0],
2628        };
2629        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2630        let outcome = render_scene_composed(
2631            &mut fb,
2632            &mut zb,
2633            XRES as usize,
2634            XRES,
2635            YRES,
2636            fog,
2637            &mut scene,
2638            &camera,
2639            &settings,
2640            sky_color,
2641            None,
2642        );
2643        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2644        let floor_count = fb.iter().filter(|&&p| p == 0x80_77_aa_44).count();
2645        assert!(
2646            floor_count > 50,
2647            "camera in chz=0 air-gap should see chz=1 floor via cross-chunk look-down — got {floor_count} floor pixels"
2648        );
2649    }
2650
2651    /// S4B.6.l KNOWN LIMITATION → RESOLVED by VC.5 (2026-05-31).
2652    /// Camera at chz=0 with all-air-bedrock at the camera's own
2653    /// XY column (seed_chz=1 via cross-chunk look-down). A DIFFERENT
2654    /// XY column has chz=0 content (= a distant mountain entirely
2655    /// inside chz=0). Pre-VC.5 the chunk-XY swap read chz=1 chunks
2656    /// across the DDA, so the chz=0 mountain was invisible. VC.5's
2657    /// multi-chz column-step install stitches every chz layer at the
2658    /// new XY column; the chz=0 mountain renders correctly.
2659    ///
2660    /// VC.0 pin (2026-05-31): re-enabled (was `#[ignore]`'d). VC.5
2661    /// flipped it from failing (mountain_chz0 = 0) to passing.
2662    #[test]
2663    fn stacked_chz0_distant_mountain_visible_from_chz0_camera() {
2664        let mut scene = Scene::new();
2665        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2666        let g = scene.grid_mut(id).unwrap();
2667        // chz=0 mountain at a column DISTANT from the camera —
2668        // entirely in chz=0 (world z=100..200), so chz=1 at the
2669        // same XY is all-air-bedrock.
2670        g.set_rect(
2671            IVec3::new(100, 100, 100),
2672            IVec3::new(124, 124, 200),
2673            Some(0x80_aa_55_22), // distinct brown
2674        );
2675        // chz=1 hills filling the floor at world z=336..360 across
2676        // the chunk EXCEPT a hole around the mountain XY (so the
2677        // mountain doesn't sit on a green tower).
2678        g.set_rect(
2679            IVec3::new(0, 0, 336),
2680            IVec3::new(128, 128, 360),
2681            Some(0x80_22_88_44),
2682        );
2683        g.set_rect(IVec3::new(100, 100, 336), IVec3::new(124, 124, 360), None);
2684        // Materialise chz=0 + chz=1 (chz=0 has the mountain; chz=1
2685        // has the hills).
2686        assert!(g.chunk(IVec3::new(0, 0, 0)).is_some());
2687        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
2688
2689        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2690        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2691        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2692        // Camera at (40, 40, 60) — chz=0 air, FAR from the mountain
2693        // XY (100..124, 100..124). Yaw=π/4 (look toward +x+y =
2694        // mountain direction), pitch=0.72 rad (≈ 41° down) so the
2695        // ray bisecting the screen aims at the chz=0 mountain centre
2696        // ≈ (112, 112, 150).
2697        let (sy, cy) = (std::f64::consts::FRAC_PI_4).sin_cos();
2698        let (sp, cp) = 0.72_f64.sin_cos();
2699        let camera = Camera {
2700            pos: [40.0, 40.0, 60.0],
2701            right: [-sy, cy, 0.0],
2702            down: [-cy * sp, -sy * sp, cp],
2703            forward: [cy * cp, sy * cp, sp],
2704        };
2705        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2706        let outcome = render_scene_composed(
2707            &mut fb,
2708            &mut zb,
2709            XRES as usize,
2710            XRES,
2711            YRES,
2712            fog,
2713            &mut scene,
2714            &camera,
2715            &settings,
2716            sky_color,
2717            None,
2718        );
2719        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2720        let mountain_count = fb.iter().filter(|&&p| p == 0x80_aa_55_22).count();
2721        let hill_count = fb.iter().filter(|&&p| p == 0x80_22_88_44).count();
2722        eprintln!("chz0-distant-mountain: mountain_chz0={mountain_count} hill_chz1={hill_count}");
2723        // chz=1 hills are reachable via seed-time cross-chunk
2724        // look-down.
2725        assert!(
2726            hill_count > 50,
2727            "expected chz=1 hills via cross-chunk look-down — got {hill_count}"
2728        );
2729        // The proper-fix assertion: chz=0 distant mountain SHOULD be
2730        // visible. Currently fails — pins the limitation.
2731        assert!(
2732            mountain_count > 50,
2733            "expected chz=0 distant mountain visible — got {mountain_count} (S4B.6.l limitation)"
2734        );
2735    }
2736
2737    /// S4B.6.h: mid-render chunk-Z handoff. Camera column has
2738    /// content in chz=0 (= a mountain at the camera's XY) so
2739    /// seed-time cross-chunk look-down does NOT fire — seed_chz=0.
2740    /// As rays DDA across the scene, they visit XY columns where
2741    /// chz=0 is all-air-bedrock. Mid-render handoff should swap
2742    /// state to chz=1's column at those XY positions and reveal
2743    /// hill content sitting under the camera's chz=0 layer.
2744    ///
2745    /// This is the "tall mountains breaching chunk-Z boundary"
2746    /// case the demo aims for.
2747    #[test]
2748    fn mid_render_handoff_reveals_chz1_hills_under_mountain_camera() {
2749        let mut scene = Scene::new();
2750        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2751        let g = scene.grid_mut(id).unwrap();
2752        // chz=0: a small "mountain peak" at the camera's XY.
2753        // Mountain at world z=150..200 — solid block.
2754        g.set_rect(
2755            IVec3::new(60, 60, 150),
2756            IVec3::new(72, 72, 200),
2757            Some(0x80_88_44_22), // brown mountain
2758        );
2759        // chz=1: hills at world z=336..360 across the WHOLE chunk
2760        // (so DDA rays hit them when chz=0 is air).
2761        g.set_rect(
2762            IVec3::new(0, 0, 336),
2763            IVec3::new(128, 128, 360),
2764            Some(0x80_22_88_44), // green hills
2765        );
2766        // Carve a hole in chz=1's hill at the mountain's footprint
2767        // so the mountain doesn't appear to "float" on green.
2768        g.set_rect(IVec3::new(60, 60, 336), IVec3::new(72, 72, 360), None);
2769        assert!(g.chunk(IVec3::new(0, 0, 0)).is_some());
2770        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
2771
2772        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
2773        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2774        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2775        // Camera at world (66, 66, 100) — directly above the
2776        // mountain peak (at z=150). Camera column has the
2777        // mountain in chz=0. Look straight down.
2778        let camera = Camera {
2779            pos: [66.0, 66.0, 100.0],
2780            right: [1.0, 0.0, 0.0],
2781            down: [0.0, 1.0, 0.0],
2782            forward: [0.0, 0.0, 1.0],
2783        };
2784        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2785        let outcome = render_scene_composed(
2786            &mut fb,
2787            &mut zb,
2788            XRES as usize,
2789            XRES,
2790            YRES,
2791            fog,
2792            &mut scene,
2793            &camera,
2794            &settings,
2795            sky_color,
2796            None,
2797        );
2798        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2799        let mountain_count = fb.iter().filter(|&&p| p == 0x80_88_44_22).count();
2800        let hill_count = fb.iter().filter(|&&p| p == 0x80_22_88_44).count();
2801        // Verify the hills render at approximately the correct
2802        // world-z by sampling the z-buffer at hill pixels. Camera
2803        // at z=100 looking straight down; hills at world z=336.
2804        // Expected depth = 236 for directly-below pixels. If
2805        // state.z1 stays stuck at the mountain peak's z=150 the
2806        // hills would render with depth ≈ 50 → orders of magnitude
2807        // off.
2808        let mut hill_depths: Vec<f32> = fb
2809            .iter()
2810            .zip(zb.iter())
2811            .filter_map(|(&p, &d)| if p == 0x80_22_88_44 { Some(d) } else { None })
2812            .collect();
2813        hill_depths.sort_by(|a, b| a.partial_cmp(b).unwrap());
2814        let median_hill_depth = hill_depths[hill_depths.len() / 2];
2815        eprintln!(
2816            "mid-render handoff: mountain={mountain_count} hill={hill_count} median_hill_depth={median_hill_depth:.1}"
2817        );
2818        assert!(
2819            mountain_count > 50,
2820            "should see mountain peak via chz=0 — got {mountain_count} mountain pixels"
2821        );
2822        assert!(
2823            hill_count > 50,
2824            "should see chz=1 hills via mid-render handoff — got {hill_count} hill pixels"
2825        );
2826        assert!(
2827            (median_hill_depth - 236.0).abs() < 80.0,
2828            "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"
2829        );
2830    }
2831
2832    /// S4B.6.g: cross-chunk look-down under multi-mip. Same scene
2833    /// as `stacked_two_chunk_z_camera_in_chz0_sees_chz1_floor` but
2834    /// with `mip_levels=2, mip_scan_dist=16` so the rasterizer
2835    /// transitions to mip-1 well within the chz=1 terrain. Locks in
2836    /// the slab_z_at mip-N offset fix (= `chunk_world_z_base >>
2837    /// gmipcnt`). Pre-fix produced a green / brown "wall in a circle
2838    /// around the camera" because mip-1 rendered the floor at
2839    /// world-z ≈ 178 instead of 306.
2840    #[test]
2841    fn stacked_two_chunk_z_camera_in_chz0_sees_chz1_floor_multi_mip() {
2842        let mut scene = Scene::new();
2843        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2844        let g = scene.grid_mut(id).unwrap();
2845        g.ensure_chunk(IVec3::new(0, 0, 0));
2846        g.set_rect(
2847            IVec3::new(60, 60, 306),
2848            IVec3::new(72, 72, 310),
2849            Some(0x80_77_aa_44),
2850        );
2851        assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
2852
2853        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
2854        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2855        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2856        let camera = Camera {
2857            pos: [66.0, 66.0, 100.0],
2858            right: [1.0, 0.0, 0.0],
2859            down: [0.0, 1.0, 0.0],
2860            forward: [0.0, 0.0, 1.0],
2861        };
2862        let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2863        settings.mip_levels = 2;
2864        settings.mip_scan_dist = 16;
2865        let outcome = render_scene_composed(
2866            &mut fb,
2867            &mut zb,
2868            XRES as usize,
2869            XRES,
2870            YRES,
2871            fog,
2872            &mut scene,
2873            &camera,
2874            &settings,
2875            sky_color,
2876            None,
2877        );
2878        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2879        let floor_count = fb.iter().filter(|&&p| p == 0x80_77_aa_44).count();
2880        assert!(
2881            floor_count > 50,
2882            "multi-mip cross-chunk look-down should still see chz=1 floor — got {floor_count} floor pixels"
2883        );
2884    }
2885
2886    /// S4B.6.d: 3-chunk-tall stack stresses the widened gylookup
2887    /// (`(chunks_z * 512) >> mip + 4` per mip). Pre-S4B.6.d, gylookup
2888    /// was hardcoded at `(512 >> mip) + 4`, which would OOB or alias
2889    /// for any z > 511. This test renders a floor at world z=562
2890    /// (= chz=2, local z=50) with the camera at world z=540, looking
2891    /// straight down. Multi-mip is on so we exercise the mip slide
2892    /// path in `phase_remiporend` that scales `advance` by chunks_z.
2893    #[test]
2894    fn stacked_three_chunk_z_camera_in_chz2_sees_own_chunk_floor_multi_mip() {
2895        let mut scene = Scene::new();
2896        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2897        let g = scene.grid_mut(id).unwrap();
2898        // Materialise chz=0 + chz=1 so chunk_xyz_backing enumerates
2899        // the full stack.
2900        g.ensure_chunk(IVec3::new(0, 0, 0));
2901        g.ensure_chunk(IVec3::new(0, 0, 1));
2902        // chz=2: floor at world z=562..566 (= local z=50..54).
2903        g.set_rect(
2904            IVec3::new(60, 60, 562),
2905            IVec3::new(72, 72, 566),
2906            Some(0x80_aa_55_22),
2907        );
2908        assert!(g.chunk(IVec3::new(0, 0, 2)).is_some());
2909
2910        let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
2911        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2912        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2913        let camera = Camera {
2914            pos: [66.0, 66.0, 540.0],
2915            right: [1.0, 0.0, 0.0],
2916            down: [0.0, 1.0, 0.0],
2917            forward: [0.0, 0.0, 1.0],
2918        };
2919        // Multi-mip on to exercise the gylookup-slide path.
2920        let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2921        settings.mip_levels = 2;
2922        settings.mip_scan_dist = 16;
2923        let outcome = render_scene_composed(
2924            &mut fb,
2925            &mut zb,
2926            XRES as usize,
2927            XRES,
2928            YRES,
2929            fog,
2930            &mut scene,
2931            &camera,
2932            &settings,
2933            sky_color,
2934            None,
2935        );
2936        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2937        let floor_count = fb.iter().filter(|&&p| p == 0x80_aa_55_22).count();
2938        assert!(
2939            floor_count > 100,
2940            "camera at chz=2 with floor in same chunk should see it — got {floor_count} floor pixels"
2941        );
2942    }
2943
2944    // ---- S7.4: render integration with streaming ----
2945
2946    /// Floor-stamping generator for S7.4 render tests. Produces a
2947    /// 10-voxel-thick floor at the bottom of every chunk it
2948    /// generates (chunk-local `z = 230..239`, all xy). Visible as
2949    /// a green stripe along the bottom of the framebuffer when
2950    /// the camera looks +y across populated chunks.
2951    #[derive(Debug)]
2952    struct FloorGenerator;
2953
2954    impl crate::ChunkGenerator for FloorGenerator {
2955        fn generate(&self, _chunk_idx: IVec3) -> roxlap_formats::vxl::Vxl {
2956            // Lean on `Grid::ensure_chunk` for the empty-chunk
2957            // builder, then carve a floor via `set_rect`. Detach
2958            // the chunk from the temporary grid and return it.
2959            let mut tmp = crate::Grid::new(GridTransform::identity());
2960            tmp.ensure_chunk(IVec3::ZERO);
2961            let mut vxl = tmp.chunks.remove(&IVec3::ZERO).unwrap();
2962            #[allow(clippy::cast_possible_wrap)]
2963            roxlap_formats::edit::set_rect(
2964                &mut vxl,
2965                glam::IVec3::new(0, 0, 230).into(),
2966                glam::IVec3::new((CHUNK_SIZE_XY - 1) as i32, (CHUNK_SIZE_XY - 1) as i32, 239)
2967                    .into(),
2968                Some(0x80_22_aa_22),
2969            );
2970            vxl
2971        }
2972    }
2973
2974    #[test]
2975    fn render_scene_composed_unpumped_streaming_grid_renders_all_sky() {
2976        // S7.4(a): a grid with a generator + active stream radius
2977        // but no pump_streaming call has zero chunks. The render
2978        // walks the grid (chunk_xyz_backing returns None for an
2979        // empty chunk map → grid is skipped), framebuffer stays
2980        // sky.
2981        use std::sync::Arc;
2982        let mut scene = Scene::new();
2983        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
2984        let g = scene.grid_mut(id).unwrap();
2985        g.set_generator(Some(Arc::new(FloorGenerator)));
2986        g.stream_radius = crate::StreamRadius::new(300.0, 600.0);
2987        assert!(g.chunks.is_empty(), "no pump yet → no chunks");
2988
2989        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2990        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2991        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2992        // Camera at (64, -100, 200) looking +y so it would see
2993        // chunks ahead once they exist.
2994        let camera = camera_at([64.0, -100.0, 200.0]);
2995        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2996        let _ = render_scene_composed(
2997            &mut fb,
2998            &mut zb,
2999            XRES as usize,
3000            XRES,
3001            YRES,
3002            fog,
3003            &mut scene,
3004            &camera,
3005            &settings,
3006            sky_color,
3007            None,
3008        );
3009        // Empty grid path skips opticast → framebuffer untouched.
3010        assert!(
3011            fb.iter().all(|&p| p == sky_color),
3012            "unpumped streaming grid must render as all sky"
3013        );
3014    }
3015
3016    #[test]
3017    fn render_scene_composed_picks_up_streamed_chunks_after_sync_pump() {
3018        // S7.4(a): once the streaming pump installs chunks, the
3019        // next render shows them. Using pump_streaming_sync for
3020        // deterministic timing — pump_streaming (async) lands
3021        // the same way modulo a frame of latency.
3022        use std::sync::Arc;
3023        let mut scene = Scene::new();
3024        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3025        let g = scene.grid_mut(id).unwrap();
3026        g.set_generator(Some(Arc::new(FloorGenerator)));
3027        // Cover chunks ahead of the camera (y=0, y=128, y=256).
3028        g.stream_radius = crate::StreamRadius::new(300.0, 600.0);
3029
3030        // Render BEFORE pump: zero floor pixels.
3031        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
3032        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3033        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3034        let camera = camera_at([64.0, -100.0, 200.0]);
3035        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3036        let _ = render_scene_composed(
3037            &mut fb,
3038            &mut zb,
3039            XRES as usize,
3040            XRES,
3041            YRES,
3042            fog,
3043            &mut scene,
3044            &camera,
3045            &settings,
3046            sky_color,
3047            None,
3048        );
3049        let pre_floor = fb.iter().filter(|&&p| p == 0x80_22_aa_22).count();
3050        assert_eq!(pre_floor, 0, "pre-pump frame has no streamed chunks");
3051
3052        // Pump synchronously — `world_pos` matches the camera so
3053        // chunks ahead of it (within r_active = 300) stream in.
3054        scene.pump_streaming_sync(DVec3::new(64.0, -100.0, 200.0));
3055        let g = scene.grid(id).unwrap();
3056        assert!(
3057            !g.chunks.is_empty(),
3058            "pump should have streamed at least one chunk"
3059        );
3060
3061        // Render AFTER pump: the floor should now be visible. Reset
3062        // the framebuffer to sky first.
3063        fb.iter_mut().for_each(|p| *p = sky_color);
3064        zb.iter_mut().for_each(|z| *z = f32::INFINITY);
3065        let outcome = render_scene_composed(
3066            &mut fb,
3067            &mut zb,
3068            XRES as usize,
3069            XRES,
3070            YRES,
3071            fog,
3072            &mut scene,
3073            &camera,
3074            &settings,
3075            sky_color,
3076            None,
3077        );
3078        assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
3079        let post_floor = fb.iter().filter(|&&p| p == 0x80_22_aa_22).count();
3080        assert!(
3081            post_floor > 100,
3082            "post-pump frame should show the streamed floor — got {post_floor} green pixels"
3083        );
3084    }
3085
3086    #[test]
3087    fn render_scene_composed_partial_streaming_renders_pending_chunks_as_air() {
3088        // S7.4(a): mixed state — some r_active chunks are
3089        // materialised, others are still pending (not in
3090        // `chunks`). The render must treat pending chunks as
3091        // implicit-air. Verified by stamping one chunk via the
3092        // generator + skipping the others, then confirming the
3093        // framebuffer has fewer floor pixels than the
3094        // fully-pumped baseline.
3095        use std::sync::Arc;
3096        let mut scene = Scene::new();
3097        let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3098        let g = scene.grid_mut(id).unwrap();
3099        g.set_generator(Some(Arc::new(FloorGenerator)));
3100        // r_active must be set so the later pump_streaming_sync
3101        // sanity-check actually streams more chunks in.
3102        g.stream_radius = crate::StreamRadius::new(400.0, 800.0);
3103
3104        // Materialise ONLY chunk (0, 0, 0) manually via the
3105        // sync helper — leave (0, 1, 0), (0, 2, 0) absent.
3106        let installed = g.ensure_chunk_generated(IVec3::ZERO);
3107        assert!(installed, "manual install of one chunk");
3108        assert_eq!(g.chunks.len(), 1);
3109        // Make sure (0, 1, 0), (0, 2, 0) are NOT present.
3110        assert!(g.chunk(IVec3::new(0, 1, 0)).is_none());
3111        assert!(g.chunk(IVec3::new(0, 2, 0)).is_none());
3112
3113        let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
3114        let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3115        let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3116        // Camera inside chunk (0, 0, 0); looking +y means the
3117        // floor of (0, 0, 0) gets rendered until the ray walks
3118        // off the chunk into implicit-air space at y=128. No
3119        // floor pixels past that distance.
3120        let camera = camera_at([64.0, 32.0, 200.0]);
3121        let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3122        let _ = render_scene_composed(
3123            &mut fb,
3124            &mut zb,
3125            XRES as usize,
3126            XRES,
3127            YRES,
3128            fog,
3129            &mut scene,
3130            &camera,
3131            &settings,
3132            sky_color,
3133            None,
3134        );
3135        let floor_pixels = fb.iter().filter(|&&p| p == 0x80_22_aa_22).count();
3136        // Visible floor inside chunk (0,0,0); pending neighbours
3137        // contribute nothing. The number isn't pinned exactly —
3138        // it just needs to be non-zero (we have content) and
3139        // less than what a fully-streamed scene would produce.
3140        assert!(
3141            floor_pixels > 0,
3142            "should see at least some floor from the loaded chunk"
3143        );
3144        // Sanity: stream the missing chunks; verify the floor
3145        // pixel count goes up.
3146        scene.pump_streaming_sync(DVec3::new(64.0, 32.0, 200.0));
3147        assert!(scene.grid(id).unwrap().chunk_count() >= 2);
3148        fb.iter_mut().for_each(|p| *p = sky_color);
3149        zb.iter_mut().for_each(|z| *z = f32::INFINITY);
3150        let _ = render_scene_composed(
3151            &mut fb,
3152            &mut zb,
3153            XRES as usize,
3154            XRES,
3155            YRES,
3156            fog,
3157            &mut scene,
3158            &camera,
3159            &settings,
3160            sky_color,
3161            None,
3162        );
3163        let floor_pixels_full = fb.iter().filter(|&&p| p == 0x80_22_aa_22).count();
3164        assert!(
3165            floor_pixels_full > floor_pixels,
3166            "fully-streamed scene should show more floor than partial: \
3167             partial={floor_pixels} full={floor_pixels_full}"
3168        );
3169    }
3170}