Skip to main content

roxlap_scene/
lod.rs

1//! Per-grid LOD tier selection — S6.0 of `PORTING-SCENE.md` § S6.
2//!
3//! S6 introduces three discrete render tiers per grid:
4//!
5//! - [`Lod::Near`]: full voxel raycast (the existing S1..S5 path).
6//! - [`Lod::Mid`]: voxel raycast at the grid's coarser mip level.
7//!   Wires through the R4.5 multi-mip infrastructure via
8//!   [`crate::Grid::mip_levels_override`] in S6.1.
9//! - [`Lod::Far`]: pre-rendered orthographic billboard blit. Lands
10//!   in S6.2 (impostor cache) + S6.3 (blit path).
11//!
12//! S6.0 lands only the **picker infrastructure**: an enum, a
13//! threshold pair on every grid, and a `select_lod` helper. The
14//! render path computes the LOD per grid each frame but always
15//! dispatches the existing `Near` code, so a workspace at S6.0 is
16//! byte-identical to one at the end of S5 — assuming the default
17//! [`LodThresholds::always_near`] (which it is, courtesy of
18//! [`Default`]). Tests pin both the picker's tier dispatch and the
19//! framebuffer invariance.
20//!
21//! Distance metric is **world-space centre-to-centre**:
22//! `(camera_pos - grid.transform.origin).length()`. The grid's
23//! bounding sphere (radius via [`crate::Grid::bounding_radius`])
24//! is *not* subtracted from the metric — thresholds are expressed
25//! directly in world distance for predictability. The
26//! [`LodThresholds::from_radius`] convenience produces the
27//! PORTING-SCENE.md § S6 derived defaults
28//! (`r_near = R`, `r_mid = 10 * R`).
29
30use glam::DVec3;
31
32use crate::GridTransform;
33
34/// Discrete LOD tier per [PORTING-SCENE.md] § S6.
35///
36/// Picker output of [`select_lod`]; consumed by
37/// [`crate::render::render_scene_composed`] (S6.1+) to choose
38/// between full voxel, low-mip voxel, and billboard impostor.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub enum Lod {
41    /// Full voxel raycast through the cross-chunk gline path.
42    /// Default for every grid pre-S6.1.
43    Near,
44    /// Voxel raycast at the grid's coarser mip level — reuses the
45    /// R4.5 multi-mip infrastructure. Wired in S6.1.
46    Mid,
47    /// Pre-rendered orthographic billboard blit. The voxel
48    /// rasterizer is bypassed entirely. Wired in S6.3.
49    Far,
50}
51
52/// Per-grid LOD picker configuration: world-distance thresholds
53/// for tier dispatch + optional Mid-tier render overrides.
54///
55/// Tier dispatch (centre-to-centre distance `d`):
56/// - `d <= r_near` → [`Lod::Near`]
57/// - `r_near < d <= r_mid` → [`Lod::Mid`]
58/// - `d > r_mid` → [`Lod::Far`]
59///
60/// All thresholds default to [`f64::INFINITY`] via [`Default`] /
61/// [`Self::always_near`], so a freshly-constructed [`crate::Grid`]
62/// always lands on [`Lod::Near`] — the S5-and-earlier byte-stable
63/// behaviour. Callers that want real LOD opt in by writing a
64/// non-default value into [`crate::Grid::lod_thresholds`].
65///
66/// `NaN` thresholds are treated as "always [`Lod::Far`]" because
67/// every `d <= NaN` comparison is `false`. No assert — callers
68/// shouldn't be passing `NaN` and we don't want runtime cost in a
69/// per-frame per-grid hot path.
70///
71/// ## S6.1 — Mid-tier mip overrides
72///
73/// When the picker returns [`Lod::Mid`], [`Self::mid_mip_levels`]
74/// and [`Self::mid_mip_scan_dist`] (if `Some`) override the
75/// corresponding [`roxlap_core::opticast::OpticastSettings`] fields
76/// for that grid's render. The intent: force coarser-mip rendering
77/// at Mid distance to recover performance, using the existing R4.5
78/// multi-mip infrastructure with no new rasterizer code.
79///
80/// Semantics:
81/// - `mid_mip_levels = Some(n)` — clamp `OpticastSettings.mip_levels`
82///   to `n` for this grid. `n` is then further clamped to
83///   `[1, settings.mip_levels]` at the call site.
84/// - `mid_mip_scan_dist = Some(d)` — set `OpticastSettings.mip_scan_dist`
85///   to `min(settings.mip_scan_dist, d)`. The renderer floors
86///   `mip_scan_dist` at 4 internally; smaller values transition to
87///   coarser mips closer to the camera, biasing the whole frame
88///   toward higher mips.
89/// - Both `None` ⇒ Mid path renders identically to Near (graceful
90///   degrade — callers can opt into the Mid plumbing without
91///   committing to a mip override).
92/// - [`crate::Grid::mip_levels_override`] continues to apply on
93///   top as a global per-grid cap regardless of tier (the ship
94///   anti-beam workaround is preserved at all LOD tiers).
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub struct LodThresholds {
97    /// Maximum world-distance at which the grid renders at
98    /// [`Lod::Near`]. Grids closer than this are full voxel.
99    pub r_near: f64,
100    /// Maximum world-distance at which the grid renders at
101    /// [`Lod::Mid`]. Beyond `r_mid` the grid uses [`Lod::Far`].
102    /// Must satisfy `r_mid >= r_near` for monotonic tier dispatch;
103    /// not enforced (an inverted pair just means the [`Lod::Mid`]
104    /// band is empty).
105    pub r_mid: f64,
106    /// S6.1 — `OpticastSettings.mip_levels` override applied only
107    /// when the picker returns [`Lod::Mid`]. `None` ⇒ Mid uses the
108    /// caller's `settings.mip_levels` unchanged (graceful degrade
109    /// to Near-equivalent behaviour). See struct doc for semantics.
110    pub mid_mip_levels: Option<u32>,
111    /// S6.1 — `OpticastSettings.mip_scan_dist` override applied
112    /// only when the picker returns [`Lod::Mid`]. `None` ⇒ Mid uses
113    /// the caller's value unchanged. Smaller values bias the grid
114    /// toward coarser mips earlier in the ray walk (floor of 4
115    /// inside the renderer).
116    pub mid_mip_scan_dist: Option<i32>,
117}
118
119impl LodThresholds {
120    /// Always-`Near` thresholds. Both distance fields set to
121    /// [`f64::INFINITY`]; the picker can never enter the Mid/Far
122    /// branches. Mid-tier mip overrides set to `None` (irrelevant
123    /// since Mid is never selected). Use as the byte-identical
124    /// default during the S6.0..S6.3 staged rollout.
125    #[must_use]
126    pub const fn always_near() -> Self {
127        Self {
128            r_near: f64::INFINITY,
129            r_mid: f64::INFINITY,
130            mid_mip_levels: None,
131            mid_mip_scan_dist: None,
132        }
133    }
134
135    /// Derived distance thresholds from the grid's bounding-sphere
136    /// radius (PORTING-SCENE.md § S6):
137    ///
138    /// - `r_near = bounding_radius` — Near while the camera is
139    ///   inside the bounding sphere.
140    /// - `r_mid = 10 * bounding_radius` — Mid up to ~10× radius,
141    ///   Far beyond.
142    /// - `mid_mip_levels` / `mid_mip_scan_dist` ⇒ `None` (Mid
143    ///   degrades to Near; opt in via
144    ///   [`Self::from_radius_with_mid_mip`]).
145    ///
146    /// A `0.0` (or negative) bounding radius collapses both
147    /// thresholds to zero; the picker returns [`Lod::Far`] for any
148    /// non-zero distance. That's correct: an empty grid has no
149    /// near range.
150    #[must_use]
151    pub fn from_radius(bounding_radius: f64) -> Self {
152        Self {
153            r_near: bounding_radius,
154            r_mid: 10.0 * bounding_radius,
155            mid_mip_levels: None,
156            mid_mip_scan_dist: None,
157        }
158    }
159
160    /// [`Self::from_radius`] + an explicit Mid-tier mip override
161    /// pair. Convenience for S6.1 consumers that want Mid LOD wired
162    /// without hand-constructing the struct.
163    ///
164    /// Typical values for a `mip_levels = 4, mip_scan_dist = 128`
165    /// world: `mid_mip_levels = 4, mid_mip_scan_dist = 16`. The
166    /// reduced scan distance biases the Mid grid into coarser mips
167    /// across the whole frame.
168    #[must_use]
169    pub fn from_radius_with_mid_mip(
170        bounding_radius: f64,
171        mid_mip_levels: u32,
172        mid_mip_scan_dist: i32,
173    ) -> Self {
174        Self {
175            r_near: bounding_radius,
176            r_mid: 10.0 * bounding_radius,
177            mid_mip_levels: Some(mid_mip_levels),
178            mid_mip_scan_dist: Some(mid_mip_scan_dist),
179        }
180    }
181}
182
183impl Default for LodThresholds {
184    fn default() -> Self {
185        Self::always_near()
186    }
187}
188
189/// Pick the LOD tier for a grid given the world-space camera
190/// position. Distance metric is centre-to-centre Euclidean —
191/// `(camera_world_pos - transform.origin).length()`.
192///
193/// Branchless monotone three-way dispatch on the two thresholds.
194/// Called once per grid per frame; cheap.
195#[must_use]
196pub fn select_lod(
197    camera_world_pos: DVec3,
198    transform: &GridTransform,
199    thresholds: LodThresholds,
200) -> Lod {
201    let distance = (camera_world_pos - transform.origin).length();
202    if distance <= thresholds.r_near {
203        Lod::Near
204    } else if distance <= thresholds.r_mid {
205        Lod::Mid
206    } else {
207        Lod::Far
208    }
209}
210
211#[cfg(test)]
212#[allow(clippy::float_cmp)]
213mod tests {
214    use super::*;
215    use crate::GridTransform;
216
217    fn at_origin() -> GridTransform {
218        GridTransform::at(DVec3::ZERO)
219    }
220
221    #[test]
222    fn default_thresholds_are_always_near() {
223        let t = LodThresholds::default();
224        assert_eq!(t.r_near, f64::INFINITY);
225        assert_eq!(t.r_mid, f64::INFINITY);
226    }
227
228    #[test]
229    fn always_near_dispatches_near_at_any_distance() {
230        // Even at "very far" distances the default thresholds keep
231        // the picker pinned to Near. This is the byte-identity
232        // contract for the staged S6 rollout.
233        let t = LodThresholds::always_near();
234        let xform = at_origin();
235        for &d in &[0.0, 100.0, 1_000.0, 1e6, 1e15] {
236            assert_eq!(
237                select_lod(DVec3::new(d, 0.0, 0.0), &xform, t),
238                Lod::Near,
239                "expected Near at d={d}"
240            );
241        }
242    }
243
244    #[test]
245    fn from_radius_picks_near_inside_mid_band_far_outside() {
246        // bounding_radius = 100 → r_near = 100, r_mid = 1000.
247        let t = LodThresholds::from_radius(100.0);
248        let xform = at_origin();
249        let pick = |d: f64| select_lod(DVec3::new(d, 0.0, 0.0), &xform, t);
250        // Strictly inside the Near sphere.
251        assert_eq!(pick(50.0), Lod::Near);
252        // Exactly on the Near boundary — inclusive in Near.
253        assert_eq!(pick(100.0), Lod::Near);
254        // Just past Near → Mid.
255        assert_eq!(pick(100.000_001), Lod::Mid);
256        // Inside Mid band.
257        assert_eq!(pick(500.0), Lod::Mid);
258        // Exactly on Mid boundary — inclusive in Mid.
259        assert_eq!(pick(1000.0), Lod::Mid);
260        // Past Mid → Far.
261        assert_eq!(pick(1000.000_001), Lod::Far);
262        assert_eq!(pick(1e6), Lod::Far);
263    }
264
265    #[test]
266    fn distance_is_centre_to_centre_in_world_space() {
267        // Grid at world (100, 200, 300); camera at (100, 200, 350)
268        // is 50 units from the grid origin (z delta only).
269        let t = LodThresholds {
270            r_near: 49.0,
271            r_mid: 51.0,
272            ..LodThresholds::always_near()
273        };
274        let xform = GridTransform::at(DVec3::new(100.0, 200.0, 300.0));
275        let cam = DVec3::new(100.0, 200.0, 350.0);
276        // Inside the Mid band (49 < 50 < 51).
277        assert_eq!(select_lod(cam, &xform, t), Lod::Mid);
278    }
279
280    #[test]
281    fn rotation_does_not_affect_distance_metric() {
282        // The picker keys off `transform.origin` only; rotation is
283        // ignored. A non-identity rotation must give the same tier.
284        use glam::DQuat;
285        let t = LodThresholds::from_radius(10.0);
286        let cam = DVec3::new(15.0, 0.0, 0.0);
287        let xform_id = GridTransform::identity();
288        let xform_rot = GridTransform {
289            origin: DVec3::ZERO,
290            rotation: DQuat::from_rotation_z(std::f64::consts::FRAC_PI_3),
291        };
292        assert_eq!(select_lod(cam, &xform_id, t), Lod::Mid);
293        assert_eq!(select_lod(cam, &xform_rot, t), Lod::Mid);
294    }
295
296    #[test]
297    fn zero_radius_collapses_to_far_for_any_nonzero_distance() {
298        // An empty grid (bounding_radius = 0) yields
299        // r_near = r_mid = 0 — the only `Near` distance is 0.
300        let t = LodThresholds::from_radius(0.0);
301        let xform = at_origin();
302        assert_eq!(select_lod(DVec3::ZERO, &xform, t), Lod::Near);
303        assert_eq!(select_lod(DVec3::new(0.5, 0.0, 0.0), &xform, t), Lod::Far);
304    }
305}