1use std::collections::HashMap;
36
37use rayon::prelude::*;
38
39use crate::camera_math::{self, CameraState};
40use crate::grid_view::GridView;
41use crate::opticast::OpticastSettings;
42use crate::raster_target::RasterTarget;
43use crate::sky::Sky;
44use crate::Camera;
45use roxlap_formats::material::{material_for_color, Material, MaterialTable};
46
47#[derive(Clone, Copy)]
54pub struct DdaEnv<'a> {
55 pub sky: Option<&'a Sky>,
58 pub fog_color: u32,
61 pub fog_max_dist: f32,
63 pub side_shades: [i8; 6],
66 pub materials: Option<&'a MaterialTable>,
69 pub terrain_materials: &'a [(u32, u8)],
73 pub lights: CpuLights<'a>,
79 pub world_shadow: Option<WorldShadowCtx<'a>>,
84}
85
86#[derive(Clone, Copy)]
88pub struct CpuPointLight {
89 pub pos: [f32; 3],
91 pub color: [f32; 3],
93 pub intensity: f32,
94 pub radius: f32,
96 pub casts_shadow: bool,
101}
102
103#[derive(Clone, Copy, Default)]
109pub struct CpuLights<'a> {
110 pub enabled: bool,
112 pub sun: bool,
114 pub sun_dir: [f32; 3],
116 pub sun_color: [f32; 3],
117 pub sun_intensity: f32,
118 pub sun_casts_shadow: bool,
120 pub points: &'a [CpuPointLight],
122 pub ambient: [f32; 3],
124 pub bands: u32,
126 pub shadow_tint: [f32; 3],
128 pub shadow_strength: f32,
132 pub shadow_bias: f32,
135 pub shadow_max_dist: f32,
138}
139
140impl Default for DdaEnv<'_> {
141 fn default() -> Self {
142 Self {
143 sky: None,
144 fog_color: 0,
145 fog_max_dist: 0.0,
146 side_shades: [0; 6],
147 materials: None,
148 terrain_materials: &[],
149 lights: CpuLights::default(),
150 world_shadow: None,
151 }
152 }
153}
154
155pub trait PixelSink {
163 fn put(&mut self, idx: usize, color: u32, dist: f32);
167}
168
169pub struct RasterSink<'a> {
176 target: RasterTarget<'a>,
177 len: usize,
178}
179
180impl<'a> RasterSink<'a> {
181 #[must_use]
184 pub fn new(framebuffer: &'a mut [u32], zbuffer: &'a mut [f32]) -> Self {
185 debug_assert_eq!(framebuffer.len(), zbuffer.len());
186 let len = framebuffer.len();
187 Self {
188 target: RasterTarget::new(framebuffer, zbuffer),
189 len,
190 }
191 }
192}
193
194impl PixelSink for RasterSink<'_> {
195 fn put(&mut self, idx: usize, color: u32, dist: f32) {
196 if idx < self.len {
197 unsafe {
200 self.target.write_color(idx, color);
201 self.target.write_depth(idx, dist);
202 }
203 }
204 }
205}
206
207#[derive(Debug, Clone, Copy)]
209struct Hit {
210 color: u32,
211 dist: f32,
212}
213
214#[cfg(test)]
216pub(crate) mod prof {
217 use std::cell::Cell;
218 thread_local! {
219 pub static CELLS: Cell<u64> = const { Cell::new(0) };
220 pub static BRICKS: Cell<u64> = const { Cell::new(0) };
221 pub static SURF: Cell<u64> = const { Cell::new(0) };
222 }
223 pub fn reset() {
224 CELLS.with(|x| x.set(0));
225 BRICKS.with(|x| x.set(0));
226 SURF.with(|x| x.set(0));
227 }
228 pub fn read() -> (u64, u64, u64) {
229 (
230 CELLS.with(Cell::get),
231 BRICKS.with(Cell::get),
232 SURF.with(Cell::get),
233 )
234 }
235}
236
237#[inline]
256pub(crate) fn shade(color: u32, bright_sub: u32) -> u32 {
257 let a = ((color >> 24) & 0xff).saturating_sub(bright_sub);
258 let ch = |shift: u32| -> u32 { ((((color >> shift) & 0xff) * a) >> 7).min(255) };
259 0x8000_0000 | (ch(16) << 16) | (ch(8) << 8) | ch(0)
260}
261
262#[inline]
264fn cel_band(x: f32, bands: u32) -> f32 {
265 let b = bands as f32;
266 ((x * b).round() / b).clamp(0.0, 1.0)
267}
268
269#[inline]
272fn point_falloff(d: f32, radius: f32) -> f32 {
273 let x = (1.0 - d / radius).clamp(0.0, 1.0);
274 x * x
275}
276
277#[inline]
281fn face_normal_cpu(axis: usize, step: [i32; 3]) -> [f32; 3] {
282 let mut n = [0.0f32; 3];
283 if axis < 3 {
284 n[axis] = -(step[axis] as f32);
285 } else {
286 n[2] = -1.0;
287 }
288 n
289}
290
291#[inline]
292fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
293 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
294}
295
296pub(crate) trait ShadowTester {
304 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool;
305}
306
307pub trait WorldOccluder: Sync {
318 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool;
319}
320
321#[derive(Clone, Copy)]
328pub struct WorldShadowCtx<'a> {
329 pub occluder: &'a dyn WorldOccluder,
330 pub origin: [f32; 3],
331 pub cols: [[f32; 3]; 3],
332}
333
334impl<'a> WorldShadowCtx<'a> {
335 #[must_use]
338 pub fn identity(occluder: &'a dyn WorldOccluder) -> Self {
339 Self {
340 occluder,
341 origin: [0.0; 3],
342 cols: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
343 }
344 }
345}
346
347pub struct CompositeOccluder<'a> {
351 pub a: &'a dyn WorldOccluder,
352 pub b: &'a dyn WorldOccluder,
353}
354
355impl WorldOccluder for CompositeOccluder<'_> {
356 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
357 self.a.occluded_world(origin, dir, max_t) || self.b.occluded_world(origin, dir, max_t)
358 }
359}
360
361pub(crate) struct WorldShadow<'a> {
366 pub ctx: WorldShadowCtx<'a>,
367}
368
369impl ShadowTester for WorldShadow<'_> {
370 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
371 let c = &self.ctx.cols;
372 let wo = [
374 self.ctx.origin[0] + c[0][0] * origin[0] + c[1][0] * origin[1] + c[2][0] * origin[2],
375 self.ctx.origin[1] + c[0][1] * origin[0] + c[1][1] * origin[1] + c[2][1] * origin[2],
376 self.ctx.origin[2] + c[0][2] * origin[0] + c[1][2] * origin[1] + c[2][2] * origin[2],
377 ];
378 let wd = [
379 c[0][0] * dir[0] + c[1][0] * dir[1] + c[2][0] * dir[2],
380 c[0][1] * dir[0] + c[1][1] * dir[1] + c[2][1] * dir[2],
381 c[0][2] * dir[0] + c[1][2] * dir[1] + c[2][2] * dir[2],
382 ];
383 self.ctx.occluder.occluded_world(wo, wd, max_t)
384 }
385}
386
387fn shade_lit_cpu(
394 color: u32,
395 bright_sub: u32,
396 axis: usize,
397 step: [i32; 3],
398 cellc: [i32; 3],
399 cell_size: f32,
400 l: &CpuLights<'_>,
401 shadow: Option<&mut dyn ShadowTester>,
402) -> u32 {
403 let a_b = ((color >> 24) & 0xff).saturating_sub(bright_sub);
404 let ao = a_b as f32 / 128.0;
405 let albedo = [
406 ((color >> 16) & 0xff) as f32 / 255.0,
407 ((color >> 8) & 0xff) as f32 / 255.0,
408 (color & 0xff) as f32 / 255.0,
409 ];
410 let n = face_normal_cpu(axis, step);
411 let center = [
413 (cellc[0] as f32 + 0.5) * cell_size,
414 (cellc[1] as f32 + 0.5) * cell_size,
415 (cellc[2] as f32 + 0.5) * cell_size,
416 ];
417 shade_dynamic(albedo, ao, n, center, l, shadow)
418}
419
420pub(crate) fn shade_dynamic(
426 albedo: [f32; 3],
427 ao: f32,
428 n: [f32; 3],
429 sample: [f32; 3],
430 l: &CpuLights<'_>,
431 shadow: Option<&mut dyn ShadowTester>,
432) -> u32 {
433 let styled = l.bands > 0;
434 let mut shadow = shadow;
438 let shadow_origin = [
439 sample[0] + n[0] * l.shadow_bias,
440 sample[1] + n[1] * l.shadow_bias,
441 sample[2] + n[2] * l.shadow_bias,
442 ];
443 let in_shadow = 1.0 - l.shadow_strength;
444
445 let sun_key = if l.sun {
447 let ndl = dot3(n, l.sun_dir).max(0.0);
448 if ndl > 0.0 && l.sun_casts_shadow {
449 let occ = shadow
450 .as_deref_mut()
451 .is_some_and(|s| s.occluded(shadow_origin, l.sun_dir, l.shadow_max_dist));
452 if occ {
453 ndl * in_shadow
454 } else {
455 ndl
456 }
457 } else {
458 ndl
459 }
460 } else {
461 0.0
462 };
463
464 let mut lit = if styled {
466 let key = cel_band(sun_key, l.bands);
467 let m = |i: usize| {
468 let warm = l.sun_color[i] * l.sun_intensity;
469 (l.shadow_tint[i] + (warm - l.shadow_tint[i]) * key) * ao
470 };
471 [albedo[0] * m(0), albedo[1] * m(1), albedo[2] * m(2)]
472 } else {
473 let base = |i: usize| {
474 albedo[i] * l.ambient[i] * ao + albedo[i] * l.sun_color[i] * l.sun_intensity * sun_key
475 };
476 [base(0), base(1), base(2)]
477 };
478
479 for p in l.points {
482 let d3 = [
483 p.pos[0] - sample[0],
484 p.pos[1] - sample[1],
485 p.pos[2] - sample[2],
486 ];
487 let dist = (d3[0] * d3[0] + d3[1] * d3[1] + d3[2] * d3[2]).sqrt();
488 if dist < p.radius && dist > 1e-4 {
489 let inv = 1.0 / dist;
490 let ldir = [d3[0] * inv, d3[1] * inv, d3[2] * inv];
491 let ndl = dot3(n, ldir).max(0.0);
492 if ndl > 0.0 {
493 let sh = if p.casts_shadow
495 && shadow
496 .as_deref_mut()
497 .is_some_and(|s| s.occluded(shadow_origin, ldir, dist))
498 {
499 in_shadow
500 } else {
501 1.0
502 };
503 let mut f = ndl * point_falloff(dist, p.radius) * sh;
504 if styled {
505 f = cel_band(f, l.bands);
506 }
507 for i in 0..3 {
508 lit[i] += albedo[i] * p.color[i] * p.intensity * f;
509 }
510 }
511 }
512 }
513
514 let pack = |v: f32| -> u32 { (v.clamp(0.0, 1.0) * 255.0) as u32 };
515 0x8000_0000 | (pack(lit[0]) << 16) | (pack(lit[1]) << 8) | pack(lit[2])
516}
517
518#[inline]
522fn apply_fog(color: u32, depth: f32, env: &DdaEnv<'_>) -> u32 {
523 if env.fog_max_dist <= 0.0 {
524 return color;
525 }
526 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
527 let f = ((depth / env.fog_max_dist).clamp(0.0, 1.0) * 256.0) as u32; let g = 256 - f;
529 let fog = env.fog_color;
530 let mix = |shift: u32| -> u32 {
531 let src = (color >> shift) & 0xff;
532 let dst = (fog >> shift) & 0xff;
533 ((src * g + dst * f) >> 8).min(255)
534 };
535 0x8000_0000 | (mix(16) << 16) | (mix(8) << 8) | mix(0)
536}
537
538#[inline]
543fn terrain_material(env: &DdaEnv<'_>, color: u32) -> Material {
544 match env.materials {
545 Some(table) if !env.terrain_materials.is_empty() => {
546 table.get(material_for_color(env.terrain_materials, color))
547 }
548 _ => Material::OPAQUE,
549 }
550}
551
552#[inline]
555fn composite_over(accum: [f32; 3], trans: f32, bg: u32) -> u32 {
556 let b = rgb_to_f32(bg);
557 f32_to_rgb([
558 accum[0] + trans * b[0],
559 accum[1] + trans * b[1],
560 accum[2] + trans * b[2],
561 ])
562}
563
564#[inline]
569fn finalize_exit(
570 touched: bool,
571 accum: [f32; 3],
572 trans: f32,
573 env: &DdaEnv<'_>,
574 dir: [f32; 3],
575 dist: f32,
576) -> Option<Hit> {
577 if !touched {
578 return None;
579 }
580 let bg = match env.sky {
581 Some(s) => sample_sky(s, dir),
582 None => 0x8000_0000 | (env.fog_color & 0x00ff_ffff),
583 };
584 Some(Hit {
585 color: composite_over(accum, trans, bg),
586 dist,
587 })
588}
589
590#[inline]
593#[allow(clippy::cast_precision_loss)]
594fn rgb_to_f32(c: u32) -> [f32; 3] {
595 [
596 ((c >> 16) & 0xff) as f32 / 255.0,
597 ((c >> 8) & 0xff) as f32 / 255.0,
598 (c & 0xff) as f32 / 255.0,
599 ]
600}
601
602#[inline]
604#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
605fn f32_to_rgb(c: [f32; 3]) -> u32 {
606 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
607 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
608}
609
610#[allow(
619 clippy::cast_possible_truncation,
620 clippy::cast_sign_loss,
621 clippy::cast_precision_loss
622)]
623fn sample_sky(sky: &Sky, dir: [f32; 3]) -> u32 {
624 let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
625 if len < 1e-9 {
626 return 0x8000_0000;
627 }
628 let d = [dir[0] / len, dir[1] / len, dir[2] / len];
629 let xsiz_full = sky.lat.len().max(1) as i32; let pi = std::f32::consts::PI;
631 let elev01 = (-d[2]).clamp(-1.0, 1.0).acos() / pi; let x = (elev01 * xsiz_full as f32) as i32;
636 let x = x.clamp(0, xsiz_full - 1);
637 let y = if sky.ysiz <= 1 {
639 0
640 } else {
641 let az = d[1].atan2(d[0]); let yf = ((az / (pi * 2.0)) + 0.5) * sky.ysiz as f32;
643 (yf as i32).rem_euclid(sky.ysiz)
644 };
645 let idx = (y * xsiz_full + x) as usize;
646 let px = sky.pixels.get(idx).copied().unwrap_or(0) as u32;
647 0x8000_0000 | (px & 0x00ff_ffff)
648}
649
650#[allow(clippy::cast_possible_truncation)]
660pub fn render_sky_fill(
661 fb: &mut [u32],
662 zb: &[f32],
663 pitch_pixels: usize,
664 width: u32,
665 height: u32,
666 cam: &CameraState,
667 settings: &OpticastSettings,
668 sky: &Sky,
669) {
670 for py in 0..height {
671 let row = py as usize * pitch_pixels;
672 for px in 0..width {
673 let idx = row + px as usize;
674 if zb[idx].is_finite() {
675 continue; }
677 let (_origin, dir) = pixel_ray(cam, settings, px, py);
678 fb[idx] = sample_sky(sky, dir);
679 }
680 }
681}
682
683#[must_use]
695pub fn pixel_ray(
696 cs: &CameraState,
697 settings: &OpticastSettings,
698 px: u32,
699 py: u32,
700) -> ([f32; 3], [f32; 3]) {
701 #[allow(clippy::cast_precision_loss)]
703 let sx = px as f32 - settings.hx;
704 #[allow(clippy::cast_precision_loss)]
705 let sy = py as f32 - settings.hy;
706 let dir = [
707 sx * cs.right[0] + sy * cs.down[0] + settings.hz * cs.forward[0],
708 sx * cs.right[1] + sy * cs.down[1] + settings.hz * cs.forward[1],
709 sx * cs.right[2] + sy * cs.down[2] + settings.hz * cs.forward[2],
710 ];
711 (cs.pos, dir)
712}
713
714pub(crate) fn intersect_aabb(
720 o: [f32; 3],
721 dir: [f32; 3],
722 lo: [f32; 3],
723 hi: [f32; 3],
724) -> Option<(f32, f32)> {
725 let mut t0 = 0.0f32;
726 let mut t1 = f32::INFINITY;
727 for a in 0..3 {
728 if dir[a].abs() < 1e-9 {
729 if o[a] < lo[a] || o[a] > hi[a] {
731 return None;
732 }
733 } else {
734 let inv = 1.0 / dir[a];
735 let mut ta = (lo[a] - o[a]) * inv;
736 let mut tb = (hi[a] - o[a]) * inv;
737 if ta > tb {
738 core::mem::swap(&mut ta, &mut tb);
739 }
740 t0 = t0.max(ta);
741 t1 = t1.min(tb);
742 if t0 > t1 {
743 return None;
744 }
745 }
746 }
747 Some((t0, t1))
748}
749
750const BRICK: i32 = 8;
752
753#[derive(Debug)]
767pub(crate) struct BrickMap {
768 nb: [i32; 3],
770 bits: Vec<u64>,
773 ns: [i32; 3],
776 super_bits: Vec<u64>,
781}
782
783const SUPER: i32 = BRICK * BRICK;
785
786impl BrickMap {
787 #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
790 fn build(grid: &GridView<'_>, mip: u32) -> Self {
791 let vsid_m = (grid.vsid >> mip).max(1) as i32;
792 let z_m = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1) as i32;
793 let nb = [
794 (vsid_m + BRICK - 1) / BRICK,
795 (vsid_m + BRICK - 1) / BRICK,
796 (z_m + BRICK - 1) / BRICK,
797 ];
798 let ns = [
799 (nb[0] + BRICK - 1) / BRICK,
800 (nb[1] + BRICK - 1) / BRICK,
801 (nb[2] + BRICK - 1) / BRICK,
802 ];
803 let count = (nb[0] * nb[1] * nb[2]) as usize;
804 let scount = (ns[0] * ns[1] * ns[2]) as usize;
805 let mut bits = vec![0u64; count.div_ceil(64)];
806 let mut super_bits = vec![0u64; scount.div_ceil(64)];
807 for y in 0..vsid_m {
808 for x in 0..vsid_m {
809 let (bx, by) = (x / BRICK, y / BRICK);
810 grid.for_each_run_mip(x as u32, y as u32, mip, |top, bot| {
811 for bz in (top / BRICK)..=((bot - 1) / BRICK) {
812 let idx = ((bz * nb[1] + by) * nb[0] + bx) as usize;
813 bits[idx / 64] |= 1u64 << (idx % 64);
814 let sidx =
815 (((bz / BRICK) * ns[1] + by / BRICK) * ns[0] + bx / BRICK) as usize;
816 super_bits[sidx / 64] |= 1u64 << (sidx % 64);
817 }
818 });
819 }
820 }
821 Self {
822 nb,
823 bits,
824 ns,
825 super_bits,
826 }
827 }
828
829 #[inline]
831 #[allow(clippy::cast_sign_loss)]
832 fn occupied(&self, b: [i32; 3]) -> bool {
833 if b[0] < 0
834 || b[0] >= self.nb[0]
835 || b[1] < 0
836 || b[1] >= self.nb[1]
837 || b[2] < 0
838 || b[2] >= self.nb[2]
839 {
840 return false;
841 }
842 let idx = ((b[2] * self.nb[1] + b[1]) * self.nb[0] + b[0]) as usize;
843 (self.bits[idx / 64] >> (idx % 64)) & 1 != 0
844 }
845
846 #[inline]
848 #[allow(clippy::cast_sign_loss)]
849 fn occupied_super(&self, s: [i32; 3]) -> bool {
850 if s[0] < 0
851 || s[0] >= self.ns[0]
852 || s[1] < 0
853 || s[1] >= self.ns[1]
854 || s[2] < 0
855 || s[2] >= self.ns[2]
856 {
857 return false;
858 }
859 let idx = ((s[2] * self.ns[1] + s[1]) * self.ns[0] + s[0]) as usize;
860 (self.super_bits[idx / 64] >> (idx % 64)) & 1 != 0
861 }
862}
863
864pub(crate) fn dda_setup(
870 origin: [f32; 3],
871 dir: [f32; 3],
872 cell: [i32; 3],
873 cell_size: f32,
874) -> ([i32; 3], [f32; 3], [f32; 3]) {
875 let mut step = [0i32; 3];
876 let mut t_max = [f32::INFINITY; 3];
877 let mut t_delta = [f32::INFINITY; 3];
878 for a in 0..3 {
879 if dir[a] > 1e-9 {
880 step[a] = 1;
881 #[allow(clippy::cast_precision_loss)]
882 let boundary = (cell[a] + 1) as f32 * cell_size;
883 t_max[a] = (boundary - origin[a]) / dir[a];
884 t_delta[a] = cell_size / dir[a];
885 } else if dir[a] < -1e-9 {
886 step[a] = -1;
887 #[allow(clippy::cast_precision_loss)]
888 let boundary = cell[a] as f32 * cell_size;
889 t_max[a] = (boundary - origin[a]) / dir[a];
890 t_delta[a] = -cell_size / dir[a];
891 }
892 }
893 (step, t_max, t_delta)
894}
895
896#[inline]
899pub(crate) fn min_axis(t_max: [f32; 3]) -> usize {
900 if t_max[0] <= t_max[1] && t_max[0] <= t_max[2] {
901 0
902 } else if t_max[1] <= t_max[2] {
903 1
904 } else {
905 2
906 }
907}
908
909#[derive(Debug, Default)]
919pub struct BrickCache {
920 maps: HashMap<(i32, i32, i32, u32), (u64, BrickMap)>,
921}
922
923impl BrickCache {
924 #[must_use]
925 pub fn new() -> Self {
926 Self::default()
927 }
928
929 pub fn ensure(&mut self, chunk: [i32; 3], mip: u32, version: u64, view: &GridView<'_>) {
932 let key = (chunk[0], chunk[1], chunk[2], mip);
933 let stale = self.maps.get(&key).map_or(true, |(v, _)| *v != version);
934 if stale {
935 self.maps.insert(key, (version, BrickMap::build(view, mip)));
936 }
937 }
938
939 #[inline]
940 fn get(&self, chunk: [i32; 3], mip: u32) -> Option<&BrickMap> {
941 self.maps
942 .get(&(chunk[0], chunk[1], chunk[2], mip))
943 .map(|(_, m)| m)
944 }
945
946 pub fn retain_chunks(&mut self, keep: impl Fn([i32; 3]) -> bool) {
949 self.maps.retain(|k, _| keep([k.0, k.1, k.2]));
950 }
951}
952
953#[allow(clippy::cast_possible_wrap)]
958fn local_cache(grid: &GridView<'_>, requested_mip: u32) -> (BrickCache, u32) {
959 let mip = effective_mip(grid, requested_mip);
960 let mut cache = BrickCache::new();
961 if let Some(cg) = grid.chunk_grid {
962 for dz in 0..cg.chunks_z as i32 {
963 for dy in 0..cg.chunks_y as i32 {
964 for dx in 0..cg.chunks_x as i32 {
965 let slot = ((dz * cg.chunks_y as i32 + dy) * cg.chunks_x as i32 + dx) as usize;
966 if let Some(Some(view)) = cg.chunks.get(slot) {
967 let ch = [
968 cg.origin_chunk_xy[0] + dx,
969 cg.origin_chunk_xy[1] + dy,
970 cg.origin_chunk_z + dz,
971 ];
972 cache.ensure(ch, mip, 0, view);
973 }
974 }
975 }
976 }
977 } else {
978 cache.ensure([0, 0, 0], mip, 0, grid);
979 }
980 (cache, mip)
981}
982
983#[must_use]
988pub fn effective_mip(grid: &GridView<'_>, requested: u32) -> u32 {
989 if requested == 0 {
990 return 0;
991 }
992 let mut m = requested;
993 if let Some(cg) = grid.chunk_grid {
994 for c in cg.chunks.iter().flatten() {
995 m = m.min(c.mip_count().saturating_sub(1));
996 }
997 } else {
998 m = m.min(grid.mip_count().saturating_sub(1));
999 }
1000 m
1001}
1002
1003struct Sampler<'a> {
1017 grid: GridView<'a>,
1018 bricks: &'a BrickCache,
1019 mip: u32,
1022 xy_shift: u32,
1031 xy_mask: i32,
1032 z_shift: u32,
1033 z_mask: i32,
1034 cur_ch: [i32; 3],
1035 cur_view: Option<GridView<'a>>,
1036 cur_brick: Option<&'a BrickMap>,
1037 has_cur: bool,
1038}
1039
1040impl<'a> Sampler<'a> {
1041 fn new(grid: GridView<'a>, bricks: &'a BrickCache, mip: u32) -> Self {
1042 let cs_xy = (grid.chunk_size_xy >> mip).max(1);
1043 let cs_z = (crate::grid_view::CHUNK_SIZE_Z >> mip).max(1);
1044 debug_assert!(
1045 cs_xy.is_power_of_two() && cs_z.is_power_of_two(),
1046 "chunk dims must be powers of two for the shift/mask split"
1047 );
1048 #[allow(clippy::cast_possible_wrap)]
1049 Self {
1050 grid,
1051 bricks,
1052 mip,
1053 xy_shift: cs_xy.trailing_zeros(),
1054 xy_mask: cs_xy as i32 - 1,
1055 z_shift: cs_z.trailing_zeros(),
1056 z_mask: cs_z as i32 - 1,
1057 cur_ch: [0; 3],
1058 cur_view: None,
1059 cur_brick: None,
1060 has_cur: false,
1061 }
1062 }
1063
1064 fn select_chunk(&mut self, ch: [i32; 3]) {
1066 if self.has_cur && self.cur_ch == ch {
1067 return;
1068 }
1069 self.cur_view = self.grid.chunk_at_xyz(ch);
1070 self.cur_brick = self.bricks.get(ch, self.mip);
1071 self.cur_ch = ch;
1072 self.has_cur = true;
1073 }
1074
1075 #[allow(clippy::cast_sign_loss)]
1080 fn locate(&self, c: [i32; 3]) -> ([i32; 3], [u32; 3]) {
1081 let ch = [
1082 c[0] >> self.xy_shift,
1083 c[1] >> self.xy_shift,
1084 c[2] >> self.z_shift,
1085 ];
1086 let loc = [
1087 (c[0] & self.xy_mask) as u32,
1088 (c[1] & self.xy_mask) as u32,
1089 (c[2] & self.z_mask) as u32,
1090 ];
1091 (ch, loc)
1092 }
1093
1094 #[allow(clippy::cast_possible_wrap)]
1098 fn hit(&mut self, c: [i32; 3]) -> Option<u32> {
1099 #[cfg(test)]
1100 prof::SURF.with(|x| x.set(x.get() + 1));
1101 let (ch, loc) = self.locate(c);
1102 self.select_chunk(ch);
1103 let occupied = self.cur_brick.is_some_and(|bm| {
1104 bm.occupied([
1105 loc[0] as i32 / BRICK,
1106 loc[1] as i32 / BRICK,
1107 loc[2] as i32 / BRICK,
1108 ])
1109 });
1110 if !occupied {
1111 return None;
1112 }
1113 self.cur_view?
1114 .surface_color_mip(loc[0], loc[1], loc[2], self.mip)
1115 }
1116
1117 #[inline]
1119 fn cells_per_chunk_xy(&self) -> i32 {
1120 1 << self.xy_shift
1121 }
1122 #[inline]
1123 fn cells_per_chunk_z(&self) -> i32 {
1124 1 << self.z_shift
1125 }
1126
1127 #[allow(clippy::cast_sign_loss)]
1132 fn brick_occupied(&mut self, brick: [i32; 3]) -> bool {
1133 let c0 = [brick[0] << 3, brick[1] << 3, brick[2] << 3];
1135 let ch = [
1136 c0[0] >> self.xy_shift,
1137 c0[1] >> self.xy_shift,
1138 c0[2] >> self.z_shift,
1139 ];
1140 self.select_chunk(ch);
1141 self.cur_brick.is_some_and(|bm| {
1142 bm.occupied([
1143 (c0[0] & self.xy_mask) >> 3,
1144 (c0[1] & self.xy_mask) >> 3,
1145 (c0[2] & self.z_mask) >> 3,
1146 ])
1147 })
1148 }
1149
1150 #[allow(clippy::cast_sign_loss)]
1155 fn super_occupied(&mut self, s: [i32; 3]) -> bool {
1156 let c0 = [s[0] << 6, s[1] << 6, s[2] << 6];
1158 let ch = [
1159 c0[0] >> self.xy_shift,
1160 c0[1] >> self.xy_shift,
1161 c0[2] >> self.z_shift,
1162 ];
1163 self.select_chunk(ch);
1164 self.cur_brick.is_some_and(|bm| {
1165 bm.occupied_super([
1166 (c0[0] & self.xy_mask) >> 6,
1167 (c0[1] & self.xy_mask) >> 6,
1168 (c0[2] & self.z_mask) >> 6,
1169 ])
1170 })
1171 }
1172}
1173
1174const SHADOW_MAX_STEPS: u32 = 1024;
1178
1179struct SamplerShadow<'s, 'a> {
1186 sampler: &'s mut Sampler<'a>,
1187 cell_size: f32,
1188 lo_c: [i32; 3],
1189 hi_c: [i32; 3],
1190}
1191
1192impl ShadowTester for SamplerShadow<'_, '_> {
1193 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
1194 fn occluded(&mut self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
1195 let cs = self.cell_size;
1196 let mut cellc = [
1197 (origin[0] / cs).floor() as i32,
1198 (origin[1] / cs).floor() as i32,
1199 (origin[2] / cs).floor() as i32,
1200 ];
1201 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cs);
1202 let mut t_curr = 0.0f32;
1203 for _ in 0..SHADOW_MAX_STEPS {
1204 if cellc[0] < self.lo_c[0]
1205 || cellc[0] >= self.hi_c[0]
1206 || cellc[1] < self.lo_c[1]
1207 || cellc[1] >= self.hi_c[1]
1208 || cellc[2] < self.lo_c[2]
1209 || cellc[2] >= self.hi_c[2]
1210 {
1211 return false; }
1213 if t_curr > max_t {
1214 return false; }
1216 if self.sampler.hit(cellc).is_some() {
1217 return true; }
1219 let axis = min_axis(t_max);
1220 t_curr = t_max[axis];
1221 cellc[axis] += step[axis];
1222 t_max[axis] += t_delta[axis];
1223 }
1224 false
1225 }
1226}
1227
1228#[allow(
1249 clippy::too_many_arguments,
1250 clippy::cast_possible_truncation,
1251 clippy::cast_sign_loss,
1252 clippy::cast_precision_loss
1253)]
1254fn cell_walk_skip(
1255 origin: [f32; 3],
1256 dir: [f32; 3],
1257 fwd_dot: f32,
1258 sampler: &mut Sampler<'_>,
1259 lo_c: [i32; 3],
1260 hi_c: [i32; 3],
1261 cell_size: f32,
1262 t_enter: f32,
1263 t_exit: f32,
1264 max_dist: f32,
1265 env: &DdaEnv<'_>,
1266) -> Option<Hit> {
1267 let has_super = sampler.cells_per_chunk_xy() >= SUPER && sampler.cells_per_chunk_z() >= SUPER;
1268 let has_brick = sampler.cells_per_chunk_xy() >= BRICK && sampler.cells_per_chunk_z() >= BRICK;
1269
1270 let start = t_enter + 1e-4;
1271 let p = [
1272 origin[0] + dir[0] * start,
1273 origin[1] + dir[1] * start,
1274 origin[2] + dir[2] * start,
1275 ];
1276 let mut cellc = [
1277 ((p[0] / cell_size).floor() as i32).clamp(lo_c[0], hi_c[0] - 1),
1278 ((p[1] / cell_size).floor() as i32).clamp(lo_c[1], hi_c[1] - 1),
1279 ((p[2] / cell_size).floor() as i32).clamp(lo_c[2], hi_c[2] - 1),
1280 ];
1281 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cellc, cell_size);
1282 let inv = [
1286 if step[0] != 0 { 1.0 / dir[0] } else { 0.0 },
1287 if step[1] != 0 { 1.0 / dir[1] } else { 0.0 },
1288 if step[2] != 0 { 1.0 / dir[2] } else { 0.0 },
1289 ];
1290 let mut t_curr = t_enter;
1291 let mut last_axis = 3usize;
1292 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
1295
1296 let mut accum = [0.0f32; 3];
1301 let mut trans = 1.0f32;
1302 let mut touched = false;
1303 let mut prev_solid = false;
1304 let mut prev_mat = 0u8;
1305
1306 let span = (hi_c[0] - lo_c[0]) + (hi_c[1] - lo_c[1]) + (hi_c[2] - lo_c[2]);
1309 let max_steps = span.max(0) as usize + 16;
1310 for _ in 0..max_steps {
1311 if cellc[0] < lo_c[0]
1312 || cellc[0] >= hi_c[0]
1313 || cellc[1] < lo_c[1]
1314 || cellc[1] >= hi_c[1]
1315 || cellc[2] < lo_c[2]
1316 || cellc[2] >= hi_c[2]
1317 {
1318 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1319 }
1320 let depth = t_curr * fwd_dot;
1321 if depth > max_dist || t_curr > t_exit {
1322 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1323 }
1324 if env.fog_max_dist > 0.0 && depth >= env.fog_max_dist {
1330 let fog = 0x8000_0000 | (env.fog_color & 0x00ff_ffff);
1331 let color = if touched {
1332 composite_over(accum, trans, fog)
1333 } else {
1334 fog
1335 };
1336 return Some(Hit {
1337 color,
1338 dist: env.fog_max_dist,
1339 });
1340 }
1341
1342 let skip_shift = if has_super
1345 && !sampler.super_occupied([cellc[0] >> 6, cellc[1] >> 6, cellc[2] >> 6])
1346 {
1347 Some(6u32)
1348 } else if has_brick
1349 && !sampler.brick_occupied([cellc[0] >> 3, cellc[1] >> 3, cellc[2] >> 3])
1350 {
1351 Some(3u32)
1352 } else {
1353 None
1354 };
1355 if let Some(sh) = skip_shift {
1356 #[cfg(test)]
1357 prof::BRICKS.with(|x| x.set(x.get() + 1));
1358 let mut best_t = f32::INFINITY;
1360 let mut best_axis = 3usize;
1361 let mut plane = [0i32; 3];
1362 for a in 0..3 {
1363 if step[a] == 0 {
1364 continue;
1365 }
1366 let idx = cellc[a] >> sh;
1367 plane[a] = if step[a] > 0 {
1368 (idx + 1) << sh
1369 } else {
1370 idx << sh
1371 };
1372 let tb = (plane[a] as f32 * cell_size - origin[a]) * inv[a];
1373 if tb < best_t {
1374 best_t = tb;
1375 best_axis = a;
1376 }
1377 }
1378 if best_axis == 3 {
1379 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1380 }
1381 let pb = [
1386 origin[0] + dir[0] * (best_t + 1e-4),
1387 origin[1] + dir[1] * (best_t + 1e-4),
1388 origin[2] + dir[2] * (best_t + 1e-4),
1389 ];
1390 let mut nc = [
1391 (pb[0] / cell_size).floor() as i32,
1392 (pb[1] / cell_size).floor() as i32,
1393 (pb[2] / cell_size).floor() as i32,
1394 ];
1395 nc[best_axis] = if step[best_axis] > 0 {
1396 plane[best_axis]
1397 } else {
1398 plane[best_axis] - 1
1399 };
1400 if nc[0] < lo_c[0]
1404 || nc[0] >= hi_c[0]
1405 || nc[1] < lo_c[1]
1406 || nc[1] >= hi_c[1]
1407 || nc[2] < lo_c[2]
1408 || nc[2] >= hi_c[2]
1409 {
1410 return finalize_exit(touched, accum, trans, env, dir, max_dist);
1411 }
1412 cellc = nc;
1413 for a in 0..3 {
1416 if step[a] > 0 {
1417 t_max[a] = ((cellc[a] + 1) as f32 * cell_size - origin[a]) * inv[a];
1418 } else if step[a] < 0 {
1419 t_max[a] = (cellc[a] as f32 * cell_size - origin[a]) * inv[a];
1420 }
1421 }
1422 t_curr = best_t.max(t_curr);
1423 last_axis = best_axis;
1424 prev_solid = false; continue;
1426 }
1427
1428 #[cfg(test)]
1430 prof::CELLS.with(|x| x.set(x.get() + 1));
1431 if let Some(color) = sampler.hit(cellc) {
1432 let bright_sub = side_shade_sub(env, last_axis, step);
1433 let shaded = if env.lights.enabled {
1439 let casts = env.lights.shadow_strength > 0.0
1440 && (env.lights.sun_casts_shadow
1441 || env.lights.points.iter().any(|p| p.casts_shadow));
1442 let mut world_sh;
1448 let mut sampler_sh;
1449 let tester: Option<&mut dyn ShadowTester> = if !casts {
1450 None
1451 } else if let Some(ctx) = env.world_shadow {
1452 world_sh = WorldShadow { ctx };
1453 Some(&mut world_sh)
1454 } else {
1455 sampler_sh = SamplerShadow {
1456 sampler: &mut *sampler,
1457 cell_size,
1458 lo_c,
1459 hi_c,
1460 };
1461 Some(&mut sampler_sh)
1462 };
1463 shade_lit_cpu(
1464 color,
1465 bright_sub,
1466 last_axis,
1467 step,
1468 cellc,
1469 cell_size,
1470 &env.lights,
1471 tester,
1472 )
1473 } else {
1474 shade(color, bright_sub)
1475 };
1476 let lit = apply_fog(shaded, depth.max(0.0), env);
1477 let m = terrain_material(env, color);
1478 if m.is_opaque() {
1479 let color = if touched {
1483 composite_over(accum, trans, lit)
1484 } else {
1485 lit
1486 };
1487 return Some(Hit {
1488 color,
1489 dist: depth.max(0.0),
1490 });
1491 }
1492 let mat_id = material_for_color(env.terrain_materials, color);
1493 let a = f32::from(m.alpha) / 255.0;
1494 if matches!(m.mode, roxlap_formats::material::BlendMode::Volumetric) {
1495 let t_exit = t_max[min_axis(t_max)];
1499 let seg_len = (t_exit - t_curr).max(0.0) * dir_len / cell_size;
1500 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
1501 let c = rgb_to_f32(lit);
1502 accum[0] += trans * eff_a * c[0];
1503 accum[1] += trans * eff_a * c[1];
1504 accum[2] += trans * eff_a * c[2];
1505 trans *= 1.0 - eff_a;
1506 touched = true;
1507 prev_mat = mat_id;
1508 if trans < 1.0 / 256.0 {
1509 return Some(Hit {
1510 color: f32_to_rgb(accum),
1511 dist: depth.max(0.0),
1512 });
1513 }
1514 } else if !prev_solid || mat_id != prev_mat {
1515 let c = rgb_to_f32(lit);
1519 accum[0] += trans * a * c[0];
1520 accum[1] += trans * a * c[1];
1521 accum[2] += trans * a * c[2];
1522 if !matches!(m.mode, roxlap_formats::material::BlendMode::Additive) {
1523 trans *= 1.0 - a; }
1525 touched = true;
1526 prev_mat = mat_id;
1527 if trans < 1.0 / 256.0 {
1528 return Some(Hit {
1529 color: f32_to_rgb(accum),
1530 dist: depth.max(0.0),
1531 });
1532 }
1533 }
1534 prev_solid = true;
1535 } else {
1536 prev_solid = false;
1537 }
1538 let axis = min_axis(t_max);
1539 last_axis = axis;
1540 t_curr = t_max[axis];
1541 cellc[axis] += step[axis];
1542 t_max[axis] += t_delta[axis];
1543 }
1544 None
1545}
1546
1547#[inline]
1553fn side_shade_sub(env: &DdaEnv<'_>, axis: usize, step: [i32; 3]) -> u32 {
1554 if axis >= 3 {
1555 return 0;
1556 }
1557 let face = axis * 2 + usize::from(step[axis] < 0);
1558 env.side_shades[face].max(0) as u32
1559}
1560
1561fn cast_ray(
1570 origin: [f32; 3],
1571 dir: [f32; 3],
1572 forward: [f32; 3],
1573 sampler: &mut Sampler<'_>,
1574 settings: &OpticastSettings,
1575 env: &DdaEnv<'_>,
1576) -> Option<Hit> {
1577 let (lo_i, hi_i) = sampler.grid.voxel_bounds();
1578 #[allow(clippy::cast_precision_loss)]
1579 let lo_f = [lo_i[0] as f32, lo_i[1] as f32, lo_i[2] as f32];
1580 #[allow(clippy::cast_precision_loss)]
1581 let hi_f = [hi_i[0] as f32, hi_i[1] as f32, hi_i[2] as f32];
1582 let (t_enter, t_exit) = intersect_aabb(origin, dir, lo_f, hi_f)?;
1583 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1584 #[allow(clippy::cast_precision_loss)]
1585 let max_dist = settings.max_scan_dist.max(1) as f32;
1586 let cell = 1i32 << sampler.mip;
1587 let cell_size = cell as f32;
1588 let lo_c = [
1589 lo_i[0].div_euclid(cell),
1590 lo_i[1].div_euclid(cell),
1591 lo_i[2].div_euclid(cell),
1592 ];
1593 let hi_c = [
1594 hi_i[0].div_euclid(cell),
1595 hi_i[1].div_euclid(cell),
1596 hi_i[2].div_euclid(cell),
1597 ];
1598 cell_walk_skip(
1599 origin, dir, fwd_dot, sampler, lo_c, hi_c, cell_size, t_enter, t_exit, max_dist, env,
1600 )
1601}
1602
1603pub fn render_dda(
1616 camera: &Camera,
1617 settings: &OpticastSettings,
1618 grid: GridView<'_>,
1619 pitch_pixels: usize,
1620 env: &DdaEnv<'_>,
1621 mip: u32,
1622 sink: &mut impl PixelSink,
1623) {
1624 let cs = camera_math::derive(
1625 camera,
1626 settings.xres,
1627 settings.yres,
1628 settings.hx,
1629 settings.hy,
1630 settings.hz,
1631 );
1632
1633 let (cache, mip) = local_cache(&grid, mip);
1636 let mut sampler = Sampler::new(grid, &cache, mip);
1637
1638 for py in settings.y_start..settings.y_end {
1639 let row = py as usize * pitch_pixels;
1640 for px in 0..settings.xres {
1641 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py) {
1642 sink.put(row + px as usize, color, dist);
1643 }
1644 }
1645 }
1646}
1647
1648#[inline]
1653fn pixel_result(
1654 cs: &CameraState,
1655 settings: &OpticastSettings,
1656 sampler: &mut Sampler<'_>,
1657 env: &DdaEnv<'_>,
1658 px: u32,
1659 py: u32,
1660) -> Option<(u32, f32)> {
1661 let (origin, dir) = pixel_ray(cs, settings, px, py);
1662 if let Some(hit) = cast_ray(origin, dir, cs.forward, sampler, settings, env) {
1663 Some((hit.color, hit.dist))
1664 } else {
1665 env.sky.map(|sky| (sample_sky(sky, dir), f32::INFINITY))
1666 }
1667}
1668
1669#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
1684pub fn render_dda_parallel(
1685 camera: &Camera,
1686 settings: &OpticastSettings,
1687 grid: GridView<'_>,
1688 fb: &mut [u32],
1689 zb: &mut [f32],
1690 pitch_pixels: usize,
1691 env: &DdaEnv<'_>,
1692 cache: &BrickCache,
1693 mip: u32,
1694) {
1695 debug_assert_eq!(fb.len(), zb.len());
1696 let (y0, y1) = (settings.y_start, settings.y_end);
1697 if y1 <= y0 {
1698 return;
1699 }
1700 let cs = camera_math::derive(
1701 camera,
1702 settings.xres,
1703 settings.yres,
1704 settings.hx,
1705 settings.hy,
1706 settings.hz,
1707 );
1708 let target = RasterTarget::new(fb, zb);
1709
1710 let nthreads = rayon::current_num_threads().max(1);
1712 let rows = (y1 - y0) as usize;
1713 let band = rows.div_ceil(nthreads).max(1) as u32;
1714 let bands: Vec<(u32, u32)> = (y0..y1)
1715 .step_by(band as usize)
1716 .map(|s| (s, (s + band).min(y1)))
1717 .collect();
1718
1719 bands.par_iter().for_each(|&(by0, by1)| {
1720 let mut sampler = Sampler::new(grid, cache, mip);
1721 for py in by0..by1 {
1722 let row = py as usize * pitch_pixels;
1723 for px in 0..settings.xres {
1724 if let Some((color, dist)) = pixel_result(&cs, settings, &mut sampler, env, px, py)
1725 {
1726 let idx = row + px as usize;
1727 unsafe {
1731 target.write_color(idx, color);
1732 target.write_depth(idx, dist);
1733 }
1734 }
1735 }
1736 }
1737 });
1738}
1739
1740#[cfg(test)]
1746#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
1747fn cast_ray_reference(
1748 origin: [f32; 3],
1749 dir: [f32; 3],
1750 forward: [f32; 3],
1751 grid: &GridView<'_>,
1752 settings: &OpticastSettings,
1753) -> Option<Hit> {
1754 let nx = grid.vsid as f32;
1755 let nz = f32::from(u16::try_from(crate::grid_view::CHUNK_SIZE_Z).unwrap_or(256));
1756 #[allow(clippy::cast_possible_wrap)]
1757 let n_i = [
1758 grid.vsid as i32,
1759 grid.vsid as i32,
1760 crate::grid_view::CHUNK_SIZE_Z as i32,
1761 ];
1762 let (t_enter, t_exit) = intersect_aabb(origin, dir, [0.0; 3], [nx, nx, nz])?;
1763 let fwd_dot = dir[0] * forward[0] + dir[1] * forward[1] + dir[2] * forward[2];
1764 let max_dist = settings.max_scan_dist.max(1) as f32;
1765
1766 let start = t_enter + 1e-4;
1767 let p = [
1768 origin[0] + dir[0] * start,
1769 origin[1] + dir[1] * start,
1770 origin[2] + dir[2] * start,
1771 ];
1772 let mut voxel = [
1773 (p[0].floor() as i32).clamp(0, n_i[0] - 1),
1774 (p[1].floor() as i32).clamp(0, n_i[1] - 1),
1775 (p[2].floor() as i32).clamp(0, n_i[2] - 1),
1776 ];
1777 let (step, mut t_max, t_delta) = dda_setup(origin, dir, voxel, 1.0);
1778 let mut t_curr = t_enter;
1779 let max_steps = (n_i[0] + n_i[1] + n_i[2]) as usize + 8;
1780 for _ in 0..max_steps {
1781 if voxel[0] < 0
1782 || voxel[0] >= n_i[0]
1783 || voxel[1] < 0
1784 || voxel[1] >= n_i[1]
1785 || voxel[2] < 0
1786 || voxel[2] >= n_i[2]
1787 {
1788 return None;
1789 }
1790 let depth = t_curr * fwd_dot;
1791 if depth > max_dist || t_curr > t_exit {
1792 return None;
1793 }
1794 #[allow(clippy::cast_sign_loss)]
1795 if let Some(color) = grid.surface_color(voxel[0] as u32, voxel[1] as u32, voxel[2] as u32) {
1796 return Some(Hit {
1797 color: shade(color, 0),
1798 dist: depth.max(0.0),
1799 });
1800 }
1801 let axis = min_axis(t_max);
1802 t_curr = t_max[axis];
1803 voxel[axis] += step[axis];
1804 t_max[axis] += t_delta[axis];
1805 }
1806 None
1807}
1808
1809#[cfg(test)]
1810mod tests {
1811 use super::*;
1812
1813 fn lum(p: u32) -> u32 {
1815 (p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)
1816 }
1817
1818 #[test]
1819 fn cel_band_quantizes_and_collapses() {
1820 assert_eq!(cel_band(0.8, 2), cel_band(0.9, 2));
1822 assert!((cel_band(0.8, 2) - 1.0).abs() < 1e-6);
1823 assert_ne!(cel_band(0.3, 2), cel_band(0.8, 2));
1825 }
1826
1827 #[test]
1828 fn shade_lit_cpu_sun_lights_by_facing() {
1829 let color = 0x80_80_80_80;
1832 let step = [0, 0, 1];
1833 let base = CpuLights {
1834 enabled: true,
1835 sun: true,
1836 sun_color: [1.0; 3],
1837 sun_intensity: 1.0,
1838 ambient: [0.2; 3],
1839 ..CpuLights::default()
1840 };
1841 let facing = CpuLights {
1842 sun_dir: [0.0, 0.0, -1.0],
1843 ..base
1844 }; let back = CpuLights {
1846 sun_dir: [0.0, 0.0, 1.0],
1847 ..base
1848 }; let lit = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &facing, None);
1850 let dark = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &back, None);
1851 assert!(
1852 lum(lit) > lum(dark),
1853 "sun facing the surface must brighten it: {lit:#08x} vs {dark:#08x}",
1854 );
1855 }
1856
1857 #[test]
1858 fn shade_lit_cpu_cel_terraces_sun() {
1859 let color = 0x80_80_80_80;
1862 let step = [0, 0, 1];
1863 let mk = |zc: f32, bands: u32| {
1864 let n = (1.0f32 - zc * zc).sqrt();
1865 CpuLights {
1866 enabled: true,
1867 sun: true,
1868 sun_dir: [n, 0.0, -zc], sun_color: [1.0; 3],
1870 sun_intensity: 1.0,
1871 ambient: [0.1; 3],
1872 bands,
1873 ..CpuLights::default()
1874 }
1875 };
1876 let smooth_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 0), None);
1877 let smooth_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 0), None);
1878 assert_ne!(smooth_a, smooth_b, "smooth diffuse must vary with N·L");
1879 let cel_a = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.8, 2), None);
1880 let cel_b = shade_lit_cpu(color, 0, 2, step, [0, 0, 0], 1.0, &mk(0.9, 2), None);
1881 assert_eq!(
1882 cel_a, cel_b,
1883 "cel banding must terrace both N·L to one level"
1884 );
1885 }
1886
1887 #[test]
1891 fn shade_dynamic_sun_shadow_darkens() {
1892 struct Mock(bool);
1893 impl ShadowTester for Mock {
1894 fn occluded(&mut self, _: [f32; 3], _: [f32; 3], _: f32) -> bool {
1895 self.0
1896 }
1897 }
1898 let l = CpuLights {
1899 enabled: true,
1900 sun: true,
1901 sun_dir: [0.0, 0.0, -1.0], sun_color: [1.0; 3],
1903 sun_intensity: 1.0,
1904 sun_casts_shadow: true,
1905 ambient: [0.2; 3],
1906 shadow_strength: 0.7,
1907 shadow_bias: 1.5,
1908 shadow_max_dist: 64.0,
1909 ..CpuLights::default()
1910 };
1911 let albedo = [0.8; 3];
1912 let n = [0.0, 0.0, -1.0]; let s = [0.5, 0.5, 0.5];
1914 let lit = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(false)));
1915 let shadowed = shade_dynamic(albedo, 1.0, n, s, &l, Some(&mut Mock(true)));
1916 assert!(
1917 lum(shadowed) < lum(lit),
1918 "an occluded sun face must darken: shadowed={shadowed:#08x} lit={lit:#08x}",
1919 );
1920 let l0 = CpuLights {
1922 shadow_strength: 0.0,
1923 ..l
1924 };
1925 assert_eq!(
1926 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(true))),
1927 shade_dynamic(albedo, 1.0, n, s, &l0, Some(&mut Mock(false))),
1928 "shadow_strength 0 ⇒ shadows invisible",
1929 );
1930 }
1931
1932 #[test]
1938 fn sampler_shadow_march_casts_sun_shadow() {
1939 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, _y, z| {
1941 if z >= 60 {
1942 Some(0x80_80_80_80) } else if x == 32 && (30..60).contains(&z) {
1944 Some(0x80_70_70_70) } else {
1946 None
1947 }
1948 });
1949 let grid = GridView::from_single_vxl(&vxl);
1950 let cam = Camera {
1952 pos: [32.0, 32.0, 6.0],
1953 right: [1.0, 0.0, 0.0],
1954 down: [0.0, 1.0, 0.0],
1955 forward: [0.0, 0.0, 1.0],
1956 };
1957 let inv = 1.0f32 / 2.0f32.sqrt();
1959 let base = CpuLights {
1960 enabled: true,
1961 sun: true,
1962 sun_dir: [inv, 0.0, -inv],
1963 sun_color: [1.0; 3],
1964 sun_intensity: 1.0,
1965 ambient: [0.25; 3],
1966 shadow_strength: 0.8,
1967 shadow_bias: 1.5,
1968 shadow_max_dist: 128.0,
1969 ..CpuLights::default()
1970 };
1971 let (w, h) = (96u32, 96u32);
1972 let lit_env = DdaEnv {
1973 lights: CpuLights {
1974 sun_casts_shadow: false,
1975 ..base
1976 },
1977 ..DdaEnv::default()
1978 };
1979 let shadow_env = DdaEnv {
1980 lights: CpuLights {
1981 sun_casts_shadow: true,
1982 ..base
1983 },
1984 ..DdaEnv::default()
1985 };
1986 let (fb_lit, _) = render_brickmap_env(grid, &cam, w, h, &lit_env);
1987 let (fb_sh, _) = render_brickmap_env(grid, &cam, w, h, &shadow_env);
1988 let sum: fn(&[u32]) -> u64 = |fb| fb.iter().map(|&p| u64::from(lum(p))).sum();
1989 let lit_sum = sum(&fb_lit);
1990 let sh_sum = sum(&fb_sh);
1991 assert!(
1992 sh_sum < lit_sum,
1993 "the wall's shadow must darken the floor: shadow_sum={sh_sum} lit_sum={lit_sum}",
1994 );
1995 assert!(
1997 (lit_sum - sh_sum) * 50 > lit_sum,
1998 "shadow should remove >2% of total luminance: lit={lit_sum} shadow={sh_sum}",
1999 );
2000 }
2001
2002 #[derive(Default)]
2004 struct Recorder {
2005 puts: Vec<(usize, u32, f32)>,
2006 }
2007 impl PixelSink for Recorder {
2008 fn put(&mut self, idx: usize, color: u32, dist: f32) {
2009 self.puts.push((idx, color, dist));
2010 }
2011 }
2012
2013 fn oracle_camera() -> Camera {
2014 Camera {
2016 pos: [0.0, 0.0, 0.0],
2017 right: [1.0, 0.0, 0.0],
2018 down: [0.0, 0.0, 1.0],
2019 forward: [0.0, 1.0, 0.0],
2020 }
2021 }
2022
2023 fn render_mask(grid: GridView<'_>, camera: &Camera, w: u32, h: u32) -> Vec<bool> {
2026 let n = (w as usize) * (h as usize);
2027 let mut fb = vec![0u32; n]; let mut zb = vec![f32::INFINITY; n];
2029 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2030 {
2031 let mut sink = RasterSink::new(&mut fb, &mut zb);
2032 render_dda(
2033 camera,
2034 &settings,
2035 grid,
2036 w as usize,
2037 &DdaEnv::default(),
2038 0,
2039 &mut sink,
2040 );
2041 }
2042 fb.iter().map(|&c| c != 0).collect()
2043 }
2044
2045 fn rows_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2050 let w = w as usize;
2051 for y in 0..h as usize {
2052 let row = &mask[y * w..(y + 1) * w];
2053 let first = row.iter().position(|&b| b);
2054 let last = row.iter().rposition(|&b| b);
2055 if let (Some(f), Some(l)) = (first, last) {
2056 if row[f..=l].iter().any(|&b| !b) {
2057 return false;
2058 }
2059 }
2060 }
2061 true
2062 }
2063
2064 fn cols_have_no_holes(mask: &[bool], w: u32, h: u32) -> bool {
2066 let w = w as usize;
2067 let h = h as usize;
2068 for x in 0..w {
2069 let col: Vec<bool> = (0..h).map(|y| mask[y * w + x]).collect();
2070 let first = col.iter().position(|&b| b);
2071 let last = col.iter().rposition(|&b| b);
2072 if let (Some(f), Some(l)) = (first, last) {
2073 if col[f..=l].iter().any(|&b| !b) {
2074 return false;
2075 }
2076 }
2077 }
2078 true
2079 }
2080
2081 #[test]
2084 fn center_pixel_ray_is_forward() {
2085 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2086 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2087 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2089 let (origin, dir) = pixel_ray(&cs, &settings, settings.hx as u32, settings.hy as u32);
2090 assert_eq!(origin, [0.0, 0.0, 0.0]);
2091 assert_eq!(
2093 dir.map(f32::to_bits),
2094 [0.0f32, 320.0, 0.0].map(f32::to_bits)
2095 );
2096 }
2097
2098 #[test]
2102 fn corner_pixel_ray_matches_camera_corn0() {
2103 let settings = OpticastSettings::for_oracle_framebuffer(640, 480);
2104 let cs = camera_math::derive(&oracle_camera(), 640, 480, 320.0, 240.0, 320.0);
2105 let (_origin, dir) = pixel_ray(&cs, &settings, 0, 0);
2106 assert_eq!(dir.map(f32::to_bits), cs.corn[0].map(f32::to_bits));
2107 }
2108
2109 #[test]
2115 fn gridview_voxel_color_matches_reference() {
2116 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |x, _, z| {
2118 let lo = (10..=12).contains(&z);
2119 let hi = (40..=42).contains(&z);
2120 (lo || hi).then_some(0x80_10_20_30 + x)
2121 });
2122 let grid = GridView::from_single_vxl(&vxl);
2123 for x in 0..8 {
2124 for y in 0..8 {
2125 for z in 0..64 {
2126 assert_eq!(
2127 grid.voxel_color(x, y, z),
2128 vxl.voxel_color(x, y, z),
2129 "mismatch at ({x},{y},{z})"
2130 );
2131 }
2132 }
2133 }
2134 }
2135
2136 #[test]
2138 fn empty_grid_no_hits() {
2139 let vxl = roxlap_formats::vxl::Vxl::empty(64);
2140 let grid = GridView::from_single_vxl(&vxl);
2141 let settings = OpticastSettings::for_oracle_framebuffer(64, 48);
2142 let mut rec = Recorder::default();
2143 render_dda(
2144 &oracle_camera(),
2145 &settings,
2146 grid,
2147 64,
2148 &DdaEnv::default(),
2149 0,
2150 &mut rec,
2151 );
2152 assert!(rec.puts.is_empty(), "all-air grid must produce no hits");
2153 }
2154
2155 #[test]
2159 fn floor_seen_from_above() {
2160 const FLOOR_Z: u32 = 40;
2161 const FLOOR_COL: u32 = 0x80_30_60_90;
2162 let vxl =
2163 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= FLOOR_Z).then_some(FLOOR_COL));
2164 let grid = GridView::from_single_vxl(&vxl);
2165
2166 let cam = Camera {
2168 pos: [16.0, 16.0, 10.0],
2169 right: [1.0, 0.0, 0.0],
2170 down: [0.0, 1.0, 0.0],
2171 forward: [0.0, 0.0, 1.0],
2172 };
2173 let settings = OpticastSettings::for_oracle_framebuffer(48, 48);
2174 let mut rec = Recorder::default();
2175 render_dda(&cam, &settings, grid, 48, &DdaEnv::default(), 0, &mut rec);
2176
2177 assert!(!rec.puts.is_empty(), "floor must be visible");
2178 let centre = 24usize * 48 + 24;
2180 let hit = rec
2181 .puts
2182 .iter()
2183 .find(|(idx, _, _)| *idx == centre)
2184 .expect("centre ray must hit the floor");
2185 assert_eq!(hit.1 & 0x00ff_ffff, FLOOR_COL & 0x00ff_ffff);
2186 let expected = (FLOOR_Z as f32) - 10.0;
2187 assert!(
2188 (hit.2 - expected).abs() < 1.5,
2189 "centre depth {} not ≈ {}",
2190 hit.2,
2191 expected
2192 );
2193 }
2194
2195 #[test]
2200 fn horizon_splits_sky_and_floor() {
2201 const FLOOR_Z: u32 = 40;
2202 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| {
2203 (z >= FLOOR_Z).then_some(0x80_44_66_88)
2204 });
2205 let grid = GridView::from_single_vxl(&vxl);
2206
2207 let cam = Camera {
2211 pos: [32.0, 4.0, 30.0],
2212 right: [-1.0, 0.0, 0.0],
2213 down: [0.0, 0.0, 1.0],
2214 forward: [0.0, 1.0, 0.0],
2215 };
2216 let (w, h) = (64u32, 64u32);
2217 let mask = render_mask(grid, &cam, w, h);
2218
2219 let count_band = |y0: usize, y1: usize| -> usize {
2220 (y0 * w as usize..y1 * w as usize)
2221 .filter(|&i| mask[i])
2222 .count()
2223 };
2224 let top = count_band(0, h as usize / 4);
2225 let bottom = count_band(3 * h as usize / 4, h as usize);
2226 assert!(mask.iter().any(|&b| b), "floor must be visible");
2227 assert!(mask.iter().any(|&b| !b), "sky must be visible");
2228 assert!(
2229 bottom > top,
2230 "bottom band ({bottom}) should hit more floor than top band ({top})"
2231 );
2232 }
2233
2234 fn render_reference(
2237 grid: GridView<'_>,
2238 camera: &Camera,
2239 w: u32,
2240 h: u32,
2241 ) -> (Vec<u32>, Vec<f32>) {
2242 let n = (w as usize) * (h as usize);
2243 let mut fb = vec![0u32; n];
2244 let mut zb = vec![f32::INFINITY; n];
2245 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2246 let cs = camera_math::derive(camera, w, h, settings.hx, settings.hy, settings.hz);
2247 for py in 0..h {
2248 for px in 0..w {
2249 let (o, d) = pixel_ray(&cs, &settings, px, py);
2250 if let Some(hit) = cast_ray_reference(o, d, cs.forward, &grid, &settings) {
2251 let i = (py * w + px) as usize;
2252 fb[i] = hit.color;
2253 zb[i] = hit.dist;
2254 }
2255 }
2256 }
2257 (fb, zb)
2258 }
2259
2260 fn render_brickmap(
2262 grid: GridView<'_>,
2263 camera: &Camera,
2264 w: u32,
2265 h: u32,
2266 ) -> (Vec<u32>, Vec<f32>) {
2267 render_brickmap_env(grid, camera, w, h, &DdaEnv::default())
2268 }
2269
2270 fn render_brickmap_env(
2273 grid: GridView<'_>,
2274 camera: &Camera,
2275 w: u32,
2276 h: u32,
2277 env: &DdaEnv<'_>,
2278 ) -> (Vec<u32>, Vec<f32>) {
2279 let n = (w as usize) * (h as usize);
2280 let mut fb = vec![0u32; n];
2281 let mut zb = vec![f32::INFINITY; n];
2282 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2283 {
2284 let mut sink = RasterSink::new(&mut fb, &mut zb);
2285 render_dda(camera, &settings, grid, w as usize, env, 0, &mut sink);
2286 }
2287 (fb, zb)
2288 }
2289
2290 #[test]
2297 fn no_sky_leak_through_diagonal_wall() {
2298 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2299 ((x + y == 64) && (2..62).contains(&z)).then_some(0x80_40_80_60)
2300 });
2301 let grid = GridView::from_single_vxl(&vxl);
2302 let (w, h) = (160u32, 160u32);
2303 let c = [10.0, 10.0, 32.0];
2304 let poses = [
2305 Camera::from_yaw_pitch(c, 0.785, 0.0),
2306 Camera::from_yaw_pitch(c, 0.6, 0.1),
2307 Camera::from_yaw_pitch(c, 0.95, -0.1),
2308 Camera::from_yaw_pitch(c, 0.785, 0.3),
2309 Camera::from_yaw_pitch(c, 0.5, 0.0),
2310 ];
2311 for (i, cam) in poses.iter().enumerate() {
2312 let (fb_b, _) = render_brickmap(grid, cam, w, h);
2313 let (fb_r, _) = render_reference(grid, cam, w, h);
2314 let leak = (0..(w * h) as usize)
2315 .filter(|&k| (fb_b[k] != 0) != (fb_r[k] != 0))
2316 .count();
2317 assert_eq!(leak, 0, "pose {i}: {leak} px diverge from dense reference");
2318 }
2319 }
2320
2321 #[test]
2325 fn terrain_glass_tints_floor_behind() {
2326 let glass = 0x80_40_C0_E0; let floor = 0x80_C0_40_40; let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2329 if z == 4 {
2330 Some(glass)
2331 } else if z >= 10 {
2332 Some(floor)
2333 } else {
2334 None
2335 }
2336 });
2337 let grid = GridView::from_single_vxl(&vxl);
2338 let cam = Camera {
2340 pos: [8.0, 8.0, 0.0],
2341 right: [1.0, 0.0, 0.0],
2342 down: [0.0, 1.0, 0.0],
2343 forward: [0.0, 0.0, 1.0],
2344 };
2345 let (w, h) = (32u32, 32u32);
2346 let centre = (h / 2 * w + w / 2) as usize;
2347
2348 let (fb_op, _) = render_brickmap(grid, &cam, w, h);
2350 assert_eq!(
2351 fb_op[centre] & 0x00ff_ffff,
2352 0x0040_C0E0,
2353 "opaque glass first-hit"
2354 );
2355
2356 let mut table = MaterialTable::new();
2358 table.set(1, Material::alpha_blend(128));
2359 let env = DdaEnv {
2360 materials: Some(&table),
2361 terrain_materials: &[(glass & 0x00ff_ffff, 1)],
2362 lights: CpuLights::default(),
2363 ..DdaEnv::default()
2364 };
2365 let (fb_tr, _) = render_brickmap_env(grid, &cam, w, h, &env);
2366 assert_ne!(
2367 fb_tr[centre], fb_op[centre],
2368 "glass should composite over the floor, not stay opaque"
2369 );
2370 let r_op = (fb_op[centre] >> 16) & 0xff; let r_tr = (fb_tr[centre] >> 16) & 0xff; assert!(
2373 r_tr > r_op,
2374 "floor red tints through the glass (op={r_op:02x} tr={r_tr:02x})"
2375 );
2376 }
2377
2378 #[test]
2383 fn terrain_volumetric_thickness_deepens_opacity() {
2384 let smoke = 0x80_90_90_90; let floor = 0x80_C0_20_20; let green_at = |depth: u32| -> u32 {
2389 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| {
2390 if (4..4 + depth).contains(&z) {
2391 Some(smoke)
2392 } else if z >= 12 {
2393 Some(floor)
2394 } else {
2395 None
2396 }
2397 });
2398 let grid = GridView::from_single_vxl(&vxl);
2399 let cam = Camera {
2400 pos: [8.0, 8.0, 0.0],
2401 right: [1.0, 0.0, 0.0],
2402 down: [0.0, 1.0, 0.0],
2403 forward: [0.0, 0.0, 1.0],
2404 };
2405 let (w, h) = (32u32, 32u32);
2406 let mut table = MaterialTable::new();
2407 table.set(1, Material::volumetric(80));
2408 let env = DdaEnv {
2409 materials: Some(&table),
2410 terrain_materials: &[(smoke & 0x00ff_ffff, 1)],
2411 lights: CpuLights::default(),
2412 ..DdaEnv::default()
2413 };
2414 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
2415 (fb[(h / 2 * w + w / 2) as usize] >> 8) & 0xff
2416 };
2417 let shallow = green_at(1);
2418 let deep = green_at(7);
2419 assert!(
2420 deep > shallow,
2421 "deeper Volumetric smoke shows more of its grey (deep g={deep:02x} > shallow g={shallow:02x})"
2422 );
2423 }
2424
2425 #[test]
2428 fn distance_fog_blends_toward_fog_color() {
2429 let vxl =
2430 roxlap_formats::vxl::Vxl::from_dense(64, |_, _, z| (z >= 40).then_some(0x80_FF_FF_FF));
2431 let grid = GridView::from_single_vxl(&vxl);
2432 let cam = Camera {
2433 pos: [32.0, 2.0, 38.0],
2434 right: [1.0, 0.0, 0.0],
2435 down: [0.0, 0.0, 1.0],
2436 forward: [0.0, 1.0, 0.0],
2437 };
2438 let env = DdaEnv {
2439 sky: None,
2440 fog_color: 0x00_00_00_00, fog_max_dist: 64.0,
2442 side_shades: [0; 6],
2443 materials: None,
2444 terrain_materials: &[],
2445 lights: CpuLights::default(),
2446 world_shadow: None,
2447 };
2448 let (w, h) = (64u32, 64u32);
2449 let (fog, _) = render_brickmap_env(grid, &cam, w, h, &env);
2450 let (nofog, zb) = render_brickmap(grid, &cam, w, h);
2451 let (idx, depth) = zb.iter().enumerate().filter(|(_, z)| z.is_finite()).fold(
2452 (0usize, 0.0f32),
2453 |acc, (i, &z)| {
2454 if z > acc.1 {
2455 (i, z)
2456 } else {
2457 acc
2458 }
2459 },
2460 );
2461 assert!(depth > 20.0, "need a deep pixel to test fog (got {depth})");
2462 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
2463 assert!(
2464 lum(fog[idx]) < lum(nofog[idx]),
2465 "fogged pixel {:08x} not darker than {:08x}",
2466 fog[idx],
2467 nofog[idx]
2468 );
2469 }
2470
2471 #[test]
2474 fn textured_sky_fills_misses() {
2475 let sky = crate::sky::Sky::blue_gradient();
2476 let vxl = roxlap_formats::vxl::Vxl::empty(32); let grid = GridView::from_single_vxl(&vxl);
2478 let env = DdaEnv {
2479 sky: Some(&sky),
2480 fog_color: 0,
2481 fog_max_dist: 0.0,
2482 side_shades: [0; 6],
2483 materials: None,
2484 terrain_materials: &[],
2485 lights: CpuLights::default(),
2486 world_shadow: None,
2487 };
2488 let cam = Camera::from_yaw_pitch([16.0, 16.0, 128.0], 0.3, -0.4);
2489 let (w, h) = (48u32, 48u32);
2490 let (fb, _) = render_brickmap_env(grid, &cam, w, h, &env);
2491 assert!(fb.iter().all(|&c| c >> 24 == 0x80), "all misses sky-filled");
2492 let top = fb[0];
2493 let bottom = fb[(h - 1) as usize * w as usize];
2494 assert_ne!(top, bottom, "sky gradient should vary with elevation");
2495 }
2496
2497 #[test]
2502 fn sky_elevation_zenith_at_column_zero() {
2503 let mut pixels = vec![0i32; 8];
2504 pixels[0] = 0x0011_1111; pixels[7] = 0x0099_9999; let sky = crate::sky::Sky::from_pixels(pixels, 8, 1);
2507 let up = sample_sky(&sky, [0.0, 0.0, -1.0]); let down = sample_sky(&sky, [0.0, 0.0, 1.0]); assert_eq!(
2510 up & 0x00ff_ffff,
2511 0x0011_1111,
2512 "looking up → column 0 (zenith)"
2513 );
2514 assert_eq!(
2515 down & 0x00ff_ffff,
2516 0x0099_9999,
2517 "looking down → last column (nadir)"
2518 );
2519 }
2520
2521 #[test]
2525 fn sky_fill_paints_panorama_gridless() {
2526 let sky = crate::sky::Sky::blue_gradient();
2527 let cam = Camera::from_yaw_pitch([0.0, 0.0, 0.0], 0.3, -0.4);
2528 let (w, h) = (48u32, 48u32);
2529 let cs = crate::camera_math::derive(&cam, w, h, 24.0, 24.0, 24.0);
2530 let settings = crate::opticast::OpticastSettings::for_oracle_framebuffer(w, h);
2531 let mut fb = vec![0u32; (w * h) as usize];
2532 let zb = vec![f32::INFINITY; (w * h) as usize];
2534 render_sky_fill(&mut fb, &zb, w as usize, w, h, &cs, &settings, &sky);
2535 assert!(
2536 fb.iter().all(|&c| c >> 24 == 0x80),
2537 "every pixel sky-filled with the brightness byte set"
2538 );
2539 let top = fb[0];
2540 let bottom = fb[(h - 1) as usize * w as usize];
2541 assert_ne!(top, bottom, "sky gradient should vary with elevation");
2542 let mut fb2 = vec![0x1234_5678u32; (w * h) as usize];
2544 let mut zb2 = vec![f32::INFINITY; (w * h) as usize];
2545 zb2[0] = 10.0; render_sky_fill(&mut fb2, &zb2, w as usize, w, h, &cs, &settings, &sky);
2547 assert_eq!(fb2[0], 0x1234_5678, "finite-z pixel is not overwritten");
2548 }
2549
2550 #[test]
2554 fn side_shades_darken_hit_face() {
2555 let vxl =
2556 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x80_FF_FF_FF));
2557 let grid = GridView::from_single_vxl(&vxl);
2558 let cam = Camera {
2559 pos: [8.0, 8.0, 2.0],
2560 right: [1.0, 0.0, 0.0],
2561 down: [0.0, 1.0, 0.0],
2562 forward: [0.0, 0.0, 1.0],
2563 };
2564 let centre = 16 * 32 + 16;
2565 let (plain, _) = render_brickmap(grid, &cam, 32, 32);
2566 let env = DdaEnv {
2567 sky: None,
2568 fog_color: 0,
2569 fog_max_dist: 0.0,
2570 side_shades: [0, 0, 0, 0, 0x40, 0],
2571 materials: None,
2572 terrain_materials: &[],
2573 lights: CpuLights::default(),
2574 world_shadow: None,
2575 };
2576 let (shaded, _) = render_brickmap_env(grid, &cam, 32, 32, &env);
2577 let lum = |c: u32| (c & 0xff) + ((c >> 8) & 0xff) + ((c >> 16) & 0xff);
2578 assert!(
2579 lum(shaded[centre]) < lum(plain[centre]),
2580 "side-shaded face {:08x} not darker than {:08x}",
2581 shaded[centre],
2582 plain[centre]
2583 );
2584 }
2585
2586 #[test]
2596 fn brickmap_approximates_dense_reference() {
2597 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2599 let surf = 30 + ((x / 5 + y / 7) % 11);
2600 let ground = z >= surf;
2601 let block = (20..=24).contains(&z) && (10..20).contains(&x) && (40..50).contains(&y);
2602 (ground || block).then_some(0x80_30_50_70 + (x ^ y) % 0x40)
2603 });
2604 let grid = GridView::from_single_vxl(&vxl);
2605
2606 let (w, h) = (80u32, 80u32);
2607 let poses = [
2608 Camera::orbit(0.6, 0.5, 90.0, [32.0, 32.0, 40.0]),
2609 Camera::orbit(2.1, 0.2, 70.0, [32.0, 32.0, 35.0]),
2610 Camera::orbit(-1.0, 0.9, 120.0, [32.0, 32.0, 45.0]),
2611 ];
2612 let n = (w * h) as usize;
2613 for (i, cam) in poses.iter().enumerate() {
2614 let (fb_b, zb_b) = render_brickmap(grid, cam, w, h);
2615 let (fb_r, _zb_r) = render_reference(grid, cam, w, h);
2616 let cov_b = fb_b.iter().filter(|&&c| c != 0).count();
2618 let cov_r = fb_r.iter().filter(|&&c| c != 0).count();
2619 assert!(cov_b > 200, "pose {i} rendered ~empty (cov {cov_b})");
2620 let cov_diff = cov_b.abs_diff(cov_r);
2621 assert!(
2622 cov_diff * 100 <= n, "pose {i} coverage diverged: brick {cov_b} vs dense {cov_r}"
2624 );
2625 let diffs = fb_b.iter().zip(&fb_r).filter(|(a, b)| a != b).count();
2627 assert!(
2628 diffs * 100 <= n * 3, "pose {i} too many pixel diffs vs dense: {diffs}/{n}"
2630 );
2631 for k in 0..n {
2633 if fb_b[k] != 0 {
2634 assert!(zb_b[k].is_finite(), "pose {i} px {k} non-finite depth");
2635 }
2636 }
2637 }
2638 }
2639
2640 #[test]
2644 fn baked_brightness_darkens_color() {
2645 let dim =
2647 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x40_FF_FF_FF));
2648 let grid = GridView::from_single_vxl(&dim);
2649 let cam = Camera {
2650 pos: [8.0, 8.0, 2.0],
2651 right: [1.0, 0.0, 0.0],
2652 down: [0.0, 1.0, 0.0],
2653 forward: [0.0, 0.0, 1.0],
2654 };
2655 let (fb, _) = render_brickmap(grid, &cam, 32, 32);
2656 let centre = 16 * 32 + 16;
2657 assert_eq!(fb[centre], 0x80_7F_7F_7F, "got {:08x}", fb[centre]);
2659
2660 let full =
2662 roxlap_formats::vxl::Vxl::from_dense(16, |_, _, z| (z >= 8).then_some(0x80_FF_FF_FF));
2663 let gridf = GridView::from_single_vxl(&full);
2664 let (fbf, _) = render_brickmap(gridf, &cam, 32, 32);
2665 assert_eq!(fbf[centre], 0x80_FF_FF_FF, "got {:08x}", fbf[centre]);
2666 }
2667
2668 #[test]
2675 fn cross_chunk_lookdown_sees_lower_stacked_floor() {
2676 const FLOOR_LOCAL_Z: u32 = 40;
2677 const FLOOR_COL: u32 = 0x80_22_88_44;
2678 let upper = roxlap_formats::vxl::Vxl::empty(32); let lower = roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| {
2680 (z >= FLOOR_LOCAL_Z).then_some(FLOOR_COL)
2681 });
2682 let v_up = GridView::from_single_vxl(&upper);
2683 let v_lo = GridView::from_single_vxl(&lower);
2684 let chunks = [Some(v_up), Some(v_lo)];
2686 let cg = crate::ChunkGrid {
2687 chunks: &chunks,
2688 origin_chunk_xy: [0, 0],
2689 origin_chunk_z: 0,
2690 chunks_x: 1,
2691 chunks_y: 1,
2692 chunks_z: 2,
2693 };
2694 let grid = GridView::from_chunk_grid(&cg, 32);
2695
2696 let cam = Camera {
2698 pos: [16.0, 16.0, 100.0],
2699 right: [1.0, 0.0, 0.0],
2700 down: [0.0, 1.0, 0.0],
2701 forward: [0.0, 0.0, 1.0],
2702 };
2703 let (w, h) = (48u32, 48u32);
2704 let (fb, zb) = render_brickmap(grid, &cam, w, h);
2705 let centre = 24 * 48 + 24;
2706 assert!(
2707 fb[centre] & 0x00ff_ffff == FLOOR_COL & 0x00ff_ffff,
2708 "centre ray must reach the lower-chunk floor (got {:08x})",
2709 fb[centre]
2710 );
2711 let expected = 296.0 - 100.0;
2713 assert!(
2714 (zb[centre] - expected).abs() < 2.0,
2715 "look-down depth {} not ≈ {expected}",
2716 zb[centre]
2717 );
2718 }
2719
2720 #[test]
2724 fn cross_chunk_xy_floor_is_seamless() {
2725 let mk = || {
2726 roxlap_formats::vxl::Vxl::from_dense(32, |_, _, z| (z >= 20).then_some(0x80_50_50_50))
2727 };
2728 let (c0, c1) = (mk(), mk());
2729 let v0 = GridView::from_single_vxl(&c0);
2730 let v1 = GridView::from_single_vxl(&c1);
2731 let chunks = [Some(v0), Some(v1)];
2732 let cg = crate::ChunkGrid {
2733 chunks: &chunks,
2734 origin_chunk_xy: [0, 0],
2735 origin_chunk_z: 0,
2736 chunks_x: 2,
2737 chunks_y: 1,
2738 chunks_z: 1,
2739 };
2740 let grid = GridView::from_chunk_grid(&cg, 32);
2741
2742 let cam = Camera {
2744 pos: [32.0, 16.0, 4.0],
2745 right: [1.0, 0.0, 0.0],
2746 down: [0.0, 1.0, 0.0],
2747 forward: [0.0, 0.0, 1.0],
2748 };
2749 let (w, h) = (64u32, 64u32);
2750 let mask = render_mask(grid, &cam, w, h);
2751 let row = (h / 2) as usize * w as usize;
2754 let left = (0..w as usize / 2).filter(|&x| mask[row + x]).count();
2755 let right = (w as usize / 2..w as usize)
2756 .filter(|&x| mask[row + x])
2757 .count();
2758 assert!(
2759 left > 5 && right > 5,
2760 "seam not continuous: left={left} right={right}"
2761 );
2762 }
2763
2764 fn render_mask_mip(grid: GridView<'_>, camera: &Camera, w: u32, h: u32, mip: u32) -> Vec<bool> {
2767 let n = (w as usize) * (h as usize);
2768 let mut fb = vec![0u32; n];
2769 let mut zb = vec![f32::INFINITY; n];
2770 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2771 {
2772 let mut sink = RasterSink::new(&mut fb, &mut zb);
2773 render_dda(
2774 camera,
2775 &settings,
2776 grid,
2777 w as usize,
2778 &DdaEnv::default(),
2779 mip,
2780 &mut sink,
2781 );
2782 }
2783 fb.iter().map(|&c| c != 0).collect()
2784 }
2785
2786 #[test]
2792 fn mip_render_is_coarse_but_complete() {
2793 let mut vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2794 let surf = 24 + ((x / 3 + y / 5) % 17);
2795 (z >= surf).then_some(0x80_50_70_90)
2796 });
2797 vxl.generate_mips(4);
2798 assert!(vxl.mip_count() >= 3, "need mips built for this test");
2799 let grid = GridView::from_single_vxl(&vxl);
2800 let (w, h) = (96u32, 96u32);
2801 let cam = Camera::orbit(0.7, 0.6, 110.0, [32.0, 32.0, 36.0]);
2802
2803 let m0 = render_mask_mip(grid, &cam, w, h, 0);
2804 let m2 = render_mask_mip(grid, &cam, w, h, 2);
2805
2806 let c0 = m0.iter().filter(|&&b| b).count();
2807 let c2 = m2.iter().filter(|&&b| b).count();
2808 assert!(c0 > 200 && c2 > 200, "both mips visible (c0={c0} c2={c2})");
2809 let ratio = c2 as f32 / c0 as f32;
2815 assert!(
2816 (0.7..1.4).contains(&ratio),
2817 "mip-2 coverage {c2} vs mip-0 {c0} (ratio {ratio:.2}) diverged"
2818 );
2819 }
2820
2821 #[test]
2827 #[ignore = "perf benchmark — run explicitly with --ignored"]
2828 fn bench_terrain() {
2829 use std::time::Instant;
2830 const NC: i32 = 6;
2832 let cs = crate::grid_view::CHUNK_SIZE_Z; let _ = cs;
2834 let mut vxls: Vec<roxlap_formats::vxl::Vxl> = Vec::new();
2835 for cy in 0..NC {
2836 for cx in 0..NC {
2837 let (ox, oy) = (cx * 128, cy * 128);
2838 let mut v = roxlap_formats::vxl::Vxl::from_dense(128, |x, y, z| {
2839 let (gx, gy) = (ox + x as i32, oy + y as i32);
2840 let surf = 90 + ((gx / 7 + gy / 9).rem_euclid(40)) + ((gx / 23).rem_euclid(20));
2841 (z as i32 >= surf).then_some(0x80_50_70_90 + (x ^ y) % 0x30)
2842 });
2843 v.generate_mips(4);
2844 vxls.push(v);
2845 }
2846 }
2847 let views: Vec<Option<GridView>> = vxls
2848 .iter()
2849 .map(|v| Some(GridView::from_single_vxl(v)))
2850 .collect();
2851 let cg = crate::ChunkGrid {
2852 chunks: &views,
2853 origin_chunk_xy: [0, 0],
2854 origin_chunk_z: 0,
2855 chunks_x: NC as u32,
2856 chunks_y: NC as u32,
2857 chunks_z: 1,
2858 };
2859 let grid = GridView::from_chunk_grid(&cg, 128);
2860
2861 let (w, h) = (960u32, 600u32);
2862 let mut settings = OpticastSettings::for_oracle_framebuffer(w, h);
2863 settings.max_scan_dist = 512;
2864 let n = (w * h) as usize;
2865 let mut fb = vec![0u32; n];
2866 let mut zb = vec![f32::INFINITY; n];
2867 let centre = [f64::from(NC * 128) / 2.0, f64::from(NC * 128) / 2.0, 60.0];
2868
2869 let poses = [
2872 (
2873 "horizon",
2874 Camera::from_yaw_pitch([20.0, 20.0, 40.0], 0.6, 0.15),
2875 ),
2876 ("down", Camera::orbit(0.7, 1.0, 130.0, centre)),
2877 ];
2878 for (name, cam) in poses {
2879 {
2880 let mut sink = RasterSink::new(&mut fb, &mut zb);
2881 prof::reset();
2882 render_dda(
2883 &cam,
2884 &settings,
2885 grid,
2886 w as usize,
2887 &DdaEnv::default(),
2888 0,
2889 &mut sink,
2890 );
2891 }
2892 let (cells, bricks, surf) = prof::read();
2893 let iters = 6;
2894 let t0 = Instant::now();
2895 for _ in 0..iters {
2896 let mut sink = RasterSink::new(&mut fb, &mut zb);
2897 render_dda(
2898 &cam,
2899 &settings,
2900 grid,
2901 w as usize,
2902 &DdaEnv::default(),
2903 0,
2904 &mut sink,
2905 );
2906 }
2907 let ms = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(iters);
2908 let hits = fb.iter().filter(|&&c| c != 0).count();
2909 eprintln!(
2910 "[{name}] {w}x{h} 1-thread: {ms:.1} ms | hits={hits}/{n} | per-px: cells={:.1} bricks={:.1} surf={:.1}",
2911 cells as f64 / n as f64,
2912 bricks as f64 / n as f64,
2913 surf as f64 / n as f64,
2914 );
2915 }
2916 }
2917
2918 #[test]
2922 fn parallel_matches_sequential() {
2923 let vxl = roxlap_formats::vxl::Vxl::from_dense(64, |x, y, z| {
2924 let surf = 28 + ((x / 4 + y / 6) % 13);
2925 (z >= surf).then_some(0x80_40_60_80 + (x ^ y) % 0x30)
2926 });
2927 let grid = GridView::from_single_vxl(&vxl);
2928 let (w, h) = (96u32, 96u32);
2929 let cam = Camera::orbit(0.8, 0.55, 100.0, [32.0, 32.0, 40.0]);
2930 let env = DdaEnv {
2931 sky: None,
2932 fog_color: 0x00_20_30_40,
2933 fog_max_dist: 120.0,
2934 side_shades: [0, 0, 0, 0, 0x30, 0x10],
2935 materials: None,
2936 terrain_materials: &[],
2937 lights: CpuLights::default(),
2938 world_shadow: None,
2939 };
2940
2941 let (seq_fb, seq_zb) = render_brickmap_env(grid, &cam, w, h, &env);
2942
2943 let n = (w * h) as usize;
2944 let mut par_fb = vec![0u32; n];
2945 let mut par_zb = vec![f32::INFINITY; n];
2946 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2947 let (cache, mip) = local_cache(&grid, 0);
2948 render_dda_parallel(
2949 &cam,
2950 &settings,
2951 grid,
2952 &mut par_fb,
2953 &mut par_zb,
2954 w as usize,
2955 &env,
2956 &cache,
2957 mip,
2958 );
2959 assert!(par_fb == seq_fb, "parallel colour differs from sequential");
2960 assert!(
2961 par_zb
2962 .iter()
2963 .zip(&seq_zb)
2964 .all(|(a, b)| a.to_bits() == b.to_bits()),
2965 "parallel depth differs from sequential"
2966 );
2967 }
2968
2969 #[test]
2975 fn cliff_side_is_solid_not_see_through() {
2976 const TOP_Z: u32 = 50;
2977 const COL: u32 = 0x80_77_88_99;
2978 let vxl = roxlap_formats::vxl::Vxl::from_dense(8, |_, _, z| (z >= TOP_Z).then_some(COL));
2979 let grid = GridView::from_single_vxl(&vxl);
2980
2981 assert_eq!(grid.voxel_color(4, 4, TOP_Z), Some(COL));
2983 assert_eq!(grid.voxel_color(4, 4, 150), None);
2985 assert_eq!(grid.surface_color(4, 4, 150), Some(COL));
2988 assert_eq!(grid.surface_color(4, 4, 10), None);
2990 }
2991
2992 #[test]
2995 fn camera_inside_solid_hits_everywhere() {
2996 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |_, _, _| Some(0x80_55_55_55));
2997 let grid = GridView::from_single_vxl(&vxl);
2998 let cam = Camera {
2999 pos: [8.0, 8.0, 128.0],
3000 right: [1.0, 0.0, 0.0],
3001 down: [0.0, 1.0, 0.0],
3002 forward: [0.0, 0.0, 1.0],
3003 };
3004 let (w, h) = (32u32, 32u32);
3005 let mask = render_mask(grid, &cam, w, h);
3006 assert!(
3007 mask.iter().all(|&b| b),
3008 "every ray must hit when the camera is inside solid"
3009 );
3010 }
3011
3012 #[test]
3018 fn single_voxel_silhouette_has_no_notch() {
3019 const C: u32 = 0x80_FF_80_40;
3020 let vxl = roxlap_formats::vxl::Vxl::from_dense(16, |x, y, z| {
3021 (x == 8 && y == 8 && z == 8).then_some(C)
3022 });
3023 let grid = GridView::from_single_vxl(&vxl);
3024
3025 let cam = Camera::orbit(0.7, 0.6, 4.0, [8.5, 8.5, 8.5]);
3028 let (w, h) = (96u32, 96u32);
3029 let mask = render_mask(grid, &cam, w, h);
3030
3031 let hits = mask.iter().filter(|&&b| b).count();
3032 assert!(
3033 hits > 30,
3034 "silhouette too small to be meaningful: {hits} px"
3035 );
3036 assert!(
3037 rows_have_no_holes(&mask, w, h),
3038 "row-interior gap in single-voxel silhouette (notch)"
3039 );
3040 assert!(
3041 cols_have_no_holes(&mask, w, h),
3042 "column-interior gap in single-voxel silhouette (notch)"
3043 );
3044 }
3045}