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 {
373 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool;
377}
378
379#[derive(Clone, Copy)]
386pub struct WorldShadowCtx<'a> {
387 pub occluder: &'a dyn WorldOccluder,
390 pub origin: [f32; 3],
394 pub cols: [[f32; 3]; 3],
398}
399
400impl<'a> WorldShadowCtx<'a> {
401 #[must_use]
404 pub fn identity(occluder: &'a dyn WorldOccluder) -> Self {
405 Self {
406 occluder,
407 origin: [0.0; 3],
408 cols: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
409 }
410 }
411}
412
413pub struct CompositeOccluder<'a> {
417 pub a: &'a dyn WorldOccluder,
419 pub b: &'a dyn WorldOccluder,
421}
422
423impl WorldOccluder for CompositeOccluder<'_> {
424 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
425 self.a.occluded_world(origin, dir, max_t) || self.b.occluded_world(origin, dir, max_t)
426 }
427}
428
429pub(crate) struct WorldShadow<'a> {
434 pub ctx: WorldShadowCtx<'a>,
435}
436
437impl ShadowTester for WorldShadow<'_> {
438 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
439 let c = &self.ctx.cols;
440 let wo = [
442 self.ctx.origin[0] + c[0][0] * origin[0] + c[1][0] * origin[1] + c[2][0] * origin[2],
443 self.ctx.origin[1] + c[0][1] * origin[0] + c[1][1] * origin[1] + c[2][1] * origin[2],
444 self.ctx.origin[2] + c[0][2] * origin[0] + c[1][2] * origin[1] + c[2][2] * origin[2],
445 ];
446 let wd = [
447 c[0][0] * dir[0] + c[1][0] * dir[1] + c[2][0] * dir[2],
448 c[0][1] * dir[0] + c[1][1] * dir[1] + c[2][1] * dir[2],
449 c[0][2] * dir[0] + c[1][2] * dir[1] + c[2][2] * dir[2],
450 ];
451 self.ctx.occluder.occluded_world(wo, wd, max_t)
452 }
453}
454
455fn shade_lit_cpu(
462 color: u32,
463 bright_sub: u32,
464 axis: usize,
465 step: [i32; 3],
466 cellc: [i32; 3],
467 cell_size: f32,
468 l: &CpuLights<'_>,
469 shadow: Option<&mut dyn ShadowTester>,
470) -> u32 {
471 let a_b = ((color >> 24) & 0xff).saturating_sub(bright_sub);
472 let ao = a_b as f32 / 128.0;
473 let albedo = [
474 ((color >> 16) & 0xff) as f32 / 255.0,
475 ((color >> 8) & 0xff) as f32 / 255.0,
476 (color & 0xff) as f32 / 255.0,
477 ];
478 let n = face_normal_cpu(axis, step);
479 let center = [
481 (cellc[0] as f32 + 0.5) * cell_size,
482 (cellc[1] as f32 + 0.5) * cell_size,
483 (cellc[2] as f32 + 0.5) * cell_size,
484 ];
485 shade_dynamic(albedo, ao, n, center, l, shadow)
486}
487
488pub(crate) fn shade_dynamic(
494 albedo: [f32; 3],
495 ao: f32,
496 n: [f32; 3],
497 sample: [f32; 3],
498 l: &CpuLights<'_>,
499 shadow: Option<&mut dyn ShadowTester>,
500) -> u32 {
501 let styled = l.bands > 0;
502 let mut shadow = shadow;
506 let shadow_origin = [
507 sample[0] + n[0] * l.shadow_bias,
508 sample[1] + n[1] * l.shadow_bias,
509 sample[2] + n[2] * l.shadow_bias,
510 ];
511 let in_shadow = 1.0 - l.shadow_strength;
512
513 let sun_key = if l.sun {
515 let ndl = dot3(n, l.sun_dir).max(0.0);
516 if ndl > 0.0 && l.sun_casts_shadow {
517 let occ = shadow
518 .as_deref_mut()
519 .is_some_and(|s| s.occluded(shadow_origin, l.sun_dir, l.shadow_max_dist));
520 if occ {
521 ndl * in_shadow
522 } else {
523 ndl
524 }
525 } else {
526 ndl
527 }
528 } else {
529 0.0
530 };
531
532 let mut lit = if styled {
534 let key = cel_band(sun_key, l.bands);
535 let m = |i: usize| {
536 let warm = l.sun_color[i] * l.sun_intensity;
537 (l.shadow_tint[i] + (warm - l.shadow_tint[i]) * key) * ao
538 };
539 [albedo[0] * m(0), albedo[1] * m(1), albedo[2] * m(2)]
540 } else {
541 let base = |i: usize| {
542 albedo[i] * l.ambient[i] * ao + albedo[i] * l.sun_color[i] * l.sun_intensity * sun_key
543 };
544 [base(0), base(1), base(2)]
545 };
546
547 for p in l.points {
550 let d3 = [
551 p.pos[0] - sample[0],
552 p.pos[1] - sample[1],
553 p.pos[2] - sample[2],
554 ];
555 let d2 = d3[0] * d3[0] + d3[1] * d3[1] + d3[2] * d3[2];
558 if d2 < p.radius * p.radius && d2 > 1e-8 {
559 let dist = d2.sqrt();
560 let inv = 1.0 / dist;
561 let ldir = [d3[0] * inv, d3[1] * inv, d3[2] * inv];
562 let ndl = dot3(n, ldir).max(0.0);
563 let cone = spot_cone(ldir, p.spot_dir, p.cos_inner, p.cos_outer);
566 if ndl > 0.0 && cone > 0.0 {
567 let sh = if p.casts_shadow
569 && shadow
570 .as_deref_mut()
571 .is_some_and(|s| s.occluded(shadow_origin, ldir, dist))
572 {
573 in_shadow
574 } else {
575 1.0
576 };
577 let mut f = ndl * point_falloff(dist, p.radius) * cone * sh;
578 if styled {
579 f = cel_band(f, l.bands);
580 }
581 for i in 0..3 {
582 lit[i] += albedo[i] * p.color[i] * p.intensity * f;
583 }
584 }
585 }
586 }
587
588 let pack = |v: f32| -> u32 { (v.clamp(0.0, 1.0) * 255.0) as u32 };
589 0x8000_0000 | (pack(lit[0]) << 16) | (pack(lit[1]) << 8) | pack(lit[2])
590}
591
592#[inline]
596fn apply_fog(color: u32, depth: f32, env: &DdaEnv<'_>) -> u32 {
597 if env.fog_max_dist <= 0.0 {
598 return color;
599 }
600 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
601 let f = ((depth / env.fog_max_dist).clamp(0.0, 1.0) * 256.0) as u32; let g = 256 - f;
603 let fog = env.fog_color;
604 let mix = |shift: u32| -> u32 {
605 let src = (color >> shift) & 0xff;
606 let dst = (fog >> shift) & 0xff;
607 ((src * g + dst * f) >> 8).min(255)
608 };
609 0x8000_0000 | (mix(16) << 16) | (mix(8) << 8) | mix(0)
610}
611
612#[inline]
615fn composite_over(accum: [f32; 3], trans: f32, bg: u32) -> u32 {
616 let b = rgb_to_f32(bg);
617 f32_to_rgb([
618 accum[0] + trans * b[0],
619 accum[1] + trans * b[1],
620 accum[2] + trans * b[2],
621 ])
622}
623
624#[inline]
629fn finalize_exit(
630 touched: bool,
631 accum: [f32; 3],
632 trans: f32,
633 env: &DdaEnv<'_>,
634 dir: [f32; 3],
635 dist: f32,
636) -> Option<Hit> {
637 if !touched {
638 return None;
639 }
640 let bg = match env.sky {
641 Some(s) => sample_sky(s, dir),
642 None => 0x8000_0000 | (env.fog_color & 0x00ff_ffff),
643 };
644 Some(Hit {
645 color: composite_over(accum, trans, bg),
646 dist,
647 })
648}
649
650#[inline]
653#[allow(clippy::cast_precision_loss)]
654fn rgb_to_f32(c: u32) -> [f32; 3] {
655 [
656 ((c >> 16) & 0xff) as f32 / 255.0,
657 ((c >> 8) & 0xff) as f32 / 255.0,
658 (c & 0xff) as f32 / 255.0,
659 ]
660}
661
662#[inline]
664#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
665fn f32_to_rgb(c: [f32; 3]) -> u32 {
666 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
667 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
668}
669
670#[allow(
679 clippy::cast_possible_truncation,
680 clippy::cast_sign_loss,
681 clippy::cast_precision_loss
682)]
683fn sample_sky(sky: &Sky, dir: [f32; 3]) -> u32 {
684 let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
685 if len < 1e-9 {
686 return 0x8000_0000;
687 }
688 let d = [dir[0] / len, dir[1] / len, dir[2] / len];
689 let xsiz_full = sky.lat.len().max(1) as i32; let pi = std::f32::consts::PI;
691 let elev01 = (-d[2]).clamp(-1.0, 1.0).acos() / pi; let x = (elev01 * xsiz_full as f32) as i32;
696 let x = x.clamp(0, xsiz_full - 1);
697 let y = if sky.ysiz <= 1 {
699 0
700 } else {
701 let az = d[1].atan2(d[0]); let yf = ((az / (pi * 2.0)) + 0.5) * sky.ysiz as f32;
703 (yf as i32).rem_euclid(sky.ysiz)
704 };
705 let idx = (y * xsiz_full + x) as usize;
706 let px = sky.pixels.get(idx).copied().unwrap_or(0) as u32;
707 0x8000_0000 | (px & 0x00ff_ffff)
708}
709
710#[allow(clippy::cast_possible_truncation)]
720pub fn render_sky_fill(
721 fb: &mut [u32],
722 zb: &[f32],
723 pitch_pixels: usize,
724 width: u32,
725 height: u32,
726 cam: &CameraState,
727 settings: &OpticastSettings,
728 sky: &Sky,
729) {
730 fb.par_chunks_mut(pitch_pixels)
735 .take(height as usize)
736 .enumerate()
737 .for_each(|(py, frow)| {
738 let row = py * pitch_pixels;
739 #[allow(clippy::cast_possible_truncation)]
740 let py = py as u32;
741 for px in 0..width {
742 let idx = row + px as usize;
743 if zb[idx].is_finite() {
744 continue; }
746 let (_origin, dir) = pixel_ray(cam, settings, px, py);
747 frow[px as usize] = sample_sky(sky, dir);
748 }
749 });
750}
751
752#[must_use]
764pub fn pixel_ray(
765 cs: &CameraState,
766 settings: &OpticastSettings,
767 px: u32,
768 py: u32,
769) -> ([f32; 3], [f32; 3]) {
770 #[allow(clippy::cast_precision_loss)]
772 let sx = px as f32 - settings.hx;
773 #[allow(clippy::cast_precision_loss)]
774 let sy = py as f32 - settings.hy;
775 let dir = [
776 sx * cs.right[0] + sy * cs.down[0] + settings.hz * cs.forward[0],
777 sx * cs.right[1] + sy * cs.down[1] + settings.hz * cs.forward[1],
778 sx * cs.right[2] + sy * cs.down[2] + settings.hz * cs.forward[2],
779 ];
780 (cs.pos, dir)
781}
782
783pub(crate) fn intersect_aabb(
789 o: [f32; 3],
790 dir: [f32; 3],
791 lo: [f32; 3],
792 hi: [f32; 3],
793) -> Option<(f32, f32)> {
794 let mut t0 = 0.0f32;
795 let mut t1 = f32::INFINITY;
796 for a in 0..3 {
797 if dir[a].abs() < 1e-9 {
798 if o[a] < lo[a] || o[a] > hi[a] {
800 return None;
801 }
802 } else {
803 let inv = 1.0 / dir[a];
804 let mut ta = (lo[a] - o[a]) * inv;
805 let mut tb = (hi[a] - o[a]) * inv;
806 if ta > tb {
807 core::mem::swap(&mut ta, &mut tb);
808 }
809 t0 = t0.max(ta);
810 t1 = t1.min(tb);
811 if t0 > t1 {
812 return None;
813 }
814 }
815 }
816 Some((t0, t1))
817}
818
819const BRICK: i32 = 8;
821
822#[derive(Debug)]
836pub(crate) struct BrickMap {
837 nb: [i32; 3],
839 bits: Vec<u64>,
842 ns: [i32; 3],
845 super_bits: Vec<u64>,
850}
851
852const SUPER: i32 = BRICK * BRICK;
854
855impl BrickMap {
856 #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
859 fn build(grid: &GridView<'_>, mip: u32) -> Self {
860 let vsid_m = (grid.vsid >> mip).max(1) as i32;
861 let z_m = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1) as i32;
862 let nb = [
863 (vsid_m + BRICK - 1) / BRICK,
864 (vsid_m + BRICK - 1) / BRICK,
865 (z_m + BRICK - 1) / BRICK,
866 ];
867 let ns = [
868 (nb[0] + BRICK - 1) / BRICK,
869 (nb[1] + BRICK - 1) / BRICK,
870 (nb[2] + BRICK - 1) / BRICK,
871 ];
872 let count = (nb[0] * nb[1] * nb[2]) as usize;
873 let scount = (ns[0] * ns[1] * ns[2]) as usize;
874 let mut bits = vec![0u64; count.div_ceil(64)];
875 let mut super_bits = vec![0u64; scount.div_ceil(64)];
876 for y in 0..vsid_m {
877 for x in 0..vsid_m {
878 let (bx, by) = (x / BRICK, y / BRICK);
879 grid.for_each_run_mip(x as u32, y as u32, mip, |top, bot| {
880 for bz in (top / BRICK)..=((bot - 1) / BRICK) {
881 let idx = ((bz * nb[1] + by) * nb[0] + bx) as usize;
882 bits[idx / 64] |= 1u64 << (idx % 64);
883 let sidx =
884 (((bz / BRICK) * ns[1] + by / BRICK) * ns[0] + bx / BRICK) as usize;
885 super_bits[sidx / 64] |= 1u64 << (sidx % 64);
886 }
887 });
888 }
889 }
890 Self {
891 nb,
892 bits,
893 ns,
894 super_bits,
895 }
896 }
897
898 #[inline]
900 #[allow(clippy::cast_sign_loss)]
901 fn occupied(&self, b: [i32; 3]) -> bool {
902 if b[0] < 0
903 || b[0] >= self.nb[0]
904 || b[1] < 0
905 || b[1] >= self.nb[1]
906 || b[2] < 0
907 || b[2] >= self.nb[2]
908 {
909 return false;
910 }
911 let idx = ((b[2] * self.nb[1] + b[1]) * self.nb[0] + b[0]) as usize;
912 (self.bits[idx / 64] >> (idx % 64)) & 1 != 0
913 }
914
915 #[inline]
917 #[allow(clippy::cast_sign_loss)]
918 fn occupied_super(&self, s: [i32; 3]) -> bool {
919 if s[0] < 0
920 || s[0] >= self.ns[0]
921 || s[1] < 0
922 || s[1] >= self.ns[1]
923 || s[2] < 0
924 || s[2] >= self.ns[2]
925 {
926 return false;
927 }
928 let idx = ((s[2] * self.ns[1] + s[1]) * self.ns[0] + s[0]) as usize;
929 (self.super_bits[idx / 64] >> (idx % 64)) & 1 != 0
930 }
931}
932
933pub(crate) fn dda_setup(
939 origin: [f32; 3],
940 dir: [f32; 3],
941 cell: [i32; 3],
942 cell_size: f32,
943) -> ([i32; 3], [f32; 3], [f32; 3]) {
944 let mut step = [0i32; 3];
945 let mut t_max = [f32::INFINITY; 3];
946 let mut t_delta = [f32::INFINITY; 3];
947 for a in 0..3 {
948 if dir[a] > 1e-9 {
949 step[a] = 1;
950 #[allow(clippy::cast_precision_loss)]
951 let boundary = (cell[a] + 1) as f32 * cell_size;
952 t_max[a] = (boundary - origin[a]) / dir[a];
953 t_delta[a] = cell_size / dir[a];
954 } else if dir[a] < -1e-9 {
955 step[a] = -1;
956 #[allow(clippy::cast_precision_loss)]
957 let boundary = cell[a] as f32 * cell_size;
958 t_max[a] = (boundary - origin[a]) / dir[a];
959 t_delta[a] = -cell_size / dir[a];
960 }
961 }
962 (step, t_max, t_delta)
963}
964
965#[inline]
968pub(crate) fn min_axis(t_max: [f32; 3]) -> usize {
969 if t_max[0] <= t_max[1] && t_max[0] <= t_max[2] {
970 0
971 } else if t_max[1] <= t_max[2] {
972 1
973 } else {
974 2
975 }
976}
977
978#[derive(Debug, Default)]
988pub struct BrickCache {
989 maps: HashMap<(i32, i32, i32, u32), (u64, BrickMap)>,
990}
991
992impl BrickCache {
993 #[must_use]
996 pub fn new() -> Self {
997 Self::default()
998 }
999
1000 pub fn ensure(&mut self, chunk: [i32; 3], mip: u32, version: u64, view: &GridView<'_>) {
1003 let key = (chunk[0], chunk[1], chunk[2], mip);
1004 let stale = self.maps.get(&key).is_none_or(|(v, _)| *v != version);
1005 if stale {
1006 self.maps.insert(key, (version, BrickMap::build(view, mip)));
1007 }
1008 }
1009
1010 #[inline]
1011 fn get(&self, chunk: [i32; 3], mip: u32) -> Option<&BrickMap> {
1012 self.maps
1013 .get(&(chunk[0], chunk[1], chunk[2], mip))
1014 .map(|(_, m)| m)
1015 }
1016
1017 pub fn retain_chunks(&mut self, keep: impl Fn([i32; 3]) -> bool) {
1020 self.maps.retain(|k, _| keep([k.0, k.1, k.2]));
1021 }
1022
1023 #[must_use]
1029 pub fn brick_occupied_at(&self, chunk: [i32; 3], mip: u32, cell: [i32; 3]) -> Option<bool> {
1030 self.get(chunk, mip)
1031 .map(|m| m.occupied([cell[0] >> 3, cell[1] >> 3, cell[2] >> 3]))
1032 }
1033
1034 #[must_use]
1037 pub fn super_occupied_at(&self, chunk: [i32; 3], mip: u32, cell: [i32; 3]) -> Option<bool> {
1038 self.get(chunk, mip)
1039 .map(|m| m.occupied_super([cell[0] >> 6, cell[1] >> 6, cell[2] >> 6]))
1040 }
1041}
1042
1043#[allow(clippy::cast_possible_wrap)]
1048fn local_cache(grid: &GridView<'_>, requested_mip: u32) -> (BrickCache, u32) {
1049 let mip = effective_mip(grid, requested_mip);
1050 let mut cache = BrickCache::new();
1051 if let Some(cg) = grid.chunk_grid {
1052 for dz in 0..cg.chunks_z as i32 {
1053 for dy in 0..cg.chunks_y as i32 {
1054 for dx in 0..cg.chunks_x as i32 {
1055 let slot = ((dz * cg.chunks_y as i32 + dy) * cg.chunks_x as i32 + dx) as usize;
1056 if let Some(Some(view)) = cg.chunks.get(slot) {
1057 let ch = [
1058 cg.origin_chunk_xy[0] + dx,
1059 cg.origin_chunk_xy[1] + dy,
1060 cg.origin_chunk_z + dz,
1061 ];
1062 cache.ensure(ch, mip, 0, view);
1063 }
1064 }
1065 }
1066 }
1067 } else {
1068 cache.ensure([0, 0, 0], mip, 0, grid);
1069 }
1070 (cache, mip)
1071}
1072
1073#[must_use]
1078pub fn effective_mip(grid: &GridView<'_>, requested: u32) -> u32 {
1079 if requested == 0 {
1080 return 0;
1081 }
1082 let mut m = requested;
1083 if let Some(cg) = grid.chunk_grid {
1084 for c in cg.chunks.iter().flatten() {
1085 m = m.min(c.mip_count().saturating_sub(1));
1086 }
1087 } else {
1088 m = m.min(grid.mip_count().saturating_sub(1));
1089 }
1090 m
1091}
1092
1093struct Sampler<'a> {
1107 grid: GridView<'a>,
1108 bricks: &'a BrickCache,
1109 mip: u32,
1112 xy_shift: u32,
1121 xy_mask: i32,
1122 z_shift: u32,
1123 z_mask: i32,
1124 cur_ch: [i32; 3],
1125 cur_view: Option<GridView<'a>>,
1126 cur_brick: Option<&'a BrickMap>,
1127 has_cur: bool,
1128}
1129
1130impl<'a> Sampler<'a> {
1131 fn new(grid: GridView<'a>, bricks: &'a BrickCache, mip: u32) -> Self {
1132 let cs_xy = (grid.chunk_size_xy >> mip).max(1);
1133 let cs_z = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1);
1134 debug_assert!(
1135 cs_xy.is_power_of_two() && cs_z.is_power_of_two(),
1136 "chunk dims must be powers of two for the shift/mask split"
1137 );
1138 #[allow(clippy::cast_possible_wrap)]
1139 Self {
1140 grid,
1141 bricks,
1142 mip,
1143 xy_shift: cs_xy.trailing_zeros(),
1144 xy_mask: cs_xy as i32 - 1,
1145 z_shift: cs_z.trailing_zeros(),
1146 z_mask: cs_z as i32 - 1,
1147 cur_ch: [0; 3],
1148 cur_view: None,
1149 cur_brick: None,
1150 has_cur: false,
1151 }
1152 }
1153
1154 fn select_chunk(&mut self, ch: [i32; 3]) {
1156 if self.has_cur && self.cur_ch == ch {
1157 return;
1158 }
1159 self.cur_view = self.grid.chunk_at_xyz(ch);
1160 self.cur_brick = self.bricks.get(ch, self.mip);
1161 self.cur_ch = ch;
1162 self.has_cur = true;
1163 }
1164
1165 #[allow(clippy::cast_sign_loss)]
1170 fn locate(&self, c: [i32; 3]) -> ([i32; 3], [u32; 3]) {
1171 let ch = [
1172 c[0] >> self.xy_shift,
1173 c[1] >> self.xy_shift,
1174 c[2] >> self.z_shift,
1175 ];
1176 let loc = [
1177 (c[0] & self.xy_mask) as u32,
1178 (c[1] & self.xy_mask) as u32,
1179 (c[2] & self.z_mask) as u32,
1180 ];
1181 (ch, loc)
1182 }
1183
1184 #[allow(clippy::cast_possible_wrap)]
1188 fn hit(&mut self, c: [i32; 3]) -> Option<u32> {
1189 #[cfg(test)]
1190 prof::SURF.with(|x| x.set(x.get() + 1));
1191 let (ch, loc) = self.locate(c);
1192 self.select_chunk(ch);
1193 let occupied = self.cur_brick.is_some_and(|bm| {
1194 bm.occupied([
1195 loc[0] as i32 / BRICK,
1196 loc[1] as i32 / BRICK,
1197 loc[2] as i32 / BRICK,
1198 ])
1199 });
1200 if !occupied {
1201 return None;
1202 }
1203 self.cur_view?
1204 .surface_color_mip(loc[0], loc[1], loc[2], self.mip)
1205 .map(|c| c.0)
1206 }
1207
1208 #[inline]
1210 fn cells_per_chunk_xy(&self) -> i32 {
1211 1 << self.xy_shift
1212 }
1213 #[inline]
1214 fn cells_per_chunk_z(&self) -> i32 {
1215 1 << self.z_shift
1216 }
1217
1218 #[allow(clippy::cast_sign_loss)]
1223 fn brick_occupied(&mut self, brick: [i32; 3]) -> bool {
1224 let c0 = [brick[0] << 3, brick[1] << 3, brick[2] << 3];
1226 let ch = [
1227 c0[0] >> self.xy_shift,
1228 c0[1] >> self.xy_shift,
1229 c0[2] >> self.z_shift,
1230 ];
1231 self.select_chunk(ch);
1232 self.cur_brick.is_some_and(|bm| {
1233 bm.occupied([
1234 (c0[0] & self.xy_mask) >> 3,
1235 (c0[1] & self.xy_mask) >> 3,
1236 (c0[2] & self.z_mask) >> 3,
1237 ])
1238 })
1239 }
1240
1241 #[allow(clippy::cast_sign_loss)]
1246 fn super_occupied(&mut self, s: [i32; 3]) -> bool {
1247 let c0 = [s[0] << 6, s[1] << 6, s[2] << 6];
1249 let ch = [
1250 c0[0] >> self.xy_shift,
1251 c0[1] >> self.xy_shift,
1252 c0[2] >> self.z_shift,
1253 ];
1254 self.select_chunk(ch);
1255 self.cur_brick.is_some_and(|bm| {
1256 bm.occupied_super([
1257 (c0[0] & self.xy_mask) >> 6,
1258 (c0[1] & self.xy_mask) >> 6,
1259 (c0[2] & self.z_mask) >> 6,
1260 ])
1261 })
1262 }
1263}
1264
1265const SHADOW_MAX_STEPS: u32 = 1024;
1269
1270struct SamplerShadow<'s, 'a> {
1277 sampler: &'s mut Sampler<'a>,
1278 cell_size: f32,
1279 lo_c: [i32; 3],
1280 hi_c: [i32; 3],
1281}
1282
1283impl ShadowTester for SamplerShadow<'_, '_> {
1284 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
1285 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
1286 let cs = self.cell_size;
1287 let has_super =
1295 self.sampler.cells_per_chunk_xy() >= SUPER && self.sampler.cells_per_chunk_z() >= SUPER;
1296 let has_brick =
1297 self.sampler.cells_per_chunk_xy() >= BRICK && self.sampler.cells_per_chunk_z() >= BRICK;
1298 let mut cellc = [
1299 (origin[0] / cs).floor() as i32,
1300 (origin[1] / cs).floor() as i32,
1301 (origin[2] / cs).floor() as i32,
1302 ];
1303 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cs);
1304 let inv = [
1305 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
1306 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
1307 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
1308 ];
1309 let mut t_curr = 0.0f32;
1310 let mut used = 0u32;
1311 while used < SHADOW_MAX_STEPS {
1312 if cellc[0] < self.lo_c[0]
1313 || cellc[0] >= self.hi_c[0]
1314 || cellc[1] < self.lo_c[1]
1315 || cellc[1] >= self.hi_c[1]
1316 || cellc[2] < self.lo_c[2]
1317 || cellc[2] >= self.hi_c[2]
1318 {
1319 return false; }
1321 if t_curr > max_t {
1322 return false; }
1324 let skip_shift = if has_super
1328 && !self
1329 .sampler
1330 .super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
1331 {
1332 Some(6u32)
1333 } else if has_brick
1334 && !self
1335 .sampler
1336 .brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
1337 {
1338 Some(3u32)
1339 } else {
1340 None
1341 };
1342 if let Some(sh) = skip_shift {
1343 let mut best_t = f32::INFINITY;
1344 let mut best_axis = 3usize;
1345 let mut plane = [0i32; 3];
1346 for a in 0..3 {
1347 if step[a] == 0 {
1348 continue;
1349 }
1350 let idx = cellc[a] >> sh;
1351 plane[a] = if step[a] > 0 {
1352 (idx + 1) << sh
1353 } else {
1354 idx << sh
1355 };
1356 let tb = (plane[a] as f32 * cs - origin[a]) * inv[a];
1357 if tb < best_t {
1358 best_t = tb;
1359 best_axis = a;
1360 }
1361 }
1362 if best_axis == 3 {
1363 return false;
1364 }
1365 let pb = [
1366 origin[0] + dir[0] * (best_t + 1e-4),
1367 origin[1] + dir[1] * (best_t + 1e-4),
1368 origin[2] + dir[2] * (best_t + 1e-4),
1369 ];
1370 let mut nc = [
1371 (pb[0] / cs).floor() as i32,
1372 (pb[1] / cs).floor() as i32,
1373 (pb[2] / cs).floor() as i32,
1374 ];
1375 nc[best_axis] = if step[best_axis] > 0 {
1376 plane[best_axis]
1377 } else {
1378 plane[best_axis] - 1
1379 };
1380 let crossed =
1385 cellc[0].abs_diff(nc[0]) + cellc[1].abs_diff(nc[1]) + cellc[2].abs_diff(nc[2]);
1386 if used.saturating_add(crossed) >= SHADOW_MAX_STEPS {
1387 return false;
1388 }
1389 used += crossed;
1390 cellc = nc;
1391 for a in 0..3 {
1392 if step[a] > 0 {
1393 t_max[a] = ((cellc[a] + 1) as f32 * cs - origin[a]) * inv[a];
1394 } else if step[a] < 0 {
1395 t_max[a] = (cellc[a] as f32 * cs - origin[a]) * inv[a];
1396 }
1397 }
1398 t_curr = best_t.max(t_curr);
1399 continue;
1400 }
1401 if self.sampler.hit(cellc).is_some() {
1402 return true; }
1404 let axis = min_axis(t_max);
1405 t_curr = t_max[axis];
1406 cellc[axis] += step[axis];
1407 t_max[axis] += t_delta[axis];
1408 used += 1;
1409 }
1410 false
1411 }
1412}
1413
1414#[allow(
1435 clippy::too_many_arguments,
1436 clippy::cast_possible_truncation,
1437 clippy::cast_sign_loss,
1438 clippy::cast_precision_loss
1439)]
1440fn cell_walk_skip(
1441 origin: [f32; 3],
1442 dir: [f32; 3],
1443 fwd_dot: f32,
1444 sampler: &mut Sampler<'_>,
1445 lo_c: [i32; 3],
1446 hi_c: [i32; 3],
1447 cell_size: f32,
1448 t_enter: f32,
1449 t_exit: f32,
1450 max_dist: f32,
1451 env: &DdaEnv<'_>,
1452) -> Option<Hit> {
1453 let has_super = sampler.cells_per_chunk_xy() >= SUPER && sampler.cells_per_chunk_z() >= SUPER;
1454 let has_brick = sampler.cells_per_chunk_xy() >= BRICK && sampler.cells_per_chunk_z() >= BRICK;
1455
1456 let start = t_enter + 1e-4;
1457 let p = [
1458 origin[0] + dir[0] * start,
1459 origin[1] + dir[1] * start,
1460 origin[2] + dir[2] * start,
1461 ];
1462 let mut cellc = [
1463 ((p[0] / cell_size).floor() as i32).clamp(lo_c[0], hi_c[0] - 1),
1464 ((p[1] / cell_size).floor() as i32).clamp(lo_c[1], hi_c[1] - 1),
1465 ((p[2] / cell_size).floor() as i32).clamp(lo_c[2], hi_c[2] - 1),
1466 ];
1467 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cell_size);
1468 let inv = [
1472 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
1473 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
1474 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
1475 ];
1476 let mut t_curr = t_enter;
1477 let mut last_axis = 3usize;
1478 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
1481 let shadow_casts = env.lights.enabled
1485 && env.lights.shadow_strength > 0.0
1486 && (env.lights.sun_casts_shadow || env.lights.points.iter().any(|p| p.casts_shadow));
1487
1488 let mut accum = [0.0f32; 3];
1493 let mut trans = 1.0f32;
1494 let mut touched = false;
1495 let mut prev_solid = false;
1496 let mut prev_mat = 0u8;
1497
1498 let span = (hi_c[0] - lo_c[0]) + (hi_c[1] - lo_c[1]) + (hi_c[2] - lo_c[2]);
1501 let max_steps = span.max(0) as usize + 16;
1502 for _ in 0..max_steps {
1503 if cellc[0] < lo_c[0]
1504 || cellc[0] >= hi_c[0]
1505 || cellc[1] < lo_c[1]
1506 || cellc[1] >= hi_c[1]
1507 || cellc[2] < lo_c[2]
1508 || cellc[2] >= hi_c[2]
1509 {
1510 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1511 }
1512 let depth = t_curr * fwd_dot;
1513 if depth > max_dist || t_curr > t_exit {
1514 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1515 }
1516 if env.fog_max_dist > 0.0 && depth >= env.fog_max_dist {
1522 let fog = 0x8000_0000 | (env.fog_color & 0x00ff_ffff);
1523 let color = if touched {
1524 composite_over(accum, trans, fog)
1525 } else {
1526 fog
1527 };
1528 return Some(Hit {
1529 color,
1530 dist: env.fog_max_dist,
1531 });
1532 }
1533
1534 let skip_shift = if has_super
1537 && !sampler.super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
1538 {
1539 Some(6u32)
1540 } else if has_brick
1541 && !sampler.brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
1542 {
1543 Some(3u32)
1544 } else {
1545 None
1546 };
1547 if let Some(sh) = skip_shift {
1548 #[cfg(test)]
1549 prof::BRICKS.with(|x| x.set(x.get() + 1));
1550 let mut best_t = f32::INFINITY;
1552 let mut best_axis = 3usize;
1553 let mut plane = [0i32; 3];
1554 for a in 0..3 {
1555 if step[a] == 0 {
1556 continue;
1557 }
1558 let idx = cellc[a] >> sh;
1559 plane[a] = if step[a] > 0 {
1560 (idx + 1) << sh
1561 } else {
1562 idx << sh
1563 };
1564 let tb = (plane[a] as f32 * cell_size - origin[a]) * inv[a];
1565 if tb < best_t {
1566 best_t = tb;
1567 best_axis = a;
1568 }
1569 }
1570 if best_axis == 3 {
1571 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1572 }
1573 let pb = [
1578 origin[0] + dir[0] * (best_t + 1e-4),
1579 origin[1] + dir[1] * (best_t + 1e-4),
1580 origin[2] + dir[2] * (best_t + 1e-4),
1581 ];
1582 let mut nc = [
1583 (pb[0] / cell_size).floor() as i32,
1584 (pb[1] / cell_size).floor() as i32,
1585 (pb[2] / cell_size).floor() as i32,
1586 ];
1587 nc[best_axis] = if step[best_axis] > 0 {
1588 plane[best_axis]
1589 } else {
1590 plane[best_axis] - 1
1591 };
1592 if nc[0] < lo_c[0]
1596 || nc[0] >= hi_c[0]
1597 || nc[1] < lo_c[1]
1598 || nc[1] >= hi_c[1]
1599 || nc[2] < lo_c[2]
1600 || nc[2] >= hi_c[2]
1601 {
1602 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1603 }
1604 cellc = nc;
1605 for a in 0..3 {
1608 if step[a] > 0 {
1609 t_max[a] = ((cellc[a] + 1) as f32 * cell_size - origin[a]) * inv[a];
1610 } else if step[a] < 0 {
1611 t_max[a] = (cellc[a] as f32 * cell_size - origin[a]) * inv[a];
1612 }
1613 }
1614 t_curr = best_t.max(t_curr);
1615 last_axis = best_axis;
1616 prev_solid = false; continue;
1618 }
1619
1620 #[cfg(test)]
1622 prof::CELLS.with(|x| x.set(x.get() + 1));
1623 if let Some(color) = sampler.hit(cellc) {
1624 let bright_sub = side_shade_sub(env, last_axis, step);
1625 let (m, mat_id) = match env.materials {
1631 Some(table) if !env.terrain_materials.is_empty() => {
1632 let id = material_for_color(env.terrain_materials, color);
1633 (table.get(id), id)
1634 }
1635 _ => (Material::OPAQUE, 0),
1636 };
1637 let shaded = if m.emissive > 0 {
1645 emissive_shade(color, m.emissive)
1646 } else if env.lights.enabled {
1647 let casts = shadow_casts;
1648 let mut world_sh;
1654 let mut sampler_sh;
1655 let tester: Option<&mut dyn ShadowTester> = if !casts {
1656 None
1657 } else if let Some(ctx) = env.world_shadow {
1658 world_sh = WorldShadow { ctx };
1659 Some(&mut world_sh)
1660 } else {
1661 sampler_sh = SamplerShadow {
1662 sampler: &mut *sampler,
1663 cell_size,
1664 lo_c,
1665 hi_c,
1666 };
1667 Some(&mut sampler_sh)
1668 };
1669 shade_lit_cpu(
1670 color,
1671 bright_sub,
1672 last_axis,
1673 step,
1674 cellc,
1675 cell_size,
1676 &env.lights,
1677 tester,
1678 )
1679 } else {
1680 shade(color, bright_sub)
1681 };
1682 let lit = apply_fog(shaded, depth.max(0.0), env);
1683 if m.is_opaque() {
1684 let color = if touched {
1688 composite_over(accum, trans, lit)
1689 } else {
1690 lit
1691 };
1692 return Some(Hit {
1693 color,
1694 dist: depth.max(0.0),
1695 });
1696 }
1697 let a = f32::from(m.alpha) / 255.0;
1698 if matches!(m.mode, roxlap_formats::material::BlendMode::Volumetric) {
1699 let t_exit = t_max[min_axis(t_max)];
1703 let seg_len = (t_exit - t_curr).max(0.0) * dir_len / cell_size;
1704 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
1705 let c = rgb_to_f32(lit);
1706 accum[0] += trans * eff_a * c[0];
1707 accum[1] += trans * eff_a * c[1];
1708 accum[2] += trans * eff_a * c[2];
1709 trans *= 1.0 - eff_a;
1710 touched = true;
1711 prev_mat = mat_id;
1712 if trans < 1.0 / 256.0 {
1713 return Some(Hit {
1714 color: f32_to_rgb(accum),
1715 dist: depth.max(0.0),
1716 });
1717 }
1718 } else if !prev_solid || mat_id != prev_mat {
1719 let c = rgb_to_f32(lit);
1723 accum[0] += trans * a * c[0];
1724 accum[1] += trans * a * c[1];
1725 accum[2] += trans * a * c[2];
1726 if !matches!(m.mode, roxlap_formats::material::BlendMode::Additive) {
1727 trans *= 1.0 - a; }
1729 touched = true;
1730 prev_mat = mat_id;
1731 if trans < 1.0 / 256.0 {
1732 return Some(Hit {
1733 color: f32_to_rgb(accum),
1734 dist: depth.max(0.0),
1735 });
1736 }
1737 }
1738 prev_solid = true;
1739 } else {
1740 prev_solid = false;
1741 }
1742 let axis = min_axis(t_max);
1743 last_axis = axis;
1744 t_curr = t_max[axis];
1745 cellc[axis] += step[axis];
1746 t_max[axis] += t_delta[axis];
1747 }
1748 None
1749}
1750
1751#[inline]
1757fn side_shade_sub(env: &DdaEnv<'_>, axis: usize, step: [i32; 3]) -> u32 {
1758 if axis >= 3 {
1759 return 0;
1760 }
1761 let face = axis * 2 + usize::from(step[axis] < 0);
1762 env.side_shades[face].max(0) as u32
1763}
1764
1765fn cast_ray(
1774 origin: [f32; 3],
1775 dir: [f32; 3],
1776 forward: [f32; 3],
1777 sampler: &mut Sampler<'_>,
1778 settings: &OpticastSettings,
1779 env: &DdaEnv<'_>,
1780) -> Option<Hit> {
1781 let (lo_i, hi_i) = sampler.grid.voxel_bounds();
1782 #[allow(clippy::cast_precision_loss)]
1783 let lo_f = [lo_i[0] as f32, lo_i[1] as f32, lo_i[2] as f32];
1784 #[allow(clippy::cast_precision_loss)]
1785 let hi_f = [hi_i[0] as f32, hi_i[1] as f32, hi_i[2] as f32];
1786 let (t_enter, t_exit) = intersect_aabb(origin, dir, lo_f, hi_f)?;
1787 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1788 #[allow(clippy::cast_precision_loss)]
1789 let max_dist = settings.max_scan_dist.max(1) as f32;
1790 let cell = 1i32 << sampler.mip;
1791 let cell_size = cell as f32;
1792 let lo_c = [
1793 lo_i[0].div_euclid(cell),
1794 lo_i[1].div_euclid(cell),
1795 lo_i[2].div_euclid(cell),
1796 ];
1797 let hi_c = [
1798 hi_i[0].div_euclid(cell),
1799 hi_i[1].div_euclid(cell),
1800 hi_i[2].div_euclid(cell),
1801 ];
1802 cell_walk_skip(
1803 origin, dir, fwd_dot, sampler, lo_c, hi_c, cell_size, t_enter, t_exit, max_dist, env,
1804 )
1805}
1806
1807pub fn render_dda(
1820 camera: &Camera,
1821 settings: &OpticastSettings,
1822 grid: GridView<'_>,
1823 pitch_pixels: usize,
1824 env: &DdaEnv<'_>,
1825 mip: u32,
1826 sink: &mut impl PixelSink,
1827) {
1828 let cs = camera_math::derive(
1829 camera,
1830 settings.xres,
1831 settings.yres,
1832 settings.hx,
1833 settings.hy,
1834 settings.hz,
1835 );
1836
1837 let (cache, mip) = local_cache(&grid, mip);
1840 let mut sampler = Sampler::new(grid, &cache, mip);
1841
1842 for py in settings.y_start..settings.y_end {
1843 let row = py as usize * pitch_pixels;
1844 for px in settings.x_start..settings.x_end {
1845 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py) {
1846 sink.put(row + px as usize, color, dist);
1847 }
1848 }
1849 }
1850}
1851
1852#[inline]
1857fn pixel_result(
1858 cs: &CameraState,
1859 settings: &OpticastSettings,
1860 sampler: &mut Sampler<'_>,
1861 env: &DdaEnv<'_>,
1862 px: u32,
1863 py: u32,
1864) -> Option<(u32, f32)> {
1865 let (origin, dir) = pixel_ray(cs, settings, px, py);
1866 if let Some(hit) = cast_ray(origin, dir, cs.forward, sampler, settings, env) {
1867 Some((hit.color, hit.dist))
1868 } else {
1869 env.sky.map(|sky| (sample_sky(sky, dir), f32::INFINITY))
1870 }
1871}
1872
1873#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
1888pub fn render_dda_parallel(
1889 camera: &Camera,
1890 settings: &OpticastSettings,
1891 grid: GridView<'_>,
1892 fb: &mut [u32],
1893 zb: &mut [f32],
1894 pitch_pixels: usize,
1895 env: &DdaEnv<'_>,
1896 cache: &BrickCache,
1897 mip: u32,
1898) {
1899 debug_assert_eq!(fb.len(), zb.len());
1900 let (y0, y1) = (settings.y_start, settings.y_end);
1901 if y1 <= y0 {
1902 return;
1903 }
1904 let cs = camera_math::derive(
1905 camera,
1906 settings.xres,
1907 settings.yres,
1908 settings.hx,
1909 settings.hy,
1910 settings.hz,
1911 );
1912 let target = RasterTarget::new(fb, zb);
1913
1914 let band = 8u32;
1921 let bands: Vec<(u32, u32)> = (y0..y1)
1922 .step_by(band as usize)
1923 .map(|s| (s, (s + band).min(y1)))
1924 .collect();
1925
1926 bands.par_iter().for_each(|&(by0, by1)| {
1927 let mut sampler = Sampler::new(grid, cache, mip);
1928 for py in by0..by1 {
1929 let row = py as usize * pitch_pixels;
1930 for px in settings.x_start..settings.x_end {
1931 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py)
1932 {
1933 let idx = row + px as usize;
1934 unsafe {
1938 target.write_color(idx, color);
1939 target.write_depth(idx, dist);
1940 }
1941 }
1942 }
1943 }
1944 });
1945}
1946
1947#[cfg(test)]
1953#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
1954fn cast_ray_reference(
1955 origin: [f32; 3],
1956 dir: [f32; 3],
1957 forward: [f32; 3],
1958 grid: &GridView<'_>,
1959 settings: &OpticastSettings,
1960) -> Option<Hit> {
1961 let nx = grid.vsid as f32;
1962 let nz = f32::from(u16::try_from(crate::grid_view::CHUNK_SIZE_Z).unwrap_or(256));
1963 #[allow(clippy::cast_possible_wrap)]
1964 let n_i = [
1965 grid.vsid as i32,
1966 grid.vsid as i32,
1967 crate::grid_view::CHUNK_SIZE_Z as i32,
1968 ];
1969 let (t_enter, t_exit) = intersect_aabb(origin, dir, [0.0; 3], [nx, nx, nz])?;
1970 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1971 let max_dist = settings.max_scan_dist.max(1) as f32;
1972
1973 let start = t_enter + 1e-4;
1974 let p = [
1975 origin[0] + dir[0] * start,
1976 origin[1] + dir[1] * start,
1977 origin[2] + dir[2] * start,
1978 ];
1979 let mut voxel = [
1980 (p[0].floor() as i32).clamp(0, n_i[0] - 1),
1981 (p[1].floor() as i32).clamp(0, n_i[1] - 1),
1982 (p[2].floor() as i32).clamp(0, n_i[2] - 1),
1983 ];
1984 let (step, mut t_max, t_delta) = dda_setup(origin, dir, voxel, 1.0);
1985 let mut t_curr = t_enter;
1986 let max_steps = (n_i[0] + n_i[1] + n_i[2]) as usize + 8;
1987 for _ in 0..max_steps {
1988 if voxel[0] < 0
1989 || voxel[0] >= n_i[0]
1990 || voxel[1] < 0
1991 || voxel[1] >= n_i[1]
1992 || voxel[2] < 0
1993 || voxel[2] >= n_i[2]
1994 {
1995 return None;
1996 }
1997 let depth = t_curr * fwd_dot;
1998 if depth > max_dist || t_curr > t_exit {
1999 return None;
2000 }
2001 #[allow(clippy::cast_sign_loss)]
2002 if let Some(color) = grid.surface_color(voxel[0] as u32, voxel[1] as u32, voxel[2] as u32) {
2003 return Some(Hit {
2004 color: shade(color.0, 0),
2005 dist: depth.max(0.0),
2006 });
2007 }
2008 let axis = min_axis(t_max);
2009 t_curr = t_max[axis];
2010 voxel[axis] += step[axis];
2011 t_max[axis] += t_delta[axis];
2012 }
2013 None
2014}
2015
2016#[cfg(test)]
2017mod tests {
2018 use super::*;
2019 use roxlap_formats::VoxColor;
2020
2021 fn lum(p: u32) -> u32 {
2023 (p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)
2024 }
2025
2026 #[test]
2027 fn cel_band_quantizes_and_collapses() {
2028 assert_eq!(cel_band(0.8, 2), cel_band(0.9, 2));
2030 assert!((cel_band(0.8, 2) - 1.0).abs() < 1e-6);
2031 assert_ne!(cel_band(0.3, 2), cel_band(0.8, 2));
2033 }
2034
2035 #[test]
2036 fn shade_lit_cpu_sun_lights_by_facing() {
2037 let color = 0x80_80_80_80;
2040 let step = [0, 0, 1];
2041 let base = CpuLights {
2042 enabled: true,
2043 sun: true,
2044 sun_color: [1.0; 3],
2045 sun_intensity: 1.0,
2046 ambient: [0.2; 3],
2047 ..CpuLights::default()
2048 };
2049 let facing = CpuLights {
2050 sun_dir: [0.0, 0.0, -1.0],
2051 ..base
2052 }; let back = CpuLights {
2054 sun_dir: [0.0, 0.0, 1.0],
2055 ..base
2056 }; let lit = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &facing, None);
2058 let dark = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &back, None);
2059 assert!(
2060 lum(lit) > lum(dark),
2061 "sun facing the surface must brighten it: {lit:#08x} vs {dark:#08x}",
2062 );
2063 }
2064
2065 #[test]
2066 fn shade_dynamic_spot_cone_masks_off_axis() {
2067 let albedo = [0.5, 0.5, 0.5];
2070 let n = [0.0, 0.0, -1.0];
2071 let sample = [0.0, 0.0, 0.0];
2072 let inner = 10.0f32.to_radians().cos();
2073 let outer = 15.0f32.to_radians().cos();
2074 let shade = |spot_dir: [f32; 3], cos_inner: f32, cos_outer: f32| {
2075 let pts = [CpuPointLight {
2076 pos: [0.0, 0.0, -10.0],
2077 color: [1.0; 3],
2078 intensity: 1.0,
2079 radius: 64.0,
2080 casts_shadow: false,
2081 spot_dir,
2082 cos_inner,
2083 cos_outer,
2084 }];
2085 let l = CpuLights {
2086 enabled: true,
2087 ambient: [0.0; 3],
2088 points: &pts,
2089 ..CpuLights::default()
2090 };
2091 shade_dynamic(albedo, 0.0, n, sample, &l, None)
2092 };
2093 let point = shade([0.0, 0.0, 1.0], -1.0, -1.0);
2095 let on_axis = shade([0.0, 0.0, 1.0], inner, outer);
2097 let off_axis = shade([1.0, 0.0, 0.0], inner, outer);
2099
2100 assert_eq!(
2102 on_axis, point,
2103 "on-axis spot must equal the point light: {on_axis:#08x} vs {point:#08x}",
2104 );
2105 assert!(
2107 lum(on_axis) > lum(off_axis),
2108 "off-axis spot must be darker: {on_axis:#08x} vs {off_axis:#08x}",
2109 );
2110 assert_eq!(lum(off_axis), 0, "off-cone spot contributes nothing");
2111 }
2112
2113 #[test]
2114 fn shade_lit_cpu_cel_terraces_sun() {
2115 let color = 0x80_80_80_80;
2118 let step = [0, 0, 1];
2119 let mk = |zc: f32, bands: u32| {
2120 let n = (1.0f32 - zc * zc).sqrt();
2121 CpuLights {
2122 enabled: true,
2123 sun: true,
2124 sun_dir: [n, 0.0, -zc], sun_color: [1.0; 3],
2126 sun_intensity: 1.0,
2127 ambient: [0.1; 3],
2128 bands,
2129 ..CpuLights::default()
2130 }
2131 };
2132 let smooth_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 0), None);
2133 let smooth_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 0), None);
2134 assert_ne!(smooth_a, smooth_b, "smooth diffuse must vary with N·L");
2135 let cel_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 2), None);
2136 let cel_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 2), None);
2137 assert_eq!(
2138 cel_a, cel_b,
2139 "cel banding must terrace both N·L to one level"
2140 );
2141 }
2142
2143 #[test]
2147 fn shade_dynamic_sun_shadow_darkens() {
2148 struct Mock(bool);
2149 impl ShadowTester for Mock {
2150 fn occluded(&mut self, _: [f32; 3], _: [f32; 3], _: f32) -> bool {
2151 self.0
2152 }
2153 }
2154 let l = CpuLights {
2155 enabled: true,
2156 sun: true,
2157 sun_dir: [0.0, 0.0, -1.0], sun_color: [1.0; 3],
2159 sun_intensity: 1.0,
2160 sun_casts_shadow: true,
2161 ambient: [0.2; 3],
2162 shadow_strength: 0.7,
2163 shadow_bias: 1.5,
2164 shadow_max_dist: 64.0,
2165 ..CpuLights::default()
2166 };
2167 let albedo = [0.8; 3];
2168 let n = [0.0, 0.0, -1.0]; let s = [0.5, 0.5, 0.5];
2170 let lit = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(false)));
2171 let shadowed = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(true)));
2172 assert!(
2173 lum(shadowed) < lum(lit),
2174 "an occluded sun face must darken: shadowed={shadowed:#08x} lit={lit:#08x}",
2175 );
2176 let l0 = CpuLights {
2178 shadow_strength: 0.0,
2179 ..l
2180 };
2181 assert_eq!(
2182 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(true))),
2183 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(false))),
2184 "shadow_strength 0 ⇒ shadows invisible",
2185 );
2186 }
2187
2188 #[test]
2194 fn sampler_shadow_march_casts_sun_shadow() {
2195 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, _y, z| {
2197 if z >= 60 {
2198 Some(VoxColor(0x80_80_80_80)) } else if x == 32 && (30..60).contains(&z) {
2200 Some(VoxColor(0x80_70_70_70)) } else {
2202 None
2203 }
2204 });
2205 let grid = GridView::from_single_vxl(&vxl);
2206 let cam = Camera {
2208 pos: [32.0, 32.0, 6.0],
2209 right: [1.0, 0.0, 0.0],
2210 down: [0.0, 1.0, 0.0],
2211 forward: [0.0, 0.0, 1.0],
2212 };
2213 let inv = 1.0f32 / 2.0f32.sqrt();
2215 let base = CpuLights {
2216 enabled: true,
2217 sun: true,
2218 sun_dir: [inv, 0.0, -inv],
2219 sun_color: [1.0; 3],
2220 sun_intensity: 1.0,
2221 ambient: [0.25; 3],
2222 shadow_strength: 0.8,
2223 shadow_bias: 1.5,
2224 shadow_max_dist: 128.0,
2225 ..CpuLights::default()
2226 };
2227 let (w, h) = (96u32, 96u32);
2228 let lit_env = DdaEnv {
2229 lights: CpuLights {
2230 sun_casts_shadow: false,
2231 ..base
2232 },
2233 ..DdaEnv::default()
2234 };
2235 let shadow_env = DdaEnv {
2236 lights: CpuLights {
2237 sun_casts_shadow: true,
2238 ..base
2239 },
2240 ..DdaEnv::default()
2241 };
2242 let (fb_lit, _) = render_brickmap_env(grid, &cam, w, h, &lit_env);
2243 let (fb_sh, _) = render_brickmap_env(grid, &cam, w, h, &shadow_env);
2244 let sum: fn(&[u32]) -> u64 = |fb| fb.iter().map(|&p| u64::from(lum(p))).sum();
2245 let lit_sum = sum(&fb_lit);
2246 let sh_sum = sum(&fb_sh);
2247 assert!(
2248 sh_sum < lit_sum,
2249 "the wall's shadow must darken the floor: shadow_sum={sh_sum} lit_sum={lit_sum}",
2250 );
2251 assert!(
2253 (lit_sum - sh_sum) * 50 > lit_sum,
2254 "shadow should remove >2% of total luminance: lit={lit_sum} shadow={sh_sum}",
2255 );
2256 }
2257
2258 #[derive(Default)]
2260 struct Recorder {
2261 puts: Vec<(usize, u32, f32)>,
2262 }
2263 impl PixelSink for Recorder {
2264 fn put(&mut self, idx: usize, color: u32, dist: f32) {
2265 self.puts.push((idx, color, dist));
2266 }
2267 }
2268
2269 fn oracle_camera() -> Camera {
2270 Camera {
2272 pos: [0.0, 0.0, 0.0],
2273 right: [1.0, 0.0, 0.0],
2274 down: [0.0, 0.0, 1.0],
2275 forward: [0.0, 1.0, 0.0],
2276 }
2277 }
2278
2279 fn render_mask(grid: GridView<'_>, camera: &Camera, w: u32, h: u32) -> Vec<bool> {
2282 let n = (w as usize) * (h as usize);
2283 let mut fb = vec![0u32; n]; let mut zb = vec![f32::INFINITY; n];
2285 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2286 {
2287 let mut sink = RasterSink::new(&mut fb, &mut zb);
2288 render_dda(
2289 camera,
2290 &settings,
2291 grid,
2292 w as usize,
2293 &DdaEnv::default(),
2294 0,
2295 &mut sink,
2296 );
2297 }
2298 fb.iter().map(|&c| c != 0).collect()
2299 }
2300
2301 fn rows_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2306 let w = w as usize;
2307 for y in 0..h as usize {
2308 let row = &mask[y * w..(y + 1) * w];
2309 let first = row.iter().position(|&b| b);
2310 let last = row.iter().rposition(|&b| b);
2311 if let (Some(f), Some(l)) = (first, last) {
2312 if row[f..=l].iter().any(|&b| !b) {
2313 return false;
2314 }
2315 }
2316 }
2317 true
2318 }
2319
2320 fn cols_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2322 let w = w as usize;
2323 let h = h as usize;
2324 for x in 0..w {
2325 let col: Vec<bool> = (0..h).map(|y| mask[y * w + x]).collect();
2326 let first = col.iter().position(|&b| b);
2327 let last = col.iter().rposition(|&b| b);
2328 if let (Some(f), Some(l)) = (first, last) {
2329 if col[f..=l].iter().any(|&b| !b) {
2330 return false;
2331 }
2332 }
2333 }
2334 true
2335 }
2336
2337 #[test]
2340 fn center_pixel_ray_is_forward() {
2341 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2342 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2343 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2345 let (origin, dir) = pixel_ray(&cs, &settings, settings.hx as u32, settings.hy as u32);
2346 assert_eq!(origin, [0.0, 0.0, 0.0]);
2347 assert_eq!(
2349 dir.map(f32::to_bits),
2350 [0.0f32, 320.0, 0.0].map(f32::to_bits)
2351 );
2352 }
2353
2354 #[test]
2358 fn corner_pixel_ray_matches_camera_corn0() {
2359 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2360 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2361 let (_origin, dir) = pixel_ray(&cs, &settings, 0, 0);
2362 assert_eq!(dir.map(f32::to_bits), cs.corn[0].map(f32::to_bits));
2363 }
2364
2365 #[test]
2371 fn gridview_voxel_color_matches_reference() {
2372 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |x, _, z| {
2374 let lo = (10..=12).contains(&z);
2375 let hi = (40..=42).contains(&z);
2376 (lo || hi).then_some(VoxColor(0x80_10_20_30 + x))
2377 });
2378 let grid = GridView::from_single_vxl(&vxl);
2379 for x in 0..8 {
2380 for y in 0..8 {
2381 for z in 0..64 {
2382 assert_eq!(
2383 grid.voxel_color(x, y, z),
2384 vxl.voxel_color(x, y, z),
2385 "mismatch at ({x},{y},{z})"
2386 );
2387 }
2388 }
2389 }
2390 }
2391
2392 #[test]
2394 fn empty_grid_no_hits() {
2395 let vxl = roxlap_formats::vxl::Vxl::empty(64);
2396 let grid = GridView::from_single_vxl(&vxl);
2397 let settings = OpticastSettings::for_oracle_framebuffer(64, 48);
2398 let mut rec = Recorder::default();
2399 render_dda(
2400 &oracle_camera(),
2401 &settings,
2402 grid,
2403 64,
2404 &DdaEnv::default(),
2405 0,
2406 &mut rec,
2407 );
2408 assert!(rec.puts.is_empty(), "all-air grid must produce no hits");
2409 }
2410
2411 #[test]
2415 fn floor_seen_from_above() {
2416 const FLOOR_Z: u32 = 40;
2417 const FLOOR_COL: VoxColor = VoxColor(0x80_30_60_90);
2418 let vxl =
2419 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= FLOOR_Z).then_some(FLOOR_COL));
2420 let grid = GridView::from_single_vxl(&vxl);
2421
2422 let cam = Camera {
2424 pos: [16.0, 16.0, 10.0],
2425 right: [1.0, 0.0, 0.0],
2426 down: [0.0, 1.0, 0.0],
2427 forward: [0.0, 0.0, 1.0],
2428 };
2429 let settings = OpticastSettings::for_oracle_framebuffer(48, 48);
2430 let mut rec = Recorder::default();
2431 render_dda(&cam, &settings, grid, 48, &DdaEnv::default(), 0, &mut rec);
2432
2433 assert!(!rec.puts.is_empty(), "floor must be visible");
2434 let centre = 24usize * 48 + 24;
2436 let hit = rec
2437 .puts
2438 .iter()
2439 .find(|(idx, _, _)| *idx == centre)
2440 .expect("centre ray must hit the floor");
2441 assert_eq!(hit.1 & 0x00ff_ffff, FLOOR_COL.0 & 0x00ff_ffff);
2442 let expected = (FLOOR_Z as f32) - 10.0;
2443 assert!(
2444 (hit.2 - expected).abs() < 1.5,
2445 "centre depth {} not ≈ {}",
2446 hit.2,
2447 expected
2448 );
2449 }
2450
2451 #[test]
2456 fn horizon_splits_sky_and_floor() {
2457 const FLOOR_Z: u32 = 40;
2458 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
2459 (z >= FLOOR_Z).then_some(VoxColor(0x80_44_66_88))
2460 });
2461 let grid = GridView::from_single_vxl(&vxl);
2462
2463 let cam = Camera {
2467 pos: [32.0, 4.0, 30.0],
2468 right: [-1.0, 0.0, 0.0],
2469 down: [0.0, 0.0, 1.0],
2470 forward: [0.0, 1.0, 0.0],
2471 };
2472 let (w, h) = (64u32, 64u32);
2473 let mask = render_mask(grid, &cam, w, h);
2474
2475 let count_band = |y0: usize, y1: usize| -> usize {
2476 (y0 * w as usize..y1 * w as usize)
2477 .filter(|&i| mask[i])
2478 .count()
2479 };
2480 let top = count_band(0, h as usize / 4);
2481 let bottom = count_band(3 * h as usize / 4, h as usize);
2482 assert!(mask.iter().any(|&b| b), "floor must be visible");
2483 assert!(mask.iter().any(|&b| !b), "sky must be visible");
2484 assert!(
2485 bottom > top,
2486 "bottom band ({bottom}) should hit more floor than top band ({top})"
2487 );
2488 }
2489
2490 fn render_reference(
2493 grid: GridView<'_>,
2494 camera: &Camera,
2495 w: u32,
2496 h: u32,
2497 ) -> (Vec<u32>, Vec<f32>) {
2498 let n = (w as usize) * (h as usize);
2499 let mut fb = vec![0u32; n];
2500 let mut zb = vec![f32::INFINITY; n];
2501 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2502 let cs = camera_math::derive(camera, w, h, settings.hx, settings.hy, settings.hz);
2503 for py in 0..h {
2504 for px in 0..w {
2505 let (o, d) = pixel_ray(&cs, &settings, px, py);
2506 if let Some(hit) = cast_ray_reference(o, d, cs.forward, &grid, &settings) {
2507 let i = (py * w + px) as usize;
2508 fb[i] = hit.color;
2509 zb[i] = hit.dist;
2510 }
2511 }
2512 }
2513 (fb, zb)
2514 }
2515
2516 fn render_brickmap(
2518 grid: GridView<'_>,
2519 camera: &Camera,
2520 w: u32,
2521 h: u32,
2522 ) -> (Vec<u32>, Vec<f32>) {
2523 render_brickmap_env(grid, camera, w, h, &DdaEnv::default())
2524 }
2525
2526 fn render_brickmap_env(
2529 grid: GridView<'_>,
2530 camera: &Camera,
2531 w: u32,
2532 h: u32,
2533 env: &DdaEnv<'_>,
2534 ) -> (Vec<u32>, Vec<f32>) {
2535 let n = (w as usize) * (h as usize);
2536 let mut fb = vec![0u32; n];
2537 let mut zb = vec![f32::INFINITY; n];
2538 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2539 {
2540 let mut sink = RasterSink::new(&mut fb, &mut zb);
2541 render_dda(camera, &settings, grid, w as usize, env, 0, &mut sink);
2542 }
2543 (fb, zb)
2544 }
2545
2546 #[test]
2553 fn no_sky_leak_through_diagonal_wall() {
2554 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2555 ((x + y == 64) && (2..62).contains(&z)).then_some(VoxColor(0x80_40_80_60))
2556 });
2557 let grid = GridView::from_single_vxl(&vxl);
2558 let (w, h) = (160u32, 160u32);
2559 let c = [10.0, 10.0, 32.0];
2560 let poses = [
2561 Camera::from_yaw_pitch(c, 0.785, 0.0),
2562 Camera::from_yaw_pitch(c, 0.6, 0.1),
2563 Camera::from_yaw_pitch(c, 0.95, -0.1),
2564 Camera::from_yaw_pitch(c, 0.785, 0.3),
2565 Camera::from_yaw_pitch(c, 0.5, 0.0),
2566 ];
2567 for (i, cam) in poses.iter().enumerate() {
2568 let (fb_b, _) = render_brickmap(grid, cam, w, h);
2569 let (fb_r, _) = render_reference(grid, cam, w, h);
2570 let leak = (0..(w * h) as usize)
2571 .filter(|&k| (fb_b[k] != 0) != (fb_r[k] != 0))
2572 .count();
2573 assert_eq!(leak, 0, "pose {i}: {leak} px diverge from dense reference");
2574 }
2575 }
2576
2577 #[test]
2581 fn terrain_glass_tints_floor_behind() {
2582 let glass = VoxColor(0x80_40_C0_E0); let floor = VoxColor(0x80_C0_40_40); let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2585 if z == 4 {
2586 Some(glass)
2587 } else if z >= 10 {
2588 Some(floor)
2589 } else {
2590 None
2591 }
2592 });
2593 let grid = GridView::from_single_vxl(&vxl);
2594 let cam = Camera {
2596 pos: [8.0, 8.0, 0.0],
2597 right: [1.0, 0.0, 0.0],
2598 down: [0.0, 1.0, 0.0],
2599 forward: [0.0, 0.0, 1.0],
2600 };
2601 let (w, h) = (32u32, 32u32);
2602 let centre = (h / 2 * w + w / 2) as usize;
2603
2604 let (fb_op, _) = render_brickmap(grid, &cam, w, h);
2606 assert_eq!(
2607 fb_op[centre] & 0x00ff_ffff,
2608 0x0040_C0E0,
2609 "opaque glass first-hit"
2610 );
2611
2612 let mut table = MaterialTable::new();
2614 table.set(1, Material::alpha_blend(128));
2615 let env = DdaEnv {
2616 materials: Some(&table),
2617 terrain_materials: &[(glass.rgb_part(), 1)],
2618 lights: CpuLights::default(),
2619 ..DdaEnv::default()
2620 };
2621 let (fb_tr, _) = render_brickmap_env(grid, &cam, w, h, &env);
2622 assert_ne!(
2623 fb_tr[centre], fb_op[centre],
2624 "glass should composite over the floor, not stay opaque"
2625 );
2626 let r_op = (fb_op[centre] >> 16) & 0xff; let r_tr = (fb_tr[centre] >> 16) & 0xff; assert!(
2629 r_tr > r_op,
2630 "floor red tints through the glass (op={r_op:02x} tr={r_tr:02x})"
2631 );
2632 }
2633
2634 #[test]
2638 fn terrain_emissive_ignores_lighting() {
2639 let crystal = VoxColor(0x40_20_60_80); let vxl =
2641 roxlap_formats::vxl::Vxl::from_dense(
2642 16,
2643 |_, _, z| if z >= 4 { Some(crystal) } else { None },
2644 );
2645 let grid = GridView::from_single_vxl(&vxl);
2646 let cam = Camera {
2647 pos: [8.0, 8.0, 0.0],
2648 right: [1.0, 0.0, 0.0],
2649 down: [0.0, 1.0, 0.0],
2650 forward: [0.0, 0.0, 1.0],
2651 };
2652 let (w, h) = (32u32, 32u32);
2653 let centre = (h / 2 * w + w / 2) as usize;
2654
2655 let (fb_dim, _) = render_brickmap(grid, &cam, w, h);
2657 assert_eq!(
2658 fb_dim[centre] & 0x00ff_ffff,
2659 0x0010_3040,
2660 "baked byte 0x40 = albedo/2"
2661 );
2662
2663 let mut table = MaterialTable::new();
2665 table.set(1, Material::glow(255));
2666 let base = DdaEnv {
2667 materials: Some(&table),
2668 terrain_materials: &[(crystal.rgb_part(), 1)],
2669 ..DdaEnv::default()
2670 };
2671 let (fb_em, _) = render_brickmap_env(grid, &cam, w, h, &base);
2672 assert_eq!(
2673 fb_em[centre] & 0x00ff_ffff,
2674 0x003f_bfff,
2675 "glow(255) ≈ 2× albedo (0x20,0x60,0x80 → 0x3f,0xbf,0xff)"
2676 );
2677
2678 let shaded_env = DdaEnv {
2680 side_shades: [64; 6],
2681 ..DdaEnv::default()
2682 };
2683 let (fb_ss_plain, _) = render_brickmap_env(grid, &cam, w, h, &shaded_env);
2684 assert_ne!(
2685 fb_ss_plain[centre], fb_dim[centre],
2686 "control: side shades darken a non-emissive voxel"
2687 );
2688 let em_ss = DdaEnv {
2689 side_shades: [64; 6],
2690 ..base
2691 };
2692 let (fb_em_ss, _) = render_brickmap_env(grid, &cam, w, h, &em_ss);
2693 assert_eq!(
2694 fb_em_ss[centre], fb_em[centre],
2695 "side shades must not touch an emissive voxel"
2696 );
2697
2698 let em_rig = DdaEnv {
2701 lights: CpuLights {
2702 enabled: true,
2703 ..CpuLights::default()
2704 },
2705 ..base
2706 };
2707 let (fb_em_rig, _) = render_brickmap_env(grid, &cam, w, h, &em_rig);
2708 assert_eq!(
2709 fb_em_rig[centre], fb_em[centre],
2710 "the dynamic rig must not touch an emissive voxel"
2711 );
2712 }
2713
2714 #[test]
2719 fn terrain_volumetric_thickness_deepens_opacity() {
2720 let smoke = VoxColor(0x80_90_90_90); let floor = VoxColor(0x80_C0_20_20); let green_at = |depth: u32| -> u32 {
2725 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2726 if (4..4 + depth).contains(&z) {
2727 Some(smoke)
2728 } else if z >= 12 {
2729 Some(floor)
2730 } else {
2731 None
2732 }
2733 });
2734 let grid = GridView::from_single_vxl(&vxl);
2735 let cam = Camera {
2736 pos: [8.0, 8.0, 0.0],
2737 right: [1.0, 0.0, 0.0],
2738 down: [0.0, 1.0, 0.0],
2739 forward: [0.0, 0.0, 1.0],
2740 };
2741 let (w, h) = (32u32, 32u32);
2742 let mut table = MaterialTable::new();
2743 table.set(1, Material::volumetric(80));
2744 let env = DdaEnv {
2745 materials: Some(&table),
2746 terrain_materials: &[(smoke.rgb_part(), 1)],
2747 lights: CpuLights::default(),
2748 ..DdaEnv::default()
2749 };
2750 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
2751 (fb[(h / 2 * w + w / 2) as usize] >> 8) & 0xff
2752 };
2753 let shallow = green_at(1);
2754 let deep = green_at(7);
2755 assert!(
2756 deep > shallow,
2757 "deeper Volumetric smoke shows more of its grey (deep g={deep:02x} > shallow g={shallow:02x})"
2758 );
2759 }
2760
2761 #[test]
2764 fn distance_fog_blends_toward_fog_color() {
2765 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
2766 (z >= 40).then_some(VoxColor(0x80_FF_FF_FF))
2767 });
2768 let grid = GridView::from_single_vxl(&vxl);
2769 let cam = Camera {
2770 pos: [32.0, 2.0, 38.0],
2771 right: [1.0, 0.0, 0.0],
2772 down: [0.0, 0.0, 1.0],
2773 forward: [0.0, 1.0, 0.0],
2774 };
2775 let env = DdaEnv {
2776 sky: None,
2777 fog_color: 0x00_00_00_00, fog_max_dist: 64.0,
2779 side_shades: [0; 6],
2780 materials: None,
2781 terrain_materials: &[],
2782 lights: CpuLights::default(),
2783 world_shadow: None,
2784 };
2785 let (w, h) = (64u32, 64u32);
2786 let (fog, _) = render_brickmap_env(grid, &cam, w, h, &env);
2787 let (nofog, zb) = render_brickmap(grid, &cam, w, h);
2788 let (idx, depth) = zb.iter().enumerate().filter(|(_, z)| z.is_finite()).fold(
2789 (0usize, 0.0f32),
2790 |acc, (i, &z)| {
2791 if z > acc.1 {
2792 (i, z)
2793 } else {
2794 acc
2795 }
2796 },
2797 );
2798 assert!(depth > 20.0, "need a deep pixel to test fog (got {depth})");
2799 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
2800 assert!(
2801 lum(fog[idx]) < lum(nofog[idx]),
2802 "fogged pixel {:08x} not darker than {:08x}",
2803 fog[idx],
2804 nofog[idx]
2805 );
2806 }
2807
2808 #[test]
2811 fn textured_sky_fills_misses() {
2812 let sky = crate::sky::Sky::blue_gradient();
2813 let vxl = roxlap_formats::vxl::Vxl::empty(32); let grid = GridView::from_single_vxl(&vxl);
2815 let env = DdaEnv {
2816 sky: Some(&sky),
2817 fog_color: 0,
2818 fog_max_dist: 0.0,
2819 side_shades: [0; 6],
2820 materials: None,
2821 terrain_materials: &[],
2822 lights: CpuLights::default(),
2823 world_shadow: None,
2824 };
2825 let cam = Camera::from_yaw_pitch([16.0, 16.0, 128.0], 0.3, -0.4);
2826 let (w, h) = (48u32, 48u32);
2827 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
2828 assert!(fb.iter().all(|&c| c >> 24 == 0x80), "all misses sky-filled");
2829 let top = fb[0];
2830 let bottom = fb[(h - 1) as usize * w as usize];
2831 assert_ne!(top, bottom, "sky gradient should vary with elevation");
2832 }
2833
2834 #[test]
2839 fn sky_elevation_zenith_at_column_zero() {
2840 let mut pixels = vec![0i32; 8];
2841 pixels[0] = 0x0011_1111; pixels[7] = 0x0099_9999; let sky = crate::sky::Sky::from_pixels(pixels, 8, 1);
2844 let up = sample_sky(&sky, [0.0, 0.0, -1.0]); let down = sample_sky(&sky, [0.0, 0.0, 1.0]); assert_eq!(
2847 up & 0x00ff_ffff,
2848 0x0011_1111,
2849 "looking up → column 0 (zenith)"
2850 );
2851 assert_eq!(
2852 down & 0x00ff_ffff,
2853 0x0099_9999,
2854 "looking down → last column (nadir)"
2855 );
2856 }
2857
2858 #[test]
2862 fn sky_fill_paints_panorama_gridless() {
2863 let sky = crate::sky::Sky::blue_gradient();
2864 let cam = Camera::from_yaw_pitch([0.0, 0.0, 0.0], 0.3, -0.4);
2865 let (w, h) = (48u32, 48u32);
2866 let cs = crate::camera_math::derive(&cam, w, h, 24.0, 24.0, 24.0);
2867 let settings = crate::opticast::OpticastSettings::for_oracle_framebuffer(w, h);
2868 let mut fb = vec![0u32; (w * h) as usize];
2869 let zb = vec![f32::INFINITY; (w * h) as usize];
2871 render_sky_fill(&mut fb, &zb, w as usize, w, h, &cs, &settings, &sky);
2872 assert!(
2873 fb.iter().all(|&c| c >> 24 == 0x80),
2874 "every pixel sky-filled with the brightness byte set"
2875 );
2876 let top = fb[0];
2877 let bottom = fb[(h - 1) as usize * w as usize];
2878 assert_ne!(top, bottom, "sky gradient should vary with elevation");
2879 let mut fb2 = vec![0x1234_5678u32; (w * h) as usize];
2881 let mut zb2 = vec![f32::INFINITY; (w * h) as usize];
2882 zb2[0] = 10.0; render_sky_fill(&mut fb2, &zb2, w as usize, w, h, &cs, &settings, &sky);
2884 assert_eq!(fb2[0], 0x1234_5678, "finite-z pixel is not overwritten");
2885 }
2886
2887 #[test]
2891 fn side_shades_darken_hit_face() {
2892 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2893 (z >= 8).then_some(VoxColor(0x80_FF_FF_FF))
2894 });
2895 let grid = GridView::from_single_vxl(&vxl);
2896 let cam = Camera {
2897 pos: [8.0, 8.0, 2.0],
2898 right: [1.0, 0.0, 0.0],
2899 down: [0.0, 1.0, 0.0],
2900 forward: [0.0, 0.0, 1.0],
2901 };
2902 let centre = 16 * 32 + 16;
2903 let (plain, _) = render_brickmap(grid, &cam, 32, 32);
2904 let env = DdaEnv {
2905 sky: None,
2906 fog_color: 0,
2907 fog_max_dist: 0.0,
2908 side_shades: [0, 0, 0, 0, 0x40, 0],
2909 materials: None,
2910 terrain_materials: &[],
2911 lights: CpuLights::default(),
2912 world_shadow: None,
2913 };
2914 let (shaded, _) = render_brickmap_env(grid, &cam, 32, 32, &env);
2915 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
2916 assert!(
2917 lum(shaded[centre]) < lum(plain[centre]),
2918 "side-shaded face {:08x} not darker than {:08x}",
2919 shaded[centre],
2920 plain[centre]
2921 );
2922 }
2923
2924 #[test]
2934 fn brickmap_approximates_dense_reference() {
2935 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2937 let surf = 30 + ((x / 5 + y / 7) % 11);
2938 let ground = z >= surf;
2939 let block = (20..=24).contains(&z) && (10..20).contains(&x) && (40..50).contains(&y);
2940 (ground || block).then_some(VoxColor(0x80_30_50_70 + (x ^ y) % 0x40))
2941 });
2942 let grid = GridView::from_single_vxl(&vxl);
2943
2944 let (w, h) = (80u32, 80u32);
2945 let poses = [
2946 Camera::orbit(0.6, 0.5, 90.0, [32.0, 32.0, 40.0]),
2947 Camera::orbit(2.1, 0.2, 70.0, [32.0, 32.0, 35.0]),
2948 Camera::orbit(-1.0, 0.9, 120.0, [32.0, 32.0, 45.0]),
2949 ];
2950 let n = (w * h) as usize;
2951 for (i, cam) in poses.iter().enumerate() {
2952 let (fb_b, zb_b) = render_brickmap(grid, cam, w, h);
2953 let (fb_r, _zb_r) = render_reference(grid, cam, w, h);
2954 let cov_b = fb_b.iter().filter(|&&c| c != 0).count();
2956 let cov_r = fb_r.iter().filter(|&&c| c != 0).count();
2957 assert!(cov_b > 200, "pose {i} rendered ~empty (cov {cov_b})");
2958 let cov_diff = cov_b.abs_diff(cov_r);
2959 assert!(
2960 cov_diff * 100 <= n, "pose {i} coverage diverged: brick {cov_b} vs dense {cov_r}"
2962 );
2963 let diffs = fb_b.iter().zip(&fb_r).filter(|(a, b)| a != b).count();
2965 assert!(
2966 diffs * 100 <= n * 3, "pose {i} too many pixel diffs vs dense: {diffs}/{n}"
2968 );
2969 for k in 0..n {
2971 if fb_b[k] != 0 {
2972 assert!(zb_b[k].is_finite(), "pose {i} px {k} non-finite depth");
2973 }
2974 }
2975 }
2976 }
2977
2978 #[test]
2982 fn baked_brightness_darkens_color() {
2983 let dim = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2985 (z >= 8).then_some(VoxColor(0x40_FF_FF_FF))
2986 });
2987 let grid = GridView::from_single_vxl(&dim);
2988 let cam = Camera {
2989 pos: [8.0, 8.0, 2.0],
2990 right: [1.0, 0.0, 0.0],
2991 down: [0.0, 1.0, 0.0],
2992 forward: [0.0, 0.0, 1.0],
2993 };
2994 let (fb, _) = render_brickmap(grid, &cam, 32, 32);
2995 let centre = 16 * 32 + 16;
2996 assert_eq!(fb[centre], 0x80_7F_7F_7F, "got {:08x}", fb[centre]);
2998
2999 let full = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
3001 (z >= 8).then_some(VoxColor(0x80_FF_FF_FF))
3002 });
3003 let gridf = GridView::from_single_vxl(&full);
3004 let (fbf, _) = render_brickmap(gridf, &cam, 32, 32);
3005 assert_eq!(fbf[centre], 0x80_FF_FF_FF, "got {:08x}", fbf[centre]);
3006 }
3007
3008 #[test]
3015 fn cross_chunk_lookdown_sees_lower_stacked_floor() {
3016 const FLOOR_LOCAL_Z: u32 = 40;
3017 const FLOOR_COL: VoxColor = VoxColor(0x80_22_88_44);
3018 let upper = roxlap_formats::vxl::Vxl::empty(32); let lower = roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
3020 (z >= FLOOR_LOCAL_Z).then_some(FLOOR_COL)
3021 });
3022 let v_up = GridView::from_single_vxl(&upper);
3023 let v_lo = GridView::from_single_vxl(&lower);
3024 let chunks = [Some(v_up), Some(v_lo)];
3026 let cg = crate::ChunkGrid {
3027 chunks: &chunks,
3028 origin_chunk_xy: [0, 0],
3029 origin_chunk_z: 0,
3030 chunks_x: 1,
3031 chunks_y: 1,
3032 chunks_z: 2,
3033 };
3034 let grid = GridView::from_chunk_grid(&cg, 32);
3035
3036 let cam = Camera {
3038 pos: [16.0, 16.0, 100.0],
3039 right: [1.0, 0.0, 0.0],
3040 down: [0.0, 1.0, 0.0],
3041 forward: [0.0, 0.0, 1.0],
3042 };
3043 let (w, h) = (48u32, 48u32);
3044 let (fb, zb) = render_brickmap(grid, &cam, w, h);
3045 let centre = 24 * 48 + 24;
3046 assert!(
3047 fb[centre] & 0x00ff_ffff == FLOOR_COL.0 & 0x00ff_ffff,
3048 "centre ray must reach the lower-chunk floor (got {:08x})",
3049 fb[centre]
3050 );
3051 let expected = 296.0 - 100.0;
3053 assert!(
3054 (zb[centre] - expected).abs() < 2.0,
3055 "look-down depth {} not ≈ {expected}",
3056 zb[centre]
3057 );
3058 }
3059
3060 #[test]
3064 fn cross_chunk_xy_floor_is_seamless() {
3065 let mk = || {
3066 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
3067 (z >= 20).then_some(VoxColor(0x80_50_50_50))
3068 })
3069 };
3070 let (c0, c1) = (mk(), mk());
3071 let v0 = GridView::from_single_vxl(&c0);
3072 let v1 = GridView::from_single_vxl(&c1);
3073 let chunks = [Some(v0), Some(v1)];
3074 let cg = crate::ChunkGrid {
3075 chunks: &chunks,
3076 origin_chunk_xy: [0, 0],
3077 origin_chunk_z: 0,
3078 chunks_x: 2,
3079 chunks_y: 1,
3080 chunks_z: 1,
3081 };
3082 let grid = GridView::from_chunk_grid(&cg, 32);
3083
3084 let cam = Camera {
3086 pos: [32.0, 16.0, 4.0],
3087 right: [1.0, 0.0, 0.0],
3088 down: [0.0, 1.0, 0.0],
3089 forward: [0.0, 0.0, 1.0],
3090 };
3091 let (w, h) = (64u32, 64u32);
3092 let mask = render_mask(grid, &cam, w, h);
3093 let row = (h / 2) as usize * w as usize;
3096 let left = (0..w as usize / 2).filter(|&x| mask[row + x]).count();
3097 let right = (w as usize / 2..w as usize)
3098 .filter(|&x| mask[row + x])
3099 .count();
3100 assert!(
3101 left > 5 && right > 5,
3102 "seam not continuous: left={left} right={right}"
3103 );
3104 }
3105
3106 fn render_mask_mip(grid: GridView<'_>, camera: &Camera, w: u32, h: u32, mip: u32) -> Vec<bool> {
3109 let n = (w as usize) * (h as usize);
3110 let mut fb = vec![0u32; n];
3111 let mut zb = vec![f32::INFINITY; n];
3112 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
3113 {
3114 let mut sink = RasterSink::new(&mut fb, &mut zb);
3115 render_dda(
3116 camera,
3117 &settings,
3118 grid,
3119 w as usize,
3120 &DdaEnv::default(),
3121 mip,
3122 &mut sink,
3123 );
3124 }
3125 fb.iter().map(|&c| c != 0).collect()
3126 }
3127
3128 #[test]
3134 fn mip_render_is_coarse_but_complete() {
3135 let mut vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
3136 let surf = 24 + ((x / 3 + y / 5) % 17);
3137 (z >= surf).then_some(VoxColor(0x80_50_70_90))
3138 });
3139 vxl.generate_mips(4);
3140 assert!(vxl.mip_count() >= 3, "need mips built for this test");
3141 let grid = GridView::from_single_vxl(&vxl);
3142 let (w, h) = (96u32, 96u32);
3143 let cam = Camera::orbit(0.7, 0.6, 110.0, [32.0, 32.0, 36.0]);
3144
3145 let m0 = render_mask_mip(grid, &cam, w, h, 0);
3146 let m2 = render_mask_mip(grid, &cam, w, h, 2);
3147
3148 let c0 = m0.iter().filter(|&&b| b).count();
3149 let c2 = m2.iter().filter(|&&b| b).count();
3150 assert!(c0 > 200 && c2 > 200, "both mips visible (c0={c0} c2={c2})");
3151 let ratio = c2 as f32 / c0 as f32;
3157 assert!(
3158 (0.7..1.4).contains(&ratio),
3159 "mip-2 coverage {c2} vs mip-0 {c0} (ratio {ratio:.2}) diverged"
3160 );
3161 }
3162
3163 #[test]
3169 #[ignore = "perf benchmark — run explicitly with --ignored"]
3170 fn bench_terrain() {
3171 use std::time::Instant;
3172 const NC: i32 = 6;
3174 let cs = crate::grid_view::CHUNK_SIZE_Z; let _ = cs;
3176 let mut vxls: Vec<roxlap_formats::vxl::Vxl> = Vec::new();
3177 for cy in 0..NC {
3178 for cx in 0..NC {
3179 let (ox, oy) = (cx * 128, cy * 128);
3180 let mut v = roxlap_formats::vxl::Vxl::from_dense(128, |x, y, z| {
3181 let (gx, gy) = (ox + x as i32, oy + y as i32);
3182 let surf = 90 + ((gx / 7 + gy / 9).rem_euclid(40)) + ((gx / 23).rem_euclid(20));
3183 (z as i32 >= surf).then_some(VoxColor(0x80_50_70_90 + (x ^ y) % 0x30))
3184 });
3185 v.generate_mips(4);
3186 vxls.push(v);
3187 }
3188 }
3189 let views: Vec<Option<GridView>> = vxls
3190 .iter()
3191 .map(|v| Some(GridView::from_single_vxl(v)))
3192 .collect();
3193 let cg = crate::ChunkGrid {
3194 chunks: &views,
3195 origin_chunk_xy: [0, 0],
3196 origin_chunk_z: 0,
3197 chunks_x: NC as u32,
3198 chunks_y: NC as u32,
3199 chunks_z: 1,
3200 };
3201 let grid = GridView::from_chunk_grid(&cg, 128);
3202
3203 let (w, h) = (960u32, 600u32);
3204 let mut settings = OpticastSettings::for_oracle_framebuffer(w, h);
3205 settings.max_scan_dist = 512;
3206 let n = (w * h) as usize;
3207 let mut fb = vec![0u32; n];
3208 let mut zb = vec![f32::INFINITY; n];
3209 let centre = [f64::from(NC * 128) / 2.0, f64::from(NC * 128) / 2.0, 60.0];
3210
3211 let poses = [
3214 (
3215 "horizon",
3216 Camera::from_yaw_pitch([20.0, 20.0, 40.0], 0.6, 0.15),
3217 ),
3218 ("down", Camera::orbit(0.7, 1.0, 130.0, centre)),
3219 ];
3220 for (name, cam) in poses {
3221 {
3222 let mut sink = RasterSink::new(&mut fb, &mut zb);
3223 prof::reset();
3224 render_dda(
3225 &cam,
3226 &settings,
3227 grid,
3228 w as usize,
3229 &DdaEnv::default(),
3230 0,
3231 &mut sink,
3232 );
3233 }
3234 let (cells, bricks, surf) = prof::read();
3235 let iters = 6;
3236 let t0 = Instant::now();
3237 for _ in 0..iters {
3238 let mut sink = RasterSink::new(&mut fb, &mut zb);
3239 render_dda(
3240 &cam,
3241 &settings,
3242 grid,
3243 w as usize,
3244 &DdaEnv::default(),
3245 0,
3246 &mut sink,
3247 );
3248 }
3249 let ms = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(iters);
3250 let hits = fb.iter().filter(|&&c| c != 0).count();
3251 eprintln!(
3252 "[{name}] {w}x{h} 1-thread: {ms:.1} ms | hits={hits}/{n} | per-px: cells={:.1} bricks={:.1} surf={:.1}",
3253 cells as f64 / n as f64,
3254 bricks as f64 / n as f64,
3255 surf as f64 / n as f64,
3256 );
3257 }
3258 }
3259
3260 #[test]
3264 fn parallel_matches_sequential() {
3265 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
3266 let surf = 28 + ((x / 4 + y / 6) % 13);
3267 (z >= surf).then_some(VoxColor(0x80_40_60_80 + (x ^ y) % 0x30))
3268 });
3269 let grid = GridView::from_single_vxl(&vxl);
3270 let (w, h) = (96u32, 96u32);
3271 let cam = Camera::orbit(0.8, 0.55, 100.0, [32.0, 32.0, 40.0]);
3272 let env = DdaEnv {
3273 sky: None,
3274 fog_color: 0x00_20_30_40,
3275 fog_max_dist: 120.0,
3276 side_shades: [0, 0, 0, 0, 0x30, 0x10],
3277 materials: None,
3278 terrain_materials: &[],
3279 lights: CpuLights::default(),
3280 world_shadow: None,
3281 };
3282
3283 let (seq_fb, seq_zb) = render_brickmap_env(grid, &cam, w, h, &env);
3284
3285 let n = (w * h) as usize;
3286 let mut par_fb = vec![0u32; n];
3287 let mut par_zb = vec![f32::INFINITY; n];
3288 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
3289 let (cache, mip) = local_cache(&grid, 0);
3290 render_dda_parallel(
3291 &cam,
3292 &settings,
3293 grid,
3294 &mut par_fb,
3295 &mut par_zb,
3296 w as usize,
3297 &env,
3298 &cache,
3299 mip,
3300 );
3301 assert!(par_fb == seq_fb, "parallel colour differs from sequential");
3302 assert!(
3303 par_zb
3304 .iter()
3305 .zip(&seq_zb)
3306 .all(|(a, b)| a.to_bits() == b.to_bits()),
3307 "parallel depth differs from sequential"
3308 );
3309 }
3310
3311 #[test]
3317 fn cliff_side_is_solid_not_see_through() {
3318 const TOP_Z: u32 = 50;
3319 const COL: VoxColor = VoxColor(0x80_77_88_99);
3320 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |_, _, z| (z >= TOP_Z).then_some(COL));
3321 let grid = GridView::from_single_vxl(&vxl);
3322
3323 assert_eq!(grid.voxel_color(4, 4, TOP_Z), Some(COL));
3325 assert_eq!(grid.voxel_color(4, 4, 150), None);
3327 assert_eq!(grid.surface_color(4, 4, 150), Some(COL));
3330 assert_eq!(grid.surface_color(4, 4, 10), None);
3332 }
3333
3334 #[test]
3337 fn camera_inside_solid_hits_everywhere() {
3338 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, _| Some(VoxColor(0x80_55_55_55)));
3339 let grid = GridView::from_single_vxl(&vxl);
3340 let cam = Camera {
3341 pos: [8.0, 8.0, 128.0],
3342 right: [1.0, 0.0, 0.0],
3343 down: [0.0, 1.0, 0.0],
3344 forward: [0.0, 0.0, 1.0],
3345 };
3346 let (w, h) = (32u32, 32u32);
3347 let mask = render_mask(grid, &cam, w, h);
3348 assert!(
3349 mask.iter().all(|&b| b),
3350 "every ray must hit when the camera is inside solid"
3351 );
3352 }
3353
3354 #[test]
3360 fn single_voxel_silhouette_has_no_notch() {
3361 const C: VoxColor = VoxColor(0x80_FF_80_40);
3362 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |x, y, z| {
3363 (x == 8 && y == 8 && z == 8).then_some(C)
3364 });
3365 let grid = GridView::from_single_vxl(&vxl);
3366
3367 let cam = Camera::orbit(0.7, 0.6, 4.0, [8.5, 8.5, 8.5]);
3370 let (w, h) = (96u32, 96u32);
3371 let mask = render_mask(grid, &cam, w, h);
3372
3373 let hits = mask.iter().filter(|&&b| b).count();
3374 assert!(
3375 hits > 30,
3376 "silhouette too small to be meaningful: {hits} px"
3377 );
3378 assert!(
3379 rows_have_no_holes(&mask, w, h),
3380 "row-interior gap in single-voxel silhouette (notch)"
3381 );
3382 assert!(
3383 cols_have_no_holes(&mask, w, h),
3384 "column-interior gap in single-voxel silhouette (notch)"
3385 );
3386 }
3387}