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};
46
47#[derive(Clone, Copy)]
54pub struct DdaEnv<'a> {
55 pub sky: Option<&'a Sky>,
58 pub fog_color: u32,
61 pub fog_max_dist: f32,
63 pub side_shades: [i8; 6],
66 pub materials: Option<&'a MaterialTable>,
69 pub terrain_materials: &'a [(u32, u8)],
73 pub lights: CpuLights<'a>,
79 pub world_shadow: Option<WorldShadowCtx<'a>>,
84}
85
86#[derive(Clone, Copy)]
88pub struct CpuPointLight {
89 pub pos: [f32; 3],
91 pub color: [f32; 3],
93 pub intensity: f32,
94 pub radius: f32,
96 pub casts_shadow: bool,
101 pub spot_dir: [f32; 3],
104 pub cos_inner: f32,
106 pub cos_outer: f32,
110}
111
112#[derive(Clone, Copy, Default)]
118pub struct CpuLights<'a> {
119 pub enabled: bool,
121 pub sun: bool,
123 pub sun_dir: [f32; 3],
125 pub sun_color: [f32; 3],
126 pub sun_intensity: f32,
127 pub sun_casts_shadow: bool,
129 pub points: &'a [CpuPointLight],
131 pub ambient: [f32; 3],
133 pub bands: u32,
135 pub shadow_tint: [f32; 3],
137 pub shadow_strength: f32,
141 pub shadow_bias: f32,
144 pub shadow_max_dist: f32,
147}
148
149impl Default for DdaEnv<'_> {
150 fn default() -> Self {
151 Self {
152 sky: None,
153 fog_color: 0,
154 fog_max_dist: 0.0,
155 side_shades: [0; 6],
156 materials: None,
157 terrain_materials: &[],
158 lights: CpuLights::default(),
159 world_shadow: None,
160 }
161 }
162}
163
164pub trait PixelSink {
172 fn put(&mut self, idx: usize, color: u32, dist: f32);
176}
177
178pub struct RasterSink<'a> {
185 target: RasterTarget<'a>,
186 len: usize,
187}
188
189impl<'a> RasterSink<'a> {
190 #[must_use]
193 pub fn new(framebuffer: &'a mut [u32], zbuffer: &'a mut [f32]) -> Self {
194 debug_assert_eq!(framebuffer.len(), zbuffer.len());
195 let len = framebuffer.len();
196 Self {
197 target: RasterTarget::new(framebuffer, zbuffer),
198 len,
199 }
200 }
201}
202
203impl PixelSink for RasterSink<'_> {
204 fn put(&mut self, idx: usize, color: u32, dist: f32) {
205 if idx < self.len {
206 unsafe {
209 self.target.write_color(idx, color);
210 self.target.write_depth(idx, dist);
211 }
212 }
213 }
214}
215
216#[derive(Debug, Clone, Copy)]
218struct Hit {
219 color: u32,
220 dist: f32,
221}
222
223#[cfg(test)]
225pub(crate) mod prof {
226 use std::cell::Cell;
227 thread_local! {
228 pub static CELLS: Cell<u64> = const { Cell::new(0) };
229 pub static BRICKS: Cell<u64> = const { Cell::new(0) };
230 pub static SURF: Cell<u64> = const { Cell::new(0) };
231 }
232 pub fn reset() {
233 CELLS.with(|x| x.set(0));
234 BRICKS.with(|x| x.set(0));
235 SURF.with(|x| x.set(0));
236 }
237 pub fn read() -> (u64, u64, u64) {
238 (
239 CELLS.with(Cell::get),
240 BRICKS.with(Cell::get),
241 SURF.with(Cell::get),
242 )
243 }
244}
245
246#[inline]
265pub(crate) fn shade(color: u32, bright_sub: u32) -> u32 {
266 let a = ((color >> 24) & 0xff).saturating_sub(bright_sub);
267 let ch = |shift: u32| -> u32 { ((((color >> shift) & 0xff) * a) >> 7).min(255) };
268 0x8000_0000 | (ch(16) << 16) | (ch(8) << 8) | ch(0)
269}
270
271#[inline]
273fn cel_band(x: f32, bands: u32) -> f32 {
274 let b = bands as f32;
275 ((x * b).round() / b).clamp(0.0, 1.0)
276}
277
278#[inline]
281fn point_falloff(d: f32, radius: f32) -> f32 {
282 let x = (1.0 - d / radius).clamp(0.0, 1.0);
283 x * x
284}
285
286#[inline]
289fn smoothstep_scalar(edge0: f32, edge1: f32, x: f32) -> f32 {
290 if edge1 <= edge0 {
291 return if x < edge0 { 0.0 } else { 1.0 };
292 }
293 let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
294 t * t * (3.0 - 2.0 * t)
295}
296
297#[inline]
303fn spot_cone(ldir: [f32; 3], axis: [f32; 3], cos_inner: f32, cos_outer: f32) -> f32 {
304 if cos_outer <= -0.999 {
305 return 1.0;
306 }
307 let cd = -dot3(ldir, axis);
308 smoothstep_scalar(cos_outer, cos_inner, cd)
309}
310
311#[inline]
315fn face_normal_cpu(axis: usize, step: [i32; 3]) -> [f32; 3] {
316 let mut n = [0.0f32; 3];
317 if axis < 3 {
318 n[axis] = -(step[axis] as f32);
319 } else {
320 n[2] = -1.0;
321 }
322 n
323}
324
325#[inline]
326fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
327 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
328}
329
330pub(crate) trait ShadowTester {
338 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool;
339}
340
341pub trait WorldOccluder: Sync {
352 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool;
353}
354
355#[derive(Clone, Copy)]
362pub struct WorldShadowCtx<'a> {
363 pub occluder: &'a dyn WorldOccluder,
364 pub origin: [f32; 3],
365 pub cols: [[f32; 3]; 3],
366}
367
368impl<'a> WorldShadowCtx<'a> {
369 #[must_use]
372 pub fn identity(occluder: &'a dyn WorldOccluder) -> Self {
373 Self {
374 occluder,
375 origin: [0.0; 3],
376 cols: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
377 }
378 }
379}
380
381pub struct CompositeOccluder<'a> {
385 pub a: &'a dyn WorldOccluder,
386 pub b: &'a dyn WorldOccluder,
387}
388
389impl WorldOccluder for CompositeOccluder<'_> {
390 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
391 self.a.occluded_world(origin, dir, max_t) || self.b.occluded_world(origin, dir, max_t)
392 }
393}
394
395pub(crate) struct WorldShadow<'a> {
400 pub ctx: WorldShadowCtx<'a>,
401}
402
403impl ShadowTester for WorldShadow<'_> {
404 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
405 let c = &self.ctx.cols;
406 let wo = [
408 self.ctx.origin[0] + c[0][0] * origin[0] + c[1][0] * origin[1] + c[2][0] * origin[2],
409 self.ctx.origin[1] + c[0][1] * origin[0] + c[1][1] * origin[1] + c[2][1] * origin[2],
410 self.ctx.origin[2] + c[0][2] * origin[0] + c[1][2] * origin[1] + c[2][2] * origin[2],
411 ];
412 let wd = [
413 c[0][0] * dir[0] + c[1][0] * dir[1] + c[2][0] * dir[2],
414 c[0][1] * dir[0] + c[1][1] * dir[1] + c[2][1] * dir[2],
415 c[0][2] * dir[0] + c[1][2] * dir[1] + c[2][2] * dir[2],
416 ];
417 self.ctx.occluder.occluded_world(wo, wd, max_t)
418 }
419}
420
421fn shade_lit_cpu(
428 color: u32,
429 bright_sub: u32,
430 axis: usize,
431 step: [i32; 3],
432 cellc: [i32; 3],
433 cell_size: f32,
434 l: &CpuLights<'_>,
435 shadow: Option<&mut dyn ShadowTester>,
436) -> u32 {
437 let a_b = ((color >> 24) & 0xff).saturating_sub(bright_sub);
438 let ao = a_b as f32 / 128.0;
439 let albedo = [
440 ((color >> 16) & 0xff) as f32 / 255.0,
441 ((color >> 8) & 0xff) as f32 / 255.0,
442 (color & 0xff) as f32 / 255.0,
443 ];
444 let n = face_normal_cpu(axis, step);
445 let center = [
447 (cellc[0] as f32 + 0.5) * cell_size,
448 (cellc[1] as f32 + 0.5) * cell_size,
449 (cellc[2] as f32 + 0.5) * cell_size,
450 ];
451 shade_dynamic(albedo, ao, n, center, l, shadow)
452}
453
454pub(crate) fn shade_dynamic(
460 albedo: [f32; 3],
461 ao: f32,
462 n: [f32; 3],
463 sample: [f32; 3],
464 l: &CpuLights<'_>,
465 shadow: Option<&mut dyn ShadowTester>,
466) -> u32 {
467 let styled = l.bands > 0;
468 let mut shadow = shadow;
472 let shadow_origin = [
473 sample[0] + n[0] * l.shadow_bias,
474 sample[1] + n[1] * l.shadow_bias,
475 sample[2] + n[2] * l.shadow_bias,
476 ];
477 let in_shadow = 1.0 - l.shadow_strength;
478
479 let sun_key = if l.sun {
481 let ndl = dot3(n, l.sun_dir).max(0.0);
482 if ndl > 0.0 && l.sun_casts_shadow {
483 let occ = shadow
484 .as_deref_mut()
485 .is_some_and(|s| s.occluded(shadow_origin, l.sun_dir, l.shadow_max_dist));
486 if occ {
487 ndl * in_shadow
488 } else {
489 ndl
490 }
491 } else {
492 ndl
493 }
494 } else {
495 0.0
496 };
497
498 let mut lit = if styled {
500 let key = cel_band(sun_key, l.bands);
501 let m = |i: usize| {
502 let warm = l.sun_color[i] * l.sun_intensity;
503 (l.shadow_tint[i] + (warm - l.shadow_tint[i]) * key) * ao
504 };
505 [albedo[0] * m(0), albedo[1] * m(1), albedo[2] * m(2)]
506 } else {
507 let base = |i: usize| {
508 albedo[i] * l.ambient[i] * ao + albedo[i] * l.sun_color[i] * l.sun_intensity * sun_key
509 };
510 [base(0), base(1), base(2)]
511 };
512
513 for p in l.points {
516 let d3 = [
517 p.pos[0] - sample[0],
518 p.pos[1] - sample[1],
519 p.pos[2] - sample[2],
520 ];
521 let d2 = d3[0] * d3[0] + d3[1] * d3[1] + d3[2] * d3[2];
524 if d2 < p.radius * p.radius && d2 > 1e-8 {
525 let dist = d2.sqrt();
526 let inv = 1.0 / dist;
527 let ldir = [d3[0] * inv, d3[1] * inv, d3[2] * inv];
528 let ndl = dot3(n, ldir).max(0.0);
529 let cone = spot_cone(ldir, p.spot_dir, p.cos_inner, p.cos_outer);
532 if ndl > 0.0 && cone > 0.0 {
533 let sh = if p.casts_shadow
535 && shadow
536 .as_deref_mut()
537 .is_some_and(|s| s.occluded(shadow_origin, ldir, dist))
538 {
539 in_shadow
540 } else {
541 1.0
542 };
543 let mut f = ndl * point_falloff(dist, p.radius) * cone * sh;
544 if styled {
545 f = cel_band(f, l.bands);
546 }
547 for i in 0..3 {
548 lit[i] += albedo[i] * p.color[i] * p.intensity * f;
549 }
550 }
551 }
552 }
553
554 let pack = |v: f32| -> u32 { (v.clamp(0.0, 1.0) * 255.0) as u32 };
555 0x8000_0000 | (pack(lit[0]) << 16) | (pack(lit[1]) << 8) | pack(lit[2])
556}
557
558#[inline]
562fn apply_fog(color: u32, depth: f32, env: &DdaEnv<'_>) -> u32 {
563 if env.fog_max_dist <= 0.0 {
564 return color;
565 }
566 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
567 let f = ((depth / env.fog_max_dist).clamp(0.0, 1.0) * 256.0) as u32; let g = 256 - f;
569 let fog = env.fog_color;
570 let mix = |shift: u32| -> u32 {
571 let src = (color >> shift) & 0xff;
572 let dst = (fog >> shift) & 0xff;
573 ((src * g + dst * f) >> 8).min(255)
574 };
575 0x8000_0000 | (mix(16) << 16) | (mix(8) << 8) | mix(0)
576}
577
578#[inline]
581fn composite_over(accum: [f32; 3], trans: f32, bg: u32) -> u32 {
582 let b = rgb_to_f32(bg);
583 f32_to_rgb([
584 accum[0] + trans * b[0],
585 accum[1] + trans * b[1],
586 accum[2] + trans * b[2],
587 ])
588}
589
590#[inline]
595fn finalize_exit(
596 touched: bool,
597 accum: [f32; 3],
598 trans: f32,
599 env: &DdaEnv<'_>,
600 dir: [f32; 3],
601 dist: f32,
602) -> Option<Hit> {
603 if !touched {
604 return None;
605 }
606 let bg = match env.sky {
607 Some(s) => sample_sky(s, dir),
608 None => 0x8000_0000 | (env.fog_color & 0x00ff_ffff),
609 };
610 Some(Hit {
611 color: composite_over(accum, trans, bg),
612 dist,
613 })
614}
615
616#[inline]
619#[allow(clippy::cast_precision_loss)]
620fn rgb_to_f32(c: u32) -> [f32; 3] {
621 [
622 ((c >> 16) & 0xff) as f32 / 255.0,
623 ((c >> 8) & 0xff) as f32 / 255.0,
624 (c & 0xff) as f32 / 255.0,
625 ]
626}
627
628#[inline]
630#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
631fn f32_to_rgb(c: [f32; 3]) -> u32 {
632 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
633 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
634}
635
636#[allow(
645 clippy::cast_possible_truncation,
646 clippy::cast_sign_loss,
647 clippy::cast_precision_loss
648)]
649fn sample_sky(sky: &Sky, dir: [f32; 3]) -> u32 {
650 let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
651 if len < 1e-9 {
652 return 0x8000_0000;
653 }
654 let d = [dir[0] / len, dir[1] / len, dir[2] / len];
655 let xsiz_full = sky.lat.len().max(1) as i32; let pi = std::f32::consts::PI;
657 let elev01 = (-d[2]).clamp(-1.0, 1.0).acos() / pi; let x = (elev01 * xsiz_full as f32) as i32;
662 let x = x.clamp(0, xsiz_full - 1);
663 let y = if sky.ysiz <= 1 {
665 0
666 } else {
667 let az = d[1].atan2(d[0]); let yf = ((az / (pi * 2.0)) + 0.5) * sky.ysiz as f32;
669 (yf as i32).rem_euclid(sky.ysiz)
670 };
671 let idx = (y * xsiz_full + x) as usize;
672 let px = sky.pixels.get(idx).copied().unwrap_or(0) as u32;
673 0x8000_0000 | (px & 0x00ff_ffff)
674}
675
676#[allow(clippy::cast_possible_truncation)]
686pub fn render_sky_fill(
687 fb: &mut [u32],
688 zb: &[f32],
689 pitch_pixels: usize,
690 width: u32,
691 height: u32,
692 cam: &CameraState,
693 settings: &OpticastSettings,
694 sky: &Sky,
695) {
696 fb.par_chunks_mut(pitch_pixels)
701 .take(height as usize)
702 .enumerate()
703 .for_each(|(py, frow)| {
704 let row = py * pitch_pixels;
705 #[allow(clippy::cast_possible_truncation)]
706 let py = py as u32;
707 for px in 0..width {
708 let idx = row + px as usize;
709 if zb[idx].is_finite() {
710 continue; }
712 let (_origin, dir) = pixel_ray(cam, settings, px, py);
713 frow[px as usize] = sample_sky(sky, dir);
714 }
715 });
716}
717
718#[must_use]
730pub fn pixel_ray(
731 cs: &CameraState,
732 settings: &OpticastSettings,
733 px: u32,
734 py: u32,
735) -> ([f32; 3], [f32; 3]) {
736 #[allow(clippy::cast_precision_loss)]
738 let sx = px as f32 - settings.hx;
739 #[allow(clippy::cast_precision_loss)]
740 let sy = py as f32 - settings.hy;
741 let dir = [
742 sx * cs.right[0] + sy * cs.down[0] + settings.hz * cs.forward[0],
743 sx * cs.right[1] + sy * cs.down[1] + settings.hz * cs.forward[1],
744 sx * cs.right[2] + sy * cs.down[2] + settings.hz * cs.forward[2],
745 ];
746 (cs.pos, dir)
747}
748
749pub(crate) fn intersect_aabb(
755 o: [f32; 3],
756 dir: [f32; 3],
757 lo: [f32; 3],
758 hi: [f32; 3],
759) -> Option<(f32, f32)> {
760 let mut t0 = 0.0f32;
761 let mut t1 = f32::INFINITY;
762 for a in 0..3 {
763 if dir[a].abs() < 1e-9 {
764 if o[a] < lo[a] || o[a] > hi[a] {
766 return None;
767 }
768 } else {
769 let inv = 1.0 / dir[a];
770 let mut ta = (lo[a] - o[a]) * inv;
771 let mut tb = (hi[a] - o[a]) * inv;
772 if ta > tb {
773 core::mem::swap(&mut ta, &mut tb);
774 }
775 t0 = t0.max(ta);
776 t1 = t1.min(tb);
777 if t0 > t1 {
778 return None;
779 }
780 }
781 }
782 Some((t0, t1))
783}
784
785const BRICK: i32 = 8;
787
788#[derive(Debug)]
802pub(crate) struct BrickMap {
803 nb: [i32; 3],
805 bits: Vec<u64>,
808 ns: [i32; 3],
811 super_bits: Vec<u64>,
816}
817
818const SUPER: i32 = BRICK * BRICK;
820
821impl BrickMap {
822 #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
825 fn build(grid: &GridView<'_>, mip: u32) -> Self {
826 let vsid_m = (grid.vsid >> mip).max(1) as i32;
827 let z_m = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1) as i32;
828 let nb = [
829 (vsid_m + BRICK - 1) / BRICK,
830 (vsid_m + BRICK - 1) / BRICK,
831 (z_m + BRICK - 1) / BRICK,
832 ];
833 let ns = [
834 (nb[0] + BRICK - 1) / BRICK,
835 (nb[1] + BRICK - 1) / BRICK,
836 (nb[2] + BRICK - 1) / BRICK,
837 ];
838 let count = (nb[0] * nb[1] * nb[2]) as usize;
839 let scount = (ns[0] * ns[1] * ns[2]) as usize;
840 let mut bits = vec![0u64; count.div_ceil(64)];
841 let mut super_bits = vec![0u64; scount.div_ceil(64)];
842 for y in 0..vsid_m {
843 for x in 0..vsid_m {
844 let (bx, by) = (x / BRICK, y / BRICK);
845 grid.for_each_run_mip(x as u32, y as u32, mip, |top, bot| {
846 for bz in (top / BRICK)..=((bot - 1) / BRICK) {
847 let idx = ((bz * nb[1] + by) * nb[0] + bx) as usize;
848 bits[idx / 64] |= 1u64 << (idx % 64);
849 let sidx =
850 (((bz / BRICK) * ns[1] + by / BRICK) * ns[0] + bx / BRICK) as usize;
851 super_bits[sidx / 64] |= 1u64 << (sidx % 64);
852 }
853 });
854 }
855 }
856 Self {
857 nb,
858 bits,
859 ns,
860 super_bits,
861 }
862 }
863
864 #[inline]
866 #[allow(clippy::cast_sign_loss)]
867 fn occupied(&self, b: [i32; 3]) -> bool {
868 if b[0] < 0
869 || b[0] >= self.nb[0]
870 || b[1] < 0
871 || b[1] >= self.nb[1]
872 || b[2] < 0
873 || b[2] >= self.nb[2]
874 {
875 return false;
876 }
877 let idx = ((b[2] * self.nb[1] + b[1]) * self.nb[0] + b[0]) as usize;
878 (self.bits[idx / 64] >> (idx % 64)) & 1 != 0
879 }
880
881 #[inline]
883 #[allow(clippy::cast_sign_loss)]
884 fn occupied_super(&self, s: [i32; 3]) -> bool {
885 if s[0] < 0
886 || s[0] >= self.ns[0]
887 || s[1] < 0
888 || s[1] >= self.ns[1]
889 || s[2] < 0
890 || s[2] >= self.ns[2]
891 {
892 return false;
893 }
894 let idx = ((s[2] * self.ns[1] + s[1]) * self.ns[0] + s[0]) as usize;
895 (self.super_bits[idx / 64] >> (idx % 64)) & 1 != 0
896 }
897}
898
899pub(crate) fn dda_setup(
905 origin: [f32; 3],
906 dir: [f32; 3],
907 cell: [i32; 3],
908 cell_size: f32,
909) -> ([i32; 3], [f32; 3], [f32; 3]) {
910 let mut step = [0i32; 3];
911 let mut t_max = [f32::INFINITY; 3];
912 let mut t_delta = [f32::INFINITY; 3];
913 for a in 0..3 {
914 if dir[a] > 1e-9 {
915 step[a] = 1;
916 #[allow(clippy::cast_precision_loss)]
917 let boundary = (cell[a] + 1) as f32 * cell_size;
918 t_max[a] = (boundary - origin[a]) / dir[a];
919 t_delta[a] = cell_size / dir[a];
920 } else if dir[a] < -1e-9 {
921 step[a] = -1;
922 #[allow(clippy::cast_precision_loss)]
923 let boundary = cell[a] as f32 * cell_size;
924 t_max[a] = (boundary - origin[a]) / dir[a];
925 t_delta[a] = -cell_size / dir[a];
926 }
927 }
928 (step, t_max, t_delta)
929}
930
931#[inline]
934pub(crate) fn min_axis(t_max: [f32; 3]) -> usize {
935 if t_max[0] <= t_max[1] && t_max[0] <= t_max[2] {
936 0
937 } else if t_max[1] <= t_max[2] {
938 1
939 } else {
940 2
941 }
942}
943
944#[derive(Debug, Default)]
954pub struct BrickCache {
955 maps: HashMap<(i32, i32, i32, u32), (u64, BrickMap)>,
956}
957
958impl BrickCache {
959 #[must_use]
960 pub fn new() -> Self {
961 Self::default()
962 }
963
964 pub fn ensure(&mut self, chunk: [i32; 3], mip: u32, version: u64, view: &GridView<'_>) {
967 let key = (chunk[0], chunk[1], chunk[2], mip);
968 let stale = self.maps.get(&key).map_or(true, |(v, _)| *v != version);
969 if stale {
970 self.maps.insert(key, (version, BrickMap::build(view, mip)));
971 }
972 }
973
974 #[inline]
975 fn get(&self, chunk: [i32; 3], mip: u32) -> Option<&BrickMap> {
976 self.maps
977 .get(&(chunk[0], chunk[1], chunk[2], mip))
978 .map(|(_, m)| m)
979 }
980
981 pub fn retain_chunks(&mut self, keep: impl Fn([i32; 3]) -> bool) {
984 self.maps.retain(|k, _| keep([k.0, k.1, k.2]));
985 }
986
987 #[must_use]
993 pub fn brick_occupied_at(&self, chunk: [i32; 3], mip: u32, cell: [i32; 3]) -> Option<bool> {
994 self.get(chunk, mip)
995 .map(|m| m.occupied([cell[0] >> 3, cell[1] >> 3, cell[2] >> 3]))
996 }
997
998 #[must_use]
1001 pub fn super_occupied_at(&self, chunk: [i32; 3], mip: u32, cell: [i32; 3]) -> Option<bool> {
1002 self.get(chunk, mip)
1003 .map(|m| m.occupied_super([cell[0] >> 6, cell[1] >> 6, cell[2] >> 6]))
1004 }
1005}
1006
1007#[allow(clippy::cast_possible_wrap)]
1012fn local_cache(grid: &GridView<'_>, requested_mip: u32) -> (BrickCache, u32) {
1013 let mip = effective_mip(grid, requested_mip);
1014 let mut cache = BrickCache::new();
1015 if let Some(cg) = grid.chunk_grid {
1016 for dz in 0..cg.chunks_z as i32 {
1017 for dy in 0..cg.chunks_y as i32 {
1018 for dx in 0..cg.chunks_x as i32 {
1019 let slot = ((dz * cg.chunks_y as i32 + dy) * cg.chunks_x as i32 + dx) as usize;
1020 if let Some(Some(view)) = cg.chunks.get(slot) {
1021 let ch = [
1022 cg.origin_chunk_xy[0] + dx,
1023 cg.origin_chunk_xy[1] + dy,
1024 cg.origin_chunk_z + dz,
1025 ];
1026 cache.ensure(ch, mip, 0, view);
1027 }
1028 }
1029 }
1030 }
1031 } else {
1032 cache.ensure([0, 0, 0], mip, 0, grid);
1033 }
1034 (cache, mip)
1035}
1036
1037#[must_use]
1042pub fn effective_mip(grid: &GridView<'_>, requested: u32) -> u32 {
1043 if requested == 0 {
1044 return 0;
1045 }
1046 let mut m = requested;
1047 if let Some(cg) = grid.chunk_grid {
1048 for c in cg.chunks.iter().flatten() {
1049 m = m.min(c.mip_count().saturating_sub(1));
1050 }
1051 } else {
1052 m = m.min(grid.mip_count().saturating_sub(1));
1053 }
1054 m
1055}
1056
1057struct Sampler<'a> {
1071 grid: GridView<'a>,
1072 bricks: &'a BrickCache,
1073 mip: u32,
1076 xy_shift: u32,
1085 xy_mask: i32,
1086 z_shift: u32,
1087 z_mask: i32,
1088 cur_ch: [i32; 3],
1089 cur_view: Option<GridView<'a>>,
1090 cur_brick: Option<&'a BrickMap>,
1091 has_cur: bool,
1092}
1093
1094impl<'a> Sampler<'a> {
1095 fn new(grid: GridView<'a>, bricks: &'a BrickCache, mip: u32) -> Self {
1096 let cs_xy = (grid.chunk_size_xy >> mip).max(1);
1097 let cs_z = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1);
1098 debug_assert!(
1099 cs_xy.is_power_of_two() && cs_z.is_power_of_two(),
1100 "chunk dims must be powers of two for the shift/mask split"
1101 );
1102 #[allow(clippy::cast_possible_wrap)]
1103 Self {
1104 grid,
1105 bricks,
1106 mip,
1107 xy_shift: cs_xy.trailing_zeros(),
1108 xy_mask: cs_xy as i32 - 1,
1109 z_shift: cs_z.trailing_zeros(),
1110 z_mask: cs_z as i32 - 1,
1111 cur_ch: [0; 3],
1112 cur_view: None,
1113 cur_brick: None,
1114 has_cur: false,
1115 }
1116 }
1117
1118 fn select_chunk(&mut self, ch: [i32; 3]) {
1120 if self.has_cur && self.cur_ch == ch {
1121 return;
1122 }
1123 self.cur_view = self.grid.chunk_at_xyz(ch);
1124 self.cur_brick = self.bricks.get(ch, self.mip);
1125 self.cur_ch = ch;
1126 self.has_cur = true;
1127 }
1128
1129 #[allow(clippy::cast_sign_loss)]
1134 fn locate(&self, c: [i32; 3]) -> ([i32; 3], [u32; 3]) {
1135 let ch = [
1136 c[0] >> self.xy_shift,
1137 c[1] >> self.xy_shift,
1138 c[2] >> self.z_shift,
1139 ];
1140 let loc = [
1141 (c[0] & self.xy_mask) as u32,
1142 (c[1] & self.xy_mask) as u32,
1143 (c[2] & self.z_mask) as u32,
1144 ];
1145 (ch, loc)
1146 }
1147
1148 #[allow(clippy::cast_possible_wrap)]
1152 fn hit(&mut self, c: [i32; 3]) -> Option<u32> {
1153 #[cfg(test)]
1154 prof::SURF.with(|x| x.set(x.get() + 1));
1155 let (ch, loc) = self.locate(c);
1156 self.select_chunk(ch);
1157 let occupied = self.cur_brick.is_some_and(|bm| {
1158 bm.occupied([
1159 loc[0] as i32 / BRICK,
1160 loc[1] as i32 / BRICK,
1161 loc[2] as i32 / BRICK,
1162 ])
1163 });
1164 if !occupied {
1165 return None;
1166 }
1167 self.cur_view?
1168 .surface_color_mip(loc[0], loc[1], loc[2], self.mip)
1169 }
1170
1171 #[inline]
1173 fn cells_per_chunk_xy(&self) -> i32 {
1174 1 << self.xy_shift
1175 }
1176 #[inline]
1177 fn cells_per_chunk_z(&self) -> i32 {
1178 1 << self.z_shift
1179 }
1180
1181 #[allow(clippy::cast_sign_loss)]
1186 fn brick_occupied(&mut self, brick: [i32; 3]) -> bool {
1187 let c0 = [brick[0] << 3, brick[1] << 3, brick[2] << 3];
1189 let ch = [
1190 c0[0] >> self.xy_shift,
1191 c0[1] >> self.xy_shift,
1192 c0[2] >> self.z_shift,
1193 ];
1194 self.select_chunk(ch);
1195 self.cur_brick.is_some_and(|bm| {
1196 bm.occupied([
1197 (c0[0] & self.xy_mask) >> 3,
1198 (c0[1] & self.xy_mask) >> 3,
1199 (c0[2] & self.z_mask) >> 3,
1200 ])
1201 })
1202 }
1203
1204 #[allow(clippy::cast_sign_loss)]
1209 fn super_occupied(&mut self, s: [i32; 3]) -> bool {
1210 let c0 = [s[0] << 6, s[1] << 6, s[2] << 6];
1212 let ch = [
1213 c0[0] >> self.xy_shift,
1214 c0[1] >> self.xy_shift,
1215 c0[2] >> self.z_shift,
1216 ];
1217 self.select_chunk(ch);
1218 self.cur_brick.is_some_and(|bm| {
1219 bm.occupied_super([
1220 (c0[0] & self.xy_mask) >> 6,
1221 (c0[1] & self.xy_mask) >> 6,
1222 (c0[2] & self.z_mask) >> 6,
1223 ])
1224 })
1225 }
1226}
1227
1228const SHADOW_MAX_STEPS: u32 = 1024;
1232
1233struct SamplerShadow<'s, 'a> {
1240 sampler: &'s mut Sampler<'a>,
1241 cell_size: f32,
1242 lo_c: [i32; 3],
1243 hi_c: [i32; 3],
1244}
1245
1246impl ShadowTester for SamplerShadow<'_, '_> {
1247 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
1248 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
1249 let cs = self.cell_size;
1250 let has_super =
1258 self.sampler.cells_per_chunk_xy() >= SUPER && self.sampler.cells_per_chunk_z() >= SUPER;
1259 let has_brick =
1260 self.sampler.cells_per_chunk_xy() >= BRICK && self.sampler.cells_per_chunk_z() >= BRICK;
1261 let mut cellc = [
1262 (origin[0] / cs).floor() as i32,
1263 (origin[1] / cs).floor() as i32,
1264 (origin[2] / cs).floor() as i32,
1265 ];
1266 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cs);
1267 let inv = [
1268 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
1269 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
1270 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
1271 ];
1272 let mut t_curr = 0.0f32;
1273 let mut used = 0u32;
1274 while used < SHADOW_MAX_STEPS {
1275 if cellc[0] < self.lo_c[0]
1276 || cellc[0] >= self.hi_c[0]
1277 || cellc[1] < self.lo_c[1]
1278 || cellc[1] >= self.hi_c[1]
1279 || cellc[2] < self.lo_c[2]
1280 || cellc[2] >= self.hi_c[2]
1281 {
1282 return false; }
1284 if t_curr > max_t {
1285 return false; }
1287 let skip_shift = if has_super
1291 && !self
1292 .sampler
1293 .super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
1294 {
1295 Some(6u32)
1296 } else if has_brick
1297 && !self
1298 .sampler
1299 .brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
1300 {
1301 Some(3u32)
1302 } else {
1303 None
1304 };
1305 if let Some(sh) = skip_shift {
1306 let mut best_t = f32::INFINITY;
1307 let mut best_axis = 3usize;
1308 let mut plane = [0i32; 3];
1309 for a in 0..3 {
1310 if step[a] == 0 {
1311 continue;
1312 }
1313 let idx = cellc[a] >> sh;
1314 plane[a] = if step[a] > 0 {
1315 (idx + 1) << sh
1316 } else {
1317 idx << sh
1318 };
1319 let tb = (plane[a] as f32 * cs - origin[a]) * inv[a];
1320 if tb < best_t {
1321 best_t = tb;
1322 best_axis = a;
1323 }
1324 }
1325 if best_axis == 3 {
1326 return false;
1327 }
1328 let pb = [
1329 origin[0] + dir[0] * (best_t + 1e-4),
1330 origin[1] + dir[1] * (best_t + 1e-4),
1331 origin[2] + dir[2] * (best_t + 1e-4),
1332 ];
1333 let mut nc = [
1334 (pb[0] / cs).floor() as i32,
1335 (pb[1] / cs).floor() as i32,
1336 (pb[2] / cs).floor() as i32,
1337 ];
1338 nc[best_axis] = if step[best_axis] > 0 {
1339 plane[best_axis]
1340 } else {
1341 plane[best_axis] - 1
1342 };
1343 let crossed =
1348 cellc[0].abs_diff(nc[0]) + cellc[1].abs_diff(nc[1]) + cellc[2].abs_diff(nc[2]);
1349 if used.saturating_add(crossed) >= SHADOW_MAX_STEPS {
1350 return false;
1351 }
1352 used += crossed;
1353 cellc = nc;
1354 for a in 0..3 {
1355 if step[a] > 0 {
1356 t_max[a] = ((cellc[a] + 1) as f32 * cs - origin[a]) * inv[a];
1357 } else if step[a] < 0 {
1358 t_max[a] = (cellc[a] as f32 * cs - origin[a]) * inv[a];
1359 }
1360 }
1361 t_curr = best_t.max(t_curr);
1362 continue;
1363 }
1364 if self.sampler.hit(cellc).is_some() {
1365 return true; }
1367 let axis = min_axis(t_max);
1368 t_curr = t_max[axis];
1369 cellc[axis] += step[axis];
1370 t_max[axis] += t_delta[axis];
1371 used += 1;
1372 }
1373 false
1374 }
1375}
1376
1377#[allow(
1398 clippy::too_many_arguments,
1399 clippy::cast_possible_truncation,
1400 clippy::cast_sign_loss,
1401 clippy::cast_precision_loss
1402)]
1403fn cell_walk_skip(
1404 origin: [f32; 3],
1405 dir: [f32; 3],
1406 fwd_dot: f32,
1407 sampler: &mut Sampler<'_>,
1408 lo_c: [i32; 3],
1409 hi_c: [i32; 3],
1410 cell_size: f32,
1411 t_enter: f32,
1412 t_exit: f32,
1413 max_dist: f32,
1414 env: &DdaEnv<'_>,
1415) -> Option<Hit> {
1416 let has_super = sampler.cells_per_chunk_xy() >= SUPER && sampler.cells_per_chunk_z() >= SUPER;
1417 let has_brick = sampler.cells_per_chunk_xy() >= BRICK && sampler.cells_per_chunk_z() >= BRICK;
1418
1419 let start = t_enter + 1e-4;
1420 let p = [
1421 origin[0] + dir[0] * start,
1422 origin[1] + dir[1] * start,
1423 origin[2] + dir[2] * start,
1424 ];
1425 let mut cellc = [
1426 ((p[0] / cell_size).floor() as i32).clamp(lo_c[0], hi_c[0] - 1),
1427 ((p[1] / cell_size).floor() as i32).clamp(lo_c[1], hi_c[1] - 1),
1428 ((p[2] / cell_size).floor() as i32).clamp(lo_c[2], hi_c[2] - 1),
1429 ];
1430 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cell_size);
1431 let inv = [
1435 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
1436 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
1437 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
1438 ];
1439 let mut t_curr = t_enter;
1440 let mut last_axis = 3usize;
1441 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
1444 let shadow_casts = env.lights.enabled
1448 && env.lights.shadow_strength > 0.0
1449 && (env.lights.sun_casts_shadow || env.lights.points.iter().any(|p| p.casts_shadow));
1450
1451 let mut accum = [0.0f32; 3];
1456 let mut trans = 1.0f32;
1457 let mut touched = false;
1458 let mut prev_solid = false;
1459 let mut prev_mat = 0u8;
1460
1461 let span = (hi_c[0] - lo_c[0]) + (hi_c[1] - lo_c[1]) + (hi_c[2] - lo_c[2]);
1464 let max_steps = span.max(0) as usize + 16;
1465 for _ in 0..max_steps {
1466 if cellc[0] < lo_c[0]
1467 || cellc[0] >= hi_c[0]
1468 || cellc[1] < lo_c[1]
1469 || cellc[1] >= hi_c[1]
1470 || cellc[2] < lo_c[2]
1471 || cellc[2] >= hi_c[2]
1472 {
1473 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1474 }
1475 let depth = t_curr * fwd_dot;
1476 if depth > max_dist || t_curr > t_exit {
1477 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1478 }
1479 if env.fog_max_dist > 0.0 && depth >= env.fog_max_dist {
1485 let fog = 0x8000_0000 | (env.fog_color & 0x00ff_ffff);
1486 let color = if touched {
1487 composite_over(accum, trans, fog)
1488 } else {
1489 fog
1490 };
1491 return Some(Hit {
1492 color,
1493 dist: env.fog_max_dist,
1494 });
1495 }
1496
1497 let skip_shift = if has_super
1500 && !sampler.super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
1501 {
1502 Some(6u32)
1503 } else if has_brick
1504 && !sampler.brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
1505 {
1506 Some(3u32)
1507 } else {
1508 None
1509 };
1510 if let Some(sh) = skip_shift {
1511 #[cfg(test)]
1512 prof::BRICKS.with(|x| x.set(x.get() + 1));
1513 let mut best_t = f32::INFINITY;
1515 let mut best_axis = 3usize;
1516 let mut plane = [0i32; 3];
1517 for a in 0..3 {
1518 if step[a] == 0 {
1519 continue;
1520 }
1521 let idx = cellc[a] >> sh;
1522 plane[a] = if step[a] > 0 {
1523 (idx + 1) << sh
1524 } else {
1525 idx << sh
1526 };
1527 let tb = (plane[a] as f32 * cell_size - origin[a]) * inv[a];
1528 if tb < best_t {
1529 best_t = tb;
1530 best_axis = a;
1531 }
1532 }
1533 if best_axis == 3 {
1534 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1535 }
1536 let pb = [
1541 origin[0] + dir[0] * (best_t + 1e-4),
1542 origin[1] + dir[1] * (best_t + 1e-4),
1543 origin[2] + dir[2] * (best_t + 1e-4),
1544 ];
1545 let mut nc = [
1546 (pb[0] / cell_size).floor() as i32,
1547 (pb[1] / cell_size).floor() as i32,
1548 (pb[2] / cell_size).floor() as i32,
1549 ];
1550 nc[best_axis] = if step[best_axis] > 0 {
1551 plane[best_axis]
1552 } else {
1553 plane[best_axis] - 1
1554 };
1555 if nc[0] < lo_c[0]
1559 || nc[0] >= hi_c[0]
1560 || nc[1] < lo_c[1]
1561 || nc[1] >= hi_c[1]
1562 || nc[2] < lo_c[2]
1563 || nc[2] >= hi_c[2]
1564 {
1565 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1566 }
1567 cellc = nc;
1568 for a in 0..3 {
1571 if step[a] > 0 {
1572 t_max[a] = ((cellc[a] + 1) as f32 * cell_size - origin[a]) * inv[a];
1573 } else if step[a] < 0 {
1574 t_max[a] = (cellc[a] as f32 * cell_size - origin[a]) * inv[a];
1575 }
1576 }
1577 t_curr = best_t.max(t_curr);
1578 last_axis = best_axis;
1579 prev_solid = false; continue;
1581 }
1582
1583 #[cfg(test)]
1585 prof::CELLS.with(|x| x.set(x.get() + 1));
1586 if let Some(color) = sampler.hit(cellc) {
1587 let bright_sub = side_shade_sub(env, last_axis, step);
1588 let shaded = if env.lights.enabled {
1594 let casts = shadow_casts;
1595 let mut world_sh;
1601 let mut sampler_sh;
1602 let tester: Option<&mut dyn ShadowTester> = if !casts {
1603 None
1604 } else if let Some(ctx) = env.world_shadow {
1605 world_sh = WorldShadow { ctx };
1606 Some(&mut world_sh)
1607 } else {
1608 sampler_sh = SamplerShadow {
1609 sampler: &mut *sampler,
1610 cell_size,
1611 lo_c,
1612 hi_c,
1613 };
1614 Some(&mut sampler_sh)
1615 };
1616 shade_lit_cpu(
1617 color,
1618 bright_sub,
1619 last_axis,
1620 step,
1621 cellc,
1622 cell_size,
1623 &env.lights,
1624 tester,
1625 )
1626 } else {
1627 shade(color, bright_sub)
1628 };
1629 let lit = apply_fog(shaded, depth.max(0.0), env);
1630 let (m, mat_id) = match env.materials {
1634 Some(table) if !env.terrain_materials.is_empty() => {
1635 let id = material_for_color(env.terrain_materials, color);
1636 (table.get(id), id)
1637 }
1638 _ => (Material::OPAQUE, 0),
1639 };
1640 if m.is_opaque() {
1641 let color = if touched {
1645 composite_over(accum, trans, lit)
1646 } else {
1647 lit
1648 };
1649 return Some(Hit {
1650 color,
1651 dist: depth.max(0.0),
1652 });
1653 }
1654 let a = f32::from(m.alpha) / 255.0;
1655 if matches!(m.mode, roxlap_formats::material::BlendMode::Volumetric) {
1656 let t_exit = t_max[min_axis(t_max)];
1660 let seg_len = (t_exit - t_curr).max(0.0) * dir_len / cell_size;
1661 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
1662 let c = rgb_to_f32(lit);
1663 accum[0] += trans * eff_a * c[0];
1664 accum[1] += trans * eff_a * c[1];
1665 accum[2] += trans * eff_a * c[2];
1666 trans *= 1.0 - eff_a;
1667 touched = true;
1668 prev_mat = mat_id;
1669 if trans < 1.0 / 256.0 {
1670 return Some(Hit {
1671 color: f32_to_rgb(accum),
1672 dist: depth.max(0.0),
1673 });
1674 }
1675 } else if !prev_solid || mat_id != prev_mat {
1676 let c = rgb_to_f32(lit);
1680 accum[0] += trans * a * c[0];
1681 accum[1] += trans * a * c[1];
1682 accum[2] += trans * a * c[2];
1683 if !matches!(m.mode, roxlap_formats::material::BlendMode::Additive) {
1684 trans *= 1.0 - a; }
1686 touched = true;
1687 prev_mat = mat_id;
1688 if trans < 1.0 / 256.0 {
1689 return Some(Hit {
1690 color: f32_to_rgb(accum),
1691 dist: depth.max(0.0),
1692 });
1693 }
1694 }
1695 prev_solid = true;
1696 } else {
1697 prev_solid = false;
1698 }
1699 let axis = min_axis(t_max);
1700 last_axis = axis;
1701 t_curr = t_max[axis];
1702 cellc[axis] += step[axis];
1703 t_max[axis] += t_delta[axis];
1704 }
1705 None
1706}
1707
1708#[inline]
1714fn side_shade_sub(env: &DdaEnv<'_>, axis: usize, step: [i32; 3]) -> u32 {
1715 if axis >= 3 {
1716 return 0;
1717 }
1718 let face = axis * 2 + usize::from(step[axis] < 0);
1719 env.side_shades[face].max(0) as u32
1720}
1721
1722fn cast_ray(
1731 origin: [f32; 3],
1732 dir: [f32; 3],
1733 forward: [f32; 3],
1734 sampler: &mut Sampler<'_>,
1735 settings: &OpticastSettings,
1736 env: &DdaEnv<'_>,
1737) -> Option<Hit> {
1738 let (lo_i, hi_i) = sampler.grid.voxel_bounds();
1739 #[allow(clippy::cast_precision_loss)]
1740 let lo_f = [lo_i[0] as f32, lo_i[1] as f32, lo_i[2] as f32];
1741 #[allow(clippy::cast_precision_loss)]
1742 let hi_f = [hi_i[0] as f32, hi_i[1] as f32, hi_i[2] as f32];
1743 let (t_enter, t_exit) = intersect_aabb(origin, dir, lo_f, hi_f)?;
1744 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1745 #[allow(clippy::cast_precision_loss)]
1746 let max_dist = settings.max_scan_dist.max(1) as f32;
1747 let cell = 1i32 << sampler.mip;
1748 let cell_size = cell as f32;
1749 let lo_c = [
1750 lo_i[0].div_euclid(cell),
1751 lo_i[1].div_euclid(cell),
1752 lo_i[2].div_euclid(cell),
1753 ];
1754 let hi_c = [
1755 hi_i[0].div_euclid(cell),
1756 hi_i[1].div_euclid(cell),
1757 hi_i[2].div_euclid(cell),
1758 ];
1759 cell_walk_skip(
1760 origin, dir, fwd_dot, sampler, lo_c, hi_c, cell_size, t_enter, t_exit, max_dist, env,
1761 )
1762}
1763
1764pub fn render_dda(
1777 camera: &Camera,
1778 settings: &OpticastSettings,
1779 grid: GridView<'_>,
1780 pitch_pixels: usize,
1781 env: &DdaEnv<'_>,
1782 mip: u32,
1783 sink: &mut impl PixelSink,
1784) {
1785 let cs = camera_math::derive(
1786 camera,
1787 settings.xres,
1788 settings.yres,
1789 settings.hx,
1790 settings.hy,
1791 settings.hz,
1792 );
1793
1794 let (cache, mip) = local_cache(&grid, mip);
1797 let mut sampler = Sampler::new(grid, &cache, mip);
1798
1799 for py in settings.y_start..settings.y_end {
1800 let row = py as usize * pitch_pixels;
1801 for px in settings.x_start..settings.x_end {
1802 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py) {
1803 sink.put(row + px as usize, color, dist);
1804 }
1805 }
1806 }
1807}
1808
1809#[inline]
1814fn pixel_result(
1815 cs: &CameraState,
1816 settings: &OpticastSettings,
1817 sampler: &mut Sampler<'_>,
1818 env: &DdaEnv<'_>,
1819 px: u32,
1820 py: u32,
1821) -> Option<(u32, f32)> {
1822 let (origin, dir) = pixel_ray(cs, settings, px, py);
1823 if let Some(hit) = cast_ray(origin, dir, cs.forward, sampler, settings, env) {
1824 Some((hit.color, hit.dist))
1825 } else {
1826 env.sky.map(|sky| (sample_sky(sky, dir), f32::INFINITY))
1827 }
1828}
1829
1830#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
1845pub fn render_dda_parallel(
1846 camera: &Camera,
1847 settings: &OpticastSettings,
1848 grid: GridView<'_>,
1849 fb: &mut [u32],
1850 zb: &mut [f32],
1851 pitch_pixels: usize,
1852 env: &DdaEnv<'_>,
1853 cache: &BrickCache,
1854 mip: u32,
1855) {
1856 debug_assert_eq!(fb.len(), zb.len());
1857 let (y0, y1) = (settings.y_start, settings.y_end);
1858 if y1 <= y0 {
1859 return;
1860 }
1861 let cs = camera_math::derive(
1862 camera,
1863 settings.xres,
1864 settings.yres,
1865 settings.hx,
1866 settings.hy,
1867 settings.hz,
1868 );
1869 let target = RasterTarget::new(fb, zb);
1870
1871 let band = 8u32;
1878 let bands: Vec<(u32, u32)> = (y0..y1)
1879 .step_by(band as usize)
1880 .map(|s| (s, (s + band).min(y1)))
1881 .collect();
1882
1883 bands.par_iter().for_each(|&(by0, by1)| {
1884 let mut sampler = Sampler::new(grid, cache, mip);
1885 for py in by0..by1 {
1886 let row = py as usize * pitch_pixels;
1887 for px in settings.x_start..settings.x_end {
1888 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py)
1889 {
1890 let idx = row + px as usize;
1891 unsafe {
1895 target.write_color(idx, color);
1896 target.write_depth(idx, dist);
1897 }
1898 }
1899 }
1900 }
1901 });
1902}
1903
1904#[cfg(test)]
1910#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
1911fn cast_ray_reference(
1912 origin: [f32; 3],
1913 dir: [f32; 3],
1914 forward: [f32; 3],
1915 grid: &GridView<'_>,
1916 settings: &OpticastSettings,
1917) -> Option<Hit> {
1918 let nx = grid.vsid as f32;
1919 let nz = f32::from(u16::try_from(crate::grid_view::CHUNK_SIZE_Z).unwrap_or(256));
1920 #[allow(clippy::cast_possible_wrap)]
1921 let n_i = [
1922 grid.vsid as i32,
1923 grid.vsid as i32,
1924 crate::grid_view::CHUNK_SIZE_Z as i32,
1925 ];
1926 let (t_enter, t_exit) = intersect_aabb(origin, dir, [0.0; 3], [nx, nx, nz])?;
1927 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1928 let max_dist = settings.max_scan_dist.max(1) as f32;
1929
1930 let start = t_enter + 1e-4;
1931 let p = [
1932 origin[0] + dir[0] * start,
1933 origin[1] + dir[1] * start,
1934 origin[2] + dir[2] * start,
1935 ];
1936 let mut voxel = [
1937 (p[0].floor() as i32).clamp(0, n_i[0] - 1),
1938 (p[1].floor() as i32).clamp(0, n_i[1] - 1),
1939 (p[2].floor() as i32).clamp(0, n_i[2] - 1),
1940 ];
1941 let (step, mut t_max, t_delta) = dda_setup(origin, dir, voxel, 1.0);
1942 let mut t_curr = t_enter;
1943 let max_steps = (n_i[0] + n_i[1] + n_i[2]) as usize + 8;
1944 for _ in 0..max_steps {
1945 if voxel[0] < 0
1946 || voxel[0] >= n_i[0]
1947 || voxel[1] < 0
1948 || voxel[1] >= n_i[1]
1949 || voxel[2] < 0
1950 || voxel[2] >= n_i[2]
1951 {
1952 return None;
1953 }
1954 let depth = t_curr * fwd_dot;
1955 if depth > max_dist || t_curr > t_exit {
1956 return None;
1957 }
1958 #[allow(clippy::cast_sign_loss)]
1959 if let Some(color) = grid.surface_color(voxel[0] as u32, voxel[1] as u32, voxel[2] as u32) {
1960 return Some(Hit {
1961 color: shade(color, 0),
1962 dist: depth.max(0.0),
1963 });
1964 }
1965 let axis = min_axis(t_max);
1966 t_curr = t_max[axis];
1967 voxel[axis] += step[axis];
1968 t_max[axis] += t_delta[axis];
1969 }
1970 None
1971}
1972
1973#[cfg(test)]
1974mod tests {
1975 use super::*;
1976
1977 fn lum(p: u32) -> u32 {
1979 (p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)
1980 }
1981
1982 #[test]
1983 fn cel_band_quantizes_and_collapses() {
1984 assert_eq!(cel_band(0.8, 2), cel_band(0.9, 2));
1986 assert!((cel_band(0.8, 2) - 1.0).abs() < 1e-6);
1987 assert_ne!(cel_band(0.3, 2), cel_band(0.8, 2));
1989 }
1990
1991 #[test]
1992 fn shade_lit_cpu_sun_lights_by_facing() {
1993 let color = 0x80_80_80_80;
1996 let step = [0, 0, 1];
1997 let base = CpuLights {
1998 enabled: true,
1999 sun: true,
2000 sun_color: [1.0; 3],
2001 sun_intensity: 1.0,
2002 ambient: [0.2; 3],
2003 ..CpuLights::default()
2004 };
2005 let facing = CpuLights {
2006 sun_dir: [0.0, 0.0, -1.0],
2007 ..base
2008 }; let back = CpuLights {
2010 sun_dir: [0.0, 0.0, 1.0],
2011 ..base
2012 }; let lit = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &facing, None);
2014 let dark = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &back, None);
2015 assert!(
2016 lum(lit) > lum(dark),
2017 "sun facing the surface must brighten it: {lit:#08x} vs {dark:#08x}",
2018 );
2019 }
2020
2021 #[test]
2022 fn shade_dynamic_spot_cone_masks_off_axis() {
2023 let albedo = [0.5, 0.5, 0.5];
2026 let n = [0.0, 0.0, -1.0];
2027 let sample = [0.0, 0.0, 0.0];
2028 let inner = 10.0f32.to_radians().cos();
2029 let outer = 15.0f32.to_radians().cos();
2030 let shade = |spot_dir: [f32; 3], cos_inner: f32, cos_outer: f32| {
2031 let pts = [CpuPointLight {
2032 pos: [0.0, 0.0, -10.0],
2033 color: [1.0; 3],
2034 intensity: 1.0,
2035 radius: 64.0,
2036 casts_shadow: false,
2037 spot_dir,
2038 cos_inner,
2039 cos_outer,
2040 }];
2041 let l = CpuLights {
2042 enabled: true,
2043 ambient: [0.0; 3],
2044 points: &pts,
2045 ..CpuLights::default()
2046 };
2047 shade_dynamic(albedo, 0.0, n, sample, &l, None)
2048 };
2049 let point = shade([0.0, 0.0, 1.0], -1.0, -1.0);
2051 let on_axis = shade([0.0, 0.0, 1.0], inner, outer);
2053 let off_axis = shade([1.0, 0.0, 0.0], inner, outer);
2055
2056 assert_eq!(
2058 on_axis, point,
2059 "on-axis spot must equal the point light: {on_axis:#08x} vs {point:#08x}",
2060 );
2061 assert!(
2063 lum(on_axis) > lum(off_axis),
2064 "off-axis spot must be darker: {on_axis:#08x} vs {off_axis:#08x}",
2065 );
2066 assert_eq!(lum(off_axis), 0, "off-cone spot contributes nothing");
2067 }
2068
2069 #[test]
2070 fn shade_lit_cpu_cel_terraces_sun() {
2071 let color = 0x80_80_80_80;
2074 let step = [0, 0, 1];
2075 let mk = |zc: f32, bands: u32| {
2076 let n = (1.0f32 - zc * zc).sqrt();
2077 CpuLights {
2078 enabled: true,
2079 sun: true,
2080 sun_dir: [n, 0.0, -zc], sun_color: [1.0; 3],
2082 sun_intensity: 1.0,
2083 ambient: [0.1; 3],
2084 bands,
2085 ..CpuLights::default()
2086 }
2087 };
2088 let smooth_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 0), None);
2089 let smooth_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 0), None);
2090 assert_ne!(smooth_a, smooth_b, "smooth diffuse must vary with N·L");
2091 let cel_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 2), None);
2092 let cel_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 2), None);
2093 assert_eq!(
2094 cel_a, cel_b,
2095 "cel banding must terrace both N·L to one level"
2096 );
2097 }
2098
2099 #[test]
2103 fn shade_dynamic_sun_shadow_darkens() {
2104 struct Mock(bool);
2105 impl ShadowTester for Mock {
2106 fn occluded(&mut self, _: [f32; 3], _: [f32; 3], _: f32) -> bool {
2107 self.0
2108 }
2109 }
2110 let l = CpuLights {
2111 enabled: true,
2112 sun: true,
2113 sun_dir: [0.0, 0.0, -1.0], sun_color: [1.0; 3],
2115 sun_intensity: 1.0,
2116 sun_casts_shadow: true,
2117 ambient: [0.2; 3],
2118 shadow_strength: 0.7,
2119 shadow_bias: 1.5,
2120 shadow_max_dist: 64.0,
2121 ..CpuLights::default()
2122 };
2123 let albedo = [0.8; 3];
2124 let n = [0.0, 0.0, -1.0]; let s = [0.5, 0.5, 0.5];
2126 let lit = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(false)));
2127 let shadowed = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(true)));
2128 assert!(
2129 lum(shadowed) < lum(lit),
2130 "an occluded sun face must darken: shadowed={shadowed:#08x} lit={lit:#08x}",
2131 );
2132 let l0 = CpuLights {
2134 shadow_strength: 0.0,
2135 ..l
2136 };
2137 assert_eq!(
2138 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(true))),
2139 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(false))),
2140 "shadow_strength 0 ⇒ shadows invisible",
2141 );
2142 }
2143
2144 #[test]
2150 fn sampler_shadow_march_casts_sun_shadow() {
2151 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, _y, z| {
2153 if z >= 60 {
2154 Some(0x80_80_80_80) } else if x == 32 && (30..60).contains(&z) {
2156 Some(0x80_70_70_70) } else {
2158 None
2159 }
2160 });
2161 let grid = GridView::from_single_vxl(&vxl);
2162 let cam = Camera {
2164 pos: [32.0, 32.0, 6.0],
2165 right: [1.0, 0.0, 0.0],
2166 down: [0.0, 1.0, 0.0],
2167 forward: [0.0, 0.0, 1.0],
2168 };
2169 let inv = 1.0f32 / 2.0f32.sqrt();
2171 let base = CpuLights {
2172 enabled: true,
2173 sun: true,
2174 sun_dir: [inv, 0.0, -inv],
2175 sun_color: [1.0; 3],
2176 sun_intensity: 1.0,
2177 ambient: [0.25; 3],
2178 shadow_strength: 0.8,
2179 shadow_bias: 1.5,
2180 shadow_max_dist: 128.0,
2181 ..CpuLights::default()
2182 };
2183 let (w, h) = (96u32, 96u32);
2184 let lit_env = DdaEnv {
2185 lights: CpuLights {
2186 sun_casts_shadow: false,
2187 ..base
2188 },
2189 ..DdaEnv::default()
2190 };
2191 let shadow_env = DdaEnv {
2192 lights: CpuLights {
2193 sun_casts_shadow: true,
2194 ..base
2195 },
2196 ..DdaEnv::default()
2197 };
2198 let (fb_lit, _) = render_brickmap_env(grid, &cam, w, h, &lit_env);
2199 let (fb_sh, _) = render_brickmap_env(grid, &cam, w, h, &shadow_env);
2200 let sum: fn(&[u32]) -> u64 = |fb| fb.iter().map(|&p| u64::from(lum(p))).sum();
2201 let lit_sum = sum(&fb_lit);
2202 let sh_sum = sum(&fb_sh);
2203 assert!(
2204 sh_sum < lit_sum,
2205 "the wall's shadow must darken the floor: shadow_sum={sh_sum} lit_sum={lit_sum}",
2206 );
2207 assert!(
2209 (lit_sum - sh_sum) * 50 > lit_sum,
2210 "shadow should remove >2% of total luminance: lit={lit_sum} shadow={sh_sum}",
2211 );
2212 }
2213
2214 #[derive(Default)]
2216 struct Recorder {
2217 puts: Vec<(usize, u32, f32)>,
2218 }
2219 impl PixelSink for Recorder {
2220 fn put(&mut self, idx: usize, color: u32, dist: f32) {
2221 self.puts.push((idx, color, dist));
2222 }
2223 }
2224
2225 fn oracle_camera() -> Camera {
2226 Camera {
2228 pos: [0.0, 0.0, 0.0],
2229 right: [1.0, 0.0, 0.0],
2230 down: [0.0, 0.0, 1.0],
2231 forward: [0.0, 1.0, 0.0],
2232 }
2233 }
2234
2235 fn render_mask(grid: GridView<'_>, camera: &Camera, w: u32, h: u32) -> Vec<bool> {
2238 let n = (w as usize) * (h as usize);
2239 let mut fb = vec![0u32; n]; let mut zb = vec![f32::INFINITY; n];
2241 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2242 {
2243 let mut sink = RasterSink::new(&mut fb, &mut zb);
2244 render_dda(
2245 camera,
2246 &settings,
2247 grid,
2248 w as usize,
2249 &DdaEnv::default(),
2250 0,
2251 &mut sink,
2252 );
2253 }
2254 fb.iter().map(|&c| c != 0).collect()
2255 }
2256
2257 fn rows_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2262 let w = w as usize;
2263 for y in 0..h as usize {
2264 let row = &mask[y * w..(y + 1) * w];
2265 let first = row.iter().position(|&b| b);
2266 let last = row.iter().rposition(|&b| b);
2267 if let (Some(f), Some(l)) = (first, last) {
2268 if row[f..=l].iter().any(|&b| !b) {
2269 return false;
2270 }
2271 }
2272 }
2273 true
2274 }
2275
2276 fn cols_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2278 let w = w as usize;
2279 let h = h as usize;
2280 for x in 0..w {
2281 let col: Vec<bool> = (0..h).map(|y| mask[y * w + x]).collect();
2282 let first = col.iter().position(|&b| b);
2283 let last = col.iter().rposition(|&b| b);
2284 if let (Some(f), Some(l)) = (first, last) {
2285 if col[f..=l].iter().any(|&b| !b) {
2286 return false;
2287 }
2288 }
2289 }
2290 true
2291 }
2292
2293 #[test]
2296 fn center_pixel_ray_is_forward() {
2297 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2298 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2299 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2301 let (origin, dir) = pixel_ray(&cs, &settings, settings.hx as u32, settings.hy as u32);
2302 assert_eq!(origin, [0.0, 0.0, 0.0]);
2303 assert_eq!(
2305 dir.map(f32::to_bits),
2306 [0.0f32, 320.0, 0.0].map(f32::to_bits)
2307 );
2308 }
2309
2310 #[test]
2314 fn corner_pixel_ray_matches_camera_corn0() {
2315 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2316 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2317 let (_origin, dir) = pixel_ray(&cs, &settings, 0, 0);
2318 assert_eq!(dir.map(f32::to_bits), cs.corn[0].map(f32::to_bits));
2319 }
2320
2321 #[test]
2327 fn gridview_voxel_color_matches_reference() {
2328 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |x, _, z| {
2330 let lo = (10..=12).contains(&z);
2331 let hi = (40..=42).contains(&z);
2332 (lo || hi).then_some(0x80_10_20_30 + x)
2333 });
2334 let grid = GridView::from_single_vxl(&vxl);
2335 for x in 0..8 {
2336 for y in 0..8 {
2337 for z in 0..64 {
2338 assert_eq!(
2339 grid.voxel_color(x, y, z),
2340 vxl.voxel_color(x, y, z),
2341 "mismatch at ({x},{y},{z})"
2342 );
2343 }
2344 }
2345 }
2346 }
2347
2348 #[test]
2350 fn empty_grid_no_hits() {
2351 let vxl = roxlap_formats::vxl::Vxl::empty(64);
2352 let grid = GridView::from_single_vxl(&vxl);
2353 let settings = OpticastSettings::for_oracle_framebuffer(64, 48);
2354 let mut rec = Recorder::default();
2355 render_dda(
2356 &oracle_camera(),
2357 &settings,
2358 grid,
2359 64,
2360 &DdaEnv::default(),
2361 0,
2362 &mut rec,
2363 );
2364 assert!(rec.puts.is_empty(), "all-air grid must produce no hits");
2365 }
2366
2367 #[test]
2371 fn floor_seen_from_above() {
2372 const FLOOR_Z: u32 = 40;
2373 const FLOOR_COL: u32 = 0x80_30_60_90;
2374 let vxl =
2375 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= FLOOR_Z).then_some(FLOOR_COL));
2376 let grid = GridView::from_single_vxl(&vxl);
2377
2378 let cam = Camera {
2380 pos: [16.0, 16.0, 10.0],
2381 right: [1.0, 0.0, 0.0],
2382 down: [0.0, 1.0, 0.0],
2383 forward: [0.0, 0.0, 1.0],
2384 };
2385 let settings = OpticastSettings::for_oracle_framebuffer(48, 48);
2386 let mut rec = Recorder::default();
2387 render_dda(&cam, &settings, grid, 48, &DdaEnv::default(), 0, &mut rec);
2388
2389 assert!(!rec.puts.is_empty(), "floor must be visible");
2390 let centre = 24usize * 48 + 24;
2392 let hit = rec
2393 .puts
2394 .iter()
2395 .find(|(idx, _, _)| *idx == centre)
2396 .expect("centre ray must hit the floor");
2397 assert_eq!(hit.1 & 0x00ff_ffff, FLOOR_COL & 0x00ff_ffff);
2398 let expected = (FLOOR_Z as f32) - 10.0;
2399 assert!(
2400 (hit.2 - expected).abs() < 1.5,
2401 "centre depth {} not ≈ {}",
2402 hit.2,
2403 expected
2404 );
2405 }
2406
2407 #[test]
2412 fn horizon_splits_sky_and_floor() {
2413 const FLOOR_Z: u32 = 40;
2414 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
2415 (z >= FLOOR_Z).then_some(0x80_44_66_88)
2416 });
2417 let grid = GridView::from_single_vxl(&vxl);
2418
2419 let cam = Camera {
2423 pos: [32.0, 4.0, 30.0],
2424 right: [-1.0, 0.0, 0.0],
2425 down: [0.0, 0.0, 1.0],
2426 forward: [0.0, 1.0, 0.0],
2427 };
2428 let (w, h) = (64u32, 64u32);
2429 let mask = render_mask(grid, &cam, w, h);
2430
2431 let count_band = |y0: usize, y1: usize| -> usize {
2432 (y0 * w as usize..y1 * w as usize)
2433 .filter(|&i| mask[i])
2434 .count()
2435 };
2436 let top = count_band(0, h as usize / 4);
2437 let bottom = count_band(3 * h as usize / 4, h as usize);
2438 assert!(mask.iter().any(|&b| b), "floor must be visible");
2439 assert!(mask.iter().any(|&b| !b), "sky must be visible");
2440 assert!(
2441 bottom > top,
2442 "bottom band ({bottom}) should hit more floor than top band ({top})"
2443 );
2444 }
2445
2446 fn render_reference(
2449 grid: GridView<'_>,
2450 camera: &Camera,
2451 w: u32,
2452 h: u32,
2453 ) -> (Vec<u32>, Vec<f32>) {
2454 let n = (w as usize) * (h as usize);
2455 let mut fb = vec![0u32; n];
2456 let mut zb = vec![f32::INFINITY; n];
2457 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2458 let cs = camera_math::derive(camera, w, h, settings.hx, settings.hy, settings.hz);
2459 for py in 0..h {
2460 for px in 0..w {
2461 let (o, d) = pixel_ray(&cs, &settings, px, py);
2462 if let Some(hit) = cast_ray_reference(o, d, cs.forward, &grid, &settings) {
2463 let i = (py * w + px) as usize;
2464 fb[i] = hit.color;
2465 zb[i] = hit.dist;
2466 }
2467 }
2468 }
2469 (fb, zb)
2470 }
2471
2472 fn render_brickmap(
2474 grid: GridView<'_>,
2475 camera: &Camera,
2476 w: u32,
2477 h: u32,
2478 ) -> (Vec<u32>, Vec<f32>) {
2479 render_brickmap_env(grid, camera, w, h, &DdaEnv::default())
2480 }
2481
2482 fn render_brickmap_env(
2485 grid: GridView<'_>,
2486 camera: &Camera,
2487 w: u32,
2488 h: u32,
2489 env: &DdaEnv<'_>,
2490 ) -> (Vec<u32>, Vec<f32>) {
2491 let n = (w as usize) * (h as usize);
2492 let mut fb = vec![0u32; n];
2493 let mut zb = vec![f32::INFINITY; n];
2494 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2495 {
2496 let mut sink = RasterSink::new(&mut fb, &mut zb);
2497 render_dda(camera, &settings, grid, w as usize, env, 0, &mut sink);
2498 }
2499 (fb, zb)
2500 }
2501
2502 #[test]
2509 fn no_sky_leak_through_diagonal_wall() {
2510 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2511 ((x + y == 64) && (2..62).contains(&z)).then_some(0x80_40_80_60)
2512 });
2513 let grid = GridView::from_single_vxl(&vxl);
2514 let (w, h) = (160u32, 160u32);
2515 let c = [10.0, 10.0, 32.0];
2516 let poses = [
2517 Camera::from_yaw_pitch(c, 0.785, 0.0),
2518 Camera::from_yaw_pitch(c, 0.6, 0.1),
2519 Camera::from_yaw_pitch(c, 0.95, -0.1),
2520 Camera::from_yaw_pitch(c, 0.785, 0.3),
2521 Camera::from_yaw_pitch(c, 0.5, 0.0),
2522 ];
2523 for (i, cam) in poses.iter().enumerate() {
2524 let (fb_b, _) = render_brickmap(grid, cam, w, h);
2525 let (fb_r, _) = render_reference(grid, cam, w, h);
2526 let leak = (0..(w * h) as usize)
2527 .filter(|&k| (fb_b[k] != 0) != (fb_r[k] != 0))
2528 .count();
2529 assert_eq!(leak, 0, "pose {i}: {leak} px diverge from dense reference");
2530 }
2531 }
2532
2533 #[test]
2537 fn terrain_glass_tints_floor_behind() {
2538 let glass = 0x80_40_C0_E0; let floor = 0x80_C0_40_40; let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2541 if z == 4 {
2542 Some(glass)
2543 } else if z >= 10 {
2544 Some(floor)
2545 } else {
2546 None
2547 }
2548 });
2549 let grid = GridView::from_single_vxl(&vxl);
2550 let cam = Camera {
2552 pos: [8.0, 8.0, 0.0],
2553 right: [1.0, 0.0, 0.0],
2554 down: [0.0, 1.0, 0.0],
2555 forward: [0.0, 0.0, 1.0],
2556 };
2557 let (w, h) = (32u32, 32u32);
2558 let centre = (h / 2 * w + w / 2) as usize;
2559
2560 let (fb_op, _) = render_brickmap(grid, &cam, w, h);
2562 assert_eq!(
2563 fb_op[centre] & 0x00ff_ffff,
2564 0x0040_C0E0,
2565 "opaque glass first-hit"
2566 );
2567
2568 let mut table = MaterialTable::new();
2570 table.set(1, Material::alpha_blend(128));
2571 let env = DdaEnv {
2572 materials: Some(&table),
2573 terrain_materials: &[(glass & 0x00ff_ffff, 1)],
2574 lights: CpuLights::default(),
2575 ..DdaEnv::default()
2576 };
2577 let (fb_tr, _) = render_brickmap_env(grid, &cam, w, h, &env);
2578 assert_ne!(
2579 fb_tr[centre], fb_op[centre],
2580 "glass should composite over the floor, not stay opaque"
2581 );
2582 let r_op = (fb_op[centre] >> 16) & 0xff; let r_tr = (fb_tr[centre] >> 16) & 0xff; assert!(
2585 r_tr > r_op,
2586 "floor red tints through the glass (op={r_op:02x} tr={r_tr:02x})"
2587 );
2588 }
2589
2590 #[test]
2595 fn terrain_volumetric_thickness_deepens_opacity() {
2596 let smoke = 0x80_90_90_90; let floor = 0x80_C0_20_20; let green_at = |depth: u32| -> u32 {
2601 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2602 if (4..4 + depth).contains(&z) {
2603 Some(smoke)
2604 } else if z >= 12 {
2605 Some(floor)
2606 } else {
2607 None
2608 }
2609 });
2610 let grid = GridView::from_single_vxl(&vxl);
2611 let cam = Camera {
2612 pos: [8.0, 8.0, 0.0],
2613 right: [1.0, 0.0, 0.0],
2614 down: [0.0, 1.0, 0.0],
2615 forward: [0.0, 0.0, 1.0],
2616 };
2617 let (w, h) = (32u32, 32u32);
2618 let mut table = MaterialTable::new();
2619 table.set(1, Material::volumetric(80));
2620 let env = DdaEnv {
2621 materials: Some(&table),
2622 terrain_materials: &[(smoke & 0x00ff_ffff, 1)],
2623 lights: CpuLights::default(),
2624 ..DdaEnv::default()
2625 };
2626 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
2627 (fb[(h / 2 * w + w / 2) as usize] >> 8) & 0xff
2628 };
2629 let shallow = green_at(1);
2630 let deep = green_at(7);
2631 assert!(
2632 deep > shallow,
2633 "deeper Volumetric smoke shows more of its grey (deep g={deep:02x} > shallow g={shallow:02x})"
2634 );
2635 }
2636
2637 #[test]
2640 fn distance_fog_blends_toward_fog_color() {
2641 let vxl =
2642 roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| (z >= 40).then_some(0x80_FF_FF_FF));
2643 let grid = GridView::from_single_vxl(&vxl);
2644 let cam = Camera {
2645 pos: [32.0, 2.0, 38.0],
2646 right: [1.0, 0.0, 0.0],
2647 down: [0.0, 0.0, 1.0],
2648 forward: [0.0, 1.0, 0.0],
2649 };
2650 let env = DdaEnv {
2651 sky: None,
2652 fog_color: 0x00_00_00_00, fog_max_dist: 64.0,
2654 side_shades: [0; 6],
2655 materials: None,
2656 terrain_materials: &[],
2657 lights: CpuLights::default(),
2658 world_shadow: None,
2659 };
2660 let (w, h) = (64u32, 64u32);
2661 let (fog, _) = render_brickmap_env(grid, &cam, w, h, &env);
2662 let (nofog, zb) = render_brickmap(grid, &cam, w, h);
2663 let (idx, depth) = zb.iter().enumerate().filter(|(_, z)| z.is_finite()).fold(
2664 (0usize, 0.0f32),
2665 |acc, (i, &z)| {
2666 if z > acc.1 {
2667 (i, z)
2668 } else {
2669 acc
2670 }
2671 },
2672 );
2673 assert!(depth > 20.0, "need a deep pixel to test fog (got {depth})");
2674 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
2675 assert!(
2676 lum(fog[idx]) < lum(nofog[idx]),
2677 "fogged pixel {:08x} not darker than {:08x}",
2678 fog[idx],
2679 nofog[idx]
2680 );
2681 }
2682
2683 #[test]
2686 fn textured_sky_fills_misses() {
2687 let sky = crate::sky::Sky::blue_gradient();
2688 let vxl = roxlap_formats::vxl::Vxl::empty(32); let grid = GridView::from_single_vxl(&vxl);
2690 let env = DdaEnv {
2691 sky: Some(&sky),
2692 fog_color: 0,
2693 fog_max_dist: 0.0,
2694 side_shades: [0; 6],
2695 materials: None,
2696 terrain_materials: &[],
2697 lights: CpuLights::default(),
2698 world_shadow: None,
2699 };
2700 let cam = Camera::from_yaw_pitch([16.0, 16.0, 128.0], 0.3, -0.4);
2701 let (w, h) = (48u32, 48u32);
2702 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
2703 assert!(fb.iter().all(|&c| c >> 24 == 0x80), "all misses sky-filled");
2704 let top = fb[0];
2705 let bottom = fb[(h - 1) as usize * w as usize];
2706 assert_ne!(top, bottom, "sky gradient should vary with elevation");
2707 }
2708
2709 #[test]
2714 fn sky_elevation_zenith_at_column_zero() {
2715 let mut pixels = vec![0i32; 8];
2716 pixels[0] = 0x0011_1111; pixels[7] = 0x0099_9999; let sky = crate::sky::Sky::from_pixels(pixels, 8, 1);
2719 let up = sample_sky(&sky, [0.0, 0.0, -1.0]); let down = sample_sky(&sky, [0.0, 0.0, 1.0]); assert_eq!(
2722 up & 0x00ff_ffff,
2723 0x0011_1111,
2724 "looking up → column 0 (zenith)"
2725 );
2726 assert_eq!(
2727 down & 0x00ff_ffff,
2728 0x0099_9999,
2729 "looking down → last column (nadir)"
2730 );
2731 }
2732
2733 #[test]
2737 fn sky_fill_paints_panorama_gridless() {
2738 let sky = crate::sky::Sky::blue_gradient();
2739 let cam = Camera::from_yaw_pitch([0.0, 0.0, 0.0], 0.3, -0.4);
2740 let (w, h) = (48u32, 48u32);
2741 let cs = crate::camera_math::derive(&cam, w, h, 24.0, 24.0, 24.0);
2742 let settings = crate::opticast::OpticastSettings::for_oracle_framebuffer(w, h);
2743 let mut fb = vec![0u32; (w * h) as usize];
2744 let zb = vec![f32::INFINITY; (w * h) as usize];
2746 render_sky_fill(&mut fb, &zb, w as usize, w, h, &cs, &settings, &sky);
2747 assert!(
2748 fb.iter().all(|&c| c >> 24 == 0x80),
2749 "every pixel sky-filled with the brightness byte set"
2750 );
2751 let top = fb[0];
2752 let bottom = fb[(h - 1) as usize * w as usize];
2753 assert_ne!(top, bottom, "sky gradient should vary with elevation");
2754 let mut fb2 = vec![0x1234_5678u32; (w * h) as usize];
2756 let mut zb2 = vec![f32::INFINITY; (w * h) as usize];
2757 zb2[0] = 10.0; render_sky_fill(&mut fb2, &zb2, w as usize, w, h, &cs, &settings, &sky);
2759 assert_eq!(fb2[0], 0x1234_5678, "finite-z pixel is not overwritten");
2760 }
2761
2762 #[test]
2766 fn side_shades_darken_hit_face() {
2767 let vxl =
2768 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x80_FF_FF_FF));
2769 let grid = GridView::from_single_vxl(&vxl);
2770 let cam = Camera {
2771 pos: [8.0, 8.0, 2.0],
2772 right: [1.0, 0.0, 0.0],
2773 down: [0.0, 1.0, 0.0],
2774 forward: [0.0, 0.0, 1.0],
2775 };
2776 let centre = 16 * 32 + 16;
2777 let (plain, _) = render_brickmap(grid, &cam, 32, 32);
2778 let env = DdaEnv {
2779 sky: None,
2780 fog_color: 0,
2781 fog_max_dist: 0.0,
2782 side_shades: [0, 0, 0, 0, 0x40, 0],
2783 materials: None,
2784 terrain_materials: &[],
2785 lights: CpuLights::default(),
2786 world_shadow: None,
2787 };
2788 let (shaded, _) = render_brickmap_env(grid, &cam, 32, 32, &env);
2789 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
2790 assert!(
2791 lum(shaded[centre]) < lum(plain[centre]),
2792 "side-shaded face {:08x} not darker than {:08x}",
2793 shaded[centre],
2794 plain[centre]
2795 );
2796 }
2797
2798 #[test]
2808 fn brickmap_approximates_dense_reference() {
2809 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2811 let surf = 30 + ((x / 5 + y / 7) % 11);
2812 let ground = z >= surf;
2813 let block = (20..=24).contains(&z) && (10..20).contains(&x) && (40..50).contains(&y);
2814 (ground || block).then_some(0x80_30_50_70 + (x ^ y) % 0x40)
2815 });
2816 let grid = GridView::from_single_vxl(&vxl);
2817
2818 let (w, h) = (80u32, 80u32);
2819 let poses = [
2820 Camera::orbit(0.6, 0.5, 90.0, [32.0, 32.0, 40.0]),
2821 Camera::orbit(2.1, 0.2, 70.0, [32.0, 32.0, 35.0]),
2822 Camera::orbit(-1.0, 0.9, 120.0, [32.0, 32.0, 45.0]),
2823 ];
2824 let n = (w * h) as usize;
2825 for (i, cam) in poses.iter().enumerate() {
2826 let (fb_b, zb_b) = render_brickmap(grid, cam, w, h);
2827 let (fb_r, _zb_r) = render_reference(grid, cam, w, h);
2828 let cov_b = fb_b.iter().filter(|&&c| c != 0).count();
2830 let cov_r = fb_r.iter().filter(|&&c| c != 0).count();
2831 assert!(cov_b > 200, "pose {i} rendered ~empty (cov {cov_b})");
2832 let cov_diff = cov_b.abs_diff(cov_r);
2833 assert!(
2834 cov_diff * 100 <= n, "pose {i} coverage diverged: brick {cov_b} vs dense {cov_r}"
2836 );
2837 let diffs = fb_b.iter().zip(&fb_r).filter(|(a, b)| a != b).count();
2839 assert!(
2840 diffs * 100 <= n * 3, "pose {i} too many pixel diffs vs dense: {diffs}/{n}"
2842 );
2843 for k in 0..n {
2845 if fb_b[k] != 0 {
2846 assert!(zb_b[k].is_finite(), "pose {i} px {k} non-finite depth");
2847 }
2848 }
2849 }
2850 }
2851
2852 #[test]
2856 fn baked_brightness_darkens_color() {
2857 let dim =
2859 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x40_FF_FF_FF));
2860 let grid = GridView::from_single_vxl(&dim);
2861 let cam = Camera {
2862 pos: [8.0, 8.0, 2.0],
2863 right: [1.0, 0.0, 0.0],
2864 down: [0.0, 1.0, 0.0],
2865 forward: [0.0, 0.0, 1.0],
2866 };
2867 let (fb, _) = render_brickmap(grid, &cam, 32, 32);
2868 let centre = 16 * 32 + 16;
2869 assert_eq!(fb[centre], 0x80_7F_7F_7F, "got {:08x}", fb[centre]);
2871
2872 let full =
2874 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x80_FF_FF_FF));
2875 let gridf = GridView::from_single_vxl(&full);
2876 let (fbf, _) = render_brickmap(gridf, &cam, 32, 32);
2877 assert_eq!(fbf[centre], 0x80_FF_FF_FF, "got {:08x}", fbf[centre]);
2878 }
2879
2880 #[test]
2887 fn cross_chunk_lookdown_sees_lower_stacked_floor() {
2888 const FLOOR_LOCAL_Z: u32 = 40;
2889 const FLOOR_COL: u32 = 0x80_22_88_44;
2890 let upper = roxlap_formats::vxl::Vxl::empty(32); let lower = roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
2892 (z >= FLOOR_LOCAL_Z).then_some(FLOOR_COL)
2893 });
2894 let v_up = GridView::from_single_vxl(&upper);
2895 let v_lo = GridView::from_single_vxl(&lower);
2896 let chunks = [Some(v_up), Some(v_lo)];
2898 let cg = crate::ChunkGrid {
2899 chunks: &chunks,
2900 origin_chunk_xy: [0, 0],
2901 origin_chunk_z: 0,
2902 chunks_x: 1,
2903 chunks_y: 1,
2904 chunks_z: 2,
2905 };
2906 let grid = GridView::from_chunk_grid(&cg, 32);
2907
2908 let cam = Camera {
2910 pos: [16.0, 16.0, 100.0],
2911 right: [1.0, 0.0, 0.0],
2912 down: [0.0, 1.0, 0.0],
2913 forward: [0.0, 0.0, 1.0],
2914 };
2915 let (w, h) = (48u32, 48u32);
2916 let (fb, zb) = render_brickmap(grid, &cam, w, h);
2917 let centre = 24 * 48 + 24;
2918 assert!(
2919 fb[centre] & 0x00ff_ffff == FLOOR_COL & 0x00ff_ffff,
2920 "centre ray must reach the lower-chunk floor (got {:08x})",
2921 fb[centre]
2922 );
2923 let expected = 296.0 - 100.0;
2925 assert!(
2926 (zb[centre] - expected).abs() < 2.0,
2927 "look-down depth {} not ≈ {expected}",
2928 zb[centre]
2929 );
2930 }
2931
2932 #[test]
2936 fn cross_chunk_xy_floor_is_seamless() {
2937 let mk = || {
2938 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= 20).then_some(0x80_50_50_50))
2939 };
2940 let (c0, c1) = (mk(), mk());
2941 let v0 = GridView::from_single_vxl(&c0);
2942 let v1 = GridView::from_single_vxl(&c1);
2943 let chunks = [Some(v0), Some(v1)];
2944 let cg = crate::ChunkGrid {
2945 chunks: &chunks,
2946 origin_chunk_xy: [0, 0],
2947 origin_chunk_z: 0,
2948 chunks_x: 2,
2949 chunks_y: 1,
2950 chunks_z: 1,
2951 };
2952 let grid = GridView::from_chunk_grid(&cg, 32);
2953
2954 let cam = Camera {
2956 pos: [32.0, 16.0, 4.0],
2957 right: [1.0, 0.0, 0.0],
2958 down: [0.0, 1.0, 0.0],
2959 forward: [0.0, 0.0, 1.0],
2960 };
2961 let (w, h) = (64u32, 64u32);
2962 let mask = render_mask(grid, &cam, w, h);
2963 let row = (h / 2) as usize * w as usize;
2966 let left = (0..w as usize / 2).filter(|&x| mask[row + x]).count();
2967 let right = (w as usize / 2..w as usize)
2968 .filter(|&x| mask[row + x])
2969 .count();
2970 assert!(
2971 left > 5 && right > 5,
2972 "seam not continuous: left={left} right={right}"
2973 );
2974 }
2975
2976 fn render_mask_mip(grid: GridView<'_>, camera: &Camera, w: u32, h: u32, mip: u32) -> Vec<bool> {
2979 let n = (w as usize) * (h as usize);
2980 let mut fb = vec![0u32; n];
2981 let mut zb = vec![f32::INFINITY; n];
2982 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2983 {
2984 let mut sink = RasterSink::new(&mut fb, &mut zb);
2985 render_dda(
2986 camera,
2987 &settings,
2988 grid,
2989 w as usize,
2990 &DdaEnv::default(),
2991 mip,
2992 &mut sink,
2993 );
2994 }
2995 fb.iter().map(|&c| c != 0).collect()
2996 }
2997
2998 #[test]
3004 fn mip_render_is_coarse_but_complete() {
3005 let mut vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
3006 let surf = 24 + ((x / 3 + y / 5) % 17);
3007 (z >= surf).then_some(0x80_50_70_90)
3008 });
3009 vxl.generate_mips(4);
3010 assert!(vxl.mip_count() >= 3, "need mips built for this test");
3011 let grid = GridView::from_single_vxl(&vxl);
3012 let (w, h) = (96u32, 96u32);
3013 let cam = Camera::orbit(0.7, 0.6, 110.0, [32.0, 32.0, 36.0]);
3014
3015 let m0 = render_mask_mip(grid, &cam, w, h, 0);
3016 let m2 = render_mask_mip(grid, &cam, w, h, 2);
3017
3018 let c0 = m0.iter().filter(|&&b| b).count();
3019 let c2 = m2.iter().filter(|&&b| b).count();
3020 assert!(c0 > 200 && c2 > 200, "both mips visible (c0={c0} c2={c2})");
3021 let ratio = c2 as f32 / c0 as f32;
3027 assert!(
3028 (0.7..1.4).contains(&ratio),
3029 "mip-2 coverage {c2} vs mip-0 {c0} (ratio {ratio:.2}) diverged"
3030 );
3031 }
3032
3033 #[test]
3039 #[ignore = "perf benchmark — run explicitly with --ignored"]
3040 fn bench_terrain() {
3041 use std::time::Instant;
3042 const NC: i32 = 6;
3044 let cs = crate::grid_view::CHUNK_SIZE_Z; let _ = cs;
3046 let mut vxls: Vec<roxlap_formats::vxl::Vxl> = Vec::new();
3047 for cy in 0..NC {
3048 for cx in 0..NC {
3049 let (ox, oy) = (cx * 128, cy * 128);
3050 let mut v = roxlap_formats::vxl::Vxl::from_dense(128, |x, y, z| {
3051 let (gx, gy) = (ox + x as i32, oy + y as i32);
3052 let surf = 90 + ((gx / 7 + gy / 9).rem_euclid(40)) + ((gx / 23).rem_euclid(20));
3053 (z as i32 >= surf).then_some(0x80_50_70_90 + (x ^ y) % 0x30)
3054 });
3055 v.generate_mips(4);
3056 vxls.push(v);
3057 }
3058 }
3059 let views: Vec<Option<GridView>> = vxls
3060 .iter()
3061 .map(|v| Some(GridView::from_single_vxl(v)))
3062 .collect();
3063 let cg = crate::ChunkGrid {
3064 chunks: &views,
3065 origin_chunk_xy: [0, 0],
3066 origin_chunk_z: 0,
3067 chunks_x: NC as u32,
3068 chunks_y: NC as u32,
3069 chunks_z: 1,
3070 };
3071 let grid = GridView::from_chunk_grid(&cg, 128);
3072
3073 let (w, h) = (960u32, 600u32);
3074 let mut settings = OpticastSettings::for_oracle_framebuffer(w, h);
3075 settings.max_scan_dist = 512;
3076 let n = (w * h) as usize;
3077 let mut fb = vec![0u32; n];
3078 let mut zb = vec![f32::INFINITY; n];
3079 let centre = [f64::from(NC * 128) / 2.0, f64::from(NC * 128) / 2.0, 60.0];
3080
3081 let poses = [
3084 (
3085 "horizon",
3086 Camera::from_yaw_pitch([20.0, 20.0, 40.0], 0.6, 0.15),
3087 ),
3088 ("down", Camera::orbit(0.7, 1.0, 130.0, centre)),
3089 ];
3090 for (name, cam) in poses {
3091 {
3092 let mut sink = RasterSink::new(&mut fb, &mut zb);
3093 prof::reset();
3094 render_dda(
3095 &cam,
3096 &settings,
3097 grid,
3098 w as usize,
3099 &DdaEnv::default(),
3100 0,
3101 &mut sink,
3102 );
3103 }
3104 let (cells, bricks, surf) = prof::read();
3105 let iters = 6;
3106 let t0 = Instant::now();
3107 for _ in 0..iters {
3108 let mut sink = RasterSink::new(&mut fb, &mut zb);
3109 render_dda(
3110 &cam,
3111 &settings,
3112 grid,
3113 w as usize,
3114 &DdaEnv::default(),
3115 0,
3116 &mut sink,
3117 );
3118 }
3119 let ms = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(iters);
3120 let hits = fb.iter().filter(|&&c| c != 0).count();
3121 eprintln!(
3122 "[{name}] {w}x{h} 1-thread: {ms:.1} ms | hits={hits}/{n} | per-px: cells={:.1} bricks={:.1} surf={:.1}",
3123 cells as f64 / n as f64,
3124 bricks as f64 / n as f64,
3125 surf as f64 / n as f64,
3126 );
3127 }
3128 }
3129
3130 #[test]
3134 fn parallel_matches_sequential() {
3135 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
3136 let surf = 28 + ((x / 4 + y / 6) % 13);
3137 (z >= surf).then_some(0x80_40_60_80 + (x ^ y) % 0x30)
3138 });
3139 let grid = GridView::from_single_vxl(&vxl);
3140 let (w, h) = (96u32, 96u32);
3141 let cam = Camera::orbit(0.8, 0.55, 100.0, [32.0, 32.0, 40.0]);
3142 let env = DdaEnv {
3143 sky: None,
3144 fog_color: 0x00_20_30_40,
3145 fog_max_dist: 120.0,
3146 side_shades: [0, 0, 0, 0, 0x30, 0x10],
3147 materials: None,
3148 terrain_materials: &[],
3149 lights: CpuLights::default(),
3150 world_shadow: None,
3151 };
3152
3153 let (seq_fb, seq_zb) = render_brickmap_env(grid, &cam, w, h, &env);
3154
3155 let n = (w * h) as usize;
3156 let mut par_fb = vec![0u32; n];
3157 let mut par_zb = vec![f32::INFINITY; n];
3158 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
3159 let (cache, mip) = local_cache(&grid, 0);
3160 render_dda_parallel(
3161 &cam,
3162 &settings,
3163 grid,
3164 &mut par_fb,
3165 &mut par_zb,
3166 w as usize,
3167 &env,
3168 &cache,
3169 mip,
3170 );
3171 assert!(par_fb == seq_fb, "parallel colour differs from sequential");
3172 assert!(
3173 par_zb
3174 .iter()
3175 .zip(&seq_zb)
3176 .all(|(a, b)| a.to_bits() == b.to_bits()),
3177 "parallel depth differs from sequential"
3178 );
3179 }
3180
3181 #[test]
3187 fn cliff_side_is_solid_not_see_through() {
3188 const TOP_Z: u32 = 50;
3189 const COL: u32 = 0x80_77_88_99;
3190 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |_, _, z| (z >= TOP_Z).then_some(COL));
3191 let grid = GridView::from_single_vxl(&vxl);
3192
3193 assert_eq!(grid.voxel_color(4, 4, TOP_Z), Some(COL));
3195 assert_eq!(grid.voxel_color(4, 4, 150), None);
3197 assert_eq!(grid.surface_color(4, 4, 150), Some(COL));
3200 assert_eq!(grid.surface_color(4, 4, 10), None);
3202 }
3203
3204 #[test]
3207 fn camera_inside_solid_hits_everywhere() {
3208 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, _| Some(0x80_55_55_55));
3209 let grid = GridView::from_single_vxl(&vxl);
3210 let cam = Camera {
3211 pos: [8.0, 8.0, 128.0],
3212 right: [1.0, 0.0, 0.0],
3213 down: [0.0, 1.0, 0.0],
3214 forward: [0.0, 0.0, 1.0],
3215 };
3216 let (w, h) = (32u32, 32u32);
3217 let mask = render_mask(grid, &cam, w, h);
3218 assert!(
3219 mask.iter().all(|&b| b),
3220 "every ray must hit when the camera is inside solid"
3221 );
3222 }
3223
3224 #[test]
3230 fn single_voxel_silhouette_has_no_notch() {
3231 const C: u32 = 0x80_FF_80_40;
3232 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |x, y, z| {
3233 (x == 8 && y == 8 && z == 8).then_some(C)
3234 });
3235 let grid = GridView::from_single_vxl(&vxl);
3236
3237 let cam = Camera::orbit(0.7, 0.6, 4.0, [8.5, 8.5, 8.5]);
3240 let (w, h) = (96u32, 96u32);
3241 let mask = render_mask(grid, &cam, w, h);
3242
3243 let hits = mask.iter().filter(|&&b| b).count();
3244 assert!(
3245 hits > 30,
3246 "silhouette too small to be meaningful: {hits} px"
3247 );
3248 assert!(
3249 rows_have_no_holes(&mask, w, h),
3250 "row-interior gap in single-voxel silhouette (notch)"
3251 );
3252 assert!(
3253 cols_have_no_holes(&mask, w, h),
3254 "column-interior gap in single-voxel silhouette (notch)"
3255 );
3256 }
3257}