use crate::error::{ViewportError, ViewportResult};
use crate::renderer::RenderCamera;
use crate::resources::mesh::mesh_store::MeshId;
use crate::scene::aabb::Aabb;
const LOD_HYSTERESIS: f32 = 0.1;
crate::resources::handle::slot_handle! {
pub struct LodGroupId;
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct LodLevel {
pub mesh: MeshId,
pub min_screen_size: f32,
}
impl LodLevel {
pub fn new(mesh: MeshId, min_screen_size: f32) -> Self {
Self {
mesh,
min_screen_size,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum LodTransition {
#[default]
Discrete,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct LodGroup {
levels: Vec<LodLevel>,
transition: LodTransition,
cull_below: Option<f32>,
}
impl LodGroup {
pub fn levels(&self) -> &[LodLevel] {
&self.levels
}
pub fn level_count(&self) -> usize {
self.levels.len()
}
pub fn mesh_at(&self, level: usize) -> MeshId {
let idx = level.min(self.levels.len().saturating_sub(1));
self.levels[idx].mesh
}
pub fn transition(&self) -> LodTransition {
self.transition
}
pub fn cull_below(&self) -> Option<f32> {
self.cull_below
}
pub fn should_cull(&self, screen_size: f32) -> bool {
matches!(self.cull_below, Some(c) if screen_size < c)
}
pub fn level_for_size(&self, screen_size: f32) -> usize {
for (i, level) in self.levels.iter().enumerate() {
if screen_size >= level.min_screen_size {
return i;
}
}
self.levels.len().saturating_sub(1)
}
pub fn select(&self, screen_size: f32, current_level: usize) -> usize {
let n = self.levels.len();
if n == 0 {
return 0;
}
let current = current_level.min(n - 1);
let target = self.level_for_size(screen_size);
if target == current {
return current;
}
if target < current {
let boundary = self.levels[current - 1].min_screen_size;
if screen_size > boundary * (1.0 + LOD_HYSTERESIS) {
return target;
}
} else {
let boundary = self.levels[current].min_screen_size;
if screen_size < boundary * (1.0 - LOD_HYSTERESIS) {
return target;
}
}
current
}
}
pub fn projected_screen_size(local_aabb: &Aabb, model: &glam::Mat4, camera: &RenderCamera) -> f32 {
let world = local_aabb.transformed(model);
let center = world.center();
let radius = (world.max - world.min).length() * 0.5;
if radius <= 0.0 {
return 0.0;
}
let eye = glam::Vec3::from(camera.eye_position);
let distance = (center - eye).length().max(camera.near);
let half_fov_tan = (camera.fov * 0.5).tan().max(1e-4);
radius / (distance * half_fov_tan)
}
pub(crate) struct LodGroupStore {
store: crate::resources::handle::SlotStore<LodGroup, LodGroupId>,
}
impl LodGroupStore {
pub fn new() -> Self {
Self {
store: crate::resources::handle::SlotStore::default(),
}
}
pub fn insert(&mut self, group: LodGroup) -> LodGroupId {
self.store.insert(group, 0)
}
pub fn get(&self, id: LodGroupId) -> Option<&LodGroup> {
self.store.get(id)
}
pub fn get_mut(&mut self, id: LodGroupId) -> Option<&mut LodGroup> {
self.store.get_mut(id)
}
pub fn remove(&mut self, id: LodGroupId) -> Option<LodGroup> {
self.store.remove(id)
}
pub fn live_member_meshes(&self) -> impl Iterator<Item = MeshId> + '_ {
self.store
.iter()
.flat_map(|(_, g)| g.levels.iter().map(|l| l.mesh))
}
}
impl crate::resources::DeviceResources {
pub fn register_lod_group(
&mut self,
levels: &[MeshId],
min_screen_sizes: &[f32],
) -> ViewportResult<LodGroupId> {
if levels.is_empty() {
return Err(ViewportError::LodGroupEmpty);
}
if levels.len() != min_screen_sizes.len() {
return Err(ViewportError::LodLevelCountMismatch {
meshes: levels.len(),
thresholds: min_screen_sizes.len(),
});
}
for (i, &mesh) in levels.iter().enumerate() {
if !self.mesh_store.contains(mesh) {
return Err(ViewportError::SlotEmpty {
index: mesh.index(),
});
}
if i > 0 && min_screen_sizes[i] >= min_screen_sizes[i - 1] {
return Err(ViewportError::LodThresholdsNotDescending { level: i });
}
}
self.validate_lod_compatibility(levels)?;
let lod_levels = levels
.iter()
.zip(min_screen_sizes)
.map(|(&mesh, &size)| LodLevel::new(mesh, size))
.collect();
Ok(self.lod_groups.insert(LodGroup {
levels: lod_levels,
transition: LodTransition::Discrete,
cull_below: None,
}))
}
#[allow(dead_code)]
pub(crate) fn lod_group(&self, id: LodGroupId) -> Option<&LodGroup> {
self.lod_groups.get(id)
}
pub fn free_lod_group(&mut self, id: LodGroupId) -> bool {
let Some(group) = self.lod_groups.remove(id) else {
return false;
};
let shared: std::collections::HashSet<MeshId> =
self.lod_groups.live_member_meshes().collect();
for level in &group.levels {
if !shared.contains(&level.mesh) {
self.free_mesh(level.mesh);
}
}
true
}
pub fn set_lod_cull_below(
&mut self,
id: LodGroupId,
cull_below: Option<f32>,
) -> ViewportResult<()> {
match self.lod_groups.get_mut(id) {
Some(group) => {
group.cull_below = cull_below;
Ok(())
}
None => Err(ViewportError::LodGroupNotFound { index: id.index() }),
}
}
fn validate_lod_compatibility(&self, levels: &[MeshId]) -> ViewportResult<()> {
let base = levels[0];
let base_attrs = self.mesh_attribute_names(base);
let base_deformed = self.deform.meshes.contains_key(&base);
for (i, &mesh) in levels.iter().enumerate().skip(1) {
let attrs = self.mesh_attribute_names(mesh);
if let Some(missing) = base_attrs.iter().find(|a| !attrs.contains(*a)) {
return Err(ViewportError::LodLevelIncompatible {
level: i,
reason: format!("missing attribute '{missing}' present on level 0"),
});
}
if let Some(extra) = attrs.iter().find(|a| !base_attrs.contains(*a)) {
return Err(ViewportError::LodLevelIncompatible {
level: i,
reason: format!("has attribute '{extra}' absent from level 0"),
});
}
if self.deform.meshes.contains_key(&mesh) != base_deformed {
let reason = if base_deformed {
"level 0 has deformer data attached but this level does not"
} else {
"this level has deformer data attached but level 0 does not"
};
return Err(ViewportError::LodLevelIncompatible {
level: i,
reason: reason.to_string(),
});
}
}
Ok(())
}
fn mesh_attribute_names(&self, mesh: MeshId) -> std::collections::BTreeSet<String> {
let mut names = std::collections::BTreeSet::new();
if let Some(m) = self.mesh_store.get(mesh) {
names.extend(m.attribute_buffers.keys().cloned());
names.extend(m.face_attribute_buffers.keys().cloned());
names.extend(m.face_colour_buffers.keys().cloned());
names.extend(m.vector_attribute_buffers.keys().cloned());
}
names
}
}
#[cfg(test)]
mod tests {
use super::*;
fn group(levels: Vec<LodLevel>, cull_below: Option<f32>) -> LodGroup {
LodGroup {
levels,
transition: LodTransition::Discrete,
cull_below,
}
}
fn lvl(min_screen_size: f32) -> LodLevel {
LodLevel::new(MeshId::INVALID, min_screen_size)
}
#[test]
fn projected_size_shrinks_with_distance() {
let aabb = Aabb {
min: glam::Vec3::splat(-0.5),
max: glam::Vec3::splat(0.5),
};
let mut camera = RenderCamera::default();
camera.eye_position = [0.0, 0.0, 0.0];
camera.fov = std::f32::consts::FRAC_PI_4;
let near = glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, -5.0));
let far = glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, -50.0));
let near_size = projected_screen_size(&aabb, &near, &camera);
let far_size = projected_screen_size(&aabb, &far, &camera);
assert!(near_size > far_size);
assert!((near_size / far_size - 10.0).abs() < 0.5);
}
#[test]
fn projected_size_shrinks_with_wider_fov() {
let aabb = Aabb {
min: glam::Vec3::splat(-0.5),
max: glam::Vec3::splat(0.5),
};
let model = glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, -10.0));
let mut narrow = RenderCamera::default();
narrow.eye_position = [0.0, 0.0, 0.0];
narrow.fov = std::f32::consts::FRAC_PI_4;
let mut wide = narrow.clone();
wide.fov = std::f32::consts::FRAC_PI_2;
assert!(
projected_screen_size(&aabb, &model, &narrow)
> projected_screen_size(&aabb, &model, &wide)
);
}
#[test]
fn level_for_size_picks_finest_cleared() {
let g = group(vec![lvl(0.5), lvl(0.2), lvl(0.0)], None);
assert_eq!(g.level_for_size(0.9), 0);
assert_eq!(g.level_for_size(0.5), 0);
assert_eq!(g.level_for_size(0.3), 1);
assert_eq!(g.level_for_size(0.05), 2);
}
#[test]
fn select_holds_level_inside_hysteresis_band() {
let g = group(vec![lvl(0.5), lvl(0.2), lvl(0.0)], None);
assert_eq!(g.select(0.48, 0), 0);
assert_eq!(g.select(0.52, 1), 1);
assert_eq!(g.select(0.40, 0), 1);
assert_eq!(g.select(0.60, 1), 0);
}
#[test]
fn select_can_jump_multiple_levels() {
let g = group(vec![lvl(0.5), lvl(0.2), lvl(0.0)], None);
assert_eq!(g.select(0.01, 0), 2);
}
#[test]
fn should_cull_respects_threshold() {
let g = group(vec![lvl(0.5), lvl(0.0)], Some(0.05));
assert!(g.should_cull(0.01));
assert!(!g.should_cull(0.10));
let no_cull = group(vec![lvl(0.5), lvl(0.0)], None);
assert!(!no_cull.should_cull(0.0));
}
}
#[cfg(test)]
mod registration_tests {
use super::*;
use crate::DeviceResources;
use crate::geometry::primitives;
use crate::resources::{AttributeData, MeshData};
fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::LowPower,
compatible_surface: None,
force_fallback_adapter: false,
}))
.ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
}
fn with_attr(mut data: MeshData, name: &str) -> MeshData {
let count = data.positions.len();
data.attributes
.insert(name.to_string(), AttributeData::Vertex(vec![0.0; count]));
data
}
fn register(
res: &mut DeviceResources,
device: &wgpu::Device,
levels: &[(MeshData, f32)],
) -> ViewportResult<LodGroupId> {
let mut ids = Vec::with_capacity(levels.len());
let mut sizes = Vec::with_capacity(levels.len());
for (data, size) in levels {
ids.push(res.upload_mesh_data(device, data)?);
sizes.push(*size);
}
res.register_lod_group(&ids, &sizes)
}
#[test]
fn register_lod_group_round_trips() {
let Some((device, _queue)) = try_make_device() else {
eprintln!("skipping: no wgpu adapter available");
return;
};
let mut res = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
let id = register(
&mut res,
&device,
&[
(primitives::icosphere(1.0, 3), 0.5),
(primitives::icosphere(1.0, 1), 0.2),
(primitives::icosphere(1.0, 0), 0.0),
],
)
.expect("group should register");
let group = res.lod_group(id).expect("group present");
assert_eq!(group.level_count(), 3);
}
#[test]
fn thresholds_must_descend() {
let Some((device, _queue)) = try_make_device() else {
eprintln!("skipping: no wgpu adapter available");
return;
};
let mut res = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
let err = register(
&mut res,
&device,
&[(primitives::cube(1.0), 0.2), (primitives::cube(1.0), 0.5)],
);
assert!(matches!(
err,
Err(ViewportError::LodThresholdsNotDescending { level: 1 })
));
}
#[test]
fn mismatched_attributes_are_rejected() {
let Some((device, _queue)) = try_make_device() else {
eprintln!("skipping: no wgpu adapter available");
return;
};
let mut res = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
let err = register(
&mut res,
&device,
&[
(with_attr(primitives::cube(1.0), "temperature"), 0.5),
(primitives::cube(1.0), 0.0),
],
);
assert!(matches!(
err,
Err(ViewportError::LodLevelIncompatible { level: 1, .. })
));
}
#[test]
fn empty_group_is_rejected() {
let Some((device, _queue)) = try_make_device() else {
eprintln!("skipping: no wgpu adapter available");
return;
};
let mut res = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
let err = res.register_lod_group(&[], &[]);
assert!(matches!(err, Err(ViewportError::LodGroupEmpty)));
}
}