ferravex_render/
visibility.rs

1//! Conservative transformed-bound visibility and per-object hysteretic LOD.
2use crate::{Mat4, Vec3, retained::ResidentMesh};
3use ferravex_graphics::{Bounds, Frustum};
4
5#[derive(Clone, Copy, Debug)]
6pub struct VisibilitySettings {
7    pub frustum_culling: bool,
8    /// Distance to the nearest point on the conservative world AABB, not its center.
9    pub max_distance: Option<f32>,
10}
11impl Default for VisibilitySettings {
12    fn default() -> Self {
13        Self {
14            frustum_culling: true,
15            max_distance: None,
16        }
17    }
18}
19impl VisibilitySettings {
20    pub fn valid(self) -> bool {
21        self.max_distance.is_none_or(|n| n.is_finite() && n >= 0.)
22    }
23}
24#[derive(Clone, Copy, Default, Debug)]
25pub struct VisibilityStats {
26    pub tested: u32,
27    pub visible: u32,
28    pub frustum_culled: u32,
29    pub distance_culled: u32,
30}
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum Visibility {
33    Visible,
34    OutsideFrustum,
35    TooDistant,
36    InvalidTransform,
37}
38pub fn distance_to_bounds(bounds: Bounds, model: Mat4, eye: Vec3) -> f32 {
39    let center = model.transform_point3(bounds.center());
40    let h = bounds.half_extents();
41    let half = model.x_axis.truncate().abs() * h.x
42        + model.y_axis.truncate().abs() * h.y
43        + model.z_axis.truncate().abs() * h.z;
44    ((eye - center).abs() - half).max(Vec3::ZERO).length()
45}
46pub fn classify(
47    bounds: Bounds,
48    model: Mat4,
49    frustum: Option<Frustum>,
50    eye: Option<Vec3>,
51    settings: VisibilitySettings,
52) -> Visibility {
53    let a = model.to_cols_array();
54    if !model.is_finite() || a[3] != 0. || a[7] != 0. || a[11] != 0. || a[15] != 1. {
55        return Visibility::InvalidTransform;
56    }
57    if settings.frustum_culling && frustum.is_some_and(|f| !f.intersects_bounds(bounds, model)) {
58        return Visibility::OutsideFrustum;
59    }
60    if let (Some(eye), Some(max)) = (eye, settings.max_distance)
61        && distance_to_bounds(bounds, model, eye) > max
62    {
63        return Visibility::TooDistant;
64    }
65    Visibility::Visible
66}
67#[derive(Clone, Copy, Default, Debug)]
68pub struct LodState {
69    level: usize,
70    initialized: bool,
71}
72impl LodState {
73    pub fn level(self) -> usize {
74        self.level
75    }
76}
77pub struct LodChain {
78    levels: Vec<ResidentMesh>,
79    thresholds: Vec<f32>,
80    hysteresis: f32,
81    bounds: Bounds,
82}
83impl LodChain {
84    /// 1..=16 authored levels and strictly increasing positive transition distances.
85    /// Threshold distance is measured to the union of all level bounds.
86    pub fn new(
87        levels: Vec<ResidentMesh>,
88        thresholds: Vec<f32>,
89        hysteresis: f32,
90    ) -> Result<Self, &'static str> {
91        if levels.is_empty()
92            || levels.len() > 16
93            || thresholds.len() + 1 != levels.len()
94            || !hysteresis.is_finite()
95            || !(0. ..=0.5).contains(&hysteresis)
96            || thresholds.iter().any(|t| !t.is_finite() || *t <= 0.)
97            || thresholds.windows(2).any(|w| w[0] >= w[1])
98        {
99            return Err("invalid LOD levels/thresholds/hysteresis");
100        }
101        let mut bounds = levels[0].bounds();
102        for level in &levels[1..] {
103            let b = level.bounds();
104            bounds.min = bounds.min.min(b.min);
105            bounds.max = bounds.max.max(b.max);
106        }
107        Ok(Self {
108            levels,
109            thresholds,
110            hysteresis,
111            bounds,
112        })
113    }
114    pub fn bounds(&self) -> Bounds {
115        self.bounds
116    }
117    pub fn select(&self, distance: f32, state: &mut LodState) -> &ResidentMesh {
118        let distance = if distance.is_finite() {
119            distance.max(0.)
120        } else {
121            0.
122        };
123        if !state.initialized || state.level >= self.levels.len() {
124            state.level = self.thresholds.partition_point(|t| distance > *t);
125            state.initialized = true;
126        } else {
127            while state.level < self.thresholds.len()
128                && distance > self.thresholds[state.level] * (1. + self.hysteresis)
129            {
130                state.level += 1;
131            }
132            while state.level > 0
133                && distance < self.thresholds[state.level - 1] * (1. - self.hysteresis)
134            {
135                state.level -= 1;
136            }
137        }
138        &self.levels[state.level]
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::{WHITE, mesh::Mesh3d};
146    use ferravex_graphics::ClipDepth;
147    #[test]
148    fn conservative_visibility_handles_near_plane_scale_and_large_objects() {
149        let bounds = Bounds {
150            min: Vec3::splat(-1.),
151            max: Vec3::ONE,
152        };
153        let f = Frustum::from_matrix(Mat4::IDENTITY, ClipDepth::NegativeOneToOne);
154        assert_eq!(
155            classify(bounds, Mat4::IDENTITY, f, None, Default::default()),
156            Visibility::Visible
157        );
158        assert_eq!(
159            classify(
160                bounds,
161                Mat4::from_translation(Vec3::X * 4.),
162                f,
163                None,
164                Default::default()
165            ),
166            Visibility::OutsideFrustum
167        );
168        assert_eq!(
169            classify(
170                bounds,
171                Mat4::from_translation(Vec3::Z * 1.5),
172                f,
173                None,
174                Default::default()
175            ),
176            Visibility::Visible
177        );
178        assert_eq!(
179            classify(
180                bounds,
181                Mat4::from_scale(Vec3::new(-5., 2., 1.)),
182                f,
183                None,
184                Default::default()
185            ),
186            Visibility::Visible
187        );
188        let settings = VisibilitySettings {
189            max_distance: Some(1.),
190            ..Default::default()
191        };
192        assert_eq!(
193            classify(
194                bounds,
195                Mat4::from_scale(Vec3::splat(100.)),
196                None,
197                Some(Vec3::X * 50.),
198                settings
199            ),
200            Visibility::Visible
201        );
202        assert_eq!(
203            classify(bounds, Mat4::IDENTITY, None, Some(Vec3::X * 50.), settings),
204            Visibility::TooDistant
205        );
206    }
207    #[test]
208    fn lod_hysteresis_retains_level_near_boundary_and_handles_camera_jumps() {
209        let mesh = Mesh3d::cuboid(Vec3::ONE, WHITE).resident().clone();
210        let chain =
211            LodChain::new(vec![mesh.clone(), mesh.clone(), mesh], vec![10., 30.], 0.1).unwrap();
212        let mut state = LodState::default();
213        for (distance, level) in [
214            (0., 0),
215            (10.5, 0),
216            (12., 1),
217            (9.5, 1),
218            (8., 0),
219            (100., 2),
220            (0., 0),
221        ] {
222            chain.select(distance, &mut state);
223            assert_eq!(state.level(), level);
224        }
225        let mut fresh = LodState::default();
226        chain.select(100., &mut fresh);
227        assert_eq!(fresh.level(), 2);
228    }
229    #[test]
230    fn invalid_lod_configs_are_rejected() {
231        assert!(LodChain::new(vec![], vec![], 0.1).is_err());
232        let mesh = Mesh3d::cuboid(Vec3::ONE, WHITE).resident().clone();
233        assert!(LodChain::new(vec![mesh.clone(), mesh.clone()], vec![f32::NAN], 0.1).is_err());
234        assert!(LodChain::new(vec![mesh.clone()], vec![], 1.).is_err());
235        assert!(
236            LodChain::new(vec![mesh.clone(), mesh.clone(), mesh], vec![20., 10.], 0.1).is_err()
237        );
238    }
239}