use super::*;
use gizmo_math::{Mat4, Quat, Vec3};
fn boxx(cx: f32, cy: f32, cz: f32, h: f32) -> Aabb {
Aabb::new(
Vec3::new(cx - h, cy - h, cz - h),
Vec3::new(cx + h, cy + h, cz + h),
)
}
fn cam(eye: Vec3, at: Vec3, far: f32) -> Frustum {
let view = Mat4::look_at_rh(eye, at, Vec3::Y);
let proj = Mat4::perspective_rh(std::f32::consts::FRAC_PI_3, 16.0 / 9.0, 0.1, far);
Frustum::from_matrix(&(proj * view))
}
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed ^ 0x9e37_79b9_7f4a_7c15)
}
fn next_u32(&mut self) -> u32 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(self.0 >> 33) as u32
}
fn f32_in(&mut self, lo: f32, hi: f32) -> f32 {
lo + (self.next_u32() as f32 / u32::MAX as f32) * (hi - lo)
}
fn usize_below(&mut self, n: usize) -> usize {
(self.next_u32() as usize) % n
}
}
fn brute_force(tight: &[(u32, Aabb)], f: &Frustum) -> Vec<u32> {
let mut v: Vec<u32> = tight
.iter()
.filter(|(_, a)| f.intersects_aabb(*a))
.map(|(k, _)| *k)
.collect();
v.sort_unstable();
v
}
fn sorted(mut v: Vec<u32>) -> Vec<u32> {
v.sort_unstable();
v
}
#[test]
fn empty_tree_queries_return_nothing() {
let t = RenderAabbTree::new();
let f = cam(Vec3::ZERO, Vec3::new(0.0, 0.0, -1.0), 100.0);
let mut out = vec![];
t.query_frustum(&f, &mut out);
assert!(out.is_empty());
t.query_frustum_full_mask(&f, &mut out);
assert!(out.is_empty());
t.query_aabb(&boxx(0.0, 0.0, 0.0, 1000.0), &mut out);
assert!(out.is_empty());
t.query_frusta(&[f], &mut out);
assert!(out.is_empty());
assert!(t.is_empty());
assert_eq!(t.len(), 0);
assert_eq!(t.height(), 0);
#[cfg(debug_assertions)]
t.validate();
}
#[test]
fn one_leaf_inside_is_returned_outside_is_not() {
let f = cam(Vec3::new(0.0, 0.0, 10.0), Vec3::ZERO, 100.0);
let mut t = RenderAabbTree::new();
assert!(t.insert(0, boxx(0.0, 0.0, 0.0, 1.0)));
let mut out = vec![];
t.query_frustum(&f, &mut out);
assert_eq!(out, vec![0]);
let mut t2 = RenderAabbTree::new();
assert!(t2.insert(0, boxx(0.0, 0.0, 500.0, 1.0))); out.clear();
t2.query_frustum(&f, &mut out);
assert!(out.is_empty());
}
#[test]
fn insert_inside_the_fat_box_is_a_no_op() {
let mut t = RenderAabbTree::with_fat_margin(1.0);
for i in 0..64u32 {
t.insert(i, boxx(i as f32 * 5.0, 0.0, 0.0, 1.0));
}
let h = t.height();
let n = t.len();
let stored = t.leaf_aabb(7).unwrap();
assert!(
!t.insert(7, boxx(0.4, 0.2, -0.3, 1.0).translated(35.0)),
"a move inside the fat box must not re-bin"
);
assert_eq!(t.height(), h);
assert_eq!(t.len(), n);
assert_eq!(
t.leaf_aabb(7).unwrap(),
stored,
"the stored box must be untouched, not merely equivalent"
);
assert!(!t.insert(7, boxx(35.0, 0.0, 0.0, 1.0)));
#[cfg(debug_assertions)]
t.validate();
}
#[test]
fn insert_outside_the_fat_box_rebins() {
let mut t = RenderAabbTree::with_fat_margin(0.5);
for i in 0..32u32 {
t.insert(i, boxx(i as f32 * 4.0, 0.0, 0.0, 1.0));
}
assert!(t.insert(3, boxx(500.0, 0.0, 0.0, 1.0)), "a big move must re-bin");
let mut out = vec![];
t.query_aabb(&boxx(500.0, 0.0, 0.0, 2.0), &mut out);
assert!(out.contains(&3), "not found at the new place");
out.clear();
t.query_aabb(&boxx(12.0, 0.0, 0.0, 1.0), &mut out);
assert!(!out.contains(&3), "still found at the old place");
assert_eq!(t.len(), 32, "re-binning must not change the key count");
#[cfg(debug_assertions)]
t.validate();
}
#[test]
fn remove_then_query_does_not_return_the_key() {
let mut t = RenderAabbTree::new();
for i in 0..16u32 {
t.insert(i, boxx(i as f32 * 3.0, 0.0, 0.0, 1.0));
}
let nodes_before = t.nodes.len();
assert!(t.remove(5));
assert!(!t.remove(5), "removing twice must report the second as a no-op");
assert!(!t.contains(5));
assert_eq!(t.len(), 15);
let mut out = vec![];
t.query_aabb(&boxx(15.0, 0.0, 0.0, 2.0), &mut out);
assert!(!out.contains(&5));
t.insert(99, boxx(-50.0, 0.0, 0.0, 1.0));
assert_eq!(
t.nodes.len(),
nodes_before,
"the freed slots must be reused, not grown past"
);
#[cfg(debug_assertions)]
t.validate();
}
#[test]
fn retain_evicts_exactly_the_rejected_keys_and_reports_the_count() {
let mut t = RenderAabbTree::new();
for i in 0..40u32 {
t.insert(i, boxx(i as f32 * 2.0, 0.0, 0.0, 0.5));
}
let dropped = t.retain(|k| k % 3 != 0);
assert_eq!(dropped, 14, "0,3,..,39 is 14 keys");
assert_eq!(t.len(), 26);
for k in 0..40u32 {
assert_eq!(t.contains(k), k % 3 != 0, "key {k}");
}
assert_eq!(t.retain(|_| true), 0);
assert_eq!(t.retain(|_| false), 26);
assert!(t.is_empty());
#[cfg(debug_assertions)]
t.validate();
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "does not enclose child")]
fn validate_catches_a_parent_that_does_not_enclose_its_child() {
let mut t = RenderAabbTree::new();
for i in 0..8u32 {
t.insert(i, boxx(i as f32 * 3.0, 0.0, 0.0, 1.0));
}
t.validate();
t.corrupt_node_aabb(4, boxx(10_000.0, 10_000.0, 10_000.0, 1.0));
t.validate();
}
fn random_scene(seed: u64, n: usize, margin: f32) -> (RenderAabbTree, Vec<(u32, Aabb)>) {
let mut rng = Lcg::new(seed);
let mut t = RenderAabbTree::with_fat_margin(margin);
let mut tight = Vec::with_capacity(n);
for k in 0..n as u32 {
let h = rng.f32_in(0.2, 8.0);
let a = boxx(
rng.f32_in(-300.0, 300.0),
rng.f32_in(-40.0, 40.0),
rng.f32_in(-300.0, 300.0),
h,
);
t.insert(k, a);
tight.push((k, a));
}
(t, tight)
}
fn random_frustum(rng: &mut Lcg) -> Frustum {
let eye = Vec3::new(
rng.f32_in(-350.0, 350.0),
rng.f32_in(-50.0, 50.0),
rng.f32_in(-350.0, 350.0),
);
let dir = Vec3::new(
rng.f32_in(-1.0, 1.0),
rng.f32_in(-1.0, 1.0),
rng.f32_in(-1.0, 1.0),
);
let dir = if dir.length_squared() < 1e-4 { Vec3::NEG_Z } else { dir.normalize() };
cam(eye, eye + dir, rng.f32_in(20.0, 800.0))
}
#[test]
fn the_masked_walk_agrees_with_the_unmasked_walk() {
for seed in 0..24u64 {
let (t, _) = random_scene(seed, 200, 0.5);
let mut rng = Lcg::new(seed ^ 0xabcd);
for _ in 0..8 {
let f = random_frustum(&mut rng);
let mut masked = vec![];
let mut plain = vec![];
t.query_frustum(&f, &mut masked);
t.query_frustum_full_mask(&f, &mut plain);
assert_eq!(
sorted(masked),
sorted(plain),
"seed {seed}: plane masking changed the answer, not just the work"
);
}
}
}
#[test]
fn query_frustum_never_misses_a_visible_key() {
for seed in 0..24u64 {
let (t, tight) = random_scene(seed, 300, 1.5);
let mut rng = Lcg::new(seed ^ 0x1234);
for _ in 0..8 {
let f = random_frustum(&mut rng);
let mut got = vec![];
t.query_frustum(&f, &mut got);
let got = sorted(got);
for k in brute_force(&tight, &f) {
assert!(
got.binary_search(&k).is_ok(),
"seed {seed}: key {k} intersects the frustum but the index dropped it"
);
}
}
}
}
#[test]
fn random_ops_then_query_matches_brute_force() {
for seed in 0..16u64 {
let mut rng = Lcg::new(seed);
let margin = [0.0f32, 0.25, 2.0][seed as usize % 3];
let mut t = RenderAabbTree::with_fat_margin(margin);
let mut mirror: Vec<Option<Aabb>> = vec![None; 120];
for step in 0..400 {
let k = rng.usize_below(mirror.len()) as u32;
match rng.usize_below(10) {
0..=5 => {
let a = boxx(
rng.f32_in(-200.0, 200.0),
rng.f32_in(-30.0, 30.0),
rng.f32_in(-200.0, 200.0),
rng.f32_in(0.2, 6.0),
);
t.insert(k, a);
mirror[k as usize] = Some(a);
}
6..=7 => {
if let Some(a) = mirror[k as usize] {
let d = Vec3::new(
rng.f32_in(-0.3, 0.3),
rng.f32_in(-0.3, 0.3),
rng.f32_in(-0.3, 0.3),
);
let moved = Aabb::new(a.min + gizmo_math::Vec3A::from(d), a.max + gizmo_math::Vec3A::from(d));
t.insert(k, moved);
mirror[k as usize] = Some(moved);
}
}
8 => {
t.remove(k);
mirror[k as usize] = None;
}
_ => {
let cut = rng.next_u32() % 4;
t.retain(|key| key % 4 != cut);
for (i, slot) in mirror.iter_mut().enumerate() {
if (i as u32) % 4 == cut {
*slot = None;
}
}
}
}
#[cfg(debug_assertions)]
t.validate();
let live: Vec<(u32, Aabb)> = mirror
.iter()
.enumerate()
.filter_map(|(i, a)| a.map(|a| (i as u32, a)))
.collect();
assert_eq!(t.len(), live.len(), "seed {seed} step {step}: key count drifted");
let f = random_frustum(&mut rng);
let mut got = vec![];
t.query_frustum(&f, &mut got);
let mut seen = std::collections::HashSet::new();
for &k in &got {
assert!(seen.insert(k), "seed {seed} step {step}: key {k} returned twice");
assert!(
mirror[k as usize].is_some(),
"seed {seed} step {step}: dead key {k} returned"
);
}
let got = sorted(got);
for k in brute_force(&live, &f) {
assert!(
got.binary_search(&k).is_ok(),
"seed {seed} step {step}: visible key {k} missing from the query"
);
}
}
}
}
#[test]
fn fat_margin_zero_makes_the_query_exact() {
for seed in 0..16u64 {
let (t, tight) = random_scene(seed, 250, 0.0);
assert_eq!(t.fat_margin(), 0.0);
let mut rng = Lcg::new(seed ^ 0xfeed);
for _ in 0..8 {
let f = random_frustum(&mut rng);
let mut got = vec![];
t.query_frustum(&f, &mut got);
assert_eq!(
sorted(got),
brute_force(&tight, &f),
"seed {seed}: with no margin the index must be exact"
);
}
}
}
#[test]
fn a_frustum_containing_everything_returns_every_key_exactly_once() {
let mut t = RenderAabbTree::new();
for i in 0..500u32 {
t.insert(i, boxx(i as f32 * 0.1 - 25.0, 0.0, -30.0, 0.2));
}
let f = cam(Vec3::new(0.0, 0.0, 400.0), Vec3::new(0.0, 0.0, -30.0), 5000.0);
let mut out = vec![];
t.query_frustum(&f, &mut out);
assert_eq!(out.len(), 500, "expected every key exactly once");
assert_eq!(sorted(out), (0..500u32).collect::<Vec<_>>());
}
#[test]
fn degenerate_boxes_are_still_never_missed() {
let eye = Vec3::new(0.0, 0.0, 50.0);
let f = cam(eye, Vec3::ZERO, 200.0);
let mut t = RenderAabbTree::with_fat_margin(0.0);
t.insert(0, boxx(0.0, 0.0, 0.0, 5000.0));
t.insert(1, Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)));
t.insert(2, Aabb::new(Vec3::new(-100.0, 0.0, -100.0), Vec3::new(100.0, 0.0, 100.0)));
t.insert(3, boxx(0.0, 0.0, 900.0, 1.0));
let mut out = vec![];
t.query_frustum(&f, &mut out);
let got = sorted(out);
for k in [0u32, 1, 2] {
assert!(got.contains(&k), "key {k} must survive the cull");
}
let tight = vec![
(0u32, boxx(0.0, 0.0, 0.0, 5000.0)),
(1, Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))),
(2, Aabb::new(Vec3::new(-100.0, 0.0, -100.0), Vec3::new(100.0, 0.0, 100.0))),
(3, boxx(0.0, 0.0, 900.0, 1.0)),
];
assert_eq!(got, brute_force(&tight, &f));
}
#[test]
fn an_empty_aabb_is_refused_in_both_profiles() {
let mut t = RenderAabbTree::new();
t.insert(0, boxx(0.0, 0.0, 0.0, 1.0));
assert!(!t.insert(1, Aabb::empty()), "an empty box must be refused");
assert!(!t.contains(1));
assert_eq!(t.len(), 1);
assert!(!t.insert(2, Aabb::new(Vec3::new(5.0, -1.0, -1.0), Vec3::new(-5.0, 1.0, 1.0))));
assert!(!t.contains(2));
assert_eq!(t.len(), 1);
assert!(t.insert(1, boxx(10.0, 0.0, 0.0, 1.0)), "the key is still free afterwards");
assert!(t.contains(1));
#[cfg(debug_assertions)]
t.validate();
}
#[test]
fn query_frusta_is_a_sorted_deduplicated_union() {
let mut t = RenderAabbTree::new();
for i in 0..60u32 {
t.insert(i, boxx(i as f32 * 2.0 - 60.0, 0.0, -40.0, 1.0));
}
let a = cam(Vec3::new(0.0, 0.0, 40.0), Vec3::new(0.0, 0.0, -40.0), 500.0);
let b = cam(Vec3::new(0.0, 0.0, 41.0), Vec3::new(0.0, 0.0, -40.0), 500.0);
let c = cam(Vec3::new(900.0, 0.0, 0.0), Vec3::new(1000.0, 0.0, 0.0), 100.0);
let mut single = vec![];
t.query_frustum(&a, &mut single);
let single = sorted(single);
assert!(!single.is_empty(), "premise: frustum a sees something");
let mut union = vec![];
t.query_frusta(&[a, b, c], &mut union);
assert!(union.windows(2).all(|w| w[0] < w[1]), "must be sorted and deduplicated");
for k in single {
assert!(union.binary_search(&k).is_ok());
}
let mut reused = vec![7, 7, 7];
t.query_frusta(&[c], &mut reused);
assert!(reused.is_empty(), "query_frusta must clear");
let mut appended = vec![u32::MAX - 1];
t.query_frustum(&c, &mut appended);
assert_eq!(appended, vec![u32::MAX - 1], "query_frustum must append");
}
#[test]
fn a_negative_fat_margin_is_clamped_to_zero() {
let t = RenderAabbTree::with_fat_margin(-5.0);
assert_eq!(t.fat_margin(), 0.0);
}
#[test]
fn clear_empties_but_keeps_the_margin_and_stays_usable() {
let mut t = RenderAabbTree::with_fat_margin(3.0);
for i in 0..50u32 {
t.insert(i, boxx(i as f32, 0.0, 0.0, 1.0));
}
t.clear();
assert!(t.is_empty());
assert_eq!(t.fat_margin(), 3.0);
assert!(!t.contains(10));
assert_eq!(t.height(), 0);
assert!(t.insert(10, boxx(0.0, 0.0, 0.0, 1.0)));
assert_eq!(t.len(), 1);
#[cfg(debug_assertions)]
t.validate();
}
#[test]
fn the_tree_stays_balanced_enough_to_be_sublinear() {
let (t, _) = random_scene(7, 4096, 1.0);
let ideal = 12; assert!(
t.height() <= (ideal * 3) as u32,
"height {} for 4096 leaves — the SAH/AVL insert has degenerated",
t.height()
);
}
#[test]
fn an_indexed_box_is_the_transformed_one() {
let local = Aabb::new(Vec3::new(-1.0, -2.0, -3.0), Vec3::new(1.0, 2.0, 3.0));
let model = Mat4::from_scale_rotation_translation(
Vec3::new(2.0, 2.0, 2.0),
Quat::from_rotation_y(std::f32::consts::FRAC_PI_2),
Vec3::new(100.0, 5.0, -20.0),
);
let world = local.transform(&model);
let mut t = RenderAabbTree::with_fat_margin(0.0);
t.insert(1, world);
assert_eq!(t.leaf_aabb(1).unwrap(), world);
let mut out = vec![];
t.query_aabb(&boxx(100.0, 5.0, -20.0, 0.1), &mut out);
assert_eq!(out, vec![1], "the box must be where the transform put it");
}
trait Translated {
fn translated(self, dx: f32) -> Aabb;
}
impl Translated for Aabb {
fn translated(self, dx: f32) -> Aabb {
let d = gizmo_math::Vec3A::new(dx, 0.0, 0.0);
Aabb::new(self.min + d, self.max + d)
}
}