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:
Generated
+1
@@ -913,6 +913,7 @@ dependencies = [
|
|||||||
"console_log",
|
"console_log",
|
||||||
"futures",
|
"futures",
|
||||||
"gltf",
|
"gltf",
|
||||||
|
"image",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
|
|||||||
+3
-1
@@ -19,6 +19,7 @@ web-sys = { version = "0.3.77", features = [
|
|||||||
"FileList",
|
"FileList",
|
||||||
"OffscreenCanvas",
|
"OffscreenCanvas",
|
||||||
"MouseEvent",
|
"MouseEvent",
|
||||||
|
"PointerEvent",
|
||||||
"WheelEvent",
|
"WheelEvent",
|
||||||
"KeyboardEvent",
|
"KeyboardEvent",
|
||||||
"Worker",
|
"Worker",
|
||||||
@@ -43,4 +44,5 @@ wgpu = "26.0.1"
|
|||||||
thiserror = "2.0.15"
|
thiserror = "2.0.15"
|
||||||
ultraviolet = "0.10.0"
|
ultraviolet = "0.10.0"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
gltf = { version = "1.4", features = ["extras", "names", "KHR_lights_punctual"] }
|
gltf = { version = "1.4", features = ["extras", "names", "KHR_lights_punctual", "KHR_materials_ior"] }
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||||
|
|||||||
+13
-6
@@ -29,7 +29,7 @@ pub struct EditorScene {
|
|||||||
impl renderer::renderer::scene::Scene for EditorScene {
|
impl renderer::renderer::scene::Scene for EditorScene {
|
||||||
fn setup(
|
fn setup(
|
||||||
renderer_context: &gpu_renderer::RendererContext,
|
renderer_context: &gpu_renderer::RendererContext,
|
||||||
resources: &mut gpu_renderer::GpuResources,
|
resources: &mut gpu_renderer::PipelineLibrary,
|
||||||
render_data: &mut RenderData,
|
render_data: &mut RenderData,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let dimension = ultraviolet::Vec2::new(
|
let dimension = ultraviolet::Vec2::new(
|
||||||
@@ -90,14 +90,18 @@ impl renderer::renderer::scene::Scene for EditorScene {
|
|||||||
self.frame_metadata.mouse_click = [x, y];
|
self.frame_metadata.mouse_click = [x, y];
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_zoom(&mut self, _delta_y: f32) {
|
fn handle_zoom(&mut self, delta_y: f32) {
|
||||||
// TODO: Implement zoom properly when Camera exposes necessary methods
|
self.cam.zoom(delta_y);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_orbit(&mut self, delta_x: f32, delta_y: f32) {
|
fn handle_orbit(&mut self, delta_x: f32, delta_y: f32) {
|
||||||
self.cam.orbit(delta_x, delta_y);
|
self.cam.orbit(delta_x, delta_y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
|
||||||
|
self.cam.pan(delta_x, delta_y, viewport_height);
|
||||||
|
}
|
||||||
|
|
||||||
fn set_camera_depth_range(&mut self, near: f32, far: f32) {
|
fn set_camera_depth_range(&mut self, near: f32, far: f32) {
|
||||||
self.cam.set_depth_range(near, far);
|
self.cam.set_depth_range(near, far);
|
||||||
}
|
}
|
||||||
@@ -149,13 +153,14 @@ impl EditorScene {
|
|||||||
fn create_default_scene(
|
fn create_default_scene(
|
||||||
&mut self,
|
&mut self,
|
||||||
device: &wgpu::Device,
|
device: &wgpu::Device,
|
||||||
resources: &mut gpu_renderer::GpuResources,
|
resources: &mut gpu_renderer::PipelineLibrary,
|
||||||
render_data: &mut RenderData,
|
render_data: &mut RenderData,
|
||||||
surface_format: wgpu::TextureFormat,
|
surface_format: wgpu::TextureFormat,
|
||||||
) {
|
) {
|
||||||
let positions: Vec<[f32; 3]> = Self::VERTICES.iter().map(|v| v.pos).collect();
|
let positions: Vec<[f32; 3]> = Self::VERTICES.iter().map(|v| v.pos).collect();
|
||||||
// Ground plane normals point upward (Y+)
|
// Ground plane normals point upward (Y+)
|
||||||
let normals: Vec<[f32; 3]> = vec![[0.0, 1.0, 0.0]; positions.len()];
|
let normals: Vec<[f32; 3]> = vec![[0.0, 1.0, 0.0]; positions.len()];
|
||||||
|
let tangents: Vec<[f32; 4]> = vec![[1.0, 0.0, 0.0, 1.0]; positions.len()];
|
||||||
let uvs: &[[f32; 2]] = &[
|
let uvs: &[[f32; 2]] = &[
|
||||||
[0.0, 0.0],
|
[0.0, 0.0],
|
||||||
[1.0, 0.0],
|
[1.0, 0.0],
|
||||||
@@ -183,9 +188,11 @@ impl EditorScene {
|
|||||||
.create_mesh(MeshCreateInfo {
|
.create_mesh(MeshCreateInfo {
|
||||||
positions: &positions,
|
positions: &positions,
|
||||||
normals: &normals,
|
normals: &normals,
|
||||||
|
tangents: &tangents,
|
||||||
uvs,
|
uvs,
|
||||||
indices: Self::INDICES,
|
indices: Self::INDICES,
|
||||||
pipeline: pipeline_index,
|
pipeline: pipeline_index,
|
||||||
|
material: renderer::render_data::MaterialKey::DEFAULT,
|
||||||
flags: RenderFlags::VISIBLE,
|
flags: RenderFlags::VISIBLE,
|
||||||
default_instance_flags: RenderFlags::VISIBLE,
|
default_instance_flags: RenderFlags::VISIBLE,
|
||||||
default_transform: transform,
|
default_transform: transform,
|
||||||
@@ -196,11 +203,11 @@ impl EditorScene {
|
|||||||
|
|
||||||
/// Entrypoint for the level editor
|
/// Entrypoint for the level editor
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn main() -> Result<RendererBridge, JsValue> {
|
pub fn main(profile: bool) -> Result<RendererBridge, JsValue> {
|
||||||
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
|
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
|
||||||
wasm_logger::init(wasm_logger::Config::default());
|
wasm_logger::init(wasm_logger::Config::default());
|
||||||
|
|
||||||
let runtime = LevelEditor::setup_runtime()?;
|
let runtime = LevelEditor::setup_runtime(profile)?;
|
||||||
Ok(RendererBridge { runtime })
|
Ok(RendererBridge { runtime })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ thiserror = { workspace = true }
|
|||||||
ultraviolet = { workspace = true }
|
ultraviolet = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
gltf = { workspace = true }
|
gltf = { workspace = true }
|
||||||
|
image = { workspace = true }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
|||||||
+84
-55
@@ -18,9 +18,10 @@ use web_sys::AddEventListenerOptions;
|
|||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub struct EventListeners {
|
pub struct EventListeners {
|
||||||
pub resize_listener: Option<Closure<dyn FnMut()>>,
|
pub resize_listener: Option<Closure<dyn FnMut()>>,
|
||||||
pub mousemove_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
|
pub pointer_listener: Option<Closure<dyn FnMut(web_sys::PointerEvent)>>,
|
||||||
pub mousedown_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
|
pub click_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
|
||||||
pub wheel_listener: Option<Closure<dyn FnMut(web_sys::WheelEvent)>>,
|
pub wheel_listener: Option<Closure<dyn FnMut(web_sys::WheelEvent)>>,
|
||||||
|
pub contextmenu_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
|
||||||
pub keyboard_listener: Option<Closure<dyn FnMut(web_sys::KeyboardEvent)>>,
|
pub keyboard_listener: Option<Closure<dyn FnMut(web_sys::KeyboardEvent)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,9 +30,10 @@ impl EventListeners {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
resize_listener: None,
|
resize_listener: None,
|
||||||
mousemove_listener: None,
|
pointer_listener: None,
|
||||||
mousedown_listener: None,
|
click_listener: None,
|
||||||
wheel_listener: None,
|
wheel_listener: None,
|
||||||
|
contextmenu_listener: None,
|
||||||
keyboard_listener: None,
|
keyboard_listener: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,65 +56,82 @@ pub fn setup_event_listeners(
|
|||||||
let width = f64::from(resize_canvas.client_width().max(1));
|
let width = f64::from(resize_canvas.client_width().max(1));
|
||||||
let height = f64::from(resize_canvas.client_height().max(1));
|
let height = f64::from(resize_canvas.client_height().max(1));
|
||||||
|
|
||||||
resize_worker_chan
|
let _ = resize_worker_chan.send(WindowEvent::Resize(ResizeMessage {
|
||||||
.send(WindowEvent::Resize(ResizeMessage {
|
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
scale_factor: window.device_pixel_ratio(),
|
scale_factor: window.device_pixel_ratio(),
|
||||||
}))
|
}));
|
||||||
.unwrap();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.add_event_listener_with_callback("resize", resize_listener.as_ref().unchecked_ref())?;
|
window.add_event_listener_with_callback("resize", resize_listener.as_ref().unchecked_ref())?;
|
||||||
|
|
||||||
let mousemove_worker_chan = worker_chan.clone();
|
let pointer_worker_chan = worker_chan.clone();
|
||||||
let mousemove_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
|
let pointer_canvas = canvas.clone();
|
||||||
|
let pointer_listener: Closure<dyn FnMut(web_sys::PointerEvent)> =
|
||||||
|
Closure::new(move |event: web_sys::PointerEvent| {
|
||||||
|
use crate::message::{camera_drag, MouseMessage};
|
||||||
|
|
||||||
|
if event.pointer_type() != "mouse" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match event.type_().as_str() {
|
||||||
|
"pointerdown" if matches!(event.button(), 1 | 2) => {
|
||||||
|
event.prevent_default();
|
||||||
|
let _ = pointer_canvas.set_pointer_capture(event.pointer_id());
|
||||||
|
}
|
||||||
|
"pointermove"
|
||||||
|
if pointer_canvas.has_pointer_capture(event.pointer_id())
|
||||||
|
&& camera_drag(event.buttons()).is_some() =>
|
||||||
|
{
|
||||||
|
event.prevent_default();
|
||||||
|
let message = MouseMessage::from_pointer_evt(
|
||||||
|
&event,
|
||||||
|
f64::from(pointer_canvas.client_height().max(1)),
|
||||||
|
);
|
||||||
|
let _ = pointer_worker_chan.send(WindowEvent::PointerMove(message));
|
||||||
|
}
|
||||||
|
"pointerup" | "pointercancel"
|
||||||
|
if pointer_canvas.has_pointer_capture(event.pointer_id()) =>
|
||||||
|
{
|
||||||
|
let _ = pointer_canvas.release_pointer_capture(event.pointer_id());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for event_name in ["pointerdown", "pointermove", "pointerup", "pointercancel"] {
|
||||||
|
canvas.add_event_listener_with_callback(
|
||||||
|
event_name,
|
||||||
|
pointer_listener.as_ref().unchecked_ref(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let click_worker_chan = worker_chan.clone();
|
||||||
|
let click_canvas = canvas.clone();
|
||||||
|
let click_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
|
||||||
Closure::new(move |event: web_sys::MouseEvent| {
|
Closure::new(move |event: web_sys::MouseEvent| {
|
||||||
use crate::message::MouseMessage;
|
use crate::message::MouseMessage;
|
||||||
if event.buttons() & 0x04 != 0 {
|
if event.button() != 0 {
|
||||||
event.prevent_default();
|
return;
|
||||||
}
|
}
|
||||||
let mouse_event_data = MouseMessage::from_evt(event.clone());
|
let message =
|
||||||
|
MouseMessage::from_evt(&event, f64::from(click_canvas.client_height().max(1)));
|
||||||
let mut event_data = WindowEvent::PointerMove(mouse_event_data.clone());
|
let _ = click_worker_chan.send(WindowEvent::PointerClick(message));
|
||||||
if event.type_() == "click" {
|
|
||||||
event_data = WindowEvent::PointerClick(mouse_event_data.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
mousemove_worker_chan.clone().send(event_data).unwrap();
|
|
||||||
});
|
});
|
||||||
|
canvas.add_event_listener_with_callback("click", click_listener.as_ref().unchecked_ref())?;
|
||||||
window.add_event_listener_with_callback(
|
|
||||||
"mousemove",
|
|
||||||
mousemove_listener.as_ref().unchecked_ref(),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
window
|
|
||||||
.add_event_listener_with_callback("click", mousemove_listener.as_ref().unchecked_ref())?;
|
|
||||||
|
|
||||||
let mousedown_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
|
|
||||||
Closure::new(move |event: web_sys::MouseEvent| {
|
|
||||||
if event.button() == 1 {
|
|
||||||
event.prevent_default();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.add_event_listener_with_callback(
|
|
||||||
"mousedown",
|
|
||||||
mousedown_listener.as_ref().unchecked_ref(),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let wheel_worker_chan = worker_chan.clone();
|
let wheel_worker_chan = worker_chan.clone();
|
||||||
|
let wheel_canvas = canvas.clone();
|
||||||
let wheel_listener: Closure<dyn FnMut(web_sys::WheelEvent)> =
|
let wheel_listener: Closure<dyn FnMut(web_sys::WheelEvent)> =
|
||||||
Closure::new(move |event: web_sys::WheelEvent| {
|
Closure::new(move |event: web_sys::WheelEvent| {
|
||||||
use crate::message::WheelMessage;
|
use crate::message::WheelMessage;
|
||||||
|
|
||||||
event.prevent_default();
|
event.prevent_default();
|
||||||
let wheel_event_data = WheelMessage::from_evt(event);
|
if let Some(message) =
|
||||||
|
WheelMessage::from_evt(&event, f64::from(wheel_canvas.client_height().max(1)))
|
||||||
wheel_worker_chan
|
{
|
||||||
.send(WindowEvent::PointerWheel(wheel_event_data))
|
let _ = wheel_worker_chan.send(WindowEvent::PointerWheel(message));
|
||||||
.unwrap();
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let wheel_options = {
|
let wheel_options = {
|
||||||
@@ -121,12 +140,19 @@ pub fn setup_event_listeners(
|
|||||||
options
|
options
|
||||||
};
|
};
|
||||||
|
|
||||||
window.add_event_listener_with_callback_and_add_event_listener_options(
|
canvas.add_event_listener_with_callback_and_add_event_listener_options(
|
||||||
"wheel",
|
"wheel",
|
||||||
wheel_listener.as_ref().unchecked_ref(),
|
wheel_listener.as_ref().unchecked_ref(),
|
||||||
&wheel_options,
|
&wheel_options,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let contextmenu_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
|
||||||
|
Closure::new(move |event: web_sys::MouseEvent| event.prevent_default());
|
||||||
|
canvas.add_event_listener_with_callback(
|
||||||
|
"contextmenu",
|
||||||
|
contextmenu_listener.as_ref().unchecked_ref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
let keyboard_worker_chan = worker_chan.clone();
|
let keyboard_worker_chan = worker_chan.clone();
|
||||||
let keyboard_listener: Closure<dyn FnMut(web_sys::KeyboardEvent)> =
|
let keyboard_listener: Closure<dyn FnMut(web_sys::KeyboardEvent)> =
|
||||||
Closure::new(move |event: web_sys::KeyboardEvent| {
|
Closure::new(move |event: web_sys::KeyboardEvent| {
|
||||||
@@ -134,9 +160,7 @@ pub fn setup_event_listeners(
|
|||||||
|
|
||||||
let keyboard_event_data = KeyboardMessage::from_evt(event);
|
let keyboard_event_data = KeyboardMessage::from_evt(event);
|
||||||
|
|
||||||
keyboard_worker_chan
|
let _ = keyboard_worker_chan.send(WindowEvent::Keyboard(keyboard_event_data));
|
||||||
.send(WindowEvent::Keyboard(keyboard_event_data))
|
|
||||||
.unwrap();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window
|
window
|
||||||
@@ -144,9 +168,10 @@ pub fn setup_event_listeners(
|
|||||||
|
|
||||||
Ok(EventListeners {
|
Ok(EventListeners {
|
||||||
resize_listener: Some(resize_listener),
|
resize_listener: Some(resize_listener),
|
||||||
mousemove_listener: Some(mousemove_listener),
|
pointer_listener: Some(pointer_listener),
|
||||||
mousedown_listener: Some(mousedown_listener),
|
click_listener: Some(click_listener),
|
||||||
wheel_listener: Some(wheel_listener),
|
wheel_listener: Some(wheel_listener),
|
||||||
|
contextmenu_listener: Some(contextmenu_listener),
|
||||||
keyboard_listener: Some(keyboard_listener),
|
keyboard_listener: Some(keyboard_listener),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -166,6 +191,7 @@ impl WebAppRuntime {
|
|||||||
pub fn new<T: crate::renderer::scene::Scene + 'static>(
|
pub fn new<T: crate::renderer::scene::Scene + 'static>(
|
||||||
worker_name: &str,
|
worker_name: &str,
|
||||||
canvas_selector: &str,
|
canvas_selector: &str,
|
||||||
|
profile: bool,
|
||||||
) -> Result<Self, JsValue> {
|
) -> Result<Self, JsValue> {
|
||||||
let (sender, receiver) = mpsc::channel::<WindowEvent>();
|
let (sender, receiver) = mpsc::channel::<WindowEvent>();
|
||||||
|
|
||||||
@@ -179,7 +205,7 @@ impl WebAppRuntime {
|
|||||||
let worker = MainWorker::spawn(worker_name, 1, ring_ptr, move || {
|
let worker = MainWorker::spawn(worker_name, 1, ring_ptr, move || {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let ring = unsafe { &*(ring_ptr as *const CommandRing) };
|
let ring = unsafe { &*(ring_ptr as *const CommandRing) };
|
||||||
MainWorker::run_render_loop::<T>(receiver, ring).await;
|
MainWorker::run_render_loop::<T>(receiver, ring, profile).await;
|
||||||
});
|
});
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -228,9 +254,12 @@ pub trait WebApp {
|
|||||||
fn on_runtime_initialized(_runtime: &mut WebAppRuntime) {}
|
fn on_runtime_initialized(_runtime: &mut WebAppRuntime) {}
|
||||||
|
|
||||||
/// Perform the default WASM initialization routine.
|
/// Perform the default WASM initialization routine.
|
||||||
fn setup_runtime() -> Result<WebAppRuntime, JsValue> {
|
fn setup_runtime(profile: bool) -> Result<WebAppRuntime, JsValue> {
|
||||||
let mut runtime =
|
let mut runtime = WebAppRuntime::new::<Self::Scene>(
|
||||||
WebAppRuntime::new::<Self::Scene>(Self::worker_name(), Self::canvas_selector())?;
|
Self::worker_name(),
|
||||||
|
Self::canvas_selector(),
|
||||||
|
profile,
|
||||||
|
)?;
|
||||||
Self::on_runtime_initialized(&mut runtime);
|
Self::on_runtime_initialized(&mut runtime);
|
||||||
Ok(runtime)
|
Ok(runtime)
|
||||||
}
|
}
|
||||||
|
|||||||
+275
-28
@@ -3,7 +3,16 @@ use std::f32::consts::PI;
|
|||||||
use ultraviolet::{projection, Bivec3, Mat4, Rotor3, Vec3};
|
use ultraviolet::{projection, Bivec3, Mat4, Rotor3, Vec3};
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
use crate::{message::WheelMessage, renderer::scene::UniformResource};
|
use crate::renderer::scene::UniformResource;
|
||||||
|
|
||||||
|
/// A camera matrix cannot produce a safe, meaningful frustum.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum FrustumError {
|
||||||
|
#[error("frustum plane {plane} contains a non-finite component")]
|
||||||
|
NonFinite { plane: usize },
|
||||||
|
#[error("frustum plane {plane} has a near-degenerate normal")]
|
||||||
|
Degenerate { plane: usize },
|
||||||
|
}
|
||||||
|
|
||||||
const MIN_DISTANCE: f32 = 0.1;
|
const MIN_DISTANCE: f32 = 0.1;
|
||||||
const MAX_PITCH: f32 = PI / 2.0 - 0.01;
|
const MAX_PITCH: f32 = PI / 2.0 - 0.01;
|
||||||
@@ -81,6 +90,9 @@ pub struct CameraUniform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Camera {
|
impl Camera {
|
||||||
|
pub fn frustum_planes(&self) -> Result<[[f32; 4]; 6], FrustumError> {
|
||||||
|
extract_frustum_planes(self.view_proj)
|
||||||
|
}
|
||||||
pub fn new(aspect_ratio: f32) -> Self {
|
pub fn new(aspect_ratio: f32) -> Self {
|
||||||
let mut camera = Camera {
|
let mut camera = Camera {
|
||||||
view_proj: [[0.0; 4]; 4],
|
view_proj: [[0.0; 4]; 4],
|
||||||
@@ -115,8 +127,14 @@ impl Camera {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn look_at(&mut self, position: Vec3, target: Vec3) {
|
pub fn look_at(&mut self, position: Vec3, target: Vec3) {
|
||||||
|
if !vec3_is_finite(position) || !vec3_is_finite(target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.position = position;
|
self.position = position;
|
||||||
self.target = target;
|
self.target = target;
|
||||||
|
if (self.position - self.target).mag_sq() <= f32::EPSILON {
|
||||||
|
self.position = self.target + Vec3::unit_z() * MIN_DISTANCE;
|
||||||
|
}
|
||||||
self.up = Vec3::unit_y();
|
self.up = Vec3::unit_y();
|
||||||
self.compute_rotor();
|
self.compute_rotor();
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
@@ -141,6 +159,9 @@ impl Camera {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
|
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
|
||||||
|
if !delta_x.is_finite() || !delta_y.is_finite() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Skip tiny movements to reduce unnecessary computations
|
// Skip tiny movements to reduce unnecessary computations
|
||||||
if delta_x.abs() < 0.001 && delta_y.abs() < 0.001 {
|
if delta_x.abs() < 0.001 && delta_y.abs() < 0.001 {
|
||||||
return;
|
return;
|
||||||
@@ -174,39 +195,62 @@ impl Camera {
|
|||||||
self.compute_view_proj_mat();
|
self.compute_view_proj_mat();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn zoom(&mut self, msg: &WheelMessage) {
|
pub fn zoom(&mut self, delta_y_pixels: f32) {
|
||||||
let mut delta = msg.delta_y as f32;
|
if !delta_y_pixels.is_finite() || delta_y_pixels.abs() <= f32::EPSILON {
|
||||||
|
|
||||||
// Match browser delta modes so the wheel delta is always roughly pixels.
|
|
||||||
match msg.delta_mode {
|
|
||||||
1 => delta *= 16.0,
|
|
||||||
2 => delta *= 800.0,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scrolling up should zoom in.
|
|
||||||
delta = -delta;
|
|
||||||
|
|
||||||
if delta.abs() <= f32::EPSILON {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get forward direction from camera position to target
|
let mut offset = self.position - self.target;
|
||||||
let mut forward_vec = self.target - self.position;
|
let mut current_distance = offset.mag();
|
||||||
if forward_vec.mag_sq() <= f32::EPSILON {
|
if !current_distance.is_finite() {
|
||||||
forward_vec = Vec3::unit_z();
|
return;
|
||||||
|
}
|
||||||
|
if current_distance <= f32::EPSILON {
|
||||||
|
offset = Vec3::unit_z() * MIN_DISTANCE;
|
||||||
|
current_distance = MIN_DISTANCE;
|
||||||
|
}
|
||||||
|
let direction = offset / current_distance;
|
||||||
|
let max_distance = (self.z_far * 0.95).max(MIN_DISTANCE);
|
||||||
|
if !max_distance.is_finite() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let candidate = f64::from(current_distance)
|
||||||
|
* (f64::from(ZOOM_SENSITIVITY) * f64::from(delta_y_pixels)).exp();
|
||||||
|
let new_distance = candidate.clamp(f64::from(MIN_DISTANCE), f64::from(max_distance)) as f32;
|
||||||
|
let new_position = self.target + direction * new_distance;
|
||||||
|
if !vec3_is_finite(new_position) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
let forward_dir = forward_vec.normalized();
|
|
||||||
let current_distance = forward_vec.mag();
|
|
||||||
|
|
||||||
// Scale dolly movement by distance to target for consistent perceived zoom speed
|
self.position = new_position;
|
||||||
let dolly_distance = delta * ZOOM_SENSITIVITY * current_distance;
|
self.distance = new_distance;
|
||||||
let dolly_translation = forward_dir * dolly_distance;
|
self.dirty = true;
|
||||||
|
self.compute_view_proj_mat();
|
||||||
|
}
|
||||||
|
|
||||||
self.position += dolly_translation;
|
pub fn pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
|
||||||
self.target += dolly_translation;
|
if !delta_x.is_finite()
|
||||||
|
|| !delta_y.is_finite()
|
||||||
self.compute_rotor();
|
|| !viewport_height.is_finite()
|
||||||
|
|| !self.fov.is_finite()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let distance = (self.position - self.target).mag().max(MIN_DISTANCE);
|
||||||
|
if !distance.is_finite() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let world_units_per_pixel =
|
||||||
|
2.0 * distance * (self.fov * 0.5).tan() / viewport_height.max(1.0);
|
||||||
|
let basis = OrthonormalBasis::from_camera(self);
|
||||||
|
let translation = (-basis.right * delta_x + basis.up * delta_y) * world_units_per_pixel;
|
||||||
|
let position = self.position + translation;
|
||||||
|
let target = self.target + translation;
|
||||||
|
if !vec3_is_finite(position) || !vec3_is_finite(target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.position = position;
|
||||||
|
self.target = target;
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
self.compute_view_proj_mat();
|
self.compute_view_proj_mat();
|
||||||
}
|
}
|
||||||
@@ -294,3 +338,206 @@ impl Camera {
|
|||||||
self.rotor = (swing_rotor * twist_rotor).normalized();
|
self.rotor = (swing_rotor * twist_rotor).normalized();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extracts inward-facing normalized WebGPU clip-space planes (zero-to-one depth).
|
||||||
|
pub fn extract_frustum_planes(m: [[f32; 4]; 4]) -> Result<[[f32; 4]; 6], FrustumError> {
|
||||||
|
let row = |r: usize| [m[0][r], m[1][r], m[2][r], m[3][r]];
|
||||||
|
let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]];
|
||||||
|
let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]];
|
||||||
|
let r0 = row(0);
|
||||||
|
let r1 = row(1);
|
||||||
|
let r2 = row(2);
|
||||||
|
let r3 = row(3);
|
||||||
|
let mut planes = [
|
||||||
|
add(r3, r0),
|
||||||
|
sub(r3, r0),
|
||||||
|
add(r3, r1),
|
||||||
|
sub(r3, r1),
|
||||||
|
r2,
|
||||||
|
sub(r3, r2),
|
||||||
|
];
|
||||||
|
for (plane, p) in planes.iter_mut().enumerate() {
|
||||||
|
if !p.iter().all(|component| component.is_finite()) {
|
||||||
|
return Err(FrustumError::NonFinite { plane });
|
||||||
|
}
|
||||||
|
// Scale first: directly squaring very large/small coefficients can overflow or
|
||||||
|
// underflow even though the plane itself is normalizable.
|
||||||
|
let scale = p[0].abs().max(p[1].abs()).max(p[2].abs());
|
||||||
|
if scale < f32::MIN_POSITIVE {
|
||||||
|
return Err(FrustumError::Degenerate { plane });
|
||||||
|
}
|
||||||
|
let scaled = [p[0] / scale, p[1] / scale, p[2] / scale];
|
||||||
|
let length = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).sqrt();
|
||||||
|
for v in p {
|
||||||
|
*v = (*v / scale) / length;
|
||||||
|
if !v.is_finite() {
|
||||||
|
return Err(FrustumError::NonFinite { plane });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(planes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vec3_is_finite(value: Vec3) -> bool {
|
||||||
|
value.x.is_finite() && value.y.is_finite() && value.z.is_finite()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frustum_extraction_rejects_nonfinite_and_degenerate_planes() {
|
||||||
|
let mut nonfinite = Camera::new(1.0).view_proj;
|
||||||
|
nonfinite[0][0] = f32::NAN;
|
||||||
|
assert!(matches!(
|
||||||
|
extract_frustum_planes(nonfinite),
|
||||||
|
Err(FrustumError::NonFinite { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
extract_frustum_planes([[0.0; 4]; 4]),
|
||||||
|
Err(FrustumError::Degenerate { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frustum_extraction_normalizes_without_overflow() {
|
||||||
|
let mut matrix = Camera::new(1.0).view_proj;
|
||||||
|
for value in matrix.iter_mut().flatten() {
|
||||||
|
*value *= 1.0e20;
|
||||||
|
}
|
||||||
|
let planes = extract_frustum_planes(matrix).unwrap();
|
||||||
|
for plane in planes {
|
||||||
|
let length = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
|
||||||
|
assert!((length - 1.0).abs() < 1.0e-5);
|
||||||
|
assert!(plane.iter().all(|value| value.is_finite()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_vec3_close(actual: Vec3, expected: Vec3, epsilon: f32) {
|
||||||
|
assert!(
|
||||||
|
(actual.x - expected.x).abs() <= epsilon,
|
||||||
|
"x: {actual:?} != {expected:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(actual.y - expected.y).abs() <= epsilon,
|
||||||
|
"y: {actual:?} != {expected:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(actual.z - expected.z).abs() <= epsilon,
|
||||||
|
"z: {actual:?} != {expected:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_camera_finite(camera: &Camera) {
|
||||||
|
assert!(vec3_is_finite(camera.position));
|
||||||
|
assert!(vec3_is_finite(camera.target));
|
||||||
|
assert!(camera.distance.is_finite());
|
||||||
|
assert!(camera
|
||||||
|
.view_proj
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.all(|component| component.is_finite()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zoom_is_multiplicative_and_preserves_target() {
|
||||||
|
let mut camera = Camera::new(1.0);
|
||||||
|
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
|
||||||
|
let initial_position = camera.position;
|
||||||
|
let initial_target = camera.target;
|
||||||
|
let initial_distance = camera.distance;
|
||||||
|
|
||||||
|
camera.zoom(-100.0);
|
||||||
|
assert!(camera.distance < initial_distance);
|
||||||
|
assert_eq!(camera.target, initial_target);
|
||||||
|
|
||||||
|
camera.zoom(100.0);
|
||||||
|
assert_vec3_close(camera.position, initial_position, 1e-4);
|
||||||
|
assert!((camera.distance - initial_distance).abs() <= 1e-4);
|
||||||
|
|
||||||
|
camera.zoom(100.0);
|
||||||
|
assert!(camera.distance > initial_distance);
|
||||||
|
assert_eq!(camera.target, initial_target);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zoom_clamps_and_remains_finite() {
|
||||||
|
let mut camera = Camera::new(1.0);
|
||||||
|
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
|
||||||
|
camera.zoom(f32::NEG_INFINITY);
|
||||||
|
assert!((camera.distance - 10.0).abs() <= 1e-5);
|
||||||
|
camera.zoom(-f32::MAX);
|
||||||
|
assert!((camera.distance - MIN_DISTANCE).abs() <= f32::EPSILON);
|
||||||
|
camera.zoom(f32::MAX);
|
||||||
|
assert!(camera.distance <= camera.z_far * 0.95);
|
||||||
|
assert_camera_finite(&camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pan_moves_eye_and_target_equally_at_target_plane_scale() {
|
||||||
|
let mut camera = Camera::new(1.0);
|
||||||
|
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
|
||||||
|
let initial_position = camera.position;
|
||||||
|
let initial_target = camera.target;
|
||||||
|
let initial_offset = initial_position - initial_target;
|
||||||
|
let units = 2.0 * 10.0 * (PI / 6.0).tan() / 1000.0;
|
||||||
|
|
||||||
|
camera.pan(20.0, 10.0, 1000.0);
|
||||||
|
|
||||||
|
let translation = Vec3::new(-20.0 * units, 10.0 * units, 0.0);
|
||||||
|
assert_vec3_close(camera.position, initial_position + translation, 1e-5);
|
||||||
|
assert_vec3_close(camera.target, initial_target + translation, 1e-5);
|
||||||
|
assert_vec3_close(camera.position - camera.target, initial_offset, 1e-5);
|
||||||
|
assert!((camera.distance - 10.0).abs() <= 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pan_scale_is_proportional_to_distance() {
|
||||||
|
let mut near = Camera::new(1.0);
|
||||||
|
near.look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::zero());
|
||||||
|
let mut far = Camera::new(1.0);
|
||||||
|
far.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
|
||||||
|
|
||||||
|
near.pan(10.0, 0.0, 1000.0);
|
||||||
|
far.pan(10.0, 0.0, 1000.0);
|
||||||
|
|
||||||
|
assert!((far.target.mag() / near.target.mag() - 2.0).abs() <= 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orbit_preserves_target_and_distance() {
|
||||||
|
let mut camera = Camera::new(1.0);
|
||||||
|
camera.look_at(Vec3::new(2.0, 3.0, 10.0), Vec3::new(1.0, -1.0, 0.5));
|
||||||
|
let initial_target = camera.target;
|
||||||
|
let initial_distance = (camera.position - camera.target).mag();
|
||||||
|
|
||||||
|
camera.orbit(40.0, -25.0);
|
||||||
|
|
||||||
|
assert_eq!(camera.target, initial_target);
|
||||||
|
assert!(((camera.position - camera.target).mag() - initial_distance).abs() <= 1e-5);
|
||||||
|
assert!((camera.distance - initial_distance).abs() <= 1e-5);
|
||||||
|
assert!(camera.distance >= MIN_DISTANCE);
|
||||||
|
assert_camera_finite(&camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn controls_reject_invalid_input_and_recover_degenerate_look_at() {
|
||||||
|
let mut camera = Camera::new(1.0);
|
||||||
|
camera.look_at(Vec3::zero(), Vec3::zero());
|
||||||
|
assert!((camera.position - camera.target).mag() >= MIN_DISTANCE);
|
||||||
|
let position = camera.position;
|
||||||
|
let target = camera.target;
|
||||||
|
|
||||||
|
camera.orbit(f32::NAN, 1.0);
|
||||||
|
camera.zoom(f32::INFINITY);
|
||||||
|
camera.pan(f32::NAN, 1.0, 0.0);
|
||||||
|
assert_eq!(camera.position, position);
|
||||||
|
assert_eq!(camera.target, target);
|
||||||
|
|
||||||
|
camera.pan(1.0, 1.0, 0.0);
|
||||||
|
camera.orbit(4.0, -3.0);
|
||||||
|
camera.zoom(2.0);
|
||||||
|
assert_camera_finite(&camera);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+516
-11
@@ -4,10 +4,100 @@ use gltf::Gltf;
|
|||||||
use ultraviolet::{Mat4, Vec3};
|
use ultraviolet::{Mat4, Vec3};
|
||||||
|
|
||||||
use crate::render_data::{
|
use crate::render_data::{
|
||||||
InstanceHandle, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey, RenderData,
|
InstanceHandle, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey,
|
||||||
RenderDataError, RenderFlags,
|
RenderData, RenderDataError, RenderFlags,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum AlphaMode {
|
||||||
|
#[default]
|
||||||
|
Opaque,
|
||||||
|
Mask,
|
||||||
|
Blend,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct TextureReference {
|
||||||
|
pub texture: usize,
|
||||||
|
pub tex_coord: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Material {
|
||||||
|
pub key: MaterialKey,
|
||||||
|
pub base_color_factor: [f32; 4],
|
||||||
|
pub metallic_factor: f32,
|
||||||
|
pub roughness_factor: f32,
|
||||||
|
pub emissive_factor: [f32; 3],
|
||||||
|
/// Index of refraction for the dielectric Fresnel response.
|
||||||
|
pub ior: f32,
|
||||||
|
pub alpha_mode: AlphaMode,
|
||||||
|
pub alpha_cutoff: f32,
|
||||||
|
pub double_sided: bool,
|
||||||
|
pub base_color_texture: Option<TextureReference>,
|
||||||
|
pub metallic_roughness_texture: Option<TextureReference>,
|
||||||
|
pub normal_texture: Option<TextureReference>,
|
||||||
|
pub normal_scale: f32,
|
||||||
|
pub occlusion_texture: Option<TextureReference>,
|
||||||
|
pub occlusion_strength: f32,
|
||||||
|
pub emissive_texture: Option<TextureReference>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Material {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
key: MaterialKey::DEFAULT,
|
||||||
|
base_color_factor: [1.0; 4],
|
||||||
|
metallic_factor: 1.0,
|
||||||
|
roughness_factor: 1.0,
|
||||||
|
emissive_factor: [0.0; 3],
|
||||||
|
ior: 1.5,
|
||||||
|
alpha_mode: AlphaMode::Opaque,
|
||||||
|
alpha_cutoff: 0.5,
|
||||||
|
double_sided: false,
|
||||||
|
base_color_texture: None,
|
||||||
|
metallic_roughness_texture: None,
|
||||||
|
normal_texture: None,
|
||||||
|
normal_scale: 1.0,
|
||||||
|
occlusion_texture: None,
|
||||||
|
occlusion_strength: 1.0,
|
||||||
|
emissive_texture: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct TextureMetadata {
|
||||||
|
pub image: usize,
|
||||||
|
pub sampler: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SamplerMetadata {
|
||||||
|
pub index: usize,
|
||||||
|
pub mag_filter: Option<String>,
|
||||||
|
pub min_filter: Option<String>,
|
||||||
|
pub wrap_s: String,
|
||||||
|
pub wrap_t: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ImageSource {
|
||||||
|
Uri(String),
|
||||||
|
BufferView(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ImageMetadata {
|
||||||
|
pub index: usize,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub source: ImageSource,
|
||||||
|
/// Encoded PNG/JPEG bytes. Kept encoded so GPU installation can decode and
|
||||||
|
/// upload images one at a time instead of retaining a decoded image batch.
|
||||||
|
pub encoded_data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct InstalledScene {
|
pub struct InstalledScene {
|
||||||
pub meshes: Vec<MeshHandle>,
|
pub meshes: Vec<MeshHandle>,
|
||||||
@@ -60,16 +150,31 @@ pub enum ImportError {
|
|||||||
GltfParse(#[from] gltf::Error),
|
GltfParse(#[from] gltf::Error),
|
||||||
#[error("unsupported or malformed primitive: {0}")]
|
#[error("unsupported or malformed primitive: {0}")]
|
||||||
InvalidPrimitive(String),
|
InvalidPrimitive(String),
|
||||||
|
#[error("unsupported image source: {0}")]
|
||||||
|
UnsupportedImage(String),
|
||||||
|
#[error("invalid KHR_materials_ior value: {0}")]
|
||||||
|
InvalidIor(f32),
|
||||||
#[error("failed to install imported scene")]
|
#[error("failed to install imported scene")]
|
||||||
Install(#[from] RenderDataError),
|
Install(#[from] RenderDataError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decode_ior(value: Option<f32>) -> Result<f32, ImportError> {
|
||||||
|
let ior = value.unwrap_or(1.5);
|
||||||
|
if ior == 0.0 || (ior.is_finite() && ior >= 1.0) {
|
||||||
|
Ok(ior)
|
||||||
|
} else {
|
||||||
|
Err(ImportError::InvalidIor(ior))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ImportedGeometry {
|
pub struct ImportedGeometry {
|
||||||
pub key: (usize, usize),
|
pub key: (usize, usize),
|
||||||
|
pub material: MaterialKey,
|
||||||
pub double_sided: bool,
|
pub double_sided: bool,
|
||||||
pub positions: Vec<[f32; 3]>,
|
pub positions: Vec<[f32; 3]>,
|
||||||
pub normals: Vec<[f32; 3]>,
|
pub normals: Vec<[f32; 3]>,
|
||||||
|
pub tangents: Vec<[f32; 4]>,
|
||||||
pub uvs: Vec<[f32; 2]>,
|
pub uvs: Vec<[f32; 2]>,
|
||||||
pub indices: Vec<u32>,
|
pub indices: Vec<u32>,
|
||||||
}
|
}
|
||||||
@@ -82,12 +187,257 @@ pub struct ImportedOccurrence {
|
|||||||
pub struct ImportedScene {
|
pub struct ImportedScene {
|
||||||
pub geometries: Vec<ImportedGeometry>,
|
pub geometries: Vec<ImportedGeometry>,
|
||||||
pub occurrences: Vec<ImportedOccurrence>,
|
pub occurrences: Vec<ImportedOccurrence>,
|
||||||
|
pub materials: Vec<Material>,
|
||||||
|
pub textures: Vec<TextureMetadata>,
|
||||||
|
pub samplers: Vec<SamplerMetadata>,
|
||||||
|
pub images: Vec<ImageMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn texture_reference(info: gltf::texture::Info<'_>) -> TextureReference {
|
||||||
|
TextureReference {
|
||||||
|
texture: info.texture().index(),
|
||||||
|
tex_coord: info.tex_coord(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(value: [f32; 3], fallback: [f32; 3]) -> [f32; 3] {
|
||||||
|
let length = value.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
if length > f32::EPSILON && length.is_finite() {
|
||||||
|
value.map(|x| x / length)
|
||||||
|
} else {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||||
|
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||||
|
[
|
||||||
|
a[1] * b[2] - a[2] * b[1],
|
||||||
|
a[2] * b[0] - a[0] * b[2],
|
||||||
|
a[0] * b[1] - a[1] * b[0],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||||
|
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes triangle vertex attributes. Generated attributes use corner vertices,
|
||||||
|
/// which deliberately splits UV seams, hard normal edges, and opposite handedness.
|
||||||
|
fn repair_geometry(
|
||||||
|
positions: Vec<[f32; 3]>,
|
||||||
|
normals: Option<Vec<[f32; 3]>>,
|
||||||
|
tangents: Option<Vec<[f32; 4]>>,
|
||||||
|
uvs: Vec<[f32; 2]>,
|
||||||
|
indices: Vec<u32>,
|
||||||
|
) -> Result<
|
||||||
|
(
|
||||||
|
Vec<[f32; 3]>,
|
||||||
|
Vec<[f32; 3]>,
|
||||||
|
Vec<[f32; 4]>,
|
||||||
|
Vec<[f32; 2]>,
|
||||||
|
Vec<u32>,
|
||||||
|
),
|
||||||
|
ImportError,
|
||||||
|
> {
|
||||||
|
if indices.len() % 3 != 0 || indices.iter().any(|&i| i as usize >= positions.len()) {
|
||||||
|
return Err(ImportError::InvalidPrimitive(
|
||||||
|
"triangle indices are malformed".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let normals_valid = normals.as_ref().is_some_and(|x| x.len() == positions.len());
|
||||||
|
let tangents_valid = tangents
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|x| x.len() == positions.len());
|
||||||
|
if normals_valid && tangents_valid {
|
||||||
|
return Ok((positions, normals.unwrap(), tangents.unwrap(), uvs, indices));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out_p = Vec::with_capacity(indices.len());
|
||||||
|
let mut out_n = Vec::with_capacity(indices.len());
|
||||||
|
let mut out_t = Vec::with_capacity(indices.len());
|
||||||
|
let mut out_uv = Vec::with_capacity(indices.len());
|
||||||
|
for triangle in indices.chunks_exact(3) {
|
||||||
|
let ids = [
|
||||||
|
triangle[0] as usize,
|
||||||
|
triangle[1] as usize,
|
||||||
|
triangle[2] as usize,
|
||||||
|
];
|
||||||
|
let p = ids.map(|i| positions[i]);
|
||||||
|
let uv = ids.map(|i| uvs.get(i).copied().unwrap_or([0.0; 2]));
|
||||||
|
let face_normal = normalize(cross(sub(p[1], p[0]), sub(p[2], p[0])), [0.0, 1.0, 0.0]);
|
||||||
|
let duv1 = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
|
||||||
|
let duv2 = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
|
||||||
|
let determinant = duv1[0] * duv2[1] - duv1[1] * duv2[0];
|
||||||
|
let edge1 = sub(p[1], p[0]);
|
||||||
|
let edge2 = sub(p[2], p[0]);
|
||||||
|
let (raw_tangent, raw_bitangent) =
|
||||||
|
if determinant.abs() > f32::EPSILON && determinant.is_finite() {
|
||||||
|
let r = determinant.recip();
|
||||||
|
(
|
||||||
|
std::array::from_fn(|i| (edge1[i] * duv2[1] - edge2[i] * duv1[1]) * r),
|
||||||
|
std::array::from_fn(|i| (edge2[i] * duv1[0] - edge1[i] * duv2[0]) * r),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
([0.0; 3], [0.0; 3])
|
||||||
|
};
|
||||||
|
for corner in 0..3 {
|
||||||
|
let n = normals
|
||||||
|
.as_ref()
|
||||||
|
.filter(|_| normals_valid)
|
||||||
|
.map_or(face_normal, |x| x[ids[corner]]);
|
||||||
|
let n = normalize(n, face_normal);
|
||||||
|
let projected = std::array::from_fn(|i| raw_tangent[i] - n[i] * dot(n, raw_tangent));
|
||||||
|
let fallback_axis = if n[0].abs() < 0.9 {
|
||||||
|
[1.0, 0.0, 0.0]
|
||||||
|
} else {
|
||||||
|
[0.0, 1.0, 0.0]
|
||||||
|
};
|
||||||
|
let tangent3 = normalize(
|
||||||
|
projected,
|
||||||
|
normalize(cross(fallback_axis, n), [0.0, 0.0, 1.0]),
|
||||||
|
);
|
||||||
|
let generated = [
|
||||||
|
tangent3[0],
|
||||||
|
tangent3[1],
|
||||||
|
tangent3[2],
|
||||||
|
if dot(cross(n, tangent3), raw_bitangent) < 0.0 {
|
||||||
|
-1.0
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
},
|
||||||
|
];
|
||||||
|
out_p.push(p[corner]);
|
||||||
|
out_n.push(n);
|
||||||
|
out_t.push(
|
||||||
|
tangents
|
||||||
|
.as_ref()
|
||||||
|
.filter(|_| tangents_valid)
|
||||||
|
.map_or(generated, |x| x[ids[corner]]),
|
||||||
|
);
|
||||||
|
out_uv.push(uv[corner]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let out_i = (0..u32::try_from(out_p.len())
|
||||||
|
.map_err(|_| ImportError::InvalidPrimitive("vertex count exceeds u32".into()))?)
|
||||||
|
.collect();
|
||||||
|
Ok((out_p, out_n, out_t, out_uv, out_i))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
|
pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
|
||||||
let model = Gltf::from_slice(bytes)?;
|
decode_gltf_model(Gltf::from_slice(bytes)?)
|
||||||
let buffers = gltf::import_buffers(&model.document, None, model.blob.clone())?;
|
}
|
||||||
|
|
||||||
|
pub fn decode_gltf_owned(bytes: Vec<u8>) -> Result<ImportedScene, ImportError> {
|
||||||
|
let model = Gltf::from_slice(&bytes)?;
|
||||||
|
drop(bytes);
|
||||||
|
decode_gltf_model(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_gltf_model(mut model: Gltf) -> Result<ImportedScene, ImportError> {
|
||||||
|
// Reject external images before buffer import can turn them into a generic
|
||||||
|
// import error (or attempt to interpret a data/external URI).
|
||||||
|
for image in model.images() {
|
||||||
|
if let gltf::image::Source::Uri { uri, .. } = image.source() {
|
||||||
|
return Err(ImportError::UnsupportedImage(format!(
|
||||||
|
"URI/external image '{uri}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let blob = model.blob.take();
|
||||||
|
let buffers = gltf::import_buffers(&model.document, None, blob)?;
|
||||||
let mut result = ImportedScene::default();
|
let mut result = ImportedScene::default();
|
||||||
|
result.materials.push(Material::default());
|
||||||
|
for material in model.materials() {
|
||||||
|
let pbr = material.pbr_metallic_roughness();
|
||||||
|
let normal = material.normal_texture();
|
||||||
|
let normal_texture = normal.as_ref().map(|x| TextureReference {
|
||||||
|
texture: x.texture().index(),
|
||||||
|
tex_coord: x.tex_coord(),
|
||||||
|
});
|
||||||
|
let occlusion = material.occlusion_texture();
|
||||||
|
let occlusion_texture = occlusion.as_ref().map(|x| TextureReference {
|
||||||
|
texture: x.texture().index(),
|
||||||
|
tex_coord: x.tex_coord(),
|
||||||
|
});
|
||||||
|
result.materials.push(Material {
|
||||||
|
key: MaterialKey::new(material.index().unwrap() as u32 + 1),
|
||||||
|
base_color_factor: pbr.base_color_factor(),
|
||||||
|
metallic_factor: pbr.metallic_factor(),
|
||||||
|
roughness_factor: pbr.roughness_factor(),
|
||||||
|
emissive_factor: material.emissive_factor(),
|
||||||
|
ior: decode_ior(material.ior())?,
|
||||||
|
alpha_mode: match material.alpha_mode() {
|
||||||
|
gltf::material::AlphaMode::Opaque => AlphaMode::Opaque,
|
||||||
|
gltf::material::AlphaMode::Mask => AlphaMode::Mask,
|
||||||
|
gltf::material::AlphaMode::Blend => AlphaMode::Blend,
|
||||||
|
},
|
||||||
|
alpha_cutoff: material.alpha_cutoff().unwrap_or(0.5),
|
||||||
|
double_sided: material.double_sided(),
|
||||||
|
base_color_texture: pbr.base_color_texture().map(texture_reference),
|
||||||
|
metallic_roughness_texture: pbr.metallic_roughness_texture().map(texture_reference),
|
||||||
|
normal_texture,
|
||||||
|
normal_scale: normal.map_or(1.0, |x| x.scale()),
|
||||||
|
occlusion_texture,
|
||||||
|
occlusion_strength: occlusion.map_or(1.0, |x| x.strength()),
|
||||||
|
emissive_texture: material.emissive_texture().map(texture_reference),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result.textures = model
|
||||||
|
.textures()
|
||||||
|
.map(|x| TextureMetadata {
|
||||||
|
image: x.source().index(),
|
||||||
|
sampler: x.sampler().index(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
result.samplers = model
|
||||||
|
.samplers()
|
||||||
|
.map(|x| SamplerMetadata {
|
||||||
|
index: x.index().unwrap(),
|
||||||
|
mag_filter: x.mag_filter().map(|v| format!("{v:?}")),
|
||||||
|
min_filter: x.min_filter().map(|v| format!("{v:?}")),
|
||||||
|
wrap_s: format!("{:?}", x.wrap_s()),
|
||||||
|
wrap_t: format!("{:?}", x.wrap_t()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
result.images = model
|
||||||
|
.images()
|
||||||
|
.map(|x| -> Result<_, ImportError> {
|
||||||
|
let (source, mime_type, encoded_data) = match x.source() {
|
||||||
|
gltf::image::Source::Uri { uri, .. } => {
|
||||||
|
return Err(ImportError::UnsupportedImage(format!(
|
||||||
|
"URI/external image '{uri}'"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
gltf::image::Source::View { view, mime_type } => {
|
||||||
|
let data = buffers.get(view.buffer().index()).ok_or_else(|| {
|
||||||
|
ImportError::UnsupportedImage("image buffer is missing".into())
|
||||||
|
})?;
|
||||||
|
let end = view.offset().checked_add(view.length()).ok_or_else(|| {
|
||||||
|
ImportError::UnsupportedImage("image bufferView overflows".into())
|
||||||
|
})?;
|
||||||
|
let bytes = data.0.get(view.offset()..end).ok_or_else(|| {
|
||||||
|
ImportError::UnsupportedImage("image bufferView is out of bounds".into())
|
||||||
|
})?;
|
||||||
|
(
|
||||||
|
ImageSource::BufferView(view.index()),
|
||||||
|
Some(mime_type.to_owned()),
|
||||||
|
bytes.to_vec(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(ImageMetadata {
|
||||||
|
index: x.index(),
|
||||||
|
name: x.name().map(str::to_owned),
|
||||||
|
mime_type,
|
||||||
|
source,
|
||||||
|
encoded_data,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
let mut seen = HashMap::new();
|
let mut seen = HashMap::new();
|
||||||
fn visit(
|
fn visit(
|
||||||
node: gltf::Node<'_>,
|
node: gltf::Node<'_>,
|
||||||
@@ -116,12 +466,8 @@ pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
|
|||||||
if count == 0 {
|
if count == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut normals: Vec<_> = reader
|
let normals = reader.read_normals().map(|x| x.collect());
|
||||||
.read_normals()
|
let tangents = reader.read_tangents().map(|x| x.collect());
|
||||||
.map(|x| x.collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
normals.resize(count, [0., 1., 0.]);
|
|
||||||
normals.truncate(count);
|
|
||||||
let mut uvs: Vec<_> = reader
|
let mut uvs: Vec<_> = reader
|
||||||
.read_tex_coords(0)
|
.read_tex_coords(0)
|
||||||
.map(|x| x.into_f32().collect())
|
.map(|x| x.into_f32().collect())
|
||||||
@@ -139,11 +485,20 @@ pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
|
|||||||
if indices.is_empty() {
|
if indices.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let (positions, normals, tangents, uvs, indices) =
|
||||||
|
repair_geometry(positions, normals, tangents, uvs, indices)?;
|
||||||
|
let primitive_material = primitive.material();
|
||||||
result.geometries.push(ImportedGeometry {
|
result.geometries.push(ImportedGeometry {
|
||||||
key,
|
key,
|
||||||
double_sided: primitive.material().double_sided(),
|
material: primitive_material
|
||||||
|
.index()
|
||||||
|
.map_or(MaterialKey::DEFAULT, |index| {
|
||||||
|
MaterialKey::new(index as u32 + 1)
|
||||||
|
}),
|
||||||
|
double_sided: primitive_material.double_sided(),
|
||||||
positions,
|
positions,
|
||||||
normals,
|
normals,
|
||||||
|
tangents,
|
||||||
uvs,
|
uvs,
|
||||||
indices,
|
indices,
|
||||||
});
|
});
|
||||||
@@ -189,9 +544,11 @@ pub fn install_imported(
|
|||||||
let created = stage.create_mesh(MeshCreateInfo {
|
let created = stage.create_mesh(MeshCreateInfo {
|
||||||
positions: &geometry.positions,
|
positions: &geometry.positions,
|
||||||
normals: &geometry.normals,
|
normals: &geometry.normals,
|
||||||
|
tangents: &geometry.tangents,
|
||||||
uvs: &geometry.uvs,
|
uvs: &geometry.uvs,
|
||||||
indices: &geometry.indices,
|
indices: &geometry.indices,
|
||||||
pipeline: pipelines[usize::from(geometry.double_sided)],
|
pipeline: pipelines[usize::from(geometry.double_sided)],
|
||||||
|
material: geometry.material,
|
||||||
flags: RenderFlags::VISIBLE,
|
flags: RenderFlags::VISIBLE,
|
||||||
default_instance_flags: RenderFlags::VISIBLE,
|
default_instance_flags: RenderFlags::VISIBLE,
|
||||||
default_transform: transform,
|
default_transform: transform,
|
||||||
@@ -250,3 +607,151 @@ pub fn install_imported(
|
|||||||
bounds,
|
bounds,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uri_images_are_rejected_explicitly() {
|
||||||
|
let json = br#"{
|
||||||
|
"asset":{"version":"2.0"},
|
||||||
|
"images":[{"uri":"external.png"}],
|
||||||
|
"scenes":[{"nodes":[]}],"scene":0
|
||||||
|
}"#;
|
||||||
|
let result = decode_gltf(json);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
result,
|
||||||
|
Err(ImportError::UnsupportedImage(ref message)) if message.contains("URI/external")
|
||||||
|
),
|
||||||
|
"unexpected result: {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn owned_and_borrowed_decode_paths_remain_compatible() {
|
||||||
|
let json = br#"{
|
||||||
|
"asset":{"version":"2.0"},
|
||||||
|
"scenes":[{"nodes":[]}],"scene":0
|
||||||
|
}"#;
|
||||||
|
let borrowed = decode_gltf(json).unwrap();
|
||||||
|
let owned = decode_gltf_owned(json.to_vec()).unwrap();
|
||||||
|
assert_eq!(borrowed.geometries.len(), owned.geometries.len());
|
||||||
|
assert_eq!(borrowed.occurrences.len(), owned.occurrences.len());
|
||||||
|
assert_eq!(borrowed.materials.len(), owned.materials.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repair_duplicates_corners_and_generates_flat_finite_frames() {
|
||||||
|
let positions = vec![
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
[0.0, 0.0, 1.0],
|
||||||
|
];
|
||||||
|
let repaired = repair_geometry(
|
||||||
|
positions,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
vec![[0.0; 2]; 4],
|
||||||
|
vec![0, 1, 2, 0, 3, 1],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(repaired.0.len(), 6);
|
||||||
|
assert_eq!(repaired.4, (0..6).collect::<Vec<_>>());
|
||||||
|
assert_eq!(&repaired.1[..3], &[[0.0, 0.0, 1.0]; 3]);
|
||||||
|
assert_eq!(&repaired.1[3..], &[[0.0, 1.0, 0.0]; 3]);
|
||||||
|
assert!(repaired
|
||||||
|
.2
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.all(|component| component.is_finite()));
|
||||||
|
assert!(repaired.2.iter().all(|tangent| tangent[3].abs() == 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_tangents_split_opposite_handedness_and_supplied_values_survive() {
|
||||||
|
let positions = vec![
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
[-1.0, 0.0, 0.0],
|
||||||
|
];
|
||||||
|
let normals = vec![[0.0, 0.0, 1.0]; 4];
|
||||||
|
let generated = repair_geometry(
|
||||||
|
positions.clone(),
|
||||||
|
Some(normals.clone()),
|
||||||
|
None,
|
||||||
|
vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 0.0]],
|
||||||
|
vec![0, 1, 2, 0, 2, 3],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(generated.2[0][3], generated.2[3][3]);
|
||||||
|
|
||||||
|
let supplied = vec![[0.25, 0.5, 0.75, -1.0]; 4];
|
||||||
|
let preserved = repair_geometry(
|
||||||
|
positions,
|
||||||
|
Some(normals),
|
||||||
|
Some(supplied.clone()),
|
||||||
|
vec![[0.0; 2]; 4],
|
||||||
|
vec![0, 1, 2],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(preserved.2, supplied);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn material_defaults_match_gltf_core_defaults() {
|
||||||
|
let material = Material::default();
|
||||||
|
assert_eq!(material.base_color_factor, [1.0; 4]);
|
||||||
|
assert_eq!(material.metallic_factor, 1.0);
|
||||||
|
assert_eq!(material.roughness_factor, 1.0);
|
||||||
|
assert_eq!(material.alpha_cutoff, 0.5);
|
||||||
|
assert_eq!(material.ior, 1.5);
|
||||||
|
assert_eq!(material.key, MaterialKey::DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imports_khr_materials_ior() {
|
||||||
|
let json = br#"{
|
||||||
|
"asset":{"version":"2.0"},
|
||||||
|
"extensionsUsed":["KHR_materials_ior"],
|
||||||
|
"materials":[{"extensions":{"KHR_materials_ior":{"ior":1.33}}}],
|
||||||
|
"scenes":[{"nodes":[]}],"scene":0
|
||||||
|
}"#;
|
||||||
|
let imported = decode_gltf(json).unwrap();
|
||||||
|
assert_eq!(imported.materials[1].ior, 1.33);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ior_gate_accepts_default_physical_values_and_explicit_zero_sentinel() {
|
||||||
|
assert_eq!(decode_ior(None).unwrap(), 1.5);
|
||||||
|
assert_eq!(decode_ior(Some(1.0)).unwrap(), 1.0);
|
||||||
|
assert_eq!(decode_ior(Some(1.33)).unwrap(), 1.33);
|
||||||
|
assert_eq!(decode_ior(Some(0.0)).unwrap(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ior_gate_rejects_nonphysical_and_nonfinite_values() {
|
||||||
|
for ior in [-1.0, 0.5, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
|
||||||
|
assert!(matches!(
|
||||||
|
decode_ior(Some(ior)),
|
||||||
|
Err(ImportError::InvalidIor(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_but_parseable_json_ior_is_rejected_before_packing() {
|
||||||
|
for ior in ["-1", "0.5"] {
|
||||||
|
let json = format!(
|
||||||
|
r#"{{"asset":{{"version":"2.0"}},"extensionsUsed":["KHR_materials_ior"],"materials":[{{"extensions":{{"KHR_materials_ior":{{"ior":{ior}}}}}}}],"scenes":[{{"nodes":[]}}],"scene":0}}"#
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_gltf(json.as_bytes()),
|
||||||
|
Err(ImportError::InvalidIor(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+46
-66
@@ -1,72 +1,52 @@
|
|||||||
struct UniformData {
|
struct UniformData { mouse_move: vec2<f32>, mouse_click: vec2<f32>, resolution: vec2<f32>, time: f32, _padding0: f32, camera_position: vec4<f32> }
|
||||||
mouse_move: vec2<f32>,
|
struct MaterialData { base_color_factor: vec4<f32>, emissive_factor: vec4<f32>, surface_factors: vec4<f32>, alpha_optics: vec4<f32>, flags: vec4<u32>, uv_sets: vec4<u32>, debug_extras: vec4<u32> }
|
||||||
mouse_click: vec2<f32>,
|
|
||||||
resolution: vec2<f32>,
|
|
||||||
time: f32,
|
|
||||||
_padding0: f32,
|
|
||||||
camera_position: vec4<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> uni: UniformData;
|
@group(0) @binding(0) var<uniform> uni: UniformData;
|
||||||
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
|
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
|
||||||
|
@group(2) @binding(0) var<uniform> material: MaterialData;
|
||||||
|
@group(2) @binding(1) var base_tex: texture_2d<f32>;
|
||||||
|
@group(2) @binding(2) var mr_tex: texture_2d<f32>;
|
||||||
|
@group(2) @binding(3) var normal_tex: texture_2d<f32>;
|
||||||
|
@group(2) @binding(4) var occlusion_tex: texture_2d<f32>;
|
||||||
|
@group(2) @binding(5) var emissive_tex: texture_2d<f32>;
|
||||||
|
@group(2) @binding(6) var base_sampler: sampler;
|
||||||
|
@group(2) @binding(7) var mr_sampler: sampler;
|
||||||
|
@group(2) @binding(8) var normal_sampler: sampler;
|
||||||
|
@group(2) @binding(9) var occlusion_sampler: sampler;
|
||||||
|
@group(2) @binding(10) var emissive_sampler: sampler;
|
||||||
|
|
||||||
struct VertexInput {
|
struct VertexInput { @location(0) pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) uv: vec2<f32>, @location(3) model_col0: vec4<f32>, @location(4) model_col1: vec4<f32>, @location(5) model_col2: vec4<f32>, @location(6) model_col3: vec4<f32>, @location(7) normal_col0: vec4<f32>, @location(8) normal_col1: vec4<f32>, @location(9) normal_col2: vec4<f32>, @location(10) tangent: vec4<f32> }
|
||||||
@location(0) pos: vec3<f32>,
|
struct VertexOutput { @builtin(position) clip_position: vec4<f32>, @location(0) world_pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) tangent: vec3<f32>, @location(3) bitangent: vec3<f32>, @location(4) uv: vec2<f32>, @location(5) @interpolate(flat) determinant_sign: f32 }
|
||||||
@location(1) normal: vec3<f32>,
|
fn safe_normalize(v: vec3<f32>, fallback: vec3<f32>) -> vec3<f32> { let l2 = dot(v, v); return select(fallback, v * inverseSqrt(l2), l2 > 1e-12 && l2 < 1e30); }
|
||||||
@location(2) uv: vec2<f32>,
|
@vertex fn vs_main(in: VertexInput) -> VertexOutput {
|
||||||
@location(3) model_col0: vec4<f32>,
|
var out: VertexOutput; let model = mat4x4<f32>(in.model_col0, in.model_col1, in.model_col2, in.model_col3);
|
||||||
@location(4) model_col1: vec4<f32>,
|
let linear = mat3x3<f32>(in.model_col0.xyz, in.model_col1.xyz, in.model_col2.xyz); let nm = mat3x3<f32>(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz);
|
||||||
@location(5) model_col2: vec4<f32>,
|
let world = model * vec4<f32>(in.pos, 1.0); let n = safe_normalize(nm * in.normal, vec3<f32>(0,1,0)); let raw_t = linear * in.tangent.xyz;
|
||||||
@location(6) model_col3: vec4<f32>,
|
var t = raw_t - n * dot(n, raw_t); if dot(t,t) < 1e-8 { t = cross(select(vec3<f32>(0,1,0), vec3<f32>(1,0,0), abs(n.x) < 0.9), n); } t = safe_normalize(t, vec3<f32>(1,0,0));
|
||||||
@location(7) normal_col0: vec4<f32>,
|
out.clip_position = view_proj * world; out.world_pos = world.xyz; out.normal = n; out.tangent = t; out.bitangent = safe_normalize(cross(n,t), vec3<f32>(0,0,1)) * in.tangent.w * in.normal_col0.w; out.uv = in.uv; out.determinant_sign = in.normal_col0.w; return out;
|
||||||
@location(8) normal_col1: vec4<f32>,
|
|
||||||
@location(9) normal_col2: vec4<f32>,
|
|
||||||
}
|
}
|
||||||
|
struct Closure { base: vec4<f32>, mr: vec2<f32>, normal_map: vec3<f32>, ao: f32, emissive: vec3<f32> }
|
||||||
struct VertexOutput {
|
fn sample_closure(uv: vec2<f32>) -> Closure {
|
||||||
@builtin(position) clip_position: vec4<f32>,
|
let bits = material.flags.x; var c: Closure;
|
||||||
@location(0) world_pos: vec3<f32>,
|
c.base = material.base_color_factor * select(vec4<f32>(1), textureSample(base_tex, base_sampler, uv), (bits & 1u) != 0u);
|
||||||
@location(1) normal: vec3<f32>
|
let mr = select(vec4<f32>(1), textureSample(mr_tex, mr_sampler, uv), (bits & 2u) != 0u); c.mr = vec2<f32>(clamp(material.surface_factors.x * mr.b,0,1), clamp(material.surface_factors.y * mr.g,0.045,1));
|
||||||
|
c.normal_map = select(vec3<f32>(0.5,0.5,1), textureSample(normal_tex, normal_sampler, uv).xyz, (bits & 4u) != 0u);
|
||||||
|
let occ = select(1.0, textureSample(occlusion_tex, occlusion_sampler, uv).r, (bits & 8u) != 0u); c.ao = mix(1.0, occ, material.surface_factors.w);
|
||||||
|
c.emissive = material.emissive_factor.rgb * select(vec3<f32>(1), textureSample(emissive_tex, emissive_sampler, uv).rgb, (bits & 16u) != 0u); return c;
|
||||||
}
|
}
|
||||||
|
fn schlick(f0: vec3<f32>, v_h: f32) -> vec3<f32> { return f0 + (vec3<f32>(1)-f0) * pow(1.0-clamp(v_h,0,1),5.0); }
|
||||||
|
fn ggx_d(n_h_input: f32, a: f32) -> f32 { let n_h=clamp(n_h_input,0.0,1.0); let a2=a*a; let nh2=n_h*n_h; let q=(1.0-nh2)+a2*nh2; return a2/(3.14159265*q*q); }
|
||||||
@vertex
|
fn smith_v(n_v: f32, n_l: f32, a: f32) -> f32 { let a2=a*a; let gv=n_l*sqrt(max(n_v*n_v*(1.0-a2)+a2,0)); let gl=n_v*sqrt(max(n_l*n_l*(1.0-a2)+a2,0)); return 0.5/max(gv+gl,1e-6); }
|
||||||
fn vs_main(in: VertexInput) -> VertexOutput {
|
@fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||||
var out: VertexOutput;
|
let c=sample_closure(in.uv); if material.alpha_optics.x == 1.0 && c.base.a < material.alpha_optics.y { discard; }
|
||||||
let model = mat4x4<f32>(
|
let physical_front=front == (in.determinant_sign > 0); let orientation=select(-1.0,1.0,physical_front || material.flags.y == 0u); let map=c.normal_map*2.0-1.0;
|
||||||
in.model_col0,
|
let n=safe_normalize(mat3x3<f32>(in.tangent,in.bitangent,in.normal)*safe_normalize(vec3<f32>(map.xy*material.surface_factors.z,map.z),vec3<f32>(0,0,1)),in.normal)*orientation;
|
||||||
in.model_col1,
|
let v=safe_normalize(uni.camera_position.xyz-in.world_pos,n); let l=safe_normalize(vec3<f32>(0.35,1,0.45),vec3<f32>(0,1,0)); let h=safe_normalize(v+l,n);
|
||||||
in.model_col2,
|
let nv=max(dot(n,v),0); let nl=max(dot(n,l),0); let nh=dot(n,h); let vh=max(dot(v,h),0); let a=c.mr.y*c.mr.y;
|
||||||
in.model_col3,
|
let f0=mix(vec3<f32>(material.alpha_optics.w),c.base.rgb,c.mr.x); let direct_f=schlick(f0,vh); let env_f=schlick(f0,nv); let spec=direct_f*ggx_d(nh,a)*smith_v(nv,nl,a); let diffuse=(vec3<f32>(1)-direct_f)*(1.0-c.mr.x)*c.base.rgb/3.14159265;
|
||||||
);
|
let sun=(diffuse+spec)*nl*vec3<f32>(3.0,2.85,2.65);
|
||||||
let world_position = model * vec4<f32>(in.pos, 1.0);
|
let up=clamp(n.y*0.5+0.5,0,1); let sky=mix(vec3<f32>(0.055,0.045,0.035),vec3<f32>(0.24,0.36,0.58),up); let env_diff=(vec3<f32>(1)-env_f)*(1.0-c.mr.x)*c.base.rgb*sky;
|
||||||
out.clip_position = view_proj * world_position;
|
let reflection=reflect(-v,n); let horizon=clamp(reflection.y*0.5+0.5,0,1); let env_spec=env_f*mix(vec3<f32>(0.04,0.035,0.03),vec3<f32>(0.28,0.42,0.7),horizon)*(1.0-0.65*c.mr.y);
|
||||||
out.world_pos = world_position.xyz;
|
var color=sun+(env_diff+env_spec)*c.ao+c.emissive;
|
||||||
let normal_matrix = mat3x3<f32>(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz);
|
if material.debug_extras.y == 1u { color=n*0.5+0.5; } else if material.debug_extras.y == 2u { color=vec3<f32>(c.mr.x,c.mr.y,c.ao); } else if material.debug_extras.y == 3u { color=f0; }
|
||||||
out.normal = normalize(normal_matrix * in.normal);
|
return vec4<f32>(color,1.0); // BLEND remains intentionally opaque.
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@fragment
|
|
||||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
|
||||||
let light_direction = normalize(vec3<f32>(0.35, 1.0, 0.45));
|
|
||||||
let light_color = vec3<f32>(1.0, 0.95, 0.85);
|
|
||||||
let base_color = vec3<f32>(0.55, 0.58, 0.62);
|
|
||||||
|
|
||||||
let normal = normalize(in.normal);
|
|
||||||
let view_dir = normalize(uni.camera_position.xyz - in.world_pos);
|
|
||||||
|
|
||||||
let diffuse_strength = max(dot(normal, light_direction), 0.0);
|
|
||||||
let ambient = 0.45;
|
|
||||||
|
|
||||||
var specular = 0.0;
|
|
||||||
if diffuse_strength > 0.0 {
|
|
||||||
let halfway_dir = normalize(light_direction + view_dir);
|
|
||||||
specular = pow(max(dot(normal, halfway_dir), 0.0), 32.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let lighting = min(base_color * (ambient + diffuse_strength) + light_color * specular, vec3<f32>(1.0));
|
|
||||||
let x = select(0.0, 0.3, distance(in.clip_position.xy, uni.mouse_move) < 25.0);
|
|
||||||
let y = select(0.0, 0.3, distance(in.clip_position.xy, uni.mouse_click) < 25.0);
|
|
||||||
return vec4<f32>(lighting + x - y, 1.0);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-19
@@ -1,6 +1,40 @@
|
|||||||
use core::fmt;
|
use core::fmt;
|
||||||
use std::cell::BorrowMutError;
|
use std::cell::BorrowMutError;
|
||||||
use std::sync::mpsc::TryRecvError;
|
use std::sync::mpsc::TryRecvError;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
pub const RIGHT_BUTTON_MASK: u16 = 0x02;
|
||||||
|
pub const MIDDLE_BUTTON_MASK: u16 = 0x04;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CameraDrag {
|
||||||
|
Orbit,
|
||||||
|
Pan,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn camera_drag(buttons: u16) -> Option<CameraDrag> {
|
||||||
|
if buttons & MIDDLE_BUTTON_MASK != 0 {
|
||||||
|
Some(CameraDrag::Orbit)
|
||||||
|
} else if buttons & RIGHT_BUTTON_MASK != 0 {
|
||||||
|
Some(CameraDrag::Pan)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_wheel_delta(delta_y: f64, delta_mode: u32, viewport_height: f64) -> Option<f32> {
|
||||||
|
if !delta_y.is_finite() || !viewport_height.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let delta = match delta_mode {
|
||||||
|
0 => delta_y,
|
||||||
|
1 => delta_y * 16.0,
|
||||||
|
2 => delta_y * viewport_height.max(1.0),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let delta = delta as f32;
|
||||||
|
delta.is_finite().then_some(delta)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum WindowEvent {
|
pub enum WindowEvent {
|
||||||
@@ -42,10 +76,11 @@ pub struct MouseMessage {
|
|||||||
pub movement_y: f64,
|
pub movement_y: f64,
|
||||||
pub offset_x: f64,
|
pub offset_x: f64,
|
||||||
pub offset_y: f64,
|
pub offset_y: f64,
|
||||||
|
pub viewport_height: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MouseMessage {
|
impl MouseMessage {
|
||||||
pub fn from_evt(event: web_sys::MouseEvent) -> Self {
|
pub fn from_evt(event: &web_sys::MouseEvent, viewport_height: f64) -> Self {
|
||||||
let window = web_sys::window().unwrap();
|
let window = web_sys::window().unwrap();
|
||||||
Self {
|
Self {
|
||||||
scale_factor: window.device_pixel_ratio(),
|
scale_factor: window.device_pixel_ratio(),
|
||||||
@@ -57,33 +92,24 @@ impl MouseMessage {
|
|||||||
movement_y: event.movement_y() as f64,
|
movement_y: event.movement_y() as f64,
|
||||||
offset_x: event.offset_x() as f64,
|
offset_x: event.offset_x() as f64,
|
||||||
offset_y: event.offset_y() as f64,
|
offset_y: event.offset_y() as f64,
|
||||||
|
viewport_height,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_pointer_evt(event: &web_sys::PointerEvent, viewport_height: f64) -> Self {
|
||||||
|
Self::from_evt(event.unchecked_ref(), viewport_height)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WheelMessage {
|
pub struct WheelMessage {
|
||||||
pub scale_factor: f64,
|
pub delta_y_pixels: f32,
|
||||||
pub delta_x: f64,
|
|
||||||
pub delta_y: f64,
|
|
||||||
pub delta_z: f64,
|
|
||||||
pub delta_mode: u32,
|
|
||||||
pub client_x: f64,
|
|
||||||
pub client_y: f64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WheelMessage {
|
impl WheelMessage {
|
||||||
pub fn from_evt(event: web_sys::WheelEvent) -> Self {
|
pub fn from_evt(event: &web_sys::WheelEvent, viewport_height: f64) -> Option<Self> {
|
||||||
let window = web_sys::window().unwrap();
|
normalize_wheel_delta(event.delta_y(), event.delta_mode(), viewport_height)
|
||||||
Self {
|
.map(|delta_y_pixels| Self { delta_y_pixels })
|
||||||
scale_factor: window.device_pixel_ratio(),
|
|
||||||
delta_x: event.delta_x(),
|
|
||||||
delta_y: event.delta_y(),
|
|
||||||
delta_z: event.delta_z(),
|
|
||||||
delta_mode: event.delta_mode(),
|
|
||||||
client_x: event.client_x() as f64,
|
|
||||||
client_y: event.client_y() as f64,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,3 +173,32 @@ impl From<BorrowMutError> for DrainEventError {
|
|||||||
DrainEventError::BorrowError(err)
|
DrainEventError::BorrowError(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wheel_delta_is_normalized_to_css_pixels() {
|
||||||
|
assert_eq!(normalize_wheel_delta(12.5, 0, 640.0), Some(12.5));
|
||||||
|
assert_eq!(normalize_wheel_delta(2.0, 1, 640.0), Some(32.0));
|
||||||
|
assert_eq!(normalize_wheel_delta(-1.0, 2, 640.0), Some(-640.0));
|
||||||
|
assert_eq!(normalize_wheel_delta(2.0, 2, 0.0), Some(2.0));
|
||||||
|
assert_eq!(normalize_wheel_delta(1.0, 3, 640.0), None);
|
||||||
|
assert_eq!(normalize_wheel_delta(f64::NAN, 0, 640.0), None);
|
||||||
|
assert_eq!(normalize_wheel_delta(f64::INFINITY, 0, 640.0), None);
|
||||||
|
assert_eq!(normalize_wheel_delta(1.0, 0, f64::NAN), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn camera_drag_prefers_orbit_when_both_buttons_are_down() {
|
||||||
|
assert_eq!(camera_drag(0), None);
|
||||||
|
assert_eq!(camera_drag(1), None);
|
||||||
|
assert_eq!(camera_drag(RIGHT_BUTTON_MASK), Some(CameraDrag::Pan));
|
||||||
|
assert_eq!(camera_drag(MIDDLE_BUTTON_MASK), Some(CameraDrag::Orbit));
|
||||||
|
assert_eq!(
|
||||||
|
camera_drag(RIGHT_BUTTON_MASK | MIDDLE_BUTTON_MASK),
|
||||||
|
Some(CameraDrag::Orbit)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,12 +109,15 @@ impl MainWorker {
|
|||||||
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>(
|
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>(
|
||||||
events_chan: Receiver<WindowEvent>,
|
events_chan: Receiver<WindowEvent>,
|
||||||
ring: &'static CommandRing,
|
ring: &'static CommandRing,
|
||||||
|
profile: bool,
|
||||||
) {
|
) {
|
||||||
use crate::renderer::Renderer;
|
use crate::renderer::Renderer;
|
||||||
|
|
||||||
let canvas = wait_for_canvas_transfer().await;
|
let canvas = wait_for_canvas_transfer().await;
|
||||||
|
|
||||||
let renderer = Rc::new(RefCell::new(Renderer::<T>::new(canvas, events_chan).await));
|
let renderer = Rc::new(RefCell::new(
|
||||||
|
Renderer::<T>::new(canvas, events_chan, profile).await,
|
||||||
|
));
|
||||||
renderer.borrow_mut().command_ring = Some(ring);
|
renderer.borrow_mut().command_ring = Some(ring);
|
||||||
Renderer::run_render_loop(renderer);
|
Renderer::run_render_loop(renderer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,24 @@ impl PipelineKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stable CPU-side identity for a device-independent material.
|
||||||
|
#[repr(transparent)]
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||||
|
pub struct MaterialKey(u32);
|
||||||
|
|
||||||
|
impl MaterialKey {
|
||||||
|
/// The glTF/default material.
|
||||||
|
pub const DEFAULT: Self = Self(0);
|
||||||
|
|
||||||
|
pub const fn new(value: u32) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn get(self) -> u32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[repr(transparent)]
|
#[repr(transparent)]
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub struct RenderFlags(u32);
|
pub struct RenderFlags(u32);
|
||||||
@@ -101,9 +119,11 @@ pub struct GeometryRange {
|
|||||||
pub struct MeshCreateInfo<'a> {
|
pub struct MeshCreateInfo<'a> {
|
||||||
pub positions: &'a [[f32; 3]],
|
pub positions: &'a [[f32; 3]],
|
||||||
pub normals: &'a [[f32; 3]],
|
pub normals: &'a [[f32; 3]],
|
||||||
|
pub tangents: &'a [[f32; 4]],
|
||||||
pub uvs: &'a [[f32; 2]],
|
pub uvs: &'a [[f32; 2]],
|
||||||
pub indices: &'a [u32],
|
pub indices: &'a [u32],
|
||||||
pub pipeline: PipelineKey,
|
pub pipeline: PipelineKey,
|
||||||
|
pub material: MaterialKey,
|
||||||
pub flags: RenderFlags,
|
pub flags: RenderFlags,
|
||||||
pub default_instance_flags: RenderFlags,
|
pub default_instance_flags: RenderFlags,
|
||||||
pub default_transform: ModelTransform,
|
pub default_transform: ModelTransform,
|
||||||
@@ -120,6 +140,7 @@ pub struct MeshView {
|
|||||||
pub handle: MeshHandle,
|
pub handle: MeshHandle,
|
||||||
pub geometry: GeometryRange,
|
pub geometry: GeometryRange,
|
||||||
pub pipeline: PipelineKey,
|
pub pipeline: PipelineKey,
|
||||||
|
pub material: MaterialKey,
|
||||||
pub flags: RenderFlags,
|
pub flags: RenderFlags,
|
||||||
pub aabb: Aabb,
|
pub aabb: Aabb,
|
||||||
pub default_instance: InstanceHandle,
|
pub default_instance: InstanceHandle,
|
||||||
@@ -178,6 +199,7 @@ pub fn affine_world_aabb(local: Aabb, model: ModelTransform) -> Result<Aabb, Ren
|
|||||||
pub struct VertexStreams<'a> {
|
pub struct VertexStreams<'a> {
|
||||||
pub positions: &'a [[f32; 3]],
|
pub positions: &'a [[f32; 3]],
|
||||||
pub normals: &'a [[f32; 3]],
|
pub normals: &'a [[f32; 3]],
|
||||||
|
pub tangents: &'a [[f32; 4]],
|
||||||
pub uvs: &'a [[f32; 2]],
|
pub uvs: &'a [[f32; 2]],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +289,7 @@ pub enum RenderDataError {
|
|||||||
struct VertexSoa {
|
struct VertexSoa {
|
||||||
positions: Vec<[f32; 3]>,
|
positions: Vec<[f32; 3]>,
|
||||||
normals: Vec<[f32; 3]>,
|
normals: Vec<[f32; 3]>,
|
||||||
|
tangents: Vec<[f32; 4]>,
|
||||||
uvs: Vec<[f32; 2]>,
|
uvs: Vec<[f32; 2]>,
|
||||||
logical_capacity: u32,
|
logical_capacity: u32,
|
||||||
max_capacity: Option<u32>,
|
max_capacity: Option<u32>,
|
||||||
@@ -287,6 +310,7 @@ struct MeshSoa {
|
|||||||
index_starts: Vec<u32>,
|
index_starts: Vec<u32>,
|
||||||
index_counts: Vec<u32>,
|
index_counts: Vec<u32>,
|
||||||
pipeline_keys: Vec<PipelineKey>,
|
pipeline_keys: Vec<PipelineKey>,
|
||||||
|
material_keys: Vec<MaterialKey>,
|
||||||
flags: Vec<RenderFlags>,
|
flags: Vec<RenderFlags>,
|
||||||
aabb_mins: Vec<[f32; 3]>,
|
aabb_mins: Vec<[f32; 3]>,
|
||||||
aabb_maxs: Vec<[f32; 3]>,
|
aabb_maxs: Vec<[f32; 3]>,
|
||||||
@@ -416,6 +440,7 @@ impl RenderData {
|
|||||||
vertices: VertexSoa {
|
vertices: VertexSoa {
|
||||||
positions: Vec::new(),
|
positions: Vec::new(),
|
||||||
normals: Vec::new(),
|
normals: Vec::new(),
|
||||||
|
tangents: Vec::new(),
|
||||||
uvs: Vec::new(),
|
uvs: Vec::new(),
|
||||||
logical_capacity: 0,
|
logical_capacity: 0,
|
||||||
max_capacity: config.max_vertices,
|
max_capacity: config.max_vertices,
|
||||||
@@ -553,6 +578,8 @@ impl RenderData {
|
|||||||
.copy_from_slice(info.positions);
|
.copy_from_slice(info.positions);
|
||||||
self.vertices.normals[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
|
self.vertices.normals[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
|
||||||
.copy_from_slice(info.normals);
|
.copy_from_slice(info.normals);
|
||||||
|
self.vertices.tangents[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
|
||||||
|
.copy_from_slice(info.tangents);
|
||||||
self.vertices.uvs[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
|
self.vertices.uvs[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
|
||||||
.copy_from_slice(info.uvs);
|
.copy_from_slice(info.uvs);
|
||||||
self.indices.values[as_usize(index_range.start)..as_usize(index_range.end)]
|
self.indices.values[as_usize(index_range.start)..as_usize(index_range.end)]
|
||||||
@@ -567,6 +594,7 @@ impl RenderData {
|
|||||||
index_count,
|
index_count,
|
||||||
},
|
},
|
||||||
info.pipeline,
|
info.pipeline,
|
||||||
|
info.material,
|
||||||
info.flags,
|
info.flags,
|
||||||
bounds,
|
bounds,
|
||||||
default_instance,
|
default_instance,
|
||||||
@@ -766,6 +794,7 @@ impl RenderData {
|
|||||||
VertexStreams {
|
VertexStreams {
|
||||||
positions: &self.vertices.positions,
|
positions: &self.vertices.positions,
|
||||||
normals: &self.vertices.normals,
|
normals: &self.vertices.normals,
|
||||||
|
tangents: &self.vertices.tangents,
|
||||||
uvs: &self.vertices.uvs,
|
uvs: &self.vertices.uvs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -783,6 +812,7 @@ impl RenderData {
|
|||||||
self.instances.slots.clear();
|
self.instances.slots.clear();
|
||||||
self.vertices.positions.clear();
|
self.vertices.positions.clear();
|
||||||
self.vertices.normals.clear();
|
self.vertices.normals.clear();
|
||||||
|
self.vertices.tangents.clear();
|
||||||
self.vertices.uvs.clear();
|
self.vertices.uvs.clear();
|
||||||
self.indices.values.clear();
|
self.indices.values.clear();
|
||||||
self.vertices.allocator.clear();
|
self.vertices.allocator.clear();
|
||||||
@@ -800,6 +830,7 @@ impl RenderData {
|
|||||||
)?;
|
)?;
|
||||||
reserve_vec(&mut self.vertices.positions, target, "vertices")?;
|
reserve_vec(&mut self.vertices.positions, target, "vertices")?;
|
||||||
reserve_vec(&mut self.vertices.normals, target, "vertices")?;
|
reserve_vec(&mut self.vertices.normals, target, "vertices")?;
|
||||||
|
reserve_vec(&mut self.vertices.tangents, target, "vertices")?;
|
||||||
reserve_vec(&mut self.vertices.uvs, target, "vertices")?;
|
reserve_vec(&mut self.vertices.uvs, target, "vertices")?;
|
||||||
self.vertices.logical_capacity = target;
|
self.vertices.logical_capacity = target;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -821,6 +852,9 @@ impl RenderData {
|
|||||||
let vertices = as_usize(self.vertices.allocator.high_water());
|
let vertices = as_usize(self.vertices.allocator.high_water());
|
||||||
self.vertices.positions.resize(vertices, [0.0; 3]);
|
self.vertices.positions.resize(vertices, [0.0; 3]);
|
||||||
self.vertices.normals.resize(vertices, [0.0; 3]);
|
self.vertices.normals.resize(vertices, [0.0; 3]);
|
||||||
|
self.vertices
|
||||||
|
.tangents
|
||||||
|
.resize(vertices, [0.0, 0.0, 0.0, 1.0]);
|
||||||
self.vertices.uvs.resize(vertices, [0.0; 2]);
|
self.vertices.uvs.resize(vertices, [0.0; 2]);
|
||||||
self.indices
|
self.indices
|
||||||
.values
|
.values
|
||||||
@@ -834,6 +868,9 @@ impl RenderData {
|
|||||||
self.vertices
|
self.vertices
|
||||||
.normals
|
.normals
|
||||||
.truncate(as_usize(self.vertices.allocator.high_water()));
|
.truncate(as_usize(self.vertices.allocator.high_water()));
|
||||||
|
self.vertices
|
||||||
|
.tangents
|
||||||
|
.truncate(as_usize(self.vertices.allocator.high_water()));
|
||||||
self.vertices
|
self.vertices
|
||||||
.uvs
|
.uvs
|
||||||
.truncate(as_usize(self.vertices.allocator.high_water()));
|
.truncate(as_usize(self.vertices.allocator.high_water()));
|
||||||
@@ -852,6 +889,7 @@ impl MeshSoa {
|
|||||||
index_starts: Vec::new(),
|
index_starts: Vec::new(),
|
||||||
index_counts: Vec::new(),
|
index_counts: Vec::new(),
|
||||||
pipeline_keys: Vec::new(),
|
pipeline_keys: Vec::new(),
|
||||||
|
material_keys: Vec::new(),
|
||||||
flags: Vec::new(),
|
flags: Vec::new(),
|
||||||
aabb_mins: Vec::new(),
|
aabb_mins: Vec::new(),
|
||||||
aabb_maxs: Vec::new(),
|
aabb_maxs: Vec::new(),
|
||||||
@@ -871,6 +909,7 @@ impl MeshSoa {
|
|||||||
reserve_vec(&mut self.index_starts, target, "meshes")?;
|
reserve_vec(&mut self.index_starts, target, "meshes")?;
|
||||||
reserve_vec(&mut self.index_counts, target, "meshes")?;
|
reserve_vec(&mut self.index_counts, target, "meshes")?;
|
||||||
reserve_vec(&mut self.pipeline_keys, target, "meshes")?;
|
reserve_vec(&mut self.pipeline_keys, target, "meshes")?;
|
||||||
|
reserve_vec(&mut self.material_keys, target, "meshes")?;
|
||||||
reserve_vec(&mut self.flags, target, "meshes")?;
|
reserve_vec(&mut self.flags, target, "meshes")?;
|
||||||
reserve_vec(&mut self.aabb_mins, target, "meshes")?;
|
reserve_vec(&mut self.aabb_mins, target, "meshes")?;
|
||||||
reserve_vec(&mut self.aabb_maxs, target, "meshes")?;
|
reserve_vec(&mut self.aabb_maxs, target, "meshes")?;
|
||||||
@@ -885,6 +924,7 @@ impl MeshSoa {
|
|||||||
prepared: PreparedSlot,
|
prepared: PreparedSlot,
|
||||||
geometry: GeometryRange,
|
geometry: GeometryRange,
|
||||||
pipeline: PipelineKey,
|
pipeline: PipelineKey,
|
||||||
|
material: MaterialKey,
|
||||||
flags: RenderFlags,
|
flags: RenderFlags,
|
||||||
bounds: Aabb,
|
bounds: Aabb,
|
||||||
default: InstanceHandle,
|
default: InstanceHandle,
|
||||||
@@ -895,6 +935,7 @@ impl MeshSoa {
|
|||||||
resize_column(&mut self.index_starts, len, 0);
|
resize_column(&mut self.index_starts, len, 0);
|
||||||
resize_column(&mut self.index_counts, len, 0);
|
resize_column(&mut self.index_counts, len, 0);
|
||||||
resize_column(&mut self.pipeline_keys, len, PipelineKey::new(0));
|
resize_column(&mut self.pipeline_keys, len, PipelineKey::new(0));
|
||||||
|
resize_column(&mut self.material_keys, len, MaterialKey::DEFAULT);
|
||||||
resize_column(&mut self.flags, len, RenderFlags::NONE);
|
resize_column(&mut self.flags, len, RenderFlags::NONE);
|
||||||
resize_column(&mut self.aabb_mins, len, [0.0; 3]);
|
resize_column(&mut self.aabb_mins, len, [0.0; 3]);
|
||||||
resize_column(&mut self.aabb_maxs, len, [0.0; 3]);
|
resize_column(&mut self.aabb_maxs, len, [0.0; 3]);
|
||||||
@@ -906,6 +947,7 @@ impl MeshSoa {
|
|||||||
self.index_starts[index] = geometry.index_start;
|
self.index_starts[index] = geometry.index_start;
|
||||||
self.index_counts[index] = geometry.index_count;
|
self.index_counts[index] = geometry.index_count;
|
||||||
self.pipeline_keys[index] = pipeline;
|
self.pipeline_keys[index] = pipeline;
|
||||||
|
self.material_keys[index] = material;
|
||||||
self.flags[index] = flags;
|
self.flags[index] = flags;
|
||||||
self.aabb_mins[index] = bounds.min;
|
self.aabb_mins[index] = bounds.min;
|
||||||
self.aabb_maxs[index] = bounds.max;
|
self.aabb_maxs[index] = bounds.max;
|
||||||
@@ -925,6 +967,7 @@ impl MeshSoa {
|
|||||||
index_count: self.index_counts[index],
|
index_count: self.index_counts[index],
|
||||||
},
|
},
|
||||||
pipeline: self.pipeline_keys[index],
|
pipeline: self.pipeline_keys[index],
|
||||||
|
material: self.material_keys[index],
|
||||||
flags: self.flags[index],
|
flags: self.flags[index],
|
||||||
aabb: Aabb {
|
aabb: Aabb {
|
||||||
min: self.aabb_mins[index],
|
min: self.aabb_mins[index],
|
||||||
@@ -1054,7 +1097,10 @@ fn validate_geometry(info: &MeshCreateInfo<'_>) -> Result<u32, RenderDataError>
|
|||||||
if info.positions.is_empty() {
|
if info.positions.is_empty() {
|
||||||
return Err(RenderDataError::EmptyVertices);
|
return Err(RenderDataError::EmptyVertices);
|
||||||
}
|
}
|
||||||
if info.positions.len() != info.normals.len() || info.positions.len() != info.uvs.len() {
|
if info.positions.len() != info.normals.len()
|
||||||
|
|| info.positions.len() != info.tangents.len()
|
||||||
|
|| info.positions.len() != info.uvs.len()
|
||||||
|
{
|
||||||
return Err(RenderDataError::MismatchedVertexStreams);
|
return Err(RenderDataError::MismatchedVertexStreams);
|
||||||
}
|
}
|
||||||
let vertex_count =
|
let vertex_count =
|
||||||
@@ -1068,6 +1114,7 @@ fn validate_geometry(info: &MeshCreateInfo<'_>) -> Result<u32, RenderDataError>
|
|||||||
.iter()
|
.iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.chain(info.normals.iter().flatten())
|
.chain(info.normals.iter().flatten())
|
||||||
|
.chain(info.tangents.iter().flatten())
|
||||||
.chain(info.uvs.iter().flatten())
|
.chain(info.uvs.iter().flatten())
|
||||||
.any(|value| !value.is_finite())
|
.any(|value| !value.is_finite())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use crate::render_data::handle::SlotState;
|
|||||||
|
|
||||||
const POSITIONS: [[f32; 3]; 3] = [[-1.0, 2.0, 3.0], [4.0, -2.0, 1.0], [0.0, 1.0, -3.0]];
|
const POSITIONS: [[f32; 3]; 3] = [[-1.0, 2.0, 3.0], [4.0, -2.0, 1.0], [0.0, 1.0, -3.0]];
|
||||||
const NORMALS: [[f32; 3]; 3] = [[0.0, 1.0, 0.0]; 3];
|
const NORMALS: [[f32; 3]; 3] = [[0.0, 1.0, 0.0]; 3];
|
||||||
|
const TANGENTS: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 1.0]; 3];
|
||||||
const UVS: [[f32; 2]; 3] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
|
const UVS: [[f32; 2]; 3] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
|
||||||
const INDICES: [u32; 3] = [0, 1, 2];
|
const INDICES: [u32; 3] = [0, 1, 2];
|
||||||
|
|
||||||
@@ -10,9 +11,11 @@ fn info() -> MeshCreateInfo<'static> {
|
|||||||
MeshCreateInfo {
|
MeshCreateInfo {
|
||||||
positions: &POSITIONS,
|
positions: &POSITIONS,
|
||||||
normals: &NORMALS,
|
normals: &NORMALS,
|
||||||
|
tangents: &TANGENTS,
|
||||||
uvs: &UVS,
|
uvs: &UVS,
|
||||||
indices: &INDICES,
|
indices: &INDICES,
|
||||||
pipeline: PipelineKey::new(7),
|
pipeline: PipelineKey::new(7),
|
||||||
|
material: MaterialKey::new(11),
|
||||||
flags: RenderFlags::from_bits_retain(2),
|
flags: RenderFlags::from_bits_retain(2),
|
||||||
default_instance_flags: RenderFlags::VISIBLE,
|
default_instance_flags: RenderFlags::VISIBLE,
|
||||||
default_transform: IDENTITY_MODEL_TRANSFORM,
|
default_transform: IDENTITY_MODEL_TRANSFORM,
|
||||||
@@ -83,6 +86,10 @@ fn default_instance_is_protected_and_flags_are_separate() {
|
|||||||
let created = data.create_mesh(info()).unwrap();
|
let created = data.create_mesh(info()).unwrap();
|
||||||
assert!(data.instance(created.default_instance).unwrap().is_default);
|
assert!(data.instance(created.default_instance).unwrap().is_default);
|
||||||
assert_eq!(data.mesh(created.mesh).unwrap().flags.bits(), 2);
|
assert_eq!(data.mesh(created.mesh).unwrap().flags.bits(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
data.mesh(created.mesh).unwrap().material,
|
||||||
|
MaterialKey::new(11)
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
data.instance(created.default_instance).unwrap().flags,
|
data.instance(created.default_instance).unwrap().flags,
|
||||||
RenderFlags::VISIBLE
|
RenderFlags::VISIBLE
|
||||||
@@ -239,6 +246,7 @@ fn streams_remain_coordinated_across_interior_delete_tail_delete_and_reuse() {
|
|||||||
data.destroy_mesh(second.mesh).unwrap();
|
data.destroy_mesh(second.mesh).unwrap();
|
||||||
assert_eq!(data.streams().positions.len(), 3);
|
assert_eq!(data.streams().positions.len(), 3);
|
||||||
assert_eq!(data.streams().normals.len(), 3);
|
assert_eq!(data.streams().normals.len(), 3);
|
||||||
|
assert_eq!(data.streams().tangents.len(), 3);
|
||||||
assert_eq!(data.streams().uvs.len(), 3);
|
assert_eq!(data.streams().uvs.len(), 3);
|
||||||
assert_eq!(data.indices().len(), 3);
|
assert_eq!(data.indices().len(), 3);
|
||||||
}
|
}
|
||||||
@@ -288,6 +296,7 @@ fn aabb_supports_one_point_and_multiple_points() {
|
|||||||
let mut one = info();
|
let mut one = info();
|
||||||
one.positions = &point;
|
one.positions = &point;
|
||||||
one.normals = &normal;
|
one.normals = &normal;
|
||||||
|
one.tangents = &[[1.0, 0.0, 0.0, 1.0]];
|
||||||
one.uvs = &uv;
|
one.uvs = &uv;
|
||||||
one.indices = &index;
|
one.indices = &index;
|
||||||
let mut data = data();
|
let mut data = data();
|
||||||
@@ -325,6 +334,13 @@ fn malformed_geometry_matrix_is_rejected_without_consumption() {
|
|||||||
data.create_mesh(candidate).unwrap_err(),
|
data.create_mesh(candidate).unwrap_err(),
|
||||||
RenderDataError::MismatchedVertexStreams
|
RenderDataError::MismatchedVertexStreams
|
||||||
);
|
);
|
||||||
|
let short_tangents = &TANGENTS[..2];
|
||||||
|
let mut candidate = info();
|
||||||
|
candidate.tangents = short_tangents;
|
||||||
|
assert_eq!(
|
||||||
|
data.create_mesh(candidate).unwrap_err(),
|
||||||
|
RenderDataError::MismatchedVertexStreams
|
||||||
|
);
|
||||||
let short_uvs = &UVS[..2];
|
let short_uvs = &UVS[..2];
|
||||||
let mut candidate = info();
|
let mut candidate = info();
|
||||||
candidate.uvs = short_uvs;
|
candidate.uvs = short_uvs;
|
||||||
@@ -339,19 +355,22 @@ fn malformed_geometry_matrix_is_rejected_without_consumption() {
|
|||||||
RenderDataError::EmptyIndices
|
RenderDataError::EmptyIndices
|
||||||
);
|
);
|
||||||
|
|
||||||
for stream in 0..3 {
|
for stream in 0..4 {
|
||||||
for bad in [f32::NAN, f32::INFINITY] {
|
for bad in [f32::NAN, f32::INFINITY] {
|
||||||
let mut positions = POSITIONS;
|
let mut positions = POSITIONS;
|
||||||
let mut normals = NORMALS;
|
let mut normals = NORMALS;
|
||||||
|
let mut tangents = TANGENTS;
|
||||||
let mut uvs = UVS;
|
let mut uvs = UVS;
|
||||||
match stream {
|
match stream {
|
||||||
0 => positions[0][0] = bad,
|
0 => positions[0][0] = bad,
|
||||||
1 => normals[0][0] = bad,
|
1 => normals[0][0] = bad,
|
||||||
|
2 => tangents[0][0] = bad,
|
||||||
_ => uvs[0][0] = bad,
|
_ => uvs[0][0] = bad,
|
||||||
}
|
}
|
||||||
let mut candidate = info();
|
let mut candidate = info();
|
||||||
candidate.positions = &positions;
|
candidate.positions = &positions;
|
||||||
candidate.normals = &normals;
|
candidate.normals = &normals;
|
||||||
|
candidate.tangents = &tangents;
|
||||||
candidate.uvs = &uvs;
|
candidate.uvs = &uvs;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
data.create_mesh(candidate).unwrap_err(),
|
data.create_mesh(candidate).unwrap_err(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
//! Device-free V1 render graph compiler and compiled graph registry.
|
//! Device-free V1 render graph compiler and compiled graph registry.
|
||||||
|
|
||||||
mod compiler;
|
mod compiler;
|
||||||
|
mod compiler_v2;
|
||||||
|
mod contracts_v2;
|
||||||
|
mod plan_v2;
|
||||||
mod registry;
|
mod registry;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
|
mod runtime_v2;
|
||||||
mod schema;
|
mod schema;
|
||||||
|
mod schema_v2;
|
||||||
|
|
||||||
pub use compiler::{
|
pub use compiler::{
|
||||||
compile, compile_with, parse_and_compile, AllocationClass, CompiledGraph, CompiledOutput,
|
compile, compile_with, parse_and_compile, AllocationClass, CompiledGraph, CompiledOutput,
|
||||||
@@ -11,12 +16,38 @@ pub use compiler::{
|
|||||||
ExecutorRegistry, ExecutorResolution, Lifetime, NormalizedParameters, SceneForwardExecutors,
|
ExecutorRegistry, ExecutorResolution, Lifetime, NormalizedParameters, SceneForwardExecutors,
|
||||||
TextureAllocationKey, TextureUsage, TransientAllocation,
|
TextureAllocationKey, TextureUsage, TransientAllocation,
|
||||||
};
|
};
|
||||||
pub use registry::{CompiledGraphId, Registry};
|
pub use compiler_v2::{compile_v2, mesh_predicate_matches, parse_and_compile_v2};
|
||||||
|
pub use contracts_v2::*;
|
||||||
|
pub use plan_v2::*;
|
||||||
|
pub use registry::{CompiledGraphId, RegisteredGraph, Registry};
|
||||||
pub use runtime::{
|
pub use runtime::{
|
||||||
class_offsets, resolve_extent, runtime_texture_key, validate_activatable, ResolvedExtent,
|
class_offsets, resolve_extent, runtime_texture_key, validate_activatable, ResolvedExtent,
|
||||||
RuntimeTextureKey,
|
RuntimeTextureKey,
|
||||||
};
|
};
|
||||||
|
pub use runtime_v2::*;
|
||||||
pub use schema::*;
|
pub use schema::*;
|
||||||
|
pub use schema_v2::*;
|
||||||
|
|
||||||
|
pub fn parse_and_compile_any(bytes: &[u8]) -> Result<RegisteredGraph, GraphError> {
|
||||||
|
if bytes.len() > MAX_JSON_BYTES {
|
||||||
|
return Err(GraphError::new(
|
||||||
|
"GRAPH_PAYLOAD_TOO_LARGE",
|
||||||
|
"graph payload exceeds 1 MiB",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let text = std::str::from_utf8(bytes)
|
||||||
|
.map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph payload is not UTF-8"))?;
|
||||||
|
let value: serde_json::Value = serde_json::from_str(text)
|
||||||
|
.map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?;
|
||||||
|
match value.get("schemaVersion").and_then(|v| v.as_u64()) {
|
||||||
|
Some(1) => parse_and_compile(bytes).map(RegisteredGraph::V1),
|
||||||
|
Some(2) => parse_and_compile_v2(bytes).map(RegisteredGraph::V2),
|
||||||
|
_ => Err(GraphError::new(
|
||||||
|
"GRAPH_SCHEMA_UNSUPPORTED",
|
||||||
|
"schemaVersion must be exactly 1 or 2",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub const MAX_JSON_BYTES: usize = 1024 * 1024;
|
pub const MAX_JSON_BYTES: usize = 1024 * 1024;
|
||||||
pub const MAX_RESOURCES: usize = 1024;
|
pub const MAX_RESOURCES: usize = 1024;
|
||||||
@@ -56,3 +87,5 @@ impl GraphError {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_v2;
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,24 @@
|
|||||||
use super::{parse_and_compile, CompiledGraph, GraphError};
|
use super::{parse_and_compile_any, CompiledGraph, CompiledGraphV2, GraphError};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum RegisteredGraph {
|
||||||
|
V1(CompiledGraph),
|
||||||
|
V2(CompiledGraphV2),
|
||||||
|
}
|
||||||
|
impl RegisteredGraph {
|
||||||
|
fn identity(&self) -> (&str, u32) {
|
||||||
|
match self {
|
||||||
|
Self::V1(g) => (g.graph_id.as_str(), g.revision),
|
||||||
|
Self::V2(g) => (g.graph_id.as_str(), g.revision),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn summary(&self, id: [u32; 2]) -> serde_json::Value {
|
||||||
|
match self {
|
||||||
|
Self::V1(g) => g.summary(id),
|
||||||
|
Self::V2(g) => g.summary(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct CompiledGraphId {
|
pub struct CompiledGraphId {
|
||||||
pub slot: u32,
|
pub slot: u32,
|
||||||
@@ -12,13 +32,14 @@ impl From<CompiledGraphId> for [u32; 2] {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct Slot {
|
struct Slot {
|
||||||
generation: u32,
|
generation: u32,
|
||||||
value: Option<CompiledGraph>,
|
value: Option<RegisteredGraph>,
|
||||||
retired: bool,
|
retired: bool,
|
||||||
}
|
}
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Registry {
|
pub struct Registry {
|
||||||
slots: Vec<Slot>,
|
slots: Vec<Slot>,
|
||||||
capacity: u32,
|
capacity: u32,
|
||||||
|
latest_revisions: HashMap<String, u32>,
|
||||||
}
|
}
|
||||||
impl Default for Registry {
|
impl Default for Registry {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -30,34 +51,25 @@ impl Registry {
|
|||||||
Self {
|
Self {
|
||||||
slots: vec![],
|
slots: vec![],
|
||||||
capacity,
|
capacity,
|
||||||
|
latest_revisions: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn compile(
|
pub fn compile(
|
||||||
&mut self,
|
&mut self,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
) -> Result<(CompiledGraphId, serde_json::Value), GraphError> {
|
) -> Result<(CompiledGraphId, serde_json::Value), GraphError> {
|
||||||
let graph = parse_and_compile(bytes)?;
|
let graph = parse_and_compile_any(bytes)?;
|
||||||
if let Some((i, s)) = self.slots.iter_mut().enumerate().find(|(_, s)| {
|
let (graph_id, revision) = graph.identity();
|
||||||
s.value
|
if self
|
||||||
.as_ref()
|
.latest_revisions
|
||||||
.is_some_and(|g| g.graph_id == graph.graph_id)
|
.get(graph_id)
|
||||||
}) {
|
.is_some_and(|latest| revision <= *latest)
|
||||||
if graph.revision <= s.value.as_ref().unwrap().revision {
|
{
|
||||||
return Err(GraphError::new(
|
return Err(GraphError::new(
|
||||||
"GRAPH_REVISION_CONFLICT",
|
"GRAPH_REVISION_CONFLICT",
|
||||||
"revision must increase",
|
"revision must increase",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let id = CompiledGraphId {
|
|
||||||
slot: u32::try_from(i).map_err(|_| {
|
|
||||||
GraphError::new("GRAPH_LIMIT_EXCEEDED", "registry slot overflow")
|
|
||||||
})?,
|
|
||||||
generation: s.generation,
|
|
||||||
};
|
|
||||||
let summary = graph.summary(id.into());
|
|
||||||
s.value = Some(graph);
|
|
||||||
return Ok((id, summary));
|
|
||||||
}
|
|
||||||
let i = if let Some(i) = self
|
let i = if let Some(i) = self
|
||||||
.slots
|
.slots
|
||||||
.iter()
|
.iter()
|
||||||
@@ -84,10 +96,26 @@ impl Registry {
|
|||||||
generation: self.slots[i].generation,
|
generation: self.slots[i].generation,
|
||||||
};
|
};
|
||||||
let summary = graph.summary(id.into());
|
let summary = graph.summary(id.into());
|
||||||
|
self.latest_revisions.insert(graph_id.to_owned(), revision);
|
||||||
self.slots[i].value = Some(graph);
|
self.slots[i].value = Some(graph);
|
||||||
Ok((id, summary))
|
Ok((id, summary))
|
||||||
}
|
}
|
||||||
pub fn get(&self, id: CompiledGraphId) -> Result<&CompiledGraph, GraphError> {
|
pub fn get(&self, id: CompiledGraphId) -> Result<&CompiledGraph, GraphError> {
|
||||||
|
match self
|
||||||
|
.slots
|
||||||
|
.get(id.slot as usize)
|
||||||
|
.filter(|s| s.generation == id.generation)
|
||||||
|
.and_then(|s| s.value.as_ref())
|
||||||
|
.ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))?
|
||||||
|
{
|
||||||
|
RegisteredGraph::V1(g) => Ok(g),
|
||||||
|
RegisteredGraph::V2(_) => Err(GraphError::new(
|
||||||
|
"GRAPH_EXECUTION_UNSUPPORTED",
|
||||||
|
"schemaVersion 2 activation is unavailable until Phase 4",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn get_registered(&self, id: CompiledGraphId) -> Result<&RegisteredGraph, GraphError> {
|
||||||
self.slots
|
self.slots
|
||||||
.get(id.slot as usize)
|
.get(id.slot as usize)
|
||||||
.filter(|s| s.generation == id.generation)
|
.filter(|s| s.generation == id.generation)
|
||||||
@@ -95,7 +123,7 @@ impl Registry {
|
|||||||
.ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))
|
.ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))
|
||||||
}
|
}
|
||||||
pub fn contains(&self, id: CompiledGraphId) -> bool {
|
pub fn contains(&self, id: CompiledGraphId) -> bool {
|
||||||
self.get(id).is_ok()
|
self.get_registered(id).is_ok()
|
||||||
}
|
}
|
||||||
pub fn drop_graph(&mut self, id: CompiledGraphId) -> Result<(), GraphError> {
|
pub fn drop_graph(&mut self, id: CompiledGraphId) -> Result<(), GraphError> {
|
||||||
let s = self
|
let s = self
|
||||||
|
|||||||
@@ -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(|_| ())
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -213,12 +213,13 @@ fn registry_capacity() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_revision_replaces_in_place() {
|
fn registry_revision_creates_immutable_handle() {
|
||||||
let mut r = Registry::new(1);
|
let mut r = Registry::new(2);
|
||||||
let (a, _) = r.compile(&empty("g", 1)).unwrap();
|
let (a, _) = r.compile(&empty("g", 1)).unwrap();
|
||||||
let (b, _) = r.compile(&empty("g", 2)).unwrap();
|
let (b, _) = r.compile(&empty("g", 2)).unwrap();
|
||||||
assert_eq!(a, b);
|
assert_ne!(a, b);
|
||||||
assert_eq!(r.get(a).unwrap().revision, 2);
|
assert_eq!(r.get(a).unwrap().revision, 1);
|
||||||
|
assert_eq!(r.get(b).unwrap().revision, 2);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_revision_conflict() {
|
fn registry_revision_conflict() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
use std::mem::size_of;
|
use std::mem::size_of;
|
||||||
|
|
||||||
use crate::render_data::{MeshHandle, PipelineKey, RenderData, RenderFlags};
|
use crate::{
|
||||||
|
render_data::{MaterialKey, MeshHandle, PipelineKey, RenderFlags},
|
||||||
|
renderer::scene_frame::SceneFramePlan,
|
||||||
|
};
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -15,10 +18,38 @@ pub struct GpuInstance {
|
|||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct DrawItem {
|
pub struct DrawItem {
|
||||||
pub pipeline: PipelineKey,
|
pub pipeline: PipelineKey,
|
||||||
|
pub material: MaterialKey,
|
||||||
pub mesh: MeshHandle,
|
pub mesh: MeshHandle,
|
||||||
pub indices: std::ops::Range<u32>,
|
pub indices: std::ops::Range<u32>,
|
||||||
pub base_vertex: i32,
|
pub base_vertex: i32,
|
||||||
pub instances: std::ops::Range<u32>,
|
pub instances: std::ops::Range<u32>,
|
||||||
|
pub effective_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq)]
|
||||||
|
pub struct GpuLocalAabb {
|
||||||
|
pub min: [f32; 4],
|
||||||
|
pub max: [f32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq, Eq)]
|
||||||
|
pub struct DrawSlotMetadata {
|
||||||
|
pub index_count: u32,
|
||||||
|
pub first_index: u32,
|
||||||
|
pub base_vertex: i32,
|
||||||
|
pub instance_index: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq, Eq)]
|
||||||
|
pub struct DrawIndexedIndirect {
|
||||||
|
pub index_count: u32,
|
||||||
|
pub instance_count: u32,
|
||||||
|
pub first_index: u32,
|
||||||
|
pub base_vertex: i32,
|
||||||
|
pub first_instance: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -26,30 +57,60 @@ pub struct GpuScenePlan {
|
|||||||
pub positions: Vec<[f32; 3]>,
|
pub positions: Vec<[f32; 3]>,
|
||||||
pub normals: Vec<[f32; 3]>,
|
pub normals: Vec<[f32; 3]>,
|
||||||
pub uvs: Vec<[f32; 2]>,
|
pub uvs: Vec<[f32; 2]>,
|
||||||
|
pub tangents: Vec<[f32; 4]>,
|
||||||
pub indices: Vec<u32>,
|
pub indices: Vec<u32>,
|
||||||
pub instances: Vec<GpuInstance>,
|
pub instances: Vec<GpuInstance>,
|
||||||
pub draws: Vec<DrawItem>,
|
pub draws: Vec<DrawItem>,
|
||||||
|
pub local_aabbs: Vec<GpuLocalAabb>,
|
||||||
|
pub effective_visibility: Vec<u32>,
|
||||||
|
pub draw_metadata: Vec<DrawSlotMetadata>,
|
||||||
|
pub commands: Vec<DrawIndexedIndirect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visibility_matches(
|
||||||
|
predicate: crate::render_graph::TriStatePredicate,
|
||||||
|
mesh: RenderFlags,
|
||||||
|
instance: RenderFlags,
|
||||||
|
) -> bool {
|
||||||
|
let effective = mesh.contains(RenderFlags::VISIBLE) && instance.contains(RenderFlags::VISIBLE);
|
||||||
|
match predicate {
|
||||||
|
crate::render_graph::TriStatePredicate::Any => true,
|
||||||
|
crate::render_graph::TriStatePredicate::RequiredTrue => effective,
|
||||||
|
crate::render_graph::TriStatePredicate::RequiredFalse => !effective,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GpuScenePlan {
|
impl GpuScenePlan {
|
||||||
pub fn build(data: &RenderData) -> Result<Self, &'static str> {
|
pub fn build(data: &SceneFramePlan) -> Result<Self, &'static str> {
|
||||||
|
self::GpuScenePlan::build_with_query(
|
||||||
|
data,
|
||||||
|
crate::render_graph::MeshQueryRuntimeKeyV2 {
|
||||||
|
visible: crate::render_graph::TriStatePredicate::RequiredTrue,
|
||||||
|
frustum_culled: crate::render_graph::TriStatePredicate::Any,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_with_query(
|
||||||
|
data: &SceneFramePlan,
|
||||||
|
query: crate::render_graph::MeshQueryRuntimeKeyV2,
|
||||||
|
) -> Result<Self, &'static str> {
|
||||||
|
let _ = query; // Packing is canonical; predicates are evaluated by the GPU.
|
||||||
let mut plan = Self::default();
|
let mut plan = Self::default();
|
||||||
let mut meshes: Vec<_> = data
|
let mut meshes: Vec<_> = data.meshes.iter().collect();
|
||||||
.meshes()
|
meshes.sort_by_key(|mesh| {
|
||||||
.filter(|(_, mesh)| mesh.flags.contains(RenderFlags::VISIBLE))
|
(
|
||||||
.collect();
|
mesh.pipeline.get(),
|
||||||
meshes.sort_by_key(|(handle, mesh)| {
|
mesh.material.get(),
|
||||||
(mesh.pipeline.get(), handle.slot(), handle.generation())
|
mesh.handle.slot(),
|
||||||
|
mesh.handle.generation(),
|
||||||
|
)
|
||||||
});
|
});
|
||||||
let streams = data.streams();
|
for mesh in meshes {
|
||||||
for (handle, mesh) in meshes {
|
let occurrences: Vec<_> = data.mesh_occurrence_indices[mesh.occurrence_range.clone()]
|
||||||
let mut occurrences: Vec<_> = data
|
.iter()
|
||||||
.instances()
|
.map(|&index| &data.occurrences[index])
|
||||||
.filter(|(_, instance)| {
|
|
||||||
instance.mesh == handle && instance.flags.contains(RenderFlags::VISIBLE)
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
occurrences.sort_by_key(|(handle, _)| (handle.slot(), handle.generation()));
|
|
||||||
if occurrences.is_empty() {
|
if occurrences.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -59,23 +120,25 @@ impl GpuScenePlan {
|
|||||||
.checked_add(mesh.geometry.vertex_count as usize)
|
.checked_add(mesh.geometry.vertex_count as usize)
|
||||||
.ok_or("vertex range overflow")?;
|
.ok_or("vertex range overflow")?;
|
||||||
plan.positions.extend_from_slice(
|
plan.positions.extend_from_slice(
|
||||||
streams
|
data.positions
|
||||||
.positions
|
|
||||||
.get(source_start..source_end)
|
.get(source_start..source_end)
|
||||||
.ok_or("invalid vertex range")?,
|
.ok_or("invalid vertex range")?,
|
||||||
);
|
);
|
||||||
plan.normals.extend_from_slice(
|
plan.normals.extend_from_slice(
|
||||||
streams
|
data.normals
|
||||||
.normals
|
|
||||||
.get(source_start..source_end)
|
.get(source_start..source_end)
|
||||||
.ok_or("invalid normal range")?,
|
.ok_or("invalid normal range")?,
|
||||||
);
|
);
|
||||||
plan.uvs.extend_from_slice(
|
plan.uvs.extend_from_slice(
|
||||||
streams
|
data.uvs
|
||||||
.uvs
|
|
||||||
.get(source_start..source_end)
|
.get(source_start..source_end)
|
||||||
.ok_or("invalid uv range")?,
|
.ok_or("invalid uv range")?,
|
||||||
);
|
);
|
||||||
|
plan.tangents.extend_from_slice(
|
||||||
|
data.tangents
|
||||||
|
.get(source_start..source_end)
|
||||||
|
.ok_or("invalid tangent range")?,
|
||||||
|
);
|
||||||
let index_start =
|
let index_start =
|
||||||
u32::try_from(plan.indices.len()).map_err(|_| "index start exceeds u32")?;
|
u32::try_from(plan.indices.len()).map_err(|_| "index start exceeds u32")?;
|
||||||
let source_index = mesh.geometry.index_start as usize;
|
let source_index = mesh.geometry.index_start as usize;
|
||||||
@@ -83,20 +146,24 @@ impl GpuScenePlan {
|
|||||||
.checked_add(mesh.geometry.index_count as usize)
|
.checked_add(mesh.geometry.index_count as usize)
|
||||||
.ok_or("index range overflow")?;
|
.ok_or("index range overflow")?;
|
||||||
plan.indices.extend_from_slice(
|
plan.indices.extend_from_slice(
|
||||||
data.indices()
|
data.indices
|
||||||
.get(source_index..source_index_end)
|
.get(source_index..source_index_end)
|
||||||
.ok_or("invalid index range")?,
|
.ok_or("invalid index range")?,
|
||||||
);
|
);
|
||||||
let instance_start =
|
for instance in occurrences {
|
||||||
u32::try_from(plan.instances.len()).map_err(|_| "instance start exceeds u32")?;
|
let instance_start = u32::try_from(plan.instances.len())
|
||||||
for (_, instance) in occurrences {
|
.map_err(|_| "instance start exceeds u32")?;
|
||||||
|
let m = &instance.model;
|
||||||
|
let determinant = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2])
|
||||||
|
- m[1][0] * (m[0][1] * m[2][2] - m[2][1] * m[0][2])
|
||||||
|
+ m[2][0] * (m[0][1] * m[1][2] - m[1][1] * m[0][2]);
|
||||||
plan.instances.push(GpuInstance {
|
plan.instances.push(GpuInstance {
|
||||||
model: instance.model,
|
model: instance.model,
|
||||||
normal_0: [
|
normal_0: [
|
||||||
instance.normal[0][0],
|
instance.normal[0][0],
|
||||||
instance.normal[0][1],
|
instance.normal[0][1],
|
||||||
instance.normal[0][2],
|
instance.normal[0][2],
|
||||||
0.0,
|
if determinant < 0.0 { -1.0 } else { 1.0 },
|
||||||
],
|
],
|
||||||
normal_1: [
|
normal_1: [
|
||||||
instance.normal[1][0],
|
instance.normal[1][0],
|
||||||
@@ -111,20 +178,42 @@ impl GpuScenePlan {
|
|||||||
0.0,
|
0.0,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
let base_vertex =
|
||||||
|
i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?;
|
||||||
|
let end = index_start
|
||||||
|
.checked_add(mesh.geometry.index_count)
|
||||||
|
.ok_or("draw index range overflow")?;
|
||||||
|
let effective_visible = mesh.flags.contains(RenderFlags::VISIBLE)
|
||||||
|
&& instance.flags.contains(RenderFlags::VISIBLE);
|
||||||
|
plan.local_aabbs.push(GpuLocalAabb {
|
||||||
|
min: [mesh.aabb.min[0], mesh.aabb.min[1], mesh.aabb.min[2], 0.],
|
||||||
|
max: [mesh.aabb.max[0], mesh.aabb.max[1], mesh.aabb.max[2], 0.],
|
||||||
|
});
|
||||||
|
plan.effective_visibility.push(effective_visible as u32);
|
||||||
|
plan.draw_metadata.push(DrawSlotMetadata {
|
||||||
|
index_count: mesh.geometry.index_count,
|
||||||
|
first_index: index_start,
|
||||||
|
base_vertex,
|
||||||
|
instance_index: instance_start,
|
||||||
|
});
|
||||||
|
plan.commands.push(DrawIndexedIndirect {
|
||||||
|
index_count: mesh.geometry.index_count,
|
||||||
|
instance_count: 0,
|
||||||
|
first_index: index_start,
|
||||||
|
base_vertex,
|
||||||
|
first_instance: 0,
|
||||||
|
});
|
||||||
plan.draws.push(DrawItem {
|
plan.draws.push(DrawItem {
|
||||||
pipeline: mesh.pipeline,
|
pipeline: mesh.pipeline,
|
||||||
mesh: handle,
|
material: mesh.material,
|
||||||
indices: index_start
|
mesh: mesh.handle,
|
||||||
..index_start
|
indices: index_start..end,
|
||||||
.checked_add(mesh.geometry.index_count)
|
base_vertex,
|
||||||
.ok_or("draw index range overflow")?,
|
instances: instance_start..instance_start + 1,
|
||||||
base_vertex: i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?,
|
effective_visible,
|
||||||
instances: instance_start
|
|
||||||
..u32::try_from(plan.instances.len())
|
|
||||||
.map_err(|_| "instance end exceeds u32")?,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(plan)
|
Ok(plan)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,7 +237,7 @@ pub fn required_buffer_capacity(
|
|||||||
Ok(grown.min(maximum))
|
Ok(grown.min(maximum))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 4] {
|
pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 5] {
|
||||||
const INSTANCE_ATTRIBUTES: [wgpu::VertexAttribute; 7] = wgpu::vertex_attr_array![3 => Float32x4, 4 => Float32x4, 5 => Float32x4, 6 => Float32x4, 7 => Float32x4, 8 => Float32x4, 9 => Float32x4];
|
const INSTANCE_ATTRIBUTES: [wgpu::VertexAttribute; 7] = wgpu::vertex_attr_array![3 => Float32x4, 4 => Float32x4, 5 => Float32x4, 6 => Float32x4, 7 => Float32x4, 8 => Float32x4, 9 => Float32x4];
|
||||||
[
|
[
|
||||||
wgpu::VertexBufferLayout {
|
wgpu::VertexBufferLayout {
|
||||||
@@ -171,6 +260,11 @@ pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 4] {
|
|||||||
step_mode: wgpu::VertexStepMode::Instance,
|
step_mode: wgpu::VertexStepMode::Instance,
|
||||||
attributes: &INSTANCE_ATTRIBUTES,
|
attributes: &INSTANCE_ATTRIBUTES,
|
||||||
},
|
},
|
||||||
|
wgpu::VertexBufferLayout {
|
||||||
|
array_stride: 16,
|
||||||
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
|
attributes: &wgpu::vertex_attr_array![10 => Float32x4],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,12 +277,45 @@ pub struct BufferSlot {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct GpuSceneCache {
|
pub struct GpuSceneCache {
|
||||||
revision: Option<u64>,
|
revision: Option<u64>,
|
||||||
|
query: Option<crate::render_graph::MeshQueryRuntimeKeyV2>,
|
||||||
pub positions: BufferSlot,
|
pub positions: BufferSlot,
|
||||||
pub normals: BufferSlot,
|
pub normals: BufferSlot,
|
||||||
pub uvs: BufferSlot,
|
pub uvs: BufferSlot,
|
||||||
|
pub tangents: BufferSlot,
|
||||||
pub indices: BufferSlot,
|
pub indices: BufferSlot,
|
||||||
pub instances: BufferSlot,
|
pub instances: BufferSlot,
|
||||||
|
pub local_aabbs: BufferSlot,
|
||||||
|
pub effective_visibility: BufferSlot,
|
||||||
|
pub draw_metadata: BufferSlot,
|
||||||
|
pub frustum_flags: BufferSlot,
|
||||||
|
pub indirect_commands: BufferSlot,
|
||||||
pub draws: Vec<DrawItem>,
|
pub draws: Vec<DrawItem>,
|
||||||
|
compute: Option<CullingCompute>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CullingCompute {
|
||||||
|
params: wgpu::Buffer,
|
||||||
|
bind_group: wgpu::BindGroup,
|
||||||
|
frustum_pipeline: wgpu::ComputePipeline,
|
||||||
|
query_pipeline: wgpu::ComputePipeline,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||||
|
struct CullingParams {
|
||||||
|
planes: [[f32; 4]; 6],
|
||||||
|
count: u32,
|
||||||
|
visible_predicate: u32,
|
||||||
|
frustum_predicate: u32,
|
||||||
|
_pad: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn predicate_code(value: crate::render_graph::TriStatePredicate) -> u32 {
|
||||||
|
match value {
|
||||||
|
crate::render_graph::TriStatePredicate::Any => 0,
|
||||||
|
crate::render_graph::TriStatePredicate::RequiredTrue => 1,
|
||||||
|
crate::render_graph::TriStatePredicate::RequiredFalse => 2,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GpuSceneCache {
|
impl GpuSceneCache {
|
||||||
@@ -196,15 +323,34 @@ impl GpuSceneCache {
|
|||||||
&mut self,
|
&mut self,
|
||||||
device: &wgpu::Device,
|
device: &wgpu::Device,
|
||||||
queue: &wgpu::Queue,
|
queue: &wgpu::Queue,
|
||||||
data: &RenderData,
|
data: &SceneFramePlan,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if self.revision == Some(data.revision()) {
|
self.upload_with_query(
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
data,
|
||||||
|
crate::render_graph::MeshQueryRuntimeKeyV2 {
|
||||||
|
visible: crate::render_graph::TriStatePredicate::RequiredTrue,
|
||||||
|
frustum_culled: crate::render_graph::TriStatePredicate::Any,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upload_with_query(
|
||||||
|
&mut self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
data: &SceneFramePlan,
|
||||||
|
query: crate::render_graph::MeshQueryRuntimeKeyV2,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if self.revision == Some(data.revision) && self.query == Some(query) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let plan = GpuScenePlan::build(data).map_err(str::to_owned)?;
|
let plan = GpuScenePlan::build_with_query(data, query).map_err(str::to_owned)?;
|
||||||
if plan.draws.is_empty() {
|
if plan.draws.is_empty() {
|
||||||
self.draws.clear();
|
self.draws.clear();
|
||||||
self.revision = Some(data.revision());
|
self.revision = Some(data.revision);
|
||||||
|
self.query = Some(query);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let maximum = device.limits().max_buffer_size;
|
let maximum = device.limits().max_buffer_size;
|
||||||
@@ -218,18 +364,30 @@ impl GpuSceneCache {
|
|||||||
bytes(&plan.positions)?,
|
bytes(&plan.positions)?,
|
||||||
bytes(&plan.normals)?,
|
bytes(&plan.normals)?,
|
||||||
bytes(&plan.uvs)?,
|
bytes(&plan.uvs)?,
|
||||||
|
bytes(&plan.tangents)?,
|
||||||
bytes(&plan.indices)?,
|
bytes(&plan.indices)?,
|
||||||
bytes(&plan.instances)?,
|
bytes(&plan.instances)?,
|
||||||
|
bytes(&plan.local_aabbs)?,
|
||||||
|
bytes(&plan.effective_visibility)?,
|
||||||
|
bytes(&plan.draw_metadata)?,
|
||||||
|
bytes(&plan.effective_visibility)?,
|
||||||
|
bytes(&plan.commands)?,
|
||||||
];
|
];
|
||||||
let old = [
|
let old = [
|
||||||
self.positions.capacity,
|
self.positions.capacity,
|
||||||
self.normals.capacity,
|
self.normals.capacity,
|
||||||
self.uvs.capacity,
|
self.uvs.capacity,
|
||||||
|
self.tangents.capacity,
|
||||||
self.indices.capacity,
|
self.indices.capacity,
|
||||||
self.instances.capacity,
|
self.instances.capacity,
|
||||||
|
self.local_aabbs.capacity,
|
||||||
|
self.effective_visibility.capacity,
|
||||||
|
self.draw_metadata.capacity,
|
||||||
|
self.frustum_flags.capacity,
|
||||||
|
self.indirect_commands.capacity,
|
||||||
];
|
];
|
||||||
let mut capacities = [0; 5];
|
let mut capacities = [0; 11];
|
||||||
for i in 0..5 {
|
for i in 0..11 {
|
||||||
capacities[i] =
|
capacities[i] =
|
||||||
required_buffer_capacity(old[i], required[i], maximum).map_err(str::to_owned)?;
|
required_buffer_capacity(old[i], required[i], maximum).map_err(str::to_owned)?;
|
||||||
}
|
}
|
||||||
@@ -237,18 +395,30 @@ impl GpuSceneCache {
|
|||||||
wgpu::BufferUsages::VERTEX,
|
wgpu::BufferUsages::VERTEX,
|
||||||
wgpu::BufferUsages::VERTEX,
|
wgpu::BufferUsages::VERTEX,
|
||||||
wgpu::BufferUsages::VERTEX,
|
wgpu::BufferUsages::VERTEX,
|
||||||
wgpu::BufferUsages::INDEX,
|
|
||||||
wgpu::BufferUsages::VERTEX,
|
wgpu::BufferUsages::VERTEX,
|
||||||
|
wgpu::BufferUsages::INDEX,
|
||||||
|
wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE,
|
||||||
|
wgpu::BufferUsages::STORAGE,
|
||||||
|
wgpu::BufferUsages::STORAGE,
|
||||||
|
wgpu::BufferUsages::STORAGE,
|
||||||
|
wgpu::BufferUsages::STORAGE,
|
||||||
|
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDIRECT,
|
||||||
];
|
];
|
||||||
let labels = [
|
let labels = [
|
||||||
"scene positions",
|
"scene positions",
|
||||||
"scene normals",
|
"scene normals",
|
||||||
"scene uvs",
|
"scene uvs",
|
||||||
|
"scene tangents",
|
||||||
"scene indices",
|
"scene indices",
|
||||||
"scene instances",
|
"scene instances",
|
||||||
|
"scene local aabbs",
|
||||||
|
"scene effective visibility",
|
||||||
|
"scene draw metadata",
|
||||||
|
"scene frustum flags",
|
||||||
|
"scene indirect commands",
|
||||||
];
|
];
|
||||||
let mut replacements: [Option<wgpu::Buffer>; 5] = Default::default();
|
let mut replacements: [Option<wgpu::Buffer>; 11] = Default::default();
|
||||||
for i in 0..5 {
|
for i in 0..11 {
|
||||||
if capacities[i] != old[i] {
|
if capacities[i] != old[i] {
|
||||||
replacements[i] = Some(device.create_buffer(&wgpu::BufferDescriptor {
|
replacements[i] = Some(device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some(labels[i]),
|
label: Some(labels[i]),
|
||||||
@@ -262,8 +432,14 @@ impl GpuSceneCache {
|
|||||||
&mut self.positions,
|
&mut self.positions,
|
||||||
&mut self.normals,
|
&mut self.normals,
|
||||||
&mut self.uvs,
|
&mut self.uvs,
|
||||||
|
&mut self.tangents,
|
||||||
&mut self.indices,
|
&mut self.indices,
|
||||||
&mut self.instances,
|
&mut self.instances,
|
||||||
|
&mut self.local_aabbs,
|
||||||
|
&mut self.effective_visibility,
|
||||||
|
&mut self.draw_metadata,
|
||||||
|
&mut self.frustum_flags,
|
||||||
|
&mut self.indirect_commands,
|
||||||
];
|
];
|
||||||
for (i, slot) in slots.into_iter().enumerate() {
|
for (i, slot) in slots.into_iter().enumerate() {
|
||||||
if let Some(buffer) = replacements[i].take() {
|
if let Some(buffer) = replacements[i].take() {
|
||||||
@@ -275,15 +451,27 @@ impl GpuSceneCache {
|
|||||||
bytemuck::cast_slice(&plan.positions),
|
bytemuck::cast_slice(&plan.positions),
|
||||||
bytemuck::cast_slice(&plan.normals),
|
bytemuck::cast_slice(&plan.normals),
|
||||||
bytemuck::cast_slice(&plan.uvs),
|
bytemuck::cast_slice(&plan.uvs),
|
||||||
|
bytemuck::cast_slice(&plan.tangents),
|
||||||
bytemuck::cast_slice(&plan.indices),
|
bytemuck::cast_slice(&plan.indices),
|
||||||
bytemuck::cast_slice(&plan.instances),
|
bytemuck::cast_slice(&plan.instances),
|
||||||
|
bytemuck::cast_slice(&plan.local_aabbs),
|
||||||
|
bytemuck::cast_slice(&plan.effective_visibility),
|
||||||
|
bytemuck::cast_slice(&plan.draw_metadata),
|
||||||
|
bytemuck::cast_slice(&plan.effective_visibility),
|
||||||
|
bytemuck::cast_slice(&plan.commands),
|
||||||
];
|
];
|
||||||
let slots = [
|
let slots = [
|
||||||
&self.positions,
|
&self.positions,
|
||||||
&self.normals,
|
&self.normals,
|
||||||
&self.uvs,
|
&self.uvs,
|
||||||
|
&self.tangents,
|
||||||
&self.indices,
|
&self.indices,
|
||||||
&self.instances,
|
&self.instances,
|
||||||
|
&self.local_aabbs,
|
||||||
|
&self.effective_visibility,
|
||||||
|
&self.draw_metadata,
|
||||||
|
&self.frustum_flags,
|
||||||
|
&self.indirect_commands,
|
||||||
];
|
];
|
||||||
for (slot, contents) in slots.into_iter().zip(contents) {
|
for (slot, contents) in slots.into_iter().zip(contents) {
|
||||||
if !contents.is_empty() {
|
if !contents.is_empty() {
|
||||||
@@ -295,15 +483,205 @@ impl GpuSceneCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.draws = plan.draws;
|
self.draws = plan.draws;
|
||||||
self.revision = Some(data.revision());
|
self.rebuild_compute(device)?;
|
||||||
|
self.revision = Some(data.revision);
|
||||||
|
self.query = Some(query);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rebuild_compute(&mut self, device: &wgpu::Device) -> Result<(), String> {
|
||||||
|
if self.draws.is_empty() {
|
||||||
|
self.compute = None;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("scene culling compute"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(include_str!("culling.wgsl").into()),
|
||||||
|
});
|
||||||
|
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("scene culling layout"),
|
||||||
|
entries: &(0..7)
|
||||||
|
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||||
|
binding,
|
||||||
|
visibility: wgpu::ShaderStages::COMPUTE,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: if binding == 0 {
|
||||||
|
wgpu::BufferBindingType::Uniform
|
||||||
|
} else {
|
||||||
|
wgpu::BufferBindingType::Storage {
|
||||||
|
read_only: binding < 5,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
});
|
||||||
|
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("scene culling params"),
|
||||||
|
size: size_of::<CullingParams>() as u64,
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let buffers = [
|
||||||
|
&self.instances,
|
||||||
|
&self.local_aabbs,
|
||||||
|
&self.effective_visibility,
|
||||||
|
&self.draw_metadata,
|
||||||
|
&self.frustum_flags,
|
||||||
|
&self.indirect_commands,
|
||||||
|
];
|
||||||
|
let mut entries = vec![wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: params.as_entire_binding(),
|
||||||
|
}];
|
||||||
|
for (i, slot) in buffers.iter().enumerate() {
|
||||||
|
entries.push(wgpu::BindGroupEntry {
|
||||||
|
binding: i as u32 + 1,
|
||||||
|
resource: slot
|
||||||
|
.buffer
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("culling buffer absent")?
|
||||||
|
.as_entire_binding(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("scene culling group"),
|
||||||
|
layout: &layout,
|
||||||
|
entries: &entries,
|
||||||
|
});
|
||||||
|
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("scene culling pipeline layout"),
|
||||||
|
bind_group_layouts: &[&layout],
|
||||||
|
push_constant_ranges: &[],
|
||||||
|
});
|
||||||
|
let pipeline = |entry| {
|
||||||
|
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||||
|
label: Some(entry),
|
||||||
|
layout: Some(&pipeline_layout),
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some(entry),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
cache: None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
self.compute = Some(CullingCompute {
|
||||||
|
params,
|
||||||
|
bind_group,
|
||||||
|
frustum_pipeline: pipeline("frustum_cull"),
|
||||||
|
query_pipeline: pipeline("mesh_query"),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_culling_params(
|
||||||
|
&self,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
planes: Option<[[f32; 4]; 6]>,
|
||||||
|
query: crate::render_graph::MeshQueryRuntimeKeyV2,
|
||||||
|
) {
|
||||||
|
if let Some(compute) = &self.compute {
|
||||||
|
if let Some(planes) = planes {
|
||||||
|
queue.write_buffer(&compute.params, 0, bytemuck::bytes_of(&planes));
|
||||||
|
}
|
||||||
|
let tail = CullingParams {
|
||||||
|
planes: [[0.0; 4]; 6],
|
||||||
|
count: self.draws.len() as u32,
|
||||||
|
visible_predicate: predicate_code(query.visible),
|
||||||
|
frustum_predicate: predicate_code(query.frustum_culled),
|
||||||
|
_pad: 0,
|
||||||
|
};
|
||||||
|
queue.write_buffer(
|
||||||
|
&compute.params,
|
||||||
|
std::mem::offset_of!(CullingParams, count) as u64,
|
||||||
|
&bytemuck::bytes_of(&tail)[std::mem::offset_of!(CullingParams, count)..],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn encode_frustum_cull(
|
||||||
|
&self,
|
||||||
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
|
profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
|
||||||
|
profile_id: &str,
|
||||||
|
) {
|
||||||
|
let Some(c) = &self.compute else { return };
|
||||||
|
let timestamps = profile.and_then(|p| p.compute_writes(profile_id));
|
||||||
|
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||||
|
label: Some("frustum_cull"),
|
||||||
|
timestamp_writes: timestamps,
|
||||||
|
});
|
||||||
|
pass.set_pipeline(&c.frustum_pipeline);
|
||||||
|
pass.set_bind_group(0, &c.bind_group, &[]);
|
||||||
|
pass.dispatch_workgroups((self.draws.len() as u32 + 63) / 64, 1, 1);
|
||||||
|
}
|
||||||
|
pub(crate) fn encode_mesh_query(
|
||||||
|
&self,
|
||||||
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
|
profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
|
||||||
|
profile_id: &str,
|
||||||
|
) {
|
||||||
|
let Some(c) = &self.compute else { return };
|
||||||
|
let timestamps = profile.and_then(|p| p.compute_writes(profile_id));
|
||||||
|
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||||
|
label: Some("mesh_query"),
|
||||||
|
timestamp_writes: timestamps,
|
||||||
|
});
|
||||||
|
pass.set_pipeline(&c.query_pipeline);
|
||||||
|
pass.set_bind_group(0, &c.bind_group, &[]);
|
||||||
|
pass.dispatch_workgroups((self.draws.len() as u32 + 63) / 64, 1, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::render_data::{MeshCreateInfo, RenderDataConfig, IDENTITY_MODEL_TRANSFORM};
|
|
||||||
|
#[test]
|
||||||
|
fn mesh_query_source_guards_optional_flag_buffer_reads() {
|
||||||
|
let source = include_str!("culling.wgsl");
|
||||||
|
let visible_guard = source.find("if (params.visible_predicate != 0u)").unwrap();
|
||||||
|
let visible_load = source.find("matches(authored_visible[i]").unwrap();
|
||||||
|
let frustum_guard = source.find("if (params.frustum_predicate != 0u)").unwrap();
|
||||||
|
let frustum_load = source.find("matches(frustum_flags[i]").unwrap();
|
||||||
|
assert!(visible_guard < visible_load && frustum_guard < frustum_load);
|
||||||
|
for binding in 0..=6 {
|
||||||
|
assert!(source.contains(&format!("@binding({binding})")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_visibility_handles_every_mesh_instance_combination() {
|
||||||
|
use crate::render_graph::TriStatePredicate::{Any, RequiredFalse, RequiredTrue};
|
||||||
|
for (mesh, instance, effective) in [
|
||||||
|
(false, false, false),
|
||||||
|
(false, true, false),
|
||||||
|
(true, false, false),
|
||||||
|
(true, true, true),
|
||||||
|
] {
|
||||||
|
let flags = |visible| {
|
||||||
|
if visible {
|
||||||
|
RenderFlags::VISIBLE
|
||||||
|
} else {
|
||||||
|
RenderFlags::NONE
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert!(visibility_matches(Any, flags(mesh), flags(instance)));
|
||||||
|
assert_eq!(
|
||||||
|
visibility_matches(RequiredTrue, flags(mesh), flags(instance)),
|
||||||
|
effective
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
visibility_matches(RequiredFalse, flags(mesh), flags(instance)),
|
||||||
|
!effective
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
use crate::render_data::{
|
||||||
|
MeshCreateInfo, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM,
|
||||||
|
};
|
||||||
#[test]
|
#[test]
|
||||||
fn instance_is_112_bytes_and_padding_is_zero() {
|
fn instance_is_112_bytes_and_padding_is_zero() {
|
||||||
assert_eq!(size_of::<GpuInstance>(), 112);
|
assert_eq!(size_of::<GpuInstance>(), 112);
|
||||||
@@ -321,6 +699,28 @@ mod tests {
|
|||||||
assert!(required_buffer_capacity(0, 33, 32).is_err());
|
assert!(required_buffer_capacity(0, 33, 32).is_err());
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
|
fn mirrored_model_stores_negative_determinant_sign_in_padding() {
|
||||||
|
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
|
||||||
|
let mut mirrored = IDENTITY_MODEL_TRANSFORM;
|
||||||
|
mirrored[0][0] = -1.0;
|
||||||
|
data.create_mesh(MeshCreateInfo {
|
||||||
|
positions: &[[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]],
|
||||||
|
normals: &[[0., 0., 1.]; 3],
|
||||||
|
tangents: &[[1., 0., 0., 1.]; 3],
|
||||||
|
uvs: &[[0., 0.]; 3],
|
||||||
|
indices: &[0, 1, 2],
|
||||||
|
pipeline: PipelineKey::new(0),
|
||||||
|
material: MaterialKey::DEFAULT,
|
||||||
|
flags: RenderFlags::VISIBLE,
|
||||||
|
default_instance_flags: RenderFlags::VISIBLE,
|
||||||
|
default_transform: mirrored,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let frame = crate::renderer::scene_frame::SceneFramePlan::build(&data).unwrap();
|
||||||
|
let plan = GpuScenePlan::build(&frame).unwrap();
|
||||||
|
assert_eq!(plan.instances[0].normal_0[3], -1.0);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
fn capacity_reuses_and_layout_matches_shader_contract() {
|
fn capacity_reuses_and_layout_matches_shader_contract() {
|
||||||
assert_eq!(required_buffer_capacity(16, 12, 32), Ok(16));
|
assert_eq!(required_buffer_capacity(16, 12, 32), Ok(16));
|
||||||
assert_eq!(required_buffer_capacity(0, 1, 32), Ok(1));
|
assert_eq!(required_buffer_capacity(0, 1, 32), Ok(1));
|
||||||
@@ -330,7 +730,7 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|layout| layout.array_stride)
|
.map(|layout| layout.array_stride)
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
[12, 12, 8, 112]
|
[12, 12, 8, 112, 16]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
layouts[3]
|
layouts[3]
|
||||||
@@ -342,7 +742,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn plan_orders_pipelines_skips_hidden_and_uses_local_indices() {
|
fn plan_is_canonical_and_predicate_independent() {
|
||||||
let mut data = RenderData::new(RenderDataConfig {
|
let mut data = RenderData::new(RenderDataConfig {
|
||||||
initial_vertices: 0,
|
initial_vertices: 0,
|
||||||
initial_indices: 0,
|
initial_indices: 0,
|
||||||
@@ -359,9 +759,11 @@ mod tests {
|
|||||||
data.create_mesh(MeshCreateInfo {
|
data.create_mesh(MeshCreateInfo {
|
||||||
positions: &p,
|
positions: &p,
|
||||||
normals: &n,
|
normals: &n,
|
||||||
|
tangents: &[[1., 0., 0., 1.]; 3],
|
||||||
uvs: &u,
|
uvs: &u,
|
||||||
indices: &i,
|
indices: &i,
|
||||||
pipeline: PipelineKey::new(pipeline),
|
pipeline: PipelineKey::new(pipeline),
|
||||||
|
material: crate::render_data::MaterialKey::DEFAULT,
|
||||||
flags: RenderFlags::VISIBLE,
|
flags: RenderFlags::VISIBLE,
|
||||||
default_instance_flags: if visible {
|
default_instance_flags: if visible {
|
||||||
RenderFlags::VISIBLE
|
RenderFlags::VISIBLE
|
||||||
@@ -377,18 +779,28 @@ mod tests {
|
|||||||
let low = add(2, true);
|
let low = add(2, true);
|
||||||
data.create_instance(low.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE)
|
data.create_instance(low.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let plan = GpuScenePlan::build(&data).unwrap();
|
let frame = crate::renderer::scene_frame::SceneFramePlan::build(&data).unwrap();
|
||||||
|
let plan = GpuScenePlan::build(&frame).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.draws
|
plan.draws
|
||||||
.iter()
|
.iter()
|
||||||
.map(|d| d.pipeline.get())
|
.map(|d| d.pipeline.get())
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![2, 9]
|
vec![0, 2, 2, 9]
|
||||||
);
|
);
|
||||||
assert_eq!(plan.draws[0].base_vertex, 0);
|
assert_eq!(plan.draws[0].base_vertex, 0);
|
||||||
|
assert!(!plan.draws[0].effective_visible);
|
||||||
assert_eq!(plan.draws[1].base_vertex, 3);
|
assert_eq!(plan.draws[1].base_vertex, 3);
|
||||||
assert_eq!(plan.draws[0].instances, 0..2);
|
assert_eq!(plan.draws[1].instances, 1..2);
|
||||||
assert_eq!(plan.indices, [0, 1, 2, 0, 1, 2]);
|
assert_eq!(plan.draws[2].instances, 2..3);
|
||||||
assert_eq!(high.mesh, plan.draws[1].mesh);
|
assert_eq!(plan.indices, [0, 1, 2, 0, 1, 2, 0, 1, 2]);
|
||||||
|
assert_eq!(high.mesh, plan.draws[3].mesh);
|
||||||
|
assert_eq!(size_of::<GpuLocalAabb>(), 32);
|
||||||
|
assert_eq!(size_of::<DrawSlotMetadata>(), 16);
|
||||||
|
assert_eq!(size_of::<DrawIndexedIndirect>(), 20);
|
||||||
|
assert!(plan
|
||||||
|
.commands
|
||||||
|
.iter()
|
||||||
|
.all(|command| command.first_instance == 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,597 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
use image::DynamicImage;
|
||||||
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
gltf::{AlphaMode, ImageSource, ImportedScene, Material, SamplerMetadata, TextureReference},
|
||||||
|
render_data::MaterialKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE: u32 = 1 << 0;
|
||||||
|
const MR: u32 = 1 << 1;
|
||||||
|
const NORMAL: u32 = 1 << 2;
|
||||||
|
const OCCLUSION: u32 = 1 << 3;
|
||||||
|
const EMISSIVE: u32 = 1 << 4;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Debug, Pod, Zeroable)]
|
||||||
|
pub struct GpuMaterial {
|
||||||
|
pub base_color_factor: [f32; 4],
|
||||||
|
pub emissive_factor: [f32; 4],
|
||||||
|
pub surface_factors: [f32; 4],
|
||||||
|
pub alpha_optics: [f32; 4],
|
||||||
|
pub flags: [u32; 4],
|
||||||
|
pub uv_sets: [u32; 4],
|
||||||
|
/// Internal shader diagnostics; zero means the normal shaded view.
|
||||||
|
pub debug_extras: [u32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enabled(reference: Option<TextureReference>, bit: u32) -> u32 {
|
||||||
|
reference.filter(|r| r.tex_coord == 0).map_or(0, |_| bit)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Material> for GpuMaterial {
|
||||||
|
fn from(value: &Material) -> Self {
|
||||||
|
Self {
|
||||||
|
base_color_factor: value.base_color_factor,
|
||||||
|
emissive_factor: [
|
||||||
|
value.emissive_factor[0],
|
||||||
|
value.emissive_factor[1],
|
||||||
|
value.emissive_factor[2],
|
||||||
|
0.0,
|
||||||
|
],
|
||||||
|
surface_factors: [
|
||||||
|
value.metallic_factor,
|
||||||
|
value.roughness_factor,
|
||||||
|
value.normal_scale,
|
||||||
|
value.occlusion_strength,
|
||||||
|
],
|
||||||
|
alpha_optics: [
|
||||||
|
match value.alpha_mode {
|
||||||
|
AlphaMode::Opaque => 0.0,
|
||||||
|
AlphaMode::Mask => 1.0,
|
||||||
|
AlphaMode::Blend => 2.0,
|
||||||
|
},
|
||||||
|
value.alpha_cutoff,
|
||||||
|
value.ior,
|
||||||
|
if value.ior == 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
((value.ior - 1.0) / (value.ior + 1.0)).powi(2)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
flags: [
|
||||||
|
enabled(value.base_color_texture, BASE)
|
||||||
|
| enabled(value.metallic_roughness_texture, MR)
|
||||||
|
| enabled(value.normal_texture, NORMAL)
|
||||||
|
| enabled(value.occlusion_texture, OCCLUSION)
|
||||||
|
| enabled(value.emissive_texture, EMISSIVE),
|
||||||
|
u32::from(value.double_sided),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
uv_sets: [
|
||||||
|
value.base_color_texture.map_or(0, |x| x.tex_coord),
|
||||||
|
value.metallic_roughness_texture.map_or(0, |x| x.tex_coord),
|
||||||
|
value.normal_texture.map_or(0, |x| x.tex_coord),
|
||||||
|
value.occlusion_texture.map_or(0, |x| x.tex_coord),
|
||||||
|
],
|
||||||
|
debug_extras: [value.emissive_texture.map_or(0, |x| x.tex_coord), 0, 0, 0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum MaterialError {
|
||||||
|
#[error("external image sources are unsupported")]
|
||||||
|
ExternalImage,
|
||||||
|
#[error("unsupported image MIME type: {0}")]
|
||||||
|
Mime(String),
|
||||||
|
#[error("image decode failed: {0}")]
|
||||||
|
Decode(#[from] image::ImageError),
|
||||||
|
#[error("invalid texture, image, or sampler index")]
|
||||||
|
InvalidReference,
|
||||||
|
#[error("invalid decoded RGBA image: {0}")]
|
||||||
|
InvalidRgba(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct PreparedMaterials {
|
||||||
|
groups: HashMap<MaterialKey, wgpu::BindGroup>,
|
||||||
|
textures: Vec<wgpu::Texture>,
|
||||||
|
views: Vec<[wgpu::TextureView; 2]>,
|
||||||
|
samplers: Vec<wgpu::Sampler>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MaterialResources {
|
||||||
|
pub layout: wgpu::BindGroupLayout,
|
||||||
|
groups: HashMap<MaterialKey, wgpu::BindGroup>,
|
||||||
|
fallback: wgpu::BindGroup,
|
||||||
|
fallback_views: Vec<wgpu::TextureView>,
|
||||||
|
fallback_sampler: wgpu::Sampler,
|
||||||
|
textures: Vec<wgpu::Texture>,
|
||||||
|
views: Vec<[wgpu::TextureView; 2]>,
|
||||||
|
samplers: Vec<wgpu::Sampler>,
|
||||||
|
pub asset_epoch: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout_entry(binding: u32, ty: wgpu::BindingType) -> wgpu::BindGroupLayoutEntry {
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty,
|
||||||
|
count: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload_rgba(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
label: &str,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
bytes_per_row: u32,
|
||||||
|
rgba: &[u8],
|
||||||
|
) -> (wgpu::Texture, [wgpu::TextureView; 2]) {
|
||||||
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some(label),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb],
|
||||||
|
});
|
||||||
|
queue.write_texture(
|
||||||
|
texture.as_image_copy(),
|
||||||
|
rgba,
|
||||||
|
wgpu::TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(bytes_per_row),
|
||||||
|
rows_per_image: Some(height),
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let linear = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||||
|
format: Some(wgpu::TextureFormat::Rgba8Unorm),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let srgb = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||||
|
format: Some(wgpu::TextureFormat::Rgba8UnormSrgb),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
(texture, [linear, srgb])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_rgba(image: DynamicImage) -> (u32, u32, Vec<u8>) {
|
||||||
|
let rgba = image.into_rgba8();
|
||||||
|
(rgba.width(), rgba.height(), rgba.into_raw())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_decoded_rgba(
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
rgba: &[u8],
|
||||||
|
max_dimension: u32,
|
||||||
|
) -> Result<u32, MaterialError> {
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
return Err(MaterialError::InvalidRgba("dimensions must be nonzero"));
|
||||||
|
}
|
||||||
|
if width > max_dimension || height > max_dimension {
|
||||||
|
return Err(MaterialError::InvalidRgba("dimensions exceed device limit"));
|
||||||
|
}
|
||||||
|
let bytes_per_row = width
|
||||||
|
.checked_mul(4)
|
||||||
|
.ok_or(MaterialError::InvalidRgba("row byte count overflows"))?;
|
||||||
|
let total = bytes_per_row
|
||||||
|
.checked_mul(height)
|
||||||
|
.ok_or(MaterialError::InvalidRgba("total byte count overflows"))?;
|
||||||
|
let total = usize::try_from(total)
|
||||||
|
.map_err(|_| MaterialError::InvalidRgba("total byte count exceeds usize"))?;
|
||||||
|
if rgba.len() != total {
|
||||||
|
return Err(MaterialError::InvalidRgba("pixel byte length is not exact"));
|
||||||
|
}
|
||||||
|
Ok(bytes_per_row)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slot_uses_srgb(slot: usize) -> bool {
|
||||||
|
matches!(slot, 0 | 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn address(value: &str) -> wgpu::AddressMode {
|
||||||
|
match value {
|
||||||
|
"ClampToEdge" => wgpu::AddressMode::ClampToEdge,
|
||||||
|
"MirroredRepeat" => wgpu::AddressMode::MirrorRepeat,
|
||||||
|
"Repeat" => wgpu::AddressMode::Repeat,
|
||||||
|
_ => unreachable!("gltf crate returned unknown wrap"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sampler_descriptor(metadata: Option<&SamplerMetadata>) -> wgpu::SamplerDescriptor<'static> {
|
||||||
|
let (mag, min, mip, s, t) = metadata.map_or(
|
||||||
|
(
|
||||||
|
wgpu::FilterMode::Linear,
|
||||||
|
wgpu::FilterMode::Linear,
|
||||||
|
wgpu::FilterMode::Linear,
|
||||||
|
wgpu::AddressMode::Repeat,
|
||||||
|
wgpu::AddressMode::Repeat,
|
||||||
|
),
|
||||||
|
|m| {
|
||||||
|
let mag = match m.mag_filter.as_deref() {
|
||||||
|
Some("Nearest") => wgpu::FilterMode::Nearest,
|
||||||
|
Some("Linear") | None => wgpu::FilterMode::Linear,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
let (min, mip) = match m.min_filter.as_deref() {
|
||||||
|
Some("Nearest") => (wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest),
|
||||||
|
Some("Linear") => (wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest),
|
||||||
|
Some("NearestMipmapNearest") => {
|
||||||
|
(wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest)
|
||||||
|
}
|
||||||
|
Some("LinearMipmapNearest") => {
|
||||||
|
(wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest)
|
||||||
|
}
|
||||||
|
Some("NearestMipmapLinear") => {
|
||||||
|
(wgpu::FilterMode::Nearest, wgpu::FilterMode::Linear)
|
||||||
|
}
|
||||||
|
Some("LinearMipmapLinear") | None => {
|
||||||
|
(wgpu::FilterMode::Linear, wgpu::FilterMode::Linear)
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
(mag, min, mip, address(&m.wrap_s), address(&m.wrap_t))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
wgpu::SamplerDescriptor {
|
||||||
|
address_mode_u: s,
|
||||||
|
address_mode_v: t,
|
||||||
|
mag_filter: mag,
|
||||||
|
min_filter: min,
|
||||||
|
mipmap_filter: mip,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MaterialResources {
|
||||||
|
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
|
||||||
|
let mut entries = vec![layout_entry(
|
||||||
|
0,
|
||||||
|
wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: wgpu::BufferSize::new(112),
|
||||||
|
},
|
||||||
|
)];
|
||||||
|
for binding in 1..=5 {
|
||||||
|
entries.push(layout_entry(
|
||||||
|
binding,
|
||||||
|
wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for binding in 6..=10 {
|
||||||
|
entries.push(layout_entry(
|
||||||
|
binding,
|
||||||
|
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("glTF material group 2"),
|
||||||
|
entries: &entries,
|
||||||
|
});
|
||||||
|
let colors = [[255, 255, 255, 255], [128, 128, 255, 255], [0, 0, 0, 255]];
|
||||||
|
let mut fallback_textures = Vec::new();
|
||||||
|
let mut fallback_views = Vec::new();
|
||||||
|
for color in colors {
|
||||||
|
let (t, v) = upload_rgba(device, queue, "neutral material texture", 1, 1, 4, &color);
|
||||||
|
fallback_views.push(v[0].clone());
|
||||||
|
fallback_textures.push(t);
|
||||||
|
}
|
||||||
|
let fallback_sampler = device.create_sampler(&sampler_descriptor(None));
|
||||||
|
let fallback = Self::make_group(
|
||||||
|
device,
|
||||||
|
&layout,
|
||||||
|
&Material::default(),
|
||||||
|
[
|
||||||
|
&fallback_views[0],
|
||||||
|
&fallback_views[0],
|
||||||
|
&fallback_views[1],
|
||||||
|
&fallback_views[0],
|
||||||
|
&fallback_views[2],
|
||||||
|
],
|
||||||
|
[&fallback_sampler; 5],
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
layout,
|
||||||
|
groups: HashMap::new(),
|
||||||
|
fallback,
|
||||||
|
fallback_views,
|
||||||
|
fallback_sampler,
|
||||||
|
textures: fallback_textures,
|
||||||
|
views: Vec::new(),
|
||||||
|
samplers: Vec::new(),
|
||||||
|
asset_epoch: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_group(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
layout: &wgpu::BindGroupLayout,
|
||||||
|
material: &Material,
|
||||||
|
views: [&wgpu::TextureView; 5],
|
||||||
|
samplers: [&wgpu::Sampler; 5],
|
||||||
|
) -> wgpu::BindGroup {
|
||||||
|
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("material uniform"),
|
||||||
|
contents: bytemuck::bytes_of(&GpuMaterial::from(material)),
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM,
|
||||||
|
});
|
||||||
|
let mut entries = vec![wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: uniform.as_entire_binding(),
|
||||||
|
}];
|
||||||
|
for (i, view) in views.into_iter().enumerate() {
|
||||||
|
entries.push(wgpu::BindGroupEntry {
|
||||||
|
binding: i as u32 + 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(view),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (i, sampler) in samplers.into_iter().enumerate() {
|
||||||
|
entries.push(wgpu::BindGroupEntry {
|
||||||
|
binding: i as u32 + 6,
|
||||||
|
resource: wgpu::BindingResource::Sampler(sampler),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("material bind group"),
|
||||||
|
layout,
|
||||||
|
entries: &entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn prepare(
|
||||||
|
&self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
scene: &ImportedScene,
|
||||||
|
) -> Result<PreparedMaterials, MaterialError> {
|
||||||
|
let max_dimension = device.limits().max_texture_dimension_2d;
|
||||||
|
let mut textures = Vec::with_capacity(scene.images.len());
|
||||||
|
let mut views = Vec::with_capacity(scene.images.len());
|
||||||
|
for image in &scene.images {
|
||||||
|
if !matches!(image.source, ImageSource::BufferView(_)) {
|
||||||
|
return Err(MaterialError::ExternalImage);
|
||||||
|
}
|
||||||
|
let format = match image.mime_type.as_deref() {
|
||||||
|
Some("image/png") => image::ImageFormat::Png,
|
||||||
|
Some("image/jpeg") => image::ImageFormat::Jpeg,
|
||||||
|
other => return Err(MaterialError::Mime(other.unwrap_or("missing").into())),
|
||||||
|
};
|
||||||
|
let decoded = image::load_from_memory_with_format(&image.encoded_data, format)?;
|
||||||
|
let (width, height, rgba) = normalize_rgba(decoded);
|
||||||
|
let bytes_per_row = validate_decoded_rgba(width, height, &rgba, max_dimension)?;
|
||||||
|
let (texture, image_views) = upload_rgba(
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
"glTF embedded image",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
bytes_per_row,
|
||||||
|
&rgba,
|
||||||
|
);
|
||||||
|
drop(rgba);
|
||||||
|
textures.push(texture);
|
||||||
|
views.push(image_views);
|
||||||
|
}
|
||||||
|
let mut samplers = Vec::new();
|
||||||
|
for sampler in &scene.samplers {
|
||||||
|
samplers.push(device.create_sampler(&sampler_descriptor(Some(sampler))));
|
||||||
|
}
|
||||||
|
let default_sampler = device.create_sampler(&sampler_descriptor(None));
|
||||||
|
samplers.push(default_sampler);
|
||||||
|
let default_index = samplers.len() - 1;
|
||||||
|
let mut groups = HashMap::new();
|
||||||
|
for material in &scene.materials {
|
||||||
|
let refs = [
|
||||||
|
material.base_color_texture,
|
||||||
|
material.metallic_roughness_texture,
|
||||||
|
material.normal_texture,
|
||||||
|
material.occlusion_texture,
|
||||||
|
material.emissive_texture,
|
||||||
|
];
|
||||||
|
let mut selected_views = [
|
||||||
|
&self.fallback_views[0],
|
||||||
|
&self.fallback_views[0],
|
||||||
|
&self.fallback_views[1],
|
||||||
|
&self.fallback_views[0],
|
||||||
|
&self.fallback_views[2],
|
||||||
|
];
|
||||||
|
let mut selected_samplers = [&self.fallback_sampler; 5];
|
||||||
|
for (slot, reference) in refs.into_iter().enumerate() {
|
||||||
|
let Some(reference) = reference.filter(|r| r.tex_coord == 0) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let texture = scene
|
||||||
|
.textures
|
||||||
|
.get(reference.texture)
|
||||||
|
.ok_or(MaterialError::InvalidReference)?;
|
||||||
|
selected_views[slot] = &views
|
||||||
|
.get(texture.image)
|
||||||
|
.ok_or(MaterialError::InvalidReference)?[usize::from(slot_uses_srgb(slot))];
|
||||||
|
let sampler_index = texture.sampler.unwrap_or(default_index);
|
||||||
|
selected_samplers[slot] = samplers
|
||||||
|
.get(sampler_index)
|
||||||
|
.ok_or(MaterialError::InvalidReference)?;
|
||||||
|
}
|
||||||
|
groups.insert(
|
||||||
|
material.key,
|
||||||
|
Self::make_group(
|
||||||
|
device,
|
||||||
|
&self.layout,
|
||||||
|
material,
|
||||||
|
selected_views,
|
||||||
|
selected_samplers,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(PreparedMaterials {
|
||||||
|
groups,
|
||||||
|
textures,
|
||||||
|
views,
|
||||||
|
samplers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn install(&mut self, prepared: PreparedMaterials, asset_epoch: u64) {
|
||||||
|
self.groups = prepared.groups;
|
||||||
|
self.textures = prepared.textures;
|
||||||
|
self.views = prepared.views;
|
||||||
|
self.samplers = prepared.samplers;
|
||||||
|
self.asset_epoch = asset_epoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn group(&self, key: MaterialKey) -> &wgpu::BindGroup {
|
||||||
|
self.groups.get(&key).unwrap_or(&self.fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn schlick(f0: f32, cosine: f32) -> f32 {
|
||||||
|
f0 + (1.0 - f0) * (1.0 - cosine.clamp(0.0, 1.0)).powi(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ggx_d(n_h: f32, alpha: f32) -> f32 {
|
||||||
|
let n_h = n_h.clamp(0.0, 1.0);
|
||||||
|
let alpha2 = alpha * alpha;
|
||||||
|
let n_h2 = n_h * n_h;
|
||||||
|
let q = (1.0 - n_h2) + alpha2 * n_h2;
|
||||||
|
alpha2 / (std::f32::consts::PI * q * q)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn smith_v(n_v: f32, n_l: f32, alpha: f32) -> f32 {
|
||||||
|
let alpha2 = alpha * alpha;
|
||||||
|
let gv = n_l * (n_v * n_v * (1.0 - alpha2) + alpha2).max(0.0).sqrt();
|
||||||
|
let gl = n_v * (n_l * n_l * (1.0 - alpha2) + alpha2).max(0.0).sqrt();
|
||||||
|
0.5 / (gv + gl).max(1e-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pbr_reference_equations_have_known_limits_and_stay_finite() {
|
||||||
|
assert!((schlick(0.04, 1.0) - 0.04).abs() < 1e-6);
|
||||||
|
assert!((schlick(0.04, 0.0) - 1.0).abs() < 1e-6);
|
||||||
|
assert!((ggx_d(0.0, 1.0) - std::f32::consts::FRAC_1_PI).abs() < 1e-6);
|
||||||
|
let expected_quarter = 16.0 / std::f32::consts::PI;
|
||||||
|
for actual in [ggx_d(1.0, 0.25), ggx_d(1.0 + f32::EPSILON, 0.25)] {
|
||||||
|
assert!((actual - expected_quarter).abs() / expected_quarter < 1e-6);
|
||||||
|
}
|
||||||
|
for value in [ggx_d(1.0, 0.045 * 0.045), smith_v(0.0, 0.0, 0.002025)] {
|
||||||
|
assert!(value.is_finite() && value >= 0.0);
|
||||||
|
}
|
||||||
|
let roughness_floor_peak = ggx_d(1.0, 0.045 * 0.045);
|
||||||
|
let expected = 1.0 / (std::f32::consts::PI * 0.045_f32.powi(4));
|
||||||
|
assert!((roughness_floor_peak - expected).abs() / expected < 1e-6);
|
||||||
|
assert!((roughness_floor_peak - 77_624.0).abs() / 77_624.0 < 1e-4);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn material_uniform_is_112_bytes() {
|
||||||
|
assert_eq!(std::mem::size_of::<GpuMaterial>(), 112);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn texcoord_one_disables_slot() {
|
||||||
|
let mut m = Material::default();
|
||||||
|
m.base_color_texture = Some(TextureReference {
|
||||||
|
texture: 0,
|
||||||
|
tex_coord: 1,
|
||||||
|
});
|
||||||
|
assert_eq!(GpuMaterial::from(&m).flags[0] & BASE, 0);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn material_packing_includes_ior_f0_flags_and_uv_sets() {
|
||||||
|
let mut m = Material::default();
|
||||||
|
m.ior = 2.0;
|
||||||
|
m.double_sided = true;
|
||||||
|
m.normal_texture = Some(TextureReference {
|
||||||
|
texture: 4,
|
||||||
|
tex_coord: 0,
|
||||||
|
});
|
||||||
|
let gpu = GpuMaterial::from(&m);
|
||||||
|
assert_eq!(gpu.alpha_optics[2], 2.0);
|
||||||
|
assert!((gpu.alpha_optics[3] - 1.0 / 9.0).abs() < 1e-6);
|
||||||
|
assert_eq!(gpu.flags[0] & NORMAL, NORMAL);
|
||||||
|
assert_eq!(gpu.flags[1], 1);
|
||||||
|
assert_eq!(gpu.uv_sets[2], 0);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn explicit_ior_sentinel_packs_unit_f0() {
|
||||||
|
let mut material = Material::default();
|
||||||
|
material.ior = 0.0;
|
||||||
|
let gpu = GpuMaterial::from(&material);
|
||||||
|
assert_eq!(gpu.alpha_optics[2], 0.0);
|
||||||
|
assert_eq!(gpu.alpha_optics[3], 1.0);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn sampler_translation_is_exact() {
|
||||||
|
let m = SamplerMetadata {
|
||||||
|
index: 0,
|
||||||
|
mag_filter: Some("Nearest".into()),
|
||||||
|
min_filter: Some("LinearMipmapNearest".into()),
|
||||||
|
wrap_s: "ClampToEdge".into(),
|
||||||
|
wrap_t: "MirroredRepeat".into(),
|
||||||
|
};
|
||||||
|
let d = sampler_descriptor(Some(&m));
|
||||||
|
assert_eq!(d.mag_filter, wgpu::FilterMode::Nearest);
|
||||||
|
assert_eq!(d.min_filter, wgpu::FilterMode::Linear);
|
||||||
|
assert_eq!(d.mipmap_filter, wgpu::FilterMode::Nearest);
|
||||||
|
assert_eq!(d.address_mode_u, wgpu::AddressMode::ClampToEdge);
|
||||||
|
assert_eq!(d.address_mode_v, wgpu::AddressMode::MirrorRepeat);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rgb_and_luma_normalize_to_rgba() {
|
||||||
|
let (_, _, rgb) = normalize_rgba(DynamicImage::ImageRgb8(
|
||||||
|
image::RgbImage::from_raw(1, 1, vec![1, 2, 3]).unwrap(),
|
||||||
|
));
|
||||||
|
assert_eq!(rgb, [1, 2, 3, 255]);
|
||||||
|
let (_, _, luma) = normalize_rgba(DynamicImage::ImageLuma8(
|
||||||
|
image::GrayImage::from_raw(1, 1, vec![7]).unwrap(),
|
||||||
|
));
|
||||||
|
assert_eq!(luma, [7, 7, 7, 255]);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn odd_width_layout_is_tight() {
|
||||||
|
let width = 3;
|
||||||
|
assert_eq!(width * 4, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoded_rgba_validation_rejects_dimensions_overflow_and_wrong_length() {
|
||||||
|
assert!(validate_decoded_rgba(0, 1, &[], 4096).is_err());
|
||||||
|
assert!(validate_decoded_rgba(4097, 1, &[], 4096).is_err());
|
||||||
|
assert!(validate_decoded_rgba(u32::MAX, 2, &[], u32::MAX).is_err());
|
||||||
|
assert!(validate_decoded_rgba(2, 2, &[0; 15], 4096).is_err());
|
||||||
|
assert_eq!(validate_decoded_rgba(3, 2, &[0; 24], 4096).unwrap(), 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_color_roles_use_srgb_views() {
|
||||||
|
assert_eq!(
|
||||||
|
(0..5).map(slot_uses_srgb).collect::<Vec<_>>(),
|
||||||
|
[true, false, false, false, true]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1147
-225
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ use wgpu::util::DeviceExt;
|
|||||||
use crate::{
|
use crate::{
|
||||||
camera::Camera,
|
camera::Camera,
|
||||||
render_data::RenderData,
|
render_data::RenderData,
|
||||||
renderer::{self, GpuResources},
|
renderer::{self, PipelineLibrary},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct UniformResource {
|
pub struct UniformResource {
|
||||||
@@ -76,13 +76,14 @@ impl FrameMetadata {
|
|||||||
pub trait Scene: Sized {
|
pub trait Scene: Sized {
|
||||||
fn setup(
|
fn setup(
|
||||||
context: &renderer::RendererContext,
|
context: &renderer::RendererContext,
|
||||||
resources: &mut GpuResources,
|
resources: &mut PipelineLibrary,
|
||||||
data: &mut RenderData,
|
data: &mut RenderData,
|
||||||
) -> Self;
|
) -> Self;
|
||||||
fn bind_groups(&self) -> &[wgpu::BindGroup];
|
fn bind_groups(&self) -> &[wgpu::BindGroup];
|
||||||
fn handle_mouse_click(&mut self, x: f32, y: f32);
|
fn handle_mouse_click(&mut self, x: f32, y: f32);
|
||||||
fn handle_zoom(&mut self, delta_y: f32);
|
fn handle_zoom(&mut self, delta_y: f32);
|
||||||
fn handle_orbit(&mut self, dx: f32, dy: f32);
|
fn handle_orbit(&mut self, dx: f32, dy: f32);
|
||||||
|
fn handle_pan(&mut self, dx: f32, dy: f32, viewport_height: f32);
|
||||||
fn set_camera_depth_range(&mut self, near: f32, far: f32);
|
fn set_camera_depth_range(&mut self, near: f32, far: f32);
|
||||||
fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3);
|
fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3);
|
||||||
fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> {
|
fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> {
|
||||||
@@ -91,6 +92,9 @@ pub trait Scene: Sized {
|
|||||||
fn camera_mut(&mut self) -> Option<&mut Camera> {
|
fn camera_mut(&mut self) -> Option<&mut Camera> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
fn frustum_planes(&mut self) -> Option<Result<[[f32; 4]; 6], crate::camera::FrustumError>> {
|
||||||
|
self.camera_mut().map(|camera| camera.frustum_planes())
|
||||||
|
}
|
||||||
fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> {
|
fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -103,7 +107,7 @@ pub trait Scene: Sized {
|
|||||||
}
|
}
|
||||||
self.write_uniforms(queue);
|
self.write_uniforms(queue);
|
||||||
}
|
}
|
||||||
fn update(&mut self, context: &renderer::RendererContext) {
|
fn update_cpu(&mut self) {
|
||||||
let position = match self.camera_mut() {
|
let position = match self.camera_mut() {
|
||||||
Some(c) => c.position(),
|
Some(c) => c.position(),
|
||||||
None => return,
|
None => return,
|
||||||
@@ -112,7 +116,6 @@ pub trait Scene: Sized {
|
|||||||
f.time = js_sys::Date::now() as f32 * 0.001;
|
f.time = js_sys::Date::now() as f32 * 0.001;
|
||||||
f.set_camera_position(position)
|
f.set_camera_position(position)
|
||||||
}
|
}
|
||||||
self.write_uniforms(&context.queue);
|
|
||||||
}
|
}
|
||||||
fn write_uniforms(&mut self, queue: &wgpu::Queue) {
|
fn write_uniforms(&mut self, queue: &wgpu::Queue) {
|
||||||
let frame = self.frame_metadata_mut().copied();
|
let frame = self.frame_metadata_mut().copied();
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::render_data::{
|
||||||
|
affine_world_aabb, Aabb, GeometryRange, InstanceHandle, MaterialKey, MeshHandle,
|
||||||
|
ModelTransform, NormalMatrix, PipelineKey, RenderData, RenderFlags,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SceneFrameMesh {
|
||||||
|
pub handle: MeshHandle,
|
||||||
|
pub geometry: GeometryRange,
|
||||||
|
pub pipeline: PipelineKey,
|
||||||
|
pub material: MaterialKey,
|
||||||
|
pub flags: RenderFlags,
|
||||||
|
pub aabb: Aabb,
|
||||||
|
pub default_instance: InstanceHandle,
|
||||||
|
pub occurrence_range: std::ops::Range<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SceneFrameOccurrence {
|
||||||
|
pub handle: InstanceHandle,
|
||||||
|
pub mesh: MeshHandle,
|
||||||
|
pub mesh_index: usize,
|
||||||
|
pub model: ModelTransform,
|
||||||
|
pub normal: NormalMatrix,
|
||||||
|
pub flags: RenderFlags,
|
||||||
|
pub is_default: bool,
|
||||||
|
pub world_aabb: Aabb,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SceneFramePlan {
|
||||||
|
pub revision: u64,
|
||||||
|
pub positions: Vec<[f32; 3]>,
|
||||||
|
pub normals: Vec<[f32; 3]>,
|
||||||
|
pub uvs: Vec<[f32; 2]>,
|
||||||
|
pub tangents: Vec<[f32; 4]>,
|
||||||
|
pub indices: Vec<u32>,
|
||||||
|
pub meshes: Vec<SceneFrameMesh>,
|
||||||
|
pub occurrences: Vec<SceneFrameOccurrence>,
|
||||||
|
/// Occurrence indices grouped by mesh without changing global occurrence order.
|
||||||
|
pub mesh_occurrence_indices: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum SceneFrameError {
|
||||||
|
#[error("an occurrence references a missing mesh")]
|
||||||
|
MissingMesh,
|
||||||
|
#[error("world bounds could not be derived")]
|
||||||
|
InvalidWorldBounds,
|
||||||
|
#[error("scene frame size overflow")]
|
||||||
|
SizeOverflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SceneFramePlan {
|
||||||
|
pub fn build(data: &RenderData) -> Result<Self, SceneFrameError> {
|
||||||
|
let streams = data.streams();
|
||||||
|
let mut meshes: Vec<_> = data.meshes().collect();
|
||||||
|
meshes.sort_by_key(|(handle, _)| (handle.slot(), handle.generation()));
|
||||||
|
let mesh_indices: HashMap<_, _> = meshes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(dense, (handle, _))| (*handle, dense))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut source_occurrences: Vec<_> = data.instances().collect();
|
||||||
|
source_occurrences.sort_by_key(|(handle, _)| (handle.slot(), handle.generation()));
|
||||||
|
let mut counts = vec![0usize; meshes.len()];
|
||||||
|
let mut occurrences = Vec::with_capacity(source_occurrences.len());
|
||||||
|
for (handle, occurrence) in source_occurrences {
|
||||||
|
let mesh_index = *mesh_indices
|
||||||
|
.get(&occurrence.mesh)
|
||||||
|
.ok_or(SceneFrameError::MissingMesh)?;
|
||||||
|
counts[mesh_index] = counts[mesh_index]
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(SceneFrameError::SizeOverflow)?;
|
||||||
|
let mesh = meshes[mesh_index].1;
|
||||||
|
occurrences.push(SceneFrameOccurrence {
|
||||||
|
handle,
|
||||||
|
mesh: occurrence.mesh,
|
||||||
|
mesh_index,
|
||||||
|
model: occurrence.model,
|
||||||
|
normal: occurrence.normal,
|
||||||
|
flags: occurrence.flags,
|
||||||
|
is_default: handle == mesh.default_instance,
|
||||||
|
world_aabb: affine_world_aabb(mesh.aabb, occurrence.model)
|
||||||
|
.map_err(|_| SceneFrameError::InvalidWorldBounds)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut offsets = Vec::with_capacity(meshes.len() + 1);
|
||||||
|
offsets.push(0usize);
|
||||||
|
for count in counts {
|
||||||
|
offsets.push(
|
||||||
|
offsets
|
||||||
|
.last()
|
||||||
|
.unwrap()
|
||||||
|
.checked_add(count)
|
||||||
|
.ok_or(SceneFrameError::SizeOverflow)?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut cursors = offsets[..meshes.len()].to_vec();
|
||||||
|
let mut mesh_occurrence_indices = vec![0; occurrences.len()];
|
||||||
|
for (occurrence_index, occurrence) in occurrences.iter().enumerate() {
|
||||||
|
let cursor = &mut cursors[occurrence.mesh_index];
|
||||||
|
mesh_occurrence_indices[*cursor] = occurrence_index;
|
||||||
|
*cursor += 1;
|
||||||
|
}
|
||||||
|
let meshes = meshes
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(dense, (handle, mesh))| SceneFrameMesh {
|
||||||
|
handle,
|
||||||
|
geometry: mesh.geometry,
|
||||||
|
pipeline: mesh.pipeline,
|
||||||
|
material: mesh.material,
|
||||||
|
flags: mesh.flags,
|
||||||
|
aabb: mesh.aabb,
|
||||||
|
default_instance: mesh.default_instance,
|
||||||
|
occurrence_range: offsets[dense]..offsets[dense + 1],
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Self {
|
||||||
|
revision: data.revision(),
|
||||||
|
positions: streams.positions.to_vec(),
|
||||||
|
normals: streams.normals.to_vec(),
|
||||||
|
uvs: streams.uvs.to_vec(),
|
||||||
|
tangents: streams.tangents.to_vec(),
|
||||||
|
indices: data.indices().to_vec(),
|
||||||
|
meshes,
|
||||||
|
occurrences,
|
||||||
|
mesh_occurrence_indices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SceneFrameCache {
|
||||||
|
plan: Option<Box<SceneFramePlan>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SceneFrameCache {
|
||||||
|
pub fn get_or_build(&mut self, data: &RenderData) -> Result<&SceneFramePlan, SceneFrameError> {
|
||||||
|
if self
|
||||||
|
.plan
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|plan| plan.revision != data.revision())
|
||||||
|
{
|
||||||
|
let replacement = Box::new(SceneFramePlan::build(data)?);
|
||||||
|
self.plan = Some(replacement);
|
||||||
|
}
|
||||||
|
Ok(self.plan.as_deref().unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::render_data::{MeshCreateInfo, RenderDataConfig, IDENTITY_MODEL_TRANSFORM};
|
||||||
|
|
||||||
|
fn mesh(data: &mut RenderData, visible: bool) -> crate::render_data::CreatedMesh {
|
||||||
|
data.create_mesh(MeshCreateInfo {
|
||||||
|
positions: &[[0., 0., 0.], [2., 0., 0.], [0., 2., 0.]],
|
||||||
|
normals: &[[0., 0., 1.]; 3],
|
||||||
|
tangents: &[[1., 0., 0., 1.]; 3],
|
||||||
|
uvs: &[[0., 0.]; 3],
|
||||||
|
indices: &[0, 1, 2],
|
||||||
|
pipeline: PipelineKey::new(0),
|
||||||
|
material: crate::render_data::MaterialKey::DEFAULT,
|
||||||
|
flags: if visible {
|
||||||
|
RenderFlags::VISIBLE
|
||||||
|
} else {
|
||||||
|
RenderFlags::NONE
|
||||||
|
},
|
||||||
|
default_instance_flags: RenderFlags::VISIBLE,
|
||||||
|
default_transform: IDENTITY_MODEL_TRANSFORM,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_reuses_pointer_and_rebuilds_on_revision() {
|
||||||
|
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
|
||||||
|
let mut cache = SceneFrameCache::default();
|
||||||
|
let first = cache.get_or_build(&data).unwrap() as *const _;
|
||||||
|
assert_eq!(first, cache.get_or_build(&data).unwrap() as *const _);
|
||||||
|
let created = mesh(&mut data, true);
|
||||||
|
let second = cache.get_or_build(&data).unwrap() as *const _;
|
||||||
|
assert_ne!(first, second);
|
||||||
|
data.set_mesh_flags(created.mesh, RenderFlags::NONE)
|
||||||
|
.unwrap();
|
||||||
|
let third = cache.get_or_build(&data).unwrap() as *const _;
|
||||||
|
assert_ne!(second, third);
|
||||||
|
let mut moved = IDENTITY_MODEL_TRANSFORM;
|
||||||
|
moved[3][1] = 9.0;
|
||||||
|
data.set_instance_transform(created.default_instance, moved)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(third, cache.get_or_build(&data).unwrap() as *const _);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retains_hidden_entries_builds_adjacency_and_world_bounds() {
|
||||||
|
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
|
||||||
|
let hidden = mesh(&mut data, false);
|
||||||
|
let shown = mesh(&mut data, true);
|
||||||
|
let mut translated = IDENTITY_MODEL_TRANSFORM;
|
||||||
|
translated[0][0] = 2.;
|
||||||
|
translated[1][1] = 3.;
|
||||||
|
translated[2][2] = 4.;
|
||||||
|
translated[3][0] = 5.;
|
||||||
|
translated[3][1] = -2.;
|
||||||
|
let extra = data
|
||||||
|
.create_instance(hidden.mesh, translated, RenderFlags::NONE)
|
||||||
|
.unwrap();
|
||||||
|
let plan = SceneFramePlan::build(&data).unwrap();
|
||||||
|
assert_eq!((plan.meshes.len(), plan.occurrences.len()), (2, 3));
|
||||||
|
assert!(plan
|
||||||
|
.occurrences
|
||||||
|
.iter()
|
||||||
|
.any(|o| o.handle == hidden.default_instance && o.is_default));
|
||||||
|
let occurrence = plan.occurrences.iter().find(|o| o.handle == extra).unwrap();
|
||||||
|
assert_eq!(occurrence.world_aabb.min, [5., -2., 0.]);
|
||||||
|
assert_eq!(occurrence.world_aabb.max, [9., 4., 0.]);
|
||||||
|
for (mesh_index, mesh) in plan.meshes.iter().enumerate() {
|
||||||
|
assert!(plan.mesh_occurrence_indices[mesh.occurrence_range.clone()]
|
||||||
|
.iter()
|
||||||
|
.all(|&i| plan.occurrences[i].mesh_index == mesh_index));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
shown.default_instance.slot(),
|
||||||
|
plan.occurrences
|
||||||
|
.iter()
|
||||||
|
.find(|o| o.mesh == shown.mesh)
|
||||||
|
.unwrap()
|
||||||
|
.handle
|
||||||
|
.slot()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slot_reuse_preserves_dense_order_adjacency_and_ownership() {
|
||||||
|
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
|
||||||
|
let a = mesh(&mut data, true);
|
||||||
|
let doomed = mesh(&mut data, true);
|
||||||
|
let c = mesh(&mut data, true);
|
||||||
|
let a_extra = data
|
||||||
|
.create_instance(a.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE)
|
||||||
|
.unwrap();
|
||||||
|
let doomed_extra = data
|
||||||
|
.create_instance(doomed.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE)
|
||||||
|
.unwrap();
|
||||||
|
let c_extra = data
|
||||||
|
.create_instance(c.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE)
|
||||||
|
.unwrap();
|
||||||
|
data.destroy_instance(a_extra).unwrap();
|
||||||
|
data.destroy_mesh(doomed.mesh).unwrap();
|
||||||
|
let replacement = mesh(&mut data, true);
|
||||||
|
let replacement_extra = data
|
||||||
|
.create_instance(
|
||||||
|
replacement.mesh,
|
||||||
|
IDENTITY_MODEL_TRANSFORM,
|
||||||
|
RenderFlags::VISIBLE,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(replacement.mesh.slot(), doomed.mesh.slot());
|
||||||
|
assert!(replacement.mesh.generation() > doomed.mesh.generation());
|
||||||
|
let destroyed = [a_extra, doomed.default_instance, doomed_extra];
|
||||||
|
for reused in [replacement.default_instance, replacement_extra] {
|
||||||
|
let old = destroyed
|
||||||
|
.iter()
|
||||||
|
.find(|old| old.slot() == reused.slot())
|
||||||
|
.expect("replacement must reuse an interior instance slot");
|
||||||
|
assert!(reused.generation() > old.generation());
|
||||||
|
}
|
||||||
|
assert!(data.instance(doomed_extra).is_none());
|
||||||
|
|
||||||
|
let plan = SceneFramePlan::build(&data).unwrap();
|
||||||
|
assert!(plan
|
||||||
|
.meshes
|
||||||
|
.windows(2)
|
||||||
|
.all(|w| (w[0].handle.slot(), w[0].handle.generation())
|
||||||
|
< (w[1].handle.slot(), w[1].handle.generation())));
|
||||||
|
assert!(plan.occurrences.windows(2).all(|w| (
|
||||||
|
w[0].handle.slot(),
|
||||||
|
w[0].handle.generation()
|
||||||
|
) < (
|
||||||
|
w[1].handle.slot(),
|
||||||
|
w[1].handle.generation()
|
||||||
|
)));
|
||||||
|
let mut seen = vec![0; plan.occurrences.len()];
|
||||||
|
for (mesh_index, mesh) in plan.meshes.iter().enumerate() {
|
||||||
|
let adjacency = &plan.mesh_occurrence_indices[mesh.occurrence_range.clone()];
|
||||||
|
assert!(adjacency.windows(2).all(|w| {
|
||||||
|
let a = plan.occurrences[w[0]].handle;
|
||||||
|
let b = plan.occurrences[w[1]].handle;
|
||||||
|
(a.slot(), a.generation()) < (b.slot(), b.generation())
|
||||||
|
}));
|
||||||
|
for &index in adjacency {
|
||||||
|
seen[index] += 1;
|
||||||
|
let occurrence = &plan.occurrences[index];
|
||||||
|
assert_eq!(occurrence.mesh, mesh.handle);
|
||||||
|
assert_eq!(occurrence.mesh_index, mesh_index);
|
||||||
|
assert_eq!(
|
||||||
|
occurrence.is_default,
|
||||||
|
occurrence.handle == mesh.default_instance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
adjacency
|
||||||
|
.iter()
|
||||||
|
.filter(|&&i| plan.occurrences[i].is_default)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(seen.into_iter().all(|count| count == 1));
|
||||||
|
assert!(plan.occurrences.iter().any(|o| o.handle == c_extra));
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
-20
@@ -1,7 +1,7 @@
|
|||||||
//! Triple-buffered, immutable packed scene snapshot shared with JavaScript.
|
//! Triple-buffered, immutable packed scene snapshot shared with JavaScript.
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
use crate::render_data::{affine_world_aabb, RenderData, RenderFlags};
|
use crate::{render_data::RenderFlags, renderer::scene_frame::SceneFramePlan};
|
||||||
|
|
||||||
pub const MAGIC: u32 = u32::from_le_bytes(*b"YSNP");
|
pub const MAGIC: u32 = u32::from_le_bytes(*b"YSNP");
|
||||||
pub const BLOB_MAGIC: u32 = u32::from_le_bytes(*b"RDS1");
|
pub const BLOB_MAGIC: u32 = u32::from_le_bytes(*b"RDS1");
|
||||||
@@ -94,11 +94,11 @@ impl SharedSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Packs and publishes if `data` changed. Returns the newly published data epoch.
|
/// Packs and publishes if `data` changed. Returns the newly published data epoch.
|
||||||
pub fn publish(&mut self, data: &RenderData) -> Result<Option<u32>, u32> {
|
pub fn publish(&mut self, data: &SceneFramePlan) -> Result<Option<u32>, u32> {
|
||||||
if self.control.header[6].load(Ordering::Acquire) == FAILED {
|
if self.control.header[6].load(Ordering::Acquire) == FAILED {
|
||||||
return Err(self.control.header[14].load(Ordering::Relaxed));
|
return Err(self.control.header[14].load(Ordering::Relaxed));
|
||||||
}
|
}
|
||||||
if self.last_revision == Some(data.revision()) {
|
if self.last_revision == Some(data.revision) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let slot = match self.claim_slot() {
|
let slot = match self.claim_slot() {
|
||||||
@@ -116,7 +116,7 @@ impl SharedSnapshot {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn publish_claimed(&mut self, slot: usize, data: &RenderData) -> Result<Option<u32>, u32> {
|
fn publish_claimed(&mut self, slot: usize, data: &SceneFramePlan) -> Result<Option<u32>, u32> {
|
||||||
let epoch = self.next_epoch;
|
let epoch = self.next_epoch;
|
||||||
let next_epoch = epoch.checked_add(1).ok_or(ERROR_OVERFLOW)?;
|
let next_epoch = epoch.checked_add(1).ok_or(ERROR_OVERFLOW)?;
|
||||||
let bytes = pack(data, epoch)?;
|
let bytes = pack(data, epoch)?;
|
||||||
@@ -136,7 +136,7 @@ impl SharedSnapshot {
|
|||||||
let ptr = self.blocks[slot].as_ptr() as usize;
|
let ptr = self.blocks[slot].as_ptr() as usize;
|
||||||
let ptr32 = u32::try_from(ptr).map_err(|_| ERROR_OVERFLOW)?;
|
let ptr32 = u32::try_from(ptr).map_err(|_| ERROR_OVERFLOW)?;
|
||||||
let length = u32::try_from(bytes.len()).map_err(|_| ERROR_OVERFLOW)?;
|
let length = u32::try_from(bytes.len()).map_err(|_| ERROR_OVERFLOW)?;
|
||||||
let revision = data.revision();
|
let revision = data.revision;
|
||||||
let d = &self.control.slots[slot].0;
|
let d = &self.control.slots[slot].0;
|
||||||
let values = [
|
let values = [
|
||||||
epoch,
|
epoch,
|
||||||
@@ -145,8 +145,8 @@ impl SharedSnapshot {
|
|||||||
length,
|
length,
|
||||||
revision as u32,
|
revision as u32,
|
||||||
(revision >> 32) as u32,
|
(revision >> 32) as u32,
|
||||||
data.mesh_count(),
|
data.meshes.len() as u32,
|
||||||
data.instance_count(),
|
data.occurrences.len() as u32,
|
||||||
SCHEMA,
|
SCHEMA,
|
||||||
SNAPSHOT_HEADER_BYTES as u32,
|
SNAPSHOT_HEADER_BYTES as u32,
|
||||||
0,
|
0,
|
||||||
@@ -244,9 +244,9 @@ fn wasm_pages(minimum_end: usize) -> Result<u32, u32> {
|
|||||||
.map_err(|_| ERROR_OVERFLOW)
|
.map_err(|_| ERROR_OVERFLOW)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
|
fn pack(data: &SceneFramePlan, epoch: u32) -> Result<Vec<u8>, u32> {
|
||||||
let meshes: Vec<_> = data.meshes().collect();
|
let meshes = &data.meshes;
|
||||||
let instances: Vec<_> = data.instances().collect();
|
let instances = &data.occurrences;
|
||||||
let strides = [4usize, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4];
|
let strides = [4usize, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4];
|
||||||
let components = [1u32, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1];
|
let components = [1u32, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1];
|
||||||
let scalar = [1u32, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1];
|
let scalar = [1u32, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1];
|
||||||
@@ -268,7 +268,7 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
|
|||||||
let put32 = |out: &mut [u8], at: usize, value: u32| {
|
let put32 = |out: &mut [u8], at: usize, value: u32| {
|
||||||
out[at..at + 4].copy_from_slice(&value.to_le_bytes())
|
out[at..at + 4].copy_from_slice(&value.to_le_bytes())
|
||||||
};
|
};
|
||||||
let revision = data.revision();
|
let revision = data.revision;
|
||||||
for (i, value) in [
|
for (i, value) in [
|
||||||
BLOB_MAGIC,
|
BLOB_MAGIC,
|
||||||
SCHEMA,
|
SCHEMA,
|
||||||
@@ -310,8 +310,12 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
|
|||||||
put32(&mut out, at + j * 4, value);
|
put32(&mut out, at + j * 4, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (dense, (handle, mesh)) in meshes.iter().enumerate() {
|
for (dense, mesh) in meshes.iter().enumerate() {
|
||||||
for (i, value) in [handle.slot(), handle.generation(), mesh.flags.bits()]
|
for (i, value) in [
|
||||||
|
mesh.handle.slot(),
|
||||||
|
mesh.handle.generation(),
|
||||||
|
mesh.flags.bits(),
|
||||||
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
{
|
{
|
||||||
@@ -330,12 +334,14 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (dense, (handle, instance)) in instances.iter().enumerate() {
|
for (dense, instance) in instances.iter().enumerate() {
|
||||||
let mesh = data.mesh(instance.mesh).ok_or(ERROR_INVARIANT)?;
|
let mesh = data
|
||||||
let world = affine_world_aabb(mesh.aabb, instance.model).map_err(|_| ERROR_INVARIANT)?;
|
.meshes
|
||||||
|
.get(instance.mesh_index)
|
||||||
|
.ok_or(ERROR_INVARIANT)?;
|
||||||
for (i, value) in [
|
for (i, value) in [
|
||||||
handle.slot(),
|
instance.handle.slot(),
|
||||||
handle.generation(),
|
instance.handle.generation(),
|
||||||
instance.mesh.slot(),
|
instance.mesh.slot(),
|
||||||
instance.mesh.generation(),
|
instance.mesh.generation(),
|
||||||
instance.flags.bits(),
|
instance.flags.bits(),
|
||||||
@@ -356,12 +362,12 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
|
|||||||
put32(
|
put32(
|
||||||
&mut out,
|
&mut out,
|
||||||
offsets[11] + dense * 12 + i * 4,
|
offsets[11] + dense * 12 + i * 4,
|
||||||
world.min[i].to_bits(),
|
instance.world_aabb.min[i].to_bits(),
|
||||||
);
|
);
|
||||||
put32(
|
put32(
|
||||||
&mut out,
|
&mut out,
|
||||||
offsets[12] + dense * 12 + i * 4,
|
offsets[12] + dense * 12 + i * 4,
|
||||||
world.max[i].to_bits(),
|
instance.world_aabb.max[i].to_bits(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
put32(
|
put32(
|
||||||
@@ -387,6 +393,9 @@ const _: [(); 256] = [(); std::mem::size_of::<SnapshotControl>()];
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::render_data::{
|
||||||
|
MeshCreateInfo, PipelineKey, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exact_control_layout_and_initial_values() {
|
fn exact_control_layout_and_initial_values() {
|
||||||
@@ -417,4 +426,166 @@ mod tests {
|
|||||||
WRITING
|
WRITING
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn producer_blob_abi_matches_schema_exactly() {
|
||||||
|
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
|
||||||
|
let create = |data: &mut RenderData, flags, x: f32| {
|
||||||
|
data.create_mesh(MeshCreateInfo {
|
||||||
|
positions: &[[x, 0., 0.], [x + 2., 0., 0.], [x, 3., 0.]],
|
||||||
|
normals: &[[0., 0., 1.]; 3],
|
||||||
|
tangents: &[[1., 0., 0., 1.]; 3],
|
||||||
|
uvs: &[[0., 0.]; 3],
|
||||||
|
indices: &[0, 1, 2],
|
||||||
|
pipeline: PipelineKey::new(7),
|
||||||
|
material: crate::render_data::MaterialKey::DEFAULT,
|
||||||
|
flags,
|
||||||
|
default_instance_flags: RenderFlags::VISIBLE,
|
||||||
|
default_transform: IDENTITY_MODEL_TRANSFORM,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let visible = create(&mut data, RenderFlags::VISIBLE, -1.0);
|
||||||
|
let hidden = create(&mut data, RenderFlags::NONE, 10.0);
|
||||||
|
let mut model = IDENTITY_MODEL_TRANSFORM;
|
||||||
|
model[0][0] = 2.0;
|
||||||
|
model[1][1] = 0.5;
|
||||||
|
model[3][0] = 4.0;
|
||||||
|
model[3][1] = -2.0;
|
||||||
|
let extra = data
|
||||||
|
.create_instance(hidden.mesh, model, RenderFlags::NONE)
|
||||||
|
.unwrap();
|
||||||
|
let plan = SceneFramePlan::build(&data).unwrap();
|
||||||
|
let epoch = 0x1234_5678;
|
||||||
|
let blob = pack(&plan, epoch).unwrap();
|
||||||
|
let word = |at: usize| u32::from_le_bytes(blob[at..at + 4].try_into().unwrap());
|
||||||
|
|
||||||
|
assert_eq!(word(0), BLOB_MAGIC);
|
||||||
|
assert_eq!(word(4), SCHEMA);
|
||||||
|
assert_eq!(word(8), SNAPSHOT_HEADER_BYTES as u32);
|
||||||
|
assert_eq!(word(12), blob.len() as u32);
|
||||||
|
assert_eq!(word(16), epoch);
|
||||||
|
assert_eq!(word(20), plan.revision as u32);
|
||||||
|
assert_eq!(word(24), (plan.revision >> 32) as u32);
|
||||||
|
assert_eq!(word(28), STREAMS as u32);
|
||||||
|
assert_eq!(word(32), SNAPSHOT_HEADER_BYTES as u32);
|
||||||
|
assert_eq!(word(36), DESCRIPTOR_BYTES as u32);
|
||||||
|
assert_eq!(word(40), 2);
|
||||||
|
assert_eq!(word(44), 3);
|
||||||
|
assert_eq!(word(48), 0x0102_0304);
|
||||||
|
assert_eq!(word(52), SCHEMA_FLAGS);
|
||||||
|
|
||||||
|
let strides = [4, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4];
|
||||||
|
let scalars = [1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1];
|
||||||
|
let components = [1, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1];
|
||||||
|
let counts = [2usize; 5]
|
||||||
|
.into_iter()
|
||||||
|
.chain([3usize; 9])
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut offsets = Vec::new();
|
||||||
|
let mut cursor = DATA_OFFSET;
|
||||||
|
for i in 0..STREAMS {
|
||||||
|
let at = SNAPSHOT_HEADER_BYTES + i * DESCRIPTOR_BYTES;
|
||||||
|
offsets.push(cursor);
|
||||||
|
assert_eq!(
|
||||||
|
[
|
||||||
|
word(at),
|
||||||
|
word(at + 4),
|
||||||
|
word(at + 8),
|
||||||
|
word(at + 12),
|
||||||
|
word(at + 16),
|
||||||
|
word(at + 20),
|
||||||
|
word(at + 24),
|
||||||
|
word(at + 28)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
i as u32 + 1,
|
||||||
|
scalars[i],
|
||||||
|
cursor as u32,
|
||||||
|
counts[i] as u32,
|
||||||
|
components[i],
|
||||||
|
strides[i] as u32,
|
||||||
|
4,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(cursor % 16, 0);
|
||||||
|
cursor = (cursor + strides[i] as usize * counts[i] + 15) & !15;
|
||||||
|
}
|
||||||
|
assert_eq!(cursor, blob.len());
|
||||||
|
|
||||||
|
for (dense, mesh) in plan.meshes.iter().enumerate() {
|
||||||
|
assert_eq!(word(offsets[0] + dense * 4), mesh.handle.slot());
|
||||||
|
assert_eq!(word(offsets[1] + dense * 4), mesh.handle.generation());
|
||||||
|
assert_eq!(word(offsets[2] + dense * 4), mesh.flags.bits());
|
||||||
|
for axis in 0..3 {
|
||||||
|
assert_eq!(
|
||||||
|
word(offsets[3] + dense * 12 + axis * 4),
|
||||||
|
mesh.aabb.min[axis].to_bits()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
word(offsets[4] + dense * 12 + axis * 4),
|
||||||
|
mesh.aabb.max[axis].to_bits()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (dense, occurrence) in plan.occurrences.iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
[
|
||||||
|
word(offsets[5] + dense * 4),
|
||||||
|
word(offsets[6] + dense * 4),
|
||||||
|
word(offsets[7] + dense * 4),
|
||||||
|
word(offsets[8] + dense * 4),
|
||||||
|
word(offsets[9] + dense * 4)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
occurrence.handle.slot(),
|
||||||
|
occurrence.handle.generation(),
|
||||||
|
occurrence.mesh.slot(),
|
||||||
|
occurrence.mesh.generation(),
|
||||||
|
occurrence.flags.bits()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
for i in 0..16 {
|
||||||
|
assert_eq!(
|
||||||
|
word(offsets[10] + dense * 64 + i * 4),
|
||||||
|
occurrence.model[i / 4][i % 4].to_bits()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for axis in 0..3 {
|
||||||
|
assert_eq!(
|
||||||
|
word(offsets[11] + dense * 12 + axis * 4),
|
||||||
|
occurrence.world_aabb.min[axis].to_bits()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
word(offsets[12] + dense * 12 + axis * 4),
|
||||||
|
occurrence.world_aabb.max[axis].to_bits()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mesh_visible = plan.meshes[occurrence.mesh_index]
|
||||||
|
.flags
|
||||||
|
.contains(RenderFlags::VISIBLE);
|
||||||
|
assert_eq!(
|
||||||
|
word(offsets[13] + dense * 4),
|
||||||
|
(mesh_visible && occurrence.flags.contains(RenderFlags::VISIBLE)) as u32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(plan
|
||||||
|
.meshes
|
||||||
|
.windows(2)
|
||||||
|
.all(|w| w[0].handle.slot() < w[1].handle.slot()));
|
||||||
|
assert!(plan
|
||||||
|
.occurrences
|
||||||
|
.windows(2)
|
||||||
|
.all(|w| w[0].handle.slot() < w[1].handle.slot()));
|
||||||
|
assert_eq!(
|
||||||
|
plan.occurrences
|
||||||
|
.iter()
|
||||||
|
.find(|o| o.handle == extra)
|
||||||
|
.unwrap()
|
||||||
|
.model,
|
||||||
|
model
|
||||||
|
);
|
||||||
|
assert!(plan.occurrences.iter().any(|o| o.mesh == visible.mesh));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-2
@@ -48,12 +48,47 @@ export function createUvSphereGeometry(segments=24,rings=12){
|
|||||||
const positions=[],normals=[],texcoords=[],indices=[];for(let y=0;y<=rings;y++){const v=y/rings,phi=v*Math.PI;for(let x=0;x<=segments;x++){const u=x/segments,theta=u*Math.PI*2,nx=Math.sin(phi)*Math.cos(theta),ny=Math.cos(phi),nz=Math.sin(phi)*Math.sin(theta);positions.push(nx,ny,nz);normals.push(nx,ny,nz);texcoords.push(u,v);}}
|
const positions=[],normals=[],texcoords=[],indices=[];for(let y=0;y<=rings;y++){const v=y/rings,phi=v*Math.PI;for(let x=0;x<=segments;x++){const u=x/segments,theta=u*Math.PI*2,nx=Math.sin(phi)*Math.cos(theta),ny=Math.cos(phi),nz=Math.sin(phi)*Math.sin(theta);positions.push(nx,ny,nz);normals.push(nx,ny,nz);texcoords.push(u,v);}}
|
||||||
for(let y=0;y<rings;y++)for(let x=0;x<segments;x++){const a=y*(segments+1)+x,b=a+segments+1;indices.push(a,a+1,b,a+1,b+1,b);}return {positions,normals,texcoords,indices};
|
for(let y=0;y<rings;y++)for(let x=0;x<segments;x++){const a=y*(segments+1)+x,b=a+segments+1;indices.push(a,a+1,b,a+1,b+1,b);}return {positions,normals,texcoords,indices};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const galleryPngBase64=Object.freeze({
|
||||||
|
base:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAIUlEQVR42mP4ryH3X+NOgIZGwP//DP/vyGn8BwI5Obn/AKsPDa3HqsdFAAAAAElFTkSuQmCC",
|
||||||
|
mr:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAJUlEQVR42gEaAOX/AP8gAP//YED//7Sg/wD/8P///0Dc///cIP/dYBIQ76JUtAAAAABJRU5ErkJggg==",
|
||||||
|
normal:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAH0lEQVR42mNoaPj//0TDu/8WQMzQcOLd/wYLIAYKAgDieBGxoS0BjwAAAABJRU5ErkJggg==",
|
||||||
|
});
|
||||||
|
const decodeBase64=value=>{const binary=atob(value),bytes=new Uint8Array(binary.length);for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);return bytes;};
|
||||||
|
|
||||||
|
/** Build the deterministic Phase 6 PBR shader validation gallery. */
|
||||||
|
export function createMaterialGalleryGlb(){
|
||||||
|
// A modest shared sphere keeps the embedded GLB compact while making roughness
|
||||||
|
// and normal-map responses much easier to compare than the former cubes.
|
||||||
|
const geometry=createUvSphereGeometry(16,8);
|
||||||
|
const streams=[new Float32Array(geometry.positions),new Float32Array(geometry.normals),new Float32Array(geometry.texcoords),new Uint32Array(geometry.indices)];
|
||||||
|
const images=Object.values(galleryPngBase64).map(decodeBase64),chunks=[],bufferViews=[];let byteLength=0;
|
||||||
|
for(const stream of [...streams,...images]){byteLength=align4(byteLength);const bytes=stream instanceof Uint8Array?stream:new Uint8Array(stream.buffer);chunks.push({offset:byteLength,bytes});bufferViews.push({buffer:0,byteOffset:byteLength,byteLength:bytes.length});byteLength+=bytes.length;}
|
||||||
|
byteLength=align4(byteLength);const bounds=finiteMinMax(streams[0],3),vertexCount=geometry.positions.length/3;
|
||||||
|
const materials=[
|
||||||
|
...[0.08,0.3,0.6,1].map(roughnessFactor=>({name:`Dielectric roughness ${roughnessFactor}`,pbrMetallicRoughness:{baseColorFactor:[0.72,0.18,0.08,1],metallicFactor:0,roughnessFactor}})),
|
||||||
|
...[0.08,0.3,0.6,1].map(roughnessFactor=>({name:`Metal roughness ${roughnessFactor}`,pbrMetallicRoughness:{baseColorFactor:[0.72,0.76,0.82,1],metallicFactor:1,roughnessFactor}})),
|
||||||
|
...[1,1.5,2].map(ior=>({name:`Dielectric IOR ${ior}`,pbrMetallicRoughness:{baseColorFactor:[0.12,0.48,0.82,1],metallicFactor:0,roughnessFactor:0.18},extensions:{KHR_materials_ior:{ior}}})),
|
||||||
|
{name:"Odd-width OpenGL normal map",pbrMetallicRoughness:{baseColorFactor:[0.7,0.7,0.7,1],metallicFactor:0,roughnessFactor:0.4},normalTexture:{index:2,scale:1}},
|
||||||
|
{name:"Odd-width AO",pbrMetallicRoughness:{baseColorFactor:[0.8,0.55,0.12,1],metallicFactor:0,roughnessFactor:0.65},occlusionTexture:{index:1,strength:1}},
|
||||||
|
{name:"Odd-width emissive",pbrMetallicRoughness:{baseColorFactor:[0.03,0.03,0.03,1],metallicFactor:0,roughnessFactor:0.8},emissiveFactor:[1,0.3,0.05],emissiveTexture:{index:0}},
|
||||||
|
{name:"Odd-width alpha MASK",pbrMetallicRoughness:{baseColorFactor:[1,1,1,1],baseColorTexture:{index:0},metallicFactor:0,roughnessFactor:0.55},alphaMode:"MASK",alphaCutoff:0.5,doubleSided:true},
|
||||||
|
{name:"Reflected non-uniform double-sided",pbrMetallicRoughness:{baseColorFactor:[0.25,0.85,0.38,1],metallicFactor:0.15,roughnessFactor:0.45},doubleSided:true},
|
||||||
|
];
|
||||||
|
const nodes=materials.map((material,index)=>({name:material.name,mesh:index,translation:[(index%4-1.5)*2.5,(1.5-Math.floor(index/4))*2.5,0],...(index===15?{scale:[-1.25,0.7,1.1]}:{})}));
|
||||||
|
const meshes=materials.map((material,index)=>({name:material.name,primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3,material:index}]}));
|
||||||
|
const json={asset:{version:"2.0",generator:"yawn-phase6-pbr-gallery"},extensionsUsed:["KHR_materials_ior"],scene:0,scenes:[{name:"Phase 6 deterministic PBR gallery",nodes:nodes.map((_,i)=>i)}],nodes,meshes,materials,
|
||||||
|
samplers:[{magFilter:9728,minFilter:9728,wrapS:10497,wrapT:10497}],images:images.map((_,i)=>({name:["Odd-width sRGB base color and emissive","Odd-width linear MR and AO","Odd-width OpenGL normal map"][i],bufferView:i+4,mimeType:"image/png"})),textures:images.map((_,i)=>({sampler:0,source:i})),
|
||||||
|
buffers:[{byteLength}],bufferViews,accessors:[{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},{bufferView:3,componentType:5125,count:geometry.indices.length,type:"SCALAR"}]};
|
||||||
|
let jsonBytes=encoder.encode(JSON.stringify(json));const jsonLength=align4(jsonBytes.length),total=12+8+jsonLength+8+byteLength,out=new ArrayBuffer(total),view=new DataView(out),bytes=new Uint8Array(out);
|
||||||
|
view.setUint32(0,0x46546c67,true);view.setUint32(4,2,true);view.setUint32(8,total,true);view.setUint32(12,jsonLength,true);view.setUint32(16,JSON_CHUNK,true);bytes.fill(0x20,20,20+jsonLength);bytes.set(jsonBytes,20);const binHeader=20+jsonLength;view.setUint32(binHeader,byteLength,true);view.setUint32(binHeader+4,BIN_CHUNK,true);for(const chunk of chunks)bytes.set(chunk.bytes,binHeader+8+chunk.offset);return out;
|
||||||
|
}
|
||||||
export function isGitLfsPointer(bytes){const text=new TextDecoder().decode(new Uint8Array(bytes,0,Math.min(bytes.byteLength,256)));return text.startsWith("version https://git-lfs.github.com/spec/v1\n");}
|
export function isGitLfsPointer(bytes){const text=new TextDecoder().decode(new Uint8Array(bytes,0,Math.min(bytes.byteLength,256)));return text.startsWith("version https://git-lfs.github.com/spec/v1\n");}
|
||||||
export class LoadoutError extends Error{constructor(code,message){super(message);this.name="LoadoutError";this.code=code;}}
|
export class LoadoutError extends Error{constructor(code,message){super(message);this.name="LoadoutError";this.code=code;}}
|
||||||
export const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},manor:{label:"The Manor"},sponza:{label:"Sponza"}});
|
export const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},materials:{label:"Phase 6 deterministic PBR gallery"},manor:{label:"The Manor"},sponza:{label:"Sponza"}});
|
||||||
const assetUrls=Object.freeze({manor:new URL("./themanor.glb",import.meta.url),sponza:new URL("./sponza.glb",import.meta.url)});
|
const assetUrls=Object.freeze({manor:new URL("./themanor.glb",import.meta.url),sponza:new URL("./sponza.glb",import.meta.url)});
|
||||||
export async function loadDemoLoadout(id,{signal,fetchImpl=fetch}={}){
|
export async function loadDemoLoadout(id,{signal,fetchImpl=fetch}={}){
|
||||||
if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry());
|
if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry());if(id==="materials")return createMaterialGalleryGlb();
|
||||||
const url=assetUrls[id];if(!url)throw new LoadoutError("LOADOUT_UNKNOWN",`Unknown loadout: ${id}`);
|
const url=assetUrls[id];if(!url)throw new LoadoutError("LOADOUT_UNKNOWN",`Unknown loadout: ${id}`);
|
||||||
let response;try{response=await fetchImpl(url,{signal});}catch(error){if(error?.name==="AbortError")throw error;throw new LoadoutError("LOADOUT_FETCH_FAILED",`Could not fetch ${id}: ${error?.message||"network error"}`);}
|
let response;try{response=await fetchImpl(url,{signal});}catch(error){if(error?.name==="AbortError")throw error;throw new LoadoutError("LOADOUT_FETCH_FAILED",`Could not fetch ${id}: ${error?.message||"network error"}`);}
|
||||||
if(!response.ok)throw new LoadoutError("LOADOUT_HTTP",`Could not fetch ${id}: HTTP ${response.status}`);const buffer=await response.arrayBuffer();if(isGitLfsPointer(buffer))throw new LoadoutError("LOADOUT_LFS_POINTER",`${id} is a Git LFS pointer; hydrate repository assets first`);return buffer;
|
if(!response.ok)throw new LoadoutError("LOADOUT_HTTP",`Could not fetch ${id}: HTTP ${response.status}`);const buffer=await response.arrayBuffer();if(isGitLfsPointer(buffer))throw new LoadoutError("LOADOUT_LFS_POINTER",`${id} is a Git LFS pointer; hydrate repository assets first`);return buffer;
|
||||||
|
|||||||
+198
-3
@@ -1,5 +1,200 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Yawn Render Graph Demo</title>
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Yawn Render Graph Demo</title>
|
||||||
<style>
|
<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>
|
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>
|
||||||
|
|||||||
+328
-36
@@ -6,55 +6,347 @@ import { AuthoringController } from "./render-graph/authoring-controller.js";
|
|||||||
import { createRenderGraphEditor } from "./render-graph/fxnode-editor.js";
|
import { createRenderGraphEditor } from "./render-graph/fxnode-editor.js";
|
||||||
import { renderGraphPresets } from "./render-graph/presets.js";
|
import { renderGraphPresets } from "./render-graph/presets.js";
|
||||||
|
|
||||||
let renderer,editor,controller,assetAbort,busy=false,cleaned=false;
|
let renderer,
|
||||||
let unsubscribeController=()=>{},unsubscribeSnapshots=()=>{};
|
editor,
|
||||||
|
controller,
|
||||||
|
assetAbort,
|
||||||
|
busy = false,
|
||||||
|
cleaned = false;
|
||||||
|
let unsubscribeController = () => {},
|
||||||
|
unsubscribeSnapshots = () => {};
|
||||||
const listeners = [];
|
const listeners = [];
|
||||||
const on=(target,type,fn)=>{target.addEventListener(type,fn);listeners.push(()=>target.removeEventListener(type,fn));};
|
const on = (target, type, fn) => {
|
||||||
const status=message=>{const node=document.querySelector("#demo-status");if(node)node.textContent=message;};
|
target.addEventListener(type, fn);
|
||||||
const sameId=(a,b)=>Array.isArray(a)&&Array.isArray(b)&&a[0]===b[0]&&a[1]===b[1];
|
listeners.push(() => target.removeEventListener(type, fn));
|
||||||
const state={loadout:"cubes",graph:"authored",compiled:{},telemetry:null};
|
};
|
||||||
|
const status = (message) => {
|
||||||
|
const node = document.querySelector("#demo-status");
|
||||||
|
if (node) node.textContent = message;
|
||||||
|
};
|
||||||
|
const sameId = (a, b) =>
|
||||||
|
Array.isArray(a) && Array.isArray(b) && a[0] === b[0] && a[1] === b[1];
|
||||||
|
const state = {
|
||||||
|
loadout: "cubes",
|
||||||
|
graph: "authored",
|
||||||
|
compiled: {},
|
||||||
|
telemetry: null,
|
||||||
|
};
|
||||||
|
export const profileRequested = (search) =>
|
||||||
|
new URLSearchParams(search).get("profile") === "1";
|
||||||
|
const profileEnabled = profileRequested(location.search);
|
||||||
|
function installProfileMenu() {
|
||||||
|
if (!profileEnabled) return;
|
||||||
|
const menu = document.createElement("details");
|
||||||
|
menu.id = "profile-menu";
|
||||||
|
menu.innerHTML =
|
||||||
|
'<summary>GPU profile</summary><div id="profile-status">Waiting for GPU timestamps…</div><table><tbody id="profile-passes"></tbody></table>';
|
||||||
|
document.querySelector(".toolbar")?.append(menu);
|
||||||
|
on(window, "renderer-profile", (event) => {
|
||||||
|
const p = event.detail;
|
||||||
|
document.querySelector("#profile-status").textContent = p.available
|
||||||
|
? `${p.graph} · epoch ${p.epoch} · ${p.dropped} dropped`
|
||||||
|
: "GPU timestamps unavailable";
|
||||||
|
document.querySelector("#profile-passes").replaceChildren(
|
||||||
|
...Object.entries(p.passes || {}).map(([id, ms]) => {
|
||||||
|
const row = document.createElement("tr"),
|
||||||
|
name = document.createElement("th"),
|
||||||
|
value = document.createElement("td");
|
||||||
|
name.textContent = id;
|
||||||
|
value.textContent = `${Number(ms).toFixed(3)} ms`;
|
||||||
|
row.append(name, value);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function publish(telemetry) {
|
function publish(telemetry) {
|
||||||
state.telemetry = telemetry;
|
state.telemetry = telemetry;
|
||||||
document.documentElement.dataset.phase8State=JSON.stringify({activeLoadout:state.loadout,activeGraph:state.graph,renderDataRevision:telemetry.revision,renderMode:telemetry.renderMode,activeCompiledId:telemetry.activeCompiledId,activeCompiledGraph:telemetry.activeCompiledGraph,activeCompiledRevision:telemetry.activeCompiledRevision,graphPasses:telemetry.graphPasses,draws:telemetry.draws,instances:telemetry.instances,indices:telemetry.indices,framingRadius:telemetry.framingRadius,gpuError:telemetry.gpuError});
|
document.documentElement.dataset.phase8State = JSON.stringify({
|
||||||
|
activeLoadout: state.loadout,
|
||||||
|
activeGraph: state.graph,
|
||||||
|
renderDataRevision: telemetry.revision,
|
||||||
|
renderMode: telemetry.renderMode,
|
||||||
|
activeCompiledId: telemetry.activeCompiledId,
|
||||||
|
activeCompiledGraph: telemetry.activeCompiledGraph,
|
||||||
|
activeCompiledRevision: telemetry.activeCompiledRevision,
|
||||||
|
activeCompiledSchemaVersion: telemetry.activeCompiledSchemaVersion,
|
||||||
|
graphPasses: telemetry.graphPasses,
|
||||||
|
graphExecutions: telemetry.graphExecutions,
|
||||||
|
graphTextureSlots: telemetry.graphTextureSlots,
|
||||||
|
draws: telemetry.draws,
|
||||||
|
instances: telemetry.instances,
|
||||||
|
indices: telemetry.indices,
|
||||||
|
framingRadius: telemetry.framingRadius,
|
||||||
|
gpuError: telemetry.gpuError,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function waitTelemetry(predicate, timeout = 30000) {
|
function waitTelemetry(predicate, timeout = 30000) {
|
||||||
const current=renderer?.telemetry;if(current&&predicate(current))return Promise.resolve(current);
|
const current = renderer?.telemetry;
|
||||||
return new Promise((resolve,reject)=>{let timer;const done=()=>{clearTimeout(timer);removeEventListener("renderer-frame",frame);};const frame=e=>{if(predicate(e.detail)){done();resolve(e.detail);}};timer=setTimeout(()=>{done();reject(new Error("Telemetry confirmation timed out"));},timeout);onAbort=()=>{done();reject(new RendererError("DISPOSED"));};addEventListener("renderer-frame",frame);timer.unref?.();});
|
if (current && predicate(current)) return Promise.resolve(current);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timer;
|
||||||
|
const done = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
removeEventListener("renderer-frame", frame);
|
||||||
|
};
|
||||||
|
const frame = (e) => {
|
||||||
|
if (predicate(e.detail)) {
|
||||||
|
done();
|
||||||
|
resolve(e.detail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
done();
|
||||||
|
reject(new Error("Telemetry confirmation timed out"));
|
||||||
|
}, timeout);
|
||||||
|
onAbort = () => {
|
||||||
|
done();
|
||||||
|
reject(new RendererError("DISPOSED"));
|
||||||
|
};
|
||||||
|
addEventListener("renderer-frame", frame);
|
||||||
|
timer.unref?.();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let onAbort = () => {};
|
let onAbort = () => {};
|
||||||
async function transaction(label, operation, rollback) {
|
async function transaction(label, operation, rollback) {
|
||||||
if(busy||cleaned)return false;busy=true;document.querySelectorAll("select, #apply-graph").forEach(x=>x.disabled=true);status(label);
|
if (busy || cleaned) return false;
|
||||||
try{const telemetry=await operation();if(cleaned)return false;if(telemetry){publish(telemetry);status(`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`);}else status(`${state.loadout} · ${state.graph} · committed; telemetry pending`);return true;}
|
busy = true;
|
||||||
catch(error){if(!cleaned){try{await rollback?.();}catch(rollbackError){console.error("Phase 8 rollback failed",rollbackError);}console.error("Phase 8 transaction failed",error);status(`Failed · ${error?.code??error?.message??error}`);}return false;}
|
document
|
||||||
finally{busy=false;if(!cleaned){document.querySelectorAll("select").forEach(x=>x.disabled=false);const button=document.querySelector("#apply-graph");if(button)button.disabled=controller?.applying||!controller?.dirty;}}
|
.querySelectorAll("select, #apply-graph")
|
||||||
|
.forEach((x) => (x.disabled = true));
|
||||||
|
status(label);
|
||||||
|
try {
|
||||||
|
const telemetry = await operation();
|
||||||
|
if (cleaned) return false;
|
||||||
|
if (telemetry) {
|
||||||
|
publish(telemetry);
|
||||||
|
status(
|
||||||
|
`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`,
|
||||||
|
);
|
||||||
|
} else
|
||||||
|
status(
|
||||||
|
`${state.loadout} · ${state.graph} · committed; telemetry pending`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!cleaned) {
|
||||||
|
try {
|
||||||
|
await rollback?.();
|
||||||
|
} catch (rollbackError) {
|
||||||
|
console.error("Phase 8 rollback failed", rollbackError);
|
||||||
|
}
|
||||||
|
console.error("Phase 8 transaction failed", error);
|
||||||
|
status(`Failed · ${error?.code ?? error?.message ?? error}`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
if (!cleaned) {
|
||||||
|
document.querySelectorAll("select").forEach((x) => (x.disabled = false));
|
||||||
|
const button = document.querySelector("#apply-graph");
|
||||||
|
if (button) button.disabled = !controller?.canApply;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function selectLoadout(next, select) {
|
async function selectLoadout(next, select) {
|
||||||
const previous=state.loadout,targetRevision=(renderer.telemetry?.revision??0)+1;assetAbort=new AbortController();
|
const previous = state.loadout,
|
||||||
const ok=await transaction(`Loading ${next}…`,async()=>{const glb=await loadDemoLoadout(next,{signal:assetAbort.signal});await renderer.replaceSceneGlb(glb,{framing:next==="sponza"?"interior":"exterior"});state.loadout=next;return waitTelemetry(x=>x.revision===targetRevision&&x.draws>0&&x.activeCompiledGraph===state.compiled[state.graph].graphId&&x.gpuError===false).catch(()=>null);});
|
targetRevision = (renderer.telemetry?.revision ?? 0) + 1;
|
||||||
assetAbort=undefined;if(!ok)select.value=previous;
|
assetAbort = new AbortController();
|
||||||
}
|
const ok = await transaction(`Loading ${next}…`, async () => {
|
||||||
async function selectGraph(next,select){
|
const glb = await loadDemoLoadout(next, { signal: assetAbort.signal });
|
||||||
const previous=state.graph,compiled=state.compiled[next];
|
await renderer.replaceSceneGlb(glb, {
|
||||||
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;});
|
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;
|
if (!ok) select.value = previous;
|
||||||
}
|
}
|
||||||
async function cleanup(){if(cleaned)return;cleaned=true;removeEventListener("pagehide",pagehide);assetAbort?.abort();onAbort();listeners.splice(0).forEach(fn=>fn());unsubscribeController();unsubscribeSnapshots();try{await editor?.destroy();}finally{renderer?.dispose();}}
|
async function selectGraph(next, select) {
|
||||||
const pagehide=()=>{void cleanup()};
|
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 controller?.destroy();
|
||||||
|
await editor?.destroy();
|
||||||
|
} finally {
|
||||||
|
renderer?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pagehide = () => {
|
||||||
|
void cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
addEventListener("pagehide",pagehide,{once:true});delete document.documentElement.dataset.phase8Ready;await wbg_init();if(cleaned)return;
|
addEventListener("pagehide", pagehide, { once: true });
|
||||||
renderer=new RendererClient(main());await renderer.ready;const nextEditor=await createRenderGraphEditor(document.querySelector("#graph-editor"));if(cleaned){await nextEditor.destroy();return}editor=nextEditor;
|
delete document.documentElement.dataset.phase8Ready;
|
||||||
controller=new AuthoringController({renderer,getState:editor.getState});const apply=document.querySelector("#apply-graph"),graphStatus=document.querySelector("#graph-status"),loadoutSelect=document.querySelector("#loadout-select"),graphSelect=document.querySelector("#graph-select");
|
await wbg_init();
|
||||||
unsubscribeController=controller.subscribe(s=>{apply.disabled=busy||s.applying||!s.dirty;graphStatus.textContent=s.applying?"Applying…":s.dirty?"Unapplied changes":`Authored revision ${s.revision}`;});unsubscribeSnapshots=editor.onSnapshots(()=>controller.markDirty());
|
if (cleaned) return;
|
||||||
const authored=await controller.apply(adaptFxNodeSnapshot);state.compiled.authored={...authored,graphId:"demo_forward"};
|
renderer = new RendererClient(main(profileEnabled));
|
||||||
for(const [name,preset] of Object.entries(renderGraphPresets)){const compiled=await renderer.compileGraph(preset);state.compiled[name]={...compiled,graphId:preset.graphId,revision:preset.revision};}
|
installProfileMenu();
|
||||||
on(window,"renderer-frame",event=>{const expected=state.compiled[state.graph],telemetry=event.detail;if(expected&&telemetry.activeCompiledGraph===expected.graphId&&sameId(telemetry.activeCompiledId,expected.compiledId)&&telemetry.gpuError===false){publish(telemetry);if(!busy)status(`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`);}});
|
await renderer.ready;
|
||||||
on(loadoutSelect,"change",()=>void selectLoadout(loadoutSelect.value,loadoutSelect));on(graphSelect,"change",()=>void selectGraph(graphSelect.value,graphSelect));
|
const nextEditor = await createRenderGraphEditor(
|
||||||
on(apply,"click",()=>{const previous=state.graph,previousAuthored=state.compiled.authored;void transaction("Applying authored graph…",async()=>{const compiled=await controller.apply(adaptFxNodeSnapshot);state.compiled.authored={...compiled,graphId:"demo_forward"};state.graph="authored";graphSelect.value="authored";return waitTelemetry(x=>sameId(x.activeCompiledId,compiled.compiledId)&&x.activeCompiledGraph==="demo_forward"&&x.activeCompiledRevision===compiled.revision&&x.gpuError===false).catch(()=>null);},async()=>{state.compiled.authored=previousAuthored;state.graph=previous;graphSelect.value=previous;controller.markDirty();});});
|
document.querySelector("#graph-editor"),
|
||||||
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 (cleaned) {
|
||||||
if(!initialized)throw new Error("Initial demo transaction failed");document.documentElement.dataset.phase8Ready="true";
|
await nextEditor.destroy();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const startupError=error=>{if(cleaned)return;console.error("Phase 8 startup failed",error);status(`Startup failed · ${error?.code??error}`);void cleanup();};
|
editor = nextEditor;
|
||||||
if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>start().catch(startupError),{once:true});else start().catch(startupError);
|
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 === "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);
|
||||||
|
|||||||
+413
-68
@@ -1,75 +1,420 @@
|
|||||||
import { CATALOG_VERSION, descriptors, GRAPH_ID } from "./catalog.js";
|
import {
|
||||||
|
CATALOG_VERSION,
|
||||||
|
descriptors,
|
||||||
|
GRAPH_ID,
|
||||||
|
nodeDefinitions,
|
||||||
|
socketTypes,
|
||||||
|
} from "./catalog.js";
|
||||||
|
|
||||||
export class AuthoringGraphError extends Error {
|
export class AuthoringGraphError extends Error {
|
||||||
constructor(code, details = {}) { super(code); this.name="AuthoringGraphError"; this.code=code; this.details=Object.freeze(details); }
|
constructor(code, details = {}) {
|
||||||
|
super(code);
|
||||||
|
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);
|
|
||||||
}
|
|
||||||
if(!keysEqual([...byType.keys()],Object.keys(descriptors))) fail("AUTHORING_TOPOLOGY",{reason:"node-set"});
|
|
||||||
const linkIds=new Set(), incoming=new Set(), links=[];
|
|
||||||
for(const l of raw.links){
|
|
||||||
if(!object(l)||!validId(l.id)) fail("AUTHORING_ID",{kind:"link",id:l?.id});
|
|
||||||
if(linkIds.has(l.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"link",id:l.id}); linkIds.add(l.id);
|
|
||||||
if(typeof l.muted!=="boolean") fail("AUTHORING_LINK",{linkId:l.id,reason:"muted"});
|
|
||||||
const from=byId.get(l.fromNodeId),to=byId.get(l.toNodeId),fs=from&&Object.values(from.sockets).find(s=>s.id===l.fromSocketId),ts=to&&Object.values(to.sockets).find(s=>s.id===l.toSocketId);
|
|
||||||
if(!fs||!ts||fs.direction!=="output"||ts.direction!=="input"||fs.type!==ts.type) fail("AUTHORING_LINK",{linkId:l.id});
|
|
||||||
if(incoming.has(ts.id)) fail("AUTHORING_LINK_INCOMING",{socketId:ts.id}); incoming.add(ts.id);
|
|
||||||
links.push({from:`${from.type}.${Object.keys(from.sockets).find(k=>from.sockets[k]===fs)}`,to:`${to.type}.${Object.keys(to.sockets).find(k=>to.sockets[k]===ts)}`,muted:l.muted});
|
|
||||||
}
|
|
||||||
const required=["surface_color.surface>scene_forward.color","depth32.depth>scene_forward.depth","scene_forward.result>present.surface"];
|
|
||||||
const active=links.filter(l=>!l.muted).map(l=>`${l.from}>${l.to}`).sort();
|
|
||||||
if(!keysEqual(active,required.sort())||links.length!==3) fail("AUTHORING_TOPOLOGY",{reason:"links"});
|
|
||||||
return Object.freeze({graphId:GRAPH_ID,clearColor:byType.get("scene_forward").parameters.clearColor,clearDepth:byType.get("scene_forward").parameters.clearDepth,passState:byType.get("scene_forward").muted?"disabled":"enabled"});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function semanticProjectionToV1(p, revision=1){
|
const fail = (code, details) => {
|
||||||
if(!Number.isInteger(revision)||revision<1||revision>0xffffffff) fail("AUTHORING_REVISION",{revision});
|
throw new AuthoringGraphError(code, details);
|
||||||
const extent={kind:"surface_relative",width:{numerator:1,denominator:1},height:{numerator:1,denominator:1},depthOrArrayLayers:1};
|
};
|
||||||
return {schemaVersion:1,graphId:p.graphId,revision,resources:[
|
const object = (value) =>
|
||||||
{id:"surface",version:0,residency:{kind:"external",source:"surface_color"},texture:{dimension:"d2",format:"surface",extent,mipLevelCount:1,sampleCount:1}},
|
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
{id:"depth",version:0,residency:{kind:"transient"},texture:{dimension:"d2",format:"depth32_float",extent,mipLevelCount:1,sampleCount:1}},
|
const identifier = (value) =>
|
||||||
],passes:[{id:"forward",state:p.passState,executor:{key:"scene_forward",version:1},parameters:{},reads:[],writes:[
|
typeof value === "string" &&
|
||||||
{binding:"color",resource:{id:"surface",version:0},access:{kind:"color_attachment",location:0,load:{op:"clear",value:p.clearColor},store:"store"}},
|
/^[A-Za-z][A-Za-z0-9_.-]*$/.test(value) &&
|
||||||
{binding:"depth",resource:{id:"depth",version:0},access:{kind:"depth_attachment",load:{op:"clear",value:p.clearDepth},store:"store"}},
|
new TextEncoder().encode(value).length <= 64;
|
||||||
]}],outputs:[{name:"present",resource:{id:"surface",version:0}}]};
|
const exactKeys = (value, keys) =>
|
||||||
|
object(value) &&
|
||||||
|
Object.keys(value).length === keys.length &&
|
||||||
|
keys.every((key) => Object.hasOwn(value, key));
|
||||||
|
const finiteJson = (value) =>
|
||||||
|
value === null ||
|
||||||
|
typeof value === "string" ||
|
||||||
|
typeof value === "boolean" ||
|
||||||
|
(typeof value === "number" && Number.isFinite(value)) ||
|
||||||
|
(Array.isArray(value) && value.every(finiteJson)) ||
|
||||||
|
(object(value) && Object.values(value).every(finiteJson));
|
||||||
|
const canonical = (value) =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value.map(canonical)
|
||||||
|
: object(value)
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.keys(value)
|
||||||
|
.sort()
|
||||||
|
.map((key) => [key, canonical(value[key])]),
|
||||||
|
)
|
||||||
|
: value;
|
||||||
|
const deepFreeze = (value) => {
|
||||||
|
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
||||||
|
Object.freeze(value);
|
||||||
|
for (const child of Object.values(value)) deepFreeze(child);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const sourceMaps = new WeakMap();
|
||||||
|
export const getSourceMap = (ir) => sourceMaps.get(ir);
|
||||||
|
export const mapAuthoringDiagnostic = (ir, diagnostic) => {
|
||||||
|
const details = diagnostic?.details;
|
||||||
|
const path = [details?.path, diagnostic?.path, details?.field, diagnostic?.field]
|
||||||
|
.find((value) => typeof value === "string");
|
||||||
|
const map = getSourceMap(ir);
|
||||||
|
let match;
|
||||||
|
if (path && map)
|
||||||
|
for (const key of Object.keys(map))
|
||||||
|
if (
|
||||||
|
(path === key || path.startsWith(`${key}.`) || path.startsWith(`${key}[`)) &&
|
||||||
|
(!match || key.length > match.length)
|
||||||
|
)
|
||||||
|
match = key;
|
||||||
|
return deepFreeze({
|
||||||
|
name: diagnostic?.name,
|
||||||
|
code: diagnostic?.code,
|
||||||
|
message: details?.message ?? diagnostic?.message ?? diagnostic?.code,
|
||||||
|
details: details === undefined ? undefined : structuredClone(details),
|
||||||
|
path,
|
||||||
|
source: match ? structuredClone(map[match]) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapValuePaths = (paths, path, source, value) => {
|
||||||
|
paths[path] = source;
|
||||||
|
if (Array.isArray(value))
|
||||||
|
value.forEach((child, index) => mapValuePaths(paths, `${path}[${index}]`, source, child));
|
||||||
|
else if (object(value))
|
||||||
|
for (const key of Object.keys(value))
|
||||||
|
mapValuePaths(paths, `${path}.${key}`, source, value[key]);
|
||||||
|
};
|
||||||
|
|
||||||
|
function parameterValue(raw, schema, nodeId, key) {
|
||||||
|
const expected = schema.type === "json" ? "json" : schema.type;
|
||||||
|
if (
|
||||||
|
!exactKeys(raw, ["kind", "value"]) ||
|
||||||
|
raw.kind !== expected ||
|
||||||
|
!finiteJson(raw.value)
|
||||||
|
)
|
||||||
|
fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
|
||||||
|
if (
|
||||||
|
(expected === "number" && typeof raw.value !== "number") ||
|
||||||
|
(expected === "string" && typeof raw.value !== "string") ||
|
||||||
|
(expected === "boolean" && typeof raw.value !== "boolean") ||
|
||||||
|
(expected === "json" && !finiteJson(raw.value))
|
||||||
|
)
|
||||||
|
fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
|
||||||
|
return canonical(structuredClone(raw.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adaptFxNodeSnapshot(raw, revision = 1) {
|
||||||
|
try {
|
||||||
|
const rootKeys = ["graphId", "catalogVersion", "nodes", "links", "metadata", "version"];
|
||||||
|
if (
|
||||||
|
!exactKeys(raw, rootKeys) ||
|
||||||
|
!Array.isArray(raw.nodes) ||
|
||||||
|
!Array.isArray(raw.links) ||
|
||||||
|
!object(raw.metadata) ||
|
||||||
|
!finiteJson(raw.metadata)
|
||||||
|
)
|
||||||
|
fail("AUTHORING_SHAPE");
|
||||||
|
if (raw.graphId !== GRAPH_ID || raw.catalogVersion !== CATALOG_VERSION)
|
||||||
|
fail("AUTHORING_CATALOG");
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(raw.version) || raw.version < 0
|
||||||
|
)
|
||||||
|
fail("AUTHORING_SHAPE");
|
||||||
|
if (!Number.isInteger(revision) || revision < 1 || revision > 0xffffffff)
|
||||||
|
fail("AUTHORING_REVISION");
|
||||||
|
const nodes = new Map(),
|
||||||
|
sockets = new Map(),
|
||||||
|
paths = {};
|
||||||
|
for (let ordinal = 0; ordinal < raw.nodes.length; ordinal++) {
|
||||||
|
const n = raw.nodes[ordinal];
|
||||||
|
if (!object(n) || !identifier(n.id)) fail("AUTHORING_ID", { id: n?.id });
|
||||||
|
if (nodes.has(n.id)) fail("AUTHORING_ID_DUPLICATE", { id: n.id });
|
||||||
|
const descriptor = descriptors[n.typeId],
|
||||||
|
definition = nodeDefinitions[n.typeId];
|
||||||
|
if (!descriptor)
|
||||||
|
fail("AUTHORING_NODE_TYPE", { nodeId: n.id, typeId: n.typeId });
|
||||||
|
const nodeKeys = ["id", "typeId", "typeVersion", "position", "size", "label", "parameters", "sockets", "muted", "collapsed", "extensions", "known"];
|
||||||
|
if (Object.hasOwn(n, "parentId")) nodeKeys.push("parentId");
|
||||||
|
if (
|
||||||
|
!exactKeys(n, nodeKeys) ||
|
||||||
|
n.known !== true ||
|
||||||
|
n.typeVersion !== 1 ||
|
||||||
|
typeof n.muted !== "boolean" ||
|
||||||
|
typeof n.collapsed !== "boolean" ||
|
||||||
|
typeof n.label !== "string" ||
|
||||||
|
!exactKeys(n.position, ["x", "y"]) || !Number.isFinite(n.position.x) || !Number.isFinite(n.position.y) ||
|
||||||
|
!exactKeys(n.size, ["x", "y"]) || !Number.isFinite(n.size.x) || !Number.isFinite(n.size.y) || n.size.x <= 0 || n.size.y <= 0 ||
|
||||||
|
(Object.hasOwn(n, "parentId") && !identifier(n.parentId)) ||
|
||||||
|
!object(n.extensions) || !finiteJson(n.extensions) ||
|
||||||
|
!Array.isArray(n.sockets) ||
|
||||||
|
!object(n.parameters)
|
||||||
|
)
|
||||||
|
fail("AUTHORING_NODE_INVALID", { nodeId: n.id });
|
||||||
|
const parameterKeys = Object.keys(definition.parameters);
|
||||||
|
if (
|
||||||
|
Object.keys(n.parameters).length !== parameterKeys.length ||
|
||||||
|
!parameterKeys.every((key) => Object.hasOwn(n.parameters, key))
|
||||||
|
)
|
||||||
|
fail("AUTHORING_PARAMETER_SET", { nodeId: n.id });
|
||||||
|
const parameters = Object.fromEntries(
|
||||||
|
parameterKeys.map((key) => [
|
||||||
|
key,
|
||||||
|
parameterValue(
|
||||||
|
n.parameters[key],
|
||||||
|
definition.parameters[key],
|
||||||
|
n.id,
|
||||||
|
key,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const expected = [
|
||||||
|
...Object.keys(descriptor.inputs),
|
||||||
|
...Object.keys(descriptor.outputs),
|
||||||
|
];
|
||||||
|
if (n.sockets.length !== expected.length)
|
||||||
|
fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
|
||||||
|
for (const s of n.sockets) {
|
||||||
|
if (!object(s) || !expected.includes(s.key) || sockets.has(s.id))
|
||||||
|
fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s?.key });
|
||||||
|
const input = descriptor.inputs[s.key],
|
||||||
|
socketDefinition = definition.sockets[s.key],
|
||||||
|
direction = input ? "input" : "output",
|
||||||
|
dataType = socketDefinition.type,
|
||||||
|
socketKeys = ["id", "key", "label", "direction", "dataType", "accepts", "maxIncomingLinks", ...(socketDefinition.value ? ["defaultValue"] : []), "visible"];
|
||||||
|
if (
|
||||||
|
!exactKeys(s, socketKeys) ||
|
||||||
|
s.id !== `${n.id}:${s.key}` ||
|
||||||
|
s.label !== socketDefinition.title ||
|
||||||
|
s.direction !== direction ||
|
||||||
|
s.dataType !== dataType ||
|
||||||
|
!Array.isArray(s.accepts) || s.accepts.length !== (direction === "input" ? socketTypes[dataType].acceptsFrom.length : 0) ||
|
||||||
|
!s.accepts.every((v, i) => v === (direction === "input" ? socketTypes[dataType].acceptsFrom[i] : undefined)) ||
|
||||||
|
(socketDefinition.value
|
||||||
|
? !exactKeys(s.defaultValue, ["kind", "value"]) || !finiteJson(s.defaultValue.value)
|
||||||
|
: s.defaultValue !== undefined) ||
|
||||||
|
s.visible !== socketDefinition.visible ||
|
||||||
|
s.maxIncomingLinks !== socketDefinition.maxIncomingLinks
|
||||||
|
)
|
||||||
|
fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s.key });
|
||||||
|
sockets.set(s.id, {
|
||||||
|
node: n.id,
|
||||||
|
key: s.key,
|
||||||
|
direction,
|
||||||
|
semanticType: input
|
||||||
|
? input.accepted.types[0]
|
||||||
|
: descriptor.outputs[s.key].type,
|
||||||
|
authoringType: s.dataType,
|
||||||
|
maxIncomingLinks: s.maxIncomingLinks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (new Set(n.sockets.map((s) => s.key)).size !== expected.length)
|
||||||
|
fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
|
||||||
|
nodes.set(n.id, {
|
||||||
|
ordinal,
|
||||||
|
value: {
|
||||||
|
id: n.id,
|
||||||
|
state: n.muted ? "muted" : "enabled",
|
||||||
|
executor: { key: n.typeId, version: 1 },
|
||||||
|
parameters,
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const incoming = new Map(),
|
||||||
|
linkIds = new Set(),
|
||||||
|
linkSources = new Map();
|
||||||
|
for (let ordinal = 0; ordinal < raw.links.length; ordinal++) {
|
||||||
|
const link = raw.links[ordinal];
|
||||||
|
if (
|
||||||
|
!object(link) ||
|
||||||
|
!identifier(link.id) ||
|
||||||
|
linkIds.has(link.id) ||
|
||||||
|
!exactKeys(link, ["id", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted", "extensions"]) ||
|
||||||
|
typeof link.muted !== "boolean" || !object(link.extensions) || !finiteJson(link.extensions)
|
||||||
|
)
|
||||||
|
fail("AUTHORING_LINK", { linkId: link?.id });
|
||||||
|
linkIds.add(link.id);
|
||||||
|
const from = sockets.get(link.fromSocketId),
|
||||||
|
to = sockets.get(link.toSocketId);
|
||||||
|
if (
|
||||||
|
!from ||
|
||||||
|
!to ||
|
||||||
|
link.fromNodeId !== from.node ||
|
||||||
|
link.toNodeId !== to.node ||
|
||||||
|
from.direction !== "output" ||
|
||||||
|
to.direction !== "input" ||
|
||||||
|
(!link.muted && (incoming.get(link.toSocketId) ?? 0) >= to.maxIncomingLinks)
|
||||||
|
)
|
||||||
|
fail(
|
||||||
|
!link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity)
|
||||||
|
? "AUTHORING_LINK_INCOMING"
|
||||||
|
: "AUTHORING_LINK",
|
||||||
|
!link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity)
|
||||||
|
? { socketId: link.toSocketId }
|
||||||
|
: { linkId: link.id },
|
||||||
|
);
|
||||||
|
const accepted =
|
||||||
|
descriptors[nodes.get(to.node).value.executor.key].inputs[to.key]
|
||||||
|
.accepted.types;
|
||||||
|
const authoringAccepted = socketTypes[nodeDefinitions[nodes.get(to.node).value.executor.key].sockets[to.key].type].acceptsFrom;
|
||||||
|
if (!accepted.includes(from.semanticType) || !authoringAccepted.includes(from.authoringType))
|
||||||
|
fail("AUTHORING_LINK_TYPE", { linkId: link.id });
|
||||||
|
const linkSource = {
|
||||||
|
kind: "link",
|
||||||
|
linkId: link.id,
|
||||||
|
fromNodeId: link.fromNodeId,
|
||||||
|
fromSocketId: link.fromSocketId,
|
||||||
|
toNodeId: link.toNodeId,
|
||||||
|
toSocketId: link.toSocketId,
|
||||||
|
muted: link.muted,
|
||||||
|
nodeId: to.node,
|
||||||
|
input: to.key,
|
||||||
|
fromSocket: from.key,
|
||||||
|
toSocket: to.key,
|
||||||
|
};
|
||||||
|
linkSources.set(link.id, linkSource);
|
||||||
|
if (!link.muted) {
|
||||||
|
incoming.set(link.toSocketId, (incoming.get(link.toSocketId) ?? 0) + 1);
|
||||||
|
nodes.get(to.node).value.inputs[to.key] = {
|
||||||
|
node: from.node,
|
||||||
|
socket: from.key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ordered = [...nodes.values()].sort((a, b) =>
|
||||||
|
a.value.id < b.value.id
|
||||||
|
? -1
|
||||||
|
: a.value.id > b.value.id
|
||||||
|
? 1
|
||||||
|
: a.ordinal - b.ordinal,
|
||||||
|
);
|
||||||
|
for (let wireOrdinal = 0; wireOrdinal < ordered.length; wireOrdinal++) {
|
||||||
|
const item = ordered[wireOrdinal];
|
||||||
|
item.value.inputs = Object.fromEntries(
|
||||||
|
Object.keys(descriptors[item.value.executor.key].inputs)
|
||||||
|
.filter((key) => Object.hasOwn(item.value.inputs, key))
|
||||||
|
.map((key) => [key, item.value.inputs[key]]),
|
||||||
|
);
|
||||||
|
const base = `nodes[${wireOrdinal}]`;
|
||||||
|
const nodeSource = { kind: "node", nodeId: item.value.id };
|
||||||
|
paths[base] = nodeSource;
|
||||||
|
for (const field of ["id", "state", "executor", "executor.key", "executor.version"])
|
||||||
|
paths[`${base}.${field}`] = nodeSource;
|
||||||
|
paths[`${base}.parameters`] = nodeSource;
|
||||||
|
for (const key of Object.keys(item.value.parameters))
|
||||||
|
mapValuePaths(paths, `${base}.parameters.${key}`, { kind: "parameter", nodeId: item.value.id, parameter: key }, item.value.parameters[key]);
|
||||||
|
for (const key of Object.keys(descriptors[item.value.executor.key].inputs)) {
|
||||||
|
const link = raw.links.find((x) => !x.muted && x.toNodeId === item.value.id && sockets.get(x.toSocketId)?.key === key);
|
||||||
|
const source = linkSources.get(link?.id) ?? {
|
||||||
|
kind: "input",
|
||||||
|
nodeId: item.value.id,
|
||||||
|
input: key,
|
||||||
|
socketId: `${item.value.id}:${key}`,
|
||||||
|
unconnected: true,
|
||||||
|
};
|
||||||
|
paths[`${base}.inputs.${key}`] = source;
|
||||||
|
if (link) {
|
||||||
|
paths[`${base}.inputs.${key}.node`] = source;
|
||||||
|
paths[`${base}.inputs.${key}.socket`] = {
|
||||||
|
kind: "socket",
|
||||||
|
nodeId: link.fromNodeId,
|
||||||
|
socketId: link.fromSocketId,
|
||||||
|
socket: sockets.get(link.fromSocketId).key,
|
||||||
|
linkId: link.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths[`${base}.inputs`] = nodeSource;
|
||||||
|
}
|
||||||
|
const ir = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
graphId: GRAPH_ID,
|
||||||
|
revision,
|
||||||
|
nodes: ordered.map((item) => item.value),
|
||||||
|
};
|
||||||
|
const graphSource = { kind: "graph", graphId: GRAPH_ID };
|
||||||
|
for (const field of ["schemaVersion", "graphId", "revision", "nodes"])
|
||||||
|
paths[field] = graphSource;
|
||||||
|
deepFreeze(paths);
|
||||||
|
deepFreeze(ir);
|
||||||
|
sourceMaps.set(ir, paths);
|
||||||
|
return ir;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthoringGraphError) throw error;
|
||||||
|
fail("AUTHORING_SHAPE");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export const adaptFxNodeSnapshot=(snapshot,revision=1)=>semanticProjectionToV1(projectAuthoringSnapshot(snapshot),revision);
|
|
||||||
export const adaptGraphSnapshot = adaptFxNodeSnapshot;
|
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 } }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,117 @@
|
|||||||
|
import { mapAuthoringDiagnostic } from "./adapter.js";
|
||||||
|
|
||||||
export class AuthoringController {
|
export class AuthoringController {
|
||||||
#renderer; #getState; #revision=0; #nextRevision=1; #dirty=true; #applying=null; #listeners=new Set();
|
#renderer; #adapt; #revision = 0; #nextRevision = 1; #generation = 0;
|
||||||
constructor({renderer,getState}) { this.#renderer=renderer; this.#getState=getState; }
|
#current; #lastGood; #applyPromise; #listeners = new Set();
|
||||||
get revision(){return this.#revision} get dirty(){return this.#dirty} get applying(){return !!this.#applying}
|
#owned = new Map(); #drops = new Map(); #activeCompiles = new Set();
|
||||||
subscribe(fn){this.#listeners.add(fn);return()=>this.#listeners.delete(fn)}
|
#disposed = false; #applyingRecord; #scheduler; #debounceMs; #timer; #destroyPromise;
|
||||||
markDirty(){this.#dirty=true;this.#emit()}
|
|
||||||
#emit(){for(const fn of this.#listeners)fn({revision:this.#revision,dirty:this.#dirty,applying:!!this.#applying})}
|
constructor({ renderer, adapt, scheduler = globalThis, debounceMs = 150 }) {
|
||||||
apply(adapt){
|
this.#renderer = renderer; this.#adapt = adapt;
|
||||||
if(this.#applying)return this.#applying;
|
this.#scheduler = scheduler; this.#debounceMs = debounceMs;
|
||||||
const revision=this.#nextRevision++; this.#dirty=false; this.#emit();
|
}
|
||||||
this.#applying=(async()=>{try{const snapshot=await this.#getState();const ir=adapt(snapshot,revision);const compiled=await this.#renderer.compileGraph(ir);await this.#renderer.switchCompiledGraph(compiled.compiledId);this.#revision=revision;return compiled} catch(e){this.#dirty=true;throw e} finally{this.#applying=null;this.#emit()}})();
|
get revision() { return this.#revision; }
|
||||||
return this.#applying;
|
get dirty() { return !!this.#current; }
|
||||||
|
get applying() { return !!this.#applyPromise; }
|
||||||
|
get staged() { return this.#current?.candidate ?? null; }
|
||||||
|
get canApply() { return !this.#disposed && !!this.#current?.candidate && !this.#applyPromise; }
|
||||||
|
subscribe(fn) {
|
||||||
|
if (this.#disposed) return () => {};
|
||||||
|
this.#listeners.add(fn); fn(this.#state());
|
||||||
|
return () => this.#listeners.delete(fn);
|
||||||
|
}
|
||||||
|
#state() { return { revision: this.#revision, dirty: this.dirty, applying: this.applying, staged: this.staged, canApply: this.canApply, error: this.#current?.diagnostic ?? null }; }
|
||||||
|
#emit() { if (!this.#disposed) for (const fn of this.#listeners) fn(this.#state()); }
|
||||||
|
#key(id) { return JSON.stringify(id); }
|
||||||
|
#drop(candidate) {
|
||||||
|
if (!candidate) return Promise.resolve();
|
||||||
|
const key = this.#key(candidate.compiledId);
|
||||||
|
if (!this.#owned.has(key)) return this.#drops.get(key) ?? Promise.resolve();
|
||||||
|
if (this.#drops.has(key)) return this.#drops.get(key);
|
||||||
|
let result;
|
||||||
|
try { result = this.#renderer.dropCompiledGraph(candidate.compiledId); }
|
||||||
|
catch (error) { result = Promise.reject(error); }
|
||||||
|
const dropping = Promise.resolve(result)
|
||||||
|
.then(() => { this.#owned.delete(key); })
|
||||||
|
.finally(() => { this.#drops.delete(key); });
|
||||||
|
this.#drops.set(key, dropping);
|
||||||
|
return dropping;
|
||||||
|
}
|
||||||
|
#retire(candidate) { if (candidate) void this.#drop(candidate).catch(() => {}); }
|
||||||
|
#start(record) {
|
||||||
|
if (!record.compile) {
|
||||||
|
record.compile = this.#compile(record);
|
||||||
|
this.#activeCompiles.add(record.compile);
|
||||||
|
record.compile.finally(() => this.#activeCompiles.delete(record.compile));
|
||||||
|
}
|
||||||
|
return record.compile;
|
||||||
|
}
|
||||||
|
#flush(record = this.#current) {
|
||||||
|
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
|
||||||
|
return record ? this.#start(record) : null;
|
||||||
|
}
|
||||||
|
markDirty(snapshot) {
|
||||||
|
if (this.#disposed) return;
|
||||||
|
const previous = this.#current;
|
||||||
|
const record = { generation: ++this.#generation, snapshot, candidate: null, error: null, diagnostic: null, compile: null };
|
||||||
|
this.#current = record;
|
||||||
|
if (previous?.candidate && previous !== this.#applyingRecord && previous.candidate !== this.#lastGood) this.#retire(previous.candidate);
|
||||||
|
if (this.#timer) this.#scheduler.clearTimeout(this.#timer);
|
||||||
|
this.#timer = this.#scheduler.setTimeout(() => { this.#timer = undefined; if (!this.#disposed) this.#start(record); }, this.#debounceMs);
|
||||||
|
this.#emit();
|
||||||
|
}
|
||||||
|
async #compile(record) {
|
||||||
|
let candidate, ir;
|
||||||
|
try {
|
||||||
|
ir = this.#adapt(record.snapshot, this.#nextRevision++);
|
||||||
|
candidate = await this.#renderer.compileGraph(ir);
|
||||||
|
this.#owned.set(this.#key(candidate.compiledId), candidate);
|
||||||
|
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
|
||||||
|
record.candidate = candidate; record.error = record.diagnostic = null; this.#emit(); return candidate;
|
||||||
|
} catch (error) {
|
||||||
|
if (candidate) this.#retire(candidate);
|
||||||
|
record.error = error;
|
||||||
|
record.diagnostic = ir ? mapAuthoringDiagnostic(ir, error) : error;
|
||||||
|
if (this.#current === record) this.#emit();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apply() {
|
||||||
|
if (this.#disposed) return Promise.resolve(null);
|
||||||
|
if (this.#applyPromise) return this.#applyPromise;
|
||||||
|
const record = this.#current;
|
||||||
|
if (!record) return Promise.resolve(this.#lastGood);
|
||||||
|
this.#applyingRecord = record; this.#flush(record);
|
||||||
|
this.#applyPromise = this.#applyRecord(record); this.#emit(); return this.#applyPromise;
|
||||||
|
}
|
||||||
|
async #applyRecord(record) {
|
||||||
|
try {
|
||||||
|
const candidate = record.candidate ?? (await record.compile);
|
||||||
|
if (!candidate) throw record.error ?? new Error("Graph compilation failed");
|
||||||
|
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
|
||||||
|
await this.#renderer.switchCompiledGraph(candidate.compiledId);
|
||||||
|
const old = this.#lastGood; this.#lastGood = candidate;
|
||||||
|
this.#revision = candidate.revision ?? this.#revision + 1;
|
||||||
|
if (this.#current === record) this.#current = undefined;
|
||||||
|
if (old && old !== candidate) this.#retire(old);
|
||||||
|
return candidate;
|
||||||
|
} finally {
|
||||||
|
if (this.#current !== record && record.candidate && record.candidate !== this.#lastGood) this.#retire(record.candidate);
|
||||||
|
this.#applyPromise = null; this.#applyingRecord = undefined; this.#emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
destroy() {
|
||||||
|
if (this.#destroyPromise) return this.#destroyPromise;
|
||||||
|
this.#disposed = true;
|
||||||
|
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
|
||||||
|
this.#listeners.clear();
|
||||||
|
this.#destroyPromise = this.#finish();
|
||||||
|
return this.#destroyPromise;
|
||||||
|
}
|
||||||
|
async #finish() {
|
||||||
|
const applying = this.#applyPromise;
|
||||||
|
await Promise.allSettled([...(applying ? [applying] : []), ...this.#activeCompiles, ...this.#drops.values()]);
|
||||||
|
await Promise.allSettled([...this.#owned.values()].map((candidate) => this.#drop(candidate)));
|
||||||
|
this.#current = this.#lastGood = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ const sizeCanvas = (canvas, value) => {
|
|||||||
};
|
};
|
||||||
const mods = e => ({ alt:e.altKey, control:e.ctrlKey, meta:e.metaKey, shift:e.shiftKey });
|
const mods = e => ({ alt:e.altKey, control:e.ctrlKey, meta:e.metaKey, shift:e.shiftKey });
|
||||||
|
|
||||||
export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeType }={}) {
|
export function prepareBrowserHost(canvas, { onError=console.error, requestAddNode }={}) {
|
||||||
const ownerDocument=canvas.ownerDocument, ownerWindow=ownerDocument.defaultView ?? window;
|
const ownerDocument=canvas.ownerDocument, ownerWindow=ownerDocument.defaultView ?? window;
|
||||||
const originalTabIndex=canvas.getAttribute("tabindex"), originalTouchAction=canvas.style.touchAction;
|
const originalTabIndex=canvas.getAttribute("tabindex"), originalTouchAction=canvas.style.touchAction;
|
||||||
let view, dead=false, generation=0, resizing=false, pending, appliedViewport, menuPending=false, unsubscribeHost=()=>{};
|
let view, root, dead=false, generation=0, requestEpoch=0, resizing=false, pending, appliedViewport, menuPending=false, menuPoint, unsubscribeHost=()=>{};
|
||||||
|
const rootSubscriptions=[];
|
||||||
|
const invalidateAddNode=()=>{requestEpoch++;menuPending=false;requestAddNode?.close?.()};
|
||||||
const captured=new Set();
|
const captured=new Set();
|
||||||
const initialViewport=viewport(canvas,ownerWindow); appliedViewport=initialViewport; sizeCanvas(canvas,initialViewport);
|
const initialViewport=viewport(canvas,ownerWindow); appliedViewport=initialViewport; sizeCanvas(canvas,initialViewport);
|
||||||
canvas.tabIndex=0; canvas.style.touchAction="none";
|
canvas.tabIndex=0; canvas.style.touchAction="none";
|
||||||
@@ -22,14 +24,14 @@ export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeTy
|
|||||||
if(!view)return;
|
if(!view)return;
|
||||||
if(e instanceof ownerWindow.PointerEvent){
|
if(e instanceof ownerWindow.PointerEvent){
|
||||||
const phase=e.type==="pointerdown"?"down":e.type==="pointermove"?"move":e.type==="pointerup"?"up":"cancel";
|
const phase=e.type==="pointerdown"?"down":e.type==="pointermove"?"move":e.type==="pointerup"?"up":"cancel";
|
||||||
if(phase==="down"){menuPending=e.button===2&&!e.ctrlKey&&(e.buttons&1)===0;canvas.focus();try{canvas.setPointerCapture(e.pointerId);captured.add(e.pointerId)}catch{}}
|
if(phase==="down"){invalidateAddNode();menuPending=e.button===2&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&!e.shiftKey&&(e.buttons&1)===0;menuPoint={x:e.clientX,y:e.clientY};canvas.focus();try{canvas.setPointerCapture(e.pointerId);captured.add(e.pointerId)}catch{}}
|
||||||
if((phase==="up"||phase==="cancel")&&captured.delete(e.pointerId))try{if(canvas.hasPointerCapture(e.pointerId))canvas.releasePointerCapture(e.pointerId)}catch{}
|
if((phase==="up"||phase==="cancel")&&captured.delete(e.pointerId))try{if(canvas.hasPointerCapture(e.pointerId))canvas.releasePointerCapture(e.pointerId)}catch{}
|
||||||
view.feedInput({kind:"pointer",phase,pointerId:e.pointerId,pointerType:e.pointerType,position:point(e),button:e.button,buttons:e.buttons,modifiers:mods(e)});
|
view.feedInput({kind:"pointer",phase,pointerId:e.pointerId,pointerType:e.pointerType,position:point(e),button:e.button,buttons:e.buttons,modifiers:mods(e)});
|
||||||
}else if(e instanceof ownerWindow.WheelEvent){
|
}else if(e instanceof ownerWindow.WheelEvent){
|
||||||
e.preventDefault(); menuPending=false;
|
e.preventDefault(); invalidateAddNode();
|
||||||
const scale=e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_LINE?16:e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_PAGE?Math.max(1,canvas.clientHeight):1;
|
const scale=e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_LINE?16:e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_PAGE?Math.max(1,canvas.clientHeight):1;
|
||||||
view.feedInput({kind:"wheel",position:point(e),delta:{x:e.deltaX*scale,y:e.deltaY*scale},modifiers:mods(e)});
|
view.feedInput({kind:"wheel",position:point(e),delta:{x:e.deltaX*scale,y:e.deltaY*scale},modifiers:mods(e)});
|
||||||
}else if(e instanceof ownerWindow.KeyboardEvent){menuPending=false;view.feedInput({kind:"key",phase:e.type==="keydown"?"down":"up",key:e.key,code:e.code,repeat:e.repeat,modifiers:mods(e)});
|
}else if(e instanceof ownerWindow.KeyboardEvent){invalidateAddNode();view.feedInput({kind:"key",phase:e.type==="keydown"?"down":"up",key:e.key,code:e.code,repeat:e.repeat,modifiers:mods(e)});
|
||||||
}else view.feedInput({kind:"focus",phase:e.type==="focus"?"focus":"blur"});
|
}else view.feedInput({kind:"focus",phase:e.type==="focus"?"focus":"blur"});
|
||||||
};
|
};
|
||||||
const names=["pointerdown","pointermove","pointerup","pointercancel","wheel","keydown","keyup","focus","blur"];
|
const names=["pointerdown","pointermove","pointerup","pointercancel","wheel","keydown","keyup","focus","blur"];
|
||||||
@@ -40,22 +42,23 @@ export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeTy
|
|||||||
resizing=true;
|
resizing=true;
|
||||||
Promise.resolve(view.setViewport(next)).then(()=>{if(dead||currentGeneration!==generation)return;appliedViewport=next;sizeCanvas(canvas,next)}).catch(error=>{if(!dead&¤tGeneration===generation)onError(error)}).finally(()=>{if(dead||currentGeneration!==generation)return;resizing=false;pump()});
|
Promise.resolve(view.setViewport(next)).then(()=>{if(dead||currentGeneration!==generation)return;appliedViewport=next;sizeCanvas(canvas,next)}).catch(error=>{if(!dead&¤tGeneration===generation)onError(error)}).finally(()=>{if(dead||currentGeneration!==generation)return;resizing=false;pump()});
|
||||||
};
|
};
|
||||||
const resize=()=>{if(dead)return;pending=viewport(canvas,ownerWindow);pump()};
|
const resize=()=>{if(dead)return;invalidateAddNode();pending=viewport(canvas,ownerWindow);pump()};
|
||||||
const outside=e=>{if(view&&e.button===0&&e.target!==canvas&&!canvas.contains(e.target)&&view.getHostSnapshot().colorPickerOpen)view.feedInput({kind:"outside-pointer",button:0})};
|
const outside=e=>{if(view&&e.button===0&&e.target!==canvas&&!canvas.contains(e.target)&&view.getHostSnapshot().colorPickerOpen)view.feedInput({kind:"outside-pointer",button:0})};
|
||||||
const lost=e=>captured.delete(e.pointerId);
|
const lost=e=>captured.delete(e.pointerId);
|
||||||
const observer=new ownerWindow.ResizeObserver(resize);
|
const observer=new ownerWindow.ResizeObserver(resize);
|
||||||
return {initialViewport,attach(_root,next){
|
return {initialViewport,attach(_root,next){
|
||||||
view=next;
|
root=_root;view=next;
|
||||||
for(const n of names)canvas.addEventListener(n,input,{passive:n!=="wheel"});
|
for(const n of names)canvas.addEventListener(n,input,{passive:n!=="wheel"});
|
||||||
canvas.addEventListener("contextmenu",prevent);canvas.addEventListener("lostpointercapture",lost);
|
canvas.addEventListener("contextmenu",prevent);canvas.addEventListener("lostpointercapture",lost);
|
||||||
ownerDocument.addEventListener("pointerdown",outside,true);ownerWindow.addEventListener("resize",resize);
|
ownerDocument.addEventListener("pointerdown",outside,true);ownerWindow.addEventListener("resize",resize);
|
||||||
unsubscribeHost=view.onHostRequests(request=>{if(request.kind!=="add-node-menu"||!menuPending)return;menuPending=false;const typeId=chooseNodeType?.(request);if(typeId)view.addNode({typeId,viewPosition:request.viewPosition}).catch(onError)});
|
unsubscribeHost=view.onHostRequests(request=>{if(request.kind!=="add-node-menu"||!menuPending||request.compositionRevision!==view.getHostSnapshot().compositionRevision){invalidateAddNode();return}menuPending=false;const epoch=requestEpoch;requestAddNode?.(request,menuPoint,()=>!dead&&epoch===requestEpoch);});
|
||||||
|
rootSubscriptions.push(root.onMutations(invalidateAddNode),root.onCompositionChanges(invalidateAddNode));
|
||||||
observer.observe(canvas);resize();
|
observer.observe(canvas);resize();
|
||||||
},destroy(){
|
},destroy(){
|
||||||
if(dead)return;dead=true;generation++;pending=undefined;observer.disconnect();unsubscribeHost();ownerWindow.removeEventListener("resize",resize);ownerDocument.removeEventListener("pointerdown",outside,true);
|
if(dead)return;dead=true;generation++;pending=undefined;invalidateAddNode();observer.disconnect();unsubscribeHost();for(const unsubscribe of rootSubscriptions)unsubscribe();rootSubscriptions.length=0;ownerWindow.removeEventListener("resize",resize);ownerDocument.removeEventListener("pointerdown",outside,true);
|
||||||
for(const n of names)canvas.removeEventListener(n,input);canvas.removeEventListener("contextmenu",prevent);canvas.removeEventListener("lostpointercapture",lost);
|
for(const n of names)canvas.removeEventListener(n,input);canvas.removeEventListener("contextmenu",prevent);canvas.removeEventListener("lostpointercapture",lost);
|
||||||
for(const id of captured)try{if(canvas.hasPointerCapture(id))canvas.releasePointerCapture(id)}catch{}captured.clear();
|
for(const id of captured)try{if(canvas.hasPointerCapture(id))canvas.releasePointerCapture(id)}catch{}captured.clear();
|
||||||
if(originalTabIndex===null)canvas.removeAttribute("tabindex");else canvas.setAttribute("tabindex",originalTabIndex);canvas.style.touchAction=originalTouchAction;view=null;
|
if(originalTabIndex===null)canvas.removeAttribute("tabindex");else canvas.setAttribute("tabindex",originalTabIndex);canvas.style.touchAction=originalTouchAction;view=null;root=null;
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
function prevent(e){e.preventDefault()}
|
function prevent(e){e.preventDefault()}
|
||||||
|
|||||||
+346
-28
@@ -1,29 +1,347 @@
|
|||||||
export const GRAPH_ID = "demo_forward";
|
export const GRAPH_ID = "authored_gpu_culling";
|
||||||
export const CATALOG_VERSION = 1;
|
export const CATALOG_VERSION = 2;
|
||||||
|
const exact = (type) => ({ kind: "exact", types: [type] });
|
||||||
export const socketTypes = {
|
const oneOf = (...types) => ({ kind: "one_of", types });
|
||||||
surface: { title: "Surface", color: "#62b0ff", acceptsFrom: ["surface"] },
|
const i = (type, required = true, authoringType) => ({
|
||||||
depth: { title: "Depth", color: "#b58cff", acceptsFrom: ["depth"] },
|
accepted: typeof type === "string" ? exact(type) : type,
|
||||||
};
|
required,
|
||||||
export const theme = {
|
...(authoringType ? { authoringType } : {}),
|
||||||
background:"#151820",grid:"#292e3a",frame:"#30343a80",frameHeader:"#59616c",body:"#292e39",control:"#191d26",controlFill:"#4775b8",controlEditing:"#101218",textSelection:"#4775b8",outline:"#0b0d12",text:"#edf1f7",muted:"#969eaa",shadow:"#00000088",nodeSelected:"#ff9f43",nodeActive:"#ffffff",unknownHeader:"#555b64",unknownSocket:"#999999",linkMuted:"#d94b4b",knifeMuted:"#e85b5b",emphasis:"#ffffff",focus:"#f5a623",editOutline:"#666a70",resize:"#8b8e95",muteOverlay:"#14141459",boxSelectionFill:"#f5a6231f",checkerLight:"#aaaaaa",checkerDark:"#777777",widgetBorder:"#111216",rampBorder:"#111111",resourceBackground:"#202228"
|
|
||||||
};
|
|
||||||
export const styles = { resource:{header:"#3977a8"}, pass:{header:"#426b43"}, output:{header:"#a75d37"} };
|
|
||||||
const socket = (title, direction, type) => ({ title,direction,type,maxIncomingLinks:direction === "input" ? 1 : 0,visible:true,value:null,showValue:false });
|
|
||||||
const node = (title, style, sockets, parameters = {}) => ({ version:1,title,behavior:"standard",style,parameters,sockets,ui:[...Object.keys(parameters).map(parameter=>({kind:"parameter",parameter})),...Object.keys(sockets).map(socket=>({kind:"socket",socket}))],muteBypass:[],migrations:[] });
|
|
||||||
export const nodeDefinitions = {
|
|
||||||
surface_color: node("Surface Color", "resource", { surface:socket("Surface","output","surface") }),
|
|
||||||
depth32: node("Depth 32", "resource", { depth:socket("Depth","output","depth") }),
|
|
||||||
scene_forward: node("Scene Forward", "pass", { color:socket("Color","input","surface"),depth:socket("Depth","input","depth"),result:socket("Result","output","surface") }, {
|
|
||||||
clearColor:{type:"color",default:{kind:"color",value:[0,0,0,1]},minimum:0,maximum:1},
|
|
||||||
clearDepth:{type:"number",default:{kind:"number",value:1},minimum:0,maximum:1,step:0.01},
|
|
||||||
}),
|
|
||||||
present: node("Present", "output", { surface:socket("Surface","input","surface") }),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const descriptors = Object.freeze({
|
|
||||||
surface_color:{version:1,sockets:{surface:["output","surface"]},parameters:[]},
|
|
||||||
depth32:{version:1,sockets:{depth:["output","depth"]},parameters:[]},
|
|
||||||
scene_forward:{version:1,sockets:{color:["input","surface"],depth:["input","depth"],result:["output","surface"]},parameters:["clearColor","clearDepth"]},
|
|
||||||
present:{version:1,sockets:{surface:["input","surface"]},parameters:[]},
|
|
||||||
});
|
});
|
||||||
|
const o = (type) => ({ type });
|
||||||
|
const texture = {
|
||||||
|
residency: "transient",
|
||||||
|
texture: {
|
||||||
|
dimension: "d2",
|
||||||
|
format: "rgba16_float",
|
||||||
|
extent: {
|
||||||
|
kind: "surface_relative",
|
||||||
|
width: { numerator: 1, denominator: 1 },
|
||||||
|
height: { numerator: 1, denominator: 1 },
|
||||||
|
depthOrArrayLayers: 1,
|
||||||
|
},
|
||||||
|
mipLevelCount: 1,
|
||||||
|
sampleCount: 1,
|
||||||
|
viewFormats: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const semanticCatalog = Object.freeze({
|
||||||
|
surface_target: {
|
||||||
|
execution: "source",
|
||||||
|
inputs: {},
|
||||||
|
outputs: { surface: o("surface_target") },
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
texture_spec: {
|
||||||
|
execution: "source",
|
||||||
|
inputs: {},
|
||||||
|
outputs: { spec: o("texture_spec") },
|
||||||
|
parameters: structuredClone(texture),
|
||||||
|
},
|
||||||
|
scene_table: {
|
||||||
|
execution: "source",
|
||||||
|
inputs: {},
|
||||||
|
outputs: { scene: o("scene_table") },
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
local_aabb_buffer: {
|
||||||
|
execution: "source",
|
||||||
|
inputs: { scene: i("scene_table") },
|
||||||
|
outputs: { localAabbs: o("local_aabb_buffer") },
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
camera_frustum: {
|
||||||
|
execution: "source",
|
||||||
|
inputs: {},
|
||||||
|
outputs: { frustum: o("camera_frustum") },
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
visibility_flags: {
|
||||||
|
execution: "source",
|
||||||
|
inputs: { scene: i("scene_table") },
|
||||||
|
outputs: {
|
||||||
|
flags: {
|
||||||
|
...o("boolean_flag_buffer"),
|
||||||
|
authoringType: "visibility_flag_buffer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
frustum_cull: {
|
||||||
|
execution: "compute",
|
||||||
|
inputs: {
|
||||||
|
scene: i("scene_table"),
|
||||||
|
localAabbs: i("local_aabb_buffer"),
|
||||||
|
frustum: i("camera_frustum"),
|
||||||
|
},
|
||||||
|
outputs: {
|
||||||
|
flags: {
|
||||||
|
...o("boolean_flag_buffer"),
|
||||||
|
authoringType: "frustum_flag_buffer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
mesh_query: {
|
||||||
|
execution: "compute",
|
||||||
|
inputs: {
|
||||||
|
scene: i("scene_table"),
|
||||||
|
isVisible: i("boolean_flag_buffer", false, "visibility_flag_buffer"),
|
||||||
|
isFrustumCulled: i("boolean_flag_buffer", false, "frustum_flag_buffer"),
|
||||||
|
},
|
||||||
|
outputs: { draws: o("draw_stream") },
|
||||||
|
parameters: {
|
||||||
|
filters: [
|
||||||
|
{ flag: "isVisible", predicate: "required_true" },
|
||||||
|
{ flag: "isFrustumCulled", predicate: "required_false" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
depth_stencil_config: {
|
||||||
|
execution: "source",
|
||||||
|
inputs: {},
|
||||||
|
outputs: { config: o("depth_stencil_config") },
|
||||||
|
parameters: {
|
||||||
|
depthCompare: "less_equal",
|
||||||
|
depthWriteEnabled: true,
|
||||||
|
clearDepth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legacy_forward: {
|
||||||
|
execution: "render",
|
||||||
|
inputs: {
|
||||||
|
scene: i("scene_table"),
|
||||||
|
draws: i("draw_stream"),
|
||||||
|
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||||
|
depthTarget: i(oneOf("texture_spec", "texture")),
|
||||||
|
depthStencil: i("depth_stencil_config"),
|
||||||
|
},
|
||||||
|
outputs: { color: o("texture"), depth: o("texture") },
|
||||||
|
parameters: { clearColor: [0.015, 0.02, 0.03, 1] },
|
||||||
|
},
|
||||||
|
fullscreen_copy: {
|
||||||
|
execution: "render",
|
||||||
|
inputs: {
|
||||||
|
source: i("texture"),
|
||||||
|
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||||
|
},
|
||||||
|
outputs: { color: o("texture") },
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
tone_map: {
|
||||||
|
execution: "render",
|
||||||
|
inputs: {
|
||||||
|
source: i("texture"),
|
||||||
|
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||||
|
},
|
||||||
|
outputs: { color: o("texture") },
|
||||||
|
parameters: { exposure: 1 },
|
||||||
|
},
|
||||||
|
bloom_extract: {
|
||||||
|
execution: "render",
|
||||||
|
inputs: {
|
||||||
|
source: i("texture"),
|
||||||
|
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||||
|
},
|
||||||
|
outputs: { color: o("texture") },
|
||||||
|
parameters: { threshold: 1, knee: 0.5 },
|
||||||
|
},
|
||||||
|
bloom_blur: {
|
||||||
|
execution: "render",
|
||||||
|
inputs: {
|
||||||
|
source: i("texture"),
|
||||||
|
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||||
|
},
|
||||||
|
outputs: { color: o("texture") },
|
||||||
|
parameters: { direction: [1, 0], radius: 1 },
|
||||||
|
},
|
||||||
|
bloom_composite: {
|
||||||
|
execution: "render",
|
||||||
|
inputs: {
|
||||||
|
source: i("texture"),
|
||||||
|
bloom: i("texture"),
|
||||||
|
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||||
|
},
|
||||||
|
outputs: { color: o("texture") },
|
||||||
|
parameters: { intensity: 1 },
|
||||||
|
},
|
||||||
|
luminance_edge: {
|
||||||
|
execution: "render",
|
||||||
|
inputs: {
|
||||||
|
source: i("texture"),
|
||||||
|
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||||
|
},
|
||||||
|
outputs: { color: o("texture") },
|
||||||
|
parameters: { strength: 2 },
|
||||||
|
},
|
||||||
|
present: {
|
||||||
|
execution: "present",
|
||||||
|
inputs: { surface: i("texture") },
|
||||||
|
outputs: {},
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const socketColors = [
|
||||||
|
"#d17c7c",
|
||||||
|
"#d19e7c",
|
||||||
|
"#d1c77c",
|
||||||
|
"#9ed17c",
|
||||||
|
"#7cd1a5",
|
||||||
|
"#7ccbd1",
|
||||||
|
"#7c98d1",
|
||||||
|
"#a27cd1",
|
||||||
|
"#d17cb8",
|
||||||
|
];
|
||||||
|
export const socketTypes = Object.fromEntries(
|
||||||
|
[
|
||||||
|
"surface_target",
|
||||||
|
"texture_spec",
|
||||||
|
"texture",
|
||||||
|
"scene_table",
|
||||||
|
"local_aabb_buffer",
|
||||||
|
"camera_frustum",
|
||||||
|
"boolean_flag_buffer",
|
||||||
|
"draw_stream",
|
||||||
|
"depth_stencil_config",
|
||||||
|
"visibility_flag_buffer",
|
||||||
|
"frustum_flag_buffer",
|
||||||
|
].map((type, index) => [
|
||||||
|
type,
|
||||||
|
{
|
||||||
|
title: type.replaceAll("_", " "),
|
||||||
|
color: socketColors[index % socketColors.length],
|
||||||
|
acceptsFrom: [type],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
socketTypes.surface_target.acceptsFrom = [
|
||||||
|
"surface_target",
|
||||||
|
"texture_spec",
|
||||||
|
"texture",
|
||||||
|
];
|
||||||
|
socketTypes.texture_spec.acceptsFrom = ["texture_spec", "texture"];
|
||||||
|
socketTypes.boolean_flag_buffer.acceptsFrom = [
|
||||||
|
"boolean_flag_buffer",
|
||||||
|
"visibility_flag_buffer",
|
||||||
|
"frustum_flag_buffer",
|
||||||
|
];
|
||||||
|
export const theme = {
|
||||||
|
background: "#151820",
|
||||||
|
grid: "#292e3a",
|
||||||
|
frame: "#30343a80",
|
||||||
|
frameHeader: "#59616c",
|
||||||
|
body: "#292e39",
|
||||||
|
control: "#24272b",
|
||||||
|
controlFill: "#4775b8",
|
||||||
|
controlEditing: "#181a1d",
|
||||||
|
textSelection: "#4775b8",
|
||||||
|
outline: "#0b0d12",
|
||||||
|
text: "#edf1f7",
|
||||||
|
muted: "#969eaa",
|
||||||
|
shadow: "#00000088",
|
||||||
|
nodeSelected: "#ff9f43",
|
||||||
|
nodeActive: "#ffffff",
|
||||||
|
unknownHeader: "#555b64",
|
||||||
|
unknownSocket: "#999999",
|
||||||
|
linkMuted: "#d94b4b",
|
||||||
|
knifeMuted: "#e85b5b",
|
||||||
|
emphasis: "#ffffff",
|
||||||
|
focus: "#f5a623",
|
||||||
|
editOutline: "#666a70",
|
||||||
|
resize: "#8b8e95",
|
||||||
|
muteOverlay: "#14141459",
|
||||||
|
boxSelectionFill: "#f5a6231f",
|
||||||
|
checkerLight: "#aaaaaa",
|
||||||
|
checkerDark: "#777777",
|
||||||
|
widgetBorder: "#111216",
|
||||||
|
rampBorder: "#111111",
|
||||||
|
resourceBackground: "#202228",
|
||||||
|
};
|
||||||
|
export const styles = {
|
||||||
|
source: { header: "#3977a8" },
|
||||||
|
compute: { header: "#725a9b" },
|
||||||
|
render: { header: "#426b43" },
|
||||||
|
present: { header: "#a75d37" },
|
||||||
|
};
|
||||||
|
const socket = (title, direction, type) => ({
|
||||||
|
title,
|
||||||
|
direction,
|
||||||
|
type,
|
||||||
|
maxIncomingLinks: direction === "input" ? 1 : 0,
|
||||||
|
visible: true,
|
||||||
|
value: null,
|
||||||
|
showValue: false,
|
||||||
|
});
|
||||||
|
const parameterSchema = (value) =>
|
||||||
|
typeof value === "number"
|
||||||
|
? { type: "number", default: { kind: "number", value } }
|
||||||
|
: typeof value === "string"
|
||||||
|
? { type: "string", default: { kind: "string", value } }
|
||||||
|
: typeof value === "boolean"
|
||||||
|
? { type: "boolean", default: { kind: "boolean", value } }
|
||||||
|
: { type: "json", default: { kind: "json", value } };
|
||||||
|
export const nodeDefinitions = Object.fromEntries(
|
||||||
|
Object.entries(semanticCatalog).map(([key, c]) => {
|
||||||
|
const sockets = {
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(c.inputs).map(([n, v]) => [
|
||||||
|
n,
|
||||||
|
socket(n, "input", v.authoringType ?? v.accepted.types[0]),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(c.outputs).map(([n, v]) => [
|
||||||
|
n,
|
||||||
|
socket(n, "output", v.authoringType ?? v.type),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
parameters = Object.fromEntries(
|
||||||
|
Object.entries(c.parameters).map(([name, value]) => [
|
||||||
|
name,
|
||||||
|
parameterSchema(value),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
title: key.replaceAll("_", " "),
|
||||||
|
behavior: "standard",
|
||||||
|
style: c.execution,
|
||||||
|
parameters,
|
||||||
|
sockets,
|
||||||
|
ui: [
|
||||||
|
...Object.keys(parameters).map((parameter) => ({
|
||||||
|
kind: "parameter",
|
||||||
|
parameter,
|
||||||
|
})),
|
||||||
|
...Object.keys(sockets).map((socket) => ({ kind: "socket", socket })),
|
||||||
|
],
|
||||||
|
muteBypass: [],
|
||||||
|
migrations: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
export const fxNodeComposition = Object.freeze({
|
||||||
|
schemaVersion: 2,
|
||||||
|
id: "yawn.render-graph",
|
||||||
|
version: CATALOG_VERSION,
|
||||||
|
compatibility: { wildcardInputTypes: [] },
|
||||||
|
socketTypes,
|
||||||
|
nodeStyles: styles,
|
||||||
|
resources: {},
|
||||||
|
theme,
|
||||||
|
nodes: nodeDefinitions,
|
||||||
|
});
|
||||||
|
export const descriptors = Object.fromEntries(
|
||||||
|
Object.entries(semanticCatalog).map(([key, c]) => [
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
inputs: c.inputs,
|
||||||
|
outputs: c.outputs,
|
||||||
|
parameters: c.parameters,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,25 +1,155 @@
|
|||||||
import { createFxNode } from "@fxnode/index.ts";
|
import { createFxNode } from "@fxnode/index.ts";
|
||||||
import { CATALOG_VERSION, GRAPH_ID, nodeDefinitions, socketTypes, styles, theme } from "./catalog.js";
|
import { CATALOG_VERSION, GRAPH_ID, fxNodeComposition } from "./catalog.js";
|
||||||
import { prepareBrowserHost } from "./browser-host.js";
|
import { prepareBrowserHost } from "./browser-host.js";
|
||||||
|
import { createAddNodeMenu } from "./add-node-menu.js";
|
||||||
|
import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.js";
|
||||||
|
|
||||||
const spec = [
|
const spec = [
|
||||||
["surface","surface_color",{x:40,y:100}],
|
["surface", "surface_target", { x: 40, y: 40 }],
|
||||||
["depth","depth32",{x:40,y:330}],
|
["hdr", "texture_spec", { x: 40, y: 170 }],
|
||||||
["forward","scene_forward",{x:360,y:190}],
|
["depth", "texture_spec", { x: 40, y: 300 }],
|
||||||
["present","present",{x:700,y:220}],
|
["scene", "scene_table", { x: 40, y: 470 }],
|
||||||
|
["aabbs", "local_aabb_buffer", { x: 290, y: 430 }],
|
||||||
|
["frustum", "camera_frustum", { x: 290, y: 590 }],
|
||||||
|
["visible", "visibility_flags", { x: 290, y: 300 }],
|
||||||
|
["cull", "frustum_cull", { x: 540, y: 480 }],
|
||||||
|
["query", "mesh_query", { x: 790, y: 330 }],
|
||||||
|
["depth_config", "depth_stencil_config", { x: 790, y: 620 }],
|
||||||
|
["forward", "legacy_forward", { x: 1040, y: 290 }],
|
||||||
|
["copy", "fullscreen_copy", { x: 1300, y: 250 }],
|
||||||
|
["present", "present", { x: 1540, y: 250 }],
|
||||||
];
|
];
|
||||||
async function seed(root) {
|
async function seed(root) {
|
||||||
await root.setState({graphId:GRAPH_ID,catalogVersion:CATALOG_VERSION,nodes:[],links:[],metadata:{}});
|
await root.setState({
|
||||||
for(const [nodeId,nodeType,position] of spec) await root.dispatch({type:"node.add",nodeId,nodeType,position});
|
graphId: GRAPH_ID,
|
||||||
for(const link of [
|
catalogVersion: CATALOG_VERSION,
|
||||||
{id:"surface_link",fromNodeId:"surface",fromSocketId:"surface:surface",toNodeId:"forward",toSocketId:"forward:color",muted:false,extensions:{}},
|
nodes: [],
|
||||||
{id:"depth_link",fromNodeId:"depth",fromSocketId:"depth:depth",toNodeId:"forward",toSocketId:"forward:depth",muted:false,extensions:{}},
|
links: [],
|
||||||
{id:"present_link",fromNodeId:"forward",fromSocketId:"forward:result",toNodeId:"present",toSocketId:"present:surface",muted:false,extensions:{}},
|
metadata: {},
|
||||||
]) await root.dispatch({type:"link.add",link});
|
});
|
||||||
|
for (const [nodeId, nodeType, position] of spec)
|
||||||
|
await root.dispatch({ type: "node.add", nodeId, nodeType, position });
|
||||||
|
const links = [
|
||||||
|
["scene", "scene", "aabbs", "scene"],
|
||||||
|
["scene", "scene", "visible", "scene"],
|
||||||
|
["scene", "scene", "cull", "scene"],
|
||||||
|
["aabbs", "localAabbs", "cull", "localAabbs"],
|
||||||
|
["frustum", "frustum", "cull", "frustum"],
|
||||||
|
["scene", "scene", "query", "scene"],
|
||||||
|
["visible", "flags", "query", "isVisible"],
|
||||||
|
["cull", "flags", "query", "isFrustumCulled"],
|
||||||
|
["scene", "scene", "forward", "scene"],
|
||||||
|
["query", "draws", "forward", "draws"],
|
||||||
|
["hdr", "spec", "forward", "colorTarget"],
|
||||||
|
["depth", "spec", "forward", "depthTarget"],
|
||||||
|
["depth_config", "config", "forward", "depthStencil"],
|
||||||
|
["forward", "color", "copy", "source"],
|
||||||
|
["surface", "surface", "copy", "colorTarget"],
|
||||||
|
["copy", "color", "present", "surface"],
|
||||||
|
];
|
||||||
|
for (const [a, as, b, bs] of links) {
|
||||||
|
const id = `${a}_${as}_${b}_${bs}`;
|
||||||
|
await root.dispatch({
|
||||||
|
type: "link.add",
|
||||||
|
link: {
|
||||||
|
id,
|
||||||
|
fromNodeId: a,
|
||||||
|
fromSocketId: `${a}:${as}`,
|
||||||
|
toNodeId: b,
|
||||||
|
toSocketId: `${b}:${bs}`,
|
||||||
|
muted: false,
|
||||||
|
extensions: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const authored = await root.getState(),
|
||||||
|
depth = authored.nodes.find((node) => node.id === "depth");
|
||||||
|
depth.parameters.texture = {
|
||||||
|
kind: "json",
|
||||||
|
value: {
|
||||||
|
dimension: "d2",
|
||||||
|
format: "depth32_float",
|
||||||
|
extent: {
|
||||||
|
kind: "surface_relative",
|
||||||
|
width: { numerator: 1, denominator: 1 },
|
||||||
|
height: { numerator: 1, denominator: 1 },
|
||||||
|
depthOrArrayLayers: 1,
|
||||||
|
},
|
||||||
|
mipLevelCount: 1,
|
||||||
|
sampleCount: 1,
|
||||||
|
viewFormats: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await root.setState(authored);
|
||||||
}
|
}
|
||||||
export async function createRenderGraphEditor(canvas) {
|
export async function createRenderGraphEditor(canvas) {
|
||||||
const chooseNodeType=()=>{const value=canvas.ownerDocument.defaultView?.prompt(`Node type: ${Object.keys(nodeDefinitions).join(", ")}`,"scene_forward");return Object.hasOwn(nodeDefinitions,value)?value:null};
|
const allocateId = createNodeIdAllocator();
|
||||||
const host=prepareBrowserHost(canvas,{chooseNodeType});let root,view,destroying;
|
let root, view, menu, destroying, dead = false;
|
||||||
const destroy=()=>destroying??=(async()=>{host.destroy();try{await view?.detach()}finally{root?.destroy();view=undefined;root=undefined}})();
|
const requestAddNode = Object.assign(async (request, point, isCurrent = () => true) => {
|
||||||
try{root=await createFxNode({applicationId:"yawn.render-graph",applicationVersion:1,resources:{}});await root.setTheme(theme);await root.setHeaderStyles(styles);for(const entry of Object.entries(socketTypes))await root.composeSocket(...entry);for(const entry of Object.entries(nodeDefinitions))await root.composeNode(...entry);await seed(root);view=await root.attachView({canvas,viewport:host.initialViewport,initialCamera:{center:{x:470,y:210},zoom:.5}});host.attach(root,view);await view.whenRendered();return {getState:()=>root.getState(),onSnapshots:fn=>root.onSnapshots(fn),whenRendered:()=>view.whenRendered(),destroy};}catch(e){await destroy().catch(()=>{});throw e}
|
let typeId;
|
||||||
|
try { typeId = await menu?.open(point); } catch (error) { if (!dead && isCurrent()) console.error(error); return; }
|
||||||
|
if (dead || !isCurrent() || !root || !view) return;
|
||||||
|
const alive = () => !dead && isCurrent();
|
||||||
|
try { await spawnRequestedNode(root, view, request, typeId, allocateId, alive); } catch (error) { if (!dead) console.error(error); }
|
||||||
|
}, { close: () => menu?.close() });
|
||||||
|
const host = prepareBrowserHost(canvas, { requestAddNode });
|
||||||
|
const destroy = () =>
|
||||||
|
(destroying ??= (async () => {
|
||||||
|
dead = true;
|
||||||
|
host.destroy();
|
||||||
|
menu?.destroy();
|
||||||
|
try {
|
||||||
|
await view?.detach();
|
||||||
|
} finally {
|
||||||
|
root?.destroy();
|
||||||
|
view = undefined;
|
||||||
|
root = undefined;
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
try {
|
||||||
|
root = await createFxNode({
|
||||||
|
applicationId: "yawn.render-graph",
|
||||||
|
applicationVersion: CATALOG_VERSION,
|
||||||
|
resources: {},
|
||||||
|
});
|
||||||
|
await root.loadComposition(fxNodeComposition);
|
||||||
|
await seed(root);
|
||||||
|
view = await root.attachView({
|
||||||
|
canvas,
|
||||||
|
viewport: host.initialViewport,
|
||||||
|
initialCamera: { center: { x: 780, y: 340 }, zoom: 0.34 },
|
||||||
|
});
|
||||||
|
menu = createAddNodeMenu(canvas.ownerDocument);
|
||||||
|
host.attach(root, view);
|
||||||
|
await view.whenRendered();
|
||||||
|
return createEditorFacade(root, view, { requestAddNode, destroy });
|
||||||
|
} catch (e) {
|
||||||
|
await destroy().catch(() => {});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the small application-facing wrapper around an fxnode root and view. */
|
||||||
|
export function createEditorFacade(
|
||||||
|
root,
|
||||||
|
view,
|
||||||
|
{ requestAddNode = () => null, destroy = async () => {} } = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
getState: () => root.getState(),
|
||||||
|
getSaveData: () => root.getSaveData(),
|
||||||
|
load: async (data) => {
|
||||||
|
await root.load(data);
|
||||||
|
await view.whenRendered();
|
||||||
|
},
|
||||||
|
loadComposition: async (data) => {
|
||||||
|
await root.loadComposition(data);
|
||||||
|
await view.whenRendered();
|
||||||
|
},
|
||||||
|
onSnapshots: (fn) =>
|
||||||
|
root.onSnapshots((event) => fn(event.snapshot, event.version)),
|
||||||
|
requestAddNode,
|
||||||
|
whenRendered: () => view.whenRendered(),
|
||||||
|
destroy,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,256 @@
|
|||||||
import { semanticProjectionToV1 } from "./adapter.js";
|
import { semanticProjectionToV1 } from "./adapter.js";
|
||||||
const make=(graphId,clearColor)=>Object.freeze(semanticProjectionToV1({graphId,clearColor,clearDepth:1,passState:"enabled"},1));
|
const make = (graphId, clearColor) =>
|
||||||
|
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 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 ember = make("preset_ember", [0.18, 0.035, 0.012, 1]);
|
||||||
export const renderGraphPresets=Object.freeze({midnight,ember});
|
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,
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class RendererError extends Error {
|
|||||||
export class RendererClient {
|
export class RendererClient {
|
||||||
#bridge; #worker; #header; #slots; #buffer; #next = 1; #payload = 1;
|
#bridge; #worker; #header; #slots; #buffer; #next = 1; #payload = 1;
|
||||||
#pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false;
|
#pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false;
|
||||||
#telemetry; #stopped = false;
|
#telemetry; #profile; #stopped = false;
|
||||||
#graphQueue = []; #graphBusy = false;
|
#graphQueue = []; #graphBusy = false;
|
||||||
#bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map();
|
#bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map();
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ export class RendererClient {
|
|||||||
|
|
||||||
get ready() { return this.#ready; }
|
get ready() { return this.#ready; }
|
||||||
get telemetry() { return this.#telemetry; }
|
get telemetry() { return this.#telemetry; }
|
||||||
|
get profile() { return this.#profile; }
|
||||||
|
|
||||||
#refreshViews() {
|
#refreshViews() {
|
||||||
const buffer = this.#bridge.memory.buffer;
|
const buffer = this.#bridge.memory.buffer;
|
||||||
@@ -61,6 +62,9 @@ export class RendererClient {
|
|||||||
} else if (message?.type === "telemetry") {
|
} else if (message?.type === "telemetry") {
|
||||||
this.#telemetry = message;
|
this.#telemetry = message;
|
||||||
dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
|
dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
|
||||||
|
} else if (message?.type === "profile-snapshot") {
|
||||||
|
this.#profile = message;
|
||||||
|
dispatchEvent(new CustomEvent("renderer-profile", { detail: message }));
|
||||||
} else if (message?.type === "fatal") {
|
} else if (message?.type === "fatal") {
|
||||||
console.error("renderer worker fatal", message.code, message.message);
|
console.error("renderer worker fatal", message.code, message.message);
|
||||||
this.#fail(message.code || "WORKER_FATAL");
|
this.#fail(message.code || "WORKER_FATAL");
|
||||||
|
|||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createCubeGeometry, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, LoadoutError } from "../static/demo-loadouts.js";
|
import { createCubeGeometry, createMaterialGalleryGlb, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, loadouts, LoadoutError } from "../static/demo-loadouts.js";
|
||||||
|
|
||||||
function parseGlb(buffer){const view=new DataView(buffer);assert.equal(view.getUint32(0,true),0x46546c67);assert.equal(view.getUint32(4,true),2);assert.equal(view.getUint32(8,true),buffer.byteLength);const length=view.getUint32(12,true);assert.equal(view.getUint32(16,true),0x4e4f534a);const json=JSON.parse(new TextDecoder().decode(new Uint8Array(buffer,20,length)).trim());const bin=20+length;assert.equal(view.getUint32(bin+4,true),0x004e4942);assert.equal(view.getUint32(bin,true),json.buffers[0].byteLength);return json;}
|
function parseGlb(buffer){const view=new DataView(buffer);assert.equal(view.getUint32(0,true),0x46546c67);assert.equal(view.getUint32(4,true),2);assert.equal(view.getUint32(8,true),buffer.byteLength);const length=view.getUint32(12,true);assert.equal(view.getUint32(16,true),0x4e4f534a);const json=JSON.parse(new TextDecoder().decode(new Uint8Array(buffer,20,length)).trim());const bin=20+length;assert.equal(view.getUint32(bin+4,true),0x004e4942);assert.equal(view.getUint32(bin,true),json.buffers[0].byteLength);return json;}
|
||||||
test("procedural cube and sphere have complete indexed vertex streams",()=>{const cube=createCubeGeometry(),sphere=createUvSphereGeometry();assert.deepEqual([cube.positions.length/3,cube.indices.length],[24,36]);assert.ok(sphere.positions.length/3>300);for(const geometry of [cube,sphere]){assert.equal(geometry.normals.length,geometry.positions.length);assert.equal(geometry.texcoords.length,geometry.positions.length/3*2);assert.ok(geometry.indices.every(i=>i>=0&&i<geometry.positions.length/3));}});
|
test("procedural cube and sphere have complete indexed vertex streams",()=>{const cube=createCubeGeometry(),sphere=createUvSphereGeometry();assert.deepEqual([cube.positions.length/3,cube.indices.length],[24,36]);assert.ok(sphere.positions.length/3>300);for(const geometry of [cube,sphere]){assert.equal(geometry.normals.length,geometry.positions.length);assert.equal(geometry.texcoords.length,geometry.positions.length/3*2);assert.ok(geometry.indices.every(i=>i>=0&&i<geometry.positions.length/3));}});
|
||||||
test("every cube triangle has counter-clockwise outward winding",()=>{const g=createCubeGeometry();for(let i=0;i<g.indices.length;i+=3){const ids=g.indices.slice(i,i+3),p=ids.map(id=>g.positions.slice(id*3,id*3+3)),a=p[1].map((v,j)=>v-p[0][j]),b=p[2].map((v,j)=>v-p[0][j]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],normal=g.normals.slice(ids[0]*3,ids[0]*3+3);assert.ok(cross.reduce((sum,v,j)=>sum+v*normal[j],0)>0)}});
|
test("every cube triangle has counter-clockwise outward winding",()=>{const g=createCubeGeometry();for(let i=0;i<g.indices.length;i+=3){const ids=g.indices.slice(i,i+3),p=ids.map(id=>g.positions.slice(id*3,id*3+3)),a=p[1].map((v,j)=>v-p[0][j]),b=p[2].map((v,j)=>v-p[0][j]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],normal=g.normals.slice(ids[0]*3,ids[0]*3+3);assert.ok(cross.reduce((sum,v,j)=>sum+v*normal[j],0)>0)}});
|
||||||
test("loadouts return fresh deterministic GLBs with one mesh and nine nodes",async()=>{for(const name of ["cubes","spheres"]){const a=await loadDemoLoadout(name),b=await loadDemoLoadout(name);assert.notStrictEqual(a,b);assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.meshes.length,1);assert.equal(json.nodes.length,9);assert.ok(json.nodes.every(n=>n.mesh===0));assert.deepEqual(json.meshes[0].primitives[0].attributes,{POSITION:0,NORMAL:1,TEXCOORD_0:2});assert.equal(json.accessors[3].componentType,5125);}});
|
test("loadouts return fresh deterministic GLBs with one mesh and nine nodes",async()=>{for(const name of ["cubes","spheres"]){const a=await loadDemoLoadout(name),b=await loadDemoLoadout(name);assert.notStrictEqual(a,b);assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.meshes.length,1);assert.equal(json.nodes.length,9);assert.ok(json.nodes.every(n=>n.mesh===0));assert.deepEqual(json.meshes[0].primitives[0].attributes,{POSITION:0,NORMAL:1,TEXCOORD_0:2});assert.equal(json.accessors[3].componentType,5125);}});
|
||||||
|
test("Phase 6 gallery is deterministic and covers core PBR shader semantics",async()=>{const a=createMaterialGalleryGlb(),b=await loadDemoLoadout("materials");assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.asset.generator,"yawn-phase6-pbr-gallery");assert.deepEqual(json.extensionsUsed,["KHR_materials_ior"]);assert.equal(json.materials.length,16);assert.equal(json.nodes.length,16);assert.equal(json.images.length,3);assert.ok(json.images.every(image=>image.mimeType==="image/png"&&image.bufferView!==undefined));assert.ok(json.meshes.every(mesh=>JSON.stringify(mesh.primitives[0].attributes)===JSON.stringify({POSITION:0,NORMAL:1,TEXCOORD_0:2})));assert.deepEqual(json.materials.slice(0,4).map(x=>x.pbrMetallicRoughness.roughnessFactor),[.08,.3,.6,1]);assert.ok(json.materials.slice(0,4).every(x=>x.pbrMetallicRoughness.metallicFactor===0));assert.ok(json.materials.slice(4,8).every(x=>x.pbrMetallicRoughness.metallicFactor===1));assert.deepEqual(json.materials.slice(8,11).map(x=>x.extensions.KHR_materials_ior.ior),[1,1.5,2]);assert.equal(json.materials[11].normalTexture.index,2);assert.equal(json.materials[12].occlusionTexture.index,1);assert.equal(json.materials[13].emissiveTexture.index,0);assert.equal(json.materials[14].alphaMode,"MASK");assert.equal(json.materials[15].doubleSided,true);assert.ok(json.nodes[15].scale[0]<0);assert.equal(loadouts.materials.label,"Phase 6 deterministic PBR gallery");});
|
||||||
|
test("loadout dropdown preserves legacy scenes and exposes the material gallery",async()=>{const html=await (await import("node:fs/promises")).readFile(new URL("../static/index.html",import.meta.url),"utf8");for(const id of ["cubes","spheres","materials","manor","sponza"])assert.match(html,new RegExp(`<option value="${id}">`));});
|
||||||
test("LFS pointers and HTTP failures are explicit and stable",async()=>{const pointer=new TextEncoder().encode("version https://git-lfs.github.com/spec/v1\noid sha256:abc\n").buffer;assert.equal(isGitLfsPointer(pointer),true);await assert.rejects(loadDemoLoadout("manor",{fetchImpl:async()=>({ok:true,arrayBuffer:async()=>pointer})}),e=>e instanceof LoadoutError&&e.code==="LOADOUT_LFS_POINTER");await assert.rejects(loadDemoLoadout("sponza",{fetchImpl:async()=>({ok:false,status:404})}),e=>e.code==="LOADOUT_HTTP"&&/404/.test(e.message));});
|
test("LFS pointers and HTTP failures are explicit and stable",async()=>{const pointer=new TextEncoder().encode("version https://git-lfs.github.com/spec/v1\noid sha256:abc\n").buffer;assert.equal(isGitLfsPointer(pointer),true);await assert.rejects(loadDemoLoadout("manor",{fetchImpl:async()=>({ok:true,arrayBuffer:async()=>pointer})}),e=>e instanceof LoadoutError&&e.code==="LOADOUT_LFS_POINTER");await assert.rejects(loadDemoLoadout("sponza",{fetchImpl:async()=>({ok:false,status:404})}),e=>e.code==="LOADOUT_HTTP"&&/404/.test(e.message));});
|
||||||
test("GLB encoder rejects non-finite and out-of-range geometry",()=>{const valid=createCubeGeometry();assert.throws(()=>encodeGeometryGlb({...valid,positions:[...valid.positions.slice(0,-1),NaN]}),/Invalid/);assert.throws(()=>encodeGeometryGlb({...valid,indices:[...valid.indices,0,1,999]}),/Invalid/)});
|
test("GLB encoder rejects non-finite and out-of-range geometry",()=>{const valid=createCubeGeometry();assert.throws(()=>encodeGeometryGlb({...valid,positions:[...valid.positions.slice(0,-1),NaN]}),/Invalid/);assert.throws(()=>encodeGeometryGlb({...valid,indices:[...valid.indices,0,1,999]}),/Invalid/)});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,15 +1,289 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { adaptFxNodeSnapshot, AuthoringGraphError } from "../static/render-graph/adapter.js";
|
import {
|
||||||
|
adaptFxNodeSnapshot,
|
||||||
|
AuthoringGraphError,
|
||||||
|
getSourceMap,
|
||||||
|
mapAuthoringDiagnostic,
|
||||||
|
} from "../static/render-graph/adapter.js";
|
||||||
|
import { RendererError } from "../static/renderer-client.js";
|
||||||
|
import {
|
||||||
|
semanticCatalog,
|
||||||
|
nodeDefinitions,
|
||||||
|
GRAPH_ID,
|
||||||
|
CATALOG_VERSION,
|
||||||
|
socketTypes,
|
||||||
|
} from "../static/render-graph/catalog.js";
|
||||||
|
import { culling } from "../static/render-graph/presets.js";
|
||||||
import { AuthoringController } from "../static/render-graph/authoring-controller.js";
|
import { AuthoringController } from "../static/render-graph/authoring-controller.js";
|
||||||
|
function fixture() {
|
||||||
const sockets={surface_color:[["surface","surface:surface","output","surface"]],depth32:[["depth","depth:depth","output","depth"]],scene_forward:[["color","forward:color","input","surface"],["depth","forward:depth","input","depth"],["result","forward:result","output","surface"]],present:[["surface","present:surface","input","surface"]]};
|
const nodes = culling.nodes.map((n) => {
|
||||||
function fixture(){const nodes=Object.entries(sockets).map(([typeId,list],i)=>({id:["surface","depth","forward","present"][i],typeId,typeVersion:1,known:true,muted:false,parameters:typeId==="scene_forward"?{clearColor:{kind:"color",value:[.1,.2,.3,1]},clearDepth:{kind:"number",value:.5}}:{},sockets:list.map(([key,id,direction,dataType])=>({key,id,direction,dataType}))}));return {version:4,graphId:"demo_forward",catalogVersion:1,nodes,links:[{id:"l1",fromNodeId:"surface",fromSocketId:"surface:surface",toNodeId:"forward",toSocketId:"forward:color",muted:false},{id:"l2",fromNodeId:"depth",fromSocketId:"depth:depth",toNodeId:"forward",toSocketId:"forward:depth",muted:false},{id:"l3",fromNodeId:"forward",fromSocketId:"forward:result",toNodeId:"present",toSocketId:"present:surface",muted:false}],metadata:{layout:"ignored"}}}
|
const d = semanticCatalog[n.executor.key];
|
||||||
const rejects=(mutate,code)=>{const x=structuredClone(fixture());mutate(x);assert.throws(()=>adaptFxNodeSnapshot(x),e=>e instanceof AuthoringGraphError&&e.code===code)};
|
const definition = nodeDefinitions[n.executor.key];
|
||||||
test("adapter emits exact deterministic V1 without fxnode state",()=>{const a=adaptFxNodeSnapshot(fixture(),7),b=fixture();b.nodes.reverse();b.links.reverse();assert.deepEqual(adaptFxNodeSnapshot(b,7),a);assert.equal(a.revision,7);assert.deepEqual(a.passes[0].writes[0].access.load.value,[.1,.2,.3,1]);for(const token of ["position","socketId","known","fxnode"])assert.equal(JSON.stringify(a).includes(token),false)});
|
return {
|
||||||
test("adapter rejects node catalog, identity, sockets, links, topology and parameters",()=>{
|
id: n.id,
|
||||||
rejects(x=>x.nodes[0].known=false,"AUTHORING_NODE_UNKNOWN");rejects(x=>delete x.nodes[0].muted,"AUTHORING_NODE_MUTED");rejects(x=>x.nodes[0].muted=true,"AUTHORING_NODE_MUTED");rejects(x=>x.nodes[0].typeVersion=2,"AUTHORING_NODE_VERSION");rejects(x=>x.nodes[0].typeId="other","AUTHORING_NODE_TYPE");rejects(x=>x.nodes[0].id="bad id","AUTHORING_ID");rejects(x=>x.nodes[1].id=x.nodes[0].id,"AUTHORING_ID_DUPLICATE");rejects(x=>x.nodes[0].sockets[0].direction="input","AUTHORING_SOCKET");rejects(x=>x.links[0].muted=true,"AUTHORING_TOPOLOGY");rejects(x=>x.links.pop(),"AUTHORING_TOPOLOGY");rejects(x=>x.nodes[2].parameters.clearDepth.value=Infinity,"AUTHORING_PARAMETER");
|
typeId: n.executor.key,
|
||||||
|
typeVersion: 1,
|
||||||
|
known: true,
|
||||||
|
muted: n.state !== "enabled",
|
||||||
|
position: { x: 10, y: 20 },
|
||||||
|
size: { x: 200, y: 120 },
|
||||||
|
label: n.id,
|
||||||
|
collapsed: false,
|
||||||
|
extensions: {},
|
||||||
|
parameters: Object.fromEntries(
|
||||||
|
Object.entries(definition.parameters).map(([key, schema]) => [
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
kind: schema.type,
|
||||||
|
value: structuredClone(n.parameters[key] ?? schema.default.value),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
sockets: [
|
||||||
|
...Object.entries(d.inputs).map(([key, x]) => ({
|
||||||
|
key,
|
||||||
|
id: `${n.id}:${key}`,
|
||||||
|
direction: "input",
|
||||||
|
dataType: x.authoringType ?? x.accepted.types[0],
|
||||||
|
label: key,
|
||||||
|
accepts: socketTypes[x.authoringType ?? x.accepted.types[0]].acceptsFrom,
|
||||||
|
visible: true,
|
||||||
|
maxIncomingLinks: 1,
|
||||||
|
})),
|
||||||
|
...Object.entries(d.outputs).map(([key]) => ({
|
||||||
|
key,
|
||||||
|
id: `${n.id}:${key}`,
|
||||||
|
direction: "output",
|
||||||
|
dataType: definition.sockets[key].type,
|
||||||
|
label: key,
|
||||||
|
accepts: [],
|
||||||
|
visible: true,
|
||||||
|
maxIncomingLinks: 0,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const links = [];
|
||||||
|
for (const n of culling.nodes)
|
||||||
|
for (const [socket, from] of Object.entries(n.inputs))
|
||||||
|
links.push({
|
||||||
|
id: `l_${from.node}_${from.socket}_${n.id}_${socket}`,
|
||||||
|
fromNodeId: from.node,
|
||||||
|
fromSocketId: `${from.node}:${from.socket}`,
|
||||||
|
toNodeId: n.id,
|
||||||
|
toSocketId: `${n.id}:${socket}`,
|
||||||
|
muted: false,
|
||||||
|
extensions: {},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
graphId: GRAPH_ID,
|
||||||
|
catalogVersion: CATALOG_VERSION,
|
||||||
|
nodes,
|
||||||
|
links,
|
||||||
|
metadata: { layout: "ignored" },
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
test("catalog exhaustively mirrors all current V2 contracts", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(semanticCatalog).sort(),
|
||||||
|
[
|
||||||
|
"surface_target",
|
||||||
|
"texture_spec",
|
||||||
|
"scene_table",
|
||||||
|
"local_aabb_buffer",
|
||||||
|
"camera_frustum",
|
||||||
|
"visibility_flags",
|
||||||
|
"frustum_cull",
|
||||||
|
"mesh_query",
|
||||||
|
"depth_stencil_config",
|
||||||
|
"legacy_forward",
|
||||||
|
"fullscreen_copy",
|
||||||
|
"tone_map",
|
||||||
|
"bloom_extract",
|
||||||
|
"bloom_blur",
|
||||||
|
"bloom_composite",
|
||||||
|
"luminance_edge",
|
||||||
|
"present",
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
for (const c of Object.values(semanticCatalog)) {
|
||||||
|
assert.ok(c.execution);
|
||||||
|
assert.ok(c.inputs);
|
||||||
|
assert.ok(c.outputs);
|
||||||
|
assert.ok(c.parameters);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("adapter deterministically emits strict V2, permits repeated types, omits muted links and maps sources", () => {
|
||||||
|
const x = fixture(),
|
||||||
|
a = adaptFxNodeSnapshot(x, 7);
|
||||||
|
x.nodes.reverse();
|
||||||
|
x.links.reverse();
|
||||||
|
assert.deepEqual(adaptFxNodeSnapshot(x, 7), a);
|
||||||
|
assert.equal(a.schemaVersion, 2);
|
||||||
|
assert.equal(a.graphId, GRAPH_ID);
|
||||||
|
assert.equal(
|
||||||
|
a.nodes.filter((n) => n.executor.key === "texture_spec").length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
Object.values(getSourceMap(a)).some((source) => source.input === "source"),
|
||||||
|
);
|
||||||
|
x.links.find((l) => l.id === "l_forward_color_copy_source").muted = true;
|
||||||
|
assert.equal(
|
||||||
|
adaptFxNodeSnapshot(x, 8).nodes.find((n) => n.id === "copy").inputs.source,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("adapter rejects hostile shape, IDs, duplicates, catalog, sockets and type mismatches", () => {
|
||||||
|
const reject = (fn, code) => {
|
||||||
|
const x = fixture();
|
||||||
|
fn(x);
|
||||||
|
assert.throws(
|
||||||
|
() => adaptFxNodeSnapshot(x),
|
||||||
|
(e) => e instanceof AuthoringGraphError && e.code === code,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
reject((x) => (x.graphId = "bad"), "AUTHORING_CATALOG");
|
||||||
|
reject((x) => (x.nodes[0].id = "bad id"), "AUTHORING_ID");
|
||||||
|
reject((x) => (x.nodes[1].id = x.nodes[0].id), "AUTHORING_ID_DUPLICATE");
|
||||||
|
reject((x) => (x.nodes[0].typeId = "wat"), "AUTHORING_NODE_TYPE");
|
||||||
|
reject((x) => (x.nodes[0].sockets = []), "AUTHORING_SOCKET_SET");
|
||||||
|
reject((x) => (x.links[0].toSocketId = "missing:x"), "AUTHORING_LINK");
|
||||||
|
reject((x) => {
|
||||||
|
const link = x.links.find((l) => l.toSocketId === "copy:source");
|
||||||
|
link.fromNodeId = "scene";
|
||||||
|
link.fromSocketId = "scene:scene";
|
||||||
|
}, "AUTHORING_LINK_TYPE");
|
||||||
|
});
|
||||||
|
test("adapter counts only active incoming links and reports socket overflow", () => {
|
||||||
|
const x = fixture();
|
||||||
|
const active = x.links.find((link) => link.toSocketId === "copy:source");
|
||||||
|
x.links.push({ ...structuredClone(active), id: "muted_duplicate", muted: true });
|
||||||
|
assert.doesNotThrow(() => adaptFxNodeSnapshot(x));
|
||||||
|
x.links.push({ ...structuredClone(active), id: "active_overflow" });
|
||||||
|
assert.throws(
|
||||||
|
() => adaptFxNodeSnapshot(x),
|
||||||
|
(error) => error.code === "AUTHORING_LINK_INCOMING" && error.details.socketId === "copy:source",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("source map covers Rust fields, nested values, every input and is deeply frozen", () => {
|
||||||
|
const snapshot = fixture();
|
||||||
|
snapshot.links.find((link) => link.toSocketId === "copy:source").muted = true;
|
||||||
|
const ir = adaptFxNodeSnapshot(snapshot, 9), map = getSourceMap(ir);
|
||||||
|
for (const path of ["schemaVersion", "graphId", "revision", "nodes", "nodes[0].id", "nodes[0].state", "nodes[0].executor.key", "nodes[0].executor.version", "nodes[0].parameters", "nodes[0].inputs"])
|
||||||
|
assert.ok(map[path], path);
|
||||||
|
for (const [index, node] of ir.nodes.entries())
|
||||||
|
for (const input of Object.keys(semanticCatalog[node.executor.key].inputs))
|
||||||
|
assert.ok(map[`nodes[${index}].inputs.${input}`]);
|
||||||
|
assert.ok(Object.keys(map).some((path) => /parameters\..+\[|parameters\..+\..+/.test(path)));
|
||||||
|
const socket = Object.values(map).find((source) => source.kind === "socket");
|
||||||
|
const unconnected = Object.values(map).find((source) => source.unconnected === true);
|
||||||
|
const link = Object.values(map).find((source) => source.kind === "link");
|
||||||
|
assert.ok(socket?.socketId && unconnected?.socketId);
|
||||||
|
assert.equal(ir.nodes.find((node) => node.id === "copy").inputs.source, undefined);
|
||||||
|
for (const field of ["linkId", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted"])
|
||||||
|
assert.ok(Object.hasOwn(link, field), field);
|
||||||
|
assert.ok(Object.isFrozen(map) && Object.isFrozen(link));
|
||||||
|
});
|
||||||
|
test("diagnostic mapper creates a frozen RendererError DTO with fallbacks and prefix matching", () => {
|
||||||
|
const ir = adaptFxNodeSnapshot(fixture());
|
||||||
|
const original = new RendererError("GRAPH_INPUT", { message: "bad", field: "nodes[0].executor.key.more", nested: { x: 1 } });
|
||||||
|
const mapped = mapAuthoringDiagnostic(ir, original);
|
||||||
|
assert.notStrictEqual(mapped, original);
|
||||||
|
assert.equal(mapped.code, original.code);
|
||||||
|
assert.equal(mapped.source.kind, "node");
|
||||||
|
assert.equal(mapped.diagnostic, undefined);
|
||||||
|
assert.ok(Object.isFrozen(mapped) && Object.isFrozen(mapped.details.nested));
|
||||||
|
assert.equal(Object.isFrozen(original), false);
|
||||||
|
original.details.nested.x = 2;
|
||||||
|
assert.equal(mapped.details.nested.x, 1);
|
||||||
|
const unmatchedOriginal = new RendererError("GRAPH_INPUT", {
|
||||||
|
message: "unmapped",
|
||||||
|
path: "resources[0]",
|
||||||
|
});
|
||||||
|
const unmatched = mapAuthoringDiagnostic(ir, unmatchedOriginal);
|
||||||
|
assert.notStrictEqual(unmatched, unmatchedOriginal);
|
||||||
|
assert.equal(unmatched.source, undefined);
|
||||||
|
assert.ok(Object.isFrozen(unmatched) && Object.isFrozen(unmatched.details));
|
||||||
|
assert.equal(Object.isFrozen(unmatchedOriginal), false);
|
||||||
|
});
|
||||||
|
test("controller keeps last-good through failures and only drops after successful switch", async () => {
|
||||||
|
let fail = false;
|
||||||
|
const calls = [];
|
||||||
|
const renderer = {
|
||||||
|
compileGraph: async (ir) => ({
|
||||||
|
compiledId: [ir.revision, 1],
|
||||||
|
revision: ir.revision,
|
||||||
|
}),
|
||||||
|
switchCompiledGraph: async (id) => {
|
||||||
|
calls.push(["switch", id]);
|
||||||
|
if (fail) throw Error("switch");
|
||||||
|
},
|
||||||
|
dropCompiledGraph: async (id) => calls.push(["drop", id]),
|
||||||
|
};
|
||||||
|
const c = new AuthoringController({
|
||||||
|
renderer,
|
||||||
|
adapt: (_, revision) => ({ revision }),
|
||||||
|
});
|
||||||
|
c.markDirty({});
|
||||||
|
await c.apply();
|
||||||
|
fail = true;
|
||||||
|
c.markDirty({});
|
||||||
|
await assert.rejects(c.apply());
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
["switch", [1, 1]],
|
||||||
|
["switch", [2, 1]],
|
||||||
|
]);
|
||||||
|
fail = false;
|
||||||
|
await c.apply();
|
||||||
|
assert.deepEqual(calls.at(-1), ["drop", [1, 1]]);
|
||||||
|
await c.destroy();
|
||||||
|
});
|
||||||
|
test("controller shares in-flight apply", async () => {
|
||||||
|
let release;
|
||||||
|
const gate = new Promise((r) => (release = r));
|
||||||
|
const c = new AuthoringController({
|
||||||
|
adapt: (_, revision) => ({ revision }),
|
||||||
|
renderer: {
|
||||||
|
compileGraph: async (ir) => {
|
||||||
|
await gate;
|
||||||
|
return { compiledId: [ir.revision, 1], revision: ir.revision };
|
||||||
|
},
|
||||||
|
switchCompiledGraph: async () => {},
|
||||||
|
dropCompiledGraph: async () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
c.markDirty({});
|
||||||
|
const a = c.apply();
|
||||||
|
assert.strictEqual(c.apply(), a);
|
||||||
|
release();
|
||||||
|
await a;
|
||||||
|
});
|
||||||
|
test("controller retains mapped diagnostic while apply rejects the original and subscriptions agree", async () => {
|
||||||
|
const original = new RendererError("GRAPH_BAD", { path: "nodes[0].id", message: "bad" });
|
||||||
|
const states = [];
|
||||||
|
const c = new AuthoringController({
|
||||||
|
adapt: (snapshot, revision) => adaptFxNodeSnapshot(snapshot, revision),
|
||||||
|
renderer: { compileGraph: async () => { throw original; }, switchCompiledGraph: async () => {}, dropCompiledGraph: async () => {} },
|
||||||
|
});
|
||||||
|
c.subscribe((state) => states.push(state));
|
||||||
|
c.markDirty(fixture());
|
||||||
|
await assert.rejects(c.apply(), (error) => error === original);
|
||||||
|
assert.notStrictEqual(states.at(-1).error, original);
|
||||||
|
let subscribed;
|
||||||
|
c.subscribe((state) => { subscribed = state; })();
|
||||||
|
assert.strictEqual(subscribed.error, states.at(-1).error);
|
||||||
|
await c.destroy();
|
||||||
|
});
|
||||||
|
test("apply after destroy does not compile and destroy returns one strict promise", async () => {
|
||||||
|
let compiles = 0;
|
||||||
|
const c = new AuthoringController({ adapt: () => ({}), renderer: { compileGraph: async () => { compiles++; }, dropCompiledGraph: async () => {}, switchCompiledGraph: async () => {} } });
|
||||||
|
c.markDirty({});
|
||||||
|
const first = c.destroy();
|
||||||
|
assert.strictEqual(c.destroy(), first);
|
||||||
|
assert.strictEqual(await c.apply(), null);
|
||||||
|
await first;
|
||||||
|
assert.equal(compiles, 0);
|
||||||
});
|
});
|
||||||
test("adapter carries scene mute and bounds revisions",()=>{const x=fixture();x.nodes[2].muted=true;assert.equal(adaptFxNodeSnapshot(x).passes[0].state,"disabled");assert.throws(()=>adaptFxNodeSnapshot(fixture(),0x100000000),e=>e.code==="AUTHORING_REVISION")});
|
|
||||||
test("controller orders compile/switch, revisions and shares one in-flight apply",async()=>{let release;const gate=new Promise(r=>release=r),calls=[];const renderer={async compileGraph(ir){calls.push(`compile:${ir.revision}`);await gate;return{compiledId:[0,1]}},async switchCompiledGraph(id){calls.push(`switch:${id}`)}};const c=new AuthoringController({renderer,getState:async()=>({})});const adapt=(_,r)=>({revision:r}),a=c.apply(adapt);assert.strictEqual(c.apply(adapt),a);c.markDirty();release();await a;assert.deepEqual(calls,["compile:1","switch:0,1"]);assert.equal(c.revision,1);assert.equal(c.dirty,true)});
|
|
||||||
test("controller reserves revisions across compile and switch failures",async()=>{let failure="compile",revisions=[];const c=new AuthoringController({getState:async()=>({}),renderer:{compileGraph:async ir=>{revisions.push(ir.revision);if(failure==="compile")throw Error("no");return{compiledId:[0,1]}},switchCompiledGraph:async()=>{if(failure==="switch")throw Error("no")}}});await assert.rejects(c.apply((_,r)=>({revision:r})));failure=null;await c.apply((_,r)=>({revision:r}));failure="switch";await assert.rejects(c.apply((_,r)=>({revision:r})));failure=null;await c.apply((_,r)=>({revision:r}));assert.deepEqual(revisions,[1,2,3,4]);assert.equal(c.revision,4)});
|
|
||||||
|
|||||||
@@ -1,4 +1,98 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { ember, midnight, renderGraphPresets } from "../static/render-graph/presets.js";
|
import {
|
||||||
test("Phase 8 presets are unique activatable V1 scene-forward graphs",()=>{assert.deepEqual(Object.keys(renderGraphPresets),["midnight","ember"]);assert.deepEqual([midnight.graphId,ember.graphId],["preset_midnight","preset_ember"]);assert.notDeepEqual(midnight.passes[0].writes[0].access.load.value,ember.passes[0].writes[0].access.load.value);for(const graph of [midnight,ember]){assert.equal(graph.schemaVersion,1);assert.equal(graph.revision,1);assert.equal(graph.passes.length,1);assert.equal(graph.passes[0].state,"enabled");assert.deepEqual(graph.passes[0].executor,{key:"scene_forward",version:1});assert.equal(graph.outputs[0].name,"present");}});
|
culling,
|
||||||
|
ember,
|
||||||
|
hdr,
|
||||||
|
midnight,
|
||||||
|
renderGraphPresets,
|
||||||
|
} from "../static/render-graph/presets.js";
|
||||||
|
test("Phase 4 unit 4 presets preserve V1 graphs and add the V2 HDR fullscreen topology", () => {
|
||||||
|
assert.deepEqual(Object.keys(renderGraphPresets), [
|
||||||
|
"midnight",
|
||||||
|
"ember",
|
||||||
|
"hdr",
|
||||||
|
"culling",
|
||||||
|
"tone",
|
||||||
|
"edges",
|
||||||
|
"bloom",
|
||||||
|
"combined",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
[midnight.graphId, ember.graphId],
|
||||||
|
["preset_midnight", "preset_ember"],
|
||||||
|
);
|
||||||
|
assert.notDeepEqual(
|
||||||
|
midnight.passes[0].writes[0].access.load.value,
|
||||||
|
ember.passes[0].writes[0].access.load.value,
|
||||||
|
);
|
||||||
|
for (const graph of [midnight, ember]) {
|
||||||
|
assert.equal(graph.schemaVersion, 1);
|
||||||
|
assert.equal(graph.revision, 1);
|
||||||
|
assert.equal(graph.passes.length, 1);
|
||||||
|
assert.equal(graph.passes[0].state, "enabled");
|
||||||
|
assert.deepEqual(graph.passes[0].executor, {
|
||||||
|
key: "scene_forward",
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
assert.equal(graph.outputs[0].name, "present");
|
||||||
|
}
|
||||||
|
assert.equal(hdr.schemaVersion, 2);
|
||||||
|
assert.equal(hdr.revision, 1);
|
||||||
|
assert.equal(hdr.graphId, "preset_hdr_fullscreen");
|
||||||
|
assert.equal(
|
||||||
|
new Set(Object.values(renderGraphPresets).map((graph) => graph.graphId))
|
||||||
|
.size,
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
hdr.nodes.map((node) => [node.id, node.executor.key]),
|
||||||
|
[
|
||||||
|
["surface", "surface_target"],
|
||||||
|
["hdr", "texture_spec"],
|
||||||
|
["depth", "texture_spec"],
|
||||||
|
["scene", "scene_table"],
|
||||||
|
["visible", "visibility_flags"],
|
||||||
|
["query", "mesh_query"],
|
||||||
|
["depth_config", "depth_stencil_config"],
|
||||||
|
["forward", "legacy_forward"],
|
||||||
|
["copy", "fullscreen_copy"],
|
||||||
|
["present", "present"],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const byId = Object.fromEntries(hdr.nodes.map((node) => [node.id, node]));
|
||||||
|
assert.equal(byId.hdr.parameters.texture.format, "rgba16_float");
|
||||||
|
assert.deepEqual(byId.query.inputs, {
|
||||||
|
scene: { node: "scene", socket: "scene" },
|
||||||
|
isVisible: { node: "visible", socket: "flags" },
|
||||||
|
});
|
||||||
|
assert.deepEqual(byId.query.parameters.filters, [
|
||||||
|
{ flag: "isVisible", predicate: "required_true" },
|
||||||
|
{ flag: "isFrustumCulled", predicate: "any" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(byId.forward.inputs.colorTarget, {
|
||||||
|
node: "hdr",
|
||||||
|
socket: "spec",
|
||||||
|
});
|
||||||
|
assert.deepEqual(byId.copy.executor, { key: "fullscreen_copy", version: 1 });
|
||||||
|
assert.deepEqual(byId.copy.inputs, {
|
||||||
|
source: { node: "forward", socket: "color" },
|
||||||
|
colorTarget: { node: "surface", socket: "surface" },
|
||||||
|
});
|
||||||
|
assert.deepEqual(byId.present.inputs.surface, {
|
||||||
|
node: "copy",
|
||||||
|
socket: "color",
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
culling.nodes
|
||||||
|
.filter((node) => ["frustum_cull", "mesh_query"].includes(node.executor.key))
|
||||||
|
.map((node) => node.executor.key),
|
||||||
|
["frustum_cull", "mesh_query"],
|
||||||
|
);
|
||||||
|
const cullingQuery = culling.nodes.find((node) => node.id === "query");
|
||||||
|
assert.equal(cullingQuery.parameters.filters[1].predicate, "required_false");
|
||||||
|
assert.deepEqual(cullingQuery.inputs.isFrustumCulled, {
|
||||||
|
node: "cull",
|
||||||
|
socket: "flags",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ test("pending reply exists before ring publication", async () => {
|
|||||||
worker.reply({type:"reply",request:2,ok:true});
|
worker.reply({type:"reply",request:2,ok:true});
|
||||||
await pending;
|
await pending;
|
||||||
});
|
});
|
||||||
|
test("profile snapshots have a dedicated getter", () => {
|
||||||
|
const f=fixture(), snapshot={type:"profile-snapshot",available:true,epoch:3,passes:{forward:1.25}};
|
||||||
|
const dispatch=globalThis.dispatchEvent; globalThis.dispatchEvent=()=>true;
|
||||||
|
try { f.worker.reply(snapshot); assert.strictEqual(f.client.profile,snapshot); }
|
||||||
|
finally { globalThis.dispatchEvent=dispatch; f.client.dispose(); }
|
||||||
|
});
|
||||||
test("worker failures and dispose reject every pending operation", async () => {
|
test("worker failures and dispose reject every pending operation", async () => {
|
||||||
const f=fixture(), mesh=await imported(f); const {worker,client,bridge}=f;
|
const f=fixture(), mesh=await imported(f); const {worker,client,bridge}=f;
|
||||||
const a=mesh.setVisible(true), b=mesh.setVisible(false);
|
const a=mesh.setVisible(true), b=mesh.setVisible(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user