feat: add gpu driven render graph pipeline

Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-27 20:26:31 +00:00
co-authored by heaust
parent d4e8634f67
commit 05311e564c
51 changed files with 13262 additions and 743 deletions
Generated
+1
View File
@@ -913,6 +913,7 @@ dependencies = [
"console_log", "console_log",
"futures", "futures",
"gltf", "gltf",
"image",
"js-sys", "js-sys",
"log", "log",
"raw-window-handle", "raw-window-handle",
+3 -1
View File
@@ -19,6 +19,7 @@ web-sys = { version = "0.3.77", features = [
"FileList", "FileList",
"OffscreenCanvas", "OffscreenCanvas",
"MouseEvent", "MouseEvent",
"PointerEvent",
"WheelEvent", "WheelEvent",
"KeyboardEvent", "KeyboardEvent",
"Worker", "Worker",
@@ -43,4 +44,5 @@ wgpu = "26.0.1"
thiserror = "2.0.15" thiserror = "2.0.15"
ultraviolet = "0.10.0" ultraviolet = "0.10.0"
futures = "0.3" 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"] }
+13 -6
View File
@@ -29,7 +29,7 @@ pub struct EditorScene {
impl renderer::renderer::scene::Scene for EditorScene { impl renderer::renderer::scene::Scene for EditorScene {
fn setup( fn setup(
renderer_context: &gpu_renderer::RendererContext, renderer_context: &gpu_renderer::RendererContext,
resources: &mut gpu_renderer::GpuResources, resources: &mut gpu_renderer::PipelineLibrary,
render_data: &mut RenderData, render_data: &mut RenderData,
) -> Self { ) -> Self {
let dimension = ultraviolet::Vec2::new( let dimension = ultraviolet::Vec2::new(
@@ -90,14 +90,18 @@ impl renderer::renderer::scene::Scene for EditorScene {
self.frame_metadata.mouse_click = [x, y]; self.frame_metadata.mouse_click = [x, y];
} }
fn handle_zoom(&mut self, _delta_y: f32) { fn handle_zoom(&mut self, delta_y: f32) {
// TODO: Implement zoom properly when Camera exposes necessary methods self.cam.zoom(delta_y);
} }
fn handle_orbit(&mut self, delta_x: f32, delta_y: f32) { fn handle_orbit(&mut self, delta_x: f32, delta_y: f32) {
self.cam.orbit(delta_x, delta_y); 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) { fn set_camera_depth_range(&mut self, near: f32, far: f32) {
self.cam.set_depth_range(near, far); self.cam.set_depth_range(near, far);
} }
@@ -149,13 +153,14 @@ impl EditorScene {
fn create_default_scene( fn create_default_scene(
&mut self, &mut self,
device: &wgpu::Device, device: &wgpu::Device,
resources: &mut gpu_renderer::GpuResources, resources: &mut gpu_renderer::PipelineLibrary,
render_data: &mut RenderData, render_data: &mut RenderData,
surface_format: wgpu::TextureFormat, surface_format: wgpu::TextureFormat,
) { ) {
let positions: Vec<[f32; 3]> = Self::VERTICES.iter().map(|v| v.pos).collect(); let positions: Vec<[f32; 3]> = Self::VERTICES.iter().map(|v| v.pos).collect();
// Ground plane normals point upward (Y+) // Ground plane normals point upward (Y+)
let normals: Vec<[f32; 3]> = vec![[0.0, 1.0, 0.0]; positions.len()]; 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]] = &[ let uvs: &[[f32; 2]] = &[
[0.0, 0.0], [0.0, 0.0],
[1.0, 0.0], [1.0, 0.0],
@@ -183,9 +188,11 @@ impl EditorScene {
.create_mesh(MeshCreateInfo { .create_mesh(MeshCreateInfo {
positions: &positions, positions: &positions,
normals: &normals, normals: &normals,
tangents: &tangents,
uvs, uvs,
indices: Self::INDICES, indices: Self::INDICES,
pipeline: pipeline_index, pipeline: pipeline_index,
material: renderer::render_data::MaterialKey::DEFAULT,
flags: RenderFlags::VISIBLE, flags: RenderFlags::VISIBLE,
default_instance_flags: RenderFlags::VISIBLE, default_instance_flags: RenderFlags::VISIBLE,
default_transform: transform, default_transform: transform,
@@ -196,11 +203,11 @@ impl EditorScene {
/// Entrypoint for the level editor /// Entrypoint for the level editor
#[wasm_bindgen] #[wasm_bindgen]
pub fn main() -> Result<RendererBridge, JsValue> { pub fn main(profile: bool) -> Result<RendererBridge, JsValue> {
std::panic::set_hook(Box::new(console_error_panic_hook::hook)); std::panic::set_hook(Box::new(console_error_panic_hook::hook));
wasm_logger::init(wasm_logger::Config::default()); wasm_logger::init(wasm_logger::Config::default());
let runtime = LevelEditor::setup_runtime()?; let runtime = LevelEditor::setup_runtime(profile)?;
Ok(RendererBridge { runtime }) Ok(RendererBridge { runtime })
} }
+1
View File
@@ -46,6 +46,7 @@ thiserror = { workspace = true }
ultraviolet = { workspace = true } ultraviolet = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
gltf = { workspace = true } gltf = { workspace = true }
image = { workspace = true }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+84 -55
View File
@@ -18,9 +18,10 @@ use web_sys::AddEventListenerOptions;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub struct EventListeners { pub struct EventListeners {
pub resize_listener: Option<Closure<dyn FnMut()>>, pub resize_listener: Option<Closure<dyn FnMut()>>,
pub mousemove_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>, pub pointer_listener: Option<Closure<dyn FnMut(web_sys::PointerEvent)>>,
pub mousedown_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>, pub click_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
pub wheel_listener: Option<Closure<dyn FnMut(web_sys::WheelEvent)>>, pub wheel_listener: Option<Closure<dyn FnMut(web_sys::WheelEvent)>>,
pub contextmenu_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
pub keyboard_listener: Option<Closure<dyn FnMut(web_sys::KeyboardEvent)>>, pub keyboard_listener: Option<Closure<dyn FnMut(web_sys::KeyboardEvent)>>,
} }
@@ -29,9 +30,10 @@ impl EventListeners {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
resize_listener: None, resize_listener: None,
mousemove_listener: None, pointer_listener: None,
mousedown_listener: None, click_listener: None,
wheel_listener: None, wheel_listener: None,
contextmenu_listener: None,
keyboard_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 width = f64::from(resize_canvas.client_width().max(1));
let height = f64::from(resize_canvas.client_height().max(1)); let height = f64::from(resize_canvas.client_height().max(1));
resize_worker_chan let _ = resize_worker_chan.send(WindowEvent::Resize(ResizeMessage {
.send(WindowEvent::Resize(ResizeMessage {
width, width,
height, height,
scale_factor: window.device_pixel_ratio(), scale_factor: window.device_pixel_ratio(),
})) }));
.unwrap();
}); });
window.add_event_listener_with_callback("resize", resize_listener.as_ref().unchecked_ref())?; window.add_event_listener_with_callback("resize", resize_listener.as_ref().unchecked_ref())?;
let mousemove_worker_chan = worker_chan.clone(); let pointer_worker_chan = worker_chan.clone();
let mousemove_listener: Closure<dyn FnMut(web_sys::MouseEvent)> = let pointer_canvas = canvas.clone();
let pointer_listener: Closure<dyn FnMut(web_sys::PointerEvent)> =
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<dyn FnMut(web_sys::MouseEvent)> =
Closure::new(move |event: web_sys::MouseEvent| { Closure::new(move |event: web_sys::MouseEvent| {
use crate::message::MouseMessage; use crate::message::MouseMessage;
if event.buttons() & 0x04 != 0 { if event.button() != 0 {
event.prevent_default(); return;
} }
let mouse_event_data = MouseMessage::from_evt(event.clone()); let message =
MouseMessage::from_evt(&event, f64::from(click_canvas.client_height().max(1)));
let mut event_data = WindowEvent::PointerMove(mouse_event_data.clone()); let _ = click_worker_chan.send(WindowEvent::PointerClick(message));
if event.type_() == "click" {
event_data = WindowEvent::PointerClick(mouse_event_data.clone());
}
mousemove_worker_chan.clone().send(event_data).unwrap();
}); });
canvas.add_event_listener_with_callback("click", click_listener.as_ref().unchecked_ref())?;
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<dyn FnMut(web_sys::MouseEvent)> =
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(),
)?;
let wheel_worker_chan = worker_chan.clone(); let wheel_worker_chan = worker_chan.clone();
let wheel_canvas = canvas.clone();
let wheel_listener: Closure<dyn FnMut(web_sys::WheelEvent)> = let wheel_listener: Closure<dyn FnMut(web_sys::WheelEvent)> =
Closure::new(move |event: web_sys::WheelEvent| { Closure::new(move |event: web_sys::WheelEvent| {
use crate::message::WheelMessage; use crate::message::WheelMessage;
event.prevent_default(); event.prevent_default();
let wheel_event_data = WheelMessage::from_evt(event); if let Some(message) =
WheelMessage::from_evt(&event, f64::from(wheel_canvas.client_height().max(1)))
wheel_worker_chan {
.send(WindowEvent::PointerWheel(wheel_event_data)) let _ = wheel_worker_chan.send(WindowEvent::PointerWheel(message));
.unwrap(); }
}); });
let wheel_options = { let wheel_options = {
@@ -121,12 +140,19 @@ pub fn setup_event_listeners(
options 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",
wheel_listener.as_ref().unchecked_ref(), wheel_listener.as_ref().unchecked_ref(),
&wheel_options, &wheel_options,
)?; )?;
let contextmenu_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
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_worker_chan = worker_chan.clone();
let keyboard_listener: Closure<dyn FnMut(web_sys::KeyboardEvent)> = let keyboard_listener: Closure<dyn FnMut(web_sys::KeyboardEvent)> =
Closure::new(move |event: web_sys::KeyboardEvent| { Closure::new(move |event: web_sys::KeyboardEvent| {
@@ -134,9 +160,7 @@ pub fn setup_event_listeners(
let keyboard_event_data = KeyboardMessage::from_evt(event); let keyboard_event_data = KeyboardMessage::from_evt(event);
keyboard_worker_chan let _ = keyboard_worker_chan.send(WindowEvent::Keyboard(keyboard_event_data));
.send(WindowEvent::Keyboard(keyboard_event_data))
.unwrap();
}); });
window window
@@ -144,9 +168,10 @@ pub fn setup_event_listeners(
Ok(EventListeners { Ok(EventListeners {
resize_listener: Some(resize_listener), resize_listener: Some(resize_listener),
mousemove_listener: Some(mousemove_listener), pointer_listener: Some(pointer_listener),
mousedown_listener: Some(mousedown_listener), click_listener: Some(click_listener),
wheel_listener: Some(wheel_listener), wheel_listener: Some(wheel_listener),
contextmenu_listener: Some(contextmenu_listener),
keyboard_listener: Some(keyboard_listener), keyboard_listener: Some(keyboard_listener),
}) })
} }
@@ -166,6 +191,7 @@ impl WebAppRuntime {
pub fn new<T: crate::renderer::scene::Scene + 'static>( pub fn new<T: crate::renderer::scene::Scene + 'static>(
worker_name: &str, worker_name: &str,
canvas_selector: &str, canvas_selector: &str,
profile: bool,
) -> Result<Self, JsValue> { ) -> Result<Self, JsValue> {
let (sender, receiver) = mpsc::channel::<WindowEvent>(); let (sender, receiver) = mpsc::channel::<WindowEvent>();
@@ -179,7 +205,7 @@ impl WebAppRuntime {
let worker = MainWorker::spawn(worker_name, 1, ring_ptr, move || { let worker = MainWorker::spawn(worker_name, 1, ring_ptr, move || {
spawn_local(async move { spawn_local(async move {
let ring = unsafe { &*(ring_ptr as *const CommandRing) }; let ring = unsafe { &*(ring_ptr as *const CommandRing) };
MainWorker::run_render_loop::<T>(receiver, ring).await; MainWorker::run_render_loop::<T>(receiver, ring, profile).await;
}); });
})?; })?;
@@ -228,9 +254,12 @@ pub trait WebApp {
fn on_runtime_initialized(_runtime: &mut WebAppRuntime) {} fn on_runtime_initialized(_runtime: &mut WebAppRuntime) {}
/// Perform the default WASM initialization routine. /// Perform the default WASM initialization routine.
fn setup_runtime() -> Result<WebAppRuntime, JsValue> { fn setup_runtime(profile: bool) -> Result<WebAppRuntime, JsValue> {
let mut runtime = let mut runtime = WebAppRuntime::new::<Self::Scene>(
WebAppRuntime::new::<Self::Scene>(Self::worker_name(), Self::canvas_selector())?; Self::worker_name(),
Self::canvas_selector(),
profile,
)?;
Self::on_runtime_initialized(&mut runtime); Self::on_runtime_initialized(&mut runtime);
Ok(runtime) Ok(runtime)
} }
+275 -28
View File
@@ -3,7 +3,16 @@ use std::f32::consts::PI;
use ultraviolet::{projection, Bivec3, Mat4, Rotor3, Vec3}; use ultraviolet::{projection, Bivec3, Mat4, Rotor3, Vec3};
use wgpu::util::DeviceExt; 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 MIN_DISTANCE: f32 = 0.1;
const MAX_PITCH: f32 = PI / 2.0 - 0.01; const MAX_PITCH: f32 = PI / 2.0 - 0.01;
@@ -81,6 +90,9 @@ pub struct CameraUniform {
} }
impl Camera { 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 { pub fn new(aspect_ratio: f32) -> Self {
let mut camera = Camera { let mut camera = Camera {
view_proj: [[0.0; 4]; 4], view_proj: [[0.0; 4]; 4],
@@ -115,8 +127,14 @@ impl Camera {
} }
pub fn look_at(&mut self, position: Vec3, target: Vec3) { pub fn look_at(&mut self, position: Vec3, target: Vec3) {
if !vec3_is_finite(position) || !vec3_is_finite(target) {
return;
}
self.position = position; self.position = position;
self.target = target; 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.up = Vec3::unit_y();
self.compute_rotor(); self.compute_rotor();
self.dirty = true; self.dirty = true;
@@ -141,6 +159,9 @@ impl Camera {
} }
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) { 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 // Skip tiny movements to reduce unnecessary computations
if delta_x.abs() < 0.001 && delta_y.abs() < 0.001 { if delta_x.abs() < 0.001 && delta_y.abs() < 0.001 {
return; return;
@@ -174,39 +195,62 @@ impl Camera {
self.compute_view_proj_mat(); self.compute_view_proj_mat();
} }
pub fn zoom(&mut self, msg: &WheelMessage) { pub fn zoom(&mut self, delta_y_pixels: f32) {
let mut delta = msg.delta_y as f32; if !delta_y_pixels.is_finite() || delta_y_pixels.abs() <= f32::EPSILON {
// 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 {
return; return;
} }
// Get forward direction from camera position to target let mut offset = self.position - self.target;
let mut forward_vec = self.target - self.position; let mut current_distance = offset.mag();
if forward_vec.mag_sq() <= f32::EPSILON { if !current_distance.is_finite() {
forward_vec = Vec3::unit_z(); 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 self.position = new_position;
let dolly_distance = delta * ZOOM_SENSITIVITY * current_distance; self.distance = new_distance;
let dolly_translation = forward_dir * dolly_distance; self.dirty = true;
self.compute_view_proj_mat();
}
self.position += dolly_translation; pub fn pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
self.target += dolly_translation; if !delta_x.is_finite()
|| !delta_y.is_finite()
self.compute_rotor(); || !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.dirty = true;
self.compute_view_proj_mat(); self.compute_view_proj_mat();
} }
@@ -294,3 +338,206 @@ impl Camera {
self.rotor = (swing_rotor * twist_rotor).normalized(); 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);
}
}
+516 -11
View File
@@ -4,10 +4,100 @@ use gltf::Gltf;
use ultraviolet::{Mat4, Vec3}; use ultraviolet::{Mat4, Vec3};
use crate::render_data::{ use crate::render_data::{
InstanceHandle, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey, RenderData, InstanceHandle, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey,
RenderDataError, RenderFlags, 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<TextureReference>,
pub metallic_roughness_texture: Option<TextureReference>,
pub normal_texture: Option<TextureReference>,
pub normal_scale: f32,
pub occlusion_texture: Option<TextureReference>,
pub occlusion_strength: f32,
pub emissive_texture: Option<TextureReference>,
}
impl Default for Material {
fn default() -> Self {
Self {
key: MaterialKey::DEFAULT,
base_color_factor: [1.0; 4],
metallic_factor: 1.0,
roughness_factor: 1.0,
emissive_factor: [0.0; 3],
ior: 1.5,
alpha_mode: AlphaMode::Opaque,
alpha_cutoff: 0.5,
double_sided: false,
base_color_texture: None,
metallic_roughness_texture: None,
normal_texture: None,
normal_scale: 1.0,
occlusion_texture: None,
occlusion_strength: 1.0,
emissive_texture: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TextureMetadata {
pub image: usize,
pub sampler: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SamplerMetadata {
pub index: usize,
pub mag_filter: Option<String>,
pub min_filter: Option<String>,
pub wrap_s: String,
pub wrap_t: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ImageSource {
Uri(String),
BufferView(usize),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImageMetadata {
pub index: usize,
pub name: Option<String>,
pub mime_type: Option<String>,
pub source: ImageSource,
/// Encoded PNG/JPEG bytes. Kept encoded so GPU installation can decode and
/// upload images one at a time instead of retaining a decoded image batch.
pub encoded_data: Vec<u8>,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct InstalledScene { pub struct InstalledScene {
pub meshes: Vec<MeshHandle>, pub meshes: Vec<MeshHandle>,
@@ -60,16 +150,31 @@ pub enum ImportError {
GltfParse(#[from] gltf::Error), GltfParse(#[from] gltf::Error),
#[error("unsupported or malformed primitive: {0}")] #[error("unsupported or malformed primitive: {0}")]
InvalidPrimitive(String), InvalidPrimitive(String),
#[error("unsupported image source: {0}")]
UnsupportedImage(String),
#[error("invalid KHR_materials_ior value: {0}")]
InvalidIor(f32),
#[error("failed to install imported scene")] #[error("failed to install imported scene")]
Install(#[from] RenderDataError), Install(#[from] RenderDataError),
} }
fn decode_ior(value: Option<f32>) -> Result<f32, ImportError> {
let ior = value.unwrap_or(1.5);
if ior == 0.0 || (ior.is_finite() && ior >= 1.0) {
Ok(ior)
} else {
Err(ImportError::InvalidIor(ior))
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct ImportedGeometry { pub struct ImportedGeometry {
pub key: (usize, usize), pub key: (usize, usize),
pub material: MaterialKey,
pub double_sided: bool, pub double_sided: bool,
pub positions: Vec<[f32; 3]>, pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>, pub normals: Vec<[f32; 3]>,
pub tangents: Vec<[f32; 4]>,
pub uvs: Vec<[f32; 2]>, pub uvs: Vec<[f32; 2]>,
pub indices: Vec<u32>, pub indices: Vec<u32>,
} }
@@ -82,12 +187,257 @@ pub struct ImportedOccurrence {
pub struct ImportedScene { pub struct ImportedScene {
pub geometries: Vec<ImportedGeometry>, pub geometries: Vec<ImportedGeometry>,
pub occurrences: Vec<ImportedOccurrence>, pub occurrences: Vec<ImportedOccurrence>,
pub materials: Vec<Material>,
pub textures: Vec<TextureMetadata>,
pub samplers: Vec<SamplerMetadata>,
pub images: Vec<ImageMetadata>,
}
fn texture_reference(info: gltf::texture::Info<'_>) -> TextureReference {
TextureReference {
texture: info.texture().index(),
tex_coord: info.tex_coord(),
}
}
fn normalize(value: [f32; 3], fallback: [f32; 3]) -> [f32; 3] {
let length = value.iter().map(|x| x * x).sum::<f32>().sqrt();
if length > f32::EPSILON && length.is_finite() {
value.map(|x| x / length)
} else {
fallback
}
}
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
/// Completes triangle vertex attributes. Generated attributes use corner vertices,
/// which deliberately splits UV seams, hard normal edges, and opposite handedness.
fn repair_geometry(
positions: Vec<[f32; 3]>,
normals: Option<Vec<[f32; 3]>>,
tangents: Option<Vec<[f32; 4]>>,
uvs: Vec<[f32; 2]>,
indices: Vec<u32>,
) -> Result<
(
Vec<[f32; 3]>,
Vec<[f32; 3]>,
Vec<[f32; 4]>,
Vec<[f32; 2]>,
Vec<u32>,
),
ImportError,
> {
if indices.len() % 3 != 0 || indices.iter().any(|&i| i as usize >= positions.len()) {
return Err(ImportError::InvalidPrimitive(
"triangle indices are malformed".into(),
));
}
let normals_valid = normals.as_ref().is_some_and(|x| x.len() == positions.len());
let tangents_valid = tangents
.as_ref()
.is_some_and(|x| x.len() == positions.len());
if normals_valid && tangents_valid {
return Ok((positions, normals.unwrap(), tangents.unwrap(), uvs, indices));
}
let mut out_p = Vec::with_capacity(indices.len());
let mut out_n = Vec::with_capacity(indices.len());
let mut out_t = Vec::with_capacity(indices.len());
let mut out_uv = Vec::with_capacity(indices.len());
for triangle in indices.chunks_exact(3) {
let ids = [
triangle[0] as usize,
triangle[1] as usize,
triangle[2] as usize,
];
let p = ids.map(|i| positions[i]);
let uv = ids.map(|i| uvs.get(i).copied().unwrap_or([0.0; 2]));
let face_normal = normalize(cross(sub(p[1], p[0]), sub(p[2], p[0])), [0.0, 1.0, 0.0]);
let duv1 = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
let duv2 = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
let determinant = duv1[0] * duv2[1] - duv1[1] * duv2[0];
let edge1 = sub(p[1], p[0]);
let edge2 = sub(p[2], p[0]);
let (raw_tangent, raw_bitangent) =
if determinant.abs() > f32::EPSILON && determinant.is_finite() {
let r = determinant.recip();
(
std::array::from_fn(|i| (edge1[i] * duv2[1] - edge2[i] * duv1[1]) * r),
std::array::from_fn(|i| (edge2[i] * duv1[0] - edge1[i] * duv2[0]) * r),
)
} else {
([0.0; 3], [0.0; 3])
};
for corner in 0..3 {
let n = normals
.as_ref()
.filter(|_| normals_valid)
.map_or(face_normal, |x| x[ids[corner]]);
let n = normalize(n, face_normal);
let projected = std::array::from_fn(|i| raw_tangent[i] - n[i] * dot(n, raw_tangent));
let fallback_axis = if n[0].abs() < 0.9 {
[1.0, 0.0, 0.0]
} else {
[0.0, 1.0, 0.0]
};
let tangent3 = normalize(
projected,
normalize(cross(fallback_axis, n), [0.0, 0.0, 1.0]),
);
let generated = [
tangent3[0],
tangent3[1],
tangent3[2],
if dot(cross(n, tangent3), raw_bitangent) < 0.0 {
-1.0
} else {
1.0
},
];
out_p.push(p[corner]);
out_n.push(n);
out_t.push(
tangents
.as_ref()
.filter(|_| tangents_valid)
.map_or(generated, |x| x[ids[corner]]),
);
out_uv.push(uv[corner]);
}
}
let out_i = (0..u32::try_from(out_p.len())
.map_err(|_| ImportError::InvalidPrimitive("vertex count exceeds u32".into()))?)
.collect();
Ok((out_p, out_n, out_t, out_uv, out_i))
} }
pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> { pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
let model = Gltf::from_slice(bytes)?; decode_gltf_model(Gltf::from_slice(bytes)?)
let buffers = gltf::import_buffers(&model.document, None, model.blob.clone())?; }
pub fn decode_gltf_owned(bytes: Vec<u8>) -> Result<ImportedScene, ImportError> {
let model = Gltf::from_slice(&bytes)?;
drop(bytes);
decode_gltf_model(model)
}
fn decode_gltf_model(mut model: Gltf) -> Result<ImportedScene, ImportError> {
// Reject external images before buffer import can turn them into a generic
// import error (or attempt to interpret a data/external URI).
for image in model.images() {
if let gltf::image::Source::Uri { uri, .. } = image.source() {
return Err(ImportError::UnsupportedImage(format!(
"URI/external image '{uri}'"
)));
}
}
let blob = model.blob.take();
let buffers = gltf::import_buffers(&model.document, None, blob)?;
let mut result = ImportedScene::default(); let mut result = ImportedScene::default();
result.materials.push(Material::default());
for material in model.materials() {
let pbr = material.pbr_metallic_roughness();
let normal = material.normal_texture();
let normal_texture = normal.as_ref().map(|x| TextureReference {
texture: x.texture().index(),
tex_coord: x.tex_coord(),
});
let occlusion = material.occlusion_texture();
let occlusion_texture = occlusion.as_ref().map(|x| TextureReference {
texture: x.texture().index(),
tex_coord: x.tex_coord(),
});
result.materials.push(Material {
key: MaterialKey::new(material.index().unwrap() as u32 + 1),
base_color_factor: pbr.base_color_factor(),
metallic_factor: pbr.metallic_factor(),
roughness_factor: pbr.roughness_factor(),
emissive_factor: material.emissive_factor(),
ior: decode_ior(material.ior())?,
alpha_mode: match material.alpha_mode() {
gltf::material::AlphaMode::Opaque => AlphaMode::Opaque,
gltf::material::AlphaMode::Mask => AlphaMode::Mask,
gltf::material::AlphaMode::Blend => AlphaMode::Blend,
},
alpha_cutoff: material.alpha_cutoff().unwrap_or(0.5),
double_sided: material.double_sided(),
base_color_texture: pbr.base_color_texture().map(texture_reference),
metallic_roughness_texture: pbr.metallic_roughness_texture().map(texture_reference),
normal_texture,
normal_scale: normal.map_or(1.0, |x| x.scale()),
occlusion_texture,
occlusion_strength: occlusion.map_or(1.0, |x| x.strength()),
emissive_texture: material.emissive_texture().map(texture_reference),
});
}
result.textures = model
.textures()
.map(|x| TextureMetadata {
image: x.source().index(),
sampler: x.sampler().index(),
})
.collect();
result.samplers = model
.samplers()
.map(|x| SamplerMetadata {
index: x.index().unwrap(),
mag_filter: x.mag_filter().map(|v| format!("{v:?}")),
min_filter: x.min_filter().map(|v| format!("{v:?}")),
wrap_s: format!("{:?}", x.wrap_s()),
wrap_t: format!("{:?}", x.wrap_t()),
})
.collect();
result.images = model
.images()
.map(|x| -> Result<_, ImportError> {
let (source, mime_type, encoded_data) = match x.source() {
gltf::image::Source::Uri { uri, .. } => {
return Err(ImportError::UnsupportedImage(format!(
"URI/external image '{uri}'"
)))
}
gltf::image::Source::View { view, mime_type } => {
let data = buffers.get(view.buffer().index()).ok_or_else(|| {
ImportError::UnsupportedImage("image buffer is missing".into())
})?;
let end = view.offset().checked_add(view.length()).ok_or_else(|| {
ImportError::UnsupportedImage("image bufferView overflows".into())
})?;
let bytes = data.0.get(view.offset()..end).ok_or_else(|| {
ImportError::UnsupportedImage("image bufferView is out of bounds".into())
})?;
(
ImageSource::BufferView(view.index()),
Some(mime_type.to_owned()),
bytes.to_vec(),
)
}
};
Ok(ImageMetadata {
index: x.index(),
name: x.name().map(str::to_owned),
mime_type,
source,
encoded_data,
})
})
.collect::<Result<_, _>>()?;
let mut seen = HashMap::new(); let mut seen = HashMap::new();
fn visit( fn visit(
node: gltf::Node<'_>, node: gltf::Node<'_>,
@@ -116,12 +466,8 @@ pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
if count == 0 { if count == 0 {
continue; continue;
} }
let mut normals: Vec<_> = reader let normals = reader.read_normals().map(|x| x.collect());
.read_normals() let tangents = reader.read_tangents().map(|x| x.collect());
.map(|x| x.collect())
.unwrap_or_default();
normals.resize(count, [0., 1., 0.]);
normals.truncate(count);
let mut uvs: Vec<_> = reader let mut uvs: Vec<_> = reader
.read_tex_coords(0) .read_tex_coords(0)
.map(|x| x.into_f32().collect()) .map(|x| x.into_f32().collect())
@@ -139,11 +485,20 @@ pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
if indices.is_empty() { if indices.is_empty() {
continue; continue;
} }
let (positions, normals, tangents, uvs, indices) =
repair_geometry(positions, normals, tangents, uvs, indices)?;
let primitive_material = primitive.material();
result.geometries.push(ImportedGeometry { result.geometries.push(ImportedGeometry {
key, 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, positions,
normals, normals,
tangents,
uvs, uvs,
indices, indices,
}); });
@@ -189,9 +544,11 @@ pub fn install_imported(
let created = stage.create_mesh(MeshCreateInfo { let created = stage.create_mesh(MeshCreateInfo {
positions: &geometry.positions, positions: &geometry.positions,
normals: &geometry.normals, normals: &geometry.normals,
tangents: &geometry.tangents,
uvs: &geometry.uvs, uvs: &geometry.uvs,
indices: &geometry.indices, indices: &geometry.indices,
pipeline: pipelines[usize::from(geometry.double_sided)], pipeline: pipelines[usize::from(geometry.double_sided)],
material: geometry.material,
flags: RenderFlags::VISIBLE, flags: RenderFlags::VISIBLE,
default_instance_flags: RenderFlags::VISIBLE, default_instance_flags: RenderFlags::VISIBLE,
default_transform: transform, default_transform: transform,
@@ -250,3 +607,151 @@ pub fn install_imported(
bounds, bounds,
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uri_images_are_rejected_explicitly() {
let json = br#"{
"asset":{"version":"2.0"},
"images":[{"uri":"external.png"}],
"scenes":[{"nodes":[]}],"scene":0
}"#;
let result = decode_gltf(json);
assert!(
matches!(
result,
Err(ImportError::UnsupportedImage(ref message)) if message.contains("URI/external")
),
"unexpected result: {result:?}"
);
}
#[test]
fn owned_and_borrowed_decode_paths_remain_compatible() {
let json = br#"{
"asset":{"version":"2.0"},
"scenes":[{"nodes":[]}],"scene":0
}"#;
let borrowed = decode_gltf(json).unwrap();
let owned = decode_gltf_owned(json.to_vec()).unwrap();
assert_eq!(borrowed.geometries.len(), owned.geometries.len());
assert_eq!(borrowed.occurrences.len(), owned.occurrences.len());
assert_eq!(borrowed.materials.len(), owned.materials.len());
}
#[test]
fn repair_duplicates_corners_and_generates_flat_finite_frames() {
let positions = vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
];
let repaired = repair_geometry(
positions,
None,
None,
vec![[0.0; 2]; 4],
vec![0, 1, 2, 0, 3, 1],
)
.unwrap();
assert_eq!(repaired.0.len(), 6);
assert_eq!(repaired.4, (0..6).collect::<Vec<_>>());
assert_eq!(&repaired.1[..3], &[[0.0, 0.0, 1.0]; 3]);
assert_eq!(&repaired.1[3..], &[[0.0, 1.0, 0.0]; 3]);
assert!(repaired
.2
.iter()
.flatten()
.all(|component| component.is_finite()));
assert!(repaired.2.iter().all(|tangent| tangent[3].abs() == 1.0));
}
#[test]
fn generated_tangents_split_opposite_handedness_and_supplied_values_survive() {
let positions = vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[-1.0, 0.0, 0.0],
];
let normals = vec![[0.0, 0.0, 1.0]; 4];
let generated = repair_geometry(
positions.clone(),
Some(normals.clone()),
None,
vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 0.0]],
vec![0, 1, 2, 0, 2, 3],
)
.unwrap();
assert_ne!(generated.2[0][3], generated.2[3][3]);
let supplied = vec![[0.25, 0.5, 0.75, -1.0]; 4];
let preserved = repair_geometry(
positions,
Some(normals),
Some(supplied.clone()),
vec![[0.0; 2]; 4],
vec![0, 1, 2],
)
.unwrap();
assert_eq!(preserved.2, supplied);
}
#[test]
fn material_defaults_match_gltf_core_defaults() {
let material = Material::default();
assert_eq!(material.base_color_factor, [1.0; 4]);
assert_eq!(material.metallic_factor, 1.0);
assert_eq!(material.roughness_factor, 1.0);
assert_eq!(material.alpha_cutoff, 0.5);
assert_eq!(material.ior, 1.5);
assert_eq!(material.key, MaterialKey::DEFAULT);
}
#[test]
fn imports_khr_materials_ior() {
let json = br#"{
"asset":{"version":"2.0"},
"extensionsUsed":["KHR_materials_ior"],
"materials":[{"extensions":{"KHR_materials_ior":{"ior":1.33}}}],
"scenes":[{"nodes":[]}],"scene":0
}"#;
let imported = decode_gltf(json).unwrap();
assert_eq!(imported.materials[1].ior, 1.33);
}
#[test]
fn ior_gate_accepts_default_physical_values_and_explicit_zero_sentinel() {
assert_eq!(decode_ior(None).unwrap(), 1.5);
assert_eq!(decode_ior(Some(1.0)).unwrap(), 1.0);
assert_eq!(decode_ior(Some(1.33)).unwrap(), 1.33);
assert_eq!(decode_ior(Some(0.0)).unwrap(), 0.0);
}
#[test]
fn ior_gate_rejects_nonphysical_and_nonfinite_values() {
for ior in [-1.0, 0.5, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
assert!(matches!(
decode_ior(Some(ior)),
Err(ImportError::InvalidIor(_))
));
}
}
#[test]
fn malformed_but_parseable_json_ior_is_rejected_before_packing() {
for ior in ["-1", "0.5"] {
let json = format!(
r#"{{"asset":{{"version":"2.0"}},"extensionsUsed":["KHR_materials_ior"],"materials":[{{"extensions":{{"KHR_materials_ior":{{"ior":{ior}}}}}}}],"scenes":[{{"nodes":[]}}],"scene":0}}"#
);
assert!(matches!(
decode_gltf(json.as_bytes()),
Err(ImportError::InvalidIor(_))
));
}
}
}
+46 -66
View File
@@ -1,72 +1,52 @@
struct UniformData { struct UniformData { mouse_move: vec2<f32>, mouse_click: vec2<f32>, resolution: vec2<f32>, time: f32, _padding0: f32, camera_position: vec4<f32> }
mouse_move: vec2<f32>, struct MaterialData { base_color_factor: vec4<f32>, emissive_factor: vec4<f32>, surface_factors: vec4<f32>, alpha_optics: vec4<f32>, flags: vec4<u32>, uv_sets: vec4<u32>, debug_extras: vec4<u32> }
mouse_click: vec2<f32>,
resolution: vec2<f32>,
time: f32,
_padding0: f32,
camera_position: vec4<f32>,
}
@group(0) @binding(0) var<uniform> uni: UniformData; @group(0) @binding(0) var<uniform> uni: UniformData;
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>; @group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
@group(2) @binding(0) var<uniform> material: MaterialData;
@group(2) @binding(1) var base_tex: texture_2d<f32>;
@group(2) @binding(2) var mr_tex: texture_2d<f32>;
@group(2) @binding(3) var normal_tex: texture_2d<f32>;
@group(2) @binding(4) var occlusion_tex: texture_2d<f32>;
@group(2) @binding(5) var emissive_tex: texture_2d<f32>;
@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 { struct VertexInput { @location(0) pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) uv: vec2<f32>, @location(3) model_col0: vec4<f32>, @location(4) model_col1: vec4<f32>, @location(5) model_col2: vec4<f32>, @location(6) model_col3: vec4<f32>, @location(7) normal_col0: vec4<f32>, @location(8) normal_col1: vec4<f32>, @location(9) normal_col2: vec4<f32>, @location(10) tangent: vec4<f32> }
@location(0) pos: vec3<f32>, struct VertexOutput { @builtin(position) clip_position: vec4<f32>, @location(0) world_pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) tangent: vec3<f32>, @location(3) bitangent: vec3<f32>, @location(4) uv: vec2<f32>, @location(5) @interpolate(flat) determinant_sign: f32 }
@location(1) normal: vec3<f32>, fn safe_normalize(v: vec3<f32>, fallback: vec3<f32>) -> vec3<f32> { let l2 = dot(v, v); return select(fallback, v * inverseSqrt(l2), l2 > 1e-12 && l2 < 1e30); }
@location(2) uv: vec2<f32>, @vertex fn vs_main(in: VertexInput) -> VertexOutput {
@location(3) model_col0: vec4<f32>, var out: VertexOutput; let model = mat4x4<f32>(in.model_col0, in.model_col1, in.model_col2, in.model_col3);
@location(4) model_col1: vec4<f32>, let linear = mat3x3<f32>(in.model_col0.xyz, in.model_col1.xyz, in.model_col2.xyz); let nm = mat3x3<f32>(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz);
@location(5) model_col2: vec4<f32>, let world = model * vec4<f32>(in.pos, 1.0); let n = safe_normalize(nm * in.normal, vec3<f32>(0,1,0)); let raw_t = linear * in.tangent.xyz;
@location(6) model_col3: vec4<f32>, var t = raw_t - n * dot(n, raw_t); if dot(t,t) < 1e-8 { t = cross(select(vec3<f32>(0,1,0), vec3<f32>(1,0,0), abs(n.x) < 0.9), n); } t = safe_normalize(t, vec3<f32>(1,0,0));
@location(7) normal_col0: vec4<f32>, 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<f32>(0,0,1)) * in.tangent.w * in.normal_col0.w; out.uv = in.uv; out.determinant_sign = in.normal_col0.w; return out;
@location(8) normal_col1: vec4<f32>,
@location(9) normal_col2: vec4<f32>,
} }
struct Closure { base: vec4<f32>, mr: vec2<f32>, normal_map: vec3<f32>, ao: f32, emissive: vec3<f32> }
struct VertexOutput { fn sample_closure(uv: vec2<f32>) -> Closure {
@builtin(position) clip_position: vec4<f32>, let bits = material.flags.x; var c: Closure;
@location(0) world_pos: vec3<f32>, c.base = material.base_color_factor * select(vec4<f32>(1), textureSample(base_tex, base_sampler, uv), (bits & 1u) != 0u);
@location(1) normal: vec3<f32> let mr = select(vec4<f32>(1), textureSample(mr_tex, mr_sampler, uv), (bits & 2u) != 0u); c.mr = vec2<f32>(clamp(material.surface_factors.x * mr.b,0,1), clamp(material.surface_factors.y * mr.g,0.045,1));
c.normal_map = select(vec3<f32>(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<f32>(1), textureSample(emissive_tex, emissive_sampler, uv).rgb, (bits & 16u) != 0u); return c;
} }
fn schlick(f0: vec3<f32>, v_h: f32) -> vec3<f32> { return f0 + (vec3<f32>(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); }
@vertex 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); }
fn vs_main(in: VertexInput) -> VertexOutput { @fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
var out: VertexOutput; let c=sample_closure(in.uv); if material.alpha_optics.x == 1.0 && c.base.a < material.alpha_optics.y { discard; }
let model = mat4x4<f32>( 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;
in.model_col0, let n=safe_normalize(mat3x3<f32>(in.tangent,in.bitangent,in.normal)*safe_normalize(vec3<f32>(map.xy*material.surface_factors.z,map.z),vec3<f32>(0,0,1)),in.normal)*orientation;
in.model_col1, let v=safe_normalize(uni.camera_position.xyz-in.world_pos,n); let l=safe_normalize(vec3<f32>(0.35,1,0.45),vec3<f32>(0,1,0)); let h=safe_normalize(v+l,n);
in.model_col2, 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;
in.model_col3, let f0=mix(vec3<f32>(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<f32>(1)-direct_f)*(1.0-c.mr.x)*c.base.rgb/3.14159265;
); let sun=(diffuse+spec)*nl*vec3<f32>(3.0,2.85,2.65);
let world_position = model * vec4<f32>(in.pos, 1.0); let up=clamp(n.y*0.5+0.5,0,1); let sky=mix(vec3<f32>(0.055,0.045,0.035),vec3<f32>(0.24,0.36,0.58),up); let env_diff=(vec3<f32>(1)-env_f)*(1.0-c.mr.x)*c.base.rgb*sky;
out.clip_position = view_proj * world_position; let reflection=reflect(-v,n); let horizon=clamp(reflection.y*0.5+0.5,0,1); let env_spec=env_f*mix(vec3<f32>(0.04,0.035,0.03),vec3<f32>(0.28,0.42,0.7),horizon)*(1.0-0.65*c.mr.y);
out.world_pos = world_position.xyz; var color=sun+(env_diff+env_spec)*c.ao+c.emissive;
let normal_matrix = mat3x3<f32>(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz); if material.debug_extras.y == 1u { color=n*0.5+0.5; } else if material.debug_extras.y == 2u { color=vec3<f32>(c.mr.x,c.mr.y,c.ao); } else if material.debug_extras.y == 3u { color=f0; }
out.normal = normalize(normal_matrix * in.normal); return vec4<f32>(color,1.0); // BLEND remains intentionally opaque.
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let light_direction = normalize(vec3<f32>(0.35, 1.0, 0.45));
let light_color = vec3<f32>(1.0, 0.95, 0.85);
let base_color = vec3<f32>(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<f32>(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<f32>(lighting + x - y, 1.0);
} }
+74 -19
View File
@@ -1,6 +1,40 @@
use core::fmt; use core::fmt;
use std::cell::BorrowMutError; use std::cell::BorrowMutError;
use std::sync::mpsc::TryRecvError; use std::sync::mpsc::TryRecvError;
use wasm_bindgen::JsCast;
pub const RIGHT_BUTTON_MASK: u16 = 0x02;
pub const MIDDLE_BUTTON_MASK: u16 = 0x04;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CameraDrag {
Orbit,
Pan,
}
pub fn camera_drag(buttons: u16) -> Option<CameraDrag> {
if buttons & MIDDLE_BUTTON_MASK != 0 {
Some(CameraDrag::Orbit)
} else if buttons & RIGHT_BUTTON_MASK != 0 {
Some(CameraDrag::Pan)
} else {
None
}
}
pub fn normalize_wheel_delta(delta_y: f64, delta_mode: u32, viewport_height: f64) -> Option<f32> {
if !delta_y.is_finite() || !viewport_height.is_finite() {
return None;
}
let delta = match delta_mode {
0 => delta_y,
1 => delta_y * 16.0,
2 => delta_y * viewport_height.max(1.0),
_ => return None,
};
let delta = delta as f32;
delta.is_finite().then_some(delta)
}
#[derive(Debug)] #[derive(Debug)]
pub enum WindowEvent { pub enum WindowEvent {
@@ -42,10 +76,11 @@ pub struct MouseMessage {
pub movement_y: f64, pub movement_y: f64,
pub offset_x: f64, pub offset_x: f64,
pub offset_y: f64, pub offset_y: f64,
pub viewport_height: f64,
} }
impl MouseMessage { 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(); let window = web_sys::window().unwrap();
Self { Self {
scale_factor: window.device_pixel_ratio(), scale_factor: window.device_pixel_ratio(),
@@ -57,33 +92,24 @@ impl MouseMessage {
movement_y: event.movement_y() as f64, movement_y: event.movement_y() as f64,
offset_x: event.offset_x() as f64, offset_x: event.offset_x() as f64,
offset_y: event.offset_y() 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)] #[derive(Debug, Clone)]
pub struct WheelMessage { pub struct WheelMessage {
pub scale_factor: f64, pub delta_y_pixels: f32,
pub delta_x: f64,
pub delta_y: f64,
pub delta_z: f64,
pub delta_mode: u32,
pub client_x: f64,
pub client_y: f64,
} }
impl WheelMessage { impl WheelMessage {
pub fn from_evt(event: web_sys::WheelEvent) -> Self { pub fn from_evt(event: &web_sys::WheelEvent, viewport_height: f64) -> Option<Self> {
let window = web_sys::window().unwrap(); normalize_wheel_delta(event.delta_y(), event.delta_mode(), viewport_height)
Self { .map(|delta_y_pixels| Self { delta_y_pixels })
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,
}
} }
} }
@@ -147,3 +173,32 @@ impl From<BorrowMutError> for DrainEventError {
DrainEventError::BorrowError(err) 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)
);
}
}
+4 -1
View File
@@ -109,12 +109,15 @@ impl MainWorker {
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>( pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>(
events_chan: Receiver<WindowEvent>, events_chan: Receiver<WindowEvent>,
ring: &'static CommandRing, ring: &'static CommandRing,
profile: bool,
) { ) {
use crate::renderer::Renderer; use crate::renderer::Renderer;
let canvas = wait_for_canvas_transfer().await; let canvas = wait_for_canvas_transfer().await;
let renderer = Rc::new(RefCell::new(Renderer::<T>::new(canvas, events_chan).await)); let renderer = Rc::new(RefCell::new(
Renderer::<T>::new(canvas, events_chan, profile).await,
));
renderer.borrow_mut().command_ring = Some(ring); renderer.borrow_mut().command_ring = Some(ring);
Renderer::run_render_loop(renderer); Renderer::run_render_loop(renderer);
} }
+48 -1
View File
@@ -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)] #[repr(transparent)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct RenderFlags(u32); pub struct RenderFlags(u32);
@@ -101,9 +119,11 @@ pub struct GeometryRange {
pub struct MeshCreateInfo<'a> { pub struct MeshCreateInfo<'a> {
pub positions: &'a [[f32; 3]], pub positions: &'a [[f32; 3]],
pub normals: &'a [[f32; 3]], pub normals: &'a [[f32; 3]],
pub tangents: &'a [[f32; 4]],
pub uvs: &'a [[f32; 2]], pub uvs: &'a [[f32; 2]],
pub indices: &'a [u32], pub indices: &'a [u32],
pub pipeline: PipelineKey, pub pipeline: PipelineKey,
pub material: MaterialKey,
pub flags: RenderFlags, pub flags: RenderFlags,
pub default_instance_flags: RenderFlags, pub default_instance_flags: RenderFlags,
pub default_transform: ModelTransform, pub default_transform: ModelTransform,
@@ -120,6 +140,7 @@ pub struct MeshView {
pub handle: MeshHandle, pub handle: MeshHandle,
pub geometry: GeometryRange, pub geometry: GeometryRange,
pub pipeline: PipelineKey, pub pipeline: PipelineKey,
pub material: MaterialKey,
pub flags: RenderFlags, pub flags: RenderFlags,
pub aabb: Aabb, pub aabb: Aabb,
pub default_instance: InstanceHandle, pub default_instance: InstanceHandle,
@@ -178,6 +199,7 @@ pub fn affine_world_aabb(local: Aabb, model: ModelTransform) -> Result<Aabb, Ren
pub struct VertexStreams<'a> { pub struct VertexStreams<'a> {
pub positions: &'a [[f32; 3]], pub positions: &'a [[f32; 3]],
pub normals: &'a [[f32; 3]], pub normals: &'a [[f32; 3]],
pub tangents: &'a [[f32; 4]],
pub uvs: &'a [[f32; 2]], pub uvs: &'a [[f32; 2]],
} }
@@ -267,6 +289,7 @@ pub enum RenderDataError {
struct VertexSoa { struct VertexSoa {
positions: Vec<[f32; 3]>, positions: Vec<[f32; 3]>,
normals: Vec<[f32; 3]>, normals: Vec<[f32; 3]>,
tangents: Vec<[f32; 4]>,
uvs: Vec<[f32; 2]>, uvs: Vec<[f32; 2]>,
logical_capacity: u32, logical_capacity: u32,
max_capacity: Option<u32>, max_capacity: Option<u32>,
@@ -287,6 +310,7 @@ struct MeshSoa {
index_starts: Vec<u32>, index_starts: Vec<u32>,
index_counts: Vec<u32>, index_counts: Vec<u32>,
pipeline_keys: Vec<PipelineKey>, pipeline_keys: Vec<PipelineKey>,
material_keys: Vec<MaterialKey>,
flags: Vec<RenderFlags>, flags: Vec<RenderFlags>,
aabb_mins: Vec<[f32; 3]>, aabb_mins: Vec<[f32; 3]>,
aabb_maxs: Vec<[f32; 3]>, aabb_maxs: Vec<[f32; 3]>,
@@ -416,6 +440,7 @@ impl RenderData {
vertices: VertexSoa { vertices: VertexSoa {
positions: Vec::new(), positions: Vec::new(),
normals: Vec::new(), normals: Vec::new(),
tangents: Vec::new(),
uvs: Vec::new(), uvs: Vec::new(),
logical_capacity: 0, logical_capacity: 0,
max_capacity: config.max_vertices, max_capacity: config.max_vertices,
@@ -553,6 +578,8 @@ impl RenderData {
.copy_from_slice(info.positions); .copy_from_slice(info.positions);
self.vertices.normals[as_usize(vertex_range.start)..as_usize(vertex_range.end)] self.vertices.normals[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
.copy_from_slice(info.normals); .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)] self.vertices.uvs[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
.copy_from_slice(info.uvs); .copy_from_slice(info.uvs);
self.indices.values[as_usize(index_range.start)..as_usize(index_range.end)] self.indices.values[as_usize(index_range.start)..as_usize(index_range.end)]
@@ -567,6 +594,7 @@ impl RenderData {
index_count, index_count,
}, },
info.pipeline, info.pipeline,
info.material,
info.flags, info.flags,
bounds, bounds,
default_instance, default_instance,
@@ -766,6 +794,7 @@ impl RenderData {
VertexStreams { VertexStreams {
positions: &self.vertices.positions, positions: &self.vertices.positions,
normals: &self.vertices.normals, normals: &self.vertices.normals,
tangents: &self.vertices.tangents,
uvs: &self.vertices.uvs, uvs: &self.vertices.uvs,
} }
} }
@@ -783,6 +812,7 @@ impl RenderData {
self.instances.slots.clear(); self.instances.slots.clear();
self.vertices.positions.clear(); self.vertices.positions.clear();
self.vertices.normals.clear(); self.vertices.normals.clear();
self.vertices.tangents.clear();
self.vertices.uvs.clear(); self.vertices.uvs.clear();
self.indices.values.clear(); self.indices.values.clear();
self.vertices.allocator.clear(); self.vertices.allocator.clear();
@@ -800,6 +830,7 @@ impl RenderData {
)?; )?;
reserve_vec(&mut self.vertices.positions, target, "vertices")?; reserve_vec(&mut self.vertices.positions, target, "vertices")?;
reserve_vec(&mut self.vertices.normals, 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")?; reserve_vec(&mut self.vertices.uvs, target, "vertices")?;
self.vertices.logical_capacity = target; self.vertices.logical_capacity = target;
Ok(()) Ok(())
@@ -821,6 +852,9 @@ impl RenderData {
let vertices = as_usize(self.vertices.allocator.high_water()); let vertices = as_usize(self.vertices.allocator.high_water());
self.vertices.positions.resize(vertices, [0.0; 3]); self.vertices.positions.resize(vertices, [0.0; 3]);
self.vertices.normals.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.vertices.uvs.resize(vertices, [0.0; 2]);
self.indices self.indices
.values .values
@@ -834,6 +868,9 @@ impl RenderData {
self.vertices self.vertices
.normals .normals
.truncate(as_usize(self.vertices.allocator.high_water())); .truncate(as_usize(self.vertices.allocator.high_water()));
self.vertices
.tangents
.truncate(as_usize(self.vertices.allocator.high_water()));
self.vertices self.vertices
.uvs .uvs
.truncate(as_usize(self.vertices.allocator.high_water())); .truncate(as_usize(self.vertices.allocator.high_water()));
@@ -852,6 +889,7 @@ impl MeshSoa {
index_starts: Vec::new(), index_starts: Vec::new(),
index_counts: Vec::new(), index_counts: Vec::new(),
pipeline_keys: Vec::new(), pipeline_keys: Vec::new(),
material_keys: Vec::new(),
flags: Vec::new(), flags: Vec::new(),
aabb_mins: Vec::new(), aabb_mins: Vec::new(),
aabb_maxs: 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_starts, target, "meshes")?;
reserve_vec(&mut self.index_counts, target, "meshes")?; reserve_vec(&mut self.index_counts, target, "meshes")?;
reserve_vec(&mut self.pipeline_keys, 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.flags, target, "meshes")?;
reserve_vec(&mut self.aabb_mins, target, "meshes")?; reserve_vec(&mut self.aabb_mins, target, "meshes")?;
reserve_vec(&mut self.aabb_maxs, target, "meshes")?; reserve_vec(&mut self.aabb_maxs, target, "meshes")?;
@@ -885,6 +924,7 @@ impl MeshSoa {
prepared: PreparedSlot, prepared: PreparedSlot,
geometry: GeometryRange, geometry: GeometryRange,
pipeline: PipelineKey, pipeline: PipelineKey,
material: MaterialKey,
flags: RenderFlags, flags: RenderFlags,
bounds: Aabb, bounds: Aabb,
default: InstanceHandle, default: InstanceHandle,
@@ -895,6 +935,7 @@ impl MeshSoa {
resize_column(&mut self.index_starts, len, 0); resize_column(&mut self.index_starts, len, 0);
resize_column(&mut self.index_counts, len, 0); resize_column(&mut self.index_counts, len, 0);
resize_column(&mut self.pipeline_keys, len, PipelineKey::new(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.flags, len, RenderFlags::NONE);
resize_column(&mut self.aabb_mins, len, [0.0; 3]); resize_column(&mut self.aabb_mins, len, [0.0; 3]);
resize_column(&mut self.aabb_maxs, 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_starts[index] = geometry.index_start;
self.index_counts[index] = geometry.index_count; self.index_counts[index] = geometry.index_count;
self.pipeline_keys[index] = pipeline; self.pipeline_keys[index] = pipeline;
self.material_keys[index] = material;
self.flags[index] = flags; self.flags[index] = flags;
self.aabb_mins[index] = bounds.min; self.aabb_mins[index] = bounds.min;
self.aabb_maxs[index] = bounds.max; self.aabb_maxs[index] = bounds.max;
@@ -925,6 +967,7 @@ impl MeshSoa {
index_count: self.index_counts[index], index_count: self.index_counts[index],
}, },
pipeline: self.pipeline_keys[index], pipeline: self.pipeline_keys[index],
material: self.material_keys[index],
flags: self.flags[index], flags: self.flags[index],
aabb: Aabb { aabb: Aabb {
min: self.aabb_mins[index], min: self.aabb_mins[index],
@@ -1054,7 +1097,10 @@ fn validate_geometry(info: &MeshCreateInfo<'_>) -> Result<u32, RenderDataError>
if info.positions.is_empty() { if info.positions.is_empty() {
return Err(RenderDataError::EmptyVertices); 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); return Err(RenderDataError::MismatchedVertexStreams);
} }
let vertex_count = let vertex_count =
@@ -1068,6 +1114,7 @@ fn validate_geometry(info: &MeshCreateInfo<'_>) -> Result<u32, RenderDataError>
.iter() .iter()
.flatten() .flatten()
.chain(info.normals.iter().flatten()) .chain(info.normals.iter().flatten())
.chain(info.tangents.iter().flatten())
.chain(info.uvs.iter().flatten()) .chain(info.uvs.iter().flatten())
.any(|value| !value.is_finite()) .any(|value| !value.is_finite())
{ {
+20 -1
View File
@@ -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 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 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 UVS: [[f32; 2]; 3] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
const INDICES: [u32; 3] = [0, 1, 2]; const INDICES: [u32; 3] = [0, 1, 2];
@@ -10,9 +11,11 @@ fn info() -> MeshCreateInfo<'static> {
MeshCreateInfo { MeshCreateInfo {
positions: &POSITIONS, positions: &POSITIONS,
normals: &NORMALS, normals: &NORMALS,
tangents: &TANGENTS,
uvs: &UVS, uvs: &UVS,
indices: &INDICES, indices: &INDICES,
pipeline: PipelineKey::new(7), pipeline: PipelineKey::new(7),
material: MaterialKey::new(11),
flags: RenderFlags::from_bits_retain(2), flags: RenderFlags::from_bits_retain(2),
default_instance_flags: RenderFlags::VISIBLE, default_instance_flags: RenderFlags::VISIBLE,
default_transform: IDENTITY_MODEL_TRANSFORM, 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(); let created = data.create_mesh(info()).unwrap();
assert!(data.instance(created.default_instance).unwrap().is_default); 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().flags.bits(), 2);
assert_eq!(
data.mesh(created.mesh).unwrap().material,
MaterialKey::new(11)
);
assert_eq!( assert_eq!(
data.instance(created.default_instance).unwrap().flags, data.instance(created.default_instance).unwrap().flags,
RenderFlags::VISIBLE RenderFlags::VISIBLE
@@ -239,6 +246,7 @@ fn streams_remain_coordinated_across_interior_delete_tail_delete_and_reuse() {
data.destroy_mesh(second.mesh).unwrap(); data.destroy_mesh(second.mesh).unwrap();
assert_eq!(data.streams().positions.len(), 3); assert_eq!(data.streams().positions.len(), 3);
assert_eq!(data.streams().normals.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.streams().uvs.len(), 3);
assert_eq!(data.indices().len(), 3); assert_eq!(data.indices().len(), 3);
} }
@@ -288,6 +296,7 @@ fn aabb_supports_one_point_and_multiple_points() {
let mut one = info(); let mut one = info();
one.positions = &point; one.positions = &point;
one.normals = &normal; one.normals = &normal;
one.tangents = &[[1.0, 0.0, 0.0, 1.0]];
one.uvs = &uv; one.uvs = &uv;
one.indices = &index; one.indices = &index;
let mut data = data(); let mut data = data();
@@ -325,6 +334,13 @@ fn malformed_geometry_matrix_is_rejected_without_consumption() {
data.create_mesh(candidate).unwrap_err(), data.create_mesh(candidate).unwrap_err(),
RenderDataError::MismatchedVertexStreams 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 short_uvs = &UVS[..2];
let mut candidate = info(); let mut candidate = info();
candidate.uvs = short_uvs; candidate.uvs = short_uvs;
@@ -339,19 +355,22 @@ fn malformed_geometry_matrix_is_rejected_without_consumption() {
RenderDataError::EmptyIndices RenderDataError::EmptyIndices
); );
for stream in 0..3 { for stream in 0..4 {
for bad in [f32::NAN, f32::INFINITY] { for bad in [f32::NAN, f32::INFINITY] {
let mut positions = POSITIONS; let mut positions = POSITIONS;
let mut normals = NORMALS; let mut normals = NORMALS;
let mut tangents = TANGENTS;
let mut uvs = UVS; let mut uvs = UVS;
match stream { match stream {
0 => positions[0][0] = bad, 0 => positions[0][0] = bad,
1 => normals[0][0] = bad, 1 => normals[0][0] = bad,
2 => tangents[0][0] = bad,
_ => uvs[0][0] = bad, _ => uvs[0][0] = bad,
} }
let mut candidate = info(); let mut candidate = info();
candidate.positions = &positions; candidate.positions = &positions;
candidate.normals = &normals; candidate.normals = &normals;
candidate.tangents = &tangents;
candidate.uvs = &uvs; candidate.uvs = &uvs;
assert_eq!( assert_eq!(
data.create_mesh(candidate).unwrap_err(), data.create_mesh(candidate).unwrap_err(),
File diff suppressed because it is too large Load Diff
+417
View File
@@ -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)
}
+34 -1
View File
@@ -1,9 +1,14 @@
//! Device-free V1 render graph compiler and compiled graph registry. //! Device-free V1 render graph compiler and compiled graph registry.
mod compiler; mod compiler;
mod compiler_v2;
mod contracts_v2;
mod plan_v2;
mod registry; mod registry;
mod runtime; mod runtime;
mod runtime_v2;
mod schema; mod schema;
mod schema_v2;
pub use compiler::{ pub use compiler::{
compile, compile_with, parse_and_compile, AllocationClass, CompiledGraph, CompiledOutput, compile, compile_with, parse_and_compile, AllocationClass, CompiledGraph, CompiledOutput,
@@ -11,12 +16,38 @@ pub use compiler::{
ExecutorRegistry, ExecutorResolution, Lifetime, NormalizedParameters, SceneForwardExecutors, ExecutorRegistry, ExecutorResolution, Lifetime, NormalizedParameters, SceneForwardExecutors,
TextureAllocationKey, TextureUsage, TransientAllocation, 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::{ pub use runtime::{
class_offsets, resolve_extent, runtime_texture_key, validate_activatable, ResolvedExtent, class_offsets, resolve_extent, runtime_texture_key, validate_activatable, ResolvedExtent,
RuntimeTextureKey, RuntimeTextureKey,
}; };
pub use runtime_v2::*;
pub use schema::*; pub use schema::*;
pub use schema_v2::*;
pub fn parse_and_compile_any(bytes: &[u8]) -> Result<RegisteredGraph, GraphError> {
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_JSON_BYTES: usize = 1024 * 1024;
pub const MAX_RESOURCES: usize = 1024; pub const MAX_RESOURCES: usize = 1024;
@@ -56,3 +87,5 @@ impl GraphError {
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
#[cfg(test)]
mod tests_v2;
+381
View File
@@ -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<CompiledResourceV2>,
pub executions: Vec<CompiledExecutionV2>,
pub texture_families: Vec<TextureFamilyV2>,
pub allocation_classes: Vec<AllocationClassV2>,
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<u32>,
pub lifetime: Option<LifetimeV2>,
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<AllocationRefV2>,
},
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<CompiledSocketInputV2>,
pub outputs: Vec<CompiledSocketOutputV2>,
pub accesses: Vec<CompiledAccessV2>,
}
#[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<ColorAttachmentPlanV2>,
depth_stencil: Option<DepthStencilAttachmentPlanV2>,
},
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<TextureFormatV2>,
}
#[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<TextureVersionV2>,
pub usage: Vec<TextureUsageV2>,
pub allocation: Option<AllocationRefV2>,
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<TextureFormatV2>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AllocationClassV2 {
pub key: TextureCompatibilityKeyV2,
pub slots: Vec<AllocationSlotV2>,
}
#[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<TextureUsageV2>,
pub occupants: Vec<u32>,
}
#[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})
}
}
+48 -20
View File
@@ -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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompiledGraphId { pub struct CompiledGraphId {
pub slot: u32, pub slot: u32,
@@ -12,13 +32,14 @@ impl From<CompiledGraphId> for [u32; 2] {
#[derive(Debug)] #[derive(Debug)]
struct Slot { struct Slot {
generation: u32, generation: u32,
value: Option<CompiledGraph>, value: Option<RegisteredGraph>,
retired: bool, retired: bool,
} }
#[derive(Debug)] #[derive(Debug)]
pub struct Registry { pub struct Registry {
slots: Vec<Slot>, slots: Vec<Slot>,
capacity: u32, capacity: u32,
latest_revisions: HashMap<String, u32>,
} }
impl Default for Registry { impl Default for Registry {
fn default() -> Self { fn default() -> Self {
@@ -30,34 +51,25 @@ impl Registry {
Self { Self {
slots: vec![], slots: vec![],
capacity, capacity,
latest_revisions: HashMap::new(),
} }
} }
pub fn compile( pub fn compile(
&mut self, &mut self,
bytes: &[u8], bytes: &[u8],
) -> Result<(CompiledGraphId, serde_json::Value), GraphError> { ) -> Result<(CompiledGraphId, serde_json::Value), GraphError> {
let graph = parse_and_compile(bytes)?; let graph = parse_and_compile_any(bytes)?;
if let Some((i, s)) = self.slots.iter_mut().enumerate().find(|(_, s)| { let (graph_id, revision) = graph.identity();
s.value if self
.as_ref() .latest_revisions
.is_some_and(|g| g.graph_id == graph.graph_id) .get(graph_id)
}) { .is_some_and(|latest| revision <= *latest)
if graph.revision <= s.value.as_ref().unwrap().revision { {
return Err(GraphError::new( return Err(GraphError::new(
"GRAPH_REVISION_CONFLICT", "GRAPH_REVISION_CONFLICT",
"revision must increase", "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 i = if let Some(i) = self let i = if let Some(i) = self
.slots .slots
.iter() .iter()
@@ -84,10 +96,26 @@ impl Registry {
generation: self.slots[i].generation, generation: self.slots[i].generation,
}; };
let summary = graph.summary(id.into()); let summary = graph.summary(id.into());
self.latest_revisions.insert(graph_id.to_owned(), revision);
self.slots[i].value = Some(graph); self.slots[i].value = Some(graph);
Ok((id, summary)) Ok((id, summary))
} }
pub fn get(&self, id: CompiledGraphId) -> Result<&CompiledGraph, GraphError> { 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 self.slots
.get(id.slot as usize) .get(id.slot as usize)
.filter(|s| s.generation == id.generation) .filter(|s| s.generation == id.generation)
@@ -95,7 +123,7 @@ impl Registry {
.ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id")) .ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))
} }
pub fn contains(&self, id: CompiledGraphId) -> bool { 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> { pub fn drop_graph(&mut self, id: CompiledGraphId) -> Result<(), GraphError> {
let s = self let s = self
+605
View File
@@ -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<wgpu::TextureFormat>,
}
#[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<wgpu::TextureFormat>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeAllocationSlotV2 {
pub kind: AllocationKindV2,
pub descriptor: RuntimeTextureDescriptorV2,
pub occupants: Vec<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeAllocationClassV2 {
pub key: TextureCompatibilityKeyV2,
pub slots: Vec<RuntimeAllocationSlotV2>,
}
#[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<RuntimeAllocationClassV2>,
pub resource_allocations: Vec<Option<AllocationRefV2>>,
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<RuntimeExecutionV2>,
pub surface: RuntimeSurfaceContractV2,
}
fn error(code: &'static str, message: impl Into<String>, path: impl Into<String>) -> 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<u32, GraphError> {
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<ResolvedExtentV2, GraphError> {
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<RuntimeTextureDescriptorV2, GraphError> {
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<String>, path: impl Into<String>) -> GraphError {
error("GRAPH_RUNTIME_PLAN_INVALID", message, path)
}
pub fn prepare_runtime_plan_v2(
graph: &CompiledGraphV2,
surface: RuntimeSurfaceContractV2,
limits: Option<&wgpu::Limits>,
) -> Result<RuntimePlanV2, GraphError> {
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(|_| ())
}
+153
View File
@@ -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<NodeV2>,
}
#[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<String, NodeOutputRef>,
}
#[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<TextureFormatV2>,
}
#[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,
}
+5 -4
View File
@@ -213,12 +213,13 @@ fn registry_capacity() {
); );
} }
#[test] #[test]
fn registry_revision_replaces_in_place() { fn registry_revision_creates_immutable_handle() {
let mut r = Registry::new(1); let mut r = Registry::new(2);
let (a, _) = r.compile(&empty("g", 1)).unwrap(); let (a, _) = r.compile(&empty("g", 1)).unwrap();
let (b, _) = r.compile(&empty("g", 2)).unwrap(); let (b, _) = r.compile(&empty("g", 2)).unwrap();
assert_eq!(a, b); assert_ne!(a, b);
assert_eq!(r.get(a).unwrap().revision, 2); assert_eq!(r.get(a).unwrap().revision, 1);
assert_eq!(r.get(b).unwrap().revision, 2);
} }
#[test] #[test]
fn registry_revision_conflict() { fn registry_revision_conflict() {
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
struct Params { planes: array<vec4<f32>, 6>, count: u32, visible_predicate: u32, frustum_predicate: u32, _pad: u32 }
struct Instance { model: mat4x4<f32>, n0: vec4<f32>, n1: vec4<f32>, n2: vec4<f32> }
struct Aabb { min: vec4<f32>, max: vec4<f32> }
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<uniform> params: Params;
@group(0) @binding(1) var<storage, read> instances: array<Instance>;
@group(0) @binding(2) var<storage, read> bounds: array<Aabb>;
@group(0) @binding(3) var<storage, read> authored_visible: array<u32>;
@group(0) @binding(4) var<storage, read> metadata: array<Meta>;
@group(0) @binding(5) var<storage, read_write> frustum_flags: array<u32>;
@group(0) @binding(6) var<storage, read_write> commands: array<Command>;
@compute @workgroup_size(64)
fn frustum_cull(@builtin(global_invocation_id) id: vec3<u32>) {
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<f32>(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<u32>) {
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);
}
@@ -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<T: Scene>(
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::<crate::renderer::gpu_scene::GpuInstance>() 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<T: Scene>(
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<T: Scene>(
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);
}
}
+3
View File
@@ -0,0 +1,3 @@
mod legacy_forward;
pub(super) use legacy_forward::{encode_compiled_v1, encode_compiled_v2, encode_immediate};
@@ -0,0 +1,38 @@
@group(0) @binding(0) var source_texture: texture_2d<f32>;
@group(0) @binding(1) var second_texture: texture_2d<f32>;
@group(0) @binding(2) var linear_clamp: sampler;
struct Parameters { a: vec4<f32>, b: vec4<f32> }
@group(0) @binding(3) var<uniform> parameters: Parameters;
struct VertexOut { @builtin(position) position: vec4<f32>, @location(0) uv: vec2<f32> }
@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<f32>) -> vec4<f32> { return textureSampleLevel(source_texture, linear_clamp, uv, 0.0); }
@fragment fn fs_copy(in: VertexOut) -> @location(0) vec4<f32> { return sample_source(in.uv); }
fn aces(x: vec3<f32>) -> vec3<f32> {
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<f32>) -> vec3<f32> {
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<f32> { 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<f32> {
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<f32> {
let size=vec2<f32>(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<f32> { 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>) -> f32 { return dot(c,vec3(0.2126,0.7152,0.0722)); }
@fragment fn fs_luminance_edge(in: VertexOut) -> @location(0) vec4<f32> {
let d=1.0/vec2<f32>(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);
}
+468 -56
View File
@@ -1,6 +1,9 @@
use std::mem::size_of; 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}; use bytemuck::{Pod, Zeroable};
#[repr(C)] #[repr(C)]
@@ -15,10 +18,38 @@ pub struct GpuInstance {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct DrawItem { pub struct DrawItem {
pub pipeline: PipelineKey, pub pipeline: PipelineKey,
pub material: MaterialKey,
pub mesh: MeshHandle, pub mesh: MeshHandle,
pub indices: std::ops::Range<u32>, pub indices: std::ops::Range<u32>,
pub base_vertex: i32, pub base_vertex: i32,
pub instances: std::ops::Range<u32>, pub instances: std::ops::Range<u32>,
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)] #[derive(Default)]
@@ -26,30 +57,60 @@ pub struct GpuScenePlan {
pub positions: Vec<[f32; 3]>, pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>, pub normals: Vec<[f32; 3]>,
pub uvs: Vec<[f32; 2]>, pub uvs: Vec<[f32; 2]>,
pub tangents: Vec<[f32; 4]>,
pub indices: Vec<u32>, pub indices: Vec<u32>,
pub instances: Vec<GpuInstance>, pub instances: Vec<GpuInstance>,
pub draws: Vec<DrawItem>, pub draws: Vec<DrawItem>,
pub local_aabbs: Vec<GpuLocalAabb>,
pub effective_visibility: Vec<u32>,
pub draw_metadata: Vec<DrawSlotMetadata>,
pub commands: Vec<DrawIndexedIndirect>,
}
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 { impl GpuScenePlan {
pub fn build(data: &RenderData) -> Result<Self, &'static str> { pub fn build(data: &SceneFramePlan) -> Result<Self, &'static str> {
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<Self, &'static str> {
let _ = query; // Packing is canonical; predicates are evaluated by the GPU.
let mut plan = Self::default(); let mut plan = Self::default();
let mut meshes: Vec<_> = data let mut meshes: Vec<_> = data.meshes.iter().collect();
.meshes() meshes.sort_by_key(|mesh| {
.filter(|(_, mesh)| mesh.flags.contains(RenderFlags::VISIBLE)) (
.collect(); mesh.pipeline.get(),
meshes.sort_by_key(|(handle, mesh)| { mesh.material.get(),
(mesh.pipeline.get(), handle.slot(), handle.generation()) mesh.handle.slot(),
mesh.handle.generation(),
)
}); });
let streams = data.streams(); for mesh in meshes {
for (handle, mesh) in meshes { let occurrences: Vec<_> = data.mesh_occurrence_indices[mesh.occurrence_range.clone()]
let mut occurrences: Vec<_> = data .iter()
.instances() .map(|&index| &data.occurrences[index])
.filter(|(_, instance)| {
instance.mesh == handle && instance.flags.contains(RenderFlags::VISIBLE)
})
.collect(); .collect();
occurrences.sort_by_key(|(handle, _)| (handle.slot(), handle.generation()));
if occurrences.is_empty() { if occurrences.is_empty() {
continue; continue;
} }
@@ -59,23 +120,25 @@ impl GpuScenePlan {
.checked_add(mesh.geometry.vertex_count as usize) .checked_add(mesh.geometry.vertex_count as usize)
.ok_or("vertex range overflow")?; .ok_or("vertex range overflow")?;
plan.positions.extend_from_slice( plan.positions.extend_from_slice(
streams data.positions
.positions
.get(source_start..source_end) .get(source_start..source_end)
.ok_or("invalid vertex range")?, .ok_or("invalid vertex range")?,
); );
plan.normals.extend_from_slice( plan.normals.extend_from_slice(
streams data.normals
.normals
.get(source_start..source_end) .get(source_start..source_end)
.ok_or("invalid normal range")?, .ok_or("invalid normal range")?,
); );
plan.uvs.extend_from_slice( plan.uvs.extend_from_slice(
streams data.uvs
.uvs
.get(source_start..source_end) .get(source_start..source_end)
.ok_or("invalid uv range")?, .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 = let index_start =
u32::try_from(plan.indices.len()).map_err(|_| "index start exceeds u32")?; u32::try_from(plan.indices.len()).map_err(|_| "index start exceeds u32")?;
let source_index = mesh.geometry.index_start as usize; let source_index = mesh.geometry.index_start as usize;
@@ -83,20 +146,24 @@ impl GpuScenePlan {
.checked_add(mesh.geometry.index_count as usize) .checked_add(mesh.geometry.index_count as usize)
.ok_or("index range overflow")?; .ok_or("index range overflow")?;
plan.indices.extend_from_slice( plan.indices.extend_from_slice(
data.indices() data.indices
.get(source_index..source_index_end) .get(source_index..source_index_end)
.ok_or("invalid index range")?, .ok_or("invalid index range")?,
); );
let instance_start = for instance in occurrences {
u32::try_from(plan.instances.len()).map_err(|_| "instance start exceeds u32")?; let instance_start = u32::try_from(plan.instances.len())
for (_, instance) in occurrences { .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 { plan.instances.push(GpuInstance {
model: instance.model, model: instance.model,
normal_0: [ normal_0: [
instance.normal[0][0], instance.normal[0][0],
instance.normal[0][1], instance.normal[0][1],
instance.normal[0][2], instance.normal[0][2],
0.0, if determinant < 0.0 { -1.0 } else { 1.0 },
], ],
normal_1: [ normal_1: [
instance.normal[1][0], instance.normal[1][0],
@@ -111,20 +178,42 @@ impl GpuScenePlan {
0.0, 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 { plan.draws.push(DrawItem {
pipeline: mesh.pipeline, pipeline: mesh.pipeline,
mesh: handle, material: mesh.material,
indices: index_start mesh: mesh.handle,
..index_start indices: index_start..end,
.checked_add(mesh.geometry.index_count) base_vertex,
.ok_or("draw index range overflow")?, instances: instance_start..instance_start + 1,
base_vertex: i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?, effective_visible,
instances: instance_start
..u32::try_from(plan.instances.len())
.map_err(|_| "instance end exceeds u32")?,
}); });
} }
}
Ok(plan) Ok(plan)
} }
} }
@@ -148,7 +237,7 @@ pub fn required_buffer_capacity(
Ok(grown.min(maximum)) 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]; 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 { wgpu::VertexBufferLayout {
@@ -171,6 +260,11 @@ pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 4] {
step_mode: wgpu::VertexStepMode::Instance, step_mode: wgpu::VertexStepMode::Instance,
attributes: &INSTANCE_ATTRIBUTES, 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)] #[derive(Default)]
pub struct GpuSceneCache { pub struct GpuSceneCache {
revision: Option<u64>, revision: Option<u64>,
query: Option<crate::render_graph::MeshQueryRuntimeKeyV2>,
pub positions: BufferSlot, pub positions: BufferSlot,
pub normals: BufferSlot, pub normals: BufferSlot,
pub uvs: BufferSlot, pub uvs: BufferSlot,
pub tangents: BufferSlot,
pub indices: BufferSlot, pub indices: BufferSlot,
pub instances: 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<DrawItem>, pub draws: Vec<DrawItem>,
compute: Option<CullingCompute>,
}
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 { impl GpuSceneCache {
@@ -196,15 +323,34 @@ impl GpuSceneCache {
&mut self, &mut self,
device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, queue: &wgpu::Queue,
data: &RenderData, data: &SceneFramePlan,
) -> Result<(), String> { ) -> 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(()); 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() { if plan.draws.is_empty() {
self.draws.clear(); self.draws.clear();
self.revision = Some(data.revision()); self.revision = Some(data.revision);
self.query = Some(query);
return Ok(()); return Ok(());
} }
let maximum = device.limits().max_buffer_size; let maximum = device.limits().max_buffer_size;
@@ -218,18 +364,30 @@ impl GpuSceneCache {
bytes(&plan.positions)?, bytes(&plan.positions)?,
bytes(&plan.normals)?, bytes(&plan.normals)?,
bytes(&plan.uvs)?, bytes(&plan.uvs)?,
bytes(&plan.tangents)?,
bytes(&plan.indices)?, bytes(&plan.indices)?,
bytes(&plan.instances)?, bytes(&plan.instances)?,
bytes(&plan.local_aabbs)?,
bytes(&plan.effective_visibility)?,
bytes(&plan.draw_metadata)?,
bytes(&plan.effective_visibility)?,
bytes(&plan.commands)?,
]; ];
let old = [ let old = [
self.positions.capacity, self.positions.capacity,
self.normals.capacity, self.normals.capacity,
self.uvs.capacity, self.uvs.capacity,
self.tangents.capacity,
self.indices.capacity, self.indices.capacity,
self.instances.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]; let mut capacities = [0; 11];
for i in 0..5 { for i in 0..11 {
capacities[i] = capacities[i] =
required_buffer_capacity(old[i], required[i], maximum).map_err(str::to_owned)?; 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::VERTEX,
wgpu::BufferUsages::VERTEX, wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::INDEX,
wgpu::BufferUsages::VERTEX, 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 = [ let labels = [
"scene positions", "scene positions",
"scene normals", "scene normals",
"scene uvs", "scene uvs",
"scene tangents",
"scene indices", "scene indices",
"scene instances", "scene instances",
"scene local aabbs",
"scene effective visibility",
"scene draw metadata",
"scene frustum flags",
"scene indirect commands",
]; ];
let mut replacements: [Option<wgpu::Buffer>; 5] = Default::default(); let mut replacements: [Option<wgpu::Buffer>; 11] = Default::default();
for i in 0..5 { for i in 0..11 {
if capacities[i] != old[i] { if capacities[i] != old[i] {
replacements[i] = Some(device.create_buffer(&wgpu::BufferDescriptor { replacements[i] = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some(labels[i]), label: Some(labels[i]),
@@ -262,8 +432,14 @@ impl GpuSceneCache {
&mut self.positions, &mut self.positions,
&mut self.normals, &mut self.normals,
&mut self.uvs, &mut self.uvs,
&mut self.tangents,
&mut self.indices, &mut self.indices,
&mut self.instances, &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() { for (i, slot) in slots.into_iter().enumerate() {
if let Some(buffer) = replacements[i].take() { if let Some(buffer) = replacements[i].take() {
@@ -275,15 +451,27 @@ impl GpuSceneCache {
bytemuck::cast_slice(&plan.positions), bytemuck::cast_slice(&plan.positions),
bytemuck::cast_slice(&plan.normals), bytemuck::cast_slice(&plan.normals),
bytemuck::cast_slice(&plan.uvs), bytemuck::cast_slice(&plan.uvs),
bytemuck::cast_slice(&plan.tangents),
bytemuck::cast_slice(&plan.indices), bytemuck::cast_slice(&plan.indices),
bytemuck::cast_slice(&plan.instances), 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 = [ let slots = [
&self.positions, &self.positions,
&self.normals, &self.normals,
&self.uvs, &self.uvs,
&self.tangents,
&self.indices, &self.indices,
&self.instances, &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) { for (slot, contents) in slots.into_iter().zip(contents) {
if !contents.is_empty() { if !contents.is_empty() {
@@ -295,15 +483,205 @@ impl GpuSceneCache {
} }
} }
self.draws = plan.draws; self.draws = plan.draws;
self.revision = Some(data.revision()); self.rebuild_compute(device)?;
self.revision = Some(data.revision);
self.query = Some(query);
Ok(()) 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::<Vec<_>>(),
});
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("scene culling params"),
size: size_of::<CullingParams>() 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn instance_is_112_bytes_and_padding_is_zero() { fn instance_is_112_bytes_and_padding_is_zero() {
assert_eq!(size_of::<GpuInstance>(), 112); assert_eq!(size_of::<GpuInstance>(), 112);
@@ -321,6 +699,28 @@ mod tests {
assert!(required_buffer_capacity(0, 33, 32).is_err()); assert!(required_buffer_capacity(0, 33, 32).is_err());
} }
#[test] #[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() { fn capacity_reuses_and_layout_matches_shader_contract() {
assert_eq!(required_buffer_capacity(16, 12, 32), Ok(16)); assert_eq!(required_buffer_capacity(16, 12, 32), Ok(16));
assert_eq!(required_buffer_capacity(0, 1, 32), Ok(1)); assert_eq!(required_buffer_capacity(0, 1, 32), Ok(1));
@@ -330,7 +730,7 @@ mod tests {
.iter() .iter()
.map(|layout| layout.array_stride) .map(|layout| layout.array_stride)
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
[12, 12, 8, 112] [12, 12, 8, 112, 16]
); );
assert_eq!( assert_eq!(
layouts[3] layouts[3]
@@ -342,7 +742,7 @@ mod tests {
); );
} }
#[test] #[test]
fn plan_orders_pipelines_skips_hidden_and_uses_local_indices() { fn plan_is_canonical_and_predicate_independent() {
let mut data = RenderData::new(RenderDataConfig { let mut data = RenderData::new(RenderDataConfig {
initial_vertices: 0, initial_vertices: 0,
initial_indices: 0, initial_indices: 0,
@@ -359,9 +759,11 @@ mod tests {
data.create_mesh(MeshCreateInfo { data.create_mesh(MeshCreateInfo {
positions: &p, positions: &p,
normals: &n, normals: &n,
tangents: &[[1., 0., 0., 1.]; 3],
uvs: &u, uvs: &u,
indices: &i, indices: &i,
pipeline: PipelineKey::new(pipeline), pipeline: PipelineKey::new(pipeline),
material: crate::render_data::MaterialKey::DEFAULT,
flags: RenderFlags::VISIBLE, flags: RenderFlags::VISIBLE,
default_instance_flags: if visible { default_instance_flags: if visible {
RenderFlags::VISIBLE RenderFlags::VISIBLE
@@ -377,18 +779,28 @@ mod tests {
let low = add(2, true); let low = add(2, true);
data.create_instance(low.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE) data.create_instance(low.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE)
.unwrap(); .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!( assert_eq!(
plan.draws plan.draws
.iter() .iter()
.map(|d| d.pipeline.get()) .map(|d| d.pipeline.get())
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
vec![2, 9] vec![0, 2, 2, 9]
); );
assert_eq!(plan.draws[0].base_vertex, 0); 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[1].base_vertex, 3);
assert_eq!(plan.draws[0].instances, 0..2); assert_eq!(plan.draws[1].instances, 1..2);
assert_eq!(plan.indices, [0, 1, 2, 0, 1, 2]); assert_eq!(plan.draws[2].instances, 2..3);
assert_eq!(high.mesh, plan.draws[1].mesh); 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::<GpuLocalAabb>(), 32);
assert_eq!(size_of::<DrawSlotMetadata>(), 16);
assert_eq!(size_of::<DrawIndexedIndirect>(), 20);
assert!(plan
.commands
.iter()
.all(|command| command.first_instance == 0));
} }
} }
+597
View File
@@ -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<TextureReference>, bit: u32) -> u32 {
reference.filter(|r| r.tex_coord == 0).map_or(0, |_| bit)
}
impl From<&Material> for GpuMaterial {
fn from(value: &Material) -> Self {
Self {
base_color_factor: value.base_color_factor,
emissive_factor: [
value.emissive_factor[0],
value.emissive_factor[1],
value.emissive_factor[2],
0.0,
],
surface_factors: [
value.metallic_factor,
value.roughness_factor,
value.normal_scale,
value.occlusion_strength,
],
alpha_optics: [
match value.alpha_mode {
AlphaMode::Opaque => 0.0,
AlphaMode::Mask => 1.0,
AlphaMode::Blend => 2.0,
},
value.alpha_cutoff,
value.ior,
if value.ior == 0.0 {
1.0
} else {
((value.ior - 1.0) / (value.ior + 1.0)).powi(2)
},
],
flags: [
enabled(value.base_color_texture, BASE)
| enabled(value.metallic_roughness_texture, MR)
| enabled(value.normal_texture, NORMAL)
| enabled(value.occlusion_texture, OCCLUSION)
| enabled(value.emissive_texture, EMISSIVE),
u32::from(value.double_sided),
0,
0,
],
uv_sets: [
value.base_color_texture.map_or(0, |x| x.tex_coord),
value.metallic_roughness_texture.map_or(0, |x| x.tex_coord),
value.normal_texture.map_or(0, |x| x.tex_coord),
value.occlusion_texture.map_or(0, |x| x.tex_coord),
],
debug_extras: [value.emissive_texture.map_or(0, |x| x.tex_coord), 0, 0, 0],
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum MaterialError {
#[error("external image sources are unsupported")]
ExternalImage,
#[error("unsupported image MIME type: {0}")]
Mime(String),
#[error("image decode failed: {0}")]
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<MaterialKey, wgpu::BindGroup>,
textures: Vec<wgpu::Texture>,
views: Vec<[wgpu::TextureView; 2]>,
samplers: Vec<wgpu::Sampler>,
}
pub struct MaterialResources {
pub layout: wgpu::BindGroupLayout,
groups: HashMap<MaterialKey, wgpu::BindGroup>,
fallback: wgpu::BindGroup,
fallback_views: Vec<wgpu::TextureView>,
fallback_sampler: wgpu::Sampler,
textures: Vec<wgpu::Texture>,
views: Vec<[wgpu::TextureView; 2]>,
samplers: Vec<wgpu::Sampler>,
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<u8>) {
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<u32, MaterialError> {
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<PreparedMaterials, MaterialError> {
let max_dimension = device.limits().max_texture_dimension_2d;
let mut textures = Vec::with_capacity(scene.images.len());
let mut views = Vec::with_capacity(scene.images.len());
for image in &scene.images {
if !matches!(image.source, ImageSource::BufferView(_)) {
return Err(MaterialError::ExternalImage);
}
let format = match image.mime_type.as_deref() {
Some("image/png") => image::ImageFormat::Png,
Some("image/jpeg") => image::ImageFormat::Jpeg,
other => return Err(MaterialError::Mime(other.unwrap_or("missing").into())),
};
let 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::<GpuMaterial>(), 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::<Vec<_>>(),
[true, false, false, false, true]
);
}
}
+1147 -225
View File
File diff suppressed because it is too large Load Diff
+575
View File
@@ -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<wgpu::VertexAttribute>,
}
#[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<PipelineLayoutKey>,
pub vertex: OwnedProgrammableStage,
pub vertex_layouts: Vec<OwnedVertexBufferLayout>,
pub fragment: Option<OwnedProgrammableStage>,
pub primitive: wgpu::PrimitiveState,
pub depth_stencil: Option<wgpu::DepthStencilState>,
pub multisample: wgpu::MultisampleState,
pub targets: Vec<Option<wgpu::ColorTargetState>>,
pub multiview: Option<NonZeroU32>,
}
#[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<PipelineLayoutKey>,
vertex: StageKey,
vertex_layouts: Vec<OwnedVertexBufferLayout>,
fragment: Option<StageKey>,
primitive: wgpu::PrimitiveState,
depth_stencil: Option<wgpu::DepthStencilState>,
multisample: wgpu::MultisampleState,
targets: Vec<Option<wgpu::ColorTargetState>>,
multiview: Option<NonZeroU32>,
}
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<wgpu::TextureFormat>,
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<wgpu::RenderPipeline>,
specs: Vec<RenderPipelineSpec>,
layout_bindings: HashMap<PipelineLayoutKey, Vec<wgpu::BindGroupLayout>>,
pipeline_layouts: HashMap<PipelineLayoutKey, wgpu::PipelineLayout>,
default_layout: Option<PipelineLayoutKey>,
material_layout: Option<PipelineLayoutKey>,
next_layout: u64,
pipeline_registry: HashMap<String, (PipelineKey, RenderPipelineKey)>,
descriptor_cache: HashMap<RenderPipelineKey, PipelineKey>,
}
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<Vec<_>> = 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<PipelineKey, String> {
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<PipelineKey> {
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<Item = PipelineKey> + '_ {
let mut keys = self
.pipeline_registry
.values()
.map(|entry| entry.0)
.collect::<Vec<_>>();
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<wgpu::TextureFormat>,
depth_compare: wgpu::CompareFunction,
depth_write: bool,
) -> Result<PipelineKey, String> {
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<wgpu::TextureFormat>,
depth_compare: wgpu::CompareFunction,
depth_write: bool,
) -> Result<wgpu::RenderPipeline, String> {
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<Vec<_>> = 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());
}
}
+526
View File
@@ -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<String>,
values: Option<Vec<u64>>,
}
pub(crate) struct ProfileFrame {
pub query_set: wgpu::QuerySet,
slot: usize,
identity: String,
ids: Vec<String>,
invalid: bool,
}
pub(crate) struct ProfileMap {
slot: usize,
epoch: u64,
ids: Vec<String>,
count: u32,
}
impl ProfileFrame {
fn allocate(&mut self, id: &str) -> Option<u32> {
allocate_id(&mut self.ids, &mut self.invalid, id)
}
pub fn render_writes(&mut self, id: &str) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
let first = self.allocate(id)?;
Some(wgpu::RenderPassTimestampWrites {
query_set: &self.query_set,
beginning_of_pass_write_index: Some(first),
end_of_pass_write_index: Some(first + 1),
})
}
pub fn compute_writes(&mut self, id: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
let first = self.allocate(id)?;
Some(wgpu::ComputePassTimestampWrites {
query_set: &self.query_set,
beginning_of_pass_write_index: Some(first),
end_of_pass_write_index: Some(first + 1),
})
}
}
pub(crate) struct Profiler {
enabled: bool,
available: bool,
slots: Vec<Slot>,
completions: Arc<Mutex<Vec<Completion>>>,
epoch: u64,
identity: String,
period_ns: f64,
samples: HashMap<String, VecDeque<(f64, f64)>>,
last_snapshot_ms: f64,
dropped: u64,
}
impl Profiler {
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<ProfileFrame> {
self.drain();
let Some((slot, identity)) = profile_gate(self.enabled, self.available, || {
begin_transition(self.slots.iter_mut().map(|slot| &mut slot.state))
.map(|slot| (slot, identity()))
}) else {
if self.enabled && self.available {
self.dropped += 1;
}
return None;
};
Some(ProfileFrame {
query_set: self.slots[slot].queries.clone(),
slot,
identity,
ids: Vec::new(),
invalid: false,
})
}
pub fn cancel(&mut self, frame: ProfileFrame) {
cancel_state(&mut self.slots[frame.slot].state);
}
pub fn finish(
&mut self,
encoder: &mut wgpu::CommandEncoder,
frame: ProfileFrame,
) -> Option<ProfileMap> {
let count = match finish_transition(
&mut self.slots[frame.slot].state,
frame.invalid,
frame.ids.len(),
) {
FinishAction::Cancel => return None,
FinishAction::Resolve(count) => count,
};
if frame.identity != self.identity {
self.identity = frame.identity;
self.epoch = self.epoch.wrapping_add(1);
self.samples.clear();
}
let slot = frame.slot;
let epoch = self.epoch;
let ids = frame.ids;
encoder.resolve_query_set(
&self.slots[slot].queries,
0..count,
&self.slots[slot].resolve,
0,
);
encoder.copy_buffer_to_buffer(
&self.slots[slot].resolve,
0,
&self.slots[slot].read,
0,
count as u64 * 8,
);
Some(ProfileMap {
slot,
epoch,
ids,
count,
})
}
pub fn map(&mut self, request: ProfileMap) {
let ProfileMap {
slot,
epoch,
ids,
count,
} = request;
self.slots[slot].state = SlotState::Mapping;
let buffer = self.slots[slot].read.clone();
let completions = self.completions.clone();
buffer
.clone()
.slice(..count as u64 * 8)
.map_async(wgpu::MapMode::Read, move |result| {
let values = result.ok().map(|_| {
let bytes = buffer.slice(..count as u64 * 8).get_mapped_range();
let values = bytes
.chunks_exact(8)
.map(|x| u64::from_le_bytes(x.try_into().unwrap()))
.collect();
drop(bytes);
buffer.unmap();
values
});
if let Ok(mut completions) = completions.lock() {
completions.push(Completion {
slot,
epoch,
ids,
values,
});
}
});
}
fn drain(&mut self) {
let completions = if let Ok(mut queue) = self.completions.lock() {
queue.drain(..).collect::<Vec<_>>()
} else {
return;
};
for c in completions {
let Some(v) = completion_transition(
&mut self.slots[c.slot].state,
c.values,
c.epoch,
self.epoch,
&mut self.available,
&mut self.samples,
) else {
continue;
};
for (id, pair) in c.ids.into_iter().zip(v.chunks_exact(2)) {
if let Some(ms) = validate(pair[0], pair[1], self.period_ns) {
let q = self.samples.entry(id).or_default();
q.push_back((js_sys::Date::now(), ms));
}
}
}
}
pub fn snapshot_json(&mut self, now: f64) -> Option<JsValue> {
self.drain();
profile_gate(self.enabled, self.available, || Some(()))?;
(now - self.last_snapshot_ms >= 250.0).then_some(())?;
self.last_snapshot_ms = now;
let cutoff = now - 1000.0;
let mut passes = serde_json::Map::new();
for (id, q) in &mut self.samples {
while q.front().is_some_and(|x| x.0 < cutoff) {
q.pop_front();
}
if !q.is_empty() {
passes.insert(
id.clone(),
serde_json::json!(q.iter().map(|x| x.1).sum::<f64>() / q.len() as f64),
);
}
}
let value = serde_json::json!({"type":"profile-snapshot","requested":self.enabled,"available":self.available,"epoch":self.epoch,"graph":self.identity,"passes":passes,"dropped":self.dropped});
js_sys::JSON::parse(&value.to_string()).ok()
}
}
fn allocate_id(ids: &mut Vec<String>, invalid: &mut bool, id: &str) -> Option<u32> {
if ids.len() >= MAX_PROFILE_PASSES {
*invalid = true;
return None;
}
let first = ids.len() as u32 * 2;
ids.push(id.to_owned());
Some(first)
}
fn profile_gate<T>(enabled: bool, available: bool, f: impl FnOnce() -> Option<T>) -> Option<T> {
(enabled && available).then(f).flatten()
}
fn begin_transition<'a>(states: impl IntoIterator<Item = &'a mut SlotState>) -> Option<usize> {
for (slot, state) in states.into_iter().enumerate() {
if *state == SlotState::Free {
*state = SlotState::Encoding;
return Some(slot);
}
}
None
}
#[derive(Debug, PartialEq, Eq)]
enum FinishAction {
Cancel,
Resolve(u32),
}
fn finish_transition(state: &mut SlotState, invalid: bool, id_count: usize) -> FinishAction {
if *state != SlotState::Encoding || invalid || id_count == 0 {
cancel_state(state);
FinishAction::Cancel
} else {
FinishAction::Resolve(id_count as u32 * 2)
}
}
fn completion_transition(
state: &mut SlotState,
values: Option<Vec<u64>>,
completion_epoch: u64,
current_epoch: u64,
available: &mut bool,
samples: &mut HashMap<String, VecDeque<(f64, f64)>>,
) -> Option<Vec<u64>> {
*state = SlotState::Free;
match values {
None => {
*available = false;
samples.clear();
None
}
Some(_) if !*available || completion_epoch != current_epoch => None,
Some(values) => Some(values),
}
}
fn cancel_state(state: &mut SlotState) {
if *state == SlotState::Encoding {
*state = SlotState::Free;
}
}
fn validate(start: u64, end: u64, period: f64) -> Option<f64> {
if (start == 0 && end == 0) || end < start || !period.is_finite() || period <= 0.0 {
return None;
}
let ms = (end - start) as f64 * period / 1_000_000.0;
if ms.is_finite() && ms >= 0.0 && ms <= 1000.0 {
Some(ms)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_gate() {
assert!(Profiler::requested_features(false, wgpu::Features::TIMESTAMP_QUERY).is_empty());
assert!(Profiler::requested_features(true, wgpu::Features::empty()).is_empty());
assert_eq!(
Profiler::requested_features(true, wgpu::Features::TIMESTAMP_QUERY),
wgpu::Features::TIMESTAMP_QUERY
)
}
#[test]
fn validation() {
assert_eq!(validate(1, 2, 1_000_000.0), Some(1.0));
assert_eq!(validate(2, 2, 1.0), Some(0.0));
assert_eq!(validate(0, 0, 1.0), None);
assert_eq!(validate(2, 1, 1.0), None);
assert_eq!(validate(0, 1, 1_000_000_000.0), Some(1000.0));
assert_eq!(validate(0, 2, 1_000_000_000.0), None);
assert_eq!(validate(1, 2, f64::NAN), None);
assert_eq!(validate(1, 2, f64::INFINITY), None);
assert_eq!(validate(1, 2, 0.0), None);
assert_eq!(validate(1, 2, -1.0), None);
}
#[test]
fn capacity_is_aligned() {
assert_eq!(RESOLVE_SIZE % wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, 0);
assert_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")
);
}
}
}
}
+7 -4
View File
@@ -3,7 +3,7 @@ use wgpu::util::DeviceExt;
use crate::{ use crate::{
camera::Camera, camera::Camera,
render_data::RenderData, render_data::RenderData,
renderer::{self, GpuResources}, renderer::{self, PipelineLibrary},
}; };
pub struct UniformResource { pub struct UniformResource {
@@ -76,13 +76,14 @@ impl FrameMetadata {
pub trait Scene: Sized { pub trait Scene: Sized {
fn setup( fn setup(
context: &renderer::RendererContext, context: &renderer::RendererContext,
resources: &mut GpuResources, resources: &mut PipelineLibrary,
data: &mut RenderData, data: &mut RenderData,
) -> Self; ) -> Self;
fn bind_groups(&self) -> &[wgpu::BindGroup]; fn bind_groups(&self) -> &[wgpu::BindGroup];
fn handle_mouse_click(&mut self, x: f32, y: f32); fn handle_mouse_click(&mut self, x: f32, y: f32);
fn handle_zoom(&mut self, delta_y: f32); fn handle_zoom(&mut self, delta_y: f32);
fn handle_orbit(&mut self, dx: f32, dy: 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_depth_range(&mut self, near: f32, far: f32);
fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3); fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3);
fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> { 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> { fn camera_mut(&mut self) -> Option<&mut Camera> {
None None
} }
fn frustum_planes(&mut self) -> Option<Result<[[f32; 4]; 6], crate::camera::FrustumError>> {
self.camera_mut().map(|camera| camera.frustum_planes())
}
fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> { fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> {
None None
} }
@@ -103,7 +107,7 @@ pub trait Scene: Sized {
} }
self.write_uniforms(queue); self.write_uniforms(queue);
} }
fn update(&mut self, context: &renderer::RendererContext) { fn update_cpu(&mut self) {
let position = match self.camera_mut() { let position = match self.camera_mut() {
Some(c) => c.position(), Some(c) => c.position(),
None => return, None => return,
@@ -112,7 +116,6 @@ pub trait Scene: Sized {
f.time = js_sys::Date::now() as f32 * 0.001; f.time = js_sys::Date::now() as f32 * 0.001;
f.set_camera_position(position) f.set_camera_position(position)
} }
self.write_uniforms(&context.queue);
} }
fn write_uniforms(&mut self, queue: &wgpu::Queue) { fn write_uniforms(&mut self, queue: &wgpu::Queue) {
let frame = self.frame_metadata_mut().copied(); let frame = self.frame_metadata_mut().copied();
+322
View File
@@ -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<usize>,
}
#[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<u32>,
pub meshes: Vec<SceneFrameMesh>,
pub occurrences: Vec<SceneFrameOccurrence>,
/// Occurrence indices grouped by mesh without changing global occurrence order.
pub mesh_occurrence_indices: Vec<usize>,
}
#[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<Self, SceneFrameError> {
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<Box<SceneFramePlan>>,
}
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));
}
}
+191 -20
View File
@@ -1,7 +1,7 @@
//! Triple-buffered, immutable packed scene snapshot shared with JavaScript. //! Triple-buffered, immutable packed scene snapshot shared with JavaScript.
use std::sync::atomic::{AtomicU32, Ordering}; 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 MAGIC: u32 = u32::from_le_bytes(*b"YSNP");
pub const BLOB_MAGIC: u32 = u32::from_le_bytes(*b"RDS1"); 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. /// Packs and publishes if `data` changed. Returns the newly published data epoch.
pub fn publish(&mut self, data: &RenderData) -> Result<Option<u32>, u32> { pub fn publish(&mut self, data: &SceneFramePlan) -> Result<Option<u32>, u32> {
if self.control.header[6].load(Ordering::Acquire) == FAILED { if self.control.header[6].load(Ordering::Acquire) == FAILED {
return Err(self.control.header[14].load(Ordering::Relaxed)); 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); return Ok(None);
} }
let slot = match self.claim_slot() { let slot = match self.claim_slot() {
@@ -116,7 +116,7 @@ impl SharedSnapshot {
result result
} }
fn publish_claimed(&mut self, slot: usize, data: &RenderData) -> Result<Option<u32>, u32> { fn publish_claimed(&mut self, slot: usize, data: &SceneFramePlan) -> Result<Option<u32>, u32> {
let epoch = self.next_epoch; let epoch = self.next_epoch;
let next_epoch = epoch.checked_add(1).ok_or(ERROR_OVERFLOW)?; let next_epoch = epoch.checked_add(1).ok_or(ERROR_OVERFLOW)?;
let bytes = pack(data, epoch)?; let bytes = pack(data, epoch)?;
@@ -136,7 +136,7 @@ impl SharedSnapshot {
let ptr = self.blocks[slot].as_ptr() as usize; let ptr = self.blocks[slot].as_ptr() as usize;
let ptr32 = u32::try_from(ptr).map_err(|_| ERROR_OVERFLOW)?; let ptr32 = u32::try_from(ptr).map_err(|_| ERROR_OVERFLOW)?;
let length = u32::try_from(bytes.len()).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 d = &self.control.slots[slot].0;
let values = [ let values = [
epoch, epoch,
@@ -145,8 +145,8 @@ impl SharedSnapshot {
length, length,
revision as u32, revision as u32,
(revision >> 32) as u32, (revision >> 32) as u32,
data.mesh_count(), data.meshes.len() as u32,
data.instance_count(), data.occurrences.len() as u32,
SCHEMA, SCHEMA,
SNAPSHOT_HEADER_BYTES as u32, SNAPSHOT_HEADER_BYTES as u32,
0, 0,
@@ -244,9 +244,9 @@ fn wasm_pages(minimum_end: usize) -> Result<u32, u32> {
.map_err(|_| ERROR_OVERFLOW) .map_err(|_| ERROR_OVERFLOW)
} }
fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> { fn pack(data: &SceneFramePlan, epoch: u32) -> Result<Vec<u8>, u32> {
let meshes: Vec<_> = data.meshes().collect(); let meshes = &data.meshes;
let instances: Vec<_> = data.instances().collect(); let instances = &data.occurrences;
let strides = [4usize, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4]; 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 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]; 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<Vec<u8>, u32> {
let put32 = |out: &mut [u8], at: usize, value: u32| { let put32 = |out: &mut [u8], at: usize, value: u32| {
out[at..at + 4].copy_from_slice(&value.to_le_bytes()) out[at..at + 4].copy_from_slice(&value.to_le_bytes())
}; };
let revision = data.revision(); let revision = data.revision;
for (i, value) in [ for (i, value) in [
BLOB_MAGIC, BLOB_MAGIC,
SCHEMA, SCHEMA,
@@ -310,8 +310,12 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
put32(&mut out, at + j * 4, value); put32(&mut out, at + j * 4, value);
} }
} }
for (dense, (handle, mesh)) in meshes.iter().enumerate() { for (dense, mesh) in meshes.iter().enumerate() {
for (i, value) in [handle.slot(), handle.generation(), mesh.flags.bits()] for (i, value) in [
mesh.handle.slot(),
mesh.handle.generation(),
mesh.flags.bits(),
]
.into_iter() .into_iter()
.enumerate() .enumerate()
{ {
@@ -330,12 +334,14 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
); );
} }
} }
for (dense, (handle, instance)) in instances.iter().enumerate() { for (dense, instance) in instances.iter().enumerate() {
let mesh = data.mesh(instance.mesh).ok_or(ERROR_INVARIANT)?; let mesh = data
let world = affine_world_aabb(mesh.aabb, instance.model).map_err(|_| ERROR_INVARIANT)?; .meshes
.get(instance.mesh_index)
.ok_or(ERROR_INVARIANT)?;
for (i, value) in [ for (i, value) in [
handle.slot(), instance.handle.slot(),
handle.generation(), instance.handle.generation(),
instance.mesh.slot(), instance.mesh.slot(),
instance.mesh.generation(), instance.mesh.generation(),
instance.flags.bits(), instance.flags.bits(),
@@ -356,12 +362,12 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
put32( put32(
&mut out, &mut out,
offsets[11] + dense * 12 + i * 4, offsets[11] + dense * 12 + i * 4,
world.min[i].to_bits(), instance.world_aabb.min[i].to_bits(),
); );
put32( put32(
&mut out, &mut out,
offsets[12] + dense * 12 + i * 4, offsets[12] + dense * 12 + i * 4,
world.max[i].to_bits(), instance.world_aabb.max[i].to_bits(),
); );
} }
put32( put32(
@@ -387,6 +393,9 @@ const _: [(); 256] = [(); std::mem::size_of::<SnapshotControl>()];
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::render_data::{
MeshCreateInfo, PipelineKey, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM,
};
#[test] #[test]
fn exact_control_layout_and_initial_values() { fn exact_control_layout_and_initial_values() {
@@ -417,4 +426,166 @@ mod tests {
WRITING 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::<Vec<_>>();
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));
}
} }
+37 -2
View File
@@ -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);}} 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<rings;y++)for(let x=0;x<segments;x++){const a=y*(segments+1)+x,b=a+segments+1;indices.push(a,a+1,b,a+1,b+1,b);}return {positions,normals,texcoords,indices}; for(let y=0;y<rings;y++)for(let x=0;x<segments;x++){const a=y*(segments+1)+x,b=a+segments+1;indices.push(a,a+1,b,a+1,b+1,b);}return {positions,normals,texcoords,indices};
} }
const galleryPngBase64=Object.freeze({
base:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAIUlEQVR42mP4ryH3X+NOgIZGwP//DP/vyGn8BwI5Obn/AKsPDa3HqsdFAAAAAElFTkSuQmCC",
mr:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAJUlEQVR42gEaAOX/AP8gAP//YED//7Sg/wD/8P///0Dc///cIP/dYBIQ76JUtAAAAABJRU5ErkJggg==",
normal:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAH0lEQVR42mNoaPj//0TDu/8WQMzQcOLd/wYLIAYKAgDieBGxoS0BjwAAAABJRU5ErkJggg==",
});
const decodeBase64=value=>{const binary=atob(value),bytes=new Uint8Array(binary.length);for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);return bytes;};
/** Build the deterministic Phase 6 PBR shader validation gallery. */
export function createMaterialGalleryGlb(){
// A modest shared sphere keeps the embedded GLB compact while making roughness
// and normal-map responses much easier to compare than the former cubes.
const geometry=createUvSphereGeometry(16,8);
const streams=[new Float32Array(geometry.positions),new Float32Array(geometry.normals),new Float32Array(geometry.texcoords),new Uint32Array(geometry.indices)];
const images=Object.values(galleryPngBase64).map(decodeBase64),chunks=[],bufferViews=[];let byteLength=0;
for(const stream of [...streams,...images]){byteLength=align4(byteLength);const bytes=stream instanceof Uint8Array?stream:new Uint8Array(stream.buffer);chunks.push({offset:byteLength,bytes});bufferViews.push({buffer:0,byteOffset:byteLength,byteLength:bytes.length});byteLength+=bytes.length;}
byteLength=align4(byteLength);const bounds=finiteMinMax(streams[0],3),vertexCount=geometry.positions.length/3;
const materials=[
...[0.08,0.3,0.6,1].map(roughnessFactor=>({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 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 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)}); 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}={}){ 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}`); 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"}`);} 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; 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;
+199 -4
View File
@@ -1,5 +1,200 @@
<!doctype html> <!doctype html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Yawn Render Graph Demo</title> <html lang="en">
<style> <head>
*{box-sizing:border-box}html,body{height:100%;margin:0;overflow:hidden;background:#0b0e14;color:#eef2f8;font:14px Inter,system-ui,sans-serif}main{display:grid;grid-template-columns:minmax(0,3fr) minmax(380px,2fr);height:100%}.viewport,.editor{min-width:0;min-height:0;position:relative}canvas{display:block;width:100%;height:100%}#canvas0{background:#090d16}.toolbar{position:absolute;z-index:2;inset:18px 18px auto;display:flex;align-items:end;gap:14px;padding:13px 16px;border:1px solid #ffffff18;border-radius:10px;background:#111722e8;box-shadow:0 12px 30px #0008}.brand{margin-right:auto}.brand strong{display:block;font-size:17px;letter-spacing:.08em}.brand small{color:#8d9bb1}.field{display:grid;gap:5px;color:#9da9ba;font-size:11px;text-transform:uppercase;letter-spacing:.08em}select,button{font:inherit;color:#eef;background:#202938;border:1px solid #3a4659;border-radius:6px;padding:7px 10px}button{background:#ba5c2e;border-color:#d77949;font-weight:650;cursor:pointer}button:disabled,select:disabled{opacity:.48;cursor:wait}#demo-status{position:absolute;z-index:2;left:18px;bottom:18px;padding:9px 12px;border-radius:7px;background:#0b1019dc;color:#bac6d8;box-shadow:0 5px 20px #0008}.editor{display:grid;grid-template-rows:58px minmax(0,1fr);border-left:1px solid #2d3542;background:#151820}.editor-bar{display:flex;align-items:center;gap:12px;padding:0 14px;border-bottom:1px solid #303745}.editor-bar strong{font-size:15px}.editor-bar span{color:#9ba7b9}@media(max-width:820px){main{grid-template-columns:1fr;grid-template-rows:52% 48%}.editor{border-left:0;border-top:1px solid #2d3542}.toolbar{flex-wrap:wrap}.brand{width:100%}} <meta charset="UTF-8" />
</style></head><body><main><section class="viewport" aria-label="Rendered scene"><div class="toolbar"><div class="brand"><strong>YAWN</strong><small>Render Graph Studio</small></div><label class="field" for="loadout-select">Scene loadout<select id="loadout-select"><option value="cubes">Cubes</option><option value="spheres">UV spheres</option><option value="manor">The Manor</option><option value="sponza">Sponza</option></select></label><label class="field" for="graph-select">Graph preset<select id="graph-select"><option value="authored">Authored</option><option value="midnight">Midnight</option><option value="ember">Ember</option></select></label></div><canvas id="canvas0"></canvas><output id="demo-status" aria-live="polite">Starting Phase 8…</output></section><section class="editor" aria-label="Render graph editor"><div class="editor-bar"><strong>Authored Graph</strong><button id="apply-graph" disabled>Apply</button><span id="graph-status">Loading editor…</span></div><canvas id="graph-editor"></canvas></section></main><script type="module" src="./index.js"></script></body></html> <meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Yawn Render Graph Demo</title>
<style>
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
background: #0b0e14;
color: #eef2f8;
font:
14px Inter,
system-ui,
sans-serif;
}
main {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(380px, 2fr);
height: 100%;
}
.viewport,
.editor {
min-width: 0;
min-height: 0;
position: relative;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
#canvas0 {
background: #090d16;
}
.toolbar {
position: absolute;
z-index: 2;
inset: 18px 18px auto;
display: flex;
align-items: end;
gap: 14px;
padding: 13px 16px;
border: 1px solid #ffffff18;
border-radius: 10px;
background: #111722e8;
box-shadow: 0 12px 30px #0008;
}
.brand {
margin-right: auto;
}
.brand strong {
display: block;
font-size: 17px;
letter-spacing: 0.08em;
}
.brand small {
color: #8d9bb1;
}
.field {
display: grid;
gap: 5px;
color: #9da9ba;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
#profile-menu { min-width: 210px; color: #9da9ba; }
#profile-menu summary { cursor: pointer; color: #eef2f8; }
#profile-menu table { width: 100%; font-size: 11px; }
#profile-menu th { text-align: left; font-weight: 500; }
#profile-menu td { text-align: right; font-variant-numeric: tabular-nums; }
select,
button {
font: inherit;
color: #eef;
background: #202938;
border: 1px solid #3a4659;
border-radius: 6px;
padding: 7px 10px;
}
button {
background: #ba5c2e;
border-color: #d77949;
font-weight: 650;
cursor: pointer;
}
button:disabled,
select:disabled {
opacity: 0.48;
cursor: wait;
}
#demo-status {
position: absolute;
z-index: 2;
left: 18px;
bottom: 18px;
padding: 9px 12px;
border-radius: 7px;
background: #0b1019dc;
color: #bac6d8;
box-shadow: 0 5px 20px #0008;
}
.editor {
display: grid;
grid-template-rows: 58px minmax(0, 1fr);
border-left: 1px solid #2d3542;
background: #151820;
}
.editor-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 0 14px;
border-bottom: 1px solid #303745;
}
.editor-bar strong {
font-size: 15px;
}
.editor-bar span {
color: #9ba7b9;
}
.fxnode-add-menu {
position: fixed; z-index: 20; width: min(280px, calc(100vw - 16px)); max-height: min(440px, calc(100vh - 16px));
padding: 8px; overflow: hidden; border: 1px solid #495568; border-radius: 8px; background: #171d27; box-shadow: 0 14px 40px #000b;
}
.fxnode-add-menu[hidden] { display: none; }
.fxnode-add-menu input { width: 100%; padding: 8px; color: #eef2f8; background: #0f141c; border: 1px solid #3a4659; border-radius: 5px; }
.fxnode-add-menu__list { max-height: 360px; margin-top: 6px; overflow: auto; }
.fxnode-add-menu__group { padding: 8px 7px 3px; color: #8491a5; font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.fxnode-add-menu .fxnode-add-menu__option { display: block; width: 100%; padding: 7px 9px; border: 0; text-align: left; text-transform: capitalize; background: transparent; }
.fxnode-add-menu__option[aria-selected="true"] { background: #394a64; outline: 1px solid #6883aa; }
@media (max-width: 820px) {
main {
grid-template-columns: 1fr;
grid-template-rows: 52% 48%;
}
.editor {
border-left: 0;
border-top: 1px solid #2d3542;
}
.toolbar {
flex-wrap: wrap;
}
.brand {
width: 100%;
}
}
</style>
</head>
<body>
<main>
<section class="viewport" aria-label="Rendered scene">
<div class="toolbar">
<div class="brand">
<strong>YAWN</strong><small>Render Graph Studio</small>
</div>
<label class="field" for="loadout-select"
>Scene loadout<select id="loadout-select">
<option value="cubes">Cubes</option>
<option value="spheres">UV spheres</option>
<option value="materials">Phase 6 PBR gallery</option>
<option value="manor">The Manor</option>
<option value="sponza">Sponza</option>
</select></label
><label class="field" for="graph-select"
>Graph preset<select id="graph-select">
<option value="authored">Authored</option>
<option value="midnight">Midnight</option>
<option value="ember">Ember</option>
<option value="hdr">HDR Fullscreen</option>
<option value="culling">GPU frustum culling</option>
<option value="tone">Tone map</option>
<option value="edges">Edges</option>
<option value="bloom">Bloom</option>
<option value="combined">Combined</option>
</select></label
>
</div>
<canvas id="canvas0"></canvas
><output id="demo-status" aria-live="polite">Starting Phase 8…</output>
</section>
<section class="editor" aria-label="Render graph editor">
<div class="editor-bar">
<strong>Authored Graph</strong
><button id="apply-graph" disabled>Apply</button
><span id="graph-status">Loading editor…</span>
</div>
<canvas id="graph-editor"></canvas>
</section>
</main>
<script type="module" src="./index.js"></script>
</body>
</html>
+335 -43
View File
@@ -6,55 +6,347 @@ import { AuthoringController } from "./render-graph/authoring-controller.js";
import { createRenderGraphEditor } from "./render-graph/fxnode-editor.js"; import { createRenderGraphEditor } from "./render-graph/fxnode-editor.js";
import { renderGraphPresets } from "./render-graph/presets.js"; import { renderGraphPresets } from "./render-graph/presets.js";
let renderer,editor,controller,assetAbort,busy=false,cleaned=false; let renderer,
let unsubscribeController=()=>{},unsubscribeSnapshots=()=>{}; editor,
const listeners=[]; controller,
const on=(target,type,fn)=>{target.addEventListener(type,fn);listeners.push(()=>target.removeEventListener(type,fn));}; assetAbort,
const status=message=>{const node=document.querySelector("#demo-status");if(node)node.textContent=message;}; busy = false,
const sameId=(a,b)=>Array.isArray(a)&&Array.isArray(b)&&a[0]===b[0]&&a[1]===b[1]; cleaned = false;
const state={loadout:"cubes",graph:"authored",compiled:{},telemetry:null}; 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 =
'<summary>GPU profile</summary><div id="profile-status">Waiting for GPU timestamps…</div><table><tbody id="profile-passes"></tbody></table>';
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){ function publish(telemetry) {
state.telemetry=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}); 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){ function waitTelemetry(predicate, timeout = 30000) {
const current=renderer?.telemetry;if(current&&predicate(current))return Promise.resolve(current); const current = renderer?.telemetry;
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?.();}); 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=()=>{}; let onAbort = () => {};
async function transaction(label,operation,rollback){ 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); if (busy || cleaned) return false;
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;} busy = 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;} document
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;}} .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){ async function selectLoadout(next, select) {
const previous=state.loadout,targetRevision=(renderer.telemetry?.revision??0)+1;assetAbort=new AbortController(); const previous = state.loadout,
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);}); targetRevision = (renderer.telemetry?.revision ?? 0) + 1;
assetAbort=undefined;if(!ok)select.value=previous; 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){ async function selectGraph(next, select) {
const previous=state.graph,compiled=state.compiled[next]; const previous = state.graph,
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;}); compiled = state.compiled[next];
if(!ok)select.value=previous; 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();}} async function cleanup() {
const pagehide=()=>{void 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(){ async function start() {
addEventListener("pagehide",pagehide,{once:true});delete document.documentElement.dataset.phase8Ready;await wbg_init();if(cleaned)return; addEventListener("pagehide", pagehide, { once: true });
renderer=new RendererClient(main());await renderer.ready;const nextEditor=await createRenderGraphEditor(document.querySelector("#graph-editor"));if(cleaned){await nextEditor.destroy();return}editor=nextEditor; delete document.documentElement.dataset.phase8Ready;
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"); await wbg_init();
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()); if (cleaned) return;
const authored=await controller.apply(adaptFxNodeSnapshot);state.compiled.authored={...authored,graphId:"demo_forward"}; renderer = new RendererClient(main(profileEnabled));
for(const [name,preset] of Object.entries(renderGraphPresets)){const compiled=await renderer.compileGraph(preset);state.compiled[name]={...compiled,graphId:preset.graphId,revision:preset.revision};} installProfileMenu();
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`);}}); await renderer.ready;
on(loadoutSelect,"change",()=>void selectLoadout(loadoutSelect.value,loadoutSelect));on(graphSelect,"change",()=>void selectGraph(graphSelect.value,graphSelect)); const nextEditor = await createRenderGraphEditor(
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();});}); 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(); 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);}); const initialized = await transaction(
if(!initialized)throw new Error("Initial demo transaction failed");document.documentElement.dataset.phase8Ready="true"; "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();}; const startupError = (error) => {
if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>start().catch(startupError),{once:true});else start().catch(startupError); 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);
+414 -69
View File
@@ -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 { export class AuthoringGraphError extends Error {
constructor(code, details = {}) { super(code); this.name="AuthoringGraphError"; this.code=code; this.details=Object.freeze(details); } constructor(code, details = {}) {
} super(code);
const fail=(code,details)=>{throw new AuthoringGraphError(code,details)}; this.name = "AuthoringGraphError";
const object=v=>v !== null && typeof v === "object" && !Array.isArray(v); this.code = code;
const validId=v=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9_.-]*$/.test(v) && new TextEncoder().encode(v).length<=64; this.details = Object.freeze({ ...details });
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);
}
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){ const fail = (code, details) => {
if(!Number.isInteger(revision)||revision<1||revision>0xffffffff) fail("AUTHORING_REVISION",{revision}); throw new AuthoringGraphError(code, details);
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:[ const object = (value) =>
{id:"surface",version:0,residency:{kind:"external",source:"surface_color"},texture:{dimension:"d2",format:"surface",extent,mipLevelCount:1,sampleCount:1}}, value !== null && typeof value === "object" && !Array.isArray(value);
{id:"depth",version:0,residency:{kind:"transient"},texture:{dimension:"d2",format:"depth32_float",extent,mipLevelCount:1,sampleCount:1}}, const identifier = (value) =>
],passes:[{id:"forward",state:p.passState,executor:{key:"scene_forward",version:1},parameters:{},reads:[],writes:[ typeof value === "string" &&
{binding:"color",resource:{id:"surface",version:0},access:{kind:"color_attachment",location:0,load:{op:"clear",value:p.clearColor},store:"store"}}, /^[A-Za-z][A-Za-z0-9_.-]*$/.test(value) &&
{binding:"depth",resource:{id:"depth",version:0},access:{kind:"depth_attachment",load:{op:"clear",value:p.clearDepth},store:"store"}}, new TextEncoder().encode(value).length <= 64;
]}],outputs:[{name:"present",resource:{id:"surface",version:0}}]}; 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;
+130
View File
@@ -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();
},
};
}
+114 -11
View File
@@ -1,14 +1,117 @@
import { mapAuthoringDiagnostic } from "./adapter.js";
export class AuthoringController { export class AuthoringController {
#renderer; #getState; #revision=0; #nextRevision=1; #dirty=true; #applying=null; #listeners=new Set(); #renderer; #adapt; #revision = 0; #nextRevision = 1; #generation = 0;
constructor({renderer,getState}) { this.#renderer=renderer; this.#getState=getState; } #current; #lastGood; #applyPromise; #listeners = new Set();
get revision(){return this.#revision} get dirty(){return this.#dirty} get applying(){return !!this.#applying} #owned = new Map(); #drops = new Map(); #activeCompiles = new Set();
subscribe(fn){this.#listeners.add(fn);return()=>this.#listeners.delete(fn)} #disposed = false; #applyingRecord; #scheduler; #debounceMs; #timer; #destroyPromise;
markDirty(){this.#dirty=true;this.#emit()}
#emit(){for(const fn of this.#listeners)fn({revision:this.#revision,dirty:this.#dirty,applying:!!this.#applying})} constructor({ renderer, adapt, scheduler = globalThis, debounceMs = 150 }) {
apply(adapt){ this.#renderer = renderer; this.#adapt = adapt;
if(this.#applying)return this.#applying; this.#scheduler = scheduler; this.#debounceMs = debounceMs;
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()}})(); get revision() { return this.#revision; }
return this.#applying; 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;
} }
} }
+13 -10
View File
@@ -10,10 +10,12 @@ const sizeCanvas = (canvas, value) => {
}; };
const mods = e => ({ alt:e.altKey, control:e.ctrlKey, meta:e.metaKey, shift:e.shiftKey }); 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 ownerDocument=canvas.ownerDocument, ownerWindow=ownerDocument.defaultView ?? window;
const originalTabIndex=canvas.getAttribute("tabindex"), originalTouchAction=canvas.style.touchAction; 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 captured=new Set();
const initialViewport=viewport(canvas,ownerWindow); appliedViewport=initialViewport; sizeCanvas(canvas,initialViewport); const initialViewport=viewport(canvas,ownerWindow); appliedViewport=initialViewport; sizeCanvas(canvas,initialViewport);
canvas.tabIndex=0; canvas.style.touchAction="none"; canvas.tabIndex=0; canvas.style.touchAction="none";
@@ -22,14 +24,14 @@ export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeTy
if(!view)return; if(!view)return;
if(e instanceof ownerWindow.PointerEvent){ if(e instanceof ownerWindow.PointerEvent){
const phase=e.type==="pointerdown"?"down":e.type==="pointermove"?"move":e.type==="pointerup"?"up":"cancel"; 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{} 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)}); 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){ }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; 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)}); 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"}); }else view.feedInput({kind:"focus",phase:e.type==="focus"?"focus":"blur"});
}; };
const names=["pointerdown","pointermove","pointerup","pointercancel","wheel","keydown","keyup","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; resizing=true;
Promise.resolve(view.setViewport(next)).then(()=>{if(dead||currentGeneration!==generation)return;appliedViewport=next;sizeCanvas(canvas,next)}).catch(error=>{if(!dead&&currentGeneration===generation)onError(error)}).finally(()=>{if(dead||currentGeneration!==generation)return;resizing=false;pump()}); Promise.resolve(view.setViewport(next)).then(()=>{if(dead||currentGeneration!==generation)return;appliedViewport=next;sizeCanvas(canvas,next)}).catch(error=>{if(!dead&&currentGeneration===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 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 lost=e=>captured.delete(e.pointerId);
const observer=new ownerWindow.ResizeObserver(resize); const observer=new ownerWindow.ResizeObserver(resize);
return {initialViewport,attach(_root,next){ return {initialViewport,attach(_root,next){
view=next; root=_root;view=next;
for(const n of names)canvas.addEventListener(n,input,{passive:n!=="wheel"}); for(const n of names)canvas.addEventListener(n,input,{passive:n!=="wheel"});
canvas.addEventListener("contextmenu",prevent);canvas.addEventListener("lostpointercapture",lost); canvas.addEventListener("contextmenu",prevent);canvas.addEventListener("lostpointercapture",lost);
ownerDocument.addEventListener("pointerdown",outside,true);ownerWindow.addEventListener("resize",resize); 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(); observer.observe(canvas);resize();
},destroy(){ },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 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(); 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()} function prevent(e){e.preventDefault()}
+346 -28
View File
@@ -1,29 +1,347 @@
export const GRAPH_ID = "demo_forward"; export const GRAPH_ID = "authored_gpu_culling";
export const CATALOG_VERSION = 1; export const CATALOG_VERSION = 2;
const exact = (type) => ({ kind: "exact", types: [type] });
export const socketTypes = { const oneOf = (...types) => ({ kind: "one_of", types });
surface: { title: "Surface", color: "#62b0ff", acceptsFrom: ["surface"] }, const i = (type, required = true, authoringType) => ({
depth: { title: "Depth", color: "#b58cff", acceptsFrom: ["depth"] }, accepted: typeof type === "string" ? exact(type) : type,
}; required,
export const theme = { ...(authoringType ? { authoringType } : {}),
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:[]},
}); });
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,
},
]),
);
+149 -19
View File
@@ -1,25 +1,155 @@
import { createFxNode } from "@fxnode/index.ts"; 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 { prepareBrowserHost } from "./browser-host.js";
import { createAddNodeMenu } from "./add-node-menu.js";
import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.js";
const spec=[ const spec = [
["surface","surface_color",{x:40,y:100}], ["surface", "surface_target", { x: 40, y: 40 }],
["depth","depth32",{x:40,y:330}], ["hdr", "texture_spec", { x: 40, y: 170 }],
["forward","scene_forward",{x:360,y:190}], ["depth", "texture_spec", { x: 40, y: 300 }],
["present","present",{x:700,y:220}], ["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){ async function seed(root) {
await root.setState({graphId:GRAPH_ID,catalogVersion:CATALOG_VERSION,nodes:[],links:[],metadata:{}}); await root.setState({
for(const [nodeId,nodeType,position] of spec) await root.dispatch({type:"node.add",nodeId,nodeType,position}); graphId: GRAPH_ID,
for(const link of [ catalogVersion: CATALOG_VERSION,
{id:"surface_link",fromNodeId:"surface",fromSocketId:"surface:surface",toNodeId:"forward",toSocketId:"forward:color",muted:false,extensions:{}}, nodes: [],
{id:"depth_link",fromNodeId:"depth",fromSocketId:"depth:depth",toNodeId:"forward",toSocketId:"forward:depth",muted:false,extensions:{}}, links: [],
{id:"present_link",fromNodeId:"forward",fromSocketId:"forward:result",toNodeId:"present",toSocketId:"present:surface",muted:false,extensions:{}}, metadata: {},
]) await root.dispatch({type:"link.add",link}); });
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){ 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 allocateId = createNodeIdAllocator();
const host=prepareBrowserHost(canvas,{chooseNodeType});let root,view,destroying; let root, view, menu, destroying, dead = false;
const destroy=()=>destroying??=(async()=>{host.destroy();try{await view?.detach()}finally{root?.destroy();view=undefined;root=undefined}})(); const requestAddNode = Object.assign(async (request, point, isCurrent = () => true) => {
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} 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,
};
} }
+42
View File
@@ -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;
}
+255 -4
View File
@@ -1,5 +1,256 @@
import { semanticProjectionToV1 } from "./adapter.js"; import { semanticProjectionToV1 } from "./adapter.js";
const make=(graphId,clearColor)=>Object.freeze(semanticProjectionToV1({graphId,clearColor,clearDepth:1,passState:"enabled"},1)); const make = (graphId, clearColor) =>
export const midnight=make("preset_midnight",[0.015,0.06,0.18,1]); Object.freeze(
export const ember=make("preset_ember",[0.18,0.035,0.012,1]); semanticProjectionToV1(
export const renderGraphPresets=Object.freeze({midnight,ember}); { 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,
});
+5 -1
View File
@@ -12,7 +12,7 @@ export class RendererError extends Error {
export class RendererClient { export class RendererClient {
#bridge; #worker; #header; #slots; #buffer; #next = 1; #payload = 1; #bridge; #worker; #header; #slots; #buffer; #next = 1; #payload = 1;
#pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false; #pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false;
#telemetry; #stopped = false; #telemetry; #profile; #stopped = false;
#graphQueue = []; #graphBusy = false; #graphQueue = []; #graphBusy = false;
#bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map(); #bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map();
@@ -40,6 +40,7 @@ export class RendererClient {
get ready() { return this.#ready; } get ready() { return this.#ready; }
get telemetry() { return this.#telemetry; } get telemetry() { return this.#telemetry; }
get profile() { return this.#profile; }
#refreshViews() { #refreshViews() {
const buffer = this.#bridge.memory.buffer; const buffer = this.#bridge.memory.buffer;
@@ -61,6 +62,9 @@ export class RendererClient {
} else if (message?.type === "telemetry") { } else if (message?.type === "telemetry") {
this.#telemetry = message; this.#telemetry = message;
dispatchEvent(new CustomEvent("renderer-frame", { detail: 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") { } else if (message?.type === "fatal") {
console.error("renderer worker fatal", message.code, message.message); console.error("renderer worker fatal", message.code, message.message);
this.#fail(message.code || "WORKER_FATAL"); this.#fail(message.code || "WORKER_FATAL");
+113
View File
@@ -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/,
);
});
+3 -1
View File
@@ -1,10 +1,12 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; 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;} 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<geometry.positions.length/3));}}); 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<geometry.positions.length/3));}});
test("every cube triangle has counter-clockwise outward winding",()=>{const g=createCubeGeometry();for(let i=0;i<g.indices.length;i+=3){const ids=g.indices.slice(i,i+3),p=ids.map(id=>g.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("every cube triangle has counter-clockwise outward winding",()=>{const g=createCubeGeometry();for(let i=0;i<g.indices.length;i+=3){const ids=g.indices.slice(i,i+3),p=ids.map(id=>g.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("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(`<option value="${id}">`));});
test("LFS pointers and HTTP failures are explicit and stable",async()=>{const pointer=new TextEncoder().encode("version https://git-lfs.github.com/spec/v1\noid sha256:abc\n").buffer;assert.equal(isGitLfsPointer(pointer),true);await assert.rejects(loadDemoLoadout("manor",{fetchImpl:async()=>({ok:true,arrayBuffer:async()=>pointer})}),e=>e instanceof LoadoutError&&e.code==="LOADOUT_LFS_POINTER");await assert.rejects(loadDemoLoadout("sponza",{fetchImpl:async()=>({ok:false,status:404})}),e=>e.code==="LOADOUT_HTTP"&&/404/.test(e.message));}); test("LFS pointers and HTTP failures are explicit and stable",async()=>{const pointer=new TextEncoder().encode("version https://git-lfs.github.com/spec/v1\noid sha256:abc\n").buffer;assert.equal(isGitLfsPointer(pointer),true);await assert.rejects(loadDemoLoadout("manor",{fetchImpl:async()=>({ok:true,arrayBuffer:async()=>pointer})}),e=>e instanceof LoadoutError&&e.code==="LOADOUT_LFS_POINTER");await assert.rejects(loadDemoLoadout("sponza",{fetchImpl:async()=>({ok:false,status:404})}),e=>e.code==="LOADOUT_HTTP"&&/404/.test(e.message));});
test("GLB encoder rejects non-finite and out-of-range geometry",()=>{const valid=createCubeGeometry();assert.throws(()=>encodeGeometryGlb({...valid,positions:[...valid.positions.slice(0,-1),NaN]}),/Invalid/);assert.throws(()=>encodeGeometryGlb({...valid,indices:[...valid.indices,0,1,999]}),/Invalid/)}); test("GLB encoder rejects non-finite and out-of-range geometry",()=>{const valid=createCubeGeometry();assert.throws(()=>encodeGeometryGlb({...valid,positions:[...valid.positions.slice(0,-1),NaN]}),/Invalid/);assert.throws(()=>encodeGeometryGlb({...valid,indices:[...valid.indices,0,1,999]}),/Invalid/)});
+27
View File
@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { build } from "vite";
import { fxNodeComposition } from "../static/render-graph/catalog.js";
test("production render graph composition passes fxnode's public validator", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "yawn-fxnode-validator-"));
try {
const entry = path.join(directory, "entry.js");
await writeFile(entry, `export { validateFxNodeComposition } from ${JSON.stringify(pathToFileURL(path.resolve("vendor/fxnode/src/index.ts")).href)};`);
await build({
configFile: false,
logLevel: "silent",
build: { lib: { entry, formats: ["es"], fileName: "validator" }, outDir: directory, emptyOutDir: false },
});
const { validateFxNodeComposition } = await import(`${pathToFileURL(path.join(directory, "validator.js")).href}?${Date.now()}`);
const result = validateFxNodeComposition(fxNodeComposition);
assert.equal(result.ok, true, result.ok ? undefined : JSON.stringify(result.issues, null, 2));
assert.equal(Object.keys(fxNodeComposition.nodes).length, 17);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
+285 -11
View File
@@ -1,15 +1,289 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { adaptFxNodeSnapshot, AuthoringGraphError } from "../static/render-graph/adapter.js"; import {
adaptFxNodeSnapshot,
AuthoringGraphError,
getSourceMap,
mapAuthoringDiagnostic,
} from "../static/render-graph/adapter.js";
import { RendererError } from "../static/renderer-client.js";
import {
semanticCatalog,
nodeDefinitions,
GRAPH_ID,
CATALOG_VERSION,
socketTypes,
} from "../static/render-graph/catalog.js";
import { culling } from "../static/render-graph/presets.js";
import { AuthoringController } from "../static/render-graph/authoring-controller.js"; import { AuthoringController } from "../static/render-graph/authoring-controller.js";
function fixture() {
const sockets={surface_color:[["surface","surface:surface","output","surface"]],depth32:[["depth","depth:depth","output","depth"]],scene_forward:[["color","forward:color","input","surface"],["depth","forward:depth","input","depth"],["result","forward:result","output","surface"]],present:[["surface","present:surface","input","surface"]]}; const nodes = culling.nodes.map((n) => {
function fixture(){const nodes=Object.entries(sockets).map(([typeId,list],i)=>({id:["surface","depth","forward","present"][i],typeId,typeVersion:1,known:true,muted:false,parameters:typeId==="scene_forward"?{clearColor:{kind:"color",value:[.1,.2,.3,1]},clearDepth:{kind:"number",value:.5}}:{},sockets:list.map(([key,id,direction,dataType])=>({key,id,direction,dataType}))}));return {version:4,graphId:"demo_forward",catalogVersion:1,nodes,links:[{id:"l1",fromNodeId:"surface",fromSocketId:"surface:surface",toNodeId:"forward",toSocketId:"forward:color",muted:false},{id:"l2",fromNodeId:"depth",fromSocketId:"depth:depth",toNodeId:"forward",toSocketId:"forward:depth",muted:false},{id:"l3",fromNodeId:"forward",fromSocketId:"forward:result",toNodeId:"present",toSocketId:"present:surface",muted:false}],metadata:{layout:"ignored"}}} const d = semanticCatalog[n.executor.key];
const rejects=(mutate,code)=>{const x=structuredClone(fixture());mutate(x);assert.throws(()=>adaptFxNodeSnapshot(x),e=>e instanceof AuthoringGraphError&&e.code===code)}; const definition = nodeDefinitions[n.executor.key];
test("adapter emits exact deterministic V1 without fxnode state",()=>{const a=adaptFxNodeSnapshot(fixture(),7),b=fixture();b.nodes.reverse();b.links.reverse();assert.deepEqual(adaptFxNodeSnapshot(b,7),a);assert.equal(a.revision,7);assert.deepEqual(a.passes[0].writes[0].access.load.value,[.1,.2,.3,1]);for(const token of ["position","socketId","known","fxnode"])assert.equal(JSON.stringify(a).includes(token),false)}); return {
test("adapter rejects node catalog, identity, sockets, links, topology and parameters",()=>{ id: n.id,
rejects(x=>x.nodes[0].known=false,"AUTHORING_NODE_UNKNOWN");rejects(x=>delete x.nodes[0].muted,"AUTHORING_NODE_MUTED");rejects(x=>x.nodes[0].muted=true,"AUTHORING_NODE_MUTED");rejects(x=>x.nodes[0].typeVersion=2,"AUTHORING_NODE_VERSION");rejects(x=>x.nodes[0].typeId="other","AUTHORING_NODE_TYPE");rejects(x=>x.nodes[0].id="bad id","AUTHORING_ID");rejects(x=>x.nodes[1].id=x.nodes[0].id,"AUTHORING_ID_DUPLICATE");rejects(x=>x.nodes[0].sockets[0].direction="input","AUTHORING_SOCKET");rejects(x=>x.links[0].muted=true,"AUTHORING_TOPOLOGY");rejects(x=>x.links.pop(),"AUTHORING_TOPOLOGY");rejects(x=>x.nodes[2].parameters.clearDepth.value=Infinity,"AUTHORING_PARAMETER"); typeId: n.executor.key,
typeVersion: 1,
known: true,
muted: n.state !== "enabled",
position: { x: 10, y: 20 },
size: { x: 200, y: 120 },
label: n.id,
collapsed: false,
extensions: {},
parameters: Object.fromEntries(
Object.entries(definition.parameters).map(([key, schema]) => [
key,
{
kind: schema.type,
value: structuredClone(n.parameters[key] ?? schema.default.value),
},
]),
),
sockets: [
...Object.entries(d.inputs).map(([key, x]) => ({
key,
id: `${n.id}:${key}`,
direction: "input",
dataType: x.authoringType ?? x.accepted.types[0],
label: key,
accepts: socketTypes[x.authoringType ?? x.accepted.types[0]].acceptsFrom,
visible: true,
maxIncomingLinks: 1,
})),
...Object.entries(d.outputs).map(([key]) => ({
key,
id: `${n.id}:${key}`,
direction: "output",
dataType: definition.sockets[key].type,
label: key,
accepts: [],
visible: true,
maxIncomingLinks: 0,
})),
],
};
});
const links = [];
for (const n of culling.nodes)
for (const [socket, from] of Object.entries(n.inputs))
links.push({
id: `l_${from.node}_${from.socket}_${n.id}_${socket}`,
fromNodeId: from.node,
fromSocketId: `${from.node}:${from.socket}`,
toNodeId: n.id,
toSocketId: `${n.id}:${socket}`,
muted: false,
extensions: {},
});
return {
graphId: GRAPH_ID,
catalogVersion: CATALOG_VERSION,
nodes,
links,
metadata: { layout: "ignored" },
version: 1,
};
}
test("catalog exhaustively mirrors all current V2 contracts", () => {
assert.deepEqual(
Object.keys(semanticCatalog).sort(),
[
"surface_target",
"texture_spec",
"scene_table",
"local_aabb_buffer",
"camera_frustum",
"visibility_flags",
"frustum_cull",
"mesh_query",
"depth_stencil_config",
"legacy_forward",
"fullscreen_copy",
"tone_map",
"bloom_extract",
"bloom_blur",
"bloom_composite",
"luminance_edge",
"present",
].sort(),
);
for (const c of Object.values(semanticCatalog)) {
assert.ok(c.execution);
assert.ok(c.inputs);
assert.ok(c.outputs);
assert.ok(c.parameters);
}
});
test("adapter deterministically emits strict V2, permits repeated types, omits muted links and maps sources", () => {
const x = fixture(),
a = adaptFxNodeSnapshot(x, 7);
x.nodes.reverse();
x.links.reverse();
assert.deepEqual(adaptFxNodeSnapshot(x, 7), a);
assert.equal(a.schemaVersion, 2);
assert.equal(a.graphId, GRAPH_ID);
assert.equal(
a.nodes.filter((n) => n.executor.key === "texture_spec").length,
2,
);
assert.ok(
Object.values(getSourceMap(a)).some((source) => source.input === "source"),
);
x.links.find((l) => l.id === "l_forward_color_copy_source").muted = true;
assert.equal(
adaptFxNodeSnapshot(x, 8).nodes.find((n) => n.id === "copy").inputs.source,
undefined,
);
});
test("adapter rejects hostile shape, IDs, duplicates, catalog, sockets and type mismatches", () => {
const reject = (fn, code) => {
const x = fixture();
fn(x);
assert.throws(
() => adaptFxNodeSnapshot(x),
(e) => e instanceof AuthoringGraphError && e.code === code,
);
};
reject((x) => (x.graphId = "bad"), "AUTHORING_CATALOG");
reject((x) => (x.nodes[0].id = "bad id"), "AUTHORING_ID");
reject((x) => (x.nodes[1].id = x.nodes[0].id), "AUTHORING_ID_DUPLICATE");
reject((x) => (x.nodes[0].typeId = "wat"), "AUTHORING_NODE_TYPE");
reject((x) => (x.nodes[0].sockets = []), "AUTHORING_SOCKET_SET");
reject((x) => (x.links[0].toSocketId = "missing:x"), "AUTHORING_LINK");
reject((x) => {
const link = x.links.find((l) => l.toSocketId === "copy:source");
link.fromNodeId = "scene";
link.fromSocketId = "scene:scene";
}, "AUTHORING_LINK_TYPE");
});
test("adapter counts only active incoming links and reports socket overflow", () => {
const x = fixture();
const active = x.links.find((link) => link.toSocketId === "copy:source");
x.links.push({ ...structuredClone(active), id: "muted_duplicate", muted: true });
assert.doesNotThrow(() => adaptFxNodeSnapshot(x));
x.links.push({ ...structuredClone(active), id: "active_overflow" });
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_LINK_INCOMING" && error.details.socketId === "copy:source",
);
});
test("source map covers Rust fields, nested values, every input and is deeply frozen", () => {
const snapshot = fixture();
snapshot.links.find((link) => link.toSocketId === "copy:source").muted = true;
const ir = adaptFxNodeSnapshot(snapshot, 9), map = getSourceMap(ir);
for (const path of ["schemaVersion", "graphId", "revision", "nodes", "nodes[0].id", "nodes[0].state", "nodes[0].executor.key", "nodes[0].executor.version", "nodes[0].parameters", "nodes[0].inputs"])
assert.ok(map[path], path);
for (const [index, node] of ir.nodes.entries())
for (const input of Object.keys(semanticCatalog[node.executor.key].inputs))
assert.ok(map[`nodes[${index}].inputs.${input}`]);
assert.ok(Object.keys(map).some((path) => /parameters\..+\[|parameters\..+\..+/.test(path)));
const socket = Object.values(map).find((source) => source.kind === "socket");
const unconnected = Object.values(map).find((source) => source.unconnected === true);
const link = Object.values(map).find((source) => source.kind === "link");
assert.ok(socket?.socketId && unconnected?.socketId);
assert.equal(ir.nodes.find((node) => node.id === "copy").inputs.source, undefined);
for (const field of ["linkId", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted"])
assert.ok(Object.hasOwn(link, field), field);
assert.ok(Object.isFrozen(map) && Object.isFrozen(link));
});
test("diagnostic mapper creates a frozen RendererError DTO with fallbacks and prefix matching", () => {
const ir = adaptFxNodeSnapshot(fixture());
const original = new RendererError("GRAPH_INPUT", { message: "bad", field: "nodes[0].executor.key.more", nested: { x: 1 } });
const mapped = mapAuthoringDiagnostic(ir, original);
assert.notStrictEqual(mapped, original);
assert.equal(mapped.code, original.code);
assert.equal(mapped.source.kind, "node");
assert.equal(mapped.diagnostic, undefined);
assert.ok(Object.isFrozen(mapped) && Object.isFrozen(mapped.details.nested));
assert.equal(Object.isFrozen(original), false);
original.details.nested.x = 2;
assert.equal(mapped.details.nested.x, 1);
const unmatchedOriginal = new RendererError("GRAPH_INPUT", {
message: "unmapped",
path: "resources[0]",
});
const unmatched = mapAuthoringDiagnostic(ir, unmatchedOriginal);
assert.notStrictEqual(unmatched, unmatchedOriginal);
assert.equal(unmatched.source, undefined);
assert.ok(Object.isFrozen(unmatched) && Object.isFrozen(unmatched.details));
assert.equal(Object.isFrozen(unmatchedOriginal), false);
});
test("controller keeps last-good through failures and only drops after successful switch", async () => {
let fail = false;
const calls = [];
const renderer = {
compileGraph: async (ir) => ({
compiledId: [ir.revision, 1],
revision: ir.revision,
}),
switchCompiledGraph: async (id) => {
calls.push(["switch", id]);
if (fail) throw Error("switch");
},
dropCompiledGraph: async (id) => calls.push(["drop", id]),
};
const c = new AuthoringController({
renderer,
adapt: (_, revision) => ({ revision }),
});
c.markDirty({});
await c.apply();
fail = true;
c.markDirty({});
await assert.rejects(c.apply());
assert.deepEqual(calls, [
["switch", [1, 1]],
["switch", [2, 1]],
]);
fail = false;
await c.apply();
assert.deepEqual(calls.at(-1), ["drop", [1, 1]]);
await c.destroy();
});
test("controller shares in-flight apply", async () => {
let release;
const gate = new Promise((r) => (release = r));
const c = new AuthoringController({
adapt: (_, revision) => ({ revision }),
renderer: {
compileGraph: async (ir) => {
await gate;
return { compiledId: [ir.revision, 1], revision: ir.revision };
},
switchCompiledGraph: async () => {},
dropCompiledGraph: async () => {},
},
});
c.markDirty({});
const a = c.apply();
assert.strictEqual(c.apply(), a);
release();
await a;
});
test("controller retains mapped diagnostic while apply rejects the original and subscriptions agree", async () => {
const original = new RendererError("GRAPH_BAD", { path: "nodes[0].id", message: "bad" });
const states = [];
const c = new AuthoringController({
adapt: (snapshot, revision) => adaptFxNodeSnapshot(snapshot, revision),
renderer: { compileGraph: async () => { throw original; }, switchCompiledGraph: async () => {}, dropCompiledGraph: async () => {} },
});
c.subscribe((state) => states.push(state));
c.markDirty(fixture());
await assert.rejects(c.apply(), (error) => error === original);
assert.notStrictEqual(states.at(-1).error, original);
let subscribed;
c.subscribe((state) => { subscribed = state; })();
assert.strictEqual(subscribed.error, states.at(-1).error);
await c.destroy();
});
test("apply after destroy does not compile and destroy returns one strict promise", async () => {
let compiles = 0;
const c = new AuthoringController({ adapt: () => ({}), renderer: { compileGraph: async () => { compiles++; }, dropCompiledGraph: async () => {}, switchCompiledGraph: async () => {} } });
c.markDirty({});
const first = c.destroy();
assert.strictEqual(c.destroy(), first);
assert.strictEqual(await c.apply(), null);
await first;
assert.equal(compiles, 0);
}); });
test("adapter carries scene mute and bounds revisions",()=>{const x=fixture();x.nodes[2].muted=true;assert.equal(adaptFxNodeSnapshot(x).passes[0].state,"disabled");assert.throws(()=>adaptFxNodeSnapshot(fixture(),0x100000000),e=>e.code==="AUTHORING_REVISION")});
test("controller orders compile/switch, revisions and shares one in-flight apply",async()=>{let release;const gate=new Promise(r=>release=r),calls=[];const renderer={async compileGraph(ir){calls.push(`compile:${ir.revision}`);await gate;return{compiledId:[0,1]}},async switchCompiledGraph(id){calls.push(`switch:${id}`)}};const c=new AuthoringController({renderer,getState:async()=>({})});const adapt=(_,r)=>({revision:r}),a=c.apply(adapt);assert.strictEqual(c.apply(adapt),a);c.markDirty();release();await a;assert.deepEqual(calls,["compile:1","switch:0,1"]);assert.equal(c.revision,1);assert.equal(c.dirty,true)});
test("controller reserves revisions across compile and switch failures",async()=>{let failure="compile",revisions=[];const c=new AuthoringController({getState:async()=>({}),renderer:{compileGraph:async ir=>{revisions.push(ir.revision);if(failure==="compile")throw Error("no");return{compiledId:[0,1]}},switchCompiledGraph:async()=>{if(failure==="switch")throw Error("no")}}});await assert.rejects(c.apply((_,r)=>({revision:r})));failure=null;await c.apply((_,r)=>({revision:r}));failure="switch";await assert.rejects(c.apply((_,r)=>({revision:r})));failure=null;await c.apply((_,r)=>({revision:r}));assert.deepEqual(revisions,[1,2,3,4]);assert.equal(c.revision,4)});
+96 -2
View File
@@ -1,4 +1,98 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { ember, midnight, renderGraphPresets } from "../static/render-graph/presets.js"; import {
test("Phase 8 presets are unique activatable V1 scene-forward graphs",()=>{assert.deepEqual(Object.keys(renderGraphPresets),["midnight","ember"]);assert.deepEqual([midnight.graphId,ember.graphId],["preset_midnight","preset_ember"]);assert.notDeepEqual(midnight.passes[0].writes[0].access.load.value,ember.passes[0].writes[0].access.load.value);for(const graph of [midnight,ember]){assert.equal(graph.schemaVersion,1);assert.equal(graph.revision,1);assert.equal(graph.passes.length,1);assert.equal(graph.passes[0].state,"enabled");assert.deepEqual(graph.passes[0].executor,{key:"scene_forward",version:1});assert.equal(graph.outputs[0].name,"present");}}); culling,
ember,
hdr,
midnight,
renderGraphPresets,
} from "../static/render-graph/presets.js";
test("Phase 4 unit 4 presets preserve V1 graphs and add the V2 HDR fullscreen topology", () => {
assert.deepEqual(Object.keys(renderGraphPresets), [
"midnight",
"ember",
"hdr",
"culling",
"tone",
"edges",
"bloom",
"combined",
]);
assert.deepEqual(
[midnight.graphId, ember.graphId],
["preset_midnight", "preset_ember"],
);
assert.notDeepEqual(
midnight.passes[0].writes[0].access.load.value,
ember.passes[0].writes[0].access.load.value,
);
for (const graph of [midnight, ember]) {
assert.equal(graph.schemaVersion, 1);
assert.equal(graph.revision, 1);
assert.equal(graph.passes.length, 1);
assert.equal(graph.passes[0].state, "enabled");
assert.deepEqual(graph.passes[0].executor, {
key: "scene_forward",
version: 1,
});
assert.equal(graph.outputs[0].name, "present");
}
assert.equal(hdr.schemaVersion, 2);
assert.equal(hdr.revision, 1);
assert.equal(hdr.graphId, "preset_hdr_fullscreen");
assert.equal(
new Set(Object.values(renderGraphPresets).map((graph) => graph.graphId))
.size,
8,
);
assert.deepEqual(
hdr.nodes.map((node) => [node.id, node.executor.key]),
[
["surface", "surface_target"],
["hdr", "texture_spec"],
["depth", "texture_spec"],
["scene", "scene_table"],
["visible", "visibility_flags"],
["query", "mesh_query"],
["depth_config", "depth_stencil_config"],
["forward", "legacy_forward"],
["copy", "fullscreen_copy"],
["present", "present"],
],
);
const byId = Object.fromEntries(hdr.nodes.map((node) => [node.id, node]));
assert.equal(byId.hdr.parameters.texture.format, "rgba16_float");
assert.deepEqual(byId.query.inputs, {
scene: { node: "scene", socket: "scene" },
isVisible: { node: "visible", socket: "flags" },
});
assert.deepEqual(byId.query.parameters.filters, [
{ flag: "isVisible", predicate: "required_true" },
{ flag: "isFrustumCulled", predicate: "any" },
]);
assert.deepEqual(byId.forward.inputs.colorTarget, {
node: "hdr",
socket: "spec",
});
assert.deepEqual(byId.copy.executor, { key: "fullscreen_copy", version: 1 });
assert.deepEqual(byId.copy.inputs, {
source: { node: "forward", socket: "color" },
colorTarget: { node: "surface", socket: "surface" },
});
assert.deepEqual(byId.present.inputs.surface, {
node: "copy",
socket: "color",
});
assert.deepEqual(
culling.nodes
.filter((node) => ["frustum_cull", "mesh_query"].includes(node.executor.key))
.map((node) => node.executor.key),
["frustum_cull", "mesh_query"],
);
const cullingQuery = culling.nodes.find((node) => node.id === "query");
assert.equal(cullingQuery.parameters.filters[1].predicate, "required_false");
assert.deepEqual(cullingQuery.inputs.isFrustumCulled, {
node: "cull",
socket: "flags",
});
});
+6
View File
@@ -55,6 +55,12 @@ test("pending reply exists before ring publication", async () => {
worker.reply({type:"reply",request:2,ok:true}); worker.reply({type:"reply",request:2,ok:true});
await pending; await pending;
}); });
test("profile snapshots have a dedicated getter", () => {
const f=fixture(), snapshot={type:"profile-snapshot",available:true,epoch:3,passes:{forward:1.25}};
const dispatch=globalThis.dispatchEvent; globalThis.dispatchEvent=()=>true;
try { f.worker.reply(snapshot); assert.strictEqual(f.client.profile,snapshot); }
finally { globalThis.dispatchEvent=dispatch; f.client.dispose(); }
});
test("worker failures and dispose reject every pending operation", async () => { test("worker failures and dispose reject every pending operation", async () => {
const f=fixture(), mesh=await imported(f); const {worker,client,bridge}=f; const f=fixture(), mesh=await imported(f); const {worker,client,bridge}=f;
const a=mesh.setVisible(true), b=mesh.setVisible(false); const a=mesh.setVisible(true), b=mesh.setVisible(false);