use glam::Vec3;
use super::{Camera3D, Mesh, Transform3D};
use crate::Color;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LodRenderer {
pub mesh: Mesh,
pub offset: Transform3D,
pub color: Color,
}
impl LodRenderer {
#[must_use]
pub fn new(mesh: Mesh) -> Self {
Self {
mesh,
offset: Transform3D::default(),
color: Color::WHITE,
}
}
#[must_use]
pub fn with_offset(self, offset: Transform3D) -> Self {
Self { offset, ..self }
}
#[must_use]
pub fn with_color(self, color: Color) -> Self {
Self { color, ..self }
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum LodShadow {
#[default]
Same,
Casters(Vec<LodRenderer>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct LodLevel {
pub screen_height: f32,
pub fade_width: f32,
pub renderers: Vec<LodRenderer>,
pub shadow: LodShadow,
}
impl LodLevel {
#[must_use]
pub fn new(screen_height: f32, renderers: Vec<LodRenderer>) -> Self {
Self {
screen_height,
fade_width: 0.0,
renderers,
shadow: LodShadow::Same,
}
}
#[must_use]
pub fn at_distance(distance: f32, size: f32, fov_y: f32, renderers: Vec<LodRenderer>) -> Self {
Self::new(LodGroup::screen_height_at(size, distance, fov_y), renderers)
}
#[must_use]
pub fn with_fade(self, width: f32) -> Self {
Self {
fade_width: width,
..self
}
}
#[must_use]
pub fn with_shadow(self, shadow: LodShadow) -> Self {
Self { shadow, ..self }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LodSettings {
pub bias: f32,
pub crossfade: bool,
}
impl Default for LodSettings {
fn default() -> Self {
Self {
bias: 1.0,
crossfade: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LodPick {
Culled,
Level(usize),
Fade {
from: usize,
to: Option<usize>,
blend: f32,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct LodGroup {
pub levels: Vec<LodLevel>,
pub size: f32,
pub center: Vec3,
}
impl LodGroup {
#[must_use]
pub fn new(size: f32, levels: Vec<LodLevel>) -> Self {
Self {
levels,
size,
center: Vec3::ZERO,
}
}
#[must_use]
pub fn with_center(self, center: Vec3) -> Self {
Self { center, ..self }
}
#[must_use]
pub fn screen_height_at(size: f32, distance: f32, fov_y: f32) -> f32 {
size / (2.0 * distance.max(1e-4) * (fov_y * 0.5).tan().max(1e-4))
}
#[must_use]
pub fn distance_at(size: f32, height: f32, fov_y: f32) -> f32 {
size / (2.0 * height.max(1e-6) * (fov_y * 0.5).tan().max(1e-4))
}
#[must_use]
pub fn screen_height(&self, transform: &Transform3D, camera: &Camera3D) -> f32 {
let center = transform.matrix().transform_point3(self.center);
let size = self.size * transform.scale.abs().max_element();
Self::screen_height_at(size, center.distance(camera.position), camera.fov_y)
}
#[must_use]
pub fn pick(&self, height: f32, settings: &LodSettings) -> LodPick {
const SNAP: f32 = 1.0 / 128.0;
let height = height * settings.bias.max(0.0);
let Some(i) = self.levels.iter().position(|l| height >= l.screen_height) else {
return LodPick::Culled;
};
let level = &self.levels[i];
let width = level.fade_width.clamp(0.0, 1.0);
if settings.crossfade && width > 0.0 {
let lower = level.screen_height;
let upper = if i == 0 { 1.0 } else { self.levels[i - 1].screen_height }.max(lower);
let top = lower + width * (upper - lower);
if height < top && top > lower {
let to = (i + 1 < self.levels.len()).then_some(i + 1);
let blend = 1.0 - (height - lower) / (top - lower);
return if blend < SNAP {
LodPick::Level(i)
} else if blend > 1.0 - SNAP {
to.map_or(LodPick::Culled, LodPick::Level)
} else {
LodPick::Fade { from: i, to, blend }
};
}
}
LodPick::Level(i)
}
#[must_use]
pub fn pick_for(&self, transform: &Transform3D, camera: &Camera3D, settings: &LodSettings) -> LodPick {
self.pick(self.screen_height(transform, camera), settings)
}
pub(crate) fn drawn(&self, pick: LodPick) -> [Option<(&LodLevel, f32)>; 2] {
match pick {
LodPick::Culled => [None, None],
LodPick::Level(i) => [self.levels.get(i).map(|l| (l, 0.0)), None],
LodPick::Fade { from, to, blend } => [
self.levels.get(from).map(|l| (l, -(1.0 - blend))),
to.and_then(|t| self.levels.get(t)).map(|l| (l, blend)),
],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn group(fade: f32) -> LodGroup {
let level = |h, f| LodLevel::new(h, vec![]).with_fade(f);
LodGroup::new(2.0, vec![level(0.5, fade), level(0.2, fade), level(0.05, fade)])
}
#[test]
fn levels_switch_at_their_thresholds() {
let g = group(0.0);
let s = LodSettings::default();
assert_eq!(g.pick(1.0, &s), LodPick::Level(0));
assert_eq!(g.pick(0.5, &s), LodPick::Level(0));
assert_eq!(g.pick(0.4999, &s), LodPick::Level(1));
assert_eq!(g.pick(0.2, &s), LodPick::Level(1));
assert_eq!(g.pick(0.0501, &s), LodPick::Level(2));
assert_eq!(g.pick(0.0499, &s), LodPick::Culled);
assert_eq!(g.pick(0.0, &s), LodPick::Culled);
}
#[test]
fn no_levels_cull_the_group() {
assert_eq!(
LodGroup::new(1.0, vec![]).pick(1.0, &LodSettings::default()),
LodPick::Culled
);
}
#[test]
fn bias_scales_the_height() {
let g = group(0.0);
let at = |bias| {
g.pick(
0.3,
&LodSettings {
bias,
..Default::default()
},
)
};
assert_eq!(at(1.0), LodPick::Level(1));
assert_eq!(at(2.0), LodPick::Level(0)); assert_eq!(at(0.5), LodPick::Level(2)); }
#[test]
fn a_smaller_bias_drops_detail_sooner() {
let g = group(0.0);
let low = LodSettings {
bias: 0.5,
..Default::default()
};
assert_eq!(g.pick(0.3, &low), LodPick::Level(2)); assert_eq!(g.pick(0.08, &low), LodPick::Culled); let off = LodSettings {
bias: 0.0,
..Default::default()
};
assert_eq!(g.pick(5.0, &off), LodPick::Culled);
}
#[test]
fn screen_height_follows_distance_fov_and_scale() {
let g = LodGroup::new(2.0, vec![]);
let mut camera = Camera3D::new(glam::Vec2::new(800.0, 600.0));
camera.look_at(Vec3::ZERO);
let height = |g: &LodGroup, t: &Transform3D, camera: &Camera3D| g.screen_height(t, camera);
let here = Transform3D::default();
camera.position = Vec3::new(0.0, 0.0, 10.0);
camera.fov_y = 60f32.to_radians();
let base = height(&g, &here, &camera);
assert!((base - 2.0 / (2.0 * 10.0 * 30f32.to_radians().tan())).abs() < 1e-5);
camera.position = Vec3::new(0.0, 0.0, 20.0);
assert!((height(&g, &here, &camera) - base / 2.0).abs() < 1e-5);
camera.position = Vec3::new(0.0, 0.0, 10.0);
camera.fov_y = 30f32.to_radians();
assert!(height(&g, &here, &camera) > base * 1.9);
camera.fov_y = 60f32.to_radians();
assert!((height(&g, &Transform3D::default().scaled(3.0), &camera) - base * 3.0).abs() < 1e-5);
let high = LodGroup::new(2.0, vec![]).with_center(Vec3::new(0.0, 0.0, 5.0));
assert!((height(&high, &here, &camera) - base * 2.0).abs() < 1e-5); }
#[test]
fn the_window_size_does_not_change_the_choice() {
let g = LodGroup::new(2.0, vec![LodLevel::new(0.1, vec![])]);
let mut camera = Camera3D::new(glam::Vec2::new(800.0, 600.0));
let before = g.screen_height(&Transform3D::default(), &camera);
camera.set_viewport(glam::Vec2::new(3840.0, 2160.0));
assert_eq!(g.screen_height(&Transform3D::default(), &camera), before);
camera.set_viewport(glam::Vec2::new(320.0, 240.0));
assert_eq!(g.screen_height(&Transform3D::default(), &camera), before);
}
#[test]
fn distances_and_heights_convert_both_ways() {
let fov = 50f32.to_radians();
for d in [1.0, 7.5, 120.0] {
let h = LodGroup::screen_height_at(3.0, d, fov);
assert!((LodGroup::distance_at(3.0, h, fov) - d).abs() < d * 1e-4);
}
let level = LodLevel::at_distance(40.0, 8.0, fov, vec![]);
assert!((LodGroup::distance_at(8.0, level.screen_height, fov) - 40.0).abs() < 0.01);
}
#[test]
fn crossfade_runs_over_the_band_above_the_threshold() {
let g = group(0.5);
let s = LodSettings::default();
let blend = |h| match g.pick(h, &s) {
LodPick::Fade { from, to, blend } => Some((from, to, blend)),
_ => None,
};
assert_eq!(blend(0.36), None);
let (from, to, b) = blend(0.3).unwrap();
assert_eq!((from, to), (1, Some(2)));
assert!((b - (1.0 - 0.1 / 0.15)).abs() < 1e-5, "{b}");
let (_, _, near_top) = blend(0.348).unwrap();
assert!(near_top < 0.02);
let (_, _, near_bottom) = blend(0.2015).unwrap();
assert!(near_bottom > 0.98);
let mut last = 0.0;
for k in 0..100 {
let h = 0.35 - 0.15 * k as f32 / 100.0;
if let Some((_, _, b)) = blend(h) {
assert!(b >= last, "{h}");
last = b;
}
}
assert_eq!(g.pick(0.19, &s), LodPick::Level(2));
}
#[test]
fn the_first_level_fades_within_the_top_of_the_screen() {
let g = group(0.4);
let s = LodSettings::default();
assert_eq!(g.pick(0.75, &s), LodPick::Level(0));
let LodPick::Fade { from, to, blend } = g.pick(0.6, &s) else {
panic!("fading");
};
assert_eq!((from, to), (0, Some(1)));
assert!((blend - 0.5).abs() < 1e-5);
}
#[test]
fn the_last_level_fades_out_to_nothing() {
let g = group(0.5);
let s = LodSettings::default();
let LodPick::Fade { from, to, blend } = g.pick(0.08, &s) else {
panic!("fading");
};
assert_eq!((from, to), (2, None));
assert!(blend > 0.0 && blend < 1.0);
assert_eq!(g.pick(0.049, &s), LodPick::Culled);
}
#[test]
fn crossfade_off_switches_at_once() {
let g = group(0.5);
let s = LodSettings {
crossfade: false,
..Default::default()
};
for k in 0..200 {
let h = k as f32 / 150.0;
assert!(!matches!(g.pick(h, &s), LodPick::Fade { .. }), "{h}");
}
}
#[test]
fn a_fade_draws_both_levels_with_complementary_dither() {
let g = group(0.5);
let LodPick::Fade { from, to, blend } = g.pick(0.3, &LodSettings::default()) else {
panic!("fading");
};
let [a, b] = g.drawn(LodPick::Fade { from, to, blend });
let (_, fa) = a.unwrap();
let (_, fb) = b.unwrap();
assert!(fa < 0.0 && fb > 0.0);
assert!((fa.abs() + fb - 1.0).abs() < 1e-6);
assert_eq!(g.drawn(LodPick::Level(1))[0].unwrap().1, 0.0);
assert!(g.drawn(LodPick::Culled)[0].is_none());
}
}