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",
"futures",
"gltf",
"image",
"js-sys",
"log",
"raw-window-handle",
+3 -1
View File
@@ -19,6 +19,7 @@ web-sys = { version = "0.3.77", features = [
"FileList",
"OffscreenCanvas",
"MouseEvent",
"PointerEvent",
"WheelEvent",
"KeyboardEvent",
"Worker",
@@ -43,4 +44,5 @@ wgpu = "26.0.1"
thiserror = "2.0.15"
ultraviolet = "0.10.0"
futures = "0.3"
gltf = { version = "1.4", features = ["extras", "names", "KHR_lights_punctual"] }
gltf = { version = "1.4", features = ["extras", "names", "KHR_lights_punctual", "KHR_materials_ior"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
+13 -6
View File
@@ -29,7 +29,7 @@ pub struct EditorScene {
impl renderer::renderer::scene::Scene for EditorScene {
fn setup(
renderer_context: &gpu_renderer::RendererContext,
resources: &mut gpu_renderer::GpuResources,
resources: &mut gpu_renderer::PipelineLibrary,
render_data: &mut RenderData,
) -> Self {
let dimension = ultraviolet::Vec2::new(
@@ -90,14 +90,18 @@ impl renderer::renderer::scene::Scene for EditorScene {
self.frame_metadata.mouse_click = [x, y];
}
fn handle_zoom(&mut self, _delta_y: f32) {
// TODO: Implement zoom properly when Camera exposes necessary methods
fn handle_zoom(&mut self, delta_y: f32) {
self.cam.zoom(delta_y);
}
fn handle_orbit(&mut self, delta_x: f32, delta_y: f32) {
self.cam.orbit(delta_x, delta_y);
}
fn handle_pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
self.cam.pan(delta_x, delta_y, viewport_height);
}
fn set_camera_depth_range(&mut self, near: f32, far: f32) {
self.cam.set_depth_range(near, far);
}
@@ -149,13 +153,14 @@ impl EditorScene {
fn create_default_scene(
&mut self,
device: &wgpu::Device,
resources: &mut gpu_renderer::GpuResources,
resources: &mut gpu_renderer::PipelineLibrary,
render_data: &mut RenderData,
surface_format: wgpu::TextureFormat,
) {
let positions: Vec<[f32; 3]> = Self::VERTICES.iter().map(|v| v.pos).collect();
// Ground plane normals point upward (Y+)
let normals: Vec<[f32; 3]> = vec![[0.0, 1.0, 0.0]; positions.len()];
let tangents: Vec<[f32; 4]> = vec![[1.0, 0.0, 0.0, 1.0]; positions.len()];
let uvs: &[[f32; 2]] = &[
[0.0, 0.0],
[1.0, 0.0],
@@ -183,9 +188,11 @@ impl EditorScene {
.create_mesh(MeshCreateInfo {
positions: &positions,
normals: &normals,
tangents: &tangents,
uvs,
indices: Self::INDICES,
pipeline: pipeline_index,
material: renderer::render_data::MaterialKey::DEFAULT,
flags: RenderFlags::VISIBLE,
default_instance_flags: RenderFlags::VISIBLE,
default_transform: transform,
@@ -196,11 +203,11 @@ impl EditorScene {
/// Entrypoint for the level editor
#[wasm_bindgen]
pub fn main() -> Result<RendererBridge, JsValue> {
pub fn main(profile: bool) -> Result<RendererBridge, JsValue> {
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
wasm_logger::init(wasm_logger::Config::default());
let runtime = LevelEditor::setup_runtime()?;
let runtime = LevelEditor::setup_runtime(profile)?;
Ok(RendererBridge { runtime })
}
+1
View File
@@ -46,6 +46,7 @@ thiserror = { workspace = true }
ultraviolet = { workspace = true }
futures = { workspace = true }
gltf = { workspace = true }
image = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+87 -58
View File
@@ -18,9 +18,10 @@ use web_sys::AddEventListenerOptions;
#[cfg(target_arch = "wasm32")]
pub struct EventListeners {
pub resize_listener: Option<Closure<dyn FnMut()>>,
pub mousemove_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
pub mousedown_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
pub pointer_listener: Option<Closure<dyn FnMut(web_sys::PointerEvent)>>,
pub click_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
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)>>,
}
@@ -29,9 +30,10 @@ impl EventListeners {
pub fn new() -> Self {
Self {
resize_listener: None,
mousemove_listener: None,
mousedown_listener: None,
pointer_listener: None,
click_listener: None,
wheel_listener: None,
contextmenu_listener: None,
keyboard_listener: None,
}
}
@@ -54,65 +56,82 @@ pub fn setup_event_listeners(
let width = f64::from(resize_canvas.client_width().max(1));
let height = f64::from(resize_canvas.client_height().max(1));
resize_worker_chan
.send(WindowEvent::Resize(ResizeMessage {
width,
height,
scale_factor: window.device_pixel_ratio(),
}))
.unwrap();
let _ = resize_worker_chan.send(WindowEvent::Resize(ResizeMessage {
width,
height,
scale_factor: window.device_pixel_ratio(),
}));
});
window.add_event_listener_with_callback("resize", resize_listener.as_ref().unchecked_ref())?;
let mousemove_worker_chan = worker_chan.clone();
let mousemove_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
let pointer_worker_chan = worker_chan.clone();
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| {
use crate::message::MouseMessage;
if event.buttons() & 0x04 != 0 {
event.prevent_default();
if event.button() != 0 {
return;
}
let mouse_event_data = MouseMessage::from_evt(event.clone());
let mut event_data = WindowEvent::PointerMove(mouse_event_data.clone());
if event.type_() == "click" {
event_data = WindowEvent::PointerClick(mouse_event_data.clone());
}
mousemove_worker_chan.clone().send(event_data).unwrap();
let message =
MouseMessage::from_evt(&event, f64::from(click_canvas.client_height().max(1)));
let _ = click_worker_chan.send(WindowEvent::PointerClick(message));
});
window.add_event_listener_with_callback(
"mousemove",
mousemove_listener.as_ref().unchecked_ref(),
)?;
window
.add_event_listener_with_callback("click", mousemove_listener.as_ref().unchecked_ref())?;
let mousedown_listener: Closure<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(),
)?;
canvas.add_event_listener_with_callback("click", click_listener.as_ref().unchecked_ref())?;
let wheel_worker_chan = worker_chan.clone();
let wheel_canvas = canvas.clone();
let wheel_listener: Closure<dyn FnMut(web_sys::WheelEvent)> =
Closure::new(move |event: web_sys::WheelEvent| {
use crate::message::WheelMessage;
event.prevent_default();
let wheel_event_data = WheelMessage::from_evt(event);
wheel_worker_chan
.send(WindowEvent::PointerWheel(wheel_event_data))
.unwrap();
if let Some(message) =
WheelMessage::from_evt(&event, f64::from(wheel_canvas.client_height().max(1)))
{
let _ = wheel_worker_chan.send(WindowEvent::PointerWheel(message));
}
});
let wheel_options = {
@@ -121,12 +140,19 @@ pub fn setup_event_listeners(
options
};
window.add_event_listener_with_callback_and_add_event_listener_options(
canvas.add_event_listener_with_callback_and_add_event_listener_options(
"wheel",
wheel_listener.as_ref().unchecked_ref(),
&wheel_options,
)?;
let contextmenu_listener: Closure<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_listener: Closure<dyn FnMut(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);
keyboard_worker_chan
.send(WindowEvent::Keyboard(keyboard_event_data))
.unwrap();
let _ = keyboard_worker_chan.send(WindowEvent::Keyboard(keyboard_event_data));
});
window
@@ -144,9 +168,10 @@ pub fn setup_event_listeners(
Ok(EventListeners {
resize_listener: Some(resize_listener),
mousemove_listener: Some(mousemove_listener),
mousedown_listener: Some(mousedown_listener),
pointer_listener: Some(pointer_listener),
click_listener: Some(click_listener),
wheel_listener: Some(wheel_listener),
contextmenu_listener: Some(contextmenu_listener),
keyboard_listener: Some(keyboard_listener),
})
}
@@ -166,6 +191,7 @@ impl WebAppRuntime {
pub fn new<T: crate::renderer::scene::Scene + 'static>(
worker_name: &str,
canvas_selector: &str,
profile: bool,
) -> Result<Self, JsValue> {
let (sender, receiver) = mpsc::channel::<WindowEvent>();
@@ -179,7 +205,7 @@ impl WebAppRuntime {
let worker = MainWorker::spawn(worker_name, 1, ring_ptr, move || {
spawn_local(async move {
let ring = unsafe { &*(ring_ptr as *const CommandRing) };
MainWorker::run_render_loop::<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) {}
/// Perform the default WASM initialization routine.
fn setup_runtime() -> Result<WebAppRuntime, JsValue> {
let mut runtime =
WebAppRuntime::new::<Self::Scene>(Self::worker_name(), Self::canvas_selector())?;
fn setup_runtime(profile: bool) -> Result<WebAppRuntime, JsValue> {
let mut runtime = WebAppRuntime::new::<Self::Scene>(
Self::worker_name(),
Self::canvas_selector(),
profile,
)?;
Self::on_runtime_initialized(&mut runtime);
Ok(runtime)
}
+275 -28
View File
@@ -3,7 +3,16 @@ use std::f32::consts::PI;
use ultraviolet::{projection, Bivec3, Mat4, Rotor3, Vec3};
use wgpu::util::DeviceExt;
use crate::{message::WheelMessage, renderer::scene::UniformResource};
use crate::renderer::scene::UniformResource;
/// A camera matrix cannot produce a safe, meaningful frustum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum FrustumError {
#[error("frustum plane {plane} contains a non-finite component")]
NonFinite { plane: usize },
#[error("frustum plane {plane} has a near-degenerate normal")]
Degenerate { plane: usize },
}
const MIN_DISTANCE: f32 = 0.1;
const MAX_PITCH: f32 = PI / 2.0 - 0.01;
@@ -81,6 +90,9 @@ pub struct CameraUniform {
}
impl Camera {
pub fn frustum_planes(&self) -> Result<[[f32; 4]; 6], FrustumError> {
extract_frustum_planes(self.view_proj)
}
pub fn new(aspect_ratio: f32) -> Self {
let mut camera = Camera {
view_proj: [[0.0; 4]; 4],
@@ -115,8 +127,14 @@ impl Camera {
}
pub fn look_at(&mut self, position: Vec3, target: Vec3) {
if !vec3_is_finite(position) || !vec3_is_finite(target) {
return;
}
self.position = position;
self.target = target;
if (self.position - self.target).mag_sq() <= f32::EPSILON {
self.position = self.target + Vec3::unit_z() * MIN_DISTANCE;
}
self.up = Vec3::unit_y();
self.compute_rotor();
self.dirty = true;
@@ -141,6 +159,9 @@ impl Camera {
}
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
if !delta_x.is_finite() || !delta_y.is_finite() {
return;
}
// Skip tiny movements to reduce unnecessary computations
if delta_x.abs() < 0.001 && delta_y.abs() < 0.001 {
return;
@@ -174,39 +195,62 @@ impl Camera {
self.compute_view_proj_mat();
}
pub fn zoom(&mut self, msg: &WheelMessage) {
let mut delta = msg.delta_y as f32;
// Match browser delta modes so the wheel delta is always roughly pixels.
match msg.delta_mode {
1 => delta *= 16.0,
2 => delta *= 800.0,
_ => {}
}
// Scrolling up should zoom in.
delta = -delta;
if delta.abs() <= f32::EPSILON {
pub fn zoom(&mut self, delta_y_pixels: f32) {
if !delta_y_pixels.is_finite() || delta_y_pixels.abs() <= f32::EPSILON {
return;
}
// Get forward direction from camera position to target
let mut forward_vec = self.target - self.position;
if forward_vec.mag_sq() <= f32::EPSILON {
forward_vec = Vec3::unit_z();
let mut offset = self.position - self.target;
let mut current_distance = offset.mag();
if !current_distance.is_finite() {
return;
}
if current_distance <= f32::EPSILON {
offset = Vec3::unit_z() * MIN_DISTANCE;
current_distance = MIN_DISTANCE;
}
let direction = offset / current_distance;
let max_distance = (self.z_far * 0.95).max(MIN_DISTANCE);
if !max_distance.is_finite() {
return;
}
let candidate = f64::from(current_distance)
* (f64::from(ZOOM_SENSITIVITY) * f64::from(delta_y_pixels)).exp();
let new_distance = candidate.clamp(f64::from(MIN_DISTANCE), f64::from(max_distance)) as f32;
let new_position = self.target + direction * new_distance;
if !vec3_is_finite(new_position) {
return;
}
let forward_dir = forward_vec.normalized();
let current_distance = forward_vec.mag();
// Scale dolly movement by distance to target for consistent perceived zoom speed
let dolly_distance = delta * ZOOM_SENSITIVITY * current_distance;
let dolly_translation = forward_dir * dolly_distance;
self.position = new_position;
self.distance = new_distance;
self.dirty = true;
self.compute_view_proj_mat();
}
self.position += dolly_translation;
self.target += dolly_translation;
self.compute_rotor();
pub fn pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
if !delta_x.is_finite()
|| !delta_y.is_finite()
|| !viewport_height.is_finite()
|| !self.fov.is_finite()
{
return;
}
let distance = (self.position - self.target).mag().max(MIN_DISTANCE);
if !distance.is_finite() {
return;
}
let world_units_per_pixel =
2.0 * distance * (self.fov * 0.5).tan() / viewport_height.max(1.0);
let basis = OrthonormalBasis::from_camera(self);
let translation = (-basis.right * delta_x + basis.up * delta_y) * world_units_per_pixel;
let position = self.position + translation;
let target = self.target + translation;
if !vec3_is_finite(position) || !vec3_is_finite(target) {
return;
}
self.position = position;
self.target = target;
self.dirty = true;
self.compute_view_proj_mat();
}
@@ -294,3 +338,206 @@ impl Camera {
self.rotor = (swing_rotor * twist_rotor).normalized();
}
}
/// Extracts inward-facing normalized WebGPU clip-space planes (zero-to-one depth).
pub fn extract_frustum_planes(m: [[f32; 4]; 4]) -> Result<[[f32; 4]; 6], FrustumError> {
let row = |r: usize| [m[0][r], m[1][r], m[2][r], m[3][r]];
let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]];
let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]];
let r0 = row(0);
let r1 = row(1);
let r2 = row(2);
let r3 = row(3);
let mut planes = [
add(r3, r0),
sub(r3, r0),
add(r3, r1),
sub(r3, r1),
r2,
sub(r3, r2),
];
for (plane, p) in planes.iter_mut().enumerate() {
if !p.iter().all(|component| component.is_finite()) {
return Err(FrustumError::NonFinite { plane });
}
// Scale first: directly squaring very large/small coefficients can overflow or
// underflow even though the plane itself is normalizable.
let scale = p[0].abs().max(p[1].abs()).max(p[2].abs());
if scale < f32::MIN_POSITIVE {
return Err(FrustumError::Degenerate { plane });
}
let scaled = [p[0] / scale, p[1] / scale, p[2] / scale];
let length = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).sqrt();
for v in p {
*v = (*v / scale) / length;
if !v.is_finite() {
return Err(FrustumError::NonFinite { plane });
}
}
}
Ok(planes)
}
fn vec3_is_finite(value: Vec3) -> bool {
value.x.is_finite() && value.y.is_finite() && value.z.is_finite()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frustum_extraction_rejects_nonfinite_and_degenerate_planes() {
let mut nonfinite = Camera::new(1.0).view_proj;
nonfinite[0][0] = f32::NAN;
assert!(matches!(
extract_frustum_planes(nonfinite),
Err(FrustumError::NonFinite { .. })
));
assert!(matches!(
extract_frustum_planes([[0.0; 4]; 4]),
Err(FrustumError::Degenerate { .. })
));
}
#[test]
fn frustum_extraction_normalizes_without_overflow() {
let mut matrix = Camera::new(1.0).view_proj;
for value in matrix.iter_mut().flatten() {
*value *= 1.0e20;
}
let planes = extract_frustum_planes(matrix).unwrap();
for plane in planes {
let length = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
assert!((length - 1.0).abs() < 1.0e-5);
assert!(plane.iter().all(|value| value.is_finite()));
}
}
fn assert_vec3_close(actual: Vec3, expected: Vec3, epsilon: f32) {
assert!(
(actual.x - expected.x).abs() <= epsilon,
"x: {actual:?} != {expected:?}"
);
assert!(
(actual.y - expected.y).abs() <= epsilon,
"y: {actual:?} != {expected:?}"
);
assert!(
(actual.z - expected.z).abs() <= epsilon,
"z: {actual:?} != {expected:?}"
);
}
fn assert_camera_finite(camera: &Camera) {
assert!(vec3_is_finite(camera.position));
assert!(vec3_is_finite(camera.target));
assert!(camera.distance.is_finite());
assert!(camera
.view_proj
.iter()
.flatten()
.all(|component| component.is_finite()));
}
#[test]
fn zoom_is_multiplicative_and_preserves_target() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
let initial_position = camera.position;
let initial_target = camera.target;
let initial_distance = camera.distance;
camera.zoom(-100.0);
assert!(camera.distance < initial_distance);
assert_eq!(camera.target, initial_target);
camera.zoom(100.0);
assert_vec3_close(camera.position, initial_position, 1e-4);
assert!((camera.distance - initial_distance).abs() <= 1e-4);
camera.zoom(100.0);
assert!(camera.distance > initial_distance);
assert_eq!(camera.target, initial_target);
}
#[test]
fn zoom_clamps_and_remains_finite() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
camera.zoom(f32::NEG_INFINITY);
assert!((camera.distance - 10.0).abs() <= 1e-5);
camera.zoom(-f32::MAX);
assert!((camera.distance - MIN_DISTANCE).abs() <= f32::EPSILON);
camera.zoom(f32::MAX);
assert!(camera.distance <= camera.z_far * 0.95);
assert_camera_finite(&camera);
}
#[test]
fn pan_moves_eye_and_target_equally_at_target_plane_scale() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
let initial_position = camera.position;
let initial_target = camera.target;
let initial_offset = initial_position - initial_target;
let units = 2.0 * 10.0 * (PI / 6.0).tan() / 1000.0;
camera.pan(20.0, 10.0, 1000.0);
let translation = Vec3::new(-20.0 * units, 10.0 * units, 0.0);
assert_vec3_close(camera.position, initial_position + translation, 1e-5);
assert_vec3_close(camera.target, initial_target + translation, 1e-5);
assert_vec3_close(camera.position - camera.target, initial_offset, 1e-5);
assert!((camera.distance - 10.0).abs() <= 1e-5);
}
#[test]
fn pan_scale_is_proportional_to_distance() {
let mut near = Camera::new(1.0);
near.look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::zero());
let mut far = Camera::new(1.0);
far.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
near.pan(10.0, 0.0, 1000.0);
far.pan(10.0, 0.0, 1000.0);
assert!((far.target.mag() / near.target.mag() - 2.0).abs() <= 1e-5);
}
#[test]
fn orbit_preserves_target_and_distance() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(2.0, 3.0, 10.0), Vec3::new(1.0, -1.0, 0.5));
let initial_target = camera.target;
let initial_distance = (camera.position - camera.target).mag();
camera.orbit(40.0, -25.0);
assert_eq!(camera.target, initial_target);
assert!(((camera.position - camera.target).mag() - initial_distance).abs() <= 1e-5);
assert!((camera.distance - initial_distance).abs() <= 1e-5);
assert!(camera.distance >= MIN_DISTANCE);
assert_camera_finite(&camera);
}
#[test]
fn controls_reject_invalid_input_and_recover_degenerate_look_at() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::zero(), Vec3::zero());
assert!((camera.position - camera.target).mag() >= MIN_DISTANCE);
let position = camera.position;
let target = camera.target;
camera.orbit(f32::NAN, 1.0);
camera.zoom(f32::INFINITY);
camera.pan(f32::NAN, 1.0, 0.0);
assert_eq!(camera.position, position);
assert_eq!(camera.target, target);
camera.pan(1.0, 1.0, 0.0);
camera.orbit(4.0, -3.0);
camera.zoom(2.0);
assert_camera_finite(&camera);
}
}
+516 -11
View File
@@ -4,10 +4,100 @@ use gltf::Gltf;
use ultraviolet::{Mat4, Vec3};
use crate::render_data::{
InstanceHandle, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey, RenderData,
RenderDataError, RenderFlags,
InstanceHandle, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey,
RenderData, RenderDataError, RenderFlags,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AlphaMode {
#[default]
Opaque,
Mask,
Blend,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TextureReference {
pub texture: usize,
pub tex_coord: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Material {
pub key: MaterialKey,
pub base_color_factor: [f32; 4],
pub metallic_factor: f32,
pub roughness_factor: f32,
pub emissive_factor: [f32; 3],
/// Index of refraction for the dielectric Fresnel response.
pub ior: f32,
pub alpha_mode: AlphaMode,
pub alpha_cutoff: f32,
pub double_sided: bool,
pub base_color_texture: Option<TextureReference>,
pub metallic_roughness_texture: Option<TextureReference>,
pub normal_texture: Option<TextureReference>,
pub normal_scale: f32,
pub occlusion_texture: Option<TextureReference>,
pub occlusion_strength: f32,
pub emissive_texture: Option<TextureReference>,
}
impl Default for Material {
fn default() -> Self {
Self {
key: MaterialKey::DEFAULT,
base_color_factor: [1.0; 4],
metallic_factor: 1.0,
roughness_factor: 1.0,
emissive_factor: [0.0; 3],
ior: 1.5,
alpha_mode: AlphaMode::Opaque,
alpha_cutoff: 0.5,
double_sided: false,
base_color_texture: None,
metallic_roughness_texture: None,
normal_texture: None,
normal_scale: 1.0,
occlusion_texture: None,
occlusion_strength: 1.0,
emissive_texture: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TextureMetadata {
pub image: usize,
pub sampler: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SamplerMetadata {
pub index: usize,
pub mag_filter: Option<String>,
pub min_filter: Option<String>,
pub wrap_s: String,
pub wrap_t: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ImageSource {
Uri(String),
BufferView(usize),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImageMetadata {
pub index: usize,
pub name: Option<String>,
pub mime_type: Option<String>,
pub source: ImageSource,
/// Encoded PNG/JPEG bytes. Kept encoded so GPU installation can decode and
/// upload images one at a time instead of retaining a decoded image batch.
pub encoded_data: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct InstalledScene {
pub meshes: Vec<MeshHandle>,
@@ -60,16 +150,31 @@ pub enum ImportError {
GltfParse(#[from] gltf::Error),
#[error("unsupported or malformed primitive: {0}")]
InvalidPrimitive(String),
#[error("unsupported image source: {0}")]
UnsupportedImage(String),
#[error("invalid KHR_materials_ior value: {0}")]
InvalidIor(f32),
#[error("failed to install imported scene")]
Install(#[from] RenderDataError),
}
fn decode_ior(value: Option<f32>) -> Result<f32, ImportError> {
let ior = value.unwrap_or(1.5);
if ior == 0.0 || (ior.is_finite() && ior >= 1.0) {
Ok(ior)
} else {
Err(ImportError::InvalidIor(ior))
}
}
#[derive(Clone, Debug)]
pub struct ImportedGeometry {
pub key: (usize, usize),
pub material: MaterialKey,
pub double_sided: bool,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub tangents: Vec<[f32; 4]>,
pub uvs: Vec<[f32; 2]>,
pub indices: Vec<u32>,
}
@@ -82,12 +187,257 @@ pub struct ImportedOccurrence {
pub struct ImportedScene {
pub geometries: Vec<ImportedGeometry>,
pub occurrences: Vec<ImportedOccurrence>,
pub materials: Vec<Material>,
pub textures: Vec<TextureMetadata>,
pub samplers: Vec<SamplerMetadata>,
pub images: Vec<ImageMetadata>,
}
fn texture_reference(info: gltf::texture::Info<'_>) -> TextureReference {
TextureReference {
texture: info.texture().index(),
tex_coord: info.tex_coord(),
}
}
fn normalize(value: [f32; 3], fallback: [f32; 3]) -> [f32; 3] {
let length = value.iter().map(|x| x * x).sum::<f32>().sqrt();
if length > f32::EPSILON && length.is_finite() {
value.map(|x| x / length)
} else {
fallback
}
}
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
/// Completes triangle vertex attributes. Generated attributes use corner vertices,
/// which deliberately splits UV seams, hard normal edges, and opposite handedness.
fn repair_geometry(
positions: Vec<[f32; 3]>,
normals: Option<Vec<[f32; 3]>>,
tangents: Option<Vec<[f32; 4]>>,
uvs: Vec<[f32; 2]>,
indices: Vec<u32>,
) -> Result<
(
Vec<[f32; 3]>,
Vec<[f32; 3]>,
Vec<[f32; 4]>,
Vec<[f32; 2]>,
Vec<u32>,
),
ImportError,
> {
if indices.len() % 3 != 0 || indices.iter().any(|&i| i as usize >= positions.len()) {
return Err(ImportError::InvalidPrimitive(
"triangle indices are malformed".into(),
));
}
let normals_valid = normals.as_ref().is_some_and(|x| x.len() == positions.len());
let tangents_valid = tangents
.as_ref()
.is_some_and(|x| x.len() == positions.len());
if normals_valid && tangents_valid {
return Ok((positions, normals.unwrap(), tangents.unwrap(), uvs, indices));
}
let mut out_p = Vec::with_capacity(indices.len());
let mut out_n = Vec::with_capacity(indices.len());
let mut out_t = Vec::with_capacity(indices.len());
let mut out_uv = Vec::with_capacity(indices.len());
for triangle in indices.chunks_exact(3) {
let ids = [
triangle[0] as usize,
triangle[1] as usize,
triangle[2] as usize,
];
let p = ids.map(|i| positions[i]);
let uv = ids.map(|i| uvs.get(i).copied().unwrap_or([0.0; 2]));
let face_normal = normalize(cross(sub(p[1], p[0]), sub(p[2], p[0])), [0.0, 1.0, 0.0]);
let duv1 = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
let duv2 = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
let determinant = duv1[0] * duv2[1] - duv1[1] * duv2[0];
let edge1 = sub(p[1], p[0]);
let edge2 = sub(p[2], p[0]);
let (raw_tangent, raw_bitangent) =
if determinant.abs() > f32::EPSILON && determinant.is_finite() {
let r = determinant.recip();
(
std::array::from_fn(|i| (edge1[i] * duv2[1] - edge2[i] * duv1[1]) * r),
std::array::from_fn(|i| (edge2[i] * duv1[0] - edge1[i] * duv2[0]) * r),
)
} else {
([0.0; 3], [0.0; 3])
};
for corner in 0..3 {
let n = normals
.as_ref()
.filter(|_| normals_valid)
.map_or(face_normal, |x| x[ids[corner]]);
let n = normalize(n, face_normal);
let projected = std::array::from_fn(|i| raw_tangent[i] - n[i] * dot(n, raw_tangent));
let fallback_axis = if n[0].abs() < 0.9 {
[1.0, 0.0, 0.0]
} else {
[0.0, 1.0, 0.0]
};
let tangent3 = normalize(
projected,
normalize(cross(fallback_axis, n), [0.0, 0.0, 1.0]),
);
let generated = [
tangent3[0],
tangent3[1],
tangent3[2],
if dot(cross(n, tangent3), raw_bitangent) < 0.0 {
-1.0
} else {
1.0
},
];
out_p.push(p[corner]);
out_n.push(n);
out_t.push(
tangents
.as_ref()
.filter(|_| tangents_valid)
.map_or(generated, |x| x[ids[corner]]),
);
out_uv.push(uv[corner]);
}
}
let out_i = (0..u32::try_from(out_p.len())
.map_err(|_| ImportError::InvalidPrimitive("vertex count exceeds u32".into()))?)
.collect();
Ok((out_p, out_n, out_t, out_uv, out_i))
}
pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
let model = Gltf::from_slice(bytes)?;
let buffers = gltf::import_buffers(&model.document, None, model.blob.clone())?;
decode_gltf_model(Gltf::from_slice(bytes)?)
}
pub fn decode_gltf_owned(bytes: Vec<u8>) -> Result<ImportedScene, ImportError> {
let model = Gltf::from_slice(&bytes)?;
drop(bytes);
decode_gltf_model(model)
}
fn decode_gltf_model(mut model: Gltf) -> Result<ImportedScene, ImportError> {
// Reject external images before buffer import can turn them into a generic
// import error (or attempt to interpret a data/external URI).
for image in model.images() {
if let gltf::image::Source::Uri { uri, .. } = image.source() {
return Err(ImportError::UnsupportedImage(format!(
"URI/external image '{uri}'"
)));
}
}
let blob = model.blob.take();
let buffers = gltf::import_buffers(&model.document, None, blob)?;
let mut result = ImportedScene::default();
result.materials.push(Material::default());
for material in model.materials() {
let pbr = material.pbr_metallic_roughness();
let normal = material.normal_texture();
let normal_texture = normal.as_ref().map(|x| TextureReference {
texture: x.texture().index(),
tex_coord: x.tex_coord(),
});
let occlusion = material.occlusion_texture();
let occlusion_texture = occlusion.as_ref().map(|x| TextureReference {
texture: x.texture().index(),
tex_coord: x.tex_coord(),
});
result.materials.push(Material {
key: MaterialKey::new(material.index().unwrap() as u32 + 1),
base_color_factor: pbr.base_color_factor(),
metallic_factor: pbr.metallic_factor(),
roughness_factor: pbr.roughness_factor(),
emissive_factor: material.emissive_factor(),
ior: decode_ior(material.ior())?,
alpha_mode: match material.alpha_mode() {
gltf::material::AlphaMode::Opaque => AlphaMode::Opaque,
gltf::material::AlphaMode::Mask => AlphaMode::Mask,
gltf::material::AlphaMode::Blend => AlphaMode::Blend,
},
alpha_cutoff: material.alpha_cutoff().unwrap_or(0.5),
double_sided: material.double_sided(),
base_color_texture: pbr.base_color_texture().map(texture_reference),
metallic_roughness_texture: pbr.metallic_roughness_texture().map(texture_reference),
normal_texture,
normal_scale: normal.map_or(1.0, |x| x.scale()),
occlusion_texture,
occlusion_strength: occlusion.map_or(1.0, |x| x.strength()),
emissive_texture: material.emissive_texture().map(texture_reference),
});
}
result.textures = model
.textures()
.map(|x| TextureMetadata {
image: x.source().index(),
sampler: x.sampler().index(),
})
.collect();
result.samplers = model
.samplers()
.map(|x| SamplerMetadata {
index: x.index().unwrap(),
mag_filter: x.mag_filter().map(|v| format!("{v:?}")),
min_filter: x.min_filter().map(|v| format!("{v:?}")),
wrap_s: format!("{:?}", x.wrap_s()),
wrap_t: format!("{:?}", x.wrap_t()),
})
.collect();
result.images = model
.images()
.map(|x| -> Result<_, ImportError> {
let (source, mime_type, encoded_data) = match x.source() {
gltf::image::Source::Uri { uri, .. } => {
return Err(ImportError::UnsupportedImage(format!(
"URI/external image '{uri}'"
)))
}
gltf::image::Source::View { view, mime_type } => {
let data = buffers.get(view.buffer().index()).ok_or_else(|| {
ImportError::UnsupportedImage("image buffer is missing".into())
})?;
let end = view.offset().checked_add(view.length()).ok_or_else(|| {
ImportError::UnsupportedImage("image bufferView overflows".into())
})?;
let bytes = data.0.get(view.offset()..end).ok_or_else(|| {
ImportError::UnsupportedImage("image bufferView is out of bounds".into())
})?;
(
ImageSource::BufferView(view.index()),
Some(mime_type.to_owned()),
bytes.to_vec(),
)
}
};
Ok(ImageMetadata {
index: x.index(),
name: x.name().map(str::to_owned),
mime_type,
source,
encoded_data,
})
})
.collect::<Result<_, _>>()?;
let mut seen = HashMap::new();
fn visit(
node: gltf::Node<'_>,
@@ -116,12 +466,8 @@ pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
if count == 0 {
continue;
}
let mut normals: Vec<_> = reader
.read_normals()
.map(|x| x.collect())
.unwrap_or_default();
normals.resize(count, [0., 1., 0.]);
normals.truncate(count);
let normals = reader.read_normals().map(|x| x.collect());
let tangents = reader.read_tangents().map(|x| x.collect());
let mut uvs: Vec<_> = reader
.read_tex_coords(0)
.map(|x| x.into_f32().collect())
@@ -139,11 +485,20 @@ pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
if indices.is_empty() {
continue;
}
let (positions, normals, tangents, uvs, indices) =
repair_geometry(positions, normals, tangents, uvs, indices)?;
let primitive_material = primitive.material();
result.geometries.push(ImportedGeometry {
key,
double_sided: primitive.material().double_sided(),
material: primitive_material
.index()
.map_or(MaterialKey::DEFAULT, |index| {
MaterialKey::new(index as u32 + 1)
}),
double_sided: primitive_material.double_sided(),
positions,
normals,
tangents,
uvs,
indices,
});
@@ -189,9 +544,11 @@ pub fn install_imported(
let created = stage.create_mesh(MeshCreateInfo {
positions: &geometry.positions,
normals: &geometry.normals,
tangents: &geometry.tangents,
uvs: &geometry.uvs,
indices: &geometry.indices,
pipeline: pipelines[usize::from(geometry.double_sided)],
material: geometry.material,
flags: RenderFlags::VISIBLE,
default_instance_flags: RenderFlags::VISIBLE,
default_transform: transform,
@@ -250,3 +607,151 @@ pub fn install_imported(
bounds,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uri_images_are_rejected_explicitly() {
let json = br#"{
"asset":{"version":"2.0"},
"images":[{"uri":"external.png"}],
"scenes":[{"nodes":[]}],"scene":0
}"#;
let result = decode_gltf(json);
assert!(
matches!(
result,
Err(ImportError::UnsupportedImage(ref message)) if message.contains("URI/external")
),
"unexpected result: {result:?}"
);
}
#[test]
fn owned_and_borrowed_decode_paths_remain_compatible() {
let json = br#"{
"asset":{"version":"2.0"},
"scenes":[{"nodes":[]}],"scene":0
}"#;
let borrowed = decode_gltf(json).unwrap();
let owned = decode_gltf_owned(json.to_vec()).unwrap();
assert_eq!(borrowed.geometries.len(), owned.geometries.len());
assert_eq!(borrowed.occurrences.len(), owned.occurrences.len());
assert_eq!(borrowed.materials.len(), owned.materials.len());
}
#[test]
fn repair_duplicates_corners_and_generates_flat_finite_frames() {
let positions = vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
];
let repaired = repair_geometry(
positions,
None,
None,
vec![[0.0; 2]; 4],
vec![0, 1, 2, 0, 3, 1],
)
.unwrap();
assert_eq!(repaired.0.len(), 6);
assert_eq!(repaired.4, (0..6).collect::<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 {
mouse_move: vec2<f32>,
mouse_click: vec2<f32>,
resolution: vec2<f32>,
time: f32,
_padding0: f32,
camera_position: vec4<f32>,
}
struct UniformData { mouse_move: vec2<f32>, mouse_click: vec2<f32>, resolution: vec2<f32>, time: f32, _padding0: f32, camera_position: vec4<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> }
@group(0) @binding(0) var<uniform> uni: UniformData;
@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 {
@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>,
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> }
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 }
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); }
@vertex fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput; let model = mat4x4<f32>(in.model_col0, in.model_col1, in.model_col2, in.model_col3);
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);
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;
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));
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;
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) normal: vec3<f32>
struct Closure { base: vec4<f32>, mr: vec2<f32>, normal_map: vec3<f32>, ao: f32, emissive: vec3<f32> }
fn sample_closure(uv: vec2<f32>) -> Closure {
let bits = material.flags.x; var c: Closure;
c.base = material.base_color_factor * select(vec4<f32>(1), textureSample(base_tex, base_sampler, uv), (bits & 1u) != 0u);
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;
}
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
let model = mat4x4<f32>(
in.model_col0,
in.model_col1,
in.model_col2,
in.model_col3,
);
let world_position = model * vec4<f32>(in.pos, 1.0);
out.clip_position = view_proj * world_position;
out.world_pos = world_position.xyz;
let normal_matrix = mat3x3<f32>(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz);
out.normal = normalize(normal_matrix * in.normal);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<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);
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); }
fn smith_v(n_v: f32, n_l: f32, a: f32) -> f32 { let a2=a*a; let gv=n_l*sqrt(max(n_v*n_v*(1.0-a2)+a2,0)); let gl=n_v*sqrt(max(n_l*n_l*(1.0-a2)+a2,0)); return 0.5/max(gv+gl,1e-6); }
@fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let c=sample_closure(in.uv); if material.alpha_optics.x == 1.0 && c.base.a < material.alpha_optics.y { discard; }
let physical_front=front == (in.determinant_sign > 0); let orientation=select(-1.0,1.0,physical_front || material.flags.y == 0u); let map=c.normal_map*2.0-1.0;
let n=safe_normalize(mat3x3<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;
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);
let nv=max(dot(n,v),0); let nl=max(dot(n,l),0); let nh=dot(n,h); let vh=max(dot(v,h),0); let a=c.mr.y*c.mr.y;
let f0=mix(vec3<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 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;
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);
var color=sun+(env_diff+env_spec)*c.ao+c.emissive;
if material.debug_extras.y == 1u { color=n*0.5+0.5; } else if material.debug_extras.y == 2u { color=vec3<f32>(c.mr.x,c.mr.y,c.ao); } else if material.debug_extras.y == 3u { color=f0; }
return vec4<f32>(color,1.0); // BLEND remains intentionally opaque.
}
+74 -19
View File
@@ -1,6 +1,40 @@
use core::fmt;
use std::cell::BorrowMutError;
use std::sync::mpsc::TryRecvError;
use wasm_bindgen::JsCast;
pub const RIGHT_BUTTON_MASK: u16 = 0x02;
pub const MIDDLE_BUTTON_MASK: u16 = 0x04;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CameraDrag {
Orbit,
Pan,
}
pub fn camera_drag(buttons: u16) -> Option<CameraDrag> {
if buttons & MIDDLE_BUTTON_MASK != 0 {
Some(CameraDrag::Orbit)
} else if buttons & RIGHT_BUTTON_MASK != 0 {
Some(CameraDrag::Pan)
} else {
None
}
}
pub fn normalize_wheel_delta(delta_y: f64, delta_mode: u32, viewport_height: f64) -> Option<f32> {
if !delta_y.is_finite() || !viewport_height.is_finite() {
return None;
}
let delta = match delta_mode {
0 => delta_y,
1 => delta_y * 16.0,
2 => delta_y * viewport_height.max(1.0),
_ => return None,
};
let delta = delta as f32;
delta.is_finite().then_some(delta)
}
#[derive(Debug)]
pub enum WindowEvent {
@@ -42,10 +76,11 @@ pub struct MouseMessage {
pub movement_y: f64,
pub offset_x: f64,
pub offset_y: f64,
pub viewport_height: f64,
}
impl MouseMessage {
pub fn from_evt(event: web_sys::MouseEvent) -> Self {
pub fn from_evt(event: &web_sys::MouseEvent, viewport_height: f64) -> Self {
let window = web_sys::window().unwrap();
Self {
scale_factor: window.device_pixel_ratio(),
@@ -57,33 +92,24 @@ impl MouseMessage {
movement_y: event.movement_y() as f64,
offset_x: event.offset_x() as f64,
offset_y: event.offset_y() as f64,
viewport_height,
}
}
pub fn from_pointer_evt(event: &web_sys::PointerEvent, viewport_height: f64) -> Self {
Self::from_evt(event.unchecked_ref(), viewport_height)
}
}
#[derive(Debug, Clone)]
pub struct WheelMessage {
pub scale_factor: f64,
pub delta_x: f64,
pub delta_y: f64,
pub delta_z: f64,
pub delta_mode: u32,
pub client_x: f64,
pub client_y: f64,
pub delta_y_pixels: f32,
}
impl WheelMessage {
pub fn from_evt(event: web_sys::WheelEvent) -> Self {
let window = web_sys::window().unwrap();
Self {
scale_factor: window.device_pixel_ratio(),
delta_x: event.delta_x(),
delta_y: event.delta_y(),
delta_z: event.delta_z(),
delta_mode: event.delta_mode(),
client_x: event.client_x() as f64,
client_y: event.client_y() as f64,
}
pub fn from_evt(event: &web_sys::WheelEvent, viewport_height: f64) -> Option<Self> {
normalize_wheel_delta(event.delta_y(), event.delta_mode(), viewport_height)
.map(|delta_y_pixels| Self { delta_y_pixels })
}
}
@@ -147,3 +173,32 @@ impl From<BorrowMutError> for DrainEventError {
DrainEventError::BorrowError(err)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wheel_delta_is_normalized_to_css_pixels() {
assert_eq!(normalize_wheel_delta(12.5, 0, 640.0), Some(12.5));
assert_eq!(normalize_wheel_delta(2.0, 1, 640.0), Some(32.0));
assert_eq!(normalize_wheel_delta(-1.0, 2, 640.0), Some(-640.0));
assert_eq!(normalize_wheel_delta(2.0, 2, 0.0), Some(2.0));
assert_eq!(normalize_wheel_delta(1.0, 3, 640.0), None);
assert_eq!(normalize_wheel_delta(f64::NAN, 0, 640.0), None);
assert_eq!(normalize_wheel_delta(f64::INFINITY, 0, 640.0), None);
assert_eq!(normalize_wheel_delta(1.0, 0, f64::NAN), None);
}
#[test]
fn camera_drag_prefers_orbit_when_both_buttons_are_down() {
assert_eq!(camera_drag(0), None);
assert_eq!(camera_drag(1), None);
assert_eq!(camera_drag(RIGHT_BUTTON_MASK), Some(CameraDrag::Pan));
assert_eq!(camera_drag(MIDDLE_BUTTON_MASK), Some(CameraDrag::Orbit));
assert_eq!(
camera_drag(RIGHT_BUTTON_MASK | MIDDLE_BUTTON_MASK),
Some(CameraDrag::Orbit)
);
}
}
+4 -1
View File
@@ -109,12 +109,15 @@ impl MainWorker {
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>(
events_chan: Receiver<WindowEvent>,
ring: &'static CommandRing,
profile: bool,
) {
use crate::renderer::Renderer;
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::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)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct RenderFlags(u32);
@@ -101,9 +119,11 @@ pub struct GeometryRange {
pub struct MeshCreateInfo<'a> {
pub positions: &'a [[f32; 3]],
pub normals: &'a [[f32; 3]],
pub tangents: &'a [[f32; 4]],
pub uvs: &'a [[f32; 2]],
pub indices: &'a [u32],
pub pipeline: PipelineKey,
pub material: MaterialKey,
pub flags: RenderFlags,
pub default_instance_flags: RenderFlags,
pub default_transform: ModelTransform,
@@ -120,6 +140,7 @@ pub struct MeshView {
pub handle: MeshHandle,
pub geometry: GeometryRange,
pub pipeline: PipelineKey,
pub material: MaterialKey,
pub flags: RenderFlags,
pub aabb: Aabb,
pub default_instance: InstanceHandle,
@@ -178,6 +199,7 @@ pub fn affine_world_aabb(local: Aabb, model: ModelTransform) -> Result<Aabb, Ren
pub struct VertexStreams<'a> {
pub positions: &'a [[f32; 3]],
pub normals: &'a [[f32; 3]],
pub tangents: &'a [[f32; 4]],
pub uvs: &'a [[f32; 2]],
}
@@ -267,6 +289,7 @@ pub enum RenderDataError {
struct VertexSoa {
positions: Vec<[f32; 3]>,
normals: Vec<[f32; 3]>,
tangents: Vec<[f32; 4]>,
uvs: Vec<[f32; 2]>,
logical_capacity: u32,
max_capacity: Option<u32>,
@@ -287,6 +310,7 @@ struct MeshSoa {
index_starts: Vec<u32>,
index_counts: Vec<u32>,
pipeline_keys: Vec<PipelineKey>,
material_keys: Vec<MaterialKey>,
flags: Vec<RenderFlags>,
aabb_mins: Vec<[f32; 3]>,
aabb_maxs: Vec<[f32; 3]>,
@@ -416,6 +440,7 @@ impl RenderData {
vertices: VertexSoa {
positions: Vec::new(),
normals: Vec::new(),
tangents: Vec::new(),
uvs: Vec::new(),
logical_capacity: 0,
max_capacity: config.max_vertices,
@@ -553,6 +578,8 @@ impl RenderData {
.copy_from_slice(info.positions);
self.vertices.normals[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
.copy_from_slice(info.normals);
self.vertices.tangents[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
.copy_from_slice(info.tangents);
self.vertices.uvs[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
.copy_from_slice(info.uvs);
self.indices.values[as_usize(index_range.start)..as_usize(index_range.end)]
@@ -567,6 +594,7 @@ impl RenderData {
index_count,
},
info.pipeline,
info.material,
info.flags,
bounds,
default_instance,
@@ -766,6 +794,7 @@ impl RenderData {
VertexStreams {
positions: &self.vertices.positions,
normals: &self.vertices.normals,
tangents: &self.vertices.tangents,
uvs: &self.vertices.uvs,
}
}
@@ -783,6 +812,7 @@ impl RenderData {
self.instances.slots.clear();
self.vertices.positions.clear();
self.vertices.normals.clear();
self.vertices.tangents.clear();
self.vertices.uvs.clear();
self.indices.values.clear();
self.vertices.allocator.clear();
@@ -800,6 +830,7 @@ impl RenderData {
)?;
reserve_vec(&mut self.vertices.positions, target, "vertices")?;
reserve_vec(&mut self.vertices.normals, target, "vertices")?;
reserve_vec(&mut self.vertices.tangents, target, "vertices")?;
reserve_vec(&mut self.vertices.uvs, target, "vertices")?;
self.vertices.logical_capacity = target;
Ok(())
@@ -821,6 +852,9 @@ impl RenderData {
let vertices = as_usize(self.vertices.allocator.high_water());
self.vertices.positions.resize(vertices, [0.0; 3]);
self.vertices.normals.resize(vertices, [0.0; 3]);
self.vertices
.tangents
.resize(vertices, [0.0, 0.0, 0.0, 1.0]);
self.vertices.uvs.resize(vertices, [0.0; 2]);
self.indices
.values
@@ -834,6 +868,9 @@ impl RenderData {
self.vertices
.normals
.truncate(as_usize(self.vertices.allocator.high_water()));
self.vertices
.tangents
.truncate(as_usize(self.vertices.allocator.high_water()));
self.vertices
.uvs
.truncate(as_usize(self.vertices.allocator.high_water()));
@@ -852,6 +889,7 @@ impl MeshSoa {
index_starts: Vec::new(),
index_counts: Vec::new(),
pipeline_keys: Vec::new(),
material_keys: Vec::new(),
flags: Vec::new(),
aabb_mins: Vec::new(),
aabb_maxs: Vec::new(),
@@ -871,6 +909,7 @@ impl MeshSoa {
reserve_vec(&mut self.index_starts, target, "meshes")?;
reserve_vec(&mut self.index_counts, target, "meshes")?;
reserve_vec(&mut self.pipeline_keys, target, "meshes")?;
reserve_vec(&mut self.material_keys, target, "meshes")?;
reserve_vec(&mut self.flags, target, "meshes")?;
reserve_vec(&mut self.aabb_mins, target, "meshes")?;
reserve_vec(&mut self.aabb_maxs, target, "meshes")?;
@@ -885,6 +924,7 @@ impl MeshSoa {
prepared: PreparedSlot,
geometry: GeometryRange,
pipeline: PipelineKey,
material: MaterialKey,
flags: RenderFlags,
bounds: Aabb,
default: InstanceHandle,
@@ -895,6 +935,7 @@ impl MeshSoa {
resize_column(&mut self.index_starts, len, 0);
resize_column(&mut self.index_counts, len, 0);
resize_column(&mut self.pipeline_keys, len, PipelineKey::new(0));
resize_column(&mut self.material_keys, len, MaterialKey::DEFAULT);
resize_column(&mut self.flags, len, RenderFlags::NONE);
resize_column(&mut self.aabb_mins, len, [0.0; 3]);
resize_column(&mut self.aabb_maxs, len, [0.0; 3]);
@@ -906,6 +947,7 @@ impl MeshSoa {
self.index_starts[index] = geometry.index_start;
self.index_counts[index] = geometry.index_count;
self.pipeline_keys[index] = pipeline;
self.material_keys[index] = material;
self.flags[index] = flags;
self.aabb_mins[index] = bounds.min;
self.aabb_maxs[index] = bounds.max;
@@ -925,6 +967,7 @@ impl MeshSoa {
index_count: self.index_counts[index],
},
pipeline: self.pipeline_keys[index],
material: self.material_keys[index],
flags: self.flags[index],
aabb: Aabb {
min: self.aabb_mins[index],
@@ -1054,7 +1097,10 @@ fn validate_geometry(info: &MeshCreateInfo<'_>) -> Result<u32, RenderDataError>
if info.positions.is_empty() {
return Err(RenderDataError::EmptyVertices);
}
if info.positions.len() != info.normals.len() || info.positions.len() != info.uvs.len() {
if info.positions.len() != info.normals.len()
|| info.positions.len() != info.tangents.len()
|| info.positions.len() != info.uvs.len()
{
return Err(RenderDataError::MismatchedVertexStreams);
}
let vertex_count =
@@ -1068,6 +1114,7 @@ fn validate_geometry(info: &MeshCreateInfo<'_>) -> Result<u32, RenderDataError>
.iter()
.flatten()
.chain(info.normals.iter().flatten())
.chain(info.tangents.iter().flatten())
.chain(info.uvs.iter().flatten())
.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 NORMALS: [[f32; 3]; 3] = [[0.0, 1.0, 0.0]; 3];
const TANGENTS: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 1.0]; 3];
const UVS: [[f32; 2]; 3] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
const INDICES: [u32; 3] = [0, 1, 2];
@@ -10,9 +11,11 @@ fn info() -> MeshCreateInfo<'static> {
MeshCreateInfo {
positions: &POSITIONS,
normals: &NORMALS,
tangents: &TANGENTS,
uvs: &UVS,
indices: &INDICES,
pipeline: PipelineKey::new(7),
material: MaterialKey::new(11),
flags: RenderFlags::from_bits_retain(2),
default_instance_flags: RenderFlags::VISIBLE,
default_transform: IDENTITY_MODEL_TRANSFORM,
@@ -83,6 +86,10 @@ fn default_instance_is_protected_and_flags_are_separate() {
let created = data.create_mesh(info()).unwrap();
assert!(data.instance(created.default_instance).unwrap().is_default);
assert_eq!(data.mesh(created.mesh).unwrap().flags.bits(), 2);
assert_eq!(
data.mesh(created.mesh).unwrap().material,
MaterialKey::new(11)
);
assert_eq!(
data.instance(created.default_instance).unwrap().flags,
RenderFlags::VISIBLE
@@ -239,6 +246,7 @@ fn streams_remain_coordinated_across_interior_delete_tail_delete_and_reuse() {
data.destroy_mesh(second.mesh).unwrap();
assert_eq!(data.streams().positions.len(), 3);
assert_eq!(data.streams().normals.len(), 3);
assert_eq!(data.streams().tangents.len(), 3);
assert_eq!(data.streams().uvs.len(), 3);
assert_eq!(data.indices().len(), 3);
}
@@ -288,6 +296,7 @@ fn aabb_supports_one_point_and_multiple_points() {
let mut one = info();
one.positions = &point;
one.normals = &normal;
one.tangents = &[[1.0, 0.0, 0.0, 1.0]];
one.uvs = &uv;
one.indices = &index;
let mut data = data();
@@ -325,6 +334,13 @@ fn malformed_geometry_matrix_is_rejected_without_consumption() {
data.create_mesh(candidate).unwrap_err(),
RenderDataError::MismatchedVertexStreams
);
let short_tangents = &TANGENTS[..2];
let mut candidate = info();
candidate.tangents = short_tangents;
assert_eq!(
data.create_mesh(candidate).unwrap_err(),
RenderDataError::MismatchedVertexStreams
);
let short_uvs = &UVS[..2];
let mut candidate = info();
candidate.uvs = short_uvs;
@@ -339,19 +355,22 @@ fn malformed_geometry_matrix_is_rejected_without_consumption() {
RenderDataError::EmptyIndices
);
for stream in 0..3 {
for stream in 0..4 {
for bad in [f32::NAN, f32::INFINITY] {
let mut positions = POSITIONS;
let mut normals = NORMALS;
let mut tangents = TANGENTS;
let mut uvs = UVS;
match stream {
0 => positions[0][0] = bad,
1 => normals[0][0] = bad,
2 => tangents[0][0] = bad,
_ => uvs[0][0] = bad,
}
let mut candidate = info();
candidate.positions = &positions;
candidate.normals = &normals;
candidate.tangents = &tangents;
candidate.uvs = &uvs;
assert_eq!(
data.create_mesh(candidate).unwrap_err(),
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.
mod compiler;
mod compiler_v2;
mod contracts_v2;
mod plan_v2;
mod registry;
mod runtime;
mod runtime_v2;
mod schema;
mod schema_v2;
pub use compiler::{
compile, compile_with, parse_and_compile, AllocationClass, CompiledGraph, CompiledOutput,
@@ -11,12 +16,38 @@ pub use compiler::{
ExecutorRegistry, ExecutorResolution, Lifetime, NormalizedParameters, SceneForwardExecutors,
TextureAllocationKey, TextureUsage, TransientAllocation,
};
pub use registry::{CompiledGraphId, Registry};
pub use compiler_v2::{compile_v2, mesh_predicate_matches, parse_and_compile_v2};
pub use contracts_v2::*;
pub use plan_v2::*;
pub use registry::{CompiledGraphId, RegisteredGraph, Registry};
pub use runtime::{
class_offsets, resolve_extent, runtime_texture_key, validate_activatable, ResolvedExtent,
RuntimeTextureKey,
};
pub use runtime_v2::*;
pub use schema::*;
pub use schema_v2::*;
pub fn parse_and_compile_any(bytes: &[u8]) -> Result<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_RESOURCES: usize = 1024;
@@ -56,3 +87,5 @@ impl GraphError {
#[cfg(test)]
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})
}
}
+52 -24
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)]
pub struct CompiledGraphId {
pub slot: u32,
@@ -12,13 +32,14 @@ impl From<CompiledGraphId> for [u32; 2] {
#[derive(Debug)]
struct Slot {
generation: u32,
value: Option<CompiledGraph>,
value: Option<RegisteredGraph>,
retired: bool,
}
#[derive(Debug)]
pub struct Registry {
slots: Vec<Slot>,
capacity: u32,
latest_revisions: HashMap<String, u32>,
}
impl Default for Registry {
fn default() -> Self {
@@ -30,33 +51,24 @@ impl Registry {
Self {
slots: vec![],
capacity,
latest_revisions: HashMap::new(),
}
}
pub fn compile(
&mut self,
bytes: &[u8],
) -> Result<(CompiledGraphId, serde_json::Value), GraphError> {
let graph = parse_and_compile(bytes)?;
if let Some((i, s)) = self.slots.iter_mut().enumerate().find(|(_, s)| {
s.value
.as_ref()
.is_some_and(|g| g.graph_id == graph.graph_id)
}) {
if graph.revision <= s.value.as_ref().unwrap().revision {
return Err(GraphError::new(
"GRAPH_REVISION_CONFLICT",
"revision must increase",
));
}
let id = CompiledGraphId {
slot: u32::try_from(i).map_err(|_| {
GraphError::new("GRAPH_LIMIT_EXCEEDED", "registry slot overflow")
})?,
generation: s.generation,
};
let summary = graph.summary(id.into());
s.value = Some(graph);
return Ok((id, summary));
let graph = parse_and_compile_any(bytes)?;
let (graph_id, revision) = graph.identity();
if self
.latest_revisions
.get(graph_id)
.is_some_and(|latest| revision <= *latest)
{
return Err(GraphError::new(
"GRAPH_REVISION_CONFLICT",
"revision must increase",
));
}
let i = if let Some(i) = self
.slots
@@ -84,10 +96,26 @@ impl Registry {
generation: self.slots[i].generation,
};
let summary = graph.summary(id.into());
self.latest_revisions.insert(graph_id.to_owned(), revision);
self.slots[i].value = Some(graph);
Ok((id, summary))
}
pub fn get(&self, id: CompiledGraphId) -> Result<&CompiledGraph, GraphError> {
match self
.slots
.get(id.slot as usize)
.filter(|s| s.generation == id.generation)
.and_then(|s| s.value.as_ref())
.ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))?
{
RegisteredGraph::V1(g) => Ok(g),
RegisteredGraph::V2(_) => Err(GraphError::new(
"GRAPH_EXECUTION_UNSUPPORTED",
"schemaVersion 2 activation is unavailable until Phase 4",
)),
}
}
pub fn get_registered(&self, id: CompiledGraphId) -> Result<&RegisteredGraph, GraphError> {
self.slots
.get(id.slot as usize)
.filter(|s| s.generation == id.generation)
@@ -95,7 +123,7 @@ impl Registry {
.ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))
}
pub fn contains(&self, id: CompiledGraphId) -> bool {
self.get(id).is_ok()
self.get_registered(id).is_ok()
}
pub fn drop_graph(&mut self, id: CompiledGraphId) -> Result<(), GraphError> {
let s = self
+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]
fn registry_revision_replaces_in_place() {
let mut r = Registry::new(1);
fn registry_revision_creates_immutable_handle() {
let mut r = Registry::new(2);
let (a, _) = r.compile(&empty("g", 1)).unwrap();
let (b, _) = r.compile(&empty("g", 2)).unwrap();
assert_eq!(a, b);
assert_eq!(r.get(a).unwrap().revision, 2);
assert_ne!(a, b);
assert_eq!(r.get(a).unwrap().revision, 1);
assert_eq!(r.get(b).unwrap().revision, 2);
}
#[test]
fn registry_revision_conflict() {
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);
}
+470 -58
View File
@@ -1,6 +1,9 @@
use std::mem::size_of;
use crate::render_data::{MeshHandle, PipelineKey, RenderData, RenderFlags};
use crate::{
render_data::{MaterialKey, MeshHandle, PipelineKey, RenderFlags},
renderer::scene_frame::SceneFramePlan,
};
use bytemuck::{Pod, Zeroable};
#[repr(C)]
@@ -15,10 +18,38 @@ pub struct GpuInstance {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DrawItem {
pub pipeline: PipelineKey,
pub material: MaterialKey,
pub mesh: MeshHandle,
pub indices: std::ops::Range<u32>,
pub base_vertex: i32,
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)]
@@ -26,30 +57,60 @@ pub struct GpuScenePlan {
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub uvs: Vec<[f32; 2]>,
pub tangents: Vec<[f32; 4]>,
pub indices: Vec<u32>,
pub instances: Vec<GpuInstance>,
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 {
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 meshes: Vec<_> = data
.meshes()
.filter(|(_, mesh)| mesh.flags.contains(RenderFlags::VISIBLE))
.collect();
meshes.sort_by_key(|(handle, mesh)| {
(mesh.pipeline.get(), handle.slot(), handle.generation())
let mut meshes: Vec<_> = data.meshes.iter().collect();
meshes.sort_by_key(|mesh| {
(
mesh.pipeline.get(),
mesh.material.get(),
mesh.handle.slot(),
mesh.handle.generation(),
)
});
let streams = data.streams();
for (handle, mesh) in meshes {
let mut occurrences: Vec<_> = data
.instances()
.filter(|(_, instance)| {
instance.mesh == handle && instance.flags.contains(RenderFlags::VISIBLE)
})
for mesh in meshes {
let occurrences: Vec<_> = data.mesh_occurrence_indices[mesh.occurrence_range.clone()]
.iter()
.map(|&index| &data.occurrences[index])
.collect();
occurrences.sort_by_key(|(handle, _)| (handle.slot(), handle.generation()));
if occurrences.is_empty() {
continue;
}
@@ -59,23 +120,25 @@ impl GpuScenePlan {
.checked_add(mesh.geometry.vertex_count as usize)
.ok_or("vertex range overflow")?;
plan.positions.extend_from_slice(
streams
.positions
data.positions
.get(source_start..source_end)
.ok_or("invalid vertex range")?,
);
plan.normals.extend_from_slice(
streams
.normals
data.normals
.get(source_start..source_end)
.ok_or("invalid normal range")?,
);
plan.uvs.extend_from_slice(
streams
.uvs
data.uvs
.get(source_start..source_end)
.ok_or("invalid uv range")?,
);
plan.tangents.extend_from_slice(
data.tangents
.get(source_start..source_end)
.ok_or("invalid tangent range")?,
);
let index_start =
u32::try_from(plan.indices.len()).map_err(|_| "index start exceeds u32")?;
let source_index = mesh.geometry.index_start as usize;
@@ -83,20 +146,24 @@ impl GpuScenePlan {
.checked_add(mesh.geometry.index_count as usize)
.ok_or("index range overflow")?;
plan.indices.extend_from_slice(
data.indices()
data.indices
.get(source_index..source_index_end)
.ok_or("invalid index range")?,
);
let instance_start =
u32::try_from(plan.instances.len()).map_err(|_| "instance start exceeds u32")?;
for (_, instance) in occurrences {
for instance in occurrences {
let instance_start = u32::try_from(plan.instances.len())
.map_err(|_| "instance start exceeds u32")?;
let m = &instance.model;
let determinant = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2])
- m[1][0] * (m[0][1] * m[2][2] - m[2][1] * m[0][2])
+ m[2][0] * (m[0][1] * m[1][2] - m[1][1] * m[0][2]);
plan.instances.push(GpuInstance {
model: instance.model,
normal_0: [
instance.normal[0][0],
instance.normal[0][1],
instance.normal[0][2],
0.0,
if determinant < 0.0 { -1.0 } else { 1.0 },
],
normal_1: [
instance.normal[1][0],
@@ -111,19 +178,41 @@ impl GpuScenePlan {
0.0,
],
});
let base_vertex =
i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?;
let end = index_start
.checked_add(mesh.geometry.index_count)
.ok_or("draw index range overflow")?;
let effective_visible = mesh.flags.contains(RenderFlags::VISIBLE)
&& instance.flags.contains(RenderFlags::VISIBLE);
plan.local_aabbs.push(GpuLocalAabb {
min: [mesh.aabb.min[0], mesh.aabb.min[1], mesh.aabb.min[2], 0.],
max: [mesh.aabb.max[0], mesh.aabb.max[1], mesh.aabb.max[2], 0.],
});
plan.effective_visibility.push(effective_visible as u32);
plan.draw_metadata.push(DrawSlotMetadata {
index_count: mesh.geometry.index_count,
first_index: index_start,
base_vertex,
instance_index: instance_start,
});
plan.commands.push(DrawIndexedIndirect {
index_count: mesh.geometry.index_count,
instance_count: 0,
first_index: index_start,
base_vertex,
first_instance: 0,
});
plan.draws.push(DrawItem {
pipeline: mesh.pipeline,
material: mesh.material,
mesh: mesh.handle,
indices: index_start..end,
base_vertex,
instances: instance_start..instance_start + 1,
effective_visible,
});
}
plan.draws.push(DrawItem {
pipeline: mesh.pipeline,
mesh: handle,
indices: index_start
..index_start
.checked_add(mesh.geometry.index_count)
.ok_or("draw index range overflow")?,
base_vertex: i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?,
instances: instance_start
..u32::try_from(plan.instances.len())
.map_err(|_| "instance end exceeds u32")?,
});
}
Ok(plan)
}
@@ -148,7 +237,7 @@ pub fn required_buffer_capacity(
Ok(grown.min(maximum))
}
pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 4] {
pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 5] {
const INSTANCE_ATTRIBUTES: [wgpu::VertexAttribute; 7] = wgpu::vertex_attr_array![3 => Float32x4, 4 => Float32x4, 5 => Float32x4, 6 => Float32x4, 7 => Float32x4, 8 => Float32x4, 9 => Float32x4];
[
wgpu::VertexBufferLayout {
@@ -171,6 +260,11 @@ pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 4] {
step_mode: wgpu::VertexStepMode::Instance,
attributes: &INSTANCE_ATTRIBUTES,
},
wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![10 => Float32x4],
},
]
}
@@ -183,12 +277,45 @@ pub struct BufferSlot {
#[derive(Default)]
pub struct GpuSceneCache {
revision: Option<u64>,
query: Option<crate::render_graph::MeshQueryRuntimeKeyV2>,
pub positions: BufferSlot,
pub normals: BufferSlot,
pub uvs: BufferSlot,
pub tangents: BufferSlot,
pub indices: BufferSlot,
pub instances: BufferSlot,
pub local_aabbs: BufferSlot,
pub effective_visibility: BufferSlot,
pub draw_metadata: BufferSlot,
pub frustum_flags: BufferSlot,
pub indirect_commands: BufferSlot,
pub draws: Vec<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 {
@@ -196,15 +323,34 @@ impl GpuSceneCache {
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
data: &RenderData,
data: &SceneFramePlan,
) -> Result<(), String> {
if self.revision == Some(data.revision()) {
self.upload_with_query(
device,
queue,
data,
crate::render_graph::MeshQueryRuntimeKeyV2 {
visible: crate::render_graph::TriStatePredicate::RequiredTrue,
frustum_culled: crate::render_graph::TriStatePredicate::Any,
},
)
}
pub fn upload_with_query(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
data: &SceneFramePlan,
query: crate::render_graph::MeshQueryRuntimeKeyV2,
) -> Result<(), String> {
if self.revision == Some(data.revision) && self.query == Some(query) {
return Ok(());
}
let plan = GpuScenePlan::build(data).map_err(str::to_owned)?;
let plan = GpuScenePlan::build_with_query(data, query).map_err(str::to_owned)?;
if plan.draws.is_empty() {
self.draws.clear();
self.revision = Some(data.revision());
self.revision = Some(data.revision);
self.query = Some(query);
return Ok(());
}
let maximum = device.limits().max_buffer_size;
@@ -218,18 +364,30 @@ impl GpuSceneCache {
bytes(&plan.positions)?,
bytes(&plan.normals)?,
bytes(&plan.uvs)?,
bytes(&plan.tangents)?,
bytes(&plan.indices)?,
bytes(&plan.instances)?,
bytes(&plan.local_aabbs)?,
bytes(&plan.effective_visibility)?,
bytes(&plan.draw_metadata)?,
bytes(&plan.effective_visibility)?,
bytes(&plan.commands)?,
];
let old = [
self.positions.capacity,
self.normals.capacity,
self.uvs.capacity,
self.tangents.capacity,
self.indices.capacity,
self.instances.capacity,
self.local_aabbs.capacity,
self.effective_visibility.capacity,
self.draw_metadata.capacity,
self.frustum_flags.capacity,
self.indirect_commands.capacity,
];
let mut capacities = [0; 5];
for i in 0..5 {
let mut capacities = [0; 11];
for i in 0..11 {
capacities[i] =
required_buffer_capacity(old[i], required[i], maximum).map_err(str::to_owned)?;
}
@@ -237,18 +395,30 @@ impl GpuSceneCache {
wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::INDEX,
wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::INDEX,
wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDIRECT,
];
let labels = [
"scene positions",
"scene normals",
"scene uvs",
"scene tangents",
"scene indices",
"scene instances",
"scene local aabbs",
"scene effective visibility",
"scene draw metadata",
"scene frustum flags",
"scene indirect commands",
];
let mut replacements: [Option<wgpu::Buffer>; 5] = Default::default();
for i in 0..5 {
let mut replacements: [Option<wgpu::Buffer>; 11] = Default::default();
for i in 0..11 {
if capacities[i] != old[i] {
replacements[i] = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some(labels[i]),
@@ -262,8 +432,14 @@ impl GpuSceneCache {
&mut self.positions,
&mut self.normals,
&mut self.uvs,
&mut self.tangents,
&mut self.indices,
&mut self.instances,
&mut self.local_aabbs,
&mut self.effective_visibility,
&mut self.draw_metadata,
&mut self.frustum_flags,
&mut self.indirect_commands,
];
for (i, slot) in slots.into_iter().enumerate() {
if let Some(buffer) = replacements[i].take() {
@@ -275,15 +451,27 @@ impl GpuSceneCache {
bytemuck::cast_slice(&plan.positions),
bytemuck::cast_slice(&plan.normals),
bytemuck::cast_slice(&plan.uvs),
bytemuck::cast_slice(&plan.tangents),
bytemuck::cast_slice(&plan.indices),
bytemuck::cast_slice(&plan.instances),
bytemuck::cast_slice(&plan.local_aabbs),
bytemuck::cast_slice(&plan.effective_visibility),
bytemuck::cast_slice(&plan.draw_metadata),
bytemuck::cast_slice(&plan.effective_visibility),
bytemuck::cast_slice(&plan.commands),
];
let slots = [
&self.positions,
&self.normals,
&self.uvs,
&self.tangents,
&self.indices,
&self.instances,
&self.local_aabbs,
&self.effective_visibility,
&self.draw_metadata,
&self.frustum_flags,
&self.indirect_commands,
];
for (slot, contents) in slots.into_iter().zip(contents) {
if !contents.is_empty() {
@@ -295,15 +483,205 @@ impl GpuSceneCache {
}
}
self.draws = plan.draws;
self.revision = Some(data.revision());
self.rebuild_compute(device)?;
self.revision = Some(data.revision);
self.query = Some(query);
Ok(())
}
fn rebuild_compute(&mut self, device: &wgpu::Device) -> Result<(), String> {
if self.draws.is_empty() {
self.compute = None;
return Ok(());
}
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("scene culling compute"),
source: wgpu::ShaderSource::Wgsl(include_str!("culling.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("scene culling layout"),
entries: &(0..7)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: if binding == 0 {
wgpu::BufferBindingType::Uniform
} else {
wgpu::BufferBindingType::Storage {
read_only: binding < 5,
}
},
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
})
.collect::<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)]
mod tests {
use super::*;
use crate::render_data::{MeshCreateInfo, RenderDataConfig, IDENTITY_MODEL_TRANSFORM};
#[test]
fn mesh_query_source_guards_optional_flag_buffer_reads() {
let source = include_str!("culling.wgsl");
let visible_guard = source.find("if (params.visible_predicate != 0u)").unwrap();
let visible_load = source.find("matches(authored_visible[i]").unwrap();
let frustum_guard = source.find("if (params.frustum_predicate != 0u)").unwrap();
let frustum_load = source.find("matches(frustum_flags[i]").unwrap();
assert!(visible_guard < visible_load && frustum_guard < frustum_load);
for binding in 0..=6 {
assert!(source.contains(&format!("@binding({binding})")));
}
}
#[test]
fn effective_visibility_handles_every_mesh_instance_combination() {
use crate::render_graph::TriStatePredicate::{Any, RequiredFalse, RequiredTrue};
for (mesh, instance, effective) in [
(false, false, false),
(false, true, false),
(true, false, false),
(true, true, true),
] {
let flags = |visible| {
if visible {
RenderFlags::VISIBLE
} else {
RenderFlags::NONE
}
};
assert!(visibility_matches(Any, flags(mesh), flags(instance)));
assert_eq!(
visibility_matches(RequiredTrue, flags(mesh), flags(instance)),
effective
);
assert_eq!(
visibility_matches(RequiredFalse, flags(mesh), flags(instance)),
!effective
);
}
}
use crate::render_data::{
MeshCreateInfo, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM,
};
#[test]
fn instance_is_112_bytes_and_padding_is_zero() {
assert_eq!(size_of::<GpuInstance>(), 112);
@@ -321,6 +699,28 @@ mod tests {
assert!(required_buffer_capacity(0, 33, 32).is_err());
}
#[test]
fn mirrored_model_stores_negative_determinant_sign_in_padding() {
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
let mut mirrored = IDENTITY_MODEL_TRANSFORM;
mirrored[0][0] = -1.0;
data.create_mesh(MeshCreateInfo {
positions: &[[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]],
normals: &[[0., 0., 1.]; 3],
tangents: &[[1., 0., 0., 1.]; 3],
uvs: &[[0., 0.]; 3],
indices: &[0, 1, 2],
pipeline: PipelineKey::new(0),
material: MaterialKey::DEFAULT,
flags: RenderFlags::VISIBLE,
default_instance_flags: RenderFlags::VISIBLE,
default_transform: mirrored,
})
.unwrap();
let frame = crate::renderer::scene_frame::SceneFramePlan::build(&data).unwrap();
let plan = GpuScenePlan::build(&frame).unwrap();
assert_eq!(plan.instances[0].normal_0[3], -1.0);
}
#[test]
fn capacity_reuses_and_layout_matches_shader_contract() {
assert_eq!(required_buffer_capacity(16, 12, 32), Ok(16));
assert_eq!(required_buffer_capacity(0, 1, 32), Ok(1));
@@ -330,7 +730,7 @@ mod tests {
.iter()
.map(|layout| layout.array_stride)
.collect::<Vec<_>>(),
[12, 12, 8, 112]
[12, 12, 8, 112, 16]
);
assert_eq!(
layouts[3]
@@ -342,7 +742,7 @@ mod tests {
);
}
#[test]
fn plan_orders_pipelines_skips_hidden_and_uses_local_indices() {
fn plan_is_canonical_and_predicate_independent() {
let mut data = RenderData::new(RenderDataConfig {
initial_vertices: 0,
initial_indices: 0,
@@ -359,9 +759,11 @@ mod tests {
data.create_mesh(MeshCreateInfo {
positions: &p,
normals: &n,
tangents: &[[1., 0., 0., 1.]; 3],
uvs: &u,
indices: &i,
pipeline: PipelineKey::new(pipeline),
material: crate::render_data::MaterialKey::DEFAULT,
flags: RenderFlags::VISIBLE,
default_instance_flags: if visible {
RenderFlags::VISIBLE
@@ -377,18 +779,28 @@ mod tests {
let low = add(2, true);
data.create_instance(low.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE)
.unwrap();
let plan = GpuScenePlan::build(&data).unwrap();
let frame = crate::renderer::scene_frame::SceneFramePlan::build(&data).unwrap();
let plan = GpuScenePlan::build(&frame).unwrap();
assert_eq!(
plan.draws
.iter()
.map(|d| d.pipeline.get())
.collect::<Vec<_>>(),
vec![2, 9]
vec![0, 2, 2, 9]
);
assert_eq!(plan.draws[0].base_vertex, 0);
assert!(!plan.draws[0].effective_visible);
assert_eq!(plan.draws[1].base_vertex, 3);
assert_eq!(plan.draws[0].instances, 0..2);
assert_eq!(plan.indices, [0, 1, 2, 0, 1, 2]);
assert_eq!(high.mesh, plan.draws[1].mesh);
assert_eq!(plan.draws[1].instances, 1..2);
assert_eq!(plan.draws[2].instances, 2..3);
assert_eq!(plan.indices, [0, 1, 2, 0, 1, 2, 0, 1, 2]);
assert_eq!(high.mesh, plan.draws[3].mesh);
assert_eq!(size_of::<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]
);
}
}
+1155 -233
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::{
camera::Camera,
render_data::RenderData,
renderer::{self, GpuResources},
renderer::{self, PipelineLibrary},
};
pub struct UniformResource {
@@ -76,13 +76,14 @@ impl FrameMetadata {
pub trait Scene: Sized {
fn setup(
context: &renderer::RendererContext,
resources: &mut GpuResources,
resources: &mut PipelineLibrary,
data: &mut RenderData,
) -> Self;
fn bind_groups(&self) -> &[wgpu::BindGroup];
fn handle_mouse_click(&mut self, x: f32, y: f32);
fn handle_zoom(&mut self, delta_y: f32);
fn handle_orbit(&mut self, dx: f32, dy: f32);
fn handle_pan(&mut self, dx: f32, dy: f32, viewport_height: f32);
fn set_camera_depth_range(&mut self, near: f32, far: f32);
fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3);
fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> {
@@ -91,6 +92,9 @@ pub trait Scene: Sized {
fn camera_mut(&mut self) -> Option<&mut Camera> {
None
}
fn frustum_planes(&mut self) -> Option<Result<[[f32; 4]; 6], crate::camera::FrustumError>> {
self.camera_mut().map(|camera| camera.frustum_planes())
}
fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> {
None
}
@@ -103,7 +107,7 @@ pub trait Scene: Sized {
}
self.write_uniforms(queue);
}
fn update(&mut self, context: &renderer::RendererContext) {
fn update_cpu(&mut self) {
let position = match self.camera_mut() {
Some(c) => c.position(),
None => return,
@@ -112,7 +116,6 @@ pub trait Scene: Sized {
f.time = js_sys::Date::now() as f32 * 0.001;
f.set_camera_position(position)
}
self.write_uniforms(&context.queue);
}
fn write_uniforms(&mut self, queue: &wgpu::Queue) {
let frame = self.frame_metadata_mut().copied();
+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));
}
}
+193 -22
View File
@@ -1,7 +1,7 @@
//! Triple-buffered, immutable packed scene snapshot shared with JavaScript.
use std::sync::atomic::{AtomicU32, Ordering};
use crate::render_data::{affine_world_aabb, RenderData, RenderFlags};
use crate::{render_data::RenderFlags, renderer::scene_frame::SceneFramePlan};
pub const MAGIC: u32 = u32::from_le_bytes(*b"YSNP");
pub const BLOB_MAGIC: u32 = u32::from_le_bytes(*b"RDS1");
@@ -94,11 +94,11 @@ impl SharedSnapshot {
}
/// Packs and publishes if `data` changed. Returns the newly published data epoch.
pub fn publish(&mut self, data: &RenderData) -> Result<Option<u32>, u32> {
pub fn publish(&mut self, data: &SceneFramePlan) -> Result<Option<u32>, u32> {
if self.control.header[6].load(Ordering::Acquire) == FAILED {
return Err(self.control.header[14].load(Ordering::Relaxed));
}
if self.last_revision == Some(data.revision()) {
if self.last_revision == Some(data.revision) {
return Ok(None);
}
let slot = match self.claim_slot() {
@@ -116,7 +116,7 @@ impl SharedSnapshot {
result
}
fn publish_claimed(&mut self, slot: usize, data: &RenderData) -> Result<Option<u32>, u32> {
fn publish_claimed(&mut self, slot: usize, data: &SceneFramePlan) -> Result<Option<u32>, u32> {
let epoch = self.next_epoch;
let next_epoch = epoch.checked_add(1).ok_or(ERROR_OVERFLOW)?;
let bytes = pack(data, epoch)?;
@@ -136,7 +136,7 @@ impl SharedSnapshot {
let ptr = self.blocks[slot].as_ptr() as usize;
let ptr32 = u32::try_from(ptr).map_err(|_| ERROR_OVERFLOW)?;
let length = u32::try_from(bytes.len()).map_err(|_| ERROR_OVERFLOW)?;
let revision = data.revision();
let revision = data.revision;
let d = &self.control.slots[slot].0;
let values = [
epoch,
@@ -145,8 +145,8 @@ impl SharedSnapshot {
length,
revision as u32,
(revision >> 32) as u32,
data.mesh_count(),
data.instance_count(),
data.meshes.len() as u32,
data.occurrences.len() as u32,
SCHEMA,
SNAPSHOT_HEADER_BYTES as u32,
0,
@@ -244,9 +244,9 @@ fn wasm_pages(minimum_end: usize) -> Result<u32, u32> {
.map_err(|_| ERROR_OVERFLOW)
}
fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
let meshes: Vec<_> = data.meshes().collect();
let instances: Vec<_> = data.instances().collect();
fn pack(data: &SceneFramePlan, epoch: u32) -> Result<Vec<u8>, u32> {
let meshes = &data.meshes;
let instances = &data.occurrences;
let strides = [4usize, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4];
let components = [1u32, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1];
let scalar = [1u32, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1];
@@ -268,7 +268,7 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
let put32 = |out: &mut [u8], at: usize, value: u32| {
out[at..at + 4].copy_from_slice(&value.to_le_bytes())
};
let revision = data.revision();
let revision = data.revision;
for (i, value) in [
BLOB_MAGIC,
SCHEMA,
@@ -310,10 +310,14 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
put32(&mut out, at + j * 4, value);
}
}
for (dense, (handle, mesh)) in meshes.iter().enumerate() {
for (i, value) in [handle.slot(), handle.generation(), mesh.flags.bits()]
.into_iter()
.enumerate()
for (dense, mesh) in meshes.iter().enumerate() {
for (i, value) in [
mesh.handle.slot(),
mesh.handle.generation(),
mesh.flags.bits(),
]
.into_iter()
.enumerate()
{
put32(&mut out, offsets[i] + dense * 4, value);
}
@@ -330,12 +334,14 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
);
}
}
for (dense, (handle, instance)) in instances.iter().enumerate() {
let mesh = data.mesh(instance.mesh).ok_or(ERROR_INVARIANT)?;
let world = affine_world_aabb(mesh.aabb, instance.model).map_err(|_| ERROR_INVARIANT)?;
for (dense, instance) in instances.iter().enumerate() {
let mesh = data
.meshes
.get(instance.mesh_index)
.ok_or(ERROR_INVARIANT)?;
for (i, value) in [
handle.slot(),
handle.generation(),
instance.handle.slot(),
instance.handle.generation(),
instance.mesh.slot(),
instance.mesh.generation(),
instance.flags.bits(),
@@ -356,12 +362,12 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
put32(
&mut out,
offsets[11] + dense * 12 + i * 4,
world.min[i].to_bits(),
instance.world_aabb.min[i].to_bits(),
);
put32(
&mut out,
offsets[12] + dense * 12 + i * 4,
world.max[i].to_bits(),
instance.world_aabb.max[i].to_bits(),
);
}
put32(
@@ -387,6 +393,9 @@ const _: [(); 256] = [(); std::mem::size_of::<SnapshotControl>()];
#[cfg(test)]
mod tests {
use super::*;
use crate::render_data::{
MeshCreateInfo, PipelineKey, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM,
};
#[test]
fn exact_control_layout_and_initial_values() {
@@ -417,4 +426,166 @@ mod tests {
WRITING
);
}
#[test]
fn producer_blob_abi_matches_schema_exactly() {
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
let create = |data: &mut RenderData, flags, x: f32| {
data.create_mesh(MeshCreateInfo {
positions: &[[x, 0., 0.], [x + 2., 0., 0.], [x, 3., 0.]],
normals: &[[0., 0., 1.]; 3],
tangents: &[[1., 0., 0., 1.]; 3],
uvs: &[[0., 0.]; 3],
indices: &[0, 1, 2],
pipeline: PipelineKey::new(7),
material: crate::render_data::MaterialKey::DEFAULT,
flags,
default_instance_flags: RenderFlags::VISIBLE,
default_transform: IDENTITY_MODEL_TRANSFORM,
})
.unwrap()
};
let visible = create(&mut data, RenderFlags::VISIBLE, -1.0);
let hidden = create(&mut data, RenderFlags::NONE, 10.0);
let mut model = IDENTITY_MODEL_TRANSFORM;
model[0][0] = 2.0;
model[1][1] = 0.5;
model[3][0] = 4.0;
model[3][1] = -2.0;
let extra = data
.create_instance(hidden.mesh, model, RenderFlags::NONE)
.unwrap();
let plan = SceneFramePlan::build(&data).unwrap();
let epoch = 0x1234_5678;
let blob = pack(&plan, epoch).unwrap();
let word = |at: usize| u32::from_le_bytes(blob[at..at + 4].try_into().unwrap());
assert_eq!(word(0), BLOB_MAGIC);
assert_eq!(word(4), SCHEMA);
assert_eq!(word(8), SNAPSHOT_HEADER_BYTES as u32);
assert_eq!(word(12), blob.len() as u32);
assert_eq!(word(16), epoch);
assert_eq!(word(20), plan.revision as u32);
assert_eq!(word(24), (plan.revision >> 32) as u32);
assert_eq!(word(28), STREAMS as u32);
assert_eq!(word(32), SNAPSHOT_HEADER_BYTES as u32);
assert_eq!(word(36), DESCRIPTOR_BYTES as u32);
assert_eq!(word(40), 2);
assert_eq!(word(44), 3);
assert_eq!(word(48), 0x0102_0304);
assert_eq!(word(52), SCHEMA_FLAGS);
let strides = [4, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4];
let scalars = [1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1];
let components = [1, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1];
let counts = [2usize; 5]
.into_iter()
.chain([3usize; 9])
.collect::<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);}}
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 class LoadoutError extends Error{constructor(code,message){super(message);this.name="LoadoutError";this.code=code;}}
export const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},manor:{label:"The Manor"},sponza:{label:"Sponza"}});
export const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},materials:{label:"Phase 6 deterministic PBR gallery"},manor:{label:"The Manor"},sponza:{label:"Sponza"}});
const assetUrls=Object.freeze({manor:new URL("./themanor.glb",import.meta.url),sponza:new URL("./sponza.glb",import.meta.url)});
export async function loadDemoLoadout(id,{signal,fetchImpl=fetch}={}){
if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry());
if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry());if(id==="materials")return createMaterialGalleryGlb();
const url=assetUrls[id];if(!url)throw new LoadoutError("LOADOUT_UNKNOWN",`Unknown loadout: ${id}`);
let response;try{response=await fetchImpl(url,{signal});}catch(error){if(error?.name==="AbortError")throw error;throw new LoadoutError("LOADOUT_FETCH_FAILED",`Could not fetch ${id}: ${error?.message||"network error"}`);}
if(!response.ok)throw new LoadoutError("LOADOUT_HTTP",`Could not fetch ${id}: HTTP ${response.status}`);const buffer=await response.arrayBuffer();if(isGitLfsPointer(buffer))throw new LoadoutError("LOADOUT_LFS_POINTER",`${id} is a Git LFS pointer; hydrate repository assets first`);return buffer;
+199 -4
View File
@@ -1,5 +1,200 @@
<!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>
<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:.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%}}
</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>
<html lang="en">
<head>
<meta charset="UTF-8" />
<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 { renderGraphPresets } from "./render-graph/presets.js";
let renderer,editor,controller,assetAbort,busy=false,cleaned=false;
let unsubscribeController=()=>{},unsubscribeSnapshots=()=>{};
const listeners=[];
const on=(target,type,fn)=>{target.addEventListener(type,fn);listeners.push(()=>target.removeEventListener(type,fn));};
const status=message=>{const node=document.querySelector("#demo-status");if(node)node.textContent=message;};
const sameId=(a,b)=>Array.isArray(a)&&Array.isArray(b)&&a[0]===b[0]&&a[1]===b[1];
const state={loadout:"cubes",graph:"authored",compiled:{},telemetry:null};
let renderer,
editor,
controller,
assetAbort,
busy = false,
cleaned = false;
let unsubscribeController = () => {},
unsubscribeSnapshots = () => {};
const listeners = [];
const on = (target, type, fn) => {
target.addEventListener(type, fn);
listeners.push(() => target.removeEventListener(type, fn));
};
const status = (message) => {
const node = document.querySelector("#demo-status");
if (node) node.textContent = message;
};
const sameId = (a, b) =>
Array.isArray(a) && Array.isArray(b) && a[0] === b[0] && a[1] === b[1];
const state = {
loadout: "cubes",
graph: "authored",
compiled: {},
telemetry: null,
};
export const profileRequested = (search) =>
new URLSearchParams(search).get("profile") === "1";
const profileEnabled = profileRequested(location.search);
function installProfileMenu() {
if (!profileEnabled) return;
const menu = document.createElement("details");
menu.id = "profile-menu";
menu.innerHTML =
'<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){
state.telemetry=telemetry;
document.documentElement.dataset.phase8State=JSON.stringify({activeLoadout:state.loadout,activeGraph:state.graph,renderDataRevision:telemetry.revision,renderMode:telemetry.renderMode,activeCompiledId:telemetry.activeCompiledId,activeCompiledGraph:telemetry.activeCompiledGraph,activeCompiledRevision:telemetry.activeCompiledRevision,graphPasses:telemetry.graphPasses,draws:telemetry.draws,instances:telemetry.instances,indices:telemetry.indices,framingRadius:telemetry.framingRadius,gpuError:telemetry.gpuError});
function publish(telemetry) {
state.telemetry = telemetry;
document.documentElement.dataset.phase8State = JSON.stringify({
activeLoadout: state.loadout,
activeGraph: state.graph,
renderDataRevision: telemetry.revision,
renderMode: telemetry.renderMode,
activeCompiledId: telemetry.activeCompiledId,
activeCompiledGraph: telemetry.activeCompiledGraph,
activeCompiledRevision: telemetry.activeCompiledRevision,
activeCompiledSchemaVersion: telemetry.activeCompiledSchemaVersion,
graphPasses: telemetry.graphPasses,
graphExecutions: telemetry.graphExecutions,
graphTextureSlots: telemetry.graphTextureSlots,
draws: telemetry.draws,
instances: telemetry.instances,
indices: telemetry.indices,
framingRadius: telemetry.framingRadius,
gpuError: telemetry.gpuError,
});
}
function waitTelemetry(predicate,timeout=30000){
const current=renderer?.telemetry;if(current&&predicate(current))return Promise.resolve(current);
return new Promise((resolve,reject)=>{let timer;const done=()=>{clearTimeout(timer);removeEventListener("renderer-frame",frame);};const frame=e=>{if(predicate(e.detail)){done();resolve(e.detail);}};timer=setTimeout(()=>{done();reject(new Error("Telemetry confirmation timed out"));},timeout);onAbort=()=>{done();reject(new RendererError("DISPOSED"));};addEventListener("renderer-frame",frame);timer.unref?.();});
function waitTelemetry(predicate, timeout = 30000) {
const current = renderer?.telemetry;
if (current && predicate(current)) return Promise.resolve(current);
return new Promise((resolve, reject) => {
let timer;
const done = () => {
clearTimeout(timer);
removeEventListener("renderer-frame", frame);
};
const frame = (e) => {
if (predicate(e.detail)) {
done();
resolve(e.detail);
}
};
timer = setTimeout(() => {
done();
reject(new Error("Telemetry confirmation timed out"));
}, timeout);
onAbort = () => {
done();
reject(new RendererError("DISPOSED"));
};
addEventListener("renderer-frame", frame);
timer.unref?.();
});
}
let onAbort=()=>{};
async function transaction(label,operation,rollback){
if(busy||cleaned)return false;busy=true;document.querySelectorAll("select, #apply-graph").forEach(x=>x.disabled=true);status(label);
try{const telemetry=await operation();if(cleaned)return false;if(telemetry){publish(telemetry);status(`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`);}else status(`${state.loadout} · ${state.graph} · committed; telemetry pending`);return true;}
catch(error){if(!cleaned){try{await rollback?.();}catch(rollbackError){console.error("Phase 8 rollback failed",rollbackError);}console.error("Phase 8 transaction failed",error);status(`Failed · ${error?.code??error?.message??error}`);}return false;}
finally{busy=false;if(!cleaned){document.querySelectorAll("select").forEach(x=>x.disabled=false);const button=document.querySelector("#apply-graph");if(button)button.disabled=controller?.applying||!controller?.dirty;}}
let onAbort = () => {};
async function transaction(label, operation, rollback) {
if (busy || cleaned) return false;
busy = true;
document
.querySelectorAll("select, #apply-graph")
.forEach((x) => (x.disabled = true));
status(label);
try {
const telemetry = await operation();
if (cleaned) return false;
if (telemetry) {
publish(telemetry);
status(
`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`,
);
} else
status(
`${state.loadout} · ${state.graph} · committed; telemetry pending`,
);
return true;
} catch (error) {
if (!cleaned) {
try {
await rollback?.();
} catch (rollbackError) {
console.error("Phase 8 rollback failed", rollbackError);
}
console.error("Phase 8 transaction failed", error);
status(`Failed · ${error?.code ?? error?.message ?? error}`);
}
return false;
} finally {
busy = false;
if (!cleaned) {
document.querySelectorAll("select").forEach((x) => (x.disabled = false));
const button = document.querySelector("#apply-graph");
if (button) button.disabled = !controller?.canApply;
}
}
}
async function selectLoadout(next,select){
const previous=state.loadout,targetRevision=(renderer.telemetry?.revision??0)+1;assetAbort=new AbortController();
const ok=await transaction(`Loading ${next}`,async()=>{const glb=await loadDemoLoadout(next,{signal:assetAbort.signal});await renderer.replaceSceneGlb(glb,{framing:next==="sponza"?"interior":"exterior"});state.loadout=next;return waitTelemetry(x=>x.revision===targetRevision&&x.draws>0&&x.activeCompiledGraph===state.compiled[state.graph].graphId&&x.gpuError===false).catch(()=>null);});
assetAbort=undefined;if(!ok)select.value=previous;
async function selectLoadout(next, select) {
const previous = state.loadout,
targetRevision = (renderer.telemetry?.revision ?? 0) + 1;
assetAbort = new AbortController();
const ok = await transaction(`Loading ${next}`, async () => {
const glb = await loadDemoLoadout(next, { signal: assetAbort.signal });
await renderer.replaceSceneGlb(glb, {
framing: next === "sponza" ? "interior" : "exterior",
});
state.loadout = next;
return waitTelemetry(
(x) =>
x.revision === targetRevision &&
x.draws > 0 &&
x.activeCompiledGraph === state.compiled[state.graph].graphId &&
x.gpuError === false,
).catch(() => null);
});
assetAbort = undefined;
if (!ok) select.value = previous;
}
async function selectGraph(next,select){
const previous=state.graph,compiled=state.compiled[next];
const ok=await transaction(`Activating ${next}`,async()=>{await renderer.switchCompiledGraph(compiled.compiledId);state.graph=next;return waitTelemetry(x=>sameId(x.activeCompiledId,compiled.compiledId)&&x.activeCompiledGraph===compiled.graphId&&x.activeCompiledRevision===compiled.revision&&x.gpuError===false).catch(()=>null);},async()=>{state.graph=previous;select.value=previous;});
if(!ok)select.value=previous;
async function selectGraph(next, select) {
const previous = state.graph,
compiled = state.compiled[next];
const ok = await transaction(
`Activating ${next}`,
async () => {
await renderer.switchCompiledGraph(compiled.compiledId);
state.graph = next;
return waitTelemetry(
(x) =>
sameId(x.activeCompiledId, compiled.compiledId) &&
x.activeCompiledGraph === compiled.graphId &&
x.activeCompiledRevision === compiled.revision &&
x.gpuError === false,
).catch(() => null);
},
async () => {
state.graph = previous;
select.value = previous;
},
);
if (!ok) select.value = previous;
}
async function cleanup(){if(cleaned)return;cleaned=true;removeEventListener("pagehide",pagehide);assetAbort?.abort();onAbort();listeners.splice(0).forEach(fn=>fn());unsubscribeController();unsubscribeSnapshots();try{await editor?.destroy();}finally{renderer?.dispose();}}
const pagehide=()=>{void cleanup()};
async function cleanup() {
if (cleaned) return;
cleaned = true;
removeEventListener("pagehide", pagehide);
assetAbort?.abort();
onAbort();
listeners.splice(0).forEach((fn) => fn());
unsubscribeController();
unsubscribeSnapshots();
try {
await controller?.destroy();
await editor?.destroy();
} finally {
renderer?.dispose();
}
}
const pagehide = () => {
void cleanup();
};
async function start(){
addEventListener("pagehide",pagehide,{once:true});delete document.documentElement.dataset.phase8Ready;await wbg_init();if(cleaned)return;
renderer=new RendererClient(main());await renderer.ready;const nextEditor=await createRenderGraphEditor(document.querySelector("#graph-editor"));if(cleaned){await nextEditor.destroy();return}editor=nextEditor;
controller=new AuthoringController({renderer,getState:editor.getState});const apply=document.querySelector("#apply-graph"),graphStatus=document.querySelector("#graph-status"),loadoutSelect=document.querySelector("#loadout-select"),graphSelect=document.querySelector("#graph-select");
unsubscribeController=controller.subscribe(s=>{apply.disabled=busy||s.applying||!s.dirty;graphStatus.textContent=s.applying?"Applying…":s.dirty?"Unapplied changes":`Authored revision ${s.revision}`;});unsubscribeSnapshots=editor.onSnapshots(()=>controller.markDirty());
const authored=await controller.apply(adaptFxNodeSnapshot);state.compiled.authored={...authored,graphId:"demo_forward"};
for(const [name,preset] of Object.entries(renderGraphPresets)){const compiled=await renderer.compileGraph(preset);state.compiled[name]={...compiled,graphId:preset.graphId,revision:preset.revision};}
on(window,"renderer-frame",event=>{const expected=state.compiled[state.graph],telemetry=event.detail;if(expected&&telemetry.activeCompiledGraph===expected.graphId&&sameId(telemetry.activeCompiledId,expected.compiledId)&&telemetry.gpuError===false){publish(telemetry);if(!busy)status(`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`);}});
on(loadoutSelect,"change",()=>void selectLoadout(loadoutSelect.value,loadoutSelect));on(graphSelect,"change",()=>void selectGraph(graphSelect.value,graphSelect));
on(apply,"click",()=>{const previous=state.graph,previousAuthored=state.compiled.authored;void transaction("Applying authored graph…",async()=>{const compiled=await controller.apply(adaptFxNodeSnapshot);state.compiled.authored={...compiled,graphId:"demo_forward"};state.graph="authored";graphSelect.value="authored";return waitTelemetry(x=>sameId(x.activeCompiledId,compiled.compiledId)&&x.activeCompiledGraph==="demo_forward"&&x.activeCompiledRevision===compiled.revision&&x.gpuError===false).catch(()=>null);},async()=>{state.compiled.authored=previousAuthored;state.graph=previous;graphSelect.value=previous;controller.markDirty();});});
async function start() {
addEventListener("pagehide", pagehide, { once: true });
delete document.documentElement.dataset.phase8Ready;
await wbg_init();
if (cleaned) return;
renderer = new RendererClient(main(profileEnabled));
installProfileMenu();
await renderer.ready;
const nextEditor = await createRenderGraphEditor(
document.querySelector("#graph-editor"),
);
if (cleaned) {
await nextEditor.destroy();
return;
}
editor = nextEditor;
controller = new AuthoringController({
renderer,
adapt: adaptFxNodeSnapshot,
});
const apply = document.querySelector("#apply-graph"),
graphStatus = document.querySelector("#graph-status"),
loadoutSelect = document.querySelector("#loadout-select"),
graphSelect = document.querySelector("#graph-select");
unsubscribeController = controller.subscribe((s) => {
apply.disabled = busy || !s.canApply;
graphStatus.textContent = s.error
? `Invalid · ${s.error.code ?? s.error.message}`
: s.applying
? "Applying…"
: s.dirty
? s.staged
? "Ready to apply"
: "Validating…"
: `Authored revision ${s.revision}`;
});
unsubscribeSnapshots = editor.onSnapshots((snapshot) =>
controller.markDirty(snapshot),
);
controller.markDirty(await editor.getState());
const authored = await controller.apply();
state.compiled.authored = { ...authored, graphId: "authored_gpu_culling" };
for (const [name, preset] of Object.entries(renderGraphPresets)) {
const compiled = await renderer.compileGraph(preset);
state.compiled[name] = {
...compiled,
graphId: preset.graphId,
revision: preset.revision,
};
}
on(window, "renderer-frame", (event) => {
const expected = state.compiled[state.graph],
telemetry = event.detail;
if (
expected &&
telemetry.activeCompiledGraph === expected.graphId &&
sameId(telemetry.activeCompiledId, expected.compiledId) &&
telemetry.gpuError === false
) {
publish(telemetry);
if (!busy)
status(
`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`,
);
}
});
on(
loadoutSelect,
"change",
() => void selectLoadout(loadoutSelect.value, loadoutSelect),
);
on(
graphSelect,
"change",
() => void selectGraph(graphSelect.value, graphSelect),
);
on(apply, "click", () => {
const previous = state.graph,
previousAuthored = state.compiled.authored;
void transaction(
"Applying authored graph…",
async () => {
const compiled = await controller.apply();
state.compiled.authored = {
...compiled,
graphId: "authored_gpu_culling",
};
state.graph = "authored";
graphSelect.value = "authored";
return waitTelemetry(
(x) =>
sameId(x.activeCompiledId, compiled.compiledId) &&
x.activeCompiledGraph === "authored_gpu_culling" &&
x.activeCompiledRevision === compiled.revision &&
x.gpuError === false,
).catch(() => null);
},
async () => {
state.compiled.authored = previousAuthored;
state.graph = previous;
graphSelect.value = previous;
},
);
});
await editor.whenRendered();
const initialized=await transaction("Preparing procedural cubes…",async()=>{const targetRevision=(renderer.telemetry?.revision??0)+1;await renderer.replaceSceneGlb(await loadDemoLoadout("cubes"));await renderer.switchCompiledGraph(authored.compiledId);return waitTelemetry(x=>x.revision===targetRevision&&x.draws>0&&x.activeCompiledGraph==="demo_forward"&&x.activeCompiledRevision===authored.revision&&x.gpuError===false);});
if(!initialized)throw new Error("Initial demo transaction failed");document.documentElement.dataset.phase8Ready="true";
const initialized = await transaction(
"Preparing procedural cubes…",
async () => {
const targetRevision = (renderer.telemetry?.revision ?? 0) + 1;
await renderer.replaceSceneGlb(await loadDemoLoadout("cubes"));
await renderer.switchCompiledGraph(authored.compiledId);
return waitTelemetry(
(x) =>
x.revision === targetRevision &&
x.draws > 0 &&
x.activeCompiledGraph === "authored_gpu_culling" &&
x.activeCompiledRevision === authored.revision &&
x.gpuError === false,
);
},
);
if (!initialized) throw new Error("Initial demo transaction failed");
document.documentElement.dataset.phase8Ready = "true";
}
const startupError=error=>{if(cleaned)return;console.error("Phase 8 startup failed",error);status(`Startup failed · ${error?.code??error}`);void cleanup();};
if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>start().catch(startupError),{once:true});else start().catch(startupError);
const startupError = (error) => {
if (cleaned) return;
console.error("Phase 8 startup failed", error);
status(`Startup failed · ${error?.code ?? error}`);
void cleanup();
};
if (document.readyState === "loading")
document.addEventListener(
"DOMContentLoaded",
() => start().catch(startupError),
{ once: true },
);
else start().catch(startupError);
+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 {
constructor(code, details = {}) { super(code); this.name="AuthoringGraphError"; this.code=code; this.details=Object.freeze(details); }
}
const fail=(code,details)=>{throw new AuthoringGraphError(code,details)};
const object=v=>v !== null && typeof v === "object" && !Array.isArray(v);
const validId=v=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9_.-]*$/.test(v) && new TextEncoder().encode(v).length<=64;
const validSocketId=v=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9_.-]*:[A-Za-z][A-Za-z0-9_.-]*$/.test(v) && new TextEncoder().encode(v).length<=129;
const keysEqual=(a,b)=>a.length===b.length && a.every(x=>b.includes(x));
/** Validate hostile fxnode state and return an app-owned, layout-free projection. */
export function projectAuthoringSnapshot(raw) {
if(!object(raw)||!Array.isArray(raw.nodes)||!Array.isArray(raw.links)) fail("AUTHORING_SHAPE",{field:"snapshot"});
if(raw.graphId!==GRAPH_ID||raw.catalogVersion!==CATALOG_VERSION) fail("AUTHORING_CATALOG",{graphId:raw.graphId,catalogVersion:raw.catalogVersion});
const nodeIds=new Set(), socketIds=new Set(), byId=new Map(), byType=new Map();
for(const n of raw.nodes){
if(!object(n)||!validId(n.id)) fail("AUTHORING_ID",{kind:"node",id:n?.id});
if(nodeIds.has(n.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"node",id:n.id}); nodeIds.add(n.id);
const d=descriptors[n.typeId];
if(!d) fail("AUTHORING_NODE_TYPE",{nodeId:n.id,typeId:n.typeId});
if(n.known!==true) fail("AUTHORING_NODE_UNKNOWN",{nodeId:n.id});
if(n.typeVersion!==d.version) fail("AUTHORING_NODE_VERSION",{nodeId:n.id,expected:d.version,actual:n.typeVersion});
if(typeof n.muted!=="boolean") fail("AUTHORING_NODE_MUTED",{nodeId:n.id});
if(n.muted&&n.typeId!=="scene_forward") fail("AUTHORING_NODE_MUTED",{nodeId:n.id});
if(byType.has(n.typeId)) fail("AUTHORING_TOPOLOGY",{reason:"duplicate-type",typeId:n.typeId});
if(!Array.isArray(n.sockets)||!keysEqual(n.sockets.map(s=>s?.key),Object.keys(d.sockets))) fail("AUTHORING_SOCKET_SET",{nodeId:n.id});
const sockets={};
for(const s of n.sockets){ const expected=d.sockets[s.key];
if(!object(s)||!validSocketId(s.id)) fail("AUTHORING_ID",{kind:"socket",id:s?.id});
if(socketIds.has(s.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"socket",id:s.id}); socketIds.add(s.id);
if(s.direction!==expected[0]||s.dataType!==expected[1]) fail("AUTHORING_SOCKET",{nodeId:n.id,socket:s.key});
sockets[s.key]={id:s.id,direction:s.direction,type:s.dataType,nodeId:n.id};
}
const p=object(n.parameters)?n.parameters:null;
if(!p||!keysEqual(Object.keys(p),d.parameters)) fail("AUTHORING_PARAMETERS",{nodeId:n.id});
let parameters={};
if(n.typeId==="scene_forward"){
const c=p.clearColor, z=p.clearDepth;
if(!object(c)||c.kind!=="color"||!Array.isArray(c.value)||c.value.length!==4||c.value.some(v=>!Number.isFinite(v)||v<0||v>1)) fail("AUTHORING_PARAMETER",{parameter:"clearColor"});
if(!object(z)||z.kind!=="number"||!Number.isFinite(z.value)||z.value<0||z.value>1) fail("AUTHORING_PARAMETER",{parameter:"clearDepth"});
parameters={clearColor:[...c.value],clearDepth:z.value};
}
const projected={type:n.typeId,id:n.id,sockets,parameters,muted:n.muted}; byId.set(n.id,projected); byType.set(n.typeId,projected);
constructor(code, details = {}) {
super(code);
this.name = "AuthoringGraphError";
this.code = code;
this.details = Object.freeze({ ...details });
}
if(!keysEqual([...byType.keys()],Object.keys(descriptors))) fail("AUTHORING_TOPOLOGY",{reason:"node-set"});
const linkIds=new Set(), incoming=new Set(), links=[];
for(const l of raw.links){
if(!object(l)||!validId(l.id)) fail("AUTHORING_ID",{kind:"link",id:l?.id});
if(linkIds.has(l.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"link",id:l.id}); linkIds.add(l.id);
if(typeof l.muted!=="boolean") fail("AUTHORING_LINK",{linkId:l.id,reason:"muted"});
const from=byId.get(l.fromNodeId),to=byId.get(l.toNodeId),fs=from&&Object.values(from.sockets).find(s=>s.id===l.fromSocketId),ts=to&&Object.values(to.sockets).find(s=>s.id===l.toSocketId);
if(!fs||!ts||fs.direction!=="output"||ts.direction!=="input"||fs.type!==ts.type) fail("AUTHORING_LINK",{linkId:l.id});
if(incoming.has(ts.id)) fail("AUTHORING_LINK_INCOMING",{socketId:ts.id}); incoming.add(ts.id);
links.push({from:`${from.type}.${Object.keys(from.sockets).find(k=>from.sockets[k]===fs)}`,to:`${to.type}.${Object.keys(to.sockets).find(k=>to.sockets[k]===ts)}`,muted:l.muted});
}
const required=["surface_color.surface>scene_forward.color","depth32.depth>scene_forward.depth","scene_forward.result>present.surface"];
const active=links.filter(l=>!l.muted).map(l=>`${l.from}>${l.to}`).sort();
if(!keysEqual(active,required.sort())||links.length!==3) fail("AUTHORING_TOPOLOGY",{reason:"links"});
return Object.freeze({graphId:GRAPH_ID,clearColor:byType.get("scene_forward").parameters.clearColor,clearDepth:byType.get("scene_forward").parameters.clearDepth,passState:byType.get("scene_forward").muted?"disabled":"enabled"});
}
export function semanticProjectionToV1(p, revision=1){
if(!Number.isInteger(revision)||revision<1||revision>0xffffffff) fail("AUTHORING_REVISION",{revision});
const extent={kind:"surface_relative",width:{numerator:1,denominator:1},height:{numerator:1,denominator:1},depthOrArrayLayers:1};
return {schemaVersion:1,graphId:p.graphId,revision,resources:[
{id:"surface",version:0,residency:{kind:"external",source:"surface_color"},texture:{dimension:"d2",format:"surface",extent,mipLevelCount:1,sampleCount:1}},
{id:"depth",version:0,residency:{kind:"transient"},texture:{dimension:"d2",format:"depth32_float",extent,mipLevelCount:1,sampleCount:1}},
],passes:[{id:"forward",state:p.passState,executor:{key:"scene_forward",version:1},parameters:{},reads:[],writes:[
{binding:"color",resource:{id:"surface",version:0},access:{kind:"color_attachment",location:0,load:{op:"clear",value:p.clearColor},store:"store"}},
{binding:"depth",resource:{id:"depth",version:0},access:{kind:"depth_attachment",load:{op:"clear",value:p.clearDepth},store:"store"}},
]}],outputs:[{name:"present",resource:{id:"surface",version:0}}]};
const fail = (code, details) => {
throw new AuthoringGraphError(code, details);
};
const object = (value) =>
value !== null && typeof value === "object" && !Array.isArray(value);
const identifier = (value) =>
typeof value === "string" &&
/^[A-Za-z][A-Za-z0-9_.-]*$/.test(value) &&
new TextEncoder().encode(value).length <= 64;
const exactKeys = (value, keys) =>
object(value) &&
Object.keys(value).length === keys.length &&
keys.every((key) => Object.hasOwn(value, key));
const finiteJson = (value) =>
value === null ||
typeof value === "string" ||
typeof value === "boolean" ||
(typeof value === "number" && Number.isFinite(value)) ||
(Array.isArray(value) && value.every(finiteJson)) ||
(object(value) && Object.values(value).every(finiteJson));
const canonical = (value) =>
Array.isArray(value)
? value.map(canonical)
: object(value)
? Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonical(value[key])]),
)
: value;
const deepFreeze = (value) => {
if (value && typeof value === "object" && !Object.isFrozen(value)) {
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
}
return value;
};
const sourceMaps = new WeakMap();
export const getSourceMap = (ir) => sourceMaps.get(ir);
export const mapAuthoringDiagnostic = (ir, diagnostic) => {
const details = diagnostic?.details;
const path = [details?.path, diagnostic?.path, details?.field, diagnostic?.field]
.find((value) => typeof value === "string");
const map = getSourceMap(ir);
let match;
if (path && map)
for (const key of Object.keys(map))
if (
(path === key || path.startsWith(`${key}.`) || path.startsWith(`${key}[`)) &&
(!match || key.length > match.length)
)
match = key;
return deepFreeze({
name: diagnostic?.name,
code: diagnostic?.code,
message: details?.message ?? diagnostic?.message ?? diagnostic?.code,
details: details === undefined ? undefined : structuredClone(details),
path,
source: match ? structuredClone(map[match]) : undefined,
});
};
const mapValuePaths = (paths, path, source, value) => {
paths[path] = source;
if (Array.isArray(value))
value.forEach((child, index) => mapValuePaths(paths, `${path}[${index}]`, source, child));
else if (object(value))
for (const key of Object.keys(value))
mapValuePaths(paths, `${path}.${key}`, source, value[key]);
};
function parameterValue(raw, schema, nodeId, key) {
const expected = schema.type === "json" ? "json" : schema.type;
if (
!exactKeys(raw, ["kind", "value"]) ||
raw.kind !== expected ||
!finiteJson(raw.value)
)
fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
if (
(expected === "number" && typeof raw.value !== "number") ||
(expected === "string" && typeof raw.value !== "string") ||
(expected === "boolean" && typeof raw.value !== "boolean") ||
(expected === "json" && !finiteJson(raw.value))
)
fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
return canonical(structuredClone(raw.value));
}
export function adaptFxNodeSnapshot(raw, revision = 1) {
try {
const rootKeys = ["graphId", "catalogVersion", "nodes", "links", "metadata", "version"];
if (
!exactKeys(raw, rootKeys) ||
!Array.isArray(raw.nodes) ||
!Array.isArray(raw.links) ||
!object(raw.metadata) ||
!finiteJson(raw.metadata)
)
fail("AUTHORING_SHAPE");
if (raw.graphId !== GRAPH_ID || raw.catalogVersion !== CATALOG_VERSION)
fail("AUTHORING_CATALOG");
if (
!Number.isSafeInteger(raw.version) || raw.version < 0
)
fail("AUTHORING_SHAPE");
if (!Number.isInteger(revision) || revision < 1 || revision > 0xffffffff)
fail("AUTHORING_REVISION");
const nodes = new Map(),
sockets = new Map(),
paths = {};
for (let ordinal = 0; ordinal < raw.nodes.length; ordinal++) {
const n = raw.nodes[ordinal];
if (!object(n) || !identifier(n.id)) fail("AUTHORING_ID", { id: n?.id });
if (nodes.has(n.id)) fail("AUTHORING_ID_DUPLICATE", { id: n.id });
const descriptor = descriptors[n.typeId],
definition = nodeDefinitions[n.typeId];
if (!descriptor)
fail("AUTHORING_NODE_TYPE", { nodeId: n.id, typeId: n.typeId });
const nodeKeys = ["id", "typeId", "typeVersion", "position", "size", "label", "parameters", "sockets", "muted", "collapsed", "extensions", "known"];
if (Object.hasOwn(n, "parentId")) nodeKeys.push("parentId");
if (
!exactKeys(n, nodeKeys) ||
n.known !== true ||
n.typeVersion !== 1 ||
typeof n.muted !== "boolean" ||
typeof n.collapsed !== "boolean" ||
typeof n.label !== "string" ||
!exactKeys(n.position, ["x", "y"]) || !Number.isFinite(n.position.x) || !Number.isFinite(n.position.y) ||
!exactKeys(n.size, ["x", "y"]) || !Number.isFinite(n.size.x) || !Number.isFinite(n.size.y) || n.size.x <= 0 || n.size.y <= 0 ||
(Object.hasOwn(n, "parentId") && !identifier(n.parentId)) ||
!object(n.extensions) || !finiteJson(n.extensions) ||
!Array.isArray(n.sockets) ||
!object(n.parameters)
)
fail("AUTHORING_NODE_INVALID", { nodeId: n.id });
const parameterKeys = Object.keys(definition.parameters);
if (
Object.keys(n.parameters).length !== parameterKeys.length ||
!parameterKeys.every((key) => Object.hasOwn(n.parameters, key))
)
fail("AUTHORING_PARAMETER_SET", { nodeId: n.id });
const parameters = Object.fromEntries(
parameterKeys.map((key) => [
key,
parameterValue(
n.parameters[key],
definition.parameters[key],
n.id,
key,
),
]),
);
const expected = [
...Object.keys(descriptor.inputs),
...Object.keys(descriptor.outputs),
];
if (n.sockets.length !== expected.length)
fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
for (const s of n.sockets) {
if (!object(s) || !expected.includes(s.key) || sockets.has(s.id))
fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s?.key });
const input = descriptor.inputs[s.key],
socketDefinition = definition.sockets[s.key],
direction = input ? "input" : "output",
dataType = socketDefinition.type,
socketKeys = ["id", "key", "label", "direction", "dataType", "accepts", "maxIncomingLinks", ...(socketDefinition.value ? ["defaultValue"] : []), "visible"];
if (
!exactKeys(s, socketKeys) ||
s.id !== `${n.id}:${s.key}` ||
s.label !== socketDefinition.title ||
s.direction !== direction ||
s.dataType !== dataType ||
!Array.isArray(s.accepts) || s.accepts.length !== (direction === "input" ? socketTypes[dataType].acceptsFrom.length : 0) ||
!s.accepts.every((v, i) => v === (direction === "input" ? socketTypes[dataType].acceptsFrom[i] : undefined)) ||
(socketDefinition.value
? !exactKeys(s.defaultValue, ["kind", "value"]) || !finiteJson(s.defaultValue.value)
: s.defaultValue !== undefined) ||
s.visible !== socketDefinition.visible ||
s.maxIncomingLinks !== socketDefinition.maxIncomingLinks
)
fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s.key });
sockets.set(s.id, {
node: n.id,
key: s.key,
direction,
semanticType: input
? input.accepted.types[0]
: descriptor.outputs[s.key].type,
authoringType: s.dataType,
maxIncomingLinks: s.maxIncomingLinks,
});
}
if (new Set(n.sockets.map((s) => s.key)).size !== expected.length)
fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
nodes.set(n.id, {
ordinal,
value: {
id: n.id,
state: n.muted ? "muted" : "enabled",
executor: { key: n.typeId, version: 1 },
parameters,
inputs: {},
},
});
}
const incoming = new Map(),
linkIds = new Set(),
linkSources = new Map();
for (let ordinal = 0; ordinal < raw.links.length; ordinal++) {
const link = raw.links[ordinal];
if (
!object(link) ||
!identifier(link.id) ||
linkIds.has(link.id) ||
!exactKeys(link, ["id", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted", "extensions"]) ||
typeof link.muted !== "boolean" || !object(link.extensions) || !finiteJson(link.extensions)
)
fail("AUTHORING_LINK", { linkId: link?.id });
linkIds.add(link.id);
const from = sockets.get(link.fromSocketId),
to = sockets.get(link.toSocketId);
if (
!from ||
!to ||
link.fromNodeId !== from.node ||
link.toNodeId !== to.node ||
from.direction !== "output" ||
to.direction !== "input" ||
(!link.muted && (incoming.get(link.toSocketId) ?? 0) >= to.maxIncomingLinks)
)
fail(
!link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity)
? "AUTHORING_LINK_INCOMING"
: "AUTHORING_LINK",
!link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity)
? { socketId: link.toSocketId }
: { linkId: link.id },
);
const accepted =
descriptors[nodes.get(to.node).value.executor.key].inputs[to.key]
.accepted.types;
const authoringAccepted = socketTypes[nodeDefinitions[nodes.get(to.node).value.executor.key].sockets[to.key].type].acceptsFrom;
if (!accepted.includes(from.semanticType) || !authoringAccepted.includes(from.authoringType))
fail("AUTHORING_LINK_TYPE", { linkId: link.id });
const linkSource = {
kind: "link",
linkId: link.id,
fromNodeId: link.fromNodeId,
fromSocketId: link.fromSocketId,
toNodeId: link.toNodeId,
toSocketId: link.toSocketId,
muted: link.muted,
nodeId: to.node,
input: to.key,
fromSocket: from.key,
toSocket: to.key,
};
linkSources.set(link.id, linkSource);
if (!link.muted) {
incoming.set(link.toSocketId, (incoming.get(link.toSocketId) ?? 0) + 1);
nodes.get(to.node).value.inputs[to.key] = {
node: from.node,
socket: from.key,
};
}
}
const ordered = [...nodes.values()].sort((a, b) =>
a.value.id < b.value.id
? -1
: a.value.id > b.value.id
? 1
: a.ordinal - b.ordinal,
);
for (let wireOrdinal = 0; wireOrdinal < ordered.length; wireOrdinal++) {
const item = ordered[wireOrdinal];
item.value.inputs = Object.fromEntries(
Object.keys(descriptors[item.value.executor.key].inputs)
.filter((key) => Object.hasOwn(item.value.inputs, key))
.map((key) => [key, item.value.inputs[key]]),
);
const base = `nodes[${wireOrdinal}]`;
const nodeSource = { kind: "node", nodeId: item.value.id };
paths[base] = nodeSource;
for (const field of ["id", "state", "executor", "executor.key", "executor.version"])
paths[`${base}.${field}`] = nodeSource;
paths[`${base}.parameters`] = nodeSource;
for (const key of Object.keys(item.value.parameters))
mapValuePaths(paths, `${base}.parameters.${key}`, { kind: "parameter", nodeId: item.value.id, parameter: key }, item.value.parameters[key]);
for (const key of Object.keys(descriptors[item.value.executor.key].inputs)) {
const link = raw.links.find((x) => !x.muted && x.toNodeId === item.value.id && sockets.get(x.toSocketId)?.key === key);
const source = linkSources.get(link?.id) ?? {
kind: "input",
nodeId: item.value.id,
input: key,
socketId: `${item.value.id}:${key}`,
unconnected: true,
};
paths[`${base}.inputs.${key}`] = source;
if (link) {
paths[`${base}.inputs.${key}.node`] = source;
paths[`${base}.inputs.${key}.socket`] = {
kind: "socket",
nodeId: link.fromNodeId,
socketId: link.fromSocketId,
socket: sockets.get(link.fromSocketId).key,
linkId: link.id,
};
}
}
paths[`${base}.inputs`] = nodeSource;
}
const ir = {
schemaVersion: 2,
graphId: GRAPH_ID,
revision,
nodes: ordered.map((item) => item.value),
};
const graphSource = { kind: "graph", graphId: GRAPH_ID };
for (const field of ["schemaVersion", "graphId", "revision", "nodes"])
paths[field] = graphSource;
deepFreeze(paths);
deepFreeze(ir);
sourceMaps.set(ir, paths);
return ir;
} catch (error) {
if (error instanceof AuthoringGraphError) throw error;
fail("AUTHORING_SHAPE");
}
}
export const adaptGraphSnapshot = adaptFxNodeSnapshot;
/** Compatibility helper retained for V1 presets. */
export function semanticProjectionToV1(p, revision = 1) {
const extent = {
kind: "surface_relative",
width: { numerator: 1, denominator: 1 },
height: { numerator: 1, denominator: 1 },
depthOrArrayLayers: 1,
};
return {
schemaVersion: 1,
graphId: p.graphId,
revision,
resources: [
{
id: "surface",
version: 0,
residency: { kind: "external", source: "surface_color" },
texture: {
dimension: "d2",
format: "surface",
extent,
mipLevelCount: 1,
sampleCount: 1,
},
},
{
id: "depth",
version: 0,
residency: { kind: "transient" },
texture: {
dimension: "d2",
format: "depth32_float",
extent,
mipLevelCount: 1,
sampleCount: 1,
},
},
],
passes: [
{
id: "forward",
state: p.passState,
executor: { key: "scene_forward", version: 1 },
parameters: {},
reads: [],
writes: [
{
binding: "color",
resource: { id: "surface", version: 0 },
access: {
kind: "color_attachment",
location: 0,
load: { op: "clear", value: p.clearColor },
store: "store",
},
},
{
binding: "depth",
resource: { id: "depth", version: 0 },
access: {
kind: "depth_attachment",
load: { op: "clear", value: p.clearDepth },
store: "store",
},
},
],
},
],
outputs: [{ name: "present", resource: { id: "surface", version: 0 } }],
};
}
export const adaptFxNodeSnapshot=(snapshot,revision=1)=>semanticProjectionToV1(projectAuthoringSnapshot(snapshot),revision);
export const adaptGraphSnapshot=adaptFxNodeSnapshot;
+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 {
#renderer; #getState; #revision=0; #nextRevision=1; #dirty=true; #applying=null; #listeners=new Set();
constructor({renderer,getState}) { this.#renderer=renderer; this.#getState=getState; }
get revision(){return this.#revision} get dirty(){return this.#dirty} get applying(){return !!this.#applying}
subscribe(fn){this.#listeners.add(fn);return()=>this.#listeners.delete(fn)}
markDirty(){this.#dirty=true;this.#emit()}
#emit(){for(const fn of this.#listeners)fn({revision:this.#revision,dirty:this.#dirty,applying:!!this.#applying})}
apply(adapt){
if(this.#applying)return this.#applying;
const revision=this.#nextRevision++; this.#dirty=false; this.#emit();
this.#applying=(async()=>{try{const snapshot=await this.#getState();const ir=adapt(snapshot,revision);const compiled=await this.#renderer.compileGraph(ir);await this.#renderer.switchCompiledGraph(compiled.compiledId);this.#revision=revision;return compiled} catch(e){this.#dirty=true;throw e} finally{this.#applying=null;this.#emit()}})();
return this.#applying;
#renderer; #adapt; #revision = 0; #nextRevision = 1; #generation = 0;
#current; #lastGood; #applyPromise; #listeners = new Set();
#owned = new Map(); #drops = new Map(); #activeCompiles = new Set();
#disposed = false; #applyingRecord; #scheduler; #debounceMs; #timer; #destroyPromise;
constructor({ renderer, adapt, scheduler = globalThis, debounceMs = 150 }) {
this.#renderer = renderer; this.#adapt = adapt;
this.#scheduler = scheduler; this.#debounceMs = debounceMs;
}
get revision() { return this.#revision; }
get dirty() { return !!this.#current; }
get applying() { return !!this.#applyPromise; }
get staged() { return this.#current?.candidate ?? null; }
get canApply() { return !this.#disposed && !!this.#current?.candidate && !this.#applyPromise; }
subscribe(fn) {
if (this.#disposed) return () => {};
this.#listeners.add(fn); fn(this.#state());
return () => this.#listeners.delete(fn);
}
#state() { return { revision: this.#revision, dirty: this.dirty, applying: this.applying, staged: this.staged, canApply: this.canApply, error: this.#current?.diagnostic ?? null }; }
#emit() { if (!this.#disposed) for (const fn of this.#listeners) fn(this.#state()); }
#key(id) { return JSON.stringify(id); }
#drop(candidate) {
if (!candidate) return Promise.resolve();
const key = this.#key(candidate.compiledId);
if (!this.#owned.has(key)) return this.#drops.get(key) ?? Promise.resolve();
if (this.#drops.has(key)) return this.#drops.get(key);
let result;
try { result = this.#renderer.dropCompiledGraph(candidate.compiledId); }
catch (error) { result = Promise.reject(error); }
const dropping = Promise.resolve(result)
.then(() => { this.#owned.delete(key); })
.finally(() => { this.#drops.delete(key); });
this.#drops.set(key, dropping);
return dropping;
}
#retire(candidate) { if (candidate) void this.#drop(candidate).catch(() => {}); }
#start(record) {
if (!record.compile) {
record.compile = this.#compile(record);
this.#activeCompiles.add(record.compile);
record.compile.finally(() => this.#activeCompiles.delete(record.compile));
}
return record.compile;
}
#flush(record = this.#current) {
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
return record ? this.#start(record) : null;
}
markDirty(snapshot) {
if (this.#disposed) return;
const previous = this.#current;
const record = { generation: ++this.#generation, snapshot, candidate: null, error: null, diagnostic: null, compile: null };
this.#current = record;
if (previous?.candidate && previous !== this.#applyingRecord && previous.candidate !== this.#lastGood) this.#retire(previous.candidate);
if (this.#timer) this.#scheduler.clearTimeout(this.#timer);
this.#timer = this.#scheduler.setTimeout(() => { this.#timer = undefined; if (!this.#disposed) this.#start(record); }, this.#debounceMs);
this.#emit();
}
async #compile(record) {
let candidate, ir;
try {
ir = this.#adapt(record.snapshot, this.#nextRevision++);
candidate = await this.#renderer.compileGraph(ir);
this.#owned.set(this.#key(candidate.compiledId), candidate);
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
record.candidate = candidate; record.error = record.diagnostic = null; this.#emit(); return candidate;
} catch (error) {
if (candidate) this.#retire(candidate);
record.error = error;
record.diagnostic = ir ? mapAuthoringDiagnostic(ir, error) : error;
if (this.#current === record) this.#emit();
return null;
}
}
apply() {
if (this.#disposed) return Promise.resolve(null);
if (this.#applyPromise) return this.#applyPromise;
const record = this.#current;
if (!record) return Promise.resolve(this.#lastGood);
this.#applyingRecord = record; this.#flush(record);
this.#applyPromise = this.#applyRecord(record); this.#emit(); return this.#applyPromise;
}
async #applyRecord(record) {
try {
const candidate = record.candidate ?? (await record.compile);
if (!candidate) throw record.error ?? new Error("Graph compilation failed");
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
await this.#renderer.switchCompiledGraph(candidate.compiledId);
const old = this.#lastGood; this.#lastGood = candidate;
this.#revision = candidate.revision ?? this.#revision + 1;
if (this.#current === record) this.#current = undefined;
if (old && old !== candidate) this.#retire(old);
return candidate;
} finally {
if (this.#current !== record && record.candidate && record.candidate !== this.#lastGood) this.#retire(record.candidate);
this.#applyPromise = null; this.#applyingRecord = undefined; this.#emit();
}
}
destroy() {
if (this.#destroyPromise) return this.#destroyPromise;
this.#disposed = true;
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
this.#listeners.clear();
this.#destroyPromise = this.#finish();
return this.#destroyPromise;
}
async #finish() {
const applying = this.#applyPromise;
await Promise.allSettled([...(applying ? [applying] : []), ...this.#activeCompiles, ...this.#drops.values()]);
await Promise.allSettled([...this.#owned.values()].map((candidate) => this.#drop(candidate)));
this.#current = this.#lastGood = undefined;
}
}
+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 });
export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeType }={}) {
export function prepareBrowserHost(canvas, { onError=console.error, requestAddNode }={}) {
const ownerDocument=canvas.ownerDocument, ownerWindow=ownerDocument.defaultView ?? window;
const originalTabIndex=canvas.getAttribute("tabindex"), originalTouchAction=canvas.style.touchAction;
let view, dead=false, generation=0, resizing=false, pending, appliedViewport, menuPending=false, unsubscribeHost=()=>{};
let view, root, dead=false, generation=0, requestEpoch=0, resizing=false, pending, appliedViewport, menuPending=false, menuPoint, unsubscribeHost=()=>{};
const rootSubscriptions=[];
const invalidateAddNode=()=>{requestEpoch++;menuPending=false;requestAddNode?.close?.()};
const captured=new Set();
const initialViewport=viewport(canvas,ownerWindow); appliedViewport=initialViewport; sizeCanvas(canvas,initialViewport);
canvas.tabIndex=0; canvas.style.touchAction="none";
@@ -22,14 +24,14 @@ export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeTy
if(!view)return;
if(e instanceof ownerWindow.PointerEvent){
const phase=e.type==="pointerdown"?"down":e.type==="pointermove"?"move":e.type==="pointerup"?"up":"cancel";
if(phase==="down"){menuPending=e.button===2&&!e.ctrlKey&&(e.buttons&1)===0;canvas.focus();try{canvas.setPointerCapture(e.pointerId);captured.add(e.pointerId)}catch{}}
if(phase==="down"){invalidateAddNode();menuPending=e.button===2&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&!e.shiftKey&&(e.buttons&1)===0;menuPoint={x:e.clientX,y:e.clientY};canvas.focus();try{canvas.setPointerCapture(e.pointerId);captured.add(e.pointerId)}catch{}}
if((phase==="up"||phase==="cancel")&&captured.delete(e.pointerId))try{if(canvas.hasPointerCapture(e.pointerId))canvas.releasePointerCapture(e.pointerId)}catch{}
view.feedInput({kind:"pointer",phase,pointerId:e.pointerId,pointerType:e.pointerType,position:point(e),button:e.button,buttons:e.buttons,modifiers:mods(e)});
}else if(e instanceof ownerWindow.WheelEvent){
e.preventDefault(); menuPending=false;
e.preventDefault(); invalidateAddNode();
const scale=e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_LINE?16:e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_PAGE?Math.max(1,canvas.clientHeight):1;
view.feedInput({kind:"wheel",position:point(e),delta:{x:e.deltaX*scale,y:e.deltaY*scale},modifiers:mods(e)});
}else if(e instanceof ownerWindow.KeyboardEvent){menuPending=false;view.feedInput({kind:"key",phase:e.type==="keydown"?"down":"up",key:e.key,code:e.code,repeat:e.repeat,modifiers:mods(e)});
}else if(e instanceof ownerWindow.KeyboardEvent){invalidateAddNode();view.feedInput({kind:"key",phase:e.type==="keydown"?"down":"up",key:e.key,code:e.code,repeat:e.repeat,modifiers:mods(e)});
}else view.feedInput({kind:"focus",phase:e.type==="focus"?"focus":"blur"});
};
const names=["pointerdown","pointermove","pointerup","pointercancel","wheel","keydown","keyup","focus","blur"];
@@ -40,22 +42,23 @@ export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeTy
resizing=true;
Promise.resolve(view.setViewport(next)).then(()=>{if(dead||currentGeneration!==generation)return;appliedViewport=next;sizeCanvas(canvas,next)}).catch(error=>{if(!dead&&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 lost=e=>captured.delete(e.pointerId);
const observer=new ownerWindow.ResizeObserver(resize);
return {initialViewport,attach(_root,next){
view=next;
root=_root;view=next;
for(const n of names)canvas.addEventListener(n,input,{passive:n!=="wheel"});
canvas.addEventListener("contextmenu",prevent);canvas.addEventListener("lostpointercapture",lost);
ownerDocument.addEventListener("pointerdown",outside,true);ownerWindow.addEventListener("resize",resize);
unsubscribeHost=view.onHostRequests(request=>{if(request.kind!=="add-node-menu"||!menuPending)return;menuPending=false;const typeId=chooseNodeType?.(request);if(typeId)view.addNode({typeId,viewPosition:request.viewPosition}).catch(onError)});
unsubscribeHost=view.onHostRequests(request=>{if(request.kind!=="add-node-menu"||!menuPending||request.compositionRevision!==view.getHostSnapshot().compositionRevision){invalidateAddNode();return}menuPending=false;const epoch=requestEpoch;requestAddNode?.(request,menuPoint,()=>!dead&&epoch===requestEpoch);});
rootSubscriptions.push(root.onMutations(invalidateAddNode),root.onCompositionChanges(invalidateAddNode));
observer.observe(canvas);resize();
},destroy(){
if(dead)return;dead=true;generation++;pending=undefined;observer.disconnect();unsubscribeHost();ownerWindow.removeEventListener("resize",resize);ownerDocument.removeEventListener("pointerdown",outside,true);
if(dead)return;dead=true;generation++;pending=undefined;invalidateAddNode();observer.disconnect();unsubscribeHost();for(const unsubscribe of rootSubscriptions)unsubscribe();rootSubscriptions.length=0;ownerWindow.removeEventListener("resize",resize);ownerDocument.removeEventListener("pointerdown",outside,true);
for(const n of names)canvas.removeEventListener(n,input);canvas.removeEventListener("contextmenu",prevent);canvas.removeEventListener("lostpointercapture",lost);
for(const id of captured)try{if(canvas.hasPointerCapture(id))canvas.releasePointerCapture(id)}catch{}captured.clear();
if(originalTabIndex===null)canvas.removeAttribute("tabindex");else canvas.setAttribute("tabindex",originalTabIndex);canvas.style.touchAction=originalTouchAction;view=null;
if(originalTabIndex===null)canvas.removeAttribute("tabindex");else canvas.setAttribute("tabindex",originalTabIndex);canvas.style.touchAction=originalTouchAction;view=null;root=null;
}};
}
function prevent(e){e.preventDefault()}
+346 -28
View File
@@ -1,29 +1,347 @@
export const GRAPH_ID = "demo_forward";
export const CATALOG_VERSION = 1;
export const socketTypes = {
surface: { title: "Surface", color: "#62b0ff", acceptsFrom: ["surface"] },
depth: { title: "Depth", color: "#b58cff", acceptsFrom: ["depth"] },
};
export const theme = {
background:"#151820",grid:"#292e3a",frame:"#30343a80",frameHeader:"#59616c",body:"#292e39",control:"#191d26",controlFill:"#4775b8",controlEditing:"#101218",textSelection:"#4775b8",outline:"#0b0d12",text:"#edf1f7",muted:"#969eaa",shadow:"#00000088",nodeSelected:"#ff9f43",nodeActive:"#ffffff",unknownHeader:"#555b64",unknownSocket:"#999999",linkMuted:"#d94b4b",knifeMuted:"#e85b5b",emphasis:"#ffffff",focus:"#f5a623",editOutline:"#666a70",resize:"#8b8e95",muteOverlay:"#14141459",boxSelectionFill:"#f5a6231f",checkerLight:"#aaaaaa",checkerDark:"#777777",widgetBorder:"#111216",rampBorder:"#111111",resourceBackground:"#202228"
};
export const styles = { resource:{header:"#3977a8"}, pass:{header:"#426b43"}, output:{header:"#a75d37"} };
const socket = (title, direction, type) => ({ title,direction,type,maxIncomingLinks:direction === "input" ? 1 : 0,visible:true,value:null,showValue:false });
const node = (title, style, sockets, parameters = {}) => ({ version:1,title,behavior:"standard",style,parameters,sockets,ui:[...Object.keys(parameters).map(parameter=>({kind:"parameter",parameter})),...Object.keys(sockets).map(socket=>({kind:"socket",socket}))],muteBypass:[],migrations:[] });
export const nodeDefinitions = {
surface_color: node("Surface Color", "resource", { surface:socket("Surface","output","surface") }),
depth32: node("Depth 32", "resource", { depth:socket("Depth","output","depth") }),
scene_forward: node("Scene Forward", "pass", { color:socket("Color","input","surface"),depth:socket("Depth","input","depth"),result:socket("Result","output","surface") }, {
clearColor:{type:"color",default:{kind:"color",value:[0,0,0,1]},minimum:0,maximum:1},
clearDepth:{type:"number",default:{kind:"number",value:1},minimum:0,maximum:1,step:0.01},
}),
present: node("Present", "output", { surface:socket("Surface","input","surface") }),
};
export const descriptors = Object.freeze({
surface_color:{version:1,sockets:{surface:["output","surface"]},parameters:[]},
depth32:{version:1,sockets:{depth:["output","depth"]},parameters:[]},
scene_forward:{version:1,sockets:{color:["input","surface"],depth:["input","depth"],result:["output","surface"]},parameters:["clearColor","clearDepth"]},
present:{version:1,sockets:{surface:["input","surface"]},parameters:[]},
export const GRAPH_ID = "authored_gpu_culling";
export const CATALOG_VERSION = 2;
const exact = (type) => ({ kind: "exact", types: [type] });
const oneOf = (...types) => ({ kind: "one_of", types });
const i = (type, required = true, authoringType) => ({
accepted: typeof type === "string" ? exact(type) : type,
required,
...(authoringType ? { authoringType } : {}),
});
const o = (type) => ({ type });
const texture = {
residency: "transient",
texture: {
dimension: "d2",
format: "rgba16_float",
extent: {
kind: "surface_relative",
width: { numerator: 1, denominator: 1 },
height: { numerator: 1, denominator: 1 },
depthOrArrayLayers: 1,
},
mipLevelCount: 1,
sampleCount: 1,
viewFormats: [],
},
};
export const semanticCatalog = Object.freeze({
surface_target: {
execution: "source",
inputs: {},
outputs: { surface: o("surface_target") },
parameters: {},
},
texture_spec: {
execution: "source",
inputs: {},
outputs: { spec: o("texture_spec") },
parameters: structuredClone(texture),
},
scene_table: {
execution: "source",
inputs: {},
outputs: { scene: o("scene_table") },
parameters: {},
},
local_aabb_buffer: {
execution: "source",
inputs: { scene: i("scene_table") },
outputs: { localAabbs: o("local_aabb_buffer") },
parameters: {},
},
camera_frustum: {
execution: "source",
inputs: {},
outputs: { frustum: o("camera_frustum") },
parameters: {},
},
visibility_flags: {
execution: "source",
inputs: { scene: i("scene_table") },
outputs: {
flags: {
...o("boolean_flag_buffer"),
authoringType: "visibility_flag_buffer",
},
},
parameters: {},
},
frustum_cull: {
execution: "compute",
inputs: {
scene: i("scene_table"),
localAabbs: i("local_aabb_buffer"),
frustum: i("camera_frustum"),
},
outputs: {
flags: {
...o("boolean_flag_buffer"),
authoringType: "frustum_flag_buffer",
},
},
parameters: {},
},
mesh_query: {
execution: "compute",
inputs: {
scene: i("scene_table"),
isVisible: i("boolean_flag_buffer", false, "visibility_flag_buffer"),
isFrustumCulled: i("boolean_flag_buffer", false, "frustum_flag_buffer"),
},
outputs: { draws: o("draw_stream") },
parameters: {
filters: [
{ flag: "isVisible", predicate: "required_true" },
{ flag: "isFrustumCulled", predicate: "required_false" },
],
},
},
depth_stencil_config: {
execution: "source",
inputs: {},
outputs: { config: o("depth_stencil_config") },
parameters: {
depthCompare: "less_equal",
depthWriteEnabled: true,
clearDepth: 1,
},
},
legacy_forward: {
execution: "render",
inputs: {
scene: i("scene_table"),
draws: i("draw_stream"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
depthTarget: i(oneOf("texture_spec", "texture")),
depthStencil: i("depth_stencil_config"),
},
outputs: { color: o("texture"), depth: o("texture") },
parameters: { clearColor: [0.015, 0.02, 0.03, 1] },
},
fullscreen_copy: {
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
},
outputs: { color: o("texture") },
parameters: {},
},
tone_map: {
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
},
outputs: { color: o("texture") },
parameters: { exposure: 1 },
},
bloom_extract: {
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
},
outputs: { color: o("texture") },
parameters: { threshold: 1, knee: 0.5 },
},
bloom_blur: {
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
},
outputs: { color: o("texture") },
parameters: { direction: [1, 0], radius: 1 },
},
bloom_composite: {
execution: "render",
inputs: {
source: i("texture"),
bloom: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
},
outputs: { color: o("texture") },
parameters: { intensity: 1 },
},
luminance_edge: {
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
},
outputs: { color: o("texture") },
parameters: { strength: 2 },
},
present: {
execution: "present",
inputs: { surface: i("texture") },
outputs: {},
parameters: {},
},
});
const socketColors = [
"#d17c7c",
"#d19e7c",
"#d1c77c",
"#9ed17c",
"#7cd1a5",
"#7ccbd1",
"#7c98d1",
"#a27cd1",
"#d17cb8",
];
export const socketTypes = Object.fromEntries(
[
"surface_target",
"texture_spec",
"texture",
"scene_table",
"local_aabb_buffer",
"camera_frustum",
"boolean_flag_buffer",
"draw_stream",
"depth_stencil_config",
"visibility_flag_buffer",
"frustum_flag_buffer",
].map((type, index) => [
type,
{
title: type.replaceAll("_", " "),
color: socketColors[index % socketColors.length],
acceptsFrom: [type],
},
]),
);
socketTypes.surface_target.acceptsFrom = [
"surface_target",
"texture_spec",
"texture",
];
socketTypes.texture_spec.acceptsFrom = ["texture_spec", "texture"];
socketTypes.boolean_flag_buffer.acceptsFrom = [
"boolean_flag_buffer",
"visibility_flag_buffer",
"frustum_flag_buffer",
];
export const theme = {
background: "#151820",
grid: "#292e3a",
frame: "#30343a80",
frameHeader: "#59616c",
body: "#292e39",
control: "#24272b",
controlFill: "#4775b8",
controlEditing: "#181a1d",
textSelection: "#4775b8",
outline: "#0b0d12",
text: "#edf1f7",
muted: "#969eaa",
shadow: "#00000088",
nodeSelected: "#ff9f43",
nodeActive: "#ffffff",
unknownHeader: "#555b64",
unknownSocket: "#999999",
linkMuted: "#d94b4b",
knifeMuted: "#e85b5b",
emphasis: "#ffffff",
focus: "#f5a623",
editOutline: "#666a70",
resize: "#8b8e95",
muteOverlay: "#14141459",
boxSelectionFill: "#f5a6231f",
checkerLight: "#aaaaaa",
checkerDark: "#777777",
widgetBorder: "#111216",
rampBorder: "#111111",
resourceBackground: "#202228",
};
export const styles = {
source: { header: "#3977a8" },
compute: { header: "#725a9b" },
render: { header: "#426b43" },
present: { header: "#a75d37" },
};
const socket = (title, direction, type) => ({
title,
direction,
type,
maxIncomingLinks: direction === "input" ? 1 : 0,
visible: true,
value: null,
showValue: false,
});
const parameterSchema = (value) =>
typeof value === "number"
? { type: "number", default: { kind: "number", value } }
: typeof value === "string"
? { type: "string", default: { kind: "string", value } }
: typeof value === "boolean"
? { type: "boolean", default: { kind: "boolean", value } }
: { type: "json", default: { kind: "json", value } };
export const nodeDefinitions = Object.fromEntries(
Object.entries(semanticCatalog).map(([key, c]) => {
const sockets = {
...Object.fromEntries(
Object.entries(c.inputs).map(([n, v]) => [
n,
socket(n, "input", v.authoringType ?? v.accepted.types[0]),
]),
),
...Object.fromEntries(
Object.entries(c.outputs).map(([n, v]) => [
n,
socket(n, "output", v.authoringType ?? v.type),
]),
),
},
parameters = Object.fromEntries(
Object.entries(c.parameters).map(([name, value]) => [
name,
parameterSchema(value),
]),
);
return [
key,
{
version: 1,
title: key.replaceAll("_", " "),
behavior: "standard",
style: c.execution,
parameters,
sockets,
ui: [
...Object.keys(parameters).map((parameter) => ({
kind: "parameter",
parameter,
})),
...Object.keys(sockets).map((socket) => ({ kind: "socket", socket })),
],
muteBypass: [],
migrations: [],
},
];
}),
);
export const fxNodeComposition = Object.freeze({
schemaVersion: 2,
id: "yawn.render-graph",
version: CATALOG_VERSION,
compatibility: { wildcardInputTypes: [] },
socketTypes,
nodeStyles: styles,
resources: {},
theme,
nodes: nodeDefinitions,
});
export const descriptors = Object.fromEntries(
Object.entries(semanticCatalog).map(([key, c]) => [
key,
{
version: 1,
inputs: c.inputs,
outputs: c.outputs,
parameters: c.parameters,
},
]),
);
+149 -19
View File
@@ -1,25 +1,155 @@
import { createFxNode } from "@fxnode/index.ts";
import { CATALOG_VERSION, GRAPH_ID, nodeDefinitions, socketTypes, styles, theme } from "./catalog.js";
import { CATALOG_VERSION, GRAPH_ID, fxNodeComposition } from "./catalog.js";
import { prepareBrowserHost } from "./browser-host.js";
import { createAddNodeMenu } from "./add-node-menu.js";
import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.js";
const spec=[
["surface","surface_color",{x:40,y:100}],
["depth","depth32",{x:40,y:330}],
["forward","scene_forward",{x:360,y:190}],
["present","present",{x:700,y:220}],
const spec = [
["surface", "surface_target", { x: 40, y: 40 }],
["hdr", "texture_spec", { x: 40, y: 170 }],
["depth", "texture_spec", { x: 40, y: 300 }],
["scene", "scene_table", { x: 40, y: 470 }],
["aabbs", "local_aabb_buffer", { x: 290, y: 430 }],
["frustum", "camera_frustum", { x: 290, y: 590 }],
["visible", "visibility_flags", { x: 290, y: 300 }],
["cull", "frustum_cull", { x: 540, y: 480 }],
["query", "mesh_query", { x: 790, y: 330 }],
["depth_config", "depth_stencil_config", { x: 790, y: 620 }],
["forward", "legacy_forward", { x: 1040, y: 290 }],
["copy", "fullscreen_copy", { x: 1300, y: 250 }],
["present", "present", { x: 1540, y: 250 }],
];
async function seed(root){
await root.setState({graphId:GRAPH_ID,catalogVersion:CATALOG_VERSION,nodes:[],links:[],metadata:{}});
for(const [nodeId,nodeType,position] of spec) await root.dispatch({type:"node.add",nodeId,nodeType,position});
for(const link of [
{id:"surface_link",fromNodeId:"surface",fromSocketId:"surface:surface",toNodeId:"forward",toSocketId:"forward:color",muted:false,extensions:{}},
{id:"depth_link",fromNodeId:"depth",fromSocketId:"depth:depth",toNodeId:"forward",toSocketId:"forward:depth",muted:false,extensions:{}},
{id:"present_link",fromNodeId:"forward",fromSocketId:"forward:result",toNodeId:"present",toSocketId:"present:surface",muted:false,extensions:{}},
]) await root.dispatch({type:"link.add",link});
async function seed(root) {
await root.setState({
graphId: GRAPH_ID,
catalogVersion: CATALOG_VERSION,
nodes: [],
links: [],
metadata: {},
});
for (const [nodeId, nodeType, position] of spec)
await root.dispatch({ type: "node.add", nodeId, nodeType, position });
const links = [
["scene", "scene", "aabbs", "scene"],
["scene", "scene", "visible", "scene"],
["scene", "scene", "cull", "scene"],
["aabbs", "localAabbs", "cull", "localAabbs"],
["frustum", "frustum", "cull", "frustum"],
["scene", "scene", "query", "scene"],
["visible", "flags", "query", "isVisible"],
["cull", "flags", "query", "isFrustumCulled"],
["scene", "scene", "forward", "scene"],
["query", "draws", "forward", "draws"],
["hdr", "spec", "forward", "colorTarget"],
["depth", "spec", "forward", "depthTarget"],
["depth_config", "config", "forward", "depthStencil"],
["forward", "color", "copy", "source"],
["surface", "surface", "copy", "colorTarget"],
["copy", "color", "present", "surface"],
];
for (const [a, as, b, bs] of links) {
const id = `${a}_${as}_${b}_${bs}`;
await root.dispatch({
type: "link.add",
link: {
id,
fromNodeId: a,
fromSocketId: `${a}:${as}`,
toNodeId: b,
toSocketId: `${b}:${bs}`,
muted: false,
extensions: {},
},
});
}
const authored = await root.getState(),
depth = authored.nodes.find((node) => node.id === "depth");
depth.parameters.texture = {
kind: "json",
value: {
dimension: "d2",
format: "depth32_float",
extent: {
kind: "surface_relative",
width: { numerator: 1, denominator: 1 },
height: { numerator: 1, denominator: 1 },
depthOrArrayLayers: 1,
},
mipLevelCount: 1,
sampleCount: 1,
viewFormats: [],
},
};
await root.setState(authored);
}
export async function createRenderGraphEditor(canvas){
const chooseNodeType=()=>{const value=canvas.ownerDocument.defaultView?.prompt(`Node type: ${Object.keys(nodeDefinitions).join(", ")}`,"scene_forward");return Object.hasOwn(nodeDefinitions,value)?value:null};
const host=prepareBrowserHost(canvas,{chooseNodeType});let root,view,destroying;
const destroy=()=>destroying??=(async()=>{host.destroy();try{await view?.detach()}finally{root?.destroy();view=undefined;root=undefined}})();
try{root=await createFxNode({applicationId:"yawn.render-graph",applicationVersion:1,resources:{}});await root.setTheme(theme);await root.setHeaderStyles(styles);for(const entry of Object.entries(socketTypes))await root.composeSocket(...entry);for(const entry of Object.entries(nodeDefinitions))await root.composeNode(...entry);await seed(root);view=await root.attachView({canvas,viewport:host.initialViewport,initialCamera:{center:{x:470,y:210},zoom:.5}});host.attach(root,view);await view.whenRendered();return {getState:()=>root.getState(),onSnapshots:fn=>root.onSnapshots(fn),whenRendered:()=>view.whenRendered(),destroy};}catch(e){await destroy().catch(()=>{});throw e}
export async function createRenderGraphEditor(canvas) {
const allocateId = createNodeIdAllocator();
let root, view, menu, destroying, dead = false;
const requestAddNode = Object.assign(async (request, point, isCurrent = () => true) => {
let typeId;
try { typeId = await menu?.open(point); } catch (error) { if (!dead && isCurrent()) console.error(error); return; }
if (dead || !isCurrent() || !root || !view) return;
const alive = () => !dead && isCurrent();
try { await spawnRequestedNode(root, view, request, typeId, allocateId, alive); } catch (error) { if (!dead) console.error(error); }
}, { close: () => menu?.close() });
const host = prepareBrowserHost(canvas, { requestAddNode });
const destroy = () =>
(destroying ??= (async () => {
dead = true;
host.destroy();
menu?.destroy();
try {
await view?.detach();
} finally {
root?.destroy();
view = undefined;
root = undefined;
}
})());
try {
root = await createFxNode({
applicationId: "yawn.render-graph",
applicationVersion: CATALOG_VERSION,
resources: {},
});
await root.loadComposition(fxNodeComposition);
await seed(root);
view = await root.attachView({
canvas,
viewport: host.initialViewport,
initialCamera: { center: { x: 780, y: 340 }, zoom: 0.34 },
});
menu = createAddNodeMenu(canvas.ownerDocument);
host.attach(root, view);
await view.whenRendered();
return createEditorFacade(root, view, { requestAddNode, destroy });
} catch (e) {
await destroy().catch(() => {});
throw e;
}
}
/** Creates the small application-facing wrapper around an fxnode root and view. */
export function createEditorFacade(
root,
view,
{ requestAddNode = () => null, destroy = async () => {} } = {},
) {
return {
getState: () => root.getState(),
getSaveData: () => root.getSaveData(),
load: async (data) => {
await root.load(data);
await view.whenRendered();
},
loadComposition: async (data) => {
await root.loadComposition(data);
await view.whenRendered();
},
onSnapshots: (fn) =>
root.onSnapshots((event) => fn(event.snapshot, event.version)),
requestAddNode,
whenRendered: () => view.whenRendered(),
destroy,
};
}
+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";
const make=(graphId,clearColor)=>Object.freeze(semanticProjectionToV1({graphId,clearColor,clearDepth:1,passState:"enabled"},1));
export const midnight=make("preset_midnight",[0.015,0.06,0.18,1]);
export const ember=make("preset_ember",[0.18,0.035,0.012,1]);
export const renderGraphPresets=Object.freeze({midnight,ember});
const make = (graphId, clearColor) =>
Object.freeze(
semanticProjectionToV1(
{ graphId, clearColor, clearDepth: 1, passState: "enabled" },
1,
),
);
export const midnight = make("preset_midnight", [0.015, 0.06, 0.18, 1]);
export const ember = make("preset_ember", [0.18, 0.035, 0.012, 1]);
const input = (node, socket) => ({ node, socket });
const node = (id, key, parameters = {}, inputs = {}) => ({
id,
state: "enabled",
executor: { key, version: 1 },
parameters,
inputs,
});
const texture = (format) => ({
texture: {
dimension: "d2",
format,
extent: {
kind: "surface_relative",
width: { numerator: 1, denominator: 1 },
height: { numerator: 1, denominator: 1 },
depthOrArrayLayers: 1,
},
mipLevelCount: 1,
sampleCount: 1,
viewFormats: [],
},
residency: "transient",
});
export const hdr = Object.freeze({
schemaVersion: 2,
graphId: "preset_hdr_fullscreen",
revision: 1,
nodes: [
node("surface", "surface_target"),
node("hdr", "texture_spec", texture("rgba16_float")),
node("depth", "texture_spec", texture("depth32_float")),
node("scene", "scene_table"),
node("visible", "visibility_flags", {}, { scene: input("scene", "scene") }),
node(
"query",
"mesh_query",
{
filters: [
{ flag: "isVisible", predicate: "required_true" },
{ flag: "isFrustumCulled", predicate: "any" },
],
},
{ scene: input("scene", "scene"), isVisible: input("visible", "flags") },
),
node("depth_config", "depth_stencil_config", {
depthCompare: "less_equal",
depthWriteEnabled: true,
clearDepth: 1,
}),
node(
"forward",
"legacy_forward",
{ clearColor: [0.015, 0.02, 0.03, 1] },
{
scene: input("scene", "scene"),
draws: input("query", "draws"),
colorTarget: input("hdr", "spec"),
depthTarget: input("depth", "spec"),
depthStencil: input("depth_config", "config"),
},
),
node(
"copy",
"fullscreen_copy",
{},
{
source: input("forward", "color"),
colorTarget: input("surface", "surface"),
},
),
node("present", "present", {}, { surface: input("copy", "color") }),
],
});
export const culling = Object.freeze((() => {
const graph = structuredClone(hdr);
graph.graphId = "preset_gpu_culling";
graph.nodes.splice(
5,
0,
node("aabbs", "local_aabb_buffer", {}, { scene: input("scene", "scene") }),
node("frustum", "camera_frustum"),
node("cull", "frustum_cull", {}, {
scene: input("scene", "scene"),
localAabbs: input("aabbs", "localAabbs"),
frustum: input("frustum", "frustum"),
}),
);
const query = graph.nodes.find((x) => x.id === "query");
query.parameters.filters[1].predicate = "required_false";
query.inputs.isFrustumCulled = input("cull", "flags");
return graph;
})());
const postPreset = (graphId, kind) => {
const nodes = hdr.nodes.slice(0, 8).map((x) => structuredClone(x));
if (kind === "tone")
nodes.push(
node(
"tone",
"tone_map",
{ exposure: 1 },
{
source: input("forward", "color"),
colorTarget: input("surface", "surface"),
},
),
);
if (kind === "edges") {
nodes.splice(1, 0, node("edge_hdr", "texture_spec", texture("rgba16_float")));
nodes.push(
node(
"edges",
"luminance_edge",
{ strength: 2 },
{
source: input("forward", "color"),
colorTarget: input("edge_hdr", "spec"),
},
),
);
nodes.push(
node(
"tone",
"tone_map",
{ exposure: 1 },
{
source: input("edges", "color"),
colorTarget: input("surface", "surface"),
},
),
);
}
if (kind === "bloom" || kind === "combined") {
const half = {
texture: {
...texture("rgba16_float").texture,
extent: {
kind: "surface_relative",
width: { numerator: 1, denominator: 2 },
height: { numerator: 1, denominator: 2 },
depthOrArrayLayers: 1,
},
},
residency: "transient",
};
nodes.splice(
1,
0,
node("half_a", "texture_spec", structuredClone(half)),
node("half_b", "texture_spec", structuredClone(half)),
node("half_c", "texture_spec", structuredClone(half)),
node("composite_hdr", "texture_spec", texture("rgba16_float")),
);
nodes.push(
node(
"extract",
"bloom_extract",
{ threshold: 1, knee: 0.5 },
{
source: input("forward", "color"),
colorTarget: input("half_a", "spec"),
},
),
);
nodes.push(
node(
"blur_h",
"bloom_blur",
{ direction: [1, 0], radius: 1 },
{
source: input("extract", "color"),
colorTarget: input("half_b", "spec"),
},
),
);
nodes.push(
node(
"blur_v",
"bloom_blur",
{ direction: [0, 1], radius: 1 },
{
source: input("blur_h", "color"),
colorTarget: input("half_c", "spec"),
},
),
);
nodes.push(
node(
"composite",
"bloom_composite",
{ intensity: 0.8 },
{
source: input("forward", "color"),
bloom: input("blur_v", "color"),
colorTarget: input("composite_hdr", "spec"),
},
),
);
let toneSource = "composite";
if (kind === "combined") {
nodes.splice(1, 0, node("edge_hdr", "texture_spec", texture("rgba16_float")));
nodes.push(
node(
"edges",
"luminance_edge",
{ strength: 2 },
{
source: input("composite", "color"),
colorTarget: input("edge_hdr", "spec"),
},
),
);
toneSource = "edges";
}
nodes.push(
node(
"tone",
"tone_map",
{ exposure: 1 },
{
source: input(toneSource, "color"),
colorTarget: input("surface", "surface"),
},
),
);
}
const last = nodes.at(-1);
nodes.push(
node("present", "present", {}, { surface: input(last.id, "color") }),
);
return Object.freeze({ schemaVersion: 2, graphId, revision: 1, nodes });
};
export const tone = postPreset("preset_tone", "tone"),
edges = postPreset("preset_edges", "edges"),
bloom = postPreset("preset_bloom", "bloom"),
combined = postPreset("preset_combined", "combined");
export const renderGraphPresets = Object.freeze({
midnight,
ember,
hdr,
culling,
tone,
edges,
bloom,
combined,
});
+5 -1
View File
@@ -12,7 +12,7 @@ export class RendererError extends Error {
export class RendererClient {
#bridge; #worker; #header; #slots; #buffer; #next = 1; #payload = 1;
#pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false;
#telemetry; #stopped = false;
#telemetry; #profile; #stopped = false;
#graphQueue = []; #graphBusy = false;
#bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map();
@@ -40,6 +40,7 @@ export class RendererClient {
get ready() { return this.#ready; }
get telemetry() { return this.#telemetry; }
get profile() { return this.#profile; }
#refreshViews() {
const buffer = this.#bridge.memory.buffer;
@@ -61,6 +62,9 @@ export class RendererClient {
} else if (message?.type === "telemetry") {
this.#telemetry = message;
dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
} else if (message?.type === "profile-snapshot") {
this.#profile = message;
dispatchEvent(new CustomEvent("renderer-profile", { detail: message }));
} else if (message?.type === "fatal") {
console.error("renderer worker fatal", message.code, message.message);
this.#fail(message.code || "WORKER_FATAL");
+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 assert from "node:assert/strict";
import { createCubeGeometry, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, LoadoutError } from "../static/demo-loadouts.js";
import { createCubeGeometry, createMaterialGalleryGlb, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, loadouts, LoadoutError } from "../static/demo-loadouts.js";
function parseGlb(buffer){const view=new DataView(buffer);assert.equal(view.getUint32(0,true),0x46546c67);assert.equal(view.getUint32(4,true),2);assert.equal(view.getUint32(8,true),buffer.byteLength);const length=view.getUint32(12,true);assert.equal(view.getUint32(16,true),0x4e4f534a);const json=JSON.parse(new TextDecoder().decode(new Uint8Array(buffer,20,length)).trim());const bin=20+length;assert.equal(view.getUint32(bin+4,true),0x004e4942);assert.equal(view.getUint32(bin,true),json.buffers[0].byteLength);return json;}
test("procedural cube and sphere have complete indexed vertex streams",()=>{const cube=createCubeGeometry(),sphere=createUvSphereGeometry();assert.deepEqual([cube.positions.length/3,cube.indices.length],[24,36]);assert.ok(sphere.positions.length/3>300);for(const geometry of [cube,sphere]){assert.equal(geometry.normals.length,geometry.positions.length);assert.equal(geometry.texcoords.length,geometry.positions.length/3*2);assert.ok(geometry.indices.every(i=>i>=0&&i<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("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("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 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";
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"]]};
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 rejects=(mutate,code)=>{const x=structuredClone(fixture());mutate(x);assert.throws(()=>adaptFxNodeSnapshot(x),e=>e instanceof AuthoringGraphError&&e.code===code)};
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)});
test("adapter rejects node catalog, identity, sockets, links, topology and parameters",()=>{
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");
function fixture() {
const nodes = culling.nodes.map((n) => {
const d = semanticCatalog[n.executor.key];
const definition = nodeDefinitions[n.executor.key];
return {
id: n.id,
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 assert from "node:assert/strict";
import { ember, midnight, renderGraphPresets } from "../static/render-graph/presets.js";
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");}});
import {
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});
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 () => {
const f=fixture(), mesh=await imported(f); const {worker,client,bridge}=f;
const a=mesh.setVisible(true), b=mesh.setVisible(false);