Files
wsg/lib/src/core/lod.rs
T
Jérôme Bousquié ab3f056dbb primitive meshes
2026-09-24 14:25:44 +02:00

249 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # 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);
}
}