//! Reusable, windowless `OpenSim` scene rendering on stable `wgpu`. #![allow(clippy::missing_errors_doc)] type CameraBasis = ([f32; 3], [f32; 3], [f32; 3]); #[derive(Clone, Copy, Debug, PartialEq)] pub struct Camera { pub position: [f32; 3], pub forward: [f32; 3], pub up: [f32; 3], pub vertical_fov_degrees: f32, } impl Camera { pub fn look_at( position: [f32; 3], target: [f32; 3], up: [f32; 3], vertical_fov_degrees: f32, ) -> Result { let camera = Self { position, forward: normalize(sub(target, position))?, up: normalize(up)?, vertical_fov_degrees, }; camera.basis()?; Ok(camera) } fn basis(self) -> Result { if !(10.0..=140.0).contains(&self.vertical_fov_degrees) || self .position .iter() .chain(self.forward.iter()) .chain(self.up.iter()) .any(|value| !value.is_finite()) { return Err(RenderError::InvalidScene); } let forward = normalize(self.forward)?; let right = normalize(cross(forward, self.up))?; Ok((right, cross(right, forward), forward)) } } #[derive(Clone, Copy, Debug, PartialEq)] pub struct Vertex { pub position: [f32; 3], pub normal: [f32; 3], pub tex_coord: [f32; 2], pub color_srgb: [u8; 4], } #[derive(Clone, Debug, Default, PartialEq)] pub struct Mesh { pub vertices: Vec, pub indices: Vec, } #[derive(Clone, Debug, PartialEq)] pub struct Texture { pub width: u32, pub height: u32, pub rgba: Vec, } #[derive(Clone, Copy, Debug, PartialEq)] pub struct UvTransform { pub scale: [f32; 2], pub offset: [f32; 2], pub rotation_radians: f32, } impl Default for UvTransform { fn default() -> Self { Self { scale: [1.0; 2], offset: [0.0; 2], rotation_radians: 0.0, } } } #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct TextureSlot { pub texture: Option, pub transform: UvTransform, } #[derive(Clone, Copy, Debug, Default, PartialEq)] pub enum AlphaMode { #[default] Opaque, Mask { cutoff: f32, }, Blend, } #[derive(Clone, Debug, PartialEq)] pub struct BlinnPhongMaterial { pub diffuse_color_srgb: [u8; 4], pub diffuse: TextureSlot, pub normal: TextureSlot, pub specular: TextureSlot, pub specular_color_srgb: [u8; 3], pub shininess: f32, pub environment_intensity: f32, pub fullbright: bool, pub double_sided: bool, pub alpha_mode: AlphaMode, /// Optional diffuse-texture cutout applied before face-alpha blending. pub alpha_cutoff: Option, } impl Default for BlinnPhongMaterial { fn default() -> Self { Self { diffuse_color_srgb: [255; 4], diffuse: TextureSlot::default(), normal: TextureSlot::default(), specular: TextureSlot::default(), specular_color_srgb: [0; 3], shininess: 32.0, environment_intensity: 0.0, fullbright: false, double_sided: false, alpha_mode: AlphaMode::Opaque, alpha_cutoff: None, } } } #[derive(Clone, Debug, PartialEq)] pub struct PbrMaterial { pub base_color_srgb: [u8; 4], pub base_color: TextureSlot, pub normal: TextureSlot, /// glTF convention: roughness in G and metallic in B. pub metallic_roughness: TextureSlot, pub emissive: TextureSlot, pub metallic_factor: f32, pub roughness_factor: f32, pub emissive_factor_srgb: [u8; 3], pub double_sided: bool, pub alpha_mode: AlphaMode, } impl Default for PbrMaterial { fn default() -> Self { Self { base_color_srgb: [255; 4], base_color: TextureSlot::default(), normal: TextureSlot::default(), metallic_roughness: TextureSlot::default(), emissive: TextureSlot::default(), metallic_factor: 1.0, roughness_factor: 1.0, emissive_factor_srgb: [0; 3], double_sided: false, alpha_mode: AlphaMode::Opaque, } } } #[derive(Clone, Debug, PartialEq)] pub enum Material { BlinnPhong(BlinnPhongMaterial), Pbr(PbrMaterial), } impl Default for Material { fn default() -> Self { Self::BlinnPhong(BlinnPhongMaterial::default()) } } impl Material { #[must_use] pub fn alpha_mode(&self) -> AlphaMode { match self { Self::BlinnPhong(material) => material.alpha_mode, Self::Pbr(material) => material.alpha_mode, } } } #[derive(Clone, Debug, Default, PartialEq)] pub struct Renderable { pub mesh: Mesh, pub material: Material, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct RenderLimits { pub width: u32, pub height: u32, pub max_triangles: usize, pub max_texture_bytes: usize, pub max_texture_pixels: usize, pub far_distance: u32, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum RenderError { AdapterUnavailable, InvalidScene, ResourceLimit, TimedOut, Readback, Device(String), } impl std::fmt::Display for RenderError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(match self { Self::AdapterUnavailable => "wgpu adapter is unavailable", Self::InvalidScene => "render scene is invalid", Self::ResourceLimit => "render resource limit exceeded", Self::TimedOut => "wgpu render timed out", Self::Readback => "wgpu readback failed", Self::Device(_) => "wgpu device failed", })?; if let Self::Device(detail) = self { write!(formatter, ": {detail}")?; } Ok(()) } } impl std::error::Error for RenderError {} fn sub(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { std::array::from_fn(|axis| left[axis] - right[axis]) } fn normalize(value: [f32; 3]) -> Result<[f32; 3], RenderError> { let length = dot(value, value).sqrt(); if length < 0.0001 || !length.is_finite() { return Err(RenderError::InvalidScene); } Ok(value.map(|component| component / length)) } fn dot(left: [f32; 3], right: [f32; 3]) -> f32 { left[0] * right[0] + left[1] * right[1] + left[2] * right[2] } fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { [ left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0], ] } #[cfg(feature = "wgpu")] mod gpu; #[cfg(feature = "wgpu")] pub use gpu::{RenderTimings, RenderedFrame, Renderer}; #[cfg(test)] mod tests { use super::*; #[test] fn camera_and_both_material_models_are_explicit() { let camera = Camera::look_at([1.0, 2.0, 3.0], [5.0, 2.0, 3.0], [0.0, 0.0, 1.0], 60.0).unwrap(); assert!( camera .forward .iter() .zip([1.0, 0.0, 0.0]) .all(|(actual, expected)| (actual - expected).abs() < f32::EPSILON) ); assert!(Camera::look_at([0.0; 3], [0.0; 3], [0.0, 0.0, 1.0], 60.0).is_err()); assert!(matches!(Material::default(), Material::BlinnPhong(_))); assert!(matches!( Material::Pbr(PbrMaterial::default()), Material::Pbr(_) )); } }