use super::RenderAabbTree;
use crate::components::MaterialType;
use crate::frustum_cull::{classify_visibility, classify_visibility_world, Visibility};
use gizmo_math::{Aabb, Frustum, Mat4, Quat, Vec3};
#[derive(Clone, Copy)]
struct Item {
local: Aabb,
model: Mat4,
material: MaterialType,
transparent: bool,
alpha: f32,
}
impl Item {
fn world(&self) -> Aabb {
self.local.transform(&self.model)
}
fn indexable(&self) -> bool {
!crate::backdrop::is_camera_locked(self.material)
}
}
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed ^ 0xda3e_39cb_94b9_5bdb)
}
fn next_u32(&mut self) -> u32 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(self.0 >> 33) as u32
}
fn f(&mut self, lo: f32, hi: f32) -> f32 {
lo + (self.next_u32() as f32 / u32::MAX as f32) * (hi - lo)
}
fn below(&mut self, n: u32) -> u32 {
self.next_u32() % n
}
}
fn frustum_at(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))
}
fn linear_draw_sets(items: &[Item], cam: &Frustum, cascades: &[Frustum]) -> (Vec<u32>, Vec<u32>) {
let mut camera = vec![];
let mut shadow = vec![];
for (i, it) in items.iter().enumerate() {
match classify_visibility(
cam,
cascades,
&it.model,
it.local,
it.material,
it.transparent,
it.alpha,
) {
Visibility::Culled => {}
Visibility::Camera => camera.push(i as u32),
Visibility::ShadowOnly => shadow.push(i as u32),
}
}
(camera, shadow)
}
fn indexed_draw_sets(
items: &[Item],
tree: &RenderAabbTree,
cam: &Frustum,
cascades: &[Frustum],
) -> (Vec<u32>, Vec<u32>) {
let mut frusta = Vec::with_capacity(1 + cascades.len());
frusta.push(*cam);
frusta.extend_from_slice(cascades);
let mut candidates = vec![];
tree.query_frusta(&frusta, &mut candidates);
let mut camera = vec![];
let mut shadow = vec![];
for (i, it) in items.iter().enumerate() {
let id = i as u32;
if tree.contains(id) && candidates.binary_search(&id).is_err() {
continue;
}
match classify_visibility_world(
cam,
cascades,
it.world(),
it.material,
it.transparent,
it.alpha,
) {
Visibility::Culled => {}
Visibility::Camera => camera.push(id),
Visibility::ShadowOnly => shadow.push(id),
}
}
(camera, shadow)
}
fn build_index(items: &[Item]) -> RenderAabbTree {
let mut t = RenderAabbTree::new();
for (i, it) in items.iter().enumerate() {
if it.indexable() {
t.insert(i as u32, it.world());
}
}
t
}
fn assert_sets_match(items: &[Item], tree: &RenderAabbTree, cam: &Frustum, cascades: &[Frustum], ctx: &str) {
let (lin_cam, lin_shadow) = linear_draw_sets(items, cam, cascades);
let (idx_cam, idx_shadow) = indexed_draw_sets(items, tree, cam, cascades);
assert_eq!(lin_cam, idx_cam, "{ctx}: camera-visible set differs");
assert_eq!(lin_shadow, idx_shadow, "{ctx}: shadow-only set differs");
for (ci, casc) in cascades.iter().enumerate() {
let one = [*casc];
let (lc, ls) = linear_draw_sets(items, cam, &one);
let (ic, is) = indexed_draw_sets(items, tree, cam, &one);
assert_eq!(lc, ic, "{ctx}: cascade {ci} camera set differs");
assert_eq!(ls, is, "{ctx}: cascade {ci} shadow set differs");
}
}
fn real_cascades(cam_pos: Vec3, forward: Vec3) -> Vec<Frustum> {
crate::csm::directional_cascade_view_projs(
cam_pos,
forward,
16.0 / 9.0,
std::f32::consts::FRAC_PI_3,
0.1,
&[30.0, 90.0, 250.0, 500.0],
Vec3::new(-0.4, -1.0, -0.3),
2048,
)
.iter()
.map(Frustum::from_matrix)
.collect()
}
fn random_items(rng: &mut Lcg, n: usize) -> Vec<Item> {
(0..n)
.map(|_| {
let h = Vec3::new(rng.f(0.2, 8.0), rng.f(0.2, 6.0), rng.f(0.2, 8.0));
Item {
local: Aabb::new(-h, h),
model: Mat4::from_scale_rotation_translation(
Vec3::splat(rng.f(0.5, 2.5)),
Quat::from_rotation_y(rng.f(0.0, std::f32::consts::TAU)),
Vec3::new(rng.f(-400.0, 400.0), rng.f(-20.0, 60.0), rng.f(-400.0, 400.0)),
),
material: match rng.below(8) {
0 => MaterialType::Unlit,
1 => MaterialType::Backdrop,
2 => MaterialType::Skybox,
3 => MaterialType::Grid,
4 => MaterialType::Water,
5 => MaterialType::BakedLit,
_ => MaterialType::Pbr,
},
transparent: rng.below(6) == 0,
alpha: if rng.below(5) == 0 { rng.f(0.1, 0.98) } else { 1.0 },
}
})
.collect()
}
#[test]
fn the_indexed_draw_set_equals_the_linear_draw_set() {
for seed in 0..20u64 {
let mut rng = Lcg::new(seed);
let items = random_items(&mut rng, 400);
let tree = build_index(&items);
for shot in 0..6 {
let eye = Vec3::new(rng.f(-450.0, 450.0), rng.f(-30.0, 70.0), rng.f(-450.0, 450.0));
let dir = Vec3::new(rng.f(-1.0, 1.0), rng.f(-0.6, 0.6), rng.f(-1.0, 1.0));
let dir = if dir.length_squared() < 1e-4 { Vec3::NEG_Z } else { dir.normalize() };
let cam = frustum_at(eye, eye + dir, rng.f(50.0, 600.0));
let cascades = real_cascades(eye, dir);
assert_sets_match(&items, &tree, &cam, &cascades, &format!("seed {seed} shot {shot}"));
}
}
}
#[test]
fn an_empty_scene_agrees() {
let items: Vec<Item> = vec![];
let tree = build_index(&items);
let cam = frustum_at(Vec3::ZERO, Vec3::NEG_Z, 100.0);
let cascades = real_cascades(Vec3::ZERO, Vec3::NEG_Z);
assert_sets_match(&items, &tree, &cam, &cascades, "empty scene");
}
#[test]
fn degenerate_placements_agree() {
let eye = Vec3::new(0.0, 0.0, 50.0);
let cam = frustum_at(eye, Vec3::ZERO, 200.0);
let cascades = real_cascades(eye, Vec3::NEG_Z);
let unit = |h: f32| Aabb::new(Vec3::splat(-h), Vec3::splat(h));
let mut items = vec![
Item { local: unit(5000.0), model: Mat4::IDENTITY, material: MaterialType::Pbr, transparent: false, alpha: 1.0 },
Item { local: Aabb::new(Vec3::ZERO, Vec3::ZERO), model: Mat4::from_translation(Vec3::new(0.0, 0.0, -10.0)), material: MaterialType::Pbr, transparent: false, alpha: 1.0 },
Item { local: Aabb::new(Vec3::new(-200.0, 0.0, -200.0), Vec3::new(200.0, 0.0, 200.0)), model: Mat4::IDENTITY, material: MaterialType::BakedLit, transparent: false, alpha: 1.0 },
Item { local: unit(2.0), model: Mat4::from_translation(Vec3::new(0.0, 0.0, 900.0)), material: MaterialType::Pbr, transparent: false, alpha: 1.0 },
];
items.push(Item {
local: unit(1.0),
model: Mat4::from_translation(Vec3::new(0.0, 0.0, eye.z - 200.0 + 1.0)),
material: MaterialType::Pbr,
transparent: false,
alpha: 1.0,
});
items.push(Item {
local: unit(1.0),
model: Mat4::from_translation(Vec3::new(0.0, 0.0, eye.z - 0.1 - 1.0)),
material: MaterialType::Pbr,
transparent: false,
alpha: 1.0,
});
let tree = build_index(&items);
assert_sets_match(&items, &tree, &cam, &cascades, "degenerate placements");
}
#[test]
fn an_entity_that_moved_since_the_index_was_built() {
let mut rng = Lcg::new(99);
let mut items = random_items(&mut rng, 250);
for it in items.iter_mut().take(30) {
it.material = MaterialType::Pbr;
it.transparent = false;
it.alpha = 1.0;
}
let mut tree = build_index(&items);
let eye = Vec3::new(0.0, 10.0, 200.0);
let dir = Vec3::NEG_Z;
let cam = frustum_at(eye, eye + dir, 500.0);
let cascades = real_cascades(eye, dir);
assert_sets_match(&items, &tree, &cam, &cascades, "before any movement");
let margin = tree.fat_margin();
let nudge = Mat4::from_translation(Vec3::new(margin * 0.4, 0.0, margin * 0.3));
for it in items.iter_mut().take(30) {
it.model = nudge * it.model;
}
assert_sets_match(&items, &tree, &cam, &cascades, "sub-margin drift, index not refreshed");
let jump = Mat4::from_translation(Vec3::new(-150.0, 0.0, -180.0));
for it in items.iter_mut().take(30) {
it.model = jump * it.model;
}
for (i, it) in items.iter().enumerate().take(30) {
tree.insert(i as u32, it.world());
}
assert_sets_match(&items, &tree, &cam, &cascades, "large move, index refreshed");
let hidden = items.len();
items.push(Item {
local: Aabb::new(Vec3::splat(-3.0), Vec3::splat(3.0)),
model: Mat4::from_translation(Vec3::new(9_000.0, 0.0, 9_000.0)),
material: MaterialType::Pbr,
transparent: false,
alpha: 1.0,
});
tree.insert(hidden as u32, items[hidden].world());
assert_sets_match(&items, &tree, &cam, &cascades, "far-away newcomer, indexed");
items[hidden].model = Mat4::from_translation(Vec3::new(0.0, 10.0, 150.0));
let (lin_cam, _) = linear_draw_sets(&items, &cam, &cascades);
let (idx_cam, _) = indexed_draw_sets(&items, &tree, &cam, &cascades);
assert!(
lin_cam.contains(&(hidden as u32)),
"premise: the moved object really is on screen now"
);
assert!(
!idx_cam.contains(&(hidden as u32)),
"premise of the maintenance contract: an object that moves into view without being \
re-inserted really does vanish. If this ever starts passing, staleness stopped being \
reachable and every other test here is proving less than it claims"
);
assert_ne!(lin_cam, idx_cam);
tree.insert(hidden as u32, items[hidden].world());
assert_sets_match(&items, &tree, &cam, &cascades, "after the refresh that was missing");
}
#[test]
fn a_renderable_the_index_never_received_is_drawn_not_skipped() {
let mut rng = Lcg::new(4242);
let mut items = random_items(&mut rng, 80);
let mut tree = build_index(&items);
let eye = Vec3::new(0.0, 5.0, 60.0);
let cam = frustum_at(eye, Vec3::new(0.0, 0.0, -40.0), 400.0);
let cascades = real_cascades(eye, Vec3::NEG_Z);
let spawned = items.len();
items.push(Item {
local: Aabb::new(Vec3::splat(-2.0), Vec3::splat(2.0)),
model: Mat4::from_translation(Vec3::new(0.0, 0.0, -20.0)),
material: MaterialType::Pbr,
transparent: false,
alpha: 1.0,
});
assert!(!tree.contains(spawned as u32), "premise: not indexed yet");
let vertexless = items.len();
items.push(Item {
local: Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0)),
model: Mat4::from_translation(Vec3::new(3.0, 0.0, -25.0)),
material: MaterialType::Pbr,
transparent: false,
alpha: 1.0,
});
assert!(
!tree.insert(vertexless as u32, Aabb::empty()),
"an empty box must be refused, and refusal is indistinguishable from the no-op early-out"
);
assert!(!tree.contains(vertexless as u32));
let (lin_cam, _) = linear_draw_sets(&items, &cam, &cascades);
assert!(
lin_cam.contains(&(spawned as u32)) && lin_cam.contains(&(vertexless as u32)),
"premise: both un-indexed newcomers really are camera-visible"
);
assert_sets_match(&items, &tree, &cam, &cascades, "un-indexed renderables");
tree.insert(
spawned as u32,
Aabb::new(Vec3::splat(9_000.0), Vec3::splat(9_010.0)),
);
let (idx_cam, _) = indexed_draw_sets(&items, &tree, &cam, &cascades);
assert!(
lin_cam.contains(&(spawned as u32)),
"premise: the linear path still draws it"
);
assert!(
!idx_cam.contains(&(spawned as u32)),
"control: once the index HOLDS a (wrong) box for the key, the guard does skip it"
);
}
#[test]
fn a_reused_key_gets_the_new_box_not_the_dead_ones() {
let mut rng = Lcg::new(7);
let mut items = random_items(&mut rng, 120);
items[50] = Item {
local: Aabb::new(Vec3::splat(-2.0), Vec3::splat(2.0)),
model: Mat4::from_translation(Vec3::new(0.0, 0.0, -30.0)),
material: MaterialType::Pbr,
transparent: false,
alpha: 1.0,
};
let mut tree = build_index(&items);
tree.remove(50);
items[50] = Item {
local: Aabb::new(Vec3::splat(-2.0), Vec3::splat(2.0)),
model: Mat4::from_translation(Vec3::new(500.0, 0.0, -30.0)),
material: MaterialType::Pbr,
transparent: false,
alpha: 1.0,
};
tree.insert(50, items[50].world());
assert_eq!(tree.leaf_aabb(50).unwrap().center().x.round(), 500.0);
let eye = Vec3::new(0.0, 0.0, 40.0);
let cam = frustum_at(eye, Vec3::new(0.0, 0.0, -30.0), 300.0);
let cascades = real_cascades(eye, Vec3::NEG_Z);
assert_sets_match(&items, &tree, &cam, &cascades, "reused key");
}
#[test]
fn a_camera_locked_backdrop_is_never_indexed_and_never_skipped() {
let authored = Mat4::from_translation(Vec3::new(0.0, 0.0, -10.0));
let items = vec![
Item { local: Aabb::new(Vec3::splat(-2.0), Vec3::splat(2.0)), model: authored, material: MaterialType::Backdrop, transparent: false, alpha: 1.0 },
Item { local: Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0)), model: Mat4::from_translation(Vec3::new(0.0, 0.0, -20.0)), material: MaterialType::Pbr, transparent: false, alpha: 1.0 },
];
let tree = build_index(&items);
assert!(!tree.contains(0), "a camera-locked backdrop must not be in the index");
assert!(tree.contains(1));
let eye = Vec3::new(0.0, 0.0, 905.0);
let cam = frustum_at(eye, Vec3::new(0.0, 0.0, -30.0), 2000.0);
let cascades = real_cascades(eye, Vec3::NEG_Z);
let (_, _) = linear_draw_sets(&items, &cam, &cascades);
let (idx_cam, idx_shadow) = indexed_draw_sets(&items, &tree, &cam, &cascades);
assert_sets_match(&items, &tree, &cam, &cascades, "camera-locked backdrop");
let _ = (idx_cam, idx_shadow);
}
#[test]
fn all_visible_and_none_visible_both_agree() {
let mut rng = Lcg::new(3);
let items: Vec<Item> = (0..300)
.map(|_| Item {
local: Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0)),
model: Mat4::from_translation(Vec3::new(rng.f(-20.0, 20.0), rng.f(-10.0, 10.0), rng.f(-60.0, -40.0))),
material: MaterialType::Pbr,
transparent: false,
alpha: 1.0,
})
.collect();
let tree = build_index(&items);
let eye = Vec3::new(0.0, 0.0, 300.0);
let cam_all = frustum_at(eye, Vec3::new(0.0, 0.0, -50.0), 2000.0);
let cascades = real_cascades(eye, Vec3::NEG_Z);
let (lin, _) = linear_draw_sets(&items, &cam_all, &cascades);
assert_eq!(lin.len(), 300, "premise: this camera sees the whole cluster");
assert_sets_match(&items, &tree, &cam_all, &cascades, "everything visible");
let far_eye = Vec3::new(50_000.0, 0.0, 0.0);
let cam_none = frustum_at(far_eye, far_eye + Vec3::X, 100.0);
let far_cascades = real_cascades(far_eye, Vec3::X);
let (lin_none, sh_none) = linear_draw_sets(&items, &cam_none, &far_cascades);
assert!(lin_none.is_empty() && sh_none.is_empty(), "premise: nothing is visible");
assert_sets_match(&items, &tree, &cam_none, &far_cascades, "nothing visible");
}