1use std::collections::HashMap;
36
37use rayon::prelude::*;
38
39use crate::camera_math::{self, CameraState};
40use crate::grid_view::GridView;
41use crate::opticast::OpticastSettings;
42use crate::raster_target::RasterTarget;
43use crate::sky::Sky;
44use crate::Camera;
45use roxlap_formats::material::{material_for_color, Material, MaterialTable};
46
47#[derive(Clone, Copy)]
54pub struct DdaEnv<'a> {
55 pub sky: Option<&'a Sky>,
58 pub fog_color: u32,
61 pub fog_max_dist: f32,
63 pub side_shades: [i8; 6],
66 pub materials: Option<&'a MaterialTable>,
69 pub terrain_materials: &'a [(u32, u8)],
73 pub lights: CpuLights<'a>,
79 pub world_shadow: Option<WorldShadowCtx<'a>>,
84}
85
86#[derive(Clone, Copy)]
88pub struct CpuPointLight {
89 pub pos: [f32; 3],
91 pub color: [f32; 3],
93 pub intensity: f32,
94 pub radius: f32,
96 pub casts_shadow: bool,
101 pub spot_dir: [f32; 3],
104 pub cos_inner: f32,
106 pub cos_outer: f32,
110}
111
112#[derive(Clone, Copy, Default)]
118pub struct CpuLights<'a> {
119 pub enabled: bool,
121 pub sun: bool,
123 pub sun_dir: [f32; 3],
125 pub sun_color: [f32; 3],
126 pub sun_intensity: f32,
127 pub sun_casts_shadow: bool,
129 pub points: &'a [CpuPointLight],
131 pub ambient: [f32; 3],
133 pub bands: u32,
135 pub shadow_tint: [f32; 3],
137 pub shadow_strength: f32,
141 pub shadow_bias: f32,
144 pub shadow_max_dist: f32,
147}
148
149impl Default for DdaEnv<'_> {
150 fn default() -> Self {
151 Self {
152 sky: None,
153 fog_color: 0,
154 fog_max_dist: 0.0,
155 side_shades: [0; 6],
156 materials: None,
157 terrain_materials: &[],
158 lights: CpuLights::default(),
159 world_shadow: None,
160 }
161 }
162}
163
164pub trait PixelSink {
172 fn put(&mut self, idx: usize, color: u32, dist: f32);
176}
177
178pub struct RasterSink<'a> {
185 target: RasterTarget<'a>,
186 len: usize,
187}
188
189impl<'a> RasterSink<'a> {
190 #[must_use]
193 pub fn new(framebuffer: &'a mut [u32], zbuffer: &'a mut [f32]) -> Self {
194 debug_assert_eq!(framebuffer.len(), zbuffer.len());
195 let len = framebuffer.len();
196 Self {
197 target: RasterTarget::new(framebuffer, zbuffer),
198 len,
199 }
200 }
201}
202
203impl PixelSink for RasterSink<'_> {
204 fn put(&mut self, idx: usize, color: u32, dist: f32) {
205 if idx < self.len {
206 unsafe {
209 self.target.write_color(idx, color);
210 self.target.write_depth(idx, dist);
211 }
212 }
213 }
214}
215
216#[derive(Debug, Clone, Copy)]
218struct Hit {
219 color: u32,
220 dist: f32,
221}
222
223#[cfg(test)]
225pub(crate) mod prof {
226 use std::cell::Cell;
227 thread_local! {
228 pub static CELLS: Cell<u64> = const { Cell::new(0) };
229 pub static BRICKS: Cell<u64> = const { Cell::new(0) };
230 pub static SURF: Cell<u64> = const { Cell::new(0) };
231 }
232 pub fn reset() {
233 CELLS.with(|x| x.set(0));
234 BRICKS.with(|x| x.set(0));
235 SURF.with(|x| x.set(0));
236 }
237 pub fn read() -> (u64, u64, u64) {
238 (
239 CELLS.with(Cell::get),
240 BRICKS.with(Cell::get),
241 SURF.with(Cell::get),
242 )
243 }
244}
245
246#[inline]
265pub(crate) fn shade(color: u32, bright_sub: u32) -> u32 {
266 let a = ((color >> 24) & 0xff).saturating_sub(bright_sub);
267 let ch = |shift: u32| -> u32 { ((((color >> shift) & 0xff) * a) >> 7).min(255) };
268 0x8000_0000 | (ch(16) << 16) | (ch(8) << 8) | ch(0)
269}
270
271#[inline]
273fn cel_band(x: f32, bands: u32) -> f32 {
274 let b = bands as f32;
275 ((x * b).round() / b).clamp(0.0, 1.0)
276}
277
278#[inline]
281fn point_falloff(d: f32, radius: f32) -> f32 {
282 let x = (1.0 - d / radius).clamp(0.0, 1.0);
283 x * x
284}
285
286#[inline]
289fn smoothstep_scalar(edge0: f32, edge1: f32, x: f32) -> f32 {
290 if edge1 <= edge0 {
291 return if x < edge0 { 0.0 } else { 1.0 };
292 }
293 let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
294 t * t * (3.0 - 2.0 * t)
295}
296
297#[inline]
303fn spot_cone(ldir: [f32; 3], axis: [f32; 3], cos_inner: f32, cos_outer: f32) -> f32 {
304 if cos_outer <= -0.999 {
305 return 1.0;
306 }
307 let cd = -dot3(ldir, axis);
308 smoothstep_scalar(cos_outer, cos_inner, cd)
309}
310
311#[inline]
315fn face_normal_cpu(axis: usize, step: [i32; 3]) -> [f32; 3] {
316 let mut n = [0.0f32; 3];
317 if axis < 3 {
318 n[axis] = -(step[axis] as f32);
319 } else {
320 n[2] = -1.0;
321 }
322 n
323}
324
325#[inline]
326fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
327 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
328}
329
330pub(crate) trait ShadowTester {
338 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool;
339}
340
341pub trait WorldOccluder: Sync {
352 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool;
353}
354
355#[derive(Clone, Copy)]
362pub struct WorldShadowCtx<'a> {
363 pub occluder: &'a dyn WorldOccluder,
364 pub origin: [f32; 3],
365 pub cols: [[f32; 3]; 3],
366}
367
368impl<'a> WorldShadowCtx<'a> {
369 #[must_use]
372 pub fn identity(occluder: &'a dyn WorldOccluder) -> Self {
373 Self {
374 occluder,
375 origin: [0.0; 3],
376 cols: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
377 }
378 }
379}
380
381pub struct CompositeOccluder<'a> {
385 pub a: &'a dyn WorldOccluder,
386 pub b: &'a dyn WorldOccluder,
387}
388
389impl WorldOccluder for CompositeOccluder<'_> {
390 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
391 self.a.occluded_world(origin, dir, max_t) || self.b.occluded_world(origin, dir, max_t)
392 }
393}
394
395pub(crate) struct WorldShadow<'a> {
400 pub ctx: WorldShadowCtx<'a>,
401}
402
403impl ShadowTester for WorldShadow<'_> {
404 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
405 let c = &self.ctx.cols;
406 let wo = [
408 self.ctx.origin[0] + c[0][0] * origin[0] + c[1][0] * origin[1] + c[2][0] * origin[2],
409 self.ctx.origin[1] + c[0][1] * origin[0] + c[1][1] * origin[1] + c[2][1] * origin[2],
410 self.ctx.origin[2] + c[0][2] * origin[0] + c[1][2] * origin[1] + c[2][2] * origin[2],
411 ];
412 let wd = [
413 c[0][0] * dir[0] + c[1][0] * dir[1] + c[2][0] * dir[2],
414 c[0][1] * dir[0] + c[1][1] * dir[1] + c[2][1] * dir[2],
415 c[0][2] * dir[0] + c[1][2] * dir[1] + c[2][2] * dir[2],
416 ];
417 self.ctx.occluder.occluded_world(wo, wd, max_t)
418 }
419}
420
421fn shade_lit_cpu(
428 color: u32,
429 bright_sub: u32,
430 axis: usize,
431 step: [i32; 3],
432 cellc: [i32; 3],
433 cell_size: f32,
434 l: &CpuLights<'_>,
435 shadow: Option<&mut dyn ShadowTester>,
436) -> u32 {
437 let a_b = ((color >> 24) & 0xff).saturating_sub(bright_sub);
438 let ao = a_b as f32 / 128.0;
439 let albedo = [
440 ((color >> 16) & 0xff) as f32 / 255.0,
441 ((color >> 8) & 0xff) as f32 / 255.0,
442 (color & 0xff) as f32 / 255.0,
443 ];
444 let n = face_normal_cpu(axis, step);
445 let center = [
447 (cellc[0] as f32 + 0.5) * cell_size,
448 (cellc[1] as f32 + 0.5) * cell_size,
449 (cellc[2] as f32 + 0.5) * cell_size,
450 ];
451 shade_dynamic(albedo, ao, n, center, l, shadow)
452}
453
454pub(crate) fn shade_dynamic(
460 albedo: [f32; 3],
461 ao: f32,
462 n: [f32; 3],
463 sample: [f32; 3],
464 l: &CpuLights<'_>,
465 shadow: Option<&mut dyn ShadowTester>,
466) -> u32 {
467 let styled = l.bands > 0;
468 let mut shadow = shadow;
472 let shadow_origin = [
473 sample[0] + n[0] * l.shadow_bias,
474 sample[1] + n[1] * l.shadow_bias,
475 sample[2] + n[2] * l.shadow_bias,
476 ];
477 let in_shadow = 1.0 - l.shadow_strength;
478
479 let sun_key = if l.sun {
481 let ndl = dot3(n, l.sun_dir).max(0.0);
482 if ndl > 0.0 && l.sun_casts_shadow {
483 let occ = shadow
484 .as_deref_mut()
485 .is_some_and(|s| s.occluded(shadow_origin, l.sun_dir, l.shadow_max_dist));
486 if occ {
487 ndl * in_shadow
488 } else {
489 ndl
490 }
491 } else {
492 ndl
493 }
494 } else {
495 0.0
496 };
497
498 let mut lit = if styled {
500 let key = cel_band(sun_key, l.bands);
501 let m = |i: usize| {
502 let warm = l.sun_color[i] * l.sun_intensity;
503 (l.shadow_tint[i] + (warm - l.shadow_tint[i]) * key) * ao
504 };
505 [albedo[0] * m(0), albedo[1] * m(1), albedo[2] * m(2)]
506 } else {
507 let base = |i: usize| {
508 albedo[i] * l.ambient[i] * ao + albedo[i] * l.sun_color[i] * l.sun_intensity * sun_key
509 };
510 [base(0), base(1), base(2)]
511 };
512
513 for p in l.points {
516 let d3 = [
517 p.pos[0] - sample[0],
518 p.pos[1] - sample[1],
519 p.pos[2] - sample[2],
520 ];
521 let dist = (d3[0] * d3[0] + d3[1] * d3[1] + d3[2] * d3[2]).sqrt();
522 if dist < p.radius && dist > 1e-4 {
523 let inv = 1.0 / dist;
524 let ldir = [d3[0] * inv, d3[1] * inv, d3[2] * inv];
525 let ndl = dot3(n, ldir).max(0.0);
526 let cone = spot_cone(ldir, p.spot_dir, p.cos_inner, p.cos_outer);
529 if ndl > 0.0 && cone > 0.0 {
530 let sh = if p.casts_shadow
532 && shadow
533 .as_deref_mut()
534 .is_some_and(|s| s.occluded(shadow_origin, ldir, dist))
535 {
536 in_shadow
537 } else {
538 1.0
539 };
540 let mut f = ndl * point_falloff(dist, p.radius) * cone * sh;
541 if styled {
542 f = cel_band(f, l.bands);
543 }
544 for i in 0..3 {
545 lit[i] += albedo[i] * p.color[i] * p.intensity * f;
546 }
547 }
548 }
549 }
550
551 let pack = |v: f32| -> u32 { (v.clamp(0.0, 1.0) * 255.0) as u32 };
552 0x8000_0000 | (pack(lit[0]) << 16) | (pack(lit[1]) << 8) | pack(lit[2])
553}
554
555#[inline]
559fn apply_fog(color: u32, depth: f32, env: &DdaEnv<'_>) -> u32 {
560 if env.fog_max_dist <= 0.0 {
561 return color;
562 }
563 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
564 let f = ((depth / env.fog_max_dist).clamp(0.0, 1.0) * 256.0) as u32; let g = 256 - f;
566 let fog = env.fog_color;
567 let mix = |shift: u32| -> u32 {
568 let src = (color >> shift) & 0xff;
569 let dst = (fog >> shift) & 0xff;
570 ((src * g + dst * f) >> 8).min(255)
571 };
572 0x8000_0000 | (mix(16) << 16) | (mix(8) << 8) | mix(0)
573}
574
575#[inline]
580fn terrain_material(env: &DdaEnv<'_>, color: u32) -> Material {
581 match env.materials {
582 Some(table) if !env.terrain_materials.is_empty() => {
583 table.get(material_for_color(env.terrain_materials, color))
584 }
585 _ => Material::OPAQUE,
586 }
587}
588
589#[inline]
592fn composite_over(accum: [f32; 3], trans: f32, bg: u32) -> u32 {
593 let b = rgb_to_f32(bg);
594 f32_to_rgb([
595 accum[0] + trans * b[0],
596 accum[1] + trans * b[1],
597 accum[2] + trans * b[2],
598 ])
599}
600
601#[inline]
606fn finalize_exit(
607 touched: bool,
608 accum: [f32; 3],
609 trans: f32,
610 env: &DdaEnv<'_>,
611 dir: [f32; 3],
612 dist: f32,
613) -> Option<Hit> {
614 if !touched {
615 return None;
616 }
617 let bg = match env.sky {
618 Some(s) => sample_sky(s, dir),
619 None => 0x8000_0000 | (env.fog_color & 0x00ff_ffff),
620 };
621 Some(Hit {
622 color: composite_over(accum, trans, bg),
623 dist,
624 })
625}
626
627#[inline]
630#[allow(clippy::cast_precision_loss)]
631fn rgb_to_f32(c: u32) -> [f32; 3] {
632 [
633 ((c >> 16) & 0xff) as f32 / 255.0,
634 ((c >> 8) & 0xff) as f32 / 255.0,
635 (c & 0xff) as f32 / 255.0,
636 ]
637}
638
639#[inline]
641#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
642fn f32_to_rgb(c: [f32; 3]) -> u32 {
643 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
644 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
645}
646
647#[allow(
656 clippy::cast_possible_truncation,
657 clippy::cast_sign_loss,
658 clippy::cast_precision_loss
659)]
660fn sample_sky(sky: &Sky, dir: [f32; 3]) -> u32 {
661 let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
662 if len < 1e-9 {
663 return 0x8000_0000;
664 }
665 let d = [dir[0] / len, dir[1] / len, dir[2] / len];
666 let xsiz_full = sky.lat.len().max(1) as i32; let pi = std::f32::consts::PI;
668 let elev01 = (-d[2]).clamp(-1.0, 1.0).acos() / pi; let x = (elev01 * xsiz_full as f32) as i32;
673 let x = x.clamp(0, xsiz_full - 1);
674 let y = if sky.ysiz <= 1 {
676 0
677 } else {
678 let az = d[1].atan2(d[0]); let yf = ((az / (pi * 2.0)) + 0.5) * sky.ysiz as f32;
680 (yf as i32).rem_euclid(sky.ysiz)
681 };
682 let idx = (y * xsiz_full + x) as usize;
683 let px = sky.pixels.get(idx).copied().unwrap_or(0) as u32;
684 0x8000_0000 | (px & 0x00ff_ffff)
685}
686
687#[allow(clippy::cast_possible_truncation)]
697pub fn render_sky_fill(
698 fb: &mut [u32],
699 zb: &[f32],
700 pitch_pixels: usize,
701 width: u32,
702 height: u32,
703 cam: &CameraState,
704 settings: &OpticastSettings,
705 sky: &Sky,
706) {
707 for py in 0..height {
708 let row = py as usize * pitch_pixels;
709 for px in 0..width {
710 let idx = row + px as usize;
711 if zb[idx].is_finite() {
712 continue; }
714 let (_origin, dir) = pixel_ray(cam, settings, px, py);
715 fb[idx] = sample_sky(sky, dir);
716 }
717 }
718}
719
720#[must_use]
732pub fn pixel_ray(
733 cs: &CameraState,
734 settings: &OpticastSettings,
735 px: u32,
736 py: u32,
737) -> ([f32; 3], [f32; 3]) {
738 #[allow(clippy::cast_precision_loss)]
740 let sx = px as f32 - settings.hx;
741 #[allow(clippy::cast_precision_loss)]
742 let sy = py as f32 - settings.hy;
743 let dir = [
744 sx * cs.right[0] + sy * cs.down[0] + settings.hz * cs.forward[0],
745 sx * cs.right[1] + sy * cs.down[1] + settings.hz * cs.forward[1],
746 sx * cs.right[2] + sy * cs.down[2] + settings.hz * cs.forward[2],
747 ];
748 (cs.pos, dir)
749}
750
751pub(crate) fn intersect_aabb(
757 o: [f32; 3],
758 dir: [f32; 3],
759 lo: [f32; 3],
760 hi: [f32; 3],
761) -> Option<(f32, f32)> {
762 let mut t0 = 0.0f32;
763 let mut t1 = f32::INFINITY;
764 for a in 0..3 {
765 if dir[a].abs() < 1e-9 {
766 if o[a] < lo[a] || o[a] > hi[a] {
768 return None;
769 }
770 } else {
771 let inv = 1.0 / dir[a];
772 let mut ta = (lo[a] - o[a]) * inv;
773 let mut tb = (hi[a] - o[a]) * inv;
774 if ta > tb {
775 core::mem::swap(&mut ta, &mut tb);
776 }
777 t0 = t0.max(ta);
778 t1 = t1.min(tb);
779 if t0 > t1 {
780 return None;
781 }
782 }
783 }
784 Some((t0, t1))
785}
786
787const BRICK: i32 = 8;
789
790#[derive(Debug)]
804pub(crate) struct BrickMap {
805 nb: [i32; 3],
807 bits: Vec<u64>,
810 ns: [i32; 3],
813 super_bits: Vec<u64>,
818}
819
820const SUPER: i32 = BRICK * BRICK;
822
823impl BrickMap {
824 #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
827 fn build(grid: &GridView<'_>, mip: u32) -> Self {
828 let vsid_m = (grid.vsid >> mip).max(1) as i32;
829 let z_m = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1) as i32;
830 let nb = [
831 (vsid_m + BRICK - 1) / BRICK,
832 (vsid_m + BRICK - 1) / BRICK,
833 (z_m + BRICK - 1) / BRICK,
834 ];
835 let ns = [
836 (nb[0] + BRICK - 1) / BRICK,
837 (nb[1] + BRICK - 1) / BRICK,
838 (nb[2] + BRICK - 1) / BRICK,
839 ];
840 let count = (nb[0] * nb[1] * nb[2]) as usize;
841 let scount = (ns[0] * ns[1] * ns[2]) as usize;
842 let mut bits = vec![0u64; count.div_ceil(64)];
843 let mut super_bits = vec![0u64; scount.div_ceil(64)];
844 for y in 0..vsid_m {
845 for x in 0..vsid_m {
846 let (bx, by) = (x / BRICK, y / BRICK);
847 grid.for_each_run_mip(x as u32, y as u32, mip, |top, bot| {
848 for bz in (top / BRICK)..=((bot - 1) / BRICK) {
849 let idx = ((bz * nb[1] + by) * nb[0] + bx) as usize;
850 bits[idx / 64] |= 1u64 << (idx % 64);
851 let sidx =
852 (((bz / BRICK) * ns[1] + by / BRICK) * ns[0] + bx / BRICK) as usize;
853 super_bits[sidx / 64] |= 1u64 << (sidx % 64);
854 }
855 });
856 }
857 }
858 Self {
859 nb,
860 bits,
861 ns,
862 super_bits,
863 }
864 }
865
866 #[inline]
868 #[allow(clippy::cast_sign_loss)]
869 fn occupied(&self, b: [i32; 3]) -> bool {
870 if b[0] < 0
871 || b[0] >= self.nb[0]
872 || b[1] < 0
873 || b[1] >= self.nb[1]
874 || b[2] < 0
875 || b[2] >= self.nb[2]
876 {
877 return false;
878 }
879 let idx = ((b[2] * self.nb[1] + b[1]) * self.nb[0] + b[0]) as usize;
880 (self.bits[idx / 64] >> (idx % 64)) & 1 != 0
881 }
882
883 #[inline]
885 #[allow(clippy::cast_sign_loss)]
886 fn occupied_super(&self, s: [i32; 3]) -> bool {
887 if s[0] < 0
888 || s[0] >= self.ns[0]
889 || s[1] < 0
890 || s[1] >= self.ns[1]
891 || s[2] < 0
892 || s[2] >= self.ns[2]
893 {
894 return false;
895 }
896 let idx = ((s[2] * self.ns[1] + s[1]) * self.ns[0] + s[0]) as usize;
897 (self.super_bits[idx / 64] >> (idx % 64)) & 1 != 0
898 }
899}
900
901pub(crate) fn dda_setup(
907 origin: [f32; 3],
908 dir: [f32; 3],
909 cell: [i32; 3],
910 cell_size: f32,
911) -> ([i32; 3], [f32; 3], [f32; 3]) {
912 let mut step = [0i32; 3];
913 let mut t_max = [f32::INFINITY; 3];
914 let mut t_delta = [f32::INFINITY; 3];
915 for a in 0..3 {
916 if dir[a] > 1e-9 {
917 step[a] = 1;
918 #[allow(clippy::cast_precision_loss)]
919 let boundary = (cell[a] + 1) as f32 * cell_size;
920 t_max[a] = (boundary - origin[a]) / dir[a];
921 t_delta[a] = cell_size / dir[a];
922 } else if dir[a] < -1e-9 {
923 step[a] = -1;
924 #[allow(clippy::cast_precision_loss)]
925 let boundary = cell[a] as f32 * cell_size;
926 t_max[a] = (boundary - origin[a]) / dir[a];
927 t_delta[a] = -cell_size / dir[a];
928 }
929 }
930 (step, t_max, t_delta)
931}
932
933#[inline]
936pub(crate) fn min_axis(t_max: [f32; 3]) -> usize {
937 if t_max[0] <= t_max[1] && t_max[0] <= t_max[2] {
938 0
939 } else if t_max[1] <= t_max[2] {
940 1
941 } else {
942 2
943 }
944}
945
946#[derive(Debug, Default)]
956pub struct BrickCache {
957 maps: HashMap<(i32, i32, i32, u32), (u64, BrickMap)>,
958}
959
960impl BrickCache {
961 #[must_use]
962 pub fn new() -> Self {
963 Self::default()
964 }
965
966 pub fn ensure(&mut self, chunk: [i32; 3], mip: u32, version: u64, view: &GridView<'_>) {
969 let key = (chunk[0], chunk[1], chunk[2], mip);
970 let stale = self.maps.get(&key).map_or(true, |(v, _)| *v != version);
971 if stale {
972 self.maps.insert(key, (version, BrickMap::build(view, mip)));
973 }
974 }
975
976 #[inline]
977 fn get(&self, chunk: [i32; 3], mip: u32) -> Option<&BrickMap> {
978 self.maps
979 .get(&(chunk[0], chunk[1], chunk[2], mip))
980 .map(|(_, m)| m)
981 }
982
983 pub fn retain_chunks(&mut self, keep: impl Fn([i32; 3]) -> bool) {
986 self.maps.retain(|k, _| keep([k.0, k.1, k.2]));
987 }
988}
989
990#[allow(clippy::cast_possible_wrap)]
995fn local_cache(grid: &GridView<'_>, requested_mip: u32) -> (BrickCache, u32) {
996 let mip = effective_mip(grid, requested_mip);
997 let mut cache = BrickCache::new();
998 if let Some(cg) = grid.chunk_grid {
999 for dz in 0..cg.chunks_z as i32 {
1000 for dy in 0..cg.chunks_y as i32 {
1001 for dx in 0..cg.chunks_x as i32 {
1002 let slot = ((dz * cg.chunks_y as i32 + dy) * cg.chunks_x as i32 + dx) as usize;
1003 if let Some(Some(view)) = cg.chunks.get(slot) {
1004 let ch = [
1005 cg.origin_chunk_xy[0] + dx,
1006 cg.origin_chunk_xy[1] + dy,
1007 cg.origin_chunk_z + dz,
1008 ];
1009 cache.ensure(ch, mip, 0, view);
1010 }
1011 }
1012 }
1013 }
1014 } else {
1015 cache.ensure([0, 0, 0], mip, 0, grid);
1016 }
1017 (cache, mip)
1018}
1019
1020#[must_use]
1025pub fn effective_mip(grid: &GridView<'_>, requested: u32) -> u32 {
1026 if requested == 0 {
1027 return 0;
1028 }
1029 let mut m = requested;
1030 if let Some(cg) = grid.chunk_grid {
1031 for c in cg.chunks.iter().flatten() {
1032 m = m.min(c.mip_count().saturating_sub(1));
1033 }
1034 } else {
1035 m = m.min(grid.mip_count().saturating_sub(1));
1036 }
1037 m
1038}
1039
1040struct Sampler<'a> {
1054 grid: GridView<'a>,
1055 bricks: &'a BrickCache,
1056 mip: u32,
1059 xy_shift: u32,
1068 xy_mask: i32,
1069 z_shift: u32,
1070 z_mask: i32,
1071 cur_ch: [i32; 3],
1072 cur_view: Option<GridView<'a>>,
1073 cur_brick: Option<&'a BrickMap>,
1074 has_cur: bool,
1075}
1076
1077impl<'a> Sampler<'a> {
1078 fn new(grid: GridView<'a>, bricks: &'a BrickCache, mip: u32) -> Self {
1079 let cs_xy = (grid.chunk_size_xy >> mip).max(1);
1080 let cs_z = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1);
1081 debug_assert!(
1082 cs_xy.is_power_of_two() && cs_z.is_power_of_two(),
1083 "chunk dims must be powers of two for the shift/mask split"
1084 );
1085 #[allow(clippy::cast_possible_wrap)]
1086 Self {
1087 grid,
1088 bricks,
1089 mip,
1090 xy_shift: cs_xy.trailing_zeros(),
1091 xy_mask: cs_xy as i32 - 1,
1092 z_shift: cs_z.trailing_zeros(),
1093 z_mask: cs_z as i32 - 1,
1094 cur_ch: [0; 3],
1095 cur_view: None,
1096 cur_brick: None,
1097 has_cur: false,
1098 }
1099 }
1100
1101 fn select_chunk(&mut self, ch: [i32; 3]) {
1103 if self.has_cur && self.cur_ch == ch {
1104 return;
1105 }
1106 self.cur_view = self.grid.chunk_at_xyz(ch);
1107 self.cur_brick = self.bricks.get(ch, self.mip);
1108 self.cur_ch = ch;
1109 self.has_cur = true;
1110 }
1111
1112 #[allow(clippy::cast_sign_loss)]
1117 fn locate(&self, c: [i32; 3]) -> ([i32; 3], [u32; 3]) {
1118 let ch = [
1119 c[0] >> self.xy_shift,
1120 c[1] >> self.xy_shift,
1121 c[2] >> self.z_shift,
1122 ];
1123 let loc = [
1124 (c[0] & self.xy_mask) as u32,
1125 (c[1] & self.xy_mask) as u32,
1126 (c[2] & self.z_mask) as u32,
1127 ];
1128 (ch, loc)
1129 }
1130
1131 #[allow(clippy::cast_possible_wrap)]
1135 fn hit(&mut self, c: [i32; 3]) -> Option<u32> {
1136 #[cfg(test)]
1137 prof::SURF.with(|x| x.set(x.get() + 1));
1138 let (ch, loc) = self.locate(c);
1139 self.select_chunk(ch);
1140 let occupied = self.cur_brick.is_some_and(|bm| {
1141 bm.occupied([
1142 loc[0] as i32 / BRICK,
1143 loc[1] as i32 / BRICK,
1144 loc[2] as i32 / BRICK,
1145 ])
1146 });
1147 if !occupied {
1148 return None;
1149 }
1150 self.cur_view?
1151 .surface_color_mip(loc[0], loc[1], loc[2], self.mip)
1152 }
1153
1154 #[inline]
1156 fn cells_per_chunk_xy(&self) -> i32 {
1157 1 << self.xy_shift
1158 }
1159 #[inline]
1160 fn cells_per_chunk_z(&self) -> i32 {
1161 1 << self.z_shift
1162 }
1163
1164 #[allow(clippy::cast_sign_loss)]
1169 fn brick_occupied(&mut self, brick: [i32; 3]) -> bool {
1170 let c0 = [brick[0] << 3, brick[1] << 3, brick[2] << 3];
1172 let ch = [
1173 c0[0] >> self.xy_shift,
1174 c0[1] >> self.xy_shift,
1175 c0[2] >> self.z_shift,
1176 ];
1177 self.select_chunk(ch);
1178 self.cur_brick.is_some_and(|bm| {
1179 bm.occupied([
1180 (c0[0] & self.xy_mask) >> 3,
1181 (c0[1] & self.xy_mask) >> 3,
1182 (c0[2] & self.z_mask) >> 3,
1183 ])
1184 })
1185 }
1186
1187 #[allow(clippy::cast_sign_loss)]
1192 fn super_occupied(&mut self, s: [i32; 3]) -> bool {
1193 let c0 = [s[0] << 6, s[1] << 6, s[2] << 6];
1195 let ch = [
1196 c0[0] >> self.xy_shift,
1197 c0[1] >> self.xy_shift,
1198 c0[2] >> self.z_shift,
1199 ];
1200 self.select_chunk(ch);
1201 self.cur_brick.is_some_and(|bm| {
1202 bm.occupied_super([
1203 (c0[0] & self.xy_mask) >> 6,
1204 (c0[1] & self.xy_mask) >> 6,
1205 (c0[2] & self.z_mask) >> 6,
1206 ])
1207 })
1208 }
1209}
1210
1211const SHADOW_MAX_STEPS: u32 = 1024;
1215
1216struct SamplerShadow<'s, 'a> {
1223 sampler: &'s mut Sampler<'a>,
1224 cell_size: f32,
1225 lo_c: [i32; 3],
1226 hi_c: [i32; 3],
1227}
1228
1229impl ShadowTester for SamplerShadow<'_, '_> {
1230 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
1231 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
1232 let cs = self.cell_size;
1233 let mut cellc = [
1234 (origin[0] / cs).floor() as i32,
1235 (origin[1] / cs).floor() as i32,
1236 (origin[2] / cs).floor() as i32,
1237 ];
1238 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cs);
1239 let mut t_curr = 0.0f32;
1240 for _ in 0..SHADOW_MAX_STEPS {
1241 if cellc[0] < self.lo_c[0]
1242 || cellc[0] >= self.hi_c[0]
1243 || cellc[1] < self.lo_c[1]
1244 || cellc[1] >= self.hi_c[1]
1245 || cellc[2] < self.lo_c[2]
1246 || cellc[2] >= self.hi_c[2]
1247 {
1248 return false; }
1250 if t_curr > max_t {
1251 return false; }
1253 if self.sampler.hit(cellc).is_some() {
1254 return true; }
1256 let axis = min_axis(t_max);
1257 t_curr = t_max[axis];
1258 cellc[axis] += step[axis];
1259 t_max[axis] += t_delta[axis];
1260 }
1261 false
1262 }
1263}
1264
1265#[allow(
1286 clippy::too_many_arguments,
1287 clippy::cast_possible_truncation,
1288 clippy::cast_sign_loss,
1289 clippy::cast_precision_loss
1290)]
1291fn cell_walk_skip(
1292 origin: [f32; 3],
1293 dir: [f32; 3],
1294 fwd_dot: f32,
1295 sampler: &mut Sampler<'_>,
1296 lo_c: [i32; 3],
1297 hi_c: [i32; 3],
1298 cell_size: f32,
1299 t_enter: f32,
1300 t_exit: f32,
1301 max_dist: f32,
1302 env: &DdaEnv<'_>,
1303) -> Option<Hit> {
1304 let has_super = sampler.cells_per_chunk_xy() >= SUPER && sampler.cells_per_chunk_z() >= SUPER;
1305 let has_brick = sampler.cells_per_chunk_xy() >= BRICK && sampler.cells_per_chunk_z() >= BRICK;
1306
1307 let start = t_enter + 1e-4;
1308 let p = [
1309 origin[0] + dir[0] * start,
1310 origin[1] + dir[1] * start,
1311 origin[2] + dir[2] * start,
1312 ];
1313 let mut cellc = [
1314 ((p[0] / cell_size).floor() as i32).clamp(lo_c[0], hi_c[0] - 1),
1315 ((p[1] / cell_size).floor() as i32).clamp(lo_c[1], hi_c[1] - 1),
1316 ((p[2] / cell_size).floor() as i32).clamp(lo_c[2], hi_c[2] - 1),
1317 ];
1318 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cell_size);
1319 let inv = [
1323 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
1324 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
1325 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
1326 ];
1327 let mut t_curr = t_enter;
1328 let mut last_axis = 3usize;
1329 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
1332
1333 let mut accum = [0.0f32; 3];
1338 let mut trans = 1.0f32;
1339 let mut touched = false;
1340 let mut prev_solid = false;
1341 let mut prev_mat = 0u8;
1342
1343 let span = (hi_c[0] - lo_c[0]) + (hi_c[1] - lo_c[1]) + (hi_c[2] - lo_c[2]);
1346 let max_steps = span.max(0) as usize + 16;
1347 for _ in 0..max_steps {
1348 if cellc[0] < lo_c[0]
1349 || cellc[0] >= hi_c[0]
1350 || cellc[1] < lo_c[1]
1351 || cellc[1] >= hi_c[1]
1352 || cellc[2] < lo_c[2]
1353 || cellc[2] >= hi_c[2]
1354 {
1355 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1356 }
1357 let depth = t_curr * fwd_dot;
1358 if depth > max_dist || t_curr > t_exit {
1359 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1360 }
1361 if env.fog_max_dist > 0.0 && depth >= env.fog_max_dist {
1367 let fog = 0x8000_0000 | (env.fog_color & 0x00ff_ffff);
1368 let color = if touched {
1369 composite_over(accum, trans, fog)
1370 } else {
1371 fog
1372 };
1373 return Some(Hit {
1374 color,
1375 dist: env.fog_max_dist,
1376 });
1377 }
1378
1379 let skip_shift = if has_super
1382 && !sampler.super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
1383 {
1384 Some(6u32)
1385 } else if has_brick
1386 && !sampler.brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
1387 {
1388 Some(3u32)
1389 } else {
1390 None
1391 };
1392 if let Some(sh) = skip_shift {
1393 #[cfg(test)]
1394 prof::BRICKS.with(|x| x.set(x.get() + 1));
1395 let mut best_t = f32::INFINITY;
1397 let mut best_axis = 3usize;
1398 let mut plane = [0i32; 3];
1399 for a in 0..3 {
1400 if step[a] == 0 {
1401 continue;
1402 }
1403 let idx = cellc[a] >> sh;
1404 plane[a] = if step[a] > 0 {
1405 (idx + 1) << sh
1406 } else {
1407 idx << sh
1408 };
1409 let tb = (plane[a] as f32 * cell_size - origin[a]) * inv[a];
1410 if tb < best_t {
1411 best_t = tb;
1412 best_axis = a;
1413 }
1414 }
1415 if best_axis == 3 {
1416 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1417 }
1418 let pb = [
1423 origin[0] + dir[0] * (best_t + 1e-4),
1424 origin[1] + dir[1] * (best_t + 1e-4),
1425 origin[2] + dir[2] * (best_t + 1e-4),
1426 ];
1427 let mut nc = [
1428 (pb[0] / cell_size).floor() as i32,
1429 (pb[1] / cell_size).floor() as i32,
1430 (pb[2] / cell_size).floor() as i32,
1431 ];
1432 nc[best_axis] = if step[best_axis] > 0 {
1433 plane[best_axis]
1434 } else {
1435 plane[best_axis] - 1
1436 };
1437 if nc[0] < lo_c[0]
1441 || nc[0] >= hi_c[0]
1442 || nc[1] < lo_c[1]
1443 || nc[1] >= hi_c[1]
1444 || nc[2] < lo_c[2]
1445 || nc[2] >= hi_c[2]
1446 {
1447 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1448 }
1449 cellc = nc;
1450 for a in 0..3 {
1453 if step[a] > 0 {
1454 t_max[a] = ((cellc[a] + 1) as f32 * cell_size - origin[a]) * inv[a];
1455 } else if step[a] < 0 {
1456 t_max[a] = (cellc[a] as f32 * cell_size - origin[a]) * inv[a];
1457 }
1458 }
1459 t_curr = best_t.max(t_curr);
1460 last_axis = best_axis;
1461 prev_solid = false; continue;
1463 }
1464
1465 #[cfg(test)]
1467 prof::CELLS.with(|x| x.set(x.get() + 1));
1468 if let Some(color) = sampler.hit(cellc) {
1469 let bright_sub = side_shade_sub(env, last_axis, step);
1470 let shaded = if env.lights.enabled {
1476 let casts = env.lights.shadow_strength > 0.0
1477 && (env.lights.sun_casts_shadow
1478 || env.lights.points.iter().any(|p| p.casts_shadow));
1479 let mut world_sh;
1485 let mut sampler_sh;
1486 let tester: Option<&mut dyn ShadowTester> = if !casts {
1487 None
1488 } else if let Some(ctx) = env.world_shadow {
1489 world_sh = WorldShadow { ctx };
1490 Some(&mut world_sh)
1491 } else {
1492 sampler_sh = SamplerShadow {
1493 sampler: &mut *sampler,
1494 cell_size,
1495 lo_c,
1496 hi_c,
1497 };
1498 Some(&mut sampler_sh)
1499 };
1500 shade_lit_cpu(
1501 color,
1502 bright_sub,
1503 last_axis,
1504 step,
1505 cellc,
1506 cell_size,
1507 &env.lights,
1508 tester,
1509 )
1510 } else {
1511 shade(color, bright_sub)
1512 };
1513 let lit = apply_fog(shaded, depth.max(0.0), env);
1514 let m = terrain_material(env, color);
1515 if m.is_opaque() {
1516 let color = if touched {
1520 composite_over(accum, trans, lit)
1521 } else {
1522 lit
1523 };
1524 return Some(Hit {
1525 color,
1526 dist: depth.max(0.0),
1527 });
1528 }
1529 let mat_id = material_for_color(env.terrain_materials, color);
1530 let a = f32::from(m.alpha) / 255.0;
1531 if matches!(m.mode, roxlap_formats::material::BlendMode::Volumetric) {
1532 let t_exit = t_max[min_axis(t_max)];
1536 let seg_len = (t_exit - t_curr).max(0.0) * dir_len / cell_size;
1537 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
1538 let c = rgb_to_f32(lit);
1539 accum[0] += trans * eff_a * c[0];
1540 accum[1] += trans * eff_a * c[1];
1541 accum[2] += trans * eff_a * c[2];
1542 trans *= 1.0 - eff_a;
1543 touched = true;
1544 prev_mat = mat_id;
1545 if trans < 1.0 / 256.0 {
1546 return Some(Hit {
1547 color: f32_to_rgb(accum),
1548 dist: depth.max(0.0),
1549 });
1550 }
1551 } else if !prev_solid || mat_id != prev_mat {
1552 let c = rgb_to_f32(lit);
1556 accum[0] += trans * a * c[0];
1557 accum[1] += trans * a * c[1];
1558 accum[2] += trans * a * c[2];
1559 if !matches!(m.mode, roxlap_formats::material::BlendMode::Additive) {
1560 trans *= 1.0 - a; }
1562 touched = true;
1563 prev_mat = mat_id;
1564 if trans < 1.0 / 256.0 {
1565 return Some(Hit {
1566 color: f32_to_rgb(accum),
1567 dist: depth.max(0.0),
1568 });
1569 }
1570 }
1571 prev_solid = true;
1572 } else {
1573 prev_solid = false;
1574 }
1575 let axis = min_axis(t_max);
1576 last_axis = axis;
1577 t_curr = t_max[axis];
1578 cellc[axis] += step[axis];
1579 t_max[axis] += t_delta[axis];
1580 }
1581 None
1582}
1583
1584#[inline]
1590fn side_shade_sub(env: &DdaEnv<'_>, axis: usize, step: [i32; 3]) -> u32 {
1591 if axis >= 3 {
1592 return 0;
1593 }
1594 let face = axis * 2 + usize::from(step[axis] < 0);
1595 env.side_shades[face].max(0) as u32
1596}
1597
1598fn cast_ray(
1607 origin: [f32; 3],
1608 dir: [f32; 3],
1609 forward: [f32; 3],
1610 sampler: &mut Sampler<'_>,
1611 settings: &OpticastSettings,
1612 env: &DdaEnv<'_>,
1613) -> Option<Hit> {
1614 let (lo_i, hi_i) = sampler.grid.voxel_bounds();
1615 #[allow(clippy::cast_precision_loss)]
1616 let lo_f = [lo_i[0] as f32, lo_i[1] as f32, lo_i[2] as f32];
1617 #[allow(clippy::cast_precision_loss)]
1618 let hi_f = [hi_i[0] as f32, hi_i[1] as f32, hi_i[2] as f32];
1619 let (t_enter, t_exit) = intersect_aabb(origin, dir, lo_f, hi_f)?;
1620 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1621 #[allow(clippy::cast_precision_loss)]
1622 let max_dist = settings.max_scan_dist.max(1) as f32;
1623 let cell = 1i32 << sampler.mip;
1624 let cell_size = cell as f32;
1625 let lo_c = [
1626 lo_i[0].div_euclid(cell),
1627 lo_i[1].div_euclid(cell),
1628 lo_i[2].div_euclid(cell),
1629 ];
1630 let hi_c = [
1631 hi_i[0].div_euclid(cell),
1632 hi_i[1].div_euclid(cell),
1633 hi_i[2].div_euclid(cell),
1634 ];
1635 cell_walk_skip(
1636 origin, dir, fwd_dot, sampler, lo_c, hi_c, cell_size, t_enter, t_exit, max_dist, env,
1637 )
1638}
1639
1640pub fn render_dda(
1653 camera: &Camera,
1654 settings: &OpticastSettings,
1655 grid: GridView<'_>,
1656 pitch_pixels: usize,
1657 env: &DdaEnv<'_>,
1658 mip: u32,
1659 sink: &mut impl PixelSink,
1660) {
1661 let cs = camera_math::derive(
1662 camera,
1663 settings.xres,
1664 settings.yres,
1665 settings.hx,
1666 settings.hy,
1667 settings.hz,
1668 );
1669
1670 let (cache, mip) = local_cache(&grid, mip);
1673 let mut sampler = Sampler::new(grid, &cache, mip);
1674
1675 for py in settings.y_start..settings.y_end {
1676 let row = py as usize * pitch_pixels;
1677 for px in 0..settings.xres {
1678 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py) {
1679 sink.put(row + px as usize, color, dist);
1680 }
1681 }
1682 }
1683}
1684
1685#[inline]
1690fn pixel_result(
1691 cs: &CameraState,
1692 settings: &OpticastSettings,
1693 sampler: &mut Sampler<'_>,
1694 env: &DdaEnv<'_>,
1695 px: u32,
1696 py: u32,
1697) -> Option<(u32, f32)> {
1698 let (origin, dir) = pixel_ray(cs, settings, px, py);
1699 if let Some(hit) = cast_ray(origin, dir, cs.forward, sampler, settings, env) {
1700 Some((hit.color, hit.dist))
1701 } else {
1702 env.sky.map(|sky| (sample_sky(sky, dir), f32::INFINITY))
1703 }
1704}
1705
1706#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
1721pub fn render_dda_parallel(
1722 camera: &Camera,
1723 settings: &OpticastSettings,
1724 grid: GridView<'_>,
1725 fb: &mut [u32],
1726 zb: &mut [f32],
1727 pitch_pixels: usize,
1728 env: &DdaEnv<'_>,
1729 cache: &BrickCache,
1730 mip: u32,
1731) {
1732 debug_assert_eq!(fb.len(), zb.len());
1733 let (y0, y1) = (settings.y_start, settings.y_end);
1734 if y1 <= y0 {
1735 return;
1736 }
1737 let cs = camera_math::derive(
1738 camera,
1739 settings.xres,
1740 settings.yres,
1741 settings.hx,
1742 settings.hy,
1743 settings.hz,
1744 );
1745 let target = RasterTarget::new(fb, zb);
1746
1747 let nthreads = rayon::current_num_threads().max(1);
1749 let rows = (y1 - y0) as usize;
1750 let band = rows.div_ceil(nthreads).max(1) as u32;
1751 let bands: Vec<(u32, u32)> = (y0..y1)
1752 .step_by(band as usize)
1753 .map(|s| (s, (s + band).min(y1)))
1754 .collect();
1755
1756 bands.par_iter().for_each(|&(by0, by1)| {
1757 let mut sampler = Sampler::new(grid, cache, mip);
1758 for py in by0..by1 {
1759 let row = py as usize * pitch_pixels;
1760 for px in 0..settings.xres {
1761 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py)
1762 {
1763 let idx = row + px as usize;
1764 unsafe {
1768 target.write_color(idx, color);
1769 target.write_depth(idx, dist);
1770 }
1771 }
1772 }
1773 }
1774 });
1775}
1776
1777#[cfg(test)]
1783#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
1784fn cast_ray_reference(
1785 origin: [f32; 3],
1786 dir: [f32; 3],
1787 forward: [f32; 3],
1788 grid: &GridView<'_>,
1789 settings: &OpticastSettings,
1790) -> Option<Hit> {
1791 let nx = grid.vsid as f32;
1792 let nz = f32::from(u16::try_from(crate::grid_view::CHUNK_SIZE_Z).unwrap_or(256));
1793 #[allow(clippy::cast_possible_wrap)]
1794 let n_i = [
1795 grid.vsid as i32,
1796 grid.vsid as i32,
1797 crate::grid_view::CHUNK_SIZE_Z as i32,
1798 ];
1799 let (t_enter, t_exit) = intersect_aabb(origin, dir, [0.0; 3], [nx, nx, nz])?;
1800 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1801 let max_dist = settings.max_scan_dist.max(1) as f32;
1802
1803 let start = t_enter + 1e-4;
1804 let p = [
1805 origin[0] + dir[0] * start,
1806 origin[1] + dir[1] * start,
1807 origin[2] + dir[2] * start,
1808 ];
1809 let mut voxel = [
1810 (p[0].floor() as i32).clamp(0, n_i[0] - 1),
1811 (p[1].floor() as i32).clamp(0, n_i[1] - 1),
1812 (p[2].floor() as i32).clamp(0, n_i[2] - 1),
1813 ];
1814 let (step, mut t_max, t_delta) = dda_setup(origin, dir, voxel, 1.0);
1815 let mut t_curr = t_enter;
1816 let max_steps = (n_i[0] + n_i[1] + n_i[2]) as usize + 8;
1817 for _ in 0..max_steps {
1818 if voxel[0] < 0
1819 || voxel[0] >= n_i[0]
1820 || voxel[1] < 0
1821 || voxel[1] >= n_i[1]
1822 || voxel[2] < 0
1823 || voxel[2] >= n_i[2]
1824 {
1825 return None;
1826 }
1827 let depth = t_curr * fwd_dot;
1828 if depth > max_dist || t_curr > t_exit {
1829 return None;
1830 }
1831 #[allow(clippy::cast_sign_loss)]
1832 if let Some(color) = grid.surface_color(voxel[0] as u32, voxel[1] as u32, voxel[2] as u32) {
1833 return Some(Hit {
1834 color: shade(color, 0),
1835 dist: depth.max(0.0),
1836 });
1837 }
1838 let axis = min_axis(t_max);
1839 t_curr = t_max[axis];
1840 voxel[axis] += step[axis];
1841 t_max[axis] += t_delta[axis];
1842 }
1843 None
1844}
1845
1846#[cfg(test)]
1847mod tests {
1848 use super::*;
1849
1850 fn lum(p: u32) -> u32 {
1852 (p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)
1853 }
1854
1855 #[test]
1856 fn cel_band_quantizes_and_collapses() {
1857 assert_eq!(cel_band(0.8, 2), cel_band(0.9, 2));
1859 assert!((cel_band(0.8, 2) - 1.0).abs() < 1e-6);
1860 assert_ne!(cel_band(0.3, 2), cel_band(0.8, 2));
1862 }
1863
1864 #[test]
1865 fn shade_lit_cpu_sun_lights_by_facing() {
1866 let color = 0x80_80_80_80;
1869 let step = [0, 0, 1];
1870 let base = CpuLights {
1871 enabled: true,
1872 sun: true,
1873 sun_color: [1.0; 3],
1874 sun_intensity: 1.0,
1875 ambient: [0.2; 3],
1876 ..CpuLights::default()
1877 };
1878 let facing = CpuLights {
1879 sun_dir: [0.0, 0.0, -1.0],
1880 ..base
1881 }; let back = CpuLights {
1883 sun_dir: [0.0, 0.0, 1.0],
1884 ..base
1885 }; let lit = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &facing, None);
1887 let dark = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &back, None);
1888 assert!(
1889 lum(lit) > lum(dark),
1890 "sun facing the surface must brighten it: {lit:#08x} vs {dark:#08x}",
1891 );
1892 }
1893
1894 #[test]
1895 fn shade_dynamic_spot_cone_masks_off_axis() {
1896 let albedo = [0.5, 0.5, 0.5];
1899 let n = [0.0, 0.0, -1.0];
1900 let sample = [0.0, 0.0, 0.0];
1901 let inner = 10.0f32.to_radians().cos();
1902 let outer = 15.0f32.to_radians().cos();
1903 let shade = |spot_dir: [f32; 3], cos_inner: f32, cos_outer: f32| {
1904 let pts = [CpuPointLight {
1905 pos: [0.0, 0.0, -10.0],
1906 color: [1.0; 3],
1907 intensity: 1.0,
1908 radius: 64.0,
1909 casts_shadow: false,
1910 spot_dir,
1911 cos_inner,
1912 cos_outer,
1913 }];
1914 let l = CpuLights {
1915 enabled: true,
1916 ambient: [0.0; 3],
1917 points: &pts,
1918 ..CpuLights::default()
1919 };
1920 shade_dynamic(albedo, 0.0, n, sample, &l, None)
1921 };
1922 let point = shade([0.0, 0.0, 1.0], -1.0, -1.0);
1924 let on_axis = shade([0.0, 0.0, 1.0], inner, outer);
1926 let off_axis = shade([1.0, 0.0, 0.0], inner, outer);
1928
1929 assert_eq!(
1931 on_axis, point,
1932 "on-axis spot must equal the point light: {on_axis:#08x} vs {point:#08x}",
1933 );
1934 assert!(
1936 lum(on_axis) > lum(off_axis),
1937 "off-axis spot must be darker: {on_axis:#08x} vs {off_axis:#08x}",
1938 );
1939 assert_eq!(lum(off_axis), 0, "off-cone spot contributes nothing");
1940 }
1941
1942 #[test]
1943 fn shade_lit_cpu_cel_terraces_sun() {
1944 let color = 0x80_80_80_80;
1947 let step = [0, 0, 1];
1948 let mk = |zc: f32, bands: u32| {
1949 let n = (1.0f32 - zc * zc).sqrt();
1950 CpuLights {
1951 enabled: true,
1952 sun: true,
1953 sun_dir: [n, 0.0, -zc], sun_color: [1.0; 3],
1955 sun_intensity: 1.0,
1956 ambient: [0.1; 3],
1957 bands,
1958 ..CpuLights::default()
1959 }
1960 };
1961 let smooth_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 0), None);
1962 let smooth_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 0), None);
1963 assert_ne!(smooth_a, smooth_b, "smooth diffuse must vary with N·L");
1964 let cel_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 2), None);
1965 let cel_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 2), None);
1966 assert_eq!(
1967 cel_a, cel_b,
1968 "cel banding must terrace both N·L to one level"
1969 );
1970 }
1971
1972 #[test]
1976 fn shade_dynamic_sun_shadow_darkens() {
1977 struct Mock(bool);
1978 impl ShadowTester for Mock {
1979 fn occluded(&mut self, _: [f32; 3], _: [f32; 3], _: f32) -> bool {
1980 self.0
1981 }
1982 }
1983 let l = CpuLights {
1984 enabled: true,
1985 sun: true,
1986 sun_dir: [0.0, 0.0, -1.0], sun_color: [1.0; 3],
1988 sun_intensity: 1.0,
1989 sun_casts_shadow: true,
1990 ambient: [0.2; 3],
1991 shadow_strength: 0.7,
1992 shadow_bias: 1.5,
1993 shadow_max_dist: 64.0,
1994 ..CpuLights::default()
1995 };
1996 let albedo = [0.8; 3];
1997 let n = [0.0, 0.0, -1.0]; let s = [0.5, 0.5, 0.5];
1999 let lit = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(false)));
2000 let shadowed = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(true)));
2001 assert!(
2002 lum(shadowed) < lum(lit),
2003 "an occluded sun face must darken: shadowed={shadowed:#08x} lit={lit:#08x}",
2004 );
2005 let l0 = CpuLights {
2007 shadow_strength: 0.0,
2008 ..l
2009 };
2010 assert_eq!(
2011 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(true))),
2012 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(false))),
2013 "shadow_strength 0 ⇒ shadows invisible",
2014 );
2015 }
2016
2017 #[test]
2023 fn sampler_shadow_march_casts_sun_shadow() {
2024 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, _y, z| {
2026 if z >= 60 {
2027 Some(0x80_80_80_80) } else if x == 32 && (30..60).contains(&z) {
2029 Some(0x80_70_70_70) } else {
2031 None
2032 }
2033 });
2034 let grid = GridView::from_single_vxl(&vxl);
2035 let cam = Camera {
2037 pos: [32.0, 32.0, 6.0],
2038 right: [1.0, 0.0, 0.0],
2039 down: [0.0, 1.0, 0.0],
2040 forward: [0.0, 0.0, 1.0],
2041 };
2042 let inv = 1.0f32 / 2.0f32.sqrt();
2044 let base = CpuLights {
2045 enabled: true,
2046 sun: true,
2047 sun_dir: [inv, 0.0, -inv],
2048 sun_color: [1.0; 3],
2049 sun_intensity: 1.0,
2050 ambient: [0.25; 3],
2051 shadow_strength: 0.8,
2052 shadow_bias: 1.5,
2053 shadow_max_dist: 128.0,
2054 ..CpuLights::default()
2055 };
2056 let (w, h) = (96u32, 96u32);
2057 let lit_env = DdaEnv {
2058 lights: CpuLights {
2059 sun_casts_shadow: false,
2060 ..base
2061 },
2062 ..DdaEnv::default()
2063 };
2064 let shadow_env = DdaEnv {
2065 lights: CpuLights {
2066 sun_casts_shadow: true,
2067 ..base
2068 },
2069 ..DdaEnv::default()
2070 };
2071 let (fb_lit, _) = render_brickmap_env(grid, &cam, w, h, &lit_env);
2072 let (fb_sh, _) = render_brickmap_env(grid, &cam, w, h, &shadow_env);
2073 let sum: fn(&[u32]) -> u64 = |fb| fb.iter().map(|&p| u64::from(lum(p))).sum();
2074 let lit_sum = sum(&fb_lit);
2075 let sh_sum = sum(&fb_sh);
2076 assert!(
2077 sh_sum < lit_sum,
2078 "the wall's shadow must darken the floor: shadow_sum={sh_sum} lit_sum={lit_sum}",
2079 );
2080 assert!(
2082 (lit_sum - sh_sum) * 50 > lit_sum,
2083 "shadow should remove >2% of total luminance: lit={lit_sum} shadow={sh_sum}",
2084 );
2085 }
2086
2087 #[derive(Default)]
2089 struct Recorder {
2090 puts: Vec<(usize, u32, f32)>,
2091 }
2092 impl PixelSink for Recorder {
2093 fn put(&mut self, idx: usize, color: u32, dist: f32) {
2094 self.puts.push((idx, color, dist));
2095 }
2096 }
2097
2098 fn oracle_camera() -> Camera {
2099 Camera {
2101 pos: [0.0, 0.0, 0.0],
2102 right: [1.0, 0.0, 0.0],
2103 down: [0.0, 0.0, 1.0],
2104 forward: [0.0, 1.0, 0.0],
2105 }
2106 }
2107
2108 fn render_mask(grid: GridView<'_>, camera: &Camera, w: u32, h: u32) -> Vec<bool> {
2111 let n = (w as usize) * (h as usize);
2112 let mut fb = vec![0u32; n]; let mut zb = vec![f32::INFINITY; n];
2114 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2115 {
2116 let mut sink = RasterSink::new(&mut fb, &mut zb);
2117 render_dda(
2118 camera,
2119 &settings,
2120 grid,
2121 w as usize,
2122 &DdaEnv::default(),
2123 0,
2124 &mut sink,
2125 );
2126 }
2127 fb.iter().map(|&c| c != 0).collect()
2128 }
2129
2130 fn rows_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2135 let w = w as usize;
2136 for y in 0..h as usize {
2137 let row = &mask[y * w..(y + 1) * w];
2138 let first = row.iter().position(|&b| b);
2139 let last = row.iter().rposition(|&b| b);
2140 if let (Some(f), Some(l)) = (first, last) {
2141 if row[f..=l].iter().any(|&b| !b) {
2142 return false;
2143 }
2144 }
2145 }
2146 true
2147 }
2148
2149 fn cols_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2151 let w = w as usize;
2152 let h = h as usize;
2153 for x in 0..w {
2154 let col: Vec<bool> = (0..h).map(|y| mask[y * w + x]).collect();
2155 let first = col.iter().position(|&b| b);
2156 let last = col.iter().rposition(|&b| b);
2157 if let (Some(f), Some(l)) = (first, last) {
2158 if col[f..=l].iter().any(|&b| !b) {
2159 return false;
2160 }
2161 }
2162 }
2163 true
2164 }
2165
2166 #[test]
2169 fn center_pixel_ray_is_forward() {
2170 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2171 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2172 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2174 let (origin, dir) = pixel_ray(&cs, &settings, settings.hx as u32, settings.hy as u32);
2175 assert_eq!(origin, [0.0, 0.0, 0.0]);
2176 assert_eq!(
2178 dir.map(f32::to_bits),
2179 [0.0f32, 320.0, 0.0].map(f32::to_bits)
2180 );
2181 }
2182
2183 #[test]
2187 fn corner_pixel_ray_matches_camera_corn0() {
2188 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2189 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2190 let (_origin, dir) = pixel_ray(&cs, &settings, 0, 0);
2191 assert_eq!(dir.map(f32::to_bits), cs.corn[0].map(f32::to_bits));
2192 }
2193
2194 #[test]
2200 fn gridview_voxel_color_matches_reference() {
2201 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |x, _, z| {
2203 let lo = (10..=12).contains(&z);
2204 let hi = (40..=42).contains(&z);
2205 (lo || hi).then_some(0x80_10_20_30 + x)
2206 });
2207 let grid = GridView::from_single_vxl(&vxl);
2208 for x in 0..8 {
2209 for y in 0..8 {
2210 for z in 0..64 {
2211 assert_eq!(
2212 grid.voxel_color(x, y, z),
2213 vxl.voxel_color(x, y, z),
2214 "mismatch at ({x},{y},{z})"
2215 );
2216 }
2217 }
2218 }
2219 }
2220
2221 #[test]
2223 fn empty_grid_no_hits() {
2224 let vxl = roxlap_formats::vxl::Vxl::empty(64);
2225 let grid = GridView::from_single_vxl(&vxl);
2226 let settings = OpticastSettings::for_oracle_framebuffer(64, 48);
2227 let mut rec = Recorder::default();
2228 render_dda(
2229 &oracle_camera(),
2230 &settings,
2231 grid,
2232 64,
2233 &DdaEnv::default(),
2234 0,
2235 &mut rec,
2236 );
2237 assert!(rec.puts.is_empty(), "all-air grid must produce no hits");
2238 }
2239
2240 #[test]
2244 fn floor_seen_from_above() {
2245 const FLOOR_Z: u32 = 40;
2246 const FLOOR_COL: u32 = 0x80_30_60_90;
2247 let vxl =
2248 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= FLOOR_Z).then_some(FLOOR_COL));
2249 let grid = GridView::from_single_vxl(&vxl);
2250
2251 let cam = Camera {
2253 pos: [16.0, 16.0, 10.0],
2254 right: [1.0, 0.0, 0.0],
2255 down: [0.0, 1.0, 0.0],
2256 forward: [0.0, 0.0, 1.0],
2257 };
2258 let settings = OpticastSettings::for_oracle_framebuffer(48, 48);
2259 let mut rec = Recorder::default();
2260 render_dda(&cam, &settings, grid, 48, &DdaEnv::default(), 0, &mut rec);
2261
2262 assert!(!rec.puts.is_empty(), "floor must be visible");
2263 let centre = 24usize * 48 + 24;
2265 let hit = rec
2266 .puts
2267 .iter()
2268 .find(|(idx, _, _)| *idx == centre)
2269 .expect("centre ray must hit the floor");
2270 assert_eq!(hit.1 & 0x00ff_ffff, FLOOR_COL & 0x00ff_ffff);
2271 let expected = (FLOOR_Z as f32) - 10.0;
2272 assert!(
2273 (hit.2 - expected).abs() < 1.5,
2274 "centre depth {} not ≈ {}",
2275 hit.2,
2276 expected
2277 );
2278 }
2279
2280 #[test]
2285 fn horizon_splits_sky_and_floor() {
2286 const FLOOR_Z: u32 = 40;
2287 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
2288 (z >= FLOOR_Z).then_some(0x80_44_66_88)
2289 });
2290 let grid = GridView::from_single_vxl(&vxl);
2291
2292 let cam = Camera {
2296 pos: [32.0, 4.0, 30.0],
2297 right: [-1.0, 0.0, 0.0],
2298 down: [0.0, 0.0, 1.0],
2299 forward: [0.0, 1.0, 0.0],
2300 };
2301 let (w, h) = (64u32, 64u32);
2302 let mask = render_mask(grid, &cam, w, h);
2303
2304 let count_band = |y0: usize, y1: usize| -> usize {
2305 (y0 * w as usize..y1 * w as usize)
2306 .filter(|&i| mask[i])
2307 .count()
2308 };
2309 let top = count_band(0, h as usize / 4);
2310 let bottom = count_band(3 * h as usize / 4, h as usize);
2311 assert!(mask.iter().any(|&b| b), "floor must be visible");
2312 assert!(mask.iter().any(|&b| !b), "sky must be visible");
2313 assert!(
2314 bottom > top,
2315 "bottom band ({bottom}) should hit more floor than top band ({top})"
2316 );
2317 }
2318
2319 fn render_reference(
2322 grid: GridView<'_>,
2323 camera: &Camera,
2324 w: u32,
2325 h: u32,
2326 ) -> (Vec<u32>, Vec<f32>) {
2327 let n = (w as usize) * (h as usize);
2328 let mut fb = vec![0u32; n];
2329 let mut zb = vec![f32::INFINITY; n];
2330 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2331 let cs = camera_math::derive(camera, w, h, settings.hx, settings.hy, settings.hz);
2332 for py in 0..h {
2333 for px in 0..w {
2334 let (o, d) = pixel_ray(&cs, &settings, px, py);
2335 if let Some(hit) = cast_ray_reference(o, d, cs.forward, &grid, &settings) {
2336 let i = (py * w + px) as usize;
2337 fb[i] = hit.color;
2338 zb[i] = hit.dist;
2339 }
2340 }
2341 }
2342 (fb, zb)
2343 }
2344
2345 fn render_brickmap(
2347 grid: GridView<'_>,
2348 camera: &Camera,
2349 w: u32,
2350 h: u32,
2351 ) -> (Vec<u32>, Vec<f32>) {
2352 render_brickmap_env(grid, camera, w, h, &DdaEnv::default())
2353 }
2354
2355 fn render_brickmap_env(
2358 grid: GridView<'_>,
2359 camera: &Camera,
2360 w: u32,
2361 h: u32,
2362 env: &DdaEnv<'_>,
2363 ) -> (Vec<u32>, Vec<f32>) {
2364 let n = (w as usize) * (h as usize);
2365 let mut fb = vec![0u32; n];
2366 let mut zb = vec![f32::INFINITY; n];
2367 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2368 {
2369 let mut sink = RasterSink::new(&mut fb, &mut zb);
2370 render_dda(camera, &settings, grid, w as usize, env, 0, &mut sink);
2371 }
2372 (fb, zb)
2373 }
2374
2375 #[test]
2382 fn no_sky_leak_through_diagonal_wall() {
2383 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2384 ((x + y == 64) && (2..62).contains(&z)).then_some(0x80_40_80_60)
2385 });
2386 let grid = GridView::from_single_vxl(&vxl);
2387 let (w, h) = (160u32, 160u32);
2388 let c = [10.0, 10.0, 32.0];
2389 let poses = [
2390 Camera::from_yaw_pitch(c, 0.785, 0.0),
2391 Camera::from_yaw_pitch(c, 0.6, 0.1),
2392 Camera::from_yaw_pitch(c, 0.95, -0.1),
2393 Camera::from_yaw_pitch(c, 0.785, 0.3),
2394 Camera::from_yaw_pitch(c, 0.5, 0.0),
2395 ];
2396 for (i, cam) in poses.iter().enumerate() {
2397 let (fb_b, _) = render_brickmap(grid, cam, w, h);
2398 let (fb_r, _) = render_reference(grid, cam, w, h);
2399 let leak = (0..(w * h) as usize)
2400 .filter(|&k| (fb_b[k] != 0) != (fb_r[k] != 0))
2401 .count();
2402 assert_eq!(leak, 0, "pose {i}: {leak} px diverge from dense reference");
2403 }
2404 }
2405
2406 #[test]
2410 fn terrain_glass_tints_floor_behind() {
2411 let glass = 0x80_40_C0_E0; let floor = 0x80_C0_40_40; let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2414 if z == 4 {
2415 Some(glass)
2416 } else if z >= 10 {
2417 Some(floor)
2418 } else {
2419 None
2420 }
2421 });
2422 let grid = GridView::from_single_vxl(&vxl);
2423 let cam = Camera {
2425 pos: [8.0, 8.0, 0.0],
2426 right: [1.0, 0.0, 0.0],
2427 down: [0.0, 1.0, 0.0],
2428 forward: [0.0, 0.0, 1.0],
2429 };
2430 let (w, h) = (32u32, 32u32);
2431 let centre = (h / 2 * w + w / 2) as usize;
2432
2433 let (fb_op, _) = render_brickmap(grid, &cam, w, h);
2435 assert_eq!(
2436 fb_op[centre] & 0x00ff_ffff,
2437 0x0040_C0E0,
2438 "opaque glass first-hit"
2439 );
2440
2441 let mut table = MaterialTable::new();
2443 table.set(1, Material::alpha_blend(128));
2444 let env = DdaEnv {
2445 materials: Some(&table),
2446 terrain_materials: &[(glass & 0x00ff_ffff, 1)],
2447 lights: CpuLights::default(),
2448 ..DdaEnv::default()
2449 };
2450 let (fb_tr, _) = render_brickmap_env(grid, &cam, w, h, &env);
2451 assert_ne!(
2452 fb_tr[centre], fb_op[centre],
2453 "glass should composite over the floor, not stay opaque"
2454 );
2455 let r_op = (fb_op[centre] >> 16) & 0xff; let r_tr = (fb_tr[centre] >> 16) & 0xff; assert!(
2458 r_tr > r_op,
2459 "floor red tints through the glass (op={r_op:02x} tr={r_tr:02x})"
2460 );
2461 }
2462
2463 #[test]
2468 fn terrain_volumetric_thickness_deepens_opacity() {
2469 let smoke = 0x80_90_90_90; let floor = 0x80_C0_20_20; let green_at = |depth: u32| -> u32 {
2474 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2475 if (4..4 + depth).contains(&z) {
2476 Some(smoke)
2477 } else if z >= 12 {
2478 Some(floor)
2479 } else {
2480 None
2481 }
2482 });
2483 let grid = GridView::from_single_vxl(&vxl);
2484 let cam = Camera {
2485 pos: [8.0, 8.0, 0.0],
2486 right: [1.0, 0.0, 0.0],
2487 down: [0.0, 1.0, 0.0],
2488 forward: [0.0, 0.0, 1.0],
2489 };
2490 let (w, h) = (32u32, 32u32);
2491 let mut table = MaterialTable::new();
2492 table.set(1, Material::volumetric(80));
2493 let env = DdaEnv {
2494 materials: Some(&table),
2495 terrain_materials: &[(smoke & 0x00ff_ffff, 1)],
2496 lights: CpuLights::default(),
2497 ..DdaEnv::default()
2498 };
2499 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
2500 (fb[(h / 2 * w + w / 2) as usize] >> 8) & 0xff
2501 };
2502 let shallow = green_at(1);
2503 let deep = green_at(7);
2504 assert!(
2505 deep > shallow,
2506 "deeper Volumetric smoke shows more of its grey (deep g={deep:02x} > shallow g={shallow:02x})"
2507 );
2508 }
2509
2510 #[test]
2513 fn distance_fog_blends_toward_fog_color() {
2514 let vxl =
2515 roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| (z >= 40).then_some(0x80_FF_FF_FF));
2516 let grid = GridView::from_single_vxl(&vxl);
2517 let cam = Camera {
2518 pos: [32.0, 2.0, 38.0],
2519 right: [1.0, 0.0, 0.0],
2520 down: [0.0, 0.0, 1.0],
2521 forward: [0.0, 1.0, 0.0],
2522 };
2523 let env = DdaEnv {
2524 sky: None,
2525 fog_color: 0x00_00_00_00, fog_max_dist: 64.0,
2527 side_shades: [0; 6],
2528 materials: None,
2529 terrain_materials: &[],
2530 lights: CpuLights::default(),
2531 world_shadow: None,
2532 };
2533 let (w, h) = (64u32, 64u32);
2534 let (fog, _) = render_brickmap_env(grid, &cam, w, h, &env);
2535 let (nofog, zb) = render_brickmap(grid, &cam, w, h);
2536 let (idx, depth) = zb.iter().enumerate().filter(|(_, z)| z.is_finite()).fold(
2537 (0usize, 0.0f32),
2538 |acc, (i, &z)| {
2539 if z > acc.1 {
2540 (i, z)
2541 } else {
2542 acc
2543 }
2544 },
2545 );
2546 assert!(depth > 20.0, "need a deep pixel to test fog (got {depth})");
2547 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
2548 assert!(
2549 lum(fog[idx]) < lum(nofog[idx]),
2550 "fogged pixel {:08x} not darker than {:08x}",
2551 fog[idx],
2552 nofog[idx]
2553 );
2554 }
2555
2556 #[test]
2559 fn textured_sky_fills_misses() {
2560 let sky = crate::sky::Sky::blue_gradient();
2561 let vxl = roxlap_formats::vxl::Vxl::empty(32); let grid = GridView::from_single_vxl(&vxl);
2563 let env = DdaEnv {
2564 sky: Some(&sky),
2565 fog_color: 0,
2566 fog_max_dist: 0.0,
2567 side_shades: [0; 6],
2568 materials: None,
2569 terrain_materials: &[],
2570 lights: CpuLights::default(),
2571 world_shadow: None,
2572 };
2573 let cam = Camera::from_yaw_pitch([16.0, 16.0, 128.0], 0.3, -0.4);
2574 let (w, h) = (48u32, 48u32);
2575 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
2576 assert!(fb.iter().all(|&c| c >> 24 == 0x80), "all misses sky-filled");
2577 let top = fb[0];
2578 let bottom = fb[(h - 1) as usize * w as usize];
2579 assert_ne!(top, bottom, "sky gradient should vary with elevation");
2580 }
2581
2582 #[test]
2587 fn sky_elevation_zenith_at_column_zero() {
2588 let mut pixels = vec![0i32; 8];
2589 pixels[0] = 0x0011_1111; pixels[7] = 0x0099_9999; let sky = crate::sky::Sky::from_pixels(pixels, 8, 1);
2592 let up = sample_sky(&sky, [0.0, 0.0, -1.0]); let down = sample_sky(&sky, [0.0, 0.0, 1.0]); assert_eq!(
2595 up & 0x00ff_ffff,
2596 0x0011_1111,
2597 "looking up → column 0 (zenith)"
2598 );
2599 assert_eq!(
2600 down & 0x00ff_ffff,
2601 0x0099_9999,
2602 "looking down → last column (nadir)"
2603 );
2604 }
2605
2606 #[test]
2610 fn sky_fill_paints_panorama_gridless() {
2611 let sky = crate::sky::Sky::blue_gradient();
2612 let cam = Camera::from_yaw_pitch([0.0, 0.0, 0.0], 0.3, -0.4);
2613 let (w, h) = (48u32, 48u32);
2614 let cs = crate::camera_math::derive(&cam, w, h, 24.0, 24.0, 24.0);
2615 let settings = crate::opticast::OpticastSettings::for_oracle_framebuffer(w, h);
2616 let mut fb = vec![0u32; (w * h) as usize];
2617 let zb = vec![f32::INFINITY; (w * h) as usize];
2619 render_sky_fill(&mut fb, &zb, w as usize, w, h, &cs, &settings, &sky);
2620 assert!(
2621 fb.iter().all(|&c| c >> 24 == 0x80),
2622 "every pixel sky-filled with the brightness byte set"
2623 );
2624 let top = fb[0];
2625 let bottom = fb[(h - 1) as usize * w as usize];
2626 assert_ne!(top, bottom, "sky gradient should vary with elevation");
2627 let mut fb2 = vec![0x1234_5678u32; (w * h) as usize];
2629 let mut zb2 = vec![f32::INFINITY; (w * h) as usize];
2630 zb2[0] = 10.0; render_sky_fill(&mut fb2, &zb2, w as usize, w, h, &cs, &settings, &sky);
2632 assert_eq!(fb2[0], 0x1234_5678, "finite-z pixel is not overwritten");
2633 }
2634
2635 #[test]
2639 fn side_shades_darken_hit_face() {
2640 let vxl =
2641 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x80_FF_FF_FF));
2642 let grid = GridView::from_single_vxl(&vxl);
2643 let cam = Camera {
2644 pos: [8.0, 8.0, 2.0],
2645 right: [1.0, 0.0, 0.0],
2646 down: [0.0, 1.0, 0.0],
2647 forward: [0.0, 0.0, 1.0],
2648 };
2649 let centre = 16 * 32 + 16;
2650 let (plain, _) = render_brickmap(grid, &cam, 32, 32);
2651 let env = DdaEnv {
2652 sky: None,
2653 fog_color: 0,
2654 fog_max_dist: 0.0,
2655 side_shades: [0, 0, 0, 0, 0x40, 0],
2656 materials: None,
2657 terrain_materials: &[],
2658 lights: CpuLights::default(),
2659 world_shadow: None,
2660 };
2661 let (shaded, _) = render_brickmap_env(grid, &cam, 32, 32, &env);
2662 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
2663 assert!(
2664 lum(shaded[centre]) < lum(plain[centre]),
2665 "side-shaded face {:08x} not darker than {:08x}",
2666 shaded[centre],
2667 plain[centre]
2668 );
2669 }
2670
2671 #[test]
2681 fn brickmap_approximates_dense_reference() {
2682 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2684 let surf = 30 + ((x / 5 + y / 7) % 11);
2685 let ground = z >= surf;
2686 let block = (20..=24).contains(&z) && (10..20).contains(&x) && (40..50).contains(&y);
2687 (ground || block).then_some(0x80_30_50_70 + (x ^ y) % 0x40)
2688 });
2689 let grid = GridView::from_single_vxl(&vxl);
2690
2691 let (w, h) = (80u32, 80u32);
2692 let poses = [
2693 Camera::orbit(0.6, 0.5, 90.0, [32.0, 32.0, 40.0]),
2694 Camera::orbit(2.1, 0.2, 70.0, [32.0, 32.0, 35.0]),
2695 Camera::orbit(-1.0, 0.9, 120.0, [32.0, 32.0, 45.0]),
2696 ];
2697 let n = (w * h) as usize;
2698 for (i, cam) in poses.iter().enumerate() {
2699 let (fb_b, zb_b) = render_brickmap(grid, cam, w, h);
2700 let (fb_r, _zb_r) = render_reference(grid, cam, w, h);
2701 let cov_b = fb_b.iter().filter(|&&c| c != 0).count();
2703 let cov_r = fb_r.iter().filter(|&&c| c != 0).count();
2704 assert!(cov_b > 200, "pose {i} rendered ~empty (cov {cov_b})");
2705 let cov_diff = cov_b.abs_diff(cov_r);
2706 assert!(
2707 cov_diff * 100 <= n, "pose {i} coverage diverged: brick {cov_b} vs dense {cov_r}"
2709 );
2710 let diffs = fb_b.iter().zip(&fb_r).filter(|(a, b)| a != b).count();
2712 assert!(
2713 diffs * 100 <= n * 3, "pose {i} too many pixel diffs vs dense: {diffs}/{n}"
2715 );
2716 for k in 0..n {
2718 if fb_b[k] != 0 {
2719 assert!(zb_b[k].is_finite(), "pose {i} px {k} non-finite depth");
2720 }
2721 }
2722 }
2723 }
2724
2725 #[test]
2729 fn baked_brightness_darkens_color() {
2730 let dim =
2732 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x40_FF_FF_FF));
2733 let grid = GridView::from_single_vxl(&dim);
2734 let cam = Camera {
2735 pos: [8.0, 8.0, 2.0],
2736 right: [1.0, 0.0, 0.0],
2737 down: [0.0, 1.0, 0.0],
2738 forward: [0.0, 0.0, 1.0],
2739 };
2740 let (fb, _) = render_brickmap(grid, &cam, 32, 32);
2741 let centre = 16 * 32 + 16;
2742 assert_eq!(fb[centre], 0x80_7F_7F_7F, "got {:08x}", fb[centre]);
2744
2745 let full =
2747 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x80_FF_FF_FF));
2748 let gridf = GridView::from_single_vxl(&full);
2749 let (fbf, _) = render_brickmap(gridf, &cam, 32, 32);
2750 assert_eq!(fbf[centre], 0x80_FF_FF_FF, "got {:08x}", fbf[centre]);
2751 }
2752
2753 #[test]
2760 fn cross_chunk_lookdown_sees_lower_stacked_floor() {
2761 const FLOOR_LOCAL_Z: u32 = 40;
2762 const FLOOR_COL: u32 = 0x80_22_88_44;
2763 let upper = roxlap_formats::vxl::Vxl::empty(32); let lower = roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
2765 (z >= FLOOR_LOCAL_Z).then_some(FLOOR_COL)
2766 });
2767 let v_up = GridView::from_single_vxl(&upper);
2768 let v_lo = GridView::from_single_vxl(&lower);
2769 let chunks = [Some(v_up), Some(v_lo)];
2771 let cg = crate::ChunkGrid {
2772 chunks: &chunks,
2773 origin_chunk_xy: [0, 0],
2774 origin_chunk_z: 0,
2775 chunks_x: 1,
2776 chunks_y: 1,
2777 chunks_z: 2,
2778 };
2779 let grid = GridView::from_chunk_grid(&cg, 32);
2780
2781 let cam = Camera {
2783 pos: [16.0, 16.0, 100.0],
2784 right: [1.0, 0.0, 0.0],
2785 down: [0.0, 1.0, 0.0],
2786 forward: [0.0, 0.0, 1.0],
2787 };
2788 let (w, h) = (48u32, 48u32);
2789 let (fb, zb) = render_brickmap(grid, &cam, w, h);
2790 let centre = 24 * 48 + 24;
2791 assert!(
2792 fb[centre] & 0x00ff_ffff == FLOOR_COL & 0x00ff_ffff,
2793 "centre ray must reach the lower-chunk floor (got {:08x})",
2794 fb[centre]
2795 );
2796 let expected = 296.0 - 100.0;
2798 assert!(
2799 (zb[centre] - expected).abs() < 2.0,
2800 "look-down depth {} not ≈ {expected}",
2801 zb[centre]
2802 );
2803 }
2804
2805 #[test]
2809 fn cross_chunk_xy_floor_is_seamless() {
2810 let mk = || {
2811 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= 20).then_some(0x80_50_50_50))
2812 };
2813 let (c0, c1) = (mk(), mk());
2814 let v0 = GridView::from_single_vxl(&c0);
2815 let v1 = GridView::from_single_vxl(&c1);
2816 let chunks = [Some(v0), Some(v1)];
2817 let cg = crate::ChunkGrid {
2818 chunks: &chunks,
2819 origin_chunk_xy: [0, 0],
2820 origin_chunk_z: 0,
2821 chunks_x: 2,
2822 chunks_y: 1,
2823 chunks_z: 1,
2824 };
2825 let grid = GridView::from_chunk_grid(&cg, 32);
2826
2827 let cam = Camera {
2829 pos: [32.0, 16.0, 4.0],
2830 right: [1.0, 0.0, 0.0],
2831 down: [0.0, 1.0, 0.0],
2832 forward: [0.0, 0.0, 1.0],
2833 };
2834 let (w, h) = (64u32, 64u32);
2835 let mask = render_mask(grid, &cam, w, h);
2836 let row = (h / 2) as usize * w as usize;
2839 let left = (0..w as usize / 2).filter(|&x| mask[row + x]).count();
2840 let right = (w as usize / 2..w as usize)
2841 .filter(|&x| mask[row + x])
2842 .count();
2843 assert!(
2844 left > 5 && right > 5,
2845 "seam not continuous: left={left} right={right}"
2846 );
2847 }
2848
2849 fn render_mask_mip(grid: GridView<'_>, camera: &Camera, w: u32, h: u32, mip: u32) -> Vec<bool> {
2852 let n = (w as usize) * (h as usize);
2853 let mut fb = vec![0u32; n];
2854 let mut zb = vec![f32::INFINITY; n];
2855 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2856 {
2857 let mut sink = RasterSink::new(&mut fb, &mut zb);
2858 render_dda(
2859 camera,
2860 &settings,
2861 grid,
2862 w as usize,
2863 &DdaEnv::default(),
2864 mip,
2865 &mut sink,
2866 );
2867 }
2868 fb.iter().map(|&c| c != 0).collect()
2869 }
2870
2871 #[test]
2877 fn mip_render_is_coarse_but_complete() {
2878 let mut vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2879 let surf = 24 + ((x / 3 + y / 5) % 17);
2880 (z >= surf).then_some(0x80_50_70_90)
2881 });
2882 vxl.generate_mips(4);
2883 assert!(vxl.mip_count() >= 3, "need mips built for this test");
2884 let grid = GridView::from_single_vxl(&vxl);
2885 let (w, h) = (96u32, 96u32);
2886 let cam = Camera::orbit(0.7, 0.6, 110.0, [32.0, 32.0, 36.0]);
2887
2888 let m0 = render_mask_mip(grid, &cam, w, h, 0);
2889 let m2 = render_mask_mip(grid, &cam, w, h, 2);
2890
2891 let c0 = m0.iter().filter(|&&b| b).count();
2892 let c2 = m2.iter().filter(|&&b| b).count();
2893 assert!(c0 > 200 && c2 > 200, "both mips visible (c0={c0} c2={c2})");
2894 let ratio = c2 as f32 / c0 as f32;
2900 assert!(
2901 (0.7..1.4).contains(&ratio),
2902 "mip-2 coverage {c2} vs mip-0 {c0} (ratio {ratio:.2}) diverged"
2903 );
2904 }
2905
2906 #[test]
2912 #[ignore = "perf benchmark — run explicitly with --ignored"]
2913 fn bench_terrain() {
2914 use std::time::Instant;
2915 const NC: i32 = 6;
2917 let cs = crate::grid_view::CHUNK_SIZE_Z; let _ = cs;
2919 let mut vxls: Vec<roxlap_formats::vxl::Vxl> = Vec::new();
2920 for cy in 0..NC {
2921 for cx in 0..NC {
2922 let (ox, oy) = (cx * 128, cy * 128);
2923 let mut v = roxlap_formats::vxl::Vxl::from_dense(128, |x, y, z| {
2924 let (gx, gy) = (ox + x as i32, oy + y as i32);
2925 let surf = 90 + ((gx / 7 + gy / 9).rem_euclid(40)) + ((gx / 23).rem_euclid(20));
2926 (z as i32 >= surf).then_some(0x80_50_70_90 + (x ^ y) % 0x30)
2927 });
2928 v.generate_mips(4);
2929 vxls.push(v);
2930 }
2931 }
2932 let views: Vec<Option<GridView>> = vxls
2933 .iter()
2934 .map(|v| Some(GridView::from_single_vxl(v)))
2935 .collect();
2936 let cg = crate::ChunkGrid {
2937 chunks: &views,
2938 origin_chunk_xy: [0, 0],
2939 origin_chunk_z: 0,
2940 chunks_x: NC as u32,
2941 chunks_y: NC as u32,
2942 chunks_z: 1,
2943 };
2944 let grid = GridView::from_chunk_grid(&cg, 128);
2945
2946 let (w, h) = (960u32, 600u32);
2947 let mut settings = OpticastSettings::for_oracle_framebuffer(w, h);
2948 settings.max_scan_dist = 512;
2949 let n = (w * h) as usize;
2950 let mut fb = vec![0u32; n];
2951 let mut zb = vec![f32::INFINITY; n];
2952 let centre = [f64::from(NC * 128) / 2.0, f64::from(NC * 128) / 2.0, 60.0];
2953
2954 let poses = [
2957 (
2958 "horizon",
2959 Camera::from_yaw_pitch([20.0, 20.0, 40.0], 0.6, 0.15),
2960 ),
2961 ("down", Camera::orbit(0.7, 1.0, 130.0, centre)),
2962 ];
2963 for (name, cam) in poses {
2964 {
2965 let mut sink = RasterSink::new(&mut fb, &mut zb);
2966 prof::reset();
2967 render_dda(
2968 &cam,
2969 &settings,
2970 grid,
2971 w as usize,
2972 &DdaEnv::default(),
2973 0,
2974 &mut sink,
2975 );
2976 }
2977 let (cells, bricks, surf) = prof::read();
2978 let iters = 6;
2979 let t0 = Instant::now();
2980 for _ in 0..iters {
2981 let mut sink = RasterSink::new(&mut fb, &mut zb);
2982 render_dda(
2983 &cam,
2984 &settings,
2985 grid,
2986 w as usize,
2987 &DdaEnv::default(),
2988 0,
2989 &mut sink,
2990 );
2991 }
2992 let ms = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(iters);
2993 let hits = fb.iter().filter(|&&c| c != 0).count();
2994 eprintln!(
2995 "[{name}] {w}x{h} 1-thread: {ms:.1} ms | hits={hits}/{n} | per-px: cells={:.1} bricks={:.1} surf={:.1}",
2996 cells as f64 / n as f64,
2997 bricks as f64 / n as f64,
2998 surf as f64 / n as f64,
2999 );
3000 }
3001 }
3002
3003 #[test]
3007 fn parallel_matches_sequential() {
3008 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
3009 let surf = 28 + ((x / 4 + y / 6) % 13);
3010 (z >= surf).then_some(0x80_40_60_80 + (x ^ y) % 0x30)
3011 });
3012 let grid = GridView::from_single_vxl(&vxl);
3013 let (w, h) = (96u32, 96u32);
3014 let cam = Camera::orbit(0.8, 0.55, 100.0, [32.0, 32.0, 40.0]);
3015 let env = DdaEnv {
3016 sky: None,
3017 fog_color: 0x00_20_30_40,
3018 fog_max_dist: 120.0,
3019 side_shades: [0, 0, 0, 0, 0x30, 0x10],
3020 materials: None,
3021 terrain_materials: &[],
3022 lights: CpuLights::default(),
3023 world_shadow: None,
3024 };
3025
3026 let (seq_fb, seq_zb) = render_brickmap_env(grid, &cam, w, h, &env);
3027
3028 let n = (w * h) as usize;
3029 let mut par_fb = vec![0u32; n];
3030 let mut par_zb = vec![f32::INFINITY; n];
3031 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
3032 let (cache, mip) = local_cache(&grid, 0);
3033 render_dda_parallel(
3034 &cam,
3035 &settings,
3036 grid,
3037 &mut par_fb,
3038 &mut par_zb,
3039 w as usize,
3040 &env,
3041 &cache,
3042 mip,
3043 );
3044 assert!(par_fb == seq_fb, "parallel colour differs from sequential");
3045 assert!(
3046 par_zb
3047 .iter()
3048 .zip(&seq_zb)
3049 .all(|(a, b)| a.to_bits() == b.to_bits()),
3050 "parallel depth differs from sequential"
3051 );
3052 }
3053
3054 #[test]
3060 fn cliff_side_is_solid_not_see_through() {
3061 const TOP_Z: u32 = 50;
3062 const COL: u32 = 0x80_77_88_99;
3063 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |_, _, z| (z >= TOP_Z).then_some(COL));
3064 let grid = GridView::from_single_vxl(&vxl);
3065
3066 assert_eq!(grid.voxel_color(4, 4, TOP_Z), Some(COL));
3068 assert_eq!(grid.voxel_color(4, 4, 150), None);
3070 assert_eq!(grid.surface_color(4, 4, 150), Some(COL));
3073 assert_eq!(grid.surface_color(4, 4, 10), None);
3075 }
3076
3077 #[test]
3080 fn camera_inside_solid_hits_everywhere() {
3081 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, _| Some(0x80_55_55_55));
3082 let grid = GridView::from_single_vxl(&vxl);
3083 let cam = Camera {
3084 pos: [8.0, 8.0, 128.0],
3085 right: [1.0, 0.0, 0.0],
3086 down: [0.0, 1.0, 0.0],
3087 forward: [0.0, 0.0, 1.0],
3088 };
3089 let (w, h) = (32u32, 32u32);
3090 let mask = render_mask(grid, &cam, w, h);
3091 assert!(
3092 mask.iter().all(|&b| b),
3093 "every ray must hit when the camera is inside solid"
3094 );
3095 }
3096
3097 #[test]
3103 fn single_voxel_silhouette_has_no_notch() {
3104 const C: u32 = 0x80_FF_80_40;
3105 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |x, y, z| {
3106 (x == 8 && y == 8 && z == 8).then_some(C)
3107 });
3108 let grid = GridView::from_single_vxl(&vxl);
3109
3110 let cam = Camera::orbit(0.7, 0.6, 4.0, [8.5, 8.5, 8.5]);
3113 let (w, h) = (96u32, 96u32);
3114 let mask = render_mask(grid, &cam, w, h);
3115
3116 let hits = mask.iter().filter(|&&b| b).count();
3117 assert!(
3118 hits > 30,
3119 "silhouette too small to be meaningful: {hits} px"
3120 );
3121 assert!(
3122 rows_have_no_holes(&mask, w, h),
3123 "row-interior gap in single-voxel silhouette (notch)"
3124 );
3125 assert!(
3126 cols_have_no_holes(&mask, w, h),
3127 "column-interior gap in single-voxel silhouette (notch)"
3128 );
3129 }
3130}