diff --git a/Cargo.lock b/Cargo.lock index bdf5757..68e640e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,6 +913,7 @@ dependencies = [ "console_log", "futures", "gltf", + "image", "js-sys", "log", "raw-window-handle", diff --git a/Cargo.toml b/Cargo.toml index 411470e..05457d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ web-sys = { version = "0.3.77", features = [ "FileList", "OffscreenCanvas", "MouseEvent", + "PointerEvent", "WheelEvent", "KeyboardEvent", "Worker", @@ -43,4 +44,5 @@ wgpu = "26.0.1" thiserror = "2.0.15" ultraviolet = "0.10.0" futures = "0.3" -gltf = { version = "1.4", features = ["extras", "names", "KHR_lights_punctual"] } +gltf = { version = "1.4", features = ["extras", "names", "KHR_lights_punctual", "KHR_materials_ior"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } diff --git a/level-editor/src/lib.rs b/level-editor/src/lib.rs index f198b87..56fdb9c 100644 --- a/level-editor/src/lib.rs +++ b/level-editor/src/lib.rs @@ -29,7 +29,7 @@ pub struct EditorScene { impl renderer::renderer::scene::Scene for EditorScene { fn setup( renderer_context: &gpu_renderer::RendererContext, - resources: &mut gpu_renderer::GpuResources, + resources: &mut gpu_renderer::PipelineLibrary, render_data: &mut RenderData, ) -> Self { let dimension = ultraviolet::Vec2::new( @@ -90,14 +90,18 @@ impl renderer::renderer::scene::Scene for EditorScene { self.frame_metadata.mouse_click = [x, y]; } - fn handle_zoom(&mut self, _delta_y: f32) { - // TODO: Implement zoom properly when Camera exposes necessary methods + fn handle_zoom(&mut self, delta_y: f32) { + self.cam.zoom(delta_y); } fn handle_orbit(&mut self, delta_x: f32, delta_y: f32) { self.cam.orbit(delta_x, delta_y); } + fn handle_pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) { + self.cam.pan(delta_x, delta_y, viewport_height); + } + fn set_camera_depth_range(&mut self, near: f32, far: f32) { self.cam.set_depth_range(near, far); } @@ -149,13 +153,14 @@ impl EditorScene { fn create_default_scene( &mut self, device: &wgpu::Device, - resources: &mut gpu_renderer::GpuResources, + resources: &mut gpu_renderer::PipelineLibrary, render_data: &mut RenderData, surface_format: wgpu::TextureFormat, ) { let positions: Vec<[f32; 3]> = Self::VERTICES.iter().map(|v| v.pos).collect(); // Ground plane normals point upward (Y+) let normals: Vec<[f32; 3]> = vec![[0.0, 1.0, 0.0]; positions.len()]; + let tangents: Vec<[f32; 4]> = vec![[1.0, 0.0, 0.0, 1.0]; positions.len()]; let uvs: &[[f32; 2]] = &[ [0.0, 0.0], [1.0, 0.0], @@ -183,9 +188,11 @@ impl EditorScene { .create_mesh(MeshCreateInfo { positions: &positions, normals: &normals, + tangents: &tangents, uvs, indices: Self::INDICES, pipeline: pipeline_index, + material: renderer::render_data::MaterialKey::DEFAULT, flags: RenderFlags::VISIBLE, default_instance_flags: RenderFlags::VISIBLE, default_transform: transform, @@ -196,11 +203,11 @@ impl EditorScene { /// Entrypoint for the level editor #[wasm_bindgen] -pub fn main() -> Result { +pub fn main(profile: bool) -> Result { std::panic::set_hook(Box::new(console_error_panic_hook::hook)); wasm_logger::init(wasm_logger::Config::default()); - let runtime = LevelEditor::setup_runtime()?; + let runtime = LevelEditor::setup_runtime(profile)?; Ok(RendererBridge { runtime }) } diff --git a/renderer/Cargo.toml b/renderer/Cargo.toml index 4bede4e..093ab13 100644 --- a/renderer/Cargo.toml +++ b/renderer/Cargo.toml @@ -46,6 +46,7 @@ thiserror = { workspace = true } ultraviolet = { workspace = true } futures = { workspace = true } gltf = { workspace = true } +image = { workspace = true } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/renderer/src/app_setup.rs b/renderer/src/app_setup.rs index 2eee25a..977b119 100644 --- a/renderer/src/app_setup.rs +++ b/renderer/src/app_setup.rs @@ -18,9 +18,10 @@ use web_sys::AddEventListenerOptions; #[cfg(target_arch = "wasm32")] pub struct EventListeners { pub resize_listener: Option>, - pub mousemove_listener: Option>, - pub mousedown_listener: Option>, + pub pointer_listener: Option>, + pub click_listener: Option>, pub wheel_listener: Option>, + pub contextmenu_listener: Option>, pub keyboard_listener: Option>, } @@ -29,9 +30,10 @@ impl EventListeners { pub fn new() -> Self { Self { resize_listener: None, - mousemove_listener: None, - mousedown_listener: None, + pointer_listener: None, + click_listener: None, wheel_listener: None, + contextmenu_listener: None, keyboard_listener: None, } } @@ -54,65 +56,82 @@ pub fn setup_event_listeners( let width = f64::from(resize_canvas.client_width().max(1)); let height = f64::from(resize_canvas.client_height().max(1)); - resize_worker_chan - .send(WindowEvent::Resize(ResizeMessage { - width, - height, - scale_factor: window.device_pixel_ratio(), - })) - .unwrap(); + let _ = resize_worker_chan.send(WindowEvent::Resize(ResizeMessage { + width, + height, + scale_factor: window.device_pixel_ratio(), + })); }); window.add_event_listener_with_callback("resize", resize_listener.as_ref().unchecked_ref())?; - let mousemove_worker_chan = worker_chan.clone(); - let mousemove_listener: Closure = + let pointer_worker_chan = worker_chan.clone(); + let pointer_canvas = canvas.clone(); + let pointer_listener: Closure = + Closure::new(move |event: web_sys::PointerEvent| { + use crate::message::{camera_drag, MouseMessage}; + + if event.pointer_type() != "mouse" { + return; + } + match event.type_().as_str() { + "pointerdown" if matches!(event.button(), 1 | 2) => { + event.prevent_default(); + let _ = pointer_canvas.set_pointer_capture(event.pointer_id()); + } + "pointermove" + if pointer_canvas.has_pointer_capture(event.pointer_id()) + && camera_drag(event.buttons()).is_some() => + { + event.prevent_default(); + let message = MouseMessage::from_pointer_evt( + &event, + f64::from(pointer_canvas.client_height().max(1)), + ); + let _ = pointer_worker_chan.send(WindowEvent::PointerMove(message)); + } + "pointerup" | "pointercancel" + if pointer_canvas.has_pointer_capture(event.pointer_id()) => + { + let _ = pointer_canvas.release_pointer_capture(event.pointer_id()); + } + _ => {} + } + }); + + for event_name in ["pointerdown", "pointermove", "pointerup", "pointercancel"] { + canvas.add_event_listener_with_callback( + event_name, + pointer_listener.as_ref().unchecked_ref(), + )?; + } + + let click_worker_chan = worker_chan.clone(); + let click_canvas = canvas.clone(); + let click_listener: Closure = Closure::new(move |event: web_sys::MouseEvent| { use crate::message::MouseMessage; - if event.buttons() & 0x04 != 0 { - event.prevent_default(); + if event.button() != 0 { + return; } - let mouse_event_data = MouseMessage::from_evt(event.clone()); - - let mut event_data = WindowEvent::PointerMove(mouse_event_data.clone()); - if event.type_() == "click" { - event_data = WindowEvent::PointerClick(mouse_event_data.clone()); - } - - mousemove_worker_chan.clone().send(event_data).unwrap(); + let message = + MouseMessage::from_evt(&event, f64::from(click_canvas.client_height().max(1))); + let _ = click_worker_chan.send(WindowEvent::PointerClick(message)); }); - - window.add_event_listener_with_callback( - "mousemove", - mousemove_listener.as_ref().unchecked_ref(), - )?; - - window - .add_event_listener_with_callback("click", mousemove_listener.as_ref().unchecked_ref())?; - - let mousedown_listener: Closure = - Closure::new(move |event: web_sys::MouseEvent| { - if event.button() == 1 { - event.prevent_default(); - } - }); - - window.add_event_listener_with_callback( - "mousedown", - mousedown_listener.as_ref().unchecked_ref(), - )?; + canvas.add_event_listener_with_callback("click", click_listener.as_ref().unchecked_ref())?; let wheel_worker_chan = worker_chan.clone(); + let wheel_canvas = canvas.clone(); let wheel_listener: Closure = Closure::new(move |event: web_sys::WheelEvent| { use crate::message::WheelMessage; event.prevent_default(); - let wheel_event_data = WheelMessage::from_evt(event); - - wheel_worker_chan - .send(WindowEvent::PointerWheel(wheel_event_data)) - .unwrap(); + if let Some(message) = + WheelMessage::from_evt(&event, f64::from(wheel_canvas.client_height().max(1))) + { + let _ = wheel_worker_chan.send(WindowEvent::PointerWheel(message)); + } }); let wheel_options = { @@ -121,12 +140,19 @@ pub fn setup_event_listeners( options }; - window.add_event_listener_with_callback_and_add_event_listener_options( + canvas.add_event_listener_with_callback_and_add_event_listener_options( "wheel", wheel_listener.as_ref().unchecked_ref(), &wheel_options, )?; + let contextmenu_listener: Closure = + Closure::new(move |event: web_sys::MouseEvent| event.prevent_default()); + canvas.add_event_listener_with_callback( + "contextmenu", + contextmenu_listener.as_ref().unchecked_ref(), + )?; + let keyboard_worker_chan = worker_chan.clone(); let keyboard_listener: Closure = Closure::new(move |event: web_sys::KeyboardEvent| { @@ -134,9 +160,7 @@ pub fn setup_event_listeners( let keyboard_event_data = KeyboardMessage::from_evt(event); - keyboard_worker_chan - .send(WindowEvent::Keyboard(keyboard_event_data)) - .unwrap(); + let _ = keyboard_worker_chan.send(WindowEvent::Keyboard(keyboard_event_data)); }); window @@ -144,9 +168,10 @@ pub fn setup_event_listeners( Ok(EventListeners { resize_listener: Some(resize_listener), - mousemove_listener: Some(mousemove_listener), - mousedown_listener: Some(mousedown_listener), + pointer_listener: Some(pointer_listener), + click_listener: Some(click_listener), wheel_listener: Some(wheel_listener), + contextmenu_listener: Some(contextmenu_listener), keyboard_listener: Some(keyboard_listener), }) } @@ -166,6 +191,7 @@ impl WebAppRuntime { pub fn new( worker_name: &str, canvas_selector: &str, + profile: bool, ) -> Result { let (sender, receiver) = mpsc::channel::(); @@ -179,7 +205,7 @@ impl WebAppRuntime { let worker = MainWorker::spawn(worker_name, 1, ring_ptr, move || { spawn_local(async move { let ring = unsafe { &*(ring_ptr as *const CommandRing) }; - MainWorker::run_render_loop::(receiver, ring).await; + MainWorker::run_render_loop::(receiver, ring, profile).await; }); })?; @@ -228,9 +254,12 @@ pub trait WebApp { fn on_runtime_initialized(_runtime: &mut WebAppRuntime) {} /// Perform the default WASM initialization routine. - fn setup_runtime() -> Result { - let mut runtime = - WebAppRuntime::new::(Self::worker_name(), Self::canvas_selector())?; + fn setup_runtime(profile: bool) -> Result { + let mut runtime = WebAppRuntime::new::( + Self::worker_name(), + Self::canvas_selector(), + profile, + )?; Self::on_runtime_initialized(&mut runtime); Ok(runtime) } diff --git a/renderer/src/camera.rs b/renderer/src/camera.rs index ad9b393..52c8341 100644 --- a/renderer/src/camera.rs +++ b/renderer/src/camera.rs @@ -3,7 +3,16 @@ use std::f32::consts::PI; use ultraviolet::{projection, Bivec3, Mat4, Rotor3, Vec3}; use wgpu::util::DeviceExt; -use crate::{message::WheelMessage, renderer::scene::UniformResource}; +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; @@ -81,6 +90,9 @@ pub struct CameraUniform { } 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], @@ -115,8 +127,14 @@ impl Camera { } 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; @@ -141,6 +159,9 @@ impl Camera { } 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; @@ -174,39 +195,62 @@ impl Camera { self.compute_view_proj_mat(); } - pub fn zoom(&mut self, msg: &WheelMessage) { - let mut delta = msg.delta_y as f32; - - // Match browser delta modes so the wheel delta is always roughly pixels. - match msg.delta_mode { - 1 => delta *= 16.0, - 2 => delta *= 800.0, - _ => {} - } - - // Scrolling up should zoom in. - delta = -delta; - - if delta.abs() <= f32::EPSILON { + pub fn zoom(&mut self, delta_y_pixels: f32) { + if !delta_y_pixels.is_finite() || delta_y_pixels.abs() <= f32::EPSILON { return; } - // Get forward direction from camera position to target - let mut forward_vec = self.target - self.position; - if forward_vec.mag_sq() <= f32::EPSILON { - forward_vec = Vec3::unit_z(); + 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; } - let forward_dir = forward_vec.normalized(); - let current_distance = forward_vec.mag(); - // Scale dolly movement by distance to target for consistent perceived zoom speed - let dolly_distance = delta * ZOOM_SENSITIVITY * current_distance; - let dolly_translation = forward_dir * dolly_distance; + self.position = new_position; + self.distance = new_distance; + self.dirty = true; + self.compute_view_proj_mat(); + } - self.position += dolly_translation; - self.target += dolly_translation; - - self.compute_rotor(); + 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(); } @@ -294,3 +338,206 @@ impl Camera { 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); + } +} diff --git a/renderer/src/gltf.rs b/renderer/src/gltf.rs index 4319e83..0e6747a 100644 --- a/renderer/src/gltf.rs +++ b/renderer/src/gltf.rs @@ -4,10 +4,100 @@ use gltf::Gltf; use ultraviolet::{Mat4, Vec3}; use crate::render_data::{ - InstanceHandle, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey, RenderData, - RenderDataError, RenderFlags, + InstanceHandle, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey, + RenderData, RenderDataError, RenderFlags, }; +#[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, + pub metallic_roughness_texture: Option, + pub normal_texture: Option, + pub normal_scale: f32, + pub occlusion_texture: Option, + pub occlusion_strength: f32, + pub emissive_texture: Option, +} + +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, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SamplerMetadata { + pub index: usize, + pub mag_filter: Option, + pub min_filter: Option, + 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, + pub mime_type: Option, + 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, +} + #[derive(Clone, Debug)] pub struct InstalledScene { pub meshes: Vec, @@ -60,16 +150,31 @@ pub enum ImportError { 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) -> Result { + 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, } @@ -82,12 +187,257 @@ pub struct ImportedOccurrence { pub struct ImportedScene { pub geometries: Vec, pub occurrences: Vec, + pub materials: Vec, + pub textures: Vec, + pub samplers: Vec, + pub images: Vec, +} + +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::().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>, + tangents: Option>, + uvs: Vec<[f32; 2]>, + indices: Vec, +) -> Result< + ( + Vec<[f32; 3]>, + Vec<[f32; 3]>, + Vec<[f32; 4]>, + Vec<[f32; 2]>, + Vec, + ), + 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 { - let model = Gltf::from_slice(bytes)?; - let buffers = gltf::import_buffers(&model.document, None, model.blob.clone())?; + decode_gltf_model(Gltf::from_slice(bytes)?) +} + +pub fn decode_gltf_owned(bytes: Vec) -> Result { + let model = Gltf::from_slice(&bytes)?; + drop(bytes); + decode_gltf_model(model) +} + +fn decode_gltf_model(mut model: Gltf) -> Result { + // 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::>()?; let mut seen = HashMap::new(); fn visit( node: gltf::Node<'_>, @@ -116,12 +466,8 @@ pub fn decode_gltf(bytes: &[u8]) -> Result { if count == 0 { continue; } - let mut normals: Vec<_> = reader - .read_normals() - .map(|x| x.collect()) - .unwrap_or_default(); - normals.resize(count, [0., 1., 0.]); - normals.truncate(count); + 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()) @@ -139,11 +485,20 @@ pub fn decode_gltf(bytes: &[u8]) -> Result { 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, - double_sided: primitive.material().double_sided(), + 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, }); @@ -189,9 +544,11 @@ pub fn install_imported( let created = stage.create_mesh(MeshCreateInfo { positions: &geometry.positions, normals: &geometry.normals, + tangents: &geometry.tangents, uvs: &geometry.uvs, indices: &geometry.indices, pipeline: pipelines[usize::from(geometry.double_sided)], + material: geometry.material, flags: RenderFlags::VISIBLE, default_instance_flags: RenderFlags::VISIBLE, default_transform: transform, @@ -250,3 +607,151 @@ pub fn install_imported( 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::>()); + 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(_)) + )); + } + } +} diff --git a/renderer/src/gltf.wgsl b/renderer/src/gltf.wgsl index 733a703..63ae536 100644 --- a/renderer/src/gltf.wgsl +++ b/renderer/src/gltf.wgsl @@ -1,72 +1,52 @@ -struct UniformData { - mouse_move: vec2, - mouse_click: vec2, - resolution: vec2, - time: f32, - _padding0: f32, - camera_position: vec4, -} - +struct UniformData { mouse_move: vec2, mouse_click: vec2, resolution: vec2, time: f32, _padding0: f32, camera_position: vec4 } +struct MaterialData { base_color_factor: vec4, emissive_factor: vec4, surface_factors: vec4, alpha_optics: vec4, flags: vec4, uv_sets: vec4, debug_extras: vec4 } @group(0) @binding(0) var uni: UniformData; @group(1) @binding(0) var view_proj: mat4x4; +@group(2) @binding(0) var material: MaterialData; +@group(2) @binding(1) var base_tex: texture_2d; +@group(2) @binding(2) var mr_tex: texture_2d; +@group(2) @binding(3) var normal_tex: texture_2d; +@group(2) @binding(4) var occlusion_tex: texture_2d; +@group(2) @binding(5) var emissive_tex: texture_2d; +@group(2) @binding(6) var base_sampler: sampler; +@group(2) @binding(7) var mr_sampler: sampler; +@group(2) @binding(8) var normal_sampler: sampler; +@group(2) @binding(9) var occlusion_sampler: sampler; +@group(2) @binding(10) var emissive_sampler: sampler; -struct VertexInput { - @location(0) pos: vec3, - @location(1) normal: vec3, - @location(2) uv: vec2, - @location(3) model_col0: vec4, - @location(4) model_col1: vec4, - @location(5) model_col2: vec4, - @location(6) model_col3: vec4, - @location(7) normal_col0: vec4, - @location(8) normal_col1: vec4, - @location(9) normal_col2: vec4, +struct VertexInput { @location(0) pos: vec3, @location(1) normal: vec3, @location(2) uv: vec2, @location(3) model_col0: vec4, @location(4) model_col1: vec4, @location(5) model_col2: vec4, @location(6) model_col3: vec4, @location(7) normal_col0: vec4, @location(8) normal_col1: vec4, @location(9) normal_col2: vec4, @location(10) tangent: vec4 } +struct VertexOutput { @builtin(position) clip_position: vec4, @location(0) world_pos: vec3, @location(1) normal: vec3, @location(2) tangent: vec3, @location(3) bitangent: vec3, @location(4) uv: vec2, @location(5) @interpolate(flat) determinant_sign: f32 } +fn safe_normalize(v: vec3, fallback: vec3) -> vec3 { let l2 = dot(v, v); return select(fallback, v * inverseSqrt(l2), l2 > 1e-12 && l2 < 1e30); } +@vertex fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; let model = mat4x4(in.model_col0, in.model_col1, in.model_col2, in.model_col3); + let linear = mat3x3(in.model_col0.xyz, in.model_col1.xyz, in.model_col2.xyz); let nm = mat3x3(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz); + let world = model * vec4(in.pos, 1.0); let n = safe_normalize(nm * in.normal, vec3(0,1,0)); let raw_t = linear * in.tangent.xyz; + var t = raw_t - n * dot(n, raw_t); if dot(t,t) < 1e-8 { t = cross(select(vec3(0,1,0), vec3(1,0,0), abs(n.x) < 0.9), n); } t = safe_normalize(t, vec3(1,0,0)); + out.clip_position = view_proj * world; out.world_pos = world.xyz; out.normal = n; out.tangent = t; out.bitangent = safe_normalize(cross(n,t), vec3(0,0,1)) * in.tangent.w * in.normal_col0.w; out.uv = in.uv; out.determinant_sign = in.normal_col0.w; return out; } - -struct VertexOutput { - @builtin(position) clip_position: vec4, - @location(0) world_pos: vec3, - @location(1) normal: vec3 +struct Closure { base: vec4, mr: vec2, normal_map: vec3, ao: f32, emissive: vec3 } +fn sample_closure(uv: vec2) -> Closure { + let bits = material.flags.x; var c: Closure; + c.base = material.base_color_factor * select(vec4(1), textureSample(base_tex, base_sampler, uv), (bits & 1u) != 0u); + let mr = select(vec4(1), textureSample(mr_tex, mr_sampler, uv), (bits & 2u) != 0u); c.mr = vec2(clamp(material.surface_factors.x * mr.b,0,1), clamp(material.surface_factors.y * mr.g,0.045,1)); + c.normal_map = select(vec3(0.5,0.5,1), textureSample(normal_tex, normal_sampler, uv).xyz, (bits & 4u) != 0u); + let occ = select(1.0, textureSample(occlusion_tex, occlusion_sampler, uv).r, (bits & 8u) != 0u); c.ao = mix(1.0, occ, material.surface_factors.w); + c.emissive = material.emissive_factor.rgb * select(vec3(1), textureSample(emissive_tex, emissive_sampler, uv).rgb, (bits & 16u) != 0u); return c; } - - -@vertex -fn vs_main(in: VertexInput) -> VertexOutput { - var out: VertexOutput; - let model = mat4x4( - in.model_col0, - in.model_col1, - in.model_col2, - in.model_col3, - ); - let world_position = model * vec4(in.pos, 1.0); - out.clip_position = view_proj * world_position; - out.world_pos = world_position.xyz; - let normal_matrix = mat3x3(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz); - out.normal = normalize(normal_matrix * in.normal); - return out; -} - -@fragment -fn fs_main(in: VertexOutput) -> @location(0) vec4 { - let light_direction = normalize(vec3(0.35, 1.0, 0.45)); - let light_color = vec3(1.0, 0.95, 0.85); - let base_color = vec3(0.55, 0.58, 0.62); - - let normal = normalize(in.normal); - let view_dir = normalize(uni.camera_position.xyz - in.world_pos); - - let diffuse_strength = max(dot(normal, light_direction), 0.0); - let ambient = 0.45; - - var specular = 0.0; - if diffuse_strength > 0.0 { - let halfway_dir = normalize(light_direction + view_dir); - specular = pow(max(dot(normal, halfway_dir), 0.0), 32.0); - } - - let lighting = min(base_color * (ambient + diffuse_strength) + light_color * specular, vec3(1.0)); - let x = select(0.0, 0.3, distance(in.clip_position.xy, uni.mouse_move) < 25.0); - let y = select(0.0, 0.3, distance(in.clip_position.xy, uni.mouse_click) < 25.0); - return vec4(lighting + x - y, 1.0); +fn schlick(f0: vec3, v_h: f32) -> vec3 { return f0 + (vec3(1)-f0) * pow(1.0-clamp(v_h,0,1),5.0); } +fn ggx_d(n_h_input: f32, a: f32) -> f32 { let n_h=clamp(n_h_input,0.0,1.0); let a2=a*a; let nh2=n_h*n_h; let q=(1.0-nh2)+a2*nh2; return a2/(3.14159265*q*q); } +fn smith_v(n_v: f32, n_l: f32, a: f32) -> f32 { let a2=a*a; let gv=n_l*sqrt(max(n_v*n_v*(1.0-a2)+a2,0)); let gl=n_v*sqrt(max(n_l*n_l*(1.0-a2)+a2,0)); return 0.5/max(gv+gl,1e-6); } +@fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front: bool) -> @location(0) vec4 { + let c=sample_closure(in.uv); if material.alpha_optics.x == 1.0 && c.base.a < material.alpha_optics.y { discard; } + let physical_front=front == (in.determinant_sign > 0); let orientation=select(-1.0,1.0,physical_front || material.flags.y == 0u); let map=c.normal_map*2.0-1.0; + let n=safe_normalize(mat3x3(in.tangent,in.bitangent,in.normal)*safe_normalize(vec3(map.xy*material.surface_factors.z,map.z),vec3(0,0,1)),in.normal)*orientation; + let v=safe_normalize(uni.camera_position.xyz-in.world_pos,n); let l=safe_normalize(vec3(0.35,1,0.45),vec3(0,1,0)); let h=safe_normalize(v+l,n); + let nv=max(dot(n,v),0); let nl=max(dot(n,l),0); let nh=dot(n,h); let vh=max(dot(v,h),0); let a=c.mr.y*c.mr.y; + let f0=mix(vec3(material.alpha_optics.w),c.base.rgb,c.mr.x); let direct_f=schlick(f0,vh); let env_f=schlick(f0,nv); let spec=direct_f*ggx_d(nh,a)*smith_v(nv,nl,a); let diffuse=(vec3(1)-direct_f)*(1.0-c.mr.x)*c.base.rgb/3.14159265; + let sun=(diffuse+spec)*nl*vec3(3.0,2.85,2.65); + let up=clamp(n.y*0.5+0.5,0,1); let sky=mix(vec3(0.055,0.045,0.035),vec3(0.24,0.36,0.58),up); let env_diff=(vec3(1)-env_f)*(1.0-c.mr.x)*c.base.rgb*sky; + let reflection=reflect(-v,n); let horizon=clamp(reflection.y*0.5+0.5,0,1); let env_spec=env_f*mix(vec3(0.04,0.035,0.03),vec3(0.28,0.42,0.7),horizon)*(1.0-0.65*c.mr.y); + var color=sun+(env_diff+env_spec)*c.ao+c.emissive; + if material.debug_extras.y == 1u { color=n*0.5+0.5; } else if material.debug_extras.y == 2u { color=vec3(c.mr.x,c.mr.y,c.ao); } else if material.debug_extras.y == 3u { color=f0; } + return vec4(color,1.0); // BLEND remains intentionally opaque. } diff --git a/renderer/src/message.rs b/renderer/src/message.rs index 60719c1..a4aff87 100644 --- a/renderer/src/message.rs +++ b/renderer/src/message.rs @@ -1,6 +1,40 @@ 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 { + 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 { + 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 { @@ -42,10 +76,11 @@ pub struct MouseMessage { 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) -> Self { + 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(), @@ -57,33 +92,24 @@ impl MouseMessage { 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 scale_factor: f64, - pub delta_x: f64, - pub delta_y: f64, - pub delta_z: f64, - pub delta_mode: u32, - pub client_x: f64, - pub client_y: f64, + pub delta_y_pixels: f32, } impl WheelMessage { - pub fn from_evt(event: web_sys::WheelEvent) -> Self { - let window = web_sys::window().unwrap(); - Self { - scale_factor: window.device_pixel_ratio(), - delta_x: event.delta_x(), - delta_y: event.delta_y(), - delta_z: event.delta_z(), - delta_mode: event.delta_mode(), - client_x: event.client_x() as f64, - client_y: event.client_y() as f64, - } + pub fn from_evt(event: &web_sys::WheelEvent, viewport_height: f64) -> Option { + normalize_wheel_delta(event.delta_y(), event.delta_mode(), viewport_height) + .map(|delta_y_pixels| Self { delta_y_pixels }) } } @@ -147,3 +173,32 @@ impl From for DrainEventError { 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) + ); + } +} diff --git a/renderer/src/platform/web/worker/mod.rs b/renderer/src/platform/web/worker/mod.rs index 95c0fc3..47ab0d6 100644 --- a/renderer/src/platform/web/worker/mod.rs +++ b/renderer/src/platform/web/worker/mod.rs @@ -109,12 +109,15 @@ impl MainWorker { pub async fn run_render_loop( events_chan: Receiver, ring: &'static CommandRing, + profile: bool, ) { use crate::renderer::Renderer; let canvas = wait_for_canvas_transfer().await; - let renderer = Rc::new(RefCell::new(Renderer::::new(canvas, events_chan).await)); + let renderer = Rc::new(RefCell::new( + Renderer::::new(canvas, events_chan, profile).await, + )); renderer.borrow_mut().command_ring = Some(ring); Renderer::run_render_loop(renderer); } diff --git a/renderer/src/render_data/mod.rs b/renderer/src/render_data/mod.rs index 452489e..0ae8ffe 100644 --- a/renderer/src/render_data/mod.rs +++ b/renderer/src/render_data/mod.rs @@ -40,6 +40,24 @@ impl PipelineKey { } } +/// Stable CPU-side identity for a device-independent material. +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct MaterialKey(u32); + +impl MaterialKey { + /// The glTF/default material. + pub const DEFAULT: Self = Self(0); + + pub const fn new(value: u32) -> Self { + Self(value) + } + + pub const fn get(self) -> u32 { + self.0 + } +} + #[repr(transparent)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct RenderFlags(u32); @@ -101,9 +119,11 @@ pub struct GeometryRange { pub struct MeshCreateInfo<'a> { pub positions: &'a [[f32; 3]], pub normals: &'a [[f32; 3]], + pub tangents: &'a [[f32; 4]], pub uvs: &'a [[f32; 2]], pub indices: &'a [u32], pub pipeline: PipelineKey, + pub material: MaterialKey, pub flags: RenderFlags, pub default_instance_flags: RenderFlags, pub default_transform: ModelTransform, @@ -120,6 +140,7 @@ pub struct MeshView { pub handle: MeshHandle, pub geometry: GeometryRange, pub pipeline: PipelineKey, + pub material: MaterialKey, pub flags: RenderFlags, pub aabb: Aabb, pub default_instance: InstanceHandle, @@ -178,6 +199,7 @@ pub fn affine_world_aabb(local: Aabb, model: ModelTransform) -> Result { pub positions: &'a [[f32; 3]], pub normals: &'a [[f32; 3]], + pub tangents: &'a [[f32; 4]], pub uvs: &'a [[f32; 2]], } @@ -267,6 +289,7 @@ pub enum RenderDataError { struct VertexSoa { positions: Vec<[f32; 3]>, normals: Vec<[f32; 3]>, + tangents: Vec<[f32; 4]>, uvs: Vec<[f32; 2]>, logical_capacity: u32, max_capacity: Option, @@ -287,6 +310,7 @@ struct MeshSoa { index_starts: Vec, index_counts: Vec, pipeline_keys: Vec, + material_keys: Vec, flags: Vec, aabb_mins: Vec<[f32; 3]>, aabb_maxs: Vec<[f32; 3]>, @@ -416,6 +440,7 @@ impl RenderData { vertices: VertexSoa { positions: Vec::new(), normals: Vec::new(), + tangents: Vec::new(), uvs: Vec::new(), logical_capacity: 0, max_capacity: config.max_vertices, @@ -553,6 +578,8 @@ impl RenderData { .copy_from_slice(info.positions); self.vertices.normals[as_usize(vertex_range.start)..as_usize(vertex_range.end)] .copy_from_slice(info.normals); + self.vertices.tangents[as_usize(vertex_range.start)..as_usize(vertex_range.end)] + .copy_from_slice(info.tangents); self.vertices.uvs[as_usize(vertex_range.start)..as_usize(vertex_range.end)] .copy_from_slice(info.uvs); self.indices.values[as_usize(index_range.start)..as_usize(index_range.end)] @@ -567,6 +594,7 @@ impl RenderData { index_count, }, info.pipeline, + info.material, info.flags, bounds, default_instance, @@ -766,6 +794,7 @@ impl RenderData { VertexStreams { positions: &self.vertices.positions, normals: &self.vertices.normals, + tangents: &self.vertices.tangents, uvs: &self.vertices.uvs, } } @@ -783,6 +812,7 @@ impl RenderData { self.instances.slots.clear(); self.vertices.positions.clear(); self.vertices.normals.clear(); + self.vertices.tangents.clear(); self.vertices.uvs.clear(); self.indices.values.clear(); self.vertices.allocator.clear(); @@ -800,6 +830,7 @@ impl RenderData { )?; reserve_vec(&mut self.vertices.positions, target, "vertices")?; reserve_vec(&mut self.vertices.normals, target, "vertices")?; + reserve_vec(&mut self.vertices.tangents, target, "vertices")?; reserve_vec(&mut self.vertices.uvs, target, "vertices")?; self.vertices.logical_capacity = target; Ok(()) @@ -821,6 +852,9 @@ impl RenderData { let vertices = as_usize(self.vertices.allocator.high_water()); self.vertices.positions.resize(vertices, [0.0; 3]); self.vertices.normals.resize(vertices, [0.0; 3]); + self.vertices + .tangents + .resize(vertices, [0.0, 0.0, 0.0, 1.0]); self.vertices.uvs.resize(vertices, [0.0; 2]); self.indices .values @@ -834,6 +868,9 @@ impl RenderData { self.vertices .normals .truncate(as_usize(self.vertices.allocator.high_water())); + self.vertices + .tangents + .truncate(as_usize(self.vertices.allocator.high_water())); self.vertices .uvs .truncate(as_usize(self.vertices.allocator.high_water())); @@ -852,6 +889,7 @@ impl MeshSoa { index_starts: Vec::new(), index_counts: Vec::new(), pipeline_keys: Vec::new(), + material_keys: Vec::new(), flags: Vec::new(), aabb_mins: Vec::new(), aabb_maxs: Vec::new(), @@ -871,6 +909,7 @@ impl MeshSoa { reserve_vec(&mut self.index_starts, target, "meshes")?; reserve_vec(&mut self.index_counts, target, "meshes")?; reserve_vec(&mut self.pipeline_keys, target, "meshes")?; + reserve_vec(&mut self.material_keys, target, "meshes")?; reserve_vec(&mut self.flags, target, "meshes")?; reserve_vec(&mut self.aabb_mins, target, "meshes")?; reserve_vec(&mut self.aabb_maxs, target, "meshes")?; @@ -885,6 +924,7 @@ impl MeshSoa { prepared: PreparedSlot, geometry: GeometryRange, pipeline: PipelineKey, + material: MaterialKey, flags: RenderFlags, bounds: Aabb, default: InstanceHandle, @@ -895,6 +935,7 @@ impl MeshSoa { resize_column(&mut self.index_starts, len, 0); resize_column(&mut self.index_counts, len, 0); resize_column(&mut self.pipeline_keys, len, PipelineKey::new(0)); + resize_column(&mut self.material_keys, len, MaterialKey::DEFAULT); resize_column(&mut self.flags, len, RenderFlags::NONE); resize_column(&mut self.aabb_mins, len, [0.0; 3]); resize_column(&mut self.aabb_maxs, len, [0.0; 3]); @@ -906,6 +947,7 @@ impl MeshSoa { self.index_starts[index] = geometry.index_start; self.index_counts[index] = geometry.index_count; self.pipeline_keys[index] = pipeline; + self.material_keys[index] = material; self.flags[index] = flags; self.aabb_mins[index] = bounds.min; self.aabb_maxs[index] = bounds.max; @@ -925,6 +967,7 @@ impl MeshSoa { index_count: self.index_counts[index], }, pipeline: self.pipeline_keys[index], + material: self.material_keys[index], flags: self.flags[index], aabb: Aabb { min: self.aabb_mins[index], @@ -1054,7 +1097,10 @@ fn validate_geometry(info: &MeshCreateInfo<'_>) -> Result if info.positions.is_empty() { return Err(RenderDataError::EmptyVertices); } - if info.positions.len() != info.normals.len() || info.positions.len() != info.uvs.len() { + if info.positions.len() != info.normals.len() + || info.positions.len() != info.tangents.len() + || info.positions.len() != info.uvs.len() + { return Err(RenderDataError::MismatchedVertexStreams); } let vertex_count = @@ -1068,6 +1114,7 @@ fn validate_geometry(info: &MeshCreateInfo<'_>) -> Result .iter() .flatten() .chain(info.normals.iter().flatten()) + .chain(info.tangents.iter().flatten()) .chain(info.uvs.iter().flatten()) .any(|value| !value.is_finite()) { diff --git a/renderer/src/render_data/tests.rs b/renderer/src/render_data/tests.rs index aeaec67..212d813 100644 --- a/renderer/src/render_data/tests.rs +++ b/renderer/src/render_data/tests.rs @@ -3,6 +3,7 @@ use crate::render_data::handle::SlotState; const POSITIONS: [[f32; 3]; 3] = [[-1.0, 2.0, 3.0], [4.0, -2.0, 1.0], [0.0, 1.0, -3.0]]; const NORMALS: [[f32; 3]; 3] = [[0.0, 1.0, 0.0]; 3]; +const TANGENTS: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 1.0]; 3]; const UVS: [[f32; 2]; 3] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]; const INDICES: [u32; 3] = [0, 1, 2]; @@ -10,9 +11,11 @@ fn info() -> MeshCreateInfo<'static> { MeshCreateInfo { positions: &POSITIONS, normals: &NORMALS, + tangents: &TANGENTS, uvs: &UVS, indices: &INDICES, pipeline: PipelineKey::new(7), + material: MaterialKey::new(11), flags: RenderFlags::from_bits_retain(2), default_instance_flags: RenderFlags::VISIBLE, default_transform: IDENTITY_MODEL_TRANSFORM, @@ -83,6 +86,10 @@ fn default_instance_is_protected_and_flags_are_separate() { let created = data.create_mesh(info()).unwrap(); assert!(data.instance(created.default_instance).unwrap().is_default); assert_eq!(data.mesh(created.mesh).unwrap().flags.bits(), 2); + assert_eq!( + data.mesh(created.mesh).unwrap().material, + MaterialKey::new(11) + ); assert_eq!( data.instance(created.default_instance).unwrap().flags, RenderFlags::VISIBLE @@ -239,6 +246,7 @@ fn streams_remain_coordinated_across_interior_delete_tail_delete_and_reuse() { data.destroy_mesh(second.mesh).unwrap(); assert_eq!(data.streams().positions.len(), 3); assert_eq!(data.streams().normals.len(), 3); + assert_eq!(data.streams().tangents.len(), 3); assert_eq!(data.streams().uvs.len(), 3); assert_eq!(data.indices().len(), 3); } @@ -288,6 +296,7 @@ fn aabb_supports_one_point_and_multiple_points() { let mut one = info(); one.positions = &point; one.normals = &normal; + one.tangents = &[[1.0, 0.0, 0.0, 1.0]]; one.uvs = &uv; one.indices = &index; let mut data = data(); @@ -325,6 +334,13 @@ fn malformed_geometry_matrix_is_rejected_without_consumption() { data.create_mesh(candidate).unwrap_err(), RenderDataError::MismatchedVertexStreams ); + let short_tangents = &TANGENTS[..2]; + let mut candidate = info(); + candidate.tangents = short_tangents; + assert_eq!( + data.create_mesh(candidate).unwrap_err(), + RenderDataError::MismatchedVertexStreams + ); let short_uvs = &UVS[..2]; let mut candidate = info(); candidate.uvs = short_uvs; @@ -339,19 +355,22 @@ fn malformed_geometry_matrix_is_rejected_without_consumption() { RenderDataError::EmptyIndices ); - for stream in 0..3 { + for stream in 0..4 { for bad in [f32::NAN, f32::INFINITY] { let mut positions = POSITIONS; let mut normals = NORMALS; + let mut tangents = TANGENTS; let mut uvs = UVS; match stream { 0 => positions[0][0] = bad, 1 => normals[0][0] = bad, + 2 => tangents[0][0] = bad, _ => uvs[0][0] = bad, } let mut candidate = info(); candidate.positions = &positions; candidate.normals = &normals; + candidate.tangents = &tangents; candidate.uvs = &uvs; assert_eq!( data.create_mesh(candidate).unwrap_err(), diff --git a/renderer/src/render_graph/compiler_v2.rs b/renderer/src/render_graph/compiler_v2.rs new file mode 100644 index 0000000..0ff5962 --- /dev/null +++ b/renderer/src/render_graph/compiler_v2.rs @@ -0,0 +1,1953 @@ +use std::cmp::Reverse; +use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet}; + +use serde::Deserialize; + +use super::*; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Empty {} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TextureParameters { + residency: TextureResidencyV2, + texture: TextureDescriptorV2, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct DepthParameters { + depth_compare: CompareFunctionV2, + depth_write_enabled: bool, + clear_depth: f32, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ForwardParameters { + clear_color: [f64; 4], +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ToneMapParameters { + exposure: f32, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BloomExtractParameters { + threshold: f32, + knee: f32, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BloomBlurParameters { + direction: [f32; 2], + radius: f32, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BloomCompositeParameters { + intensity: f32, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LuminanceEdgeParameters { + strength: f32, +} + +fn range(value: f32, min: f32, max: f32, path: String) -> Result { + if value.is_finite() && (min..=max).contains(&value) { + Ok(value) + } else { + Err(error( + "GRAPH_PARAMETERS_INVALID", + &format!("value must be finite and in [{min},{max}]"), + path, + )) + } +} + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +struct OutputKey(usize, u16); +#[derive(Clone, Copy)] +struct BoundInput { + producer: OutputKey, + active: bool, +} +#[derive(Clone)] +struct DependencyEdge { + from_node: usize, + from_socket: String, + producer_output_ordinal: u16, + to_node: usize, + to_socket: String, + consumer_input_ordinal: u16, + resource: NodeOutputRef, +} + +#[derive(Clone, Copy)] +struct TextureTransition { + writer_node: usize, + input_socket: &'static str, + target: OutputKey, + output: OutputKey, +} + +#[derive(Clone, Copy)] +enum ResolvedTransition { + Resolved { + family: u32, + version: u32, + target: OutputKey, + }, + Cyclic, +} + +fn reaches( + from: usize, + to: usize, + outgoing_edges: &[Vec], + edges: &[DependencyEdge], + live: &HashSet, + memo: &mut HashMap<(usize, usize), bool>, +) -> bool { + if let Some(&answer) = memo.get(&(from, to)) { + return answer; + } + let mut stack = vec![from]; + let mut visited = HashSet::new(); + let mut answer = false; + while let Some(node) = stack.pop() { + if !visited.insert(node) { + continue; + } + if node == to { + answer = true; + break; + } + for &edge_index in &outgoing_edges[node] { + let next = edges[edge_index].to_node; + if live.contains(&next) { + stack.push(next); + } + } + } + memo.insert((from, to), answer); + answer +} + +fn error(code: &'static str, message: &str, path: impl Into) -> GraphError { + GraphError::at(code, message, path) +} +fn validate_name_length(s: &str, path: impl Into) -> Result<(), GraphError> { + if s.len() > 64 { + Err(error( + "GRAPH_LIMIT_EXCEEDED", + "identifier exceeds 64 bytes", + path.into(), + )) + } else { + Ok(()) + } +} +fn validate_name_grammar(s: &str, path: impl Into) -> Result<(), GraphError> { + if s.is_empty() || !identifier(s) { + Err(error("GRAPH_INVALID_ID", "invalid identifier", path)) + } else { + Ok(()) + } +} + +pub fn mesh_predicate_matches(predicate: TriStatePredicate, flag: bool) -> bool { + match predicate { + TriStatePredicate::Any => true, + TriStatePredicate::RequiredTrue => flag, + TriStatePredicate::RequiredFalse => !flag, + } +} + +pub fn parse_and_compile_v2(bytes: &[u8]) -> Result { + if bytes.len() > MAX_JSON_BYTES { + return Err(GraphError::new( + "GRAPH_PAYLOAD_TOO_LARGE", + "graph payload exceeds 1 MiB", + )); + } + let text = std::str::from_utf8(bytes) + .map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph payload is not UTF-8"))?; + let probe: serde_json::Value = serde_json::from_str(text) + .map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?; + if probe.get("schemaVersion").and_then(|v| v.as_u64()) != Some(2) { + return Err(GraphError::new( + "GRAPH_SCHEMA_UNSUPPORTED", + "schemaVersion must be 2", + )); + } + let graph = serde_json::from_str(text) + .map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?; + compile_v2(graph) +} + +fn gcd(mut a: u32, mut b: u32) -> u32 { + while b != 0 { + (a, b) = (b, a % b); + } + a +} +fn compatible_view(a: TextureFormatV2, b: TextureFormatV2) -> bool { + matches!( + (a, b), + (TextureFormatV2::Rgba8Unorm, TextureFormatV2::Rgba8UnormSrgb) + | (TextureFormatV2::Rgba8UnormSrgb, TextureFormatV2::Rgba8Unorm) + | (TextureFormatV2::Bgra8Unorm, TextureFormatV2::Bgra8UnormSrgb) + | (TextureFormatV2::Bgra8UnormSrgb, TextureFormatV2::Bgra8Unorm) + ) +} +fn normalize_texture( + d: TextureDescriptorV2, + base: &str, +) -> Result { + let bad = |message: &str, suffix: &str| { + error( + "GRAPH_PARAMETERS_INVALID", + message, + format!("{base}.texture.{suffix}"), + ) + }; + let (extent, w, h, layers, relative) = match d.extent { + TextureExtentV2::Absolute { + width, + height, + depth_or_array_layers, + } => ( + NormalizedTextureExtentV2::Absolute { + width, + height, + depth_or_array_layers, + }, + width, + height, + depth_or_array_layers, + false, + ), + TextureExtentV2::SurfaceRelative { + mut width, + mut height, + depth_or_array_layers, + } => { + if width.numerator == 0 + || width.denominator == 0 + || height.numerator == 0 + || height.denominator == 0 + || depth_or_array_layers == 0 + { + return Err(bad( + "extent components and ratio terms must be nonzero", + "extent", + )); + } + let g = gcd(width.numerator, width.denominator); + width.numerator /= g; + width.denominator /= g; + let g = gcd(height.numerator, height.denominator); + height.numerator /= g; + height.denominator /= g; + ( + NormalizedTextureExtentV2::SurfaceRelative { + width, + height, + depth_or_array_layers, + }, + 1, + 1, + depth_or_array_layers, + true, + ) + } + }; + if w == 0 || h == 0 || layers == 0 { + return Err(bad("extent components must be nonzero", "extent")); + } + if relative && d.dimension != TextureDimensionV2::D2 { + return Err(bad("surface-relative textures must be d2", "extent")); + } + if d.dimension == TextureDimensionV2::D1 && (h != 1 || layers != 1) { + return Err(bad( + "d1 textures require height and layers equal to one", + "extent", + )); + } + if d.format == TextureFormatV2::Depth32Float && d.dimension != TextureDimensionV2::D2 { + return Err(bad("depth textures must be d2", "dimension")); + } + if !matches!(d.sample_count, 1 | 4) { + return Err(bad("sampleCount must be 1 or 4", "sampleCount")); + } + if d.mip_level_count == 0 { + return Err(bad("mipLevelCount must be at least one", "mipLevelCount")); + } + if d.sample_count == 4 + && (d.dimension != TextureDimensionV2::D2 || d.mip_level_count != 1 || layers != 1) + { + return Err(bad( + "multisampled textures must be d2, single-mip, single-layer", + "sampleCount", + )); + } + let max_dim = w.max(h).max(if d.dimension == TextureDimensionV2::D3 { + layers + } else { + 1 + }); + let max_mips = 32 - max_dim.leading_zeros(); + if !relative && d.mip_level_count > max_mips { + return Err(bad( + "mipLevelCount exceeds the full mip chain", + "mipLevelCount", + )); + } + let limit = if d.dimension == TextureDimensionV2::D3 { + 2048 + } else { + 8192 + }; + if w > limit + || h > limit + || (d.dimension == TextureDimensionV2::D3 && layers > 2048) + || (d.dimension != TextureDimensionV2::D3 && layers > 256) + { + return Err(bad("texture exceeds dimension limits", "extent")); + } + for (j, &view) in d.view_formats.iter().enumerate() { + if view == d.format || !compatible_view(d.format, view) { + return Err(bad( + "view format must be compatible and exclude the base format", + &format!("viewFormats[{j}]"), + )); + } + } + let mut views = d.view_formats; + views.sort(); + views.dedup(); + Ok(NormalizedTextureDescriptorV2 { + dimension: d.dimension, + format: d.format, + extent, + mip_level_count: d.mip_level_count, + sample_count: d.sample_count, + view_formats: views, + }) +} + +fn decode(node: &NodeV2, i: usize) -> Result { + let base = format!("nodes[{i}].parameters"); + let invalid = + |e: serde_json::Error| error("GRAPH_PARAMETERS_INVALID", &e.to_string(), base.clone()); + macro_rules! empty { + ($variant:expr) => {{ + serde_json::from_value::(node.parameters.clone()).map_err(invalid)?; + $variant + }}; + } + Ok(match node.executor.key.as_str() { + "surface_target" => empty!(NormalizedParametersV2::SurfaceTarget), + "scene_table" => empty!(NormalizedParametersV2::SceneTable), + "local_aabb_buffer" => empty!(NormalizedParametersV2::LocalAabbBuffer), + "camera_frustum" => empty!(NormalizedParametersV2::CameraFrustum), + "visibility_flags" => empty!(NormalizedParametersV2::VisibilityFlags), + "frustum_cull" => empty!(NormalizedParametersV2::FrustumCull), + "fullscreen_copy" => empty!(NormalizedParametersV2::FullscreenCopy), + "tone_map" => { + let p: ToneMapParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + NormalizedParametersV2::ToneMap { + exposure: range(p.exposure, 0.0, 32.0, format!("{base}.exposure"))?, + } + } + "bloom_extract" => { + let p: BloomExtractParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + NormalizedParametersV2::BloomExtract { + threshold: range(p.threshold, 0.0, 64.0, format!("{base}.threshold"))?, + knee: range(p.knee, 0.0, 1.0, format!("{base}.knee"))?, + } + } + "bloom_blur" => { + let p: BloomBlurParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + let x = range(p.direction[0], -1.0, 1.0, format!("{base}.direction[0]"))?; + let y = range(p.direction[1], -1.0, 1.0, format!("{base}.direction[1]"))?; + if (x.abs() + y.abs() - 1.0).abs() > 0.0001 { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "direction must be a unit axis", + format!("{base}.direction"), + )); + } + NormalizedParametersV2::BloomBlur { + direction: [x, y], + radius: range(p.radius, 1.0, 16.0, format!("{base}.radius"))?, + } + } + "bloom_composite" => { + let p: BloomCompositeParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + NormalizedParametersV2::BloomComposite { + intensity: range(p.intensity, 0.0, 16.0, format!("{base}.intensity"))?, + } + } + "luminance_edge" => { + let p: LuminanceEdgeParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + NormalizedParametersV2::LuminanceEdge { + strength: range(p.strength, 0.0, 16.0, format!("{base}.strength"))?, + } + } + "present" => empty!(NormalizedParametersV2::Present), + "texture_spec" => { + let p: TextureParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + if matches!( + p.residency, + TextureResidencyV2::History | TextureResidencyV2::Readback + ) { + return Err(error( + "GRAPH_UNSUPPORTED_FEATURE", + "history and readback textures are unsupported", + format!("{base}.residency"), + )); + } + NormalizedParametersV2::TextureSpec { + residency: p.residency, + texture: normalize_texture(p.texture, &base)?, + } + } + "mesh_query" => { + let object = node.parameters.as_object().ok_or_else(|| { + error( + "GRAPH_PARAMETERS_INVALID", + "parameters must be an object", + base.clone(), + ) + })?; + if object.len() != 1 || !object.contains_key("filters") { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "mesh query parameters must contain only filters", + base.clone(), + )); + } + let filters = object["filters"].as_array().ok_or_else(|| { + error( + "GRAPH_PARAMETERS_INVALID", + "filters must be an array", + format!("{base}.filters"), + ) + })?; + let mut found = [None, None]; + for (j, value) in filters.iter().enumerate() { + let filter = value.as_object().ok_or_else(|| { + error( + "GRAPH_PARAMETERS_INVALID", + "filter must be an object", + format!("{base}.filters[{j}]"), + ) + })?; + if filter.len() != 2 + || !filter.contains_key("flag") + || !filter.contains_key("predicate") + { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "filter must contain flag and predicate", + format!("{base}.filters[{j}]"), + )); + } + let flag: MeshFlagV2 = + serde_json::from_value(filter["flag"].clone()).map_err(|e| { + error( + "GRAPH_PARAMETERS_INVALID", + &e.to_string(), + format!("{base}.filters[{j}].flag"), + ) + })?; + let predicate: TriStatePredicate = + serde_json::from_value(filter["predicate"].clone()).map_err(|e| { + error( + "GRAPH_PARAMETERS_INVALID", + &e.to_string(), + format!("{base}.filters[{j}].predicate"), + ) + })?; + let index = if flag == MeshFlagV2::IsVisible { 0 } else { 1 }; + if found[index].replace(predicate).is_some() { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "duplicate mesh flag", + format!("{base}.filters[{j}].flag"), + )); + } + } + if found.iter().any(Option::is_none) { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "both mesh flags are required", + format!("{base}.filters"), + )); + } + NormalizedParametersV2::MeshQuery { + filters: [ + NormalizedMeshFilterV2 { + flag: MeshFlagV2::IsVisible, + predicate: found[0].unwrap(), + }, + NormalizedMeshFilterV2 { + flag: MeshFlagV2::IsFrustumCulled, + predicate: found[1].unwrap(), + }, + ], + } + } + "depth_stencil_config" => { + let p: DepthParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + if !p.clear_depth.is_finite() || !(0.0..=1.0).contains(&p.clear_depth) { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "clearDepth must be finite and in [0,1]", + format!("{base}.clearDepth"), + )); + } + NormalizedParametersV2::DepthStencilConfig { + config: NormalizedDepthStencilV2 { + depth_compare: p.depth_compare, + depth_write_enabled: p.depth_write_enabled, + clear_depth: p.clear_depth, + }, + } + } + "legacy_forward" => { + let p: ForwardParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + if p.clear_color.iter().any(|x| !x.is_finite()) { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "clearColor must be finite", + format!("{base}.clearColor"), + )); + } + NormalizedParametersV2::LegacyForward { + clear_color: p.clear_color, + } + } + _ => unreachable!(), + }) +} + +fn accepts(c: TypeConstraintV2, ty: SemanticTypeV2) -> bool { + match c { + TypeConstraintV2::Exact(x) => x == ty, + TypeConstraintV2::OneOf(xs) => xs.contains(&ty), + } +} + +pub fn compile_v2(graph: GraphV2) -> Result { + if graph.nodes.len() > 1024 { + return Err(error( + "GRAPH_LIMIT_EXCEEDED", + "node count exceeds 1024", + "nodes", + )); + } + let mut input_count = 0usize; + for (i, node) in graph.nodes.iter().enumerate() { + input_count = input_count.saturating_add(node.inputs.len()); + if input_count > 8192 { + return Err(error( + "GRAPH_LIMIT_EXCEEDED", + "input count exceeds 8192", + format!("nodes[{i}].inputs"), + )); + } + } + if graph + .nodes + .iter() + .filter(|n| n.executor.key == "present") + .count() + > 64 + { + return Err(error( + "GRAPH_LIMIT_EXCEEDED", + "present count exceeds 64", + "nodes", + )); + } + if graph.schema_version != 2 { + return Err(GraphError::new( + "GRAPH_SCHEMA_UNSUPPORTED", + "schemaVersion must be 2", + )); + } + validate_name_length(&graph.graph_id, "graphId")?; + for (i, n) in graph.nodes.iter().enumerate() { + for (value, path) in [ + (&n.id, format!("nodes[{i}].id")), + (&n.executor.key, format!("nodes[{i}].executor.key")), + ] { + validate_name_length(value, path)?; + } + for (socket, r) in &n.inputs { + for (value, path) in [ + (socket, format!("nodes[{i}].inputs.{socket}")), + (&r.node, format!("nodes[{i}].inputs.{socket}.node")), + (&r.socket, format!("nodes[{i}].inputs.{socket}.socket")), + ] { + validate_name_length(value, path)?; + } + } + } + + validate_name_grammar(&graph.graph_id, "graphId")?; + let mut ids = HashMap::new(); + for (i, n) in graph.nodes.iter().enumerate() { + for (value, path) in [ + (&n.id, format!("nodes[{i}].id")), + (&n.executor.key, format!("nodes[{i}].executor.key")), + ] { + validate_name_grammar(value, path)?; + } + if ids.insert(n.id.as_str(), i).is_some() { + return Err(error( + "GRAPH_DUPLICATE_ID", + "duplicate node id", + format!("nodes[{i}].id"), + )); + } + for (socket, r) in &n.inputs { + for (value, path) in [ + (socket, format!("nodes[{i}].inputs.{socket}")), + (&r.node, format!("nodes[{i}].inputs.{socket}.node")), + (&r.socket, format!("nodes[{i}].inputs.{socket}.socket")), + ] { + validate_name_grammar(value, path)?; + } + } + } + for (i, n) in graph.nodes.iter().enumerate() { + for (s, r) in &n.inputs { + if !ids.contains_key(r.node.as_str()) { + return Err(error( + "GRAPH_UNKNOWN_NODE", + "unknown input node", + format!("nodes[{i}].inputs.{s}.node"), + )); + } + } + } + let contracts: Vec<_> = graph + .nodes + .iter() + .enumerate() + .map(|(i, n)| { + contract(&n.executor.key).ok_or_else(|| { + error( + "GRAPH_UNKNOWN_EXECUTOR", + "unknown executor", + format!("nodes[{i}].executor.key"), + ) + }) + }) + .collect::>()?; + for (i, n) in graph.nodes.iter().enumerate() { + if n.executor.version != contracts[i].version { + return Err(error( + "GRAPH_EXECUTOR_VERSION_UNSUPPORTED", + "unsupported executor version", + format!("nodes[{i}].executor.version"), + )); + } + } + let params: Vec<_> = graph + .nodes + .iter() + .enumerate() + .map(|(i, n)| decode(n, i)) + .collect::>()?; + for (i, n) in graph.nodes.iter().enumerate() { + if n.state != NodeStateV2::Enabled { + return Err(error( + "GRAPH_NODE_STATE_INVALID", + "muted nodes are unsupported", + format!("nodes[{i}].state"), + )); + } + } + + // Socket validation is intentionally global and phased. In particular, no + // cardinality or semantic error may hide a later structural socket error. + for (i, n) in graph.nodes.iter().enumerate() { + for name in n.inputs.keys() { + if !contracts[i].inputs.iter().any(|s| s.name == name) { + return Err(error( + "GRAPH_UNKNOWN_SOCKET", + "unknown input socket", + format!("nodes[{i}].inputs.{name}"), + )); + } + } + } + for (i, n) in graph.nodes.iter().enumerate() { + for (name, r) in &n.inputs { + let pn = ids[r.node.as_str()]; + if !contracts[pn].outputs.iter().any(|out| out.name == r.socket) { + return Err(error( + "GRAPH_UNKNOWN_SOCKET", + "unknown output socket", + format!("nodes[{i}].inputs.{name}.socket"), + )); + } + } + } + for (i, n) in graph.nodes.iter().enumerate() { + for input in contracts[i].inputs { + let inactive = matches!(¶ms[i], NormalizedParametersV2::MeshQuery { filters } if filters.iter().any(|f| f.flag.input_socket() == input.name && f.predicate == TriStatePredicate::Any)); + if !n.inputs.contains_key(input.name) { + if input.cardinality == InputCardinalityV2::RequiredOne + || (!inactive + && matches!(params[i], NormalizedParametersV2::MeshQuery { .. }) + && input.name != "scene") + { + return Err(error( + "GRAPH_SOCKET_CARDINALITY", + "required input is missing", + format!("nodes[{i}].inputs.{}", input.name), + )); + } + } + } + } + + let mut bound: Vec> = vec![BTreeMap::new(); graph.nodes.len()]; + for (i, n) in graph.nodes.iter().enumerate() { + for input in contracts[i].inputs { + let inactive = matches!(¶ms[i], NormalizedParametersV2::MeshQuery { filters } if filters.iter().any(|f| f.flag.input_socket() == input.name && f.predicate == TriStatePredicate::Any)); + let Some(r) = n.inputs.get(input.name) else { + continue; + }; + let pn = ids[r.node.as_str()]; + let (ordinal, out) = contracts[pn] + .outputs + .iter() + .enumerate() + .find(|(_, o)| o.name == r.socket) + .expect("producer sockets were globally validated"); + let attachment_shape_checked_later = contracts[i].key == "legacy_forward" + && input.name == "depthTarget" + && out.semantic_type == SemanticTypeV2::SurfaceTarget; + if !accepts(input.accepted, out.semantic_type) && !attachment_shape_checked_later { + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "socket type mismatch", + format!("nodes[{i}].inputs.{}", input.name), + )); + } + if let Some(flag) = MeshFlagV2::ORDERED + .iter() + .find(|f| f.input_socket() == input.name) + { + if out.metadata != (OutputMetadataV2::BooleanFlag { flag: *flag }) { + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "mesh flag metadata mismatch", + format!("nodes[{i}].inputs.{}", input.name), + )); + } + } + bound[i].insert( + input.name, + BoundInput { + producer: OutputKey(pn, ordinal as u16), + active: !inactive, + }, + ); + } + } + let root = |key: OutputKey, + bound: &Vec>, + contracts: &Vec<&ContractV2>| + -> Option { + let mut k = key; + let mut seen = HashSet::new(); + loop { + if !seen.insert(k.0) { + return None; + } + if contracts[k.0].outputs[k.1 as usize].semantic_type == SemanticTypeV2::SceneTable { + return Some(k); + } + k = bound[k.0].get("scene")?.producer; + } + }; + for (i, c) in contracts.iter().enumerate() { + if c.key == "frustum_cull" + && root(bound[i]["scene"].producer, &bound, &contracts) + != root(bound[i]["localAabbs"].producer, &bound, &contracts) + { + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "scene roots differ", + format!("nodes[{i}].inputs.localAabbs"), + )); + } + if matches!(c.key, "mesh_query" | "legacy_forward") { + let scene = root(bound[i]["scene"].producer, &bound, &contracts); + for (s, b) in &bound[i] { + if b.active + && matches!(*s, "isVisible" | "isFrustumCulled" | "draws") + && root(b.producer, &bound, &contracts) != scene + { + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "scene roots differ", + format!("nodes[{i}].inputs.{s}"), + )); + } + } + } + } + let mut edges = Vec::new(); + for i in 0..graph.nodes.len() { + for (input_ordinal, input) in contracts[i].inputs.iter().enumerate() { + if let Some(b) = bound[i].get(input.name).filter(|b| b.active) { + edges.push(DependencyEdge { + from_node: b.producer.0, + from_socket: contracts[b.producer.0].outputs[b.producer.1 as usize] + .name + .into(), + producer_output_ordinal: b.producer.1, + to_node: i, + to_socket: input.name.into(), + consumer_input_ordinal: input_ordinal as u16, + resource: graph.nodes[i].inputs[input.name].clone(), + }); + } + } + } + edges.sort_by_key(|e| { + ( + e.to_node, + e.consumer_input_ordinal, + e.from_node, + e.producer_output_ordinal, + ) + }); + let mut deps = vec![Vec::new(); graph.nodes.len()]; + for edge in &edges { + deps[edge.to_node].push(edge.from_node); + } + for node_deps in &mut deps { + node_deps.sort(); + node_deps.dedup(); + } + let mut live = HashSet::new(); + let mut stack: Vec<_> = contracts + .iter() + .enumerate() + .filter(|(_, c)| c.inherently_observable) + .map(|(i, _)| i) + .collect(); + while let Some(i) = stack.pop() { + if live.insert(i) { + stack.extend(deps[i].iter().copied()); + } + } + // IDs are independent of scheduling: original node order, then contract output order. + let mut output_ids = BTreeMap::new(); + let mut resource_meta = Vec::new(); + for i in 0..graph.nodes.len() { + if live.contains(&i) { + for (o, out) in contracts[i].outputs.iter().enumerate() { + let id = resource_meta.len() as u32; + output_ids.insert(OutputKey(i, o as u16), id); + resource_meta.push((i, o as u16, *out)); + } + } + } + let all_outputs: usize = contracts.iter().map(|c| c.outputs.len()).sum(); + + // Establish families and transitions without relying on a schedule. + let mut families = Vec::new(); + let mut source_family = HashMap::new(); + for i in 0..graph.nodes.len() { + if !live.contains(&i) { + continue; + } + let source = output_ids.get(&OutputKey(i, 0)).copied(); + match ¶ms[i] { + NormalizedParametersV2::SurfaceTarget => { + let id = families.len() as u32; + let r = source.unwrap(); + source_family.insert(OutputKey(i, 0), id); + families.push(TextureFamilyV2 { + id, + key: TextureFamilyKeyV2 { + source_node: i as u32, + source_socket: 0, + }, + source: TextureFamilySourceV2::ImportedSurface { resource: r }, + lifetime: LifetimeV2 { + first_use: 0, + last_use: 0, + }, + versions: vec![], + usage: vec![], + allocation: None, + aliasable: false, + }); + } + NormalizedParametersV2::TextureSpec { residency, texture } => { + let id = families.len() as u32; + let r = source.unwrap(); + source_family.insert(OutputKey(i, 0), id); + families.push(TextureFamilyV2 { + id, + key: TextureFamilyKeyV2 { + source_node: i as u32, + source_socket: 0, + }, + source: TextureFamilySourceV2::AuthoredTexture { + resource: r, + residency: *residency, + descriptor: texture.clone(), + }, + lifetime: LifetimeV2 { + first_use: 0, + last_use: 0, + }, + versions: vec![], + usage: vec![], + allocation: None, + aliasable: false, + }); + } + _ => {} + } + } + let mut transitions: Vec = Vec::new(); + let mut transitions_by_target: BTreeMap> = BTreeMap::new(); + for i in 0..graph.nodes.len() { + if !live.contains(&i) { + continue; + } + let transition_sockets: &[(&str, u16)] = match contracts[i].key { + "legacy_forward" => &[("colorTarget", 0), ("depthTarget", 1)], + "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" + | "luminance_edge" => &[("colorTarget", 0)], + _ => continue, + }; + for &(input_socket, output_ordinal) in transition_sockets { + let transition = TextureTransition { + writer_node: i, + input_socket, + target: bound[i][input_socket].producer, + output: OutputKey(i, output_ordinal), + }; + let index = transitions.len(); + transitions.push(transition); + transitions_by_target + .entry(transition.target) + .or_default() + .push(index); + } + } + + fn resolve_transition( + output: OutputKey, + transitions: &[TextureTransition], + transition_for_output: &HashMap, + source_family: &HashMap, + colors: &mut HashMap, + resolved: &mut HashMap, + ) -> ResolvedTransition { + if let Some(&value) = resolved.get(&output) { + return value; + } + if colors.get(&output) == Some(&1) { + return ResolvedTransition::Cyclic; + } + colors.insert(output, 1); + let transition = transitions[transition_for_output[&output]]; + let value = if let Some(&family) = source_family.get(&transition.target) { + ResolvedTransition::Resolved { + family, + version: 0, + target: transition.target, + } + } else if transition_for_output.contains_key(&transition.target) { + match resolve_transition( + transition.target, + transitions, + transition_for_output, + source_family, + colors, + resolved, + ) { + ResolvedTransition::Resolved { + family, version, .. + } => ResolvedTransition::Resolved { + family, + version: version + 1, + target: transition.target, + }, + ResolvedTransition::Cyclic => ResolvedTransition::Cyclic, + } + } else { + ResolvedTransition::Cyclic + }; + colors.insert(output, 2); + resolved.insert(output, value); + value + } + + let transition_for_output: HashMap<_, _> = transitions + .iter() + .enumerate() + .map(|(index, transition)| (transition.output, index)) + .collect(); + let mut resolved = HashMap::new(); + let mut colors = HashMap::new(); + for transition in &transitions { + resolve_transition( + transition.output, + &transitions, + &transition_for_output, + &source_family, + &mut colors, + &mut resolved, + ); + } + let mut version_of: HashMap = HashMap::new(); + for transition in &transitions { + if let ResolvedTransition::Resolved { + family, + version, + target, + } = resolved[&transition.output] + { + let target_id = output_ids[&target]; + version_of.insert(transition.output, (family, version, target_id)); + } + } + + let mut outgoing_edges = vec![Vec::new(); graph.nodes.len()]; + for (index, edge) in edges.iter().enumerate() { + if live.contains(&edge.from_node) && live.contains(&edge.to_node) { + outgoing_edges[edge.from_node].push(index); + } + } + for outgoing in &mut outgoing_edges { + outgoing.sort_by_key(|&index| { + let edge = &edges[index]; + ( + edge.to_node, + edge.producer_output_ordinal, + edge.consumer_input_ordinal, + ) + }); + } + + // Every texture reader must execute before a successor overwrites the + // physical allocation backing the older symbolic version. + let mut reachability = HashMap::new(); + for (i, contract) in contracts.iter().enumerate() { + if !live.contains(&i) { + continue; + } + for input in contract.inputs.iter().filter(|input| { + matches!( + input.role, + InputRoleV2::Present | InputRoleV2::SampledTexture + ) + }) { + let key = bound[i][input.name].producer; + if !version_of.contains_key(&key) { + continue; + } + let Some(next_indices) = transitions_by_target.get(&key) else { + continue; + }; + let [next_index] = next_indices.as_slice() else { + continue; + }; + let next = transitions[*next_index]; + if i != next.writer_node + && !reaches( + i, + next.writer_node, + &outgoing_edges, + &edges, + &live, + &mut reachability, + ) + { + return Err(error( + "GRAPH_RESOURCE_VERSION_INVALID", + "older texture version may be read after its successor", + format!("nodes[{i}].inputs.{}", input.name), + )); + } + } + } + + // Same-pass hazards are global and precede every duplicate-writer diagnostic. + for i in 0..graph.nodes.len() { + if !live.contains(&i) { + continue; + } + if contracts[i].key == "legacy_forward" { + if bound[i]["colorTarget"].producer == bound[i]["depthTarget"].producer + || matches!((version_of.get(&OutputKey(i, 0)), version_of.get(&OutputKey(i, 1))), (Some((cf, _, _)), Some((df, _, _))) if cf == df) + { + return Err(error( + "GRAPH_SAME_PASS_HAZARD", + "color and depth use one texture family", + format!("nodes[{i}].inputs"), + )); + } + } else if contracts[i] + .inputs + .iter() + .any(|input| matches!(input.role, InputRoleV2::SampledTexture)) + { + let hazard = contracts[i].inputs.iter().filter(|input| matches!(input.role, InputRoleV2::SampledTexture)).any(|input| matches!((version_of.get(&bound[i][input.name].producer), version_of.get(&OutputKey(i, 0))), (Some((sf, _, _)), Some((tf, _, _))) if sf == tf)); + if hazard { + return Err(error( + "GRAPH_SAME_PASS_HAZARD", + "copy source and target use one texture family", + format!("nodes[{i}].inputs"), + )); + } + } + } + let mut first_writer = BTreeMap::new(); + for transition in &transitions { + if first_writer + .insert(transition.target, transition.writer_node) + .is_some_and(|writer| writer != transition.writer_node) + { + return Err(error( + "GRAPH_DUPLICATE_WRITER", + "texture version has multiple writers", + format!( + "nodes[{}].inputs.{}", + transition.writer_node, transition.input_socket + ), + )); + } + } + + // Materialize versions only after hazard and writer precedence has been settled. + for transition in &transitions { + if let Some(&(family, version, target)) = version_of.get(&transition.output) { + families[family as usize].versions.push(TextureVersionV2 { + version, + resource: output_ids[&transition.output], + target, + initialized: true, + stored: true, + lifetime: LifetimeV2 { + first_use: 0, + last_use: 0, + }, + }); + } + } + for family in &mut families { + family.versions.sort_by_key(|version| version.version); + for (index, version) in family.versions.iter().enumerate() { + if version.version != index as u32 { + return Err(error( + "GRAPH_RESOURCE_VERSION_INVALID", + "texture versions must form a dense linear chain", + "resources", + )); + } + } + } + + // Validate every independently resolved attachment before graph cycle reporting. + for i in 0..graph.nodes.len() { + if !live.contains(&i) || contracts[i].key != "legacy_forward" { + continue; + } + let (Some(&(cf, _, _)), Some(&(df, _, _))) = ( + version_of.get(&OutputKey(i, 0)), + version_of.get(&OutputKey(i, 1)), + ) else { + continue; + }; + let cd = match &families[cf as usize].source { + TextureFamilySourceV2::AuthoredTexture { descriptor, .. } => Some(descriptor), + _ => None, + }; + let dd = match &families[df as usize].source { + TextureFamilySourceV2::AuthoredTexture { descriptor, .. } => descriptor, + _ => { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "depth target must be authored", + format!("nodes[{i}].inputs.depthTarget"), + )) + } + }; + let ok_depth = dd.dimension == TextureDimensionV2::D2 + && dd.format == TextureFormatV2::Depth32Float + && dd.sample_count == 1 + && extent_layers(&dd.extent) == 1; + let ok_color = cd.is_none_or(|d| { + d.format != TextureFormatV2::Depth32Float + && d.dimension == dd.dimension + && d.extent == dd.extent + && d.sample_count == 1 + }); + let surface_ok = cd.is_some() + || matches!(&dd.extent,NormalizedTextureExtentV2::SurfaceRelative{width,height,..} if *width==RatioV2{numerator:1,denominator:1}&&*height==RatioV2{numerator:1,denominator:1}); + if !ok_depth || !ok_color || !surface_ok { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "attachments are incompatible", + format!("nodes[{i}].inputs"), + )); + } + } + + for i in 0..graph.nodes.len() { + if !live.contains(&i) + || !contracts[i] + .inputs + .iter() + .any(|input| matches!(input.role, InputRoleV2::SampledTexture)) + { + continue; + } + let source_key = bound[i]["source"].producer; + let Some(&(source_family_id, _, _)) = version_of.get(&source_key) else { + return Err(error( + "GRAPH_UNINITIALIZED_RESOURCE", + "copy source is not produced", + format!("nodes[{i}].inputs.source"), + )); + }; + let Some(&(target_family_id, _, _)) = version_of.get(&OutputKey(i, 0)) else { + continue; + }; + let source_descriptor = match &families[source_family_id as usize].source { + TextureFamilySourceV2::AuthoredTexture { descriptor, .. } => descriptor, + TextureFamilySourceV2::ImportedSurface { .. } => { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "copy source must be an authored texture", + format!("nodes[{i}].inputs.source"), + )) + } + }; + let source_ok = source_descriptor.format == TextureFormatV2::Rgba16Float + && is_single_view_d2(source_descriptor); + let target_descriptor = match &families[target_family_id as usize].source { + TextureFamilySourceV2::AuthoredTexture { descriptor, .. } => Some(descriptor), + TextureFamilySourceV2::ImportedSurface { .. } => None, + }; + let authored_target_ok = target_descriptor.is_some_and(|descriptor| { + descriptor.format == TextureFormatV2::Rgba16Float && is_single_view_d2(descriptor) + }); + let bloom_input_ok = if contracts[i].key == "bloom_composite" { + let bloom_key = bound[i]["bloom"].producer; + let Some(&(bloom_family_id, _, _)) = version_of.get(&bloom_key) else { + return Err(error( + "GRAPH_UNINITIALIZED_RESOURCE", + "bloom source is not produced", + format!("nodes[{i}].inputs.bloom"), + )); + }; + match &families[bloom_family_id as usize].source { + TextureFamilySourceV2::AuthoredTexture { descriptor, .. } => { + descriptor.format == TextureFormatV2::Rgba16Float + && is_single_view_d2(descriptor) + } + TextureFamilySourceV2::ImportedSurface { .. } => { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "bloom source must be an authored texture", + format!("nodes[{i}].inputs.bloom"), + )) + } + } + } else { + true + }; + let source_is_full_surface = matches!(&source_descriptor.extent, NormalizedTextureExtentV2::SurfaceRelative { width, height, depth_or_array_layers: 1 } if *width == RatioV2 { numerator:1, denominator:1 } && *height == RatioV2 { numerator:1, denominator:1 }); + let target_matches_source = target_descriptor + .is_some_and(|descriptor| descriptor.extent == source_descriptor.extent); + let descriptor_ok = match contracts[i].key { + "fullscreen_copy" => { + target_descriptor.is_none() && source_is_full_surface + || target_descriptor.is_some_and(|descriptor| { + descriptor.format != TextureFormatV2::Depth32Float + && is_single_view_d2(descriptor) + && descriptor.extent == source_descriptor.extent + }) + } + "tone_map" => target_descriptor.is_none() && source_is_full_surface, + "bloom_extract" => authored_target_ok, + "bloom_blur" | "luminance_edge" => authored_target_ok && target_matches_source, + "bloom_composite" => authored_target_ok && target_matches_source && bloom_input_ok, + _ => false, + }; + if !source_ok || !descriptor_ok { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "fullscreen textures are incompatible", + format!("nodes[{i}].inputs"), + )); + } + } + + // Initialization and presentation legality are later than attachment compatibility. + for (i, contract) in contracts.iter().enumerate() { + if !live.contains(&i) || contract.key != "present" { + continue; + } + let key = bound[i]["surface"].producer; + let Some(&(family, _, _)) = version_of.get(&key) else { + if !matches!(resolved.get(&key), Some(ResolvedTransition::Cyclic)) { + return Err(error( + "GRAPH_UNINITIALIZED_RESOURCE", + "present source is not produced", + format!("nodes[{i}].inputs.surface"), + )); + } + continue; + }; + if !matches!( + families[family as usize].source, + TextureFamilySourceV2::ImportedSurface { .. } + ) { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "offscreen textures cannot be presented", + format!("nodes[{i}].inputs.surface"), + )); + } + } + + // Stable Kahn scheduling is deliberately after resource and access validation. + let mut indegree = vec![0; graph.nodes.len()]; + for &node in &live { + indegree[node] = edges + .iter() + .filter(|edge| edge.to_node == node && live.contains(&edge.from_node)) + .count(); + } + let mut queue = BinaryHeap::new(); + for &node in &live { + if indegree[node] == 0 { + queue.push(Reverse(node)); + } + } + let mut order = Vec::new(); + while let Some(Reverse(node)) = queue.pop() { + order.push(node); + for &edge_index in &outgoing_edges[node] { + let consumer = edges[edge_index].to_node; + indegree[consumer] -= 1; + if indegree[consumer] == 0 { + queue.push(Reverse(consumer)); + } + } + } + if order.len() != live.len() { + let residual: Vec<_> = (0..graph.nodes.len()) + .map(|node| live.contains(&node) && indegree[node] != 0) + .collect(); + fn cycle_dfs( + node: usize, + outgoing_edges: &[Vec], + edges: &[DependencyEdge], + residual: &[bool], + colors: &mut [u8], + node_stack: &mut Vec, + edge_stack: &mut Vec, + ) -> Option> { + colors[node] = 1; + node_stack.push(node); + for &edge_index in &outgoing_edges[node] { + let to = edges[edge_index].to_node; + if !residual[to] { + continue; + } + if colors[to] == 0 { + edge_stack.push(edge_index); + if let Some(cycle) = cycle_dfs( + to, + outgoing_edges, + edges, + residual, + colors, + node_stack, + edge_stack, + ) { + return Some(cycle); + } + edge_stack.pop(); + } else if colors[to] == 1 { + let position = node_stack + .iter() + .position(|&stacked| stacked == to) + .unwrap(); + let mut cycle = edge_stack[position..].to_vec(); + cycle.push(edge_index); + return Some(cycle); + } + } + node_stack.pop(); + colors[node] = 2; + None + } + let mut colors = vec![0; graph.nodes.len()]; + let mut cycle = None; + for node in 0..graph.nodes.len() { + if residual[node] && colors[node] == 0 { + cycle = cycle_dfs( + node, + &outgoing_edges, + &edges, + &residual, + &mut colors, + &mut Vec::new(), + &mut Vec::new(), + ); + if cycle.is_some() { + break; + } + } + } + let mut graph_error = GraphError::new("GRAPH_CYCLE", "live graph contains a cycle"); + let payload: Vec<_> = cycle + .unwrap_or_default() + .into_iter() + .map(|index| { + let edge = &edges[index]; + serde_json::json!({ + "fromNode": graph.nodes[edge.from_node].id, + "fromSocket": edge.from_socket, + "toNode": graph.nodes[edge.to_node].id, + "toSocket": edge.to_socket, + "resource": edge.resource, + }) + }) + .collect(); + graph_error.details = + serde_json::json!({"message":graph_error.message,"kind":"cycle","edges":payload}); + return Err(graph_error); + } + if transitions + .iter() + .any(|transition| matches!(resolved[&transition.output], ResolvedTransition::Cyclic)) + { + return Err(error( + "GRAPH_RESOURCE_VERSION_INVALID", + "texture predecessor is unresolved in an acyclic graph", + "resources", + )); + } + + let mut resources = Vec::new(); + for (i, o, out) in resource_meta { + let key = OutputKey(i, o); + let id = output_ids[&key]; + let scene = || output_ids[&root(bound[i]["scene"].producer, &bound, &contracts).unwrap()]; + let plan = match out.semantic_type { + SemanticTypeV2::SurfaceTarget => ResourcePlanV2::SurfaceTarget { + family: source_family[&key], + }, + SemanticTypeV2::TextureSpec => { + if let NormalizedParametersV2::TextureSpec { residency, texture } = ¶ms[i] { + ResourcePlanV2::TextureSpec { + family: source_family[&key], + residency: *residency, + descriptor: texture.clone(), + } + } else { + unreachable!() + } + } + SemanticTypeV2::Texture => { + let (f, v, t) = version_of[&key]; + ResourcePlanV2::Texture { + family: f, + version: v, + target: t, + initialized: true, + stored: true, + allocation: None, + } + } + SemanticTypeV2::SceneTable => ResourcePlanV2::SceneTable, + SemanticTypeV2::LocalAabbBuffer => ResourcePlanV2::LocalAabbBuffer { scene: scene() }, + SemanticTypeV2::CameraFrustum => ResourcePlanV2::CameraFrustum, + SemanticTypeV2::BooleanFlagBuffer => { + if let OutputMetadataV2::BooleanFlag { flag } = out.metadata { + ResourcePlanV2::BooleanFlagBuffer { + scene: scene(), + flag, + } + } else { + unreachable!() + } + } + SemanticTypeV2::DrawStream => ResourcePlanV2::DrawStream { scene: scene() }, + SemanticTypeV2::DepthStencilConfig => { + if let NormalizedParametersV2::DepthStencilConfig { config } = ¶ms[i] { + ResourcePlanV2::DepthStencilConfig { config: *config } + } else { + unreachable!() + } + } + }; + resources.push(CompiledResourceV2 { + original_node_index: i as u32, + output_ordinal: o, + origin: NodeOutputRef { + node: graph.nodes[i].id.clone(), + socket: out.name.into(), + }, + semantic_type: out.semantic_type, + producer_execution: None, + lifetime: None, + plan, + }); + let _ = id; + } + let mut executions = Vec::new(); + let mut node_execution = HashMap::new(); + for &i in &order { + if contracts[i].execution == ExecutionClassV2::Source { + continue; + } + let ordinal = executions.len() as u32; + node_execution.insert(i, ordinal); + let input_resource = |s: &str| output_ids[&bound[i][s].producer]; + let mut inputs = Vec::new(); + for s in contracts[i].inputs { + if let Some(b) = bound[i].get(s.name).filter(|b| b.active) { + inputs.push(CompiledSocketInputV2 { + socket: s.name.into(), + resource: output_ids[&b.producer], + }); + } + } + let outputs: Vec<_> = contracts[i] + .outputs + .iter() + .enumerate() + .map(|(o, s)| CompiledSocketOutputV2 { + socket: s.name.into(), + resource: output_ids[&OutputKey(i, o as u16)], + }) + .collect(); + let mut accesses = Vec::new(); + let kind = match contracts[i].key { + "frustum_cull" => { + for (s, m) in [ + ("scene", AccessModeV2::StorageRead), + ("localAabbs", AccessModeV2::StorageRead), + ("frustum", AccessModeV2::UniformRead), + ] { + accesses.push(CompiledAccessV2 { + socket: s.into(), + resource: input_resource(s), + mode: m, + }); + } + let r = output_ids[&OutputKey(i, 0)]; + accesses.push(CompiledAccessV2 { + socket: "flags".into(), + resource: r, + mode: AccessModeV2::StorageWrite { + full_overwrite: true, + }, + }); + ExecutionKindV2::Compute { + work: ComputeWorkV2::FrustumCull, + } + } + "mesh_query" => { + for s in ["scene", "isVisible", "isFrustumCulled"] { + if let Some(b) = bound[i].get(s).filter(|b| b.active) { + accesses.push(CompiledAccessV2 { + socket: s.into(), + resource: output_ids[&b.producer], + mode: AccessModeV2::StorageRead, + }); + } + } + accesses.push(CompiledAccessV2 { + socket: "draws".into(), + resource: output_ids[&OutputKey(i, 0)], + mode: AccessModeV2::StorageWrite { + full_overwrite: true, + }, + }); + ExecutionKindV2::Compute { + work: ComputeWorkV2::MeshQuery, + } + } + "legacy_forward" => { + let color = output_ids[&OutputKey(i, 0)]; + let depth = output_ids[&OutputKey(i, 1)]; + let clear = match params[i] { + NormalizedParametersV2::LegacyForward { clear_color } => clear_color, + _ => unreachable!(), + }; + let config_node = bound[i]["depthStencil"].producer.0; + let dc = match params[config_node] { + NormalizedParametersV2::DepthStencilConfig { config } => config, + _ => unreachable!(), + }; + let cl = NormalizedColorLoadV2::Clear { value: clear }; + let dl = NormalizedDepthLoadV2::Clear { + value: dc.clear_depth, + }; + for s in ["scene", "draws"] { + accesses.push(CompiledAccessV2 { + socket: s.into(), + resource: input_resource(s), + mode: if s == "draws" { + AccessModeV2::IndirectRead + } else { + AccessModeV2::SemanticRead + }, + }); + } + accesses.push(CompiledAccessV2 { + socket: "color".into(), + resource: color, + mode: AccessModeV2::ColorAttachment { + location: 0, + load: cl, + store: StoreOpV2::Store, + full_overwrite: true, + }, + }); + accesses.push(CompiledAccessV2 { + socket: "depth".into(), + resource: depth, + mode: AccessModeV2::DepthAttachment { + load: dl, + store: StoreOpV2::Store, + full_overwrite: true, + }, + }); + ExecutionKindV2::Render { + color_attachments: vec![ColorAttachmentPlanV2 { + resource: color, + location: 0, + load: cl, + store: StoreOpV2::Store, + }], + depth_stencil: Some(DepthStencilAttachmentPlanV2 { + resource: depth, + load: dl, + store: StoreOpV2::Store, + }), + } + } + "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" + | "luminance_edge" => { + let color = output_ids[&OutputKey(i, 0)]; + let load = NormalizedColorLoadV2::Clear { + value: [0.0, 0.0, 0.0, 0.0], + }; + accesses.push(CompiledAccessV2 { + socket: "source".into(), + resource: input_resource("source"), + mode: AccessModeV2::SampledTexture, + }); + if contracts[i].key == "bloom_composite" { + accesses.push(CompiledAccessV2 { + socket: "bloom".into(), + resource: input_resource("bloom"), + mode: AccessModeV2::SampledTexture, + }); + } + accesses.push(CompiledAccessV2 { + socket: "color".into(), + resource: color, + mode: AccessModeV2::ColorAttachment { + location: 0, + load, + store: StoreOpV2::Store, + full_overwrite: true, + }, + }); + ExecutionKindV2::Render { + color_attachments: vec![ColorAttachmentPlanV2 { + resource: color, + location: 0, + load, + store: StoreOpV2::Store, + }], + depth_stencil: None, + } + } + "present" => { + let r = input_resource("surface"); + accesses.push(CompiledAccessV2 { + socket: "surface".into(), + resource: r, + mode: AccessModeV2::Present, + }); + ExecutionKindV2::Present { surface: r } + } + _ => unreachable!(), + }; + executions.push(CompiledExecutionV2 { + id: graph.nodes[i].id.clone(), + original_node_index: i as u32, + executor: graph.nodes[i].executor.clone(), + parameters: params[i].clone(), + kind, + inputs, + outputs, + accesses, + }); + } + for (ordinal, e) in executions.iter().enumerate() { + for o in &e.outputs { + resources[o.resource as usize].producer_execution = Some(ordinal as u32); + } + } + // Dense lifetimes touch bindings, outputs, and accesses. + for (ordinal, e) in executions.iter().enumerate() { + let ordinal = ordinal as u32; + let mut touched = BTreeSet::new(); + for x in &e.inputs { + touched.insert(x.resource); + } + for x in &e.outputs { + touched.insert(x.resource); + } + for x in &e.accesses { + touched.insert(x.resource); + } + for r in touched { + let life = resources[r as usize].lifetime.get_or_insert(LifetimeV2 { + first_use: ordinal, + last_use: ordinal, + }); + life.first_use = life.first_use.min(ordinal); + life.last_use = life.last_use.max(ordinal); + } + } + for f in &mut families { + let mut first = None; + let mut last = 0; + for v in &mut f.versions { + v.lifetime = resources[v.resource as usize].lifetime.unwrap(); + first = Some(first.map_or(v.lifetime.first_use, |x: u32| x.min(v.lifetime.first_use))); + last = last.max(v.lifetime.last_use); + } + f.lifetime = LifetimeV2 { + first_use: first.unwrap_or(0), + last_use: last, + }; + f.usage = texture_usage(f, &executions); + f.aliasable = matches!( + f.source, + TextureFamilySourceV2::AuthoredTexture { + residency: TextureResidencyV2::Transient, + .. + } + ) && f.versions.iter().all(|v| v.initialized); + } + let (classes, transient) = allocate(&mut families, &mut resources); + if resources.len() > 1024 { + return Err(error( + "GRAPH_LIMIT_EXCEEDED", + "too many final resources", + "resources", + )); + } + Ok(CompiledGraphV2 { + schema_version: 2, + graph_id: graph.graph_id, + revision: graph.revision, + node_count: graph.nodes.len() as u32, + resources, + executions, + texture_families: families, + allocation_classes: classes, + culled_node_count: (graph.nodes.len() - live.len()) as u32, + culled_resource_count: (all_outputs - output_ids.len()) as u32, + transient_slot_count: transient, + }) +} + +fn extent_layers(e: &NormalizedTextureExtentV2) -> u32 { + match e { + NormalizedTextureExtentV2::Absolute { + depth_or_array_layers, + .. + } + | NormalizedTextureExtentV2::SurfaceRelative { + depth_or_array_layers, + .. + } => *depth_or_array_layers, + } +} +fn is_single_view_d2(descriptor: &NormalizedTextureDescriptorV2) -> bool { + descriptor.dimension == TextureDimensionV2::D2 + && descriptor.sample_count == 1 + && descriptor.mip_level_count == 1 + && extent_layers(&descriptor.extent) == 1 +} +fn texture_usage(f: &TextureFamilyV2, executions: &[CompiledExecutionV2]) -> Vec { + let rs: HashSet<_> = f.versions.iter().map(|v| v.resource).collect(); + let mut u = BTreeSet::new(); + for e in executions { + for a in &e.accesses { + if !rs.contains(&a.resource) { + continue; + } + match a.mode { + AccessModeV2::SampledTexture => { + u.insert(TextureUsageV2::Sampled); + } + AccessModeV2::StorageRead | AccessModeV2::StorageWrite { .. } => { + u.insert(TextureUsageV2::Storage); + } + AccessModeV2::ColorAttachment { .. } => { + u.insert(TextureUsageV2::ColorAttachment); + } + AccessModeV2::DepthAttachment { .. } => { + u.insert(TextureUsageV2::DepthAttachment); + } + _ => {} + } + } + } + u.into_iter().collect() +} +fn allocate( + families: &mut [TextureFamilyV2], + resources: &mut [CompiledResourceV2], +) -> (Vec, u32) { + let mut grouped: BTreeMap> = BTreeMap::new(); + for (i, f) in families.iter().enumerate() { + if let TextureFamilySourceV2::AuthoredTexture { descriptor, .. } = &f.source { + grouped + .entry(TextureCompatibilityKeyV2 { + dimension: descriptor.dimension, + format: descriptor.format, + extent: descriptor.extent.clone(), + mip_level_count: descriptor.mip_level_count, + sample_count: descriptor.sample_count, + view_formats: descriptor.view_formats.clone(), + }) + .or_default() + .push(i); + } + } + let mut classes = Vec::new(); + let mut transient = 0; + for (key, ids) in grouped { + let class = classes.len() as u32; + let mut slots: Vec = Vec::new(); + let mut aliasable = Vec::new(); + let mut dedicated = Vec::new(); + let mut persistent_ids = Vec::new(); + for fi in ids { + let persistent = matches!( + families[fi].source, + TextureFamilySourceV2::AuthoredTexture { + residency: TextureResidencyV2::Persistent, + .. + } + ); + let alias = families[fi].aliasable && !persistent; + if persistent { + persistent_ids.push(fi); + } else if alias { + aliasable.push(fi); + } else { + dedicated.push(fi); + } + } + aliasable.sort_by_key(|&fi| { + ( + families[fi].lifetime.first_use, + families[fi].lifetime.last_use, + families[fi].key.clone(), + ) + }); + dedicated.sort_by_key(|&fi| families[fi].key.clone()); + persistent_ids.sort_by_key(|&fi| families[fi].key.clone()); + for fi in aliasable.into_iter().chain(dedicated).chain(persistent_ids) { + let persistent = matches!( + families[fi].source, + TextureFamilySourceV2::AuthoredTexture { + residency: TextureResidencyV2::Persistent, + .. + } + ); + let alias = families[fi].aliasable && !persistent; + let found = if alias { + slots.iter().position(|s| { + s.kind == AllocationKindV2::AliasedTransient + && s.occupants.iter().all(|&old| { + families[old as usize].lifetime.last_use + < families[fi].lifetime.first_use + }) + }) + } else { + None + }; + let slot = found.unwrap_or_else(|| { + let s = slots.len(); + if !persistent { + transient += 1; + } + slots.push(AllocationSlotV2 { + kind: if persistent { + AllocationKindV2::Persistent + } else if alias { + AllocationKindV2::AliasedTransient + } else { + AllocationKindV2::DedicatedTransient + }, + usage: Vec::new(), + occupants: Vec::new(), + }); + s + }); + slots[slot].occupants.push(fi as u32); + slots[slot].usage.extend(families[fi].usage.iter().copied()); + slots[slot].usage.sort(); + slots[slot].usage.dedup(); + let a = AllocationRefV2 { + class, + slot: slot as u32, + }; + families[fi].allocation = Some(a); + for v in &families[fi].versions { + if let ResourcePlanV2::Texture { allocation, .. } = + &mut resources[v.resource as usize].plan + { + *allocation = Some(a); + } + } + } + classes.push(AllocationClassV2 { key, slots }); + } + (classes, transient) +} diff --git a/renderer/src/render_graph/contracts_v2.rs b/renderer/src/render_graph/contracts_v2.rs new file mode 100644 index 0000000..7ca998d --- /dev/null +++ b/renderer/src/render_graph/contracts_v2.rs @@ -0,0 +1,417 @@ +use super::MeshFlagV2; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SemanticTypeV2 { + SurfaceTarget, + TextureSpec, + Texture, + SceneTable, + LocalAabbBuffer, + CameraFrustum, + BooleanFlagBuffer, + DrawStream, + DepthStencilConfig, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ExecutionClassV2 { + Source, + CpuPreparation, + Compute, + Render, + Present, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InputCardinalityV2 { + RequiredOne, + OptionalOne, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "kind", content = "types", rename_all = "snake_case")] +pub enum TypeConstraintV2 { + Exact(SemanticTypeV2), + OneOf(&'static [SemanticTypeV2]), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum InputRoleV2 { + SemanticRead, + UniformRead, + StorageRead, + IndirectRead, + SampledTexture, + ColorTarget { location: u32 }, + DepthTarget, + Present, + Configuration, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum OutputMetadataV2 { + None, + BooleanFlag { flag: MeshFlagV2 }, +} + +#[derive(Clone, Copy, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InputSocketContractV2 { + pub name: &'static str, + pub accepted: TypeConstraintV2, + pub cardinality: InputCardinalityV2, + pub role: InputRoleV2, +} + +#[derive(Clone, Copy, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OutputSocketContractV2 { + pub name: &'static str, + pub semantic_type: SemanticTypeV2, + pub metadata: OutputMetadataV2, +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ContractV2 { + pub key: &'static str, + pub version: u32, + pub execution: ExecutionClassV2, + pub inputs: &'static [InputSocketContractV2], + pub outputs: &'static [OutputSocketContractV2], + pub inherently_observable: bool, +} + +use SemanticTypeV2::*; + +const fn input( + name: &'static str, + accepted: TypeConstraintV2, + cardinality: InputCardinalityV2, + role: InputRoleV2, +) -> InputSocketContractV2 { + InputSocketContractV2 { + name, + accepted, + cardinality, + role, + } +} + +const fn output( + name: &'static str, + semantic_type: SemanticTypeV2, + metadata: OutputMetadataV2, +) -> OutputSocketContractV2 { + OutputSocketContractV2 { + name, + semantic_type, + metadata, + } +} + +const REQUIRED: InputCardinalityV2 = InputCardinalityV2::RequiredOne; +const OPTIONAL: InputCardinalityV2 = InputCardinalityV2::OptionalOne; +const NONE_IN: &[InputSocketContractV2] = &[]; +const NONE_OUT: &[OutputSocketContractV2] = &[]; +const SURFACE_OUT: &[OutputSocketContractV2] = + &[output("surface", SurfaceTarget, OutputMetadataV2::None)]; +const SPEC_OUT: &[OutputSocketContractV2] = &[output("spec", TextureSpec, OutputMetadataV2::None)]; +const SCENE_OUT: &[OutputSocketContractV2] = &[output("scene", SceneTable, OutputMetadataV2::None)]; +const AABB_OUT: &[OutputSocketContractV2] = &[output( + "localAabbs", + LocalAabbBuffer, + OutputMetadataV2::None, +)]; +const FRUSTUM_OUT: &[OutputSocketContractV2] = + &[output("frustum", CameraFrustum, OutputMetadataV2::None)]; +const VISIBLE_OUT: &[OutputSocketContractV2] = &[output( + "flags", + BooleanFlagBuffer, + OutputMetadataV2::BooleanFlag { + flag: MeshFlagV2::IsVisible, + }, +)]; +const CULLED_OUT: &[OutputSocketContractV2] = &[output( + "flags", + BooleanFlagBuffer, + OutputMetadataV2::BooleanFlag { + flag: MeshFlagV2::IsFrustumCulled, + }, +)]; +const DRAW_OUT: &[OutputSocketContractV2] = &[output("draws", DrawStream, OutputMetadataV2::None)]; +const CONFIG_OUT: &[OutputSocketContractV2] = + &[output("config", DepthStencilConfig, OutputMetadataV2::None)]; +const FORWARD_OUT: &[OutputSocketContractV2] = &[ + output("color", Texture, OutputMetadataV2::None), + output("depth", Texture, OutputMetadataV2::None), +]; +const FULLSCREEN_COPY_OUT: &[OutputSocketContractV2] = + &[output("color", Texture, OutputMetadataV2::None)]; +const LOCAL_IN: &[InputSocketContractV2] = &[input( + "scene", + TypeConstraintV2::Exact(SceneTable), + REQUIRED, + InputRoleV2::SemanticRead, +)]; +const VISIBILITY_IN: &[InputSocketContractV2] = LOCAL_IN; +const CULL_IN: &[InputSocketContractV2] = &[ + input( + "scene", + TypeConstraintV2::Exact(SceneTable), + REQUIRED, + InputRoleV2::StorageRead, + ), + input( + "localAabbs", + TypeConstraintV2::Exact(LocalAabbBuffer), + REQUIRED, + InputRoleV2::StorageRead, + ), + input( + "frustum", + TypeConstraintV2::Exact(CameraFrustum), + REQUIRED, + InputRoleV2::UniformRead, + ), +]; +const QUERY_IN: &[InputSocketContractV2] = &[ + input( + "scene", + TypeConstraintV2::Exact(SceneTable), + REQUIRED, + InputRoleV2::StorageRead, + ), + input( + "isVisible", + TypeConstraintV2::Exact(BooleanFlagBuffer), + OPTIONAL, + InputRoleV2::StorageRead, + ), + input( + "isFrustumCulled", + TypeConstraintV2::Exact(BooleanFlagBuffer), + OPTIONAL, + InputRoleV2::StorageRead, + ), +]; +const FORWARD_IN: &[InputSocketContractV2] = &[ + input( + "scene", + TypeConstraintV2::Exact(SceneTable), + REQUIRED, + InputRoleV2::SemanticRead, + ), + input( + "draws", + TypeConstraintV2::Exact(DrawStream), + REQUIRED, + InputRoleV2::IndirectRead, + ), + input( + "colorTarget", + TypeConstraintV2::OneOf(&[SurfaceTarget, TextureSpec, Texture]), + REQUIRED, + InputRoleV2::ColorTarget { location: 0 }, + ), + input( + "depthTarget", + TypeConstraintV2::OneOf(&[TextureSpec, Texture]), + REQUIRED, + InputRoleV2::DepthTarget, + ), + input( + "depthStencil", + TypeConstraintV2::Exact(DepthStencilConfig), + REQUIRED, + InputRoleV2::Configuration, + ), +]; +const FULLSCREEN_COPY_IN: &[InputSocketContractV2] = &[ + input( + "source", + TypeConstraintV2::Exact(Texture), + REQUIRED, + InputRoleV2::SampledTexture, + ), + input( + "colorTarget", + TypeConstraintV2::OneOf(&[SurfaceTarget, TextureSpec, Texture]), + REQUIRED, + InputRoleV2::ColorTarget { location: 0 }, + ), +]; +const BLOOM_COMPOSITE_IN: &[InputSocketContractV2] = &[ + input( + "source", + TypeConstraintV2::Exact(Texture), + REQUIRED, + InputRoleV2::SampledTexture, + ), + input( + "bloom", + TypeConstraintV2::Exact(Texture), + REQUIRED, + InputRoleV2::SampledTexture, + ), + input( + "colorTarget", + TypeConstraintV2::OneOf(&[SurfaceTarget, TextureSpec, Texture]), + REQUIRED, + InputRoleV2::ColorTarget { location: 0 }, + ), +]; +const PRESENT_IN: &[InputSocketContractV2] = &[input( + "surface", + TypeConstraintV2::Exact(Texture), + REQUIRED, + InputRoleV2::Present, +)]; + +pub static CONTRACTS_V2: &[ContractV2] = &[ + ContractV2 { + key: "surface_target", + version: 1, + execution: ExecutionClassV2::Source, + inputs: NONE_IN, + outputs: SURFACE_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "texture_spec", + version: 1, + execution: ExecutionClassV2::Source, + inputs: NONE_IN, + outputs: SPEC_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "scene_table", + version: 1, + execution: ExecutionClassV2::Source, + inputs: NONE_IN, + outputs: SCENE_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "local_aabb_buffer", + version: 1, + execution: ExecutionClassV2::Source, + inputs: LOCAL_IN, + outputs: AABB_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "camera_frustum", + version: 1, + execution: ExecutionClassV2::Source, + inputs: NONE_IN, + outputs: FRUSTUM_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "visibility_flags", + version: 1, + execution: ExecutionClassV2::Source, + inputs: VISIBILITY_IN, + outputs: VISIBLE_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "frustum_cull", + version: 1, + execution: ExecutionClassV2::Compute, + inputs: CULL_IN, + outputs: CULLED_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "mesh_query", + version: 1, + execution: ExecutionClassV2::Compute, + inputs: QUERY_IN, + outputs: DRAW_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "depth_stencil_config", + version: 1, + execution: ExecutionClassV2::Source, + inputs: NONE_IN, + outputs: CONFIG_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "legacy_forward", + version: 1, + execution: ExecutionClassV2::Render, + inputs: FORWARD_IN, + outputs: FORWARD_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "fullscreen_copy", + version: 1, + execution: ExecutionClassV2::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "tone_map", + version: 1, + execution: ExecutionClassV2::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "bloom_extract", + version: 1, + execution: ExecutionClassV2::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "bloom_blur", + version: 1, + execution: ExecutionClassV2::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "bloom_composite", + version: 1, + execution: ExecutionClassV2::Render, + inputs: BLOOM_COMPOSITE_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "luminance_edge", + version: 1, + execution: ExecutionClassV2::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + }, + ContractV2 { + key: "present", + version: 1, + execution: ExecutionClassV2::Present, + inputs: PRESENT_IN, + outputs: NONE_OUT, + inherently_observable: true, + }, +]; + +pub fn contract(key: &str) -> Option<&'static ContractV2> { + CONTRACTS_V2.iter().find(|contract| contract.key == key) +} diff --git a/renderer/src/render_graph/mod.rs b/renderer/src/render_graph/mod.rs index dec32eb..9e15f85 100644 --- a/renderer/src/render_graph/mod.rs +++ b/renderer/src/render_graph/mod.rs @@ -1,9 +1,14 @@ //! Device-free V1 render graph compiler and compiled graph registry. mod compiler; +mod compiler_v2; +mod contracts_v2; +mod plan_v2; mod registry; mod runtime; +mod runtime_v2; mod schema; +mod schema_v2; pub use compiler::{ compile, compile_with, parse_and_compile, AllocationClass, CompiledGraph, CompiledOutput, @@ -11,12 +16,38 @@ pub use compiler::{ ExecutorRegistry, ExecutorResolution, Lifetime, NormalizedParameters, SceneForwardExecutors, TextureAllocationKey, TextureUsage, TransientAllocation, }; -pub use registry::{CompiledGraphId, Registry}; +pub use compiler_v2::{compile_v2, mesh_predicate_matches, parse_and_compile_v2}; +pub use contracts_v2::*; +pub use plan_v2::*; +pub use registry::{CompiledGraphId, RegisteredGraph, Registry}; pub use runtime::{ class_offsets, resolve_extent, runtime_texture_key, validate_activatable, ResolvedExtent, RuntimeTextureKey, }; +pub use runtime_v2::*; pub use schema::*; +pub use schema_v2::*; + +pub fn parse_and_compile_any(bytes: &[u8]) -> Result { + if bytes.len() > MAX_JSON_BYTES { + return Err(GraphError::new( + "GRAPH_PAYLOAD_TOO_LARGE", + "graph payload exceeds 1 MiB", + )); + } + let text = std::str::from_utf8(bytes) + .map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph payload is not UTF-8"))?; + let value: serde_json::Value = serde_json::from_str(text) + .map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?; + match value.get("schemaVersion").and_then(|v| v.as_u64()) { + Some(1) => parse_and_compile(bytes).map(RegisteredGraph::V1), + Some(2) => parse_and_compile_v2(bytes).map(RegisteredGraph::V2), + _ => Err(GraphError::new( + "GRAPH_SCHEMA_UNSUPPORTED", + "schemaVersion must be exactly 1 or 2", + )), + } +} pub const MAX_JSON_BYTES: usize = 1024 * 1024; pub const MAX_RESOURCES: usize = 1024; @@ -56,3 +87,5 @@ impl GraphError { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_v2; diff --git a/renderer/src/render_graph/plan_v2.rs b/renderer/src/render_graph/plan_v2.rs new file mode 100644 index 0000000..189bc7d --- /dev/null +++ b/renderer/src/render_graph/plan_v2.rs @@ -0,0 +1,381 @@ +use serde::Serialize; + +use super::*; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompiledGraphV2 { + pub schema_version: u32, + pub graph_id: String, + pub revision: u32, + pub node_count: u32, + pub resources: Vec, + pub executions: Vec, + pub texture_families: Vec, + pub allocation_classes: Vec, + pub culled_node_count: u32, + pub culled_resource_count: u32, + pub transient_slot_count: u32, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompiledResourceV2 { + pub original_node_index: u32, + pub output_ordinal: u16, + pub origin: NodeOutputRef, + pub semantic_type: SemanticTypeV2, + pub producer_execution: Option, + pub lifetime: Option, + pub plan: ResourcePlanV2, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ResourcePlanV2 { + SurfaceTarget { + family: u32, + }, + TextureSpec { + family: u32, + residency: TextureResidencyV2, + descriptor: NormalizedTextureDescriptorV2, + }, + Texture { + family: u32, + version: u32, + target: u32, + initialized: bool, + stored: bool, + allocation: Option, + }, + SceneTable, + LocalAabbBuffer { + scene: u32, + }, + CameraFrustum, + BooleanFlagBuffer { + scene: u32, + flag: MeshFlagV2, + }, + DrawStream { + scene: u32, + }, + DepthStencilConfig { + config: NormalizedDepthStencilV2, + }, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompiledExecutionV2 { + pub id: String, + pub original_node_index: u32, + pub executor: ExecutorRefV2, + pub parameters: NormalizedParametersV2, + pub kind: ExecutionKindV2, + pub inputs: Vec, + pub outputs: Vec, + pub accesses: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompiledSocketInputV2 { + pub socket: String, + pub resource: u32, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompiledSocketOutputV2 { + pub socket: String, + pub resource: u32, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ExecutionKindV2 { + CpuPreparation, + Compute { + work: ComputeWorkV2, + }, + Render { + color_attachments: Vec, + depth_stencil: Option, + }, + Present { + surface: u32, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ComputeWorkV2 { + FrustumCull, + MeshQuery, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ColorAttachmentPlanV2 { + pub resource: u32, + pub location: u32, + pub load: NormalizedColorLoadV2, + pub store: StoreOpV2, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DepthStencilAttachmentPlanV2 { + pub resource: u32, + pub load: NormalizedDepthLoadV2, + pub store: StoreOpV2, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum NormalizedColorLoadV2 { + Load, + Clear { value: [f64; 4] }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum NormalizedDepthLoadV2 { + Load, + Clear { value: f32 }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StoreOpV2 { + Store, + Discard, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompiledAccessV2 { + pub socket: String, + pub resource: u32, + pub mode: AccessModeV2, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AccessModeV2 { + SemanticRead, + UniformRead, + StorageRead, + StorageWrite { + full_overwrite: bool, + }, + IndirectRead, + SampledTexture, + ColorAttachment { + location: u32, + load: NormalizedColorLoadV2, + store: StoreOpV2, + full_overwrite: bool, + }, + DepthAttachment { + load: NormalizedDepthLoadV2, + store: StoreOpV2, + full_overwrite: bool, + }, + Present, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum NormalizedParametersV2 { + SurfaceTarget, + TextureSpec { + residency: TextureResidencyV2, + texture: NormalizedTextureDescriptorV2, + }, + SceneTable, + LocalAabbBuffer, + CameraFrustum, + VisibilityFlags, + FrustumCull, + MeshQuery { + filters: [NormalizedMeshFilterV2; 2], + }, + DepthStencilConfig { + config: NormalizedDepthStencilV2, + }, + LegacyForward { + clear_color: [f64; 4], + }, + FullscreenCopy, + ToneMap { + exposure: f32, + }, + BloomExtract { + threshold: f32, + knee: f32, + }, + BloomBlur { + direction: [f32; 2], + radius: f32, + }, + BloomComposite { + intensity: f32, + }, + LuminanceEdge { + strength: f32, + }, + Present, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NormalizedMeshFilterV2 { + pub flag: MeshFlagV2, + pub predicate: TriStatePredicate, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NormalizedDepthStencilV2 { + pub depth_compare: CompareFunctionV2, + pub depth_write_enabled: bool, + pub clear_depth: f32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NormalizedTextureDescriptorV2 { + pub dimension: TextureDimensionV2, + pub format: TextureFormatV2, + pub extent: NormalizedTextureExtentV2, + pub mip_level_count: u32, + pub sample_count: u32, + pub view_formats: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum NormalizedTextureExtentV2 { + Absolute { + width: u32, + height: u32, + depth_or_array_layers: u32, + }, + SurfaceRelative { + width: RatioV2, + height: RatioV2, + depth_or_array_layers: u32, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LifetimeV2 { + pub first_use: u32, + pub last_use: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextureFamilyKeyV2 { + pub source_node: u32, + pub source_socket: u16, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TextureFamilySourceV2 { + ImportedSurface { + resource: u32, + }, + AuthoredTexture { + resource: u32, + residency: TextureResidencyV2, + descriptor: NormalizedTextureDescriptorV2, + }, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextureFamilyV2 { + pub id: u32, + pub key: TextureFamilyKeyV2, + pub source: TextureFamilySourceV2, + pub lifetime: LifetimeV2, + pub versions: Vec, + pub usage: Vec, + pub allocation: Option, + pub aliasable: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextureVersionV2 { + pub version: u32, + pub resource: u32, + pub target: u32, + pub initialized: bool, + pub stored: bool, + pub lifetime: LifetimeV2, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextureCompatibilityKeyV2 { + pub dimension: TextureDimensionV2, + pub format: TextureFormatV2, + pub extent: NormalizedTextureExtentV2, + pub mip_level_count: u32, + pub sample_count: u32, + pub view_formats: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AllocationClassV2 { + pub key: TextureCompatibilityKeyV2, + pub slots: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AllocationKindV2 { + AliasedTransient, + DedicatedTransient, + Persistent, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AllocationSlotV2 { + pub kind: AllocationKindV2, + pub usage: Vec, + pub occupants: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AllocationRefV2 { + pub class: u32, + pub slot: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TextureUsageV2 { + Sampled, + Storage, + CopySrc, + CopyDst, + ColorAttachment, + DepthAttachment, +} + +impl CompiledGraphV2 { + pub fn summary(&self, id: [u32; 2]) -> serde_json::Value { + serde_json::json!({"compiledId":id,"graphId":self.graph_id,"revision":self.revision,"schemaVersion":self.schema_version,"nodeCount":self.node_count,"executionCount":self.executions.len(),"resourceCount":self.resources.len(),"culledNodeCount":self.culled_node_count,"culledResourceCount":self.culled_resource_count,"transientSlotCount":self.transient_slot_count}) + } +} diff --git a/renderer/src/render_graph/registry.rs b/renderer/src/render_graph/registry.rs index 9b38e72..7e61eb2 100644 --- a/renderer/src/render_graph/registry.rs +++ b/renderer/src/render_graph/registry.rs @@ -1,4 +1,24 @@ -use super::{parse_and_compile, CompiledGraph, GraphError}; +use super::{parse_and_compile_any, CompiledGraph, CompiledGraphV2, GraphError}; +use std::collections::HashMap; +#[derive(Debug, Clone)] +pub enum RegisteredGraph { + V1(CompiledGraph), + V2(CompiledGraphV2), +} +impl RegisteredGraph { + fn identity(&self) -> (&str, u32) { + match self { + Self::V1(g) => (g.graph_id.as_str(), g.revision), + Self::V2(g) => (g.graph_id.as_str(), g.revision), + } + } + fn summary(&self, id: [u32; 2]) -> serde_json::Value { + match self { + Self::V1(g) => g.summary(id), + Self::V2(g) => g.summary(id), + } + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CompiledGraphId { pub slot: u32, @@ -12,13 +32,14 @@ impl From for [u32; 2] { #[derive(Debug)] struct Slot { generation: u32, - value: Option, + value: Option, retired: bool, } #[derive(Debug)] pub struct Registry { slots: Vec, capacity: u32, + latest_revisions: HashMap, } impl Default for Registry { fn default() -> Self { @@ -30,33 +51,24 @@ impl Registry { Self { slots: vec![], capacity, + latest_revisions: HashMap::new(), } } pub fn compile( &mut self, bytes: &[u8], ) -> Result<(CompiledGraphId, serde_json::Value), GraphError> { - let graph = parse_and_compile(bytes)?; - if let Some((i, s)) = self.slots.iter_mut().enumerate().find(|(_, s)| { - s.value - .as_ref() - .is_some_and(|g| g.graph_id == graph.graph_id) - }) { - if graph.revision <= s.value.as_ref().unwrap().revision { - return Err(GraphError::new( - "GRAPH_REVISION_CONFLICT", - "revision must increase", - )); - } - let id = CompiledGraphId { - slot: u32::try_from(i).map_err(|_| { - GraphError::new("GRAPH_LIMIT_EXCEEDED", "registry slot overflow") - })?, - generation: s.generation, - }; - let summary = graph.summary(id.into()); - s.value = Some(graph); - return Ok((id, summary)); + let graph = parse_and_compile_any(bytes)?; + let (graph_id, revision) = graph.identity(); + if self + .latest_revisions + .get(graph_id) + .is_some_and(|latest| revision <= *latest) + { + return Err(GraphError::new( + "GRAPH_REVISION_CONFLICT", + "revision must increase", + )); } let i = if let Some(i) = self .slots @@ -84,10 +96,26 @@ impl Registry { generation: self.slots[i].generation, }; let summary = graph.summary(id.into()); + self.latest_revisions.insert(graph_id.to_owned(), revision); self.slots[i].value = Some(graph); Ok((id, summary)) } pub fn get(&self, id: CompiledGraphId) -> Result<&CompiledGraph, GraphError> { + match self + .slots + .get(id.slot as usize) + .filter(|s| s.generation == id.generation) + .and_then(|s| s.value.as_ref()) + .ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))? + { + RegisteredGraph::V1(g) => Ok(g), + RegisteredGraph::V2(_) => Err(GraphError::new( + "GRAPH_EXECUTION_UNSUPPORTED", + "schemaVersion 2 activation is unavailable until Phase 4", + )), + } + } + pub fn get_registered(&self, id: CompiledGraphId) -> Result<&RegisteredGraph, GraphError> { self.slots .get(id.slot as usize) .filter(|s| s.generation == id.generation) @@ -95,7 +123,7 @@ impl Registry { .ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id")) } pub fn contains(&self, id: CompiledGraphId) -> bool { - self.get(id).is_ok() + self.get_registered(id).is_ok() } pub fn drop_graph(&mut self, id: CompiledGraphId) -> Result<(), GraphError> { let s = self diff --git a/renderer/src/render_graph/runtime_v2.rs b/renderer/src/render_graph/runtime_v2.rs new file mode 100644 index 0000000..53b2777 --- /dev/null +++ b/renderer/src/render_graph/runtime_v2.rs @@ -0,0 +1,605 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ResolvedExtentV2 { + pub width: u32, + pub height: u32, + pub depth_or_array_layers: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeTextureDescriptorV2 { + pub dimension: wgpu::TextureDimension, + pub format: wgpu::TextureFormat, + pub extent: ResolvedExtentV2, + pub mip_level_count: u32, + pub sample_count: u32, + pub usage: wgpu::TextureUsages, + pub view_formats: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeSurfaceContractV2 { + pub format: wgpu::TextureFormat, + pub width: u32, + pub height: u32, + pub usage: wgpu::TextureUsages, + pub view_formats: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeAllocationSlotV2 { + pub kind: AllocationKindV2, + pub descriptor: RuntimeTextureDescriptorV2, + pub occupants: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeAllocationClassV2 { + pub key: TextureCompatibilityKeyV2, + pub slots: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MeshQueryRuntimeKeyV2 { + pub visible: TriStatePredicate, + pub frustum_culled: TriStatePredicate, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeExecutionV2 { + pub execution: u32, + pub executor: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeAllocationPlanV2 { + pub classes: Vec, + pub resource_allocations: Vec>, + pub surface_family: u32, + pub surface_resource: u32, + pub query: MeshQueryRuntimeKeyV2, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimePlanV2 { + pub allocations: RuntimeAllocationPlanV2, + pub executions: Vec, + pub surface: RuntimeSurfaceContractV2, +} + +fn error(code: &'static str, message: impl Into, path: impl Into) -> GraphError { + GraphError::at(code, message, path) +} + +pub const fn texture_dimension_v2(value: TextureDimensionV2) -> wgpu::TextureDimension { + match value { + TextureDimensionV2::D1 => wgpu::TextureDimension::D1, + TextureDimensionV2::D2 => wgpu::TextureDimension::D2, + TextureDimensionV2::D3 => wgpu::TextureDimension::D3, + } +} + +pub const fn texture_format_v2(value: TextureFormatV2) -> wgpu::TextureFormat { + match value { + TextureFormatV2::Rgba8Unorm => wgpu::TextureFormat::Rgba8Unorm, + TextureFormatV2::Rgba8UnormSrgb => wgpu::TextureFormat::Rgba8UnormSrgb, + TextureFormatV2::Bgra8Unorm => wgpu::TextureFormat::Bgra8Unorm, + TextureFormatV2::Bgra8UnormSrgb => wgpu::TextureFormat::Bgra8UnormSrgb, + TextureFormatV2::Rgba16Float => wgpu::TextureFormat::Rgba16Float, + TextureFormatV2::R32Float => wgpu::TextureFormat::R32Float, + TextureFormatV2::Depth32Float => wgpu::TextureFormat::Depth32Float, + } +} + +pub const fn texture_usage_v2(value: TextureUsageV2) -> wgpu::TextureUsages { + match value { + TextureUsageV2::Sampled => wgpu::TextureUsages::TEXTURE_BINDING, + TextureUsageV2::Storage => wgpu::TextureUsages::STORAGE_BINDING, + TextureUsageV2::CopySrc => wgpu::TextureUsages::COPY_SRC, + TextureUsageV2::CopyDst => wgpu::TextureUsages::COPY_DST, + TextureUsageV2::ColorAttachment | TextureUsageV2::DepthAttachment => { + wgpu::TextureUsages::RENDER_ATTACHMENT + } + } +} + +pub fn texture_usages_v2(values: &[TextureUsageV2]) -> wgpu::TextureUsages { + values + .iter() + .fold(wgpu::TextureUsages::empty(), |usage, value| { + usage | texture_usage_v2(*value) + }) +} + +fn scaled(value: u32, ratio: RatioV2, path: &str) -> Result { + if ratio.denominator == 0 { + return Err(error( + "GRAPH_RESOURCE_LIMIT", + "zero extent denominator", + path, + )); + } + let product = u64::from(value) + .checked_mul(u64::from(ratio.numerator)) + .ok_or_else(|| error("GRAPH_RESOURCE_LIMIT", "extent arithmetic overflow", path))?; + let result = product + .checked_add(u64::from(ratio.denominator) - 1) + .ok_or_else(|| error("GRAPH_RESOURCE_LIMIT", "extent arithmetic overflow", path))? + / u64::from(ratio.denominator); + u32::try_from(result.max(1)) + .map_err(|_| error("GRAPH_RESOURCE_LIMIT", "extent exceeds u32", path)) +} + +pub fn resolve_extent_v2( + extent: &NormalizedTextureExtentV2, + surface: [u32; 2], +) -> Result { + let resolved = match extent { + NormalizedTextureExtentV2::Absolute { + width, + height, + depth_or_array_layers, + } => ResolvedExtentV2 { + width: *width, + height: *height, + depth_or_array_layers: *depth_or_array_layers, + }, + NormalizedTextureExtentV2::SurfaceRelative { + width, + height, + depth_or_array_layers, + } => ResolvedExtentV2 { + width: scaled(surface[0], *width, "extent.width")?, + height: scaled(surface[1], *height, "extent.height")?, + depth_or_array_layers: *depth_or_array_layers, + }, + }; + if resolved.width == 0 || resolved.height == 0 || resolved.depth_or_array_layers == 0 { + return Err(error( + "GRAPH_RESOURCE_LIMIT", + "texture extent is zero", + "extent", + )); + } + Ok(resolved) +} + +pub fn resolved_mip_level_count_v2(extent: ResolvedExtentV2) -> u32 { + 32 - extent + .width + .max(extent.height) + .max(extent.depth_or_array_layers) + .leading_zeros() +} + +fn validate_limits( + dimension: TextureDimensionV2, + extent: ResolvedExtentV2, + mip_count: u32, + limits: Option<&wgpu::Limits>, + path: &str, +) -> Result<(), GraphError> { + let max_mips = resolved_mip_level_count_v2(extent); + if mip_count == 0 || mip_count > max_mips { + return Err(error( + "GRAPH_RESOURCE_LIMIT", + "invalid mip level count", + path, + )); + } + if let Some(l) = limits { + let valid = match dimension { + TextureDimensionV2::D1 => extent.width <= l.max_texture_dimension_1d, + TextureDimensionV2::D2 => { + extent.width <= l.max_texture_dimension_2d + && extent.height <= l.max_texture_dimension_2d + && extent.depth_or_array_layers <= l.max_texture_array_layers + } + TextureDimensionV2::D3 => { + extent.width <= l.max_texture_dimension_3d + && extent.height <= l.max_texture_dimension_3d + && extent.depth_or_array_layers <= l.max_texture_dimension_3d + } + }; + if !valid { + return Err(error( + "GRAPH_RESOURCE_LIMIT", + "texture exceeds device limits", + path, + )); + } + } + Ok(()) +} + +pub fn runtime_texture_descriptor_v2( + key: &TextureCompatibilityKeyV2, + usage: &[TextureUsageV2], + surface: [u32; 2], + limits: Option<&wgpu::Limits>, +) -> Result { + let extent = resolve_extent_v2(&key.extent, surface)?; + validate_limits( + key.dimension, + extent, + key.mip_level_count, + limits, + "allocationClasses.key", + )?; + Ok(RuntimeTextureDescriptorV2 { + dimension: texture_dimension_v2(key.dimension), + format: texture_format_v2(key.format), + extent, + mip_level_count: key.mip_level_count, + sample_count: key.sample_count, + usage: texture_usages_v2(usage), + view_formats: key + .view_formats + .iter() + .copied() + .map(texture_format_v2) + .collect(), + }) +} + +fn invalid(message: impl Into, path: impl Into) -> GraphError { + error("GRAPH_RUNTIME_PLAN_INVALID", message, path) +} + +pub fn prepare_runtime_plan_v2( + graph: &CompiledGraphV2, + surface: RuntimeSurfaceContractV2, + limits: Option<&wgpu::Limits>, +) -> Result { + if surface.width == 0 || surface.height == 0 { + return Err(error( + "GRAPH_SURFACE_INCOMPATIBLE", + "surface extent is zero", + "surface", + )); + } + if !surface + .usage + .contains(wgpu::TextureUsages::RENDER_ATTACHMENT) + { + return Err(error( + "GRAPH_SURFACE_INCOMPATIBLE", + "surface lacks render attachment usage", + "surface.usage", + )); + } + + let mut present_count = 0; + let mut query = None; + let mut executions = Vec::with_capacity(graph.executions.len()); + for (i, execution) in graph.executions.iter().enumerate() { + let path = format!("executions[{i}]"); + match execution.executor.key.as_str() { + "mesh_query" => { + let NormalizedParametersV2::MeshQuery { filters } = &execution.parameters else { + return Err(invalid("mesh query parameters mismatch", &path)); + }; + let key = MeshQueryRuntimeKeyV2 { + visible: filters[0].predicate, + frustum_culled: filters[1].predicate, + }; + if query.replace(key).is_some() { + return Err(error( + "GRAPH_EXECUTION_UNSUPPORTED", + "multiple draw stream queries", + &path, + )); + } + } + "legacy_forward" => {} + "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" + | "luminance_edge" => {} + "frustum_cull" => {} + "present" => present_count += 1, + _ => { + return Err(error( + "GRAPH_EXECUTION_UNSUPPORTED", + "unsupported execution", + &path, + )) + } + } + executions.push(RuntimeExecutionV2 { + execution: u32::try_from(i).map_err(|_| invalid("execution index overflow", &path))?, + executor: execution.executor.key.clone(), + }); + } + if present_count != 1 { + return Err(error( + "GRAPH_EXECUTION_UNSUPPORTED", + "exactly one present is required", + "executions", + )); + } + let query = query.ok_or_else(|| { + error( + "GRAPH_EXECUTION_UNSUPPORTED", + "one mesh query is required", + "executions", + ) + })?; + + let mut surface_pair = None; + let mut resource_allocations = vec![None; graph.resources.len()]; + for (fi, family) in graph.texture_families.iter().enumerate() { + if family.id as usize != fi { + return Err(invalid( + "texture family id does not match index", + format!("textureFamilies[{fi}].id"), + )); + } + match &family.source { + TextureFamilySourceV2::ImportedSurface { resource } => { + if family.allocation.is_some() + || surface_pair.replace((family.id, *resource)).is_some() + { + return Err(invalid( + "invalid imported surface allocation", + format!("textureFamilies[{fi}]"), + )); + } + } + TextureFamilySourceV2::AuthoredTexture { + residency, + descriptor, + .. + } => { + if !matches!( + residency, + TextureResidencyV2::Transient | TextureResidencyV2::Persistent + ) || descriptor.dimension != TextureDimensionV2::D2 + || descriptor.mip_level_count != 1 + || descriptor.sample_count != 1 + || !matches!( + descriptor.extent, + NormalizedTextureExtentV2::Absolute { + depth_or_array_layers: 1, + .. + } | NormalizedTextureExtentV2::SurfaceRelative { + depth_or_array_layers: 1, + .. + } + ) + { + return Err(error( + "GRAPH_EXECUTION_UNSUPPORTED", + "unsupported runtime texture descriptor", + format!("textureFamilies[{fi}]"), + )); + } + if family.allocation.is_none() { + return Err(invalid( + "authored family has no allocation", + format!("textureFamilies[{fi}].allocation"), + )); + } + } + } + for (vi, version) in family.versions.iter().enumerate() { + if version.version as usize != vi { + return Err(invalid( + "texture version does not match index", + format!("textureFamilies[{fi}].versions[{vi}]"), + )); + } + let resource = graph + .resources + .get(version.resource as usize) + .ok_or_else(|| { + invalid( + "version resource is out of bounds", + format!("textureFamilies[{fi}].versions[{vi}].resource"), + ) + })?; + let ResourcePlanV2::Texture { + family: rf, + version: rv, + allocation, + .. + } = &resource.plan + else { + return Err(invalid( + "version resource is not a texture", + format!("resources[{}].plan", version.resource), + )); + }; + if *rf != family.id || *rv != version.version || *allocation != family.allocation { + return Err(invalid( + "texture resource and family disagree", + format!("resources[{}].plan", version.resource), + )); + } + resource_allocations[version.resource as usize] = *allocation; + } + } + let (surface_family, surface_resource) = surface_pair + .ok_or_else(|| invalid("missing imported surface family", "textureFamilies"))?; + + // Validate the resource-to-family direction as well; compiled plans are public and may be + // cloned and modified by callers. + for (ri, resource) in graph.resources.iter().enumerate() { + if let ResourcePlanV2::Texture { + family, + version, + allocation, + .. + } = resource.plan + { + let family_plan = graph.texture_families.get(family as usize).ok_or_else(|| { + invalid( + "texture resource family is out of bounds", + format!("resources[{ri}].plan.family"), + ) + })?; + let family_version = family_plan.versions.get(version as usize).ok_or_else(|| { + invalid( + "texture resource version is out of bounds", + format!("resources[{ri}].plan.version"), + ) + })?; + if family_version.resource as usize != ri || allocation != family_plan.allocation { + return Err(invalid( + "texture resource is inconsistent with its family", + format!("resources[{ri}].plan"), + )); + } + } + } + + let mut classes = Vec::with_capacity(graph.allocation_classes.len()); + for (ci, class) in graph.allocation_classes.iter().enumerate() { + let mut slots = Vec::with_capacity(class.slots.len()); + for (si, slot) in class.slots.iter().enumerate() { + let allocation = AllocationRefV2 { + class: ci as u32, + slot: si as u32, + }; + for &family_id in &slot.occupants { + let family = graph + .texture_families + .get(family_id as usize) + .ok_or_else(|| { + invalid( + "slot occupant is out of bounds", + format!("allocationClasses[{ci}].slots[{si}].occupants"), + ) + })?; + if family.allocation != Some(allocation) { + return Err(invalid( + "slot occupant allocation disagrees", + format!("allocationClasses[{ci}].slots[{si}].occupants"), + )); + } + let TextureFamilySourceV2::AuthoredTexture { descriptor, .. } = &family.source + else { + return Err(invalid( + "imported family occupies a slot", + format!("allocationClasses[{ci}].slots[{si}]"), + )); + }; + if descriptor.dimension != class.key.dimension + || descriptor.format != class.key.format + || descriptor.extent != class.key.extent + || descriptor.mip_level_count != class.key.mip_level_count + || descriptor.sample_count != class.key.sample_count + || descriptor.view_formats != class.key.view_formats + { + return Err(invalid( + "occupant descriptor does not match class key", + format!("allocationClasses[{ci}].key"), + )); + } + } + slots.push(RuntimeAllocationSlotV2 { + kind: slot.kind, + descriptor: runtime_texture_descriptor_v2( + &class.key, + &slot.usage, + [surface.width, surface.height], + limits, + )?, + occupants: slot.occupants.clone(), + }); + } + classes.push(RuntimeAllocationClassV2 { + key: class.key.clone(), + slots, + }); + } + for (fi, family) in graph.texture_families.iter().enumerate() { + if let Some(allocation) = family.allocation { + let slot = graph + .allocation_classes + .get(allocation.class as usize) + .and_then(|c| c.slots.get(allocation.slot as usize)) + .ok_or_else(|| { + invalid( + "family allocation is out of bounds", + format!("textureFamilies[{fi}].allocation"), + ) + })?; + if slot.occupants.iter().filter(|&&id| id == family.id).count() != 1 { + return Err(invalid( + "family is not exactly once in allocation occupants", + format!("textureFamilies[{fi}].allocation"), + )); + } + } + } + let imported = graph + .resources + .get(surface_resource as usize) + .ok_or_else(|| invalid("surface resource is out of bounds", "textureFamilies"))?; + if !matches!(imported.plan, ResourcePlanV2::SurfaceTarget { family } if family == surface_family) + { + return Err(invalid( + "surface source resource mismatch", + format!("resources[{surface_resource}]"), + )); + } + // The imported family may only flow through texture versions and the single present. + for (ri, resource) in graph.resources.iter().enumerate() { + if let ResourcePlanV2::Texture { + family, allocation, .. + } = resource.plan + { + if family == surface_family && allocation.is_some() { + return Err(invalid( + "surface texture has an allocation", + format!("resources[{ri}].plan"), + )); + } + } + } + let present = graph + .executions + .iter() + .find(|execution| execution.executor.key == "present") + .ok_or_else(|| invalid("present execution disappeared", "executions"))?; + let ExecutionKindV2::Present { surface: presented } = present.kind else { + return Err(invalid("present execution kind mismatch", "executions")); + }; + let presented_resource = graph.resources.get(presented as usize).ok_or_else(|| { + invalid( + "present resource is out of bounds", + "executions.present.surface", + ) + })?; + if !matches!(presented_resource.plan, ResourcePlanV2::Texture { family, .. } if family == surface_family) + { + return Err(error( + "GRAPH_SURFACE_INCOMPATIBLE", + "present does not resolve to the imported surface", + "executions.present.surface", + )); + } + + Ok(RuntimePlanV2 { + allocations: RuntimeAllocationPlanV2 { + classes, + resource_allocations, + surface_family, + surface_resource, + query, + }, + executions, + surface, + }) +} + +pub fn validate_activatable_v2(graph: &CompiledGraphV2) -> Result<(), GraphError> { + let surface = RuntimeSurfaceContractV2 { + format: wgpu::TextureFormat::Bgra8Unorm, + width: 1, + height: 1, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: Vec::new(), + }; + prepare_runtime_plan_v2(graph, surface, None).map(|_| ()) +} diff --git a/renderer/src/render_graph/schema_v2.rs b/renderer/src/render_graph/schema_v2.rs new file mode 100644 index 0000000..101507e --- /dev/null +++ b/renderer/src/render_graph/schema_v2.rs @@ -0,0 +1,153 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GraphV2 { + pub schema_version: u32, + pub graph_id: String, + pub revision: u32, + pub nodes: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NodeV2 { + pub id: String, + pub state: NodeStateV2, + pub executor: ExecutorRefV2, + pub parameters: serde_json::Value, + pub inputs: BTreeMap, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NodeStateV2 { + Enabled, + Muted, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ExecutorRefV2 { + pub key: String, + pub version: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub struct NodeOutputRef { + pub node: String, + pub socket: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum TextureDimensionV2 { + D1, + D2, + D3, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum TextureFormatV2 { + Rgba8Unorm, + Rgba8UnormSrgb, + Bgra8Unorm, + Bgra8UnormSrgb, + Rgba16Float, + R32Float, + Depth32Float, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum TextureExtentV2 { + Absolute { + width: u32, + height: u32, + #[serde(rename = "depthOrArrayLayers")] + depth_or_array_layers: u32, + }, + SurfaceRelative { + width: RatioV2, + height: RatioV2, + #[serde(rename = "depthOrArrayLayers")] + depth_or_array_layers: u32, + }, +} +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub struct RatioV2 { + pub numerator: u32, + pub denominator: u32, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum TextureResidencyV2 { + Transient, + Persistent, + History, + Readback, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TextureDescriptorV2 { + pub dimension: TextureDimensionV2, + pub format: TextureFormatV2, + pub extent: TextureExtentV2, + pub mip_level_count: u32, + pub sample_count: u32, + #[serde(default)] + pub view_formats: Vec, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum TriStatePredicate { + Any, + RequiredTrue, + RequiredFalse, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub struct MeshFilterV2 { + pub flag: MeshFlagV2, + pub predicate: TriStatePredicate, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub enum MeshFlagV2 { + IsVisible, + IsFrustumCulled, +} + +impl MeshFlagV2 { + pub const ORDERED: [Self; 2] = [Self::IsVisible, Self::IsFrustumCulled]; + + pub const fn input_socket(self) -> &'static str { + match self { + Self::IsVisible => "isVisible", + Self::IsFrustumCulled => "isFrustumCulled", + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CompareFunctionV2 { + Never, + Less, + Equal, + LessEqual, + Greater, + NotEqual, + GreaterEqual, + Always, +} diff --git a/renderer/src/render_graph/tests.rs b/renderer/src/render_graph/tests.rs index 13da4ca..3e320b8 100644 --- a/renderer/src/render_graph/tests.rs +++ b/renderer/src/render_graph/tests.rs @@ -213,12 +213,13 @@ fn registry_capacity() { ); } #[test] -fn registry_revision_replaces_in_place() { - let mut r = Registry::new(1); +fn registry_revision_creates_immutable_handle() { + let mut r = Registry::new(2); let (a, _) = r.compile(&empty("g", 1)).unwrap(); let (b, _) = r.compile(&empty("g", 2)).unwrap(); - assert_eq!(a, b); - assert_eq!(r.get(a).unwrap().revision, 2); + assert_ne!(a, b); + assert_eq!(r.get(a).unwrap().revision, 1); + assert_eq!(r.get(b).unwrap().revision, 2); } #[test] fn registry_revision_conflict() { diff --git a/renderer/src/render_graph/tests_v2.rs b/renderer/src/render_graph/tests_v2.rs new file mode 100644 index 0000000..c1e834f --- /dev/null +++ b/renderer/src/render_graph/tests_v2.rs @@ -0,0 +1,1676 @@ +use std::collections::BTreeSet; + +use super::*; +use serde_json::{json, Value}; + +fn input(node: &str, socket: &str) -> Value { + json!({"node":node,"socket":socket}) +} +fn node(id: &str, key: &str, parameters: Value, inputs: Value) -> Value { + json!({"id":id,"state":"enabled","executor":{"key":key,"version":1},"parameters":parameters,"inputs":inputs}) +} +fn texture(format: &str, residency: &str) -> Value { + json!({"texture":{"dimension":"d2","format":format,"extent":{"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":1},"mipLevelCount":1,"sampleCount":1,"viewFormats":[]},"residency":residency}) +} +fn full_cull_graph() -> Value { + json!({"schemaVersion":2,"graphId":"full","revision":1,"nodes":[ + node("surface","surface_target",json!({}),json!({})), + node("depth","texture_spec",texture("depth32_float","transient"),json!({})), + node("scene","scene_table",json!({}),json!({})), + node("aabbs","local_aabb_buffer",json!({}),json!({"scene":input("scene","scene")})), + node("frustum","camera_frustum",json!({}),json!({})), + node("visible","visibility_flags",json!({}),json!({"scene":input("scene","scene")})), + node("cull","frustum_cull",json!({}),json!({"scene":input("scene","scene"),"localAabbs":input("aabbs","localAabbs"),"frustum":input("frustum","frustum")})), + node("query","mesh_query",json!({"filters":[{"flag":"isFrustumCulled","predicate":"required_false"},{"flag":"isVisible","predicate":"required_true"}]}),json!({"scene":input("scene","scene"),"isVisible":input("visible","flags"),"isFrustumCulled":input("cull","flags")})), + node("depth_config","depth_stencil_config",json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0}),json!({})), + node("forward","legacy_forward",json!({"clearColor":[0,0,0,1]}),json!({"scene":input("scene","scene"),"draws":input("query","draws"),"colorTarget":input("surface","surface"),"depthTarget":input("depth","spec"),"depthStencil":input("depth_config","config")})), + node("present","present",json!({}),json!({"surface":input("forward","color")})) + ]}) +} +fn forward(id: &str, color: Value, depth: Value) -> Value { + node( + id, + "legacy_forward", + json!({"clearColor":[0,0,0,1]}), + json!({ + "scene":input("scene","scene"), + "draws":input("query","draws"), + "colorTarget":color, + "depthTarget":depth, + "depthStencil":input("depth_config","config") + }), + ) +} +fn render_support_nodes() -> Vec { + vec![ + node("scene", "scene_table", json!({}), json!({})), + node( + "visible", + "visibility_flags", + json!({}), + json!({"scene":input("scene","scene")}), + ), + node( + "query", + "mesh_query", + json!({"filters":[ + {"flag":"isVisible","predicate":"required_true"}, + {"flag":"isFrustumCulled","predicate":"any"} + ]}), + json!({"scene":input("scene","scene"),"isVisible":input("visible","flags")}), + ), + node( + "depth_config", + "depth_stencil_config", + json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0}), + json!({}), + ), + ] +} +fn graph(nodes: Vec) -> Value { + json!({"schemaVersion":2,"graphId":"hazards","revision":1,"nodes":nodes}) +} +fn hdr_copy_graph() -> Value { + let mut nodes = vec![ + node("surface", "surface_target", json!({}), json!({})), + node( + "hdr", + "texture_spec", + texture("rgba16_float", "transient"), + json!({}), + ), + depth_spec("depth", "transient"), + ]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward("forward", input("hdr", "spec"), input("depth", "spec")), + node( + "copy", + "fullscreen_copy", + json!({}), + json!({"source":input("forward","color"),"colorTarget":input("surface","surface")}), + ), + node( + "present", + "present", + json!({}), + json!({"surface":input("copy","color")}), + ), + ]); + graph(nodes) +} +fn cyclic_forwards() -> Vec { + vec![ + forward("A", input("B", "color"), input("B", "depth")), + forward("B", input("A", "color"), input("A", "depth")), + ] +} +fn compile(v: Value) -> CompiledGraphV2 { + compile_v2(serde_json::from_value(v).unwrap()).unwrap() +} + +#[test] +fn fullscreen_copy_hdr_graph_lowers_versions_accesses_and_usage() { + let p = compile(hdr_copy_graph()); + assert_eq!( + p.executions + .iter() + .map(|execution| execution.id.as_str()) + .collect::>(), + ["query", "forward", "copy", "present"] + ); + for (node, socket) in [ + ("forward", "color"), + ("forward", "depth"), + ("copy", "color"), + ] { + assert!(matches!( + resource_by_origin(&p, node, socket).plan, + ResourcePlanV2::Texture { version: 0, .. } + )); + } + let copy = execution(&p, "copy"); + let source = resource_by_origin(&p, "forward", "color"); + let color = resource_by_origin(&p, "copy", "color"); + assert!(copy.accesses.iter().any(|access| access.resource + == p.resources + .iter() + .position(|resource| std::ptr::eq(resource, source)) + .unwrap() as u32 + && access.mode == AccessModeV2::SampledTexture)); + let color_id = p + .resources + .iter() + .position(|resource| std::ptr::eq(resource, color)) + .unwrap() as u32; + assert!(matches!( + ©.kind, + ExecutionKindV2::Render { color_attachments, depth_stencil: None } + if color_attachments[0].resource == color_id + && color_attachments[0].load == NormalizedColorLoadV2::Clear { value: [0.0; 4] } + )); + assert!(copy.accesses.iter().any(|access| matches!( + access.mode, + AccessModeV2::ColorAttachment { + full_overwrite: true, + .. + } + ) && access.resource == color_id)); + let hdr = family_by_source(&p, "hdr"); + assert_eq!( + hdr.usage.iter().copied().collect::>(), + [TextureUsageV2::Sampled, TextureUsageV2::ColorAttachment] + .into_iter() + .collect() + ); + let surface = p + .texture_families + .iter() + .find(|family| matches!(family.source, TextureFamilySourceV2::ImportedSurface { .. })) + .unwrap(); + assert_eq!(surface.versions[0].resource, color_id); +} + +#[test] +fn fullscreen_copy_parameters_are_exactly_empty() { + let mut g = hdr_copy_graph(); + g["nodes"][8]["parameters"] = json!({"obsolete":true}); + assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); + assert_eq!(CONTRACTS_V2.len(), 17); +} + +#[test] +fn fullscreen_copy_rejects_same_source_and_target_family() { + let mut g = hdr_copy_graph(); + g["nodes"][8]["inputs"]["colorTarget"] = input("forward", "color"); + let error = compile_error(g); + assert_eq!(error.code, "GRAPH_SAME_PASS_HAZARD"); + assert_eq!(error.details["path"], "nodes[8].inputs"); +} + +#[test] +fn duplicate_texture_writer_reports_second_color_target() { + let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; + nodes.extend(render_support_nodes()); + nodes.extend([ + node( + "depth_a", + "texture_spec", + texture("depth32_float", "transient"), + json!({}), + ), + node( + "depth_b", + "texture_spec", + texture("depth32_float", "transient"), + json!({}), + ), + forward("F0", input("surface", "surface"), input("depth_a", "spec")), + node( + "P0", + "present", + json!({}), + json!({"surface":input("F0","color")}), + ), + forward("F1", input("surface", "surface"), input("depth_b", "spec")), + node( + "P1", + "present", + json!({}), + json!({"surface":input("F1","color")}), + ), + ]); + let error = compile_error(graph(nodes)); + assert_eq!(error.code, "GRAPH_DUPLICATE_WRITER"); + assert_eq!(error.details["path"], "nodes[9].inputs.colorTarget"); +} + +#[test] +fn same_output_bound_to_both_attachments_is_a_same_pass_hazard() { + let mut nodes = vec![node( + "target", + "texture_spec", + texture("rgba8_unorm", "transient"), + json!({}), + )]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward("F", input("target", "spec"), input("target", "spec")), + node( + "P", + "present", + json!({}), + json!({"surface":input("F","color")}), + ), + ]); + let error = compile_error(graph(nodes)); + assert_eq!(error.code, "GRAPH_SAME_PASS_HAZARD"); + assert_eq!(error.details["path"], "nodes[5].inputs"); +} + +#[test] +fn unordered_old_texture_version_read_is_rejected_before_scheduling() { + let mut nodes = vec![ + node("surface", "surface_target", json!({}), json!({})), + node( + "depth_0", + "texture_spec", + texture("depth32_float", "transient"), + json!({}), + ), + node( + "depth_1", + "texture_spec", + texture("depth32_float", "transient"), + json!({}), + ), + ]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward("F0", input("surface", "surface"), input("depth_0", "spec")), + forward("F1", input("F0", "color"), input("depth_1", "spec")), + node( + "P0", + "present", + json!({}), + json!({"surface":input("F0","color")}), + ), + node( + "P1", + "present", + json!({}), + json!({"surface":input("F1","color")}), + ), + ]); + let error = compile_error(graph(nodes)); + assert_eq!(error.code, "GRAPH_RESOURCE_VERSION_INVALID"); + assert_eq!(error.details["path"], "nodes[9].inputs.surface"); +} + +#[test] +fn duplicate_successors_defer_old_version_reachability() { + let mut nodes = vec![ + node("surface", "surface_target", json!({}), json!({})), + depth_spec("depth_0", "transient"), + depth_spec("depth_1", "transient"), + depth_spec("depth_2", "transient"), + ]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward("F0", input("surface", "surface"), input("depth_0", "spec")), + forward("F1", input("F0", "color"), input("depth_1", "spec")), + forward("F2", input("F0", "color"), input("depth_2", "spec")), + node( + "P0", + "present", + json!({}), + json!({"surface":input("F0","color")}), + ), + node( + "P1", + "present", + json!({}), + json!({"surface":input("F1","color")}), + ), + node( + "P2", + "present", + json!({}), + json!({"surface":input("F2","color")}), + ), + ]); + let error = compile_error(graph(nodes)); + assert_eq!(error.code, "GRAPH_DUPLICATE_WRITER"); + assert_eq!(error.details["path"], "nodes[10].inputs.colorTarget"); +} + +#[test] +fn live_texture_cycle_reports_the_exact_first_cycle() { + let mut nodes = render_support_nodes(); + nodes.extend(cyclic_forwards()); + nodes.push(node( + "present", + "present", + json!({}), + json!({"surface":input("A","color")}), + )); + let error = compile_error(graph(nodes)); + assert_eq!(error.code, "GRAPH_CYCLE"); + assert_eq!( + error.details, + json!({ + "message":"live graph contains a cycle", + "kind":"cycle", + "edges":[ + {"fromNode":"A","fromSocket":"color","toNode":"B","toSocket":"colorTarget","resource":{"node":"A","socket":"color"}}, + {"fromNode":"B","fromSocket":"color","toNode":"A","toSocket":"colorTarget","resource":{"node":"B","socket":"color"}} + ] + }) + ); +} + +#[test] +fn dead_texture_cycle_is_culled_without_cycle_execution() { + let mut value = full_cull_graph(); + value["nodes"] + .as_array_mut() + .unwrap() + .extend(cyclic_forwards()); + let plan = compile(value); + assert_eq!(plan.node_count, 13); + assert_eq!(plan.culled_node_count, 2); + assert_eq!(plan.culled_resource_count, 4); + assert!(!plan + .executions + .iter() + .any(|execution| matches!(execution.id.as_str(), "A" | "B"))); +} +fn compile_error(v: Value) -> GraphError { + compile_v2(serde_json::from_value(v).unwrap()).unwrap_err() +} +fn execution<'a>(p: &'a CompiledGraphV2, authored_id: &str) -> &'a CompiledExecutionV2 { + p.executions.iter().find(|e| e.id == authored_id).unwrap() +} +fn resource_by_origin<'a>( + p: &'a CompiledGraphV2, + node: &str, + socket: &str, +) -> &'a CompiledResourceV2 { + p.resources + .iter() + .find(|r| r.origin.node == node && r.origin.socket == socket) + .unwrap() +} + +fn family_by_source<'a>(p: &'a CompiledGraphV2, node: &str) -> &'a TextureFamilyV2 { + let source = resource_by_origin(p, node, "spec"); + let family = match source.plan { + ResourcePlanV2::TextureSpec { family, .. } => family, + _ => panic!("{node} is not a texture specification"), + }; + &p.texture_families[family as usize] +} + +fn allocation_slot<'a>( + p: &'a CompiledGraphV2, + allocation: AllocationRefV2, +) -> &'a AllocationSlotV2 { + &p.allocation_classes[allocation.class as usize].slots[allocation.slot as usize] +} + +fn independent_depth_graph( + depth_specs: Vec, + forwards: Vec, + present_from: &str, +) -> Value { + let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; + nodes.extend(render_support_nodes()); + nodes.extend(depth_specs); + nodes.extend(forwards); + nodes.push(node( + "present", + "present", + json!({}), + json!({"surface":input(present_from,"color")}), + )); + graph(nodes) +} + +fn depth_spec(id: &str, residency: &str) -> Value { + node( + id, + "texture_spec", + texture("depth32_float", residency), + json!({}), + ) +} + +#[test] +fn dense_lifetimes_exclude_authored_source_ordinals() { + let p = compile(full_cull_graph()); + assert_eq!( + p.executions + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + ["cull", "query", "forward", "present"] + ); + for (node, socket, first, last) in [ + ("scene", "scene", 0, 2), + ("aabbs", "localAabbs", 0, 0), + ("frustum", "frustum", 0, 0), + ("visible", "flags", 1, 1), + ("depth_config", "config", 2, 2), + ] { + assert_eq!( + resource_by_origin(&p, node, socket).lifetime, + Some(LifetimeV2 { + first_use: first, + last_use: last + }), + "lifetime for {node}.{socket}" + ); + } + let color = resource_by_origin(&p, "forward", "color"); + let depth = resource_by_origin(&p, "forward", "depth"); + assert_eq!(color.producer_execution, Some(2)); + assert_eq!(depth.producer_execution, Some(2)); + assert_eq!( + color.lifetime, + Some(LifetimeV2 { + first_use: 2, + last_use: 3 + }) + ); + assert_eq!( + depth.lifetime, + Some(LifetimeV2 { + first_use: 2, + last_use: 2 + }) + ); + let depth_family = family_by_source(&p, "depth"); + assert_eq!( + depth_family.lifetime, + LifetimeV2 { + first_use: 2, + last_use: 2 + } + ); + assert_eq!(depth_family.versions[0].lifetime, depth_family.lifetime); +} + +#[test] +fn transient_aliasing_is_declaration_order_independent() { + let p = compile(independent_depth_graph( + vec![ + depth_spec("depth_second", "transient"), + depth_spec("depth_first", "transient"), + ], + vec![ + forward( + "F0", + input("surface", "surface"), + input("depth_first", "spec"), + ), + forward("F1", input("F0", "color"), input("depth_second", "spec")), + ], + "F1", + )); + assert_eq!(execution(&p, "F1").original_node_index, 8); + let f0_ordinal = p.executions.iter().position(|e| e.id == "F0").unwrap() as u32; + let f1_ordinal = p.executions.iter().position(|e| e.id == "F1").unwrap() as u32; + assert_eq!(f1_ordinal, f0_ordinal + 1); + let first = family_by_source(&p, "depth_first"); + let second = family_by_source(&p, "depth_second"); + assert_eq!( + first.lifetime, + LifetimeV2 { + first_use: f0_ordinal, + last_use: f0_ordinal + } + ); + assert_eq!( + second.lifetime, + LifetimeV2 { + first_use: f1_ordinal, + last_use: f1_ordinal + } + ); + assert_eq!(first.versions[0].lifetime, first.lifetime); + assert_eq!(second.versions[0].lifetime, second.lifetime); + assert_eq!(first.allocation, second.allocation); + let slot = allocation_slot(&p, first.allocation.unwrap()); + assert_eq!(slot.kind, AllocationKindV2::AliasedTransient); + assert_eq!(slot.usage, [TextureUsageV2::DepthAttachment]); + assert_eq!( + slot.occupants.iter().copied().collect::>(), + [first.id, second.id].into_iter().collect() + ); +} + +#[test] +fn overlapping_family_lifetimes_prevent_transient_reuse() { + let p = compile(independent_depth_graph( + vec![ + depth_spec("depth_a", "transient"), + depth_spec("depth_b", "transient"), + ], + vec![ + forward("F0", input("surface", "surface"), input("depth_a", "spec")), + forward("F1", input("F0", "color"), input("depth_b", "spec")), + forward("F2", input("F1", "color"), input("F0", "depth")), + ], + "F2", + )); + let a = family_by_source(&p, "depth_a"); + let b = family_by_source(&p, "depth_b"); + assert!(a.lifetime.first_use < b.lifetime.first_use); + assert!(a.lifetime.last_use > b.lifetime.last_use); + assert_ne!(a.allocation, b.allocation); + assert_ne!(a.allocation.unwrap().slot, b.allocation.unwrap().slot); +} + +#[test] +fn persistent_textures_are_dedicated_and_follow_transient_slots() { + let p = compile(independent_depth_graph( + vec![ + depth_spec("persistent_b", "persistent"), + depth_spec("transient", "transient"), + depth_spec("persistent_a", "persistent"), + ], + vec![ + forward( + "F0", + input("surface", "surface"), + input("persistent_a", "spec"), + ), + forward("F1", input("F0", "color"), input("transient", "spec")), + forward("F2", input("F1", "color"), input("persistent_b", "spec")), + ], + "F2", + )); + let a = family_by_source(&p, "persistent_a"); + let b = family_by_source(&p, "persistent_b"); + assert_ne!(a.allocation, b.allocation); + for family in [a, b] { + let allocation = family.allocation.unwrap(); + assert_eq!( + allocation_slot(&p, allocation).kind, + AllocationKindV2::Persistent + ); + assert_eq!(family.usage, [TextureUsageV2::DepthAttachment]); + for version in &family.versions { + let ResourcePlanV2::Texture { + allocation: resource_allocation, + .. + } = p.resources[version.resource as usize].plan + else { + panic!() + }; + assert_eq!(resource_allocation, Some(allocation)); + } + } + let transient = family_by_source(&p, "transient").allocation.unwrap(); + assert!(transient.slot < a.allocation.unwrap().slot); + assert!(transient.slot < b.allocation.unwrap().slot); + assert_eq!(p.transient_slot_count, 1); +} + +#[test] +fn exact_texture_compatibility_separates_allocation_classes() { + let mut different = texture("depth32_float", "transient"); + different["texture"]["mipLevelCount"] = json!(2); + let p = compile(independent_depth_graph( + vec![ + depth_spec("relative", "transient"), + node("absolute", "texture_spec", different, json!({})), + ], + vec![ + forward("F0", input("surface", "surface"), input("relative", "spec")), + forward("F1", input("F0", "color"), input("absolute", "spec")), + ], + "F1", + )); + let relative = family_by_source(&p, "relative").allocation.unwrap(); + let absolute = family_by_source(&p, "absolute").allocation.unwrap(); + assert_ne!(relative.class, absolute.class); + assert_ne!(relative, absolute); +} + +#[test] +fn dispatch_and_registry_are_version_isolated() { + let bytes = + serde_json::to_vec(&json!({"schemaVersion":2,"graphId":"empty","revision":1,"nodes":[]})) + .unwrap(); + assert_eq!( + parse_and_compile(&bytes).unwrap_err().code, + "GRAPH_SCHEMA_UNSUPPORTED" + ); + assert!(matches!( + parse_and_compile_any(&bytes).unwrap(), + RegisteredGraph::V2(_) + )); + let mut r = Registry::default(); + let (id, _) = r.compile(&bytes).unwrap(); + assert!(matches!( + r.get_registered(id).unwrap(), + RegisteredGraph::V2(_) + )); + assert_eq!( + r.get(id).unwrap_err().message, + "schemaVersion 2 activation is unavailable until Phase 4" + ); +} + +#[test] +fn authoritative_eleven_node_graph_lowers_exactly() { + let p = compile(full_cull_graph()); + assert_eq!(p.node_count, 11); + assert_eq!(p.resources.len(), 11); + assert_eq!( + p.executions + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + ["cull", "query", "forward", "present"] + ); + for resource in &p.resources { + if let ResourcePlanV2::Texture { version, .. } = resource.plan { + assert_eq!(version, 0, "every first produced texture is symbolic v0"); + } + } + assert!(p.executions.iter().all(|e| !matches!( + e.executor.key.as_str(), + "surface_target" | "texture_spec" | "scene_table" + ))); +} + +#[test] +fn exact_wire_catalog_rejections() { + let cases = [ + ("local_aabb", 0, "GRAPH_UNKNOWN_EXECUTOR"), + ("frustum", 4, "GRAPH_UNKNOWN_EXECUTOR"), + ("cull", 6, "GRAPH_UNKNOWN_EXECUTOR"), + ]; + for (key, i, code) in cases { + let mut g = full_cull_graph(); + g["nodes"][i]["executor"]["key"] = json!(key); + assert_eq!(compile_error(g).code, code); + } + for field in ["clearDepth", "clearColor"] { + let mut g = full_cull_graph(); + let i = if field == "clearDepth" { 8 } else { 9 }; + g["nodes"][i]["parameters"] + .as_object_mut() + .unwrap() + .remove(field); + assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); + } + let mut g = full_cull_graph(); + g["nodes"][0]["executor"]["version"] = json!(2); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_EXECUTOR_VERSION_UNSUPPORTED"); + assert_eq!(e.details["path"], "nodes[0].executor.version"); +} + +#[test] +fn mesh_filters_are_closed_and_any_removes_dependency() { + let p = compile(full_cull_graph()); + let NormalizedParametersV2::MeshQuery { filters } = execution(&p, "query").parameters.clone() + else { + panic!() + }; + assert_eq!( + filters.map(|f| f.flag), + [MeshFlagV2::IsVisible, MeshFlagV2::IsFrustumCulled] + ); + for filters in [ + json!([{"flag":"isVisible","predicate":"any"}]), + json!([{"flag":"isVisible","predicate":"any"},{"flag":"isVisible","predicate":"required_true"}]), + json!([{"flag":"bogus","predicate":"any"},{"flag":"isVisible","predicate":"any"}]), + ] { + let mut g = full_cull_graph(); + g["nodes"][7]["parameters"]["filters"] = filters; + assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); + } + let mut g = full_cull_graph(); + // The authored order is [culled, visible], while normalization is catalog order. + g["nodes"][7]["parameters"]["filters"][0]["predicate"] = json!("any"); + let p = compile(g); + let q = execution(&p, "query"); + assert!(!q.inputs.iter().any(|x| x.socket == "isFrustumCulled")); + assert!(!p.executions.iter().any(|e| e.id == "cull")); + assert!(!p + .resources + .iter() + .any(|r| r.origin.node == "cull" && r.origin.socket == "flags")); +} + +#[test] +fn provenance_and_lowering_are_consistent() { + let p = compile(full_cull_graph()); + let scene = resource_by_origin(&p, "scene", "scene"); + for id in ["aabbs", "visible", "cull", "query"] { + let r = p.resources.iter().find(|r| r.origin.node == id).unwrap(); + match r.plan { + ResourcePlanV2::LocalAabbBuffer { scene: s } + | ResourcePlanV2::BooleanFlagBuffer { scene: s, .. } + | ResourcePlanV2::DrawStream { scene: s } => { + assert_eq!( + s, + p.resources + .iter() + .position(|r| std::ptr::eq(r, scene)) + .unwrap() as u32 + ) + } + _ => {} + } + } + let f = execution(&p, "forward"); + let color_in = f + .inputs + .iter() + .find(|x| x.socket == "colorTarget") + .unwrap() + .resource; + let color_out = f + .outputs + .iter() + .find(|x| x.socket == "color") + .unwrap() + .resource; + assert_ne!(color_in, color_out); + assert!( + f.accesses + .iter() + .any(|a| a.resource == color_out + && matches!(a.mode, AccessModeV2::ColorAttachment { .. })) + ); + assert!(!f + .accesses + .iter() + .any(|a| a.resource == color_in && matches!(a.mode, AccessModeV2::ColorAttachment { .. }))); + for (socket, expected) in [ + ("scene", AccessModeV2::SemanticRead), + ("draws", AccessModeV2::IndirectRead), + ] { + let access = f.accesses.iter().find(|a| a.socket == socket).unwrap(); + assert_eq!(access.mode, expected, "legacy_forward {socket} access"); + } +} + +#[test] +fn descriptor_validation_and_normalization_table() { + for (field, value) in [("sampleCount", json!(3))] { + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["texture"][field] = value; + assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); + } + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["texture"]["extent"] = + json!({"kind":"absolute","width":1,"height":1,"depthOrArrayLayers":1}); + g["nodes"][1]["parameters"]["texture"]["mipLevelCount"] = json!(2); + assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); + + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["texture"]["mipLevelCount"] = json!(99); + assert_eq!( + resource_by_origin(&compile(g), "depth", "spec").semantic_type, + SemanticTypeV2::TextureSpec + ); + for residency in ["history", "readback"] { + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["residency"] = json!(residency); + assert_eq!(compile_error(g).code, "GRAPH_UNSUPPORTED_FEATURE"); + } + let p = compile(full_cull_graph()); + let surface = p + .texture_families + .iter() + .find(|f| matches!(f.source, TextureFamilySourceV2::ImportedSurface { .. })) + .unwrap(); + assert!(surface.allocation.is_none()); +} + +#[test] +fn validation_precedence_and_identifier_limits() { + let mut g = full_cull_graph(); + g["nodes"][0]["id"] = json!("x".repeat(65)); + g["nodes"][1]["executor"]["key"] = json!("bad"); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_LIMIT_EXCEEDED"); + assert_eq!(e.details["path"], "nodes[0].id"); + let mut g = full_cull_graph(); + g["nodes"][0]["id"] = json!("bad id"); + assert_eq!(compile_error(g).code, "GRAPH_INVALID_ID"); + let mut g = full_cull_graph(); + g["nodes"][1]["executor"]["key"] = json!("bad"); + g["nodes"][0]["parameters"] = json!({"bad":1}); + assert_eq!(compile_error(g).details["path"], "nodes[1].executor.key"); +} + +#[test] +fn global_identifier_lengths_precede_grammar_and_duplicates() { + let mut invalid_grammar = full_cull_graph(); + invalid_grammar["nodes"][0]["id"] = json!("bad id"); + invalid_grammar["nodes"][10]["executor"]["key"] = json!("x".repeat(65)); + let error = compile_error(invalid_grammar); + assert_eq!(error.code, "GRAPH_LIMIT_EXCEEDED"); + assert_eq!(error.details["path"], "nodes[10].executor.key"); + + let mut duplicate = full_cull_graph(); + duplicate["nodes"][1]["id"] = duplicate["nodes"][0]["id"].clone(); + duplicate["nodes"][10]["executor"]["key"] = json!("x".repeat(65)); + let error = compile_error(duplicate); + assert_eq!(error.code, "GRAPH_LIMIT_EXCEEDED"); + assert_eq!(error.details["path"], "nodes[10].executor.key"); +} + +#[test] +fn strict_mesh_diagnostics_and_inactive_any_edges() { + let cases = [ + ( + json!([{"flag":"bogus","predicate":"any"},{"flag":"isVisible","predicate":"any"}]), + "nodes[7].parameters.filters[0].flag", + ), + ( + json!([{"flag":"isFrustumCulled","predicate":"nope"},{"flag":"isVisible","predicate":"any"}]), + "nodes[7].parameters.filters[0].predicate", + ), + ( + json!([{"flag":"isVisible","predicate":"any"},{"flag":"isVisible","predicate":"required_true"}]), + "nodes[7].parameters.filters[1].flag", + ), + ( + json!([{"flag":"isVisible","predicate":"any"}]), + "nodes[7].parameters.filters", + ), + ]; + for (filters, path) in cases { + let mut g = full_cull_graph(); + g["nodes"][7]["parameters"]["filters"] = filters; + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_PARAMETERS_INVALID"); + assert_eq!(e.details["path"], path); + } + for predicate in ["required_true", "required_false"] { + let mut g = full_cull_graph(); + g["nodes"][7]["parameters"]["filters"][0]["predicate"] = json!(predicate); + g["nodes"][7]["inputs"] + .as_object_mut() + .unwrap() + .remove("isFrustumCulled"); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_SOCKET_CARDINALITY"); + assert_eq!(e.details["path"], "nodes[7].inputs.isFrustumCulled"); + } + let mut g = full_cull_graph(); + g["nodes"][7]["inputs"]["isVisible"] = input("cull", "flags"); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); + assert_eq!(e.details["path"], "nodes[7].inputs.isVisible"); + + let mut g = full_cull_graph(); + g["nodes"][7]["parameters"]["filters"][0]["predicate"] = json!("any"); + let p = compile(g); + let q = execution(&p, "query"); + assert!(!q.inputs.iter().any(|i| i.socket == "isFrustumCulled")); + assert!(!q.accesses.iter().any(|a| a.socket == "isFrustumCulled")); + assert!(!p.executions.iter().any(|e| e.id == "cull")); +} + +#[test] +fn transitive_scene_roots_are_vector_resource_ids() { + let p = compile(full_cull_graph()); + let scene_id = p + .resources + .iter() + .position(|r| r.origin.node == "scene") + .unwrap() as u32; + for origin in ["aabbs", "visible", "cull", "query"] { + let resource = p + .resources + .iter() + .find(|r| r.origin.node == origin) + .unwrap(); + let rooted = match resource.plan { + ResourcePlanV2::LocalAabbBuffer { scene } + | ResourcePlanV2::BooleanFlagBuffer { scene, .. } + | ResourcePlanV2::DrawStream { scene } => Some(scene), + _ => None, + }; + assert_eq!(rooted, Some(scene_id)); + } + let mut g = full_cull_graph(); + g["nodes"] + .as_array_mut() + .unwrap() + .push(node("sceneB", "scene_table", json!({}), json!({}))); + g["nodes"][3]["inputs"]["scene"] = input("sceneB", "scene"); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); + assert_eq!(e.details["path"], "nodes[6].inputs.localAabbs"); + let mut g = full_cull_graph(); + g["nodes"] + .as_array_mut() + .unwrap() + .push(node("sceneB", "scene_table", json!({}), json!({}))); + g["nodes"][3]["inputs"]["scene"] = input("sceneB", "scene"); + g["nodes"][5]["inputs"]["scene"] = input("sceneB", "scene"); + g["nodes"][6]["inputs"]["scene"] = input("sceneB", "scene"); + g["nodes"][7]["inputs"]["scene"] = input("sceneB", "scene"); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); + assert_eq!(e.details["path"], "nodes[9].inputs.draws"); +} + +#[test] +fn descriptor_exact_paths_and_normalization() { + let cases = [ + ("sampleCount", json!(2), "sampleCount"), + ("sampleCount", json!(8), "sampleCount"), + ("mipLevelCount", json!(0), "mipLevelCount"), + ]; + for (field, value, suffix) in cases { + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["texture"][field] = value; + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_PARAMETERS_INVALID"); + assert_eq!( + e.details["path"], + format!("nodes[1].parameters.texture.{suffix}") + ); + } + for (view, index) in [("depth32_float", 0), ("rgba8_unorm", 0)] { + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["texture"]["viewFormats"] = json!([view]); + let e = compile_error(g); + assert_eq!( + e.details["path"], + format!("nodes[1].parameters.texture.viewFormats[{index}]") + ); + } + for residency in ["history", "readback"] { + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["residency"] = json!(residency); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_UNSUPPORTED_FEATURE"); + assert_eq!(e.details["path"], "nodes[1].parameters.residency"); + } + let mut g = full_cull_graph(); + let d = &mut g["nodes"][1]["parameters"]["texture"]; + d["extent"]["width"] = json!({"numerator":2,"denominator":2}); + let p = compile(g); + let TextureFamilySourceV2::AuthoredTexture { descriptor, .. } = + &family_by_source(&p, "depth").source + else { + panic!() + }; + assert!( + matches!(&descriptor.extent, NormalizedTextureExtentV2::SurfaceRelative { width, .. } if *width == RatioV2 { numerator: 1, denominator: 1 }) + ); +} + +#[test] +fn descriptor_multi_error_precedence_is_exact() { + let cases = [ + (json!(3), json!(0), 9000, "sampleCount"), + (json!(1), json!(0), 9000, "mipLevelCount"), + (json!(1), json!(30), 9000, "mipLevelCount"), + (json!(1), json!(14), 9000, "extent"), + (json!(1), json!(14), 8192, "viewFormats[0]"), + ]; + for (sample_count, mip_count, width, expected) in cases { + let mut g = full_cull_graph(); + let d = &mut g["nodes"][1]["parameters"]["texture"]; + d["format"] = json!("rgba8_unorm"); + d["extent"] = json!({ + "kind":"absolute", + "width":width, + "height":1, + "depthOrArrayLayers":1 + }); + d["sampleCount"] = sample_count; + d["mipLevelCount"] = mip_count; + d["viewFormats"] = json!(["rgba8_unorm"]); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_PARAMETERS_INVALID"); + assert_eq!( + e.details["path"], + format!("nodes[1].parameters.texture.{expected}") + ); + } +} + +#[test] +fn global_raw_limits_have_stable_narrow_paths() { + for (mut g, path) in [ + ( + { + let mut g = full_cull_graph(); + g["graphId"] = json!("x".repeat(65)); + g + }, + "graphId", + ), + ( + { + let mut g = full_cull_graph(); + g["nodes"][0]["executor"]["key"] = json!("x".repeat(65)); + g + }, + "nodes[0].executor.key", + ), + ( + { + let mut g = full_cull_graph(); + g["nodes"][9]["inputs"]["x".repeat(65)] = input("scene", "scene"); + g + }, + "nodes[9].inputs.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + ), + ( + { + let mut g = full_cull_graph(); + g["nodes"][9]["inputs"]["scene"]["node"] = json!("x".repeat(65)); + g + }, + "nodes[9].inputs.scene.node", + ), + ( + { + let mut g = full_cull_graph(); + g["nodes"][9]["inputs"]["scene"]["socket"] = json!("x".repeat(65)); + g + }, + "nodes[9].inputs.scene.socket", + ), + ] { + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_LIMIT_EXCEEDED"); + assert_eq!(e.details["path"], path); + } + let mut inputs = serde_json::Map::new(); + for i in 0..8193 { + inputs.insert(format!("s{i}"), input("n", "x")); + } + let g = graph(vec![node( + "n", + "surface_target", + json!({}), + Value::Object(inputs), + )]); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_LIMIT_EXCEEDED"); + assert_eq!(e.details["path"], "nodes[0].inputs"); +} + +#[test] +fn empty_v2_plan_has_no_lowered_objects() { + let p = compile(json!({"schemaVersion":2,"graphId":"empty","revision":1,"nodes":[]})); + assert_eq!( + ( + p.node_count, + p.resources.len(), + p.executions.len(), + p.texture_families.len(), + p.allocation_classes.len() + ), + (0, 0, 0, 0, 0) + ); +} + +#[test] +fn registry_revision_handles_are_immutable_and_drop_is_transactional() { + let bytes = |revision| { + serde_json::to_vec( + &json!({"schemaVersion":2,"graphId":"registry","revision":revision,"nodes":[]}), + ) + .unwrap() + }; + let mut r = Registry::new(2); + let (id, _) = r.compile(&bytes(1)).unwrap(); + assert_eq!( + r.compile(&bytes(1)).unwrap_err().message, + "revision must increase" + ); + let (second, _) = r.compile(&bytes(2)).unwrap(); + assert_ne!(id, second); + assert!(matches!( + r.get_registered(id).unwrap(), + RegisteredGraph::V2(graph) if graph.revision == 1 + )); + r.drop_graph(id).unwrap(); + assert_eq!(r.get_registered(id).unwrap_err().code, "STALE_GRAPH_ID"); + let (next, _) = r.compile(&bytes(3)).unwrap(); + assert_ne!(id, next); + assert!(r.get_registered(second).is_ok()); +} + +#[test] +fn v1_parse_compile_regression() { + let v1=br#"{"schemaVersion":1,"graphId":"v1","revision":1,"resources":[],"passes":[],"outputs":[]}"#; + assert!(parse_and_compile(v1).is_ok()); + assert!(matches!( + parse_and_compile_any(v1).unwrap(), + RegisteredGraph::V1(_) + )); +} + +#[test] +fn socket_validation_is_globally_phased() { + let mut g = full_cull_graph(); + g["nodes"][3]["inputs"] + .as_object_mut() + .unwrap() + .remove("scene"); + g["nodes"][9]["inputs"]["bogus"] = input("scene", "scene"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_UNKNOWN_SOCKET", Some("nodes[9].inputs.bogus")) + ); + + let mut g = full_cull_graph(); + g["nodes"][3]["inputs"]["scene"] = input("frustum", "frustum"); + g["nodes"][9]["inputs"]["colorTarget"]["socket"] = json!("bogus"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ( + "GRAPH_UNKNOWN_SOCKET", + Some("nodes[9].inputs.colorTarget.socket") + ) + ); + + let mut g = full_cull_graph(); + g["nodes"][3]["inputs"]["scene"] = input("frustum", "frustum"); + g["nodes"][9]["inputs"] + .as_object_mut() + .unwrap() + .remove("draws"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_SOCKET_CARDINALITY", Some("nodes[9].inputs.draws")) + ); + + let mut g = full_cull_graph(); + g["nodes"][3]["inputs"]["scene"] = input("frustum", "frustum"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_SOCKET_TYPE_MISMATCH", Some("nodes[3].inputs.scene")) + ); +} + +#[test] +fn executor_version_parameter_and_state_precedence_is_global() { + let mut g = full_cull_graph(); + g["nodes"][2]["inputs"]["bad"] = input("missing", "bad"); + g["nodes"][10]["executor"]["key"] = json!("unknown"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_UNKNOWN_NODE", Some("nodes[2].inputs.bad.node")) + ); + + let mut g = full_cull_graph(); + g["nodes"][0]["parameters"] = json!({"bad":1}); + g["nodes"][1]["state"] = json!("muted"); + g["nodes"][2]["inputs"]["bad"] = input("scene", "bad"); + g["nodes"][10]["executor"]["key"] = json!("unknown"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_UNKNOWN_EXECUTOR", Some("nodes[10].executor.key")) + ); + + let mut g = full_cull_graph(); + g["nodes"][0]["parameters"] = json!({"bad":1}); + g["nodes"][1]["state"] = json!("muted"); + g["nodes"][2]["inputs"]["bad"] = input("scene", "bad"); + g["nodes"][10]["executor"]["version"] = json!(2); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ( + "GRAPH_EXECUTOR_VERSION_UNSUPPORTED", + Some("nodes[10].executor.version") + ) + ); + + let mut g = full_cull_graph(); + g["nodes"][0]["parameters"] = json!({"bad":1}); + g["nodes"][1]["state"] = json!("muted"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_PARAMETERS_INVALID", Some("nodes[0].parameters")) + ); +} + +#[test] +fn attachment_compatibility_matrix_is_enforced() { + let cases = [ + ( + "depth_surface", + json!({"kind":"absolute","width":4,"height":4,"depthOrArrayLayers":1}), + "depth32_float", + "d2", + 1, + ), + ( + "depth_half", + json!({"kind":"surface_relative","width":{"numerator":1,"denominator":2},"height":{"numerator":1,"denominator":2},"depthOrArrayLayers":1}), + "depth32_float", + "d2", + 1, + ), + ( + "depth_layers", + json!({"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":2}), + "depth32_float", + "d2", + 1, + ), + ( + "depth_format", + json!({"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":1}), + "rgba8_unorm", + "d2", + 1, + ), + ]; + for (_, extent, format, dimension, samples) in cases { + let mut g = full_cull_graph(); + let d = &mut g["nodes"][1]["parameters"]["texture"]; + d["extent"] = extent; + d["format"] = json!(format); + d["dimension"] = json!(dimension); + d["sampleCount"] = json!(samples); + assert_eq!(compile_error(g).code, "GRAPH_ILLEGAL_ACCESS"); + } + + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"] = texture("rgba8_unorm", "transient"); + g["nodes"][9]["inputs"]["colorTarget"] = input("depth", "spec"); + g["nodes"][9]["inputs"]["depthTarget"] = input("surface", "surface"); + assert_eq!(compile_error(g).code, "GRAPH_ILLEGAL_ACCESS"); + + for field in ["dimension", "extent", "sampleCount"] { + let mut g = full_cull_graph(); + let mut color = texture("rgba8_unorm", "transient"); + match field { + "dimension" => { + color["texture"][field] = json!("d3"); + color["texture"]["extent"] = + json!({"kind":"absolute","width":1,"height":1,"depthOrArrayLayers":1}); + } + "extent" => { + color["texture"][field] = + json!({"kind":"absolute","width":4,"height":4,"depthOrArrayLayers":1}) + } + _ => color["texture"][field] = json!(4), + } + g["nodes"] + .as_array_mut() + .unwrap() + .insert(2, node("color", "texture_spec", color, json!({}))); + g["nodes"][10]["inputs"]["colorTarget"] = input("color", "spec"); + assert_eq!(compile_error(g).code, "GRAPH_ILLEGAL_ACCESS", "{field}"); + } + + let mut g = full_cull_graph(); + g["nodes"].as_array_mut().unwrap().insert( + 2, + node( + "color", + "texture_spec", + texture("rgba8_unorm", "transient"), + json!({}), + ), + ); + g["nodes"][10]["inputs"]["colorTarget"] = input("color", "spec"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_ILLEGAL_ACCESS", Some("nodes[11].inputs.surface")) + ); +} + +#[test] +fn v2_wire_rejects_old_and_unknown_fields_exactly() { + let mut cases = Vec::new(); + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["descriptor"] = g["nodes"][1]["parameters"]["texture"].take(); + cases.push(g); + for old in ["compare", "writeEnabled", "clear"] { + let mut g = full_cull_graph(); + g["nodes"][8]["parameters"][old] = json!(1); + cases.push(g); + } + for missing in ["clearDepth", "clearColor"] { + let mut g = full_cull_graph(); + let i = if missing == "clearDepth" { 8 } else { 9 }; + g["nodes"][i]["parameters"] + .as_object_mut() + .unwrap() + .remove(missing); + cases.push(g); + } + for mut g in cases { + assert_eq!( + parse_and_compile_v2(&serde_json::to_vec(&g).unwrap()) + .unwrap_err() + .code, + "GRAPH_PARAMETERS_INVALID" + ); + } + for (index, field) in [(0, "legacyOptional"), (0, "unknownNodeField")] { + let mut g = full_cull_graph(); + g["nodes"][index][field] = json!(null); + assert_eq!( + parse_and_compile_v2(&serde_json::to_vec(&g).unwrap()) + .unwrap_err() + .code, + "GRAPH_JSON_INVALID" + ); + } + let mut g = full_cull_graph(); + g["unknownGraphField"] = json!(true); + assert_eq!( + parse_and_compile_v2(&serde_json::to_vec(&g).unwrap()) + .unwrap_err() + .code, + "GRAPH_JSON_INVALID" + ); +} + +#[test] +fn raw_limits_precede_malformed_content_and_cover_live_resources() { + let mut g = graph( + (0..1025) + .map(|i| node(&format!("n{i}"), "surface_target", json!({}), json!({}))) + .collect(), + ); + g["graphId"] = json!("bad id"); + assert_eq!(compile_error(g).details["path"], "nodes"); + let mut nodes: Vec<_> = (0..65) + .map(|i| { + node( + &format!("p{i}"), + "present", + json!({}), + json!({"surface":input("missing","bad")}), + ) + }) + .collect(); + assert_eq!( + compile_error(graph(std::mem::take(&mut nodes))).details["path"], + "nodes" + ); + let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; + nodes.extend(render_support_nodes()); + let mut color = input("surface", "surface"); + for i in 0..508 { + let d = format!("d{i}"); + let f = format!("f{i}"); + nodes.push(node( + &d, + "texture_spec", + texture("depth32_float", "transient"), + json!({}), + )); + nodes.push(forward(&f, color, input(&d, "spec"))); + color = input(&f, "color"); + } + nodes.push(node( + "present", + "present", + json!({}), + json!({"surface":color}), + )); + let e = compile_error(graph(nodes)); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_LIMIT_EXCEEDED", Some("resources")) + ); +} + +#[test] +fn generated_resource_limit_is_only_final() { + fn oversized(old_present: bool) -> Value { + let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; + nodes.extend(render_support_nodes()); + let mut color = input("surface", "surface"); + for i in 0..508 { + let d = format!("d{i}"); + let f = format!("f{i}"); + nodes.push(node( + &d, + "texture_spec", + texture("depth32_float", "transient"), + json!({}), + )); + nodes.push(forward(&f, color, input(&d, "spec"))); + color = input(&f, "color"); + } + nodes.push(node( + "present", + "present", + json!({}), + json!({"surface":color}), + )); + if old_present { + nodes.push(node( + "old_present", + "present", + json!({}), + json!({"surface":input("f0","color")}), + )); + } + assert!(nodes.len() <= 1024); + graph(nodes) + } + + let clean = compile_error(oversized(false)); + assert_eq!( + (clean.code, clean.details["path"].as_str()), + ("GRAPH_LIMIT_EXCEEDED", Some("resources")) + ); + let polluted = compile_error(oversized(true)); + assert_eq!(polluted.code, "GRAPH_RESOURCE_VERSION_INVALID"); + assert_eq!(polluted.details["path"], "nodes[1022].inputs.surface"); +} + +#[test] +fn bloom_composite_rejects_each_stale_sampled_texture_version() { + for stale_socket in ["source", "bloom"] { + let mut half = texture("rgba16_float", "transient"); + half["texture"]["extent"]["width"] = json!({"numerator":1,"denominator":2}); + half["texture"]["extent"]["height"] = json!({"numerator":1,"denominator":2}); + let mut half_depth = texture("depth32_float", "transient"); + half_depth["texture"]["extent"] = half["texture"]["extent"].clone(); + let mut nodes = vec![ + node("surface", "surface_target", json!({}), json!({})), + node( + "source_target", + "texture_spec", + texture("rgba16_float", "transient"), + json!({}), + ), + node("bloom_target", "texture_spec", half, json!({})), + node( + "output", + "texture_spec", + texture("rgba16_float", "transient"), + json!({}), + ), + depth_spec("source_depth_0", "transient"), + depth_spec("source_depth_1", "transient"), + node( + "bloom_depth_0", + "texture_spec", + half_depth.clone(), + json!({}), + ), + node("bloom_depth_1", "texture_spec", half_depth, json!({})), + ]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward("source_f0", input("source_target","spec"), input("source_depth_0","spec")), + forward("source_f1", input("source_f0","color"), input("source_depth_1","spec")), + forward("bloom_f0", input("bloom_target","spec"), input("bloom_depth_0","spec")), + forward("bloom_f1", input("bloom_f0","color"), input("bloom_depth_1","spec")), + node( + "composite", + "bloom_composite", + json!({"intensity":1.0}), + json!({ + "source":input(if stale_socket == "source" { "source_f0" } else { "source_f1" },"color"), + "bloom":input(if stale_socket == "bloom" { "source_f0" } else { "source_f1" },"color"), + "colorTarget":input("output","spec") + }), + ), + node( + "to_surface", + "fullscreen_copy", + json!({}), + json!({"source":input("composite","color"),"colorTarget":input("surface","surface")}), + ), + node( + "present", + "present", + json!({}), + json!({"surface":input("to_surface","color")}), + ), + ]); + let error = compile_error(graph(nodes)); + assert_eq!(error.code, "GRAPH_RESOURCE_VERSION_INVALID"); + assert_eq!( + error.details["path"], + format!("nodes[16].inputs.{stale_socket}") + ); + } +} + +#[test] +fn bloom_composite_requires_a_single_view_rgba16_half_resolution_bloom() { + let mut half = texture("rgba16_float", "transient"); + half["texture"]["extent"]["width"] = json!({"numerator":1,"denominator":2}); + half["texture"]["extent"]["height"] = json!({"numerator":1,"denominator":2}); + let make_graph = || { + let mut bloom_depth = texture("depth32_float", "transient"); + bloom_depth["texture"]["extent"] = half["texture"]["extent"].clone(); + let mut nodes = vec![ + node("surface", "surface_target", json!({}), json!({})), + node( + "source", + "texture_spec", + texture("rgba16_float", "transient"), + json!({}), + ), + node("bloom", "texture_spec", half.clone(), json!({})), + node( + "target", + "texture_spec", + texture("rgba16_float", "transient"), + json!({}), + ), + depth_spec("source_depth", "transient"), + node("bloom_depth", "texture_spec", bloom_depth, json!({})), + ]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward( + "source_writer", + input("source", "spec"), + input("source_depth", "spec"), + ), + forward( + "bloom_writer", + input("bloom", "spec"), + input("bloom_depth", "spec"), + ), + node( + "composite", + "bloom_composite", + json!({"intensity":1.0}), + json!({"source":input("source_writer","color"),"bloom":input("bloom_writer","color"),"colorTarget":input("target","spec")}), + ), + node( + "to_surface", + "fullscreen_copy", + json!({}), + json!({"source":input("composite","color"),"colorTarget":input("surface","surface")}), + ), + node( + "present", + "present", + json!({}), + json!({"surface":input("to_surface","color")}), + ), + ]); + graph(nodes) + }; + compile(make_graph()); + + let mut invalid = make_graph(); + invalid["nodes"][2]["parameters"]["texture"]["format"] = json!("rgba8_unorm"); + invalid["nodes"][2]["parameters"]["texture"]["mipLevelCount"] = json!(2); + let error = compile_error(invalid); + assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); + assert_eq!(error.details["path"], "nodes[12].inputs"); +} + +#[test] +fn fullscreen_copy_rejects_incompatible_authored_targets_at_copy_inputs() { + for (field, value, expected_code, expected_path) in [ + ( + "format", + json!("depth32_float"), + "GRAPH_ILLEGAL_ACCESS", + "nodes[9].inputs", + ), + ( + "dimension", + json!("d3"), + "GRAPH_PARAMETERS_INVALID", + "nodes[2].parameters.texture.extent", + ), + ( + "sampleCount", + json!(4), + "GRAPH_ILLEGAL_ACCESS", + "nodes[9].inputs", + ), + ( + "mipLevelCount", + json!(2), + "GRAPH_ILLEGAL_ACCESS", + "nodes[9].inputs", + ), + ] { + let mut target = texture("rgba16_float", "transient"); + target["texture"][field] = value; + let mut nodes = vec![ + node("surface", "surface_target", json!({}), json!({})), + node( + "source", + "texture_spec", + texture("rgba16_float", "transient"), + json!({}), + ), + node("target", "texture_spec", target, json!({})), + depth_spec("depth", "transient"), + ]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward("source_writer", input("source","spec"), input("depth","spec")), + node( + "copy", + "fullscreen_copy", + json!({}), + json!({"source":input("source_writer","color"),"colorTarget":input("target","spec")}), + ), + node( + "to_surface", + "fullscreen_copy", + json!({}), + json!({"source":input("copy","color"),"colorTarget":input("surface","surface")}), + ), + node( + "present", + "present", + json!({}), + json!({"surface":input("to_surface","color")}), + ), + ]); + let error = compile_error(graph(nodes)); + assert_eq!(error.code, expected_code, "field {field}"); + assert_eq!(error.details["path"], expected_path, "field {field}"); + } +} diff --git a/renderer/src/renderer/culling.wgsl b/renderer/src/renderer/culling.wgsl new file mode 100644 index 0000000..a065adb --- /dev/null +++ b/renderer/src/renderer/culling.wgsl @@ -0,0 +1,52 @@ +struct Params { planes: array, 6>, count: u32, visible_predicate: u32, frustum_predicate: u32, _pad: u32 } +struct Instance { model: mat4x4, n0: vec4, n1: vec4, n2: vec4 } +struct Aabb { min: vec4, max: vec4 } +struct Meta { index_count: u32, first_index: u32, base_vertex: i32, instance_index: u32 } +struct Command { index_count: u32, instance_count: u32, first_index: u32, base_vertex: i32, first_instance: u32 } +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var instances: array; +@group(0) @binding(2) var bounds: array; +@group(0) @binding(3) var authored_visible: array; +@group(0) @binding(4) var metadata: array; +@group(0) @binding(5) var frustum_flags: array; +@group(0) @binding(6) var commands: array; + +@compute @workgroup_size(64) +fn frustum_cull(@builtin(global_invocation_id) id: vec3) { + let i = id.x; + if (i >= params.count) { return; } + let b = bounds[i]; + let center = (b.min.xyz + b.max.xyz) * 0.5; + let extent = (b.max.xyz - b.min.xyz) * 0.5; + let m = instances[i].model; + let wc = (m * vec4(center, 1.0)).xyz; + let ax = m[0].xyz * extent.x; + let ay = m[1].xyz * extent.y; + let az = m[2].xyz * extent.z; + var inside = 1u; + for (var p = 0u; p < 6u; p++) { + let plane = params.planes[p]; + let radius = abs(dot(plane.xyz, ax)) + abs(dot(plane.xyz, ay)) + abs(dot(plane.xyz, az)); + if (dot(plane.xyz, wc) + plane.w + radius < 0.0) { inside = 0u; } + } + frustum_flags[i] = 1u - inside; +} + +fn matches(value: u32, predicate: u32) -> bool { + return predicate == 0u || (predicate == 1u && value != 0u) || (predicate == 2u && value == 0u); +} + +@compute @workgroup_size(64) +fn mesh_query(@builtin(global_invocation_id) id: vec3) { + let i = id.x; + if (i >= params.count) { return; } + let draw_meta = metadata[i]; + var selected = true; + if (params.visible_predicate != 0u) { + selected = matches(authored_visible[i], params.visible_predicate); + } + if (params.frustum_predicate != 0u) { + selected = selected && matches(frustum_flags[i], params.frustum_predicate); + } + commands[i] = Command(draw_meta.index_count, select(0u, 1u, selected), draw_meta.first_index, draw_meta.base_vertex, 0u); +} diff --git a/renderer/src/renderer/executors/legacy_forward.rs b/renderer/src/renderer/executors/legacy_forward.rs new file mode 100644 index 0000000..391912e --- /dev/null +++ b/renderer/src/renderer/executors/legacy_forward.rs @@ -0,0 +1,391 @@ +use crate::renderer::{ + gpu_scene::GpuSceneCache, material::MaterialResources, ActiveCompiledV1, ActiveCompiledV2, + PipelineLibrary, PreparedExecutionV2, +}; + +use super::super::scene::Scene; + +fn encode_scene<'a, T: Scene>( + pass: &mut wgpu::RenderPass<'a>, + scene: &'a T, + gpu: &'a GpuSceneCache, + pipelines: &'a PipelineLibrary, + materials: &'a MaterialResources, +) { + for (i, bind_group) in scene.bind_groups().iter().enumerate() { + pass.set_bind_group(i as u32, bind_group, &[]); + } + if let (Some(p), Some(n), Some(u), Some(t), Some(i), Some(inst)) = ( + &gpu.positions.buffer, + &gpu.normals.buffer, + &gpu.uvs.buffer, + &gpu.tangents.buffer, + &gpu.indices.buffer, + &gpu.instances.buffer, + ) { + pass.set_vertex_buffer(0, p.slice(..)); + pass.set_vertex_buffer(1, n.slice(..)); + pass.set_vertex_buffer(2, u.slice(..)); + pass.set_vertex_buffer(3, inst.slice(..)); + pass.set_vertex_buffer(4, t.slice(..)); + pass.set_index_buffer(i.slice(..), wgpu::IndexFormat::Uint32); + for draw in &gpu.draws { + if !draw.effective_visible { + continue; + } + pass.set_pipeline(pipelines.get_pipeline(draw.pipeline)); + if pipelines.requires_material(draw.pipeline) { + pass.set_bind_group(2, materials.group(draw.material), &[]); + } + pass.draw_indexed( + draw.indices.clone(), + draw.base_vertex, + draw.instances.clone(), + ); + } + } +} + +pub(crate) fn encode_compiled_v2( + encoder: &mut wgpu::CommandEncoder, + surface: &wgpu::TextureView, + active: &ActiveCompiledV2, + scene: &T, + gpu: &GpuSceneCache, + pipelines: &PipelineLibrary, + materials: &MaterialResources, + mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>, +) -> Result<(), &'static str> { + use crate::render_graph::{ + ExecutionKindV2, NormalizedColorLoadV2, NormalizedDepthLoadV2, ResourcePlanV2, StoreOpV2, + }; + let view = |resource: u32| -> Result<&wgpu::TextureView, &'static str> { + let is_surface = active + .graph + .resources + .get(resource as usize) + .is_some_and(|resource| { + matches!( + resource.plan, + ResourcePlanV2::SurfaceTarget { family } + | ResourcePlanV2::Texture { family, .. } + if family == active.runtime.allocations.surface_family + ) + }); + if is_surface { + return Ok(surface); + } + let a = active + .runtime + .allocations + .resource_allocations + .get(resource as usize) + .copied() + .flatten() + .ok_or("V2 resource has no allocation")?; + active + .textures + .get(a.class as usize) + .and_then(|c| c.get(a.slot as usize)) + .map(|s| &s.view) + .ok_or("V2 allocation out of bounds") + }; + for (execution_index, prepared) in active.executions.iter().enumerate() { + let profile_id = &active.graph.executions[execution_index].id; + match prepared { + PreparedExecutionV2::FrustumCull => { + gpu.encode_frustum_cull(encoder, profile.as_deref_mut(), profile_id); + } + PreparedExecutionV2::MeshQuery => { + gpu.encode_mesh_query(encoder, profile.as_deref_mut(), profile_id); + } + PreparedExecutionV2::Present => {} + PreparedExecutionV2::Fullscreen { + execution, + bind_group, + pipeline, + .. + } => { + let execution = active + .graph + .executions + .get(*execution) + .ok_or("V2 execution out of bounds")?; + let ExecutionKindV2::Render { + color_attachments, .. + } = &execution.kind + else { + return Err("fullscreen is not render"); + }; + let color = color_attachments + .first() + .ok_or("fullscreen target missing")?; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(&execution.id), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: view(color.resource)?, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: match color.load { + NormalizedColorLoadV2::Load => wgpu::LoadOp::Load, + NormalizedColorLoadV2::Clear { value } => { + wgpu::LoadOp::Clear(wgpu::Color { + r: value[0], + g: value[1], + b: value[2], + a: value[3], + }) + } + }, + store: if color.store == StoreOpV2::Store { + wgpu::StoreOp::Store + } else { + wgpu::StoreOp::Discard + }, + }, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: profile + .as_deref_mut() + .and_then(|p| p.render_writes(&execution.id)), + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, bind_group, &[]); + pass.draw(0..3, 0..1); + } + PreparedExecutionV2::LegacyForward { + execution, + variants, + } => { + let execution = active + .graph + .executions + .get(*execution) + .ok_or("V2 execution out of bounds")?; + let ExecutionKindV2::Render { + color_attachments, + depth_stencil, + } = &execution.kind + else { + return Err("legacy forward is not render"); + }; + let color = color_attachments.first().ok_or("legacy color missing")?; + let depth = depth_stencil.as_ref().ok_or("legacy depth missing")?; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(&execution.id), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: view(color.resource)?, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: match color.load { + NormalizedColorLoadV2::Load => wgpu::LoadOp::Load, + NormalizedColorLoadV2::Clear { value } => { + wgpu::LoadOp::Clear(wgpu::Color { + r: value[0], + g: value[1], + b: value[2], + a: value[3], + }) + } + }, + store: if color.store == StoreOpV2::Store { + wgpu::StoreOp::Store + } else { + wgpu::StoreOp::Discard + }, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: view(depth.resource)?, + depth_ops: Some(wgpu::Operations { + load: match depth.load { + NormalizedDepthLoadV2::Load => wgpu::LoadOp::Load, + NormalizedDepthLoadV2::Clear { value } => { + wgpu::LoadOp::Clear(value) + } + }, + store: if depth.store == StoreOpV2::Store { + wgpu::StoreOp::Store + } else { + wgpu::StoreOp::Discard + }, + }), + stencil_ops: None, + }), + occlusion_query_set: None, + timestamp_writes: profile + .as_deref_mut() + .and_then(|p| p.render_writes(&execution.id)), + }); + for (i, group) in scene.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)) = ( + &gpu.positions.buffer, + &gpu.normals.buffer, + &gpu.uvs.buffer, + &gpu.tangents.buffer, + &gpu.indices.buffer, + &gpu.instances.buffer, + ) { + pass.set_vertex_buffer(0, p.slice(..)); + pass.set_vertex_buffer(1, n.slice(..)); + pass.set_vertex_buffer(2, u.slice(..)); + pass.set_vertex_buffer(4, t.slice(..)); + pass.set_index_buffer(ix.slice(..), wgpu::IndexFormat::Uint32); + for draw in &gpu.draws { + let slot = draw.instances.start as u64; + let start = slot + * std::mem::size_of::() as u64; + pass.set_vertex_buffer(3, inst.slice(start..start + 112)); + let key = variants + .iter() + .find(|(base, _)| *base == draw.pipeline) + .map(|x| &x.1) + .ok_or("pipeline variant missing")?; + pass.set_pipeline(key); + if pipelines.requires_material(draw.pipeline) { + pass.set_bind_group(2, materials.group(draw.material), &[]); + } + pass.draw_indexed_indirect( + gpu.indirect_commands + .buffer + .as_ref() + .ok_or("indirect command buffer missing")?, + slot * 20, + ); + } + } + } + } + } + Ok(()) +} + +pub(crate) fn encode_immediate( + encoder: &mut wgpu::CommandEncoder, + color: &wgpu::TextureView, + depth: &wgpu::TextureView, + scene: &T, + gpu: &GpuSceneCache, + pipelines: &PipelineLibrary, + materials: &MaterialResources, + profile: Option<&mut crate::renderer::profiler::ProfileFrame>, +) { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Render pass"), + 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("immediate.forward")), + }); + encode_scene(&mut pass, scene, gpu, pipelines, materials); +} + +pub(crate) fn encode_compiled_v1( + encoder: &mut wgpu::CommandEncoder, + color_view: &wgpu::TextureView, + active: &ActiveCompiledV1, + scene: &T, + gpu: &GpuSceneCache, + pipelines: &PipelineLibrary, + materials: &MaterialResources, + mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>, +) { + for pass in &active.graph.passes { + let depth_resource = pass + .writes + .iter() + .find(|w| w.binding == "depth") + .unwrap() + .resource as usize; + let allocation = active.graph.resources[depth_resource].allocation.unwrap(); + let depth_view = + &active.views[active.class_bases[allocation.class as usize] + allocation.slot as usize]; + let color = pass.writes.iter().find(|w| w.binding == "color").unwrap(); + let depth = pass.writes.iter().find(|w| w.binding == "depth").unwrap(); + let (color_load, color_store) = match &color.access { + crate::render_graph::WriteAccess::ColorAttachment { load, store, .. } => ( + match load { + crate::render_graph::ColorLoad::Clear { value } => { + wgpu::LoadOp::Clear(wgpu::Color { + r: value[0], + g: value[1], + b: value[2], + a: value[3], + }) + } + crate::render_graph::ColorLoad::Load => wgpu::LoadOp::Load, + }, + if matches!(store, crate::render_graph::StoreOp::Store) { + wgpu::StoreOp::Store + } else { + wgpu::StoreOp::Discard + }, + ), + _ => unreachable!(), + }; + let (depth_load, depth_store) = match &depth.access { + crate::render_graph::WriteAccess::DepthAttachment { load, store } => ( + match load { + crate::render_graph::DepthLoad::Clear { value } => wgpu::LoadOp::Clear(*value), + crate::render_graph::DepthLoad::Load => wgpu::LoadOp::Load, + }, + if matches!(store, crate::render_graph::StoreOp::Store) { + wgpu::StoreOp::Store + } else { + wgpu::StoreOp::Discard + }, + ), + _ => unreachable!(), + }; + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(&pass.id), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + depth_slice: None, + view: color_view, + resolve_target: None, + ops: wgpu::Operations { + load: color_load, + store: color_store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: depth_view, + depth_ops: Some(wgpu::Operations { + load: depth_load, + store: depth_store, + }), + stencil_ops: None, + }), + occlusion_query_set: None, + timestamp_writes: profile + .as_deref_mut() + .and_then(|p| p.render_writes(&pass.id)), + }); + encode_scene(&mut render_pass, scene, gpu, pipelines, materials); + } +} diff --git a/renderer/src/renderer/executors/mod.rs b/renderer/src/renderer/executors/mod.rs new file mode 100644 index 0000000..fd7b6cd --- /dev/null +++ b/renderer/src/renderer/executors/mod.rs @@ -0,0 +1,3 @@ +mod legacy_forward; + +pub(super) use legacy_forward::{encode_compiled_v1, encode_compiled_v2, encode_immediate}; diff --git a/renderer/src/renderer/fullscreen_copy.wgsl b/renderer/src/renderer/fullscreen_copy.wgsl new file mode 100644 index 0000000..72189d5 --- /dev/null +++ b/renderer/src/renderer/fullscreen_copy.wgsl @@ -0,0 +1,38 @@ +@group(0) @binding(0) var source_texture: texture_2d; +@group(0) @binding(1) var second_texture: texture_2d; +@group(0) @binding(2) var linear_clamp: sampler; +struct Parameters { a: vec4, b: vec4 } +@group(0) @binding(3) var parameters: Parameters; + +struct VertexOut { @builtin(position) position: vec4, @location(0) uv: vec2 } +@vertex fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut { + let positions = array(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + let p = positions[index]; + var out: VertexOut; out.position = vec4(p, 0.0, 1.0); out.uv = p * vec2(0.5, -0.5) + vec2(0.5); return out; +} +fn sample_source(uv: vec2) -> vec4 { return textureSampleLevel(source_texture, linear_clamp, uv, 0.0); } +@fragment fn fs_copy(in: VertexOut) -> @location(0) vec4 { return sample_source(in.uv); } + +fn aces(x: vec3) -> vec3 { + return clamp((x * (2.51 * x + vec3(0.03))) / (x * (2.43 * x + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0)); +} +fn linear_to_srgb(x: vec3) -> vec3 { + let low = x * 12.92; let high = 1.055 * pow(x, vec3(1.0 / 2.4)) - vec3(0.055); + return select(high, low, x <= vec3(0.0031308)); +} +@fragment fn fs_tone_map(in: VertexOut) -> @location(0) vec4 { let c=sample_source(in.uv); return vec4(linear_to_srgb(aces(c.rgb * parameters.a.x)), c.a); } +@fragment fn fs_bloom_extract(in: VertexOut) -> @location(0) vec4 { + let c=sample_source(in.uv); let brightness=max(c.r,max(c.g,c.b)); let knee=max(parameters.a.y,0.00001); let soft=clamp((brightness-parameters.a.x+knee)/(2.0*knee),0.0,1.0); let contribution=max(brightness-parameters.a.x,0.0)+soft*soft*knee; return vec4(c.rgb*contribution/max(brightness,0.00001),1.0); +} +@fragment fn fs_bloom_blur(in: VertexOut) -> @location(0) vec4 { + let size=vec2(textureDimensions(source_texture)); let step=parameters.a.xy*parameters.a.z/size; + var c=sample_source(in.uv)*0.227027; c+=sample_source(in.uv+step*1.384615)*0.316216; c+=sample_source(in.uv-step*1.384615)*0.316216; c+=sample_source(in.uv+step*3.230769)*0.070270; c+=sample_source(in.uv-step*3.230769)*0.070270; return c; +} +@fragment fn fs_bloom_composite(in: VertexOut) -> @location(0) vec4 { let c=sample_source(in.uv); return vec4(c.rgb+textureSampleLevel(second_texture,linear_clamp,in.uv,0.0).rgb*parameters.a.x,c.a); } +fn luminance(c: vec3) -> f32 { return dot(c,vec3(0.2126,0.7152,0.0722)); } +@fragment fn fs_luminance_edge(in: VertexOut) -> @location(0) vec4 { + let d=1.0/vec2(textureDimensions(source_texture)); var gx=0.0; var gy=0.0; + gx += -luminance(sample_source(in.uv+d*vec2(-1.0,-1.0)).rgb)+luminance(sample_source(in.uv+d*vec2(1.0,-1.0)).rgb); gx += -2.0*luminance(sample_source(in.uv+d*vec2(-1.0,0.0)).rgb)+2.0*luminance(sample_source(in.uv+d*vec2(1.0,0.0)).rgb); gx += -luminance(sample_source(in.uv+d*vec2(-1.0,1.0)).rgb)+luminance(sample_source(in.uv+d*vec2(1.0,1.0)).rgb); + gy += -luminance(sample_source(in.uv+d*vec2(-1.0,-1.0)).rgb)-2.0*luminance(sample_source(in.uv+d*vec2(0.0,-1.0)).rgb)-luminance(sample_source(in.uv+d*vec2(1.0,-1.0)).rgb); gy += luminance(sample_source(in.uv+d*vec2(-1.0,1.0)).rgb)+2.0*luminance(sample_source(in.uv+d*vec2(0.0,1.0)).rgb)+luminance(sample_source(in.uv+d*vec2(1.0,1.0)).rgb); + let edge=clamp(length(vec2(gx,gy))*parameters.a.x,0.0,1.0); return vec4(vec3(edge),1.0); +} diff --git a/renderer/src/renderer/gpu_scene.rs b/renderer/src/renderer/gpu_scene.rs index 1177a13..d4bc1f7 100644 --- a/renderer/src/renderer/gpu_scene.rs +++ b/renderer/src/renderer/gpu_scene.rs @@ -1,6 +1,9 @@ use std::mem::size_of; -use crate::render_data::{MeshHandle, PipelineKey, RenderData, RenderFlags}; +use crate::{ + render_data::{MaterialKey, MeshHandle, PipelineKey, RenderFlags}, + renderer::scene_frame::SceneFramePlan, +}; use bytemuck::{Pod, Zeroable}; #[repr(C)] @@ -15,10 +18,38 @@ pub struct GpuInstance { #[derive(Clone, Debug, PartialEq, Eq)] pub struct DrawItem { pub pipeline: PipelineKey, + pub material: MaterialKey, pub mesh: MeshHandle, pub indices: std::ops::Range, pub base_vertex: i32, pub instances: std::ops::Range, + pub effective_visible: bool, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq)] +pub struct GpuLocalAabb { + pub min: [f32; 4], + pub max: [f32; 4], +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq, Eq)] +pub struct DrawSlotMetadata { + pub index_count: u32, + pub first_index: u32, + pub base_vertex: i32, + pub instance_index: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq, Eq)] +pub struct DrawIndexedIndirect { + pub index_count: u32, + pub instance_count: u32, + pub first_index: u32, + pub base_vertex: i32, + pub first_instance: u32, } #[derive(Default)] @@ -26,30 +57,60 @@ pub struct GpuScenePlan { pub positions: Vec<[f32; 3]>, pub normals: Vec<[f32; 3]>, pub uvs: Vec<[f32; 2]>, + pub tangents: Vec<[f32; 4]>, pub indices: Vec, pub instances: Vec, pub draws: Vec, + pub local_aabbs: Vec, + pub effective_visibility: Vec, + pub draw_metadata: Vec, + pub commands: Vec, +} + +fn visibility_matches( + predicate: crate::render_graph::TriStatePredicate, + mesh: RenderFlags, + instance: RenderFlags, +) -> bool { + let effective = mesh.contains(RenderFlags::VISIBLE) && instance.contains(RenderFlags::VISIBLE); + match predicate { + crate::render_graph::TriStatePredicate::Any => true, + crate::render_graph::TriStatePredicate::RequiredTrue => effective, + crate::render_graph::TriStatePredicate::RequiredFalse => !effective, + } } impl GpuScenePlan { - pub fn build(data: &RenderData) -> Result { + pub fn build(data: &SceneFramePlan) -> Result { + self::GpuScenePlan::build_with_query( + data, + crate::render_graph::MeshQueryRuntimeKeyV2 { + visible: crate::render_graph::TriStatePredicate::RequiredTrue, + frustum_culled: crate::render_graph::TriStatePredicate::Any, + }, + ) + } + + pub fn build_with_query( + data: &SceneFramePlan, + query: crate::render_graph::MeshQueryRuntimeKeyV2, + ) -> Result { + let _ = query; // Packing is canonical; predicates are evaluated by the GPU. let mut plan = Self::default(); - let mut meshes: Vec<_> = data - .meshes() - .filter(|(_, mesh)| mesh.flags.contains(RenderFlags::VISIBLE)) - .collect(); - meshes.sort_by_key(|(handle, mesh)| { - (mesh.pipeline.get(), handle.slot(), handle.generation()) + let mut meshes: Vec<_> = data.meshes.iter().collect(); + meshes.sort_by_key(|mesh| { + ( + mesh.pipeline.get(), + mesh.material.get(), + mesh.handle.slot(), + mesh.handle.generation(), + ) }); - let streams = data.streams(); - for (handle, mesh) in meshes { - let mut occurrences: Vec<_> = data - .instances() - .filter(|(_, instance)| { - instance.mesh == handle && instance.flags.contains(RenderFlags::VISIBLE) - }) + for mesh in meshes { + let occurrences: Vec<_> = data.mesh_occurrence_indices[mesh.occurrence_range.clone()] + .iter() + .map(|&index| &data.occurrences[index]) .collect(); - occurrences.sort_by_key(|(handle, _)| (handle.slot(), handle.generation())); if occurrences.is_empty() { continue; } @@ -59,23 +120,25 @@ impl GpuScenePlan { .checked_add(mesh.geometry.vertex_count as usize) .ok_or("vertex range overflow")?; plan.positions.extend_from_slice( - streams - .positions + data.positions .get(source_start..source_end) .ok_or("invalid vertex range")?, ); plan.normals.extend_from_slice( - streams - .normals + data.normals .get(source_start..source_end) .ok_or("invalid normal range")?, ); plan.uvs.extend_from_slice( - streams - .uvs + data.uvs .get(source_start..source_end) .ok_or("invalid uv range")?, ); + plan.tangents.extend_from_slice( + data.tangents + .get(source_start..source_end) + .ok_or("invalid tangent range")?, + ); let index_start = u32::try_from(plan.indices.len()).map_err(|_| "index start exceeds u32")?; let source_index = mesh.geometry.index_start as usize; @@ -83,20 +146,24 @@ impl GpuScenePlan { .checked_add(mesh.geometry.index_count as usize) .ok_or("index range overflow")?; plan.indices.extend_from_slice( - data.indices() + data.indices .get(source_index..source_index_end) .ok_or("invalid index range")?, ); - let instance_start = - u32::try_from(plan.instances.len()).map_err(|_| "instance start exceeds u32")?; - for (_, instance) in occurrences { + for instance in occurrences { + let instance_start = u32::try_from(plan.instances.len()) + .map_err(|_| "instance start exceeds u32")?; + let m = &instance.model; + let determinant = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2]) + - m[1][0] * (m[0][1] * m[2][2] - m[2][1] * m[0][2]) + + m[2][0] * (m[0][1] * m[1][2] - m[1][1] * m[0][2]); plan.instances.push(GpuInstance { model: instance.model, normal_0: [ instance.normal[0][0], instance.normal[0][1], instance.normal[0][2], - 0.0, + if determinant < 0.0 { -1.0 } else { 1.0 }, ], normal_1: [ instance.normal[1][0], @@ -111,19 +178,41 @@ impl GpuScenePlan { 0.0, ], }); + let base_vertex = + i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?; + let end = index_start + .checked_add(mesh.geometry.index_count) + .ok_or("draw index range overflow")?; + let effective_visible = mesh.flags.contains(RenderFlags::VISIBLE) + && instance.flags.contains(RenderFlags::VISIBLE); + plan.local_aabbs.push(GpuLocalAabb { + min: [mesh.aabb.min[0], mesh.aabb.min[1], mesh.aabb.min[2], 0.], + max: [mesh.aabb.max[0], mesh.aabb.max[1], mesh.aabb.max[2], 0.], + }); + plan.effective_visibility.push(effective_visible as u32); + plan.draw_metadata.push(DrawSlotMetadata { + index_count: mesh.geometry.index_count, + first_index: index_start, + base_vertex, + instance_index: instance_start, + }); + plan.commands.push(DrawIndexedIndirect { + index_count: mesh.geometry.index_count, + instance_count: 0, + first_index: index_start, + base_vertex, + first_instance: 0, + }); + plan.draws.push(DrawItem { + pipeline: mesh.pipeline, + material: mesh.material, + mesh: mesh.handle, + indices: index_start..end, + base_vertex, + instances: instance_start..instance_start + 1, + effective_visible, + }); } - plan.draws.push(DrawItem { - pipeline: mesh.pipeline, - mesh: handle, - indices: index_start - ..index_start - .checked_add(mesh.geometry.index_count) - .ok_or("draw index range overflow")?, - base_vertex: i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?, - instances: instance_start - ..u32::try_from(plan.instances.len()) - .map_err(|_| "instance end exceeds u32")?, - }); } Ok(plan) } @@ -148,7 +237,7 @@ pub fn required_buffer_capacity( Ok(grown.min(maximum)) } -pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 4] { +pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 5] { const INSTANCE_ATTRIBUTES: [wgpu::VertexAttribute; 7] = wgpu::vertex_attr_array![3 => Float32x4, 4 => Float32x4, 5 => Float32x4, 6 => Float32x4, 7 => Float32x4, 8 => Float32x4, 9 => Float32x4]; [ wgpu::VertexBufferLayout { @@ -171,6 +260,11 @@ pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 4] { step_mode: wgpu::VertexStepMode::Instance, attributes: &INSTANCE_ATTRIBUTES, }, + wgpu::VertexBufferLayout { + array_stride: 16, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![10 => Float32x4], + }, ] } @@ -183,12 +277,45 @@ pub struct BufferSlot { #[derive(Default)] pub struct GpuSceneCache { revision: Option, + query: Option, pub positions: BufferSlot, pub normals: BufferSlot, pub uvs: BufferSlot, + pub tangents: BufferSlot, pub indices: BufferSlot, pub instances: BufferSlot, + pub local_aabbs: BufferSlot, + pub effective_visibility: BufferSlot, + pub draw_metadata: BufferSlot, + pub frustum_flags: BufferSlot, + pub indirect_commands: BufferSlot, pub draws: Vec, + compute: Option, +} + +struct CullingCompute { + params: wgpu::Buffer, + bind_group: wgpu::BindGroup, + frustum_pipeline: wgpu::ComputePipeline, + query_pipeline: wgpu::ComputePipeline, +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct CullingParams { + planes: [[f32; 4]; 6], + count: u32, + visible_predicate: u32, + frustum_predicate: u32, + _pad: u32, +} + +fn predicate_code(value: crate::render_graph::TriStatePredicate) -> u32 { + match value { + crate::render_graph::TriStatePredicate::Any => 0, + crate::render_graph::TriStatePredicate::RequiredTrue => 1, + crate::render_graph::TriStatePredicate::RequiredFalse => 2, + } } impl GpuSceneCache { @@ -196,15 +323,34 @@ impl GpuSceneCache { &mut self, device: &wgpu::Device, queue: &wgpu::Queue, - data: &RenderData, + data: &SceneFramePlan, ) -> Result<(), String> { - if self.revision == Some(data.revision()) { + self.upload_with_query( + device, + queue, + data, + crate::render_graph::MeshQueryRuntimeKeyV2 { + visible: crate::render_graph::TriStatePredicate::RequiredTrue, + frustum_culled: crate::render_graph::TriStatePredicate::Any, + }, + ) + } + + pub fn upload_with_query( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + data: &SceneFramePlan, + query: crate::render_graph::MeshQueryRuntimeKeyV2, + ) -> Result<(), String> { + if self.revision == Some(data.revision) && self.query == Some(query) { return Ok(()); } - let plan = GpuScenePlan::build(data).map_err(str::to_owned)?; + let plan = GpuScenePlan::build_with_query(data, query).map_err(str::to_owned)?; if plan.draws.is_empty() { self.draws.clear(); - self.revision = Some(data.revision()); + self.revision = Some(data.revision); + self.query = Some(query); return Ok(()); } let maximum = device.limits().max_buffer_size; @@ -218,18 +364,30 @@ impl GpuSceneCache { bytes(&plan.positions)?, bytes(&plan.normals)?, bytes(&plan.uvs)?, + bytes(&plan.tangents)?, bytes(&plan.indices)?, bytes(&plan.instances)?, + bytes(&plan.local_aabbs)?, + bytes(&plan.effective_visibility)?, + bytes(&plan.draw_metadata)?, + bytes(&plan.effective_visibility)?, + bytes(&plan.commands)?, ]; let old = [ self.positions.capacity, self.normals.capacity, self.uvs.capacity, + self.tangents.capacity, self.indices.capacity, self.instances.capacity, + self.local_aabbs.capacity, + self.effective_visibility.capacity, + self.draw_metadata.capacity, + self.frustum_flags.capacity, + self.indirect_commands.capacity, ]; - let mut capacities = [0; 5]; - for i in 0..5 { + let mut capacities = [0; 11]; + for i in 0..11 { capacities[i] = required_buffer_capacity(old[i], required[i], maximum).map_err(str::to_owned)?; } @@ -237,18 +395,30 @@ impl GpuSceneCache { wgpu::BufferUsages::VERTEX, wgpu::BufferUsages::VERTEX, wgpu::BufferUsages::VERTEX, - wgpu::BufferUsages::INDEX, wgpu::BufferUsages::VERTEX, + wgpu::BufferUsages::INDEX, + wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE, + wgpu::BufferUsages::STORAGE, + wgpu::BufferUsages::STORAGE, + wgpu::BufferUsages::STORAGE, + wgpu::BufferUsages::STORAGE, + wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDIRECT, ]; let labels = [ "scene positions", "scene normals", "scene uvs", + "scene tangents", "scene indices", "scene instances", + "scene local aabbs", + "scene effective visibility", + "scene draw metadata", + "scene frustum flags", + "scene indirect commands", ]; - let mut replacements: [Option; 5] = Default::default(); - for i in 0..5 { + let mut replacements: [Option; 11] = Default::default(); + for i in 0..11 { if capacities[i] != old[i] { replacements[i] = Some(device.create_buffer(&wgpu::BufferDescriptor { label: Some(labels[i]), @@ -262,8 +432,14 @@ impl GpuSceneCache { &mut self.positions, &mut self.normals, &mut self.uvs, + &mut self.tangents, &mut self.indices, &mut self.instances, + &mut self.local_aabbs, + &mut self.effective_visibility, + &mut self.draw_metadata, + &mut self.frustum_flags, + &mut self.indirect_commands, ]; for (i, slot) in slots.into_iter().enumerate() { if let Some(buffer) = replacements[i].take() { @@ -275,15 +451,27 @@ impl GpuSceneCache { bytemuck::cast_slice(&plan.positions), bytemuck::cast_slice(&plan.normals), bytemuck::cast_slice(&plan.uvs), + bytemuck::cast_slice(&plan.tangents), bytemuck::cast_slice(&plan.indices), bytemuck::cast_slice(&plan.instances), + bytemuck::cast_slice(&plan.local_aabbs), + bytemuck::cast_slice(&plan.effective_visibility), + bytemuck::cast_slice(&plan.draw_metadata), + bytemuck::cast_slice(&plan.effective_visibility), + bytemuck::cast_slice(&plan.commands), ]; let slots = [ &self.positions, &self.normals, &self.uvs, + &self.tangents, &self.indices, &self.instances, + &self.local_aabbs, + &self.effective_visibility, + &self.draw_metadata, + &self.frustum_flags, + &self.indirect_commands, ]; for (slot, contents) in slots.into_iter().zip(contents) { if !contents.is_empty() { @@ -295,15 +483,205 @@ impl GpuSceneCache { } } self.draws = plan.draws; - self.revision = Some(data.revision()); + self.rebuild_compute(device)?; + self.revision = Some(data.revision); + self.query = Some(query); Ok(()) } + + fn rebuild_compute(&mut self, device: &wgpu::Device) -> Result<(), String> { + if self.draws.is_empty() { + self.compute = None; + return Ok(()); + } + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene culling compute"), + source: wgpu::ShaderSource::Wgsl(include_str!("culling.wgsl").into()), + }); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("scene culling layout"), + entries: &(0..7) + .map(|binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: if binding == 0 { + wgpu::BufferBindingType::Uniform + } else { + wgpu::BufferBindingType::Storage { + read_only: binding < 5, + } + }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }) + .collect::>(), + }); + let params = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("scene culling params"), + size: size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let buffers = [ + &self.instances, + &self.local_aabbs, + &self.effective_visibility, + &self.draw_metadata, + &self.frustum_flags, + &self.indirect_commands, + ]; + let mut entries = vec![wgpu::BindGroupEntry { + binding: 0, + resource: params.as_entire_binding(), + }]; + for (i, slot) in buffers.iter().enumerate() { + entries.push(wgpu::BindGroupEntry { + binding: i as u32 + 1, + resource: slot + .buffer + .as_ref() + .ok_or("culling buffer absent")? + .as_entire_binding(), + }); + } + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("scene culling group"), + layout: &layout, + entries: &entries, + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("scene culling pipeline layout"), + bind_group_layouts: &[&layout], + push_constant_ranges: &[], + }); + let pipeline = |entry| { + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(entry), + layout: Some(&pipeline_layout), + module: &shader, + entry_point: Some(entry), + compilation_options: Default::default(), + cache: None, + }) + }; + self.compute = Some(CullingCompute { + params, + bind_group, + frustum_pipeline: pipeline("frustum_cull"), + query_pipeline: pipeline("mesh_query"), + }); + Ok(()) + } + + pub fn write_culling_params( + &self, + queue: &wgpu::Queue, + planes: Option<[[f32; 4]; 6]>, + query: crate::render_graph::MeshQueryRuntimeKeyV2, + ) { + if let Some(compute) = &self.compute { + if let Some(planes) = planes { + queue.write_buffer(&compute.params, 0, bytemuck::bytes_of(&planes)); + } + let tail = CullingParams { + planes: [[0.0; 4]; 6], + count: self.draws.len() as u32, + visible_predicate: predicate_code(query.visible), + frustum_predicate: predicate_code(query.frustum_culled), + _pad: 0, + }; + queue.write_buffer( + &compute.params, + std::mem::offset_of!(CullingParams, count) as u64, + &bytemuck::bytes_of(&tail)[std::mem::offset_of!(CullingParams, count)..], + ); + } + } + + pub(crate) fn encode_frustum_cull( + &self, + encoder: &mut wgpu::CommandEncoder, + profile: Option<&mut crate::renderer::profiler::ProfileFrame>, + profile_id: &str, + ) { + let Some(c) = &self.compute else { return }; + let timestamps = profile.and_then(|p| p.compute_writes(profile_id)); + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("frustum_cull"), + timestamp_writes: timestamps, + }); + pass.set_pipeline(&c.frustum_pipeline); + pass.set_bind_group(0, &c.bind_group, &[]); + pass.dispatch_workgroups((self.draws.len() as u32 + 63) / 64, 1, 1); + } + pub(crate) fn encode_mesh_query( + &self, + encoder: &mut wgpu::CommandEncoder, + profile: Option<&mut crate::renderer::profiler::ProfileFrame>, + profile_id: &str, + ) { + let Some(c) = &self.compute else { return }; + let timestamps = profile.and_then(|p| p.compute_writes(profile_id)); + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("mesh_query"), + timestamp_writes: timestamps, + }); + pass.set_pipeline(&c.query_pipeline); + pass.set_bind_group(0, &c.bind_group, &[]); + pass.dispatch_workgroups((self.draws.len() as u32 + 63) / 64, 1, 1); + } } #[cfg(test)] mod tests { use super::*; - use crate::render_data::{MeshCreateInfo, RenderDataConfig, IDENTITY_MODEL_TRANSFORM}; + + #[test] + fn mesh_query_source_guards_optional_flag_buffer_reads() { + let source = include_str!("culling.wgsl"); + let visible_guard = source.find("if (params.visible_predicate != 0u)").unwrap(); + let visible_load = source.find("matches(authored_visible[i]").unwrap(); + let frustum_guard = source.find("if (params.frustum_predicate != 0u)").unwrap(); + let frustum_load = source.find("matches(frustum_flags[i]").unwrap(); + assert!(visible_guard < visible_load && frustum_guard < frustum_load); + for binding in 0..=6 { + assert!(source.contains(&format!("@binding({binding})"))); + } + } + + #[test] + fn effective_visibility_handles_every_mesh_instance_combination() { + use crate::render_graph::TriStatePredicate::{Any, RequiredFalse, RequiredTrue}; + for (mesh, instance, effective) in [ + (false, false, false), + (false, true, false), + (true, false, false), + (true, true, true), + ] { + let flags = |visible| { + if visible { + RenderFlags::VISIBLE + } else { + RenderFlags::NONE + } + }; + assert!(visibility_matches(Any, flags(mesh), flags(instance))); + assert_eq!( + visibility_matches(RequiredTrue, flags(mesh), flags(instance)), + effective + ); + assert_eq!( + visibility_matches(RequiredFalse, flags(mesh), flags(instance)), + !effective + ); + } + } + use crate::render_data::{ + MeshCreateInfo, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM, + }; #[test] fn instance_is_112_bytes_and_padding_is_zero() { assert_eq!(size_of::(), 112); @@ -321,6 +699,28 @@ mod tests { assert!(required_buffer_capacity(0, 33, 32).is_err()); } #[test] + fn mirrored_model_stores_negative_determinant_sign_in_padding() { + let mut data = RenderData::new(RenderDataConfig::default()).unwrap(); + let mut mirrored = IDENTITY_MODEL_TRANSFORM; + mirrored[0][0] = -1.0; + data.create_mesh(MeshCreateInfo { + positions: &[[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]], + normals: &[[0., 0., 1.]; 3], + tangents: &[[1., 0., 0., 1.]; 3], + uvs: &[[0., 0.]; 3], + indices: &[0, 1, 2], + pipeline: PipelineKey::new(0), + material: MaterialKey::DEFAULT, + flags: RenderFlags::VISIBLE, + default_instance_flags: RenderFlags::VISIBLE, + default_transform: mirrored, + }) + .unwrap(); + let frame = crate::renderer::scene_frame::SceneFramePlan::build(&data).unwrap(); + let plan = GpuScenePlan::build(&frame).unwrap(); + assert_eq!(plan.instances[0].normal_0[3], -1.0); + } + #[test] fn capacity_reuses_and_layout_matches_shader_contract() { assert_eq!(required_buffer_capacity(16, 12, 32), Ok(16)); assert_eq!(required_buffer_capacity(0, 1, 32), Ok(1)); @@ -330,7 +730,7 @@ mod tests { .iter() .map(|layout| layout.array_stride) .collect::>(), - [12, 12, 8, 112] + [12, 12, 8, 112, 16] ); assert_eq!( layouts[3] @@ -342,7 +742,7 @@ mod tests { ); } #[test] - fn plan_orders_pipelines_skips_hidden_and_uses_local_indices() { + fn plan_is_canonical_and_predicate_independent() { let mut data = RenderData::new(RenderDataConfig { initial_vertices: 0, initial_indices: 0, @@ -359,9 +759,11 @@ mod tests { data.create_mesh(MeshCreateInfo { positions: &p, normals: &n, + tangents: &[[1., 0., 0., 1.]; 3], uvs: &u, indices: &i, pipeline: PipelineKey::new(pipeline), + material: crate::render_data::MaterialKey::DEFAULT, flags: RenderFlags::VISIBLE, default_instance_flags: if visible { RenderFlags::VISIBLE @@ -377,18 +779,28 @@ mod tests { let low = add(2, true); data.create_instance(low.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE) .unwrap(); - let plan = GpuScenePlan::build(&data).unwrap(); + let frame = crate::renderer::scene_frame::SceneFramePlan::build(&data).unwrap(); + let plan = GpuScenePlan::build(&frame).unwrap(); assert_eq!( plan.draws .iter() .map(|d| d.pipeline.get()) .collect::>(), - vec![2, 9] + vec![0, 2, 2, 9] ); assert_eq!(plan.draws[0].base_vertex, 0); + assert!(!plan.draws[0].effective_visible); assert_eq!(plan.draws[1].base_vertex, 3); - assert_eq!(plan.draws[0].instances, 0..2); - assert_eq!(plan.indices, [0, 1, 2, 0, 1, 2]); - assert_eq!(high.mesh, plan.draws[1].mesh); + assert_eq!(plan.draws[1].instances, 1..2); + assert_eq!(plan.draws[2].instances, 2..3); + assert_eq!(plan.indices, [0, 1, 2, 0, 1, 2, 0, 1, 2]); + assert_eq!(high.mesh, plan.draws[3].mesh); + assert_eq!(size_of::(), 32); + assert_eq!(size_of::(), 16); + assert_eq!(size_of::(), 20); + assert!(plan + .commands + .iter() + .all(|command| command.first_instance == 0)); } } diff --git a/renderer/src/renderer/material.rs b/renderer/src/renderer/material.rs new file mode 100644 index 0000000..8b8da1f --- /dev/null +++ b/renderer/src/renderer/material.rs @@ -0,0 +1,597 @@ +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, +}; + +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, 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}")] + Decode(#[from] image::ImageError), + #[error("invalid texture, image, or sampler index")] + InvalidReference, + #[error("invalid decoded RGBA image: {0}")] + InvalidRgba(&'static str), +} + +pub(super) struct PreparedMaterials { + groups: HashMap, + textures: Vec, + views: Vec<[wgpu::TextureView; 2]>, + samplers: Vec, +} + +pub struct MaterialResources { + pub layout: wgpu::BindGroupLayout, + groups: HashMap, + fallback: wgpu::BindGroup, + fallback_views: Vec, + fallback_sampler: wgpu::Sampler, + textures: Vec, + views: Vec<[wgpu::TextureView; 2]>, + samplers: Vec, + pub asset_epoch: u64, +} + +fn layout_entry(binding: u32, ty: wgpu::BindingType) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty, + count: None, + } +} + +fn upload_rgba( + device: &wgpu::Device, + queue: &wgpu::Queue, + label: &str, + width: u32, + height: u32, + bytes_per_row: u32, + rgba: &[u8], +) -> (wgpu::Texture, [wgpu::TextureView; 2]) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb], + }); + queue.write_texture( + texture.as_image_copy(), + rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: Some(height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + let linear = texture.create_view(&wgpu::TextureViewDescriptor { + format: Some(wgpu::TextureFormat::Rgba8Unorm), + ..Default::default() + }); + let srgb = texture.create_view(&wgpu::TextureViewDescriptor { + format: Some(wgpu::TextureFormat::Rgba8UnormSrgb), + ..Default::default() + }); + (texture, [linear, srgb]) +} + +fn normalize_rgba(image: DynamicImage) -> (u32, u32, Vec) { + let rgba = image.into_rgba8(); + (rgba.width(), rgba.height(), rgba.into_raw()) +} + +fn validate_decoded_rgba( + width: u32, + height: u32, + rgba: &[u8], + max_dimension: u32, +) -> Result { + if width == 0 || height == 0 { + return Err(MaterialError::InvalidRgba("dimensions must be nonzero")); + } + if width > max_dimension || height > max_dimension { + return Err(MaterialError::InvalidRgba("dimensions exceed device limit")); + } + let bytes_per_row = width + .checked_mul(4) + .ok_or(MaterialError::InvalidRgba("row byte count overflows"))?; + let total = bytes_per_row + .checked_mul(height) + .ok_or(MaterialError::InvalidRgba("total byte count overflows"))?; + let total = usize::try_from(total) + .map_err(|_| MaterialError::InvalidRgba("total byte count exceeds usize"))?; + if rgba.len() != total { + return Err(MaterialError::InvalidRgba("pixel byte length is not exact")); + } + Ok(bytes_per_row) +} + +fn slot_uses_srgb(slot: usize) -> bool { + matches!(slot, 0 | 4) +} + +fn address(value: &str) -> wgpu::AddressMode { + match value { + "ClampToEdge" => wgpu::AddressMode::ClampToEdge, + "MirroredRepeat" => wgpu::AddressMode::MirrorRepeat, + "Repeat" => wgpu::AddressMode::Repeat, + _ => unreachable!("gltf crate returned unknown wrap"), + } +} + +fn sampler_descriptor(metadata: Option<&SamplerMetadata>) -> wgpu::SamplerDescriptor<'static> { + let (mag, min, mip, s, t) = metadata.map_or( + ( + wgpu::FilterMode::Linear, + wgpu::FilterMode::Linear, + wgpu::FilterMode::Linear, + wgpu::AddressMode::Repeat, + 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)) + }, + ); + wgpu::SamplerDescriptor { + address_mode_u: s, + address_mode_v: t, + mag_filter: mag, + min_filter: min, + mipmap_filter: mip, + ..Default::default() + } +} + +impl MaterialResources { + pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self { + let mut entries = vec![layout_entry( + 0, + wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(112), + }, + )]; + for binding in 1..=5 { + entries.push(layout_entry( + binding, + wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + )); + } + for binding in 6..=10 { + entries.push(layout_entry( + binding, + wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + )); + } + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("glTF material group 2"), + entries: &entries, + }); + let colors = [[255, 255, 255, 255], [128, 128, 255, 255], [0, 0, 0, 255]]; + let mut fallback_textures = Vec::new(); + let mut fallback_views = Vec::new(); + for color in colors { + let (t, v) = upload_rgba(device, queue, "neutral material texture", 1, 1, 4, &color); + fallback_views.push(v[0].clone()); + fallback_textures.push(t); + } + let fallback_sampler = device.create_sampler(&sampler_descriptor(None)); + let fallback = Self::make_group( + device, + &layout, + &Material::default(), + [ + &fallback_views[0], + &fallback_views[0], + &fallback_views[1], + &fallback_views[0], + &fallback_views[2], + ], + [&fallback_sampler; 5], + ); + Self { + layout, + groups: HashMap::new(), + fallback, + fallback_views, + fallback_sampler, + textures: fallback_textures, + views: Vec::new(), + samplers: Vec::new(), + asset_epoch: 0, + } + } + + fn make_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + material: &Material, + views: [&wgpu::TextureView; 5], + samplers: [&wgpu::Sampler; 5], + ) -> wgpu::BindGroup { + let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("material uniform"), + contents: bytemuck::bytes_of(&GpuMaterial::from(material)), + usage: wgpu::BufferUsages::UNIFORM, + }); + let mut entries = vec![wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }]; + for (i, view) in views.into_iter().enumerate() { + entries.push(wgpu::BindGroupEntry { + binding: i as u32 + 1, + resource: wgpu::BindingResource::TextureView(view), + }); + } + for (i, sampler) in samplers.into_iter().enumerate() { + entries.push(wgpu::BindGroupEntry { + binding: i as u32 + 6, + resource: wgpu::BindingResource::Sampler(sampler), + }); + } + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("material bind group"), + layout, + entries: &entries, + }) + } + + pub(super) fn prepare( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + scene: &ImportedScene, + ) -> Result { + 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 decoded = image::load_from_memory_with_format(&image.encoded_data, format)?; + let (width, height, rgba) = normalize_rgba(decoded); + let bytes_per_row = validate_decoded_rgba(width, height, &rgba, max_dimension)?; + let (texture, image_views) = upload_rgba( + device, + queue, + "glTF embedded image", + width, + height, + bytes_per_row, + &rgba, + ); + drop(rgba); + textures.push(texture); + views.push(image_views); + } + let mut samplers = Vec::new(); + for sampler in &scene.samplers { + samplers.push(device.create_sampler(&sampler_descriptor(Some(sampler)))); + } + let default_sampler = device.create_sampler(&sampler_descriptor(None)); + samplers.push(default_sampler); + let default_index = samplers.len() - 1; + let mut groups = HashMap::new(); + for material in &scene.materials { + let refs = [ + material.base_color_texture, + material.metallic_roughness_texture, + material.normal_texture, + material.occlusion_texture, + material.emissive_texture, + ]; + let mut selected_views = [ + &self.fallback_views[0], + &self.fallback_views[0], + &self.fallback_views[1], + &self.fallback_views[0], + &self.fallback_views[2], + ]; + let mut selected_samplers = [&self.fallback_sampler; 5]; + for (slot, reference) in refs.into_iter().enumerate() { + let Some(reference) = reference.filter(|r| r.tex_coord == 0) else { + continue; + }; + let texture = scene + .textures + .get(reference.texture) + .ok_or(MaterialError::InvalidReference)?; + selected_views[slot] = &views + .get(texture.image) + .ok_or(MaterialError::InvalidReference)?[usize::from(slot_uses_srgb(slot))]; + let sampler_index = texture.sampler.unwrap_or(default_index); + selected_samplers[slot] = samplers + .get(sampler_index) + .ok_or(MaterialError::InvalidReference)?; + } + groups.insert( + material.key, + Self::make_group( + device, + &self.layout, + material, + selected_views, + selected_samplers, + ), + ); + } + Ok(PreparedMaterials { + groups, + textures, + views, + samplers, + }) + } + + pub(super) fn install(&mut self, prepared: PreparedMaterials, asset_epoch: u64) { + self.groups = prepared.groups; + self.textures = prepared.textures; + self.views = prepared.views; + self.samplers = prepared.samplers; + self.asset_epoch = asset_epoch; + } + + pub fn group(&self, key: MaterialKey) -> &wgpu::BindGroup { + self.groups.get(&key).unwrap_or(&self.fallback) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn schlick(f0: f32, cosine: f32) -> f32 { + f0 + (1.0 - f0) * (1.0 - cosine.clamp(0.0, 1.0)).powi(5) + } + + fn ggx_d(n_h: f32, alpha: f32) -> f32 { + let n_h = n_h.clamp(0.0, 1.0); + let alpha2 = alpha * alpha; + let n_h2 = n_h * n_h; + let q = (1.0 - n_h2) + alpha2 * n_h2; + alpha2 / (std::f32::consts::PI * q * q) + } + + fn smith_v(n_v: f32, n_l: f32, alpha: f32) -> f32 { + let alpha2 = alpha * alpha; + let gv = n_l * (n_v * n_v * (1.0 - alpha2) + alpha2).max(0.0).sqrt(); + let gl = n_v * (n_l * n_l * (1.0 - alpha2) + alpha2).max(0.0).sqrt(); + 0.5 / (gv + gl).max(1e-6) + } + + #[test] + fn pbr_reference_equations_have_known_limits_and_stay_finite() { + assert!((schlick(0.04, 1.0) - 0.04).abs() < 1e-6); + assert!((schlick(0.04, 0.0) - 1.0).abs() < 1e-6); + assert!((ggx_d(0.0, 1.0) - std::f32::consts::FRAC_1_PI).abs() < 1e-6); + let expected_quarter = 16.0 / std::f32::consts::PI; + for actual in [ggx_d(1.0, 0.25), ggx_d(1.0 + f32::EPSILON, 0.25)] { + assert!((actual - expected_quarter).abs() / expected_quarter < 1e-6); + } + for value in [ggx_d(1.0, 0.045 * 0.045), smith_v(0.0, 0.0, 0.002025)] { + assert!(value.is_finite() && value >= 0.0); + } + let roughness_floor_peak = ggx_d(1.0, 0.045 * 0.045); + let expected = 1.0 / (std::f32::consts::PI * 0.045_f32.powi(4)); + assert!((roughness_floor_peak - expected).abs() / expected < 1e-6); + assert!((roughness_floor_peak - 77_624.0).abs() / 77_624.0 < 1e-4); + } + #[test] + fn material_uniform_is_112_bytes() { + assert_eq!(std::mem::size_of::(), 112); + } + #[test] + fn texcoord_one_disables_slot() { + let mut m = Material::default(); + m.base_color_texture = Some(TextureReference { + texture: 0, + tex_coord: 1, + }); + assert_eq!(GpuMaterial::from(&m).flags[0] & BASE, 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 { + texture: 4, + tex_coord: 0, + }); + let gpu = GpuMaterial::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[1], 1); + assert_eq!(gpu.uv_sets[2], 0); + } + #[test] + fn explicit_ior_sentinel_packs_unit_f0() { + let mut material = Material::default(); + material.ior = 0.0; + let gpu = GpuMaterial::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(), + }; + let d = sampler_descriptor(Some(&m)); + assert_eq!(d.mag_filter, wgpu::FilterMode::Nearest); + assert_eq!(d.min_filter, wgpu::FilterMode::Linear); + assert_eq!(d.mipmap_filter, wgpu::FilterMode::Nearest); + assert_eq!(d.address_mode_u, wgpu::AddressMode::ClampToEdge); + assert_eq!(d.address_mode_v, wgpu::AddressMode::MirrorRepeat); + } + #[test] + fn rgb_and_luma_normalize_to_rgba() { + let (_, _, rgb) = normalize_rgba(DynamicImage::ImageRgb8( + image::RgbImage::from_raw(1, 1, vec![1, 2, 3]).unwrap(), + )); + assert_eq!(rgb, [1, 2, 3, 255]); + let (_, _, luma) = normalize_rgba(DynamicImage::ImageLuma8( + image::GrayImage::from_raw(1, 1, vec![7]).unwrap(), + )); + assert_eq!(luma, [7, 7, 7, 255]); + } + #[test] + fn odd_width_layout_is_tight() { + let width = 3; + assert_eq!(width * 4, 12); + } + + #[test] + fn decoded_rgba_validation_rejects_dimensions_overflow_and_wrong_length() { + assert!(validate_decoded_rgba(0, 1, &[], 4096).is_err()); + assert!(validate_decoded_rgba(4097, 1, &[], 4096).is_err()); + assert!(validate_decoded_rgba(u32::MAX, 2, &[], u32::MAX).is_err()); + assert!(validate_decoded_rgba(2, 2, &[0; 15], 4096).is_err()); + assert_eq!(validate_decoded_rgba(3, 2, &[0; 24], 4096).unwrap(), 12); + } + + #[test] + fn only_color_roles_use_srgb_views() { + assert_eq!( + (0..5).map(slot_uses_srgb).collect::>(), + [true, false, false, false, true] + ); + } +} diff --git a/renderer/src/renderer/mod.rs b/renderer/src/renderer/mod.rs index 5abd6ba..539563b 100644 --- a/renderer/src/renderer/mod.rs +++ b/renderer/src/renderer/mod.rs @@ -9,20 +9,26 @@ use web_sys::DedicatedWorkerGlobalScope; use crate::{ command_ring::CommandRing, - gltf::{decode_gltf, install_imported, ModelBounds}, - message::{DrainEventError, MouseMessage, ResizeMessage, WindowEvent}, - render_data::{ - InstanceHandle, MeshHandle, PipelineKey, RenderData, RenderDataConfig, RenderFlags, - }, + gltf::{install_imported, ModelBounds}, + message::{camera_drag, CameraDrag, DrainEventError, MouseMessage, ResizeMessage, WindowEvent}, + render_data::{InstanceHandle, MeshHandle, RenderData, RenderDataConfig, RenderFlags}, renderer::scene::Scene, }; +pub mod executors; pub mod gpu_scene; +pub mod material; +pub mod pipeline_library; +pub mod profiler; pub mod scene; +pub mod scene_frame; + +pub use pipeline_library::PipelineLibrary; +pub type GpuResources = PipelineLibrary; const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; -struct ActiveCompiled { +struct ActiveCompiledV1 { id: crate::render_graph::CompiledGraphId, graph: crate::render_graph::CompiledGraph, _textures: Vec, @@ -30,6 +36,141 @@ struct ActiveCompiled { class_bases: Vec, } +struct GpuTextureSlotV2 { + _texture: wgpu::Texture, + view: wgpu::TextureView, +} + +enum PreparedExecutionV2 { + FrustumCull, + MeshQuery, + LegacyForward { + execution: usize, + variants: Vec<(crate::render_data::PipelineKey, wgpu::RenderPipeline)>, + }, + Fullscreen { + execution: usize, + bind_group: wgpu::BindGroup, + pipeline: wgpu::RenderPipeline, + _uniform: wgpu::Buffer, + }, + Present, +} + +struct ActiveCompiledV2 { + id: crate::render_graph::CompiledGraphId, + graph: crate::render_graph::CompiledGraphV2, + runtime: crate::render_graph::RuntimePlanV2, + textures: Vec>, + executions: Vec, + _fullscreen_layout: wgpu::BindGroupLayout, +} + +enum ActiveCompiledGraph { + V1(ActiveCompiledV1), + V2(ActiveCompiledV2), +} + +#[derive(Clone, Copy)] +enum UploadGraph { + Immediate, + V1, + V2(crate::render_graph::MeshQueryRuntimeKeyV2), +} + +fn classify_upload_graph(graph: &ActiveCompiledGraph) -> UploadGraph { + match graph { + ActiveCompiledGraph::V1(_) => UploadGraph::V1, + ActiveCompiledGraph::V2(active) => UploadGraph::V2(active.runtime.allocations.query), + } +} + +fn upload_query_for_render( + pending: Option, + active: Option, +) -> Option { + match pending { + Some(UploadGraph::V2(query)) => Some(query), + Some(UploadGraph::V1 | UploadGraph::Immediate) => None, + None => match active { + Some(UploadGraph::V2(query)) => Some(query), + _ => None, + }, + } +} + +fn resolve_culling_frustum( + query: crate::render_graph::MeshQueryRuntimeKeyV2, + read: impl FnOnce() -> Option>, +) -> Result, crate::render_graph::GraphError> { + if query.frustum_culled == crate::render_graph::TriStatePredicate::Any { + return Ok(None); + } + match read() { + Some(Ok(planes)) => Ok(Some(planes)), + Some(Err(error)) => Err(crate::render_graph::GraphError::new( + "GRAPH_EXECUTION_FAILED", + format!("camera frustum is invalid: {error}"), + )), + None => Err(crate::render_graph::GraphError::new( + "GRAPH_EXECUTION_FAILED", + "culling graph requires a camera frustum, but the scene has no camera", + )), + } +} + +fn update_validate_write_scene( + scene: &mut S, + queue: &wgpu::Queue, + query: Option, +) -> Result, crate::render_graph::GraphError> { + scene.update_cpu(); + let planes = match query { + Some(query) => resolve_culling_frustum(query, || scene.frustum_planes())?, + None => None, + }; + scene.write_uniforms(queue); + Ok(planes) +} +impl ActiveCompiledGraph { + fn id(&self) -> crate::render_graph::CompiledGraphId { + match self { + Self::V1(a) => a.id, + Self::V2(a) => a.id, + } + } + fn graph_id(&self) -> &str { + match self { + Self::V1(a) => &a.graph.graph_id, + Self::V2(a) => &a.graph.graph_id, + } + } + fn revision(&self) -> u32 { + match self { + Self::V1(a) => a.graph.revision, + Self::V2(a) => a.graph.revision, + } + } + fn schema_version(&self) -> u32 { + match self { + Self::V1(_) => 1, + Self::V2(_) => 2, + } + } + fn execution_count(&self) -> usize { + match self { + Self::V1(a) => a.graph.passes.len(), + Self::V2(a) => a.graph.executions.len(), + } + } + fn texture_slot_count(&self) -> usize { + match self { + Self::V1(a) => a.views.len(), + Self::V2(a) => a.textures.iter().map(Vec::len).sum(), + } + } +} + struct PooledTransient { texture: wgpu::Texture, view: wgpu::TextureView, @@ -37,7 +178,189 @@ struct PooledTransient { enum SwitchTarget { Immediate, - Compiled(ActiveCompiled), + Compiled(ActiveCompiledGraph), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResolvedSwitchRequest { + Immediate, + Compiled(crate::render_graph::CompiledGraphId), +} + +fn resolve_switch_request( + registry: &crate::render_graph::Registry, + pending: bool, + mode: u32, + slot: u32, + generation: u32, +) -> Result { + if pending { + return Err(crate::render_graph::GraphError::new( + "GRAPH_SWITCH_PENDING", + "a graph switch is pending", + )); + } + match mode { + 0 if slot == 0 && generation == 0 => Ok(ResolvedSwitchRequest::Immediate), + 0 => Err(crate::render_graph::GraphError::new( + "STALE_GRAPH_ID", + "immediate mode requires a zero id", + )), + 1 => { + let id = crate::render_graph::CompiledGraphId { slot, generation }; + // Resolve the registry entry here, before any GPU preparation or pending + // state mutation. Registry::get is also the Phase 4 activation gate. + registry.get_registered(id)?; + Ok(ResolvedSwitchRequest::Compiled(id)) + } + _ => Err(crate::render_graph::GraphError::new( + "GRAPH_EXECUTION_UNSUPPORTED", + "unknown render mode", + )), + } +} + +#[cfg(test)] +mod switch_request_tests { + use super::*; + + fn query(visible: crate::render_graph::TriStatePredicate) -> UploadGraph { + UploadGraph::V2(crate::render_graph::MeshQueryRuntimeKeyV2 { + visible, + frustum_culled: crate::render_graph::TriStatePredicate::Any, + }) + } + + #[test] + fn upload_selection_follows_the_graph_rendered_for_the_commit_frame() { + use crate::render_graph::TriStatePredicate::{Any, RequiredFalse, RequiredTrue}; + let selected = + |pending, active| upload_query_for_render(pending, active).map(|query| query.visible); + assert_eq!( + selected(Some(query(RequiredFalse)), Some(query(RequiredTrue))), + Some(RequiredFalse) + ); + assert_eq!( + selected(Some(UploadGraph::V1), Some(query(RequiredTrue))), + None + ); + assert_eq!( + selected(Some(UploadGraph::Immediate), Some(query(RequiredTrue))), + None + ); + assert_eq!( + selected(None, Some(query(RequiredTrue))), + Some(RequiredTrue) + ); + assert_eq!(selected(None, Some(UploadGraph::V1)), None); + assert_eq!(selected(None, None), None); + assert_eq!(selected(Some(query(Any)), None), Some(Any)); + } + + #[test] + fn frustum_preflight_skips_any_and_distinguishes_missing_from_invalid() { + use crate::render_graph::TriStatePredicate::{Any, RequiredFalse, RequiredTrue}; + let query = |frustum_culled| crate::render_graph::MeshQueryRuntimeKeyV2 { + visible: RequiredTrue, + frustum_culled, + }; + let mut reads = 0; + assert_eq!( + resolve_culling_frustum(query(Any), || { + reads += 1; + None + }) + .unwrap(), + None + ); + assert_eq!( + reads, 0, + "inactive frustum filtering must not read the camera" + ); + let missing = resolve_culling_frustum(query(RequiredFalse), || None).unwrap_err(); + assert!(missing.message.contains("no camera")); + let invalid = resolve_culling_frustum(query(RequiredFalse), || { + Some(Err(crate::camera::FrustumError::Degenerate { plane: 2 })) + }) + .unwrap_err(); + assert_eq!(invalid.code, "GRAPH_EXECUTION_FAILED"); + assert!(invalid.message.contains("invalid")); + } + + #[test] + fn v2_resolves_at_command_boundary_before_gpu_work() { + let mut registry = crate::render_graph::Registry::default(); + let bytes = br#"{"schemaVersion":2,"graphId":"switch_v2","revision":1,"nodes":[]}"#; + let (id, _) = registry.compile(bytes).unwrap(); + let active = "existing_v1"; + let pending: Option<&str> = None; + assert_eq!( + resolve_switch_request(®istry, false, 1, id.slot, id.generation).unwrap(), + ResolvedSwitchRequest::Compiled(id) + ); + assert_eq!(active, "existing_v1"); + assert_eq!(pending, None); + assert!(registry.contains(id)); + + let pending_error = resolve_switch_request(®istry, true, 1, id.slot, id.generation) + .expect_err("an existing pending request must win"); + assert_eq!(pending_error.code, "GRAPH_SWITCH_PENDING"); + assert_eq!(active, "existing_v1"); + assert_eq!(pending, None); + + let invalid_replacement = br#"{"schemaVersion":2,"graphId":"switch_v2","revision":2,"nodes":[],"unexpected":true}"#; + assert_eq!( + registry.compile(invalid_replacement).unwrap_err().code, + "GRAPH_JSON_INVALID" + ); + let crate::render_graph::RegisteredGraph::V2(stored) = registry.get_registered(id).unwrap() + else { + panic!("the original V2 graph must remain registered") + }; + assert_eq!(stored.revision, 1); + + registry.drop_graph(id).unwrap(); + assert_eq!( + registry.get_registered(id).unwrap_err().code, + "STALE_GRAPH_ID" + ); + } + + #[test] + fn resize_restart_snapshot_remains_bound_to_its_immutable_registry_revision() { + let mut registry = crate::render_graph::Registry::default(); + let (id, _) = registry + .compile(br#"{"schemaVersion":2,"graphId":"resize","revision":1,"nodes":[]}"#) + .unwrap(); + let crate::render_graph::RegisteredGraph::V2(revision_one) = + registry.get_registered(id).unwrap().clone() + else { + panic!("expected V2 graph") + }; + let in_flight = InFlightV2Preparation { + token: 1, + id, + purpose: V2PreparationPurpose::Resize, + graph: revision_one, + }; + let (revision_two_id, _) = registry + .compile(br#"{"schemaVersion":2,"graphId":"resize","revision":2,"nodes":[]}"#) + .unwrap(); + let crate::render_graph::RegisteredGraph::V2(original) = + registry.get_registered(id).unwrap() + else { + panic!("expected V2 graph") + }; + let crate::render_graph::RegisteredGraph::V2(revision_two) = + registry.get_registered(revision_two_id).unwrap() + else { + panic!("expected V2 graph") + }; + assert_eq!(in_flight.graph.revision, 1); + assert_eq!(original.revision, 1); + assert_eq!(revision_two.revision, 2); + assert_ne!(id, revision_two_id); + } } struct PendingSwitch { @@ -45,6 +368,27 @@ struct PendingSwitch { target: SwitchTarget, } +#[derive(Clone, Copy)] +enum V2PreparationPurpose { + Switch { request: u32 }, + Resize, +} + +struct InFlightV2Preparation { + token: u64, + id: crate::render_graph::CompiledGraphId, + purpose: V2PreparationPurpose, + graph: crate::render_graph::CompiledGraphV2, +} + +struct V2PreparationCompletion { + token: u64, + purpose: V2PreparationPurpose, + candidate: Result, + validation_error: Option, + out_of_memory_error: Option, +} + struct CommandError { code: &'static str, details: JsValue, @@ -95,7 +439,7 @@ fn render_data_error_code(error: &crate::render_data::RenderDataError) -> &'stat } } -pub struct GpuResources { +/*pub struct GpuResources { // Core resources pipelines: Vec, @@ -247,6 +591,7 @@ impl Default for GpuResources { Self::new() } } +*/ pub struct RendererContext { pub device: wgpu::Device, @@ -266,16 +611,22 @@ pub struct Renderer { render_data: RenderData, snapshot: crate::shared_snapshot::SharedSnapshot, snapshot_init_sent: bool, + scene_frame: scene_frame::SceneFrameCache, gpu_scene: gpu_scene::GpuSceneCache, + materials: material::MaterialResources, pub(crate) command_ring: Option<&'static CommandRing>, pending_replies: Vec, gpu_error: std::sync::Arc, framing_radius: f32, graph_registry: crate::render_graph::Registry, - active_compiled: Option, + active_compiled: Option, pending_switch: Option, + in_flight_v2: Option, + next_v2_preparation_token: u64, + v2_preparation_completions: Rc>>, transient_pool: HashMap>, halted: bool, + profiler: profiler::Profiler, } impl Renderer { @@ -334,14 +685,15 @@ impl Renderer { generation: words[3], }; let outcome = - if self.active_compiled.as_ref().is_some_and(|a| a.id == id) { + if self.active_compiled.as_ref().is_some_and(|a| a.id() == id) { Err(crate::render_graph::GraphError::new( "GRAPH_ACTIVE", "compiled graph is active", )) } else if self.pending_switch.as_ref().is_some_and( - |p| matches!(&p.target, SwitchTarget::Compiled(a) if a.id == id), - ) { + |p| matches!(&p.target, SwitchTarget::Compiled(a) if a.id() == id), + ) || self.in_flight_v2.as_ref().is_some_and(|p| p.id == id) + { Err(crate::render_graph::GraphError::new( "GRAPH_SWITCH_PENDING", "compiled graph switch is pending", @@ -355,41 +707,42 @@ impl Renderer { } continue; } else if opcode == 9 { - let outcome = if self.pending_switch.is_some() { - Err(crate::render_graph::GraphError::new( - "GRAPH_SWITCH_PENDING", - "a graph switch is pending", - )) - } else if words[2] == 0 { - if words[3] != 0 || words[4] != 0 { - Err(crate::render_graph::GraphError::new( - "STALE_GRAPH_ID", - "immediate mode requires a zero id", - )) - } else { + let outcome = resolve_switch_request( + &self.graph_registry, + self.pending_switch.is_some() || self.in_flight_v2.is_some(), + words[2], + words[3], + words[4], + ) + .and_then(|target| match target { + ResolvedSwitchRequest::Immediate => { self.pending_switch = Some(PendingSwitch { request, target: SwitchTarget::Immediate, }); Ok(()) } - } else if words[2] == 1 { - let id = crate::render_graph::CompiledGraphId { - slot: words[3], - generation: words[4], - }; - self.prepare_compiled(id).map(|active| { - self.pending_switch = Some(PendingSwitch { - request, - target: SwitchTarget::Compiled(active), - }) - }) - } else { - Err(crate::render_graph::GraphError::new( - "GRAPH_EXECUTION_UNSUPPORTED", - "unknown render mode", - )) - }; + ResolvedSwitchRequest::Compiled(id) => { + match self.graph_registry.get_registered(id)?.clone() { + crate::render_graph::RegisteredGraph::V1(graph) => { + self.prepare_compiled_snapshot(id, graph).map(|active| { + self.pending_switch = Some(PendingSwitch { + request, + target: SwitchTarget::Compiled(ActiveCompiledGraph::V1( + active, + )), + }) + }) + } + crate::render_graph::RegisteredGraph::V2(graph) => self + .begin_compiled_v2_preparation( + id, + graph, + V2PreparationPurpose::Switch { request }, + ), + } + } + }); if let Err(error) = outcome { self.reply(request, Err(error.into())); } @@ -401,28 +754,21 @@ impl Renderer { return Err("INVALID_FRAMING"); } let bytes = crate::take_payload(words[2]).ok_or("PAYLOAD_MISSING")?; - let imported = decode_gltf(&bytes).map_err(|_| "GLB_INVALID")?; - let layout = gpu_scene::vertex_layouts(); - let culled_pipeline = self.resources.get_or_create_pipeline( - &self.context.device, - "gltf_standard", - &layout, - include_str!("../gltf.wgsl"), - self.context.surface_config.format, - ); - let double_sided_pipeline = self.resources.get_or_create_pipeline( - &self.context.device, - "gltf_standard_double_sided", - &layout, - include_str!("../gltf.wgsl"), - self.context.surface_config.format, - ); - let installed = install_imported( - &mut self.render_data, - &imported, - [culled_pipeline, double_sided_pipeline], - ) - .map_err(|_| "INSTALL_FAILED")?; + let imported = + crate::gltf::decode_gltf_owned(bytes).map_err(|_| "GLB_INVALID")?; + let pipelines = Self::ensure_gltf_pipelines(&mut self.resources, &self.context); + // Build a complete GPU candidate first. Neither the live scene nor + // its material epoch changes if image decode/resource creation fails. + let prepared_materials = self + .materials + .prepare(&self.context.device, &self.context.queue, &imported) + .map_err(|_| "MATERIAL_INVALID")?; + let installed = install_imported(&mut self.render_data, &imported, pipelines) + .map_err(|_| "INSTALL_FAILED")?; + // RenderData replacement and material publication are adjacent in + // this synchronous command, preventing a frame with mixed assets. + self.materials + .install(prepared_materials, self.render_data.revision()); if let Some(ModelBounds { min, max }) = installed.bounds { let center = ultraviolet::Vec3::new( (min[0] + max[0]) * 0.5, @@ -564,19 +910,33 @@ impl Renderer { self.context.depth_view = view; } - fn prepare_compiled( - &mut self, - id: crate::render_graph::CompiledGraphId, - ) -> Result { - let graph = self.graph_registry.get(id)?.clone(); - self.prepare_compiled_snapshot(id, graph) + fn ensure_gltf_pipelines( + resources: &mut GpuResources, + context: &RendererContext, + ) -> [crate::render_data::PipelineKey; 2] { + let layout = gpu_scene::vertex_layouts(); + let culled = resources.get_or_create_pipeline( + &context.device, + "gltf_standard", + &layout, + include_str!("../gltf.wgsl"), + context.surface_config.format, + ); + let double_sided = resources.get_or_create_pipeline( + &context.device, + "gltf_standard_double_sided", + &layout, + include_str!("../gltf.wgsl"), + context.surface_config.format, + ); + [culled, double_sided] } fn prepare_compiled_snapshot( &mut self, id: crate::render_graph::CompiledGraphId, graph: crate::render_graph::CompiledGraph, - ) -> Result { + ) -> Result { crate::render_graph::validate_activatable(&graph)?; let surface = [ self.context.surface_config.width, @@ -660,7 +1020,7 @@ impl Renderer { views.push(bucket[slot].view.clone()); } } - Ok(ActiveCompiled { + Ok(ActiveCompiledV1 { id, graph, _textures: textures, @@ -669,8 +1029,482 @@ impl Renderer { }) } + fn plan_compiled_v2( + &self, + graph: &crate::render_graph::CompiledGraphV2, + ) -> Result { + crate::render_graph::prepare_runtime_plan_v2( + graph, + crate::render_graph::RuntimeSurfaceContractV2 { + format: self.context.surface_config.format, + width: self.context.surface_config.width, + height: self.context.surface_config.height, + usage: self.context.surface_config.usage, + view_formats: self.context.surface_config.view_formats.clone(), + }, + Some(&self.context.device.limits()), + ) + } + + fn create_compiled_v2_candidate( + &mut self, + id: crate::render_graph::CompiledGraphId, + graph: crate::render_graph::CompiledGraphV2, + runtime: crate::render_graph::RuntimePlanV2, + ) -> Result { + use crate::render_graph::*; + let fail = |message| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", message); + let mut textures = Vec::with_capacity(runtime.allocations.classes.len()); + for class in &runtime.allocations.classes { + let mut gpu_class = Vec::with_capacity(class.slots.len()); + for slot in &class.slots { + let d = &slot.descriptor; + let texture = self + .context + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("V2 graph texture"), + size: wgpu::Extent3d { + width: d.extent.width, + height: d.extent.height, + depth_or_array_layers: d.extent.depth_or_array_layers, + }, + mip_level_count: d.mip_level_count, + sample_count: d.sample_count, + dimension: d.dimension, + format: d.format, + usage: d.usage, + view_formats: &d.view_formats, + }); + let view = texture.create_view(&Default::default()); + gpu_class.push(GpuTextureSlotV2 { + _texture: texture, + view, + }); + } + textures.push(gpu_class); + } + let resolve = |resource: u32| -> Result<&wgpu::TextureView, GraphError> { + let allocation = runtime + .allocations + .resource_allocations + .get(resource as usize) + .copied() + .flatten() + .ok_or_else(|| fail("resource has no GPU allocation"))?; + textures + .get(allocation.class as usize) + .and_then(|c| c.get(allocation.slot as usize)) + .map(|s| &s.view) + .ok_or_else(|| fail("resource allocation is out of bounds")) + }; + let fullscreen_layout = + self.context + .device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("V2 fullscreen texture"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + let pipeline_layout = + self.context + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("V2 fullscreen"), + bind_group_layouts: &[&fullscreen_layout], + push_constant_ranges: &[], + }); + let shader = self + .context + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("V2 fullscreen"), + source: wgpu::ShaderSource::Wgsl(include_str!("fullscreen_copy.wgsl").into()), + }); + let sampler = self + .context + .device + .create_sampler(&wgpu::SamplerDescriptor { + label: Some("V2 post linear clamp"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let mut executions = Vec::new(); + for (index, execution) in graph.executions.iter().enumerate() { + match execution.executor.key.as_str() { + "frustum_cull" => executions.push(PreparedExecutionV2::FrustumCull), + "mesh_query" => executions.push(PreparedExecutionV2::MeshQuery), + "present" => executions.push(PreparedExecutionV2::Present), + "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" + | "bloom_composite" | "luminance_edge" => { + let sampled: Vec<_> = execution + .accesses + .iter() + .filter(|a| matches!(a.mode, AccessModeV2::SampledTexture)) + .map(|a| a.resource) + .collect(); + let source = *sampled + .first() + .ok_or_else(|| fail("fullscreen source missing"))?; + let second = *sampled.get(1).unwrap_or(&source); + let values: [f32; 8] = match execution.parameters { + NormalizedParametersV2::ToneMap { exposure } => { + [exposure, 0., 0., 0., 0., 0., 0., 0.] + } + NormalizedParametersV2::BloomExtract { threshold, knee } => { + [threshold, knee, 0., 0., 0., 0., 0., 0.] + } + NormalizedParametersV2::BloomBlur { direction, radius } => { + [direction[0], direction[1], radius, 0., 0., 0., 0., 0.] + } + NormalizedParametersV2::BloomComposite { intensity } => { + [intensity, 0., 0., 0., 0., 0., 0., 0.] + } + NormalizedParametersV2::LuminanceEdge { strength } => { + [strength, 0., 0., 0., 0., 0., 0., 0.] + } + _ => [0.; 8], + }; + use wgpu::util::DeviceExt; + let uniform = + self.context + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("V2 post parameters"), + contents: bytemuck::cast_slice(&values), + usage: wgpu::BufferUsages::UNIFORM, + }); + let ExecutionKindV2::Render { + color_attachments, .. + } = &execution.kind + else { + return Err(fail("fullscreen execution is not render")); + }; + let target = color_attachments + .first() + .ok_or_else(|| fail("fullscreen target missing"))? + .resource; + let target_format = if graph.resources.get(target as usize).is_some_and(|r| matches!(r.plan, ResourcePlanV2::Texture { family, .. } if family == runtime.allocations.surface_family)) { runtime.surface.format } else { let a=runtime.allocations.resource_allocations[target as usize].ok_or_else(|| fail("fullscreen target allocation missing"))?; runtime.allocations.classes[a.class as usize].slots[a.slot as usize].descriptor.format }; + let entry = match execution.executor.key.as_str() { + "fullscreen_copy" => "fs_copy", + "tone_map" => "fs_tone_map", + "bloom_extract" => "fs_bloom_extract", + "bloom_blur" => "fs_bloom_blur", + "bloom_composite" => "fs_bloom_composite", + "luminance_edge" => "fs_luminance_edge", + _ => unreachable!(), + }; + let pipeline = self.context.device.create_render_pipeline( + &wgpu::RenderPipelineDescriptor { + label: Some("V2 post pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some(entry), + targets: &[Some(wgpu::ColorTargetState { + format: target_format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + multiview: None, + cache: None, + }, + ); + let bind_group = + self.context + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("V2 fullscreen source"), + layout: &fullscreen_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(resolve( + source, + )?), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(resolve( + second, + )?), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: uniform.as_entire_binding(), + }, + ], + }); + executions.push(PreparedExecutionV2::Fullscreen { + execution: index, + bind_group, + pipeline, + _uniform: uniform, + }); + } + "legacy_forward" => { + let ExecutionKindV2::Render { + color_attachments, + depth_stencil, + } = &execution.kind + else { + return Err(fail("legacy forward is not render")); + }; + let color = color_attachments + .first() + .ok_or_else(|| fail("legacy color missing"))?; + let color_is_surface = graph + .resources + .get(color.resource as usize) + .is_some_and(|resource| { + matches!( + resource.plan, + ResourcePlanV2::SurfaceTarget { family } + | ResourcePlanV2::Texture { family, .. } + if family == runtime.allocations.surface_family + ) + }); + let color_format = if color_is_surface { + runtime.surface.format + } else { + let a = runtime + .allocations + .resource_allocations + .get(color.resource as usize) + .copied() + .flatten() + .ok_or_else(|| fail("color allocation missing"))?; + runtime + .allocations + .classes + .get(a.class as usize) + .and_then(|c| c.slots.get(a.slot as usize)) + .map(|s| s.descriptor.format) + .ok_or_else(|| fail("color allocation invalid"))? + }; + let depth_format = depth_stencil + .as_ref() + .map(|d| { + let a = runtime + .allocations + .resource_allocations + .get(d.resource as usize) + .copied() + .flatten() + .ok_or_else(|| fail("depth allocation missing"))?; + runtime + .allocations + .classes + .get(a.class as usize) + .and_then(|c| c.slots.get(a.slot as usize)) + .map(|s| s.descriptor.format) + .ok_or_else(|| fail("depth allocation invalid")) + }) + .transpose()?; + let config = execution + .inputs + .iter() + .filter_map(|i| graph.resources.get(i.resource as usize)) + .find_map(|r| { + if let ResourcePlanV2::DepthStencilConfig { config } = r.plan { + Some(config) + } else { + None + } + }) + .ok_or_else(|| fail("depth config missing"))?; + let compare = match config.depth_compare { + CompareFunctionV2::Never => wgpu::CompareFunction::Never, + CompareFunctionV2::Less => wgpu::CompareFunction::Less, + CompareFunctionV2::LessEqual => wgpu::CompareFunction::LessEqual, + CompareFunctionV2::Greater => wgpu::CompareFunction::Greater, + CompareFunctionV2::GreaterEqual => wgpu::CompareFunction::GreaterEqual, + CompareFunctionV2::Equal => wgpu::CompareFunction::Equal, + CompareFunctionV2::NotEqual => wgpu::CompareFunction::NotEqual, + CompareFunctionV2::Always => wgpu::CompareFunction::Always, + }; + let mut variants = Vec::new(); + let bases: Vec<_> = self.resources.pipeline_keys().collect(); + for base in bases { + let variant = self + .resources + .create_target_variant( + &self.context.device, + base, + color_format, + depth_format, + compare, + config.depth_write_enabled, + ) + .map_err(|e| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", e))?; + variants.push((base, variant)); + } + executions.push(PreparedExecutionV2::LegacyForward { + execution: index, + variants, + }); + } + _ => return Err(fail("unsupported prepared execution")), + } + } + Ok(ActiveCompiledV2 { + id, + graph, + runtime, + textures, + executions, + _fullscreen_layout: fullscreen_layout, + }) + } + + fn begin_compiled_v2_preparation( + &mut self, + id: crate::render_graph::CompiledGraphId, + graph: crate::render_graph::CompiledGraphV2, + purpose: V2PreparationPurpose, + ) -> Result<(), crate::render_graph::GraphError> { + let runtime = self.plan_compiled_v2(&graph)?; + // Candidate construction allocates GPU resources, so the live scene preflight + // belongs here: this is the earliest boundary with both the runtime query and + // scene access, and precedes GPU work and all pending/in-flight mutation. + resolve_culling_frustum(runtime.allocations.query, || self.scene.frustum_planes())?; + let restart_graph = graph.clone(); + self.next_v2_preparation_token = self.next_v2_preparation_token.wrapping_add(1).max(1); + let token = self.next_v2_preparation_token; + self.context + .device + .push_error_scope(wgpu::ErrorFilter::OutOfMemory); + self.context + .device + .push_error_scope(wgpu::ErrorFilter::Validation); + let candidate = self.create_compiled_v2_candidate(id, graph, runtime); + let validation = self.context.device.pop_error_scope(); + let out_of_memory = self.context.device.pop_error_scope(); + self.in_flight_v2 = Some(InFlightV2Preparation { + token, + id, + purpose, + graph: restart_graph, + }); + let completions = self.v2_preparation_completions.clone(); + spawn_local(async move { + let validation_error = validation.await.map(|error| error.to_string()); + let out_of_memory_error = out_of_memory.await.map(|error| error.to_string()); + completions.borrow_mut().push(V2PreparationCompletion { + token, + purpose, + candidate, + validation_error, + out_of_memory_error, + }); + }); + Ok(()) + } + + fn drain_v2_preparation_completions(&mut self) { + let completions = std::mem::take(&mut *self.v2_preparation_completions.borrow_mut()); + for completion in completions { + let Some(in_flight) = self.in_flight_v2.as_ref() else { + continue; + }; + if in_flight.token != completion.token { + continue; + } + self.in_flight_v2 = None; + let result = if let Some(message) = completion.out_of_memory_error { + Err(crate::render_graph::GraphError::new( + "GRAPH_RESOURCE_LIMIT", + message, + )) + } else if let Some(message) = completion.validation_error { + Err(crate::render_graph::GraphError::new( + "GRAPH_RUNTIME_PLAN_INVALID", + message, + )) + } else { + completion.candidate + }; + match (completion.purpose, result) { + (V2PreparationPurpose::Switch { request }, Ok(candidate)) => { + self.pending_switch = Some(PendingSwitch { + request, + target: SwitchTarget::Compiled(ActiveCompiledGraph::V2(candidate)), + }); + } + (V2PreparationPurpose::Switch { request }, Err(error)) => { + self.reply(request, Err(error.into())); + } + (V2PreparationPurpose::Resize, Ok(candidate)) => { + self.active_compiled = Some(ActiveCompiledGraph::V2(candidate)); + } + (V2PreparationPurpose::Resize, Err(error)) => { + log::error!( + "compiled graph resize preparation failed: {}", + error.message + ); + } + } + } + } + #[cfg(target_arch = "wasm32")] - pub async fn new(canvas: web_sys::OffscreenCanvas, events_chan: Receiver) -> Self { + pub async fn new( + canvas: web_sys::OffscreenCanvas, + events_chan: Receiver, + profile: bool, + ) -> Self { let id = wgpu::InstanceDescriptor { backends: wgpu::Backends::BROWSER_WEBGPU, ..Default::default() @@ -680,7 +1514,7 @@ impl Renderer { let surface = instance .create_surface(wgpu::SurfaceTarget::OffscreenCanvas(canvas.clone())) .unwrap(); - let adapter = instance + let mut adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { compatible_surface: Some(&surface), force_fallback_adapter: false, @@ -689,19 +1523,42 @@ impl Renderer { .await .unwrap(); - info!("Adapter info: {:?}", adapter.get_info()); - info!("Adapter features: {:?}", adapter.features()); - info!("Adapter limits: {:?}", adapter.limits()); - + let optional_features = profiler::Profiler::requested_features(profile, adapter.features()); let descriptor = wgpu::DeviceDescriptor { - required_features: wgpu::Features::empty(), + required_features: optional_features, required_limits: wgpu::Limits::default(), label: None, memory_hints: wgpu::MemoryHints::default(), trace: wgpu::Trace::default(), }; - let (device, queue) = adapter.request_device(&descriptor).await.unwrap(); + let (device, queue) = match adapter.request_device(&descriptor).await { + Ok(result) => result, + Err(error) if !optional_features.is_empty() => { + log::warn!("timestamp-enabled device request failed, retrying baseline: {error}"); + adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + compatible_surface: Some(&surface), + force_fallback_adapter: false, + ..Default::default() + }) + .await + .expect("surface-compatible adapter required for baseline device"); + let baseline = wgpu::DeviceDescriptor { + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + label: None, + memory_hints: wgpu::MemoryHints::default(), + trace: wgpu::Trace::default(), + }; + adapter.request_device(&baseline).await.unwrap() + } + Err(error) => panic!("baseline WebGPU device request failed: {error}"), + }; + info!("Adapter info: {:?}", adapter.get_info()); + info!("Adapter features: {:?}", adapter.features()); + info!("Adapter limits: {:?}", adapter.limits()); + let profiler = profiler::Profiler::new(profile, &device, &queue).await; let gpu_error = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let error_flag = gpu_error.clone(); device.on_uncaptured_error(Box::new(move |error| { @@ -741,6 +1598,9 @@ impl Renderer { let mut render_data = RenderData::new(RenderDataConfig::default()).expect("valid render data config"); let scene = T::setup(&context, &mut resources, &mut render_data); + let materials = material::MaterialResources::new(&context.device, &context.queue); + resources.set_material_bind_group_layout(&materials.layout); + Self::ensure_gltf_pipelines(&mut resources, &context); Self { canvas, @@ -751,7 +1611,9 @@ impl Renderer { render_data, snapshot: crate::shared_snapshot::SharedSnapshot::new(), snapshot_init_sent: false, + scene_frame: Default::default(), gpu_scene: Default::default(), + materials, command_ring: None, pending_replies: Vec::new(), gpu_error, @@ -759,8 +1621,12 @@ impl Renderer { graph_registry: Default::default(), active_compiled: None, pending_switch: None, + in_flight_v2: None, + next_v2_preparation_token: 0, + v2_preparation_completions: Default::default(), transient_pool: HashMap::new(), halted: false, + profiler, } } @@ -768,6 +1634,7 @@ impl Renderer { if self.halted { return; } + self.drain_v2_preparation_completions(); if self .gpu_error .swap(false, std::sync::atomic::Ordering::AcqRel) @@ -779,6 +1646,15 @@ impl Renderer { if !self.drain_commands() { return; } + let frame_plan = match self.scene_frame.get_or_build(&self.render_data) { + Ok(plan) => plan, + Err(error) => { + log::error!("scene frame extraction failed: {error}"); + self.halted = true; + self.post_fatal("SCENE_FRAME_FAILED", &error.to_string()); + return; + } + }; let global = js_sys::global().unchecked_into::(); if !self.snapshot_init_sent { let message = js_sys::Object::new(); @@ -793,7 +1669,7 @@ impl Renderer { let _ = global.post_message(&message); self.snapshot_init_sent = true; } - match self.snapshot.publish(&self.render_data) { + match self.snapshot.publish(frame_plan) { Ok(Some(epoch)) => { let message = js_sys::Object::new(); let _ = @@ -804,15 +1680,48 @@ impl Renderer { Ok(None) => {} Err(error) => log::error!("picking snapshot failed closed with error {error}"), } - self.scene.update(&self.context); - if let Err(error) = - self.gpu_scene - .upload(&self.context.device, &self.context.queue, &self.render_data) + let pending = self.pending_switch.as_ref().map(|p| match &p.target { + SwitchTarget::Immediate => UploadGraph::Immediate, + SwitchTarget::Compiled(graph) => classify_upload_graph(graph), + }); + let query = upload_query_for_render( + pending, + self.active_compiled.as_ref().map(classify_upload_graph), + ); + // Resolve again immediately before every active frame. Do this before scene + // upload so an invalid camera cannot mutate GPU state or produce a frame. + let planes = match update_validate_write_scene(&mut self.scene, &self.context.queue, query) { + Ok(planes) => planes, + Err(error) => { + if let Some(pending) = self.pending_switch.take() { + self.reply(pending.request, Err(error.into())); + } else { + self.post_fatal("GRAPH_EXECUTION_FAILED", &error.message); + } + return; + } + }; + let upload = if let Some(query) = query { + self.gpu_scene.upload_with_query( + &self.context.device, + &self.context.queue, + frame_plan, + query, + ) + } else { + self.gpu_scene + .upload(&self.context.device, &self.context.queue, frame_plan) + }; + if let Err(error) = upload { log::error!("GPU scene upload failed: {error}"); self.post_fatal("GPU_UPLOAD_FAILED", &error); return; } + if let Some(query) = query { + self.gpu_scene + .write_culling_params(&self.context.queue, planes, query); + } let surface_texture = match self.context.surface.get_current_texture() { Ok(value) => value, @@ -834,112 +1743,76 @@ impl Renderer { Some(SwitchTarget::Immediate) => None, None => self.active_compiled.as_ref(), }; - if let Some(active) = rendering_compiled { - for pass in &active.graph.passes { - let depth_resource = pass - .writes - .iter() - .find(|w| w.binding == "depth") - .unwrap() - .resource as usize; - let allocation = active.graph.resources[depth_resource].allocation.unwrap(); - let depth_view = &active.views - [active.class_bases[allocation.class as usize] + allocation.slot as usize]; - let color = pass.writes.iter().find(|w| w.binding == "color").unwrap(); - let depth = pass.writes.iter().find(|w| w.binding == "depth").unwrap(); - let (color_load, color_store) = match &color.access { - crate::render_graph::WriteAccess::ColorAttachment { load, store, .. } => ( - match load { - crate::render_graph::ColorLoad::Clear { value } => { - wgpu::LoadOp::Clear(wgpu::Color { - r: value[0], - g: value[1], - b: value[2], - a: value[3], - }) - } - crate::render_graph::ColorLoad::Load => wgpu::LoadOp::Load, - }, - if matches!(store, crate::render_graph::StoreOp::Store) { - wgpu::StoreOp::Store - } else { - wgpu::StoreOp::Discard - }, - ), - _ => unreachable!(), - }; - let (depth_load, depth_store) = match &depth.access { - crate::render_graph::WriteAccess::DepthAttachment { load, store } => ( - match load { - crate::render_graph::DepthLoad::Clear { value } => { - wgpu::LoadOp::Clear(*value) - } - crate::render_graph::DepthLoad::Load => wgpu::LoadOp::Load, - }, - if matches!(store, crate::render_graph::StoreOp::Store) { - wgpu::StoreOp::Store - } else { - wgpu::StoreOp::Discard - }, - ), - _ => unreachable!(), - }; - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some(&pass.id), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - depth_slice: None, - view: &texture_view, - resolve_target: None, - ops: wgpu::Operations { - load: color_load, - store: color_store, - }, - })], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: depth_view, - depth_ops: Some(wgpu::Operations { - load: depth_load, - store: depth_store, - }), - stencil_ops: None, - }), - occlusion_query_set: None, - timestamp_writes: None, - }); - self.encode_scene_forward(&mut render_pass); + let mut profile_frame = self.profiler.begin(|| match rendering_compiled { + None => "immediate".to_owned(), + Some(ActiveCompiledGraph::V1(active)) => format!( + "v1:{}:{}:{}:{}", + active.graph.graph_id, active.graph.revision, active.id.slot, active.id.generation + ), + Some(ActiveCompiledGraph::V2(active)) => format!( + "v2:{}:{}:{}:{}", + active.graph.graph_id, active.graph.revision, active.id.slot, active.id.generation + ), + }); + let encode_result = if let Some(active) = rendering_compiled { + match active { + ActiveCompiledGraph::V1(active) => { + executors::encode_compiled_v1( + &mut encoder, + &texture_view, + active, + &self.scene, + &self.gpu_scene, + &self.resources, + &self.materials, + profile_frame.as_mut(), + ); + Ok(()) + } + ActiveCompiledGraph::V2(active) => executors::encode_compiled_v2( + &mut encoder, + &texture_view, + active, + &self.scene, + &self.gpu_scene, + &self.resources, + &self.materials, + profile_frame.as_mut(), + ), } } else { - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Render pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - depth_slice: None, - view: &texture_view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 1.0, - }), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &self.context.depth_view, - depth_ops: Some(wgpu::Operations { - load: wgpu::LoadOp::Clear(1.0), - store: wgpu::StoreOp::Store, - }), - stencil_ops: None, - }), - occlusion_query_set: None, - timestamp_writes: None, - }); - - self.encode_scene_forward(&mut render_pass); + executors::encode_immediate( + &mut encoder, + &texture_view, + &self.context.depth_view, + &self.scene, + &self.gpu_scene, + &self.resources, + &self.materials, + profile_frame.as_mut(), + ); + Ok(()) + }; + if let Err(error) = encode_result { + if let Some(frame) = profile_frame.take() { + self.profiler.cancel(frame); + } + if let Some(pending) = self.pending_switch.take() { + self.reply( + pending.request, + Err( + crate::render_graph::GraphError::new("GRAPH_EXECUTION_FAILED", error) + .into(), + ), + ); + } + return; } + let profile_map = profile_frame.and_then(|frame| self.profiler.finish(&mut encoder, frame)); self.context.queue.submit(std::iter::once(encoder.finish())); + if let Some(request) = profile_map { + self.profiler.map(request); + } surface_texture.present(); if let Some(pending) = self.pending_switch.take() { let result = match pending.target { @@ -950,9 +1823,10 @@ impl Renderer { SwitchTarget::Compiled(active) => { let summary = serde_json::json!({ "mode":"compiled", - "compiledId":[active.id.slot, active.id.generation], - "graphId":active.graph.graph_id, - "revision":active.graph.revision + "compiledId":[active.id().slot, active.id().generation], + "graphId":active.graph_id(), + "revision":active.revision(), + "schemaVersion":active.schema_version() }); self.active_compiled = Some(active); js_sys::JSON::parse(&summary.to_string()).unwrap_or(JsValue::NULL) @@ -979,7 +1853,7 @@ impl Renderer { .sum(); let active = self.active_compiled.as_ref(); let active_id = active - .map(|a| js_sys::Array::of2(&a.id.slot.into(), &a.id.generation.into()).into()) + .map(|a| js_sys::Array::of2(&a.id().slot.into(), &a.id().generation.into()).into()) .unwrap_or(JsValue::NULL); for (key, value) in [ ("type", "telemetry".into()), @@ -999,21 +1873,36 @@ impl Renderer { }, ), ("activeCompiledId", active_id), + ( + "activeCompiledSchemaVersion", + active.map(|a| a.schema_version()).unwrap_or(0).into(), + ), ( "activeCompiledGraph", - active - .map(|a| a.graph.graph_id.as_str()) - .unwrap_or("") - .into(), + active.map(|a| a.graph_id()).unwrap_or("").into(), ), ( "activeCompiledRevision", - active.map(|a| a.graph.revision).unwrap_or(0).into(), + active.map(|a| a.revision()).unwrap_or(0).into(), ), ( "graphPasses", active - .map(|a| a.graph.passes.len() as u32) + .map(|a| a.execution_count() as u32) + .unwrap_or(0) + .into(), + ), + ( + "graphExecutions", + active + .map(|a| a.execution_count() as u32) + .unwrap_or(0) + .into(), + ), + ( + "graphTextureSlots", + active + .map(|a| a.texture_slot_count() as u32) .unwrap_or(0) .into(), ), @@ -1031,32 +1920,8 @@ impl Renderer { let _ = js_sys::Reflect::set(&telemetry, &key.into(), &value); } let _ = global.post_message(&telemetry); - } - - fn encode_scene_forward<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>) { - for (i, bind_group) in self.scene.bind_groups().iter().enumerate() { - render_pass.set_bind_group(i as u32, bind_group, &[]); - } - if let (Some(p), Some(n), Some(u), Some(i), Some(inst)) = ( - &self.gpu_scene.positions.buffer, - &self.gpu_scene.normals.buffer, - &self.gpu_scene.uvs.buffer, - &self.gpu_scene.indices.buffer, - &self.gpu_scene.instances.buffer, - ) { - render_pass.set_vertex_buffer(0, p.slice(..)); - render_pass.set_vertex_buffer(1, n.slice(..)); - render_pass.set_vertex_buffer(2, u.slice(..)); - render_pass.set_vertex_buffer(3, inst.slice(..)); - render_pass.set_index_buffer(i.slice(..), wgpu::IndexFormat::Uint32); - for draw in &self.gpu_scene.draws { - render_pass.set_pipeline(self.resources.get_pipeline(draw.pipeline)); - render_pass.draw_indexed( - draw.indices.clone(), - draw.base_vertex, - draw.instances.clone(), - ); - } + if let Some(snapshot) = self.profiler.snapshot_json(js_sys::Date::now()) { + let _ = global.post_message(&snapshot); } } @@ -1202,7 +2067,7 @@ impl Renderer { } WindowEvent::PointerWheel(msg) => { let mut r = renderer.borrow_mut(); - r.scene.handle_zoom(msg.delta_y as f32); + r.scene.handle_zoom(msg.delta_y_pixels); } WindowEvent::Keyboard(_) => {} } @@ -1270,16 +2135,68 @@ impl Renderer { // The executable subset uses surface-relative transients exclusively. // Dropping old buckets prevents stale-size reuse and bounds resize growth. self.transient_pool.clear(); + if let Some(pending) = self.pending_switch.take() { + self.reply( + pending.request, + Err(crate::render_graph::GraphError::new( + "GRAPH_SWITCH_INVALIDATED", + "graph switch invalidated by resize", + ) + .into()), + ); + } + let interrupted_resize = + self.in_flight_v2 + .take() + .and_then(|preparation| match preparation.purpose { + V2PreparationPurpose::Switch { request } => { + self.reply( + request, + Err(crate::render_graph::GraphError::new( + "GRAPH_SWITCH_INVALIDATED", + "graph switch invalidated by resize", + ) + .into()), + ); + None + } + V2PreparationPurpose::Resize => Some((preparation.id, preparation.graph)), + }); + let mut restarted_v2 = false; if let Some(old) = self.active_compiled.take() { - let id = old.id; - let graph = old.graph; + let id = old.id(); // Keep immediate resources live and fall back for this frame if recreation fails. - match self.prepare_compiled_snapshot(id, graph) { - Ok(active) => self.active_compiled = Some(active), - Err(error) => log::error!( + match old { + ActiveCompiledGraph::V1(a) => self + .prepare_compiled_snapshot(id, a.graph) + .map(ActiveCompiledGraph::V1) + .map(|active| self.active_compiled = Some(active)), + ActiveCompiledGraph::V2(a) => { + restarted_v2 = true; + self.begin_compiled_v2_preparation( + id, + a.graph, + V2PreparationPurpose::Resize, + ) + } + } + .unwrap_or_else(|error| { + log::error!( "compiled graph resize preparation failed: {}", error.message - ), + ) + }); + } + if !restarted_v2 { + if let Some((id, graph)) = interrupted_resize { + if let Err(error) = + self.begin_compiled_v2_preparation(id, graph, V2PreparationPurpose::Resize) + { + log::error!( + "compiled graph resize preparation failed: {}", + error.message + ); + } } } @@ -1298,10 +2215,15 @@ impl Renderer { } pub fn mouse_move(&mut self, msg: MouseMessage) { - if (msg.buttons & 0x04) != 0 { - let delta_x = (msg.movement_x * msg.scale_factor) as f32; - let delta_y = (msg.movement_y * msg.scale_factor) as f32; - self.scene.handle_orbit(delta_x, delta_y); + let delta_x = msg.movement_x as f32; + let delta_y = msg.movement_y as f32; + match camera_drag(msg.buttons) { + Some(CameraDrag::Orbit) => self.scene.handle_orbit(delta_x, delta_y), + Some(CameraDrag::Pan) => { + self.scene + .handle_pan(delta_x, delta_y, msg.viewport_height as f32); + } + None => {} } } } diff --git a/renderer/src/renderer/pipeline_library.rs b/renderer/src/renderer/pipeline_library.rs new file mode 100644 index 0000000..6dd0eb5 --- /dev/null +++ b/renderer/src/renderer/pipeline_library.rs @@ -0,0 +1,575 @@ +use std::{collections::HashMap, num::NonZeroU32}; + +use crate::render_data::PipelineKey; + +use super::DEPTH_FORMAT; + +/// Identity of a set of bind-group layouts registered with this library. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub struct PipelineLayoutKey(u64); + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct OwnedVertexBufferLayout { + pub array_stride: u64, + pub step_mode: wgpu::VertexStepMode, + pub attributes: Vec, +} + +#[derive(Clone, Debug)] +pub struct OwnedProgrammableStage { + pub shader_source: String, + pub entry_point: String, + pub constants: Vec<(String, f64)>, + pub zero_initialize_workgroup_memory: bool, +} + +#[derive(Clone, Debug)] +pub struct RenderPipelineSpec { + pub layout: Option, + pub vertex: OwnedProgrammableStage, + pub vertex_layouts: Vec, + pub fragment: Option, + pub primitive: wgpu::PrimitiveState, + pub depth_stencil: Option, + pub multisample: wgpu::MultisampleState, + pub targets: Vec>, + pub multiview: Option, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +struct StageKey { + shader_source: String, + entry_point: String, + constants: Vec<(String, u64)>, + zero_initialize_workgroup_memory: bool, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +struct RenderPipelineKey { + layout: Option, + vertex: StageKey, + vertex_layouts: Vec, + fragment: Option, + primitive: wgpu::PrimitiveState, + depth_stencil: Option, + multisample: wgpu::MultisampleState, + targets: Vec>, + multiview: Option, +} + +impl StageKey { + fn from_stage(stage: &OwnedProgrammableStage) -> Self { + let mut constants: Vec<_> = stage + .constants + .iter() + .map(|(name, value)| (name.clone(), value.to_bits())) + .collect(); + constants.sort_by(|a, b| a.0.cmp(&b.0)); + Self { + shader_source: stage.shader_source.clone(), + entry_point: stage.entry_point.clone(), + constants, + zero_initialize_workgroup_memory: stage.zero_initialize_workgroup_memory, + } + } +} + +impl RenderPipelineSpec { + fn key(&self) -> RenderPipelineKey { + RenderPipelineKey { + layout: self.layout, + vertex: StageKey::from_stage(&self.vertex), + vertex_layouts: self.vertex_layouts.clone(), + fragment: self.fragment.as_ref().map(StageKey::from_stage), + primitive: self.primitive, + depth_stencil: self.depth_stencil.clone(), + multisample: self.multisample, + targets: self.targets.clone(), + multiview: self.multiview, + } + } +} + +fn target_variant_spec( + mut spec: RenderPipelineSpec, + color_format: wgpu::TextureFormat, + depth_format: Option, + depth_compare: wgpu::CompareFunction, + depth_write: bool, +) -> RenderPipelineSpec { + spec.targets = vec![Some(wgpu::ColorTargetState { + format: color_format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })]; + spec.depth_stencil = depth_format.map(|format| wgpu::DepthStencilState { + format, + depth_write_enabled: depth_write, + depth_compare, + stencil: Default::default(), + bias: Default::default(), + }); + spec +} + +pub struct PipelineLibrary { + pipelines: Vec, + specs: Vec, + layout_bindings: HashMap>, + pipeline_layouts: HashMap, + default_layout: Option, + material_layout: Option, + next_layout: u64, + pipeline_registry: HashMap, + descriptor_cache: HashMap, +} + +impl PipelineLibrary { + pub fn new() -> Self { + Self { + pipelines: Vec::new(), + specs: Vec::new(), + layout_bindings: HashMap::new(), + pipeline_layouts: HashMap::new(), + default_layout: None, + material_layout: None, + next_layout: 0, + pipeline_registry: HashMap::new(), + descriptor_cache: HashMap::new(), + } + } + + /// Registers a new default layout identity. Existing pipelines retain their layout. + pub fn set_bind_group_layouts( + &mut self, + layouts: &[wgpu::BindGroupLayout; 2], + ) -> PipelineLayoutKey { + let key = PipelineLayoutKey(self.next_layout); + self.next_layout = self + .next_layout + .checked_add(1) + .expect("pipeline layout key overflow"); + self.layout_bindings.insert(key, layouts.to_vec()); + self.default_layout = Some(key); + key + } + + /// Registers the glTF-only layout, preserving scene groups at 0 and 1. + pub fn set_material_bind_group_layout(&mut self, layout: &wgpu::BindGroupLayout) { + let base = self + .default_layout + .expect("scene layouts must be registered first"); + let mut layouts = self.layout_bindings[&base].clone(); + layouts.push(layout.clone()); + let key = PipelineLayoutKey(self.next_layout); + self.next_layout += 1; + self.layout_bindings.insert(key, layouts); + self.material_layout = Some(key); + } + + fn compatibility_spec( + &self, + name: &str, + layouts: &[wgpu::VertexBufferLayout], + shader: &str, + format: wgpu::TextureFormat, + ) -> RenderPipelineSpec { + let (vertex_entry, fragment_entry) = if name == "triangle_colored" { + ("v_main", "f_main") + } else { + ("vs_main", "fs_main") + }; + let stage = |entry: &str| OwnedProgrammableStage { + shader_source: shader.to_owned(), + entry_point: entry.to_owned(), + constants: Vec::new(), + zero_initialize_workgroup_memory: true, + }; + RenderPipelineSpec { + layout: if name.starts_with("gltf_") { + self.material_layout.or(self.default_layout) + } else { + self.default_layout + }, + vertex: stage(vertex_entry), + vertex_layouts: layouts + .iter() + .map(|layout| OwnedVertexBufferLayout { + array_stride: layout.array_stride, + step_mode: layout.step_mode, + attributes: layout.attributes.to_vec(), + }) + .collect(), + fragment: Some(stage(fragment_entry)), + primitive: wgpu::PrimitiveState { + cull_mode: (name != "gltf_standard_double_sided").then_some(wgpu::Face::Back), + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + targets: vec![Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + multiview: None, + } + } + + /// Creates or reuses a pipeline solely by its owned descriptor identity. + pub fn get_or_create_from_spec( + &mut self, + device: &wgpu::Device, + spec: &RenderPipelineSpec, + label: Option<&str>, + ) -> PipelineKey { + let key = spec.key(); + if let Some(pipeline) = self.descriptor_cache.get(&key) { + return *pipeline; + } + let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label, + source: wgpu::ShaderSource::Wgsl(spec.vertex.shader_source.as_str().into()), + }); + let fragment_shader = spec.fragment.as_ref().map(|stage| { + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label, + source: wgpu::ShaderSource::Wgsl(stage.shader_source.as_str().into()), + }) + }); + let vertex_constants: Vec<_> = spec + .vertex + .constants + .iter() + .map(|(name, value)| (name.as_str(), *value)) + .collect(); + let fragment_constants: Option> = spec.fragment.as_ref().map(|stage| { + stage + .constants + .iter() + .map(|(name, value)| (name.as_str(), *value)) + .collect() + }); + let vertex_layouts: Vec<_> = spec + .vertex_layouts + .iter() + .map(|layout| wgpu::VertexBufferLayout { + array_stride: layout.array_stride, + step_mode: layout.step_mode, + attributes: &layout.attributes, + }) + .collect(); + let layout = spec.layout.map(|layout_key| { + if !self.pipeline_layouts.contains_key(&layout_key) { + let bindings = self + .layout_bindings + .get(&layout_key) + .unwrap_or_else(|| panic!("unregistered pipeline layout key {layout_key:?}")); + let refs: Vec<_> = bindings.iter().collect(); + self.pipeline_layouts.insert( + layout_key, + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label, + bind_group_layouts: &refs, + push_constant_ranges: &[], + }), + ); + } + self.pipeline_layouts.get(&layout_key).unwrap() + }); + let fragment = spec.fragment.as_ref().map(|stage| wgpu::FragmentState { + module: fragment_shader.as_ref().unwrap(), + entry_point: Some(&stage.entry_point), + compilation_options: wgpu::PipelineCompilationOptions { + constants: fragment_constants.as_ref().unwrap(), + zero_initialize_workgroup_memory: stage.zero_initialize_workgroup_memory, + }, + targets: &spec.targets, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label, + layout, + vertex: wgpu::VertexState { + module: &vertex_shader, + entry_point: Some(&spec.vertex.entry_point), + compilation_options: wgpu::PipelineCompilationOptions { + constants: &vertex_constants, + zero_initialize_workgroup_memory: spec.vertex.zero_initialize_workgroup_memory, + }, + buffers: &vertex_layouts, + }, + primitive: spec.primitive, + depth_stencil: spec.depth_stencil.clone(), + multisample: spec.multisample, + fragment, + multiview: spec.multiview, + cache: None, + }); + let pipeline_key = PipelineKey::new(self.pipelines.len() as u32); + self.pipelines.push(pipeline); + self.specs.push(spec.clone()); + self.descriptor_cache.insert(key, pipeline_key); + pipeline_key + } + + pub fn create_pipeline( + &mut self, + device: &wgpu::Device, + name: &str, + layouts: &[wgpu::VertexBufferLayout], + shader: &str, + format: wgpu::TextureFormat, + ) -> Result { + let spec = self.compatibility_spec(name, layouts, shader, format); + let descriptor = spec.key(); + if let Some((_, existing)) = self.pipeline_registry.get(name) { + return Err(if existing == &descriptor { + format!("Pipeline '{name}' already exists") + } else { + format!("Pipeline '{name}' already exists with a different descriptor") + }); + } + let key = self.get_or_create_from_spec(device, &spec, Some(name)); + self.pipeline_registry + .insert(name.to_owned(), (key, descriptor)); + Ok(key) + } + + pub fn find_pipeline(&self, name: &str) -> Option { + self.pipeline_registry.get(name).map(|v| v.0) + } + pub fn get_or_create_pipeline( + &mut self, + device: &wgpu::Device, + name: &str, + layouts: &[wgpu::VertexBufferLayout], + shader: &str, + format: wgpu::TextureFormat, + ) -> PipelineKey { + let wanted = self.compatibility_spec(name, layouts, shader, format).key(); + if let Some((key, existing)) = self.pipeline_registry.get(name) { + assert_eq!( + existing, &wanted, + "Pipeline '{name}' requested with a different descriptor" + ); + return *key; + } + self.create_pipeline(device, name, layouts, shader, format) + .unwrap_or_else(|e| panic!("Failed to create pipeline '{name}': {e}")) + } + pub fn get_pipeline(&self, key: PipelineKey) -> &wgpu::RenderPipeline { + &self.pipelines[key.get() as usize] + } + + pub fn requires_material(&self, key: PipelineKey) -> bool { + self.material_layout.is_some() + && self.specs[key.get() as usize].layout == self.material_layout + } + + pub fn pipeline_keys(&self) -> impl Iterator + '_ { + let mut keys = self + .pipeline_registry + .values() + .map(|entry| entry.0) + .collect::>(); + keys.sort_by_key(|key| key.get()); + keys.dedup(); + keys.into_iter() + } + + pub fn get_or_create_target_variant( + &mut self, + device: &wgpu::Device, + base: PipelineKey, + color_format: wgpu::TextureFormat, + depth_format: Option, + depth_compare: wgpu::CompareFunction, + depth_write: bool, + ) -> Result { + let spec = self + .specs + .get(base.get() as usize) + .cloned() + .ok_or_else(|| "unknown base pipeline".to_owned())?; + let spec = + target_variant_spec(spec, color_format, depth_format, depth_compare, depth_write); + Ok(self.get_or_create_from_spec(device, &spec, Some("target variant"))) + } + + pub fn create_target_variant( + &self, + device: &wgpu::Device, + base: PipelineKey, + color_format: wgpu::TextureFormat, + depth_format: Option, + depth_compare: wgpu::CompareFunction, + depth_write: bool, + ) -> Result { + let spec = self + .specs + .get(base.get() as usize) + .cloned() + .ok_or_else(|| "unknown base pipeline".to_owned())?; + let spec = + target_variant_spec(spec, color_format, depth_format, depth_compare, depth_write); + let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("V2 target variant"), + source: wgpu::ShaderSource::Wgsl(spec.vertex.shader_source.as_str().into()), + }); + let fragment_shader = spec.fragment.as_ref().map(|stage| { + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("V2 target variant"), + source: wgpu::ShaderSource::Wgsl(stage.shader_source.as_str().into()), + }) + }); + let vertex_constants: Vec<_> = spec + .vertex + .constants + .iter() + .map(|(name, value)| (name.as_str(), *value)) + .collect(); + let fragment_constants: Option> = spec.fragment.as_ref().map(|stage| { + stage + .constants + .iter() + .map(|(name, value)| (name.as_str(), *value)) + .collect() + }); + let vertex_layouts: Vec<_> = spec + .vertex_layouts + .iter() + .map(|layout| wgpu::VertexBufferLayout { + array_stride: layout.array_stride, + step_mode: layout.step_mode, + attributes: &layout.attributes, + }) + .collect(); + let layout = spec + .layout + .map(|key| { + self.pipeline_layouts + .get(&key) + .ok_or("pipeline layout missing") + }) + .transpose()?; + let fragment = spec.fragment.as_ref().map(|stage| wgpu::FragmentState { + module: fragment_shader.as_ref().unwrap(), + entry_point: Some(&stage.entry_point), + compilation_options: wgpu::PipelineCompilationOptions { + constants: fragment_constants.as_ref().unwrap(), + zero_initialize_workgroup_memory: stage.zero_initialize_workgroup_memory, + }, + targets: &spec.targets, + }); + Ok( + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("V2 target variant"), + layout, + vertex: wgpu::VertexState { + module: &vertex_shader, + entry_point: Some(&spec.vertex.entry_point), + compilation_options: wgpu::PipelineCompilationOptions { + constants: &vertex_constants, + zero_initialize_workgroup_memory: spec + .vertex + .zero_initialize_workgroup_memory, + }, + buffers: &vertex_layouts, + }, + primitive: spec.primitive, + depth_stencil: spec.depth_stencil, + multisample: spec.multisample, + fragment, + multiview: spec.multiview, + cache: None, + }), + ) + } +} + +impl Default for PipelineLibrary { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec() -> RenderPipelineSpec { + PipelineLibrary::new().compatibility_spec( + "x", + &[], + "shader", + wgpu::TextureFormat::Rgba8Unorm, + ) + } + + #[test] + fn v2_target_spec_disables_blending_without_mutating_base() { + let mut base = spec(); + base.primitive.cull_mode = Some(wgpu::Face::Front); + base.multisample.count = 4; + base.targets[0].as_mut().unwrap().write_mask = wgpu::ColorWrites::RED; + let preserved_vertex = StageKey::from_stage(&base.vertex); + let preserved_fragment = base.fragment.as_ref().map(StageKey::from_stage); + assert!(base.targets[0].as_ref().unwrap().blend.is_some()); + let variant = target_variant_spec( + base.clone(), + wgpu::TextureFormat::Rgba16Float, + None, + wgpu::CompareFunction::Always, + false, + ); + assert_eq!(variant.targets[0].as_ref().unwrap().blend, None); + assert_eq!( + variant.targets[0].as_ref().unwrap().format, + wgpu::TextureFormat::Rgba16Float + ); + assert!(base.targets[0].as_ref().unwrap().blend.is_some()); + assert_eq!(variant.primitive, base.primitive); + assert_eq!(variant.multisample, base.multisample); + assert_eq!(StageKey::from_stage(&variant.vertex), preserved_vertex); + assert_eq!( + variant.fragment.as_ref().map(StageKey::from_stage), + preserved_fragment + ); + assert_eq!( + variant.targets[0].as_ref().unwrap().write_mask, + wgpu::ColorWrites::ALL + ); + } + + #[test] + fn descriptor_identity_covers_optional_and_exact_state() { + let base = spec().key(); + let mut changed = spec(); + changed.layout = Some(PipelineLayoutKey(1)); + assert_ne!(base, changed.key()); + let first_layout = changed.key(); + changed.layout = Some(PipelineLayoutKey(2)); + assert_ne!(first_layout, changed.key()); + let mut changed = spec(); + changed.fragment = None; + assert_ne!(base, changed.key()); + let mut changed = spec(); + changed.multiview = NonZeroU32::new(2); + assert_ne!(base, changed.key()); + let mut changed = spec(); + changed.vertex.constants.push(("x".into(), -0.0)); + assert_ne!(base, changed.key()); + let mut changed2 = changed.clone(); + changed2.vertex.constants[0].1 = 0.0; + assert_ne!(changed.key(), changed2.key()); + let mut changed = spec(); + changed.vertex.zero_initialize_workgroup_memory = false; + assert_ne!(base, changed.key()); + } +} diff --git a/renderer/src/renderer/profiler.rs b/renderer/src/renderer/profiler.rs new file mode 100644 index 0000000..537f2d0 --- /dev/null +++ b/renderer/src/renderer/profiler.rs @@ -0,0 +1,526 @@ +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex}, +}; +use wasm_bindgen::JsValue; + +pub const MAX_PROFILE_PASSES: usize = crate::render_graph::MAX_PASSES; +const SLOT_COUNT: usize = 4; +const QUERY_COUNT: u32 = (MAX_PROFILE_PASSES * 2) as u32; +const RESOLVE_SIZE: u64 = QUERY_COUNT as u64 * 8; + +#[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, + values: Option>, +} + +pub(crate) struct ProfileFrame { + pub query_set: wgpu::QuerySet, + slot: usize, + identity: String, + ids: Vec, + invalid: bool, +} +pub(crate) struct ProfileMap { + slot: usize, + epoch: u64, + ids: Vec, + count: u32, +} +impl ProfileFrame { + fn allocate(&mut self, id: &str) -> Option { + allocate_id(&mut self.ids, &mut self.invalid, id) + } + pub fn render_writes(&mut self, id: &str) -> Option> { + 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> { + 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, + completions: Arc>>, + epoch: u64, + identity: String, + period_ns: f64, + samples: HashMap>, + last_snapshot_ms: f64, + dropped: u64, +} + +impl Profiler { + 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() + } + } + 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 { + 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 { + 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::>() + } 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 { + 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::() / 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, invalid: &mut bool, id: &str) -> Option { + 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(enabled: bool, available: bool, f: impl FnOnce() -> Option) -> Option { + (enabled && available).then(f).flatten() +} + +fn begin_transition<'a>(states: impl IntoIterator) -> Option { + 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>, + completion_epoch: u64, + current_epoch: u64, + available: &mut bool, + samples: &mut HashMap>, +) -> Option> { + *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 { + 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_eq!(QUERY_COUNT, 2048) + } + #[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") + ); + } + } + } +} diff --git a/renderer/src/renderer/scene.rs b/renderer/src/renderer/scene.rs index 350e92d..2ae2573 100644 --- a/renderer/src/renderer/scene.rs +++ b/renderer/src/renderer/scene.rs @@ -3,7 +3,7 @@ use wgpu::util::DeviceExt; use crate::{ camera::Camera, render_data::RenderData, - renderer::{self, GpuResources}, + renderer::{self, PipelineLibrary}, }; pub struct UniformResource { @@ -76,13 +76,14 @@ impl FrameMetadata { pub trait Scene: Sized { fn setup( context: &renderer::RendererContext, - resources: &mut GpuResources, + 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> { @@ -91,6 +92,9 @@ pub trait Scene: Sized { fn camera_mut(&mut self) -> Option<&mut Camera> { None } + fn frustum_planes(&mut self) -> Option> { + self.camera_mut().map(|camera| camera.frustum_planes()) + } fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> { None } @@ -103,7 +107,7 @@ pub trait Scene: Sized { } self.write_uniforms(queue); } - fn update(&mut self, context: &renderer::RendererContext) { + fn update_cpu(&mut self) { let position = match self.camera_mut() { Some(c) => c.position(), None => return, @@ -112,7 +116,6 @@ pub trait Scene: Sized { f.time = js_sys::Date::now() as f32 * 0.001; f.set_camera_position(position) } - self.write_uniforms(&context.queue); } fn write_uniforms(&mut self, queue: &wgpu::Queue) { let frame = self.frame_metadata_mut().copied(); diff --git a/renderer/src/renderer/scene_frame.rs b/renderer/src/renderer/scene_frame.rs new file mode 100644 index 0000000..a6c1c78 --- /dev/null +++ b/renderer/src/renderer/scene_frame.rs @@ -0,0 +1,322 @@ +use std::collections::HashMap; + +use thiserror::Error; + +use crate::render_data::{ + affine_world_aabb, Aabb, GeometryRange, InstanceHandle, MaterialKey, MeshHandle, + ModelTransform, NormalMatrix, PipelineKey, RenderData, RenderFlags, +}; + +#[derive(Clone, Debug)] +pub struct SceneFrameMesh { + pub handle: MeshHandle, + pub geometry: GeometryRange, + pub pipeline: PipelineKey, + pub material: MaterialKey, + pub flags: RenderFlags, + pub aabb: Aabb, + pub default_instance: InstanceHandle, + pub occurrence_range: std::ops::Range, +} + +#[derive(Clone, Debug)] +pub struct SceneFrameOccurrence { + pub handle: InstanceHandle, + pub mesh: MeshHandle, + pub mesh_index: usize, + pub model: ModelTransform, + pub normal: NormalMatrix, + pub flags: RenderFlags, + pub is_default: bool, + pub world_aabb: Aabb, +} + +#[derive(Clone, Debug)] +pub struct SceneFramePlan { + pub revision: u64, + pub positions: Vec<[f32; 3]>, + pub normals: Vec<[f32; 3]>, + pub uvs: Vec<[f32; 2]>, + pub tangents: Vec<[f32; 4]>, + pub indices: Vec, + pub meshes: Vec, + pub occurrences: Vec, + /// Occurrence indices grouped by mesh without changing global occurrence order. + pub mesh_occurrence_indices: Vec, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum SceneFrameError { + #[error("an occurrence references a missing mesh")] + MissingMesh, + #[error("world bounds could not be derived")] + InvalidWorldBounds, + #[error("scene frame size overflow")] + SizeOverflow, +} + +impl SceneFramePlan { + pub fn build(data: &RenderData) -> Result { + let streams = data.streams(); + let mut meshes: Vec<_> = data.meshes().collect(); + meshes.sort_by_key(|(handle, _)| (handle.slot(), handle.generation())); + let mesh_indices: HashMap<_, _> = meshes + .iter() + .enumerate() + .map(|(dense, (handle, _))| (*handle, dense)) + .collect(); + + let mut source_occurrences: Vec<_> = data.instances().collect(); + source_occurrences.sort_by_key(|(handle, _)| (handle.slot(), handle.generation())); + let mut counts = vec![0usize; meshes.len()]; + let mut occurrences = Vec::with_capacity(source_occurrences.len()); + for (handle, occurrence) in source_occurrences { + let mesh_index = *mesh_indices + .get(&occurrence.mesh) + .ok_or(SceneFrameError::MissingMesh)?; + counts[mesh_index] = counts[mesh_index] + .checked_add(1) + .ok_or(SceneFrameError::SizeOverflow)?; + let mesh = meshes[mesh_index].1; + occurrences.push(SceneFrameOccurrence { + handle, + mesh: occurrence.mesh, + mesh_index, + model: occurrence.model, + normal: occurrence.normal, + flags: occurrence.flags, + is_default: handle == mesh.default_instance, + world_aabb: affine_world_aabb(mesh.aabb, occurrence.model) + .map_err(|_| SceneFrameError::InvalidWorldBounds)?, + }); + } + + let mut offsets = Vec::with_capacity(meshes.len() + 1); + offsets.push(0usize); + for count in counts { + offsets.push( + offsets + .last() + .unwrap() + .checked_add(count) + .ok_or(SceneFrameError::SizeOverflow)?, + ); + } + let mut cursors = offsets[..meshes.len()].to_vec(); + let mut mesh_occurrence_indices = vec![0; occurrences.len()]; + for (occurrence_index, occurrence) in occurrences.iter().enumerate() { + let cursor = &mut cursors[occurrence.mesh_index]; + mesh_occurrence_indices[*cursor] = occurrence_index; + *cursor += 1; + } + let meshes = meshes + .into_iter() + .enumerate() + .map(|(dense, (handle, mesh))| SceneFrameMesh { + handle, + geometry: mesh.geometry, + pipeline: mesh.pipeline, + material: mesh.material, + flags: mesh.flags, + aabb: mesh.aabb, + default_instance: mesh.default_instance, + occurrence_range: offsets[dense]..offsets[dense + 1], + }) + .collect(); + Ok(Self { + revision: data.revision(), + positions: streams.positions.to_vec(), + normals: streams.normals.to_vec(), + uvs: streams.uvs.to_vec(), + tangents: streams.tangents.to_vec(), + indices: data.indices().to_vec(), + meshes, + occurrences, + mesh_occurrence_indices, + }) + } +} + +#[derive(Default)] +pub struct SceneFrameCache { + plan: Option>, +} + +impl SceneFrameCache { + pub fn get_or_build(&mut self, data: &RenderData) -> Result<&SceneFramePlan, SceneFrameError> { + if self + .plan + .as_ref() + .is_none_or(|plan| plan.revision != data.revision()) + { + let replacement = Box::new(SceneFramePlan::build(data)?); + self.plan = Some(replacement); + } + Ok(self.plan.as_deref().unwrap()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render_data::{MeshCreateInfo, RenderDataConfig, IDENTITY_MODEL_TRANSFORM}; + + fn mesh(data: &mut RenderData, visible: bool) -> crate::render_data::CreatedMesh { + data.create_mesh(MeshCreateInfo { + positions: &[[0., 0., 0.], [2., 0., 0.], [0., 2., 0.]], + normals: &[[0., 0., 1.]; 3], + tangents: &[[1., 0., 0., 1.]; 3], + uvs: &[[0., 0.]; 3], + indices: &[0, 1, 2], + pipeline: PipelineKey::new(0), + material: crate::render_data::MaterialKey::DEFAULT, + flags: if visible { + RenderFlags::VISIBLE + } else { + RenderFlags::NONE + }, + default_instance_flags: RenderFlags::VISIBLE, + default_transform: IDENTITY_MODEL_TRANSFORM, + }) + .unwrap() + } + + #[test] + fn cache_reuses_pointer_and_rebuilds_on_revision() { + let mut data = RenderData::new(RenderDataConfig::default()).unwrap(); + let mut cache = SceneFrameCache::default(); + let first = cache.get_or_build(&data).unwrap() as *const _; + assert_eq!(first, cache.get_or_build(&data).unwrap() as *const _); + let created = mesh(&mut data, true); + let second = cache.get_or_build(&data).unwrap() as *const _; + assert_ne!(first, second); + data.set_mesh_flags(created.mesh, RenderFlags::NONE) + .unwrap(); + let third = cache.get_or_build(&data).unwrap() as *const _; + assert_ne!(second, third); + let mut moved = IDENTITY_MODEL_TRANSFORM; + moved[3][1] = 9.0; + data.set_instance_transform(created.default_instance, moved) + .unwrap(); + assert_ne!(third, cache.get_or_build(&data).unwrap() as *const _); + } + + #[test] + fn retains_hidden_entries_builds_adjacency_and_world_bounds() { + let mut data = RenderData::new(RenderDataConfig::default()).unwrap(); + let hidden = mesh(&mut data, false); + let shown = mesh(&mut data, true); + let mut translated = IDENTITY_MODEL_TRANSFORM; + translated[0][0] = 2.; + translated[1][1] = 3.; + translated[2][2] = 4.; + translated[3][0] = 5.; + translated[3][1] = -2.; + let extra = data + .create_instance(hidden.mesh, translated, RenderFlags::NONE) + .unwrap(); + let plan = SceneFramePlan::build(&data).unwrap(); + assert_eq!((plan.meshes.len(), plan.occurrences.len()), (2, 3)); + assert!(plan + .occurrences + .iter() + .any(|o| o.handle == hidden.default_instance && o.is_default)); + let occurrence = plan.occurrences.iter().find(|o| o.handle == extra).unwrap(); + assert_eq!(occurrence.world_aabb.min, [5., -2., 0.]); + assert_eq!(occurrence.world_aabb.max, [9., 4., 0.]); + for (mesh_index, mesh) in plan.meshes.iter().enumerate() { + assert!(plan.mesh_occurrence_indices[mesh.occurrence_range.clone()] + .iter() + .all(|&i| plan.occurrences[i].mesh_index == mesh_index)); + } + assert_eq!( + shown.default_instance.slot(), + plan.occurrences + .iter() + .find(|o| o.mesh == shown.mesh) + .unwrap() + .handle + .slot() + ); + } + + #[test] + fn slot_reuse_preserves_dense_order_adjacency_and_ownership() { + let mut data = RenderData::new(RenderDataConfig::default()).unwrap(); + let a = mesh(&mut data, true); + let doomed = mesh(&mut data, true); + let c = mesh(&mut data, true); + let a_extra = data + .create_instance(a.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE) + .unwrap(); + let doomed_extra = data + .create_instance(doomed.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE) + .unwrap(); + let c_extra = data + .create_instance(c.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE) + .unwrap(); + data.destroy_instance(a_extra).unwrap(); + data.destroy_mesh(doomed.mesh).unwrap(); + let replacement = mesh(&mut data, true); + let replacement_extra = data + .create_instance( + replacement.mesh, + IDENTITY_MODEL_TRANSFORM, + RenderFlags::VISIBLE, + ) + .unwrap(); + assert_eq!(replacement.mesh.slot(), doomed.mesh.slot()); + assert!(replacement.mesh.generation() > doomed.mesh.generation()); + let destroyed = [a_extra, doomed.default_instance, doomed_extra]; + for reused in [replacement.default_instance, replacement_extra] { + let old = destroyed + .iter() + .find(|old| old.slot() == reused.slot()) + .expect("replacement must reuse an interior instance slot"); + assert!(reused.generation() > old.generation()); + } + assert!(data.instance(doomed_extra).is_none()); + + let plan = SceneFramePlan::build(&data).unwrap(); + assert!(plan + .meshes + .windows(2) + .all(|w| (w[0].handle.slot(), w[0].handle.generation()) + < (w[1].handle.slot(), w[1].handle.generation()))); + assert!(plan.occurrences.windows(2).all(|w| ( + w[0].handle.slot(), + w[0].handle.generation() + ) < ( + w[1].handle.slot(), + w[1].handle.generation() + ))); + let mut seen = vec![0; plan.occurrences.len()]; + for (mesh_index, mesh) in plan.meshes.iter().enumerate() { + let adjacency = &plan.mesh_occurrence_indices[mesh.occurrence_range.clone()]; + assert!(adjacency.windows(2).all(|w| { + let a = plan.occurrences[w[0]].handle; + let b = plan.occurrences[w[1]].handle; + (a.slot(), a.generation()) < (b.slot(), b.generation()) + })); + for &index in adjacency { + seen[index] += 1; + let occurrence = &plan.occurrences[index]; + assert_eq!(occurrence.mesh, mesh.handle); + assert_eq!(occurrence.mesh_index, mesh_index); + assert_eq!( + occurrence.is_default, + occurrence.handle == mesh.default_instance + ); + } + assert_eq!( + adjacency + .iter() + .filter(|&&i| plan.occurrences[i].is_default) + .count(), + 1 + ); + } + assert!(seen.into_iter().all(|count| count == 1)); + assert!(plan.occurrences.iter().any(|o| o.handle == c_extra)); + } +} diff --git a/renderer/src/shared_snapshot.rs b/renderer/src/shared_snapshot.rs index b48848e..a87eb1c 100644 --- a/renderer/src/shared_snapshot.rs +++ b/renderer/src/shared_snapshot.rs @@ -1,7 +1,7 @@ //! Triple-buffered, immutable packed scene snapshot shared with JavaScript. use std::sync::atomic::{AtomicU32, Ordering}; -use crate::render_data::{affine_world_aabb, RenderData, RenderFlags}; +use crate::{render_data::RenderFlags, renderer::scene_frame::SceneFramePlan}; pub const MAGIC: u32 = u32::from_le_bytes(*b"YSNP"); pub const BLOB_MAGIC: u32 = u32::from_le_bytes(*b"RDS1"); @@ -94,11 +94,11 @@ impl SharedSnapshot { } /// Packs and publishes if `data` changed. Returns the newly published data epoch. - pub fn publish(&mut self, data: &RenderData) -> Result, u32> { + pub fn publish(&mut self, data: &SceneFramePlan) -> Result, u32> { if self.control.header[6].load(Ordering::Acquire) == FAILED { return Err(self.control.header[14].load(Ordering::Relaxed)); } - if self.last_revision == Some(data.revision()) { + if self.last_revision == Some(data.revision) { return Ok(None); } let slot = match self.claim_slot() { @@ -116,7 +116,7 @@ impl SharedSnapshot { result } - fn publish_claimed(&mut self, slot: usize, data: &RenderData) -> Result, u32> { + fn publish_claimed(&mut self, slot: usize, data: &SceneFramePlan) -> Result, u32> { let epoch = self.next_epoch; let next_epoch = epoch.checked_add(1).ok_or(ERROR_OVERFLOW)?; let bytes = pack(data, epoch)?; @@ -136,7 +136,7 @@ impl SharedSnapshot { let ptr = self.blocks[slot].as_ptr() as usize; let ptr32 = u32::try_from(ptr).map_err(|_| ERROR_OVERFLOW)?; let length = u32::try_from(bytes.len()).map_err(|_| ERROR_OVERFLOW)?; - let revision = data.revision(); + let revision = data.revision; let d = &self.control.slots[slot].0; let values = [ epoch, @@ -145,8 +145,8 @@ impl SharedSnapshot { length, revision as u32, (revision >> 32) as u32, - data.mesh_count(), - data.instance_count(), + data.meshes.len() as u32, + data.occurrences.len() as u32, SCHEMA, SNAPSHOT_HEADER_BYTES as u32, 0, @@ -244,9 +244,9 @@ fn wasm_pages(minimum_end: usize) -> Result { .map_err(|_| ERROR_OVERFLOW) } -fn pack(data: &RenderData, epoch: u32) -> Result, u32> { - let meshes: Vec<_> = data.meshes().collect(); - let instances: Vec<_> = data.instances().collect(); +fn pack(data: &SceneFramePlan, epoch: u32) -> Result, u32> { + let meshes = &data.meshes; + let instances = &data.occurrences; let strides = [4usize, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4]; let components = [1u32, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1]; let scalar = [1u32, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1]; @@ -268,7 +268,7 @@ fn pack(data: &RenderData, epoch: u32) -> Result, u32> { let put32 = |out: &mut [u8], at: usize, value: u32| { out[at..at + 4].copy_from_slice(&value.to_le_bytes()) }; - let revision = data.revision(); + let revision = data.revision; for (i, value) in [ BLOB_MAGIC, SCHEMA, @@ -310,10 +310,14 @@ fn pack(data: &RenderData, epoch: u32) -> Result, u32> { put32(&mut out, at + j * 4, value); } } - for (dense, (handle, mesh)) in meshes.iter().enumerate() { - for (i, value) in [handle.slot(), handle.generation(), mesh.flags.bits()] - .into_iter() - .enumerate() + for (dense, mesh) in meshes.iter().enumerate() { + for (i, value) in [ + mesh.handle.slot(), + mesh.handle.generation(), + mesh.flags.bits(), + ] + .into_iter() + .enumerate() { put32(&mut out, offsets[i] + dense * 4, value); } @@ -330,12 +334,14 @@ fn pack(data: &RenderData, epoch: u32) -> Result, u32> { ); } } - for (dense, (handle, instance)) in instances.iter().enumerate() { - let mesh = data.mesh(instance.mesh).ok_or(ERROR_INVARIANT)?; - let world = affine_world_aabb(mesh.aabb, instance.model).map_err(|_| ERROR_INVARIANT)?; + for (dense, instance) in instances.iter().enumerate() { + let mesh = data + .meshes + .get(instance.mesh_index) + .ok_or(ERROR_INVARIANT)?; for (i, value) in [ - handle.slot(), - handle.generation(), + instance.handle.slot(), + instance.handle.generation(), instance.mesh.slot(), instance.mesh.generation(), instance.flags.bits(), @@ -356,12 +362,12 @@ fn pack(data: &RenderData, epoch: u32) -> Result, u32> { put32( &mut out, offsets[11] + dense * 12 + i * 4, - world.min[i].to_bits(), + instance.world_aabb.min[i].to_bits(), ); put32( &mut out, offsets[12] + dense * 12 + i * 4, - world.max[i].to_bits(), + instance.world_aabb.max[i].to_bits(), ); } put32( @@ -387,6 +393,9 @@ const _: [(); 256] = [(); std::mem::size_of::()]; #[cfg(test)] mod tests { use super::*; + use crate::render_data::{ + MeshCreateInfo, PipelineKey, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM, + }; #[test] fn exact_control_layout_and_initial_values() { @@ -417,4 +426,166 @@ mod tests { WRITING ); } + + #[test] + fn producer_blob_abi_matches_schema_exactly() { + let mut data = RenderData::new(RenderDataConfig::default()).unwrap(); + let create = |data: &mut RenderData, flags, x: f32| { + data.create_mesh(MeshCreateInfo { + positions: &[[x, 0., 0.], [x + 2., 0., 0.], [x, 3., 0.]], + normals: &[[0., 0., 1.]; 3], + tangents: &[[1., 0., 0., 1.]; 3], + uvs: &[[0., 0.]; 3], + indices: &[0, 1, 2], + pipeline: PipelineKey::new(7), + material: crate::render_data::MaterialKey::DEFAULT, + flags, + default_instance_flags: RenderFlags::VISIBLE, + default_transform: IDENTITY_MODEL_TRANSFORM, + }) + .unwrap() + }; + let visible = create(&mut data, RenderFlags::VISIBLE, -1.0); + let hidden = create(&mut data, RenderFlags::NONE, 10.0); + let mut model = IDENTITY_MODEL_TRANSFORM; + model[0][0] = 2.0; + model[1][1] = 0.5; + model[3][0] = 4.0; + model[3][1] = -2.0; + let extra = data + .create_instance(hidden.mesh, model, RenderFlags::NONE) + .unwrap(); + let plan = SceneFramePlan::build(&data).unwrap(); + let epoch = 0x1234_5678; + let blob = pack(&plan, epoch).unwrap(); + let word = |at: usize| u32::from_le_bytes(blob[at..at + 4].try_into().unwrap()); + + assert_eq!(word(0), BLOB_MAGIC); + assert_eq!(word(4), SCHEMA); + assert_eq!(word(8), SNAPSHOT_HEADER_BYTES as u32); + assert_eq!(word(12), blob.len() as u32); + assert_eq!(word(16), epoch); + assert_eq!(word(20), plan.revision as u32); + assert_eq!(word(24), (plan.revision >> 32) as u32); + assert_eq!(word(28), STREAMS as u32); + assert_eq!(word(32), SNAPSHOT_HEADER_BYTES as u32); + assert_eq!(word(36), DESCRIPTOR_BYTES as u32); + assert_eq!(word(40), 2); + assert_eq!(word(44), 3); + assert_eq!(word(48), 0x0102_0304); + assert_eq!(word(52), SCHEMA_FLAGS); + + let strides = [4, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4]; + let scalars = [1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1]; + let components = [1, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1]; + let counts = [2usize; 5] + .into_iter() + .chain([3usize; 9]) + .collect::>(); + let mut offsets = Vec::new(); + let mut cursor = DATA_OFFSET; + for i in 0..STREAMS { + let at = SNAPSHOT_HEADER_BYTES + i * DESCRIPTOR_BYTES; + offsets.push(cursor); + assert_eq!( + [ + word(at), + word(at + 4), + word(at + 8), + word(at + 12), + word(at + 16), + word(at + 20), + word(at + 24), + word(at + 28) + ], + [ + i as u32 + 1, + scalars[i], + cursor as u32, + counts[i] as u32, + components[i], + strides[i] as u32, + 4, + 0 + ] + ); + assert_eq!(cursor % 16, 0); + cursor = (cursor + strides[i] as usize * counts[i] + 15) & !15; + } + assert_eq!(cursor, blob.len()); + + for (dense, mesh) in plan.meshes.iter().enumerate() { + assert_eq!(word(offsets[0] + dense * 4), mesh.handle.slot()); + assert_eq!(word(offsets[1] + dense * 4), mesh.handle.generation()); + assert_eq!(word(offsets[2] + dense * 4), mesh.flags.bits()); + for axis in 0..3 { + assert_eq!( + word(offsets[3] + dense * 12 + axis * 4), + mesh.aabb.min[axis].to_bits() + ); + assert_eq!( + word(offsets[4] + dense * 12 + axis * 4), + mesh.aabb.max[axis].to_bits() + ); + } + } + for (dense, occurrence) in plan.occurrences.iter().enumerate() { + assert_eq!( + [ + word(offsets[5] + dense * 4), + word(offsets[6] + dense * 4), + word(offsets[7] + dense * 4), + word(offsets[8] + dense * 4), + word(offsets[9] + dense * 4) + ], + [ + occurrence.handle.slot(), + occurrence.handle.generation(), + occurrence.mesh.slot(), + occurrence.mesh.generation(), + occurrence.flags.bits() + ] + ); + for i in 0..16 { + assert_eq!( + word(offsets[10] + dense * 64 + i * 4), + occurrence.model[i / 4][i % 4].to_bits() + ); + } + for axis in 0..3 { + assert_eq!( + word(offsets[11] + dense * 12 + axis * 4), + occurrence.world_aabb.min[axis].to_bits() + ); + assert_eq!( + word(offsets[12] + dense * 12 + axis * 4), + occurrence.world_aabb.max[axis].to_bits() + ); + } + let mesh_visible = plan.meshes[occurrence.mesh_index] + .flags + .contains(RenderFlags::VISIBLE); + assert_eq!( + word(offsets[13] + dense * 4), + (mesh_visible && occurrence.flags.contains(RenderFlags::VISIBLE)) as u32 + ); + } + assert!(plan + .meshes + .windows(2) + .all(|w| w[0].handle.slot() < w[1].handle.slot())); + assert!(plan + .occurrences + .windows(2) + .all(|w| w[0].handle.slot() < w[1].handle.slot())); + assert_eq!( + plan.occurrences + .iter() + .find(|o| o.handle == extra) + .unwrap() + .model, + model + ); + assert!(plan.occurrences.iter().any(|o| o.mesh == visible.mesh)); + } } diff --git a/static/demo-loadouts.js b/static/demo-loadouts.js index c633b20..34245cf 100644 --- a/static/demo-loadouts.js +++ b/static/demo-loadouts.js @@ -48,12 +48,47 @@ export function createUvSphereGeometry(segments=24,rings=12){ const positions=[],normals=[],texcoords=[],indices=[];for(let y=0;y<=rings;y++){const v=y/rings,phi=v*Math.PI;for(let x=0;x<=segments;x++){const u=x/segments,theta=u*Math.PI*2,nx=Math.sin(phi)*Math.cos(theta),ny=Math.cos(phi),nz=Math.sin(phi)*Math.sin(theta);positions.push(nx,ny,nz);normals.push(nx,ny,nz);texcoords.push(u,v);}} for(let y=0;y{const binary=atob(value),bytes=new Uint8Array(binary.length);for(let i=0;i({name:`Dielectric roughness ${roughnessFactor}`,pbrMetallicRoughness:{baseColorFactor:[0.72,0.18,0.08,1],metallicFactor:0,roughnessFactor}})), + ...[0.08,0.3,0.6,1].map(roughnessFactor=>({name:`Metal roughness ${roughnessFactor}`,pbrMetallicRoughness:{baseColorFactor:[0.72,0.76,0.82,1],metallicFactor:1,roughnessFactor}})), + ...[1,1.5,2].map(ior=>({name:`Dielectric IOR ${ior}`,pbrMetallicRoughness:{baseColorFactor:[0.12,0.48,0.82,1],metallicFactor:0,roughnessFactor:0.18},extensions:{KHR_materials_ior:{ior}}})), + {name:"Odd-width OpenGL normal map",pbrMetallicRoughness:{baseColorFactor:[0.7,0.7,0.7,1],metallicFactor:0,roughnessFactor:0.4},normalTexture:{index:2,scale:1}}, + {name:"Odd-width AO",pbrMetallicRoughness:{baseColorFactor:[0.8,0.55,0.12,1],metallicFactor:0,roughnessFactor:0.65},occlusionTexture:{index:1,strength:1}}, + {name:"Odd-width emissive",pbrMetallicRoughness:{baseColorFactor:[0.03,0.03,0.03,1],metallicFactor:0,roughnessFactor:0.8},emissiveFactor:[1,0.3,0.05],emissiveTexture:{index:0}}, + {name:"Odd-width alpha MASK",pbrMetallicRoughness:{baseColorFactor:[1,1,1,1],baseColorTexture:{index:0},metallicFactor:0,roughnessFactor:0.55},alphaMode:"MASK",alphaCutoff:0.5,doubleSided:true}, + {name:"Reflected non-uniform double-sided",pbrMetallicRoughness:{baseColorFactor:[0.25,0.85,0.38,1],metallicFactor:0.15,roughnessFactor:0.45},doubleSided:true}, + ]; + const nodes=materials.map((material,index)=>({name:material.name,mesh:index,translation:[(index%4-1.5)*2.5,(1.5-Math.floor(index/4))*2.5,0],...(index===15?{scale:[-1.25,0.7,1.1]}:{})})); + const meshes=materials.map((material,index)=>({name:material.name,primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3,material:index}]})); + const json={asset:{version:"2.0",generator:"yawn-phase6-pbr-gallery"},extensionsUsed:["KHR_materials_ior"],scene:0,scenes:[{name:"Phase 6 deterministic PBR gallery",nodes:nodes.map((_,i)=>i)}],nodes,meshes,materials, + samplers:[{magFilter:9728,minFilter:9728,wrapS:10497,wrapT:10497}],images:images.map((_,i)=>({name:["Odd-width sRGB base color and emissive","Odd-width linear MR and AO","Odd-width OpenGL normal map"][i],bufferView:i+4,mimeType:"image/png"})),textures:images.map((_,i)=>({sampler:0,source:i})), + buffers:[{byteLength}],bufferViews,accessors:[{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},{bufferView:3,componentType:5125,count:geometry.indices.length,type:"SCALAR"}]}; + let jsonBytes=encoder.encode(JSON.stringify(json));const jsonLength=align4(jsonBytes.length),total=12+8+jsonLength+8+byteLength,out=new ArrayBuffer(total),view=new DataView(out),bytes=new Uint8Array(out); + view.setUint32(0,0x46546c67,true);view.setUint32(4,2,true);view.setUint32(8,total,true);view.setUint32(12,jsonLength,true);view.setUint32(16,JSON_CHUNK,true);bytes.fill(0x20,20,20+jsonLength);bytes.set(jsonBytes,20);const binHeader=20+jsonLength;view.setUint32(binHeader,byteLength,true);view.setUint32(binHeader+4,BIN_CHUNK,true);for(const chunk of chunks)bytes.set(chunk.bytes,binHeader+8+chunk.offset);return out; +} export function isGitLfsPointer(bytes){const text=new TextDecoder().decode(new Uint8Array(bytes,0,Math.min(bytes.byteLength,256)));return text.startsWith("version https://git-lfs.github.com/spec/v1\n");} export class LoadoutError extends Error{constructor(code,message){super(message);this.name="LoadoutError";this.code=code;}} -export const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},manor:{label:"The Manor"},sponza:{label:"Sponza"}}); +export const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},materials:{label:"Phase 6 deterministic PBR gallery"},manor:{label:"The Manor"},sponza:{label:"Sponza"}}); const assetUrls=Object.freeze({manor:new URL("./themanor.glb",import.meta.url),sponza:new URL("./sponza.glb",import.meta.url)}); export async function loadDemoLoadout(id,{signal,fetchImpl=fetch}={}){ - if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry()); + if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry());if(id==="materials")return createMaterialGalleryGlb(); const url=assetUrls[id];if(!url)throw new LoadoutError("LOADOUT_UNKNOWN",`Unknown loadout: ${id}`); let response;try{response=await fetchImpl(url,{signal});}catch(error){if(error?.name==="AbortError")throw error;throw new LoadoutError("LOADOUT_FETCH_FAILED",`Could not fetch ${id}: ${error?.message||"network error"}`);} if(!response.ok)throw new LoadoutError("LOADOUT_HTTP",`Could not fetch ${id}: HTTP ${response.status}`);const buffer=await response.arrayBuffer();if(isGitLfsPointer(buffer))throw new LoadoutError("LOADOUT_LFS_POINTER",`${id} is a Git LFS pointer; hydrate repository assets first`);return buffer; diff --git a/static/index.html b/static/index.html index 0cb712d..0a79917 100644 --- a/static/index.html +++ b/static/index.html @@ -1,5 +1,200 @@ -Yawn Render Graph Demo -
YAWNRender Graph Studio
Starting Phase 8…
Authored GraphLoading editor…
+ + + + + Yawn Render Graph Demo + + + +
+
+
+
+ YAWNRender Graph Studio +
+ +
+ Starting Phase 8… +
+
+
+ Authored GraphLoading editor… +
+ +
+
+ + + diff --git a/static/index.js b/static/index.js index fac92ea..45ff96b 100644 --- a/static/index.js +++ b/static/index.js @@ -6,55 +6,347 @@ import { AuthoringController } from "./render-graph/authoring-controller.js"; import { createRenderGraphEditor } from "./render-graph/fxnode-editor.js"; import { renderGraphPresets } from "./render-graph/presets.js"; -let renderer,editor,controller,assetAbort,busy=false,cleaned=false; -let unsubscribeController=()=>{},unsubscribeSnapshots=()=>{}; -const listeners=[]; -const on=(target,type,fn)=>{target.addEventListener(type,fn);listeners.push(()=>target.removeEventListener(type,fn));}; -const status=message=>{const node=document.querySelector("#demo-status");if(node)node.textContent=message;}; -const sameId=(a,b)=>Array.isArray(a)&&Array.isArray(b)&&a[0]===b[0]&&a[1]===b[1]; -const state={loadout:"cubes",graph:"authored",compiled:{},telemetry:null}; +let renderer, + editor, + controller, + assetAbort, + busy = false, + cleaned = false; +let unsubscribeController = () => {}, + unsubscribeSnapshots = () => {}; +const listeners = []; +const on = (target, type, fn) => { + target.addEventListener(type, fn); + listeners.push(() => target.removeEventListener(type, fn)); +}; +const status = (message) => { + const node = document.querySelector("#demo-status"); + if (node) node.textContent = message; +}; +const sameId = (a, b) => + Array.isArray(a) && Array.isArray(b) && a[0] === b[0] && a[1] === b[1]; +const state = { + loadout: "cubes", + graph: "authored", + compiled: {}, + telemetry: null, +}; +export const profileRequested = (search) => + new URLSearchParams(search).get("profile") === "1"; +const profileEnabled = profileRequested(location.search); +function installProfileMenu() { + if (!profileEnabled) return; + const menu = document.createElement("details"); + menu.id = "profile-menu"; + menu.innerHTML = + 'GPU profile
Waiting for GPU timestamps…
'; + document.querySelector(".toolbar")?.append(menu); + on(window, "renderer-profile", (event) => { + const p = event.detail; + document.querySelector("#profile-status").textContent = p.available + ? `${p.graph} · epoch ${p.epoch} · ${p.dropped} dropped` + : "GPU timestamps unavailable"; + document.querySelector("#profile-passes").replaceChildren( + ...Object.entries(p.passes || {}).map(([id, ms]) => { + const row = document.createElement("tr"), + name = document.createElement("th"), + value = document.createElement("td"); + name.textContent = id; + value.textContent = `${Number(ms).toFixed(3)} ms`; + row.append(name, value); + return row; + }), + ); + }); +} -function publish(telemetry){ - state.telemetry=telemetry; - document.documentElement.dataset.phase8State=JSON.stringify({activeLoadout:state.loadout,activeGraph:state.graph,renderDataRevision:telemetry.revision,renderMode:telemetry.renderMode,activeCompiledId:telemetry.activeCompiledId,activeCompiledGraph:telemetry.activeCompiledGraph,activeCompiledRevision:telemetry.activeCompiledRevision,graphPasses:telemetry.graphPasses,draws:telemetry.draws,instances:telemetry.instances,indices:telemetry.indices,framingRadius:telemetry.framingRadius,gpuError:telemetry.gpuError}); +function publish(telemetry) { + state.telemetry = telemetry; + document.documentElement.dataset.phase8State = JSON.stringify({ + activeLoadout: state.loadout, + activeGraph: state.graph, + renderDataRevision: telemetry.revision, + renderMode: telemetry.renderMode, + activeCompiledId: telemetry.activeCompiledId, + activeCompiledGraph: telemetry.activeCompiledGraph, + activeCompiledRevision: telemetry.activeCompiledRevision, + activeCompiledSchemaVersion: telemetry.activeCompiledSchemaVersion, + graphPasses: telemetry.graphPasses, + graphExecutions: telemetry.graphExecutions, + graphTextureSlots: telemetry.graphTextureSlots, + draws: telemetry.draws, + instances: telemetry.instances, + indices: telemetry.indices, + framingRadius: telemetry.framingRadius, + gpuError: telemetry.gpuError, + }); } -function waitTelemetry(predicate,timeout=30000){ - const current=renderer?.telemetry;if(current&&predicate(current))return Promise.resolve(current); - return new Promise((resolve,reject)=>{let timer;const done=()=>{clearTimeout(timer);removeEventListener("renderer-frame",frame);};const frame=e=>{if(predicate(e.detail)){done();resolve(e.detail);}};timer=setTimeout(()=>{done();reject(new Error("Telemetry confirmation timed out"));},timeout);onAbort=()=>{done();reject(new RendererError("DISPOSED"));};addEventListener("renderer-frame",frame);timer.unref?.();}); +function waitTelemetry(predicate, timeout = 30000) { + const current = renderer?.telemetry; + if (current && predicate(current)) return Promise.resolve(current); + return new Promise((resolve, reject) => { + let timer; + const done = () => { + clearTimeout(timer); + removeEventListener("renderer-frame", frame); + }; + const frame = (e) => { + if (predicate(e.detail)) { + done(); + resolve(e.detail); + } + }; + timer = setTimeout(() => { + done(); + reject(new Error("Telemetry confirmation timed out")); + }, timeout); + onAbort = () => { + done(); + reject(new RendererError("DISPOSED")); + }; + addEventListener("renderer-frame", frame); + timer.unref?.(); + }); } -let onAbort=()=>{}; -async function transaction(label,operation,rollback){ - if(busy||cleaned)return false;busy=true;document.querySelectorAll("select, #apply-graph").forEach(x=>x.disabled=true);status(label); - try{const telemetry=await operation();if(cleaned)return false;if(telemetry){publish(telemetry);status(`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`);}else status(`${state.loadout} · ${state.graph} · committed; telemetry pending`);return true;} - catch(error){if(!cleaned){try{await rollback?.();}catch(rollbackError){console.error("Phase 8 rollback failed",rollbackError);}console.error("Phase 8 transaction failed",error);status(`Failed · ${error?.code??error?.message??error}`);}return false;} - finally{busy=false;if(!cleaned){document.querySelectorAll("select").forEach(x=>x.disabled=false);const button=document.querySelector("#apply-graph");if(button)button.disabled=controller?.applying||!controller?.dirty;}} +let onAbort = () => {}; +async function transaction(label, operation, rollback) { + if (busy || cleaned) return false; + busy = true; + document + .querySelectorAll("select, #apply-graph") + .forEach((x) => (x.disabled = true)); + status(label); + try { + const telemetry = await operation(); + if (cleaned) return false; + if (telemetry) { + publish(telemetry); + status( + `${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`, + ); + } else + status( + `${state.loadout} · ${state.graph} · committed; telemetry pending`, + ); + return true; + } catch (error) { + if (!cleaned) { + try { + await rollback?.(); + } catch (rollbackError) { + console.error("Phase 8 rollback failed", rollbackError); + } + console.error("Phase 8 transaction failed", error); + status(`Failed · ${error?.code ?? error?.message ?? error}`); + } + return false; + } finally { + busy = false; + if (!cleaned) { + document.querySelectorAll("select").forEach((x) => (x.disabled = false)); + const button = document.querySelector("#apply-graph"); + if (button) button.disabled = !controller?.canApply; + } + } } -async function selectLoadout(next,select){ - const previous=state.loadout,targetRevision=(renderer.telemetry?.revision??0)+1;assetAbort=new AbortController(); - const ok=await transaction(`Loading ${next}…`,async()=>{const glb=await loadDemoLoadout(next,{signal:assetAbort.signal});await renderer.replaceSceneGlb(glb,{framing:next==="sponza"?"interior":"exterior"});state.loadout=next;return waitTelemetry(x=>x.revision===targetRevision&&x.draws>0&&x.activeCompiledGraph===state.compiled[state.graph].graphId&&x.gpuError===false).catch(()=>null);}); - assetAbort=undefined;if(!ok)select.value=previous; +async function selectLoadout(next, select) { + const previous = state.loadout, + targetRevision = (renderer.telemetry?.revision ?? 0) + 1; + assetAbort = new AbortController(); + const ok = await transaction(`Loading ${next}…`, async () => { + const glb = await loadDemoLoadout(next, { signal: assetAbort.signal }); + await renderer.replaceSceneGlb(glb, { + framing: next === "sponza" ? "interior" : "exterior", + }); + state.loadout = next; + return waitTelemetry( + (x) => + x.revision === targetRevision && + x.draws > 0 && + x.activeCompiledGraph === state.compiled[state.graph].graphId && + x.gpuError === false, + ).catch(() => null); + }); + assetAbort = undefined; + if (!ok) select.value = previous; } -async function selectGraph(next,select){ - const previous=state.graph,compiled=state.compiled[next]; - const ok=await transaction(`Activating ${next}…`,async()=>{await renderer.switchCompiledGraph(compiled.compiledId);state.graph=next;return waitTelemetry(x=>sameId(x.activeCompiledId,compiled.compiledId)&&x.activeCompiledGraph===compiled.graphId&&x.activeCompiledRevision===compiled.revision&&x.gpuError===false).catch(()=>null);},async()=>{state.graph=previous;select.value=previous;}); - if(!ok)select.value=previous; +async function selectGraph(next, select) { + const previous = state.graph, + compiled = state.compiled[next]; + const ok = await transaction( + `Activating ${next}…`, + async () => { + await renderer.switchCompiledGraph(compiled.compiledId); + state.graph = next; + return waitTelemetry( + (x) => + sameId(x.activeCompiledId, compiled.compiledId) && + x.activeCompiledGraph === compiled.graphId && + x.activeCompiledRevision === compiled.revision && + x.gpuError === false, + ).catch(() => null); + }, + async () => { + state.graph = previous; + select.value = previous; + }, + ); + if (!ok) select.value = previous; } -async function cleanup(){if(cleaned)return;cleaned=true;removeEventListener("pagehide",pagehide);assetAbort?.abort();onAbort();listeners.splice(0).forEach(fn=>fn());unsubscribeController();unsubscribeSnapshots();try{await editor?.destroy();}finally{renderer?.dispose();}} -const pagehide=()=>{void cleanup()}; +async function cleanup() { + if (cleaned) return; + cleaned = true; + removeEventListener("pagehide", pagehide); + assetAbort?.abort(); + onAbort(); + listeners.splice(0).forEach((fn) => fn()); + unsubscribeController(); + unsubscribeSnapshots(); + try { + await controller?.destroy(); + await editor?.destroy(); + } finally { + renderer?.dispose(); + } +} +const pagehide = () => { + void cleanup(); +}; -async function start(){ - addEventListener("pagehide",pagehide,{once:true});delete document.documentElement.dataset.phase8Ready;await wbg_init();if(cleaned)return; - renderer=new RendererClient(main());await renderer.ready;const nextEditor=await createRenderGraphEditor(document.querySelector("#graph-editor"));if(cleaned){await nextEditor.destroy();return}editor=nextEditor; - controller=new AuthoringController({renderer,getState:editor.getState});const apply=document.querySelector("#apply-graph"),graphStatus=document.querySelector("#graph-status"),loadoutSelect=document.querySelector("#loadout-select"),graphSelect=document.querySelector("#graph-select"); - unsubscribeController=controller.subscribe(s=>{apply.disabled=busy||s.applying||!s.dirty;graphStatus.textContent=s.applying?"Applying…":s.dirty?"Unapplied changes":`Authored revision ${s.revision}`;});unsubscribeSnapshots=editor.onSnapshots(()=>controller.markDirty()); - const authored=await controller.apply(adaptFxNodeSnapshot);state.compiled.authored={...authored,graphId:"demo_forward"}; - for(const [name,preset] of Object.entries(renderGraphPresets)){const compiled=await renderer.compileGraph(preset);state.compiled[name]={...compiled,graphId:preset.graphId,revision:preset.revision};} - on(window,"renderer-frame",event=>{const expected=state.compiled[state.graph],telemetry=event.detail;if(expected&&telemetry.activeCompiledGraph===expected.graphId&&sameId(telemetry.activeCompiledId,expected.compiledId)&&telemetry.gpuError===false){publish(telemetry);if(!busy)status(`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`);}}); - on(loadoutSelect,"change",()=>void selectLoadout(loadoutSelect.value,loadoutSelect));on(graphSelect,"change",()=>void selectGraph(graphSelect.value,graphSelect)); - on(apply,"click",()=>{const previous=state.graph,previousAuthored=state.compiled.authored;void transaction("Applying authored graph…",async()=>{const compiled=await controller.apply(adaptFxNodeSnapshot);state.compiled.authored={...compiled,graphId:"demo_forward"};state.graph="authored";graphSelect.value="authored";return waitTelemetry(x=>sameId(x.activeCompiledId,compiled.compiledId)&&x.activeCompiledGraph==="demo_forward"&&x.activeCompiledRevision===compiled.revision&&x.gpuError===false).catch(()=>null);},async()=>{state.compiled.authored=previousAuthored;state.graph=previous;graphSelect.value=previous;controller.markDirty();});}); +async function start() { + addEventListener("pagehide", pagehide, { once: true }); + delete document.documentElement.dataset.phase8Ready; + await wbg_init(); + if (cleaned) return; + renderer = new RendererClient(main(profileEnabled)); + installProfileMenu(); + await renderer.ready; + const nextEditor = await createRenderGraphEditor( + document.querySelector("#graph-editor"), + ); + if (cleaned) { + await nextEditor.destroy(); + return; + } + editor = nextEditor; + controller = new AuthoringController({ + renderer, + adapt: adaptFxNodeSnapshot, + }); + const apply = document.querySelector("#apply-graph"), + graphStatus = document.querySelector("#graph-status"), + loadoutSelect = document.querySelector("#loadout-select"), + graphSelect = document.querySelector("#graph-select"); + unsubscribeController = controller.subscribe((s) => { + apply.disabled = busy || !s.canApply; + graphStatus.textContent = s.error + ? `Invalid · ${s.error.code ?? s.error.message}` + : s.applying + ? "Applying…" + : s.dirty + ? s.staged + ? "Ready to apply" + : "Validating…" + : `Authored revision ${s.revision}`; + }); + unsubscribeSnapshots = editor.onSnapshots((snapshot) => + controller.markDirty(snapshot), + ); + controller.markDirty(await editor.getState()); + const authored = await controller.apply(); + state.compiled.authored = { ...authored, graphId: "authored_gpu_culling" }; + for (const [name, preset] of Object.entries(renderGraphPresets)) { + const compiled = await renderer.compileGraph(preset); + state.compiled[name] = { + ...compiled, + graphId: preset.graphId, + revision: preset.revision, + }; + } + on(window, "renderer-frame", (event) => { + const expected = state.compiled[state.graph], + telemetry = event.detail; + if ( + expected && + telemetry.activeCompiledGraph === expected.graphId && + sameId(telemetry.activeCompiledId, expected.compiledId) && + telemetry.gpuError === false + ) { + publish(telemetry); + if (!busy) + status( + `${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`, + ); + } + }); + on( + loadoutSelect, + "change", + () => void selectLoadout(loadoutSelect.value, loadoutSelect), + ); + on( + graphSelect, + "change", + () => void selectGraph(graphSelect.value, graphSelect), + ); + on(apply, "click", () => { + const previous = state.graph, + previousAuthored = state.compiled.authored; + void transaction( + "Applying authored graph…", + async () => { + const compiled = await controller.apply(); + state.compiled.authored = { + ...compiled, + graphId: "authored_gpu_culling", + }; + state.graph = "authored"; + graphSelect.value = "authored"; + return waitTelemetry( + (x) => + sameId(x.activeCompiledId, compiled.compiledId) && + x.activeCompiledGraph === "authored_gpu_culling" && + x.activeCompiledRevision === compiled.revision && + x.gpuError === false, + ).catch(() => null); + }, + async () => { + state.compiled.authored = previousAuthored; + state.graph = previous; + graphSelect.value = previous; + }, + ); + }); await editor.whenRendered(); - const initialized=await transaction("Preparing procedural cubes…",async()=>{const targetRevision=(renderer.telemetry?.revision??0)+1;await renderer.replaceSceneGlb(await loadDemoLoadout("cubes"));await renderer.switchCompiledGraph(authored.compiledId);return waitTelemetry(x=>x.revision===targetRevision&&x.draws>0&&x.activeCompiledGraph==="demo_forward"&&x.activeCompiledRevision===authored.revision&&x.gpuError===false);}); - if(!initialized)throw new Error("Initial demo transaction failed");document.documentElement.dataset.phase8Ready="true"; + const initialized = await transaction( + "Preparing procedural cubes…", + async () => { + const targetRevision = (renderer.telemetry?.revision ?? 0) + 1; + await renderer.replaceSceneGlb(await loadDemoLoadout("cubes")); + await renderer.switchCompiledGraph(authored.compiledId); + return waitTelemetry( + (x) => + x.revision === targetRevision && + x.draws > 0 && + x.activeCompiledGraph === "authored_gpu_culling" && + x.activeCompiledRevision === authored.revision && + x.gpuError === false, + ); + }, + ); + if (!initialized) throw new Error("Initial demo transaction failed"); + document.documentElement.dataset.phase8Ready = "true"; } -const startupError=error=>{if(cleaned)return;console.error("Phase 8 startup failed",error);status(`Startup failed · ${error?.code??error}`);void cleanup();}; -if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>start().catch(startupError),{once:true});else start().catch(startupError); +const startupError = (error) => { + if (cleaned) return; + console.error("Phase 8 startup failed", error); + status(`Startup failed · ${error?.code ?? error}`); + void cleanup(); +}; +if (document.readyState === "loading") + document.addEventListener( + "DOMContentLoaded", + () => start().catch(startupError), + { once: true }, + ); +else start().catch(startupError); diff --git a/static/render-graph/adapter.js b/static/render-graph/adapter.js index 53cd470..13d6ac8 100644 --- a/static/render-graph/adapter.js +++ b/static/render-graph/adapter.js @@ -1,75 +1,420 @@ -import { CATALOG_VERSION, descriptors, GRAPH_ID } from "./catalog.js"; +import { + CATALOG_VERSION, + descriptors, + GRAPH_ID, + nodeDefinitions, + socketTypes, +} from "./catalog.js"; export class AuthoringGraphError extends Error { - constructor(code, details = {}) { super(code); this.name="AuthoringGraphError"; this.code=code; this.details=Object.freeze(details); } -} -const fail=(code,details)=>{throw new AuthoringGraphError(code,details)}; -const object=v=>v !== null && typeof v === "object" && !Array.isArray(v); -const validId=v=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9_.-]*$/.test(v) && new TextEncoder().encode(v).length<=64; -const validSocketId=v=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9_.-]*:[A-Za-z][A-Za-z0-9_.-]*$/.test(v) && new TextEncoder().encode(v).length<=129; -const keysEqual=(a,b)=>a.length===b.length && a.every(x=>b.includes(x)); - -/** Validate hostile fxnode state and return an app-owned, layout-free projection. */ -export function projectAuthoringSnapshot(raw) { - if(!object(raw)||!Array.isArray(raw.nodes)||!Array.isArray(raw.links)) fail("AUTHORING_SHAPE",{field:"snapshot"}); - if(raw.graphId!==GRAPH_ID||raw.catalogVersion!==CATALOG_VERSION) fail("AUTHORING_CATALOG",{graphId:raw.graphId,catalogVersion:raw.catalogVersion}); - const nodeIds=new Set(), socketIds=new Set(), byId=new Map(), byType=new Map(); - for(const n of raw.nodes){ - if(!object(n)||!validId(n.id)) fail("AUTHORING_ID",{kind:"node",id:n?.id}); - if(nodeIds.has(n.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"node",id:n.id}); nodeIds.add(n.id); - const d=descriptors[n.typeId]; - if(!d) fail("AUTHORING_NODE_TYPE",{nodeId:n.id,typeId:n.typeId}); - if(n.known!==true) fail("AUTHORING_NODE_UNKNOWN",{nodeId:n.id}); - if(n.typeVersion!==d.version) fail("AUTHORING_NODE_VERSION",{nodeId:n.id,expected:d.version,actual:n.typeVersion}); - if(typeof n.muted!=="boolean") fail("AUTHORING_NODE_MUTED",{nodeId:n.id}); - if(n.muted&&n.typeId!=="scene_forward") fail("AUTHORING_NODE_MUTED",{nodeId:n.id}); - if(byType.has(n.typeId)) fail("AUTHORING_TOPOLOGY",{reason:"duplicate-type",typeId:n.typeId}); - if(!Array.isArray(n.sockets)||!keysEqual(n.sockets.map(s=>s?.key),Object.keys(d.sockets))) fail("AUTHORING_SOCKET_SET",{nodeId:n.id}); - const sockets={}; - for(const s of n.sockets){ const expected=d.sockets[s.key]; - if(!object(s)||!validSocketId(s.id)) fail("AUTHORING_ID",{kind:"socket",id:s?.id}); - if(socketIds.has(s.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"socket",id:s.id}); socketIds.add(s.id); - if(s.direction!==expected[0]||s.dataType!==expected[1]) fail("AUTHORING_SOCKET",{nodeId:n.id,socket:s.key}); - sockets[s.key]={id:s.id,direction:s.direction,type:s.dataType,nodeId:n.id}; - } - const p=object(n.parameters)?n.parameters:null; - if(!p||!keysEqual(Object.keys(p),d.parameters)) fail("AUTHORING_PARAMETERS",{nodeId:n.id}); - let parameters={}; - if(n.typeId==="scene_forward"){ - const c=p.clearColor, z=p.clearDepth; - if(!object(c)||c.kind!=="color"||!Array.isArray(c.value)||c.value.length!==4||c.value.some(v=>!Number.isFinite(v)||v<0||v>1)) fail("AUTHORING_PARAMETER",{parameter:"clearColor"}); - if(!object(z)||z.kind!=="number"||!Number.isFinite(z.value)||z.value<0||z.value>1) fail("AUTHORING_PARAMETER",{parameter:"clearDepth"}); - parameters={clearColor:[...c.value],clearDepth:z.value}; - } - const projected={type:n.typeId,id:n.id,sockets,parameters,muted:n.muted}; byId.set(n.id,projected); byType.set(n.typeId,projected); + constructor(code, details = {}) { + super(code); + this.name = "AuthoringGraphError"; + this.code = code; + this.details = Object.freeze({ ...details }); } - if(!keysEqual([...byType.keys()],Object.keys(descriptors))) fail("AUTHORING_TOPOLOGY",{reason:"node-set"}); - const linkIds=new Set(), incoming=new Set(), links=[]; - for(const l of raw.links){ - if(!object(l)||!validId(l.id)) fail("AUTHORING_ID",{kind:"link",id:l?.id}); - if(linkIds.has(l.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"link",id:l.id}); linkIds.add(l.id); - if(typeof l.muted!=="boolean") fail("AUTHORING_LINK",{linkId:l.id,reason:"muted"}); - const from=byId.get(l.fromNodeId),to=byId.get(l.toNodeId),fs=from&&Object.values(from.sockets).find(s=>s.id===l.fromSocketId),ts=to&&Object.values(to.sockets).find(s=>s.id===l.toSocketId); - if(!fs||!ts||fs.direction!=="output"||ts.direction!=="input"||fs.type!==ts.type) fail("AUTHORING_LINK",{linkId:l.id}); - if(incoming.has(ts.id)) fail("AUTHORING_LINK_INCOMING",{socketId:ts.id}); incoming.add(ts.id); - links.push({from:`${from.type}.${Object.keys(from.sockets).find(k=>from.sockets[k]===fs)}`,to:`${to.type}.${Object.keys(to.sockets).find(k=>to.sockets[k]===ts)}`,muted:l.muted}); - } - const required=["surface_color.surface>scene_forward.color","depth32.depth>scene_forward.depth","scene_forward.result>present.surface"]; - const active=links.filter(l=>!l.muted).map(l=>`${l.from}>${l.to}`).sort(); - if(!keysEqual(active,required.sort())||links.length!==3) fail("AUTHORING_TOPOLOGY",{reason:"links"}); - return Object.freeze({graphId:GRAPH_ID,clearColor:byType.get("scene_forward").parameters.clearColor,clearDepth:byType.get("scene_forward").parameters.clearDepth,passState:byType.get("scene_forward").muted?"disabled":"enabled"}); } -export function semanticProjectionToV1(p, revision=1){ - if(!Number.isInteger(revision)||revision<1||revision>0xffffffff) fail("AUTHORING_REVISION",{revision}); - const extent={kind:"surface_relative",width:{numerator:1,denominator:1},height:{numerator:1,denominator:1},depthOrArrayLayers:1}; - return {schemaVersion:1,graphId:p.graphId,revision,resources:[ - {id:"surface",version:0,residency:{kind:"external",source:"surface_color"},texture:{dimension:"d2",format:"surface",extent,mipLevelCount:1,sampleCount:1}}, - {id:"depth",version:0,residency:{kind:"transient"},texture:{dimension:"d2",format:"depth32_float",extent,mipLevelCount:1,sampleCount:1}}, - ],passes:[{id:"forward",state:p.passState,executor:{key:"scene_forward",version:1},parameters:{},reads:[],writes:[ - {binding:"color",resource:{id:"surface",version:0},access:{kind:"color_attachment",location:0,load:{op:"clear",value:p.clearColor},store:"store"}}, - {binding:"depth",resource:{id:"depth",version:0},access:{kind:"depth_attachment",load:{op:"clear",value:p.clearDepth},store:"store"}}, - ]}],outputs:[{name:"present",resource:{id:"surface",version:0}}]}; +const fail = (code, details) => { + throw new AuthoringGraphError(code, details); +}; +const object = (value) => + value !== null && typeof value === "object" && !Array.isArray(value); +const identifier = (value) => + typeof value === "string" && + /^[A-Za-z][A-Za-z0-9_.-]*$/.test(value) && + new TextEncoder().encode(value).length <= 64; +const exactKeys = (value, keys) => + object(value) && + Object.keys(value).length === keys.length && + keys.every((key) => Object.hasOwn(value, key)); +const finiteJson = (value) => + value === null || + typeof value === "string" || + typeof value === "boolean" || + (typeof value === "number" && Number.isFinite(value)) || + (Array.isArray(value) && value.every(finiteJson)) || + (object(value) && Object.values(value).every(finiteJson)); +const canonical = (value) => + Array.isArray(value) + ? value.map(canonical) + : object(value) + ? Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, canonical(value[key])]), + ) + : value; +const deepFreeze = (value) => { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + } + return value; +}; +const sourceMaps = new WeakMap(); +export const getSourceMap = (ir) => sourceMaps.get(ir); +export const mapAuthoringDiagnostic = (ir, diagnostic) => { + const details = diagnostic?.details; + const path = [details?.path, diagnostic?.path, details?.field, diagnostic?.field] + .find((value) => typeof value === "string"); + const map = getSourceMap(ir); + let match; + if (path && map) + for (const key of Object.keys(map)) + if ( + (path === key || path.startsWith(`${key}.`) || path.startsWith(`${key}[`)) && + (!match || key.length > match.length) + ) + match = key; + return deepFreeze({ + name: diagnostic?.name, + code: diagnostic?.code, + message: details?.message ?? diagnostic?.message ?? diagnostic?.code, + details: details === undefined ? undefined : structuredClone(details), + path, + source: match ? structuredClone(map[match]) : undefined, + }); +}; + +const mapValuePaths = (paths, path, source, value) => { + paths[path] = source; + if (Array.isArray(value)) + value.forEach((child, index) => mapValuePaths(paths, `${path}[${index}]`, source, child)); + else if (object(value)) + for (const key of Object.keys(value)) + mapValuePaths(paths, `${path}.${key}`, source, value[key]); +}; + +function parameterValue(raw, schema, nodeId, key) { + const expected = schema.type === "json" ? "json" : schema.type; + if ( + !exactKeys(raw, ["kind", "value"]) || + raw.kind !== expected || + !finiteJson(raw.value) + ) + fail("AUTHORING_PARAMETER", { nodeId, parameter: key }); + if ( + (expected === "number" && typeof raw.value !== "number") || + (expected === "string" && typeof raw.value !== "string") || + (expected === "boolean" && typeof raw.value !== "boolean") || + (expected === "json" && !finiteJson(raw.value)) + ) + fail("AUTHORING_PARAMETER", { nodeId, parameter: key }); + return canonical(structuredClone(raw.value)); +} + +export function adaptFxNodeSnapshot(raw, revision = 1) { + try { + const rootKeys = ["graphId", "catalogVersion", "nodes", "links", "metadata", "version"]; + if ( + !exactKeys(raw, rootKeys) || + !Array.isArray(raw.nodes) || + !Array.isArray(raw.links) || + !object(raw.metadata) || + !finiteJson(raw.metadata) + ) + fail("AUTHORING_SHAPE"); + if (raw.graphId !== GRAPH_ID || raw.catalogVersion !== CATALOG_VERSION) + fail("AUTHORING_CATALOG"); + if ( + !Number.isSafeInteger(raw.version) || raw.version < 0 + ) + fail("AUTHORING_SHAPE"); + if (!Number.isInteger(revision) || revision < 1 || revision > 0xffffffff) + fail("AUTHORING_REVISION"); + const nodes = new Map(), + sockets = new Map(), + paths = {}; + for (let ordinal = 0; ordinal < raw.nodes.length; ordinal++) { + const n = raw.nodes[ordinal]; + if (!object(n) || !identifier(n.id)) fail("AUTHORING_ID", { id: n?.id }); + if (nodes.has(n.id)) fail("AUTHORING_ID_DUPLICATE", { id: n.id }); + const descriptor = descriptors[n.typeId], + definition = nodeDefinitions[n.typeId]; + if (!descriptor) + fail("AUTHORING_NODE_TYPE", { nodeId: n.id, typeId: n.typeId }); + const nodeKeys = ["id", "typeId", "typeVersion", "position", "size", "label", "parameters", "sockets", "muted", "collapsed", "extensions", "known"]; + if (Object.hasOwn(n, "parentId")) nodeKeys.push("parentId"); + if ( + !exactKeys(n, nodeKeys) || + n.known !== true || + n.typeVersion !== 1 || + typeof n.muted !== "boolean" || + typeof n.collapsed !== "boolean" || + typeof n.label !== "string" || + !exactKeys(n.position, ["x", "y"]) || !Number.isFinite(n.position.x) || !Number.isFinite(n.position.y) || + !exactKeys(n.size, ["x", "y"]) || !Number.isFinite(n.size.x) || !Number.isFinite(n.size.y) || n.size.x <= 0 || n.size.y <= 0 || + (Object.hasOwn(n, "parentId") && !identifier(n.parentId)) || + !object(n.extensions) || !finiteJson(n.extensions) || + !Array.isArray(n.sockets) || + !object(n.parameters) + ) + fail("AUTHORING_NODE_INVALID", { nodeId: n.id }); + const parameterKeys = Object.keys(definition.parameters); + if ( + Object.keys(n.parameters).length !== parameterKeys.length || + !parameterKeys.every((key) => Object.hasOwn(n.parameters, key)) + ) + fail("AUTHORING_PARAMETER_SET", { nodeId: n.id }); + const parameters = Object.fromEntries( + parameterKeys.map((key) => [ + key, + parameterValue( + n.parameters[key], + definition.parameters[key], + n.id, + key, + ), + ]), + ); + const expected = [ + ...Object.keys(descriptor.inputs), + ...Object.keys(descriptor.outputs), + ]; + if (n.sockets.length !== expected.length) + fail("AUTHORING_SOCKET_SET", { nodeId: n.id }); + for (const s of n.sockets) { + if (!object(s) || !expected.includes(s.key) || sockets.has(s.id)) + fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s?.key }); + const input = descriptor.inputs[s.key], + socketDefinition = definition.sockets[s.key], + direction = input ? "input" : "output", + dataType = socketDefinition.type, + socketKeys = ["id", "key", "label", "direction", "dataType", "accepts", "maxIncomingLinks", ...(socketDefinition.value ? ["defaultValue"] : []), "visible"]; + if ( + !exactKeys(s, socketKeys) || + s.id !== `${n.id}:${s.key}` || + s.label !== socketDefinition.title || + s.direction !== direction || + s.dataType !== dataType || + !Array.isArray(s.accepts) || s.accepts.length !== (direction === "input" ? socketTypes[dataType].acceptsFrom.length : 0) || + !s.accepts.every((v, i) => v === (direction === "input" ? socketTypes[dataType].acceptsFrom[i] : undefined)) || + (socketDefinition.value + ? !exactKeys(s.defaultValue, ["kind", "value"]) || !finiteJson(s.defaultValue.value) + : s.defaultValue !== undefined) || + s.visible !== socketDefinition.visible || + s.maxIncomingLinks !== socketDefinition.maxIncomingLinks + ) + fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s.key }); + sockets.set(s.id, { + node: n.id, + key: s.key, + direction, + semanticType: input + ? input.accepted.types[0] + : descriptor.outputs[s.key].type, + authoringType: s.dataType, + maxIncomingLinks: s.maxIncomingLinks, + }); + } + if (new Set(n.sockets.map((s) => s.key)).size !== expected.length) + fail("AUTHORING_SOCKET_SET", { nodeId: n.id }); + nodes.set(n.id, { + ordinal, + value: { + id: n.id, + state: n.muted ? "muted" : "enabled", + executor: { key: n.typeId, version: 1 }, + parameters, + inputs: {}, + }, + }); + } + const incoming = new Map(), + linkIds = new Set(), + linkSources = new Map(); + for (let ordinal = 0; ordinal < raw.links.length; ordinal++) { + const link = raw.links[ordinal]; + if ( + !object(link) || + !identifier(link.id) || + linkIds.has(link.id) || + !exactKeys(link, ["id", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted", "extensions"]) || + typeof link.muted !== "boolean" || !object(link.extensions) || !finiteJson(link.extensions) + ) + fail("AUTHORING_LINK", { linkId: link?.id }); + linkIds.add(link.id); + const from = sockets.get(link.fromSocketId), + to = sockets.get(link.toSocketId); + if ( + !from || + !to || + link.fromNodeId !== from.node || + link.toNodeId !== to.node || + from.direction !== "output" || + to.direction !== "input" || + (!link.muted && (incoming.get(link.toSocketId) ?? 0) >= to.maxIncomingLinks) + ) + fail( + !link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity) + ? "AUTHORING_LINK_INCOMING" + : "AUTHORING_LINK", + !link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity) + ? { socketId: link.toSocketId } + : { linkId: link.id }, + ); + const accepted = + descriptors[nodes.get(to.node).value.executor.key].inputs[to.key] + .accepted.types; + const authoringAccepted = socketTypes[nodeDefinitions[nodes.get(to.node).value.executor.key].sockets[to.key].type].acceptsFrom; + if (!accepted.includes(from.semanticType) || !authoringAccepted.includes(from.authoringType)) + fail("AUTHORING_LINK_TYPE", { linkId: link.id }); + const linkSource = { + kind: "link", + linkId: link.id, + fromNodeId: link.fromNodeId, + fromSocketId: link.fromSocketId, + toNodeId: link.toNodeId, + toSocketId: link.toSocketId, + muted: link.muted, + nodeId: to.node, + input: to.key, + fromSocket: from.key, + toSocket: to.key, + }; + linkSources.set(link.id, linkSource); + if (!link.muted) { + incoming.set(link.toSocketId, (incoming.get(link.toSocketId) ?? 0) + 1); + nodes.get(to.node).value.inputs[to.key] = { + node: from.node, + socket: from.key, + }; + } + } + const ordered = [...nodes.values()].sort((a, b) => + a.value.id < b.value.id + ? -1 + : a.value.id > b.value.id + ? 1 + : a.ordinal - b.ordinal, + ); + for (let wireOrdinal = 0; wireOrdinal < ordered.length; wireOrdinal++) { + const item = ordered[wireOrdinal]; + item.value.inputs = Object.fromEntries( + Object.keys(descriptors[item.value.executor.key].inputs) + .filter((key) => Object.hasOwn(item.value.inputs, key)) + .map((key) => [key, item.value.inputs[key]]), + ); + const base = `nodes[${wireOrdinal}]`; + const nodeSource = { kind: "node", nodeId: item.value.id }; + paths[base] = nodeSource; + for (const field of ["id", "state", "executor", "executor.key", "executor.version"]) + paths[`${base}.${field}`] = nodeSource; + paths[`${base}.parameters`] = nodeSource; + for (const key of Object.keys(item.value.parameters)) + mapValuePaths(paths, `${base}.parameters.${key}`, { kind: "parameter", nodeId: item.value.id, parameter: key }, item.value.parameters[key]); + for (const key of Object.keys(descriptors[item.value.executor.key].inputs)) { + const link = raw.links.find((x) => !x.muted && x.toNodeId === item.value.id && sockets.get(x.toSocketId)?.key === key); + const source = linkSources.get(link?.id) ?? { + kind: "input", + nodeId: item.value.id, + input: key, + socketId: `${item.value.id}:${key}`, + unconnected: true, + }; + paths[`${base}.inputs.${key}`] = source; + if (link) { + paths[`${base}.inputs.${key}.node`] = source; + paths[`${base}.inputs.${key}.socket`] = { + kind: "socket", + nodeId: link.fromNodeId, + socketId: link.fromSocketId, + socket: sockets.get(link.fromSocketId).key, + linkId: link.id, + }; + } + } + paths[`${base}.inputs`] = nodeSource; + } + const ir = { + schemaVersion: 2, + graphId: GRAPH_ID, + revision, + nodes: ordered.map((item) => item.value), + }; + const graphSource = { kind: "graph", graphId: GRAPH_ID }; + for (const field of ["schemaVersion", "graphId", "revision", "nodes"]) + paths[field] = graphSource; + deepFreeze(paths); + deepFreeze(ir); + sourceMaps.set(ir, paths); + return ir; + } catch (error) { + if (error instanceof AuthoringGraphError) throw error; + fail("AUTHORING_SHAPE"); + } +} +export const adaptGraphSnapshot = adaptFxNodeSnapshot; + +/** Compatibility helper retained for V1 presets. */ +export function semanticProjectionToV1(p, revision = 1) { + const extent = { + kind: "surface_relative", + width: { numerator: 1, denominator: 1 }, + height: { numerator: 1, denominator: 1 }, + depthOrArrayLayers: 1, + }; + return { + schemaVersion: 1, + graphId: p.graphId, + revision, + resources: [ + { + id: "surface", + version: 0, + residency: { kind: "external", source: "surface_color" }, + texture: { + dimension: "d2", + format: "surface", + extent, + mipLevelCount: 1, + sampleCount: 1, + }, + }, + { + id: "depth", + version: 0, + residency: { kind: "transient" }, + texture: { + dimension: "d2", + format: "depth32_float", + extent, + mipLevelCount: 1, + sampleCount: 1, + }, + }, + ], + passes: [ + { + id: "forward", + state: p.passState, + executor: { key: "scene_forward", version: 1 }, + parameters: {}, + reads: [], + writes: [ + { + binding: "color", + resource: { id: "surface", version: 0 }, + access: { + kind: "color_attachment", + location: 0, + load: { op: "clear", value: p.clearColor }, + store: "store", + }, + }, + { + binding: "depth", + resource: { id: "depth", version: 0 }, + access: { + kind: "depth_attachment", + load: { op: "clear", value: p.clearDepth }, + store: "store", + }, + }, + ], + }, + ], + outputs: [{ name: "present", resource: { id: "surface", version: 0 } }], + }; } -export const adaptFxNodeSnapshot=(snapshot,revision=1)=>semanticProjectionToV1(projectAuthoringSnapshot(snapshot),revision); -export const adaptGraphSnapshot=adaptFxNodeSnapshot; diff --git a/static/render-graph/add-node-menu.js b/static/render-graph/add-node-menu.js new file mode 100644 index 0000000..836a748 --- /dev/null +++ b/static/render-graph/add-node-menu.js @@ -0,0 +1,130 @@ +import { semanticCatalog } from "./catalog.js"; + +const GROUPS = Object.freeze([ + ["source", "Source"], + ["compute", "Compute"], + ["render", "Render / post"], + ["present", "Present"], +]); + +const title = (typeId) => typeId.replaceAll("_", " "); + +/** Application-owned, immutable add-node catalog model. */ +export const addNodeItems = Object.freeze( + GROUPS.flatMap(([execution, group]) => + Object.entries(semanticCatalog) + .filter(([, definition]) => definition.execution === execution) + .map(([typeId]) => Object.freeze({ typeId, title: title(typeId), group })), + ), +); + +export function searchAddNodeItems(query, items = addNodeItems) { + const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); + return items.filter((item) => terms.every((term) => + `${item.title} ${item.typeId} ${item.group}`.toLocaleLowerCase().includes(term), + )); +} + +export function moveAddNodeSelection(index, delta, length) { + return length ? ((Math.max(0, index) + delta) % length + length) % length : -1; +} + +/** Creates one transient DOM menu owned by the application rather than fxnode. */ +export function createAddNodeMenu(ownerDocument = document) { + const ownerWindow = ownerDocument.defaultView; + const root = ownerDocument.createElement("div"); + root.className = "fxnode-add-menu"; + root.hidden = true; + root.setAttribute("role", "dialog"); + root.setAttribute("aria-label", "Add render graph node"); + const input = ownerDocument.createElement("input"); + input.type = "search"; + input.placeholder = "Search nodes…"; + input.setAttribute("aria-label", "Search nodes"); + input.setAttribute("aria-controls", "fxnode-add-options"); + input.setAttribute("aria-autocomplete", "list"); + const list = ownerDocument.createElement("div"); + list.id = "fxnode-add-options"; + list.className = "fxnode-add-menu__list"; + list.setAttribute("role", "listbox"); + root.append(input, list); + ownerDocument.body.append(root); + let resolve, filtered = addNodeItems, selected = 0, serial = 0, previousFocus; + + const close = (value = null) => { + if (root.hidden) return; + root.hidden = true; + const done = resolve; + resolve = undefined; + previousFocus?.focus?.(); + previousFocus = undefined; + done?.(value); + }; + const render = () => { + filtered = searchAddNodeItems(input.value); + selected = filtered.length ? Math.min(Math.max(selected, 0), filtered.length - 1) : -1; + list.replaceChildren(); + let group; + for (const [index, item] of filtered.entries()) { + if (item.group !== group) { + group = item.group; + const heading = ownerDocument.createElement("div"); + heading.className = "fxnode-add-menu__group"; + heading.textContent = group; + heading.setAttribute("role", "presentation"); + list.append(heading); + } + const option = ownerDocument.createElement("button"); + option.type = "button"; + option.id = `fxnode-add-option-${serial}-${index}`; + option.className = "fxnode-add-menu__option"; + option.dataset.typeId = item.typeId; + option.textContent = item.title; + option.setAttribute("role", "option"); + option.setAttribute("aria-selected", String(index === selected)); + option.tabIndex = -1; + option.addEventListener("pointermove", () => { selected = index; render(); }); + option.addEventListener("click", () => close(item.typeId)); + list.append(option); + } + const active = selected >= 0 ? list.querySelector(`[data-type-id="${filtered[selected].typeId}"]`) : null; + input.setAttribute("aria-activedescendant", active?.id ?? ""); + active?.scrollIntoView({ block: "nearest" }); + }; + const reposition = () => { + if (root.hidden) return; + const margin = 8, box = root.getBoundingClientRect(); + root.style.left = `${Math.max(margin, Math.min(Number(root.dataset.x), ownerWindow.innerWidth - box.width - margin))}px`; + root.style.top = `${Math.max(margin, Math.min(Number(root.dataset.y), ownerWindow.innerHeight - box.height - margin))}px`; + }; + input.addEventListener("input", () => { selected = 0; render(); }); + input.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); selected = moveAddNodeSelection(selected, event.key === "ArrowDown" ? 1 : -1, filtered.length); render(); + } else if (event.key === "Enter" && selected >= 0) { + event.preventDefault(); close(filtered[selected].typeId); + } else if (event.key === "Escape") { event.preventDefault(); close(); } + }); + const outside = (event) => { if (!root.hidden && !root.contains(event.target)) close(); }; + ownerDocument.addEventListener("pointerdown", outside, true); + ownerWindow.addEventListener("resize", close); + ownerWindow.addEventListener("blur", close); + return { + open({ x, y }) { + close(); + serial++; + previousFocus = ownerDocument.activeElement; + root.dataset.x = String(x); root.dataset.y = String(y); + input.value = ""; selected = 0; root.hidden = false; render(); reposition(); input.focus(); + return new Promise((done) => { resolve = done; }); + }, + close, + destroy() { + close(); + ownerDocument.removeEventListener("pointerdown", outside, true); + ownerWindow.removeEventListener("resize", close); + ownerWindow.removeEventListener("blur", close); + root.remove(); + }, + }; +} diff --git a/static/render-graph/authoring-controller.js b/static/render-graph/authoring-controller.js index 54dc2cf..991c140 100644 --- a/static/render-graph/authoring-controller.js +++ b/static/render-graph/authoring-controller.js @@ -1,14 +1,117 @@ +import { mapAuthoringDiagnostic } from "./adapter.js"; + export class AuthoringController { - #renderer; #getState; #revision=0; #nextRevision=1; #dirty=true; #applying=null; #listeners=new Set(); - constructor({renderer,getState}) { this.#renderer=renderer; this.#getState=getState; } - get revision(){return this.#revision} get dirty(){return this.#dirty} get applying(){return !!this.#applying} - subscribe(fn){this.#listeners.add(fn);return()=>this.#listeners.delete(fn)} - markDirty(){this.#dirty=true;this.#emit()} - #emit(){for(const fn of this.#listeners)fn({revision:this.#revision,dirty:this.#dirty,applying:!!this.#applying})} - apply(adapt){ - if(this.#applying)return this.#applying; - const revision=this.#nextRevision++; this.#dirty=false; this.#emit(); - this.#applying=(async()=>{try{const snapshot=await this.#getState();const ir=adapt(snapshot,revision);const compiled=await this.#renderer.compileGraph(ir);await this.#renderer.switchCompiledGraph(compiled.compiledId);this.#revision=revision;return compiled} catch(e){this.#dirty=true;throw e} finally{this.#applying=null;this.#emit()}})(); - return this.#applying; + #renderer; #adapt; #revision = 0; #nextRevision = 1; #generation = 0; + #current; #lastGood; #applyPromise; #listeners = new Set(); + #owned = new Map(); #drops = new Map(); #activeCompiles = new Set(); + #disposed = false; #applyingRecord; #scheduler; #debounceMs; #timer; #destroyPromise; + + constructor({ renderer, adapt, scheduler = globalThis, debounceMs = 150 }) { + this.#renderer = renderer; this.#adapt = adapt; + this.#scheduler = scheduler; this.#debounceMs = debounceMs; + } + get revision() { return this.#revision; } + get dirty() { return !!this.#current; } + get applying() { return !!this.#applyPromise; } + get staged() { return this.#current?.candidate ?? null; } + get canApply() { return !this.#disposed && !!this.#current?.candidate && !this.#applyPromise; } + subscribe(fn) { + if (this.#disposed) return () => {}; + this.#listeners.add(fn); fn(this.#state()); + return () => this.#listeners.delete(fn); + } + #state() { return { revision: this.#revision, dirty: this.dirty, applying: this.applying, staged: this.staged, canApply: this.canApply, error: this.#current?.diagnostic ?? null }; } + #emit() { if (!this.#disposed) for (const fn of this.#listeners) fn(this.#state()); } + #key(id) { return JSON.stringify(id); } + #drop(candidate) { + if (!candidate) return Promise.resolve(); + const key = this.#key(candidate.compiledId); + if (!this.#owned.has(key)) return this.#drops.get(key) ?? Promise.resolve(); + if (this.#drops.has(key)) return this.#drops.get(key); + let result; + try { result = this.#renderer.dropCompiledGraph(candidate.compiledId); } + catch (error) { result = Promise.reject(error); } + const dropping = Promise.resolve(result) + .then(() => { this.#owned.delete(key); }) + .finally(() => { this.#drops.delete(key); }); + this.#drops.set(key, dropping); + return dropping; + } + #retire(candidate) { if (candidate) void this.#drop(candidate).catch(() => {}); } + #start(record) { + if (!record.compile) { + record.compile = this.#compile(record); + this.#activeCompiles.add(record.compile); + record.compile.finally(() => this.#activeCompiles.delete(record.compile)); + } + return record.compile; + } + #flush(record = this.#current) { + if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; } + return record ? this.#start(record) : null; + } + markDirty(snapshot) { + if (this.#disposed) return; + const previous = this.#current; + const record = { generation: ++this.#generation, snapshot, candidate: null, error: null, diagnostic: null, compile: null }; + this.#current = record; + if (previous?.candidate && previous !== this.#applyingRecord && previous.candidate !== this.#lastGood) this.#retire(previous.candidate); + if (this.#timer) this.#scheduler.clearTimeout(this.#timer); + this.#timer = this.#scheduler.setTimeout(() => { this.#timer = undefined; if (!this.#disposed) this.#start(record); }, this.#debounceMs); + this.#emit(); + } + async #compile(record) { + let candidate, ir; + try { + ir = this.#adapt(record.snapshot, this.#nextRevision++); + candidate = await this.#renderer.compileGraph(ir); + this.#owned.set(this.#key(candidate.compiledId), candidate); + if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; } + record.candidate = candidate; record.error = record.diagnostic = null; this.#emit(); return candidate; + } catch (error) { + if (candidate) this.#retire(candidate); + record.error = error; + record.diagnostic = ir ? mapAuthoringDiagnostic(ir, error) : error; + if (this.#current === record) this.#emit(); + return null; + } + } + apply() { + if (this.#disposed) return Promise.resolve(null); + if (this.#applyPromise) return this.#applyPromise; + const record = this.#current; + if (!record) return Promise.resolve(this.#lastGood); + this.#applyingRecord = record; this.#flush(record); + this.#applyPromise = this.#applyRecord(record); this.#emit(); return this.#applyPromise; + } + async #applyRecord(record) { + try { + const candidate = record.candidate ?? (await record.compile); + if (!candidate) throw record.error ?? new Error("Graph compilation failed"); + if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; } + await this.#renderer.switchCompiledGraph(candidate.compiledId); + const old = this.#lastGood; this.#lastGood = candidate; + this.#revision = candidate.revision ?? this.#revision + 1; + if (this.#current === record) this.#current = undefined; + if (old && old !== candidate) this.#retire(old); + return candidate; + } finally { + if (this.#current !== record && record.candidate && record.candidate !== this.#lastGood) this.#retire(record.candidate); + this.#applyPromise = null; this.#applyingRecord = undefined; this.#emit(); + } + } + destroy() { + if (this.#destroyPromise) return this.#destroyPromise; + this.#disposed = true; + if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; } + this.#listeners.clear(); + this.#destroyPromise = this.#finish(); + return this.#destroyPromise; + } + async #finish() { + const applying = this.#applyPromise; + await Promise.allSettled([...(applying ? [applying] : []), ...this.#activeCompiles, ...this.#drops.values()]); + await Promise.allSettled([...this.#owned.values()].map((candidate) => this.#drop(candidate))); + this.#current = this.#lastGood = undefined; } } diff --git a/static/render-graph/browser-host.js b/static/render-graph/browser-host.js index cc5389d..5ad369d 100644 --- a/static/render-graph/browser-host.js +++ b/static/render-graph/browser-host.js @@ -10,10 +10,12 @@ const sizeCanvas = (canvas, value) => { }; const mods = e => ({ alt:e.altKey, control:e.ctrlKey, meta:e.metaKey, shift:e.shiftKey }); -export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeType }={}) { +export function prepareBrowserHost(canvas, { onError=console.error, requestAddNode }={}) { const ownerDocument=canvas.ownerDocument, ownerWindow=ownerDocument.defaultView ?? window; const originalTabIndex=canvas.getAttribute("tabindex"), originalTouchAction=canvas.style.touchAction; - let view, dead=false, generation=0, resizing=false, pending, appliedViewport, menuPending=false, unsubscribeHost=()=>{}; + let view, root, dead=false, generation=0, requestEpoch=0, resizing=false, pending, appliedViewport, menuPending=false, menuPoint, unsubscribeHost=()=>{}; + const rootSubscriptions=[]; + const invalidateAddNode=()=>{requestEpoch++;menuPending=false;requestAddNode?.close?.()}; const captured=new Set(); const initialViewport=viewport(canvas,ownerWindow); appliedViewport=initialViewport; sizeCanvas(canvas,initialViewport); canvas.tabIndex=0; canvas.style.touchAction="none"; @@ -22,14 +24,14 @@ export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeTy if(!view)return; if(e instanceof ownerWindow.PointerEvent){ const phase=e.type==="pointerdown"?"down":e.type==="pointermove"?"move":e.type==="pointerup"?"up":"cancel"; - if(phase==="down"){menuPending=e.button===2&&!e.ctrlKey&&(e.buttons&1)===0;canvas.focus();try{canvas.setPointerCapture(e.pointerId);captured.add(e.pointerId)}catch{}} + if(phase==="down"){invalidateAddNode();menuPending=e.button===2&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&!e.shiftKey&&(e.buttons&1)===0;menuPoint={x:e.clientX,y:e.clientY};canvas.focus();try{canvas.setPointerCapture(e.pointerId);captured.add(e.pointerId)}catch{}} if((phase==="up"||phase==="cancel")&&captured.delete(e.pointerId))try{if(canvas.hasPointerCapture(e.pointerId))canvas.releasePointerCapture(e.pointerId)}catch{} view.feedInput({kind:"pointer",phase,pointerId:e.pointerId,pointerType:e.pointerType,position:point(e),button:e.button,buttons:e.buttons,modifiers:mods(e)}); }else if(e instanceof ownerWindow.WheelEvent){ - e.preventDefault(); menuPending=false; + e.preventDefault(); invalidateAddNode(); const scale=e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_LINE?16:e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_PAGE?Math.max(1,canvas.clientHeight):1; view.feedInput({kind:"wheel",position:point(e),delta:{x:e.deltaX*scale,y:e.deltaY*scale},modifiers:mods(e)}); - }else if(e instanceof ownerWindow.KeyboardEvent){menuPending=false;view.feedInput({kind:"key",phase:e.type==="keydown"?"down":"up",key:e.key,code:e.code,repeat:e.repeat,modifiers:mods(e)}); + }else if(e instanceof ownerWindow.KeyboardEvent){invalidateAddNode();view.feedInput({kind:"key",phase:e.type==="keydown"?"down":"up",key:e.key,code:e.code,repeat:e.repeat,modifiers:mods(e)}); }else view.feedInput({kind:"focus",phase:e.type==="focus"?"focus":"blur"}); }; const names=["pointerdown","pointermove","pointerup","pointercancel","wheel","keydown","keyup","focus","blur"]; @@ -40,22 +42,23 @@ export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeTy resizing=true; Promise.resolve(view.setViewport(next)).then(()=>{if(dead||currentGeneration!==generation)return;appliedViewport=next;sizeCanvas(canvas,next)}).catch(error=>{if(!dead&¤tGeneration===generation)onError(error)}).finally(()=>{if(dead||currentGeneration!==generation)return;resizing=false;pump()}); }; - const resize=()=>{if(dead)return;pending=viewport(canvas,ownerWindow);pump()}; + const resize=()=>{if(dead)return;invalidateAddNode();pending=viewport(canvas,ownerWindow);pump()}; const outside=e=>{if(view&&e.button===0&&e.target!==canvas&&!canvas.contains(e.target)&&view.getHostSnapshot().colorPickerOpen)view.feedInput({kind:"outside-pointer",button:0})}; const lost=e=>captured.delete(e.pointerId); const observer=new ownerWindow.ResizeObserver(resize); return {initialViewport,attach(_root,next){ - view=next; + root=_root;view=next; for(const n of names)canvas.addEventListener(n,input,{passive:n!=="wheel"}); canvas.addEventListener("contextmenu",prevent);canvas.addEventListener("lostpointercapture",lost); ownerDocument.addEventListener("pointerdown",outside,true);ownerWindow.addEventListener("resize",resize); - unsubscribeHost=view.onHostRequests(request=>{if(request.kind!=="add-node-menu"||!menuPending)return;menuPending=false;const typeId=chooseNodeType?.(request);if(typeId)view.addNode({typeId,viewPosition:request.viewPosition}).catch(onError)}); + unsubscribeHost=view.onHostRequests(request=>{if(request.kind!=="add-node-menu"||!menuPending||request.compositionRevision!==view.getHostSnapshot().compositionRevision){invalidateAddNode();return}menuPending=false;const epoch=requestEpoch;requestAddNode?.(request,menuPoint,()=>!dead&&epoch===requestEpoch);}); + rootSubscriptions.push(root.onMutations(invalidateAddNode),root.onCompositionChanges(invalidateAddNode)); observer.observe(canvas);resize(); },destroy(){ - if(dead)return;dead=true;generation++;pending=undefined;observer.disconnect();unsubscribeHost();ownerWindow.removeEventListener("resize",resize);ownerDocument.removeEventListener("pointerdown",outside,true); + if(dead)return;dead=true;generation++;pending=undefined;invalidateAddNode();observer.disconnect();unsubscribeHost();for(const unsubscribe of rootSubscriptions)unsubscribe();rootSubscriptions.length=0;ownerWindow.removeEventListener("resize",resize);ownerDocument.removeEventListener("pointerdown",outside,true); for(const n of names)canvas.removeEventListener(n,input);canvas.removeEventListener("contextmenu",prevent);canvas.removeEventListener("lostpointercapture",lost); for(const id of captured)try{if(canvas.hasPointerCapture(id))canvas.releasePointerCapture(id)}catch{}captured.clear(); - if(originalTabIndex===null)canvas.removeAttribute("tabindex");else canvas.setAttribute("tabindex",originalTabIndex);canvas.style.touchAction=originalTouchAction;view=null; + if(originalTabIndex===null)canvas.removeAttribute("tabindex");else canvas.setAttribute("tabindex",originalTabIndex);canvas.style.touchAction=originalTouchAction;view=null;root=null; }}; } function prevent(e){e.preventDefault()} diff --git a/static/render-graph/catalog.js b/static/render-graph/catalog.js index aff394c..03aaee9 100644 --- a/static/render-graph/catalog.js +++ b/static/render-graph/catalog.js @@ -1,29 +1,347 @@ -export const GRAPH_ID = "demo_forward"; -export const CATALOG_VERSION = 1; - -export const socketTypes = { - surface: { title: "Surface", color: "#62b0ff", acceptsFrom: ["surface"] }, - depth: { title: "Depth", color: "#b58cff", acceptsFrom: ["depth"] }, -}; -export const theme = { - background:"#151820",grid:"#292e3a",frame:"#30343a80",frameHeader:"#59616c",body:"#292e39",control:"#191d26",controlFill:"#4775b8",controlEditing:"#101218",textSelection:"#4775b8",outline:"#0b0d12",text:"#edf1f7",muted:"#969eaa",shadow:"#00000088",nodeSelected:"#ff9f43",nodeActive:"#ffffff",unknownHeader:"#555b64",unknownSocket:"#999999",linkMuted:"#d94b4b",knifeMuted:"#e85b5b",emphasis:"#ffffff",focus:"#f5a623",editOutline:"#666a70",resize:"#8b8e95",muteOverlay:"#14141459",boxSelectionFill:"#f5a6231f",checkerLight:"#aaaaaa",checkerDark:"#777777",widgetBorder:"#111216",rampBorder:"#111111",resourceBackground:"#202228" -}; -export const styles = { resource:{header:"#3977a8"}, pass:{header:"#426b43"}, output:{header:"#a75d37"} }; -const socket = (title, direction, type) => ({ title,direction,type,maxIncomingLinks:direction === "input" ? 1 : 0,visible:true,value:null,showValue:false }); -const node = (title, style, sockets, parameters = {}) => ({ version:1,title,behavior:"standard",style,parameters,sockets,ui:[...Object.keys(parameters).map(parameter=>({kind:"parameter",parameter})),...Object.keys(sockets).map(socket=>({kind:"socket",socket}))],muteBypass:[],migrations:[] }); -export const nodeDefinitions = { - surface_color: node("Surface Color", "resource", { surface:socket("Surface","output","surface") }), - depth32: node("Depth 32", "resource", { depth:socket("Depth","output","depth") }), - scene_forward: node("Scene Forward", "pass", { color:socket("Color","input","surface"),depth:socket("Depth","input","depth"),result:socket("Result","output","surface") }, { - clearColor:{type:"color",default:{kind:"color",value:[0,0,0,1]},minimum:0,maximum:1}, - clearDepth:{type:"number",default:{kind:"number",value:1},minimum:0,maximum:1,step:0.01}, - }), - present: node("Present", "output", { surface:socket("Surface","input","surface") }), -}; - -export const descriptors = Object.freeze({ - surface_color:{version:1,sockets:{surface:["output","surface"]},parameters:[]}, - depth32:{version:1,sockets:{depth:["output","depth"]},parameters:[]}, - scene_forward:{version:1,sockets:{color:["input","surface"],depth:["input","depth"],result:["output","surface"]},parameters:["clearColor","clearDepth"]}, - present:{version:1,sockets:{surface:["input","surface"]},parameters:[]}, +export const GRAPH_ID = "authored_gpu_culling"; +export const CATALOG_VERSION = 2; +const exact = (type) => ({ kind: "exact", types: [type] }); +const oneOf = (...types) => ({ kind: "one_of", types }); +const i = (type, required = true, authoringType) => ({ + accepted: typeof type === "string" ? exact(type) : type, + required, + ...(authoringType ? { authoringType } : {}), }); +const o = (type) => ({ type }); +const texture = { + residency: "transient", + texture: { + dimension: "d2", + format: "rgba16_float", + extent: { + kind: "surface_relative", + width: { numerator: 1, denominator: 1 }, + height: { numerator: 1, denominator: 1 }, + depthOrArrayLayers: 1, + }, + mipLevelCount: 1, + sampleCount: 1, + viewFormats: [], + }, +}; +export const semanticCatalog = Object.freeze({ + surface_target: { + execution: "source", + inputs: {}, + outputs: { surface: o("surface_target") }, + parameters: {}, + }, + texture_spec: { + execution: "source", + inputs: {}, + outputs: { spec: o("texture_spec") }, + parameters: structuredClone(texture), + }, + scene_table: { + execution: "source", + inputs: {}, + outputs: { scene: o("scene_table") }, + parameters: {}, + }, + local_aabb_buffer: { + execution: "source", + inputs: { scene: i("scene_table") }, + outputs: { localAabbs: o("local_aabb_buffer") }, + parameters: {}, + }, + camera_frustum: { + execution: "source", + inputs: {}, + outputs: { frustum: o("camera_frustum") }, + parameters: {}, + }, + visibility_flags: { + execution: "source", + inputs: { scene: i("scene_table") }, + outputs: { + flags: { + ...o("boolean_flag_buffer"), + authoringType: "visibility_flag_buffer", + }, + }, + parameters: {}, + }, + frustum_cull: { + execution: "compute", + inputs: { + scene: i("scene_table"), + localAabbs: i("local_aabb_buffer"), + frustum: i("camera_frustum"), + }, + outputs: { + flags: { + ...o("boolean_flag_buffer"), + authoringType: "frustum_flag_buffer", + }, + }, + parameters: {}, + }, + mesh_query: { + execution: "compute", + inputs: { + scene: i("scene_table"), + isVisible: i("boolean_flag_buffer", false, "visibility_flag_buffer"), + isFrustumCulled: i("boolean_flag_buffer", false, "frustum_flag_buffer"), + }, + outputs: { draws: o("draw_stream") }, + parameters: { + filters: [ + { flag: "isVisible", predicate: "required_true" }, + { flag: "isFrustumCulled", predicate: "required_false" }, + ], + }, + }, + depth_stencil_config: { + execution: "source", + inputs: {}, + outputs: { config: o("depth_stencil_config") }, + parameters: { + depthCompare: "less_equal", + depthWriteEnabled: true, + clearDepth: 1, + }, + }, + legacy_forward: { + execution: "render", + inputs: { + scene: i("scene_table"), + draws: i("draw_stream"), + colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), + depthTarget: i(oneOf("texture_spec", "texture")), + depthStencil: i("depth_stencil_config"), + }, + outputs: { color: o("texture"), depth: o("texture") }, + parameters: { clearColor: [0.015, 0.02, 0.03, 1] }, + }, + fullscreen_copy: { + execution: "render", + inputs: { + source: i("texture"), + colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), + }, + outputs: { color: o("texture") }, + parameters: {}, + }, + tone_map: { + execution: "render", + inputs: { + source: i("texture"), + colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), + }, + outputs: { color: o("texture") }, + parameters: { exposure: 1 }, + }, + bloom_extract: { + execution: "render", + inputs: { + source: i("texture"), + colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), + }, + outputs: { color: o("texture") }, + parameters: { threshold: 1, knee: 0.5 }, + }, + bloom_blur: { + execution: "render", + inputs: { + source: i("texture"), + colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), + }, + outputs: { color: o("texture") }, + parameters: { direction: [1, 0], radius: 1 }, + }, + bloom_composite: { + execution: "render", + inputs: { + source: i("texture"), + bloom: i("texture"), + colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), + }, + outputs: { color: o("texture") }, + parameters: { intensity: 1 }, + }, + luminance_edge: { + execution: "render", + inputs: { + source: i("texture"), + colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), + }, + outputs: { color: o("texture") }, + parameters: { strength: 2 }, + }, + present: { + execution: "present", + inputs: { surface: i("texture") }, + outputs: {}, + parameters: {}, + }, +}); +const socketColors = [ + "#d17c7c", + "#d19e7c", + "#d1c77c", + "#9ed17c", + "#7cd1a5", + "#7ccbd1", + "#7c98d1", + "#a27cd1", + "#d17cb8", +]; +export const socketTypes = Object.fromEntries( + [ + "surface_target", + "texture_spec", + "texture", + "scene_table", + "local_aabb_buffer", + "camera_frustum", + "boolean_flag_buffer", + "draw_stream", + "depth_stencil_config", + "visibility_flag_buffer", + "frustum_flag_buffer", + ].map((type, index) => [ + type, + { + title: type.replaceAll("_", " "), + color: socketColors[index % socketColors.length], + acceptsFrom: [type], + }, + ]), +); +socketTypes.surface_target.acceptsFrom = [ + "surface_target", + "texture_spec", + "texture", +]; +socketTypes.texture_spec.acceptsFrom = ["texture_spec", "texture"]; +socketTypes.boolean_flag_buffer.acceptsFrom = [ + "boolean_flag_buffer", + "visibility_flag_buffer", + "frustum_flag_buffer", +]; +export const theme = { + background: "#151820", + grid: "#292e3a", + frame: "#30343a80", + frameHeader: "#59616c", + body: "#292e39", + control: "#24272b", + controlFill: "#4775b8", + controlEditing: "#181a1d", + textSelection: "#4775b8", + outline: "#0b0d12", + text: "#edf1f7", + muted: "#969eaa", + shadow: "#00000088", + nodeSelected: "#ff9f43", + nodeActive: "#ffffff", + unknownHeader: "#555b64", + unknownSocket: "#999999", + linkMuted: "#d94b4b", + knifeMuted: "#e85b5b", + emphasis: "#ffffff", + focus: "#f5a623", + editOutline: "#666a70", + resize: "#8b8e95", + muteOverlay: "#14141459", + boxSelectionFill: "#f5a6231f", + checkerLight: "#aaaaaa", + checkerDark: "#777777", + widgetBorder: "#111216", + rampBorder: "#111111", + resourceBackground: "#202228", +}; +export const styles = { + source: { header: "#3977a8" }, + compute: { header: "#725a9b" }, + render: { header: "#426b43" }, + present: { header: "#a75d37" }, +}; +const socket = (title, direction, type) => ({ + title, + direction, + type, + maxIncomingLinks: direction === "input" ? 1 : 0, + visible: true, + value: null, + showValue: false, +}); +const parameterSchema = (value) => + typeof value === "number" + ? { type: "number", default: { kind: "number", value } } + : typeof value === "string" + ? { type: "string", default: { kind: "string", value } } + : typeof value === "boolean" + ? { type: "boolean", default: { kind: "boolean", value } } + : { type: "json", default: { kind: "json", value } }; +export const nodeDefinitions = Object.fromEntries( + Object.entries(semanticCatalog).map(([key, c]) => { + const sockets = { + ...Object.fromEntries( + Object.entries(c.inputs).map(([n, v]) => [ + n, + socket(n, "input", v.authoringType ?? v.accepted.types[0]), + ]), + ), + ...Object.fromEntries( + Object.entries(c.outputs).map(([n, v]) => [ + n, + socket(n, "output", v.authoringType ?? v.type), + ]), + ), + }, + parameters = Object.fromEntries( + Object.entries(c.parameters).map(([name, value]) => [ + name, + parameterSchema(value), + ]), + ); + return [ + key, + { + version: 1, + title: key.replaceAll("_", " "), + behavior: "standard", + style: c.execution, + parameters, + sockets, + ui: [ + ...Object.keys(parameters).map((parameter) => ({ + kind: "parameter", + parameter, + })), + ...Object.keys(sockets).map((socket) => ({ kind: "socket", socket })), + ], + muteBypass: [], + migrations: [], + }, + ]; + }), +); +export const fxNodeComposition = Object.freeze({ + schemaVersion: 2, + id: "yawn.render-graph", + version: CATALOG_VERSION, + compatibility: { wildcardInputTypes: [] }, + socketTypes, + nodeStyles: styles, + resources: {}, + theme, + nodes: nodeDefinitions, +}); +export const descriptors = Object.fromEntries( + Object.entries(semanticCatalog).map(([key, c]) => [ + key, + { + version: 1, + inputs: c.inputs, + outputs: c.outputs, + parameters: c.parameters, + }, + ]), +); diff --git a/static/render-graph/fxnode-editor.js b/static/render-graph/fxnode-editor.js index ed683f6..db454b0 100644 --- a/static/render-graph/fxnode-editor.js +++ b/static/render-graph/fxnode-editor.js @@ -1,25 +1,155 @@ import { createFxNode } from "@fxnode/index.ts"; -import { CATALOG_VERSION, GRAPH_ID, nodeDefinitions, socketTypes, styles, theme } from "./catalog.js"; +import { CATALOG_VERSION, GRAPH_ID, fxNodeComposition } from "./catalog.js"; import { prepareBrowserHost } from "./browser-host.js"; +import { createAddNodeMenu } from "./add-node-menu.js"; +import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.js"; -const spec=[ - ["surface","surface_color",{x:40,y:100}], - ["depth","depth32",{x:40,y:330}], - ["forward","scene_forward",{x:360,y:190}], - ["present","present",{x:700,y:220}], +const spec = [ + ["surface", "surface_target", { x: 40, y: 40 }], + ["hdr", "texture_spec", { x: 40, y: 170 }], + ["depth", "texture_spec", { x: 40, y: 300 }], + ["scene", "scene_table", { x: 40, y: 470 }], + ["aabbs", "local_aabb_buffer", { x: 290, y: 430 }], + ["frustum", "camera_frustum", { x: 290, y: 590 }], + ["visible", "visibility_flags", { x: 290, y: 300 }], + ["cull", "frustum_cull", { x: 540, y: 480 }], + ["query", "mesh_query", { x: 790, y: 330 }], + ["depth_config", "depth_stencil_config", { x: 790, y: 620 }], + ["forward", "legacy_forward", { x: 1040, y: 290 }], + ["copy", "fullscreen_copy", { x: 1300, y: 250 }], + ["present", "present", { x: 1540, y: 250 }], ]; -async function seed(root){ - await root.setState({graphId:GRAPH_ID,catalogVersion:CATALOG_VERSION,nodes:[],links:[],metadata:{}}); - for(const [nodeId,nodeType,position] of spec) await root.dispatch({type:"node.add",nodeId,nodeType,position}); - for(const link of [ - {id:"surface_link",fromNodeId:"surface",fromSocketId:"surface:surface",toNodeId:"forward",toSocketId:"forward:color",muted:false,extensions:{}}, - {id:"depth_link",fromNodeId:"depth",fromSocketId:"depth:depth",toNodeId:"forward",toSocketId:"forward:depth",muted:false,extensions:{}}, - {id:"present_link",fromNodeId:"forward",fromSocketId:"forward:result",toNodeId:"present",toSocketId:"present:surface",muted:false,extensions:{}}, - ]) await root.dispatch({type:"link.add",link}); +async function seed(root) { + await root.setState({ + graphId: GRAPH_ID, + catalogVersion: CATALOG_VERSION, + nodes: [], + links: [], + metadata: {}, + }); + for (const [nodeId, nodeType, position] of spec) + await root.dispatch({ type: "node.add", nodeId, nodeType, position }); + const links = [ + ["scene", "scene", "aabbs", "scene"], + ["scene", "scene", "visible", "scene"], + ["scene", "scene", "cull", "scene"], + ["aabbs", "localAabbs", "cull", "localAabbs"], + ["frustum", "frustum", "cull", "frustum"], + ["scene", "scene", "query", "scene"], + ["visible", "flags", "query", "isVisible"], + ["cull", "flags", "query", "isFrustumCulled"], + ["scene", "scene", "forward", "scene"], + ["query", "draws", "forward", "draws"], + ["hdr", "spec", "forward", "colorTarget"], + ["depth", "spec", "forward", "depthTarget"], + ["depth_config", "config", "forward", "depthStencil"], + ["forward", "color", "copy", "source"], + ["surface", "surface", "copy", "colorTarget"], + ["copy", "color", "present", "surface"], + ]; + for (const [a, as, b, bs] of links) { + const id = `${a}_${as}_${b}_${bs}`; + await root.dispatch({ + type: "link.add", + link: { + id, + fromNodeId: a, + fromSocketId: `${a}:${as}`, + toNodeId: b, + toSocketId: `${b}:${bs}`, + muted: false, + extensions: {}, + }, + }); + } + const authored = await root.getState(), + depth = authored.nodes.find((node) => node.id === "depth"); + depth.parameters.texture = { + kind: "json", + value: { + dimension: "d2", + format: "depth32_float", + extent: { + kind: "surface_relative", + width: { numerator: 1, denominator: 1 }, + height: { numerator: 1, denominator: 1 }, + depthOrArrayLayers: 1, + }, + mipLevelCount: 1, + sampleCount: 1, + viewFormats: [], + }, + }; + await root.setState(authored); } -export async function createRenderGraphEditor(canvas){ - const chooseNodeType=()=>{const value=canvas.ownerDocument.defaultView?.prompt(`Node type: ${Object.keys(nodeDefinitions).join(", ")}`,"scene_forward");return Object.hasOwn(nodeDefinitions,value)?value:null}; - const host=prepareBrowserHost(canvas,{chooseNodeType});let root,view,destroying; - const destroy=()=>destroying??=(async()=>{host.destroy();try{await view?.detach()}finally{root?.destroy();view=undefined;root=undefined}})(); - try{root=await createFxNode({applicationId:"yawn.render-graph",applicationVersion:1,resources:{}});await root.setTheme(theme);await root.setHeaderStyles(styles);for(const entry of Object.entries(socketTypes))await root.composeSocket(...entry);for(const entry of Object.entries(nodeDefinitions))await root.composeNode(...entry);await seed(root);view=await root.attachView({canvas,viewport:host.initialViewport,initialCamera:{center:{x:470,y:210},zoom:.5}});host.attach(root,view);await view.whenRendered();return {getState:()=>root.getState(),onSnapshots:fn=>root.onSnapshots(fn),whenRendered:()=>view.whenRendered(),destroy};}catch(e){await destroy().catch(()=>{});throw e} +export async function createRenderGraphEditor(canvas) { + const allocateId = createNodeIdAllocator(); + let root, view, menu, destroying, dead = false; + const requestAddNode = Object.assign(async (request, point, isCurrent = () => true) => { + let typeId; + try { typeId = await menu?.open(point); } catch (error) { if (!dead && isCurrent()) console.error(error); return; } + if (dead || !isCurrent() || !root || !view) return; + const alive = () => !dead && isCurrent(); + try { await spawnRequestedNode(root, view, request, typeId, allocateId, alive); } catch (error) { if (!dead) console.error(error); } + }, { close: () => menu?.close() }); + const host = prepareBrowserHost(canvas, { requestAddNode }); + const destroy = () => + (destroying ??= (async () => { + dead = true; + host.destroy(); + menu?.destroy(); + try { + await view?.detach(); + } finally { + root?.destroy(); + view = undefined; + root = undefined; + } + })()); + try { + root = await createFxNode({ + applicationId: "yawn.render-graph", + applicationVersion: CATALOG_VERSION, + resources: {}, + }); + await root.loadComposition(fxNodeComposition); + await seed(root); + view = await root.attachView({ + canvas, + viewport: host.initialViewport, + initialCamera: { center: { x: 780, y: 340 }, zoom: 0.34 }, + }); + menu = createAddNodeMenu(canvas.ownerDocument); + host.attach(root, view); + await view.whenRendered(); + return createEditorFacade(root, view, { requestAddNode, destroy }); + } catch (e) { + await destroy().catch(() => {}); + throw e; + } +} + +/** Creates the small application-facing wrapper around an fxnode root and view. */ +export function createEditorFacade( + root, + view, + { requestAddNode = () => null, destroy = async () => {} } = {}, +) { + return { + getState: () => root.getState(), + getSaveData: () => root.getSaveData(), + load: async (data) => { + await root.load(data); + await view.whenRendered(); + }, + loadComposition: async (data) => { + await root.loadComposition(data); + await view.whenRendered(); + }, + onSnapshots: (fn) => + root.onSnapshots((event) => fn(event.snapshot, event.version)), + requestAddNode, + whenRendered: () => view.whenRendered(), + destroy, + }; } diff --git a/static/render-graph/node-spawn.js b/static/render-graph/node-spawn.js new file mode 100644 index 0000000..6bddc8a --- /dev/null +++ b/static/render-graph/node-spawn.js @@ -0,0 +1,42 @@ +/** Allocates a bounded fxnode-safe ID, reserving candidates for this session. */ +export function createNodeIdAllocator(randomUUID = () => crypto.randomUUID()) { + const reserved = new Set(); + return (existingIds) => { + const existing = new Set(existingIds); + for (let attempt = 0; attempt < 64; attempt++) { + const id = `node_${randomUUID().replaceAll("-", "")}`; + if (/^node_[A-Za-z0-9_]+$/.test(id) && id.length <= 128 && !existing.has(id) && !reserved.has(id)) { + reserved.add(id); + return id; + } + } + throw new Error("Unable to allocate a unique node ID"); + }; +} + +/** Adds exactly one node when the request still targets the loaded composition. */ +export async function spawnRequestedNode(root, view, request, typeId, allocateId, isCurrent = () => true) { + const current = () => + isCurrent() && request.compositionRevision === view.getHostSnapshot().compositionRevision; + if (!typeId || !current()) return false; + let state; + try { + state = await root.getState(); + } catch (error) { + if (!isCurrent()) return false; + throw error; + } + if (!current()) return false; + const nodeId = allocateId(state.nodes.map((node) => node.id)); + if (!current()) return false; + try { + await view.addNode( + { typeId, nodeId, viewPosition: request.viewPosition }, + { expectedVersion: state.version }, + ); + } catch (error) { + if (!isCurrent()) return false; + throw error; + } + return true; +} diff --git a/static/render-graph/presets.js b/static/render-graph/presets.js index 54ddbe9..8c2c3bf 100644 --- a/static/render-graph/presets.js +++ b/static/render-graph/presets.js @@ -1,5 +1,256 @@ import { semanticProjectionToV1 } from "./adapter.js"; -const make=(graphId,clearColor)=>Object.freeze(semanticProjectionToV1({graphId,clearColor,clearDepth:1,passState:"enabled"},1)); -export const midnight=make("preset_midnight",[0.015,0.06,0.18,1]); -export const ember=make("preset_ember",[0.18,0.035,0.012,1]); -export const renderGraphPresets=Object.freeze({midnight,ember}); +const make = (graphId, clearColor) => + Object.freeze( + semanticProjectionToV1( + { graphId, clearColor, clearDepth: 1, passState: "enabled" }, + 1, + ), + ); +export const midnight = make("preset_midnight", [0.015, 0.06, 0.18, 1]); +export const ember = make("preset_ember", [0.18, 0.035, 0.012, 1]); +const input = (node, socket) => ({ node, socket }); +const node = (id, key, parameters = {}, inputs = {}) => ({ + id, + state: "enabled", + executor: { key, version: 1 }, + parameters, + inputs, +}); +const texture = (format) => ({ + texture: { + dimension: "d2", + format, + extent: { + kind: "surface_relative", + width: { numerator: 1, denominator: 1 }, + height: { numerator: 1, denominator: 1 }, + depthOrArrayLayers: 1, + }, + mipLevelCount: 1, + sampleCount: 1, + viewFormats: [], + }, + residency: "transient", +}); +export const hdr = Object.freeze({ + schemaVersion: 2, + graphId: "preset_hdr_fullscreen", + revision: 1, + nodes: [ + node("surface", "surface_target"), + node("hdr", "texture_spec", texture("rgba16_float")), + node("depth", "texture_spec", texture("depth32_float")), + node("scene", "scene_table"), + node("visible", "visibility_flags", {}, { scene: input("scene", "scene") }), + node( + "query", + "mesh_query", + { + filters: [ + { flag: "isVisible", predicate: "required_true" }, + { flag: "isFrustumCulled", predicate: "any" }, + ], + }, + { scene: input("scene", "scene"), isVisible: input("visible", "flags") }, + ), + node("depth_config", "depth_stencil_config", { + depthCompare: "less_equal", + depthWriteEnabled: true, + clearDepth: 1, + }), + node( + "forward", + "legacy_forward", + { clearColor: [0.015, 0.02, 0.03, 1] }, + { + scene: input("scene", "scene"), + draws: input("query", "draws"), + colorTarget: input("hdr", "spec"), + depthTarget: input("depth", "spec"), + depthStencil: input("depth_config", "config"), + }, + ), + node( + "copy", + "fullscreen_copy", + {}, + { + source: input("forward", "color"), + colorTarget: input("surface", "surface"), + }, + ), + node("present", "present", {}, { surface: input("copy", "color") }), + ], +}); +export const culling = Object.freeze((() => { + const graph = structuredClone(hdr); + graph.graphId = "preset_gpu_culling"; + graph.nodes.splice( + 5, + 0, + node("aabbs", "local_aabb_buffer", {}, { scene: input("scene", "scene") }), + node("frustum", "camera_frustum"), + node("cull", "frustum_cull", {}, { + scene: input("scene", "scene"), + localAabbs: input("aabbs", "localAabbs"), + frustum: input("frustum", "frustum"), + }), + ); + const query = graph.nodes.find((x) => x.id === "query"); + query.parameters.filters[1].predicate = "required_false"; + query.inputs.isFrustumCulled = input("cull", "flags"); + return graph; +})()); +const postPreset = (graphId, kind) => { + const nodes = hdr.nodes.slice(0, 8).map((x) => structuredClone(x)); + if (kind === "tone") + nodes.push( + node( + "tone", + "tone_map", + { exposure: 1 }, + { + source: input("forward", "color"), + colorTarget: input("surface", "surface"), + }, + ), + ); + if (kind === "edges") { + nodes.splice(1, 0, node("edge_hdr", "texture_spec", texture("rgba16_float"))); + nodes.push( + node( + "edges", + "luminance_edge", + { strength: 2 }, + { + source: input("forward", "color"), + colorTarget: input("edge_hdr", "spec"), + }, + ), + ); + nodes.push( + node( + "tone", + "tone_map", + { exposure: 1 }, + { + source: input("edges", "color"), + colorTarget: input("surface", "surface"), + }, + ), + ); + } + if (kind === "bloom" || kind === "combined") { + const half = { + texture: { + ...texture("rgba16_float").texture, + extent: { + kind: "surface_relative", + width: { numerator: 1, denominator: 2 }, + height: { numerator: 1, denominator: 2 }, + depthOrArrayLayers: 1, + }, + }, + residency: "transient", + }; + nodes.splice( + 1, + 0, + node("half_a", "texture_spec", structuredClone(half)), + node("half_b", "texture_spec", structuredClone(half)), + node("half_c", "texture_spec", structuredClone(half)), + node("composite_hdr", "texture_spec", texture("rgba16_float")), + ); + nodes.push( + node( + "extract", + "bloom_extract", + { threshold: 1, knee: 0.5 }, + { + source: input("forward", "color"), + colorTarget: input("half_a", "spec"), + }, + ), + ); + nodes.push( + node( + "blur_h", + "bloom_blur", + { direction: [1, 0], radius: 1 }, + { + source: input("extract", "color"), + colorTarget: input("half_b", "spec"), + }, + ), + ); + nodes.push( + node( + "blur_v", + "bloom_blur", + { direction: [0, 1], radius: 1 }, + { + source: input("blur_h", "color"), + colorTarget: input("half_c", "spec"), + }, + ), + ); + nodes.push( + node( + "composite", + "bloom_composite", + { intensity: 0.8 }, + { + source: input("forward", "color"), + bloom: input("blur_v", "color"), + colorTarget: input("composite_hdr", "spec"), + }, + ), + ); + let toneSource = "composite"; + if (kind === "combined") { + nodes.splice(1, 0, node("edge_hdr", "texture_spec", texture("rgba16_float"))); + nodes.push( + node( + "edges", + "luminance_edge", + { strength: 2 }, + { + source: input("composite", "color"), + colorTarget: input("edge_hdr", "spec"), + }, + ), + ); + toneSource = "edges"; + } + nodes.push( + node( + "tone", + "tone_map", + { exposure: 1 }, + { + source: input(toneSource, "color"), + colorTarget: input("surface", "surface"), + }, + ), + ); + } + const last = nodes.at(-1); + nodes.push( + node("present", "present", {}, { surface: input(last.id, "color") }), + ); + return Object.freeze({ schemaVersion: 2, graphId, revision: 1, nodes }); +}; +export const tone = postPreset("preset_tone", "tone"), + edges = postPreset("preset_edges", "edges"), + bloom = postPreset("preset_bloom", "bloom"), + combined = postPreset("preset_combined", "combined"); +export const renderGraphPresets = Object.freeze({ + midnight, + ember, + hdr, + culling, + tone, + edges, + bloom, + combined, +}); diff --git a/static/renderer-client.js b/static/renderer-client.js index a6bb78f..de1a688 100644 --- a/static/renderer-client.js +++ b/static/renderer-client.js @@ -12,7 +12,7 @@ export class RendererError extends Error { export class RendererClient { #bridge; #worker; #header; #slots; #buffer; #next = 1; #payload = 1; #pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false; - #telemetry; #stopped = false; + #telemetry; #profile; #stopped = false; #graphQueue = []; #graphBusy = false; #bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map(); @@ -40,6 +40,7 @@ export class RendererClient { get ready() { return this.#ready; } get telemetry() { return this.#telemetry; } + get profile() { return this.#profile; } #refreshViews() { const buffer = this.#bridge.memory.buffer; @@ -61,6 +62,9 @@ export class RendererClient { } else if (message?.type === "telemetry") { this.#telemetry = message; dispatchEvent(new CustomEvent("renderer-frame", { detail: message })); + } else if (message?.type === "profile-snapshot") { + this.#profile = message; + dispatchEvent(new CustomEvent("renderer-profile", { detail: message })); } else if (message?.type === "fatal") { console.error("renderer worker fatal", message.code, message.message); this.#fail(message.code || "WORKER_FATAL"); diff --git a/tests/add-node-menu.test.js b/tests/add-node-menu.test.js new file mode 100644 index 0000000..36550ab --- /dev/null +++ b/tests/add-node-menu.test.js @@ -0,0 +1,113 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { addNodeItems, moveAddNodeSelection, searchAddNodeItems } from "../static/render-graph/add-node-menu.js"; +import { createNodeIdAllocator, spawnRequestedNode } from "../static/render-graph/node-spawn.js"; + +test("add-node model contains all 17 catalog types in application groups", () => { + assert.equal(addNodeItems.length, 17); + assert.deepEqual([...new Set(addNodeItems.map((item) => item.group))], ["Source", "Compute", "Render / post", "Present"]); + assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 17); + assert.deepEqual(searchAddNodeItems("tone render").map((item) => item.typeId), ["tone_map"]); + assert.deepEqual(searchAddNodeItems("no such node"), []); +}); + +test("menu selection wraps and handles an empty search", () => { + assert.equal(moveAddNodeSelection(0, -1, 3), 2); + assert.equal(moveAddNodeSelection(2, 1, 3), 0); + assert.equal(moveAddNodeSelection(0, 1, 0), -1); +}); + +test("allocator avoids existing and session-reserved IDs and is bounded", () => { + const values = ["a-a", "a-a", "b-b"]; + const allocate = createNodeIdAllocator(() => values.shift()); + assert.equal(allocate(["node_aa"]), "node_bb"); + assert.throws(() => createNodeIdAllocator(() => "bad id")([]), /Unable to allocate/); +}); + +test("all 17 types spawn with exact position, current version and generated ID", async () => { + let revision = 5, expectedType; + const request = { compositionRevision: 5, viewPosition: { x: 12.25, y: -4 } }; + const root = { getState: async () => ({ version: 91, nodes: [{ id: "existing" }] }) }; + const view = { + getHostSnapshot: () => ({ compositionRevision: revision }), + addNode: async (params, options) => { + assert.equal(params.typeId, expectedType); + assert.strictEqual(params.viewPosition, request.viewPosition); + assert.match(params.nodeId, /^node_/); + assert.deepEqual(options, { expectedVersion: 91 }); + }, + }; + let id = 0; + const allocate = createNodeIdAllocator(() => `00000000-0000-0000-0000-${String(++id).padStart(12, "0")}`); + for (const item of addNodeItems) { + expectedType = item.typeId; + assert.equal(await spawnRequestedNode(root, view, request, item.typeId, allocate), true); + } + revision = 6; + assert.equal(await spawnRequestedNode(root, view, request, "tone_map", allocate), false); +}); + +test("spawn rechecks composition after getState and propagates add errors", async () => { + let revision = 2; + const request = { compositionRevision: 2, viewPosition: { x: 1, y: 2 } }; + const root = { getState: async () => { revision++; return { version: 3, nodes: [] }; } }; + const allocate = createNodeIdAllocator(() => "a"); + const view = { getHostSnapshot: () => ({ compositionRevision: revision }), addNode: async () => { throw Error("must not add"); } }; + assert.equal(await spawnRequestedNode(root, view, request, "tone_map", allocate), false); + revision = 2; + root.getState = async () => ({ version: 3, nodes: [] }); + await assert.rejects(spawnRequestedNode(root, view, request, "tone_map", allocate), /must not add/); +}); + +test("spawn cancels when a pending getState becomes mutated or dead", async () => { + const request = { compositionRevision: 2, viewPosition: { x: 1, y: 2 } }; + let resolveState, revision = 2, alive = true, adds = 0; + const root = { getState: () => new Promise((resolve) => { resolveState = resolve; }) }; + const view = { + getHostSnapshot: () => ({ compositionRevision: revision }), + addNode: async () => { adds++; }, + }; + const pendingMutation = spawnRequestedNode(root, view, request, "tone_map", () => "node_a", () => alive); + revision++; + resolveState({ version: 1, nodes: [] }); + assert.equal(await pendingMutation, false); + revision = 2; + const pendingDestroy = spawnRequestedNode(root, view, request, "tone_map", () => "node_b", () => alive); + alive = false; + resolveState({ version: 1, nodes: [] }); + assert.equal(await pendingDestroy, false); + assert.equal(adds, 0); +}); + +test("spawn has a final liveness guard after ID allocation", async () => { + let alive = true, adds = 0; + const request = { compositionRevision: 1, viewPosition: { x: 0, y: 0 } }; + const root = { getState: async () => ({ version: 1, nodes: [] }) }; + const view = { getHostSnapshot: () => ({ compositionRevision: 1 }), addNode: async () => { adds++; } }; + const result = await spawnRequestedNode(root, view, request, "tone_map", () => { + alive = false; + return "node_reserved"; + }, () => alive); + assert.equal(result, false); + assert.equal(adds, 0); +}); + +test("spawn suppresses teardown RPC rejections but propagates genuine live add errors", async () => { + const request = { compositionRevision: 1, viewPosition: { x: 0, y: 0 } }; + let alive = true; + const view = { getHostSnapshot: () => ({ compositionRevision: 1 }), addNode: async () => {} }; + const root = { getState: async () => { alive = false; throw Error("detached state"); } }; + assert.equal(await spawnRequestedNode(root, view, request, "tone_map", () => "node_a", () => alive), false); + + alive = true; + root.getState = async () => ({ version: 1, nodes: [] }); + view.addNode = async () => { alive = false; throw Error("detached add"); }; + assert.equal(await spawnRequestedNode(root, view, request, "tone_map", () => "node_b", () => alive), false); + + alive = true; + view.addNode = async () => { throw Error("live add failure"); }; + await assert.rejects( + spawnRequestedNode(root, view, request, "tone_map", () => "node_c", () => alive), + /live add failure/, + ); +}); diff --git a/tests/demo-loadouts.test.js b/tests/demo-loadouts.test.js index 8408e52..c18c347 100644 --- a/tests/demo-loadouts.test.js +++ b/tests/demo-loadouts.test.js @@ -1,10 +1,12 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { createCubeGeometry, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, LoadoutError } from "../static/demo-loadouts.js"; +import { createCubeGeometry, createMaterialGalleryGlb, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, loadouts, LoadoutError } from "../static/demo-loadouts.js"; function parseGlb(buffer){const view=new DataView(buffer);assert.equal(view.getUint32(0,true),0x46546c67);assert.equal(view.getUint32(4,true),2);assert.equal(view.getUint32(8,true),buffer.byteLength);const length=view.getUint32(12,true);assert.equal(view.getUint32(16,true),0x4e4f534a);const json=JSON.parse(new TextDecoder().decode(new Uint8Array(buffer,20,length)).trim());const bin=20+length;assert.equal(view.getUint32(bin+4,true),0x004e4942);assert.equal(view.getUint32(bin,true),json.buffers[0].byteLength);return json;} test("procedural cube and sphere have complete indexed vertex streams",()=>{const cube=createCubeGeometry(),sphere=createUvSphereGeometry();assert.deepEqual([cube.positions.length/3,cube.indices.length],[24,36]);assert.ok(sphere.positions.length/3>300);for(const geometry of [cube,sphere]){assert.equal(geometry.normals.length,geometry.positions.length);assert.equal(geometry.texcoords.length,geometry.positions.length/3*2);assert.ok(geometry.indices.every(i=>i>=0&&i{const g=createCubeGeometry();for(let i=0;ig.positions.slice(id*3,id*3+3)),a=p[1].map((v,j)=>v-p[0][j]),b=p[2].map((v,j)=>v-p[0][j]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],normal=g.normals.slice(ids[0]*3,ids[0]*3+3);assert.ok(cross.reduce((sum,v,j)=>sum+v*normal[j],0)>0)}}); test("loadouts return fresh deterministic GLBs with one mesh and nine nodes",async()=>{for(const name of ["cubes","spheres"]){const a=await loadDemoLoadout(name),b=await loadDemoLoadout(name);assert.notStrictEqual(a,b);assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.meshes.length,1);assert.equal(json.nodes.length,9);assert.ok(json.nodes.every(n=>n.mesh===0));assert.deepEqual(json.meshes[0].primitives[0].attributes,{POSITION:0,NORMAL:1,TEXCOORD_0:2});assert.equal(json.accessors[3].componentType,5125);}}); +test("Phase 6 gallery is deterministic and covers core PBR shader semantics",async()=>{const a=createMaterialGalleryGlb(),b=await loadDemoLoadout("materials");assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.asset.generator,"yawn-phase6-pbr-gallery");assert.deepEqual(json.extensionsUsed,["KHR_materials_ior"]);assert.equal(json.materials.length,16);assert.equal(json.nodes.length,16);assert.equal(json.images.length,3);assert.ok(json.images.every(image=>image.mimeType==="image/png"&&image.bufferView!==undefined));assert.ok(json.meshes.every(mesh=>JSON.stringify(mesh.primitives[0].attributes)===JSON.stringify({POSITION:0,NORMAL:1,TEXCOORD_0:2})));assert.deepEqual(json.materials.slice(0,4).map(x=>x.pbrMetallicRoughness.roughnessFactor),[.08,.3,.6,1]);assert.ok(json.materials.slice(0,4).every(x=>x.pbrMetallicRoughness.metallicFactor===0));assert.ok(json.materials.slice(4,8).every(x=>x.pbrMetallicRoughness.metallicFactor===1));assert.deepEqual(json.materials.slice(8,11).map(x=>x.extensions.KHR_materials_ior.ior),[1,1.5,2]);assert.equal(json.materials[11].normalTexture.index,2);assert.equal(json.materials[12].occlusionTexture.index,1);assert.equal(json.materials[13].emissiveTexture.index,0);assert.equal(json.materials[14].alphaMode,"MASK");assert.equal(json.materials[15].doubleSided,true);assert.ok(json.nodes[15].scale[0]<0);assert.equal(loadouts.materials.label,"Phase 6 deterministic PBR gallery");}); +test("loadout dropdown preserves legacy scenes and exposes the material gallery",async()=>{const html=await (await import("node:fs/promises")).readFile(new URL("../static/index.html",import.meta.url),"utf8");for(const id of ["cubes","spheres","materials","manor","sponza"])assert.match(html,new RegExp(`