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",
|
||||
"futures",
|
||||
"gltf",
|
||||
"image",
|
||||
"js-sys",
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
|
||||
+3
-1
@@ -19,6 +19,7 @@ web-sys = { version = "0.3.77", features = [
|
||||
"FileList",
|
||||
"OffscreenCanvas",
|
||||
"MouseEvent",
|
||||
"PointerEvent",
|
||||
"WheelEvent",
|
||||
"KeyboardEvent",
|
||||
"Worker",
|
||||
@@ -43,4 +44,5 @@ wgpu = "26.0.1"
|
||||
thiserror = "2.0.15"
|
||||
ultraviolet = "0.10.0"
|
||||
futures = "0.3"
|
||||
gltf = { version = "1.4", features = ["extras", "names", "KHR_lights_punctual"] }
|
||||
gltf = { version = "1.4", features = ["extras", "names", "KHR_lights_punctual", "KHR_materials_ior"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
+13
-6
@@ -29,7 +29,7 @@ pub struct EditorScene {
|
||||
impl renderer::renderer::scene::Scene for EditorScene {
|
||||
fn setup(
|
||||
renderer_context: &gpu_renderer::RendererContext,
|
||||
resources: &mut gpu_renderer::GpuResources,
|
||||
resources: &mut gpu_renderer::PipelineLibrary,
|
||||
render_data: &mut RenderData,
|
||||
) -> Self {
|
||||
let dimension = ultraviolet::Vec2::new(
|
||||
@@ -90,14 +90,18 @@ impl renderer::renderer::scene::Scene for EditorScene {
|
||||
self.frame_metadata.mouse_click = [x, y];
|
||||
}
|
||||
|
||||
fn handle_zoom(&mut self, _delta_y: f32) {
|
||||
// TODO: Implement zoom properly when Camera exposes necessary methods
|
||||
fn handle_zoom(&mut self, delta_y: f32) {
|
||||
self.cam.zoom(delta_y);
|
||||
}
|
||||
|
||||
fn handle_orbit(&mut self, delta_x: f32, delta_y: f32) {
|
||||
self.cam.orbit(delta_x, delta_y);
|
||||
}
|
||||
|
||||
fn handle_pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
|
||||
self.cam.pan(delta_x, delta_y, viewport_height);
|
||||
}
|
||||
|
||||
fn set_camera_depth_range(&mut self, near: f32, far: f32) {
|
||||
self.cam.set_depth_range(near, far);
|
||||
}
|
||||
@@ -149,13 +153,14 @@ impl EditorScene {
|
||||
fn create_default_scene(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
resources: &mut gpu_renderer::GpuResources,
|
||||
resources: &mut gpu_renderer::PipelineLibrary,
|
||||
render_data: &mut RenderData,
|
||||
surface_format: wgpu::TextureFormat,
|
||||
) {
|
||||
let positions: Vec<[f32; 3]> = Self::VERTICES.iter().map(|v| v.pos).collect();
|
||||
// Ground plane normals point upward (Y+)
|
||||
let normals: Vec<[f32; 3]> = vec![[0.0, 1.0, 0.0]; positions.len()];
|
||||
let tangents: Vec<[f32; 4]> = vec![[1.0, 0.0, 0.0, 1.0]; positions.len()];
|
||||
let uvs: &[[f32; 2]] = &[
|
||||
[0.0, 0.0],
|
||||
[1.0, 0.0],
|
||||
@@ -183,9 +188,11 @@ impl EditorScene {
|
||||
.create_mesh(MeshCreateInfo {
|
||||
positions: &positions,
|
||||
normals: &normals,
|
||||
tangents: &tangents,
|
||||
uvs,
|
||||
indices: Self::INDICES,
|
||||
pipeline: pipeline_index,
|
||||
material: renderer::render_data::MaterialKey::DEFAULT,
|
||||
flags: RenderFlags::VISIBLE,
|
||||
default_instance_flags: RenderFlags::VISIBLE,
|
||||
default_transform: transform,
|
||||
@@ -196,11 +203,11 @@ impl EditorScene {
|
||||
|
||||
/// Entrypoint for the level editor
|
||||
#[wasm_bindgen]
|
||||
pub fn main() -> Result<RendererBridge, JsValue> {
|
||||
pub fn main(profile: bool) -> Result<RendererBridge, JsValue> {
|
||||
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
|
||||
wasm_logger::init(wasm_logger::Config::default());
|
||||
|
||||
let runtime = LevelEditor::setup_runtime()?;
|
||||
let runtime = LevelEditor::setup_runtime(profile)?;
|
||||
Ok(RendererBridge { runtime })
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ thiserror = { workspace = true }
|
||||
ultraviolet = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
gltf = { workspace = true }
|
||||
image = { workspace = true }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
|
||||
+84
-55
@@ -18,9 +18,10 @@ use web_sys::AddEventListenerOptions;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub struct EventListeners {
|
||||
pub resize_listener: Option<Closure<dyn FnMut()>>,
|
||||
pub mousemove_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
|
||||
pub mousedown_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
|
||||
pub pointer_listener: Option<Closure<dyn FnMut(web_sys::PointerEvent)>>,
|
||||
pub click_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
|
||||
pub wheel_listener: Option<Closure<dyn FnMut(web_sys::WheelEvent)>>,
|
||||
pub contextmenu_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
|
||||
pub keyboard_listener: Option<Closure<dyn FnMut(web_sys::KeyboardEvent)>>,
|
||||
}
|
||||
|
||||
@@ -29,9 +30,10 @@ impl EventListeners {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
resize_listener: None,
|
||||
mousemove_listener: None,
|
||||
mousedown_listener: None,
|
||||
pointer_listener: None,
|
||||
click_listener: None,
|
||||
wheel_listener: None,
|
||||
contextmenu_listener: None,
|
||||
keyboard_listener: None,
|
||||
}
|
||||
}
|
||||
@@ -54,65 +56,82 @@ pub fn setup_event_listeners(
|
||||
let width = f64::from(resize_canvas.client_width().max(1));
|
||||
let height = f64::from(resize_canvas.client_height().max(1));
|
||||
|
||||
resize_worker_chan
|
||||
.send(WindowEvent::Resize(ResizeMessage {
|
||||
let _ = resize_worker_chan.send(WindowEvent::Resize(ResizeMessage {
|
||||
width,
|
||||
height,
|
||||
scale_factor: window.device_pixel_ratio(),
|
||||
}))
|
||||
.unwrap();
|
||||
}));
|
||||
});
|
||||
|
||||
window.add_event_listener_with_callback("resize", resize_listener.as_ref().unchecked_ref())?;
|
||||
|
||||
let mousemove_worker_chan = worker_chan.clone();
|
||||
let mousemove_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
|
||||
let pointer_worker_chan = worker_chan.clone();
|
||||
let pointer_canvas = canvas.clone();
|
||||
let pointer_listener: Closure<dyn FnMut(web_sys::PointerEvent)> =
|
||||
Closure::new(move |event: web_sys::PointerEvent| {
|
||||
use crate::message::{camera_drag, MouseMessage};
|
||||
|
||||
if event.pointer_type() != "mouse" {
|
||||
return;
|
||||
}
|
||||
match event.type_().as_str() {
|
||||
"pointerdown" if matches!(event.button(), 1 | 2) => {
|
||||
event.prevent_default();
|
||||
let _ = pointer_canvas.set_pointer_capture(event.pointer_id());
|
||||
}
|
||||
"pointermove"
|
||||
if pointer_canvas.has_pointer_capture(event.pointer_id())
|
||||
&& camera_drag(event.buttons()).is_some() =>
|
||||
{
|
||||
event.prevent_default();
|
||||
let message = MouseMessage::from_pointer_evt(
|
||||
&event,
|
||||
f64::from(pointer_canvas.client_height().max(1)),
|
||||
);
|
||||
let _ = pointer_worker_chan.send(WindowEvent::PointerMove(message));
|
||||
}
|
||||
"pointerup" | "pointercancel"
|
||||
if pointer_canvas.has_pointer_capture(event.pointer_id()) =>
|
||||
{
|
||||
let _ = pointer_canvas.release_pointer_capture(event.pointer_id());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
for event_name in ["pointerdown", "pointermove", "pointerup", "pointercancel"] {
|
||||
canvas.add_event_listener_with_callback(
|
||||
event_name,
|
||||
pointer_listener.as_ref().unchecked_ref(),
|
||||
)?;
|
||||
}
|
||||
|
||||
let click_worker_chan = worker_chan.clone();
|
||||
let click_canvas = canvas.clone();
|
||||
let click_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
|
||||
Closure::new(move |event: web_sys::MouseEvent| {
|
||||
use crate::message::MouseMessage;
|
||||
if event.buttons() & 0x04 != 0 {
|
||||
event.prevent_default();
|
||||
if event.button() != 0 {
|
||||
return;
|
||||
}
|
||||
let mouse_event_data = MouseMessage::from_evt(event.clone());
|
||||
|
||||
let mut event_data = WindowEvent::PointerMove(mouse_event_data.clone());
|
||||
if event.type_() == "click" {
|
||||
event_data = WindowEvent::PointerClick(mouse_event_data.clone());
|
||||
}
|
||||
|
||||
mousemove_worker_chan.clone().send(event_data).unwrap();
|
||||
let message =
|
||||
MouseMessage::from_evt(&event, f64::from(click_canvas.client_height().max(1)));
|
||||
let _ = click_worker_chan.send(WindowEvent::PointerClick(message));
|
||||
});
|
||||
|
||||
window.add_event_listener_with_callback(
|
||||
"mousemove",
|
||||
mousemove_listener.as_ref().unchecked_ref(),
|
||||
)?;
|
||||
|
||||
window
|
||||
.add_event_listener_with_callback("click", mousemove_listener.as_ref().unchecked_ref())?;
|
||||
|
||||
let mousedown_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
|
||||
Closure::new(move |event: web_sys::MouseEvent| {
|
||||
if event.button() == 1 {
|
||||
event.prevent_default();
|
||||
}
|
||||
});
|
||||
|
||||
window.add_event_listener_with_callback(
|
||||
"mousedown",
|
||||
mousedown_listener.as_ref().unchecked_ref(),
|
||||
)?;
|
||||
canvas.add_event_listener_with_callback("click", click_listener.as_ref().unchecked_ref())?;
|
||||
|
||||
let wheel_worker_chan = worker_chan.clone();
|
||||
let wheel_canvas = canvas.clone();
|
||||
let wheel_listener: Closure<dyn FnMut(web_sys::WheelEvent)> =
|
||||
Closure::new(move |event: web_sys::WheelEvent| {
|
||||
use crate::message::WheelMessage;
|
||||
|
||||
event.prevent_default();
|
||||
let wheel_event_data = WheelMessage::from_evt(event);
|
||||
|
||||
wheel_worker_chan
|
||||
.send(WindowEvent::PointerWheel(wheel_event_data))
|
||||
.unwrap();
|
||||
if let Some(message) =
|
||||
WheelMessage::from_evt(&event, f64::from(wheel_canvas.client_height().max(1)))
|
||||
{
|
||||
let _ = wheel_worker_chan.send(WindowEvent::PointerWheel(message));
|
||||
}
|
||||
});
|
||||
|
||||
let wheel_options = {
|
||||
@@ -121,12 +140,19 @@ pub fn setup_event_listeners(
|
||||
options
|
||||
};
|
||||
|
||||
window.add_event_listener_with_callback_and_add_event_listener_options(
|
||||
canvas.add_event_listener_with_callback_and_add_event_listener_options(
|
||||
"wheel",
|
||||
wheel_listener.as_ref().unchecked_ref(),
|
||||
&wheel_options,
|
||||
)?;
|
||||
|
||||
let contextmenu_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
|
||||
Closure::new(move |event: web_sys::MouseEvent| event.prevent_default());
|
||||
canvas.add_event_listener_with_callback(
|
||||
"contextmenu",
|
||||
contextmenu_listener.as_ref().unchecked_ref(),
|
||||
)?;
|
||||
|
||||
let keyboard_worker_chan = worker_chan.clone();
|
||||
let keyboard_listener: Closure<dyn FnMut(web_sys::KeyboardEvent)> =
|
||||
Closure::new(move |event: web_sys::KeyboardEvent| {
|
||||
@@ -134,9 +160,7 @@ pub fn setup_event_listeners(
|
||||
|
||||
let keyboard_event_data = KeyboardMessage::from_evt(event);
|
||||
|
||||
keyboard_worker_chan
|
||||
.send(WindowEvent::Keyboard(keyboard_event_data))
|
||||
.unwrap();
|
||||
let _ = keyboard_worker_chan.send(WindowEvent::Keyboard(keyboard_event_data));
|
||||
});
|
||||
|
||||
window
|
||||
@@ -144,9 +168,10 @@ pub fn setup_event_listeners(
|
||||
|
||||
Ok(EventListeners {
|
||||
resize_listener: Some(resize_listener),
|
||||
mousemove_listener: Some(mousemove_listener),
|
||||
mousedown_listener: Some(mousedown_listener),
|
||||
pointer_listener: Some(pointer_listener),
|
||||
click_listener: Some(click_listener),
|
||||
wheel_listener: Some(wheel_listener),
|
||||
contextmenu_listener: Some(contextmenu_listener),
|
||||
keyboard_listener: Some(keyboard_listener),
|
||||
})
|
||||
}
|
||||
@@ -166,6 +191,7 @@ impl WebAppRuntime {
|
||||
pub fn new<T: crate::renderer::scene::Scene + 'static>(
|
||||
worker_name: &str,
|
||||
canvas_selector: &str,
|
||||
profile: bool,
|
||||
) -> Result<Self, JsValue> {
|
||||
let (sender, receiver) = mpsc::channel::<WindowEvent>();
|
||||
|
||||
@@ -179,7 +205,7 @@ impl WebAppRuntime {
|
||||
let worker = MainWorker::spawn(worker_name, 1, ring_ptr, move || {
|
||||
spawn_local(async move {
|
||||
let ring = unsafe { &*(ring_ptr as *const CommandRing) };
|
||||
MainWorker::run_render_loop::<T>(receiver, ring).await;
|
||||
MainWorker::run_render_loop::<T>(receiver, ring, profile).await;
|
||||
});
|
||||
})?;
|
||||
|
||||
@@ -228,9 +254,12 @@ pub trait WebApp {
|
||||
fn on_runtime_initialized(_runtime: &mut WebAppRuntime) {}
|
||||
|
||||
/// Perform the default WASM initialization routine.
|
||||
fn setup_runtime() -> Result<WebAppRuntime, JsValue> {
|
||||
let mut runtime =
|
||||
WebAppRuntime::new::<Self::Scene>(Self::worker_name(), Self::canvas_selector())?;
|
||||
fn setup_runtime(profile: bool) -> Result<WebAppRuntime, JsValue> {
|
||||
let mut runtime = WebAppRuntime::new::<Self::Scene>(
|
||||
Self::worker_name(),
|
||||
Self::canvas_selector(),
|
||||
profile,
|
||||
)?;
|
||||
Self::on_runtime_initialized(&mut runtime);
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
+275
-28
@@ -3,7 +3,16 @@ use std::f32::consts::PI;
|
||||
use ultraviolet::{projection, Bivec3, Mat4, Rotor3, Vec3};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::{message::WheelMessage, renderer::scene::UniformResource};
|
||||
use crate::renderer::scene::UniformResource;
|
||||
|
||||
/// A camera matrix cannot produce a safe, meaningful frustum.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum FrustumError {
|
||||
#[error("frustum plane {plane} contains a non-finite component")]
|
||||
NonFinite { plane: usize },
|
||||
#[error("frustum plane {plane} has a near-degenerate normal")]
|
||||
Degenerate { plane: usize },
|
||||
}
|
||||
|
||||
const MIN_DISTANCE: f32 = 0.1;
|
||||
const MAX_PITCH: f32 = PI / 2.0 - 0.01;
|
||||
@@ -81,6 +90,9 @@ pub struct CameraUniform {
|
||||
}
|
||||
|
||||
impl Camera {
|
||||
pub fn frustum_planes(&self) -> Result<[[f32; 4]; 6], FrustumError> {
|
||||
extract_frustum_planes(self.view_proj)
|
||||
}
|
||||
pub fn new(aspect_ratio: f32) -> Self {
|
||||
let mut camera = Camera {
|
||||
view_proj: [[0.0; 4]; 4],
|
||||
@@ -115,8 +127,14 @@ impl Camera {
|
||||
}
|
||||
|
||||
pub fn look_at(&mut self, position: Vec3, target: Vec3) {
|
||||
if !vec3_is_finite(position) || !vec3_is_finite(target) {
|
||||
return;
|
||||
}
|
||||
self.position = position;
|
||||
self.target = target;
|
||||
if (self.position - self.target).mag_sq() <= f32::EPSILON {
|
||||
self.position = self.target + Vec3::unit_z() * MIN_DISTANCE;
|
||||
}
|
||||
self.up = Vec3::unit_y();
|
||||
self.compute_rotor();
|
||||
self.dirty = true;
|
||||
@@ -141,6 +159,9 @@ impl Camera {
|
||||
}
|
||||
|
||||
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
|
||||
if !delta_x.is_finite() || !delta_y.is_finite() {
|
||||
return;
|
||||
}
|
||||
// Skip tiny movements to reduce unnecessary computations
|
||||
if delta_x.abs() < 0.001 && delta_y.abs() < 0.001 {
|
||||
return;
|
||||
@@ -174,39 +195,62 @@ impl Camera {
|
||||
self.compute_view_proj_mat();
|
||||
}
|
||||
|
||||
pub fn zoom(&mut self, msg: &WheelMessage) {
|
||||
let mut delta = msg.delta_y as f32;
|
||||
|
||||
// Match browser delta modes so the wheel delta is always roughly pixels.
|
||||
match msg.delta_mode {
|
||||
1 => delta *= 16.0,
|
||||
2 => delta *= 800.0,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Scrolling up should zoom in.
|
||||
delta = -delta;
|
||||
|
||||
if delta.abs() <= f32::EPSILON {
|
||||
pub fn zoom(&mut self, delta_y_pixels: f32) {
|
||||
if !delta_y_pixels.is_finite() || delta_y_pixels.abs() <= f32::EPSILON {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get forward direction from camera position to target
|
||||
let mut forward_vec = self.target - self.position;
|
||||
if forward_vec.mag_sq() <= f32::EPSILON {
|
||||
forward_vec = Vec3::unit_z();
|
||||
let mut offset = self.position - self.target;
|
||||
let mut current_distance = offset.mag();
|
||||
if !current_distance.is_finite() {
|
||||
return;
|
||||
}
|
||||
if current_distance <= f32::EPSILON {
|
||||
offset = Vec3::unit_z() * MIN_DISTANCE;
|
||||
current_distance = MIN_DISTANCE;
|
||||
}
|
||||
let direction = offset / current_distance;
|
||||
let max_distance = (self.z_far * 0.95).max(MIN_DISTANCE);
|
||||
if !max_distance.is_finite() {
|
||||
return;
|
||||
}
|
||||
let candidate = f64::from(current_distance)
|
||||
* (f64::from(ZOOM_SENSITIVITY) * f64::from(delta_y_pixels)).exp();
|
||||
let new_distance = candidate.clamp(f64::from(MIN_DISTANCE), f64::from(max_distance)) as f32;
|
||||
let new_position = self.target + direction * new_distance;
|
||||
if !vec3_is_finite(new_position) {
|
||||
return;
|
||||
}
|
||||
let forward_dir = forward_vec.normalized();
|
||||
let current_distance = forward_vec.mag();
|
||||
|
||||
// Scale dolly movement by distance to target for consistent perceived zoom speed
|
||||
let dolly_distance = delta * ZOOM_SENSITIVITY * current_distance;
|
||||
let dolly_translation = forward_dir * dolly_distance;
|
||||
self.position = new_position;
|
||||
self.distance = new_distance;
|
||||
self.dirty = true;
|
||||
self.compute_view_proj_mat();
|
||||
}
|
||||
|
||||
self.position += dolly_translation;
|
||||
self.target += dolly_translation;
|
||||
|
||||
self.compute_rotor();
|
||||
pub fn pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
|
||||
if !delta_x.is_finite()
|
||||
|| !delta_y.is_finite()
|
||||
|| !viewport_height.is_finite()
|
||||
|| !self.fov.is_finite()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let distance = (self.position - self.target).mag().max(MIN_DISTANCE);
|
||||
if !distance.is_finite() {
|
||||
return;
|
||||
}
|
||||
let world_units_per_pixel =
|
||||
2.0 * distance * (self.fov * 0.5).tan() / viewport_height.max(1.0);
|
||||
let basis = OrthonormalBasis::from_camera(self);
|
||||
let translation = (-basis.right * delta_x + basis.up * delta_y) * world_units_per_pixel;
|
||||
let position = self.position + translation;
|
||||
let target = self.target + translation;
|
||||
if !vec3_is_finite(position) || !vec3_is_finite(target) {
|
||||
return;
|
||||
}
|
||||
self.position = position;
|
||||
self.target = target;
|
||||
self.dirty = true;
|
||||
self.compute_view_proj_mat();
|
||||
}
|
||||
@@ -294,3 +338,206 @@ impl Camera {
|
||||
self.rotor = (swing_rotor * twist_rotor).normalized();
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts inward-facing normalized WebGPU clip-space planes (zero-to-one depth).
|
||||
pub fn extract_frustum_planes(m: [[f32; 4]; 4]) -> Result<[[f32; 4]; 6], FrustumError> {
|
||||
let row = |r: usize| [m[0][r], m[1][r], m[2][r], m[3][r]];
|
||||
let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]];
|
||||
let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]];
|
||||
let r0 = row(0);
|
||||
let r1 = row(1);
|
||||
let r2 = row(2);
|
||||
let r3 = row(3);
|
||||
let mut planes = [
|
||||
add(r3, r0),
|
||||
sub(r3, r0),
|
||||
add(r3, r1),
|
||||
sub(r3, r1),
|
||||
r2,
|
||||
sub(r3, r2),
|
||||
];
|
||||
for (plane, p) in planes.iter_mut().enumerate() {
|
||||
if !p.iter().all(|component| component.is_finite()) {
|
||||
return Err(FrustumError::NonFinite { plane });
|
||||
}
|
||||
// Scale first: directly squaring very large/small coefficients can overflow or
|
||||
// underflow even though the plane itself is normalizable.
|
||||
let scale = p[0].abs().max(p[1].abs()).max(p[2].abs());
|
||||
if scale < f32::MIN_POSITIVE {
|
||||
return Err(FrustumError::Degenerate { plane });
|
||||
}
|
||||
let scaled = [p[0] / scale, p[1] / scale, p[2] / scale];
|
||||
let length = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).sqrt();
|
||||
for v in p {
|
||||
*v = (*v / scale) / length;
|
||||
if !v.is_finite() {
|
||||
return Err(FrustumError::NonFinite { plane });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(planes)
|
||||
}
|
||||
|
||||
fn vec3_is_finite(value: Vec3) -> bool {
|
||||
value.x.is_finite() && value.y.is_finite() && value.z.is_finite()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frustum_extraction_rejects_nonfinite_and_degenerate_planes() {
|
||||
let mut nonfinite = Camera::new(1.0).view_proj;
|
||||
nonfinite[0][0] = f32::NAN;
|
||||
assert!(matches!(
|
||||
extract_frustum_planes(nonfinite),
|
||||
Err(FrustumError::NonFinite { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
extract_frustum_planes([[0.0; 4]; 4]),
|
||||
Err(FrustumError::Degenerate { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frustum_extraction_normalizes_without_overflow() {
|
||||
let mut matrix = Camera::new(1.0).view_proj;
|
||||
for value in matrix.iter_mut().flatten() {
|
||||
*value *= 1.0e20;
|
||||
}
|
||||
let planes = extract_frustum_planes(matrix).unwrap();
|
||||
for plane in planes {
|
||||
let length = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
|
||||
assert!((length - 1.0).abs() < 1.0e-5);
|
||||
assert!(plane.iter().all(|value| value.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_vec3_close(actual: Vec3, expected: Vec3, epsilon: f32) {
|
||||
assert!(
|
||||
(actual.x - expected.x).abs() <= epsilon,
|
||||
"x: {actual:?} != {expected:?}"
|
||||
);
|
||||
assert!(
|
||||
(actual.y - expected.y).abs() <= epsilon,
|
||||
"y: {actual:?} != {expected:?}"
|
||||
);
|
||||
assert!(
|
||||
(actual.z - expected.z).abs() <= epsilon,
|
||||
"z: {actual:?} != {expected:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_camera_finite(camera: &Camera) {
|
||||
assert!(vec3_is_finite(camera.position));
|
||||
assert!(vec3_is_finite(camera.target));
|
||||
assert!(camera.distance.is_finite());
|
||||
assert!(camera
|
||||
.view_proj
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|component| component.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_is_multiplicative_and_preserves_target() {
|
||||
let mut camera = Camera::new(1.0);
|
||||
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
|
||||
let initial_position = camera.position;
|
||||
let initial_target = camera.target;
|
||||
let initial_distance = camera.distance;
|
||||
|
||||
camera.zoom(-100.0);
|
||||
assert!(camera.distance < initial_distance);
|
||||
assert_eq!(camera.target, initial_target);
|
||||
|
||||
camera.zoom(100.0);
|
||||
assert_vec3_close(camera.position, initial_position, 1e-4);
|
||||
assert!((camera.distance - initial_distance).abs() <= 1e-4);
|
||||
|
||||
camera.zoom(100.0);
|
||||
assert!(camera.distance > initial_distance);
|
||||
assert_eq!(camera.target, initial_target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_clamps_and_remains_finite() {
|
||||
let mut camera = Camera::new(1.0);
|
||||
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
|
||||
camera.zoom(f32::NEG_INFINITY);
|
||||
assert!((camera.distance - 10.0).abs() <= 1e-5);
|
||||
camera.zoom(-f32::MAX);
|
||||
assert!((camera.distance - MIN_DISTANCE).abs() <= f32::EPSILON);
|
||||
camera.zoom(f32::MAX);
|
||||
assert!(camera.distance <= camera.z_far * 0.95);
|
||||
assert_camera_finite(&camera);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_moves_eye_and_target_equally_at_target_plane_scale() {
|
||||
let mut camera = Camera::new(1.0);
|
||||
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
|
||||
let initial_position = camera.position;
|
||||
let initial_target = camera.target;
|
||||
let initial_offset = initial_position - initial_target;
|
||||
let units = 2.0 * 10.0 * (PI / 6.0).tan() / 1000.0;
|
||||
|
||||
camera.pan(20.0, 10.0, 1000.0);
|
||||
|
||||
let translation = Vec3::new(-20.0 * units, 10.0 * units, 0.0);
|
||||
assert_vec3_close(camera.position, initial_position + translation, 1e-5);
|
||||
assert_vec3_close(camera.target, initial_target + translation, 1e-5);
|
||||
assert_vec3_close(camera.position - camera.target, initial_offset, 1e-5);
|
||||
assert!((camera.distance - 10.0).abs() <= 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_scale_is_proportional_to_distance() {
|
||||
let mut near = Camera::new(1.0);
|
||||
near.look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::zero());
|
||||
let mut far = Camera::new(1.0);
|
||||
far.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
|
||||
|
||||
near.pan(10.0, 0.0, 1000.0);
|
||||
far.pan(10.0, 0.0, 1000.0);
|
||||
|
||||
assert!((far.target.mag() / near.target.mag() - 2.0).abs() <= 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orbit_preserves_target_and_distance() {
|
||||
let mut camera = Camera::new(1.0);
|
||||
camera.look_at(Vec3::new(2.0, 3.0, 10.0), Vec3::new(1.0, -1.0, 0.5));
|
||||
let initial_target = camera.target;
|
||||
let initial_distance = (camera.position - camera.target).mag();
|
||||
|
||||
camera.orbit(40.0, -25.0);
|
||||
|
||||
assert_eq!(camera.target, initial_target);
|
||||
assert!(((camera.position - camera.target).mag() - initial_distance).abs() <= 1e-5);
|
||||
assert!((camera.distance - initial_distance).abs() <= 1e-5);
|
||||
assert!(camera.distance >= MIN_DISTANCE);
|
||||
assert_camera_finite(&camera);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_reject_invalid_input_and_recover_degenerate_look_at() {
|
||||
let mut camera = Camera::new(1.0);
|
||||
camera.look_at(Vec3::zero(), Vec3::zero());
|
||||
assert!((camera.position - camera.target).mag() >= MIN_DISTANCE);
|
||||
let position = camera.position;
|
||||
let target = camera.target;
|
||||
|
||||
camera.orbit(f32::NAN, 1.0);
|
||||
camera.zoom(f32::INFINITY);
|
||||
camera.pan(f32::NAN, 1.0, 0.0);
|
||||
assert_eq!(camera.position, position);
|
||||
assert_eq!(camera.target, target);
|
||||
|
||||
camera.pan(1.0, 1.0, 0.0);
|
||||
camera.orbit(4.0, -3.0);
|
||||
camera.zoom(2.0);
|
||||
assert_camera_finite(&camera);
|
||||
}
|
||||
}
|
||||
|
||||
+516
-11
@@ -4,10 +4,100 @@ use gltf::Gltf;
|
||||
use ultraviolet::{Mat4, Vec3};
|
||||
|
||||
use crate::render_data::{
|
||||
InstanceHandle, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey, RenderData,
|
||||
RenderDataError, RenderFlags,
|
||||
InstanceHandle, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey,
|
||||
RenderData, RenderDataError, RenderFlags,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum AlphaMode {
|
||||
#[default]
|
||||
Opaque,
|
||||
Mask,
|
||||
Blend,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct TextureReference {
|
||||
pub texture: usize,
|
||||
pub tex_coord: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Material {
|
||||
pub key: MaterialKey,
|
||||
pub base_color_factor: [f32; 4],
|
||||
pub metallic_factor: f32,
|
||||
pub roughness_factor: f32,
|
||||
pub emissive_factor: [f32; 3],
|
||||
/// Index of refraction for the dielectric Fresnel response.
|
||||
pub ior: f32,
|
||||
pub alpha_mode: AlphaMode,
|
||||
pub alpha_cutoff: f32,
|
||||
pub double_sided: bool,
|
||||
pub base_color_texture: Option<TextureReference>,
|
||||
pub metallic_roughness_texture: Option<TextureReference>,
|
||||
pub normal_texture: Option<TextureReference>,
|
||||
pub normal_scale: f32,
|
||||
pub occlusion_texture: Option<TextureReference>,
|
||||
pub occlusion_strength: f32,
|
||||
pub emissive_texture: Option<TextureReference>,
|
||||
}
|
||||
|
||||
impl Default for Material {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
key: MaterialKey::DEFAULT,
|
||||
base_color_factor: [1.0; 4],
|
||||
metallic_factor: 1.0,
|
||||
roughness_factor: 1.0,
|
||||
emissive_factor: [0.0; 3],
|
||||
ior: 1.5,
|
||||
alpha_mode: AlphaMode::Opaque,
|
||||
alpha_cutoff: 0.5,
|
||||
double_sided: false,
|
||||
base_color_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
normal_texture: None,
|
||||
normal_scale: 1.0,
|
||||
occlusion_texture: None,
|
||||
occlusion_strength: 1.0,
|
||||
emissive_texture: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TextureMetadata {
|
||||
pub image: usize,
|
||||
pub sampler: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SamplerMetadata {
|
||||
pub index: usize,
|
||||
pub mag_filter: Option<String>,
|
||||
pub min_filter: Option<String>,
|
||||
pub wrap_s: String,
|
||||
pub wrap_t: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ImageSource {
|
||||
Uri(String),
|
||||
BufferView(usize),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImageMetadata {
|
||||
pub index: usize,
|
||||
pub name: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub source: ImageSource,
|
||||
/// Encoded PNG/JPEG bytes. Kept encoded so GPU installation can decode and
|
||||
/// upload images one at a time instead of retaining a decoded image batch.
|
||||
pub encoded_data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InstalledScene {
|
||||
pub meshes: Vec<MeshHandle>,
|
||||
@@ -60,16 +150,31 @@ pub enum ImportError {
|
||||
GltfParse(#[from] gltf::Error),
|
||||
#[error("unsupported or malformed primitive: {0}")]
|
||||
InvalidPrimitive(String),
|
||||
#[error("unsupported image source: {0}")]
|
||||
UnsupportedImage(String),
|
||||
#[error("invalid KHR_materials_ior value: {0}")]
|
||||
InvalidIor(f32),
|
||||
#[error("failed to install imported scene")]
|
||||
Install(#[from] RenderDataError),
|
||||
}
|
||||
|
||||
fn decode_ior(value: Option<f32>) -> Result<f32, ImportError> {
|
||||
let ior = value.unwrap_or(1.5);
|
||||
if ior == 0.0 || (ior.is_finite() && ior >= 1.0) {
|
||||
Ok(ior)
|
||||
} else {
|
||||
Err(ImportError::InvalidIor(ior))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImportedGeometry {
|
||||
pub key: (usize, usize),
|
||||
pub material: MaterialKey,
|
||||
pub double_sided: bool,
|
||||
pub positions: Vec<[f32; 3]>,
|
||||
pub normals: Vec<[f32; 3]>,
|
||||
pub tangents: Vec<[f32; 4]>,
|
||||
pub uvs: Vec<[f32; 2]>,
|
||||
pub indices: Vec<u32>,
|
||||
}
|
||||
@@ -82,12 +187,257 @@ pub struct ImportedOccurrence {
|
||||
pub struct ImportedScene {
|
||||
pub geometries: Vec<ImportedGeometry>,
|
||||
pub occurrences: Vec<ImportedOccurrence>,
|
||||
pub materials: Vec<Material>,
|
||||
pub textures: Vec<TextureMetadata>,
|
||||
pub samplers: Vec<SamplerMetadata>,
|
||||
pub images: Vec<ImageMetadata>,
|
||||
}
|
||||
|
||||
fn texture_reference(info: gltf::texture::Info<'_>) -> TextureReference {
|
||||
TextureReference {
|
||||
texture: info.texture().index(),
|
||||
tex_coord: info.tex_coord(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(value: [f32; 3], fallback: [f32; 3]) -> [f32; 3] {
|
||||
let length = value.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if length > f32::EPSILON && length.is_finite() {
|
||||
value.map(|x| x / length)
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
|
||||
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
]
|
||||
}
|
||||
|
||||
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
/// Completes triangle vertex attributes. Generated attributes use corner vertices,
|
||||
/// which deliberately splits UV seams, hard normal edges, and opposite handedness.
|
||||
fn repair_geometry(
|
||||
positions: Vec<[f32; 3]>,
|
||||
normals: Option<Vec<[f32; 3]>>,
|
||||
tangents: Option<Vec<[f32; 4]>>,
|
||||
uvs: Vec<[f32; 2]>,
|
||||
indices: Vec<u32>,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<[f32; 3]>,
|
||||
Vec<[f32; 3]>,
|
||||
Vec<[f32; 4]>,
|
||||
Vec<[f32; 2]>,
|
||||
Vec<u32>,
|
||||
),
|
||||
ImportError,
|
||||
> {
|
||||
if indices.len() % 3 != 0 || indices.iter().any(|&i| i as usize >= positions.len()) {
|
||||
return Err(ImportError::InvalidPrimitive(
|
||||
"triangle indices are malformed".into(),
|
||||
));
|
||||
}
|
||||
let normals_valid = normals.as_ref().is_some_and(|x| x.len() == positions.len());
|
||||
let tangents_valid = tangents
|
||||
.as_ref()
|
||||
.is_some_and(|x| x.len() == positions.len());
|
||||
if normals_valid && tangents_valid {
|
||||
return Ok((positions, normals.unwrap(), tangents.unwrap(), uvs, indices));
|
||||
}
|
||||
|
||||
let mut out_p = Vec::with_capacity(indices.len());
|
||||
let mut out_n = Vec::with_capacity(indices.len());
|
||||
let mut out_t = Vec::with_capacity(indices.len());
|
||||
let mut out_uv = Vec::with_capacity(indices.len());
|
||||
for triangle in indices.chunks_exact(3) {
|
||||
let ids = [
|
||||
triangle[0] as usize,
|
||||
triangle[1] as usize,
|
||||
triangle[2] as usize,
|
||||
];
|
||||
let p = ids.map(|i| positions[i]);
|
||||
let uv = ids.map(|i| uvs.get(i).copied().unwrap_or([0.0; 2]));
|
||||
let face_normal = normalize(cross(sub(p[1], p[0]), sub(p[2], p[0])), [0.0, 1.0, 0.0]);
|
||||
let duv1 = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
|
||||
let duv2 = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
|
||||
let determinant = duv1[0] * duv2[1] - duv1[1] * duv2[0];
|
||||
let edge1 = sub(p[1], p[0]);
|
||||
let edge2 = sub(p[2], p[0]);
|
||||
let (raw_tangent, raw_bitangent) =
|
||||
if determinant.abs() > f32::EPSILON && determinant.is_finite() {
|
||||
let r = determinant.recip();
|
||||
(
|
||||
std::array::from_fn(|i| (edge1[i] * duv2[1] - edge2[i] * duv1[1]) * r),
|
||||
std::array::from_fn(|i| (edge2[i] * duv1[0] - edge1[i] * duv2[0]) * r),
|
||||
)
|
||||
} else {
|
||||
([0.0; 3], [0.0; 3])
|
||||
};
|
||||
for corner in 0..3 {
|
||||
let n = normals
|
||||
.as_ref()
|
||||
.filter(|_| normals_valid)
|
||||
.map_or(face_normal, |x| x[ids[corner]]);
|
||||
let n = normalize(n, face_normal);
|
||||
let projected = std::array::from_fn(|i| raw_tangent[i] - n[i] * dot(n, raw_tangent));
|
||||
let fallback_axis = if n[0].abs() < 0.9 {
|
||||
[1.0, 0.0, 0.0]
|
||||
} else {
|
||||
[0.0, 1.0, 0.0]
|
||||
};
|
||||
let tangent3 = normalize(
|
||||
projected,
|
||||
normalize(cross(fallback_axis, n), [0.0, 0.0, 1.0]),
|
||||
);
|
||||
let generated = [
|
||||
tangent3[0],
|
||||
tangent3[1],
|
||||
tangent3[2],
|
||||
if dot(cross(n, tangent3), raw_bitangent) < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
];
|
||||
out_p.push(p[corner]);
|
||||
out_n.push(n);
|
||||
out_t.push(
|
||||
tangents
|
||||
.as_ref()
|
||||
.filter(|_| tangents_valid)
|
||||
.map_or(generated, |x| x[ids[corner]]),
|
||||
);
|
||||
out_uv.push(uv[corner]);
|
||||
}
|
||||
}
|
||||
let out_i = (0..u32::try_from(out_p.len())
|
||||
.map_err(|_| ImportError::InvalidPrimitive("vertex count exceeds u32".into()))?)
|
||||
.collect();
|
||||
Ok((out_p, out_n, out_t, out_uv, out_i))
|
||||
}
|
||||
|
||||
pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
|
||||
let model = Gltf::from_slice(bytes)?;
|
||||
let buffers = gltf::import_buffers(&model.document, None, model.blob.clone())?;
|
||||
decode_gltf_model(Gltf::from_slice(bytes)?)
|
||||
}
|
||||
|
||||
pub fn decode_gltf_owned(bytes: Vec<u8>) -> Result<ImportedScene, ImportError> {
|
||||
let model = Gltf::from_slice(&bytes)?;
|
||||
drop(bytes);
|
||||
decode_gltf_model(model)
|
||||
}
|
||||
|
||||
fn decode_gltf_model(mut model: Gltf) -> Result<ImportedScene, ImportError> {
|
||||
// Reject external images before buffer import can turn them into a generic
|
||||
// import error (or attempt to interpret a data/external URI).
|
||||
for image in model.images() {
|
||||
if let gltf::image::Source::Uri { uri, .. } = image.source() {
|
||||
return Err(ImportError::UnsupportedImage(format!(
|
||||
"URI/external image '{uri}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let blob = model.blob.take();
|
||||
let buffers = gltf::import_buffers(&model.document, None, blob)?;
|
||||
let mut result = ImportedScene::default();
|
||||
result.materials.push(Material::default());
|
||||
for material in model.materials() {
|
||||
let pbr = material.pbr_metallic_roughness();
|
||||
let normal = material.normal_texture();
|
||||
let normal_texture = normal.as_ref().map(|x| TextureReference {
|
||||
texture: x.texture().index(),
|
||||
tex_coord: x.tex_coord(),
|
||||
});
|
||||
let occlusion = material.occlusion_texture();
|
||||
let occlusion_texture = occlusion.as_ref().map(|x| TextureReference {
|
||||
texture: x.texture().index(),
|
||||
tex_coord: x.tex_coord(),
|
||||
});
|
||||
result.materials.push(Material {
|
||||
key: MaterialKey::new(material.index().unwrap() as u32 + 1),
|
||||
base_color_factor: pbr.base_color_factor(),
|
||||
metallic_factor: pbr.metallic_factor(),
|
||||
roughness_factor: pbr.roughness_factor(),
|
||||
emissive_factor: material.emissive_factor(),
|
||||
ior: decode_ior(material.ior())?,
|
||||
alpha_mode: match material.alpha_mode() {
|
||||
gltf::material::AlphaMode::Opaque => AlphaMode::Opaque,
|
||||
gltf::material::AlphaMode::Mask => AlphaMode::Mask,
|
||||
gltf::material::AlphaMode::Blend => AlphaMode::Blend,
|
||||
},
|
||||
alpha_cutoff: material.alpha_cutoff().unwrap_or(0.5),
|
||||
double_sided: material.double_sided(),
|
||||
base_color_texture: pbr.base_color_texture().map(texture_reference),
|
||||
metallic_roughness_texture: pbr.metallic_roughness_texture().map(texture_reference),
|
||||
normal_texture,
|
||||
normal_scale: normal.map_or(1.0, |x| x.scale()),
|
||||
occlusion_texture,
|
||||
occlusion_strength: occlusion.map_or(1.0, |x| x.strength()),
|
||||
emissive_texture: material.emissive_texture().map(texture_reference),
|
||||
});
|
||||
}
|
||||
result.textures = model
|
||||
.textures()
|
||||
.map(|x| TextureMetadata {
|
||||
image: x.source().index(),
|
||||
sampler: x.sampler().index(),
|
||||
})
|
||||
.collect();
|
||||
result.samplers = model
|
||||
.samplers()
|
||||
.map(|x| SamplerMetadata {
|
||||
index: x.index().unwrap(),
|
||||
mag_filter: x.mag_filter().map(|v| format!("{v:?}")),
|
||||
min_filter: x.min_filter().map(|v| format!("{v:?}")),
|
||||
wrap_s: format!("{:?}", x.wrap_s()),
|
||||
wrap_t: format!("{:?}", x.wrap_t()),
|
||||
})
|
||||
.collect();
|
||||
result.images = model
|
||||
.images()
|
||||
.map(|x| -> Result<_, ImportError> {
|
||||
let (source, mime_type, encoded_data) = match x.source() {
|
||||
gltf::image::Source::Uri { uri, .. } => {
|
||||
return Err(ImportError::UnsupportedImage(format!(
|
||||
"URI/external image '{uri}'"
|
||||
)))
|
||||
}
|
||||
gltf::image::Source::View { view, mime_type } => {
|
||||
let data = buffers.get(view.buffer().index()).ok_or_else(|| {
|
||||
ImportError::UnsupportedImage("image buffer is missing".into())
|
||||
})?;
|
||||
let end = view.offset().checked_add(view.length()).ok_or_else(|| {
|
||||
ImportError::UnsupportedImage("image bufferView overflows".into())
|
||||
})?;
|
||||
let bytes = data.0.get(view.offset()..end).ok_or_else(|| {
|
||||
ImportError::UnsupportedImage("image bufferView is out of bounds".into())
|
||||
})?;
|
||||
(
|
||||
ImageSource::BufferView(view.index()),
|
||||
Some(mime_type.to_owned()),
|
||||
bytes.to_vec(),
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(ImageMetadata {
|
||||
index: x.index(),
|
||||
name: x.name().map(str::to_owned),
|
||||
mime_type,
|
||||
source,
|
||||
encoded_data,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
let mut seen = HashMap::new();
|
||||
fn visit(
|
||||
node: gltf::Node<'_>,
|
||||
@@ -116,12 +466,8 @@ pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut normals: Vec<_> = reader
|
||||
.read_normals()
|
||||
.map(|x| x.collect())
|
||||
.unwrap_or_default();
|
||||
normals.resize(count, [0., 1., 0.]);
|
||||
normals.truncate(count);
|
||||
let normals = reader.read_normals().map(|x| x.collect());
|
||||
let tangents = reader.read_tangents().map(|x| x.collect());
|
||||
let mut uvs: Vec<_> = reader
|
||||
.read_tex_coords(0)
|
||||
.map(|x| x.into_f32().collect())
|
||||
@@ -139,11 +485,20 @@ pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
|
||||
if indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (positions, normals, tangents, uvs, indices) =
|
||||
repair_geometry(positions, normals, tangents, uvs, indices)?;
|
||||
let primitive_material = primitive.material();
|
||||
result.geometries.push(ImportedGeometry {
|
||||
key,
|
||||
double_sided: primitive.material().double_sided(),
|
||||
material: primitive_material
|
||||
.index()
|
||||
.map_or(MaterialKey::DEFAULT, |index| {
|
||||
MaterialKey::new(index as u32 + 1)
|
||||
}),
|
||||
double_sided: primitive_material.double_sided(),
|
||||
positions,
|
||||
normals,
|
||||
tangents,
|
||||
uvs,
|
||||
indices,
|
||||
});
|
||||
@@ -189,9 +544,11 @@ pub fn install_imported(
|
||||
let created = stage.create_mesh(MeshCreateInfo {
|
||||
positions: &geometry.positions,
|
||||
normals: &geometry.normals,
|
||||
tangents: &geometry.tangents,
|
||||
uvs: &geometry.uvs,
|
||||
indices: &geometry.indices,
|
||||
pipeline: pipelines[usize::from(geometry.double_sided)],
|
||||
material: geometry.material,
|
||||
flags: RenderFlags::VISIBLE,
|
||||
default_instance_flags: RenderFlags::VISIBLE,
|
||||
default_transform: transform,
|
||||
@@ -250,3 +607,151 @@ pub fn install_imported(
|
||||
bounds,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn uri_images_are_rejected_explicitly() {
|
||||
let json = br#"{
|
||||
"asset":{"version":"2.0"},
|
||||
"images":[{"uri":"external.png"}],
|
||||
"scenes":[{"nodes":[]}],"scene":0
|
||||
}"#;
|
||||
let result = decode_gltf(json);
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(ImportError::UnsupportedImage(ref message)) if message.contains("URI/external")
|
||||
),
|
||||
"unexpected result: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_and_borrowed_decode_paths_remain_compatible() {
|
||||
let json = br#"{
|
||||
"asset":{"version":"2.0"},
|
||||
"scenes":[{"nodes":[]}],"scene":0
|
||||
}"#;
|
||||
let borrowed = decode_gltf(json).unwrap();
|
||||
let owned = decode_gltf_owned(json.to_vec()).unwrap();
|
||||
assert_eq!(borrowed.geometries.len(), owned.geometries.len());
|
||||
assert_eq!(borrowed.occurrences.len(), owned.occurrences.len());
|
||||
assert_eq!(borrowed.materials.len(), owned.materials.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_duplicates_corners_and_generates_flat_finite_frames() {
|
||||
let positions = vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
];
|
||||
let repaired = repair_geometry(
|
||||
positions,
|
||||
None,
|
||||
None,
|
||||
vec![[0.0; 2]; 4],
|
||||
vec![0, 1, 2, 0, 3, 1],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(repaired.0.len(), 6);
|
||||
assert_eq!(repaired.4, (0..6).collect::<Vec<_>>());
|
||||
assert_eq!(&repaired.1[..3], &[[0.0, 0.0, 1.0]; 3]);
|
||||
assert_eq!(&repaired.1[3..], &[[0.0, 1.0, 0.0]; 3]);
|
||||
assert!(repaired
|
||||
.2
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|component| component.is_finite()));
|
||||
assert!(repaired.2.iter().all(|tangent| tangent[3].abs() == 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_tangents_split_opposite_handedness_and_supplied_values_survive() {
|
||||
let positions = vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
[-1.0, 0.0, 0.0],
|
||||
];
|
||||
let normals = vec![[0.0, 0.0, 1.0]; 4];
|
||||
let generated = repair_geometry(
|
||||
positions.clone(),
|
||||
Some(normals.clone()),
|
||||
None,
|
||||
vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 0.0]],
|
||||
vec![0, 1, 2, 0, 2, 3],
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(generated.2[0][3], generated.2[3][3]);
|
||||
|
||||
let supplied = vec![[0.25, 0.5, 0.75, -1.0]; 4];
|
||||
let preserved = repair_geometry(
|
||||
positions,
|
||||
Some(normals),
|
||||
Some(supplied.clone()),
|
||||
vec![[0.0; 2]; 4],
|
||||
vec![0, 1, 2],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(preserved.2, supplied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_defaults_match_gltf_core_defaults() {
|
||||
let material = Material::default();
|
||||
assert_eq!(material.base_color_factor, [1.0; 4]);
|
||||
assert_eq!(material.metallic_factor, 1.0);
|
||||
assert_eq!(material.roughness_factor, 1.0);
|
||||
assert_eq!(material.alpha_cutoff, 0.5);
|
||||
assert_eq!(material.ior, 1.5);
|
||||
assert_eq!(material.key, MaterialKey::DEFAULT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_khr_materials_ior() {
|
||||
let json = br#"{
|
||||
"asset":{"version":"2.0"},
|
||||
"extensionsUsed":["KHR_materials_ior"],
|
||||
"materials":[{"extensions":{"KHR_materials_ior":{"ior":1.33}}}],
|
||||
"scenes":[{"nodes":[]}],"scene":0
|
||||
}"#;
|
||||
let imported = decode_gltf(json).unwrap();
|
||||
assert_eq!(imported.materials[1].ior, 1.33);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ior_gate_accepts_default_physical_values_and_explicit_zero_sentinel() {
|
||||
assert_eq!(decode_ior(None).unwrap(), 1.5);
|
||||
assert_eq!(decode_ior(Some(1.0)).unwrap(), 1.0);
|
||||
assert_eq!(decode_ior(Some(1.33)).unwrap(), 1.33);
|
||||
assert_eq!(decode_ior(Some(0.0)).unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ior_gate_rejects_nonphysical_and_nonfinite_values() {
|
||||
for ior in [-1.0, 0.5, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
|
||||
assert!(matches!(
|
||||
decode_ior(Some(ior)),
|
||||
Err(ImportError::InvalidIor(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_but_parseable_json_ior_is_rejected_before_packing() {
|
||||
for ior in ["-1", "0.5"] {
|
||||
let json = format!(
|
||||
r#"{{"asset":{{"version":"2.0"}},"extensionsUsed":["KHR_materials_ior"],"materials":[{{"extensions":{{"KHR_materials_ior":{{"ior":{ior}}}}}}}],"scenes":[{{"nodes":[]}}],"scene":0}}"#
|
||||
);
|
||||
assert!(matches!(
|
||||
decode_gltf(json.as_bytes()),
|
||||
Err(ImportError::InvalidIor(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
-66
@@ -1,72 +1,52 @@
|
||||
struct UniformData {
|
||||
mouse_move: vec2<f32>,
|
||||
mouse_click: vec2<f32>,
|
||||
resolution: vec2<f32>,
|
||||
time: f32,
|
||||
_padding0: f32,
|
||||
camera_position: vec4<f32>,
|
||||
}
|
||||
|
||||
struct UniformData { mouse_move: vec2<f32>, mouse_click: vec2<f32>, resolution: vec2<f32>, time: f32, _padding0: f32, camera_position: vec4<f32> }
|
||||
struct MaterialData { base_color_factor: vec4<f32>, emissive_factor: vec4<f32>, surface_factors: vec4<f32>, alpha_optics: vec4<f32>, flags: vec4<u32>, uv_sets: vec4<u32>, debug_extras: vec4<u32> }
|
||||
@group(0) @binding(0) var<uniform> uni: UniformData;
|
||||
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
|
||||
@group(2) @binding(0) var<uniform> material: MaterialData;
|
||||
@group(2) @binding(1) var base_tex: texture_2d<f32>;
|
||||
@group(2) @binding(2) var mr_tex: texture_2d<f32>;
|
||||
@group(2) @binding(3) var normal_tex: texture_2d<f32>;
|
||||
@group(2) @binding(4) var occlusion_tex: texture_2d<f32>;
|
||||
@group(2) @binding(5) var emissive_tex: texture_2d<f32>;
|
||||
@group(2) @binding(6) var base_sampler: sampler;
|
||||
@group(2) @binding(7) var mr_sampler: sampler;
|
||||
@group(2) @binding(8) var normal_sampler: sampler;
|
||||
@group(2) @binding(9) var occlusion_sampler: sampler;
|
||||
@group(2) @binding(10) var emissive_sampler: sampler;
|
||||
|
||||
struct VertexInput {
|
||||
@location(0) pos: vec3<f32>,
|
||||
@location(1) normal: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
@location(3) model_col0: vec4<f32>,
|
||||
@location(4) model_col1: vec4<f32>,
|
||||
@location(5) model_col2: vec4<f32>,
|
||||
@location(6) model_col3: vec4<f32>,
|
||||
@location(7) normal_col0: vec4<f32>,
|
||||
@location(8) normal_col1: vec4<f32>,
|
||||
@location(9) normal_col2: vec4<f32>,
|
||||
struct VertexInput { @location(0) pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) uv: vec2<f32>, @location(3) model_col0: vec4<f32>, @location(4) model_col1: vec4<f32>, @location(5) model_col2: vec4<f32>, @location(6) model_col3: vec4<f32>, @location(7) normal_col0: vec4<f32>, @location(8) normal_col1: vec4<f32>, @location(9) normal_col2: vec4<f32>, @location(10) tangent: vec4<f32> }
|
||||
struct VertexOutput { @builtin(position) clip_position: vec4<f32>, @location(0) world_pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) tangent: vec3<f32>, @location(3) bitangent: vec3<f32>, @location(4) uv: vec2<f32>, @location(5) @interpolate(flat) determinant_sign: f32 }
|
||||
fn safe_normalize(v: vec3<f32>, fallback: vec3<f32>) -> vec3<f32> { let l2 = dot(v, v); return select(fallback, v * inverseSqrt(l2), l2 > 1e-12 && l2 < 1e30); }
|
||||
@vertex fn vs_main(in: VertexInput) -> VertexOutput {
|
||||
var out: VertexOutput; let model = mat4x4<f32>(in.model_col0, in.model_col1, in.model_col2, in.model_col3);
|
||||
let linear = mat3x3<f32>(in.model_col0.xyz, in.model_col1.xyz, in.model_col2.xyz); let nm = mat3x3<f32>(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz);
|
||||
let world = model * vec4<f32>(in.pos, 1.0); let n = safe_normalize(nm * in.normal, vec3<f32>(0,1,0)); let raw_t = linear * in.tangent.xyz;
|
||||
var t = raw_t - n * dot(n, raw_t); if dot(t,t) < 1e-8 { t = cross(select(vec3<f32>(0,1,0), vec3<f32>(1,0,0), abs(n.x) < 0.9), n); } t = safe_normalize(t, vec3<f32>(1,0,0));
|
||||
out.clip_position = view_proj * world; out.world_pos = world.xyz; out.normal = n; out.tangent = t; out.bitangent = safe_normalize(cross(n,t), vec3<f32>(0,0,1)) * in.tangent.w * in.normal_col0.w; out.uv = in.uv; out.determinant_sign = in.normal_col0.w; return out;
|
||||
}
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) world_pos: vec3<f32>,
|
||||
@location(1) normal: vec3<f32>
|
||||
struct Closure { base: vec4<f32>, mr: vec2<f32>, normal_map: vec3<f32>, ao: f32, emissive: vec3<f32> }
|
||||
fn sample_closure(uv: vec2<f32>) -> Closure {
|
||||
let bits = material.flags.x; var c: Closure;
|
||||
c.base = material.base_color_factor * select(vec4<f32>(1), textureSample(base_tex, base_sampler, uv), (bits & 1u) != 0u);
|
||||
let mr = select(vec4<f32>(1), textureSample(mr_tex, mr_sampler, uv), (bits & 2u) != 0u); c.mr = vec2<f32>(clamp(material.surface_factors.x * mr.b,0,1), clamp(material.surface_factors.y * mr.g,0.045,1));
|
||||
c.normal_map = select(vec3<f32>(0.5,0.5,1), textureSample(normal_tex, normal_sampler, uv).xyz, (bits & 4u) != 0u);
|
||||
let occ = select(1.0, textureSample(occlusion_tex, occlusion_sampler, uv).r, (bits & 8u) != 0u); c.ao = mix(1.0, occ, material.surface_factors.w);
|
||||
c.emissive = material.emissive_factor.rgb * select(vec3<f32>(1), textureSample(emissive_tex, emissive_sampler, uv).rgb, (bits & 16u) != 0u); return c;
|
||||
}
|
||||
|
||||
|
||||
@vertex
|
||||
fn vs_main(in: VertexInput) -> VertexOutput {
|
||||
var out: VertexOutput;
|
||||
let model = mat4x4<f32>(
|
||||
in.model_col0,
|
||||
in.model_col1,
|
||||
in.model_col2,
|
||||
in.model_col3,
|
||||
);
|
||||
let world_position = model * vec4<f32>(in.pos, 1.0);
|
||||
out.clip_position = view_proj * world_position;
|
||||
out.world_pos = world_position.xyz;
|
||||
let normal_matrix = mat3x3<f32>(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz);
|
||||
out.normal = normalize(normal_matrix * in.normal);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let light_direction = normalize(vec3<f32>(0.35, 1.0, 0.45));
|
||||
let light_color = vec3<f32>(1.0, 0.95, 0.85);
|
||||
let base_color = vec3<f32>(0.55, 0.58, 0.62);
|
||||
|
||||
let normal = normalize(in.normal);
|
||||
let view_dir = normalize(uni.camera_position.xyz - in.world_pos);
|
||||
|
||||
let diffuse_strength = max(dot(normal, light_direction), 0.0);
|
||||
let ambient = 0.45;
|
||||
|
||||
var specular = 0.0;
|
||||
if diffuse_strength > 0.0 {
|
||||
let halfway_dir = normalize(light_direction + view_dir);
|
||||
specular = pow(max(dot(normal, halfway_dir), 0.0), 32.0);
|
||||
}
|
||||
|
||||
let lighting = min(base_color * (ambient + diffuse_strength) + light_color * specular, vec3<f32>(1.0));
|
||||
let x = select(0.0, 0.3, distance(in.clip_position.xy, uni.mouse_move) < 25.0);
|
||||
let y = select(0.0, 0.3, distance(in.clip_position.xy, uni.mouse_click) < 25.0);
|
||||
return vec4<f32>(lighting + x - y, 1.0);
|
||||
fn schlick(f0: vec3<f32>, v_h: f32) -> vec3<f32> { return f0 + (vec3<f32>(1)-f0) * pow(1.0-clamp(v_h,0,1),5.0); }
|
||||
fn ggx_d(n_h_input: f32, a: f32) -> f32 { let n_h=clamp(n_h_input,0.0,1.0); let a2=a*a; let nh2=n_h*n_h; let q=(1.0-nh2)+a2*nh2; return a2/(3.14159265*q*q); }
|
||||
fn smith_v(n_v: f32, n_l: f32, a: f32) -> f32 { let a2=a*a; let gv=n_l*sqrt(max(n_v*n_v*(1.0-a2)+a2,0)); let gl=n_v*sqrt(max(n_l*n_l*(1.0-a2)+a2,0)); return 0.5/max(gv+gl,1e-6); }
|
||||
@fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let c=sample_closure(in.uv); if material.alpha_optics.x == 1.0 && c.base.a < material.alpha_optics.y { discard; }
|
||||
let physical_front=front == (in.determinant_sign > 0); let orientation=select(-1.0,1.0,physical_front || material.flags.y == 0u); let map=c.normal_map*2.0-1.0;
|
||||
let n=safe_normalize(mat3x3<f32>(in.tangent,in.bitangent,in.normal)*safe_normalize(vec3<f32>(map.xy*material.surface_factors.z,map.z),vec3<f32>(0,0,1)),in.normal)*orientation;
|
||||
let v=safe_normalize(uni.camera_position.xyz-in.world_pos,n); let l=safe_normalize(vec3<f32>(0.35,1,0.45),vec3<f32>(0,1,0)); let h=safe_normalize(v+l,n);
|
||||
let nv=max(dot(n,v),0); let nl=max(dot(n,l),0); let nh=dot(n,h); let vh=max(dot(v,h),0); let a=c.mr.y*c.mr.y;
|
||||
let f0=mix(vec3<f32>(material.alpha_optics.w),c.base.rgb,c.mr.x); let direct_f=schlick(f0,vh); let env_f=schlick(f0,nv); let spec=direct_f*ggx_d(nh,a)*smith_v(nv,nl,a); let diffuse=(vec3<f32>(1)-direct_f)*(1.0-c.mr.x)*c.base.rgb/3.14159265;
|
||||
let sun=(diffuse+spec)*nl*vec3<f32>(3.0,2.85,2.65);
|
||||
let up=clamp(n.y*0.5+0.5,0,1); let sky=mix(vec3<f32>(0.055,0.045,0.035),vec3<f32>(0.24,0.36,0.58),up); let env_diff=(vec3<f32>(1)-env_f)*(1.0-c.mr.x)*c.base.rgb*sky;
|
||||
let reflection=reflect(-v,n); let horizon=clamp(reflection.y*0.5+0.5,0,1); let env_spec=env_f*mix(vec3<f32>(0.04,0.035,0.03),vec3<f32>(0.28,0.42,0.7),horizon)*(1.0-0.65*c.mr.y);
|
||||
var color=sun+(env_diff+env_spec)*c.ao+c.emissive;
|
||||
if material.debug_extras.y == 1u { color=n*0.5+0.5; } else if material.debug_extras.y == 2u { color=vec3<f32>(c.mr.x,c.mr.y,c.ao); } else if material.debug_extras.y == 3u { color=f0; }
|
||||
return vec4<f32>(color,1.0); // BLEND remains intentionally opaque.
|
||||
}
|
||||
|
||||
+74
-19
@@ -1,6 +1,40 @@
|
||||
use core::fmt;
|
||||
use std::cell::BorrowMutError;
|
||||
use std::sync::mpsc::TryRecvError;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
pub const RIGHT_BUTTON_MASK: u16 = 0x02;
|
||||
pub const MIDDLE_BUTTON_MASK: u16 = 0x04;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CameraDrag {
|
||||
Orbit,
|
||||
Pan,
|
||||
}
|
||||
|
||||
pub fn camera_drag(buttons: u16) -> Option<CameraDrag> {
|
||||
if buttons & MIDDLE_BUTTON_MASK != 0 {
|
||||
Some(CameraDrag::Orbit)
|
||||
} else if buttons & RIGHT_BUTTON_MASK != 0 {
|
||||
Some(CameraDrag::Pan)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_wheel_delta(delta_y: f64, delta_mode: u32, viewport_height: f64) -> Option<f32> {
|
||||
if !delta_y.is_finite() || !viewport_height.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let delta = match delta_mode {
|
||||
0 => delta_y,
|
||||
1 => delta_y * 16.0,
|
||||
2 => delta_y * viewport_height.max(1.0),
|
||||
_ => return None,
|
||||
};
|
||||
let delta = delta as f32;
|
||||
delta.is_finite().then_some(delta)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WindowEvent {
|
||||
@@ -42,10 +76,11 @@ pub struct MouseMessage {
|
||||
pub movement_y: f64,
|
||||
pub offset_x: f64,
|
||||
pub offset_y: f64,
|
||||
pub viewport_height: f64,
|
||||
}
|
||||
|
||||
impl MouseMessage {
|
||||
pub fn from_evt(event: web_sys::MouseEvent) -> Self {
|
||||
pub fn from_evt(event: &web_sys::MouseEvent, viewport_height: f64) -> Self {
|
||||
let window = web_sys::window().unwrap();
|
||||
Self {
|
||||
scale_factor: window.device_pixel_ratio(),
|
||||
@@ -57,33 +92,24 @@ impl MouseMessage {
|
||||
movement_y: event.movement_y() as f64,
|
||||
offset_x: event.offset_x() as f64,
|
||||
offset_y: event.offset_y() as f64,
|
||||
viewport_height,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_pointer_evt(event: &web_sys::PointerEvent, viewport_height: f64) -> Self {
|
||||
Self::from_evt(event.unchecked_ref(), viewport_height)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WheelMessage {
|
||||
pub scale_factor: f64,
|
||||
pub delta_x: f64,
|
||||
pub delta_y: f64,
|
||||
pub delta_z: f64,
|
||||
pub delta_mode: u32,
|
||||
pub client_x: f64,
|
||||
pub client_y: f64,
|
||||
pub delta_y_pixels: f32,
|
||||
}
|
||||
|
||||
impl WheelMessage {
|
||||
pub fn from_evt(event: web_sys::WheelEvent) -> Self {
|
||||
let window = web_sys::window().unwrap();
|
||||
Self {
|
||||
scale_factor: window.device_pixel_ratio(),
|
||||
delta_x: event.delta_x(),
|
||||
delta_y: event.delta_y(),
|
||||
delta_z: event.delta_z(),
|
||||
delta_mode: event.delta_mode(),
|
||||
client_x: event.client_x() as f64,
|
||||
client_y: event.client_y() as f64,
|
||||
}
|
||||
pub fn from_evt(event: &web_sys::WheelEvent, viewport_height: f64) -> Option<Self> {
|
||||
normalize_wheel_delta(event.delta_y(), event.delta_mode(), viewport_height)
|
||||
.map(|delta_y_pixels| Self { delta_y_pixels })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,3 +173,32 @@ impl From<BorrowMutError> for DrainEventError {
|
||||
DrainEventError::BorrowError(err)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wheel_delta_is_normalized_to_css_pixels() {
|
||||
assert_eq!(normalize_wheel_delta(12.5, 0, 640.0), Some(12.5));
|
||||
assert_eq!(normalize_wheel_delta(2.0, 1, 640.0), Some(32.0));
|
||||
assert_eq!(normalize_wheel_delta(-1.0, 2, 640.0), Some(-640.0));
|
||||
assert_eq!(normalize_wheel_delta(2.0, 2, 0.0), Some(2.0));
|
||||
assert_eq!(normalize_wheel_delta(1.0, 3, 640.0), None);
|
||||
assert_eq!(normalize_wheel_delta(f64::NAN, 0, 640.0), None);
|
||||
assert_eq!(normalize_wheel_delta(f64::INFINITY, 0, 640.0), None);
|
||||
assert_eq!(normalize_wheel_delta(1.0, 0, f64::NAN), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_drag_prefers_orbit_when_both_buttons_are_down() {
|
||||
assert_eq!(camera_drag(0), None);
|
||||
assert_eq!(camera_drag(1), None);
|
||||
assert_eq!(camera_drag(RIGHT_BUTTON_MASK), Some(CameraDrag::Pan));
|
||||
assert_eq!(camera_drag(MIDDLE_BUTTON_MASK), Some(CameraDrag::Orbit));
|
||||
assert_eq!(
|
||||
camera_drag(RIGHT_BUTTON_MASK | MIDDLE_BUTTON_MASK),
|
||||
Some(CameraDrag::Orbit)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,12 +109,15 @@ impl MainWorker {
|
||||
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>(
|
||||
events_chan: Receiver<WindowEvent>,
|
||||
ring: &'static CommandRing,
|
||||
profile: bool,
|
||||
) {
|
||||
use crate::renderer::Renderer;
|
||||
|
||||
let canvas = wait_for_canvas_transfer().await;
|
||||
|
||||
let renderer = Rc::new(RefCell::new(Renderer::<T>::new(canvas, events_chan).await));
|
||||
let renderer = Rc::new(RefCell::new(
|
||||
Renderer::<T>::new(canvas, events_chan, profile).await,
|
||||
));
|
||||
renderer.borrow_mut().command_ring = Some(ring);
|
||||
Renderer::run_render_loop(renderer);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,24 @@ impl PipelineKey {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable CPU-side identity for a device-independent material.
|
||||
#[repr(transparent)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct MaterialKey(u32);
|
||||
|
||||
impl MaterialKey {
|
||||
/// The glTF/default material.
|
||||
pub const DEFAULT: Self = Self(0);
|
||||
|
||||
pub const fn new(value: u32) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub const fn get(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(transparent)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RenderFlags(u32);
|
||||
@@ -101,9 +119,11 @@ pub struct GeometryRange {
|
||||
pub struct MeshCreateInfo<'a> {
|
||||
pub positions: &'a [[f32; 3]],
|
||||
pub normals: &'a [[f32; 3]],
|
||||
pub tangents: &'a [[f32; 4]],
|
||||
pub uvs: &'a [[f32; 2]],
|
||||
pub indices: &'a [u32],
|
||||
pub pipeline: PipelineKey,
|
||||
pub material: MaterialKey,
|
||||
pub flags: RenderFlags,
|
||||
pub default_instance_flags: RenderFlags,
|
||||
pub default_transform: ModelTransform,
|
||||
@@ -120,6 +140,7 @@ pub struct MeshView {
|
||||
pub handle: MeshHandle,
|
||||
pub geometry: GeometryRange,
|
||||
pub pipeline: PipelineKey,
|
||||
pub material: MaterialKey,
|
||||
pub flags: RenderFlags,
|
||||
pub aabb: Aabb,
|
||||
pub default_instance: InstanceHandle,
|
||||
@@ -178,6 +199,7 @@ pub fn affine_world_aabb(local: Aabb, model: ModelTransform) -> Result<Aabb, Ren
|
||||
pub struct VertexStreams<'a> {
|
||||
pub positions: &'a [[f32; 3]],
|
||||
pub normals: &'a [[f32; 3]],
|
||||
pub tangents: &'a [[f32; 4]],
|
||||
pub uvs: &'a [[f32; 2]],
|
||||
}
|
||||
|
||||
@@ -267,6 +289,7 @@ pub enum RenderDataError {
|
||||
struct VertexSoa {
|
||||
positions: Vec<[f32; 3]>,
|
||||
normals: Vec<[f32; 3]>,
|
||||
tangents: Vec<[f32; 4]>,
|
||||
uvs: Vec<[f32; 2]>,
|
||||
logical_capacity: u32,
|
||||
max_capacity: Option<u32>,
|
||||
@@ -287,6 +310,7 @@ struct MeshSoa {
|
||||
index_starts: Vec<u32>,
|
||||
index_counts: Vec<u32>,
|
||||
pipeline_keys: Vec<PipelineKey>,
|
||||
material_keys: Vec<MaterialKey>,
|
||||
flags: Vec<RenderFlags>,
|
||||
aabb_mins: Vec<[f32; 3]>,
|
||||
aabb_maxs: Vec<[f32; 3]>,
|
||||
@@ -416,6 +440,7 @@ impl RenderData {
|
||||
vertices: VertexSoa {
|
||||
positions: Vec::new(),
|
||||
normals: Vec::new(),
|
||||
tangents: Vec::new(),
|
||||
uvs: Vec::new(),
|
||||
logical_capacity: 0,
|
||||
max_capacity: config.max_vertices,
|
||||
@@ -553,6 +578,8 @@ impl RenderData {
|
||||
.copy_from_slice(info.positions);
|
||||
self.vertices.normals[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
|
||||
.copy_from_slice(info.normals);
|
||||
self.vertices.tangents[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
|
||||
.copy_from_slice(info.tangents);
|
||||
self.vertices.uvs[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
|
||||
.copy_from_slice(info.uvs);
|
||||
self.indices.values[as_usize(index_range.start)..as_usize(index_range.end)]
|
||||
@@ -567,6 +594,7 @@ impl RenderData {
|
||||
index_count,
|
||||
},
|
||||
info.pipeline,
|
||||
info.material,
|
||||
info.flags,
|
||||
bounds,
|
||||
default_instance,
|
||||
@@ -766,6 +794,7 @@ impl RenderData {
|
||||
VertexStreams {
|
||||
positions: &self.vertices.positions,
|
||||
normals: &self.vertices.normals,
|
||||
tangents: &self.vertices.tangents,
|
||||
uvs: &self.vertices.uvs,
|
||||
}
|
||||
}
|
||||
@@ -783,6 +812,7 @@ impl RenderData {
|
||||
self.instances.slots.clear();
|
||||
self.vertices.positions.clear();
|
||||
self.vertices.normals.clear();
|
||||
self.vertices.tangents.clear();
|
||||
self.vertices.uvs.clear();
|
||||
self.indices.values.clear();
|
||||
self.vertices.allocator.clear();
|
||||
@@ -800,6 +830,7 @@ impl RenderData {
|
||||
)?;
|
||||
reserve_vec(&mut self.vertices.positions, target, "vertices")?;
|
||||
reserve_vec(&mut self.vertices.normals, target, "vertices")?;
|
||||
reserve_vec(&mut self.vertices.tangents, target, "vertices")?;
|
||||
reserve_vec(&mut self.vertices.uvs, target, "vertices")?;
|
||||
self.vertices.logical_capacity = target;
|
||||
Ok(())
|
||||
@@ -821,6 +852,9 @@ impl RenderData {
|
||||
let vertices = as_usize(self.vertices.allocator.high_water());
|
||||
self.vertices.positions.resize(vertices, [0.0; 3]);
|
||||
self.vertices.normals.resize(vertices, [0.0; 3]);
|
||||
self.vertices
|
||||
.tangents
|
||||
.resize(vertices, [0.0, 0.0, 0.0, 1.0]);
|
||||
self.vertices.uvs.resize(vertices, [0.0; 2]);
|
||||
self.indices
|
||||
.values
|
||||
@@ -834,6 +868,9 @@ impl RenderData {
|
||||
self.vertices
|
||||
.normals
|
||||
.truncate(as_usize(self.vertices.allocator.high_water()));
|
||||
self.vertices
|
||||
.tangents
|
||||
.truncate(as_usize(self.vertices.allocator.high_water()));
|
||||
self.vertices
|
||||
.uvs
|
||||
.truncate(as_usize(self.vertices.allocator.high_water()));
|
||||
@@ -852,6 +889,7 @@ impl MeshSoa {
|
||||
index_starts: Vec::new(),
|
||||
index_counts: Vec::new(),
|
||||
pipeline_keys: Vec::new(),
|
||||
material_keys: Vec::new(),
|
||||
flags: Vec::new(),
|
||||
aabb_mins: Vec::new(),
|
||||
aabb_maxs: Vec::new(),
|
||||
@@ -871,6 +909,7 @@ impl MeshSoa {
|
||||
reserve_vec(&mut self.index_starts, target, "meshes")?;
|
||||
reserve_vec(&mut self.index_counts, target, "meshes")?;
|
||||
reserve_vec(&mut self.pipeline_keys, target, "meshes")?;
|
||||
reserve_vec(&mut self.material_keys, target, "meshes")?;
|
||||
reserve_vec(&mut self.flags, target, "meshes")?;
|
||||
reserve_vec(&mut self.aabb_mins, target, "meshes")?;
|
||||
reserve_vec(&mut self.aabb_maxs, target, "meshes")?;
|
||||
@@ -885,6 +924,7 @@ impl MeshSoa {
|
||||
prepared: PreparedSlot,
|
||||
geometry: GeometryRange,
|
||||
pipeline: PipelineKey,
|
||||
material: MaterialKey,
|
||||
flags: RenderFlags,
|
||||
bounds: Aabb,
|
||||
default: InstanceHandle,
|
||||
@@ -895,6 +935,7 @@ impl MeshSoa {
|
||||
resize_column(&mut self.index_starts, len, 0);
|
||||
resize_column(&mut self.index_counts, len, 0);
|
||||
resize_column(&mut self.pipeline_keys, len, PipelineKey::new(0));
|
||||
resize_column(&mut self.material_keys, len, MaterialKey::DEFAULT);
|
||||
resize_column(&mut self.flags, len, RenderFlags::NONE);
|
||||
resize_column(&mut self.aabb_mins, len, [0.0; 3]);
|
||||
resize_column(&mut self.aabb_maxs, len, [0.0; 3]);
|
||||
@@ -906,6 +947,7 @@ impl MeshSoa {
|
||||
self.index_starts[index] = geometry.index_start;
|
||||
self.index_counts[index] = geometry.index_count;
|
||||
self.pipeline_keys[index] = pipeline;
|
||||
self.material_keys[index] = material;
|
||||
self.flags[index] = flags;
|
||||
self.aabb_mins[index] = bounds.min;
|
||||
self.aabb_maxs[index] = bounds.max;
|
||||
@@ -925,6 +967,7 @@ impl MeshSoa {
|
||||
index_count: self.index_counts[index],
|
||||
},
|
||||
pipeline: self.pipeline_keys[index],
|
||||
material: self.material_keys[index],
|
||||
flags: self.flags[index],
|
||||
aabb: Aabb {
|
||||
min: self.aabb_mins[index],
|
||||
@@ -1054,7 +1097,10 @@ fn validate_geometry(info: &MeshCreateInfo<'_>) -> Result<u32, RenderDataError>
|
||||
if info.positions.is_empty() {
|
||||
return Err(RenderDataError::EmptyVertices);
|
||||
}
|
||||
if info.positions.len() != info.normals.len() || info.positions.len() != info.uvs.len() {
|
||||
if info.positions.len() != info.normals.len()
|
||||
|| info.positions.len() != info.tangents.len()
|
||||
|| info.positions.len() != info.uvs.len()
|
||||
{
|
||||
return Err(RenderDataError::MismatchedVertexStreams);
|
||||
}
|
||||
let vertex_count =
|
||||
@@ -1068,6 +1114,7 @@ fn validate_geometry(info: &MeshCreateInfo<'_>) -> Result<u32, RenderDataError>
|
||||
.iter()
|
||||
.flatten()
|
||||
.chain(info.normals.iter().flatten())
|
||||
.chain(info.tangents.iter().flatten())
|
||||
.chain(info.uvs.iter().flatten())
|
||||
.any(|value| !value.is_finite())
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::render_data::handle::SlotState;
|
||||
|
||||
const POSITIONS: [[f32; 3]; 3] = [[-1.0, 2.0, 3.0], [4.0, -2.0, 1.0], [0.0, 1.0, -3.0]];
|
||||
const NORMALS: [[f32; 3]; 3] = [[0.0, 1.0, 0.0]; 3];
|
||||
const TANGENTS: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 1.0]; 3];
|
||||
const UVS: [[f32; 2]; 3] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
|
||||
const INDICES: [u32; 3] = [0, 1, 2];
|
||||
|
||||
@@ -10,9 +11,11 @@ fn info() -> MeshCreateInfo<'static> {
|
||||
MeshCreateInfo {
|
||||
positions: &POSITIONS,
|
||||
normals: &NORMALS,
|
||||
tangents: &TANGENTS,
|
||||
uvs: &UVS,
|
||||
indices: &INDICES,
|
||||
pipeline: PipelineKey::new(7),
|
||||
material: MaterialKey::new(11),
|
||||
flags: RenderFlags::from_bits_retain(2),
|
||||
default_instance_flags: RenderFlags::VISIBLE,
|
||||
default_transform: IDENTITY_MODEL_TRANSFORM,
|
||||
@@ -83,6 +86,10 @@ fn default_instance_is_protected_and_flags_are_separate() {
|
||||
let created = data.create_mesh(info()).unwrap();
|
||||
assert!(data.instance(created.default_instance).unwrap().is_default);
|
||||
assert_eq!(data.mesh(created.mesh).unwrap().flags.bits(), 2);
|
||||
assert_eq!(
|
||||
data.mesh(created.mesh).unwrap().material,
|
||||
MaterialKey::new(11)
|
||||
);
|
||||
assert_eq!(
|
||||
data.instance(created.default_instance).unwrap().flags,
|
||||
RenderFlags::VISIBLE
|
||||
@@ -239,6 +246,7 @@ fn streams_remain_coordinated_across_interior_delete_tail_delete_and_reuse() {
|
||||
data.destroy_mesh(second.mesh).unwrap();
|
||||
assert_eq!(data.streams().positions.len(), 3);
|
||||
assert_eq!(data.streams().normals.len(), 3);
|
||||
assert_eq!(data.streams().tangents.len(), 3);
|
||||
assert_eq!(data.streams().uvs.len(), 3);
|
||||
assert_eq!(data.indices().len(), 3);
|
||||
}
|
||||
@@ -288,6 +296,7 @@ fn aabb_supports_one_point_and_multiple_points() {
|
||||
let mut one = info();
|
||||
one.positions = &point;
|
||||
one.normals = &normal;
|
||||
one.tangents = &[[1.0, 0.0, 0.0, 1.0]];
|
||||
one.uvs = &uv;
|
||||
one.indices = &index;
|
||||
let mut data = data();
|
||||
@@ -325,6 +334,13 @@ fn malformed_geometry_matrix_is_rejected_without_consumption() {
|
||||
data.create_mesh(candidate).unwrap_err(),
|
||||
RenderDataError::MismatchedVertexStreams
|
||||
);
|
||||
let short_tangents = &TANGENTS[..2];
|
||||
let mut candidate = info();
|
||||
candidate.tangents = short_tangents;
|
||||
assert_eq!(
|
||||
data.create_mesh(candidate).unwrap_err(),
|
||||
RenderDataError::MismatchedVertexStreams
|
||||
);
|
||||
let short_uvs = &UVS[..2];
|
||||
let mut candidate = info();
|
||||
candidate.uvs = short_uvs;
|
||||
@@ -339,19 +355,22 @@ fn malformed_geometry_matrix_is_rejected_without_consumption() {
|
||||
RenderDataError::EmptyIndices
|
||||
);
|
||||
|
||||
for stream in 0..3 {
|
||||
for stream in 0..4 {
|
||||
for bad in [f32::NAN, f32::INFINITY] {
|
||||
let mut positions = POSITIONS;
|
||||
let mut normals = NORMALS;
|
||||
let mut tangents = TANGENTS;
|
||||
let mut uvs = UVS;
|
||||
match stream {
|
||||
0 => positions[0][0] = bad,
|
||||
1 => normals[0][0] = bad,
|
||||
2 => tangents[0][0] = bad,
|
||||
_ => uvs[0][0] = bad,
|
||||
}
|
||||
let mut candidate = info();
|
||||
candidate.positions = &positions;
|
||||
candidate.normals = &normals;
|
||||
candidate.tangents = &tangents;
|
||||
candidate.uvs = &uvs;
|
||||
assert_eq!(
|
||||
data.create_mesh(candidate).unwrap_err(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
mod compiler;
|
||||
mod compiler_v2;
|
||||
mod contracts_v2;
|
||||
mod plan_v2;
|
||||
mod registry;
|
||||
mod runtime;
|
||||
mod runtime_v2;
|
||||
mod schema;
|
||||
mod schema_v2;
|
||||
|
||||
pub use compiler::{
|
||||
compile, compile_with, parse_and_compile, AllocationClass, CompiledGraph, CompiledOutput,
|
||||
@@ -11,12 +16,38 @@ pub use compiler::{
|
||||
ExecutorRegistry, ExecutorResolution, Lifetime, NormalizedParameters, SceneForwardExecutors,
|
||||
TextureAllocationKey, TextureUsage, TransientAllocation,
|
||||
};
|
||||
pub use registry::{CompiledGraphId, Registry};
|
||||
pub use compiler_v2::{compile_v2, mesh_predicate_matches, parse_and_compile_v2};
|
||||
pub use contracts_v2::*;
|
||||
pub use plan_v2::*;
|
||||
pub use registry::{CompiledGraphId, RegisteredGraph, Registry};
|
||||
pub use runtime::{
|
||||
class_offsets, resolve_extent, runtime_texture_key, validate_activatable, ResolvedExtent,
|
||||
RuntimeTextureKey,
|
||||
};
|
||||
pub use runtime_v2::*;
|
||||
pub use schema::*;
|
||||
pub use schema_v2::*;
|
||||
|
||||
pub fn parse_and_compile_any(bytes: &[u8]) -> Result<RegisteredGraph, GraphError> {
|
||||
if bytes.len() > MAX_JSON_BYTES {
|
||||
return Err(GraphError::new(
|
||||
"GRAPH_PAYLOAD_TOO_LARGE",
|
||||
"graph payload exceeds 1 MiB",
|
||||
));
|
||||
}
|
||||
let text = std::str::from_utf8(bytes)
|
||||
.map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph payload is not UTF-8"))?;
|
||||
let value: serde_json::Value = serde_json::from_str(text)
|
||||
.map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?;
|
||||
match value.get("schemaVersion").and_then(|v| v.as_u64()) {
|
||||
Some(1) => parse_and_compile(bytes).map(RegisteredGraph::V1),
|
||||
Some(2) => parse_and_compile_v2(bytes).map(RegisteredGraph::V2),
|
||||
_ => Err(GraphError::new(
|
||||
"GRAPH_SCHEMA_UNSUPPORTED",
|
||||
"schemaVersion must be exactly 1 or 2",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub const MAX_JSON_BYTES: usize = 1024 * 1024;
|
||||
pub const MAX_RESOURCES: usize = 1024;
|
||||
@@ -56,3 +87,5 @@ impl GraphError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_v2;
|
||||
|
||||
@@ -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)]
|
||||
pub struct CompiledGraphId {
|
||||
pub slot: u32,
|
||||
@@ -12,13 +32,14 @@ impl From<CompiledGraphId> for [u32; 2] {
|
||||
#[derive(Debug)]
|
||||
struct Slot {
|
||||
generation: u32,
|
||||
value: Option<CompiledGraph>,
|
||||
value: Option<RegisteredGraph>,
|
||||
retired: bool,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct Registry {
|
||||
slots: Vec<Slot>,
|
||||
capacity: u32,
|
||||
latest_revisions: HashMap<String, u32>,
|
||||
}
|
||||
impl Default for Registry {
|
||||
fn default() -> Self {
|
||||
@@ -30,34 +51,25 @@ impl Registry {
|
||||
Self {
|
||||
slots: vec![],
|
||||
capacity,
|
||||
latest_revisions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
pub fn compile(
|
||||
&mut self,
|
||||
bytes: &[u8],
|
||||
) -> Result<(CompiledGraphId, serde_json::Value), GraphError> {
|
||||
let graph = parse_and_compile(bytes)?;
|
||||
if let Some((i, s)) = self.slots.iter_mut().enumerate().find(|(_, s)| {
|
||||
s.value
|
||||
.as_ref()
|
||||
.is_some_and(|g| g.graph_id == graph.graph_id)
|
||||
}) {
|
||||
if graph.revision <= s.value.as_ref().unwrap().revision {
|
||||
let graph = parse_and_compile_any(bytes)?;
|
||||
let (graph_id, revision) = graph.identity();
|
||||
if self
|
||||
.latest_revisions
|
||||
.get(graph_id)
|
||||
.is_some_and(|latest| revision <= *latest)
|
||||
{
|
||||
return Err(GraphError::new(
|
||||
"GRAPH_REVISION_CONFLICT",
|
||||
"revision must increase",
|
||||
));
|
||||
}
|
||||
let 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
|
||||
.slots
|
||||
.iter()
|
||||
@@ -84,10 +96,26 @@ impl Registry {
|
||||
generation: self.slots[i].generation,
|
||||
};
|
||||
let summary = graph.summary(id.into());
|
||||
self.latest_revisions.insert(graph_id.to_owned(), revision);
|
||||
self.slots[i].value = Some(graph);
|
||||
Ok((id, summary))
|
||||
}
|
||||
pub fn get(&self, id: CompiledGraphId) -> Result<&CompiledGraph, GraphError> {
|
||||
match self
|
||||
.slots
|
||||
.get(id.slot as usize)
|
||||
.filter(|s| s.generation == id.generation)
|
||||
.and_then(|s| s.value.as_ref())
|
||||
.ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))?
|
||||
{
|
||||
RegisteredGraph::V1(g) => Ok(g),
|
||||
RegisteredGraph::V2(_) => Err(GraphError::new(
|
||||
"GRAPH_EXECUTION_UNSUPPORTED",
|
||||
"schemaVersion 2 activation is unavailable until Phase 4",
|
||||
)),
|
||||
}
|
||||
}
|
||||
pub fn get_registered(&self, id: CompiledGraphId) -> Result<&RegisteredGraph, GraphError> {
|
||||
self.slots
|
||||
.get(id.slot as usize)
|
||||
.filter(|s| s.generation == id.generation)
|
||||
@@ -95,7 +123,7 @@ impl Registry {
|
||||
.ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))
|
||||
}
|
||||
pub fn contains(&self, id: CompiledGraphId) -> bool {
|
||||
self.get(id).is_ok()
|
||||
self.get_registered(id).is_ok()
|
||||
}
|
||||
pub fn drop_graph(&mut self, id: CompiledGraphId) -> Result<(), GraphError> {
|
||||
let s = self
|
||||
|
||||
@@ -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]
|
||||
fn registry_revision_replaces_in_place() {
|
||||
let mut r = Registry::new(1);
|
||||
fn registry_revision_creates_immutable_handle() {
|
||||
let mut r = Registry::new(2);
|
||||
let (a, _) = r.compile(&empty("g", 1)).unwrap();
|
||||
let (b, _) = r.compile(&empty("g", 2)).unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(r.get(a).unwrap().revision, 2);
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(r.get(a).unwrap().revision, 1);
|
||||
assert_eq!(r.get(b).unwrap().revision, 2);
|
||||
}
|
||||
#[test]
|
||||
fn registry_revision_conflict() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 crate::render_data::{MeshHandle, PipelineKey, RenderData, RenderFlags};
|
||||
use crate::{
|
||||
render_data::{MaterialKey, MeshHandle, PipelineKey, RenderFlags},
|
||||
renderer::scene_frame::SceneFramePlan,
|
||||
};
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
|
||||
#[repr(C)]
|
||||
@@ -15,10 +18,38 @@ pub struct GpuInstance {
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DrawItem {
|
||||
pub pipeline: PipelineKey,
|
||||
pub material: MaterialKey,
|
||||
pub mesh: MeshHandle,
|
||||
pub indices: std::ops::Range<u32>,
|
||||
pub base_vertex: i32,
|
||||
pub instances: std::ops::Range<u32>,
|
||||
pub effective_visible: bool,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq)]
|
||||
pub struct GpuLocalAabb {
|
||||
pub min: [f32; 4],
|
||||
pub max: [f32; 4],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq, Eq)]
|
||||
pub struct DrawSlotMetadata {
|
||||
pub index_count: u32,
|
||||
pub first_index: u32,
|
||||
pub base_vertex: i32,
|
||||
pub instance_index: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq, Eq)]
|
||||
pub struct DrawIndexedIndirect {
|
||||
pub index_count: u32,
|
||||
pub instance_count: u32,
|
||||
pub first_index: u32,
|
||||
pub base_vertex: i32,
|
||||
pub first_instance: u32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -26,30 +57,60 @@ pub struct GpuScenePlan {
|
||||
pub positions: Vec<[f32; 3]>,
|
||||
pub normals: Vec<[f32; 3]>,
|
||||
pub uvs: Vec<[f32; 2]>,
|
||||
pub tangents: Vec<[f32; 4]>,
|
||||
pub indices: Vec<u32>,
|
||||
pub instances: Vec<GpuInstance>,
|
||||
pub draws: Vec<DrawItem>,
|
||||
pub local_aabbs: Vec<GpuLocalAabb>,
|
||||
pub effective_visibility: Vec<u32>,
|
||||
pub draw_metadata: Vec<DrawSlotMetadata>,
|
||||
pub commands: Vec<DrawIndexedIndirect>,
|
||||
}
|
||||
|
||||
fn visibility_matches(
|
||||
predicate: crate::render_graph::TriStatePredicate,
|
||||
mesh: RenderFlags,
|
||||
instance: RenderFlags,
|
||||
) -> bool {
|
||||
let effective = mesh.contains(RenderFlags::VISIBLE) && instance.contains(RenderFlags::VISIBLE);
|
||||
match predicate {
|
||||
crate::render_graph::TriStatePredicate::Any => true,
|
||||
crate::render_graph::TriStatePredicate::RequiredTrue => effective,
|
||||
crate::render_graph::TriStatePredicate::RequiredFalse => !effective,
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuScenePlan {
|
||||
pub fn build(data: &RenderData) -> Result<Self, &'static str> {
|
||||
pub fn build(data: &SceneFramePlan) -> Result<Self, &'static str> {
|
||||
self::GpuScenePlan::build_with_query(
|
||||
data,
|
||||
crate::render_graph::MeshQueryRuntimeKeyV2 {
|
||||
visible: crate::render_graph::TriStatePredicate::RequiredTrue,
|
||||
frustum_culled: crate::render_graph::TriStatePredicate::Any,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_with_query(
|
||||
data: &SceneFramePlan,
|
||||
query: crate::render_graph::MeshQueryRuntimeKeyV2,
|
||||
) -> Result<Self, &'static str> {
|
||||
let _ = query; // Packing is canonical; predicates are evaluated by the GPU.
|
||||
let mut plan = Self::default();
|
||||
let mut meshes: Vec<_> = data
|
||||
.meshes()
|
||||
.filter(|(_, mesh)| mesh.flags.contains(RenderFlags::VISIBLE))
|
||||
.collect();
|
||||
meshes.sort_by_key(|(handle, mesh)| {
|
||||
(mesh.pipeline.get(), handle.slot(), handle.generation())
|
||||
let mut meshes: Vec<_> = data.meshes.iter().collect();
|
||||
meshes.sort_by_key(|mesh| {
|
||||
(
|
||||
mesh.pipeline.get(),
|
||||
mesh.material.get(),
|
||||
mesh.handle.slot(),
|
||||
mesh.handle.generation(),
|
||||
)
|
||||
});
|
||||
let streams = data.streams();
|
||||
for (handle, mesh) in meshes {
|
||||
let mut occurrences: Vec<_> = data
|
||||
.instances()
|
||||
.filter(|(_, instance)| {
|
||||
instance.mesh == handle && instance.flags.contains(RenderFlags::VISIBLE)
|
||||
})
|
||||
for mesh in meshes {
|
||||
let occurrences: Vec<_> = data.mesh_occurrence_indices[mesh.occurrence_range.clone()]
|
||||
.iter()
|
||||
.map(|&index| &data.occurrences[index])
|
||||
.collect();
|
||||
occurrences.sort_by_key(|(handle, _)| (handle.slot(), handle.generation()));
|
||||
if occurrences.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -59,23 +120,25 @@ impl GpuScenePlan {
|
||||
.checked_add(mesh.geometry.vertex_count as usize)
|
||||
.ok_or("vertex range overflow")?;
|
||||
plan.positions.extend_from_slice(
|
||||
streams
|
||||
.positions
|
||||
data.positions
|
||||
.get(source_start..source_end)
|
||||
.ok_or("invalid vertex range")?,
|
||||
);
|
||||
plan.normals.extend_from_slice(
|
||||
streams
|
||||
.normals
|
||||
data.normals
|
||||
.get(source_start..source_end)
|
||||
.ok_or("invalid normal range")?,
|
||||
);
|
||||
plan.uvs.extend_from_slice(
|
||||
streams
|
||||
.uvs
|
||||
data.uvs
|
||||
.get(source_start..source_end)
|
||||
.ok_or("invalid uv range")?,
|
||||
);
|
||||
plan.tangents.extend_from_slice(
|
||||
data.tangents
|
||||
.get(source_start..source_end)
|
||||
.ok_or("invalid tangent range")?,
|
||||
);
|
||||
let index_start =
|
||||
u32::try_from(plan.indices.len()).map_err(|_| "index start exceeds u32")?;
|
||||
let source_index = mesh.geometry.index_start as usize;
|
||||
@@ -83,20 +146,24 @@ impl GpuScenePlan {
|
||||
.checked_add(mesh.geometry.index_count as usize)
|
||||
.ok_or("index range overflow")?;
|
||||
plan.indices.extend_from_slice(
|
||||
data.indices()
|
||||
data.indices
|
||||
.get(source_index..source_index_end)
|
||||
.ok_or("invalid index range")?,
|
||||
);
|
||||
let instance_start =
|
||||
u32::try_from(plan.instances.len()).map_err(|_| "instance start exceeds u32")?;
|
||||
for (_, instance) in occurrences {
|
||||
for instance in occurrences {
|
||||
let instance_start = u32::try_from(plan.instances.len())
|
||||
.map_err(|_| "instance start exceeds u32")?;
|
||||
let m = &instance.model;
|
||||
let determinant = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2])
|
||||
- m[1][0] * (m[0][1] * m[2][2] - m[2][1] * m[0][2])
|
||||
+ m[2][0] * (m[0][1] * m[1][2] - m[1][1] * m[0][2]);
|
||||
plan.instances.push(GpuInstance {
|
||||
model: instance.model,
|
||||
normal_0: [
|
||||
instance.normal[0][0],
|
||||
instance.normal[0][1],
|
||||
instance.normal[0][2],
|
||||
0.0,
|
||||
if determinant < 0.0 { -1.0 } else { 1.0 },
|
||||
],
|
||||
normal_1: [
|
||||
instance.normal[1][0],
|
||||
@@ -111,20 +178,42 @@ impl GpuScenePlan {
|
||||
0.0,
|
||||
],
|
||||
});
|
||||
}
|
||||
let base_vertex =
|
||||
i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?;
|
||||
let end = index_start
|
||||
.checked_add(mesh.geometry.index_count)
|
||||
.ok_or("draw index range overflow")?;
|
||||
let effective_visible = mesh.flags.contains(RenderFlags::VISIBLE)
|
||||
&& instance.flags.contains(RenderFlags::VISIBLE);
|
||||
plan.local_aabbs.push(GpuLocalAabb {
|
||||
min: [mesh.aabb.min[0], mesh.aabb.min[1], mesh.aabb.min[2], 0.],
|
||||
max: [mesh.aabb.max[0], mesh.aabb.max[1], mesh.aabb.max[2], 0.],
|
||||
});
|
||||
plan.effective_visibility.push(effective_visible as u32);
|
||||
plan.draw_metadata.push(DrawSlotMetadata {
|
||||
index_count: mesh.geometry.index_count,
|
||||
first_index: index_start,
|
||||
base_vertex,
|
||||
instance_index: instance_start,
|
||||
});
|
||||
plan.commands.push(DrawIndexedIndirect {
|
||||
index_count: mesh.geometry.index_count,
|
||||
instance_count: 0,
|
||||
first_index: index_start,
|
||||
base_vertex,
|
||||
first_instance: 0,
|
||||
});
|
||||
plan.draws.push(DrawItem {
|
||||
pipeline: mesh.pipeline,
|
||||
mesh: handle,
|
||||
indices: index_start
|
||||
..index_start
|
||||
.checked_add(mesh.geometry.index_count)
|
||||
.ok_or("draw index range overflow")?,
|
||||
base_vertex: i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?,
|
||||
instances: instance_start
|
||||
..u32::try_from(plan.instances.len())
|
||||
.map_err(|_| "instance end exceeds u32")?,
|
||||
material: mesh.material,
|
||||
mesh: mesh.handle,
|
||||
indices: index_start..end,
|
||||
base_vertex,
|
||||
instances: instance_start..instance_start + 1,
|
||||
effective_visible,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
}
|
||||
@@ -148,7 +237,7 @@ pub fn required_buffer_capacity(
|
||||
Ok(grown.min(maximum))
|
||||
}
|
||||
|
||||
pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 4] {
|
||||
pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 5] {
|
||||
const INSTANCE_ATTRIBUTES: [wgpu::VertexAttribute; 7] = wgpu::vertex_attr_array![3 => Float32x4, 4 => Float32x4, 5 => Float32x4, 6 => Float32x4, 7 => Float32x4, 8 => Float32x4, 9 => Float32x4];
|
||||
[
|
||||
wgpu::VertexBufferLayout {
|
||||
@@ -171,6 +260,11 @@ pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 4] {
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &INSTANCE_ATTRIBUTES,
|
||||
},
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![10 => Float32x4],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -183,12 +277,45 @@ pub struct BufferSlot {
|
||||
#[derive(Default)]
|
||||
pub struct GpuSceneCache {
|
||||
revision: Option<u64>,
|
||||
query: Option<crate::render_graph::MeshQueryRuntimeKeyV2>,
|
||||
pub positions: BufferSlot,
|
||||
pub normals: BufferSlot,
|
||||
pub uvs: BufferSlot,
|
||||
pub tangents: BufferSlot,
|
||||
pub indices: BufferSlot,
|
||||
pub instances: BufferSlot,
|
||||
pub local_aabbs: BufferSlot,
|
||||
pub effective_visibility: BufferSlot,
|
||||
pub draw_metadata: BufferSlot,
|
||||
pub frustum_flags: BufferSlot,
|
||||
pub indirect_commands: BufferSlot,
|
||||
pub draws: Vec<DrawItem>,
|
||||
compute: Option<CullingCompute>,
|
||||
}
|
||||
|
||||
struct CullingCompute {
|
||||
params: wgpu::Buffer,
|
||||
bind_group: wgpu::BindGroup,
|
||||
frustum_pipeline: wgpu::ComputePipeline,
|
||||
query_pipeline: wgpu::ComputePipeline,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
struct CullingParams {
|
||||
planes: [[f32; 4]; 6],
|
||||
count: u32,
|
||||
visible_predicate: u32,
|
||||
frustum_predicate: u32,
|
||||
_pad: u32,
|
||||
}
|
||||
|
||||
fn predicate_code(value: crate::render_graph::TriStatePredicate) -> u32 {
|
||||
match value {
|
||||
crate::render_graph::TriStatePredicate::Any => 0,
|
||||
crate::render_graph::TriStatePredicate::RequiredTrue => 1,
|
||||
crate::render_graph::TriStatePredicate::RequiredFalse => 2,
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuSceneCache {
|
||||
@@ -196,15 +323,34 @@ impl GpuSceneCache {
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
data: &RenderData,
|
||||
data: &SceneFramePlan,
|
||||
) -> Result<(), String> {
|
||||
if self.revision == Some(data.revision()) {
|
||||
self.upload_with_query(
|
||||
device,
|
||||
queue,
|
||||
data,
|
||||
crate::render_graph::MeshQueryRuntimeKeyV2 {
|
||||
visible: crate::render_graph::TriStatePredicate::RequiredTrue,
|
||||
frustum_culled: crate::render_graph::TriStatePredicate::Any,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn upload_with_query(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
data: &SceneFramePlan,
|
||||
query: crate::render_graph::MeshQueryRuntimeKeyV2,
|
||||
) -> Result<(), String> {
|
||||
if self.revision == Some(data.revision) && self.query == Some(query) {
|
||||
return Ok(());
|
||||
}
|
||||
let plan = GpuScenePlan::build(data).map_err(str::to_owned)?;
|
||||
let plan = GpuScenePlan::build_with_query(data, query).map_err(str::to_owned)?;
|
||||
if plan.draws.is_empty() {
|
||||
self.draws.clear();
|
||||
self.revision = Some(data.revision());
|
||||
self.revision = Some(data.revision);
|
||||
self.query = Some(query);
|
||||
return Ok(());
|
||||
}
|
||||
let maximum = device.limits().max_buffer_size;
|
||||
@@ -218,18 +364,30 @@ impl GpuSceneCache {
|
||||
bytes(&plan.positions)?,
|
||||
bytes(&plan.normals)?,
|
||||
bytes(&plan.uvs)?,
|
||||
bytes(&plan.tangents)?,
|
||||
bytes(&plan.indices)?,
|
||||
bytes(&plan.instances)?,
|
||||
bytes(&plan.local_aabbs)?,
|
||||
bytes(&plan.effective_visibility)?,
|
||||
bytes(&plan.draw_metadata)?,
|
||||
bytes(&plan.effective_visibility)?,
|
||||
bytes(&plan.commands)?,
|
||||
];
|
||||
let old = [
|
||||
self.positions.capacity,
|
||||
self.normals.capacity,
|
||||
self.uvs.capacity,
|
||||
self.tangents.capacity,
|
||||
self.indices.capacity,
|
||||
self.instances.capacity,
|
||||
self.local_aabbs.capacity,
|
||||
self.effective_visibility.capacity,
|
||||
self.draw_metadata.capacity,
|
||||
self.frustum_flags.capacity,
|
||||
self.indirect_commands.capacity,
|
||||
];
|
||||
let mut capacities = [0; 5];
|
||||
for i in 0..5 {
|
||||
let mut capacities = [0; 11];
|
||||
for i in 0..11 {
|
||||
capacities[i] =
|
||||
required_buffer_capacity(old[i], required[i], maximum).map_err(str::to_owned)?;
|
||||
}
|
||||
@@ -237,18 +395,30 @@ impl GpuSceneCache {
|
||||
wgpu::BufferUsages::VERTEX,
|
||||
wgpu::BufferUsages::VERTEX,
|
||||
wgpu::BufferUsages::VERTEX,
|
||||
wgpu::BufferUsages::INDEX,
|
||||
wgpu::BufferUsages::VERTEX,
|
||||
wgpu::BufferUsages::INDEX,
|
||||
wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDIRECT,
|
||||
];
|
||||
let labels = [
|
||||
"scene positions",
|
||||
"scene normals",
|
||||
"scene uvs",
|
||||
"scene tangents",
|
||||
"scene indices",
|
||||
"scene instances",
|
||||
"scene local aabbs",
|
||||
"scene effective visibility",
|
||||
"scene draw metadata",
|
||||
"scene frustum flags",
|
||||
"scene indirect commands",
|
||||
];
|
||||
let mut replacements: [Option<wgpu::Buffer>; 5] = Default::default();
|
||||
for i in 0..5 {
|
||||
let mut replacements: [Option<wgpu::Buffer>; 11] = Default::default();
|
||||
for i in 0..11 {
|
||||
if capacities[i] != old[i] {
|
||||
replacements[i] = Some(device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(labels[i]),
|
||||
@@ -262,8 +432,14 @@ impl GpuSceneCache {
|
||||
&mut self.positions,
|
||||
&mut self.normals,
|
||||
&mut self.uvs,
|
||||
&mut self.tangents,
|
||||
&mut self.indices,
|
||||
&mut self.instances,
|
||||
&mut self.local_aabbs,
|
||||
&mut self.effective_visibility,
|
||||
&mut self.draw_metadata,
|
||||
&mut self.frustum_flags,
|
||||
&mut self.indirect_commands,
|
||||
];
|
||||
for (i, slot) in slots.into_iter().enumerate() {
|
||||
if let Some(buffer) = replacements[i].take() {
|
||||
@@ -275,15 +451,27 @@ impl GpuSceneCache {
|
||||
bytemuck::cast_slice(&plan.positions),
|
||||
bytemuck::cast_slice(&plan.normals),
|
||||
bytemuck::cast_slice(&plan.uvs),
|
||||
bytemuck::cast_slice(&plan.tangents),
|
||||
bytemuck::cast_slice(&plan.indices),
|
||||
bytemuck::cast_slice(&plan.instances),
|
||||
bytemuck::cast_slice(&plan.local_aabbs),
|
||||
bytemuck::cast_slice(&plan.effective_visibility),
|
||||
bytemuck::cast_slice(&plan.draw_metadata),
|
||||
bytemuck::cast_slice(&plan.effective_visibility),
|
||||
bytemuck::cast_slice(&plan.commands),
|
||||
];
|
||||
let slots = [
|
||||
&self.positions,
|
||||
&self.normals,
|
||||
&self.uvs,
|
||||
&self.tangents,
|
||||
&self.indices,
|
||||
&self.instances,
|
||||
&self.local_aabbs,
|
||||
&self.effective_visibility,
|
||||
&self.draw_metadata,
|
||||
&self.frustum_flags,
|
||||
&self.indirect_commands,
|
||||
];
|
||||
for (slot, contents) in slots.into_iter().zip(contents) {
|
||||
if !contents.is_empty() {
|
||||
@@ -295,15 +483,205 @@ impl GpuSceneCache {
|
||||
}
|
||||
}
|
||||
self.draws = plan.draws;
|
||||
self.revision = Some(data.revision());
|
||||
self.rebuild_compute(device)?;
|
||||
self.revision = Some(data.revision);
|
||||
self.query = Some(query);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rebuild_compute(&mut self, device: &wgpu::Device) -> Result<(), String> {
|
||||
if self.draws.is_empty() {
|
||||
self.compute = None;
|
||||
return Ok(());
|
||||
}
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("scene culling compute"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("culling.wgsl").into()),
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("scene culling layout"),
|
||||
entries: &(0..7)
|
||||
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: if binding == 0 {
|
||||
wgpu::BufferBindingType::Uniform
|
||||
} else {
|
||||
wgpu::BufferBindingType::Storage {
|
||||
read_only: binding < 5,
|
||||
}
|
||||
},
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
});
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("scene culling params"),
|
||||
size: size_of::<CullingParams>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let buffers = [
|
||||
&self.instances,
|
||||
&self.local_aabbs,
|
||||
&self.effective_visibility,
|
||||
&self.draw_metadata,
|
||||
&self.frustum_flags,
|
||||
&self.indirect_commands,
|
||||
];
|
||||
let mut entries = vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: params.as_entire_binding(),
|
||||
}];
|
||||
for (i, slot) in buffers.iter().enumerate() {
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: i as u32 + 1,
|
||||
resource: slot
|
||||
.buffer
|
||||
.as_ref()
|
||||
.ok_or("culling buffer absent")?
|
||||
.as_entire_binding(),
|
||||
});
|
||||
}
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("scene culling group"),
|
||||
layout: &layout,
|
||||
entries: &entries,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("scene culling pipeline layout"),
|
||||
bind_group_layouts: &[&layout],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
let pipeline = |entry| {
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(entry),
|
||||
layout: Some(&pipeline_layout),
|
||||
module: &shader,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
self.compute = Some(CullingCompute {
|
||||
params,
|
||||
bind_group,
|
||||
frustum_pipeline: pipeline("frustum_cull"),
|
||||
query_pipeline: pipeline("mesh_query"),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_culling_params(
|
||||
&self,
|
||||
queue: &wgpu::Queue,
|
||||
planes: Option<[[f32; 4]; 6]>,
|
||||
query: crate::render_graph::MeshQueryRuntimeKeyV2,
|
||||
) {
|
||||
if let Some(compute) = &self.compute {
|
||||
if let Some(planes) = planes {
|
||||
queue.write_buffer(&compute.params, 0, bytemuck::bytes_of(&planes));
|
||||
}
|
||||
let tail = CullingParams {
|
||||
planes: [[0.0; 4]; 6],
|
||||
count: self.draws.len() as u32,
|
||||
visible_predicate: predicate_code(query.visible),
|
||||
frustum_predicate: predicate_code(query.frustum_culled),
|
||||
_pad: 0,
|
||||
};
|
||||
queue.write_buffer(
|
||||
&compute.params,
|
||||
std::mem::offset_of!(CullingParams, count) as u64,
|
||||
&bytemuck::bytes_of(&tail)[std::mem::offset_of!(CullingParams, count)..],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode_frustum_cull(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
|
||||
profile_id: &str,
|
||||
) {
|
||||
let Some(c) = &self.compute else { return };
|
||||
let timestamps = profile.and_then(|p| p.compute_writes(profile_id));
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("frustum_cull"),
|
||||
timestamp_writes: timestamps,
|
||||
});
|
||||
pass.set_pipeline(&c.frustum_pipeline);
|
||||
pass.set_bind_group(0, &c.bind_group, &[]);
|
||||
pass.dispatch_workgroups((self.draws.len() as u32 + 63) / 64, 1, 1);
|
||||
}
|
||||
pub(crate) fn encode_mesh_query(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
|
||||
profile_id: &str,
|
||||
) {
|
||||
let Some(c) = &self.compute else { return };
|
||||
let timestamps = profile.and_then(|p| p.compute_writes(profile_id));
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("mesh_query"),
|
||||
timestamp_writes: timestamps,
|
||||
});
|
||||
pass.set_pipeline(&c.query_pipeline);
|
||||
pass.set_bind_group(0, &c.bind_group, &[]);
|
||||
pass.dispatch_workgroups((self.draws.len() as u32 + 63) / 64, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::render_data::{MeshCreateInfo, RenderDataConfig, IDENTITY_MODEL_TRANSFORM};
|
||||
|
||||
#[test]
|
||||
fn mesh_query_source_guards_optional_flag_buffer_reads() {
|
||||
let source = include_str!("culling.wgsl");
|
||||
let visible_guard = source.find("if (params.visible_predicate != 0u)").unwrap();
|
||||
let visible_load = source.find("matches(authored_visible[i]").unwrap();
|
||||
let frustum_guard = source.find("if (params.frustum_predicate != 0u)").unwrap();
|
||||
let frustum_load = source.find("matches(frustum_flags[i]").unwrap();
|
||||
assert!(visible_guard < visible_load && frustum_guard < frustum_load);
|
||||
for binding in 0..=6 {
|
||||
assert!(source.contains(&format!("@binding({binding})")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_visibility_handles_every_mesh_instance_combination() {
|
||||
use crate::render_graph::TriStatePredicate::{Any, RequiredFalse, RequiredTrue};
|
||||
for (mesh, instance, effective) in [
|
||||
(false, false, false),
|
||||
(false, true, false),
|
||||
(true, false, false),
|
||||
(true, true, true),
|
||||
] {
|
||||
let flags = |visible| {
|
||||
if visible {
|
||||
RenderFlags::VISIBLE
|
||||
} else {
|
||||
RenderFlags::NONE
|
||||
}
|
||||
};
|
||||
assert!(visibility_matches(Any, flags(mesh), flags(instance)));
|
||||
assert_eq!(
|
||||
visibility_matches(RequiredTrue, flags(mesh), flags(instance)),
|
||||
effective
|
||||
);
|
||||
assert_eq!(
|
||||
visibility_matches(RequiredFalse, flags(mesh), flags(instance)),
|
||||
!effective
|
||||
);
|
||||
}
|
||||
}
|
||||
use crate::render_data::{
|
||||
MeshCreateInfo, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM,
|
||||
};
|
||||
#[test]
|
||||
fn instance_is_112_bytes_and_padding_is_zero() {
|
||||
assert_eq!(size_of::<GpuInstance>(), 112);
|
||||
@@ -321,6 +699,28 @@ mod tests {
|
||||
assert!(required_buffer_capacity(0, 33, 32).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn mirrored_model_stores_negative_determinant_sign_in_padding() {
|
||||
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
|
||||
let mut mirrored = IDENTITY_MODEL_TRANSFORM;
|
||||
mirrored[0][0] = -1.0;
|
||||
data.create_mesh(MeshCreateInfo {
|
||||
positions: &[[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]],
|
||||
normals: &[[0., 0., 1.]; 3],
|
||||
tangents: &[[1., 0., 0., 1.]; 3],
|
||||
uvs: &[[0., 0.]; 3],
|
||||
indices: &[0, 1, 2],
|
||||
pipeline: PipelineKey::new(0),
|
||||
material: MaterialKey::DEFAULT,
|
||||
flags: RenderFlags::VISIBLE,
|
||||
default_instance_flags: RenderFlags::VISIBLE,
|
||||
default_transform: mirrored,
|
||||
})
|
||||
.unwrap();
|
||||
let frame = crate::renderer::scene_frame::SceneFramePlan::build(&data).unwrap();
|
||||
let plan = GpuScenePlan::build(&frame).unwrap();
|
||||
assert_eq!(plan.instances[0].normal_0[3], -1.0);
|
||||
}
|
||||
#[test]
|
||||
fn capacity_reuses_and_layout_matches_shader_contract() {
|
||||
assert_eq!(required_buffer_capacity(16, 12, 32), Ok(16));
|
||||
assert_eq!(required_buffer_capacity(0, 1, 32), Ok(1));
|
||||
@@ -330,7 +730,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|layout| layout.array_stride)
|
||||
.collect::<Vec<_>>(),
|
||||
[12, 12, 8, 112]
|
||||
[12, 12, 8, 112, 16]
|
||||
);
|
||||
assert_eq!(
|
||||
layouts[3]
|
||||
@@ -342,7 +742,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn plan_orders_pipelines_skips_hidden_and_uses_local_indices() {
|
||||
fn plan_is_canonical_and_predicate_independent() {
|
||||
let mut data = RenderData::new(RenderDataConfig {
|
||||
initial_vertices: 0,
|
||||
initial_indices: 0,
|
||||
@@ -359,9 +759,11 @@ mod tests {
|
||||
data.create_mesh(MeshCreateInfo {
|
||||
positions: &p,
|
||||
normals: &n,
|
||||
tangents: &[[1., 0., 0., 1.]; 3],
|
||||
uvs: &u,
|
||||
indices: &i,
|
||||
pipeline: PipelineKey::new(pipeline),
|
||||
material: crate::render_data::MaterialKey::DEFAULT,
|
||||
flags: RenderFlags::VISIBLE,
|
||||
default_instance_flags: if visible {
|
||||
RenderFlags::VISIBLE
|
||||
@@ -377,18 +779,28 @@ mod tests {
|
||||
let low = add(2, true);
|
||||
data.create_instance(low.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE)
|
||||
.unwrap();
|
||||
let plan = GpuScenePlan::build(&data).unwrap();
|
||||
let frame = crate::renderer::scene_frame::SceneFramePlan::build(&data).unwrap();
|
||||
let plan = GpuScenePlan::build(&frame).unwrap();
|
||||
assert_eq!(
|
||||
plan.draws
|
||||
.iter()
|
||||
.map(|d| d.pipeline.get())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![2, 9]
|
||||
vec![0, 2, 2, 9]
|
||||
);
|
||||
assert_eq!(plan.draws[0].base_vertex, 0);
|
||||
assert!(!plan.draws[0].effective_visible);
|
||||
assert_eq!(plan.draws[1].base_vertex, 3);
|
||||
assert_eq!(plan.draws[0].instances, 0..2);
|
||||
assert_eq!(plan.indices, [0, 1, 2, 0, 1, 2]);
|
||||
assert_eq!(high.mesh, plan.draws[1].mesh);
|
||||
assert_eq!(plan.draws[1].instances, 1..2);
|
||||
assert_eq!(plan.draws[2].instances, 2..3);
|
||||
assert_eq!(plan.indices, [0, 1, 2, 0, 1, 2, 0, 1, 2]);
|
||||
assert_eq!(high.mesh, plan.draws[3].mesh);
|
||||
assert_eq!(size_of::<GpuLocalAabb>(), 32);
|
||||
assert_eq!(size_of::<DrawSlotMetadata>(), 16);
|
||||
assert_eq!(size_of::<DrawIndexedIndirect>(), 20);
|
||||
assert!(plan
|
||||
.commands
|
||||
.iter()
|
||||
.all(|command| command.first_instance == 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
camera::Camera,
|
||||
render_data::RenderData,
|
||||
renderer::{self, GpuResources},
|
||||
renderer::{self, PipelineLibrary},
|
||||
};
|
||||
|
||||
pub struct UniformResource {
|
||||
@@ -76,13 +76,14 @@ impl FrameMetadata {
|
||||
pub trait Scene: Sized {
|
||||
fn setup(
|
||||
context: &renderer::RendererContext,
|
||||
resources: &mut GpuResources,
|
||||
resources: &mut PipelineLibrary,
|
||||
data: &mut RenderData,
|
||||
) -> Self;
|
||||
fn bind_groups(&self) -> &[wgpu::BindGroup];
|
||||
fn handle_mouse_click(&mut self, x: f32, y: f32);
|
||||
fn handle_zoom(&mut self, delta_y: f32);
|
||||
fn handle_orbit(&mut self, dx: f32, dy: f32);
|
||||
fn handle_pan(&mut self, dx: f32, dy: f32, viewport_height: f32);
|
||||
fn set_camera_depth_range(&mut self, near: f32, far: f32);
|
||||
fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3);
|
||||
fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> {
|
||||
@@ -91,6 +92,9 @@ pub trait Scene: Sized {
|
||||
fn camera_mut(&mut self) -> Option<&mut Camera> {
|
||||
None
|
||||
}
|
||||
fn frustum_planes(&mut self) -> Option<Result<[[f32; 4]; 6], crate::camera::FrustumError>> {
|
||||
self.camera_mut().map(|camera| camera.frustum_planes())
|
||||
}
|
||||
fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> {
|
||||
None
|
||||
}
|
||||
@@ -103,7 +107,7 @@ pub trait Scene: Sized {
|
||||
}
|
||||
self.write_uniforms(queue);
|
||||
}
|
||||
fn update(&mut self, context: &renderer::RendererContext) {
|
||||
fn update_cpu(&mut self) {
|
||||
let position = match self.camera_mut() {
|
||||
Some(c) => c.position(),
|
||||
None => return,
|
||||
@@ -112,7 +116,6 @@ pub trait Scene: Sized {
|
||||
f.time = js_sys::Date::now() as f32 * 0.001;
|
||||
f.set_camera_position(position)
|
||||
}
|
||||
self.write_uniforms(&context.queue);
|
||||
}
|
||||
fn write_uniforms(&mut self, queue: &wgpu::Queue) {
|
||||
let frame = self.frame_metadata_mut().copied();
|
||||
|
||||
@@ -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.
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use crate::render_data::{affine_world_aabb, RenderData, RenderFlags};
|
||||
use crate::{render_data::RenderFlags, renderer::scene_frame::SceneFramePlan};
|
||||
|
||||
pub const MAGIC: u32 = u32::from_le_bytes(*b"YSNP");
|
||||
pub const BLOB_MAGIC: u32 = u32::from_le_bytes(*b"RDS1");
|
||||
@@ -94,11 +94,11 @@ impl SharedSnapshot {
|
||||
}
|
||||
|
||||
/// Packs and publishes if `data` changed. Returns the newly published data epoch.
|
||||
pub fn publish(&mut self, data: &RenderData) -> Result<Option<u32>, u32> {
|
||||
pub fn publish(&mut self, data: &SceneFramePlan) -> Result<Option<u32>, u32> {
|
||||
if self.control.header[6].load(Ordering::Acquire) == FAILED {
|
||||
return Err(self.control.header[14].load(Ordering::Relaxed));
|
||||
}
|
||||
if self.last_revision == Some(data.revision()) {
|
||||
if self.last_revision == Some(data.revision) {
|
||||
return Ok(None);
|
||||
}
|
||||
let slot = match self.claim_slot() {
|
||||
@@ -116,7 +116,7 @@ impl SharedSnapshot {
|
||||
result
|
||||
}
|
||||
|
||||
fn publish_claimed(&mut self, slot: usize, data: &RenderData) -> Result<Option<u32>, u32> {
|
||||
fn publish_claimed(&mut self, slot: usize, data: &SceneFramePlan) -> Result<Option<u32>, u32> {
|
||||
let epoch = self.next_epoch;
|
||||
let next_epoch = epoch.checked_add(1).ok_or(ERROR_OVERFLOW)?;
|
||||
let bytes = pack(data, epoch)?;
|
||||
@@ -136,7 +136,7 @@ impl SharedSnapshot {
|
||||
let ptr = self.blocks[slot].as_ptr() as usize;
|
||||
let ptr32 = u32::try_from(ptr).map_err(|_| ERROR_OVERFLOW)?;
|
||||
let length = u32::try_from(bytes.len()).map_err(|_| ERROR_OVERFLOW)?;
|
||||
let revision = data.revision();
|
||||
let revision = data.revision;
|
||||
let d = &self.control.slots[slot].0;
|
||||
let values = [
|
||||
epoch,
|
||||
@@ -145,8 +145,8 @@ impl SharedSnapshot {
|
||||
length,
|
||||
revision as u32,
|
||||
(revision >> 32) as u32,
|
||||
data.mesh_count(),
|
||||
data.instance_count(),
|
||||
data.meshes.len() as u32,
|
||||
data.occurrences.len() as u32,
|
||||
SCHEMA,
|
||||
SNAPSHOT_HEADER_BYTES as u32,
|
||||
0,
|
||||
@@ -244,9 +244,9 @@ fn wasm_pages(minimum_end: usize) -> Result<u32, u32> {
|
||||
.map_err(|_| ERROR_OVERFLOW)
|
||||
}
|
||||
|
||||
fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
|
||||
let meshes: Vec<_> = data.meshes().collect();
|
||||
let instances: Vec<_> = data.instances().collect();
|
||||
fn pack(data: &SceneFramePlan, epoch: u32) -> Result<Vec<u8>, u32> {
|
||||
let meshes = &data.meshes;
|
||||
let instances = &data.occurrences;
|
||||
let strides = [4usize, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4];
|
||||
let components = [1u32, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1];
|
||||
let scalar = [1u32, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1];
|
||||
@@ -268,7 +268,7 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
|
||||
let put32 = |out: &mut [u8], at: usize, value: u32| {
|
||||
out[at..at + 4].copy_from_slice(&value.to_le_bytes())
|
||||
};
|
||||
let revision = data.revision();
|
||||
let revision = data.revision;
|
||||
for (i, value) in [
|
||||
BLOB_MAGIC,
|
||||
SCHEMA,
|
||||
@@ -310,8 +310,12 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
|
||||
put32(&mut out, at + j * 4, value);
|
||||
}
|
||||
}
|
||||
for (dense, (handle, mesh)) in meshes.iter().enumerate() {
|
||||
for (i, value) in [handle.slot(), handle.generation(), mesh.flags.bits()]
|
||||
for (dense, mesh) in meshes.iter().enumerate() {
|
||||
for (i, value) in [
|
||||
mesh.handle.slot(),
|
||||
mesh.handle.generation(),
|
||||
mesh.flags.bits(),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
@@ -330,12 +334,14 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
|
||||
);
|
||||
}
|
||||
}
|
||||
for (dense, (handle, instance)) in instances.iter().enumerate() {
|
||||
let mesh = data.mesh(instance.mesh).ok_or(ERROR_INVARIANT)?;
|
||||
let world = affine_world_aabb(mesh.aabb, instance.model).map_err(|_| ERROR_INVARIANT)?;
|
||||
for (dense, instance) in instances.iter().enumerate() {
|
||||
let mesh = data
|
||||
.meshes
|
||||
.get(instance.mesh_index)
|
||||
.ok_or(ERROR_INVARIANT)?;
|
||||
for (i, value) in [
|
||||
handle.slot(),
|
||||
handle.generation(),
|
||||
instance.handle.slot(),
|
||||
instance.handle.generation(),
|
||||
instance.mesh.slot(),
|
||||
instance.mesh.generation(),
|
||||
instance.flags.bits(),
|
||||
@@ -356,12 +362,12 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
|
||||
put32(
|
||||
&mut out,
|
||||
offsets[11] + dense * 12 + i * 4,
|
||||
world.min[i].to_bits(),
|
||||
instance.world_aabb.min[i].to_bits(),
|
||||
);
|
||||
put32(
|
||||
&mut out,
|
||||
offsets[12] + dense * 12 + i * 4,
|
||||
world.max[i].to_bits(),
|
||||
instance.world_aabb.max[i].to_bits(),
|
||||
);
|
||||
}
|
||||
put32(
|
||||
@@ -387,6 +393,9 @@ const _: [(); 256] = [(); std::mem::size_of::<SnapshotControl>()];
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::render_data::{
|
||||
MeshCreateInfo, PipelineKey, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn exact_control_layout_and_initial_values() {
|
||||
@@ -417,4 +426,166 @@ mod tests {
|
||||
WRITING
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn producer_blob_abi_matches_schema_exactly() {
|
||||
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
|
||||
let create = |data: &mut RenderData, flags, x: f32| {
|
||||
data.create_mesh(MeshCreateInfo {
|
||||
positions: &[[x, 0., 0.], [x + 2., 0., 0.], [x, 3., 0.]],
|
||||
normals: &[[0., 0., 1.]; 3],
|
||||
tangents: &[[1., 0., 0., 1.]; 3],
|
||||
uvs: &[[0., 0.]; 3],
|
||||
indices: &[0, 1, 2],
|
||||
pipeline: PipelineKey::new(7),
|
||||
material: crate::render_data::MaterialKey::DEFAULT,
|
||||
flags,
|
||||
default_instance_flags: RenderFlags::VISIBLE,
|
||||
default_transform: IDENTITY_MODEL_TRANSFORM,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let visible = create(&mut data, RenderFlags::VISIBLE, -1.0);
|
||||
let hidden = create(&mut data, RenderFlags::NONE, 10.0);
|
||||
let mut model = IDENTITY_MODEL_TRANSFORM;
|
||||
model[0][0] = 2.0;
|
||||
model[1][1] = 0.5;
|
||||
model[3][0] = 4.0;
|
||||
model[3][1] = -2.0;
|
||||
let extra = data
|
||||
.create_instance(hidden.mesh, model, RenderFlags::NONE)
|
||||
.unwrap();
|
||||
let plan = SceneFramePlan::build(&data).unwrap();
|
||||
let epoch = 0x1234_5678;
|
||||
let blob = pack(&plan, epoch).unwrap();
|
||||
let word = |at: usize| u32::from_le_bytes(blob[at..at + 4].try_into().unwrap());
|
||||
|
||||
assert_eq!(word(0), BLOB_MAGIC);
|
||||
assert_eq!(word(4), SCHEMA);
|
||||
assert_eq!(word(8), SNAPSHOT_HEADER_BYTES as u32);
|
||||
assert_eq!(word(12), blob.len() as u32);
|
||||
assert_eq!(word(16), epoch);
|
||||
assert_eq!(word(20), plan.revision as u32);
|
||||
assert_eq!(word(24), (plan.revision >> 32) as u32);
|
||||
assert_eq!(word(28), STREAMS as u32);
|
||||
assert_eq!(word(32), SNAPSHOT_HEADER_BYTES as u32);
|
||||
assert_eq!(word(36), DESCRIPTOR_BYTES as u32);
|
||||
assert_eq!(word(40), 2);
|
||||
assert_eq!(word(44), 3);
|
||||
assert_eq!(word(48), 0x0102_0304);
|
||||
assert_eq!(word(52), SCHEMA_FLAGS);
|
||||
|
||||
let strides = [4, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4];
|
||||
let scalars = [1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1];
|
||||
let components = [1, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1];
|
||||
let counts = [2usize; 5]
|
||||
.into_iter()
|
||||
.chain([3usize; 9])
|
||||
.collect::<Vec<_>>();
|
||||
let mut offsets = Vec::new();
|
||||
let mut cursor = DATA_OFFSET;
|
||||
for i in 0..STREAMS {
|
||||
let at = SNAPSHOT_HEADER_BYTES + i * DESCRIPTOR_BYTES;
|
||||
offsets.push(cursor);
|
||||
assert_eq!(
|
||||
[
|
||||
word(at),
|
||||
word(at + 4),
|
||||
word(at + 8),
|
||||
word(at + 12),
|
||||
word(at + 16),
|
||||
word(at + 20),
|
||||
word(at + 24),
|
||||
word(at + 28)
|
||||
],
|
||||
[
|
||||
i as u32 + 1,
|
||||
scalars[i],
|
||||
cursor as u32,
|
||||
counts[i] as u32,
|
||||
components[i],
|
||||
strides[i] as u32,
|
||||
4,
|
||||
0
|
||||
]
|
||||
);
|
||||
assert_eq!(cursor % 16, 0);
|
||||
cursor = (cursor + strides[i] as usize * counts[i] + 15) & !15;
|
||||
}
|
||||
assert_eq!(cursor, blob.len());
|
||||
|
||||
for (dense, mesh) in plan.meshes.iter().enumerate() {
|
||||
assert_eq!(word(offsets[0] + dense * 4), mesh.handle.slot());
|
||||
assert_eq!(word(offsets[1] + dense * 4), mesh.handle.generation());
|
||||
assert_eq!(word(offsets[2] + dense * 4), mesh.flags.bits());
|
||||
for axis in 0..3 {
|
||||
assert_eq!(
|
||||
word(offsets[3] + dense * 12 + axis * 4),
|
||||
mesh.aabb.min[axis].to_bits()
|
||||
);
|
||||
assert_eq!(
|
||||
word(offsets[4] + dense * 12 + axis * 4),
|
||||
mesh.aabb.max[axis].to_bits()
|
||||
);
|
||||
}
|
||||
}
|
||||
for (dense, occurrence) in plan.occurrences.iter().enumerate() {
|
||||
assert_eq!(
|
||||
[
|
||||
word(offsets[5] + dense * 4),
|
||||
word(offsets[6] + dense * 4),
|
||||
word(offsets[7] + dense * 4),
|
||||
word(offsets[8] + dense * 4),
|
||||
word(offsets[9] + dense * 4)
|
||||
],
|
||||
[
|
||||
occurrence.handle.slot(),
|
||||
occurrence.handle.generation(),
|
||||
occurrence.mesh.slot(),
|
||||
occurrence.mesh.generation(),
|
||||
occurrence.flags.bits()
|
||||
]
|
||||
);
|
||||
for i in 0..16 {
|
||||
assert_eq!(
|
||||
word(offsets[10] + dense * 64 + i * 4),
|
||||
occurrence.model[i / 4][i % 4].to_bits()
|
||||
);
|
||||
}
|
||||
for axis in 0..3 {
|
||||
assert_eq!(
|
||||
word(offsets[11] + dense * 12 + axis * 4),
|
||||
occurrence.world_aabb.min[axis].to_bits()
|
||||
);
|
||||
assert_eq!(
|
||||
word(offsets[12] + dense * 12 + axis * 4),
|
||||
occurrence.world_aabb.max[axis].to_bits()
|
||||
);
|
||||
}
|
||||
let mesh_visible = plan.meshes[occurrence.mesh_index]
|
||||
.flags
|
||||
.contains(RenderFlags::VISIBLE);
|
||||
assert_eq!(
|
||||
word(offsets[13] + dense * 4),
|
||||
(mesh_visible && occurrence.flags.contains(RenderFlags::VISIBLE)) as u32
|
||||
);
|
||||
}
|
||||
assert!(plan
|
||||
.meshes
|
||||
.windows(2)
|
||||
.all(|w| w[0].handle.slot() < w[1].handle.slot()));
|
||||
assert!(plan
|
||||
.occurrences
|
||||
.windows(2)
|
||||
.all(|w| w[0].handle.slot() < w[1].handle.slot()));
|
||||
assert_eq!(
|
||||
plan.occurrences
|
||||
.iter()
|
||||
.find(|o| o.handle == extra)
|
||||
.unwrap()
|
||||
.model,
|
||||
model
|
||||
);
|
||||
assert!(plan.occurrences.iter().any(|o| o.mesh == visible.mesh));
|
||||
}
|
||||
}
|
||||
|
||||
+37
-2
@@ -48,12 +48,47 @@ export function createUvSphereGeometry(segments=24,rings=12){
|
||||
const positions=[],normals=[],texcoords=[],indices=[];for(let y=0;y<=rings;y++){const v=y/rings,phi=v*Math.PI;for(let x=0;x<=segments;x++){const u=x/segments,theta=u*Math.PI*2,nx=Math.sin(phi)*Math.cos(theta),ny=Math.cos(phi),nz=Math.sin(phi)*Math.sin(theta);positions.push(nx,ny,nz);normals.push(nx,ny,nz);texcoords.push(u,v);}}
|
||||
for(let y=0;y<rings;y++)for(let x=0;x<segments;x++){const a=y*(segments+1)+x,b=a+segments+1;indices.push(a,a+1,b,a+1,b+1,b);}return {positions,normals,texcoords,indices};
|
||||
}
|
||||
|
||||
const galleryPngBase64=Object.freeze({
|
||||
base:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAIUlEQVR42mP4ryH3X+NOgIZGwP//DP/vyGn8BwI5Obn/AKsPDa3HqsdFAAAAAElFTkSuQmCC",
|
||||
mr:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAJUlEQVR42gEaAOX/AP8gAP//YED//7Sg/wD/8P///0Dc///cIP/dYBIQ76JUtAAAAABJRU5ErkJggg==",
|
||||
normal:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAH0lEQVR42mNoaPj//0TDu/8WQMzQcOLd/wYLIAYKAgDieBGxoS0BjwAAAABJRU5ErkJggg==",
|
||||
});
|
||||
const decodeBase64=value=>{const binary=atob(value),bytes=new Uint8Array(binary.length);for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);return bytes;};
|
||||
|
||||
/** Build the deterministic Phase 6 PBR shader validation gallery. */
|
||||
export function createMaterialGalleryGlb(){
|
||||
// A modest shared sphere keeps the embedded GLB compact while making roughness
|
||||
// and normal-map responses much easier to compare than the former cubes.
|
||||
const geometry=createUvSphereGeometry(16,8);
|
||||
const streams=[new Float32Array(geometry.positions),new Float32Array(geometry.normals),new Float32Array(geometry.texcoords),new Uint32Array(geometry.indices)];
|
||||
const images=Object.values(galleryPngBase64).map(decodeBase64),chunks=[],bufferViews=[];let byteLength=0;
|
||||
for(const stream of [...streams,...images]){byteLength=align4(byteLength);const bytes=stream instanceof Uint8Array?stream:new Uint8Array(stream.buffer);chunks.push({offset:byteLength,bytes});bufferViews.push({buffer:0,byteOffset:byteLength,byteLength:bytes.length});byteLength+=bytes.length;}
|
||||
byteLength=align4(byteLength);const bounds=finiteMinMax(streams[0],3),vertexCount=geometry.positions.length/3;
|
||||
const materials=[
|
||||
...[0.08,0.3,0.6,1].map(roughnessFactor=>({name:`Dielectric roughness ${roughnessFactor}`,pbrMetallicRoughness:{baseColorFactor:[0.72,0.18,0.08,1],metallicFactor:0,roughnessFactor}})),
|
||||
...[0.08,0.3,0.6,1].map(roughnessFactor=>({name:`Metal roughness ${roughnessFactor}`,pbrMetallicRoughness:{baseColorFactor:[0.72,0.76,0.82,1],metallicFactor:1,roughnessFactor}})),
|
||||
...[1,1.5,2].map(ior=>({name:`Dielectric IOR ${ior}`,pbrMetallicRoughness:{baseColorFactor:[0.12,0.48,0.82,1],metallicFactor:0,roughnessFactor:0.18},extensions:{KHR_materials_ior:{ior}}})),
|
||||
{name:"Odd-width OpenGL normal map",pbrMetallicRoughness:{baseColorFactor:[0.7,0.7,0.7,1],metallicFactor:0,roughnessFactor:0.4},normalTexture:{index:2,scale:1}},
|
||||
{name:"Odd-width AO",pbrMetallicRoughness:{baseColorFactor:[0.8,0.55,0.12,1],metallicFactor:0,roughnessFactor:0.65},occlusionTexture:{index:1,strength:1}},
|
||||
{name:"Odd-width emissive",pbrMetallicRoughness:{baseColorFactor:[0.03,0.03,0.03,1],metallicFactor:0,roughnessFactor:0.8},emissiveFactor:[1,0.3,0.05],emissiveTexture:{index:0}},
|
||||
{name:"Odd-width alpha MASK",pbrMetallicRoughness:{baseColorFactor:[1,1,1,1],baseColorTexture:{index:0},metallicFactor:0,roughnessFactor:0.55},alphaMode:"MASK",alphaCutoff:0.5,doubleSided:true},
|
||||
{name:"Reflected non-uniform double-sided",pbrMetallicRoughness:{baseColorFactor:[0.25,0.85,0.38,1],metallicFactor:0.15,roughnessFactor:0.45},doubleSided:true},
|
||||
];
|
||||
const nodes=materials.map((material,index)=>({name:material.name,mesh:index,translation:[(index%4-1.5)*2.5,(1.5-Math.floor(index/4))*2.5,0],...(index===15?{scale:[-1.25,0.7,1.1]}:{})}));
|
||||
const meshes=materials.map((material,index)=>({name:material.name,primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3,material:index}]}));
|
||||
const json={asset:{version:"2.0",generator:"yawn-phase6-pbr-gallery"},extensionsUsed:["KHR_materials_ior"],scene:0,scenes:[{name:"Phase 6 deterministic PBR gallery",nodes:nodes.map((_,i)=>i)}],nodes,meshes,materials,
|
||||
samplers:[{magFilter:9728,minFilter:9728,wrapS:10497,wrapT:10497}],images:images.map((_,i)=>({name:["Odd-width sRGB base color and emissive","Odd-width linear MR and AO","Odd-width OpenGL normal map"][i],bufferView:i+4,mimeType:"image/png"})),textures:images.map((_,i)=>({sampler:0,source:i})),
|
||||
buffers:[{byteLength}],bufferViews,accessors:[{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},{bufferView:3,componentType:5125,count:geometry.indices.length,type:"SCALAR"}]};
|
||||
let jsonBytes=encoder.encode(JSON.stringify(json));const jsonLength=align4(jsonBytes.length),total=12+8+jsonLength+8+byteLength,out=new ArrayBuffer(total),view=new DataView(out),bytes=new Uint8Array(out);
|
||||
view.setUint32(0,0x46546c67,true);view.setUint32(4,2,true);view.setUint32(8,total,true);view.setUint32(12,jsonLength,true);view.setUint32(16,JSON_CHUNK,true);bytes.fill(0x20,20,20+jsonLength);bytes.set(jsonBytes,20);const binHeader=20+jsonLength;view.setUint32(binHeader,byteLength,true);view.setUint32(binHeader+4,BIN_CHUNK,true);for(const chunk of chunks)bytes.set(chunk.bytes,binHeader+8+chunk.offset);return out;
|
||||
}
|
||||
export function isGitLfsPointer(bytes){const text=new TextDecoder().decode(new Uint8Array(bytes,0,Math.min(bytes.byteLength,256)));return text.startsWith("version https://git-lfs.github.com/spec/v1\n");}
|
||||
export class LoadoutError extends Error{constructor(code,message){super(message);this.name="LoadoutError";this.code=code;}}
|
||||
export const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},manor:{label:"The Manor"},sponza:{label:"Sponza"}});
|
||||
export const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},materials:{label:"Phase 6 deterministic PBR gallery"},manor:{label:"The Manor"},sponza:{label:"Sponza"}});
|
||||
const assetUrls=Object.freeze({manor:new URL("./themanor.glb",import.meta.url),sponza:new URL("./sponza.glb",import.meta.url)});
|
||||
export async function loadDemoLoadout(id,{signal,fetchImpl=fetch}={}){
|
||||
if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry());
|
||||
if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry());if(id==="materials")return createMaterialGalleryGlb();
|
||||
const url=assetUrls[id];if(!url)throw new LoadoutError("LOADOUT_UNKNOWN",`Unknown loadout: ${id}`);
|
||||
let response;try{response=await fetchImpl(url,{signal});}catch(error){if(error?.name==="AbortError")throw error;throw new LoadoutError("LOADOUT_FETCH_FAILED",`Could not fetch ${id}: ${error?.message||"network error"}`);}
|
||||
if(!response.ok)throw new LoadoutError("LOADOUT_HTTP",`Could not fetch ${id}: HTTP ${response.status}`);const buffer=await response.arrayBuffer();if(isGitLfsPointer(buffer))throw new LoadoutError("LOADOUT_LFS_POINTER",`${id} is a Git LFS pointer; hydrate repository assets first`);return buffer;
|
||||
|
||||
+198
-3
@@ -1,5 +1,200 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Yawn Render Graph Demo</title>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Yawn Render Graph Demo</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}html,body{height:100%;margin:0;overflow:hidden;background:#0b0e14;color:#eef2f8;font:14px Inter,system-ui,sans-serif}main{display:grid;grid-template-columns:minmax(0,3fr) minmax(380px,2fr);height:100%}.viewport,.editor{min-width:0;min-height:0;position:relative}canvas{display:block;width:100%;height:100%}#canvas0{background:#090d16}.toolbar{position:absolute;z-index:2;inset:18px 18px auto;display:flex;align-items:end;gap:14px;padding:13px 16px;border:1px solid #ffffff18;border-radius:10px;background:#111722e8;box-shadow:0 12px 30px #0008}.brand{margin-right:auto}.brand strong{display:block;font-size:17px;letter-spacing:.08em}.brand small{color:#8d9bb1}.field{display:grid;gap:5px;color:#9da9ba;font-size:11px;text-transform:uppercase;letter-spacing:.08em}select,button{font:inherit;color:#eef;background:#202938;border:1px solid #3a4659;border-radius:6px;padding:7px 10px}button{background:#ba5c2e;border-color:#d77949;font-weight:650;cursor:pointer}button:disabled,select:disabled{opacity:.48;cursor:wait}#demo-status{position:absolute;z-index:2;left:18px;bottom:18px;padding:9px 12px;border-radius:7px;background:#0b1019dc;color:#bac6d8;box-shadow:0 5px 20px #0008}.editor{display:grid;grid-template-rows:58px minmax(0,1fr);border-left:1px solid #2d3542;background:#151820}.editor-bar{display:flex;align-items:center;gap:12px;padding:0 14px;border-bottom:1px solid #303745}.editor-bar strong{font-size:15px}.editor-bar span{color:#9ba7b9}@media(max-width:820px){main{grid-template-columns:1fr;grid-template-rows:52% 48%}.editor{border-left:0;border-top:1px solid #2d3542}.toolbar{flex-wrap:wrap}.brand{width:100%}}
|
||||
</style></head><body><main><section class="viewport" aria-label="Rendered scene"><div class="toolbar"><div class="brand"><strong>YAWN</strong><small>Render Graph Studio</small></div><label class="field" for="loadout-select">Scene loadout<select id="loadout-select"><option value="cubes">Cubes</option><option value="spheres">UV spheres</option><option value="manor">The Manor</option><option value="sponza">Sponza</option></select></label><label class="field" for="graph-select">Graph preset<select id="graph-select"><option value="authored">Authored</option><option value="midnight">Midnight</option><option value="ember">Ember</option></select></label></div><canvas id="canvas0"></canvas><output id="demo-status" aria-live="polite">Starting Phase 8…</output></section><section class="editor" aria-label="Render graph editor"><div class="editor-bar"><strong>Authored Graph</strong><button id="apply-graph" disabled>Apply</button><span id="graph-status">Loading editor…</span></div><canvas id="graph-editor"></canvas></section></main><script type="module" src="./index.js"></script></body></html>
|
||||
* {
|
||||
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 { renderGraphPresets } from "./render-graph/presets.js";
|
||||
|
||||
let renderer,editor,controller,assetAbort,busy=false,cleaned=false;
|
||||
let unsubscribeController=()=>{},unsubscribeSnapshots=()=>{};
|
||||
let renderer,
|
||||
editor,
|
||||
controller,
|
||||
assetAbort,
|
||||
busy = false,
|
||||
cleaned = false;
|
||||
let unsubscribeController = () => {},
|
||||
unsubscribeSnapshots = () => {};
|
||||
const listeners = [];
|
||||
const on=(target,type,fn)=>{target.addEventListener(type,fn);listeners.push(()=>target.removeEventListener(type,fn));};
|
||||
const status=message=>{const node=document.querySelector("#demo-status");if(node)node.textContent=message;};
|
||||
const sameId=(a,b)=>Array.isArray(a)&&Array.isArray(b)&&a[0]===b[0]&&a[1]===b[1];
|
||||
const state={loadout:"cubes",graph:"authored",compiled:{},telemetry:null};
|
||||
const on = (target, type, fn) => {
|
||||
target.addEventListener(type, fn);
|
||||
listeners.push(() => target.removeEventListener(type, fn));
|
||||
};
|
||||
const status = (message) => {
|
||||
const node = document.querySelector("#demo-status");
|
||||
if (node) node.textContent = message;
|
||||
};
|
||||
const sameId = (a, b) =>
|
||||
Array.isArray(a) && Array.isArray(b) && a[0] === b[0] && a[1] === b[1];
|
||||
const state = {
|
||||
loadout: "cubes",
|
||||
graph: "authored",
|
||||
compiled: {},
|
||||
telemetry: null,
|
||||
};
|
||||
export const profileRequested = (search) =>
|
||||
new URLSearchParams(search).get("profile") === "1";
|
||||
const profileEnabled = profileRequested(location.search);
|
||||
function installProfileMenu() {
|
||||
if (!profileEnabled) return;
|
||||
const menu = document.createElement("details");
|
||||
menu.id = "profile-menu";
|
||||
menu.innerHTML =
|
||||
'<summary>GPU profile</summary><div id="profile-status">Waiting for GPU timestamps…</div><table><tbody id="profile-passes"></tbody></table>';
|
||||
document.querySelector(".toolbar")?.append(menu);
|
||||
on(window, "renderer-profile", (event) => {
|
||||
const p = event.detail;
|
||||
document.querySelector("#profile-status").textContent = p.available
|
||||
? `${p.graph} · epoch ${p.epoch} · ${p.dropped} dropped`
|
||||
: "GPU timestamps unavailable";
|
||||
document.querySelector("#profile-passes").replaceChildren(
|
||||
...Object.entries(p.passes || {}).map(([id, ms]) => {
|
||||
const row = document.createElement("tr"),
|
||||
name = document.createElement("th"),
|
||||
value = document.createElement("td");
|
||||
name.textContent = id;
|
||||
value.textContent = `${Number(ms).toFixed(3)} ms`;
|
||||
row.append(name, value);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function publish(telemetry) {
|
||||
state.telemetry = telemetry;
|
||||
document.documentElement.dataset.phase8State=JSON.stringify({activeLoadout:state.loadout,activeGraph:state.graph,renderDataRevision:telemetry.revision,renderMode:telemetry.renderMode,activeCompiledId:telemetry.activeCompiledId,activeCompiledGraph:telemetry.activeCompiledGraph,activeCompiledRevision:telemetry.activeCompiledRevision,graphPasses:telemetry.graphPasses,draws:telemetry.draws,instances:telemetry.instances,indices:telemetry.indices,framingRadius:telemetry.framingRadius,gpuError:telemetry.gpuError});
|
||||
document.documentElement.dataset.phase8State = JSON.stringify({
|
||||
activeLoadout: state.loadout,
|
||||
activeGraph: state.graph,
|
||||
renderDataRevision: telemetry.revision,
|
||||
renderMode: telemetry.renderMode,
|
||||
activeCompiledId: telemetry.activeCompiledId,
|
||||
activeCompiledGraph: telemetry.activeCompiledGraph,
|
||||
activeCompiledRevision: telemetry.activeCompiledRevision,
|
||||
activeCompiledSchemaVersion: telemetry.activeCompiledSchemaVersion,
|
||||
graphPasses: telemetry.graphPasses,
|
||||
graphExecutions: telemetry.graphExecutions,
|
||||
graphTextureSlots: telemetry.graphTextureSlots,
|
||||
draws: telemetry.draws,
|
||||
instances: telemetry.instances,
|
||||
indices: telemetry.indices,
|
||||
framingRadius: telemetry.framingRadius,
|
||||
gpuError: telemetry.gpuError,
|
||||
});
|
||||
}
|
||||
function waitTelemetry(predicate, timeout = 30000) {
|
||||
const current=renderer?.telemetry;if(current&&predicate(current))return Promise.resolve(current);
|
||||
return new Promise((resolve,reject)=>{let timer;const done=()=>{clearTimeout(timer);removeEventListener("renderer-frame",frame);};const frame=e=>{if(predicate(e.detail)){done();resolve(e.detail);}};timer=setTimeout(()=>{done();reject(new Error("Telemetry confirmation timed out"));},timeout);onAbort=()=>{done();reject(new RendererError("DISPOSED"));};addEventListener("renderer-frame",frame);timer.unref?.();});
|
||||
const current = renderer?.telemetry;
|
||||
if (current && predicate(current)) return Promise.resolve(current);
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer;
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
removeEventListener("renderer-frame", frame);
|
||||
};
|
||||
const frame = (e) => {
|
||||
if (predicate(e.detail)) {
|
||||
done();
|
||||
resolve(e.detail);
|
||||
}
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
done();
|
||||
reject(new Error("Telemetry confirmation timed out"));
|
||||
}, timeout);
|
||||
onAbort = () => {
|
||||
done();
|
||||
reject(new RendererError("DISPOSED"));
|
||||
};
|
||||
addEventListener("renderer-frame", frame);
|
||||
timer.unref?.();
|
||||
});
|
||||
}
|
||||
let onAbort = () => {};
|
||||
async function transaction(label, operation, rollback) {
|
||||
if(busy||cleaned)return false;busy=true;document.querySelectorAll("select, #apply-graph").forEach(x=>x.disabled=true);status(label);
|
||||
try{const telemetry=await operation();if(cleaned)return false;if(telemetry){publish(telemetry);status(`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`);}else status(`${state.loadout} · ${state.graph} · committed; telemetry pending`);return true;}
|
||||
catch(error){if(!cleaned){try{await rollback?.();}catch(rollbackError){console.error("Phase 8 rollback failed",rollbackError);}console.error("Phase 8 transaction failed",error);status(`Failed · ${error?.code??error?.message??error}`);}return false;}
|
||||
finally{busy=false;if(!cleaned){document.querySelectorAll("select").forEach(x=>x.disabled=false);const button=document.querySelector("#apply-graph");if(button)button.disabled=controller?.applying||!controller?.dirty;}}
|
||||
if (busy || cleaned) return false;
|
||||
busy = true;
|
||||
document
|
||||
.querySelectorAll("select, #apply-graph")
|
||||
.forEach((x) => (x.disabled = true));
|
||||
status(label);
|
||||
try {
|
||||
const telemetry = await operation();
|
||||
if (cleaned) return false;
|
||||
if (telemetry) {
|
||||
publish(telemetry);
|
||||
status(
|
||||
`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`,
|
||||
);
|
||||
} else
|
||||
status(
|
||||
`${state.loadout} · ${state.graph} · committed; telemetry pending`,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!cleaned) {
|
||||
try {
|
||||
await rollback?.();
|
||||
} catch (rollbackError) {
|
||||
console.error("Phase 8 rollback failed", rollbackError);
|
||||
}
|
||||
console.error("Phase 8 transaction failed", error);
|
||||
status(`Failed · ${error?.code ?? error?.message ?? error}`);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
busy = false;
|
||||
if (!cleaned) {
|
||||
document.querySelectorAll("select").forEach((x) => (x.disabled = false));
|
||||
const button = document.querySelector("#apply-graph");
|
||||
if (button) button.disabled = !controller?.canApply;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function selectLoadout(next, select) {
|
||||
const previous=state.loadout,targetRevision=(renderer.telemetry?.revision??0)+1;assetAbort=new AbortController();
|
||||
const ok=await transaction(`Loading ${next}…`,async()=>{const glb=await loadDemoLoadout(next,{signal:assetAbort.signal});await renderer.replaceSceneGlb(glb,{framing:next==="sponza"?"interior":"exterior"});state.loadout=next;return waitTelemetry(x=>x.revision===targetRevision&&x.draws>0&&x.activeCompiledGraph===state.compiled[state.graph].graphId&&x.gpuError===false).catch(()=>null);});
|
||||
assetAbort=undefined;if(!ok)select.value=previous;
|
||||
}
|
||||
async function selectGraph(next,select){
|
||||
const previous=state.graph,compiled=state.compiled[next];
|
||||
const ok=await transaction(`Activating ${next}…`,async()=>{await renderer.switchCompiledGraph(compiled.compiledId);state.graph=next;return waitTelemetry(x=>sameId(x.activeCompiledId,compiled.compiledId)&&x.activeCompiledGraph===compiled.graphId&&x.activeCompiledRevision===compiled.revision&&x.gpuError===false).catch(()=>null);},async()=>{state.graph=previous;select.value=previous;});
|
||||
const previous = state.loadout,
|
||||
targetRevision = (renderer.telemetry?.revision ?? 0) + 1;
|
||||
assetAbort = new AbortController();
|
||||
const ok = await transaction(`Loading ${next}…`, async () => {
|
||||
const glb = await loadDemoLoadout(next, { signal: assetAbort.signal });
|
||||
await renderer.replaceSceneGlb(glb, {
|
||||
framing: next === "sponza" ? "interior" : "exterior",
|
||||
});
|
||||
state.loadout = next;
|
||||
return waitTelemetry(
|
||||
(x) =>
|
||||
x.revision === targetRevision &&
|
||||
x.draws > 0 &&
|
||||
x.activeCompiledGraph === state.compiled[state.graph].graphId &&
|
||||
x.gpuError === false,
|
||||
).catch(() => null);
|
||||
});
|
||||
assetAbort = undefined;
|
||||
if (!ok) select.value = previous;
|
||||
}
|
||||
async function cleanup(){if(cleaned)return;cleaned=true;removeEventListener("pagehide",pagehide);assetAbort?.abort();onAbort();listeners.splice(0).forEach(fn=>fn());unsubscribeController();unsubscribeSnapshots();try{await editor?.destroy();}finally{renderer?.dispose();}}
|
||||
const pagehide=()=>{void cleanup()};
|
||||
async function selectGraph(next, select) {
|
||||
const previous = state.graph,
|
||||
compiled = state.compiled[next];
|
||||
const ok = await transaction(
|
||||
`Activating ${next}…`,
|
||||
async () => {
|
||||
await renderer.switchCompiledGraph(compiled.compiledId);
|
||||
state.graph = next;
|
||||
return waitTelemetry(
|
||||
(x) =>
|
||||
sameId(x.activeCompiledId, compiled.compiledId) &&
|
||||
x.activeCompiledGraph === compiled.graphId &&
|
||||
x.activeCompiledRevision === compiled.revision &&
|
||||
x.gpuError === false,
|
||||
).catch(() => null);
|
||||
},
|
||||
async () => {
|
||||
state.graph = previous;
|
||||
select.value = previous;
|
||||
},
|
||||
);
|
||||
if (!ok) select.value = previous;
|
||||
}
|
||||
async function cleanup() {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
removeEventListener("pagehide", pagehide);
|
||||
assetAbort?.abort();
|
||||
onAbort();
|
||||
listeners.splice(0).forEach((fn) => fn());
|
||||
unsubscribeController();
|
||||
unsubscribeSnapshots();
|
||||
try {
|
||||
await controller?.destroy();
|
||||
await editor?.destroy();
|
||||
} finally {
|
||||
renderer?.dispose();
|
||||
}
|
||||
}
|
||||
const pagehide = () => {
|
||||
void cleanup();
|
||||
};
|
||||
|
||||
async function start() {
|
||||
addEventListener("pagehide",pagehide,{once:true});delete document.documentElement.dataset.phase8Ready;await wbg_init();if(cleaned)return;
|
||||
renderer=new RendererClient(main());await renderer.ready;const nextEditor=await createRenderGraphEditor(document.querySelector("#graph-editor"));if(cleaned){await nextEditor.destroy();return}editor=nextEditor;
|
||||
controller=new AuthoringController({renderer,getState:editor.getState});const apply=document.querySelector("#apply-graph"),graphStatus=document.querySelector("#graph-status"),loadoutSelect=document.querySelector("#loadout-select"),graphSelect=document.querySelector("#graph-select");
|
||||
unsubscribeController=controller.subscribe(s=>{apply.disabled=busy||s.applying||!s.dirty;graphStatus.textContent=s.applying?"Applying…":s.dirty?"Unapplied changes":`Authored revision ${s.revision}`;});unsubscribeSnapshots=editor.onSnapshots(()=>controller.markDirty());
|
||||
const authored=await controller.apply(adaptFxNodeSnapshot);state.compiled.authored={...authored,graphId:"demo_forward"};
|
||||
for(const [name,preset] of Object.entries(renderGraphPresets)){const compiled=await renderer.compileGraph(preset);state.compiled[name]={...compiled,graphId:preset.graphId,revision:preset.revision};}
|
||||
on(window,"renderer-frame",event=>{const expected=state.compiled[state.graph],telemetry=event.detail;if(expected&&telemetry.activeCompiledGraph===expected.graphId&&sameId(telemetry.activeCompiledId,expected.compiledId)&&telemetry.gpuError===false){publish(telemetry);if(!busy)status(`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`);}});
|
||||
on(loadoutSelect,"change",()=>void selectLoadout(loadoutSelect.value,loadoutSelect));on(graphSelect,"change",()=>void selectGraph(graphSelect.value,graphSelect));
|
||||
on(apply,"click",()=>{const previous=state.graph,previousAuthored=state.compiled.authored;void transaction("Applying authored graph…",async()=>{const compiled=await controller.apply(adaptFxNodeSnapshot);state.compiled.authored={...compiled,graphId:"demo_forward"};state.graph="authored";graphSelect.value="authored";return waitTelemetry(x=>sameId(x.activeCompiledId,compiled.compiledId)&&x.activeCompiledGraph==="demo_forward"&&x.activeCompiledRevision===compiled.revision&&x.gpuError===false).catch(()=>null);},async()=>{state.compiled.authored=previousAuthored;state.graph=previous;graphSelect.value=previous;controller.markDirty();});});
|
||||
await editor.whenRendered();
|
||||
const initialized=await transaction("Preparing procedural cubes…",async()=>{const targetRevision=(renderer.telemetry?.revision??0)+1;await renderer.replaceSceneGlb(await loadDemoLoadout("cubes"));await renderer.switchCompiledGraph(authored.compiledId);return waitTelemetry(x=>x.revision===targetRevision&&x.draws>0&&x.activeCompiledGraph==="demo_forward"&&x.activeCompiledRevision===authored.revision&&x.gpuError===false);});
|
||||
if(!initialized)throw new Error("Initial demo transaction failed");document.documentElement.dataset.phase8Ready="true";
|
||||
addEventListener("pagehide", pagehide, { once: true });
|
||||
delete document.documentElement.dataset.phase8Ready;
|
||||
await wbg_init();
|
||||
if (cleaned) return;
|
||||
renderer = new RendererClient(main(profileEnabled));
|
||||
installProfileMenu();
|
||||
await renderer.ready;
|
||||
const nextEditor = await createRenderGraphEditor(
|
||||
document.querySelector("#graph-editor"),
|
||||
);
|
||||
if (cleaned) {
|
||||
await nextEditor.destroy();
|
||||
return;
|
||||
}
|
||||
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);
|
||||
editor = nextEditor;
|
||||
controller = new AuthoringController({
|
||||
renderer,
|
||||
adapt: adaptFxNodeSnapshot,
|
||||
});
|
||||
const apply = document.querySelector("#apply-graph"),
|
||||
graphStatus = document.querySelector("#graph-status"),
|
||||
loadoutSelect = document.querySelector("#loadout-select"),
|
||||
graphSelect = document.querySelector("#graph-select");
|
||||
unsubscribeController = controller.subscribe((s) => {
|
||||
apply.disabled = busy || !s.canApply;
|
||||
graphStatus.textContent = s.error
|
||||
? `Invalid · ${s.error.code ?? s.error.message}`
|
||||
: s.applying
|
||||
? "Applying…"
|
||||
: s.dirty
|
||||
? s.staged
|
||||
? "Ready to apply"
|
||||
: "Validating…"
|
||||
: `Authored revision ${s.revision}`;
|
||||
});
|
||||
unsubscribeSnapshots = editor.onSnapshots((snapshot) =>
|
||||
controller.markDirty(snapshot),
|
||||
);
|
||||
controller.markDirty(await editor.getState());
|
||||
const authored = await controller.apply();
|
||||
state.compiled.authored = { ...authored, graphId: "authored_gpu_culling" };
|
||||
for (const [name, preset] of Object.entries(renderGraphPresets)) {
|
||||
const compiled = await renderer.compileGraph(preset);
|
||||
state.compiled[name] = {
|
||||
...compiled,
|
||||
graphId: preset.graphId,
|
||||
revision: preset.revision,
|
||||
};
|
||||
}
|
||||
on(window, "renderer-frame", (event) => {
|
||||
const expected = state.compiled[state.graph],
|
||||
telemetry = event.detail;
|
||||
if (
|
||||
expected &&
|
||||
telemetry.activeCompiledGraph === expected.graphId &&
|
||||
sameId(telemetry.activeCompiledId, expected.compiledId) &&
|
||||
telemetry.gpuError === false
|
||||
) {
|
||||
publish(telemetry);
|
||||
if (!busy)
|
||||
status(
|
||||
`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`,
|
||||
);
|
||||
}
|
||||
});
|
||||
on(
|
||||
loadoutSelect,
|
||||
"change",
|
||||
() => void selectLoadout(loadoutSelect.value, loadoutSelect),
|
||||
);
|
||||
on(
|
||||
graphSelect,
|
||||
"change",
|
||||
() => void selectGraph(graphSelect.value, graphSelect),
|
||||
);
|
||||
on(apply, "click", () => {
|
||||
const previous = state.graph,
|
||||
previousAuthored = state.compiled.authored;
|
||||
void transaction(
|
||||
"Applying authored graph…",
|
||||
async () => {
|
||||
const compiled = await controller.apply();
|
||||
state.compiled.authored = {
|
||||
...compiled,
|
||||
graphId: "authored_gpu_culling",
|
||||
};
|
||||
state.graph = "authored";
|
||||
graphSelect.value = "authored";
|
||||
return waitTelemetry(
|
||||
(x) =>
|
||||
sameId(x.activeCompiledId, compiled.compiledId) &&
|
||||
x.activeCompiledGraph === "authored_gpu_culling" &&
|
||||
x.activeCompiledRevision === compiled.revision &&
|
||||
x.gpuError === false,
|
||||
).catch(() => null);
|
||||
},
|
||||
async () => {
|
||||
state.compiled.authored = previousAuthored;
|
||||
state.graph = previous;
|
||||
graphSelect.value = previous;
|
||||
},
|
||||
);
|
||||
});
|
||||
await editor.whenRendered();
|
||||
const initialized = await transaction(
|
||||
"Preparing procedural cubes…",
|
||||
async () => {
|
||||
const targetRevision = (renderer.telemetry?.revision ?? 0) + 1;
|
||||
await renderer.replaceSceneGlb(await loadDemoLoadout("cubes"));
|
||||
await renderer.switchCompiledGraph(authored.compiledId);
|
||||
return waitTelemetry(
|
||||
(x) =>
|
||||
x.revision === targetRevision &&
|
||||
x.draws > 0 &&
|
||||
x.activeCompiledGraph === "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 {
|
||||
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){
|
||||
if(!Number.isInteger(revision)||revision<1||revision>0xffffffff) fail("AUTHORING_REVISION",{revision});
|
||||
const extent={kind:"surface_relative",width:{numerator:1,denominator:1},height:{numerator:1,denominator:1},depthOrArrayLayers:1};
|
||||
return {schemaVersion:1,graphId:p.graphId,revision,resources:[
|
||||
{id:"surface",version:0,residency:{kind:"external",source:"surface_color"},texture:{dimension:"d2",format:"surface",extent,mipLevelCount:1,sampleCount:1}},
|
||||
{id:"depth",version:0,residency:{kind:"transient"},texture:{dimension:"d2",format:"depth32_float",extent,mipLevelCount:1,sampleCount:1}},
|
||||
],passes:[{id:"forward",state:p.passState,executor:{key:"scene_forward",version:1},parameters:{},reads:[],writes:[
|
||||
{binding:"color",resource:{id:"surface",version:0},access:{kind:"color_attachment",location:0,load:{op:"clear",value:p.clearColor},store:"store"}},
|
||||
{binding:"depth",resource:{id:"depth",version:0},access:{kind:"depth_attachment",load:{op:"clear",value:p.clearDepth},store:"store"}},
|
||||
]}],outputs:[{name:"present",resource:{id:"surface",version:0}}]};
|
||||
const fail = (code, details) => {
|
||||
throw new AuthoringGraphError(code, details);
|
||||
};
|
||||
const object = (value) =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
const identifier = (value) =>
|
||||
typeof value === "string" &&
|
||||
/^[A-Za-z][A-Za-z0-9_.-]*$/.test(value) &&
|
||||
new TextEncoder().encode(value).length <= 64;
|
||||
const exactKeys = (value, keys) =>
|
||||
object(value) &&
|
||||
Object.keys(value).length === keys.length &&
|
||||
keys.every((key) => Object.hasOwn(value, key));
|
||||
const finiteJson = (value) =>
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value)) ||
|
||||
(Array.isArray(value) && value.every(finiteJson)) ||
|
||||
(object(value) && Object.values(value).every(finiteJson));
|
||||
const canonical = (value) =>
|
||||
Array.isArray(value)
|
||||
? value.map(canonical)
|
||||
: object(value)
|
||||
? Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, canonical(value[key])]),
|
||||
)
|
||||
: value;
|
||||
const deepFreeze = (value) => {
|
||||
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const sourceMaps = new WeakMap();
|
||||
export const getSourceMap = (ir) => sourceMaps.get(ir);
|
||||
export const mapAuthoringDiagnostic = (ir, diagnostic) => {
|
||||
const details = diagnostic?.details;
|
||||
const path = [details?.path, diagnostic?.path, details?.field, diagnostic?.field]
|
||||
.find((value) => typeof value === "string");
|
||||
const map = getSourceMap(ir);
|
||||
let match;
|
||||
if (path && map)
|
||||
for (const key of Object.keys(map))
|
||||
if (
|
||||
(path === key || path.startsWith(`${key}.`) || path.startsWith(`${key}[`)) &&
|
||||
(!match || key.length > match.length)
|
||||
)
|
||||
match = key;
|
||||
return deepFreeze({
|
||||
name: diagnostic?.name,
|
||||
code: diagnostic?.code,
|
||||
message: details?.message ?? diagnostic?.message ?? diagnostic?.code,
|
||||
details: details === undefined ? undefined : structuredClone(details),
|
||||
path,
|
||||
source: match ? structuredClone(map[match]) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const mapValuePaths = (paths, path, source, value) => {
|
||||
paths[path] = source;
|
||||
if (Array.isArray(value))
|
||||
value.forEach((child, index) => mapValuePaths(paths, `${path}[${index}]`, source, child));
|
||||
else if (object(value))
|
||||
for (const key of Object.keys(value))
|
||||
mapValuePaths(paths, `${path}.${key}`, source, value[key]);
|
||||
};
|
||||
|
||||
function parameterValue(raw, schema, nodeId, key) {
|
||||
const expected = schema.type === "json" ? "json" : schema.type;
|
||||
if (
|
||||
!exactKeys(raw, ["kind", "value"]) ||
|
||||
raw.kind !== expected ||
|
||||
!finiteJson(raw.value)
|
||||
)
|
||||
fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
|
||||
if (
|
||||
(expected === "number" && typeof raw.value !== "number") ||
|
||||
(expected === "string" && typeof raw.value !== "string") ||
|
||||
(expected === "boolean" && typeof raw.value !== "boolean") ||
|
||||
(expected === "json" && !finiteJson(raw.value))
|
||||
)
|
||||
fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
|
||||
return canonical(structuredClone(raw.value));
|
||||
}
|
||||
|
||||
export function adaptFxNodeSnapshot(raw, revision = 1) {
|
||||
try {
|
||||
const rootKeys = ["graphId", "catalogVersion", "nodes", "links", "metadata", "version"];
|
||||
if (
|
||||
!exactKeys(raw, rootKeys) ||
|
||||
!Array.isArray(raw.nodes) ||
|
||||
!Array.isArray(raw.links) ||
|
||||
!object(raw.metadata) ||
|
||||
!finiteJson(raw.metadata)
|
||||
)
|
||||
fail("AUTHORING_SHAPE");
|
||||
if (raw.graphId !== GRAPH_ID || raw.catalogVersion !== CATALOG_VERSION)
|
||||
fail("AUTHORING_CATALOG");
|
||||
if (
|
||||
!Number.isSafeInteger(raw.version) || raw.version < 0
|
||||
)
|
||||
fail("AUTHORING_SHAPE");
|
||||
if (!Number.isInteger(revision) || revision < 1 || revision > 0xffffffff)
|
||||
fail("AUTHORING_REVISION");
|
||||
const nodes = new Map(),
|
||||
sockets = new Map(),
|
||||
paths = {};
|
||||
for (let ordinal = 0; ordinal < raw.nodes.length; ordinal++) {
|
||||
const n = raw.nodes[ordinal];
|
||||
if (!object(n) || !identifier(n.id)) fail("AUTHORING_ID", { id: n?.id });
|
||||
if (nodes.has(n.id)) fail("AUTHORING_ID_DUPLICATE", { id: n.id });
|
||||
const descriptor = descriptors[n.typeId],
|
||||
definition = nodeDefinitions[n.typeId];
|
||||
if (!descriptor)
|
||||
fail("AUTHORING_NODE_TYPE", { nodeId: n.id, typeId: n.typeId });
|
||||
const nodeKeys = ["id", "typeId", "typeVersion", "position", "size", "label", "parameters", "sockets", "muted", "collapsed", "extensions", "known"];
|
||||
if (Object.hasOwn(n, "parentId")) nodeKeys.push("parentId");
|
||||
if (
|
||||
!exactKeys(n, nodeKeys) ||
|
||||
n.known !== true ||
|
||||
n.typeVersion !== 1 ||
|
||||
typeof n.muted !== "boolean" ||
|
||||
typeof n.collapsed !== "boolean" ||
|
||||
typeof n.label !== "string" ||
|
||||
!exactKeys(n.position, ["x", "y"]) || !Number.isFinite(n.position.x) || !Number.isFinite(n.position.y) ||
|
||||
!exactKeys(n.size, ["x", "y"]) || !Number.isFinite(n.size.x) || !Number.isFinite(n.size.y) || n.size.x <= 0 || n.size.y <= 0 ||
|
||||
(Object.hasOwn(n, "parentId") && !identifier(n.parentId)) ||
|
||||
!object(n.extensions) || !finiteJson(n.extensions) ||
|
||||
!Array.isArray(n.sockets) ||
|
||||
!object(n.parameters)
|
||||
)
|
||||
fail("AUTHORING_NODE_INVALID", { nodeId: n.id });
|
||||
const parameterKeys = Object.keys(definition.parameters);
|
||||
if (
|
||||
Object.keys(n.parameters).length !== parameterKeys.length ||
|
||||
!parameterKeys.every((key) => Object.hasOwn(n.parameters, key))
|
||||
)
|
||||
fail("AUTHORING_PARAMETER_SET", { nodeId: n.id });
|
||||
const parameters = Object.fromEntries(
|
||||
parameterKeys.map((key) => [
|
||||
key,
|
||||
parameterValue(
|
||||
n.parameters[key],
|
||||
definition.parameters[key],
|
||||
n.id,
|
||||
key,
|
||||
),
|
||||
]),
|
||||
);
|
||||
const expected = [
|
||||
...Object.keys(descriptor.inputs),
|
||||
...Object.keys(descriptor.outputs),
|
||||
];
|
||||
if (n.sockets.length !== expected.length)
|
||||
fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
|
||||
for (const s of n.sockets) {
|
||||
if (!object(s) || !expected.includes(s.key) || sockets.has(s.id))
|
||||
fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s?.key });
|
||||
const input = descriptor.inputs[s.key],
|
||||
socketDefinition = definition.sockets[s.key],
|
||||
direction = input ? "input" : "output",
|
||||
dataType = socketDefinition.type,
|
||||
socketKeys = ["id", "key", "label", "direction", "dataType", "accepts", "maxIncomingLinks", ...(socketDefinition.value ? ["defaultValue"] : []), "visible"];
|
||||
if (
|
||||
!exactKeys(s, socketKeys) ||
|
||||
s.id !== `${n.id}:${s.key}` ||
|
||||
s.label !== socketDefinition.title ||
|
||||
s.direction !== direction ||
|
||||
s.dataType !== dataType ||
|
||||
!Array.isArray(s.accepts) || s.accepts.length !== (direction === "input" ? socketTypes[dataType].acceptsFrom.length : 0) ||
|
||||
!s.accepts.every((v, i) => v === (direction === "input" ? socketTypes[dataType].acceptsFrom[i] : undefined)) ||
|
||||
(socketDefinition.value
|
||||
? !exactKeys(s.defaultValue, ["kind", "value"]) || !finiteJson(s.defaultValue.value)
|
||||
: s.defaultValue !== undefined) ||
|
||||
s.visible !== socketDefinition.visible ||
|
||||
s.maxIncomingLinks !== socketDefinition.maxIncomingLinks
|
||||
)
|
||||
fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s.key });
|
||||
sockets.set(s.id, {
|
||||
node: n.id,
|
||||
key: s.key,
|
||||
direction,
|
||||
semanticType: input
|
||||
? input.accepted.types[0]
|
||||
: descriptor.outputs[s.key].type,
|
||||
authoringType: s.dataType,
|
||||
maxIncomingLinks: s.maxIncomingLinks,
|
||||
});
|
||||
}
|
||||
if (new Set(n.sockets.map((s) => s.key)).size !== expected.length)
|
||||
fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
|
||||
nodes.set(n.id, {
|
||||
ordinal,
|
||||
value: {
|
||||
id: n.id,
|
||||
state: n.muted ? "muted" : "enabled",
|
||||
executor: { key: n.typeId, version: 1 },
|
||||
parameters,
|
||||
inputs: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
const incoming = new Map(),
|
||||
linkIds = new Set(),
|
||||
linkSources = new Map();
|
||||
for (let ordinal = 0; ordinal < raw.links.length; ordinal++) {
|
||||
const link = raw.links[ordinal];
|
||||
if (
|
||||
!object(link) ||
|
||||
!identifier(link.id) ||
|
||||
linkIds.has(link.id) ||
|
||||
!exactKeys(link, ["id", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted", "extensions"]) ||
|
||||
typeof link.muted !== "boolean" || !object(link.extensions) || !finiteJson(link.extensions)
|
||||
)
|
||||
fail("AUTHORING_LINK", { linkId: link?.id });
|
||||
linkIds.add(link.id);
|
||||
const from = sockets.get(link.fromSocketId),
|
||||
to = sockets.get(link.toSocketId);
|
||||
if (
|
||||
!from ||
|
||||
!to ||
|
||||
link.fromNodeId !== from.node ||
|
||||
link.toNodeId !== to.node ||
|
||||
from.direction !== "output" ||
|
||||
to.direction !== "input" ||
|
||||
(!link.muted && (incoming.get(link.toSocketId) ?? 0) >= to.maxIncomingLinks)
|
||||
)
|
||||
fail(
|
||||
!link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity)
|
||||
? "AUTHORING_LINK_INCOMING"
|
||||
: "AUTHORING_LINK",
|
||||
!link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity)
|
||||
? { socketId: link.toSocketId }
|
||||
: { linkId: link.id },
|
||||
);
|
||||
const accepted =
|
||||
descriptors[nodes.get(to.node).value.executor.key].inputs[to.key]
|
||||
.accepted.types;
|
||||
const authoringAccepted = socketTypes[nodeDefinitions[nodes.get(to.node).value.executor.key].sockets[to.key].type].acceptsFrom;
|
||||
if (!accepted.includes(from.semanticType) || !authoringAccepted.includes(from.authoringType))
|
||||
fail("AUTHORING_LINK_TYPE", { linkId: link.id });
|
||||
const linkSource = {
|
||||
kind: "link",
|
||||
linkId: link.id,
|
||||
fromNodeId: link.fromNodeId,
|
||||
fromSocketId: link.fromSocketId,
|
||||
toNodeId: link.toNodeId,
|
||||
toSocketId: link.toSocketId,
|
||||
muted: link.muted,
|
||||
nodeId: to.node,
|
||||
input: to.key,
|
||||
fromSocket: from.key,
|
||||
toSocket: to.key,
|
||||
};
|
||||
linkSources.set(link.id, linkSource);
|
||||
if (!link.muted) {
|
||||
incoming.set(link.toSocketId, (incoming.get(link.toSocketId) ?? 0) + 1);
|
||||
nodes.get(to.node).value.inputs[to.key] = {
|
||||
node: from.node,
|
||||
socket: from.key,
|
||||
};
|
||||
}
|
||||
}
|
||||
const ordered = [...nodes.values()].sort((a, b) =>
|
||||
a.value.id < b.value.id
|
||||
? -1
|
||||
: a.value.id > b.value.id
|
||||
? 1
|
||||
: a.ordinal - b.ordinal,
|
||||
);
|
||||
for (let wireOrdinal = 0; wireOrdinal < ordered.length; wireOrdinal++) {
|
||||
const item = ordered[wireOrdinal];
|
||||
item.value.inputs = Object.fromEntries(
|
||||
Object.keys(descriptors[item.value.executor.key].inputs)
|
||||
.filter((key) => Object.hasOwn(item.value.inputs, key))
|
||||
.map((key) => [key, item.value.inputs[key]]),
|
||||
);
|
||||
const base = `nodes[${wireOrdinal}]`;
|
||||
const nodeSource = { kind: "node", nodeId: item.value.id };
|
||||
paths[base] = nodeSource;
|
||||
for (const field of ["id", "state", "executor", "executor.key", "executor.version"])
|
||||
paths[`${base}.${field}`] = nodeSource;
|
||||
paths[`${base}.parameters`] = nodeSource;
|
||||
for (const key of Object.keys(item.value.parameters))
|
||||
mapValuePaths(paths, `${base}.parameters.${key}`, { kind: "parameter", nodeId: item.value.id, parameter: key }, item.value.parameters[key]);
|
||||
for (const key of Object.keys(descriptors[item.value.executor.key].inputs)) {
|
||||
const link = raw.links.find((x) => !x.muted && x.toNodeId === item.value.id && sockets.get(x.toSocketId)?.key === key);
|
||||
const source = linkSources.get(link?.id) ?? {
|
||||
kind: "input",
|
||||
nodeId: item.value.id,
|
||||
input: key,
|
||||
socketId: `${item.value.id}:${key}`,
|
||||
unconnected: true,
|
||||
};
|
||||
paths[`${base}.inputs.${key}`] = source;
|
||||
if (link) {
|
||||
paths[`${base}.inputs.${key}.node`] = source;
|
||||
paths[`${base}.inputs.${key}.socket`] = {
|
||||
kind: "socket",
|
||||
nodeId: link.fromNodeId,
|
||||
socketId: link.fromSocketId,
|
||||
socket: sockets.get(link.fromSocketId).key,
|
||||
linkId: link.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
paths[`${base}.inputs`] = nodeSource;
|
||||
}
|
||||
const ir = {
|
||||
schemaVersion: 2,
|
||||
graphId: GRAPH_ID,
|
||||
revision,
|
||||
nodes: ordered.map((item) => item.value),
|
||||
};
|
||||
const graphSource = { kind: "graph", graphId: GRAPH_ID };
|
||||
for (const field of ["schemaVersion", "graphId", "revision", "nodes"])
|
||||
paths[field] = graphSource;
|
||||
deepFreeze(paths);
|
||||
deepFreeze(ir);
|
||||
sourceMaps.set(ir, paths);
|
||||
return ir;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthoringGraphError) throw error;
|
||||
fail("AUTHORING_SHAPE");
|
||||
}
|
||||
}
|
||||
export const adaptFxNodeSnapshot=(snapshot,revision=1)=>semanticProjectionToV1(projectAuthoringSnapshot(snapshot),revision);
|
||||
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 {
|
||||
#renderer; #getState; #revision=0; #nextRevision=1; #dirty=true; #applying=null; #listeners=new Set();
|
||||
constructor({renderer,getState}) { this.#renderer=renderer; this.#getState=getState; }
|
||||
get revision(){return this.#revision} get dirty(){return this.#dirty} get applying(){return !!this.#applying}
|
||||
subscribe(fn){this.#listeners.add(fn);return()=>this.#listeners.delete(fn)}
|
||||
markDirty(){this.#dirty=true;this.#emit()}
|
||||
#emit(){for(const fn of this.#listeners)fn({revision:this.#revision,dirty:this.#dirty,applying:!!this.#applying})}
|
||||
apply(adapt){
|
||||
if(this.#applying)return this.#applying;
|
||||
const revision=this.#nextRevision++; this.#dirty=false; this.#emit();
|
||||
this.#applying=(async()=>{try{const snapshot=await this.#getState();const ir=adapt(snapshot,revision);const compiled=await this.#renderer.compileGraph(ir);await this.#renderer.switchCompiledGraph(compiled.compiledId);this.#revision=revision;return compiled} catch(e){this.#dirty=true;throw e} finally{this.#applying=null;this.#emit()}})();
|
||||
return this.#applying;
|
||||
#renderer; #adapt; #revision = 0; #nextRevision = 1; #generation = 0;
|
||||
#current; #lastGood; #applyPromise; #listeners = new Set();
|
||||
#owned = new Map(); #drops = new Map(); #activeCompiles = new Set();
|
||||
#disposed = false; #applyingRecord; #scheduler; #debounceMs; #timer; #destroyPromise;
|
||||
|
||||
constructor({ renderer, adapt, scheduler = globalThis, debounceMs = 150 }) {
|
||||
this.#renderer = renderer; this.#adapt = adapt;
|
||||
this.#scheduler = scheduler; this.#debounceMs = debounceMs;
|
||||
}
|
||||
get revision() { return this.#revision; }
|
||||
get dirty() { return !!this.#current; }
|
||||
get applying() { return !!this.#applyPromise; }
|
||||
get staged() { return this.#current?.candidate ?? null; }
|
||||
get canApply() { return !this.#disposed && !!this.#current?.candidate && !this.#applyPromise; }
|
||||
subscribe(fn) {
|
||||
if (this.#disposed) return () => {};
|
||||
this.#listeners.add(fn); fn(this.#state());
|
||||
return () => this.#listeners.delete(fn);
|
||||
}
|
||||
#state() { return { revision: this.#revision, dirty: this.dirty, applying: this.applying, staged: this.staged, canApply: this.canApply, error: this.#current?.diagnostic ?? null }; }
|
||||
#emit() { if (!this.#disposed) for (const fn of this.#listeners) fn(this.#state()); }
|
||||
#key(id) { return JSON.stringify(id); }
|
||||
#drop(candidate) {
|
||||
if (!candidate) return Promise.resolve();
|
||||
const key = this.#key(candidate.compiledId);
|
||||
if (!this.#owned.has(key)) return this.#drops.get(key) ?? Promise.resolve();
|
||||
if (this.#drops.has(key)) return this.#drops.get(key);
|
||||
let result;
|
||||
try { result = this.#renderer.dropCompiledGraph(candidate.compiledId); }
|
||||
catch (error) { result = Promise.reject(error); }
|
||||
const dropping = Promise.resolve(result)
|
||||
.then(() => { this.#owned.delete(key); })
|
||||
.finally(() => { this.#drops.delete(key); });
|
||||
this.#drops.set(key, dropping);
|
||||
return dropping;
|
||||
}
|
||||
#retire(candidate) { if (candidate) void this.#drop(candidate).catch(() => {}); }
|
||||
#start(record) {
|
||||
if (!record.compile) {
|
||||
record.compile = this.#compile(record);
|
||||
this.#activeCompiles.add(record.compile);
|
||||
record.compile.finally(() => this.#activeCompiles.delete(record.compile));
|
||||
}
|
||||
return record.compile;
|
||||
}
|
||||
#flush(record = this.#current) {
|
||||
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
|
||||
return record ? this.#start(record) : null;
|
||||
}
|
||||
markDirty(snapshot) {
|
||||
if (this.#disposed) return;
|
||||
const previous = this.#current;
|
||||
const record = { generation: ++this.#generation, snapshot, candidate: null, error: null, diagnostic: null, compile: null };
|
||||
this.#current = record;
|
||||
if (previous?.candidate && previous !== this.#applyingRecord && previous.candidate !== this.#lastGood) this.#retire(previous.candidate);
|
||||
if (this.#timer) this.#scheduler.clearTimeout(this.#timer);
|
||||
this.#timer = this.#scheduler.setTimeout(() => { this.#timer = undefined; if (!this.#disposed) this.#start(record); }, this.#debounceMs);
|
||||
this.#emit();
|
||||
}
|
||||
async #compile(record) {
|
||||
let candidate, ir;
|
||||
try {
|
||||
ir = this.#adapt(record.snapshot, this.#nextRevision++);
|
||||
candidate = await this.#renderer.compileGraph(ir);
|
||||
this.#owned.set(this.#key(candidate.compiledId), candidate);
|
||||
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
|
||||
record.candidate = candidate; record.error = record.diagnostic = null; this.#emit(); return candidate;
|
||||
} catch (error) {
|
||||
if (candidate) this.#retire(candidate);
|
||||
record.error = error;
|
||||
record.diagnostic = ir ? mapAuthoringDiagnostic(ir, error) : error;
|
||||
if (this.#current === record) this.#emit();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
apply() {
|
||||
if (this.#disposed) return Promise.resolve(null);
|
||||
if (this.#applyPromise) return this.#applyPromise;
|
||||
const record = this.#current;
|
||||
if (!record) return Promise.resolve(this.#lastGood);
|
||||
this.#applyingRecord = record; this.#flush(record);
|
||||
this.#applyPromise = this.#applyRecord(record); this.#emit(); return this.#applyPromise;
|
||||
}
|
||||
async #applyRecord(record) {
|
||||
try {
|
||||
const candidate = record.candidate ?? (await record.compile);
|
||||
if (!candidate) throw record.error ?? new Error("Graph compilation failed");
|
||||
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
|
||||
await this.#renderer.switchCompiledGraph(candidate.compiledId);
|
||||
const old = this.#lastGood; this.#lastGood = candidate;
|
||||
this.#revision = candidate.revision ?? this.#revision + 1;
|
||||
if (this.#current === record) this.#current = undefined;
|
||||
if (old && old !== candidate) this.#retire(old);
|
||||
return candidate;
|
||||
} finally {
|
||||
if (this.#current !== record && record.candidate && record.candidate !== this.#lastGood) this.#retire(record.candidate);
|
||||
this.#applyPromise = null; this.#applyingRecord = undefined; this.#emit();
|
||||
}
|
||||
}
|
||||
destroy() {
|
||||
if (this.#destroyPromise) return this.#destroyPromise;
|
||||
this.#disposed = true;
|
||||
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
|
||||
this.#listeners.clear();
|
||||
this.#destroyPromise = this.#finish();
|
||||
return this.#destroyPromise;
|
||||
}
|
||||
async #finish() {
|
||||
const applying = this.#applyPromise;
|
||||
await Promise.allSettled([...(applying ? [applying] : []), ...this.#activeCompiles, ...this.#drops.values()]);
|
||||
await Promise.allSettled([...this.#owned.values()].map((candidate) => this.#drop(candidate)));
|
||||
this.#current = this.#lastGood = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ const sizeCanvas = (canvas, value) => {
|
||||
};
|
||||
const mods = e => ({ alt:e.altKey, control:e.ctrlKey, meta:e.metaKey, shift:e.shiftKey });
|
||||
|
||||
export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeType }={}) {
|
||||
export function prepareBrowserHost(canvas, { onError=console.error, requestAddNode }={}) {
|
||||
const ownerDocument=canvas.ownerDocument, ownerWindow=ownerDocument.defaultView ?? window;
|
||||
const originalTabIndex=canvas.getAttribute("tabindex"), originalTouchAction=canvas.style.touchAction;
|
||||
let view, dead=false, generation=0, resizing=false, pending, appliedViewport, menuPending=false, unsubscribeHost=()=>{};
|
||||
let view, root, dead=false, generation=0, requestEpoch=0, resizing=false, pending, appliedViewport, menuPending=false, menuPoint, unsubscribeHost=()=>{};
|
||||
const rootSubscriptions=[];
|
||||
const invalidateAddNode=()=>{requestEpoch++;menuPending=false;requestAddNode?.close?.()};
|
||||
const captured=new Set();
|
||||
const initialViewport=viewport(canvas,ownerWindow); appliedViewport=initialViewport; sizeCanvas(canvas,initialViewport);
|
||||
canvas.tabIndex=0; canvas.style.touchAction="none";
|
||||
@@ -22,14 +24,14 @@ export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeTy
|
||||
if(!view)return;
|
||||
if(e instanceof ownerWindow.PointerEvent){
|
||||
const phase=e.type==="pointerdown"?"down":e.type==="pointermove"?"move":e.type==="pointerup"?"up":"cancel";
|
||||
if(phase==="down"){menuPending=e.button===2&&!e.ctrlKey&&(e.buttons&1)===0;canvas.focus();try{canvas.setPointerCapture(e.pointerId);captured.add(e.pointerId)}catch{}}
|
||||
if(phase==="down"){invalidateAddNode();menuPending=e.button===2&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&!e.shiftKey&&(e.buttons&1)===0;menuPoint={x:e.clientX,y:e.clientY};canvas.focus();try{canvas.setPointerCapture(e.pointerId);captured.add(e.pointerId)}catch{}}
|
||||
if((phase==="up"||phase==="cancel")&&captured.delete(e.pointerId))try{if(canvas.hasPointerCapture(e.pointerId))canvas.releasePointerCapture(e.pointerId)}catch{}
|
||||
view.feedInput({kind:"pointer",phase,pointerId:e.pointerId,pointerType:e.pointerType,position:point(e),button:e.button,buttons:e.buttons,modifiers:mods(e)});
|
||||
}else if(e instanceof ownerWindow.WheelEvent){
|
||||
e.preventDefault(); menuPending=false;
|
||||
e.preventDefault(); invalidateAddNode();
|
||||
const scale=e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_LINE?16:e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_PAGE?Math.max(1,canvas.clientHeight):1;
|
||||
view.feedInput({kind:"wheel",position:point(e),delta:{x:e.deltaX*scale,y:e.deltaY*scale},modifiers:mods(e)});
|
||||
}else if(e instanceof ownerWindow.KeyboardEvent){menuPending=false;view.feedInput({kind:"key",phase:e.type==="keydown"?"down":"up",key:e.key,code:e.code,repeat:e.repeat,modifiers:mods(e)});
|
||||
}else if(e instanceof ownerWindow.KeyboardEvent){invalidateAddNode();view.feedInput({kind:"key",phase:e.type==="keydown"?"down":"up",key:e.key,code:e.code,repeat:e.repeat,modifiers:mods(e)});
|
||||
}else view.feedInput({kind:"focus",phase:e.type==="focus"?"focus":"blur"});
|
||||
};
|
||||
const names=["pointerdown","pointermove","pointerup","pointercancel","wheel","keydown","keyup","focus","blur"];
|
||||
@@ -40,22 +42,23 @@ export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeTy
|
||||
resizing=true;
|
||||
Promise.resolve(view.setViewport(next)).then(()=>{if(dead||currentGeneration!==generation)return;appliedViewport=next;sizeCanvas(canvas,next)}).catch(error=>{if(!dead&¤tGeneration===generation)onError(error)}).finally(()=>{if(dead||currentGeneration!==generation)return;resizing=false;pump()});
|
||||
};
|
||||
const resize=()=>{if(dead)return;pending=viewport(canvas,ownerWindow);pump()};
|
||||
const resize=()=>{if(dead)return;invalidateAddNode();pending=viewport(canvas,ownerWindow);pump()};
|
||||
const outside=e=>{if(view&&e.button===0&&e.target!==canvas&&!canvas.contains(e.target)&&view.getHostSnapshot().colorPickerOpen)view.feedInput({kind:"outside-pointer",button:0})};
|
||||
const lost=e=>captured.delete(e.pointerId);
|
||||
const observer=new ownerWindow.ResizeObserver(resize);
|
||||
return {initialViewport,attach(_root,next){
|
||||
view=next;
|
||||
root=_root;view=next;
|
||||
for(const n of names)canvas.addEventListener(n,input,{passive:n!=="wheel"});
|
||||
canvas.addEventListener("contextmenu",prevent);canvas.addEventListener("lostpointercapture",lost);
|
||||
ownerDocument.addEventListener("pointerdown",outside,true);ownerWindow.addEventListener("resize",resize);
|
||||
unsubscribeHost=view.onHostRequests(request=>{if(request.kind!=="add-node-menu"||!menuPending)return;menuPending=false;const typeId=chooseNodeType?.(request);if(typeId)view.addNode({typeId,viewPosition:request.viewPosition}).catch(onError)});
|
||||
unsubscribeHost=view.onHostRequests(request=>{if(request.kind!=="add-node-menu"||!menuPending||request.compositionRevision!==view.getHostSnapshot().compositionRevision){invalidateAddNode();return}menuPending=false;const epoch=requestEpoch;requestAddNode?.(request,menuPoint,()=>!dead&&epoch===requestEpoch);});
|
||||
rootSubscriptions.push(root.onMutations(invalidateAddNode),root.onCompositionChanges(invalidateAddNode));
|
||||
observer.observe(canvas);resize();
|
||||
},destroy(){
|
||||
if(dead)return;dead=true;generation++;pending=undefined;observer.disconnect();unsubscribeHost();ownerWindow.removeEventListener("resize",resize);ownerDocument.removeEventListener("pointerdown",outside,true);
|
||||
if(dead)return;dead=true;generation++;pending=undefined;invalidateAddNode();observer.disconnect();unsubscribeHost();for(const unsubscribe of rootSubscriptions)unsubscribe();rootSubscriptions.length=0;ownerWindow.removeEventListener("resize",resize);ownerDocument.removeEventListener("pointerdown",outside,true);
|
||||
for(const n of names)canvas.removeEventListener(n,input);canvas.removeEventListener("contextmenu",prevent);canvas.removeEventListener("lostpointercapture",lost);
|
||||
for(const id of captured)try{if(canvas.hasPointerCapture(id))canvas.releasePointerCapture(id)}catch{}captured.clear();
|
||||
if(originalTabIndex===null)canvas.removeAttribute("tabindex");else canvas.setAttribute("tabindex",originalTabIndex);canvas.style.touchAction=originalTouchAction;view=null;
|
||||
if(originalTabIndex===null)canvas.removeAttribute("tabindex");else canvas.setAttribute("tabindex",originalTabIndex);canvas.style.touchAction=originalTouchAction;view=null;root=null;
|
||||
}};
|
||||
}
|
||||
function prevent(e){e.preventDefault()}
|
||||
|
||||
+346
-28
@@ -1,29 +1,347 @@
|
||||
export const GRAPH_ID = "demo_forward";
|
||||
export const CATALOG_VERSION = 1;
|
||||
|
||||
export const socketTypes = {
|
||||
surface: { title: "Surface", color: "#62b0ff", acceptsFrom: ["surface"] },
|
||||
depth: { title: "Depth", color: "#b58cff", acceptsFrom: ["depth"] },
|
||||
};
|
||||
export const theme = {
|
||||
background:"#151820",grid:"#292e3a",frame:"#30343a80",frameHeader:"#59616c",body:"#292e39",control:"#191d26",controlFill:"#4775b8",controlEditing:"#101218",textSelection:"#4775b8",outline:"#0b0d12",text:"#edf1f7",muted:"#969eaa",shadow:"#00000088",nodeSelected:"#ff9f43",nodeActive:"#ffffff",unknownHeader:"#555b64",unknownSocket:"#999999",linkMuted:"#d94b4b",knifeMuted:"#e85b5b",emphasis:"#ffffff",focus:"#f5a623",editOutline:"#666a70",resize:"#8b8e95",muteOverlay:"#14141459",boxSelectionFill:"#f5a6231f",checkerLight:"#aaaaaa",checkerDark:"#777777",widgetBorder:"#111216",rampBorder:"#111111",resourceBackground:"#202228"
|
||||
};
|
||||
export const styles = { resource:{header:"#3977a8"}, pass:{header:"#426b43"}, output:{header:"#a75d37"} };
|
||||
const socket = (title, direction, type) => ({ title,direction,type,maxIncomingLinks:direction === "input" ? 1 : 0,visible:true,value:null,showValue:false });
|
||||
const node = (title, style, sockets, parameters = {}) => ({ version:1,title,behavior:"standard",style,parameters,sockets,ui:[...Object.keys(parameters).map(parameter=>({kind:"parameter",parameter})),...Object.keys(sockets).map(socket=>({kind:"socket",socket}))],muteBypass:[],migrations:[] });
|
||||
export const nodeDefinitions = {
|
||||
surface_color: node("Surface Color", "resource", { surface:socket("Surface","output","surface") }),
|
||||
depth32: node("Depth 32", "resource", { depth:socket("Depth","output","depth") }),
|
||||
scene_forward: node("Scene Forward", "pass", { color:socket("Color","input","surface"),depth:socket("Depth","input","depth"),result:socket("Result","output","surface") }, {
|
||||
clearColor:{type:"color",default:{kind:"color",value:[0,0,0,1]},minimum:0,maximum:1},
|
||||
clearDepth:{type:"number",default:{kind:"number",value:1},minimum:0,maximum:1,step:0.01},
|
||||
}),
|
||||
present: node("Present", "output", { surface:socket("Surface","input","surface") }),
|
||||
};
|
||||
|
||||
export const descriptors = Object.freeze({
|
||||
surface_color:{version:1,sockets:{surface:["output","surface"]},parameters:[]},
|
||||
depth32:{version:1,sockets:{depth:["output","depth"]},parameters:[]},
|
||||
scene_forward:{version:1,sockets:{color:["input","surface"],depth:["input","depth"],result:["output","surface"]},parameters:["clearColor","clearDepth"]},
|
||||
present:{version:1,sockets:{surface:["input","surface"]},parameters:[]},
|
||||
export const GRAPH_ID = "authored_gpu_culling";
|
||||
export const CATALOG_VERSION = 2;
|
||||
const exact = (type) => ({ kind: "exact", types: [type] });
|
||||
const oneOf = (...types) => ({ kind: "one_of", types });
|
||||
const i = (type, required = true, authoringType) => ({
|
||||
accepted: typeof type === "string" ? exact(type) : type,
|
||||
required,
|
||||
...(authoringType ? { authoringType } : {}),
|
||||
});
|
||||
const o = (type) => ({ type });
|
||||
const texture = {
|
||||
residency: "transient",
|
||||
texture: {
|
||||
dimension: "d2",
|
||||
format: "rgba16_float",
|
||||
extent: {
|
||||
kind: "surface_relative",
|
||||
width: { numerator: 1, denominator: 1 },
|
||||
height: { numerator: 1, denominator: 1 },
|
||||
depthOrArrayLayers: 1,
|
||||
},
|
||||
mipLevelCount: 1,
|
||||
sampleCount: 1,
|
||||
viewFormats: [],
|
||||
},
|
||||
};
|
||||
export const semanticCatalog = Object.freeze({
|
||||
surface_target: {
|
||||
execution: "source",
|
||||
inputs: {},
|
||||
outputs: { surface: o("surface_target") },
|
||||
parameters: {},
|
||||
},
|
||||
texture_spec: {
|
||||
execution: "source",
|
||||
inputs: {},
|
||||
outputs: { spec: o("texture_spec") },
|
||||
parameters: structuredClone(texture),
|
||||
},
|
||||
scene_table: {
|
||||
execution: "source",
|
||||
inputs: {},
|
||||
outputs: { scene: o("scene_table") },
|
||||
parameters: {},
|
||||
},
|
||||
local_aabb_buffer: {
|
||||
execution: "source",
|
||||
inputs: { scene: i("scene_table") },
|
||||
outputs: { localAabbs: o("local_aabb_buffer") },
|
||||
parameters: {},
|
||||
},
|
||||
camera_frustum: {
|
||||
execution: "source",
|
||||
inputs: {},
|
||||
outputs: { frustum: o("camera_frustum") },
|
||||
parameters: {},
|
||||
},
|
||||
visibility_flags: {
|
||||
execution: "source",
|
||||
inputs: { scene: i("scene_table") },
|
||||
outputs: {
|
||||
flags: {
|
||||
...o("boolean_flag_buffer"),
|
||||
authoringType: "visibility_flag_buffer",
|
||||
},
|
||||
},
|
||||
parameters: {},
|
||||
},
|
||||
frustum_cull: {
|
||||
execution: "compute",
|
||||
inputs: {
|
||||
scene: i("scene_table"),
|
||||
localAabbs: i("local_aabb_buffer"),
|
||||
frustum: i("camera_frustum"),
|
||||
},
|
||||
outputs: {
|
||||
flags: {
|
||||
...o("boolean_flag_buffer"),
|
||||
authoringType: "frustum_flag_buffer",
|
||||
},
|
||||
},
|
||||
parameters: {},
|
||||
},
|
||||
mesh_query: {
|
||||
execution: "compute",
|
||||
inputs: {
|
||||
scene: i("scene_table"),
|
||||
isVisible: i("boolean_flag_buffer", false, "visibility_flag_buffer"),
|
||||
isFrustumCulled: i("boolean_flag_buffer", false, "frustum_flag_buffer"),
|
||||
},
|
||||
outputs: { draws: o("draw_stream") },
|
||||
parameters: {
|
||||
filters: [
|
||||
{ flag: "isVisible", predicate: "required_true" },
|
||||
{ flag: "isFrustumCulled", predicate: "required_false" },
|
||||
],
|
||||
},
|
||||
},
|
||||
depth_stencil_config: {
|
||||
execution: "source",
|
||||
inputs: {},
|
||||
outputs: { config: o("depth_stencil_config") },
|
||||
parameters: {
|
||||
depthCompare: "less_equal",
|
||||
depthWriteEnabled: true,
|
||||
clearDepth: 1,
|
||||
},
|
||||
},
|
||||
legacy_forward: {
|
||||
execution: "render",
|
||||
inputs: {
|
||||
scene: i("scene_table"),
|
||||
draws: i("draw_stream"),
|
||||
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||
depthTarget: i(oneOf("texture_spec", "texture")),
|
||||
depthStencil: i("depth_stencil_config"),
|
||||
},
|
||||
outputs: { color: o("texture"), depth: o("texture") },
|
||||
parameters: { clearColor: [0.015, 0.02, 0.03, 1] },
|
||||
},
|
||||
fullscreen_copy: {
|
||||
execution: "render",
|
||||
inputs: {
|
||||
source: i("texture"),
|
||||
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||
},
|
||||
outputs: { color: o("texture") },
|
||||
parameters: {},
|
||||
},
|
||||
tone_map: {
|
||||
execution: "render",
|
||||
inputs: {
|
||||
source: i("texture"),
|
||||
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||
},
|
||||
outputs: { color: o("texture") },
|
||||
parameters: { exposure: 1 },
|
||||
},
|
||||
bloom_extract: {
|
||||
execution: "render",
|
||||
inputs: {
|
||||
source: i("texture"),
|
||||
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||
},
|
||||
outputs: { color: o("texture") },
|
||||
parameters: { threshold: 1, knee: 0.5 },
|
||||
},
|
||||
bloom_blur: {
|
||||
execution: "render",
|
||||
inputs: {
|
||||
source: i("texture"),
|
||||
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||
},
|
||||
outputs: { color: o("texture") },
|
||||
parameters: { direction: [1, 0], radius: 1 },
|
||||
},
|
||||
bloom_composite: {
|
||||
execution: "render",
|
||||
inputs: {
|
||||
source: i("texture"),
|
||||
bloom: i("texture"),
|
||||
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||
},
|
||||
outputs: { color: o("texture") },
|
||||
parameters: { intensity: 1 },
|
||||
},
|
||||
luminance_edge: {
|
||||
execution: "render",
|
||||
inputs: {
|
||||
source: i("texture"),
|
||||
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
|
||||
},
|
||||
outputs: { color: o("texture") },
|
||||
parameters: { strength: 2 },
|
||||
},
|
||||
present: {
|
||||
execution: "present",
|
||||
inputs: { surface: i("texture") },
|
||||
outputs: {},
|
||||
parameters: {},
|
||||
},
|
||||
});
|
||||
const socketColors = [
|
||||
"#d17c7c",
|
||||
"#d19e7c",
|
||||
"#d1c77c",
|
||||
"#9ed17c",
|
||||
"#7cd1a5",
|
||||
"#7ccbd1",
|
||||
"#7c98d1",
|
||||
"#a27cd1",
|
||||
"#d17cb8",
|
||||
];
|
||||
export const socketTypes = Object.fromEntries(
|
||||
[
|
||||
"surface_target",
|
||||
"texture_spec",
|
||||
"texture",
|
||||
"scene_table",
|
||||
"local_aabb_buffer",
|
||||
"camera_frustum",
|
||||
"boolean_flag_buffer",
|
||||
"draw_stream",
|
||||
"depth_stencil_config",
|
||||
"visibility_flag_buffer",
|
||||
"frustum_flag_buffer",
|
||||
].map((type, index) => [
|
||||
type,
|
||||
{
|
||||
title: type.replaceAll("_", " "),
|
||||
color: socketColors[index % socketColors.length],
|
||||
acceptsFrom: [type],
|
||||
},
|
||||
]),
|
||||
);
|
||||
socketTypes.surface_target.acceptsFrom = [
|
||||
"surface_target",
|
||||
"texture_spec",
|
||||
"texture",
|
||||
];
|
||||
socketTypes.texture_spec.acceptsFrom = ["texture_spec", "texture"];
|
||||
socketTypes.boolean_flag_buffer.acceptsFrom = [
|
||||
"boolean_flag_buffer",
|
||||
"visibility_flag_buffer",
|
||||
"frustum_flag_buffer",
|
||||
];
|
||||
export const theme = {
|
||||
background: "#151820",
|
||||
grid: "#292e3a",
|
||||
frame: "#30343a80",
|
||||
frameHeader: "#59616c",
|
||||
body: "#292e39",
|
||||
control: "#24272b",
|
||||
controlFill: "#4775b8",
|
||||
controlEditing: "#181a1d",
|
||||
textSelection: "#4775b8",
|
||||
outline: "#0b0d12",
|
||||
text: "#edf1f7",
|
||||
muted: "#969eaa",
|
||||
shadow: "#00000088",
|
||||
nodeSelected: "#ff9f43",
|
||||
nodeActive: "#ffffff",
|
||||
unknownHeader: "#555b64",
|
||||
unknownSocket: "#999999",
|
||||
linkMuted: "#d94b4b",
|
||||
knifeMuted: "#e85b5b",
|
||||
emphasis: "#ffffff",
|
||||
focus: "#f5a623",
|
||||
editOutline: "#666a70",
|
||||
resize: "#8b8e95",
|
||||
muteOverlay: "#14141459",
|
||||
boxSelectionFill: "#f5a6231f",
|
||||
checkerLight: "#aaaaaa",
|
||||
checkerDark: "#777777",
|
||||
widgetBorder: "#111216",
|
||||
rampBorder: "#111111",
|
||||
resourceBackground: "#202228",
|
||||
};
|
||||
export const styles = {
|
||||
source: { header: "#3977a8" },
|
||||
compute: { header: "#725a9b" },
|
||||
render: { header: "#426b43" },
|
||||
present: { header: "#a75d37" },
|
||||
};
|
||||
const socket = (title, direction, type) => ({
|
||||
title,
|
||||
direction,
|
||||
type,
|
||||
maxIncomingLinks: direction === "input" ? 1 : 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
});
|
||||
const parameterSchema = (value) =>
|
||||
typeof value === "number"
|
||||
? { type: "number", default: { kind: "number", value } }
|
||||
: typeof value === "string"
|
||||
? { type: "string", default: { kind: "string", value } }
|
||||
: typeof value === "boolean"
|
||||
? { type: "boolean", default: { kind: "boolean", value } }
|
||||
: { type: "json", default: { kind: "json", value } };
|
||||
export const nodeDefinitions = Object.fromEntries(
|
||||
Object.entries(semanticCatalog).map(([key, c]) => {
|
||||
const sockets = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(c.inputs).map(([n, v]) => [
|
||||
n,
|
||||
socket(n, "input", v.authoringType ?? v.accepted.types[0]),
|
||||
]),
|
||||
),
|
||||
...Object.fromEntries(
|
||||
Object.entries(c.outputs).map(([n, v]) => [
|
||||
n,
|
||||
socket(n, "output", v.authoringType ?? v.type),
|
||||
]),
|
||||
),
|
||||
},
|
||||
parameters = Object.fromEntries(
|
||||
Object.entries(c.parameters).map(([name, value]) => [
|
||||
name,
|
||||
parameterSchema(value),
|
||||
]),
|
||||
);
|
||||
return [
|
||||
key,
|
||||
{
|
||||
version: 1,
|
||||
title: key.replaceAll("_", " "),
|
||||
behavior: "standard",
|
||||
style: c.execution,
|
||||
parameters,
|
||||
sockets,
|
||||
ui: [
|
||||
...Object.keys(parameters).map((parameter) => ({
|
||||
kind: "parameter",
|
||||
parameter,
|
||||
})),
|
||||
...Object.keys(sockets).map((socket) => ({ kind: "socket", socket })),
|
||||
],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
export const fxNodeComposition = Object.freeze({
|
||||
schemaVersion: 2,
|
||||
id: "yawn.render-graph",
|
||||
version: CATALOG_VERSION,
|
||||
compatibility: { wildcardInputTypes: [] },
|
||||
socketTypes,
|
||||
nodeStyles: styles,
|
||||
resources: {},
|
||||
theme,
|
||||
nodes: nodeDefinitions,
|
||||
});
|
||||
export const descriptors = Object.fromEntries(
|
||||
Object.entries(semanticCatalog).map(([key, c]) => [
|
||||
key,
|
||||
{
|
||||
version: 1,
|
||||
inputs: c.inputs,
|
||||
outputs: c.outputs,
|
||||
parameters: c.parameters,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -1,25 +1,155 @@
|
||||
import { createFxNode } from "@fxnode/index.ts";
|
||||
import { CATALOG_VERSION, GRAPH_ID, nodeDefinitions, socketTypes, styles, theme } from "./catalog.js";
|
||||
import { CATALOG_VERSION, GRAPH_ID, fxNodeComposition } from "./catalog.js";
|
||||
import { prepareBrowserHost } from "./browser-host.js";
|
||||
import { createAddNodeMenu } from "./add-node-menu.js";
|
||||
import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.js";
|
||||
|
||||
const spec = [
|
||||
["surface","surface_color",{x:40,y:100}],
|
||||
["depth","depth32",{x:40,y:330}],
|
||||
["forward","scene_forward",{x:360,y:190}],
|
||||
["present","present",{x:700,y:220}],
|
||||
["surface", "surface_target", { x: 40, y: 40 }],
|
||||
["hdr", "texture_spec", { x: 40, y: 170 }],
|
||||
["depth", "texture_spec", { x: 40, y: 300 }],
|
||||
["scene", "scene_table", { x: 40, y: 470 }],
|
||||
["aabbs", "local_aabb_buffer", { x: 290, y: 430 }],
|
||||
["frustum", "camera_frustum", { x: 290, y: 590 }],
|
||||
["visible", "visibility_flags", { x: 290, y: 300 }],
|
||||
["cull", "frustum_cull", { x: 540, y: 480 }],
|
||||
["query", "mesh_query", { x: 790, y: 330 }],
|
||||
["depth_config", "depth_stencil_config", { x: 790, y: 620 }],
|
||||
["forward", "legacy_forward", { x: 1040, y: 290 }],
|
||||
["copy", "fullscreen_copy", { x: 1300, y: 250 }],
|
||||
["present", "present", { x: 1540, y: 250 }],
|
||||
];
|
||||
async function seed(root) {
|
||||
await root.setState({graphId:GRAPH_ID,catalogVersion:CATALOG_VERSION,nodes:[],links:[],metadata:{}});
|
||||
for(const [nodeId,nodeType,position] of spec) await root.dispatch({type:"node.add",nodeId,nodeType,position});
|
||||
for(const link of [
|
||||
{id:"surface_link",fromNodeId:"surface",fromSocketId:"surface:surface",toNodeId:"forward",toSocketId:"forward:color",muted:false,extensions:{}},
|
||||
{id:"depth_link",fromNodeId:"depth",fromSocketId:"depth:depth",toNodeId:"forward",toSocketId:"forward:depth",muted:false,extensions:{}},
|
||||
{id:"present_link",fromNodeId:"forward",fromSocketId:"forward:result",toNodeId:"present",toSocketId:"present:surface",muted:false,extensions:{}},
|
||||
]) await root.dispatch({type:"link.add",link});
|
||||
await root.setState({
|
||||
graphId: GRAPH_ID,
|
||||
catalogVersion: CATALOG_VERSION,
|
||||
nodes: [],
|
||||
links: [],
|
||||
metadata: {},
|
||||
});
|
||||
for (const [nodeId, nodeType, position] of spec)
|
||||
await root.dispatch({ type: "node.add", nodeId, nodeType, position });
|
||||
const links = [
|
||||
["scene", "scene", "aabbs", "scene"],
|
||||
["scene", "scene", "visible", "scene"],
|
||||
["scene", "scene", "cull", "scene"],
|
||||
["aabbs", "localAabbs", "cull", "localAabbs"],
|
||||
["frustum", "frustum", "cull", "frustum"],
|
||||
["scene", "scene", "query", "scene"],
|
||||
["visible", "flags", "query", "isVisible"],
|
||||
["cull", "flags", "query", "isFrustumCulled"],
|
||||
["scene", "scene", "forward", "scene"],
|
||||
["query", "draws", "forward", "draws"],
|
||||
["hdr", "spec", "forward", "colorTarget"],
|
||||
["depth", "spec", "forward", "depthTarget"],
|
||||
["depth_config", "config", "forward", "depthStencil"],
|
||||
["forward", "color", "copy", "source"],
|
||||
["surface", "surface", "copy", "colorTarget"],
|
||||
["copy", "color", "present", "surface"],
|
||||
];
|
||||
for (const [a, as, b, bs] of links) {
|
||||
const id = `${a}_${as}_${b}_${bs}`;
|
||||
await root.dispatch({
|
||||
type: "link.add",
|
||||
link: {
|
||||
id,
|
||||
fromNodeId: a,
|
||||
fromSocketId: `${a}:${as}`,
|
||||
toNodeId: b,
|
||||
toSocketId: `${b}:${bs}`,
|
||||
muted: false,
|
||||
extensions: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
const authored = await root.getState(),
|
||||
depth = authored.nodes.find((node) => node.id === "depth");
|
||||
depth.parameters.texture = {
|
||||
kind: "json",
|
||||
value: {
|
||||
dimension: "d2",
|
||||
format: "depth32_float",
|
||||
extent: {
|
||||
kind: "surface_relative",
|
||||
width: { numerator: 1, denominator: 1 },
|
||||
height: { numerator: 1, denominator: 1 },
|
||||
depthOrArrayLayers: 1,
|
||||
},
|
||||
mipLevelCount: 1,
|
||||
sampleCount: 1,
|
||||
viewFormats: [],
|
||||
},
|
||||
};
|
||||
await root.setState(authored);
|
||||
}
|
||||
export async function createRenderGraphEditor(canvas) {
|
||||
const chooseNodeType=()=>{const value=canvas.ownerDocument.defaultView?.prompt(`Node type: ${Object.keys(nodeDefinitions).join(", ")}`,"scene_forward");return Object.hasOwn(nodeDefinitions,value)?value:null};
|
||||
const host=prepareBrowserHost(canvas,{chooseNodeType});let root,view,destroying;
|
||||
const destroy=()=>destroying??=(async()=>{host.destroy();try{await view?.detach()}finally{root?.destroy();view=undefined;root=undefined}})();
|
||||
try{root=await createFxNode({applicationId:"yawn.render-graph",applicationVersion:1,resources:{}});await root.setTheme(theme);await root.setHeaderStyles(styles);for(const entry of Object.entries(socketTypes))await root.composeSocket(...entry);for(const entry of Object.entries(nodeDefinitions))await root.composeNode(...entry);await seed(root);view=await root.attachView({canvas,viewport:host.initialViewport,initialCamera:{center:{x:470,y:210},zoom:.5}});host.attach(root,view);await view.whenRendered();return {getState:()=>root.getState(),onSnapshots:fn=>root.onSnapshots(fn),whenRendered:()=>view.whenRendered(),destroy};}catch(e){await destroy().catch(()=>{});throw e}
|
||||
const allocateId = createNodeIdAllocator();
|
||||
let root, view, menu, destroying, dead = false;
|
||||
const requestAddNode = Object.assign(async (request, point, isCurrent = () => true) => {
|
||||
let typeId;
|
||||
try { typeId = await menu?.open(point); } catch (error) { if (!dead && isCurrent()) console.error(error); return; }
|
||||
if (dead || !isCurrent() || !root || !view) return;
|
||||
const alive = () => !dead && isCurrent();
|
||||
try { await spawnRequestedNode(root, view, request, typeId, allocateId, alive); } catch (error) { if (!dead) console.error(error); }
|
||||
}, { close: () => menu?.close() });
|
||||
const host = prepareBrowserHost(canvas, { requestAddNode });
|
||||
const destroy = () =>
|
||||
(destroying ??= (async () => {
|
||||
dead = true;
|
||||
host.destroy();
|
||||
menu?.destroy();
|
||||
try {
|
||||
await view?.detach();
|
||||
} finally {
|
||||
root?.destroy();
|
||||
view = undefined;
|
||||
root = undefined;
|
||||
}
|
||||
})());
|
||||
try {
|
||||
root = await createFxNode({
|
||||
applicationId: "yawn.render-graph",
|
||||
applicationVersion: CATALOG_VERSION,
|
||||
resources: {},
|
||||
});
|
||||
await root.loadComposition(fxNodeComposition);
|
||||
await seed(root);
|
||||
view = await root.attachView({
|
||||
canvas,
|
||||
viewport: host.initialViewport,
|
||||
initialCamera: { center: { x: 780, y: 340 }, zoom: 0.34 },
|
||||
});
|
||||
menu = createAddNodeMenu(canvas.ownerDocument);
|
||||
host.attach(root, view);
|
||||
await view.whenRendered();
|
||||
return createEditorFacade(root, view, { requestAddNode, destroy });
|
||||
} catch (e) {
|
||||
await destroy().catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates the small application-facing wrapper around an fxnode root and view. */
|
||||
export function createEditorFacade(
|
||||
root,
|
||||
view,
|
||||
{ requestAddNode = () => null, destroy = async () => {} } = {},
|
||||
) {
|
||||
return {
|
||||
getState: () => root.getState(),
|
||||
getSaveData: () => root.getSaveData(),
|
||||
load: async (data) => {
|
||||
await root.load(data);
|
||||
await view.whenRendered();
|
||||
},
|
||||
loadComposition: async (data) => {
|
||||
await root.loadComposition(data);
|
||||
await view.whenRendered();
|
||||
},
|
||||
onSnapshots: (fn) =>
|
||||
root.onSnapshots((event) => fn(event.snapshot, event.version)),
|
||||
requestAddNode,
|
||||
whenRendered: () => view.whenRendered(),
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
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 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 {
|
||||
#bridge; #worker; #header; #slots; #buffer; #next = 1; #payload = 1;
|
||||
#pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false;
|
||||
#telemetry; #stopped = false;
|
||||
#telemetry; #profile; #stopped = false;
|
||||
#graphQueue = []; #graphBusy = false;
|
||||
#bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map();
|
||||
|
||||
@@ -40,6 +40,7 @@ export class RendererClient {
|
||||
|
||||
get ready() { return this.#ready; }
|
||||
get telemetry() { return this.#telemetry; }
|
||||
get profile() { return this.#profile; }
|
||||
|
||||
#refreshViews() {
|
||||
const buffer = this.#bridge.memory.buffer;
|
||||
@@ -61,6 +62,9 @@ export class RendererClient {
|
||||
} else if (message?.type === "telemetry") {
|
||||
this.#telemetry = message;
|
||||
dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
|
||||
} else if (message?.type === "profile-snapshot") {
|
||||
this.#profile = message;
|
||||
dispatchEvent(new CustomEvent("renderer-profile", { detail: message }));
|
||||
} else if (message?.type === "fatal") {
|
||||
console.error("renderer worker fatal", message.code, message.message);
|
||||
this.#fail(message.code || "WORKER_FATAL");
|
||||
|
||||
@@ -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 assert from "node:assert/strict";
|
||||
import { createCubeGeometry, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, LoadoutError } from "../static/demo-loadouts.js";
|
||||
import { createCubeGeometry, createMaterialGalleryGlb, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, loadouts, LoadoutError } from "../static/demo-loadouts.js";
|
||||
|
||||
function parseGlb(buffer){const view=new DataView(buffer);assert.equal(view.getUint32(0,true),0x46546c67);assert.equal(view.getUint32(4,true),2);assert.equal(view.getUint32(8,true),buffer.byteLength);const length=view.getUint32(12,true);assert.equal(view.getUint32(16,true),0x4e4f534a);const json=JSON.parse(new TextDecoder().decode(new Uint8Array(buffer,20,length)).trim());const bin=20+length;assert.equal(view.getUint32(bin+4,true),0x004e4942);assert.equal(view.getUint32(bin,true),json.buffers[0].byteLength);return json;}
|
||||
test("procedural cube and sphere have complete indexed vertex streams",()=>{const cube=createCubeGeometry(),sphere=createUvSphereGeometry();assert.deepEqual([cube.positions.length/3,cube.indices.length],[24,36]);assert.ok(sphere.positions.length/3>300);for(const geometry of [cube,sphere]){assert.equal(geometry.normals.length,geometry.positions.length);assert.equal(geometry.texcoords.length,geometry.positions.length/3*2);assert.ok(geometry.indices.every(i=>i>=0&&i<geometry.positions.length/3));}});
|
||||
test("every cube triangle has counter-clockwise outward winding",()=>{const g=createCubeGeometry();for(let i=0;i<g.indices.length;i+=3){const ids=g.indices.slice(i,i+3),p=ids.map(id=>g.positions.slice(id*3,id*3+3)),a=p[1].map((v,j)=>v-p[0][j]),b=p[2].map((v,j)=>v-p[0][j]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],normal=g.normals.slice(ids[0]*3,ids[0]*3+3);assert.ok(cross.reduce((sum,v,j)=>sum+v*normal[j],0)>0)}});
|
||||
test("loadouts return fresh deterministic GLBs with one mesh and nine nodes",async()=>{for(const name of ["cubes","spheres"]){const a=await loadDemoLoadout(name),b=await loadDemoLoadout(name);assert.notStrictEqual(a,b);assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.meshes.length,1);assert.equal(json.nodes.length,9);assert.ok(json.nodes.every(n=>n.mesh===0));assert.deepEqual(json.meshes[0].primitives[0].attributes,{POSITION:0,NORMAL:1,TEXCOORD_0:2});assert.equal(json.accessors[3].componentType,5125);}});
|
||||
test("Phase 6 gallery is deterministic and covers core PBR shader semantics",async()=>{const a=createMaterialGalleryGlb(),b=await loadDemoLoadout("materials");assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.asset.generator,"yawn-phase6-pbr-gallery");assert.deepEqual(json.extensionsUsed,["KHR_materials_ior"]);assert.equal(json.materials.length,16);assert.equal(json.nodes.length,16);assert.equal(json.images.length,3);assert.ok(json.images.every(image=>image.mimeType==="image/png"&&image.bufferView!==undefined));assert.ok(json.meshes.every(mesh=>JSON.stringify(mesh.primitives[0].attributes)===JSON.stringify({POSITION:0,NORMAL:1,TEXCOORD_0:2})));assert.deepEqual(json.materials.slice(0,4).map(x=>x.pbrMetallicRoughness.roughnessFactor),[.08,.3,.6,1]);assert.ok(json.materials.slice(0,4).every(x=>x.pbrMetallicRoughness.metallicFactor===0));assert.ok(json.materials.slice(4,8).every(x=>x.pbrMetallicRoughness.metallicFactor===1));assert.deepEqual(json.materials.slice(8,11).map(x=>x.extensions.KHR_materials_ior.ior),[1,1.5,2]);assert.equal(json.materials[11].normalTexture.index,2);assert.equal(json.materials[12].occlusionTexture.index,1);assert.equal(json.materials[13].emissiveTexture.index,0);assert.equal(json.materials[14].alphaMode,"MASK");assert.equal(json.materials[15].doubleSided,true);assert.ok(json.nodes[15].scale[0]<0);assert.equal(loadouts.materials.label,"Phase 6 deterministic PBR gallery");});
|
||||
test("loadout dropdown preserves legacy scenes and exposes the material gallery",async()=>{const html=await (await import("node:fs/promises")).readFile(new URL("../static/index.html",import.meta.url),"utf8");for(const id of ["cubes","spheres","materials","manor","sponza"])assert.match(html,new RegExp(`<option value="${id}">`));});
|
||||
test("LFS pointers and HTTP failures are explicit and stable",async()=>{const pointer=new TextEncoder().encode("version https://git-lfs.github.com/spec/v1\noid sha256:abc\n").buffer;assert.equal(isGitLfsPointer(pointer),true);await assert.rejects(loadDemoLoadout("manor",{fetchImpl:async()=>({ok:true,arrayBuffer:async()=>pointer})}),e=>e instanceof LoadoutError&&e.code==="LOADOUT_LFS_POINTER");await assert.rejects(loadDemoLoadout("sponza",{fetchImpl:async()=>({ok:false,status:404})}),e=>e.code==="LOADOUT_HTTP"&&/404/.test(e.message));});
|
||||
test("GLB encoder rejects non-finite and out-of-range geometry",()=>{const valid=createCubeGeometry();assert.throws(()=>encodeGeometryGlb({...valid,positions:[...valid.positions.slice(0,-1),NaN]}),/Invalid/);assert.throws(()=>encodeGeometryGlb({...valid,indices:[...valid.indices,0,1,999]}),/Invalid/)});
|
||||
|
||||
@@ -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 assert from "node:assert/strict";
|
||||
import { adaptFxNodeSnapshot, AuthoringGraphError } from "../static/render-graph/adapter.js";
|
||||
import {
|
||||
adaptFxNodeSnapshot,
|
||||
AuthoringGraphError,
|
||||
getSourceMap,
|
||||
mapAuthoringDiagnostic,
|
||||
} from "../static/render-graph/adapter.js";
|
||||
import { RendererError } from "../static/renderer-client.js";
|
||||
import {
|
||||
semanticCatalog,
|
||||
nodeDefinitions,
|
||||
GRAPH_ID,
|
||||
CATALOG_VERSION,
|
||||
socketTypes,
|
||||
} from "../static/render-graph/catalog.js";
|
||||
import { culling } from "../static/render-graph/presets.js";
|
||||
import { AuthoringController } from "../static/render-graph/authoring-controller.js";
|
||||
|
||||
const sockets={surface_color:[["surface","surface:surface","output","surface"]],depth32:[["depth","depth:depth","output","depth"]],scene_forward:[["color","forward:color","input","surface"],["depth","forward:depth","input","depth"],["result","forward:result","output","surface"]],present:[["surface","present:surface","input","surface"]]};
|
||||
function fixture(){const nodes=Object.entries(sockets).map(([typeId,list],i)=>({id:["surface","depth","forward","present"][i],typeId,typeVersion:1,known:true,muted:false,parameters:typeId==="scene_forward"?{clearColor:{kind:"color",value:[.1,.2,.3,1]},clearDepth:{kind:"number",value:.5}}:{},sockets:list.map(([key,id,direction,dataType])=>({key,id,direction,dataType}))}));return {version:4,graphId:"demo_forward",catalogVersion:1,nodes,links:[{id:"l1",fromNodeId:"surface",fromSocketId:"surface:surface",toNodeId:"forward",toSocketId:"forward:color",muted:false},{id:"l2",fromNodeId:"depth",fromSocketId:"depth:depth",toNodeId:"forward",toSocketId:"forward:depth",muted:false},{id:"l3",fromNodeId:"forward",fromSocketId:"forward:result",toNodeId:"present",toSocketId:"present:surface",muted:false}],metadata:{layout:"ignored"}}}
|
||||
const rejects=(mutate,code)=>{const x=structuredClone(fixture());mutate(x);assert.throws(()=>adaptFxNodeSnapshot(x),e=>e instanceof AuthoringGraphError&&e.code===code)};
|
||||
test("adapter emits exact deterministic V1 without fxnode state",()=>{const a=adaptFxNodeSnapshot(fixture(),7),b=fixture();b.nodes.reverse();b.links.reverse();assert.deepEqual(adaptFxNodeSnapshot(b,7),a);assert.equal(a.revision,7);assert.deepEqual(a.passes[0].writes[0].access.load.value,[.1,.2,.3,1]);for(const token of ["position","socketId","known","fxnode"])assert.equal(JSON.stringify(a).includes(token),false)});
|
||||
test("adapter rejects node catalog, identity, sockets, links, topology and parameters",()=>{
|
||||
rejects(x=>x.nodes[0].known=false,"AUTHORING_NODE_UNKNOWN");rejects(x=>delete x.nodes[0].muted,"AUTHORING_NODE_MUTED");rejects(x=>x.nodes[0].muted=true,"AUTHORING_NODE_MUTED");rejects(x=>x.nodes[0].typeVersion=2,"AUTHORING_NODE_VERSION");rejects(x=>x.nodes[0].typeId="other","AUTHORING_NODE_TYPE");rejects(x=>x.nodes[0].id="bad id","AUTHORING_ID");rejects(x=>x.nodes[1].id=x.nodes[0].id,"AUTHORING_ID_DUPLICATE");rejects(x=>x.nodes[0].sockets[0].direction="input","AUTHORING_SOCKET");rejects(x=>x.links[0].muted=true,"AUTHORING_TOPOLOGY");rejects(x=>x.links.pop(),"AUTHORING_TOPOLOGY");rejects(x=>x.nodes[2].parameters.clearDepth.value=Infinity,"AUTHORING_PARAMETER");
|
||||
function fixture() {
|
||||
const nodes = culling.nodes.map((n) => {
|
||||
const d = semanticCatalog[n.executor.key];
|
||||
const definition = nodeDefinitions[n.executor.key];
|
||||
return {
|
||||
id: n.id,
|
||||
typeId: n.executor.key,
|
||||
typeVersion: 1,
|
||||
known: true,
|
||||
muted: n.state !== "enabled",
|
||||
position: { x: 10, y: 20 },
|
||||
size: { x: 200, y: 120 },
|
||||
label: n.id,
|
||||
collapsed: false,
|
||||
extensions: {},
|
||||
parameters: Object.fromEntries(
|
||||
Object.entries(definition.parameters).map(([key, schema]) => [
|
||||
key,
|
||||
{
|
||||
kind: schema.type,
|
||||
value: structuredClone(n.parameters[key] ?? schema.default.value),
|
||||
},
|
||||
]),
|
||||
),
|
||||
sockets: [
|
||||
...Object.entries(d.inputs).map(([key, x]) => ({
|
||||
key,
|
||||
id: `${n.id}:${key}`,
|
||||
direction: "input",
|
||||
dataType: x.authoringType ?? x.accepted.types[0],
|
||||
label: key,
|
||||
accepts: socketTypes[x.authoringType ?? x.accepted.types[0]].acceptsFrom,
|
||||
visible: true,
|
||||
maxIncomingLinks: 1,
|
||||
})),
|
||||
...Object.entries(d.outputs).map(([key]) => ({
|
||||
key,
|
||||
id: `${n.id}:${key}`,
|
||||
direction: "output",
|
||||
dataType: definition.sockets[key].type,
|
||||
label: key,
|
||||
accepts: [],
|
||||
visible: true,
|
||||
maxIncomingLinks: 0,
|
||||
})),
|
||||
],
|
||||
};
|
||||
});
|
||||
const links = [];
|
||||
for (const n of culling.nodes)
|
||||
for (const [socket, from] of Object.entries(n.inputs))
|
||||
links.push({
|
||||
id: `l_${from.node}_${from.socket}_${n.id}_${socket}`,
|
||||
fromNodeId: from.node,
|
||||
fromSocketId: `${from.node}:${from.socket}`,
|
||||
toNodeId: n.id,
|
||||
toSocketId: `${n.id}:${socket}`,
|
||||
muted: false,
|
||||
extensions: {},
|
||||
});
|
||||
return {
|
||||
graphId: GRAPH_ID,
|
||||
catalogVersion: CATALOG_VERSION,
|
||||
nodes,
|
||||
links,
|
||||
metadata: { layout: "ignored" },
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
test("catalog exhaustively mirrors all current V2 contracts", () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(semanticCatalog).sort(),
|
||||
[
|
||||
"surface_target",
|
||||
"texture_spec",
|
||||
"scene_table",
|
||||
"local_aabb_buffer",
|
||||
"camera_frustum",
|
||||
"visibility_flags",
|
||||
"frustum_cull",
|
||||
"mesh_query",
|
||||
"depth_stencil_config",
|
||||
"legacy_forward",
|
||||
"fullscreen_copy",
|
||||
"tone_map",
|
||||
"bloom_extract",
|
||||
"bloom_blur",
|
||||
"bloom_composite",
|
||||
"luminance_edge",
|
||||
"present",
|
||||
].sort(),
|
||||
);
|
||||
for (const c of Object.values(semanticCatalog)) {
|
||||
assert.ok(c.execution);
|
||||
assert.ok(c.inputs);
|
||||
assert.ok(c.outputs);
|
||||
assert.ok(c.parameters);
|
||||
}
|
||||
});
|
||||
test("adapter deterministically emits strict V2, permits repeated types, omits muted links and maps sources", () => {
|
||||
const x = fixture(),
|
||||
a = adaptFxNodeSnapshot(x, 7);
|
||||
x.nodes.reverse();
|
||||
x.links.reverse();
|
||||
assert.deepEqual(adaptFxNodeSnapshot(x, 7), a);
|
||||
assert.equal(a.schemaVersion, 2);
|
||||
assert.equal(a.graphId, GRAPH_ID);
|
||||
assert.equal(
|
||||
a.nodes.filter((n) => n.executor.key === "texture_spec").length,
|
||||
2,
|
||||
);
|
||||
assert.ok(
|
||||
Object.values(getSourceMap(a)).some((source) => source.input === "source"),
|
||||
);
|
||||
x.links.find((l) => l.id === "l_forward_color_copy_source").muted = true;
|
||||
assert.equal(
|
||||
adaptFxNodeSnapshot(x, 8).nodes.find((n) => n.id === "copy").inputs.source,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
test("adapter rejects hostile shape, IDs, duplicates, catalog, sockets and type mismatches", () => {
|
||||
const reject = (fn, code) => {
|
||||
const x = fixture();
|
||||
fn(x);
|
||||
assert.throws(
|
||||
() => adaptFxNodeSnapshot(x),
|
||||
(e) => e instanceof AuthoringGraphError && e.code === code,
|
||||
);
|
||||
};
|
||||
reject((x) => (x.graphId = "bad"), "AUTHORING_CATALOG");
|
||||
reject((x) => (x.nodes[0].id = "bad id"), "AUTHORING_ID");
|
||||
reject((x) => (x.nodes[1].id = x.nodes[0].id), "AUTHORING_ID_DUPLICATE");
|
||||
reject((x) => (x.nodes[0].typeId = "wat"), "AUTHORING_NODE_TYPE");
|
||||
reject((x) => (x.nodes[0].sockets = []), "AUTHORING_SOCKET_SET");
|
||||
reject((x) => (x.links[0].toSocketId = "missing:x"), "AUTHORING_LINK");
|
||||
reject((x) => {
|
||||
const link = x.links.find((l) => l.toSocketId === "copy:source");
|
||||
link.fromNodeId = "scene";
|
||||
link.fromSocketId = "scene:scene";
|
||||
}, "AUTHORING_LINK_TYPE");
|
||||
});
|
||||
test("adapter counts only active incoming links and reports socket overflow", () => {
|
||||
const x = fixture();
|
||||
const active = x.links.find((link) => link.toSocketId === "copy:source");
|
||||
x.links.push({ ...structuredClone(active), id: "muted_duplicate", muted: true });
|
||||
assert.doesNotThrow(() => adaptFxNodeSnapshot(x));
|
||||
x.links.push({ ...structuredClone(active), id: "active_overflow" });
|
||||
assert.throws(
|
||||
() => adaptFxNodeSnapshot(x),
|
||||
(error) => error.code === "AUTHORING_LINK_INCOMING" && error.details.socketId === "copy:source",
|
||||
);
|
||||
});
|
||||
test("source map covers Rust fields, nested values, every input and is deeply frozen", () => {
|
||||
const snapshot = fixture();
|
||||
snapshot.links.find((link) => link.toSocketId === "copy:source").muted = true;
|
||||
const ir = adaptFxNodeSnapshot(snapshot, 9), map = getSourceMap(ir);
|
||||
for (const path of ["schemaVersion", "graphId", "revision", "nodes", "nodes[0].id", "nodes[0].state", "nodes[0].executor.key", "nodes[0].executor.version", "nodes[0].parameters", "nodes[0].inputs"])
|
||||
assert.ok(map[path], path);
|
||||
for (const [index, node] of ir.nodes.entries())
|
||||
for (const input of Object.keys(semanticCatalog[node.executor.key].inputs))
|
||||
assert.ok(map[`nodes[${index}].inputs.${input}`]);
|
||||
assert.ok(Object.keys(map).some((path) => /parameters\..+\[|parameters\..+\..+/.test(path)));
|
||||
const socket = Object.values(map).find((source) => source.kind === "socket");
|
||||
const unconnected = Object.values(map).find((source) => source.unconnected === true);
|
||||
const link = Object.values(map).find((source) => source.kind === "link");
|
||||
assert.ok(socket?.socketId && unconnected?.socketId);
|
||||
assert.equal(ir.nodes.find((node) => node.id === "copy").inputs.source, undefined);
|
||||
for (const field of ["linkId", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted"])
|
||||
assert.ok(Object.hasOwn(link, field), field);
|
||||
assert.ok(Object.isFrozen(map) && Object.isFrozen(link));
|
||||
});
|
||||
test("diagnostic mapper creates a frozen RendererError DTO with fallbacks and prefix matching", () => {
|
||||
const ir = adaptFxNodeSnapshot(fixture());
|
||||
const original = new RendererError("GRAPH_INPUT", { message: "bad", field: "nodes[0].executor.key.more", nested: { x: 1 } });
|
||||
const mapped = mapAuthoringDiagnostic(ir, original);
|
||||
assert.notStrictEqual(mapped, original);
|
||||
assert.equal(mapped.code, original.code);
|
||||
assert.equal(mapped.source.kind, "node");
|
||||
assert.equal(mapped.diagnostic, undefined);
|
||||
assert.ok(Object.isFrozen(mapped) && Object.isFrozen(mapped.details.nested));
|
||||
assert.equal(Object.isFrozen(original), false);
|
||||
original.details.nested.x = 2;
|
||||
assert.equal(mapped.details.nested.x, 1);
|
||||
const unmatchedOriginal = new RendererError("GRAPH_INPUT", {
|
||||
message: "unmapped",
|
||||
path: "resources[0]",
|
||||
});
|
||||
const unmatched = mapAuthoringDiagnostic(ir, unmatchedOriginal);
|
||||
assert.notStrictEqual(unmatched, unmatchedOriginal);
|
||||
assert.equal(unmatched.source, undefined);
|
||||
assert.ok(Object.isFrozen(unmatched) && Object.isFrozen(unmatched.details));
|
||||
assert.equal(Object.isFrozen(unmatchedOriginal), false);
|
||||
});
|
||||
test("controller keeps last-good through failures and only drops after successful switch", async () => {
|
||||
let fail = false;
|
||||
const calls = [];
|
||||
const renderer = {
|
||||
compileGraph: async (ir) => ({
|
||||
compiledId: [ir.revision, 1],
|
||||
revision: ir.revision,
|
||||
}),
|
||||
switchCompiledGraph: async (id) => {
|
||||
calls.push(["switch", id]);
|
||||
if (fail) throw Error("switch");
|
||||
},
|
||||
dropCompiledGraph: async (id) => calls.push(["drop", id]),
|
||||
};
|
||||
const c = new AuthoringController({
|
||||
renderer,
|
||||
adapt: (_, revision) => ({ revision }),
|
||||
});
|
||||
c.markDirty({});
|
||||
await c.apply();
|
||||
fail = true;
|
||||
c.markDirty({});
|
||||
await assert.rejects(c.apply());
|
||||
assert.deepEqual(calls, [
|
||||
["switch", [1, 1]],
|
||||
["switch", [2, 1]],
|
||||
]);
|
||||
fail = false;
|
||||
await c.apply();
|
||||
assert.deepEqual(calls.at(-1), ["drop", [1, 1]]);
|
||||
await c.destroy();
|
||||
});
|
||||
test("controller shares in-flight apply", async () => {
|
||||
let release;
|
||||
const gate = new Promise((r) => (release = r));
|
||||
const c = new AuthoringController({
|
||||
adapt: (_, revision) => ({ revision }),
|
||||
renderer: {
|
||||
compileGraph: async (ir) => {
|
||||
await gate;
|
||||
return { compiledId: [ir.revision, 1], revision: ir.revision };
|
||||
},
|
||||
switchCompiledGraph: async () => {},
|
||||
dropCompiledGraph: async () => {},
|
||||
},
|
||||
});
|
||||
c.markDirty({});
|
||||
const a = c.apply();
|
||||
assert.strictEqual(c.apply(), a);
|
||||
release();
|
||||
await a;
|
||||
});
|
||||
test("controller retains mapped diagnostic while apply rejects the original and subscriptions agree", async () => {
|
||||
const original = new RendererError("GRAPH_BAD", { path: "nodes[0].id", message: "bad" });
|
||||
const states = [];
|
||||
const c = new AuthoringController({
|
||||
adapt: (snapshot, revision) => adaptFxNodeSnapshot(snapshot, revision),
|
||||
renderer: { compileGraph: async () => { throw original; }, switchCompiledGraph: async () => {}, dropCompiledGraph: async () => {} },
|
||||
});
|
||||
c.subscribe((state) => states.push(state));
|
||||
c.markDirty(fixture());
|
||||
await assert.rejects(c.apply(), (error) => error === original);
|
||||
assert.notStrictEqual(states.at(-1).error, original);
|
||||
let subscribed;
|
||||
c.subscribe((state) => { subscribed = state; })();
|
||||
assert.strictEqual(subscribed.error, states.at(-1).error);
|
||||
await c.destroy();
|
||||
});
|
||||
test("apply after destroy does not compile and destroy returns one strict promise", async () => {
|
||||
let compiles = 0;
|
||||
const c = new AuthoringController({ adapt: () => ({}), renderer: { compileGraph: async () => { compiles++; }, dropCompiledGraph: async () => {}, switchCompiledGraph: async () => {} } });
|
||||
c.markDirty({});
|
||||
const first = c.destroy();
|
||||
assert.strictEqual(c.destroy(), first);
|
||||
assert.strictEqual(await c.apply(), null);
|
||||
await first;
|
||||
assert.equal(compiles, 0);
|
||||
});
|
||||
test("adapter carries scene mute and bounds revisions",()=>{const x=fixture();x.nodes[2].muted=true;assert.equal(adaptFxNodeSnapshot(x).passes[0].state,"disabled");assert.throws(()=>adaptFxNodeSnapshot(fixture(),0x100000000),e=>e.code==="AUTHORING_REVISION")});
|
||||
test("controller orders compile/switch, revisions and shares one in-flight apply",async()=>{let release;const gate=new Promise(r=>release=r),calls=[];const renderer={async compileGraph(ir){calls.push(`compile:${ir.revision}`);await gate;return{compiledId:[0,1]}},async switchCompiledGraph(id){calls.push(`switch:${id}`)}};const c=new AuthoringController({renderer,getState:async()=>({})});const adapt=(_,r)=>({revision:r}),a=c.apply(adapt);assert.strictEqual(c.apply(adapt),a);c.markDirty();release();await a;assert.deepEqual(calls,["compile:1","switch:0,1"]);assert.equal(c.revision,1);assert.equal(c.dirty,true)});
|
||||
test("controller reserves revisions across compile and switch failures",async()=>{let failure="compile",revisions=[];const c=new AuthoringController({getState:async()=>({}),renderer:{compileGraph:async ir=>{revisions.push(ir.revision);if(failure==="compile")throw Error("no");return{compiledId:[0,1]}},switchCompiledGraph:async()=>{if(failure==="switch")throw Error("no")}}});await assert.rejects(c.apply((_,r)=>({revision:r})));failure=null;await c.apply((_,r)=>({revision:r}));failure="switch";await assert.rejects(c.apply((_,r)=>({revision:r})));failure=null;await c.apply((_,r)=>({revision:r}));assert.deepEqual(revisions,[1,2,3,4]);assert.equal(c.revision,4)});
|
||||
|
||||
@@ -1,4 +1,98 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ember, midnight, renderGraphPresets } from "../static/render-graph/presets.js";
|
||||
test("Phase 8 presets are unique activatable V1 scene-forward graphs",()=>{assert.deepEqual(Object.keys(renderGraphPresets),["midnight","ember"]);assert.deepEqual([midnight.graphId,ember.graphId],["preset_midnight","preset_ember"]);assert.notDeepEqual(midnight.passes[0].writes[0].access.load.value,ember.passes[0].writes[0].access.load.value);for(const graph of [midnight,ember]){assert.equal(graph.schemaVersion,1);assert.equal(graph.revision,1);assert.equal(graph.passes.length,1);assert.equal(graph.passes[0].state,"enabled");assert.deepEqual(graph.passes[0].executor,{key:"scene_forward",version:1});assert.equal(graph.outputs[0].name,"present");}});
|
||||
import {
|
||||
culling,
|
||||
ember,
|
||||
hdr,
|
||||
midnight,
|
||||
renderGraphPresets,
|
||||
} from "../static/render-graph/presets.js";
|
||||
test("Phase 4 unit 4 presets preserve V1 graphs and add the V2 HDR fullscreen topology", () => {
|
||||
assert.deepEqual(Object.keys(renderGraphPresets), [
|
||||
"midnight",
|
||||
"ember",
|
||||
"hdr",
|
||||
"culling",
|
||||
"tone",
|
||||
"edges",
|
||||
"bloom",
|
||||
"combined",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
[midnight.graphId, ember.graphId],
|
||||
["preset_midnight", "preset_ember"],
|
||||
);
|
||||
assert.notDeepEqual(
|
||||
midnight.passes[0].writes[0].access.load.value,
|
||||
ember.passes[0].writes[0].access.load.value,
|
||||
);
|
||||
for (const graph of [midnight, ember]) {
|
||||
assert.equal(graph.schemaVersion, 1);
|
||||
assert.equal(graph.revision, 1);
|
||||
assert.equal(graph.passes.length, 1);
|
||||
assert.equal(graph.passes[0].state, "enabled");
|
||||
assert.deepEqual(graph.passes[0].executor, {
|
||||
key: "scene_forward",
|
||||
version: 1,
|
||||
});
|
||||
assert.equal(graph.outputs[0].name, "present");
|
||||
}
|
||||
assert.equal(hdr.schemaVersion, 2);
|
||||
assert.equal(hdr.revision, 1);
|
||||
assert.equal(hdr.graphId, "preset_hdr_fullscreen");
|
||||
assert.equal(
|
||||
new Set(Object.values(renderGraphPresets).map((graph) => graph.graphId))
|
||||
.size,
|
||||
8,
|
||||
);
|
||||
assert.deepEqual(
|
||||
hdr.nodes.map((node) => [node.id, node.executor.key]),
|
||||
[
|
||||
["surface", "surface_target"],
|
||||
["hdr", "texture_spec"],
|
||||
["depth", "texture_spec"],
|
||||
["scene", "scene_table"],
|
||||
["visible", "visibility_flags"],
|
||||
["query", "mesh_query"],
|
||||
["depth_config", "depth_stencil_config"],
|
||||
["forward", "legacy_forward"],
|
||||
["copy", "fullscreen_copy"],
|
||||
["present", "present"],
|
||||
],
|
||||
);
|
||||
const byId = Object.fromEntries(hdr.nodes.map((node) => [node.id, node]));
|
||||
assert.equal(byId.hdr.parameters.texture.format, "rgba16_float");
|
||||
assert.deepEqual(byId.query.inputs, {
|
||||
scene: { node: "scene", socket: "scene" },
|
||||
isVisible: { node: "visible", socket: "flags" },
|
||||
});
|
||||
assert.deepEqual(byId.query.parameters.filters, [
|
||||
{ flag: "isVisible", predicate: "required_true" },
|
||||
{ flag: "isFrustumCulled", predicate: "any" },
|
||||
]);
|
||||
assert.deepEqual(byId.forward.inputs.colorTarget, {
|
||||
node: "hdr",
|
||||
socket: "spec",
|
||||
});
|
||||
assert.deepEqual(byId.copy.executor, { key: "fullscreen_copy", version: 1 });
|
||||
assert.deepEqual(byId.copy.inputs, {
|
||||
source: { node: "forward", socket: "color" },
|
||||
colorTarget: { node: "surface", socket: "surface" },
|
||||
});
|
||||
assert.deepEqual(byId.present.inputs.surface, {
|
||||
node: "copy",
|
||||
socket: "color",
|
||||
});
|
||||
assert.deepEqual(
|
||||
culling.nodes
|
||||
.filter((node) => ["frustum_cull", "mesh_query"].includes(node.executor.key))
|
||||
.map((node) => node.executor.key),
|
||||
["frustum_cull", "mesh_query"],
|
||||
);
|
||||
const cullingQuery = culling.nodes.find((node) => node.id === "query");
|
||||
assert.equal(cullingQuery.parameters.filters[1].predicate, "required_false");
|
||||
assert.deepEqual(cullingQuery.inputs.isFrustumCulled, {
|
||||
node: "cull",
|
||||
socket: "flags",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,12 @@ test("pending reply exists before ring publication", async () => {
|
||||
worker.reply({type:"reply",request:2,ok:true});
|
||||
await pending;
|
||||
});
|
||||
test("profile snapshots have a dedicated getter", () => {
|
||||
const f=fixture(), snapshot={type:"profile-snapshot",available:true,epoch:3,passes:{forward:1.25}};
|
||||
const dispatch=globalThis.dispatchEvent; globalThis.dispatchEvent=()=>true;
|
||||
try { f.worker.reply(snapshot); assert.strictEqual(f.client.profile,snapshot); }
|
||||
finally { globalThis.dispatchEvent=dispatch; f.client.dispose(); }
|
||||
});
|
||||
test("worker failures and dispose reject every pending operation", async () => {
|
||||
const f=fixture(), mesh=await imported(f); const {worker,client,bridge}=f;
|
||||
const a=mesh.setVisible(true), b=mesh.setVisible(false);
|
||||
|
||||
Reference in New Issue
Block a user