1use 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 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 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}