//! # 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); } }