1use crate::error::{ViewportError, ViewportResult};
14use crate::renderer::RenderCamera;
15use crate::resources::mesh::mesh_store::MeshId;
16use crate::scene::aabb::Aabb;
17
18const LOD_HYSTERESIS: f32 = 0.1;
22
23crate::resources::handle::slot_handle! {
24 pub struct LodGroupId;
32}
33
34#[derive(Debug, Clone, Copy)]
36#[non_exhaustive]
37pub struct LodLevel {
38 pub mesh: MeshId,
40 pub min_screen_size: f32,
45}
46
47impl LodLevel {
48 pub fn new(mesh: MeshId, min_screen_size: f32) -> Self {
50 Self {
51 mesh,
52 min_screen_size,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum LodTransition {
61 #[default]
63 Discrete,
64}
65
66#[derive(Debug, Clone)]
75#[non_exhaustive]
76pub struct LodGroup {
77 levels: Vec<LodLevel>,
79 transition: LodTransition,
81 cull_below: Option<f32>,
84}
85
86impl LodGroup {
87 pub fn levels(&self) -> &[LodLevel] {
89 &self.levels
90 }
91
92 pub fn level_count(&self) -> usize {
94 self.levels.len()
95 }
96
97 pub fn mesh_at(&self, level: usize) -> MeshId {
99 let idx = level.min(self.levels.len().saturating_sub(1));
100 self.levels[idx].mesh
101 }
102
103 pub fn transition(&self) -> LodTransition {
105 self.transition
106 }
107
108 pub fn cull_below(&self) -> Option<f32> {
110 self.cull_below
111 }
112
113 pub fn should_cull(&self, screen_size: f32) -> bool {
115 matches!(self.cull_below, Some(c) if screen_size < c)
116 }
117
118 pub fn level_for_size(&self, screen_size: f32) -> usize {
123 for (i, level) in self.levels.iter().enumerate() {
124 if screen_size >= level.min_screen_size {
125 return i;
126 }
127 }
128 self.levels.len().saturating_sub(1)
129 }
130
131 pub fn select(&self, screen_size: f32, current_level: usize) -> usize {
139 let n = self.levels.len();
140 if n == 0 {
141 return 0;
142 }
143 let current = current_level.min(n - 1);
144 let target = self.level_for_size(screen_size);
145 if target == current {
146 return current;
147 }
148 if target < current {
149 let boundary = self.levels[current - 1].min_screen_size;
152 if screen_size > boundary * (1.0 + LOD_HYSTERESIS) {
153 return target;
154 }
155 } else {
156 let boundary = self.levels[current].min_screen_size;
159 if screen_size < boundary * (1.0 - LOD_HYSTERESIS) {
160 return target;
161 }
162 }
163 current
164 }
165}
166
167pub fn projected_screen_size(local_aabb: &Aabb, model: &glam::Mat4, camera: &RenderCamera) -> f32 {
177 let world = local_aabb.transformed(model);
178 let center = world.center();
179 let radius = (world.max - world.min).length() * 0.5;
180 if radius <= 0.0 {
181 return 0.0;
182 }
183 let eye = glam::Vec3::from(camera.eye_position);
184 let distance = (center - eye).length().max(camera.near);
185 let half_fov_tan = (camera.fov * 0.5).tan().max(1e-4);
186 radius / (distance * half_fov_tan)
187}
188
189pub(crate) struct LodGroupStore {
195 store: crate::resources::handle::SlotStore<LodGroup, LodGroupId>,
196}
197
198impl LodGroupStore {
199 pub fn new() -> Self {
200 Self {
201 store: crate::resources::handle::SlotStore::default(),
202 }
203 }
204
205 pub fn insert(&mut self, group: LodGroup) -> LodGroupId {
206 self.store.insert(group, 0)
207 }
208
209 pub fn get(&self, id: LodGroupId) -> Option<&LodGroup> {
210 self.store.get(id)
211 }
212
213 pub fn get_mut(&mut self, id: LodGroupId) -> Option<&mut LodGroup> {
214 self.store.get_mut(id)
215 }
216
217 pub fn remove(&mut self, id: LodGroupId) -> Option<LodGroup> {
221 self.store.remove(id)
222 }
223
224 pub fn live_member_meshes(&self) -> impl Iterator<Item = MeshId> + '_ {
227 self.store
228 .iter()
229 .flat_map(|(_, g)| g.levels.iter().map(|l| l.mesh))
230 }
231}
232
233impl crate::resources::DeviceResources {
234 pub fn register_lod_group(
257 &mut self,
258 levels: &[MeshId],
259 min_screen_sizes: &[f32],
260 ) -> ViewportResult<LodGroupId> {
261 if levels.is_empty() {
262 return Err(ViewportError::LodGroupEmpty);
263 }
264 if levels.len() != min_screen_sizes.len() {
265 return Err(ViewportError::LodLevelCountMismatch {
266 meshes: levels.len(),
267 thresholds: min_screen_sizes.len(),
268 });
269 }
270
271 for (i, &mesh) in levels.iter().enumerate() {
272 if !self.mesh_store.contains(mesh) {
273 return Err(ViewportError::SlotEmpty {
274 index: mesh.index(),
275 });
276 }
277 if i > 0 && min_screen_sizes[i] >= min_screen_sizes[i - 1] {
278 return Err(ViewportError::LodThresholdsNotDescending { level: i });
279 }
280 }
281
282 self.validate_lod_compatibility(levels)?;
283
284 let lod_levels = levels
285 .iter()
286 .zip(min_screen_sizes)
287 .map(|(&mesh, &size)| LodLevel::new(mesh, size))
288 .collect();
289
290 Ok(self.lod_groups.insert(LodGroup {
291 levels: lod_levels,
292 transition: LodTransition::Discrete,
293 cull_below: None,
294 }))
295 }
296
297 #[allow(dead_code)]
300 pub(crate) fn lod_group(&self, id: LodGroupId) -> Option<&LodGroup> {
301 self.lod_groups.get(id)
302 }
303
304 pub fn free_lod_group(&mut self, id: LodGroupId) -> bool {
316 let Some(group) = self.lod_groups.remove(id) else {
317 return false;
318 };
319
320 let shared: std::collections::HashSet<MeshId> =
322 self.lod_groups.live_member_meshes().collect();
323
324 for level in &group.levels {
325 if !shared.contains(&level.mesh) {
326 self.free_mesh(level.mesh);
327 }
328 }
329 true
330 }
331
332 pub fn set_lod_cull_below(
342 &mut self,
343 id: LodGroupId,
344 cull_below: Option<f32>,
345 ) -> ViewportResult<()> {
346 match self.lod_groups.get_mut(id) {
347 Some(group) => {
348 group.cull_below = cull_below;
349 Ok(())
350 }
351 None => Err(ViewportError::LodGroupNotFound { index: id.index() }),
352 }
353 }
354
355 fn validate_lod_compatibility(&self, levels: &[MeshId]) -> ViewportResult<()> {
358 let base = levels[0];
359 let base_attrs = self.mesh_attribute_names(base);
360 let base_deformed = self.deform.meshes.contains_key(&base);
361
362 for (i, &mesh) in levels.iter().enumerate().skip(1) {
363 let attrs = self.mesh_attribute_names(mesh);
364 if let Some(missing) = base_attrs.iter().find(|a| !attrs.contains(*a)) {
365 return Err(ViewportError::LodLevelIncompatible {
366 level: i,
367 reason: format!("missing attribute '{missing}' present on level 0"),
368 });
369 }
370 if let Some(extra) = attrs.iter().find(|a| !base_attrs.contains(*a)) {
371 return Err(ViewportError::LodLevelIncompatible {
372 level: i,
373 reason: format!("has attribute '{extra}' absent from level 0"),
374 });
375 }
376 if self.deform.meshes.contains_key(&mesh) != base_deformed {
377 let reason = if base_deformed {
378 "level 0 has deformer data attached but this level does not"
379 } else {
380 "this level has deformer data attached but level 0 does not"
381 };
382 return Err(ViewportError::LodLevelIncompatible {
383 level: i,
384 reason: reason.to_string(),
385 });
386 }
387 }
388 Ok(())
389 }
390
391 fn mesh_attribute_names(&self, mesh: MeshId) -> std::collections::BTreeSet<String> {
395 let mut names = std::collections::BTreeSet::new();
396 if let Some(m) = self.mesh_store.get(mesh) {
397 names.extend(m.attribute_buffers.keys().cloned());
398 names.extend(m.face_attribute_buffers.keys().cloned());
399 names.extend(m.face_colour_buffers.keys().cloned());
400 names.extend(m.vector_attribute_buffers.keys().cloned());
401 }
402 names
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 fn group(levels: Vec<LodLevel>, cull_below: Option<f32>) -> LodGroup {
411 LodGroup {
412 levels,
413 transition: LodTransition::Discrete,
414 cull_below,
415 }
416 }
417
418 fn lvl(min_screen_size: f32) -> LodLevel {
419 LodLevel::new(MeshId::INVALID, min_screen_size)
421 }
422
423 #[test]
424 fn projected_size_shrinks_with_distance() {
425 let aabb = Aabb {
426 min: glam::Vec3::splat(-0.5),
427 max: glam::Vec3::splat(0.5),
428 };
429 let mut camera = RenderCamera::default();
430 camera.eye_position = [0.0, 0.0, 0.0];
431 camera.fov = std::f32::consts::FRAC_PI_4;
432
433 let near = glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, -5.0));
434 let far = glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, -50.0));
435 let near_size = projected_screen_size(&aabb, &near, &camera);
436 let far_size = projected_screen_size(&aabb, &far, &camera);
437
438 assert!(near_size > far_size);
439 assert!((near_size / far_size - 10.0).abs() < 0.5);
441 }
442
443 #[test]
444 fn projected_size_shrinks_with_wider_fov() {
445 let aabb = Aabb {
446 min: glam::Vec3::splat(-0.5),
447 max: glam::Vec3::splat(0.5),
448 };
449 let model = glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, -10.0));
450 let mut narrow = RenderCamera::default();
451 narrow.eye_position = [0.0, 0.0, 0.0];
452 narrow.fov = std::f32::consts::FRAC_PI_4;
453 let mut wide = narrow.clone();
454 wide.fov = std::f32::consts::FRAC_PI_2;
455
456 assert!(
457 projected_screen_size(&aabb, &model, &narrow)
458 > projected_screen_size(&aabb, &model, &wide)
459 );
460 }
461
462 #[test]
463 fn level_for_size_picks_finest_cleared() {
464 let g = group(vec![lvl(0.5), lvl(0.2), lvl(0.0)], None);
465 assert_eq!(g.level_for_size(0.9), 0);
466 assert_eq!(g.level_for_size(0.5), 0);
467 assert_eq!(g.level_for_size(0.3), 1);
468 assert_eq!(g.level_for_size(0.05), 2);
469 }
470
471 #[test]
472 fn select_holds_level_inside_hysteresis_band() {
473 let g = group(vec![lvl(0.5), lvl(0.2), lvl(0.0)], None);
474 assert_eq!(g.select(0.48, 0), 0);
477 assert_eq!(g.select(0.52, 1), 1);
480 assert_eq!(g.select(0.40, 0), 1);
482 assert_eq!(g.select(0.60, 1), 0);
483 }
484
485 #[test]
486 fn select_can_jump_multiple_levels() {
487 let g = group(vec![lvl(0.5), lvl(0.2), lvl(0.0)], None);
488 assert_eq!(g.select(0.01, 0), 2);
490 }
491
492 #[test]
493 fn should_cull_respects_threshold() {
494 let g = group(vec![lvl(0.5), lvl(0.0)], Some(0.05));
495 assert!(g.should_cull(0.01));
496 assert!(!g.should_cull(0.10));
497 let no_cull = group(vec![lvl(0.5), lvl(0.0)], None);
498 assert!(!no_cull.should_cull(0.0));
499 }
500}
501
502#[cfg(test)]
503mod registration_tests {
504 use super::*;
505 use crate::DeviceResources;
506 use crate::geometry::primitives;
507 use crate::resources::{AttributeData, MeshData};
508
509 fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
510 let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
511 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
512 power_preference: wgpu::PowerPreference::LowPower,
513 compatible_surface: None,
514 force_fallback_adapter: false,
515 }))
516 .ok()?;
517 pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
518 }
519
520 fn with_attr(mut data: MeshData, name: &str) -> MeshData {
521 let count = data.positions.len();
522 data.attributes
523 .insert(name.to_string(), AttributeData::Vertex(vec![0.0; count]));
524 data
525 }
526
527 fn register(
530 res: &mut DeviceResources,
531 device: &wgpu::Device,
532 levels: &[(MeshData, f32)],
533 ) -> ViewportResult<LodGroupId> {
534 let mut ids = Vec::with_capacity(levels.len());
535 let mut sizes = Vec::with_capacity(levels.len());
536 for (data, size) in levels {
537 ids.push(res.upload_mesh_data(device, data)?);
538 sizes.push(*size);
539 }
540 res.register_lod_group(&ids, &sizes)
541 }
542
543 #[test]
544 fn register_lod_group_round_trips() {
545 let Some((device, _queue)) = try_make_device() else {
546 eprintln!("skipping: no wgpu adapter available");
547 return;
548 };
549 let mut res = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
550 let id = register(
551 &mut res,
552 &device,
553 &[
554 (primitives::icosphere(1.0, 3), 0.5),
555 (primitives::icosphere(1.0, 1), 0.2),
556 (primitives::icosphere(1.0, 0), 0.0),
557 ],
558 )
559 .expect("group should register");
560 let group = res.lod_group(id).expect("group present");
561 assert_eq!(group.level_count(), 3);
562 }
563
564 #[test]
565 fn thresholds_must_descend() {
566 let Some((device, _queue)) = try_make_device() else {
567 eprintln!("skipping: no wgpu adapter available");
568 return;
569 };
570 let mut res = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
571 let err = register(
572 &mut res,
573 &device,
574 &[(primitives::cube(1.0), 0.2), (primitives::cube(1.0), 0.5)],
575 );
576 assert!(matches!(
577 err,
578 Err(ViewportError::LodThresholdsNotDescending { level: 1 })
579 ));
580 }
581
582 #[test]
583 fn mismatched_attributes_are_rejected() {
584 let Some((device, _queue)) = try_make_device() else {
585 eprintln!("skipping: no wgpu adapter available");
586 return;
587 };
588 let mut res = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
589 let err = register(
590 &mut res,
591 &device,
592 &[
593 (with_attr(primitives::cube(1.0), "temperature"), 0.5),
594 (primitives::cube(1.0), 0.0),
595 ],
596 );
597 assert!(matches!(
598 err,
599 Err(ViewportError::LodLevelIncompatible { level: 1, .. })
600 ));
601 }
602
603 #[test]
604 fn empty_group_is_rejected() {
605 let Some((device, _queue)) = try_make_device() else {
606 eprintln!("skipping: no wgpu adapter available");
607 return;
608 };
609 let mut res = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
610 let err = res.register_lod_group(&[], &[]);
611 assert!(matches!(err, Err(ViewportError::LodGroupEmpty)));
612 }
613}