feat: add render graph driven renderer architecture

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

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-27 02:53:44 +00:00
co-authored by heaust
parent 8a8369706b
commit d4e8634f67
290 changed files with 48804 additions and 1995 deletions
Generated
+10 -1108
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -40,7 +40,6 @@ bytemuck = { version = "1.23.1", features = ["derive"] }
cgmath = "0.18"
raw-window-handle = "0.6.2"
wgpu = "26.0.1"
reqwest = { version = "0.12.23", features = ["json"] }
thiserror = "2.0.15"
ultraviolet = "0.10.0"
futures = "0.3"
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://agent-browser.dev/schema.json",
"webgpu": true,
"args": "--enable-unsafe-webgpu,--enable-features=Vulkan,--use-angle=vulkan,--use-vulkan=swiftshader,--use-webgpu-adapter=swiftshader,--disable-vulkan-surface"
"args": "--enable-unsafe-webgpu,--enable-features=Vulkan,--use-angle=vulkan,--use-vulkan=swiftshader,--use-webgpu-adapter=swiftshader"
}
+47 -39
View File
@@ -6,15 +6,16 @@ use wasm_bindgen::prelude::*;
use renderer::app_setup::WebApp;
use renderer::camera::Camera;
use renderer::message::WindowEvent;
use renderer::render_data::{MeshCreateInfo, RenderData, RenderFlags};
use renderer::renderer as gpu_renderer;
use renderer::renderer::scene::{mesh_vertex_layout, FrameMetadata, Mesh, MeshBuilder};
use renderer::renderer::gpu_scene::vertex_layouts;
use renderer::renderer::scene::FrameMetadata;
/// Simple vertex format.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
pos: [f32; 3],
color: [f32; 3],
}
pub struct EditorScene {
@@ -23,13 +24,13 @@ pub struct EditorScene {
bind_group_layouts: [wgpu::BindGroupLayout; 2],
frame_metadata: FrameMetadata,
cam: Camera,
meshes: Vec<Mesh>,
}
impl renderer::renderer::scene::Scene for EditorScene {
fn setup(
renderer_context: &gpu_renderer::RendererContext,
resources: &mut gpu_renderer::GpuResources,
render_data: &mut RenderData,
) -> Self {
let dimension = ultraviolet::Vec2::new(
renderer_context.surface_config.width as f32,
@@ -57,12 +58,12 @@ impl renderer::renderer::scene::Scene for EditorScene {
bind_group_layouts,
frame_metadata,
cam: camera,
meshes: Vec::new(),
};
scene.create_default_scene(
&renderer_context.device,
resources,
render_data,
renderer_context.surface_config.format,
);
@@ -77,18 +78,14 @@ impl renderer::renderer::scene::Scene for EditorScene {
Some(&mut self.cam)
}
fn uniform_buffers(&self) -> Option<&[wgpu::Buffer]> {
Some(&self.uniform_buffers)
fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> {
Some([&self.uniform_buffers[0], &self.uniform_buffers[1]])
}
fn bind_groups(&self) -> &[wgpu::BindGroup] {
&self.bind_groups
}
fn meshes(&self) -> &[Mesh] {
&self.meshes
}
fn handle_mouse_click(&mut self, x: f32, y: f32) {
self.frame_metadata.mouse_click = [x, y];
}
@@ -101,14 +98,6 @@ impl renderer::renderer::scene::Scene for EditorScene {
self.cam.orbit(delta_x, delta_y);
}
fn clear(&mut self) {
self.meshes.clear();
}
fn add_mesh(&mut self, mesh: Mesh) {
self.meshes.push(mesh);
}
fn set_camera_depth_range(&mut self, near: f32, far: f32) {
self.cam.set_depth_range(near, far);
}
@@ -135,28 +124,22 @@ impl EditorScene {
// First triangle of quad
Vertex {
pos: [-5.0, 0.0, -5.0],
color: [0.2, 0.8, 0.2], // Green
},
Vertex {
pos: [5.0, 0.0, -5.0],
color: [0.2, 0.8, 0.2], // Green
},
Vertex {
pos: [-5.0, 0.0, 5.0],
color: [0.2, 0.8, 0.2], // Green
},
// Second triangle of quad
Vertex {
pos: [5.0, 0.0, -5.0],
color: [0.2, 0.8, 0.2], // Green
},
Vertex {
pos: [5.0, 0.0, 5.0],
color: [0.2, 0.8, 0.2], // Green
},
Vertex {
pos: [-5.0, 0.0, 5.0],
color: [0.2, 0.8, 0.2], // Green
},
];
// Wind the ground plane so the upward-facing side is front-facing (CCW from
@@ -167,6 +150,7 @@ impl EditorScene {
&mut self,
device: &wgpu::Device,
resources: &mut gpu_renderer::GpuResources,
render_data: &mut RenderData,
surface_format: wgpu::TextureFormat,
) {
let positions: Vec<[f32; 3]> = Self::VERTICES.iter().map(|v| v.pos).collect();
@@ -181,7 +165,7 @@ impl EditorScene {
[0.0, 1.0],
];
let vertex_layout = mesh_vertex_layout();
let vertex_layout = vertex_layouts();
let pipeline_index = resources.get_or_create_pipeline(
device,
@@ -194,28 +178,52 @@ impl EditorScene {
let scale_factor = 100.0;
let scale_matrix = Mat4::from_scale(scale_factor);
let mesh = MeshBuilder::default()
.with_vertices(device, resources, &positions, &normals, uvs)
.with_indices(device, resources, Self::INDICES)
.with_pipeline(pipeline_index)
.with_model_matrix(device, resources, scale_matrix)
.build();
self.meshes.push(mesh);
let transform: [[f32; 4]; 4] = scale_matrix.into();
render_data
.create_mesh(MeshCreateInfo {
positions: &positions,
normals: &normals,
uvs,
indices: Self::INDICES,
pipeline: pipeline_index,
flags: RenderFlags::VISIBLE,
default_instance_flags: RenderFlags::VISIBLE,
default_transform: transform,
})
.expect("ground plane geometry is valid");
}
}
/// Entrypoint for the level editor
#[wasm_bindgen]
pub fn main() {
pub fn main() -> Result<RendererBridge, JsValue> {
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
wasm_logger::init(wasm_logger::Config::default());
wasm_bindgen_futures::spawn_local(async {
let runtime = LevelEditor::setup_runtime().unwrap();
// Keep the runtime running and prevent drops
Box::leak(Box::new(runtime));
});
let runtime = LevelEditor::setup_runtime()?;
Ok(RendererBridge { runtime })
}
/// Opaque owner of the worker, event listeners, and pinned command ring.
#[wasm_bindgen]
pub struct RendererBridge {
runtime: renderer::app_setup::WebAppRuntime,
}
#[wasm_bindgen]
impl RendererBridge {
#[wasm_bindgen(getter)]
pub fn worker(&self) -> web_sys::Worker {
web_sys::Worker::clone(&*self.runtime.worker())
}
#[wasm_bindgen(getter, js_name = ringPtr)]
pub fn ring_ptr(&self) -> u32 {
self.runtime.ring_ptr()
}
#[wasm_bindgen(getter)]
pub fn memory(&self) -> JsValue {
wasm_bindgen::memory()
}
}
renderer::export_worker_entrypoint!();
+5 -1
View File
@@ -18,6 +18,9 @@ struct VertexInput {
@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 VertexOutput {
@@ -39,7 +42,8 @@ fn vs_main(in: VertexInput) -> VertexOutput {
let world_position = model * vec4<f32>(in.pos, 1.0);
out.clip_position = view_proj * world_position;
out.world_pos = world_position.xyz;
out.normal = normalize(in.normal);
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;
}
+1
View File
@@ -19,6 +19,7 @@
"build-release": "run-s clean wasm-release bundle-release",
"build-renderer": "run-s clean wasm-renderer-dev bundle-dev",
"build-renderer-release": "run-s clean wasm-renderer-release bundle-release",
"test:js": "node --test tests/*.test.js",
"start": "vite preview",
"clean": "rimraf --glob dist **/pkg",
"clean-all": "rimraf --glob dist **/pkg target node_modules"
+2 -1
View File
@@ -42,11 +42,12 @@ bytemuck = { workspace = true }
cgmath = { workspace = true }
raw-window-handle = { workspace = true }
wgpu = { workspace = true }
reqwest = { workspace = true }
thiserror = { workspace = true }
ultraviolet = { workspace = true }
futures = { workspace = true }
gltf = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
+31 -16
View File
@@ -3,11 +3,7 @@ use wasm_bindgen::closure::Closure;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
#[cfg(target_arch = "wasm32")]
use web_sys::AddEventListenerOptions;
#[cfg(target_arch = "wasm32")]
use wgpu::Error;
use crate::command_ring::CommandRing;
use crate::message::WindowEvent;
#[cfg(target_arch = "wasm32")]
use crate::platform::web;
@@ -15,6 +11,8 @@ use crate::platform::web;
use crate::platform::web::worker::MainWorker;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
#[cfg(target_arch = "wasm32")]
use web_sys::AddEventListenerOptions;
/// Helper struct to store event listener closures
#[cfg(target_arch = "wasm32")]
@@ -41,16 +39,20 @@ impl EventListeners {
/// Setup default window event listeners that forward events to the worker thread
#[cfg(target_arch = "wasm32")]
pub fn setup_event_listeners(worker_chan: &Sender<WindowEvent>) -> Result<EventListeners, JsValue> {
pub fn setup_event_listeners(
worker_chan: &Sender<WindowEvent>,
canvas: &web_sys::HtmlCanvasElement,
) -> Result<EventListeners, JsValue> {
let window = web_sys::window().unwrap();
let resize_worker_chan = worker_chan.clone();
let resize_canvas = canvas.clone();
let resize_listener: Closure<dyn FnMut()> = Closure::new(move || {
use crate::message::ResizeMessage;
let window = web_sys::window().unwrap();
let width = window.inner_width().ok().unwrap().as_f64().unwrap();
let height = window.inner_height().ok().unwrap().as_f64().unwrap();
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 {
@@ -155,29 +157,41 @@ pub struct WebAppRuntime {
worker: MainWorker,
worker_chan: Sender<WindowEvent>,
_event_listeners: EventListeners,
ring: Box<CommandRing>,
}
#[cfg(target_arch = "wasm32")]
impl WebAppRuntime {
/// Initialize the web worker, canvas ownership, and event listeners.
pub fn new<T: crate::renderer::scene::Scene + 'static>(worker_name: &str, canvas_selector: &str) -> Result<Self, JsValue> {
pub fn new<T: crate::renderer::scene::Scene + 'static>(
worker_name: &str,
canvas_selector: &str,
) -> Result<Self, JsValue> {
let (sender, receiver) = mpsc::channel::<WindowEvent>();
let canvas = web::get_canvas_element(canvas_selector);
let worker = MainWorker::spawn(worker_name, 1, move || {
let window = web_sys::window().unwrap();
let dpr = window.device_pixel_ratio();
canvas.set_width((canvas.client_width() as f64 * dpr).round() as u32);
canvas.set_height((canvas.client_height() as f64 * dpr).round() as u32);
let ring = CommandRing::new();
let ring_ptr = ring.ptr();
let worker = MainWorker::spawn(worker_name, 1, ring_ptr, move || {
spawn_local(async move {
MainWorker::run_render_loop::<T>(receiver).await;
let ring = unsafe { &*(ring_ptr as *const CommandRing) };
MainWorker::run_render_loop::<T>(receiver, ring).await;
});
})?;
worker.transfer_ownership(&canvas);
let event_listeners = setup_event_listeners(&sender)?;
let event_listeners = setup_event_listeners(&sender, &canvas)?;
Ok(Self {
worker,
worker_chan: sender,
_event_listeners: event_listeners,
ring,
})
}
@@ -190,6 +204,9 @@ impl WebAppRuntime {
pub fn worker(&self) -> &MainWorker {
&self.worker
}
pub fn ring_ptr(&self) -> u32 {
self.ring.ptr()
}
}
/// Trait for applications that rely on the renderer's default WASM setup.
@@ -212,10 +229,8 @@ pub trait WebApp {
/// 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(),
)?;
let mut runtime =
WebAppRuntime::new::<Self::Scene>(Self::worker_name(), Self::canvas_selector())?;
Self::on_runtime_initialized(&mut runtime);
Ok(runtime)
}
+153
View File
@@ -0,0 +1,153 @@
//! Versioned, fixed-slot SPSC command transport in shared WebAssembly memory.
use std::sync::atomic::{AtomicU32, Ordering};
pub const MAGIC: u32 = u32::from_le_bytes(*b"YAWN");
pub const VERSION: u32 = 1;
pub const CAPACITY: usize = 1024;
pub const SLOT_WORDS: usize = 24;
pub const SLOT_BYTES: usize = 96;
pub const HEADER_BYTES: usize = 64;
pub const SLOT_VERSION: u32 = 1;
const STATE_OPEN: u32 = 0;
const STATE_CORRUPT: u32 = 1;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RingError {
Closed,
Backlog,
SlotVersion,
ZeroRequest,
}
#[repr(C, align(64))]
pub struct CommandRing {
header: [AtomicU32; 16],
slots: [[AtomicU32; SLOT_WORDS]; CAPACITY],
}
impl CommandRing {
pub fn new() -> Box<Self> {
let ring = Box::new(Self {
header: std::array::from_fn(|_| AtomicU32::new(0)),
slots: std::array::from_fn(|_| std::array::from_fn(|_| AtomicU32::new(0))),
});
ring.header[0].store(MAGIC, Ordering::Relaxed);
ring.header[1].store(VERSION, Ordering::Relaxed);
ring.header[2].store(CAPACITY as u32, Ordering::Relaxed);
ring.header[3].store(SLOT_WORDS as u32, Ordering::Relaxed);
ring
}
pub fn ptr(&self) -> u32 {
self as *const Self as usize as u32
}
/// Consumer-only. The producer publishes word zero (slot version) last, then write_index.
pub fn drain(&self, mut visit: impl FnMut([u32; SLOT_WORDS])) -> Result<(), RingError> {
if self.header[6].load(Ordering::Acquire) != STATE_OPEN {
return Err(RingError::Closed);
}
let mut read = self.header[4].load(Ordering::Relaxed);
let write = self.header[5].load(Ordering::Acquire);
if write.wrapping_sub(read) > CAPACITY as u32 {
self.header[6].store(STATE_CORRUPT, Ordering::Release);
return Err(RingError::Backlog);
}
while read != write {
let slot = &self.slots[read as usize % CAPACITY];
let mut words = [0; SLOT_WORDS];
for (out, word) in words.iter_mut().zip(slot) {
*out = word.load(Ordering::Relaxed);
}
let error = if words[0] != SLOT_VERSION {
Some(RingError::SlotVersion)
} else if words[2] == 0 {
Some(RingError::ZeroRequest)
} else {
None
};
if let Some(error) = error {
self.header[6].store(STATE_CORRUPT, Ordering::Release);
return Err(error);
}
visit(words);
read = read.wrapping_add(1);
self.header[4].store(read, Ordering::Release);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_layout() {
assert_eq!(std::mem::size_of::<[AtomicU32; 16]>(), HEADER_BYTES);
assert_eq!(std::mem::size_of::<[AtomicU32; SLOT_WORDS]>(), SLOT_BYTES);
assert_eq!(
std::mem::size_of::<CommandRing>(),
HEADER_BYTES + CAPACITY * SLOT_BYTES
);
assert_eq!(std::mem::align_of::<CommandRing>(), 64);
}
#[test]
fn tagged_header_and_fifo_drain() {
let ring = CommandRing::new();
assert_eq!(ring.header[0].load(Ordering::Relaxed), MAGIC);
assert_eq!(ring.header[1].load(Ordering::Relaxed), VERSION);
ring.slots[0][0].store(SLOT_VERSION, Ordering::Relaxed);
ring.slots[0][1].store(7, Ordering::Relaxed);
ring.slots[0][2].store(99, Ordering::Relaxed);
ring.header[5].store(1, Ordering::Release);
let mut seen = vec![];
ring.drain(|w| seen.push((w[1], w[2]))).unwrap();
assert_eq!(seen, [(7, 99)]);
assert_eq!(ring.header[4].load(Ordering::Acquire), 1);
}
#[test]
fn wraps_slots() {
let ring = CommandRing::new();
ring.header[4].store(CAPACITY as u32, Ordering::Relaxed);
ring.slots[0][0].store(SLOT_VERSION, Ordering::Relaxed);
ring.slots[0][1].store(3, Ordering::Relaxed);
ring.slots[0][2].store(1, Ordering::Relaxed);
ring.header[5].store(CAPACITY as u32 + 1, Ordering::Release);
let mut opcode = 0;
ring.drain(|w| opcode = w[1]).unwrap();
assert_eq!(opcode, 3);
}
#[test]
fn malformed_slot_fails_closed() {
for (version, request, expected) in [
(2, 1, RingError::SlotVersion),
(SLOT_VERSION, 0, RingError::ZeroRequest),
] {
let ring = CommandRing::new();
ring.slots[0][0].store(version, Ordering::Relaxed);
ring.slots[0][2].store(request, Ordering::Relaxed);
ring.header[5].store(1, Ordering::Release);
assert_eq!(ring.drain(|_| {}), Err(expected));
assert_eq!(ring.drain(|_| {}), Err(RingError::Closed));
}
}
#[test]
fn full_is_valid_but_overfull_is_corrupt() {
let full = CommandRing::new();
for slot in &full.slots {
slot[0].store(SLOT_VERSION, Ordering::Relaxed);
slot[2].store(1, Ordering::Relaxed);
}
full.header[5].store(CAPACITY as u32, Ordering::Release);
let mut count = 0;
full.drain(|_| count += 1).unwrap();
assert_eq!(count, CAPACITY);
let overfull = CommandRing::new();
overfull.header[5].store(CAPACITY as u32 + 1, Ordering::Release);
assert_eq!(overfull.drain(|_| {}), Err(RingError::Backlog));
assert_eq!(overfull.drain(|_| {}), Err(RingError::Closed));
}
}
+220 -184
View File
@@ -1,216 +1,252 @@
use std::collections::HashMap;
use gltf::Gltf;
use ultraviolet::{Mat4, Vec3};
use wgpu::TextureFormat;
use crate::renderer::scene::{mesh_vertex_layout, MeshBuilder};
use crate::render_data::{
InstanceHandle, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey, RenderData,
RenderDataError, RenderFlags,
};
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
pub struct InstalledScene {
pub meshes: Vec<MeshHandle>,
pub instances: Vec<InstanceHandle>,
pub bounds: Option<ModelBounds>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ModelBounds {
pub min: [f32; 3],
pub max: [f32; 3],
}
impl ModelBounds {
fn new(min: [f32; 3], max: [f32; 3]) -> Self {
Self { min, max }
}
fn include_point(&mut self, point: [f32; 3]) {
fn include(&mut self, p: [f32; 3]) {
for i in 0..3 {
self.min[i] = self.min[i].min(point[i]);
self.max[i] = self.max[i].max(point[i]);
self.min[i] = self.min[i].min(p[i]);
self.max[i] = self.max[i].max(p[i]);
}
}
}
fn focus_bounds(points: &[[f32; 3]]) -> Option<ModelBounds> {
let first = *points.first()?;
if points.len() < 200 {
let mut bounds = ModelBounds {
min: first,
max: first,
};
for point in &points[1..] {
bounds.include(*point);
}
return Some(bounds);
}
let trim = points.len() / 100;
let mut min = [0.0; 3];
let mut max = [0.0; 3];
for axis in 0..3 {
let mut values: Vec<_> = points.iter().map(|point| point[axis]).collect();
values.sort_by(f32::total_cmp);
min[axis] = values[trim];
max[axis] = values[values.len() - trim - 1];
}
Some(ModelBounds { min, max })
}
#[derive(Debug, thiserror::Error)]
pub enum ImportError {
#[error("failed to fetch the model")]
Http(#[from] reqwest::Error),
#[error("failed to decode bytes")]
GltfParse(#[from] gltf::Error),
#[error("failed to load model")]
LoadError,
#[error("{0}")]
Other(String),
#[error("unsupported or malformed primitive: {0}")]
InvalidPrimitive(String),
#[error("failed to install imported scene")]
Install(#[from] RenderDataError),
}
fn convert_tex_coords(tex_coords: gltf::mesh::util::ReadTexCoords<'_>) -> Vec<[f32; 2]> {
use gltf::mesh::util::ReadTexCoords;
match tex_coords {
ReadTexCoords::F32(iter) => iter.collect(),
ReadTexCoords::U16(iter) => iter
.map(|[u, v]| [u as f32 / u16::MAX as f32, v as f32 / u16::MAX as f32])
.collect(),
ReadTexCoords::U8(iter) => iter
.map(|[u, v]| [u as f32 / u8::MAX as f32, v as f32 / u8::MAX as f32])
.collect(),
}
#[derive(Clone, Debug)]
pub struct ImportedGeometry {
pub key: (usize, usize),
pub double_sided: bool,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub uvs: Vec<[f32; 2]>,
pub indices: Vec<u32>,
}
#[derive(Clone, Debug)]
pub struct ImportedOccurrence {
pub key: (usize, usize),
pub transform: ModelTransform,
}
#[derive(Clone, Debug, Default)]
pub struct ImportedScene {
pub geometries: Vec<ImportedGeometry>,
pub occurrences: Vec<ImportedOccurrence>,
}
fn convert_indices(indices: gltf::mesh::util::ReadIndices<'_>) -> Vec<u32> {
use gltf::mesh::util::ReadIndices;
match indices {
ReadIndices::U8(iter) => iter.map(|i| i as u32).collect(),
ReadIndices::U16(iter) => iter.map(|i| i as u32).collect(),
ReadIndices::U32(iter) => iter.collect(),
}
}
fn visit_node<'a>(
node: gltf::Node<'a>,
parent_transform: Mat4,
device: &wgpu::Device,
resources: &mut crate::renderer::GpuResources,
meshes: &mut Vec<crate::renderer::scene::Mesh>,
data_blob: &[u8],
pipeline_index: usize,
model_bounds: &mut Option<ModelBounds>,
) {
let local_transform = Mat4::from(node.transform().matrix());
let world_transform = parent_transform * local_transform;
let normal_matrix = world_transform.inversed().transposed();
if let Some(mesh) = node.mesh() {
for primitive in mesh.primitives() {
let reader = primitive.reader(|buffer| match buffer.source() {
gltf::buffer::Source::Bin => Some(&data_blob[..]),
_ => None,
});
let positions: Vec<[f32; 3]> = match reader.read_positions() {
Some(iter) => iter.collect(),
None => Vec::new(),
};
if positions.is_empty() {
continue;
}
let vertex_count = positions.len();
let default_normal_vec = normal_matrix.transform_vec3(Vec3::unit_y()).normalized();
let default_normal = [
default_normal_vec.x,
default_normal_vec.y,
default_normal_vec.z,
];
let mut normals: Vec<[f32; 3]> = reader
.read_normals()
.map(|iter| {
iter.map(|normal| {
let vec = Vec3::new(normal[0], normal[1], normal[2]);
let transformed = normal_matrix.transform_vec3(vec).normalized();
[transformed.x, transformed.y, transformed.z]
})
.collect()
})
.unwrap_or_else(|| vec![default_normal; vertex_count]);
if normals.len() != vertex_count {
normals.resize(vertex_count, default_normal);
}
let mut uvs: Vec<[f32; 2]> = reader
.read_tex_coords(0)
.map(convert_tex_coords)
.unwrap_or_else(|| vec![[0.0, 0.0]; vertex_count]);
if uvs.len() != vertex_count {
uvs.resize(vertex_count, [0.0, 0.0]);
}
for position in &positions {
let vec = Vec3::new(position[0], position[1], position[2]);
let transformed = world_transform.transform_point3(vec);
let world_point = [transformed.x, transformed.y, transformed.z];
if let Some(bounds) = model_bounds.as_mut() {
bounds.include_point(world_point);
} else {
*model_bounds = Some(ModelBounds::new(world_point, world_point));
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())?;
let mut result = ImportedScene::default();
let mut seen = HashMap::new();
fn visit(
node: gltf::Node<'_>,
parent: Mat4,
buffers: &[gltf::buffer::Data],
result: &mut ImportedScene,
seen: &mut HashMap<(usize, usize), ()>,
) -> Result<(), ImportError> {
let world = parent * Mat4::from(node.transform().matrix());
if let Some(mesh) = node.mesh() {
for primitive in mesh.primitives() {
if primitive.mode() != gltf::mesh::Mode::Triangles {
return Err(ImportError::InvalidPrimitive(
"only triangle primitives are supported".into(),
));
}
let key = (mesh.index(), primitive.index());
if seen.insert(key, ()).is_none() {
let reader = primitive
.reader(|buffer| buffers.get(buffer.index()).map(|data| data.0.as_slice()));
let Some(read_positions) = reader.read_positions() else {
continue;
};
let positions: Vec<_> = read_positions.collect();
let count = positions.len();
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 mut uvs: Vec<_> = reader
.read_tex_coords(0)
.map(|x| x.into_f32().collect())
.unwrap_or_default();
uvs.resize(count, [0., 0.]);
uvs.truncate(count);
let indices: Vec<u32> = if let Some(indices) = reader.read_indices() {
indices.into_u32().collect()
} else {
let count = u32::try_from(count).map_err(|_| {
ImportError::InvalidPrimitive("vertex count exceeds u32".into())
})?;
(0..count).collect()
};
if indices.is_empty() {
continue;
}
result.geometries.push(ImportedGeometry {
key,
double_sided: primitive.material().double_sided(),
positions,
normals,
uvs,
indices,
});
} else if !result.geometries.iter().any(|geometry| geometry.key == key) {
continue;
}
result.occurrences.push(ImportedOccurrence {
key,
transform: world.into(),
});
}
let indices: Vec<u32> = reader
.read_indices()
.map(convert_indices)
.unwrap_or_else(|| (0..vertex_count as u32).collect());
if indices.is_empty() {
continue;
}
let mesh = MeshBuilder::default()
.with_vertices(device, resources, &positions, &normals, &uvs)
.with_indices(device, resources, &indices)
.with_pipeline(pipeline_index)
.with_model_matrix(device, resources, world_transform)
.build();
meshes.push(mesh);
}
for child in node.children() {
visit(child, world, buffers, result, seen)?
}
Ok(())
}
for child in node.children() {
visit_node(
child,
world_transform,
device,
resources,
meshes,
data_blob,
pipeline_index,
model_bounds,
);
}
}
pub async fn load_gltf_model(
device: &wgpu::Device,
resources: &mut crate::renderer::GpuResources,
meshes: &mut Vec<crate::renderer::scene::Mesh>,
surface_format: TextureFormat,
) -> Result<Option<ModelBounds>, ImportError> {
let glb_data = reqwest::get("http://localhost:8080/themanor.glb")
.await?
.bytes()
.await?;
let model = Gltf::from_slice(&glb_data)?;
let data_blob = model.blob.as_ref().ok_or(ImportError::LoadError)?;
let vertex_layout = mesh_vertex_layout();
let pipeline_index = resources.get_or_create_pipeline(
device,
"gltf_standard",
&vertex_layout,
include_str!("./gltf.wgsl"),
surface_format,
);
let mut model_bounds: Option<ModelBounds> = None;
for scene in model.scenes() {
for node in scene.nodes() {
visit_node(
node,
Mat4::identity(),
device,
resources,
meshes,
data_blob,
pipeline_index,
&mut model_bounds,
);
visit(node, Mat4::identity(), &buffers, &mut result, &mut seen)?
}
}
Ok(result)
}
Ok(model_bounds)
pub fn install_imported(
target: &mut RenderData,
imported: &ImportedScene,
pipelines: [PipelineKey; 2],
) -> Result<InstalledScene, ImportError> {
let mut stage = target.replacement_stage()?;
let mut handles = HashMap::new();
let mut mesh_handles = Vec::with_capacity(imported.geometries.len());
let mut instance_handles = Vec::new();
let mut first = HashMap::new();
for occurrence in &imported.occurrences {
first.entry(occurrence.key).or_insert(occurrence.transform);
}
for geometry in &imported.geometries {
let transform = *first
.get(&geometry.key)
.ok_or_else(|| ImportError::InvalidPrimitive("geometry has no occurrence".into()))?;
let created = stage.create_mesh(MeshCreateInfo {
positions: &geometry.positions,
normals: &geometry.normals,
uvs: &geometry.uvs,
indices: &geometry.indices,
pipeline: pipelines[usize::from(geometry.double_sided)],
flags: RenderFlags::VISIBLE,
default_instance_flags: RenderFlags::VISIBLE,
default_transform: transform,
})?;
handles.insert(geometry.key, created.mesh);
mesh_handles.push(created.mesh);
instance_handles.push(created.default_instance);
}
let mut consumed = HashMap::new();
let mut bounds: Option<ModelBounds> = None;
let geometries: HashMap<_, _> = imported
.geometries
.iter()
.map(|geometry| (geometry.key, geometry))
.collect();
let mut focus_points = Vec::new();
for occurrence in &imported.occurrences {
let mesh = *handles
.get(&occurrence.key)
.ok_or_else(|| ImportError::InvalidPrimitive("occurrence has no geometry".into()))?;
if consumed.insert(occurrence.key, ()).is_some() {
instance_handles.push(stage.create_instance(
mesh,
occurrence.transform,
RenderFlags::VISIBLE,
)?);
}
let geometry = geometries
.get(&occurrence.key)
.expect("installed occurrence must have geometry");
let transform = Mat4::from(occurrence.transform);
focus_points.extend(geometry.positions.iter().map(|position| {
let point = transform.transform_point3(Vec3::from(*position));
[point.x, point.y, point.z]
}));
let local = stage.mesh(mesh).unwrap().aabb;
for x in [local.min[0], local.max[0]] {
for y in [local.min[1], local.max[1]] {
for z in [local.min[2], local.max[2]] {
let p = Mat4::from(occurrence.transform).transform_point3(Vec3::new(x, y, z));
let p = [p.x, p.y, p.z];
if let Some(b) = bounds.as_mut() {
b.include(p)
} else {
bounds = Some(ModelBounds { min: p, max: p })
}
}
}
}
}
bounds = focus_bounds(&focus_points).or(bounds);
target.replace_with(stage)?;
Ok(InstalledScene {
meshes: mesh_handles,
instances: instance_handles,
bounds,
})
}
+7 -3
View File
@@ -18,6 +18,9 @@ struct VertexInput {
@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 VertexOutput {
@@ -39,7 +42,8 @@ fn vs_main(in: VertexInput) -> VertexOutput {
let world_position = model * vec4<f32>(in.pos, 1.0);
out.clip_position = view_proj * world_position;
out.world_pos = world_position.xyz;
out.normal = normalize(in.normal);
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;
}
@@ -47,13 +51,13 @@ fn vs_main(in: VertexInput) -> VertexOutput {
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.2, 0.2, 0.2);
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.15;
let ambient = 0.45;
var specular = 0.0;
if diffuse_strength > 0.0 {
+39
View File
@@ -1,9 +1,48 @@
pub mod app_setup;
pub mod camera;
pub mod command_ring;
pub mod gltf;
pub mod message;
pub mod platform;
pub mod render_data;
pub mod render_graph;
pub mod renderer;
pub mod shared_snapshot;
#[cfg(target_arch = "wasm32")]
thread_local! { static PAYLOADS: std::cell::RefCell<std::collections::HashMap<u32, Vec<u8>>> = Default::default(); }
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn stage_payload(id: u32, bytes: js_sys::Uint8Array) {
PAYLOADS.with(|payloads| {
payloads.borrow_mut().insert(id, bytes.to_vec());
});
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn discard_payload(id: u32) {
PAYLOADS.with(|payloads| {
payloads.borrow_mut().remove(&id);
});
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn clear_payloads() {
PAYLOADS.with(|payloads| payloads.borrow_mut().clear());
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn take_payload(id: u32) -> Option<Vec<u8>> {
PAYLOADS.with(|payloads| payloads.borrow_mut().remove(&id))
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn take_payload(_id: u32) -> Option<Vec<u8>> {
None
}
/// Worker entrypoint helper - executes the closure it is spawned with
/// Applications should export this with #[wasm_bindgen]
+1 -1
View File
@@ -1,6 +1,6 @@
use core::fmt;
use std::sync::mpsc::TryRecvError;
use std::cell::BorrowMutError;
use std::sync::mpsc::TryRecvError;
#[derive(Debug)]
pub enum WindowEvent {
+49 -15
View File
@@ -1,21 +1,32 @@
// Generic worker that imports the app's WASM module relative to the generated pkg folder.
// Works for any application because the relative depth from this file to pkg is stable.
import initWasm, { worker_entrypoint } from "/level-editor/pkg/level_editor.js";
import initWasm, { clear_payloads, discard_payload, stage_payload, worker_entrypoint } from "/level-editor/pkg/level_editor.js";
export function attachMain() {}
let isReady = false;
export function listenerReady() {
if (state !== "waiting-listener") return;
state = "replaying";
for (const queued of pending.splice(0)) route(queued);
state = "ready";
}
onmessage = async (event) => {
console.log("worker received message", event);
if (isReady) return;
let api;
let state = "uninitialized";
const pending = [];
isReady = true;
const wasmModule = event.data[0]; // WebAssembly.Module from wasm_bindgen::module()
const workerId = event.data[1]; // worker ID
const memory = event.data[2]; // shared memory
const entryPtr = event.data[3]; // worker entrypoint function pointer
// This listener is never replaced: canvas and payload transfers that race WASM
// initialization remain ordered and are replayed after init.
addEventListener("message", async (event) => {
const message = event.data;
if (message?.type !== "init") {
if (state !== "ready") pending.push(message);
else route(message);
return;
}
if (state !== "uninitialized") return;
state = "initializing";
const { wasmModule, workerId, memory, entryPtr } = message;
console.log(
"worker: initializing with WASM module",
@@ -25,8 +36,31 @@ onmessage = async (event) => {
);
// Initialize WASM with the shared module and memory forwarded from the main thread.
await initWasm({ module_or_path: wasmModule, memory });
try {
api = await initWasm({ module_or_path: wasmModule, memory });
state = "waiting-listener";
worker_entrypoint(entryPtr);
} catch (error) {
fatal("WORKER_INIT_FAILED", String(error));
}
});
// Call the app-provided worker entrypoint once initialization completes.
worker_entrypoint(entryPtr);
};
function route(message) {
if (message?.type === "canvas") {
dispatchEvent(new MessageEvent("renderer-canvas", { data: message.canvas }));
} else if (message?.type === "payload") {
stage_payload(message.id, new Uint8Array(message.buffer));
postMessage({ type: "payload-ready", id: message.id });
} else if (message?.type === "payload-release") {
discard_payload(message.id);
}
}
function fatal(code, message) {
state = "failed";
pending.length = 0;
try { clear_payloads?.(); } catch { /* best effort during a fatal failure */ }
postMessage({type:"fatal",code,message});
}
addEventListener("error", event => fatal("WORKER_RUNTIME_ERROR", event.error?.stack || `${event.message} (${event.filename}:${event.lineno}:${event.colno})`));
addEventListener("unhandledrejection", event => fatal("WORKER_UNHANDLED_REJECTION",String(event.reason)));
+28 -13
View File
@@ -1,3 +1,4 @@
use crate::command_ring::CommandRing;
use crate::message::WindowEvent;
use log::info;
use std::sync::mpsc::Receiver;
@@ -22,6 +23,9 @@ extern "C" {
/// Nothing to do.
#[wasm_bindgen]
fn attachMain();
#[wasm_bindgen(js_name = "listenerReady")]
fn listener_ready();
}
pub struct MainWorker {
@@ -52,6 +56,7 @@ impl MainWorker {
pub fn spawn(
name: &str,
id: usize,
ring_ptr: u32,
f: impl FnOnce() + Send + 'static,
) -> Result<Self, JsValue> {
// Creates a new worker.
@@ -62,19 +67,20 @@ impl MainWorker {
let ptr = Box::into_raw(Box::new(Box::new(f) as Box<dyn FnOnce()>));
// Sets default callback.
let callback = Closure::new(|_ev| {
info!("got a message..canvas?");
});
let callback = Closure::new(|_ev| {});
handle.set_onmessage(Some(callback.as_ref().unchecked_ref()));
let msg: js_sys::Array = [
&wasm_bindgen::module(),
&id.into(),
&wasm_bindgen::memory(),
&JsValue::from(ptr as u32),
]
.into_iter()
.collect();
let msg = js_sys::Object::new();
for (key, value) in [
("type", JsValue::from("init")),
("wasmModule", wasm_bindgen::module()),
("workerId", id.into()),
("memory", wasm_bindgen::memory()),
("entryPtr", (ptr as u32).into()),
("ringPtr", ring_ptr.into()),
] {
js_sys::Reflect::set(&msg, &key.into(), &value)?;
}
info!("posting message");
handle.post_message(&msg)?;
@@ -90,21 +96,26 @@ impl MainWorker {
let offscreen_canvas = canvas.transfer_control_to_offscreen().unwrap();
let transfer_list = js_sys::Array::new();
transfer_list.push(&offscreen_canvas);
let msg = js_sys::Object::new();
js_sys::Reflect::set(&msg, &"type".into(), &"canvas".into()).unwrap();
js_sys::Reflect::set(&msg, &"canvas".into(), &offscreen_canvas).unwrap();
info!("posting canvas (is_undefined: {})", canvas.is_undefined());
self.handle
.post_message_with_transfer(&offscreen_canvas, &transfer_list)
.post_message_with_transfer(&msg, &transfer_list)
.unwrap();
}
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>(
events_chan: Receiver<WindowEvent>,
ring: &'static CommandRing,
) {
use crate::renderer::Renderer;
let canvas = wait_for_canvas_transfer().await;
let renderer = Rc::new(RefCell::new(Renderer::<T>::new(canvas, events_chan).await));
renderer.borrow_mut().command_ring = Some(ring);
Renderer::run_render_loop(renderer);
}
}
@@ -135,8 +146,12 @@ pub async fn wait_for_canvas_transfer() -> web_sys::OffscreenCanvas {
}
});
global.set_onmessage(Some(handler.as_ref().unchecked_ref()));
global
.add_event_listener_with_callback("renderer-canvas", handler.as_ref().unchecked_ref())
.unwrap();
handler.forget();
listener_ready();
});
let canvas: web_sys::OffscreenCanvas = JsFuture::from(promise)
+240
View File
@@ -0,0 +1,240 @@
use bytemuck::{Pod, Zeroable};
macro_rules! handle {
($name:ident) => {
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Pod, Zeroable)]
pub struct $name {
slot: u32,
generation: u32,
}
impl $name {
pub const fn from_parts(slot: u32, generation: u32) -> Self {
Self { slot, generation }
}
pub const fn slot(self) -> u32 {
self.slot
}
pub const fn generation(self) -> u32 {
self.generation
}
}
};
}
handle!(MeshHandle);
handle!(InstanceHandle);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SlotState {
Occupied,
Vacant { next: Option<u32> },
Retired,
}
#[derive(Clone, Copy, Debug)]
pub(super) struct PreparedSlot {
pub slot: u32,
pub generation: u32,
reused_next: Option<u32>,
append: bool,
}
pub(super) struct SlotTable {
pub(super) generations: Vec<u32>,
pub(super) states: Vec<SlotState>,
free_head: Option<u32>,
live_count: u32,
logical_capacity: u32,
pub(super) max_capacity: Option<u32>,
}
impl SlotTable {
pub fn new(
initial: u32,
max: Option<u32>,
resource: &'static str,
) -> Result<Self, crate::render_data::RenderDataError> {
let mut table = Self {
generations: Vec::new(),
states: Vec::new(),
free_head: None,
live_count: 0,
logical_capacity: 0,
max_capacity: max,
};
table.reserve_for_len(initial, resource)?;
Ok(table)
}
pub fn live_count(&self) -> u32 {
self.live_count
}
pub fn logical_capacity(&self) -> u32 {
self.logical_capacity
}
pub fn max_capacity(&self) -> Option<u32> {
self.max_capacity
}
pub fn required_len_for_prepare(&self) -> Result<u32, crate::render_data::RenderDataError> {
if self.free_head.is_some() {
u32::try_from(self.generations.len()).map_err(|_| {
crate::render_data::RenderDataError::CapacityOverflow { resource: "slots" }
})
} else {
let len = u32::try_from(self.generations.len()).map_err(|_| {
crate::render_data::RenderDataError::CapacityOverflow { resource: "slots" }
})?;
len.checked_add(1)
.ok_or(crate::render_data::RenderDataError::CapacityOverflow { resource: "slots" })
}
}
pub fn reserve_for_len(
&mut self,
required: u32,
resource: &'static str,
) -> Result<(), crate::render_data::RenderDataError> {
let target = crate::render_data::next_capacity(
self.logical_capacity,
required,
self.max_capacity,
resource,
)?;
crate::render_data::reserve_vec(&mut self.generations, target, resource)?;
crate::render_data::reserve_vec(&mut self.states, target, resource)?;
self.logical_capacity = target;
Ok(())
}
pub fn prepare(&self) -> Result<PreparedSlot, crate::render_data::RenderDataError> {
if let Some(slot) = self.free_head {
let index = slot as usize;
let SlotState::Vacant { next } = self.states[index] else {
unreachable!("free list points to a non-vacant slot")
};
Ok(PreparedSlot {
slot,
generation: self.generations[index],
reused_next: next,
append: false,
})
} else {
let slot = u32::try_from(self.generations.len()).map_err(|_| {
crate::render_data::RenderDataError::CapacityOverflow { resource: "slots" }
})?;
Ok(PreparedSlot {
slot,
generation: 1,
reused_next: None,
append: true,
})
}
}
pub fn commit(&mut self, prepared: PreparedSlot) {
if prepared.append {
self.generations.push(prepared.generation);
self.states.push(SlotState::Occupied);
} else {
self.free_head = prepared.reused_next;
self.states[prepared.slot as usize] = SlotState::Occupied;
}
self.live_count += 1;
}
pub fn contains(&self, slot: u32, generation: u32) -> bool {
let index = slot as usize;
self.generations.get(index) == Some(&generation)
&& matches!(self.states.get(index), Some(SlotState::Occupied))
}
pub fn remove(&mut self, slot: u32, generation: u32) -> bool {
if !self.contains(slot, generation) {
return false;
}
let index = slot as usize;
self.live_count -= 1;
if generation == u32::MAX {
self.states[index] = SlotState::Retired;
} else {
self.generations[index] = generation + 1;
self.states[index] = SlotState::Vacant {
next: self.free_head,
};
self.free_head = Some(slot);
}
true
}
pub fn clear(&mut self) {
self.free_head = None;
self.live_count = 0;
for index in (0..self.states.len()).rev() {
match self.states[index] {
SlotState::Occupied if self.generations[index] == u32::MAX => {
self.states[index] = SlotState::Retired;
}
SlotState::Occupied => {
self.generations[index] += 1;
self.states[index] = SlotState::Vacant {
next: self.free_head,
};
self.free_head = Some(
u32::try_from(index).expect("slot table length was checked before append"),
);
}
SlotState::Vacant { .. } => {
self.states[index] = SlotState::Vacant {
next: self.free_head,
};
self.free_head = Some(
u32::try_from(index).expect("slot table length was checked before append"),
);
}
SlotState::Retired => {}
}
}
}
pub fn seed_successor(&mut self, predecessor: &Self) {
self.generations.clear();
self.states.clear();
self.free_head = None;
self.live_count = 0;
for generation in predecessor.generations.iter().copied() {
let generation = generation.saturating_add(1);
self.generations.push(generation);
if generation == u32::MAX {
self.states.push(SlotState::Retired);
} else {
self.states.push(SlotState::Vacant {
next: self.free_head,
});
self.free_head = Some((self.states.len() - 1) as u32);
}
}
}
pub fn occupied(&self) -> impl Iterator<Item = (u32, u32)> + '_ {
self.states.iter().enumerate().filter_map(|(index, state)| {
matches!(state, SlotState::Occupied).then(|| {
(
u32::try_from(index).expect("slot table length was checked before append"),
self.generations[index],
)
})
})
}
#[cfg(test)]
pub fn force_generation(&mut self, slot: u32, generation: u32) {
self.generations[slot as usize] = generation;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
use std::ops::Range;
use super::RenderDataError;
#[derive(Default, Debug)]
pub(super) struct RangeAllocator {
free: Vec<Range<u32>>,
pub(super) high_water: u32,
}
impl RangeAllocator {
pub fn allocate(&mut self, count: u32) -> Result<Range<u32>, RenderDataError> {
if count == 0 {
return Err(RenderDataError::EmptyRange);
}
if let Some(index) = self
.free
.iter()
.position(|range| range.end - range.start >= count)
{
let start = self.free[index].start;
let end = start
.checked_add(count)
.ok_or(RenderDataError::RangeOverflow)?;
self.free[index].start = end;
if self.free[index].is_empty() {
self.free.remove(index);
}
return Ok(start..end);
}
let end = self
.high_water
.checked_add(count)
.ok_or(RenderDataError::RangeOverflow)?;
let range = self.high_water..end;
self.high_water = end;
Ok(range)
}
pub fn free(&mut self, range: Range<u32>) -> Result<u32, RenderDataError> {
if range.start >= range.end {
return Err(RenderDataError::EmptyRange);
}
if range.end > self.high_water {
return Err(RenderDataError::RangeOutOfBounds);
}
let index = self
.free
.partition_point(|candidate| candidate.start < range.start);
if index > 0 && self.free[index - 1].end > range.start
|| index < self.free.len() && self.free[index].start < range.end
{
return Err(RenderDataError::RangeOverlap);
}
let joins_left = index > 0 && self.free[index - 1].end == range.start;
let joins_right = index < self.free.len() && self.free[index].start == range.end;
match (joins_left, joins_right) {
(true, true) => {
let right_end = self.free.remove(index).end;
self.free[index - 1].end = right_end;
}
(true, false) => self.free[index - 1].end = range.end,
(false, true) => self.free[index].start = range.start,
(false, false) => self.free.insert(index, range),
}
while self
.free
.last()
.is_some_and(|range| range.end == self.high_water)
{
self.high_water = self.free.pop().unwrap().start;
}
Ok(self.high_water)
}
pub fn high_water(&self) -> u32 {
self.high_water
}
pub fn clear(&mut self) {
self.free.clear();
self.high_water = 0;
}
}
+522
View File
@@ -0,0 +1,522 @@
use super::*;
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 UVS: [[f32; 2]; 3] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
const INDICES: [u32; 3] = [0, 1, 2];
fn info() -> MeshCreateInfo<'static> {
MeshCreateInfo {
positions: &POSITIONS,
normals: &NORMALS,
uvs: &UVS,
indices: &INDICES,
pipeline: PipelineKey::new(7),
flags: RenderFlags::from_bits_retain(2),
default_instance_flags: RenderFlags::VISIBLE,
default_transform: IDENTITY_MODEL_TRANSFORM,
}
}
fn data() -> RenderData {
RenderData::new(RenderDataConfig {
initial_vertices: 0,
initial_indices: 0,
initial_meshes: 0,
initial_instances: 0,
..RenderDataConfig::default()
})
.unwrap()
}
#[test]
fn affine_world_bounds_cover_translation_scale_shear_and_planes() {
let local = Aabb {
min: [-1.0, -2.0, 0.0],
max: [1.0, 2.0, 0.0],
};
assert_eq!(
affine_world_aabb(local, IDENTITY_MODEL_TRANSFORM),
Ok(local)
);
let model = [
[-2.0, 0.0, 0.0, 0.0],
[0.5, 3.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[10.0, -4.0, 2.0, 1.0],
];
assert_eq!(
affine_world_aabb(local, model),
Ok(Aabb {
min: [7.0, -10.0, 2.0],
max: [13.0, 2.0, 2.0],
})
);
}
#[test]
fn world_bounds_reject_projective_and_overflowing_transforms() {
let local = Aabb {
min: [-1.0; 3],
max: [1.0; 3],
};
let mut projective = IDENTITY_MODEL_TRANSFORM;
projective[0][3] = 0.5;
assert_eq!(
affine_world_aabb(local, projective),
Err(RenderDataError::InvalidTransform)
);
let mut overflowing = IDENTITY_MODEL_TRANSFORM;
overflowing[0][0] = f32::MAX;
overflowing[1][0] = f32::MAX;
assert_eq!(
affine_world_aabb(local, overflowing),
Err(RenderDataError::InvalidTransform)
);
}
#[test]
fn default_instance_is_protected_and_flags_are_separate() {
let mut data = data();
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.instance(created.default_instance).unwrap().flags,
RenderFlags::VISIBLE
);
assert_eq!(
data.destroy_instance(created.default_instance),
Err(RenderDataError::CannotDestroyDefaultInstance)
);
data.set_mesh_flags(created.mesh, RenderFlags::NONE)
.unwrap();
assert_eq!(data.mesh(created.mesh).unwrap().flags, RenderFlags::NONE);
assert_eq!(
data.instance(created.default_instance).unwrap().flags,
RenderFlags::VISIBLE
);
}
#[test]
fn stale_mesh_and_instance_handles_are_rejected_after_reuse() {
let mut data = data();
let first = data.create_mesh(info()).unwrap();
let old_instance = data
.create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE)
.unwrap();
data.destroy_instance(old_instance).unwrap();
let replacement = data
.create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE)
.unwrap();
assert_eq!(old_instance.slot(), replacement.slot());
assert_ne!(old_instance.generation(), replacement.generation());
assert!(data.instance(old_instance).is_none());
data.destroy_mesh(first.mesh).unwrap();
let second = data.create_mesh(info()).unwrap();
assert_eq!(first.mesh.slot(), second.mesh.slot());
assert_ne!(first.mesh.generation(), second.mesh.generation());
assert!(data.mesh(first.mesh).is_none());
}
#[test]
fn clear_handles_all_slot_states_retains_capacity_and_never_reuses_retired() {
let mut data = data();
let mesh = data.create_mesh(info()).unwrap();
let vacant = data
.create_instance(mesh.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE)
.unwrap();
data.destroy_instance(vacant).unwrap();
data.instances
.slots
.force_generation(mesh.default_instance.slot(), u32::MAX);
let old_capacity = data.capacities();
data.clear().unwrap();
assert_eq!(data.capacities(), old_capacity);
assert_eq!(data.mesh_count(), 0);
assert_eq!(data.instance_count(), 0);
assert!(data.mesh(mesh.mesh).is_none());
assert!(matches!(
data.instances.slots.states[mesh.default_instance.slot() as usize],
SlotState::Retired
));
let new_mesh = data.create_mesh(info()).unwrap();
assert_ne!(
new_mesh.default_instance.slot(),
mesh.default_instance.slot()
);
}
#[test]
fn capacity_math_has_exact_bounded_and_unbounded_overflow_behavior() {
assert_eq!(next_capacity(0, 1, None, "x"), Ok(1));
assert_eq!(next_capacity(1, 2, None, "x"), Ok(2));
assert_eq!(next_capacity(2, 3, Some(3), "x"), Ok(3));
assert_eq!(
next_capacity(u32::MAX - 1, u32::MAX, Some(u32::MAX), "x"),
Ok(u32::MAX)
);
assert!(matches!(
next_capacity(u32::MAX - 1, u32::MAX, None, "x"),
Err(RenderDataError::CapacityOverflow { .. })
));
assert!(matches!(
next_capacity(2, 4, Some(3), "x"),
Err(RenderDataError::CapacityExceeded { .. })
));
}
#[test]
fn all_storage_classes_grow_and_retired_slots_force_max_checked_append() {
let mut data = data();
let mesh = data.create_mesh(info()).unwrap();
assert_eq!(
data.capacities(),
RenderDataCapacities {
vertices: 3,
indices: 3,
meshes: 1,
instances: 1,
}
);
data.create_instance(mesh.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE)
.unwrap();
assert_eq!(data.capacities().instances, 2);
let mut slots = SlotTable::new(0, Some(1), "test").unwrap();
slots.reserve_for_len(1, "test").unwrap();
let prepared = slots.prepare().unwrap();
slots.commit(prepared);
slots.force_generation(0, u32::MAX);
slots.remove(0, u32::MAX);
assert!(matches!(
slots.reserve_for_len(slots.required_len_for_prepare().unwrap(), "test"),
Err(RenderDataError::CapacityExceeded { .. })
));
}
#[test]
fn allocator_checks_errors_splits_first_fit_coalesces_and_trims_tail() {
let mut allocator = RangeAllocator::default();
assert_eq!(allocator.allocate(0), Err(RenderDataError::EmptyRange));
let left = allocator.allocate(2).unwrap();
let middle = allocator.allocate(4).unwrap();
let right = allocator.allocate(2).unwrap();
assert_eq!(allocator.free(middle.clone()), Ok(8));
assert_eq!(allocator.allocate(2).unwrap(), 2..4);
assert_eq!(allocator.free(2..4), Ok(8));
assert_eq!(allocator.free(2..4), Err(RenderDataError::RangeOverlap));
assert_eq!(allocator.free(8..9), Err(RenderDataError::RangeOutOfBounds));
assert_eq!(allocator.free(3..3), Err(RenderDataError::EmptyRange));
assert_eq!(allocator.free(left), Ok(8));
assert_eq!(allocator.free(right), Ok(0));
let mut bridge = RangeAllocator::default();
bridge.allocate(6).unwrap();
bridge.free(0..2).unwrap();
bridge.free(4..6).unwrap();
bridge.free(2..4).unwrap();
assert_eq!(bridge.high_water(), 0);
let mut overflow = RangeAllocator::default();
overflow.high_water = u32::MAX;
assert_eq!(overflow.allocate(1), Err(RenderDataError::RangeOverflow));
}
#[test]
fn streams_remain_coordinated_across_interior_delete_tail_delete_and_reuse() {
let mut data = data();
let first = data.create_mesh(info()).unwrap();
let second = data.create_mesh(info()).unwrap();
assert_eq!(data.streams().positions.len(), 6);
assert_eq!(data.indices().len(), 6);
data.destroy_mesh(first.mesh).unwrap();
assert_eq!(data.streams().positions.len(), 6);
let reused = data.create_mesh(info()).unwrap();
assert_eq!(data.mesh(reused.mesh).unwrap().geometry.vertex_start, 0);
data.destroy_mesh(second.mesh).unwrap();
assert_eq!(data.streams().positions.len(), 3);
assert_eq!(data.streams().normals.len(), 3);
assert_eq!(data.streams().uvs.len(), 3);
assert_eq!(data.indices().len(), 3);
}
#[test]
fn failed_default_instance_preparation_rolls_back_empty_and_existing_geometry() {
let mut data = RenderData::new(RenderDataConfig {
initial_vertices: 0,
initial_indices: 0,
initial_meshes: 0,
initial_instances: 0,
max_instances: Some(0),
..RenderDataConfig::default()
})
.unwrap();
for _ in 0..2 {
let generations = data.meshes.slots.generations.clone();
assert!(matches!(
data.create_mesh(info()),
Err(RenderDataError::CapacityExceeded {
resource: "instances",
..
})
));
assert_eq!(data.vertices.allocator.high_water(), 0);
assert_eq!(data.indices.allocator.high_water(), 0);
assert!(data.streams().positions.is_empty());
assert!(data.indices().is_empty());
assert_eq!(data.meshes.slots.generations, generations);
}
data.instances.slots.max_capacity = Some(1);
let existing = data.create_mesh(info()).unwrap();
data.instances.slots.max_capacity = Some(0);
assert!(data.create_mesh(info()).is_err());
assert_eq!(data.vertices.allocator.high_water(), 3);
assert_eq!(data.mesh_count(), 1);
assert!(data.mesh(existing.mesh).is_some());
}
#[test]
fn aabb_supports_one_point_and_multiple_points() {
let point = [[2.0, -3.0, 4.0]];
let normal = [[0.0, 1.0, 0.0]];
let uv = [[0.0, 0.0]];
let index = [0];
let mut one = info();
one.positions = &point;
one.normals = &normal;
one.uvs = &uv;
one.indices = &index;
let mut data = data();
let mesh = data.create_mesh(one).unwrap();
assert_eq!(
data.mesh(mesh.mesh).unwrap().aabb,
Aabb {
min: point[0],
max: point[0]
}
);
let mesh = data.create_mesh(info()).unwrap();
assert_eq!(
data.mesh(mesh.mesh).unwrap().aabb,
Aabb {
min: [-1.0, -2.0, -3.0],
max: [4.0, 2.0, 3.0],
}
);
}
#[test]
fn malformed_geometry_matrix_is_rejected_without_consumption() {
let mut data = data();
let mut candidate = info();
candidate.positions = &[];
assert_eq!(
data.create_mesh(candidate).unwrap_err(),
RenderDataError::EmptyVertices
);
let short_normals = &NORMALS[..2];
let mut candidate = info();
candidate.normals = short_normals;
assert_eq!(
data.create_mesh(candidate).unwrap_err(),
RenderDataError::MismatchedVertexStreams
);
let short_uvs = &UVS[..2];
let mut candidate = info();
candidate.uvs = short_uvs;
assert_eq!(
data.create_mesh(candidate).unwrap_err(),
RenderDataError::MismatchedVertexStreams
);
let mut candidate = info();
candidate.indices = &[];
assert_eq!(
data.create_mesh(candidate).unwrap_err(),
RenderDataError::EmptyIndices
);
for stream in 0..3 {
for bad in [f32::NAN, f32::INFINITY] {
let mut positions = POSITIONS;
let mut normals = NORMALS;
let mut uvs = UVS;
match stream {
0 => positions[0][0] = bad,
1 => normals[0][0] = bad,
_ => uvs[0][0] = bad,
}
let mut candidate = info();
candidate.positions = &positions;
candidate.normals = &normals;
candidate.uvs = &uvs;
assert_eq!(
data.create_mesh(candidate).unwrap_err(),
RenderDataError::NonFiniteGeometry
);
}
}
let invalid = [3];
let mut candidate = info();
candidate.indices = &invalid;
assert_eq!(
data.create_mesh(candidate).unwrap_err(),
RenderDataError::IndexOutOfBounds
);
let valid_last = [2];
let mut candidate = info();
candidate.indices = &valid_last;
assert!(data.create_mesh(candidate).is_ok());
}
#[test]
fn normal_matrices_and_failed_transform_operations_are_transactional() {
let mut data = data();
let mesh = data.create_mesh(info()).unwrap();
assert_eq!(
data.instance(mesh.default_instance).unwrap().normal,
IDENTITY_NORMAL_MATRIX
);
let translation = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[4.0, 5.0, 6.0, 1.0],
];
data.set_instance_transform(mesh.default_instance, translation)
.unwrap();
assert_eq!(
data.instance(mesh.default_instance).unwrap().normal,
IDENTITY_NORMAL_MATRIX
);
let scale = [
[2.0, 0.0, 0.0, 0.0],
[0.0, 4.0, 0.0, 0.0],
[0.0, 0.0, 0.5, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
data.set_instance_transform(mesh.default_instance, scale)
.unwrap();
assert_eq!(
data.instance(mesh.default_instance).unwrap().normal,
[[0.5, 0.0, 0.0], [0.0, 0.25, 0.0], [0.0, 0.0, 2.0]]
);
let rotation = [
[0.0, 1.0, 0.0, 0.0],
[-1.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
data.set_instance_transform(mesh.default_instance, rotation)
.unwrap();
let old = data.instance(mesh.default_instance).unwrap();
for invalid in [[[0.0; 4]; 4], {
let mut value = IDENTITY_MODEL_TRANSFORM;
value[0][0] = f32::INFINITY;
value
}] {
assert_eq!(
data.set_instance_transform(mesh.default_instance, invalid),
Err(RenderDataError::InvalidTransform)
);
assert_eq!(data.instance(mesh.default_instance).unwrap(), old);
let count = data.instance_count();
assert_eq!(
data.create_instance(mesh.mesh, invalid, RenderFlags::NONE),
Err(RenderDataError::InvalidTransform)
);
assert_eq!(data.instance_count(), count);
let mut candidate = info();
candidate.default_transform = invalid;
assert_eq!(
data.create_mesh(candidate).unwrap_err(),
RenderDataError::InvalidTransform
);
}
}
#[test]
fn destroying_mesh_invalidates_exact_owner_instances_with_reused_generations() {
let mut data = data();
let first = data.create_mesh(info()).unwrap();
let second = data.create_mesh(info()).unwrap();
let first_extra = data
.create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE)
.unwrap();
let second_extra = data
.create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE)
.unwrap();
data.destroy_instance(first_extra).unwrap();
let reused = data
.create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE)
.unwrap();
assert_eq!(first_extra.slot(), reused.slot());
data.destroy_mesh(first.mesh).unwrap();
assert!(data.instance(first.default_instance).is_none());
assert!(data.instance(second.default_instance).is_some());
assert!(data.instance(second_extra).is_some());
assert!(data.instance(reused).is_some());
assert_eq!(data.instances().count(), 3);
}
#[test]
fn revision_changes_only_after_success_and_replacement_rejects_old_handles() {
let mut data = data();
assert_eq!(data.revision(), 0);
assert!(data.destroy_mesh(MeshHandle::from_parts(9, 9)).is_err());
assert_eq!(data.revision(), 0);
let old = data.create_mesh(info()).unwrap();
assert_eq!(data.revision(), 1);
let mut stage = data.replacement_stage().unwrap();
let new = stage.create_mesh(info()).unwrap();
assert_ne!(old.mesh, new.mesh);
data.replace_with(stage).unwrap();
assert_eq!(data.revision(), 2);
assert!(data.mesh(old.mesh).is_none());
assert!(data.mesh(new.mesh).is_some());
}
#[test]
fn replacement_stage_is_rejected_after_source_mutation() {
let mut data = data();
let original = data.create_mesh(info()).unwrap();
let mut stage = data.replacement_stage().unwrap();
stage.create_mesh(info()).unwrap();
data.destroy_mesh(original.mesh).unwrap();
let current = data.create_mesh(info()).unwrap();
assert_eq!(current.mesh.slot(), original.mesh.slot());
assert_eq!(
data.replace_with(stage),
Err(RenderDataError::StaleReplacementStage)
);
assert!(data.mesh(current.mesh).is_some());
}
#[test]
fn replacement_stage_is_rejected_by_a_different_render_data() {
let source = data();
let stage = source.replacement_stage().unwrap();
let mut other = data();
assert_eq!(
other.replace_with(stage),
Err(RenderDataError::StaleReplacementStage)
);
}
#[test]
fn revision_overflow_rejects_mutation_without_committing() {
let mut data = data();
data.revision = u64::MAX;
assert_eq!(
data.create_mesh(info()),
Err(RenderDataError::RevisionOverflow)
);
assert_eq!(data.mesh_count(), 0);
assert_eq!(data.revision(), u64::MAX);
}
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
//! Device-free V1 render graph compiler and compiled graph registry.
mod compiler;
mod registry;
mod runtime;
mod schema;
pub use compiler::{
compile, compile_with, parse_and_compile, AllocationClass, CompiledGraph, CompiledOutput,
CompiledPass, CompiledRead, CompiledResource, CompiledWrite, ExecutorContract,
ExecutorRegistry, ExecutorResolution, Lifetime, NormalizedParameters, SceneForwardExecutors,
TextureAllocationKey, TextureUsage, TransientAllocation,
};
pub use registry::{CompiledGraphId, Registry};
pub use runtime::{
class_offsets, resolve_extent, runtime_texture_key, validate_activatable, ResolvedExtent,
RuntimeTextureKey,
};
pub use schema::*;
pub const MAX_JSON_BYTES: usize = 1024 * 1024;
pub const MAX_RESOURCES: usize = 1024;
pub const MAX_PASSES: usize = 1024;
pub const MAX_USES: usize = 8192;
pub const MAX_OUTPUTS: usize = 64;
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct GraphError {
pub code: &'static str,
pub message: String,
pub details: serde_json::Value,
}
impl GraphError {
pub(crate) fn new(code: &'static str, message: impl Into<String>) -> Self {
let message = message.into();
Self {
code,
details: serde_json::json!({"message": message}),
message,
}
}
pub(crate) fn at(
code: &'static str,
message: impl Into<String>,
path: impl Into<String>,
) -> Self {
let message = message.into();
Self {
code,
details: serde_json::json!({"message": message, "path": path.into()}),
message,
}
}
}
#[cfg(test)]
mod tests;
+114
View File
@@ -0,0 +1,114 @@
use super::{parse_and_compile, CompiledGraph, GraphError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompiledGraphId {
pub slot: u32,
pub generation: u32,
}
impl From<CompiledGraphId> for [u32; 2] {
fn from(x: CompiledGraphId) -> Self {
[x.slot, x.generation]
}
}
#[derive(Debug)]
struct Slot {
generation: u32,
value: Option<CompiledGraph>,
retired: bool,
}
#[derive(Debug)]
pub struct Registry {
slots: Vec<Slot>,
capacity: u32,
}
impl Default for Registry {
fn default() -> Self {
Self::new(16)
}
}
impl Registry {
pub fn new(capacity: u32) -> Self {
Self {
slots: vec![],
capacity,
}
}
pub fn compile(
&mut self,
bytes: &[u8],
) -> Result<(CompiledGraphId, serde_json::Value), GraphError> {
let graph = parse_and_compile(bytes)?;
if let Some((i, s)) = self.slots.iter_mut().enumerate().find(|(_, s)| {
s.value
.as_ref()
.is_some_and(|g| g.graph_id == graph.graph_id)
}) {
if graph.revision <= s.value.as_ref().unwrap().revision {
return Err(GraphError::new(
"GRAPH_REVISION_CONFLICT",
"revision must increase",
));
}
let id = CompiledGraphId {
slot: u32::try_from(i).map_err(|_| {
GraphError::new("GRAPH_LIMIT_EXCEEDED", "registry slot overflow")
})?,
generation: s.generation,
};
let summary = graph.summary(id.into());
s.value = Some(graph);
return Ok((id, summary));
}
let i = if let Some(i) = self
.slots
.iter()
.position(|s| s.value.is_none() && !s.retired)
{
i
} else {
if u32::try_from(self.slots.len()).map_or(true, |len| len >= self.capacity) {
return Err(GraphError::new(
"GRAPH_REGISTRY_FULL",
"compiled graph registry is full",
));
}
self.slots.push(Slot {
generation: 1,
value: None,
retired: false,
});
self.slots.len() - 1
};
let id = CompiledGraphId {
slot: u32::try_from(i)
.map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "registry slot overflow"))?,
generation: self.slots[i].generation,
};
let summary = graph.summary(id.into());
self.slots[i].value = Some(graph);
Ok((id, summary))
}
pub fn get(&self, id: CompiledGraphId) -> Result<&CompiledGraph, GraphError> {
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"))
}
pub fn contains(&self, id: CompiledGraphId) -> bool {
self.get(id).is_ok()
}
pub fn drop_graph(&mut self, id: CompiledGraphId) -> Result<(), GraphError> {
let s = self
.slots
.get_mut(id.slot as usize)
.filter(|s| s.generation == id.generation && s.value.is_some())
.ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))?;
s.value = None;
if s.generation == u32::MAX {
s.retired = true
} else {
s.generation += 1
}
Ok(())
}
}
+215
View File
@@ -0,0 +1,215 @@
use std::collections::BTreeMap;
use super::{
CompiledGraph, Dimension, Extent, ExternalSource, Format, GraphError, Residency,
TextureAllocationKey, TextureUsage,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ResolvedExtent {
pub width: u32,
pub height: u32,
pub depth_or_array_layers: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct RuntimeTextureKey {
pub dimension: Dimension,
pub format: Format,
pub extent: ResolvedExtent,
pub mip_level_count: u32,
pub sample_count: u32,
pub usage: Vec<TextureUsage>,
pub view_formats: Vec<Format>,
}
fn scaled(value: u32, numerator: u32, denominator: u32) -> Result<u32, GraphError> {
if denominator == 0 {
return Err(GraphError::new(
"GRAPH_EXECUTION_UNSUPPORTED",
"zero extent denominator",
));
}
let product = u64::from(value)
.checked_mul(u64::from(numerator))
.ok_or_else(|| GraphError::new("GRAPH_EXECUTION_UNSUPPORTED", "extent overflow"))?;
let result = product
.checked_add(u64::from(denominator) - 1)
.ok_or_else(|| GraphError::new("GRAPH_EXECUTION_UNSUPPORTED", "extent overflow"))?
/ u64::from(denominator);
u32::try_from(result.max(1))
.map_err(|_| GraphError::new("GRAPH_EXECUTION_UNSUPPORTED", "extent overflow"))
}
pub fn resolve_extent(extent: &Extent, surface: [u32; 2]) -> Result<ResolvedExtent, GraphError> {
let (width, height, depth_or_array_layers) = match extent {
Extent::Absolute {
width,
height,
depth_or_array_layers,
} => (*width, *height, *depth_or_array_layers),
Extent::SurfaceRelative {
width,
height,
depth_or_array_layers,
} => (
scaled(surface[0], width.numerator, width.denominator)?,
scaled(surface[1], height.numerator, height.denominator)?,
*depth_or_array_layers,
),
};
if width == 0 || height == 0 || depth_or_array_layers == 0 {
return Err(GraphError::new(
"GRAPH_EXECUTION_UNSUPPORTED",
"texture extent must be nonzero",
));
}
Ok(ResolvedExtent {
width,
height,
depth_or_array_layers,
})
}
pub fn runtime_texture_key(
key: &TextureAllocationKey,
surface: [u32; 2],
) -> Result<RuntimeTextureKey, GraphError> {
Ok(RuntimeTextureKey {
dimension: key.descriptor.dimension,
format: key.descriptor.format,
extent: resolve_extent(&key.descriptor.extent, surface)?,
mip_level_count: key.descriptor.mip_level_count,
sample_count: key.descriptor.sample_count,
usage: key.usage.clone(),
view_formats: key.view_formats.clone(),
})
}
/// Assigns disjoint physical ranges after merging symbolic allocation classes that
/// resolve to the same concrete descriptor key.
pub fn class_offsets(
classes: &[(TextureAllocationKey, u32)],
surface: [u32; 2],
) -> Result<Vec<u32>, GraphError> {
let mut next = BTreeMap::new();
let mut offsets = Vec::with_capacity(classes.len());
for (key, count) in classes {
let concrete = runtime_texture_key(key, surface)?;
let offset = next.entry(concrete).or_insert(0u32);
offsets.push(*offset);
*offset = offset.checked_add(*count).ok_or_else(|| {
GraphError::new("GRAPH_EXECUTION_UNSUPPORTED", "transient slot overflow")
})?;
}
Ok(offsets)
}
pub fn validate_activatable(graph: &CompiledGraph) -> Result<(), GraphError> {
let unsupported = || {
GraphError::new(
"GRAPH_EXECUTION_UNSUPPORTED",
"graph is outside the activatable Phase 6 subset",
)
};
if graph.passes.is_empty() || graph.outputs.is_empty() {
return Err(unsupported());
}
let surface_outputs = graph
.outputs
.iter()
.filter(|o| {
matches!(
graph.resources[o.resource as usize].residency,
Residency::External {
source: ExternalSource::SurfaceColor
}
)
})
.count();
if surface_outputs == 0 {
return Err(unsupported());
}
for pass in &graph.passes {
if pass.executor.key != "scene_forward"
|| pass.executor.version != 1
|| !pass.reads.is_empty()
{
return Err(unsupported());
}
let color = pass
.writes
.iter()
.find(|w| w.binding == "color")
.ok_or_else(&unsupported)?;
let depth = pass
.writes
.iter()
.find(|w| w.binding == "depth")
.ok_or_else(&unsupported)?;
let c = &graph.resources[color.resource as usize];
let d = &graph.resources[depth.resource as usize];
if !matches!(
c.residency,
Residency::External {
source: ExternalSource::SurfaceColor
}
) || !matches!(d.residency, Residency::Transient)
|| d.descriptor.format != Format::Depth32Float
|| d.descriptor.dimension != Dimension::D2
|| d.descriptor.mip_level_count != 1
|| d.descriptor.sample_count != 1
|| d.descriptor.extent != c.descriptor.extent
{
return Err(unsupported());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render_graph::{Dimension, Ratio, TextureDescriptor, TextureUsage};
fn key(n: u32, d: u32) -> TextureAllocationKey {
TextureAllocationKey {
descriptor: TextureDescriptor {
dimension: Dimension::D2,
format: Format::Depth32Float,
extent: Extent::SurfaceRelative {
width: Ratio {
numerator: n,
denominator: d,
},
height: Ratio {
numerator: n,
denominator: d,
},
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
},
usage: vec![TextureUsage::DepthAttachment],
view_formats: vec![],
}
}
#[test]
fn extent_uses_checked_ceil_and_minimum_one() {
assert_eq!(
resolve_extent(&key(1, 2).descriptor.extent, [3, 1]).unwrap(),
ResolvedExtent {
width: 2,
height: 1,
depth_or_array_layers: 1
}
);
}
#[test]
fn equivalent_symbolic_classes_are_disjoint() {
assert_eq!(
class_offsets(&[(key(1, 2), 2), (key(2, 4), 3)], [100, 100]).unwrap(),
vec![0, 2]
);
}
}
+188
View File
@@ -0,0 +1,188 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct GraphV1 {
pub schema_version: u32,
pub graph_id: String,
pub revision: u32,
pub resources: Vec<Resource>,
pub passes: Vec<Pass>,
pub outputs: Vec<Output>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ResourceRef {
pub id: String,
pub version: u32,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct Resource {
pub id: String,
pub version: u32,
pub residency: Residency,
pub texture: TextureDescriptor,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Residency {
External { source: ExternalSource },
Transient,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ExternalSource {
SurfaceColor,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct TextureDescriptor {
pub dimension: Dimension,
pub format: Format,
pub extent: Extent,
pub mip_level_count: u32,
pub sample_count: u32,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum Dimension {
D1,
D2,
D3,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum Format {
Surface,
Rgba8Unorm,
Rgba8UnormSrgb,
Bgra8Unorm,
Bgra8UnormSrgb,
Rgba16Float,
R32Float,
Depth32Float,
}
impl Format {
pub(crate) fn depth(self) -> bool {
self == Self::Depth32Float
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Extent {
Absolute {
width: u32,
height: u32,
#[serde(rename = "depthOrArrayLayers")]
depth_or_array_layers: u32,
},
SurfaceRelative {
width: Ratio,
height: Ratio,
#[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 Ratio {
pub numerator: u32,
pub denominator: u32,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct Pass {
pub id: String,
pub state: PassState,
pub executor: ExecutorRef,
pub parameters: serde_json::Value,
pub reads: Vec<ReadBinding>,
pub writes: Vec<WriteBinding>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PassState {
Enabled,
Muted,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ExecutorRef {
pub key: String,
pub version: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ReadBinding {
pub binding: String,
pub resource: ResourceRef,
pub access: ReadAccess,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ReadAccess {
Sampled,
Storage,
CopySrc,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WriteBinding {
pub binding: String,
pub resource: ResourceRef,
pub access: WriteAccess,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum WriteAccess {
Storage,
CopyDst,
ColorAttachment {
location: u32,
load: ColorLoad,
store: StoreOp,
},
DepthAttachment {
load: DepthLoad,
store: StoreOp,
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
pub enum ColorLoad {
Clear { value: [f64; 4] },
Load,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
pub enum DepthLoad {
Clear { value: f32 },
Load,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StoreOp {
Store,
Discard,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Output {
pub name: String,
pub resource: ResourceRef,
}
pub(crate) fn identifier(s: &str) -> bool {
s.as_bytes()
.first()
.is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_')
&& s.bytes()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'/' | b'-'))
}
+702
View File
@@ -0,0 +1,702 @@
use super::*;
struct TestExecutors;
struct TestExecutor {
observable: bool,
}
static TEST_EXECUTOR: TestExecutor = TestExecutor { observable: false };
static OBSERVABLE_EXECUTOR: TestExecutor = TestExecutor { observable: true };
impl ExecutorRegistry for TestExecutors {
fn resolve(&self, executor: &ExecutorRef) -> ExecutorResolution<'_> {
if executor.version != 1 {
return ExecutorResolution::UnsupportedVersion;
}
match executor.key.as_str() {
"test" => ExecutorResolution::Found(&TEST_EXECUTOR),
"observable" => ExecutorResolution::Found(&OBSERVABLE_EXECUTOR),
_ => ExecutorResolution::UnknownKey,
}
}
}
impl ExecutorContract for TestExecutor {
fn inherently_observable(&self) -> bool {
self.observable
}
fn normalize_parameters(
&self,
parameters: &serde_json::Value,
) -> Result<NormalizedParameters, String> {
if parameters == &serde_json::json!({}) {
Ok(NormalizedParameters::SceneForward)
} else {
Err("test parameters must be empty".into())
}
}
fn validate_bindings(
&self,
_pass: &Pass,
_resources: &std::collections::HashMap<ResourceRef, &Resource>,
) -> Result<(), String> {
Ok(())
}
}
fn compile_json(value: serde_json::Value) -> Result<CompiledGraph, GraphError> {
compile_with(&serde_json::to_vec(&value).unwrap(), &TestExecutors)
}
fn texture(format: &str) -> serde_json::Value {
serde_json::json!({
"dimension": "d2",
"format": format,
"extent": {"kind":"absolute", "width":16, "height":16, "depthOrArrayLayers":1},
"mipLevelCount": 1,
"sampleCount": 1
})
}
fn transient(id: &str, format: &str) -> serde_json::Value {
serde_json::json!({
"id": id,
"version": 0,
"residency": {"kind":"transient"},
"texture": texture(format)
})
}
fn pass(
id: &str,
executor: &str,
reads: serde_json::Value,
writes: serde_json::Value,
) -> serde_json::Value {
serde_json::json!({
"id": id,
"state": "enabled",
"executor": {"key":executor, "version":1},
"parameters": {},
"reads": reads,
"writes": writes
})
}
fn resource_ref(id: &str) -> serde_json::Value {
serde_json::json!({"id":id, "version":0})
}
fn sampled(binding: &str, id: &str) -> serde_json::Value {
serde_json::json!({"binding":binding, "resource":resource_ref(id), "access":"sampled"})
}
fn copy_write(binding: &str, id: &str) -> serde_json::Value {
serde_json::json!({"binding":binding, "resource":resource_ref(id), "access":{"kind":"copy_dst"}})
}
fn color_write(binding: &str, id: &str, location: u32) -> serde_json::Value {
serde_json::json!({
"binding":binding,
"resource":resource_ref(id),
"access":{
"kind":"color_attachment",
"location":location,
"load":{"op":"clear", "value":[0.0, 0.0, 0.0, 1.0]},
"store":"store"
}
})
}
fn color_load(binding: &str, id: &str, location: u32) -> serde_json::Value {
serde_json::json!({
"binding":binding,
"resource":resource_ref(id),
"access":{
"kind":"color_attachment",
"location":location,
"load":{"op":"load"},
"store":"store"
}
})
}
fn graph(
resources: serde_json::Value,
passes: serde_json::Value,
outputs: serde_json::Value,
) -> serde_json::Value {
serde_json::json!({
"schemaVersion":1,
"graphId":"test_graph",
"revision":1,
"resources":resources,
"passes":passes,
"outputs":outputs
})
}
fn empty(id: &str, revision: u32) -> Vec<u8> {
format!(r#"{{"schemaVersion":1,"graphId":"{id}","revision":{revision},"resources":[],"passes":[],"outputs":[]}}"#).into_bytes()
}
fn error(bytes: &[u8]) -> &'static str {
parse_and_compile(bytes).unwrap_err().code
}
#[test]
fn size_precedes_encoding() {
assert_eq!(
error(&vec![0xff; MAX_JSON_BYTES + 1]),
"GRAPH_PAYLOAD_TOO_LARGE"
);
}
#[test]
fn encoding_precedes_schema() {
assert_eq!(error(&[0xff]), "GRAPH_ENCODING_INVALID");
}
#[test]
fn malformed_json() {
assert_eq!(error(b"{"), "GRAPH_JSON_INVALID");
}
#[test]
fn missing_schema_probe() {
assert_eq!(error(b"{}"), "GRAPH_SCHEMA_UNSUPPORTED");
}
#[test]
fn unsupported_schema_probe() {
assert_eq!(error(br#"{"schemaVersion":2}"#), "GRAPH_SCHEMA_UNSUPPORTED");
}
#[test]
fn strict_unknown_field() {
assert_eq!(error(br#"{"schemaVersion":1,"graphId":"g","revision":1,"resources":[],"passes":[],"outputs":[],"extra":0}"#), "GRAPH_JSON_INVALID");
}
#[test]
fn identifier_first_character() {
assert_eq!(error(&empty("1bad", 1)), "GRAPH_INVALID_ID");
}
#[test]
fn underscore_identifier() {
assert_eq!(parse_and_compile(&empty("_ok", 1)).unwrap().graph_id, "_ok");
}
#[test]
fn revision_required() {
assert_eq!(error(&empty("g", 0)), "GRAPH_INVALID_ID");
}
#[test]
fn resource_version_zero_and_wire_names() {
let json = br#"{"schemaVersion":1,"graphId":"g","revision":1,"resources":[{"id":"r","version":0,"residency":{"kind":"transient"},"texture":{"dimension":"d2","format":"rgba8_unorm","extent":{"kind":"absolute","width":1,"height":1,"depthOrArrayLayers":1},"mipLevelCount":1,"sampleCount":1}}],"passes":[],"outputs":[]}"#;
assert_eq!(parse_and_compile(json).unwrap().culled_resource_count, 1);
}
#[test]
fn old_mip_wire_rejected() {
let mut s = String::from_utf8(empty("g", 1)).unwrap();
s=s.replace("\"resources\":[]", "\"resources\":[{\"id\":\"r\",\"version\":0,\"residency\":{\"kind\":\"transient\"},\"texture\":{\"dimension\":\"d2\",\"format\":\"rgba8_unorm\",\"extent\":{\"kind\":\"absolute\",\"width\":1,\"height\":1,\"depthOrArrayLayers\":1},\"mipLevels\":1,\"sampleCount\":1}}]");
assert_eq!(error(s.as_bytes()), "GRAPH_JSON_INVALID");
}
#[test]
fn registry_transaction_on_parse_failure() {
let mut r = Registry::new(1);
assert!(r.compile(b"{").is_err());
assert!(r.compile(&empty("g", 1)).is_ok());
}
#[test]
fn registry_capacity() {
let mut r = Registry::new(1);
r.compile(&empty("a", 1)).unwrap();
assert_eq!(
r.compile(&empty("b", 1)).unwrap_err().code,
"GRAPH_REGISTRY_FULL"
);
}
#[test]
fn registry_revision_replaces_in_place() {
let mut r = Registry::new(1);
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);
}
#[test]
fn registry_revision_conflict() {
let mut r = Registry::new(1);
r.compile(&empty("g", 2)).unwrap();
assert_eq!(
r.compile(&empty("g", 2)).unwrap_err().code,
"GRAPH_REVISION_CONFLICT"
);
}
#[test]
fn registry_drop_and_stale() {
let mut r = Registry::new(1);
let (id, _) = r.compile(&empty("g", 1)).unwrap();
r.drop_graph(id).unwrap();
assert_eq!(r.get(id).unwrap_err().code, "STALE_GRAPH_ID");
assert_eq!(r.drop_graph(id).unwrap_err().code, "STALE_GRAPH_ID");
}
#[test]
fn registry_reuse_increments_generation() {
let mut r = Registry::new(1);
let (a, _) = r.compile(&empty("a", 1)).unwrap();
r.drop_graph(a).unwrap();
let (b, _) = r.compile(&empty("b", 1)).unwrap();
assert_eq!(a.slot, b.slot);
assert_eq!(a.generation + 1, b.generation);
}
#[test]
fn graph_error_details_always_have_message() {
let e = parse_and_compile(b"{}").unwrap_err();
assert!(e.details["message"].is_string());
}
#[test]
fn zero_surface_ratio_is_rejected_without_panicking() {
for field in ["width", "height"] {
let mut resource = transient("r", "rgba8_unorm");
resource["texture"]["extent"] = serde_json::json!({
"kind":"surface_relative",
"width":{"numerator":1,"denominator":1},
"height":{"numerator":1,"denominator":1},
"depthOrArrayLayers":1
});
resource["texture"]["extent"][field] = serde_json::json!({"numerator":0,"denominator":0});
let error = compile_json(graph(
serde_json::json!([resource]),
serde_json::json!([]),
serde_json::json!([]),
))
.unwrap_err();
assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS");
}
}
#[test]
fn depth_texture_accepts_copy_destination_access() {
let compiled = compile_json(graph(
serde_json::json!([transient("depth", "depth32_float")]),
serde_json::json!([pass(
"write_depth",
"observable",
serde_json::json!([]),
serde_json::json!([copy_write("destination", "depth")])
)]),
serde_json::json!([]),
))
.unwrap();
assert_eq!(compiled.passes.len(), 1);
}
#[test]
fn unknown_resources_precede_executor_and_parameter_errors() {
let mut invalid = pass(
"bad",
"missing_executor",
serde_json::json!([sampled("input", "missing_resource")]),
serde_json::json!([]),
);
invalid["parameters"] = serde_json::json!({"also":"invalid"});
let error = compile_json(graph(
serde_json::json!([]),
serde_json::json!([invalid]),
serde_json::json!([]),
))
.unwrap_err();
assert_eq!(error.code, "GRAPH_UNKNOWN_RESOURCE");
}
#[test]
fn executor_contract_normalizes_parameters_and_reports_invalid_parameters() {
let valid = compile_json(graph(
serde_json::json!([transient("r", "rgba8_unorm")]),
serde_json::json!([pass(
"p",
"observable",
serde_json::json!([]),
serde_json::json!([copy_write("out", "r")])
)]),
serde_json::json!([]),
))
.unwrap();
assert_eq!(
valid.passes[0].parameters,
NormalizedParameters::SceneForward
);
let mut invalid = pass(
"p",
"observable",
serde_json::json!([]),
serde_json::json!([copy_write("out", "r")]),
);
invalid["parameters"] = serde_json::json!({"unexpected":true});
let error = compile_json(graph(
serde_json::json!([transient("r", "rgba8_unorm")]),
serde_json::json!([invalid]),
serde_json::json!([]),
))
.unwrap_err();
assert_eq!(error.code, "GRAPH_PARAMETERS_INVALID");
}
#[test]
fn culls_dead_branches_and_orders_live_dependencies_deterministically() {
let compiled = compile_json(graph(
serde_json::json!([
transient("middle", "rgba8_unorm"),
transient("output", "rgba8_unorm"),
transient("dead", "rgba8_unorm")
]),
serde_json::json!([
pass(
"consumer",
"test",
serde_json::json!([sampled("input", "middle")]),
serde_json::json!([copy_write("out", "output")])
),
pass(
"producer",
"test",
serde_json::json!([]),
serde_json::json!([copy_write("out", "middle")])
),
pass(
"dead",
"test",
serde_json::json!([]),
serde_json::json!([copy_write("out", "dead")])
)
]),
serde_json::json!([{"name":"present", "resource":resource_ref("output")}]),
))
.unwrap();
assert_eq!(
compiled
.passes
.iter()
.map(|p| p.id.as_str())
.collect::<Vec<_>>(),
["producer", "consumer"]
);
assert_eq!(compiled.culled_pass_count, 1);
assert_eq!(compiled.culled_resource_count, 1);
}
#[test]
fn output_extends_inclusive_lifetime_to_graph_boundary() {
let compiled = compile_json(graph(
serde_json::json!([transient("out", "rgba8_unorm")]),
serde_json::json!([pass(
"write",
"test",
serde_json::json!([]),
serde_json::json!([copy_write("out", "out")])
)]),
serde_json::json!([{"name":"present", "resource":resource_ref("out")}]),
))
.unwrap();
assert_eq!(compiled.resources[0].lifetime.first_use, 0);
assert_eq!(compiled.resources[0].lifetime.last_use, 1);
}
#[test]
fn transient_slots_reuse_only_for_non_overlapping_compatible_lifetimes() {
let compiled = compile_json(graph(
serde_json::json!([
transient("first", "rgba8_unorm"),
transient("second", "rgba8_unorm"),
transient("incompatible", "rgba16_float")
]),
serde_json::json!([
pass(
"a",
"observable",
serde_json::json!([]),
serde_json::json!([copy_write("out", "first")])
),
pass(
"b",
"observable",
serde_json::json!([]),
serde_json::json!([copy_write("out", "second")])
),
pass(
"c",
"observable",
serde_json::json!([]),
serde_json::json!([copy_write("out", "incompatible")])
)
]),
serde_json::json!([]),
))
.unwrap();
let allocations: Vec<_> = compiled
.resources
.iter()
.map(|resource| resource.allocation.unwrap())
.collect();
assert_eq!(allocations[0], allocations[1]);
assert_ne!(allocations[0].class, allocations[2].class);
assert_eq!(compiled.allocation_classes.len(), 2);
}
#[test]
fn cycle_details_survive_a_non_cycle_dfs_branch() {
let result = compile_json(graph(
serde_json::json!([
transient("ab", "rgba8_unorm"),
transient("bc", "rgba8_unorm"),
transient("ca", "rgba8_unorm"),
transient("branch", "rgba8_unorm")
]),
serde_json::json!([
pass(
"a",
"observable",
serde_json::json!([sampled("ca", "ca")]),
serde_json::json!([copy_write("ab", "ab"), copy_write("branch", "branch")])
),
pass(
"branch",
"observable",
serde_json::json!([sampled("input", "branch")]),
serde_json::json!([])
),
pass(
"b",
"observable",
serde_json::json!([sampled("ab", "ab")]),
serde_json::json!([copy_write("bc", "bc")])
),
pass(
"c",
"observable",
serde_json::json!([sampled("bc", "bc")]),
serde_json::json!([copy_write("ca", "ca")])
)
]),
serde_json::json!([]),
));
let error = result.unwrap_err();
assert_eq!(error.code, "GRAPH_CYCLE");
assert_eq!(error.details["kind"], "cycle");
let edges = error.details["edges"].as_array().unwrap();
assert_eq!(edges.len(), 3);
assert!(edges.iter().all(|edge| edge["from"] != "branch"));
}
#[test]
fn duplicate_external_source_is_an_identity_error_before_descriptor_validation() {
let external = |id: &str, texture: serde_json::Value| {
serde_json::json!({
"id":id,
"version":0,
"residency":{"kind":"external", "source":"surface_color"},
"texture":texture
})
};
let surface = serde_json::json!({
"dimension":"d2",
"format":"surface",
"extent":{
"kind":"surface_relative",
"width":{"numerator":1,"denominator":1},
"height":{"numerator":1,"denominator":1},
"depthOrArrayLayers":1
},
"mipLevelCount":1,
"sampleCount":1
});
let error = compile_json(graph(
serde_json::json!([
external("first", surface),
external("second", texture("rgba8_unorm"))
]),
serde_json::json!([]),
serde_json::json!([]),
))
.unwrap_err();
assert_eq!(error.code, "GRAPH_DUPLICATE_ID");
}
#[test]
fn duplicate_writer_precedes_illegal_access_on_the_second_writer() {
let illegal_depth_color = color_write("bad", "depth", 0);
let error = compile_json(graph(
serde_json::json!([transient("depth", "depth32_float")]),
serde_json::json!([
pass(
"first",
"observable",
serde_json::json!([]),
serde_json::json!([copy_write("out", "depth")])
),
pass(
"second",
"observable",
serde_json::json!([]),
serde_json::json!([illegal_depth_color])
)
]),
serde_json::json!([]),
))
.unwrap_err();
assert_eq!(error.code, "GRAPH_DUPLICATE_WRITER");
}
#[test]
fn rejects_device_invalid_dimensions_and_mismatched_attachments() {
let mut d1 = transient("d1", "rgba8_unorm");
d1["texture"]["dimension"] = serde_json::json!("d1");
assert_eq!(
compile_json(graph(
serde_json::json!([d1]),
serde_json::json!([]),
serde_json::json!([])
))
.unwrap_err()
.code,
"GRAPH_ILLEGAL_ACCESS"
);
let mut depth_d3 = transient("depth", "depth32_float");
depth_d3["texture"]["dimension"] = serde_json::json!("d3");
assert_eq!(
compile_json(graph(
serde_json::json!([depth_d3]),
serde_json::json!([]),
serde_json::json!([])
))
.unwrap_err()
.code,
"GRAPH_ILLEGAL_ACCESS"
);
let first = transient("first", "rgba8_unorm");
let mut second = transient("second", "rgba8_unorm");
second["texture"]["extent"]["width"] = serde_json::json!(32);
let error = compile_json(graph(
serde_json::json!([first, second]),
serde_json::json!([pass(
"attachments",
"observable",
serde_json::json!([]),
serde_json::json!([
color_write("first", "first", 0),
color_write("second", "second", 1)
])
)]),
serde_json::json!([]),
))
.unwrap_err();
assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS");
}
#[test]
fn cycle_tie_breaks_parallel_edges_by_original_resource_index() {
let error = compile_json(graph(
serde_json::json!([
transient("z_declared_first", "rgba8_unorm"),
transient("a_declared_second", "rgba8_unorm"),
transient("back", "rgba8_unorm")
]),
serde_json::json!([
pass(
"a",
"observable",
serde_json::json!([sampled("back", "back")]),
serde_json::json!([
copy_write("first", "z_declared_first"),
copy_write("second", "a_declared_second")
])
),
pass(
"b",
"observable",
serde_json::json!([
sampled("first", "z_declared_first"),
sampled("second", "a_declared_second")
]),
serde_json::json!([copy_write("back", "back")])
)
]),
serde_json::json!([]),
))
.unwrap_err();
assert_eq!(error.code, "GRAPH_CYCLE");
assert_eq!(
error.details["edges"][0]["resource"]["id"],
"z_declared_first"
);
}
#[test]
fn identifier_byte_limit_precedes_reference_and_executor_resolution() {
let overlong = "a".repeat(65);
let invalid = pass(
"p",
"unknown_executor",
serde_json::json!([sampled(&overlong, &overlong)]),
serde_json::json!([]),
);
let error = compile_json(graph(
serde_json::json!([]),
serde_json::json!([invalid]),
serde_json::json!([]),
))
.unwrap_err();
assert_eq!(error.code, "GRAPH_LIMIT_EXCEEDED");
}
#[test]
fn uninitialized_resource_precedes_transient_attachment_load_legality() {
let error = compile_json(graph(
serde_json::json!([
transient("loaded", "rgba8_unorm"),
transient("uninitialized", "rgba8_unorm")
]),
serde_json::json!([pass(
"conflicting_errors",
"observable",
serde_json::json!([sampled("missing_writer", "uninitialized")]),
serde_json::json!([color_load("loaded", "loaded", 0)])
)]),
serde_json::json!([]),
))
.unwrap_err();
assert_eq!(error.code, "GRAPH_UNINITIALIZED_RESOURCE");
}
#[test]
fn malformed_resource_reference_ids_precede_resolution() {
for bindings in ["reads", "writes"] {
let mut invalid = pass(
"p",
"unknown_executor",
serde_json::json!([]),
serde_json::json!([]),
);
invalid[bindings] = if bindings == "reads" {
serde_json::json!([sampled("input", "1bad")])
} else {
serde_json::json!([copy_write("output", "1bad")])
};
let error = compile_json(graph(
serde_json::json!([]),
serde_json::json!([invalid]),
serde_json::json!([]),
))
.unwrap_err();
assert_eq!(error.code, "GRAPH_INVALID_ID");
}
let error = compile_json(graph(
serde_json::json!([]),
serde_json::json!([]),
serde_json::json!([{"name":"present", "resource":resource_ref("1bad")}]),
))
.unwrap_err();
assert_eq!(error.code, "GRAPH_INVALID_ID");
}
+394
View File
@@ -0,0 +1,394 @@
use std::mem::size_of;
use crate::render_data::{MeshHandle, PipelineKey, RenderData, RenderFlags};
use bytemuck::{Pod, Zeroable};
#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq)]
pub struct GpuInstance {
pub model: [[f32; 4]; 4],
pub normal_0: [f32; 4],
pub normal_1: [f32; 4],
pub normal_2: [f32; 4],
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DrawItem {
pub pipeline: PipelineKey,
pub mesh: MeshHandle,
pub indices: std::ops::Range<u32>,
pub base_vertex: i32,
pub instances: std::ops::Range<u32>,
}
#[derive(Default)]
pub struct GpuScenePlan {
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub uvs: Vec<[f32; 2]>,
pub indices: Vec<u32>,
pub instances: Vec<GpuInstance>,
pub draws: Vec<DrawItem>,
}
impl GpuScenePlan {
pub fn build(data: &RenderData) -> Result<Self, &'static str> {
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 streams = data.streams();
for (handle, mesh) in meshes {
let mut occurrences: Vec<_> = data
.instances()
.filter(|(_, instance)| {
instance.mesh == handle && instance.flags.contains(RenderFlags::VISIBLE)
})
.collect();
occurrences.sort_by_key(|(handle, _)| (handle.slot(), handle.generation()));
if occurrences.is_empty() {
continue;
}
let vertex_start = plan.positions.len();
let source_start = mesh.geometry.vertex_start as usize;
let source_end = source_start
.checked_add(mesh.geometry.vertex_count as usize)
.ok_or("vertex range overflow")?;
plan.positions.extend_from_slice(
streams
.positions
.get(source_start..source_end)
.ok_or("invalid vertex range")?,
);
plan.normals.extend_from_slice(
streams
.normals
.get(source_start..source_end)
.ok_or("invalid normal range")?,
);
plan.uvs.extend_from_slice(
streams
.uvs
.get(source_start..source_end)
.ok_or("invalid uv 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;
let source_index_end = source_index
.checked_add(mesh.geometry.index_count as usize)
.ok_or("index range overflow")?;
plan.indices.extend_from_slice(
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 {
plan.instances.push(GpuInstance {
model: instance.model,
normal_0: [
instance.normal[0][0],
instance.normal[0][1],
instance.normal[0][2],
0.0,
],
normal_1: [
instance.normal[1][0],
instance.normal[1][1],
instance.normal[1][2],
0.0,
],
normal_2: [
instance.normal[2][0],
instance.normal[2][1],
instance.normal[2][2],
0.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")?,
});
}
Ok(plan)
}
}
pub fn required_buffer_capacity(
current: u64,
required: u64,
maximum: u64,
) -> Result<u64, &'static str> {
if required > maximum {
return Err("buffer exceeds device max_buffer_size");
}
if required == 0 || current >= required {
return Ok(current);
}
let grown = current
.checked_mul(2)
.ok_or("buffer capacity overflow")?
.max(1)
.max(required);
Ok(grown.min(maximum))
}
pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 4] {
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 {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3],
},
wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![1 => Float32x3],
},
wgpu::VertexBufferLayout {
array_stride: 8,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![2 => Float32x2],
},
wgpu::VertexBufferLayout {
array_stride: size_of::<GpuInstance>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &INSTANCE_ATTRIBUTES,
},
]
}
#[derive(Default)]
pub struct BufferSlot {
pub buffer: Option<wgpu::Buffer>,
capacity: u64,
}
#[derive(Default)]
pub struct GpuSceneCache {
revision: Option<u64>,
pub positions: BufferSlot,
pub normals: BufferSlot,
pub uvs: BufferSlot,
pub indices: BufferSlot,
pub instances: BufferSlot,
pub draws: Vec<DrawItem>,
}
impl GpuSceneCache {
pub fn upload(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
data: &RenderData,
) -> Result<(), String> {
if self.revision == Some(data.revision()) {
return Ok(());
}
let plan = GpuScenePlan::build(data).map_err(str::to_owned)?;
if plan.draws.is_empty() {
self.draws.clear();
self.revision = Some(data.revision());
return Ok(());
}
let maximum = device.limits().max_buffer_size;
fn bytes<T>(values: &[T]) -> Result<u64, String> {
u64::try_from(values.len())
.map_err(|_| "buffer length overflow".to_owned())?
.checked_mul(size_of::<T>() as u64)
.ok_or_else(|| "buffer byte size overflow".to_owned())
}
let required = [
bytes(&plan.positions)?,
bytes(&plan.normals)?,
bytes(&plan.uvs)?,
bytes(&plan.indices)?,
bytes(&plan.instances)?,
];
let old = [
self.positions.capacity,
self.normals.capacity,
self.uvs.capacity,
self.indices.capacity,
self.instances.capacity,
];
let mut capacities = [0; 5];
for i in 0..5 {
capacities[i] =
required_buffer_capacity(old[i], required[i], maximum).map_err(str::to_owned)?;
}
let usages = [
wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::INDEX,
wgpu::BufferUsages::VERTEX,
];
let labels = [
"scene positions",
"scene normals",
"scene uvs",
"scene indices",
"scene instances",
];
let mut replacements: [Option<wgpu::Buffer>; 5] = Default::default();
for i in 0..5 {
if capacities[i] != old[i] {
replacements[i] = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some(labels[i]),
size: capacities[i],
usage: usages[i] | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
}
}
let slots = [
&mut self.positions,
&mut self.normals,
&mut self.uvs,
&mut self.indices,
&mut self.instances,
];
for (i, slot) in slots.into_iter().enumerate() {
if let Some(buffer) = replacements[i].take() {
slot.buffer = Some(buffer);
slot.capacity = capacities[i];
}
}
let contents = [
bytemuck::cast_slice(&plan.positions),
bytemuck::cast_slice(&plan.normals),
bytemuck::cast_slice(&plan.uvs),
bytemuck::cast_slice(&plan.indices),
bytemuck::cast_slice(&plan.instances),
];
let slots = [
&self.positions,
&self.normals,
&self.uvs,
&self.indices,
&self.instances,
];
for (slot, contents) in slots.into_iter().zip(contents) {
if !contents.is_empty() {
queue.write_buffer(
slot.buffer.as_ref().expect("nonempty slot allocated"),
0,
contents,
);
}
}
self.draws = plan.draws;
self.revision = Some(data.revision());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render_data::{MeshCreateInfo, RenderDataConfig, IDENTITY_MODEL_TRANSFORM};
#[test]
fn instance_is_112_bytes_and_padding_is_zero() {
assert_eq!(size_of::<GpuInstance>(), 112);
let value = GpuInstance {
model: [[1.0; 4]; 4],
normal_0: [1., 2., 3., 0.],
normal_1: [4., 5., 6., 0.],
normal_2: [7., 8., 9., 0.],
};
assert_eq!(value.normal_2[3], 0.0);
}
#[test]
fn capacity_grows_and_checks_limit() {
assert_eq!(required_buffer_capacity(8, 9, 32), Ok(16));
assert!(required_buffer_capacity(0, 33, 32).is_err());
}
#[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));
let layouts = vertex_layouts();
assert_eq!(
layouts
.iter()
.map(|layout| layout.array_stride)
.collect::<Vec<_>>(),
[12, 12, 8, 112]
);
assert_eq!(
layouts[3]
.attributes
.iter()
.map(|attribute| attribute.shader_location)
.collect::<Vec<_>>(),
vec![3, 4, 5, 6, 7, 8, 9]
);
}
#[test]
fn plan_orders_pipelines_skips_hidden_and_uses_local_indices() {
let mut data = RenderData::new(RenderDataConfig {
initial_vertices: 0,
initial_indices: 0,
initial_meshes: 0,
initial_instances: 0,
..Default::default()
})
.unwrap();
let p = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
let n = [[0., 0., 1.]; 3];
let u = [[0., 0.]; 3];
let i = [0, 1, 2];
let mut add = |pipeline, visible| {
data.create_mesh(MeshCreateInfo {
positions: &p,
normals: &n,
uvs: &u,
indices: &i,
pipeline: PipelineKey::new(pipeline),
flags: RenderFlags::VISIBLE,
default_instance_flags: if visible {
RenderFlags::VISIBLE
} else {
RenderFlags::NONE
},
default_transform: IDENTITY_MODEL_TRANSFORM,
})
.unwrap()
};
let high = add(9, true);
let _hidden = add(0, false);
let low = add(2, true);
data.create_instance(low.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE)
.unwrap();
let plan = GpuScenePlan::build(&data).unwrap();
assert_eq!(
plan.draws
.iter()
.map(|d| d.pipeline.get())
.collect::<Vec<_>>(),
vec![2, 9]
);
assert_eq!(plan.draws[0].base_vertex, 0);
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);
}
}
File diff suppressed because it is too large Load Diff
+47 -308
View File
@@ -1,9 +1,9 @@
use ultraviolet::Mat4;
use wgpu::util::DeviceExt;
use crate::{
camera::Camera,
renderer::{self, BufferIndex, GpuResources, Index, ModelMatrix, Normal, Position, UV},
render_data::RenderData,
renderer::{self, GpuResources},
};
pub struct UniformResource {
@@ -12,7 +12,6 @@ pub struct UniformResource {
pub bind_group_layout: wgpu::BindGroupLayout,
}
/// Simple uniform data.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable, Debug, Default)]
pub struct FrameMetadata {
@@ -23,36 +22,30 @@ pub struct FrameMetadata {
_padding0: f32,
pub camera_position: [f32; 4],
}
impl FrameMetadata {
pub fn new(dimension: ultraviolet::Vec2) -> Self {
FrameMetadata {
Self {
resolution: dimension.into(),
mouse_move: [std::f32::MIN, std::f32::MIN],
mouse_click: [std::f32::MIN, std::f32::MIN],
_padding0: 0.0,
camera_position: [0.0, 0.0, 0.0, 1.0],
mouse_move: [f32::MIN; 2],
mouse_click: [f32::MIN; 2],
camera_position: [0., 0., 0., 1.],
..Default::default()
}
}
pub fn set_camera_position(&mut self, position: ultraviolet::Vec3) {
self.camera_position = [position.x, position.y, position.z, 1.0];
pub fn set_camera_position(&mut self, p: ultraviolet::Vec3) {
self.camera_position = [p.x, p.y, p.z, 1.];
}
pub fn update_dimension(&mut self, dimension: ultraviolet::Vec2) {
self.resolution = dimension.into();
pub fn update_dimension(&mut self, d: ultraviolet::Vec2) {
self.resolution = d.into();
}
pub fn create_uniform_resource(self, device: &wgpu::Device) -> UniformResource {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("frame metadata uniform buffer"),
contents: bytemuck::cast_slice(&[self][..]),
label: Some("frame metadata"),
contents: bytemuck::bytes_of(&self),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Uniform bind group layout"),
label: Some("frame layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
@@ -64,325 +57,71 @@ impl FrameMetadata {
count: None,
}],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Uniform bind group"),
label: Some("frame group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
UniformResource {
buffer,
bind_group_layout,
bind_group,
}
}
}
pub struct Mesh {
pub pipeline_index: usize,
pub position_buffer_index: BufferIndex<Position>,
pub normal_buffer_index: BufferIndex<Normal>,
pub uv_buffer_index: BufferIndex<UV>,
pub model_buffer_index: BufferIndex<ModelMatrix>,
pub index_buffer_index: BufferIndex<Index>,
pub index_format: wgpu::IndexFormat,
pub index_count: u32,
pub instance_count: u32,
}
type VertexBufferSet = (BufferIndex<Position>, BufferIndex<Normal>, BufferIndex<UV>);
type IndexBufferInfo = (BufferIndex<Index>, u32, wgpu::IndexFormat);
pub fn mesh_vertex_layout() -> [wgpu::VertexBufferLayout<'static>; 4] {
[
wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x3,
}],
},
wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
offset: 0,
shader_location: 1,
format: wgpu::VertexFormat::Float32x3,
}],
},
wgpu::VertexBufferLayout {
array_stride: 8,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
offset: 0,
shader_location: 2,
format: wgpu::VertexFormat::Float32x2,
}],
},
wgpu::VertexBufferLayout {
array_stride: 64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 3,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: 16,
shader_location: 4,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: 32,
shader_location: 5,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: 48,
shader_location: 6,
format: wgpu::VertexFormat::Float32x4,
},
],
},
]
}
pub struct MeshBuilder<I, V, P, M> {
indices: I,
vertices: V,
pipeline: P,
model_matrix: M,
instance_count: u32,
}
impl Default for MeshBuilder<(), (), (), ()> {
fn default() -> Self {
Self {
indices: (),
vertices: (),
pipeline: (),
model_matrix: (),
instance_count: 1,
}
}
}
impl<P, M> MeshBuilder<(), (), P, M> {
pub fn with_vertices(
self,
device: &wgpu::Device,
resources: &mut GpuResources,
positions: &[[f32; 3]],
normals: &[[f32; 3]],
uvs: &[[f32; 2]],
) -> MeshBuilder<(), VertexBufferSet, P, M> {
let position_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Positions"),
contents: bytemuck::cast_slice(positions),
usage: wgpu::BufferUsages::VERTEX,
});
let normal_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Normals"),
contents: bytemuck::cast_slice(normals),
usage: wgpu::BufferUsages::VERTEX,
});
let uv_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh UVs"),
contents: bytemuck::cast_slice(uvs),
usage: wgpu::BufferUsages::VERTEX,
});
let position_buffer_index = resources.add_position_buffer(position_buffer);
let normal_buffer_index = resources.add_normal_buffer(normal_buffer);
let uv_buffer_index = resources.add_uv_buffer(uv_buffer);
MeshBuilder {
vertices: (position_buffer_index, normal_buffer_index, uv_buffer_index),
indices: self.indices,
pipeline: self.pipeline,
model_matrix: self.model_matrix,
instance_count: self.instance_count,
}
}
}
impl<V, P, M> MeshBuilder<(), V, P, M> {
pub fn with_indices(
self,
device: &wgpu::Device,
resources: &mut GpuResources,
indices: &[u32],
) -> MeshBuilder<IndexBufferInfo, V, P, M> {
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Indices"),
contents: bytemuck::cast_slice(indices),
usage: wgpu::BufferUsages::INDEX,
});
let index_buffer_index = resources.add_index_buffer(index_buffer);
MeshBuilder {
indices: (
index_buffer_index,
indices.len() as u32,
wgpu::IndexFormat::Uint32,
),
vertices: self.vertices,
pipeline: self.pipeline,
model_matrix: self.model_matrix,
instance_count: self.instance_count,
}
}
}
impl<I, V, M> MeshBuilder<I, V, (), M> {
pub fn with_pipeline(self, pipeline_index: usize) -> MeshBuilder<I, V, usize, M> {
MeshBuilder {
pipeline: pipeline_index,
indices: self.indices,
vertices: self.vertices,
model_matrix: self.model_matrix,
instance_count: self.instance_count,
}
}
}
impl<I, V, P> MeshBuilder<I, V, P, ()> {
pub fn with_model_matrix(
self,
device: &wgpu::Device,
resources: &mut GpuResources,
matrix_columns: Mat4,
) -> MeshBuilder<I, V, P, BufferIndex<ModelMatrix>> {
let model_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Model Matrix"),
contents: bytemuck::cast_slice(matrix_columns.as_slice()),
usage: wgpu::BufferUsages::VERTEX,
});
let model_buffer_index = resources.add_model_matrix_buffer(model_buffer);
MeshBuilder {
indices: self.indices,
vertices: self.vertices,
pipeline: self.pipeline,
model_matrix: model_buffer_index,
instance_count: self.instance_count,
}
}
}
impl MeshBuilder<IndexBufferInfo, VertexBufferSet, usize, BufferIndex<ModelMatrix>> {
pub fn build(self) -> Mesh {
Mesh {
pipeline_index: self.pipeline,
position_buffer_index: (self.vertices).0,
normal_buffer_index: (self.vertices).1,
uv_buffer_index: (self.vertices).2,
model_buffer_index: self.model_matrix,
index_buffer_index: (self.indices).0,
index_count: (self.indices).1,
index_format: (self.indices).2,
instance_count: self.instance_count,
bind_group_layout,
}
}
}
pub trait Scene: Sized {
fn setup(renderer_context: &renderer::RendererContext, resources: &mut GpuResources) -> Self;
fn setup(
context: &renderer::RendererContext,
resources: &mut GpuResources,
data: &mut RenderData,
) -> Self;
fn bind_groups(&self) -> &[wgpu::BindGroup];
fn meshes(&self) -> &[Mesh];
fn handle_mouse_click(&mut self, x: f32, y: f32);
fn handle_zoom(&mut self, delta_y: f32);
fn handle_orbit(&mut self, delta_x: f32, delta_y: f32);
fn clear(&mut self);
fn add_mesh(&mut self, mesh: Mesh);
fn handle_orbit(&mut self, dx: f32, dy: 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> {
None
}
fn camera_mut(&mut self) -> Option<&mut Camera> {
None
}
fn uniform_buffers(&self) -> Option<&[wgpu::Buffer]> {
fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> {
None
}
fn resize(&mut self, width: f64, height: f64, _scale_factor: f64, queue: &wgpu::Queue) {
let fm_copy = if let Some(fm) = self.frame_metadata_mut() {
let dimension = ultraviolet::Vec2::new(width as f32, height as f32);
fm.update_dimension(dimension);
*fm
} else {
return;
};
let view_proj_copy = if let Some(cam) = self.camera_mut() {
cam.update_aspect_ratio(width as f32 / height as f32);
cam.view_proj
} else {
return;
};
if let Some(buffers) = self.uniform_buffers() {
if buffers.len() >= 2 {
queue.write_buffer(&buffers[0], 0, bytemuck::cast_slice(&[fm_copy]));
queue.write_buffer(&buffers[1], 0, bytemuck::cast_slice(&[view_proj_copy]));
}
fn resize(&mut self, width: f64, height: f64, _: f64, queue: &wgpu::Queue) {
if let Some(f) = self.frame_metadata_mut() {
f.update_dimension(ultraviolet::Vec2::new(width as f32, height as f32));
}
if let Some(c) = self.camera_mut() {
c.update_aspect_ratio(width as f32 / height as f32)
}
self.write_uniforms(queue);
}
fn update(
&mut self,
renderer_context: &renderer::RendererContext,
_resources: &mut GpuResources,
) {
let camera_position = if let Some(cam) = self.camera_mut() {
cam.position()
} else {
return;
fn update(&mut self, context: &renderer::RendererContext) {
let position = match self.camera_mut() {
Some(c) => c.position(),
None => return,
};
let fm_copy = if let Some(fm) = self.frame_metadata_mut() {
let time = (js_sys::Date::now() as f32) * 0.001;
fm.time = time;
fm.set_camera_position(camera_position);
*fm
} else {
return;
};
let view_proj_copy = if let Some(cam) = self.camera_mut() {
cam.view_proj
} else {
return;
};
if let Some(buffers) = self.uniform_buffers() {
if buffers.len() >= 2 {
renderer_context.queue.write_buffer(
&buffers[0],
0,
bytemuck::cast_slice(&[fm_copy]),
);
renderer_context.queue.write_buffer(
&buffers[1],
0,
bytemuck::cast_slice(&[view_proj_copy]),
);
}
if let Some(f) = self.frame_metadata_mut() {
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();
let view = self.camera_mut().map(|c| c.view_proj);
if let (Some(f), Some(v), Some([frame_buffer, camera_buffer])) =
(frame, view, self.uniform_buffers())
{
queue.write_buffer(frame_buffer, 0, bytemuck::bytes_of(&f));
queue.write_buffer(camera_buffer, 0, bytemuck::bytes_of(&v));
}
}
}
+420
View File
@@ -0,0 +1,420 @@
//! Triple-buffered, immutable packed scene snapshot shared with JavaScript.
use std::sync::atomic::{AtomicU32, Ordering};
use crate::render_data::{affine_world_aabb, RenderData, RenderFlags};
pub const MAGIC: u32 = u32::from_le_bytes(*b"YSNP");
pub const BLOB_MAGIC: u32 = u32::from_le_bytes(*b"RDS1");
pub const CONTROL_VERSION: u32 = 1;
pub const SCHEMA: u32 = 1;
pub const SLOT_COUNT: usize = 3;
pub const INIT: u32 = 0;
pub const OPEN: u32 = 1;
pub const FAILED: u32 = 2;
pub const CLOSED: u32 = 3;
pub const FREE: u32 = 0;
pub const WRITING: u32 = 1;
pub const READY: u32 = 2;
pub const READING: u32 = 3;
pub const ERROR_NO_SLOT: u32 = 1;
pub const ERROR_OVERFLOW: u32 = 2;
pub const ERROR_INVARIANT: u32 = 3;
pub const ERROR_PUBLICATION: u32 = 4;
const CONTROL_BYTES: u32 = 256;
const SLOT_BYTES: u32 = 64;
const SNAPSHOT_HEADER_BYTES: usize = 64;
const DATA_OFFSET: usize = 512;
const DESCRIPTOR_BYTES: usize = 32;
const STREAMS: usize = 14;
const SCHEMA_FLAGS: u32 = 3; // dense arrays | affine transforms
#[repr(C, align(64))]
pub struct SnapshotDescriptor(pub [AtomicU32; 16]);
#[repr(C, align(64))]
pub struct SnapshotControl {
pub header: [AtomicU32; 16],
pub slots: [SnapshotDescriptor; SLOT_COUNT],
}
#[repr(C, align(16))]
#[derive(Clone, Copy)]
pub struct SnapshotBlock(pub [u8; 16]);
pub struct SharedSnapshot {
pub control: Box<SnapshotControl>,
blocks: [Vec<SnapshotBlock>; SLOT_COUNT],
last_revision: Option<u64>,
next_epoch: u32,
layout_epoch: u32,
}
impl SharedSnapshot {
pub fn new() -> Self {
let control = Box::new(SnapshotControl {
header: std::array::from_fn(|_| AtomicU32::new(0)),
slots: std::array::from_fn(|_| {
SnapshotDescriptor(std::array::from_fn(|_| AtomicU32::new(0)))
}),
});
let this = Self {
control,
blocks: Default::default(),
last_revision: None,
next_epoch: 1,
layout_epoch: 0,
};
for (i, value) in [
MAGIC,
CONTROL_VERSION,
CONTROL_BYTES,
SLOT_COUNT as u32,
SLOT_BYTES,
SCHEMA,
INIT,
]
.into_iter()
.enumerate()
{
this.control.header[i].store(value, Ordering::Relaxed);
}
// No publication exists yet; zero is a valid slot number.
this.control.header[9].store(u32::MAX, Ordering::Relaxed);
this
}
pub fn control_ptr(&self) -> u32 {
self.control.as_ref() as *const _ as usize as u32
}
/// Permanently fails snapshot publication without affecting rendering or mutations.
pub fn fail(&self, error: u32) {
self.control.header[14].store(error, Ordering::Relaxed);
self.control.header[6].store(FAILED, Ordering::Release);
}
/// Packs and publishes if `data` changed. Returns the newly published data epoch.
pub fn publish(&mut self, data: &RenderData) -> 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()) {
return Ok(None);
}
let slot = match self.claim_slot() {
Some(slot) => slot,
None => {
self.fail(ERROR_NO_SLOT);
return Err(ERROR_NO_SLOT);
}
};
let result = self.publish_claimed(slot, data);
if let Err(error) = result {
self.control.slots[slot].0[0].store(FREE, Ordering::Release);
self.fail(error);
}
result
}
fn publish_claimed(&mut self, slot: usize, data: &RenderData) -> 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)?;
let blocks = bytes.len().checked_add(15).ok_or(ERROR_OVERFLOW)? / 16;
if self.blocks[slot].capacity() < blocks {
self.layout_epoch = self.layout_epoch.checked_add(1).ok_or(ERROR_OVERFLOW)?;
}
self.blocks[slot].resize(blocks, SnapshotBlock([0; 16]));
let allocation_bytes = blocks.checked_mul(16).ok_or(ERROR_OVERFLOW)?;
let target = unsafe {
std::slice::from_raw_parts_mut(
self.blocks[slot].as_mut_ptr().cast::<u8>(),
allocation_bytes,
)
};
target[..bytes.len()].copy_from_slice(&bytes);
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 d = &self.control.slots[slot].0;
let values = [
epoch,
self.layout_epoch,
ptr32,
length,
revision as u32,
(revision >> 32) as u32,
data.mesh_count(),
data.instance_count(),
SCHEMA,
SNAPSHOT_HEADER_BYTES as u32,
0,
0,
0,
0,
0,
];
for (i, value) in values.into_iter().enumerate() {
d[i + 1].store(value, Ordering::Relaxed);
}
let end = ptr.checked_add(allocation_bytes).ok_or(ERROR_OVERFLOW)?;
let pages = wasm_pages(end)?;
let seq = self.open_sequence()?;
d[0].store(READY, Ordering::Release);
for (i, value) in [
epoch,
slot as u32,
revision as u32,
(revision >> 32) as u32,
pages,
self.layout_epoch,
]
.into_iter()
.enumerate()
{
self.control.header[8 + i].store(value, Ordering::Relaxed);
}
self.control.header[14].store(0, Ordering::Relaxed);
self.control.header[15].store(0, Ordering::Relaxed);
self.control.header[6].store(OPEN, Ordering::Relaxed);
self.control.header[7].fetch_add(1, Ordering::Release);
debug_assert_eq!(seq & 1, 0);
self.next_epoch = next_epoch;
self.last_revision = Some(revision);
Ok(Some(epoch))
}
fn open_sequence(&self) -> Result<u32, u32> {
loop {
let seq = self.control.header[7].load(Ordering::Acquire);
if seq & 1 != 0 {
std::hint::spin_loop();
continue;
}
match self.control.header[7].compare_exchange_weak(
seq,
seq.checked_add(1).ok_or(ERROR_OVERFLOW)?,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => return Ok(seq),
Err(_) => continue,
}
}
}
fn claim_slot(&self) -> Option<usize> {
loop {
for slot in 0..SLOT_COUNT {
if self.control.slots[slot].0[0]
.compare_exchange(FREE, WRITING, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
return Some(slot);
}
}
let oldest = (0..SLOT_COUNT)
.filter(|&slot| self.control.slots[slot].0[0].load(Ordering::Acquire) == READY)
.min_by_key(|&slot| self.control.slots[slot].0[1].load(Ordering::Relaxed));
let slot = oldest?;
if self.control.slots[slot].0[0]
.compare_exchange(READY, WRITING, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
return Some(slot);
}
// A reader or another claimant won. Recompute rather than touching READING.
}
}
}
fn align16(value: usize) -> Result<usize, u32> {
Ok(value.checked_add(15).ok_or(ERROR_OVERFLOW)? & !15)
}
#[cfg(target_arch = "wasm32")]
fn wasm_pages(_minimum_end: usize) -> Result<u32, u32> {
u32::try_from(core::arch::wasm32::memory_size(0)).map_err(|_| ERROR_OVERFLOW)
}
#[cfg(not(target_arch = "wasm32"))]
fn wasm_pages(minimum_end: usize) -> Result<u32, u32> {
u32::try_from(minimum_end.checked_add(65535).ok_or(ERROR_OVERFLOW)? / 65536)
.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();
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];
let counts = [meshes.len(); 5]
.into_iter()
.chain([instances.len(); 9])
.collect::<Vec<_>>();
let mut offsets = [0usize; STREAMS];
let mut cursor = DATA_OFFSET;
for i in 0..STREAMS {
offsets[i] = cursor;
let bytes = strides[i].checked_mul(counts[i]).ok_or(ERROR_OVERFLOW)?;
cursor = align16(cursor.checked_add(bytes).ok_or(ERROR_OVERFLOW)?)?;
}
let total = u32::try_from(cursor).map_err(|_| ERROR_OVERFLOW)?;
let mesh_count = u32::try_from(meshes.len()).map_err(|_| ERROR_OVERFLOW)?;
let instance_count = u32::try_from(instances.len()).map_err(|_| ERROR_OVERFLOW)?;
let mut out = vec![0u8; cursor];
let put32 = |out: &mut [u8], at: usize, value: u32| {
out[at..at + 4].copy_from_slice(&value.to_le_bytes())
};
let revision = data.revision();
for (i, value) in [
BLOB_MAGIC,
SCHEMA,
SNAPSHOT_HEADER_BYTES as u32,
total,
epoch,
revision as u32,
(revision >> 32) as u32,
STREAMS as u32,
SNAPSHOT_HEADER_BYTES as u32,
DESCRIPTOR_BYTES as u32,
mesh_count,
instance_count,
0x0102_0304,
SCHEMA_FLAGS,
0,
0,
]
.into_iter()
.enumerate()
{
put32(&mut out, i * 4, value);
}
for i in 0..STREAMS {
let at = SNAPSHOT_HEADER_BYTES + i * DESCRIPTOR_BYTES;
for (j, value) in [
i as u32 + 1,
scalar[i],
offsets[i] as u32,
counts[i] as u32,
components[i],
strides[i] as u32,
4,
0,
]
.into_iter()
.enumerate()
{
put32(&mut out, at + j * 4, value);
}
}
for (dense, (handle, mesh)) in meshes.iter().enumerate() {
for (i, value) in [handle.slot(), handle.generation(), mesh.flags.bits()]
.into_iter()
.enumerate()
{
put32(&mut out, offsets[i] + dense * 4, value);
}
for i in 0..3 {
put32(
&mut out,
offsets[3] + dense * 12 + i * 4,
mesh.aabb.min[i].to_bits(),
);
put32(
&mut out,
offsets[4] + dense * 12 + i * 4,
mesh.aabb.max[i].to_bits(),
);
}
}
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 (i, value) in [
handle.slot(),
handle.generation(),
instance.mesh.slot(),
instance.mesh.generation(),
instance.flags.bits(),
]
.into_iter()
.enumerate()
{
put32(&mut out, offsets[5 + i] + dense * 4, value);
}
for i in 0..16 {
put32(
&mut out,
offsets[10] + dense * 64 + i * 4,
instance.model[i / 4][i % 4].to_bits(),
);
}
for i in 0..3 {
put32(
&mut out,
offsets[11] + dense * 12 + i * 4,
world.min[i].to_bits(),
);
put32(
&mut out,
offsets[12] + dense * 12 + i * 4,
world.max[i].to_bits(),
);
}
put32(
&mut out,
offsets[13] + dense * 4,
(mesh.flags.contains(RenderFlags::VISIBLE)
&& instance.flags.contains(RenderFlags::VISIBLE)) as u32,
);
}
Ok(out)
}
impl Drop for SharedSnapshot {
fn drop(&mut self) {
if self.control.header[6].load(Ordering::Acquire) != FAILED {
self.control.header[6].store(CLOSED, Ordering::Release);
}
}
}
const _: [(); 256] = [(); std::mem::size_of::<SnapshotControl>()];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_control_layout_and_initial_values() {
assert_eq!(std::mem::size_of::<SnapshotControl>(), 256);
assert_eq!(std::mem::size_of::<SnapshotDescriptor>(), 64);
let snapshot = SharedSnapshot::new();
let values: Vec<_> = snapshot
.control
.header
.iter()
.map(|v| v.load(Ordering::Relaxed))
.collect();
assert_eq!(&values[..7], &[MAGIC, 1, 256, 3, 64, 1, INIT]);
assert_eq!(values[9], u32::MAX);
}
#[test]
fn claiming_never_overwrites_reading_and_prefers_free() {
let snapshot = SharedSnapshot::new();
snapshot.control.slots[0].0[0].store(READING, Ordering::Relaxed);
assert_eq!(snapshot.claim_slot(), Some(1));
assert_eq!(
snapshot.control.slots[0].0[0].load(Ordering::Relaxed),
READING
);
assert_eq!(
snapshot.control.slots[1].0[0].load(Ordering::Relaxed),
WRITING
);
}
}
+25
View File
@@ -0,0 +1,25 @@
export class DerivedBvh {
constructor() { this.count = 0; this.identity = new Uint32Array(); this.meshIdentity = new Uint32Array(); this.pickable = new Uint8Array(); this.bounds = new Float32Array(); this.nodeBounds = new Float32Array(); this.left = this.right = new Int32Array(); this.leafStart = this.leafCount = new Uint32Array(); this.leaves = new Uint32Array(); this.root = -1; this.rebuilds = 0; this.refits = 0; }
update(snapshot) {
const s = snapshot.streams, n = snapshot.instanceCount;
let changed = n !== this.count;
if (!changed) for (let i = 0; i < n; i++) if (this.identity[i * 2] !== s.instanceSlot[i] || this.identity[i * 2 + 1] !== s.instanceGeneration[i] || this.meshIdentity[i * 2] !== s.instanceMeshSlot[i] || this.meshIdentity[i * 2 + 1] !== s.instanceMeshGeneration[i]) { changed = true; break; }
this.count = n; this.identity = new Uint32Array(n * 2); this.meshIdentity = new Uint32Array(n * 2); this.pickable = new Uint8Array(n); this.bounds = new Float32Array(n * 6);
for (let i = 0; i < n; i++) { this.identity.set([s.instanceSlot[i], s.instanceGeneration[i]], i * 2); this.meshIdentity.set([s.instanceMeshSlot[i], s.instanceMeshGeneration[i]], i * 2); this.pickable[i] = !!s.instancePickable[i]; this.bounds.set(s.instanceWorldMin.subarray(i * 3, i * 3 + 3), i * 6); this.bounds.set(s.instanceWorldMax.subarray(i * 3, i * 3 + 3), i * 6 + 3); }
changed ? this.rebuild() : this.refit();
}
rebuild() {
this.rebuilds++; const nodes = [], leaves = [];
const build = indices => { const at = nodes.length, node = {left: -1, right: -1, start: 0, count: 0, bounds: [Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity]}; nodes.push(node); for (const i of indices) for (let a = 0; a < 3; a++) { node.bounds[a] = Math.min(node.bounds[a], this.bounds[i * 6 + a]); node.bounds[a + 3] = Math.max(node.bounds[a + 3], this.bounds[i * 6 + a + 3]); } if (indices.length <= 2) { node.start = leaves.length; node.count = indices.length; leaves.push(...indices); return at; } let axis = 0, extent = node.bounds[3] - node.bounds[0]; for (let a = 1; a < 3; a++) if (node.bounds[a + 3] - node.bounds[a] > extent) { axis = a; extent = node.bounds[a + 3] - node.bounds[a]; } indices.sort((a, b) => (this.bounds[a * 6 + axis] + this.bounds[a * 6 + axis + 3]) - (this.bounds[b * 6 + axis] + this.bounds[b * 6 + axis + 3]) || a - b); const mid = indices.length >> 1; node.left = build(indices.slice(0, mid)); node.right = build(indices.slice(mid)); return at; };
this.root = this.count ? build(Array.from({length: this.count}, (_, i) => i)) : -1; const n = nodes.length;
this.nodeBounds = new Float32Array(n * 6); this.left = new Int32Array(n); this.right = new Int32Array(n); this.leafStart = new Uint32Array(n); this.leafCount = new Uint32Array(n); this.leaves = Uint32Array.from(leaves);
nodes.forEach((x, i) => { this.nodeBounds.set(x.bounds, i * 6); this.left[i] = x.left; this.right[i] = x.right; this.leafStart[i] = x.start; this.leafCount[i] = x.count; });
}
refit() { this.refits++; for (let n = this.left.length - 1; n >= 0; n--) { const at = n * 6; for (let a = 0; a < 3; a++) { let lo = Infinity, hi = -Infinity; if (this.leafCount[n]) for (let j = 0; j < this.leafCount[n]; j++) { const i = this.leaves[this.leafStart[n] + j]; lo = Math.min(lo, this.bounds[i * 6 + a]); hi = Math.max(hi, this.bounds[i * 6 + a + 3]); } else { lo = Math.min(this.nodeBounds[this.left[n] * 6 + a], this.nodeBounds[this.right[n] * 6 + a]); hi = Math.max(this.nodeBounds[this.left[n] * 6 + a + 3], this.nodeBounds[this.right[n] * 6 + a + 3]); } this.nodeBounds[at + a] = lo; this.nodeBounds[at + a + 3] = hi; } } }
pick(origin, direction, maxDistance = Infinity, maxHits = 1) {
if (this.root < 0) return []; let magnitude = Math.hypot(...direction); const dir = direction.map(v => v / magnitude);
const intersect = (array, at) => { let lo = 0, hi = maxDistance; for (let a = 0; a < 3; a++) { const min = array[at + a], max = array[at + a + 3]; if (dir[a] === 0) { if (origin[a] < min || origin[a] > max) return Infinity; } else { let x = (min - origin[a]) / dir[a], y = (max - origin[a]) / dir[a]; if (x > y) [x, y] = [y, x]; lo = Math.max(lo, x); hi = Math.min(hi, y); if (lo > hi) return Infinity; } } return lo; };
const hits = [], stack = [this.root]; while (stack.length) { const n = stack.pop(); if (intersect(this.nodeBounds, n * 6) === Infinity) continue; if (this.leafCount[n]) for (let j = 0; j < this.leafCount[n]; j++) { const i = this.leaves[this.leafStart[n] + j]; if (!this.pickable[i]) continue; const distance = intersect(this.bounds, i * 6); if (distance !== Infinity) hits.push({slot: this.identity[i * 2], generation: this.identity[i * 2 + 1], distance}); } else stack.push(this.right[n], this.left[n]); }
hits.sort((a, b) => a.distance - b.distance || a.slot - b.slot || a.generation - b.generation); return hits.slice(0, maxHits);
}
}
+27
View File
@@ -0,0 +1,27 @@
import {SnapshotReader} from "./render-data-snapshot.js";
import {DerivedBvh} from "./bvh-core.js";
let reader, bvh = new DerivedBvh(), epoch = 0, updating = false, requestedEpoch = 0;
function ensureEpoch(expected) {
if (!reader) return false;
const latest = reader.latest();
if (latest.epoch !== expected) return false;
if (epoch === expected) return true;
const result = reader.transaction(snapshot => { bvh.update(snapshot); epoch = snapshot.epoch; }, expected);
return result !== null && epoch === expected;
}
function coalescedUpdate(hint = 0) {
requestedEpoch = Math.max(requestedEpoch, hint >>> 0);
if (updating) return;
updating = true;
queueMicrotask(() => { try { const latest = reader?.latest(); if (latest?.epoch && latest.epoch !== epoch) ensureEpoch(latest.epoch); if (epoch) postMessage({type: "updated", epoch}); } catch (error) { postMessage({type: "fatal", code: "PICK_PROTOCOL_MISMATCH", message: String(error)}); } finally { updating = false; if (requestedEpoch > epoch) coalescedUpdate(); } });
}
addEventListener("message", event => {
const m = event.data;
try {
if (m.type === "init") { reader = new SnapshotReader(m.memory, m.controlPtr); if (m.controlVersion !== 1 || m.schemaVersion !== 1) throw Object.assign(new Error("version"), {code: "PICK_PROTOCOL_MISMATCH"}); postMessage({type: "ready"}); }
else if (m.type === "update") coalescedUpdate(m.epoch);
else if (m.type === "pick") { if (!ensureEpoch(m.epoch)) { postMessage({type: "pick", request: m.request, stale: true, epoch}); return; } const hits = bvh.pick(m.origin, m.direction, m.maxDistance, m.maxHits); const latest = reader.latest().epoch; postMessage({type: "pick", request: m.request, stale: latest !== m.epoch || epoch !== m.epoch, epoch, hits}); }
else if (m.type === "dispose") close();
} catch (error) { postMessage({type: "fatal", code: error.name === "SnapshotProtocolError" || error.code === "PICK_PROTOCOL_MISMATCH" ? "PICK_PROTOCOL_MISMATCH" : "PICK_WORKER_ERROR", message: String(error)}); }
});
+60
View File
@@ -0,0 +1,60 @@
const JSON_CHUNK = 0x4e4f534a;
const BIN_CHUNK = 0x004e4942;
const encoder = new TextEncoder();
const align4 = value => (value + 3) & ~3;
const finiteMinMax = (values, width) => {
const min = Array(width).fill(Infinity), max = Array(width).fill(-Infinity);
for (let i=0;i<values.length;i++) { const lane=i%width; min[lane]=Math.min(min[lane],values[i]); max[lane]=Math.max(max[lane],values[i]); }
return {min,max};
};
/** Encode indexed geometry as a deterministic, self-contained GLB 2.0 scene. */
export function encodeGeometryGlb({positions,normals,texcoords,indices}) {
if([...positions,...normals,...texcoords].some(value=>!Number.isFinite(value))||indices.some(value=>!Number.isInteger(value)||value<0))throw new TypeError("Invalid demo geometry");
const streams=[new Float32Array(positions),new Float32Array(normals),new Float32Array(texcoords),new Uint32Array(indices)];
if(!streams[0].length||streams[0].length%3||streams[1].length!==streams[0].length||streams[2].length/2!==streams[0].length/3||streams[3].length%3) throw new TypeError("Invalid demo geometry");
const offsets=[], chunks=[], views=[]; let byteLength=0;
for(const stream of streams){byteLength=align4(byteLength);offsets.push(byteLength);const bytes=new Uint8Array(stream.buffer);chunks.push({offset:byteLength,bytes});views.push({buffer:0,byteOffset:byteLength,byteLength:bytes.length});byteLength+=bytes.length;}
byteLength=align4(byteLength);
const vertexCount=streams[0].length/3, bounds=finiteMinMax(streams[0],3);
if(indices.some(value=>value>=vertexCount))throw new TypeError("Invalid demo geometry");
const nodes=[]; for(let z=-1;z<=1;z++)for(let x=-1;x<=1;x++)nodes.push({mesh:0,translation:[x*3,0,z*3]});
const json={asset:{version:"2.0",generator:"yawn-phase8"},scene:0,scenes:[{nodes:nodes.map((_,i)=>i)}],nodes,meshes:[{primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3}]}],buffers:[{byteLength}],bufferViews:views,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:streams[3].length,type:"SCALAR"},
]};
let jsonBytes=encoder.encode(JSON.stringify(json)); const jsonLength=align4(jsonBytes.length), total=12+8+jsonLength+8+byteLength;
const 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 createCubeGeometry(){
const positions=[],normals=[],texcoords=[],indices=[];const faces=[[[1,0,0],[1,-1,-1],[1,-1,1],[1,1,1],[1,1,-1]],[[-1,0,0],[-1,-1,1],[-1,-1,-1],[-1,1,-1],[-1,1,1]],[[0,1,0],[-1,1,1],[1,1,1],[1,1,-1],[-1,1,-1]],[[0,-1,0],[-1,-1,-1],[1,-1,-1],[1,-1,1],[-1,-1,1]],[[0,0,1],[-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1]],[[0,0,-1],[1,-1,-1],[-1,-1,-1],[-1,1,-1],[1,1,-1]]];
for(const [normal,...corners] of faces){
const base=positions.length/3;corners.forEach((p,i)=>{positions.push(...p);normals.push(...normal);texcoords.push(...[[0,0],[1,0],[1,1],[0,1]][i]);});
const a=corners[0],b=corners[1],c=corners[2],ab=b.map((value,i)=>value-a[i]),ac=c.map((value,i)=>value-a[i]);
const cross=[ab[1]*ac[2]-ab[2]*ac[1],ab[2]*ac[0]-ab[0]*ac[2],ab[0]*ac[1]-ab[1]*ac[0]];
const outward=cross.reduce((sum,value,i)=>sum+value*normal[i],0)>0;
indices.push(...(outward?[base,base+1,base+2,base,base+2,base+3]:[base,base+2,base+1,base,base+3,base+2]));
}
return {positions,normals,texcoords,indices};
}
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};
}
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"}});
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());
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;
}
+5 -42
View File
@@ -1,42 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
* {
margin: 0;
padding: 0;
}
html, body {
height: 100%;
}
canvas {
width: 100%;
height: 100vh;
background-color: yellowgreen;
}
</style>
</head>
<body>
<header>
<nav>
<ul>
</ul>
</nav>
</header>
<main>
<section>
<!-- TODO: Intro -->
</section>
<section>
<canvas id="canvas0"></canvas>
<script type="module" src="./index.js"></script>
</section>
</main>
<footer>
</footer>
</body>
</html>
<!doctype html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Yawn Render Graph Demo</title>
<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>
+57 -10
View File
@@ -1,13 +1,60 @@
import wbg_init, { main } from "../level-editor/pkg/level_editor.js";
import wbg_init, { main } from "./level-editor/pkg/level_editor.js";
import { RendererClient, RendererError } from "./renderer-client.js";
import { loadDemoLoadout } from "./demo-loadouts.js";
import { adaptFxNodeSnapshot } from "./render-graph/adapter.js";
import { AuthoringController } from "./render-graph/authoring-controller.js";
import { createRenderGraphEditor } from "./render-graph/fxnode-editor.js";
import { renderGraphPresets } from "./render-graph/presets.js";
const start = async () => {
await wbg_init();
main();
};
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};
// Wait for DOM to be ready before starting
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
function publish(telemetry){
state.telemetry=telemetry;
document.documentElement.dataset.phase8State=JSON.stringify({activeLoadout:state.loadout,activeGraph:state.graph,renderDataRevision:telemetry.revision,renderMode:telemetry.renderMode,activeCompiledId:telemetry.activeCompiledId,activeCompiledGraph:telemetry.activeCompiledGraph,activeCompiledRevision:telemetry.activeCompiledRevision,graphPasses:telemetry.graphPasses,draws:telemetry.draws,instances:telemetry.instances,indices:telemetry.indices,framingRadius:telemetry.framingRadius,gpuError:telemetry.gpuError});
}
function waitTelemetry(predicate,timeout=30000){
const current=renderer?.telemetry;if(current&&predicate(current))return Promise.resolve(current);
return new Promise((resolve,reject)=>{let timer;const done=()=>{clearTimeout(timer);removeEventListener("renderer-frame",frame);};const frame=e=>{if(predicate(e.detail)){done();resolve(e.detail);}};timer=setTimeout(()=>{done();reject(new Error("Telemetry confirmation timed out"));},timeout);onAbort=()=>{done();reject(new RendererError("DISPOSED"));};addEventListener("renderer-frame",frame);timer.unref?.();});
}
let onAbort=()=>{};
async function transaction(label,operation,rollback){
if(busy||cleaned)return false;busy=true;document.querySelectorAll("select, #apply-graph").forEach(x=>x.disabled=true);status(label);
try{const telemetry=await operation();if(cleaned)return false;if(telemetry){publish(telemetry);status(`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`);}else status(`${state.loadout} · ${state.graph} · committed; telemetry pending`);return true;}
catch(error){if(!cleaned){try{await rollback?.();}catch(rollbackError){console.error("Phase 8 rollback failed",rollbackError);}console.error("Phase 8 transaction failed",error);status(`Failed · ${error?.code??error?.message??error}`);}return false;}
finally{busy=false;if(!cleaned){document.querySelectorAll("select").forEach(x=>x.disabled=false);const button=document.querySelector("#apply-graph");if(button)button.disabled=controller?.applying||!controller?.dirty;}}
}
async function selectLoadout(next,select){
const previous=state.loadout,targetRevision=(renderer.telemetry?.revision??0)+1;assetAbort=new AbortController();
const ok=await transaction(`Loading ${next}`,async()=>{const glb=await loadDemoLoadout(next,{signal:assetAbort.signal});await renderer.replaceSceneGlb(glb,{framing:next==="sponza"?"interior":"exterior"});state.loadout=next;return waitTelemetry(x=>x.revision===targetRevision&&x.draws>0&&x.activeCompiledGraph===state.compiled[state.graph].graphId&&x.gpuError===false).catch(()=>null);});
assetAbort=undefined;if(!ok)select.value=previous;
}
async function selectGraph(next,select){
const previous=state.graph,compiled=state.compiled[next];
const ok=await transaction(`Activating ${next}`,async()=>{await renderer.switchCompiledGraph(compiled.compiledId);state.graph=next;return waitTelemetry(x=>sameId(x.activeCompiledId,compiled.compiledId)&&x.activeCompiledGraph===compiled.graphId&&x.activeCompiledRevision===compiled.revision&&x.gpuError===false).catch(()=>null);},async()=>{state.graph=previous;select.value=previous;});
if(!ok)select.value=previous;
}
async function 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 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";
}
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);
+95
View File
@@ -0,0 +1,95 @@
export const SNAPSHOT = Object.freeze({
MAGIC: 0x504e5359, BLOB_MAGIC: 0x31534452, VERSION: 1, BYTES: 256,
SLOTS: 3, SLOT_BYTES: 64, SCHEMA: 1, INIT: 0, OPEN: 1, FAILED: 2,
CLOSED: 3, FREE: 0, WRITING: 1, READY: 2, READING: 3,
});
export const STREAM_NAMES = ["meshSlot", "meshGeneration", "meshFlags", "meshLocalMin", "meshLocalMax", "instanceSlot", "instanceGeneration", "instanceMeshSlot", "instanceMeshGeneration", "instanceFlags", "instanceModel", "instanceWorldMin", "instanceWorldMax", "instancePickable"];
const COMPONENTS = [1, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1];
const SCALARS = [1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1];
const STRIDES = COMPONENTS.map(n => n * 4);
export class SnapshotProtocolError extends Error {
constructor(code) { super(code); this.code = code; this.name = "SnapshotProtocolError"; }
}
const bad = code => { throw new SnapshotProtocolError(code); };
const add = (a, b) => { const n = a + b; if (!Number.isSafeInteger(n) || n > 0xffffffff) bad("BAD_RANGE"); return n; };
const mul = (a, b) => { const n = a * b; if (!Number.isSafeInteger(n) || n > 0xffffffff) bad("BAD_RANGE"); return n; };
export class SnapshotReader {
constructor(memory, controlPtr) {
if (!memory || !(memory.buffer instanceof SharedArrayBuffer)) bad("BAD_MEMORY");
if (!Number.isInteger(controlPtr) || controlPtr < 0 || controlPtr % 64 || add(controlPtr, 256) > memory.buffer.byteLength) bad("BAD_CONTROL_POINTER");
this.memory = memory; this.controlPtr = controlPtr; this.buffer = null;
this.refresh(); this.validateControl();
}
refresh() {
if (this.buffer === this.memory.buffer) return;
this.buffer = this.memory.buffer;
if (add(this.controlPtr, 256) > this.buffer.byteLength) bad("BAD_CONTROL_POINTER");
this.control = new Int32Array(this.buffer, this.controlPtr, 64);
}
validateControl() {
const h = this.control;
if ((Atomics.load(h, 0) >>> 0) !== SNAPSHOT.MAGIC) bad("BAD_MAGIC");
if ((Atomics.load(h, 1) >>> 0) !== SNAPSHOT.VERSION) bad("BAD_VERSION");
if ((Atomics.load(h, 2) >>> 0) !== SNAPSHOT.BYTES || (Atomics.load(h, 3) >>> 0) !== SNAPSHOT.SLOTS || (Atomics.load(h, 4) >>> 0) !== SNAPSHOT.SLOT_BYTES || (Atomics.load(h, 5) >>> 0) !== SNAPSHOT.SCHEMA) bad("BAD_LAYOUT");
const lifecycle = Atomics.load(h, 6) >>> 0;
if (lifecycle > SNAPSHOT.CLOSED) bad("BAD_LIFECYCLE");
if ((Atomics.load(h, 15) >>> 0) !== 0) bad("BAD_RESERVED");
}
latest() {
this.refresh(); this.validateControl();
for (let tries = 0; tries < 16; tries++) {
const a = Atomics.load(this.control, 7) >>> 0;
if (a & 1) continue;
const lifecycle = Atomics.load(this.control, 6) >>> 0;
const value = { lifecycle, epoch: Atomics.load(this.control, 8) >>> 0, slot: Atomics.load(this.control, 9) >>> 0, revisionLo: Atomics.load(this.control, 10) >>> 0, revisionHi: Atomics.load(this.control, 11) >>> 0, wasmPages: Atomics.load(this.control, 12) >>> 0, layoutEpoch: Atomics.load(this.control, 13) >>> 0, error: Atomics.load(this.control, 14) >>> 0 };
const b = Atomics.load(this.control, 7) >>> 0;
if (a === b && !(b & 1)) {
if (lifecycle === SNAPSHOT.FAILED) bad("SNAPSHOT_FAILED");
if (lifecycle === SNAPSHOT.CLOSED) bad("SNAPSHOT_CLOSED");
if (lifecycle !== SNAPSHOT.INIT && lifecycle !== SNAPSHOT.OPEN) bad("BAD_LIFECYCLE");
if (value.wasmPages && value.wasmPages > this.buffer.byteLength / 65536) bad("BAD_WASM_PAGES");
return value;
}
}
bad("UNSTABLE_CONTROL");
}
transaction(fn, expectedEpoch = 0) {
this.refresh();
const latest = this.latest();
if (!latest.epoch || latest.slot >= SNAPSHOT.SLOTS || (expectedEpoch && latest.epoch !== expectedEpoch)) return null;
let control = this.control;
const base = 16 + latest.slot * 16;
if (Atomics.compareExchange(control, base, SNAPSHOT.READY, SNAPSHOT.READING) !== SNAPSHOT.READY) return null;
try {
// memory.grow replaces memory.buffer even after the slot has been pinned.
this.refresh(); control = this.control;
const slot = Array.from({length: 16}, (_, i) => Atomics.load(control, base + i) >>> 0);
if (slot[0] !== SNAPSHOT.READING || slot[1] !== latest.epoch || slot[2] !== latest.layoutEpoch || slot[5] !== latest.revisionLo || slot[6] !== latest.revisionHi || slot[9] !== SNAPSHOT.SCHEMA || slot[10] !== 64) return null;
if (slot.slice(11).some(Boolean)) bad("BAD_SLOT_RESERVED");
const ptr = slot[3], bytes = slot[4];
if (ptr % 16 || bytes < 512 || bytes % 16 || add(ptr, bytes) > this.buffer.byteLength) bad("BAD_SLOT");
const u32 = new Uint32Array(this.buffer, ptr, bytes / 4);
if (u32[0] !== SNAPSHOT.BLOB_MAGIC || u32[1] !== SNAPSHOT.SCHEMA || u32[2] !== 64 || u32[3] !== bytes || u32[4] !== slot[1] || u32[5] !== slot[5] || u32[6] !== slot[6] || u32[7] !== 14 || u32[8] !== 64 || u32[9] !== 32 || u32[10] !== slot[7] || u32[11] !== slot[8] || u32[12] !== 0x01020304 || u32[13] !== 3) bad("BAD_BLOB");
if (u32[14] || u32[15]) bad("BAD_BLOB_RESERVED");
const ranges = [], streams = {};
for (let i = 0; i < 14; i++) {
const d = 16 + i * 8, semantic = u32[d], scalar = u32[d + 1], offset = u32[d + 2], count = u32[d + 3], components = u32[d + 4], stride = u32[d + 5], width = u32[d + 6], reserved = u32[d + 7];
const want = i < 5 ? slot[7] : slot[8];
if (semantic !== i + 1 || scalar !== SCALARS[i] || count !== want || components !== COMPONENTS[i] || stride !== STRIDES[i] || width !== 4 || reserved || offset < 512 || offset % 16) bad("BAD_DESCRIPTOR");
const end = add(offset, mul(stride, count));
if (end > bytes) bad("BAD_DESCRIPTOR_RANGE");
if (count) ranges.push([offset, end]);
const Type = scalar === 2 ? Float32Array : Uint32Array;
streams[STREAM_NAMES[i]] = new Type(this.buffer, add(ptr, offset), mul(count, components));
}
ranges.sort((a, b) => a[0] - b[0]);
for (let i = 1; i < ranges.length; i++) if (ranges[i][0] < ranges[i - 1][1]) bad("OVERLAPPING_STREAMS");
return fn(Object.freeze({epoch: slot[1], revisionLo: slot[5], revisionHi: slot[6], meshCount: slot[7], instanceCount: slot[8], streams: Object.freeze(streams)}));
} finally {
Atomics.store(control, base, SNAPSHOT.FREE); Atomics.notify(control, base);
}
}
}
+75
View File
@@ -0,0 +1,75 @@
import { CATALOG_VERSION, descriptors, GRAPH_ID } from "./catalog.js";
export class AuthoringGraphError extends Error {
constructor(code, details = {}) { super(code); this.name="AuthoringGraphError"; this.code=code; this.details=Object.freeze(details); }
}
const fail=(code,details)=>{throw new AuthoringGraphError(code,details)};
const object=v=>v !== null && typeof v === "object" && !Array.isArray(v);
const validId=v=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9_.-]*$/.test(v) && new TextEncoder().encode(v).length<=64;
const validSocketId=v=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9_.-]*:[A-Za-z][A-Za-z0-9_.-]*$/.test(v) && new TextEncoder().encode(v).length<=129;
const keysEqual=(a,b)=>a.length===b.length && a.every(x=>b.includes(x));
/** Validate hostile fxnode state and return an app-owned, layout-free projection. */
export function projectAuthoringSnapshot(raw) {
if(!object(raw)||!Array.isArray(raw.nodes)||!Array.isArray(raw.links)) fail("AUTHORING_SHAPE",{field:"snapshot"});
if(raw.graphId!==GRAPH_ID||raw.catalogVersion!==CATALOG_VERSION) fail("AUTHORING_CATALOG",{graphId:raw.graphId,catalogVersion:raw.catalogVersion});
const nodeIds=new Set(), socketIds=new Set(), byId=new Map(), byType=new Map();
for(const n of raw.nodes){
if(!object(n)||!validId(n.id)) fail("AUTHORING_ID",{kind:"node",id:n?.id});
if(nodeIds.has(n.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"node",id:n.id}); nodeIds.add(n.id);
const d=descriptors[n.typeId];
if(!d) fail("AUTHORING_NODE_TYPE",{nodeId:n.id,typeId:n.typeId});
if(n.known!==true) fail("AUTHORING_NODE_UNKNOWN",{nodeId:n.id});
if(n.typeVersion!==d.version) fail("AUTHORING_NODE_VERSION",{nodeId:n.id,expected:d.version,actual:n.typeVersion});
if(typeof n.muted!=="boolean") fail("AUTHORING_NODE_MUTED",{nodeId:n.id});
if(n.muted&&n.typeId!=="scene_forward") fail("AUTHORING_NODE_MUTED",{nodeId:n.id});
if(byType.has(n.typeId)) fail("AUTHORING_TOPOLOGY",{reason:"duplicate-type",typeId:n.typeId});
if(!Array.isArray(n.sockets)||!keysEqual(n.sockets.map(s=>s?.key),Object.keys(d.sockets))) fail("AUTHORING_SOCKET_SET",{nodeId:n.id});
const sockets={};
for(const s of n.sockets){ const expected=d.sockets[s.key];
if(!object(s)||!validSocketId(s.id)) fail("AUTHORING_ID",{kind:"socket",id:s?.id});
if(socketIds.has(s.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"socket",id:s.id}); socketIds.add(s.id);
if(s.direction!==expected[0]||s.dataType!==expected[1]) fail("AUTHORING_SOCKET",{nodeId:n.id,socket:s.key});
sockets[s.key]={id:s.id,direction:s.direction,type:s.dataType,nodeId:n.id};
}
const p=object(n.parameters)?n.parameters:null;
if(!p||!keysEqual(Object.keys(p),d.parameters)) fail("AUTHORING_PARAMETERS",{nodeId:n.id});
let parameters={};
if(n.typeId==="scene_forward"){
const c=p.clearColor, z=p.clearDepth;
if(!object(c)||c.kind!=="color"||!Array.isArray(c.value)||c.value.length!==4||c.value.some(v=>!Number.isFinite(v)||v<0||v>1)) fail("AUTHORING_PARAMETER",{parameter:"clearColor"});
if(!object(z)||z.kind!=="number"||!Number.isFinite(z.value)||z.value<0||z.value>1) fail("AUTHORING_PARAMETER",{parameter:"clearDepth"});
parameters={clearColor:[...c.value],clearDepth:z.value};
}
const projected={type:n.typeId,id:n.id,sockets,parameters,muted:n.muted}; byId.set(n.id,projected); byType.set(n.typeId,projected);
}
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}}]};
}
export const adaptFxNodeSnapshot=(snapshot,revision=1)=>semanticProjectionToV1(projectAuthoringSnapshot(snapshot),revision);
export const adaptGraphSnapshot=adaptFxNodeSnapshot;
@@ -0,0 +1,14 @@
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;
}
}
+61
View File
@@ -0,0 +1,61 @@
const viewport = (canvas, ownerWindow) => ({
width: Math.max(1, canvas.clientWidth),
height: Math.max(1, canvas.clientHeight),
dpr: Math.min(4, Math.max(1, ownerWindow.devicePixelRatio || 1)),
});
const sameViewport = (a, b) => a.width === b.width && a.height === b.height && a.dpr === b.dpr;
const sizeCanvas = (canvas, value) => {
canvas.width = Math.round(value.width * value.dpr);
canvas.height = Math.round(value.height * value.dpr);
};
const mods = e => ({ alt:e.altKey, control:e.ctrlKey, meta:e.metaKey, shift:e.shiftKey });
export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeType }={}) {
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=()=>{};
const captured=new Set();
const initialViewport=viewport(canvas,ownerWindow); appliedViewport=initialViewport; sizeCanvas(canvas,initialViewport);
canvas.tabIndex=0; canvas.style.touchAction="none";
const point=e=>{const r=canvas.getBoundingClientRect();return{x:e.clientX-r.left,y:e.clientY-r.top}};
const input=e=>{
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==="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;
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 view.feedInput({kind:"focus",phase:e.type==="focus"?"focus":"blur"});
};
const names=["pointerdown","pointermove","pointerup","pointercancel","wheel","keydown","keyup","focus","blur"];
const pump=()=>{
if(!view||resizing||!pending||dead)return;
const next=pending, currentGeneration=generation;pending=undefined;
if(sameViewport(next,appliedViewport)){sizeCanvas(canvas,next);pump();return}
resizing=true;
Promise.resolve(view.setViewport(next)).then(()=>{if(dead||currentGeneration!==generation)return;appliedViewport=next;sizeCanvas(canvas,next)}).catch(error=>{if(!dead&&currentGeneration===generation)onError(error)}).finally(()=>{if(dead||currentGeneration!==generation)return;resizing=false;pump()});
};
const resize=()=>{if(dead)return;pending=viewport(canvas,ownerWindow);pump()};
const 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;
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)});
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);
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;
}};
}
function prevent(e){e.preventDefault()}
+29
View File
@@ -0,0 +1,29 @@
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:[]},
});
+25
View File
@@ -0,0 +1,25 @@
import { createFxNode } from "@fxnode/index.ts";
import { CATALOG_VERSION, GRAPH_ID, nodeDefinitions, socketTypes, styles, theme } from "./catalog.js";
import { prepareBrowserHost } from "./browser-host.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}],
];
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});
}
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}
}
+5
View File
@@ -0,0 +1,5 @@
import { semanticProjectionToV1 } from "./adapter.js";
const make=(graphId,clearColor)=>Object.freeze(semanticProjectionToV1({graphId,clearColor,clearDepth:1,passState:"enabled"},1));
export const midnight=make("preset_midnight",[0.015,0.06,0.18,1]);
export const ember=make("preset_ember",[0.18,0.035,0.012,1]);
export const renderGraphPresets=Object.freeze({midnight,ember});
+287
View File
@@ -0,0 +1,287 @@
import { SnapshotReader } from "./render-data-snapshot.js";
export const VISIBLE = 1;
const HEADER_WORDS = 16, SLOT_WORDS = 24, CAPACITY = 1024, SLOT_VERSION = 1;
const OP = { IMPORT_GLB: 1, MESH_FLAGS: 2, CREATE_INSTANCE: 3, INSTANCE_FLAGS: 4, INSTANCE_TRANSFORM: 5, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9 };
const HANDLE_TOKEN = Symbol("renderer handle");
export class RendererError extends Error {
constructor(code, details) { super(details?.message ?? code); this.name = "RendererError"; this.code = code; this.details = details; }
}
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;
#graphQueue = []; #graphBusy = false;
#bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map();
constructor(bridge) {
this.#bridge = bridge;
this.#worker = bridge.worker;
this.#refreshViews();
if (Atomics.load(this.#header, 0) !== 0x4e574159 || Atomics.load(this.#header, 1) !== 1 || Atomics.load(this.#header, 2) !== CAPACITY || Atomics.load(this.#header, 3) !== SLOT_WORDS) {
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
try { bridge?.free?.(); } catch { /* best effort */ }
throw new RendererError("PROTOCOL_MISMATCH");
}
this.#worker.addEventListener("message", e => this.#message(e.data));
this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR"));
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_MESSAGE_ERROR"));
try {
const factory = bridge.workerFactory || (() => new Worker(new URL("./bvh-worker.js", import.meta.url), { type: "module" }));
this.#bvh = factory();
this.#bvh.addEventListener("message", e => this.#bvhMessage(e.data));
this.#bvh.addEventListener("error", () => this.#disablePicking("PICKING_FAILED"));
this.#bvh.addEventListener("messageerror", () => this.#disablePicking("PICKING_FAILED"));
} catch { this.#picking = false; }
this.#ready = Promise.resolve(this);
}
get ready() { return this.#ready; }
get telemetry() { return this.#telemetry; }
#refreshViews() {
const buffer = this.#bridge.memory.buffer;
if (buffer === this.#buffer) return;
this.#buffer = buffer;
this.#header = new Int32Array(buffer, this.#bridge.ringPtr, HEADER_WORDS);
this.#slots = new Int32Array(buffer, this.#bridge.ringPtr + 64, CAPACITY * SLOT_WORDS);
}
#message(message) {
if (message?.type === "reply") {
const pending = this.#pending.get(message.request);
if (!pending) return;
this.#pending.delete(message.request);
message.ok ? pending.resolve(message.result) : pending.reject(new RendererError(message.code, message.details));
} else if (message?.type === "payload-ready") {
const pending = this.#payloadPending.get(message.id);
if (pending) { this.#payloadPending.delete(message.id); pending.resolve(); }
} else if (message?.type === "telemetry") {
this.#telemetry = message;
dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
} else if (message?.type === "fatal") {
console.error("renderer worker fatal", message.code, message.message);
this.#fail(message.code || "WORKER_FATAL");
} else if (message?.type === "snapshot-init") {
try {
if (message.controlVersion !== 1 || message.schemaVersion !== 1) throw new Error("version");
this.#snapshotReader = new SnapshotReader(this.#bridge.memory, message.controlPtr);
this.#bvh?.postMessage({type:"init",memory:this.#bridge.memory,controlPtr:message.controlPtr,controlVersion:1,schemaVersion:1});
} catch { this.#disablePicking("PICK_PROTOCOL_MISMATCH"); }
} else if (message?.type === "snapshot-published") {
try { this.#snapshotEpoch=this.#snapshotReader?.latest().epoch||0; this.#bvh?.postMessage({type:"update",epoch:this.#snapshotEpoch}); } catch { this.#disablePicking("PICK_PROTOCOL_MISMATCH"); }
}
}
#disablePicking(code) { this.#picking=false; const allowed=new Set(["PICK_UNAVAILABLE","PICK_PROTOCOL_MISMATCH","PICK_WORKER_ERROR","PICK_STALE","DISPOSED"]); const error=new RendererError(allowed.has(code)?code:"PICK_WORKER_ERROR"); for(const p of this.#picks.values())p.reject(error); this.#picks.clear(); try{this.#bvh?.terminate?.();}catch{} this.#bvh=null; }
#bvhMessage(message) {
if(message?.type==="fatal"){this.#disablePicking(message.code);return;}
if(message?.type!=="pick")return;
const p=this.#picks.get(message.request);if(!p)return;this.#picks.delete(message.request);
let latest=0;try{latest=this.#snapshotReader.latest().epoch;this.#snapshotEpoch=latest;}catch{this.#disablePicking("PICK_PROTOCOL_MISMATCH");p.reject(new RendererError("PICK_PROTOCOL_MISMATCH"));return;}
if(message.stale||p.epoch!==message.epoch||message.epoch!==latest){if(!p.retried&&latest){this.#sendPick({...p,retried:true},latest);}else p.reject(new RendererError("PICK_STALE"));return;}
const hits=(message.hits||[]).map(hit=>({instance:this.#instance([hit.slot>>>0,hit.generation>>>0]),distance:hit.distance}));p.resolve({epoch:latest,hits});
}
#sendPick(p,epoch){const request=this.#pickNext++>>>0||this.#pickNext++;p.epoch=epoch;this.#picks.set(request,p);try{this.#bvh.postMessage({type:"pick",request,epoch,origin:p.origin,direction:p.direction,maxDistance:p.maxDistance,maxHits:p.maxHits});}catch{this.#picks.delete(request);p.reject(new RendererError("PICK_WORKER_ERROR"));}}
pickRay(origin,direction,{maxDistance=Infinity,maxHits=1}={}) {
const vector=(v,name)=>{if(!v||v.length!==3||[...v].some(x=>typeof x!=="number"||!Number.isFinite(x)))throw new TypeError(`${name} must contain 3 finite numbers`);return [...v];};
origin=vector(origin,"origin");direction=vector(direction,"direction");if(direction.every(x=>x===0))throw new TypeError("direction must be nonzero");if(typeof maxDistance!=="number"||(!(Number.isFinite(maxDistance)&&maxDistance>=0)&&maxDistance!==Infinity)||!Number.isInteger(maxHits)||maxHits<1||maxHits>64)throw new TypeError("invalid pick options");
if(this.#disposed)return Promise.reject(new RendererError("DISPOSED"));if(!this.#picking||!this.#bvh||!this.#snapshotReader)return Promise.reject(new RendererError("PICK_UNAVAILABLE"));
let epoch;try{epoch=this.#snapshotReader.latest().epoch;this.#snapshotEpoch=epoch;}catch{return Promise.reject(new RendererError("PICK_PROTOCOL_MISMATCH"));}if(!epoch)return Promise.reject(new RendererError("PICK_STALE"));
return new Promise((resolve,reject)=>this.#sendPick({resolve,reject,origin,direction,maxDistance,maxHits,retried:false},epoch));
}
#stop() {
if (this.#stopped) return;
this.#stopped = true;
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
try { this.#bvh?.postMessage?.({type:"dispose"}); this.#bvh?.terminate?.(); } catch { /* best effort */ }
try { this.#bridge?.free?.(); } catch { /* best effort */ }
this.#bridge = null;
}
#fail(code) {
if (this.#disposed) { this.#stop(); return; }
this.#disposed = true;
const error = new RendererError(code);
for (const pending of this.#pending.values()) pending.reject(error);
this.#pending.clear();
for (const pending of this.#payloadPending.values()) pending.reject(error);
this.#payloadPending.clear();
for (const pending of this.#graphQueue) pending.reject(error);
this.#graphQueue.length = 0;
this.#disablePicking(code === "DISPOSED" ? "DISPOSED" : "PICK_WORKER_ERROR");
this.#stop();
}
#corrupt(code) {
Atomics.store(this.#header, 6, 1);
this.#fail(code);
return Promise.reject(new RendererError(code));
}
#enqueue(opcode, words = []) {
if (this.#disposed) return Promise.reject(new RendererError("DISPOSED"));
this.#refreshViews();
if (Atomics.load(this.#header, 6) !== 0) return this.#corrupt("RING_CLOSED");
const read = Atomics.load(this.#header, 4) >>> 0;
const write = Atomics.load(this.#header, 5) >>> 0;
const backlog = (write - read) >>> 0;
if (backlog > CAPACITY) return this.#corrupt("RING_CORRUPT");
if (backlog === CAPACITY) return Promise.reject(new RendererError("RING_FULL"));
let request = this.#next++ >>> 0;
if (request === 0) { request = 1; this.#next = 2; }
const base = (write % CAPACITY) * SLOT_WORDS;
const promise = new Promise((resolve, reject) => this.#pending.set(request, { resolve, reject }));
try {
for (let i = 0; i < SLOT_WORDS; i++) Atomics.store(this.#slots, base + i, 0);
for (let i = 0; i < words.length; i++) Atomics.store(this.#slots, base + 3 + i, words[i]);
Atomics.store(this.#slots, base + 2, request);
Atomics.store(this.#slots, base + 1, opcode);
// The slot tag is its publication marker; write_index publishes the complete slot.
Atomics.store(this.#slots, base, SLOT_VERSION);
Atomics.store(this.#header, 5, (write + 1) | 0);
Atomics.notify(this.#header, 5);
} catch (error) {
this.#pending.delete(request);
this.#fail("PUBLICATION_FAILED");
return Promise.reject(error);
}
return promise;
}
#mesh(handle) {
return new Mesh(HANDLE_TOKEN,
visible => this.#enqueue(OP.MESH_FLAGS, [...handle, visible ? VISIBLE : 0]),
async (transform, visible) => {
const result = await this.#enqueue(OP.CREATE_INSTANCE, [...handle, ...floatWords(transform), visible ? VISIBLE : 0]);
return this.#instance(result);
});
}
#instance(handle) {
return new Instance(HANDLE_TOKEN,
visible => this.#enqueue(OP.INSTANCE_FLAGS, [...handle, visible ? VISIBLE : 0]),
transform => this.#enqueue(OP.INSTANCE_TRANSFORM, [...handle, ...floatWords(transform)]),
() => this.#enqueue(OP.DESTROY_INSTANCE, [...handle]));
}
async replaceSceneGlb(source, { framing = "exterior" } = {}) {
if (this.#disposed) throw new RendererError("DISPOSED");
if (framing !== "exterior" && framing !== "interior") throw new TypeError("framing must be exterior or interior");
let buffer;
if (typeof source === "string" || source instanceof URL) buffer = await (await fetch(source)).arrayBuffer();
else if (typeof File !== "undefined" && source instanceof File) buffer = await source.arrayBuffer();
else if (source instanceof ArrayBuffer) buffer = source;
else throw new TypeError("GLB source must be URL, File, or ArrayBuffer");
if (this.#disposed) throw new RendererError("DISPOSED");
const result = await this.#withPayload(buffer, OP.IMPORT_GLB, [framing === "interior" ? 1 : 0]);
return result.meshes.map(handle => this.#mesh(handle));
}
/** Compatibility alias for the original opcode-1 API. */
importGlb(source, options) { return this.replaceSceneGlb(source, options); }
async #withPayload(buffer, opcode, words = []) {
if (this.#disposed) throw new RendererError("DISPOSED");
let id;
do { id = this.#payload++ >>> 0; if (!id) id = this.#payload++ >>> 0; }
while (!id || this.#payloadActive.has(id));
this.#payloadActive.add(id);
const worker = this.#worker;
const ready = new Promise((resolve, reject) => this.#payloadPending.set(id, { resolve, reject }));
try {
worker.postMessage({ type: "payload", id, buffer }, [buffer]);
await ready;
return await this.#enqueue(opcode, [id, ...words]);
} finally {
this.#payloadPending.delete(id);
this.#payloadActive.delete(id);
try { worker.postMessage({ type: "payload-release", id }); } catch { /* best effort after termination */ }
}
}
#graphCall(operation) {
const result = new Promise((resolve, reject) => this.#graphQueue.push({operation, resolve, reject}));
this.#pumpGraphQueue();
return result;
}
#pumpGraphQueue() {
if (this.#graphBusy || !this.#graphQueue.length) return;
const call = this.#graphQueue.shift();
if (this.#disposed) { call.reject(new RendererError("DISPOSED")); this.#pumpGraphQueue(); return; }
this.#graphBusy = true;
let outcome;
try { outcome = call.operation(); } catch (error) { outcome = Promise.reject(error); }
Promise.resolve(outcome).then(call.resolve, call.reject).finally(() => { this.#graphBusy = false; this.#pumpGraphQueue(); });
}
compileGraph(graph) {
return this.#graphCall(() => this.#compileGraph(graph));
}
async #compileGraph(graph) {
if (this.#disposed) throw new RendererError("DISPOSED");
let json;
try { json = JSON.stringify(graph); } catch (error) { throw new RendererError("GRAPH_JSON_INVALID", { message: error?.message || "GRAPH_JSON_INVALID" }); }
if (json === undefined) throw new RendererError("GRAPH_JSON_INVALID");
const buffer = new TextEncoder().encode(json).buffer;
if (buffer.byteLength > 1024 * 1024) throw new RendererError("GRAPH_PAYLOAD_TOO_LARGE");
return this.#withPayload(buffer, OP.COMPILE_GRAPH);
}
dropCompiledGraph(compiledId) {
validateCompiledId(compiledId);
return this.#graphCall(() => this.#enqueue(OP.DROP_GRAPH, compiledId));
}
switchCompiledGraph(compiledId) {
validateCompiledId(compiledId);
if (compiledId[0] === 0 && compiledId[1] === 0) throw new TypeError("compiledId must be nonzero");
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, [1, ...compiledId]));
}
switchToImmediate() {
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, [0, 0, 0]));
}
dispose() { this.#fail("DISPOSED"); }
}
function validateCompiledId(compiledId) {
if (!Array.isArray(compiledId) || compiledId.length !== 2 || compiledId.some(word => !Number.isInteger(word) || word < 0 || word > 0xffffffff)) throw new TypeError("compiledId must contain exactly two uint32 values");
}
function floatWords(matrix) {
if (!matrix || matrix.length !== 16) throw new TypeError("transform must contain 16 numbers");
return [...new Int32Array(new Float32Array(matrix).buffer)];
}
class Mesh {
#setVisible; #createInstance;
constructor(token, setVisible, createInstance) {
if (token !== HANDLE_TOKEN) throw new TypeError("Mesh cannot be constructed directly");
this.#setVisible = setVisible;
this.#createInstance = createInstance;
}
setVisible(visible) { return this.#setVisible(visible); }
createInstance(transform, visible = true) { return this.#createInstance(transform, visible); }
}
class Instance {
#setVisible; #setTransform; #destroy; #dead = false;
constructor(token, setVisible, setTransform, destroy) {
if (token !== HANDLE_TOKEN) throw new TypeError("Instance cannot be constructed directly");
this.#setVisible = setVisible;
this.#setTransform = setTransform;
this.#destroy = destroy;
}
#live() { if (this.#dead) throw new RendererError("STALE_HANDLE"); }
setVisible(visible) { this.#live(); return this.#setVisible(visible); }
setTransform(transform) { this.#live(); return this.#setTransform(transform); }
async destroy() { this.#live(); await this.#destroy(); this.#dead = true; }
}
+10
View File
@@ -0,0 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createCubeGeometry, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, 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("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/)});
+15
View File
@@ -0,0 +1,15 @@
import test from "node:test";
import assert from "node:assert/strict";
import { adaptFxNodeSnapshot, AuthoringGraphError } from "../static/render-graph/adapter.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");
});
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)});
+4
View File
@@ -0,0 +1,4 @@
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");}});
+135
View File
@@ -0,0 +1,135 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as rendererModule from "../static/renderer-client.js";
const { RendererClient, RendererError } = rendererModule;
class WorkerMock extends EventTarget {
messages=[]; transfers=[]; terminated=false;
postMessage(message, transfer=[]) { this.messages.push(message); this.transfers.push(transfer); }
terminate(){this.terminated=true;}
reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); }
}
function fixture() {
const memory = new WebAssembly.Memory({initial:2, maximum:4, shared:true});
const header = new Int32Array(memory.buffer, 0, 16);
header.set([0x4e574159,1,1024,24,0,0]);
const worker = new WorkerMock();
const bridge = {memory,ringPtr:0,worker,freed:false,free(){this.freed=true;}};
const client = new RendererClient(bridge);
return {memory,header,worker,bridge,client};
}
async function imported(f) {
const loading=f.client.importGlb(new ArrayBuffer(8));
f.worker.reply({type:"payload-ready",id:1});
await Promise.resolve();
f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[[7,3]]}});
return (await loading)[0];
}
test("replaceSceneGlb is opcode 1 and importGlb remains an alias", async()=>{
for(const method of ["replaceSceneGlb","importGlb"]){const f=fixture(),pending=f.client[method](new ArrayBuffer(8));f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();assert.equal(new Int32Array(f.memory.buffer,64,24)[1],1);f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[]}});assert.deepEqual(await pending,[]);}
});
test("scene replacement carries the framing mode in opcode 1",async()=>{const f=fixture(),pending=f.client.replaceSceneGlb(new ArrayBuffer(8),{framing:"interior"});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();assert.deepEqual([...new Int32Array(f.memory.buffer,64,24).slice(1,5)],[1,1,1,1]);f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[]}});await pending;await assert.rejects(f.client.replaceSceneGlb(new ArrayBuffer(8),{framing:"bad"}),TypeError)});
test("writes tagged fixed-slot protocol and resolves reply", async () => {
const f=fixture(); const mesh=await imported(f);
const pending=mesh.setVisible(true);
const {memory,header,worker}=f;
assert.equal(Atomics.load(header,5),2);
const slot=new Int32Array(memory.buffer,64+96,24);
assert.deepEqual([...slot.slice(0,6)],[1,2,2,7,3,1]);
worker.reply({type:"reply",request:2,ok:true,code:"OK"}); await pending;
});
test("maps stable errors and gates destroyed instances", async () => {
const f=fixture(), mesh=await imported(f); const {worker}=f;
const creating=mesh.createInstance(new Float32Array([1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]),false);
worker.reply({type:"reply",request:2,ok:true,result:[4,2]}); const instance=await creating;
const destroying=instance.destroy(); worker.reply({type:"reply",request:3,ok:true}); await destroying;
assert.throws(()=>instance.setVisible(true), error=>error instanceof RendererError&&error.code==="STALE_HANDLE");
});
test("rejects protocol mismatch", () => {
const {memory,worker}=fixture(); new Int32Array(memory.buffer)[1]=2;
assert.throws(()=>new RendererClient({memory,ringPtr:0,worker}), /PROTOCOL_MISMATCH/);
});
test("pending reply exists before ring publication", async () => {
const f=fixture(), mesh=await imported(f); const {worker}=f;
const pending=mesh.setVisible(true);
worker.reply({type:"reply",request:2,ok:true});
await pending;
});
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);
worker.dispatchEvent(new Event("error"));
await assert.rejects(a,/WORKER_ERROR/); await assert.rejects(b,/WORKER_ERROR/);
client.dispose(); assert.equal(worker.terminated,true); assert.equal(bridge.freed,true);
});
test("import always releases staged payload when ring is full", async () => {
const {header,worker,client}=fixture(); Atomics.store(header,5,1024);
const loading=client.importGlb(new ArrayBuffer(8));
worker.reply({type:"payload-ready",id:1});
await assert.rejects(loading,/RING_FULL/);
assert.equal(worker.messages.at(-1).type,"payload-release");
});
test("does not export handle constructors or internal mutation methods", () => {
const {client}=fixture();
assert.equal(rendererModule.Mesh,undefined);
assert.equal(rendererModule.Instance,undefined);
assert.equal(client._meshFlags,undefined);
assert.equal(client._createInstance,undefined);
});
test("corrupt backlog closes and terminates the transport", async () => {
const f=fixture(); Atomics.store(f.header,5,1025);
const loading=f.client.importGlb(new ArrayBuffer(8));
// Payload staging must first acknowledge before enqueue sees corruption.
f.worker.reply({type:"payload-ready",id:1});
await assert.rejects(loading,/RING_CORRUPT/);
assert.equal(Atomics.load(f.header,6),1);
assert.equal(f.worker.terminated,true);
});
test("import rejects immediately after disposal", async () => {
const f=fixture();
f.client.dispose();
await assert.rejects(f.client.importGlb(new ArrayBuffer(8)),/DISPOSED/);
assert.equal(f.worker.messages.length,0);
});
test("import rejects when disposed during asynchronous source loading", async () => {
const f=fixture();
const originalFetch=globalThis.fetch;
let finishFetch;
globalThis.fetch=()=>new Promise(resolve=>{finishFetch=resolve;});
try {
const loading=f.client.importGlb("model.glb");
f.client.dispose();
finishFetch({arrayBuffer:async()=>new ArrayBuffer(8)});
await assert.rejects(loading,/DISPOSED/);
assert.equal(f.worker.messages.length,0);
} finally {
globalThis.fetch=originalFetch;
}
});
test("compile transfers payload and waits for ready before opcode 7", async()=>{
const f=fixture(), pending=f.client.compileGraph({schemaVersion:1});
assert.equal(f.worker.transfers[0].length,1); assert.equal(Atomics.load(f.header,5),0);
f.worker.reply({type:"payload-ready",id:1}); await Promise.resolve();
assert.equal(new Int32Array(f.memory.buffer,64,24)[1],7);
f.worker.reply({type:"reply",request:1,ok:true,result:{compiledId:[2,3]}});
assert.deepEqual(await pending,{compiledId:[2,3]});
});
test("flat error reply preserves structured details", async()=>{
const f=fixture(), pending=f.client.compileGraph({}); f.worker.reply({type:"payload-ready",id:1}); await Promise.resolve();
f.worker.reply({type:"reply",request:1,ok:false,code:"GRAPH_INVALID_ID",details:{message:"bad",path:"graphId"}});
await assert.rejects(pending,e=>e instanceof RendererError&&e.code==="GRAPH_INVALID_ID"&&e.details.path==="graphId"&&e.message==="bad");
});
test("compile releases payload after success", async()=>{const f=fixture(),p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();f.worker.reply({type:"reply",request:1,ok:true,result:{}});await p;assert.equal(f.worker.messages.at(-1).type,"payload-release");});
test("compile releases payload after backend error", async()=>{const f=fixture(),p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();f.worker.reply({type:"reply",request:1,ok:false,code:"X",details:{message:"x"}});await assert.rejects(p);assert.equal(f.worker.messages.at(-1).type,"payload-release");});
test("compile rejects circular and BigInt JSON", async()=>{const f=fixture(),x={};x.x=x;await assert.rejects(f.client.compileGraph(x),/circular/i);await assert.rejects(f.client.compileGraph({x:1n}),e=>e.code==="GRAPH_JSON_INVALID");});
test("compile rejects oversized encoding", async()=>{const f=fixture();await assert.rejects(f.client.compileGraph({x:"x".repeat(1024*1024)}),e=>e.code==="GRAPH_PAYLOAD_TOO_LARGE");assert.equal(f.worker.messages.length,0);});
test("drop graph emits opcode 8 and validates id", async()=>{const f=fixture(),p=f.client.dropCompiledGraph([9,4]);const slot=new Int32Array(f.memory.buffer,64,24);assert.deepEqual([...slot.slice(1,5)],[8,1,9,4]);f.worker.reply({type:"reply",request:1,ok:true});await p;assert.throws(()=>f.client.dropCompiledGraph([1]),TypeError);});
test("ring-full graph compile releases staged payload", async()=>{const f=fixture();Atomics.store(f.header,5,1024);const p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await assert.rejects(p,e=>e.code==="RING_FULL");assert.equal(f.worker.messages.at(-1).type,"payload-release");});
test("disposal while graph payload is pending releases and rejects", async()=>{const f=fixture(),p=f.client.compileGraph({});f.client.dispose();await assert.rejects(p,e=>e.code==="DISPOSED");assert.equal(f.worker.messages.at(-1).type,"payload-release");});
test("payload transfer uses the exact encoded ArrayBuffer", async()=>{const f=fixture(),graph={schemaVersion:1};const expected=new TextEncoder().encode(JSON.stringify(graph));const p=f.client.compileGraph(graph);const transferred=f.worker.transfers[0][0];assert.strictEqual(f.worker.messages[0].buffer,transferred);assert.deepEqual(new Uint8Array(transferred),expected);f.client.dispose();await assert.rejects(p);});
test("cycle error details are preserved exactly", async()=>{const f=fixture(),p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();const details={message:"cycle",kind:"cycle",edges:[{from:"a",resource:{id:"r",version:0},to:"b"}]};f.worker.reply({type:"reply",request:1,ok:false,code:"GRAPH_CYCLE",details});await assert.rejects(p,e=>e.details===details&&e.details.edges[0].from==="a");});
test("error without details leaves details undefined", async()=>{const f=fixture(),p=f.client.dropCompiledGraph([1,1]);f.worker.reply({type:"reply",request:1,ok:false,code:"STALE_GRAPH_ID"});await assert.rejects(p,e=>e instanceof RendererError&&e.details===undefined&&e.message==="STALE_GRAPH_ID");});
test("switch methods emit exact opcode 9 words", async()=>{const f=fixture();const a=f.client.switchCompiledGraph([9,4]);assert.deepEqual([...new Int32Array(f.memory.buffer,64,24).slice(1,7)],[9,1,1,9,4,0]);f.worker.reply({type:"reply",request:1,ok:true});await a;await new Promise(queueMicrotask);const b=f.client.switchToImmediate();assert.deepEqual([...new Int32Array(f.memory.buffer,160,24).slice(1,7)],[9,2,0,0,0,0]);f.worker.reply({type:"reply",request:2,ok:true});await b;assert.throws(()=>f.client.switchCompiledGraph([0,0]),TypeError);});
test("graph lifecycle FIFO recovers after failure", async()=>{const f=fixture();const a=f.client.switchCompiledGraph([1,1]),b=f.client.switchToImmediate();assert.equal(Atomics.load(f.header,5),1);f.worker.reply({type:"reply",request:1,ok:false,code:"X"});await assert.rejects(a);await new Promise(queueMicrotask);assert.equal(Atomics.load(f.header,5),2);f.worker.reply({type:"reply",request:2,ok:true});await b;});
test("dispose rejects queued graph lifecycle calls", async()=>{const f=fixture();const a=f.client.switchCompiledGraph([1,1]),b=f.client.switchToImmediate();f.client.dispose();await assert.rejects(a,/DISPOSED/);await assert.rejects(b,/DISPOSED/);});
+130
View File
@@ -0,0 +1,130 @@
import test from "node:test";
import assert from "node:assert/strict";
import { SnapshotReader, SnapshotProtocolError } from "../static/render-data-snapshot.js";
import { DerivedBvh } from "../static/bvh-core.js";
import { RendererClient } from "../static/renderer-client.js";
const align16 = value => (value + 15) & ~15;
const componentCounts = [1, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1];
const scalarTypes = [1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1];
function snapshotFixture({ instances = 1 } = {}) {
const memory = new WebAssembly.Memory({ initial: 4, maximum: 8, shared: true });
const words = new Uint32Array(memory.buffer);
const control = new Int32Array(memory.buffer, 0, 64);
const ptr = 256;
const counts = [1, 1, 1, 1, 1, ...Array(9).fill(instances)];
const offsets = [];
let cursor = 512;
for (let i = 0; i < 14; i++) {
offsets.push(cursor);
cursor = align16(cursor + counts[i] * componentCounts[i] * 4);
}
control.set([0x504e5359, 1, 256, 3, 64, 1, 1, 2, 1, 0, 7, 0, 4, 1, 0, 0]);
control.set([2, 1, 1, ptr, cursor, 7, 0, 1, instances, 1, 64, 0, 0, 0, 0, 0], 16);
const blob = new Uint32Array(memory.buffer, ptr, cursor / 4);
blob.set([0x31534452, 1, 64, cursor, 1, 7, 0, 14, 64, 32, 1, instances, 0x01020304, 3, 0, 0]);
for (let i = 0; i < 14; i++) {
blob.set([i + 1, scalarTypes[i], offsets[i], counts[i], componentCounts[i], componentCounts[i] * 4, 4, 0], 16 + i * 8);
}
const stream = i => scalarTypes[i] === 2
? new Float32Array(memory.buffer, ptr + offsets[i], counts[i] * componentCounts[i])
: new Uint32Array(memory.buffer, ptr + offsets[i], counts[i] * componentCounts[i]);
stream(0)[0] = 4; stream(1)[0] = 2; stream(2)[0] = 1;
stream(3).set([-1, -1, -1]); stream(4).set([1, 1, 1]);
for (let i = 0; i < instances; i++) {
stream(5)[i] = 10 + i; stream(6)[i] = 3; stream(7)[i] = 4; stream(8)[i] = 2;
stream(9)[i] = 1; stream(13)[i] = 1;
stream(11).set([i * 4, -1, -1], i * 3); stream(12).set([i * 4 + 2, 1, 1], i * 3);
}
return { memory, control, ptr, cursor };
}
test("snapshot reader validates and pins an exact ABI snapshot", () => {
const f = snapshotFixture();
const reader = new SnapshotReader(f.memory, 0);
const result = reader.transaction(snapshot => ({
epoch: snapshot.epoch,
slot: snapshot.streams.instanceSlot[0],
min: [...snapshot.streams.instanceWorldMin],
}), 1);
assert.deepEqual(result, { epoch: 1, slot: 10, min: [0, -1, -1] });
assert.equal(Atomics.load(f.control, 16), 0);
});
test("snapshot reader rejects malformed control and WRITING slots", () => {
const bad = snapshotFixture();
bad.control[0] = 0;
assert.throws(() => new SnapshotReader(bad.memory, 0), SnapshotProtocolError);
const writing = snapshotFixture();
Atomics.store(writing.control, 16, 1);
assert.equal(new SnapshotReader(writing.memory, 0).transaction(() => true, 1), null);
});
test("snapshot reader refreshes memory after pinning", () => {
const f = snapshotFixture();
const reader = new SnapshotReader(f.memory, 0);
const original = Atomics.compareExchange;
let grew = false;
Atomics.compareExchange = (...args) => {
const result = original(...args);
if (!grew && args[1] === 16 && result === 2) { grew = true; f.memory.grow(1); }
return result;
};
try {
assert.equal(reader.transaction(snapshot => snapshot.instanceCount, 1), 1);
assert.equal(grew, true);
} finally {
Atomics.compareExchange = original;
}
});
function bvhSnapshot({ pickable = [1, 1], shifted = false } = {}) {
const count = 2;
return { instanceCount: count, streams: {
instanceSlot: Uint32Array.from([5, 6]), instanceGeneration: Uint32Array.from([1, 1]),
instanceMeshSlot: Uint32Array.from([2, 2]), instanceMeshGeneration: Uint32Array.from([4, 4]),
instancePickable: Uint32Array.from(pickable),
instanceWorldMin: Float32Array.from(shifted ? [10, -1, -1, 4, -1, -1] : [2, -1, -1, 4, -1, -1]),
instanceWorldMax: Float32Array.from(shifted ? [12, 1, 1, 6, 1, 1] : [3, 1, 1, 6, 1, 1]),
}};
}
test("BVH preserves topology for visibility/refit and reports world distances", () => {
const bvh = new DerivedBvh();
bvh.update(bvhSnapshot());
assert.equal(bvh.rebuilds, 1);
assert.equal(bvh.pick([0, 0, 0], [2, 0, 0], Infinity, 2)[0].distance, 2);
bvh.update(bvhSnapshot({ pickable: [0, 1], shifted: true }));
assert.equal(bvh.rebuilds, 1);
assert.equal(bvh.refits, 1);
assert.deepEqual(bvh.pick([0, 0, 0], [1, 0, 0], Infinity, 2).map(hit => hit.slot), [6]);
});
class WorkerMock extends EventTarget {
messages = []; terminated = false;
postMessage(message) { this.messages.push(message); }
terminate() { this.terminated = true; }
reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); }
}
test("renderer pick returns gated instances and exact epoch", async () => {
const scene = snapshotFixture();
const ring = 8192;
const ringHeader = new Int32Array(scene.memory.buffer, ring, 16);
ringHeader.set([0x4e574159, 1, 1024, 24]);
const rendererWorker = new WorkerMock(), bvhWorker = new WorkerMock();
const bridge = { memory: scene.memory, ringPtr: ring, worker: rendererWorker, workerFactory: () => bvhWorker, free() {} };
const client = new RendererClient(bridge);
rendererWorker.reply({ type: "snapshot-init", controlPtr: 0, controlVersion: 1, schemaVersion: 1 });
rendererWorker.reply({ type: "snapshot-published", epoch: 1 });
const picking = client.pickRay([0, 0, 0], [1, 0, 0]);
const request = bvhWorker.messages.find(message => message.type === "pick");
bvhWorker.reply({ type: "pick", request: request.request, epoch: 1, stale: false, hits: [{ slot: 10, generation: 3, distance: 2 }] });
const result = await picking;
assert.equal(result.epoch, 1);
assert.equal(result.hits[0].distance, 2);
assert.equal(typeof result.hits[0].instance.setVisible, "function");
client.dispose();
assert.equal(bvhWorker.terminated, true);
});
+122
View File
@@ -0,0 +1,122 @@
# fxnode orb workflow
This repository is a TypeScript node-editor library with a browser client and worker-owned authority. This guide is
the operating checklist for Amp orbs and future implementation threads.
## Environment and services
- No `.env` file or secrets are required for development or tests.
- `.agents/setup` installs the exact `package-lock.json` dependency tree, Chromium, Firefox, and their Linux runtime
dependencies. It then typechecks the repository and starts declared services.
- `.agents/resume` only reconciles declared services. Keep it fast and idempotent; do not install dependencies there.
- `.amp/services.yaml` declares the examples gallery. Use `amp orb services ensure` to start it and obtain its portal.
Inspect it with `amp orb service status examples` and `amp orb service logs examples`.
- Run `npm run examples` outside an orb when a local Vite server is sufficient. Vite uses `examples/` as its root.
- Generated `.amp/portals/` manifests and resume logs are local runtime state and must not be committed.
## Architectural invariants
Preserve these unless the user explicitly requests an architectural change:
1. The main-thread root is a message-passing facade. Graph state, composition, history, commands, selection behavior,
and rendering authority live in the worker.
2. A root can be headless or own multiple attached views. Views share graph/history while camera, selection, pointer
lane, host requests, and render barriers remain view-local.
3. The worker lazily owns one `OffscreenCanvas` atlas and one retained worker 2D context. Views occupy atlas slots;
paint and cropped bitmap production are serialized. The final detach releases the atlas.
4. Main-thread HTML canvases necessarily have one presentation context each. Do not describe the design as one
context across the entire browser application.
5. RAF polling remains continuous for responsive shared-pointer-lane input, but layout, atlas paint, bitmap creation,
worker frame messages, and presentation remain dirty/on-demand.
6. `FxNodeView.setViewport()` is acknowledged and transactional. Runtime hosts await it before changing canvas backing
dimensions. Surface generations and dimensions reject stale or incoherent frames.
7. The core library does not register DOM listeners, observers, menus, modals, or file pickers. Those are application
policy. The example browser host demonstrates explicit cleanup and opt-in detach-on-disconnect behavior.
8. Composition definitions are collections installed through `setTheme`, `setHeaderStyles`, `composeSocket`, and
`composeNode`; initial graph layout is applied afterward with `setState`.
9. Durable save/load uses command journals plus save-time composition. `setState`/`getState` are portable layout/state
APIs, not the preferred persistence mechanism.
10. Commands distinguish durable graph mutations from transient UI previews and provide undo/redo semantics.
The implementation history and Oracle-reviewed atlas/multi-view decisions are in the originating
[Amp thread](https://ampcode.com/threads/T-019f806f-11fe-74ef-a1f1-90933c1dc543). Treat the current code, tests, README,
and authored docs as authoritative when they differ from historical discussion.
## Design and implementation process
1. Read the nearest code, applicable `AGENTS.md`, public types, protocol validators, and focused tests before editing.
2. Prefer the smallest change at the existing ownership boundary. Remove obsolete paths rather than layering adapters.
3. For cross-layer architectural work, ask Oracle for a phased plan. Before each phase ask for concrete implementation
details; after each phase ask for a high-confidence blocker review. Address blockers before moving on.
4. Keep protocol boundaries exact and hostile-safe. A public type change normally requires protocol validation, client,
worker, declaration/type tests, docs, and real-worker browser coverage.
5. Keep DOM policy outside `src/browser/client.ts`. Extend application hosts or examples instead of secretly attaching
listeners in `attachView()`.
6. For rendering changes, reason explicitly about device/logical coordinates, DPR, atlas slot clipping, transforms,
`putImageData` ignoring clip/CTM, bitmap ownership/closing, frame ACKs, and context-loss generations.
7. Do not revert unrelated worktree changes. Do not commit generated `.amp` runtime files. Commit/push only when asked.
## Verification ladder
Choose the narrowest useful checks while iterating, then broaden according to blast radius.
### Fast and focused
```sh
npx prettier --write <touched-files>
npm run typecheck -- --pretty false
node --import tsx --test test/<focused>.test.ts
npx playwright test test/browser/<focused>.spec.ts --config playwright.config.ts --project chromium
```
For multi-view or worker lifecycle changes, run focused tests in both supported engines:
```sh
npx playwright test \
test/browser/client-multiview.spec.ts \
test/browser/worker-multiview.spec.ts \
--config playwright.config.ts
```
### Test groups
```sh
npm test # Node unit, protocol, layout, persistence, atlas, and scheduler tests
npm run test:browser # Real browser behavior in Chromium and Firefox
npm run test:visual # Core Chromium screenshot baselines
npm run test:examples:visual # Documentation/example screenshots
npm run check:performance # Large-layout performance budgets
npm run check:docs # TypeDoc generation plus VitePress build
npm run check:readme # README structure and link checks
npm run check:package # Packed-package and worker-asset smoke test
npm run build # Vite library bundle and declaration build
```
### Full release gate
Run this after shared contracts, architecture, rendering, examples, persistence, or release-facing docs change:
```sh
npm run release:check
git diff --check
npm run format:check
```
`release:check` includes typecheck, all unit/browser/visual/example tests, fixture and reference checks, performance,
build, docs, README, composition, and package smoke verification.
## Visual review policy
- Never update screenshots merely to make a failure green.
- Inspect expected, actual, and diff images under `test-results/`. Confirm whether changes are intended and localized.
- Use `npm run test:visual:update` only for reviewed core baseline changes.
- Use `npm run test:examples:visual:update` only for reviewed example/documentation image changes.
- Re-run the corresponding non-update command after refreshing a baseline.
- Save one-off user-review screenshots under `.amp/in/artifacts/`; keep transient inspection images in `test-results/`.
## Documentation expectations
- README and VitePress learning docs explain user workflows and ownership boundaries.
- TypeDoc is the API contract; regenerate it with `npm run docs:api` after public type changes.
- Examples import library source through `@lib/`, never relative `../src` paths.
- Keep the examples gallery linked to every standalone application and relevant focused scene.
Vendored Executable
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p .amp
echo "[fxnode resume] ensuring declared orb services"
amp orb services ensure >.amp/resume-services.log 2>&1
echo "[fxnode resume] ready"
Vendored Executable
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[fxnode setup] installing locked npm dependencies"
npm ci
echo "[fxnode setup] installing Playwright browsers and Linux dependencies"
npx playwright install --with-deps chromium firefox
echo "[fxnode setup] checking the TypeScript environment"
npm run typecheck -- --pretty false
echo "[fxnode setup] ensuring declared orb services"
amp orb services ensure
echo "[fxnode setup] complete; see .agents/ORB_WORKFLOW.md for development and verification guidance"
+6
View File
@@ -0,0 +1,6 @@
services:
examples:
command: npm run examples -- --port "$PORT"
portal:
title: fxnode examples
description: Browse the interactive examples gallery and focused Blender-inspired test scenes.
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.png binary
*.blend binary
+16
View File
@@ -0,0 +1,16 @@
node_modules/
dist/
docs/reference/generated/
docs/.vitepress/cache/
docs/.vitepress/dist/
*.blend
*.blend1
.DS_Store
test-results/
playwright-report/
blob-report/
.env
.env.*
!.env.example
.amp/portals/
.amp/resume-services.log
+16
View File
@@ -0,0 +1,16 @@
node_modules/
dist/
docs/reference/generated/
test-results/
playwright-report/
blob-report/
.amp/
# Canonical generated data is formatted by its generator and byte-checked.
examples/blender/initialLayout.json
examples/blender/**/initialLayout.json
# Binary and captured reference assets.
**/*.png
**/*.blend
**/*.blend1
+14
View File
@@ -0,0 +1,14 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf",
"htmlWhitespaceSensitivity": "css",
"printWidth": 120,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false
}
+5
View File
@@ -0,0 +1,5 @@
# fxnode agent guidance
Read [`.agents/ORB_WORKFLOW.md`](.agents/ORB_WORKFLOW.md) before changing architecture, browser/worker boundaries,
examples, tests, or documentation. It records the repository's design invariants, phased review process, test ladder,
visual-baseline policy, and orb service workflow.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 fxnode contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
# Reference notice
Blender is free software from the Blender Foundation and contributors, licensed under GNU GPL. “Blender” is used here only to identify the researched application. This project is not affiliated with or endorsed by the Blender Foundation.
`tools/blender/create-reference-fixtures.py` is project-authored fixture automation and is licensed under **GPL-3.0-or-later** because it executes through Blender's Python API. All other project-authored content is under the package license unless noted.
No Blender source, bundled assets, icons, fonts, or Blender Manual screenshots are distributed here. Future files in `docs/research/blender-references/4.5.0/` must be whole-window screenshots captured by this project from the verified baseline binary; their hashes and capture metadata must be recorded in the manifest.
+326
View File
@@ -0,0 +1,326 @@
# fxnode
> **Internal private 0.x prerelease.** This package has `private: true`; its API and data formats may change.
![fxnode Color Balance node showing three grading wheels and image sockets](https://raw.githubusercontent.com/Heaust-ops/fxnode/main/examples/assets/color-balance.png)
_The Color Balance example rendered by fxnode. It demonstrates editor presentation only; fxnode does not process pixels._
fxnode is a typed, worker-owned node editor for application-defined nodes, sockets, styles, resources, and migrations.
One root can run headlessly or attach multiple independent canvas views to the same graph and worker. The application
explicitly owns its DOM integration: canvas sizing, input forwarding, menus, file pickers, accessibility, lifecycle,
and cleanup. The worker owns graph state, validation, history, hit testing, layout, and frame generation; the browser
client presents transferred frames without keeping a shadow graph.
fxnode edits and presents graphs. It does **not** execute or evaluate graphs and contains no image-processing engine.
It does not read or write Blender files, and makes no Blender compatibility, affiliation, endorsement, or parity claim.
## Install and platform requirements
```sh
npm install fxnode
```
That command is for a future registry release; this repository is currently private and not publishable.
Consumers need a modern browser with module workers, Canvas 2D, and worker-side `OffscreenCanvas`/`ImageBitmap` support.
See the committed [browser support matrix](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/guides/browser-support.md) and
[Content Security Policy guide](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/guides/csp.md) before integrating.
## The simplest node
Definitions are serializable, consumer-facing tuples. This complete definition matches the executable
[minimal definition](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/definition.ts):
```ts
import type { FxNodeDefinition, FxNodeSocketTypeDefinition, FxNodeStyleDefinition } from "fxnode";
export const numberSocket = [
"number",
{ title: "Number", color: "#a8a8a8", acceptsFrom: ["number"] },
] as const satisfies readonly [string, FxNodeSocketTypeDefinition];
export const minimalStyles = {
value: { header: "#4c6ef5" },
} as const satisfies Readonly<Record<string, FxNodeStyleDefinition>>;
export const valueNode = [
"example.minimal.value",
{
version: 1,
title: "Number Value",
behavior: "standard",
style: "value",
parameters: {
value: { type: "number", default: { kind: "number", value: 42 }, step: 1 },
},
sockets: {
value: {
title: "Value",
direction: "output",
type: "number",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "parameter", parameter: "value" },
{ kind: "socket", socket: "value" },
],
muteBypass: [],
migrations: [],
},
] as const satisfies readonly [string, FxNodeDefinition];
```
Bootstrap the editor after creating an application-local host and theme:
```ts
import { createFxNode } from "fxnode";
import { prepareFxNodeBrowserHost } from "./browser-host.js";
import { exampleTheme } from "./theme.js";
import { minimalStyles, numberSocket, valueNode } from "./definition.js";
const canvas = document.querySelector<HTMLCanvasElement>("#graph")!;
const host = prepareFxNodeBrowserHost({ canvas });
let cleaned = false;
let api: Awaited<ReturnType<typeof createFxNode>> | null = null;
let view: Awaited<ReturnType<Awaited<ReturnType<typeof createFxNode>>["attachView"]>> | null = null;
function cleanup() {
window.removeEventListener("pagehide", cleanup);
cleaned = true;
const root = api;
api = null;
host.destroy();
const destroyRoot = () => root?.destroy();
if (view) void view.detach().then(destroyRoot, destroyRoot);
else destroyRoot();
view = null;
}
window.addEventListener("pagehide", cleanup);
try {
const created = await createFxNode({
applicationId: "fxnode.example.minimal",
applicationVersion: 1,
resources: {},
});
if (cleaned) created.destroy();
else {
api = created;
await api.setTheme(exampleTheme);
await api.setHeaderStyles(minimalStyles);
await api.composeSocket(...numberSocket);
await api.composeNode(...valueNode);
await api.setState({ graphId: "minimal", catalogVersion: 1, nodes: [], links: [], metadata: {} });
view = await api.attachView({ canvas, viewport: host.initialViewport });
host.attach(api, view);
await view.addNode({ nodeId: "value", typeId: valueNode[0], viewPosition: { x: 360, y: 190 } });
await view.whenRendered();
}
} catch (error) {
cleanup();
throw error;
}
```
`exampleTheme` and `prepareFxNodeBrowserHost` are application-local examples, not fxnode package exports.
![Minimal Number Value node rendered by fxnode](https://raw.githubusercontent.com/Heaust-ops/fxnode/main/examples/assets/minimal.png)
Complete sources: [definition](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/definition.ts), [bootstrap](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/main.ts), and
[first-node tutorial](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/tutorials/first-node.md).
## One graph, zero or many views
`createFxNode()` creates the shared graph authority and starts one worker, but creates no canvas. This is valid for
headless browser workflows. Call `attachView()` whenever the application needs a presentation. Each view has its own
canvas, viewport, camera, selection, input stream, host requests, render barriers, and lifecycle; graph state,
composition, versions, events, persistence, and undo/redo remain shared on the root.
All views render through one worker-owned atlas canvas and one 2D rendering context. Each HTML canvas keeps its own
presentation context. `FXNODE_VIEW_LIMITS.maxViews` is a resource bound, not a count of worker rendering contexts.
```ts
const left = await api.attachView({
canvas: leftCanvas,
viewport: leftViewport,
initialCamera: { center: { x: 480, y: -550 }, zoom: 0.5 },
});
const right = await api.attachView({
canvas: rightCanvas,
viewport: rightViewport,
initialCamera: { center: { x: 2080, y: -400 }, zoom: 0.45 },
});
left.feedInput(pointerInputFromLeftCanvas);
await left.addNode({ typeId: "fxnode.shader.noise-texture", viewPosition: { x: 400, y: 260 } });
await Promise.all([left.whenRendered(), right.whenRendered()]);
await Promise.all([left.detach(), right.detach()]);
api.destroy();
```
View-local `addNode`, `removeSelected`, and `setSelectedMuted` use that view's camera and selection. Root-level
`dispatch`, `undo`, and `redo` are useful when no view context is required. A canvas can belong to only one live view.
The application must detach its view before reusing that canvas.
![One shared graph shown through two independent fxnode canvases](https://raw.githubusercontent.com/Heaust-ops/fxnode/main/examples/assets/multi-view.png)
The [multi-view example](https://github.com/Heaust-ops/fxnode/tree/main/examples/multi-view) uses a single DOM toolbar
to target whichever canvas was most recently activated. Its canvases forward pointer events only; menus, controls,
resize observation, and teardown remain application code.
## Focused example: Color Balance
The Color Balance example keeps strict local socket tuples and styles, then composes its larger node definition:
```ts
import type { FxNode, FxNodeSocketTypeDefinition, FxNodeStyleDefinition } from "fxnode";
import { colorBalanceNode } from "./color-balance.js";
import { exampleTheme } from "./theme.js";
const floatSocket = [
"float",
{ title: "Float", color: "#a8a8a8", acceptsFrom: ["float"] },
] as const satisfies readonly [string, FxNodeSocketTypeDefinition];
const colorSocket = [
"color",
{ title: "Color", color: "#d7ca63", acceptsFrom: ["color"] },
] as const satisfies readonly [string, FxNodeSocketTypeDefinition];
const styles = {
compositorColor: { header: "#8c5cc4" },
} as const satisfies Readonly<Record<string, FxNodeStyleDefinition>>;
async function installColorBalance(api: FxNode) {
await api.setTheme(exampleTheme);
await api.setHeaderStyles(styles);
await api.composeSocket(...floatSocket);
await api.composeSocket(...colorSocket);
await api.composeNode(...colorBalanceNode);
}
```
The node's UI schema includes a representative grading-wheels row:
```ts
import type { FxNodeDefinition } from "fxnode";
const gradingWheelsRow = {
kind: "widget",
widget: "grading-wheels",
bindings: [
{ title: "Lift", scalar: "lift", color: "liftColor" },
{ title: "Gamma", scalar: "gamma", color: "gammaColor" },
{ title: "Gain", scalar: "gain", color: "gainColor" },
],
visibleWhen: { parameter: "mode", equals: "Lift/Gamma/Gain" },
} satisfies FxNodeDefinition["ui"][number];
```
See the complete [bootstrap](https://github.com/Heaust-ops/fxnode/blob/main/examples/color-balance/main.ts),
[definition](https://github.com/Heaust-ops/fxnode/blob/main/examples/shared/nodes/color-balance.ts), and
[tutorial](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/tutorials/color-balance.md). This schema renders controls and connections; it does not evaluate
the graph or process pixels.
## Application-owned browser host
fxnode deliberately does not install global listeners or create application UI. A compact host should:
- measure and DPR-size the canvas, observe resize, and call `setViewport`;
- translate pointer, wheel, keyboard, focus, and outside-pointer events into `feedInput`;
- manage pointer capture, focus, context menus, add-node UI, and authorized resource pickers;
- subscribe to bounded host projections/requests and provide an accessible DOM workflow; and
- remove listeners, subscriptions, observers, temporary DOM, and captures during teardown.
The example host defaults to explicit lifecycle ownership. Its opt-in `lifecycle: "detach-on-disconnect"` policy
uses a document-level observer to tear down host policy and detach the view after a canvas remains disconnected for a
microtask; same-task moves are preserved. `host.destroy()` itself never detaches the view.
The host receives both the shared `FxNode` root and one `FxNodeView`. Route graph subscriptions and composition calls
to the root; route canvas input, viewport updates, selection actions, host requests, resources, and rendering to the
view. For multiple canvases, create one host (or equivalent listener owner) per view.
Use the [browser-host guide](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/guides/browser-host.md),
[interaction guide](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/guides/interactions.md), and the repository's
[compact host implementation](https://github.com/Heaust-ops/fxnode/blob/main/examples/shared/browser-host.ts).
## State, persistence, and events
The worker is authoritative. All calls are asynchronous unless their signature says otherwise.
| API | Meaning |
| ----------------- | -------------------------------------------------------------------------------------------- |
| `getState()` | Detached, readonly current graph snapshot with graph version; does not mutate runtime state. |
| `setState(value)` | Validates and atomically replaces graph state; supports optimistic `expectedVersion`. |
| `save()` | Canonical current `GraphLayoutV2`; a compact graph export, not command history. |
| `getSaveData()` | Durable envelope: canonical baseline, applied journal, and effective save-time composition. |
| `load(value)` | Atomically validates/loads durable data; failure preserves current state. |
Committed graph changes publish mutations **before** snapshots in version order. `onMutations` and `onSnapshots`
do not mutate state; each returns an unsubscribe function, and subscriber failures are isolated. Composition has a
separate revision domain and `onCompositionChanges`; a rebind that changes the graph publishes its composition event
before the corresponding mutation and snapshot. Read [graph state and events](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/concepts/graph-state-and-events.md)
and [state and persistence](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/concepts/state-and-persistence.md).
## Headless use
`fxnode/headless` exposes the same composition-bound document and command authority without browser or worker
resources. With the minimal tuples above:
```ts
import { createFxNodeHeadless } from "fxnode/headless";
import { exampleTheme } from "./theme.js";
const runtime = createFxNodeHeadless({
schemaVersion: 2,
id: "fxnode.example.minimal",
version: 1,
compatibility: { wildcardInputTypes: [] },
theme: exampleTheme,
socketTypes: { [numberSocket[0]]: numberSocket[1] },
nodeStyles: minimalStyles,
resources: {},
nodes: { [valueNode[0]]: valueNode[1] },
} as const);
const empty = runtime.emptyDocument("minimal");
const document = {
...empty,
nodes: { value: runtime.materializeNode("value", valueNode[0], { x: 360, y: 190 }) },
};
const issues = runtime.validateDocument(document);
if (issues.length) throw new Error(issues.map((issue) => issue.message).join("; "));
const layout = runtime.save(document);
```
Headless operations are explicit and immutable; they still edit graph documents and never evaluate them.
## Documentation
- [Learn](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/index.md): tutorials, concepts, integration guides, and examples
- [Concepts](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/concepts/index.md): worker authority, composition, state, and persistence
- [Guides](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/guides/index.md): browser hosting, lifecycle, accessibility, CSP, and support
- [Tutorials](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/tutorials/index.md): minimal, Color Balance, and live composition
- [API reference landing page](https://github.com/Heaust-ops/fxnode/blob/main/docs/reference/index.md)
- [Research notes](https://github.com/Heaust-ops/fxnode/blob/main/docs/research/blender.md) and [architecture decision](https://github.com/Heaust-ops/fxnode/blob/main/docs/decisions/worker-transport-protobuf-benchmark.md)
## Development
Requires Node.js 20 or newer.
```sh
npm install
npm run typecheck # TypeScript checks
npm test # Node test suite
npm run build # Vite library build + declarations
npm run examples # Example gallery development server
npm run test:examples:visual # Example screenshot checks
npm run docs:dev # Generated API + VitePress development server
npm run docs:build # Build generated API and documentation
npm run format:check # Prettier verification
npm run release:check # Full release gate (maintainers)
```
For contribution context, start with the executable [examples](https://github.com/Heaust-ops/fxnode/tree/main/examples) and committed
[Learn landing page](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/index.md). The MIT license is in [LICENSE](LICENSE); notices are in
[NOTICE.md](NOTICE.md).
+25
View File
@@ -0,0 +1,25 @@
# fxnode upstream provenance
- Source: https://github.com/Heaust-ops/fxnode
- Commit: `3f8745717bf4574577be72e9769373475cc300c9`
- Tree: `fb8bf407854b68dad8c0249c9ba63c7fd0bd9332`
- Imported: 2026-07-26
- License: MIT (see `LICENSE` and `NOTICE.md`)
- Local patches: none
This directory is a committed source snapshot. Application adaptations belong outside
`vendor/fxnode`; do not modify vendored source for integration convenience.
## Reimport
```sh
git clone --filter=blob:none https://github.com/Heaust-ops/fxnode /tmp/fxnode
git -C /tmp/fxnode fetch --depth=1 origin 3f8745717bf4574577be72e9769373475cc300c9
git -C /tmp/fxnode checkout --detach 3f8745717bf4574577be72e9769373475cc300c9
rm -rf vendor/fxnode
mkdir -p vendor/fxnode
git -C /tmp/fxnode archive HEAD | tar -x -C vendor/fxnode
```
After importing, restore this file with the new commit/tree hashes and document any
unavoidable local patches explicitly.
+66
View File
@@ -0,0 +1,66 @@
import { defineConfig } from "vitepress";
import generated from "../reference/generated/typedoc-sidebar.json" with { type: "json" };
const learn = [
{ text: "Learn", link: "/learn/" },
{
text: "Tutorials",
collapsed: false,
items: [
{ text: "Overview", link: "/learn/tutorials/" },
{ text: "Your first node", link: "/learn/tutorials/first-node" },
{ text: "Color Balance", link: "/learn/tutorials/color-balance" },
{ text: "Live composition", link: "/learn/tutorials/live-composition" },
],
},
{
text: "Concepts",
items: [
{ text: "Overview", link: "/learn/concepts/" },
{ text: "Worker authority", link: "/learn/concepts/worker-authority" },
{ text: "Composition", link: "/learn/concepts/composition" },
{ text: "Graph state and events", link: "/learn/concepts/graph-state-and-events" },
{ text: "State and persistence", link: "/learn/concepts/state-and-persistence" },
],
},
{
text: "Guides",
items: [
{ text: "Overview", link: "/learn/guides/" },
{ text: "Browser host", link: "/learn/guides/browser-host" },
{ text: "Interactions", link: "/learn/guides/interactions" },
{ text: "Rendering and lifecycle", link: "/learn/guides/rendering-and-lifecycle" },
{ text: "Browser support", link: "/learn/guides/browser-support" },
{ text: "CSP", link: "/learn/guides/csp" },
{ text: "Accessibility", link: "/learn/guides/accessibility" },
],
},
{ text: "Examples", link: "/learn/examples/" },
];
export default defineConfig({
title: "fxnode",
description: "A typed, worker-owned node editor",
lastUpdated: true,
srcExclude: ["research/**", "decisions/**"],
ignoreDeadLinks: false,
transformPageData(pageData) {
if (pageData.relativePath.startsWith("reference/generated/"))
pageData.frontmatter = { ...pageData.frontmatter, editLink: false, lastUpdated: false };
},
themeConfig: {
nav: [
{ text: "Learn", link: "/learn/" },
{ text: "API Reference", link: "/reference/" },
],
sidebar: { "/learn/": learn, "/reference/": [{ text: "API Reference", link: "/reference/" }, ...generated] },
search: { provider: "local" },
outline: { level: [2, 3] },
editLink: {
pattern: "https://github.com/Heaust-ops/fxnode/edit/main/docs/:path",
text: "Edit this page on GitHub",
},
socialLinks: [{ icon: "github", link: "https://github.com/Heaust-ops/fxnode" }],
footer: { message: "Released under the MIT License.", copyright: "Copyright © fxnode contributors" },
},
});
@@ -0,0 +1,55 @@
# Phase 3 worker transport Protobuf benchmark
**Status:** NO-GO (measured 2026-07-22). This decision does not authorize a production transport change.
## Question and method
The experiment compared the current browser structured-clone shape with a generated Protobuf-ES schema sent as a transferable `ArrayBuffer`. It ran in headless, real Chromium with a dedicated module worker. The timed Protobuf path included the main-thread object-to-schema adapter, encode, transferable `postMessage`, worker decode and schema-to-object adapter. The structured path included `postMessage` and structured clone. Both paths computed the same FNV-1a checksum over a recursively key-sorted serialization of the reconstructed synthetic payload in the worker. Every Protobuf send asserted immediate transfer detachment.
Fixtures are deterministic multiplications of nodes, links, sockets, parameters, composition definitions and commands. Graph state, snapshot, node, socket, link and envelope fields are generated typed messages (not JSON bytes in Protobuf). Recursive `JsonValue` represents open values and composition/command/save variants. JSON byte counts below are UTF-8 diagnostics, not a measured transport. Pointer SAB, ImageBitmap frames and resource byte payloads are explicitly excluded.
The orb-constrained run used 10 warmups and 50 samples per payload/path (rather than an exhaustive 3×120). Samples were sequential in one browser, structured clone always ran first, and the harness did not retain raw samples for paired analysis. It therefore cannot establish cross-machine confidence or formal results for every row. The correctly directed `state.set` failures are far outside timer resolution and independently make the wholesale migration fail its acceptance gates.
The harness always encoded on the page and decoded in the worker. Consequently, response, snapshot, save-data, mutation, and host-projection rows were synthetic reverse-direction estimates rather than production-faithful worker-to-host measurements. Some of those envelopes also approximated, rather than passed, the current protocol validators. They corroborate the result but are not used as decisive evidence. The graph-heavy `state.set` requests did run in the production direction and used typed graph messages; those are the basis of the decision.
## Environment
- Orb: Linux 6.1.158+, x86_64, 2 reported logical CPUs
- Chromium: HeadlessChrome 149.0.7827.55, Linux; `crossOriginIsolated=false`
- Node 20.9.0, npm 10.9.8, Vite 6.1.0, Playwright 1.61.1
- Exact tools: `@bufbuild/buf@1.72.0`, `@bufbuild/protobuf@2.13.0`, `@bufbuild/protoc-gen-es@2.13.0`
- The one-off benchmark harness and generated output were removed after review; this record preserves its result and limitations.
## Results
Times are milliseconds. Delta is `(structured p95 - protobuf p95) / structured p95`; positive favors Protobuf.
| Payload | JSON bytes | clone p50 / p95 | protobuf p50 / p95 | main codec p95 | p95 delta |
| ---------------------------- | ---------: | --------------: | -----------------: | -------------: | --------: |
| tiny command | 143 | 0.00 / 0.20 | 0.30 / 0.70 | 0.20 | -250.0% |
| state.set medium | 129,422 | 3.70 / 5.40 | 28.00 / 43.30 | 31.30 | -701.9% |
| state.set large | 654,168 | 16.80 / 19.50 | 152.40 / 176.70 | 119.20 | -806.2% |
| snapshot medium | 129,413 | 3.40 / 6.00 | 26.60 / 45.20 | 27.80 | -653.3% |
| snapshot large | 654,159 | 17.40 / 20.40 | 149.70 / 183.70 | 113.70 | -800.5% |
| document.replaced large | 654,175 | 18.10 / 25.50 | 149.80 / 175.50 | 106.70 | -588.2% |
| save-data medium | 143,961 | 3.90 / 6.90 | 70.10 / 94.40 | 66.70 | -1,268.1% |
| save-data large | 624,879 | 17.30 / 21.50 | 310.50 / 383.00 | 243.60 | -1,681.4% |
| initial composition + layout | 559,668 | 14.40 / 17.70 | 279.80 / 322.00 | 204.80 | -1,719.2% |
| receipt | 152 | 0.10 / 0.20 | 0.20 / 0.30 | 0.20 | -50.0% |
| error | 139 | 0.10 / 0.20 | 0.20 / 0.30 | 0.10 | -50.0% |
| input | 179 | 0.10 / 0.20 | 0.20 / 0.30 | 0.20 | -50.0% |
| host projection | 10,084 | 0.30 / 0.30 | 3.50 / 9.00 | 3.10 | -2,900.0% |
The harness build produced 23,989 gzip bytes (417 bytes for its worker entry and 23,572 bytes for its combined codec chunk). This was not a production baseline/delta comparison and did not account for host/worker runtime duplication, so it is only a non-decisive harness-size estimate.
## Thresholds and decision
The planned gates were: graph-heavy p95 improvement ≥20% in at least 3/4 of medium/large state and snapshot cases; no >5% regression on any other large payload; a small-payload deadband (do not decide on sub-millisecond noise); main-thread codec p95 ≤4 ms and no codec task >16.7 ms; worker gzip delta ≤30 KiB and total ≤45 KiB. A noisy threshold crossing would have been **INCONCLUSIVE**. Because only request-direction cases were production-faithful and the bundle comparison was approximate, not every planned gate was formally established.
The result is still **NO-GO**. Both correctly directed medium and large `state.set` requests regressed by multiples, making the planned 3-of-4 graph gate mathematically impossible to pass. Their page-side adapter/encode latency also exceeded both codec limits by wide margins. Tiny-message differences and reverse-direction estimates are not used to strengthen the decision. The generic `JsonValue` portions do not prove every conceivable fully typed schema would be slow, but the typed graph result is sufficient to reject a wholesale migration now.
Production retains structured clone for object-rich commands, composition, state, and events; the existing `SharedArrayBuffer` pointer lane and naturally binary transferables remain the appropriate narrow optimizations. A future packed typed-array format should be considered only for a measured numeric hot path, not as a generic graph protocol.
## Repository outcome
The reviewed harness was intentionally not retained: it approximated several current envelopes, tested only host-to-worker direction, and would have permanently added a generator/runtime dependency tree for a rejected design. This ADR retains the environment, measured table, decisive evidence, and limitations without turning the one-off experiment into a misleading supported benchmark.
+31
View File
@@ -0,0 +1,31 @@
---
layout: home
hero:
name: fxnode
text: A worker-owned node editor
tagline: Compose a typed graph language, present it on Canvas, and persist it safely.
actions:
- theme: brand
text: Start learning
link: /learn/
- theme: alt
text: API Reference
link: /reference/
features:
- title: Worker authority
details: Commands, validation, hit testing, layout, history, and rendering live behind one explicit boundary.
- title: Zero or many views
details: Run headlessly or attach independent canvases with view-local cameras, selections, input, and rendering to one shared graph.
- title: Application composition
details: Your application supplies node definitions, socket compatibility, theme, and resource policies.
- title: Durable documents
details: Canonical saves, bounded decoding, opaque unknown nodes, and declarative migrations protect user data.
---
::: warning Prerelease
fxnode is an internal `0.x` prerelease. APIs and persistence contracts can still change.
:::
fxnode **presents and persists** node graphs. It does not evaluate or execute them. It is not Blender-compatible and makes no Blender feature or visual parity claim.
+11
View File
@@ -0,0 +1,11 @@
# Composition
Composition is the application-defined graph language, distinct from graph state. It owns themes, header styles, directional socket compatibility, resources, node definitions, UI rows, defaults, bypasses, and migrations. Graph state is a document written in that language. Compatibility is checked from the destination socket's accepted source types; changing it can invalidate existing links.
There is no built-in registry. Browser applications install definitions through `setTheme`, `setHeaderStyles`, `setCompatibility`, `composeSocket`, and `composeNode`, or atomically with `loadComposition`. Dependencies come first; `setState` comes last. Plain structured-clone-safe data crosses the worker boundary—no callbacks or classes.
Updates compile, validate, rebind, and publish atomically and return a receipt (`status`, composition `revision`, graph version, and whether rebinding changed the graph). A rejected candidate changes nothing. Distinct definition IDs converge regardless of concurrent installation order once dependencies exist; updates to the same ID are ordered, and references still require their dependency to be installed first.
Every committed change to a node definition resets definition-bound undo/redo history, even if no current instance uses that definition. Removing a node definition preserves its instances as opaque, read-only nodes. Removing a socket type is rejected while compatibility rules, another socket type, or a node definition references it; update or remove those dependents first. A valid composition rebind may remove graph links that have become incompatible. Reintroducing compatible definitions can promote opaque instances. A migration `rename-socket` rewrites both the node's socket data and every link endpoint that refers to it in the same transaction—there is no observable half-renamed graph. A semantic no-op emits nothing and advances neither revision nor graph version.
Static/headless authoring can use `compileFxNodeComposition` and immutable helpers to retain literal ID types. Browser handles intentionally accept string IDs because their composition authority can change live.
@@ -0,0 +1,15 @@
# Graph state and events
Runtime graph state contains `graphId`, `catalogVersion`, nodes, links, and metadata. It is not the persistence envelope. The worker commits commands atomically and checks optional optimistic `expectedVersion` values.
Committed graph changes emit mutations before snapshots, in version order. Subscribers are isolated and return an unsubscribe function. Composition changes use a separate revision domain and emit `onCompositionChanges`; when rebinding changes a graph, the composition event precedes the matching mutation and snapshot.
Command and composition calls resolve with receipts only after authoritative validation and publication. Use their returned versions/revisions for the next compare-and-swap rather than inferring them from event timing. Structured validation/protocol failures reject without partial mutation; a `noop` receipt means no graph publication.
| Domain | Meaning | Advances on |
| ---------------------- | -------------------------------------- | ---------------------------------- |
| Graph `version` | runtime document concurrency | graph-changing command/load/rebind |
| Composition `revision` | live authority concurrency | committed composition update |
| `catalogVersion` | bound composition version in documents | normalization/binding; persisted |
Do not compare or substitute these values. Gesture previews remain worker-local until one commit.
+10
View File
@@ -0,0 +1,10 @@
# Concepts
For integrators deciding where application responsibilities end and fxnode authority begins. Read in this order:
1. [Worker authority](./worker-authority): locate truth, work, and the asynchronous host boundary.
2. [Composition](./composition): model the application's graph language and live updates.
3. [Graph state and events](./graph-state-and-events): reason about documents, receipts, versions, and observation.
4. [State and persistence](./state-and-persistence): choose runtime replacement, canonical export, or replayable persistence.
Afterward you should be able to choose the correct API and concurrency domain, predict publication order, and design durable loading without treating fxnode as a graph evaluator. fxnode is an editor and presenter, not an evaluator.
@@ -0,0 +1,11 @@
# State and persistence
`getState()` and `setState()` exchange exact, process-local state for the currently installed composition. `setState()` is useful for bootstrap and controlled replacement, not historical imports.
`save()` returns the canonical current `GraphLayoutV2`—a compact graph export, not history. For replayable durable storage use `getSaveData()`: its envelope records the canonical baseline, the applied command journal since that baseline, and the effective save-time composition used to establish compatibility. The baseline and journal are composed at save time to verify that they reproduce the exported current graph.
`load()` accepts historical `GraphLayoutV1`, canonical `GraphLayoutV2`, or the save-data envelope. It stages decode, compatibility checks, declarative migrations, and replay before one atomic publication; structured issues identify paths/codes on failure, and rejected input leaves graph, history, and observable state unchanged. A successful graph change publishes the load mutation/snapshot as one commit. Loading an envelope installs its migrated baseline and command journal (including checkpoint placement); if the resulting graph equals current state, the load is a no-op but the validated journal/baseline is still installed for subsequent undo/redo and saves.
Durable `GraphLayoutV2` uses `schemaVersion: 2`; its historical `catalogVersion` field stores composition version. Unknown types and future node versions round-trip as opaque read-only records. Declarative migration edges must form a complete valid route; failures preserve the original opaque payload. Canonical ordering and bounded admission make saves deterministic and hostile inputs reject safely.
In short: **set/get state** for exact current runtime state; **save** for canonical `GraphLayoutV2`; **save data/load** for compatible persistence and replay. Selection, camera, hover, composition revision, and undo/redo internals are not durable graph fields.
+13
View File
@@ -0,0 +1,13 @@
# Worker authority
The worker is authoritative for graph state, composition, validation, command history, hit testing, layout, gestures, and rendering. One root owns one worker and one shared graph, whether it has zero, one, or many attached views. The browser client keeps only bounded host projections. It does not keep a graph shadow.
Graph state, composition, persistence, events, and history belong to the root. Canvas, viewport, camera, selection, gestures, rendering, host requests, and resource authorization belong to a view. A mutation from any view changes the shared graph and schedules every attached view, while cameras and selections remain independent.
The worker serializes view painting and cropping through one atlas canvas and one 2D context. Each attached HTML canvas has its own presentation context; `maxViews` remains a resource bound rather than a rendering-context count.
The application owns the DOM: canvas sizing, listeners, focus policy, menus, dialogs, measurement, and teardown. It turns DOM events into `feedInput()` DTOs. fxnode never registers document/window/canvas listeners or creates controls.
Host requests cross an asynchronous trust boundary. For `resource-open`, the worker emits an immutable descriptor and one-use authorization. The application chooses UI and later calls `provideResource(authorization, data)`. The token is consumed only by a valid accepted submission: failed data validation does **not** consume it, so the application may correct the data and retry. Do not depend on the original pointer's browser activation; ask for a fresh user action when required. Authorizations become stale after relevant graph/composition changes, and transferred `ArrayBuffer`s detach.
This boundary makes worker ordering definitive: await composition dependencies and treat terminal startup/protocol failures as terminal.
+33
View File
@@ -0,0 +1,33 @@
# Examples
The repository has five current experiences. Images below use the examples' existing captured assets—there are no documentation copies.
## Minimal
![A single Number Value node on the fxnode canvas](../../../examples/assets/minimal.png)
_A minimal composition and one node. [Source](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/main.ts)._
## Color Balance
![A Color Balance node with lift, gamma, and gain grading wheels](../../../examples/assets/color-balance.png)
_A focused custom-widget composition. [Source](https://github.com/Heaust-ops/fxnode/blob/main/examples/color-balance/main.ts)._
## Live composition
![A live composition example with a parameter node and upgrade control](../../../examples/assets/live-composition.png)
_Replacing a node definition and migrating its instance. [Source](https://github.com/Heaust-ops/fxnode/blob/main/examples/live-composition/main.ts)._
## Multi-view
![One shared graph rendered in two independent canvas views](../../../examples/assets/multi-view.png)
_One worker and graph with independent cameras and selections. The application-owned toolbar targets the active view, and the canvases forward pointer events only. [Source](https://github.com/Heaust-ops/fxnode/blob/main/examples/multi-view/main.ts)._
## Blender-shaped gallery
The [larger gallery source](https://github.com/Heaust-ops/fxnode/blob/main/examples/blender/main.ts) exercises many node and interaction shapes; it is repository application code, not package authority.
These examples present and persist editable graphs; they do **not evaluate** them. Blender-shaped fixtures and visual regression images do not establish Blender compatibility, behavioral parity, or pixel parity.
+5
View File
@@ -0,0 +1,5 @@
# Accessibility
The editor is bitmap Canvas. Its nodes, sockets, labels, controls, and relationships are not semantic accessibility-tree objects. fxnode makes **no WCAG conformance claim** and must not be the only interface when assistive access is required.
Applications should provide an equivalent semantic DOM workflow, status announcements, instructions, and controls. Keyboard support alone is not accessibility. An application-owned DOM add-node dialog can implement combobox/listbox semantics and focus restoration, but that does not make the Canvas editor accessible.
+20
View File
@@ -0,0 +1,20 @@
# Browser host
`createFxNode` needs application identity/version, resource policies, and optionally a worker URL/history limit. It creates the shared root and worker without a canvas. `root.attachView()` needs a canvas, logical CSS-pixel viewport plus DPR, and optionally an initial camera. The **application owns the DOM and canvas dimensions**. Before attachment, measure the initial layout and set the backing dimensions. For a runtime resize, await `view.setViewport(next)` before updating the backing dimensions. Attach and remove your own listeners.
fxnode creates one module worker per root and never creates a resize observer, menu, modal, or file picker. A root may have no views or multiple views. Convert pointer/keyboard/wheel events to each view's `feedInput()` values. The worker performs authoritative hit testing and may issue view-scoped `add-node-menu` or `resource-open` host requests; your DOM decides presentation and ordering.
Use root methods for composition, state, persistence, subscriptions, and context-free commands. Use view methods for input, viewport changes, selection actions, resource responses, and render checkpoints. A canvas can have only one live view. On teardown, remove application listeners and observers first, detach each view, then destroy the root.
The repository example host defaults to `lifecycle: "explicit"`, so `host.destroy()` removes only host-owned policy
and never detaches its view. Opt in with `lifecycle: "detach-on-disconnect"` for component-style examples. That mode
requires an initially connected canvas and `MutationObserver`; hosts share one observer per document. A removal is
confirmed in a microtask (so a same-task remove/reinsert or reparent survives), then host resources are synchronously
removed before `view.detach()` is requested. Moving the canvas to another document counts as disconnection; hiding it
or giving it zero layout size does not.
Resize observations are coalesced while a viewport request is in flight. The host updates canvas backing dimensions
only after `setViewport()` acknowledges that request. A rejection preserves the prior backing store, reports through
`onError`, and a later observation can retry.
Install composition before initial state. Imported/historical data belongs in `load()`, not `setState()`. See [state and persistence](../concepts/state-and-persistence).
+7
View File
@@ -0,0 +1,7 @@
# Browser support
The certified functional matrix is Chromium and Firefox from Playwright 1.61.1 on desktop Linux. Chromium image goldens are regression tests, not cross-engine or Blender parity tests. WebKit, Safari-branded builds, and mobile are not certified.
The main thread requires module `Worker`, `crypto.randomUUID`, and Canvas 2D. The worker requires `OffscreenCanvas`, a 2D context, cropped `createImageBitmap`, and `ImageBitmap.close`; there is no fallback. Named capability errors identify missing features.
Cross-origin isolation enables an optional `SharedArrayBuffer` pointer lane per view. Without it, normal `postMessage` transport remains functional. Limits include 16 attached views, DPR 4, 8192 logical pixels per dimension, 16,777,216 device pixels per view, 67,108,864 device pixels across all views, and history 1,000.
+5
View File
@@ -0,0 +1,5 @@
# Content Security Policy
fxnode starts a same-origin ES module worker with no blob, classic-worker, or main-thread fallback. Permit it with an appropriate `worker-src 'self'` and `script-src`, and serve JavaScript with the correct MIME type. Pass `workerUrl` if assets move independently.
For optional shared-memory input use `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`; embedded cross-origin resources must satisfy CORS or CORP. Otherwise fxnode automatically uses messages. Worker construction blocked synchronously reports `worker.construct`; a worker script/network/module load failure reports `worker.load`; failure to complete startup in time reports `worker.timeout`. None silently downgrade to main-thread execution.
+5
View File
@@ -0,0 +1,5 @@
# Integration guides
For browser/platform engineers turning an editor bootstrap into a production integration. Start with the [browser host](./browser-host), then wire [interactions](./interactions) and [rendering and lifecycle](./rendering-and-lifecycle). These establish canvas ownership, input forwarding, render checkpoints, and teardown.
Before release, review [browser support](./browser-support), [CSP](./csp), and [accessibility](./accessibility), in that order. The outcome is a host with explicit capability fallbacks, deployable worker policy, accessible DOM-owned controls, and no leaked listeners or workers.
+7
View File
@@ -0,0 +1,7 @@
# Interactions
The host translates DOM input; the worker owns gesture state and commits. Supported editor gestures include movement, box selection, resize, link creation/replacement, Ctrl-right-click cutting, Ctrl-Alt-right-click muting, `M` node mute, `H` collapse, `G` modal move, and undo/redo.
Plain right-click on eligible empty canvas may request an add-node menu. The host owns its HTML, search, grouping, focus, dismissal, and calls `addNode`. Controls follow composition `ui` order. Scrubbing modifiers, text commit/cancel, and reset all become atomic commands.
Do not derive behavior from projected pixels or retain a parallel graph. Subscribe to committed events when application UI needs updates.
@@ -0,0 +1,7 @@
# Rendering and lifecycle
Each view's `whenRendered()` synchronizes a frame for that view. Attach another view when a second canvas needs an independent camera or selection over the same graph; graph mutations schedule all attached views. Set the initial backing dimensions before attachment. At runtime, await that view's `setViewport(next)` first, then update the host canvas backing dimensions.
Keep every listener, observer, menu, and focus behavior in an application-owned cleanup object. On unmount/page teardown, remove those resources, await `view.detach()` for each view, then call `root.destroy()`. The example browser host keeps this explicit by default; its opt-in disconnect policy can perform host cleanup and request detachment when a connected canvas is removed. Destroying that host alone never detaches. View detachment is idempotent and rejects subsequent view work with `FxNodeViewDetachedError`. Root destruction is idempotent, detaches all remaining views, and makes pending and future work reject with `FxNodeDestroyedError`. Fatal startup/protocol failures also release resources and make future calls reject the stored terminal error.
Do not use rendering completion as graph execution completion: fxnode never executes graphs.
+23
View File
@@ -0,0 +1,23 @@
# Learn fxnode
Treat this as a trail map rather than a giant manual.
## Start
Build [your first node](/learn/tutorials/first-node), then tour [all examples](/learn/examples/).
## Build
Use the [browser-host guide](/learn/guides/browser-host) to connect your DOM and the [interaction guide](/learn/guides/interactions) to translate input.
## Understand
Read [worker authority](/learn/concepts/worker-authority), [composition](/learn/concepts/composition), and [state and persistence](/learn/concepts/state-and-persistence).
## Integrate
Check [browser support](/learn/guides/browser-support), [CSP](/learn/guides/csp), lifecycle, and [accessibility](/learn/guides/accessibility) before shipping.
## Reference
When you know the concept and need an exact signature, use the [API Reference](/reference/).
+57
View File
@@ -0,0 +1,57 @@
# Build a Color Balance editor
## What you will build
A focused editor with float/color socket types and the repository's grading-wheel Color Balance definition.
## Prerequisites and checkpoint
Complete [your first node](./first-node). Confirm the empty editor renders before adding the two socket definitions.
## 1. Install dependencies
After `createFxNode`, install theme and styles, then compose `float`, compose `color`, and compose the node. Make `setState` the final bootstrap state call; attach a view, add the node through that view, attach the host, and await the view's `whenRendered()`.
```ts
import { createFxNode } from "fxnode";
const root = await createFxNode({
applicationId: "color.balance",
applicationVersion: 1,
resources: {},
});
await root.setTheme(theme);
await root.setHeaderStyles(styles);
await root.composeSocket(...floatSocket);
await root.composeSocket(...colorSocket);
await root.composeNode(...colorBalanceNode);
await root.setState({ graphId: "color-balance", catalogVersion: 1, nodes: [], links: [], metadata: {} });
const view = await root.attachView({ canvas, viewport });
host.attach(root, view);
await view.addNode({
nodeId: "color-balance",
typeId: colorBalanceNode[0],
viewPosition: { x: 300, y: 40 },
});
await view.whenRendered();
```
This is an **excerpt**: `canvas`, `host`, `viewport`, theme, styles, sockets, and node definition are application-owned setup shown in the working source.
**Checkpoint:** the Color Balance node and its three grading wheels are visible and interactive.
### Why?
Definitions refer to styles and sockets, so dependencies must exist first. The widget edits graph data; fxnode does not perform color correction or execute the graph.
## 2. Attach, verify, and clean up
Attachment starts DOM input forwarding; the view's `whenRendered()` establishes a visible-frame checkpoint. On teardown remove listeners, run `host.destroy()`, await `view.detach()`, and call `root.destroy()` (including startup failure and startup/teardown races).
## Complete example
See [`examples/color-balance/main.ts`](https://github.com/Heaust-ops/fxnode/blob/main/examples/color-balance/main.ts) and the shared [node definition](https://github.com/Heaust-ops/fxnode/blob/main/examples/shared/nodes/color-balance.ts).
## Related concepts / relevant API / next
Read [composition](../concepts/composition), then inspect [`FxNode.composeNode`](/reference/generated/fxnode/interfaces/FxNode#composenode) and continue to [live composition](./live-composition).
+72
View File
@@ -0,0 +1,72 @@
# Your first node
## What you will build
A Canvas editor containing one numeric value node, matching the repository's executable minimal example.
## Prerequisites
Install `fxnode`. Give the canvas non-zero CSS dimensions (the attributes also provide a useful fallback), then prepare a browser host that measures it and forwards input:
```html
<canvas id="graph" width="1000" height="560" style="width: 100%; height: 560px"></canvas>
```
The repository's [small host implementation](https://github.com/Heaust-ops/fxnode/blob/main/examples/shared/browser-host.ts) contains viewport, resize, and input wiring; see [browser host](../guides/browser-host) for its contract.
## Checkpoint
Your canvas has non-zero CSS dimensions and your host has produced `initialViewport`.
## 1. Prepare application-owned definitions
`theme`, `minimalStyles`, `numberSocket`, and `valueNode` below are **application-owned definitions**, not fxnode globals. The socket and node are exported as `[id, definition]` tuples so they can be passed directly to the composition methods. Define or import them before bootstrap; the executable [definition file](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/definition.ts) is the compact reference.
## 2. Bootstrap in dependency order
Create the shared root first, then install composition dependencies in order: theme, header styles, sockets, nodes, and finally graph state. Attach the canvas view after that bootstrap.
```ts
import { createFxNode } from "fxnode";
const root = await createFxNode({
applicationId: "my.first.editor",
applicationVersion: 1,
resources: {},
});
await root.setTheme(theme);
await root.setHeaderStyles(minimalStyles);
await root.composeSocket(...numberSocket);
await root.composeNode(...valueNode);
await root.setState({ graphId: "first", catalogVersion: 1, nodes: [], links: [], metadata: {} });
const view = await root.attachView({ canvas, viewport: host.initialViewport });
host.attach(root, view);
await view.addNode({ nodeId: "value", typeId: valueNode[0], viewPosition: { x: 360, y: 190 } });
await view.whenRendered();
```
**Checkpoint:** a “Number Value” node is visible. The host is attached only after setup, and the view's `whenRendered()` confirms the committed node reached a frame.
### Why this order?
The worker validates every definition against current authority. `setState` is last so known nodes bind against the complete composition. Host attachment follows bootstrap so input cannot race setup.
## 3. Clean up
Remove application listeners, call `host.destroy()`, await `view.detach()`, then call `root.destroy()` on unmount or `pagehide`. Also destroy a late-created root if teardown wins a startup race. The complete source demonstrates that guard.
## Complete example
The complete executable source is [`examples/minimal/main.ts`](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/main.ts), with its [`definition.ts`](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/definition.ts).
## Related concepts
[Composition](../concepts/composition) and [worker authority](../concepts/worker-authority).
## Relevant API
[`createFxNode`](/reference/generated/fxnode/functions/createFxNode), [`FxNode`](/reference/generated/fxnode/interfaces/FxNode), and [`FxNodeView`](/reference/generated/fxnode/interfaces/FxNodeView).
## Next
Build a richer [Color Balance node](./color-balance).
+9
View File
@@ -0,0 +1,9 @@
# Tutorials
For application developers integrating fxnode for the first time. Follow these in order: each tutorial builds on the previous one's host and composition vocabulary. You will finish able to bootstrap a visible editor, install a custom widget, and safely replace a live definition with optimistic concurrency.
1. [Your first node](./first-node) — size and host a canvas, install definitions, render, and tear down.
2. [Color Balance](./color-balance) — add dependency-ordered socket types and a custom widget.
3. [Live composition](./live-composition) — migrate a visible instance using composition receipts.
Each page marks excerpts, establishes ordered checkpoints, explains why each concern exists, and links to a complete executable source.
+47
View File
@@ -0,0 +1,47 @@
# Live composition
## What you will build
An editor that replaces a version-1 node definition with version 2 and migrates its graph instance atomically.
## Prerequisites and checkpoint
Understand [composition](../concepts/composition). Start with the v1 node visible and retain the receipt's `revision`.
## 1. Acquire the v1 revision
```ts
import type { FxNode, FxNodeView } from "fxnode";
const v1Receipt = await api.composeNode("example.live.parameter", liveNodeV1);
let revision = v1Receipt.revision;
```
This is an **excerpt**: await socket dependencies first, compose v1, call `setState`, attach a view, add its instance through that view, attach the host, and render. **Checkpoint:** v1 is visible and `revision` came from its receipt—not a guessed constant.
## 2. Replace it using the v2 receipt
```ts
async function upgrade(root: FxNode, view: FxNodeView) {
const v2Receipt = await root.composeNode("example.live.parameter", liveNodeV2, {
expectedRevision: revision,
});
revision = v2Receipt.revision;
await view.whenRendered();
return v2Receipt;
}
```
Invoke this on an explicit host action. **Checkpoint:** inspect `v2Receipt.status`, `graphChanged`, `graphVersion`, and updated `revision`; the migrated v2 node is visible. Clean up the button/page listeners, host, and API on teardown.
### Why?
Composition revision and graph version are separate concurrency domains. A committed rebind can advance both; a no-op advances neither. Compare-and-swap prevents two writers from assuming the same authority.
## Complete example
See the working [`examples/live-composition/main.ts`](https://github.com/Heaust-ops/fxnode/blob/main/examples/live-composition/main.ts) and its [definitions](https://github.com/Heaust-ops/fxnode/blob/main/examples/live-composition/definitions.ts).
## Related concepts / relevant API / next
Read [graph state and events](../concepts/graph-state-and-events) and [`CompositionReceipt`](/reference/generated/fxnode/type-aliases/CompositionReceipt), then plan [lifecycle cleanup](../guides/rendering-and-lifecycle).
+9
View File
@@ -0,0 +1,9 @@
# API Reference
fxnode exposes exactly three package entrypoints:
1. [`fxnode`](/reference/generated/fxnode/) — browser client, shared graph types, and composition authoring.
2. [`fxnode/headless`](/reference/generated/fxnode/headless/) — composition-bound decoding and command execution without browser resources.
3. [`fxnode/widgets/color-ramp`](/reference/generated/fxnode/widgets/color-ramp/) — immutable color-ramp model and operations.
The generated pages describe exact signatures. For intent, ordering, and ownership, return to [Learn](/learn/) or start with [your first node](/learn/tutorials/first-node). Integration guidance lives in [browser host](/learn/guides/browser-host), while [composition](/learn/concepts/composition) and [state and persistence](/learn/concepts/state-and-persistence) explain the two main data boundaries.
+13
View File
@@ -0,0 +1,13 @@
# Capturing Blender references
Use the official Linux Blender 4.5.0 binary whose archive SHA-256 is recorded in the manifest. A graphical X11 session (a disposable Xvfb session is suitable) and a whole-window capture utility are required.
For each of the eight IDs:
1. Start Blender at a deterministic 1440×900 window size with factory settings.
2. Run `blender --python tools/blender/create-reference-fixtures.py -- --fixture <id>` (add `--save /tmp/<id>.blend` if desired).
3. Wait for redraw, keep the entire Blender window—including chrome—visible, and capture it to `docs/research/blender-references/4.5.0/<id>.png`. For the hover fixture, move the pointer over the active node title before capture; this interaction cannot honestly be synthesized by Blender's data API.
4. Record UTC capture time, pixel dimensions, and `sha256sum` in `src/research/reference-manifest.ts`, change status to `captured`, and set capture method to `self-captured-blender-window`.
5. Run `npm run check:references:strict`.
The script validates Blender 4.5.x, rebuilds the current file, creates material or geometry node trees, lays nodes out deterministically, configures editor zoom, and saves only when asked. Generated `.blend` files are ignored and are not reference artifacts.
@@ -0,0 +1,3 @@
# Blender 4.5.0 self-captures
This directory intentionally contains no PNGs yet. Eight expected paths and their honest pending reasons are recorded in `src/research/reference-manifest.ts`. Do not substitute downloaded images or screenshots from the Blender Manual.
+20
View File
@@ -0,0 +1,20 @@
# Blender 4.5 research freeze
## Reproducible baseline
- Blender version: **4.5.0**
- Source commit: [`8cb6b388974a817afedf1317ce26f0c75aa5f181`](https://projects.blender.org/blender/blender/src/commit/8cb6b388974a817afedf1317ce26f0c75aa5f181)
- Official binary SHA-256: `1188b95cc12321c770b631939f7c25a096910b6f884a990bf9c0f62d52b38aec`
- Manual snapshot: [`f72fe39427bf150242dd6cfdd94d902e535d2286`](https://projects.blender.org/blender/blender-manual/src/commit/f72fe39427bf150242dd6cfdd94d902e535d2286)
Source citations are commit-pinned so later UI changes cannot silently alter the baseline. Relevant implementation entry points used only for behavioral study are [node drawing](https://projects.blender.org/blender/blender/src/commit/8cb6b388974a817afedf1317ce26f0c75aa5f181/source/blender/editors/space_node/node_draw.cc) and [node editor space](https://projects.blender.org/blender/blender/src/commit/8cb6b388974a817afedf1317ce26f0c75aa5f181/source/blender/editors/space_node/space_node.cc).
## Clean-room observations
The node editor presents a zoomable canvas. Nodes use title bars, body panels, labeled input/output sockets, links, selection/active emphasis, collapse controls, and inline controls where a socket is not linked. Frames group nodes visually; reroutes reshape link paths. Shader and geometry trees share interaction conventions while exposing domain-specific socket types and node content.
fxnode derives only functional observations and independently measured self-captures. No Blender source code, assets, icons, fonts, manual prose, or manual screenshots are copied into this repository. Names required for compatibility and factual citations are retained. See `NOTICE.md` and the typed reference manifest.
## Reference state
The eight requested captures are represented in `src/research/reference-manifest.ts`. They remain truthfully `pending` because this orb had neither Blender nor Xvfb. The normal Phase 1 check validates this state; the strict check fails until genuine captures and metadata are committed.
+60
View File
@@ -0,0 +1,60 @@
<!-- Repository research; excluded from the documentation site. -->
# Blender parity survey
## Current parity matrix
| Area | Status | Evidence and limits |
| ------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Uniform ordinary controls | Implemented | Number, integer, enum, boolean, string, and vector fields share composition-ordered widget-unit rows. Colors use compact swatches backed by a worker-rendered Oklch/RGBA/HSV/hex picker. Compound widgets intentionally span multiple rows. |
| Field reset | Implemented | Backspace resets the focused or hovered editable field; linked/read-only fields are inert; reset is one-step undoable. |
| Color Ramp | Implemented structurally and behaviorally | Browser-tested stop selection/drag, add/remove, modes, interpolation, hue mode, popup color edits, flip, distribute, reset, cancellation, and undo. Eyedropper integration remains unavailable. |
| Noise Texture | Implemented structurally | All Blender 4.5 dimensions/type visibility combinations are exhaustively tested. fxnode does not evaluate noise. |
| Shader Image Texture | Implemented as editor intent | Local image selection, worker decoding/thumbnail rendering, projection/interpolation/extension, and Box-only Blend are browser-tested. Texture evaluation remains outside scope. |
| Compositor Image | Partial by design | Image-user fields are represented and browser-tested. Dynamic multilayer/view/pass sockets require host resource metadata. |
| Color Balance | Implemented structurally | Lift/Gamma/Gain, Offset/Power/Slope, and White Point layouts are browser-tested. “Master Color Grading” is an example label, not a Blender type. |
| Knife and link mute | Implemented | Ctrl-RMB and Ctrl-Alt-RMB freehand gestures are browser-tested as one atomic version/history entry. |
| Node mute | Implemented | `M` grays every ordinary known node; declared compatible pairs additionally receive derived red bypass curves. There is no graph evaluation. |
| Pixel-exact Blender appearance | Not established | Blender captures remain 0/8 pending. Playwright images are fxnode regression and structural-parity evidence only. |
## Image Texture and Compositor Image
References: Blender 4.5's [Image Texture manual](https://docs.blender.org/manual/en/4.5/render/shader_nodes/textures/image.html), [Compositor Image manual](https://docs.blender.org/manual/en/4.5/compositing/types/input/image.html), [`ShaderNodeTexImage` RNA](https://docs.blender.org/api/4.5/bpy.types.ShaderNodeTexImage.html), and [`CompositorNodeImage` RNA](https://docs.blender.org/api/4.5/bpy.types.CompositorNodeImage.html).
These are intentionally distinct example composition definitions. Image Texture persists an image reference plus interpolation, projection (including Box-only Blend), extension, editor color-space intent, alpha mode, Vector input, and Color/Alpha outputs. Compositor Image persists its data-block reference and source; Movie/Sequence expose frame count, start, offset, cyclic, and auto-refresh. It has only static Image/Alpha/Z outputs. Resource controls synchronously open a host file chooser, transfer bytes into the worker, decode there, and render a bounded-cache thumbnail. The graph stores only a serializable local reference—not image bytes—so loading that graph in a fresh editor shows the filename/unavailable state until the user reopens the file. Neither node evaluates image pixels. Dynamic multilayer/render passes require a future host metadata provider and are not fabricated.
## Color Balance / “Master Color Grading” example
References: Blender 4.5's [Color Balance manual](https://docs.blender.org/manual/en/4.5/compositing/types/color/adjust/color_balance.html), [`CompositorNodeColorBalance` RNA](https://docs.blender.org/api/4.5/bpy.types.CompositorNodeColorBalance.html), and [commit-pinned Blender compositor node source](https://projects.blender.org/blender/blender/src/commit/8cb6b388974a817afedf1317ce26f0c75aa5f181/source/blender/nodes/composite/nodes/node_composite_colorbalance.cc).
The example definition remains **Color Balance**. “Master Color Grading” is only the parity fixture's custom instance label; Blender has no core Master node. Lift/Gamma/Gain and Offset/Power/Slope use paired scalar/color rows. White Point exposes Input and Output temperature, tint, and color sections. Eyedroppers are visibly disabled placeholders pending a host bridge. This is presentation and persistence only; fxnode performs no compositing evaluation.
## Color Ramp
References: [Blender 4.5 Color Ramp manual](https://docs.blender.org/manual/en/4.5/modeling/geometry_nodes/utilities/color_ramp.html), [Blender 4.5 `ColorRamp` RNA](https://docs.blender.org/api/4.5/bpy.types.ColorRamp.html), and [commit-pinned Blender source](https://projects.blender.org/blender/blender/src/commit/8cb6b388974a817afedf1317ce26f0c75aa5f181/source/blender/editors/interface/templates/interface_template_color_ramp.cc).
The V2 persisted value records RGB/HSV/HSL mode, all five interpolation modes, hue interpolation, and two to 32 sorted, identified RGBA stops. Legacy arrays receive stable index-derived IDs. Worker-owned interactions cover overlapping selection, sampled insertion, Blender-style midpoint insertion, removal, clamped/reordering movement, RGBA updates, flip, even distribution, descriptor reset, cancellation, and one-step undo. The compound row owns toolbar/menu, checker-gradient, handle, selector, position, and color bounds; stops are not sockets. Active-stop selection remains transient and is not serialized.
Deferred Blender details: eyedropper, precise Blender cardinal/B-spline kernels and HSL conversion, context popup styling, and keyboard navigation within popup menus.
## Noise Texture (Blender 4.5)
References: [Blender 4.5 Noise Texture manual](https://docs.blender.org/manual/en/4.5/render/shader_nodes/textures/noise.html) and [Blender 4.5 ShaderNodeTexNoise RNA](https://docs.blender.org/api/4.5/bpy.types.ShaderNodeTexNoise.html).
Dimensions and fractal Type drive V2 `in`/`equals` visibility expressions. Vector is shown for 2D/3D/4D, W for 1D/4D, Normalize only for fBM, Offset for Hybrid/Ridged/Hetero, and Gain for Hybrid/Ridged. Links and defaults remain in the document while their rows are hidden.
## Link knife and mute
`Ctrl`+RMB draws a captured freehand knife and atomically removes every crossed visible effective link. `Ctrl`+`Alt`+RMB uses the same gesture to toggle authored link mute instead; muted and reroute-propagated links are red and do not suppress input defaults. Escape, pointer cancellation, and blur cancel without history. Each completed gesture is one version/event/history entry.
`M` toggles mute for selected ordinary known nodes, including generators. Every muted node receives a clipped neutral overlay. Math, Vector Math, Mix, Set Position, and Transform Geometry additionally show explicitly declared, type-compatible red bypass curves; generator and type-incompatible nodes intentionally have no fabricated bypass. Bypasses are layout-only; fxnode still does not evaluate graphs. Collapse state is immediate and undoable while the header chevron rotates between expanded/down and collapsed/right with a short worker-owned animation.
### Shortcut table
| Shortcut | Behavior |
| --------------------- | ------------------------------------- |
| RMB on empty canvas | Open searchable add-node dialog |
| `Ctrl`+RMB drag | Knife/remove crossed effective links |
| `Ctrl`+`Alt`+RMB drag | Toggle mute on crossed authored links |
| `M` | Toggle selected node mute |
| `Escape` | Cancel active gesture silently |
+24
View File
@@ -0,0 +1,24 @@
<!-- Repository research; excluded from the documentation site. -->
# Phase 7 spatial performance
The deterministic stress workload contains exactly **5,000 node rectangles** and
**10,000 link rectangles** (seed 7). Nodes are arranged on a sparse 100 × 50
grid and links connect nearby nodes. The query viewport is 1200 × 800 logical
pixels at DPR 2. CI runs `npm run check:performance`; it checks exact totals and
requires at least 90% culling at candidate p95. Timing is reported, never gated.
Measured in the Amp orb on 2026-07-20: Node 20.9.0, Playwright/Chromium 1.49.1,
generic orb CPU (Intel Xeon 2.60 GHz). Across 100 deterministic viewport queries:
| metric | result |
| -------------- | ----------: |
| index build | 32.604 ms |
| candidates p50 | 72 / 15,000 |
| candidates p95 | 75 / 15,000 |
| query p50 | 0.295 ms |
| query p95 | 0.431 ms |
| p95 culling | 99.50% |
These values are one-orb observations, not performance guarantees. Candidate
counts and the 90% ratio are deterministic; elapsed times vary by host load.
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>fxnode all supported</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<canvas id="graph"></canvas>
<script type="module" src="./main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
import { createApplicationFxNode } from "../application-browser.js";
import { prepareFxNodeBrowserHost } from "../../shared/browser-host.js";
import initialLayout from "./initialLayout.json" with { type: "json" };
const canvas = document.querySelector("canvas")!,
host = prepareFxNodeBrowserHost({ canvas });
let root: Awaited<ReturnType<typeof createApplicationFxNode>> | undefined,
view: Awaited<ReturnType<NonNullable<typeof root>["attachView"]>> | undefined;
try {
root = await createApplicationFxNode();
await root.setState(initialLayout);
view = await root.attachView({ canvas, viewport: host.initialViewport });
host.attach(root, view);
(window as unknown as { fxnodeExample: unknown }).fxnodeExample = { root, view };
await view.whenRendered();
} catch (error) {
host.destroy();
await view?.detach();
root?.destroy();
throw error;
}
+13
View File
@@ -0,0 +1,13 @@
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #202124;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
+86
View File
@@ -0,0 +1,86 @@
import { createFxNode, type CreateFxNodeOptions, type FxNode } from "@lib/index.js";
import {
APPLICATION_HEADER_STYLES,
APPLICATION_ID,
APPLICATION_RESOURCES,
APPLICATION_VERSION,
applicationCompatibility,
} from "./nodes/application.js";
import { exampleTheme } from "../shared/theme.js";
import { frameNode } from "./nodes/common/frame.js";
import { rerouteNode } from "./nodes/common/reroute.js";
import {
anySocket,
floatSocket,
vectorSocket,
colorSocket,
shaderSocket,
geometrySocket,
} from "./nodes/socket-types.js";
import { groupInputNode } from "./nodes/common/group-input.js";
import { groupOutputNode } from "./nodes/common/group-output.js";
import { valueNode } from "./nodes/shader/value.js";
import { colorNode } from "./nodes/shader/color.js";
import { mathNode } from "./nodes/shader/math.js";
import { vectorMathNode } from "./nodes/shader/vector-math.js";
import { mixNode } from "./nodes/shader/mix.js";
import { colorRampNode } from "./nodes/shader/color-ramp.js";
import { textureCoordinateNode } from "./nodes/shader/texture-coordinate.js";
import { noiseTextureNode } from "./nodes/shader/noise-texture.js";
import { imageTextureNode } from "./nodes/shader/image-texture.js";
import { principledBsdfNode } from "./nodes/shader/principled-bsdf.js";
import { materialOutputNode } from "./nodes/shader/material-output.js";
import { positionNode } from "./nodes/geometry/position.js";
import { meshCubeNode } from "./nodes/geometry/mesh-cube.js";
import { setPositionNode } from "./nodes/geometry/set-position.js";
import { transformGeometryNode } from "./nodes/geometry/transform-geometry.js";
import { joinGeometryNode } from "./nodes/geometry/join-geometry.js";
import { imageNode } from "./nodes/compositor/image.js";
import { colorBalanceNode } from "../shared/nodes/color-balance.js";
export type ApplicationFxNode = FxNode;
type ApplicationBrowserOptions = Omit<CreateFxNodeOptions, "applicationId" | "applicationVersion" | "resources">;
export async function createApplicationFxNode(options: ApplicationBrowserOptions = {}): Promise<ApplicationFxNode> {
const api = await createFxNode({
...options,
applicationId: APPLICATION_ID,
applicationVersion: APPLICATION_VERSION,
resources: APPLICATION_RESOURCES,
});
try {
await api.setTheme(exampleTheme);
await api.setHeaderStyles(APPLICATION_HEADER_STYLES);
await api.composeSocket(...anySocket);
await api.composeSocket(...floatSocket);
await api.composeSocket(...vectorSocket);
await api.composeSocket(...colorSocket);
await api.composeSocket(...shaderSocket);
await api.composeSocket(...geometrySocket);
await api.setCompatibility(applicationCompatibility);
await api.composeNode(...frameNode);
await api.composeNode(...rerouteNode);
await api.composeNode(...groupInputNode);
await api.composeNode(...groupOutputNode);
await api.composeNode(...valueNode);
await api.composeNode(...colorNode);
await api.composeNode(...mathNode);
await api.composeNode(...vectorMathNode);
await api.composeNode(...mixNode);
await api.composeNode(...colorRampNode);
await api.composeNode(...textureCoordinateNode);
await api.composeNode(...noiseTextureNode);
await api.composeNode(...imageTextureNode);
await api.composeNode(...principledBsdfNode);
await api.composeNode(...materialOutputNode);
await api.composeNode(...positionNode);
await api.composeNode(...meshCubeNode);
await api.composeNode(...setPositionNode);
await api.composeNode(...transformGeometryNode);
await api.composeNode(...joinGeometryNode);
await api.composeNode(...imageNode);
await api.composeNode(...colorBalanceNode);
return api;
} catch (error) {
api.destroy();
throw error;
}
}
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>FxNode controls</title>
<style>
html,
body {
margin: 0;
background: #111;
}
canvas {
display: block;
width: 1200px;
height: 640px;
}
</style>
</head>
<body>
<canvas id="controls"></canvas>
<script type="module" src="./main.ts"></script>
</body>
</html>
@@ -0,0 +1,293 @@
{
"graphId": "control-test",
"catalogVersion": 4,
"nodes": [
{
"id": "color",
"typeId": "fxnode.shader.color",
"typeVersion": 1,
"known": true,
"position": {
"x": 300,
"y": 200
},
"size": {
"x": 151,
"y": 100
},
"label": "Color",
"parameters": {
"color": {
"kind": "color",
"value": [
0.8,
0.8,
0.8,
1
]
}
},
"sockets": [
{
"id": "color:color",
"key": "color",
"label": "Color",
"direction": "output",
"dataType": "color",
"accepts": [],
"maxIncomingLinks": 0,
"visible": true
}
],
"muted": false,
"collapsed": false,
"extensions": {}
},
{
"id": "group",
"typeId": "fxnode.common.group-input",
"typeVersion": 1,
"known": true,
"position": {
"x": -100,
"y": -100
},
"size": {
"x": 257,
"y": 100
},
"label": "Group Input",
"parameters": {
"interfaceName": {
"kind": "string",
"value": "Socket"
}
},
"sockets": [
{
"id": "group:output",
"key": "output",
"label": "Interface",
"direction": "output",
"dataType": "any",
"accepts": [],
"maxIncomingLinks": 0,
"visible": true
}
],
"muted": false,
"collapsed": false,
"extensions": {}
},
{
"id": "math",
"typeId": "fxnode.shader.math",
"typeVersion": 1,
"known": true,
"position": {
"x": -250,
"y": 200
},
"size": {
"x": 192,
"y": 148
},
"label": "Math",
"parameters": {
"operation": {
"kind": "string",
"value": "add"
},
"clamp": {
"kind": "boolean",
"value": false
}
},
"sockets": [
{
"id": "math:a",
"key": "a",
"label": "A",
"direction": "input",
"dataType": "float",
"accepts": [
"float",
"any"
],
"maxIncomingLinks": 1,
"visible": true,
"defaultValue": {
"kind": "number",
"value": 0
}
},
{
"id": "math:b",
"key": "b",
"label": "B",
"direction": "input",
"dataType": "float",
"accepts": [
"float",
"any"
],
"maxIncomingLinks": 1,
"visible": true,
"defaultValue": {
"kind": "number",
"value": 0
}
},
{
"id": "math:value",
"key": "value",
"label": "Value",
"direction": "output",
"dataType": "float",
"accepts": [],
"maxIncomingLinks": 0,
"visible": true
}
],
"muted": false,
"collapsed": false,
"extensions": {}
},
{
"id": "value",
"typeId": "fxnode.shader.value",
"typeVersion": 1,
"known": true,
"position": {
"x": -500,
"y": 200
},
"size": {
"x": 151,
"y": 100
},
"label": "Value",
"parameters": {
"value": {
"kind": "number",
"value": 0
}
},
"sockets": [
{
"id": "value:value",
"key": "value",
"label": "Value",
"direction": "output",
"dataType": "float",
"accepts": [],
"maxIncomingLinks": 0,
"visible": true
}
],
"muted": false,
"collapsed": false,
"extensions": {}
},
{
"id": "vector",
"typeId": "fxnode.shader.vector-math",
"typeVersion": 1,
"known": true,
"position": {
"x": 20,
"y": 200
},
"size": {
"x": 340,
"y": 148
},
"label": "Vector Math",
"parameters": {
"operation": {
"kind": "string",
"value": "add"
}
},
"sockets": [
{
"id": "vector:a",
"key": "a",
"label": "A",
"direction": "input",
"dataType": "vector",
"accepts": [
"vector",
"any"
],
"maxIncomingLinks": 1,
"visible": true,
"defaultValue": {
"kind": "vector",
"value": [
0,
0,
0
]
}
},
{
"id": "vector:b",
"key": "b",
"label": "B",
"direction": "input",
"dataType": "vector",
"accepts": [
"vector",
"any"
],
"maxIncomingLinks": 1,
"visible": true,
"defaultValue": {
"kind": "vector",
"value": [
0,
0,
0
]
}
},
{
"id": "vector:vector",
"key": "vector",
"label": "Vector",
"direction": "output",
"dataType": "vector",
"accepts": [],
"maxIncomingLinks": 0,
"visible": true
},
{
"id": "vector:value",
"key": "value",
"label": "Value",
"direction": "output",
"dataType": "float",
"accepts": [],
"maxIncomingLinks": 0,
"visible": true
}
],
"muted": false,
"collapsed": false,
"extensions": {}
}
],
"links": [
{
"id": "value-math",
"fromNodeId": "value",
"fromSocketId": "value:value",
"toNodeId": "math",
"toSocketId": "math:a",
"muted": false,
"extensions": {}
}
],
"metadata": {}
}
+30
View File
@@ -0,0 +1,30 @@
import { createApplicationFxNode, type ApplicationFxNode } from "../application-browser.js";
import type { FxNodeView } from "@lib/index.js";
import { prepareFxNodeBrowserHost } from "../../shared/browser-host.js";
import initialLayout from "./initialLayout.json" with { type: "json" };
const canvas = document.querySelector<HTMLCanvasElement>("#controls");
if (!canvas) throw new Error("Control test canvas missing");
const host = prepareFxNodeBrowserHost({ canvas });
const handle: { root: ApplicationFxNode | null; view: FxNodeView | null; ready: Promise<void> } = {
root: null,
view: null,
ready: Promise.resolve(),
};
window.controlTest = handle;
handle.ready = (async () => {
let root: ApplicationFxNode | undefined, view: FxNodeView | undefined;
try {
root = await createApplicationFxNode();
await root.setState(initialLayout);
view = await root.attachView({ canvas, viewport: host.initialViewport });
handle.root = root;
handle.view = view;
host.attach(root, view);
await view.whenRendered();
} catch (error) {
host.destroy();
await view?.detach();
root?.destroy();
throw error;
}
})();
+18
View File
@@ -0,0 +1,18 @@
import type { FxNode, FxNodeView } from "@lib/index.js";
import type { PreparedFxNodeBrowserHost } from "../shared/browser-host.js";
declare global {
interface FxNodeExampleHandle {
root: FxNode | null;
view: FxNodeView | null;
host: PreparedFxNodeBrowserHost;
ready: Promise<void>;
readonly rendered: Promise<void>;
}
interface Window {
fxnodeExample: FxNodeExampleHandle;
linkToolsTest: { root: FxNode | null; view: FxNodeView | null; ready: Promise<void> };
}
}
export {};

Some files were not shown because too many files have changed in this diff Show More