Strip core to render data and render graphs

Move glTF, picking, camera controls, and conventional handles into addons. Keep camera and material mutations in SIMD-aligned shared SOA rows and synchronize material updates directly into GPU buffers.

Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-19 09:41:41 +00:00
co-authored by heaust
parent 0e44917f9e
commit 6bbf8039e4
67 changed files with 3152 additions and 3669 deletions
-22
View File
@@ -1,5 +1,3 @@
cargo-features = ["profile-rustflags"]
[package]
name = "renderer"
description = "WGPU renderer core library"
@@ -14,38 +12,18 @@ default = []
atomics = []
bulk-memory = []
[profile.release]
opt-level = "z"
lto = true
[target.wasm32-unknown-unknown]
rustflags = [
"-Clink-args=--shared-memory",
"-Clink-args=--max-memory=1073741824",
"-Clink-args=--import-memory",
"-Clink-args=--export=__wasm_init_tls",
"-Clink-args=--export=__tls_size",
"-Clink-args=--export=__tls_align",
"-Clink-args=--export=__tls_base",
]
[dependencies]
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
console_error_panic_hook = { workspace = true }
console_log = { workspace = true }
log = { workspace = true }
wasm-logger = { workspace = true }
web-sys = { workspace = true }
js-sys = { workspace = true }
bytemuck = { workspace = true }
cgmath = { workspace = true }
raw-window-handle = { workspace = true }
wgpu = { workspace = true }
thiserror = { workspace = true }
ultraviolet = { workspace = true }
futures = { workspace = true }
gltf = { workspace = true }
image = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+6 -25
View File
@@ -7,11 +7,11 @@ use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::command_ring::CommandRing;
use crate::message::{MouseMessage, ResizeMessage, WheelMessage, WindowEvent};
use crate::platform::web::worker;
use crate::renderer::ResizeMessage;
thread_local! {
static WORKER_EVENTS: RefCell<Option<mpsc::Sender<WindowEvent>>> = const { RefCell::new(None) };
static WORKER_EVENTS: RefCell<Option<mpsc::Sender<ResizeMessage>>> = const { RefCell::new(None) };
}
/// Deliver a low-frequency browser event to the worker-owned renderer channel.
@@ -20,30 +20,11 @@ pub fn worker_window_event(kind: u32, values: js_sys::Float64Array) {
let values = values.to_vec();
let value = |index: usize| values.get(index).copied().unwrap_or_default();
let event = match kind {
0 => WindowEvent::Resize(ResizeMessage {
0 => ResizeMessage {
width: value(0),
height: value(1),
scale_factor: value(2),
}),
1 | 2 => {
let message = MouseMessage {
scale_factor: value(0),
buttons: value(1) as u16,
movement_x: value(2),
movement_y: value(3),
offset_x: value(4),
offset_y: value(5),
viewport_height: value(6),
};
if kind == 1 {
WindowEvent::PointerMove(message)
} else {
WindowEvent::PointerClick(message)
}
}
3 => WindowEvent::PointerWheel(WheelMessage {
delta_y_pixels: value(0) as f32,
}),
},
_ => return,
};
WORKER_EVENTS.with(|sender| {
@@ -54,7 +35,7 @@ pub fn worker_window_event(kind: u32, values: js_sys::Float64Array) {
}
/// Start the typed renderer and return its SAB command-ring pointer.
pub fn worker_entrypoint<T: crate::renderer::scene::Scene + 'static>(profile: bool) -> u32 {
pub fn worker_entrypoint() -> u32 {
let (sender, events) = mpsc::channel();
// The render worker owns this allocation for its entire lifetime. Publishing a
// stable address lets every connected thread use the same shared command ring.
@@ -62,7 +43,7 @@ pub fn worker_entrypoint<T: crate::renderer::scene::Scene + 'static>(profile: bo
let ring_ptr = ring.ptr();
WORKER_EVENTS.with(|worker_events| *worker_events.borrow_mut() = Some(sender));
spawn_local(async move {
worker::run_render_loop::<T>(events, ring, profile).await;
worker::run_render_loop(events, ring).await;
});
ring_ptr
}
-543
View File
@@ -1,543 +0,0 @@
use std::f32::consts::PI;
use ultraviolet::{projection, Bivec3, Mat4, Rotor3, Vec3};
use wgpu::util::DeviceExt;
use crate::renderer::scene::UniformResource;
/// A camera matrix cannot produce a safe, meaningful frustum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum FrustumError {
#[error("frustum plane {plane} contains a non-finite component")]
NonFinite { plane: usize },
#[error("frustum plane {plane} has a near-degenerate normal")]
Degenerate { plane: usize },
}
const MIN_DISTANCE: f32 = 0.1;
const MAX_PITCH: f32 = PI / 2.0 - 0.01;
const ORBIT_SENSITIVITY: f32 = 0.005;
const ZOOM_SENSITIVITY: f32 = 0.002;
#[repr(C)]
pub struct Camera {
// Hot data - cached computed matrix (64 bytes, 1 cache line)
pub view_proj: [[f32; 4]; 4],
// Warm data - frequently accessed vectors (36 bytes)
position: Vec3,
target: Vec3,
up: Vec3,
// Cold data - projection parameters (16 bytes)
fov: f32,
aspect_ratio: f32,
z_near: f32,
z_far: f32,
// Rotor orientation for orbit camera behaviour
rotor: Rotor3,
distance: f32,
// Dirty flag for lazy evaluation
dirty: bool,
}
struct OrthonormalBasis {
right: Vec3,
up: Vec3,
forward: Vec3,
}
impl OrthonormalBasis {
pub fn new(right: Vec3, up: Vec3, forward: Vec3) -> Self {
Self { right, up, forward }
}
pub fn from_camera(camera: &Camera) -> Self {
let mut forward_offset = camera.target - camera.position;
if forward_offset.mag_sq() <= f32::EPSILON {
forward_offset = -Vec3::unit_z();
}
let forward = forward_offset.normalized();
let mut right = forward.cross(camera.up);
// Check if right vector is near zero (forward and up are parallel)
if right.mag_sq() < 1e-10 {
// Try alternate axes to find a valid right vector
let alternate_axes = [Vec3::unit_y(), Vec3::unit_x()];
for axis in alternate_axes.iter() {
right = forward.cross(*axis);
if right.mag_sq() >= 1e-10 {
break;
}
}
}
right = right.normalized();
let up = right.cross(forward).normalized();
Self::new(right, up, forward)
}
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)]
pub struct CameraUniform {
view_proj: [[f32; 4]; 4],
}
impl Camera {
pub fn frustum_planes(&self) -> Result<[[f32; 4]; 6], FrustumError> {
extract_frustum_planes(self.view_proj)
}
pub fn new(aspect_ratio: f32) -> Self {
let mut camera = Camera {
view_proj: [[0.0; 4]; 4],
position: Vec3::new(0.0, 0.5, 3.0),
target: Vec3::new(0.0, 0.0, 0.0),
up: Vec3::unit_y(),
fov: PI / 3.0,
aspect_ratio,
z_near: 0.1,
z_far: 100000.0,
rotor: Rotor3::identity(),
distance: 1.0,
dirty: true,
};
camera.compute_rotor();
camera.compute_view_proj_mat();
camera
}
pub fn compute_view_proj_mat(&mut self) {
let view = Mat4::look_at(self.position, self.target, self.up);
let proj = projection::rh_yup::perspective_wgpu_dx(
self.fov,
self.aspect_ratio,
self.z_near,
self.z_far,
);
self.view_proj = (proj * view).into();
self.dirty = false;
}
pub fn look_at(&mut self, position: Vec3, target: Vec3) {
if !vec3_is_finite(position) || !vec3_is_finite(target) {
return;
}
self.position = position;
self.target = target;
if (self.position - self.target).mag_sq() <= f32::EPSILON {
self.position = self.target + Vec3::unit_z() * MIN_DISTANCE;
}
self.up = Vec3::unit_y();
self.compute_rotor();
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn set_depth_range(&mut self, z_near: f32, z_far: f32) {
self.z_near = z_near;
self.z_far = z_far.max(z_near + f32::EPSILON);
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn position(&self) -> Vec3 {
self.position
}
pub fn update_aspect_ratio(&mut self, aspect_ratio: f32) {
self.aspect_ratio = aspect_ratio;
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
if !delta_x.is_finite() || !delta_y.is_finite() {
return;
}
// Skip tiny movements to reduce unnecessary computations
if delta_x.abs() < 0.001 && delta_y.abs() < 0.001 {
return;
}
let yaw_theta = delta_x * ORBIT_SENSITIVITY;
let yaw_rotor =
Rotor3::from_angle_plane(yaw_theta, Bivec3::from_normalized_axis(Vec3::unit_y()));
let basis = OrthonormalBasis::from_camera(self);
let pitch_angle = (delta_y * ORBIT_SENSITIVITY).clamp(-MAX_PITCH, MAX_PITCH);
let pitch_rotor =
Rotor3::from_angle_plane(pitch_angle, Bivec3::from_normalized_axis(basis.right));
let orbit_rotor = (yaw_rotor * pitch_rotor).normalized();
self.rotor = (orbit_rotor * self.rotor).normalized();
let mut offset = self.position - self.target;
if offset.mag_sq() <= f32::EPSILON {
offset = Vec3::unit_z() * self.distance.max(MIN_DISTANCE);
}
orbit_rotor.rotate_vec(&mut offset);
self.distance = offset.mag().max(MIN_DISTANCE);
self.position = offset + self.target;
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn zoom(&mut self, delta_y_pixels: f32) {
if !delta_y_pixels.is_finite() || delta_y_pixels.abs() <= f32::EPSILON {
return;
}
let mut offset = self.position - self.target;
let mut current_distance = offset.mag();
if !current_distance.is_finite() {
return;
}
if current_distance <= f32::EPSILON {
offset = Vec3::unit_z() * MIN_DISTANCE;
current_distance = MIN_DISTANCE;
}
let direction = offset / current_distance;
let max_distance = (self.z_far * 0.95).max(MIN_DISTANCE);
if !max_distance.is_finite() {
return;
}
let candidate = f64::from(current_distance)
* (f64::from(ZOOM_SENSITIVITY) * f64::from(delta_y_pixels)).exp();
let new_distance = candidate.clamp(f64::from(MIN_DISTANCE), f64::from(max_distance)) as f32;
let new_position = self.target + direction * new_distance;
if !vec3_is_finite(new_position) {
return;
}
self.position = new_position;
self.distance = new_distance;
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
if !delta_x.is_finite()
|| !delta_y.is_finite()
|| !viewport_height.is_finite()
|| !self.fov.is_finite()
{
return;
}
let distance = (self.position - self.target).mag().max(MIN_DISTANCE);
if !distance.is_finite() {
return;
}
let world_units_per_pixel =
2.0 * distance * (self.fov * 0.5).tan() / viewport_height.max(1.0);
let basis = OrthonormalBasis::from_camera(self);
let translation = (-basis.right * delta_x + basis.up * delta_y) * world_units_per_pixel;
let position = self.position + translation;
let target = self.target + translation;
if !vec3_is_finite(position) || !vec3_is_finite(target) {
return;
}
self.position = position;
self.target = target;
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn create_uniform_resource(&self, device: &wgpu::Device) -> UniformResource {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: "camera uniform buffer".into(),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
contents: bytemuck::cast_slice(&[self.view_proj]),
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Camera bind group layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Camera bind group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
UniformResource {
buffer,
bind_group,
bind_group_layout,
}
}
fn compute_rotor(&mut self) {
let offset = self.position - self.target;
let distance = (offset.x * offset.x + offset.y * offset.y + offset.z * offset.z).sqrt();
self.distance = distance.max(MIN_DISTANCE);
// to compute the initial rotor we will do two rotations
// these will orient the camera to the new coordinates
//
// but first we need the orthonormal basis for the current camera
let basis = OrthonormalBasis::from_camera(self);
// first rotation
// this is the swing to make position face the target
let camera_local_up = Vec3::unit_z();
let swing_rotor = Rotor3::from_rotation_between(camera_local_up, -basis.forward);
// now we need a twist rotor which aligns the camera up
let mut up_after_swing = self.up.clone();
swing_rotor.rotate_vec(&mut up_after_swing);
// to rotate a vector by a rotor we need
// - a bivector (represents the axis of rotation)
// - angle of rotation
let twist_axis = (-basis.forward).normalized();
let twist_plane = Bivec3::from_normalized_axis(twist_axis);
// Calculate twist angle between the up vectors:
// u1 × uc ⋅ (-f)
// θ = atan2( ————————————— , u1 ⋅ uc )
// ‖u1 × uc‖
//
// Where:
// u1 = up vector after swing rotation
// uc = camera's current up vector
// f = forward vector (twist axis)
let theta = up_after_swing
.cross(self.up)
.dot(twist_axis)
.atan2(up_after_swing.dot(self.up));
let twist_rotor = Rotor3::from_angle_plane(theta, twist_plane);
self.rotor = (swing_rotor * twist_rotor).normalized();
}
}
/// Extracts inward-facing normalized WebGPU clip-space planes (zero-to-one depth).
pub fn extract_frustum_planes(m: [[f32; 4]; 4]) -> Result<[[f32; 4]; 6], FrustumError> {
let row = |r: usize| [m[0][r], m[1][r], m[2][r], m[3][r]];
let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]];
let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]];
let r0 = row(0);
let r1 = row(1);
let r2 = row(2);
let r3 = row(3);
let mut planes = [
add(r3, r0),
sub(r3, r0),
add(r3, r1),
sub(r3, r1),
r2,
sub(r3, r2),
];
for (plane, p) in planes.iter_mut().enumerate() {
if !p.iter().all(|component| component.is_finite()) {
return Err(FrustumError::NonFinite { plane });
}
// Scale first: directly squaring very large/small coefficients can overflow or
// underflow even though the plane itself is normalizable.
let scale = p[0].abs().max(p[1].abs()).max(p[2].abs());
if scale < f32::MIN_POSITIVE {
return Err(FrustumError::Degenerate { plane });
}
let scaled = [p[0] / scale, p[1] / scale, p[2] / scale];
let length = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).sqrt();
for v in p {
*v = (*v / scale) / length;
if !v.is_finite() {
return Err(FrustumError::NonFinite { plane });
}
}
}
Ok(planes)
}
fn vec3_is_finite(value: Vec3) -> bool {
value.x.is_finite() && value.y.is_finite() && value.z.is_finite()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frustum_extraction_rejects_nonfinite_and_degenerate_planes() {
let mut nonfinite = Camera::new(1.0).view_proj;
nonfinite[0][0] = f32::NAN;
assert!(matches!(
extract_frustum_planes(nonfinite),
Err(FrustumError::NonFinite { .. })
));
assert!(matches!(
extract_frustum_planes([[0.0; 4]; 4]),
Err(FrustumError::Degenerate { .. })
));
}
#[test]
fn frustum_extraction_normalizes_without_overflow() {
let mut matrix = Camera::new(1.0).view_proj;
for value in matrix.iter_mut().flatten() {
*value *= 1.0e20;
}
let planes = extract_frustum_planes(matrix).unwrap();
for plane in planes {
let length = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
assert!((length - 1.0).abs() < 1.0e-5);
assert!(plane.iter().all(|value| value.is_finite()));
}
}
fn assert_vec3_close(actual: Vec3, expected: Vec3, epsilon: f32) {
assert!(
(actual.x - expected.x).abs() <= epsilon,
"x: {actual:?} != {expected:?}"
);
assert!(
(actual.y - expected.y).abs() <= epsilon,
"y: {actual:?} != {expected:?}"
);
assert!(
(actual.z - expected.z).abs() <= epsilon,
"z: {actual:?} != {expected:?}"
);
}
fn assert_camera_finite(camera: &Camera) {
assert!(vec3_is_finite(camera.position));
assert!(vec3_is_finite(camera.target));
assert!(camera.distance.is_finite());
assert!(camera
.view_proj
.iter()
.flatten()
.all(|component| component.is_finite()));
}
#[test]
fn zoom_is_multiplicative_and_preserves_target() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
let initial_position = camera.position;
let initial_target = camera.target;
let initial_distance = camera.distance;
camera.zoom(-100.0);
assert!(camera.distance < initial_distance);
assert_eq!(camera.target, initial_target);
camera.zoom(100.0);
assert_vec3_close(camera.position, initial_position, 1e-4);
assert!((camera.distance - initial_distance).abs() <= 1e-4);
camera.zoom(100.0);
assert!(camera.distance > initial_distance);
assert_eq!(camera.target, initial_target);
}
#[test]
fn zoom_clamps_and_remains_finite() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
camera.zoom(f32::NEG_INFINITY);
assert!((camera.distance - 10.0).abs() <= 1e-5);
camera.zoom(-f32::MAX);
assert!((camera.distance - MIN_DISTANCE).abs() <= f32::EPSILON);
camera.zoom(f32::MAX);
assert!(camera.distance <= camera.z_far * 0.95);
assert_camera_finite(&camera);
}
#[test]
fn pan_moves_eye_and_target_equally_at_target_plane_scale() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
let initial_position = camera.position;
let initial_target = camera.target;
let initial_offset = initial_position - initial_target;
let units = 2.0 * 10.0 * (PI / 6.0).tan() / 1000.0;
camera.pan(20.0, 10.0, 1000.0);
let translation = Vec3::new(-20.0 * units, 10.0 * units, 0.0);
assert_vec3_close(camera.position, initial_position + translation, 1e-5);
assert_vec3_close(camera.target, initial_target + translation, 1e-5);
assert_vec3_close(camera.position - camera.target, initial_offset, 1e-5);
assert!((camera.distance - 10.0).abs() <= 1e-5);
}
#[test]
fn pan_scale_is_proportional_to_distance() {
let mut near = Camera::new(1.0);
near.look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::zero());
let mut far = Camera::new(1.0);
far.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
near.pan(10.0, 0.0, 1000.0);
far.pan(10.0, 0.0, 1000.0);
assert!((far.target.mag() / near.target.mag() - 2.0).abs() <= 1e-5);
}
#[test]
fn orbit_preserves_target_and_distance() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(2.0, 3.0, 10.0), Vec3::new(1.0, -1.0, 0.5));
let initial_target = camera.target;
let initial_distance = (camera.position - camera.target).mag();
camera.orbit(40.0, -25.0);
assert_eq!(camera.target, initial_target);
assert!(((camera.position - camera.target).mag() - initial_distance).abs() <= 1e-5);
assert!((camera.distance - initial_distance).abs() <= 1e-5);
assert!(camera.distance >= MIN_DISTANCE);
assert_camera_finite(&camera);
}
#[test]
fn controls_reject_invalid_input_and_recover_degenerate_look_at() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::zero(), Vec3::zero());
assert!((camera.position - camera.target).mag() >= MIN_DISTANCE);
let position = camera.position;
let target = camera.target;
camera.orbit(f32::NAN, 1.0);
camera.zoom(f32::INFINITY);
camera.pan(f32::NAN, 1.0, 0.0);
assert_eq!(camera.position, position);
assert_eq!(camera.target, target);
camera.pan(1.0, 1.0, 0.0);
camera.orbit(4.0, -3.0);
camera.zoom(2.0);
assert_camera_finite(&camera);
}
}
-774
View File
@@ -1,774 +0,0 @@
use std::collections::HashMap;
use gltf::Gltf;
use ultraviolet::{Mat4, Vec3};
use crate::render_data::{
InstanceHandle, InstanceType, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform,
RenderData, RenderDataError,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AlphaMode {
#[default]
Opaque,
Mask,
Blend,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TextureReference {
pub texture: usize,
pub tex_coord: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Material {
pub key: MaterialKey,
pub base_color_factor: [f32; 4],
pub metallic_factor: f32,
pub roughness_factor: f32,
pub emissive_factor: [f32; 3],
/// Index of refraction for the dielectric Fresnel response.
pub ior: f32,
pub alpha_mode: AlphaMode,
pub alpha_cutoff: f32,
pub double_sided: bool,
pub base_color_texture: Option<TextureReference>,
pub metallic_roughness_texture: Option<TextureReference>,
pub normal_texture: Option<TextureReference>,
pub normal_scale: f32,
pub occlusion_texture: Option<TextureReference>,
pub occlusion_strength: f32,
pub emissive_texture: Option<TextureReference>,
}
impl Default for Material {
fn default() -> Self {
Self {
key: MaterialKey::DEFAULT,
base_color_factor: [1.0; 4],
metallic_factor: 1.0,
roughness_factor: 1.0,
emissive_factor: [0.0; 3],
ior: 1.5,
alpha_mode: AlphaMode::Opaque,
alpha_cutoff: 0.5,
double_sided: false,
base_color_texture: None,
metallic_roughness_texture: None,
normal_texture: None,
normal_scale: 1.0,
occlusion_texture: None,
occlusion_strength: 1.0,
emissive_texture: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TextureMetadata {
pub image: usize,
pub sampler: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SamplerMetadata {
pub index: usize,
pub mag_filter: Option<String>,
pub min_filter: Option<String>,
pub wrap_s: String,
pub wrap_t: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ImageSource {
Uri(String),
BufferView(usize),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImageMetadata {
pub index: usize,
pub name: Option<String>,
pub mime_type: Option<String>,
pub source: ImageSource,
/// Encoded PNG/JPEG bytes. Kept encoded so GPU installation can decode and
/// upload images one at a time instead of retaining a decoded image batch.
pub encoded_data: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct InstalledScene {
pub meshes: Vec<MeshHandle>,
pub instances: Vec<InstanceHandle>,
pub bounds: Option<ModelBounds>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ModelBounds {
pub min: [f32; 3],
pub max: [f32; 3],
}
impl ModelBounds {
fn include(&mut self, p: [f32; 3]) {
for i in 0..3 {
self.min[i] = self.min[i].min(p[i]);
self.max[i] = self.max[i].max(p[i]);
}
}
}
fn focus_bounds(points: &[[f32; 3]]) -> Option<ModelBounds> {
let first = *points.first()?;
if points.len() < 200 {
let mut bounds = ModelBounds {
min: first,
max: first,
};
for point in &points[1..] {
bounds.include(*point);
}
return Some(bounds);
}
let trim = points.len() / 100;
let mut min = [0.0; 3];
let mut max = [0.0; 3];
for axis in 0..3 {
let mut values: Vec<_> = points.iter().map(|point| point[axis]).collect();
values.sort_by(f32::total_cmp);
min[axis] = values[trim];
max[axis] = values[values.len() - trim - 1];
}
Some(ModelBounds { min, max })
}
#[derive(Debug, thiserror::Error)]
pub enum ImportError {
#[error("failed to decode bytes")]
GltfParse(#[from] gltf::Error),
#[error("unsupported or malformed primitive: {0}")]
InvalidPrimitive(String),
#[error("unsupported image source: {0}")]
UnsupportedImage(String),
#[error("invalid KHR_materials_ior value: {0}")]
InvalidIor(f32),
#[error("failed to install imported scene")]
Install(#[from] RenderDataError),
}
fn decode_ior(value: Option<f32>) -> Result<f32, ImportError> {
let ior = value.unwrap_or(1.5);
if ior == 0.0 || (ior.is_finite() && ior >= 1.0) {
Ok(ior)
} else {
Err(ImportError::InvalidIor(ior))
}
}
#[derive(Clone, Debug)]
pub struct ImportedGeometry {
pub key: (usize, usize),
pub material: MaterialKey,
pub double_sided: bool,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub tangents: Vec<[f32; 4]>,
pub uvs: Vec<[f32; 2]>,
pub indices: Vec<u32>,
}
#[derive(Clone, Debug)]
pub struct ImportedOccurrence {
pub key: (usize, usize),
pub transform: ModelTransform,
}
#[derive(Clone, Debug, Default)]
pub struct ImportedScene {
pub geometries: Vec<ImportedGeometry>,
pub occurrences: Vec<ImportedOccurrence>,
pub materials: Vec<Material>,
pub textures: Vec<TextureMetadata>,
pub samplers: Vec<SamplerMetadata>,
pub images: Vec<ImageMetadata>,
}
fn texture_reference(info: gltf::texture::Info<'_>) -> TextureReference {
TextureReference {
texture: info.texture().index(),
tex_coord: info.tex_coord(),
}
}
fn normalize(value: [f32; 3], fallback: [f32; 3]) -> [f32; 3] {
let length = value.iter().map(|x| x * x).sum::<f32>().sqrt();
if length > f32::EPSILON && length.is_finite() {
value.map(|x| x / length)
} else {
fallback
}
}
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
/// Completes triangle vertex attributes. Generated attributes use corner vertices,
/// which deliberately splits UV seams, hard normal edges, and opposite handedness.
fn repair_geometry(
positions: Vec<[f32; 3]>,
normals: Option<Vec<[f32; 3]>>,
tangents: Option<Vec<[f32; 4]>>,
uvs: Vec<[f32; 2]>,
indices: Vec<u32>,
) -> Result<
(
Vec<[f32; 3]>,
Vec<[f32; 3]>,
Vec<[f32; 4]>,
Vec<[f32; 2]>,
Vec<u32>,
),
ImportError,
> {
if indices.len() % 3 != 0 || indices.iter().any(|&i| i as usize >= positions.len()) {
return Err(ImportError::InvalidPrimitive(
"triangle indices are malformed".into(),
));
}
let normals_valid = normals.as_ref().is_some_and(|x| x.len() == positions.len());
let tangents_valid = tangents
.as_ref()
.is_some_and(|x| x.len() == positions.len());
if normals_valid && tangents_valid {
return Ok((positions, normals.unwrap(), tangents.unwrap(), uvs, indices));
}
let mut out_p = Vec::with_capacity(indices.len());
let mut out_n = Vec::with_capacity(indices.len());
let mut out_t = Vec::with_capacity(indices.len());
let mut out_uv = Vec::with_capacity(indices.len());
for triangle in indices.chunks_exact(3) {
let ids = [
triangle[0] as usize,
triangle[1] as usize,
triangle[2] as usize,
];
let p = ids.map(|i| positions[i]);
let uv = ids.map(|i| uvs.get(i).copied().unwrap_or([0.0; 2]));
let face_normal = normalize(cross(sub(p[1], p[0]), sub(p[2], p[0])), [0.0, 1.0, 0.0]);
let duv1 = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
let duv2 = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
let determinant = duv1[0] * duv2[1] - duv1[1] * duv2[0];
let edge1 = sub(p[1], p[0]);
let edge2 = sub(p[2], p[0]);
let (raw_tangent, raw_bitangent) =
if determinant.abs() > f32::EPSILON && determinant.is_finite() {
let r = determinant.recip();
(
std::array::from_fn(|i| (edge1[i] * duv2[1] - edge2[i] * duv1[1]) * r),
std::array::from_fn(|i| (edge2[i] * duv1[0] - edge1[i] * duv2[0]) * r),
)
} else {
([0.0; 3], [0.0; 3])
};
for corner in 0..3 {
let n = normals
.as_ref()
.filter(|_| normals_valid)
.map_or(face_normal, |x| x[ids[corner]]);
let n = normalize(n, face_normal);
let projected = std::array::from_fn(|i| raw_tangent[i] - n[i] * dot(n, raw_tangent));
let fallback_axis = if n[0].abs() < 0.9 {
[1.0, 0.0, 0.0]
} else {
[0.0, 1.0, 0.0]
};
let tangent3 = normalize(
projected,
normalize(cross(fallback_axis, n), [0.0, 0.0, 1.0]),
);
let generated = [
tangent3[0],
tangent3[1],
tangent3[2],
if dot(cross(n, tangent3), raw_bitangent) < 0.0 {
-1.0
} else {
1.0
},
];
out_p.push(p[corner]);
out_n.push(n);
out_t.push(
tangents
.as_ref()
.filter(|_| tangents_valid)
.map_or(generated, |x| x[ids[corner]]),
);
out_uv.push(uv[corner]);
}
}
let out_i = (0..u32::try_from(out_p.len())
.map_err(|_| ImportError::InvalidPrimitive("vertex count exceeds u32".into()))?)
.collect();
Ok((out_p, out_n, out_t, out_uv, out_i))
}
pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
decode_gltf_model(Gltf::from_slice(bytes)?)
}
pub fn decode_gltf_owned(bytes: Vec<u8>) -> Result<ImportedScene, ImportError> {
let model = Gltf::from_slice(&bytes)?;
drop(bytes);
decode_gltf_model(model)
}
fn decode_gltf_model(mut model: Gltf) -> Result<ImportedScene, ImportError> {
// Reject external images before buffer import can turn them into a generic
// import error (or attempt to interpret a data/external URI).
for image in model.images() {
if let gltf::image::Source::Uri { uri, .. } = image.source() {
return Err(ImportError::UnsupportedImage(format!(
"URI/external image '{uri}'"
)));
}
}
let blob = model.blob.take();
let buffers = gltf::import_buffers(&model.document, None, blob)?;
let mut result = ImportedScene::default();
result.materials.push(Material::default());
for material in model.materials() {
let pbr = material.pbr_metallic_roughness();
let normal = material.normal_texture();
let normal_texture = normal.as_ref().map(|x| TextureReference {
texture: x.texture().index(),
tex_coord: x.tex_coord(),
});
let occlusion = material.occlusion_texture();
let occlusion_texture = occlusion.as_ref().map(|x| TextureReference {
texture: x.texture().index(),
tex_coord: x.tex_coord(),
});
result.materials.push(Material {
key: MaterialKey::new(material.index().unwrap() as u32 + 1),
base_color_factor: pbr.base_color_factor(),
metallic_factor: pbr.metallic_factor(),
roughness_factor: pbr.roughness_factor(),
emissive_factor: material.emissive_factor(),
ior: decode_ior(material.ior())?,
alpha_mode: match material.alpha_mode() {
gltf::material::AlphaMode::Opaque => AlphaMode::Opaque,
gltf::material::AlphaMode::Mask => AlphaMode::Mask,
gltf::material::AlphaMode::Blend => AlphaMode::Blend,
},
alpha_cutoff: material.alpha_cutoff().unwrap_or(0.5),
double_sided: material.double_sided(),
base_color_texture: pbr.base_color_texture().map(texture_reference),
metallic_roughness_texture: pbr.metallic_roughness_texture().map(texture_reference),
normal_texture,
normal_scale: normal.map_or(1.0, |x| x.scale()),
occlusion_texture,
occlusion_strength: occlusion.map_or(1.0, |x| x.strength()),
emissive_texture: material.emissive_texture().map(texture_reference),
});
}
result.textures = model
.textures()
.map(|x| TextureMetadata {
image: x.source().index(),
sampler: x.sampler().index(),
})
.collect();
result.samplers = model
.samplers()
.map(|x| SamplerMetadata {
index: x.index().unwrap(),
mag_filter: x.mag_filter().map(|v| format!("{v:?}")),
min_filter: x.min_filter().map(|v| format!("{v:?}")),
wrap_s: format!("{:?}", x.wrap_s()),
wrap_t: format!("{:?}", x.wrap_t()),
})
.collect();
result.images = model
.images()
.map(|x| -> Result<_, ImportError> {
let (source, mime_type, encoded_data) = match x.source() {
gltf::image::Source::Uri { uri, .. } => {
return Err(ImportError::UnsupportedImage(format!(
"URI/external image '{uri}'"
)))
}
gltf::image::Source::View { view, mime_type } => {
let data = buffers.get(view.buffer().index()).ok_or_else(|| {
ImportError::UnsupportedImage("image buffer is missing".into())
})?;
let end = view.offset().checked_add(view.length()).ok_or_else(|| {
ImportError::UnsupportedImage("image bufferView overflows".into())
})?;
let bytes = data.0.get(view.offset()..end).ok_or_else(|| {
ImportError::UnsupportedImage("image bufferView is out of bounds".into())
})?;
(
ImageSource::BufferView(view.index()),
Some(mime_type.to_owned()),
bytes.to_vec(),
)
}
};
Ok(ImageMetadata {
index: x.index(),
name: x.name().map(str::to_owned),
mime_type,
source,
encoded_data,
})
})
.collect::<Result<_, _>>()?;
let mut seen = HashMap::new();
fn visit(
node: gltf::Node<'_>,
parent: Mat4,
buffers: &[gltf::buffer::Data],
result: &mut ImportedScene,
seen: &mut HashMap<(usize, usize), ()>,
) -> Result<(), ImportError> {
let world = parent * Mat4::from(node.transform().matrix());
if let Some(mesh) = node.mesh() {
for primitive in mesh.primitives() {
if primitive.mode() != gltf::mesh::Mode::Triangles {
return Err(ImportError::InvalidPrimitive(
"only triangle primitives are supported".into(),
));
}
let key = (mesh.index(), primitive.index());
if seen.insert(key, ()).is_none() {
let reader = primitive
.reader(|buffer| buffers.get(buffer.index()).map(|data| data.0.as_slice()));
let Some(read_positions) = reader.read_positions() else {
continue;
};
let positions: Vec<_> = read_positions.collect();
let count = positions.len();
if count == 0 {
continue;
}
let normals = reader.read_normals().map(|x| x.collect());
let tangents = reader.read_tangents().map(|x| x.collect());
let mut uvs: Vec<_> = reader
.read_tex_coords(0)
.map(|x| x.into_f32().collect())
.unwrap_or_default();
uvs.resize(count, [0., 0.]);
uvs.truncate(count);
let indices: Vec<u32> = if let Some(indices) = reader.read_indices() {
indices.into_u32().collect()
} else {
let count = u32::try_from(count).map_err(|_| {
ImportError::InvalidPrimitive("vertex count exceeds u32".into())
})?;
(0..count).collect()
};
if indices.is_empty() {
continue;
}
let (positions, normals, tangents, uvs, indices) =
repair_geometry(positions, normals, tangents, uvs, indices)?;
let primitive_material = primitive.material();
result.geometries.push(ImportedGeometry {
key,
material: primitive_material
.index()
.map_or(MaterialKey::DEFAULT, |index| {
MaterialKey::new(index as u32 + 1)
}),
double_sided: primitive_material.double_sided(),
positions,
normals,
tangents,
uvs,
indices,
});
} else if !result.geometries.iter().any(|geometry| geometry.key == key) {
continue;
}
result.occurrences.push(ImportedOccurrence {
key,
transform: world.into(),
});
}
}
for child in node.children() {
visit(child, world, buffers, result, seen)?
}
Ok(())
}
for scene in model.scenes() {
for node in scene.nodes() {
visit(node, Mat4::identity(), &buffers, &mut result, &mut seen)?
}
}
Ok(result)
}
pub fn install_imported(
target: &mut RenderData,
imported: &ImportedScene,
) -> Result<InstalledScene, ImportError> {
let mut stage = target.replacement_stage()?;
let mut handles = HashMap::new();
let mut mesh_handles = Vec::with_capacity(imported.geometries.len());
let mut instance_handles = Vec::new();
let mut first = HashMap::new();
for occurrence in &imported.occurrences {
first.entry(occurrence.key).or_insert(occurrence.transform);
}
for geometry in &imported.geometries {
let transform = *first
.get(&geometry.key)
.ok_or_else(|| ImportError::InvalidPrimitive("geometry has no occurrence".into()))?;
let created = stage.create_mesh(MeshCreateInfo {
positions: &geometry.positions,
normals: &geometry.normals,
tangents: &geometry.tangents,
uvs: &geometry.uvs,
indices: &geometry.indices,
material: geometry.material,
default_instance_type: InstanceType {
words: [
1 | 4 | (geometry.double_sided as u32) * 8,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
},
default_transform: transform,
})?;
handles.insert(geometry.key, created.mesh);
mesh_handles.push(created.mesh);
instance_handles.push(created.default_instance);
}
let mut consumed = HashMap::new();
let mut bounds: Option<ModelBounds> = None;
let geometries: HashMap<_, _> = imported
.geometries
.iter()
.map(|geometry| (geometry.key, geometry))
.collect();
let mut focus_points = Vec::new();
for occurrence in &imported.occurrences {
let mesh = *handles
.get(&occurrence.key)
.ok_or_else(|| ImportError::InvalidPrimitive("occurrence has no geometry".into()))?;
if consumed.insert(occurrence.key, ()).is_some() {
let instance_type = stage.mesh(mesh).unwrap().default_instance_type;
instance_handles.push(stage.create_instance(
mesh,
occurrence.transform,
instance_type,
)?);
}
let geometry = geometries
.get(&occurrence.key)
.expect("installed occurrence must have geometry");
let transform = Mat4::from(occurrence.transform);
focus_points.extend(geometry.positions.iter().map(|position| {
let point = transform.transform_point3(Vec3::from(*position));
[point.x, point.y, point.z]
}));
let local = stage.mesh(mesh).unwrap().local_aabb;
for x in [local.min[0], local.max[0]] {
for y in [local.min[1], local.max[1]] {
for z in [local.min[2], local.max[2]] {
let p = Mat4::from(occurrence.transform).transform_point3(Vec3::new(x, y, z));
let p = [p.x, p.y, p.z];
if let Some(b) = bounds.as_mut() {
b.include(p)
} else {
bounds = Some(ModelBounds { min: p, max: p })
}
}
}
}
}
bounds = focus_bounds(&focus_points).or(bounds);
target.replace_with(stage)?;
Ok(InstalledScene {
meshes: mesh_handles,
instances: instance_handles,
bounds,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uri_images_are_rejected_explicitly() {
let json = br#"{
"asset":{"version":"2.0"},
"images":[{"uri":"external.png"}],
"scenes":[{"nodes":[]}],"scene":0
}"#;
let result = decode_gltf(json);
assert!(
matches!(
result,
Err(ImportError::UnsupportedImage(ref message)) if message.contains("URI/external")
),
"unexpected result: {result:?}"
);
}
#[test]
fn owned_and_borrowed_decode_paths_remain_compatible() {
let json = br#"{
"asset":{"version":"2.0"},
"scenes":[{"nodes":[]}],"scene":0
}"#;
let borrowed = decode_gltf(json).unwrap();
let owned = decode_gltf_owned(json.to_vec()).unwrap();
assert_eq!(borrowed.geometries.len(), owned.geometries.len());
assert_eq!(borrowed.occurrences.len(), owned.occurrences.len());
assert_eq!(borrowed.materials.len(), owned.materials.len());
}
#[test]
fn repair_duplicates_corners_and_generates_flat_finite_frames() {
let positions = vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
];
let repaired = repair_geometry(
positions,
None,
None,
vec![[0.0; 2]; 4],
vec![0, 1, 2, 0, 3, 1],
)
.unwrap();
assert_eq!(repaired.0.len(), 6);
assert_eq!(repaired.4, (0..6).collect::<Vec<_>>());
assert_eq!(&repaired.1[..3], &[[0.0, 0.0, 1.0]; 3]);
assert_eq!(&repaired.1[3..], &[[0.0, 1.0, 0.0]; 3]);
assert!(repaired
.2
.iter()
.flatten()
.all(|component| component.is_finite()));
assert!(repaired.2.iter().all(|tangent| tangent[3].abs() == 1.0));
}
#[test]
fn generated_tangents_split_opposite_handedness_and_supplied_values_survive() {
let positions = vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[-1.0, 0.0, 0.0],
];
let normals = vec![[0.0, 0.0, 1.0]; 4];
let generated = repair_geometry(
positions.clone(),
Some(normals.clone()),
None,
vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 0.0]],
vec![0, 1, 2, 0, 2, 3],
)
.unwrap();
assert_ne!(generated.2[0][3], generated.2[3][3]);
let supplied = vec![[0.25, 0.5, 0.75, -1.0]; 4];
let preserved = repair_geometry(
positions,
Some(normals),
Some(supplied.clone()),
vec![[0.0; 2]; 4],
vec![0, 1, 2],
)
.unwrap();
assert_eq!(preserved.2, supplied);
}
#[test]
fn material_defaults_match_gltf_core_defaults() {
let material = Material::default();
assert_eq!(material.base_color_factor, [1.0; 4]);
assert_eq!(material.metallic_factor, 1.0);
assert_eq!(material.roughness_factor, 1.0);
assert_eq!(material.alpha_cutoff, 0.5);
assert_eq!(material.ior, 1.5);
assert_eq!(material.key, MaterialKey::DEFAULT);
}
#[test]
fn imports_khr_materials_ior() {
let json = br#"{
"asset":{"version":"2.0"},
"extensionsUsed":["KHR_materials_ior"],
"materials":[{"extensions":{"KHR_materials_ior":{"ior":1.33}}}],
"scenes":[{"nodes":[]}],"scene":0
}"#;
let imported = decode_gltf(json).unwrap();
assert_eq!(imported.materials[1].ior, 1.33);
}
#[test]
fn ior_gate_accepts_default_physical_values_and_explicit_zero_sentinel() {
assert_eq!(decode_ior(None).unwrap(), 1.5);
assert_eq!(decode_ior(Some(1.0)).unwrap(), 1.0);
assert_eq!(decode_ior(Some(1.33)).unwrap(), 1.33);
assert_eq!(decode_ior(Some(0.0)).unwrap(), 0.0);
}
#[test]
fn ior_gate_rejects_nonphysical_and_nonfinite_values() {
for ior in [-1.0, 0.5, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
assert!(matches!(
decode_ior(Some(ior)),
Err(ImportError::InvalidIor(_))
));
}
}
#[test]
fn malformed_but_parseable_json_ior_is_rejected_before_packing() {
for ior in ["-1", "0.5"] {
let json = format!(
r#"{{"asset":{{"version":"2.0"}},"extensionsUsed":["KHR_materials_ior"],"materials":[{{"extensions":{{"KHR_materials_ior":{{"ior":{ior}}}}}}}],"scenes":[{{"nodes":[]}}],"scene":0}}"#
);
assert!(matches!(
decode_gltf(json.as_bytes()),
Err(ImportError::InvalidIor(_))
));
}
}
}
+16 -3
View File
@@ -1,8 +1,5 @@
pub mod app_setup;
pub mod camera;
pub mod command_ring;
pub mod gltf;
pub mod message;
pub mod platform;
pub mod render_data;
pub mod render_graph;
@@ -10,6 +7,22 @@ pub mod renderer;
pub mod shared_snapshot;
pub mod shared_soa;
/// Start the core renderer inside its owning worker.
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn worker_main() -> u32 {
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
wasm_logger::init(wasm_logger::Config::default());
app_setup::worker_entrypoint()
}
/// Return this worker's shared WebAssembly memory to messaging clients.
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn worker_memory() -> wasm_bindgen::JsValue {
wasm_bindgen::memory()
}
#[cfg(target_arch = "wasm32")]
thread_local! { static PAYLOADS: std::cell::RefCell<std::collections::HashMap<u32, Vec<u8>>> = Default::default(); }
-157
View File
@@ -1,157 +0,0 @@
use core::fmt;
use std::cell::BorrowMutError;
use std::sync::mpsc::TryRecvError;
use wasm_bindgen::JsCast;
pub const RIGHT_BUTTON_MASK: u16 = 0x02;
pub const MIDDLE_BUTTON_MASK: u16 = 0x04;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CameraDrag {
Orbit,
Pan,
}
pub fn camera_drag(buttons: u16) -> Option<CameraDrag> {
if buttons & MIDDLE_BUTTON_MASK != 0 {
Some(CameraDrag::Orbit)
} else if buttons & RIGHT_BUTTON_MASK != 0 {
Some(CameraDrag::Pan)
} else {
None
}
}
pub fn normalize_wheel_delta(delta_y: f64, delta_mode: u32, viewport_height: f64) -> Option<f32> {
if !delta_y.is_finite() || !viewport_height.is_finite() {
return None;
}
let delta = match delta_mode {
0 => delta_y,
1 => delta_y * 16.0,
2 => delta_y * viewport_height.max(1.0),
_ => return None,
};
let delta = delta as f32;
delta.is_finite().then_some(delta)
}
#[derive(Debug)]
pub enum WindowEvent {
Resize(ResizeMessage),
PointerMove(MouseMessage),
PointerClick(MouseMessage),
PointerWheel(WheelMessage),
}
#[derive(Debug, Clone)]
pub struct ResizeMessage {
pub scale_factor: f64,
pub width: f64,
pub height: f64,
}
#[derive(Debug, Clone)]
pub struct MouseMessage {
pub scale_factor: f64,
pub buttons: u16,
pub movement_x: f64,
pub movement_y: f64,
pub offset_x: f64,
pub offset_y: f64,
pub viewport_height: f64,
}
impl MouseMessage {
pub fn from_evt(event: &web_sys::MouseEvent, viewport_height: f64) -> Self {
let window = web_sys::window().unwrap();
Self {
scale_factor: window.device_pixel_ratio(),
buttons: event.buttons(),
movement_x: event.movement_x() as f64,
movement_y: event.movement_y() as f64,
offset_x: event.offset_x() as f64,
offset_y: event.offset_y() as f64,
viewport_height,
}
}
pub fn from_pointer_evt(event: &web_sys::PointerEvent, viewport_height: f64) -> Self {
Self::from_evt(event.unchecked_ref(), viewport_height)
}
}
#[derive(Debug, Clone)]
pub struct WheelMessage {
pub delta_y_pixels: f32,
}
impl WheelMessage {
pub fn from_evt(event: &web_sys::WheelEvent, viewport_height: f64) -> Option<Self> {
normalize_wheel_delta(event.delta_y(), event.delta_mode(), viewport_height)
.map(|delta_y_pixels| Self { delta_y_pixels })
}
}
#[derive(Debug)]
pub enum DrainEventError {
BorrowError(BorrowMutError),
ChannelDisconnected,
ChannelEmpty,
}
impl fmt::Display for DrainEventError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DrainEventError::BorrowError(err) => write!(f, "Failed to borrow renderer: {}", err),
DrainEventError::ChannelDisconnected => write!(f, "Event channel disconnected"),
DrainEventError::ChannelEmpty => write!(f, "Event channel empty"),
}
}
}
impl std::error::Error for DrainEventError {}
impl From<TryRecvError> for DrainEventError {
fn from(err: TryRecvError) -> Self {
match err {
TryRecvError::Empty => DrainEventError::ChannelEmpty,
TryRecvError::Disconnected => DrainEventError::ChannelDisconnected,
}
}
}
impl From<BorrowMutError> for DrainEventError {
fn from(err: BorrowMutError) -> Self {
DrainEventError::BorrowError(err)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wheel_delta_is_normalized_to_css_pixels() {
assert_eq!(normalize_wheel_delta(12.5, 0, 640.0), Some(12.5));
assert_eq!(normalize_wheel_delta(2.0, 1, 640.0), Some(32.0));
assert_eq!(normalize_wheel_delta(-1.0, 2, 640.0), Some(-640.0));
assert_eq!(normalize_wheel_delta(2.0, 2, 0.0), Some(2.0));
assert_eq!(normalize_wheel_delta(1.0, 3, 640.0), None);
assert_eq!(normalize_wheel_delta(f64::NAN, 0, 640.0), None);
assert_eq!(normalize_wheel_delta(f64::INFINITY, 0, 640.0), None);
assert_eq!(normalize_wheel_delta(1.0, 0, f64::NAN), None);
}
#[test]
fn camera_drag_prefers_orbit_when_both_buttons_are_down() {
assert_eq!(camera_drag(0), None);
assert_eq!(camera_drag(1), None);
assert_eq!(camera_drag(RIGHT_BUTTON_MASK), Some(CameraDrag::Pan));
assert_eq!(camera_drag(MIDDLE_BUTTON_MASK), Some(CameraDrag::Orbit));
assert_eq!(
camera_drag(RIGHT_BUTTON_MASK | MIDDLE_BUTTON_MASK),
Some(CameraDrag::Orbit)
);
}
}
@@ -1,4 +1,4 @@
// The level editor's render worker owns its only WASM and WebGPU runtime.
// The render worker owns its only WASM and WebGPU runtime.
import initWasm, {
clear_payloads,
discard_payload,
@@ -6,7 +6,7 @@ import initWasm, {
worker_main,
worker_memory,
worker_window_event,
} from "/level-editor/pkg/level_editor.js";
} from "/renderer/pkg/renderer.js";
function listenerReady() {
if (state !== "waiting-listener") return;
@@ -30,7 +30,7 @@ addEventListener("message", async (event) => {
}
if (state !== "uninitialized") return;
state = "initializing";
const { canvas, profile } = message;
const { canvas } = message;
// The renderer worker exclusively owns the one WASM instance. Other threads
// receive only its shared memory and mutate the published SAB layouts.
@@ -43,7 +43,7 @@ addEventListener("message", async (event) => {
state = "waiting-listener";
pending.push({ type: "canvas", canvas });
try {
const ringPtr = worker_main(profile);
const ringPtr = worker_main();
postMessage({ type: "bootstrap", memory: worker_memory(), ringPtr });
setTimeout(listenerReady, 0);
} catch (error) {
+3 -9
View File
@@ -1,5 +1,5 @@
use crate::command_ring::CommandRing;
use crate::message::WindowEvent;
use crate::renderer::ResizeMessage;
use log::info;
use std::sync::mpsc::Receiver;
use std::{cell::RefCell, rc::Rc};
@@ -7,18 +7,12 @@ use wasm_bindgen::{prelude::*, JsValue};
use wasm_bindgen_futures::JsFuture;
use web_sys::MessageEvent;
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>(
events_chan: Receiver<WindowEvent>,
ring: &'static CommandRing,
profile: bool,
) {
pub async fn run_render_loop(events_chan: Receiver<ResizeMessage>, ring: &'static CommandRing) {
use crate::renderer::Renderer;
let canvas = wait_for_canvas_transfer().await;
let renderer = Rc::new(RefCell::new(
Renderer::<T>::new(canvas, events_chan, profile).await,
));
let renderer = Rc::new(RefCell::new(Renderer::new(canvas, events_chan).await));
renderer.borrow_mut().command_ring = Some(ring);
Renderer::run_render_loop(renderer);
}
+285
View File
@@ -0,0 +1,285 @@
use std::f32::consts::PI;
use ultraviolet::{projection, Mat4, Vec3};
#[cfg(target_arch = "wasm32")]
use wgpu::util::DeviceExt;
#[cfg(target_arch = "wasm32")]
use crate::renderer::frame_data::UniformResource;
/// A camera matrix cannot produce a safe, meaningful frustum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum FrustumError {
#[error("frustum plane {plane} contains a non-finite component")]
NonFinite { plane: usize },
#[error("frustum plane {plane} has a near-degenerate normal")]
Degenerate { plane: usize },
}
const MIN_DISTANCE: f32 = 0.1;
/// SIMD-width shared camera row: eye, target, up, then projection parameters.
pub type SharedCameraState = [f32; 16];
#[repr(C)]
pub struct Camera {
// Hot data - cached computed matrix (64 bytes, 1 cache line)
pub view_proj: [[f32; 4]; 4],
// Warm data - frequently accessed vectors (36 bytes)
position: Vec3,
target: Vec3,
up: Vec3,
// Cold data - projection parameters (16 bytes)
fov: f32,
aspect_ratio: f32,
z_near: f32,
z_far: f32,
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)]
pub struct CameraUniform {
view_proj: [[f32; 4]; 4],
}
impl Camera {
pub fn frustum_planes(&self) -> Result<[[f32; 4]; 6], FrustumError> {
extract_frustum_planes(self.view_proj)
}
pub fn new(aspect_ratio: f32) -> Self {
let mut camera = Camera {
view_proj: [[0.0; 4]; 4],
position: Vec3::new(0.0, 0.5, 3.0),
target: Vec3::new(0.0, 0.0, 0.0),
up: Vec3::unit_y(),
fov: PI / 3.0,
aspect_ratio,
z_near: 0.1,
z_far: 100000.0,
};
camera.compute_view_proj_mat();
camera
}
pub fn compute_view_proj_mat(&mut self) {
let view = Mat4::look_at(self.position, self.target, self.up);
let proj = projection::rh_yup::perspective_wgpu_dx(
self.fov,
self.aspect_ratio,
self.z_near,
self.z_far,
);
self.view_proj = (proj * view).into();
}
pub fn position(&self) -> Vec3 {
self.position
}
pub fn update_aspect_ratio(&mut self, aspect_ratio: f32) {
self.aspect_ratio = aspect_ratio;
self.compute_view_proj_mat();
}
/// Snapshot the canonical 64-byte shared row.
pub fn shared_state(&self) -> SharedCameraState {
[
self.position.x,
self.position.y,
self.position.z,
1.0,
self.target.x,
self.target.y,
self.target.z,
1.0,
self.up.x,
self.up.y,
self.up.z,
0.0,
self.fov,
self.aspect_ratio,
self.z_near,
self.z_far,
]
}
/// Apply a complete shared row, rejecting malformed external writes.
pub fn apply_shared_state(&mut self, state: SharedCameraState) -> bool {
if !state.iter().all(|value| value.is_finite())
|| !(0.0..PI).contains(&state[12])
|| state[13] <= 0.0
|| state[14] <= 0.0
|| state[15] <= state[14]
{
return false;
}
let position = Vec3::new(state[0], state[1], state[2]);
let target = Vec3::new(state[4], state[5], state[6]);
let up = Vec3::new(state[8], state[9], state[10]);
let forward = target - position;
if forward.mag_sq() < MIN_DISTANCE * MIN_DISTANCE
|| up.mag_sq() <= f32::EPSILON
|| forward.cross(up).mag_sq() <= f32::EPSILON
{
return false;
}
self.position = position;
self.target = target;
self.up = up.normalized();
self.fov = state[12];
self.aspect_ratio = state[13];
self.z_near = state[14];
self.z_far = state[15];
self.compute_view_proj_mat();
true
}
#[cfg(target_arch = "wasm32")]
pub fn create_uniform_resource(&self, device: &wgpu::Device) -> UniformResource {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: "camera uniform buffer".into(),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
contents: bytemuck::cast_slice(&[self.view_proj]),
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Camera bind group layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Camera bind group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
UniformResource {
buffer,
bind_group,
bind_group_layout,
}
}
}
/// Extracts inward-facing normalized WebGPU clip-space planes (zero-to-one depth).
pub fn extract_frustum_planes(m: [[f32; 4]; 4]) -> Result<[[f32; 4]; 6], FrustumError> {
let row = |r: usize| [m[0][r], m[1][r], m[2][r], m[3][r]];
let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]];
let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]];
let r0 = row(0);
let r1 = row(1);
let r2 = row(2);
let r3 = row(3);
let mut planes = [
add(r3, r0),
sub(r3, r0),
add(r3, r1),
sub(r3, r1),
r2,
sub(r3, r2),
];
for (plane, p) in planes.iter_mut().enumerate() {
if !p.iter().all(|component| component.is_finite()) {
return Err(FrustumError::NonFinite { plane });
}
// Scale first: directly squaring very large/small coefficients can overflow or
// underflow even though the plane itself is normalizable.
let scale = p[0].abs().max(p[1].abs()).max(p[2].abs());
if scale < f32::MIN_POSITIVE {
return Err(FrustumError::Degenerate { plane });
}
let scaled = [p[0] / scale, p[1] / scale, p[2] / scale];
let length = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).sqrt();
for v in p {
*v = (*v / scale) / length;
if !v.is_finite() {
return Err(FrustumError::NonFinite { plane });
}
}
}
Ok(planes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frustum_extraction_rejects_nonfinite_and_degenerate_planes() {
let mut nonfinite = Camera::new(1.0).view_proj;
nonfinite[0][0] = f32::NAN;
assert!(matches!(
extract_frustum_planes(nonfinite),
Err(FrustumError::NonFinite { .. })
));
assert!(matches!(
extract_frustum_planes([[0.0; 4]; 4]),
Err(FrustumError::Degenerate { .. })
));
}
#[test]
fn frustum_extraction_normalizes_without_overflow() {
let mut matrix = Camera::new(1.0).view_proj;
for value in matrix.iter_mut().flatten() {
*value *= 1.0e20;
}
let planes = extract_frustum_planes(matrix).unwrap();
for plane in planes {
let length = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
assert!((length - 1.0).abs() < 1.0e-5);
assert!(plane.iter().all(|value| value.is_finite()));
}
}
#[test]
fn shared_state_round_trips_and_rejects_invalid_projection() {
let mut camera = Camera::new(1.0);
let state = [
2.0,
3.0,
10.0,
1.0,
1.0,
-1.0,
0.5,
1.0,
0.0,
1.0,
0.0,
0.0,
PI / 4.0,
16.0 / 9.0,
0.25,
500.0,
];
assert!(camera.apply_shared_state(state));
assert_eq!(camera.shared_state(), state);
assert!(camera
.view_proj
.iter()
.flatten()
.all(|component| component.is_finite()));
let mut invalid = state;
invalid[15] = invalid[14];
assert!(!camera.apply_shared_state(invalid));
assert_eq!(camera.shared_state(), state);
}
}
+6 -1
View File
@@ -1,5 +1,7 @@
pub(crate) mod camera;
mod handle;
mod range_allocator;
pub(crate) mod upload;
pub use handle::{InstanceHandle, MeshHandle};
@@ -32,7 +34,7 @@ pub const IDENTITY_NORMAL_MATRIX: NormalMatrix =
pub struct MaterialKey(u32);
impl MaterialKey {
/// The glTF/default material.
/// The default material.
pub const DEFAULT: Self = Self(0);
pub const fn new(value: u32) -> Self {
@@ -428,6 +430,9 @@ impl RenderData {
/// Creates an empty transactional successor whose handles cannot alias this data.
pub fn replacement_stage(&self) -> Result<ReplacementStage, RenderDataError> {
// Preflight the only fallible operation left at commit time. With the stage
// prepared synchronously, lineage and handle generations cannot drift.
self.next_revision()?;
let capacities = self.capacities();
let mut stage = Self::new(RenderDataConfig {
initial_vertices: capacities.vertices,
+734
View File
@@ -0,0 +1,734 @@
use std::collections::{HashMap, HashSet};
use serde::Deserialize;
use ultraviolet::{Mat4, Vec3};
use super::{
InstanceType, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform, RenderData,
RenderDataError, ReplacementStage,
};
const MAGIC: u32 = u32::from_le_bytes(*b"YRDP");
const VERSION: u32 = 1;
const HEADER_BYTES: usize = 16;
const BASE_COLOR_TEXTURE: u32 = 1 << 0;
const METALLIC_ROUGHNESS_TEXTURE: u32 = 1 << 1;
const NORMAL_TEXTURE: u32 = 1 << 2;
const OCCLUSION_TEXTURE: u32 = 1 << 3;
const EMISSIVE_TEXTURE: u32 = 1 << 4;
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AlphaMode {
#[default]
Opaque,
Mask,
Blend,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TextureReference {
pub texture: usize,
pub tex_coord: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Material {
pub key: MaterialKey,
pub base_color_factor: [f32; 4],
pub metallic_factor: f32,
pub roughness_factor: f32,
pub emissive_factor: [f32; 3],
pub ior: f32,
pub alpha_mode: AlphaMode,
pub alpha_cutoff: f32,
pub double_sided: bool,
pub base_color_texture: Option<TextureReference>,
pub metallic_roughness_texture: Option<TextureReference>,
pub normal_texture: Option<TextureReference>,
pub normal_scale: f32,
pub occlusion_texture: Option<TextureReference>,
pub occlusion_strength: f32,
pub emissive_texture: Option<TextureReference>,
}
impl Default for Material {
fn default() -> Self {
Self {
key: MaterialKey::DEFAULT,
base_color_factor: [1.0; 4],
metallic_factor: 1.0,
roughness_factor: 1.0,
emissive_factor: [0.0; 3],
ior: 1.5,
alpha_mode: AlphaMode::Opaque,
alpha_cutoff: 0.5,
double_sided: false,
base_color_texture: None,
metallic_roughness_texture: None,
normal_texture: None,
normal_scale: 1.0,
occlusion_texture: None,
occlusion_strength: 1.0,
emissive_texture: None,
}
}
}
/// SIMD-aligned material row shared with external render-data writers and the GPU.
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct MaterialState {
pub base_color_factor: [f32; 4],
pub emissive_factor: [f32; 4],
pub surface_factors: [f32; 4],
pub alpha_optics: [f32; 4],
pub flags: [u32; 4],
pub uv_sets: [u32; 4],
pub debug_extras: [u32; 4],
}
fn enabled(reference: Option<TextureReference>, bit: u32) -> u32 {
reference
.filter(|value| value.tex_coord == 0)
.map_or(0, |_| bit)
}
impl From<&Material> for MaterialState {
fn from(value: &Material) -> Self {
Self {
base_color_factor: value.base_color_factor,
emissive_factor: [
value.emissive_factor[0],
value.emissive_factor[1],
value.emissive_factor[2],
0.0,
],
surface_factors: [
value.metallic_factor,
value.roughness_factor,
value.normal_scale,
value.occlusion_strength,
],
alpha_optics: [
match value.alpha_mode {
AlphaMode::Opaque => 0.0,
AlphaMode::Mask => 1.0,
AlphaMode::Blend => 2.0,
},
value.alpha_cutoff,
value.ior,
if value.ior == 0.0 {
1.0
} else {
((value.ior - 1.0) / (value.ior + 1.0)).powi(2)
},
],
flags: [
enabled(value.base_color_texture, BASE_COLOR_TEXTURE)
| enabled(value.metallic_roughness_texture, METALLIC_ROUGHNESS_TEXTURE)
| enabled(value.normal_texture, NORMAL_TEXTURE)
| enabled(value.occlusion_texture, OCCLUSION_TEXTURE)
| enabled(value.emissive_texture, EMISSIVE_TEXTURE),
u32::from(value.double_sided),
0,
0,
],
uv_sets: [
value.base_color_texture.map_or(0, |value| value.tex_coord),
value
.metallic_roughness_texture
.map_or(0, |value| value.tex_coord),
value.normal_texture.map_or(0, |value| value.tex_coord),
value.occlusion_texture.map_or(0, |value| value.tex_coord),
],
debug_extras: [
value.emissive_texture.map_or(0, |value| value.tex_coord),
0,
0,
0,
],
}
}
}
impl MaterialState {
pub const LANES: u32 = 28;
pub fn words(self) -> [u32; Self::LANES as usize] {
bytemuck::cast(self)
}
pub fn from_words(words: [u32; Self::LANES as usize]) -> Self {
bytemuck::cast(words)
}
}
const _: [(); 112] = [(); std::mem::size_of::<MaterialState>()];
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FilterMode {
Nearest,
#[default]
Linear,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AddressMode {
ClampToEdge,
MirrorRepeat,
#[default]
Repeat,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TextureMetadata {
pub image: usize,
pub sampler: Option<usize>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SamplerMetadata {
#[serde(default)]
pub mag_filter: FilterMode,
#[serde(default)]
pub min_filter: FilterMode,
#[serde(default)]
pub mipmap_filter: FilterMode,
#[serde(default)]
pub address_u: AddressMode,
#[serde(default)]
pub address_v: AddressMode,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImageMetadata {
pub mime_type: String,
pub encoded_data: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct UploadedGeometry {
pub id: u32,
pub material: MaterialKey,
pub instance_type: InstanceType,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub tangents: Vec<[f32; 4]>,
pub uvs: Vec<[f32; 2]>,
pub indices: Vec<u32>,
}
#[derive(Clone, Debug)]
pub struct UploadedOccurrence {
pub geometry: u32,
pub transform: ModelTransform,
}
#[derive(Clone, Debug, Default)]
pub struct RenderDataUpload {
pub geometries: Vec<UploadedGeometry>,
pub occurrences: Vec<UploadedOccurrence>,
pub materials: Vec<Material>,
pub textures: Vec<TextureMetadata>,
pub samplers: Vec<SamplerMetadata>,
pub images: Vec<ImageMetadata>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ModelBounds {
pub min: [f32; 3],
pub max: [f32; 3],
}
impl ModelBounds {
fn include(&mut self, point: [f32; 3]) {
for axis in 0..3 {
self.min[axis] = self.min[axis].min(point[axis]);
self.max[axis] = self.max[axis].max(point[axis]);
}
}
}
#[derive(Clone, Debug)]
pub struct InstalledRenderData {
pub meshes: Vec<MeshHandle>,
pub bounds: Option<ModelBounds>,
}
pub struct PreparedRenderData {
pub stage: ReplacementStage,
pub installed: InstalledRenderData,
}
#[derive(Debug, thiserror::Error)]
pub enum RenderDataUploadError {
#[error("render-data packet is malformed: {0}")]
Malformed(&'static str),
#[error("render-data packet metadata is invalid: {0}")]
Metadata(#[from] serde_json::Error),
#[error("render-data packet contains invalid geometry: {0}")]
InvalidGeometry(&'static str),
#[error("render-data packet contains invalid material data")]
InvalidMaterial,
#[error("failed to install uploaded render data")]
Install(#[from] RenderDataError),
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct DataSlice {
offset: u32,
count: u32,
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ByteSlice {
offset: u32,
byte_length: u32,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct GeometryMetadata {
id: u32,
material: u32,
instance_type: [u32; 16],
positions: DataSlice,
normals: DataSlice,
tangents: DataSlice,
uvs: DataSlice,
indices: DataSlice,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct OccurrenceMetadata {
geometry: u32,
transform: [f32; 16],
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MaterialMetadata {
key: u32,
base_color_factor: [f32; 4],
metallic_factor: f32,
roughness_factor: f32,
emissive_factor: [f32; 3],
ior: f32,
alpha_mode: AlphaMode,
alpha_cutoff: f32,
double_sided: bool,
base_color_texture: Option<TextureReference>,
metallic_roughness_texture: Option<TextureReference>,
normal_texture: Option<TextureReference>,
normal_scale: f32,
occlusion_texture: Option<TextureReference>,
occlusion_strength: f32,
emissive_texture: Option<TextureReference>,
}
impl TryFrom<MaterialMetadata> for Material {
type Error = RenderDataUploadError;
fn try_from(value: MaterialMetadata) -> Result<Self, Self::Error> {
let finite = value
.base_color_factor
.iter()
.chain(value.emissive_factor.iter())
.chain([
&value.metallic_factor,
&value.roughness_factor,
&value.ior,
&value.alpha_cutoff,
&value.normal_scale,
&value.occlusion_strength,
])
.all(|component| component.is_finite());
if !finite || (value.ior != 0.0 && value.ior < 1.0) {
return Err(RenderDataUploadError::InvalidMaterial);
}
Ok(Self {
key: MaterialKey::new(value.key),
base_color_factor: value.base_color_factor,
metallic_factor: value.metallic_factor,
roughness_factor: value.roughness_factor,
emissive_factor: value.emissive_factor,
ior: value.ior,
alpha_mode: value.alpha_mode,
alpha_cutoff: value.alpha_cutoff,
double_sided: value.double_sided,
base_color_texture: value.base_color_texture,
metallic_roughness_texture: value.metallic_roughness_texture,
normal_texture: value.normal_texture,
normal_scale: value.normal_scale,
occlusion_texture: value.occlusion_texture,
occlusion_strength: value.occlusion_strength,
emissive_texture: value.emissive_texture,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ImageMetadataPacket {
mime_type: String,
data: ByteSlice,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PacketMetadata {
#[serde(default)]
geometries: Vec<GeometryMetadata>,
#[serde(default)]
occurrences: Vec<OccurrenceMetadata>,
#[serde(default)]
materials: Vec<MaterialMetadata>,
#[serde(default)]
textures: Vec<TextureMetadata>,
#[serde(default)]
samplers: Vec<SamplerMetadata>,
#[serde(default)]
images: Vec<ImageMetadataPacket>,
}
fn word(bytes: &[u8], offset: usize) -> Result<u32, RenderDataUploadError> {
let raw: [u8; 4] = bytes
.get(offset..offset + 4)
.ok_or(RenderDataUploadError::Malformed("header is truncated"))?
.try_into()
.unwrap();
Ok(u32::from_le_bytes(raw))
}
fn range(payload: &[u8], offset: u32, byte_length: usize) -> Result<&[u8], RenderDataUploadError> {
let start = usize::try_from(offset)
.map_err(|_| RenderDataUploadError::Malformed("data offset exceeds usize"))?;
let end = start
.checked_add(byte_length)
.ok_or(RenderDataUploadError::Malformed("data range overflows"))?;
payload
.get(start..end)
.ok_or(RenderDataUploadError::Malformed(
"data range is out of bounds",
))
}
fn f32_vectors<const N: usize>(
payload: &[u8],
slice: DataSlice,
) -> Result<Vec<[f32; N]>, RenderDataUploadError> {
if slice.offset % 4 != 0 {
return Err(RenderDataUploadError::Malformed("float data is unaligned"));
}
let count = usize::try_from(slice.count)
.map_err(|_| RenderDataUploadError::Malformed("element count exceeds usize"))?;
let byte_length = count
.checked_mul(N)
.and_then(|value| value.checked_mul(4))
.ok_or(RenderDataUploadError::Malformed(
"float data size overflows",
))?;
let bytes = range(payload, slice.offset, byte_length)?;
Ok(bytes
.chunks_exact(N * 4)
.map(|chunk| {
std::array::from_fn(|lane| {
f32::from_le_bytes(chunk[lane * 4..lane * 4 + 4].try_into().unwrap())
})
})
.collect())
}
fn u32_values(payload: &[u8], slice: DataSlice) -> Result<Vec<u32>, RenderDataUploadError> {
if slice.offset % 4 != 0 {
return Err(RenderDataUploadError::Malformed(
"integer data is unaligned",
));
}
let byte_length = usize::try_from(slice.count)
.ok()
.and_then(|count| count.checked_mul(4))
.ok_or(RenderDataUploadError::Malformed(
"integer data size overflows",
))?;
Ok(range(payload, slice.offset, byte_length)?
.chunks_exact(4)
.map(|chunk| u32::from_le_bytes(chunk.try_into().unwrap()))
.collect())
}
/// Decode the generic binary render-data packet accepted by core.
pub fn decode_render_data_packet(bytes: &[u8]) -> Result<RenderDataUpload, RenderDataUploadError> {
if bytes.len() < HEADER_BYTES || word(bytes, 0)? != MAGIC {
return Err(RenderDataUploadError::Malformed("magic is invalid"));
}
if word(bytes, 4)? != VERSION {
return Err(RenderDataUploadError::Malformed("version is unsupported"));
}
let metadata_len = usize::try_from(word(bytes, 8)?)
.map_err(|_| RenderDataUploadError::Malformed("metadata size exceeds usize"))?;
let payload_len = usize::try_from(word(bytes, 12)?)
.map_err(|_| RenderDataUploadError::Malformed("payload size exceeds usize"))?;
let metadata_end = HEADER_BYTES
.checked_add(metadata_len)
.ok_or(RenderDataUploadError::Malformed("metadata size overflows"))?;
let payload_start = metadata_end.checked_add(3).map(|value| value & !3).ok_or(
RenderDataUploadError::Malformed("payload alignment overflows"),
)?;
let packet_end = payload_start
.checked_add(payload_len)
.ok_or(RenderDataUploadError::Malformed("packet size overflows"))?;
if packet_end != bytes.len() {
return Err(RenderDataUploadError::Malformed(
"packet length is not exact",
));
}
let metadata: PacketMetadata = serde_json::from_slice(
bytes
.get(HEADER_BYTES..metadata_end)
.ok_or(RenderDataUploadError::Malformed("metadata is truncated"))?,
)?;
let payload = &bytes[payload_start..packet_end];
let mut geometry_ids = HashSet::new();
let geometries = metadata
.geometries
.into_iter()
.map(|geometry| {
if !geometry_ids.insert(geometry.id) {
return Err(RenderDataUploadError::InvalidGeometry(
"geometry id is duplicated",
));
}
Ok(UploadedGeometry {
id: geometry.id,
material: MaterialKey::new(geometry.material),
instance_type: InstanceType {
words: geometry.instance_type,
},
positions: f32_vectors(payload, geometry.positions)?,
normals: f32_vectors(payload, geometry.normals)?,
tangents: f32_vectors(payload, geometry.tangents)?,
uvs: f32_vectors(payload, geometry.uvs)?,
indices: u32_values(payload, geometry.indices)?,
})
})
.collect::<Result<Vec<_>, _>>()?;
let occurrences = metadata
.occurrences
.into_iter()
.map(|occurrence| UploadedOccurrence {
geometry: occurrence.geometry,
transform: std::array::from_fn(|column| {
std::array::from_fn(|row| occurrence.transform[column * 4 + row])
}),
})
.collect();
let materials = metadata
.materials
.into_iter()
.map(Material::try_from)
.collect::<Result<Vec<_>, _>>()?;
let images = metadata
.images
.into_iter()
.map(|image| -> Result<_, RenderDataUploadError> {
let byte_length = usize::try_from(image.data.byte_length)
.map_err(|_| RenderDataUploadError::Malformed("image size exceeds usize"))?;
Ok(ImageMetadata {
mime_type: image.mime_type,
encoded_data: range(payload, image.data.offset, byte_length)?.to_vec(),
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(RenderDataUpload {
geometries,
occurrences,
materials,
textures: metadata.textures,
samplers: metadata.samplers,
images,
})
}
fn focus_bounds(points: &[[f32; 3]]) -> Option<ModelBounds> {
let first = *points.first()?;
if points.len() < 200 {
let mut bounds = ModelBounds {
min: first,
max: first,
};
for point in &points[1..] {
bounds.include(*point);
}
return Some(bounds);
}
let trim = points.len() / 100;
let mut min = [0.0; 3];
let mut max = [0.0; 3];
for axis in 0..3 {
let mut values: Vec<_> = points.iter().map(|point| point[axis]).collect();
values.sort_by(f32::total_cmp);
min[axis] = values[trim];
max[axis] = values[values.len() - trim - 1];
}
Some(ModelBounds { min, max })
}
/// Prepare a complete CPU-side replacement without changing live render data.
pub fn prepare_render_data(
target: &RenderData,
upload: &RenderDataUpload,
) -> Result<PreparedRenderData, RenderDataUploadError> {
let mut stage = target.replacement_stage()?;
let mut handles = HashMap::new();
let mut mesh_handles = Vec::with_capacity(upload.geometries.len());
let mut first = HashMap::new();
for occurrence in &upload.occurrences {
first
.entry(occurrence.geometry)
.or_insert(occurrence.transform);
}
for geometry in &upload.geometries {
let transform = *first
.get(&geometry.id)
.ok_or(RenderDataUploadError::InvalidGeometry(
"geometry has no occurrence",
))?;
let created = stage.create_mesh(MeshCreateInfo {
positions: &geometry.positions,
normals: &geometry.normals,
tangents: &geometry.tangents,
uvs: &geometry.uvs,
indices: &geometry.indices,
material: geometry.material,
default_instance_type: geometry.instance_type,
default_transform: transform,
})?;
handles.insert(geometry.id, created.mesh);
mesh_handles.push(created.mesh);
}
let geometries: HashMap<_, _> = upload
.geometries
.iter()
.map(|geometry| (geometry.id, geometry))
.collect();
let mut consumed = HashSet::new();
let mut focus_points = Vec::new();
let mut bounds: Option<ModelBounds> = None;
for occurrence in &upload.occurrences {
let mesh =
*handles
.get(&occurrence.geometry)
.ok_or(RenderDataUploadError::InvalidGeometry(
"occurrence has no geometry",
))?;
if !consumed.insert(occurrence.geometry) {
let instance_type = stage.mesh(mesh).unwrap().default_instance_type;
stage.create_instance(mesh, occurrence.transform, instance_type)?;
}
let geometry = geometries[&occurrence.geometry];
let transform = Mat4::from(occurrence.transform);
focus_points.extend(geometry.positions.iter().map(|position| {
let point = transform.transform_point3(Vec3::from(*position));
[point.x, point.y, point.z]
}));
let local = stage.mesh(mesh).unwrap().local_aabb;
for x in [local.min[0], local.max[0]] {
for y in [local.min[1], local.max[1]] {
for z in [local.min[2], local.max[2]] {
let point = transform.transform_point3(Vec3::new(x, y, z));
let point = [point.x, point.y, point.z];
if let Some(existing) = bounds.as_mut() {
existing.include(point);
} else {
bounds = Some(ModelBounds {
min: point,
max: point,
});
}
}
}
}
}
bounds = focus_bounds(&focus_points).or(bounds);
Ok(PreparedRenderData {
stage,
installed: InstalledRenderData {
meshes: mesh_handles,
bounds,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
fn packet(metadata: serde_json::Value, payload: &[u8]) -> Vec<u8> {
let metadata = serde_json::to_vec(&metadata).unwrap();
let payload_offset = (HEADER_BYTES + metadata.len() + 3) & !3;
let mut packet = vec![0; payload_offset + payload.len()];
packet[0..4].copy_from_slice(&MAGIC.to_le_bytes());
packet[4..8].copy_from_slice(&VERSION.to_le_bytes());
packet[8..12].copy_from_slice(&(metadata.len() as u32).to_le_bytes());
packet[12..16].copy_from_slice(&(payload.len() as u32).to_le_bytes());
packet[HEADER_BYTES..HEADER_BYTES + metadata.len()].copy_from_slice(&metadata);
packet[payload_offset..].copy_from_slice(payload);
packet
}
#[test]
fn generic_packet_decodes_typed_streams_without_format_knowledge() {
let floats: Vec<f32> = [
0., 0., 0., 1., 0., 0., 0., 1., 0., // positions
0., 0., 1., 0., 0., 1., 0., 0., 1., // normals
1., 0., 0., 1., 1., 0., 0., 1., 1., 0., 0., 1., // tangents
0., 0., 1., 0., 0., 1., // uvs
]
.into();
let mut payload = bytemuck::cast_slice(&floats).to_vec();
payload.extend_from_slice(bytemuck::cast_slice(&[0u32, 1, 2]));
let upload = decode_render_data_packet(&packet(
serde_json::json!({
"geometries":[{
"id":7,"material":0,"instanceType":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
"positions":{"offset":0,"count":3},
"normals":{"offset":36,"count":3},
"tangents":{"offset":72,"count":3},
"uvs":{"offset":120,"count":3},
"indices":{"offset":144,"count":3}
}],
"occurrences":[{"geometry":7,"transform":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]}],
"materials":[],"textures":[],"samplers":[],"images":[]
}),
&payload,
))
.unwrap();
assert_eq!(upload.geometries[0].positions.len(), 3);
assert_eq!(upload.geometries[0].indices, [0, 1, 2]);
assert_eq!(upload.occurrences[0].geometry, 7);
}
#[test]
fn packet_length_and_ranges_are_exact() {
let mut invalid = packet(serde_json::json!({}), &[]);
invalid.push(0);
assert!(matches!(
decode_render_data_packet(&invalid),
Err(RenderDataUploadError::Malformed(
"packet length is not exact"
))
));
}
}
+8 -3
View File
@@ -322,10 +322,12 @@ pub fn parse(bytes: &[u8]) -> Result<Graph, GraphError> {
})
}
#[cfg(test)]
fn push_string(out: &mut String, value: &str) {
out.push_str(&serde_json::to_string(value).expect("strings always serialize"));
}
#[cfg(test)]
fn push_json(out: &mut String, value: &Value) {
match value {
Value::Null => out.push_str("null"),
@@ -357,6 +359,7 @@ fn push_json(out: &mut String, value: &Value) {
}
/// Serializes an internal graph for fixtures and cross-language conformance tests.
#[cfg(test)]
pub fn serialize(graph: &Graph) -> String {
let mut out = format!("(yawn-graph {AST_VERSION}\n (id ");
push_string(&mut out, &graph.graph_id);
@@ -425,12 +428,14 @@ pub(crate) fn validate_pipeline_declarations(graph: &Graph) -> Result<(), GraphE
));
}
}
if !super::contract(name).is_some_and(|contract| {
contract.is_raster_draw() || contract.fullscreen_policy.is_some() || name == "frame_out"
if super::contract(name).is_some_and(|contract| {
!contract.is_raster_draw()
&& contract.fullscreen_policy.is_none()
&& name != "frame_out"
}) {
return Err(GraphError::new(
"GRAPH_EXECUTION_UNSUPPORTED",
format!("authored render pipeline '{name}' has no render executor"),
format!("authored render pipeline '{name}' conflicts with a core executor"),
));
}
shader_bytes = shader_bytes.saturating_add(shader.len());
+10 -6
View File
@@ -460,7 +460,11 @@ fn normalize_texture(
})
}
fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
fn decode(
node: &Node,
i: usize,
pipelines: &PipelineDeclarations,
) -> Result<NormalizedParameters, GraphError> {
let base = format!("nodes[{i}].parameters");
let invalid =
|e: serde_json::Error| error("GRAPH_PARAMETERS_INVALID", &e.to_string(), base.clone());
@@ -733,7 +737,7 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
descriptor: normalize_texture(p.texture, &base)?,
}
}
key if contract(key).is_some_and(|contract| contract.is_raster_draw()) => {
key if contract_for(key, pipelines).is_some_and(|contract| contract.is_raster_draw()) => {
let p: RasterParameters =
serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
if !p.clear_depth.is_finite() || !(0.0..=1.0).contains(&p.clear_depth) {
@@ -758,10 +762,10 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
predicate_default: p.predicate_default,
}
}
key if contract(key)
key if contract_for(key, pipelines)
.is_some_and(|contract| contract.execution == ExecutionClass::Expression) =>
{
let contract = contract(key).unwrap();
let contract = contract_for(key, pipelines).unwrap();
let object = node.parameters.as_object().ok_or_else(|| {
error(
"GRAPH_PARAMETERS_INVALID",
@@ -909,7 +913,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
.iter()
.enumerate()
.map(|(i, n)| {
contract(&n.executor.key).ok_or_else(|| {
contract_for(&n.executor.key, &graph.pipelines).ok_or_else(|| {
error(
"GRAPH_UNKNOWN_EXECUTOR",
"unknown executor",
@@ -931,7 +935,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
.nodes
.iter()
.enumerate()
.map(|(i, n)| decode(n, i))
.map(|(i, n)| decode(n, i, &graph.pipelines))
.collect::<Result<_, _>>()?;
if graph
.nodes
+24 -11
View File
@@ -202,17 +202,6 @@ pub static CONTRACTS: &[Contract] = &[
c!("mesh", 2, Source, NONE_I, MESH_O, false, None),
c!("texture", 2, Source, NONE_I, TEXTURE_O, false, None),
c!("frustum_cull", 2, Expression, CULL_I, CULL_O, false, None),
c!("ground_plane", 2, Render, RASTER_I, RASTER_O, false, None),
c!("gltf_standard", 2, Render, RASTER_I, RASTER_O, false, None),
c!(
"gltf_standard_double_sided",
2,
Render,
RASTER_I,
RASTER_O,
false,
None
),
c!(
"and",
2,
@@ -443,3 +432,27 @@ pub static CONTRACTS: &[Contract] = &[
pub fn contract(key: &str) -> Option<&'static Contract> {
CONTRACTS.iter().find(|c| c.key == key)
}
static AUTHORED_RENDER_PIPELINE: Contract = c!(
"authored_render_pipeline",
2,
Render,
RASTER_I,
RASTER_O,
false,
None
);
/// Resolve static core executors or a render pipeline declared by this graph.
pub fn contract_for(
key: &str,
pipelines: &crate::render_graph::PipelineDeclarations,
) -> Option<&'static Contract> {
contract(key).or_else(|| {
pipelines
.render
.iter()
.any(|pipeline| pipeline.name == key)
.then_some(&AUTHORED_RENDER_PIPELINE)
})
}
-1
View File
@@ -10,7 +10,6 @@ mod registry;
mod runtime;
mod schema;
pub use ast::{parse as parse_ast, serialize as serialize_ast};
pub use compiler::{compile, parse_and_compile};
pub use contracts::*;
pub use expression::*;
+15 -12
View File
@@ -366,8 +366,8 @@ fn invalid(message: impl Into<String>, path: impl Into<String>) -> GraphError {
error("GRAPH_RUNTIME_PLAN_INVALID", message, path)
}
fn execution_supported(key: &str) -> bool {
contract(key).is_some_and(|contract| {
fn execution_supported(graph: &CompiledGraph, key: &str) -> bool {
contract_for(key, &graph.pipelines).is_some_and(|contract| {
contract.fullscreen_policy.is_some() || contract.is_raster_draw() || key == "frame_out"
})
}
@@ -742,7 +742,7 @@ fn validate_pipeline_resolve(
.filter(|access| matches!(access.mode, AccessMode::ColorResolve { .. }))
.count()
== 1;
if !contract(&producer.executor.key).is_some_and(Contract::is_raster_draw)
if !contract_for(&producer.executor.key, &graph.pipelines).is_some_and(Contract::is_raster_draw)
|| producer.original_node_index != *producer_node_index
|| !exact_output
|| !matches!(&source.origin,
@@ -787,7 +787,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
}
}
for (i, execution) in graph.executions.iter().enumerate() {
if !execution_supported(&execution.executor.key) {
if !execution_supported(graph, &execution.executor.key) {
return Err(error(
"GRAPH_EXECUTION_UNSUPPORTED",
"unsupported execution",
@@ -926,7 +926,8 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
Ok(())
}
for (i, execution) in graph.executions.iter().enumerate() {
let contract = contract(&execution.executor.key).expect("supported executor has contract");
let contract = contract_for(&execution.executor.key, &graph.pipelines)
.expect("supported executor has contract");
if execution.executor.version != contract.version {
return Err(invalid(
"executor version does not match its contract",
@@ -1011,7 +1012,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
));
}
let producer_execution = &graph.executions[producer as usize];
let input_contract = contract(&execution.executor.key)
let input_contract = contract_for(&execution.executor.key, &graph.pipelines)
.expect("supported executor has contract")
.inputs
.iter()
@@ -1125,7 +1126,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
.count()
== 1
})
&& contract(&owner_executions[0].executor.key)
&& contract_for(&owner_executions[0].executor.key, &graph.pipelines)
.is_some_and(Contract::is_raster_draw)
&& owner_executions[0].executor.version == 2
&& graph
@@ -1135,7 +1136,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
.filter(|input| input.resource == source)
.count()
== 1
&& contract(&owner_executions[0].executor.key)
&& contract_for(&owner_executions[0].executor.key, &graph.pipelines)
.and_then(|contract| contract.inputs.get(*input_ordinal as usize))
.is_some_and(|input| {
input.name == *socket
@@ -1365,7 +1366,7 @@ fn validate_instance_traversal(graph: &CompiledGraph) -> Result<(), GraphError>
.iter()
.enumerate()
.filter_map(|(i, e)| {
contract(&e.executor.key)
contract_for(&e.executor.key, &graph.pipelines)
.is_some_and(Contract::is_raster_draw)
.then_some(i as u32)
})
@@ -1693,8 +1694,8 @@ pub fn prepare_runtime_plan(
for (i, execution) in graph.executions.iter().enumerate() {
let path = format!("executions[{i}]");
match execution.executor.key.as_str() {
key if contract(key).is_some_and(Contract::is_raster_draw) => {}
_ if contract(&execution.executor.key)
key if contract_for(key, &graph.pipelines).is_some_and(Contract::is_raster_draw) => {}
_ if contract_for(&execution.executor.key, &graph.pipelines)
.is_some_and(|contract| contract.fullscreen_policy.is_some()) => {}
"frame_out" => {
if frame_out_index.replace(i).is_some() {
@@ -1728,7 +1729,9 @@ pub fn prepare_runtime_plan(
validate_instance_traversal(graph)?;
for (i, execution) in graph.executions.iter().enumerate() {
if !contract(&execution.executor.key).is_some_and(Contract::is_raster_draw) {
if !contract_for(&execution.executor.key, &graph.pipelines)
.is_some_and(Contract::is_raster_draw)
{
continue;
}
let NormalizedParameters::Raster {
+2
View File
@@ -43,6 +43,8 @@ pub struct RenderPipelineDeclaration {
pub fragment_entry: String,
#[serde(default)]
pub double_sided: bool,
#[serde(default)]
pub material: bool,
}
/// A binding-free compute pass dispatched before the graph's render passes.
+30 -19
View File
@@ -42,8 +42,17 @@ fn node(id: &str, key: &str, version: u32, parameters: Value, inputs: Value) ->
"parameters": parameters, "inputs": inputs })
}
fn render_pipeline_declarations() -> Value {
json!({"render":[
{"name":"unlit","shader":"shader","vertexEntry":"vs_main","fragmentEntry":"fs_main","doubleSided":false,"material":false},
{"name":"material","shader":"shader","vertexEntry":"vs_main","fragmentEntry":"fs_main","doubleSided":false,"material":true},
{"name":"material_double_sided","shader":"shader","vertexEntry":"vs_main","fragmentEntry":"fs_main","doubleSided":true,"material":true}
],"compute":[]})
}
pub(crate) fn full_cull_graph() -> Value {
json!({ "schemaVersion": 3, "graphId": "typed", "revision": 1, "nodes": [
json!({ "schemaVersion": 3, "graphId": "typed", "revision": 1,
"pipelines": render_pipeline_declarations(), "nodes": [
texture("color", "rgba16_float"), texture("depth", "depth32_float"),
node("mesh", "mesh", 2, json!({}), json!({})),
node("words", "separate_u32x16", 1, json!({"valueDefault":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}),
@@ -54,7 +63,7 @@ pub(crate) fn full_cull_graph() -> Value {
node("visible", "not", 1, json!({"operandDefault":false}), json!({"operand":input("cull","isFrustumCulled")})),
node("class", "and", 2, json!({}),
json!({"inputs":[input("bits","bit0")[0].clone(),input("visible","value")[0].clone()]})),
node("pipeline", "gltf_standard", 2,
node("pipeline", "material", 2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
json!({"mesh":input("mesh","mesh"),"predicate":input("class","value"),"color":input("color","texture"),"depth":input("depth","texture")})),
node("frame", "frame_out", 3,
@@ -68,12 +77,11 @@ pub(crate) fn full_cull_graph() -> Value {
fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() {
assert_eq!(contract("mesh").unwrap().version, 2);
assert!(contract("pipeline").is_none());
for key in [
"ground_plane",
"gltf_standard",
"gltf_standard_double_sided",
] {
let contract = contract(key).unwrap();
assert!(contract("material").is_none());
let pipelines: PipelineDeclarations =
serde_json::from_value(render_pipeline_declarations()).unwrap();
for key in ["unlit", "material", "material_double_sided"] {
let contract = contract_for(key, &pipelines).unwrap();
assert_eq!(contract.version, 2);
assert!(contract.is_raster_draw());
}
@@ -90,7 +98,7 @@ fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() {
("localAabb", SemanticType::LocalAabb)
]
);
let predicate = contract("gltf_standard")
let predicate = contract_for("material", &pipelines)
.unwrap()
.inputs
.iter()
@@ -376,7 +384,7 @@ fn pipeline_predicate_defaults_true_and_expression_edges_are_validated() {
#[test]
fn raster_executors_reject_removed_pipeline_parameter() {
let mut graph = full_cull_graph();
graph["nodes"][8]["parameters"]["pipeline"] = json!("gltf_standard");
graph["nodes"][8]["parameters"]["pipeline"] = json!("material");
let error = compile_value(graph).unwrap_err();
assert_eq!(error.code, "GRAPH_PARAMETERS_INVALID");
assert_eq!(error.details["path"], "nodes[8].parameters");
@@ -426,7 +434,7 @@ fn sibling_raster_writers_form_one_ordered_physical_pass() {
9,
node(
"sibling",
"ground_plane",
"unlit",
2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
json!({"mesh":input("mesh","mesh"),"color":input("color","texture"),"depth":input("depth","texture")}),
@@ -476,12 +484,13 @@ fn expression_provenance_rejects_cross_mesh_values() {
}
fn implicit_pipeline_graph() -> Value {
json!({ "schemaVersion": 3, "graphId": "implicit", "revision": 1, "nodes": [
json!({ "schemaVersion": 3, "graphId": "implicit", "revision": 1,
"pipelines": render_pipeline_declarations(), "nodes": [
node("mesh", "mesh", 2, json!({}), json!({})),
node("first", "ground_plane", 2,
node("first", "unlit", 2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
json!({"mesh":input("mesh","mesh")})),
node("second", "gltf_standard", 2,
node("second", "material", 2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
json!({"mesh":input("mesh","mesh"),"color":input("first","color"),"depth":input("first","depth")})),
node("frame", "frame_out", 3,
@@ -493,12 +502,12 @@ fn implicit_pipeline_graph() -> Value {
fn three_raster_graph() -> Value {
let mut graph = full_cull_graph();
let nodes = graph["nodes"].as_array_mut().unwrap();
nodes[8]["executor"] = json!({"key":"ground_plane","version":2});
nodes[8]["executor"] = json!({"key":"unlit","version":2});
nodes.insert(
9,
node(
"standard",
"gltf_standard",
"material",
2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[1,0,0,1],"predicateDefault":true}),
json!({"mesh":input("mesh","mesh"),"color":input("pipeline","color"),"depth":input("pipeline","depth")}),
@@ -508,7 +517,7 @@ fn three_raster_graph() -> Value {
10,
node(
"double",
"gltf_standard_double_sided",
"material_double_sided",
2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":0.5,"clearColor":[0,1,0,1],"predicateDefault":true}),
json!({"mesh":input("mesh","mesh"),"color":input("standard","color"),"depth":input("standard","depth")}),
@@ -857,7 +866,9 @@ fn runtime_rejects_noncanonical_physical_passes() {
#[test]
fn contract_v4_declares_strict_default_policies() {
let pipeline = contract("gltf_standard").unwrap();
let declarations: PipelineDeclarations =
serde_json::from_value(render_pipeline_declarations()).unwrap();
let pipeline = contract_for("material", &declarations).unwrap();
assert_eq!(pipeline.version, 2);
assert_eq!(pipeline.inputs[0].default_policy, InputDefaultPolicy::None);
assert_eq!(
@@ -1115,7 +1126,7 @@ fn fullscreen_output_is_a_valid_raster_attachment_root() {
11,
node(
"later",
"ground_plane",
"unlit",
2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
json!({
+1 -1
View File
@@ -1,3 +1,3 @@
mod pipeline;
pub(super) use pipeline::{encode_compiled, encode_immediate};
pub(super) use pipeline::encode_compiled;
+7 -47
View File
@@ -1,28 +1,23 @@
use crate::renderer::{
gpu_scene::GpuSceneCache, material::MaterialResources, ActiveCompiledGraph, PipelineLibrary,
PreparedExecution,
frame_data::FrameData, gpu_scene::GpuSceneCache, material::MaterialResources,
ActiveCompiledGraph, PipelineLibrary, PreparedExecution,
};
use super::super::scene::Scene;
pub(crate) fn encode_compiled<T: Scene>(
pub(crate) fn encode_compiled(
encoder: &mut wgpu::CommandEncoder,
surface: &wgpu::TextureView,
active: &ActiveCompiledGraph,
scene: &T,
frame_data: &FrameData,
gpu: &GpuSceneCache,
pipelines: &PipelineLibrary,
materials: &MaterialResources,
planes: Option<&[[f32; 4]; 6]>,
mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
) -> Result<(), &'static str> {
use crate::render_graph::{NormalizedColorLoad, NormalizedDepthLoad, StoreOp};
for compute in &active.compute {
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some(&compute.name),
timestamp_writes: profile
.as_deref_mut()
.and_then(|profile| profile.compute_writes(&compute.name)),
timestamp_writes: None,
});
pass.set_pipeline(&compute.pipeline);
pass.dispatch_workgroups(
@@ -150,7 +145,7 @@ pub(crate) fn encode_compiled<T: Scene>(
color_attachments: &colors,
depth_stencil_attachment: depth,
occlusion_query_set: None,
timestamp_writes: profile.as_deref_mut().and_then(|p| p.render_writes(&label)),
timestamp_writes: None,
});
for &member in &physical.executions {
match active
@@ -178,7 +173,7 @@ pub(crate) fn encode_compiled<T: Scene>(
.instance_traversal
.as_ref()
.ok_or("compiled graph instance traversal missing")?;
for (i, group) in scene.bind_groups().iter().enumerate() {
for (i, group) in frame_data.bind_groups().iter().enumerate() {
pass.set_bind_group(i as u32, group, &[]);
}
if let (Some(p), Some(n), Some(u), Some(t), Some(ix), Some(inst)) = (
@@ -229,38 +224,3 @@ pub(crate) fn encode_compiled<T: Scene>(
}
Ok(())
}
pub(crate) fn encode_immediate(
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
) {
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("No active render graph"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
depth_slice: None,
view: color,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.,
g: 0.,
b: 0.,
a: 1.,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
occlusion_query_set: None,
timestamp_writes: profile.and_then(|p| p.render_writes("no-active-graph")),
});
}
+146
View File
@@ -0,0 +1,146 @@
#[cfg(target_arch = "wasm32")]
use wgpu::util::DeviceExt;
use crate::render_data::camera::Camera;
#[cfg(target_arch = "wasm32")]
use crate::renderer::{self, PipelineLibrary};
#[cfg(target_arch = "wasm32")]
pub struct UniformResource {
pub buffer: wgpu::Buffer,
pub bind_group: wgpu::BindGroup,
pub bind_group_layout: wgpu::BindGroupLayout,
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable, Debug, Default)]
pub struct FrameMetadata {
pub resolution: [f32; 2],
time: f32,
_padding0: f32,
pub camera_position: [f32; 4],
}
impl FrameMetadata {
#[cfg(target_arch = "wasm32")]
pub fn new(dimension: ultraviolet::Vec2) -> Self {
Self {
resolution: dimension.into(),
camera_position: [0., 0., 0., 1.],
..Default::default()
}
}
pub fn set_camera_position(&mut self, p: ultraviolet::Vec3) {
self.camera_position = [p.x, p.y, p.z, 1.];
}
pub fn update_dimension(&mut self, d: ultraviolet::Vec2) {
self.resolution = d.into();
}
#[cfg(target_arch = "wasm32")]
pub fn create_uniform_resource(self, device: &wgpu::Device) -> UniformResource {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("frame metadata"),
contents: bytemuck::bytes_of(&self),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("frame layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("frame group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
UniformResource {
buffer,
bind_group,
bind_group_layout,
}
}
}
pub(crate) struct FrameData {
uniform_buffers: [wgpu::Buffer; 2],
bind_groups: [wgpu::BindGroup; 2],
metadata: FrameMetadata,
camera: Camera,
}
impl FrameData {
#[cfg(target_arch = "wasm32")]
pub(crate) fn new(
context: &renderer::RendererContext,
resources: &mut PipelineLibrary,
) -> Self {
let dimensions = ultraviolet::Vec2::new(
context.surface_config.width as f32,
context.surface_config.height as f32,
);
let mut metadata = FrameMetadata::new(dimensions);
let camera = Camera::new(dimensions.x / dimensions.y);
metadata.set_camera_position(camera.position());
let frame = metadata.create_uniform_resource(&context.device);
let camera_uniform = camera.create_uniform_resource(&context.device);
resources
.set_bind_group_layouts(&[frame.bind_group_layout, camera_uniform.bind_group_layout]);
Self {
uniform_buffers: [frame.buffer, camera_uniform.buffer],
bind_groups: [frame.bind_group, camera_uniform.bind_group],
metadata,
camera,
}
}
pub(crate) fn bind_groups(&self) -> &[wgpu::BindGroup] {
&self.bind_groups
}
pub(crate) fn camera_mut(&mut self) -> &mut Camera {
&mut self.camera
}
pub(crate) fn frustum_planes(
&mut self,
) -> Result<[[f32; 4]; 6], crate::render_data::camera::FrustumError> {
self.camera.frustum_planes()
}
pub(crate) fn resize(&mut self, width: f64, height: f64, queue: &wgpu::Queue) {
self.metadata
.update_dimension(ultraviolet::Vec2::new(width as f32, height as f32));
self.camera
.update_aspect_ratio(width as f32 / height as f32);
self.write_uniforms(queue);
}
pub(crate) fn update(&mut self, queue: &wgpu::Queue) {
self.metadata.time = js_sys::Date::now() as f32 * 0.001;
self.metadata.set_camera_position(self.camera.position());
self.write_uniforms(queue);
}
fn write_uniforms(&self, queue: &wgpu::Queue) {
queue.write_buffer(
&self.uniform_buffers[0],
0,
bytemuck::bytes_of(&self.metadata),
);
queue.write_buffer(
&self.uniform_buffers[1],
0,
bytemuck::bytes_of(&self.camera.view_proj),
);
}
}
+72 -139
View File
@@ -1,92 +1,15 @@
use std::collections::HashMap;
use bytemuck::{Pod, Zeroable};
use image::DynamicImage;
use wgpu::util::DeviceExt;
use crate::{
gltf::{AlphaMode, ImageSource, ImportedScene, Material, SamplerMetadata, TextureReference},
render_data::MaterialKey,
use crate::render_data::{
upload::{AddressMode, FilterMode, Material, MaterialState, RenderDataUpload, SamplerMetadata},
MaterialKey,
};
const BASE: u32 = 1 << 0;
const MR: u32 = 1 << 1;
const NORMAL: u32 = 1 << 2;
const OCCLUSION: u32 = 1 << 3;
const EMISSIVE: u32 = 1 << 4;
#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable)]
pub struct GpuMaterial {
pub base_color_factor: [f32; 4],
pub emissive_factor: [f32; 4],
pub surface_factors: [f32; 4],
pub alpha_optics: [f32; 4],
pub flags: [u32; 4],
pub uv_sets: [u32; 4],
/// Internal shader diagnostics; zero means the normal shaded view.
pub debug_extras: [u32; 4],
}
fn enabled(reference: Option<TextureReference>, bit: u32) -> u32 {
reference.filter(|r| r.tex_coord == 0).map_or(0, |_| bit)
}
impl From<&Material> for GpuMaterial {
fn from(value: &Material) -> Self {
Self {
base_color_factor: value.base_color_factor,
emissive_factor: [
value.emissive_factor[0],
value.emissive_factor[1],
value.emissive_factor[2],
0.0,
],
surface_factors: [
value.metallic_factor,
value.roughness_factor,
value.normal_scale,
value.occlusion_strength,
],
alpha_optics: [
match value.alpha_mode {
AlphaMode::Opaque => 0.0,
AlphaMode::Mask => 1.0,
AlphaMode::Blend => 2.0,
},
value.alpha_cutoff,
value.ior,
if value.ior == 0.0 {
1.0
} else {
((value.ior - 1.0) / (value.ior + 1.0)).powi(2)
},
],
flags: [
enabled(value.base_color_texture, BASE)
| enabled(value.metallic_roughness_texture, MR)
| enabled(value.normal_texture, NORMAL)
| enabled(value.occlusion_texture, OCCLUSION)
| enabled(value.emissive_texture, EMISSIVE),
u32::from(value.double_sided),
0,
0,
],
uv_sets: [
value.base_color_texture.map_or(0, |x| x.tex_coord),
value.metallic_roughness_texture.map_or(0, |x| x.tex_coord),
value.normal_texture.map_or(0, |x| x.tex_coord),
value.occlusion_texture.map_or(0, |x| x.tex_coord),
],
debug_extras: [value.emissive_texture.map_or(0, |x| x.tex_coord), 0, 0, 0],
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum MaterialError {
#[error("external image sources are unsupported")]
ExternalImage,
#[error("unsupported image MIME type: {0}")]
Mime(String),
#[error("image decode failed: {0}")]
@@ -97,8 +20,13 @@ pub enum MaterialError {
InvalidRgba(&'static str),
}
struct MaterialBinding {
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
}
pub(super) struct PreparedMaterials {
groups: HashMap<MaterialKey, wgpu::BindGroup>,
groups: HashMap<MaterialKey, MaterialBinding>,
textures: Vec<wgpu::Texture>,
views: Vec<[wgpu::TextureView; 2]>,
samplers: Vec<wgpu::Sampler>,
@@ -106,8 +34,8 @@ pub(super) struct PreparedMaterials {
pub struct MaterialResources {
pub layout: wgpu::BindGroupLayout,
groups: HashMap<MaterialKey, wgpu::BindGroup>,
fallback: wgpu::BindGroup,
groups: HashMap<MaterialKey, MaterialBinding>,
fallback: MaterialBinding,
fallback_views: Vec<wgpu::TextureView>,
fallback_sampler: wgpu::Sampler,
textures: Vec<wgpu::Texture>,
@@ -208,12 +136,18 @@ fn slot_uses_srgb(slot: usize) -> bool {
matches!(slot, 0 | 4)
}
fn address(value: &str) -> wgpu::AddressMode {
fn address(value: AddressMode) -> wgpu::AddressMode {
match value {
"ClampToEdge" => wgpu::AddressMode::ClampToEdge,
"MirroredRepeat" => wgpu::AddressMode::MirrorRepeat,
"Repeat" => wgpu::AddressMode::Repeat,
_ => unreachable!("gltf crate returned unknown wrap"),
AddressMode::ClampToEdge => wgpu::AddressMode::ClampToEdge,
AddressMode::MirrorRepeat => wgpu::AddressMode::MirrorRepeat,
AddressMode::Repeat => wgpu::AddressMode::Repeat,
}
}
fn filter(value: FilterMode) -> wgpu::FilterMode {
match value {
FilterMode::Nearest => wgpu::FilterMode::Nearest,
FilterMode::Linear => wgpu::FilterMode::Linear,
}
}
@@ -227,29 +161,13 @@ fn sampler_descriptor(metadata: Option<&SamplerMetadata>) -> wgpu::SamplerDescri
wgpu::AddressMode::Repeat,
),
|m| {
let mag = match m.mag_filter.as_deref() {
Some("Nearest") => wgpu::FilterMode::Nearest,
Some("Linear") | None => wgpu::FilterMode::Linear,
_ => unreachable!(),
};
let (min, mip) = match m.min_filter.as_deref() {
Some("Nearest") => (wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest),
Some("Linear") => (wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest),
Some("NearestMipmapNearest") => {
(wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest)
}
Some("LinearMipmapNearest") => {
(wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest)
}
Some("NearestMipmapLinear") => {
(wgpu::FilterMode::Nearest, wgpu::FilterMode::Linear)
}
Some("LinearMipmapLinear") | None => {
(wgpu::FilterMode::Linear, wgpu::FilterMode::Linear)
}
_ => unreachable!(),
};
(mag, min, mip, address(&m.wrap_s), address(&m.wrap_t))
(
filter(m.mag_filter),
filter(m.min_filter),
filter(m.mipmap_filter),
address(m.address_u),
address(m.address_v),
)
},
);
wgpu::SamplerDescriptor {
@@ -289,7 +207,7 @@ impl MaterialResources {
));
}
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("glTF material group 2"),
label: Some("render-data material group 2"),
entries: &entries,
});
let colors = [[255, 255, 255, 255], [128, 128, 255, 255], [0, 0, 0, 255]];
@@ -333,11 +251,11 @@ impl MaterialResources {
material: &Material,
views: [&wgpu::TextureView; 5],
samplers: [&wgpu::Sampler; 5],
) -> wgpu::BindGroup {
) -> MaterialBinding {
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("material uniform"),
contents: bytemuck::bytes_of(&GpuMaterial::from(material)),
usage: wgpu::BufferUsages::UNIFORM,
contents: bytemuck::bytes_of(&MaterialState::from(material)),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let mut entries = vec![wgpu::BindGroupEntry {
binding: 0,
@@ -355,30 +273,28 @@ impl MaterialResources {
resource: wgpu::BindingResource::Sampler(sampler),
});
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("material bind group"),
layout,
entries: &entries,
})
});
MaterialBinding { group, uniform }
}
pub(super) fn prepare(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
scene: &ImportedScene,
scene: &RenderDataUpload,
) -> Result<PreparedMaterials, MaterialError> {
let max_dimension = device.limits().max_texture_dimension_2d;
let mut textures = Vec::with_capacity(scene.images.len());
let mut views = Vec::with_capacity(scene.images.len());
for image in &scene.images {
if !matches!(image.source, ImageSource::BufferView(_)) {
return Err(MaterialError::ExternalImage);
}
let format = match image.mime_type.as_deref() {
Some("image/png") => image::ImageFormat::Png,
Some("image/jpeg") => image::ImageFormat::Jpeg,
other => return Err(MaterialError::Mime(other.unwrap_or("missing").into())),
let format = match image.mime_type.as_str() {
"image/png" => image::ImageFormat::Png,
"image/jpeg" => image::ImageFormat::Jpeg,
other => return Err(MaterialError::Mime(other.into())),
};
let decoded = image::load_from_memory_with_format(&image.encoded_data, format)?;
let (width, height, rgba) = normalize_rgba(decoded);
@@ -386,7 +302,7 @@ impl MaterialResources {
let (texture, image_views) = upload_rgba(
device,
queue,
"glTF embedded image",
"render-data image",
width,
height,
bytes_per_row,
@@ -464,7 +380,24 @@ impl MaterialResources {
}
pub fn group(&self, key: MaterialKey) -> &wgpu::BindGroup {
self.groups.get(&key).unwrap_or(&self.fallback)
&self.groups.get(&key).unwrap_or(&self.fallback).group
}
pub fn synchronize(
&self,
queue: &wgpu::Queue,
rows: &[(MaterialKey, [u32; MaterialState::LANES as usize])],
) {
for (key, words) in rows {
let binding = self
.groups
.get(key)
.or_else(|| (*key == MaterialKey::DEFAULT).then_some(&self.fallback));
if let Some(binding) = binding {
let state = MaterialState::from_words(*words);
queue.write_buffer(&binding.uniform, 0, bytemuck::bytes_of(&state));
}
}
}
}
@@ -510,30 +443,30 @@ mod tests {
}
#[test]
fn material_uniform_is_112_bytes() {
assert_eq!(std::mem::size_of::<GpuMaterial>(), 112);
assert_eq!(std::mem::size_of::<MaterialState>(), 112);
}
#[test]
fn texcoord_one_disables_slot() {
let mut m = Material::default();
m.base_color_texture = Some(TextureReference {
m.base_color_texture = Some(crate::render_data::upload::TextureReference {
texture: 0,
tex_coord: 1,
});
assert_eq!(GpuMaterial::from(&m).flags[0] & BASE, 0);
assert_eq!(MaterialState::from(&m).flags[0] & 1, 0);
}
#[test]
fn material_packing_includes_ior_f0_flags_and_uv_sets() {
let mut m = Material::default();
m.ior = 2.0;
m.double_sided = true;
m.normal_texture = Some(TextureReference {
m.normal_texture = Some(crate::render_data::upload::TextureReference {
texture: 4,
tex_coord: 0,
});
let gpu = GpuMaterial::from(&m);
let gpu = MaterialState::from(&m);
assert_eq!(gpu.alpha_optics[2], 2.0);
assert!((gpu.alpha_optics[3] - 1.0 / 9.0).abs() < 1e-6);
assert_eq!(gpu.flags[0] & NORMAL, NORMAL);
assert_eq!(gpu.flags[0] & (1 << 2), 1 << 2);
assert_eq!(gpu.flags[1], 1);
assert_eq!(gpu.uv_sets[2], 0);
}
@@ -541,18 +474,18 @@ mod tests {
fn explicit_ior_sentinel_packs_unit_f0() {
let mut material = Material::default();
material.ior = 0.0;
let gpu = GpuMaterial::from(&material);
let gpu = MaterialState::from(&material);
assert_eq!(gpu.alpha_optics[2], 0.0);
assert_eq!(gpu.alpha_optics[3], 1.0);
}
#[test]
fn sampler_translation_is_exact() {
let m = SamplerMetadata {
index: 0,
mag_filter: Some("Nearest".into()),
min_filter: Some("LinearMipmapNearest".into()),
wrap_s: "ClampToEdge".into(),
wrap_t: "MirroredRepeat".into(),
mag_filter: FilterMode::Nearest,
min_filter: FilterMode::Linear,
mipmap_filter: FilterMode::Nearest,
address_u: AddressMode::ClampToEdge,
address_v: AddressMode::MirrorRepeat,
};
let d = sampler_descriptor(Some(&m));
assert_eq!(d.mag_filter, wgpu::FilterMode::Nearest);
File diff suppressed because it is too large Load Diff
+19 -13
View File
@@ -171,11 +171,11 @@ impl PipelineLibrary {
key
}
/// Registers the glTF-only layout, preserving scene groups at 0 and 1.
/// Registers the optional material layout after the frame and render-data groups.
pub fn set_material_bind_group_layout(&mut self, layout: &wgpu::BindGroupLayout) {
let base = self
.default_layout
.expect("scene layouts must be registered first");
.expect("render-data layouts must be registered first");
let mut layouts = self.layout_bindings[&base].clone();
layouts.push(layout.clone());
let key = PipelineLayoutKey(self.next_layout);
@@ -184,12 +184,13 @@ impl PipelineLibrary {
self.material_layout = Some(key);
}
fn compatibility_spec(
fn authored_spec(
&self,
name: &str,
layouts: &[wgpu::VertexBufferLayout],
shader: &str,
format: wgpu::TextureFormat,
material: bool,
double_sided: bool,
) -> RenderPipelineSpec {
let stage = |entry: &str| OwnedProgrammableStage {
shader_source: shader.to_owned(),
@@ -198,7 +199,7 @@ impl PipelineLibrary {
zero_initialize_workgroup_memory: true,
};
RenderPipelineSpec {
layout: if name.starts_with("gltf_") {
layout: if material {
self.material_layout.or(self.default_layout)
} else {
self.default_layout
@@ -214,7 +215,7 @@ impl PipelineLibrary {
.collect(),
fragment: Some(stage("fs_main")),
primitive: wgpu::PrimitiveState {
cull_mode: (name != "gltf_standard_double_sided").then_some(wgpu::Face::Back),
cull_mode: (!double_sided).then_some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
@@ -330,7 +331,7 @@ impl PipelineLibrary {
pipeline_key
}
/// Creates a graph-owned scene pipeline from its authored declaration.
/// Creates a graph-owned render-data pipeline from its authored declaration.
pub(crate) fn get_or_create_authored_pipeline(
&mut self,
device: &wgpu::Device,
@@ -338,14 +339,18 @@ impl PipelineLibrary {
layouts: &[wgpu::VertexBufferLayout],
format: wgpu::TextureFormat,
) -> PipelineKey {
let mut spec =
self.compatibility_spec(&declaration.name, layouts, &declaration.shader, format);
let mut spec = self.authored_spec(
layouts,
&declaration.shader,
format,
declaration.material,
declaration.double_sided,
);
spec.vertex.entry_point = declaration.vertex_entry.clone();
spec.fragment
.as_mut()
.expect("scene pipelines have fragment stages")
.expect("render-data pipelines have fragment stages")
.entry_point = declaration.fragment_entry.clone();
spec.primitive.cull_mode = (!declaration.double_sided).then_some(wgpu::Face::Back);
self.get_or_create_from_spec(device, &spec, Some(&declaration.name))
}
@@ -463,11 +468,12 @@ mod tests {
use super::*;
fn spec() -> RenderPipelineSpec {
PipelineLibrary::new().compatibility_spec(
"x",
PipelineLibrary::new().authored_spec(
&[],
"shader",
wgpu::TextureFormat::Rgba8Unorm,
false,
false,
)
}
-537
View File
@@ -1,537 +0,0 @@
use std::{
collections::{HashMap, VecDeque},
sync::{Arc, Mutex},
};
use wasm_bindgen::JsValue;
// A frame can contain every logical execution as a singleton physical pass and
// one instance-traversal compute pass.
pub const MAX_PROFILE_PASSES: usize = crate::render_graph::MAX_EXECUTIONS + 1;
#[cfg(any(target_arch = "wasm32", test))]
const SLOT_COUNT: usize = 4;
#[cfg(any(target_arch = "wasm32", test))]
const QUERY_COUNT: u32 = (MAX_PROFILE_PASSES * 2) as u32;
#[cfg(any(target_arch = "wasm32", test))]
const USED_RESOLVE_SIZE: u64 = QUERY_COUNT as u64 * 8;
#[cfg(any(target_arch = "wasm32", test))]
const RESOLVE_SIZE: u64 = USED_RESOLVE_SIZE.next_multiple_of(wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlotState {
Free,
Encoding,
Mapping,
}
struct Slot {
queries: wgpu::QuerySet,
resolve: wgpu::Buffer,
read: wgpu::Buffer,
state: SlotState,
}
struct Completion {
slot: usize,
epoch: u64,
ids: Vec<String>,
values: Option<Vec<u64>>,
}
pub(crate) struct ProfileFrame {
pub query_set: wgpu::QuerySet,
slot: usize,
identity: String,
ids: Vec<String>,
invalid: bool,
}
pub(crate) struct ProfileMap {
slot: usize,
epoch: u64,
ids: Vec<String>,
count: u32,
}
impl ProfileFrame {
fn allocate(&mut self, id: &str) -> Option<u32> {
allocate_id(&mut self.ids, &mut self.invalid, id)
}
pub fn render_writes(&mut self, id: &str) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
let first = self.allocate(id)?;
Some(wgpu::RenderPassTimestampWrites {
query_set: &self.query_set,
beginning_of_pass_write_index: Some(first),
end_of_pass_write_index: Some(first + 1),
})
}
pub fn compute_writes(&mut self, id: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
let first = self.allocate(id)?;
Some(wgpu::ComputePassTimestampWrites {
query_set: &self.query_set,
beginning_of_pass_write_index: Some(first),
end_of_pass_write_index: Some(first + 1),
})
}
}
pub(crate) struct Profiler {
enabled: bool,
available: bool,
slots: Vec<Slot>,
completions: Arc<Mutex<Vec<Completion>>>,
epoch: u64,
identity: String,
period_ns: f64,
samples: HashMap<String, VecDeque<(f64, f64)>>,
last_snapshot_ms: f64,
dropped: u64,
}
impl Profiler {
#[cfg(any(target_arch = "wasm32", test))]
pub fn requested_features(requested: bool, supported: wgpu::Features) -> wgpu::Features {
if requested && supported.contains(wgpu::Features::TIMESTAMP_QUERY) {
wgpu::Features::TIMESTAMP_QUERY
} else {
wgpu::Features::empty()
}
}
#[cfg(target_arch = "wasm32")]
pub async fn new(requested: bool, device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
let available = requested && device.features().contains(wgpu::Features::TIMESTAMP_QUERY);
let mut slots = Vec::new();
if available {
device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
device.push_error_scope(wgpu::ErrorFilter::Validation);
for _ in 0..SLOT_COUNT {
let queries = device.create_query_set(&wgpu::QuerySetDescriptor {
label: Some("profile timestamps"),
ty: wgpu::QueryType::Timestamp,
count: QUERY_COUNT,
});
let resolve = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("profile resolve"),
size: RESOLVE_SIZE,
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("profile readback"),
size: RESOLVE_SIZE,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
slots.push(Slot {
queries,
resolve,
read,
state: SlotState::Free,
});
}
}
let allocation_failed = if available {
// Pop both scopes before yielding: WebGPU's scope stack must be unwound
// synchronously, even though completion of each pop is asynchronous.
let validation = device.pop_error_scope();
let oom = device.pop_error_scope();
let (validation, oom) = futures::join!(validation, oom);
validation.is_some() || oom.is_some()
} else {
false
};
if allocation_failed {
slots.clear();
}
Self {
enabled: requested,
available: available && !allocation_failed,
slots,
completions: Default::default(),
epoch: 0,
identity: String::new(),
period_ns: queue.get_timestamp_period() as f64,
samples: Default::default(),
last_snapshot_ms: 0.0,
dropped: 0,
}
}
pub fn begin(&mut self, identity: impl FnOnce() -> String) -> Option<ProfileFrame> {
self.drain();
let Some((slot, identity)) = profile_gate(self.enabled, self.available, || {
begin_transition(self.slots.iter_mut().map(|slot| &mut slot.state))
.map(|slot| (slot, identity()))
}) else {
if self.enabled && self.available {
self.dropped += 1;
}
return None;
};
Some(ProfileFrame {
query_set: self.slots[slot].queries.clone(),
slot,
identity,
ids: Vec::new(),
invalid: false,
})
}
pub fn cancel(&mut self, frame: ProfileFrame) {
cancel_state(&mut self.slots[frame.slot].state);
}
pub fn finish(
&mut self,
encoder: &mut wgpu::CommandEncoder,
frame: ProfileFrame,
) -> Option<ProfileMap> {
let count = match finish_transition(
&mut self.slots[frame.slot].state,
frame.invalid,
frame.ids.len(),
) {
FinishAction::Cancel => return None,
FinishAction::Resolve(count) => count,
};
if frame.identity != self.identity {
self.identity = frame.identity;
self.epoch = self.epoch.wrapping_add(1);
self.samples.clear();
}
let slot = frame.slot;
let epoch = self.epoch;
let ids = frame.ids;
encoder.resolve_query_set(
&self.slots[slot].queries,
0..count,
&self.slots[slot].resolve,
0,
);
encoder.copy_buffer_to_buffer(
&self.slots[slot].resolve,
0,
&self.slots[slot].read,
0,
count as u64 * 8,
);
Some(ProfileMap {
slot,
epoch,
ids,
count,
})
}
pub fn map(&mut self, request: ProfileMap) {
let ProfileMap {
slot,
epoch,
ids,
count,
} = request;
self.slots[slot].state = SlotState::Mapping;
let buffer = self.slots[slot].read.clone();
let completions = self.completions.clone();
buffer
.clone()
.slice(..count as u64 * 8)
.map_async(wgpu::MapMode::Read, move |result| {
let values = result.ok().map(|_| {
let bytes = buffer.slice(..count as u64 * 8).get_mapped_range();
let values = bytes
.chunks_exact(8)
.map(|x| u64::from_le_bytes(x.try_into().unwrap()))
.collect();
drop(bytes);
buffer.unmap();
values
});
if let Ok(mut completions) = completions.lock() {
completions.push(Completion {
slot,
epoch,
ids,
values,
});
}
});
}
fn drain(&mut self) {
let completions = if let Ok(mut queue) = self.completions.lock() {
queue.drain(..).collect::<Vec<_>>()
} else {
return;
};
for c in completions {
let Some(v) = completion_transition(
&mut self.slots[c.slot].state,
c.values,
c.epoch,
self.epoch,
&mut self.available,
&mut self.samples,
) else {
continue;
};
for (id, pair) in c.ids.into_iter().zip(v.chunks_exact(2)) {
if let Some(ms) = validate(pair[0], pair[1], self.period_ns) {
let q = self.samples.entry(id).or_default();
q.push_back((js_sys::Date::now(), ms));
}
}
}
}
pub fn snapshot_json(&mut self, now: f64) -> Option<JsValue> {
self.drain();
profile_gate(self.enabled, self.available, || Some(()))?;
(now - self.last_snapshot_ms >= 250.0).then_some(())?;
self.last_snapshot_ms = now;
let cutoff = now - 1000.0;
let mut passes = serde_json::Map::new();
for (id, q) in &mut self.samples {
while q.front().is_some_and(|x| x.0 < cutoff) {
q.pop_front();
}
if !q.is_empty() {
passes.insert(
id.clone(),
serde_json::json!(q.iter().map(|x| x.1).sum::<f64>() / q.len() as f64),
);
}
}
let value = serde_json::json!({"type":"profile-snapshot","requested":self.enabled,"available":self.available,"epoch":self.epoch,"graph":self.identity,"passes":passes,"dropped":self.dropped});
js_sys::JSON::parse(&value.to_string()).ok()
}
}
fn allocate_id(ids: &mut Vec<String>, invalid: &mut bool, id: &str) -> Option<u32> {
if ids.len() >= MAX_PROFILE_PASSES {
*invalid = true;
return None;
}
let first = ids.len() as u32 * 2;
ids.push(id.to_owned());
Some(first)
}
fn profile_gate<T>(enabled: bool, available: bool, f: impl FnOnce() -> Option<T>) -> Option<T> {
(enabled && available).then(f).flatten()
}
fn begin_transition<'a>(states: impl IntoIterator<Item = &'a mut SlotState>) -> Option<usize> {
for (slot, state) in states.into_iter().enumerate() {
if *state == SlotState::Free {
*state = SlotState::Encoding;
return Some(slot);
}
}
None
}
#[derive(Debug, PartialEq, Eq)]
enum FinishAction {
Cancel,
Resolve(u32),
}
fn finish_transition(state: &mut SlotState, invalid: bool, id_count: usize) -> FinishAction {
if *state != SlotState::Encoding || invalid || id_count == 0 {
cancel_state(state);
FinishAction::Cancel
} else {
FinishAction::Resolve(id_count as u32 * 2)
}
}
fn completion_transition(
state: &mut SlotState,
values: Option<Vec<u64>>,
completion_epoch: u64,
current_epoch: u64,
available: &mut bool,
samples: &mut HashMap<String, VecDeque<(f64, f64)>>,
) -> Option<Vec<u64>> {
*state = SlotState::Free;
match values {
None => {
*available = false;
samples.clear();
None
}
Some(_) if !*available || completion_epoch != current_epoch => None,
Some(values) => Some(values),
}
}
fn cancel_state(state: &mut SlotState) {
if *state == SlotState::Encoding {
*state = SlotState::Free;
}
}
fn validate(start: u64, end: u64, period: f64) -> Option<f64> {
if (start == 0 && end == 0) || end < start || !period.is_finite() || period <= 0.0 {
return None;
}
let ms = (end - start) as f64 * period / 1_000_000.0;
if ms.is_finite() && ms >= 0.0 && ms <= 1000.0 {
Some(ms)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_gate() {
assert!(Profiler::requested_features(false, wgpu::Features::TIMESTAMP_QUERY).is_empty());
assert!(Profiler::requested_features(true, wgpu::Features::empty()).is_empty());
assert_eq!(
Profiler::requested_features(true, wgpu::Features::TIMESTAMP_QUERY),
wgpu::Features::TIMESTAMP_QUERY
)
}
#[test]
fn validation() {
assert_eq!(validate(1, 2, 1_000_000.0), Some(1.0));
assert_eq!(validate(2, 2, 1.0), Some(0.0));
assert_eq!(validate(0, 0, 1.0), None);
assert_eq!(validate(2, 1, 1.0), None);
assert_eq!(validate(0, 1, 1_000_000_000.0), Some(1000.0));
assert_eq!(validate(0, 2, 1_000_000_000.0), None);
assert_eq!(validate(1, 2, f64::NAN), None);
assert_eq!(validate(1, 2, f64::INFINITY), None);
assert_eq!(validate(1, 2, 0.0), None);
assert_eq!(validate(1, 2, -1.0), None);
}
#[test]
fn capacity_is_aligned() {
assert_eq!(RESOLVE_SIZE % wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, 0);
assert!(RESOLVE_SIZE >= USED_RESOLVE_SIZE);
assert!(RESOLVE_SIZE - USED_RESOLVE_SIZE < wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT);
assert_eq!(QUERY_COUNT as usize, MAX_PROFILE_PASSES * 2)
}
#[test]
fn lazy_identity_when_disabled_unavailable_or_full() {
let mut calls = 0;
for (enabled, available) in [(false, true), (true, false)] {
assert_eq!(
profile_gate(enabled, available, || {
calls += 1;
Some(7)
}),
None
);
}
assert_eq!(calls, 0);
let mut full = [SlotState::Mapping; SLOT_COUNT];
assert_eq!(
profile_gate(true, true, || {
begin_transition(&mut full).map(|slot| {
calls += 1;
slot
})
}),
None
);
assert_eq!(calls, 0);
assert_eq!(full, [SlotState::Mapping; SLOT_COUNT]);
let mut states = [SlotState::Mapping, SlotState::Free];
assert_eq!(
profile_gate(true, true, || {
begin_transition(&mut states).map(|slot| {
calls += 1;
slot
})
}),
Some(1)
);
assert_eq!(calls, 1);
assert_eq!(states, [SlotState::Mapping, SlotState::Encoding]);
}
#[test]
fn compact_ids_and_query_pairs_resolve_four() {
let mut ids = Vec::new();
let mut invalid = false;
assert_eq!(allocate_id(&mut ids, &mut invalid, "a"), Some(0));
assert_eq!(allocate_id(&mut ids, &mut invalid, "b"), Some(2));
assert_eq!(ids, ["a", "b"]);
let mut state = SlotState::Encoding;
assert_eq!(
finish_transition(&mut state, invalid, ids.len()),
FinishAction::Resolve(4)
);
assert!(!invalid);
}
#[test]
fn capacity_overflow_marks_frame_invalid() {
let mut ids = (0..MAX_PROFILE_PASSES).map(|i| i.to_string()).collect();
let mut invalid = false;
assert_eq!(allocate_id(&mut ids, &mut invalid, "overflow"), None);
assert!(invalid);
assert_eq!(ids.len(), MAX_PROFILE_PASSES);
let mut overflow = SlotState::Encoding;
assert_eq!(
finish_transition(&mut overflow, invalid, ids.len()),
FinishAction::Cancel
);
assert_eq!(overflow, SlotState::Free);
let mut empty = SlotState::Encoding;
assert_eq!(
finish_transition(&mut empty, false, 0),
FinishAction::Cancel
);
assert_eq!(empty, SlotState::Free);
}
#[test]
fn repeated_cancel_and_mapping_guard() {
let mut encoding = SlotState::Encoding;
for _ in 0..=SLOT_COUNT {
cancel_state(&mut encoding);
assert_eq!(encoding, SlotState::Free);
encoding = SlotState::Encoding;
}
let mut mapping = SlotState::Mapping;
cancel_state(&mut mapping);
assert_eq!(mapping, SlotState::Mapping);
let mut free = SlotState::Free;
cancel_state(&mut free);
assert_eq!(free, SlotState::Free);
}
#[test]
fn stale_epoch_map_failure_is_terminal_and_prevents_later_aggregation() {
let mut state = SlotState::Mapping;
let mut available = true;
let mut samples = HashMap::from([
("a".into(), VecDeque::from([(1.0, 2.0)])),
("b".into(), VecDeque::from([(3.0, 4.0)])),
]);
assert_eq!(
completion_transition(&mut state, None, 1, 2, &mut available, &mut samples),
None
);
assert_eq!(state, SlotState::Free);
assert!(!available);
assert!(samples.is_empty());
for epoch in [2, 1] {
state = SlotState::Mapping;
assert_eq!(
completion_transition(
&mut state,
Some(vec![1, 2]),
epoch,
2,
&mut available,
&mut samples
),
None
);
assert_eq!(state, SlotState::Free);
assert!(samples.is_empty());
}
}
#[test]
fn snapshot_gate_is_silent_when_disabled_or_unavailable() {
for enabled in [false, true] {
for available in [false, true] {
assert_eq!(
profile_gate(enabled, available, || Some("snapshot")),
(enabled && available).then_some("snapshot")
);
}
}
}
}
-130
View File
@@ -1,130 +0,0 @@
use wgpu::util::DeviceExt;
use crate::{
camera::Camera,
render_data::RenderData,
renderer::{self, PipelineLibrary},
};
pub struct UniformResource {
pub buffer: wgpu::Buffer,
pub bind_group: wgpu::BindGroup,
pub bind_group_layout: wgpu::BindGroupLayout,
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable, Debug, Default)]
pub struct FrameMetadata {
pub mouse_move: [f32; 2],
pub mouse_click: [f32; 2],
pub resolution: [f32; 2],
time: f32,
_padding0: f32,
pub camera_position: [f32; 4],
}
impl FrameMetadata {
pub fn new(dimension: ultraviolet::Vec2) -> Self {
Self {
resolution: dimension.into(),
mouse_move: [f32::MIN; 2],
mouse_click: [f32::MIN; 2],
camera_position: [0., 0., 0., 1.],
..Default::default()
}
}
pub fn set_camera_position(&mut self, p: ultraviolet::Vec3) {
self.camera_position = [p.x, p.y, p.z, 1.];
}
pub fn update_dimension(&mut self, d: ultraviolet::Vec2) {
self.resolution = d.into();
}
pub fn create_uniform_resource(self, device: &wgpu::Device) -> UniformResource {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("frame metadata"),
contents: bytemuck::bytes_of(&self),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("frame layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("frame group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
UniformResource {
buffer,
bind_group,
bind_group_layout,
}
}
}
pub trait Scene: Sized {
fn setup(
context: &renderer::RendererContext,
resources: &mut PipelineLibrary,
data: &mut RenderData,
) -> Self;
fn bind_groups(&self) -> &[wgpu::BindGroup];
fn handle_mouse_click(&mut self, x: f32, y: f32);
fn handle_zoom(&mut self, delta_y: f32);
fn handle_orbit(&mut self, dx: f32, dy: f32);
fn handle_pan(&mut self, dx: f32, dy: f32, viewport_height: f32);
fn set_camera_depth_range(&mut self, near: f32, far: f32);
fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3);
fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> {
None
}
fn camera_mut(&mut self) -> Option<&mut Camera> {
None
}
fn frustum_planes(&mut self) -> Option<Result<[[f32; 4]; 6], crate::camera::FrustumError>> {
self.camera_mut().map(|camera| camera.frustum_planes())
}
fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> {
None
}
fn resize(&mut self, width: f64, height: f64, _: f64, queue: &wgpu::Queue) {
if let Some(f) = self.frame_metadata_mut() {
f.update_dimension(ultraviolet::Vec2::new(width as f32, height as f32));
}
if let Some(c) = self.camera_mut() {
c.update_aspect_ratio(width as f32 / height as f32)
}
self.write_uniforms(queue);
}
fn update_cpu(&mut self) {
let position = match self.camera_mut() {
Some(c) => c.position(),
None => return,
};
if let Some(f) = self.frame_metadata_mut() {
f.time = js_sys::Date::now() as f32 * 0.001;
f.set_camera_position(position)
}
}
fn write_uniforms(&mut self, queue: &wgpu::Queue) {
let frame = self.frame_metadata_mut().copied();
let view = self.camera_mut().map(|c| c.view_proj);
if let (Some(f), Some(v), Some([frame_buffer, camera_buffer])) =
(frame, view, self.uniform_buffers())
{
queue.write_buffer(frame_buffer, 0, bytemuck::bytes_of(&f));
queue.write_buffer(camera_buffer, 0, bytemuck::bytes_of(&v));
}
}
}
+232 -7
View File
@@ -8,7 +8,11 @@ use std::{
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::render_data::{InstanceHandle, RenderData, RenderDataCapacities};
use crate::render_data::{
camera::Camera,
upload::{Material, MaterialState},
InstanceHandle, MaterialKey, RenderData, RenderDataCapacities,
};
pub const MAGIC: u32 = u32::from_le_bytes(*b"YSOA");
pub const VERSION: u32 = 1;
@@ -344,6 +348,8 @@ fn valid_name(value: &str) -> bool {
pub struct SharedSoaRegistry {
arrays: BTreeMap<String, SharedArray>,
next_id: u32,
layout_changed: bool,
material_keys: Vec<MaterialKey>,
published_instance_generations: BTreeMap<u32, u32>,
published_mesh_generations: BTreeMap<u32, u32>,
}
@@ -353,6 +359,8 @@ impl SharedSoaRegistry {
let mut registry = Self {
arrays: BTreeMap::new(),
next_id: 1,
layout_changed: false,
material_keys: Vec::new(),
published_instance_generations: BTreeMap::new(),
published_mesh_generations: BTreeMap::new(),
};
@@ -389,9 +397,28 @@ impl SharedSoaRegistry {
stride: Some(16),
length: None,
},
ArrayRequest {
name: "camera.state".into(),
domain: ArrayDomain::Fixed,
scalar: ScalarType::F32,
lanes: 16,
stride: Some(64),
length: Some(1),
},
ArrayRequest {
name: "material.state".into(),
domain: ArrayDomain::Fixed,
scalar: ScalarType::U32,
lanes: MaterialState::LANES,
stride: Some(112),
length: Some(1),
},
] {
registry.allocate(request, capacities)?;
}
registry.publish_materials(&[Material::default()])?;
registry.material_keys.clear();
registry.layout_changed = false;
Ok(registry)
}
@@ -447,7 +474,7 @@ impl SharedSoaRegistry {
return Err(SharedSoaError::LayoutConflict);
}
if request.domain == ArrayDomain::Fixed {
existing.resize(capacity)?;
self.layout_changed |= existing.resize(capacity)?;
}
return existing.descriptor();
}
@@ -460,6 +487,7 @@ impl SharedSoaRegistry {
let array = SharedArray::new(id, request, stride / 4, capacity)?;
let descriptor = array.descriptor()?;
self.arrays.insert(name, array);
self.layout_changed = true;
Ok(descriptor)
}
@@ -514,12 +542,132 @@ impl SharedSoaRegistry {
Ok(bytes)
}
/// Publish an infrequent worker-owned camera reset into the canonical shared row.
pub fn publish_camera(&mut self, camera: &Camera) -> Result<(), SharedSoaError> {
let array = self
.arrays
.get_mut("camera.state")
.ok_or(SharedSoaError::UnknownArray)?;
let sequence = array.try_lock().ok_or(SharedSoaError::Busy)?;
for (lane, value) in camera.shared_state().into_iter().enumerate() {
array
.data_word(0, lane as u32)
.store(value.to_bits(), Ordering::Relaxed);
}
array.unlock(sequence);
Ok(())
}
/// Apply a newly published shared camera row. Invalid external rows are replaced
/// with the last valid worker state so all writers can recover on their next read.
pub fn synchronize_camera(&mut self, camera: &mut Camera) -> Result<(), SharedSoaError> {
let Some(array) = self.arrays.get_mut("camera.state") else {
return Err(SharedSoaError::UnknownArray);
};
if !array.changed() {
return Ok(());
}
let sequence = array.try_lock().ok_or(SharedSoaError::Busy)?;
let state = std::array::from_fn(|lane| {
f32::from_bits(array.data_word(0, lane as u32).load(Ordering::Acquire))
});
array.unlock(sequence);
if !camera.apply_shared_state(state) {
self.publish_camera(camera)?;
}
Ok(())
}
/// Replaces the packed material rows after a transactional render-data upload.
pub fn publish_materials(&mut self, materials: &[Material]) -> Result<(), SharedSoaError> {
let length = materials
.iter()
.map(|material| material.key.get())
.max()
.unwrap_or(0)
.checked_add(1)
.ok_or(SharedSoaError::SizeOverflow)?;
self.allocate(
ArrayRequest {
name: "material.state".into(),
domain: ArrayDomain::Fixed,
scalar: ScalarType::U32,
lanes: MaterialState::LANES,
stride: Some(112),
length: Some(length),
},
RenderDataCapacities {
vertices: 0,
indices: 0,
meshes: 0,
instances: 0,
},
)?;
let array = self
.arrays
.get_mut("material.state")
.ok_or(SharedSoaError::UnknownArray)?;
let sequence = (0..1024)
.find_map(|_| array.try_lock())
.ok_or(SharedSoaError::Busy)?;
let fallback = MaterialState::from(&Material::default()).words();
for slot in 0..length {
for (lane, word) in fallback.iter().copied().enumerate() {
array
.data_word(slot, lane as u32)
.store(word, Ordering::Relaxed);
}
}
for material in materials {
for (lane, word) in MaterialState::from(material)
.words()
.into_iter()
.enumerate()
{
array
.data_word(material.key.get(), lane as u32)
.store(word, Ordering::Relaxed);
}
}
array.unlock(sequence);
self.material_keys = materials.iter().map(|material| material.key).collect();
self.material_keys.sort_by_key(|key| key.get());
self.material_keys.dedup();
Ok(())
}
/// Takes complete changed material rows for one batched queue write per material.
pub fn take_material_words(
&mut self,
) -> Option<Vec<(MaterialKey, [u32; MaterialState::LANES as usize])>> {
let array = self.arrays.get_mut("material.state")?;
if !array.changed() {
return None;
}
let sequence = array.try_lock()?;
let rows = self
.material_keys
.iter()
.copied()
.map(|key| {
let words = std::array::from_fn(|lane| {
array
.data_word(key.get(), lane as u32)
.load(Ordering::Acquire)
});
(key, words)
})
.collect();
array.unlock(sequence);
Some(rows)
}
/// Reallocates matching-domain columns before a frame. Old blocks stay pinned.
pub fn sync_capacities(
&mut self,
capacities: RenderDataCapacities,
) -> Result<bool, SharedSoaError> {
let mut changed = false;
let mut changed = std::mem::take(&mut self.layout_changed);
for array in self.arrays.values_mut() {
if array.request.domain == ArrayDomain::Fixed {
continue;
@@ -718,6 +866,83 @@ mod tests {
}
}
#[test]
fn camera_is_one_aligned_row_and_external_updates_are_validated() {
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
let descriptor = registry.arrays["camera.state"].descriptor().unwrap();
assert_eq!(descriptor.domain, ArrayDomain::Fixed);
assert_eq!(descriptor.scalar, ScalarType::F32);
assert_eq!(
(descriptor.lanes, descriptor.stride, descriptor.length),
(16, 64, 1)
);
let mut camera = Camera::new(1.5);
registry.publish_camera(&camera).unwrap();
let mut external = camera.shared_state();
external[0..3].copy_from_slice(&[2.0, 1.0, 4.0]);
external[13] = 2.0;
let array = registry.arrays.get_mut("camera.state").unwrap();
let sequence = array.word(9).load(Ordering::Acquire);
array.word(9).store(sequence + 1, Ordering::Release);
for (lane, value) in external.into_iter().enumerate() {
array
.data_word(0, lane as u32)
.store(value.to_bits(), Ordering::Relaxed);
}
array.word(9).store(sequence + 2, Ordering::Release);
registry.synchronize_camera(&mut camera).unwrap();
assert_eq!(camera.shared_state(), external);
let valid = camera.shared_state();
let array = registry.arrays.get_mut("camera.state").unwrap();
let sequence = array.word(9).load(Ordering::Acquire);
array.word(9).store(sequence + 1, Ordering::Release);
array
.data_word(0, 13)
.store(f32::NAN.to_bits(), Ordering::Relaxed);
array.word(9).store(sequence + 2, Ordering::Release);
registry.synchronize_camera(&mut camera).unwrap();
let recovered = registry.arrays["camera.state"].data_word(0, 13);
assert_eq!(f32::from_bits(recovered.load(Ordering::Acquire)), valid[13]);
}
#[test]
fn material_rows_are_packed_resized_and_consumed_after_external_writes() {
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
let mut material = Material {
key: MaterialKey::new(3),
..Material::default()
};
material.base_color_factor = [0.2, 0.4, 0.8, 1.0];
material.roughness_factor = 0.75;
registry.publish_materials(&[material.clone()]).unwrap();
let descriptor = registry.arrays["material.state"].descriptor().unwrap();
assert_eq!(
(
descriptor.scalar,
descriptor.lanes,
descriptor.stride,
descriptor.length
),
(ScalarType::U32, 28, 112, 4)
);
let array = registry.arrays.get_mut("material.state").unwrap();
let sequence = array.word(9).load(Ordering::Acquire);
array.word(9).store(sequence + 1, Ordering::Release);
array
.data_word(3, 9)
.store(0.25f32.to_bits(), Ordering::Relaxed);
array.word(9).store(sequence + 2, Ordering::Release);
let rows = registry.take_material_words().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, MaterialKey::new(3));
assert_eq!(f32::from_bits(rows[0].1[9]), 0.25);
assert!(registry.take_material_words().is_none());
}
#[test]
fn custom_layouts_are_aligned_idempotent_and_conflict_checked() {
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
@@ -761,7 +986,7 @@ mod tests {
fn fixed_arrays_grow_and_publish_stable_byte_uploads() {
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
let request = |length| ArrayRequest {
name: "upload.gltf".into(),
name: "upload.renderData".into(),
domain: ArrayDomain::Fixed,
scalar: ScalarType::U32,
lanes: 4,
@@ -773,18 +998,18 @@ mod tests {
assert_eq!(first.id, second.id);
assert_eq!((second.length, second.capacity), (2, 2));
let array = registry.arrays.get_mut("upload.gltf").unwrap();
let array = registry.arrays.get_mut("upload.renderData").unwrap();
let sequence = array.try_lock().unwrap();
array
.data_word(0, 0)
.store(u32::from_le_bytes(*b"glTF"), Ordering::Relaxed);
.store(u32::from_le_bytes(*b"YRDP"), Ordering::Relaxed);
array
.data_word(0, 1)
.store(u32::from_le_bytes([2, 0, 0, 0]), Ordering::Relaxed);
array.unlock(sequence);
assert_eq!(
registry.read_fixed_bytes(first.id, 8).unwrap(),
b"glTF\x02\0\0\0"
b"YRDP\x02\0\0\0"
);
}