1use std::collections::HashMap;
36
37use rayon::prelude::*;
38
39use crate::camera_math::{self, CameraState};
40use crate::grid_view::GridView;
41use crate::opticast::OpticastSettings;
42use crate::raster_target::RasterTarget;
43use crate::sky::Sky;
44use crate::Camera;
45use roxlap_formats::material::{material_for_color, Material, MaterialTable};
46
47#[derive(Clone, Copy)]
54pub struct DdaEnv<'a> {
55 pub sky: Option<&'a Sky>,
58 pub fog_color: u32,
61 pub fog_max_dist: f32,
63 pub side_shades: [i8; 6],
66 pub materials: Option<&'a MaterialTable>,
69 pub terrain_materials: &'a [(u32, u8)],
73}
74
75impl Default for DdaEnv<'_> {
76 fn default() -> Self {
77 Self {
78 sky: None,
79 fog_color: 0,
80 fog_max_dist: 0.0,
81 side_shades: [0; 6],
82 materials: None,
83 terrain_materials: &[],
84 }
85 }
86}
87
88pub trait PixelSink {
96 fn put(&mut self, idx: usize, color: u32, dist: f32);
100}
101
102pub struct RasterSink<'a> {
109 target: RasterTarget<'a>,
110 len: usize,
111}
112
113impl<'a> RasterSink<'a> {
114 #[must_use]
117 pub fn new(framebuffer: &'a mut [u32], zbuffer: &'a mut [f32]) -> Self {
118 debug_assert_eq!(framebuffer.len(), zbuffer.len());
119 let len = framebuffer.len();
120 Self {
121 target: RasterTarget::new(framebuffer, zbuffer),
122 len,
123 }
124 }
125}
126
127impl PixelSink for RasterSink<'_> {
128 fn put(&mut self, idx: usize, color: u32, dist: f32) {
129 if idx < self.len {
130 unsafe {
133 self.target.write_color(idx, color);
134 self.target.write_depth(idx, dist);
135 }
136 }
137 }
138}
139
140#[derive(Debug, Clone, Copy)]
142struct Hit {
143 color: u32,
144 dist: f32,
145}
146
147#[cfg(test)]
149pub(crate) mod prof {
150 use std::cell::Cell;
151 thread_local! {
152 pub static CELLS: Cell<u64> = const { Cell::new(0) };
153 pub static BRICKS: Cell<u64> = const { Cell::new(0) };
154 pub static SURF: Cell<u64> = const { Cell::new(0) };
155 }
156 pub fn reset() {
157 CELLS.with(|x| x.set(0));
158 BRICKS.with(|x| x.set(0));
159 SURF.with(|x| x.set(0));
160 }
161 pub fn read() -> (u64, u64, u64) {
162 (
163 CELLS.with(Cell::get),
164 BRICKS.with(Cell::get),
165 SURF.with(Cell::get),
166 )
167 }
168}
169
170#[inline]
189pub(crate) fn shade(color: u32, bright_sub: u32) -> u32 {
190 let a = ((color >> 24) & 0xff).saturating_sub(bright_sub);
191 let ch = |shift: u32| -> u32 { ((((color >> shift) & 0xff) * a) >> 7).min(255) };
192 0x8000_0000 | (ch(16) << 16) | (ch(8) << 8) | ch(0)
193}
194
195#[inline]
199fn apply_fog(color: u32, depth: f32, env: &DdaEnv<'_>) -> u32 {
200 if env.fog_max_dist <= 0.0 {
201 return color;
202 }
203 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
204 let f = ((depth / env.fog_max_dist).clamp(0.0, 1.0) * 256.0) as u32; let g = 256 - f;
206 let fog = env.fog_color;
207 let mix = |shift: u32| -> u32 {
208 let src = (color >> shift) & 0xff;
209 let dst = (fog >> shift) & 0xff;
210 ((src * g + dst * f) >> 8).min(255)
211 };
212 0x8000_0000 | (mix(16) << 16) | (mix(8) << 8) | mix(0)
213}
214
215#[inline]
220fn terrain_material(env: &DdaEnv<'_>, color: u32) -> Material {
221 match env.materials {
222 Some(table) if !env.terrain_materials.is_empty() => {
223 table.get(material_for_color(env.terrain_materials, color))
224 }
225 _ => Material::OPAQUE,
226 }
227}
228
229#[inline]
232fn composite_over(accum: [f32; 3], trans: f32, bg: u32) -> u32 {
233 let b = rgb_to_f32(bg);
234 f32_to_rgb([
235 accum[0] + trans * b[0],
236 accum[1] + trans * b[1],
237 accum[2] + trans * b[2],
238 ])
239}
240
241#[inline]
246fn finalize_exit(
247 touched: bool,
248 accum: [f32; 3],
249 trans: f32,
250 env: &DdaEnv<'_>,
251 dir: [f32; 3],
252 dist: f32,
253) -> Option<Hit> {
254 if !touched {
255 return None;
256 }
257 let bg = match env.sky {
258 Some(s) => sample_sky(s, dir),
259 None => 0x8000_0000 | (env.fog_color & 0x00ff_ffff),
260 };
261 Some(Hit {
262 color: composite_over(accum, trans, bg),
263 dist,
264 })
265}
266
267#[inline]
270#[allow(clippy::cast_precision_loss)]
271fn rgb_to_f32(c: u32) -> [f32; 3] {
272 [
273 ((c >> 16) & 0xff) as f32 / 255.0,
274 ((c >> 8) & 0xff) as f32 / 255.0,
275 (c & 0xff) as f32 / 255.0,
276 ]
277}
278
279#[inline]
281#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
282fn f32_to_rgb(c: [f32; 3]) -> u32 {
283 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
284 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
285}
286
287#[allow(
296 clippy::cast_possible_truncation,
297 clippy::cast_sign_loss,
298 clippy::cast_precision_loss
299)]
300fn sample_sky(sky: &Sky, dir: [f32; 3]) -> u32 {
301 let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
302 if len < 1e-9 {
303 return 0x8000_0000;
304 }
305 let d = [dir[0] / len, dir[1] / len, dir[2] / len];
306 let xsiz_full = sky.lat.len().max(1) as i32; let pi = std::f32::consts::PI;
308 let elev01 = (-d[2]).clamp(-1.0, 1.0).acos() / pi; let x = (elev01 * xsiz_full as f32) as i32;
313 let x = x.clamp(0, xsiz_full - 1);
314 let y = if sky.ysiz <= 1 {
316 0
317 } else {
318 let az = d[1].atan2(d[0]); let yf = ((az / (pi * 2.0)) + 0.5) * sky.ysiz as f32;
320 (yf as i32).rem_euclid(sky.ysiz)
321 };
322 let idx = (y * xsiz_full + x) as usize;
323 let px = sky.pixels.get(idx).copied().unwrap_or(0) as u32;
324 0x8000_0000 | (px & 0x00ff_ffff)
325}
326
327#[allow(clippy::cast_possible_truncation)]
337pub fn render_sky_fill(
338 fb: &mut [u32],
339 zb: &[f32],
340 pitch_pixels: usize,
341 width: u32,
342 height: u32,
343 cam: &CameraState,
344 settings: &OpticastSettings,
345 sky: &Sky,
346) {
347 for py in 0..height {
348 let row = py as usize * pitch_pixels;
349 for px in 0..width {
350 let idx = row + px as usize;
351 if zb[idx].is_finite() {
352 continue; }
354 let (_origin, dir) = pixel_ray(cam, settings, px, py);
355 fb[idx] = sample_sky(sky, dir);
356 }
357 }
358}
359
360#[must_use]
372pub fn pixel_ray(
373 cs: &CameraState,
374 settings: &OpticastSettings,
375 px: u32,
376 py: u32,
377) -> ([f32; 3], [f32; 3]) {
378 #[allow(clippy::cast_precision_loss)]
380 let sx = px as f32 - settings.hx;
381 #[allow(clippy::cast_precision_loss)]
382 let sy = py as f32 - settings.hy;
383 let dir = [
384 sx * cs.right[0] + sy * cs.down[0] + settings.hz * cs.forward[0],
385 sx * cs.right[1] + sy * cs.down[1] + settings.hz * cs.forward[1],
386 sx * cs.right[2] + sy * cs.down[2] + settings.hz * cs.forward[2],
387 ];
388 (cs.pos, dir)
389}
390
391pub(crate) fn intersect_aabb(
397 o: [f32; 3],
398 dir: [f32; 3],
399 lo: [f32; 3],
400 hi: [f32; 3],
401) -> Option<(f32, f32)> {
402 let mut t0 = 0.0f32;
403 let mut t1 = f32::INFINITY;
404 for a in 0..3 {
405 if dir[a].abs() < 1e-9 {
406 if o[a] < lo[a] || o[a] > hi[a] {
408 return None;
409 }
410 } else {
411 let inv = 1.0 / dir[a];
412 let mut ta = (lo[a] - o[a]) * inv;
413 let mut tb = (hi[a] - o[a]) * inv;
414 if ta > tb {
415 core::mem::swap(&mut ta, &mut tb);
416 }
417 t0 = t0.max(ta);
418 t1 = t1.min(tb);
419 if t0 > t1 {
420 return None;
421 }
422 }
423 }
424 Some((t0, t1))
425}
426
427const BRICK: i32 = 8;
429
430#[derive(Debug)]
444pub(crate) struct BrickMap {
445 nb: [i32; 3],
447 bits: Vec<u64>,
450 ns: [i32; 3],
453 super_bits: Vec<u64>,
458}
459
460const SUPER: i32 = BRICK * BRICK;
462
463impl BrickMap {
464 #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
467 fn build(grid: &GridView<'_>, mip: u32) -> Self {
468 let vsid_m = (grid.vsid >> mip).max(1) as i32;
469 let z_m = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1) as i32;
470 let nb = [
471 (vsid_m + BRICK - 1) / BRICK,
472 (vsid_m + BRICK - 1) / BRICK,
473 (z_m + BRICK - 1) / BRICK,
474 ];
475 let ns = [
476 (nb[0] + BRICK - 1) / BRICK,
477 (nb[1] + BRICK - 1) / BRICK,
478 (nb[2] + BRICK - 1) / BRICK,
479 ];
480 let count = (nb[0] * nb[1] * nb[2]) as usize;
481 let scount = (ns[0] * ns[1] * ns[2]) as usize;
482 let mut bits = vec![0u64; count.div_ceil(64)];
483 let mut super_bits = vec![0u64; scount.div_ceil(64)];
484 for y in 0..vsid_m {
485 for x in 0..vsid_m {
486 let (bx, by) = (x / BRICK, y / BRICK);
487 grid.for_each_run_mip(x as u32, y as u32, mip, |top, bot| {
488 for bz in (top / BRICK)..=((bot - 1) / BRICK) {
489 let idx = ((bz * nb[1] + by) * nb[0] + bx) as usize;
490 bits[idx / 64] |= 1u64 << (idx % 64);
491 let sidx =
492 (((bz / BRICK) * ns[1] + by / BRICK) * ns[0] + bx / BRICK) as usize;
493 super_bits[sidx / 64] |= 1u64 << (sidx % 64);
494 }
495 });
496 }
497 }
498 Self {
499 nb,
500 bits,
501 ns,
502 super_bits,
503 }
504 }
505
506 #[inline]
508 #[allow(clippy::cast_sign_loss)]
509 fn occupied(&self, b: [i32; 3]) -> bool {
510 if b[0] < 0
511 || b[0] >= self.nb[0]
512 || b[1] < 0
513 || b[1] >= self.nb[1]
514 || b[2] < 0
515 || b[2] >= self.nb[2]
516 {
517 return false;
518 }
519 let idx = ((b[2] * self.nb[1] + b[1]) * self.nb[0] + b[0]) as usize;
520 (self.bits[idx / 64] >> (idx % 64)) & 1 != 0
521 }
522
523 #[inline]
525 #[allow(clippy::cast_sign_loss)]
526 fn occupied_super(&self, s: [i32; 3]) -> bool {
527 if s[0] < 0
528 || s[0] >= self.ns[0]
529 || s[1] < 0
530 || s[1] >= self.ns[1]
531 || s[2] < 0
532 || s[2] >= self.ns[2]
533 {
534 return false;
535 }
536 let idx = ((s[2] * self.ns[1] + s[1]) * self.ns[0] + s[0]) as usize;
537 (self.super_bits[idx / 64] >> (idx % 64)) & 1 != 0
538 }
539}
540
541pub(crate) fn dda_setup(
547 origin: [f32; 3],
548 dir: [f32; 3],
549 cell: [i32; 3],
550 cell_size: f32,
551) -> ([i32; 3], [f32; 3], [f32; 3]) {
552 let mut step = [0i32; 3];
553 let mut t_max = [f32::INFINITY; 3];
554 let mut t_delta = [f32::INFINITY; 3];
555 for a in 0..3 {
556 if dir[a] > 1e-9 {
557 step[a] = 1;
558 #[allow(clippy::cast_precision_loss)]
559 let boundary = (cell[a] + 1) as f32 * cell_size;
560 t_max[a] = (boundary - origin[a]) / dir[a];
561 t_delta[a] = cell_size / dir[a];
562 } else if dir[a] < -1e-9 {
563 step[a] = -1;
564 #[allow(clippy::cast_precision_loss)]
565 let boundary = cell[a] as f32 * cell_size;
566 t_max[a] = (boundary - origin[a]) / dir[a];
567 t_delta[a] = -cell_size / dir[a];
568 }
569 }
570 (step, t_max, t_delta)
571}
572
573#[inline]
576pub(crate) fn min_axis(t_max: [f32; 3]) -> usize {
577 if t_max[0] <= t_max[1] && t_max[0] <= t_max[2] {
578 0
579 } else if t_max[1] <= t_max[2] {
580 1
581 } else {
582 2
583 }
584}
585
586#[derive(Debug, Default)]
596pub struct BrickCache {
597 maps: HashMap<(i32, i32, i32, u32), (u64, BrickMap)>,
598}
599
600impl BrickCache {
601 #[must_use]
602 pub fn new() -> Self {
603 Self::default()
604 }
605
606 pub fn ensure(&mut self, chunk: [i32; 3], mip: u32, version: u64, view: &GridView<'_>) {
609 let key = (chunk[0], chunk[1], chunk[2], mip);
610 let stale = self.maps.get(&key).map_or(true, |(v, _)| *v != version);
611 if stale {
612 self.maps.insert(key, (version, BrickMap::build(view, mip)));
613 }
614 }
615
616 #[inline]
617 fn get(&self, chunk: [i32; 3], mip: u32) -> Option<&BrickMap> {
618 self.maps
619 .get(&(chunk[0], chunk[1], chunk[2], mip))
620 .map(|(_, m)| m)
621 }
622
623 pub fn retain_chunks(&mut self, keep: impl Fn([i32; 3]) -> bool) {
626 self.maps.retain(|k, _| keep([k.0, k.1, k.2]));
627 }
628}
629
630#[allow(clippy::cast_possible_wrap)]
635fn local_cache(grid: &GridView<'_>, requested_mip: u32) -> (BrickCache, u32) {
636 let mip = effective_mip(grid, requested_mip);
637 let mut cache = BrickCache::new();
638 if let Some(cg) = grid.chunk_grid {
639 for dz in 0..cg.chunks_z as i32 {
640 for dy in 0..cg.chunks_y as i32 {
641 for dx in 0..cg.chunks_x as i32 {
642 let slot = ((dz * cg.chunks_y as i32 + dy) * cg.chunks_x as i32 + dx) as usize;
643 if let Some(Some(view)) = cg.chunks.get(slot) {
644 let ch = [
645 cg.origin_chunk_xy[0] + dx,
646 cg.origin_chunk_xy[1] + dy,
647 cg.origin_chunk_z + dz,
648 ];
649 cache.ensure(ch, mip, 0, view);
650 }
651 }
652 }
653 }
654 } else {
655 cache.ensure([0, 0, 0], mip, 0, grid);
656 }
657 (cache, mip)
658}
659
660#[must_use]
665pub fn effective_mip(grid: &GridView<'_>, requested: u32) -> u32 {
666 if requested == 0 {
667 return 0;
668 }
669 let mut m = requested;
670 if let Some(cg) = grid.chunk_grid {
671 for c in cg.chunks.iter().flatten() {
672 m = m.min(c.mip_count().saturating_sub(1));
673 }
674 } else {
675 m = m.min(grid.mip_count().saturating_sub(1));
676 }
677 m
678}
679
680struct Sampler<'a> {
694 grid: GridView<'a>,
695 bricks: &'a BrickCache,
696 mip: u32,
699 xy_shift: u32,
708 xy_mask: i32,
709 z_shift: u32,
710 z_mask: i32,
711 cur_ch: [i32; 3],
712 cur_view: Option<GridView<'a>>,
713 cur_brick: Option<&'a BrickMap>,
714 has_cur: bool,
715}
716
717impl<'a> Sampler<'a> {
718 fn new(grid: GridView<'a>, bricks: &'a BrickCache, mip: u32) -> Self {
719 let cs_xy = (grid.chunk_size_xy >> mip).max(1);
720 let cs_z = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1);
721 debug_assert!(
722 cs_xy.is_power_of_two() && cs_z.is_power_of_two(),
723 "chunk dims must be powers of two for the shift/mask split"
724 );
725 #[allow(clippy::cast_possible_wrap)]
726 Self {
727 grid,
728 bricks,
729 mip,
730 xy_shift: cs_xy.trailing_zeros(),
731 xy_mask: cs_xy as i32 - 1,
732 z_shift: cs_z.trailing_zeros(),
733 z_mask: cs_z as i32 - 1,
734 cur_ch: [0; 3],
735 cur_view: None,
736 cur_brick: None,
737 has_cur: false,
738 }
739 }
740
741 fn select_chunk(&mut self, ch: [i32; 3]) {
743 if self.has_cur && self.cur_ch == ch {
744 return;
745 }
746 self.cur_view = self.grid.chunk_at_xyz(ch);
747 self.cur_brick = self.bricks.get(ch, self.mip);
748 self.cur_ch = ch;
749 self.has_cur = true;
750 }
751
752 #[allow(clippy::cast_sign_loss)]
757 fn locate(&self, c: [i32; 3]) -> ([i32; 3], [u32; 3]) {
758 let ch = [
759 c[0] >> self.xy_shift,
760 c[1] >> self.xy_shift,
761 c[2] >> self.z_shift,
762 ];
763 let loc = [
764 (c[0] & self.xy_mask) as u32,
765 (c[1] & self.xy_mask) as u32,
766 (c[2] & self.z_mask) as u32,
767 ];
768 (ch, loc)
769 }
770
771 #[allow(clippy::cast_possible_wrap)]
775 fn hit(&mut self, c: [i32; 3]) -> Option<u32> {
776 #[cfg(test)]
777 prof::SURF.with(|x| x.set(x.get() + 1));
778 let (ch, loc) = self.locate(c);
779 self.select_chunk(ch);
780 let occupied = self.cur_brick.is_some_and(|bm| {
781 bm.occupied([
782 loc[0] as i32 / BRICK,
783 loc[1] as i32 / BRICK,
784 loc[2] as i32 / BRICK,
785 ])
786 });
787 if !occupied {
788 return None;
789 }
790 self.cur_view?
791 .surface_color_mip(loc[0], loc[1], loc[2], self.mip)
792 }
793
794 #[inline]
796 fn cells_per_chunk_xy(&self) -> i32 {
797 1 << self.xy_shift
798 }
799 #[inline]
800 fn cells_per_chunk_z(&self) -> i32 {
801 1 << self.z_shift
802 }
803
804 #[allow(clippy::cast_sign_loss)]
809 fn brick_occupied(&mut self, brick: [i32; 3]) -> bool {
810 let c0 = [brick[0] << 3, brick[1] << 3, brick[2] << 3];
812 let ch = [
813 c0[0] >> self.xy_shift,
814 c0[1] >> self.xy_shift,
815 c0[2] >> self.z_shift,
816 ];
817 self.select_chunk(ch);
818 self.cur_brick.is_some_and(|bm| {
819 bm.occupied([
820 (c0[0] & self.xy_mask) >> 3,
821 (c0[1] & self.xy_mask) >> 3,
822 (c0[2] & self.z_mask) >> 3,
823 ])
824 })
825 }
826
827 #[allow(clippy::cast_sign_loss)]
832 fn super_occupied(&mut self, s: [i32; 3]) -> bool {
833 let c0 = [s[0] << 6, s[1] << 6, s[2] << 6];
835 let ch = [
836 c0[0] >> self.xy_shift,
837 c0[1] >> self.xy_shift,
838 c0[2] >> self.z_shift,
839 ];
840 self.select_chunk(ch);
841 self.cur_brick.is_some_and(|bm| {
842 bm.occupied_super([
843 (c0[0] & self.xy_mask) >> 6,
844 (c0[1] & self.xy_mask) >> 6,
845 (c0[2] & self.z_mask) >> 6,
846 ])
847 })
848 }
849}
850
851#[allow(
872 clippy::too_many_arguments,
873 clippy::cast_possible_truncation,
874 clippy::cast_sign_loss,
875 clippy::cast_precision_loss
876)]
877fn cell_walk_skip(
878 origin: [f32; 3],
879 dir: [f32; 3],
880 fwd_dot: f32,
881 sampler: &mut Sampler<'_>,
882 lo_c: [i32; 3],
883 hi_c: [i32; 3],
884 cell_size: f32,
885 t_enter: f32,
886 t_exit: f32,
887 max_dist: f32,
888 env: &DdaEnv<'_>,
889) -> Option<Hit> {
890 let has_super = sampler.cells_per_chunk_xy() >= SUPER && sampler.cells_per_chunk_z() >= SUPER;
891 let has_brick = sampler.cells_per_chunk_xy() >= BRICK && sampler.cells_per_chunk_z() >= BRICK;
892
893 let start = t_enter + 1e-4;
894 let p = [
895 origin[0] + dir[0] * start,
896 origin[1] + dir[1] * start,
897 origin[2] + dir[2] * start,
898 ];
899 let mut cellc = [
900 ((p[0] / cell_size).floor() as i32).clamp(lo_c[0], hi_c[0] - 1),
901 ((p[1] / cell_size).floor() as i32).clamp(lo_c[1], hi_c[1] - 1),
902 ((p[2] / cell_size).floor() as i32).clamp(lo_c[2], hi_c[2] - 1),
903 ];
904 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cell_size);
905 let inv = [
909 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
910 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
911 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
912 ];
913 let mut t_curr = t_enter;
914 let mut last_axis = 3usize;
915 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
918
919 let mut accum = [0.0f32; 3];
924 let mut trans = 1.0f32;
925 let mut touched = false;
926 let mut prev_solid = false;
927 let mut prev_mat = 0u8;
928
929 let span = (hi_c[0] - lo_c[0]) + (hi_c[1] - lo_c[1]) + (hi_c[2] - lo_c[2]);
932 let max_steps = span.max(0) as usize + 16;
933 for _ in 0..max_steps {
934 if cellc[0] < lo_c[0]
935 || cellc[0] >= hi_c[0]
936 || cellc[1] < lo_c[1]
937 || cellc[1] >= hi_c[1]
938 || cellc[2] < lo_c[2]
939 || cellc[2] >= hi_c[2]
940 {
941 return finalize_exit(touched, accum, trans, env, dir, max_dist);
942 }
943 let depth = t_curr * fwd_dot;
944 if depth > max_dist || t_curr > t_exit {
945 return finalize_exit(touched, accum, trans, env, dir, max_dist);
946 }
947 if env.fog_max_dist > 0.0 && depth >= env.fog_max_dist {
953 let fog = 0x8000_0000 | (env.fog_color & 0x00ff_ffff);
954 let color = if touched {
955 composite_over(accum, trans, fog)
956 } else {
957 fog
958 };
959 return Some(Hit {
960 color,
961 dist: env.fog_max_dist,
962 });
963 }
964
965 let skip_shift = if has_super
968 && !sampler.super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
969 {
970 Some(6u32)
971 } else if has_brick
972 && !sampler.brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
973 {
974 Some(3u32)
975 } else {
976 None
977 };
978 if let Some(sh) = skip_shift {
979 #[cfg(test)]
980 prof::BRICKS.with(|x| x.set(x.get() + 1));
981 let mut best_t = f32::INFINITY;
983 let mut best_axis = 3usize;
984 let mut plane = [0i32; 3];
985 for a in 0..3 {
986 if step[a] == 0 {
987 continue;
988 }
989 let idx = cellc[a] >> sh;
990 plane[a] = if step[a] > 0 {
991 (idx + 1) << sh
992 } else {
993 idx << sh
994 };
995 let tb = (plane[a] as f32 * cell_size - origin[a]) * inv[a];
996 if tb < best_t {
997 best_t = tb;
998 best_axis = a;
999 }
1000 }
1001 if best_axis == 3 {
1002 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1003 }
1004 let pb = [
1009 origin[0] + dir[0] * (best_t + 1e-4),
1010 origin[1] + dir[1] * (best_t + 1e-4),
1011 origin[2] + dir[2] * (best_t + 1e-4),
1012 ];
1013 let mut nc = [
1014 (pb[0] / cell_size).floor() as i32,
1015 (pb[1] / cell_size).floor() as i32,
1016 (pb[2] / cell_size).floor() as i32,
1017 ];
1018 nc[best_axis] = if step[best_axis] > 0 {
1019 plane[best_axis]
1020 } else {
1021 plane[best_axis] - 1
1022 };
1023 if nc[0] < lo_c[0]
1027 || nc[0] >= hi_c[0]
1028 || nc[1] < lo_c[1]
1029 || nc[1] >= hi_c[1]
1030 || nc[2] < lo_c[2]
1031 || nc[2] >= hi_c[2]
1032 {
1033 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1034 }
1035 cellc = nc;
1036 for a in 0..3 {
1039 if step[a] > 0 {
1040 t_max[a] = ((cellc[a] + 1) as f32 * cell_size - origin[a]) * inv[a];
1041 } else if step[a] < 0 {
1042 t_max[a] = (cellc[a] as f32 * cell_size - origin[a]) * inv[a];
1043 }
1044 }
1045 t_curr = best_t.max(t_curr);
1046 last_axis = best_axis;
1047 prev_solid = false; continue;
1049 }
1050
1051 #[cfg(test)]
1053 prof::CELLS.with(|x| x.set(x.get() + 1));
1054 if let Some(color) = sampler.hit(cellc) {
1055 let bright_sub = side_shade_sub(env, last_axis, step);
1056 let lit = apply_fog(shade(color, bright_sub), depth.max(0.0), env);
1057 let m = terrain_material(env, color);
1058 if m.is_opaque() {
1059 let color = if touched {
1063 composite_over(accum, trans, lit)
1064 } else {
1065 lit
1066 };
1067 return Some(Hit {
1068 color,
1069 dist: depth.max(0.0),
1070 });
1071 }
1072 let mat_id = material_for_color(env.terrain_materials, color);
1073 let a = f32::from(m.alpha) / 255.0;
1074 if matches!(m.mode, roxlap_formats::material::BlendMode::Volumetric) {
1075 let t_exit = t_max[min_axis(t_max)];
1079 let seg_len = (t_exit - t_curr).max(0.0) * dir_len / cell_size;
1080 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
1081 let c = rgb_to_f32(lit);
1082 accum[0] += trans * eff_a * c[0];
1083 accum[1] += trans * eff_a * c[1];
1084 accum[2] += trans * eff_a * c[2];
1085 trans *= 1.0 - eff_a;
1086 touched = true;
1087 prev_mat = mat_id;
1088 if trans < 1.0 / 256.0 {
1089 return Some(Hit {
1090 color: f32_to_rgb(accum),
1091 dist: depth.max(0.0),
1092 });
1093 }
1094 } else if !prev_solid || mat_id != prev_mat {
1095 let c = rgb_to_f32(lit);
1099 accum[0] += trans * a * c[0];
1100 accum[1] += trans * a * c[1];
1101 accum[2] += trans * a * c[2];
1102 if !matches!(m.mode, roxlap_formats::material::BlendMode::Additive) {
1103 trans *= 1.0 - a; }
1105 touched = true;
1106 prev_mat = mat_id;
1107 if trans < 1.0 / 256.0 {
1108 return Some(Hit {
1109 color: f32_to_rgb(accum),
1110 dist: depth.max(0.0),
1111 });
1112 }
1113 }
1114 prev_solid = true;
1115 } else {
1116 prev_solid = false;
1117 }
1118 let axis = min_axis(t_max);
1119 last_axis = axis;
1120 t_curr = t_max[axis];
1121 cellc[axis] += step[axis];
1122 t_max[axis] += t_delta[axis];
1123 }
1124 None
1125}
1126
1127#[inline]
1133fn side_shade_sub(env: &DdaEnv<'_>, axis: usize, step: [i32; 3]) -> u32 {
1134 if axis >= 3 {
1135 return 0;
1136 }
1137 let face = axis * 2 + usize::from(step[axis] < 0);
1138 env.side_shades[face].max(0) as u32
1139}
1140
1141fn cast_ray(
1150 origin: [f32; 3],
1151 dir: [f32; 3],
1152 forward: [f32; 3],
1153 sampler: &mut Sampler<'_>,
1154 settings: &OpticastSettings,
1155 env: &DdaEnv<'_>,
1156) -> Option<Hit> {
1157 let (lo_i, hi_i) = sampler.grid.voxel_bounds();
1158 #[allow(clippy::cast_precision_loss)]
1159 let lo_f = [lo_i[0] as f32, lo_i[1] as f32, lo_i[2] as f32];
1160 #[allow(clippy::cast_precision_loss)]
1161 let hi_f = [hi_i[0] as f32, hi_i[1] as f32, hi_i[2] as f32];
1162 let (t_enter, t_exit) = intersect_aabb(origin, dir, lo_f, hi_f)?;
1163 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1164 #[allow(clippy::cast_precision_loss)]
1165 let max_dist = settings.max_scan_dist.max(1) as f32;
1166 let cell = 1i32 << sampler.mip;
1167 let cell_size = cell as f32;
1168 let lo_c = [
1169 lo_i[0].div_euclid(cell),
1170 lo_i[1].div_euclid(cell),
1171 lo_i[2].div_euclid(cell),
1172 ];
1173 let hi_c = [
1174 hi_i[0].div_euclid(cell),
1175 hi_i[1].div_euclid(cell),
1176 hi_i[2].div_euclid(cell),
1177 ];
1178 cell_walk_skip(
1179 origin, dir, fwd_dot, sampler, lo_c, hi_c, cell_size, t_enter, t_exit, max_dist, env,
1180 )
1181}
1182
1183pub fn render_dda(
1196 camera: &Camera,
1197 settings: &OpticastSettings,
1198 grid: GridView<'_>,
1199 pitch_pixels: usize,
1200 env: &DdaEnv<'_>,
1201 mip: u32,
1202 sink: &mut impl PixelSink,
1203) {
1204 let cs = camera_math::derive(
1205 camera,
1206 settings.xres,
1207 settings.yres,
1208 settings.hx,
1209 settings.hy,
1210 settings.hz,
1211 );
1212
1213 let (cache, mip) = local_cache(&grid, mip);
1216 let mut sampler = Sampler::new(grid, &cache, mip);
1217
1218 for py in settings.y_start..settings.y_end {
1219 let row = py as usize * pitch_pixels;
1220 for px in 0..settings.xres {
1221 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py) {
1222 sink.put(row + px as usize, color, dist);
1223 }
1224 }
1225 }
1226}
1227
1228#[inline]
1233fn pixel_result(
1234 cs: &CameraState,
1235 settings: &OpticastSettings,
1236 sampler: &mut Sampler<'_>,
1237 env: &DdaEnv<'_>,
1238 px: u32,
1239 py: u32,
1240) -> Option<(u32, f32)> {
1241 let (origin, dir) = pixel_ray(cs, settings, px, py);
1242 if let Some(hit) = cast_ray(origin, dir, cs.forward, sampler, settings, env) {
1243 Some((hit.color, hit.dist))
1244 } else {
1245 env.sky.map(|sky| (sample_sky(sky, dir), f32::INFINITY))
1246 }
1247}
1248
1249#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
1264pub fn render_dda_parallel(
1265 camera: &Camera,
1266 settings: &OpticastSettings,
1267 grid: GridView<'_>,
1268 fb: &mut [u32],
1269 zb: &mut [f32],
1270 pitch_pixels: usize,
1271 env: &DdaEnv<'_>,
1272 cache: &BrickCache,
1273 mip: u32,
1274) {
1275 debug_assert_eq!(fb.len(), zb.len());
1276 let (y0, y1) = (settings.y_start, settings.y_end);
1277 if y1 <= y0 {
1278 return;
1279 }
1280 let cs = camera_math::derive(
1281 camera,
1282 settings.xres,
1283 settings.yres,
1284 settings.hx,
1285 settings.hy,
1286 settings.hz,
1287 );
1288 let target = RasterTarget::new(fb, zb);
1289
1290 let nthreads = rayon::current_num_threads().max(1);
1292 let rows = (y1 - y0) as usize;
1293 let band = rows.div_ceil(nthreads).max(1) as u32;
1294 let bands: Vec<(u32, u32)> = (y0..y1)
1295 .step_by(band as usize)
1296 .map(|s| (s, (s + band).min(y1)))
1297 .collect();
1298
1299 bands.par_iter().for_each(|&(by0, by1)| {
1300 let mut sampler = Sampler::new(grid, cache, mip);
1301 for py in by0..by1 {
1302 let row = py as usize * pitch_pixels;
1303 for px in 0..settings.xres {
1304 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py)
1305 {
1306 let idx = row + px as usize;
1307 unsafe {
1311 target.write_color(idx, color);
1312 target.write_depth(idx, dist);
1313 }
1314 }
1315 }
1316 }
1317 });
1318}
1319
1320#[cfg(test)]
1326#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
1327fn cast_ray_reference(
1328 origin: [f32; 3],
1329 dir: [f32; 3],
1330 forward: [f32; 3],
1331 grid: &GridView<'_>,
1332 settings: &OpticastSettings,
1333) -> Option<Hit> {
1334 let nx = grid.vsid as f32;
1335 let nz = f32::from(u16::try_from(crate::grid_view::CHUNK_SIZE_Z).unwrap_or(256));
1336 #[allow(clippy::cast_possible_wrap)]
1337 let n_i = [
1338 grid.vsid as i32,
1339 grid.vsid as i32,
1340 crate::grid_view::CHUNK_SIZE_Z as i32,
1341 ];
1342 let (t_enter, t_exit) = intersect_aabb(origin, dir, [0.0; 3], [nx, nx, nz])?;
1343 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1344 let max_dist = settings.max_scan_dist.max(1) as f32;
1345
1346 let start = t_enter + 1e-4;
1347 let p = [
1348 origin[0] + dir[0] * start,
1349 origin[1] + dir[1] * start,
1350 origin[2] + dir[2] * start,
1351 ];
1352 let mut voxel = [
1353 (p[0].floor() as i32).clamp(0, n_i[0] - 1),
1354 (p[1].floor() as i32).clamp(0, n_i[1] - 1),
1355 (p[2].floor() as i32).clamp(0, n_i[2] - 1),
1356 ];
1357 let (step, mut t_max, t_delta) = dda_setup(origin, dir, voxel, 1.0);
1358 let mut t_curr = t_enter;
1359 let max_steps = (n_i[0] + n_i[1] + n_i[2]) as usize + 8;
1360 for _ in 0..max_steps {
1361 if voxel[0] < 0
1362 || voxel[0] >= n_i[0]
1363 || voxel[1] < 0
1364 || voxel[1] >= n_i[1]
1365 || voxel[2] < 0
1366 || voxel[2] >= n_i[2]
1367 {
1368 return None;
1369 }
1370 let depth = t_curr * fwd_dot;
1371 if depth > max_dist || t_curr > t_exit {
1372 return None;
1373 }
1374 #[allow(clippy::cast_sign_loss)]
1375 if let Some(color) = grid.surface_color(voxel[0] as u32, voxel[1] as u32, voxel[2] as u32) {
1376 return Some(Hit {
1377 color: shade(color, 0),
1378 dist: depth.max(0.0),
1379 });
1380 }
1381 let axis = min_axis(t_max);
1382 t_curr = t_max[axis];
1383 voxel[axis] += step[axis];
1384 t_max[axis] += t_delta[axis];
1385 }
1386 None
1387}
1388
1389#[cfg(test)]
1390mod tests {
1391 use super::*;
1392
1393 #[derive(Default)]
1395 struct Recorder {
1396 puts: Vec<(usize, u32, f32)>,
1397 }
1398 impl PixelSink for Recorder {
1399 fn put(&mut self, idx: usize, color: u32, dist: f32) {
1400 self.puts.push((idx, color, dist));
1401 }
1402 }
1403
1404 fn oracle_camera() -> Camera {
1405 Camera {
1407 pos: [0.0, 0.0, 0.0],
1408 right: [1.0, 0.0, 0.0],
1409 down: [0.0, 0.0, 1.0],
1410 forward: [0.0, 1.0, 0.0],
1411 }
1412 }
1413
1414 fn render_mask(grid: GridView<'_>, camera: &Camera, w: u32, h: u32) -> Vec<bool> {
1417 let n = (w as usize) * (h as usize);
1418 let mut fb = vec![0u32; n]; let mut zb = vec![f32::INFINITY; n];
1420 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
1421 {
1422 let mut sink = RasterSink::new(&mut fb, &mut zb);
1423 render_dda(
1424 camera,
1425 &settings,
1426 grid,
1427 w as usize,
1428 &DdaEnv::default(),
1429 0,
1430 &mut sink,
1431 );
1432 }
1433 fb.iter().map(|&c| c != 0).collect()
1434 }
1435
1436 fn rows_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
1441 let w = w as usize;
1442 for y in 0..h as usize {
1443 let row = &mask[y * w..(y + 1) * w];
1444 let first = row.iter().position(|&b| b);
1445 let last = row.iter().rposition(|&b| b);
1446 if let (Some(f), Some(l)) = (first, last) {
1447 if row[f..=l].iter().any(|&b| !b) {
1448 return false;
1449 }
1450 }
1451 }
1452 true
1453 }
1454
1455 fn cols_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
1457 let w = w as usize;
1458 let h = h as usize;
1459 for x in 0..w {
1460 let col: Vec<bool> = (0..h).map(|y| mask[y * w + x]).collect();
1461 let first = col.iter().position(|&b| b);
1462 let last = col.iter().rposition(|&b| b);
1463 if let (Some(f), Some(l)) = (first, last) {
1464 if col[f..=l].iter().any(|&b| !b) {
1465 return false;
1466 }
1467 }
1468 }
1469 true
1470 }
1471
1472 #[test]
1475 fn center_pixel_ray_is_forward() {
1476 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
1477 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
1478 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1480 let (origin, dir) = pixel_ray(&cs, &settings, settings.hx as u32, settings.hy as u32);
1481 assert_eq!(origin, [0.0, 0.0, 0.0]);
1482 assert_eq!(
1484 dir.map(f32::to_bits),
1485 [0.0f32, 320.0, 0.0].map(f32::to_bits)
1486 );
1487 }
1488
1489 #[test]
1493 fn corner_pixel_ray_matches_camera_corn0() {
1494 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
1495 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
1496 let (_origin, dir) = pixel_ray(&cs, &settings, 0, 0);
1497 assert_eq!(dir.map(f32::to_bits), cs.corn[0].map(f32::to_bits));
1498 }
1499
1500 #[test]
1506 fn gridview_voxel_color_matches_reference() {
1507 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |x, _, z| {
1509 let lo = (10..=12).contains(&z);
1510 let hi = (40..=42).contains(&z);
1511 (lo || hi).then_some(0x80_10_20_30 + x)
1512 });
1513 let grid = GridView::from_single_vxl(&vxl);
1514 for x in 0..8 {
1515 for y in 0..8 {
1516 for z in 0..64 {
1517 assert_eq!(
1518 grid.voxel_color(x, y, z),
1519 vxl.voxel_color(x, y, z),
1520 "mismatch at ({x},{y},{z})"
1521 );
1522 }
1523 }
1524 }
1525 }
1526
1527 #[test]
1529 fn empty_grid_no_hits() {
1530 let vxl = roxlap_formats::vxl::Vxl::empty(64);
1531 let grid = GridView::from_single_vxl(&vxl);
1532 let settings = OpticastSettings::for_oracle_framebuffer(64, 48);
1533 let mut rec = Recorder::default();
1534 render_dda(
1535 &oracle_camera(),
1536 &settings,
1537 grid,
1538 64,
1539 &DdaEnv::default(),
1540 0,
1541 &mut rec,
1542 );
1543 assert!(rec.puts.is_empty(), "all-air grid must produce no hits");
1544 }
1545
1546 #[test]
1550 fn floor_seen_from_above() {
1551 const FLOOR_Z: u32 = 40;
1552 const FLOOR_COL: u32 = 0x80_30_60_90;
1553 let vxl =
1554 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= FLOOR_Z).then_some(FLOOR_COL));
1555 let grid = GridView::from_single_vxl(&vxl);
1556
1557 let cam = Camera {
1559 pos: [16.0, 16.0, 10.0],
1560 right: [1.0, 0.0, 0.0],
1561 down: [0.0, 1.0, 0.0],
1562 forward: [0.0, 0.0, 1.0],
1563 };
1564 let settings = OpticastSettings::for_oracle_framebuffer(48, 48);
1565 let mut rec = Recorder::default();
1566 render_dda(&cam, &settings, grid, 48, &DdaEnv::default(), 0, &mut rec);
1567
1568 assert!(!rec.puts.is_empty(), "floor must be visible");
1569 let centre = 24usize * 48 + 24;
1571 let hit = rec
1572 .puts
1573 .iter()
1574 .find(|(idx, _, _)| *idx == centre)
1575 .expect("centre ray must hit the floor");
1576 assert_eq!(hit.1 & 0x00ff_ffff, FLOOR_COL & 0x00ff_ffff);
1577 let expected = (FLOOR_Z as f32) - 10.0;
1578 assert!(
1579 (hit.2 - expected).abs() < 1.5,
1580 "centre depth {} not ≈ {}",
1581 hit.2,
1582 expected
1583 );
1584 }
1585
1586 #[test]
1591 fn horizon_splits_sky_and_floor() {
1592 const FLOOR_Z: u32 = 40;
1593 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
1594 (z >= FLOOR_Z).then_some(0x80_44_66_88)
1595 });
1596 let grid = GridView::from_single_vxl(&vxl);
1597
1598 let cam = Camera {
1602 pos: [32.0, 4.0, 30.0],
1603 right: [-1.0, 0.0, 0.0],
1604 down: [0.0, 0.0, 1.0],
1605 forward: [0.0, 1.0, 0.0],
1606 };
1607 let (w, h) = (64u32, 64u32);
1608 let mask = render_mask(grid, &cam, w, h);
1609
1610 let count_band = |y0: usize, y1: usize| -> usize {
1611 (y0 * w as usize..y1 * w as usize)
1612 .filter(|&i| mask[i])
1613 .count()
1614 };
1615 let top = count_band(0, h as usize / 4);
1616 let bottom = count_band(3 * h as usize / 4, h as usize);
1617 assert!(mask.iter().any(|&b| b), "floor must be visible");
1618 assert!(mask.iter().any(|&b| !b), "sky must be visible");
1619 assert!(
1620 bottom > top,
1621 "bottom band ({bottom}) should hit more floor than top band ({top})"
1622 );
1623 }
1624
1625 fn render_reference(
1628 grid: GridView<'_>,
1629 camera: &Camera,
1630 w: u32,
1631 h: u32,
1632 ) -> (Vec<u32>, Vec<f32>) {
1633 let n = (w as usize) * (h as usize);
1634 let mut fb = vec![0u32; n];
1635 let mut zb = vec![f32::INFINITY; n];
1636 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
1637 let cs = camera_math::derive(camera, w, h, settings.hx, settings.hy, settings.hz);
1638 for py in 0..h {
1639 for px in 0..w {
1640 let (o, d) = pixel_ray(&cs, &settings, px, py);
1641 if let Some(hit) = cast_ray_reference(o, d, cs.forward, &grid, &settings) {
1642 let i = (py * w + px) as usize;
1643 fb[i] = hit.color;
1644 zb[i] = hit.dist;
1645 }
1646 }
1647 }
1648 (fb, zb)
1649 }
1650
1651 fn render_brickmap(
1653 grid: GridView<'_>,
1654 camera: &Camera,
1655 w: u32,
1656 h: u32,
1657 ) -> (Vec<u32>, Vec<f32>) {
1658 render_brickmap_env(grid, camera, w, h, &DdaEnv::default())
1659 }
1660
1661 fn render_brickmap_env(
1664 grid: GridView<'_>,
1665 camera: &Camera,
1666 w: u32,
1667 h: u32,
1668 env: &DdaEnv<'_>,
1669 ) -> (Vec<u32>, Vec<f32>) {
1670 let n = (w as usize) * (h as usize);
1671 let mut fb = vec![0u32; n];
1672 let mut zb = vec![f32::INFINITY; n];
1673 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
1674 {
1675 let mut sink = RasterSink::new(&mut fb, &mut zb);
1676 render_dda(camera, &settings, grid, w as usize, env, 0, &mut sink);
1677 }
1678 (fb, zb)
1679 }
1680
1681 #[test]
1688 fn no_sky_leak_through_diagonal_wall() {
1689 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
1690 ((x + y == 64) && (2..62).contains(&z)).then_some(0x80_40_80_60)
1691 });
1692 let grid = GridView::from_single_vxl(&vxl);
1693 let (w, h) = (160u32, 160u32);
1694 let c = [10.0, 10.0, 32.0];
1695 let poses = [
1696 Camera::from_yaw_pitch(c, 0.785, 0.0),
1697 Camera::from_yaw_pitch(c, 0.6, 0.1),
1698 Camera::from_yaw_pitch(c, 0.95, -0.1),
1699 Camera::from_yaw_pitch(c, 0.785, 0.3),
1700 Camera::from_yaw_pitch(c, 0.5, 0.0),
1701 ];
1702 for (i, cam) in poses.iter().enumerate() {
1703 let (fb_b, _) = render_brickmap(grid, cam, w, h);
1704 let (fb_r, _) = render_reference(grid, cam, w, h);
1705 let leak = (0..(w * h) as usize)
1706 .filter(|&k| (fb_b[k] != 0) != (fb_r[k] != 0))
1707 .count();
1708 assert_eq!(leak, 0, "pose {i}: {leak} px diverge from dense reference");
1709 }
1710 }
1711
1712 #[test]
1716 fn terrain_glass_tints_floor_behind() {
1717 let glass = 0x80_40_C0_E0; let floor = 0x80_C0_40_40; let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
1720 if z == 4 {
1721 Some(glass)
1722 } else if z >= 10 {
1723 Some(floor)
1724 } else {
1725 None
1726 }
1727 });
1728 let grid = GridView::from_single_vxl(&vxl);
1729 let cam = Camera {
1731 pos: [8.0, 8.0, 0.0],
1732 right: [1.0, 0.0, 0.0],
1733 down: [0.0, 1.0, 0.0],
1734 forward: [0.0, 0.0, 1.0],
1735 };
1736 let (w, h) = (32u32, 32u32);
1737 let centre = (h / 2 * w + w / 2) as usize;
1738
1739 let (fb_op, _) = render_brickmap(grid, &cam, w, h);
1741 assert_eq!(
1742 fb_op[centre] & 0x00ff_ffff,
1743 0x0040_C0E0,
1744 "opaque glass first-hit"
1745 );
1746
1747 let mut table = MaterialTable::new();
1749 table.set(1, Material::alpha_blend(128));
1750 let env = DdaEnv {
1751 materials: Some(&table),
1752 terrain_materials: &[(glass & 0x00ff_ffff, 1)],
1753 ..DdaEnv::default()
1754 };
1755 let (fb_tr, _) = render_brickmap_env(grid, &cam, w, h, &env);
1756 assert_ne!(
1757 fb_tr[centre], fb_op[centre],
1758 "glass should composite over the floor, not stay opaque"
1759 );
1760 let r_op = (fb_op[centre] >> 16) & 0xff; let r_tr = (fb_tr[centre] >> 16) & 0xff; assert!(
1763 r_tr > r_op,
1764 "floor red tints through the glass (op={r_op:02x} tr={r_tr:02x})"
1765 );
1766 }
1767
1768 #[test]
1773 fn terrain_volumetric_thickness_deepens_opacity() {
1774 let smoke = 0x80_90_90_90; let floor = 0x80_C0_20_20; let green_at = |depth: u32| -> u32 {
1779 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
1780 if (4..4 + depth).contains(&z) {
1781 Some(smoke)
1782 } else if z >= 12 {
1783 Some(floor)
1784 } else {
1785 None
1786 }
1787 });
1788 let grid = GridView::from_single_vxl(&vxl);
1789 let cam = Camera {
1790 pos: [8.0, 8.0, 0.0],
1791 right: [1.0, 0.0, 0.0],
1792 down: [0.0, 1.0, 0.0],
1793 forward: [0.0, 0.0, 1.0],
1794 };
1795 let (w, h) = (32u32, 32u32);
1796 let mut table = MaterialTable::new();
1797 table.set(1, Material::volumetric(80));
1798 let env = DdaEnv {
1799 materials: Some(&table),
1800 terrain_materials: &[(smoke & 0x00ff_ffff, 1)],
1801 ..DdaEnv::default()
1802 };
1803 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
1804 (fb[(h / 2 * w + w / 2) as usize] >> 8) & 0xff
1805 };
1806 let shallow = green_at(1);
1807 let deep = green_at(7);
1808 assert!(
1809 deep > shallow,
1810 "deeper Volumetric smoke shows more of its grey (deep g={deep:02x} > shallow g={shallow:02x})"
1811 );
1812 }
1813
1814 #[test]
1817 fn distance_fog_blends_toward_fog_color() {
1818 let vxl =
1819 roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| (z >= 40).then_some(0x80_FF_FF_FF));
1820 let grid = GridView::from_single_vxl(&vxl);
1821 let cam = Camera {
1822 pos: [32.0, 2.0, 38.0],
1823 right: [1.0, 0.0, 0.0],
1824 down: [0.0, 0.0, 1.0],
1825 forward: [0.0, 1.0, 0.0],
1826 };
1827 let env = DdaEnv {
1828 sky: None,
1829 fog_color: 0x00_00_00_00, fog_max_dist: 64.0,
1831 side_shades: [0; 6],
1832 materials: None,
1833 terrain_materials: &[],
1834 };
1835 let (w, h) = (64u32, 64u32);
1836 let (fog, _) = render_brickmap_env(grid, &cam, w, h, &env);
1837 let (nofog, zb) = render_brickmap(grid, &cam, w, h);
1838 let (idx, depth) = zb.iter().enumerate().filter(|(_, z)| z.is_finite()).fold(
1839 (0usize, 0.0f32),
1840 |acc, (i, &z)| {
1841 if z > acc.1 {
1842 (i, z)
1843 } else {
1844 acc
1845 }
1846 },
1847 );
1848 assert!(depth > 20.0, "need a deep pixel to test fog (got {depth})");
1849 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
1850 assert!(
1851 lum(fog[idx]) < lum(nofog[idx]),
1852 "fogged pixel {:08x} not darker than {:08x}",
1853 fog[idx],
1854 nofog[idx]
1855 );
1856 }
1857
1858 #[test]
1861 fn textured_sky_fills_misses() {
1862 let sky = crate::sky::Sky::blue_gradient();
1863 let vxl = roxlap_formats::vxl::Vxl::empty(32); let grid = GridView::from_single_vxl(&vxl);
1865 let env = DdaEnv {
1866 sky: Some(&sky),
1867 fog_color: 0,
1868 fog_max_dist: 0.0,
1869 side_shades: [0; 6],
1870 materials: None,
1871 terrain_materials: &[],
1872 };
1873 let cam = Camera::from_yaw_pitch([16.0, 16.0, 128.0], 0.3, -0.4);
1874 let (w, h) = (48u32, 48u32);
1875 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
1876 assert!(fb.iter().all(|&c| c >> 24 == 0x80), "all misses sky-filled");
1877 let top = fb[0];
1878 let bottom = fb[(h - 1) as usize * w as usize];
1879 assert_ne!(top, bottom, "sky gradient should vary with elevation");
1880 }
1881
1882 #[test]
1887 fn sky_elevation_zenith_at_column_zero() {
1888 let mut pixels = vec![0i32; 8];
1889 pixels[0] = 0x0011_1111; pixels[7] = 0x0099_9999; let sky = crate::sky::Sky::from_pixels(pixels, 8, 1);
1892 let up = sample_sky(&sky, [0.0, 0.0, -1.0]); let down = sample_sky(&sky, [0.0, 0.0, 1.0]); assert_eq!(
1895 up & 0x00ff_ffff,
1896 0x0011_1111,
1897 "looking up → column 0 (zenith)"
1898 );
1899 assert_eq!(
1900 down & 0x00ff_ffff,
1901 0x0099_9999,
1902 "looking down → last column (nadir)"
1903 );
1904 }
1905
1906 #[test]
1910 fn sky_fill_paints_panorama_gridless() {
1911 let sky = crate::sky::Sky::blue_gradient();
1912 let cam = Camera::from_yaw_pitch([0.0, 0.0, 0.0], 0.3, -0.4);
1913 let (w, h) = (48u32, 48u32);
1914 let cs = crate::camera_math::derive(&cam, w, h, 24.0, 24.0, 24.0);
1915 let settings = crate::opticast::OpticastSettings::for_oracle_framebuffer(w, h);
1916 let mut fb = vec![0u32; (w * h) as usize];
1917 let zb = vec![f32::INFINITY; (w * h) as usize];
1919 render_sky_fill(&mut fb, &zb, w as usize, w, h, &cs, &settings, &sky);
1920 assert!(
1921 fb.iter().all(|&c| c >> 24 == 0x80),
1922 "every pixel sky-filled with the brightness byte set"
1923 );
1924 let top = fb[0];
1925 let bottom = fb[(h - 1) as usize * w as usize];
1926 assert_ne!(top, bottom, "sky gradient should vary with elevation");
1927 let mut fb2 = vec![0x1234_5678u32; (w * h) as usize];
1929 let mut zb2 = vec![f32::INFINITY; (w * h) as usize];
1930 zb2[0] = 10.0; render_sky_fill(&mut fb2, &zb2, w as usize, w, h, &cs, &settings, &sky);
1932 assert_eq!(fb2[0], 0x1234_5678, "finite-z pixel is not overwritten");
1933 }
1934
1935 #[test]
1939 fn side_shades_darken_hit_face() {
1940 let vxl =
1941 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x80_FF_FF_FF));
1942 let grid = GridView::from_single_vxl(&vxl);
1943 let cam = Camera {
1944 pos: [8.0, 8.0, 2.0],
1945 right: [1.0, 0.0, 0.0],
1946 down: [0.0, 1.0, 0.0],
1947 forward: [0.0, 0.0, 1.0],
1948 };
1949 let centre = 16 * 32 + 16;
1950 let (plain, _) = render_brickmap(grid, &cam, 32, 32);
1951 let env = DdaEnv {
1952 sky: None,
1953 fog_color: 0,
1954 fog_max_dist: 0.0,
1955 side_shades: [0, 0, 0, 0, 0x40, 0],
1956 materials: None,
1957 terrain_materials: &[],
1958 };
1959 let (shaded, _) = render_brickmap_env(grid, &cam, 32, 32, &env);
1960 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
1961 assert!(
1962 lum(shaded[centre]) < lum(plain[centre]),
1963 "side-shaded face {:08x} not darker than {:08x}",
1964 shaded[centre],
1965 plain[centre]
1966 );
1967 }
1968
1969 #[test]
1979 fn brickmap_approximates_dense_reference() {
1980 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
1982 let surf = 30 + ((x / 5 + y / 7) % 11);
1983 let ground = z >= surf;
1984 let block = (20..=24).contains(&z) && (10..20).contains(&x) && (40..50).contains(&y);
1985 (ground || block).then_some(0x80_30_50_70 + (x ^ y) % 0x40)
1986 });
1987 let grid = GridView::from_single_vxl(&vxl);
1988
1989 let (w, h) = (80u32, 80u32);
1990 let poses = [
1991 Camera::orbit(0.6, 0.5, 90.0, [32.0, 32.0, 40.0]),
1992 Camera::orbit(2.1, 0.2, 70.0, [32.0, 32.0, 35.0]),
1993 Camera::orbit(-1.0, 0.9, 120.0, [32.0, 32.0, 45.0]),
1994 ];
1995 let n = (w * h) as usize;
1996 for (i, cam) in poses.iter().enumerate() {
1997 let (fb_b, zb_b) = render_brickmap(grid, cam, w, h);
1998 let (fb_r, _zb_r) = render_reference(grid, cam, w, h);
1999 let cov_b = fb_b.iter().filter(|&&c| c != 0).count();
2001 let cov_r = fb_r.iter().filter(|&&c| c != 0).count();
2002 assert!(cov_b > 200, "pose {i} rendered ~empty (cov {cov_b})");
2003 let cov_diff = cov_b.abs_diff(cov_r);
2004 assert!(
2005 cov_diff * 100 <= n, "pose {i} coverage diverged: brick {cov_b} vs dense {cov_r}"
2007 );
2008 let diffs = fb_b.iter().zip(&fb_r).filter(|(a, b)| a != b).count();
2010 assert!(
2011 diffs * 100 <= n * 3, "pose {i} too many pixel diffs vs dense: {diffs}/{n}"
2013 );
2014 for k in 0..n {
2016 if fb_b[k] != 0 {
2017 assert!(zb_b[k].is_finite(), "pose {i} px {k} non-finite depth");
2018 }
2019 }
2020 }
2021 }
2022
2023 #[test]
2027 fn baked_brightness_darkens_color() {
2028 let dim =
2030 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x40_FF_FF_FF));
2031 let grid = GridView::from_single_vxl(&dim);
2032 let cam = Camera {
2033 pos: [8.0, 8.0, 2.0],
2034 right: [1.0, 0.0, 0.0],
2035 down: [0.0, 1.0, 0.0],
2036 forward: [0.0, 0.0, 1.0],
2037 };
2038 let (fb, _) = render_brickmap(grid, &cam, 32, 32);
2039 let centre = 16 * 32 + 16;
2040 assert_eq!(fb[centre], 0x80_7F_7F_7F, "got {:08x}", fb[centre]);
2042
2043 let full =
2045 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x80_FF_FF_FF));
2046 let gridf = GridView::from_single_vxl(&full);
2047 let (fbf, _) = render_brickmap(gridf, &cam, 32, 32);
2048 assert_eq!(fbf[centre], 0x80_FF_FF_FF, "got {:08x}", fbf[centre]);
2049 }
2050
2051 #[test]
2058 fn cross_chunk_lookdown_sees_lower_stacked_floor() {
2059 const FLOOR_LOCAL_Z: u32 = 40;
2060 const FLOOR_COL: u32 = 0x80_22_88_44;
2061 let upper = roxlap_formats::vxl::Vxl::empty(32); let lower = roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
2063 (z >= FLOOR_LOCAL_Z).then_some(FLOOR_COL)
2064 });
2065 let v_up = GridView::from_single_vxl(&upper);
2066 let v_lo = GridView::from_single_vxl(&lower);
2067 let chunks = [Some(v_up), Some(v_lo)];
2069 let cg = crate::ChunkGrid {
2070 chunks: &chunks,
2071 origin_chunk_xy: [0, 0],
2072 origin_chunk_z: 0,
2073 chunks_x: 1,
2074 chunks_y: 1,
2075 chunks_z: 2,
2076 };
2077 let grid = GridView::from_chunk_grid(&cg, 32);
2078
2079 let cam = Camera {
2081 pos: [16.0, 16.0, 100.0],
2082 right: [1.0, 0.0, 0.0],
2083 down: [0.0, 1.0, 0.0],
2084 forward: [0.0, 0.0, 1.0],
2085 };
2086 let (w, h) = (48u32, 48u32);
2087 let (fb, zb) = render_brickmap(grid, &cam, w, h);
2088 let centre = 24 * 48 + 24;
2089 assert!(
2090 fb[centre] & 0x00ff_ffff == FLOOR_COL & 0x00ff_ffff,
2091 "centre ray must reach the lower-chunk floor (got {:08x})",
2092 fb[centre]
2093 );
2094 let expected = 296.0 - 100.0;
2096 assert!(
2097 (zb[centre] - expected).abs() < 2.0,
2098 "look-down depth {} not ≈ {expected}",
2099 zb[centre]
2100 );
2101 }
2102
2103 #[test]
2107 fn cross_chunk_xy_floor_is_seamless() {
2108 let mk = || {
2109 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= 20).then_some(0x80_50_50_50))
2110 };
2111 let (c0, c1) = (mk(), mk());
2112 let v0 = GridView::from_single_vxl(&c0);
2113 let v1 = GridView::from_single_vxl(&c1);
2114 let chunks = [Some(v0), Some(v1)];
2115 let cg = crate::ChunkGrid {
2116 chunks: &chunks,
2117 origin_chunk_xy: [0, 0],
2118 origin_chunk_z: 0,
2119 chunks_x: 2,
2120 chunks_y: 1,
2121 chunks_z: 1,
2122 };
2123 let grid = GridView::from_chunk_grid(&cg, 32);
2124
2125 let cam = Camera {
2127 pos: [32.0, 16.0, 4.0],
2128 right: [1.0, 0.0, 0.0],
2129 down: [0.0, 1.0, 0.0],
2130 forward: [0.0, 0.0, 1.0],
2131 };
2132 let (w, h) = (64u32, 64u32);
2133 let mask = render_mask(grid, &cam, w, h);
2134 let row = (h / 2) as usize * w as usize;
2137 let left = (0..w as usize / 2).filter(|&x| mask[row + x]).count();
2138 let right = (w as usize / 2..w as usize)
2139 .filter(|&x| mask[row + x])
2140 .count();
2141 assert!(
2142 left > 5 && right > 5,
2143 "seam not continuous: left={left} right={right}"
2144 );
2145 }
2146
2147 fn render_mask_mip(grid: GridView<'_>, camera: &Camera, w: u32, h: u32, mip: u32) -> Vec<bool> {
2150 let n = (w as usize) * (h as usize);
2151 let mut fb = vec![0u32; n];
2152 let mut zb = vec![f32::INFINITY; n];
2153 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2154 {
2155 let mut sink = RasterSink::new(&mut fb, &mut zb);
2156 render_dda(
2157 camera,
2158 &settings,
2159 grid,
2160 w as usize,
2161 &DdaEnv::default(),
2162 mip,
2163 &mut sink,
2164 );
2165 }
2166 fb.iter().map(|&c| c != 0).collect()
2167 }
2168
2169 #[test]
2175 fn mip_render_is_coarse_but_complete() {
2176 let mut vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2177 let surf = 24 + ((x / 3 + y / 5) % 17);
2178 (z >= surf).then_some(0x80_50_70_90)
2179 });
2180 vxl.generate_mips(4);
2181 assert!(vxl.mip_count() >= 3, "need mips built for this test");
2182 let grid = GridView::from_single_vxl(&vxl);
2183 let (w, h) = (96u32, 96u32);
2184 let cam = Camera::orbit(0.7, 0.6, 110.0, [32.0, 32.0, 36.0]);
2185
2186 let m0 = render_mask_mip(grid, &cam, w, h, 0);
2187 let m2 = render_mask_mip(grid, &cam, w, h, 2);
2188
2189 let c0 = m0.iter().filter(|&&b| b).count();
2190 let c2 = m2.iter().filter(|&&b| b).count();
2191 assert!(c0 > 200 && c2 > 200, "both mips visible (c0={c0} c2={c2})");
2192 let ratio = c2 as f32 / c0 as f32;
2198 assert!(
2199 (0.7..1.4).contains(&ratio),
2200 "mip-2 coverage {c2} vs mip-0 {c0} (ratio {ratio:.2}) diverged"
2201 );
2202 }
2203
2204 #[test]
2210 #[ignore = "perf benchmark — run explicitly with --ignored"]
2211 fn bench_terrain() {
2212 use std::time::Instant;
2213 const NC: i32 = 6;
2215 let cs = crate::grid_view::CHUNK_SIZE_Z; let _ = cs;
2217 let mut vxls: Vec<roxlap_formats::vxl::Vxl> = Vec::new();
2218 for cy in 0..NC {
2219 for cx in 0..NC {
2220 let (ox, oy) = (cx * 128, cy * 128);
2221 let mut v = roxlap_formats::vxl::Vxl::from_dense(128, |x, y, z| {
2222 let (gx, gy) = (ox + x as i32, oy + y as i32);
2223 let surf = 90 + ((gx / 7 + gy / 9).rem_euclid(40)) + ((gx / 23).rem_euclid(20));
2224 (z as i32 >= surf).then_some(0x80_50_70_90 + (x ^ y) % 0x30)
2225 });
2226 v.generate_mips(4);
2227 vxls.push(v);
2228 }
2229 }
2230 let views: Vec<Option<GridView>> = vxls
2231 .iter()
2232 .map(|v| Some(GridView::from_single_vxl(v)))
2233 .collect();
2234 let cg = crate::ChunkGrid {
2235 chunks: &views,
2236 origin_chunk_xy: [0, 0],
2237 origin_chunk_z: 0,
2238 chunks_x: NC as u32,
2239 chunks_y: NC as u32,
2240 chunks_z: 1,
2241 };
2242 let grid = GridView::from_chunk_grid(&cg, 128);
2243
2244 let (w, h) = (960u32, 600u32);
2245 let mut settings = OpticastSettings::for_oracle_framebuffer(w, h);
2246 settings.max_scan_dist = 512;
2247 let n = (w * h) as usize;
2248 let mut fb = vec![0u32; n];
2249 let mut zb = vec![f32::INFINITY; n];
2250 let centre = [f64::from(NC * 128) / 2.0, f64::from(NC * 128) / 2.0, 60.0];
2251
2252 let poses = [
2255 (
2256 "horizon",
2257 Camera::from_yaw_pitch([20.0, 20.0, 40.0], 0.6, 0.15),
2258 ),
2259 ("down", Camera::orbit(0.7, 1.0, 130.0, centre)),
2260 ];
2261 for (name, cam) in poses {
2262 {
2263 let mut sink = RasterSink::new(&mut fb, &mut zb);
2264 prof::reset();
2265 render_dda(
2266 &cam,
2267 &settings,
2268 grid,
2269 w as usize,
2270 &DdaEnv::default(),
2271 0,
2272 &mut sink,
2273 );
2274 }
2275 let (cells, bricks, surf) = prof::read();
2276 let iters = 6;
2277 let t0 = Instant::now();
2278 for _ in 0..iters {
2279 let mut sink = RasterSink::new(&mut fb, &mut zb);
2280 render_dda(
2281 &cam,
2282 &settings,
2283 grid,
2284 w as usize,
2285 &DdaEnv::default(),
2286 0,
2287 &mut sink,
2288 );
2289 }
2290 let ms = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(iters);
2291 let hits = fb.iter().filter(|&&c| c != 0).count();
2292 eprintln!(
2293 "[{name}] {w}x{h} 1-thread: {ms:.1} ms | hits={hits}/{n} | per-px: cells={:.1} bricks={:.1} surf={:.1}",
2294 cells as f64 / n as f64,
2295 bricks as f64 / n as f64,
2296 surf as f64 / n as f64,
2297 );
2298 }
2299 }
2300
2301 #[test]
2305 fn parallel_matches_sequential() {
2306 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2307 let surf = 28 + ((x / 4 + y / 6) % 13);
2308 (z >= surf).then_some(0x80_40_60_80 + (x ^ y) % 0x30)
2309 });
2310 let grid = GridView::from_single_vxl(&vxl);
2311 let (w, h) = (96u32, 96u32);
2312 let cam = Camera::orbit(0.8, 0.55, 100.0, [32.0, 32.0, 40.0]);
2313 let env = DdaEnv {
2314 sky: None,
2315 fog_color: 0x00_20_30_40,
2316 fog_max_dist: 120.0,
2317 side_shades: [0, 0, 0, 0, 0x30, 0x10],
2318 materials: None,
2319 terrain_materials: &[],
2320 };
2321
2322 let (seq_fb, seq_zb) = render_brickmap_env(grid, &cam, w, h, &env);
2323
2324 let n = (w * h) as usize;
2325 let mut par_fb = vec![0u32; n];
2326 let mut par_zb = vec![f32::INFINITY; n];
2327 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2328 let (cache, mip) = local_cache(&grid, 0);
2329 render_dda_parallel(
2330 &cam,
2331 &settings,
2332 grid,
2333 &mut par_fb,
2334 &mut par_zb,
2335 w as usize,
2336 &env,
2337 &cache,
2338 mip,
2339 );
2340 assert!(par_fb == seq_fb, "parallel colour differs from sequential");
2341 assert!(
2342 par_zb
2343 .iter()
2344 .zip(&seq_zb)
2345 .all(|(a, b)| a.to_bits() == b.to_bits()),
2346 "parallel depth differs from sequential"
2347 );
2348 }
2349
2350 #[test]
2356 fn cliff_side_is_solid_not_see_through() {
2357 const TOP_Z: u32 = 50;
2358 const COL: u32 = 0x80_77_88_99;
2359 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |_, _, z| (z >= TOP_Z).then_some(COL));
2360 let grid = GridView::from_single_vxl(&vxl);
2361
2362 assert_eq!(grid.voxel_color(4, 4, TOP_Z), Some(COL));
2364 assert_eq!(grid.voxel_color(4, 4, 150), None);
2366 assert_eq!(grid.surface_color(4, 4, 150), Some(COL));
2369 assert_eq!(grid.surface_color(4, 4, 10), None);
2371 }
2372
2373 #[test]
2376 fn camera_inside_solid_hits_everywhere() {
2377 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, _| Some(0x80_55_55_55));
2378 let grid = GridView::from_single_vxl(&vxl);
2379 let cam = Camera {
2380 pos: [8.0, 8.0, 128.0],
2381 right: [1.0, 0.0, 0.0],
2382 down: [0.0, 1.0, 0.0],
2383 forward: [0.0, 0.0, 1.0],
2384 };
2385 let (w, h) = (32u32, 32u32);
2386 let mask = render_mask(grid, &cam, w, h);
2387 assert!(
2388 mask.iter().all(|&b| b),
2389 "every ray must hit when the camera is inside solid"
2390 );
2391 }
2392
2393 #[test]
2399 fn single_voxel_silhouette_has_no_notch() {
2400 const C: u32 = 0x80_FF_80_40;
2401 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |x, y, z| {
2402 (x == 8 && y == 8 && z == 8).then_some(C)
2403 });
2404 let grid = GridView::from_single_vxl(&vxl);
2405
2406 let cam = Camera::orbit(0.7, 0.6, 4.0, [8.5, 8.5, 8.5]);
2409 let (w, h) = (96u32, 96u32);
2410 let mask = render_mask(grid, &cam, w, h);
2411
2412 let hits = mask.iter().filter(|&&b| b).count();
2413 assert!(
2414 hits > 30,
2415 "silhouette too small to be meaningful: {hits} px"
2416 );
2417 assert!(
2418 rows_have_no_holes(&mask, w, h),
2419 "row-interior gap in single-voxel silhouette (notch)"
2420 );
2421 assert!(
2422 cols_have_no_holes(&mask, w, h),
2423 "column-interior gap in single-voxel silhouette (notch)"
2424 );
2425 }
2426}