1use crate::bake::environment_map as em;
13use crate::gfx::cubemap::FACE_BASIS;
14use crate::gfx::projection::{perspective_rh, view_from_basis};
15use crate::gfx::transform::mat4_mul;
16use crate::math::{ceil, floor, powi, round, sqrt};
17use crate::render::uniforms::ProbePrefilterParams;
18use alloc::vec;
19use alloc::vec::Vec;
20use core::f32::consts::FRAC_PI_2;
21
22fn perspective_90(near: f32, far: f32) -> [[f32; 4]; 4] {
24 perspective_rh(FRAC_PI_2, 1.0, near, far)
25}
26
27pub fn face_view_projection(eye: [f32; 3], face: usize, near: f32, far: f32) -> [[f32; 4]; 4] {
29 let b = FACE_BASIS[face];
30 let view = view_from_basis(eye, b[0], b[1], b[2]);
31 mat4_mul(perspective_90(near, far), view)
32}
33
34pub fn face_view_matrix(eye: [f32; 3], face: usize) -> [[f32; 4]; 4] {
39 let b = FACE_BASIS[face];
40 view_from_basis(eye, b[0], b[1], b[2])
41}
42
43#[derive(Clone, Copy, Debug, PartialEq)]
48pub struct ProbePlacement {
49 pub position: [f32; 3],
51 pub box_min: [f32; 3],
53 pub box_max: [f32; 3],
55}
56
57impl ProbePlacement {
58 pub fn from_center_extents(position: [f32; 3], half_extents: [f32; 3]) -> ProbePlacement {
61 ProbePlacement {
62 position,
63 box_min: [
64 position[0] - half_extents[0],
65 position[1] - half_extents[1],
66 position[2] - half_extents[2],
67 ],
68 box_max: [
69 position[0] + half_extents[0],
70 position[1] + half_extents[1],
71 position[2] + half_extents[2],
72 ],
73 }
74 }
75}
76
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub struct ProbeBakeQueue {
84 total: usize,
85 next: usize,
86}
87
88impl ProbeBakeQueue {
89 pub fn new(total: usize) -> ProbeBakeQueue {
91 ProbeBakeQueue { total, next: 0 }
92 }
93
94 pub fn pending(&self) -> bool {
96 self.next < self.total
97 }
98
99 pub fn take_next(&mut self) -> Option<usize> {
101 (self.next < self.total).then(|| {
102 let i = self.next;
103 self.next += 1;
104 i
105 })
106 }
107
108 pub fn abort(&mut self) {
111 self.next = self.total;
112 }
113}
114
115#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum BakePhase {
123 Idle,
125 Rendering,
127 Prefiltering,
129}
130
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum BakeAction {
141 Idle,
143 StartNext,
145 RenderFace,
147 StartPrefilter,
149 PrefilterMip,
151 Install,
153}
154
155#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
160pub struct BakeSignals {
161 pub faces_done: bool,
163 pub mips_done: bool,
168 pub queue_pending: bool,
170 pub eligible: bool,
173 pub more_faces: bool,
175 pub more_mips: bool,
177}
178
179pub fn next_bake_action(phase: BakePhase, signals: BakeSignals) -> BakeAction {
195 match phase {
196 BakePhase::Rendering => {
197 if signals.more_faces {
198 BakeAction::RenderFace
199 } else if signals.faces_done {
200 BakeAction::StartPrefilter
201 } else {
202 BakeAction::Idle
203 }
204 }
205 BakePhase::Prefiltering => {
206 if signals.more_mips {
207 BakeAction::PrefilterMip
208 } else if signals.mips_done {
209 BakeAction::Install
210 } else {
211 BakeAction::Idle
212 }
213 }
214 BakePhase::Idle => {
215 if signals.queue_pending && signals.eligible {
216 BakeAction::StartNext
217 } else {
218 BakeAction::Idle
219 }
220 }
221 }
222}
223
224#[derive(Clone, Copy, Debug, PartialEq)]
233pub struct PrefilterPlan {
234 face_size: u32,
235 mips: u32,
236 samples: u32,
237 clamp: f32,
238}
239
240impl PrefilterPlan {
241 pub const RUNTIME: PrefilterPlan = PrefilterPlan {
251 face_size: 512,
252 mips: em::max_mip_count(512),
253 samples: 128,
254 clamp: 12.0,
255 };
256
257 pub const fn new(face_size: u32, samples: u32, clamp: f32) -> PrefilterPlan {
260 PrefilterPlan {
261 face_size,
262 mips: em::max_mip_count(face_size),
263 samples,
264 clamp,
265 }
266 }
267
268 pub const fn face_size(&self) -> u32 {
270 self.face_size
271 }
272
273 pub const fn mips(&self) -> u32 {
275 self.mips
276 }
277
278 pub const fn mip_face_size(&self, mip: u32) -> u32 {
280 self.face_size >> mip
281 }
282
283 pub fn mip0_params(&self) -> ProbePrefilterParams {
285 ProbePrefilterParams {
286 dst_size: self.face_size,
287 ..self.base_params()
288 }
289 }
290
291 pub fn downsample_params(&self, dst_mip: u32) -> ProbePrefilterParams {
294 ProbePrefilterParams {
295 dst_size: self.mip_face_size(dst_mip),
296 src_mip: dst_mip - 1,
297 ..self.base_params()
298 }
299 }
300
301 pub fn ggx_params(&self, dst_mip: u32) -> ProbePrefilterParams {
304 ProbePrefilterParams {
305 dst_size: self.mip_face_size(dst_mip),
306 roughness: em::prefilter_roughness(dst_mip, self.mips),
307 ..self.base_params()
308 }
309 }
310
311 fn base_params(&self) -> ProbePrefilterParams {
312 ProbePrefilterParams {
313 dst_size: self.face_size,
314 src_size: self.face_size,
315 sample_count: self.samples,
316 src_mip: 0,
317 roughness: 0.0,
318 clamp_lum: self.clamp,
319 src_mip_count: self.mips as f32,
320 _pad: 0.0,
321 }
322 }
323}
324
325pub(crate) const AUTO_SEED_BUDGET: usize = 8;
330
331const AUTO_SEED_CELL_TARGET: f32 = 12.0;
335
336const INTERIOR_VOXELS_LONG_AXIS: usize = 48;
340const INTERIOR_MAX_DIM: usize = 128;
342const INTERIOR_MIN_ENCLOSED: u8 = 5;
348const INTERIOR_MIN_CLUSTER: usize = 4;
351const INTERIOR_MIN_ROOM_SPAN: f32 = 2.0;
360
361fn fit_grid(nx: usize, nz: usize, budget: usize) -> (usize, usize) {
365 let (mut nx, mut nz) = (nx.max(1), nz.max(1));
366 if nx * nz > budget {
367 let scale = sqrt(budget as f32 / (nx * nz) as f32);
368 nx = (round(nx as f32 * scale) as usize).max(1);
369 nz = (round(nz as f32 * scale) as usize).max(1);
370 while nx * nz > budget {
371 if nx >= nz {
372 nx -= 1;
373 } else {
374 nz -= 1;
375 }
376 }
377 }
378 (nx.max(1), nz.max(1))
379}
380
381fn point_inside_any(p: [f32; 3], occupancy: &[([f32; 3], [f32; 3])]) -> bool {
383 occupancy.iter().any(|(mn, mx)| {
384 p[0] >= mn[0]
385 && p[0] <= mx[0]
386 && p[1] >= mn[1]
387 && p[1] <= mx[1]
388 && p[2] >= mn[2]
389 && p[2] <= mx[2]
390 })
391}
392
393fn open_capture_point(
400 center: [f32; 3],
401 x0: f32,
402 x1: f32,
403 z0: f32,
404 z1: f32,
405 occupancy: &[([f32; 3], [f32; 3])],
406) -> [f32; 3] {
407 if !point_inside_any(center, occupancy) {
408 return center;
409 }
410 let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
411 for (fx, fz) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
412 let p = [lerp(x0, x1, fx), center[1], lerp(z0, z1, fz)];
413 if !point_inside_any(p, occupancy) {
414 return p;
415 }
416 }
417 center
418}
419
420fn interior_voxel_grid(
426 aabb_min: [f32; 3],
427 aabb_max: [f32; 3],
428) -> Option<(f32, usize, usize, usize)> {
429 let extent = [
430 aabb_max[0] - aabb_min[0],
431 aabb_max[1] - aabb_min[1],
432 aabb_max[2] - aabb_min[2],
433 ];
434 let long = extent[0].max(extent[2]);
435 if long <= 0.0 || extent[1] <= 0.0 {
436 return None;
437 }
438 let vs = (long / INTERIOR_VOXELS_LONG_AXIS as f32).max(0.25);
439 let dim = |e: f32| (ceil(e / vs) as usize).clamp(1, INTERIOR_MAX_DIM);
440 Some((vs, dim(extent[0]), dim(extent[1]), dim(extent[2])))
441}
442
443fn solid_from_aabbs(
447 aabb_min: [f32; 3],
448 vs: f32,
449 nx: usize,
450 ny: usize,
451 nz: usize,
452 occupancy: &[([f32; 3], [f32; 3])],
453) -> Vec<bool> {
454 let idx = |x: usize, y: usize, z: usize| (z * ny + y) * nx + x;
455 let mut solid = vec![false; nx * ny * nz];
456 let to_vx =
457 |v: f32, origin: f32, hi: usize| (floor((v - origin) / vs).max(0.0) as usize).min(hi);
458 for (mn, mx) in occupancy {
459 let x0 = to_vx(mn[0], aabb_min[0], nx - 1);
460 let x1 = to_vx(mx[0], aabb_min[0], nx - 1);
461 let y0 = to_vx(mn[1], aabb_min[1], ny - 1);
462 let y1 = to_vx(mx[1], aabb_min[1], ny - 1);
463 let z0 = to_vx(mn[2], aabb_min[2], nz - 1);
464 let z1 = to_vx(mx[2], aabb_min[2], nz - 1);
465 for z in z0..=z1 {
466 for y in y0..=y1 {
467 for x in x0..=x1 {
468 solid[idx(x, y, z)] = true;
469 }
470 }
471 }
472 }
473 solid
474}
475
476fn tri_box_overlap(box_c: [f32; 3], box_h: [f32; 3], tri: &[[f32; 3]; 3]) -> bool {
484 let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
485 let dot = |a: [f32; 3], b: [f32; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
486 let cross = |a: [f32; 3], b: [f32; 3]| {
487 [
488 a[1] * b[2] - a[2] * b[1],
489 a[2] * b[0] - a[0] * b[2],
490 a[0] * b[1] - a[1] * b[0],
491 ]
492 };
493 let v = [sub(tri[0], box_c), sub(tri[1], box_c), sub(tri[2], box_c)];
495 let edges = [sub(v[1], v[0]), sub(v[2], v[1]), sub(v[0], v[2])];
496 let box_axes = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
497
498 let separated = |l: [f32; 3]| -> bool {
499 let r = box_h[0] * l[0].abs() + box_h[1] * l[1].abs() + box_h[2] * l[2].abs();
500 let p0 = dot(l, v[0]);
501 let p1 = dot(l, v[1]);
502 let p2 = dot(l, v[2]);
503 p0.min(p1).min(p2) > r || p0.max(p1).max(p2) < -r
504 };
505
506 for a in box_axes {
507 if separated(a) {
508 return false;
509 }
510 }
511 for e in edges {
512 for a in box_axes {
513 if separated(cross(e, a)) {
514 return false;
515 }
516 }
517 }
518 !separated(cross(edges[0], edges[1]))
519}
520
521fn solid_from_triangles(
527 aabb_min: [f32; 3],
528 vs: f32,
529 nx: usize,
530 ny: usize,
531 nz: usize,
532 triangles: &[[[f32; 3]; 3]],
533) -> Vec<bool> {
534 let idx = |x: usize, y: usize, z: usize| (z * ny + y) * nx + x;
535 let mut solid = vec![false; nx * ny * nz];
536 let half = [vs * 0.5 + vs * 1e-3; 3];
542 let to_vx =
543 |v: f32, origin: f32, hi: usize| (floor((v - origin) / vs).max(0.0) as usize).min(hi);
544 for tri in triangles {
545 let mut tmn = tri[0];
546 let mut tmx = tri[0];
547 for vtx in &tri[1..] {
548 for a in 0..3 {
549 tmn[a] = tmn[a].min(vtx[a]);
550 tmx[a] = tmx[a].max(vtx[a]);
551 }
552 }
553 if !tmn.iter().chain(tmx.iter()).all(|c| c.is_finite()) {
554 continue;
555 }
556 let x0 = to_vx(tmn[0], aabb_min[0], nx - 1);
557 let x1 = to_vx(tmx[0], aabb_min[0], nx - 1);
558 let y0 = to_vx(tmn[1], aabb_min[1], ny - 1);
559 let y1 = to_vx(tmx[1], aabb_min[1], ny - 1);
560 let z0 = to_vx(tmn[2], aabb_min[2], nz - 1);
561 let z1 = to_vx(tmx[2], aabb_min[2], nz - 1);
562 for z in z0..=z1 {
563 for y in y0..=y1 {
564 for x in x0..=x1 {
565 let i = idx(x, y, z);
566 if solid[i] {
567 continue;
568 }
569 let c = [
570 aabb_min[0] + (x as f32 + 0.5) * vs,
571 aabb_min[1] + (y as f32 + 0.5) * vs,
572 aabb_min[2] + (z as f32 + 0.5) * vs,
573 ];
574 if tri_box_overlap(c, half, tri) {
575 solid[i] = true;
576 }
577 }
578 }
579 }
580 }
581 solid
582}
583
584fn interior_probes_from_solid(
594 aabb_min: [f32; 3],
595 vs: f32,
596 nx: usize,
597 ny: usize,
598 nz: usize,
599 solid: &[bool],
600 budget: usize,
601) -> Vec<ProbePlacement> {
602 let n = nx * ny * nz;
603 let idx = |x: usize, y: usize, z: usize| (z * ny + y) * nx + x;
604
605 let mut enclosed = vec![0u8; n];
608 for z in 0..nz {
609 for y in 0..ny {
610 let (mut fwd, mut bwd) = (false, false);
611 for x in (0..nx).rev() {
612 let i = idx(x, y, z);
613 if solid[i] {
614 fwd = true;
615 } else if fwd {
616 enclosed[i] += 1;
617 }
618 }
619 for x in 0..nx {
620 let i = idx(x, y, z);
621 if solid[i] {
622 bwd = true;
623 } else if bwd {
624 enclosed[i] += 1;
625 }
626 }
627 }
628 }
629 for z in 0..nz {
630 for x in 0..nx {
631 let (mut fwd, mut bwd) = (false, false);
632 for y in (0..ny).rev() {
633 let i = idx(x, y, z);
634 if solid[i] {
635 fwd = true;
636 } else if fwd {
637 enclosed[i] += 1;
638 }
639 }
640 for y in 0..ny {
641 let i = idx(x, y, z);
642 if solid[i] {
643 bwd = true;
644 } else if bwd {
645 enclosed[i] += 1;
646 }
647 }
648 }
649 }
650 for y in 0..ny {
651 for x in 0..nx {
652 let (mut fwd, mut bwd) = (false, false);
653 for z in (0..nz).rev() {
654 let i = idx(x, y, z);
655 if solid[i] {
656 fwd = true;
657 } else if fwd {
658 enclosed[i] += 1;
659 }
660 }
661 for z in 0..nz {
662 let i = idx(x, y, z);
663 if solid[i] {
664 bwd = true;
665 } else if bwd {
666 enclosed[i] += 1;
667 }
668 }
669 }
670 }
671
672 let is_interior = |i: usize| !solid[i] && enclosed[i] >= INTERIOR_MIN_ENCLOSED;
673
674 let mut label = vec![usize::MAX; n];
676 let mut clusters: Vec<Vec<usize>> = Vec::new();
677 let mut stack: Vec<usize> = Vec::new();
678 for start in 0..n {
679 if !is_interior(start) || label[start] != usize::MAX {
680 continue;
681 }
682 let cid = clusters.len();
683 let mut members = Vec::new();
684 label[start] = cid;
685 stack.push(start);
686 while let Some(i) = stack.pop() {
687 members.push(i);
688 let z = i / (nx * ny);
689 let y = (i / nx) % ny;
690 let x = i % nx;
691 let neighbours = [
692 (x > 0).then(|| i - 1),
693 (x + 1 < nx).then_some(i + 1),
694 (y > 0).then(|| i - nx),
695 (y + 1 < ny).then_some(i + nx),
696 (z > 0).then(|| i - nx * ny),
697 (z + 1 < nz).then_some(i + nx * ny),
698 ];
699 for j in neighbours.into_iter().flatten() {
700 if is_interior(j) && label[j] == usize::MAX {
701 label[j] = cid;
702 stack.push(j);
703 }
704 }
705 }
706 clusters.push(members);
707 }
708
709 let voxel_center = |i: usize| {
710 let z = i / (nx * ny);
711 let y = (i / nx) % ny;
712 let x = i % nx;
713 [
714 aabb_min[0] + (x as f32 + 0.5) * vs,
715 aabb_min[1] + (y as f32 + 0.5) * vs,
716 aabb_min[2] + (z as f32 + 0.5) * vs,
717 ]
718 };
719 let cluster_span = |members: &[usize]| {
721 let mut lo = [f32::MAX; 3];
722 let mut hi = [f32::MIN; 3];
723 for &i in members {
724 let c = voxel_center(i);
725 for a in 0..3 {
726 lo[a] = lo[a].min(c[a] - vs * 0.5);
727 hi[a] = hi[a].max(c[a] + vs * 0.5);
728 }
729 }
730 [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]
731 };
732
733 clusters.retain(|c| {
736 c.len() >= INTERIOR_MIN_CLUSTER
737 && cluster_span(c).iter().all(|d| *d >= INTERIOR_MIN_ROOM_SPAN)
738 });
739 clusters.sort_by_key(|c| core::cmp::Reverse(c.len()));
740 clusters.truncate(budget);
741 clusters
742 .iter()
743 .map(|members| {
744 let inv = 1.0 / members.len() as f32;
747 let mut centroid = [0.0f32; 3];
748 for &i in members {
749 let c = voxel_center(i);
750 for a in 0..3 {
751 centroid[a] += c[a] * inv;
752 }
753 }
754 let dist2 = |c: [f32; 3]| {
755 powi(c[0] - centroid[0], 2)
756 + powi(c[1] - centroid[1], 2)
757 + powi(c[2] - centroid[2], 2)
758 };
759 let position = members
760 .iter()
761 .map(|&i| voxel_center(i))
762 .min_by(|a, b| dist2(*a).total_cmp(&dist2(*b)))
763 .unwrap_or(centroid);
764 let mut box_min = [f32::MAX; 3];
767 let mut box_max = [f32::MIN; 3];
768 for &i in members {
769 let c = voxel_center(i);
770 for a in 0..3 {
771 box_min[a] = box_min[a].min(c[a] - vs * 0.5);
772 box_max[a] = box_max[a].max(c[a] + vs * 0.5);
773 }
774 }
775 ProbePlacement {
776 position,
777 box_min,
778 box_max,
779 }
780 })
781 .collect()
782}
783
784fn seed_interior_probes(
788 aabb_min: [f32; 3],
789 aabb_max: [f32; 3],
790 occupancy: &[([f32; 3], [f32; 3])],
791 budget: usize,
792) -> Vec<ProbePlacement> {
793 if budget == 0 || occupancy.is_empty() {
794 return Vec::new();
795 }
796 let (vs, nx, ny, nz) = match interior_voxel_grid(aabb_min, aabb_max) {
797 Some(g) => g,
798 None => return Vec::new(),
799 };
800 let solid = solid_from_aabbs(aabb_min, vs, nx, ny, nz, occupancy);
801 interior_probes_from_solid(aabb_min, vs, nx, ny, nz, &solid, budget)
802}
803
804fn seed_interior_probes_tris(
808 aabb_min: [f32; 3],
809 aabb_max: [f32; 3],
810 triangles: &[[[f32; 3]; 3]],
811 budget: usize,
812) -> Vec<ProbePlacement> {
813 if budget == 0 || triangles.is_empty() {
814 return Vec::new();
815 }
816 let (vs, nx, ny, nz) = match interior_voxel_grid(aabb_min, aabb_max) {
817 Some(g) => g,
818 None => return Vec::new(),
819 };
820 let solid = solid_from_triangles(aabb_min, vs, nx, ny, nz, triangles);
821 interior_probes_from_solid(aabb_min, vs, nx, ny, nz, &solid, budget)
822}
823
824const REFLECTOR_BOUNDS_HALF_HEIGHT: f32 = 2.0;
830
831pub fn reflector_bounds(centre: [f32; 3], half_extents: [f32; 3]) -> ([f32; 3], [f32; 3]) {
844 let half = |a: usize| half_extents[a].abs().max(REFLECTOR_BOUNDS_HALF_HEIGHT);
845 (
846 [
847 centre[0] - half(0),
848 centre[1] - half(1),
849 centre[2] - half(2),
850 ],
851 [
852 centre[0] + half(0),
853 centre[1] + half(1),
854 centre[2] + half(2),
855 ],
856 )
857}
858
859pub fn auto_seed_probes(
863 aabb_min: [f32; 3],
864 aabb_max: [f32; 3],
865 occupancy: &[([f32; 3], [f32; 3])],
866) -> Vec<ProbePlacement> {
867 auto_seed_probes_with_geometry(aabb_min, aabb_max, occupancy, &[])
868}
869
870pub fn auto_seed_probes_with_geometry(
882 aabb_min: [f32; 3],
883 aabb_max: [f32; 3],
884 occupancy: &[([f32; 3], [f32; 3])],
885 triangles: &[[[f32; 3]; 3]],
886) -> Vec<ProbePlacement> {
887 let finite = aabb_min
888 .iter()
889 .chain(aabb_max.iter())
890 .all(|c| c.is_finite());
891 if !finite || aabb_max[0] <= aabb_min[0] || aabb_max[2] <= aabb_min[2] {
892 return Vec::new();
893 }
894 let mut out = if triangles.is_empty() {
895 seed_interior_probes(aabb_min, aabb_max, occupancy, AUTO_SEED_BUDGET)
896 } else {
897 seed_interior_probes_tris(aabb_min, aabb_max, triangles, AUTO_SEED_BUDGET)
898 };
899 let remaining = AUTO_SEED_BUDGET.saturating_sub(out.len());
900 if remaining > 0 {
901 out.extend(seed_grid_probes(aabb_min, aabb_max, occupancy, remaining));
902 }
903 out
904}
905
906fn seed_grid_probes(
912 aabb_min: [f32; 3],
913 aabb_max: [f32; 3],
914 occupancy: &[([f32; 3], [f32; 3])],
915 budget: usize,
916) -> Vec<ProbePlacement> {
917 if budget == 0 {
918 return Vec::new();
919 }
920 let dx = aabb_max[0] - aabb_min[0];
921 let dz = aabb_max[2] - aabb_min[2];
922 let nx_raw = ceil(dx / AUTO_SEED_CELL_TARGET).max(1.0) as usize;
923 let nz_raw = ceil(dz / AUTO_SEED_CELL_TARGET).max(1.0) as usize;
924 let (nx, nz) = fit_grid(nx_raw, nz_raw, budget);
925
926 let y_eye = probe_eye_point(aabb_min, aabb_max)[1];
927 let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
928 let mut out = Vec::with_capacity(nx * nz);
929 for ix in 0..nx {
930 for iz in 0..nz {
931 let x0 = lerp(aabb_min[0], aabb_max[0], ix as f32 / nx as f32);
932 let x1 = lerp(aabb_min[0], aabb_max[0], (ix + 1) as f32 / nx as f32);
933 let z0 = lerp(aabb_min[2], aabb_max[2], iz as f32 / nz as f32);
934 let z1 = lerp(aabb_min[2], aabb_max[2], (iz + 1) as f32 / nz as f32);
935 let center = [(x0 + x1) * 0.5, y_eye, (z0 + z1) * 0.5];
936 out.push(ProbePlacement {
937 position: open_capture_point(center, x0, x1, z0, z1, occupancy),
938 box_min: [x0, aabb_min[1], z0],
939 box_max: [x1, aabb_max[1], z1],
940 });
941 }
942 }
943 out
944}
945
946pub fn fold_world_bounds(
950 boxes: impl IntoIterator<Item = ([f32; 3], [f32; 3])>,
951) -> Option<([f32; 3], [f32; 3])> {
952 let mut acc: Option<([f32; 3], [f32; 3])> = None;
953 for (mn, mx) in boxes {
954 if !mn.iter().chain(mx.iter()).all(|c| c.is_finite()) {
955 continue;
956 }
957 match &mut acc {
958 None => acc = Some((mn, mx)),
959 Some((amn, amx)) => {
960 for i in 0..3 {
961 amn[i] = amn[i].min(mn[i]);
962 amx[i] = amx[i].max(mx[i]);
963 }
964 }
965 }
966 }
967 acc
968}
969
970pub(crate) fn probe_eye_point(aabb_min: [f32; 3], aabb_max: [f32; 3]) -> [f32; 3] {
978 const EYE_HEIGHT: f32 = 1.7;
979 let cx = 0.5 * (aabb_min[0] + aabb_max[0]);
980 let cz = 0.5 * (aabb_min[2] + aabb_max[2]);
981 let floor = aabb_min[1];
982 let ceil = aabb_max[1];
983 let y = (floor + EYE_HEIGHT).min(0.5 * (floor + ceil)).max(floor);
986 [cx, y, cz]
987}
988
989#[cfg(test)]
990mod tests {
991 use super::*;
992 use crate::gfx::cubemap::face_dir;
993
994 fn project(vp: [[f32; 4]; 4], p: [f32; 3]) -> (f32, f32, f32) {
995 let mut c = [0.0f32; 4];
996 let pv = [p[0], p[1], p[2], 1.0];
997 for row in 0..4 {
998 for k in 0..4 {
999 c[row] += vp[k][row] * pv[k];
1000 }
1001 }
1002 (c[0] / c[3], c[1] / c[3], c[3])
1003 }
1004
1005 #[test]
1010 fn face_view_projection_matches_cube_convention() {
1011 let eye = [3.0, -1.5, 2.0];
1012 let samples = [
1013 (0.0f32, 0.0f32),
1014 (0.5, 0.0),
1015 (0.0, 0.5),
1016 (-0.6, 0.3),
1017 (0.7, -0.4),
1018 ];
1019 for face in 0..6 {
1020 let vp = face_view_projection(eye, face, 0.05, 100.0);
1021 for &(u, v) in &samples {
1022 let d = face_dir(face, u, v);
1023 let p = [eye[0] + d[0], eye[1] + d[1], eye[2] + d[2]];
1024 let (nx, ny, w) = project(vp, p);
1025 assert!(
1026 w > 0.0,
1027 "face {face} sample ({u},{v}) behind camera (w={w})"
1028 );
1029 assert!(
1030 (nx - u).abs() < 1e-4 && (ny - (-v)).abs() < 1e-4,
1031 "face {face} ({u},{v}) -> ndc ({nx},{ny}), expected ({u},{})",
1032 -v
1033 );
1034 }
1035 }
1036 }
1037
1038 #[test]
1039 fn probe_eye_point_centres_at_eye_height() {
1040 let eye = probe_eye_point([-10.0, 0.0, -4.0], [6.0, 30.0, 12.0]);
1043 assert!((eye[0] - (-2.0)).abs() < 1e-6, "x not centred: {}", eye[0]);
1044 assert!((eye[2] - 4.0).abs() < 1e-6, "z not centred: {}", eye[2]);
1045 assert!((eye[1] - 1.7).abs() < 1e-6, "y not eye height: {}", eye[1]);
1046 }
1047
1048 #[test]
1049 fn probe_eye_point_clamps_to_a_flat_scene() {
1050 let eye = probe_eye_point([0.0, 0.0, 0.0], [2.0, 1.0, 2.0]);
1052 assert!(
1053 eye[1] >= 0.0 && eye[1] <= 1.0,
1054 "y escaped bounds: {}",
1055 eye[1]
1056 );
1057 }
1058
1059 #[test]
1060 fn face_view_matrix_composes_to_face_vp() {
1061 let eye = [1.0, 2.0, -3.0];
1065 for face in 0..6 {
1066 let vp = face_view_projection(eye, face, 0.1, 50.0);
1067 let comp = mat4_mul(perspective_90(0.1, 50.0), face_view_matrix(eye, face));
1068 for c in 0..4 {
1069 for r in 0..4 {
1070 assert!(
1071 (vp[c][r] - comp[c][r]).abs() < 1e-5,
1072 "face {face} [{c}][{r}] mismatch"
1073 );
1074 }
1075 }
1076 }
1077 }
1078
1079 #[test]
1080 fn placement_from_center_extents_builds_box() {
1081 let p = ProbePlacement::from_center_extents([1.0, 2.0, 3.0], [4.0, 5.0, 6.0]);
1082 assert_eq!(p.box_min, [-3.0, -3.0, -3.0]);
1083 assert_eq!(p.box_max, [5.0, 7.0, 9.0]);
1084 assert_eq!(p.position, [1.0, 2.0, 3.0]);
1085 }
1086
1087 fn probe_union(probes: &[ProbePlacement]) -> ([f32; 3], [f32; 3]) {
1089 let mn = probes.iter().fold([f32::MAX; 3], |a, p| {
1090 core::array::from_fn(|i| a[i].min(p.box_min[i]))
1091 });
1092 let mx = probes.iter().fold([f32::MIN; 3], |a, p| {
1093 core::array::from_fn(|i| a[i].max(p.box_max[i]))
1094 });
1095 (mn, mx)
1096 }
1097
1098 #[test]
1099 fn auto_seed_probes_tiles_the_scene() {
1100 let probes = auto_seed_probes([-10.0, 0.0, -10.0], [10.0, 6.0, 10.0], &[]);
1102 assert_eq!(probes.len(), 4);
1103 let (union_min, union_max) = probe_union(&probes);
1104 assert_eq!(union_min, [-10.0, 0.0, -10.0]);
1105 assert_eq!(union_max, [10.0, 6.0, 10.0]);
1106 assert!(auto_seed_probes([0.0; 3], [0.0; 3], &[]).is_empty());
1108 assert!(auto_seed_probes([f32::NAN, 0.0, 0.0], [1.0, 1.0, 1.0], &[]).is_empty());
1109 }
1110
1111 #[test]
1112 fn auto_seed_scales_count_to_scene_size_and_aspect() {
1113 let small = auto_seed_probes([-3.0, 0.0, -3.0], [3.0, 3.0, 3.0], &[]);
1116 assert_eq!(small.len(), 1);
1117 let long = auto_seed_probes([0.0, 0.0, 0.0], [96.0, 4.0, 12.0], &[]);
1120 assert!(long.len() > 1 && long.len() <= AUTO_SEED_BUDGET);
1121 let nx = long.iter().filter(|p| p.box_min[2] == 0.0).count();
1122 let nz = long.len() / nx;
1123 assert!(nx > nz, "long axis (x) should have more cells: {nx}x{nz}");
1124 let (mn, mx) = probe_union(&long);
1125 assert_eq!(mn, [0.0, 0.0, 0.0]);
1126 assert_eq!(mx, [96.0, 4.0, 12.0]);
1127 let big = auto_seed_probes([0.0, 0.0, 0.0], [500.0, 4.0, 500.0], &[]);
1129 assert!(big.len() <= AUTO_SEED_BUDGET);
1130 }
1131
1132 #[test]
1133 fn auto_seed_nudges_capture_point_out_of_geometry() {
1134 let occ = [([-1.0, 0.0, -1.0], [1.0, 5.0, 1.0])];
1137 let probes = auto_seed_probes([-3.0, 0.0, -3.0], [3.0, 3.0, 3.0], &occ);
1138 assert_eq!(probes.len(), 1);
1139 let p = probes[0].position;
1140 assert!(
1141 !point_inside_any(p, &occ),
1142 "capture point {p:?} still inside the occupancy box"
1143 );
1144 assert!(p[0] >= probes[0].box_min[0] && p[0] <= probes[0].box_max[0]);
1146 assert!(p[2] >= probes[0].box_min[2] && p[2] <= probes[0].box_max[2]);
1147 let everywhere = [([-100.0, -100.0, -100.0], [100.0, 100.0, 100.0])];
1149 let trapped = auto_seed_probes([-3.0, 0.0, -3.0], [3.0, 3.0, 3.0], &everywhere);
1150 assert_eq!(trapped.len(), 1);
1151 }
1152
1153 #[test]
1154 fn fit_grid_respects_budget_and_aspect() {
1155 assert_eq!(fit_grid(1, 1, 8), (1, 1));
1156 assert_eq!(fit_grid(2, 2, 8), (2, 2)); let (nx, nz) = fit_grid(9, 2, 8); assert!(nx * nz <= 8 && nx > nz);
1159 let (nx, nz) = fit_grid(20, 20, 8); assert!(nx * nz <= 8 && nx >= 1 && nz >= 1);
1161 }
1162
1163 fn box_room(min: [f32; 3], max: [f32; 3]) -> Vec<([f32; 3], [f32; 3])> {
1166 let [x0, y0, z0] = min;
1167 let [x1, y1, z1] = max;
1168 vec![
1169 ([x0, y0 - 1.0, z0], [x1, y0, z1]), ([x0, y1, z0], [x1, y1 + 1.0, z1]), ([x0 - 1.0, y0, z0], [x0, y1, z1]), ([x1, y0, z0], [x1 + 1.0, y1, z1]), ([x0, y0, z0 - 1.0], [x1, y1, z0]), ([x0, y0, z1], [x1, y1, z1 + 1.0]), ]
1176 }
1177
1178 #[test]
1179 fn seed_interior_probes_finds_a_sealed_room() {
1180 let room = box_room([0.0, 0.0, 0.0], [10.0, 6.0, 10.0]);
1183 let probes = seed_interior_probes([-3.0, -3.0, -3.0], [13.0, 9.0, 13.0], &room, 8);
1184 assert_eq!(probes.len(), 1, "one room -> one interior probe");
1185 let p = probes[0].position;
1186 assert!(
1187 p[0] > 0.0 && p[0] < 10.0 && p[1] > 0.0 && p[1] < 6.0 && p[2] > 0.0 && p[2] < 10.0,
1188 "probe {p:?} should sit inside the room"
1189 );
1190 assert!(probes[0].box_min[0] < 2.0 && probes[0].box_max[0] > 8.0);
1192 }
1193
1194 #[test]
1195 fn reflector_bounds_covers_the_surface_and_has_volume() {
1196 let (mn, mx) = reflector_bounds([0.0, 0.0, 0.0], [14.0, 0.0, 14.0]);
1200 assert_eq!(mn[0], -14.0);
1201 assert_eq!(mx[2], 14.0);
1202 assert!(
1203 mx[1] - mn[1] > 0.0,
1204 "flat axis is inflated, not left at zero"
1205 );
1206
1207 let (mn, mx) = reflector_bounds([5.0, 3.0, -2.0], [0.5, 6.0, 0.5]);
1209 assert_eq!(mn[1], -3.0);
1210 assert_eq!(mx[1], 9.0);
1211 assert!(mn[0] < 5.0 && mx[0] > 5.0);
1212
1213 let crate_aabb = ([-0.7, 0.6, -0.7], [0.7, 2.0, 0.7]);
1215 let pool = reflector_bounds([0.0, 0.0, 0.0], [14.0, 0.0, 14.0]);
1216 let (mn, mx) = fold_world_bounds([crate_aabb, pool]).expect("finite bounds");
1217 assert_eq!((mn[0], mx[0]), (-14.0, 14.0));
1218 assert_eq!((mn[2], mx[2]), (-14.0, 14.0));
1219 }
1220
1221 #[test]
1222 fn seed_interior_probes_ignores_a_prop_sized_hollow() {
1223 let crate_tris = box_mesh_tris([-0.7, 0.6, -0.7], [0.7, 2.0, 0.7]);
1229 let probes = seed_interior_probes_tris([-0.8, 0.5, -0.8], [0.8, 2.1, 0.8], &crate_tris, 8);
1230 assert!(
1231 probes.is_empty(),
1232 "a prop-sized hollow is not a room: {probes:?}"
1233 );
1234
1235 let room_tris = box_mesh_tris([-2.5, 0.0, -2.5], [2.5, 3.0, 2.5]);
1238 let probes = seed_interior_probes_tris([-3.0, -0.5, -3.0], [3.0, 3.5, 3.0], &room_tris, 8);
1239 assert_eq!(
1240 probes.len(),
1241 1,
1242 "a standing-height room still earns a probe"
1243 );
1244 }
1245
1246 #[test]
1247 fn seed_interior_probes_ignores_an_open_scene() {
1248 let open = vec![
1251 ([-20.0, -1.0, -20.0], [20.0, 0.0, 20.0]), ([-5.0, 0.0, -5.0], [-3.0, 6.0, -3.0]), ([3.0, 0.0, 3.0], [5.0, 6.0, 5.0]), ];
1255 let probes = seed_interior_probes([-20.0, -1.0, -20.0], [20.0, 8.0, 20.0], &open, 8);
1256 assert!(
1257 probes.is_empty(),
1258 "open scene seeds no interior probes: {probes:?}"
1259 );
1260 assert!(seed_interior_probes([0.0, 0.0, 0.0], [10.0, 5.0, 10.0], &[], 8).is_empty());
1262 }
1263
1264 #[test]
1265 fn auto_seed_places_a_room_probe_then_grid() {
1266 let room = box_room([0.0, 0.0, 0.0], [10.0, 6.0, 10.0]);
1269 let probes = auto_seed_probes([-12.0, -3.0, -12.0], [22.0, 9.0, 22.0], &room);
1270 assert!(!probes.is_empty() && probes.len() <= AUTO_SEED_BUDGET);
1271 let inside_room = probes.iter().any(|p| {
1272 let q = p.position;
1273 q[0] > 0.0 && q[0] < 10.0 && q[1] > 0.0 && q[1] < 6.0 && q[2] > 0.0 && q[2] < 10.0
1274 });
1275 assert!(
1276 inside_room,
1277 "auto-seed should drop a probe inside the room: {probes:?}"
1278 );
1279 }
1280
1281 fn box_mesh_tris(min: [f32; 3], max: [f32; 3]) -> Vec<[[f32; 3]; 3]> {
1284 let [x0, y0, z0] = min;
1285 let [x1, y1, z1] = max;
1286 let c = [
1287 [x0, y0, z0],
1288 [x1, y0, z0],
1289 [x1, y1, z0],
1290 [x0, y1, z0],
1291 [x0, y0, z1],
1292 [x1, y0, z1],
1293 [x1, y1, z1],
1294 [x0, y1, z1],
1295 ];
1296 let quads = [
1298 [0, 1, 2, 3], [4, 5, 6, 7], [0, 3, 7, 4], [1, 2, 6, 5], [0, 1, 5, 4], [3, 2, 6, 7], ];
1305 let mut tris = Vec::with_capacity(12);
1306 for q in quads {
1307 tris.push([c[q[0]], c[q[1]], c[q[2]]]);
1308 tris.push([c[q[0]], c[q[2]], c[q[3]]]);
1309 }
1310 tris
1311 }
1312
1313 #[test]
1314 fn tri_box_overlap_detects_intersection_and_separation() {
1315 let h = [0.5, 0.5, 0.5];
1316 let through = [[-1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1318 assert!(tri_box_overlap([0.0, 0.0, 0.0], h, &through));
1319 assert!(!tri_box_overlap([10.0, 0.0, 0.0], h, &through));
1321 let above = [[-1.0, 5.0, -1.0], [3.0, 5.0, -1.0], [0.0, 5.0, 3.0]];
1324 assert!(!tri_box_overlap([0.0, 0.0, 0.0], h, &above));
1325 let inside = [[-0.1, 0.0, 0.0], [0.1, 0.0, 0.0], [0.0, 0.1, 0.0]];
1327 assert!(tri_box_overlap([0.0, 0.0, 0.0], h, &inside));
1328 }
1329
1330 #[test]
1331 fn surface_voxels_leave_a_watertight_mesh_hollow() {
1332 let scene_min = [-3.0, -3.0, -3.0];
1336 let scene_max = [13.0, 9.0, 13.0];
1337 let room_aabb = vec![([0.0, 0.0, 0.0], [10.0, 6.0, 10.0])];
1338 let room_tris = box_mesh_tris([0.0, 0.0, 0.0], [10.0, 6.0, 10.0]);
1339
1340 let from_aabb = seed_interior_probes(scene_min, scene_max, &room_aabb, 8);
1342 assert!(
1343 from_aabb.is_empty(),
1344 "a watertight mesh's AABB hides its interior: {from_aabb:?}"
1345 );
1346
1347 let from_tris = seed_interior_probes_tris(scene_min, scene_max, &room_tris, 8);
1349 assert_eq!(from_tris.len(), 1, "the hollow interior earns one probe");
1350 let p = from_tris[0].position;
1351 assert!(
1352 p[0] > 0.0 && p[0] < 10.0 && p[1] > 0.0 && p[1] < 6.0 && p[2] > 0.0 && p[2] < 10.0,
1353 "probe {p:?} should sit inside the watertight room"
1354 );
1355
1356 let auto = auto_seed_probes_with_geometry(scene_min, scene_max, &room_aabb, &room_tris);
1358 assert!(
1359 auto.iter().any(|q| {
1360 let q = q.position;
1361 q[0] > 0.0 && q[0] < 10.0 && q[1] > 0.0 && q[1] < 6.0 && q[2] > 0.0 && q[2] < 10.0
1362 }),
1363 "auto-seed with geometry drops a probe inside the room: {auto:?}"
1364 );
1365 assert_eq!(
1367 auto_seed_probes_with_geometry(scene_min, scene_max, &room_aabb, &[]).len(),
1368 auto_seed_probes(scene_min, scene_max, &room_aabb).len(),
1369 );
1370 }
1371
1372 #[test]
1373 fn fold_world_bounds_unions_and_skips_nonfinite() {
1374 let boxes = [
1375 ([0.0, 0.0, 0.0], [1.0, 2.0, 1.0]),
1376 ([-3.0, 1.0, -1.0], [0.5, 4.0, 2.0]),
1377 ([f32::NAN, 0.0, 0.0], [1.0, 1.0, 1.0]), ];
1379 let (mn, mx) = fold_world_bounds(boxes).expect("non-empty");
1380 assert_eq!(mn, [-3.0, 0.0, -1.0]);
1381 assert_eq!(mx, [1.0, 4.0, 2.0]);
1382 assert!(fold_world_bounds(core::iter::empty()).is_none());
1383 }
1384
1385 #[test]
1386 fn face_centres_look_down_their_axis() {
1387 let eye = [0.0, 0.0, 0.0];
1389 for face in 0..6 {
1390 let vp = face_view_projection(eye, face, 0.05, 100.0);
1391 let d = face_dir(face, 0.0, 0.0);
1392 let (nx, ny, w) = project(vp, d);
1393 assert!(w > 0.0);
1394 assert!(
1395 nx.abs() < 1e-5 && ny.abs() < 1e-5,
1396 "face {face} centre off-origin"
1397 );
1398 }
1399 }
1400
1401 #[test]
1402 fn bake_queue_hands_out_indices_in_order() {
1403 let mut q = ProbeBakeQueue::new(3);
1404 assert!(q.pending());
1405 assert_eq!(q.take_next(), Some(0));
1406 assert_eq!(q.take_next(), Some(1));
1407 assert!(q.pending());
1408 assert_eq!(q.take_next(), Some(2));
1409 assert!(!q.pending());
1410 assert_eq!(q.take_next(), None);
1411 }
1412
1413 #[test]
1414 fn bake_queue_empty_is_never_pending() {
1415 let mut q = ProbeBakeQueue::new(0);
1416 assert!(!q.pending());
1417 assert_eq!(q.take_next(), None);
1418 }
1419
1420 #[test]
1421 fn bake_queue_abort_skips_the_remainder() {
1422 let mut q = ProbeBakeQueue::new(4);
1423 assert_eq!(q.take_next(), Some(0));
1424 q.abort();
1425 assert!(!q.pending());
1426 assert_eq!(q.take_next(), None);
1427 }
1428
1429 #[test]
1430 fn bake_action_idle_starts_only_when_pending_and_eligible() {
1431 assert_eq!(
1433 next_bake_action(
1434 BakePhase::Idle,
1435 BakeSignals {
1436 queue_pending: true,
1437 eligible: true,
1438 ..Default::default()
1439 }
1440 ),
1441 BakeAction::StartNext
1442 );
1443 assert_eq!(
1445 next_bake_action(
1446 BakePhase::Idle,
1447 BakeSignals {
1448 eligible: true,
1449 ..Default::default()
1450 }
1451 ),
1452 BakeAction::Idle
1453 );
1454 assert_eq!(
1457 next_bake_action(
1458 BakePhase::Idle,
1459 BakeSignals {
1460 queue_pending: true,
1461 ..Default::default()
1462 }
1463 ),
1464 BakeAction::Idle
1465 );
1466 }
1467
1468 #[test]
1469 fn bake_action_rendering_submits_faces_before_waiting_for_completion() {
1470 assert_eq!(
1474 next_bake_action(
1475 BakePhase::Rendering,
1476 BakeSignals {
1477 faces_done: true,
1478 more_faces: true,
1479 ..Default::default()
1480 }
1481 ),
1482 BakeAction::RenderFace
1483 );
1484 assert_eq!(
1486 next_bake_action(BakePhase::Rendering, BakeSignals::default()),
1487 BakeAction::Idle
1488 );
1489 assert_eq!(
1491 next_bake_action(
1492 BakePhase::Rendering,
1493 BakeSignals {
1494 faces_done: true,
1495 ..Default::default()
1496 }
1497 ),
1498 BakeAction::StartPrefilter
1499 );
1500 }
1501
1502 #[test]
1503 fn bake_action_prefiltering_installs_only_once_every_mip_is_dispatched() {
1504 assert_eq!(
1507 next_bake_action(
1508 BakePhase::Prefiltering,
1509 BakeSignals {
1510 more_mips: true,
1511 mips_done: true,
1512 ..Default::default()
1513 }
1514 ),
1515 BakeAction::PrefilterMip
1516 );
1517 assert_eq!(
1521 next_bake_action(BakePhase::Prefiltering, BakeSignals::default()),
1522 BakeAction::Idle
1523 );
1524 assert_eq!(
1526 next_bake_action(
1527 BakePhase::Prefiltering,
1528 BakeSignals {
1529 mips_done: true,
1530 ..Default::default()
1531 }
1532 ),
1533 BakeAction::Install
1534 );
1535 }
1536
1537 #[test]
1538 fn bake_action_never_starts_a_second_bake_while_one_is_in_flight() {
1539 for phase in [BakePhase::Rendering, BakePhase::Prefiltering] {
1543 for more_faces in [false, true] {
1544 for more_mips in [false, true] {
1545 assert_ne!(
1546 next_bake_action(
1547 phase,
1548 BakeSignals {
1549 queue_pending: true,
1550 eligible: true,
1551 more_faces,
1552 more_mips,
1553 ..Default::default()
1554 }
1555 ),
1556 BakeAction::StartNext
1557 );
1558 }
1559 }
1560 }
1561 }
1562
1563 #[test]
1564 fn the_runtime_prefilter_plan_halves_each_mip_down_to_four_texels() {
1565 let plan = PrefilterPlan::RUNTIME;
1566 assert_eq!(plan.face_size(), 512);
1567 assert_eq!(plan.mips(), 8);
1570 assert_eq!(plan.mip_face_size(0), 512);
1571 assert_eq!(plan.mip_face_size(plan.mips() - 1), 4);
1572 for mip in 1..plan.mips() {
1573 assert_eq!(plan.mip_face_size(mip), plan.mip_face_size(mip - 1) / 2);
1574 }
1575 }
1576
1577 #[test]
1578 fn the_prefilter_plan_matches_the_cpu_convolution_it_replaces() {
1579 let plan = PrefilterPlan::RUNTIME;
1583 for mip in 1..plan.mips() {
1584 let p = plan.ggx_params(mip);
1585 assert_eq!(p.roughness, em::prefilter_roughness(mip, plan.mips()));
1586 assert_eq!(p.dst_size, plan.mip_face_size(mip));
1587 assert_eq!(p.src_size, plan.face_size());
1589 assert_eq!(p.src_mip_count, plan.mips() as f32);
1590 }
1591 assert_eq!(plan.ggx_params(plan.mips() - 1).roughness, 1.0);
1592 assert_eq!(plan.mip0_params().dst_size, plan.face_size());
1594 assert_eq!(plan.mip0_params().roughness, 0.0);
1595 }
1596
1597 #[test]
1598 fn a_downsample_reads_the_level_above_the_one_it_writes() {
1599 let plan = PrefilterPlan::RUNTIME;
1600 for mip in 1..plan.mips() {
1601 let p = plan.downsample_params(mip);
1602 assert_eq!(p.src_mip, mip - 1);
1603 assert_eq!(p.dst_size, plan.mip_face_size(mip));
1604 assert_eq!(plan.mip_face_size(p.src_mip), 2 * p.dst_size);
1606 }
1607 }
1608
1609 #[test]
1610 fn every_prefilter_dispatch_carries_the_same_firefly_clamp() {
1611 let plan = PrefilterPlan::RUNTIME;
1615 let clamp = plan.mip0_params().clamp_lum;
1616 assert!(clamp > 0.0);
1617 for mip in 1..plan.mips() {
1618 assert_eq!(plan.downsample_params(mip).clamp_lum, clamp);
1619 assert_eq!(plan.ggx_params(mip).clamp_lum, clamp);
1620 }
1621 }
1622}