Skip to main content

appcore_render/
lod.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: lod.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/10/15 08:11:39 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/10/15 10:24:40 by dnettoRaw
8//      ###########      S: 0.1.0-alpha.1
9// =============================================================================
10
11use crate::{frustum::validate_sphere, BoundingSphere, Frustum, RenderError};
12/// Packed triangle range and conservative model-space geometric error, finest first.
13#[derive(Debug, Clone, Copy)]
14pub struct LodLevel {
15    /// Triangle indices, in 3..=196608 and divisible by three.
16    pub index_count: u32,
17    /// Offset in the caller-owned packed index buffer.
18    pub first_index: u32,
19    /// Nonnegative base vertex in the caller-owned packed vertex buffer.
20    pub base_vertex: u32,
21    /// Conservative deviation from finest geometry; finest must be zero.
22    /// The asset producer owns this bound; the selector cannot infer it from metadata.
23    pub geometric_error: f32,
24    /// Whether the corresponding packed geometry is currently available. Coarsest stays resident.
25    pub resident: bool,
26}
27/// Per-instance conservative bounds and maximum absolute transform scale.
28#[derive(Debug, Clone, Copy)]
29pub struct LodObject {
30    /// Sphere enclosing every LOD, in the same coordinate system as LodView.eye.
31    pub bounds: BoundingSphere,
32    /// Maximum absolute scale, in 0.000001..=10000.
33    pub max_scale: f32,
34}
35impl LodObject {
36    pub(crate) fn validate(self) -> Result<(), RenderError> {
37        validate_sphere(self.bounds)?;
38        if !self.max_scale.is_finite() || !(0.000001..=10000.0).contains(&self.max_scale) {
39            return Err(RenderError::InvalidLimits);
40        }
41        Ok(())
42    }
43}
44/// Explicit centered-perspective error budget; caller supplies matching frustum and camera provenance.
45#[derive(Debug, Clone, Copy)]
46pub struct LodView {
47    /// Camera position in the same coordinates as object bounds.
48    pub eye: [f32; 3],
49    /// Camera forward axis; validate normalizes a finite nonzero vector.
50    pub forward: [f32; 3],
51    /// Conservative physical viewport half-diagonal upper bound, in 0.5..=32768 pixels.
52    pub viewport_half_diagonal: f32,
53    /// Near-camera guard distance, in 0.0001..=1000000.
54    pub near_distance: f32,
55    /// Vertical focal length in physical pixels, in 0.5..=32768.
56    pub focal_pixels: f32,
57    /// Maximum projected deviation, in 0.125..=1024 physical pixels.
58    pub maximum_error_pixels: f32,
59}
60impl LodView {
61    /// Reject invalid numeric inputs before upload. Orthographic projection is not this profile.
62    pub fn validate(mut self) -> Result<Self, RenderError> {
63        if !self.eye.iter().all(|v| v.is_finite() && v.abs() <= 1e12)
64            || !self.near_distance.is_finite()
65            || !(0.0001..=1000000.0).contains(&self.near_distance)
66            || !self.focal_pixels.is_finite()
67            || !(0.5..=32768.0).contains(&self.focal_pixels)
68            || !self.maximum_error_pixels.is_finite()
69            || !(0.125..=1024.0).contains(&self.maximum_error_pixels)
70        {
71            return Err(RenderError::InvalidLimits);
72        }
73        if !self.forward.iter().all(|v| v.is_finite() && v.abs() <= 1e6)
74            || !self.viewport_half_diagonal.is_finite()
75            || !(0.5..=32768.0).contains(&self.viewport_half_diagonal)
76        {
77            return Err(RenderError::InvalidLimits);
78        }
79        let length = self
80            .forward
81            .iter()
82            .map(|v| f64::from(*v).powi(2))
83            .sum::<f64>()
84            .sqrt();
85        if length == 0.0 {
86            return Err(RenderError::InvalidLimits);
87        }
88        self.forward = self.forward.map(|v| (f64::from(v) / length) as f32);
89        Ok(self)
90    }
91}
92/// Selected geometry and explicit degradation when the desired level is not resident.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub struct LodChoice {
95    /// Index in the finest-to-coarsest level list.
96    pub level: u32,
97    /// True when residency forced a coarser level beyond the desired error selection.
98    pub degraded: bool,
99}
100pub(crate) fn validate_levels(levels: &[LodLevel]) -> Result<(), RenderError> {
101    if levels.is_empty()
102        || levels.len() > 8
103        || levels[0].geometric_error != 0.0
104        || !levels.last().is_some_and(|l| l.resident)
105    {
106        return Err(RenderError::InvalidLimits);
107    }
108    let mut previous_error = 0.0;
109    let mut previous_end = 0;
110    for level in levels {
111        if level.index_count == 0
112            || level.index_count > 196608
113            || !level.index_count.is_multiple_of(3)
114            || level.first_index < previous_end
115            || level.base_vertex > 524288
116            || !level.geometric_error.is_finite()
117            || !(previous_error..=1000000.0).contains(&level.geometric_error)
118        {
119            return Err(RenderError::InvalidLimits);
120        }
121        previous_end = level
122            .first_index
123            .checked_add(level.index_count)
124            .filter(|end| *end <= 1572864)
125            .ok_or(RenderError::InvalidLimits)?;
126        previous_error = level.geometric_error;
127    }
128    Ok(())
129}
130/// f64 visibility/LOD reference using perspective screen-error bounds.
131/// Bounds must enclose every level. Clipped spheres request finest. Missing geometry
132/// chooses the next resident coarser level and reports degradation; culled returns None.
133pub fn choose_lod(
134    levels: &[LodLevel],
135    object: LodObject,
136    view: LodView,
137    frustum: Frustum,
138) -> Result<Option<LodChoice>, RenderError> {
139    validate_levels(levels)?;
140    object.validate()?;
141    let view = view.validate()?;
142    if !frustum.intersects(object.bounds)? {
143        return Ok(None);
144    }
145    let clipped = frustum.planes().iter().any(|plane| {
146        let distance = (0..3)
147            .map(|i| f64::from(plane[i]) * f64::from(object.bounds.center[i]))
148            .sum::<f64>()
149            + f64::from(plane[3]);
150        let magnitude = (0..3)
151            .map(|i| (f64::from(plane[i]) * f64::from(object.bounds.center[i])).abs())
152            .sum::<f64>()
153            + f64::from(plane[3]).abs()
154            + f64::from(object.bounds.radius)
155            + 1.0;
156        distance <= f64::from(object.bounds.radius) + magnitude * 0.00001
157    });
158    let delta = std::array::from_fn::<_, 3, _>(|i| {
159        f64::from(object.bounds.center[i]) - f64::from(view.eye[i])
160    });
161    let magnitude = (0..3)
162        .map(|i| (delta[i] * f64::from(view.forward[i])).abs())
163        .sum::<f64>()
164        + f64::from(object.bounds.radius)
165        + 1.0;
166    let depth = (0..3)
167        .map(|i| delta[i] * f64::from(view.forward[i]))
168        .sum::<f64>()
169        - f64::from(object.bounds.radius)
170        - magnitude * 0.00001;
171    let mut desired = 0;
172    if !clipped && depth > f64::from(view.near_distance) {
173        for (index, level) in levels.iter().enumerate().skip(1) {
174            let error = f64::from(level.geometric_error) * f64::from(object.max_scale);
175            if depth - error <= f64::from(view.near_distance) {
176                continue;
177            }
178            let projected = (f64::from(view.focal_pixels) + f64::from(view.viewport_half_diagonal))
179                * error
180                / (depth - error);
181            if projected * (1.0 + 0.00001) <= f64::from(view.maximum_error_pixels) {
182                desired = index;
183            }
184        }
185    }
186    let selected = (desired..levels.len())
187        .find(|i| levels[*i].resident)
188        .ok_or(RenderError::InvalidResource)?;
189    Ok(Some(LodChoice {
190        level: selected as u32,
191        degraded: selected != desired,
192    }))
193}