Skip to main content

viewport_lib/resources/mesh/
lod.rs

1//! Level-of-detail mesh groups.
2//!
3//! A LOD group bundles several meshes that are different-detail versions of one
4//! object, ordered from full detail to crude. Each level carries the smallest
5//! on-screen size at which it is used. The renderer measures how large an object
6//! appears each frame and draws the matching level, so distant objects fall back
7//! to cheaper geometry.
8//!
9//! The group holds geometry only: no material, transform, or per-object state.
10//! Those live on the scene item, so swapping levels changes which mesh is drawn
11//! and nothing else.
12
13use crate::error::{ViewportError, ViewportResult};
14use crate::renderer::RenderCamera;
15use crate::resources::mesh::mesh_store::MeshId;
16use crate::scene::aabb::Aabb;
17
18/// Margin applied to a level boundary before a switch is allowed, as a fraction
19/// of the threshold. Keeps an object sitting on a boundary from flipping level
20/// every frame: it must move past the boundary by this much to switch.
21const LOD_HYSTERESIS: f32 = 0.1;
22
23crate::resources::handle::slot_handle! {
24    /// Handle to a LOD group in the renderer's group registry.
25    ///
26    /// Carries the slot index plus the generation the slot had when the handle
27    /// was issued. A group freed with
28    /// [`free_lod_group`](crate::DeviceResources::free_lod_group) bumps its
29    /// slot generation, so a stale handle resolves to `None` rather than
30    /// aliasing a group registered later into the reused slot.
31    pub struct LodGroupId;
32}
33
34/// One detail level of a [`LodGroup`].
35#[derive(Debug, Clone, Copy)]
36#[non_exhaustive]
37pub struct LodLevel {
38    /// Mesh drawn while this level is selected.
39    pub mesh: MeshId,
40    /// Smallest projected size, as a fraction of viewport height, at which this
41    /// level is used. Levels are ordered full to crude with strictly decreasing
42    /// values: the full level has the largest threshold, the crudest the
43    /// smallest (often `0.0` so it always applies as the fallback).
44    pub min_screen_size: f32,
45}
46
47impl LodLevel {
48    /// Build a level from a mesh and its lower screen-size bound.
49    pub fn new(mesh: MeshId, min_screen_size: f32) -> Self {
50        Self {
51            mesh,
52            min_screen_size,
53        }
54    }
55}
56
57/// How a group moves between adjacent levels.
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum LodTransition {
61    /// Swap meshes at the threshold with no blend.
62    #[default]
63    Discrete,
64}
65
66/// A set of meshes that are detail variants of one object, plus the thresholds
67/// that decide which one to draw.
68///
69/// Upload the level meshes however you like (sync `upload_mesh_data` or the
70/// async job path), then bundle them with
71/// [`DeviceResources::register_lod_group`].
72///
73/// [`DeviceResources::register_lod_group`]: crate::DeviceResources::register_lod_group
74#[derive(Debug, Clone)]
75#[non_exhaustive]
76pub struct LodGroup {
77    /// Levels ordered full detail to crude.
78    levels: Vec<LodLevel>,
79    /// How to move between levels.
80    transition: LodTransition,
81    /// Stop drawing the object once its projected size drops below this, as a
82    /// fraction of viewport height. `None` means never cull on size.
83    cull_below: Option<f32>,
84}
85
86impl LodGroup {
87    /// The levels, full detail first.
88    pub fn levels(&self) -> &[LodLevel] {
89        &self.levels
90    }
91
92    /// Number of levels.
93    pub fn level_count(&self) -> usize {
94        self.levels.len()
95    }
96
97    /// Mesh for a level index, clamped to the crudest level if out of range.
98    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    /// Transition mode.
104    pub fn transition(&self) -> LodTransition {
105        self.transition
106    }
107
108    /// Size below which the object is culled, if set.
109    pub fn cull_below(&self) -> Option<f32> {
110        self.cull_below
111    }
112
113    /// Whether an object of this projected size should be culled entirely.
114    pub fn should_cull(&self, screen_size: f32) -> bool {
115        matches!(self.cull_below, Some(c) if screen_size < c)
116    }
117
118    /// The level an object of this size maps to, ignoring the current level.
119    ///
120    /// Returns the finest level whose `min_screen_size` the object clears, or
121    /// the crudest level if it clears none.
122    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    /// The level to draw given the object's projected size and the level it drew
132    /// last frame.
133    ///
134    /// This is [`level_for_size`](Self::level_for_size) plus hysteresis: a
135    /// switch only happens once the size has moved past the current level's
136    /// boundary by [`LOD_HYSTERESIS`]. An object oscillating inside that margin
137    /// stays put.
138    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            // Want a finer level (object grew). The boundary to cross is the
150            // start of the level just finer than the current one.
151            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            // Want a cruder level (object shrank). The boundary is the start of
157            // the current level.
158            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
167/// Projected size of an object, as a fraction of viewport height.
168///
169/// Takes the object's local-space AABB and model transform plus the camera, and
170/// returns how much of the viewport's vertical extent the object's bounding
171/// sphere covers. This is the metric LOD thresholds compare against: it folds in
172/// distance and field of view, so it is independent of window resolution.
173///
174/// Assumes a perspective projection (uses the camera field of view). Objects at
175/// or behind the near plane are clamped to the near distance.
176pub 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
189/// Registry of LOD groups owned by the renderer. Slotted with generational
190/// handles: `free_lod_group` frees a slot and bumps its generation, so a later
191/// `register_lod_group` can reuse the slot without a stale `LodGroupId` aliasing
192/// the new group. LOD groups carry no measured GPU size, so slots are charged
193/// zero bytes.
194pub(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    /// Remove the group for `id`, bump the slot generation, and free the slot.
218    /// Returns the removed group so the caller can free member meshes, or `None`
219    /// if the slot was empty, out of range, or the handle was stale.
220    pub fn remove(&mut self, id: LodGroupId) -> Option<LodGroup> {
221        self.store.remove(id)
222    }
223
224    /// Iterate the meshes still referenced by any live group. Used to decide
225    /// whether a member mesh of a freed group is shared with another group.
226    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    /// Group meshes that are already uploaded into a LOD chain.
235    ///
236    /// `levels` lists the meshes full detail first; `min_screen_sizes` gives the
237    /// matching lower screen-size bound for each, as a fraction of viewport
238    /// height. The thresholds must strictly decrease.
239    ///
240    /// All levels must be drawable the same way: they need the same named
241    /// attributes and the same deformer attachment, otherwise switching to a
242    /// level would silently change or drop coloring, warp, or skinning. That is
243    /// checked here so a mismatch fails at registration instead of at render
244    /// time.
245    ///
246    /// # Errors
247    ///
248    /// - [`ViewportError::LodGroupEmpty`] if `levels` is empty.
249    /// - [`ViewportError::LodLevelCountMismatch`] if the two lists differ in
250    ///   length.
251    /// - [`ViewportError::SlotEmpty`] if a mesh id is not in the store.
252    /// - [`ViewportError::LodThresholdsNotDescending`] if a threshold is not
253    ///   smaller than the previous one.
254    /// - [`ViewportError::LodLevelIncompatible`] if a level's attribute set or
255    ///   deform attachment differs from level 0.
256    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    /// Look up a registered LOD group. The per-frame resolve pass uses this to
298    /// turn a group id into the mesh for the chosen level.
299    #[allow(dead_code)]
300    pub(crate) fn lod_group(&self, id: LodGroupId) -> Option<&LodGroup> {
301        self.lod_groups.get(id)
302    }
303
304    /// Free a LOD group and, composition-aware, its member meshes.
305    ///
306    /// Removes the group from the registry and bumps its slot generation so
307    /// `id` no longer resolves. Each member mesh is freed with
308    /// [`free_mesh`](Self::free_mesh) unless another still-live group also
309    /// references it, in which case the shared mesh is left resident. Freeing
310    /// the member meshes is the point of the call: freeing a group without it
311    /// would leak every level's GPU buffers.
312    ///
313    /// Returns `true` if a group was freed, `false` if `id` did not resolve to a
314    /// live group (already freed or a stale handle).
315    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        // Meshes still owned by any other live group must not be freed.
321        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    /// Set the screen size, as a fraction of viewport height, below which
333    /// objects in this group stop drawing. `None` disables size culling.
334    ///
335    /// Applies to both `SceneRenderItem`s and individual `MeshInstanceItem`
336    /// instances that use the group.
337    ///
338    /// # Errors
339    ///
340    /// Returns [`ViewportError::LodGroupNotFound`] if `id` is not registered.
341    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    /// Check that every level draws the same way as level 0: same named
356    /// attributes and same deformer attachment.
357    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    /// Names of every attribute that drives a draw-time lookup on a mesh: scalar
392    /// vertex/face attributes, face colours, and vector attributes. Empty if the
393    /// mesh slot is gone.
394    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        // Mesh id is irrelevant to the selection math.
420        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        // Ten times the distance is roughly a tenth the size.
440        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        // Sitting just under the 0.5 boundary: from level 0 we should not yet
475        // drop, because we have not fallen past 0.5 * (1 - 0.1) = 0.45.
476        assert_eq!(g.select(0.48, 0), 0);
477        // Coming up from level 1, we should not jump to 0 until past
478        // 0.5 * (1 + 0.1) = 0.55.
479        assert_eq!(g.select(0.52, 1), 1);
480        // Clearly past the margin in each direction.
481        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        // A big shrink from level 0 lands directly on the crudest level.
489        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    /// Upload each level mesh, then register the group: the path a consumer
528    /// takes, made one call for the tests.
529    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}