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;
45
46#[derive(Clone, Copy)]
53pub struct DdaEnv<'a> {
54 pub sky: Option<&'a Sky>,
57 pub fog_color: u32,
60 pub fog_max_dist: f32,
62 pub side_shades: [i8; 6],
65}
66
67impl Default for DdaEnv<'_> {
68 fn default() -> Self {
69 Self {
70 sky: None,
71 fog_color: 0,
72 fog_max_dist: 0.0,
73 side_shades: [0; 6],
74 }
75 }
76}
77
78pub trait PixelSink {
86 fn put(&mut self, idx: usize, color: u32, dist: f32);
90}
91
92pub struct RasterSink<'a> {
99 target: RasterTarget<'a>,
100 len: usize,
101}
102
103impl<'a> RasterSink<'a> {
104 #[must_use]
107 pub fn new(framebuffer: &'a mut [u32], zbuffer: &'a mut [f32]) -> Self {
108 debug_assert_eq!(framebuffer.len(), zbuffer.len());
109 let len = framebuffer.len();
110 Self {
111 target: RasterTarget::new(framebuffer, zbuffer),
112 len,
113 }
114 }
115}
116
117impl PixelSink for RasterSink<'_> {
118 fn put(&mut self, idx: usize, color: u32, dist: f32) {
119 if idx < self.len {
120 unsafe {
123 self.target.write_color(idx, color);
124 self.target.write_depth(idx, dist);
125 }
126 }
127 }
128}
129
130#[derive(Debug, Clone, Copy)]
132struct Hit {
133 color: u32,
134 dist: f32,
135}
136
137#[cfg(test)]
139pub(crate) mod prof {
140 use std::cell::Cell;
141 thread_local! {
142 pub static CELLS: Cell<u64> = const { Cell::new(0) };
143 pub static BRICKS: Cell<u64> = const { Cell::new(0) };
144 pub static SURF: Cell<u64> = const { Cell::new(0) };
145 }
146 pub fn reset() {
147 CELLS.with(|x| x.set(0));
148 BRICKS.with(|x| x.set(0));
149 SURF.with(|x| x.set(0));
150 }
151 pub fn read() -> (u64, u64, u64) {
152 (
153 CELLS.with(Cell::get),
154 BRICKS.with(Cell::get),
155 SURF.with(Cell::get),
156 )
157 }
158}
159
160#[inline]
179pub(crate) fn shade(color: u32, bright_sub: u32) -> u32 {
180 let a = ((color >> 24) & 0xff).saturating_sub(bright_sub);
181 let ch = |shift: u32| -> u32 { ((((color >> shift) & 0xff) * a) >> 7).min(255) };
182 0x8000_0000 | (ch(16) << 16) | (ch(8) << 8) | ch(0)
183}
184
185#[inline]
189fn apply_fog(color: u32, depth: f32, env: &DdaEnv<'_>) -> u32 {
190 if env.fog_max_dist <= 0.0 {
191 return color;
192 }
193 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
194 let f = ((depth / env.fog_max_dist).clamp(0.0, 1.0) * 256.0) as u32; let g = 256 - f;
196 let fog = env.fog_color;
197 let mix = |shift: u32| -> u32 {
198 let src = (color >> shift) & 0xff;
199 let dst = (fog >> shift) & 0xff;
200 ((src * g + dst * f) >> 8).min(255)
201 };
202 0x8000_0000 | (mix(16) << 16) | (mix(8) << 8) | mix(0)
203}
204
205#[allow(
214 clippy::cast_possible_truncation,
215 clippy::cast_sign_loss,
216 clippy::cast_precision_loss
217)]
218fn sample_sky(sky: &Sky, dir: [f32; 3]) -> u32 {
219 let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
220 if len < 1e-9 {
221 return 0x8000_0000;
222 }
223 let d = [dir[0] / len, dir[1] / len, dir[2] / len];
224 let xsiz_full = sky.lat.len().max(1) as i32; let pi = std::f32::consts::PI;
226 let elev = (-d[2]).clamp(-1.0, 1.0).asin(); let x = (((elev / pi) + 0.5) * xsiz_full as f32) as i32;
229 let x = x.clamp(0, xsiz_full - 1);
230 let y = if sky.ysiz <= 1 {
232 0
233 } else {
234 let az = d[1].atan2(d[0]); let yf = ((az / (pi * 2.0)) + 0.5) * sky.ysiz as f32;
236 (yf as i32).rem_euclid(sky.ysiz)
237 };
238 let idx = (y * xsiz_full + x) as usize;
239 let px = sky.pixels.get(idx).copied().unwrap_or(0) as u32;
240 0x8000_0000 | (px & 0x00ff_ffff)
241}
242
243#[must_use]
255pub fn pixel_ray(
256 cs: &CameraState,
257 settings: &OpticastSettings,
258 px: u32,
259 py: u32,
260) -> ([f32; 3], [f32; 3]) {
261 #[allow(clippy::cast_precision_loss)]
263 let sx = px as f32 - settings.hx;
264 #[allow(clippy::cast_precision_loss)]
265 let sy = py as f32 - settings.hy;
266 let dir = [
267 sx * cs.right[0] + sy * cs.down[0] + settings.hz * cs.forward[0],
268 sx * cs.right[1] + sy * cs.down[1] + settings.hz * cs.forward[1],
269 sx * cs.right[2] + sy * cs.down[2] + settings.hz * cs.forward[2],
270 ];
271 (cs.pos, dir)
272}
273
274pub(crate) fn intersect_aabb(
280 o: [f32; 3],
281 dir: [f32; 3],
282 lo: [f32; 3],
283 hi: [f32; 3],
284) -> Option<(f32, f32)> {
285 let mut t0 = 0.0f32;
286 let mut t1 = f32::INFINITY;
287 for a in 0..3 {
288 if dir[a].abs() < 1e-9 {
289 if o[a] < lo[a] || o[a] > hi[a] {
291 return None;
292 }
293 } else {
294 let inv = 1.0 / dir[a];
295 let mut ta = (lo[a] - o[a]) * inv;
296 let mut tb = (hi[a] - o[a]) * inv;
297 if ta > tb {
298 core::mem::swap(&mut ta, &mut tb);
299 }
300 t0 = t0.max(ta);
301 t1 = t1.min(tb);
302 if t0 > t1 {
303 return None;
304 }
305 }
306 }
307 Some((t0, t1))
308}
309
310const BRICK: i32 = 8;
312
313#[derive(Debug)]
327pub(crate) struct BrickMap {
328 nb: [i32; 3],
330 bits: Vec<u64>,
333 ns: [i32; 3],
336 super_bits: Vec<u64>,
341}
342
343const SUPER: i32 = BRICK * BRICK;
345
346impl BrickMap {
347 #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
350 fn build(grid: &GridView<'_>, mip: u32) -> Self {
351 let vsid_m = (grid.vsid >> mip).max(1) as i32;
352 let z_m = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1) as i32;
353 let nb = [
354 (vsid_m + BRICK - 1) / BRICK,
355 (vsid_m + BRICK - 1) / BRICK,
356 (z_m + BRICK - 1) / BRICK,
357 ];
358 let ns = [
359 (nb[0] + BRICK - 1) / BRICK,
360 (nb[1] + BRICK - 1) / BRICK,
361 (nb[2] + BRICK - 1) / BRICK,
362 ];
363 let count = (nb[0] * nb[1] * nb[2]) as usize;
364 let scount = (ns[0] * ns[1] * ns[2]) as usize;
365 let mut bits = vec![0u64; count.div_ceil(64)];
366 let mut super_bits = vec![0u64; scount.div_ceil(64)];
367 for y in 0..vsid_m {
368 for x in 0..vsid_m {
369 let (bx, by) = (x / BRICK, y / BRICK);
370 grid.for_each_run_mip(x as u32, y as u32, mip, |top, bot| {
371 for bz in (top / BRICK)..=((bot - 1) / BRICK) {
372 let idx = ((bz * nb[1] + by) * nb[0] + bx) as usize;
373 bits[idx / 64] |= 1u64 << (idx % 64);
374 let sidx =
375 (((bz / BRICK) * ns[1] + by / BRICK) * ns[0] + bx / BRICK) as usize;
376 super_bits[sidx / 64] |= 1u64 << (sidx % 64);
377 }
378 });
379 }
380 }
381 Self {
382 nb,
383 bits,
384 ns,
385 super_bits,
386 }
387 }
388
389 #[inline]
391 #[allow(clippy::cast_sign_loss)]
392 fn occupied(&self, b: [i32; 3]) -> bool {
393 if b[0] < 0
394 || b[0] >= self.nb[0]
395 || b[1] < 0
396 || b[1] >= self.nb[1]
397 || b[2] < 0
398 || b[2] >= self.nb[2]
399 {
400 return false;
401 }
402 let idx = ((b[2] * self.nb[1] + b[1]) * self.nb[0] + b[0]) as usize;
403 (self.bits[idx / 64] >> (idx % 64)) & 1 != 0
404 }
405
406 #[inline]
408 #[allow(clippy::cast_sign_loss)]
409 fn occupied_super(&self, s: [i32; 3]) -> bool {
410 if s[0] < 0
411 || s[0] >= self.ns[0]
412 || s[1] < 0
413 || s[1] >= self.ns[1]
414 || s[2] < 0
415 || s[2] >= self.ns[2]
416 {
417 return false;
418 }
419 let idx = ((s[2] * self.ns[1] + s[1]) * self.ns[0] + s[0]) as usize;
420 (self.super_bits[idx / 64] >> (idx % 64)) & 1 != 0
421 }
422}
423
424pub(crate) fn dda_setup(
430 origin: [f32; 3],
431 dir: [f32; 3],
432 cell: [i32; 3],
433 cell_size: f32,
434) -> ([i32; 3], [f32; 3], [f32; 3]) {
435 let mut step = [0i32; 3];
436 let mut t_max = [f32::INFINITY; 3];
437 let mut t_delta = [f32::INFINITY; 3];
438 for a in 0..3 {
439 if dir[a] > 1e-9 {
440 step[a] = 1;
441 #[allow(clippy::cast_precision_loss)]
442 let boundary = (cell[a] + 1) as f32 * cell_size;
443 t_max[a] = (boundary - origin[a]) / dir[a];
444 t_delta[a] = cell_size / dir[a];
445 } else if dir[a] < -1e-9 {
446 step[a] = -1;
447 #[allow(clippy::cast_precision_loss)]
448 let boundary = cell[a] as f32 * cell_size;
449 t_max[a] = (boundary - origin[a]) / dir[a];
450 t_delta[a] = -cell_size / dir[a];
451 }
452 }
453 (step, t_max, t_delta)
454}
455
456#[inline]
459pub(crate) fn min_axis(t_max: [f32; 3]) -> usize {
460 if t_max[0] <= t_max[1] && t_max[0] <= t_max[2] {
461 0
462 } else if t_max[1] <= t_max[2] {
463 1
464 } else {
465 2
466 }
467}
468
469#[derive(Debug, Default)]
479pub struct BrickCache {
480 maps: HashMap<(i32, i32, i32, u32), (u64, BrickMap)>,
481}
482
483impl BrickCache {
484 #[must_use]
485 pub fn new() -> Self {
486 Self::default()
487 }
488
489 pub fn ensure(&mut self, chunk: [i32; 3], mip: u32, version: u64, view: &GridView<'_>) {
492 let key = (chunk[0], chunk[1], chunk[2], mip);
493 let stale = self.maps.get(&key).map_or(true, |(v, _)| *v != version);
494 if stale {
495 self.maps.insert(key, (version, BrickMap::build(view, mip)));
496 }
497 }
498
499 #[inline]
500 fn get(&self, chunk: [i32; 3], mip: u32) -> Option<&BrickMap> {
501 self.maps
502 .get(&(chunk[0], chunk[1], chunk[2], mip))
503 .map(|(_, m)| m)
504 }
505
506 pub fn retain_chunks(&mut self, keep: impl Fn([i32; 3]) -> bool) {
509 self.maps.retain(|k, _| keep([k.0, k.1, k.2]));
510 }
511}
512
513#[allow(clippy::cast_possible_wrap)]
518fn local_cache(grid: &GridView<'_>, requested_mip: u32) -> (BrickCache, u32) {
519 let mip = effective_mip(grid, requested_mip);
520 let mut cache = BrickCache::new();
521 if let Some(cg) = grid.chunk_grid {
522 for dz in 0..cg.chunks_z as i32 {
523 for dy in 0..cg.chunks_y as i32 {
524 for dx in 0..cg.chunks_x as i32 {
525 let slot = ((dz * cg.chunks_y as i32 + dy) * cg.chunks_x as i32 + dx) as usize;
526 if let Some(Some(view)) = cg.chunks.get(slot) {
527 let ch = [
528 cg.origin_chunk_xy[0] + dx,
529 cg.origin_chunk_xy[1] + dy,
530 cg.origin_chunk_z + dz,
531 ];
532 cache.ensure(ch, mip, 0, view);
533 }
534 }
535 }
536 }
537 } else {
538 cache.ensure([0, 0, 0], mip, 0, grid);
539 }
540 (cache, mip)
541}
542
543#[must_use]
548pub fn effective_mip(grid: &GridView<'_>, requested: u32) -> u32 {
549 if requested == 0 {
550 return 0;
551 }
552 let mut m = requested;
553 if let Some(cg) = grid.chunk_grid {
554 for c in cg.chunks.iter().flatten() {
555 m = m.min(c.mip_count().saturating_sub(1));
556 }
557 } else {
558 m = m.min(grid.mip_count().saturating_sub(1));
559 }
560 m
561}
562
563struct Sampler<'a> {
577 grid: GridView<'a>,
578 bricks: &'a BrickCache,
579 mip: u32,
582 xy_shift: u32,
591 xy_mask: i32,
592 z_shift: u32,
593 z_mask: i32,
594 cur_ch: [i32; 3],
595 cur_view: Option<GridView<'a>>,
596 cur_brick: Option<&'a BrickMap>,
597 has_cur: bool,
598}
599
600impl<'a> Sampler<'a> {
601 fn new(grid: GridView<'a>, bricks: &'a BrickCache, mip: u32) -> Self {
602 let cs_xy = (grid.chunk_size_xy >> mip).max(1);
603 let cs_z = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1);
604 debug_assert!(
605 cs_xy.is_power_of_two() && cs_z.is_power_of_two(),
606 "chunk dims must be powers of two for the shift/mask split"
607 );
608 #[allow(clippy::cast_possible_wrap)]
609 Self {
610 grid,
611 bricks,
612 mip,
613 xy_shift: cs_xy.trailing_zeros(),
614 xy_mask: cs_xy as i32 - 1,
615 z_shift: cs_z.trailing_zeros(),
616 z_mask: cs_z as i32 - 1,
617 cur_ch: [0; 3],
618 cur_view: None,
619 cur_brick: None,
620 has_cur: false,
621 }
622 }
623
624 fn select_chunk(&mut self, ch: [i32; 3]) {
626 if self.has_cur && self.cur_ch == ch {
627 return;
628 }
629 self.cur_view = self.grid.chunk_at_xyz(ch);
630 self.cur_brick = self.bricks.get(ch, self.mip);
631 self.cur_ch = ch;
632 self.has_cur = true;
633 }
634
635 #[allow(clippy::cast_sign_loss)]
640 fn locate(&self, c: [i32; 3]) -> ([i32; 3], [u32; 3]) {
641 let ch = [
642 c[0] >> self.xy_shift,
643 c[1] >> self.xy_shift,
644 c[2] >> self.z_shift,
645 ];
646 let loc = [
647 (c[0] & self.xy_mask) as u32,
648 (c[1] & self.xy_mask) as u32,
649 (c[2] & self.z_mask) as u32,
650 ];
651 (ch, loc)
652 }
653
654 #[allow(clippy::cast_possible_wrap)]
658 fn hit(&mut self, c: [i32; 3]) -> Option<u32> {
659 #[cfg(test)]
660 prof::SURF.with(|x| x.set(x.get() + 1));
661 let (ch, loc) = self.locate(c);
662 self.select_chunk(ch);
663 let occupied = self.cur_brick.is_some_and(|bm| {
664 bm.occupied([
665 loc[0] as i32 / BRICK,
666 loc[1] as i32 / BRICK,
667 loc[2] as i32 / BRICK,
668 ])
669 });
670 if !occupied {
671 return None;
672 }
673 self.cur_view?
674 .surface_color_mip(loc[0], loc[1], loc[2], self.mip)
675 }
676
677 #[inline]
679 fn cells_per_chunk_xy(&self) -> i32 {
680 1 << self.xy_shift
681 }
682 #[inline]
683 fn cells_per_chunk_z(&self) -> i32 {
684 1 << self.z_shift
685 }
686
687 #[allow(clippy::cast_sign_loss)]
692 fn brick_occupied(&mut self, brick: [i32; 3]) -> bool {
693 let c0 = [brick[0] << 3, brick[1] << 3, brick[2] << 3];
695 let ch = [
696 c0[0] >> self.xy_shift,
697 c0[1] >> self.xy_shift,
698 c0[2] >> self.z_shift,
699 ];
700 self.select_chunk(ch);
701 self.cur_brick.is_some_and(|bm| {
702 bm.occupied([
703 (c0[0] & self.xy_mask) >> 3,
704 (c0[1] & self.xy_mask) >> 3,
705 (c0[2] & self.z_mask) >> 3,
706 ])
707 })
708 }
709
710 #[allow(clippy::cast_sign_loss)]
715 fn super_occupied(&mut self, s: [i32; 3]) -> bool {
716 let c0 = [s[0] << 6, s[1] << 6, s[2] << 6];
718 let ch = [
719 c0[0] >> self.xy_shift,
720 c0[1] >> self.xy_shift,
721 c0[2] >> self.z_shift,
722 ];
723 self.select_chunk(ch);
724 self.cur_brick.is_some_and(|bm| {
725 bm.occupied_super([
726 (c0[0] & self.xy_mask) >> 6,
727 (c0[1] & self.xy_mask) >> 6,
728 (c0[2] & self.z_mask) >> 6,
729 ])
730 })
731 }
732}
733
734#[allow(
755 clippy::too_many_arguments,
756 clippy::cast_possible_truncation,
757 clippy::cast_sign_loss,
758 clippy::cast_precision_loss
759)]
760fn cell_walk_skip(
761 origin: [f32; 3],
762 dir: [f32; 3],
763 fwd_dot: f32,
764 sampler: &mut Sampler<'_>,
765 lo_c: [i32; 3],
766 hi_c: [i32; 3],
767 cell_size: f32,
768 t_enter: f32,
769 t_exit: f32,
770 max_dist: f32,
771 env: &DdaEnv<'_>,
772) -> Option<Hit> {
773 let has_super = sampler.cells_per_chunk_xy() >= SUPER && sampler.cells_per_chunk_z() >= SUPER;
774 let has_brick = sampler.cells_per_chunk_xy() >= BRICK && sampler.cells_per_chunk_z() >= BRICK;
775
776 let start = t_enter + 1e-4;
777 let p = [
778 origin[0] + dir[0] * start,
779 origin[1] + dir[1] * start,
780 origin[2] + dir[2] * start,
781 ];
782 let mut cellc = [
783 ((p[0] / cell_size).floor() as i32).clamp(lo_c[0], hi_c[0] - 1),
784 ((p[1] / cell_size).floor() as i32).clamp(lo_c[1], hi_c[1] - 1),
785 ((p[2] / cell_size).floor() as i32).clamp(lo_c[2], hi_c[2] - 1),
786 ];
787 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cell_size);
788 let inv = [
792 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
793 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
794 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
795 ];
796 let mut t_curr = t_enter;
797 let mut last_axis = 3usize;
798
799 let span = (hi_c[0] - lo_c[0]) + (hi_c[1] - lo_c[1]) + (hi_c[2] - lo_c[2]);
802 let max_steps = span.max(0) as usize + 16;
803 for _ in 0..max_steps {
804 if cellc[0] < lo_c[0]
805 || cellc[0] >= hi_c[0]
806 || cellc[1] < lo_c[1]
807 || cellc[1] >= hi_c[1]
808 || cellc[2] < lo_c[2]
809 || cellc[2] >= hi_c[2]
810 {
811 return None;
812 }
813 let depth = t_curr * fwd_dot;
814 if depth > max_dist || t_curr > t_exit {
815 return None;
816 }
817 if env.fog_max_dist > 0.0 && depth >= env.fog_max_dist {
823 return Some(Hit {
824 color: 0x8000_0000 | (env.fog_color & 0x00ff_ffff),
825 dist: env.fog_max_dist,
826 });
827 }
828
829 let skip_shift = if has_super
832 && !sampler.super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
833 {
834 Some(6u32)
835 } else if has_brick
836 && !sampler.brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
837 {
838 Some(3u32)
839 } else {
840 None
841 };
842 if let Some(sh) = skip_shift {
843 #[cfg(test)]
844 prof::BRICKS.with(|x| x.set(x.get() + 1));
845 let mut best_t = f32::INFINITY;
847 let mut best_axis = 3usize;
848 let mut plane = [0i32; 3];
849 for a in 0..3 {
850 if step[a] == 0 {
851 continue;
852 }
853 let idx = cellc[a] >> sh;
854 plane[a] = if step[a] > 0 {
855 (idx + 1) << sh
856 } else {
857 idx << sh
858 };
859 let tb = (plane[a] as f32 * cell_size - origin[a]) * inv[a];
860 if tb < best_t {
861 best_t = tb;
862 best_axis = a;
863 }
864 }
865 if best_axis == 3 {
866 return None;
867 }
868 let pb = [
873 origin[0] + dir[0] * (best_t + 1e-4),
874 origin[1] + dir[1] * (best_t + 1e-4),
875 origin[2] + dir[2] * (best_t + 1e-4),
876 ];
877 let mut nc = [
878 (pb[0] / cell_size).floor() as i32,
879 (pb[1] / cell_size).floor() as i32,
880 (pb[2] / cell_size).floor() as i32,
881 ];
882 nc[best_axis] = if step[best_axis] > 0 {
883 plane[best_axis]
884 } else {
885 plane[best_axis] - 1
886 };
887 if nc[0] < lo_c[0]
891 || nc[0] >= hi_c[0]
892 || nc[1] < lo_c[1]
893 || nc[1] >= hi_c[1]
894 || nc[2] < lo_c[2]
895 || nc[2] >= hi_c[2]
896 {
897 return None;
898 }
899 cellc = nc;
900 for a in 0..3 {
903 if step[a] > 0 {
904 t_max[a] = ((cellc[a] + 1) as f32 * cell_size - origin[a]) * inv[a];
905 } else if step[a] < 0 {
906 t_max[a] = (cellc[a] as f32 * cell_size - origin[a]) * inv[a];
907 }
908 }
909 t_curr = best_t.max(t_curr);
910 last_axis = best_axis;
911 continue;
912 }
913
914 #[cfg(test)]
916 prof::CELLS.with(|x| x.set(x.get() + 1));
917 if let Some(color) = sampler.hit(cellc) {
918 let bright_sub = side_shade_sub(env, last_axis, step);
919 let lit = shade(color, bright_sub);
920 return Some(Hit {
921 color: apply_fog(lit, depth.max(0.0), env),
922 dist: depth.max(0.0),
923 });
924 }
925 let axis = min_axis(t_max);
926 last_axis = axis;
927 t_curr = t_max[axis];
928 cellc[axis] += step[axis];
929 t_max[axis] += t_delta[axis];
930 }
931 None
932}
933
934#[inline]
940fn side_shade_sub(env: &DdaEnv<'_>, axis: usize, step: [i32; 3]) -> u32 {
941 if axis >= 3 {
942 return 0;
943 }
944 let face = axis * 2 + usize::from(step[axis] < 0);
945 env.side_shades[face].max(0) as u32
946}
947
948fn cast_ray(
957 origin: [f32; 3],
958 dir: [f32; 3],
959 forward: [f32; 3],
960 sampler: &mut Sampler<'_>,
961 settings: &OpticastSettings,
962 env: &DdaEnv<'_>,
963) -> Option<Hit> {
964 let (lo_i, hi_i) = sampler.grid.voxel_bounds();
965 #[allow(clippy::cast_precision_loss)]
966 let lo_f = [lo_i[0] as f32, lo_i[1] as f32, lo_i[2] as f32];
967 #[allow(clippy::cast_precision_loss)]
968 let hi_f = [hi_i[0] as f32, hi_i[1] as f32, hi_i[2] as f32];
969 let (t_enter, t_exit) = intersect_aabb(origin, dir, lo_f, hi_f)?;
970 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
971 #[allow(clippy::cast_precision_loss)]
972 let max_dist = settings.max_scan_dist.max(1) as f32;
973 let cell = 1i32 << sampler.mip;
974 let cell_size = cell as f32;
975 let lo_c = [
976 lo_i[0].div_euclid(cell),
977 lo_i[1].div_euclid(cell),
978 lo_i[2].div_euclid(cell),
979 ];
980 let hi_c = [
981 hi_i[0].div_euclid(cell),
982 hi_i[1].div_euclid(cell),
983 hi_i[2].div_euclid(cell),
984 ];
985 cell_walk_skip(
986 origin, dir, fwd_dot, sampler, lo_c, hi_c, cell_size, t_enter, t_exit, max_dist, env,
987 )
988}
989
990pub fn render_dda(
1003 camera: &Camera,
1004 settings: &OpticastSettings,
1005 grid: GridView<'_>,
1006 pitch_pixels: usize,
1007 env: &DdaEnv<'_>,
1008 mip: u32,
1009 sink: &mut impl PixelSink,
1010) {
1011 let cs = camera_math::derive(
1012 camera,
1013 settings.xres,
1014 settings.yres,
1015 settings.hx,
1016 settings.hy,
1017 settings.hz,
1018 );
1019
1020 let (cache, mip) = local_cache(&grid, mip);
1023 let mut sampler = Sampler::new(grid, &cache, mip);
1024
1025 for py in settings.y_start..settings.y_end {
1026 let row = py as usize * pitch_pixels;
1027 for px in 0..settings.xres {
1028 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py) {
1029 sink.put(row + px as usize, color, dist);
1030 }
1031 }
1032 }
1033}
1034
1035#[inline]
1040fn pixel_result(
1041 cs: &CameraState,
1042 settings: &OpticastSettings,
1043 sampler: &mut Sampler<'_>,
1044 env: &DdaEnv<'_>,
1045 px: u32,
1046 py: u32,
1047) -> Option<(u32, f32)> {
1048 let (origin, dir) = pixel_ray(cs, settings, px, py);
1049 if let Some(hit) = cast_ray(origin, dir, cs.forward, sampler, settings, env) {
1050 Some((hit.color, hit.dist))
1051 } else {
1052 env.sky.map(|sky| (sample_sky(sky, dir), f32::INFINITY))
1053 }
1054}
1055
1056#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
1071pub fn render_dda_parallel(
1072 camera: &Camera,
1073 settings: &OpticastSettings,
1074 grid: GridView<'_>,
1075 fb: &mut [u32],
1076 zb: &mut [f32],
1077 pitch_pixels: usize,
1078 env: &DdaEnv<'_>,
1079 cache: &BrickCache,
1080 mip: u32,
1081) {
1082 debug_assert_eq!(fb.len(), zb.len());
1083 let (y0, y1) = (settings.y_start, settings.y_end);
1084 if y1 <= y0 {
1085 return;
1086 }
1087 let cs = camera_math::derive(
1088 camera,
1089 settings.xres,
1090 settings.yres,
1091 settings.hx,
1092 settings.hy,
1093 settings.hz,
1094 );
1095 let target = RasterTarget::new(fb, zb);
1096
1097 let nthreads = rayon::current_num_threads().max(1);
1099 let rows = (y1 - y0) as usize;
1100 let band = rows.div_ceil(nthreads).max(1) as u32;
1101 let bands: Vec<(u32, u32)> = (y0..y1)
1102 .step_by(band as usize)
1103 .map(|s| (s, (s + band).min(y1)))
1104 .collect();
1105
1106 bands.par_iter().for_each(|&(by0, by1)| {
1107 let mut sampler = Sampler::new(grid, cache, mip);
1108 for py in by0..by1 {
1109 let row = py as usize * pitch_pixels;
1110 for px in 0..settings.xres {
1111 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py)
1112 {
1113 let idx = row + px as usize;
1114 unsafe {
1118 target.write_color(idx, color);
1119 target.write_depth(idx, dist);
1120 }
1121 }
1122 }
1123 }
1124 });
1125}
1126
1127#[cfg(test)]
1133#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
1134fn cast_ray_reference(
1135 origin: [f32; 3],
1136 dir: [f32; 3],
1137 forward: [f32; 3],
1138 grid: &GridView<'_>,
1139 settings: &OpticastSettings,
1140) -> Option<Hit> {
1141 let nx = grid.vsid as f32;
1142 let nz = f32::from(u16::try_from(crate::grid_view::CHUNK_SIZE_Z).unwrap_or(256));
1143 #[allow(clippy::cast_possible_wrap)]
1144 let n_i = [
1145 grid.vsid as i32,
1146 grid.vsid as i32,
1147 crate::grid_view::CHUNK_SIZE_Z as i32,
1148 ];
1149 let (t_enter, t_exit) = intersect_aabb(origin, dir, [0.0; 3], [nx, nx, nz])?;
1150 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1151 let max_dist = settings.max_scan_dist.max(1) as f32;
1152
1153 let start = t_enter + 1e-4;
1154 let p = [
1155 origin[0] + dir[0] * start,
1156 origin[1] + dir[1] * start,
1157 origin[2] + dir[2] * start,
1158 ];
1159 let mut voxel = [
1160 (p[0].floor() as i32).clamp(0, n_i[0] - 1),
1161 (p[1].floor() as i32).clamp(0, n_i[1] - 1),
1162 (p[2].floor() as i32).clamp(0, n_i[2] - 1),
1163 ];
1164 let (step, mut t_max, t_delta) = dda_setup(origin, dir, voxel, 1.0);
1165 let mut t_curr = t_enter;
1166 let max_steps = (n_i[0] + n_i[1] + n_i[2]) as usize + 8;
1167 for _ in 0..max_steps {
1168 if voxel[0] < 0
1169 || voxel[0] >= n_i[0]
1170 || voxel[1] < 0
1171 || voxel[1] >= n_i[1]
1172 || voxel[2] < 0
1173 || voxel[2] >= n_i[2]
1174 {
1175 return None;
1176 }
1177 let depth = t_curr * fwd_dot;
1178 if depth > max_dist || t_curr > t_exit {
1179 return None;
1180 }
1181 #[allow(clippy::cast_sign_loss)]
1182 if let Some(color) = grid.surface_color(voxel[0] as u32, voxel[1] as u32, voxel[2] as u32) {
1183 return Some(Hit {
1184 color: shade(color, 0),
1185 dist: depth.max(0.0),
1186 });
1187 }
1188 let axis = min_axis(t_max);
1189 t_curr = t_max[axis];
1190 voxel[axis] += step[axis];
1191 t_max[axis] += t_delta[axis];
1192 }
1193 None
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198 use super::*;
1199
1200 #[derive(Default)]
1202 struct Recorder {
1203 puts: Vec<(usize, u32, f32)>,
1204 }
1205 impl PixelSink for Recorder {
1206 fn put(&mut self, idx: usize, color: u32, dist: f32) {
1207 self.puts.push((idx, color, dist));
1208 }
1209 }
1210
1211 fn oracle_camera() -> Camera {
1212 Camera {
1214 pos: [0.0, 0.0, 0.0],
1215 right: [1.0, 0.0, 0.0],
1216 down: [0.0, 0.0, 1.0],
1217 forward: [0.0, 1.0, 0.0],
1218 }
1219 }
1220
1221 fn render_mask(grid: GridView<'_>, camera: &Camera, w: u32, h: u32) -> Vec<bool> {
1224 let n = (w as usize) * (h as usize);
1225 let mut fb = vec![0u32; n]; let mut zb = vec![f32::INFINITY; n];
1227 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
1228 {
1229 let mut sink = RasterSink::new(&mut fb, &mut zb);
1230 render_dda(
1231 camera,
1232 &settings,
1233 grid,
1234 w as usize,
1235 &DdaEnv::default(),
1236 0,
1237 &mut sink,
1238 );
1239 }
1240 fb.iter().map(|&c| c != 0).collect()
1241 }
1242
1243 fn rows_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
1248 let w = w as usize;
1249 for y in 0..h as usize {
1250 let row = &mask[y * w..(y + 1) * w];
1251 let first = row.iter().position(|&b| b);
1252 let last = row.iter().rposition(|&b| b);
1253 if let (Some(f), Some(l)) = (first, last) {
1254 if row[f..=l].iter().any(|&b| !b) {
1255 return false;
1256 }
1257 }
1258 }
1259 true
1260 }
1261
1262 fn cols_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
1264 let w = w as usize;
1265 let h = h as usize;
1266 for x in 0..w {
1267 let col: Vec<bool> = (0..h).map(|y| mask[y * w + x]).collect();
1268 let first = col.iter().position(|&b| b);
1269 let last = col.iter().rposition(|&b| b);
1270 if let (Some(f), Some(l)) = (first, last) {
1271 if col[f..=l].iter().any(|&b| !b) {
1272 return false;
1273 }
1274 }
1275 }
1276 true
1277 }
1278
1279 #[test]
1282 fn center_pixel_ray_is_forward() {
1283 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
1284 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
1285 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1287 let (origin, dir) = pixel_ray(&cs, &settings, settings.hx as u32, settings.hy as u32);
1288 assert_eq!(origin, [0.0, 0.0, 0.0]);
1289 assert_eq!(
1291 dir.map(f32::to_bits),
1292 [0.0f32, 320.0, 0.0].map(f32::to_bits)
1293 );
1294 }
1295
1296 #[test]
1300 fn corner_pixel_ray_matches_camera_corn0() {
1301 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
1302 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
1303 let (_origin, dir) = pixel_ray(&cs, &settings, 0, 0);
1304 assert_eq!(dir.map(f32::to_bits), cs.corn[0].map(f32::to_bits));
1305 }
1306
1307 #[test]
1313 fn gridview_voxel_color_matches_reference() {
1314 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |x, _, z| {
1316 let lo = (10..=12).contains(&z);
1317 let hi = (40..=42).contains(&z);
1318 (lo || hi).then_some(0x80_10_20_30 + x)
1319 });
1320 let grid = GridView::from_single_vxl(&vxl);
1321 for x in 0..8 {
1322 for y in 0..8 {
1323 for z in 0..64 {
1324 assert_eq!(
1325 grid.voxel_color(x, y, z),
1326 vxl.voxel_color(x, y, z),
1327 "mismatch at ({x},{y},{z})"
1328 );
1329 }
1330 }
1331 }
1332 }
1333
1334 #[test]
1336 fn empty_grid_no_hits() {
1337 let vxl = roxlap_formats::vxl::Vxl::empty(64);
1338 let grid = GridView::from_single_vxl(&vxl);
1339 let settings = OpticastSettings::for_oracle_framebuffer(64, 48);
1340 let mut rec = Recorder::default();
1341 render_dda(
1342 &oracle_camera(),
1343 &settings,
1344 grid,
1345 64,
1346 &DdaEnv::default(),
1347 0,
1348 &mut rec,
1349 );
1350 assert!(rec.puts.is_empty(), "all-air grid must produce no hits");
1351 }
1352
1353 #[test]
1357 fn floor_seen_from_above() {
1358 const FLOOR_Z: u32 = 40;
1359 const FLOOR_COL: u32 = 0x80_30_60_90;
1360 let vxl =
1361 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= FLOOR_Z).then_some(FLOOR_COL));
1362 let grid = GridView::from_single_vxl(&vxl);
1363
1364 let cam = Camera {
1366 pos: [16.0, 16.0, 10.0],
1367 right: [1.0, 0.0, 0.0],
1368 down: [0.0, 1.0, 0.0],
1369 forward: [0.0, 0.0, 1.0],
1370 };
1371 let settings = OpticastSettings::for_oracle_framebuffer(48, 48);
1372 let mut rec = Recorder::default();
1373 render_dda(&cam, &settings, grid, 48, &DdaEnv::default(), 0, &mut rec);
1374
1375 assert!(!rec.puts.is_empty(), "floor must be visible");
1376 let centre = 24usize * 48 + 24;
1378 let hit = rec
1379 .puts
1380 .iter()
1381 .find(|(idx, _, _)| *idx == centre)
1382 .expect("centre ray must hit the floor");
1383 assert_eq!(hit.1 & 0x00ff_ffff, FLOOR_COL & 0x00ff_ffff);
1384 let expected = (FLOOR_Z as f32) - 10.0;
1385 assert!(
1386 (hit.2 - expected).abs() < 1.5,
1387 "centre depth {} not ≈ {}",
1388 hit.2,
1389 expected
1390 );
1391 }
1392
1393 #[test]
1398 fn horizon_splits_sky_and_floor() {
1399 const FLOOR_Z: u32 = 40;
1400 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
1401 (z >= FLOOR_Z).then_some(0x80_44_66_88)
1402 });
1403 let grid = GridView::from_single_vxl(&vxl);
1404
1405 let cam = Camera {
1409 pos: [32.0, 4.0, 30.0],
1410 right: [-1.0, 0.0, 0.0],
1411 down: [0.0, 0.0, 1.0],
1412 forward: [0.0, 1.0, 0.0],
1413 };
1414 let (w, h) = (64u32, 64u32);
1415 let mask = render_mask(grid, &cam, w, h);
1416
1417 let count_band = |y0: usize, y1: usize| -> usize {
1418 (y0 * w as usize..y1 * w as usize)
1419 .filter(|&i| mask[i])
1420 .count()
1421 };
1422 let top = count_band(0, h as usize / 4);
1423 let bottom = count_band(3 * h as usize / 4, h as usize);
1424 assert!(mask.iter().any(|&b| b), "floor must be visible");
1425 assert!(mask.iter().any(|&b| !b), "sky must be visible");
1426 assert!(
1427 bottom > top,
1428 "bottom band ({bottom}) should hit more floor than top band ({top})"
1429 );
1430 }
1431
1432 fn render_reference(
1435 grid: GridView<'_>,
1436 camera: &Camera,
1437 w: u32,
1438 h: u32,
1439 ) -> (Vec<u32>, Vec<f32>) {
1440 let n = (w as usize) * (h as usize);
1441 let mut fb = vec![0u32; n];
1442 let mut zb = vec![f32::INFINITY; n];
1443 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
1444 let cs = camera_math::derive(camera, w, h, settings.hx, settings.hy, settings.hz);
1445 for py in 0..h {
1446 for px in 0..w {
1447 let (o, d) = pixel_ray(&cs, &settings, px, py);
1448 if let Some(hit) = cast_ray_reference(o, d, cs.forward, &grid, &settings) {
1449 let i = (py * w + px) as usize;
1450 fb[i] = hit.color;
1451 zb[i] = hit.dist;
1452 }
1453 }
1454 }
1455 (fb, zb)
1456 }
1457
1458 fn render_brickmap(
1460 grid: GridView<'_>,
1461 camera: &Camera,
1462 w: u32,
1463 h: u32,
1464 ) -> (Vec<u32>, Vec<f32>) {
1465 render_brickmap_env(grid, camera, w, h, &DdaEnv::default())
1466 }
1467
1468 fn render_brickmap_env(
1471 grid: GridView<'_>,
1472 camera: &Camera,
1473 w: u32,
1474 h: u32,
1475 env: &DdaEnv<'_>,
1476 ) -> (Vec<u32>, Vec<f32>) {
1477 let n = (w as usize) * (h as usize);
1478 let mut fb = vec![0u32; n];
1479 let mut zb = vec![f32::INFINITY; n];
1480 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
1481 {
1482 let mut sink = RasterSink::new(&mut fb, &mut zb);
1483 render_dda(camera, &settings, grid, w as usize, env, 0, &mut sink);
1484 }
1485 (fb, zb)
1486 }
1487
1488 #[test]
1495 fn no_sky_leak_through_diagonal_wall() {
1496 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
1497 ((x + y == 64) && (2..62).contains(&z)).then_some(0x80_40_80_60)
1498 });
1499 let grid = GridView::from_single_vxl(&vxl);
1500 let (w, h) = (160u32, 160u32);
1501 let c = [10.0, 10.0, 32.0];
1502 let poses = [
1503 Camera::from_yaw_pitch(c, 0.785, 0.0),
1504 Camera::from_yaw_pitch(c, 0.6, 0.1),
1505 Camera::from_yaw_pitch(c, 0.95, -0.1),
1506 Camera::from_yaw_pitch(c, 0.785, 0.3),
1507 Camera::from_yaw_pitch(c, 0.5, 0.0),
1508 ];
1509 for (i, cam) in poses.iter().enumerate() {
1510 let (fb_b, _) = render_brickmap(grid, cam, w, h);
1511 let (fb_r, _) = render_reference(grid, cam, w, h);
1512 let leak = (0..(w * h) as usize)
1513 .filter(|&k| (fb_b[k] != 0) != (fb_r[k] != 0))
1514 .count();
1515 assert_eq!(leak, 0, "pose {i}: {leak} px diverge from dense reference");
1516 }
1517 }
1518
1519 #[test]
1522 fn distance_fog_blends_toward_fog_color() {
1523 let vxl =
1524 roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| (z >= 40).then_some(0x80_FF_FF_FF));
1525 let grid = GridView::from_single_vxl(&vxl);
1526 let cam = Camera {
1527 pos: [32.0, 2.0, 38.0],
1528 right: [1.0, 0.0, 0.0],
1529 down: [0.0, 0.0, 1.0],
1530 forward: [0.0, 1.0, 0.0],
1531 };
1532 let env = DdaEnv {
1533 sky: None,
1534 fog_color: 0x00_00_00_00, fog_max_dist: 64.0,
1536 side_shades: [0; 6],
1537 };
1538 let (w, h) = (64u32, 64u32);
1539 let (fog, _) = render_brickmap_env(grid, &cam, w, h, &env);
1540 let (nofog, zb) = render_brickmap(grid, &cam, w, h);
1541 let (idx, depth) = zb.iter().enumerate().filter(|(_, z)| z.is_finite()).fold(
1542 (0usize, 0.0f32),
1543 |acc, (i, &z)| {
1544 if z > acc.1 {
1545 (i, z)
1546 } else {
1547 acc
1548 }
1549 },
1550 );
1551 assert!(depth > 20.0, "need a deep pixel to test fog (got {depth})");
1552 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
1553 assert!(
1554 lum(fog[idx]) < lum(nofog[idx]),
1555 "fogged pixel {:08x} not darker than {:08x}",
1556 fog[idx],
1557 nofog[idx]
1558 );
1559 }
1560
1561 #[test]
1564 fn textured_sky_fills_misses() {
1565 let sky = crate::sky::Sky::blue_gradient();
1566 let vxl = roxlap_formats::vxl::Vxl::empty(32); let grid = GridView::from_single_vxl(&vxl);
1568 let env = DdaEnv {
1569 sky: Some(&sky),
1570 fog_color: 0,
1571 fog_max_dist: 0.0,
1572 side_shades: [0; 6],
1573 };
1574 let cam = Camera::from_yaw_pitch([16.0, 16.0, 128.0], 0.3, -0.4);
1575 let (w, h) = (48u32, 48u32);
1576 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
1577 assert!(fb.iter().all(|&c| c >> 24 == 0x80), "all misses sky-filled");
1578 let top = fb[0];
1579 let bottom = fb[(h - 1) as usize * w as usize];
1580 assert_ne!(top, bottom, "sky gradient should vary with elevation");
1581 }
1582
1583 #[test]
1587 fn side_shades_darken_hit_face() {
1588 let vxl =
1589 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x80_FF_FF_FF));
1590 let grid = GridView::from_single_vxl(&vxl);
1591 let cam = Camera {
1592 pos: [8.0, 8.0, 2.0],
1593 right: [1.0, 0.0, 0.0],
1594 down: [0.0, 1.0, 0.0],
1595 forward: [0.0, 0.0, 1.0],
1596 };
1597 let centre = 16 * 32 + 16;
1598 let (plain, _) = render_brickmap(grid, &cam, 32, 32);
1599 let env = DdaEnv {
1600 sky: None,
1601 fog_color: 0,
1602 fog_max_dist: 0.0,
1603 side_shades: [0, 0, 0, 0, 0x40, 0],
1604 };
1605 let (shaded, _) = render_brickmap_env(grid, &cam, 32, 32, &env);
1606 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
1607 assert!(
1608 lum(shaded[centre]) < lum(plain[centre]),
1609 "side-shaded face {:08x} not darker than {:08x}",
1610 shaded[centre],
1611 plain[centre]
1612 );
1613 }
1614
1615 #[test]
1625 fn brickmap_approximates_dense_reference() {
1626 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
1628 let surf = 30 + ((x / 5 + y / 7) % 11);
1629 let ground = z >= surf;
1630 let block = (20..=24).contains(&z) && (10..20).contains(&x) && (40..50).contains(&y);
1631 (ground || block).then_some(0x80_30_50_70 + (x ^ y) % 0x40)
1632 });
1633 let grid = GridView::from_single_vxl(&vxl);
1634
1635 let (w, h) = (80u32, 80u32);
1636 let poses = [
1637 Camera::orbit(0.6, 0.5, 90.0, [32.0, 32.0, 40.0]),
1638 Camera::orbit(2.1, 0.2, 70.0, [32.0, 32.0, 35.0]),
1639 Camera::orbit(-1.0, 0.9, 120.0, [32.0, 32.0, 45.0]),
1640 ];
1641 let n = (w * h) as usize;
1642 for (i, cam) in poses.iter().enumerate() {
1643 let (fb_b, zb_b) = render_brickmap(grid, cam, w, h);
1644 let (fb_r, _zb_r) = render_reference(grid, cam, w, h);
1645 let cov_b = fb_b.iter().filter(|&&c| c != 0).count();
1647 let cov_r = fb_r.iter().filter(|&&c| c != 0).count();
1648 assert!(cov_b > 200, "pose {i} rendered ~empty (cov {cov_b})");
1649 let cov_diff = cov_b.abs_diff(cov_r);
1650 assert!(
1651 cov_diff * 100 <= n, "pose {i} coverage diverged: brick {cov_b} vs dense {cov_r}"
1653 );
1654 let diffs = fb_b.iter().zip(&fb_r).filter(|(a, b)| a != b).count();
1656 assert!(
1657 diffs * 100 <= n * 3, "pose {i} too many pixel diffs vs dense: {diffs}/{n}"
1659 );
1660 for k in 0..n {
1662 if fb_b[k] != 0 {
1663 assert!(zb_b[k].is_finite(), "pose {i} px {k} non-finite depth");
1664 }
1665 }
1666 }
1667 }
1668
1669 #[test]
1673 fn baked_brightness_darkens_color() {
1674 let dim =
1676 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x40_FF_FF_FF));
1677 let grid = GridView::from_single_vxl(&dim);
1678 let cam = Camera {
1679 pos: [8.0, 8.0, 2.0],
1680 right: [1.0, 0.0, 0.0],
1681 down: [0.0, 1.0, 0.0],
1682 forward: [0.0, 0.0, 1.0],
1683 };
1684 let (fb, _) = render_brickmap(grid, &cam, 32, 32);
1685 let centre = 16 * 32 + 16;
1686 assert_eq!(fb[centre], 0x80_7F_7F_7F, "got {:08x}", fb[centre]);
1688
1689 let full =
1691 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x80_FF_FF_FF));
1692 let gridf = GridView::from_single_vxl(&full);
1693 let (fbf, _) = render_brickmap(gridf, &cam, 32, 32);
1694 assert_eq!(fbf[centre], 0x80_FF_FF_FF, "got {:08x}", fbf[centre]);
1695 }
1696
1697 #[test]
1704 fn cross_chunk_lookdown_sees_lower_stacked_floor() {
1705 const FLOOR_LOCAL_Z: u32 = 40;
1706 const FLOOR_COL: u32 = 0x80_22_88_44;
1707 let upper = roxlap_formats::vxl::Vxl::empty(32); let lower = roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
1709 (z >= FLOOR_LOCAL_Z).then_some(FLOOR_COL)
1710 });
1711 let v_up = GridView::from_single_vxl(&upper);
1712 let v_lo = GridView::from_single_vxl(&lower);
1713 let chunks = [Some(v_up), Some(v_lo)];
1715 let cg = crate::ChunkGrid {
1716 chunks: &chunks,
1717 origin_chunk_xy: [0, 0],
1718 origin_chunk_z: 0,
1719 chunks_x: 1,
1720 chunks_y: 1,
1721 chunks_z: 2,
1722 };
1723 let grid = GridView::from_chunk_grid(&cg, 32);
1724
1725 let cam = Camera {
1727 pos: [16.0, 16.0, 100.0],
1728 right: [1.0, 0.0, 0.0],
1729 down: [0.0, 1.0, 0.0],
1730 forward: [0.0, 0.0, 1.0],
1731 };
1732 let (w, h) = (48u32, 48u32);
1733 let (fb, zb) = render_brickmap(grid, &cam, w, h);
1734 let centre = 24 * 48 + 24;
1735 assert!(
1736 fb[centre] & 0x00ff_ffff == FLOOR_COL & 0x00ff_ffff,
1737 "centre ray must reach the lower-chunk floor (got {:08x})",
1738 fb[centre]
1739 );
1740 let expected = 296.0 - 100.0;
1742 assert!(
1743 (zb[centre] - expected).abs() < 2.0,
1744 "look-down depth {} not ≈ {expected}",
1745 zb[centre]
1746 );
1747 }
1748
1749 #[test]
1753 fn cross_chunk_xy_floor_is_seamless() {
1754 let mk = || {
1755 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= 20).then_some(0x80_50_50_50))
1756 };
1757 let (c0, c1) = (mk(), mk());
1758 let v0 = GridView::from_single_vxl(&c0);
1759 let v1 = GridView::from_single_vxl(&c1);
1760 let chunks = [Some(v0), Some(v1)];
1761 let cg = crate::ChunkGrid {
1762 chunks: &chunks,
1763 origin_chunk_xy: [0, 0],
1764 origin_chunk_z: 0,
1765 chunks_x: 2,
1766 chunks_y: 1,
1767 chunks_z: 1,
1768 };
1769 let grid = GridView::from_chunk_grid(&cg, 32);
1770
1771 let cam = Camera {
1773 pos: [32.0, 16.0, 4.0],
1774 right: [1.0, 0.0, 0.0],
1775 down: [0.0, 1.0, 0.0],
1776 forward: [0.0, 0.0, 1.0],
1777 };
1778 let (w, h) = (64u32, 64u32);
1779 let mask = render_mask(grid, &cam, w, h);
1780 let row = (h / 2) as usize * w as usize;
1783 let left = (0..w as usize / 2).filter(|&x| mask[row + x]).count();
1784 let right = (w as usize / 2..w as usize)
1785 .filter(|&x| mask[row + x])
1786 .count();
1787 assert!(
1788 left > 5 && right > 5,
1789 "seam not continuous: left={left} right={right}"
1790 );
1791 }
1792
1793 fn render_mask_mip(grid: GridView<'_>, camera: &Camera, w: u32, h: u32, mip: u32) -> Vec<bool> {
1796 let n = (w as usize) * (h as usize);
1797 let mut fb = vec![0u32; n];
1798 let mut zb = vec![f32::INFINITY; n];
1799 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
1800 {
1801 let mut sink = RasterSink::new(&mut fb, &mut zb);
1802 render_dda(
1803 camera,
1804 &settings,
1805 grid,
1806 w as usize,
1807 &DdaEnv::default(),
1808 mip,
1809 &mut sink,
1810 );
1811 }
1812 fb.iter().map(|&c| c != 0).collect()
1813 }
1814
1815 #[test]
1821 fn mip_render_is_coarse_but_complete() {
1822 let mut vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
1823 let surf = 24 + ((x / 3 + y / 5) % 17);
1824 (z >= surf).then_some(0x80_50_70_90)
1825 });
1826 vxl.generate_mips(4);
1827 assert!(vxl.mip_count() >= 3, "need mips built for this test");
1828 let grid = GridView::from_single_vxl(&vxl);
1829 let (w, h) = (96u32, 96u32);
1830 let cam = Camera::orbit(0.7, 0.6, 110.0, [32.0, 32.0, 36.0]);
1831
1832 let m0 = render_mask_mip(grid, &cam, w, h, 0);
1833 let m2 = render_mask_mip(grid, &cam, w, h, 2);
1834
1835 let c0 = m0.iter().filter(|&&b| b).count();
1836 let c2 = m2.iter().filter(|&&b| b).count();
1837 assert!(c0 > 200 && c2 > 200, "both mips visible (c0={c0} c2={c2})");
1838 let ratio = c2 as f32 / c0 as f32;
1844 assert!(
1845 (0.7..1.4).contains(&ratio),
1846 "mip-2 coverage {c2} vs mip-0 {c0} (ratio {ratio:.2}) diverged"
1847 );
1848 }
1849
1850 #[test]
1856 #[ignore = "perf benchmark — run explicitly with --ignored"]
1857 fn bench_terrain() {
1858 use std::time::Instant;
1859 const NC: i32 = 6;
1861 let cs = crate::grid_view::CHUNK_SIZE_Z; let _ = cs;
1863 let mut vxls: Vec<roxlap_formats::vxl::Vxl> = Vec::new();
1864 for cy in 0..NC {
1865 for cx in 0..NC {
1866 let (ox, oy) = (cx * 128, cy * 128);
1867 let mut v = roxlap_formats::vxl::Vxl::from_dense(128, |x, y, z| {
1868 let (gx, gy) = (ox + x as i32, oy + y as i32);
1869 let surf = 90 + ((gx / 7 + gy / 9).rem_euclid(40)) + ((gx / 23).rem_euclid(20));
1870 (z as i32 >= surf).then_some(0x80_50_70_90 + (x ^ y) % 0x30)
1871 });
1872 v.generate_mips(4);
1873 vxls.push(v);
1874 }
1875 }
1876 let views: Vec<Option<GridView>> = vxls
1877 .iter()
1878 .map(|v| Some(GridView::from_single_vxl(v)))
1879 .collect();
1880 let cg = crate::ChunkGrid {
1881 chunks: &views,
1882 origin_chunk_xy: [0, 0],
1883 origin_chunk_z: 0,
1884 chunks_x: NC as u32,
1885 chunks_y: NC as u32,
1886 chunks_z: 1,
1887 };
1888 let grid = GridView::from_chunk_grid(&cg, 128);
1889
1890 let (w, h) = (960u32, 600u32);
1891 let mut settings = OpticastSettings::for_oracle_framebuffer(w, h);
1892 settings.max_scan_dist = 512;
1893 let n = (w * h) as usize;
1894 let mut fb = vec![0u32; n];
1895 let mut zb = vec![f32::INFINITY; n];
1896 let centre = [f64::from(NC * 128) / 2.0, f64::from(NC * 128) / 2.0, 60.0];
1897
1898 let poses = [
1901 (
1902 "horizon",
1903 Camera::from_yaw_pitch([20.0, 20.0, 40.0], 0.6, 0.15),
1904 ),
1905 ("down", Camera::orbit(0.7, 1.0, 130.0, centre)),
1906 ];
1907 for (name, cam) in poses {
1908 {
1909 let mut sink = RasterSink::new(&mut fb, &mut zb);
1910 prof::reset();
1911 render_dda(
1912 &cam,
1913 &settings,
1914 grid,
1915 w as usize,
1916 &DdaEnv::default(),
1917 0,
1918 &mut sink,
1919 );
1920 }
1921 let (cells, bricks, surf) = prof::read();
1922 let iters = 6;
1923 let t0 = Instant::now();
1924 for _ in 0..iters {
1925 let mut sink = RasterSink::new(&mut fb, &mut zb);
1926 render_dda(
1927 &cam,
1928 &settings,
1929 grid,
1930 w as usize,
1931 &DdaEnv::default(),
1932 0,
1933 &mut sink,
1934 );
1935 }
1936 let ms = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(iters);
1937 let hits = fb.iter().filter(|&&c| c != 0).count();
1938 eprintln!(
1939 "[{name}] {w}x{h} 1-thread: {ms:.1} ms | hits={hits}/{n} | per-px: cells={:.1} bricks={:.1} surf={:.1}",
1940 cells as f64 / n as f64,
1941 bricks as f64 / n as f64,
1942 surf as f64 / n as f64,
1943 );
1944 }
1945 }
1946
1947 #[test]
1951 fn parallel_matches_sequential() {
1952 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
1953 let surf = 28 + ((x / 4 + y / 6) % 13);
1954 (z >= surf).then_some(0x80_40_60_80 + (x ^ y) % 0x30)
1955 });
1956 let grid = GridView::from_single_vxl(&vxl);
1957 let (w, h) = (96u32, 96u32);
1958 let cam = Camera::orbit(0.8, 0.55, 100.0, [32.0, 32.0, 40.0]);
1959 let env = DdaEnv {
1960 sky: None,
1961 fog_color: 0x00_20_30_40,
1962 fog_max_dist: 120.0,
1963 side_shades: [0, 0, 0, 0, 0x30, 0x10],
1964 };
1965
1966 let (seq_fb, seq_zb) = render_brickmap_env(grid, &cam, w, h, &env);
1967
1968 let n = (w * h) as usize;
1969 let mut par_fb = vec![0u32; n];
1970 let mut par_zb = vec![f32::INFINITY; n];
1971 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
1972 let (cache, mip) = local_cache(&grid, 0);
1973 render_dda_parallel(
1974 &cam,
1975 &settings,
1976 grid,
1977 &mut par_fb,
1978 &mut par_zb,
1979 w as usize,
1980 &env,
1981 &cache,
1982 mip,
1983 );
1984 assert!(par_fb == seq_fb, "parallel colour differs from sequential");
1985 assert!(
1986 par_zb
1987 .iter()
1988 .zip(&seq_zb)
1989 .all(|(a, b)| a.to_bits() == b.to_bits()),
1990 "parallel depth differs from sequential"
1991 );
1992 }
1993
1994 #[test]
2000 fn cliff_side_is_solid_not_see_through() {
2001 const TOP_Z: u32 = 50;
2002 const COL: u32 = 0x80_77_88_99;
2003 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |_, _, z| (z >= TOP_Z).then_some(COL));
2004 let grid = GridView::from_single_vxl(&vxl);
2005
2006 assert_eq!(grid.voxel_color(4, 4, TOP_Z), Some(COL));
2008 assert_eq!(grid.voxel_color(4, 4, 150), None);
2010 assert_eq!(grid.surface_color(4, 4, 150), Some(COL));
2013 assert_eq!(grid.surface_color(4, 4, 10), None);
2015 }
2016
2017 #[test]
2020 fn camera_inside_solid_hits_everywhere() {
2021 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, _| Some(0x80_55_55_55));
2022 let grid = GridView::from_single_vxl(&vxl);
2023 let cam = Camera {
2024 pos: [8.0, 8.0, 128.0],
2025 right: [1.0, 0.0, 0.0],
2026 down: [0.0, 1.0, 0.0],
2027 forward: [0.0, 0.0, 1.0],
2028 };
2029 let (w, h) = (32u32, 32u32);
2030 let mask = render_mask(grid, &cam, w, h);
2031 assert!(
2032 mask.iter().all(|&b| b),
2033 "every ray must hit when the camera is inside solid"
2034 );
2035 }
2036
2037 #[test]
2043 fn single_voxel_silhouette_has_no_notch() {
2044 const C: u32 = 0x80_FF_80_40;
2045 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |x, y, z| {
2046 (x == 8 && y == 8 && z == 8).then_some(C)
2047 });
2048 let grid = GridView::from_single_vxl(&vxl);
2049
2050 let cam = Camera::orbit(0.7, 0.6, 4.0, [8.5, 8.5, 8.5]);
2053 let (w, h) = (96u32, 96u32);
2054 let mask = render_mask(grid, &cam, w, h);
2055
2056 let hits = mask.iter().filter(|&&b| b).count();
2057 assert!(
2058 hits > 30,
2059 "silhouette too small to be meaningful: {hits} px"
2060 );
2061 assert!(
2062 rows_have_no_holes(&mask, w, h),
2063 "row-interior gap in single-voxel silhouette (notch)"
2064 );
2065 assert!(
2066 cols_have_no_holes(&mask, w, h),
2067 "column-interior gap in single-voxel silhouette (notch)"
2068 );
2069 }
2070}