use crate::bake::environment_map as em;
use crate::gfx::cubemap::FACE_BASIS;
use crate::gfx::projection::{perspective_rh, view_from_basis};
use crate::gfx::transform::mat4_mul;
use crate::math::{ceil, floor, powi, round, sqrt};
use crate::render::uniforms::ProbePrefilterParams;
use alloc::vec;
use alloc::vec::Vec;
use core::f32::consts::FRAC_PI_2;
fn perspective_90(near: f32, far: f32) -> [[f32; 4]; 4] {
perspective_rh(FRAC_PI_2, 1.0, near, far)
}
pub fn face_view_projection(eye: [f32; 3], face: usize, near: f32, far: f32) -> [[f32; 4]; 4] {
let b = FACE_BASIS[face];
let view = view_from_basis(eye, b[0], b[1], b[2]);
mat4_mul(perspective_90(near, far), view)
}
pub fn face_view_matrix(eye: [f32; 3], face: usize) -> [[f32; 4]; 4] {
let b = FACE_BASIS[face];
view_from_basis(eye, b[0], b[1], b[2])
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ProbePlacement {
pub position: [f32; 3],
pub box_min: [f32; 3],
pub box_max: [f32; 3],
}
impl ProbePlacement {
pub fn from_center_extents(position: [f32; 3], half_extents: [f32; 3]) -> ProbePlacement {
ProbePlacement {
position,
box_min: [
position[0] - half_extents[0],
position[1] - half_extents[1],
position[2] - half_extents[2],
],
box_max: [
position[0] + half_extents[0],
position[1] + half_extents[1],
position[2] + half_extents[2],
],
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProbeBakeQueue {
total: usize,
next: usize,
}
impl ProbeBakeQueue {
pub fn new(total: usize) -> ProbeBakeQueue {
ProbeBakeQueue { total, next: 0 }
}
pub fn pending(&self) -> bool {
self.next < self.total
}
pub fn take_next(&mut self) -> Option<usize> {
(self.next < self.total).then(|| {
let i = self.next;
self.next += 1;
i
})
}
pub fn abort(&mut self) {
self.next = self.total;
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BakePhase {
Idle,
Rendering,
Prefiltering,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BakeAction {
Idle,
StartNext,
RenderFace,
StartPrefilter,
PrefilterMip,
Install,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct BakeSignals {
pub faces_done: bool,
pub mips_done: bool,
pub queue_pending: bool,
pub eligible: bool,
pub more_faces: bool,
pub more_mips: bool,
}
pub fn next_bake_action(phase: BakePhase, signals: BakeSignals) -> BakeAction {
match phase {
BakePhase::Rendering => {
if signals.more_faces {
BakeAction::RenderFace
} else if signals.faces_done {
BakeAction::StartPrefilter
} else {
BakeAction::Idle
}
}
BakePhase::Prefiltering => {
if signals.more_mips {
BakeAction::PrefilterMip
} else if signals.mips_done {
BakeAction::Install
} else {
BakeAction::Idle
}
}
BakePhase::Idle => {
if signals.queue_pending && signals.eligible {
BakeAction::StartNext
} else {
BakeAction::Idle
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PrefilterPlan {
face_size: u32,
mips: u32,
samples: u32,
clamp: f32,
}
impl PrefilterPlan {
pub const RUNTIME: PrefilterPlan = PrefilterPlan {
face_size: 512,
mips: em::max_mip_count(512),
samples: 128,
clamp: 12.0,
};
pub const fn new(face_size: u32, samples: u32, clamp: f32) -> PrefilterPlan {
PrefilterPlan {
face_size,
mips: em::max_mip_count(face_size),
samples,
clamp,
}
}
pub const fn face_size(&self) -> u32 {
self.face_size
}
pub const fn mips(&self) -> u32 {
self.mips
}
pub const fn mip_face_size(&self, mip: u32) -> u32 {
self.face_size >> mip
}
pub fn mip0_params(&self) -> ProbePrefilterParams {
ProbePrefilterParams {
dst_size: self.face_size,
..self.base_params()
}
}
pub fn downsample_params(&self, dst_mip: u32) -> ProbePrefilterParams {
ProbePrefilterParams {
dst_size: self.mip_face_size(dst_mip),
src_mip: dst_mip - 1,
..self.base_params()
}
}
pub fn ggx_params(&self, dst_mip: u32) -> ProbePrefilterParams {
ProbePrefilterParams {
dst_size: self.mip_face_size(dst_mip),
roughness: em::prefilter_roughness(dst_mip, self.mips),
..self.base_params()
}
}
fn base_params(&self) -> ProbePrefilterParams {
ProbePrefilterParams {
dst_size: self.face_size,
src_size: self.face_size,
sample_count: self.samples,
src_mip: 0,
roughness: 0.0,
clamp_lum: self.clamp,
src_mip_count: self.mips as f32,
_pad: 0.0,
}
}
}
pub(crate) const AUTO_SEED_BUDGET: usize = 8;
const AUTO_SEED_CELL_TARGET: f32 = 12.0;
const INTERIOR_VOXELS_LONG_AXIS: usize = 48;
const INTERIOR_MAX_DIM: usize = 128;
const INTERIOR_MIN_ENCLOSED: u8 = 5;
const INTERIOR_MIN_CLUSTER: usize = 4;
const INTERIOR_MIN_ROOM_SPAN: f32 = 2.0;
fn fit_grid(nx: usize, nz: usize, budget: usize) -> (usize, usize) {
let (mut nx, mut nz) = (nx.max(1), nz.max(1));
if nx * nz > budget {
let scale = sqrt(budget as f32 / (nx * nz) as f32);
nx = (round(nx as f32 * scale) as usize).max(1);
nz = (round(nz as f32 * scale) as usize).max(1);
while nx * nz > budget {
if nx >= nz {
nx -= 1;
} else {
nz -= 1;
}
}
}
(nx.max(1), nz.max(1))
}
fn point_inside_any(p: [f32; 3], occupancy: &[([f32; 3], [f32; 3])]) -> bool {
occupancy.iter().any(|(mn, mx)| {
p[0] >= mn[0]
&& p[0] <= mx[0]
&& p[1] >= mn[1]
&& p[1] <= mx[1]
&& p[2] >= mn[2]
&& p[2] <= mx[2]
})
}
fn open_capture_point(
center: [f32; 3],
x0: f32,
x1: f32,
z0: f32,
z1: f32,
occupancy: &[([f32; 3], [f32; 3])],
) -> [f32; 3] {
if !point_inside_any(center, occupancy) {
return center;
}
let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
for (fx, fz) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
let p = [lerp(x0, x1, fx), center[1], lerp(z0, z1, fz)];
if !point_inside_any(p, occupancy) {
return p;
}
}
center
}
fn interior_voxel_grid(
aabb_min: [f32; 3],
aabb_max: [f32; 3],
) -> Option<(f32, usize, usize, usize)> {
let extent = [
aabb_max[0] - aabb_min[0],
aabb_max[1] - aabb_min[1],
aabb_max[2] - aabb_min[2],
];
let long = extent[0].max(extent[2]);
if long <= 0.0 || extent[1] <= 0.0 {
return None;
}
let vs = (long / INTERIOR_VOXELS_LONG_AXIS as f32).max(0.25);
let dim = |e: f32| (ceil(e / vs) as usize).clamp(1, INTERIOR_MAX_DIM);
Some((vs, dim(extent[0]), dim(extent[1]), dim(extent[2])))
}
fn solid_from_aabbs(
aabb_min: [f32; 3],
vs: f32,
nx: usize,
ny: usize,
nz: usize,
occupancy: &[([f32; 3], [f32; 3])],
) -> Vec<bool> {
let idx = |x: usize, y: usize, z: usize| (z * ny + y) * nx + x;
let mut solid = vec![false; nx * ny * nz];
let to_vx =
|v: f32, origin: f32, hi: usize| (floor((v - origin) / vs).max(0.0) as usize).min(hi);
for (mn, mx) in occupancy {
let x0 = to_vx(mn[0], aabb_min[0], nx - 1);
let x1 = to_vx(mx[0], aabb_min[0], nx - 1);
let y0 = to_vx(mn[1], aabb_min[1], ny - 1);
let y1 = to_vx(mx[1], aabb_min[1], ny - 1);
let z0 = to_vx(mn[2], aabb_min[2], nz - 1);
let z1 = to_vx(mx[2], aabb_min[2], nz - 1);
for z in z0..=z1 {
for y in y0..=y1 {
for x in x0..=x1 {
solid[idx(x, y, z)] = true;
}
}
}
}
solid
}
fn tri_box_overlap(box_c: [f32; 3], box_h: [f32; 3], tri: &[[f32; 3]; 3]) -> bool {
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
let dot = |a: [f32; 3], b: [f32; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
let cross = |a: [f32; 3], b: [f32; 3]| {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
};
let v = [sub(tri[0], box_c), sub(tri[1], box_c), sub(tri[2], box_c)];
let edges = [sub(v[1], v[0]), sub(v[2], v[1]), sub(v[0], v[2])];
let box_axes = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
let separated = |l: [f32; 3]| -> bool {
let r = box_h[0] * l[0].abs() + box_h[1] * l[1].abs() + box_h[2] * l[2].abs();
let p0 = dot(l, v[0]);
let p1 = dot(l, v[1]);
let p2 = dot(l, v[2]);
p0.min(p1).min(p2) > r || p0.max(p1).max(p2) < -r
};
for a in box_axes {
if separated(a) {
return false;
}
}
for e in edges {
for a in box_axes {
if separated(cross(e, a)) {
return false;
}
}
}
!separated(cross(edges[0], edges[1]))
}
fn solid_from_triangles(
aabb_min: [f32; 3],
vs: f32,
nx: usize,
ny: usize,
nz: usize,
triangles: &[[[f32; 3]; 3]],
) -> Vec<bool> {
let idx = |x: usize, y: usize, z: usize| (z * ny + y) * nx + x;
let mut solid = vec![false; nx * ny * nz];
let half = [vs * 0.5 + vs * 1e-3; 3];
let to_vx =
|v: f32, origin: f32, hi: usize| (floor((v - origin) / vs).max(0.0) as usize).min(hi);
for tri in triangles {
let mut tmn = tri[0];
let mut tmx = tri[0];
for vtx in &tri[1..] {
for a in 0..3 {
tmn[a] = tmn[a].min(vtx[a]);
tmx[a] = tmx[a].max(vtx[a]);
}
}
if !tmn.iter().chain(tmx.iter()).all(|c| c.is_finite()) {
continue;
}
let x0 = to_vx(tmn[0], aabb_min[0], nx - 1);
let x1 = to_vx(tmx[0], aabb_min[0], nx - 1);
let y0 = to_vx(tmn[1], aabb_min[1], ny - 1);
let y1 = to_vx(tmx[1], aabb_min[1], ny - 1);
let z0 = to_vx(tmn[2], aabb_min[2], nz - 1);
let z1 = to_vx(tmx[2], aabb_min[2], nz - 1);
for z in z0..=z1 {
for y in y0..=y1 {
for x in x0..=x1 {
let i = idx(x, y, z);
if solid[i] {
continue;
}
let c = [
aabb_min[0] + (x as f32 + 0.5) * vs,
aabb_min[1] + (y as f32 + 0.5) * vs,
aabb_min[2] + (z as f32 + 0.5) * vs,
];
if tri_box_overlap(c, half, tri) {
solid[i] = true;
}
}
}
}
}
solid
}
fn interior_probes_from_solid(
aabb_min: [f32; 3],
vs: f32,
nx: usize,
ny: usize,
nz: usize,
solid: &[bool],
budget: usize,
) -> Vec<ProbePlacement> {
let n = nx * ny * nz;
let idx = |x: usize, y: usize, z: usize| (z * ny + y) * nx + x;
let mut enclosed = vec![0u8; n];
for z in 0..nz {
for y in 0..ny {
let (mut fwd, mut bwd) = (false, false);
for x in (0..nx).rev() {
let i = idx(x, y, z);
if solid[i] {
fwd = true;
} else if fwd {
enclosed[i] += 1;
}
}
for x in 0..nx {
let i = idx(x, y, z);
if solid[i] {
bwd = true;
} else if bwd {
enclosed[i] += 1;
}
}
}
}
for z in 0..nz {
for x in 0..nx {
let (mut fwd, mut bwd) = (false, false);
for y in (0..ny).rev() {
let i = idx(x, y, z);
if solid[i] {
fwd = true;
} else if fwd {
enclosed[i] += 1;
}
}
for y in 0..ny {
let i = idx(x, y, z);
if solid[i] {
bwd = true;
} else if bwd {
enclosed[i] += 1;
}
}
}
}
for y in 0..ny {
for x in 0..nx {
let (mut fwd, mut bwd) = (false, false);
for z in (0..nz).rev() {
let i = idx(x, y, z);
if solid[i] {
fwd = true;
} else if fwd {
enclosed[i] += 1;
}
}
for z in 0..nz {
let i = idx(x, y, z);
if solid[i] {
bwd = true;
} else if bwd {
enclosed[i] += 1;
}
}
}
}
let is_interior = |i: usize| !solid[i] && enclosed[i] >= INTERIOR_MIN_ENCLOSED;
let mut label = vec![usize::MAX; n];
let mut clusters: Vec<Vec<usize>> = Vec::new();
let mut stack: Vec<usize> = Vec::new();
for start in 0..n {
if !is_interior(start) || label[start] != usize::MAX {
continue;
}
let cid = clusters.len();
let mut members = Vec::new();
label[start] = cid;
stack.push(start);
while let Some(i) = stack.pop() {
members.push(i);
let z = i / (nx * ny);
let y = (i / nx) % ny;
let x = i % nx;
let neighbours = [
(x > 0).then(|| i - 1),
(x + 1 < nx).then_some(i + 1),
(y > 0).then(|| i - nx),
(y + 1 < ny).then_some(i + nx),
(z > 0).then(|| i - nx * ny),
(z + 1 < nz).then_some(i + nx * ny),
];
for j in neighbours.into_iter().flatten() {
if is_interior(j) && label[j] == usize::MAX {
label[j] = cid;
stack.push(j);
}
}
}
clusters.push(members);
}
let voxel_center = |i: usize| {
let z = i / (nx * ny);
let y = (i / nx) % ny;
let x = i % nx;
[
aabb_min[0] + (x as f32 + 0.5) * vs,
aabb_min[1] + (y as f32 + 0.5) * vs,
aabb_min[2] + (z as f32 + 0.5) * vs,
]
};
let cluster_span = |members: &[usize]| {
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for &i in members {
let c = voxel_center(i);
for a in 0..3 {
lo[a] = lo[a].min(c[a] - vs * 0.5);
hi[a] = hi[a].max(c[a] + vs * 0.5);
}
}
[hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]
};
clusters.retain(|c| {
c.len() >= INTERIOR_MIN_CLUSTER
&& cluster_span(c).iter().all(|d| *d >= INTERIOR_MIN_ROOM_SPAN)
});
clusters.sort_by_key(|c| core::cmp::Reverse(c.len()));
clusters.truncate(budget);
clusters
.iter()
.map(|members| {
let inv = 1.0 / members.len() as f32;
let mut centroid = [0.0f32; 3];
for &i in members {
let c = voxel_center(i);
for a in 0..3 {
centroid[a] += c[a] * inv;
}
}
let dist2 = |c: [f32; 3]| {
powi(c[0] - centroid[0], 2)
+ powi(c[1] - centroid[1], 2)
+ powi(c[2] - centroid[2], 2)
};
let position = members
.iter()
.map(|&i| voxel_center(i))
.min_by(|a, b| dist2(*a).total_cmp(&dist2(*b)))
.unwrap_or(centroid);
let mut box_min = [f32::MAX; 3];
let mut box_max = [f32::MIN; 3];
for &i in members {
let c = voxel_center(i);
for a in 0..3 {
box_min[a] = box_min[a].min(c[a] - vs * 0.5);
box_max[a] = box_max[a].max(c[a] + vs * 0.5);
}
}
ProbePlacement {
position,
box_min,
box_max,
}
})
.collect()
}
fn seed_interior_probes(
aabb_min: [f32; 3],
aabb_max: [f32; 3],
occupancy: &[([f32; 3], [f32; 3])],
budget: usize,
) -> Vec<ProbePlacement> {
if budget == 0 || occupancy.is_empty() {
return Vec::new();
}
let (vs, nx, ny, nz) = match interior_voxel_grid(aabb_min, aabb_max) {
Some(g) => g,
None => return Vec::new(),
};
let solid = solid_from_aabbs(aabb_min, vs, nx, ny, nz, occupancy);
interior_probes_from_solid(aabb_min, vs, nx, ny, nz, &solid, budget)
}
fn seed_interior_probes_tris(
aabb_min: [f32; 3],
aabb_max: [f32; 3],
triangles: &[[[f32; 3]; 3]],
budget: usize,
) -> Vec<ProbePlacement> {
if budget == 0 || triangles.is_empty() {
return Vec::new();
}
let (vs, nx, ny, nz) = match interior_voxel_grid(aabb_min, aabb_max) {
Some(g) => g,
None => return Vec::new(),
};
let solid = solid_from_triangles(aabb_min, vs, nx, ny, nz, triangles);
interior_probes_from_solid(aabb_min, vs, nx, ny, nz, &solid, budget)
}
const REFLECTOR_BOUNDS_HALF_HEIGHT: f32 = 2.0;
pub fn reflector_bounds(centre: [f32; 3], half_extents: [f32; 3]) -> ([f32; 3], [f32; 3]) {
let half = |a: usize| half_extents[a].abs().max(REFLECTOR_BOUNDS_HALF_HEIGHT);
(
[
centre[0] - half(0),
centre[1] - half(1),
centre[2] - half(2),
],
[
centre[0] + half(0),
centre[1] + half(1),
centre[2] + half(2),
],
)
}
pub fn auto_seed_probes(
aabb_min: [f32; 3],
aabb_max: [f32; 3],
occupancy: &[([f32; 3], [f32; 3])],
) -> Vec<ProbePlacement> {
auto_seed_probes_with_geometry(aabb_min, aabb_max, occupancy, &[])
}
pub fn auto_seed_probes_with_geometry(
aabb_min: [f32; 3],
aabb_max: [f32; 3],
occupancy: &[([f32; 3], [f32; 3])],
triangles: &[[[f32; 3]; 3]],
) -> Vec<ProbePlacement> {
let finite = aabb_min
.iter()
.chain(aabb_max.iter())
.all(|c| c.is_finite());
if !finite || aabb_max[0] <= aabb_min[0] || aabb_max[2] <= aabb_min[2] {
return Vec::new();
}
let mut out = if triangles.is_empty() {
seed_interior_probes(aabb_min, aabb_max, occupancy, AUTO_SEED_BUDGET)
} else {
seed_interior_probes_tris(aabb_min, aabb_max, triangles, AUTO_SEED_BUDGET)
};
let remaining = AUTO_SEED_BUDGET.saturating_sub(out.len());
if remaining > 0 {
out.extend(seed_grid_probes(aabb_min, aabb_max, occupancy, remaining));
}
out
}
fn seed_grid_probes(
aabb_min: [f32; 3],
aabb_max: [f32; 3],
occupancy: &[([f32; 3], [f32; 3])],
budget: usize,
) -> Vec<ProbePlacement> {
if budget == 0 {
return Vec::new();
}
let dx = aabb_max[0] - aabb_min[0];
let dz = aabb_max[2] - aabb_min[2];
let nx_raw = ceil(dx / AUTO_SEED_CELL_TARGET).max(1.0) as usize;
let nz_raw = ceil(dz / AUTO_SEED_CELL_TARGET).max(1.0) as usize;
let (nx, nz) = fit_grid(nx_raw, nz_raw, budget);
let y_eye = probe_eye_point(aabb_min, aabb_max)[1];
let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
let mut out = Vec::with_capacity(nx * nz);
for ix in 0..nx {
for iz in 0..nz {
let x0 = lerp(aabb_min[0], aabb_max[0], ix as f32 / nx as f32);
let x1 = lerp(aabb_min[0], aabb_max[0], (ix + 1) as f32 / nx as f32);
let z0 = lerp(aabb_min[2], aabb_max[2], iz as f32 / nz as f32);
let z1 = lerp(aabb_min[2], aabb_max[2], (iz + 1) as f32 / nz as f32);
let center = [(x0 + x1) * 0.5, y_eye, (z0 + z1) * 0.5];
out.push(ProbePlacement {
position: open_capture_point(center, x0, x1, z0, z1, occupancy),
box_min: [x0, aabb_min[1], z0],
box_max: [x1, aabb_max[1], z1],
});
}
}
out
}
pub fn fold_world_bounds(
boxes: impl IntoIterator<Item = ([f32; 3], [f32; 3])>,
) -> Option<([f32; 3], [f32; 3])> {
let mut acc: Option<([f32; 3], [f32; 3])> = None;
for (mn, mx) in boxes {
if !mn.iter().chain(mx.iter()).all(|c| c.is_finite()) {
continue;
}
match &mut acc {
None => acc = Some((mn, mx)),
Some((amn, amx)) => {
for i in 0..3 {
amn[i] = amn[i].min(mn[i]);
amx[i] = amx[i].max(mx[i]);
}
}
}
}
acc
}
pub(crate) fn probe_eye_point(aabb_min: [f32; 3], aabb_max: [f32; 3]) -> [f32; 3] {
const EYE_HEIGHT: f32 = 1.7;
let cx = 0.5 * (aabb_min[0] + aabb_max[0]);
let cz = 0.5 * (aabb_min[2] + aabb_max[2]);
let floor = aabb_min[1];
let ceil = aabb_max[1];
let y = (floor + EYE_HEIGHT).min(0.5 * (floor + ceil)).max(floor);
[cx, y, cz]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gfx::cubemap::face_dir;
fn project(vp: [[f32; 4]; 4], p: [f32; 3]) -> (f32, f32, f32) {
let mut c = [0.0f32; 4];
let pv = [p[0], p[1], p[2], 1.0];
for row in 0..4 {
for k in 0..4 {
c[row] += vp[k][row] * pv[k];
}
}
(c[0] / c[3], c[1] / c[3], c[3])
}
#[test]
fn face_view_projection_matches_cube_convention() {
let eye = [3.0, -1.5, 2.0];
let samples = [
(0.0f32, 0.0f32),
(0.5, 0.0),
(0.0, 0.5),
(-0.6, 0.3),
(0.7, -0.4),
];
for face in 0..6 {
let vp = face_view_projection(eye, face, 0.05, 100.0);
for &(u, v) in &samples {
let d = face_dir(face, u, v);
let p = [eye[0] + d[0], eye[1] + d[1], eye[2] + d[2]];
let (nx, ny, w) = project(vp, p);
assert!(
w > 0.0,
"face {face} sample ({u},{v}) behind camera (w={w})"
);
assert!(
(nx - u).abs() < 1e-4 && (ny - (-v)).abs() < 1e-4,
"face {face} ({u},{v}) -> ndc ({nx},{ny}), expected ({u},{})",
-v
);
}
}
}
#[test]
fn probe_eye_point_centres_at_eye_height() {
let eye = probe_eye_point([-10.0, 0.0, -4.0], [6.0, 30.0, 12.0]);
assert!((eye[0] - (-2.0)).abs() < 1e-6, "x not centred: {}", eye[0]);
assert!((eye[2] - 4.0).abs() < 1e-6, "z not centred: {}", eye[2]);
assert!((eye[1] - 1.7).abs() < 1e-6, "y not eye height: {}", eye[1]);
}
#[test]
fn probe_eye_point_clamps_to_a_flat_scene() {
let eye = probe_eye_point([0.0, 0.0, 0.0], [2.0, 1.0, 2.0]);
assert!(
eye[1] >= 0.0 && eye[1] <= 1.0,
"y escaped bounds: {}",
eye[1]
);
}
#[test]
fn face_view_matrix_composes_to_face_vp() {
let eye = [1.0, 2.0, -3.0];
for face in 0..6 {
let vp = face_view_projection(eye, face, 0.1, 50.0);
let comp = mat4_mul(perspective_90(0.1, 50.0), face_view_matrix(eye, face));
for c in 0..4 {
for r in 0..4 {
assert!(
(vp[c][r] - comp[c][r]).abs() < 1e-5,
"face {face} [{c}][{r}] mismatch"
);
}
}
}
}
#[test]
fn placement_from_center_extents_builds_box() {
let p = ProbePlacement::from_center_extents([1.0, 2.0, 3.0], [4.0, 5.0, 6.0]);
assert_eq!(p.box_min, [-3.0, -3.0, -3.0]);
assert_eq!(p.box_max, [5.0, 7.0, 9.0]);
assert_eq!(p.position, [1.0, 2.0, 3.0]);
}
fn probe_union(probes: &[ProbePlacement]) -> ([f32; 3], [f32; 3]) {
let mn = probes.iter().fold([f32::MAX; 3], |a, p| {
core::array::from_fn(|i| a[i].min(p.box_min[i]))
});
let mx = probes.iter().fold([f32::MIN; 3], |a, p| {
core::array::from_fn(|i| a[i].max(p.box_max[i]))
});
(mn, mx)
}
#[test]
fn auto_seed_probes_tiles_the_scene() {
let probes = auto_seed_probes([-10.0, 0.0, -10.0], [10.0, 6.0, 10.0], &[]);
assert_eq!(probes.len(), 4);
let (union_min, union_max) = probe_union(&probes);
assert_eq!(union_min, [-10.0, 0.0, -10.0]);
assert_eq!(union_max, [10.0, 6.0, 10.0]);
assert!(auto_seed_probes([0.0; 3], [0.0; 3], &[]).is_empty());
assert!(auto_seed_probes([f32::NAN, 0.0, 0.0], [1.0, 1.0, 1.0], &[]).is_empty());
}
#[test]
fn auto_seed_scales_count_to_scene_size_and_aspect() {
let small = auto_seed_probes([-3.0, 0.0, -3.0], [3.0, 3.0, 3.0], &[]);
assert_eq!(small.len(), 1);
let long = auto_seed_probes([0.0, 0.0, 0.0], [96.0, 4.0, 12.0], &[]);
assert!(long.len() > 1 && long.len() <= AUTO_SEED_BUDGET);
let nx = long.iter().filter(|p| p.box_min[2] == 0.0).count();
let nz = long.len() / nx;
assert!(nx > nz, "long axis (x) should have more cells: {nx}x{nz}");
let (mn, mx) = probe_union(&long);
assert_eq!(mn, [0.0, 0.0, 0.0]);
assert_eq!(mx, [96.0, 4.0, 12.0]);
let big = auto_seed_probes([0.0, 0.0, 0.0], [500.0, 4.0, 500.0], &[]);
assert!(big.len() <= AUTO_SEED_BUDGET);
}
#[test]
fn auto_seed_nudges_capture_point_out_of_geometry() {
let occ = [([-1.0, 0.0, -1.0], [1.0, 5.0, 1.0])];
let probes = auto_seed_probes([-3.0, 0.0, -3.0], [3.0, 3.0, 3.0], &occ);
assert_eq!(probes.len(), 1);
let p = probes[0].position;
assert!(
!point_inside_any(p, &occ),
"capture point {p:?} still inside the occupancy box"
);
assert!(p[0] >= probes[0].box_min[0] && p[0] <= probes[0].box_max[0]);
assert!(p[2] >= probes[0].box_min[2] && p[2] <= probes[0].box_max[2]);
let everywhere = [([-100.0, -100.0, -100.0], [100.0, 100.0, 100.0])];
let trapped = auto_seed_probes([-3.0, 0.0, -3.0], [3.0, 3.0, 3.0], &everywhere);
assert_eq!(trapped.len(), 1);
}
#[test]
fn fit_grid_respects_budget_and_aspect() {
assert_eq!(fit_grid(1, 1, 8), (1, 1));
assert_eq!(fit_grid(2, 2, 8), (2, 2)); let (nx, nz) = fit_grid(9, 2, 8); assert!(nx * nz <= 8 && nx > nz);
let (nx, nz) = fit_grid(20, 20, 8); assert!(nx * nz <= 8 && nx >= 1 && nz >= 1);
}
fn box_room(min: [f32; 3], max: [f32; 3]) -> Vec<([f32; 3], [f32; 3])> {
let [x0, y0, z0] = min;
let [x1, y1, z1] = max;
vec![
([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]), ]
}
#[test]
fn seed_interior_probes_finds_a_sealed_room() {
let room = box_room([0.0, 0.0, 0.0], [10.0, 6.0, 10.0]);
let probes = seed_interior_probes([-3.0, -3.0, -3.0], [13.0, 9.0, 13.0], &room, 8);
assert_eq!(probes.len(), 1, "one room -> one interior probe");
let p = probes[0].position;
assert!(
p[0] > 0.0 && p[0] < 10.0 && p[1] > 0.0 && p[1] < 6.0 && p[2] > 0.0 && p[2] < 10.0,
"probe {p:?} should sit inside the room"
);
assert!(probes[0].box_min[0] < 2.0 && probes[0].box_max[0] > 8.0);
}
#[test]
fn reflector_bounds_covers_the_surface_and_has_volume() {
let (mn, mx) = reflector_bounds([0.0, 0.0, 0.0], [14.0, 0.0, 14.0]);
assert_eq!(mn[0], -14.0);
assert_eq!(mx[2], 14.0);
assert!(
mx[1] - mn[1] > 0.0,
"flat axis is inflated, not left at zero"
);
let (mn, mx) = reflector_bounds([5.0, 3.0, -2.0], [0.5, 6.0, 0.5]);
assert_eq!(mn[1], -3.0);
assert_eq!(mx[1], 9.0);
assert!(mn[0] < 5.0 && mx[0] > 5.0);
let crate_aabb = ([-0.7, 0.6, -0.7], [0.7, 2.0, 0.7]);
let pool = reflector_bounds([0.0, 0.0, 0.0], [14.0, 0.0, 14.0]);
let (mn, mx) = fold_world_bounds([crate_aabb, pool]).expect("finite bounds");
assert_eq!((mn[0], mx[0]), (-14.0, 14.0));
assert_eq!((mn[2], mx[2]), (-14.0, 14.0));
}
#[test]
fn seed_interior_probes_ignores_a_prop_sized_hollow() {
let crate_tris = box_mesh_tris([-0.7, 0.6, -0.7], [0.7, 2.0, 0.7]);
let probes = seed_interior_probes_tris([-0.8, 0.5, -0.8], [0.8, 2.1, 0.8], &crate_tris, 8);
assert!(
probes.is_empty(),
"a prop-sized hollow is not a room: {probes:?}"
);
let room_tris = box_mesh_tris([-2.5, 0.0, -2.5], [2.5, 3.0, 2.5]);
let probes = seed_interior_probes_tris([-3.0, -0.5, -3.0], [3.0, 3.5, 3.0], &room_tris, 8);
assert_eq!(
probes.len(),
1,
"a standing-height room still earns a probe"
);
}
#[test]
fn seed_interior_probes_ignores_an_open_scene() {
let open = vec![
([-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]), ];
let probes = seed_interior_probes([-20.0, -1.0, -20.0], [20.0, 8.0, 20.0], &open, 8);
assert!(
probes.is_empty(),
"open scene seeds no interior probes: {probes:?}"
);
assert!(seed_interior_probes([0.0, 0.0, 0.0], [10.0, 5.0, 10.0], &[], 8).is_empty());
}
#[test]
fn auto_seed_places_a_room_probe_then_grid() {
let room = box_room([0.0, 0.0, 0.0], [10.0, 6.0, 10.0]);
let probes = auto_seed_probes([-12.0, -3.0, -12.0], [22.0, 9.0, 22.0], &room);
assert!(!probes.is_empty() && probes.len() <= AUTO_SEED_BUDGET);
let inside_room = probes.iter().any(|p| {
let q = p.position;
q[0] > 0.0 && q[0] < 10.0 && q[1] > 0.0 && q[1] < 6.0 && q[2] > 0.0 && q[2] < 10.0
});
assert!(
inside_room,
"auto-seed should drop a probe inside the room: {probes:?}"
);
}
fn box_mesh_tris(min: [f32; 3], max: [f32; 3]) -> Vec<[[f32; 3]; 3]> {
let [x0, y0, z0] = min;
let [x1, y1, z1] = max;
let c = [
[x0, y0, z0],
[x1, y0, z0],
[x1, y1, z0],
[x0, y1, z0],
[x0, y0, z1],
[x1, y0, z1],
[x1, y1, z1],
[x0, y1, z1],
];
let quads = [
[0, 1, 2, 3], [4, 5, 6, 7], [0, 3, 7, 4], [1, 2, 6, 5], [0, 1, 5, 4], [3, 2, 6, 7], ];
let mut tris = Vec::with_capacity(12);
for q in quads {
tris.push([c[q[0]], c[q[1]], c[q[2]]]);
tris.push([c[q[0]], c[q[2]], c[q[3]]]);
}
tris
}
#[test]
fn tri_box_overlap_detects_intersection_and_separation() {
let h = [0.5, 0.5, 0.5];
let through = [[-1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
assert!(tri_box_overlap([0.0, 0.0, 0.0], h, &through));
assert!(!tri_box_overlap([10.0, 0.0, 0.0], h, &through));
let above = [[-1.0, 5.0, -1.0], [3.0, 5.0, -1.0], [0.0, 5.0, 3.0]];
assert!(!tri_box_overlap([0.0, 0.0, 0.0], h, &above));
let inside = [[-0.1, 0.0, 0.0], [0.1, 0.0, 0.0], [0.0, 0.1, 0.0]];
assert!(tri_box_overlap([0.0, 0.0, 0.0], h, &inside));
}
#[test]
fn surface_voxels_leave_a_watertight_mesh_hollow() {
let scene_min = [-3.0, -3.0, -3.0];
let scene_max = [13.0, 9.0, 13.0];
let room_aabb = vec![([0.0, 0.0, 0.0], [10.0, 6.0, 10.0])];
let room_tris = box_mesh_tris([0.0, 0.0, 0.0], [10.0, 6.0, 10.0]);
let from_aabb = seed_interior_probes(scene_min, scene_max, &room_aabb, 8);
assert!(
from_aabb.is_empty(),
"a watertight mesh's AABB hides its interior: {from_aabb:?}"
);
let from_tris = seed_interior_probes_tris(scene_min, scene_max, &room_tris, 8);
assert_eq!(from_tris.len(), 1, "the hollow interior earns one probe");
let p = from_tris[0].position;
assert!(
p[0] > 0.0 && p[0] < 10.0 && p[1] > 0.0 && p[1] < 6.0 && p[2] > 0.0 && p[2] < 10.0,
"probe {p:?} should sit inside the watertight room"
);
let auto = auto_seed_probes_with_geometry(scene_min, scene_max, &room_aabb, &room_tris);
assert!(
auto.iter().any(|q| {
let q = q.position;
q[0] > 0.0 && q[0] < 10.0 && q[1] > 0.0 && q[1] < 6.0 && q[2] > 0.0 && q[2] < 10.0
}),
"auto-seed with geometry drops a probe inside the room: {auto:?}"
);
assert_eq!(
auto_seed_probes_with_geometry(scene_min, scene_max, &room_aabb, &[]).len(),
auto_seed_probes(scene_min, scene_max, &room_aabb).len(),
);
}
#[test]
fn fold_world_bounds_unions_and_skips_nonfinite() {
let boxes = [
([0.0, 0.0, 0.0], [1.0, 2.0, 1.0]),
([-3.0, 1.0, -1.0], [0.5, 4.0, 2.0]),
([f32::NAN, 0.0, 0.0], [1.0, 1.0, 1.0]), ];
let (mn, mx) = fold_world_bounds(boxes).expect("non-empty");
assert_eq!(mn, [-3.0, 0.0, -1.0]);
assert_eq!(mx, [1.0, 4.0, 2.0]);
assert!(fold_world_bounds(core::iter::empty()).is_none());
}
#[test]
fn face_centres_look_down_their_axis() {
let eye = [0.0, 0.0, 0.0];
for face in 0..6 {
let vp = face_view_projection(eye, face, 0.05, 100.0);
let d = face_dir(face, 0.0, 0.0);
let (nx, ny, w) = project(vp, d);
assert!(w > 0.0);
assert!(
nx.abs() < 1e-5 && ny.abs() < 1e-5,
"face {face} centre off-origin"
);
}
}
#[test]
fn bake_queue_hands_out_indices_in_order() {
let mut q = ProbeBakeQueue::new(3);
assert!(q.pending());
assert_eq!(q.take_next(), Some(0));
assert_eq!(q.take_next(), Some(1));
assert!(q.pending());
assert_eq!(q.take_next(), Some(2));
assert!(!q.pending());
assert_eq!(q.take_next(), None);
}
#[test]
fn bake_queue_empty_is_never_pending() {
let mut q = ProbeBakeQueue::new(0);
assert!(!q.pending());
assert_eq!(q.take_next(), None);
}
#[test]
fn bake_queue_abort_skips_the_remainder() {
let mut q = ProbeBakeQueue::new(4);
assert_eq!(q.take_next(), Some(0));
q.abort();
assert!(!q.pending());
assert_eq!(q.take_next(), None);
}
#[test]
fn bake_action_idle_starts_only_when_pending_and_eligible() {
assert_eq!(
next_bake_action(
BakePhase::Idle,
BakeSignals {
queue_pending: true,
eligible: true,
..Default::default()
}
),
BakeAction::StartNext
);
assert_eq!(
next_bake_action(
BakePhase::Idle,
BakeSignals {
eligible: true,
..Default::default()
}
),
BakeAction::Idle
);
assert_eq!(
next_bake_action(
BakePhase::Idle,
BakeSignals {
queue_pending: true,
..Default::default()
}
),
BakeAction::Idle
);
}
#[test]
fn bake_action_rendering_submits_faces_before_waiting_for_completion() {
assert_eq!(
next_bake_action(
BakePhase::Rendering,
BakeSignals {
faces_done: true,
more_faces: true,
..Default::default()
}
),
BakeAction::RenderFace
);
assert_eq!(
next_bake_action(BakePhase::Rendering, BakeSignals::default()),
BakeAction::Idle
);
assert_eq!(
next_bake_action(
BakePhase::Rendering,
BakeSignals {
faces_done: true,
..Default::default()
}
),
BakeAction::StartPrefilter
);
}
#[test]
fn bake_action_prefiltering_installs_only_once_every_mip_is_dispatched() {
assert_eq!(
next_bake_action(
BakePhase::Prefiltering,
BakeSignals {
more_mips: true,
mips_done: true,
..Default::default()
}
),
BakeAction::PrefilterMip
);
assert_eq!(
next_bake_action(BakePhase::Prefiltering, BakeSignals::default()),
BakeAction::Idle
);
assert_eq!(
next_bake_action(
BakePhase::Prefiltering,
BakeSignals {
mips_done: true,
..Default::default()
}
),
BakeAction::Install
);
}
#[test]
fn bake_action_never_starts_a_second_bake_while_one_is_in_flight() {
for phase in [BakePhase::Rendering, BakePhase::Prefiltering] {
for more_faces in [false, true] {
for more_mips in [false, true] {
assert_ne!(
next_bake_action(
phase,
BakeSignals {
queue_pending: true,
eligible: true,
more_faces,
more_mips,
..Default::default()
}
),
BakeAction::StartNext
);
}
}
}
}
#[test]
fn the_runtime_prefilter_plan_halves_each_mip_down_to_four_texels() {
let plan = PrefilterPlan::RUNTIME;
assert_eq!(plan.face_size(), 512);
assert_eq!(plan.mips(), 8);
assert_eq!(plan.mip_face_size(0), 512);
assert_eq!(plan.mip_face_size(plan.mips() - 1), 4);
for mip in 1..plan.mips() {
assert_eq!(plan.mip_face_size(mip), plan.mip_face_size(mip - 1) / 2);
}
}
#[test]
fn the_prefilter_plan_matches_the_cpu_convolution_it_replaces() {
let plan = PrefilterPlan::RUNTIME;
for mip in 1..plan.mips() {
let p = plan.ggx_params(mip);
assert_eq!(p.roughness, em::prefilter_roughness(mip, plan.mips()));
assert_eq!(p.dst_size, plan.mip_face_size(mip));
assert_eq!(p.src_size, plan.face_size());
assert_eq!(p.src_mip_count, plan.mips() as f32);
}
assert_eq!(plan.ggx_params(plan.mips() - 1).roughness, 1.0);
assert_eq!(plan.mip0_params().dst_size, plan.face_size());
assert_eq!(plan.mip0_params().roughness, 0.0);
}
#[test]
fn a_downsample_reads_the_level_above_the_one_it_writes() {
let plan = PrefilterPlan::RUNTIME;
for mip in 1..plan.mips() {
let p = plan.downsample_params(mip);
assert_eq!(p.src_mip, mip - 1);
assert_eq!(p.dst_size, plan.mip_face_size(mip));
assert_eq!(plan.mip_face_size(p.src_mip), 2 * p.dst_size);
}
}
#[test]
fn every_prefilter_dispatch_carries_the_same_firefly_clamp() {
let plan = PrefilterPlan::RUNTIME;
let clamp = plan.mip0_params().clamp_lum;
assert!(clamp > 0.0);
for mip in 1..plan.mips() {
assert_eq!(plan.downsample_params(mip).clamp_lum, clamp);
assert_eq!(plan.ggx_params(mip).clamp_lum, clamp);
}
}
}