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}