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}
86
87#[derive(Clone, Copy)]
89pub struct CpuPointLight {
90 pub pos: [f32; 3],
92 pub color: [f32; 3],
94 pub intensity: f32,
99 pub radius: f32,
101 pub casts_shadow: bool,
106 pub spot_dir: [f32; 3],
109 pub cos_inner: f32,
111 pub cos_outer: f32,
115}
116
117#[derive(Clone, Copy, Default)]
123pub struct CpuLights<'a> {
124 pub enabled: bool,
126 pub sun: bool,
128 pub sun_dir: [f32; 3],
130 pub sun_color: [f32; 3],
132 pub sun_intensity: f32,
135 pub sun_casts_shadow: bool,
137 pub points: &'a [CpuPointLight],
139 pub ambient: [f32; 3],
141 pub bands: u32,
143 pub shadow_tint: [f32; 3],
145 pub shadow_strength: f32,
149 pub shadow_bias: f32,
152 pub shadow_max_dist: f32,
155}
156
157impl Default for DdaEnv<'_> {
158 fn default() -> Self {
159 Self {
160 sky: None,
161 fog_color: 0,
162 fog_max_dist: 0.0,
163 side_shades: [0; 6],
164 materials: None,
165 terrain_materials: &[],
166 lights: CpuLights::default(),
167 world_shadow: None,
168 }
169 }
170}
171
172pub trait PixelSink {
180 fn put(&mut self, idx: usize, color: u32, dist: f32);
184}
185
186pub struct RasterSink<'a> {
193 target: RasterTarget<'a>,
194 len: usize,
195}
196
197impl<'a> RasterSink<'a> {
198 #[must_use]
201 pub fn new(framebuffer: &'a mut [u32], zbuffer: &'a mut [f32]) -> Self {
202 debug_assert_eq!(framebuffer.len(), zbuffer.len());
203 let len = framebuffer.len();
204 Self {
205 target: RasterTarget::new(framebuffer, zbuffer),
206 len,
207 }
208 }
209}
210
211impl PixelSink for RasterSink<'_> {
212 fn put(&mut self, idx: usize, color: u32, dist: f32) {
213 if idx < self.len {
214 unsafe {
217 self.target.write_color(idx, color);
218 self.target.write_depth(idx, dist);
219 }
220 }
221 }
222}
223
224#[derive(Debug, Clone, Copy)]
226struct Hit {
227 color: u32,
228 dist: f32,
229}
230
231#[cfg(test)]
233pub(crate) mod prof {
234 use std::cell::Cell;
235 thread_local! {
236 pub static CELLS: Cell<u64> = const { Cell::new(0) };
237 pub static BRICKS: Cell<u64> = const { Cell::new(0) };
238 pub static SURF: Cell<u64> = const { Cell::new(0) };
239 }
240 pub fn reset() {
241 CELLS.with(|x| x.set(0));
242 BRICKS.with(|x| x.set(0));
243 SURF.with(|x| x.set(0));
244 }
245 pub fn read() -> (u64, u64, u64) {
246 (
247 CELLS.with(Cell::get),
248 BRICKS.with(Cell::get),
249 SURF.with(Cell::get),
250 )
251 }
252}
253
254#[inline]
273pub(crate) fn shade(color: u32, bright_sub: u32) -> u32 {
274 let a = ((color >> 24) & 0xff).saturating_sub(bright_sub);
275 let ch = |shift: u32| -> u32 { ((((color >> shift) & 0xff) * a) >> 7).min(255) };
276 0x8000_0000 | (ch(16) << 16) | (ch(8) << 8) | ch(0)
277}
278
279#[inline]
281fn cel_band(x: f32, bands: u32) -> f32 {
282 let b = bands as f32;
283 ((x * b).round() / b).clamp(0.0, 1.0)
284}
285
286#[inline]
289fn point_falloff(d: f32, radius: f32) -> f32 {
290 let x = (1.0 - d / radius).clamp(0.0, 1.0);
291 x * x
292}
293
294#[inline]
297fn smoothstep_scalar(edge0: f32, edge1: f32, x: f32) -> f32 {
298 if edge1 <= edge0 {
299 return if x < edge0 { 0.0 } else { 1.0 };
300 }
301 let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
302 t * t * (3.0 - 2.0 * t)
303}
304
305#[inline]
311fn spot_cone(ldir: [f32; 3], axis: [f32; 3], cos_inner: f32, cos_outer: f32) -> f32 {
312 if cos_outer <= -0.999 {
313 return 1.0;
314 }
315 let cd = -dot3(ldir, axis);
316 smoothstep_scalar(cos_outer, cos_inner, cd)
317}
318
319#[inline]
323fn face_normal_cpu(axis: usize, step: [i32; 3]) -> [f32; 3] {
324 let mut n = [0.0f32; 3];
325 if axis < 3 {
326 n[axis] = -(step[axis] as f32);
327 } else {
328 n[2] = -1.0;
329 }
330 n
331}
332
333#[inline]
334fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
335 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
336}
337
338pub(crate) trait ShadowTester {
346 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool;
347}
348
349pub trait WorldOccluder: Sync {
360 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool;
364}
365
366#[derive(Clone, Copy)]
373pub struct WorldShadowCtx<'a> {
374 pub occluder: &'a dyn WorldOccluder,
377 pub origin: [f32; 3],
381 pub cols: [[f32; 3]; 3],
385}
386
387impl<'a> WorldShadowCtx<'a> {
388 #[must_use]
391 pub fn identity(occluder: &'a dyn WorldOccluder) -> Self {
392 Self {
393 occluder,
394 origin: [0.0; 3],
395 cols: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
396 }
397 }
398}
399
400pub struct CompositeOccluder<'a> {
404 pub a: &'a dyn WorldOccluder,
406 pub b: &'a dyn WorldOccluder,
408}
409
410impl WorldOccluder for CompositeOccluder<'_> {
411 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
412 self.a.occluded_world(origin, dir, max_t) || self.b.occluded_world(origin, dir, max_t)
413 }
414}
415
416pub(crate) struct WorldShadow<'a> {
421 pub ctx: WorldShadowCtx<'a>,
422}
423
424impl ShadowTester for WorldShadow<'_> {
425 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
426 let c = &self.ctx.cols;
427 let wo = [
429 self.ctx.origin[0] + c[0][0] * origin[0] + c[1][0] * origin[1] + c[2][0] * origin[2],
430 self.ctx.origin[1] + c[0][1] * origin[0] + c[1][1] * origin[1] + c[2][1] * origin[2],
431 self.ctx.origin[2] + c[0][2] * origin[0] + c[1][2] * origin[1] + c[2][2] * origin[2],
432 ];
433 let wd = [
434 c[0][0] * dir[0] + c[1][0] * dir[1] + c[2][0] * dir[2],
435 c[0][1] * dir[0] + c[1][1] * dir[1] + c[2][1] * dir[2],
436 c[0][2] * dir[0] + c[1][2] * dir[1] + c[2][2] * dir[2],
437 ];
438 self.ctx.occluder.occluded_world(wo, wd, max_t)
439 }
440}
441
442fn shade_lit_cpu(
449 color: u32,
450 bright_sub: u32,
451 axis: usize,
452 step: [i32; 3],
453 cellc: [i32; 3],
454 cell_size: f32,
455 l: &CpuLights<'_>,
456 shadow: Option<&mut dyn ShadowTester>,
457) -> u32 {
458 let a_b = ((color >> 24) & 0xff).saturating_sub(bright_sub);
459 let ao = a_b as f32 / 128.0;
460 let albedo = [
461 ((color >> 16) & 0xff) as f32 / 255.0,
462 ((color >> 8) & 0xff) as f32 / 255.0,
463 (color & 0xff) as f32 / 255.0,
464 ];
465 let n = face_normal_cpu(axis, step);
466 let center = [
468 (cellc[0] as f32 + 0.5) * cell_size,
469 (cellc[1] as f32 + 0.5) * cell_size,
470 (cellc[2] as f32 + 0.5) * cell_size,
471 ];
472 shade_dynamic(albedo, ao, n, center, l, shadow)
473}
474
475pub(crate) fn shade_dynamic(
481 albedo: [f32; 3],
482 ao: f32,
483 n: [f32; 3],
484 sample: [f32; 3],
485 l: &CpuLights<'_>,
486 shadow: Option<&mut dyn ShadowTester>,
487) -> u32 {
488 let styled = l.bands > 0;
489 let mut shadow = shadow;
493 let shadow_origin = [
494 sample[0] + n[0] * l.shadow_bias,
495 sample[1] + n[1] * l.shadow_bias,
496 sample[2] + n[2] * l.shadow_bias,
497 ];
498 let in_shadow = 1.0 - l.shadow_strength;
499
500 let sun_key = if l.sun {
502 let ndl = dot3(n, l.sun_dir).max(0.0);
503 if ndl > 0.0 && l.sun_casts_shadow {
504 let occ = shadow
505 .as_deref_mut()
506 .is_some_and(|s| s.occluded(shadow_origin, l.sun_dir, l.shadow_max_dist));
507 if occ {
508 ndl * in_shadow
509 } else {
510 ndl
511 }
512 } else {
513 ndl
514 }
515 } else {
516 0.0
517 };
518
519 let mut lit = if styled {
521 let key = cel_band(sun_key, l.bands);
522 let m = |i: usize| {
523 let warm = l.sun_color[i] * l.sun_intensity;
524 (l.shadow_tint[i] + (warm - l.shadow_tint[i]) * key) * ao
525 };
526 [albedo[0] * m(0), albedo[1] * m(1), albedo[2] * m(2)]
527 } else {
528 let base = |i: usize| {
529 albedo[i] * l.ambient[i] * ao + albedo[i] * l.sun_color[i] * l.sun_intensity * sun_key
530 };
531 [base(0), base(1), base(2)]
532 };
533
534 for p in l.points {
537 let d3 = [
538 p.pos[0] - sample[0],
539 p.pos[1] - sample[1],
540 p.pos[2] - sample[2],
541 ];
542 let d2 = d3[0] * d3[0] + d3[1] * d3[1] + d3[2] * d3[2];
545 if d2 < p.radius * p.radius && d2 > 1e-8 {
546 let dist = d2.sqrt();
547 let inv = 1.0 / dist;
548 let ldir = [d3[0] * inv, d3[1] * inv, d3[2] * inv];
549 let ndl = dot3(n, ldir).max(0.0);
550 let cone = spot_cone(ldir, p.spot_dir, p.cos_inner, p.cos_outer);
553 if ndl > 0.0 && cone > 0.0 {
554 let sh = if p.casts_shadow
556 && shadow
557 .as_deref_mut()
558 .is_some_and(|s| s.occluded(shadow_origin, ldir, dist))
559 {
560 in_shadow
561 } else {
562 1.0
563 };
564 let mut f = ndl * point_falloff(dist, p.radius) * cone * sh;
565 if styled {
566 f = cel_band(f, l.bands);
567 }
568 for i in 0..3 {
569 lit[i] += albedo[i] * p.color[i] * p.intensity * f;
570 }
571 }
572 }
573 }
574
575 let pack = |v: f32| -> u32 { (v.clamp(0.0, 1.0) * 255.0) as u32 };
576 0x8000_0000 | (pack(lit[0]) << 16) | (pack(lit[1]) << 8) | pack(lit[2])
577}
578
579#[inline]
583fn apply_fog(color: u32, depth: f32, env: &DdaEnv<'_>) -> u32 {
584 if env.fog_max_dist <= 0.0 {
585 return color;
586 }
587 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
588 let f = ((depth / env.fog_max_dist).clamp(0.0, 1.0) * 256.0) as u32; let g = 256 - f;
590 let fog = env.fog_color;
591 let mix = |shift: u32| -> u32 {
592 let src = (color >> shift) & 0xff;
593 let dst = (fog >> shift) & 0xff;
594 ((src * g + dst * f) >> 8).min(255)
595 };
596 0x8000_0000 | (mix(16) << 16) | (mix(8) << 8) | mix(0)
597}
598
599#[inline]
602fn composite_over(accum: [f32; 3], trans: f32, bg: u32) -> u32 {
603 let b = rgb_to_f32(bg);
604 f32_to_rgb([
605 accum[0] + trans * b[0],
606 accum[1] + trans * b[1],
607 accum[2] + trans * b[2],
608 ])
609}
610
611#[inline]
616fn finalize_exit(
617 touched: bool,
618 accum: [f32; 3],
619 trans: f32,
620 env: &DdaEnv<'_>,
621 dir: [f32; 3],
622 dist: f32,
623) -> Option<Hit> {
624 if !touched {
625 return None;
626 }
627 let bg = match env.sky {
628 Some(s) => sample_sky(s, dir),
629 None => 0x8000_0000 | (env.fog_color & 0x00ff_ffff),
630 };
631 Some(Hit {
632 color: composite_over(accum, trans, bg),
633 dist,
634 })
635}
636
637#[inline]
640#[allow(clippy::cast_precision_loss)]
641fn rgb_to_f32(c: u32) -> [f32; 3] {
642 [
643 ((c >> 16) & 0xff) as f32 / 255.0,
644 ((c >> 8) & 0xff) as f32 / 255.0,
645 (c & 0xff) as f32 / 255.0,
646 ]
647}
648
649#[inline]
651#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
652fn f32_to_rgb(c: [f32; 3]) -> u32 {
653 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
654 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
655}
656
657#[allow(
666 clippy::cast_possible_truncation,
667 clippy::cast_sign_loss,
668 clippy::cast_precision_loss
669)]
670fn sample_sky(sky: &Sky, dir: [f32; 3]) -> u32 {
671 let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
672 if len < 1e-9 {
673 return 0x8000_0000;
674 }
675 let d = [dir[0] / len, dir[1] / len, dir[2] / len];
676 let xsiz_full = sky.lat.len().max(1) as i32; let pi = std::f32::consts::PI;
678 let elev01 = (-d[2]).clamp(-1.0, 1.0).acos() / pi; let x = (elev01 * xsiz_full as f32) as i32;
683 let x = x.clamp(0, xsiz_full - 1);
684 let y = if sky.ysiz <= 1 {
686 0
687 } else {
688 let az = d[1].atan2(d[0]); let yf = ((az / (pi * 2.0)) + 0.5) * sky.ysiz as f32;
690 (yf as i32).rem_euclid(sky.ysiz)
691 };
692 let idx = (y * xsiz_full + x) as usize;
693 let px = sky.pixels.get(idx).copied().unwrap_or(0) as u32;
694 0x8000_0000 | (px & 0x00ff_ffff)
695}
696
697#[allow(clippy::cast_possible_truncation)]
707pub fn render_sky_fill(
708 fb: &mut [u32],
709 zb: &[f32],
710 pitch_pixels: usize,
711 width: u32,
712 height: u32,
713 cam: &CameraState,
714 settings: &OpticastSettings,
715 sky: &Sky,
716) {
717 fb.par_chunks_mut(pitch_pixels)
722 .take(height as usize)
723 .enumerate()
724 .for_each(|(py, frow)| {
725 let row = py * pitch_pixels;
726 #[allow(clippy::cast_possible_truncation)]
727 let py = py as u32;
728 for px in 0..width {
729 let idx = row + px as usize;
730 if zb[idx].is_finite() {
731 continue; }
733 let (_origin, dir) = pixel_ray(cam, settings, px, py);
734 frow[px as usize] = sample_sky(sky, dir);
735 }
736 });
737}
738
739#[must_use]
751pub fn pixel_ray(
752 cs: &CameraState,
753 settings: &OpticastSettings,
754 px: u32,
755 py: u32,
756) -> ([f32; 3], [f32; 3]) {
757 #[allow(clippy::cast_precision_loss)]
759 let sx = px as f32 - settings.hx;
760 #[allow(clippy::cast_precision_loss)]
761 let sy = py as f32 - settings.hy;
762 let dir = [
763 sx * cs.right[0] + sy * cs.down[0] + settings.hz * cs.forward[0],
764 sx * cs.right[1] + sy * cs.down[1] + settings.hz * cs.forward[1],
765 sx * cs.right[2] + sy * cs.down[2] + settings.hz * cs.forward[2],
766 ];
767 (cs.pos, dir)
768}
769
770pub(crate) fn intersect_aabb(
776 o: [f32; 3],
777 dir: [f32; 3],
778 lo: [f32; 3],
779 hi: [f32; 3],
780) -> Option<(f32, f32)> {
781 let mut t0 = 0.0f32;
782 let mut t1 = f32::INFINITY;
783 for a in 0..3 {
784 if dir[a].abs() < 1e-9 {
785 if o[a] < lo[a] || o[a] > hi[a] {
787 return None;
788 }
789 } else {
790 let inv = 1.0 / dir[a];
791 let mut ta = (lo[a] - o[a]) * inv;
792 let mut tb = (hi[a] - o[a]) * inv;
793 if ta > tb {
794 core::mem::swap(&mut ta, &mut tb);
795 }
796 t0 = t0.max(ta);
797 t1 = t1.min(tb);
798 if t0 > t1 {
799 return None;
800 }
801 }
802 }
803 Some((t0, t1))
804}
805
806const BRICK: i32 = 8;
808
809#[derive(Debug)]
823pub(crate) struct BrickMap {
824 nb: [i32; 3],
826 bits: Vec<u64>,
829 ns: [i32; 3],
832 super_bits: Vec<u64>,
837}
838
839const SUPER: i32 = BRICK * BRICK;
841
842impl BrickMap {
843 #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
846 fn build(grid: &GridView<'_>, mip: u32) -> Self {
847 let vsid_m = (grid.vsid >> mip).max(1) as i32;
848 let z_m = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1) as i32;
849 let nb = [
850 (vsid_m + BRICK - 1) / BRICK,
851 (vsid_m + BRICK - 1) / BRICK,
852 (z_m + BRICK - 1) / BRICK,
853 ];
854 let ns = [
855 (nb[0] + BRICK - 1) / BRICK,
856 (nb[1] + BRICK - 1) / BRICK,
857 (nb[2] + BRICK - 1) / BRICK,
858 ];
859 let count = (nb[0] * nb[1] * nb[2]) as usize;
860 let scount = (ns[0] * ns[1] * ns[2]) as usize;
861 let mut bits = vec![0u64; count.div_ceil(64)];
862 let mut super_bits = vec![0u64; scount.div_ceil(64)];
863 for y in 0..vsid_m {
864 for x in 0..vsid_m {
865 let (bx, by) = (x / BRICK, y / BRICK);
866 grid.for_each_run_mip(x as u32, y as u32, mip, |top, bot| {
867 for bz in (top / BRICK)..=((bot - 1) / BRICK) {
868 let idx = ((bz * nb[1] + by) * nb[0] + bx) as usize;
869 bits[idx / 64] |= 1u64 << (idx % 64);
870 let sidx =
871 (((bz / BRICK) * ns[1] + by / BRICK) * ns[0] + bx / BRICK) as usize;
872 super_bits[sidx / 64] |= 1u64 << (sidx % 64);
873 }
874 });
875 }
876 }
877 Self {
878 nb,
879 bits,
880 ns,
881 super_bits,
882 }
883 }
884
885 #[inline]
887 #[allow(clippy::cast_sign_loss)]
888 fn occupied(&self, b: [i32; 3]) -> bool {
889 if b[0] < 0
890 || b[0] >= self.nb[0]
891 || b[1] < 0
892 || b[1] >= self.nb[1]
893 || b[2] < 0
894 || b[2] >= self.nb[2]
895 {
896 return false;
897 }
898 let idx = ((b[2] * self.nb[1] + b[1]) * self.nb[0] + b[0]) as usize;
899 (self.bits[idx / 64] >> (idx % 64)) & 1 != 0
900 }
901
902 #[inline]
904 #[allow(clippy::cast_sign_loss)]
905 fn occupied_super(&self, s: [i32; 3]) -> bool {
906 if s[0] < 0
907 || s[0] >= self.ns[0]
908 || s[1] < 0
909 || s[1] >= self.ns[1]
910 || s[2] < 0
911 || s[2] >= self.ns[2]
912 {
913 return false;
914 }
915 let idx = ((s[2] * self.ns[1] + s[1]) * self.ns[0] + s[0]) as usize;
916 (self.super_bits[idx / 64] >> (idx % 64)) & 1 != 0
917 }
918}
919
920pub(crate) fn dda_setup(
926 origin: [f32; 3],
927 dir: [f32; 3],
928 cell: [i32; 3],
929 cell_size: f32,
930) -> ([i32; 3], [f32; 3], [f32; 3]) {
931 let mut step = [0i32; 3];
932 let mut t_max = [f32::INFINITY; 3];
933 let mut t_delta = [f32::INFINITY; 3];
934 for a in 0..3 {
935 if dir[a] > 1e-9 {
936 step[a] = 1;
937 #[allow(clippy::cast_precision_loss)]
938 let boundary = (cell[a] + 1) as f32 * cell_size;
939 t_max[a] = (boundary - origin[a]) / dir[a];
940 t_delta[a] = cell_size / dir[a];
941 } else if dir[a] < -1e-9 {
942 step[a] = -1;
943 #[allow(clippy::cast_precision_loss)]
944 let boundary = cell[a] as f32 * cell_size;
945 t_max[a] = (boundary - origin[a]) / dir[a];
946 t_delta[a] = -cell_size / dir[a];
947 }
948 }
949 (step, t_max, t_delta)
950}
951
952#[inline]
955pub(crate) fn min_axis(t_max: [f32; 3]) -> usize {
956 if t_max[0] <= t_max[1] && t_max[0] <= t_max[2] {
957 0
958 } else if t_max[1] <= t_max[2] {
959 1
960 } else {
961 2
962 }
963}
964
965#[derive(Debug, Default)]
975pub struct BrickCache {
976 maps: HashMap<(i32, i32, i32, u32), (u64, BrickMap)>,
977}
978
979impl BrickCache {
980 #[must_use]
983 pub fn new() -> Self {
984 Self::default()
985 }
986
987 pub fn ensure(&mut self, chunk: [i32; 3], mip: u32, version: u64, view: &GridView<'_>) {
990 let key = (chunk[0], chunk[1], chunk[2], mip);
991 let stale = self.maps.get(&key).map_or(true, |(v, _)| *v != version);
992 if stale {
993 self.maps.insert(key, (version, BrickMap::build(view, mip)));
994 }
995 }
996
997 #[inline]
998 fn get(&self, chunk: [i32; 3], mip: u32) -> Option<&BrickMap> {
999 self.maps
1000 .get(&(chunk[0], chunk[1], chunk[2], mip))
1001 .map(|(_, m)| m)
1002 }
1003
1004 pub fn retain_chunks(&mut self, keep: impl Fn([i32; 3]) -> bool) {
1007 self.maps.retain(|k, _| keep([k.0, k.1, k.2]));
1008 }
1009
1010 #[must_use]
1016 pub fn brick_occupied_at(&self, chunk: [i32; 3], mip: u32, cell: [i32; 3]) -> Option<bool> {
1017 self.get(chunk, mip)
1018 .map(|m| m.occupied([cell[0] >> 3, cell[1] >> 3, cell[2] >> 3]))
1019 }
1020
1021 #[must_use]
1024 pub fn super_occupied_at(&self, chunk: [i32; 3], mip: u32, cell: [i32; 3]) -> Option<bool> {
1025 self.get(chunk, mip)
1026 .map(|m| m.occupied_super([cell[0] >> 6, cell[1] >> 6, cell[2] >> 6]))
1027 }
1028}
1029
1030#[allow(clippy::cast_possible_wrap)]
1035fn local_cache(grid: &GridView<'_>, requested_mip: u32) -> (BrickCache, u32) {
1036 let mip = effective_mip(grid, requested_mip);
1037 let mut cache = BrickCache::new();
1038 if let Some(cg) = grid.chunk_grid {
1039 for dz in 0..cg.chunks_z as i32 {
1040 for dy in 0..cg.chunks_y as i32 {
1041 for dx in 0..cg.chunks_x as i32 {
1042 let slot = ((dz * cg.chunks_y as i32 + dy) * cg.chunks_x as i32 + dx) as usize;
1043 if let Some(Some(view)) = cg.chunks.get(slot) {
1044 let ch = [
1045 cg.origin_chunk_xy[0] + dx,
1046 cg.origin_chunk_xy[1] + dy,
1047 cg.origin_chunk_z + dz,
1048 ];
1049 cache.ensure(ch, mip, 0, view);
1050 }
1051 }
1052 }
1053 }
1054 } else {
1055 cache.ensure([0, 0, 0], mip, 0, grid);
1056 }
1057 (cache, mip)
1058}
1059
1060#[must_use]
1065pub fn effective_mip(grid: &GridView<'_>, requested: u32) -> u32 {
1066 if requested == 0 {
1067 return 0;
1068 }
1069 let mut m = requested;
1070 if let Some(cg) = grid.chunk_grid {
1071 for c in cg.chunks.iter().flatten() {
1072 m = m.min(c.mip_count().saturating_sub(1));
1073 }
1074 } else {
1075 m = m.min(grid.mip_count().saturating_sub(1));
1076 }
1077 m
1078}
1079
1080struct Sampler<'a> {
1094 grid: GridView<'a>,
1095 bricks: &'a BrickCache,
1096 mip: u32,
1099 xy_shift: u32,
1108 xy_mask: i32,
1109 z_shift: u32,
1110 z_mask: i32,
1111 cur_ch: [i32; 3],
1112 cur_view: Option<GridView<'a>>,
1113 cur_brick: Option<&'a BrickMap>,
1114 has_cur: bool,
1115}
1116
1117impl<'a> Sampler<'a> {
1118 fn new(grid: GridView<'a>, bricks: &'a BrickCache, mip: u32) -> Self {
1119 let cs_xy = (grid.chunk_size_xy >> mip).max(1);
1120 let cs_z = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1);
1121 debug_assert!(
1122 cs_xy.is_power_of_two() && cs_z.is_power_of_two(),
1123 "chunk dims must be powers of two for the shift/mask split"
1124 );
1125 #[allow(clippy::cast_possible_wrap)]
1126 Self {
1127 grid,
1128 bricks,
1129 mip,
1130 xy_shift: cs_xy.trailing_zeros(),
1131 xy_mask: cs_xy as i32 - 1,
1132 z_shift: cs_z.trailing_zeros(),
1133 z_mask: cs_z as i32 - 1,
1134 cur_ch: [0; 3],
1135 cur_view: None,
1136 cur_brick: None,
1137 has_cur: false,
1138 }
1139 }
1140
1141 fn select_chunk(&mut self, ch: [i32; 3]) {
1143 if self.has_cur && self.cur_ch == ch {
1144 return;
1145 }
1146 self.cur_view = self.grid.chunk_at_xyz(ch);
1147 self.cur_brick = self.bricks.get(ch, self.mip);
1148 self.cur_ch = ch;
1149 self.has_cur = true;
1150 }
1151
1152 #[allow(clippy::cast_sign_loss)]
1157 fn locate(&self, c: [i32; 3]) -> ([i32; 3], [u32; 3]) {
1158 let ch = [
1159 c[0] >> self.xy_shift,
1160 c[1] >> self.xy_shift,
1161 c[2] >> self.z_shift,
1162 ];
1163 let loc = [
1164 (c[0] & self.xy_mask) as u32,
1165 (c[1] & self.xy_mask) as u32,
1166 (c[2] & self.z_mask) as u32,
1167 ];
1168 (ch, loc)
1169 }
1170
1171 #[allow(clippy::cast_possible_wrap)]
1175 fn hit(&mut self, c: [i32; 3]) -> Option<u32> {
1176 #[cfg(test)]
1177 prof::SURF.with(|x| x.set(x.get() + 1));
1178 let (ch, loc) = self.locate(c);
1179 self.select_chunk(ch);
1180 let occupied = self.cur_brick.is_some_and(|bm| {
1181 bm.occupied([
1182 loc[0] as i32 / BRICK,
1183 loc[1] as i32 / BRICK,
1184 loc[2] as i32 / BRICK,
1185 ])
1186 });
1187 if !occupied {
1188 return None;
1189 }
1190 self.cur_view?
1191 .surface_color_mip(loc[0], loc[1], loc[2], self.mip)
1192 .map(|c| c.0)
1193 }
1194
1195 #[inline]
1197 fn cells_per_chunk_xy(&self) -> i32 {
1198 1 << self.xy_shift
1199 }
1200 #[inline]
1201 fn cells_per_chunk_z(&self) -> i32 {
1202 1 << self.z_shift
1203 }
1204
1205 #[allow(clippy::cast_sign_loss)]
1210 fn brick_occupied(&mut self, brick: [i32; 3]) -> bool {
1211 let c0 = [brick[0] << 3, brick[1] << 3, brick[2] << 3];
1213 let ch = [
1214 c0[0] >> self.xy_shift,
1215 c0[1] >> self.xy_shift,
1216 c0[2] >> self.z_shift,
1217 ];
1218 self.select_chunk(ch);
1219 self.cur_brick.is_some_and(|bm| {
1220 bm.occupied([
1221 (c0[0] & self.xy_mask) >> 3,
1222 (c0[1] & self.xy_mask) >> 3,
1223 (c0[2] & self.z_mask) >> 3,
1224 ])
1225 })
1226 }
1227
1228 #[allow(clippy::cast_sign_loss)]
1233 fn super_occupied(&mut self, s: [i32; 3]) -> bool {
1234 let c0 = [s[0] << 6, s[1] << 6, s[2] << 6];
1236 let ch = [
1237 c0[0] >> self.xy_shift,
1238 c0[1] >> self.xy_shift,
1239 c0[2] >> self.z_shift,
1240 ];
1241 self.select_chunk(ch);
1242 self.cur_brick.is_some_and(|bm| {
1243 bm.occupied_super([
1244 (c0[0] & self.xy_mask) >> 6,
1245 (c0[1] & self.xy_mask) >> 6,
1246 (c0[2] & self.z_mask) >> 6,
1247 ])
1248 })
1249 }
1250}
1251
1252const SHADOW_MAX_STEPS: u32 = 1024;
1256
1257struct SamplerShadow<'s, 'a> {
1264 sampler: &'s mut Sampler<'a>,
1265 cell_size: f32,
1266 lo_c: [i32; 3],
1267 hi_c: [i32; 3],
1268}
1269
1270impl ShadowTester for SamplerShadow<'_, '_> {
1271 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
1272 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
1273 let cs = self.cell_size;
1274 let has_super =
1282 self.sampler.cells_per_chunk_xy() >= SUPER && self.sampler.cells_per_chunk_z() >= SUPER;
1283 let has_brick =
1284 self.sampler.cells_per_chunk_xy() >= BRICK && self.sampler.cells_per_chunk_z() >= BRICK;
1285 let mut cellc = [
1286 (origin[0] / cs).floor() as i32,
1287 (origin[1] / cs).floor() as i32,
1288 (origin[2] / cs).floor() as i32,
1289 ];
1290 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cs);
1291 let inv = [
1292 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
1293 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
1294 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
1295 ];
1296 let mut t_curr = 0.0f32;
1297 let mut used = 0u32;
1298 while used < SHADOW_MAX_STEPS {
1299 if cellc[0] < self.lo_c[0]
1300 || cellc[0] >= self.hi_c[0]
1301 || cellc[1] < self.lo_c[1]
1302 || cellc[1] >= self.hi_c[1]
1303 || cellc[2] < self.lo_c[2]
1304 || cellc[2] >= self.hi_c[2]
1305 {
1306 return false; }
1308 if t_curr > max_t {
1309 return false; }
1311 let skip_shift = if has_super
1315 && !self
1316 .sampler
1317 .super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
1318 {
1319 Some(6u32)
1320 } else if has_brick
1321 && !self
1322 .sampler
1323 .brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
1324 {
1325 Some(3u32)
1326 } else {
1327 None
1328 };
1329 if let Some(sh) = skip_shift {
1330 let mut best_t = f32::INFINITY;
1331 let mut best_axis = 3usize;
1332 let mut plane = [0i32; 3];
1333 for a in 0..3 {
1334 if step[a] == 0 {
1335 continue;
1336 }
1337 let idx = cellc[a] >> sh;
1338 plane[a] = if step[a] > 0 {
1339 (idx + 1) << sh
1340 } else {
1341 idx << sh
1342 };
1343 let tb = (plane[a] as f32 * cs - origin[a]) * inv[a];
1344 if tb < best_t {
1345 best_t = tb;
1346 best_axis = a;
1347 }
1348 }
1349 if best_axis == 3 {
1350 return false;
1351 }
1352 let pb = [
1353 origin[0] + dir[0] * (best_t + 1e-4),
1354 origin[1] + dir[1] * (best_t + 1e-4),
1355 origin[2] + dir[2] * (best_t + 1e-4),
1356 ];
1357 let mut nc = [
1358 (pb[0] / cs).floor() as i32,
1359 (pb[1] / cs).floor() as i32,
1360 (pb[2] / cs).floor() as i32,
1361 ];
1362 nc[best_axis] = if step[best_axis] > 0 {
1363 plane[best_axis]
1364 } else {
1365 plane[best_axis] - 1
1366 };
1367 let crossed =
1372 cellc[0].abs_diff(nc[0]) + cellc[1].abs_diff(nc[1]) + cellc[2].abs_diff(nc[2]);
1373 if used.saturating_add(crossed) >= SHADOW_MAX_STEPS {
1374 return false;
1375 }
1376 used += crossed;
1377 cellc = nc;
1378 for a in 0..3 {
1379 if step[a] > 0 {
1380 t_max[a] = ((cellc[a] + 1) as f32 * cs - origin[a]) * inv[a];
1381 } else if step[a] < 0 {
1382 t_max[a] = (cellc[a] as f32 * cs - origin[a]) * inv[a];
1383 }
1384 }
1385 t_curr = best_t.max(t_curr);
1386 continue;
1387 }
1388 if self.sampler.hit(cellc).is_some() {
1389 return true; }
1391 let axis = min_axis(t_max);
1392 t_curr = t_max[axis];
1393 cellc[axis] += step[axis];
1394 t_max[axis] += t_delta[axis];
1395 used += 1;
1396 }
1397 false
1398 }
1399}
1400
1401#[allow(
1422 clippy::too_many_arguments,
1423 clippy::cast_possible_truncation,
1424 clippy::cast_sign_loss,
1425 clippy::cast_precision_loss
1426)]
1427fn cell_walk_skip(
1428 origin: [f32; 3],
1429 dir: [f32; 3],
1430 fwd_dot: f32,
1431 sampler: &mut Sampler<'_>,
1432 lo_c: [i32; 3],
1433 hi_c: [i32; 3],
1434 cell_size: f32,
1435 t_enter: f32,
1436 t_exit: f32,
1437 max_dist: f32,
1438 env: &DdaEnv<'_>,
1439) -> Option<Hit> {
1440 let has_super = sampler.cells_per_chunk_xy() >= SUPER && sampler.cells_per_chunk_z() >= SUPER;
1441 let has_brick = sampler.cells_per_chunk_xy() >= BRICK && sampler.cells_per_chunk_z() >= BRICK;
1442
1443 let start = t_enter + 1e-4;
1444 let p = [
1445 origin[0] + dir[0] * start,
1446 origin[1] + dir[1] * start,
1447 origin[2] + dir[2] * start,
1448 ];
1449 let mut cellc = [
1450 ((p[0] / cell_size).floor() as i32).clamp(lo_c[0], hi_c[0] - 1),
1451 ((p[1] / cell_size).floor() as i32).clamp(lo_c[1], hi_c[1] - 1),
1452 ((p[2] / cell_size).floor() as i32).clamp(lo_c[2], hi_c[2] - 1),
1453 ];
1454 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cell_size);
1455 let inv = [
1459 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
1460 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
1461 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
1462 ];
1463 let mut t_curr = t_enter;
1464 let mut last_axis = 3usize;
1465 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
1468 let shadow_casts = env.lights.enabled
1472 && env.lights.shadow_strength > 0.0
1473 && (env.lights.sun_casts_shadow || env.lights.points.iter().any(|p| p.casts_shadow));
1474
1475 let mut accum = [0.0f32; 3];
1480 let mut trans = 1.0f32;
1481 let mut touched = false;
1482 let mut prev_solid = false;
1483 let mut prev_mat = 0u8;
1484
1485 let span = (hi_c[0] - lo_c[0]) + (hi_c[1] - lo_c[1]) + (hi_c[2] - lo_c[2]);
1488 let max_steps = span.max(0) as usize + 16;
1489 for _ in 0..max_steps {
1490 if cellc[0] < lo_c[0]
1491 || cellc[0] >= hi_c[0]
1492 || cellc[1] < lo_c[1]
1493 || cellc[1] >= hi_c[1]
1494 || cellc[2] < lo_c[2]
1495 || cellc[2] >= hi_c[2]
1496 {
1497 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1498 }
1499 let depth = t_curr * fwd_dot;
1500 if depth > max_dist || t_curr > t_exit {
1501 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1502 }
1503 if env.fog_max_dist > 0.0 && depth >= env.fog_max_dist {
1509 let fog = 0x8000_0000 | (env.fog_color & 0x00ff_ffff);
1510 let color = if touched {
1511 composite_over(accum, trans, fog)
1512 } else {
1513 fog
1514 };
1515 return Some(Hit {
1516 color,
1517 dist: env.fog_max_dist,
1518 });
1519 }
1520
1521 let skip_shift = if has_super
1524 && !sampler.super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
1525 {
1526 Some(6u32)
1527 } else if has_brick
1528 && !sampler.brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
1529 {
1530 Some(3u32)
1531 } else {
1532 None
1533 };
1534 if let Some(sh) = skip_shift {
1535 #[cfg(test)]
1536 prof::BRICKS.with(|x| x.set(x.get() + 1));
1537 let mut best_t = f32::INFINITY;
1539 let mut best_axis = 3usize;
1540 let mut plane = [0i32; 3];
1541 for a in 0..3 {
1542 if step[a] == 0 {
1543 continue;
1544 }
1545 let idx = cellc[a] >> sh;
1546 plane[a] = if step[a] > 0 {
1547 (idx + 1) << sh
1548 } else {
1549 idx << sh
1550 };
1551 let tb = (plane[a] as f32 * cell_size - origin[a]) * inv[a];
1552 if tb < best_t {
1553 best_t = tb;
1554 best_axis = a;
1555 }
1556 }
1557 if best_axis == 3 {
1558 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1559 }
1560 let pb = [
1565 origin[0] + dir[0] * (best_t + 1e-4),
1566 origin[1] + dir[1] * (best_t + 1e-4),
1567 origin[2] + dir[2] * (best_t + 1e-4),
1568 ];
1569 let mut nc = [
1570 (pb[0] / cell_size).floor() as i32,
1571 (pb[1] / cell_size).floor() as i32,
1572 (pb[2] / cell_size).floor() as i32,
1573 ];
1574 nc[best_axis] = if step[best_axis] > 0 {
1575 plane[best_axis]
1576 } else {
1577 plane[best_axis] - 1
1578 };
1579 if nc[0] < lo_c[0]
1583 || nc[0] >= hi_c[0]
1584 || nc[1] < lo_c[1]
1585 || nc[1] >= hi_c[1]
1586 || nc[2] < lo_c[2]
1587 || nc[2] >= hi_c[2]
1588 {
1589 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1590 }
1591 cellc = nc;
1592 for a in 0..3 {
1595 if step[a] > 0 {
1596 t_max[a] = ((cellc[a] + 1) as f32 * cell_size - origin[a]) * inv[a];
1597 } else if step[a] < 0 {
1598 t_max[a] = (cellc[a] as f32 * cell_size - origin[a]) * inv[a];
1599 }
1600 }
1601 t_curr = best_t.max(t_curr);
1602 last_axis = best_axis;
1603 prev_solid = false; continue;
1605 }
1606
1607 #[cfg(test)]
1609 prof::CELLS.with(|x| x.set(x.get() + 1));
1610 if let Some(color) = sampler.hit(cellc) {
1611 let bright_sub = side_shade_sub(env, last_axis, step);
1612 let shaded = if env.lights.enabled {
1618 let casts = shadow_casts;
1619 let mut world_sh;
1625 let mut sampler_sh;
1626 let tester: Option<&mut dyn ShadowTester> = if !casts {
1627 None
1628 } else if let Some(ctx) = env.world_shadow {
1629 world_sh = WorldShadow { ctx };
1630 Some(&mut world_sh)
1631 } else {
1632 sampler_sh = SamplerShadow {
1633 sampler: &mut *sampler,
1634 cell_size,
1635 lo_c,
1636 hi_c,
1637 };
1638 Some(&mut sampler_sh)
1639 };
1640 shade_lit_cpu(
1641 color,
1642 bright_sub,
1643 last_axis,
1644 step,
1645 cellc,
1646 cell_size,
1647 &env.lights,
1648 tester,
1649 )
1650 } else {
1651 shade(color, bright_sub)
1652 };
1653 let lit = apply_fog(shaded, depth.max(0.0), env);
1654 let (m, mat_id) = match env.materials {
1658 Some(table) if !env.terrain_materials.is_empty() => {
1659 let id = material_for_color(env.terrain_materials, color);
1660 (table.get(id), id)
1661 }
1662 _ => (Material::OPAQUE, 0),
1663 };
1664 if m.is_opaque() {
1665 let color = if touched {
1669 composite_over(accum, trans, lit)
1670 } else {
1671 lit
1672 };
1673 return Some(Hit {
1674 color,
1675 dist: depth.max(0.0),
1676 });
1677 }
1678 let a = f32::from(m.alpha) / 255.0;
1679 if matches!(m.mode, roxlap_formats::material::BlendMode::Volumetric) {
1680 let t_exit = t_max[min_axis(t_max)];
1684 let seg_len = (t_exit - t_curr).max(0.0) * dir_len / cell_size;
1685 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
1686 let c = rgb_to_f32(lit);
1687 accum[0] += trans * eff_a * c[0];
1688 accum[1] += trans * eff_a * c[1];
1689 accum[2] += trans * eff_a * c[2];
1690 trans *= 1.0 - eff_a;
1691 touched = true;
1692 prev_mat = mat_id;
1693 if trans < 1.0 / 256.0 {
1694 return Some(Hit {
1695 color: f32_to_rgb(accum),
1696 dist: depth.max(0.0),
1697 });
1698 }
1699 } else if !prev_solid || mat_id != prev_mat {
1700 let c = rgb_to_f32(lit);
1704 accum[0] += trans * a * c[0];
1705 accum[1] += trans * a * c[1];
1706 accum[2] += trans * a * c[2];
1707 if !matches!(m.mode, roxlap_formats::material::BlendMode::Additive) {
1708 trans *= 1.0 - a; }
1710 touched = true;
1711 prev_mat = mat_id;
1712 if trans < 1.0 / 256.0 {
1713 return Some(Hit {
1714 color: f32_to_rgb(accum),
1715 dist: depth.max(0.0),
1716 });
1717 }
1718 }
1719 prev_solid = true;
1720 } else {
1721 prev_solid = false;
1722 }
1723 let axis = min_axis(t_max);
1724 last_axis = axis;
1725 t_curr = t_max[axis];
1726 cellc[axis] += step[axis];
1727 t_max[axis] += t_delta[axis];
1728 }
1729 None
1730}
1731
1732#[inline]
1738fn side_shade_sub(env: &DdaEnv<'_>, axis: usize, step: [i32; 3]) -> u32 {
1739 if axis >= 3 {
1740 return 0;
1741 }
1742 let face = axis * 2 + usize::from(step[axis] < 0);
1743 env.side_shades[face].max(0) as u32
1744}
1745
1746fn cast_ray(
1755 origin: [f32; 3],
1756 dir: [f32; 3],
1757 forward: [f32; 3],
1758 sampler: &mut Sampler<'_>,
1759 settings: &OpticastSettings,
1760 env: &DdaEnv<'_>,
1761) -> Option<Hit> {
1762 let (lo_i, hi_i) = sampler.grid.voxel_bounds();
1763 #[allow(clippy::cast_precision_loss)]
1764 let lo_f = [lo_i[0] as f32, lo_i[1] as f32, lo_i[2] as f32];
1765 #[allow(clippy::cast_precision_loss)]
1766 let hi_f = [hi_i[0] as f32, hi_i[1] as f32, hi_i[2] as f32];
1767 let (t_enter, t_exit) = intersect_aabb(origin, dir, lo_f, hi_f)?;
1768 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1769 #[allow(clippy::cast_precision_loss)]
1770 let max_dist = settings.max_scan_dist.max(1) as f32;
1771 let cell = 1i32 << sampler.mip;
1772 let cell_size = cell as f32;
1773 let lo_c = [
1774 lo_i[0].div_euclid(cell),
1775 lo_i[1].div_euclid(cell),
1776 lo_i[2].div_euclid(cell),
1777 ];
1778 let hi_c = [
1779 hi_i[0].div_euclid(cell),
1780 hi_i[1].div_euclid(cell),
1781 hi_i[2].div_euclid(cell),
1782 ];
1783 cell_walk_skip(
1784 origin, dir, fwd_dot, sampler, lo_c, hi_c, cell_size, t_enter, t_exit, max_dist, env,
1785 )
1786}
1787
1788pub fn render_dda(
1801 camera: &Camera,
1802 settings: &OpticastSettings,
1803 grid: GridView<'_>,
1804 pitch_pixels: usize,
1805 env: &DdaEnv<'_>,
1806 mip: u32,
1807 sink: &mut impl PixelSink,
1808) {
1809 let cs = camera_math::derive(
1810 camera,
1811 settings.xres,
1812 settings.yres,
1813 settings.hx,
1814 settings.hy,
1815 settings.hz,
1816 );
1817
1818 let (cache, mip) = local_cache(&grid, mip);
1821 let mut sampler = Sampler::new(grid, &cache, mip);
1822
1823 for py in settings.y_start..settings.y_end {
1824 let row = py as usize * pitch_pixels;
1825 for px in settings.x_start..settings.x_end {
1826 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py) {
1827 sink.put(row + px as usize, color, dist);
1828 }
1829 }
1830 }
1831}
1832
1833#[inline]
1838fn pixel_result(
1839 cs: &CameraState,
1840 settings: &OpticastSettings,
1841 sampler: &mut Sampler<'_>,
1842 env: &DdaEnv<'_>,
1843 px: u32,
1844 py: u32,
1845) -> Option<(u32, f32)> {
1846 let (origin, dir) = pixel_ray(cs, settings, px, py);
1847 if let Some(hit) = cast_ray(origin, dir, cs.forward, sampler, settings, env) {
1848 Some((hit.color, hit.dist))
1849 } else {
1850 env.sky.map(|sky| (sample_sky(sky, dir), f32::INFINITY))
1851 }
1852}
1853
1854#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
1869pub fn render_dda_parallel(
1870 camera: &Camera,
1871 settings: &OpticastSettings,
1872 grid: GridView<'_>,
1873 fb: &mut [u32],
1874 zb: &mut [f32],
1875 pitch_pixels: usize,
1876 env: &DdaEnv<'_>,
1877 cache: &BrickCache,
1878 mip: u32,
1879) {
1880 debug_assert_eq!(fb.len(), zb.len());
1881 let (y0, y1) = (settings.y_start, settings.y_end);
1882 if y1 <= y0 {
1883 return;
1884 }
1885 let cs = camera_math::derive(
1886 camera,
1887 settings.xres,
1888 settings.yres,
1889 settings.hx,
1890 settings.hy,
1891 settings.hz,
1892 );
1893 let target = RasterTarget::new(fb, zb);
1894
1895 let band = 8u32;
1902 let bands: Vec<(u32, u32)> = (y0..y1)
1903 .step_by(band as usize)
1904 .map(|s| (s, (s + band).min(y1)))
1905 .collect();
1906
1907 bands.par_iter().for_each(|&(by0, by1)| {
1908 let mut sampler = Sampler::new(grid, cache, mip);
1909 for py in by0..by1 {
1910 let row = py as usize * pitch_pixels;
1911 for px in settings.x_start..settings.x_end {
1912 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py)
1913 {
1914 let idx = row + px as usize;
1915 unsafe {
1919 target.write_color(idx, color);
1920 target.write_depth(idx, dist);
1921 }
1922 }
1923 }
1924 }
1925 });
1926}
1927
1928#[cfg(test)]
1934#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
1935fn cast_ray_reference(
1936 origin: [f32; 3],
1937 dir: [f32; 3],
1938 forward: [f32; 3],
1939 grid: &GridView<'_>,
1940 settings: &OpticastSettings,
1941) -> Option<Hit> {
1942 let nx = grid.vsid as f32;
1943 let nz = f32::from(u16::try_from(crate::grid_view::CHUNK_SIZE_Z).unwrap_or(256));
1944 #[allow(clippy::cast_possible_wrap)]
1945 let n_i = [
1946 grid.vsid as i32,
1947 grid.vsid as i32,
1948 crate::grid_view::CHUNK_SIZE_Z as i32,
1949 ];
1950 let (t_enter, t_exit) = intersect_aabb(origin, dir, [0.0; 3], [nx, nx, nz])?;
1951 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1952 let max_dist = settings.max_scan_dist.max(1) as f32;
1953
1954 let start = t_enter + 1e-4;
1955 let p = [
1956 origin[0] + dir[0] * start,
1957 origin[1] + dir[1] * start,
1958 origin[2] + dir[2] * start,
1959 ];
1960 let mut voxel = [
1961 (p[0].floor() as i32).clamp(0, n_i[0] - 1),
1962 (p[1].floor() as i32).clamp(0, n_i[1] - 1),
1963 (p[2].floor() as i32).clamp(0, n_i[2] - 1),
1964 ];
1965 let (step, mut t_max, t_delta) = dda_setup(origin, dir, voxel, 1.0);
1966 let mut t_curr = t_enter;
1967 let max_steps = (n_i[0] + n_i[1] + n_i[2]) as usize + 8;
1968 for _ in 0..max_steps {
1969 if voxel[0] < 0
1970 || voxel[0] >= n_i[0]
1971 || voxel[1] < 0
1972 || voxel[1] >= n_i[1]
1973 || voxel[2] < 0
1974 || voxel[2] >= n_i[2]
1975 {
1976 return None;
1977 }
1978 let depth = t_curr * fwd_dot;
1979 if depth > max_dist || t_curr > t_exit {
1980 return None;
1981 }
1982 #[allow(clippy::cast_sign_loss)]
1983 if let Some(color) = grid.surface_color(voxel[0] as u32, voxel[1] as u32, voxel[2] as u32) {
1984 return Some(Hit {
1985 color: shade(color.0, 0),
1986 dist: depth.max(0.0),
1987 });
1988 }
1989 let axis = min_axis(t_max);
1990 t_curr = t_max[axis];
1991 voxel[axis] += step[axis];
1992 t_max[axis] += t_delta[axis];
1993 }
1994 None
1995}
1996
1997#[cfg(test)]
1998mod tests {
1999 use super::*;
2000 use roxlap_formats::VoxColor;
2001
2002 fn lum(p: u32) -> u32 {
2004 (p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)
2005 }
2006
2007 #[test]
2008 fn cel_band_quantizes_and_collapses() {
2009 assert_eq!(cel_band(0.8, 2), cel_band(0.9, 2));
2011 assert!((cel_band(0.8, 2) - 1.0).abs() < 1e-6);
2012 assert_ne!(cel_band(0.3, 2), cel_band(0.8, 2));
2014 }
2015
2016 #[test]
2017 fn shade_lit_cpu_sun_lights_by_facing() {
2018 let color = 0x80_80_80_80;
2021 let step = [0, 0, 1];
2022 let base = CpuLights {
2023 enabled: true,
2024 sun: true,
2025 sun_color: [1.0; 3],
2026 sun_intensity: 1.0,
2027 ambient: [0.2; 3],
2028 ..CpuLights::default()
2029 };
2030 let facing = CpuLights {
2031 sun_dir: [0.0, 0.0, -1.0],
2032 ..base
2033 }; let back = CpuLights {
2035 sun_dir: [0.0, 0.0, 1.0],
2036 ..base
2037 }; let lit = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &facing, None);
2039 let dark = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &back, None);
2040 assert!(
2041 lum(lit) > lum(dark),
2042 "sun facing the surface must brighten it: {lit:#08x} vs {dark:#08x}",
2043 );
2044 }
2045
2046 #[test]
2047 fn shade_dynamic_spot_cone_masks_off_axis() {
2048 let albedo = [0.5, 0.5, 0.5];
2051 let n = [0.0, 0.0, -1.0];
2052 let sample = [0.0, 0.0, 0.0];
2053 let inner = 10.0f32.to_radians().cos();
2054 let outer = 15.0f32.to_radians().cos();
2055 let shade = |spot_dir: [f32; 3], cos_inner: f32, cos_outer: f32| {
2056 let pts = [CpuPointLight {
2057 pos: [0.0, 0.0, -10.0],
2058 color: [1.0; 3],
2059 intensity: 1.0,
2060 radius: 64.0,
2061 casts_shadow: false,
2062 spot_dir,
2063 cos_inner,
2064 cos_outer,
2065 }];
2066 let l = CpuLights {
2067 enabled: true,
2068 ambient: [0.0; 3],
2069 points: &pts,
2070 ..CpuLights::default()
2071 };
2072 shade_dynamic(albedo, 0.0, n, sample, &l, None)
2073 };
2074 let point = shade([0.0, 0.0, 1.0], -1.0, -1.0);
2076 let on_axis = shade([0.0, 0.0, 1.0], inner, outer);
2078 let off_axis = shade([1.0, 0.0, 0.0], inner, outer);
2080
2081 assert_eq!(
2083 on_axis, point,
2084 "on-axis spot must equal the point light: {on_axis:#08x} vs {point:#08x}",
2085 );
2086 assert!(
2088 lum(on_axis) > lum(off_axis),
2089 "off-axis spot must be darker: {on_axis:#08x} vs {off_axis:#08x}",
2090 );
2091 assert_eq!(lum(off_axis), 0, "off-cone spot contributes nothing");
2092 }
2093
2094 #[test]
2095 fn shade_lit_cpu_cel_terraces_sun() {
2096 let color = 0x80_80_80_80;
2099 let step = [0, 0, 1];
2100 let mk = |zc: f32, bands: u32| {
2101 let n = (1.0f32 - zc * zc).sqrt();
2102 CpuLights {
2103 enabled: true,
2104 sun: true,
2105 sun_dir: [n, 0.0, -zc], sun_color: [1.0; 3],
2107 sun_intensity: 1.0,
2108 ambient: [0.1; 3],
2109 bands,
2110 ..CpuLights::default()
2111 }
2112 };
2113 let smooth_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 0), None);
2114 let smooth_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 0), None);
2115 assert_ne!(smooth_a, smooth_b, "smooth diffuse must vary with N·L");
2116 let cel_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 2), None);
2117 let cel_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 2), None);
2118 assert_eq!(
2119 cel_a, cel_b,
2120 "cel banding must terrace both N·L to one level"
2121 );
2122 }
2123
2124 #[test]
2128 fn shade_dynamic_sun_shadow_darkens() {
2129 struct Mock(bool);
2130 impl ShadowTester for Mock {
2131 fn occluded(&mut self, _: [f32; 3], _: [f32; 3], _: f32) -> bool {
2132 self.0
2133 }
2134 }
2135 let l = CpuLights {
2136 enabled: true,
2137 sun: true,
2138 sun_dir: [0.0, 0.0, -1.0], sun_color: [1.0; 3],
2140 sun_intensity: 1.0,
2141 sun_casts_shadow: true,
2142 ambient: [0.2; 3],
2143 shadow_strength: 0.7,
2144 shadow_bias: 1.5,
2145 shadow_max_dist: 64.0,
2146 ..CpuLights::default()
2147 };
2148 let albedo = [0.8; 3];
2149 let n = [0.0, 0.0, -1.0]; let s = [0.5, 0.5, 0.5];
2151 let lit = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(false)));
2152 let shadowed = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(true)));
2153 assert!(
2154 lum(shadowed) < lum(lit),
2155 "an occluded sun face must darken: shadowed={shadowed:#08x} lit={lit:#08x}",
2156 );
2157 let l0 = CpuLights {
2159 shadow_strength: 0.0,
2160 ..l
2161 };
2162 assert_eq!(
2163 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(true))),
2164 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(false))),
2165 "shadow_strength 0 ⇒ shadows invisible",
2166 );
2167 }
2168
2169 #[test]
2175 fn sampler_shadow_march_casts_sun_shadow() {
2176 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, _y, z| {
2178 if z >= 60 {
2179 Some(VoxColor(0x80_80_80_80)) } else if x == 32 && (30..60).contains(&z) {
2181 Some(VoxColor(0x80_70_70_70)) } else {
2183 None
2184 }
2185 });
2186 let grid = GridView::from_single_vxl(&vxl);
2187 let cam = Camera {
2189 pos: [32.0, 32.0, 6.0],
2190 right: [1.0, 0.0, 0.0],
2191 down: [0.0, 1.0, 0.0],
2192 forward: [0.0, 0.0, 1.0],
2193 };
2194 let inv = 1.0f32 / 2.0f32.sqrt();
2196 let base = CpuLights {
2197 enabled: true,
2198 sun: true,
2199 sun_dir: [inv, 0.0, -inv],
2200 sun_color: [1.0; 3],
2201 sun_intensity: 1.0,
2202 ambient: [0.25; 3],
2203 shadow_strength: 0.8,
2204 shadow_bias: 1.5,
2205 shadow_max_dist: 128.0,
2206 ..CpuLights::default()
2207 };
2208 let (w, h) = (96u32, 96u32);
2209 let lit_env = DdaEnv {
2210 lights: CpuLights {
2211 sun_casts_shadow: false,
2212 ..base
2213 },
2214 ..DdaEnv::default()
2215 };
2216 let shadow_env = DdaEnv {
2217 lights: CpuLights {
2218 sun_casts_shadow: true,
2219 ..base
2220 },
2221 ..DdaEnv::default()
2222 };
2223 let (fb_lit, _) = render_brickmap_env(grid, &cam, w, h, &lit_env);
2224 let (fb_sh, _) = render_brickmap_env(grid, &cam, w, h, &shadow_env);
2225 let sum: fn(&[u32]) -> u64 = |fb| fb.iter().map(|&p| u64::from(lum(p))).sum();
2226 let lit_sum = sum(&fb_lit);
2227 let sh_sum = sum(&fb_sh);
2228 assert!(
2229 sh_sum < lit_sum,
2230 "the wall's shadow must darken the floor: shadow_sum={sh_sum} lit_sum={lit_sum}",
2231 );
2232 assert!(
2234 (lit_sum - sh_sum) * 50 > lit_sum,
2235 "shadow should remove >2% of total luminance: lit={lit_sum} shadow={sh_sum}",
2236 );
2237 }
2238
2239 #[derive(Default)]
2241 struct Recorder {
2242 puts: Vec<(usize, u32, f32)>,
2243 }
2244 impl PixelSink for Recorder {
2245 fn put(&mut self, idx: usize, color: u32, dist: f32) {
2246 self.puts.push((idx, color, dist));
2247 }
2248 }
2249
2250 fn oracle_camera() -> Camera {
2251 Camera {
2253 pos: [0.0, 0.0, 0.0],
2254 right: [1.0, 0.0, 0.0],
2255 down: [0.0, 0.0, 1.0],
2256 forward: [0.0, 1.0, 0.0],
2257 }
2258 }
2259
2260 fn render_mask(grid: GridView<'_>, camera: &Camera, w: u32, h: u32) -> Vec<bool> {
2263 let n = (w as usize) * (h as usize);
2264 let mut fb = vec![0u32; n]; let mut zb = vec![f32::INFINITY; n];
2266 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2267 {
2268 let mut sink = RasterSink::new(&mut fb, &mut zb);
2269 render_dda(
2270 camera,
2271 &settings,
2272 grid,
2273 w as usize,
2274 &DdaEnv::default(),
2275 0,
2276 &mut sink,
2277 );
2278 }
2279 fb.iter().map(|&c| c != 0).collect()
2280 }
2281
2282 fn rows_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2287 let w = w as usize;
2288 for y in 0..h as usize {
2289 let row = &mask[y * w..(y + 1) * w];
2290 let first = row.iter().position(|&b| b);
2291 let last = row.iter().rposition(|&b| b);
2292 if let (Some(f), Some(l)) = (first, last) {
2293 if row[f..=l].iter().any(|&b| !b) {
2294 return false;
2295 }
2296 }
2297 }
2298 true
2299 }
2300
2301 fn cols_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2303 let w = w as usize;
2304 let h = h as usize;
2305 for x in 0..w {
2306 let col: Vec<bool> = (0..h).map(|y| mask[y * w + x]).collect();
2307 let first = col.iter().position(|&b| b);
2308 let last = col.iter().rposition(|&b| b);
2309 if let (Some(f), Some(l)) = (first, last) {
2310 if col[f..=l].iter().any(|&b| !b) {
2311 return false;
2312 }
2313 }
2314 }
2315 true
2316 }
2317
2318 #[test]
2321 fn center_pixel_ray_is_forward() {
2322 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2323 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2324 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2326 let (origin, dir) = pixel_ray(&cs, &settings, settings.hx as u32, settings.hy as u32);
2327 assert_eq!(origin, [0.0, 0.0, 0.0]);
2328 assert_eq!(
2330 dir.map(f32::to_bits),
2331 [0.0f32, 320.0, 0.0].map(f32::to_bits)
2332 );
2333 }
2334
2335 #[test]
2339 fn corner_pixel_ray_matches_camera_corn0() {
2340 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2341 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2342 let (_origin, dir) = pixel_ray(&cs, &settings, 0, 0);
2343 assert_eq!(dir.map(f32::to_bits), cs.corn[0].map(f32::to_bits));
2344 }
2345
2346 #[test]
2352 fn gridview_voxel_color_matches_reference() {
2353 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |x, _, z| {
2355 let lo = (10..=12).contains(&z);
2356 let hi = (40..=42).contains(&z);
2357 (lo || hi).then_some(VoxColor(0x80_10_20_30 + x))
2358 });
2359 let grid = GridView::from_single_vxl(&vxl);
2360 for x in 0..8 {
2361 for y in 0..8 {
2362 for z in 0..64 {
2363 assert_eq!(
2364 grid.voxel_color(x, y, z),
2365 vxl.voxel_color(x, y, z),
2366 "mismatch at ({x},{y},{z})"
2367 );
2368 }
2369 }
2370 }
2371 }
2372
2373 #[test]
2375 fn empty_grid_no_hits() {
2376 let vxl = roxlap_formats::vxl::Vxl::empty(64);
2377 let grid = GridView::from_single_vxl(&vxl);
2378 let settings = OpticastSettings::for_oracle_framebuffer(64, 48);
2379 let mut rec = Recorder::default();
2380 render_dda(
2381 &oracle_camera(),
2382 &settings,
2383 grid,
2384 64,
2385 &DdaEnv::default(),
2386 0,
2387 &mut rec,
2388 );
2389 assert!(rec.puts.is_empty(), "all-air grid must produce no hits");
2390 }
2391
2392 #[test]
2396 fn floor_seen_from_above() {
2397 const FLOOR_Z: u32 = 40;
2398 const FLOOR_COL: VoxColor = VoxColor(0x80_30_60_90);
2399 let vxl =
2400 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= FLOOR_Z).then_some(FLOOR_COL));
2401 let grid = GridView::from_single_vxl(&vxl);
2402
2403 let cam = Camera {
2405 pos: [16.0, 16.0, 10.0],
2406 right: [1.0, 0.0, 0.0],
2407 down: [0.0, 1.0, 0.0],
2408 forward: [0.0, 0.0, 1.0],
2409 };
2410 let settings = OpticastSettings::for_oracle_framebuffer(48, 48);
2411 let mut rec = Recorder::default();
2412 render_dda(&cam, &settings, grid, 48, &DdaEnv::default(), 0, &mut rec);
2413
2414 assert!(!rec.puts.is_empty(), "floor must be visible");
2415 let centre = 24usize * 48 + 24;
2417 let hit = rec
2418 .puts
2419 .iter()
2420 .find(|(idx, _, _)| *idx == centre)
2421 .expect("centre ray must hit the floor");
2422 assert_eq!(hit.1 & 0x00ff_ffff, FLOOR_COL.0 & 0x00ff_ffff);
2423 let expected = (FLOOR_Z as f32) - 10.0;
2424 assert!(
2425 (hit.2 - expected).abs() < 1.5,
2426 "centre depth {} not ≈ {}",
2427 hit.2,
2428 expected
2429 );
2430 }
2431
2432 #[test]
2437 fn horizon_splits_sky_and_floor() {
2438 const FLOOR_Z: u32 = 40;
2439 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
2440 (z >= FLOOR_Z).then_some(VoxColor(0x80_44_66_88))
2441 });
2442 let grid = GridView::from_single_vxl(&vxl);
2443
2444 let cam = Camera {
2448 pos: [32.0, 4.0, 30.0],
2449 right: [-1.0, 0.0, 0.0],
2450 down: [0.0, 0.0, 1.0],
2451 forward: [0.0, 1.0, 0.0],
2452 };
2453 let (w, h) = (64u32, 64u32);
2454 let mask = render_mask(grid, &cam, w, h);
2455
2456 let count_band = |y0: usize, y1: usize| -> usize {
2457 (y0 * w as usize..y1 * w as usize)
2458 .filter(|&i| mask[i])
2459 .count()
2460 };
2461 let top = count_band(0, h as usize / 4);
2462 let bottom = count_band(3 * h as usize / 4, h as usize);
2463 assert!(mask.iter().any(|&b| b), "floor must be visible");
2464 assert!(mask.iter().any(|&b| !b), "sky must be visible");
2465 assert!(
2466 bottom > top,
2467 "bottom band ({bottom}) should hit more floor than top band ({top})"
2468 );
2469 }
2470
2471 fn render_reference(
2474 grid: GridView<'_>,
2475 camera: &Camera,
2476 w: u32,
2477 h: u32,
2478 ) -> (Vec<u32>, Vec<f32>) {
2479 let n = (w as usize) * (h as usize);
2480 let mut fb = vec![0u32; n];
2481 let mut zb = vec![f32::INFINITY; n];
2482 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2483 let cs = camera_math::derive(camera, w, h, settings.hx, settings.hy, settings.hz);
2484 for py in 0..h {
2485 for px in 0..w {
2486 let (o, d) = pixel_ray(&cs, &settings, px, py);
2487 if let Some(hit) = cast_ray_reference(o, d, cs.forward, &grid, &settings) {
2488 let i = (py * w + px) as usize;
2489 fb[i] = hit.color;
2490 zb[i] = hit.dist;
2491 }
2492 }
2493 }
2494 (fb, zb)
2495 }
2496
2497 fn render_brickmap(
2499 grid: GridView<'_>,
2500 camera: &Camera,
2501 w: u32,
2502 h: u32,
2503 ) -> (Vec<u32>, Vec<f32>) {
2504 render_brickmap_env(grid, camera, w, h, &DdaEnv::default())
2505 }
2506
2507 fn render_brickmap_env(
2510 grid: GridView<'_>,
2511 camera: &Camera,
2512 w: u32,
2513 h: u32,
2514 env: &DdaEnv<'_>,
2515 ) -> (Vec<u32>, Vec<f32>) {
2516 let n = (w as usize) * (h as usize);
2517 let mut fb = vec![0u32; n];
2518 let mut zb = vec![f32::INFINITY; n];
2519 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2520 {
2521 let mut sink = RasterSink::new(&mut fb, &mut zb);
2522 render_dda(camera, &settings, grid, w as usize, env, 0, &mut sink);
2523 }
2524 (fb, zb)
2525 }
2526
2527 #[test]
2534 fn no_sky_leak_through_diagonal_wall() {
2535 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2536 ((x + y == 64) && (2..62).contains(&z)).then_some(VoxColor(0x80_40_80_60))
2537 });
2538 let grid = GridView::from_single_vxl(&vxl);
2539 let (w, h) = (160u32, 160u32);
2540 let c = [10.0, 10.0, 32.0];
2541 let poses = [
2542 Camera::from_yaw_pitch(c, 0.785, 0.0),
2543 Camera::from_yaw_pitch(c, 0.6, 0.1),
2544 Camera::from_yaw_pitch(c, 0.95, -0.1),
2545 Camera::from_yaw_pitch(c, 0.785, 0.3),
2546 Camera::from_yaw_pitch(c, 0.5, 0.0),
2547 ];
2548 for (i, cam) in poses.iter().enumerate() {
2549 let (fb_b, _) = render_brickmap(grid, cam, w, h);
2550 let (fb_r, _) = render_reference(grid, cam, w, h);
2551 let leak = (0..(w * h) as usize)
2552 .filter(|&k| (fb_b[k] != 0) != (fb_r[k] != 0))
2553 .count();
2554 assert_eq!(leak, 0, "pose {i}: {leak} px diverge from dense reference");
2555 }
2556 }
2557
2558 #[test]
2562 fn terrain_glass_tints_floor_behind() {
2563 let glass = VoxColor(0x80_40_C0_E0); let floor = VoxColor(0x80_C0_40_40); let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2566 if z == 4 {
2567 Some(glass)
2568 } else if z >= 10 {
2569 Some(floor)
2570 } else {
2571 None
2572 }
2573 });
2574 let grid = GridView::from_single_vxl(&vxl);
2575 let cam = Camera {
2577 pos: [8.0, 8.0, 0.0],
2578 right: [1.0, 0.0, 0.0],
2579 down: [0.0, 1.0, 0.0],
2580 forward: [0.0, 0.0, 1.0],
2581 };
2582 let (w, h) = (32u32, 32u32);
2583 let centre = (h / 2 * w + w / 2) as usize;
2584
2585 let (fb_op, _) = render_brickmap(grid, &cam, w, h);
2587 assert_eq!(
2588 fb_op[centre] & 0x00ff_ffff,
2589 0x0040_C0E0,
2590 "opaque glass first-hit"
2591 );
2592
2593 let mut table = MaterialTable::new();
2595 table.set(1, Material::alpha_blend(128));
2596 let env = DdaEnv {
2597 materials: Some(&table),
2598 terrain_materials: &[(glass.rgb_part(), 1)],
2599 lights: CpuLights::default(),
2600 ..DdaEnv::default()
2601 };
2602 let (fb_tr, _) = render_brickmap_env(grid, &cam, w, h, &env);
2603 assert_ne!(
2604 fb_tr[centre], fb_op[centre],
2605 "glass should composite over the floor, not stay opaque"
2606 );
2607 let r_op = (fb_op[centre] >> 16) & 0xff; let r_tr = (fb_tr[centre] >> 16) & 0xff; assert!(
2610 r_tr > r_op,
2611 "floor red tints through the glass (op={r_op:02x} tr={r_tr:02x})"
2612 );
2613 }
2614
2615 #[test]
2620 fn terrain_volumetric_thickness_deepens_opacity() {
2621 let smoke = VoxColor(0x80_90_90_90); let floor = VoxColor(0x80_C0_20_20); let green_at = |depth: u32| -> u32 {
2626 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2627 if (4..4 + depth).contains(&z) {
2628 Some(smoke)
2629 } else if z >= 12 {
2630 Some(floor)
2631 } else {
2632 None
2633 }
2634 });
2635 let grid = GridView::from_single_vxl(&vxl);
2636 let cam = Camera {
2637 pos: [8.0, 8.0, 0.0],
2638 right: [1.0, 0.0, 0.0],
2639 down: [0.0, 1.0, 0.0],
2640 forward: [0.0, 0.0, 1.0],
2641 };
2642 let (w, h) = (32u32, 32u32);
2643 let mut table = MaterialTable::new();
2644 table.set(1, Material::volumetric(80));
2645 let env = DdaEnv {
2646 materials: Some(&table),
2647 terrain_materials: &[(smoke.rgb_part(), 1)],
2648 lights: CpuLights::default(),
2649 ..DdaEnv::default()
2650 };
2651 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
2652 (fb[(h / 2 * w + w / 2) as usize] >> 8) & 0xff
2653 };
2654 let shallow = green_at(1);
2655 let deep = green_at(7);
2656 assert!(
2657 deep > shallow,
2658 "deeper Volumetric smoke shows more of its grey (deep g={deep:02x} > shallow g={shallow:02x})"
2659 );
2660 }
2661
2662 #[test]
2665 fn distance_fog_blends_toward_fog_color() {
2666 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
2667 (z >= 40).then_some(VoxColor(0x80_FF_FF_FF))
2668 });
2669 let grid = GridView::from_single_vxl(&vxl);
2670 let cam = Camera {
2671 pos: [32.0, 2.0, 38.0],
2672 right: [1.0, 0.0, 0.0],
2673 down: [0.0, 0.0, 1.0],
2674 forward: [0.0, 1.0, 0.0],
2675 };
2676 let env = DdaEnv {
2677 sky: None,
2678 fog_color: 0x00_00_00_00, fog_max_dist: 64.0,
2680 side_shades: [0; 6],
2681 materials: None,
2682 terrain_materials: &[],
2683 lights: CpuLights::default(),
2684 world_shadow: None,
2685 };
2686 let (w, h) = (64u32, 64u32);
2687 let (fog, _) = render_brickmap_env(grid, &cam, w, h, &env);
2688 let (nofog, zb) = render_brickmap(grid, &cam, w, h);
2689 let (idx, depth) = zb.iter().enumerate().filter(|(_, z)| z.is_finite()).fold(
2690 (0usize, 0.0f32),
2691 |acc, (i, &z)| {
2692 if z > acc.1 {
2693 (i, z)
2694 } else {
2695 acc
2696 }
2697 },
2698 );
2699 assert!(depth > 20.0, "need a deep pixel to test fog (got {depth})");
2700 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
2701 assert!(
2702 lum(fog[idx]) < lum(nofog[idx]),
2703 "fogged pixel {:08x} not darker than {:08x}",
2704 fog[idx],
2705 nofog[idx]
2706 );
2707 }
2708
2709 #[test]
2712 fn textured_sky_fills_misses() {
2713 let sky = crate::sky::Sky::blue_gradient();
2714 let vxl = roxlap_formats::vxl::Vxl::empty(32); let grid = GridView::from_single_vxl(&vxl);
2716 let env = DdaEnv {
2717 sky: Some(&sky),
2718 fog_color: 0,
2719 fog_max_dist: 0.0,
2720 side_shades: [0; 6],
2721 materials: None,
2722 terrain_materials: &[],
2723 lights: CpuLights::default(),
2724 world_shadow: None,
2725 };
2726 let cam = Camera::from_yaw_pitch([16.0, 16.0, 128.0], 0.3, -0.4);
2727 let (w, h) = (48u32, 48u32);
2728 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
2729 assert!(fb.iter().all(|&c| c >> 24 == 0x80), "all misses sky-filled");
2730 let top = fb[0];
2731 let bottom = fb[(h - 1) as usize * w as usize];
2732 assert_ne!(top, bottom, "sky gradient should vary with elevation");
2733 }
2734
2735 #[test]
2740 fn sky_elevation_zenith_at_column_zero() {
2741 let mut pixels = vec![0i32; 8];
2742 pixels[0] = 0x0011_1111; pixels[7] = 0x0099_9999; let sky = crate::sky::Sky::from_pixels(pixels, 8, 1);
2745 let up = sample_sky(&sky, [0.0, 0.0, -1.0]); let down = sample_sky(&sky, [0.0, 0.0, 1.0]); assert_eq!(
2748 up & 0x00ff_ffff,
2749 0x0011_1111,
2750 "looking up → column 0 (zenith)"
2751 );
2752 assert_eq!(
2753 down & 0x00ff_ffff,
2754 0x0099_9999,
2755 "looking down → last column (nadir)"
2756 );
2757 }
2758
2759 #[test]
2763 fn sky_fill_paints_panorama_gridless() {
2764 let sky = crate::sky::Sky::blue_gradient();
2765 let cam = Camera::from_yaw_pitch([0.0, 0.0, 0.0], 0.3, -0.4);
2766 let (w, h) = (48u32, 48u32);
2767 let cs = crate::camera_math::derive(&cam, w, h, 24.0, 24.0, 24.0);
2768 let settings = crate::opticast::OpticastSettings::for_oracle_framebuffer(w, h);
2769 let mut fb = vec![0u32; (w * h) as usize];
2770 let zb = vec![f32::INFINITY; (w * h) as usize];
2772 render_sky_fill(&mut fb, &zb, w as usize, w, h, &cs, &settings, &sky);
2773 assert!(
2774 fb.iter().all(|&c| c >> 24 == 0x80),
2775 "every pixel sky-filled with the brightness byte set"
2776 );
2777 let top = fb[0];
2778 let bottom = fb[(h - 1) as usize * w as usize];
2779 assert_ne!(top, bottom, "sky gradient should vary with elevation");
2780 let mut fb2 = vec![0x1234_5678u32; (w * h) as usize];
2782 let mut zb2 = vec![f32::INFINITY; (w * h) as usize];
2783 zb2[0] = 10.0; render_sky_fill(&mut fb2, &zb2, w as usize, w, h, &cs, &settings, &sky);
2785 assert_eq!(fb2[0], 0x1234_5678, "finite-z pixel is not overwritten");
2786 }
2787
2788 #[test]
2792 fn side_shades_darken_hit_face() {
2793 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2794 (z >= 8).then_some(VoxColor(0x80_FF_FF_FF))
2795 });
2796 let grid = GridView::from_single_vxl(&vxl);
2797 let cam = Camera {
2798 pos: [8.0, 8.0, 2.0],
2799 right: [1.0, 0.0, 0.0],
2800 down: [0.0, 1.0, 0.0],
2801 forward: [0.0, 0.0, 1.0],
2802 };
2803 let centre = 16 * 32 + 16;
2804 let (plain, _) = render_brickmap(grid, &cam, 32, 32);
2805 let env = DdaEnv {
2806 sky: None,
2807 fog_color: 0,
2808 fog_max_dist: 0.0,
2809 side_shades: [0, 0, 0, 0, 0x40, 0],
2810 materials: None,
2811 terrain_materials: &[],
2812 lights: CpuLights::default(),
2813 world_shadow: None,
2814 };
2815 let (shaded, _) = render_brickmap_env(grid, &cam, 32, 32, &env);
2816 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
2817 assert!(
2818 lum(shaded[centre]) < lum(plain[centre]),
2819 "side-shaded face {:08x} not darker than {:08x}",
2820 shaded[centre],
2821 plain[centre]
2822 );
2823 }
2824
2825 #[test]
2835 fn brickmap_approximates_dense_reference() {
2836 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2838 let surf = 30 + ((x / 5 + y / 7) % 11);
2839 let ground = z >= surf;
2840 let block = (20..=24).contains(&z) && (10..20).contains(&x) && (40..50).contains(&y);
2841 (ground || block).then_some(VoxColor(0x80_30_50_70 + (x ^ y) % 0x40))
2842 });
2843 let grid = GridView::from_single_vxl(&vxl);
2844
2845 let (w, h) = (80u32, 80u32);
2846 let poses = [
2847 Camera::orbit(0.6, 0.5, 90.0, [32.0, 32.0, 40.0]),
2848 Camera::orbit(2.1, 0.2, 70.0, [32.0, 32.0, 35.0]),
2849 Camera::orbit(-1.0, 0.9, 120.0, [32.0, 32.0, 45.0]),
2850 ];
2851 let n = (w * h) as usize;
2852 for (i, cam) in poses.iter().enumerate() {
2853 let (fb_b, zb_b) = render_brickmap(grid, cam, w, h);
2854 let (fb_r, _zb_r) = render_reference(grid, cam, w, h);
2855 let cov_b = fb_b.iter().filter(|&&c| c != 0).count();
2857 let cov_r = fb_r.iter().filter(|&&c| c != 0).count();
2858 assert!(cov_b > 200, "pose {i} rendered ~empty (cov {cov_b})");
2859 let cov_diff = cov_b.abs_diff(cov_r);
2860 assert!(
2861 cov_diff * 100 <= n, "pose {i} coverage diverged: brick {cov_b} vs dense {cov_r}"
2863 );
2864 let diffs = fb_b.iter().zip(&fb_r).filter(|(a, b)| a != b).count();
2866 assert!(
2867 diffs * 100 <= n * 3, "pose {i} too many pixel diffs vs dense: {diffs}/{n}"
2869 );
2870 for k in 0..n {
2872 if fb_b[k] != 0 {
2873 assert!(zb_b[k].is_finite(), "pose {i} px {k} non-finite depth");
2874 }
2875 }
2876 }
2877 }
2878
2879 #[test]
2883 fn baked_brightness_darkens_color() {
2884 let dim = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2886 (z >= 8).then_some(VoxColor(0x40_FF_FF_FF))
2887 });
2888 let grid = GridView::from_single_vxl(&dim);
2889 let cam = Camera {
2890 pos: [8.0, 8.0, 2.0],
2891 right: [1.0, 0.0, 0.0],
2892 down: [0.0, 1.0, 0.0],
2893 forward: [0.0, 0.0, 1.0],
2894 };
2895 let (fb, _) = render_brickmap(grid, &cam, 32, 32);
2896 let centre = 16 * 32 + 16;
2897 assert_eq!(fb[centre], 0x80_7F_7F_7F, "got {:08x}", fb[centre]);
2899
2900 let full = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2902 (z >= 8).then_some(VoxColor(0x80_FF_FF_FF))
2903 });
2904 let gridf = GridView::from_single_vxl(&full);
2905 let (fbf, _) = render_brickmap(gridf, &cam, 32, 32);
2906 assert_eq!(fbf[centre], 0x80_FF_FF_FF, "got {:08x}", fbf[centre]);
2907 }
2908
2909 #[test]
2916 fn cross_chunk_lookdown_sees_lower_stacked_floor() {
2917 const FLOOR_LOCAL_Z: u32 = 40;
2918 const FLOOR_COL: VoxColor = VoxColor(0x80_22_88_44);
2919 let upper = roxlap_formats::vxl::Vxl::empty(32); let lower = roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
2921 (z >= FLOOR_LOCAL_Z).then_some(FLOOR_COL)
2922 });
2923 let v_up = GridView::from_single_vxl(&upper);
2924 let v_lo = GridView::from_single_vxl(&lower);
2925 let chunks = [Some(v_up), Some(v_lo)];
2927 let cg = crate::ChunkGrid {
2928 chunks: &chunks,
2929 origin_chunk_xy: [0, 0],
2930 origin_chunk_z: 0,
2931 chunks_x: 1,
2932 chunks_y: 1,
2933 chunks_z: 2,
2934 };
2935 let grid = GridView::from_chunk_grid(&cg, 32);
2936
2937 let cam = Camera {
2939 pos: [16.0, 16.0, 100.0],
2940 right: [1.0, 0.0, 0.0],
2941 down: [0.0, 1.0, 0.0],
2942 forward: [0.0, 0.0, 1.0],
2943 };
2944 let (w, h) = (48u32, 48u32);
2945 let (fb, zb) = render_brickmap(grid, &cam, w, h);
2946 let centre = 24 * 48 + 24;
2947 assert!(
2948 fb[centre] & 0x00ff_ffff == FLOOR_COL.0 & 0x00ff_ffff,
2949 "centre ray must reach the lower-chunk floor (got {:08x})",
2950 fb[centre]
2951 );
2952 let expected = 296.0 - 100.0;
2954 assert!(
2955 (zb[centre] - expected).abs() < 2.0,
2956 "look-down depth {} not ≈ {expected}",
2957 zb[centre]
2958 );
2959 }
2960
2961 #[test]
2965 fn cross_chunk_xy_floor_is_seamless() {
2966 let mk = || {
2967 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
2968 (z >= 20).then_some(VoxColor(0x80_50_50_50))
2969 })
2970 };
2971 let (c0, c1) = (mk(), mk());
2972 let v0 = GridView::from_single_vxl(&c0);
2973 let v1 = GridView::from_single_vxl(&c1);
2974 let chunks = [Some(v0), Some(v1)];
2975 let cg = crate::ChunkGrid {
2976 chunks: &chunks,
2977 origin_chunk_xy: [0, 0],
2978 origin_chunk_z: 0,
2979 chunks_x: 2,
2980 chunks_y: 1,
2981 chunks_z: 1,
2982 };
2983 let grid = GridView::from_chunk_grid(&cg, 32);
2984
2985 let cam = Camera {
2987 pos: [32.0, 16.0, 4.0],
2988 right: [1.0, 0.0, 0.0],
2989 down: [0.0, 1.0, 0.0],
2990 forward: [0.0, 0.0, 1.0],
2991 };
2992 let (w, h) = (64u32, 64u32);
2993 let mask = render_mask(grid, &cam, w, h);
2994 let row = (h / 2) as usize * w as usize;
2997 let left = (0..w as usize / 2).filter(|&x| mask[row + x]).count();
2998 let right = (w as usize / 2..w as usize)
2999 .filter(|&x| mask[row + x])
3000 .count();
3001 assert!(
3002 left > 5 && right > 5,
3003 "seam not continuous: left={left} right={right}"
3004 );
3005 }
3006
3007 fn render_mask_mip(grid: GridView<'_>, camera: &Camera, w: u32, h: u32, mip: u32) -> Vec<bool> {
3010 let n = (w as usize) * (h as usize);
3011 let mut fb = vec![0u32; n];
3012 let mut zb = vec![f32::INFINITY; n];
3013 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
3014 {
3015 let mut sink = RasterSink::new(&mut fb, &mut zb);
3016 render_dda(
3017 camera,
3018 &settings,
3019 grid,
3020 w as usize,
3021 &DdaEnv::default(),
3022 mip,
3023 &mut sink,
3024 );
3025 }
3026 fb.iter().map(|&c| c != 0).collect()
3027 }
3028
3029 #[test]
3035 fn mip_render_is_coarse_but_complete() {
3036 let mut vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
3037 let surf = 24 + ((x / 3 + y / 5) % 17);
3038 (z >= surf).then_some(VoxColor(0x80_50_70_90))
3039 });
3040 vxl.generate_mips(4);
3041 assert!(vxl.mip_count() >= 3, "need mips built for this test");
3042 let grid = GridView::from_single_vxl(&vxl);
3043 let (w, h) = (96u32, 96u32);
3044 let cam = Camera::orbit(0.7, 0.6, 110.0, [32.0, 32.0, 36.0]);
3045
3046 let m0 = render_mask_mip(grid, &cam, w, h, 0);
3047 let m2 = render_mask_mip(grid, &cam, w, h, 2);
3048
3049 let c0 = m0.iter().filter(|&&b| b).count();
3050 let c2 = m2.iter().filter(|&&b| b).count();
3051 assert!(c0 > 200 && c2 > 200, "both mips visible (c0={c0} c2={c2})");
3052 let ratio = c2 as f32 / c0 as f32;
3058 assert!(
3059 (0.7..1.4).contains(&ratio),
3060 "mip-2 coverage {c2} vs mip-0 {c0} (ratio {ratio:.2}) diverged"
3061 );
3062 }
3063
3064 #[test]
3070 #[ignore = "perf benchmark — run explicitly with --ignored"]
3071 fn bench_terrain() {
3072 use std::time::Instant;
3073 const NC: i32 = 6;
3075 let cs = crate::grid_view::CHUNK_SIZE_Z; let _ = cs;
3077 let mut vxls: Vec<roxlap_formats::vxl::Vxl> = Vec::new();
3078 for cy in 0..NC {
3079 for cx in 0..NC {
3080 let (ox, oy) = (cx * 128, cy * 128);
3081 let mut v = roxlap_formats::vxl::Vxl::from_dense(128, |x, y, z| {
3082 let (gx, gy) = (ox + x as i32, oy + y as i32);
3083 let surf = 90 + ((gx / 7 + gy / 9).rem_euclid(40)) + ((gx / 23).rem_euclid(20));
3084 (z as i32 >= surf).then_some(VoxColor(0x80_50_70_90 + (x ^ y) % 0x30))
3085 });
3086 v.generate_mips(4);
3087 vxls.push(v);
3088 }
3089 }
3090 let views: Vec<Option<GridView>> = vxls
3091 .iter()
3092 .map(|v| Some(GridView::from_single_vxl(v)))
3093 .collect();
3094 let cg = crate::ChunkGrid {
3095 chunks: &views,
3096 origin_chunk_xy: [0, 0],
3097 origin_chunk_z: 0,
3098 chunks_x: NC as u32,
3099 chunks_y: NC as u32,
3100 chunks_z: 1,
3101 };
3102 let grid = GridView::from_chunk_grid(&cg, 128);
3103
3104 let (w, h) = (960u32, 600u32);
3105 let mut settings = OpticastSettings::for_oracle_framebuffer(w, h);
3106 settings.max_scan_dist = 512;
3107 let n = (w * h) as usize;
3108 let mut fb = vec![0u32; n];
3109 let mut zb = vec![f32::INFINITY; n];
3110 let centre = [f64::from(NC * 128) / 2.0, f64::from(NC * 128) / 2.0, 60.0];
3111
3112 let poses = [
3115 (
3116 "horizon",
3117 Camera::from_yaw_pitch([20.0, 20.0, 40.0], 0.6, 0.15),
3118 ),
3119 ("down", Camera::orbit(0.7, 1.0, 130.0, centre)),
3120 ];
3121 for (name, cam) in poses {
3122 {
3123 let mut sink = RasterSink::new(&mut fb, &mut zb);
3124 prof::reset();
3125 render_dda(
3126 &cam,
3127 &settings,
3128 grid,
3129 w as usize,
3130 &DdaEnv::default(),
3131 0,
3132 &mut sink,
3133 );
3134 }
3135 let (cells, bricks, surf) = prof::read();
3136 let iters = 6;
3137 let t0 = Instant::now();
3138 for _ in 0..iters {
3139 let mut sink = RasterSink::new(&mut fb, &mut zb);
3140 render_dda(
3141 &cam,
3142 &settings,
3143 grid,
3144 w as usize,
3145 &DdaEnv::default(),
3146 0,
3147 &mut sink,
3148 );
3149 }
3150 let ms = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(iters);
3151 let hits = fb.iter().filter(|&&c| c != 0).count();
3152 eprintln!(
3153 "[{name}] {w}x{h} 1-thread: {ms:.1} ms | hits={hits}/{n} | per-px: cells={:.1} bricks={:.1} surf={:.1}",
3154 cells as f64 / n as f64,
3155 bricks as f64 / n as f64,
3156 surf as f64 / n as f64,
3157 );
3158 }
3159 }
3160
3161 #[test]
3165 fn parallel_matches_sequential() {
3166 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
3167 let surf = 28 + ((x / 4 + y / 6) % 13);
3168 (z >= surf).then_some(VoxColor(0x80_40_60_80 + (x ^ y) % 0x30))
3169 });
3170 let grid = GridView::from_single_vxl(&vxl);
3171 let (w, h) = (96u32, 96u32);
3172 let cam = Camera::orbit(0.8, 0.55, 100.0, [32.0, 32.0, 40.0]);
3173 let env = DdaEnv {
3174 sky: None,
3175 fog_color: 0x00_20_30_40,
3176 fog_max_dist: 120.0,
3177 side_shades: [0, 0, 0, 0, 0x30, 0x10],
3178 materials: None,
3179 terrain_materials: &[],
3180 lights: CpuLights::default(),
3181 world_shadow: None,
3182 };
3183
3184 let (seq_fb, seq_zb) = render_brickmap_env(grid, &cam, w, h, &env);
3185
3186 let n = (w * h) as usize;
3187 let mut par_fb = vec![0u32; n];
3188 let mut par_zb = vec![f32::INFINITY; n];
3189 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
3190 let (cache, mip) = local_cache(&grid, 0);
3191 render_dda_parallel(
3192 &cam,
3193 &settings,
3194 grid,
3195 &mut par_fb,
3196 &mut par_zb,
3197 w as usize,
3198 &env,
3199 &cache,
3200 mip,
3201 );
3202 assert!(par_fb == seq_fb, "parallel colour differs from sequential");
3203 assert!(
3204 par_zb
3205 .iter()
3206 .zip(&seq_zb)
3207 .all(|(a, b)| a.to_bits() == b.to_bits()),
3208 "parallel depth differs from sequential"
3209 );
3210 }
3211
3212 #[test]
3218 fn cliff_side_is_solid_not_see_through() {
3219 const TOP_Z: u32 = 50;
3220 const COL: VoxColor = VoxColor(0x80_77_88_99);
3221 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |_, _, z| (z >= TOP_Z).then_some(COL));
3222 let grid = GridView::from_single_vxl(&vxl);
3223
3224 assert_eq!(grid.voxel_color(4, 4, TOP_Z), Some(COL));
3226 assert_eq!(grid.voxel_color(4, 4, 150), None);
3228 assert_eq!(grid.surface_color(4, 4, 150), Some(COL));
3231 assert_eq!(grid.surface_color(4, 4, 10), None);
3233 }
3234
3235 #[test]
3238 fn camera_inside_solid_hits_everywhere() {
3239 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, _| Some(VoxColor(0x80_55_55_55)));
3240 let grid = GridView::from_single_vxl(&vxl);
3241 let cam = Camera {
3242 pos: [8.0, 8.0, 128.0],
3243 right: [1.0, 0.0, 0.0],
3244 down: [0.0, 1.0, 0.0],
3245 forward: [0.0, 0.0, 1.0],
3246 };
3247 let (w, h) = (32u32, 32u32);
3248 let mask = render_mask(grid, &cam, w, h);
3249 assert!(
3250 mask.iter().all(|&b| b),
3251 "every ray must hit when the camera is inside solid"
3252 );
3253 }
3254
3255 #[test]
3261 fn single_voxel_silhouette_has_no_notch() {
3262 const C: VoxColor = VoxColor(0x80_FF_80_40);
3263 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |x, y, z| {
3264 (x == 8 && y == 8 && z == 8).then_some(C)
3265 });
3266 let grid = GridView::from_single_vxl(&vxl);
3267
3268 let cam = Camera::orbit(0.7, 0.6, 4.0, [8.5, 8.5, 8.5]);
3271 let (w, h) = (96u32, 96u32);
3272 let mask = render_mask(grid, &cam, w, h);
3273
3274 let hits = mask.iter().filter(|&&b| b).count();
3275 assert!(
3276 hits > 30,
3277 "silhouette too small to be meaningful: {hits} px"
3278 );
3279 assert!(
3280 rows_have_no_holes(&mask, w, h),
3281 "row-interior gap in single-voxel silhouette (notch)"
3282 );
3283 assert!(
3284 cols_have_no_holes(&mask, w, h),
3285 "column-interior gap in single-voxel silhouette (notch)"
3286 );
3287 }
3288}