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]
286pub(crate) fn emissive_shade(color: u32, emissive: u8) -> u32 {
287 let a = 128 + u32::from(emissive >> 1);
288 let ch = |shift: u32| -> u32 { ((((color >> shift) & 0xff) * a) >> 7).min(255) };
289 0x8000_0000 | (ch(16) << 16) | (ch(8) << 8) | ch(0)
290}
291
292#[inline]
294fn cel_band(x: f32, bands: u32) -> f32 {
295 let b = bands as f32;
296 ((x * b).round() / b).clamp(0.0, 1.0)
297}
298
299#[inline]
302fn point_falloff(d: f32, radius: f32) -> f32 {
303 let x = (1.0 - d / radius).clamp(0.0, 1.0);
304 x * x
305}
306
307#[inline]
310fn smoothstep_scalar(edge0: f32, edge1: f32, x: f32) -> f32 {
311 if edge1 <= edge0 {
312 return if x < edge0 { 0.0 } else { 1.0 };
313 }
314 let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
315 t * t * (3.0 - 2.0 * t)
316}
317
318#[inline]
324fn spot_cone(ldir: [f32; 3], axis: [f32; 3], cos_inner: f32, cos_outer: f32) -> f32 {
325 if cos_outer <= -0.999 {
326 return 1.0;
327 }
328 let cd = -dot3(ldir, axis);
329 smoothstep_scalar(cos_outer, cos_inner, cd)
330}
331
332#[inline]
336fn face_normal_cpu(axis: usize, step: [i32; 3]) -> [f32; 3] {
337 let mut n = [0.0f32; 3];
338 if axis < 3 {
339 n[axis] = -(step[axis] as f32);
340 } else {
341 n[2] = -1.0;
342 }
343 n
344}
345
346#[inline]
347fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
348 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
349}
350
351pub(crate) trait ShadowTester {
359 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool;
360}
361
362pub trait WorldOccluder: Sync {
379 fn occluded_world(&self, origin: [f64; 3], dir: [f32; 3], max_t: f32) -> bool;
383}
384
385#[derive(Clone, Copy)]
392pub struct WorldShadowCtx<'a> {
393 pub occluder: &'a dyn WorldOccluder,
396 pub origin: [f64; 3],
402 pub cols: [[f32; 3]; 3],
407 pub voxel_world_size: f32,
413}
414
415impl<'a> WorldShadowCtx<'a> {
416 #[must_use]
419 pub fn identity(occluder: &'a dyn WorldOccluder) -> Self {
420 Self {
421 occluder,
422 origin: [0.0; 3],
423 cols: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
424 voxel_world_size: 1.0,
425 }
426 }
427}
428
429pub struct CompositeOccluder<'a> {
433 pub a: &'a dyn WorldOccluder,
435 pub b: &'a dyn WorldOccluder,
437}
438
439impl WorldOccluder for CompositeOccluder<'_> {
440 fn occluded_world(&self, origin: [f64; 3], dir: [f32; 3], max_t: f32) -> bool {
441 self.a.occluded_world(origin, dir, max_t) || self.b.occluded_world(origin, dir, max_t)
442 }
443}
444
445pub(crate) struct WorldShadow<'a> {
450 pub ctx: WorldShadowCtx<'a>,
451}
452
453impl ShadowTester for WorldShadow<'_> {
454 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
455 let c = &self.ctx.cols;
456 let s = self.ctx.voxel_world_size;
464 let l = [origin[0] * s, origin[1] * s, origin[2] * s];
465 let wo = [
469 self.ctx.origin[0] + f64::from(c[0][0] * l[0] + c[1][0] * l[1] + c[2][0] * l[2]),
470 self.ctx.origin[1] + f64::from(c[0][1] * l[0] + c[1][1] * l[1] + c[2][1] * l[2]),
471 self.ctx.origin[2] + f64::from(c[0][2] * l[0] + c[1][2] * l[1] + c[2][2] * l[2]),
472 ];
473 let wd = [
475 (c[0][0] * dir[0] + c[1][0] * dir[1] + c[2][0] * dir[2]) * s,
476 (c[0][1] * dir[0] + c[1][1] * dir[1] + c[2][1] * dir[2]) * s,
477 (c[0][2] * dir[0] + c[1][2] * dir[1] + c[2][2] * dir[2]) * s,
478 ];
479 self.ctx.occluder.occluded_world(wo, wd, max_t)
480 }
481}
482
483fn shade_lit_cpu(
490 color: u32,
491 bright_sub: u32,
492 axis: usize,
493 step: [i32; 3],
494 cellc: [i32; 3],
495 cell_size: f32,
496 l: &CpuLights<'_>,
497 shadow: Option<&mut dyn ShadowTester>,
498) -> u32 {
499 let a_b = ((color >> 24) & 0xff).saturating_sub(bright_sub);
500 let ao = a_b as f32 / 128.0;
501 let albedo = [
502 ((color >> 16) & 0xff) as f32 / 255.0,
503 ((color >> 8) & 0xff) as f32 / 255.0,
504 (color & 0xff) as f32 / 255.0,
505 ];
506 let n = face_normal_cpu(axis, step);
507 let center = [
509 (cellc[0] as f32 + 0.5) * cell_size,
510 (cellc[1] as f32 + 0.5) * cell_size,
511 (cellc[2] as f32 + 0.5) * cell_size,
512 ];
513 shade_dynamic(albedo, ao, n, center, l, shadow)
514}
515
516pub(crate) fn shade_dynamic(
522 albedo: [f32; 3],
523 ao: f32,
524 n: [f32; 3],
525 sample: [f32; 3],
526 l: &CpuLights<'_>,
527 shadow: Option<&mut dyn ShadowTester>,
528) -> u32 {
529 let styled = l.bands > 0;
530 let mut shadow = shadow;
534 let shadow_origin = [
535 sample[0] + n[0] * l.shadow_bias,
536 sample[1] + n[1] * l.shadow_bias,
537 sample[2] + n[2] * l.shadow_bias,
538 ];
539 let in_shadow = 1.0 - l.shadow_strength;
540
541 let sun_key = if l.sun {
543 let ndl = dot3(n, l.sun_dir).max(0.0);
544 if ndl > 0.0 && l.sun_casts_shadow {
545 let occ = shadow
546 .as_deref_mut()
547 .is_some_and(|s| s.occluded(shadow_origin, l.sun_dir, l.shadow_max_dist));
548 if occ {
549 ndl * in_shadow
550 } else {
551 ndl
552 }
553 } else {
554 ndl
555 }
556 } else {
557 0.0
558 };
559
560 let mut lit = if styled {
562 let key = cel_band(sun_key, l.bands);
563 let m = |i: usize| {
564 let warm = l.sun_color[i] * l.sun_intensity;
565 (l.shadow_tint[i] + (warm - l.shadow_tint[i]) * key) * ao
566 };
567 [albedo[0] * m(0), albedo[1] * m(1), albedo[2] * m(2)]
568 } else {
569 let base = |i: usize| {
570 albedo[i] * l.ambient[i] * ao + albedo[i] * l.sun_color[i] * l.sun_intensity * sun_key
571 };
572 [base(0), base(1), base(2)]
573 };
574
575 for p in l.points {
578 let d3 = [
579 p.pos[0] - sample[0],
580 p.pos[1] - sample[1],
581 p.pos[2] - sample[2],
582 ];
583 let d2 = d3[0] * d3[0] + d3[1] * d3[1] + d3[2] * d3[2];
586 if d2 < p.radius * p.radius && d2 > 1e-8 {
587 let dist = d2.sqrt();
588 let inv = 1.0 / dist;
589 let ldir = [d3[0] * inv, d3[1] * inv, d3[2] * inv];
590 let ndl = dot3(n, ldir).max(0.0);
591 let cone = spot_cone(ldir, p.spot_dir, p.cos_inner, p.cos_outer);
594 if ndl > 0.0 && cone > 0.0 {
595 let sh = if p.casts_shadow
597 && shadow
598 .as_deref_mut()
599 .is_some_and(|s| s.occluded(shadow_origin, ldir, dist))
600 {
601 in_shadow
602 } else {
603 1.0
604 };
605 let mut f = ndl * point_falloff(dist, p.radius) * cone * sh;
606 if styled {
607 f = cel_band(f, l.bands);
608 }
609 for i in 0..3 {
610 lit[i] += albedo[i] * p.color[i] * p.intensity * f;
611 }
612 }
613 }
614 }
615
616 let pack = |v: f32| -> u32 { (v.clamp(0.0, 1.0) * 255.0) as u32 };
617 0x8000_0000 | (pack(lit[0]) << 16) | (pack(lit[1]) << 8) | pack(lit[2])
618}
619
620#[inline]
624fn apply_fog(color: u32, depth: f32, env: &DdaEnv<'_>) -> u32 {
625 if env.fog_max_dist <= 0.0 {
626 return color;
627 }
628 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
629 let f = ((depth / env.fog_max_dist).clamp(0.0, 1.0) * 256.0) as u32; let g = 256 - f;
631 let fog = env.fog_color;
632 let mix = |shift: u32| -> u32 {
633 let src = (color >> shift) & 0xff;
634 let dst = (fog >> shift) & 0xff;
635 ((src * g + dst * f) >> 8).min(255)
636 };
637 0x8000_0000 | (mix(16) << 16) | (mix(8) << 8) | mix(0)
638}
639
640#[inline]
643fn composite_over(accum: [f32; 3], trans: f32, bg: u32) -> u32 {
644 let b = rgb_to_f32(bg);
645 f32_to_rgb([
646 accum[0] + trans * b[0],
647 accum[1] + trans * b[1],
648 accum[2] + trans * b[2],
649 ])
650}
651
652#[inline]
657fn finalize_exit(
658 touched: bool,
659 accum: [f32; 3],
660 trans: f32,
661 env: &DdaEnv<'_>,
662 dir: [f32; 3],
663 dist: f32,
664) -> Option<Hit> {
665 if !touched {
666 return None;
667 }
668 let bg = match env.sky {
669 Some(s) => sample_sky(s, dir),
670 None => 0x8000_0000 | (env.fog_color & 0x00ff_ffff),
671 };
672 Some(Hit {
673 color: composite_over(accum, trans, bg),
674 dist,
675 })
676}
677
678#[inline]
681#[allow(clippy::cast_precision_loss)]
682fn rgb_to_f32(c: u32) -> [f32; 3] {
683 [
684 ((c >> 16) & 0xff) as f32 / 255.0,
685 ((c >> 8) & 0xff) as f32 / 255.0,
686 (c & 0xff) as f32 / 255.0,
687 ]
688}
689
690#[inline]
692#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
693fn f32_to_rgb(c: [f32; 3]) -> u32 {
694 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
695 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
696}
697
698#[allow(
707 clippy::cast_possible_truncation,
708 clippy::cast_sign_loss,
709 clippy::cast_precision_loss
710)]
711fn sample_sky(sky: &Sky, dir: [f32; 3]) -> u32 {
712 let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
713 if len < 1e-9 {
714 return 0x8000_0000;
715 }
716 let d = [dir[0] / len, dir[1] / len, dir[2] / len];
717 let xsiz_full = sky.lat.len().max(1) as i32; let pi = std::f32::consts::PI;
719 let elev01 = (-d[2]).clamp(-1.0, 1.0).acos() / pi; let x = (elev01 * xsiz_full as f32) as i32;
724 let x = x.clamp(0, xsiz_full - 1);
725 let y = if sky.ysiz <= 1 {
727 0
728 } else {
729 let az = d[1].atan2(d[0]); let yf = ((az / (pi * 2.0)) + 0.5) * sky.ysiz as f32;
731 (yf as i32).rem_euclid(sky.ysiz)
732 };
733 let idx = (y * xsiz_full + x) as usize;
734 let px = sky.pixels.get(idx).copied().unwrap_or(0) as u32;
735 0x8000_0000 | (px & 0x00ff_ffff)
736}
737
738#[allow(clippy::cast_possible_truncation)]
748pub fn render_sky_fill(
749 fb: &mut [u32],
750 zb: &[f32],
751 pitch_pixels: usize,
752 width: u32,
753 height: u32,
754 cam: &CameraState,
755 settings: &OpticastSettings,
756 sky: &Sky,
757) {
758 fb.par_chunks_mut(pitch_pixels)
763 .take(height as usize)
764 .enumerate()
765 .for_each(|(py, frow)| {
766 let row = py * pitch_pixels;
767 #[allow(clippy::cast_possible_truncation)]
768 let py = py as u32;
769 for px in 0..width {
770 let idx = row + px as usize;
771 if zb[idx].is_finite() {
772 continue; }
774 let (_origin, dir) = pixel_ray(cam, settings, px, py);
775 frow[px as usize] = sample_sky(sky, dir);
776 }
777 });
778}
779
780#[must_use]
792pub fn pixel_ray(
793 cs: &CameraState,
794 settings: &OpticastSettings,
795 px: u32,
796 py: u32,
797) -> ([f32; 3], [f32; 3]) {
798 #[allow(clippy::cast_precision_loss)]
800 let sx = px as f32 - settings.hx;
801 #[allow(clippy::cast_precision_loss)]
802 let sy = py as f32 - settings.hy;
803 let dir = [
804 sx * cs.right[0] + sy * cs.down[0] + settings.hz * cs.forward[0],
805 sx * cs.right[1] + sy * cs.down[1] + settings.hz * cs.forward[1],
806 sx * cs.right[2] + sy * cs.down[2] + settings.hz * cs.forward[2],
807 ];
808 (cs.pos, dir)
809}
810
811pub(crate) fn intersect_aabb(
817 o: [f32; 3],
818 dir: [f32; 3],
819 lo: [f32; 3],
820 hi: [f32; 3],
821) -> Option<(f32, f32)> {
822 let mut t0 = 0.0f32;
823 let mut t1 = f32::INFINITY;
824 for a in 0..3 {
825 if dir[a].abs() < 1e-9 {
826 if o[a] < lo[a] || o[a] > hi[a] {
828 return None;
829 }
830 } else {
831 let inv = 1.0 / dir[a];
832 let mut ta = (lo[a] - o[a]) * inv;
833 let mut tb = (hi[a] - o[a]) * inv;
834 if ta > tb {
835 core::mem::swap(&mut ta, &mut tb);
836 }
837 t0 = t0.max(ta);
838 t1 = t1.min(tb);
839 if t0 > t1 {
840 return None;
841 }
842 }
843 }
844 Some((t0, t1))
845}
846
847const BRICK: i32 = 8;
849
850#[derive(Debug)]
864pub(crate) struct BrickMap {
865 nb: [i32; 3],
867 bits: Vec<u64>,
870 ns: [i32; 3],
873 super_bits: Vec<u64>,
878}
879
880const SUPER: i32 = BRICK * BRICK;
882
883impl BrickMap {
884 #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
887 fn build(grid: &GridView<'_>, mip: u32) -> Self {
888 let vsid_m = (grid.vsid >> mip).max(1) as i32;
889 let z_m = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1) as i32;
890 let nb = [
891 (vsid_m + BRICK - 1) / BRICK,
892 (vsid_m + BRICK - 1) / BRICK,
893 (z_m + BRICK - 1) / BRICK,
894 ];
895 let ns = [
896 (nb[0] + BRICK - 1) / BRICK,
897 (nb[1] + BRICK - 1) / BRICK,
898 (nb[2] + BRICK - 1) / BRICK,
899 ];
900 let count = (nb[0] * nb[1] * nb[2]) as usize;
901 let scount = (ns[0] * ns[1] * ns[2]) as usize;
902 let mut bits = vec![0u64; count.div_ceil(64)];
903 let mut super_bits = vec![0u64; scount.div_ceil(64)];
904 for y in 0..vsid_m {
905 for x in 0..vsid_m {
906 let (bx, by) = (x / BRICK, y / BRICK);
907 grid.for_each_run_mip(x as u32, y as u32, mip, |top, bot| {
908 for bz in (top / BRICK)..=((bot - 1) / BRICK) {
909 let idx = ((bz * nb[1] + by) * nb[0] + bx) as usize;
910 bits[idx / 64] |= 1u64 << (idx % 64);
911 let sidx =
912 (((bz / BRICK) * ns[1] + by / BRICK) * ns[0] + bx / BRICK) as usize;
913 super_bits[sidx / 64] |= 1u64 << (sidx % 64);
914 }
915 });
916 }
917 }
918 Self {
919 nb,
920 bits,
921 ns,
922 super_bits,
923 }
924 }
925
926 #[inline]
928 #[allow(clippy::cast_sign_loss)]
929 fn occupied(&self, b: [i32; 3]) -> bool {
930 if b[0] < 0
931 || b[0] >= self.nb[0]
932 || b[1] < 0
933 || b[1] >= self.nb[1]
934 || b[2] < 0
935 || b[2] >= self.nb[2]
936 {
937 return false;
938 }
939 let idx = ((b[2] * self.nb[1] + b[1]) * self.nb[0] + b[0]) as usize;
940 (self.bits[idx / 64] >> (idx % 64)) & 1 != 0
941 }
942
943 #[inline]
945 #[allow(clippy::cast_sign_loss)]
946 fn occupied_super(&self, s: [i32; 3]) -> bool {
947 if s[0] < 0
948 || s[0] >= self.ns[0]
949 || s[1] < 0
950 || s[1] >= self.ns[1]
951 || s[2] < 0
952 || s[2] >= self.ns[2]
953 {
954 return false;
955 }
956 let idx = ((s[2] * self.ns[1] + s[1]) * self.ns[0] + s[0]) as usize;
957 (self.super_bits[idx / 64] >> (idx % 64)) & 1 != 0
958 }
959}
960
961pub(crate) fn dda_setup(
967 origin: [f32; 3],
968 dir: [f32; 3],
969 cell: [i32; 3],
970 cell_size: f32,
971) -> ([i32; 3], [f32; 3], [f32; 3]) {
972 let mut step = [0i32; 3];
973 let mut t_max = [f32::INFINITY; 3];
974 let mut t_delta = [f32::INFINITY; 3];
975 for a in 0..3 {
976 if dir[a] > 1e-9 {
977 step[a] = 1;
978 #[allow(clippy::cast_precision_loss)]
979 let boundary = (cell[a] + 1) as f32 * cell_size;
980 t_max[a] = (boundary - origin[a]) / dir[a];
981 t_delta[a] = cell_size / dir[a];
982 } else if dir[a] < -1e-9 {
983 step[a] = -1;
984 #[allow(clippy::cast_precision_loss)]
985 let boundary = cell[a] as f32 * cell_size;
986 t_max[a] = (boundary - origin[a]) / dir[a];
987 t_delta[a] = -cell_size / dir[a];
988 }
989 }
990 (step, t_max, t_delta)
991}
992
993#[inline]
996pub(crate) fn min_axis(t_max: [f32; 3]) -> usize {
997 if t_max[0] <= t_max[1] && t_max[0] <= t_max[2] {
998 0
999 } else if t_max[1] <= t_max[2] {
1000 1
1001 } else {
1002 2
1003 }
1004}
1005
1006#[derive(Debug, Default)]
1016pub struct BrickCache {
1017 maps: HashMap<(i32, i32, i32, u32), (u64, BrickMap)>,
1018}
1019
1020impl BrickCache {
1021 #[must_use]
1024 pub fn new() -> Self {
1025 Self::default()
1026 }
1027
1028 pub fn ensure(&mut self, chunk: [i32; 3], mip: u32, version: u64, view: &GridView<'_>) {
1031 let key = (chunk[0], chunk[1], chunk[2], mip);
1032 let stale = self.maps.get(&key).is_none_or(|(v, _)| *v != version);
1033 if stale {
1034 self.maps.insert(key, (version, BrickMap::build(view, mip)));
1035 }
1036 }
1037
1038 #[inline]
1039 fn get(&self, chunk: [i32; 3], mip: u32) -> Option<&BrickMap> {
1040 self.maps
1041 .get(&(chunk[0], chunk[1], chunk[2], mip))
1042 .map(|(_, m)| m)
1043 }
1044
1045 pub fn retain_chunks(&mut self, keep: impl Fn([i32; 3]) -> bool) {
1048 self.maps.retain(|k, _| keep([k.0, k.1, k.2]));
1049 }
1050
1051 #[must_use]
1057 pub fn brick_occupied_at(&self, chunk: [i32; 3], mip: u32, cell: [i32; 3]) -> Option<bool> {
1058 self.get(chunk, mip)
1059 .map(|m| m.occupied([cell[0] >> 3, cell[1] >> 3, cell[2] >> 3]))
1060 }
1061
1062 #[must_use]
1065 pub fn super_occupied_at(&self, chunk: [i32; 3], mip: u32, cell: [i32; 3]) -> Option<bool> {
1066 self.get(chunk, mip)
1067 .map(|m| m.occupied_super([cell[0] >> 6, cell[1] >> 6, cell[2] >> 6]))
1068 }
1069}
1070
1071#[allow(clippy::cast_possible_wrap)]
1076fn local_cache(grid: &GridView<'_>, requested_mip: u32) -> (BrickCache, u32) {
1077 let mip = effective_mip(grid, requested_mip);
1078 let mut cache = BrickCache::new();
1079 if let Some(cg) = grid.chunk_grid {
1080 for dz in 0..cg.chunks_z as i32 {
1081 for dy in 0..cg.chunks_y as i32 {
1082 for dx in 0..cg.chunks_x as i32 {
1083 let slot = ((dz * cg.chunks_y as i32 + dy) * cg.chunks_x as i32 + dx) as usize;
1084 if let Some(Some(view)) = cg.chunks.get(slot) {
1085 let ch = [
1086 cg.origin_chunk_xy[0] + dx,
1087 cg.origin_chunk_xy[1] + dy,
1088 cg.origin_chunk_z + dz,
1089 ];
1090 cache.ensure(ch, mip, 0, view);
1091 }
1092 }
1093 }
1094 }
1095 } else {
1096 cache.ensure([0, 0, 0], mip, 0, grid);
1097 }
1098 (cache, mip)
1099}
1100
1101#[must_use]
1106pub fn effective_mip(grid: &GridView<'_>, requested: u32) -> u32 {
1107 if requested == 0 {
1108 return 0;
1109 }
1110 let mut m = requested;
1111 if let Some(cg) = grid.chunk_grid {
1112 for c in cg.chunks.iter().flatten() {
1113 m = m.min(c.mip_count().saturating_sub(1));
1114 }
1115 } else {
1116 m = m.min(grid.mip_count().saturating_sub(1));
1117 }
1118 m
1119}
1120
1121struct Sampler<'a> {
1135 grid: GridView<'a>,
1136 bricks: &'a BrickCache,
1137 mip: u32,
1140 xy_shift: u32,
1149 xy_mask: i32,
1150 z_shift: u32,
1151 z_mask: i32,
1152 cur_ch: [i32; 3],
1153 cur_view: Option<GridView<'a>>,
1154 cur_brick: Option<&'a BrickMap>,
1155 has_cur: bool,
1156}
1157
1158impl<'a> Sampler<'a> {
1159 fn new(grid: GridView<'a>, bricks: &'a BrickCache, mip: u32) -> Self {
1160 let cs_xy = (grid.chunk_size_xy >> mip).max(1);
1161 let cs_z = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1);
1162 debug_assert!(
1163 cs_xy.is_power_of_two() && cs_z.is_power_of_two(),
1164 "chunk dims must be powers of two for the shift/mask split"
1165 );
1166 #[allow(clippy::cast_possible_wrap)]
1167 Self {
1168 grid,
1169 bricks,
1170 mip,
1171 xy_shift: cs_xy.trailing_zeros(),
1172 xy_mask: cs_xy as i32 - 1,
1173 z_shift: cs_z.trailing_zeros(),
1174 z_mask: cs_z as i32 - 1,
1175 cur_ch: [0; 3],
1176 cur_view: None,
1177 cur_brick: None,
1178 has_cur: false,
1179 }
1180 }
1181
1182 fn select_chunk(&mut self, ch: [i32; 3]) {
1184 if self.has_cur && self.cur_ch == ch {
1185 return;
1186 }
1187 self.cur_view = self.grid.chunk_at_xyz(ch);
1188 self.cur_brick = self.bricks.get(ch, self.mip);
1189 self.cur_ch = ch;
1190 self.has_cur = true;
1191 }
1192
1193 #[allow(clippy::cast_sign_loss)]
1198 fn locate(&self, c: [i32; 3]) -> ([i32; 3], [u32; 3]) {
1199 let ch = [
1200 c[0] >> self.xy_shift,
1201 c[1] >> self.xy_shift,
1202 c[2] >> self.z_shift,
1203 ];
1204 let loc = [
1205 (c[0] & self.xy_mask) as u32,
1206 (c[1] & self.xy_mask) as u32,
1207 (c[2] & self.z_mask) as u32,
1208 ];
1209 (ch, loc)
1210 }
1211
1212 #[allow(clippy::cast_possible_wrap)]
1216 fn hit(&mut self, c: [i32; 3]) -> Option<u32> {
1217 #[cfg(test)]
1218 prof::SURF.with(|x| x.set(x.get() + 1));
1219 let (ch, loc) = self.locate(c);
1220 self.select_chunk(ch);
1221 let occupied = self.cur_brick.is_some_and(|bm| {
1222 bm.occupied([
1223 loc[0] as i32 / BRICK,
1224 loc[1] as i32 / BRICK,
1225 loc[2] as i32 / BRICK,
1226 ])
1227 });
1228 if !occupied {
1229 return None;
1230 }
1231 self.cur_view?
1232 .surface_color_mip(loc[0], loc[1], loc[2], self.mip)
1233 .map(|c| c.0)
1234 }
1235
1236 #[inline]
1238 fn cells_per_chunk_xy(&self) -> i32 {
1239 1 << self.xy_shift
1240 }
1241 #[inline]
1242 fn cells_per_chunk_z(&self) -> i32 {
1243 1 << self.z_shift
1244 }
1245
1246 #[allow(clippy::cast_sign_loss)]
1251 fn brick_occupied(&mut self, brick: [i32; 3]) -> bool {
1252 let c0 = [brick[0] << 3, brick[1] << 3, brick[2] << 3];
1254 let ch = [
1255 c0[0] >> self.xy_shift,
1256 c0[1] >> self.xy_shift,
1257 c0[2] >> self.z_shift,
1258 ];
1259 self.select_chunk(ch);
1260 self.cur_brick.is_some_and(|bm| {
1261 bm.occupied([
1262 (c0[0] & self.xy_mask) >> 3,
1263 (c0[1] & self.xy_mask) >> 3,
1264 (c0[2] & self.z_mask) >> 3,
1265 ])
1266 })
1267 }
1268
1269 #[allow(clippy::cast_sign_loss)]
1274 fn super_occupied(&mut self, s: [i32; 3]) -> bool {
1275 let c0 = [s[0] << 6, s[1] << 6, s[2] << 6];
1277 let ch = [
1278 c0[0] >> self.xy_shift,
1279 c0[1] >> self.xy_shift,
1280 c0[2] >> self.z_shift,
1281 ];
1282 self.select_chunk(ch);
1283 self.cur_brick.is_some_and(|bm| {
1284 bm.occupied_super([
1285 (c0[0] & self.xy_mask) >> 6,
1286 (c0[1] & self.xy_mask) >> 6,
1287 (c0[2] & self.z_mask) >> 6,
1288 ])
1289 })
1290 }
1291}
1292
1293const SHADOW_MAX_STEPS: u32 = 1024;
1297
1298struct SamplerShadow<'s, 'a> {
1305 sampler: &'s mut Sampler<'a>,
1306 cell_size: f32,
1307 lo_c: [i32; 3],
1308 hi_c: [i32; 3],
1309}
1310
1311impl ShadowTester for SamplerShadow<'_, '_> {
1312 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
1313 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
1314 let cs = self.cell_size;
1315 let has_super =
1323 self.sampler.cells_per_chunk_xy() >= SUPER && self.sampler.cells_per_chunk_z() >= SUPER;
1324 let has_brick =
1325 self.sampler.cells_per_chunk_xy() >= BRICK && self.sampler.cells_per_chunk_z() >= BRICK;
1326 let mut cellc = [
1327 (origin[0] / cs).floor() as i32,
1328 (origin[1] / cs).floor() as i32,
1329 (origin[2] / cs).floor() as i32,
1330 ];
1331 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cs);
1332 let inv = [
1333 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
1334 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
1335 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
1336 ];
1337 let mut t_curr = 0.0f32;
1338 let mut used = 0u32;
1339 while used < SHADOW_MAX_STEPS {
1340 if cellc[0] < self.lo_c[0]
1341 || cellc[0] >= self.hi_c[0]
1342 || cellc[1] < self.lo_c[1]
1343 || cellc[1] >= self.hi_c[1]
1344 || cellc[2] < self.lo_c[2]
1345 || cellc[2] >= self.hi_c[2]
1346 {
1347 return false; }
1349 if t_curr > max_t {
1350 return false; }
1352 let skip_shift = if has_super
1356 && !self
1357 .sampler
1358 .super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
1359 {
1360 Some(6u32)
1361 } else if has_brick
1362 && !self
1363 .sampler
1364 .brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
1365 {
1366 Some(3u32)
1367 } else {
1368 None
1369 };
1370 if let Some(sh) = skip_shift {
1371 let mut best_t = f32::INFINITY;
1372 let mut best_axis = 3usize;
1373 let mut plane = [0i32; 3];
1374 for a in 0..3 {
1375 if step[a] == 0 {
1376 continue;
1377 }
1378 let idx = cellc[a] >> sh;
1379 plane[a] = if step[a] > 0 {
1380 (idx + 1) << sh
1381 } else {
1382 idx << sh
1383 };
1384 let tb = (plane[a] as f32 * cs - origin[a]) * inv[a];
1385 if tb < best_t {
1386 best_t = tb;
1387 best_axis = a;
1388 }
1389 }
1390 if best_axis == 3 {
1391 return false;
1392 }
1393 let pb = [
1394 origin[0] + dir[0] * (best_t + 1e-4),
1395 origin[1] + dir[1] * (best_t + 1e-4),
1396 origin[2] + dir[2] * (best_t + 1e-4),
1397 ];
1398 let mut nc = [
1399 (pb[0] / cs).floor() as i32,
1400 (pb[1] / cs).floor() as i32,
1401 (pb[2] / cs).floor() as i32,
1402 ];
1403 nc[best_axis] = if step[best_axis] > 0 {
1404 plane[best_axis]
1405 } else {
1406 plane[best_axis] - 1
1407 };
1408 let crossed =
1413 cellc[0].abs_diff(nc[0]) + cellc[1].abs_diff(nc[1]) + cellc[2].abs_diff(nc[2]);
1414 if used.saturating_add(crossed) >= SHADOW_MAX_STEPS {
1415 return false;
1416 }
1417 used += crossed;
1418 cellc = nc;
1419 for a in 0..3 {
1420 if step[a] > 0 {
1421 t_max[a] = ((cellc[a] + 1) as f32 * cs - origin[a]) * inv[a];
1422 } else if step[a] < 0 {
1423 t_max[a] = (cellc[a] as f32 * cs - origin[a]) * inv[a];
1424 }
1425 }
1426 t_curr = best_t.max(t_curr);
1427 continue;
1428 }
1429 if self.sampler.hit(cellc).is_some() {
1430 return true; }
1432 let axis = min_axis(t_max);
1433 t_curr = t_max[axis];
1434 cellc[axis] += step[axis];
1435 t_max[axis] += t_delta[axis];
1436 used += 1;
1437 }
1438 false
1439 }
1440}
1441
1442#[allow(
1463 clippy::too_many_arguments,
1464 clippy::cast_possible_truncation,
1465 clippy::cast_sign_loss,
1466 clippy::cast_precision_loss
1467)]
1468fn cell_walk_skip(
1469 origin: [f32; 3],
1470 dir: [f32; 3],
1471 fwd_dot: f32,
1472 sampler: &mut Sampler<'_>,
1473 lo_c: [i32; 3],
1474 hi_c: [i32; 3],
1475 cell_size: f32,
1476 t_enter: f32,
1477 t_exit: f32,
1478 max_dist: f32,
1479 env: &DdaEnv<'_>,
1480) -> Option<Hit> {
1481 let has_super = sampler.cells_per_chunk_xy() >= SUPER && sampler.cells_per_chunk_z() >= SUPER;
1482 let has_brick = sampler.cells_per_chunk_xy() >= BRICK && sampler.cells_per_chunk_z() >= BRICK;
1483
1484 let start = t_enter + 1e-4;
1485 let p = [
1486 origin[0] + dir[0] * start,
1487 origin[1] + dir[1] * start,
1488 origin[2] + dir[2] * start,
1489 ];
1490 let mut cellc = [
1491 ((p[0] / cell_size).floor() as i32).clamp(lo_c[0], hi_c[0] - 1),
1492 ((p[1] / cell_size).floor() as i32).clamp(lo_c[1], hi_c[1] - 1),
1493 ((p[2] / cell_size).floor() as i32).clamp(lo_c[2], hi_c[2] - 1),
1494 ];
1495 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cell_size);
1496 let inv = [
1500 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
1501 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
1502 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
1503 ];
1504 let mut t_curr = t_enter;
1505 let mut last_axis = 3usize;
1506 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
1509 let shadow_casts = env.lights.enabled
1513 && env.lights.shadow_strength > 0.0
1514 && (env.lights.sun_casts_shadow || env.lights.points.iter().any(|p| p.casts_shadow));
1515
1516 let mut accum = [0.0f32; 3];
1521 let mut trans = 1.0f32;
1522 let mut touched = false;
1523 let mut prev_solid = false;
1524 let mut prev_mat = 0u8;
1525
1526 let span = (hi_c[0] - lo_c[0]) + (hi_c[1] - lo_c[1]) + (hi_c[2] - lo_c[2]);
1529 let max_steps = span.max(0) as usize + 16;
1530 for _ in 0..max_steps {
1531 if cellc[0] < lo_c[0]
1532 || cellc[0] >= hi_c[0]
1533 || cellc[1] < lo_c[1]
1534 || cellc[1] >= hi_c[1]
1535 || cellc[2] < lo_c[2]
1536 || cellc[2] >= hi_c[2]
1537 {
1538 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1539 }
1540 let depth = t_curr * fwd_dot;
1541 if depth > max_dist || t_curr > t_exit {
1542 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1543 }
1544 if env.fog_max_dist > 0.0 && depth >= env.fog_max_dist {
1550 let fog = 0x8000_0000 | (env.fog_color & 0x00ff_ffff);
1551 let color = if touched {
1552 composite_over(accum, trans, fog)
1553 } else {
1554 fog
1555 };
1556 return Some(Hit {
1557 color,
1558 dist: env.fog_max_dist,
1559 });
1560 }
1561
1562 let skip_shift = if has_super
1565 && !sampler.super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
1566 {
1567 Some(6u32)
1568 } else if has_brick
1569 && !sampler.brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
1570 {
1571 Some(3u32)
1572 } else {
1573 None
1574 };
1575 if let Some(sh) = skip_shift {
1576 #[cfg(test)]
1577 prof::BRICKS.with(|x| x.set(x.get() + 1));
1578 let mut best_t = f32::INFINITY;
1580 let mut best_axis = 3usize;
1581 let mut plane = [0i32; 3];
1582 for a in 0..3 {
1583 if step[a] == 0 {
1584 continue;
1585 }
1586 let idx = cellc[a] >> sh;
1587 plane[a] = if step[a] > 0 {
1588 (idx + 1) << sh
1589 } else {
1590 idx << sh
1591 };
1592 let tb = (plane[a] as f32 * cell_size - origin[a]) * inv[a];
1593 if tb < best_t {
1594 best_t = tb;
1595 best_axis = a;
1596 }
1597 }
1598 if best_axis == 3 {
1599 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1600 }
1601 let pb = [
1606 origin[0] + dir[0] * (best_t + 1e-4),
1607 origin[1] + dir[1] * (best_t + 1e-4),
1608 origin[2] + dir[2] * (best_t + 1e-4),
1609 ];
1610 let mut nc = [
1611 (pb[0] / cell_size).floor() as i32,
1612 (pb[1] / cell_size).floor() as i32,
1613 (pb[2] / cell_size).floor() as i32,
1614 ];
1615 nc[best_axis] = if step[best_axis] > 0 {
1616 plane[best_axis]
1617 } else {
1618 plane[best_axis] - 1
1619 };
1620 if nc[0] < lo_c[0]
1624 || nc[0] >= hi_c[0]
1625 || nc[1] < lo_c[1]
1626 || nc[1] >= hi_c[1]
1627 || nc[2] < lo_c[2]
1628 || nc[2] >= hi_c[2]
1629 {
1630 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1631 }
1632 cellc = nc;
1633 for a in 0..3 {
1636 if step[a] > 0 {
1637 t_max[a] = ((cellc[a] + 1) as f32 * cell_size - origin[a]) * inv[a];
1638 } else if step[a] < 0 {
1639 t_max[a] = (cellc[a] as f32 * cell_size - origin[a]) * inv[a];
1640 }
1641 }
1642 t_curr = best_t.max(t_curr);
1643 last_axis = best_axis;
1644 prev_solid = false; continue;
1646 }
1647
1648 #[cfg(test)]
1650 prof::CELLS.with(|x| x.set(x.get() + 1));
1651 if let Some(color) = sampler.hit(cellc) {
1652 let bright_sub = side_shade_sub(env, last_axis, step);
1653 let (m, mat_id) = match env.materials {
1659 Some(table) if !env.terrain_materials.is_empty() => {
1660 let id = material_for_color(env.terrain_materials, color);
1661 (table.get(id), id)
1662 }
1663 _ => (Material::OPAQUE, 0),
1664 };
1665 let shaded = if m.emissive > 0 {
1673 emissive_shade(color, m.emissive)
1674 } else if env.lights.enabled {
1675 let casts = shadow_casts;
1676 let mut world_sh;
1682 let mut sampler_sh;
1683 let tester: Option<&mut dyn ShadowTester> = if !casts {
1684 None
1685 } else if let Some(ctx) = env.world_shadow {
1686 world_sh = WorldShadow { ctx };
1687 Some(&mut world_sh)
1688 } else {
1689 sampler_sh = SamplerShadow {
1690 sampler: &mut *sampler,
1691 cell_size,
1692 lo_c,
1693 hi_c,
1694 };
1695 Some(&mut sampler_sh)
1696 };
1697 shade_lit_cpu(
1698 color,
1699 bright_sub,
1700 last_axis,
1701 step,
1702 cellc,
1703 cell_size,
1704 &env.lights,
1705 tester,
1706 )
1707 } else {
1708 shade(color, bright_sub)
1709 };
1710 let lit = apply_fog(shaded, depth.max(0.0), env);
1711 if m.is_opaque() {
1712 let color = if touched {
1716 composite_over(accum, trans, lit)
1717 } else {
1718 lit
1719 };
1720 return Some(Hit {
1721 color,
1722 dist: depth.max(0.0),
1723 });
1724 }
1725 let a = f32::from(m.alpha) / 255.0;
1726 if matches!(m.mode, roxlap_formats::material::BlendMode::Volumetric) {
1727 let t_exit = t_max[min_axis(t_max)];
1731 let seg_len = (t_exit - t_curr).max(0.0) * dir_len / cell_size;
1732 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
1733 let c = rgb_to_f32(lit);
1734 accum[0] += trans * eff_a * c[0];
1735 accum[1] += trans * eff_a * c[1];
1736 accum[2] += trans * eff_a * c[2];
1737 trans *= 1.0 - eff_a;
1738 touched = true;
1739 prev_mat = mat_id;
1740 if trans < 1.0 / 256.0 {
1741 return Some(Hit {
1742 color: f32_to_rgb(accum),
1743 dist: depth.max(0.0),
1744 });
1745 }
1746 } else if !prev_solid || mat_id != prev_mat {
1747 let c = rgb_to_f32(lit);
1751 accum[0] += trans * a * c[0];
1752 accum[1] += trans * a * c[1];
1753 accum[2] += trans * a * c[2];
1754 if !matches!(m.mode, roxlap_formats::material::BlendMode::Additive) {
1755 trans *= 1.0 - a; }
1757 touched = true;
1758 prev_mat = mat_id;
1759 if trans < 1.0 / 256.0 {
1760 return Some(Hit {
1761 color: f32_to_rgb(accum),
1762 dist: depth.max(0.0),
1763 });
1764 }
1765 }
1766 prev_solid = true;
1767 } else {
1768 prev_solid = false;
1769 }
1770 let axis = min_axis(t_max);
1771 last_axis = axis;
1772 t_curr = t_max[axis];
1773 cellc[axis] += step[axis];
1774 t_max[axis] += t_delta[axis];
1775 }
1776 None
1777}
1778
1779#[inline]
1785fn side_shade_sub(env: &DdaEnv<'_>, axis: usize, step: [i32; 3]) -> u32 {
1786 if axis >= 3 {
1787 return 0;
1788 }
1789 let face = axis * 2 + usize::from(step[axis] < 0);
1790 env.side_shades[face].max(0) as u32
1791}
1792
1793fn cast_ray(
1802 origin: [f32; 3],
1803 dir: [f32; 3],
1804 forward: [f32; 3],
1805 sampler: &mut Sampler<'_>,
1806 settings: &OpticastSettings,
1807 env: &DdaEnv<'_>,
1808) -> Option<Hit> {
1809 let (lo_i, hi_i) = sampler.grid.voxel_bounds();
1810 #[allow(clippy::cast_precision_loss)]
1811 let lo_f = [lo_i[0] as f32, lo_i[1] as f32, lo_i[2] as f32];
1812 #[allow(clippy::cast_precision_loss)]
1813 let hi_f = [hi_i[0] as f32, hi_i[1] as f32, hi_i[2] as f32];
1814 let (t_enter, t_exit) = intersect_aabb(origin, dir, lo_f, hi_f)?;
1815 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1816 #[allow(clippy::cast_precision_loss)]
1817 let max_dist = settings.max_scan_dist.max(1) as f32;
1818 let cell = 1i32 << sampler.mip;
1819 let cell_size = cell as f32;
1820 let lo_c = [
1821 lo_i[0].div_euclid(cell),
1822 lo_i[1].div_euclid(cell),
1823 lo_i[2].div_euclid(cell),
1824 ];
1825 let hi_c = [
1826 hi_i[0].div_euclid(cell),
1827 hi_i[1].div_euclid(cell),
1828 hi_i[2].div_euclid(cell),
1829 ];
1830 cell_walk_skip(
1831 origin, dir, fwd_dot, sampler, lo_c, hi_c, cell_size, t_enter, t_exit, max_dist, env,
1832 )
1833}
1834
1835pub fn render_dda(
1848 camera: &Camera,
1849 settings: &OpticastSettings,
1850 grid: GridView<'_>,
1851 pitch_pixels: usize,
1852 env: &DdaEnv<'_>,
1853 mip: u32,
1854 sink: &mut impl PixelSink,
1855) {
1856 let cs = camera_math::derive(
1857 camera,
1858 settings.xres,
1859 settings.yres,
1860 settings.hx,
1861 settings.hy,
1862 settings.hz,
1863 );
1864
1865 let (cache, mip) = local_cache(&grid, mip);
1868 let mut sampler = Sampler::new(grid, &cache, mip);
1869
1870 for py in settings.y_start..settings.y_end {
1871 let row = py as usize * pitch_pixels;
1872 for px in settings.x_start..settings.x_end {
1873 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py) {
1874 sink.put(row + px as usize, color, dist);
1875 }
1876 }
1877 }
1878}
1879
1880#[inline]
1885fn pixel_result(
1886 cs: &CameraState,
1887 settings: &OpticastSettings,
1888 sampler: &mut Sampler<'_>,
1889 env: &DdaEnv<'_>,
1890 px: u32,
1891 py: u32,
1892) -> Option<(u32, f32)> {
1893 let (origin, dir) = pixel_ray(cs, settings, px, py);
1894 if let Some(hit) = cast_ray(origin, dir, cs.forward, sampler, settings, env) {
1895 Some((hit.color, hit.dist))
1896 } else {
1897 env.sky.map(|sky| (sample_sky(sky, dir), f32::INFINITY))
1898 }
1899}
1900
1901#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
1916pub fn render_dda_parallel(
1917 camera: &Camera,
1918 settings: &OpticastSettings,
1919 grid: GridView<'_>,
1920 fb: &mut [u32],
1921 zb: &mut [f32],
1922 pitch_pixels: usize,
1923 env: &DdaEnv<'_>,
1924 cache: &BrickCache,
1925 mip: u32,
1926) {
1927 debug_assert_eq!(fb.len(), zb.len());
1928 let (y0, y1) = (settings.y_start, settings.y_end);
1929 if y1 <= y0 {
1930 return;
1931 }
1932 let cs = camera_math::derive(
1933 camera,
1934 settings.xres,
1935 settings.yres,
1936 settings.hx,
1937 settings.hy,
1938 settings.hz,
1939 );
1940 let target = RasterTarget::new(fb, zb);
1941
1942 let band = 8u32;
1949 let bands: Vec<(u32, u32)> = (y0..y1)
1950 .step_by(band as usize)
1951 .map(|s| (s, (s + band).min(y1)))
1952 .collect();
1953
1954 bands.par_iter().for_each(|&(by0, by1)| {
1955 let mut sampler = Sampler::new(grid, cache, mip);
1956 for py in by0..by1 {
1957 let row = py as usize * pitch_pixels;
1958 for px in settings.x_start..settings.x_end {
1959 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py)
1960 {
1961 let idx = row + px as usize;
1962 unsafe {
1966 target.write_color(idx, color);
1967 target.write_depth(idx, dist);
1968 }
1969 }
1970 }
1971 }
1972 });
1973}
1974
1975#[cfg(test)]
1981#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
1982fn cast_ray_reference(
1983 origin: [f32; 3],
1984 dir: [f32; 3],
1985 forward: [f32; 3],
1986 grid: &GridView<'_>,
1987 settings: &OpticastSettings,
1988) -> Option<Hit> {
1989 let nx = grid.vsid as f32;
1990 let nz = f32::from(u16::try_from(crate::grid_view::CHUNK_SIZE_Z).unwrap_or(256));
1991 #[allow(clippy::cast_possible_wrap)]
1992 let n_i = [
1993 grid.vsid as i32,
1994 grid.vsid as i32,
1995 crate::grid_view::CHUNK_SIZE_Z as i32,
1996 ];
1997 let (t_enter, t_exit) = intersect_aabb(origin, dir, [0.0; 3], [nx, nx, nz])?;
1998 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1999 let max_dist = settings.max_scan_dist.max(1) as f32;
2000
2001 let start = t_enter + 1e-4;
2002 let p = [
2003 origin[0] + dir[0] * start,
2004 origin[1] + dir[1] * start,
2005 origin[2] + dir[2] * start,
2006 ];
2007 let mut voxel = [
2008 (p[0].floor() as i32).clamp(0, n_i[0] - 1),
2009 (p[1].floor() as i32).clamp(0, n_i[1] - 1),
2010 (p[2].floor() as i32).clamp(0, n_i[2] - 1),
2011 ];
2012 let (step, mut t_max, t_delta) = dda_setup(origin, dir, voxel, 1.0);
2013 let mut t_curr = t_enter;
2014 let max_steps = (n_i[0] + n_i[1] + n_i[2]) as usize + 8;
2015 for _ in 0..max_steps {
2016 if voxel[0] < 0
2017 || voxel[0] >= n_i[0]
2018 || voxel[1] < 0
2019 || voxel[1] >= n_i[1]
2020 || voxel[2] < 0
2021 || voxel[2] >= n_i[2]
2022 {
2023 return None;
2024 }
2025 let depth = t_curr * fwd_dot;
2026 if depth > max_dist || t_curr > t_exit {
2027 return None;
2028 }
2029 #[allow(clippy::cast_sign_loss)]
2030 if let Some(color) = grid.surface_color(voxel[0] as u32, voxel[1] as u32, voxel[2] as u32) {
2031 return Some(Hit {
2032 color: shade(color.0, 0),
2033 dist: depth.max(0.0),
2034 });
2035 }
2036 let axis = min_axis(t_max);
2037 t_curr = t_max[axis];
2038 voxel[axis] += step[axis];
2039 t_max[axis] += t_delta[axis];
2040 }
2041 None
2042}
2043
2044#[cfg(test)]
2045mod tests {
2046 use super::*;
2047 use roxlap_formats::VoxColor;
2048
2049 fn lum(p: u32) -> u32 {
2051 (p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)
2052 }
2053
2054 #[test]
2055 fn cel_band_quantizes_and_collapses() {
2056 assert_eq!(cel_band(0.8, 2), cel_band(0.9, 2));
2058 assert!((cel_band(0.8, 2) - 1.0).abs() < 1e-6);
2059 assert_ne!(cel_band(0.3, 2), cel_band(0.8, 2));
2061 }
2062
2063 #[test]
2064 fn shade_lit_cpu_sun_lights_by_facing() {
2065 let color = 0x80_80_80_80;
2068 let step = [0, 0, 1];
2069 let base = CpuLights {
2070 enabled: true,
2071 sun: true,
2072 sun_color: [1.0; 3],
2073 sun_intensity: 1.0,
2074 ambient: [0.2; 3],
2075 ..CpuLights::default()
2076 };
2077 let facing = CpuLights {
2078 sun_dir: [0.0, 0.0, -1.0],
2079 ..base
2080 }; let back = CpuLights {
2082 sun_dir: [0.0, 0.0, 1.0],
2083 ..base
2084 }; let lit = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &facing, None);
2086 let dark = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &back, None);
2087 assert!(
2088 lum(lit) > lum(dark),
2089 "sun facing the surface must brighten it: {lit:#08x} vs {dark:#08x}",
2090 );
2091 }
2092
2093 #[test]
2094 fn shade_dynamic_spot_cone_masks_off_axis() {
2095 let albedo = [0.5, 0.5, 0.5];
2098 let n = [0.0, 0.0, -1.0];
2099 let sample = [0.0, 0.0, 0.0];
2100 let inner = 10.0f32.to_radians().cos();
2101 let outer = 15.0f32.to_radians().cos();
2102 let shade = |spot_dir: [f32; 3], cos_inner: f32, cos_outer: f32| {
2103 let pts = [CpuPointLight {
2104 pos: [0.0, 0.0, -10.0],
2105 color: [1.0; 3],
2106 intensity: 1.0,
2107 radius: 64.0,
2108 casts_shadow: false,
2109 spot_dir,
2110 cos_inner,
2111 cos_outer,
2112 }];
2113 let l = CpuLights {
2114 enabled: true,
2115 ambient: [0.0; 3],
2116 points: &pts,
2117 ..CpuLights::default()
2118 };
2119 shade_dynamic(albedo, 0.0, n, sample, &l, None)
2120 };
2121 let point = shade([0.0, 0.0, 1.0], -1.0, -1.0);
2123 let on_axis = shade([0.0, 0.0, 1.0], inner, outer);
2125 let off_axis = shade([1.0, 0.0, 0.0], inner, outer);
2127
2128 assert_eq!(
2130 on_axis, point,
2131 "on-axis spot must equal the point light: {on_axis:#08x} vs {point:#08x}",
2132 );
2133 assert!(
2135 lum(on_axis) > lum(off_axis),
2136 "off-axis spot must be darker: {on_axis:#08x} vs {off_axis:#08x}",
2137 );
2138 assert_eq!(lum(off_axis), 0, "off-cone spot contributes nothing");
2139 }
2140
2141 #[test]
2142 fn shade_lit_cpu_cel_terraces_sun() {
2143 let color = 0x80_80_80_80;
2146 let step = [0, 0, 1];
2147 let mk = |zc: f32, bands: u32| {
2148 let n = (1.0f32 - zc * zc).sqrt();
2149 CpuLights {
2150 enabled: true,
2151 sun: true,
2152 sun_dir: [n, 0.0, -zc], sun_color: [1.0; 3],
2154 sun_intensity: 1.0,
2155 ambient: [0.1; 3],
2156 bands,
2157 ..CpuLights::default()
2158 }
2159 };
2160 let smooth_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 0), None);
2161 let smooth_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 0), None);
2162 assert_ne!(smooth_a, smooth_b, "smooth diffuse must vary with N·L");
2163 let cel_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 2), None);
2164 let cel_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 2), None);
2165 assert_eq!(
2166 cel_a, cel_b,
2167 "cel banding must terrace both N·L to one level"
2168 );
2169 }
2170
2171 #[test]
2175 fn shade_dynamic_sun_shadow_darkens() {
2176 struct Mock(bool);
2177 impl ShadowTester for Mock {
2178 fn occluded(&mut self, _: [f32; 3], _: [f32; 3], _: f32) -> bool {
2179 self.0
2180 }
2181 }
2182 let l = CpuLights {
2183 enabled: true,
2184 sun: true,
2185 sun_dir: [0.0, 0.0, -1.0], sun_color: [1.0; 3],
2187 sun_intensity: 1.0,
2188 sun_casts_shadow: true,
2189 ambient: [0.2; 3],
2190 shadow_strength: 0.7,
2191 shadow_bias: 1.5,
2192 shadow_max_dist: 64.0,
2193 ..CpuLights::default()
2194 };
2195 let albedo = [0.8; 3];
2196 let n = [0.0, 0.0, -1.0]; let s = [0.5, 0.5, 0.5];
2198 let lit = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(false)));
2199 let shadowed = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(true)));
2200 assert!(
2201 lum(shadowed) < lum(lit),
2202 "an occluded sun face must darken: shadowed={shadowed:#08x} lit={lit:#08x}",
2203 );
2204 let l0 = CpuLights {
2206 shadow_strength: 0.0,
2207 ..l
2208 };
2209 assert_eq!(
2210 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(true))),
2211 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(false))),
2212 "shadow_strength 0 ⇒ shadows invisible",
2213 );
2214 }
2215
2216 #[test]
2222 fn sampler_shadow_march_casts_sun_shadow() {
2223 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, _y, z| {
2225 if z >= 60 {
2226 Some(VoxColor(0x80_80_80_80)) } else if x == 32 && (30..60).contains(&z) {
2228 Some(VoxColor(0x80_70_70_70)) } else {
2230 None
2231 }
2232 });
2233 let grid = GridView::from_single_vxl(&vxl);
2234 let cam = Camera {
2236 pos: [32.0, 32.0, 6.0],
2237 right: [1.0, 0.0, 0.0],
2238 down: [0.0, 1.0, 0.0],
2239 forward: [0.0, 0.0, 1.0],
2240 };
2241 let inv = 1.0f32 / 2.0f32.sqrt();
2243 let base = CpuLights {
2244 enabled: true,
2245 sun: true,
2246 sun_dir: [inv, 0.0, -inv],
2247 sun_color: [1.0; 3],
2248 sun_intensity: 1.0,
2249 ambient: [0.25; 3],
2250 shadow_strength: 0.8,
2251 shadow_bias: 1.5,
2252 shadow_max_dist: 128.0,
2253 ..CpuLights::default()
2254 };
2255 let (w, h) = (96u32, 96u32);
2256 let lit_env = DdaEnv {
2257 lights: CpuLights {
2258 sun_casts_shadow: false,
2259 ..base
2260 },
2261 ..DdaEnv::default()
2262 };
2263 let shadow_env = DdaEnv {
2264 lights: CpuLights {
2265 sun_casts_shadow: true,
2266 ..base
2267 },
2268 ..DdaEnv::default()
2269 };
2270 let (fb_lit, _) = render_brickmap_env(grid, &cam, w, h, &lit_env);
2271 let (fb_sh, _) = render_brickmap_env(grid, &cam, w, h, &shadow_env);
2272 let sum: fn(&[u32]) -> u64 = |fb| fb.iter().map(|&p| u64::from(lum(p))).sum();
2273 let lit_sum = sum(&fb_lit);
2274 let sh_sum = sum(&fb_sh);
2275 assert!(
2276 sh_sum < lit_sum,
2277 "the wall's shadow must darken the floor: shadow_sum={sh_sum} lit_sum={lit_sum}",
2278 );
2279 assert!(
2281 (lit_sum - sh_sum) * 50 > lit_sum,
2282 "shadow should remove >2% of total luminance: lit={lit_sum} shadow={sh_sum}",
2283 );
2284 }
2285
2286 #[derive(Default)]
2288 struct Recorder {
2289 puts: Vec<(usize, u32, f32)>,
2290 }
2291 impl PixelSink for Recorder {
2292 fn put(&mut self, idx: usize, color: u32, dist: f32) {
2293 self.puts.push((idx, color, dist));
2294 }
2295 }
2296
2297 fn oracle_camera() -> Camera {
2298 Camera {
2300 pos: [0.0, 0.0, 0.0],
2301 right: [1.0, 0.0, 0.0],
2302 down: [0.0, 0.0, 1.0],
2303 forward: [0.0, 1.0, 0.0],
2304 }
2305 }
2306
2307 fn render_mask(grid: GridView<'_>, camera: &Camera, w: u32, h: u32) -> Vec<bool> {
2310 let n = (w as usize) * (h as usize);
2311 let mut fb = vec![0u32; n]; let mut zb = vec![f32::INFINITY; n];
2313 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2314 {
2315 let mut sink = RasterSink::new(&mut fb, &mut zb);
2316 render_dda(
2317 camera,
2318 &settings,
2319 grid,
2320 w as usize,
2321 &DdaEnv::default(),
2322 0,
2323 &mut sink,
2324 );
2325 }
2326 fb.iter().map(|&c| c != 0).collect()
2327 }
2328
2329 fn rows_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2334 let w = w as usize;
2335 for y in 0..h as usize {
2336 let row = &mask[y * w..(y + 1) * w];
2337 let first = row.iter().position(|&b| b);
2338 let last = row.iter().rposition(|&b| b);
2339 if let (Some(f), Some(l)) = (first, last) {
2340 if row[f..=l].iter().any(|&b| !b) {
2341 return false;
2342 }
2343 }
2344 }
2345 true
2346 }
2347
2348 fn cols_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2350 let w = w as usize;
2351 let h = h as usize;
2352 for x in 0..w {
2353 let col: Vec<bool> = (0..h).map(|y| mask[y * w + x]).collect();
2354 let first = col.iter().position(|&b| b);
2355 let last = col.iter().rposition(|&b| b);
2356 if let (Some(f), Some(l)) = (first, last) {
2357 if col[f..=l].iter().any(|&b| !b) {
2358 return false;
2359 }
2360 }
2361 }
2362 true
2363 }
2364
2365 #[test]
2368 fn center_pixel_ray_is_forward() {
2369 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2370 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2371 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2373 let (origin, dir) = pixel_ray(&cs, &settings, settings.hx as u32, settings.hy as u32);
2374 assert_eq!(origin, [0.0, 0.0, 0.0]);
2375 assert_eq!(
2377 dir.map(f32::to_bits),
2378 [0.0f32, 320.0, 0.0].map(f32::to_bits)
2379 );
2380 }
2381
2382 #[test]
2386 fn corner_pixel_ray_matches_camera_corn0() {
2387 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2388 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2389 let (_origin, dir) = pixel_ray(&cs, &settings, 0, 0);
2390 assert_eq!(dir.map(f32::to_bits), cs.corn[0].map(f32::to_bits));
2391 }
2392
2393 #[test]
2399 fn gridview_voxel_color_matches_reference() {
2400 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |x, _, z| {
2402 let lo = (10..=12).contains(&z);
2403 let hi = (40..=42).contains(&z);
2404 (lo || hi).then_some(VoxColor(0x80_10_20_30 + x))
2405 });
2406 let grid = GridView::from_single_vxl(&vxl);
2407 for x in 0..8 {
2408 for y in 0..8 {
2409 for z in 0..64 {
2410 assert_eq!(
2411 grid.voxel_color(x, y, z),
2412 vxl.voxel_color(x, y, z),
2413 "mismatch at ({x},{y},{z})"
2414 );
2415 }
2416 }
2417 }
2418 }
2419
2420 #[test]
2422 fn empty_grid_no_hits() {
2423 let vxl = roxlap_formats::vxl::Vxl::empty(64);
2424 let grid = GridView::from_single_vxl(&vxl);
2425 let settings = OpticastSettings::for_oracle_framebuffer(64, 48);
2426 let mut rec = Recorder::default();
2427 render_dda(
2428 &oracle_camera(),
2429 &settings,
2430 grid,
2431 64,
2432 &DdaEnv::default(),
2433 0,
2434 &mut rec,
2435 );
2436 assert!(rec.puts.is_empty(), "all-air grid must produce no hits");
2437 }
2438
2439 #[test]
2443 fn floor_seen_from_above() {
2444 const FLOOR_Z: u32 = 40;
2445 const FLOOR_COL: VoxColor = VoxColor(0x80_30_60_90);
2446 let vxl =
2447 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= FLOOR_Z).then_some(FLOOR_COL));
2448 let grid = GridView::from_single_vxl(&vxl);
2449
2450 let cam = Camera {
2452 pos: [16.0, 16.0, 10.0],
2453 right: [1.0, 0.0, 0.0],
2454 down: [0.0, 1.0, 0.0],
2455 forward: [0.0, 0.0, 1.0],
2456 };
2457 let settings = OpticastSettings::for_oracle_framebuffer(48, 48);
2458 let mut rec = Recorder::default();
2459 render_dda(&cam, &settings, grid, 48, &DdaEnv::default(), 0, &mut rec);
2460
2461 assert!(!rec.puts.is_empty(), "floor must be visible");
2462 let centre = 24usize * 48 + 24;
2464 let hit = rec
2465 .puts
2466 .iter()
2467 .find(|(idx, _, _)| *idx == centre)
2468 .expect("centre ray must hit the floor");
2469 assert_eq!(hit.1 & 0x00ff_ffff, FLOOR_COL.0 & 0x00ff_ffff);
2470 let expected = (FLOOR_Z as f32) - 10.0;
2471 assert!(
2472 (hit.2 - expected).abs() < 1.5,
2473 "centre depth {} not ≈ {}",
2474 hit.2,
2475 expected
2476 );
2477 }
2478
2479 #[test]
2484 fn horizon_splits_sky_and_floor() {
2485 const FLOOR_Z: u32 = 40;
2486 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
2487 (z >= FLOOR_Z).then_some(VoxColor(0x80_44_66_88))
2488 });
2489 let grid = GridView::from_single_vxl(&vxl);
2490
2491 let cam = Camera {
2495 pos: [32.0, 4.0, 30.0],
2496 right: [-1.0, 0.0, 0.0],
2497 down: [0.0, 0.0, 1.0],
2498 forward: [0.0, 1.0, 0.0],
2499 };
2500 let (w, h) = (64u32, 64u32);
2501 let mask = render_mask(grid, &cam, w, h);
2502
2503 let count_band = |y0: usize, y1: usize| -> usize {
2504 (y0 * w as usize..y1 * w as usize)
2505 .filter(|&i| mask[i])
2506 .count()
2507 };
2508 let top = count_band(0, h as usize / 4);
2509 let bottom = count_band(3 * h as usize / 4, h as usize);
2510 assert!(mask.iter().any(|&b| b), "floor must be visible");
2511 assert!(mask.iter().any(|&b| !b), "sky must be visible");
2512 assert!(
2513 bottom > top,
2514 "bottom band ({bottom}) should hit more floor than top band ({top})"
2515 );
2516 }
2517
2518 fn render_reference(
2521 grid: GridView<'_>,
2522 camera: &Camera,
2523 w: u32,
2524 h: u32,
2525 ) -> (Vec<u32>, Vec<f32>) {
2526 let n = (w as usize) * (h as usize);
2527 let mut fb = vec![0u32; n];
2528 let mut zb = vec![f32::INFINITY; n];
2529 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2530 let cs = camera_math::derive(camera, w, h, settings.hx, settings.hy, settings.hz);
2531 for py in 0..h {
2532 for px in 0..w {
2533 let (o, d) = pixel_ray(&cs, &settings, px, py);
2534 if let Some(hit) = cast_ray_reference(o, d, cs.forward, &grid, &settings) {
2535 let i = (py * w + px) as usize;
2536 fb[i] = hit.color;
2537 zb[i] = hit.dist;
2538 }
2539 }
2540 }
2541 (fb, zb)
2542 }
2543
2544 fn render_brickmap(
2546 grid: GridView<'_>,
2547 camera: &Camera,
2548 w: u32,
2549 h: u32,
2550 ) -> (Vec<u32>, Vec<f32>) {
2551 render_brickmap_env(grid, camera, w, h, &DdaEnv::default())
2552 }
2553
2554 fn render_brickmap_env(
2557 grid: GridView<'_>,
2558 camera: &Camera,
2559 w: u32,
2560 h: u32,
2561 env: &DdaEnv<'_>,
2562 ) -> (Vec<u32>, Vec<f32>) {
2563 let n = (w as usize) * (h as usize);
2564 let mut fb = vec![0u32; n];
2565 let mut zb = vec![f32::INFINITY; n];
2566 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2567 {
2568 let mut sink = RasterSink::new(&mut fb, &mut zb);
2569 render_dda(camera, &settings, grid, w as usize, env, 0, &mut sink);
2570 }
2571 (fb, zb)
2572 }
2573
2574 #[test]
2581 fn no_sky_leak_through_diagonal_wall() {
2582 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2583 ((x + y == 64) && (2..62).contains(&z)).then_some(VoxColor(0x80_40_80_60))
2584 });
2585 let grid = GridView::from_single_vxl(&vxl);
2586 let (w, h) = (160u32, 160u32);
2587 let c = [10.0, 10.0, 32.0];
2588 let poses = [
2589 Camera::from_yaw_pitch(c, 0.785, 0.0),
2590 Camera::from_yaw_pitch(c, 0.6, 0.1),
2591 Camera::from_yaw_pitch(c, 0.95, -0.1),
2592 Camera::from_yaw_pitch(c, 0.785, 0.3),
2593 Camera::from_yaw_pitch(c, 0.5, 0.0),
2594 ];
2595 for (i, cam) in poses.iter().enumerate() {
2596 let (fb_b, _) = render_brickmap(grid, cam, w, h);
2597 let (fb_r, _) = render_reference(grid, cam, w, h);
2598 let leak = (0..(w * h) as usize)
2599 .filter(|&k| (fb_b[k] != 0) != (fb_r[k] != 0))
2600 .count();
2601 assert_eq!(leak, 0, "pose {i}: {leak} px diverge from dense reference");
2602 }
2603 }
2604
2605 #[test]
2609 fn terrain_glass_tints_floor_behind() {
2610 let glass = VoxColor(0x80_40_C0_E0); let floor = VoxColor(0x80_C0_40_40); let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2613 if z == 4 {
2614 Some(glass)
2615 } else if z >= 10 {
2616 Some(floor)
2617 } else {
2618 None
2619 }
2620 });
2621 let grid = GridView::from_single_vxl(&vxl);
2622 let cam = Camera {
2624 pos: [8.0, 8.0, 0.0],
2625 right: [1.0, 0.0, 0.0],
2626 down: [0.0, 1.0, 0.0],
2627 forward: [0.0, 0.0, 1.0],
2628 };
2629 let (w, h) = (32u32, 32u32);
2630 let centre = (h / 2 * w + w / 2) as usize;
2631
2632 let (fb_op, _) = render_brickmap(grid, &cam, w, h);
2634 assert_eq!(
2635 fb_op[centre] & 0x00ff_ffff,
2636 0x0040_C0E0,
2637 "opaque glass first-hit"
2638 );
2639
2640 let mut table = MaterialTable::new();
2642 table.set(1, Material::alpha_blend(128));
2643 let env = DdaEnv {
2644 materials: Some(&table),
2645 terrain_materials: &[(glass.rgb_part(), 1)],
2646 lights: CpuLights::default(),
2647 ..DdaEnv::default()
2648 };
2649 let (fb_tr, _) = render_brickmap_env(grid, &cam, w, h, &env);
2650 assert_ne!(
2651 fb_tr[centre], fb_op[centre],
2652 "glass should composite over the floor, not stay opaque"
2653 );
2654 let r_op = (fb_op[centre] >> 16) & 0xff; let r_tr = (fb_tr[centre] >> 16) & 0xff; assert!(
2657 r_tr > r_op,
2658 "floor red tints through the glass (op={r_op:02x} tr={r_tr:02x})"
2659 );
2660 }
2661
2662 #[test]
2666 fn terrain_emissive_ignores_lighting() {
2667 let crystal = VoxColor(0x40_20_60_80); let vxl =
2669 roxlap_formats::vxl::Vxl::from_dense(
2670 16,
2671 |_, _, z| if z >= 4 { Some(crystal) } else { None },
2672 );
2673 let grid = GridView::from_single_vxl(&vxl);
2674 let cam = Camera {
2675 pos: [8.0, 8.0, 0.0],
2676 right: [1.0, 0.0, 0.0],
2677 down: [0.0, 1.0, 0.0],
2678 forward: [0.0, 0.0, 1.0],
2679 };
2680 let (w, h) = (32u32, 32u32);
2681 let centre = (h / 2 * w + w / 2) as usize;
2682
2683 let (fb_dim, _) = render_brickmap(grid, &cam, w, h);
2685 assert_eq!(
2686 fb_dim[centre] & 0x00ff_ffff,
2687 0x0010_3040,
2688 "baked byte 0x40 = albedo/2"
2689 );
2690
2691 let mut table = MaterialTable::new();
2693 table.set(1, Material::glow(255));
2694 let base = DdaEnv {
2695 materials: Some(&table),
2696 terrain_materials: &[(crystal.rgb_part(), 1)],
2697 ..DdaEnv::default()
2698 };
2699 let (fb_em, _) = render_brickmap_env(grid, &cam, w, h, &base);
2700 assert_eq!(
2701 fb_em[centre] & 0x00ff_ffff,
2702 0x003f_bfff,
2703 "glow(255) ≈ 2× albedo (0x20,0x60,0x80 → 0x3f,0xbf,0xff)"
2704 );
2705
2706 let shaded_env = DdaEnv {
2708 side_shades: [64; 6],
2709 ..DdaEnv::default()
2710 };
2711 let (fb_ss_plain, _) = render_brickmap_env(grid, &cam, w, h, &shaded_env);
2712 assert_ne!(
2713 fb_ss_plain[centre], fb_dim[centre],
2714 "control: side shades darken a non-emissive voxel"
2715 );
2716 let em_ss = DdaEnv {
2717 side_shades: [64; 6],
2718 ..base
2719 };
2720 let (fb_em_ss, _) = render_brickmap_env(grid, &cam, w, h, &em_ss);
2721 assert_eq!(
2722 fb_em_ss[centre], fb_em[centre],
2723 "side shades must not touch an emissive voxel"
2724 );
2725
2726 let em_rig = DdaEnv {
2729 lights: CpuLights {
2730 enabled: true,
2731 ..CpuLights::default()
2732 },
2733 ..base
2734 };
2735 let (fb_em_rig, _) = render_brickmap_env(grid, &cam, w, h, &em_rig);
2736 assert_eq!(
2737 fb_em_rig[centre], fb_em[centre],
2738 "the dynamic rig must not touch an emissive voxel"
2739 );
2740 }
2741
2742 #[test]
2747 fn terrain_volumetric_thickness_deepens_opacity() {
2748 let smoke = VoxColor(0x80_90_90_90); let floor = VoxColor(0x80_C0_20_20); let green_at = |depth: u32| -> u32 {
2753 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2754 if (4..4 + depth).contains(&z) {
2755 Some(smoke)
2756 } else if z >= 12 {
2757 Some(floor)
2758 } else {
2759 None
2760 }
2761 });
2762 let grid = GridView::from_single_vxl(&vxl);
2763 let cam = Camera {
2764 pos: [8.0, 8.0, 0.0],
2765 right: [1.0, 0.0, 0.0],
2766 down: [0.0, 1.0, 0.0],
2767 forward: [0.0, 0.0, 1.0],
2768 };
2769 let (w, h) = (32u32, 32u32);
2770 let mut table = MaterialTable::new();
2771 table.set(1, Material::volumetric(80));
2772 let env = DdaEnv {
2773 materials: Some(&table),
2774 terrain_materials: &[(smoke.rgb_part(), 1)],
2775 lights: CpuLights::default(),
2776 ..DdaEnv::default()
2777 };
2778 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
2779 (fb[(h / 2 * w + w / 2) as usize] >> 8) & 0xff
2780 };
2781 let shallow = green_at(1);
2782 let deep = green_at(7);
2783 assert!(
2784 deep > shallow,
2785 "deeper Volumetric smoke shows more of its grey (deep g={deep:02x} > shallow g={shallow:02x})"
2786 );
2787 }
2788
2789 #[test]
2792 fn distance_fog_blends_toward_fog_color() {
2793 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
2794 (z >= 40).then_some(VoxColor(0x80_FF_FF_FF))
2795 });
2796 let grid = GridView::from_single_vxl(&vxl);
2797 let cam = Camera {
2798 pos: [32.0, 2.0, 38.0],
2799 right: [1.0, 0.0, 0.0],
2800 down: [0.0, 0.0, 1.0],
2801 forward: [0.0, 1.0, 0.0],
2802 };
2803 let env = DdaEnv {
2804 sky: None,
2805 fog_color: 0x00_00_00_00, fog_max_dist: 64.0,
2807 side_shades: [0; 6],
2808 materials: None,
2809 terrain_materials: &[],
2810 lights: CpuLights::default(),
2811 world_shadow: None,
2812 };
2813 let (w, h) = (64u32, 64u32);
2814 let (fog, _) = render_brickmap_env(grid, &cam, w, h, &env);
2815 let (nofog, zb) = render_brickmap(grid, &cam, w, h);
2816 let (idx, depth) = zb.iter().enumerate().filter(|(_, z)| z.is_finite()).fold(
2817 (0usize, 0.0f32),
2818 |acc, (i, &z)| {
2819 if z > acc.1 {
2820 (i, z)
2821 } else {
2822 acc
2823 }
2824 },
2825 );
2826 assert!(depth > 20.0, "need a deep pixel to test fog (got {depth})");
2827 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
2828 assert!(
2829 lum(fog[idx]) < lum(nofog[idx]),
2830 "fogged pixel {:08x} not darker than {:08x}",
2831 fog[idx],
2832 nofog[idx]
2833 );
2834 }
2835
2836 #[test]
2839 fn textured_sky_fills_misses() {
2840 let sky = crate::sky::Sky::blue_gradient();
2841 let vxl = roxlap_formats::vxl::Vxl::empty(32); let grid = GridView::from_single_vxl(&vxl);
2843 let env = DdaEnv {
2844 sky: Some(&sky),
2845 fog_color: 0,
2846 fog_max_dist: 0.0,
2847 side_shades: [0; 6],
2848 materials: None,
2849 terrain_materials: &[],
2850 lights: CpuLights::default(),
2851 world_shadow: None,
2852 };
2853 let cam = Camera::from_yaw_pitch([16.0, 16.0, 128.0], 0.3, -0.4);
2854 let (w, h) = (48u32, 48u32);
2855 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
2856 assert!(fb.iter().all(|&c| c >> 24 == 0x80), "all misses sky-filled");
2857 let top = fb[0];
2858 let bottom = fb[(h - 1) as usize * w as usize];
2859 assert_ne!(top, bottom, "sky gradient should vary with elevation");
2860 }
2861
2862 #[test]
2867 fn sky_elevation_zenith_at_column_zero() {
2868 let mut pixels = vec![0i32; 8];
2869 pixels[0] = 0x0011_1111; pixels[7] = 0x0099_9999; let sky = crate::sky::Sky::from_pixels(pixels, 8, 1);
2872 let up = sample_sky(&sky, [0.0, 0.0, -1.0]); let down = sample_sky(&sky, [0.0, 0.0, 1.0]); assert_eq!(
2875 up & 0x00ff_ffff,
2876 0x0011_1111,
2877 "looking up → column 0 (zenith)"
2878 );
2879 assert_eq!(
2880 down & 0x00ff_ffff,
2881 0x0099_9999,
2882 "looking down → last column (nadir)"
2883 );
2884 }
2885
2886 #[test]
2890 fn sky_fill_paints_panorama_gridless() {
2891 let sky = crate::sky::Sky::blue_gradient();
2892 let cam = Camera::from_yaw_pitch([0.0, 0.0, 0.0], 0.3, -0.4);
2893 let (w, h) = (48u32, 48u32);
2894 let cs = crate::camera_math::derive(&cam, w, h, 24.0, 24.0, 24.0);
2895 let settings = crate::opticast::OpticastSettings::for_oracle_framebuffer(w, h);
2896 let mut fb = vec![0u32; (w * h) as usize];
2897 let zb = vec![f32::INFINITY; (w * h) as usize];
2899 render_sky_fill(&mut fb, &zb, w as usize, w, h, &cs, &settings, &sky);
2900 assert!(
2901 fb.iter().all(|&c| c >> 24 == 0x80),
2902 "every pixel sky-filled with the brightness byte set"
2903 );
2904 let top = fb[0];
2905 let bottom = fb[(h - 1) as usize * w as usize];
2906 assert_ne!(top, bottom, "sky gradient should vary with elevation");
2907 let mut fb2 = vec![0x1234_5678u32; (w * h) as usize];
2909 let mut zb2 = vec![f32::INFINITY; (w * h) as usize];
2910 zb2[0] = 10.0; render_sky_fill(&mut fb2, &zb2, w as usize, w, h, &cs, &settings, &sky);
2912 assert_eq!(fb2[0], 0x1234_5678, "finite-z pixel is not overwritten");
2913 }
2914
2915 #[test]
2919 fn side_shades_darken_hit_face() {
2920 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2921 (z >= 8).then_some(VoxColor(0x80_FF_FF_FF))
2922 });
2923 let grid = GridView::from_single_vxl(&vxl);
2924 let cam = Camera {
2925 pos: [8.0, 8.0, 2.0],
2926 right: [1.0, 0.0, 0.0],
2927 down: [0.0, 1.0, 0.0],
2928 forward: [0.0, 0.0, 1.0],
2929 };
2930 let centre = 16 * 32 + 16;
2931 let (plain, _) = render_brickmap(grid, &cam, 32, 32);
2932 let env = DdaEnv {
2933 sky: None,
2934 fog_color: 0,
2935 fog_max_dist: 0.0,
2936 side_shades: [0, 0, 0, 0, 0x40, 0],
2937 materials: None,
2938 terrain_materials: &[],
2939 lights: CpuLights::default(),
2940 world_shadow: None,
2941 };
2942 let (shaded, _) = render_brickmap_env(grid, &cam, 32, 32, &env);
2943 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
2944 assert!(
2945 lum(shaded[centre]) < lum(plain[centre]),
2946 "side-shaded face {:08x} not darker than {:08x}",
2947 shaded[centre],
2948 plain[centre]
2949 );
2950 }
2951
2952 #[test]
2962 fn brickmap_approximates_dense_reference() {
2963 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2965 let surf = 30 + ((x / 5 + y / 7) % 11);
2966 let ground = z >= surf;
2967 let block = (20..=24).contains(&z) && (10..20).contains(&x) && (40..50).contains(&y);
2968 (ground || block).then_some(VoxColor(0x80_30_50_70 + (x ^ y) % 0x40))
2969 });
2970 let grid = GridView::from_single_vxl(&vxl);
2971
2972 let (w, h) = (80u32, 80u32);
2973 let poses = [
2974 Camera::orbit(0.6, 0.5, 90.0, [32.0, 32.0, 40.0]),
2975 Camera::orbit(2.1, 0.2, 70.0, [32.0, 32.0, 35.0]),
2976 Camera::orbit(-1.0, 0.9, 120.0, [32.0, 32.0, 45.0]),
2977 ];
2978 let n = (w * h) as usize;
2979 for (i, cam) in poses.iter().enumerate() {
2980 let (fb_b, zb_b) = render_brickmap(grid, cam, w, h);
2981 let (fb_r, _zb_r) = render_reference(grid, cam, w, h);
2982 let cov_b = fb_b.iter().filter(|&&c| c != 0).count();
2984 let cov_r = fb_r.iter().filter(|&&c| c != 0).count();
2985 assert!(cov_b > 200, "pose {i} rendered ~empty (cov {cov_b})");
2986 let cov_diff = cov_b.abs_diff(cov_r);
2987 assert!(
2988 cov_diff * 100 <= n, "pose {i} coverage diverged: brick {cov_b} vs dense {cov_r}"
2990 );
2991 let diffs = fb_b.iter().zip(&fb_r).filter(|(a, b)| a != b).count();
2993 assert!(
2994 diffs * 100 <= n * 3, "pose {i} too many pixel diffs vs dense: {diffs}/{n}"
2996 );
2997 for k in 0..n {
2999 if fb_b[k] != 0 {
3000 assert!(zb_b[k].is_finite(), "pose {i} px {k} non-finite depth");
3001 }
3002 }
3003 }
3004 }
3005
3006 #[test]
3010 fn baked_brightness_darkens_color() {
3011 let dim = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
3013 (z >= 8).then_some(VoxColor(0x40_FF_FF_FF))
3014 });
3015 let grid = GridView::from_single_vxl(&dim);
3016 let cam = Camera {
3017 pos: [8.0, 8.0, 2.0],
3018 right: [1.0, 0.0, 0.0],
3019 down: [0.0, 1.0, 0.0],
3020 forward: [0.0, 0.0, 1.0],
3021 };
3022 let (fb, _) = render_brickmap(grid, &cam, 32, 32);
3023 let centre = 16 * 32 + 16;
3024 assert_eq!(fb[centre], 0x80_7F_7F_7F, "got {:08x}", fb[centre]);
3026
3027 let full = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
3029 (z >= 8).then_some(VoxColor(0x80_FF_FF_FF))
3030 });
3031 let gridf = GridView::from_single_vxl(&full);
3032 let (fbf, _) = render_brickmap(gridf, &cam, 32, 32);
3033 assert_eq!(fbf[centre], 0x80_FF_FF_FF, "got {:08x}", fbf[centre]);
3034 }
3035
3036 #[test]
3043 fn cross_chunk_lookdown_sees_lower_stacked_floor() {
3044 const FLOOR_LOCAL_Z: u32 = 40;
3045 const FLOOR_COL: VoxColor = VoxColor(0x80_22_88_44);
3046 let upper = roxlap_formats::vxl::Vxl::empty(32); let lower = roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
3048 (z >= FLOOR_LOCAL_Z).then_some(FLOOR_COL)
3049 });
3050 let v_up = GridView::from_single_vxl(&upper);
3051 let v_lo = GridView::from_single_vxl(&lower);
3052 let chunks = [Some(v_up), Some(v_lo)];
3054 let cg = crate::ChunkGrid {
3055 chunks: &chunks,
3056 origin_chunk_xy: [0, 0],
3057 origin_chunk_z: 0,
3058 chunks_x: 1,
3059 chunks_y: 1,
3060 chunks_z: 2,
3061 };
3062 let grid = GridView::from_chunk_grid(&cg, 32);
3063
3064 let cam = Camera {
3066 pos: [16.0, 16.0, 100.0],
3067 right: [1.0, 0.0, 0.0],
3068 down: [0.0, 1.0, 0.0],
3069 forward: [0.0, 0.0, 1.0],
3070 };
3071 let (w, h) = (48u32, 48u32);
3072 let (fb, zb) = render_brickmap(grid, &cam, w, h);
3073 let centre = 24 * 48 + 24;
3074 assert!(
3075 fb[centre] & 0x00ff_ffff == FLOOR_COL.0 & 0x00ff_ffff,
3076 "centre ray must reach the lower-chunk floor (got {:08x})",
3077 fb[centre]
3078 );
3079 let expected = 296.0 - 100.0;
3081 assert!(
3082 (zb[centre] - expected).abs() < 2.0,
3083 "look-down depth {} not ≈ {expected}",
3084 zb[centre]
3085 );
3086 }
3087
3088 #[test]
3092 fn cross_chunk_xy_floor_is_seamless() {
3093 let mk = || {
3094 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
3095 (z >= 20).then_some(VoxColor(0x80_50_50_50))
3096 })
3097 };
3098 let (c0, c1) = (mk(), mk());
3099 let v0 = GridView::from_single_vxl(&c0);
3100 let v1 = GridView::from_single_vxl(&c1);
3101 let chunks = [Some(v0), Some(v1)];
3102 let cg = crate::ChunkGrid {
3103 chunks: &chunks,
3104 origin_chunk_xy: [0, 0],
3105 origin_chunk_z: 0,
3106 chunks_x: 2,
3107 chunks_y: 1,
3108 chunks_z: 1,
3109 };
3110 let grid = GridView::from_chunk_grid(&cg, 32);
3111
3112 let cam = Camera {
3114 pos: [32.0, 16.0, 4.0],
3115 right: [1.0, 0.0, 0.0],
3116 down: [0.0, 1.0, 0.0],
3117 forward: [0.0, 0.0, 1.0],
3118 };
3119 let (w, h) = (64u32, 64u32);
3120 let mask = render_mask(grid, &cam, w, h);
3121 let row = (h / 2) as usize * w as usize;
3124 let left = (0..w as usize / 2).filter(|&x| mask[row + x]).count();
3125 let right = (w as usize / 2..w as usize)
3126 .filter(|&x| mask[row + x])
3127 .count();
3128 assert!(
3129 left > 5 && right > 5,
3130 "seam not continuous: left={left} right={right}"
3131 );
3132 }
3133
3134 fn render_mask_mip(grid: GridView<'_>, camera: &Camera, w: u32, h: u32, mip: u32) -> Vec<bool> {
3137 let n = (w as usize) * (h as usize);
3138 let mut fb = vec![0u32; n];
3139 let mut zb = vec![f32::INFINITY; n];
3140 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
3141 {
3142 let mut sink = RasterSink::new(&mut fb, &mut zb);
3143 render_dda(
3144 camera,
3145 &settings,
3146 grid,
3147 w as usize,
3148 &DdaEnv::default(),
3149 mip,
3150 &mut sink,
3151 );
3152 }
3153 fb.iter().map(|&c| c != 0).collect()
3154 }
3155
3156 #[test]
3162 fn mip_render_is_coarse_but_complete() {
3163 let mut vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
3164 let surf = 24 + ((x / 3 + y / 5) % 17);
3165 (z >= surf).then_some(VoxColor(0x80_50_70_90))
3166 });
3167 vxl.generate_mips(4);
3168 assert!(vxl.mip_count() >= 3, "need mips built for this test");
3169 let grid = GridView::from_single_vxl(&vxl);
3170 let (w, h) = (96u32, 96u32);
3171 let cam = Camera::orbit(0.7, 0.6, 110.0, [32.0, 32.0, 36.0]);
3172
3173 let m0 = render_mask_mip(grid, &cam, w, h, 0);
3174 let m2 = render_mask_mip(grid, &cam, w, h, 2);
3175
3176 let c0 = m0.iter().filter(|&&b| b).count();
3177 let c2 = m2.iter().filter(|&&b| b).count();
3178 assert!(c0 > 200 && c2 > 200, "both mips visible (c0={c0} c2={c2})");
3179 let ratio = c2 as f32 / c0 as f32;
3185 assert!(
3186 (0.7..1.4).contains(&ratio),
3187 "mip-2 coverage {c2} vs mip-0 {c0} (ratio {ratio:.2}) diverged"
3188 );
3189 }
3190
3191 #[test]
3197 #[ignore = "perf benchmark — run explicitly with --ignored"]
3198 fn bench_terrain() {
3199 use std::time::Instant;
3200 const NC: i32 = 6;
3202 let cs = crate::grid_view::CHUNK_SIZE_Z; let _ = cs;
3204 let mut vxls: Vec<roxlap_formats::vxl::Vxl> = Vec::new();
3205 for cy in 0..NC {
3206 for cx in 0..NC {
3207 let (ox, oy) = (cx * 128, cy * 128);
3208 let mut v = roxlap_formats::vxl::Vxl::from_dense(128, |x, y, z| {
3209 let (gx, gy) = (ox + x as i32, oy + y as i32);
3210 let surf = 90 + ((gx / 7 + gy / 9).rem_euclid(40)) + ((gx / 23).rem_euclid(20));
3211 (z as i32 >= surf).then_some(VoxColor(0x80_50_70_90 + (x ^ y) % 0x30))
3212 });
3213 v.generate_mips(4);
3214 vxls.push(v);
3215 }
3216 }
3217 let views: Vec<Option<GridView>> = vxls
3218 .iter()
3219 .map(|v| Some(GridView::from_single_vxl(v)))
3220 .collect();
3221 let cg = crate::ChunkGrid {
3222 chunks: &views,
3223 origin_chunk_xy: [0, 0],
3224 origin_chunk_z: 0,
3225 chunks_x: NC as u32,
3226 chunks_y: NC as u32,
3227 chunks_z: 1,
3228 };
3229 let grid = GridView::from_chunk_grid(&cg, 128);
3230
3231 let (w, h) = (960u32, 600u32);
3232 let mut settings = OpticastSettings::for_oracle_framebuffer(w, h);
3233 settings.max_scan_dist = 512;
3234 let n = (w * h) as usize;
3235 let mut fb = vec![0u32; n];
3236 let mut zb = vec![f32::INFINITY; n];
3237 let centre = [f64::from(NC * 128) / 2.0, f64::from(NC * 128) / 2.0, 60.0];
3238
3239 let poses = [
3242 (
3243 "horizon",
3244 Camera::from_yaw_pitch([20.0, 20.0, 40.0], 0.6, 0.15),
3245 ),
3246 ("down", Camera::orbit(0.7, 1.0, 130.0, centre)),
3247 ];
3248 for (name, cam) in poses {
3249 {
3250 let mut sink = RasterSink::new(&mut fb, &mut zb);
3251 prof::reset();
3252 render_dda(
3253 &cam,
3254 &settings,
3255 grid,
3256 w as usize,
3257 &DdaEnv::default(),
3258 0,
3259 &mut sink,
3260 );
3261 }
3262 let (cells, bricks, surf) = prof::read();
3263 let iters = 6;
3264 let t0 = Instant::now();
3265 for _ in 0..iters {
3266 let mut sink = RasterSink::new(&mut fb, &mut zb);
3267 render_dda(
3268 &cam,
3269 &settings,
3270 grid,
3271 w as usize,
3272 &DdaEnv::default(),
3273 0,
3274 &mut sink,
3275 );
3276 }
3277 let ms = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(iters);
3278 let hits = fb.iter().filter(|&&c| c != 0).count();
3279 eprintln!(
3280 "[{name}] {w}x{h} 1-thread: {ms:.1} ms | hits={hits}/{n} | per-px: cells={:.1} bricks={:.1} surf={:.1}",
3281 cells as f64 / n as f64,
3282 bricks as f64 / n as f64,
3283 surf as f64 / n as f64,
3284 );
3285 }
3286 }
3287
3288 #[test]
3292 fn parallel_matches_sequential() {
3293 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
3294 let surf = 28 + ((x / 4 + y / 6) % 13);
3295 (z >= surf).then_some(VoxColor(0x80_40_60_80 + (x ^ y) % 0x30))
3296 });
3297 let grid = GridView::from_single_vxl(&vxl);
3298 let (w, h) = (96u32, 96u32);
3299 let cam = Camera::orbit(0.8, 0.55, 100.0, [32.0, 32.0, 40.0]);
3300 let env = DdaEnv {
3301 sky: None,
3302 fog_color: 0x00_20_30_40,
3303 fog_max_dist: 120.0,
3304 side_shades: [0, 0, 0, 0, 0x30, 0x10],
3305 materials: None,
3306 terrain_materials: &[],
3307 lights: CpuLights::default(),
3308 world_shadow: None,
3309 };
3310
3311 let (seq_fb, seq_zb) = render_brickmap_env(grid, &cam, w, h, &env);
3312
3313 let n = (w * h) as usize;
3314 let mut par_fb = vec![0u32; n];
3315 let mut par_zb = vec![f32::INFINITY; n];
3316 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
3317 let (cache, mip) = local_cache(&grid, 0);
3318 render_dda_parallel(
3319 &cam,
3320 &settings,
3321 grid,
3322 &mut par_fb,
3323 &mut par_zb,
3324 w as usize,
3325 &env,
3326 &cache,
3327 mip,
3328 );
3329 assert!(par_fb == seq_fb, "parallel colour differs from sequential");
3330 assert!(
3331 par_zb
3332 .iter()
3333 .zip(&seq_zb)
3334 .all(|(a, b)| a.to_bits() == b.to_bits()),
3335 "parallel depth differs from sequential"
3336 );
3337 }
3338
3339 #[test]
3345 fn cliff_side_is_solid_not_see_through() {
3346 const TOP_Z: u32 = 50;
3347 const COL: VoxColor = VoxColor(0x80_77_88_99);
3348 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |_, _, z| (z >= TOP_Z).then_some(COL));
3349 let grid = GridView::from_single_vxl(&vxl);
3350
3351 assert_eq!(grid.voxel_color(4, 4, TOP_Z), Some(COL));
3353 assert_eq!(grid.voxel_color(4, 4, 150), None);
3355 assert_eq!(grid.surface_color(4, 4, 150), Some(COL));
3358 assert_eq!(grid.surface_color(4, 4, 10), None);
3360 }
3361
3362 #[test]
3365 fn camera_inside_solid_hits_everywhere() {
3366 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, _| Some(VoxColor(0x80_55_55_55)));
3367 let grid = GridView::from_single_vxl(&vxl);
3368 let cam = Camera {
3369 pos: [8.0, 8.0, 128.0],
3370 right: [1.0, 0.0, 0.0],
3371 down: [0.0, 1.0, 0.0],
3372 forward: [0.0, 0.0, 1.0],
3373 };
3374 let (w, h) = (32u32, 32u32);
3375 let mask = render_mask(grid, &cam, w, h);
3376 assert!(
3377 mask.iter().all(|&b| b),
3378 "every ray must hit when the camera is inside solid"
3379 );
3380 }
3381
3382 #[test]
3388 fn single_voxel_silhouette_has_no_notch() {
3389 const C: VoxColor = VoxColor(0x80_FF_80_40);
3390 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |x, y, z| {
3391 (x == 8 && y == 8 && z == 8).then_some(C)
3392 });
3393 let grid = GridView::from_single_vxl(&vxl);
3394
3395 let cam = Camera::orbit(0.7, 0.6, 4.0, [8.5, 8.5, 8.5]);
3398 let (w, h) = (96u32, 96u32);
3399 let mask = render_mask(grid, &cam, w, h);
3400
3401 let hits = mask.iter().filter(|&&b| b).count();
3402 assert!(
3403 hits > 30,
3404 "silhouette too small to be meaningful: {hits} px"
3405 );
3406 assert!(
3407 rows_have_no_holes(&mask, w, h),
3408 "row-interior gap in single-voxel silhouette (notch)"
3409 );
3410 assert!(
3411 cols_have_no_holes(&mask, w, h),
3412 "column-interior gap in single-voxel silhouette (notch)"
3413 );
3414 }
3415}