use std::f32::consts::TAU;
#[derive(Debug, Clone)]
pub struct AoConfig {
pub ray_count: u32,
pub max_distance: f32,
pub bias: f32,
}
impl Default for AoConfig {
fn default() -> Self {
Self {
ray_count: 64,
max_distance: 1.0,
bias: 1e-4,
}
}
}
pub struct MeshBuffers<'a> {
pub positions: &'a [[f32; 3]],
pub normals: &'a [[f32; 3]],
pub indices: &'a [u32],
}
pub fn bake_ambient_occlusion(mesh: &MeshBuffers<'_>, config: &AoConfig) -> Vec<f32> {
let nv = mesh.positions.len();
if nv == 0 || config.ray_count == 0 {
return vec![1.0; nv];
}
let bvh = AoBvh::build(mesh.positions, mesh.indices);
let mut ao_out = Vec::with_capacity(nv);
for v in 0..nv {
let pos = mesh.positions[v];
let normal = if v < mesh.normals.len() {
normalize3f(mesh.normals[v])
} else {
[0.0, 0.0, 1.0]
};
let origin = [
pos[0] + normal[0] * config.bias,
pos[1] + normal[1] * config.bias,
pos[2] + normal[2] * config.bias,
];
let seed = (v as u64)
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let mut lcg = Lcg::new(seed);
let mut unoccluded = 0u32;
for ray_idx in 0..config.ray_count {
let dir = cosine_hemisphere_sample(normal, ray_idx, config.ray_count, &mut lcg);
let hit = bvh.ray_cast(origin, dir, config.max_distance);
if !hit {
unoccluded += 1;
}
}
ao_out.push(unoccluded as f32 / config.ray_count as f32);
}
ao_out
}
pub struct Lcg {
state: u64,
}
impl Lcg {
pub fn new(seed: u64) -> Self {
Self { state: seed | 1 }
}
pub fn next_f32(&mut self) -> f32 {
self.state = self
.state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let mantissa = (self.state >> 41) as u32;
let bits = 0x3f80_0000_u32 | mantissa;
f32::from_bits(bits) - 1.0
}
}
fn cosine_hemisphere_sample(
normal: [f32; 3],
ray_idx: u32,
ray_count: u32,
lcg: &mut Lcg,
) -> [f32; 3] {
let u = (ray_idx as f32 + lcg.next_f32()) / ray_count.max(1) as f32;
let v = radical_inverse_base2(ray_idx);
let phi = TAU * u;
let cos_theta = (1.0 - v).max(0.0).sqrt();
let sin_theta = (1.0 - cos_theta * cos_theta).max(0.0).sqrt();
let lx = sin_theta * phi.cos();
let ly = sin_theta * phi.sin();
let lz = cos_theta;
let (tangent, bitangent) = make_tangent_frame(normal);
[
lx * tangent[0] + ly * bitangent[0] + lz * normal[0],
lx * tangent[1] + ly * bitangent[1] + lz * normal[1],
lx * tangent[2] + ly * bitangent[2] + lz * normal[2],
]
}
#[inline]
fn radical_inverse_base2(n: u32) -> f32 {
let mut bits = n.reverse_bits();
bits = 0x3f80_0000_u32 | (bits >> 9);
f32::from_bits(bits) - 1.0
}
fn make_tangent_frame(normal: [f32; 3]) -> ([f32; 3], [f32; 3]) {
let up = if normal[1].abs() < 0.999 {
[0.0f32, 1.0, 0.0]
} else {
[1.0f32, 0.0, 0.0]
};
let tangent = normalize3f(cross3f(up, normal));
let bitangent = cross3f(normal, tangent);
(tangent, bitangent)
}
#[derive(Clone, Debug)]
struct Aabb {
min: [f32; 3],
max: [f32; 3],
}
impl Aabb {
fn empty() -> Self {
Self {
min: [f32::INFINITY; 3],
max: [f32::NEG_INFINITY; 3],
}
}
fn extend_point(&mut self, p: [f32; 3]) {
for ((&pv, mn), mx) in p.iter().zip(self.min.iter_mut()).zip(self.max.iter_mut()) {
if pv < *mn {
*mn = pv;
}
if pv > *mx {
*mx = pv;
}
}
}
#[allow(dead_code)]
fn extend_aabb(&mut self, other: &Aabb) {
for i in 0..3 {
if other.min[i] < self.min[i] {
self.min[i] = other.min[i];
}
if other.max[i] > self.max[i] {
self.max[i] = other.max[i];
}
}
}
fn centroid(&self) -> [f32; 3] {
[
(self.min[0] + self.max[0]) * 0.5,
(self.min[1] + self.max[1]) * 0.5,
(self.min[2] + self.max[2]) * 0.5,
]
}
fn ray_intersect(&self, ro: [f32; 3], rd_inv: [f32; 3], t_max: f32) -> bool {
let mut t_min = 0.0f32;
let mut t_far = t_max;
for i in 0..3 {
let t1 = (self.min[i] - ro[i]) * rd_inv[i];
let t2 = (self.max[i] - ro[i]) * rd_inv[i];
let (ta, tb) = if t1 < t2 { (t1, t2) } else { (t2, t1) };
t_min = t_min.max(ta);
t_far = t_far.min(tb);
}
t_min <= t_far
}
}
#[derive(Clone)]
struct BvhNode {
bounds: Aabb,
left: u32,
right: u32,
tri_start: u32,
tri_count: u32,
}
pub struct AoBvh {
nodes: Vec<BvhNode>,
tris: Vec<[[f32; 3]; 3]>,
}
impl AoBvh {
pub fn build(positions: &[[f32; 3]], indices: &[u32]) -> Self {
let mut tris: Vec<[[f32; 3]; 3]> = Vec::with_capacity(indices.len() / 3);
for chunk in indices.chunks(3) {
if chunk.len() < 3 {
continue;
}
let a = chunk[0] as usize;
let b = chunk[1] as usize;
let c = chunk[2] as usize;
if a >= positions.len() || b >= positions.len() || c >= positions.len() {
continue;
}
tris.push([positions[a], positions[b], positions[c]]);
}
if tris.is_empty() {
return AoBvh {
nodes: Vec::new(),
tris,
};
}
let n_tris = tris.len();
let mut prim_indices: Vec<u32> = (0..n_tris as u32).collect();
let mut nodes = Vec::with_capacity(n_tris * 2);
let root_idx = Self::build_recursive(&tris, &mut prim_indices, &mut nodes, 0, n_tris, 0);
let _ = root_idx;
let ordered_tris: Vec<[[f32; 3]; 3]> =
prim_indices.iter().map(|&i| tris[i as usize]).collect();
AoBvh {
nodes,
tris: ordered_tris,
}
}
fn build_recursive(
tris: &[[[f32; 3]; 3]],
prim_indices: &mut Vec<u32>,
nodes: &mut Vec<BvhNode>,
start: usize,
end: usize,
depth: u32,
) -> u32 {
let node_idx = nodes.len() as u32;
let mut bounds = Aabb::empty();
for &idx in &prim_indices[start..end] {
let tri = tris[idx as usize];
bounds.extend_point(tri[0]);
bounds.extend_point(tri[1]);
bounds.extend_point(tri[2]);
}
let count = end - start;
if count <= 4 || depth >= 24 {
let node = BvhNode {
bounds,
left: u32::MAX,
right: u32::MAX,
tri_start: start as u32,
tri_count: count as u32,
};
nodes.push(node);
return node_idx;
}
let extent = [
bounds.max[0] - bounds.min[0],
bounds.max[1] - bounds.min[1],
bounds.max[2] - bounds.min[2],
];
let axis = if extent[0] >= extent[1] && extent[0] >= extent[2] {
0
} else if extent[1] >= extent[2] {
1
} else {
2
};
let mid_val = bounds.centroid()[axis];
let (left_prim, right_prim): (Vec<u32>, Vec<u32>) =
prim_indices[start..end].iter().copied().partition(|&idx| {
let tri = tris[idx as usize];
let centroid = [
(tri[0][0] + tri[1][0] + tri[2][0]) / 3.0,
(tri[0][1] + tri[1][1] + tri[2][1]) / 3.0,
(tri[0][2] + tri[1][2] + tri[2][2]) / 3.0,
];
centroid[axis] < mid_val
});
let left_len = left_prim.len();
prim_indices[start..start + left_len].copy_from_slice(&left_prim);
prim_indices[start + left_len..end].copy_from_slice(&right_prim);
let split_pos = start + left_len;
let split = if split_pos == start || split_pos == end {
start + count / 2
} else {
split_pos
};
nodes.push(BvhNode {
bounds: bounds.clone(),
left: u32::MAX,
right: u32::MAX,
tri_start: 0,
tri_count: 0,
});
let left = Self::build_recursive(tris, prim_indices, nodes, start, split, depth + 1);
let right = Self::build_recursive(tris, prim_indices, nodes, split, end, depth + 1);
nodes[node_idx as usize].left = left;
nodes[node_idx as usize].right = right;
node_idx
}
pub fn ray_cast(&self, origin: [f32; 3], direction: [f32; 3], t_max: f32) -> bool {
if self.nodes.is_empty() {
return false;
}
let rd_inv = [
if direction[0].abs() > 1e-30 {
1.0 / direction[0]
} else {
f32::INFINITY
},
if direction[1].abs() > 1e-30 {
1.0 / direction[1]
} else {
f32::INFINITY
},
if direction[2].abs() > 1e-30 {
1.0 / direction[2]
} else {
f32::INFINITY
},
];
let mut stack = [0u32; 64];
let mut stack_top: usize = 1;
stack[0] = 0;
while stack_top > 0 {
stack_top -= 1;
let node_idx = stack[stack_top] as usize;
if node_idx >= self.nodes.len() {
continue;
}
let node = &self.nodes[node_idx];
if !node.bounds.ray_intersect(origin, rd_inv, t_max) {
continue;
}
if node.left == u32::MAX {
let tri_end = (node.tri_start + node.tri_count) as usize;
for ti in node.tri_start as usize..tri_end.min(self.tris.len()) {
let tri = &self.tris[ti];
if let Some(t) = moller_trumbore(origin, direction, tri[0], tri[1], tri[2]) {
if t > 0.0 && t < t_max {
return true;
}
}
}
} else {
if stack_top + 2 < stack.len() {
stack[stack_top] = node.left;
stack[stack_top + 1] = node.right;
stack_top += 2;
}
}
}
false
}
}
fn moller_trumbore(
origin: [f32; 3],
direction: [f32; 3],
v0: [f32; 3],
v1: [f32; 3],
v2: [f32; 3],
) -> Option<f32> {
let edge1 = sub3f(v1, v0);
let edge2 = sub3f(v2, v0);
let h = cross3f(direction, edge2);
let det = dot3f(edge1, h);
if det.abs() < 1e-8 {
return None;
}
let inv_det = 1.0 / det;
let s = sub3f(origin, v0);
let u = dot3f(s, h) * inv_det;
if !(0.0..=1.0).contains(&u) {
return None;
}
let q = cross3f(s, edge1);
let v = dot3f(direction, q) * inv_det;
if v < 0.0 || u + v > 1.0 {
return None;
}
let t = dot3f(edge2, q) * inv_det;
if t > 1e-6 {
Some(t)
} else {
None
}
}
#[inline]
fn sub3f(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
#[inline]
fn dot3f(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[inline]
fn cross3f(a: [f32; 3], b: [f32; 3]) -> [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],
]
}
#[inline]
fn normalize3f(v: [f32; 3]) -> [f32; 3] {
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if l < 1e-12 {
[0.0, 0.0, 1.0]
} else {
[v[0] / l, v[1] / l, v[2] / l]
}
}
pub struct AoParams {
pub num_rays: u32,
pub max_distance: f32,
pub bias: f32,
}
pub fn new_ao_params(num_rays: u32) -> AoParams {
AoParams {
num_rays,
max_distance: 1.0,
bias: 0.001,
}
}
pub fn ao_sample_hemisphere(normal: [f32; 3], sample_idx: u32, total: u32) -> [f32; 3] {
let mut lcg = Lcg::new(sample_idx as u64);
cosine_hemisphere_sample(normal, sample_idx, total, &mut lcg)
}
pub fn ao_estimate(_normal: [f32; 3], _num_rays: u32) -> f32 {
1.0
}
pub fn ao_to_color(ao: f32) -> [f32; 3] {
let v = ao.clamp(0.0, 1.0);
[v, v, v]
}
pub fn ao_params_is_valid(p: &AoParams) -> bool {
p.num_rays > 0 && p.max_distance > 0.0 && p.bias >= 0.0
}
#[cfg(test)]
mod tests {
use super::*;
fn single_triangle() -> (Vec<[f32; 3]>, Vec<[f32; 3]>, Vec<u32>) {
let positions = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
let normals = vec![[0.0, 0.0, 1.0]; 3];
let indices = vec![0, 1, 2];
(positions, normals, indices)
}
fn quad_mesh() -> (Vec<[f32; 3]>, Vec<[f32; 3]>, Vec<u32>) {
let positions = vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
];
let normals = vec![[0.0, 0.0, 1.0]; 4];
let indices = vec![0, 1, 2, 0, 2, 3];
(positions, normals, indices)
}
#[test]
fn ao_values_in_range() {
let (positions, normals, indices) = quad_mesh();
let mesh = MeshBuffers {
positions: &positions,
normals: &normals,
indices: &indices,
};
let config = AoConfig {
ray_count: 32,
..Default::default()
};
let ao = bake_ambient_occlusion(&mesh, &config);
assert_eq!(ao.len(), positions.len());
for &v in &ao {
assert!((0.0..=1.0).contains(&v), "AO value {v} is outside [0,1]");
}
}
#[test]
fn ao_is_one_for_isolated_vertex() {
let mut positions = vec![
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[1.0, 2.0, 0.0],
[0.0, 0.0, 10.0],
];
let mut normals = vec![[0.0, 0.0, 1.0]; 4];
let indices = vec![0, 1, 2];
let mesh = MeshBuffers {
positions: &positions,
normals: &normals,
indices: &indices,
};
let config = AoConfig {
ray_count: 64,
max_distance: 5.0, bias: 1e-4,
};
let ao = bake_ambient_occlusion(&mesh, &config);
assert!(
ao[3] > 0.9,
"isolated vertex at height 10 should have AO near 1.0, got {}",
ao[3]
);
let _ = (&mut positions, &mut normals);
}
#[test]
fn ao_bvh_ray_cast_hit() {
let positions = vec![[0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [0.0, 1.0, 1.0]];
let indices = vec![0, 1, 2];
let bvh = AoBvh::build(&positions, &indices);
let hit = bvh.ray_cast([0.25, 0.25, 0.0], [0.0, 0.0, 1.0], 2.0);
assert!(hit, "BVH should report a hit");
}
#[test]
fn ao_bvh_ray_cast_miss() {
let positions = vec![[0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [0.0, 1.0, 1.0]];
let indices = vec![0, 1, 2];
let bvh = AoBvh::build(&positions, &indices);
let hit = bvh.ray_cast([0.25, 0.25, 0.0], [0.0, 0.0, -1.0], 2.0);
assert!(!hit, "BVH should not report a hit for reversed ray");
}
#[test]
fn moller_trumbore_hit() {
let v0 = [0.0, 0.0, 1.0f32];
let v1 = [1.0, 0.0, 1.0];
let v2 = [0.0, 1.0, 1.0];
let t = moller_trumbore([0.25, 0.25, 0.0], [0.0, 0.0, 1.0], v0, v1, v2);
assert!(t.is_some());
assert!((t.expect("should succeed") - 1.0).abs() < 1e-5, "t should be ≈ 1.0");
}
#[test]
fn moller_trumbore_miss() {
let v0 = [0.0, 0.0, 1.0f32];
let v1 = [1.0, 0.0, 1.0];
let v2 = [0.0, 1.0, 1.0];
let t = moller_trumbore([5.0, 5.0, 0.0], [0.0, 0.0, 1.0], v0, v1, v2);
assert!(t.is_none());
}
#[test]
fn lcg_produces_values_in_range() {
let mut lcg = Lcg::new(42);
for _ in 0..1000 {
let v = lcg.next_f32();
assert!((0.0..1.0).contains(&v), "LCG out of range: {v}");
}
}
#[test]
fn bake_ao_empty_mesh_returns_empty() {
let mesh = MeshBuffers {
positions: &[],
normals: &[],
indices: &[],
};
let ao = bake_ambient_occlusion(&mesh, &AoConfig::default());
assert!(ao.is_empty());
}
#[test]
fn test_new_ao_params() {
let p = new_ao_params(16);
assert_eq!(p.num_rays, 16);
assert!(ao_params_is_valid(&p));
}
#[test]
fn test_ao_estimate_stub() {
let v = ao_estimate([0.0, 1.0, 0.0], 32);
assert!((v - 1.0).abs() < 1e-6);
}
#[test]
fn test_ao_to_color() {
let c = ao_to_color(0.5);
assert!((c[0] - 0.5).abs() < 1e-6);
assert!((c[1] - 0.5).abs() < 1e-6);
}
#[test]
fn test_ao_sample_hemisphere_normalized() {
let s = ao_sample_hemisphere([0.0, 1.0, 0.0], 0, 16);
let len = (s[0] * s[0] + s[1] * s[1] + s[2] * s[2]).sqrt();
assert!(
(len - 1.0).abs() < 1e-3,
"sample should be unit length, got {len}"
);
}
#[test]
fn test_ao_params_invalid() {
let p = AoParams {
num_rays: 0,
max_distance: 1.0,
bias: 0.0,
};
assert!(!ao_params_is_valid(&p));
}
#[test]
fn single_triangle_ao_all_in_range() {
let (positions, normals, indices) = single_triangle();
let mesh = MeshBuffers {
positions: &positions,
normals: &normals,
indices: &indices,
};
let config = AoConfig {
ray_count: 16,
..Default::default()
};
let ao = bake_ambient_occlusion(&mesh, &config);
assert_eq!(ao.len(), 3);
for v in ao {
assert!((0.0..=1.0).contains(&v));
}
}
}