249 lines
10 KiB
Rust
249 lines
10 KiB
Rust
//! # LOD — Per-frame Level Selection (pure, testable without a GPU)
|
||
//!
|
||
//! The pure functions behind the LOD feature (Step 19, D1/D4/D8). Each frame the **CPU**
|
||
//! decides which detail level every entity draws; these functions do that math:
|
||
//!
|
||
//! - [`projected_radius_px`]: the entity's *perceived size* — its bounding-sphere radius in
|
||
//! screen pixels (the **same sphere** the GPU frustum culling uses, D8);
|
||
//! - [`lod_level`]: the level decision with **asymmetric hysteresis** (D4) — the core
|
||
//! anti-flicker mechanism.
|
||
//!
|
||
//! Both are pure (no GPU, no state beyond the caller-supplied `last` level) → unit-testable.
|
||
//! The Renderer calls them per slot each frame and uploads the resulting levels to the GPU,
|
||
//! which only maps level → draw args (the packed-buffer offsets live in the per-mesh LOD
|
||
//! table — see `resources::uniform::LodTable`).
|
||
|
||
use glam::{Mat4, Vec3, Vec4};
|
||
|
||
/// Projected radius (in **pixels**) of a bounding sphere, given the camera's view/projection.
|
||
///
|
||
/// The sphere center (world space) is transformed into view space; a sphere at depth `d` with
|
||
/// radius `r` subtends `r / d` in view space, which the projection's vertical scale
|
||
/// (`proj.y.y = 1 / tan(fov / 2)`) maps to NDC — multiplied by `height_px / 2` (half the
|
||
/// viewport height in pixels) gives pixels.
|
||
///
|
||
/// A sphere whose center is inside/behind the near plane (`depth <= 1e-4`) returns
|
||
/// `f32::INFINITY` — the entity dominates the screen, so the finest level (0) is chosen.
|
||
pub fn projected_radius_px(
|
||
center_world: Vec3,
|
||
radius: f32,
|
||
view: Mat4,
|
||
proj: Mat4,
|
||
height_px: f32,
|
||
) -> f32 {
|
||
let v = view * Vec4::new(center_world.x, center_world.y, center_world.z, 1.0);
|
||
let depth = -v.z; // view space: the camera looks along -Z (glam `look_at_mat4`)
|
||
if depth <= 1e-4 {
|
||
return f32::INFINITY;
|
||
}
|
||
(radius / depth) * proj.y_axis.y * (height_px * 0.5)
|
||
}
|
||
|
||
/// Level decision with **asymmetric hysteresis** (Step 19, D4).
|
||
///
|
||
/// `thresholds` is a **descending** pixel radius: `thresholds[k]` is the radius *above which*
|
||
/// level k+1 is required (i.e. level k is sufficient up to that bound; level 0 has no bound).
|
||
/// Levels beyond the threshold count share the last bound (clamped) — e.g. with `[48, 12]`
|
||
/// only the first three levels are distinct.
|
||
///
|
||
/// Hysteresis (dead band):
|
||
/// - to a **finer** level: immediate, as soon as `radius_px` exceeds the current level's bound;
|
||
/// - to a **coarser** level: only if `radius_px <= bound(k) * 0.8` (20 % dead band), stepped
|
||
/// incrementally (each intermediate bound × 0.8 must hold).
|
||
///
|
||
/// The "detail loss" pop (going coarser) is therefore delayed; the "detail regain" pop (going
|
||
/// finer) is immediate — standard engine practice. `f32::INFINITY` (object at the camera)
|
||
/// always returns 0. The result is always within `0..=max_level`.
|
||
pub fn lod_level(radius_px: f32, last: u32, max_level: u32, thresholds: &[f32]) -> u32 {
|
||
if radius_px.is_infinite() || max_level == 0 || thresholds.is_empty() {
|
||
return 0;
|
||
}
|
||
// Bound for level k+1: the k-th threshold, clamped for levels beyond the threshold count.
|
||
let bound = |k: u32| thresholds[(k as usize).min(thresholds.len() - 1)];
|
||
let last = (last as usize).min(max_level as usize) as u32;
|
||
|
||
// Target without hysteresis: the coarsest level whose bound is still satisfied.
|
||
let mut target = 0u32;
|
||
let mut k = 0u32;
|
||
while k < max_level {
|
||
if radius_px <= bound(k) {
|
||
target = k + 1;
|
||
k += 1;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if target <= last {
|
||
// Finer or equal: immediate (no dead band on the way to more detail).
|
||
target
|
||
} else {
|
||
// Coarser: 20 % dead band per step, incremental.
|
||
let mut lvl = last;
|
||
while lvl < target {
|
||
if radius_px <= bound(lvl) * 0.8 {
|
||
lvl += 1;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
lvl
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use glam::Mat4;
|
||
use glam::Vec3;
|
||
|
||
/// A camera at `(0, 0, dist)` looking at the origin, up `+Y`, with vertical `fov`.
|
||
fn camera(dist: f32, fov: f32) -> (Mat4, Mat4) {
|
||
let view =
|
||
glam::camera::rh::view::look_at_mat4(Vec3::new(0.0, 0.0, dist), Vec3::ZERO, Vec3::Y);
|
||
let proj = glam::camera::rh::proj::opengl::perspective(fov, 1.0, 0.1, 100.0);
|
||
(view, proj)
|
||
}
|
||
|
||
// ========================================================================
|
||
// projected_radius_px
|
||
// ========================================================================
|
||
|
||
#[test]
|
||
fn projected_radius_analytic() {
|
||
// Sphere of radius 1 at the origin; camera 5 units away; fov = 90°
|
||
// (proj vertical scale = 1/tan(45°) = 1); viewport 1000 px tall.
|
||
// Expected: (1 / 5) * 1 * 500 = 100 px.
|
||
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
|
||
let r = projected_radius_px(Vec3::ZERO, 1.0, view, proj, 1000.0);
|
||
assert!((r - 100.0).abs() < 1e-3, "expected 100 px, got {r}");
|
||
}
|
||
|
||
#[test]
|
||
fn projected_radius_scale_invariance() {
|
||
// 10x bigger object 10x further away → same projected radius (similarity).
|
||
let (view1, proj1) = camera(5.0, std::f32::consts::PI / 2.0);
|
||
let (view2, proj2) = camera(50.0, std::f32::consts::PI / 2.0);
|
||
let r1 = projected_radius_px(Vec3::ZERO, 1.0, view1, proj1, 1000.0);
|
||
let r2 = projected_radius_px(Vec3::ZERO, 10.0, view2, proj2, 1000.0);
|
||
assert!((r1 - r2).abs() < 1e-2, "expected equal, got {r1} vs {r2}");
|
||
}
|
||
|
||
#[test]
|
||
fn projected_radius_at_camera_is_infinite() {
|
||
// Center at the camera position → depth 0 → INFINITY (finest level).
|
||
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
|
||
let r = projected_radius_px(Vec3::new(0.0, 0.0, 5.0), 1.0, view, proj, 1000.0);
|
||
assert!(r.is_infinite());
|
||
}
|
||
|
||
#[test]
|
||
fn projected_radius_behind_camera_is_infinite() {
|
||
// Center behind the camera → negative depth → INFINITY.
|
||
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
|
||
let r = projected_radius_px(Vec3::new(0.0, 0.0, 20.0), 1.0, view, proj, 1000.0);
|
||
assert!(r.is_infinite());
|
||
}
|
||
|
||
#[test]
|
||
fn projected_radius_narrower_fov_larger_pixels() {
|
||
// Narrower FOV (zoomed in) → LARGER vertical projection scale (1/tan(fov/2)) →
|
||
// more pixels for the same sphere at the same distance.
|
||
let fov_narrow = std::f32::consts::PI / 3.0; // 60°
|
||
let fov_wide = std::f32::consts::PI / 2.0; // 90°
|
||
let (v1, p1) = camera(5.0, fov_narrow);
|
||
let (v2, p2) = camera(5.0, fov_wide);
|
||
let r1 = projected_radius_px(Vec3::ZERO, 1.0, v1, p1, 1000.0);
|
||
let r2 = projected_radius_px(Vec3::ZERO, 1.0, v2, p2, 1000.0);
|
||
assert!(
|
||
r1 > r2,
|
||
"narrower FOV should give more pixels: {r1} vs {r2}"
|
||
);
|
||
}
|
||
|
||
// ========================================================================
|
||
// lod_level
|
||
// ========================================================================
|
||
|
||
#[test]
|
||
fn lod_level_simple_thresholds() {
|
||
let t = [48.0f32, 12.0];
|
||
// r > 48 → level 0 (too big for any coarser level).
|
||
assert_eq!(lod_level(100.0, 0, 2, &t), 0);
|
||
assert_eq!(lod_level(52.0, 0, 2, &t), 0);
|
||
// 48 >= r > 38.4 (0.8·48): target is L1, but the dead band holds it at L0.
|
||
assert_eq!(lod_level(44.0, 0, 2, &t), 0);
|
||
// r <= 38.4 → L1.
|
||
assert_eq!(lod_level(38.4, 0, 2, &t), 1);
|
||
assert_eq!(lod_level(30.0, 0, 2, &t), 1);
|
||
// 12 > r > 9.6 (0.8·12): target L2, dead band holds at L1.
|
||
assert_eq!(lod_level(10.0, 0, 2, &t), 1);
|
||
// r <= 9.6 → L2 (both steps pass the band).
|
||
assert_eq!(lod_level(9.6, 0, 2, &t), 2);
|
||
assert_eq!(lod_level(9.0, 0, 2, &t), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn lod_level_finer_is_immediate() {
|
||
let t = [48.0f32, 12.0];
|
||
// Already coarse (L2); radius grows past 48 → immediately back to L0.
|
||
assert_eq!(lod_level(100.0, 2, 2, &t), 0);
|
||
// L2, radius between the bounds → immediately to L1.
|
||
assert_eq!(lod_level(30.0, 2, 2, &t), 1);
|
||
// L1, radius past 48 → immediately to L0.
|
||
assert_eq!(lod_level(52.0, 1, 2, &t), 0);
|
||
// L1, radius below 12 → target L2 but dead band (10 > 9.6) holds at L1.
|
||
assert_eq!(lod_level(10.0, 1, 2, &t), 1);
|
||
// L1, radius below 9.6 → L2.
|
||
assert_eq!(lod_level(9.0, 1, 2, &t), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn lod_level_oscillation_is_stable() {
|
||
// Anti-flicker (D4): a radius oscillating ±10 % around threshold 48 (43.2..52.8)
|
||
// must not make the level flip back and forth.
|
||
let t = [48.0f32];
|
||
let mut level = 0u32;
|
||
for _ in 0..100 {
|
||
for r in [43.2f32, 52.8, 43.2, 52.8] {
|
||
level = lod_level(r, level, 2, &t);
|
||
}
|
||
}
|
||
// Whatever level it settled on, it must not have changed on the last pass.
|
||
let before = level;
|
||
for r in [43.2f32, 52.8, 43.2, 52.8] {
|
||
level = lod_level(r, level, 2, &t);
|
||
}
|
||
assert_eq!(before, level, "level flickered around the threshold");
|
||
// From L0 the oscillation never leaves L0 (coarser needs r ≤ 38.4).
|
||
assert_eq!(lod_level(43.2, 0, 2, &t), 0);
|
||
assert_eq!(lod_level(52.8, 0, 2, &t), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn lod_level_clamped_thresholds_for_extra_levels() {
|
||
// 4 levels but only 2 thresholds: levels 2 and 3 share the last bound (12).
|
||
let t = [48.0f32, 12.0];
|
||
// r = 9 passes both bands (38.4, 9.6) AND the clamped third bound (0.8·12) → L3.
|
||
assert_eq!(lod_level(9.0, 0, 3, &t), 3);
|
||
// r = 10 passes the first two targets but the clamped band holds at L2.
|
||
assert_eq!(lod_level(10.0, 0, 3, &t), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn lod_level_infinite_returns_zero() {
|
||
let t = [48.0f32, 12.0];
|
||
assert_eq!(lod_level(f32::INFINITY, 2, 2, &t), 0);
|
||
assert_eq!(lod_level(f32::INFINITY, 0, 2, &t), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn lod_level_degenerate_inputs() {
|
||
let t = [48.0f32];
|
||
assert_eq!(lod_level(1.0, 5, 0, &t), 0); // max_level 0
|
||
assert_eq!(lod_level(1.0, 0, 2, &[]), 0); // no thresholds
|
||
// Stale `last` beyond max_level is clamped, not a panic.
|
||
assert_eq!(lod_level(100.0, 9, 2, &t), 0);
|
||
}
|
||
}
|