288 lines
7.4 KiB
Rust
288 lines
7.4 KiB
Rust
//! 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<Self, RenderError> {
|
|
let camera = Self {
|
|
position,
|
|
forward: normalize(sub(target, position))?,
|
|
up: normalize(up)?,
|
|
vertical_fov_degrees,
|
|
};
|
|
camera.basis()?;
|
|
Ok(camera)
|
|
}
|
|
|
|
fn basis(self) -> Result<CameraBasis, RenderError> {
|
|
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<Vertex>,
|
|
pub indices: Vec<u32>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Texture {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub rgba: Vec<u8>,
|
|
}
|
|
|
|
#[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<usize>,
|
|
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<f32>,
|
|
}
|
|
|
|
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(_)
|
|
));
|
|
}
|
|
}
|