1use std::collections::HashMap;
36
37use rayon::prelude::*;
38
39use crate::camera_math::{self, CameraState};
40use crate::grid_view::GridView;
41use crate::opticast::OpticastSettings;
42use crate::raster_target::RasterTarget;
43use crate::sky::Sky;
44use crate::Camera;
45use roxlap_formats::material::{material_for_color, Material, MaterialTable};
46use roxlap_formats::Rgb;
47
48#[derive(Clone, Copy)]
55pub struct DdaEnv<'a> {
56 pub sky: Option<&'a Sky>,
59 pub fog_color: u32,
62 pub fog_max_dist: f32,
64 pub side_shades: [i8; 6],
67 pub materials: Option<&'a MaterialTable>,
70 pub terrain_materials: &'a [(Rgb, u8)],
74 pub lights: CpuLights<'a>,
80 pub world_shadow: Option<WorldShadowCtx<'a>>,
85 pub z_clip: Option<i32>,
95 pub cutout: Option<CpuCutout>,
102 pub fow: Option<&'a dyn FowStyler>,
112}
113
114#[derive(Clone, Copy, Debug, PartialEq)]
116pub enum FowVerdict {
117 Hide,
121 Show {
123 dynamic: bool,
127 dim: f32,
130 desaturate: f32,
133 },
134}
135
136impl FowVerdict {
137 pub const LIVE: Self = Self::Show {
140 dynamic: true,
141 dim: 1.0,
142 desaturate: 0.0,
143 };
144}
145
146pub trait FowStyler: Send + Sync {
155 fn verdict(&self, x: i32, y: i32, z: i32) -> FowVerdict;
157}
158
159#[must_use]
163pub fn fow_style(color: u32, dim: f32, desaturate: f32) -> u32 {
164 if dim == 1.0 && desaturate == 0.0 {
165 return color;
166 }
167 let hi = color & 0xff00_0000;
168 let r = ((color >> 16) & 0xff) as f32;
169 let g = ((color >> 8) & 0xff) as f32;
170 let b = (color & 0xff) as f32;
171 let luma = 0.299 * r + 0.587 * g + 0.114 * b;
172 let mix = |c: f32| ((c + (luma - c) * desaturate) * dim).clamp(0.0, 255.0) as u32;
173 hi | (mix(r) << 16) | (mix(g) << 8) | mix(b)
174}
175
176#[derive(Clone, Copy, Debug, PartialEq)]
195pub struct CpuCutout {
196 pub focus_local: [f32; 3],
199 pub tan_outer: f32,
204 pub tan_inner: f32,
209 pub margin: f32,
221 pub focus_z: i32,
229}
230
231#[derive(Clone, Copy)]
233pub struct CpuPointLight {
234 pub pos: [f32; 3],
236 pub color: [f32; 3],
238 pub intensity: f32,
243 pub radius: f32,
245 pub casts_shadow: bool,
250 pub spot_dir: [f32; 3],
253 pub cos_inner: f32,
255 pub cos_outer: f32,
259}
260
261#[derive(Clone, Copy, Default)]
267pub struct CpuLights<'a> {
268 pub enabled: bool,
270 pub sun: bool,
272 pub sun_dir: [f32; 3],
274 pub sun_color: [f32; 3],
276 pub sun_intensity: f32,
279 pub sun_casts_shadow: bool,
281 pub points: &'a [CpuPointLight],
283 pub ambient: [f32; 3],
285 pub bands: u32,
287 pub shadow_tint: [f32; 3],
289 pub shadow_strength: f32,
293 pub shadow_bias: f32,
296 pub shadow_max_dist: f32,
299}
300
301impl Default for DdaEnv<'_> {
302 fn default() -> Self {
303 Self {
304 sky: None,
305 fog_color: 0,
306 fog_max_dist: 0.0,
307 side_shades: [0; 6],
308 materials: None,
309 terrain_materials: &[],
310 lights: CpuLights::default(),
311 world_shadow: None,
312 z_clip: None,
313 cutout: None,
314 fow: None,
315 }
316 }
317}
318
319pub trait PixelSink {
327 fn put(&mut self, idx: usize, color: u32, dist: f32);
331}
332
333pub struct RasterSink<'a> {
340 target: RasterTarget<'a>,
341 len: usize,
342}
343
344impl<'a> RasterSink<'a> {
345 #[must_use]
348 pub fn new(framebuffer: &'a mut [u32], zbuffer: &'a mut [f32]) -> Self {
349 debug_assert_eq!(framebuffer.len(), zbuffer.len());
350 let len = framebuffer.len();
351 Self {
352 target: RasterTarget::new(framebuffer, zbuffer),
353 len,
354 }
355 }
356}
357
358impl PixelSink for RasterSink<'_> {
359 fn put(&mut self, idx: usize, color: u32, dist: f32) {
360 if idx < self.len {
361 unsafe {
364 self.target.write_color(idx, color);
365 self.target.write_depth(idx, dist);
366 }
367 }
368 }
369}
370
371#[derive(Debug, Clone, Copy)]
373struct Hit {
374 color: u32,
375 dist: f32,
376}
377
378#[cfg(test)]
380pub(crate) mod prof {
381 use std::cell::Cell;
382 thread_local! {
383 pub static CELLS: Cell<u64> = const { Cell::new(0) };
384 pub static BRICKS: Cell<u64> = const { Cell::new(0) };
385 pub static SURF: Cell<u64> = const { Cell::new(0) };
386 }
387 pub fn reset() {
388 CELLS.with(|x| x.set(0));
389 BRICKS.with(|x| x.set(0));
390 SURF.with(|x| x.set(0));
391 }
392 pub fn read() -> (u64, u64, u64) {
393 (
394 CELLS.with(Cell::get),
395 BRICKS.with(Cell::get),
396 SURF.with(Cell::get),
397 )
398 }
399}
400
401#[inline]
420pub(crate) fn shade(color: u32, bright_sub: u32) -> u32 {
421 let a = ((color >> 24) & 0xff).saturating_sub(bright_sub);
422 let ch = |shift: u32| -> u32 { ((((color >> shift) & 0xff) * a) >> 7).min(255) };
423 0x8000_0000 | (ch(16) << 16) | (ch(8) << 8) | ch(0)
424}
425
426#[inline]
433pub(crate) fn emissive_shade(color: u32, emissive: u8) -> u32 {
434 let a = 128 + u32::from(emissive >> 1);
435 let ch = |shift: u32| -> u32 { ((((color >> shift) & 0xff) * a) >> 7).min(255) };
436 0x8000_0000 | (ch(16) << 16) | (ch(8) << 8) | ch(0)
437}
438
439#[inline]
441fn cel_band(x: f32, bands: u32) -> f32 {
442 let b = bands as f32;
443 ((x * b).round() / b).clamp(0.0, 1.0)
444}
445
446#[inline]
449fn point_falloff(d: f32, radius: f32) -> f32 {
450 let x = (1.0 - d / radius).clamp(0.0, 1.0);
451 x * x
452}
453
454#[inline]
457fn smoothstep_scalar(edge0: f32, edge1: f32, x: f32) -> f32 {
458 if edge1 <= edge0 {
459 return if x < edge0 { 0.0 } else { 1.0 };
460 }
461 let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
462 t * t * (3.0 - 2.0 * t)
463}
464
465#[inline]
471fn spot_cone(ldir: [f32; 3], axis: [f32; 3], cos_inner: f32, cos_outer: f32) -> f32 {
472 if cos_outer <= -0.999 {
473 return 1.0;
474 }
475 let cd = -dot3(ldir, axis);
476 smoothstep_scalar(cos_outer, cos_inner, cd)
477}
478
479#[inline]
483fn face_normal_cpu(axis: usize, step: [i32; 3]) -> [f32; 3] {
484 let mut n = [0.0f32; 3];
485 if axis < 3 {
486 n[axis] = -(step[axis] as f32);
487 } else {
488 n[2] = -1.0;
489 }
490 n
491}
492
493#[inline]
494fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
495 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
496}
497
498pub(crate) trait ShadowTester {
506 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool;
507}
508
509pub trait WorldOccluder: Sync {
526 fn occluded_world(&self, origin: [f64; 3], dir: [f32; 3], max_t: f32) -> bool;
530}
531
532#[derive(Clone, Copy)]
539pub struct WorldShadowCtx<'a> {
540 pub occluder: &'a dyn WorldOccluder,
543 pub origin: [f64; 3],
549 pub cols: [[f32; 3]; 3],
554 pub voxel_world_size: f32,
560}
561
562impl<'a> WorldShadowCtx<'a> {
563 #[must_use]
566 pub fn identity(occluder: &'a dyn WorldOccluder) -> Self {
567 Self {
568 occluder,
569 origin: [0.0; 3],
570 cols: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
571 voxel_world_size: 1.0,
572 }
573 }
574}
575
576pub struct CompositeOccluder<'a> {
580 pub a: &'a dyn WorldOccluder,
582 pub b: &'a dyn WorldOccluder,
584}
585
586impl WorldOccluder for CompositeOccluder<'_> {
587 fn occluded_world(&self, origin: [f64; 3], dir: [f32; 3], max_t: f32) -> bool {
588 self.a.occluded_world(origin, dir, max_t) || self.b.occluded_world(origin, dir, max_t)
589 }
590}
591
592pub(crate) struct WorldShadow<'a> {
597 pub ctx: WorldShadowCtx<'a>,
598}
599
600impl ShadowTester for WorldShadow<'_> {
601 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
602 let c = &self.ctx.cols;
603 let s = self.ctx.voxel_world_size;
611 let l = [origin[0] * s, origin[1] * s, origin[2] * s];
612 let wo = [
616 self.ctx.origin[0] + f64::from(c[0][0] * l[0] + c[1][0] * l[1] + c[2][0] * l[2]),
617 self.ctx.origin[1] + f64::from(c[0][1] * l[0] + c[1][1] * l[1] + c[2][1] * l[2]),
618 self.ctx.origin[2] + f64::from(c[0][2] * l[0] + c[1][2] * l[1] + c[2][2] * l[2]),
619 ];
620 let wd = [
622 (c[0][0] * dir[0] + c[1][0] * dir[1] + c[2][0] * dir[2]) * s,
623 (c[0][1] * dir[0] + c[1][1] * dir[1] + c[2][1] * dir[2]) * s,
624 (c[0][2] * dir[0] + c[1][2] * dir[1] + c[2][2] * dir[2]) * s,
625 ];
626 self.ctx.occluder.occluded_world(wo, wd, max_t)
627 }
628}
629
630fn shade_lit_cpu(
637 color: u32,
638 bright_sub: u32,
639 axis: usize,
640 step: [i32; 3],
641 cellc: [i32; 3],
642 cell_size: f32,
643 l: &CpuLights<'_>,
644 shadow: Option<&mut dyn ShadowTester>,
645) -> u32 {
646 let a_b = ((color >> 24) & 0xff).saturating_sub(bright_sub);
647 let ao = a_b as f32 / 128.0;
648 let albedo = [
649 ((color >> 16) & 0xff) as f32 / 255.0,
650 ((color >> 8) & 0xff) as f32 / 255.0,
651 (color & 0xff) as f32 / 255.0,
652 ];
653 let n = face_normal_cpu(axis, step);
654 let center = [
656 (cellc[0] as f32 + 0.5) * cell_size,
657 (cellc[1] as f32 + 0.5) * cell_size,
658 (cellc[2] as f32 + 0.5) * cell_size,
659 ];
660 shade_dynamic(albedo, ao, n, center, l, shadow)
661}
662
663pub(crate) fn shade_dynamic(
669 albedo: [f32; 3],
670 ao: f32,
671 n: [f32; 3],
672 sample: [f32; 3],
673 l: &CpuLights<'_>,
674 shadow: Option<&mut dyn ShadowTester>,
675) -> u32 {
676 let styled = l.bands > 0;
677 let mut shadow = shadow;
681 let shadow_origin = [
682 sample[0] + n[0] * l.shadow_bias,
683 sample[1] + n[1] * l.shadow_bias,
684 sample[2] + n[2] * l.shadow_bias,
685 ];
686 let in_shadow = 1.0 - l.shadow_strength;
687
688 let sun_key = if l.sun {
690 let ndl = dot3(n, l.sun_dir).max(0.0);
691 if ndl > 0.0 && l.sun_casts_shadow {
692 let occ = shadow
693 .as_deref_mut()
694 .is_some_and(|s| s.occluded(shadow_origin, l.sun_dir, l.shadow_max_dist));
695 if occ {
696 ndl * in_shadow
697 } else {
698 ndl
699 }
700 } else {
701 ndl
702 }
703 } else {
704 0.0
705 };
706
707 let mut lit = if styled {
709 let key = cel_band(sun_key, l.bands);
710 let m = |i: usize| {
711 let warm = l.sun_color[i] * l.sun_intensity;
712 (l.shadow_tint[i] + (warm - l.shadow_tint[i]) * key) * ao
713 };
714 [albedo[0] * m(0), albedo[1] * m(1), albedo[2] * m(2)]
715 } else {
716 let base = |i: usize| {
717 albedo[i] * l.ambient[i] * ao + albedo[i] * l.sun_color[i] * l.sun_intensity * sun_key
718 };
719 [base(0), base(1), base(2)]
720 };
721
722 for p in l.points {
725 let d3 = [
726 p.pos[0] - sample[0],
727 p.pos[1] - sample[1],
728 p.pos[2] - sample[2],
729 ];
730 let d2 = d3[0] * d3[0] + d3[1] * d3[1] + d3[2] * d3[2];
733 if d2 < p.radius * p.radius && d2 > 1e-8 {
734 let dist = d2.sqrt();
735 let inv = 1.0 / dist;
736 let ldir = [d3[0] * inv, d3[1] * inv, d3[2] * inv];
737 let ndl = dot3(n, ldir).max(0.0);
738 let cone = spot_cone(ldir, p.spot_dir, p.cos_inner, p.cos_outer);
741 if ndl > 0.0 && cone > 0.0 {
742 let sh = if p.casts_shadow
744 && shadow
745 .as_deref_mut()
746 .is_some_and(|s| s.occluded(shadow_origin, ldir, dist))
747 {
748 in_shadow
749 } else {
750 1.0
751 };
752 let mut f = ndl * point_falloff(dist, p.radius) * cone * sh;
753 if styled {
754 f = cel_band(f, l.bands);
755 }
756 for i in 0..3 {
757 lit[i] += albedo[i] * p.color[i] * p.intensity * f;
758 }
759 }
760 }
761 }
762
763 let pack = |v: f32| -> u32 { (v.clamp(0.0, 1.0) * 255.0) as u32 };
764 0x8000_0000 | (pack(lit[0]) << 16) | (pack(lit[1]) << 8) | pack(lit[2])
765}
766
767#[inline]
771fn apply_fog(color: u32, depth: f32, env: &DdaEnv<'_>) -> u32 {
772 if env.fog_max_dist <= 0.0 {
773 return color;
774 }
775 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
776 let f = ((depth / env.fog_max_dist).clamp(0.0, 1.0) * 256.0) as u32; let g = 256 - f;
778 let fog = env.fog_color;
779 let mix = |shift: u32| -> u32 {
780 let src = (color >> shift) & 0xff;
781 let dst = (fog >> shift) & 0xff;
782 ((src * g + dst * f) >> 8).min(255)
783 };
784 0x8000_0000 | (mix(16) << 16) | (mix(8) << 8) | mix(0)
785}
786
787#[inline]
790fn composite_over(accum: [f32; 3], trans: f32, bg: u32) -> u32 {
791 let b = rgb_to_f32(bg);
792 f32_to_rgb([
793 accum[0] + trans * b[0],
794 accum[1] + trans * b[1],
795 accum[2] + trans * b[2],
796 ])
797}
798
799#[inline]
804fn finalize_exit(
805 touched: bool,
806 accum: [f32; 3],
807 trans: f32,
808 env: &DdaEnv<'_>,
809 dir: [f32; 3],
810 dist: f32,
811) -> Option<Hit> {
812 if !touched {
813 return None;
814 }
815 let bg = match env.sky {
816 Some(s) => sample_sky(s, dir),
817 None => 0x8000_0000 | (env.fog_color & 0x00ff_ffff),
818 };
819 Some(Hit {
820 color: composite_over(accum, trans, bg),
821 dist,
822 })
823}
824
825#[inline]
828#[allow(clippy::cast_precision_loss)]
829fn rgb_to_f32(c: u32) -> [f32; 3] {
830 [
831 ((c >> 16) & 0xff) as f32 / 255.0,
832 ((c >> 8) & 0xff) as f32 / 255.0,
833 (c & 0xff) as f32 / 255.0,
834 ]
835}
836
837#[inline]
839#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
840fn f32_to_rgb(c: [f32; 3]) -> u32 {
841 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
842 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
843}
844
845#[allow(
854 clippy::cast_possible_truncation,
855 clippy::cast_sign_loss,
856 clippy::cast_precision_loss
857)]
858fn sample_sky(sky: &Sky, dir: [f32; 3]) -> u32 {
859 let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
860 if len < 1e-9 {
861 return 0x8000_0000;
862 }
863 let d = [dir[0] / len, dir[1] / len, dir[2] / len];
864 let xsiz_full = sky.lat.len().max(1) as i32; let pi = std::f32::consts::PI;
866 let elev01 = (-d[2]).clamp(-1.0, 1.0).acos() / pi; let x = (elev01 * xsiz_full as f32) as i32;
871 let x = x.clamp(0, xsiz_full - 1);
872 let y = if sky.ysiz <= 1 {
874 0
875 } else {
876 let az = d[1].atan2(d[0]); let yf = ((az / (pi * 2.0)) + 0.5) * sky.ysiz as f32;
878 (yf as i32).rem_euclid(sky.ysiz)
879 };
880 let idx = (y * xsiz_full + x) as usize;
881 let px = sky.pixels.get(idx).copied().unwrap_or(0) as u32;
882 0x8000_0000 | (px & 0x00ff_ffff)
883}
884
885#[allow(clippy::cast_possible_truncation)]
895pub fn render_sky_fill(
896 fb: &mut [u32],
897 zb: &[f32],
898 pitch_pixels: usize,
899 width: u32,
900 height: u32,
901 cam: &CameraState,
902 settings: &OpticastSettings,
903 sky: &Sky,
904) {
905 fb.par_chunks_mut(pitch_pixels)
910 .take(height as usize)
911 .enumerate()
912 .for_each(|(py, frow)| {
913 let row = py * pitch_pixels;
914 #[allow(clippy::cast_possible_truncation)]
915 let py = py as u32;
916 for px in 0..width {
917 let idx = row + px as usize;
918 if zb[idx].is_finite() {
919 continue; }
921 let (_origin, dir) = pixel_ray(cam, settings, px, py);
922 frow[px as usize] = sample_sky(sky, dir);
923 }
924 });
925}
926
927#[must_use]
939pub fn pixel_ray(
940 cs: &CameraState,
941 settings: &OpticastSettings,
942 px: u32,
943 py: u32,
944) -> ([f32; 3], [f32; 3]) {
945 #[allow(clippy::cast_precision_loss)]
947 let sx = px as f32 - settings.hx;
948 #[allow(clippy::cast_precision_loss)]
949 let sy = py as f32 - settings.hy;
950 let dir = [
951 sx * cs.right[0] + sy * cs.down[0] + settings.hz * cs.forward[0],
952 sx * cs.right[1] + sy * cs.down[1] + settings.hz * cs.forward[1],
953 sx * cs.right[2] + sy * cs.down[2] + settings.hz * cs.forward[2],
954 ];
955 (cs.pos, dir)
956}
957
958pub(crate) fn intersect_aabb(
964 o: [f32; 3],
965 dir: [f32; 3],
966 lo: [f32; 3],
967 hi: [f32; 3],
968) -> Option<(f32, f32)> {
969 let mut t0 = 0.0f32;
970 let mut t1 = f32::INFINITY;
971 for a in 0..3 {
972 if dir[a].abs() < 1e-9 {
973 if o[a] < lo[a] || o[a] > hi[a] {
975 return None;
976 }
977 } else {
978 let inv = 1.0 / dir[a];
979 let mut ta = (lo[a] - o[a]) * inv;
980 let mut tb = (hi[a] - o[a]) * inv;
981 if ta > tb {
982 core::mem::swap(&mut ta, &mut tb);
983 }
984 t0 = t0.max(ta);
985 t1 = t1.min(tb);
986 if t0 > t1 {
987 return None;
988 }
989 }
990 }
991 Some((t0, t1))
992}
993
994const BRICK: i32 = 8;
996
997#[derive(Debug)]
1011pub(crate) struct BrickMap {
1012 nb: [i32; 3],
1014 bits: Vec<u64>,
1017 ns: [i32; 3],
1020 super_bits: Vec<u64>,
1025}
1026
1027const SUPER: i32 = BRICK * BRICK;
1029
1030impl BrickMap {
1031 #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
1034 fn build(grid: &GridView<'_>, mip: u32) -> Self {
1035 let vsid_m = (grid.vsid >> mip).max(1) as i32;
1036 let z_m = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1) as i32;
1037 let nb = [
1038 (vsid_m + BRICK - 1) / BRICK,
1039 (vsid_m + BRICK - 1) / BRICK,
1040 (z_m + BRICK - 1) / BRICK,
1041 ];
1042 let ns = [
1043 (nb[0] + BRICK - 1) / BRICK,
1044 (nb[1] + BRICK - 1) / BRICK,
1045 (nb[2] + BRICK - 1) / BRICK,
1046 ];
1047 let count = (nb[0] * nb[1] * nb[2]) as usize;
1048 let scount = (ns[0] * ns[1] * ns[2]) as usize;
1049 let mut bits = vec![0u64; count.div_ceil(64)];
1050 let mut super_bits = vec![0u64; scount.div_ceil(64)];
1051 for y in 0..vsid_m {
1052 for x in 0..vsid_m {
1053 let (bx, by) = (x / BRICK, y / BRICK);
1054 grid.for_each_run_mip(x as u32, y as u32, mip, |top, bot| {
1055 for bz in (top / BRICK)..=((bot - 1) / BRICK) {
1056 let idx = ((bz * nb[1] + by) * nb[0] + bx) as usize;
1057 bits[idx / 64] |= 1u64 << (idx % 64);
1058 let sidx =
1059 (((bz / BRICK) * ns[1] + by / BRICK) * ns[0] + bx / BRICK) as usize;
1060 super_bits[sidx / 64] |= 1u64 << (sidx % 64);
1061 }
1062 });
1063 }
1064 }
1065 Self {
1066 nb,
1067 bits,
1068 ns,
1069 super_bits,
1070 }
1071 }
1072
1073 #[inline]
1075 #[allow(clippy::cast_sign_loss)]
1076 fn occupied(&self, b: [i32; 3]) -> bool {
1077 if b[0] < 0
1078 || b[0] >= self.nb[0]
1079 || b[1] < 0
1080 || b[1] >= self.nb[1]
1081 || b[2] < 0
1082 || b[2] >= self.nb[2]
1083 {
1084 return false;
1085 }
1086 let idx = ((b[2] * self.nb[1] + b[1]) * self.nb[0] + b[0]) as usize;
1087 (self.bits[idx / 64] >> (idx % 64)) & 1 != 0
1088 }
1089
1090 #[inline]
1092 #[allow(clippy::cast_sign_loss)]
1093 fn occupied_super(&self, s: [i32; 3]) -> bool {
1094 if s[0] < 0
1095 || s[0] >= self.ns[0]
1096 || s[1] < 0
1097 || s[1] >= self.ns[1]
1098 || s[2] < 0
1099 || s[2] >= self.ns[2]
1100 {
1101 return false;
1102 }
1103 let idx = ((s[2] * self.ns[1] + s[1]) * self.ns[0] + s[0]) as usize;
1104 (self.super_bits[idx / 64] >> (idx % 64)) & 1 != 0
1105 }
1106}
1107
1108pub(crate) fn dda_setup(
1114 origin: [f32; 3],
1115 dir: [f32; 3],
1116 cell: [i32; 3],
1117 cell_size: f32,
1118) -> ([i32; 3], [f32; 3], [f32; 3]) {
1119 let mut step = [0i32; 3];
1120 let mut t_max = [f32::INFINITY; 3];
1121 let mut t_delta = [f32::INFINITY; 3];
1122 for a in 0..3 {
1123 if dir[a] > 1e-9 {
1124 step[a] = 1;
1125 #[allow(clippy::cast_precision_loss)]
1126 let boundary = (cell[a] + 1) as f32 * cell_size;
1127 t_max[a] = (boundary - origin[a]) / dir[a];
1128 t_delta[a] = cell_size / dir[a];
1129 } else if dir[a] < -1e-9 {
1130 step[a] = -1;
1131 #[allow(clippy::cast_precision_loss)]
1132 let boundary = cell[a] as f32 * cell_size;
1133 t_max[a] = (boundary - origin[a]) / dir[a];
1134 t_delta[a] = -cell_size / dir[a];
1135 }
1136 }
1137 (step, t_max, t_delta)
1138}
1139
1140#[inline]
1143pub(crate) fn min_axis(t_max: [f32; 3]) -> usize {
1144 if t_max[0] <= t_max[1] && t_max[0] <= t_max[2] {
1145 0
1146 } else if t_max[1] <= t_max[2] {
1147 1
1148 } else {
1149 2
1150 }
1151}
1152
1153#[derive(Debug, Default)]
1163pub struct BrickCache {
1164 maps: HashMap<(i32, i32, i32, u32), (u64, BrickMap)>,
1165}
1166
1167impl BrickCache {
1168 #[must_use]
1171 pub fn new() -> Self {
1172 Self::default()
1173 }
1174
1175 pub fn ensure(&mut self, chunk: [i32; 3], mip: u32, version: u64, view: &GridView<'_>) {
1178 let key = (chunk[0], chunk[1], chunk[2], mip);
1179 let stale = self.maps.get(&key).is_none_or(|(v, _)| *v != version);
1180 if stale {
1181 self.maps.insert(key, (version, BrickMap::build(view, mip)));
1182 }
1183 }
1184
1185 #[inline]
1186 fn get(&self, chunk: [i32; 3], mip: u32) -> Option<&BrickMap> {
1187 self.maps
1188 .get(&(chunk[0], chunk[1], chunk[2], mip))
1189 .map(|(_, m)| m)
1190 }
1191
1192 pub fn retain_chunks(&mut self, keep: impl Fn([i32; 3]) -> bool) {
1195 self.maps.retain(|k, _| keep([k.0, k.1, k.2]));
1196 }
1197
1198 #[must_use]
1204 pub fn brick_occupied_at(&self, chunk: [i32; 3], mip: u32, cell: [i32; 3]) -> Option<bool> {
1205 self.get(chunk, mip)
1206 .map(|m| m.occupied([cell[0] >> 3, cell[1] >> 3, cell[2] >> 3]))
1207 }
1208
1209 #[must_use]
1212 pub fn super_occupied_at(&self, chunk: [i32; 3], mip: u32, cell: [i32; 3]) -> Option<bool> {
1213 self.get(chunk, mip)
1214 .map(|m| m.occupied_super([cell[0] >> 6, cell[1] >> 6, cell[2] >> 6]))
1215 }
1216}
1217
1218#[allow(clippy::cast_possible_wrap)]
1223fn local_cache(grid: &GridView<'_>, requested_mip: u32) -> (BrickCache, u32) {
1224 let mip = effective_mip(grid, requested_mip);
1225 let mut cache = BrickCache::new();
1226 if let Some(cg) = grid.chunk_grid {
1227 for dz in 0..cg.chunks_z as i32 {
1228 for dy in 0..cg.chunks_y as i32 {
1229 for dx in 0..cg.chunks_x as i32 {
1230 let slot = ((dz * cg.chunks_y as i32 + dy) * cg.chunks_x as i32 + dx) as usize;
1231 if let Some(Some(view)) = cg.chunks.get(slot) {
1232 let ch = [
1233 cg.origin_chunk_xy[0] + dx,
1234 cg.origin_chunk_xy[1] + dy,
1235 cg.origin_chunk_z + dz,
1236 ];
1237 cache.ensure(ch, mip, 0, view);
1238 }
1239 }
1240 }
1241 }
1242 } else {
1243 cache.ensure([0, 0, 0], mip, 0, grid);
1244 }
1245 (cache, mip)
1246}
1247
1248#[must_use]
1253pub fn effective_mip(grid: &GridView<'_>, requested: u32) -> u32 {
1254 if requested == 0 {
1255 return 0;
1256 }
1257 let mut m = requested;
1258 if let Some(cg) = grid.chunk_grid {
1259 for c in cg.chunks.iter().flatten() {
1260 m = m.min(c.mip_count().saturating_sub(1));
1261 }
1262 } else {
1263 m = m.min(grid.mip_count().saturating_sub(1));
1264 }
1265 m
1266}
1267
1268struct Sampler<'a> {
1282 grid: GridView<'a>,
1283 bricks: &'a BrickCache,
1284 mip: u32,
1287 xy_shift: u32,
1296 xy_mask: i32,
1297 z_shift: u32,
1298 z_mask: i32,
1299 z_clip_mip: i32,
1305 cur_ch: [i32; 3],
1306 cur_view: Option<GridView<'a>>,
1307 cur_brick: Option<&'a BrickMap>,
1308 has_cur: bool,
1309}
1310
1311impl<'a> Sampler<'a> {
1312 fn new(grid: GridView<'a>, bricks: &'a BrickCache, mip: u32, z_clip: Option<i32>) -> Self {
1313 let cs_xy = (grid.chunk_size_xy >> mip).max(1);
1314 let cs_z = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1);
1315 debug_assert!(
1316 cs_xy.is_power_of_two() && cs_z.is_power_of_two(),
1317 "chunk dims must be powers of two for the shift/mask split"
1318 );
1319 #[allow(clippy::cast_possible_wrap)]
1320 Self {
1321 grid,
1322 bricks,
1323 mip,
1324 xy_shift: cs_xy.trailing_zeros(),
1325 xy_mask: cs_xy as i32 - 1,
1326 z_shift: cs_z.trailing_zeros(),
1327 z_mask: cs_z as i32 - 1,
1328 z_clip_mip: z_clip.map_or(i32::MIN, |z| z >> mip),
1333 cur_ch: [0; 3],
1334 cur_view: None,
1335 cur_brick: None,
1336 has_cur: false,
1337 }
1338 }
1339
1340 fn select_chunk(&mut self, ch: [i32; 3]) {
1342 if self.has_cur && self.cur_ch == ch {
1343 return;
1344 }
1345 self.cur_view = self.grid.chunk_at_xyz(ch);
1346 self.cur_brick = self.bricks.get(ch, self.mip);
1347 self.cur_ch = ch;
1348 self.has_cur = true;
1349 }
1350
1351 #[allow(clippy::cast_sign_loss)]
1356 fn locate(&self, c: [i32; 3]) -> ([i32; 3], [u32; 3]) {
1357 let ch = [
1358 c[0] >> self.xy_shift,
1359 c[1] >> self.xy_shift,
1360 c[2] >> self.z_shift,
1361 ];
1362 let loc = [
1363 (c[0] & self.xy_mask) as u32,
1364 (c[1] & self.xy_mask) as u32,
1365 (c[2] & self.z_mask) as u32,
1366 ];
1367 (ch, loc)
1368 }
1369
1370 #[allow(clippy::cast_possible_wrap)]
1374 fn hit(&mut self, c: [i32; 3]) -> Option<u32> {
1375 #[cfg(test)]
1376 prof::SURF.with(|x| x.set(x.get() + 1));
1377 if c[2] < self.z_clip_mip {
1384 return None;
1385 }
1386 let (ch, loc) = self.locate(c);
1387 self.select_chunk(ch);
1388 let occupied = self.cur_brick.is_some_and(|bm| {
1389 bm.occupied([
1390 loc[0] as i32 / BRICK,
1391 loc[1] as i32 / BRICK,
1392 loc[2] as i32 / BRICK,
1393 ])
1394 });
1395 if !occupied {
1396 return None;
1397 }
1398 self.cur_view?
1399 .surface_color_mip(loc[0], loc[1], loc[2], self.mip)
1400 .map(|c| c.0)
1401 }
1402
1403 #[inline]
1405 fn cells_per_chunk_xy(&self) -> i32 {
1406 1 << self.xy_shift
1407 }
1408 #[inline]
1409 fn cells_per_chunk_z(&self) -> i32 {
1410 1 << self.z_shift
1411 }
1412
1413 #[allow(clippy::cast_sign_loss)]
1418 fn brick_occupied(&mut self, brick: [i32; 3]) -> bool {
1419 let c0 = [brick[0] << 3, brick[1] << 3, brick[2] << 3];
1421 let ch = [
1422 c0[0] >> self.xy_shift,
1423 c0[1] >> self.xy_shift,
1424 c0[2] >> self.z_shift,
1425 ];
1426 self.select_chunk(ch);
1427 self.cur_brick.is_some_and(|bm| {
1428 bm.occupied([
1429 (c0[0] & self.xy_mask) >> 3,
1430 (c0[1] & self.xy_mask) >> 3,
1431 (c0[2] & self.z_mask) >> 3,
1432 ])
1433 })
1434 }
1435
1436 #[allow(clippy::cast_sign_loss)]
1441 fn super_occupied(&mut self, s: [i32; 3]) -> bool {
1442 let c0 = [s[0] << 6, s[1] << 6, s[2] << 6];
1444 let ch = [
1445 c0[0] >> self.xy_shift,
1446 c0[1] >> self.xy_shift,
1447 c0[2] >> self.z_shift,
1448 ];
1449 self.select_chunk(ch);
1450 self.cur_brick.is_some_and(|bm| {
1451 bm.occupied_super([
1452 (c0[0] & self.xy_mask) >> 6,
1453 (c0[1] & self.xy_mask) >> 6,
1454 (c0[2] & self.z_mask) >> 6,
1455 ])
1456 })
1457 }
1458}
1459
1460const SHADOW_MAX_STEPS: u32 = 1024;
1464
1465struct SamplerShadow<'s, 'a, 'f> {
1472 sampler: &'s mut Sampler<'a>,
1473 cell_size: f32,
1474 lo_c: [i32; 3],
1475 hi_c: [i32; 3],
1476 fow: Option<&'f dyn FowStyler>,
1481}
1482
1483impl ShadowTester for SamplerShadow<'_, '_, '_> {
1484 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
1485 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
1486 let cs = self.cell_size;
1487 let has_super =
1495 self.sampler.cells_per_chunk_xy() >= SUPER && self.sampler.cells_per_chunk_z() >= SUPER;
1496 let has_brick =
1497 self.sampler.cells_per_chunk_xy() >= BRICK && self.sampler.cells_per_chunk_z() >= BRICK;
1498 let mut cellc = [
1499 (origin[0] / cs).floor() as i32,
1500 (origin[1] / cs).floor() as i32,
1501 (origin[2] / cs).floor() as i32,
1502 ];
1503 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cs);
1504 let inv = [
1505 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
1506 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
1507 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
1508 ];
1509 let mut t_curr = 0.0f32;
1510 let mut used = 0u32;
1511 while used < SHADOW_MAX_STEPS {
1512 if cellc[0] < self.lo_c[0]
1513 || cellc[0] >= self.hi_c[0]
1514 || cellc[1] < self.lo_c[1]
1515 || cellc[1] >= self.hi_c[1]
1516 || cellc[2] < self.lo_c[2]
1517 || cellc[2] >= self.hi_c[2]
1518 {
1519 return false; }
1521 if t_curr > max_t {
1522 return false; }
1524 let skip_shift = if has_super
1528 && !self
1529 .sampler
1530 .super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
1531 {
1532 Some(6u32)
1533 } else if has_brick
1534 && !self
1535 .sampler
1536 .brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
1537 {
1538 Some(3u32)
1539 } else {
1540 None
1541 };
1542 if let Some(sh) = skip_shift {
1543 let mut best_t = f32::INFINITY;
1544 let mut best_axis = 3usize;
1545 let mut plane = [0i32; 3];
1546 for a in 0..3 {
1547 if step[a] == 0 {
1548 continue;
1549 }
1550 let idx = cellc[a] >> sh;
1551 plane[a] = if step[a] > 0 {
1552 (idx + 1) << sh
1553 } else {
1554 idx << sh
1555 };
1556 let tb = (plane[a] as f32 * cs - origin[a]) * inv[a];
1557 if tb < best_t {
1558 best_t = tb;
1559 best_axis = a;
1560 }
1561 }
1562 if best_axis == 3 {
1563 return false;
1564 }
1565 let mut nc = cellc;
1570 for a in 0..3 {
1571 if a == best_axis || step[a] == 0 || t_max[a] > best_t {
1572 continue;
1573 }
1574 #[allow(clippy::cast_possible_truncation)]
1575 let k = ((best_t - t_max[a]) / t_delta[a]) as i32 + 1;
1576 nc[a] += k * step[a];
1577 t_max[a] += k as f32 * t_delta[a];
1578 }
1579 nc[best_axis] = if step[best_axis] > 0 {
1580 plane[best_axis]
1581 } else {
1582 plane[best_axis] - 1
1583 };
1584 t_max[best_axis] = best_t + t_delta[best_axis];
1585 let crossed =
1590 cellc[0].abs_diff(nc[0]) + cellc[1].abs_diff(nc[1]) + cellc[2].abs_diff(nc[2]);
1591 if used.saturating_add(crossed) >= SHADOW_MAX_STEPS {
1592 return false;
1593 }
1594 used += crossed;
1595 cellc = nc;
1596 t_curr = best_t.max(t_curr);
1597 continue;
1598 }
1599 if self.sampler.hit(cellc).is_some() && !self.fow_hidden(cellc) {
1600 return true; }
1602 let axis = min_axis(t_max);
1603 t_curr = t_max[axis];
1604 cellc[axis] += step[axis];
1605 t_max[axis] += t_delta[axis];
1606 used += 1;
1607 }
1608 false
1609 }
1610}
1611
1612impl SamplerShadow<'_, '_, '_> {
1613 #[inline]
1618 fn fow_hidden(&self, cellc: [i32; 3]) -> bool {
1619 let Some(styler) = self.fow else {
1620 return false;
1621 };
1622 let mip = self.sampler.mip;
1623 let half = (1i32 << mip) >> 1;
1624 matches!(
1625 styler.verdict(
1626 (cellc[0] << mip) + half,
1627 (cellc[1] << mip) + half,
1628 (cellc[2] << mip) + half,
1629 ),
1630 FowVerdict::Hide
1631 )
1632 }
1633}
1634
1635#[allow(
1656 clippy::too_many_arguments,
1657 clippy::cast_possible_truncation,
1658 clippy::cast_sign_loss,
1659 clippy::cast_precision_loss
1660)]
1661fn cell_walk_skip(
1662 origin: [f32; 3],
1663 dir: [f32; 3],
1664 fwd_dot: f32,
1665 sampler: &mut Sampler<'_>,
1666 lo_c: [i32; 3],
1667 hi_c: [i32; 3],
1668 cell_size: f32,
1669 t_enter: f32,
1670 t_exit: f32,
1671 max_dist: f32,
1672 env: &DdaEnv<'_>,
1673) -> Option<Hit> {
1674 let has_super = sampler.cells_per_chunk_xy() >= SUPER && sampler.cells_per_chunk_z() >= SUPER;
1675 let has_brick = sampler.cells_per_chunk_xy() >= BRICK && sampler.cells_per_chunk_z() >= BRICK;
1676 let cut_z_mip = env.cutout.map_or(i32::MIN, |c| c.focus_z >> sampler.mip);
1682 let fow_mip_shift = sampler.mip;
1689 let fow_mip_half = (1i32 << fow_mip_shift) >> 1;
1690 let cut_axis = env.cutout.map_or([0.0; 3], |c| {
1695 let d = [
1696 c.focus_local[0] - origin[0],
1697 c.focus_local[1] - origin[1],
1698 c.focus_local[2] - origin[2],
1699 ];
1700 let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
1701 if len > 1e-6 {
1702 [d[0] / len, d[1] / len, d[2] / len]
1703 } else {
1704 [0.0; 3]
1705 }
1706 });
1707
1708 let start = t_enter + 1e-4;
1709 let p = [
1710 origin[0] + dir[0] * start,
1711 origin[1] + dir[1] * start,
1712 origin[2] + dir[2] * start,
1713 ];
1714 let mut cellc = [
1715 ((p[0] / cell_size).floor() as i32).clamp(lo_c[0], hi_c[0] - 1),
1716 ((p[1] / cell_size).floor() as i32).clamp(lo_c[1], hi_c[1] - 1),
1717 ((p[2] / cell_size).floor() as i32).clamp(lo_c[2], hi_c[2] - 1),
1718 ];
1719 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cell_size);
1720 let inv = [
1724 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
1725 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
1726 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
1727 ];
1728 let mut t_curr = t_enter;
1729 let mut last_axis = 3usize;
1730 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
1733 let shadow_casts = env.lights.enabled
1737 && env.lights.shadow_strength > 0.0
1738 && (env.lights.sun_casts_shadow || env.lights.points.iter().any(|p| p.casts_shadow));
1739
1740 let mut accum = [0.0f32; 3];
1745 let mut trans = 1.0f32;
1746 let mut touched = false;
1747 let mut prev_solid = false;
1748 let mut prev_mat = 0u8;
1749
1750 let span = (hi_c[0] - lo_c[0]) + (hi_c[1] - lo_c[1]) + (hi_c[2] - lo_c[2]);
1753 let max_steps = span.max(0) as usize + 16;
1754 for _ in 0..max_steps {
1755 if cellc[0] < lo_c[0]
1756 || cellc[0] >= hi_c[0]
1757 || cellc[1] < lo_c[1]
1758 || cellc[1] >= hi_c[1]
1759 || cellc[2] < lo_c[2]
1760 || cellc[2] >= hi_c[2]
1761 {
1762 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1763 }
1764 let depth = t_curr * fwd_dot;
1765 if depth > max_dist || t_curr > t_exit {
1766 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1767 }
1768 if env.fog_max_dist > 0.0 && depth >= env.fog_max_dist {
1774 let fog = 0x8000_0000 | (env.fog_color & 0x00ff_ffff);
1775 let color = if touched {
1776 composite_over(accum, trans, fog)
1777 } else {
1778 fog
1779 };
1780 return Some(Hit {
1781 color,
1782 dist: env.fog_max_dist,
1783 });
1784 }
1785
1786 let skip_shift = if has_super
1789 && !sampler.super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
1790 {
1791 Some(6u32)
1792 } else if has_brick
1793 && !sampler.brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
1794 {
1795 Some(3u32)
1796 } else {
1797 None
1798 };
1799 if let Some(sh) = skip_shift {
1800 #[cfg(test)]
1801 prof::BRICKS.with(|x| x.set(x.get() + 1));
1802 let mut best_t = f32::INFINITY;
1804 let mut best_axis = 3usize;
1805 let mut plane = [0i32; 3];
1806 for a in 0..3 {
1807 if step[a] == 0 {
1808 continue;
1809 }
1810 let idx = cellc[a] >> sh;
1811 plane[a] = if step[a] > 0 {
1812 (idx + 1) << sh
1813 } else {
1814 idx << sh
1815 };
1816 let tb = (plane[a] as f32 * cell_size - origin[a]) * inv[a];
1817 if tb < best_t {
1818 best_t = tb;
1819 best_axis = a;
1820 }
1821 }
1822 if best_axis == 3 {
1823 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1824 }
1825 let mut nc = cellc;
1836 for a in 0..3 {
1837 if a == best_axis || step[a] == 0 || t_max[a] > best_t {
1838 continue;
1839 }
1840 #[allow(clippy::cast_possible_truncation)]
1843 let k = ((best_t - t_max[a]) / t_delta[a]) as i32 + 1;
1844 nc[a] += k * step[a];
1845 t_max[a] += k as f32 * t_delta[a];
1846 }
1847 nc[best_axis] = if step[best_axis] > 0 {
1848 plane[best_axis]
1849 } else {
1850 plane[best_axis] - 1
1851 };
1852 t_max[best_axis] = best_t + t_delta[best_axis];
1853 if nc[0] < lo_c[0]
1857 || nc[0] >= hi_c[0]
1858 || nc[1] < lo_c[1]
1859 || nc[1] >= hi_c[1]
1860 || nc[2] < lo_c[2]
1861 || nc[2] >= hi_c[2]
1862 {
1863 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1864 }
1865 cellc = nc;
1866 t_curr = best_t.max(t_curr);
1871 last_axis = best_axis;
1872 prev_solid = false; continue;
1874 }
1875
1876 #[cfg(test)]
1878 prof::CELLS.with(|x| x.set(x.get() + 1));
1879 let mut fow_verdict = FowVerdict::LIVE;
1901 let cell_hit = sampler.hit(cellc).filter(|_| {
1902 let keyhole_hides = cellc[2] < cut_z_mip
1903 && env.cutout.is_some_and(|c| {
1904 #[allow(clippy::cast_precision_loss)]
1905 let pc = [
1906 (cellc[0] as f32 + 0.5) * cell_size,
1907 (cellc[1] as f32 + 0.5) * cell_size,
1908 (cellc[2] as f32 + 0.5) * cell_size,
1909 ];
1910 let d = [pc[0] - origin[0], pc[1] - origin[1], pc[2] - origin[2]];
1911 let along = d[0] * cut_axis[0] + d[1] * cut_axis[1] + d[2] * cut_axis[2];
1912 if along <= 0.0 {
1913 return false; }
1915 let d2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
1916 let tan_c = ((d2 - along * along).max(0.0)).sqrt() / along;
1917 let s = ((c.tan_outer - tan_c)
1922 / (c.tan_outer - c.tan_inner).max(f32::MIN_POSITIVE))
1923 .clamp(0.0, 1.0);
1924 #[allow(clippy::cast_precision_loss)]
1929 let plane = c.focus_z as f32;
1930 let top = 2.0 * c.focus_local[2] - plane;
1931 let rz = pc[2].clamp(top.min(plane), top.max(plane));
1932 let dr = [
1933 c.focus_local[0] - origin[0],
1934 c.focus_local[1] - origin[1],
1935 rz - origin[2],
1936 ];
1937 let ref_dist = (dr[0] * dr[0] + dr[1] * dr[1] + dr[2] * dr[2]).sqrt();
1938 d2.sqrt() < (ref_dist - c.margin).max(0.0) * s
1939 });
1940 if keyhole_hides {
1941 return false;
1942 }
1943 if let Some(styler) = env.fow {
1944 let v = styler.verdict(
1945 (cellc[0] << fow_mip_shift) + fow_mip_half,
1946 (cellc[1] << fow_mip_shift) + fow_mip_half,
1947 (cellc[2] << fow_mip_shift) + fow_mip_half,
1948 );
1949 fow_verdict = v;
1950 if matches!(v, FowVerdict::Hide) {
1951 return false;
1952 }
1953 }
1954 true
1955 });
1956 if let Some(color) = cell_hit {
1957 let bright_sub = side_shade_sub(env, last_axis, step);
1958 let (m, mat_id) = match env.materials {
1964 Some(table) if !env.terrain_materials.is_empty() => {
1965 let id = material_for_color(env.terrain_materials, color);
1966 (table.get(id), id)
1967 }
1968 _ => (Material::OPAQUE, 0),
1969 };
1970 let fow_dynamic = match fow_verdict {
1986 FowVerdict::Show { dynamic, .. } => dynamic,
1987 FowVerdict::Hide => true, };
1989 let shaded = if m.emissive > 0 {
1990 emissive_shade(color, m.emissive)
1991 } else if fow_dynamic && env.lights.enabled {
1992 let casts = shadow_casts;
1993 let mut world_sh;
1999 let mut sampler_sh;
2000 let tester: Option<&mut dyn ShadowTester> = if !casts {
2001 None
2002 } else if let Some(ctx) = env.world_shadow {
2003 world_sh = WorldShadow { ctx };
2004 Some(&mut world_sh)
2005 } else {
2006 sampler_sh = SamplerShadow {
2007 sampler: &mut *sampler,
2008 cell_size,
2009 lo_c,
2010 hi_c,
2011 fow: env.fow,
2012 };
2013 Some(&mut sampler_sh)
2014 };
2015 shade_lit_cpu(
2016 color,
2017 bright_sub,
2018 last_axis,
2019 step,
2020 cellc,
2021 cell_size,
2022 &env.lights,
2023 tester,
2024 )
2025 } else {
2026 shade(color, bright_sub)
2027 };
2028 let styled = match fow_verdict {
2032 FowVerdict::Show {
2033 dim, desaturate, ..
2034 } => fow_style(shaded, dim, desaturate),
2035 FowVerdict::Hide => shaded,
2036 };
2037 let lit = apply_fog(styled, depth.max(0.0), env);
2038 if m.is_opaque() {
2039 let color = if touched {
2043 composite_over(accum, trans, lit)
2044 } else {
2045 lit
2046 };
2047 return Some(Hit {
2048 color,
2049 dist: depth.max(0.0),
2050 });
2051 }
2052 let a = f32::from(m.alpha) / 255.0;
2053 if matches!(m.mode, roxlap_formats::material::BlendMode::Volumetric) {
2054 let t_exit = t_max[min_axis(t_max)];
2058 let seg_len = (t_exit - t_curr).max(0.0) * dir_len / cell_size;
2059 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
2060 let c = rgb_to_f32(lit);
2061 accum[0] += trans * eff_a * c[0];
2062 accum[1] += trans * eff_a * c[1];
2063 accum[2] += trans * eff_a * c[2];
2064 trans *= 1.0 - eff_a;
2065 touched = true;
2066 prev_mat = mat_id;
2067 if trans < 1.0 / 256.0 {
2068 return Some(Hit {
2069 color: f32_to_rgb(accum),
2070 dist: depth.max(0.0),
2071 });
2072 }
2073 } else if !prev_solid || mat_id != prev_mat {
2074 let c = rgb_to_f32(lit);
2078 accum[0] += trans * a * c[0];
2079 accum[1] += trans * a * c[1];
2080 accum[2] += trans * a * c[2];
2081 if !matches!(m.mode, roxlap_formats::material::BlendMode::Additive) {
2082 trans *= 1.0 - a; }
2084 touched = true;
2085 prev_mat = mat_id;
2086 if trans < 1.0 / 256.0 {
2087 return Some(Hit {
2088 color: f32_to_rgb(accum),
2089 dist: depth.max(0.0),
2090 });
2091 }
2092 }
2093 prev_solid = true;
2094 } else {
2095 prev_solid = false;
2096 }
2097 let axis = min_axis(t_max);
2098 last_axis = axis;
2099 t_curr = t_max[axis];
2100 cellc[axis] += step[axis];
2101 t_max[axis] += t_delta[axis];
2102 }
2103 None
2104}
2105
2106#[inline]
2112fn side_shade_sub(env: &DdaEnv<'_>, axis: usize, step: [i32; 3]) -> u32 {
2113 if axis >= 3 {
2114 return 0;
2115 }
2116 let face = axis * 2 + usize::from(step[axis] < 0);
2117 env.side_shades[face].max(0) as u32
2118}
2119
2120fn cast_ray(
2129 origin: [f32; 3],
2130 dir: [f32; 3],
2131 forward: [f32; 3],
2132 sampler: &mut Sampler<'_>,
2133 settings: &OpticastSettings,
2134 env: &DdaEnv<'_>,
2135) -> Option<Hit> {
2136 let (lo_i, hi_i) = sampler.grid.voxel_bounds();
2137 #[allow(clippy::cast_precision_loss)]
2138 let lo_f = [lo_i[0] as f32, lo_i[1] as f32, lo_i[2] as f32];
2139 #[allow(clippy::cast_precision_loss)]
2140 let hi_f = [hi_i[0] as f32, hi_i[1] as f32, hi_i[2] as f32];
2141 let (t_enter, t_exit) = intersect_aabb(origin, dir, lo_f, hi_f)?;
2142 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
2143 #[allow(clippy::cast_precision_loss)]
2144 let max_dist = settings.max_scan_dist.max(1) as f32;
2145 let cell = 1i32 << sampler.mip;
2146 let cell_size = cell as f32;
2147 let lo_c = [
2148 lo_i[0].div_euclid(cell),
2149 lo_i[1].div_euclid(cell),
2150 lo_i[2].div_euclid(cell),
2151 ];
2152 let hi_c = [
2153 hi_i[0].div_euclid(cell),
2154 hi_i[1].div_euclid(cell),
2155 hi_i[2].div_euclid(cell),
2156 ];
2157 cell_walk_skip(
2158 origin, dir, fwd_dot, sampler, lo_c, hi_c, cell_size, t_enter, t_exit, max_dist, env,
2159 )
2160}
2161
2162pub fn render_dda(
2175 camera: &Camera,
2176 settings: &OpticastSettings,
2177 grid: GridView<'_>,
2178 pitch_pixels: usize,
2179 env: &DdaEnv<'_>,
2180 mip: u32,
2181 sink: &mut impl PixelSink,
2182) {
2183 let cs = camera_math::derive(
2184 camera,
2185 settings.xres,
2186 settings.yres,
2187 settings.hx,
2188 settings.hy,
2189 settings.hz,
2190 );
2191
2192 let (cache, mip) = local_cache(&grid, mip);
2195 let mut sampler = Sampler::new(grid, &cache, mip, env.z_clip);
2196
2197 for py in settings.y_start..settings.y_end {
2198 let row = py as usize * pitch_pixels;
2199 for px in settings.x_start..settings.x_end {
2200 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py) {
2201 sink.put(row + px as usize, color, dist);
2202 }
2203 }
2204 }
2205}
2206
2207#[inline]
2214fn pixel_result(
2215 cs: &CameraState,
2216 settings: &OpticastSettings,
2217 sampler: &mut Sampler<'_>,
2218 env: &DdaEnv<'_>,
2219 px: u32,
2220 py: u32,
2221) -> Option<(u32, f32)> {
2222 let (origin, dir) = pixel_ray(cs, settings, px, py);
2223 if let Some(hit) = cast_ray(origin, dir, cs.forward, sampler, settings, env) {
2224 Some((hit.color, hit.dist))
2225 } else {
2226 env.sky.map(|sky| (sample_sky(sky, dir), f32::INFINITY))
2227 }
2228}
2229
2230#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
2245pub fn render_dda_parallel(
2246 camera: &Camera,
2247 settings: &OpticastSettings,
2248 grid: GridView<'_>,
2249 fb: &mut [u32],
2250 zb: &mut [f32],
2251 pitch_pixels: usize,
2252 env: &DdaEnv<'_>,
2253 cache: &BrickCache,
2254 mip: u32,
2255) {
2256 debug_assert_eq!(fb.len(), zb.len());
2257 let (y0, y1) = (settings.y_start, settings.y_end);
2258 if y1 <= y0 {
2259 return;
2260 }
2261 let cs = camera_math::derive(
2262 camera,
2263 settings.xres,
2264 settings.yres,
2265 settings.hx,
2266 settings.hy,
2267 settings.hz,
2268 );
2269 let target = RasterTarget::new(fb, zb);
2270
2271 let band = 8u32;
2278 let bands: Vec<(u32, u32)> = (y0..y1)
2279 .step_by(band as usize)
2280 .map(|s| (s, (s + band).min(y1)))
2281 .collect();
2282
2283 bands.par_iter().for_each(|&(by0, by1)| {
2284 let mut sampler = Sampler::new(grid, cache, mip, env.z_clip);
2285 for py in by0..by1 {
2286 let row = py as usize * pitch_pixels;
2287 for px in settings.x_start..settings.x_end {
2288 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py)
2289 {
2290 let idx = row + px as usize;
2291 unsafe {
2295 target.write_color(idx, color);
2296 target.write_depth(idx, dist);
2297 }
2298 }
2299 }
2300 }
2301 });
2302}
2303
2304#[cfg(test)]
2310#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
2311fn cast_ray_reference(
2312 origin: [f32; 3],
2313 dir: [f32; 3],
2314 forward: [f32; 3],
2315 grid: &GridView<'_>,
2316 settings: &OpticastSettings,
2317) -> Option<Hit> {
2318 let nx = grid.vsid as f32;
2319 let nz = f32::from(u16::try_from(crate::grid_view::CHUNK_SIZE_Z).unwrap_or(256));
2320 #[allow(clippy::cast_possible_wrap)]
2321 let n_i = [
2322 grid.vsid as i32,
2323 grid.vsid as i32,
2324 crate::grid_view::CHUNK_SIZE_Z as i32,
2325 ];
2326 let (t_enter, t_exit) = intersect_aabb(origin, dir, [0.0; 3], [nx, nx, nz])?;
2327 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
2328 let max_dist = settings.max_scan_dist.max(1) as f32;
2329
2330 let start = t_enter + 1e-4;
2331 let p = [
2332 origin[0] + dir[0] * start,
2333 origin[1] + dir[1] * start,
2334 origin[2] + dir[2] * start,
2335 ];
2336 let mut voxel = [
2337 (p[0].floor() as i32).clamp(0, n_i[0] - 1),
2338 (p[1].floor() as i32).clamp(0, n_i[1] - 1),
2339 (p[2].floor() as i32).clamp(0, n_i[2] - 1),
2340 ];
2341 let (step, mut t_max, t_delta) = dda_setup(origin, dir, voxel, 1.0);
2342 let mut t_curr = t_enter;
2343 let max_steps = (n_i[0] + n_i[1] + n_i[2]) as usize + 8;
2344 for _ in 0..max_steps {
2345 if voxel[0] < 0
2346 || voxel[0] >= n_i[0]
2347 || voxel[1] < 0
2348 || voxel[1] >= n_i[1]
2349 || voxel[2] < 0
2350 || voxel[2] >= n_i[2]
2351 {
2352 return None;
2353 }
2354 let depth = t_curr * fwd_dot;
2355 if depth > max_dist || t_curr > t_exit {
2356 return None;
2357 }
2358 #[allow(clippy::cast_sign_loss)]
2359 if let Some(color) = grid.surface_color(voxel[0] as u32, voxel[1] as u32, voxel[2] as u32) {
2360 return Some(Hit {
2361 color: shade(color.0, 0),
2362 dist: depth.max(0.0),
2363 });
2364 }
2365 let axis = min_axis(t_max);
2366 t_curr = t_max[axis];
2367 voxel[axis] += step[axis];
2368 t_max[axis] += t_delta[axis];
2369 }
2370 None
2371}
2372
2373#[cfg(test)]
2374mod tests {
2375 use super::*;
2376 use roxlap_formats::VoxColor;
2377
2378 fn lum(p: u32) -> u32 {
2380 (p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)
2381 }
2382
2383 #[test]
2384 fn cel_band_quantizes_and_collapses() {
2385 assert_eq!(cel_band(0.8, 2), cel_band(0.9, 2));
2387 assert!((cel_band(0.8, 2) - 1.0).abs() < 1e-6);
2388 assert_ne!(cel_band(0.3, 2), cel_band(0.8, 2));
2390 }
2391
2392 #[test]
2393 fn shade_lit_cpu_sun_lights_by_facing() {
2394 let color = 0x80_80_80_80;
2397 let step = [0, 0, 1];
2398 let base = CpuLights {
2399 enabled: true,
2400 sun: true,
2401 sun_color: [1.0; 3],
2402 sun_intensity: 1.0,
2403 ambient: [0.2; 3],
2404 ..CpuLights::default()
2405 };
2406 let facing = CpuLights {
2407 sun_dir: [0.0, 0.0, -1.0],
2408 ..base
2409 }; let back = CpuLights {
2411 sun_dir: [0.0, 0.0, 1.0],
2412 ..base
2413 }; let lit = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &facing, None);
2415 let dark = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &back, None);
2416 assert!(
2417 lum(lit) > lum(dark),
2418 "sun facing the surface must brighten it: {lit:#08x} vs {dark:#08x}",
2419 );
2420 }
2421
2422 #[test]
2423 fn shade_dynamic_spot_cone_masks_off_axis() {
2424 let albedo = [0.5, 0.5, 0.5];
2427 let n = [0.0, 0.0, -1.0];
2428 let sample = [0.0, 0.0, 0.0];
2429 let inner = 10.0f32.to_radians().cos();
2430 let outer = 15.0f32.to_radians().cos();
2431 let shade = |spot_dir: [f32; 3], cos_inner: f32, cos_outer: f32| {
2432 let pts = [CpuPointLight {
2433 pos: [0.0, 0.0, -10.0],
2434 color: [1.0; 3],
2435 intensity: 1.0,
2436 radius: 64.0,
2437 casts_shadow: false,
2438 spot_dir,
2439 cos_inner,
2440 cos_outer,
2441 }];
2442 let l = CpuLights {
2443 enabled: true,
2444 ambient: [0.0; 3],
2445 points: &pts,
2446 ..CpuLights::default()
2447 };
2448 shade_dynamic(albedo, 0.0, n, sample, &l, None)
2449 };
2450 let point = shade([0.0, 0.0, 1.0], -1.0, -1.0);
2452 let on_axis = shade([0.0, 0.0, 1.0], inner, outer);
2454 let off_axis = shade([1.0, 0.0, 0.0], inner, outer);
2456
2457 assert_eq!(
2459 on_axis, point,
2460 "on-axis spot must equal the point light: {on_axis:#08x} vs {point:#08x}",
2461 );
2462 assert!(
2464 lum(on_axis) > lum(off_axis),
2465 "off-axis spot must be darker: {on_axis:#08x} vs {off_axis:#08x}",
2466 );
2467 assert_eq!(lum(off_axis), 0, "off-cone spot contributes nothing");
2468 }
2469
2470 #[test]
2471 fn shade_lit_cpu_cel_terraces_sun() {
2472 let color = 0x80_80_80_80;
2475 let step = [0, 0, 1];
2476 let mk = |zc: f32, bands: u32| {
2477 let n = (1.0f32 - zc * zc).sqrt();
2478 CpuLights {
2479 enabled: true,
2480 sun: true,
2481 sun_dir: [n, 0.0, -zc], sun_color: [1.0; 3],
2483 sun_intensity: 1.0,
2484 ambient: [0.1; 3],
2485 bands,
2486 ..CpuLights::default()
2487 }
2488 };
2489 let smooth_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 0), None);
2490 let smooth_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 0), None);
2491 assert_ne!(smooth_a, smooth_b, "smooth diffuse must vary with N·L");
2492 let cel_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 2), None);
2493 let cel_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 2), None);
2494 assert_eq!(
2495 cel_a, cel_b,
2496 "cel banding must terrace both N·L to one level"
2497 );
2498 }
2499
2500 #[test]
2504 fn shade_dynamic_sun_shadow_darkens() {
2505 struct Mock(bool);
2506 impl ShadowTester for Mock {
2507 fn occluded(&mut self, _: [f32; 3], _: [f32; 3], _: f32) -> bool {
2508 self.0
2509 }
2510 }
2511 let l = CpuLights {
2512 enabled: true,
2513 sun: true,
2514 sun_dir: [0.0, 0.0, -1.0], sun_color: [1.0; 3],
2516 sun_intensity: 1.0,
2517 sun_casts_shadow: true,
2518 ambient: [0.2; 3],
2519 shadow_strength: 0.7,
2520 shadow_bias: 1.5,
2521 shadow_max_dist: 64.0,
2522 ..CpuLights::default()
2523 };
2524 let albedo = [0.8; 3];
2525 let n = [0.0, 0.0, -1.0]; let s = [0.5, 0.5, 0.5];
2527 let lit = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(false)));
2528 let shadowed = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(true)));
2529 assert!(
2530 lum(shadowed) < lum(lit),
2531 "an occluded sun face must darken: shadowed={shadowed:#08x} lit={lit:#08x}",
2532 );
2533 let l0 = CpuLights {
2535 shadow_strength: 0.0,
2536 ..l
2537 };
2538 assert_eq!(
2539 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(true))),
2540 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(false))),
2541 "shadow_strength 0 ⇒ shadows invisible",
2542 );
2543 }
2544
2545 #[test]
2551 fn sampler_shadow_march_casts_sun_shadow() {
2552 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, _y, z| {
2554 if z >= 60 {
2555 Some(VoxColor(0x80_80_80_80)) } else if x == 32 && (30..60).contains(&z) {
2557 Some(VoxColor(0x80_70_70_70)) } else {
2559 None
2560 }
2561 });
2562 let grid = GridView::from_single_vxl(&vxl);
2563 let cam = Camera {
2565 pos: [32.0, 32.0, 6.0],
2566 right: [1.0, 0.0, 0.0],
2567 down: [0.0, 1.0, 0.0],
2568 forward: [0.0, 0.0, 1.0],
2569 };
2570 let inv = 1.0f32 / 2.0f32.sqrt();
2572 let base = CpuLights {
2573 enabled: true,
2574 sun: true,
2575 sun_dir: [inv, 0.0, -inv],
2576 sun_color: [1.0; 3],
2577 sun_intensity: 1.0,
2578 ambient: [0.25; 3],
2579 shadow_strength: 0.8,
2580 shadow_bias: 1.5,
2581 shadow_max_dist: 128.0,
2582 ..CpuLights::default()
2583 };
2584 let (w, h) = (96u32, 96u32);
2585 let lit_env = DdaEnv {
2586 lights: CpuLights {
2587 sun_casts_shadow: false,
2588 ..base
2589 },
2590 ..DdaEnv::default()
2591 };
2592 let shadow_env = DdaEnv {
2593 lights: CpuLights {
2594 sun_casts_shadow: true,
2595 ..base
2596 },
2597 ..DdaEnv::default()
2598 };
2599 let (fb_lit, _) = render_brickmap_env(grid, &cam, w, h, &lit_env);
2600 let (fb_sh, _) = render_brickmap_env(grid, &cam, w, h, &shadow_env);
2601 let sum: fn(&[u32]) -> u64 = |fb| fb.iter().map(|&p| u64::from(lum(p))).sum();
2602 let lit_sum = sum(&fb_lit);
2603 let sh_sum = sum(&fb_sh);
2604 assert!(
2605 sh_sum < lit_sum,
2606 "the wall's shadow must darken the floor: shadow_sum={sh_sum} lit_sum={lit_sum}",
2607 );
2608 assert!(
2610 (lit_sum - sh_sum) * 50 > lit_sum,
2611 "shadow should remove >2% of total luminance: lit={lit_sum} shadow={sh_sum}",
2612 );
2613 }
2614
2615 #[test]
2623 fn cutaway_hidden_deck_neither_renders_nor_shadows() {
2624 const ROOF: VoxColor = VoxColor(0x80_A0_50_20);
2625 const FLOOR: VoxColor = VoxColor(0x80_80_80_80);
2626 let roofed = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
2627 if z == 20 {
2628 Some(ROOF)
2629 } else if z >= 60 {
2630 Some(FLOOR)
2631 } else {
2632 None
2633 }
2634 });
2635 let roofless =
2636 roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| (z >= 60).then_some(FLOOR));
2637 let g_roofed = GridView::from_single_vxl(&roofed);
2638 let g_roofless = GridView::from_single_vxl(&roofless);
2639 let cam = Camera {
2640 pos: [32.0, 32.0, 6.0],
2641 right: [1.0, 0.0, 0.0],
2642 down: [0.0, 1.0, 0.0],
2643 forward: [0.0, 0.0, 1.0],
2644 };
2645 let inv = 1.0f32 / 2.0f32.sqrt();
2646 let lights = CpuLights {
2647 enabled: true,
2648 sun: true,
2649 sun_dir: [inv, 0.0, -inv],
2650 sun_color: [1.0; 3],
2651 sun_intensity: 1.0,
2652 sun_casts_shadow: true,
2653 ambient: [0.25; 3],
2654 shadow_strength: 0.8,
2655 shadow_bias: 1.5,
2656 shadow_max_dist: 128.0,
2657 ..CpuLights::default()
2658 };
2659 let (w, h) = (64u32, 64u32);
2660 let env_clipped = DdaEnv {
2661 lights,
2662 z_clip: Some(40),
2663 ..DdaEnv::default()
2664 };
2665 let env_plain = DdaEnv {
2666 lights,
2667 ..DdaEnv::default()
2668 };
2669 let (fb_clip, zb_clip) = render_brickmap_env(g_roofed, &cam, w, h, &env_clipped);
2670 let (fb_ref, zb_ref) = render_brickmap_env(g_roofless, &cam, w, h, &env_plain);
2671 assert_eq!(
2672 fb_clip, fb_ref,
2673 "clipped roof must render AND shadow exactly like no roof"
2674 );
2675 assert_eq!(zb_clip, zb_ref);
2676 let (fb_roof, _) = render_brickmap_env(g_roofed, &cam, w, h, &env_plain);
2678 assert_ne!(fb_roof, fb_ref, "unclipped roof must be visible");
2679 }
2680
2681 #[derive(Default)]
2683 struct Recorder {
2684 puts: Vec<(usize, u32, f32)>,
2685 }
2686 impl PixelSink for Recorder {
2687 fn put(&mut self, idx: usize, color: u32, dist: f32) {
2688 self.puts.push((idx, color, dist));
2689 }
2690 }
2691
2692 fn oracle_camera() -> Camera {
2693 Camera {
2695 pos: [0.0, 0.0, 0.0],
2696 right: [1.0, 0.0, 0.0],
2697 down: [0.0, 0.0, 1.0],
2698 forward: [0.0, 1.0, 0.0],
2699 }
2700 }
2701
2702 fn render_mask(grid: GridView<'_>, camera: &Camera, w: u32, h: u32) -> Vec<bool> {
2705 let n = (w as usize) * (h as usize);
2706 let mut fb = vec![0u32; n]; let mut zb = vec![f32::INFINITY; n];
2708 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2709 {
2710 let mut sink = RasterSink::new(&mut fb, &mut zb);
2711 render_dda(
2712 camera,
2713 &settings,
2714 grid,
2715 w as usize,
2716 &DdaEnv::default(),
2717 0,
2718 &mut sink,
2719 );
2720 }
2721 fb.iter().map(|&c| c != 0).collect()
2722 }
2723
2724 fn rows_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2729 let w = w as usize;
2730 for y in 0..h as usize {
2731 let row = &mask[y * w..(y + 1) * w];
2732 let first = row.iter().position(|&b| b);
2733 let last = row.iter().rposition(|&b| b);
2734 if let (Some(f), Some(l)) = (first, last) {
2735 if row[f..=l].iter().any(|&b| !b) {
2736 return false;
2737 }
2738 }
2739 }
2740 true
2741 }
2742
2743 fn cols_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2745 let w = w as usize;
2746 let h = h as usize;
2747 for x in 0..w {
2748 let col: Vec<bool> = (0..h).map(|y| mask[y * w + x]).collect();
2749 let first = col.iter().position(|&b| b);
2750 let last = col.iter().rposition(|&b| b);
2751 if let (Some(f), Some(l)) = (first, last) {
2752 if col[f..=l].iter().any(|&b| !b) {
2753 return false;
2754 }
2755 }
2756 }
2757 true
2758 }
2759
2760 #[test]
2763 fn center_pixel_ray_is_forward() {
2764 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2765 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2766 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2768 let (origin, dir) = pixel_ray(&cs, &settings, settings.hx as u32, settings.hy as u32);
2769 assert_eq!(origin, [0.0, 0.0, 0.0]);
2770 assert_eq!(
2772 dir.map(f32::to_bits),
2773 [0.0f32, 320.0, 0.0].map(f32::to_bits)
2774 );
2775 }
2776
2777 #[test]
2781 fn corner_pixel_ray_matches_camera_corn0() {
2782 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2783 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2784 let (_origin, dir) = pixel_ray(&cs, &settings, 0, 0);
2785 assert_eq!(dir.map(f32::to_bits), cs.corn[0].map(f32::to_bits));
2786 }
2787
2788 #[test]
2794 fn gridview_voxel_color_matches_reference() {
2795 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |x, _, z| {
2797 let lo = (10..=12).contains(&z);
2798 let hi = (40..=42).contains(&z);
2799 (lo || hi).then_some(VoxColor(0x80_10_20_30 + x))
2800 });
2801 let grid = GridView::from_single_vxl(&vxl);
2802 for x in 0..8 {
2803 for y in 0..8 {
2804 for z in 0..64 {
2805 assert_eq!(
2806 grid.voxel_color(x, y, z),
2807 vxl.voxel_color(x, y, z),
2808 "mismatch at ({x},{y},{z})"
2809 );
2810 }
2811 }
2812 }
2813 }
2814
2815 #[test]
2817 fn empty_grid_no_hits() {
2818 let vxl = roxlap_formats::vxl::Vxl::empty(64);
2819 let grid = GridView::from_single_vxl(&vxl);
2820 let settings = OpticastSettings::for_oracle_framebuffer(64, 48);
2821 let mut rec = Recorder::default();
2822 render_dda(
2823 &oracle_camera(),
2824 &settings,
2825 grid,
2826 64,
2827 &DdaEnv::default(),
2828 0,
2829 &mut rec,
2830 );
2831 assert!(rec.puts.is_empty(), "all-air grid must produce no hits");
2832 }
2833
2834 #[test]
2838 fn floor_seen_from_above() {
2839 const FLOOR_Z: u32 = 40;
2840 const FLOOR_COL: VoxColor = VoxColor(0x80_30_60_90);
2841 let vxl =
2842 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= FLOOR_Z).then_some(FLOOR_COL));
2843 let grid = GridView::from_single_vxl(&vxl);
2844
2845 let cam = Camera {
2847 pos: [16.0, 16.0, 10.0],
2848 right: [1.0, 0.0, 0.0],
2849 down: [0.0, 1.0, 0.0],
2850 forward: [0.0, 0.0, 1.0],
2851 };
2852 let settings = OpticastSettings::for_oracle_framebuffer(48, 48);
2853 let mut rec = Recorder::default();
2854 render_dda(&cam, &settings, grid, 48, &DdaEnv::default(), 0, &mut rec);
2855
2856 assert!(!rec.puts.is_empty(), "floor must be visible");
2857 let centre = 24usize * 48 + 24;
2859 let hit = rec
2860 .puts
2861 .iter()
2862 .find(|(idx, _, _)| *idx == centre)
2863 .expect("centre ray must hit the floor");
2864 assert_eq!(hit.1 & 0x00ff_ffff, FLOOR_COL.0 & 0x00ff_ffff);
2865 let expected = (FLOOR_Z as f32) - 10.0;
2866 assert!(
2867 (hit.2 - expected).abs() < 1.5,
2868 "centre depth {} not ≈ {}",
2869 hit.2,
2870 expected
2871 );
2872 }
2873
2874 #[test]
2879 fn horizon_splits_sky_and_floor() {
2880 const FLOOR_Z: u32 = 40;
2881 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
2882 (z >= FLOOR_Z).then_some(VoxColor(0x80_44_66_88))
2883 });
2884 let grid = GridView::from_single_vxl(&vxl);
2885
2886 let cam = Camera {
2890 pos: [32.0, 4.0, 30.0],
2891 right: [-1.0, 0.0, 0.0],
2892 down: [0.0, 0.0, 1.0],
2893 forward: [0.0, 1.0, 0.0],
2894 };
2895 let (w, h) = (64u32, 64u32);
2896 let mask = render_mask(grid, &cam, w, h);
2897
2898 let count_band = |y0: usize, y1: usize| -> usize {
2899 (y0 * w as usize..y1 * w as usize)
2900 .filter(|&i| mask[i])
2901 .count()
2902 };
2903 let top = count_band(0, h as usize / 4);
2904 let bottom = count_band(3 * h as usize / 4, h as usize);
2905 assert!(mask.iter().any(|&b| b), "floor must be visible");
2906 assert!(mask.iter().any(|&b| !b), "sky must be visible");
2907 assert!(
2908 bottom > top,
2909 "bottom band ({bottom}) should hit more floor than top band ({top})"
2910 );
2911 }
2912
2913 fn render_reference(
2916 grid: GridView<'_>,
2917 camera: &Camera,
2918 w: u32,
2919 h: u32,
2920 ) -> (Vec<u32>, Vec<f32>) {
2921 let n = (w as usize) * (h as usize);
2922 let mut fb = vec![0u32; n];
2923 let mut zb = vec![f32::INFINITY; n];
2924 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2925 let cs = camera_math::derive(camera, w, h, settings.hx, settings.hy, settings.hz);
2926 for py in 0..h {
2927 for px in 0..w {
2928 let (o, d) = pixel_ray(&cs, &settings, px, py);
2929 if let Some(hit) = cast_ray_reference(o, d, cs.forward, &grid, &settings) {
2930 let i = (py * w + px) as usize;
2931 fb[i] = hit.color;
2932 zb[i] = hit.dist;
2933 }
2934 }
2935 }
2936 (fb, zb)
2937 }
2938
2939 fn render_brickmap(
2941 grid: GridView<'_>,
2942 camera: &Camera,
2943 w: u32,
2944 h: u32,
2945 ) -> (Vec<u32>, Vec<f32>) {
2946 render_brickmap_env(grid, camera, w, h, &DdaEnv::default())
2947 }
2948
2949 fn render_brickmap_env(
2952 grid: GridView<'_>,
2953 camera: &Camera,
2954 w: u32,
2955 h: u32,
2956 env: &DdaEnv<'_>,
2957 ) -> (Vec<u32>, Vec<f32>) {
2958 let n = (w as usize) * (h as usize);
2959 let mut fb = vec![0u32; n];
2960 let mut zb = vec![f32::INFINITY; n];
2961 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2962 {
2963 let mut sink = RasterSink::new(&mut fb, &mut zb);
2964 render_dda(camera, &settings, grid, w as usize, env, 0, &mut sink);
2965 }
2966 (fb, zb)
2967 }
2968
2969 #[test]
2976 fn no_sky_leak_through_diagonal_wall() {
2977 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2978 ((x + y == 64) && (2..62).contains(&z)).then_some(VoxColor(0x80_40_80_60))
2979 });
2980 let grid = GridView::from_single_vxl(&vxl);
2981 let (w, h) = (160u32, 160u32);
2982 let c = [10.0, 10.0, 32.0];
2983 let poses = [
2984 Camera::from_yaw_pitch(c, 0.785, 0.0),
2985 Camera::from_yaw_pitch(c, 0.6, 0.1),
2986 Camera::from_yaw_pitch(c, 0.95, -0.1),
2987 Camera::from_yaw_pitch(c, 0.785, 0.3),
2988 Camera::from_yaw_pitch(c, 0.5, 0.0),
2989 ];
2990 for (i, cam) in poses.iter().enumerate() {
2991 let (fb_b, _) = render_brickmap(grid, cam, w, h);
2992 let (fb_r, _) = render_reference(grid, cam, w, h);
2993 let leak = (0..(w * h) as usize)
2994 .filter(|&k| (fb_b[k] != 0) != (fb_r[k] != 0))
2995 .count();
2996 assert_eq!(leak, 0, "pose {i}: {leak} px diverge from dense reference");
2997 }
2998 }
2999
3000 #[test]
3004 fn terrain_glass_tints_floor_behind() {
3005 let glass = VoxColor(0x80_40_C0_E0); let floor = VoxColor(0x80_C0_40_40); let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
3008 if z == 4 {
3009 Some(glass)
3010 } else if z >= 10 {
3011 Some(floor)
3012 } else {
3013 None
3014 }
3015 });
3016 let grid = GridView::from_single_vxl(&vxl);
3017 let cam = Camera {
3019 pos: [8.0, 8.0, 0.0],
3020 right: [1.0, 0.0, 0.0],
3021 down: [0.0, 1.0, 0.0],
3022 forward: [0.0, 0.0, 1.0],
3023 };
3024 let (w, h) = (32u32, 32u32);
3025 let centre = (h / 2 * w + w / 2) as usize;
3026
3027 let (fb_op, _) = render_brickmap(grid, &cam, w, h);
3029 assert_eq!(
3030 fb_op[centre] & 0x00ff_ffff,
3031 0x0040_C0E0,
3032 "opaque glass first-hit"
3033 );
3034
3035 let mut table = MaterialTable::new();
3037 table.set(1, Material::alpha_blend(128));
3038 let env = DdaEnv {
3039 materials: Some(&table),
3040 terrain_materials: &[(glass.rgb_part(), 1)],
3041 lights: CpuLights::default(),
3042 ..DdaEnv::default()
3043 };
3044 let (fb_tr, _) = render_brickmap_env(grid, &cam, w, h, &env);
3045 assert_ne!(
3046 fb_tr[centre], fb_op[centre],
3047 "glass should composite over the floor, not stay opaque"
3048 );
3049 let r_op = (fb_op[centre] >> 16) & 0xff; let r_tr = (fb_tr[centre] >> 16) & 0xff; assert!(
3052 r_tr > r_op,
3053 "floor red tints through the glass (op={r_op:02x} tr={r_tr:02x})"
3054 );
3055 }
3056
3057 #[test]
3061 fn terrain_emissive_ignores_lighting() {
3062 let crystal = VoxColor(0x40_20_60_80); let vxl =
3064 roxlap_formats::vxl::Vxl::from_dense(
3065 16,
3066 |_, _, z| if z >= 4 { Some(crystal) } else { None },
3067 );
3068 let grid = GridView::from_single_vxl(&vxl);
3069 let cam = Camera {
3070 pos: [8.0, 8.0, 0.0],
3071 right: [1.0, 0.0, 0.0],
3072 down: [0.0, 1.0, 0.0],
3073 forward: [0.0, 0.0, 1.0],
3074 };
3075 let (w, h) = (32u32, 32u32);
3076 let centre = (h / 2 * w + w / 2) as usize;
3077
3078 let (fb_dim, _) = render_brickmap(grid, &cam, w, h);
3080 assert_eq!(
3081 fb_dim[centre] & 0x00ff_ffff,
3082 0x0010_3040,
3083 "baked byte 0x40 = albedo/2"
3084 );
3085
3086 let mut table = MaterialTable::new();
3088 table.set(1, Material::glow(255));
3089 let base = DdaEnv {
3090 materials: Some(&table),
3091 terrain_materials: &[(crystal.rgb_part(), 1)],
3092 ..DdaEnv::default()
3093 };
3094 let (fb_em, _) = render_brickmap_env(grid, &cam, w, h, &base);
3095 assert_eq!(
3096 fb_em[centre] & 0x00ff_ffff,
3097 0x003f_bfff,
3098 "glow(255) ≈ 2× albedo (0x20,0x60,0x80 → 0x3f,0xbf,0xff)"
3099 );
3100
3101 let shaded_env = DdaEnv {
3103 side_shades: [64; 6],
3104 ..DdaEnv::default()
3105 };
3106 let (fb_ss_plain, _) = render_brickmap_env(grid, &cam, w, h, &shaded_env);
3107 assert_ne!(
3108 fb_ss_plain[centre], fb_dim[centre],
3109 "control: side shades darken a non-emissive voxel"
3110 );
3111 let em_ss = DdaEnv {
3112 side_shades: [64; 6],
3113 ..base
3114 };
3115 let (fb_em_ss, _) = render_brickmap_env(grid, &cam, w, h, &em_ss);
3116 assert_eq!(
3117 fb_em_ss[centre], fb_em[centre],
3118 "side shades must not touch an emissive voxel"
3119 );
3120
3121 let em_rig = DdaEnv {
3124 lights: CpuLights {
3125 enabled: true,
3126 ..CpuLights::default()
3127 },
3128 ..base
3129 };
3130 let (fb_em_rig, _) = render_brickmap_env(grid, &cam, w, h, &em_rig);
3131 assert_eq!(
3132 fb_em_rig[centre], fb_em[centre],
3133 "the dynamic rig must not touch an emissive voxel"
3134 );
3135 }
3136
3137 #[test]
3142 fn terrain_volumetric_thickness_deepens_opacity() {
3143 let smoke = VoxColor(0x80_90_90_90); let floor = VoxColor(0x80_C0_20_20); let green_at = |depth: u32| -> u32 {
3148 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
3149 if (4..4 + depth).contains(&z) {
3150 Some(smoke)
3151 } else if z >= 12 {
3152 Some(floor)
3153 } else {
3154 None
3155 }
3156 });
3157 let grid = GridView::from_single_vxl(&vxl);
3158 let cam = Camera {
3159 pos: [8.0, 8.0, 0.0],
3160 right: [1.0, 0.0, 0.0],
3161 down: [0.0, 1.0, 0.0],
3162 forward: [0.0, 0.0, 1.0],
3163 };
3164 let (w, h) = (32u32, 32u32);
3165 let mut table = MaterialTable::new();
3166 table.set(1, Material::volumetric(80));
3167 let env = DdaEnv {
3168 materials: Some(&table),
3169 terrain_materials: &[(smoke.rgb_part(), 1)],
3170 lights: CpuLights::default(),
3171 ..DdaEnv::default()
3172 };
3173 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
3174 (fb[(h / 2 * w + w / 2) as usize] >> 8) & 0xff
3175 };
3176 let shallow = green_at(1);
3177 let deep = green_at(7);
3178 assert!(
3179 deep > shallow,
3180 "deeper Volumetric smoke shows more of its grey (deep g={deep:02x} > shallow g={shallow:02x})"
3181 );
3182 }
3183
3184 #[test]
3187 fn distance_fog_blends_toward_fog_color() {
3188 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
3189 (z >= 40).then_some(VoxColor(0x80_FF_FF_FF))
3190 });
3191 let grid = GridView::from_single_vxl(&vxl);
3192 let cam = Camera {
3193 pos: [32.0, 2.0, 38.0],
3194 right: [1.0, 0.0, 0.0],
3195 down: [0.0, 0.0, 1.0],
3196 forward: [0.0, 1.0, 0.0],
3197 };
3198 let env = DdaEnv {
3199 sky: None,
3200 fog_color: 0x00_00_00_00, fog_max_dist: 64.0,
3202 side_shades: [0; 6],
3203 materials: None,
3204 terrain_materials: &[],
3205 lights: CpuLights::default(),
3206 world_shadow: None,
3207 z_clip: None,
3208 cutout: None,
3209 fow: None,
3210 };
3211 let (w, h) = (64u32, 64u32);
3212 let (fog, _) = render_brickmap_env(grid, &cam, w, h, &env);
3213 let (nofog, zb) = render_brickmap(grid, &cam, w, h);
3214 let (idx, depth) = zb.iter().enumerate().filter(|(_, z)| z.is_finite()).fold(
3215 (0usize, 0.0f32),
3216 |acc, (i, &z)| {
3217 if z > acc.1 {
3218 (i, z)
3219 } else {
3220 acc
3221 }
3222 },
3223 );
3224 assert!(depth > 20.0, "need a deep pixel to test fog (got {depth})");
3225 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
3226 assert!(
3227 lum(fog[idx]) < lum(nofog[idx]),
3228 "fogged pixel {:08x} not darker than {:08x}",
3229 fog[idx],
3230 nofog[idx]
3231 );
3232 }
3233
3234 #[test]
3237 fn textured_sky_fills_misses() {
3238 let sky = crate::sky::Sky::blue_gradient();
3239 let vxl = roxlap_formats::vxl::Vxl::empty(32); let grid = GridView::from_single_vxl(&vxl);
3241 let env = DdaEnv {
3242 sky: Some(&sky),
3243 fog_color: 0,
3244 fog_max_dist: 0.0,
3245 side_shades: [0; 6],
3246 materials: None,
3247 terrain_materials: &[],
3248 lights: CpuLights::default(),
3249 world_shadow: None,
3250 z_clip: None,
3251 cutout: None,
3252 fow: None,
3253 };
3254 let cam = Camera::from_yaw_pitch([16.0, 16.0, 128.0], 0.3, -0.4);
3255 let (w, h) = (48u32, 48u32);
3256 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
3257 assert!(fb.iter().all(|&c| c >> 24 == 0x80), "all misses sky-filled");
3258 let top = fb[0];
3259 let bottom = fb[(h - 1) as usize * w as usize];
3260 assert_ne!(top, bottom, "sky gradient should vary with elevation");
3261 }
3262
3263 #[test]
3268 fn sky_elevation_zenith_at_column_zero() {
3269 let mut pixels = vec![0i32; 8];
3270 pixels[0] = 0x0011_1111; pixels[7] = 0x0099_9999; let sky = crate::sky::Sky::from_pixels(pixels, 8, 1);
3273 let up = sample_sky(&sky, [0.0, 0.0, -1.0]); let down = sample_sky(&sky, [0.0, 0.0, 1.0]); assert_eq!(
3276 up & 0x00ff_ffff,
3277 0x0011_1111,
3278 "looking up → column 0 (zenith)"
3279 );
3280 assert_eq!(
3281 down & 0x00ff_ffff,
3282 0x0099_9999,
3283 "looking down → last column (nadir)"
3284 );
3285 }
3286
3287 #[test]
3291 fn sky_fill_paints_panorama_gridless() {
3292 let sky = crate::sky::Sky::blue_gradient();
3293 let cam = Camera::from_yaw_pitch([0.0, 0.0, 0.0], 0.3, -0.4);
3294 let (w, h) = (48u32, 48u32);
3295 let cs = crate::camera_math::derive(&cam, w, h, 24.0, 24.0, 24.0);
3296 let settings = crate::opticast::OpticastSettings::for_oracle_framebuffer(w, h);
3297 let mut fb = vec![0u32; (w * h) as usize];
3298 let zb = vec![f32::INFINITY; (w * h) as usize];
3300 render_sky_fill(&mut fb, &zb, w as usize, w, h, &cs, &settings, &sky);
3301 assert!(
3302 fb.iter().all(|&c| c >> 24 == 0x80),
3303 "every pixel sky-filled with the brightness byte set"
3304 );
3305 let top = fb[0];
3306 let bottom = fb[(h - 1) as usize * w as usize];
3307 assert_ne!(top, bottom, "sky gradient should vary with elevation");
3308 let mut fb2 = vec![0x1234_5678u32; (w * h) as usize];
3310 let mut zb2 = vec![f32::INFINITY; (w * h) as usize];
3311 zb2[0] = 10.0; render_sky_fill(&mut fb2, &zb2, w as usize, w, h, &cs, &settings, &sky);
3313 assert_eq!(fb2[0], 0x1234_5678, "finite-z pixel is not overwritten");
3314 }
3315
3316 #[test]
3320 fn side_shades_darken_hit_face() {
3321 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
3322 (z >= 8).then_some(VoxColor(0x80_FF_FF_FF))
3323 });
3324 let grid = GridView::from_single_vxl(&vxl);
3325 let cam = Camera {
3326 pos: [8.0, 8.0, 2.0],
3327 right: [1.0, 0.0, 0.0],
3328 down: [0.0, 1.0, 0.0],
3329 forward: [0.0, 0.0, 1.0],
3330 };
3331 let centre = 16 * 32 + 16;
3332 let (plain, _) = render_brickmap(grid, &cam, 32, 32);
3333 let env = DdaEnv {
3334 sky: None,
3335 fog_color: 0,
3336 fog_max_dist: 0.0,
3337 side_shades: [0, 0, 0, 0, 0x40, 0],
3338 materials: None,
3339 terrain_materials: &[],
3340 lights: CpuLights::default(),
3341 world_shadow: None,
3342 z_clip: None,
3343 cutout: None,
3344 fow: None,
3345 };
3346 let (shaded, _) = render_brickmap_env(grid, &cam, 32, 32, &env);
3347 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
3348 assert!(
3349 lum(shaded[centre]) < lum(plain[centre]),
3350 "side-shaded face {:08x} not darker than {:08x}",
3351 shaded[centre],
3352 plain[centre]
3353 );
3354 }
3355
3356 #[test]
3366 fn brickmap_approximates_dense_reference() {
3367 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
3369 let surf = 30 + ((x / 5 + y / 7) % 11);
3370 let ground = z >= surf;
3371 let block = (20..=24).contains(&z) && (10..20).contains(&x) && (40..50).contains(&y);
3372 (ground || block).then_some(VoxColor(0x80_30_50_70 + (x ^ y) % 0x40))
3373 });
3374 let grid = GridView::from_single_vxl(&vxl);
3375
3376 let (w, h) = (80u32, 80u32);
3377 let poses = [
3378 Camera::orbit(0.6, 0.5, 90.0, [32.0, 32.0, 40.0]),
3379 Camera::orbit(2.1, 0.2, 70.0, [32.0, 32.0, 35.0]),
3380 Camera::orbit(-1.0, 0.9, 120.0, [32.0, 32.0, 45.0]),
3381 ];
3382 let n = (w * h) as usize;
3383 for (i, cam) in poses.iter().enumerate() {
3384 let (fb_b, zb_b) = render_brickmap(grid, cam, w, h);
3385 let (fb_r, _zb_r) = render_reference(grid, cam, w, h);
3386 let cov_b = fb_b.iter().filter(|&&c| c != 0).count();
3388 let cov_r = fb_r.iter().filter(|&&c| c != 0).count();
3389 assert!(cov_b > 200, "pose {i} rendered ~empty (cov {cov_b})");
3390 let cov_diff = cov_b.abs_diff(cov_r);
3391 assert!(
3392 cov_diff * 100 <= n, "pose {i} coverage diverged: brick {cov_b} vs dense {cov_r}"
3394 );
3395 let diffs = fb_b.iter().zip(&fb_r).filter(|(a, b)| a != b).count();
3397 assert!(
3398 diffs * 100 <= n * 3, "pose {i} too many pixel diffs vs dense: {diffs}/{n}"
3400 );
3401 for k in 0..n {
3403 if fb_b[k] != 0 {
3404 assert!(zb_b[k].is_finite(), "pose {i} px {k} non-finite depth");
3405 }
3406 }
3407 }
3408 }
3409
3410 #[test]
3414 fn baked_brightness_darkens_color() {
3415 let dim = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
3417 (z >= 8).then_some(VoxColor(0x40_FF_FF_FF))
3418 });
3419 let grid = GridView::from_single_vxl(&dim);
3420 let cam = Camera {
3421 pos: [8.0, 8.0, 2.0],
3422 right: [1.0, 0.0, 0.0],
3423 down: [0.0, 1.0, 0.0],
3424 forward: [0.0, 0.0, 1.0],
3425 };
3426 let (fb, _) = render_brickmap(grid, &cam, 32, 32);
3427 let centre = 16 * 32 + 16;
3428 assert_eq!(fb[centre], 0x80_7F_7F_7F, "got {:08x}", fb[centre]);
3430
3431 let full = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
3433 (z >= 8).then_some(VoxColor(0x80_FF_FF_FF))
3434 });
3435 let gridf = GridView::from_single_vxl(&full);
3436 let (fbf, _) = render_brickmap(gridf, &cam, 32, 32);
3437 assert_eq!(fbf[centre], 0x80_FF_FF_FF, "got {:08x}", fbf[centre]);
3438 }
3439
3440 #[test]
3447 fn cross_chunk_lookdown_sees_lower_stacked_floor() {
3448 const FLOOR_LOCAL_Z: u32 = 40;
3449 const FLOOR_COL: VoxColor = VoxColor(0x80_22_88_44);
3450 let upper = roxlap_formats::vxl::Vxl::empty(32); let lower = roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
3452 (z >= FLOOR_LOCAL_Z).then_some(FLOOR_COL)
3453 });
3454 let v_up = GridView::from_single_vxl(&upper);
3455 let v_lo = GridView::from_single_vxl(&lower);
3456 let chunks = [Some(v_up), Some(v_lo)];
3458 let cg = crate::ChunkGrid {
3459 chunks: &chunks,
3460 origin_chunk_xy: [0, 0],
3461 origin_chunk_z: 0,
3462 chunks_x: 1,
3463 chunks_y: 1,
3464 chunks_z: 2,
3465 };
3466 let grid = GridView::from_chunk_grid(&cg, 32);
3467
3468 let cam = Camera {
3470 pos: [16.0, 16.0, 100.0],
3471 right: [1.0, 0.0, 0.0],
3472 down: [0.0, 1.0, 0.0],
3473 forward: [0.0, 0.0, 1.0],
3474 };
3475 let (w, h) = (48u32, 48u32);
3476 let (fb, zb) = render_brickmap(grid, &cam, w, h);
3477 let centre = 24 * 48 + 24;
3478 assert!(
3479 fb[centre] & 0x00ff_ffff == FLOOR_COL.0 & 0x00ff_ffff,
3480 "centre ray must reach the lower-chunk floor (got {:08x})",
3481 fb[centre]
3482 );
3483 let expected = 296.0 - 100.0;
3485 assert!(
3486 (zb[centre] - expected).abs() < 2.0,
3487 "look-down depth {} not ≈ {expected}",
3488 zb[centre]
3489 );
3490 }
3491
3492 fn render_clip(
3495 grid: GridView<'_>,
3496 camera: &Camera,
3497 w: u32,
3498 h: u32,
3499 z_clip: Option<i32>,
3500 ) -> (Vec<u32>, Vec<f32>) {
3501 let env = DdaEnv {
3502 z_clip,
3503 ..DdaEnv::default()
3504 };
3505 render_brickmap_env(grid, camera, w, h, &env)
3506 }
3507
3508 #[test]
3512 fn cutaway_clip_reveals_lower_decks() {
3513 const DECK_A: VoxColor = VoxColor(0x80_C0_30_30); const DECK_B: VoxColor = VoxColor(0x80_30_C0_30); const DECK_C: VoxColor = VoxColor(0x80_30_30_C0); let vxl = roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| match z {
3517 60 => Some(DECK_A),
3518 120 => Some(DECK_B),
3519 180 => Some(DECK_C),
3520 _ => None,
3521 });
3522 let grid = GridView::from_single_vxl(&vxl);
3523 let cam = Camera {
3524 pos: [16.0, 16.0, 10.0],
3525 right: [1.0, 0.0, 0.0],
3526 down: [0.0, 1.0, 0.0],
3527 forward: [0.0, 0.0, 1.0],
3528 };
3529 let (w, h) = (32u32, 32u32);
3530 let centre = (h / 2 * w + w / 2) as usize;
3531 let cases = [
3533 (None, DECK_A, 60.0),
3534 (Some(100), DECK_B, 120.0), (Some(120), DECK_B, 120.0), (Some(121), DECK_C, 180.0), ];
3538 for (clip, col, z) in cases {
3539 let (fb, zb) = render_clip(grid, &cam, w, h, clip);
3540 assert_eq!(
3541 fb[centre], col.0,
3542 "clip {clip:?}: expected {:08x}, got {:08x}",
3543 col.0, fb[centre]
3544 );
3545 let expected = z - 10.0;
3546 assert!(
3547 (zb[centre] - expected).abs() < 1.0,
3548 "clip {clip:?}: depth {} not ≈ {expected}",
3549 zb[centre]
3550 );
3551 }
3552 }
3553
3554 #[test]
3559 fn cutaway_cut_face_uses_run_top_colour() {
3560 let col_at = |z: u32| VoxColor(0x8000_0000 | (0x40 + z));
3563 let vxl =
3564 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= 100).then_some(col_at(z)));
3565 let grid = GridView::from_single_vxl(&vxl);
3566 let cam = Camera {
3567 pos: [16.0, 16.0, 10.0],
3568 right: [1.0, 0.0, 0.0],
3569 down: [0.0, 1.0, 0.0],
3570 forward: [0.0, 0.0, 1.0],
3571 };
3572 let (w, h) = (32u32, 32u32);
3573 let centre = (h / 2 * w + w / 2) as usize;
3574 let (fb, zb) = render_clip(grid, &cam, w, h, None);
3576 assert_eq!(fb[centre], col_at(100).0);
3577 assert!((zb[centre] - 90.0).abs() < 1.0);
3578 let (fb, zb) = render_clip(grid, &cam, w, h, Some(150));
3581 assert_eq!(
3582 fb[centre],
3583 col_at(100).0,
3584 "cut face must reuse the run-top colour, got {:08x}",
3585 fb[centre]
3586 );
3587 assert!(
3588 (zb[centre] - 140.0).abs() < 1.0,
3589 "cut face depth {} not ≈ 140",
3590 zb[centre]
3591 );
3592 }
3593
3594 #[test]
3600 fn cutaway_clip_is_absolute_z_on_stacked_chunks() {
3601 const UPPER_COL: VoxColor = VoxColor(0x80_C0_C0_30); const LOWER_COL: VoxColor = VoxColor(0x80_30_C0_C0); let upper =
3604 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z == 40).then_some(UPPER_COL));
3605 let lower =
3606 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= 40).then_some(LOWER_COL));
3607 let v_up = GridView::from_single_vxl(&upper);
3608 let v_lo = GridView::from_single_vxl(&lower);
3609 let chunks = [Some(v_up), Some(v_lo)];
3610 let cg = crate::ChunkGrid {
3611 chunks: &chunks,
3612 origin_chunk_xy: [0, 0],
3613 origin_chunk_z: 0,
3614 chunks_x: 1,
3615 chunks_y: 1,
3616 chunks_z: 2,
3617 };
3618 let grid = GridView::from_chunk_grid(&cg, 32);
3619 let cam = Camera {
3620 pos: [16.0, 16.0, 10.0],
3621 right: [1.0, 0.0, 0.0],
3622 down: [0.0, 1.0, 0.0],
3623 forward: [0.0, 0.0, 1.0],
3624 };
3625 let (w, h) = (32u32, 32u32);
3626 let centre = (h / 2 * w + w / 2) as usize;
3627 let (fb, zb) = render_clip(grid, &cam, w, h, None);
3629 assert_eq!(fb[centre], UPPER_COL.0);
3630 assert!((zb[centre] - 30.0).abs() < 1.0);
3631 let (fb, zb) = render_clip(grid, &cam, w, h, Some(290));
3634 assert_eq!(
3635 fb[centre], LOWER_COL.0,
3636 "expected the chz=1 floor, got {:08x}",
3637 fb[centre]
3638 );
3639 assert!(
3640 (zb[centre] - 286.0).abs() < 1.0,
3641 "depth {} not ≈ 286",
3642 zb[centre]
3643 );
3644 }
3645
3646 #[test]
3653 fn cutaway_clip_mip_formula_floors() {
3654 let mut vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
3655 (z >= 100).then_some(VoxColor(0x80_80_80_80))
3656 });
3657 vxl.generate_mips(2);
3658 assert!(vxl.mip_count() >= 2, "need mip 1 built");
3659 let grid = GridView::from_single_vxl(&vxl);
3660 let cam = Camera {
3661 pos: [32.0, 32.0, 10.0],
3662 right: [1.0, 0.0, 0.0],
3663 down: [0.0, 1.0, 0.0],
3664 forward: [0.0, 0.0, 1.0],
3665 };
3666 let (w, h) = (32u32, 32u32);
3667 let n = (w as usize) * (h as usize);
3668 let centre = (h / 2 * w + w / 2) as usize;
3669 let mut fb = vec![0u32; n];
3670 let mut zb = vec![f32::INFINITY; n];
3671 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
3672 let env = DdaEnv {
3673 z_clip: Some(101),
3674 ..DdaEnv::default()
3675 };
3676 {
3677 let mut sink = RasterSink::new(&mut fb, &mut zb);
3678 render_dda(&cam, &settings, grid, w as usize, &env, 1, &mut sink);
3679 }
3680 assert_ne!(fb[centre], 0, "mip-1 clipped surface must still hit");
3681 assert!(
3682 (zb[centre] - 90.0).abs() < 1.5,
3683 "mip-cell 50 (voxel z=100) must be the first visible layer \
3684 (floor formula); depth {} not ≈ 90",
3685 zb[centre]
3686 );
3687 }
3688
3689 #[test]
3692 fn cutaway_clip_disabled_is_byte_identical() {
3693 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
3694 let surf = 28 + ((x / 4 + y / 6) % 13);
3695 (z >= surf).then_some(VoxColor(0x80_40_60_80 + (x ^ y) % 0x30))
3696 });
3697 let grid = GridView::from_single_vxl(&vxl);
3698 let cam = Camera::orbit(0.8, 0.55, 100.0, [32.0, 32.0, 40.0]);
3699 let (w, h) = (96u32, 96u32);
3700 let (fb_base, zb_base) = render_brickmap(grid, &cam, w, h);
3701 let (fb_none, zb_none) = render_clip(grid, &cam, w, h, None);
3702 assert_eq!(fb_base, fb_none, "z_clip=None must not change a pixel");
3703 assert_eq!(zb_base, zb_none);
3704 let (fb_zero, zb_zero) = render_clip(grid, &cam, w, h, Some(0));
3707 assert_eq!(fb_base, fb_zero, "no-op clip must not change a pixel");
3708 assert_eq!(zb_base, zb_zero);
3709 }
3710
3711 fn render_cutout(
3714 grid: GridView<'_>,
3715 camera: &Camera,
3716 w: u32,
3717 h: u32,
3718 cutout: Option<CpuCutout>,
3719 ) -> (Vec<u32>, Vec<f32>) {
3720 let env = DdaEnv {
3721 cutout,
3722 ..DdaEnv::default()
3723 };
3724 render_brickmap_env(grid, camera, w, h, &env)
3725 }
3726
3727 const CUT_WALL: VoxColor = VoxColor(0x80_C0_40_40);
3730 const CUT_BACK: VoxColor = VoxColor(0x80_40_C0_40);
3731 fn keyhole_fixture() -> roxlap_formats::vxl::Vxl {
3732 roxlap_formats::vxl::Vxl::from_dense(64, |_, y, _| match y {
3733 20 => Some(CUT_WALL),
3734 48 => Some(CUT_BACK),
3735 _ => None,
3736 })
3737 }
3738 fn keyhole_camera() -> Camera {
3739 Camera {
3740 pos: [32.0, 4.0, 128.0],
3741 right: [1.0, 0.0, 0.0],
3742 down: [0.0, 0.0, 1.0],
3743 forward: [0.0, 1.0, 0.0],
3744 }
3745 }
3746
3747 #[test]
3752 fn cutout_reveals_wall_inside_window_only() {
3753 let vxl = keyhole_fixture();
3754 let grid = GridView::from_single_vxl(&vxl);
3755 let cam = keyhole_camera();
3756 let (w, h) = (32u32, 32u32);
3757 let centre = (h / 2 * w + w / 2) as usize;
3758 let cut = CpuCutout {
3763 focus_local: [32.0, 30.0, 128.0],
3764 tan_outer: 0.25,
3765 tan_inner: 0.25,
3766 margin: 2.0,
3767 focus_z: 200,
3768 };
3769 let (fb, zb) = render_cutout(grid, &cam, w, h, Some(cut));
3770 assert_eq!(
3771 fb[centre], CUT_BACK.0,
3772 "cone centre must see through the wall, got {:08x}",
3773 fb[centre]
3774 );
3775 assert!(
3776 (zb[centre] - 44.0).abs() < 1.0,
3777 "revealed depth {} not ≈ 44 (the back wall)",
3778 zb[centre]
3779 );
3780 let outside = (h / 2 * w + 2) as usize;
3783 assert_eq!(
3784 fb[outside], CUT_WALL.0,
3785 "outside the cone the wall must stay, got {:08x}",
3786 fb[outside]
3787 );
3788 assert!((zb[outside] - 16.0).abs() < 1.0);
3789 let below = CpuCutout {
3792 focus_z: 100,
3793 ..cut
3794 };
3795 let (fb, zb) = render_cutout(grid, &cam, w, h, Some(below));
3796 assert_eq!(
3797 fb[centre], CUT_WALL.0,
3798 "cells at/below the focus plane must stay, got {:08x}",
3799 fb[centre]
3800 );
3801 assert!((zb[centre] - 16.0).abs() < 1.0);
3802 }
3803
3804 #[test]
3811 fn cutout_feather_tapers_reveal_radially() {
3812 let vxl = keyhole_fixture();
3813 let grid = GridView::from_single_vxl(&vxl);
3814 let cam = keyhole_camera();
3815 let (w, h) = (64u32, 64u32);
3816 let tapered = CpuCutout {
3817 focus_local: [32.0, 30.0, 128.0],
3818 tan_outer: 0.9,
3819 tan_inner: 0.3,
3820 margin: 2.0,
3821 focus_z: 200,
3822 };
3823 let (fb, _) = render_cutout(grid, &cam, w, h, Some(tapered));
3824 let (fb2, _) = render_cutout(grid, &cam, w, h, Some(tapered));
3825 assert_eq!(fb, fb2, "the feather taper must be deterministic");
3826 let row = (h / 2) as usize * w as usize;
3830 let flags: Vec<bool> = (0..w as usize)
3831 .map(|px| fb[row + px] == CUT_BACK.0)
3832 .collect();
3833 let first = flags.iter().position(|&b| b);
3834 let last = flags.iter().rposition(|&b| b);
3835 let (Some(first), Some(last)) = (first, last) else {
3836 panic!("axis row must contain revealed pixels");
3837 };
3838 assert!(
3839 flags[first..=last].iter().all(|&b| b),
3840 "revealed run must be contiguous (no teeth): {flags:?}"
3841 );
3842 assert!(
3843 flags.iter().any(|&b| !b),
3844 "the taper must keep wall pixels on the axis row"
3845 );
3846 let hard = CpuCutout {
3849 tan_inner: 0.9,
3850 ..tapered
3851 };
3852 let (fb_hard, _) = render_cutout(grid, &cam, w, h, Some(hard));
3853 let count = |fb: &[u32]| fb.iter().filter(|&&p| p == CUT_BACK.0).count();
3854 let (n_taper, n_hard) = (count(&fb), count(&fb_hard));
3855 assert!(
3856 0 < n_taper && n_taper < n_hard,
3857 "taper must shrink the hole: tapered {n_taper} vs hard {n_hard}"
3858 );
3859 }
3860
3861 #[test]
3869 fn cutout_pillar_in_front_cuts_down_to_the_feet() {
3870 const PILLAR: VoxColor = VoxColor(0x80_C0_C0_20);
3871 const FLOOR: VoxColor = VoxColor(0x80_60_60_60);
3872 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
3873 if z >= 132 {
3874 Some(FLOOR)
3875 } else if (31..=33).contains(&x) && (24..=25).contains(&y) && (120..130).contains(&z) {
3876 Some(PILLAR)
3881 } else {
3882 None
3883 }
3884 });
3885 let grid = GridView::from_single_vxl(&vxl);
3886 let cam = Camera {
3889 pos: [32.0, 4.0, 112.0],
3890 right: [1.0, 0.0, 0.0],
3891 down: [0.0, 0.0, 1.0],
3892 forward: [0.0, 1.0, 0.0],
3893 };
3894 let cut = CpuCutout {
3895 focus_local: [32.0, 30.0, 126.0],
3896 tan_outer: 1.0,
3897 tan_inner: 1.0,
3898 margin: 2.0,
3899 focus_z: 132,
3900 };
3901 let (w, h) = (64u32, 64u32);
3902 let count = |fb: &[u32], c: VoxColor| fb.iter().filter(|&&p| p == c.0).count();
3903 let (fb, _) = render_cutout(grid, &cam, w, h, None);
3905 assert!(count(&fb, PILLAR) > 0, "uncut pillar must be visible");
3906 let (fb, _) = render_cutout(grid, &cam, w, h, Some(cut));
3909 assert_eq!(
3910 count(&fb, PILLAR),
3911 0,
3912 "the pillar must cut down to the feet, not stop at the chest"
3913 );
3914 assert!(count(&fb, FLOOR) > 0, "the floor must survive the cut");
3915 }
3916
3917 #[test]
3921 fn cutout_focus_plane_is_absolute_z_on_stacked_chunks() {
3922 let upper = roxlap_formats::vxl::Vxl::from_dense(32, |_, _, _| None);
3924 let lower = roxlap_formats::vxl::Vxl::from_dense(32, |_, y, _| match y {
3925 20 => Some(CUT_WALL),
3926 28 => Some(CUT_BACK),
3927 _ => None,
3928 });
3929 let v_up = GridView::from_single_vxl(&upper);
3930 let v_lo = GridView::from_single_vxl(&lower);
3931 let chunks = [Some(v_up), Some(v_lo)];
3932 let cg = crate::ChunkGrid {
3933 chunks: &chunks,
3934 origin_chunk_xy: [0, 0],
3935 origin_chunk_z: 0,
3936 chunks_x: 1,
3937 chunks_y: 1,
3938 chunks_z: 2,
3939 };
3940 let grid = GridView::from_chunk_grid(&cg, 32);
3941 let cam = Camera {
3942 pos: [16.0, 4.0, 300.0],
3943 right: [1.0, 0.0, 0.0],
3944 down: [0.0, 0.0, 1.0],
3945 forward: [0.0, 1.0, 0.0],
3946 };
3947 let (w, h) = (32u32, 32u32);
3948 let centre = (h / 2 * w + w / 2) as usize;
3949 let cut = CpuCutout {
3950 focus_local: [16.0, 25.0, 300.0],
3951 tan_outer: 1.0,
3952 tan_inner: 1.0,
3953 margin: 2.0,
3954 focus_z: 320, };
3956 let (fb, zb) = render_cutout(grid, &cam, w, h, Some(cut));
3957 assert_eq!(
3958 fb[centre], CUT_BACK.0,
3959 "abs-z focus plane must hide the chz=1 wall, got {:08x}",
3960 fb[centre]
3961 );
3962 assert!((zb[centre] - 24.0).abs() < 1.0);
3963 let above = CpuCutout {
3966 focus_z: 280,
3967 ..cut
3968 };
3969 let (fb, _) = render_cutout(grid, &cam, w, h, Some(above));
3970 assert_eq!(
3971 fb[centre], CUT_WALL.0,
3972 "focus plane above the ray must keep the wall, got {:08x}",
3973 fb[centre]
3974 );
3975 }
3976
3977 #[test]
3983 fn cutout_focus_plane_mip_formula_floors() {
3984 let mut vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
3985 (z >= 100).then_some(VoxColor(0x80_80_80_80))
3986 });
3987 vxl.generate_mips(2);
3988 assert!(vxl.mip_count() >= 2, "need mip 1 built");
3989 let grid = GridView::from_single_vxl(&vxl);
3990 let cam = Camera {
3991 pos: [32.0, 32.0, 10.0],
3992 right: [1.0, 0.0, 0.0],
3993 down: [0.0, 1.0, 0.0],
3994 forward: [0.0, 0.0, 1.0],
3995 };
3996 let (w, h) = (32u32, 32u32);
3997 let n = (w as usize) * (h as usize);
3998 let centre = (h / 2 * w + w / 2) as usize;
3999 let mut fb = vec![0u32; n];
4000 let mut zb = vec![f32::INFINITY; n];
4001 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
4002 let env = DdaEnv {
4003 cutout: Some(CpuCutout {
4004 focus_local: [32.0, 32.0, 120.0],
4005 tan_outer: 4.0, tan_inner: 4.0,
4007 margin: 0.0,
4008 focus_z: 101,
4009 }),
4010 ..DdaEnv::default()
4011 };
4012 {
4013 let mut sink = RasterSink::new(&mut fb, &mut zb);
4014 render_dda(&cam, &settings, grid, w as usize, &env, 1, &mut sink);
4015 }
4016 assert_ne!(fb[centre], 0, "mip-1 cut surface must still hit");
4017 assert!(
4018 (zb[centre] - 90.0).abs() < 1.5,
4019 "mip-cell 50 (voxel z=100) must stay visible (floor \
4020 formula); depth {} not ≈ 90",
4021 zb[centre]
4022 );
4023 }
4024
4025 #[test]
4029 fn cutout_disabled_is_byte_identical() {
4030 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
4031 let surf = 28 + ((x / 4 + y / 6) % 13);
4032 (z >= surf).then_some(VoxColor(0x80_40_60_80 + (x ^ y) % 0x30))
4033 });
4034 let grid = GridView::from_single_vxl(&vxl);
4035 let cam = Camera::orbit(0.8, 0.55, 100.0, [32.0, 32.0, 40.0]);
4036 let (w, h) = (96u32, 96u32);
4037 let (fb_base, zb_base) = render_brickmap(grid, &cam, w, h);
4038 let (fb_none, zb_none) = render_cutout(grid, &cam, w, h, None);
4039 assert_eq!(fb_base, fb_none, "cutout=None must not change a pixel");
4040 assert_eq!(zb_base, zb_none);
4041 let no_cone = CpuCutout {
4043 focus_local: [32.0, 32.0, 40.0],
4044 tan_outer: 0.0,
4045 tan_inner: 0.0,
4046 margin: 0.0,
4047 focus_z: i32::MAX,
4048 };
4049 let (fb, zb) = render_cutout(grid, &cam, w, h, Some(no_cone));
4050 assert_eq!(fb_base, fb, "a zero cone must not change a pixel");
4051 assert_eq!(zb_base, zb);
4052 let no_reveal = CpuCutout {
4055 focus_local: [32.0, 32.0, 40.0],
4056 tan_outer: 10.0,
4057 tan_inner: 10.0,
4058 margin: 1.0e6,
4059 focus_z: i32::MAX,
4060 };
4061 let (fb, zb) = render_cutout(grid, &cam, w, h, Some(no_reveal));
4062 assert_eq!(fb_base, fb, "zero reveal must not change a pixel");
4063 assert_eq!(zb_base, zb);
4064 }
4065
4066 #[test]
4071 fn cutout_hidden_roof_still_casts_shadows() {
4072 const ROOF: VoxColor = VoxColor(0x80_A0_50_20);
4073 const FLOOR: VoxColor = VoxColor(0x80_80_80_80);
4074 let roofed = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
4075 if z == 20 {
4076 Some(ROOF)
4077 } else if z >= 60 {
4078 Some(FLOOR)
4079 } else {
4080 None
4081 }
4082 });
4083 let roofless =
4084 roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| (z >= 60).then_some(FLOOR));
4085 let g_roofed = GridView::from_single_vxl(&roofed);
4086 let g_roofless = GridView::from_single_vxl(&roofless);
4087 let cam = Camera {
4088 pos: [32.0, 32.0, 6.0],
4089 right: [1.0, 0.0, 0.0],
4090 down: [0.0, 1.0, 0.0],
4091 forward: [0.0, 0.0, 1.0],
4092 };
4093 let inv = 1.0f32 / 2.0f32.sqrt();
4094 let lights = CpuLights {
4095 enabled: true,
4096 sun: true,
4097 sun_dir: [inv, 0.0, -inv],
4098 sun_color: [1.0; 3],
4099 sun_intensity: 1.0,
4100 sun_casts_shadow: true,
4101 ambient: [0.25; 3],
4102 shadow_strength: 0.8,
4103 shadow_bias: 1.5,
4104 shadow_max_dist: 128.0,
4105 ..CpuLights::default()
4106 };
4107 let (w, h) = (64u32, 64u32);
4108 let cut = CpuCutout {
4112 focus_local: [32.0, 32.0, 46.0],
4113 tan_outer: 10.0,
4114 tan_inner: 10.0,
4115 margin: 1.0,
4116 focus_z: 50,
4117 };
4118 let env_cut = DdaEnv {
4119 lights,
4120 cutout: Some(cut),
4121 ..DdaEnv::default()
4122 };
4123 let env_plain = DdaEnv {
4124 lights,
4125 ..DdaEnv::default()
4126 };
4127 let (fb_cut, zb_cut) = render_brickmap_env(g_roofed, &cam, w, h, &env_cut);
4128 let (fb_ref, zb_ref) = render_brickmap_env(g_roofless, &cam, w, h, &env_plain);
4129 assert_eq!(zb_cut, zb_ref, "the cutout must reveal the same floor");
4131 let sum: fn(&[u32]) -> u64 = |fb| {
4134 fb.iter()
4135 .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
4136 .sum()
4137 };
4138 let (dark, light) = (sum(&fb_cut), sum(&fb_ref));
4139 assert!(
4140 dark < light,
4141 "hidden roof must keep shadowing the floor: cut={dark} roofless={light}"
4142 );
4143 }
4144
4145 #[test]
4149 fn cross_chunk_xy_floor_is_seamless() {
4150 let mk = || {
4151 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
4152 (z >= 20).then_some(VoxColor(0x80_50_50_50))
4153 })
4154 };
4155 let (c0, c1) = (mk(), mk());
4156 let v0 = GridView::from_single_vxl(&c0);
4157 let v1 = GridView::from_single_vxl(&c1);
4158 let chunks = [Some(v0), Some(v1)];
4159 let cg = crate::ChunkGrid {
4160 chunks: &chunks,
4161 origin_chunk_xy: [0, 0],
4162 origin_chunk_z: 0,
4163 chunks_x: 2,
4164 chunks_y: 1,
4165 chunks_z: 1,
4166 };
4167 let grid = GridView::from_chunk_grid(&cg, 32);
4168
4169 let cam = Camera {
4171 pos: [32.0, 16.0, 4.0],
4172 right: [1.0, 0.0, 0.0],
4173 down: [0.0, 1.0, 0.0],
4174 forward: [0.0, 0.0, 1.0],
4175 };
4176 let (w, h) = (64u32, 64u32);
4177 let mask = render_mask(grid, &cam, w, h);
4178 let row = (h / 2) as usize * w as usize;
4181 let left = (0..w as usize / 2).filter(|&x| mask[row + x]).count();
4182 let right = (w as usize / 2..w as usize)
4183 .filter(|&x| mask[row + x])
4184 .count();
4185 assert!(
4186 left > 5 && right > 5,
4187 "seam not continuous: left={left} right={right}"
4188 );
4189 }
4190
4191 fn render_mask_mip(grid: GridView<'_>, camera: &Camera, w: u32, h: u32, mip: u32) -> Vec<bool> {
4194 let n = (w as usize) * (h as usize);
4195 let mut fb = vec![0u32; n];
4196 let mut zb = vec![f32::INFINITY; n];
4197 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
4198 {
4199 let mut sink = RasterSink::new(&mut fb, &mut zb);
4200 render_dda(
4201 camera,
4202 &settings,
4203 grid,
4204 w as usize,
4205 &DdaEnv::default(),
4206 mip,
4207 &mut sink,
4208 );
4209 }
4210 fb.iter().map(|&c| c != 0).collect()
4211 }
4212
4213 #[test]
4219 fn mip_render_is_coarse_but_complete() {
4220 let mut vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
4221 let surf = 24 + ((x / 3 + y / 5) % 17);
4222 (z >= surf).then_some(VoxColor(0x80_50_70_90))
4223 });
4224 vxl.generate_mips(4);
4225 assert!(vxl.mip_count() >= 3, "need mips built for this test");
4226 let grid = GridView::from_single_vxl(&vxl);
4227 let (w, h) = (96u32, 96u32);
4228 let cam = Camera::orbit(0.7, 0.6, 110.0, [32.0, 32.0, 36.0]);
4229
4230 let m0 = render_mask_mip(grid, &cam, w, h, 0);
4231 let m2 = render_mask_mip(grid, &cam, w, h, 2);
4232
4233 let c0 = m0.iter().filter(|&&b| b).count();
4234 let c2 = m2.iter().filter(|&&b| b).count();
4235 assert!(c0 > 200 && c2 > 200, "both mips visible (c0={c0} c2={c2})");
4236 let ratio = c2 as f32 / c0 as f32;
4242 assert!(
4243 (0.7..1.4).contains(&ratio),
4244 "mip-2 coverage {c2} vs mip-0 {c0} (ratio {ratio:.2}) diverged"
4245 );
4246 }
4247
4248 #[test]
4254 #[ignore = "perf benchmark — run explicitly with --ignored"]
4255 fn bench_terrain() {
4256 use std::time::Instant;
4257 const NC: i32 = 6;
4259 let cs = crate::grid_view::CHUNK_SIZE_Z; let _ = cs;
4261 let mut vxls: Vec<roxlap_formats::vxl::Vxl> = Vec::new();
4262 for cy in 0..NC {
4263 for cx in 0..NC {
4264 let (ox, oy) = (cx * 128, cy * 128);
4265 let mut v = roxlap_formats::vxl::Vxl::from_dense(128, |x, y, z| {
4266 let (gx, gy) = (ox + x as i32, oy + y as i32);
4267 let surf = 90 + ((gx / 7 + gy / 9).rem_euclid(40)) + ((gx / 23).rem_euclid(20));
4268 (z as i32 >= surf).then_some(VoxColor(0x80_50_70_90 + (x ^ y) % 0x30))
4269 });
4270 v.generate_mips(4);
4271 vxls.push(v);
4272 }
4273 }
4274 let views: Vec<Option<GridView>> = vxls
4275 .iter()
4276 .map(|v| Some(GridView::from_single_vxl(v)))
4277 .collect();
4278 let cg = crate::ChunkGrid {
4279 chunks: &views,
4280 origin_chunk_xy: [0, 0],
4281 origin_chunk_z: 0,
4282 chunks_x: NC as u32,
4283 chunks_y: NC as u32,
4284 chunks_z: 1,
4285 };
4286 let grid = GridView::from_chunk_grid(&cg, 128);
4287
4288 let (w, h) = (960u32, 600u32);
4289 let mut settings = OpticastSettings::for_oracle_framebuffer(w, h);
4290 settings.max_scan_dist = 512;
4291 let n = (w * h) as usize;
4292 let mut fb = vec![0u32; n];
4293 let mut zb = vec![f32::INFINITY; n];
4294 let centre = [f64::from(NC * 128) / 2.0, f64::from(NC * 128) / 2.0, 60.0];
4295
4296 let poses = [
4299 (
4300 "horizon",
4301 Camera::from_yaw_pitch([20.0, 20.0, 40.0], 0.6, 0.15),
4302 ),
4303 ("down", Camera::orbit(0.7, 1.0, 130.0, centre)),
4304 ];
4305 for (name, cam) in poses {
4306 {
4307 let mut sink = RasterSink::new(&mut fb, &mut zb);
4308 prof::reset();
4309 render_dda(
4310 &cam,
4311 &settings,
4312 grid,
4313 w as usize,
4314 &DdaEnv::default(),
4315 0,
4316 &mut sink,
4317 );
4318 }
4319 let (cells, bricks, surf) = prof::read();
4320 let iters = 6;
4321 let t0 = Instant::now();
4322 for _ in 0..iters {
4323 let mut sink = RasterSink::new(&mut fb, &mut zb);
4324 render_dda(
4325 &cam,
4326 &settings,
4327 grid,
4328 w as usize,
4329 &DdaEnv::default(),
4330 0,
4331 &mut sink,
4332 );
4333 }
4334 let ms = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(iters);
4335 let hits = fb.iter().filter(|&&c| c != 0).count();
4336 eprintln!(
4337 "[{name}] {w}x{h} 1-thread: {ms:.1} ms | hits={hits}/{n} | per-px: cells={:.1} bricks={:.1} surf={:.1}",
4338 cells as f64 / n as f64,
4339 bricks as f64 / n as f64,
4340 surf as f64 / n as f64,
4341 );
4342 }
4343 }
4344
4345 #[test]
4349 fn parallel_matches_sequential() {
4350 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
4351 let surf = 28 + ((x / 4 + y / 6) % 13);
4352 (z >= surf).then_some(VoxColor(0x80_40_60_80 + (x ^ y) % 0x30))
4353 });
4354 let grid = GridView::from_single_vxl(&vxl);
4355 let (w, h) = (96u32, 96u32);
4356 let cam = Camera::orbit(0.8, 0.55, 100.0, [32.0, 32.0, 40.0]);
4357 let env = DdaEnv {
4358 sky: None,
4359 fog_color: 0x00_20_30_40,
4360 fog_max_dist: 120.0,
4361 side_shades: [0, 0, 0, 0, 0x30, 0x10],
4362 materials: None,
4363 terrain_materials: &[],
4364 lights: CpuLights::default(),
4365 world_shadow: None,
4366 z_clip: None,
4367 cutout: None,
4368 fow: None,
4369 };
4370
4371 let (seq_fb, seq_zb) = render_brickmap_env(grid, &cam, w, h, &env);
4372
4373 let n = (w * h) as usize;
4374 let mut par_fb = vec![0u32; n];
4375 let mut par_zb = vec![f32::INFINITY; n];
4376 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
4377 let (cache, mip) = local_cache(&grid, 0);
4378 render_dda_parallel(
4379 &cam,
4380 &settings,
4381 grid,
4382 &mut par_fb,
4383 &mut par_zb,
4384 w as usize,
4385 &env,
4386 &cache,
4387 mip,
4388 );
4389 assert!(par_fb == seq_fb, "parallel colour differs from sequential");
4390 assert!(
4391 par_zb
4392 .iter()
4393 .zip(&seq_zb)
4394 .all(|(a, b)| a.to_bits() == b.to_bits()),
4395 "parallel depth differs from sequential"
4396 );
4397 }
4398
4399 #[test]
4405 fn cliff_side_is_solid_not_see_through() {
4406 const TOP_Z: u32 = 50;
4407 const COL: VoxColor = VoxColor(0x80_77_88_99);
4408 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |_, _, z| (z >= TOP_Z).then_some(COL));
4409 let grid = GridView::from_single_vxl(&vxl);
4410
4411 assert_eq!(grid.voxel_color(4, 4, TOP_Z), Some(COL));
4413 assert_eq!(grid.voxel_color(4, 4, 150), None);
4415 assert_eq!(grid.surface_color(4, 4, 150), Some(COL));
4418 assert_eq!(grid.surface_color(4, 4, 10), None);
4420 }
4421
4422 #[test]
4425 fn camera_inside_solid_hits_everywhere() {
4426 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, _| Some(VoxColor(0x80_55_55_55)));
4427 let grid = GridView::from_single_vxl(&vxl);
4428 let cam = Camera {
4429 pos: [8.0, 8.0, 128.0],
4430 right: [1.0, 0.0, 0.0],
4431 down: [0.0, 1.0, 0.0],
4432 forward: [0.0, 0.0, 1.0],
4433 };
4434 let (w, h) = (32u32, 32u32);
4435 let mask = render_mask(grid, &cam, w, h);
4436 assert!(
4437 mask.iter().all(|&b| b),
4438 "every ray must hit when the camera is inside solid"
4439 );
4440 }
4441
4442 #[test]
4448 fn single_voxel_silhouette_has_no_notch() {
4449 const C: VoxColor = VoxColor(0x80_FF_80_40);
4450 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |x, y, z| {
4451 (x == 8 && y == 8 && z == 8).then_some(C)
4452 });
4453 let grid = GridView::from_single_vxl(&vxl);
4454
4455 let cam = Camera::orbit(0.7, 0.6, 4.0, [8.5, 8.5, 8.5]);
4458 let (w, h) = (96u32, 96u32);
4459 let mask = render_mask(grid, &cam, w, h);
4460
4461 let hits = mask.iter().filter(|&&b| b).count();
4462 assert!(
4463 hits > 30,
4464 "silhouette too small to be meaningful: {hits} px"
4465 );
4466 assert!(
4467 rows_have_no_holes(&mask, w, h),
4468 "row-interior gap in single-voxel silhouette (notch)"
4469 );
4470 assert!(
4471 cols_have_no_holes(&mask, w, h),
4472 "column-interior gap in single-voxel silhouette (notch)"
4473 );
4474 }
4475
4476 struct FixedStyler(FowVerdict);
4479 impl FowStyler for FixedStyler {
4480 fn verdict(&self, _x: i32, _y: i32, _z: i32) -> FowVerdict {
4481 self.0
4482 }
4483 }
4484
4485 #[test]
4486 fn fow_style_dims_and_desaturates() {
4487 assert_eq!(fow_style(0x80_40_80_C0, 1.0, 0.0), 0x80_40_80_C0);
4489 assert_eq!(fow_style(0x80_40_80_C0, 0.5, 0.0), 0x80_20_40_60);
4491 let g = fow_style(0x80_40_80_C0, 1.0, 1.0);
4493 assert_eq!((g >> 16) & 0xff, g & 0xff);
4494 assert_eq!((g >> 8) & 0xff, g & 0xff);
4495 assert_eq!(g & 0xff00_0000, 0x8000_0000);
4496 }
4497
4498 fn floor_scene() -> (roxlap_formats::vxl::Vxl, Camera, u32, u32) {
4501 let vxl = roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
4502 (z >= 20).then_some(VoxColor(0x80_40_80_C0))
4503 });
4504 let cam = Camera {
4505 pos: [16.0, 16.0, 5.0],
4506 right: [1.0, 0.0, 0.0],
4507 down: [0.0, 1.0, 0.0],
4508 forward: [0.0, 0.0, 1.0],
4509 };
4510 (vxl, cam, 48, 48)
4511 }
4512
4513 #[test]
4514 fn fow_hide_makes_geometry_vanish() {
4515 let (vxl, cam, w, h) = floor_scene();
4516 let grid = GridView::from_single_vxl(&vxl);
4517 let (base, _) = render_brickmap(grid, &cam, w, h);
4518 assert!(base.iter().any(|&p| p != 0), "baseline must draw the floor");
4519
4520 let styler = FixedStyler(FowVerdict::Hide);
4521 let env = DdaEnv {
4522 fow: Some(&styler),
4523 ..DdaEnv::default()
4524 };
4525 let (hidden, _) = render_brickmap_env(grid, &cam, w, h, &env);
4526 assert!(
4527 hidden.iter().all(|&p| p == 0),
4528 "every Hide cell must read as air (sky)"
4529 );
4530 }
4531
4532 #[test]
4533 fn fow_dim_desaturate_matches_helper() {
4534 let (vxl, cam, w, h) = floor_scene();
4535 let grid = GridView::from_single_vxl(&vxl);
4536 let (base, _) = render_brickmap(grid, &cam, w, h);
4537
4538 let styler = FixedStyler(FowVerdict::Show {
4539 dynamic: true,
4540 dim: 0.5,
4541 desaturate: 0.4,
4542 });
4543 let env = DdaEnv {
4544 fow: Some(&styler),
4545 ..DdaEnv::default()
4546 };
4547 let (styled, _) = render_brickmap_env(grid, &cam, w, h, &env);
4548 for k in 0..(w * h) as usize {
4549 if base[k] != 0 {
4550 assert_eq!(styled[k], fow_style(base[k], 0.5, 0.4), "pixel {k}");
4551 }
4552 }
4553 }
4554
4555 #[test]
4559 fn fow_memory_skips_dynamic_lights() {
4560 let (vxl, cam, w, h) = floor_scene();
4561 let grid = GridView::from_single_vxl(&vxl);
4562 let pt = [CpuPointLight {
4565 pos: [16.0, 16.0, 18.0],
4566 color: [1.0, 0.0, 0.0],
4567 intensity: 5.0,
4568 radius: 40.0,
4569 casts_shadow: false,
4570 spot_dir: [0.0, 0.0, 0.0],
4571 cos_inner: -1.0,
4572 cos_outer: -1.0,
4573 }];
4574 let lights = CpuLights {
4575 enabled: true,
4576 points: &pt,
4577 ..CpuLights::default()
4578 };
4579 let (baked, _) = render_brickmap(grid, &cam, w, h);
4581 let lit_env = DdaEnv {
4583 lights,
4584 ..DdaEnv::default()
4585 };
4586 let (lit, _) = render_brickmap_env(grid, &cam, w, h, &lit_env);
4587 assert_ne!(lit, baked, "the sun rig must change the floor");
4588
4589 let styler = FixedStyler(FowVerdict::Show {
4591 dynamic: false,
4592 dim: 1.0,
4593 desaturate: 0.0,
4594 });
4595 let mem_env = DdaEnv {
4596 lights,
4597 fow: Some(&styler),
4598 ..DdaEnv::default()
4599 };
4600 let (mem, _) = render_brickmap_env(grid, &cam, w, h, &mem_env);
4601 assert_eq!(mem, baked, "memory must skip the dynamic rig (baked look)");
4602 }
4603
4604 struct HideColumnStyler {
4607 wall_x: i32,
4608 }
4609 impl FowStyler for HideColumnStyler {
4610 fn verdict(&self, x: i32, _y: i32, _z: i32) -> FowVerdict {
4611 if x == self.wall_x {
4612 FowVerdict::Hide
4613 } else {
4614 FowVerdict::LIVE
4615 }
4616 }
4617 }
4618
4619 #[test]
4624 fn fow_hidden_wall_casts_no_shadow() {
4625 const FLOOR: VoxColor = VoxColor(0x80_80_80_80);
4626 const WALL: VoxColor = VoxColor(0x80_40_40_40);
4627 let wall_x = 16;
4628 let vxl = roxlap_formats::vxl::Vxl::from_dense(32, |x, y, z| {
4630 if z >= 30 {
4631 Some(FLOOR)
4632 } else if x as i32 == wall_x && y == 16 && (18..30).contains(&z) {
4633 Some(WALL)
4634 } else {
4635 None
4636 }
4637 });
4638 let grid = GridView::from_single_vxl(&vxl);
4639 let cam = Camera {
4640 pos: [16.0, 16.0, 4.0],
4641 right: [1.0, 0.0, 0.0],
4642 down: [0.0, 1.0, 0.0],
4643 forward: [0.0, 0.0, 1.0],
4644 };
4645 let lights = CpuLights {
4647 enabled: true,
4648 sun: true,
4649 sun_dir: [-0.7, 0.0, -0.7],
4650 sun_color: [1.0, 1.0, 1.0],
4651 sun_intensity: 1.0,
4652 sun_casts_shadow: true,
4653 shadow_strength: 1.0,
4654 shadow_bias: 1.5,
4655 shadow_max_dist: 64.0,
4656 ..CpuLights::default()
4657 };
4658 let (w, h) = (48u32, 48u32);
4659 let live = HideColumnStyler { wall_x: -999 };
4661 let env_a = DdaEnv {
4662 lights,
4663 fow: Some(&live),
4664 ..DdaEnv::default()
4665 };
4666 let (fb_a, _) = render_brickmap_env(grid, &cam, w, h, &env_a);
4667 let hide = HideColumnStyler { wall_x };
4669 let env_b = DdaEnv {
4670 lights,
4671 fow: Some(&hide),
4672 ..DdaEnv::default()
4673 };
4674 let (fb_b, _) = render_brickmap_env(grid, &cam, w, h, &env_b);
4675
4676 let lum = |c: u32| ((c >> 16) & 0xff) + ((c >> 8) & 0xff) + (c & 0xff);
4677 let total: u64 = (0..(w * h) as usize)
4678 .map(|k| u64::from(lum(fb_b[k])).saturating_sub(u64::from(lum(fb_a[k]))))
4679 .sum();
4680 assert!(
4683 total > 0,
4684 "hiding the wall must lift its shadow off the floor (Δlum {total})"
4685 );
4686 }
4687
4688 #[test]
4693 fn fow_memory_keeps_emissive() {
4694 const GLOW: VoxColor = VoxColor(0x80_20_FF_80);
4695 let vxl = roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= 20).then_some(GLOW));
4696 let grid = GridView::from_single_vxl(&vxl);
4697 let cam = Camera {
4698 pos: [16.0, 16.0, 5.0],
4699 right: [1.0, 0.0, 0.0],
4700 down: [0.0, 1.0, 0.0],
4701 forward: [0.0, 0.0, 1.0],
4702 };
4703 let mut table = MaterialTable::new();
4704 table.set(1, roxlap_formats::material::Material::glow(255));
4705 let terrain = [(GLOW.rgb_part(), 1u8)];
4706 let (w, h) = (48u32, 48u32);
4707
4708 let live = FixedStyler(FowVerdict::LIVE);
4710 let env_live = DdaEnv {
4711 materials: Some(&table),
4712 terrain_materials: &terrain,
4713 fow: Some(&live),
4714 ..DdaEnv::default()
4715 };
4716 let (fb_live, _) = render_brickmap_env(grid, &cam, w, h, &env_live);
4717 let mem = FixedStyler(FowVerdict::Show {
4719 dynamic: false,
4720 dim: 1.0,
4721 desaturate: 0.0,
4722 });
4723 let env_mem = DdaEnv {
4724 materials: Some(&table),
4725 terrain_materials: &terrain,
4726 fow: Some(&mem),
4727 ..DdaEnv::default()
4728 };
4729 let (fb_mem, _) = render_brickmap_env(grid, &cam, w, h, &env_mem);
4730 assert_eq!(
4731 fb_mem, fb_live,
4732 "memory must keep the emissive glow (dim 1 == the live emissive look)"
4733 );
4734 let baked = FixedStyler(FowVerdict::Show {
4737 dynamic: false,
4738 dim: 1.0,
4739 desaturate: 0.0,
4740 });
4741 let env_baked = DdaEnv {
4742 fow: Some(&baked),
4743 ..DdaEnv::default()
4744 };
4745 let (fb_baked, _) = render_brickmap_env(grid, &cam, w, h, &env_baked);
4746 let lum = |c: u32| ((c >> 16) & 0xff) + ((c >> 8) & 0xff) + (c & 0xff);
4747 let centre = (24 * 48 + 24) as usize;
4748 assert!(
4749 lum(fb_mem[centre]) > lum(fb_baked[centre]),
4750 "emissive memory ({:08x}) must be brighter than baked ({:08x})",
4751 fb_mem[centre],
4752 fb_baked[centre]
4753 );
4754 }
4755
4756 #[test]
4758 fn fow_disabled_byte_identical() {
4759 let (vxl, cam, w, h) = floor_scene();
4760 let grid = GridView::from_single_vxl(&vxl);
4761 let (base, _) = render_brickmap(grid, &cam, w, h);
4762 let styler = FixedStyler(FowVerdict::LIVE);
4764 let env = DdaEnv {
4765 fow: Some(&styler),
4766 ..DdaEnv::default()
4767 };
4768 let (live, _) = render_brickmap_env(grid, &cam, w, h, &env);
4769 assert_eq!(live, base, "a fully-visible cell must be bit-identical");
4770 }
4771}