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::color::Rgb;
45use roxlap_formats::material::MaterialTable;
46
47use crate::billboard::{self, BillboardCache, DEFAULT_RESOLUTION as BILLBOARD_RESOLUTION};
48use crate::chunks;
49use crate::lod::Lod;
50use crate::occluder::SceneOccluder;
51use crate::{GridId, GridTransform, Scene, CHUNK_SIZE_XY};
52use roxlap_core::{CompositeOccluder, WorldOccluder, WorldShadowCtx};
53use std::collections::HashMap;
54
55/// Sentinel colour stamped into a `render_sky = false` grid's
56/// temporary framebuffer wherever the rasterizer would have drawn
57/// sky. After opticast, [`render_scene_composed`] walks the temp
58/// buffer and resets `temp_zb` to [`f32::INFINITY`] for any pixel
59/// still carrying this value — those pixels then always lose
60/// [`compose_into`]'s min-z test and the underlying grid's sky
61/// (or another grid's hit) wins.
62///
63/// Alpha byte is `0x00`. Voxlap voxel slabs carry an alpha-encoded
64/// shade in `[0x00, 0x80]`, but a `0x00` alpha **with this exact
65/// RGB pattern** is exceedingly unlikely to occur on a real hit
66/// (the lit-voxel path produces alpha ≥ 0x40 in practice). Bit
67/// pattern is also visually distinct (cyan-ish neon) if anything
68/// ever leaks through to the screen, making the bug obvious.
69const SKY_MASK_SENTINEL: u32 = 0x00_DE_AD_BE;
70
71/// CPU fog + per-face shading config for the DDA backend, passed by
72/// value into the scene render entry points (replaces the old
73/// `&mut ScratchPool` parameter the voxlap path threaded fog through).
74///
75/// `max_scan_dist <= 0` disables fog (no distance blend). Otherwise the
76/// DDA renderer linearly ramps a hit's colour toward [`Self::color`]
77/// over `max_scan_dist` voxels. `side_shades` darkens each of the six
78/// voxel faces — `[x-, x+, y-, y+, z-, z+]`.
79#[derive(Debug, Clone, Copy, Default)]
80pub struct CpuFog {
81 /// Low-24-bit RGB fog colour.
82 pub color: u32,
83 /// Distance (voxels) at which fog is fully opaque; `<= 0` ⇒ fog OFF.
84 pub max_scan_dist: i32,
85 /// Per-face brightness reduction `[x-, x+, y-, y+, z-, z+]`.
86 pub side_shades: [i8; 6],
87}
88
89/// Project a world-space [`Camera`] into a grid's local frame:
90/// translate by `-transform.origin`, then apply
91/// `transform.rotation.inverse()` to the position and the
92/// orthonormal basis (`right` / `down` / `forward`).
93///
94/// Identity rotation collapses to pure translation, byte-identical
95/// to the pre-S5 path (`DQuat::IDENTITY * v == v`). For a rotated
96/// grid the rasterizer still sees an axis-aligned chunk grid —
97/// rotation is invisible below this layer per PORTING-SCENE.md § S5.
98///
99/// The basis is rotated as a free vector (no translation
100/// component); position is rotated about the grid origin.
101fn world_camera_to_grid_local(camera: &Camera, transform: &GridTransform) -> Camera {
102 let inv = transform.rotation.inverse();
103 // SC — un-rotate into the grid frame, then divide the origin AND the
104 // pinhole basis by the grid's world units per voxel so the whole
105 // world ray `pos + t·(px·right + py·down + hz·forward)` maps into
106 // voxel space; opticast then marches integer voxels and its depth
107 // comes back in VOXEL units (the caller scales it back to world by
108 // `voxel_world_size` before compositing). `vws == 1.0` is the pre-SC
109 // path, bit-for-bit.
110 let vws = transform.voxel_world_size;
111 let world_offset = DVec3::from_array(camera.pos) - transform.origin;
112 let local_pos = (inv * world_offset) / vws;
113 let local_right = (inv * DVec3::from_array(camera.right)) / vws;
114 let local_down = (inv * DVec3::from_array(camera.down)) / vws;
115 let local_forward = (inv * DVec3::from_array(camera.forward)) / vws;
116 Camera {
117 pos: local_pos.to_array(),
118 right: local_right.to_array(),
119 down: local_down.to_array(),
120 forward: local_forward.to_array(),
121 }
122}
123
124/// SC — scale a rendered grid's depth buffer back to WORLD units so the
125/// cross-grid min-z compose ([`compose_into`] / [`compose_rect`]) stays
126/// world-comparable across grids of different scale.
127///
128/// The factor is **`voxel_world_size²`**, not `vws`. opticast writes
129/// `depth = t · (dir·forward)` (`dda.rs`, perpendicular depth). With a
130/// `world_camera_to_grid_local` camera whose whole pinhole basis is
131/// divided by `vws`, the ray parameter `t` stays the world value, but
132/// `dir·forward` shrinks by `vws²` (both `dir` and `forward` are
133/// `/vws`) — so the written depth is `world / vws²`. Multiplying by
134/// `vws²` recovers world. `INFINITY` (a miss / sky sentinel) stays
135/// `INFINITY`. Only the grid's screen rect is touched. No-op — and
136/// skipped entirely — at `vws == 1.0`.
137fn scale_depth_rect(zb: &mut [f32], pitch_pixels: usize, rect: ScreenRect, voxel_world_size: f64) {
138 if (voxel_world_size - 1.0).abs() <= f64::EPSILON {
139 return;
140 }
141 #[allow(clippy::cast_possible_truncation)]
142 let vws2 = (voxel_world_size * voxel_world_size) as f32;
143 for y in rect.y0..rect.y1 {
144 let row = y as usize * pitch_pixels;
145 for d in &mut zb[row + rect.x0 as usize..row + rect.x1 as usize] {
146 // INFINITY · vws² == INFINITY (vws > 0), so misses stay misses.
147 *d *= vws2;
148 }
149 }
150}
151
152/// SC — the world→grid-local camera divides the whole pinhole basis by
153/// `vws` (see [`world_camera_to_grid_local`]), so opticast writes
154/// `depth = world / vws²` (see [`scale_depth_rect`]). Any WORLD distance
155/// opticast compares against that depth must be divided by `vws²` so the
156/// comparison fires at the intended world range.
157///
158/// This governs the two ray-*terminating* thresholds — the scan cutoff
159/// (`max_scan_dist`, `depth > max_dist`) and the opaque-fog distance
160/// (`fog_max_dist`, `depth >= fog_max_dist`). Both stop the ray, so
161/// leaving them unscaled is **geometry-affecting**, not merely cosmetic: a
162/// fine grid (`vws < 1`) would have its visible terrain clipped to
163/// `range · vws²` — only 6 % of the intended distance at `vws = 0.25`.
164///
165/// (`mip_scan_dist` is a *scene-LOD-picker* input, not compared against the
166/// depth buffer inside the ray, so it is not scaled here. It is also dead
167/// config for the DDA backend — vws-aware projected-size LOD is an optional
168/// future perf optimization, not a scale bug; see the SC.3 status.)
169///
170/// Identity — and byte-identical — at `vws == 1.0`.
171fn scale_world_dist_f32(world_dist: f32, voxel_world_size: f64) -> f32 {
172 if (voxel_world_size - 1.0).abs() <= f64::EPSILON {
173 return world_dist;
174 }
175 #[allow(clippy::cast_possible_truncation)]
176 let scaled = (f64::from(world_dist) / (voxel_world_size * voxel_world_size)) as f32;
177 scaled
178}
179
180/// SC — [`scale_world_dist_f32`] for the integer `max_scan_dist` handed to
181/// opticast. Rounds and clamps to `[1, i32::MAX]`. Identity at
182/// `vws == 1.0` (byte-identical). The world-space grid distance cull uses
183/// the *unscaled* `settings.max_scan_dist`, so only the copy passed to the
184/// ray is rescaled here.
185fn scale_scan_dist_i32(max_scan_dist: i32, voxel_world_size: f64) -> i32 {
186 if (voxel_world_size - 1.0).abs() <= f64::EPSILON {
187 return max_scan_dist;
188 }
189 #[allow(clippy::cast_possible_truncation)]
190 let scaled = (f64::from(max_scan_dist) / (voxel_world_size * voxel_world_size))
191 .round()
192 .clamp(1.0, f64::from(i32::MAX)) as i32;
193 scaled
194}
195
196/// SC.3 — a grid's bounding sphere in **world** space. [`billboard::grid_bounds`]
197/// returns grid-local (voxel) units, so the world sphere scales the centre
198/// AND radius by `voxel_world_size` (the centre is then rotated + translated
199/// into world). Used by the per-frame distance cull, screen-rect projection,
200/// billboard blit, and light-reach cull — all world-space. Identity — and
201/// byte-identical — at `vws == 1.0`.
202fn grid_world_bounds(grid: &crate::Grid) -> (DVec3, f64) {
203 let b = billboard::grid_bounds(grid);
204 let vws = grid.transform.voxel_world_size;
205 let centre = grid.transform.origin + grid.transform.rotation * (b.centre * vws);
206 (centre, b.radius * vws)
207}
208
209/// CPU.1 — transform world-space dynamic lights into a grid's local frame
210/// (the same translate + inverse-rotation as [`world_camera_to_grid_local`]):
211/// point positions are points (origin-relative + inverse-rotated); the sun
212/// direction is a vector (inverse-rotated only). Point lights land in `scratch`
213/// so the returned [`CpuLights`] can borrow them for the grid's render.
214///
215/// PF.7 (C4) — `grid_sphere` is the grid's world-space bounding sphere
216/// `(centre, radius)`: a light whose reach-sphere can't touch it (with
217/// slack for the shadow-bias sample offset) is dropped BEFORE the
218/// transform, so the per-hit light loop never sees it. Conservative ⇒
219/// byte-identical (a dropped light's `point_falloff` would be 0 at every
220/// reachable sample anyway). `None` skips the cull.
221fn grid_local_lights<'a>(
222 world: &CpuLights<'_>,
223 transform: &GridTransform,
224 scratch: &'a mut Vec<CpuPointLight>,
225 grid_sphere: Option<(DVec3, f64)>,
226) -> CpuLights<'a> {
227 scratch.clear();
228 if !world.enabled {
229 return CpuLights::default();
230 }
231 let inv = transform.rotation.inverse();
232 let vws = transform.voxel_world_size;
233 #[allow(clippy::cast_possible_truncation)]
234 let sun_dir = if world.sun {
235 let d = inv
236 * DVec3::new(
237 f64::from(world.sun_dir[0]),
238 f64::from(world.sun_dir[1]),
239 f64::from(world.sun_dir[2]),
240 );
241 [d.x as f32, d.y as f32, d.z as f32]
242 } else {
243 [0.0; 3]
244 };
245 // Shade samples sit on voxel surfaces inside the bounding sphere,
246 // nudged up to `shadow_bias` along the normal — expand by that plus
247 // a unit of float slack.
248 let cull_slack = f64::from(world.shadow_bias) + 1.0;
249 for p in world.points {
250 if let Some((centre, radius)) = grid_sphere {
251 let lp = DVec3::new(
252 f64::from(p.pos[0]),
253 f64::from(p.pos[1]),
254 f64::from(p.pos[2]),
255 );
256 if (lp - centre).length() > f64::from(p.radius) + radius + cull_slack {
257 continue;
258 }
259 }
260 // SC — the shade evaluates falloff in the grid's VOXEL frame, so
261 // the light's POSITION divides by `voxel_world_size` (a point)
262 // and its RADIUS too (a world distance → voxel distance), so the
263 // reach sphere stays the same world size. The cone axis is a
264 // direction — scale-invariant (uniform scale), rotate only.
265 let lp = (inv
266 * (DVec3::new(
267 f64::from(p.pos[0]),
268 f64::from(p.pos[1]),
269 f64::from(p.pos[2]),
270 ) - transform.origin))
271 / vws;
272 // SL — the cone axis is a vector: inverse-rotate only (no origin).
273 let sd = inv
274 * DVec3::new(
275 f64::from(p.spot_dir[0]),
276 f64::from(p.spot_dir[1]),
277 f64::from(p.spot_dir[2]),
278 );
279 #[allow(clippy::cast_possible_truncation)]
280 scratch.push(CpuPointLight {
281 pos: [lp.x as f32, lp.y as f32, lp.z as f32],
282 color: p.color,
283 intensity: p.intensity,
284 #[allow(clippy::cast_possible_truncation)]
285 radius: (f64::from(p.radius) / vws) as f32,
286 casts_shadow: p.casts_shadow,
287 spot_dir: [sd.x as f32, sd.y as f32, sd.z as f32],
288 cos_inner: p.cos_inner,
289 cos_outer: p.cos_outer,
290 });
291 }
292 CpuLights {
293 enabled: true,
294 sun: world.sun,
295 sun_dir,
296 sun_color: world.sun_color,
297 sun_intensity: world.sun_intensity,
298 sun_casts_shadow: world.sun_casts_shadow,
299 points: scratch.as_slice(),
300 ambient: world.ambient,
301 bands: world.bands,
302 shadow_tint: world.shadow_tint,
303 shadow_strength: world.shadow_strength,
304 shadow_bias: world.shadow_bias,
305 // SC.2 — `shadow_max_dist` is a WORLD distance (uniform across grids):
306 // the sun shadow ray works in this grid's VOXEL frame, so divide by
307 // vws to a voxel cap. `WorldShadow`'s ×vws lift (or a single-grid
308 // `SamplerShadow`'s voxel march) then reaches `shadow_max_dist` world
309 // units on every grid — a fine grid (vws<1) gets full world shadow
310 // reach instead of `shadow_max_dist·vws`. Point-light shadows are
311 // unaffected (they march to the light's actual `dist`, not this cap).
312 // Byte-identical at vws == 1.0.
313 #[allow(clippy::cast_possible_truncation)]
314 shadow_max_dist: (f64::from(world.shadow_max_dist) / vws) as f32,
315 }
316}
317
318/// OC.0 — the frame's view cutout ("keyhole", stage OC) as the facade
319/// hands it to the composed render: the view-cone half-angles (already
320/// derived from the keyhole's pixel radius under the frame's own
321/// projection — hazard 1: never window/host pixels; angles are
322/// resolution- and rotation-invariant) plus the world-space camera +
323/// focus the per-grid loop converts into each grid's frame (exactly
324/// like the lights / CA clip conversions). Per-frame VIEW state —
325/// primary rays only: shadows, occluders, collision and gameplay
326/// raycasts never see it.
327#[derive(Debug, Clone, Copy, PartialEq)]
328pub struct SceneViewCutout {
329 /// Tangent of the cone's outer half-angle (`radius_px / focal`).
330 pub tan_outer: f32,
331 /// Tangent of the full-reveal inner half-angle
332 /// (`(radius − feather)/focal`, clamped ≥ 0): the reveal distance
333 /// tapers linearly to zero between the two.
334 pub tan_inner: f32,
335 /// World-space focus point (the controlled character).
336 pub focus_world: [f64; 3],
337 /// How far short of the character column the reveal stops, WORLD
338 /// units (non-negative); per grid it converts to voxel units
339 /// (`/vws`) before entering the ray loop.
340 pub margin: f32,
341 /// Focus-plane bias, WORLD units along grid-local z (z-down:
342 /// positive lowers the plane, cutting more). Divided by the grid's
343 /// `voxel_world_size` at the per-grid conversion.
344 pub z_bias: f64,
345}
346
347/// The composed render's bundled per-frame inputs: the positional
348/// wrapper ladder (`render_scene_composed` → `_with_materials` →
349/// `_with_materials_scratch`) had reached 17 arguments with adjacent
350/// same-typed `Option`s a compiler can't tell apart — a swapped slot
351/// would compile fine. `#[non_exhaustive]`: construct with
352/// [`ComposedFrameParams::new`] and override fields, so future frame
353/// inputs (OC.4 ghost mode, …) are field additions, not new wrappers.
354#[non_exhaustive]
355pub struct ComposedFrameParams<'a> {
356 /// World camera.
357 pub camera: &'a Camera,
358 /// Projection + scan settings.
359 pub settings: &'a OpticastSettings,
360 /// Fog + per-face side shades.
361 pub fog: CpuFog,
362 /// Solid sky colour for the per-grid temp pre-fill.
363 pub sky_color: u32,
364 /// Optional textured sky panorama.
365 pub sky: Option<&'a Sky>,
366 /// TV — global voxel-material palette.
367 pub materials: Option<&'a MaterialTable>,
368 /// TV.4 — terrain colour→material map.
369 pub terrain_materials: &'a [(Rgb, u8)],
370 /// CPU.1 — world-space dynamic lights (transformed per grid).
371 pub lights: CpuLights<'a>,
372 /// XS.2 — sprite-volume occluder (sprites cast onto terrain).
373 pub sprite_occluder: Option<&'a dyn WorldOccluder>,
374 /// OC — the frame's view cutout (keyhole).
375 pub view_cutout: Option<&'a SceneViewCutout>,
376 /// FW.2 — fog-of-war styling for ONE grid (the twin): its
377 /// [`GridId`] plus the mask/config to style it with. Only that grid
378 /// gets the per-hit dim / desaturate / hide; every other grid
379 /// renders normally. `None` (the default) = no fog styling,
380 /// byte-identical.
381 pub fow: Option<(crate::GridId, &'a crate::FogOfWar)>,
382}
383
384impl<'a> ComposedFrameParams<'a> {
385 /// Params with every optional input off — override what differs.
386 #[must_use]
387 pub fn new(camera: &'a Camera, settings: &'a OpticastSettings) -> Self {
388 Self {
389 camera,
390 settings,
391 fog: CpuFog::default(),
392 sky_color: 0,
393 sky: None,
394 materials: None,
395 terrain_materials: &[],
396 lights: CpuLights::default(),
397 sprite_occluder: None,
398 view_cutout: None,
399 fow: None,
400 }
401 }
402}
403
404/// The composed multi-grid render with its per-frame inputs bundled
405/// ([`ComposedFrameParams`]) — the production entry point (the
406/// positional wrappers below predate it and forward here). Caller
407/// pre-fills `fb` with sky and `zb` with `INFINITY`; grids z-merge in.
408#[must_use]
409pub fn render_scene_composed_frame(
410 fb: &mut [u32],
411 zb: &mut [f32],
412 pitch_pixels: usize,
413 width: u32,
414 height: u32,
415 scene: &mut Scene,
416 params: &ComposedFrameParams<'_>,
417 scratch: &mut SceneRenderScratch,
418) -> RenderOutcome {
419 render_scene_composed_scissored(
420 fb,
421 zb,
422 pitch_pixels,
423 width,
424 height,
425 scene,
426 params,
427 true,
428 scratch,
429 )
430}
431
432/// OC — the view cutout's focus in a grid's frame: world focus →
433/// grid-local VOXEL coordinates plus the biased focus-plane z
434/// (`floor()` into the kernels' `>> mip` integer domain). The ONE
435/// conversion both backends' facades feed their kernels from — a
436/// one-voxel drift between hand-copies would cut the floor out from
437/// under the character on one backend only.
438#[must_use]
439#[allow(clippy::cast_possible_truncation)]
440pub fn cutout_grid_local(
441 focus_world: DVec3,
442 z_bias: f64,
443 transform: &GridTransform,
444) -> (DVec3, i32) {
445 let vws = transform.voxel_world_size;
446 let local = (transform.rotation.inverse() * (focus_world - transform.origin)) / vws;
447 let plane = (local.z + z_bias / vws).floor() as i32;
448 (local, plane)
449}
450
451/// Outcome of a [`render_scene`] / [`render_scene_composed`] call.
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum RenderOutcome {
454 /// At least one grid produced a render.
455 Rendered {
456 /// Number of grids that were drawn.
457 grids_drawn: usize,
458 },
459 /// No grid rendered — the scene was empty (no populated grids).
460 Empty,
461}
462
463/// Render every grid in `scene` directly into `(fb, zb)` — no
464/// per-grid temp buffer, no compose merge. For multi-grid scenes
465/// this is last-grid-wins (later grids' opticast writes overwrite
466/// earlier grids' pixels indiscriminately, including sky), so it's
467/// only correct for single-grid scenes.
468///
469/// Use this when you have one grid and want the byte-stable
470/// PR.3: pick the cheapest `GridView` constructor that matches the
471/// grid's chunk layout.
472///
473/// Trivial-single-chunk grids (1 chunk at index `(0, 0, 0)`) bypass
474/// the multi-chunk rasterizer path: `GridView::from_single_vxl`
475/// leaves `chunk_grid = None`, so `phase_after_delete_kept_presync`
476/// takes the cheaper single-chunk branch instead of doing
477/// `chunk_at_xyz` + IVec2-equality + `Option::is_some` per
478/// column-step. Markers / pickups / small ships qualify.
479///
480/// Multi-chunk grids (ground, larger ships) fall through to
481/// `from_chunk_grid` with the supplied `ChunkGrid`.
482fn single_chunk_fast_path<'a>(
483 backing: &'a chunks::ChunkXyBacking<'a>,
484 cg: &'a roxlap_core::ChunkGrid<'a>,
485) -> roxlap_core::GridView<'a> {
486 if backing.chunks_x == 1
487 && backing.chunks_y == 1
488 && backing.chunks_z == 1
489 && backing.origin_chunk_xy == [0, 0]
490 && backing.origin_chunk_z == 0
491 {
492 // chunk_xyz_backing populates each `Vec<Option<GridView>>`
493 // slot via `GridView::from_single_vxl`, which leaves
494 // `chunk_grid = None`. Reuse that directly.
495 if let Some(single) = backing.chunks[0] {
496 return single;
497 }
498 }
499 roxlap_core::GridView::from_chunk_grid(cg, CHUNK_SIZE_XY)
500}
501
502/// matches-direct-opticast property — the test suite uses it as a
503/// sanity check that the combined-world stitch + render harness
504/// doesn't drift vs. a raw `opticast` call.
505///
506/// Caller pre-fills `fb` with the desired sky colour and `zb` with
507/// any value (typically `0.0` matching the per-chunk renderer's
508/// convention or `f32::INFINITY` for compose-friendly init); the
509/// rasterizer overwrites both per pixel that gets a hit.
510#[allow(clippy::too_many_arguments)]
511pub fn render_scene(
512 fb: &mut [u32],
513 zb: &mut [f32],
514 pitch_pixels: usize,
515 width: u32,
516 height: u32,
517 fog: CpuFog,
518 scene: &mut Scene,
519 camera: &Camera,
520 settings: &OpticastSettings,
521 sky: Option<&Sky>,
522) -> RenderOutcome {
523 debug_assert_eq!(fb.len(), zb.len());
524 let pixel_count = (width as usize) * (height as usize);
525 debug_assert_eq!(fb.len(), pixel_count);
526
527 let mut grids_drawn = 0usize;
528 for (_id, grid) in scene.render_grids_mut() {
529 // S4B.2.e: Approach B render path. World → grid-local
530 // camera transform doesn't need a voxel-offset adjustment
531 // anymore — Approach B's chunks live at their signed
532 // (chx, chy) indices and `chunk_at_xy` handles negative-
533 // index lookups natively.
534 //
535 // S5.0: per-grid arbitrary rotation. The local camera is
536 // built by `world_camera_to_grid_local` — translation +
537 // inverse-rotation of the basis. Identity rotation keeps
538 // this byte-identical to the pre-S5 translate-only form.
539 // DDA.7: refresh the cross-frame brick cache (needs `&mut grid`)
540 // before borrowing the grid immutably for `backing`.
541 let dda_mip = grid.ensure_dda_bricks(0);
542 let Some(backing) = grid.chunk_xyz_backing() else {
543 // Empty grid (no populated chz=0 chunks) — skip.
544 continue;
545 };
546 let local_cam = world_camera_to_grid_local(camera, &grid.transform);
547 let cg = roxlap_core::ChunkGrid {
548 chunks: &backing.chunks,
549 origin_chunk_xy: backing.origin_chunk_xy,
550 origin_chunk_z: backing.origin_chunk_z,
551 chunks_x: backing.chunks_x,
552 chunks_y: backing.chunks_y,
553 chunks_z: backing.chunks_z,
554 };
555 let grid_view = single_chunk_fast_path(&backing, &cg);
556 // DDA backend. The direct path doesn't pre-fill, so seed sky
557 // (black) + far depth here — DDA leaves misses untouched.
558 for px in fb.iter_mut() {
559 *px = 0;
560 }
561 for d in zb.iter_mut() {
562 *d = f32::INFINITY;
563 }
564 let fog_on = fog.max_scan_dist > 0;
565 // SC — opticast writes WORLD/vws² depth under a scaled basis, so the
566 // ray-terminating world thresholds (scan cutoff + opaque fog) are
567 // divided by vws² to fire at the intended world range. Identity at
568 // vws == 1.0 (byte-identical). See `scale_world_dist_f32`.
569 let vws = grid.transform.voxel_world_size;
570 #[allow(clippy::cast_precision_loss)]
571 let env = DdaEnv {
572 sky,
573 fog_color: if fog_on { fog.color } else { 0 },
574 fog_max_dist: if fog_on {
575 scale_world_dist_f32(fog.max_scan_dist.max(1) as f32, vws)
576 } else {
577 0.0
578 },
579 side_shades: fog.side_shades,
580 // The direct (non-composed) path is opaque-only; terrain
581 // materials flow through render_scene_composed_with_materials.
582 materials: None,
583 terrain_materials: &[],
584 // The direct path is unlit (lighting flows through the composed
585 // path); keep it on the baked-byte shade.
586 lights: CpuLights::default(),
587 world_shadow: None,
588 // CA.0 — per-grid cutaway clip (unread until CA.1).
589 z_clip: grid.z_clip,
590 // OC — the view cutout flows through the composed path only
591 // (per-frame facade state, like lights); the direct path
592 // renders uncut.
593 cutout: None,
594 fow: None,
595 };
596 // Scan-cutoff copy (identity at vws == 1.0 → same &settings).
597 let grid_settings;
598 let ray_settings = if (vws - 1.0).abs() <= f64::EPSILON {
599 settings
600 } else {
601 grid_settings = OpticastSettings {
602 max_scan_dist: scale_scan_dist_i32(settings.max_scan_dist, vws),
603 ..*settings
604 };
605 &grid_settings
606 };
607 render_dda_parallel(
608 &local_cam,
609 ray_settings,
610 grid_view,
611 fb,
612 zb,
613 pitch_pixels,
614 &env,
615 &grid.dda_brick_cache,
616 dda_mip,
617 );
618 // SC — opticast wrote VOXEL-unit depth (the camera is voxel-frame);
619 // scale it back to WORLD so the buffer is world-consistent (this
620 // path clears + renders per grid, so only this grid's pixels are
621 // present). No-op at vws == 1.0. INFINITY misses stay misses.
622 if (vws - 1.0).abs() > f64::EPSILON {
623 // world = written · vws² (see `scale_depth_rect`).
624 #[allow(clippy::cast_possible_truncation)]
625 let vws2 = (vws * vws) as f32;
626 for d in zb.iter_mut() {
627 *d *= vws2;
628 }
629 }
630 grids_drawn += 1;
631 }
632 if grids_drawn == 0 {
633 RenderOutcome::Empty
634 } else {
635 RenderOutcome::Rendered { grids_drawn }
636 }
637}
638
639/// Per-pixel "min-z wins" merge of `(temp_fb, temp_zb)` into
640/// `(shared_fb, shared_zb)`.
641///
642/// Voxlap's z-buffer convention: `z` = perpendicular distance from
643/// camera; **smaller `z` = closer to camera**. This helper picks
644/// the closer pixel per slot. Sky pixels emerge with a large `z`
645/// (`scratch.skycast.dist`, set to `gxmax` or `i32::MAX` per
646/// `phase_startsky`) so they always lose to any hit's finite
647/// distance.
648///
649/// `temp_fb` / `temp_zb` are read-only inputs; both must have the
650/// same length as `shared_fb` / `shared_zb` (debug-asserted).
651pub fn compose_into(
652 shared_fb: &mut [u32],
653 shared_zb: &mut [f32],
654 temp_fb: &[u32],
655 temp_zb: &[f32],
656) {
657 debug_assert_eq!(shared_fb.len(), shared_zb.len());
658 debug_assert_eq!(shared_fb.len(), temp_fb.len());
659 debug_assert_eq!(shared_fb.len(), temp_zb.len());
660 for i in 0..shared_fb.len() {
661 if temp_zb[i] < shared_zb[i] {
662 shared_fb[i] = temp_fb[i];
663 shared_zb[i] = temp_zb[i];
664 }
665 }
666}
667
668/// Half-open screen rectangle `[x0, x1) × [y0, y1)` a grid's
669/// projection is confined to — the scissor [`render_scene_composed`]
670/// uses to render and compose each grid only within its screen
671/// footprint instead of over the whole frame.
672#[derive(Clone, Copy, Debug)]
673struct ScreenRect {
674 x0: u32,
675 x1: u32,
676 y0: u32,
677 y1: u32,
678}
679
680impl ScreenRect {
681 fn is_empty(self) -> bool {
682 self.x0 >= self.x1 || self.y0 >= self.y1
683 }
684}
685
686/// Project a world-space bounding sphere `(centre, radius)` to a
687/// conservative screen rectangle under opticast's pinhole — focal `hz`,
688/// principal point `(hx, hy)`, ray for pixel `(px, py)` being
689/// `(px-hx)·right + (py-hy)·down + hz·forward` (camera_math). Returns:
690///
691/// - `Some(rect)` clamped to the viewport when the sphere is safely in
692/// front of the camera. The rect may be **empty** (sphere off to one
693/// side) → the grid can't appear, so the caller skips it entirely.
694/// - `None` when the camera is inside or near the sphere (forward-depth
695/// `z ≤ radius`), where a finite screen bound is unsafe → the caller
696/// must render the grid full-frame.
697///
698/// Conservative on purpose (never clips a pixel the full render would
699/// touch): the projected radius uses the over-estimate `hz·R/(z−R)`
700/// (exact is `hz·R/√(z²−R²)`) and pads by `anginc + 1`, matching the
701/// projection's `anginc` viewport padding.
702fn project_sphere_to_screen(
703 camera: &Camera,
704 centre: DVec3,
705 radius: f64,
706 settings: &OpticastSettings,
707) -> Option<ScreenRect> {
708 let d = centre - DVec3::from_array(camera.pos);
709 let z = d.dot(DVec3::from_array(camera.forward));
710 if z <= radius {
711 return None; // camera inside / in front of the sphere shell
712 }
713 let x = d.dot(DVec3::from_array(camera.right));
714 let y = d.dot(DVec3::from_array(camera.down));
715 let (hx, hy, hz) = (
716 f64::from(settings.hx),
717 f64::from(settings.hy),
718 f64::from(settings.hz),
719 );
720 let sr = hz * radius / (z - radius); // over-estimated screen radius
721 let sx = hx + x / z * hz;
722 let sy = hy + y / z * hz;
723 let pad = f64::from(settings.anginc) + 1.0;
724 let (xres, yres) = (f64::from(settings.xres), f64::from(settings.yres));
725 let clamp = |v: f64, hi: f64| v.clamp(0.0, hi);
726 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
727 Some(ScreenRect {
728 x0: clamp((sx - sr - pad).floor(), xres) as u32,
729 x1: clamp((sx + sr + pad).ceil(), xres) as u32,
730 y0: clamp((sy - sr - pad).floor(), yres) as u32,
731 y1: clamp((sy + sr + pad).ceil(), yres) as u32,
732 })
733}
734
735/// Fill each `rect` row of a `u32` buffer (row stride `pitch`) with
736/// `val` — the scissored analogue of `slice.fill(val)`.
737fn fill_rect_u32(buf: &mut [u32], pitch: usize, rect: ScreenRect, val: u32) {
738 for y in rect.y0..rect.y1 {
739 let row = y as usize * pitch;
740 buf[row + rect.x0 as usize..row + rect.x1 as usize].fill(val);
741 }
742}
743
744/// Fill each `rect` row of an `f32` buffer (row stride `pitch`) with `val`.
745fn fill_rect_f32(buf: &mut [f32], pitch: usize, rect: ScreenRect, val: f32) {
746 for y in rect.y0..rect.y1 {
747 let row = y as usize * pitch;
748 buf[row + rect.x0 as usize..row + rect.x1 as usize].fill(val);
749 }
750}
751
752/// Min-z compose `temp_*` into `fb`/`zb` over `rect` only — the
753/// scissored analogue of [`compose_into`]. A `temp` pixel wins where its
754/// `z` is strictly smaller than the destination's.
755///
756/// PF.7 (C6) — rayon rows: a memory-bandwidth loop repeated per grid per
757/// frame; rows are disjoint (`par_chunks_mut` of both destinations),
758/// sources read-only. Bit-identical.
759fn compose_rect(
760 fb: &mut [u32],
761 zb: &mut [f32],
762 temp_fb: &[u32],
763 temp_zb: &[f32],
764 pitch: usize,
765 rect: ScreenRect,
766) {
767 use rayon::prelude::*;
768 let (y0, y1) = (rect.y0 as usize, rect.y1 as usize);
769 let (x0, x1) = (rect.x0 as usize, rect.x1 as usize);
770 if y0 >= y1 {
771 return;
772 }
773 // The last row may be short of a full `pitch` when the buffer is
774 // exactly `width*height` — clamp the slice end.
775 let end = (y1 * pitch).min(fb.len());
776 fb[y0 * pitch..end]
777 .par_chunks_mut(pitch)
778 .zip(zb[y0 * pitch..end].par_chunks_mut(pitch))
779 .enumerate()
780 .for_each(|(dy, (frow, zrow))| {
781 let row = (y0 + dy) * pitch;
782 for x in x0..x1 {
783 if temp_zb[row + x] < zrow[x] {
784 zrow[x] = temp_zb[row + x];
785 frow[x] = temp_fb[row + x];
786 }
787 }
788 });
789}
790
791/// PF.7 (C6) — reusable scratch for the composed scene render: the
792/// per-grid temp framebuffer/z-buffer pair (was two full-frame `vec!`
793/// allocations + initialising writes per call — ≈7.4 MB at 720p, ×16
794/// under 4×SSAA), the per-grid light scratch, and the phase-A mip map.
795/// Own one per renderer and pass it to
796/// [`render_scene_composed_with_materials_scratch`]; the buffers grow to
797/// the frame size on first use and are reused verbatim afterwards (every
798/// pixel the render reads is filled per grid first, so no per-frame
799/// clear is needed).
800#[derive(Default)]
801pub struct SceneRenderScratch {
802 temp_fb: Vec<u32>,
803 temp_zb: Vec<f32>,
804 lights: Vec<CpuPointLight>,
805 eff_mips: HashMap<GridId, u32>,
806}
807
808/// Render every grid in `scene` with per-grid temporary buffers +
809/// z-buffer composition. The canonical multi-grid scene render
810/// path.
811///
812/// Algorithm:
813/// 1. Caller pre-fills `fb` with the desired sky colour and `zb`
814/// with [`f32::INFINITY`] (so any rendered pixel wins the
815/// initial composition).
816/// 2. For each grid, allocate a temporary `(temp_fb, temp_zb)` of
817/// the same size, pre-fill them with sky / `INFINITY`, and run
818/// `opticast` into them via a `ScalarRasterizer` over the
819/// temporary buffers AND the grid's combined-world view (S4.0).
820/// 3. Merge the temporary buffers into the shared `(fb, zb)` via
821/// [`compose_into`] — closer pixels (smaller `z`) win.
822///
823/// Pixel correctness across overlapping grids: sky pixels emerge
824/// with `z` = `gxmax` / `i32::MAX` (a very large value), so they
825/// always lose to any hit. Hits compete on actual perpendicular
826/// distance — the closer grid's surface is what gets composited.
827///
828/// `pitch_pixels` is the framebuffer's row stride in pixels (×4 for
829/// bytes). `width` × `height` must equal `fb.len()` /
830/// `zb.len()`. `sky` is the optional textured sky resource the
831/// rasterizer threads through to `phase_startsky`; `None` ⇒ solid
832/// `pool.skycast` fill.
833///
834/// **Heap allocation per call:** two `Vec` allocations per grid (a
835/// temp framebuffer and zbuffer). For repeated frame rendering an
836/// owned scratch struct that pre-allocates these is the obvious
837/// optimisation; deferred until profiling shows it matters.
838#[allow(clippy::too_many_arguments)]
839pub fn render_scene_composed(
840 fb: &mut [u32],
841 zb: &mut [f32],
842 pitch_pixels: usize,
843 width: u32,
844 height: u32,
845 fog: CpuFog,
846 scene: &mut Scene,
847 camera: &Camera,
848 settings: &OpticastSettings,
849 sky_color: u32,
850 sky: Option<&Sky>,
851) -> RenderOutcome {
852 let mut params = ComposedFrameParams::new(camera, settings);
853 params.fog = fog;
854 params.sky_color = sky_color;
855 params.sky = sky;
856 render_scene_composed_scissored(
857 fb,
858 zb,
859 pitch_pixels,
860 width,
861 height,
862 scene,
863 ¶ms,
864 true,
865 &mut SceneRenderScratch::default(),
866 )
867}
868
869/// [`render_scene_composed`] with TV terrain materials: `materials` is the
870/// global palette and `terrain_materials` the colour→material map; together
871/// they make matching-colour terrain voxels translucent (front-to-back
872/// composited). An empty map / `None` palette renders identically to
873/// [`render_scene_composed`].
874#[allow(clippy::too_many_arguments)]
875pub fn render_scene_composed_with_materials(
876 fb: &mut [u32],
877 zb: &mut [f32],
878 pitch_pixels: usize,
879 width: u32,
880 height: u32,
881 fog: CpuFog,
882 scene: &mut Scene,
883 camera: &Camera,
884 settings: &OpticastSettings,
885 sky_color: u32,
886 sky: Option<&Sky>,
887 materials: Option<&MaterialTable>,
888 terrain_materials: &[(Rgb, u8)],
889 lights: CpuLights<'_>,
890 // XS.2 — sprite-cast shadow occluder (so sprites darken terrain). `None` ⇒
891 // grids-only shadows.
892 sprite_occluder: Option<&dyn WorldOccluder>,
893) -> RenderOutcome {
894 let mut params = ComposedFrameParams::new(camera, settings);
895 params.fog = fog;
896 params.sky_color = sky_color;
897 params.sky = sky;
898 params.materials = materials;
899 params.terrain_materials = terrain_materials;
900 params.lights = lights;
901 params.sprite_occluder = sprite_occluder;
902 render_scene_composed_scissored(
903 fb,
904 zb,
905 pitch_pixels,
906 width,
907 height,
908 scene,
909 ¶ms,
910 true,
911 &mut SceneRenderScratch::default(),
912 )
913}
914
915/// [`render_scene_composed_with_materials`] with a caller-owned
916/// [`SceneRenderScratch`] (PF.7) — the temp buffer pair and per-grid
917/// scratch are reused across frames instead of re-allocated per call.
918/// (New inputs land as [`ComposedFrameParams`] fields consumed by
919/// [`render_scene_composed_frame`] — this positional family is frozen.)
920#[allow(clippy::too_many_arguments)]
921pub fn render_scene_composed_with_materials_scratch(
922 fb: &mut [u32],
923 zb: &mut [f32],
924 pitch_pixels: usize,
925 width: u32,
926 height: u32,
927 fog: CpuFog,
928 scene: &mut Scene,
929 camera: &Camera,
930 settings: &OpticastSettings,
931 sky_color: u32,
932 sky: Option<&Sky>,
933 materials: Option<&MaterialTable>,
934 terrain_materials: &[(Rgb, u8)],
935 lights: CpuLights<'_>,
936 sprite_occluder: Option<&dyn WorldOccluder>,
937 scratch: &mut SceneRenderScratch,
938) -> RenderOutcome {
939 let mut params = ComposedFrameParams::new(camera, settings);
940 params.fog = fog;
941 params.sky_color = sky_color;
942 params.sky = sky;
943 params.materials = materials;
944 params.terrain_materials = terrain_materials;
945 params.lights = lights;
946 params.sprite_occluder = sprite_occluder;
947 render_scene_composed_scissored(
948 fb,
949 zb,
950 pitch_pixels,
951 width,
952 height,
953 scene,
954 ¶ms,
955 true,
956 scratch,
957 )
958}
959
960/// Backing implementation of [`render_scene_composed`] with the
961/// per-grid screen-AABB scissor toggleable. `scissor = true` is the
962/// production path; the regression test renders the same scene with
963/// `false` (full-frame per grid, the pre-scissor behaviour) and asserts
964/// the framebuffer is byte-identical — the scissor must be a pure
965/// speed-up, never change a pixel.
966#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
967fn render_scene_composed_scissored(
968 fb: &mut [u32],
969 zb: &mut [f32],
970 pitch_pixels: usize,
971 width: u32,
972 height: u32,
973 scene: &mut Scene,
974 params: &ComposedFrameParams<'_>,
975 scissor: bool,
976 // PF.7 — caller-owned reusable buffers (see [`SceneRenderScratch`]).
977 scratch: &mut SceneRenderScratch,
978) -> RenderOutcome {
979 // Every field is `Copy` (values or shared refs) — unpack under the
980 // historical local names so the body reads unchanged.
981 let &ComposedFrameParams {
982 camera,
983 settings,
984 fog,
985 sky_color,
986 sky,
987 materials,
988 terrain_materials,
989 lights,
990 sprite_occluder,
991 view_cutout,
992 fow,
993 } = params;
994 debug_assert_eq!(fb.len(), zb.len());
995 let pixel_count = (width as usize) * (height as usize);
996 debug_assert_eq!(fb.len(), pixel_count);
997
998 let mut grids_drawn = 0usize;
999 // PF.7 (C6) — size (don't clear) the temp pair: every pixel the
1000 // render reads inside a grid's rect is `fill_rect_*`-initialised for
1001 // that grid first, and `compose_rect` reads only within the rect, so
1002 // stale contents outside are never observed. This removes two
1003 // full-frame allocations AND their initialising writes per call.
1004 let scratch = &mut *scratch;
1005 scratch.temp_fb.resize(pixel_count, 0);
1006 scratch.temp_zb.resize(pixel_count, f32::INFINITY);
1007 let temp_fb = &mut scratch.temp_fb[..pixel_count];
1008 let temp_zb = &mut scratch.temp_zb[..pixel_count];
1009
1010 // XS.1 — phase A (`&mut`): materialise the per-frame caches the render
1011 // reads — DDA brick caches (Near/Mid) and Far-tier billboard impostors —
1012 // and record each grid's effective DDA mip. Hoisting these out of the
1013 // render loop lets phase B run over `&Scene` immutably, so the cross-grid
1014 // shadow occluder (which also borrows the scene) can coexist with it.
1015 let cam_world = DVec3::from_array(camera.pos);
1016 let eff_mips = &mut scratch.eff_mips;
1017 eff_mips.clear();
1018 for (id, grid) in scene.render_grids_mut() {
1019 let lod = grid.select_lod(cam_world);
1020 if lod == Lod::Far {
1021 // CA — a cache built under a different cutaway clip is
1022 // stale: rebuilding here (not only in `set_grid_z_clip`)
1023 // also catches hosts that write `grid.z_clip` directly.
1024 let stale_clip = grid
1025 .billboards
1026 .as_ref()
1027 .is_some_and(|c| c.built_z_clip != grid.z_clip);
1028 if !grid.chunks.is_empty() && (grid.billboards.is_none() || stale_clip) {
1029 let cache = BillboardCache::build(grid, BILLBOARD_RESOLUTION);
1030 grid.billboards = Some(cache);
1031 }
1032 continue; // Far blits an impostor; no brick cache / mip needed.
1033 }
1034 let req = match lod {
1035 Lod::Mid => grid
1036 .lod_thresholds
1037 .mid_mip_levels
1038 .map_or(0, |n| n.saturating_sub(1)),
1039 Lod::Near | Lod::Far => 0,
1040 };
1041 eff_mips.insert(id, grid.ensure_dda_bricks(req));
1042 }
1043
1044 // Reborrow immutably for phase B + the shadow occluder.
1045 let scene: &Scene = scene;
1046
1047 // XS.1 — cross-grid hard shadows: build the world-space scene occluder
1048 // once when shadows are actually active (a caster flagged + non-zero
1049 // strength), so the shadow ray at a terrain hit tests every grid, not
1050 // just the one it hit. `None` ⇒ the single-grid `SamplerShadow` path.
1051 let shadows_on = lights.enabled
1052 && lights.shadow_strength > 0.0
1053 && (lights.sun_casts_shadow || lights.points.iter().any(|p| p.casts_shadow));
1054 let grid_occ = shadows_on
1055 .then(|| SceneOccluder::build(scene, fow))
1056 .filter(|o| !o.is_empty());
1057 // XS.2 — combine the grid occluder with the sprite occluder (sprites cast
1058 // onto terrain). `composite_store` backs the borrow when both are present.
1059 let composite_store;
1060 let active_occluder: Option<&dyn WorldOccluder> = if shadows_on {
1061 match (grid_occ.as_ref(), sprite_occluder) {
1062 (Some(g), Some(s)) => {
1063 composite_store = CompositeOccluder { a: g, b: s };
1064 Some(&composite_store)
1065 }
1066 (Some(g), None) => Some(g),
1067 (None, Some(s)) => Some(s),
1068 (None, None) => None,
1069 }
1070 } else {
1071 None
1072 };
1073
1074 for (grid_id, grid) in scene.render_grids() {
1075 // S6.0/S6.1: per-grid LOD tier dispatch. The picker keys
1076 // off the grid's `lod_thresholds` and the world-space
1077 // camera. Default thresholds are `always_near` so every
1078 // grid lands on `Lod::Near` and the framebuffer stays
1079 // byte-identical to the pre-S6 path.
1080 //
1081 // S6.1: `Mid` applies the grid's `mid_mip_levels` /
1082 // `mid_mip_scan_dist` overrides (if `Some`) on top of the
1083 // base settings, biasing the grid into coarser mips. With
1084 // both `None`, Mid renders identically to Near (graceful
1085 // degrade — callers opt into the Mid plumbing via
1086 // `LodThresholds::from_radius_with_mid_mip`).
1087 //
1088 // S6.3: `Far` skips the opticast path entirely — render
1089 // dispatches into the billboard impostor blit (below). The
1090 // LOD enum is computed before `chunk_xyz_backing` because
1091 // the Far branch needs `&mut grid` for the lazy cache
1092 // populate, which conflicts with the `&grid` lifetime
1093 // backing's tied to.
1094 let lod = grid.select_lod(DVec3::from_array(camera.pos));
1095
1096 if lod == Lod::Far {
1097 // S6.3: Far-tier billboard blit. The impostor cache was built in
1098 // phase A (above); this immutable pass only reads it.
1099 //
1100 // Empty grids have nothing to impostor; skip.
1101 if grid.chunks.is_empty() {
1102 continue;
1103 }
1104 // Grid bounds → world-space centre + radius (SC.3 folds in vws).
1105 let (centre_world, world_radius) = grid_world_bounds(grid);
1106 // Query direction = unit vector from grid centre TO
1107 // camera, in grid-local space (snapshots' `view_dir`s
1108 // live in that frame).
1109 let cam_pos = DVec3::from_array(camera.pos);
1110 let centre_to_cam_world = cam_pos - centre_world;
1111 let ctc_len = centre_to_cam_world.length();
1112 if !ctc_len.is_finite() || ctc_len < 1e-9 {
1113 // Camera essentially at grid centre — pick_nearest
1114 // is ill-defined. Skip; a future frame at a
1115 // resolvable pose will render normally.
1116 continue;
1117 }
1118 let query_dir_world = centre_to_cam_world / ctc_len;
1119 let query_dir_local = grid.transform.rotation.inverse() * query_dir_world;
1120 // Cache was populated in phase A for non-empty Far grids; if it's
1121 // somehow absent, skip (a future frame re-enters Far and builds).
1122 let Some(cache) = grid.billboards.as_ref() else {
1123 continue;
1124 };
1125 // pick_nearest only returns None for empty caches; the phase-A
1126 // build produced a 26-snapshot cache so this resolves.
1127 let Some(snapshot) = cache.pick_nearest(query_dir_local) else {
1128 continue;
1129 };
1130 billboard::billboard_blit_into(
1131 fb,
1132 zb,
1133 pitch_pixels,
1134 width,
1135 height,
1136 snapshot,
1137 centre_world,
1138 world_radius,
1139 camera,
1140 settings,
1141 );
1142 grids_drawn += 1;
1143 continue;
1144 }
1145
1146 // S4B.2.e: Approach B render path. See `render_scene`'s
1147 // body for the camera transform + ChunkGrid construction
1148 // commentary; the only difference is this writes to
1149 // (temp_fb, temp_zb) and composes via `compose_into`.
1150 // S5.0: per-grid rotation flows via the shared helper.
1151 //
1152 // DDA.7: refresh the cross-frame brick cache (needs `&mut grid`)
1153 // before the immutable `backing` borrow. Render mip by LOD tier:
1154 // Near = full detail, Mid = coarser (clamped to built mips).
1155 // Mid tier: coarsen by the grid's `mid_mip_levels` override
1156 // (a level count → uniform DDA mip `n-1`). No override ⇒ mip
1157 // 0, i.e. byte-identical to Near (the override is opt-in).
1158 // Effective DDA mip: the brick cache was ensured in phase A; reuse the
1159 // mip it resolved (Near/Mid grids are recorded; default 0 otherwise).
1160 let dda_eff_mip = eff_mips.get(&grid_id).copied().unwrap_or(0);
1161 let Some(backing) = grid.chunk_xyz_backing() else {
1162 continue;
1163 };
1164
1165 // Out-of-range early-out: skip the per-grid opticast pass
1166 // when the grid's bounding sphere is entirely beyond
1167 // `max_scan_dist`. Each opticast call walks ~width*height
1168 // rays even when no ray reaches a voxel, so far-away marker
1169 // pillars / pickups otherwise cost ~9 ms each at the bench
1170 // pose. Safe: if the closest point of the sphere is past
1171 // max_scan_dist, no ray can possibly reach the grid, so
1172 // dropping the opticast pass is byte-identical.
1173 //
1174 // `grid_bounds` walks `grid.chunks.keys()`; for the ground's
1175 // ~1024 chunks it costs ~10 µs amortised against the ~50 ms
1176 // it might save by culling 4-of-5 markers in the live demo.
1177 let (centre_world, world_radius) = grid_world_bounds(grid);
1178 let cam_pos = DVec3::from_array(camera.pos);
1179 let dist_to_centre = (centre_world - cam_pos).length();
1180 if dist_to_centre - world_radius > f64::from(settings.max_scan_dist) {
1181 continue;
1182 }
1183
1184 // Per-grid screen-space scissor: confine this grid's opticast +
1185 // temp reset + compose to the true screen rect its projection
1186 // spans, and skip the grid entirely when it projects fully
1187 // off-screen on either axis. `project_sphere_to_screen` is
1188 // conservative (over-estimates the footprint), so the rendered
1189 // pixels stay byte-identical to the full-frame path — only the
1190 // work shrinks.
1191 //
1192 // PF.13 (C7) — the horizontal extent now clips the render too.
1193 // The historical full-width-only constraint guarded the deleted
1194 // voxlap radar's column-indexed `angstart` (never reset per
1195 // grid, so x-clipping read stale entries at extreme poses); the
1196 // DDA renderer's pixels are fully independent, so the x band is
1197 // as safe as the long-proven y band. Small grids (markers,
1198 // pickups, ships) stop paying full-width rows of render + fill
1199 // + compose. `None` (camera inside/near the sphere) renders
1200 // full-frame; `scissor = false` disables it all for the
1201 // byte-identity regression test.
1202 let full_rect = ScreenRect {
1203 x0: 0,
1204 x1: width,
1205 y0: 0,
1206 y1: height,
1207 };
1208 let rect = if scissor {
1209 match project_sphere_to_screen(camera, centre_world, world_radius, settings) {
1210 // Off-screen on either axis → the grid can't appear.
1211 Some(r) if r.is_empty() => continue,
1212 Some(r) => r,
1213 None => full_rect,
1214 }
1215 } else {
1216 full_rect
1217 };
1218
1219 // S5.2-followup: per-grid sky opt-out. Grids with
1220 // `render_sky = false` (e.g. a rotating ship) must not
1221 // contribute sky pixels — the grid-local sky lookup
1222 // rotates with the grid and visibly fights the world's
1223 // sky during compose. Implementation: stamp a sentinel
1224 // colour into temp_fb everywhere the rasterizer would
1225 // paint sky, then walk the buffer post-opticast and
1226 // mark sentinel pixels as `INFINITY` in temp_zb so
1227 // [`compose_into`]'s min-z test always drops them.
1228 let owns_sky = grid.render_sky;
1229 let local_sky_color = if owns_sky {
1230 sky_color
1231 } else {
1232 SKY_MASK_SENTINEL
1233 };
1234
1235 // Reset temp to sky / INFINITY so each grid starts fresh —
1236 // only within the grid's screen rect (opticast writes nothing
1237 // outside it, and the rect-limited compose reads nothing there).
1238 fill_rect_u32(temp_fb, pitch_pixels, rect, local_sky_color);
1239 fill_rect_f32(temp_zb, pitch_pixels, rect, f32::INFINITY);
1240
1241 let local_cam = world_camera_to_grid_local(camera, &grid.transform);
1242 let cg = roxlap_core::ChunkGrid {
1243 chunks: &backing.chunks,
1244 origin_chunk_xy: backing.origin_chunk_xy,
1245 origin_chunk_z: backing.origin_chunk_z,
1246 chunks_x: backing.chunks_x,
1247 chunks_y: backing.chunks_y,
1248 chunks_z: backing.chunks_z,
1249 };
1250 let grid_view = single_chunk_fast_path(&backing, &cg);
1251
1252 // Build the per-grid settings by layering three opt-in
1253 // overrides on top of the caller's `settings`:
1254 //
1255 // 1. (S6.1) `lod_thresholds.mid_mip_levels` /
1256 // `mid_mip_scan_dist` — applied iff `lod == Mid`.
1257 // Biases the grid into coarser mips via the existing
1258 // multi-mip path. None ⇒ Mid degrades to Near's
1259 // settings (graceful).
1260 // 2. (S5.2-followup) `Grid::mip_levels_override` — global
1261 // per-grid cap applied at ALL tiers. Preserves the
1262 // ship anti-axis-aligned-beam workaround through Mid
1263 // tier (so a rotating ship pinned at mip-0 stays at
1264 // mip-0 even when distant).
1265 //
1266 // Layer order: Mid overrides first, then global cap. Both
1267 // mip_levels overrides are clamped to `[1, base.mip_levels]`
1268 // since the base is the maximum the renderer can use
1269 // (chunk's `chunk_mips`-min logic inside scalar_rasterizer
1270 // applies further per-chunk).
1271 let per_grid_settings;
1272 let active_settings = {
1273 let base_mip_levels = settings.mip_levels;
1274 let base_mip_scan = settings.mip_scan_dist;
1275 let lod_mip_levels = match lod {
1276 Lod::Mid => grid.lod_thresholds.mid_mip_levels,
1277 Lod::Near | Lod::Far => None,
1278 };
1279 let lod_mip_scan = match lod {
1280 Lod::Mid => grid.lod_thresholds.mid_mip_scan_dist,
1281 Lod::Near | Lod::Far => None,
1282 };
1283 let global_mip_cap = grid.mip_levels_override;
1284 let needs_override =
1285 lod_mip_levels.is_some() || lod_mip_scan.is_some() || global_mip_cap.is_some();
1286 if needs_override {
1287 // Resolve mip_levels: start with base, apply LOD
1288 // override (clamped to base), then apply global cap.
1289 let mut mip_levels =
1290 lod_mip_levels.map_or(base_mip_levels, |n| n.clamp(1, base_mip_levels));
1291 if let Some(cap) = global_mip_cap {
1292 mip_levels = mip_levels.min(cap.clamp(1, base_mip_levels));
1293 }
1294 // Resolve mip_scan_dist: LOD override clamps to
1295 // `min(base, override)` — the override only makes
1296 // transitions kick in CLOSER, never farther. The
1297 // renderer floors at 4 internally so we don't
1298 // bottom-clamp here.
1299 let mip_scan_dist = lod_mip_scan.map_or(base_mip_scan, |d| base_mip_scan.min(d));
1300 per_grid_settings = OpticastSettings {
1301 mip_levels,
1302 mip_scan_dist,
1303 ..*settings
1304 };
1305 &per_grid_settings
1306 } else {
1307 settings
1308 }
1309 };
1310
1311 // PF.13 (C7) — 2D scissor: restrict the render to the grid's
1312 // true screen rect. The y strip is the long-proven path; the x
1313 // band joins it now that the radar-era `angstart` fragility is
1314 // gone (see the rect computation above). Byte-identical to the
1315 // full frame when the rect is `0..width × 0..height`.
1316 // SC — the scan cutoff (`max_scan_dist`) is a WORLD distance the ray
1317 // compares against its WORLD/vws² depth, so it is divided by vws² for
1318 // the ray to reach the intended world range (a fine grid would
1319 // otherwise be clipped short). Identity at vws == 1.0 (byte-identical).
1320 // The world-space distance cull above uses the *unscaled* setting.
1321 let vws = grid.transform.voxel_world_size;
1322 let mut scissored = (*active_settings)
1323 .with_y_range(rect.y0, rect.y1)
1324 .with_x_range(rect.x0, rect.x1);
1325 scissored.max_scan_dist = scale_scan_dist_i32(scissored.max_scan_dist, vws);
1326 // DDA backend. temp_fb / temp_zb are already pre-filled with
1327 // sky / INFINITY for this grid's rect, so a miss with no
1328 // textured sky yields the correct solid sky.
1329 //
1330 // Fog is config-driven: on iff the caller set `max_scan_dist > 0`
1331 // in `fog`. Off → no blend, so exact-colour tests and unfogged
1332 // hosts are unaffected. Linear ramp toward the configured fog
1333 // colour over `max_scan_dist`. Sky texture is suppressed for
1334 // `!owns_sky` grids so the textured-sky branch doesn't bypass
1335 // the sentinel.
1336 let fog_on = fog.max_scan_dist > 0;
1337 // CPU.1 — transform the world lights into this grid's local frame
1338 // (the reused point scratch lives for the grid's render below).
1339 // PF.7 — lights that can't reach the grid's bounding sphere are
1340 // culled (`bounds`/`centre_world` computed for the distance cull
1341 // above).
1342 let local_lights = grid_local_lights(
1343 &lights,
1344 &grid.transform,
1345 &mut scratch.lights,
1346 Some((centre_world, world_radius)),
1347 );
1348 // XS.1 — cross-grid shadows: hand the shade the scene-wide occluder
1349 // plus this grid's local→world transform, so a grid-local shadow ray
1350 // is lifted to world space and tested against every grid. `cols[i]`
1351 // is the world image of grid-local axis `i` (the rotation's columns).
1352 let world_shadow = active_occluder.map(|occ| {
1353 let r = grid.transform.rotation;
1354 let col = |v: DVec3| {
1355 let w = r * v;
1356 [w.x as f32, w.y as f32, w.z as f32]
1357 };
1358 let o = grid.transform.origin;
1359 #[allow(clippy::cast_possible_truncation)]
1360 WorldShadowCtx {
1361 occluder: occ,
1362 // SC.2 — keep the grid world origin at full f64 precision.
1363 origin: [o.x, o.y, o.z],
1364 cols: [col(DVec3::X), col(DVec3::Y), col(DVec3::Z)],
1365 // SC.2 — caster vws: the shade's grid-local voxel ray scales
1366 // to world by this before the scene-wide occlusion test.
1367 voxel_world_size: grid.transform.voxel_world_size as f32,
1368 }
1369 });
1370 // FW.2 — fog-of-war styling for the twin grid only. Built as a
1371 // loop-local so `DdaEnv` can borrow it as `&dyn FowStyler`; every
1372 // other grid gets `None` (byte-identical). The mask is grid-local
1373 // and the twin shares the real grid's voxel coordinates, so the
1374 // hit's grid-local voxel indexes it directly.
1375 let fow_styler = fow
1376 .filter(|(fid, _)| *fid == grid_id)
1377 .map(|(_, f)| crate::fow::FowRender::new(f));
1378 #[allow(clippy::cast_precision_loss)]
1379 let env = DdaEnv {
1380 sky: if owns_sky { sky } else { None },
1381 fog_color: if fog_on { fog.color } else { 0 },
1382 // SC — opaque-fog distance also terminates the ray, so it is
1383 // divided by vws² alongside the scan cutoff (identity at vws==1.0).
1384 fog_max_dist: if fog_on {
1385 scale_world_dist_f32(fog.max_scan_dist.max(1) as f32, vws)
1386 } else {
1387 0.0
1388 },
1389 side_shades: fog.side_shades,
1390 materials,
1391 terrain_materials,
1392 lights: local_lights,
1393 world_shadow,
1394 // CA.0 — per-grid cutaway clip (unread until CA.1).
1395 z_clip: grid.z_clip,
1396 // OC.0 — the frame's view cutout in this grid's terms:
1397 // world focus → grid-local voxel coordinates exactly like
1398 // the light transforms above; the reveal distance is a
1399 // LINEAR world→voxel conversion (`/vws`, hazard 3 — the
1400 // per-cell rule measures Euclidean cell distances, not
1401 // opticast's `vws²` ray depth); the cone half-angles are
1402 // rotation/scale-invariant and pass through.
1403 cutout: view_cutout.map(|c| {
1404 // The SHARED world→grid conversion (both backends'
1405 // facades call it — see [`cutout_grid_local`]).
1406 let (local_focus, focus_z) =
1407 cutout_grid_local(DVec3::from_array(c.focus_world), c.z_bias, &grid.transform);
1408 #[allow(clippy::cast_possible_truncation)]
1409 roxlap_core::dda::CpuCutout {
1410 focus_local: [
1411 local_focus.x as f32,
1412 local_focus.y as f32,
1413 local_focus.z as f32,
1414 ],
1415 tan_outer: c.tan_outer,
1416 tan_inner: c.tan_inner,
1417 margin: (f64::from(c.margin) / vws) as f32,
1418 focus_z,
1419 }
1420 }),
1421 fow: fow_styler
1422 .as_ref()
1423 .map(|s| s as &dyn roxlap_core::dda::FowStyler),
1424 };
1425 // Effective render mip + brick cache were prepared above
1426 // (DDA.6 uniform per-grid mip, DDA.7 cross-frame cache).
1427 render_dda_parallel(
1428 &local_cam,
1429 &scissored,
1430 grid_view,
1431 temp_fb,
1432 temp_zb,
1433 pitch_pixels,
1434 &env,
1435 &grid.dda_brick_cache,
1436 dda_eff_mip,
1437 );
1438 // SC — voxel-unit depth → world, so the cross-grid min-z compose
1439 // below is world-comparable across grids of different scale.
1440 // No-op at vws == 1.0 (byte-identical).
1441 scale_depth_rect(temp_zb, pitch_pixels, rect, grid.transform.voxel_world_size);
1442
1443 if !owns_sky {
1444 // Mask sentinel pixels so compose drops them — only within
1445 // the grid's rect (opticast wrote nothing outside it).
1446 for y in rect.y0..rect.y1 {
1447 let row = y as usize * pitch_pixels;
1448 for i in row + rect.x0 as usize..row + rect.x1 as usize {
1449 if temp_fb[i] == SKY_MASK_SENTINEL {
1450 temp_zb[i] = f32::INFINITY;
1451 }
1452 }
1453 }
1454 }
1455
1456 compose_rect(fb, zb, temp_fb, temp_zb, pitch_pixels, rect);
1457 grids_drawn += 1;
1458 }
1459
1460 if grids_drawn == 0 {
1461 RenderOutcome::Empty
1462 } else {
1463 RenderOutcome::Rendered { grids_drawn }
1464 }
1465}
1466
1467#[cfg(test)]
1468#[allow(clippy::float_cmp)]
1469mod tests {
1470 use super::*;
1471 use crate::{GridTransform, Scene, CHUNK_SIZE_XY};
1472 use glam::{DVec3, IVec3};
1473 use roxlap_core::opticast::OpticastSettings;
1474 use roxlap_core::{Camera, Engine};
1475 use roxlap_formats::color::VoxColor;
1476
1477 const XRES: u32 = 320;
1478 const YRES: u32 = 200;
1479
1480 /// Build a single-grid scene at the given world origin with a
1481 /// recognisable shape inside its chunk (0, 0, 0): a 16-voxel
1482 /// box plus a 6-radius sphere. Returns `(scene, grid_id)`.
1483 fn build_one_grid_scene(world_origin: DVec3) -> (Scene, crate::GridId) {
1484 let mut scene = Scene::new();
1485 let id = scene.add_grid(GridTransform::at(world_origin));
1486 let grid = scene.grid_mut(id).unwrap();
1487 // Box covering [40..56]³ in chunk-local coords.
1488 grid.set_rect(
1489 IVec3::new(40, 40, 40),
1490 IVec3::new(55, 55, 55),
1491 Some(VoxColor(0x80_88_88_88)),
1492 );
1493 // Sphere at (80, 80, 80) radius 6.
1494 grid.set_sphere(IVec3::new(80, 80, 80), 6, Some(VoxColor(0x80_22_aa_22)));
1495 (scene, id)
1496 }
1497
1498 fn camera_at(pos: [f64; 3]) -> Camera {
1499 // Look +y axis; voxlap z-down convention. Right-handed:
1500 // right × down == forward.
1501 Camera {
1502 pos,
1503 right: [-1.0, 0.0, 0.0],
1504 down: [0.0, 0.0, 1.0],
1505 forward: [0.0, 1.0, 0.0],
1506 }
1507 }
1508
1509 /// Spin up an engine + framebuffers ready for one `render_scene`
1510 /// pass. `_pool_vsid` is retained for call-site compatibility but
1511 /// the DDA backend needs no pre-sized scratch pool.
1512 fn render_setup(_pool_vsid: u32) -> (Engine, Vec<u32>, Vec<f32>) {
1513 let engine = Engine::new();
1514 let sky = engine.sky_color();
1515 let pixel_count = (XRES as usize) * (YRES as usize);
1516 let framebuffer = vec![sky; pixel_count];
1517 let zbuffer = vec![0.0f32; pixel_count];
1518 (engine, framebuffer, zbuffer)
1519 }
1520
1521 /// Render `scene` via [`render_scene`] (single-grid no-compose
1522 /// path) and return the resulting framebuffer.
1523 fn render_via_scene(scene: &mut Scene, camera: &Camera) -> Vec<u32> {
1524 let (_engine, mut fb, mut zb) = render_setup(CHUNK_SIZE_XY);
1525 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1526 let outcome = render_scene(
1527 &mut fb,
1528 &mut zb,
1529 XRES as usize,
1530 XRES,
1531 YRES,
1532 CpuFog::default(),
1533 scene,
1534 camera,
1535 &settings,
1536 None,
1537 );
1538 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
1539 fb
1540 }
1541
1542 /// XS.1 — cross-grid hard shadows: a block in grid **B** casts a sun
1543 /// shadow onto the floor of grid **A**. Renders the two-grid scene with
1544 /// the sun shadow-casting vs not; the shadow only exists if the shadow
1545 /// ray from A's floor crossed into B, so the shadowed render must be
1546 /// strictly (and non-trivially) darker.
1547 #[test]
1548 fn cross_grid_sun_shadow_darkens_other_grid() {
1549 // Grid A: a wide floor at world z∈[60,62]. Grid B (same origin): a
1550 // 10-tall block at x∈[50,60]. Sun grazes from +x and above, so B's
1551 // shadow lands on A's floor at x≈[40,50] — visible to a straight-down
1552 // camera (B itself occludes only x∈[50,60]).
1553 let mut scene = Scene::new();
1554 let a = scene.add_grid(GridTransform::at(DVec3::ZERO));
1555 scene.grid_mut(a).unwrap().set_rect(
1556 IVec3::new(30, 30, 60),
1557 IVec3::new(90, 90, 62),
1558 Some(VoxColor(0x80_88_88_88)),
1559 );
1560 let b = scene.add_grid(GridTransform::at(DVec3::ZERO));
1561 scene.grid_mut(b).unwrap().set_rect(
1562 IVec3::new(50, 50, 40),
1563 IVec3::new(60, 60, 50),
1564 Some(VoxColor(0x80_60_60_60)),
1565 );
1566
1567 // Straight-down camera over the floor (voxlap z-down ⇒ forward +z).
1568 let cam = Camera {
1569 pos: [55.0, 55.0, 6.0],
1570 right: [1.0, 0.0, 0.0],
1571 down: [0.0, 1.0, 0.0],
1572 forward: [0.0, 0.0, 1.0],
1573 };
1574 let inv = 1.0f32 / 2.0f32.sqrt();
1575 let base = CpuLights {
1576 enabled: true,
1577 sun: true,
1578 sun_dir: [inv, 0.0, -inv], // to-sun: +x and up
1579 sun_color: [1.0; 3],
1580 sun_intensity: 1.0,
1581 ambient: [0.3; 3],
1582 shadow_strength: 0.85,
1583 shadow_bias: 1.5,
1584 shadow_max_dist: 128.0,
1585 ..CpuLights::default()
1586 };
1587 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1588 let mut sum_lum = |lights: CpuLights| -> u64 {
1589 let n = (XRES as usize) * (YRES as usize);
1590 let mut fb = vec![0u32; n];
1591 let mut zb = vec![f32::INFINITY; n];
1592 let mut params = ComposedFrameParams::new(&cam, &settings);
1593 params.sky_color = 0x0011_2233;
1594 params.lights = lights;
1595 render_scene_composed_scissored(
1596 &mut fb,
1597 &mut zb,
1598 XRES as usize,
1599 XRES,
1600 YRES,
1601 &mut scene,
1602 ¶ms,
1603 false,
1604 &mut SceneRenderScratch::default(),
1605 );
1606 fb.iter()
1607 .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
1608 .sum()
1609 };
1610 let lit = sum_lum(CpuLights {
1611 sun_casts_shadow: false,
1612 ..base
1613 });
1614 let shadowed = sum_lum(CpuLights {
1615 sun_casts_shadow: true,
1616 ..base
1617 });
1618 assert!(
1619 shadowed < lit,
1620 "B's shadow must darken A's floor: shadowed={shadowed} lit={lit}"
1621 );
1622 assert!(
1623 (lit - shadowed) * 200 > lit,
1624 "cross-grid shadow should remove >0.5% of total luminance: lit={lit} shadowed={shadowed}"
1625 );
1626 }
1627
1628 #[test]
1629 fn sc2_sun_shadow_cap_is_world_uniform() {
1630 // SC.2 finding #1 — `shadow_max_dist` is a WORLD distance. The sun
1631 // shadow ray marches the grid's VOXEL frame, so the per-grid cap is
1632 // `shadow_max_dist / vws`: a fine grid (vws<1) then reaches MORE
1633 // voxels (= the same world distance), a coarse grid fewer. Without
1634 // this a global cap gives `shadow_max_dist·vws` world reach — a
1635 // flying vws=0.25 grid would only see occluders within 1/4 the range.
1636 let world = CpuLights {
1637 enabled: true,
1638 sun: true,
1639 shadow_max_dist: 40.0,
1640 ..CpuLights::default()
1641 };
1642 let mut scratch = Vec::new();
1643 // vws == 1.0: unchanged (byte-identical to pre-SC).
1644 let unit = grid_local_lights(&world, &GridTransform::identity(), &mut scratch, None);
1645 assert!((unit.shadow_max_dist - 40.0).abs() < 1e-3);
1646 // vws == 0.5 (fine grid): the voxel cap doubles → same 40 world units.
1647 let fine = grid_local_lights(
1648 &world,
1649 &GridTransform::at_scale(DVec3::ZERO, 0.5),
1650 &mut scratch,
1651 None,
1652 );
1653 assert!(
1654 (fine.shadow_max_dist - 80.0).abs() < 1e-3,
1655 "vws=0.5 sun cap must be 40/0.5 = 80 voxels (40 world): got {}",
1656 fine.shadow_max_dist
1657 );
1658 // vws == 4.0 (coarse grid): the voxel cap quarters → same 40 world.
1659 let coarse = grid_local_lights(
1660 &world,
1661 &GridTransform::at_scale(DVec3::ZERO, 4.0),
1662 &mut scratch,
1663 None,
1664 );
1665 assert!(
1666 (coarse.shadow_max_dist - 10.0).abs() < 1e-3,
1667 "vws=4.0 sun cap must be 40/4 = 10 voxels (40 world): got {}",
1668 coarse.shadow_max_dist
1669 );
1670 }
1671
1672 #[test]
1673 fn sc2_scaled_grid_casts_world_correct_shadow() {
1674 // SC.2 — a SCALED occluder grid must drop its shadow at the same WORLD
1675 // place as the equivalent unscaled block. Grid B at vws 2.0 with a
1676 // block at local [25,25,20]..[29,29,24] fills world [50,60)×[50,60)×
1677 // [40,50) — the identical world box the unscaled [50,50,40]..[59,59,49]
1678 // block fills. Its shadow on A's unscaled floor must MATCH the
1679 // unscaled reference, not merely darken. Without the occluder-side
1680 // /vws the world shadow ray (x≈55) would miss B's voxel AABB
1681 // (x∈[25,30]) entirely → zero shadow (scaled_delta ≈ 0 fails).
1682 let cam = Camera {
1683 pos: [55.0, 55.0, 6.0],
1684 right: [1.0, 0.0, 0.0],
1685 down: [0.0, 1.0, 0.0],
1686 forward: [0.0, 0.0, 1.0],
1687 };
1688 let inv = 1.0f32 / 2.0f32.sqrt();
1689 let base = CpuLights {
1690 enabled: true,
1691 sun: true,
1692 sun_dir: [inv, 0.0, -inv], // to-sun: +x and up
1693 sun_color: [1.0; 3],
1694 sun_intensity: 1.0,
1695 ambient: [0.3; 3],
1696 shadow_strength: 0.85,
1697 shadow_bias: 1.5,
1698 shadow_max_dist: 128.0,
1699 ..CpuLights::default()
1700 };
1701 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1702
1703 // Render A (unscaled floor) + B (block at `b_vws`), return luminance.
1704 let render_lum = |b_vws: f64, b_lo: IVec3, b_hi: IVec3, casts: bool| -> u64 {
1705 let mut scene = Scene::new();
1706 let a = scene.add_grid(GridTransform::at(DVec3::ZERO));
1707 scene.grid_mut(a).unwrap().set_rect(
1708 IVec3::new(30, 30, 60),
1709 IVec3::new(90, 90, 62),
1710 Some(VoxColor(0x80_88_88_88)),
1711 );
1712 let b = scene.add_grid(GridTransform::at_scale(DVec3::ZERO, b_vws));
1713 scene
1714 .grid_mut(b)
1715 .unwrap()
1716 .set_rect(b_lo, b_hi, Some(VoxColor(0x80_60_60_60)));
1717 let n = (XRES as usize) * (YRES as usize);
1718 let mut fb = vec![0u32; n];
1719 let mut zb = vec![f32::INFINITY; n];
1720 let mut params = ComposedFrameParams::new(&cam, &settings);
1721 params.sky_color = 0x0011_2233;
1722 params.lights = CpuLights {
1723 sun_casts_shadow: casts,
1724 ..base
1725 };
1726 render_scene_composed_scissored(
1727 &mut fb,
1728 &mut zb,
1729 XRES as usize,
1730 XRES,
1731 YRES,
1732 &mut scene,
1733 ¶ms,
1734 false,
1735 &mut SceneRenderScratch::default(),
1736 );
1737 fb.iter()
1738 .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
1739 .sum()
1740 };
1741
1742 let unscaled_lo = IVec3::new(50, 50, 40);
1743 let unscaled_hi = IVec3::new(59, 59, 49);
1744 let scaled_lo = IVec3::new(25, 25, 20);
1745 let scaled_hi = IVec3::new(29, 29, 24);
1746 let lit = render_lum(1.0, unscaled_lo, unscaled_hi, false);
1747 let ref_shadow = render_lum(1.0, unscaled_lo, unscaled_hi, true);
1748 let scaled_shadow = render_lum(2.0, scaled_lo, scaled_hi, true);
1749
1750 assert!(ref_shadow < lit, "sanity: the unscaled block must shadow A");
1751 assert!(
1752 scaled_shadow < lit,
1753 "the scaled occluder must cast a shadow — a missing occluder-side \
1754 /vws makes the world ray miss its voxel AABB: scaled={scaled_shadow} lit={lit}"
1755 );
1756 // World-correctness: the scaled block fills the identical world box, so
1757 // its shadow footprint tracks the unscaled reference (only voxel edge
1758 // quantization differs). A mis-scaled ray would land elsewhere / miss.
1759 let ref_delta = lit - ref_shadow;
1760 let scaled_delta = lit - scaled_shadow;
1761 assert!(
1762 scaled_delta * 10 > ref_delta * 7 && scaled_delta * 7 < ref_delta * 10,
1763 "scaled shadow must match the unscaled world shadow within ~30% \
1764 (world-placement check): ref_delta={ref_delta} scaled_delta={scaled_delta}"
1765 );
1766 }
1767
1768 // ---- S5.0: world_camera_to_grid_local helper ----
1769
1770 /// Identity rotation: pos translates by `-origin`; basis is
1771 /// untouched. This is the byte-identical-to-pre-S5 contract.
1772 #[test]
1773 fn world_camera_to_grid_local_identity_rotation_translates_pos_only() {
1774 let camera = Camera {
1775 pos: [110.0, 220.0, 330.0],
1776 right: [1.0, 0.0, 0.0],
1777 down: [0.0, 0.0, 1.0],
1778 forward: [0.0, 1.0, 0.0],
1779 };
1780 let transform = GridTransform::at(DVec3::new(100.0, 200.0, 300.0));
1781 let local = super::world_camera_to_grid_local(&camera, &transform);
1782 // Basis must be bit-for-bit unchanged for the identity case.
1783 assert_eq!(local.right, camera.right);
1784 assert_eq!(local.down, camera.down);
1785 assert_eq!(local.forward, camera.forward);
1786 // Pos translates by `-origin`.
1787 for (got, want) in local.pos.iter().zip([10.0, 20.0, 30.0].iter()) {
1788 assert!((got - want).abs() < 1e-12, "pos got={got} want={want}");
1789 }
1790 }
1791
1792 /// 90° rotation about +Z: grid-local `+x` aligns with world `+y`.
1793 /// World camera at `(0, 10, 0)` looking world `+y` lives in
1794 /// grid-local at `(10, 0, 0)` looking grid-local `+x`.
1795 #[test]
1796 fn world_camera_to_grid_local_90deg_z_rotates_basis_and_pos() {
1797 use glam::DQuat;
1798 let camera = Camera {
1799 pos: [0.0, 10.0, 0.0],
1800 right: [1.0, 0.0, 0.0],
1801 down: [0.0, 0.0, 1.0],
1802 forward: [0.0, 1.0, 0.0],
1803 };
1804 let transform = GridTransform {
1805 origin: DVec3::ZERO,
1806 rotation: DQuat::from_rotation_z(std::f64::consts::FRAC_PI_2),
1807 voxel_world_size: 1.0,
1808 };
1809 let local = super::world_camera_to_grid_local(&camera, &transform);
1810 // World +y == grid-local +x.
1811 let approx_eq =
1812 |a: [f64; 3], b: [f64; 3]| a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-9);
1813 assert!(
1814 approx_eq(local.pos, [10.0, 0.0, 0.0]),
1815 "pos={:?} expected ~(10, 0, 0)",
1816 local.pos
1817 );
1818 // World +x (right) maps to grid-local -y.
1819 assert!(
1820 approx_eq(local.right, [0.0, -1.0, 0.0]),
1821 "right={:?} expected ~(0, -1, 0)",
1822 local.right
1823 );
1824 // World +z (down) is unchanged — it's the rotation axis.
1825 assert!(
1826 approx_eq(local.down, [0.0, 0.0, 1.0]),
1827 "down={:?} expected ~(0, 0, 1)",
1828 local.down
1829 );
1830 // World +y (forward) maps to grid-local +x.
1831 assert!(
1832 approx_eq(local.forward, [1.0, 0.0, 0.0]),
1833 "forward={:?} expected ~(1, 0, 0)",
1834 local.forward
1835 );
1836 }
1837
1838 /// Basis orthonormality + handedness both survive the
1839 /// inverse-rotation transform. Property: any unit-quaternion
1840 /// conjugation preserves the input basis's orthonormality AND
1841 /// its handedness (rotations are orientation-preserving).
1842 #[test]
1843 fn world_camera_to_grid_local_preserves_basis_orthonormality() {
1844 use glam::DQuat;
1845 // Right-handed voxlap basis (`right × down == forward`):
1846 // looking +y, right = -x makes the cross product land on +y.
1847 let camera = Camera {
1848 pos: [3.0, -5.0, 7.0],
1849 right: [-1.0, 0.0, 0.0],
1850 down: [0.0, 0.0, 1.0],
1851 forward: [0.0, 1.0, 0.0],
1852 };
1853 let transform = GridTransform {
1854 origin: DVec3::new(1.0, 2.0, 3.0),
1855 rotation: DQuat::from_axis_angle(glam::DVec3::new(0.3, 0.8, 0.5).normalize(), 0.7),
1856 voxel_world_size: 1.0,
1857 };
1858 let local = super::world_camera_to_grid_local(&camera, &transform);
1859 let r = DVec3::from_array(local.right);
1860 let d = DVec3::from_array(local.down);
1861 let f = DVec3::from_array(local.forward);
1862 // Norms ≈ 1.
1863 for v in [r, d, f] {
1864 assert!(
1865 (v.length_squared() - 1.0).abs() < 1e-12,
1866 "basis vec {v:?} not unit length"
1867 );
1868 }
1869 // Orthogonality.
1870 assert!(r.dot(d).abs() < 1e-12, "right·down = {}", r.dot(d));
1871 assert!(r.dot(f).abs() < 1e-12, "right·forward = {}", r.dot(f));
1872 assert!(d.dot(f).abs() < 1e-12, "down·forward = {}", d.dot(f));
1873 // Right-handed: right × down == forward (voxlap convention).
1874 let cross = r.cross(d);
1875 assert!(
1876 (cross - f).length() < 1e-12,
1877 "right×down={cross:?} forward={f:?}"
1878 );
1879 }
1880
1881 // ---- S5.1: rotated-grid render correctness ----
1882
1883 /// Build a single-grid scene at the given transform with a
1884 /// marker box near one corner of chunk (0, 0, 0). Returns the
1885 /// scene and the marker colour. Picking a single chunk + small
1886 /// box keeps the test compact while still exercising the gline
1887 /// + grouscan path through the rotated frame.
1888 fn build_one_grid_marker_scene(transform: GridTransform) -> (Scene, crate::GridId, u32) {
1889 let mut scene = Scene::new();
1890 let id = scene.add_grid(transform);
1891 let grid = scene.grid_mut(id).unwrap();
1892 // Bright marker box at chunk-local (40..56, 40..56, 40..56).
1893 grid.set_rect(
1894 IVec3::new(40, 40, 40),
1895 IVec3::new(55, 55, 55),
1896 Some(VoxColor(0x80_55_aa_22)), // distinctive green
1897 );
1898 (scene, id, 0x80_55_aa_22)
1899 }
1900
1901 /// Pin S5.1's central equivalence: rotating both the grid and the
1902 /// camera by the SAME rotation around the grid's origin must
1903 /// leave the rendered framebuffer unchanged — the grid-local
1904 /// camera pose collapses to the same values in both scenarios.
1905 ///
1906 /// We use `DQuat::from_xyzw(0.0, 0.0, 1.0, 0.0)`, the
1907 /// 180°-around-Z unit quaternion. This rotation acts on vectors
1908 /// as `(x, y, z) → (-x, -y, z)`, which only multiplies f64
1909 /// components by 0 or ±1 — bit-exact under glam's standard quat
1910 /// conjugation formula. Other angles (e.g. 90°) would introduce
1911 /// sub-1e-15 noise from sin/cos, breaking byte-identity at
1912 /// chunk / voxel boundaries.
1913 #[test]
1914 fn s5_1_180deg_z_rotated_grid_byte_identical_to_axis_aligned() {
1915 use glam::DQuat;
1916 // Right-handed voxlap basis (right × down == forward).
1917 let axis_aligned_camera = Camera {
1918 pos: [40.0, -20.0, 50.0],
1919 right: [-1.0, 0.0, 0.0],
1920 down: [0.0, 0.0, 1.0],
1921 forward: [0.0, 1.0, 0.0],
1922 };
1923 // R_z(180°): (x, y, z) → (-x, -y, z).
1924 let rotated_camera = Camera {
1925 pos: [-40.0, 20.0, 50.0],
1926 right: [1.0, 0.0, 0.0],
1927 down: [0.0, 0.0, 1.0],
1928 forward: [0.0, -1.0, 0.0],
1929 };
1930 // Sanity: prove the exact-arithmetic rotation lands on the
1931 // baseline. If glam ever changes its quat*vec formula in a
1932 // way that loses exactness here, the next two assertions
1933 // catch it before the framebuffer comparison.
1934 let q = DQuat::from_xyzw(0.0, 0.0, 1.0, 0.0);
1935 let rot_pos = q * DVec3::from_array(axis_aligned_camera.pos);
1936 let rot_fwd = q * DVec3::from_array(axis_aligned_camera.forward);
1937 assert_eq!(rot_pos.to_array(), rotated_camera.pos);
1938 assert_eq!(rot_fwd.to_array(), rotated_camera.forward);
1939
1940 let (mut scene_a, _, _) = build_one_grid_marker_scene(GridTransform::identity());
1941 let fb_a = render_via_scene(&mut scene_a, &axis_aligned_camera);
1942
1943 let (mut scene_b, _, _) = build_one_grid_marker_scene(GridTransform {
1944 origin: DVec3::ZERO,
1945 rotation: q,
1946 voxel_world_size: 1.0,
1947 });
1948 let fb_b = render_via_scene(&mut scene_b, &rotated_camera);
1949
1950 assert_eq!(
1951 fb_a, fb_b,
1952 "rotating both grid and camera by R about the grid origin must leave the framebuffer unchanged"
1953 );
1954 }
1955
1956 /// 45° smoke test: rotated grid renders to something non-trivial
1957 /// without panicking. No equivalence assertion (45° quat math is
1958 /// approximate at f64 level; that path is exercised structurally,
1959 /// not bit-exactly). Camera is placed at a fixed world pose where
1960 /// — under the rotation — the marker box stays inside the view
1961 /// frustum.
1962 #[test]
1963 fn s5_1_45deg_z_rotated_grid_renders_marker() {
1964 use glam::DQuat;
1965 let rotation = DQuat::from_rotation_z(std::f64::consts::FRAC_PI_4);
1966 let (mut scene, _, marker) = build_one_grid_marker_scene(GridTransform {
1967 origin: DVec3::ZERO,
1968 rotation,
1969 voxel_world_size: 1.0,
1970 });
1971
1972 // World position of the marker's centre. Grid-local
1973 // (47.5, 47.5, 47.5) → world `rotation * (47.5, 47.5, 47.5)`.
1974 // R_z(45°): (47.5, 47.5, 47.5) → (0, 67.18, 47.5) (the x/y
1975 // components combine into a single +y vector at √2 * 47.5).
1976 let marker_world = rotation * DVec3::new(47.5, 47.5, 47.5);
1977 // Camera 80 units south of the marker on the world Y axis,
1978 // looking +y at the same z. RH basis.
1979 let camera = Camera {
1980 pos: [marker_world.x, marker_world.y - 80.0, marker_world.z],
1981 right: [-1.0, 0.0, 0.0],
1982 down: [0.0, 0.0, 1.0],
1983 forward: [0.0, 1.0, 0.0],
1984 };
1985
1986 let (_engine, mut fb, mut zb) = render_setup(CHUNK_SIZE_XY);
1987 let fog = CpuFog::default();
1988 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
1989 let outcome = render_scene(
1990 &mut fb,
1991 &mut zb,
1992 XRES as usize,
1993 XRES,
1994 YRES,
1995 fog,
1996 &mut scene,
1997 &camera,
1998 &settings,
1999 None,
2000 );
2001 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2002 let marker_count = fb.iter().filter(|&&p| p == marker).count();
2003 assert!(
2004 marker_count > 50,
2005 "45°-rotated marker box should be visible — got {marker_count} marker pixels"
2006 );
2007 }
2008
2009 // ---- S5.2-followup: per-grid render_sky opt-out ----
2010
2011 /// Two-grid scene where grid B sits behind grid A along +y;
2012 /// grid A is opaque only in the centre of the framebuffer, so
2013 /// the camera's view through grid A is mostly "ray miss". When
2014 /// `A.render_sky = false`, the pixels around A's silhouette
2015 /// must remain whatever grid B (or the shared pre-fill)
2016 /// painted — NOT A's grid-local sky colour. This pins the
2017 /// sentinel-mask path: without it, A's sky would write into
2018 /// the composed framebuffer wherever its sky-z happened to win
2019 /// the min-z race with B's sky-z.
2020 #[test]
2021 fn render_sky_false_drops_grid_sky_pixels() {
2022 use crate::{GridId, GridTransform};
2023
2024 // Grid B (far, sky owner) — a wide floor of distinct
2025 // colour spanning chunk-local x/y so most rays land on it.
2026 let mut scene = Scene::new();
2027 let _b_id: GridId = scene.add_grid(GridTransform::at(DVec3::new(0.0, 600.0, 0.0)));
2028 // Find grid B's id (HashMap iteration; we only just added
2029 // one grid, so its id is whichever the iterator yields).
2030 let b_id = scene.grids().next().unwrap().0;
2031 scene.grid_mut(b_id).unwrap().set_rect(
2032 IVec3::new(0, 0, 100),
2033 IVec3::new(127, 127, 110),
2034 Some(VoxColor(0x80_22_88_22)), // green floor
2035 );
2036
2037 // Grid A (near, sky disabled) — a SMALL marker box that
2038 // covers only a fraction of the screen. Most pixels of A's
2039 // local render are sky.
2040 let a_id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
2041 scene.grid_mut(a_id).unwrap().set_rect(
2042 IVec3::new(60, 60, 60),
2043 IVec3::new(67, 67, 67),
2044 Some(VoxColor(0x80_aa_22_22)), // red cube
2045 );
2046 scene.grid_mut(a_id).unwrap().render_sky = false;
2047
2048 let unique_sky: u32 = 0xFF_AB_CD_EF;
2049 let (_engine, fog, _) = make_composed_pool(CHUNK_SIZE_XY);
2050 let mut fb = vec![unique_sky; pixel_count(XRES, YRES)];
2051 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2052 let camera = camera_at([64.0, 0.0, 100.0]);
2053 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2054 let outcome = render_scene_composed(
2055 &mut fb,
2056 &mut zb,
2057 XRES as usize,
2058 XRES,
2059 YRES,
2060 fog,
2061 &mut scene,
2062 &camera,
2063 &settings,
2064 unique_sky,
2065 None,
2066 );
2067 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
2068
2069 // The sentinel must never appear in the composed output —
2070 // every sentinel pixel must have been masked out before
2071 // compose. If any leak through, the test catches it.
2072 let leaked = fb
2073 .iter()
2074 .filter(|&&p| p == super::SKY_MASK_SENTINEL)
2075 .count();
2076 assert_eq!(
2077 leaked, 0,
2078 "SKY_MASK_SENTINEL leaked into composed framebuffer ({leaked} pixels)"
2079 );
2080 // Grid A's hit (red cube) must still render — render_sky=false
2081 // only affects sky pixels, not hits.
2082 let red_count = fb.iter().filter(|&&p| p == 0x80_aa_22_22).count();
2083 assert!(
2084 red_count > 0,
2085 "red cube from sky-disabled grid A is missing — render_sky=false should only mask sky"
2086 );
2087 // Grid B's floor must be visible past grid A's silhouette
2088 // (the sky-disabled grid doesn't hide B's render).
2089 let green_count = fb.iter().filter(|&&p| p == 0x80_22_88_22).count();
2090 assert!(
2091 green_count > 0,
2092 "grid B's floor invisible — grid A's masked sky may have overwritten it"
2093 );
2094 }
2095
2096 /// Identity-rotation, single-grid scene with `render_sky = false`
2097 /// must produce a sentinel-free framebuffer. Sanity test for the
2098 /// trivial 1-grid case (no second grid to compose against).
2099 #[test]
2100 fn render_sky_false_single_grid_no_sentinel_leak() {
2101 let (mut scene, id, _) = build_one_grid_marker_scene(GridTransform::identity());
2102 scene.grid_mut(id).unwrap().render_sky = false;
2103 let unique_sky: u32 = 0xFF_12_34_56;
2104 let (_engine, fog, _) = make_composed_pool(CHUNK_SIZE_XY);
2105 let mut fb = vec![unique_sky; pixel_count(XRES, YRES)];
2106 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2107 let camera = camera_at([64.0, 0.0, 64.0]);
2108 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2109 let outcome = render_scene_composed(
2110 &mut fb,
2111 &mut zb,
2112 XRES as usize,
2113 XRES,
2114 YRES,
2115 fog,
2116 &mut scene,
2117 &camera,
2118 &settings,
2119 unique_sky,
2120 None,
2121 );
2122 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2123 let leaked = fb
2124 .iter()
2125 .filter(|&&p| p == super::SKY_MASK_SENTINEL)
2126 .count();
2127 assert_eq!(leaked, 0, "SKY_MASK_SENTINEL leaked ({leaked} pixels)");
2128 // Pixels that would have been the grid's sky now show
2129 // through to the pre-fill (unique_sky).
2130 let prefill_count = fb.iter().filter(|&&p| p == unique_sky).count();
2131 assert!(
2132 prefill_count > 0,
2133 "no pre-fill pixels survived — render_sky=false should leave non-hit pixels untouched"
2134 );
2135 }
2136
2137 // DDA.9: `render_scene_at_origin_matches_direct_opticast` and
2138 // `render_scene_translated_grid_matches_grid_local_opticast` were
2139 // removed — they asserted the scene render byte-matches voxlap
2140 // `opticast`, which no longer holds now that the scene's CPU backend
2141 // is the DDA renderer (different, intentionally non-bit-exact). The
2142 // grid-local camera transform they also exercised is covered by the
2143 // `stacked_*` / two-grid composition tests below.
2144
2145 #[test]
2146 fn empty_scene_returns_empty_outcome() {
2147 let mut scene = Scene::new();
2148 let (_engine, mut fb, mut zb) = render_setup(CHUNK_SIZE_XY);
2149 let fog = CpuFog::default();
2150 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2151 let outcome = render_scene(
2152 &mut fb,
2153 &mut zb,
2154 XRES as usize,
2155 XRES,
2156 YRES,
2157 fog,
2158 &mut scene,
2159 &camera_at([0.0, 0.0, 0.0]),
2160 &settings,
2161 None,
2162 );
2163 assert_eq!(outcome, RenderOutcome::Empty);
2164 }
2165
2166 // ---- S3.1 / S4.0: render_scene_composed + 2-grid composition ----
2167
2168 /// Build a 2-grid scene with two distinguishable boxes placed
2169 /// side-by-side in world space along the camera's right axis.
2170 /// Each grid holds one chunk (`(0, 0, 0)`) containing a single
2171 /// 16-voxel box with a uniquely-coloured surface so the
2172 /// composited framebuffer is partitionable by colour.
2173 fn build_two_grid_side_by_side() -> (Scene, u32, u32) {
2174 let mut scene = Scene::new();
2175 // Grid 0 at world (0, 200, 0): box centred chunk-local (64, 64, 100).
2176 let g0 = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
2177 scene.grid_mut(g0).unwrap().set_rect(
2178 IVec3::new(56, 56, 92),
2179 IVec3::new(71, 71, 107),
2180 Some(VoxColor(0x80_88_22_22)), // dark red
2181 );
2182 // Grid 1 at world (200, 200, 0): box centred chunk-local (64, 64, 100).
2183 let _g1 = scene.add_grid(GridTransform::at(DVec3::new(200.0, 200.0, 0.0)));
2184 // Borrow-checker dance: re-borrow grid 1 mutably.
2185 let g1_id = scene
2186 .grids()
2187 .filter(|(id, _)| *id != g0)
2188 .map(|(id, _)| id)
2189 .next()
2190 .unwrap();
2191 scene.grid_mut(g1_id).unwrap().set_rect(
2192 IVec3::new(56, 56, 92),
2193 IVec3::new(71, 71, 107),
2194 Some(VoxColor(0x80_22_22_88)), // dark blue
2195 );
2196 (scene, 0x80_88_22_22, 0x80_22_22_88)
2197 }
2198
2199 /// Engine + default (off) fog config + sky colour for the
2200 /// composed-render tests. `_pool_vsid` retained for call-site
2201 /// compatibility; the DDA backend needs no scratch pool.
2202 fn make_composed_pool(_pool_vsid: u32) -> (Engine, CpuFog, u32) {
2203 let engine = Engine::new();
2204 let sky_color = engine.sky_color();
2205 (engine, CpuFog::default(), sky_color)
2206 }
2207
2208 fn pixel_count(width: u32, height: u32) -> usize {
2209 (width as usize) * (height as usize)
2210 }
2211
2212 #[test]
2213 fn compose_into_takes_smaller_z() {
2214 let mut shared_fb = vec![0xff_ff_ff_ff_u32; 4];
2215 let mut shared_zb = vec![10.0f32; 4];
2216 let temp_fb = [0xaa_aa_aa_aa, 0x11_22_33_44, 0x55_66_77_88, 0xde_ad_be_ef];
2217 let temp_zb = [5.0f32, 20.0, 10.0, f32::INFINITY];
2218 compose_into(&mut shared_fb, &mut shared_zb, &temp_fb, &temp_zb);
2219 // i=0: 5 < 10 → take temp.
2220 assert_eq!(shared_fb[0], 0xaa_aa_aa_aa);
2221 assert_eq!(shared_zb[0], 5.0);
2222 // i=1: 20 > 10 → keep shared.
2223 assert_eq!(shared_fb[1], 0xff_ff_ff_ff);
2224 assert_eq!(shared_zb[1], 10.0);
2225 // i=2: 10 == 10 → keep shared (`<` not `<=`).
2226 assert_eq!(shared_fb[2], 0xff_ff_ff_ff);
2227 // i=3: INFINITY > 10 → keep shared.
2228 assert_eq!(shared_fb[3], 0xff_ff_ff_ff);
2229 }
2230
2231 #[test]
2232 fn render_scene_composed_two_grids_both_visible() {
2233 // Camera positioned to see both grids' boxes. Grid 0's box
2234 // at world (~64, ~264, ~100); grid 1's box at world
2235 // (~264, ~264, ~100). Camera at world (160, 100, 100)
2236 // looking +y centres both in view.
2237 let (mut scene, red, blue) = build_two_grid_side_by_side();
2238 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2239 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2240 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2241
2242 let camera = camera_at([160.0, 100.0, 100.0]);
2243 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2244 let outcome = render_scene_composed(
2245 &mut fb,
2246 &mut zb,
2247 XRES as usize,
2248 XRES,
2249 YRES,
2250 fog,
2251 &mut scene,
2252 &camera,
2253 &settings,
2254 sky_color,
2255 None,
2256 );
2257 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
2258
2259 // Both colours should appear somewhere in the framebuffer.
2260 let red_count = fb.iter().filter(|&&p| p == red).count();
2261 let blue_count = fb.iter().filter(|&&p| p == blue).count();
2262 assert!(
2263 red_count > 0,
2264 "no red pixels: grid 0 (red box) not visible after compose"
2265 );
2266 assert!(
2267 blue_count > 0,
2268 "no blue pixels: grid 1 (blue box) not visible after compose"
2269 );
2270 }
2271
2272 /// The per-grid screen scissor (vertical band + lateral/vertical
2273 /// off-screen cull + rect-limited memory passes) must be a pure
2274 /// speed-up: rendering a multi-grid scene with it on
2275 /// (`render_scene_composed`) must produce a **byte-identical**
2276 /// framebuffer to rendering each grid full-frame
2277 /// (`scissor = false`). Includes a third grid placed off the left
2278 /// edge but within scan distance, so the lateral cull (scissor on)
2279 /// vs a sky-only full render (scissor off) must still agree pixel
2280 /// for pixel.
2281 #[test]
2282 fn scissor_render_is_byte_identical_to_full_frame() {
2283 let (mut scene, red, blue) = build_two_grid_side_by_side();
2284 // Third grid far to the +x side at the camera's depth: within
2285 // max_scan_dist (so the distance cull doesn't fire) but its box
2286 // projects off the left screen edge → screen-culled with the
2287 // scissor, sky-only when rendered full-frame.
2288 let g2 = scene.add_grid(GridTransform::at(DVec3::new(700.0, 130.0, 0.0)));
2289 let g2_id = scene
2290 .grids()
2291 .map(|(id, _)| id)
2292 .max_by_key(|id| id.raw())
2293 .unwrap();
2294 let _ = g2;
2295 scene.grid_mut(g2_id).unwrap().set_rect(
2296 IVec3::new(56, 56, 92),
2297 IVec3::new(71, 71, 107),
2298 Some(VoxColor(0x80_22_88_22)), // green — must never appear (off-screen)
2299 );
2300
2301 let camera = camera_at([160.0, 100.0, 100.0]);
2302 let render = |scene: &mut Scene, scissor: bool| -> Vec<u32> {
2303 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2304 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2305 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2306 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2307 let mut params = ComposedFrameParams::new(&camera, &settings);
2308 params.fog = fog;
2309 params.sky_color = sky_color;
2310 render_scene_composed_scissored(
2311 &mut fb,
2312 &mut zb,
2313 XRES as usize,
2314 XRES,
2315 YRES,
2316 scene,
2317 ¶ms,
2318 scissor,
2319 &mut SceneRenderScratch::default(),
2320 );
2321 fb
2322 };
2323
2324 let scissored = render(&mut scene, true);
2325 let full = render(&mut scene, false);
2326 assert_eq!(
2327 scissored, full,
2328 "the screen scissor changed the framebuffer — it must be a pure speed-up",
2329 );
2330 // Sanity: the scene actually drew content (not a vacuous all-sky
2331 // match), and the off-screen green grid never appears.
2332 assert!(scissored.iter().any(|&p| p == red || p == blue));
2333 assert!(
2334 !scissored.contains(&0x80_22_88_22),
2335 "off-screen grid leaked pixels",
2336 );
2337 }
2338
2339 #[test]
2340 fn render_scene_composed_grid_a_in_front_of_grid_b() {
2341 // Two grids stacked along +y so grid A (closer) occludes
2342 // grid B (farther). After composition only grid A's colour
2343 // should appear on the overlap.
2344 let mut scene = Scene::new();
2345 let g_a = scene.add_grid(GridTransform::at(DVec3::new(0.0, 50.0, 0.0)));
2346 scene.grid_mut(g_a).unwrap().set_rect(
2347 IVec3::new(56, 56, 92),
2348 IVec3::new(71, 71, 107),
2349 Some(VoxColor(0x80_aa_00_00)), // red
2350 );
2351 let _g_b = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
2352 let g_b_id = scene
2353 .grids()
2354 .filter(|(id, _)| *id != g_a)
2355 .map(|(id, _)| id)
2356 .next()
2357 .unwrap();
2358 scene.grid_mut(g_b_id).unwrap().set_rect(
2359 IVec3::new(56, 56, 92),
2360 IVec3::new(71, 71, 107),
2361 Some(VoxColor(0x80_00_00_aa)), // blue
2362 );
2363
2364 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2365 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2366 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2367
2368 // Camera at (64, -10, 100) looking +y — both boxes line up
2369 // along the camera's forward axis.
2370 let camera = camera_at([64.0, -10.0, 100.0]);
2371 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2372 let outcome = render_scene_composed(
2373 &mut fb,
2374 &mut zb,
2375 XRES as usize,
2376 XRES,
2377 YRES,
2378 fog,
2379 &mut scene,
2380 &camera,
2381 &settings,
2382 sky_color,
2383 None,
2384 );
2385 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
2386
2387 // Red (closer grid) should be visible. Blue (farther grid)
2388 // may peek around the edges but the central pixels should
2389 // be red where both boxes project.
2390 let red_count = fb.iter().filter(|&&p| p == 0x80_aa_00_00).count();
2391 assert!(
2392 red_count > 0,
2393 "expected red pixels (closer box should win z-test)"
2394 );
2395
2396 // Reverse the registration order (force grid B drawn first)
2397 // and verify that's irrelevant — composition is commutative.
2398 let mut scene2 = Scene::new();
2399 let g_b2 = scene2.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
2400 scene2.grid_mut(g_b2).unwrap().set_rect(
2401 IVec3::new(56, 56, 92),
2402 IVec3::new(71, 71, 107),
2403 Some(VoxColor(0x80_00_00_aa)),
2404 );
2405 let g_a2 = scene2.add_grid(GridTransform::at(DVec3::new(0.0, 50.0, 0.0)));
2406 scene2.grid_mut(g_a2).unwrap().set_rect(
2407 IVec3::new(56, 56, 92),
2408 IVec3::new(71, 71, 107),
2409 Some(VoxColor(0x80_aa_00_00)),
2410 );
2411
2412 let mut fb2 = vec![sky_color; pixel_count(XRES, YRES)];
2413 let mut zb2 = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2414 let outcome2 = render_scene_composed(
2415 &mut fb2,
2416 &mut zb2,
2417 XRES as usize,
2418 XRES,
2419 YRES,
2420 fog,
2421 &mut scene2,
2422 &camera,
2423 &settings,
2424 sky_color,
2425 None,
2426 );
2427 assert_eq!(outcome2, RenderOutcome::Rendered { grids_drawn: 2 });
2428 assert_eq!(
2429 fb, fb2,
2430 "composition should be order-independent — same scene in different add order should produce identical output"
2431 );
2432 }
2433
2434 #[test]
2435 fn sc1_scaled_grid_composites_by_world_depth() {
2436 // SC.1 — two grids at the same origin, boxes on the SAME world
2437 // column but different scale, so the raw-written and world depth
2438 // metrics DISAGREE. Camera at y=-10; perpendicular world depth to a
2439 // box's near face is `world_y_near + 10`:
2440 // - grid B (vws 1.0): box world y-near = 92 → written depth 102
2441 // (vws==1 so raw == world).
2442 // - grid A (vws 2.0): box world y-near = 104 → world depth 114, but
2443 // opticast writes `world / vws² = 114 / 4 ≈ 28.5` (the scaled
2444 // basis shrinks `dir·forward` by vws²). `scale_depth_rect` then
2445 // multiplies by vws² = 4 → 114.
2446 // Correct (world depth): A (114) is FARTHER than B (102) → the
2447 // world-nearer BLUE box wins.
2448 // Broken (no `scale_depth_rect`): A's raw 28.5 < B's 102 → RED wins.
2449 // (This test only pins the ORDER; `sc1_scaled_grid_depth_is_world`
2450 // pins the exact vws² factor by asserting the world depth value.)
2451 let red = 0x80_aa_00_00;
2452 let blue = 0x80_00_00_aa;
2453 let mut scene = Scene::new();
2454 // Grid B, unscaled, nearer in world.
2455 let b = scene.add_grid(GridTransform::at(DVec3::ZERO));
2456 scene.grid_mut(b).unwrap().set_rect(
2457 IVec3::new(56, 92, 92),
2458 IVec3::new(71, 107, 107),
2459 Some(VoxColor(blue)),
2460 );
2461 // Grid A, vws 2.0, farther in world (local coords = world / 2).
2462 let a = scene.add_grid(GridTransform::at_scale(DVec3::ZERO, 2.0));
2463 scene.grid_mut(a).unwrap().set_rect(
2464 IVec3::new(28, 52, 50),
2465 IVec3::new(35, 57, 57),
2466 Some(VoxColor(red)),
2467 );
2468
2469 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2470 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2471 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2472 let camera = camera_at([64.0, -10.0, 100.0]); // looks +y
2473 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2474 let outcome = render_scene_composed(
2475 &mut fb,
2476 &mut zb,
2477 XRES as usize,
2478 XRES,
2479 YRES,
2480 fog,
2481 &mut scene,
2482 &camera,
2483 &settings,
2484 sky_color,
2485 None,
2486 );
2487 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
2488
2489 let centre = (YRES / 2) as usize * XRES as usize + (XRES / 2) as usize;
2490 assert_eq!(
2491 fb[centre], blue,
2492 "the world-nearer unscaled grid must win the depth test; RED here \
2493 means the scaled grid's depth wasn't converted to world units"
2494 );
2495 }
2496
2497 #[test]
2498 fn sc1_scaled_grid_depth_is_world() {
2499 // SC.1 — render ONLY a scaled grid (vws 2.0) and assert the composited
2500 // depth buffer holds the WORLD perpendicular depth, pinning the vws²
2501 // factor exactly (the ordering test above only bounds it below).
2502 //
2503 // Box A local y 52..57 → world y-near = 52·2 = 104. Camera at y=-10
2504 // looks +y, centre ray horizontal, so the world perpendicular depth is
2505 // 104 - (-10) = 114. opticast writes world/vws² = 114/4 ≈ 28.5;
2506 // `scale_depth_rect` (×vws²) recovers 114. Wrong factors miss badly:
2507 // no scale → 28.5, ×vws → 57, ×vws³ → 228. Only ×vws² lands on 114.
2508 let red = 0x80_aa_00_00;
2509 let mut scene = Scene::new();
2510 let a = scene.add_grid(GridTransform::at_scale(DVec3::ZERO, 2.0));
2511 // Local box centred on the camera column (world x 56..70 → centre 63,
2512 // world z 100..114 → centre 107) so the centre ray hits the interior.
2513 scene.grid_mut(a).unwrap().set_rect(
2514 IVec3::new(28, 52, 50),
2515 IVec3::new(35, 57, 57),
2516 Some(VoxColor(red)),
2517 );
2518
2519 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2520 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2521 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2522 let camera = camera_at([63.0, -10.0, 107.0]); // looks +y, hits box A
2523 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2524 let outcome = render_scene_composed(
2525 &mut fb,
2526 &mut zb,
2527 XRES as usize,
2528 XRES,
2529 YRES,
2530 fog,
2531 &mut scene,
2532 &camera,
2533 &settings,
2534 sky_color,
2535 None,
2536 );
2537 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2538
2539 let centre = (YRES / 2) as usize * XRES as usize + (XRES / 2) as usize;
2540 assert_eq!(fb[centre], red, "centre ray should hit the scaled box");
2541 let depth = zb[centre];
2542 assert!(
2543 (depth - 114.0).abs() <= 3.0,
2544 "expected WORLD perpendicular depth ≈ 114 (pins the vws² factor); \
2545 got {depth} — 28.5 means no scale, 57 means ×vws, 228 means ×vws³"
2546 );
2547 }
2548
2549 #[test]
2550 fn sc3_fine_grid_renders_beyond_unscaled_range() {
2551 // SC.1/SC.3 finding — the ray-terminating scan cutoff is a WORLD
2552 // distance divided by vws² (opticast writes depth = world/vws²). This
2553 // is the ONE vws<1 test that exercises the clip the fix removes: a
2554 // fine grid (vws=0.5) with geometry PAST `max_scan_dist·vws²` but
2555 // within `max_scan_dist` world. Without the /vws² scale the ray stops
2556 // at `max_scan_dist·vws²` (25 world here) and the box (world y≈50) is
2557 // clipped to sky; with it the ray reaches 100 world and the box draws.
2558 let red = 0x80_aa_00_00;
2559 let mut scene = Scene::new();
2560 // vws=0.5: local (·) → world (·)/2. Box near face local y=100 →
2561 // world y=50; local x/z 28..36 → world 14..18 (centre 16).
2562 let g = scene.add_grid(GridTransform::at_scale(DVec3::ZERO, 0.5));
2563 scene.grid_mut(g).unwrap().set_rect(
2564 IVec3::new(28, 100, 28),
2565 IVec3::new(36, 110, 36),
2566 Some(VoxColor(red)),
2567 );
2568
2569 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2570 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2571 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2572 // Camera in world coords, looking +y at the box.
2573 let camera = camera_at([16.0, 0.0, 16.0]);
2574 // max_scan_dist = 100 WORLD. Unscaled reach at vws=0.5 would be
2575 // 100·0.25 = 25 world (box at 50 clipped); scaled reach is 100.
2576 let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2577 settings.max_scan_dist = 100;
2578 let outcome = render_scene_composed(
2579 &mut fb,
2580 &mut zb,
2581 XRES as usize,
2582 XRES,
2583 YRES,
2584 fog,
2585 &mut scene,
2586 &camera,
2587 &settings,
2588 sky_color,
2589 None,
2590 );
2591 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2592 let centre = (YRES / 2) as usize * XRES as usize + (XRES / 2) as usize;
2593 assert_eq!(
2594 fb[centre], red,
2595 "fine grid's box at world y≈50 (> max_scan_dist·vws²=25) must \
2596 render; sky here means the scan cutoff wasn't scaled by vws²"
2597 );
2598 }
2599
2600 // ---- S6.1: Mid-tier mip overrides ----
2601
2602 /// Build a multi-mip-friendly grid: solid floor spanning the
2603 /// whole chunk at z=100..254 + `generate_mips(3)`. This is the
2604 /// same setup `vxl_generate_mips_on_set_voxel_chunk_renders`
2605 /// uses and is known to render at `mip_levels = 3,
2606 /// mip_scan_dist = 32`.
2607 ///
2608 /// Returns `(scene, grid_id)`. The Mid test sets the camera
2609 /// inside the chunk so chunk-local rays reach the floor at
2610 /// short distances; that lets the Mid override use
2611 /// `mip_scan_dist = 16` without busting the ray budget
2612 /// (`mip_scan_dist * 2^(mip_levels-1) = 16 * 4 = 64` covers the
2613 /// distance from camera to floor).
2614 fn build_mip_visible_grid(world_origin: DVec3) -> (Scene, crate::GridId) {
2615 let mut scene = Scene::new();
2616 let id = scene.add_grid(GridTransform::at(world_origin));
2617 let grid = scene.grid_mut(id).unwrap();
2618 // Solid floor across the entire chunk at z=100..254.
2619 grid.set_rect(
2620 IVec3::new(0, 0, 100),
2621 IVec3::new(127, 127, 254),
2622 Some(VoxColor(0x80_88_88_88)),
2623 );
2624 // Build the per-chunk mip ladder so `gmipnum` can grow past 1.
2625 grid.chunk_mut(IVec3::ZERO).unwrap().generate_mips(3);
2626 (scene, id)
2627 }
2628
2629 /// Render `scene` via composed path with `mip_levels = 3,
2630 /// mip_scan_dist = 32` — same values the working
2631 /// `vxl_generate_mips_on_set_voxel_chunk_renders` test uses.
2632 /// Returns the framebuffer.
2633 fn render_with_multi_mip(scene: &mut Scene, camera: &Camera) -> Vec<u32> {
2634 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2635 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2636 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2637 let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2638 settings.mip_levels = 3;
2639 settings.mip_scan_dist = 32;
2640 let outcome = render_scene_composed(
2641 &mut fb,
2642 &mut zb,
2643 XRES as usize,
2644 XRES,
2645 YRES,
2646 fog,
2647 scene,
2648 camera,
2649 &settings,
2650 sky_color,
2651 None,
2652 );
2653 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2654 fb
2655 }
2656
2657 // DDA.9: `s6_1_mid_overrides_produce_different_framebuffer_than_near`
2658 // was removed. It encoded voxlap's mip-*transition* semantics
2659 // (mid_mip_levels=Some(1) caps in-grid mip transitions, differing
2660 // from Near's mip0→1→2 distance ramp). The DDA renderer uses a
2661 // *uniform* per-grid mip (no in-grid transition), so Some(1) → mip 0
2662 // = identical to Near. DDA mip coarsening is covered by
2663 // `roxlap_core::dda` `mip_render_is_coarse_but_complete`; the LOD-Mid
2664 // wiring by `s6_1_mid_without_overrides_byte_identical_to_near`.
2665
2666 /// Mid tier with `mid_mip_levels = None` AND
2667 /// `mid_mip_scan_dist = None` must produce a byte-identical
2668 /// framebuffer to Near. This is the graceful-degrade contract
2669 /// — callers can opt into the Mid plumbing without committing
2670 /// to a mip override and stay byte-stable.
2671 #[test]
2672 fn s6_1_mid_without_overrides_byte_identical_to_near() {
2673 let camera = camera_at([64.0, 0.0, 64.0]);
2674
2675 // Scene A: default thresholds → Near.
2676 let (mut scene_a, _) = build_mip_visible_grid(DVec3::ZERO);
2677 let fb_near = render_with_multi_mip(&mut scene_a, &camera);
2678
2679 // Scene B: thresholds force Mid but no mip overrides set.
2680 let (mut scene_b, b_id) = build_mip_visible_grid(DVec3::ZERO);
2681 scene_b.grid_mut(b_id).unwrap().lod_thresholds = crate::LodThresholds {
2682 r_near: 0.0,
2683 r_mid: f64::INFINITY,
2684 mid_mip_levels: None,
2685 mid_mip_scan_dist: None,
2686 };
2687 let lod = scene_b
2688 .grid(b_id)
2689 .unwrap()
2690 .select_lod(DVec3::from_array(camera.pos));
2691 assert_eq!(lod, Lod::Mid);
2692 let fb_mid = render_with_multi_mip(&mut scene_b, &camera);
2693
2694 // Byte-identical: Mid with no overrides degrades cleanly.
2695 assert_eq!(
2696 fb_near, fb_mid,
2697 "Mid with both overrides=None must byte-match Near"
2698 );
2699 }
2700
2701 // DDA.9: `s6_1_global_mip_cap_survives_mid_tier` was removed. It
2702 // pinned voxlap's `mip_levels_override` global cap composing with the
2703 // Mid override — the ship anti-axis-aligned-beam workaround. The DDA
2704 // renderer has no axis-aligned mip beam (honest per-cell traversal),
2705 // so the workaround / global cap is obsolete and the DDA path doesn't
2706 // consult `mip_levels_override`.
2707
2708 // ---- S6.3: Far-tier billboard blit ----
2709
2710 /// Force Far tier via `r_near = 0, r_mid = 0`: any non-zero
2711 /// camera-to-grid distance lands on `Lod::Far`. Renders a small
2712 /// grid at world (0, 200, 0) with default-radius thresholds
2713 /// turned all-Far. The composed framebuffer must contain
2714 /// non-sky pixels from the impostor blit.
2715 #[test]
2716 fn s6_3_far_tier_blits_non_sky_pixels() {
2717 let (mut scene, id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
2718 scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
2719 r_near: 0.0,
2720 r_mid: 0.0,
2721 mid_mip_levels: None,
2722 mid_mip_scan_dist: None,
2723 };
2724
2725 let camera = camera_at([64.0, 0.0, 100.0]);
2726 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2727 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2728 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2729 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2730 let outcome = render_scene_composed(
2731 &mut fb,
2732 &mut zb,
2733 XRES as usize,
2734 XRES,
2735 YRES,
2736 fog,
2737 &mut scene,
2738 &camera,
2739 &settings,
2740 sky_color,
2741 None,
2742 );
2743 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
2744
2745 // Sanity: picker actually picked Far.
2746 let lod = scene
2747 .grid(id)
2748 .unwrap()
2749 .select_lod(DVec3::from_array(camera.pos));
2750 assert_eq!(lod, Lod::Far);
2751
2752 // Impostor must paint at least some non-sky pixels.
2753 let non_sky = fb.iter().filter(|&&p| p != sky_color).count();
2754 assert!(
2755 non_sky > 0,
2756 "Far-tier render produced no non-sky pixels — billboard blit not firing"
2757 );
2758 }
2759
2760 /// Lazy populate: cache starts `None`, becomes `Some` after the
2761 /// first Far render.
2762 #[test]
2763 fn s6_3_far_render_lazily_populates_cache() {
2764 let (mut scene, id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
2765 scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
2766 r_near: 0.0,
2767 r_mid: 0.0,
2768 mid_mip_levels: None,
2769 mid_mip_scan_dist: None,
2770 };
2771 assert!(scene.grid(id).unwrap().billboards.is_none());
2772
2773 let camera = camera_at([64.0, 0.0, 100.0]);
2774 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2775 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2776 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2777 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2778 let _ = render_scene_composed(
2779 &mut fb,
2780 &mut zb,
2781 XRES as usize,
2782 XRES,
2783 YRES,
2784 fog,
2785 &mut scene,
2786 &camera,
2787 &settings,
2788 sky_color,
2789 None,
2790 );
2791 let cache = scene
2792 .grid(id)
2793 .unwrap()
2794 .billboards
2795 .as_ref()
2796 .expect("Far render should have populated billboards");
2797 assert_eq!(cache.len(), 26);
2798 }
2799
2800 /// Edit invalidates the cache; a subsequent Far render rebuilds.
2801 #[test]
2802 fn s6_3_edit_invalidates_then_far_render_rebuilds() {
2803 let (mut scene, id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
2804 scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
2805 r_near: 0.0,
2806 r_mid: 0.0,
2807 mid_mip_levels: None,
2808 mid_mip_scan_dist: None,
2809 };
2810 let camera = camera_at([64.0, 0.0, 100.0]);
2811 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2812 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2813
2814 // First Far render → cache built.
2815 let mut fb1 = vec![sky_color; pixel_count(XRES, YRES)];
2816 let mut zb1 = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2817 let _ = render_scene_composed(
2818 &mut fb1,
2819 &mut zb1,
2820 XRES as usize,
2821 XRES,
2822 YRES,
2823 fog,
2824 &mut scene,
2825 &camera,
2826 &settings,
2827 sky_color,
2828 None,
2829 );
2830 assert!(scene.grid(id).unwrap().billboards.is_some());
2831
2832 // Edit invalidates.
2833 scene
2834 .grid_mut(id)
2835 .unwrap()
2836 .set_voxel(IVec3::new(70, 70, 70), Some(VoxColor(0x80_aa_aa_22)));
2837 assert!(scene.grid(id).unwrap().billboards.is_none());
2838
2839 // Second Far render rebuilds.
2840 let mut fb2 = vec![sky_color; pixel_count(XRES, YRES)];
2841 let mut zb2 = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2842 let _ = render_scene_composed(
2843 &mut fb2,
2844 &mut zb2,
2845 XRES as usize,
2846 XRES,
2847 YRES,
2848 fog,
2849 &mut scene,
2850 &camera,
2851 &settings,
2852 sky_color,
2853 None,
2854 );
2855 assert!(scene.grid(id).unwrap().billboards.is_some());
2856 }
2857
2858 /// CA — the cutaway clip reaches the Far tier: impostor snapshots
2859 /// render WITH the grid's clip ("world as if removed" holds at
2860 /// every LOD), a clip change rebuilds the cache — via the facade
2861 /// setter (drops it eagerly) or a direct `z_clip` field write (the
2862 /// Far dispatch self-heals on `built_z_clip` mismatch) — and the
2863 /// rebuilt impostor actually loses the clipped voxels.
2864 #[test]
2865 fn cutaway_clip_rebuilds_and_clips_far_billboards() {
2866 let (mut scene, id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
2867 scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
2868 r_near: 0.0,
2869 r_mid: 0.0,
2870 mid_mip_levels: None,
2871 mid_mip_scan_dist: None,
2872 };
2873 let camera = camera_at([64.0, 0.0, 100.0]);
2874 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2875 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2876 let render = |scene: &mut Scene| {
2877 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2878 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2879 let _ = render_scene_composed(
2880 &mut fb,
2881 &mut zb,
2882 XRES as usize,
2883 XRES,
2884 YRES,
2885 fog,
2886 scene,
2887 &camera,
2888 &settings,
2889 sky_color,
2890 None,
2891 );
2892 };
2893 // Impostor solid coverage = finite-depth pixels of snapshot 0.
2894 let solid_px = |scene: &Scene| {
2895 let c = scene.grid(id).unwrap().billboards.as_ref().unwrap();
2896 c.snapshots[0]
2897 .depth
2898 .iter()
2899 .filter(|d| d.is_finite())
2900 .count()
2901 };
2902
2903 // Unclipped Far render: cache built with no clip, solid pixels.
2904 render(&mut scene);
2905 let unclipped = solid_px(&scene);
2906 assert!(unclipped > 0, "unclipped impostor must have solid pixels");
2907 assert_eq!(
2908 scene
2909 .grid(id)
2910 .unwrap()
2911 .billboards
2912 .as_ref()
2913 .unwrap()
2914 .built_z_clip,
2915 None
2916 );
2917
2918 // Facade setter: hides the WHOLE grid → cache dropped eagerly,
2919 // the next Far render rebuilds all-sky snapshots.
2920 assert!(scene.set_grid_z_clip(id, Some(256)));
2921 assert!(
2922 scene.grid(id).unwrap().billboards.is_none(),
2923 "set_grid_z_clip must drop a cache built under another clip"
2924 );
2925 render(&mut scene);
2926 let cache = scene.grid(id).unwrap().billboards.as_ref().unwrap();
2927 assert_eq!(cache.built_z_clip, Some(256));
2928 assert_eq!(
2929 solid_px(&scene),
2930 0,
2931 "a fully clipped grid's impostor must be all sky"
2932 );
2933
2934 // Direct field write bypasses the setter — the Far dispatch
2935 // must self-heal on the built_z_clip mismatch.
2936 scene.grid_mut(id).unwrap().z_clip = None;
2937 render(&mut scene);
2938 let cache = scene.grid(id).unwrap().billboards.as_ref().unwrap();
2939 assert_eq!(cache.built_z_clip, None, "stale-clip cache must rebuild");
2940 assert_eq!(solid_px(&scene), unclipped, "unclipped impostor restored");
2941 }
2942
2943 /// OC.1 — composed render with only a view cutout set.
2944 fn render_composed_cutout(
2945 scene: &mut Scene,
2946 camera: &Camera,
2947 cutout: Option<&SceneViewCutout>,
2948 ) -> Vec<u32> {
2949 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
2950 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
2951 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
2952 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
2953 let mut params = ComposedFrameParams::new(camera, &settings);
2954 params.fog = fog;
2955 params.sky_color = sky_color;
2956 params.view_cutout = cutout;
2957 let _ = render_scene_composed_frame(
2958 &mut fb,
2959 &mut zb,
2960 XRES as usize,
2961 XRES,
2962 YRES,
2963 scene,
2964 ¶ms,
2965 &mut SceneRenderScratch::default(),
2966 );
2967 fb
2968 }
2969
2970 /// OC.1 (hazard 3) — the cutout's world→grid conversion is
2971 /// vws-aware: on a `vws = 0.25` grid the focus (and with it the
2972 /// column + focus plane) must land at `world/vws` voxel
2973 /// coordinates. An unscaled conversion would put the plane at
2974 /// local z 30 instead of 120 — the z-gate would keep the wall and
2975 /// the centre pixel would stay WALL.
2976 #[test]
2977 fn cutout_t_reveal_scales_with_voxel_world_size() {
2978 const WALL: VoxColor = VoxColor(0x80_C0_40_40);
2979 const BACK: VoxColor = VoxColor(0x80_40_C0_40);
2980 let mut scene = Scene::new();
2981 let mut t = GridTransform::at(DVec3::ZERO);
2982 t.voxel_world_size = 0.25;
2983 let id = scene.add_grid(t);
2984 let grid = scene.grid_mut(id).unwrap();
2985 // Grid-local planes: wall at y=40 (world y 10), back wall at
2986 // y=120 (world y 30), both spanning the full x/z extent.
2987 grid.set_rect(IVec3::new(0, 40, 0), IVec3::new(127, 40, 255), Some(WALL));
2988 grid.set_rect(IVec3::new(0, 120, 0), IVec3::new(127, 120, 255), Some(BACK));
2989 // World camera at y=2 looking +y: wall at world depth 8, back
2990 // wall at world depth 28. Centre ray at world z 16 = local 64.
2991 let camera = camera_at([16.0, 2.0, 16.0]);
2992 let centre = (usize::try_from(YRES / 2).unwrap() * XRES as usize) + XRES as usize / 2;
2993 // Whole-frustum cone; the character column at world y 20 sits
2994 // between the wall (10) and the back wall (30), so the wall
2995 // cuts and the back wall survives. Focus plane at local z 120
2996 // (world z 30), well below the centre ray's local z 64.
2997 let cut = SceneViewCutout {
2998 tan_outer: 10.0,
2999 tan_inner: 10.0,
3000 focus_world: [16.0, 20.0, 30.0],
3001 margin: 1.0,
3002 z_bias: 0.0,
3003 };
3004 let fb = render_composed_cutout(&mut scene, &camera, Some(&cut));
3005 assert_eq!(
3006 fb[centre], BACK.0,
3007 "the scaled grid's wall must cut in front of the column, got {:08x}",
3008 fb[centre]
3009 );
3010 // Negative control: no cutout → the wall.
3011 let fb = render_composed_cutout(&mut scene, &camera, None);
3012 assert_eq!(fb[centre], WALL.0, "uncut render must show the wall");
3013 }
3014
3015 /// OC.1 (hazard 8) — Far-tier billboards are naturally unaffected:
3016 /// the impostor blit path never consults the cutout, so a Far
3017 /// render with an everything-revealing cutout is byte-identical
3018 /// to the uncut render.
3019 #[test]
3020 fn cutout_far_billboards_unaffected() {
3021 let (mut scene, id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
3022 scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
3023 r_near: 0.0,
3024 r_mid: 0.0,
3025 mid_mip_levels: None,
3026 mid_mip_scan_dist: None,
3027 };
3028 let camera = camera_at([64.0, 0.0, 100.0]);
3029 let base = render_composed_cutout(&mut scene, &camera, None);
3030 let cut = SceneViewCutout {
3031 tan_outer: 10.0,
3032 tan_inner: 10.0,
3033 focus_world: [64.0, 400.0, 100.0],
3034 margin: 0.0,
3035 z_bias: 0.0,
3036 };
3037 let with_cut = render_composed_cutout(&mut scene, &camera, Some(&cut));
3038 assert_eq!(
3039 base, with_cut,
3040 "the Far impostor blit must ignore the view cutout"
3041 );
3042 }
3043
3044 /// Hybrid scene: one Near grid + one Far grid. Both must render
3045 /// visibly; the Far grid via blit, the Near grid via opticast.
3046 /// Sanity check that the two paths cohabit one
3047 /// `render_scene_composed` call.
3048 #[test]
3049 fn s6_3_near_and_far_grids_in_same_scene() {
3050 let mut scene = Scene::new();
3051 // Grid A: stays Near (default thresholds). Solid box at
3052 // world (-30..-20, 190..210, 50..70).
3053 let a_id = scene.add_grid(GridTransform::at(DVec3::new(-100.0, 200.0, 0.0)));
3054 scene.grid_mut(a_id).unwrap().set_rect(
3055 IVec3::new(70, 0, 50),
3056 IVec3::new(85, 15, 70),
3057 Some(VoxColor(0x80_22_88_22)), // green
3058 );
3059 // Grid B: forced Far. Box at world (~100, 200, 100).
3060 let b_id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 200.0, 0.0)));
3061 scene.grid_mut(b_id).unwrap().set_rect(
3062 IVec3::new(0, 0, 80),
3063 IVec3::new(20, 20, 110),
3064 Some(VoxColor(0x80_aa_22_22)), // red
3065 );
3066 scene.grid_mut(b_id).unwrap().lod_thresholds = crate::LodThresholds {
3067 r_near: 0.0,
3068 r_mid: 0.0,
3069 mid_mip_levels: None,
3070 mid_mip_scan_dist: None,
3071 };
3072
3073 let camera = camera_at([0.0, 0.0, 80.0]);
3074 // Confirm A is Near, B is Far for this pose.
3075 assert_eq!(
3076 scene
3077 .grid(a_id)
3078 .unwrap()
3079 .select_lod(DVec3::from_array(camera.pos)),
3080 Lod::Near
3081 );
3082 assert_eq!(
3083 scene
3084 .grid(b_id)
3085 .unwrap()
3086 .select_lod(DVec3::from_array(camera.pos)),
3087 Lod::Far
3088 );
3089
3090 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
3091 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3092 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3093 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3094 let outcome = render_scene_composed(
3095 &mut fb,
3096 &mut zb,
3097 XRES as usize,
3098 XRES,
3099 YRES,
3100 fog,
3101 &mut scene,
3102 &camera,
3103 &settings,
3104 sky_color,
3105 None,
3106 );
3107 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 2 });
3108
3109 // Each grid should contribute visible pixels.
3110 let non_sky = fb.iter().filter(|&&p| p != sky_color).count();
3111 assert!(
3112 non_sky > 20,
3113 "hybrid scene produced too few non-sky pixels ({non_sky}); one tier may have failed"
3114 );
3115 }
3116
3117 /// Empty grid at Far tier: skipped silently (no panic, no
3118 /// allocation), `billboards` stays `None`.
3119 #[test]
3120 fn s6_3_empty_grid_at_far_is_skipped() {
3121 let mut scene = Scene::new();
3122 let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 200.0, 0.0)));
3123 scene.grid_mut(id).unwrap().lod_thresholds = crate::LodThresholds {
3124 r_near: 0.0,
3125 r_mid: 0.0,
3126 mid_mip_levels: None,
3127 mid_mip_scan_dist: None,
3128 };
3129
3130 let camera = camera_at([0.0, 0.0, 100.0]);
3131 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
3132 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3133 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3134 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3135 let outcome = render_scene_composed(
3136 &mut fb,
3137 &mut zb,
3138 XRES as usize,
3139 XRES,
3140 YRES,
3141 fog,
3142 &mut scene,
3143 &camera,
3144 &settings,
3145 sky_color,
3146 None,
3147 );
3148 // No grids contributed.
3149 assert_eq!(outcome, RenderOutcome::Empty);
3150 // Cache must NOT have been built for an empty grid.
3151 assert!(scene.grid(id).unwrap().billboards.is_none());
3152 // Framebuffer unchanged.
3153 assert!(fb.iter().all(|&p| p == sky_color));
3154 }
3155
3156 // ---- S6.0: LOD picker wired but every tier falls through to Near ----
3157
3158 /// Threshold-invariance: a grid rendered with the S6 derived
3159 /// thresholds (`from_radius` of the actual bounding sphere) must
3160 /// produce a framebuffer byte-identical to the same grid with
3161 /// default `always_near` thresholds, because S6.0 takes the
3162 /// `Near` arm of the match for all three tiers. This is the
3163 /// regression test for the S6.0 contract.
3164 #[test]
3165 fn render_scene_composed_lod_threshold_invariance() {
3166 // Scene A: default thresholds (always_near).
3167 let (mut scene_a, _a_id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
3168 let cam = camera_at([64.0, 0.0, 100.0]);
3169 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
3170 let mut fb_a = vec![sky_color; pixel_count(XRES, YRES)];
3171 let mut zb_a = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3172 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3173 let outcome_a = render_scene_composed(
3174 &mut fb_a,
3175 &mut zb_a,
3176 XRES as usize,
3177 XRES,
3178 YRES,
3179 fog,
3180 &mut scene_a,
3181 &cam,
3182 &settings,
3183 sky_color,
3184 None,
3185 );
3186 assert_eq!(outcome_a, RenderOutcome::Rendered { grids_drawn: 1 });
3187
3188 // Scene B: thresholds derived from the grid's bounding
3189 // radius. At this camera distance the grid lands on Mid or
3190 // Far; if S6.0 ever stops falling through to Near, this test
3191 // catches the divergence.
3192 let (mut scene_b, b_id) = build_one_grid_scene(DVec3::new(0.0, 200.0, 0.0));
3193 let radius = scene_b.grid(b_id).unwrap().bounding_radius();
3194 assert!(
3195 radius > 0.0,
3196 "bounding_radius should be > 0 for a populated grid"
3197 );
3198 scene_b.grid_mut(b_id).unwrap().lod_thresholds = crate::LodThresholds::from_radius(radius);
3199 // Sanity: the camera is far enough that the picker no longer
3200 // returns Near (otherwise the invariance test would be vacuous).
3201 let lod = scene_b
3202 .grid(b_id)
3203 .unwrap()
3204 .select_lod(DVec3::from_array(cam.pos));
3205 assert_ne!(
3206 lod,
3207 Lod::Near,
3208 "camera should land in Mid or Far for derived thresholds — got {lod:?}",
3209 );
3210
3211 let mut fb_b = vec![sky_color; pixel_count(XRES, YRES)];
3212 let mut zb_b = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3213 let outcome_b = render_scene_composed(
3214 &mut fb_b,
3215 &mut zb_b,
3216 XRES as usize,
3217 XRES,
3218 YRES,
3219 fog,
3220 &mut scene_b,
3221 &cam,
3222 &settings,
3223 sky_color,
3224 None,
3225 );
3226 assert_eq!(outcome_b, RenderOutcome::Rendered { grids_drawn: 1 });
3227
3228 // Byte-identity is the S6.0 contract — Mid/Far still take
3229 // the Near arm.
3230 assert_eq!(
3231 fb_a, fb_b,
3232 "S6.0 framebuffer must be byte-identical regardless of LOD thresholds"
3233 );
3234 }
3235
3236 #[test]
3237 fn render_scene_composed_empty_scene_returns_empty() {
3238 let mut scene = Scene::new();
3239 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
3240 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3241 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3242 let camera = camera_at([0.0, 0.0, 0.0]);
3243 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3244 let outcome = render_scene_composed(
3245 &mut fb,
3246 &mut zb,
3247 XRES as usize,
3248 XRES,
3249 YRES,
3250 fog,
3251 &mut scene,
3252 &camera,
3253 &settings,
3254 sky_color,
3255 None,
3256 );
3257 assert_eq!(outcome, RenderOutcome::Empty);
3258 // fb should be unchanged (still all sky).
3259 assert!(fb.iter().all(|&p| p == sky_color));
3260 }
3261
3262 /// FNV-1a 64-bit hash. Same offset/prime as the
3263 /// `roxlap-oracle::fnv1a64` helper used by the wasm-render
3264 /// goldens; pinning a render hash here is the same flavour of
3265 /// regression catch.
3266 fn fnv1a64(data: &[u8]) -> u64 {
3267 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
3268 for &b in data {
3269 h ^= u64::from(b);
3270 h = h.wrapping_mul(0x0000_0100_0000_01b3);
3271 }
3272 h
3273 }
3274
3275 // ---- S4.0 cross-chunk smoke test ----
3276
3277 /// Two-chunk-wide grid: a recognisable shape spans the chunk
3278 /// boundary at `virtual_x = 128`. The render must not have a
3279 /// horizontal seam line at the boundary.
3280 #[test]
3281 fn render_scene_two_chunk_x_grid_no_seam() {
3282 let mut scene = Scene::new();
3283 let id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
3284 let g = scene.grid_mut(id).unwrap();
3285 // 100-voxel-tall stripe spanning x=[120..136] across the
3286 // x=128 chunk seam at z=200, y=[60..68]. After bake-free
3287 // render, every column in the stripe paints the same colour
3288 // at the same z; a seam at x=128 would show as missing
3289 // pixels in the column at virtual_x=128 / 129 / ...
3290 g.set_rect(
3291 IVec3::new(120, 60, 200),
3292 IVec3::new(136, 67, 215),
3293 Some(VoxColor(0x80_aa_55_22)),
3294 );
3295 // Sanity: ensure both chunks were materialised.
3296 assert_eq!(g.chunk_count(), 2);
3297
3298 // Render with a camera positioned to look at the stripe
3299 // straight on. Stripe at world (120..136, 260..268, 200..215).
3300 // Camera at (128, 100, 207) looking +y centres on it.
3301 let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
3302 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3303 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3304 let camera = camera_at([128.0, 100.0, 207.0]);
3305 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3306 let outcome = render_scene_composed(
3307 &mut fb,
3308 &mut zb,
3309 XRES as usize,
3310 XRES,
3311 YRES,
3312 fog,
3313 &mut scene,
3314 &camera,
3315 &settings,
3316 sky_color,
3317 None,
3318 );
3319 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
3320
3321 // Stripe colour should appear in roughly the centre of the
3322 // framebuffer. A chunk-edge seam would manifest as a thin
3323 // sky-coloured vertical line splitting the stripe in two.
3324 let stripe = 0x80_aa_55_22;
3325 let stripe_count = fb.iter().filter(|&&p| p == stripe).count();
3326 assert!(
3327 stripe_count > 200,
3328 "stripe rendered too few pixels ({stripe_count}) — chunks may not be stitching"
3329 );
3330
3331 // Walk the centre row left-to-right looking for a sky-pixel
3332 // gap inside a stripe run. A gap 1+ pixels wide flags a
3333 // chunk-edge seam.
3334 let centre_y = (YRES / 2) as usize;
3335 let row_start = centre_y * (XRES as usize);
3336 let row = &fb[row_start..row_start + (XRES as usize)];
3337 let mut in_stripe = false;
3338 let mut seam_gaps = 0usize;
3339 for &px in row {
3340 if px == stripe {
3341 in_stripe = true;
3342 } else if in_stripe && px == sky_color {
3343 // Stripe ended; if we re-enter it on this row that's
3344 // a seam.
3345 if row.iter().skip_while(|&&p| p != px).any(|&p| p == stripe) {
3346 // Look ahead for any further stripe pixel.
3347 seam_gaps += 1;
3348 }
3349 in_stripe = false;
3350 }
3351 }
3352 // We allow seam_gaps to count the legitimate "stripe ended,
3353 // didn't restart" transition once; more than that means
3354 // multiple disjoint runs on the row → seam.
3355 assert!(
3356 seam_gaps <= 1,
3357 "centre row has {seam_gaps} disjoint stripe runs — expected 1 (chunk-edge seam suspected)"
3358 );
3359 }
3360
3361 // DDA.9: the voxlap-era mip regression tests here
3362 // (`vxl_generate_mips_on_set_voxel_chunk_renders` + the byte-exact
3363 // 2-chunk opticast pin) were removed — they drove voxlap `opticast` +
3364 // `ScalarRasterizer` directly, a path no longer reachable from this
3365 // consumer crate. The DDA mip ladder + multi-mip render is covered by
3366 // `render_with_mips_present_still_renders_mip0` and the
3367 // `stacked_*_multi_mip` tests below.
3368
3369 /// Mip-0 preservation when mips are generated on the combined
3370 /// view but `mip_levels = 1` in the rasterizer's settings.
3371 /// Confirms `generate_mips` only APPENDS data — mip-0
3372 /// prefix is unchanged.
3373 #[test]
3374 fn render_with_mips_present_still_renders_mip0() {
3375 let mut scene = Scene::new();
3376 let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3377 scene.grid_mut(id).unwrap().set_rect(
3378 IVec3::new(40, 40, 40),
3379 IVec3::new(55, 55, 55),
3380 Some(VoxColor(0x80_88_88_88)),
3381 );
3382 // S4B.4.a: force mip-1..mip-2 generation on the single
3383 // chunk directly (the Grid's combined-view cache API was
3384 // removed). The chunk's own Vxl::generate_mips builds its
3385 // own mip tables and the renderer happens to render through
3386 // them via Approach B's chunk_at_xy lookup.
3387 {
3388 let grid = scene.grid_mut(id).unwrap();
3389 let chunk = grid.chunks.get_mut(&IVec3::ZERO).unwrap();
3390 chunk.generate_mips(3);
3391 }
3392
3393 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
3394 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3395 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3396 let camera = camera_at([64.0, 0.0, 64.0]);
3397 // mip_scan_dist huge → renderer never transitions past mip-0
3398 // so this test pins mip-0 correctness only.
3399 let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3400 settings.mip_scan_dist = 100_000;
3401 let outcome = render_scene_composed(
3402 &mut fb,
3403 &mut zb,
3404 XRES as usize,
3405 XRES,
3406 YRES,
3407 fog,
3408 &mut scene,
3409 &camera,
3410 &settings,
3411 sky_color,
3412 None,
3413 );
3414 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
3415 let non_sky = fb.iter().filter(|&&p| p != sky_color).count();
3416 assert!(
3417 non_sky > 0,
3418 "render of single-grid scene with mips present rendered all-sky: mip-0 may be corrupted by generate_mips"
3419 );
3420 }
3421
3422 #[test]
3423 fn render_scene_two_chunk_x_grid_hash_is_stable() {
3424 // Frozen 2026-05-10 at S4.0 landing on x86_64.
3425 // DDA.9: re-frozen to the DDA renderer's output (was the
3426 // voxlap-opticast golden 0x215e_d66d_7359_4725).
3427 // CA follow-up: re-frozen for the well-conditioned skip landing
3428 // (crossing counts from t-differences instead of re-flooring an
3429 // absolute position — fixes distant-camera seam lines; was
3430 // 0x492e_c4bb_718f_d7e5). The structural gates (no-seam +
3431 // skip-vs-dense equivalence) pin correctness; this hash only
3432 // pins stability.
3433 const GOLDEN: u64 = 0x4867_4db5_5738_7065;
3434 // Same scene shape as `render_scene_two_chunk_x_grid_no_seam`
3435 // — kept distinct so the hash assertion doesn't share its
3436 // setup with the structural seam check.
3437 let mut scene = Scene::new();
3438 let id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 200.0, 0.0)));
3439 scene.grid_mut(id).unwrap().set_rect(
3440 IVec3::new(120, 60, 200),
3441 IVec3::new(136, 67, 215),
3442 Some(VoxColor(0x80_aa_55_22)),
3443 );
3444 let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
3445 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3446 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3447 let camera = camera_at([128.0, 100.0, 207.0]);
3448 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3449 let outcome = render_scene_composed(
3450 &mut fb,
3451 &mut zb,
3452 XRES as usize,
3453 XRES,
3454 YRES,
3455 fog,
3456 &mut scene,
3457 &camera,
3458 &settings,
3459 sky_color,
3460 None,
3461 );
3462 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
3463
3464 let bytes: Vec<u8> = fb.iter().flat_map(|p| p.to_ne_bytes()).collect();
3465 let hash = fnv1a64(&bytes);
3466 if GOLDEN == SENTINEL {
3467 // First-run capture mode — print the hash so the
3468 // developer can paste it into GOLDEN above.
3469 eprintln!("render_scene_two_chunk_x_grid_hash_is_stable: capture hash = 0x{hash:016x}");
3470 panic!("GOLDEN is the SENTINEL placeholder — paste 0x{hash:016x} into GOLDEN above");
3471 }
3472 assert_eq!(
3473 hash, GOLDEN,
3474 "2-chunk render hash drifted: expected 0x{GOLDEN:016x}, got 0x{hash:016x}"
3475 );
3476 }
3477
3478 /// Sentinel for first-run hash capture in
3479 /// [`render_scene_two_chunk_x_grid_hash_is_stable`]. Replace
3480 /// `GOLDEN`'s definition with the printed value once captured.
3481 const SENTINEL: u64 = 0xDEAD_BEEF_DEAD_BEEF;
3482
3483 /// S4B.6.c: stacked-grid scaffold — camera in chz=1 (= world
3484 /// z=256..511) of a 2-chunk-tall grid should render its own
3485 /// chunk's terrain. Verifies cf seed + slab-byte reads + chunk-
3486 /// XY swaps all use world-z consistently.
3487 ///
3488 /// Cross-chunk look-down (= camera in chz=0 sees terrain in
3489 /// chz=1) needs cf z range extension at air-gap-lookup time;
3490 /// that's a follow-up to S4B.6.c.
3491 #[test]
3492 fn stacked_two_chunk_z_camera_in_chz1_sees_own_chunk_floor() {
3493 let mut scene = Scene::new();
3494 let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3495 let g = scene.grid_mut(id).unwrap();
3496 // chz=0: all-air (materialised so chunk_xyz_backing enumerates).
3497 g.ensure_chunk(IVec3::new(0, 0, 0));
3498 // chz=1: floor at local z=50 (= world z=306).
3499 g.set_rect(
3500 IVec3::new(60, 60, 306),
3501 IVec3::new(72, 72, 310),
3502 Some(VoxColor(0x80_33_66_99)),
3503 );
3504 assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
3505
3506 let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
3507 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3508 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3509 // Camera at world (66, 66, 280) — directly above the
3510 // floor at world z=306. Look STRAIGHT DOWN (z increases =
3511 // down in voxlap z-down).
3512 let camera = Camera {
3513 pos: [66.0, 66.0, 280.0],
3514 right: [1.0, 0.0, 0.0],
3515 down: [0.0, 1.0, 0.0],
3516 forward: [0.0, 0.0, 1.0],
3517 };
3518 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3519 let outcome = render_scene_composed(
3520 &mut fb,
3521 &mut zb,
3522 XRES as usize,
3523 XRES,
3524 YRES,
3525 fog,
3526 &mut scene,
3527 &camera,
3528 &settings,
3529 sky_color,
3530 None,
3531 );
3532 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
3533 let floor_count = fb.iter().filter(|&&p| p == 0x80_33_66_99).count();
3534 assert!(
3535 floor_count > 100,
3536 "camera at chz=1 with floor in same chunk should see it — got {floor_count} floor pixels"
3537 );
3538 }
3539
3540 /// S4B.6.e: cross-chunk look-down. Camera in chz=0's all-air
3541 /// chunk should see chz=1's floor below it. This was deferred
3542 /// from S4B.6.c because the cf seed's z range capped at the
3543 /// camera-chunk's bedrock (world z=255); S4B.6.e extends the
3544 /// air-gap walk in `camera_chunk_air_gap` to step into the
3545 /// next chunk down when the camera's column is all-air-bedrock,
3546 /// and the rasterizer routes state.column / slab_buf to the
3547 /// chunk holding the real floor via `seed_chunk_z`.
3548 #[test]
3549 fn stacked_two_chunk_z_camera_in_chz0_sees_chz1_floor() {
3550 let mut scene = Scene::new();
3551 let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3552 let g = scene.grid_mut(id).unwrap();
3553 // chz=0: all-air. Materialised so chunk_xyz_backing
3554 // enumerates it.
3555 g.ensure_chunk(IVec3::new(0, 0, 0));
3556 // chz=1: floor at world z=306..310 (= local z=50..54).
3557 g.set_rect(
3558 IVec3::new(60, 60, 306),
3559 IVec3::new(72, 72, 310),
3560 Some(VoxColor(0x80_77_aa_44)),
3561 );
3562 assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
3563
3564 let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
3565 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3566 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3567 // Camera at world (66, 66, 100) — in chz=0's all-air
3568 // chunk. Look STRAIGHT DOWN (z+) toward chz=1's floor at
3569 // world z=306.
3570 let camera = Camera {
3571 pos: [66.0, 66.0, 100.0],
3572 right: [1.0, 0.0, 0.0],
3573 down: [0.0, 1.0, 0.0],
3574 forward: [0.0, 0.0, 1.0],
3575 };
3576 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3577 let outcome = render_scene_composed(
3578 &mut fb,
3579 &mut zb,
3580 XRES as usize,
3581 XRES,
3582 YRES,
3583 fog,
3584 &mut scene,
3585 &camera,
3586 &settings,
3587 sky_color,
3588 None,
3589 );
3590 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
3591 let floor_count = fb.iter().filter(|&&p| p == 0x80_77_aa_44).count();
3592 assert!(
3593 floor_count > 50,
3594 "camera in chz=0 air-gap should see chz=1 floor via cross-chunk look-down — got {floor_count} floor pixels"
3595 );
3596 }
3597
3598 /// S4B.6.l KNOWN LIMITATION → RESOLVED by VC.5 (2026-05-31).
3599 /// Camera at chz=0 with all-air-bedrock at the camera's own
3600 /// XY column (seed_chz=1 via cross-chunk look-down). A DIFFERENT
3601 /// XY column has chz=0 content (= a distant mountain entirely
3602 /// inside chz=0). Pre-VC.5 the chunk-XY swap read chz=1 chunks
3603 /// across the DDA, so the chz=0 mountain was invisible. VC.5's
3604 /// multi-chz column-step install stitches every chz layer at the
3605 /// new XY column; the chz=0 mountain renders correctly.
3606 ///
3607 /// VC.0 pin (2026-05-31): re-enabled (was `#[ignore]`'d). VC.5
3608 /// flipped it from failing (mountain_chz0 = 0) to passing.
3609 #[test]
3610 fn stacked_chz0_distant_mountain_visible_from_chz0_camera() {
3611 let mut scene = Scene::new();
3612 let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3613 let g = scene.grid_mut(id).unwrap();
3614 // chz=0 mountain at a column DISTANT from the camera —
3615 // entirely in chz=0 (world z=100..200), so chz=1 at the
3616 // same XY is all-air-bedrock.
3617 g.set_rect(
3618 IVec3::new(100, 100, 100),
3619 IVec3::new(124, 124, 200),
3620 Some(VoxColor(0x80_aa_55_22)), // distinct brown
3621 );
3622 // chz=1 hills filling the floor at world z=336..360 across
3623 // the chunk EXCEPT a hole around the mountain XY (so the
3624 // mountain doesn't sit on a green tower).
3625 g.set_rect(
3626 IVec3::new(0, 0, 336),
3627 IVec3::new(128, 128, 360),
3628 Some(VoxColor(0x80_22_88_44)),
3629 );
3630 g.set_rect(IVec3::new(100, 100, 336), IVec3::new(124, 124, 360), None);
3631 // Materialise chz=0 + chz=1 (chz=0 has the mountain; chz=1
3632 // has the hills).
3633 assert!(g.chunk(IVec3::new(0, 0, 0)).is_some());
3634 assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
3635
3636 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
3637 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3638 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3639 // Camera at (40, 40, 60) — chz=0 air, FAR from the mountain
3640 // XY (100..124, 100..124). Yaw=π/4 (look toward +x+y =
3641 // mountain direction), pitch=0.72 rad (≈ 41° down) so the
3642 // ray bisecting the screen aims at the chz=0 mountain centre
3643 // ≈ (112, 112, 150).
3644 let camera = Camera::from_yaw_pitch([40.0, 40.0, 60.0], std::f64::consts::FRAC_PI_4, 0.72);
3645 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3646 let outcome = render_scene_composed(
3647 &mut fb,
3648 &mut zb,
3649 XRES as usize,
3650 XRES,
3651 YRES,
3652 fog,
3653 &mut scene,
3654 &camera,
3655 &settings,
3656 sky_color,
3657 None,
3658 );
3659 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
3660 let mountain_count = fb.iter().filter(|&&p| p == 0x80_aa_55_22).count();
3661 let hill_count = fb.iter().filter(|&&p| p == 0x80_22_88_44).count();
3662 eprintln!("chz0-distant-mountain: mountain_chz0={mountain_count} hill_chz1={hill_count}");
3663 // chz=1 hills are reachable via seed-time cross-chunk
3664 // look-down.
3665 assert!(
3666 hill_count > 50,
3667 "expected chz=1 hills via cross-chunk look-down — got {hill_count}"
3668 );
3669 // The proper-fix assertion: chz=0 distant mountain SHOULD be
3670 // visible. Currently fails — pins the limitation.
3671 assert!(
3672 mountain_count > 50,
3673 "expected chz=0 distant mountain visible — got {mountain_count} (S4B.6.l limitation)"
3674 );
3675 }
3676
3677 /// S4B.6.h: mid-render chunk-Z handoff. Camera column has
3678 /// content in chz=0 (= a mountain at the camera's XY) so
3679 /// seed-time cross-chunk look-down does NOT fire — seed_chz=0.
3680 /// As rays DDA across the scene, they visit XY columns where
3681 /// chz=0 is all-air-bedrock. Mid-render handoff should swap
3682 /// state to chz=1's column at those XY positions and reveal
3683 /// hill content sitting under the camera's chz=0 layer.
3684 ///
3685 /// This is the "tall mountains breaching chunk-Z boundary"
3686 /// case the demo aims for.
3687 #[test]
3688 fn mid_render_handoff_reveals_chz1_hills_under_mountain_camera() {
3689 let mut scene = Scene::new();
3690 let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3691 let g = scene.grid_mut(id).unwrap();
3692 // chz=0: a small "mountain peak" at the camera's XY.
3693 // Mountain at world z=150..200 — solid block.
3694 g.set_rect(
3695 IVec3::new(60, 60, 150),
3696 IVec3::new(72, 72, 200),
3697 Some(VoxColor(0x80_88_44_22)), // brown mountain
3698 );
3699 // chz=1: hills at world z=336..360 across the WHOLE chunk
3700 // (so DDA rays hit them when chz=0 is air).
3701 g.set_rect(
3702 IVec3::new(0, 0, 336),
3703 IVec3::new(128, 128, 360),
3704 Some(VoxColor(0x80_22_88_44)), // green hills
3705 );
3706 // Carve a hole in chz=1's hill at the mountain's footprint
3707 // so the mountain doesn't appear to "float" on green.
3708 g.set_rect(IVec3::new(60, 60, 336), IVec3::new(72, 72, 360), None);
3709 assert!(g.chunk(IVec3::new(0, 0, 0)).is_some());
3710 assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
3711
3712 let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
3713 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3714 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3715 // Camera at world (66, 66, 100) — directly above the
3716 // mountain peak (at z=150). Camera column has the
3717 // mountain in chz=0. Look straight down.
3718 let camera = Camera {
3719 pos: [66.0, 66.0, 100.0],
3720 right: [1.0, 0.0, 0.0],
3721 down: [0.0, 1.0, 0.0],
3722 forward: [0.0, 0.0, 1.0],
3723 };
3724 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3725 let outcome = render_scene_composed(
3726 &mut fb,
3727 &mut zb,
3728 XRES as usize,
3729 XRES,
3730 YRES,
3731 fog,
3732 &mut scene,
3733 &camera,
3734 &settings,
3735 sky_color,
3736 None,
3737 );
3738 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
3739 let mountain_count = fb.iter().filter(|&&p| p == 0x80_88_44_22).count();
3740 let hill_count = fb.iter().filter(|&&p| p == 0x80_22_88_44).count();
3741 // Verify the hills render at approximately the correct
3742 // world-z by sampling the z-buffer at hill pixels. Camera
3743 // at z=100 looking straight down; hills at world z=336.
3744 // Expected depth = 236 for directly-below pixels. If
3745 // state.z1 stays stuck at the mountain peak's z=150 the
3746 // hills would render with depth ≈ 50 → orders of magnitude
3747 // off.
3748 let mut hill_depths: Vec<f32> = fb
3749 .iter()
3750 .zip(zb.iter())
3751 .filter_map(|(&p, &d)| if p == 0x80_22_88_44 { Some(d) } else { None })
3752 .collect();
3753 hill_depths.sort_by(|a, b| a.partial_cmp(b).unwrap());
3754 let median_hill_depth = hill_depths[hill_depths.len() / 2];
3755 eprintln!(
3756 "mid-render handoff: mountain={mountain_count} hill={hill_count} median_hill_depth={median_hill_depth:.1}"
3757 );
3758 assert!(
3759 mountain_count > 50,
3760 "should see mountain peak via chz=0 — got {mountain_count} mountain pixels"
3761 );
3762 assert!(
3763 hill_count > 50,
3764 "should see chz=1 hills via mid-render handoff — got {hill_count} hill pixels"
3765 );
3766 assert!(
3767 (median_hill_depth - 236.0).abs() < 80.0,
3768 "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"
3769 );
3770 }
3771
3772 /// S4B.6.g: cross-chunk look-down under multi-mip. Same scene
3773 /// as `stacked_two_chunk_z_camera_in_chz0_sees_chz1_floor` but
3774 /// with `mip_levels=2, mip_scan_dist=16` so the rasterizer
3775 /// transitions to mip-1 well within the chz=1 terrain. Locks in
3776 /// the slab_z_at mip-N offset fix (= `chunk_world_z_base >>
3777 /// gmipcnt`). Pre-fix produced a green / brown "wall in a circle
3778 /// around the camera" because mip-1 rendered the floor at
3779 /// world-z ≈ 178 instead of 306.
3780 #[test]
3781 fn stacked_two_chunk_z_camera_in_chz0_sees_chz1_floor_multi_mip() {
3782 let mut scene = Scene::new();
3783 let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3784 let g = scene.grid_mut(id).unwrap();
3785 g.ensure_chunk(IVec3::new(0, 0, 0));
3786 g.set_rect(
3787 IVec3::new(60, 60, 306),
3788 IVec3::new(72, 72, 310),
3789 Some(VoxColor(0x80_77_aa_44)),
3790 );
3791 assert!(g.chunk(IVec3::new(0, 0, 1)).is_some());
3792
3793 let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
3794 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3795 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3796 let camera = Camera {
3797 pos: [66.0, 66.0, 100.0],
3798 right: [1.0, 0.0, 0.0],
3799 down: [0.0, 1.0, 0.0],
3800 forward: [0.0, 0.0, 1.0],
3801 };
3802 let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3803 settings.mip_levels = 2;
3804 settings.mip_scan_dist = 16;
3805 let outcome = render_scene_composed(
3806 &mut fb,
3807 &mut zb,
3808 XRES as usize,
3809 XRES,
3810 YRES,
3811 fog,
3812 &mut scene,
3813 &camera,
3814 &settings,
3815 sky_color,
3816 None,
3817 );
3818 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
3819 let floor_count = fb.iter().filter(|&&p| p == 0x80_77_aa_44).count();
3820 assert!(
3821 floor_count > 50,
3822 "multi-mip cross-chunk look-down should still see chz=1 floor — got {floor_count} floor pixels"
3823 );
3824 }
3825
3826 /// S4B.6.d: 3-chunk-tall stack stresses the widened gylookup
3827 /// (`(chunks_z * 512) >> mip + 4` per mip). Pre-S4B.6.d, gylookup
3828 /// was hardcoded at `(512 >> mip) + 4`, which would OOB or alias
3829 /// for any z > 511. This test renders a floor at world z=562
3830 /// (= chz=2, local z=50) with the camera at world z=540, looking
3831 /// straight down. Multi-mip is on so we exercise the mip slide
3832 /// path in `phase_remiporend` that scales `advance` by chunks_z.
3833 #[test]
3834 fn stacked_three_chunk_z_camera_in_chz2_sees_own_chunk_floor_multi_mip() {
3835 let mut scene = Scene::new();
3836 let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3837 let g = scene.grid_mut(id).unwrap();
3838 // Materialise chz=0 + chz=1 so chunk_xyz_backing enumerates
3839 // the full stack.
3840 g.ensure_chunk(IVec3::new(0, 0, 0));
3841 g.ensure_chunk(IVec3::new(0, 0, 1));
3842 // chz=2: floor at world z=562..566 (= local z=50..54).
3843 g.set_rect(
3844 IVec3::new(60, 60, 562),
3845 IVec3::new(72, 72, 566),
3846 Some(VoxColor(0x80_aa_55_22)),
3847 );
3848 assert!(g.chunk(IVec3::new(0, 0, 2)).is_some());
3849
3850 let (_engine, fog, sky_color) = make_composed_pool(2 * CHUNK_SIZE_XY);
3851 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3852 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3853 let camera = Camera {
3854 pos: [66.0, 66.0, 540.0],
3855 right: [1.0, 0.0, 0.0],
3856 down: [0.0, 1.0, 0.0],
3857 forward: [0.0, 0.0, 1.0],
3858 };
3859 // Multi-mip on to exercise the gylookup-slide path.
3860 let mut settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3861 settings.mip_levels = 2;
3862 settings.mip_scan_dist = 16;
3863 let outcome = render_scene_composed(
3864 &mut fb,
3865 &mut zb,
3866 XRES as usize,
3867 XRES,
3868 YRES,
3869 fog,
3870 &mut scene,
3871 &camera,
3872 &settings,
3873 sky_color,
3874 None,
3875 );
3876 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
3877 let floor_count = fb.iter().filter(|&&p| p == 0x80_aa_55_22).count();
3878 assert!(
3879 floor_count > 100,
3880 "camera at chz=2 with floor in same chunk should see it — got {floor_count} floor pixels"
3881 );
3882 }
3883
3884 // ---- S7.4: render integration with streaming ----
3885
3886 /// Floor-stamping generator for S7.4 render tests. Produces a
3887 /// 10-voxel-thick floor at the bottom of every chunk it
3888 /// generates (chunk-local `z = 230..239`, all xy). Visible as
3889 /// a green stripe along the bottom of the framebuffer when
3890 /// the camera looks +y across populated chunks.
3891 #[derive(Debug)]
3892 struct FloorGenerator;
3893
3894 impl crate::ChunkGenerator for FloorGenerator {
3895 fn generate(&self, _chunk_idx: IVec3) -> roxlap_formats::vxl::Vxl {
3896 // Lean on `Grid::ensure_chunk` for the empty-chunk
3897 // builder, then carve a floor via `set_rect`. Detach
3898 // the chunk from the temporary grid and return it.
3899 let mut tmp = crate::Grid::new(GridTransform::identity());
3900 tmp.ensure_chunk(IVec3::ZERO);
3901 let mut vxl = tmp.chunks.remove(&IVec3::ZERO).unwrap();
3902 #[allow(clippy::cast_possible_wrap)]
3903 roxlap_formats::edit::set_rect(
3904 &mut vxl,
3905 glam::IVec3::new(0, 0, 230).into(),
3906 glam::IVec3::new((CHUNK_SIZE_XY - 1) as i32, (CHUNK_SIZE_XY - 1) as i32, 239)
3907 .into(),
3908 Some(VoxColor(0x80_22_aa_22)),
3909 );
3910 vxl
3911 }
3912 }
3913
3914 #[test]
3915 fn render_scene_composed_unpumped_streaming_grid_renders_all_sky() {
3916 // S7.4(a): a grid with a generator + active stream radius
3917 // but no pump_streaming call has zero chunks. The render
3918 // walks the grid (chunk_xyz_backing returns None for an
3919 // empty chunk map → grid is skipped), framebuffer stays
3920 // sky.
3921 use std::sync::Arc;
3922 let mut scene = Scene::new();
3923 let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3924 let g = scene.grid_mut(id).unwrap();
3925 g.set_generator(Some(Arc::new(FloorGenerator)));
3926 g.stream_radius = crate::StreamRadius::new(300.0, 600.0);
3927 assert!(g.chunks.is_empty(), "no pump yet → no chunks");
3928
3929 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
3930 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3931 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3932 // Camera at (64, -100, 200) looking +y so it would see
3933 // chunks ahead once they exist.
3934 let camera = camera_at([64.0, -100.0, 200.0]);
3935 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3936 let _ = render_scene_composed(
3937 &mut fb,
3938 &mut zb,
3939 XRES as usize,
3940 XRES,
3941 YRES,
3942 fog,
3943 &mut scene,
3944 &camera,
3945 &settings,
3946 sky_color,
3947 None,
3948 );
3949 // Empty grid path skips opticast → framebuffer untouched.
3950 assert!(
3951 fb.iter().all(|&p| p == sky_color),
3952 "unpumped streaming grid must render as all sky"
3953 );
3954 }
3955
3956 #[test]
3957 fn render_scene_composed_picks_up_streamed_chunks_after_sync_pump() {
3958 // S7.4(a): once the streaming pump installs chunks, the
3959 // next render shows them. Using pump_streaming_sync for
3960 // deterministic timing — pump_streaming (async) lands
3961 // the same way modulo a frame of latency.
3962 use std::sync::Arc;
3963 let mut scene = Scene::new();
3964 let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
3965 let g = scene.grid_mut(id).unwrap();
3966 g.set_generator(Some(Arc::new(FloorGenerator)));
3967 // Cover chunks ahead of the camera (y=0, y=128, y=256).
3968 g.stream_radius = crate::StreamRadius::new(300.0, 600.0);
3969
3970 // Render BEFORE pump: zero floor pixels.
3971 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
3972 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
3973 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
3974 let camera = camera_at([64.0, -100.0, 200.0]);
3975 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
3976 let _ = render_scene_composed(
3977 &mut fb,
3978 &mut zb,
3979 XRES as usize,
3980 XRES,
3981 YRES,
3982 fog,
3983 &mut scene,
3984 &camera,
3985 &settings,
3986 sky_color,
3987 None,
3988 );
3989 let pre_floor = fb.iter().filter(|&&p| p == 0x80_22_aa_22).count();
3990 assert_eq!(pre_floor, 0, "pre-pump frame has no streamed chunks");
3991
3992 // Pump synchronously — `world_pos` matches the camera so
3993 // chunks ahead of it (within r_active = 300) stream in.
3994 scene.pump_streaming_sync(DVec3::new(64.0, -100.0, 200.0));
3995 let g = scene.grid(id).unwrap();
3996 assert!(
3997 !g.chunks.is_empty(),
3998 "pump should have streamed at least one chunk"
3999 );
4000
4001 // Render AFTER pump: the floor should now be visible. Reset
4002 // the framebuffer to sky first.
4003 fb.iter_mut().for_each(|p| *p = sky_color);
4004 zb.iter_mut().for_each(|z| *z = f32::INFINITY);
4005 let outcome = render_scene_composed(
4006 &mut fb,
4007 &mut zb,
4008 XRES as usize,
4009 XRES,
4010 YRES,
4011 fog,
4012 &mut scene,
4013 &camera,
4014 &settings,
4015 sky_color,
4016 None,
4017 );
4018 assert_eq!(outcome, RenderOutcome::Rendered { grids_drawn: 1 });
4019 let post_floor = fb.iter().filter(|&&p| p == 0x80_22_aa_22).count();
4020 assert!(
4021 post_floor > 100,
4022 "post-pump frame should show the streamed floor — got {post_floor} green pixels"
4023 );
4024 }
4025
4026 #[test]
4027 fn render_scene_composed_partial_streaming_renders_pending_chunks_as_air() {
4028 // S7.4(a): mixed state — some r_active chunks are
4029 // materialised, others are still pending (not in
4030 // `chunks`). The render must treat pending chunks as
4031 // implicit-air. Verified by stamping one chunk via the
4032 // generator + skipping the others, then confirming the
4033 // framebuffer has fewer floor pixels than the
4034 // fully-pumped baseline.
4035 use std::sync::Arc;
4036 let mut scene = Scene::new();
4037 let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
4038 let g = scene.grid_mut(id).unwrap();
4039 g.set_generator(Some(Arc::new(FloorGenerator)));
4040 // r_active must be set so the later pump_streaming_sync
4041 // sanity-check actually streams more chunks in.
4042 g.stream_radius = crate::StreamRadius::new(400.0, 800.0);
4043
4044 // Materialise ONLY chunk (0, 0, 0) manually via the
4045 // sync helper — leave (0, 1, 0), (0, 2, 0) absent.
4046 let installed = g.ensure_chunk_generated(IVec3::ZERO);
4047 assert!(installed, "manual install of one chunk");
4048 assert_eq!(g.chunks.len(), 1);
4049 // Make sure (0, 1, 0), (0, 2, 0) are NOT present.
4050 assert!(g.chunk(IVec3::new(0, 1, 0)).is_none());
4051 assert!(g.chunk(IVec3::new(0, 2, 0)).is_none());
4052
4053 let (_engine, fog, sky_color) = make_composed_pool(CHUNK_SIZE_XY);
4054 let mut fb = vec![sky_color; pixel_count(XRES, YRES)];
4055 let mut zb = vec![f32::INFINITY; pixel_count(XRES, YRES)];
4056 // Camera inside chunk (0, 0, 0); looking +y means the
4057 // floor of (0, 0, 0) gets rendered until the ray walks
4058 // off the chunk into implicit-air space at y=128. No
4059 // floor pixels past that distance.
4060 let camera = camera_at([64.0, 32.0, 200.0]);
4061 let settings = OpticastSettings::for_oracle_framebuffer(XRES, YRES);
4062 let _ = render_scene_composed(
4063 &mut fb,
4064 &mut zb,
4065 XRES as usize,
4066 XRES,
4067 YRES,
4068 fog,
4069 &mut scene,
4070 &camera,
4071 &settings,
4072 sky_color,
4073 None,
4074 );
4075 let floor_pixels = fb.iter().filter(|&&p| p == 0x80_22_aa_22).count();
4076 // Visible floor inside chunk (0,0,0); pending neighbours
4077 // contribute nothing. The number isn't pinned exactly —
4078 // it just needs to be non-zero (we have content) and
4079 // less than what a fully-streamed scene would produce.
4080 assert!(
4081 floor_pixels > 0,
4082 "should see at least some floor from the loaded chunk"
4083 );
4084 // Sanity: stream the missing chunks; verify the floor
4085 // pixel count goes up.
4086 scene.pump_streaming_sync(DVec3::new(64.0, 32.0, 200.0));
4087 assert!(scene.grid(id).unwrap().chunk_count() >= 2);
4088 fb.iter_mut().for_each(|p| *p = sky_color);
4089 zb.iter_mut().for_each(|z| *z = f32::INFINITY);
4090 let _ = render_scene_composed(
4091 &mut fb,
4092 &mut zb,
4093 XRES as usize,
4094 XRES,
4095 YRES,
4096 fog,
4097 &mut scene,
4098 &camera,
4099 &settings,
4100 sky_color,
4101 None,
4102 );
4103 let floor_pixels_full = fb.iter().filter(|&&p| p == 0x80_22_aa_22).count();
4104 assert!(
4105 floor_pixels_full > floor_pixels,
4106 "fully-streamed scene should show more floor than partial: \
4107 partial={floor_pixels} full={floor_pixels_full}"
4108 );
4109 }
4110}