diff --git a/level-editor/src/lib.rs b/level-editor/src/lib.rs index 56fdb9c..6ade8a1 100644 --- a/level-editor/src/lib.rs +++ b/level-editor/src/lib.rs @@ -6,7 +6,7 @@ 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::render_data::{InstanceType, MeshCreateInfo, RenderData}; use renderer::renderer as gpu_renderer; use renderer::renderer::gpu_scene::vertex_layouts; use renderer::renderer::scene::FrameMetadata; @@ -193,8 +193,9 @@ impl EditorScene { indices: Self::INDICES, pipeline: pipeline_index, material: renderer::render_data::MaterialKey::DEFAULT, - flags: RenderFlags::VISIBLE, - default_instance_flags: RenderFlags::VISIBLE, + default_instance_type: InstanceType { + words: [1 | 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, default_transform: transform, }) .expect("ground plane geometry is valid"); diff --git a/renderer/src/command_ring.rs b/renderer/src/command_ring.rs index d03d088..2fdab5f 100644 --- a/renderer/src/command_ring.rs +++ b/renderer/src/command_ring.rs @@ -2,12 +2,12 @@ use std::sync::atomic::{AtomicU32, Ordering}; pub const MAGIC: u32 = u32::from_le_bytes(*b"YAWN"); -pub const VERSION: u32 = 1; +pub const VERSION: u32 = 2; pub const CAPACITY: usize = 1024; -pub const SLOT_WORDS: usize = 24; -pub const SLOT_BYTES: usize = 96; +pub const SLOT_WORDS: usize = 40; +pub const SLOT_BYTES: usize = 160; pub const HEADER_BYTES: usize = 64; -pub const SLOT_VERSION: u32 = 1; +pub const SLOT_VERSION: u32 = 2; const STATE_OPEN: u32 = 0; const STATE_CORRUPT: u32 = 1; @@ -121,7 +121,7 @@ mod tests { #[test] fn malformed_slot_fails_closed() { for (version, request, expected) in [ - (2, 1, RingError::SlotVersion), + (SLOT_VERSION + 1, 1, RingError::SlotVersion), (SLOT_VERSION, 0, RingError::ZeroRequest), ] { let ring = CommandRing::new(); diff --git a/renderer/src/gltf.rs b/renderer/src/gltf.rs index 0e6747a..d61cf85 100644 --- a/renderer/src/gltf.rs +++ b/renderer/src/gltf.rs @@ -4,8 +4,8 @@ use gltf::Gltf; use ultraviolet::{Mat4, Vec3}; use crate::render_data::{ - InstanceHandle, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey, - RenderData, RenderDataError, RenderFlags, + InstanceHandle, InstanceType, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform, + PipelineKey, RenderData, RenderDataError, }; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -549,8 +549,26 @@ pub fn install_imported( indices: &geometry.indices, pipeline: pipelines[usize::from(geometry.double_sided)], material: geometry.material, - flags: RenderFlags::VISIBLE, - default_instance_flags: RenderFlags::VISIBLE, + default_instance_type: InstanceType { + words: [ + 1 | 4 | (geometry.double_sided as u32) * 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + }, default_transform: transform, })?; handles.insert(geometry.key, created.mesh); @@ -570,10 +588,11 @@ pub fn install_imported( .get(&occurrence.key) .ok_or_else(|| ImportError::InvalidPrimitive("occurrence has no geometry".into()))?; if consumed.insert(occurrence.key, ()).is_some() { + let instance_type = stage.mesh(mesh).unwrap().default_instance_type; instance_handles.push(stage.create_instance( mesh, occurrence.transform, - RenderFlags::VISIBLE, + instance_type, )?); } let geometry = geometries @@ -584,7 +603,7 @@ pub fn install_imported( let point = transform.transform_point3(Vec3::from(*position)); [point.x, point.y, point.z] })); - let local = stage.mesh(mesh).unwrap().aabb; + let local = stage.mesh(mesh).unwrap().local_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]] { diff --git a/renderer/src/render_data/mod.rs b/renderer/src/render_data/mod.rs index 0ae8ffe..0ca4504 100644 --- a/renderer/src/render_data/mod.rs +++ b/renderer/src/render_data/mod.rs @@ -4,7 +4,7 @@ mod range_allocator; pub use handle::{InstanceHandle, MeshHandle}; use std::{ - ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, Deref}, + ops::Deref, sync::atomic::{AtomicU64, Ordering}, }; @@ -58,50 +58,36 @@ impl MaterialKey { } } -#[repr(transparent)] -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct RenderFlags(u32); +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)] +pub struct InstanceType { + pub words: [u32; 16], +} -impl RenderFlags { - pub const NONE: Self = Self(0); - pub const VISIBLE: Self = Self(1); +impl InstanceType { + pub const VISIBLE_MASK: u32 = 1; + pub const ZERO: Self = Self { words: [0; 16] }; + pub const VISIBLE: Self = Self { + words: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }; - pub const fn from_bits_retain(bits: u32) -> Self { - Self(bits) + pub const fn is_visible(self) -> bool { + self.words[0] & Self::VISIBLE_MASK != 0 } - - pub const fn bits(self) -> u32 { - self.0 - } - - pub const fn contains(self, other: Self) -> bool { - self.0 & other.0 == other.0 + pub fn set_visible(&mut self, visible: bool) { + self.words[0] = (self.words[0] & !Self::VISIBLE_MASK) | visible as u32; } } -impl BitOr for RenderFlags { - type Output = Self; - fn bitor(self, rhs: Self) -> Self { - Self(self.0 | rhs.0) - } -} -impl BitOrAssign for RenderFlags { - fn bitor_assign(&mut self, rhs: Self) { - self.0 |= rhs.0; - } -} -impl BitAnd for RenderFlags { - type Output = Self; - fn bitand(self, rhs: Self) -> Self { - Self(self.0 & rhs.0) - } -} -impl BitAndAssign for RenderFlags { - fn bitand_assign(&mut self, rhs: Self) { - self.0 &= rhs.0; +impl Default for InstanceType { + fn default() -> Self { + Self::VISIBLE } } +const _: [(); 64] = [(); std::mem::size_of::()]; +const _: [(); 4] = [(); std::mem::align_of::()]; + #[derive(Clone, Copy, Debug, PartialEq)] pub struct Aabb { pub min: [f32; 3], @@ -124,8 +110,7 @@ pub struct MeshCreateInfo<'a> { pub indices: &'a [u32], pub pipeline: PipelineKey, pub material: MaterialKey, - pub flags: RenderFlags, - pub default_instance_flags: RenderFlags, + pub default_instance_type: InstanceType, pub default_transform: ModelTransform, } @@ -141,8 +126,8 @@ pub struct MeshView { pub geometry: GeometryRange, pub pipeline: PipelineKey, pub material: MaterialKey, - pub flags: RenderFlags, - pub aabb: Aabb, + pub default_instance_type: InstanceType, + pub local_aabb: Aabb, pub default_instance: InstanceHandle, } @@ -152,7 +137,7 @@ pub struct InstanceView { pub mesh: MeshHandle, pub model: ModelTransform, pub normal: NormalMatrix, - pub flags: RenderFlags, + pub instance_type: InstanceType, pub is_default: bool, } @@ -311,7 +296,7 @@ struct MeshSoa { index_counts: Vec, pipeline_keys: Vec, material_keys: Vec, - flags: Vec, + default_instance_types: Vec, aabb_mins: Vec<[f32; 3]>, aabb_maxs: Vec<[f32; 3]>, default_instance_slots: Vec, @@ -329,7 +314,7 @@ struct InstanceSoa { normal_col_0: Vec<[f32; 3]>, normal_col_1: Vec<[f32; 3]>, normal_col_2: Vec<[f32; 3]>, - flags: Vec, + instance_types: Vec, } pub struct RenderData { @@ -369,9 +354,9 @@ impl ReplacementStage { &mut self, mesh: MeshHandle, model: ModelTransform, - flags: RenderFlags, + instance_type: InstanceType, ) -> Result { - self.data.create_instance(mesh, model, flags) + self.data.create_instance(mesh, model, instance_type) } } @@ -595,7 +580,7 @@ impl RenderData { }, info.pipeline, info.material, - info.flags, + info.default_instance_type, bounds, default_instance, ); @@ -604,7 +589,7 @@ impl RenderData { mesh, info.default_transform, normal, - info.default_instance_flags, + info.default_instance_type, ); self.revision = next_revision; Ok(CreatedMesh { @@ -617,19 +602,20 @@ impl RenderData { &mut self, mesh: MeshHandle, model: ModelTransform, - flags: RenderFlags, + instance_type: InstanceType, ) -> Result { if !self.meshes.slots.contains(mesh.slot(), mesh.generation()) { return Err(RenderDataError::InvalidMeshHandle); } let normal = normal_matrix(model)?; - affine_world_aabb(self.mesh(mesh).unwrap().aabb, model)?; + affine_world_aabb(self.mesh(mesh).unwrap().local_aabb, model)?; let next_revision = self.next_revision()?; let required = self.instances.slots.required_len_for_prepare()?; self.instances.reserve(required)?; let prepared = self.instances.slots.prepare()?; let handle = InstanceHandle::from_parts(prepared.slot, prepared.generation); - self.instances.commit(prepared, mesh, model, normal, flags); + self.instances + .commit(prepared, mesh, model, normal, instance_type); self.revision = next_revision; Ok(handle) } @@ -699,10 +685,10 @@ impl RenderData { Ok(()) } - pub fn set_mesh_flags( + pub fn set_mesh_visible( &mut self, handle: MeshHandle, - flags: RenderFlags, + visible: bool, ) -> Result<(), RenderDataError> { if !self .meshes @@ -711,16 +697,24 @@ impl RenderData { { return Err(RenderDataError::InvalidMeshHandle); } + let owned: Vec = self + .instances + .slots + .occupied() + .filter_map(|(slot, _)| (self.instances.mesh_handle(slot) == handle).then_some(slot)) + .collect(); let next_revision = self.next_revision()?; - self.meshes.flags[handle.slot() as usize] = flags; + for slot in owned { + self.instances.instance_types[slot as usize].set_visible(visible); + } self.revision = next_revision; Ok(()) } - pub fn set_instance_flags( + pub fn set_instance_type( &mut self, handle: InstanceHandle, - flags: RenderFlags, + instance_type: InstanceType, ) -> Result<(), RenderDataError> { if !self .instances @@ -730,7 +724,25 @@ impl RenderData { return Err(RenderDataError::InvalidInstanceHandle); } let next_revision = self.next_revision()?; - self.instances.flags[handle.slot() as usize] = flags; + self.instances.instance_types[handle.slot() as usize] = instance_type; + self.revision = next_revision; + Ok(()) + } + + pub fn set_instance_visible( + &mut self, + handle: InstanceHandle, + visible: bool, + ) -> Result<(), RenderDataError> { + if !self + .instances + .slots + .contains(handle.slot(), handle.generation()) + { + return Err(RenderDataError::InvalidInstanceHandle); + } + let next_revision = self.next_revision()?; + self.instances.instance_types[handle.slot() as usize].set_visible(visible); self.revision = next_revision; Ok(()) } @@ -749,7 +761,7 @@ impl RenderData { } let normal = normal_matrix(model)?; let mesh = self.instances.mesh_handle(handle.slot()); - affine_world_aabb(self.mesh(mesh).unwrap().aabb, model)?; + affine_world_aabb(self.mesh(mesh).unwrap().local_aabb, model)?; let next_revision = self.next_revision()?; self.instances.set_transform(handle.slot(), model, normal); self.revision = next_revision; @@ -890,7 +902,7 @@ impl MeshSoa { index_counts: Vec::new(), pipeline_keys: Vec::new(), material_keys: Vec::new(), - flags: Vec::new(), + default_instance_types: Vec::new(), aabb_mins: Vec::new(), aabb_maxs: Vec::new(), default_instance_slots: Vec::new(), @@ -910,7 +922,7 @@ impl MeshSoa { reserve_vec(&mut self.index_counts, target, "meshes")?; reserve_vec(&mut self.pipeline_keys, target, "meshes")?; reserve_vec(&mut self.material_keys, target, "meshes")?; - reserve_vec(&mut self.flags, target, "meshes")?; + reserve_vec(&mut self.default_instance_types, target, "meshes")?; reserve_vec(&mut self.aabb_mins, target, "meshes")?; reserve_vec(&mut self.aabb_maxs, target, "meshes")?; reserve_vec(&mut self.default_instance_slots, target, "meshes")?; @@ -925,7 +937,7 @@ impl MeshSoa { geometry: GeometryRange, pipeline: PipelineKey, material: MaterialKey, - flags: RenderFlags, + default_instance_type: InstanceType, bounds: Aabb, default: InstanceHandle, ) { @@ -936,7 +948,7 @@ impl MeshSoa { resize_column(&mut self.index_counts, len, 0); resize_column(&mut self.pipeline_keys, len, PipelineKey::new(0)); resize_column(&mut self.material_keys, len, MaterialKey::DEFAULT); - resize_column(&mut self.flags, len, RenderFlags::NONE); + resize_column(&mut self.default_instance_types, len, InstanceType::ZERO); resize_column(&mut self.aabb_mins, len, [0.0; 3]); resize_column(&mut self.aabb_maxs, len, [0.0; 3]); resize_column(&mut self.default_instance_slots, len, 0); @@ -948,7 +960,7 @@ impl MeshSoa { self.index_counts[index] = geometry.index_count; self.pipeline_keys[index] = pipeline; self.material_keys[index] = material; - self.flags[index] = flags; + self.default_instance_types[index] = default_instance_type; self.aabb_mins[index] = bounds.min; self.aabb_maxs[index] = bounds.max; self.default_instance_slots[index] = default.slot(); @@ -968,8 +980,8 @@ impl MeshSoa { }, pipeline: self.pipeline_keys[index], material: self.material_keys[index], - flags: self.flags[index], - aabb: Aabb { + default_instance_type: self.default_instance_types[index], + local_aabb: Aabb { min: self.aabb_mins[index], max: self.aabb_maxs[index], }, @@ -994,7 +1006,7 @@ impl InstanceSoa { normal_col_0: Vec::new(), normal_col_1: Vec::new(), normal_col_2: Vec::new(), - flags: Vec::new(), + instance_types: Vec::new(), }; soa.reserve(initial)?; Ok(soa) @@ -1013,7 +1025,7 @@ impl InstanceSoa { reserve_vec(&mut self.normal_col_0, target, "instances")?; reserve_vec(&mut self.normal_col_1, target, "instances")?; reserve_vec(&mut self.normal_col_2, target, "instances")?; - reserve_vec(&mut self.flags, target, "instances")?; + reserve_vec(&mut self.instance_types, target, "instances")?; self.slots.reserve_for_len(target, "instances")?; Ok(()) } @@ -1024,7 +1036,7 @@ impl InstanceSoa { mesh: MeshHandle, model: ModelTransform, normal: NormalMatrix, - flags: RenderFlags, + instance_type: InstanceType, ) { let len = prepared.slot as usize + 1; resize_column(&mut self.mesh_slots, len, 0); @@ -1036,11 +1048,11 @@ impl InstanceSoa { resize_column(&mut self.normal_col_0, len, [0.0; 3]); resize_column(&mut self.normal_col_1, len, [0.0; 3]); resize_column(&mut self.normal_col_2, len, [0.0; 3]); - resize_column(&mut self.flags, len, RenderFlags::NONE); + resize_column(&mut self.instance_types, len, InstanceType::ZERO); let index = prepared.slot as usize; self.mesh_slots[index] = mesh.slot(); self.mesh_generations[index] = mesh.generation(); - self.flags[index] = flags; + self.instance_types[index] = instance_type; self.set_transform(prepared.slot, model, normal); self.slots.commit(prepared); } @@ -1077,7 +1089,7 @@ impl InstanceSoa { self.normal_col_1[index], self.normal_col_2[index], ], - flags: self.flags[index], + instance_type: self.instance_types[index], is_default, } } diff --git a/renderer/src/render_data/tests.rs b/renderer/src/render_data/tests.rs index 212d813..ca88327 100644 --- a/renderer/src/render_data/tests.rs +++ b/renderer/src/render_data/tests.rs @@ -16,8 +16,9 @@ fn info() -> MeshCreateInfo<'static> { indices: &INDICES, pipeline: PipelineKey::new(7), material: MaterialKey::new(11), - flags: RenderFlags::from_bits_retain(2), - default_instance_flags: RenderFlags::VISIBLE, + default_instance_type: InstanceType { + words: [3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, default_transform: IDENTITY_MODEL_TRANSFORM, } } @@ -81,29 +82,42 @@ fn world_bounds_reject_projective_and_overflowing_transforms() { } #[test] -fn default_instance_is_protected_and_flags_are_separate() { +fn default_instance_is_protected_and_preserves_its_type() { 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.mesh(created.mesh).unwrap().default_instance_type.words[0], + 3 + ); assert_eq!( data.mesh(created.mesh).unwrap().material, MaterialKey::new(11) ); assert_eq!( - data.instance(created.default_instance).unwrap().flags, - RenderFlags::VISIBLE + data.instance(created.default_instance) + .unwrap() + .instance_type, + info().default_instance_type ); 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); + data.set_mesh_visible(created.mesh, false).unwrap(); assert_eq!( - data.instance(created.default_instance).unwrap().flags, - RenderFlags::VISIBLE + data.mesh(created.mesh).unwrap().default_instance_type, + info().default_instance_type + ); + assert_eq!( + data.instance(created.default_instance) + .unwrap() + .instance_type, + { + let mut expected = info().default_instance_type; + expected.set_visible(false); + expected + } ); } @@ -112,11 +126,11 @@ 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) + .create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO) .unwrap(); data.destroy_instance(old_instance).unwrap(); let replacement = data - .create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) + .create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO) .unwrap(); assert_eq!(old_instance.slot(), replacement.slot()); assert_ne!(old_instance.generation(), replacement.generation()); @@ -133,7 +147,7 @@ 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) + .create_instance(mesh.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO) .unwrap(); data.destroy_instance(vacant).unwrap(); data.instances @@ -188,7 +202,7 @@ fn all_storage_classes_grow_and_retired_slots_force_max_checked_append() { instances: 1, } ); - data.create_instance(mesh.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) + data.create_instance(mesh.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO) .unwrap(); assert_eq!(data.capacities().instances, 2); @@ -302,7 +316,7 @@ fn aabb_supports_one_point_and_multiple_points() { let mut data = data(); let mesh = data.create_mesh(one).unwrap(); assert_eq!( - data.mesh(mesh.mesh).unwrap().aabb, + data.mesh(mesh.mesh).unwrap().local_aabb, Aabb { min: point[0], max: point[0] @@ -310,7 +324,7 @@ fn aabb_supports_one_point_and_multiple_points() { ); let mesh = data.create_mesh(info()).unwrap(); assert_eq!( - data.mesh(mesh.mesh).unwrap().aabb, + data.mesh(mesh.mesh).unwrap().local_aabb, Aabb { min: [-1.0, -2.0, -3.0], max: [4.0, 2.0, 3.0], @@ -444,7 +458,7 @@ fn normal_matrices_and_failed_transform_operations_are_transactional() { assert_eq!(data.instance(mesh.default_instance).unwrap(), old); let count = data.instance_count(); assert_eq!( - data.create_instance(mesh.mesh, invalid, RenderFlags::NONE), + data.create_instance(mesh.mesh, invalid, InstanceType::ZERO), Err(RenderDataError::InvalidTransform) ); assert_eq!(data.instance_count(), count); @@ -463,14 +477,14 @@ fn destroying_mesh_invalidates_exact_owner_instances_with_reused_generations() { 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) + .create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO) .unwrap(); let second_extra = data - .create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) + .create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO) .unwrap(); data.destroy_instance(first_extra).unwrap(); let reused = data - .create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) + .create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO) .unwrap(); assert_eq!(first_extra.slot(), reused.slot()); data.destroy_mesh(first.mesh).unwrap(); diff --git a/renderer/src/render_graph/compiler.rs b/renderer/src/render_graph/compiler.rs index 26e55e1..c618a86 100644 --- a/renderer/src/render_graph/compiler.rs +++ b/renderer/src/render_graph/compiler.rs @@ -21,20 +21,13 @@ struct CullParameters { } #[derive(Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -struct QueryParameters { - visible_predicate: TriStatePredicate, - visible_default: bool, - frustum_culled_predicate: TriStatePredicate, - frustum_culled_default: bool, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] struct PipelineParameters { pipeline: String, depth_compare: CompareFunction, depth_write_enabled: bool, clear_depth: f32, clear_color: [f64; 4], + predicate_default: bool, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -146,7 +139,6 @@ struct OutputKey(usize, u16); #[derive(Clone, Copy)] struct BoundInput { producer: OutputKey, - active: bool, } #[derive(Clone)] struct DependencyEdge { @@ -232,15 +224,6 @@ fn validate_name_grammar(s: &str, path: impl Into) -> Result<(), GraphEr } } -pub fn mesh_predicate_matches(predicate: RuntimePredicate, flag: bool) -> bool { - match predicate { - RuntimePredicate::Any => true, - RuntimePredicate::RequiredTrue => flag, - RuntimePredicate::RequiredFalse => !flag, - RuntimePredicate::Never => false, - } -} - pub fn parse_and_compile(bytes: &[u8]) -> Result { if bytes.len() > MAX_JSON_BYTES { return Err(GraphError::new( @@ -424,6 +407,89 @@ fn decode(node: &Node, i: usize) -> Result { $variant }}; } + fn literal(value: &serde_json::Value, ty: SemanticType) -> Option { + let floats = |value: &serde_json::Value, n: usize| -> Option> { + let values = value.as_array()?; + if values.len() != n { + return None; + } + values + .iter() + .map(|value| value.as_f64().map(|value| value as f32)) + .collect::>>() + .filter(|values| values.iter().all(|value| value.is_finite())) + }; + let vector = |n| floats(value, n); + Some(match ty { + SemanticType::Bool => TypedLiteral::Bool(value.as_bool()?), + SemanticType::F32 => { + let value = value.as_f64()? as f32; + if !value.is_finite() { + return None; + } + TypedLiteral::F32(value) + } + SemanticType::U32 => TypedLiteral::U32(value.as_u64()?.try_into().ok()?), + SemanticType::Vec2 => TypedLiteral::Vec2(vector(2)?.try_into().ok()?), + SemanticType::Vec3 => TypedLiteral::Vec3(vector(3)?.try_into().ok()?), + SemanticType::Vec4 => TypedLiteral::Vec4(vector(4)?.try_into().ok()?), + SemanticType::U32x16 => TypedLiteral::U32x16( + value + .as_array()? + .iter() + .map(|v| v.as_u64()?.try_into().ok()) + .collect::>>()? + .try_into() + .ok()?, + ), + SemanticType::LocalAabb => TypedLiteral::LocalAabb { + min: floats(value.get("min")?, 3)?.try_into().ok()?, + max: floats(value.get("max")?, 3)?.try_into().ok()?, + }, + ty @ (SemanticType::Mat2 | SemanticType::Mat3 | SemanticType::Mat4) => { + let n = match ty { + SemanticType::Mat2 => 2, + SemanticType::Mat3 => 3, + _ => 4, + }; + let columns = value.as_array()?; + if columns.len() != n { + return None; + } + let columns = columns + .iter() + .map(|v| floats(v, n)) + .collect::>>()?; + match ty { + SemanticType::Mat2 => TypedLiteral::Mat2( + columns + .into_iter() + .map(|v| v.try_into().ok()) + .collect::>>()? + .try_into() + .ok()?, + ), + SemanticType::Mat3 => TypedLiteral::Mat3( + columns + .into_iter() + .map(|v| v.try_into().ok()) + .collect::>>()? + .try_into() + .ok()?, + ), + _ => TypedLiteral::Mat4( + columns + .into_iter() + .map(|v| v.try_into().ok()) + .collect::>>()? + .try_into() + .ok()?, + ), + } + } + SemanticType::MeshData | SemanticType::Texture => return None, + }) + } Ok(match node.executor.key.as_str() { "mesh" => empty!(NormalizedParameters::Mesh), "frustum_cull" => { @@ -607,37 +673,6 @@ fn decode(node: &Node, i: usize) -> Result { descriptor: normalize_texture(p.texture, &base)?, } } - "mesh_query" => { - let p: QueryParameters = - serde_json::from_value(node.parameters.clone()).map_err(invalid)?; - let fold = |predicate, default, linked| match (predicate, linked, default) { - (TriStatePredicate::Any, _, _) => RuntimePredicate::Any, - (TriStatePredicate::RequiredTrue, true, _) => RuntimePredicate::RequiredTrue, - (TriStatePredicate::RequiredFalse, true, _) => RuntimePredicate::RequiredFalse, - (TriStatePredicate::RequiredTrue, false, true) - | (TriStatePredicate::RequiredFalse, false, false) => RuntimePredicate::Any, - _ => RuntimePredicate::Never, - }; - let mut visible = fold( - p.visible_predicate, - p.visible_default, - node.inputs.contains_key("isVisible"), - ); - let mut culled = fold( - p.frustum_culled_predicate, - p.frustum_culled_default, - node.inputs.contains_key("isFrustumCulled"), - ); - if visible == RuntimePredicate::Never || culled == RuntimePredicate::Never { - visible = RuntimePredicate::Never; - culled = RuntimePredicate::Never; - } - NormalizedParameters::MeshQuery { - visible_predicate: visible, - frustum_culled_predicate: culled, - } - } - "pipeline_registry" => empty!(NormalizedParameters::PipelineRegistry), "pipeline" => { let p: PipelineParameters = serde_json::from_value(node.parameters.clone()).map_err(invalid)?; @@ -673,8 +708,50 @@ fn decode(node: &Node, i: usize) -> Result { depth_write_enabled: p.depth_write_enabled, clear_depth: p.clear_depth, clear_color: p.clear_color, + predicate_default: p.predicate_default, } } + key if contract(key) + .is_some_and(|contract| contract.execution == ExecutionClass::Expression) => + { + let contract = contract(key).unwrap(); + let object = node.parameters.as_object().ok_or_else(|| { + error( + "GRAPH_PARAMETERS_INVALID", + "parameters must be an object", + base.clone(), + ) + })?; + if object.len() != contract.inputs.len() { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "expression defaults must exactly match inputs", + base, + )); + } + let mut defaults = Vec::with_capacity(contract.inputs.len()); + for input in contract.inputs { + let key = format!("{}Default", input.name); + let value = object.get(&key).ok_or_else(|| { + error( + "GRAPH_PARAMETERS_INVALID", + "missing expression default", + format!("{base}.{key}"), + ) + })?; + let TypeConstraint::Exact(ty) = input.accepted else { + unreachable!() + }; + defaults.push(literal(value, ty).ok_or_else(|| { + error( + "GRAPH_PARAMETERS_INVALID", + "invalid typed expression default", + format!("{base}.{key}"), + ) + })?); + } + NormalizedParameters::ExpressionDefaults { defaults } + } _ => unreachable!(), }) } @@ -846,13 +923,8 @@ pub fn compile(graph: Graph) -> Result { } for (i, n) in graph.nodes.iter().enumerate() { for input in contracts[i].inputs { - let inactive = matches!(¶ms[i], NormalizedParameters::MeshQuery { visible_predicate, frustum_culled_predicate } if (input.name == "isVisible" && matches!(visible_predicate, RuntimePredicate::Any | RuntimePredicate::Never)) || (input.name == "isFrustumCulled" && matches!(frustum_culled_predicate, RuntimePredicate::Any | RuntimePredicate::Never))); if !n.inputs.contains_key(input.name) { - if input.cardinality == InputCardinality::RequiredOne - || (!inactive - && matches!(params[i], NormalizedParameters::MeshQuery { .. }) - && input.name != "mesh") - { + if input.cardinality == InputCardinality::RequiredOne { return Err(error( "GRAPH_SOCKET_CARDINALITY", "required input is missing", @@ -866,7 +938,6 @@ pub fn compile(graph: Graph) -> Result { let mut bound: Vec> = vec![BTreeMap::new(); graph.nodes.len()]; for (i, n) in graph.nodes.iter().enumerate() { for input in contracts[i].inputs { - let inactive = matches!(¶ms[i], NormalizedParameters::MeshQuery { visible_predicate, frustum_culled_predicate } if (input.name == "isVisible" && matches!(visible_predicate, RuntimePredicate::Any | RuntimePredicate::Never)) || (input.name == "isFrustumCulled" && matches!(frustum_culled_predicate, RuntimePredicate::Any | RuntimePredicate::Never))); let Some(r) = n.inputs.get(input.name) else { continue; }; @@ -884,97 +955,18 @@ pub fn compile(graph: Graph) -> Result { format!("nodes[{i}].inputs.{}", input.name), )); } - if let Some(flag) = MeshFlag::ORDERED - .iter() - .find(|f| f.input_socket() == input.name) - { - if out.metadata != (OutputMetadata::BooleanFlag { flag: *flag }) { - return Err(error( - "GRAPH_SOCKET_TYPE_MISMATCH", - "mesh flag metadata mismatch", - format!("nodes[{i}].inputs.{}", input.name), - )); - } - } bound[i].insert( input.name, BoundInput { producer: OutputKey(pn, ordinal as u16), - active: !inactive, }, ); } } - let root = |key: OutputKey, - bound: &Vec>, - contracts: &Vec<&Contract>| - -> Option { - let mut k = key; - let mut seen = HashSet::new(); - loop { - if !seen.insert(k.0) { - return None; - } - if contracts[k.0].key == "mesh" { - let ordinal = contracts[k.0] - .outputs - .iter() - .position(|output| output.semantic_type == SemanticType::MeshData)?; - return Some(OutputKey(k.0, ordinal as u16)); - } - if contracts[k.0].outputs[k.1 as usize].semantic_type == SemanticType::MeshData { - return Some(k); - } - k = bound[k.0] - .get("mesh") - .or_else(|| bound[k.0].get("pipelineIndices")) - .or_else(|| bound[k.0].get("activation"))? - .producer; - } - }; - for (i, c) in contracts.iter().enumerate() { - if c.key == "frustum_cull" - && root(bound[i]["mesh"].producer, &bound, &contracts) - != root(bound[i]["localAabbs"].producer, &bound, &contracts) - { - return Err(error( - "GRAPH_SOCKET_TYPE_MISMATCH", - "scene roots differ", - format!("nodes[{i}].inputs.localAabbs"), - )); - } - if matches!(c.key, "mesh_query" | "pipeline_registry" | "pipeline") { - let scene_socket = if c.key == "pipeline_registry" { - "pipelineIndices" - } else { - "mesh" - }; - let scene = root(bound[i][scene_socket].producer, &bound, &contracts); - for (s, b) in &bound[i] { - if b.active - && matches!( - *s, - "isVisible" - | "isFrustumCulled" - | "draws" - | "pipelineIndices" - | "activation" - ) - && root(b.producer, &bound, &contracts) != scene - { - return Err(error( - "GRAPH_SOCKET_TYPE_MISMATCH", - "scene roots differ", - format!("nodes[{i}].inputs.{s}"), - )); - } - } - } - } let mut edges = Vec::new(); for i in 0..graph.nodes.len() { for (input_ordinal, input) in contracts[i].inputs.iter().enumerate() { - if let Some(b) = bound[i].get(input.name).filter(|b| b.active) { + if let Some(b) = bound[i].get(input.name) { edges.push(DependencyEdge { from_node: b.producer.0, from_socket: contracts[b.producer.0].outputs[b.producer.1 as usize] @@ -1030,6 +1022,9 @@ pub fn compile(graph: Graph) -> Result { for i in 0..graph.nodes.len() { if live.contains(&i) { for (o, out) in contracts[i].outputs.iter().enumerate() { + if out.semantic_type.is_virtual() { + continue; + } let key = OutputKey(i, o as u16); if contracts[i].execution == ExecutionClass::Source && !referenced_outputs.contains(&key) @@ -1586,7 +1581,6 @@ pub fn compile(graph: Graph) -> Result { for (i, o, out) in resource_meta { let key = OutputKey(i, o); let id = output_ids[&key]; - let mesh = || output_ids[&root(key, &bound, &contracts).unwrap()]; let plan = match out.semantic_type { SemanticType::Texture if matches!(params[i], NormalizedParameters::Texture { .. }) => { if let NormalizedParameters::Texture { @@ -1615,19 +1609,19 @@ pub fn compile(graph: Graph) -> Result { } } SemanticType::MeshData => ResourcePlan::MeshData, - SemanticType::LocalAabbBuffer => ResourcePlan::LocalAabbBuffer { mesh: mesh() }, - SemanticType::BooleanFlagBuffer => { - if let OutputMetadata::BooleanFlag { flag } = out.metadata { - ResourcePlan::BooleanFlagBuffer { mesh: mesh(), flag } - } else { - unreachable!() - } + SemanticType::Bool + | SemanticType::F32 + | SemanticType::U32 + | SemanticType::Vec2 + | SemanticType::Vec3 + | SemanticType::Vec4 + | SemanticType::Mat2 + | SemanticType::Mat3 + | SemanticType::Mat4 + | SemanticType::U32x16 + | SemanticType::LocalAabb => { + unreachable!("pure expression outputs are never materialized") } - SemanticType::PipelineIndexStream => ResourcePlan::PipelineIndexStream { mesh: mesh() }, - SemanticType::PipelineActivation => ResourcePlan::PipelineActivation { - pipeline_indices: output_ids[&bound[i]["pipelineIndices"].producer], - }, - SemanticType::DrawStream => ResourcePlan::DrawStream { mesh: mesh() }, }; resources.push(CompiledResource { original_node_index: i as u32, @@ -1644,17 +1638,20 @@ pub fn compile(graph: Graph) -> Result { let _ = id; } let mut executions = Vec::new(); - let mut node_execution = HashMap::new(); for &i in &order { - if contracts[i].execution == ExecutionClass::Source { + if matches!( + contracts[i].execution, + ExecutionClass::Source | ExecutionClass::Expression + ) { continue; } - let ordinal = executions.len() as u32; - node_execution.insert(i, ordinal); let input_resource = |s: &str| output_ids[&bound[i][s].producer]; let mut inputs = Vec::new(); for s in contracts[i].inputs { - if let Some(b) = bound[i].get(s.name).filter(|b| b.active) { + if s.role != InputRole::Expression { + let Some(b) = bound[i].get(s.name) else { + continue; + }; inputs.push(CompiledSocketInput { socket: s.name.into(), resource: output_ids[&b.producer], @@ -1665,6 +1662,7 @@ pub fn compile(graph: Graph) -> Result { .outputs .iter() .enumerate() + .filter(|(_, s)| !s.semantic_type.is_virtual()) .map(|(o, s)| CompiledSocketOutput { socket: s.name.into(), resource: output_ids[&OutputKey(i, o as u16)], @@ -1672,58 +1670,6 @@ pub fn compile(graph: Graph) -> Result { .collect(); let mut accesses = Vec::new(); let kind = match contracts[i].key { - "frustum_cull" => { - for (s, m) in [ - ("mesh", AccessMode::StorageRead), - ("localAabbs", AccessMode::StorageRead), - ] { - accesses.push(CompiledAccess { - socket: s.into(), - resource: input_resource(s), - mode: m, - }); - } - let r = output_ids[&OutputKey(i, 0)]; - accesses.push(CompiledAccess { - socket: "isFrustumCulled".into(), - resource: r, - mode: AccessMode::StorageWrite { - full_overwrite: true, - }, - }); - ExecutionKind::Compute { - work: ComputeWork::FrustumCull, - } - } - "mesh_query" => { - for s in ["mesh", "isVisible", "isFrustumCulled"] { - if let Some(b) = bound[i].get(s).filter(|b| b.active) { - accesses.push(CompiledAccess { - socket: s.into(), - resource: output_ids[&b.producer], - mode: AccessMode::StorageRead, - }); - } - } - accesses.push(CompiledAccess { - socket: "draws".into(), - resource: output_ids[&OutputKey(i, 0)], - mode: AccessMode::StorageWrite { - full_overwrite: true, - }, - }); - ExecutionKind::Compute { - work: ComputeWork::MeshQuery, - } - } - "pipeline_registry" => { - accesses.push(CompiledAccess { - socket: "pipelineIndices".into(), - resource: input_resource("pipelineIndices"), - mode: AccessMode::SemanticRead, - }); - ExecutionKind::CpuPreparation - } "pipeline" => { let color = output_ids[&OutputKey(i, 0)]; let depth = output_ids[&OutputKey(i, 1)]; @@ -1747,15 +1693,11 @@ pub fn compile(graph: Graph) -> Result { } else { NormalizedDepthLoad::Load }; - for s in ["mesh", "draws", "activation"] { + for s in ["mesh"] { accesses.push(CompiledAccess { socket: s.into(), resource: input_resource(s), - mode: if s == "draws" { - AccessMode::IndirectRead - } else { - AccessMode::SemanticRead - }, + mode: AccessMode::SemanticRead, }); } accesses.push(CompiledAccess { @@ -1849,6 +1791,270 @@ pub fn compile(graph: Graph) -> Result { accesses, }); } + // Lower virtual values into a stable, device-independent expression plan. + let mut expression_plan = ExpressionPlan::default(); + let mut expression_ids = HashMap::::new(); + let mut expression_provenance = HashMap::>::new(); + let mut cse = HashMap::::new(); + let mut requires_camera = false; + let mut mesh_root = None; + let mut intern = |semantic_type: SemanticType, + op: ExpressionOp, + origin: NodeOutputRef, + mesh_provenance: Option| { + let key = format!("{semantic_type:?}:{op:?}:{mesh_provenance:?}"); + if let Some(id) = cse.get(&key) { + return *id; + } + let id = ExprId(expression_plan.expressions.len() as u32); + expression_plan.expressions.push(Expression { + semantic_type, + op, + origin, + mesh_provenance, + }); + cse.insert(key, id); + id + }; + for &i in &order { + if contracts[i].execution != ExecutionClass::Expression { + continue; + } + let defaults: &[TypedLiteral] = match ¶ms[i] { + NormalizedParameters::ExpressionDefaults { defaults } => defaults.as_slice(), + NormalizedParameters::FrustumCull { .. } => &[], + _ => unreachable!(), + }; + let mut operands = Vec::new(); + let mut operand_provenance = Vec::new(); + for (ordinal, input) in contracts[i].inputs.iter().enumerate() { + if let Some(binding) = bound[i].get(input.name) { + let key = binding.producer; + let producer_type = contracts[key.0].outputs[key.1 as usize].semantic_type; + let operand_mesh = if contracts[key.0].key == "mesh" { + Some(output_ids[&OutputKey(key.0, 0)]) + } else { + expression_provenance[&key] + }; + let id = if producer_type.is_virtual() { + if contracts[key.0].key == "mesh" { + let mesh = output_ids[&OutputKey(key.0, 0)]; + if mesh_root.is_some_and(|root| root != mesh) { + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "instance traversal has multiple mesh roots", + format!("nodes[{i}].inputs.{}", input.name), + )); + } + mesh_root = Some(mesh); + let op = match producer_type { + SemanticType::U32x16 => ExpressionOp::InstanceType { mesh }, + SemanticType::LocalAabb => ExpressionOp::LocalAabb { mesh }, + _ => unreachable!(), + }; + intern( + producer_type, + op, + graph.nodes[key.0] + .inputs + .get("") + .cloned() + .unwrap_or(NodeOutputRef { + node: graph.nodes[key.0].id.clone(), + socket: contracts[key.0].outputs[key.1 as usize].name.into(), + }), + Some(mesh), + ) + } else { + expression_ids[&key] + } + } else { + continue; + }; + operands.push(id); + operand_provenance.push(operand_mesh); + } else { + let literal = defaults[ordinal].clone(); + operands.push(intern( + literal.semantic_type(), + ExpressionOp::Literal { literal }, + NodeOutputRef { + node: graph.nodes[i].id.clone(), + socket: input.name.into(), + }, + None, + )); + operand_provenance.push(None); + } + } + let mut provenances = operand_provenance.into_iter().flatten(); + let provenance = provenances.next(); + if provenances.any(|candidate| Some(candidate) != provenance) { + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "expression mixes mesh provenance", + format!("nodes[{i}].inputs"), + )); + } + for (output_ordinal, output) in contracts[i].outputs.iter().enumerate() { + let key = contracts[i].key; + let op = match key { + "not" => ExpressionOp::Not { value: operands[0] }, + "and" | "or" | "xor" | "xnor" => ExpressionOp::BooleanBinary { + operation: match key { + "and" => BooleanBinaryOp::And, + "or" => BooleanBinaryOp::Or, + "xor" => BooleanBinaryOp::Xor, + _ => BooleanBinaryOp::Xnor, + }, + left: operands[0], + right: operands[1], + }, + "greater_than_f32" | "less_than_f32" | "equals_f32" => ExpressionOp::CompareF32 { + operation: if key.starts_with("greater") { + CompareOp::GreaterThan + } else if key.starts_with("less") { + CompareOp::LessThan + } else { + CompareOp::Equals + }, + left: operands[0], + right: operands[1], + }, + "greater_than_u32" | "less_than_u32" | "equals_u32" => ExpressionOp::CompareU32 { + operation: if key.starts_with("greater") { + CompareOp::GreaterThan + } else if key.starts_with("less") { + CompareOp::LessThan + } else { + CompareOp::Equals + }, + left: operands[0], + right: operands[1], + }, + k if k.starts_with("separate_vec") => ExpressionOp::VectorProject { + vector: operands[0], + index: output_ordinal as u8, + }, + k if k.starts_with("combine_vec") => ExpressionOp::VectorConstruct { + components: operands.clone(), + }, + k if k.starts_with("separate_mat") => ExpressionOp::MatrixColumn { + matrix: operands[0], + index: output_ordinal as u8, + }, + k if k.starts_with("combine_mat") => ExpressionOp::MatrixConstruct { + columns: operands.clone(), + }, + "separate_u32x16" => ExpressionOp::TypeWord { + value: operands[0], + index: output_ordinal as u8, + }, + "combine_u32x16" => ExpressionOp::TypeConstruct { + words: operands.clone(), + }, + "separate_u32_bits" => ExpressionOp::U32Bit { + value: operands[0], + index: output_ordinal as u8, + }, + "combine_u32_bits" => ExpressionOp::U32Construct { + bits: operands.clone(), + }, + "separate_local_aabb" if output_ordinal == 0 => { + ExpressionOp::AabbMin { aabb: operands[0] } + } + "separate_local_aabb" => ExpressionOp::AabbMax { aabb: operands[0] }, + "frustum_cull" => { + requires_camera = true; + let mesh = output_ids[&bound[i]["mesh"].producer]; + if mesh_root.is_some_and(|root| root != mesh) { + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "instance traversal has multiple mesh roots", + format!("nodes[{i}].inputs.mesh"), + )); + } + mesh_root = Some(mesh); + ExpressionOp::FrustumCulled { + mesh, + local_aabb: operands[0], + } + } + _ => unreachable!(), + }; + let id = intern( + output.semantic_type, + op, + NodeOutputRef { + node: graph.nodes[i].id.clone(), + socket: output.name.into(), + }, + provenance, + ); + expression_ids.insert(OutputKey(i, output_ordinal as u16), id); + expression_provenance.insert(OutputKey(i, output_ordinal as u16), provenance); + } + } + let mut predicates = Vec::new(); + for (execution, compiled) in executions.iter().enumerate() { + let node = compiled.original_node_index as usize; + if contracts[node].key != "pipeline" { + continue; + } + let mesh = output_ids[&bound[node]["mesh"].producer]; + if mesh_root.is_some_and(|root| root != mesh) { + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "instance traversal has multiple mesh roots", + format!("nodes[{node}].inputs.mesh"), + )); + } + mesh_root = Some(mesh); + let predicate = if let Some(binding) = bound[node].get("predicate") { + expression_ids[&binding.producer] + } else { + let NormalizedParameters::Pipeline { + predicate_default, .. + } = params[node] + else { + unreachable!() + }; + intern( + SemanticType::Bool, + ExpressionOp::Literal { + literal: TypedLiteral::Bool(predicate_default), + }, + NodeOutputRef { + node: graph.nodes[node].id.clone(), + socket: "predicate".into(), + }, + None, + ) + }; + predicates.push(PipelinePredicatePlan { + execution: execution as u32, + predicate, + ordinal: 0, + }); + } + for (ordinal, predicate) in predicates.iter_mut().enumerate() { + predicate.ordinal = ordinal as u32; + } + if expression_plan.expressions.len() > MAX_EXPRESSIONS + || predicates.len() > MAX_PREDICATE_PIPELINES + { + return Err(error( + "GRAPH_LIMIT_EXCEEDED", + "instance traversal plan exceeds limits", + "nodes", + )); + } + let instance_traversal = mesh_root.map(|mesh| InstanceTraversalPlan { + mesh, + expressions: expression_plan, + pipelines: predicates, + requires_camera, + }); for (ordinal, e) in executions.iter().enumerate() { for o in &e.outputs { resources[o.resource as usize].producer_execution = Some(ordinal as u32); @@ -1917,6 +2123,7 @@ pub fn compile(graph: Graph) -> Result { culled_node_count: (graph.nodes.len() - live.len()) as u32, culled_resource_count: (all_outputs - output_ids.len()) as u32, transient_slot_count: transient, + instance_traversal, }) } diff --git a/renderer/src/render_graph/contracts.rs b/renderer/src/render_graph/contracts.rs index 408f706..b5951f2 100644 --- a/renderer/src/render_graph/contracts.rs +++ b/renderer/src/render_graph/contracts.rs @@ -1,27 +1,35 @@ -use super::MeshFlag; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize)] #[serde(rename_all = "snake_case")] pub enum SemanticType { MeshData, Texture, - LocalAabbBuffer, - BooleanFlagBuffer, - PipelineIndexStream, - PipelineActivation, - DrawStream, + Bool, + F32, + U32, + Vec2, + Vec3, + Vec4, + Mat2, + Mat3, + Mat4, + U32x16, + LocalAabb, +} + +impl SemanticType { + pub const fn is_virtual(self) -> bool { + !matches!(self, Self::MeshData | Self::Texture) + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] pub enum ExecutionClass { Source, - CpuPreparation, - Compute, + Expression, Render, Frame, } - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FullscreenPolicy { Copy, @@ -29,21 +37,18 @@ pub enum FullscreenPolicy { BloomExtract, BloomComposite, } - #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] pub enum InputCardinality { RequiredOne, OptionalOne, } - #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(tag = "kind", content = "types", rename_all = "snake_case")] pub enum TypeConstraint { Exact(SemanticType), OneOf(&'static [SemanticType]), } - #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum InputRole { @@ -54,15 +59,8 @@ pub enum InputRole { SampledTexture, ColorTarget { location: u32 }, DepthTarget, + Expression, } - -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum OutputMetadata { - None, - BooleanFlag { flag: MeshFlag }, -} - #[derive(Clone, Copy, Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct InputSocketContract { @@ -71,15 +69,12 @@ pub struct InputSocketContract { pub cardinality: InputCardinality, pub role: InputRole, } - #[derive(Clone, Copy, Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct OutputSocketContract { pub name: &'static str, pub semantic_type: SemanticType, - pub metadata: OutputMetadata, } - #[derive(Clone, Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct Contract { @@ -94,329 +89,294 @@ pub struct Contract { } use SemanticType::*; - -const fn input( +const R: InputCardinality = InputCardinality::RequiredOne; +const O: InputCardinality = InputCardinality::OptionalOne; +const fn i( name: &'static str, - accepted: TypeConstraint, + ty: SemanticType, cardinality: InputCardinality, role: InputRole, ) -> InputSocketContract { InputSocketContract { name, - accepted, + accepted: TypeConstraint::Exact(ty), cardinality, role, } } - -const fn output( - name: &'static str, - semantic_type: SemanticType, - metadata: OutputMetadata, -) -> OutputSocketContract { +const fn o(name: &'static str, semantic_type: SemanticType) -> OutputSocketContract { OutputSocketContract { name, semantic_type, - metadata, } } - -const REQUIRED: InputCardinality = InputCardinality::RequiredOne; -const OPTIONAL: InputCardinality = InputCardinality::OptionalOne; -const NONE_IN: &[InputSocketContract] = &[]; -const NONE_OUT: &[OutputSocketContract] = &[]; -const TEXTURE_OUT: &[OutputSocketContract] = &[output("texture", Texture, OutputMetadata::None)]; -const MESH_OUT: &[OutputSocketContract] = &[ - output("mesh", MeshData, OutputMetadata::None), - output("localAabbs", LocalAabbBuffer, OutputMetadata::None), - output( - "isVisible", - BooleanFlagBuffer, - OutputMetadata::BooleanFlag { - flag: MeshFlag::IsVisible, - }, - ), - output("pipelineIndices", PipelineIndexStream, OutputMetadata::None), +const NONE_I: &[InputSocketContract] = &[]; +const NONE_O: &[OutputSocketContract] = &[]; +const MESH_O: &[OutputSocketContract] = &[ + o("mesh", MeshData), + o("type", U32x16), + o("localAabb", LocalAabb), ]; -const CULLED_OUT: &[OutputSocketContract] = &[output( - "isFrustumCulled", - BooleanFlagBuffer, - OutputMetadata::BooleanFlag { - flag: MeshFlag::IsFrustumCulled, - }, -)]; -const DRAW_OUT: &[OutputSocketContract] = &[output("draws", DrawStream, OutputMetadata::None)]; -const ACTIVATION_OUT: &[OutputSocketContract] = &[output( - "activation", - PipelineActivation, - OutputMetadata::None, -)]; -const PIPELINE_OUT: &[OutputSocketContract] = &[ - output("color", Texture, OutputMetadata::None), - output("depth", Texture, OutputMetadata::None), -]; -const FULLSCREEN_COPY_OUT: &[OutputSocketContract] = - &[output("color", Texture, OutputMetadata::None)]; -const CULL_IN: &[InputSocketContract] = &[ - input( - "mesh", - TypeConstraint::Exact(MeshData), - REQUIRED, - InputRole::StorageRead, - ), - input( - "localAabbs", - TypeConstraint::Exact(LocalAabbBuffer), - REQUIRED, - InputRole::StorageRead, - ), -]; -const QUERY_IN: &[InputSocketContract] = &[ - input( - "mesh", - TypeConstraint::Exact(MeshData), - REQUIRED, - InputRole::StorageRead, - ), - input( - "isVisible", - TypeConstraint::Exact(BooleanFlagBuffer), - OPTIONAL, - InputRole::StorageRead, - ), - input( - "isFrustumCulled", - TypeConstraint::Exact(BooleanFlagBuffer), - OPTIONAL, - InputRole::StorageRead, - ), -]; -const REGISTRY_IN: &[InputSocketContract] = &[input( - "pipelineIndices", - TypeConstraint::Exact(PipelineIndexStream), - REQUIRED, - InputRole::SemanticRead, -)]; -const PIPELINE_IN: &[InputSocketContract] = &[ - input( - "mesh", - TypeConstraint::Exact(MeshData), - REQUIRED, - InputRole::SemanticRead, - ), - input( - "draws", - TypeConstraint::Exact(DrawStream), - REQUIRED, - InputRole::IndirectRead, - ), - input( - "activation", - TypeConstraint::Exact(PipelineActivation), - REQUIRED, - InputRole::SemanticRead, - ), - input( +const TEXTURE_O: &[OutputSocketContract] = &[o("texture", Texture)]; +const PIPE_I: &[InputSocketContract] = &[ + i("mesh", MeshData, R, InputRole::SemanticRead), + i("predicate", Bool, O, InputRole::Expression), + i( "colorTarget", - TypeConstraint::Exact(Texture), - REQUIRED, + Texture, + R, InputRole::ColorTarget { location: 0 }, ), - input( - "depthTarget", - TypeConstraint::Exact(Texture), - REQUIRED, - InputRole::DepthTarget, - ), + i("depthTarget", Texture, R, InputRole::DepthTarget), ]; -const FULLSCREEN_COPY_IN: &[InputSocketContract] = &[ - input( - "source", - TypeConstraint::Exact(Texture), - REQUIRED, - InputRole::SampledTexture, - ), - input( +const PIPE_O: &[OutputSocketContract] = &[o("color", Texture), o("depth", Texture)]; +const CULL_I: &[InputSocketContract] = &[ + i("mesh", MeshData, R, InputRole::Expression), + i("localAabb", LocalAabb, R, InputRole::Expression), +]; +const CULL_O: &[OutputSocketContract] = &[o("isFrustumCulled", Bool)]; +const COPY_I: &[InputSocketContract] = &[ + i("source", Texture, R, InputRole::SampledTexture), + i( "colorTarget", - TypeConstraint::Exact(Texture), - REQUIRED, + Texture, + R, InputRole::ColorTarget { location: 0 }, ), ]; -const BLOOM_COMPOSITE_IN: &[InputSocketContract] = &[ - input( - "source", - TypeConstraint::Exact(Texture), - REQUIRED, - InputRole::SampledTexture, - ), - input( - "bloom", - TypeConstraint::Exact(Texture), - REQUIRED, - InputRole::SampledTexture, - ), - input( +const BLOOM_I: &[InputSocketContract] = &[ + i("source", Texture, R, InputRole::SampledTexture), + i("bloom", Texture, R, InputRole::SampledTexture), + i( "colorTarget", - TypeConstraint::Exact(Texture), - REQUIRED, + Texture, + R, InputRole::ColorTarget { location: 0 }, ), ]; -const FRAME_OUT_IN: &[InputSocketContract] = &[input( - "color", - TypeConstraint::Exact(Texture), - REQUIRED, - InputRole::SampledTexture, -)]; +const COLOR_O: &[OutputSocketContract] = &[o("color", Texture)]; +const FRAME_I: &[InputSocketContract] = &[i("color", Texture, R, InputRole::SampledTexture)]; +macro_rules! ins { ($($n:literal:$t:ident),*) => { &[$(i($n,$t,O,InputRole::Expression)),*] } } +macro_rules! outs { ($($n:literal:$t:ident),*) => { &[$(o($n,$t)),*] } } +macro_rules! c { + ($k:literal,$v:expr,$e:ident,$ins:expr,$outs:expr,$obs:expr,$policy:expr) => { + Contract { + key: $k, + version: $v, + execution: ExecutionClass::$e, + inputs: $ins, + outputs: $outs, + inherently_observable: $obs, + fullscreen_policy: $policy, + } + }; +} +macro_rules! ex { + ($k:literal,$ins:expr,$outs:expr) => { + c!($k, 1, Expression, $ins, $outs, false, None) + }; +} pub static CONTRACTS: &[Contract] = &[ - Contract { - key: "mesh", - version: 1, - execution: ExecutionClass::Source, - inputs: NONE_IN, - outputs: MESH_OUT, - inherently_observable: false, - fullscreen_policy: None, - }, - Contract { - key: "texture", - version: 1, - execution: ExecutionClass::Source, - inputs: NONE_IN, - outputs: TEXTURE_OUT, - inherently_observable: false, - fullscreen_policy: None, - }, - Contract { - key: "frustum_cull", - version: 1, - execution: ExecutionClass::Compute, - inputs: CULL_IN, - outputs: CULLED_OUT, - inherently_observable: false, - fullscreen_policy: None, - }, - Contract { - key: "mesh_query", - version: 1, - execution: ExecutionClass::Compute, - inputs: QUERY_IN, - outputs: DRAW_OUT, - inherently_observable: false, - fullscreen_policy: None, - }, - Contract { - key: "pipeline_registry", - version: 1, - execution: ExecutionClass::CpuPreparation, - inputs: REGISTRY_IN, - outputs: ACTIVATION_OUT, - inherently_observable: false, - fullscreen_policy: None, - }, - Contract { - key: "pipeline", - version: 1, - execution: ExecutionClass::Render, - inputs: PIPELINE_IN, - outputs: PIPELINE_OUT, - inherently_observable: false, - fullscreen_policy: None, - }, - Contract { - key: "fullscreen_copy", - version: 1, - execution: ExecutionClass::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - fullscreen_policy: Some(FullscreenPolicy::Copy), - }, - Contract { - key: "color_balance", - version: 1, - execution: ExecutionClass::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), - }, - Contract { - key: "exposure_contrast", - version: 1, - execution: ExecutionClass::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), - }, - Contract { - key: "saturation", - version: 1, - execution: ExecutionClass::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), - }, - Contract { - key: "channel_mixer", - version: 1, - execution: ExecutionClass::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), - }, - Contract { - key: "bloom_extract", - version: 1, - execution: ExecutionClass::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - fullscreen_policy: Some(FullscreenPolicy::BloomExtract), - }, - Contract { - key: "bloom_blur", - version: 1, - execution: ExecutionClass::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), - }, - Contract { - key: "bloom_composite", - version: 1, - execution: ExecutionClass::Render, - inputs: BLOOM_COMPOSITE_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - fullscreen_policy: Some(FullscreenPolicy::BloomComposite), - }, - Contract { - key: "luminance_edge", - version: 1, - execution: ExecutionClass::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), - }, - Contract { - key: "frame_out", - version: 3, - execution: ExecutionClass::Frame, - inputs: FRAME_OUT_IN, - outputs: NONE_OUT, - inherently_observable: true, - fullscreen_policy: None, - }, + c!("mesh", 2, Source, NONE_I, MESH_O, false, None), + c!("texture", 1, Source, NONE_I, TEXTURE_O, false, None), + c!("frustum_cull", 2, Expression, CULL_I, CULL_O, false, None), + c!("pipeline", 2, Render, PIPE_I, PIPE_O, false, None), + ex!("and", ins!("left":Bool,"right":Bool), outs!("value":Bool)), + ex!("or", ins!("left":Bool,"right":Bool), outs!("value":Bool)), + ex!("not", ins!("operand":Bool), outs!("value":Bool)), + ex!("xor", ins!("left":Bool,"right":Bool), outs!("value":Bool)), + ex!("xnor", ins!("left":Bool,"right":Bool), outs!("value":Bool)), + ex!( + "greater_than_f32", + ins!("left":F32,"right":F32), + outs!("value":Bool) + ), + ex!( + "less_than_f32", + ins!("left":F32,"right":F32), + outs!("value":Bool) + ), + ex!( + "equals_f32", + ins!("left":F32,"right":F32), + outs!("value":Bool) + ), + ex!( + "greater_than_u32", + ins!("left":U32,"right":U32), + outs!("value":Bool) + ), + ex!( + "less_than_u32", + ins!("left":U32,"right":U32), + outs!("value":Bool) + ), + ex!( + "equals_u32", + ins!("left":U32,"right":U32), + outs!("value":Bool) + ), + ex!("separate_vec2", ins!("vector":Vec2), outs!("x":F32,"y":F32)), + ex!("combine_vec2", ins!("x":F32,"y":F32), outs!("vector":Vec2)), + ex!( + "separate_vec3", + ins!("vector":Vec3), + outs!("x":F32,"y":F32,"z":F32) + ), + ex!( + "combine_vec3", + ins!("x":F32,"y":F32,"z":F32), + outs!("vector":Vec3) + ), + ex!( + "separate_vec4", + ins!("vector":Vec4), + outs!("x":F32,"y":F32,"z":F32,"w":F32) + ), + ex!( + "combine_vec4", + ins!("x":F32,"y":F32,"z":F32,"w":F32), + outs!("vector":Vec4) + ), + ex!( + "separate_mat2", + ins!("matrix":Mat2), + outs!("column0":Vec2,"column1":Vec2) + ), + ex!( + "combine_mat2", + ins!("column0":Vec2,"column1":Vec2), + outs!("matrix":Mat2) + ), + ex!( + "separate_mat3", + ins!("matrix":Mat3), + outs!("column0":Vec3,"column1":Vec3,"column2":Vec3) + ), + ex!( + "combine_mat3", + ins!("column0":Vec3,"column1":Vec3,"column2":Vec3), + outs!("matrix":Mat3) + ), + ex!( + "separate_mat4", + ins!("matrix":Mat4), + outs!("column0":Vec4,"column1":Vec4,"column2":Vec4,"column3":Vec4) + ), + ex!( + "combine_mat4", + ins!("column0":Vec4,"column1":Vec4,"column2":Vec4,"column3":Vec4), + outs!("matrix":Mat4) + ), + ex!( + "separate_u32x16", + ins!("value":U32x16), + outs!("word0":U32,"word1":U32,"word2":U32,"word3":U32,"word4":U32,"word5":U32,"word6":U32,"word7":U32,"word8":U32,"word9":U32,"word10":U32,"word11":U32,"word12":U32,"word13":U32,"word14":U32,"word15":U32) + ), + ex!( + "combine_u32x16", + ins!("word0":U32,"word1":U32,"word2":U32,"word3":U32,"word4":U32,"word5":U32,"word6":U32,"word7":U32,"word8":U32,"word9":U32,"word10":U32,"word11":U32,"word12":U32,"word13":U32,"word14":U32,"word15":U32), + outs!("value":U32x16) + ), + ex!( + "separate_u32_bits", + ins!("value":U32), + outs!("bit0":Bool,"bit1":Bool,"bit2":Bool,"bit3":Bool,"bit4":Bool,"bit5":Bool,"bit6":Bool,"bit7":Bool,"bit8":Bool,"bit9":Bool,"bit10":Bool,"bit11":Bool,"bit12":Bool,"bit13":Bool,"bit14":Bool,"bit15":Bool,"bit16":Bool,"bit17":Bool,"bit18":Bool,"bit19":Bool,"bit20":Bool,"bit21":Bool,"bit22":Bool,"bit23":Bool,"bit24":Bool,"bit25":Bool,"bit26":Bool,"bit27":Bool,"bit28":Bool,"bit29":Bool,"bit30":Bool,"bit31":Bool) + ), + ex!( + "combine_u32_bits", + ins!("bit0":Bool,"bit1":Bool,"bit2":Bool,"bit3":Bool,"bit4":Bool,"bit5":Bool,"bit6":Bool,"bit7":Bool,"bit8":Bool,"bit9":Bool,"bit10":Bool,"bit11":Bool,"bit12":Bool,"bit13":Bool,"bit14":Bool,"bit15":Bool,"bit16":Bool,"bit17":Bool,"bit18":Bool,"bit19":Bool,"bit20":Bool,"bit21":Bool,"bit22":Bool,"bit23":Bool,"bit24":Bool,"bit25":Bool,"bit26":Bool,"bit27":Bool,"bit28":Bool,"bit29":Bool,"bit30":Bool,"bit31":Bool), + outs!("value":U32) + ), + ex!( + "separate_local_aabb", + ins!("value":LocalAabb), + outs!("min":Vec3,"max":Vec3) + ), + c!( + "fullscreen_copy", + 1, + Render, + COPY_I, + COLOR_O, + false, + Some(FullscreenPolicy::Copy) + ), + c!( + "color_balance", + 1, + Render, + COPY_I, + COLOR_O, + false, + Some(FullscreenPolicy::HdrSameExtent) + ), + c!( + "exposure_contrast", + 1, + Render, + COPY_I, + COLOR_O, + false, + Some(FullscreenPolicy::HdrSameExtent) + ), + c!( + "saturation", + 1, + Render, + COPY_I, + COLOR_O, + false, + Some(FullscreenPolicy::HdrSameExtent) + ), + c!( + "channel_mixer", + 1, + Render, + COPY_I, + COLOR_O, + false, + Some(FullscreenPolicy::HdrSameExtent) + ), + c!( + "bloom_extract", + 1, + Render, + COPY_I, + COLOR_O, + false, + Some(FullscreenPolicy::BloomExtract) + ), + c!( + "bloom_blur", + 1, + Render, + COPY_I, + COLOR_O, + false, + Some(FullscreenPolicy::HdrSameExtent) + ), + c!( + "bloom_composite", + 1, + Render, + BLOOM_I, + COLOR_O, + false, + Some(FullscreenPolicy::BloomComposite) + ), + c!( + "luminance_edge", + 1, + Render, + COPY_I, + COLOR_O, + false, + Some(FullscreenPolicy::HdrSameExtent) + ), + c!("frame_out", 3, Frame, FRAME_I, NONE_O, true, None), ]; - pub fn contract(key: &str) -> Option<&'static Contract> { - CONTRACTS.iter().find(|contract| contract.key == key) + CONTRACTS.iter().find(|c| c.key == key) } diff --git a/renderer/src/render_graph/expression.rs b/renderer/src/render_graph/expression.rs new file mode 100644 index 0000000..feb881e --- /dev/null +++ b/renderer/src/render_graph/expression.rs @@ -0,0 +1,181 @@ +//! Typed, device-independent instance predicate IR. + +use serde::Serialize; + +use super::{NodeOutputRef, SemanticType}; + +pub const MAX_EXPRESSIONS: usize = 4096; +pub const MAX_PREDICATE_PIPELINES: usize = 64; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +pub struct ExprId(pub u32); + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum TypedLiteral { + Bool(bool), + F32(f32), + U32(u32), + Vec2([f32; 2]), + Vec3([f32; 3]), + Vec4([f32; 4]), + Mat2([[f32; 2]; 2]), + Mat3([[f32; 3]; 3]), + Mat4([[f32; 4]; 4]), + U32x16([u32; 16]), + LocalAabb { min: [f32; 3], max: [f32; 3] }, +} + +impl TypedLiteral { + pub fn semantic_type(&self) -> SemanticType { + match self { + Self::Bool(_) => SemanticType::Bool, + Self::F32(_) => SemanticType::F32, + Self::U32(_) => SemanticType::U32, + Self::Vec2(_) => SemanticType::Vec2, + Self::Vec3(_) => SemanticType::Vec3, + Self::Vec4(_) => SemanticType::Vec4, + Self::Mat2(_) => SemanticType::Mat2, + Self::Mat3(_) => SemanticType::Mat3, + Self::Mat4(_) => SemanticType::Mat4, + Self::U32x16(_) => SemanticType::U32x16, + Self::LocalAabb { .. } => SemanticType::LocalAabb, + } + } + + pub fn is_finite(&self) -> bool { + let finite = |values: &[f32]| values.iter().all(|value| value.is_finite()); + match self { + Self::F32(value) => value.is_finite(), + Self::Vec2(value) => finite(value), + Self::Vec3(value) => finite(value), + Self::Vec4(value) => finite(value), + Self::Mat2(value) => value.iter().all(|column| finite(column)), + Self::Mat3(value) => value.iter().all(|column| finite(column)), + Self::Mat4(value) => value.iter().all(|column| finite(column)), + Self::LocalAabb { min, max } => finite(min) && finite(max), + _ => true, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompareOp { + GreaterThan, + LessThan, + Equals, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BooleanBinaryOp { + And, + Or, + Xor, + Xnor, +} + +/// All operand IDs refer to earlier entries in [`ExpressionPlan::expressions`]. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum ExpressionOp { + Literal { + literal: TypedLiteral, + }, + InstanceType { + mesh: u32, + }, + LocalAabb { + mesh: u32, + }, + Not { + value: ExprId, + }, + BooleanBinary { + operation: BooleanBinaryOp, + left: ExprId, + right: ExprId, + }, + CompareF32 { + operation: CompareOp, + left: ExprId, + right: ExprId, + }, + CompareU32 { + operation: CompareOp, + left: ExprId, + right: ExprId, + }, + VectorProject { + vector: ExprId, + index: u8, + }, + VectorConstruct { + components: Vec, + }, + MatrixColumn { + matrix: ExprId, + index: u8, + }, + MatrixConstruct { + columns: Vec, + }, + TypeWord { + value: ExprId, + index: u8, + }, + TypeConstruct { + words: Vec, + }, + U32Bit { + value: ExprId, + index: u8, + }, + U32Construct { + bits: Vec, + }, + AabbMin { + aabb: ExprId, + }, + AabbMax { + aabb: ExprId, + }, + FrustumCulled { + mesh: u32, + local_aabb: ExprId, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Expression { + pub semantic_type: SemanticType, + pub op: ExpressionOp, + pub origin: NodeOutputRef, + pub mesh_provenance: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExpressionPlan { + pub expressions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelinePredicatePlan { + pub execution: u32, + pub predicate: ExprId, + pub ordinal: u32, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstanceTraversalPlan { + pub mesh: u32, + pub expressions: ExpressionPlan, + pub pipelines: Vec, + pub requires_camera: bool, +} diff --git a/renderer/src/render_graph/mod.rs b/renderer/src/render_graph/mod.rs index e5efa98..a323b19 100644 --- a/renderer/src/render_graph/mod.rs +++ b/renderer/src/render_graph/mod.rs @@ -2,13 +2,15 @@ mod compiler; mod contracts; +mod expression; mod plan; mod registry; mod runtime; mod schema; -pub use compiler::{compile, mesh_predicate_matches, parse_and_compile}; +pub use compiler::{compile, parse_and_compile}; pub use contracts::*; +pub use expression::*; pub use plan::*; pub use registry::{CompiledGraphId, Registry}; pub use runtime::*; diff --git a/renderer/src/render_graph/plan.rs b/renderer/src/render_graph/plan.rs index 33b910f..c839618 100644 --- a/renderer/src/render_graph/plan.rs +++ b/renderer/src/render_graph/plan.rs @@ -16,6 +16,8 @@ pub struct CompiledGraph { pub culled_node_count: u32, pub culled_resource_count: u32, pub transient_slot_count: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_traversal: Option, } #[derive(Clone, Debug, Serialize)] @@ -47,22 +49,6 @@ pub enum ResourcePlan { allocation: Option, }, MeshData, - LocalAabbBuffer { - mesh: u32, - }, - BooleanFlagBuffer { - mesh: u32, - flag: MeshFlag, - }, - PipelineIndexStream { - mesh: u32, - }, - PipelineActivation { - pipeline_indices: u32, - }, - DrawStream { - mesh: u32, - }, } #[derive(Clone, Debug, Serialize)] @@ -95,10 +81,6 @@ pub struct CompiledSocketOutput { #[derive(Clone, Debug, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ExecutionKind { - CpuPreparation, - Compute { - work: ComputeWork, - }, Render { color_attachments: Vec, depth_stencil: Option, @@ -108,13 +90,6 @@ pub enum ExecutionKind { }, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ComputeWork { - FrustumCull, - MeshQuery, -} - #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ColorAttachmentPlan { @@ -196,17 +171,16 @@ pub enum NormalizedParameters { FrustumCull { camera: ActiveCamera, }, - MeshQuery { - visible_predicate: RuntimePredicate, - frustum_culled_predicate: RuntimePredicate, + ExpressionDefaults { + defaults: Vec, }, - PipelineRegistry, Pipeline { pipeline: String, depth_compare: CompareFunction, depth_write_enabled: bool, clear_depth: f32, clear_color: [f64; 4], + predicate_default: bool, }, FullscreenCopy, ColorBalance { diff --git a/renderer/src/render_graph/runtime.rs b/renderer/src/render_graph/runtime.rs index 5329f86..416fe33 100644 --- a/renderer/src/render_graph/runtime.rs +++ b/renderer/src/render_graph/runtime.rs @@ -166,12 +166,6 @@ pub struct RuntimeAllocationClass { pub slots: Vec, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct MeshQueryRuntimeKey { - pub visible: RuntimePredicate, - pub frustum_culled: RuntimePredicate, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub struct RuntimeExecution { pub execution: u32, @@ -182,13 +176,13 @@ pub struct RuntimeExecution { pub struct RuntimeAllocationPlan { pub classes: Vec, pub resource_allocations: Vec>, - pub query: MeshQueryRuntimeKey, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct RuntimePlan { pub allocations: RuntimeAllocationPlan, pub executions: Vec, + pub instance_traversal: Option, pub surface: RuntimeSurfaceContract, } @@ -381,11 +375,7 @@ fn valid_pipeline_name(name: &str) -> bool { fn execution_supported(key: &str) -> bool { contract(key).is_some_and(|contract| { - contract.fullscreen_policy.is_some() - || matches!( - key, - "frustum_cull" | "mesh_query" | "pipeline_registry" | "pipeline" | "frame_out" - ) + contract.fullscreen_policy.is_some() || matches!(key, "pipeline" | "frame_out") }) } @@ -396,19 +386,6 @@ fn resource_is_mesh(graph: &CompiledGraph, id: u32) -> bool { }) } -fn has_exact_producer( - graph: &CompiledGraph, - consumer: usize, - resource: u32, - executor: &str, - socket: &str, -) -> bool { - graph.executions[..consumer].iter().any(|execution| { - execution.executor.key == executor - && matches!(execution.outputs.as_slice(), [output] if output.socket == socket && output.resource == resource) - }) -} - fn texture_descriptor<'a>( graph: &'a CompiledGraph, id: u32, @@ -704,181 +681,15 @@ fn validate_fullscreen_execution( Ok(()) } -fn validate_compute_execution( - graph: &CompiledGraph, - i: usize, - execution: &CompiledExecution, -) -> Result<(), GraphError> { - let path = |field| format!("executions[{i}].{field}"); - match execution.executor.key.as_str() { - "frustum_cull" => { - if !matches!( - execution.parameters, - NormalizedParameters::FrustumCull { - camera: ActiveCamera::Active - } - ) { - return Err(invalid( - "frustum cull parameters mismatch", - path("parameters"), - )); - } - if !matches!( - execution.kind, - ExecutionKind::Compute { - work: ComputeWork::FrustumCull - } - ) { - return Err(invalid("frustum cull work mismatch", path("kind"))); - } - let [mesh, aabbs] = execution.inputs.as_slice() else { - return Err(invalid("frustum cull inputs mismatch", path("inputs"))); - }; - let [flags] = execution.outputs.as_slice() else { - return Err(invalid("frustum cull outputs mismatch", path("outputs"))); - }; - if mesh.socket != "mesh" - || aabbs.socket != "localAabbs" - || flags.socket != "isFrustumCulled" - { - return Err(invalid( - "frustum cull socket order mismatch", - path("inputs"), - )); - } - if !matches!(execution.accesses.as_slice(), - [CompiledAccess { socket: s0, resource: r0, mode: AccessMode::StorageRead }, - CompiledAccess { socket: s1, resource: r1, mode: AccessMode::StorageRead }, - CompiledAccess { socket: s2, resource: r2, mode: AccessMode::StorageWrite { full_overwrite: true } }] - if s0 == "mesh" && *r0 == mesh.resource && s1 == "localAabbs" && *r1 == aabbs.resource - && s2 == "isFrustumCulled" && *r2 == flags.resource) - { - return Err(invalid("frustum cull accesses mismatch", path("accesses"))); - } - if !resource_is_mesh(graph, mesh.resource) - || !matches!(graph.resources[aabbs.resource as usize], CompiledResource { semantic_type: SemanticType::LocalAabbBuffer, plan: ResourcePlan::LocalAabbBuffer { mesh: m }, .. } if m == mesh.resource) - || !matches!(graph.resources[flags.resource as usize], CompiledResource { semantic_type: SemanticType::BooleanFlagBuffer, plan: ResourcePlan::BooleanFlagBuffer { mesh: m, flag: MeshFlag::IsFrustumCulled }, .. } if m == mesh.resource) - { - return Err(invalid( - "frustum cull mesh provenance mismatch", - path("inputs"), - )); - } - } - "mesh_query" => { - let NormalizedParameters::MeshQuery { - visible_predicate, - frustum_culled_predicate, - } = execution.parameters - else { - return Err(invalid( - "mesh query parameters mismatch", - path("parameters"), - )); - }; - if (visible_predicate == RuntimePredicate::Never) - != (frustum_culled_predicate == RuntimePredicate::Never) - { - return Err(invalid( - "mesh query never predicates must be paired", - path("parameters"), - )); - } - if !matches!( - execution.kind, - ExecutionKind::Compute { - work: ComputeWork::MeshQuery - } - ) { - return Err(invalid("mesh query work mismatch", path("kind"))); - } - let active = |p| { - matches!( - p, - RuntimePredicate::RequiredTrue | RuntimePredicate::RequiredFalse - ) - }; - let mut sockets = vec!["mesh"]; - if active(visible_predicate) { - sockets.push("isVisible"); - } - if active(frustum_culled_predicate) { - sockets.push("isFrustumCulled"); - } - if execution.inputs.len() != sockets.len() - || execution - .inputs - .iter() - .zip(&sockets) - .any(|(v, s)| v.socket != *s) - || !matches!(execution.outputs.as_slice(), [CompiledSocketOutput { socket, .. }] if socket == "draws") - { - return Err(invalid("mesh query socket order mismatch", path("inputs"))); - } - let output = execution.outputs[0].resource; - if execution.accesses.len() != sockets.len() + 1 - || execution - .inputs - .iter() - .zip(&execution.accesses) - .any(|(input, access)| { - access.socket != input.socket - || access.resource != input.resource - || !matches!(access.mode, AccessMode::StorageRead) - }) - || !matches!(&execution.accesses[sockets.len()], CompiledAccess { socket, resource, mode: AccessMode::StorageWrite { full_overwrite: true } } if socket == "draws" && *resource == output) - { - return Err(invalid("mesh query accesses mismatch", path("accesses"))); - } - let mesh = execution.inputs[0].resource; - if !resource_is_mesh(graph, mesh) { - return Err(invalid( - "mesh query mesh provenance mismatch", - path("inputs"), - )); - } - for input in execution.inputs.iter().skip(1) { - let flag = if input.socket == "isVisible" { - MeshFlag::IsVisible - } else { - MeshFlag::IsFrustumCulled - }; - if !matches!(graph.resources[input.resource as usize], CompiledResource { semantic_type: SemanticType::BooleanFlagBuffer, plan: ResourcePlan::BooleanFlagBuffer { mesh: m, flag: f }, .. } if m == mesh && f == flag) - { - return Err(invalid( - "mesh query flag provenance mismatch", - path("inputs"), - )); - } - if flag == MeshFlag::IsFrustumCulled - && !has_exact_producer( - graph, - i, - input.resource, - "frustum_cull", - "isFrustumCulled", - ) - { - return Err(invalid( - "mesh query frustum flag producer mismatch", - path("inputs"), - )); - } - } - if !matches!(graph.resources[output as usize], CompiledResource { semantic_type: SemanticType::DrawStream, plan: ResourcePlan::DrawStream { mesh: m }, .. } if m == mesh) - { - return Err(invalid( - "mesh query output provenance mismatch", - path("outputs"), - )); - } - } - _ => {} - } - Ok(()) -} - fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { + for (i, resource) in graph.resources.iter().enumerate() { + if resource.semantic_type.is_virtual() { + return Err(invalid( + "virtual semantic type was materialized", + format!("resources[{i}].semanticType"), + )); + } + } for (i, execution) in graph.executions.iter().enumerate() { if !execution_supported(&execution.executor.key) { return Err(error( @@ -939,7 +750,6 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { } referenced.insert(access.resource); } - validate_compute_execution(graph, i, execution)?; validate_fullscreen_execution(graph, i, execution, contract)?; for resource in referenced { uses.get_mut(resource as usize) @@ -1071,6 +881,291 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { Ok(()) } +fn validate_instance_traversal(graph: &CompiledGraph) -> Result<(), GraphError> { + let pipeline_indices: Vec<_> = graph + .executions + .iter() + .enumerate() + .filter_map(|(i, e)| (e.executor.key == "pipeline").then_some(i as u32)) + .collect(); + let Some(plan) = &graph.instance_traversal else { + return if pipeline_indices.is_empty() { + Ok(()) + } else { + Err(invalid( + "live pipelines require instance traversal", + "instanceTraversal", + )) + }; + }; + if pipeline_indices.is_empty() { + return Err(invalid( + "instance traversal has no live pipelines", + "instanceTraversal", + )); + } + if !resource_is_mesh(graph, plan.mesh) { + return Err(invalid( + "traversal mesh is invalid", + "instanceTraversal.mesh", + )); + } + let expressions = &plan.expressions.expressions; + if expressions.len() > MAX_EXPRESSIONS || plan.pipelines.len() > MAX_PREDICATE_PIPELINES { + return Err(invalid( + "instance traversal exceeds runtime limits", + "instanceTraversal", + )); + } + let ty = |id: ExprId| expressions.get(id.0 as usize).map(|e| e.semantic_type); + for (i, expression) in expressions.iter().enumerate() { + let path = format!("instanceTraversal.expressions.expressions[{i}]"); + let ids: Vec = match &expression.op { + ExpressionOp::Literal { literal } => { + if literal.semantic_type() != expression.semantic_type || !literal.is_finite() { + return Err(invalid("literal type or value is invalid", &path)); + } + vec![] + } + ExpressionOp::InstanceType { mesh } => { + if expression.semantic_type != SemanticType::U32x16 || *mesh != plan.mesh { + return Err(invalid("instance type signature is invalid", &path)); + } + vec![] + } + ExpressionOp::LocalAabb { mesh } => { + if expression.semantic_type != SemanticType::LocalAabb || *mesh != plan.mesh { + return Err(invalid("local aabb signature is invalid", &path)); + } + vec![] + } + ExpressionOp::Not { value } => { + if expression.semantic_type != SemanticType::Bool + || ty(*value) != Some(SemanticType::Bool) + { + return Err(invalid("not signature is invalid", &path)); + } + vec![*value] + } + ExpressionOp::BooleanBinary { left, right, .. } => { + if expression.semantic_type != SemanticType::Bool + || ty(*left) != Some(SemanticType::Bool) + || ty(*right) != Some(SemanticType::Bool) + { + return Err(invalid("boolean signature is invalid", &path)); + } + vec![*left, *right] + } + ExpressionOp::CompareF32 { left, right, .. } => { + if expression.semantic_type != SemanticType::Bool + || ty(*left) != Some(SemanticType::F32) + || ty(*right) != Some(SemanticType::F32) + { + return Err(invalid("f32 comparison signature is invalid", &path)); + } + vec![*left, *right] + } + ExpressionOp::CompareU32 { left, right, .. } => { + if expression.semantic_type != SemanticType::Bool + || ty(*left) != Some(SemanticType::U32) + || ty(*right) != Some(SemanticType::U32) + { + return Err(invalid("u32 comparison signature is invalid", &path)); + } + vec![*left, *right] + } + ExpressionOp::VectorProject { vector, index } => { + let n = match ty(*vector) { + Some(SemanticType::Vec2) => 2, + Some(SemanticType::Vec3) => 3, + Some(SemanticType::Vec4) => 4, + _ => 0, + }; + if expression.semantic_type != SemanticType::F32 || usize::from(*index) >= n { + return Err(invalid("vector projection signature is invalid", &path)); + } + vec![*vector] + } + ExpressionOp::VectorConstruct { components } => { + let n = match expression.semantic_type { + SemanticType::Vec2 => 2, + SemanticType::Vec3 => 3, + SemanticType::Vec4 => 4, + _ => 0, + }; + if components.len() != n + || components + .iter() + .any(|id| ty(*id) != Some(SemanticType::F32)) + { + return Err(invalid("vector constructor signature is invalid", &path)); + } + components.clone() + } + ExpressionOp::MatrixColumn { matrix, index } => { + let (n, out) = match ty(*matrix) { + Some(SemanticType::Mat2) => (2, SemanticType::Vec2), + Some(SemanticType::Mat3) => (3, SemanticType::Vec3), + Some(SemanticType::Mat4) => (4, SemanticType::Vec4), + _ => (0, SemanticType::Bool), + }; + if usize::from(*index) >= n || expression.semantic_type != out { + return Err(invalid("matrix column signature is invalid", &path)); + } + vec![*matrix] + } + ExpressionOp::MatrixConstruct { columns } => { + let (n, col) = match expression.semantic_type { + SemanticType::Mat2 => (2, SemanticType::Vec2), + SemanticType::Mat3 => (3, SemanticType::Vec3), + SemanticType::Mat4 => (4, SemanticType::Vec4), + _ => (0, SemanticType::Bool), + }; + if columns.len() != n || columns.iter().any(|id| ty(*id) != Some(col)) { + return Err(invalid("matrix constructor signature is invalid", &path)); + } + columns.clone() + } + ExpressionOp::TypeWord { value, index } => { + if expression.semantic_type != SemanticType::U32 + || ty(*value) != Some(SemanticType::U32x16) + || *index >= 16 + { + return Err(invalid("type word signature is invalid", &path)); + } + vec![*value] + } + ExpressionOp::TypeConstruct { words } => { + if expression.semantic_type != SemanticType::U32x16 + || words.len() != 16 + || words.iter().any(|id| ty(*id) != Some(SemanticType::U32)) + { + return Err(invalid("type constructor signature is invalid", &path)); + } + words.clone() + } + ExpressionOp::U32Bit { value, index } => { + if expression.semantic_type != SemanticType::Bool + || ty(*value) != Some(SemanticType::U32) + || *index >= 32 + { + return Err(invalid("bit signature is invalid", &path)); + } + vec![*value] + } + ExpressionOp::U32Construct { bits } => { + if expression.semantic_type != SemanticType::U32 + || bits.len() != 32 + || bits.iter().any(|id| ty(*id) != Some(SemanticType::Bool)) + { + return Err(invalid("u32 constructor signature is invalid", &path)); + } + bits.clone() + } + ExpressionOp::AabbMin { aabb } | ExpressionOp::AabbMax { aabb } => { + if expression.semantic_type != SemanticType::Vec3 + || ty(*aabb) != Some(SemanticType::LocalAabb) + { + return Err(invalid("aabb projection signature is invalid", &path)); + } + vec![*aabb] + } + ExpressionOp::FrustumCulled { mesh, local_aabb } => { + if expression.semantic_type != SemanticType::Bool + || *mesh != plan.mesh + || ty(*local_aabb) != Some(SemanticType::LocalAabb) + || expressions + .get(local_aabb.0 as usize) + .and_then(|e| e.mesh_provenance) + != Some(plan.mesh) + { + return Err(invalid("frustum signature or provenance is invalid", &path)); + } + vec![*local_aabb] + } + }; + if ids.iter().any(|id| id.0 as usize >= i) { + return Err(invalid("expression operands must precede consumer", &path)); + } + let expected_provenance = match &expression.op { + ExpressionOp::Literal { .. } => None, + ExpressionOp::InstanceType { .. } | ExpressionOp::LocalAabb { .. } => Some(plan.mesh), + _ => { + let mut p = ids + .iter() + .filter_map(|id| expressions[id.0 as usize].mesh_provenance); + let first = p.next(); + if p.any(|v| Some(v) != first) { + return Err(invalid("expression mixes mesh provenance", &path)); + } + first + } + }; + if expression.mesh_provenance != expected_provenance { + return Err(invalid("expression provenance is not canonical", &path)); + } + } + let mut seen = HashSet::new(); + let mut reachable = vec![false; expressions.len()]; + for (ordinal, entry) in plan.pipelines.iter().enumerate() { + if entry.ordinal as usize != ordinal + || pipeline_indices.get(ordinal) != Some(&entry.execution) + || !seen.insert(entry.execution) + || ty(entry.predicate) != Some(SemanticType::Bool) + { + return Err(invalid( + "pipeline predicate table is not canonical", + format!("instanceTraversal.pipelines[{ordinal}]"), + )); + } + let mut stack = vec![entry.predicate]; + while let Some(id) = stack.pop() { + if reachable[id.0 as usize] { + continue; + } + reachable[id.0 as usize] = true; + stack.extend(expression_operands(&expressions[id.0 as usize].op)); + } + } + if plan.pipelines.len() != pipeline_indices.len() { + return Err(invalid( + "pipeline predicate table is incomplete", + "instanceTraversal.pipelines", + )); + } + let requires_camera = expressions + .iter() + .enumerate() + .any(|(i, e)| reachable[i] && matches!(e.op, ExpressionOp::FrustumCulled { .. })); + if plan.requires_camera != requires_camera { + return Err(invalid( + "requires_camera is not canonical", + "instanceTraversal.requiresCamera", + )); + } + Ok(()) +} + +fn expression_operands(op: &ExpressionOp) -> Vec { + match op { + ExpressionOp::Not { value } + | ExpressionOp::VectorProject { vector: value, .. } + | ExpressionOp::MatrixColumn { matrix: value, .. } + | ExpressionOp::TypeWord { value, .. } + | ExpressionOp::U32Bit { value, .. } => vec![*value], + ExpressionOp::AabbMin { aabb } | ExpressionOp::AabbMax { aabb } => vec![*aabb], + ExpressionOp::FrustumCulled { local_aabb, .. } => vec![*local_aabb], + ExpressionOp::BooleanBinary { left, right, .. } + | ExpressionOp::CompareF32 { left, right, .. } + | ExpressionOp::CompareU32 { left, right, .. } => vec![*left, *right], + ExpressionOp::VectorConstruct { components } => components.clone(), + ExpressionOp::MatrixConstruct { columns } => columns.clone(), + ExpressionOp::TypeConstruct { words } => words.clone(), + ExpressionOp::U32Construct { bits } => bits.clone(), + _ => vec![], + } +} + pub fn prepare_runtime_plan( graph: &CompiledGraph, surface: RuntimeSurfaceContract, @@ -1095,35 +1190,13 @@ pub fn prepare_runtime_plan( )); } let mut frame_out_index = None; - let mut query = None; let mut executions = Vec::with_capacity(graph.executions.len()); for (i, execution) in graph.executions.iter().enumerate() { let path = format!("executions[{i}]"); match execution.executor.key.as_str() { - "mesh_query" => { - let NormalizedParameters::MeshQuery { - visible_predicate, - frustum_culled_predicate, - } = &execution.parameters - else { - return Err(invalid("mesh query parameters mismatch", &path)); - }; - let key = MeshQueryRuntimeKey { - visible: *visible_predicate, - frustum_culled: *frustum_culled_predicate, - }; - if query.replace(key).is_some() { - return Err(error( - "GRAPH_EXECUTION_UNSUPPORTED", - "multiple draw stream queries", - &path, - )); - } - } - "pipeline_registry" | "pipeline" => {} + "pipeline" => {} _ if contract(&execution.executor.key) .is_some_and(|contract| contract.fullscreen_policy.is_some()) => {} - "frustum_cull" => {} "frame_out" => { if frame_out_index.replace(i).is_some() { return Err(error( @@ -1153,115 +1226,7 @@ pub fn prepare_runtime_plan( "executions", ) })?; - let query = query.ok_or_else(|| { - error( - "GRAPH_EXECUTION_UNSUPPORTED", - "one mesh query is required", - "executions", - ) - })?; - - for (i, execution) in graph.executions.iter().enumerate() { - if execution.executor.key != "pipeline_registry" { - continue; - } - if !matches!(execution.parameters, NormalizedParameters::PipelineRegistry) { - return Err(invalid( - "pipeline registry parameters mismatch", - format!("executions[{i}].parameters"), - )); - } - if !matches!(execution.kind, ExecutionKind::CpuPreparation) { - return Err(invalid( - "pipeline registry kind mismatch", - format!("executions[{i}].kind"), - )); - } - let [CompiledSocketInput { - socket: input_socket, - resource: pipeline_indices, - }] = execution.inputs.as_slice() - else { - return Err(invalid( - "pipeline registry input shape mismatch", - format!("executions[{i}].inputs"), - )); - }; - if input_socket != "pipelineIndices" { - return Err(invalid( - "pipeline registry input socket mismatch", - format!("executions[{i}].inputs"), - )); - } - let [CompiledSocketOutput { - socket: output_socket, - resource: activation, - }] = execution.outputs.as_slice() - else { - return Err(invalid( - "pipeline registry output shape mismatch", - format!("executions[{i}].outputs"), - )); - }; - if output_socket != "activation" { - return Err(invalid( - "pipeline registry output socket mismatch", - format!("executions[{i}].outputs"), - )); - } - if !matches!( - execution.accesses.as_slice(), - [CompiledAccess { socket, resource, mode: AccessMode::SemanticRead }] - if socket == "pipelineIndices" && resource == pipeline_indices - ) { - return Err(invalid( - "pipeline registry access mismatch", - format!("executions[{i}].accesses"), - )); - } - let indices_resource = - graph - .resources - .get(*pipeline_indices as usize) - .ok_or_else(|| { - invalid( - "pipeline index stream is out of bounds", - format!("executions[{i}].inputs"), - ) - })?; - let ResourcePlan::PipelineIndexStream { mesh } = indices_resource.plan else { - return Err(invalid( - "pipeline registry input is not a pipeline index stream", - format!("resources[{pipeline_indices}].plan"), - )); - }; - if indices_resource.semantic_type != SemanticType::PipelineIndexStream - || !graph.resources.get(mesh as usize).is_some_and(|resource| { - resource.semantic_type == SemanticType::MeshData - && matches!(resource.plan, ResourcePlan::MeshData) - }) - { - return Err(invalid( - "pipeline index stream mesh provenance is invalid", - format!("resources[{pipeline_indices}].plan"), - )); - } - let activation_resource = graph.resources.get(*activation as usize).ok_or_else(|| { - invalid( - "pipeline activation is out of bounds", - format!("executions[{i}].outputs"), - ) - })?; - if activation_resource.semantic_type != SemanticType::PipelineActivation - || !matches!(activation_resource.plan, ResourcePlan::PipelineActivation { pipeline_indices: source } if source == *pipeline_indices) - || activation_resource.producer_execution != Some(i as u32) - { - return Err(invalid( - "pipeline activation provenance is invalid", - format!("resources[{activation}].plan"), - )); - } - } + validate_instance_traversal(graph)?; for (i, execution) in graph.executions.iter().enumerate() { if execution.executor.key != "pipeline" { @@ -1305,9 +1270,7 @@ pub fn prepare_runtime_plan( format!("executions[{i}].kind"), )); }; - let [mesh_input, draws_input, activation_input, color_input, depth_input] = - execution.inputs.as_slice() - else { + let [mesh_input, color_input, depth_input] = execution.inputs.as_slice() else { return Err(invalid( "pipeline input shape mismatch", format!("executions[{i}].inputs"), @@ -1315,11 +1278,9 @@ pub fn prepare_runtime_plan( }; if [ mesh_input.socket.as_str(), - draws_input.socket.as_str(), - activation_input.socket.as_str(), color_input.socket.as_str(), depth_input.socket.as_str(), - ] != ["mesh", "draws", "activation", "colorTarget", "depthTarget"] + ] != ["mesh", "colorTarget", "depthTarget"] { return Err(invalid( "pipeline input sockets mismatch", @@ -1394,9 +1355,7 @@ pub fn prepare_runtime_plan( format!("executions[{i}].kind"), )); } - let [mesh_access, draws_access, activation_access, color_access, depth_access] = - execution.accesses.as_slice() - else { + let [mesh_access, color_access, depth_access] = execution.accesses.as_slice() else { return Err(invalid( "pipeline access shape mismatch", format!("executions[{i}].accesses"), @@ -1405,12 +1364,6 @@ pub fn prepare_runtime_plan( if mesh_access.socket != "mesh" || mesh_access.resource != mesh_input.resource || !matches!(mesh_access.mode, AccessMode::SemanticRead) - || draws_access.socket != "draws" - || draws_access.resource != draws_input.resource - || !matches!(draws_access.mode, AccessMode::IndirectRead) - || activation_access.socket != "activation" - || activation_access.resource != activation_input.resource - || !matches!(activation_access.mode, AccessMode::SemanticRead) { return Err(invalid( "pipeline semantic accesses mismatch", @@ -1459,73 +1412,6 @@ pub fn prepare_runtime_plan( format!("resources[{}].plan", mesh_input.resource), )); } - let draws_mesh = match graph.resources.get(draws_input.resource as usize) { - Some(CompiledResource { - semantic_type: SemanticType::DrawStream, - plan: ResourcePlan::DrawStream { mesh }, - .. - }) => *mesh, - _ => { - return Err(invalid( - "pipeline draw stream is invalid", - format!("resources[{}].plan", draws_input.resource), - )) - } - }; - let activation_resource = graph - .resources - .get(activation_input.resource as usize) - .ok_or_else(|| { - invalid( - "pipeline activation is out of bounds", - format!("executions[{i}].inputs"), - ) - })?; - let indices = match activation_resource.plan { - ResourcePlan::PipelineActivation { pipeline_indices } - if activation_resource.semantic_type == SemanticType::PipelineActivation => - { - pipeline_indices - } - _ => { - return Err(invalid( - "pipeline activation is invalid", - format!("resources[{}].plan", activation_input.resource), - )) - } - }; - let activation_mesh = match graph.resources.get(indices as usize) { - Some(CompiledResource { - semantic_type: SemanticType::PipelineIndexStream, - plan: ResourcePlan::PipelineIndexStream { mesh }, - .. - }) => *mesh, - _ => { - return Err(invalid( - "pipeline activation index stream is invalid", - format!("resources[{indices}].plan"), - )) - } - }; - let valid_activation_producer = activation_resource - .producer_execution - .and_then(|producer| graph.executions.get(producer as usize)) - .is_some_and(|producer| producer.executor.key == "pipeline_registry"); - if draws_mesh != mesh_input.resource - || activation_mesh != mesh_input.resource - || !valid_activation_producer - { - return Err(invalid( - "pipeline mesh provenance disagrees", - format!("executions[{i}].inputs"), - )); - } - if !has_exact_producer(graph, i, draws_input.resource, "mesh_query", "draws") { - return Err(invalid( - "pipeline draw stream producer mismatch", - format!("executions[{i}].inputs"), - )); - } for (output, target) in [ (color_output.resource, color_input.resource), (depth_output.resource, depth_input.resource), @@ -1969,9 +1855,9 @@ pub fn prepare_runtime_plan( allocations: RuntimeAllocationPlan { classes, resource_allocations, - query, }, executions, + instance_traversal: graph.instance_traversal.clone(), surface, }) } diff --git a/renderer/src/render_graph/schema.rs b/renderer/src/render_graph/schema.rs index ce089ab..f0be140 100644 --- a/renderer/src/render_graph/schema.rs +++ b/renderer/src/render_graph/schema.rs @@ -106,41 +106,6 @@ pub struct TextureDescriptor { pub view_formats: Vec, } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "snake_case")] -pub enum TriStatePredicate { - Any, - RequiredTrue, - RequiredFalse, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "snake_case")] -pub enum RuntimePredicate { - Any, - RequiredTrue, - RequiredFalse, - Never, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "camelCase")] -pub enum MeshFlag { - IsVisible, - IsFrustumCulled, -} - -impl MeshFlag { - pub const ORDERED: [Self; 2] = [Self::IsVisible, Self::IsFrustumCulled]; - - pub const fn input_socket(self) -> &'static str { - match self { - Self::IsVisible => "isVisible", - Self::IsFrustumCulled => "isFrustumCulled", - } - } -} - #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum CompareFunction { diff --git a/renderer/src/render_graph/tests.rs b/renderer/src/render_graph/tests.rs index 218ea20..4d71c69 100644 --- a/renderer/src/render_graph/tests.rs +++ b/renderer/src/render_graph/tests.rs @@ -1,3589 +1,147 @@ -use std::collections::BTreeSet; - -use super::*; use serde_json::{json, Value}; +use super::*; + +fn compile_value(value: Value) -> Result { + compile(serde_json::from_value(value).unwrap()) +} + fn input(node: &str, socket: &str) -> Value { - json!({"node":node,"socket":socket}) + json!({ "node": node, "socket": socket }) } -fn node(id: &str, key: &str, mut parameters: Value, inputs: Value) -> Value { - if key == "frame_out" && parameters.as_object().is_some_and(|p| p.is_empty()) { - parameters = json!({"surfaceFormat":"preferred","hdrEnabled":false,"toneMapper":"aces","exposureStops":0,"outputTransfer":"srgb","scaleMode":"stretch","filter":"linear","backgroundColor":[0,0,0,1]}); - } - json!({"id":id,"state":"enabled","executor":{"key":key,"version":if key == "frame_out" { 3 } else { 1 }},"parameters":parameters,"inputs":inputs}) + +fn texture(id: &str, format: &str) -> Value { + json!({ + "id": id, "state": "enabled", "executor": { "key": "texture", "version": 1 }, + "parameters": { "residency": "transient", "texture": { + "dimension": "d2", "format": format, + "extent": { "kind": "surface_relative", "width": { "numerator": 1, "denominator": 1 }, + "height": { "numerator": 1, "denominator": 1 }, "depthOrArrayLayers": 1 }, + "mipLevelCount": 1, "sampleCount": 1, "viewFormats": [] } }, "inputs": {} + }) } -fn texture(format: &str, residency: &str) -> Value { - json!({"texture":{"dimension":"d2","format":format,"extent":{"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":1},"mipLevelCount":1,"sampleCount":1,"viewFormats":[]},"residency":residency}) + +fn node(id: &str, key: &str, version: u32, parameters: Value, inputs: Value) -> Value { + json!({ "id": id, "state": "enabled", "executor": { "key": key, "version": version }, + "parameters": parameters, "inputs": inputs }) } + pub(crate) fn full_cull_graph() -> Value { - json!({"schemaVersion":2,"graphId":"full","revision":1,"nodes":[ - node("color","texture",texture("rgba8_unorm","transient"),json!({})), - node("depth","texture",texture("depth32_float","transient"),json!({})), - node("mesh","mesh",json!({}),json!({})), - node("cull","frustum_cull",json!({"camera":"active"}),json!({"mesh":input("mesh","mesh"),"localAabbs":input("mesh","localAabbs")})), - node("query","mesh_query",json!({"visiblePredicate":"required_true","visibleDefault":true,"frustumCulledPredicate":"required_false","frustumCulledDefault":false}),json!({"mesh":input("mesh","mesh"),"isVisible":input("mesh","isVisible"),"isFrustumCulled":input("cull","isFrustumCulled")})), - node("registry","pipeline_registry",json!({}),json!({"pipelineIndices":input("mesh","pipelineIndices")})), - node("pipeline_main","pipeline",json!({"pipeline":"gltf_standard","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1]}),json!({"mesh":input("mesh","mesh"),"draws":input("query","draws"),"activation":input("registry","activation"),"colorTarget":input("color","texture"),"depthTarget":input("depth","texture")})), - node("frame_out","frame_out",json!({}),json!({"color":input("pipeline_main","color")})) + json!({ "schemaVersion": 2, "graphId": "typed", "revision": 1, "nodes": [ + texture("color", "rgba16_float"), texture("depth", "depth32_float"), + node("mesh", "mesh", 2, json!({}), json!({})), + node("words", "separate_u32x16", 1, json!({"valueDefault":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}), + json!({"value":input("mesh","type")})), + node("bits", "separate_u32_bits", 1, json!({"valueDefault":0}), json!({"value":input("words","word0")})), + node("cull", "frustum_cull", 2, json!({"camera":"active"}), + json!({"mesh":input("mesh","mesh"),"localAabb":input("mesh","localAabb")})), + node("visible", "not", 1, json!({"operandDefault":false}), json!({"operand":input("cull","isFrustumCulled")})), + node("class", "and", 1, json!({"leftDefault":true,"rightDefault":true}), + json!({"left":input("bits","bit0"),"right":input("visible","value")})), + node("pipeline", "pipeline", 2, + json!({"pipeline":"gltf_standard","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), + json!({"mesh":input("mesh","mesh"),"predicate":input("class","value"),"colorTarget":input("color","texture"),"depthTarget":input("depth","texture")})), + node("frame", "frame_out", 3, + json!({"surfaceFormat":"preferred","hdrEnabled":true,"toneMapper":"aces","exposureStops":0, + "outputTransfer":"srgb","scaleMode":"stretch","filter":"linear","backgroundColor":[0,0,0,1]}), + json!({"color":input("pipeline","color")})) ]}) } -fn pipeline_node(id: &str, color: Value, depth: Value) -> Value { - node( - id, - "pipeline", - json!({"pipeline":"gltf_standard","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1]}), - json!({ - "mesh":input("mesh","mesh"), - "draws":input("query","draws"), - "colorTarget":color, - "depthTarget":depth, - "activation":input("registry","activation") - }), - ) -} -fn render_support_nodes() -> Vec { - vec![ - node("mesh", "mesh", json!({}), json!({})), - node( - "query", - "mesh_query", - json!({"visiblePredicate":"required_true","visibleDefault":true,"frustumCulledPredicate":"any","frustumCulledDefault":false}), - json!({"mesh":input("mesh","mesh"),"isVisible":input("mesh","isVisible")}), - ), - node( - "registry", - "pipeline_registry", - json!({}), - json!({"pipelineIndices":input("mesh","pipelineIndices")}), - ), - ] -} -fn graph(nodes: Vec) -> Value { - json!({"schemaVersion":2,"graphId":"hazards","revision":1,"nodes":nodes}) -} -fn node_index(graph: &Value, id: &str) -> usize { - graph["nodes"] - .as_array() - .unwrap() - .iter() - .position(|node| node["id"] == id) - .unwrap() -} -fn node_path(graph: &Value, id: &str, suffix: &str) -> String { - format!("nodes[{}].{suffix}", node_index(graph, id)) -} -fn hdr_copy_graph() -> Value { - let mut nodes = vec![ - node( - "color", - "texture", - texture("rgba8_unorm", "transient"), - json!({}), - ), - node( - "hdr", - "texture", - texture("rgba16_float", "transient"), - json!({}), - ), - depth_spec("depth", "transient"), - ]; - nodes.extend(render_support_nodes()); - nodes.extend([ - pipeline_node( - "pipeline_main", - input("hdr", "texture"), - input("depth", "texture"), - ), - node( - "copy", - "fullscreen_copy", - json!({}), - json!({"source":input("pipeline_main","color"),"colorTarget":input("color","texture")}), - ), - node( - "frame_out", - "frame_out", - json!({}), - json!({"color":input("copy","color")}), - ), - ]); - graph(nodes) -} - -fn bloom_composite_graph() -> Value { - let mut half = texture("rgba16_float", "transient"); - half["texture"]["extent"]["width"] = json!({"numerator":1,"denominator":2}); - half["texture"]["extent"]["height"] = json!({"numerator":1,"denominator":2}); - let mut bloom_depth = texture("depth32_float", "transient"); - bloom_depth["texture"]["extent"] = half["texture"]["extent"].clone(); - let mut nodes = vec![ - node( - "color", - "texture", - texture("rgba8_unorm", "transient"), - json!({}), - ), - node( - "source", - "texture", - texture("rgba16_float", "transient"), - json!({}), - ), - node("bloom", "texture", half, json!({})), - node( - "target", - "texture", - texture("rgba16_float", "transient"), - json!({}), - ), - depth_spec("source_depth", "transient"), - node("bloom_depth", "texture", bloom_depth, json!({})), - ]; - nodes.extend(render_support_nodes()); - nodes.extend([ - pipeline_node( - "source_writer", - input("source", "texture"), - input("source_depth", "texture"), - ), - pipeline_node( - "bloom_writer", - input("bloom", "texture"), - input("bloom_depth", "texture"), - ), - node( - "composite", - "bloom_composite", - json!({"intensity":1.0}), - json!({"source":input("source_writer","color"),"bloom":input("bloom_writer","color"),"colorTarget":input("target","texture")}), - ), - node( - "to_surface", - "fullscreen_copy", - json!({}), - json!({"source":input("composite","color"),"colorTarget":input("color","texture")}), - ), - node( - "frame_out", - "frame_out", - json!({}), - json!({"color":input("to_surface","color")}), - ), - ]); - graph(nodes) -} - -fn cyclic_pipelines() -> Vec { - vec![ - pipeline_node("A", input("B", "color"), input("B", "depth")), - pipeline_node("B", input("A", "color"), input("A", "depth")), - ] -} -fn compile_graph(v: Value) -> CompiledGraph { - super::compile(serde_json::from_value(v).unwrap()).unwrap() -} #[test] -fn fullscreen_copy_hdr_graph_lowers_versions_accesses_and_usage() { - let p = compile_graph(hdr_copy_graph()); +fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() { + assert_eq!(contract("mesh").unwrap().version, 2); + assert_eq!(contract("pipeline").unwrap().version, 2); assert_eq!( - p.executions - .iter() - .map(|execution| execution.id.as_str()) - .collect::>(), - ["query", "registry", "pipeline_main", "copy", "frame_out"] - ); - for (node, socket) in [ - ("pipeline_main", "color"), - ("pipeline_main", "depth"), - ("copy", "color"), - ] { - assert!(matches!( - resource_by_origin(&p, node, socket).plan, - ResourcePlan::Texture { version: 0, .. } - )); - } - let copy = execution(&p, "copy"); - let source = resource_by_origin(&p, "pipeline_main", "color"); - let color = resource_by_origin(&p, "copy", "color"); - assert!(copy.accesses.iter().any(|access| access.resource - == p.resources - .iter() - .position(|resource| std::ptr::eq(resource, source)) - .unwrap() as u32 - && access.mode == AccessMode::SampledTexture)); - let color_id = p - .resources - .iter() - .position(|resource| std::ptr::eq(resource, color)) - .unwrap() as u32; - assert!(matches!( - ©.kind, - ExecutionKind::Render { color_attachments, depth_stencil: None } - if color_attachments[0].resource == color_id - && color_attachments[0].load == NormalizedColorLoad::Clear { value: [0.0; 4] } - )); - assert!(copy.accesses.iter().any(|access| matches!( - access.mode, - AccessMode::ColorAttachment { - full_overwrite: true, - .. - } - ) && access.resource == color_id)); - let hdr = family_by_source(&p, "hdr"); - assert_eq!( - hdr.usage.iter().copied().collect::>(), - [TextureUsage::Sampled, TextureUsage::ColorAttachment] - .into_iter() - .collect() - ); - assert!(p - .texture_families - .iter() - .all(|family| matches!(family.source, TextureFamilySource::AuthoredTexture { .. }))); -} - -#[test] -fn fullscreen_copy_parameters_are_exactly_empty() { - let mut g = hdr_copy_graph(); - let copy = node_index(&g, "copy"); - g["nodes"][copy]["parameters"] = json!({"obsolete":true}); - assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); - assert_eq!(CONTRACTS.len(), 16); -} - -#[test] -fn fullscreen_copy_rejects_same_source_and_target_family() { - let mut g = hdr_copy_graph(); - let copy = node_index(&g, "copy"); - let path = node_path(&g, "copy", "inputs"); - g["nodes"][copy]["inputs"]["colorTarget"] = input("pipeline_main", "color"); - let error = compile_error(g); - assert_eq!(error.code, "GRAPH_SAME_PASS_HAZARD"); - assert_eq!(error.details["path"], path); -} - -#[test] -fn duplicate_texture_writer_reports_second_color_target() { - let mut nodes = vec![ - node( - "color", - "texture", - texture("rgba16_float", "transient"), - json!({}), - ), - node( - "output", - "texture", - texture("rgba16_float", "transient"), - json!({}), - ), - ]; - nodes.extend(render_support_nodes()); - nodes.extend([ - node( - "depth_a", - "texture", - texture("depth32_float", "transient"), - json!({}), - ), - node( - "depth_b", - "texture", - texture("depth32_float", "transient"), - json!({}), - ), - pipeline_node("F0", input("color", "texture"), input("depth_a", "texture")), - pipeline_node("F1", input("color", "texture"), input("depth_b", "texture")), - node( - "join", - "bloom_composite", - json!({"intensity":1.0}), - json!({"source":input("F0","color"),"bloom":input("F1","color"),"colorTarget":input("output","texture")}), - ), - node( - "frame_out", - "frame_out", - json!({}), - json!({"color":input("join","color")}), - ), - ]); - let graph = graph(nodes); - let path = node_path(&graph, "F1", "inputs.colorTarget"); - let error = compile_error(graph); - assert_eq!(error.code, "GRAPH_DUPLICATE_WRITER"); - assert_eq!(error.details["path"], path); -} - -#[test] -fn same_output_bound_to_both_attachments_is_a_same_pass_hazard() { - let mut nodes = vec![node( - "target", - "texture", - texture("rgba8_unorm", "transient"), - json!({}), - )]; - nodes.extend(render_support_nodes()); - nodes.extend([ - pipeline_node("F", input("target", "texture"), input("target", "texture")), - node( - "P", - "frame_out", - json!({}), - json!({"color":input("F","color")}), - ), - ]); - let error = compile_error(graph(nodes)); - assert_eq!(error.code, "GRAPH_SAME_PASS_HAZARD"); - assert_eq!(error.details["path"], "nodes[4].inputs"); -} - -#[test] -fn unordered_old_texture_version_read_is_rejected_before_scheduling() { - let mut nodes = vec![ - node( - "color", - "texture", - texture("rgba16_float", "transient"), - json!({}), - ), - node( - "output", - "texture", - texture("rgba16_float", "transient"), - json!({}), - ), - node( - "depth_0", - "texture", - texture("depth32_float", "transient"), - json!({}), - ), - node( - "depth_1", - "texture", - texture("depth32_float", "transient"), - json!({}), - ), - ]; - nodes.extend(render_support_nodes()); - nodes.extend([ - pipeline_node("F0", input("color", "texture"), input("depth_0", "texture")), - pipeline_node("F1", input("F0", "color"), input("depth_1", "texture")), - node( - "join", - "bloom_composite", - json!({"intensity":1.0}), - json!({"source":input("F0","color"),"bloom":input("F1","color"),"colorTarget":input("output","texture")}), - ), - node( - "frame_out", - "frame_out", - json!({}), - json!({"color":input("join","color")}), - ), - ]); - let graph = graph(nodes); - let path = node_path(&graph, "join", "inputs.source"); - let error = compile_error(graph); - assert_eq!(error.code, "GRAPH_RESOURCE_VERSION_INVALID"); - assert_eq!(error.details["path"], path); -} - -#[test] -fn duplicate_successors_defer_old_version_reachability() { - let mut nodes = vec![ - node( - "color", - "texture", - texture("rgba16_float", "transient"), - json!({}), - ), - node( - "output", - "texture", - texture("rgba16_float", "transient"), - json!({}), - ), - depth_spec("depth_0", "transient"), - depth_spec("depth_1", "transient"), - depth_spec("depth_2", "transient"), - ]; - nodes.extend(render_support_nodes()); - nodes.extend([ - pipeline_node("F0", input("color", "texture"), input("depth_0", "texture")), - pipeline_node("F1", input("F0", "color"), input("depth_1", "texture")), - pipeline_node("F2", input("F0", "color"), input("depth_2", "texture")), - node( - "join", - "bloom_composite", - json!({"intensity":1.0}), - json!({"source":input("F1","color"),"bloom":input("F2","color"),"colorTarget":input("output","texture")}), - ), - node( - "P2", - "frame_out", - json!({}), - json!({"color":input("join","color")}), - ), - ]); - let graph = graph(nodes); - let path = node_path(&graph, "F2", "inputs.colorTarget"); - let error = compile_error(graph); - assert_eq!(error.code, "GRAPH_DUPLICATE_WRITER"); - assert_eq!(error.details["path"], path); -} - -#[test] -fn live_texture_cycle_reports_the_exact_first_cycle() { - let mut nodes = render_support_nodes(); - nodes.extend(cyclic_pipelines()); - nodes.push(node( - "frame_out", - "frame_out", - json!({}), - json!({"color":input("A","color")}), - )); - let error = compile_error(graph(nodes)); - assert_eq!(error.code, "GRAPH_CYCLE"); - assert_eq!( - error.details, - json!({ - "message":"live graph contains a cycle", - "kind":"cycle", - "edges":[ - {"fromNode":"A","fromSocket":"color","toNode":"B","toSocket":"colorTarget","resource":{"node":"A","socket":"color"}}, - {"fromNode":"B","fromSocket":"color","toNode":"A","toSocket":"colorTarget","resource":{"node":"B","socket":"color"}} - ] - }) - ); -} - -#[test] -fn dead_texture_cycle_is_culled_without_cycle_execution() { - let mut value = full_cull_graph(); - value["nodes"] - .as_array_mut() - .unwrap() - .extend(cyclic_pipelines()); - let plan = compile_graph(value); - assert_eq!(plan.node_count, 10); - assert_eq!(plan.culled_node_count, 2); - assert_eq!(plan.culled_resource_count, 4); - assert!(!plan - .executions - .iter() - .any(|execution| matches!(execution.id.as_str(), "A" | "B"))); -} -fn compile_error(v: Value) -> GraphError { - super::compile(serde_json::from_value(v).unwrap()).unwrap_err() -} -fn execution<'a>(p: &'a CompiledGraph, authored_id: &str) -> &'a CompiledExecution { - p.executions.iter().find(|e| e.id == authored_id).unwrap() -} -fn resource_by_origin<'a>(p: &'a CompiledGraph, node: &str, socket: &str) -> &'a CompiledResource { - p.resources - .iter() - .find(|r| r.origin.node == node && r.origin.socket == socket) - .unwrap() -} - -fn family_by_source<'a>(p: &'a CompiledGraph, node: &str) -> &'a TextureFamily { - let source = resource_by_origin(p, node, "texture"); - let family = match source.plan { - ResourcePlan::TextureSource { family, .. } => family, - _ => panic!("{node} is not a texture specification"), - }; - &p.texture_families[family as usize] -} - -fn allocation_slot<'a>(p: &'a CompiledGraph, allocation: AllocationRef) -> &'a AllocationSlot { - &p.allocation_classes[allocation.class as usize].slots[allocation.slot as usize] -} - -fn independent_depth_graph( - depth_specs: Vec, - pipelines: Vec, - present_from: &str, -) -> Value { - let mut nodes = vec![node( - "color", - "texture", - texture("rgba8_unorm", "transient"), - json!({}), - )]; - nodes.extend(render_support_nodes()); - nodes.extend(depth_specs); - nodes.extend(pipelines); - nodes.push(node( - "frame_out", - "frame_out", - json!({}), - json!({"color":input(present_from,"color")}), - )); - graph(nodes) -} - -fn depth_spec(id: &str, residency: &str) -> Value { - node( - id, - "texture", - texture("depth32_float", residency), - json!({}), - ) -} - -#[test] -fn dense_lifetimes_exclude_authored_source_ordinals() { - let p = compile_graph(full_cull_graph()); - assert_eq!( - p.executions - .iter() - .map(|e| e.id.as_str()) - .collect::>(), - ["cull", "query", "registry", "pipeline_main", "frame_out"] - ); - for (node, socket, first, last) in [ - ("mesh", "mesh", 0, 3), - ("mesh", "localAabbs", 0, 0), - ("cull", "isFrustumCulled", 0, 1), - ("mesh", "isVisible", 1, 1), - ("mesh", "pipelineIndices", 2, 2), - ("registry", "activation", 2, 3), - ] { - assert_eq!( - resource_by_origin(&p, node, socket).lifetime, - Some(Lifetime { - first_use: first, - last_use: last - }), - "lifetime for {node}.{socket}" - ); - } - let color = resource_by_origin(&p, "pipeline_main", "color"); - let depth = resource_by_origin(&p, "pipeline_main", "depth"); - assert_eq!(color.producer_execution, Some(3)); - assert_eq!(depth.producer_execution, Some(3)); - assert_eq!( - color.lifetime, - Some(Lifetime { - first_use: 3, - last_use: 4 - }) - ); - assert_eq!( - depth.lifetime, - Some(Lifetime { - first_use: 3, - last_use: 3 - }) - ); - let depth_family = family_by_source(&p, "depth"); - assert_eq!( - depth_family.lifetime, - Lifetime { - first_use: 3, - last_use: 3 - } - ); - assert_eq!(depth_family.versions[0].lifetime, depth_family.lifetime); -} - -#[test] -fn transient_aliasing_is_declaration_order_independent() { - let p = compile_graph(independent_depth_graph( - vec![ - depth_spec("depth_second", "transient"), - depth_spec("depth_first", "transient"), - ], - vec![ - pipeline_node( - "F0", - input("color", "texture"), - input("depth_first", "texture"), - ), - pipeline_node("F1", input("F0", "color"), input("depth_second", "texture")), - ], - "F1", - )); - assert_eq!(execution(&p, "F1").original_node_index, 7); - let f0_ordinal = p.executions.iter().position(|e| e.id == "F0").unwrap() as u32; - let f1_ordinal = p.executions.iter().position(|e| e.id == "F1").unwrap() as u32; - assert_eq!(f1_ordinal, f0_ordinal + 1); - let first = family_by_source(&p, "depth_first"); - let second = family_by_source(&p, "depth_second"); - assert_eq!( - first.lifetime, - Lifetime { - first_use: f0_ordinal, - last_use: f0_ordinal - } - ); - assert_eq!( - second.lifetime, - Lifetime { - first_use: f1_ordinal, - last_use: f1_ordinal - } - ); - assert_eq!(first.versions[0].lifetime, first.lifetime); - assert_eq!(second.versions[0].lifetime, second.lifetime); - assert_eq!(first.allocation, second.allocation); - let slot = allocation_slot(&p, first.allocation.unwrap()); - assert_eq!(slot.kind, AllocationKind::AliasedTransient); - assert_eq!(slot.usage, [TextureUsage::DepthAttachment]); - assert_eq!( - slot.occupants.iter().copied().collect::>(), - [first.id, second.id].into_iter().collect() - ); -} - -#[test] -fn overlapping_family_lifetimes_prevent_transient_reuse() { - let p = compile_graph(independent_depth_graph( - vec![ - depth_spec("depth_a", "transient"), - depth_spec("depth_b", "transient"), - ], - vec![ - pipeline_node("F0", input("color", "texture"), input("depth_a", "texture")), - pipeline_node("F1", input("F0", "color"), input("depth_b", "texture")), - pipeline_node("F2", input("F1", "color"), input("F0", "depth")), - ], - "F2", - )); - let a = family_by_source(&p, "depth_a"); - let b = family_by_source(&p, "depth_b"); - assert!(a.lifetime.first_use < b.lifetime.first_use); - assert!(a.lifetime.last_use > b.lifetime.last_use); - assert_ne!(a.allocation, b.allocation); - assert_ne!(a.allocation.unwrap().slot, b.allocation.unwrap().slot); -} - -#[test] -fn persistent_textures_are_dedicated_and_follow_transient_slots() { - let p = compile_graph(independent_depth_graph( - vec![ - depth_spec("persistent_b", "persistent"), - depth_spec("transient", "transient"), - depth_spec("persistent_a", "persistent"), - ], - vec![ - pipeline_node( - "F0", - input("color", "texture"), - input("persistent_a", "texture"), - ), - pipeline_node("F1", input("F0", "color"), input("transient", "texture")), - pipeline_node("F2", input("F1", "color"), input("persistent_b", "texture")), - ], - "F2", - )); - let a = family_by_source(&p, "persistent_a"); - let b = family_by_source(&p, "persistent_b"); - assert_ne!(a.allocation, b.allocation); - for family in [a, b] { - let allocation = family.allocation.unwrap(); - assert_eq!( - allocation_slot(&p, allocation).kind, - AllocationKind::Persistent - ); - assert_eq!(family.usage, [TextureUsage::DepthAttachment]); - for version in &family.versions { - let ResourcePlan::Texture { - allocation: resource_allocation, - .. - } = p.resources[version.resource as usize].plan - else { - panic!() - }; - assert_eq!(resource_allocation, Some(allocation)); - } - } - let transient = family_by_source(&p, "transient").allocation.unwrap(); - assert!(transient.slot < a.allocation.unwrap().slot); - assert!(transient.slot < b.allocation.unwrap().slot); - assert_eq!(p.transient_slot_count, 2); -} - -#[test] -fn unsupported_texture_features_are_rejected_during_decode() { - let cases = [ - ("dimension", json!("d1"), "dimension"), - ("dimension", json!("d3"), "dimension"), - ("mipLevelCount", json!(2), "mipLevelCount"), - ("sampleCount", json!(4), "sampleCount"), - ]; - for (field, value, suffix) in cases { - let mut graph = full_cull_graph(); - graph["nodes"][1]["parameters"]["texture"][field] = value; - let error = compile_error(graph); - assert_eq!(error.code, "GRAPH_UNSUPPORTED_FEATURE", "{field}"); - assert_eq!( - error.details["path"], - format!("nodes[1].parameters.texture.{suffix}"), - "{field}" - ); - } - for kind in ["absolute", "surface_relative"] { - let mut graph = full_cull_graph(); - if kind == "absolute" { - graph["nodes"][1]["parameters"]["texture"]["extent"] = - json!({"kind":"absolute","width":64,"height":64,"depthOrArrayLayers":2}); - } else { - graph["nodes"][1]["parameters"]["texture"]["extent"]["depthOrArrayLayers"] = json!(2); - } - let error = compile_error(graph); - assert_eq!(error.code, "GRAPH_UNSUPPORTED_FEATURE", "{kind}"); - assert_eq!( - error.details["path"], "nodes[1].parameters.texture.extent.depthOrArrayLayers", - "{kind}" - ); - } -} - -#[test] -fn parser_and_registry_accept_only_canonical_schema() { - let bytes = serde_json::to_vec(&full_cull_graph()).unwrap(); - assert!(parse_and_compile(&bytes).is_ok()); - assert_eq!( - parse_and_compile(br#"{"schemaVersion":1}"#) - .unwrap_err() - .code, - "GRAPH_SCHEMA_UNSUPPORTED" - ); - let mut r = Registry::default(); - let (id, _) = r.compile(&bytes).unwrap(); - assert_eq!(r.get(id).unwrap().graph_id, "full"); -} - -#[test] -fn authoritative_eight_node_graph_lowers_exactly() { - let p = compile_graph(full_cull_graph()); - assert_eq!(p.node_count, 8); - assert_eq!(p.resources.len(), 11); - assert_eq!(p.culled_resource_count, 0); - assert_eq!( - p.resources - .iter() - .map(|resource| ( - resource.origin.node.as_str(), - resource.origin.socket.as_str() - )) - .collect::>(), - [ - ("color", "texture"), - ("depth", "texture"), - ("mesh", "mesh"), - ("mesh", "localAabbs"), - ("mesh", "isVisible"), - ("mesh", "pipelineIndices"), - ("cull", "isFrustumCulled"), - ("query", "draws"), - ("registry", "activation"), - ("pipeline_main", "color"), - ("pipeline_main", "depth"), - ] - ); - assert_eq!( - p.executions - .iter() - .map(|e| e.id.as_str()) - .collect::>(), - ["cull", "query", "registry", "pipeline_main", "frame_out"] - ); - for resource in &p.resources { - if let ResourcePlan::Texture { version, .. } = resource.plan { - assert_eq!(version, 0, "every first produced texture is symbolic v0"); - } - } - assert!(p.executions.iter().all(|e| !matches!( - e.executor.key.as_str(), - "surface_target" | "texture" | "mesh" - ))); - let frame = execution(&p, "frame_out"); - let color = resource_by_origin(&p, "pipeline_main", "color"); - let color_id = p - .resources - .iter() - .position(|resource| std::ptr::eq(resource, color)) - .unwrap() as u32; - assert!(matches!(frame.kind, ExecutionKind::FrameOut { color } if color == color_id)); - assert!(frame.outputs.is_empty()); - assert_eq!(frame.inputs.len(), 1); - assert_eq!(frame.accesses.len(), 1); - assert_eq!(frame.accesses[0].socket, "color"); - assert_eq!(frame.accesses[0].resource, color_id); - assert_eq!(frame.accesses[0].mode, AccessMode::SampledTexture); - let family = family_by_source(&p, "color"); - assert_eq!(family.lifetime.last_use, 4); - assert!(family.allocation.is_some()); - assert_eq!( - family.usage.iter().copied().collect::>(), - [TextureUsage::Sampled, TextureUsage::ColorAttachment] - .into_iter() - .collect() - ); -} - -#[test] -fn exact_phase_four_contract_catalog_and_mesh_metadata() { - assert_eq!( - CONTRACTS - .iter() - .map(|contract| (contract.key, contract.version)) - .collect::>(), - [ - ("mesh", 1), - ("texture", 1), - ("frustum_cull", 1), - ("mesh_query", 1), - ("pipeline_registry", 1), - ("pipeline", 1), - ("fullscreen_copy", 1), - ("color_balance", 1), - ("exposure_contrast", 1), - ("saturation", 1), - ("channel_mixer", 1), - ("bloom_extract", 1), - ("bloom_blur", 1), - ("bloom_composite", 1), - ("luminance_edge", 1), - ("frame_out", 3), - ] - ); - assert_eq!( - CONTRACTS - .iter() - .map(|contract| (contract.key, contract.fullscreen_policy)) - .collect::>(), - [ - ("mesh", None), - ("texture", None), - ("frustum_cull", None), - ("mesh_query", None), - ("pipeline_registry", None), - ("pipeline", None), - ("fullscreen_copy", Some(FullscreenPolicy::Copy)), - ("color_balance", Some(FullscreenPolicy::HdrSameExtent)), - ("exposure_contrast", Some(FullscreenPolicy::HdrSameExtent)), - ("saturation", Some(FullscreenPolicy::HdrSameExtent)), - ("channel_mixer", Some(FullscreenPolicy::HdrSameExtent)), - ("bloom_extract", Some(FullscreenPolicy::BloomExtract)), - ("bloom_blur", Some(FullscreenPolicy::HdrSameExtent)), - ("bloom_composite", Some(FullscreenPolicy::BloomComposite)), - ("luminance_edge", Some(FullscreenPolicy::HdrSameExtent)), - ("frame_out", None), - ] - ); - for contract in CONTRACTS { - let serialized = serde_json::to_value(contract).unwrap(); - assert!(serialized.get("fullscreenPolicy").is_none()); - } - let mesh = contract("mesh").unwrap(); - assert_eq!(mesh.execution, ExecutionClass::Source); - assert!(mesh.inputs.is_empty()); - assert_eq!( - mesh.outputs - .iter() - .map(|output| (output.name, output.semantic_type, output.metadata)) - .collect::>(), - [ - ("mesh", SemanticType::MeshData, OutputMetadata::None), - ( - "localAabbs", - SemanticType::LocalAabbBuffer, - OutputMetadata::None, - ), - ( - "isVisible", - SemanticType::BooleanFlagBuffer, - OutputMetadata::BooleanFlag { - flag: MeshFlag::IsVisible, - }, - ), - ( - "pipelineIndices", - SemanticType::PipelineIndexStream, - OutputMetadata::None, - ), - ] - ); - let cull = contract("frustum_cull").unwrap(); - assert_eq!(cull.inputs.len(), 2); - assert_eq!( - (cull.outputs[0].name, cull.outputs[0].metadata), - ( - "isFrustumCulled", - OutputMetadata::BooleanFlag { - flag: MeshFlag::IsFrustumCulled, - }, - ) - ); - let query = contract("mesh_query").unwrap(); - assert_eq!( - query - .inputs - .iter() - .map(|input| (input.name, input.cardinality)) - .collect::>(), - [ - ("mesh", InputCardinality::RequiredOne), - ("isVisible", InputCardinality::OptionalOne), - ("isFrustumCulled", InputCardinality::OptionalOne), - ] - ); - let frame = contract("frame_out").unwrap(); - assert_eq!(frame.execution, ExecutionClass::Frame); - assert!(frame.inherently_observable); - assert!(frame.outputs.is_empty()); - assert_eq!(frame.inputs.len(), 1); - assert_eq!(frame.inputs[0].name, "color"); - assert_eq!( - frame.inputs[0].accepted, - TypeConstraint::Exact(SemanticType::Texture) - ); - assert_eq!(frame.inputs[0].cardinality, InputCardinality::RequiredOne); - assert_eq!(frame.inputs[0].role, InputRole::SampledTexture); -} - -#[test] -fn removed_executor_keys_are_rejected_without_aliases() { - let cases = [ - "texture_spec", - "scene_table", - "local_aabb_buffer", - "camera_frustum", - "visibility_flags", - "surface_target", - "present", - "legacy_forward", - "depth_stencil_config", - ]; - for key in cases { - let mut g = full_cull_graph(); - g["nodes"][0]["executor"]["key"] = json!(key); - let error = compile_error(g); - assert_eq!(error.code, "GRAPH_UNKNOWN_EXECUTOR", "{key}"); - assert_eq!(error.details["path"], "nodes[0].executor.key", "{key}"); - } -} - -#[test] -fn exact_wire_catalog_rejections() { - for field in [ - "pipeline", - "depthCompare", - "depthWriteEnabled", - "clearDepth", - "clearColor", - ] { - let mut g = full_cull_graph(); - let i = node_index(&g, "pipeline_main"); - g["nodes"][i]["parameters"] - .as_object_mut() + contract("mesh") .unwrap() - .remove(field); - assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); - } - let mut g = full_cull_graph(); - g["nodes"][0]["executor"]["version"] = json!(2); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_EXECUTOR_VERSION_UNSUPPORTED"); - assert_eq!(e.details["path"], "nodes[0].executor.version"); -} - -#[test] -fn mesh_predicates_are_normalized_and_any_removes_dependency() { - let p = compile_graph(full_cull_graph()); - let NormalizedParameters::MeshQuery { - visible_predicate, - frustum_culled_predicate, - } = execution(&p, "query").parameters.clone() - else { - panic!() - }; - assert_eq!(visible_predicate, RuntimePredicate::RequiredTrue); - assert_eq!(frustum_culled_predicate, RuntimePredicate::RequiredFalse); - let mut g = full_cull_graph(); - g["nodes"][4]["parameters"]["frustumCulledPredicate"] = json!("any"); - let p = compile_graph(g); - assert!(!execution(&p, "query") - .inputs - .iter() - .any(|x| x.socket == "isFrustumCulled")); - assert!(!p.executions.iter().any(|e| e.id == "cull")); -} - -#[test] -fn mesh_query_predicate_defaults_have_a_complete_truth_matrix() { - for field in ["visible", "frustum"] { - for predicate in ["any", "required_true", "required_false"] { - for linked in [false, true] { - for default in [false, true] { - let mut graph = full_cull_graph(); - let query = &mut graph["nodes"][4]; - query["parameters"]["visiblePredicate"] = json!("any"); - query["parameters"]["frustumCulledPredicate"] = json!("any"); - let (parameter, default_parameter, socket) = if field == "visible" { - ("visiblePredicate", "visibleDefault", "isVisible") - } else { - ( - "frustumCulledPredicate", - "frustumCulledDefault", - "isFrustumCulled", - ) - }; - query["parameters"][parameter] = json!(predicate); - query["parameters"][default_parameter] = json!(default); - if !linked { - query["inputs"].as_object_mut().unwrap().remove(socket); - } - let expected = match (predicate, linked, default) { - ("any", _, _) => RuntimePredicate::Any, - ("required_true", true, _) => RuntimePredicate::RequiredTrue, - ("required_false", true, _) => RuntimePredicate::RequiredFalse, - ("required_true", false, true) | ("required_false", false, false) => { - RuntimePredicate::Any - } - _ => RuntimePredicate::Never, - }; - let plan = compile_graph(graph); - let execution = execution(&plan, "query"); - let NormalizedParameters::MeshQuery { - visible_predicate, - frustum_culled_predicate, - } = &execution.parameters - else { - panic!() - }; - let (actual, other) = if field == "visible" { - (*visible_predicate, *frustum_culled_predicate) - } else { - (*frustum_culled_predicate, *visible_predicate) - }; - let active = matches!( - expected, - RuntimePredicate::RequiredTrue | RuntimePredicate::RequiredFalse - ); - let label = format!("{field}/{predicate}/linked={linked}/default={default}"); - if expected == RuntimePredicate::Never { - assert_eq!(actual, RuntimePredicate::Never, "{label}"); - assert_eq!(other, RuntimePredicate::Never, "{label}"); - } else { - assert_eq!(actual, expected, "{label}"); - assert_eq!(other, RuntimePredicate::Any, "{label}"); - } - assert_eq!( - execution.inputs.iter().any(|input| input.socket == socket), - active, - "{label}" - ); - assert_eq!( - execution - .accesses - .iter() - .any(|access| access.socket == socket), - active, - "{label}" - ); - if expected == RuntimePredicate::Never { - assert!(!plan - .executions - .iter() - .any(|execution| execution.id == "cull")); - } - } - } - } - } -} - -#[test] -fn inactive_mesh_source_outputs_are_pruned_but_executable_outputs_remain() { - let mut graph = full_cull_graph(); - graph["nodes"][4]["parameters"]["visiblePredicate"] = json!("any"); - graph["nodes"][4]["parameters"]["frustumCulledPredicate"] = json!("any"); - let plan = compile_graph(graph); - for socket in ["localAabbs", "isVisible"] { - assert!(!plan - .resources - .iter() - .any(|resource| resource.origin.node == "mesh" && resource.origin.socket == socket)); - } - assert!(!plan - .executions - .iter() - .any(|execution| execution.id == "cull")); - for socket in ["color", "depth"] { - assert!(plan - .resources - .iter() - .any(|resource| resource.origin.node == "pipeline_main" - && resource.origin.socket == socket)); - } - - let mut graph = full_cull_graph(); - graph["nodes"][4]["parameters"]["visiblePredicate"] = json!("any"); - let plan = compile_graph(graph); - assert!(!plan - .resources - .iter() - .any(|resource| resource.origin.node == "mesh" && resource.origin.socket == "isVisible")); - assert!(plan - .resources - .iter() - .any(|resource| resource.origin.node == "mesh" && resource.origin.socket == "localAabbs")); -} - -#[test] -fn provenance_and_lowering_are_consistent() { - let p = compile_graph(full_cull_graph()); - let scene = resource_by_origin(&p, "mesh", "mesh"); - for (id, socket) in [ - ("mesh", "localAabbs"), - ("mesh", "isVisible"), - ("cull", "isFrustumCulled"), - ("query", "draws"), - ] { - let r = resource_by_origin(&p, id, socket); - match r.plan { - ResourcePlan::LocalAabbBuffer { mesh: s } - | ResourcePlan::BooleanFlagBuffer { mesh: s, .. } - | ResourcePlan::DrawStream { mesh: s } => { - assert_eq!( - s, - p.resources - .iter() - .position(|r| std::ptr::eq(r, scene)) - .unwrap() as u32 - ) - } - _ => {} - } - } - let f = execution(&p, "pipeline_main"); - let color_in = f - .inputs - .iter() - .find(|x| x.socket == "colorTarget") - .unwrap() - .resource; - let color_out = f - .outputs - .iter() - .find(|x| x.socket == "color") - .unwrap() - .resource; - assert_ne!(color_in, color_out); - assert!(f - .accesses - .iter() - .any(|a| a.resource == color_out && matches!(a.mode, AccessMode::ColorAttachment { .. }))); - assert!(!f - .accesses - .iter() - .any(|a| a.resource == color_in && matches!(a.mode, AccessMode::ColorAttachment { .. }))); - for (socket, expected) in [ - ("mesh", AccessMode::SemanticRead), - ("draws", AccessMode::IndirectRead), - ] { - let access = f.accesses.iter().find(|a| a.socket == socket).unwrap(); - assert_eq!(access.mode, expected, "pipeline {socket} access"); - } - - let registry = execution(&p, "registry"); - assert!(matches!( - registry.parameters, - NormalizedParameters::PipelineRegistry - )); - assert!(matches!(registry.kind, ExecutionKind::CpuPreparation)); - assert_eq!(registry.inputs.len(), 1); - assert_eq!(registry.outputs.len(), 1); - assert_eq!(registry.accesses.len(), 1); - assert_eq!(registry.inputs[0].socket, "pipelineIndices"); - assert_eq!(registry.outputs[0].socket, "activation"); - assert_eq!(registry.accesses[0].mode, AccessMode::SemanticRead); - let activation = &p.resources[registry.outputs[0].resource as usize]; - assert_eq!(activation.producer_execution, Some(2)); - assert_eq!( - activation.lifetime, - Some(Lifetime { - first_use: 2, - last_use: 3 - }) - ); - assert!(matches!( - activation.plan, - ResourcePlan::PipelineActivation { pipeline_indices } - if pipeline_indices == registry.inputs[0].resource - )); -} - -#[test] -fn pipeline_clear_then_chained_load_is_independent_for_color_and_depth() { - let p = compile_graph(independent_depth_graph( - vec![depth_spec("depth", "transient")], - vec![ - pipeline_node( - "pipeline_first", - input("color", "texture"), - input("depth", "texture"), - ), - pipeline_node( - "pipeline_second", - input("pipeline_first", "color"), - input("pipeline_first", "depth"), - ), - ], - "pipeline_second", - )); - let ExecutionKind::Render { - color_attachments, - depth_stencil: Some(depth), - } = &execution(&p, "pipeline_first").kind - else { - panic!() - }; - assert!(matches!( - color_attachments[0].load, - NormalizedColorLoad::Clear { .. } - )); - assert!(matches!(depth.load, NormalizedDepthLoad::Clear { .. })); - let ExecutionKind::Render { - color_attachments, - depth_stencil: Some(depth), - } = &execution(&p, "pipeline_second").kind - else { - panic!() - }; - assert_eq!(color_attachments[0].load, NormalizedColorLoad::Load); - assert_eq!(depth.load, NormalizedDepthLoad::Load); - - for pipeline in ["", "bad name", "pipeline/name"] { - let mut graph = full_cull_graph(); - let i = node_index(&graph, "pipeline_main"); - graph["nodes"][i]["parameters"]["pipeline"] = json!(pipeline); - assert_eq!( - compile_error(graph).code, - "GRAPH_PARAMETERS_INVALID", - "{pipeline:?}" - ); - } -} - -#[test] -fn descriptor_validation_and_normalization_table() { - for (field, value) in [("sampleCount", json!(3))] { - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["texture"][field] = value; - assert_eq!(compile_error(g).code, "GRAPH_UNSUPPORTED_FEATURE"); - } - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["texture"]["extent"] = - json!({"kind":"absolute","width":1,"height":1,"depthOrArrayLayers":1}); - g["nodes"][1]["parameters"]["texture"]["mipLevelCount"] = json!(2); - assert_eq!(compile_error(g).code, "GRAPH_UNSUPPORTED_FEATURE"); - - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["texture"]["mipLevelCount"] = json!(99); - assert_eq!(compile_error(g).code, "GRAPH_UNSUPPORTED_FEATURE"); - for residency in ["history", "readback"] { - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["residency"] = json!(residency); - assert_eq!(compile_error(g).code, "GRAPH_UNSUPPORTED_FEATURE"); - } - let p = compile_graph(full_cull_graph()); - assert!(p - .texture_families - .iter() - .all(|family| matches!(family.source, TextureFamilySource::AuthoredTexture { .. }))); -} - -#[test] -fn validation_precedence_and_identifier_limits() { - let mut g = full_cull_graph(); - g["nodes"][0]["id"] = json!("x".repeat(65)); - g["nodes"][1]["executor"]["key"] = json!("bad"); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_LIMIT_EXCEEDED"); - assert_eq!(e.details["path"], "nodes[0].id"); - let mut g = full_cull_graph(); - g["nodes"][0]["id"] = json!("bad id"); - assert_eq!(compile_error(g).code, "GRAPH_INVALID_ID"); - let mut g = full_cull_graph(); - g["nodes"][1]["executor"]["key"] = json!("bad"); - g["nodes"][0]["parameters"] = json!({"bad":1}); - assert_eq!(compile_error(g).details["path"], "nodes[1].executor.key"); -} - -#[test] -fn global_identifier_lengths_precede_grammar_and_duplicates() { - let mut invalid_grammar = full_cull_graph(); - invalid_grammar["nodes"][0]["id"] = json!("bad id"); - invalid_grammar["nodes"][4]["executor"]["key"] = json!("x".repeat(65)); - let error = compile_error(invalid_grammar); - assert_eq!(error.code, "GRAPH_LIMIT_EXCEEDED"); - assert_eq!(error.details["path"], "nodes[4].executor.key"); - - let mut duplicate = full_cull_graph(); - duplicate["nodes"][1]["id"] = duplicate["nodes"][0]["id"].clone(); - duplicate["nodes"][4]["executor"]["key"] = json!("x".repeat(65)); - let error = compile_error(duplicate); - assert_eq!(error.code, "GRAPH_LIMIT_EXCEEDED"); - assert_eq!(error.details["path"], "nodes[4].executor.key"); -} - -#[test] -fn strict_mesh_diagnostics_and_inactive_any_edges() { - for field in ["visiblePredicate", "frustumCulledPredicate"] { - let mut g = full_cull_graph(); - g["nodes"][4]["parameters"][field] = json!("invalid"); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_PARAMETERS_INVALID"); - assert_eq!(e.details["path"], "nodes[4].parameters"); - } - let mut g = full_cull_graph(); - g["nodes"][4]["parameters"]["frustumCulledPredicate"] = json!("required_true"); - g["nodes"][4]["inputs"] - .as_object_mut() - .unwrap() - .remove("isFrustumCulled"); - let p = compile_graph(g); - assert!(matches!( - execution(&p, "query").parameters, - NormalizedParameters::MeshQuery { - frustum_culled_predicate: RuntimePredicate::Never, - .. - } - )); - assert!(!p.executions.iter().any(|e| e.id == "cull")); - - let mut g = full_cull_graph(); - g["nodes"][4]["inputs"] - .as_object_mut() - .unwrap() - .remove("isFrustumCulled"); - let p = compile_graph(g); - assert!(!p.executions.iter().any(|e| e.id == "cull")); - let mut g = full_cull_graph(); - g["nodes"][4]["inputs"]["isVisible"] = input("cull", "isFrustumCulled"); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); - assert_eq!(e.details["path"], "nodes[4].inputs.isVisible"); - - let mut g = full_cull_graph(); - g["nodes"][4]["parameters"]["frustumCulledPredicate"] = json!("any"); - let p = compile_graph(g); - let q = execution(&p, "query"); - assert!(!q.inputs.iter().any(|i| i.socket == "isFrustumCulled")); - assert!(!q.accesses.iter().any(|a| a.socket == "isFrustumCulled")); - assert!(!p.executions.iter().any(|e| e.id == "cull")); -} - -#[test] -fn transitive_scene_roots_are_vector_resource_ids() { - let p = compile_graph(full_cull_graph()); - let mesh_id = p - .resources - .iter() - .position(|r| r.origin.node == "mesh") - .unwrap() as u32; - for (origin, socket) in [ - ("mesh", "localAabbs"), - ("mesh", "isVisible"), - ("cull", "isFrustumCulled"), - ("query", "draws"), - ] { - let resource = resource_by_origin(&p, origin, socket); - let rooted = match resource.plan { - ResourcePlan::LocalAabbBuffer { mesh } - | ResourcePlan::BooleanFlagBuffer { mesh, .. } - | ResourcePlan::DrawStream { mesh } => Some(mesh), - _ => None, - }; - assert_eq!(rooted, Some(mesh_id)); - } - let mut g = full_cull_graph(); - g["nodes"] - .as_array_mut() - .unwrap() - .push(node("sceneB", "mesh", json!({}), json!({}))); - g["nodes"][3]["inputs"]["mesh"] = input("sceneB", "mesh"); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); - assert_eq!(e.details["path"], "nodes[3].inputs.localAabbs"); - let mut g = full_cull_graph(); - g["nodes"] - .as_array_mut() - .unwrap() - .push(node("sceneB", "mesh", json!({}), json!({}))); - g["nodes"][3]["inputs"]["mesh"] = input("sceneB", "mesh"); - g["nodes"][3]["inputs"]["localAabbs"] = input("sceneB", "localAabbs"); - g["nodes"][4]["inputs"]["mesh"] = input("sceneB", "mesh"); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); - assert_eq!(e.details["path"], "nodes[4].inputs.isVisible"); -} - -#[test] -fn descriptor_exact_paths_and_normalization() { - let cases = [ - ("sampleCount", json!(2), "sampleCount"), - ("sampleCount", json!(8), "sampleCount"), - ("mipLevelCount", json!(0), "mipLevelCount"), - ]; - for (field, value, suffix) in cases { - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["texture"][field] = value; - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_UNSUPPORTED_FEATURE"); - assert_eq!( - e.details["path"], - format!("nodes[1].parameters.texture.{suffix}") - ); - } - for (view, index) in [("depth32_float", 0), ("rgba8_unorm", 0)] { - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["texture"]["viewFormats"] = json!([view]); - let e = compile_error(g); - assert_eq!( - e.details["path"], - format!("nodes[1].parameters.texture.viewFormats[{index}]") - ); - } - for residency in ["history", "readback"] { - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["residency"] = json!(residency); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_UNSUPPORTED_FEATURE"); - assert_eq!(e.details["path"], "nodes[1].parameters.residency"); - } - let mut g = full_cull_graph(); - let d = &mut g["nodes"][1]["parameters"]["texture"]; - d["extent"]["width"] = json!({"numerator":2,"denominator":2}); - let p = compile_graph(g); - let TextureFamilySource::AuthoredTexture { descriptor, .. } = - &family_by_source(&p, "depth").source - else { - panic!() - }; - assert!( - matches!(&descriptor.extent, NormalizedTextureExtent::SurfaceRelative { width, .. } if *width == Ratio { numerator: 1, denominator: 1 }) - ); -} - -#[test] -fn descriptor_multi_error_precedence_is_exact() { - let cases = [ - (json!(3), json!(0), 9000, "mipLevelCount"), - (json!(1), json!(0), 9000, "mipLevelCount"), - (json!(1), json!(30), 9000, "mipLevelCount"), - (json!(1), json!(14), 9000, "mipLevelCount"), - (json!(1), json!(14), 8192, "mipLevelCount"), - ]; - for (sample_count, mip_count, width, expected) in cases { - let mut g = full_cull_graph(); - let d = &mut g["nodes"][1]["parameters"]["texture"]; - d["format"] = json!("rgba8_unorm"); - d["extent"] = json!({ - "kind":"absolute", - "width":width, - "height":1, - "depthOrArrayLayers":1 - }); - d["sampleCount"] = sample_count; - d["mipLevelCount"] = mip_count; - d["viewFormats"] = json!(["rgba8_unorm"]); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_UNSUPPORTED_FEATURE"); - assert_eq!( - e.details["path"], - format!("nodes[1].parameters.texture.{expected}") - ); - } -} - -#[test] -fn global_raw_limits_have_stable_narrow_paths() { - for (g, path) in [ - ( - { - let mut g = full_cull_graph(); - g["graphId"] = json!("x".repeat(65)); - g - }, - "graphId", - ), - ( - { - let mut g = full_cull_graph(); - g["nodes"][0]["executor"]["key"] = json!("x".repeat(65)); - g - }, - "nodes[0].executor.key", - ), - ( - { - let mut g = full_cull_graph(); - g["nodes"][3]["inputs"]["x".repeat(65)] = input("mesh", "mesh"); - g - }, - "nodes[3].inputs.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - ), - ( - { - let mut g = full_cull_graph(); - g["nodes"][3]["inputs"]["mesh"]["node"] = json!("x".repeat(65)); - g - }, - "nodes[3].inputs.mesh.node", - ), - ( - { - let mut g = full_cull_graph(); - g["nodes"][3]["inputs"]["mesh"]["socket"] = json!("x".repeat(65)); - g - }, - "nodes[3].inputs.mesh.socket", - ), - ] { - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_LIMIT_EXCEEDED"); - assert_eq!(e.details["path"], path); - } - let mut inputs = serde_json::Map::new(); - for i in 0..8193 { - inputs.insert(format!("s{i}"), input("n", "x")); - } - let g = graph(vec![node( - "n", - "surface_target", - json!({}), - Value::Object(inputs), - )]); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_LIMIT_EXCEEDED"); - assert_eq!(e.details["path"], "nodes[0].inputs"); -} - -#[test] -fn empty_graph_is_rejected_without_frame_out() { - let error = compile_error(json!({"schemaVersion":2,"graphId":"empty","revision":1,"nodes":[]})); - assert_eq!(error.code, "GRAPH_EXECUTION_UNSUPPORTED"); - assert_eq!( - error.details, - json!({"message":"exactly one frame_out is required","path":"nodes"}) - ); -} - -#[test] -fn multiple_frame_outputs_and_uninitialized_sources_are_rejected() { - let mut value = full_cull_graph(); - value["nodes"].as_array_mut().unwrap().push(node( - "frame_out_2", - "frame_out", - json!({}), - json!({"color":input("pipeline_main","color")}), - )); - let error = compile_error(value); - assert_eq!(error.code, "GRAPH_EXECUTION_UNSUPPORTED"); - assert_eq!(error.details["path"], "nodes"); - - let error = compile_error(graph(vec![ - node( - "color", - "texture", - texture("rgba8_unorm", "transient"), - json!({}), - ), - node( - "frame_out", - "frame_out", - json!({}), - json!({"color":input("color","texture")}), - ), - ])); - assert_eq!(error.code, "GRAPH_UNINITIALIZED_RESOURCE"); - assert_eq!(error.details["path"], "nodes[1].inputs.color"); -} - -#[test] -fn frame_out_cardinality_counts_only_enabled_nodes() { - let mut value = full_cull_graph(); - value["nodes"][7]["state"] = json!("muted"); - let error = compile_error(value); - assert_eq!(error.code, "GRAPH_EXECUTION_UNSUPPORTED"); - assert_eq!( - error.details, - json!({"message":"exactly one frame_out is required","path":"nodes"}) - ); - - let mut value = full_cull_graph(); - let mut muted = node( - "muted_frame_out", - "frame_out", - json!({}), - json!({"color":input("pipeline_main","color")}), - ); - muted["state"] = json!("muted"); - value["nodes"].as_array_mut().unwrap().push(muted); - let compiled = compile_graph(value); - assert_eq!( - compiled - .executions - .iter() - .filter(|execution| execution.executor.key == "frame_out") - .count(), - 1 - ); - assert!(!compiled - .executions - .iter() - .any(|execution| execution.id == "muted_frame_out")); - - let mut value = full_cull_graph(); - value["nodes"][3]["state"] = json!("muted"); - let error = compile_error(value); - assert_eq!(error.code, "GRAPH_NODE_STATE_INVALID"); - assert_eq!(error.details["path"], "nodes[3].state"); - - let mut value = full_cull_graph(); - value["nodes"][7]["state"] = json!("muted"); - value["nodes"][6]["parameters"]["clearColor"] = json!("bad"); - assert_eq!(compile_error(value).code, "GRAPH_PARAMETERS_INVALID"); - - let mut value = full_cull_graph(); - value["nodes"][7]["state"] = json!("muted"); - value["nodes"][7]["inputs"] = json!({}); - assert_eq!(compile_error(value).code, "GRAPH_EXECUTION_UNSUPPORTED"); -} - -#[test] -fn runtime_rejects_noncanonical_frame_out_mutations() { - let baseline = compile_graph(full_cull_graph()); - validate_activatable(&baseline).unwrap(); - let frame_index = baseline - .executions - .iter() - .position(|execution| execution.executor.key == "frame_out") - .unwrap(); - let ExecutionKind::FrameOut { color } = baseline.executions[frame_index].kind else { - unreachable!() - }; - let family_id = match baseline.resources[color as usize].plan { - ResourcePlan::Texture { family, .. } => family, - _ => unreachable!(), - }; - let assert_invalid = |graph: &CompiledGraph, path: &str| { - let error = validate_activatable(graph).unwrap_err(); - assert_eq!(error.code, "GRAPH_RUNTIME_PLAN_INVALID"); - assert_eq!(error.details["path"], path); - }; - - let mut graph = baseline.clone(); - graph.executions.remove(frame_index); - let error = validate_activatable(&graph).unwrap_err(); - assert_eq!(error.code, "GRAPH_RUNTIME_PLAN_INVALID"); - assert_eq!(error.details["path"], "resources[9].lifetime"); - - let mut graph = baseline.clone(); - graph.executions.push(graph.executions[frame_index].clone()); - let error = validate_activatable(&graph).unwrap_err(); - assert_eq!(error.code, "GRAPH_RUNTIME_PLAN_INVALID"); - assert_eq!(error.details["path"], "resources[9].lifetime"); - - let mut graph = baseline.clone(); - graph.executions[frame_index].parameters = NormalizedParameters::FullscreenCopy; - assert_invalid(&graph, &format!("executions[{frame_index}].parameters")); - - let mut graph = baseline.clone(); - graph.executions[frame_index].kind = ExecutionKind::CpuPreparation; - assert_invalid(&graph, &format!("executions[{frame_index}].kind")); - - let mut graph = baseline.clone(); - graph.executions[frame_index] - .outputs - .push(CompiledSocketOutput { - socket: "color".into(), - resource: color, - }); - assert_invalid(&graph, &format!("executions[{frame_index}].outputs")); - - for mutate in [ - |inputs: &mut Vec| inputs.clear(), - |inputs: &mut Vec| inputs.push(inputs[0].clone()), - |inputs: &mut Vec| inputs[0].socket = "source".into(), - |inputs: &mut Vec| inputs[0].resource = u32::MAX - 1, - ] { - let mut graph = baseline.clone(); - mutate(&mut graph.executions[frame_index].inputs); - assert_invalid(&graph, &format!("executions[{frame_index}].inputs")); - } - for mutate in [ - |accesses: &mut Vec| accesses.clear(), - |accesses: &mut Vec| accesses.push(accesses[0].clone()), - |accesses: &mut Vec| accesses[0].socket = "source".into(), - |accesses: &mut Vec| accesses[0].resource = u32::MAX - 1, - |accesses: &mut Vec| accesses[0].mode = AccessMode::StorageRead, - ] { - let mut graph = baseline.clone(); - mutate(&mut graph.executions[frame_index].accesses); - assert_invalid(&graph, &format!("executions[{frame_index}].accesses")); - } - - let mut graph = baseline.clone(); - graph.executions[frame_index].kind = ExecutionKind::FrameOut { color: u32::MAX }; - graph.executions[frame_index].inputs[0].resource = u32::MAX; - graph.executions[frame_index].accesses[0].resource = u32::MAX; - assert_invalid(&graph, &format!("executions[{frame_index}].inputs")); - - let mut graph = baseline.clone(); - graph.resources[color as usize].semantic_type = SemanticType::MeshData; - assert_invalid(&graph, &format!("textureFamilies[{family_id}].versions[0]")); - - for mutate in [ - |plan: &mut ResourcePlan| *plan = ResourcePlan::MeshData, - |plan: &mut ResourcePlan| { - let ResourcePlan::Texture { initialized, .. } = plan else { - unreachable!() - }; - *initialized = false; - }, - |plan: &mut ResourcePlan| { - let ResourcePlan::Texture { stored, .. } = plan else { - unreachable!() - }; - *stored = false; - }, - |plan: &mut ResourcePlan| { - let ResourcePlan::Texture { allocation, .. } = plan else { - unreachable!() - }; - *allocation = None; - }, - ] { - let mut graph = baseline.clone(); - mutate(&mut graph.resources[color as usize].plan); - assert_invalid(&graph, &format!("textureFamilies[{family_id}].versions[0]")); - } - - let descriptor_path = format!("textureFamilies[{family_id}].source"); - for mutate in [ - |descriptor: &mut NormalizedTextureDescriptor| descriptor.dimension = TextureDimension::D1, - |descriptor: &mut NormalizedTextureDescriptor| descriptor.mip_level_count = 2, - |descriptor: &mut NormalizedTextureDescriptor| descriptor.sample_count = 4, - |descriptor: &mut NormalizedTextureDescriptor| match &mut descriptor.extent { - NormalizedTextureExtent::Absolute { - depth_or_array_layers, - .. - } - | NormalizedTextureExtent::SurfaceRelative { - depth_or_array_layers, - .. - } => *depth_or_array_layers = 2, - }, - |descriptor: &mut NormalizedTextureDescriptor| descriptor.format = TextureFormat::R32Float, - |descriptor: &mut NormalizedTextureDescriptor| { - descriptor.format = TextureFormat::Depth32Float - }, - ] { - let mut graph = baseline.clone(); - let TextureFamilySource::AuthoredTexture { descriptor, .. } = - &mut graph.texture_families[family_id as usize].source; - mutate(descriptor); - assert_invalid(&graph, &descriptor_path); - } -} - -#[test] -fn runtime_rejects_noncanonical_pipeline_registry_plan() { - let baseline = compile_graph(full_cull_graph()); - validate_activatable(&baseline).unwrap(); - let registry = baseline - .executions - .iter() - .position(|execution| execution.executor.key == "pipeline_registry") - .unwrap(); - let indices = baseline.executions[registry].inputs[0].resource as usize; - let activation = baseline.executions[registry].outputs[0].resource as usize; - let activation_path = format!("resources[{activation}].plan"); - let indices_path = format!("resources[{indices}].plan"); - let cases: Vec<(&str, Box)> = vec![ - ( - "parameters", - Box::new(move |g| { - g.executions[registry].parameters = NormalizedParameters::FullscreenCopy - }), - ), - ( - "kind", - Box::new( - move |g| g.executions[registry].kind = ExecutionKind::CpuPreparation, /* replaced below */ - ), - ), - ( - "inputs", - Box::new(move |g| g.executions[registry].inputs.clear()), - ), - ( - "inputs", - Box::new(move |g| { - let duplicate = g.executions[registry].inputs[0].clone(); - g.executions[registry].inputs.push(duplicate) - }), - ), - ( - "inputs", - Box::new(move |g| g.executions[registry].inputs[0].socket = "mesh".into()), - ), - ( - "resources[8].producerExecution", - Box::new(move |g| g.executions[registry].outputs.clear()), - ), - ( - "outputs", - Box::new(move |g| { - let duplicate = g.executions[registry].outputs[0].clone(); - g.executions[registry].outputs.push(duplicate) - }), - ), - ( - "outputs", - Box::new(move |g| g.executions[registry].outputs[0].socket = "draws".into()), - ), - ( - "accesses", - Box::new(move |g| g.executions[registry].accesses[0].mode = AccessMode::StorageRead), - ), - ( - "accesses", - Box::new(move |g| g.executions[registry].accesses.clear()), - ), - ( - &activation_path, - Box::new(move |g| g.resources[activation].semantic_type = SemanticType::DrawStream), - ), - ( - &activation_path, - Box::new(move |g| { - g.resources[activation].plan = ResourcePlan::PipelineActivation { - pipeline_indices: u32::MAX, - } - }), - ), - ( - "resources[8].producerExecution", - Box::new(move |g| g.resources[activation].producer_execution = None), - ), - ( - &indices_path, - Box::new(move |g| { - g.resources[indices].plan = ResourcePlan::PipelineIndexStream { mesh: u32::MAX } - }), - ), - ]; - for (suffix, mutate) in cases { - let mut graph = baseline.clone(); - if suffix == "kind" { - graph.executions[registry].kind = ExecutionKind::FrameOut { color: 0 }; - } else { - mutate(&mut graph); - } - let error = validate_activatable(&graph).unwrap_err(); - assert_eq!(error.code, "GRAPH_RUNTIME_PLAN_INVALID", "{suffix}"); - let expected = if suffix.starts_with("resources") { - suffix.to_owned() - } else { - format!("executions[{registry}].{suffix}") - }; - assert_eq!(error.details["path"], expected, "{suffix}"); - } -} - -#[test] -fn runtime_rejects_noncanonical_pipeline_plan() { - let baseline = compile_graph(full_cull_graph()); - validate_activatable(&baseline).unwrap(); - let pipeline = baseline - .executions - .iter() - .position(|execution| execution.executor.key == "pipeline") - .unwrap(); - let outputs = baseline.executions[pipeline] - .outputs - .iter() - .map(|output| output.resource as usize) - .collect::>(); - let mesh = baseline.executions[pipeline].inputs[0].resource as usize; - let draws = baseline.executions[pipeline].inputs[1].resource as usize; - let activation = baseline.executions[pipeline].inputs[2].resource as usize; - let indices = match baseline.resources[activation].plan { - ResourcePlan::PipelineActivation { pipeline_indices } => pipeline_indices as usize, - _ => unreachable!(), - }; - let color_family = match baseline.resources[outputs[0]].plan { - ResourcePlan::Texture { family, .. } => family, - _ => unreachable!(), - }; - let color_path = format!("textureFamilies[{color_family}].versions[0]"); - let depth_path = format!("resources[{}].producerExecution", outputs[1]); - let invalid_activation_path = format!("resources[{activation}].lifetime"); - let invalid_mesh_path = format!("resources[{mesh}].lifetime"); - let indices_path = format!("resources[{indices}].plan"); - let draws_producer = baseline.resources[draws].producer_execution.unwrap(); - let draws_path = format!("executions[{draws_producer}].outputs"); - let cases: Vec<(&str, Box)> = vec![ - ( - "parameters", - Box::new(move |g| { - g.executions[pipeline].parameters = NormalizedParameters::FullscreenCopy - }), - ), - ( - "parameters", - Box::new(move |g| { - if let NormalizedParameters::Pipeline { pipeline: name, .. } = - &mut g.executions[pipeline].parameters - { - *name = "1bad".into() - } - }), - ), - ( - "parameters", - Box::new(move |g| { - if let NormalizedParameters::Pipeline { clear_depth, .. } = - &mut g.executions[pipeline].parameters - { - *clear_depth = f32::NAN - } - }), - ), - ( - "kind", - Box::new(move |g| g.executions[pipeline].kind = ExecutionKind::CpuPreparation), - ), - ( - "resources[1].lifetime", - Box::new(move |g| g.executions[pipeline].inputs.pop().map(|_| ()).unwrap()), - ), - ( - "inputs", - Box::new(move |g| g.executions[pipeline].inputs.swap(0, 1)), - ), - ( - "resources[10].producerExecution", - Box::new(move |g| g.executions[pipeline].outputs.pop().map(|_| ()).unwrap()), - ), - ( - "outputs", - Box::new(move |g| g.executions[pipeline].outputs.swap(0, 1)), - ), - ( - "accesses", - Box::new(move |g| g.executions[pipeline].accesses.pop().map(|_| ()).unwrap()), - ), - ( - "accesses", - Box::new(move |g| g.executions[pipeline].accesses.swap(0, 1)), - ), - ( - "accesses", - Box::new(move |g| g.executions[pipeline].accesses[2].mode = AccessMode::IndirectRead), - ), - ( - "kind", - Box::new(move |g| { - if let ExecutionKind::Render { - color_attachments, .. - } = &mut g.executions[pipeline].kind - { - color_attachments[0].load = NormalizedColorLoad::Load - } - }), - ), - ( - "accesses", - Box::new(move |g| { - if let AccessMode::ColorAttachment { full_overwrite, .. } = - &mut g.executions[pipeline].accesses[3].mode - { - *full_overwrite = false - } - }), - ), - ( - &color_path, - Box::new({ - let output = outputs[0]; - move |g| { - if let ResourcePlan::Texture { target, .. } = &mut g.resources[output].plan { - *target = u32::MAX - } - } - }), - ), - ( - &depth_path, - Box::new({ - let output = outputs[1]; - move |g| g.resources[output].producer_execution = None - }), - ), - ( - &invalid_activation_path, - Box::new(move |g| { - g.executions[pipeline].inputs[2].resource = draws as u32; - g.executions[pipeline].accesses[2].resource = draws as u32; - }), - ), - ( - &indices_path, - Box::new(move |g| g.resources[indices].semantic_type = SemanticType::DrawStream), - ), - ( - &draws_path, - Box::new(move |g| { - g.resources[draws].plan = ResourcePlan::DrawStream { mesh: u32::MAX } - }), - ), - ( - &invalid_mesh_path, - Box::new(move |g| { - g.executions[pipeline].inputs[0].resource = draws as u32; - g.executions[pipeline].accesses[0].resource = draws as u32; - }), - ), - ]; - for (path, mutate) in cases { - let mut graph = baseline.clone(); - mutate(&mut graph); - let error = validate_activatable(&graph).unwrap_err(); - assert_eq!(error.code, "GRAPH_RUNTIME_PLAN_INVALID", "{path}"); - let expected = if path.starts_with("executions") - || path.starts_with("resources") - || path.starts_with("textureFamilies") - { - path.to_owned() - } else { - format!("executions[{pipeline}].{path}") - }; - assert_eq!(error.details["path"], expected, "{path}"); - } - assert!(matches!( - baseline.resources[mesh].plan, - ResourcePlan::MeshData - )); -} - -fn assert_runtime_path(graph: &CompiledGraph, path: impl AsRef) { - let error = validate_activatable(graph).unwrap_err(); - assert_eq!(error.code, "GRAPH_RUNTIME_PLAN_INVALID"); - assert_eq!(error.details["path"], path.as_ref()); -} - -fn mutate_texture_descriptor( - graph: &mut CompiledGraph, - resource: u32, - mutate: impl FnOnce(&mut NormalizedTextureDescriptor), -) { - let (family, allocation) = match graph.resources[resource as usize].plan { - ResourcePlan::Texture { - family, allocation, .. - } => (family as usize, allocation.unwrap()), - _ => unreachable!(), - }; - let TextureFamilySource::AuthoredTexture { - resource: source, - descriptor, - .. - } = &mut graph.texture_families[family].source; - mutate(descriptor); - let descriptor = descriptor.clone(); - let ResourcePlan::TextureSource { - descriptor: source_descriptor, - .. - } = &mut graph.resources[*source as usize].plan - else { - unreachable!() - }; - *source_descriptor = descriptor.clone(); - let key = &mut graph.allocation_classes[allocation.class as usize].key; - key.dimension = descriptor.dimension; - key.format = descriptor.format; - key.extent = descriptor.extent; - key.mip_level_count = descriptor.mip_level_count; - key.sample_count = descriptor.sample_count; - key.view_formats = descriptor.view_formats; -} - -#[test] -fn runtime_rejects_coordinated_contract_and_texture_target_mutations() { - let baseline = compile_graph(full_cull_graph()); - validate_activatable(&baseline).unwrap(); - - let mut graph = baseline.clone(); - graph.schema_version = 1; - assert_runtime_path(&graph, "schemaVersion"); - - for key in ["frame_out", "pipeline_registry", "pipeline"] { - let i = baseline - .executions - .iter() - .position(|execution| execution.executor.key == key) - .unwrap(); - let mut graph = baseline.clone(); - graph.executions[i].executor.version += 1; - assert_runtime_path(&graph, format!("executions[{i}].executor.version")); - } - - let pipeline = baseline - .executions - .iter() - .position(|execution| execution.executor.key == "pipeline") - .unwrap(); - let color = baseline.executions[pipeline] - .outputs - .iter() - .find(|output| output.socket == "color") - .unwrap() - .resource as usize; - let depth = baseline.executions[pipeline] - .outputs - .iter() - .find(|output| output.socket == "depth") - .unwrap() - .resource; - let (color_family, color_version) = match baseline.resources[color].plan { - ResourcePlan::Texture { - family, version, .. - } => (family as usize, version as usize), - _ => unreachable!(), - }; - let depth_target = match baseline.resources[depth as usize].plan { - ResourcePlan::Texture { target, .. } => target, - _ => unreachable!(), - }; - let mut graph = baseline.clone(); - graph.executions[pipeline] - .inputs - .iter_mut() - .find(|input| input.socket == "colorTarget") - .unwrap() - .resource = depth_target; - if let ResourcePlan::Texture { target, .. } = &mut graph.resources[color].plan { - *target = depth_target; - } - graph.texture_families[color_family].versions[color_version].target = depth_target; - graph.resources[match baseline.resources[color].plan { - ResourcePlan::Texture { target, .. } => target as usize, - _ => unreachable!(), - }] - .lifetime = None; - assert_runtime_path( - &graph, - format!("textureFamilies[{color_family}].versions[{color_version}]"), - ); -} - -#[test] -fn runtime_rejects_coordinated_execution_metadata_mutations() { - let baseline = compile_graph(full_cull_graph()); - let consumer = baseline - .executions - .iter() - .position(|execution| execution.executor.key == "frame_out") - .unwrap(); - let mut graph = baseline.clone(); - let producer = graph.executions[consumer].inputs[0].resource as usize; - let producer_execution = graph.resources[producer].producer_execution.unwrap() as usize; - graph.executions.swap(producer_execution, consumer); - for resource in &mut graph.resources { - if resource.producer_execution == Some(producer_execution as u32) { - resource.producer_execution = Some(consumer as u32); - } else if resource.producer_execution == Some(consumer as u32) { - resource.producer_execution = Some(producer_execution as u32); - } - } - let swap_ordinal = |ordinal: &mut u32| { - if *ordinal == producer_execution as u32 { - *ordinal = consumer as u32; - } else if *ordinal == consumer as u32 { - *ordinal = producer_execution as u32; - } - }; - for resource in &mut graph.resources { - if let Some(lifetime) = &mut resource.lifetime { - swap_ordinal(&mut lifetime.first_use); - swap_ordinal(&mut lifetime.last_use); - if lifetime.first_use > lifetime.last_use { - std::mem::swap(&mut lifetime.first_use, &mut lifetime.last_use); - } - } - } - for family in &mut graph.texture_families { - swap_ordinal(&mut family.lifetime.first_use); - swap_ordinal(&mut family.lifetime.last_use); - if family.lifetime.first_use > family.lifetime.last_use { - std::mem::swap( - &mut family.lifetime.first_use, - &mut family.lifetime.last_use, - ); - } - for version in &mut family.versions { - swap_ordinal(&mut version.lifetime.first_use); - swap_ordinal(&mut version.lifetime.last_use); - if version.lifetime.first_use > version.lifetime.last_use { - std::mem::swap( - &mut version.lifetime.first_use, - &mut version.lifetime.last_use, - ); - } - } - } - assert_runtime_path(&graph, format!("executions[{producer_execution}].inputs")); - - let pipeline = baseline - .executions - .iter() - .position(|execution| execution.executor.key == "pipeline") - .unwrap(); - let mut graph = baseline.clone(); - let ExecutionKind::Render { - color_attachments, - depth_stencil: Some(depth), - } = &mut graph.executions[pipeline].kind - else { - unreachable!() - }; - color_attachments[0].load = NormalizedColorLoad::Load; - depth.load = NormalizedDepthLoad::Load; - for access in &mut graph.executions[pipeline].accesses { - match &mut access.mode { - AccessMode::ColorAttachment { - load, - full_overwrite, - .. - } => { - *load = NormalizedColorLoad::Load; - *full_overwrite = false; - } - AccessMode::DepthAttachment { - load, - full_overwrite, - .. - } => { - *load = NormalizedDepthLoad::Load; - *full_overwrite = false; - } - _ => {} - } - } - assert_runtime_path(&graph, format!("executions[{pipeline}].kind")); - - let mut graph = baseline.clone(); - let ExecutionKind::Render { - color_attachments, - depth_stencil: Some(depth), - } = &mut graph.executions[pipeline].kind - else { - unreachable!() - }; - let changed_color = NormalizedColorLoad::Clear { value: [0.5; 4] }; - let changed_depth = NormalizedDepthLoad::Clear { value: 0.5 }; - color_attachments[0].load = changed_color; - depth.load = changed_depth; - for access in &mut graph.executions[pipeline].accesses { - match &mut access.mode { - AccessMode::ColorAttachment { load, .. } => *load = changed_color, - AccessMode::DepthAttachment { load, .. } => *load = changed_depth, - _ => {} - } - } - assert_runtime_path(&graph, format!("executions[{pipeline}].kind")); -} - -#[test] -fn runtime_rejects_coordinated_usage_and_alias_mutations() { - let baseline = compile_graph(hdr_copy_graph()); - let family = family_by_source(&baseline, "hdr").id as usize; - let allocation = baseline.texture_families[family].allocation.unwrap(); - let mut graph = baseline.clone(); - graph.texture_families[family].usage = vec![TextureUsage::ColorAttachment]; - graph.allocation_classes[allocation.class as usize].slots[allocation.slot as usize].usage = - vec![TextureUsage::ColorAttachment]; - assert_runtime_path(&graph, format!("textureFamilies[{family}].usage")); - - let mut graph = baseline.clone(); - graph.texture_families[family] - .usage - .push(TextureUsage::Sampled); - graph.allocation_classes[allocation.class as usize].slots[allocation.slot as usize] - .usage - .push(TextureUsage::Sampled); - assert_runtime_path(&graph, format!("textureFamilies[{family}].usage")); - - let mut graph = baseline.clone(); - graph.allocation_classes[allocation.class as usize].slots[allocation.slot as usize] - .usage - .clear(); - assert_runtime_path( - &graph, - format!( - "allocationClasses[{}].slots[{}].usage", - allocation.class, allocation.slot - ), - ); - - let mut overlap = compile_graph(independent_depth_graph( - vec![ - depth_spec("depth_a", "transient"), - depth_spec("depth_b", "transient"), - ], - vec![ - pipeline_node("F0", input("color", "texture"), input("depth_a", "texture")), - pipeline_node("F1", input("F0", "color"), input("depth_b", "texture")), - pipeline_node("F2", input("F1", "color"), input("F0", "depth")), - ], - "F2", - )); - let a = family_by_source(&overlap, "depth_a").id as usize; - let b = family_by_source(&overlap, "depth_b").id as usize; - let destination = overlap.texture_families[a].allocation.unwrap(); - let old = overlap.texture_families[b].allocation.unwrap(); - overlap.texture_families[b].allocation = Some(destination); - for version in overlap.texture_families[b].versions.clone() { - if let ResourcePlan::Texture { allocation, .. } = - &mut overlap.resources[version.resource as usize].plan - { - *allocation = Some(destination); - } - } - overlap.allocation_classes[old.class as usize].slots[old.slot as usize] - .occupants - .retain(|id| *id != b as u32); - let slot = &mut overlap.allocation_classes[destination.class as usize].slots - [destination.slot as usize]; - slot.kind = AllocationKind::AliasedTransient; - slot.occupants.push(b as u32); - assert_runtime_path( - &overlap, - format!( - "allocationClasses[{}].slots[{}].occupants", - destination.class, destination.slot - ), - ); -} - -#[test] -fn registry_revision_handles_are_immutable_and_drop_is_transactional() { - let bytes = |revision| { - let mut graph = full_cull_graph(); - graph["graphId"] = json!("registry"); - graph["revision"] = json!(revision); - serde_json::to_vec(&graph).unwrap() - }; - let mut r = Registry::new(2); - let (id, _) = r.compile(&bytes(1)).unwrap(); - assert_eq!( - r.compile(&bytes(1)).unwrap_err().message, - "revision must increase" - ); - let (second, _) = r.compile(&bytes(2)).unwrap(); - assert_ne!(id, second); - assert_eq!(r.get(id).unwrap().revision, 1); - r.drop_graph(id).unwrap(); - assert_eq!(r.get(id).unwrap_err().code, "STALE_GRAPH_ID"); - let (next, _) = r.compile(&bytes(3)).unwrap(); - assert_ne!(id, next); - assert!(r.get(second).is_ok()); -} - -#[test] -fn old_schema_is_rejected() { - let old = br#"{"schemaVersion":1,"graphId":"old","revision":1,"nodes":[]}"#; - assert_eq!( - parse_and_compile(old).unwrap_err().code, - "GRAPH_SCHEMA_UNSUPPORTED" - ); -} - -#[test] -fn socket_validation_is_globally_phased() { - let mut g = full_cull_graph(); - g["nodes"][3]["inputs"] - .as_object_mut() - .unwrap() - .remove("mesh"); - g["nodes"][3]["inputs"]["bogus"] = input("mesh", "mesh"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_UNKNOWN_SOCKET", Some("nodes[3].inputs.bogus")) - ); - - let mut g = full_cull_graph(); - g["nodes"][6]["inputs"]["colorTarget"]["socket"] = json!("bogus"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ( - "GRAPH_UNKNOWN_SOCKET", - Some("nodes[6].inputs.colorTarget.socket") - ) - ); - - let mut g = full_cull_graph(); - g["nodes"][6]["inputs"] - .as_object_mut() - .unwrap() - .remove("draws"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_SOCKET_CARDINALITY", Some("nodes[6].inputs.draws")) - ); - - let mut g = full_cull_graph(); - g["nodes"][6]["inputs"]["mesh"] = input("depth", "texture"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_SOCKET_TYPE_MISMATCH", Some("nodes[6].inputs.mesh")) - ); -} - -#[test] -fn executor_version_parameter_and_state_precedence_is_global() { - let mut g = full_cull_graph(); - g["nodes"][2]["inputs"]["bad"] = input("missing", "bad"); - g["nodes"][4]["executor"]["key"] = json!("unknown"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_UNKNOWN_NODE", Some("nodes[2].inputs.bad.node")) - ); - - let mut g = full_cull_graph(); - g["nodes"][0]["parameters"] = json!({"bad":1}); - g["nodes"][1]["state"] = json!("muted"); - g["nodes"][2]["inputs"]["bad"] = input("mesh", "bad"); - g["nodes"][4]["executor"]["key"] = json!("unknown"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_UNKNOWN_EXECUTOR", Some("nodes[4].executor.key")) - ); - - let mut g = full_cull_graph(); - g["nodes"][0]["parameters"] = json!({"bad":1}); - g["nodes"][1]["state"] = json!("muted"); - g["nodes"][2]["inputs"]["bad"] = input("mesh", "bad"); - g["nodes"][4]["executor"]["version"] = json!(2); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ( - "GRAPH_EXECUTOR_VERSION_UNSUPPORTED", - Some("nodes[4].executor.version") - ) - ); - - let mut g = full_cull_graph(); - g["nodes"][0]["parameters"] = json!({"bad":1}); - g["nodes"][1]["state"] = json!("muted"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_PARAMETERS_INVALID", Some("nodes[0].parameters")) - ); -} - -#[test] -fn attachment_compatibility_matrix_is_enforced() { - let cases = [ - ( - "depth_surface", - json!({"kind":"absolute","width":4,"height":4,"depthOrArrayLayers":1}), - "depth32_float", - "d2", - 1, - ), - ( - "depth_half", - json!({"kind":"surface_relative","width":{"numerator":1,"denominator":2},"height":{"numerator":1,"denominator":2},"depthOrArrayLayers":1}), - "depth32_float", - "d2", - 1, - ), - ( - "depth_layers", - json!({"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":2}), - "depth32_float", - "d2", - 1, - ), - ( - "depth_format", - json!({"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":1}), - "rgba8_unorm", - "d2", - 1, - ), - ]; - for (_, extent, format, dimension, samples) in cases { - let mut g = full_cull_graph(); - let d = &mut g["nodes"][1]["parameters"]["texture"]; - d["extent"] = extent; - d["format"] = json!(format); - d["dimension"] = json!(dimension); - d["sampleCount"] = json!(samples); - let error = compile_error(g); - if error.details["path"] == "nodes[1].parameters.texture.extent.depthOrArrayLayers" { - assert_eq!(error.code, "GRAPH_UNSUPPORTED_FEATURE"); - } else { - assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); - } - } - - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"] = texture("rgba8_unorm", "transient"); - g["nodes"][6]["inputs"]["colorTarget"] = input("depth", "texture"); - g["nodes"][6]["inputs"]["depthTarget"] = input("color", "texture"); - assert_eq!(compile_error(g).code, "GRAPH_ILLEGAL_ACCESS"); - - for field in ["dimension", "extent", "sampleCount"] { - let mut g = full_cull_graph(); - let mut color = texture("rgba8_unorm", "transient"); - match field { - "dimension" => { - color["texture"][field] = json!("d3"); - color["texture"]["extent"] = - json!({"kind":"absolute","width":1,"height":1,"depthOrArrayLayers":1}); - } - "extent" => { - color["texture"][field] = - json!({"kind":"absolute","width":4,"height":4,"depthOrArrayLayers":1}) - } - _ => color["texture"][field] = json!(4), - } - g["nodes"] - .as_array_mut() - .unwrap() - .insert(2, node("test_color", "texture", color, json!({}))); - g["nodes"][7]["inputs"]["colorTarget"] = input("test_color", "texture"); - assert_eq!( - compile_error(g).code, - if field == "extent" { - "GRAPH_ILLEGAL_ACCESS" - } else { - "GRAPH_UNSUPPORTED_FEATURE" - }, - "{field}" - ); - } - - let mut g = full_cull_graph(); - g["nodes"].as_array_mut().unwrap().insert( - 2, - node( - "test_color", - "texture", - texture("r32_float", "transient"), - json!({}), - ), - ); - g["nodes"][7]["inputs"]["colorTarget"] = input("test_color", "texture"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_ILLEGAL_ACCESS", Some("nodes[8].inputs.color")) - ); -} - -#[test] -fn wire_rejects_old_and_unknown_fields_exactly() { - let mut cases = Vec::new(); - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["descriptor"] = g["nodes"][1]["parameters"]["texture"].take(); - cases.push(g); - for old in ["compare", "writeEnabled", "clear"] { - let mut g = full_cull_graph(); - g["nodes"][6]["parameters"][old] = json!(1); - cases.push(g); - } - for missing in ["clearDepth", "clearColor"] { - let mut g = full_cull_graph(); - let i = 6; - g["nodes"][i]["parameters"] - .as_object_mut() - .unwrap() - .remove(missing); - cases.push(g); - } - for g in cases { - assert_eq!( - parse_and_compile(&serde_json::to_vec(&g).unwrap()) - .unwrap_err() - .code, - "GRAPH_PARAMETERS_INVALID" - ); - } - for (index, field) in [(0, "legacyOptional"), (0, "unknownNodeField")] { - let mut g = full_cull_graph(); - g["nodes"][index][field] = json!(null); - assert_eq!( - parse_and_compile(&serde_json::to_vec(&g).unwrap()) - .unwrap_err() - .code, - "GRAPH_JSON_INVALID" - ); - } - let mut g = full_cull_graph(); - g["unknownGraphField"] = json!(true); - assert_eq!( - parse_and_compile(&serde_json::to_vec(&g).unwrap()) - .unwrap_err() - .code, - "GRAPH_JSON_INVALID" - ); -} - -#[test] -fn raw_limits_precede_malformed_content_and_cover_live_resources() { - let mut g = graph( - (0..1025) - .map(|i| node(&format!("n{i}"), "surface_target", json!({}), json!({}))) - .collect(), - ); - g["graphId"] = json!("bad id"); - assert_eq!(compile_error(g).details["path"], "nodes"); - let mut nodes: Vec<_> = (0..65) - .map(|i| { - node( - &format!("p{i}"), - "frame_out", - json!({}), - json!({"color":input("missing","bad")}), - ) - }) - .collect(); - assert_eq!( - compile_error(graph(std::mem::take(&mut nodes))).details["path"], - "nodes[0].inputs.color.node" - ); - let mut nodes = vec![node( - "color", - "texture", - texture("rgba8_unorm", "transient"), - json!({}), - )]; - nodes.extend(render_support_nodes()); - let mut color = input("color", "texture"); - for i in 0..508 { - let d = format!("d{i}"); - let f = format!("f{i}"); - nodes.push(node( - &d, - "texture", - texture("depth32_float", "transient"), - json!({}), - )); - nodes.push(pipeline_node(&f, color, input(&d, "texture"))); - color = input(&f, "color"); - } - nodes.push(node( - "frame_out", - "frame_out", - json!({}), - json!({"color":color}), - )); - let e = compile_error(graph(nodes)); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_LIMIT_EXCEEDED", Some("resources")) - ); -} - -#[test] -fn generated_resource_limit_is_only_final() { - fn oversized(old_present: bool) -> Value { - let mut nodes = vec![node( - "color", - "texture", - texture("rgba8_unorm", "transient"), - json!({}), - )]; - nodes.extend(render_support_nodes()); - let mut color = input("color", "texture"); - for i in 0..508 { - let d = format!("d{i}"); - let f = format!("f{i}"); - nodes.push(node( - &d, - "texture", - texture("depth32_float", "transient"), - json!({}), - )); - nodes.push(pipeline_node(&f, color, input(&d, "texture"))); - color = input(&f, "color"); - } - nodes.push(node( - "frame_out", - "frame_out", - json!({}), - json!({"color":color}), - )); - if old_present { - nodes.push(node( - "old_present", - "frame_out", - json!({}), - json!({"color":input("f0","color")}), - )); - } - assert!(nodes.len() <= 1024); - graph(nodes) - } - - let clean = compile_error(oversized(false)); - assert_eq!( - (clean.code, clean.details["path"].as_str()), - ("GRAPH_LIMIT_EXCEEDED", Some("resources")) - ); - let polluted = compile_error(oversized(true)); - assert_eq!(polluted.code, "GRAPH_EXECUTION_UNSUPPORTED"); - assert_eq!(polluted.details["path"], "nodes"); -} - -#[test] -fn bloom_composite_rejects_each_stale_sampled_texture_version() { - for stale_socket in ["source", "bloom"] { - let mut half = texture("rgba16_float", "transient"); - half["texture"]["extent"]["width"] = json!({"numerator":1,"denominator":2}); - half["texture"]["extent"]["height"] = json!({"numerator":1,"denominator":2}); - let mut half_depth = texture("depth32_float", "transient"); - half_depth["texture"]["extent"] = half["texture"]["extent"].clone(); - let mut nodes = vec![ - node( - "color", - "texture", - texture("rgba8_unorm", "transient"), - json!({}), - ), - node( - "source_target", - "texture", - texture("rgba16_float", "transient"), - json!({}), - ), - node("bloom_target", "texture", half, json!({})), - node( - "output", - "texture", - texture("rgba16_float", "transient"), - json!({}), - ), - depth_spec("source_depth_0", "transient"), - depth_spec("source_depth_1", "transient"), - node("bloom_depth_0", "texture", half_depth.clone(), json!({})), - node("bloom_depth_1", "texture", half_depth, json!({})), - ]; - nodes.extend(render_support_nodes()); - nodes.extend([ - pipeline_node("source_f0", input("source_target","texture"), input("source_depth_0","texture")), - pipeline_node("source_f1", input("source_f0","color"), input("source_depth_1","texture")), - pipeline_node("bloom_f0", input("bloom_target","texture"), input("bloom_depth_0","texture")), - pipeline_node("bloom_f1", input("bloom_f0","color"), input("bloom_depth_1","texture")), - node( - "composite", - "bloom_composite", - json!({"intensity":1.0}), - json!({ - "source":input(if stale_socket == "source" { "source_f0" } else { "source_f1" },"color"), - "bloom":input(if stale_socket == "bloom" { "source_f0" } else { "source_f1" },"color"), - "colorTarget":input("output","texture") - }), - ), - node( - "to_surface", - "fullscreen_copy", - json!({}), - json!({"source":input("composite","color"),"colorTarget":input("color","texture")}), - ), - node( - "frame_out", - "frame_out", - json!({}), - json!({"color":input("to_surface","color")}), - ), - ]); - let error = compile_error(graph(nodes)); - assert_eq!(error.code, "GRAPH_RESOURCE_VERSION_INVALID"); - assert_eq!( - error.details["path"], - format!("nodes[15].inputs.{stale_socket}") - ); - } -} - -#[test] -fn bloom_composite_requires_a_single_view_rgba16_half_resolution_bloom() { - compile_graph(bloom_composite_graph()); - - let mut invalid = bloom_composite_graph(); - invalid["nodes"][2]["parameters"]["texture"]["format"] = json!("rgba8_unorm"); - invalid["nodes"][2]["parameters"]["texture"]["mipLevelCount"] = json!(2); - let error = compile_error(invalid); - assert_eq!(error.code, "GRAPH_UNSUPPORTED_FEATURE"); - assert_eq!( - error.details["path"], - "nodes[2].parameters.texture.mipLevelCount" - ); -} - -#[test] -fn fullscreen_copy_rejects_incompatible_authored_targets_at_copy_inputs() { - for (field, value, expected_code, expected_path) in [ - ( - "format", - json!("depth32_float"), - "GRAPH_ILLEGAL_ACCESS", - "nodes[8].inputs", - ), - ( - "dimension", - json!("d3"), - "GRAPH_UNSUPPORTED_FEATURE", - "nodes[2].parameters.texture.dimension", - ), - ( - "sampleCount", - json!(4), - "GRAPH_UNSUPPORTED_FEATURE", - "nodes[2].parameters.texture.sampleCount", - ), - ( - "mipLevelCount", - json!(2), - "GRAPH_UNSUPPORTED_FEATURE", - "nodes[2].parameters.texture.mipLevelCount", - ), - ] { - let mut target = texture("rgba16_float", "transient"); - target["texture"][field] = value; - let mut nodes = vec![ - node( - "color", - "texture", - texture("rgba8_unorm", "transient"), - json!({}), - ), - node( - "source", - "texture", - texture("rgba16_float", "transient"), - json!({}), - ), - node("target", "texture", target, json!({})), - depth_spec("depth", "transient"), - ]; - nodes.extend(render_support_nodes()); - nodes.extend([ - pipeline_node("source_writer", input("source","texture"), input("depth","texture")), - node( - "copy", - "fullscreen_copy", - json!({}), - json!({"source":input("source_writer","color"),"colorTarget":input("target","texture")}), - ), - node( - "to_surface", - "fullscreen_copy", - json!({}), - json!({"source":input("copy","color"),"colorTarget":input("color","texture")}), - ), - node( - "frame_out", - "frame_out", - json!({}), - json!({"color":input("to_surface","color")}), - ), - ]); - let error = compile_error(graph(nodes)); - assert_eq!(error.code, expected_code, "field {field}"); - assert_eq!(error.details["path"], expected_path, "field {field}"); - } -} - -#[test] -fn runtime_rejects_coordinated_executor_frustum_and_fullscreen_mutations() { - let baseline = compile_graph(full_cull_graph()); - validate_activatable(&baseline).unwrap(); - - let mut graph = baseline.clone(); - graph.executions[0].executor.key = "unknown_executor".into(); - graph.executions[0].inputs.push(CompiledSocketInput { - socket: "bad".into(), - resource: u32::MAX, - }); - let error = validate_activatable(&graph).unwrap_err(); - assert_eq!(error.code, "GRAPH_EXECUTION_UNSUPPORTED"); - assert_eq!(error.details["path"], "executions[0]"); - - let cull = baseline - .executions - .iter() - .position(|execution| execution.executor.key == "frustum_cull") - .unwrap(); - let mut graph = baseline.clone(); - graph.executions[cull].kind = ExecutionKind::Compute { - work: ComputeWork::MeshQuery, - }; - assert_runtime_path(&graph, format!("executions[{cull}].kind")); - let mut graph = baseline.clone(); - graph.executions[cull].inputs.swap(0, 1); - graph.executions[cull].accesses.swap(0, 1); - assert_runtime_path(&graph, format!("executions[{cull}].inputs")); - - let post = compile_graph(hdr_copy_graph()); - validate_activatable(&post).unwrap(); - let copy = post - .executions - .iter() - .position(|execution| execution.executor.key == "fullscreen_copy") - .unwrap(); - let mut graph = post.clone(); - let ExecutionKind::Render { - color_attachments, .. - } = &mut graph.executions[copy].kind - else { - unreachable!() - }; - color_attachments[0].store = StoreOp::Discard; - if let AccessMode::ColorAttachment { store, .. } = &mut graph.executions[copy].accesses[1].mode - { - *store = StoreOp::Discard; - } - assert_runtime_path(&graph, format!("executions[{copy}].kind")); -} - -fn frame_parameters(hdr: bool) -> Value { - json!({"surfaceFormat":"preferred","hdrEnabled":hdr,"toneMapper":"reinhard","exposureStops":2, - "outputTransfer":"srgb","scaleMode":"contain","filter":"nearest", - "backgroundColor":[0.1,0.2,0.3,0.4]}) -} - -#[test] -fn frame_out_v3_has_exact_eight_fields_normalizes_sdr_and_rejects_v2() { - let fields = [ - "surfaceFormat", - "hdrEnabled", - "toneMapper", - "exposureStops", - "outputTransfer", - "scaleMode", - "filter", - "backgroundColor", - ]; - for field in fields { - let mut g = full_cull_graph(); - let i = node_index(&g, "frame_out"); - g["nodes"][i]["parameters"] = frame_parameters(false); - g["nodes"][i]["parameters"] - .as_object_mut() - .unwrap() - .remove(field); - assert_eq!( - compile_error(g).code, - "GRAPH_PARAMETERS_INVALID", - "missing {field}" - ); - } - let mut g = full_cull_graph(); - let i = node_index(&g, "frame_out"); - g["nodes"][i]["parameters"] = frame_parameters(false); - g["nodes"][i]["parameters"]["extra"] = json!(0); - assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); - for (field, bad) in [ - ("toneMapper", json!("bad")), - ("exposureStops", json!(11)), - ("backgroundColor", json!([0, 0, -0.1, 1])), - ] { - let mut g = full_cull_graph(); - let i = node_index(&g, "frame_out"); - g["nodes"][i]["parameters"] = frame_parameters(false); - g["nodes"][i]["parameters"][field] = bad; - assert_eq!( - compile_error(g).code, - "GRAPH_PARAMETERS_INVALID", - "hidden {field}" - ); - } - let mut g = full_cull_graph(); - let i = node_index(&g, "frame_out"); - g["nodes"][i]["parameters"] = frame_parameters(false); - assert!(matches!( - execution(&compile_graph(g), "frame_out").parameters, - NormalizedParameters::FrameOut { - dynamic_range: FrameDynamicRange::Sdr, - .. - } - )); - let mut g = full_cull_graph(); - let i = node_index(&g, "frame_out"); - g["nodes"][i]["executor"]["version"] = json!(2); - assert_eq!(compile_error(g).code, "GRAPH_EXECUTOR_VERSION_UNSUPPORTED"); -} - -#[test] -fn frame_out_source_format_matrix_is_exact() { - let surface = RuntimeSurfaceContract { - format: wgpu::TextureFormat::Bgra8Unorm, - width: 1280, - height: 720, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - present_mode: wgpu::PresentMode::Fifo, - alpha_mode: wgpu::CompositeAlphaMode::Opaque, - view_formats: vec![], - desired_maximum_frame_latency: 2, - }; - for (format, sdr, hdr) in [ - ("rgba8_unorm", true, false), - ("bgra8_unorm", true, false), - ("rgba16_float", true, true), - ("rgba8_unorm_srgb", false, false), - ("bgra8_unorm_srgb", false, false), - ("r32_float", false, false), - ("depth32_float", false, false), - ] { - for (hdr_enabled, accepted) in [(false, sdr), (true, hdr)] { - let mut g = full_cull_graph(); - let i = node_index(&g, "frame_out"); - if format == "depth32_float" { - g["nodes"][i]["inputs"]["color"] = input("pipeline_main", "depth"); - } else { - g["nodes"][0]["parameters"]["texture"]["format"] = json!(format); - } - g["nodes"][i]["parameters"] = frame_parameters(hdr_enabled); - match compile(serde_json::from_value(g).unwrap()) { - Ok(compiled) => { - assert!( - accepted, - "unexpected accept: hdr={hdr_enabled} format={format}" - ); - prepare_runtime_plan(&compiled, surface.clone(), None).unwrap(); - } - Err(error) => { - assert!( - !accepted, - "unexpected reject: hdr={hdr_enabled} format={format}" - ); - let expected_message = if hdr_enabled { - "HDR frame output requires rgba16_float" - } else { - "SDR frame output requires a linear filterable color texture" - }; - assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); - assert_eq!(error.details["path"], format!("nodes[{i}].inputs.color")); - assert_eq!(error.message, expected_message); - assert_eq!(error.details["message"], expected_message); - } - } - } - } -} - -#[test] -fn surface_contract_resolution_is_capability_driven_and_policy_is_fixed() { - let capabilities = wgpu::SurfaceCapabilities { - formats: vec![ - wgpu::TextureFormat::Bgra8UnormSrgb, - wgpu::TextureFormat::Rgba16Float, - wgpu::TextureFormat::Bgra8Unorm, - ], - present_modes: vec![wgpu::PresentMode::Immediate, wgpu::PresentMode::Fifo], - alpha_modes: vec![ - wgpu::CompositeAlphaMode::PreMultiplied, - wgpu::CompositeAlphaMode::Opaque, - ], - usages: wgpu::TextureUsages::RENDER_ATTACHMENT, - }; - let preferred = resolve_surface_contract( - SurfaceFormatRequest::Preferred, - &capabilities, - 640, - 480, - "format", - ) - .unwrap(); - assert_eq!(preferred.format, wgpu::TextureFormat::Rgba16Float); - assert_eq!(preferred.present_mode, wgpu::PresentMode::Fifo); - assert_eq!(preferred.alpha_mode, wgpu::CompositeAlphaMode::Opaque); - assert_eq!(preferred.desired_maximum_frame_latency, 2); - assert_eq!((preferred.width, preferred.height), (640, 480)); - assert!(resolve_surface_contract( - SurfaceFormatRequest::Bgra8Unorm, - &capabilities, - 1, - 1, - "format" - ) - .is_ok()); - for (width, height) in [(0, 1), (1, 0)] { - assert_eq!( - resolve_surface_contract( - SurfaceFormatRequest::Preferred, - &capabilities, - width, - height, - "format", - ) - .unwrap_err() - .details["path"], - "surface" - ); - } - let no_attachment = wgpu::SurfaceCapabilities { - formats: capabilities.formats.clone(), - present_modes: capabilities.present_modes.clone(), - alpha_modes: capabilities.alpha_modes.clone(), - usages: wgpu::TextureUsages::COPY_DST, - }; - assert_eq!( - resolve_surface_contract( - SurfaceFormatRequest::Preferred, - &no_attachment, - 1, - 1, - "format", - ) - .unwrap_err() - .details["path"], - "surface.usage" - ); - for request in [ - SurfaceFormatRequest::Rgba8Unorm, - SurfaceFormatRequest::Preferred, - ] { - let caps = wgpu::SurfaceCapabilities { - formats: vec![wgpu::TextureFormat::Bgra8UnormSrgb], - present_modes: capabilities.present_modes.clone(), - alpha_modes: capabilities.alpha_modes.clone(), - usages: capabilities.usages, - }; - assert_eq!( - resolve_surface_contract(request, &caps, 1, 1, "format") - .unwrap_err() - .code, - "GRAPH_SURFACE_INCOMPATIBLE" - ); - } - for caps in [ - wgpu::SurfaceCapabilities { - formats: capabilities.formats.clone(), - present_modes: vec![wgpu::PresentMode::Immediate], - alpha_modes: capabilities.alpha_modes.clone(), - usages: capabilities.usages, - }, - wgpu::SurfaceCapabilities { - formats: capabilities.formats.clone(), - present_modes: capabilities.present_modes.clone(), - alpha_modes: vec![wgpu::CompositeAlphaMode::PreMultiplied], - usages: capabilities.usages, - }, - ] { - assert_eq!( - resolve_surface_contract(SurfaceFormatRequest::Preferred, &caps, 1, 1, "format") - .unwrap_err() - .code, - "GRAPH_SURFACE_INCOMPATIBLE" - ); - } - for (present_modes, alpha_modes, message) in [ - ( - vec![wgpu::PresentMode::Immediate], - vec![wgpu::CompositeAlphaMode::Opaque], - "fixed surface present mode is unsupported", - ), - ( - vec![wgpu::PresentMode::Fifo], - vec![wgpu::CompositeAlphaMode::PreMultiplied], - "fixed surface alpha mode is unsupported", - ), - ] { - let caps = wgpu::SurfaceCapabilities { - formats: vec![wgpu::TextureFormat::Bgra8UnormSrgb], - present_modes, - alpha_modes, - usages: capabilities.usages, - }; - let error = resolve_surface_contract( - SurfaceFormatRequest::Rgba8Unorm, - &caps, - 1, - 1, - "nodes[7].parameters.surfaceFormat", - ) - .unwrap_err(); - assert_eq!(error.code, "GRAPH_SURFACE_INCOMPATIBLE"); - assert_eq!(error.details["path"], "surface"); - assert_eq!(error.message, message); - } -} - -#[test] -fn graph_surface_resolution_uses_canonical_authored_path_and_all_requests_activate() { - for (request, format) in [ - ("preferred", wgpu::TextureFormat::Bgra8Unorm), - ("rgba8_unorm", wgpu::TextureFormat::Rgba8Unorm), - ("bgra8_unorm", wgpu::TextureFormat::Bgra8Unorm), - ("rgba16_float", wgpu::TextureFormat::Rgba16Float), - ] { - let mut authored = full_cull_graph(); - let index = node_index(&authored, "frame_out"); - authored["nodes"][index]["parameters"]["surfaceFormat"] = json!(request); - let graph = compile_graph(authored); - validate_activatable(&graph).unwrap(); - let caps = wgpu::SurfaceCapabilities { - formats: vec![format], - present_modes: vec![wgpu::PresentMode::Fifo], - alpha_modes: vec![wgpu::CompositeAlphaMode::Opaque], - usages: wgpu::TextureUsages::RENDER_ATTACHMENT, - }; - resolve_graph_surface_contract(&graph, &caps, 1, 1).unwrap(); - let mut unsupported = caps; - unsupported.formats.clear(); - let error = resolve_graph_surface_contract(&graph, &unsupported, 1, 1).unwrap_err(); - assert_eq!( - error.details["path"], - format!("nodes[{index}].parameters.surfaceFormat") - ); - } -} - -#[test] -fn runtime_rejects_every_frame_parameter_lane_and_coordinated_source_mutations() { - let baseline = compile_graph(full_cull_graph()); - let i = baseline - .executions - .iter() - .position(|e| e.executor.key == "frame_out") - .unwrap(); - for version in [1, 2, 4] { - let mut g = baseline.clone(); - g.executions[i].executor.version = version; - assert_runtime_path(&g, format!("executions[{i}].executor.version")); - } - for lane in 0..4 { - for value in [f32::NAN, -0.1, 1.1] { - let mut g = baseline.clone(); - let NormalizedParameters::FrameOut { - background_color, .. - } = &mut g.executions[i].parameters - else { - unreachable!() - }; - background_color[lane] = value; - assert_runtime_path(&g, format!("executions[{i}].parameters")); - } - } - for exposure in [f32::NAN, -10.1, 10.1] { - let mut g = baseline.clone(); - let NormalizedParameters::FrameOut { dynamic_range, .. } = &mut g.executions[i].parameters - else { - unreachable!() - }; - *dynamic_range = FrameDynamicRange::Hdr { - tone_mapper: ToneMapper::Aces, - exposure_stops: exposure, - }; - assert_runtime_path(&g, format!("executions[{i}].parameters")); - } - let ExecutionKind::FrameOut { color } = baseline.executions[i].kind else { - unreachable!() - }; - let family = match baseline.resources[color as usize].plan { - ResourcePlan::Texture { family, .. } => family, - _ => unreachable!(), - } as usize; - for format in [ - TextureFormat::Rgba8UnormSrgb, - TextureFormat::Bgra8UnormSrgb, - TextureFormat::R32Float, - ] { - let mut g = baseline.clone(); - mutate_texture_descriptor(&mut g, color, |descriptor| descriptor.format = format); - assert_runtime_path(&g, format!("textureFamilies[{family}].source.descriptor")); - } - - let mut sdr_to_hdr = baseline.clone(); - let NormalizedParameters::FrameOut { dynamic_range, .. } = - &mut sdr_to_hdr.executions[i].parameters - else { - unreachable!() - }; - *dynamic_range = FrameDynamicRange::Hdr { - tone_mapper: ToneMapper::Aces, - exposure_stops: 0., - }; - assert_runtime_path( - &sdr_to_hdr, - format!("textureFamilies[{family}].source.descriptor"), - ); - - let mut hdr_value = full_cull_graph(); - hdr_value["nodes"][0]["parameters"]["texture"]["format"] = json!("rgba16_float"); - let frame_node = node_index(&hdr_value, "frame_out"); - hdr_value["nodes"][frame_node]["parameters"] = frame_parameters(true); - let mut hdr_to_sdr = compile_graph(hdr_value); - let hdr_frame = hdr_to_sdr - .executions - .iter() - .position(|e| e.executor.key == "frame_out") - .unwrap(); - let NormalizedParameters::FrameOut { - dynamic_range, - output_transfer, - .. - } = &mut hdr_to_sdr.executions[hdr_frame].parameters - else { - unreachable!() - }; - *dynamic_range = FrameDynamicRange::Sdr; - *output_transfer = OutputTransfer::Linear; - let linear_surface = RuntimeSurfaceContract { - format: wgpu::TextureFormat::Bgra8Unorm, - width: 1280, - height: 720, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - present_mode: wgpu::PresentMode::Fifo, - alpha_mode: wgpu::CompositeAlphaMode::Opaque, - view_formats: vec![], - desired_maximum_frame_latency: 2, - }; - prepare_runtime_plan(&hdr_to_sdr, linear_surface.clone(), None).unwrap(); - let error = prepare_runtime_plan( - &hdr_to_sdr, - RuntimeSurfaceContract { - format: wgpu::TextureFormat::Bgra8UnormSrgb, - ..linear_surface - }, - None, - ) - .unwrap_err(); - assert_eq!(error.code, "GRAPH_SURFACE_INCOMPATIBLE"); - assert_eq!( - error.details["path"], - format!("executions[{hdr_frame}].parameters.outputTransfer") - ); -} - -#[test] -fn runtime_rejects_mesh_query_predicate_shape_mutations() { - let baseline = compile_graph(full_cull_graph()); - validate_activatable(&baseline).unwrap(); - let query = baseline - .executions - .iter() - .position(|execution| execution.executor.key == "mesh_query") - .unwrap(); - - let mut graph = baseline.clone(); - let removed = graph.executions[query].inputs.pop().unwrap().resource; - graph.executions[query].accesses.remove(2); - let producer = graph.resources[removed as usize] - .producer_execution - .unwrap(); - graph.resources[removed as usize].lifetime = Some(Lifetime { - first_use: producer, - last_use: producer, - }); - assert_runtime_path(&graph, format!("executions[{query}].inputs")); - - let mut graph = baseline.clone(); - let NormalizedParameters::MeshQuery { - frustum_culled_predicate, - .. - } = &mut graph.executions[query].parameters - else { - unreachable!() - }; - *frustum_culled_predicate = RuntimePredicate::Any; - assert_runtime_path(&graph, format!("executions[{query}].inputs")); - - let mut graph = baseline; - let NormalizedParameters::MeshQuery { - visible_predicate, .. - } = &mut graph.executions[query].parameters - else { - unreachable!() - }; - *visible_predicate = RuntimePredicate::Never; - assert_runtime_path(&graph, format!("executions[{query}].parameters")); -} - -#[test] -fn runtime_rejects_fullscreen_parameter_and_sample_order_mutations() { - let baseline = compile_graph(hdr_copy_graph()); - validate_activatable(&baseline).unwrap(); - let copy = baseline - .executions - .iter() - .position(|execution| execution.executor.key == "fullscreen_copy") - .unwrap(); - let invalid_parameters = [ - ( - "bloom_extract", - NormalizedParameters::BloomExtract { - threshold: -0.1, - knee: 0.5, - }, - ), - ( - "bloom_extract", - NormalizedParameters::BloomExtract { - threshold: 1.0, - knee: f32::NAN, - }, - ), - ( - "bloom_blur", - NormalizedParameters::BloomBlur { - direction: [0.5, 0.5], - radius: 0.9, - }, - ), - ( - "bloom_blur", - NormalizedParameters::BloomBlur { - direction: [f32::NAN, 0.0], - radius: 1.0, - }, - ), - ( - "bloom_composite", - NormalizedParameters::BloomComposite { intensity: 16.1 }, - ), - ( - "luminance_edge", - NormalizedParameters::LuminanceEdge { strength: -0.1 }, - ), - ]; - for (key, parameters) in invalid_parameters { - let mut graph = baseline.clone(); - graph.executions[copy].executor.key = key.into(); - graph.executions[copy].parameters = parameters; - assert_runtime_path(&graph, format!("executions[{copy}].parameters")); - } - - let mut graph = compile_graph(bloom_composite_graph()); - validate_activatable(&graph).unwrap(); - let composite = graph - .executions - .iter() - .position(|execution| execution.executor.key == "bloom_composite") - .unwrap(); - graph.executions[composite].accesses.swap(0, 1); - assert_runtime_path(&graph, format!("executions[{composite}].accesses")); -} - -#[test] -fn runtime_requires_exact_query_and_draw_stream_producers() { - let baseline = compile_graph(full_cull_graph()); - validate_activatable(&baseline).unwrap(); - let query = baseline - .executions - .iter() - .position(|execution| execution.executor.key == "mesh_query") - .unwrap(); - let pipeline = baseline - .executions - .iter() - .position(|execution| execution.executor.key == "pipeline") - .unwrap(); - - let mut graph = baseline.clone(); - let original = graph.executions[query].inputs[2].resource; - let mut synthetic = graph.resources[original as usize].clone(); - synthetic.producer_execution = None; - synthetic.lifetime = Some(Lifetime { - first_use: query as u32, - last_use: query as u32, - }); - let replacement = graph.resources.len() as u32; - graph.resources.push(synthetic); - graph.executions[query].inputs[2].resource = replacement; - graph.executions[query].accesses[2].resource = replacement; - let producer = graph.resources[original as usize] - .producer_execution - .unwrap(); - graph.resources[original as usize].lifetime = Some(Lifetime { - first_use: producer, - last_use: producer, - }); - assert_runtime_path(&graph, format!("executions[{query}].inputs")); - - let mut graph = baseline; - let original = graph.executions[pipeline].inputs[1].resource; - let mut synthetic = graph.resources[original as usize].clone(); - synthetic.producer_execution = None; - synthetic.lifetime = Some(Lifetime { - first_use: pipeline as u32, - last_use: pipeline as u32, - }); - let replacement = graph.resources.len() as u32; - graph.resources.push(synthetic); - graph.executions[pipeline].inputs[1].resource = replacement; - graph.executions[pipeline].accesses[1].resource = replacement; - graph.resources[original as usize].lifetime = Some(Lifetime { - first_use: query as u32, - last_use: query as u32, - }); - assert_runtime_path(&graph, format!("executions[{pipeline}].inputs")); -} - -#[test] -fn runtime_rechecks_pipeline_attachment_descriptors() { - let baseline = compile_graph(full_cull_graph()); - validate_activatable(&baseline).unwrap(); - let pipeline = baseline - .executions - .iter() - .position(|execution| execution.executor.key == "pipeline") - .unwrap(); - let attachment = |graph: &CompiledGraph, socket: &str| { - graph.executions[pipeline] .outputs .iter() - .find(|output| output.socket == socket) - .unwrap() - .resource - }; - let mut graph = baseline.clone(); - let color = attachment(&graph, "color"); - mutate_texture_descriptor(&mut graph, color, |descriptor| { - descriptor.format = TextureFormat::Depth32Float; - }); - assert_runtime_path(&graph, format!("executions[{pipeline}].inputs")); + .map(|o| (o.name, o.semantic_type)) + .collect::>(), + [ + ("mesh", SemanticType::MeshData), + ("type", SemanticType::U32x16), + ("localAabb", SemanticType::LocalAabb) + ] + ); + let predicate = contract("pipeline") + .unwrap() + .inputs + .iter() + .find(|i| i.name == "predicate") + .unwrap(); + assert_eq!(predicate.cardinality, InputCardinality::OptionalOne); + for key in [ + "and", + "xnor", + "greater_than_f32", + "equals_u32", + "combine_vec4", + "separate_mat4", + "combine_u32_bits", + "separate_u32x16", + "separate_local_aabb", + ] { + assert!(contract(key).is_some(), "missing {key}"); + } +} - let mut graph = baseline.clone(); - let depth = attachment(&graph, "depth"); - mutate_texture_descriptor(&mut graph, depth, |descriptor| { - descriptor.format = TextureFormat::Rgba16Float; - }); - assert_runtime_path(&graph, format!("executions[{pipeline}].inputs")); +#[test] +fn typed_graph_builds_one_dense_deterministic_traversal() { + let first = compile_value(full_cull_graph()).unwrap(); + let second = compile_value(full_cull_graph()).unwrap(); + let traversal = first.instance_traversal.as_ref().unwrap(); + assert!(traversal.requires_camera); + assert_eq!(traversal.pipelines.len(), 1); + assert_eq!(traversal.pipelines[0].ordinal, 0); + assert!(traversal + .expressions + .expressions + .iter() + .enumerate() + .all(|(id, expression)| { expression.origin.node.len() > 0 && id < MAX_EXPRESSIONS })); + assert_eq!( + serde_json::to_value(traversal).unwrap(), + serde_json::to_value(second.instance_traversal.unwrap()).unwrap() + ); +} - let mut graph = baseline; - let color = attachment(&graph, "color"); - mutate_texture_descriptor(&mut graph, color, |descriptor| { - descriptor.extent = NormalizedTextureExtent::Absolute { - width: 4, - height: 4, - depth_or_array_layers: 1, - }; - }); - assert_runtime_path(&graph, format!("executions[{pipeline}].inputs")); +#[test] +fn pipeline_predicate_defaults_true_and_expression_edges_are_validated() { + let mut graph = full_cull_graph(); + graph["nodes"][8]["inputs"] + .as_object_mut() + .unwrap() + .remove("predicate"); + let compiled = compile_value(graph).unwrap(); + let pipeline = compiled + .executions + .iter() + .find(|e| e.id == "pipeline") + .unwrap(); + assert!(matches!( + pipeline.parameters, + NormalizedParameters::Pipeline { + predicate_default: true, + .. + } + )); + + let mut cycle = full_cull_graph(); + cycle["nodes"][6]["inputs"]["operand"] = input("class", "value"); + assert_eq!(compile_value(cycle).unwrap_err().code, "GRAPH_CYCLE"); +} + +#[test] +fn expression_provenance_rejects_cross_mesh_values() { + let mut graph = full_cull_graph(); + graph["nodes"] + .as_array_mut() + .unwrap() + .insert(3, node("other", "mesh", 2, json!({}), json!({}))); + // The insertion shifts cull to index 6; pair another mesh with the first mesh's AABB. + graph["nodes"][6]["inputs"]["mesh"] = input("other", "mesh"); + let error = compile_value(graph).unwrap_err(); + assert_eq!(error.code, "GRAPH_SOCKET_TYPE_MISMATCH"); } diff --git a/renderer/src/renderer/culling.wgsl b/renderer/src/renderer/culling.wgsl deleted file mode 100644 index e20420b..0000000 --- a/renderer/src/renderer/culling.wgsl +++ /dev/null @@ -1,56 +0,0 @@ -struct Params { planes: array, 6>, count: u32, visible_predicate: u32, frustum_predicate: u32, _pad: u32 } -struct Instance { model: mat4x4, n0: vec4, n1: vec4, n2: vec4 } -struct Aabb { min: vec4, max: vec4 } -struct Meta { index_count: u32, first_index: u32, base_vertex: i32, instance_index: u32 } -struct Command { index_count: u32, instance_count: u32, first_index: u32, base_vertex: i32, first_instance: u32 } -@group(0) @binding(0) var params: Params; -@group(0) @binding(1) var instances: array; -@group(0) @binding(2) var bounds: array; -@group(0) @binding(3) var authored_visible: array; -@group(0) @binding(4) var metadata: array; -@group(0) @binding(5) var frustum_flags: array; -@group(0) @binding(6) var commands: array; - -@compute @workgroup_size(64) -fn frustum_cull(@builtin(global_invocation_id) id: vec3) { - let i = id.x; - if (i >= params.count) { return; } - let b = bounds[i]; - let center = (b.min.xyz + b.max.xyz) * 0.5; - let extent = (b.max.xyz - b.min.xyz) * 0.5; - let m = instances[i].model; - let wc = (m * vec4(center, 1.0)).xyz; - let ax = m[0].xyz * extent.x; - let ay = m[1].xyz * extent.y; - let az = m[2].xyz * extent.z; - var inside = 1u; - for (var p = 0u; p < 6u; p++) { - let plane = params.planes[p]; - let radius = abs(dot(plane.xyz, ax)) + abs(dot(plane.xyz, ay)) + abs(dot(plane.xyz, az)); - if (dot(plane.xyz, wc) + plane.w + radius < 0.0) { inside = 0u; } - } - frustum_flags[i] = 1u - inside; -} - -fn matches(value: u32, predicate: u32) -> bool { - return predicate == 0u || (predicate == 1u && value != 0u) || (predicate == 2u && value == 0u); -} - -@compute @workgroup_size(64) -fn mesh_query(@builtin(global_invocation_id) id: vec3) { - let i = id.x; - if (i >= params.count) { return; } - let draw_meta = metadata[i]; - var selected = true; - if (params.visible_predicate == 3u) { - selected = false; - } else if (params.visible_predicate != 0u) { - selected = matches(authored_visible[i], params.visible_predicate); - } - if (selected && params.frustum_predicate == 3u) { - selected = false; - } else if (selected && params.frustum_predicate != 0u) { - selected = selected && matches(frustum_flags[i], params.frustum_predicate); - } - commands[i] = Command(draw_meta.index_count, select(0u, 1u, selected), draw_meta.first_index, draw_meta.base_vertex, 0u); -} diff --git a/renderer/src/renderer/executors/pipeline.rs b/renderer/src/renderer/executors/pipeline.rs index a79b7eb..a9dabe2 100644 --- a/renderer/src/renderer/executors/pipeline.rs +++ b/renderer/src/renderer/executors/pipeline.rs @@ -30,7 +30,7 @@ fn encode_scene<'a, T: Scene>( pass.set_vertex_buffer(4, t.slice(..)); pass.set_index_buffer(i.slice(..), wgpu::IndexFormat::Uint32); for draw in &gpu.draws { - if !draw.effective_visible { + if !draw.instance_type.is_visible() { continue; } pass.set_pipeline(pipelines.get_pipeline(draw.pipeline)); @@ -54,6 +54,7 @@ pub(crate) fn encode_compiled( gpu: &GpuSceneCache, pipelines: &PipelineLibrary, materials: &MaterialResources, + indirect_commands: &wgpu::Buffer, mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>, ) -> Result<(), &'static str> { use crate::render_graph::{ExecutionKind, NormalizedColorLoad, NormalizedDepthLoad, StoreOp}; @@ -73,16 +74,8 @@ pub(crate) fn encode_compiled( .map(|s| &s.view) .ok_or(" allocation out of bounds") }; - for (execution_index, prepared) in active.executions.iter().enumerate() { - let profile_id = &active.graph.executions[execution_index].id; + for prepared in &active.executions { match prepared { - PreparedExecution::PipelineRegistry => {} - PreparedExecution::FrustumCull => { - gpu.encode_frustum_cull(encoder, profile.as_deref_mut(), profile_id); - } - PreparedExecution::MeshQuery => { - gpu.encode_mesh_query(encoder, profile.as_deref_mut(), profile_id); - } PreparedExecution::Fullscreen { execution, frame_out, @@ -159,6 +152,7 @@ pub(crate) fn encode_compiled( PreparedExecution::Pipeline { execution, base, + predicate_ordinal, variant, } => { let execution = active @@ -236,24 +230,19 @@ pub(crate) fn encode_compiled( pass.set_vertex_buffer(2, u.slice(..)); pass.set_vertex_buffer(4, t.slice(..)); pass.set_index_buffer(ix.slice(..), wgpu::IndexFormat::Uint32); - for draw in &gpu.draws { - if draw.pipeline != *base { - continue; - } - let slot = draw.instances.start as u64; - let start = slot - * std::mem::size_of::() as u64; - pass.set_vertex_buffer(3, inst.slice(start..start + 112)); + pass.set_vertex_buffer(3, inst.slice(..)); + for (draw_index, draw) in gpu.draws.iter().enumerate() { pass.set_pipeline(variant); - if pipelines.requires_material(draw.pipeline) { + if pipelines.requires_material(*base) { pass.set_bind_group(2, materials.group(draw.material), &[]); } pass.draw_indexed_indirect( - gpu.indirect_commands - .buffer - .as_ref() - .ok_or("indirect command buffer missing")?, - slot * 20, + indirect_commands, + crate::renderer::instance_traversal::command_offset( + *predicate_ordinal, + gpu.draws.len(), + draw_index, + ), ); } } diff --git a/renderer/src/renderer/gpu_scene.rs b/renderer/src/renderer/gpu_scene.rs index a4b7d52..8076fd3 100644 --- a/renderer/src/renderer/gpu_scene.rs +++ b/renderer/src/renderer/gpu_scene.rs @@ -1,10 +1,11 @@ use std::mem::size_of; +use bytemuck::{Pod, Zeroable}; + use crate::{ - render_data::{MaterialKey, MeshHandle, PipelineKey, RenderFlags}, + render_data::{InstanceType, MaterialKey, MeshHandle, PipelineKey}, renderer::scene_frame::SceneFramePlan, }; -use bytemuck::{Pod, Zeroable}; #[repr(C)] #[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq)] @@ -23,7 +24,7 @@ pub struct DrawItem { pub indices: std::ops::Range, pub base_vertex: i32, pub instances: std::ops::Range, - pub effective_visible: bool, + pub instance_type: InstanceType, } #[repr(C)] @@ -62,162 +63,142 @@ pub struct GpuScenePlan { pub instances: Vec, pub draws: Vec, pub local_aabbs: Vec, - pub effective_visibility: Vec, + pub instance_types: Vec<[u32; 16]>, pub draw_metadata: Vec, - pub commands: Vec, -} - -fn visibility_matches( - predicate: crate::render_graph::TriStatePredicate, - mesh: RenderFlags, - instance: RenderFlags, -) -> bool { - let effective = mesh.contains(RenderFlags::VISIBLE) && instance.contains(RenderFlags::VISIBLE); - match predicate { - crate::render_graph::TriStatePredicate::Any => true, - crate::render_graph::TriStatePredicate::RequiredTrue => effective, - crate::render_graph::TriStatePredicate::RequiredFalse => !effective, - } } impl GpuScenePlan { pub fn build(data: &SceneFramePlan) -> Result { - self::GpuScenePlan::build_with_query( - data, - crate::render_graph::MeshQueryRuntimeKey { - visible: crate::render_graph::RuntimePredicate::RequiredTrue, - frustum_culled: crate::render_graph::RuntimePredicate::Any, - }, - ) - } - - pub fn build_with_query( - data: &SceneFramePlan, - query: crate::render_graph::MeshQueryRuntimeKey, - ) -> Result { - let _ = query; // Packing is canonical; predicates are evaluated by the GPU. - let mut plan = Self::default(); + let mut p = Self::default(); let mut meshes: Vec<_> = data.meshes.iter().collect(); - meshes.sort_by_key(|mesh| { + meshes.sort_by_key(|m| { ( - mesh.pipeline.get(), - mesh.material.get(), - mesh.handle.slot(), - mesh.handle.generation(), + m.pipeline.get(), + m.material.get(), + m.handle.slot(), + m.handle.generation(), ) }); for mesh in meshes { let occurrences: Vec<_> = data.mesh_occurrence_indices[mesh.occurrence_range.clone()] .iter() - .map(|&index| &data.occurrences[index]) + .map(|&i| &data.occurrences[i]) .collect(); 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 + let vs = mesh.geometry.vertex_start as usize; + let ve = vs .checked_add(mesh.geometry.vertex_count as usize) .ok_or("vertex range overflow")?; - plan.positions.extend_from_slice( - data.positions - .get(source_start..source_end) - .ok_or("invalid vertex range")?, - ); - plan.normals.extend_from_slice( - data.normals - .get(source_start..source_end) - .ok_or("invalid normal range")?, - ); - plan.uvs.extend_from_slice( - data.uvs - .get(source_start..source_end) - .ok_or("invalid uv range")?, - ); - plan.tangents.extend_from_slice( - data.tangents - .get(source_start..source_end) - .ok_or("invalid tangent range")?, - ); - let index_start = - u32::try_from(plan.indices.len()).map_err(|_| "index start exceeds u32")?; - let source_index = mesh.geometry.index_start as usize; - let source_index_end = source_index + let vertex_start = p.positions.len(); + p.positions + .extend_from_slice(data.positions.get(vs..ve).ok_or("invalid vertex range")?); + p.normals + .extend_from_slice(data.normals.get(vs..ve).ok_or("invalid normal range")?); + p.uvs + .extend_from_slice(data.uvs.get(vs..ve).ok_or("invalid uv range")?); + p.tangents + .extend_from_slice(data.tangents.get(vs..ve).ok_or("invalid tangent range")?); + let first_index = + u32::try_from(p.indices.len()).map_err(|_| "index start exceeds u32")?; + let is = mesh.geometry.index_start as usize; + let ie = is .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")?, - ); - for instance in occurrences { - let instance_start = u32::try_from(plan.instances.len()) - .map_err(|_| "instance start exceeds u32")?; - let m = &instance.model; - let determinant = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2]) + p.indices + .extend_from_slice(data.indices.get(is..ie).ok_or("invalid index range")?); + for occurrence in occurrences { + let instance_index = + u32::try_from(p.instances.len()).map_err(|_| "instance start exceeds u32")?; + let m = &occurrence.model; + let det = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2]) - m[1][0] * (m[0][1] * m[2][2] - m[2][1] * m[0][2]) + m[2][0] * (m[0][1] * m[1][2] - m[1][1] * m[0][2]); - plan.instances.push(GpuInstance { - model: instance.model, + p.instances.push(GpuInstance { + model: occurrence.model, normal_0: [ - instance.normal[0][0], - instance.normal[0][1], - instance.normal[0][2], - if determinant < 0.0 { -1.0 } else { 1.0 }, + occurrence.normal[0][0], + occurrence.normal[0][1], + occurrence.normal[0][2], + if det < 0. { -1. } else { 1. }, ], normal_1: [ - instance.normal[1][0], - instance.normal[1][1], - instance.normal[1][2], - 0.0, + occurrence.normal[1][0], + occurrence.normal[1][1], + occurrence.normal[1][2], + 0., ], normal_2: [ - instance.normal[2][0], - instance.normal[2][1], - instance.normal[2][2], - 0.0, + occurrence.normal[2][0], + occurrence.normal[2][1], + occurrence.normal[2][2], + 0., ], }); + p.local_aabbs.push(GpuLocalAabb { + min: [ + mesh.local_aabb.min[0], + mesh.local_aabb.min[1], + mesh.local_aabb.min[2], + 0., + ], + max: [ + mesh.local_aabb.max[0], + mesh.local_aabb.max[1], + mesh.local_aabb.max[2], + 0., + ], + }); + p.instance_types.push(occurrence.instance_type.words); let base_vertex = i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?; - let end = index_start - .checked_add(mesh.geometry.index_count) - .ok_or("draw index range overflow")?; - let effective_visible = mesh.flags.contains(RenderFlags::VISIBLE) - && instance.flags.contains(RenderFlags::VISIBLE); - plan.local_aabbs.push(GpuLocalAabb { - min: [mesh.aabb.min[0], mesh.aabb.min[1], mesh.aabb.min[2], 0.], - max: [mesh.aabb.max[0], mesh.aabb.max[1], mesh.aabb.max[2], 0.], - }); - plan.effective_visibility.push(effective_visible as u32); - plan.draw_metadata.push(DrawSlotMetadata { + p.draw_metadata.push(DrawSlotMetadata { index_count: mesh.geometry.index_count, - first_index: index_start, + first_index, base_vertex, - instance_index: instance_start, + instance_index, }); - plan.commands.push(DrawIndexedIndirect { - index_count: mesh.geometry.index_count, - instance_count: 0, - first_index: index_start, - base_vertex, - first_instance: 0, - }); - plan.draws.push(DrawItem { + p.draws.push(DrawItem { pipeline: mesh.pipeline, material: mesh.material, mesh: mesh.handle, - indices: index_start..end, + indices: first_index + ..first_index + .checked_add(mesh.geometry.index_count) + .ok_or("draw range overflow")?, base_vertex, - instances: instance_start..instance_start + 1, - effective_visible, + instances: instance_index..instance_index + 1, + instance_type: occurrence.instance_type, }); } } - Ok(plan) + Ok(p) } } +#[derive(Default)] +pub struct BufferSlot { + pub buffer: Option, + capacity: u64, +} + +#[derive(Default)] +pub struct GpuSceneCache { + revision: Option, + pub buffer_epoch: u64, + pub positions: BufferSlot, + pub normals: BufferSlot, + pub uvs: BufferSlot, + pub tangents: BufferSlot, + pub indices: BufferSlot, + pub instances: BufferSlot, + pub local_aabbs: BufferSlot, + pub instance_types: BufferSlot, + pub draw_metadata: BufferSlot, + pub draws: Vec, +} + pub fn required_buffer_capacity( current: u64, required: u64, @@ -229,92 +210,19 @@ pub fn required_buffer_capacity( if required == 0 || current >= required { return Ok(current); } - let grown = current + Ok(current .checked_mul(2) .ok_or("buffer capacity overflow")? .max(1) - .max(required); - Ok(grown.min(maximum)) + .max(required) + .min(maximum)) } -pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 5] { - const INSTANCE_ATTRIBUTES: [wgpu::VertexAttribute; 7] = wgpu::vertex_attr_array![3 => Float32x4, 4 => Float32x4, 5 => Float32x4, 6 => Float32x4, 7 => Float32x4, 8 => Float32x4, 9 => Float32x4]; - [ - wgpu::VertexBufferLayout { - 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::() as u64, - step_mode: wgpu::VertexStepMode::Instance, - attributes: &INSTANCE_ATTRIBUTES, - }, - wgpu::VertexBufferLayout { - array_stride: 16, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &wgpu::vertex_attr_array![10 => Float32x4], - }, - ] -} - -#[derive(Default)] -pub struct BufferSlot { - pub buffer: Option, - capacity: u64, -} - -#[derive(Default)] -pub struct GpuSceneCache { - revision: Option, - pub positions: BufferSlot, - pub normals: BufferSlot, - pub uvs: BufferSlot, - pub tangents: BufferSlot, - pub indices: BufferSlot, - pub instances: BufferSlot, - pub local_aabbs: BufferSlot, - pub effective_visibility: BufferSlot, - pub draw_metadata: BufferSlot, - pub frustum_flags: BufferSlot, - pub indirect_commands: BufferSlot, - pub draws: Vec, - compute: Option, -} - -struct CullingCompute { - params: wgpu::Buffer, - bind_group: wgpu::BindGroup, - frustum_pipeline: wgpu::ComputePipeline, - query_pipeline: wgpu::ComputePipeline, -} - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct CullingParams { - planes: [[f32; 4]; 6], - count: u32, - visible_predicate: u32, - frustum_predicate: u32, - _pad: u32, -} - -fn predicate_code(value: crate::render_graph::RuntimePredicate) -> u32 { - match value { - crate::render_graph::RuntimePredicate::Any => 0, - crate::render_graph::RuntimePredicate::RequiredTrue => 1, - crate::render_graph::RuntimePredicate::RequiredFalse => 2, - crate::render_graph::RuntimePredicate::Never => 3, +fn logical_or_zero<'a, T: Pod>(values: &'a [T], zero: &'a T) -> &'a [u8] { + if values.is_empty() { + bytemuck::bytes_of(zero) + } else { + bytemuck::cast_slice(values) } } @@ -324,73 +232,40 @@ impl GpuSceneCache { device: &wgpu::Device, queue: &wgpu::Queue, data: &SceneFramePlan, - ) -> Result<(), String> { - self.upload_with_query( - device, - queue, - data, - crate::render_graph::MeshQueryRuntimeKey { - visible: crate::render_graph::RuntimePredicate::RequiredTrue, - frustum_culled: crate::render_graph::RuntimePredicate::Any, - }, - ) - } - - pub fn upload_with_query( - &mut self, - device: &wgpu::Device, - queue: &wgpu::Queue, - data: &SceneFramePlan, - query: crate::render_graph::MeshQueryRuntimeKey, ) -> Result<(), String> { if self.revision == Some(data.revision) { return Ok(()); } - let plan = GpuScenePlan::build_with_query(data, query).map_err(str::to_owned)?; - if plan.draws.is_empty() { - self.draws.clear(); - self.revision = Some(data.revision); - return Ok(()); - } - let maximum = device.limits().max_buffer_size; - fn bytes(values: &[T]) -> Result { - u64::try_from(values.len()) - .map_err(|_| "buffer length overflow".to_owned())? + let p = GpuScenePlan::build(data).map_err(str::to_owned)?; + let max = device.limits().max_buffer_size; + fn bytes(v: &[T]) -> Result { + (v.len() as u64) .checked_mul(size_of::() as u64) - .ok_or_else(|| "buffer byte size overflow".to_owned()) + .ok_or("buffer byte size overflow".into()) } let required = [ - bytes(&plan.positions)?, - bytes(&plan.normals)?, - bytes(&plan.uvs)?, - bytes(&plan.tangents)?, - bytes(&plan.indices)?, - bytes(&plan.instances)?, - bytes(&plan.local_aabbs)?, - bytes(&plan.effective_visibility)?, - bytes(&plan.draw_metadata)?, - bytes(&plan.effective_visibility)?, - bytes(&plan.commands)?, + bytes(&p.positions)?, + bytes(&p.normals)?, + bytes(&p.uvs)?, + bytes(&p.tangents)?, + bytes(&p.indices)?, + bytes(&p.instances)?.max(size_of::() as u64), + bytes(&p.local_aabbs)?.max(size_of::() as u64), + bytes(&p.instance_types)?.max(size_of::<[u32; 16]>() as u64), + bytes(&p.draw_metadata)?.max(size_of::() as u64), ]; - let old = [ - self.positions.capacity, - self.normals.capacity, - self.uvs.capacity, - self.tangents.capacity, - self.indices.capacity, - self.instances.capacity, - self.local_aabbs.capacity, - self.effective_visibility.capacity, - self.draw_metadata.capacity, - self.frustum_flags.capacity, - self.indirect_commands.capacity, + let slots = [ + &mut self.positions, + &mut self.normals, + &mut self.uvs, + &mut self.tangents, + &mut self.indices, + &mut self.instances, + &mut self.local_aabbs, + &mut self.instance_types, + &mut self.draw_metadata, ]; - let mut capacities = [0; 11]; - for i in 0..11 { - capacities[i] = - required_buffer_capacity(old[i], required[i], maximum).map_err(str::to_owned)?; - } - let usages = [ + let usage = [ wgpu::BufferUsages::VERTEX, wgpu::BufferUsages::VERTEX, wgpu::BufferUsages::VERTEX, @@ -400,64 +275,35 @@ impl GpuSceneCache { wgpu::BufferUsages::STORAGE, wgpu::BufferUsages::STORAGE, wgpu::BufferUsages::STORAGE, - wgpu::BufferUsages::STORAGE, - wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDIRECT, ]; - let labels = [ - "scene positions", - "scene normals", - "scene uvs", - "scene tangents", - "scene indices", - "scene instances", - "scene local aabbs", - "scene effective visibility", - "scene draw metadata", - "scene frustum flags", - "scene indirect commands", - ]; - let mut replacements: [Option; 11] = Default::default(); - for i in 0..11 { - if capacities[i] != old[i] { - replacements[i] = Some(device.create_buffer(&wgpu::BufferDescriptor { - label: Some(labels[i]), - size: capacities[i], - usage: usages[i] | wgpu::BufferUsages::COPY_DST, + let mut replaced = false; + for ((slot, &need), use_) in slots.into_iter().zip(&required).zip(usage) { + let cap = required_buffer_capacity(slot.capacity, need, max).map_err(str::to_owned)?; + if cap != slot.capacity { + slot.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor { + label: Some("scene buffer"), + size: cap, + usage: use_ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, })); + slot.capacity = cap; + replaced = true } } - let slots = [ - &mut self.positions, - &mut self.normals, - &mut self.uvs, - &mut self.tangents, - &mut self.indices, - &mut self.instances, - &mut self.local_aabbs, - &mut self.effective_visibility, - &mut self.draw_metadata, - &mut self.frustum_flags, - &mut self.indirect_commands, - ]; - for (i, slot) in slots.into_iter().enumerate() { - if let Some(buffer) = replacements[i].take() { - 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.tangents), - bytemuck::cast_slice(&plan.indices), - bytemuck::cast_slice(&plan.instances), - bytemuck::cast_slice(&plan.local_aabbs), - bytemuck::cast_slice(&plan.effective_visibility), - bytemuck::cast_slice(&plan.draw_metadata), - bytemuck::cast_slice(&plan.effective_visibility), - bytemuck::cast_slice(&plan.commands), + let zero_instance = GpuInstance::zeroed(); + let zero_aabb = GpuLocalAabb::zeroed(); + let zero_type = [0u32; 16]; + let zero_metadata = DrawSlotMetadata::zeroed(); + let contents: [&[u8]; 9] = [ + bytemuck::cast_slice(&p.positions), + bytemuck::cast_slice(&p.normals), + bytemuck::cast_slice(&p.uvs), + bytemuck::cast_slice(&p.tangents), + bytemuck::cast_slice(&p.indices), + logical_or_zero(&p.instances, &zero_instance), + logical_or_zero(&p.local_aabbs, &zero_aabb), + logical_or_zero(&p.instance_types, &zero_type), + logical_or_zero(&p.draw_metadata, &zero_metadata), ]; let slots = [ &self.positions, @@ -467,355 +313,89 @@ impl GpuSceneCache { &self.indices, &self.instances, &self.local_aabbs, - &self.effective_visibility, + &self.instance_types, &self.draw_metadata, - &self.frustum_flags, - &self.indirect_commands, ]; - 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, - ); + for (s, c) in slots.into_iter().zip(contents) { + if !c.is_empty() { + queue.write_buffer(s.buffer.as_ref().unwrap(), 0, c) } } - self.draws = plan.draws; - self.rebuild_compute(device)?; + if replaced { + self.buffer_epoch = self.buffer_epoch.wrapping_add(1).max(1) + } + self.draws = p.draws; self.revision = Some(data.revision); Ok(()) } +} - fn rebuild_compute(&mut self, device: &wgpu::Device) -> Result<(), String> { - if self.draws.is_empty() { - self.compute = None; - return Ok(()); - } - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("scene culling compute"), - source: wgpu::ShaderSource::Wgsl(include_str!("culling.wgsl").into()), - }); - let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("scene culling layout"), - entries: &(0..7) - .map(|binding| wgpu::BindGroupLayoutEntry { - binding, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Buffer { - ty: if binding == 0 { - wgpu::BufferBindingType::Uniform - } else { - wgpu::BufferBindingType::Storage { - read_only: binding < 5, - } - }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }) - .collect::>(), - }); - let params = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("scene culling params"), - size: size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let buffers = [ - &self.instances, - &self.local_aabbs, - &self.effective_visibility, - &self.draw_metadata, - &self.frustum_flags, - &self.indirect_commands, - ]; - let mut entries = vec![wgpu::BindGroupEntry { - binding: 0, - resource: params.as_entire_binding(), - }]; - for (i, slot) in buffers.iter().enumerate() { - entries.push(wgpu::BindGroupEntry { - binding: i as u32 + 1, - resource: slot - .buffer - .as_ref() - .ok_or("culling buffer absent")? - .as_entire_binding(), - }); - } - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("scene culling group"), - layout: &layout, - entries: &entries, - }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("scene culling pipeline layout"), - bind_group_layouts: &[&layout], - push_constant_ranges: &[], - }); - let pipeline = |entry| { - device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some(entry), - layout: Some(&pipeline_layout), - module: &shader, - entry_point: Some(entry), - compilation_options: Default::default(), - cache: None, - }) - }; - self.compute = Some(CullingCompute { - params, - bind_group, - frustum_pipeline: pipeline("frustum_cull"), - query_pipeline: pipeline("mesh_query"), - }); - Ok(()) - } - - pub fn write_culling_params( - &self, - queue: &wgpu::Queue, - planes: Option<[[f32; 4]; 6]>, - query: crate::render_graph::MeshQueryRuntimeKey, - ) { - if let Some(compute) = &self.compute { - if let Some(planes) = planes { - queue.write_buffer(&compute.params, 0, bytemuck::bytes_of(&planes)); - } - let tail = CullingParams { - planes: [[0.0; 4]; 6], - count: self.draws.len() as u32, - visible_predicate: predicate_code(query.visible), - frustum_predicate: predicate_code(query.frustum_culled), - _pad: 0, - }; - queue.write_buffer( - &compute.params, - std::mem::offset_of!(CullingParams, count) as u64, - &bytemuck::bytes_of(&tail)[std::mem::offset_of!(CullingParams, count)..], - ); - } - } - - pub(crate) fn encode_frustum_cull( - &self, - encoder: &mut wgpu::CommandEncoder, - profile: Option<&mut crate::renderer::profiler::ProfileFrame>, - profile_id: &str, - ) { - let Some(c) = &self.compute else { return }; - let timestamps = profile.and_then(|p| p.compute_writes(profile_id)); - let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { - label: Some("frustum_cull"), - timestamp_writes: timestamps, - }); - pass.set_pipeline(&c.frustum_pipeline); - pass.set_bind_group(0, &c.bind_group, &[]); - pass.dispatch_workgroups((self.draws.len() as u32 + 63) / 64, 1, 1); - } - pub(crate) fn encode_mesh_query( - &self, - encoder: &mut wgpu::CommandEncoder, - profile: Option<&mut crate::renderer::profiler::ProfileFrame>, - profile_id: &str, - ) { - let Some(c) = &self.compute else { return }; - let timestamps = profile.and_then(|p| p.compute_writes(profile_id)); - let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { - label: Some("mesh_query"), - timestamp_writes: timestamps, - }); - pass.set_pipeline(&c.query_pipeline); - pass.set_bind_group(0, &c.bind_group, &[]); - pass.dispatch_workgroups((self.draws.len() as u32 + 63) / 64, 1, 1); - } +pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 5] { + const IA: [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: 112, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &IA, + }, + wgpu::VertexBufferLayout { + array_stride: 16, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![10=>Float32x4], + }, + ] } #[cfg(test)] mod tests { use super::*; - #[test] - fn mesh_query_source_guards_optional_flag_buffer_reads() { - let source = include_str!("culling.wgsl"); - let visible_never = source.find("if (params.visible_predicate == 3u)").unwrap(); - let visible_guard = source - .find("else if (params.visible_predicate != 0u)") - .unwrap(); - let visible_load = source.find("matches(authored_visible[i]").unwrap(); - let frustum_never = source - .find("if (selected && params.frustum_predicate == 3u)") - .unwrap(); - let frustum_guard = source - .find("else if (selected && params.frustum_predicate != 0u)") - .unwrap(); - let frustum_load = source.find("matches(frustum_flags[i]").unwrap(); - assert!(visible_never < visible_guard && visible_guard < visible_load); - assert!(frustum_never < frustum_guard && frustum_guard < frustum_load); - assert!( - visible_load < frustum_load, - "visible rejection must precede the frustum load" - ); - assert!( - source.contains("predicate == 0u ||"), - "predicate zero is handled without a load by the guards" - ); - for binding in 0..=6 { - assert!(source.contains(&format!("@binding({binding})"))); - } - } - - #[test] - fn effective_visibility_handles_every_mesh_instance_combination() { - use crate::render_graph::TriStatePredicate::{Any, RequiredFalse, RequiredTrue}; - for (mesh, instance, effective) in [ - (false, false, false), - (false, true, false), - (true, false, false), - (true, true, true), - ] { - let flags = |visible| { - if visible { - RenderFlags::VISIBLE - } else { - RenderFlags::NONE - } - }; - assert!(visibility_matches(Any, flags(mesh), flags(instance))); - assert_eq!( - visibility_matches(RequiredTrue, flags(mesh), flags(instance)), - effective - ); - assert_eq!( - visibility_matches(RequiredFalse, flags(mesh), flags(instance)), - !effective - ); - } - } - use crate::render_data::{ - MeshCreateInfo, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM, - }; - #[test] - fn instance_is_112_bytes_and_padding_is_zero() { + fn abi() { assert_eq!(size_of::(), 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 mirrored_model_stores_negative_determinant_sign_in_padding() { - let mut data = RenderData::new(RenderDataConfig::default()).unwrap(); - let mut mirrored = IDENTITY_MODEL_TRANSFORM; - mirrored[0][0] = -1.0; - data.create_mesh(MeshCreateInfo { - positions: &[[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]], - normals: &[[0., 0., 1.]; 3], - tangents: &[[1., 0., 0., 1.]; 3], - uvs: &[[0., 0.]; 3], - indices: &[0, 1, 2], - pipeline: PipelineKey::new(0), - material: MaterialKey::DEFAULT, - flags: RenderFlags::VISIBLE, - default_instance_flags: RenderFlags::VISIBLE, - default_transform: mirrored, - }) - .unwrap(); - let frame = crate::renderer::scene_frame::SceneFramePlan::build(&data).unwrap(); - let plan = GpuScenePlan::build(&frame).unwrap(); - assert_eq!(plan.instances[0].normal_0[3], -1.0); - } - #[test] - fn capacity_reuses_and_layout_matches_shader_contract() { - assert_eq!(required_buffer_capacity(16, 12, 32), Ok(16)); - assert_eq!(required_buffer_capacity(0, 1, 32), Ok(1)); - let layouts = vertex_layouts(); - assert_eq!( - layouts - .iter() - .map(|layout| layout.array_stride) - .collect::>(), - [12, 12, 8, 112, 16] - ); - assert_eq!( - layouts[3] - .attributes - .iter() - .map(|attribute| attribute.shader_location) - .collect::>(), - vec![3, 4, 5, 6, 7, 8, 9] - ); - } - #[test] - fn plan_is_canonical_and_predicate_independent() { - 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, - tangents: &[[1., 0., 0., 1.]; 3], - uvs: &u, - indices: &i, - pipeline: PipelineKey::new(pipeline), - material: crate::render_data::MaterialKey::DEFAULT, - flags: RenderFlags::VISIBLE, - default_instance_flags: if visible { - RenderFlags::VISIBLE - } 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 frame = crate::renderer::scene_frame::SceneFramePlan::build(&data).unwrap(); - let plan = GpuScenePlan::build(&frame).unwrap(); - assert_eq!( - plan.draws - .iter() - .map(|d| d.pipeline.get()) - .collect::>(), - vec![0, 2, 2, 9] - ); - assert_eq!(plan.draws[0].base_vertex, 0); - assert!(!plan.draws[0].effective_visible); - assert_eq!(plan.draws[1].base_vertex, 3); - assert_eq!(plan.draws[1].instances, 1..2); - assert_eq!(plan.draws[2].instances, 2..3); - assert_eq!(plan.indices, [0, 1, 2, 0, 1, 2, 0, 1, 2]); - assert_eq!(high.mesh, plan.draws[3].mesh); assert_eq!(size_of::(), 32); + assert_eq!(size_of::<[u32; 16]>(), 64); assert_eq!(size_of::(), 16); - assert_eq!(size_of::(), 20); - assert!(plan - .commands - .iter() - .all(|command| command.first_instance == 0)); + assert_eq!(size_of::(), 20) + } + #[test] + fn command_offset() { + let n = 7u64; + assert_eq!((3 * n + 2) * 20, 460) + } + + #[test] + fn empty_traversal_records_have_exact_zero_floors() { + assert_eq!( + logical_or_zero::(&[], &GpuInstance::zeroed()), + [0; 112] + ); + assert_eq!( + logical_or_zero::(&[], &GpuLocalAabb::zeroed()), + [0; 32] + ); + assert_eq!(logical_or_zero::<[u32; 16]>(&[], &[0; 16]), [0; 64]); + assert_eq!( + logical_or_zero::(&[], &DrawSlotMetadata::zeroed()), + [0; 16] + ); + assert_eq!(required_buffer_capacity(0, 112, 1024), Ok(112)); + assert_eq!(required_buffer_capacity(0, 32, 1024), Ok(32)); + assert_eq!(required_buffer_capacity(0, 64, 1024), Ok(64)); + assert_eq!(required_buffer_capacity(0, 16, 1024), Ok(16)); } } diff --git a/renderer/src/renderer/instance_traversal.rs b/renderer/src/renderer/instance_traversal.rs new file mode 100644 index 0000000..b129993 --- /dev/null +++ b/renderer/src/renderer/instance_traversal.rs @@ -0,0 +1,427 @@ +//! Graph-owned instance predicate compute support. + +use crate::render_graph::{ + BooleanBinaryOp, CompareOp, ExpressionOp, InstanceTraversalPlan, SemanticType, TypedLiteral, +}; + +use super::gpu_scene::{DrawIndexedIndirect, GpuSceneCache}; + +fn f(value: f32) -> Result { + if !value.is_finite() { + return Err("non-finite expression literal".into()); + } + Ok(format!("{value:?}")) +} + +/// Generates the dense, single-invocation traversal body. Expressions are emitted in +/// `ExprId` order, so shared IR nodes are evaluated exactly once. +pub fn generate_wgsl(plan: &InstanceTraversalPlan) -> Result { + let mut s = String::from("struct Params { planes: array,6>, instance_count:u32, pipeline_count:u32, _pad:vec2 };\nstruct Inst{model:mat4x4,n0:vec4,n1:vec4,n2:vec4}; struct Aabb{min:vec4,max:vec4}; struct Type16{words:array}; struct Meta{index_count:u32,first_index:u32,base_vertex:i32,instance_index:u32}; struct Cmd{index_count:u32,instance_count:u32,first_index:u32,base_vertex:i32,first_instance:u32};\n@group(0) @binding(0)varp:Params; @group(0) @binding(1)varinstances:array; @group(0) @binding(2)varaabbs:array; @group(0) @binding(3)vartypes:array; @group(0) @binding(4)varmetadata:array; @group(0) @binding(5)varcommands:array;\nstruct LocalAabb{min:vec3,max:vec3}; fn culled(i:u32,a:LocalAabb)->bool{var outside=false;for(var q=0u;q<6u;q++){var all=true;for(var c=0u;c<8u;c++){let v=vec3(select(a.min.x,a.max.x,(c&1u)!=0u),select(a.min.y,a.max.y,(c&2u)!=0u),select(a.min.z,a.max.z,(c&4u)!=0u));all=all&&(dot(p.planes[q],instances[i].model*vec4(v,1.0))<0.0);}outside=outside||all;}return outside;} @compute @workgroup_size(64) fn main(@builtin(global_invocation_id)gid:vec3){let i=gid.x;if(i>=p.instance_count){return;}\n"); + for (i, e) in plan.expressions.expressions.iter().enumerate() { + let x = |id: crate::render_graph::ExprId| format!("e{}", id.0); + let rhs = match &e.op { + ExpressionOp::Literal { literal } => match literal { + TypedLiteral::Bool(v) => v.to_string(), + TypedLiteral::F32(v) => f(*v)?, + TypedLiteral::U32(v) => format!("{v}u"), + TypedLiteral::Vec2(v) => format!("vec2({},{})", f(v[0])?, f(v[1])?), + TypedLiteral::Vec3(v) => { + format!("vec3({},{},{})", f(v[0])?, f(v[1])?, f(v[2])?) + } + TypedLiteral::Vec4(v) => format!( + "vec4({},{},{},{})", + f(v[0])?, + f(v[1])?, + f(v[2])?, + f(v[3])? + ), + TypedLiteral::U32x16(v) => format!( + "Type16(array({}))", + v.iter() + .map(|x| format!("{x}u")) + .collect::>() + .join(",") + ), + TypedLiteral::LocalAabb { min, max } => format!( + "LocalAabb(vec3({},{},{}),vec3({},{},{}))", + f(min[0])?, + f(min[1])?, + f(min[2])?, + f(max[0])?, + f(max[1])?, + f(max[2])? + ), + TypedLiteral::Mat2(v) => matrix_literal("mat2x2", v)?, + TypedLiteral::Mat3(v) => matrix_literal("mat3x3", v)?, + TypedLiteral::Mat4(v) => matrix_literal("mat4x4", v)?, + }, + ExpressionOp::InstanceType { .. } => "types[i]".into(), + ExpressionOp::LocalAabb { .. } => "LocalAabb(aabbs[i].min.xyz,aabbs[i].max.xyz)".into(), + ExpressionOp::Not { value } => format!("!{}", x(*value)), + ExpressionOp::BooleanBinary { + operation, + left, + right, + } => format!( + "({} {} {})", + x(*left), + match operation { + BooleanBinaryOp::And => "&&", + BooleanBinaryOp::Or => "||", + BooleanBinaryOp::Xor => "!=", + BooleanBinaryOp::Xnor => "==", + }, + x(*right) + ), + ExpressionOp::CompareF32 { + operation, + left, + right, + } + | ExpressionOp::CompareU32 { + operation, + left, + right, + } => format!( + "({} {} {})", + x(*left), + match operation { + CompareOp::GreaterThan => ">", + CompareOp::LessThan => "<", + CompareOp::Equals => "==", + }, + x(*right) + ), + ExpressionOp::VectorProject { vector, index } => { + let limit = + vector_width(&plan.expressions.expressions[vector.0 as usize].semantic_type) + .ok_or("vector projection source is not a vector")?; + fixed_index(*index, limit, "vector projection")?; + format!("{}[{}]", x(*vector), index) + } + ExpressionOp::VectorConstruct { components } => format!( + "{}({})", + wgsl_type(&e.semantic_type)?, + components + .iter() + .map(|id| x(*id)) + .collect::>() + .join(",") + ), + ExpressionOp::MatrixColumn { matrix, index } => { + let limit = + matrix_width(&plan.expressions.expressions[matrix.0 as usize].semantic_type) + .ok_or("matrix projection source is not a matrix")?; + fixed_index(*index, limit, "matrix projection")?; + format!("{}[{}]", x(*matrix), index) + } + ExpressionOp::MatrixConstruct { columns } => format!( + "{}({})", + wgsl_type(&e.semantic_type)?, + columns + .iter() + .map(|id| x(*id)) + .collect::>() + .join(",") + ), + ExpressionOp::TypeWord { value, index } => { + fixed_index(*index, 16, "u32x16 projection")?; + format!("{}.words[{}]", x(*value), index) + } + ExpressionOp::TypeConstruct { words } => format!( + "Type16(array({}))", + words.iter().map(|id| x(*id)).collect::>().join(",") + ), + ExpressionOp::U32Bit { value, index } => { + fixed_index(*index, 32, "u32 bit projection")?; + format!("(({} & (1u<<{}u))!=0u)", x(*value), index) + } + ExpressionOp::U32Construct { bits } => bits + .iter() + .enumerate() + .map(|(bit, id)| format!("select(0u,{}u,{})", 1u32 << bit, x(*id))) + .collect::>() + .join("|") + .pipe(|terms| format!("({terms})")), + ExpressionOp::AabbMin { aabb } => format!("{}.min", x(*aabb)), + ExpressionOp::AabbMax { aabb } => format!("{}.max", x(*aabb)), + ExpressionOp::FrustumCulled { local_aabb, .. } => { + format!("culled(i,{})", x(*local_aabb)) + } + }; + s.push_str(&format!("let e{i}={rhs};\n")); + } + for entry in &plan.pipelines { + s.push_str(&format!("{{let m=metadata[i];commands[{}u*p.instance_count+i]=Cmd(m.index_count,select(0u,1u,e{}),m.first_index,m.base_vertex,m.instance_index);}}\n",entry.ordinal,entry.predicate.0)); + } + s.push('}'); + if s.len() >= 1024 * 1024 { + return Err("generated traversal WGSL exceeds 1 MiB".into()); + } + Ok(s) +} + +fn fixed_index(index: u8, limit: u8, kind: &str) -> Result<(), String> { + (index < limit) + .then_some(()) + .ok_or_else(|| format!("invalid fixed {kind} index")) +} +fn vector_width(ty: &SemanticType) -> Option { + match ty { + SemanticType::Vec2 => Some(2), + SemanticType::Vec3 => Some(3), + SemanticType::Vec4 => Some(4), + _ => None, + } +} +fn matrix_width(ty: &SemanticType) -> Option { + match ty { + SemanticType::Mat2 => Some(2), + SemanticType::Mat3 => Some(3), + SemanticType::Mat4 => Some(4), + _ => None, + } +} +fn wgsl_type(ty: &SemanticType) -> Result<&'static str, String> { + match ty { + SemanticType::Vec2 => Ok("vec2"), + SemanticType::Vec3 => Ok("vec3"), + SemanticType::Vec4 => Ok("vec4"), + SemanticType::Mat2 => Ok("mat2x2"), + SemanticType::Mat3 => Ok("mat3x3"), + SemanticType::Mat4 => Ok("mat4x4"), + _ => Err("invalid combine result type".into()), + } +} +fn matrix_literal(name: &str, columns: &[[f32; N]; N]) -> Result { + let values = columns + .iter() + .flatten() + .map(|v| f(*v)) + .collect::, _>>()?; + Ok(format!("{name}({})", values.join(","))) +} +trait Pipe: Sized { + fn pipe(self, f: impl FnOnce(Self) -> R) -> R { + f(self) + } +} +impl Pipe for T {} + +pub fn dispatch_count(instances: u32, pipelines: u32) -> u32 { + if pipelines == 0 { + 0 + } else { + instances.max(1).div_ceil(64) + } +} + +pub fn command_offset(predicate_ordinal: u32, instance_count: usize, draw_index: usize) -> u64 { + (u64::from(predicate_ordinal) * instance_count as u64 + draw_index as u64) + * std::mem::size_of::() as u64 +} + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct Params { + planes: [[f32; 4]; 6], + instance_count: u32, + pipeline_count: u32, + pad: [u32; 2], +} + +pub struct TraversalGpu { + graph: crate::render_graph::CompiledGraphId, + plan: InstanceTraversalPlan, + scene_epoch: u64, + draw_count: usize, + pipeline: wgpu::ComputePipeline, + params: wgpu::Buffer, + bind_group: wgpu::BindGroup, + pub commands: wgpu::Buffer, +} + +impl TraversalGpu { + pub fn matches( + &self, + graph: crate::render_graph::CompiledGraphId, + plan: &InstanceTraversalPlan, + scene_epoch: u64, + draw_count: usize, + ) -> bool { + self.graph == graph + && self.plan == *plan + && self.scene_epoch == scene_epoch + && self.draw_count == draw_count + } + pub fn create( + device: &wgpu::Device, + graph: crate::render_graph::CompiledGraphId, + plan: &InstanceTraversalPlan, + gpu: &GpuSceneCache, + ) -> Result { + let source = generate_wgsl(plan)?; + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("instance traversal"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let entries = [ + (0, wgpu::BufferBindingType::Uniform), + (1, wgpu::BufferBindingType::Storage { read_only: true }), + (2, wgpu::BufferBindingType::Storage { read_only: true }), + (3, wgpu::BufferBindingType::Storage { read_only: true }), + (4, wgpu::BufferBindingType::Storage { read_only: true }), + (5, wgpu::BufferBindingType::Storage { read_only: false }), + ] + .map(|(binding, ty)| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("instance traversal"), + entries: &entries, + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("instance traversal"), + bind_group_layouts: &[&layout], + push_constant_ranges: &[], + }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("instance traversal"), + layout: Some(&pipeline_layout), + module: &module, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + let params = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("instance traversal params"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let count = (gpu.draws.len() as u64) + .checked_mul(plan.pipelines.len() as u64) + .and_then(|n| n.checked_mul(std::mem::size_of::() as u64)) + .ok_or("indirect command size overflow")? + .max(std::mem::size_of::() as u64); + if count > device.limits().max_buffer_size { + return Err("indirect command buffer exceeds device limit".into()); + } + let commands = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("instance traversal commands"), + size: count, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDIRECT, + mapped_at_creation: false, + }); + fn required(slot: &super::gpu_scene::BufferSlot) -> Result<&wgpu::Buffer, String> { + slot.buffer + .as_ref() + .ok_or_else(|| "instance traversal scene buffer missing".to_owned()) + } + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("instance traversal"), + layout: &layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: params.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: required(&gpu.instances)?.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: required(&gpu.local_aabbs)?.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: required(&gpu.instance_types)?.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: required(&gpu.draw_metadata)?.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: commands.as_entire_binding(), + }, + ], + }); + Ok(Self { + graph, + plan: plan.clone(), + scene_epoch: gpu.buffer_epoch, + draw_count: gpu.draws.len(), + pipeline, + params, + bind_group, + commands, + }) + } + pub(crate) fn encode( + &self, + encoder: &mut wgpu::CommandEncoder, + queue: &wgpu::Queue, + planes: Option<[[f32; 4]; 6]>, + instances: u32, + mut profile: Option<&mut super::profiler::ProfileFrame>, + ) { + queue.write_buffer( + &self.params, + 0, + bytemuck::bytes_of(&Params { + planes: planes.unwrap_or([[0.; 4]; 6]), + instance_count: instances, + pipeline_count: self.plan.pipelines.len() as u32, + pad: [0; 2], + }), + ); + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("instance traversal"), + timestamp_writes: profile + .as_deref_mut() + .and_then(|p| p.compute_writes("instance_traversal")), + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.dispatch_workgroups( + dispatch_count(instances, self.plan.pipelines.len() as u32), + 1, + 1, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pipeline_major_offsets_and_single_dispatch_are_deterministic() { + assert_eq!(command_offset(0, 7, 6), 120); + assert_eq!(command_offset(1, 7, 0), 140); + assert_eq!(command_offset(3, 7, 2), 460); + assert_eq!(dispatch_count(1, 4), 1); + assert_eq!(dispatch_count(64, 4), 1); + assert_eq!(dispatch_count(65, 4), 2); + assert_eq!(dispatch_count(0, 4), 1); + } + + #[test] + fn lowering_helpers_cover_matrices_and_reject_dynamic_indexes() { + assert_eq!( + matrix_literal("mat2x2", &[[1.0, 2.0], [3.0, 4.0]]).unwrap(), + "mat2x2(1.0,2.0,3.0,4.0)" + ); + assert!(fixed_index(3, 3, "vector projection").is_err()); + assert_eq!(wgsl_type(&SemanticType::Mat4).unwrap(), "mat4x4"); + } +} diff --git a/renderer/src/renderer/mod.rs b/renderer/src/renderer/mod.rs index 50a54bd..f70deb3 100644 --- a/renderer/src/renderer/mod.rs +++ b/renderer/src/renderer/mod.rs @@ -11,12 +11,13 @@ use crate::{ command_ring::CommandRing, gltf::{install_imported, ModelBounds}, message::{camera_drag, CameraDrag, DrainEventError, MouseMessage, ResizeMessage, WindowEvent}, - render_data::{InstanceHandle, MeshHandle, RenderData, RenderDataConfig, RenderFlags}, + render_data::{InstanceHandle, InstanceType, MeshHandle, RenderData, RenderDataConfig}, renderer::scene::Scene, }; pub mod executors; pub mod gpu_scene; +pub mod instance_traversal; pub mod material; pub mod pipeline_library; pub mod profiler; @@ -27,6 +28,43 @@ pub use pipeline_library::PipelineLibrary; const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DeviceFeaturePlan { + hard: wgpu::Features, + initial: wgpu::Features, + profiling_enabled: bool, +} + +fn device_feature_plan(profile: bool, supported: wgpu::Features) -> DeviceFeaturePlan { + let hard = wgpu::Features::INDIRECT_FIRST_INSTANCE; + let profiling = profiler::Profiler::requested_features(profile, supported); + DeviceFeaturePlan { + hard, + initial: hard | profiling, + profiling_enabled: !profiling.is_empty(), + } +} + +#[cfg(test)] +mod device_feature_tests { + use super::*; + + #[test] + fn retry_plan_never_drops_hard_features() { + let hard_only = device_feature_plan(false, wgpu::Features::INDIRECT_FIRST_INSTANCE); + assert_eq!(hard_only.initial, hard_only.hard); + assert!(!hard_only.profiling_enabled); + + let profiled = device_feature_plan( + true, + wgpu::Features::INDIRECT_FIRST_INSTANCE | wgpu::Features::TIMESTAMP_QUERY, + ); + assert!(profiled.initial.contains(profiled.hard)); + assert!(profiled.initial.contains(wgpu::Features::TIMESTAMP_QUERY)); + assert!(profiled.profiling_enabled); + } +} + #[repr(C, align(16))] #[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] struct FullscreenUniforms { @@ -748,12 +786,10 @@ struct GpuTextureSlot { } enum PreparedExecution { - FrustumCull, - MeshQuery, - PipelineRegistry, Pipeline { execution: usize, base: crate::render_data::PipelineKey, + predicate_ordinal: u32, variant: wgpu::RenderPipeline, }, Fullscreen { @@ -777,7 +813,7 @@ struct ActiveCompiledGraph { #[derive(Clone, Copy)] enum UploadGraph { Immediate, - Compiled(crate::render_graph::MeshQueryRuntimeKey), + Compiled(bool), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -832,27 +868,27 @@ fn acquisition_action(source: FrameTargetSource, error: &wgpu::SurfaceError) -> } fn classify_upload_graph(graph: &ActiveCompiledGraph) -> UploadGraph { - UploadGraph::Compiled(graph.runtime.allocations.query) + UploadGraph::Compiled( + graph + .runtime + .instance_traversal + .as_ref() + .is_some_and(|p| p.requires_camera), + ) } -fn upload_query_for_render( - pending: Option, - active: Option, -) -> Option { +fn upload_query_for_render(pending: Option, active: Option) -> bool { match pending.or(active) { - Some(UploadGraph::Compiled(query)) => Some(query), - Some(UploadGraph::Immediate) | None => None, + Some(UploadGraph::Compiled(value)) => value, + Some(UploadGraph::Immediate) | None => false, } } fn resolve_culling_frustum( - query: crate::render_graph::MeshQueryRuntimeKey, + required: bool, read: impl FnOnce() -> Option>, ) -> Result, crate::render_graph::GraphError> { - if matches!( - query.frustum_culled, - crate::render_graph::RuntimePredicate::Any | crate::render_graph::RuntimePredicate::Never - ) { + if !required { return Ok(None); } match read() { @@ -871,13 +907,10 @@ fn resolve_culling_frustum( fn update_validate_write_scene( scene: &mut S, queue: &wgpu::Queue, - query: Option, + requires_camera: bool, ) -> Result, crate::render_graph::GraphError> { scene.update_cpu(); - let planes = match query { - Some(query) => resolve_culling_frustum(query, || scene.frustum_planes())?, - None => None, - }; + let planes = resolve_culling_frustum(requires_camera, || scene.frustum_planes())?; scene.write_uniforms(queue); Ok(planes) } @@ -935,7 +968,7 @@ fn resolve_switch_request( 1 => { let id = crate::render_graph::CompiledGraphId { slot, generation }; // Resolve the registry entry here, before any GPU preparation or pending - // state mutation. Registry::get is also the Phase 4 activation gate. + // state mutation. Registry::get is also the compiled-graph availability gate. registry.get(id)?; Ok(ResolvedSwitchRequest::Compiled(id)) } @@ -980,42 +1013,29 @@ mod switch_request_tests { use super::*; - fn query(visible: crate::render_graph::RuntimePredicate) -> UploadGraph { - UploadGraph::Compiled(crate::render_graph::MeshQueryRuntimeKey { - visible, - frustum_culled: crate::render_graph::RuntimePredicate::Any, - }) + fn graph(requires_camera: bool) -> UploadGraph { + UploadGraph::Compiled(requires_camera) } #[test] fn upload_selection_follows_the_graph_rendered_for_the_commit_frame() { - use crate::render_graph::RuntimePredicate::{Any, RequiredFalse, RequiredTrue}; - let selected = - |pending, active| upload_query_for_render(pending, active).map(|query| query.visible); + let selected = upload_query_for_render; + assert!(!selected(Some(graph(false)), Some(graph(true)))); assert_eq!( - selected(Some(query(RequiredFalse)), Some(query(RequiredTrue))), - Some(RequiredFalse) + selected(Some(UploadGraph::Immediate), Some(graph(true))), + false ); assert_eq!( - selected(Some(UploadGraph::Immediate), Some(query(RequiredTrue))), - None + selected(Some(UploadGraph::Immediate), Some(graph(true))), + false ); - assert_eq!( - selected(Some(UploadGraph::Immediate), Some(query(RequiredTrue))), - None - ); - assert_eq!( - selected(None, Some(query(RequiredTrue))), - Some(RequiredTrue) - ); - assert_eq!(selected(None, Some(UploadGraph::Immediate)), None); - assert_eq!(selected(None, None), None); - assert_eq!(selected(Some(query(Any)), None), Some(Any)); + assert!(selected(None, Some(graph(true)))); + assert!(!selected(None, Some(UploadGraph::Immediate))); + assert!(!selected(None, None)); } #[test] fn frame_target_precedence_and_pending_resize_upload_are_exact() { - use crate::render_graph::RuntimePredicate::{RequiredFalse, RequiredTrue}; assert_eq!( select_frame_target_source(true, true, true), FrameTargetSource::PendingSwitch @@ -1032,14 +1052,10 @@ mod switch_request_tests { select_frame_target_source(false, false, false), FrameTargetSource::Immediate ); - let pending_resize = query(RequiredFalse); - let active = query(RequiredTrue); - assert_eq!( - upload_query_for_render(Some(pending_resize), Some(active)) - .unwrap() - .visible, - RequiredFalse - ); + assert!(!upload_query_for_render( + Some(graph(false)), + Some(graph(true)) + )); } #[test] @@ -1063,15 +1079,10 @@ mod switch_request_tests { } #[test] - fn frustum_preflight_skips_any_and_distinguishes_missing_from_invalid() { - use crate::render_graph::RuntimePredicate::{Any, RequiredFalse, RequiredTrue}; - let query = |frustum_culled| crate::render_graph::MeshQueryRuntimeKey { - visible: RequiredTrue, - frustum_culled, - }; + fn frustum_preflight_uses_boolean_traversal_requirement() { let mut reads = 0; assert_eq!( - resolve_culling_frustum(query(Any), || { + resolve_culling_frustum(false, || { reads += 1; None }) @@ -1082,9 +1093,9 @@ mod switch_request_tests { reads, 0, "inactive frustum filtering must not read the camera" ); - let missing = resolve_culling_frustum(query(RequiredFalse), || None).unwrap_err(); + let missing = resolve_culling_frustum(true, || None).unwrap_err(); assert!(missing.message.contains("no camera")); - let invalid = resolve_culling_frustum(query(RequiredFalse), || { + let invalid = resolve_culling_frustum(true, || { Some(Err(crate::camera::FrustumError::Degenerate { plane: 2 })) }) .unwrap_err(); @@ -1265,10 +1276,11 @@ pub struct Renderer { snapshot_init_sent: bool, scene_frame: scene_frame::SceneFrameCache, gpu_scene: gpu_scene::GpuSceneCache, + instance_traversal: Option, materials: material::MaterialResources, pub(crate) command_ring: Option<&'static CommandRing>, pending_replies: Vec, - gpu_error: std::sync::Arc, + gpu_error: std::sync::Arc>>, framing_radius: f32, graph_registry: crate::render_graph::Registry, active_compiled: Option, @@ -1471,19 +1483,39 @@ impl Renderer { let result = js_sys::Object::new(); let meshes = js_sys::Array::new(); for h in installed.meshes { - meshes.push(&js_sys::Array::of2( - &h.slot().into(), - &h.generation().into(), - )); + let item = js_sys::Object::new(); + js_sys::Reflect::set( + &item, + &"handle".into(), + &js_sys::Array::of2(&h.slot().into(), &h.generation().into()), + ) + .unwrap(); + let ty = self + .render_data + .mesh(h) + .unwrap() + .default_instance_type + .words; + js_sys::Reflect::set( + &item, + &"defaultType".into(), + &js_sys::Array::from_iter(ty.into_iter().map(JsValue::from)), + ) + .unwrap(); + meshes.push(&item); } js_sys::Reflect::set(&result, &"meshes".into(), &meshes).unwrap(); Ok(result.into()) } 2 => { self.render_data - .set_mesh_flags( + .set_mesh_visible( MeshHandle::from_parts(words[2], words[3]), - RenderFlags::from_bits_retain(words[4]), + match words[4] { + 0 => false, + 1 => true, + _ => return Err("INVALID_VISIBILITY"), + }, ) .map_err(|e| render_data_error_code(&e))?; Ok(JsValue::UNDEFINED) @@ -1496,15 +1528,25 @@ impl Renderer { } let h = self .render_data - .create_instance(mesh, m, RenderFlags::from_bits_retain(words[20])) + .create_instance( + mesh, + m, + InstanceType { + words: std::array::from_fn(|i| words[20 + i]), + }, + ) .map_err(|e| render_data_error_code(&e))?; Ok(js_sys::Array::of2(&h.slot().into(), &h.generation().into()).into()) } 4 => { self.render_data - .set_instance_flags( + .set_instance_visible( InstanceHandle::from_parts(words[2], words[3]), - RenderFlags::from_bits_retain(words[4]), + match words[4] { + 0 => false, + 1 => true, + _ => return Err("INVALID_VISIBILITY"), + }, ) .map_err(|e| render_data_error_code(&e))?; Ok(JsValue::UNDEFINED) @@ -1526,6 +1568,17 @@ impl Renderer { .map_err(|e| render_data_error_code(&e))?; Ok(JsValue::UNDEFINED) } + 10 => { + self.render_data + .set_instance_type( + InstanceHandle::from_parts(words[2], words[3]), + InstanceType { + words: std::array::from_fn(|i| words[4 + i]), + }, + ) + .map_err(|e| render_data_error_code(&e))?; + Ok(JsValue::UNDEFINED) + } _ => Err("UNKNOWN_OPCODE"), })(); match outcome { @@ -1765,8 +1818,7 @@ impl Renderer { let contract = crate::render_graph::contract(&execution.executor.key) .ok_or_else(|| fail("executor contract missing"))?; match execution.executor.key.as_str() { - "frustum_cull" => executions.push(PreparedExecution::FrustumCull), - "mesh_query" => executions.push(PreparedExecution::MeshQuery), + "frustum_cull" => continue, _ if execution.executor.key == "frame_out" || contract.fullscreen_policy.is_some() => { @@ -1924,7 +1976,6 @@ impl Renderer { _uniform: uniform, }); } - "pipeline_registry" => executions.push(PreparedExecution::PipelineRegistry), "pipeline" => { let ExecutionKind::Render { color_attachments, @@ -2009,6 +2060,14 @@ impl Renderer { executions.push(PreparedExecution::Pipeline { execution: index, base, + predicate_ordinal: runtime + .instance_traversal + .as_ref() + .and_then(|p| { + p.pipelines.iter().find(|v| v.execution as usize == index) + }) + .map(|p| p.ordinal) + .ok_or_else(|| fail("pipeline predicate missing"))?, variant, }); } @@ -2049,7 +2108,13 @@ impl Renderer { // Candidate construction allocates GPU resources, so the live scene preflight // belongs here: this is the earliest boundary with both the runtime query and // scene access, and precedes GPU work and all pending/in-flight mutation. - resolve_culling_frustum(runtime.allocations.query, || self.scene.frustum_planes())?; + resolve_culling_frustum( + runtime + .instance_traversal + .as_ref() + .is_some_and(|p| p.requires_camera), + || self.scene.frustum_planes(), + )?; let restart_graph = graph.clone(); self.next_preparation_token = self.next_preparation_token.wrapping_add(1).max(1); let token = self.next_preparation_token; @@ -2153,9 +2218,13 @@ impl Renderer { .await .unwrap(); - let optional_features = profiler::Profiler::requested_features(profile, adapter.features()); + let feature_plan = device_feature_plan(profile, adapter.features()); + assert!( + adapter.features().contains(feature_plan.hard), + "WebGPU adapter lacks required indirect-first-instance support" + ); let descriptor = wgpu::DeviceDescriptor { - required_features: optional_features, + required_features: feature_plan.initial, required_limits: wgpu::Limits::default(), label: None, memory_hints: wgpu::MemoryHints::default(), @@ -2164,7 +2233,7 @@ impl Renderer { let (device, queue) = match adapter.request_device(&descriptor).await { Ok(result) => result, - Err(error) if !optional_features.is_empty() => { + Err(error) if feature_plan.profiling_enabled => { log::warn!("timestamp-enabled device request failed, retrying baseline: {error}"); adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { @@ -2174,8 +2243,12 @@ impl Renderer { }) .await .expect("surface-compatible adapter required for baseline device"); + assert!( + adapter.features().contains(feature_plan.hard), + "reacquired WebGPU adapter lacks required indirect-first-instance support" + ); let baseline = wgpu::DeviceDescriptor { - required_features: wgpu::Features::empty(), + required_features: feature_plan.hard, required_limits: wgpu::Limits::default(), label: None, memory_hints: wgpu::MemoryHints::default(), @@ -2185,15 +2258,23 @@ impl Renderer { } Err(error) => panic!("baseline WebGPU device request failed: {error}"), }; + assert!( + device.features().contains(feature_plan.hard), + "WebGPU device lacks required indirect-first-instance support" + ); info!("Adapter info: {:?}", adapter.get_info()); info!("Adapter features: {:?}", adapter.features()); info!("Adapter limits: {:?}", adapter.limits()); let profiler = profiler::Profiler::new(profile, &device, &queue).await; - let gpu_error = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let gpu_error = std::sync::Arc::new(std::sync::Mutex::new(None)); let error_flag = gpu_error.clone(); device.on_uncaptured_error(Box::new(move |error| { - error_flag.store(true, std::sync::atomic::Ordering::Relaxed); - log::error!("Uncaptured GPU error: {error}"); + let message = error.to_string(); + let mut first = error_flag.lock().unwrap(); + if first.is_none() { + *first = Some(message.clone()); + } + log::error!("Uncaptured GPU error: {message}"); })); let surface_caps = surface.get_capabilities(&adapter); @@ -2244,6 +2325,7 @@ impl Renderer { snapshot_init_sent: false, scene_frame: Default::default(), gpu_scene: Default::default(), + instance_traversal: None, materials, command_ring: None, pending_replies: Vec::new(), @@ -2266,12 +2348,10 @@ impl Renderer { return; } self.drain_preparation_completions(); - if self - .gpu_error - .swap(false, std::sync::atomic::Ordering::AcqRel) - { + let gpu_error = self.gpu_error.lock().unwrap().take(); + if let Some(error) = gpu_error { self.halted = true; - self.post_fatal("GPU_VALIDATION_FAILED", "uncaptured WebGPU error"); + self.post_fatal("GPU_VALIDATION_FAILED", &error); return; } if !self.drain_commands() { @@ -2295,8 +2375,16 @@ impl Renderer { &"controlPtr".into(), &self.snapshot.control_ptr().into(), ); - let _ = js_sys::Reflect::set(&message, &"controlVersion".into(), &1.into()); - let _ = js_sys::Reflect::set(&message, &"schemaVersion".into(), &1.into()); + let _ = js_sys::Reflect::set( + &message, + &"controlVersion".into(), + &crate::shared_snapshot::CONTROL_VERSION.into(), + ); + let _ = js_sys::Reflect::set( + &message, + &"schemaVersion".into(), + &crate::shared_snapshot::SCHEMA.into(), + ); let _ = global.post_message(&message); self.snapshot_init_sent = true; } @@ -2351,26 +2439,14 @@ impl Renderer { return; } }; - let upload = if let Some(query) = query { - self.gpu_scene.upload_with_query( - &self.context.device, - &self.context.queue, - frame_plan, - query, - ) - } else { - self.gpu_scene - .upload(&self.context.device, &self.context.queue, frame_plan) - }; + let upload = self + .gpu_scene + .upload(&self.context.device, &self.context.queue, frame_plan); if let Err(error) = upload { log::error!("GPU scene upload failed: {error}"); self.post_fatal("GPU_UPLOAD_FAILED", &error); return; } - if let Some(query) = query { - self.gpu_scene - .write_culling_params(&self.context.queue, planes, query); - } // Candidate publication is transactional: configure its complete contract at // the last possible point before acquisition, but retain the known-good @@ -2463,16 +2539,53 @@ impl Renderer { ), }); let encode_result = if let Some(active) = rendering_compiled { - executors::encode_compiled( - &mut encoder, - &texture_view, - active, - &self.scene, - &self.gpu_scene, - &self.resources, - &self.materials, - profile_frame.as_mut(), - ) + (|| -> Result<(), &'static str> { + let plan = active + .runtime + .instance_traversal + .as_ref() + .ok_or("compiled graph instance traversal missing")?; + let rebuild = self.instance_traversal.as_ref().is_none_or(|traversal| { + !traversal.matches( + active.id, + plan, + self.gpu_scene.buffer_epoch, + self.gpu_scene.draws.len(), + ) + }); + if rebuild { + self.instance_traversal = Some( + instance_traversal::TraversalGpu::create( + &self.context.device, + active.id, + plan, + &self.gpu_scene, + ) + .map_err(|error| { + log::error!("instance traversal preparation failed: {error}"); + "instance traversal preparation failed" + })?, + ); + } + self.instance_traversal.as_ref().unwrap().encode( + &mut encoder, + &self.context.queue, + planes, + self.gpu_scene.draws.len() as u32, + profile_frame.as_mut(), + ); + executors::encode_compiled( + &mut encoder, + &texture_view, + active, + &self.scene, + &self.gpu_scene, + &self.resources, + &self.materials, + &self.instance_traversal.as_ref().unwrap().commands, + profile_frame.as_mut(), + ) + })() } else { executors::encode_immediate( &mut encoder, @@ -2620,12 +2733,7 @@ impl Renderer { .unwrap_or(0) .into(), ), - ( - "gpuError", - self.gpu_error - .load(std::sync::atomic::Ordering::Relaxed) - .into(), - ), + ("gpuError", self.gpu_error.lock().unwrap().is_some().into()), ] { let _ = js_sys::Reflect::set(&telemetry, &key.into(), &value); } diff --git a/renderer/src/renderer/pipeline_library.rs b/renderer/src/renderer/pipeline_library.rs index 2955c4f..056c358 100644 --- a/renderer/src/renderer/pipeline_library.rs +++ b/renderer/src/renderer/pipeline_library.rs @@ -120,7 +120,7 @@ pub struct PipelineLibrary { default_layout: Option, material_layout: Option, next_layout: u64, - pipeline_registry: HashMap, + named_bases: HashMap, descriptor_cache: HashMap, } @@ -134,7 +134,7 @@ impl PipelineLibrary { default_layout: None, material_layout: None, next_layout: 0, - pipeline_registry: HashMap::new(), + named_bases: HashMap::new(), descriptor_cache: HashMap::new(), } } @@ -328,7 +328,7 @@ impl PipelineLibrary { ) -> Result { let spec = self.compatibility_spec(name, layouts, shader, format); let descriptor = spec.key(); - if let Some((_, existing)) = self.pipeline_registry.get(name) { + if let Some((_, existing)) = self.named_bases.get(name) { return Err(if existing == &descriptor { format!("Pipeline '{name}' already exists") } else { @@ -336,13 +336,12 @@ impl PipelineLibrary { }); } let key = self.get_or_create_from_spec(device, &spec, Some(name)); - self.pipeline_registry - .insert(name.to_owned(), (key, descriptor)); + self.named_bases.insert(name.to_owned(), (key, descriptor)); Ok(key) } pub fn find_pipeline(&self, name: &str) -> Option { - self.pipeline_registry.get(name).map(|v| v.0) + self.named_bases.get(name).map(|v| v.0) } pub fn get_or_create_pipeline( &mut self, @@ -353,7 +352,7 @@ impl PipelineLibrary { format: wgpu::TextureFormat, ) -> PipelineKey { let wanted = self.compatibility_spec(name, layouts, shader, format).key(); - if let Some((key, existing)) = self.pipeline_registry.get(name) { + if let Some((key, existing)) = self.named_bases.get(name) { assert_eq!( existing, &wanted, "Pipeline '{name}' requested with a different descriptor" diff --git a/renderer/src/renderer/scene_frame.rs b/renderer/src/renderer/scene_frame.rs index a6c1c78..6db9d0a 100644 --- a/renderer/src/renderer/scene_frame.rs +++ b/renderer/src/renderer/scene_frame.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use thiserror::Error; use crate::render_data::{ - affine_world_aabb, Aabb, GeometryRange, InstanceHandle, MaterialKey, MeshHandle, - ModelTransform, NormalMatrix, PipelineKey, RenderData, RenderFlags, + affine_world_aabb, Aabb, GeometryRange, InstanceHandle, InstanceType, MaterialKey, MeshHandle, + ModelTransform, NormalMatrix, PipelineKey, RenderData, }; #[derive(Clone, Debug)] @@ -13,8 +13,8 @@ pub struct SceneFrameMesh { pub geometry: GeometryRange, pub pipeline: PipelineKey, pub material: MaterialKey, - pub flags: RenderFlags, - pub aabb: Aabb, + pub instance_type: InstanceType, + pub local_aabb: Aabb, pub default_instance: InstanceHandle, pub occurrence_range: std::ops::Range, } @@ -26,7 +26,7 @@ pub struct SceneFrameOccurrence { pub mesh_index: usize, pub model: ModelTransform, pub normal: NormalMatrix, - pub flags: RenderFlags, + pub instance_type: InstanceType, pub is_default: bool, pub world_aabb: Aabb, } @@ -84,9 +84,9 @@ impl SceneFramePlan { mesh_index, model: occurrence.model, normal: occurrence.normal, - flags: occurrence.flags, + instance_type: occurrence.instance_type, is_default: handle == mesh.default_instance, - world_aabb: affine_world_aabb(mesh.aabb, occurrence.model) + world_aabb: affine_world_aabb(mesh.local_aabb, occurrence.model) .map_err(|_| SceneFrameError::InvalidWorldBounds)?, }); } @@ -117,8 +117,8 @@ impl SceneFramePlan { geometry: mesh.geometry, pipeline: mesh.pipeline, material: mesh.material, - flags: mesh.flags, - aabb: mesh.aabb, + instance_type: mesh.default_instance_type, + local_aabb: mesh.local_aabb, default_instance: mesh.default_instance, occurrence_range: offsets[dense]..offsets[dense + 1], }) @@ -170,12 +170,11 @@ mod tests { indices: &[0, 1, 2], pipeline: PipelineKey::new(0), material: crate::render_data::MaterialKey::DEFAULT, - flags: if visible { - RenderFlags::VISIBLE + default_instance_type: if visible { + InstanceType::VISIBLE } else { - RenderFlags::NONE + InstanceType::ZERO }, - default_instance_flags: RenderFlags::VISIBLE, default_transform: IDENTITY_MODEL_TRANSFORM, }) .unwrap() @@ -190,8 +189,7 @@ mod tests { let created = mesh(&mut data, true); let second = cache.get_or_build(&data).unwrap() as *const _; assert_ne!(first, second); - data.set_mesh_flags(created.mesh, RenderFlags::NONE) - .unwrap(); + data.set_mesh_visible(created.mesh, false).unwrap(); let third = cache.get_or_build(&data).unwrap() as *const _; assert_ne!(second, third); let mut moved = IDENTITY_MODEL_TRANSFORM; @@ -213,7 +211,7 @@ mod tests { translated[3][0] = 5.; translated[3][1] = -2.; let extra = data - .create_instance(hidden.mesh, translated, RenderFlags::NONE) + .create_instance(hidden.mesh, translated, InstanceType::ZERO) .unwrap(); let plan = SceneFramePlan::build(&data).unwrap(); assert_eq!((plan.meshes.len(), plan.occurrences.len()), (2, 3)); @@ -247,13 +245,13 @@ mod tests { let doomed = mesh(&mut data, true); let c = mesh(&mut data, true); let a_extra = data - .create_instance(a.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE) + .create_instance(a.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::VISIBLE) .unwrap(); let doomed_extra = data - .create_instance(doomed.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE) + .create_instance(doomed.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::VISIBLE) .unwrap(); let c_extra = data - .create_instance(c.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE) + .create_instance(c.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::VISIBLE) .unwrap(); data.destroy_instance(a_extra).unwrap(); data.destroy_mesh(doomed.mesh).unwrap(); @@ -262,7 +260,7 @@ mod tests { .create_instance( replacement.mesh, IDENTITY_MODEL_TRANSFORM, - RenderFlags::VISIBLE, + InstanceType::VISIBLE, ) .unwrap(); assert_eq!(replacement.mesh.slot(), doomed.mesh.slot()); diff --git a/renderer/src/shared_snapshot.rs b/renderer/src/shared_snapshot.rs index a87eb1c..0505c0a 100644 --- a/renderer/src/shared_snapshot.rs +++ b/renderer/src/shared_snapshot.rs @@ -1,12 +1,12 @@ //! Triple-buffered, immutable packed scene snapshot shared with JavaScript. use std::sync::atomic::{AtomicU32, Ordering}; -use crate::{render_data::RenderFlags, renderer::scene_frame::SceneFramePlan}; +use crate::renderer::scene_frame::SceneFramePlan; pub const MAGIC: u32 = u32::from_le_bytes(*b"YSNP"); -pub const BLOB_MAGIC: u32 = u32::from_le_bytes(*b"RDS1"); +pub const BLOB_MAGIC: u32 = u32::from_le_bytes(*b"RDS2"); pub const CONTROL_VERSION: u32 = 1; -pub const SCHEMA: u32 = 1; +pub const SCHEMA: u32 = 2; pub const SLOT_COUNT: usize = 3; pub const INIT: u32 = 0; pub const OPEN: u32 = 1; @@ -23,9 +23,9 @@ 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 DATA_OFFSET: usize = 448; const DESCRIPTOR_BYTES: usize = 32; -const STREAMS: usize = 14; +const STREAMS: usize = 12; const SCHEMA_FLAGS: u32 = 3; // dense arrays | affine transforms #[repr(C, align(64))] @@ -247,41 +247,40 @@ fn wasm_pages(minimum_end: usize) -> Result { fn pack(data: &SceneFramePlan, epoch: u32) -> Result, u32> { let meshes = &data.meshes; let instances = &data.occurrences; - let strides = [4usize, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4]; - let components = [1u32, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1]; - let scalar = [1u32, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1]; - let counts = [meshes.len(); 5] + let strides = [4usize, 4, 12, 12, 4, 4, 4, 4, 64, 12, 12, 64]; + let components = [1u32, 1, 3, 3, 1, 1, 1, 1, 16, 3, 3, 16]; + let scalar = [1u32, 1, 2, 2, 1, 1, 1, 1, 2, 2, 2, 1]; + let counts = [meshes.len(); 4] .into_iter() - .chain([instances.len(); 9]) + .chain([instances.len(); 8]) .collect::>(); 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)?)?; + cursor = align16( + cursor + .checked_add(strides[i].checked_mul(counts[i]).ok_or(ERROR_OVERFLOW)?) + .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 put32 = + |out: &mut [u8], at: usize, v: u32| out[at..at + 4].copy_from_slice(&v.to_le_bytes()); let revision = data.revision; - for (i, value) in [ + for (i, v) in [ BLOB_MAGIC, SCHEMA, - SNAPSHOT_HEADER_BYTES as u32, - total, + 64, + cursor as u32, epoch, revision as u32, (revision >> 32) as u32, STREAMS as u32, - SNAPSHOT_HEADER_BYTES as u32, - DESCRIPTOR_BYTES as u32, - mesh_count, - instance_count, + 64, + 32, + meshes.len() as u32, + instances.len() as u32, 0x0102_0304, SCHEMA_FLAGS, 0, @@ -290,11 +289,11 @@ fn pack(data: &SceneFramePlan, epoch: u32) -> Result, u32> { .into_iter() .enumerate() { - put32(&mut out, i * 4, value); + put32(&mut out, i * 4, v); } for i in 0..STREAMS { - let at = SNAPSHOT_HEADER_BYTES + i * DESCRIPTOR_BYTES; - for (j, value) in [ + let at = 64 + i * 32; + for (j, v) in [ i as u32 + 1, scalar[i], offsets[i] as u32, @@ -307,75 +306,61 @@ fn pack(data: &SceneFramePlan, epoch: u32) -> Result, u32> { .into_iter() .enumerate() { - put32(&mut out, at + j * 4, value); + put32(&mut out, at + j * 4, v); } } - for (dense, mesh) in meshes.iter().enumerate() { - for (i, value) in [ - mesh.handle.slot(), - mesh.handle.generation(), - mesh.flags.bits(), - ] - .into_iter() - .enumerate() - { - put32(&mut out, offsets[i] + dense * 4, value); - } + for (d, m) in meshes.iter().enumerate() { + put32(&mut out, offsets[0] + d * 4, m.handle.slot()); + put32(&mut out, offsets[1] + d * 4, m.handle.generation()); for i in 0..3 { put32( &mut out, - offsets[3] + dense * 12 + i * 4, - mesh.aabb.min[i].to_bits(), + offsets[2] + d * 12 + i * 4, + m.local_aabb.min[i].to_bits(), ); put32( &mut out, - offsets[4] + dense * 12 + i * 4, - mesh.aabb.max[i].to_bits(), + offsets[3] + d * 12 + i * 4, + m.local_aabb.max[i].to_bits(), ); } } - for (dense, instance) in instances.iter().enumerate() { - let mesh = data - .meshes - .get(instance.mesh_index) - .ok_or(ERROR_INVARIANT)?; - for (i, value) in [ - instance.handle.slot(), - instance.handle.generation(), - instance.mesh.slot(), - instance.mesh.generation(), - instance.flags.bits(), + for (d, x) in instances.iter().enumerate() { + for (i, v) in [ + x.handle.slot(), + x.handle.generation(), + x.mesh.slot(), + x.mesh.generation(), ] .into_iter() .enumerate() { - put32(&mut out, offsets[5 + i] + dense * 4, value); + put32(&mut out, offsets[4 + i] + d * 4, v); } for i in 0..16 { put32( &mut out, - offsets[10] + dense * 64 + i * 4, - instance.model[i / 4][i % 4].to_bits(), + offsets[8] + d * 64 + i * 4, + x.model[i / 4][i % 4].to_bits(), + ); + put32( + &mut out, + offsets[11] + d * 64 + i * 4, + x.instance_type.words[i], ); } for i in 0..3 { put32( &mut out, - offsets[11] + dense * 12 + i * 4, - instance.world_aabb.min[i].to_bits(), + offsets[9] + d * 12 + i * 4, + x.world_aabb.min[i].to_bits(), ); put32( &mut out, - offsets[12] + dense * 12 + i * 4, - instance.world_aabb.max[i].to_bits(), + offsets[10] + d * 12 + i * 4, + x.world_aabb.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) } @@ -393,199 +378,14 @@ const _: [(); 256] = [(); std::mem::size_of::()]; #[cfg(test)] mod tests { use super::*; - use crate::render_data::{ - MeshCreateInfo, PipelineKey, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM, - }; - #[test] - fn exact_control_layout_and_initial_values() { + fn exact_schema_two_layout() { + assert_eq!(BLOB_MAGIC, u32::from_le_bytes(*b"RDS2")); + assert_eq!(SCHEMA, 2); + assert_eq!(STREAMS, 12); + assert_eq!(DATA_OFFSET, 448); assert_eq!(std::mem::size_of::(), 256); - assert_eq!(std::mem::size_of::(), 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 - ); - } - - #[test] - fn producer_blob_abi_matches_schema_exactly() { - let mut data = RenderData::new(RenderDataConfig::default()).unwrap(); - let create = |data: &mut RenderData, flags, x: f32| { - data.create_mesh(MeshCreateInfo { - positions: &[[x, 0., 0.], [x + 2., 0., 0.], [x, 3., 0.]], - normals: &[[0., 0., 1.]; 3], - tangents: &[[1., 0., 0., 1.]; 3], - uvs: &[[0., 0.]; 3], - indices: &[0, 1, 2], - pipeline: PipelineKey::new(7), - material: crate::render_data::MaterialKey::DEFAULT, - flags, - default_instance_flags: RenderFlags::VISIBLE, - default_transform: IDENTITY_MODEL_TRANSFORM, - }) - .unwrap() - }; - let visible = create(&mut data, RenderFlags::VISIBLE, -1.0); - let hidden = create(&mut data, RenderFlags::NONE, 10.0); - let mut model = IDENTITY_MODEL_TRANSFORM; - model[0][0] = 2.0; - model[1][1] = 0.5; - model[3][0] = 4.0; - model[3][1] = -2.0; - let extra = data - .create_instance(hidden.mesh, model, RenderFlags::NONE) - .unwrap(); - let plan = SceneFramePlan::build(&data).unwrap(); - let epoch = 0x1234_5678; - let blob = pack(&plan, epoch).unwrap(); - let word = |at: usize| u32::from_le_bytes(blob[at..at + 4].try_into().unwrap()); - - assert_eq!(word(0), BLOB_MAGIC); - assert_eq!(word(4), SCHEMA); - assert_eq!(word(8), SNAPSHOT_HEADER_BYTES as u32); - assert_eq!(word(12), blob.len() as u32); - assert_eq!(word(16), epoch); - assert_eq!(word(20), plan.revision as u32); - assert_eq!(word(24), (plan.revision >> 32) as u32); - assert_eq!(word(28), STREAMS as u32); - assert_eq!(word(32), SNAPSHOT_HEADER_BYTES as u32); - assert_eq!(word(36), DESCRIPTOR_BYTES as u32); - assert_eq!(word(40), 2); - assert_eq!(word(44), 3); - assert_eq!(word(48), 0x0102_0304); - assert_eq!(word(52), SCHEMA_FLAGS); - - let strides = [4, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4]; - let scalars = [1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1]; - let components = [1, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1]; - let counts = [2usize; 5] - .into_iter() - .chain([3usize; 9]) - .collect::>(); - let mut offsets = Vec::new(); - let mut cursor = DATA_OFFSET; - for i in 0..STREAMS { - let at = SNAPSHOT_HEADER_BYTES + i * DESCRIPTOR_BYTES; - offsets.push(cursor); - assert_eq!( - [ - word(at), - word(at + 4), - word(at + 8), - word(at + 12), - word(at + 16), - word(at + 20), - word(at + 24), - word(at + 28) - ], - [ - i as u32 + 1, - scalars[i], - cursor as u32, - counts[i] as u32, - components[i], - strides[i] as u32, - 4, - 0 - ] - ); - assert_eq!(cursor % 16, 0); - cursor = (cursor + strides[i] as usize * counts[i] + 15) & !15; - } - assert_eq!(cursor, blob.len()); - - for (dense, mesh) in plan.meshes.iter().enumerate() { - assert_eq!(word(offsets[0] + dense * 4), mesh.handle.slot()); - assert_eq!(word(offsets[1] + dense * 4), mesh.handle.generation()); - assert_eq!(word(offsets[2] + dense * 4), mesh.flags.bits()); - for axis in 0..3 { - assert_eq!( - word(offsets[3] + dense * 12 + axis * 4), - mesh.aabb.min[axis].to_bits() - ); - assert_eq!( - word(offsets[4] + dense * 12 + axis * 4), - mesh.aabb.max[axis].to_bits() - ); - } - } - for (dense, occurrence) in plan.occurrences.iter().enumerate() { - assert_eq!( - [ - word(offsets[5] + dense * 4), - word(offsets[6] + dense * 4), - word(offsets[7] + dense * 4), - word(offsets[8] + dense * 4), - word(offsets[9] + dense * 4) - ], - [ - occurrence.handle.slot(), - occurrence.handle.generation(), - occurrence.mesh.slot(), - occurrence.mesh.generation(), - occurrence.flags.bits() - ] - ); - for i in 0..16 { - assert_eq!( - word(offsets[10] + dense * 64 + i * 4), - occurrence.model[i / 4][i % 4].to_bits() - ); - } - for axis in 0..3 { - assert_eq!( - word(offsets[11] + dense * 12 + axis * 4), - occurrence.world_aabb.min[axis].to_bits() - ); - assert_eq!( - word(offsets[12] + dense * 12 + axis * 4), - occurrence.world_aabb.max[axis].to_bits() - ); - } - let mesh_visible = plan.meshes[occurrence.mesh_index] - .flags - .contains(RenderFlags::VISIBLE); - assert_eq!( - word(offsets[13] + dense * 4), - (mesh_visible && occurrence.flags.contains(RenderFlags::VISIBLE)) as u32 - ); - } - assert!(plan - .meshes - .windows(2) - .all(|w| w[0].handle.slot() < w[1].handle.slot())); - assert!(plan - .occurrences - .windows(2) - .all(|w| w[0].handle.slot() < w[1].handle.slot())); - assert_eq!( - plan.occurrences - .iter() - .find(|o| o.handle == extra) - .unwrap() - .model, - model - ); - assert!(plan.occurrences.iter().any(|o| o.mesh == visible.mesh)); + assert_eq!(snapshot.control.header[5].load(Ordering::Relaxed), 2); } } diff --git a/static/bvh-core.js b/static/bvh-core.js index eebb665..ac8cfe7 100644 --- a/static/bvh-core.js +++ b/static/bvh-core.js @@ -5,7 +5,7 @@ export class DerivedBvh { 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); } + 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.instanceType[i * 16] & 1); 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() { diff --git a/static/bvh-worker.js b/static/bvh-worker.js index 22a5979..6727e69 100644 --- a/static/bvh-worker.js +++ b/static/bvh-worker.js @@ -19,7 +19,7 @@ function coalescedUpdate(hint = 0) { 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"}); } + if (m.type === "init") { reader = new SnapshotReader(m.memory, m.controlPtr); if (m.controlVersion !== 1 || m.schemaVersion !== 2) 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(); diff --git a/static/render-data-snapshot.js b/static/render-data-snapshot.js index 0449148..818fb8e 100644 --- a/static/render-data-snapshot.js +++ b/static/render-data-snapshot.js @@ -1,11 +1,11 @@ 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, + MAGIC: 0x504e5359, BLOB_MAGIC: 0x32534452, VERSION: 1, BYTES: 256, + SLOTS: 3, SLOT_BYTES: 64, SCHEMA: 2, 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]; +export const STREAM_NAMES = ["meshSlot", "meshGeneration", "meshLocalMin", "meshLocalMax", "instanceSlot", "instanceGeneration", "instanceMeshSlot", "instanceMeshGeneration", "instanceModel", "instanceWorldMin", "instanceWorldMax", "instanceType"]; +const COMPONENTS = [1, 1, 3, 3, 1, 1, 1, 1, 16, 3, 3, 16]; +const SCALARS = [1, 1, 2, 2, 1, 1, 1, 1, 2, 2, 2, 1]; const STRIDES = COMPONENTS.map(n => n * 4); export class SnapshotProtocolError extends Error { @@ -70,15 +70,15 @@ export class SnapshotReader { 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"); + if (ptr % 16 || bytes < 448 || 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[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] !== 12 || 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++) { + for (let i = 0; i < 12; 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 want = i < 4 ? slot[7] : slot[8]; + if (semantic !== i + 1 || scalar !== SCALARS[i] || count !== want || components !== COMPONENTS[i] || stride !== STRIDES[i] || width !== 4 || reserved || offset < 448 || 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]); diff --git a/static/render-graph/adapter.js b/static/render-graph/adapter.js index 471eeed..c52824b 100644 --- a/static/render-graph/adapter.js +++ b/static/render-graph/adapter.js @@ -94,7 +94,7 @@ const mapValuePaths = (paths, path, source, value) => { mapValuePaths(paths, `${path}.${key}`, source, value[key]); }; -function parameterValue(raw, schema, nodeId, key) { +function parameterValue(raw, schema, nodeId, key, semanticType) { if (!exactKeys(raw, ["kind", "value"]) || raw.kind !== schema.type) fail("AUTHORING_PARAMETER", { nodeId, parameter: key }); const value = raw.value; @@ -112,13 +112,33 @@ function parameterValue(raw, schema, nodeId, key) { ? typeof value === "boolean" : schema.type === "vector" || schema.type === "color" ? Array.isArray(value) && - value.length === (schema.type === "vector" ? 3 : 4) && + value.length === (semanticType?.startsWith("vec") ? Number(semanticType.at(-1)) : (schema.type === "vector" ? 3 : 4)) && value.every(bounded) - : schema.type === "json" && finiteJson(value); + : schema.type === "json" && finiteJson(value) && validSemanticValue(value, semanticType); if (!valid) fail("AUTHORING_PARAMETER", { nodeId, parameter: key }); return canonical(structuredClone(raw.value)); } +function validSemanticValue(value, type) { + if (!type) return true; + const finiteVector = (candidate, size) => + Array.isArray(candidate) && candidate.length === size && candidate.every(Number.isFinite); + const vector = /^vec([24])$/.exec(type); + if (vector) return finiteVector(value, Number(vector[1])); + if (type === "u32x16") + return Array.isArray(value) && value.length === 16 && + value.every((word) => Number.isInteger(word) && word >= 0 && word <= 0xffffffff); + if (type === "local_aabb") + return exactKeys(value, ["min", "max"]) && finiteVector(value.min, 3) && finiteVector(value.max, 3); + const match = /^mat([234])$/.exec(type); + if (match) { + const size = Number(match[1]); + return Array.isArray(value) && value.length === size && + value.every((column) => finiteVector(column, size)); + } + return false; +} + export function adaptFxNodeSnapshot(raw, revision = 1) { try { const rootKeys = [ @@ -300,6 +320,7 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { socketDefinition.value, n.id, s.key, + input.accepted.types[0], ); return false; } catch { @@ -327,13 +348,16 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { } if (new Set(n.sockets.map((s) => s.key)).size !== expected.length) fail("AUTHORING_SOCKET_SET", { nodeId: n.id }); - if (n.typeId === "mesh_query") { - parameters.visibleDefault = sockets.get( - `${n.id}:isVisible`, - ).defaultValue.value; - parameters.frustumCulledDefault = sockets.get( - `${n.id}:isFrustumCulled`, - ).defaultValue.value; + for (const key of Object.keys(descriptor.inputs)) { + const authoredDefault = sockets.get(`${n.id}:${key}`).defaultValue; + if (authoredDefault) + parameters[`${key}Default`] = parameterValue( + authoredDefault, + definition.sockets[key].value, + n.id, + key, + descriptor.inputs[key].accepted.types[0], + ); } nodes.set(n.id, { ordinal, @@ -507,13 +531,12 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { mapValuePaths( paths, `${base}.parameters.${key}`, - item.value.executor.key === "mesh_query" && key.endsWith("Default") + key.endsWith("Default") && Object.hasOwn(descriptors[item.value.executor.key].inputs, key.slice(0, -7)) ? { kind: "input", nodeId: item.value.id, - input: - key === "visibleDefault" ? "isVisible" : "isFrustumCulled", - socketId: `${item.value.id}:${key === "visibleDefault" ? "isVisible" : "isFrustumCulled"}`, + input: key.slice(0, -7), + socketId: `${item.value.id}:${key.slice(0, -7)}`, unconnected: true, } : parameterSource( diff --git a/static/render-graph/add-node-menu.js b/static/render-graph/add-node-menu.js index 7d9217f..3963ca7 100644 --- a/static/render-graph/add-node-menu.js +++ b/static/render-graph/add-node-menu.js @@ -2,13 +2,14 @@ import { semanticCatalog } from "./catalog.js"; const GROUPS = Object.freeze([ ["source", "Source"], + ["expression", "Expression"], ["compute", "Compute"], ["cpu_preparation", "CPU preparation"], ["render", "Render / post"], ["frame", "Frame"], ]); -const title = (typeId) => typeId.replaceAll("_", " "); +const title = (typeId) => typeId.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); /** Application-owned, immutable add-node catalog model. */ export const addNodeItems = Object.freeze( diff --git a/static/render-graph/catalog.js b/static/render-graph/catalog.js index 5cdd3d1..aafd841 100644 --- a/static/render-graph/catalog.js +++ b/static/render-graph/catalog.js @@ -1,5 +1,5 @@ export const GRAPH_ID = "authored_gpu_culling"; -export const CATALOG_VERSION = 7; +export const CATALOG_VERSION = 8; const exact = (type) => ({ kind: "exact", types: [type] }); const i = (type, required = true, authoringType) => ({ accepted: typeof type === "string" ? exact(type) : type, @@ -7,6 +7,45 @@ const i = (type, required = true, authoringType) => ({ ...(authoringType ? { authoringType } : {}), }); const o = (type) => ({ type }); +const expression = (inputs, outputs) => ({ + version: 1, + execution: "expression", + inputs: Object.fromEntries(Object.entries(inputs).map(([name, type]) => [name, i(type, false)])), + outputs: Object.fromEntries(Object.entries(outputs).map(([name, type]) => [name, o(type)])), + parameters: {}, +}); +const numbered = (prefix, count, type) => + Object.fromEntries(Array.from({ length: count }, (_, index) => [`${prefix}${index}`, type])); +const expressionCatalog = { + and: expression({ left: "bool", right: "bool" }, { value: "bool" }), + or: expression({ left: "bool", right: "bool" }, { value: "bool" }), + not: expression({ operand: "bool" }, { value: "bool" }), + xor: expression({ left: "bool", right: "bool" }, { value: "bool" }), + xnor: expression({ left: "bool", right: "bool" }, { value: "bool" }), + greater_than_f32: expression({ left: "f32", right: "f32" }, { value: "bool" }), + less_than_f32: expression({ left: "f32", right: "f32" }, { value: "bool" }), + equals_f32: expression({ left: "f32", right: "f32" }, { value: "bool" }), + greater_than_u32: expression({ left: "u32", right: "u32" }, { value: "bool" }), + less_than_u32: expression({ left: "u32", right: "u32" }, { value: "bool" }), + equals_u32: expression({ left: "u32", right: "u32" }, { value: "bool" }), + separate_vec2: expression({ vector: "vec2" }, { x: "f32", y: "f32" }), + combine_vec2: expression({ x: "f32", y: "f32" }, { vector: "vec2" }), + separate_vec3: expression({ vector: "vec3" }, { x: "f32", y: "f32", z: "f32" }), + combine_vec3: expression({ x: "f32", y: "f32", z: "f32" }, { vector: "vec3" }), + separate_vec4: expression({ vector: "vec4" }, { x: "f32", y: "f32", z: "f32", w: "f32" }), + combine_vec4: expression({ x: "f32", y: "f32", z: "f32", w: "f32" }, { vector: "vec4" }), + separate_mat2: expression({ matrix: "mat2" }, numbered("column", 2, "vec2")), + combine_mat2: expression(numbered("column", 2, "vec2"), { matrix: "mat2" }), + separate_mat3: expression({ matrix: "mat3" }, numbered("column", 3, "vec3")), + combine_mat3: expression(numbered("column", 3, "vec3"), { matrix: "mat3" }), + separate_mat4: expression({ matrix: "mat4" }, numbered("column", 4, "vec4")), + combine_mat4: expression(numbered("column", 4, "vec4"), { matrix: "mat4" }), + separate_u32x16: expression({ value: "u32x16" }, numbered("word", 16, "u32")), + combine_u32x16: expression(numbered("word", 16, "u32"), { value: "u32x16" }), + separate_u32_bits: expression({ value: "u32" }, numbered("bit", 32, "bool")), + combine_u32_bits: expression(numbered("bit", 32, "bool"), { value: "u32" }), + separate_local_aabb: expression({ value: "local_aabb" }, { min: "vec3", max: "vec3" }), +}; const texture = { residency: "transient", texture: { @@ -25,17 +64,13 @@ const texture = { }; export const semanticCatalog = Object.freeze({ mesh: { - version: 1, + version: 2, execution: "source", inputs: {}, outputs: { mesh: o("mesh_data"), - localAabbs: o("local_aabb_buffer"), - isVisible: { - ...o("boolean_flag_buffer"), - authoringType: "visibility_flag_buffer", - }, - pipelineIndices: o("pipeline_index_stream"), + type: o("u32x16"), + localAabb: o("local_aabb"), }, parameters: {}, }, @@ -62,54 +97,28 @@ export const semanticCatalog = Object.freeze({ }, }, frustum_cull: { - version: 1, - execution: "compute", + version: 2, + execution: "expression", inputs: { mesh: i("mesh_data"), - localAabbs: i("local_aabb_buffer"), - }, - outputs: { - isFrustumCulled: { - ...o("boolean_flag_buffer"), - authoringType: "frustum_flag_buffer", - }, + localAabb: i("local_aabb"), }, + outputs: { isFrustumCulled: o("bool") }, parameters: { cameraSelection: "active" }, }, - mesh_query: { - version: 1, - execution: "compute", - inputs: { - mesh: i("mesh_data"), - isVisible: i("boolean_flag_buffer", false, "visibility_flag_buffer"), - isFrustumCulled: i("boolean_flag_buffer", false, "frustum_flag_buffer"), - }, - outputs: { draws: o("draw_stream") }, - parameters: { - visiblePredicate: "required_true", - frustumCulledPredicate: "required_false", - }, - }, - pipeline_registry: { - version: 1, - execution: "cpu_preparation", - inputs: { pipelineIndices: i("pipeline_index_stream") }, - outputs: { activation: o("pipeline_activation") }, - parameters: {}, - }, pipeline: { - version: 1, + version: 2, execution: "render", inputs: { mesh: i("mesh_data"), - draws: i("draw_stream"), - activation: i("pipeline_activation"), + predicate: i("bool", false), colorTarget: i("texture"), depthTarget: i("texture"), }, outputs: { color: o("texture"), depth: o("texture") }, parameters: { pipeline: "gltf_standard", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1] }, }, + ...expressionCatalog, fullscreen_copy: { version: 1, execution: "render", @@ -210,13 +219,8 @@ export const socketTypes = Object.fromEntries( [ "texture", "mesh_data", - "local_aabb_buffer", - "boolean_flag_buffer", - "pipeline_index_stream", - "draw_stream", - "pipeline_activation", - "visibility_flag_buffer", - "frustum_flag_buffer", + "bool", "f32", "u32", "vec2", "vec3", "vec4", + "mat2", "mat3", "mat4", "u32x16", "local_aabb", ].map((type, index) => [ type, { @@ -226,11 +230,6 @@ export const socketTypes = Object.fromEntries( }, ]), ); -socketTypes.boolean_flag_buffer.acceptsFrom = [ - "boolean_flag_buffer", - "visibility_flag_buffer", - "frustum_flag_buffer", -]; export const theme = { background: "#151820", grid: "#292e3a", @@ -266,6 +265,7 @@ export const theme = { export const styles = { source: { header: "#3977a8" }, compute: { header: "#725a9b" }, + expression: { header: "#725a9b" }, cpu_preparation: { header: "#8a6d3b" }, render: { header: "#426b43" }, frame: { header: "#a75d37" }, @@ -283,8 +283,8 @@ const tagged = (kind, value) => ({ kind, value: structuredClone(value) }); const number = (value, minimum, maximum) => ({ type: "number", default: tagged("number", value), - minimum, - maximum, + ...(minimum !== undefined ? { minimum } : {}), + ...(maximum !== undefined ? { maximum } : {}), }); const enumeration = (value, values) => ({ type: "string", @@ -302,8 +302,38 @@ const color = (value, minimum = 0, maximum = 1) => ({ minimum, maximum, }); -const vector = (value, minimum, maximum) => ({ type: "vector", default: tagged("vector", value), minimum, maximum }); +const vector = (value, minimum, maximum) => ({ + type: "vector", default: tagged("vector", value), + ...(minimum !== undefined ? { minimum } : {}), + ...(maximum !== undefined ? { maximum } : {}), +}); const json = (value) => ({ type: "json", default: tagged("json", value) }); +const socketDefault = (type, value) => { + if (type === "bool") return boolean(value); + if (type === "f32") return number(value); + if (type === "u32") return { ...number(value, 0, 0xffffffff), integer: true }; + if (type === "vec3") return vector(value); + return json(value); +}; +const zero = (type) => { + if (type === "bool") return false; + if (type === "f32" || type === "u32") return 0; + if (/^vec[234]$/.test(type)) return Array(Number(type.at(-1))).fill(0); + if (type === "u32x16") return Array(16).fill(0); + if (type === "local_aabb") return { min: [0, 0, 0], max: [0, 0, 0] }; + const size = Number(type.at(-1)); + return Array.from({ length: size }, (_, column) => + Array.from({ length: size }, (_, row) => Number(column === row))); +}; +const defaultForInput = (key, name, type) => { + if (key === "pipeline" && name === "predicate") return true; + if (key === "and") return true; + if (/^combine_mat[234]$/.test(key)) { + const index = Number(name.replace("column", "")); + return zero(type).map((_, row) => Number(index === row)); + } + return zero(type); +}; const parameterSchemas = { texture: { residency: enumeration("transient", ["transient", "persistent"]), @@ -343,19 +373,6 @@ const parameterSchemas = { }, mesh: {}, frustum_cull: { cameraSelection: enumeration("active", ["active"]) }, - mesh_query: { - visiblePredicate: enumeration("required_true", [ - "any", - "required_true", - "required_false", - ]), - frustumCulledPredicate: enumeration("required_false", [ - "any", - "required_true", - "required_false", - ]), - }, - pipeline_registry: {}, pipeline: { pipeline: string("gltf_standard"), depthCompare: enumeration("less_equal", [ @@ -399,6 +416,7 @@ const parameterSchemas = { backgroundColor: color([0, 0, 0, 1]), }, }; +for (const key of Object.keys(expressionCatalog)) parameterSchemas[key] = {}; export const nodeDefinitions = Object.fromEntries( Object.entries(semanticCatalog).map(([key, c]) => { const sockets = { @@ -409,11 +427,7 @@ export const nodeDefinitions = Object.fromEntries( n, "input", v.authoringType ?? v.accepted.types[0], - key === "mesh_query" && n === "isVisible" - ? boolean(true) - : key === "mesh_query" && n === "isFrustumCulled" - ? boolean(false) - : null, + !v.required ? socketDefault(v.accepted.types[0], defaultForInput(key, n, v.accepted.types[0])) : null, ), ]), ), @@ -458,6 +472,7 @@ export const nodeDefinitions = Object.fromEntries( ]; }), ); +nodeDefinitions.mesh.sockets.localAabb.title = "Local AABB"; nodeDefinitions.color_balance.ui = [ { kind: "parameter", parameter: "mode" }, { kind: "widget", widget: "grading-wheels", bindings: [ diff --git a/static/render-graph/fxnode-editor.js b/static/render-graph/fxnode-editor.js index 744bb1a..79acc03 100644 --- a/static/render-graph/fxnode-editor.js +++ b/static/render-graph/fxnode-editor.js @@ -3,19 +3,8 @@ import { CATALOG_VERSION, GRAPH_ID, fxNodeComposition } from "./catalog.js"; import { prepareBrowserHost } from "./browser-host.js"; import { createAddNodeMenu } from "./add-node-menu.js"; import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.js"; +import { culling } from "./presets.js"; -const spec = [ - ["hdr", "texture", { x: 40, y: 170 }], - ["depth", "texture", { x: 40, y: 300 }], - ["mesh", "mesh", { x: 40, y: 470 }], - ["cull", "frustum_cull", { x: 540, y: 480 }], - ["query", "mesh_query", { x: 790, y: 330 }], - ["registry", "pipeline_registry", { x: 790, y: 620 }], - ["ground", "pipeline", { x: 1040, y: 290 }], - ["pbr", "pipeline", { x: 1300, y: 290 }], - ["pbr_double", "pipeline", { x: 1560, y: 290 }], - ["frame_out", "frame_out", { x: 1820, y: 250 }], -]; async function seed(root) { await root.setState({ graphId: GRAPH_ID, @@ -24,28 +13,11 @@ async function seed(root) { links: [], metadata: {}, }); - for (const [nodeId, nodeType, position] of spec) - await root.dispatch({ type: "node.add", nodeId, nodeType, position }); - const links = [ - ["mesh", "mesh", "cull", "mesh"], - ["mesh", "localAabbs", "cull", "localAabbs"], - ["mesh", "mesh", "query", "mesh"], - ["mesh", "isVisible", "query", "isVisible"], - ["cull", "isFrustumCulled", "query", "isFrustumCulled"], - ["mesh", "pipelineIndices", "registry", "pipelineIndices"], - ...["ground", "pbr", "pbr_double"].flatMap((pipeline) => [ - ["mesh", "mesh", pipeline, "mesh"], - ["query", "draws", pipeline, "draws"], - ["registry", "activation", pipeline, "activation"], - ]), - ["hdr", "texture", "ground", "colorTarget"], - ["depth", "texture", "ground", "depthTarget"], - ["ground", "color", "pbr", "colorTarget"], - ["ground", "depth", "pbr", "depthTarget"], - ["pbr", "color", "pbr_double", "colorTarget"], - ["pbr", "depth", "pbr_double", "depthTarget"], - ["pbr_double", "color", "frame_out", "color"], - ]; + for (const [index, item] of culling.nodes.entries()) + await root.dispatch({ type: "node.add", nodeId: item.id, nodeType: item.executor.key, + position: { x: 40 + (index % 6) * 280, y: 120 + Math.floor(index / 6) * 260 } }); + const links = culling.nodes.flatMap((item) => Object.entries(item.inputs).map(([socket, from]) => + [from.node, from.socket, item.id, socket])); for (const [a, as, b, bs] of links) { const id = `${a}_${as}_${b}_${bs}`; await root.dispatch({ @@ -61,11 +33,43 @@ async function seed(root) { }, }); } - const authored = await root.getState(), - depth = authored.nodes.find((node) => node.id === "depth"); - depth.parameters.format = { kind: "string", value: "depth32_float" }; - for (const [id, name] of [["ground", "ground_plane"], ["pbr", "gltf_standard"], ["pbr_double", "gltf_standard_double_sided"]]) - authored.nodes.find((node) => node.id === id).parameters.pipeline = { kind: "string", value: name }; + const authored = await root.getState(); + for (const item of culling.nodes) { + const target = authored.nodes.find((candidate) => candidate.id === item.id); + if (item.executor.key === "texture") { + const texture = item.parameters.texture; + const relative = texture.extent.kind === "surface_relative"; + const values = { + residency: item.parameters.residency, + format: texture.format, + dimension: texture.dimension, + extentMode: texture.extent.kind, + absoluteWidth: relative ? 1 : texture.extent.width, + absoluteHeight: relative ? 1 : texture.extent.height, + relativeWidthNumerator: relative ? texture.extent.width.numerator : 1, + relativeWidthDenominator: relative ? texture.extent.width.denominator : 1, + relativeHeightNumerator: relative ? texture.extent.height.numerator : 1, + relativeHeightDenominator: relative ? texture.extent.height.denominator : 1, + depthOrArrayLayers: texture.extent.depthOrArrayLayers, + mipLevelCount: texture.mipLevelCount, + sampleCount: String(texture.sampleCount), + viewFormat: texture.viewFormats[0] ?? "none", + }; + for (const [key, value] of Object.entries(values)) + target.parameters[key].value = structuredClone(value); + continue; + } + for (const [key, value] of Object.entries(item.parameters)) { + const input = key.endsWith("Default") ? key.slice(0, -7) : null; + if (input) { + const socket = target.sockets.find((candidate) => candidate.key === input); + if (socket?.defaultValue) socket.defaultValue.value = structuredClone(value); + } else { + const authoredKey = item.executor.key === "frustum_cull" && key === "camera" ? "cameraSelection" : key; + if (target.parameters[authoredKey]) target.parameters[authoredKey].value = structuredClone(value); + } + } + } await root.setState(authored); } export async function createRenderGraphEditor(canvas) { diff --git a/static/render-graph/presets.js b/static/render-graph/presets.js index c811852..3f9777f 100644 --- a/static/render-graph/presets.js +++ b/static/render-graph/presets.js @@ -13,16 +13,37 @@ const texture = (format, scale = 1, heightScale = scale) => ({ residency: "transient", }); const frameOut = (hdr, options = {}) => ({ surfaceFormat: "preferred", hdrEnabled: hdr, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1], ...options }); -const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1) => [ +const predicates = (withCulling = false) => { + const result = [ + node("type_words", "separate_u32x16", { valueDefault: Array(16).fill(0) }, { value: input("mesh", "type") }), + node("type_bits", "separate_u32_bits", { valueDefault: 0 }, { value: input("type_words", "word0") }), + node("ground_class", "and", { leftDefault: true, rightDefault: true }, { left: input("type_bits", "bit0"), right: input("type_bits", "bit1") }), + node("visible_pbr", "and", { leftDefault: true, rightDefault: true }, { left: input("type_bits", "bit0"), right: input("type_bits", "bit2") }), + node("not_double", "not", { operandDefault: false }, { operand: input("type_bits", "bit3") }), + node("standard_class", "and", { leftDefault: true, rightDefault: true }, { left: input("visible_pbr", "value"), right: input("not_double", "value") }), + node("double_class", "and", { leftDefault: true, rightDefault: true }, { left: input("type_bits", "bit0"), right: input("type_bits", "bit3") }), + ]; + if (!withCulling) return { nodes: result, classes: { ground: "ground_class", pbr: "standard_class", pbr_double: "double_class" } }; + result.push( + node("cull", "frustum_cull", { camera: "active" }, { mesh: input("mesh", "mesh"), localAabb: input("mesh", "localAabb") }), + node("not_culled", "not", { operandDefault: false }, { operand: input("cull", "isFrustumCulled") }), + ...[["ground", "ground_class"], ["pbr", "standard_class"], ["pbr_double", "double_class"]].map(([name, classification]) => + node(`${name}_final`, "and", { leftDefault: true, rightDefault: true }, { left: input(classification, "value"), right: input("not_culled", "value") })), + ); + return { nodes: result, classes: { ground: "ground_final", pbr: "pbr_final", pbr_double: "pbr_double_final" } }; +}; +const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1, withCulling = false) => { + const classification = predicates(withCulling); + return [ node("hdr", "texture", texture("rgba16_float", 1, heightScale)), node("depth", "texture", texture("depth32_float", 1, heightScale)), node("mesh", "mesh"), - node("query", "mesh_query", { visiblePredicate: "required_true", visibleDefault: true, frustumCulledPredicate: "any", frustumCulledDefault: false }, { mesh: input("mesh", "mesh"), isVisible: input("mesh", "isVisible") }), - node("registry", "pipeline_registry", {}, { pipelineIndices: input("mesh", "pipelineIndices") }), - node("ground", "pipeline", { pipeline: "ground_plane", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor }, { mesh: input("mesh", "mesh"), draws: input("query", "draws"), activation: input("registry", "activation"), colorTarget: input(colorTarget, "texture"), depthTarget: input("depth", "texture") }), - node("pbr", "pipeline", { pipeline: "gltf_standard", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor }, { mesh: input("mesh", "mesh"), draws: input("query", "draws"), activation: input("registry", "activation"), colorTarget: input("ground", "color"), depthTarget: input("ground", "depth") }), - node("pbr_double", "pipeline", { pipeline: "gltf_standard_double_sided", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor }, { mesh: input("mesh", "mesh"), draws: input("query", "draws"), activation: input("registry", "activation"), colorTarget: input("pbr", "color"), depthTarget: input("pbr", "depth") }), + ...classification.nodes, + node("ground", "pipeline", { pipeline: "ground_plane", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.ground, "value"), colorTarget: input(colorTarget, "texture"), depthTarget: input("depth", "texture") }), + node("pbr", "pipeline", { pipeline: "gltf_standard", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.pbr, "value"), colorTarget: input("ground", "color"), depthTarget: input("ground", "depth") }), + node("pbr_double", "pipeline", { pipeline: "gltf_standard_double_sided", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.pbr_double, "value"), colorTarget: input("pbr", "color"), depthTarget: input("pbr", "depth") }), ]; +}; const graph = (graphId, nodes) => Object.freeze({ schemaVersion: 2, graphId, revision: 1, nodes }); const direct = (graphId, clearColor) => graph(graphId, [ node("ldr", "texture", texture("rgba8_unorm")), @@ -36,12 +57,7 @@ export const hdr = graph("preset_hdr_fullscreen", [ node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") }), ]); export const culling = graph("preset_gpu_culling", (() => { - const nodes = structuredClone(hdr.nodes); - nodes.splice(3, 0, node("cull", "frustum_cull", { camera: "active" }, { mesh: input("mesh", "mesh"), localAabbs: input("mesh", "localAabbs") })); - const query = nodes.find((item) => item.id === "query"); - query.parameters.frustumCulledPredicate = "required_false"; - query.inputs.isFrustumCulled = input("cull", "isFrustumCulled"); - return nodes; + return [...scene("hdr", undefined, 1, true), node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") })]; })()); const postPreset = (graphId, kind) => { const nodes = [...scene("hdr")]; diff --git a/static/renderer-client.js b/static/renderer-client.js index de1a688..4d34b4b 100644 --- a/static/renderer-client.js +++ b/static/renderer-client.js @@ -1,8 +1,8 @@ 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 HEADER_WORDS = 16, SLOT_WORDS = 40, CAPACITY = 1024, SLOT_VERSION = 2; +const OP = { IMPORT_GLB: 1, MESH_FLAGS: 2, CREATE_INSTANCE: 3, INSTANCE_VISIBLE: 4, INSTANCE_TRANSFORM: 5, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9, SET_INSTANCE_TYPE: 10 }; const HANDLE_TOKEN = Symbol("renderer handle"); export class RendererError extends Error { @@ -20,7 +20,7 @@ export class RendererClient { 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) { + if (Atomics.load(this.#header, 0) !== 0x4e574159 || Atomics.load(this.#header, 1) !== 2 || 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"); @@ -70,9 +70,9 @@ export class RendererClient { this.#fail(message.code || "WORKER_FATAL"); } else if (message?.type === "snapshot-init") { try { - if (message.controlVersion !== 1 || message.schemaVersion !== 1) throw new Error("version"); + if (message.controlVersion !== 1 || message.schemaVersion !== 2) 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}); + this.#bvh?.postMessage({type:"init",memory:this.#bridge.memory,controlPtr:message.controlPtr,controlVersion:1,schemaVersion:2}); } 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"); } @@ -157,18 +157,20 @@ export class RendererClient { return promise; } - #mesh(handle) { + #mesh(handle, defaultType = Array(16).fill(0)) { 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]); + visible => { validateVisible(visible); return this.#enqueue(OP.MESH_FLAGS, [...handle, visible ? 1 : 0]); }, + async (transform, {type = defaultType, visible} = {}) => { + type = typeWords(type); if (visible !== undefined) { validateVisible(visible); type[0] = (type[0] & ~1) | Number(visible); } + const result = await this.#enqueue(OP.CREATE_INSTANCE, [...handle, ...floatWords(transform), ...type]); return this.#instance(result); }); } #instance(handle) { return new Instance(HANDLE_TOKEN, - visible => this.#enqueue(OP.INSTANCE_FLAGS, [...handle, visible ? VISIBLE : 0]), + visible => { validateVisible(visible); return this.#enqueue(OP.INSTANCE_VISIBLE, [...handle, visible ? 1 : 0]); }, + type => this.#enqueue(OP.SET_INSTANCE_TYPE, [...handle, ...typeWords(type)]), transform => this.#enqueue(OP.INSTANCE_TRANSFORM, [...handle, ...floatWords(transform)]), () => this.#enqueue(OP.DESTROY_INSTANCE, [...handle])); } @@ -183,11 +185,9 @@ export class RendererClient { 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)); + return result.meshes.map(item => this.#mesh(item.handle, item.defaultType)); } - /** 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"); @@ -260,6 +260,9 @@ 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 validateVisible(value) { if (typeof value !== "boolean") throw new TypeError("visible must be boolean"); } +function typeWords(words) { if (!words || words.length !== 16 || [...words].some(x => !Number.isInteger(x) || x < 0 || x > 0xffffffff)) throw new TypeError("type must contain exactly 16 uint32 values"); return Array.from(words, x => x >>> 0); } + function floatWords(matrix) { if (!matrix || matrix.length !== 16) throw new TypeError("transform must contain 16 numbers"); return [...new Int32Array(new Float32Array(matrix).buffer)]; @@ -273,19 +276,21 @@ class Mesh { this.#createInstance = createInstance; } setVisible(visible) { return this.#setVisible(visible); } - createInstance(transform, visible = true) { return this.#createInstance(transform, visible); } + createInstance(transform, options = {}) { return this.#createInstance(transform, options); } } class Instance { - #setVisible; #setTransform; #destroy; #dead = false; - constructor(token, setVisible, setTransform, destroy) { + #setVisible; #setType; #setTransform; #destroy; #dead = false; + constructor(token, setVisible, setType, setTransform, destroy) { if (token !== HANDLE_TOKEN) throw new TypeError("Instance cannot be constructed directly"); this.#setVisible = setVisible; + this.#setType = setType; this.#setTransform = setTransform; this.#destroy = destroy; } #live() { if (this.#dead) throw new RendererError("STALE_HANDLE"); } setVisible(visible) { this.#live(); return this.#setVisible(visible); } + setType(words) { this.#live(); return this.#setType(words); } setTransform(transform) { this.#live(); return this.#setTransform(transform); } async destroy() { this.#live(); await this.#destroy(); this.#dead = true; } } diff --git a/tests/add-node-menu.test.js b/tests/add-node-menu.test.js index 6637176..70a51a0 100644 --- a/tests/add-node-menu.test.js +++ b/tests/add-node-menu.test.js @@ -10,13 +10,15 @@ import { spawnRequestedNode, } from "../static/render-graph/node-spawn.js"; -test("add-node model contains all 16 catalog types in application groups", () => { - assert.equal(addNodeItems.length, 16); +test("add-node model contains all final catalog types in application groups", () => { + assert.equal(addNodeItems.length, 42); assert.deepEqual( [...new Set(addNodeItems.map((item) => item.group))], - ["Source", "Compute", "CPU preparation", "Render / post", "Frame"], + ["Source", "Expression", "Render / post", "Frame"], ); - assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 16); + assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 42); + assert.ok(addNodeItems.some((item) => item.typeId === "separate_u32_bits" && item.group === "Expression")); + assert.ok(!addNodeItems.some((item) => ["mesh_query", "pipeline_registry"].includes(item.typeId))); assert.deepEqual(searchAddNodeItems("no such node"), []); }); @@ -36,7 +38,7 @@ test("allocator avoids existing and session-reserved IDs and is bounded", () => ); }); -test("all 16 types spawn with exact position, current version and generated ID", async () => { +test("all final types spawn with exact position, current version and generated ID", async () => { let revision = 5, expectedType; const request = { compositionRevision: 5, viewPosition: { x: 12.25, y: -4 } }; diff --git a/tests/fxnode-composition.test.js b/tests/fxnode-composition.test.js index 5dd05f4..a36f8fb 100644 --- a/tests/fxnode-composition.test.js +++ b/tests/fxnode-composition.test.js @@ -36,13 +36,30 @@ test("production render graph composition passes fxnode's public validator", asy result.ok ? undefined : JSON.stringify(result.issues, null, 2), ); assert.equal(fxNodeComposition.schemaVersion, 2); - assert.equal(fxNodeComposition.version, 7); - assert.equal(Object.keys(fxNodeComposition.nodes).length, 16); + assert.equal(fxNodeComposition.version, 8); + assert.equal(Object.keys(fxNodeComposition.nodes).length, 42); assert.ok( Object.values(fxNodeComposition.nodes).every( (definition) => definition.migrations.length === 0, ), ); + for (const [type, descriptor] of Object.entries(fxNodeComposition.nodes)) { + const socketKeys = Object.keys(descriptor.sockets); + assert.equal( + socketKeys.length, + new Set(socketKeys).size, + `${type} has colliding input and output socket names`, + ); + } + assert.deepEqual(fxNodeComposition.nodes.not.sockets.operand, { + title: "operand", + direction: "input", + type: "bool", + maxIncomingLinks: 1, + visible: true, + value: { type: "boolean", default: { kind: "boolean", value: false } }, + showValue: true, + }); } finally { await rm(directory, { recursive: true, force: true }); } diff --git a/tests/render-graph-authoring.test.js b/tests/render-graph-authoring.test.js index dad7c45..780c622 100644 --- a/tests/render-graph-authoring.test.js +++ b/tests/render-graph-authoring.test.js @@ -1,660 +1,40 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { - adaptFxNodeSnapshot, - AuthoringGraphError, - getSourceMap, - mapAuthoringDiagnostic, -} from "../static/render-graph/adapter.js"; -import { RendererError } from "../static/renderer-client.js"; -import { - semanticCatalog, - nodeDefinitions, - GRAPH_ID, - CATALOG_VERSION, - descriptors, - socketTypes, -} from "../static/render-graph/catalog.js"; import { culling } from "../static/render-graph/presets.js"; -import { AuthoringController } from "../static/render-graph/authoring-controller.js"; -function fixture() { - const nodes = culling.nodes.map((n) => { - const d = semanticCatalog[n.executor.key]; - const definition = nodeDefinitions[n.executor.key]; - return { - id: n.id, - typeId: n.executor.key, - typeVersion: d.version, - known: true, - muted: n.state !== "enabled", - position: { x: 10, y: 20 }, - size: { x: 200, y: 120 }, - label: n.id, - collapsed: false, - extensions: {}, - parameters: Object.fromEntries( - Object.entries(definition.parameters).map(([key, schema]) => [ - key, - { - kind: schema.type, - value: structuredClone(n.parameters[key] ?? schema.default.value), - }, - ]), - ), - sockets: [ - ...Object.entries(d.inputs).map(([key, x]) => { - const socket = definition.sockets[key]; - return { - key, - id: `${n.id}:${key}`, - direction: "input", - dataType: x.authoringType ?? x.accepted.types[0], - label: socket.title, - accepts: - socketTypes[x.authoringType ?? x.accepted.types[0]].acceptsFrom, - ...(socket.value - ? { defaultValue: structuredClone(socket.value.default) } - : {}), - visible: socket.visible, - maxIncomingLinks: socket.maxIncomingLinks, - }; - }), - ...Object.entries(d.outputs).map(([key]) => ({ - key, - id: `${n.id}:${key}`, - direction: "output", - dataType: definition.sockets[key].type, - label: key, - accepts: [], - visible: true, - maxIncomingLinks: 0, - })), - ], - }; - }); - const links = []; - for (const n of culling.nodes) - for (const [socket, from] of Object.entries(n.inputs)) - links.push({ - id: `l_${from.node}_${from.socket}_${n.id}_${socket}`, - fromNodeId: from.node, - fromSocketId: `${from.node}:${from.socket}`, - toNodeId: n.id, - toSocketId: `${n.id}:${socket}`, - muted: false, - extensions: {}, - }); - return { - graphId: GRAPH_ID, - catalogVersion: CATALOG_VERSION, - nodes, - links, - metadata: { layout: "ignored" }, - version: 1, - }; -} -test("catalog exhaustively mirrors all current contracts", () => { - for (const [key, semantic] of Object.entries(semanticCatalog)) { - assert.ok(Object.hasOwn(semantic, "version")); - assert.equal(semantic.version, key === "frame_out" ? 3 : 1); - assert.equal(nodeDefinitions[key].version, semantic.version); - assert.equal(descriptors[key].version, semantic.version); - } - assert.deepEqual( - Object.keys(semanticCatalog), - [ - "mesh", - "texture", - "frustum_cull", - "mesh_query", - "pipeline_registry", - "pipeline", - "fullscreen_copy", - "color_balance", - "exposure_contrast", - "saturation", - "channel_mixer", - "bloom_extract", - "bloom_blur", - "bloom_composite", - "luminance_edge", - "frame_out", - ], - ); - for (const c of Object.values(semanticCatalog)) { - assert.ok(c.execution); - assert.ok(c.inputs); - assert.ok(c.outputs); - assert.ok(c.parameters); - } - for (const [key, contract] of Object.entries(semanticCatalog)) - assert.deepEqual( - Object.keys(nodeDefinitions[key].parameters).sort(), - Object.keys(contract.parameters).sort(), - key, - ); - assert.equal(CATALOG_VERSION, 7); - assert.deepEqual(nodeDefinitions.pipeline.parameters, { - pipeline: { - type: "string", - default: { kind: "string", value: "gltf_standard" }, - }, - depthCompare: { - type: "string", - default: { kind: "string", value: "less_equal" }, - enum: ["never", "less", "equal", "less_equal", "greater", "not_equal", "greater_equal", "always"], - }, - depthWriteEnabled: { - type: "boolean", - default: { kind: "boolean", value: true }, - }, - clearDepth: { - type: "number", - default: { kind: "number", value: 1 }, - minimum: 0, - maximum: 1, - }, - clearColor: { - type: "color", - default: { kind: "color", value: [0.015, 0.02, 0.03, 1] }, - minimum: 0, - maximum: 1, - }, - }); - assert.deepEqual(nodeDefinitions.bloom_blur.parameters.direction.enum, [ - "horizontal", - "vertical", - ]); - assert.deepEqual(nodeDefinitions.texture.parameters.residency.enum, [ - "transient", - "persistent", - ]); - assert.deepEqual( - nodeDefinitions.frustum_cull.parameters.cameraSelection.enum, - ["active"], - ); - assert.deepEqual(nodeDefinitions.mesh_query.sockets.isVisible.value.default, { - kind: "boolean", - value: true, - }); - assert.equal(nodeDefinitions.mesh_query.sockets.isVisible.showValue, true); +import { + CATALOG_VERSION, semanticCatalog, nodeDefinitions, descriptors, +} from "../static/render-graph/catalog.js"; - assert.deepEqual(nodeDefinitions.color_balance.ui.slice(0, 4), [ - { kind: "parameter", parameter: "mode" }, - { 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" } }, - { kind: "widget", widget: "grading-wheels", bindings: [ - { title: "Offset", scalar: "offset", color: "offsetColor" }, - { title: "Power", scalar: "power", color: "powerColor" }, - { title: "Slope", scalar: "slope", color: "slopeColor" }, - ], visibleWhen: { parameter: "mode", equals: "offset_power_slope" } }, - { kind: "parameter", parameter: "factor" }, - ]); - for (const name of ["liftColor", "gammaColor", "gainColor", "offsetColor", "powerColor", "slopeColor"]) - assert.deepEqual(nodeDefinitions.color_balance.parameters[name].default, { kind: "color", value: [1, 1, 1, 1] }); - assert.deepEqual(nodeDefinitions.channel_mixer.parameters.redOutput, { - type: "vector", default: { kind: "vector", value: [1, 0, 0] }, minimum: -2, maximum: 2, +test("catalog v8 exposes the final mesh, pipeline, and typed-expression contracts", () => { + assert.equal(CATALOG_VERSION, 8); + assert.deepEqual(semanticCatalog.mesh.outputs, { + mesh: { type: "mesh_data" }, type: { type: "u32x16" }, localAabb: { type: "local_aabb" }, }); -}); - -test("adapter validates and exactly lowers canonical pipeline controls and blur direction", () => { - const x = fixture(); - const pipeline = x.nodes.find((node) => node.id === "ground"); - assert.equal(pipeline.parameters.clearColor.kind, "color"); - assert.deepEqual( - adaptFxNodeSnapshot(x).nodes.find((node) => node.id === "ground").parameters, - { - pipeline: "ground_plane", - depthCompare: "less_equal", - depthWriteEnabled: true, - clearDepth: 1, - clearColor: [0.015, 0.02, 0.03, 1], - }, - ); - const schema = nodeDefinitions.bloom_blur.parameters; - const blur = structuredClone(x.nodes.find((node) => node.id === "frame_out")); - blur.id = "blur"; - blur.typeId = "bloom_blur"; - blur.parameters = { - direction: { kind: "string", value: "vertical" }, - radius: structuredClone(schema.radius.default), - }; - blur.sockets = Object.entries(nodeDefinitions.bloom_blur.sockets).map( - ([key, socket]) => ({ - key, - id: `blur:${key}`, - label: socket.title, - direction: socket.direction, - dataType: socket.type, - accepts: - socket.direction === "input" - ? socketTypes[socket.type].acceptsFrom - : [], - maxIncomingLinks: socket.maxIncomingLinks, - visible: socket.visible, - }), - ); - blur.typeVersion = descriptors.bloom_blur.version; - x.nodes.push(blur); - assert.deepEqual( - adaptFxNodeSnapshot(x).nodes.find((node) => node.id === "blur").parameters - .direction, - [0, 1], - ); - pipeline.parameters.clearColor.value[0] = 2; - assert.throws( - () => adaptFxNodeSnapshot(x), - (error) => error.code === "AUTHORING_PARAMETER", - ); - pipeline.parameters.clearColor.value = [0.015, 0.02, 0.03, 1]; - pipeline.parameters.clearColor.value = [0, 0, 0]; - assert.throws( - () => adaptFxNodeSnapshot(x), - (error) => error.code === "AUTHORING_PARAMETER", - ); - pipeline.parameters.clearColor.value = [0.015, 0.02, 0.03, 1]; - pipeline.parameters.clearColor.value = [0, 0, Number.NaN, 1]; - assert.throws( - () => adaptFxNodeSnapshot(x), - (error) => error.code === "AUTHORING_PARAMETER", - ); - pipeline.parameters.clearColor.value = [0.015, 0.02, 0.03, 1]; - const texture = x.nodes.find((node) => node.id === "hdr"); - texture.parameters.residency.value = "unknown"; - assert.throws( - () => adaptFxNodeSnapshot(x), - (error) => error.code === "AUTHORING_PARAMETER", - ); - texture.parameters.residency.value = "transient"; - pipeline.parameters.clearDepth.value = -1; - assert.throws( - () => adaptFxNodeSnapshot(x), - (error) => error.code === "AUTHORING_PARAMETER", - ); -}); - -test("adapter validates and lowers disconnected query socket defaults", () => { - const x = fixture(); - const query = x.nodes.find((node) => node.id === "query"); - const visible = query.sockets.find((socket) => socket.key === "isVisible"); - visible.defaultValue.value = false; - const ir = adaptFxNodeSnapshot(x); - assert.equal(visible.defaultValue.value, false); - const parameters = ir.nodes.find((node) => node.id === "query").parameters; - assert.equal(parameters.isVisible, undefined); - assert.equal(parameters.visibleDefault, false); - assert.equal(parameters.frustumCulledDefault, false); - visible.defaultValue = { kind: "number", value: 0 }; - assert.throws( - () => adaptFxNodeSnapshot(x), - (error) => error.code === "AUTHORING_SOCKET", - ); - delete visible.defaultValue; - assert.throws( - () => adaptFxNodeSnapshot(x), - (error) => error.code === "AUTHORING_SOCKET", - ); -}); -test("adapter lowers the authoring-safe camera selector to the Rust wire field", () => { - const ir = adaptFxNodeSnapshot(fixture()); - const parameters = ir.nodes.find((node) => node.id === "cull").parameters; - assert.deepEqual(parameters, { camera: "active" }); - assert.equal(parameters.cameraSelection, undefined); -}); -test("adapter deterministically emits the canonical schema, permits repeated types, omits muted links and maps sources", () => { - const x = fixture(), - a = adaptFxNodeSnapshot(x, 7); - x.nodes.reverse(); - x.links.reverse(); - assert.deepEqual(adaptFxNodeSnapshot(x, 7), a); - assert.equal(a.schemaVersion, 2); - assert.equal(a.graphId, GRAPH_ID); - assert.equal(a.nodes.filter((n) => n.executor.key === "texture").length, 2); - assert.ok( - Object.values(getSourceMap(a)).some((source) => source.input === "color"), - ); - x.links.find((l) => l.id === "l_pbr_double_color_frame_out_color").muted = true; - assert.equal( - adaptFxNodeSnapshot(x, 8).nodes.find((n) => n.id === "frame_out").inputs - .color, - undefined, - ); -}); -test("adapter rejects hostile shape, IDs, duplicates, catalog, sockets and type mismatches", () => { - const reject = (fn, code) => { - const x = fixture(); - fn(x); - assert.throws( - () => adaptFxNodeSnapshot(x), - (e) => e instanceof AuthoringGraphError && e.code === code, - ); - }; - reject((x) => (x.graphId = "bad"), "AUTHORING_CATALOG"); - reject((x) => (x.catalogVersion = 2), "AUTHORING_CATALOG"); - reject((x) => (x.nodes[0].id = "bad id"), "AUTHORING_ID"); - reject((x) => (x.nodes[1].id = x.nodes[0].id), "AUTHORING_ID_DUPLICATE"); - reject((x) => (x.nodes[0].typeId = "wat"), "AUTHORING_NODE_TYPE"); - reject((x) => (x.nodes[0].typeVersion = 2), "AUTHORING_NODE_INVALID"); - reject((x) => (x.nodes[0].sockets = []), "AUTHORING_SOCKET_SET"); - reject((x) => (x.links[0].toSocketId = "missing:x"), "AUTHORING_LINK"); - reject((x) => { - const link = x.links.find((l) => l.toSocketId === "frame_out:color"); - link.fromNodeId = "mesh"; - link.fromSocketId = "mesh:mesh"; - }, "AUTHORING_LINK_TYPE"); -}); -test("Frame Out has the exact v3 schema, defaults, UI, and strict authoring validation", () => { - const fields = ["surfaceFormat", "hdrEnabled", "toneMapper", "exposureStops", "outputTransfer", "scaleMode", "filter", "backgroundColor"]; - assert.equal(CATALOG_VERSION, 7); - assert.deepEqual(semanticCatalog.frame_out, { - version: 3, execution: "frame", inputs: { color: semanticCatalog.frame_out.inputs.color }, outputs: {}, - parameters: { surfaceFormat: "preferred", hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] }, - }); - assert.deepEqual(nodeDefinitions.frame_out.parameters, { - surfaceFormat: { type: "string", default: { kind: "string", value: "preferred" }, enum: ["preferred", "rgba8_unorm", "bgra8_unorm", "rgba16_float"] }, - hdrEnabled: { type: "boolean", default: { kind: "boolean", value: true } }, - toneMapper: { type: "string", default: { kind: "string", value: "aces" }, enum: ["aces", "reinhard", "none"] }, - exposureStops: { type: "number", default: { kind: "number", value: 0 }, minimum: -10, maximum: 10 }, - outputTransfer: { type: "string", default: { kind: "string", value: "srgb" }, enum: ["srgb", "linear"] }, - scaleMode: { type: "string", default: { kind: "string", value: "stretch" }, enum: ["stretch", "contain", "cover"] }, - filter: { type: "string", default: { kind: "string", value: "linear" }, enum: ["linear", "nearest"] }, - backgroundColor: { type: "color", default: { kind: "color", value: [0, 0, 0, 1] }, minimum: 0, maximum: 1 }, - }); - assert.deepEqual(nodeDefinitions.frame_out.ui, [ - { kind: "text", variant: "section", title: "Canvas Presentation" }, - { kind: "parameter", parameter: "surfaceFormat", title: "Surface Format" }, - { kind: "text", variant: "section", title: "Display Transform" }, - { kind: "parameter", parameter: "hdrEnabled", title: "HDR" }, - { kind: "parameter", parameter: "toneMapper", title: "Tone Mapper", visibleWhen: { parameter: "hdrEnabled", equals: true } }, - { kind: "parameter", parameter: "exposureStops", title: "Exposure", visibleWhen: { parameter: "hdrEnabled", equals: true } }, - { kind: "parameter", parameter: "outputTransfer", title: "Transfer" }, - { kind: "parameter", parameter: "scaleMode", title: "Scale" }, - { kind: "parameter", parameter: "filter" }, - { kind: "parameter", parameter: "backgroundColor", title: "Background", visibleWhen: { parameter: "scaleMode", equals: "contain" } }, - { kind: "socket", socket: "color" }, - ]); - const reject = (mutate, code, parameter) => { - const x = fixture(), n = x.nodes.find((node) => node.typeId === "frame_out"); - mutate(x, n); - assert.throws(() => adaptFxNodeSnapshot(x), (e) => e instanceof AuthoringGraphError && e.code === code && (!parameter || e.details.nodeId === n.id && e.details.parameter === parameter)); - }; - reject((x) => x.catalogVersion = 5, "AUTHORING_CATALOG"); - reject((x, n) => n.typeVersion = 2, "AUTHORING_NODE_INVALID"); - for (const field of fields) reject((x, n) => delete n.parameters[field], "AUTHORING_PARAMETER_SET"); - reject((x, n) => n.parameters.extra = { kind: "number", value: 0 }, "AUTHORING_PARAMETER_SET"); - for (const [field, value] of [ - ["surfaceFormat", "bad"], ["hdrEnabled", 1], ["toneMapper", "bad"], ["outputTransfer", "bad"], ["scaleMode", "bad"], ["filter", "bad"], - ["exposureStops", NaN], ["exposureStops", -10.01], ["exposureStops", 10.01], - ["backgroundColor", [0, 0, 0]], ["backgroundColor", [0, 0, Infinity, 1]], ["backgroundColor", [-0.01, 0, 0, 1]], ["backgroundColor", [0, 0, 0, 1.01]], - ]) reject((x, n) => n.parameters[field].value = value, "AUTHORING_PARAMETER", field); - for (const [hidden, value] of [["toneMapper", "bad"], ["exposureStops", Infinity]]) - reject((x, n) => { n.parameters.hdrEnabled.value = false; n.parameters[hidden].value = value; }, "AUTHORING_PARAMETER", hidden); - reject((x, n) => { n.parameters.scaleMode.value = "stretch"; n.parameters.backgroundColor.value = [2, 0, 0, 1]; }, "AUTHORING_PARAMETER", "backgroundColor"); -}); -test("adapter counts only active incoming links and reports socket overflow", () => { - const x = fixture(); - const active = x.links.find((link) => link.toSocketId === "frame_out:color"); - x.links.push({ - ...structuredClone(active), - id: "muted_duplicate", - muted: true, - }); - assert.doesNotThrow(() => adaptFxNodeSnapshot(x)); - x.links.push({ ...structuredClone(active), id: "active_overflow" }); - assert.throws( - () => adaptFxNodeSnapshot(x), - (error) => - error.code === "AUTHORING_LINK_INCOMING" && - error.details.socketId === "frame_out:color", - ); -}); -test("source map covers Rust fields, nested values, every input and is deeply frozen", () => { - const snapshot = fixture(); - snapshot.links.find((link) => link.toSocketId === "frame_out:color").muted = - true; - const ir = adaptFxNodeSnapshot(snapshot, 9), - map = getSourceMap(ir); - for (const path of [ - "schemaVersion", - "graphId", - "revision", - "nodes", - "nodes[0].id", - "nodes[0].state", - "nodes[0].executor.key", - "nodes[0].executor.version", - "nodes[0].parameters", - "nodes[0].inputs", - ]) - assert.ok(map[path], path); - for (const [index, node] of ir.nodes.entries()) - for (const input of Object.keys(semanticCatalog[node.executor.key].inputs)) - assert.ok(map[`nodes[${index}].inputs.${input}`]); - assert.ok( - Object.keys(map).some((path) => - /parameters\..+\[|parameters\..+\..+/.test(path), - ), - ); - const socket = Object.values(map).find((source) => source.kind === "socket"); - const unconnected = Object.values(map).find( - (source) => source.unconnected === true, - ); - const link = Object.values(map).find((source) => source.kind === "link"); - assert.ok(socket?.socketId && unconnected?.socketId); - assert.equal( - ir.nodes.find((node) => node.id === "frame_out").inputs.source, - undefined, - ); - for (const field of [ - "linkId", - "fromNodeId", - "fromSocketId", - "toNodeId", - "toSocketId", - "muted", - ]) - assert.ok(Object.hasOwn(link, field), field); - assert.ok(Object.isFrozen(map) && Object.isFrozen(link)); -}); -test("texture source maps identify every flat authored control", () => { - const snapshot = fixture(); - const hdr = snapshot.nodes.find((node) => node.id === "hdr"); - hdr.parameters.viewFormat.value = "rgba16_float"; - const ir = adaptFxNodeSnapshot(snapshot); - const index = ir.nodes.findIndex((node) => node.id === "hdr"); - const root = `nodes[${index}].parameters`; - const map = getSourceMap(ir); - const source = (parameter) => ({ - kind: "parameter", - nodeId: "hdr", - parameter, - }); - assert.deepEqual(map[`${root}.residency`], source("residency")); - assert.deepEqual(map[`${root}.texture`], { kind: "node", nodeId: "hdr" }); - for (const [path, parameter] of [ - ["dimension", "dimension"], - ["format", "format"], - ["extent", "extentMode"], - ["extent.kind", "extentMode"], - ["extent.depthOrArrayLayers", "depthOrArrayLayers"], - ["extent.width", "extentMode"], - ["extent.width.numerator", "relativeWidthNumerator"], - ["extent.width.denominator", "relativeWidthDenominator"], - ["extent.height", "extentMode"], - ["extent.height.numerator", "relativeHeightNumerator"], - ["extent.height.denominator", "relativeHeightDenominator"], - ["mipLevelCount", "mipLevelCount"], - ["sampleCount", "sampleCount"], - ["viewFormats", "viewFormat"], - ["viewFormats[0]", "viewFormat"], - ]) - assert.deepEqual(map[`${root}.texture.${path}`], source(parameter), path); - assert.ok(!Object.values(map).some((value) => value.parameter === "texture")); - - const absolute = fixture(); - absolute.nodes.find((node) => node.id === "hdr").parameters.extentMode.value = - "absolute"; - const absoluteIr = adaptFxNodeSnapshot(absolute); - const absoluteIndex = absoluteIr.nodes.findIndex((node) => node.id === "hdr"); - const absoluteMap = getSourceMap(absoluteIr); - assert.deepEqual( - absoluteMap[`nodes[${absoluteIndex}].parameters.texture.extent.width`], - source("absoluteWidth"), - ); - assert.deepEqual( - absoluteMap[`nodes[${absoluteIndex}].parameters.texture.extent.height`], - source("absoluteHeight"), - ); -}); -test("unsupported texture diagnostics map to their exact authored controls", () => { - const ir = adaptFxNodeSnapshot(fixture()); - const index = ir.nodes.findIndex((node) => node.id === "hdr"); - for (const [suffix, parameter] of [ - ["dimension", "dimension"], - ["mipLevelCount", "mipLevelCount"], - ["sampleCount", "sampleCount"], - ["extent.depthOrArrayLayers", "depthOrArrayLayers"], - ]) { - const path = `nodes[${index}].parameters.texture.${suffix}`; - const mapped = mapAuthoringDiagnostic( - ir, - new RendererError("GRAPH_UNSUPPORTED_FEATURE", { - message: "unsupported", - path, - }), - ); - assert.equal(mapped.path, path); - assert.deepEqual(mapped.source, { - kind: "parameter", - nodeId: "hdr", - parameter, - }); + assert.equal(semanticCatalog.mesh.version, 2); + assert.equal(nodeDefinitions.mesh.sockets.localAabb.title, "Local AABB"); + assert.equal(semanticCatalog.pipeline.version, 2); + assert.equal(semanticCatalog.pipeline.inputs.predicate.required, false); + for (const key of ["and", "xnor", "equals_f32", "greater_than_u32", "combine_vec4", + "separate_mat4", "combine_u32_bits", "separate_u32x16", "separate_local_aabb"]) + assert.equal(semanticCatalog[key].execution, "expression", key); + for (const [key, contract] of Object.entries(semanticCatalog)) { + assert.equal(nodeDefinitions[key].version, contract.version, key); + assert.equal(descriptors[key].version, contract.version, key); } }); -test("diagnostic mapper creates a frozen RendererError DTO with fallbacks and prefix matching", () => { - const ir = adaptFxNodeSnapshot(fixture()); - const original = new RendererError("GRAPH_INPUT", { - message: "bad", - field: "nodes[0].executor.key.more", - nested: { x: 1 }, - }); - const mapped = mapAuthoringDiagnostic(ir, original); - assert.notStrictEqual(mapped, original); - assert.equal(mapped.code, original.code); - assert.equal(mapped.source.kind, "node"); - assert.equal(mapped.diagnostic, undefined); - assert.ok(Object.isFrozen(mapped) && Object.isFrozen(mapped.details.nested)); - assert.equal(Object.isFrozen(original), false); - original.details.nested.x = 2; - assert.equal(mapped.details.nested.x, 1); - const unmatchedOriginal = new RendererError("GRAPH_INPUT", { - message: "unmapped", - path: "resources[0]", - }); - const unmatched = mapAuthoringDiagnostic(ir, unmatchedOriginal); - assert.notStrictEqual(unmatched, unmatchedOriginal); - assert.equal(unmatched.source, undefined); - assert.ok(Object.isFrozen(unmatched) && Object.isFrozen(unmatched.details)); - assert.equal(Object.isFrozen(unmatchedOriginal), false); + +test("current culling fixture uses type-bit predicates and final socket versions", () => { + const byId = Object.fromEntries(culling.nodes.map((node) => [node.id, node])); + assert.deepEqual(byId.cull.inputs.localAabb, { node: "mesh", socket: "localAabb" }); + assert.deepEqual(byId.type_words.inputs.value, { node: "mesh", socket: "type" }); + assert.equal(byId.ground.executor.version, 2); + assert.equal(byId.ground.inputs.predicate.node, "ground_final"); }); -test("controller keeps last-good through failures and only drops after successful switch", async () => { - let fail = false; - const calls = []; - const renderer = { - compileGraph: async (ir) => ({ - compiledId: [ir.revision, 1], - revision: ir.revision, - }), - switchCompiledGraph: async (id) => { - calls.push(["switch", id]); - if (fail) throw Error("switch"); - }, - dropCompiledGraph: async (id) => calls.push(["drop", id]), - }; - const c = new AuthoringController({ - renderer, - adapt: (_, revision) => ({ revision }), - }); - c.markDirty({}); - await c.apply(); - fail = true; - c.markDirty({}); - await assert.rejects(c.apply()); - assert.deepEqual(calls, [ - ["switch", [1, 1]], - ["switch", [2, 1]], - ]); - fail = false; - await c.apply(); - assert.deepEqual(calls.at(-1), ["drop", [1, 1]]); - await c.destroy(); -}); -test("controller shares in-flight apply", async () => { - let release; - const gate = new Promise((r) => (release = r)); - const c = new AuthoringController({ - adapt: (_, revision) => ({ revision }), - renderer: { - compileGraph: async (ir) => { - await gate; - return { compiledId: [ir.revision, 1], revision: ir.revision }; - }, - switchCompiledGraph: async () => {}, - dropCompiledGraph: async () => {}, - }, - }); - c.markDirty({}); - const a = c.apply(); - assert.strictEqual(c.apply(), a); - release(); - await a; -}); -test("controller retains mapped diagnostic while apply rejects the original and subscriptions agree", async () => { - const original = new RendererError("GRAPH_BAD", { - path: "nodes[0].id", - message: "bad", - }); - const states = []; - const c = new AuthoringController({ - adapt: (snapshot, revision) => adaptFxNodeSnapshot(snapshot, revision), - renderer: { - compileGraph: async () => { - throw original; - }, - switchCompiledGraph: async () => {}, - dropCompiledGraph: async () => {}, - }, - }); - c.subscribe((state) => states.push(state)); - c.markDirty(fixture()); - await assert.rejects(c.apply(), (error) => error === original); - assert.notStrictEqual(states.at(-1).error, original); - let subscribed; - c.subscribe((state) => { - subscribed = state; - })(); - assert.strictEqual(subscribed.error, states.at(-1).error); - await c.destroy(); -}); -test("apply after destroy does not compile and destroy returns one strict promise", async () => { - let compiles = 0; - const c = new AuthoringController({ - adapt: () => ({}), - renderer: { - compileGraph: async () => { - compiles++; - }, - dropCompiledGraph: async () => {}, - switchCompiledGraph: async () => {}, - }, - }); - c.markDirty({}); - const first = c.destroy(); - assert.strictEqual(c.destroy(), first); - assert.strictEqual(await c.apply(), null); - await first; - assert.equal(compiles, 0); + +test("removed architecture is absent from the authoring catalog", () => { + for (const removed of ["mesh_query", "pipeline_registry"]) + assert.equal(semanticCatalog[removed], undefined); + const serialized = JSON.stringify(semanticCatalog); + for (const removedSocket of ["isVisible", "localAabbs", "activation"]) + assert.equal(serialized.includes(`\"${removedSocket}\"`), false); }); diff --git a/tests/render-graph-presets.test.js b/tests/render-graph-presets.test.js index 212d7a0..d435ed6 100644 --- a/tests/render-graph-presets.test.js +++ b/tests/render-graph-presets.test.js @@ -3,304 +3,39 @@ import assert from "node:assert/strict"; import * as presets from "../static/render-graph/presets.js"; import { descriptors } from "../static/render-graph/catalog.js"; -const order = [ - "midnight", - "ember", - "hdr", - "culling", - "tone", - "contain", - "reinhard", - "linear", - "grading", - "edges", - "bloom", - "combined", -]; -const sequences = { - midnight: [ - ["ldr", "texture"], - ["depth", "texture"], - ["mesh", "mesh"], - ["query", "mesh_query"], - ["registry", "pipeline_registry"], - ["ground", "pipeline"], - ["pbr", "pipeline"], - ["pbr_double", "pipeline"], - ["frame_out", "frame_out"], - ], - ember: [ - ["ldr", "texture"], - ["depth", "texture"], - ["mesh", "mesh"], - ["query", "mesh_query"], - ["registry", "pipeline_registry"], - ["ground", "pipeline"], - ["pbr", "pipeline"], - ["pbr_double", "pipeline"], - ["frame_out", "frame_out"], - ], - hdr: [ - ["hdr", "texture"], - ["depth", "texture"], - ["mesh", "mesh"], - ["query", "mesh_query"], - ["registry", "pipeline_registry"], - ["ground", "pipeline"], - ["pbr", "pipeline"], - ["pbr_double", "pipeline"], - ["frame_out", "frame_out"], - ], - culling: [ - ["hdr", "texture"], - ["depth", "texture"], - ["mesh", "mesh"], - ["cull", "frustum_cull"], - ["query", "mesh_query"], - ["registry", "pipeline_registry"], - ["ground", "pipeline"], - ["pbr", "pipeline"], - ["pbr_double", "pipeline"], - ["frame_out", "frame_out"], - ], - tone: [ - ["hdr", "texture"], - ["depth", "texture"], - ["mesh", "mesh"], - ["query", "mesh_query"], - ["registry", "pipeline_registry"], - ["ground", "pipeline"], - ["pbr", "pipeline"], - ["pbr_double", "pipeline"], - ["frame_out", "frame_out"], - ], - grading: [ - ["balance_hdr", "texture"], ["exposure_hdr", "texture"], ["saturation_hdr", "texture"], ["mixer_hdr", "texture"], - ["hdr", "texture"], ["depth", "texture"], ["mesh", "mesh"], ["query", "mesh_query"], ["registry", "pipeline_registry"], - ["ground", "pipeline"], ["pbr", "pipeline"], ["pbr_double", "pipeline"], ["balance", "color_balance"], ["exposure", "exposure_contrast"], - ["saturation", "saturation"], ["mixer", "channel_mixer"], ["frame_out", "frame_out"], - ], - edges: [ - ["edge_hdr", "texture"], - ["hdr", "texture"], - ["depth", "texture"], - ["mesh", "mesh"], - ["query", "mesh_query"], - ["registry", "pipeline_registry"], - ["ground", "pipeline"], - ["pbr", "pipeline"], - ["pbr_double", "pipeline"], - ["edges", "luminance_edge"], - ["frame_out", "frame_out"], - ], - bloom: [ - ["half_a", "texture"], - ["half_b", "texture"], - ["half_c", "texture"], - ["composite_hdr", "texture"], - ["hdr", "texture"], - ["depth", "texture"], - ["mesh", "mesh"], - ["query", "mesh_query"], - ["registry", "pipeline_registry"], - ["ground", "pipeline"], - ["pbr", "pipeline"], - ["pbr_double", "pipeline"], - ["extract", "bloom_extract"], - ["blur_h", "bloom_blur"], - ["blur_v", "bloom_blur"], - ["composite", "bloom_composite"], - ["frame_out", "frame_out"], - ], - combined: [ - ["edge_hdr", "texture"], - ["half_a", "texture"], - ["half_b", "texture"], - ["half_c", "texture"], - ["composite_hdr", "texture"], - ["hdr", "texture"], - ["depth", "texture"], - ["mesh", "mesh"], - ["query", "mesh_query"], - ["registry", "pipeline_registry"], - ["ground", "pipeline"], - ["pbr", "pipeline"], - ["pbr_double", "pipeline"], - ["extract", "bloom_extract"], - ["blur_h", "bloom_blur"], - ["blur_v", "bloom_blur"], - ["composite", "bloom_composite"], - ["edges", "luminance_edge"], - ["frame_out", "frame_out"], - ], -}; -for (const name of ["contain", "reinhard", "linear"]) - sequences[name] = sequences.tone; - -test("presets have the exact canonical pipeline identities, schemas, and node sequences", () => { - assert.deepEqual(Object.keys(presets.renderGraphPresets), order); - assert.deepEqual( - order.map((name) => presets[name].graphId), - [ - "preset_midnight", - "preset_ember", - "preset_hdr_fullscreen", - "preset_gpu_culling", - "preset_tone", - "preset_contain", - "preset_reinhard", - "preset_linear", - "preset_grading", - "preset_edges", - "preset_bloom", - "preset_combined", - ], - ); - for (const name of order) { - const graph = presets[name]; - assert.deepEqual([graph.schemaVersion, graph.revision], [2, 1]); - assert.equal( - new Set(graph.nodes.map((node) => node.id)).size, - graph.nodes.length, - ); - assert.deepEqual( - graph.nodes.map((node) => [node.id, node.executor.key]), - sequences[name], - ); - assert.equal( - graph.nodes.filter((node) => node.executor.key === "frame_out").length, - 1, - ); - assert.ok( - graph.nodes.every( - (node) => !["surface_target", "present"].includes(node.executor.key), - ), - ); - assert.ok(!graph.nodes.some((node) => node.id === "copy")); - assert.ok( - graph.nodes.every( - (node) => node.executor.version === descriptors[node.executor.key].version, - ), - ); +test("all presets use current schemas, versions, and one frame output", () => { + assert.equal(Object.keys(presets.renderGraphPresets).length, 12); + for (const [name, graph] of Object.entries(presets.renderGraphPresets)) { + assert.deepEqual([graph.schemaVersion, graph.revision], [2, 1], name); + assert.equal(new Set(graph.nodes.map((node) => node.id)).size, graph.nodes.length, name); + assert.equal(graph.nodes.filter((node) => node.executor.key === "frame_out").length, 1, name); + for (const node of graph.nodes) + assert.equal(node.executor.version, descriptors[node.executor.key].version, `${name}:${node.id}`); } - const grading = presets.grading; - assert.deepEqual(grading.nodes.find((n) => n.id === "balance").parameters, { - mode: "lift_gamma_gain", factor: 1, lift: 0, liftColor: [1,1,1,1], - gamma: 1, gammaColor: [1,1,1,1], gain: 1, gainColor: [1,1,1,1], - offset: 0, offsetColor: [1,1,1,1], power: 1, powerColor: [1,1,1,1], - slope: 1, slopeColor: [1,1,1,1], - }); - assert.deepEqual(grading.nodes.find((n) => n.id === "exposure").parameters, { exposureStops: 0, contrast: 1, pivot: .18, factor: 1 }); - assert.deepEqual(grading.nodes.find((n) => n.id === "saturation").parameters, { saturation: 1, factor: 1 }); - assert.deepEqual(grading.nodes.find((n) => n.id === "mixer").parameters, { redOutput: [1,0,0], greenOutput: [0,1,0], blueOutput: [0,0,1], factor: 1 }); }); -test("presets preserve common mesh, texture, query, pipeline, culling and post wiring", () => { - const removed = [ - "texture_spec", - "scene_table", - "local_aabb_buffer", - "camera_frustum", - "visibility_flags", - ]; +test("presets classify visibility and material through type.words[0] predicates", () => { for (const [name, graph] of Object.entries(presets.renderGraphPresets)) { const byId = Object.fromEntries(graph.nodes.map((node) => [node.id, node])); - assert.deepEqual(byId.query.parameters, { - visiblePredicate: "required_true", - visibleDefault: true, - frustumCulledPredicate: name === "culling" ? "required_false" : "any", - frustumCulledDefault: false, - }); - assert.deepEqual(byId.query.inputs.mesh, { node: "mesh", socket: "mesh" }); - assert.deepEqual(byId.query.inputs.isVisible, { - node: "mesh", - socket: "isVisible", - }); - assert.deepEqual(byId.ground.inputs.mesh, { - node: "mesh", - socket: "mesh", - }); - assert.deepEqual(byId.ground.inputs.draws, { - node: "query", - socket: "draws", - }); - assert.deepEqual(byId.ground.inputs.depthTarget, { - node: "depth", - socket: "texture", - }); - assert.equal( - graph.nodes.filter((node) => node.executor.key === "pipeline_registry") - .length, - 1, - ); - const pipelines = graph.nodes.filter( - (node) => node.executor.key === "pipeline", - ); - assert.deepEqual( - pipelines.map((node) => node.parameters.pipeline), - ["ground_plane", "gltf_standard", "gltf_standard_double_sided"], - ); - for (const pipeline of pipelines) - assert.deepEqual(pipeline.inputs.activation, { - node: "registry", - socket: "activation", - }); - assert.deepEqual(byId.pbr.inputs.colorTarget, { - node: "ground", - socket: "color", - }); - assert.deepEqual(byId.pbr.inputs.depthTarget, { - node: "ground", - socket: "depth", - }); - assert.deepEqual(byId.pbr_double.inputs.colorTarget, { - node: "pbr", - socket: "color", - }); - assert.deepEqual(byId.pbr_double.inputs.depthTarget, { - node: "pbr", - socket: "depth", - }); - assert.ok( - graph.nodes - .filter((node) => node.executor.key === "texture") - .every((node) => node.parameters.texture.dimension === "d2"), - ); - assert.ok( - graph.nodes.every((node) => !removed.includes(node.executor.key)), - ); + assert.deepEqual(byId.type_words.inputs.value, { node: "mesh", socket: "type" }, name); + assert.deepEqual(byId.type_bits.inputs.value, { node: "type_words", socket: "word0" }, name); + const suffix = name === "culling" ? "_final" : "_class"; + assert.deepEqual(byId.ground.inputs.predicate, { node: `ground${suffix}`, socket: "value" }, name); + assert.deepEqual(byId.pbr.inputs.predicate, { node: name === "culling" ? "pbr_final" : "standard_class", socket: "value" }, name); + assert.deepEqual(byId.pbr_double.inputs.predicate, { node: name === "culling" ? "pbr_double_final" : "double_class", socket: "value" }, name); + for (const pipeline of [byId.ground, byId.pbr, byId.pbr_double]) { + assert.deepEqual(pipeline.inputs.mesh, { node: "mesh", socket: "mesh" }); + assert.equal(pipeline.executor.version, 2); + } } - const cull = Object.fromEntries( - presets.culling.nodes.map((node) => [node.id, node]), - ); - assert.deepEqual(cull.cull.parameters, { camera: "active" }); - assert.deepEqual(cull.cull.inputs, { - mesh: { node: "mesh", socket: "mesh" }, - localAabbs: { node: "mesh", socket: "localAabbs" }, +}); + +test("culling adds a local-AABB expression to each material predicate", () => { + const byId = Object.fromEntries(presets.culling.nodes.map((node) => [node.id, node])); + assert.deepEqual(byId.cull.inputs, { + mesh: { node: "mesh", socket: "mesh" }, localAabb: { node: "mesh", socket: "localAabb" }, }); - assert.deepEqual(cull.query.inputs.isFrustumCulled, { - node: "cull", - socket: "isFrustumCulled", - }); - for (const name of ["tone", "contain", "reinhard", "linear", "grading", "edges", "bloom", "combined"]) - assert.ok(!presets[name].nodes.some((node) => node.id === "copy")); - const finalSource = { - hdr: "pbr_double", - culling: "pbr_double", - tone: "pbr_double", - contain: "pbr_double", - reinhard: "pbr_double", - linear: "pbr_double", - grading: "mixer", - edges: "edges", - bloom: "composite", - combined: "edges", - midnight: "pbr_double", - ember: "pbr_double", - }; - for (const name of order) - assert.deepEqual(presets[name].nodes.at(-1).inputs.color, { - node: finalSource[name], - socket: "color", - }); + assert.deepEqual(byId.not_culled.inputs.operand, { node: "cull", socket: "isFrustumCulled" }); + for (const id of ["ground", "pbr", "pbr_double"]) + assert.equal(byId[id].inputs.predicate.node.endsWith("_final"), true); }); diff --git a/tests/renderer-client.test.js b/tests/renderer-client.test.js index 6a417ac..4fc4e9d 100644 --- a/tests/renderer-client.test.js +++ b/tests/renderer-client.test.js @@ -10,43 +10,41 @@ class WorkerMock extends EventTarget { reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); } } function fixture() { - const memory = new WebAssembly.Memory({initial:2, maximum:4, shared:true}); + const memory = new WebAssembly.Memory({initial:4, maximum:8, shared:true}); const header = new Int32Array(memory.buffer, 0, 16); - header.set([0x4e574159,1,1024,24,0,0]); + header.set([0x4e574159,2,1024,40,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)); + const loading=f.client.replaceSceneGlb(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]]}}); + f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[{handle:[7,3],defaultType:[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}); 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("replaceSceneGlb is opcode 1", async()=>{const f=fixture(),pending=f.client.replaceSceneGlb(new ArrayBuffer(8));f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();assert.equal(new Int32Array(f.memory.buffer,64,40)[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,40).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]); + const slot=new Int32Array(memory.buffer,64+160,40); + assert.deepEqual([...slot.slice(0,6)],[2,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); + const creating=mesh.createInstance(new Float32Array([1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]),{visible: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; + const {memory,worker}=fixture(); new Int32Array(memory.buffer)[1]=1; assert.throws(()=>new RendererClient({memory,ringPtr:0,worker}), /PROTOCOL_MISMATCH/); }); test("pending reply exists before ring publication", async () => { @@ -70,7 +68,7 @@ test("worker failures and dispose reject every pending operation", async () => { }); 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)); + const loading=client.replaceSceneGlb(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"); @@ -84,7 +82,7 @@ test("does not export handle constructors or internal mutation methods", () => { }); 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)); + const loading=f.client.replaceSceneGlb(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/); @@ -94,7 +92,7 @@ test("corrupt backlog closes and terminates the transport", async () => { test("import rejects immediately after disposal", async () => { const f=fixture(); f.client.dispose(); - await assert.rejects(f.client.importGlb(new ArrayBuffer(8)),/DISPOSED/); + await assert.rejects(f.client.replaceSceneGlb(new ArrayBuffer(8)),/DISPOSED/); assert.equal(f.worker.messages.length,0); }); test("import rejects when disposed during asynchronous source loading", async () => { @@ -103,7 +101,7 @@ test("import rejects when disposed during asynchronous source loading", async () let finishFetch; globalThis.fetch=()=>new Promise(resolve=>{finishFetch=resolve;}); try { - const loading=f.client.importGlb("model.glb"); + const loading=f.client.replaceSceneGlb("model.glb"); f.client.dispose(); finishFetch({arrayBuffer:async()=>new ArrayBuffer(8)}); await assert.rejects(loading,/DISPOSED/); @@ -117,7 +115,7 @@ test("compile transfers payload and waits for ready before opcode 7", async()=>{ const f=fixture(), pending=f.client.compileGraph({schemaVersion:2}); 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); + assert.equal(new Int32Array(f.memory.buffer,64,40)[1],7); f.worker.reply({type:"reply",request:1,ok:true,result:{compiledId:[2,3]}}); assert.deepEqual(await pending,{compiledId:[2,3]}); }); @@ -130,12 +128,12 @@ test("compile releases payload after success", async()=>{const f=fixture(),p=f.c 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("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,40);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:2};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("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,40).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,224,40).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/);}); diff --git a/tests/snapshot-bvh.test.js b/tests/snapshot-bvh.test.js index 666f789..b4a9882 100644 --- a/tests/snapshot-bvh.test.js +++ b/tests/snapshot-bvh.test.js @@ -5,37 +5,37 @@ 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]; +const componentCounts = [1, 1, 3, 3, 1, 1, 1, 1, 16, 3, 3, 16]; +const scalarTypes = [1, 1, 2, 2, 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 counts = [1, 1, 1, 1, ...Array(8).fill(instances)]; const offsets = []; - let cursor = 512; - for (let i = 0; i < 14; i++) { + let cursor = 448; + for (let i = 0; i < 12; 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); + control.set([0x504e5359, 1, 256, 3, 64, 2, 1, 2, 1, 0, 7, 0, 4, 1, 0, 0]); + control.set([2, 1, 1, ptr, cursor, 7, 0, 1, instances, 2, 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([0x32534452, 2, 64, cursor, 1, 7, 0, 12, 64, 32, 1, instances, 0x01020304, 3, 0, 0]); + for (let i = 0; i < 12; 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]); + stream(0)[0] = 4; stream(1)[0] = 2; + stream(2).set([-1, -1, -1]); stream(3).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); + stream(4)[i] = 10 + i; stream(5)[i] = 3; stream(6)[i] = 4; stream(7)[i] = 2; + stream(11)[i * 16] = 1; + stream(9).set([i * 4, -1, -1], i * 3); stream(10).set([i * 4 + 2, 1, 1], i * 3); } return { memory, control, ptr, cursor }; } @@ -84,7 +84,7 @@ function bvhSnapshot({ pickable = [1, 1], shifted = false } = {}) { 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), + instanceType: Uint32Array.from(pickable.flatMap(x => [x, ...Array(15).fill(0)])), 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]), }}; @@ -112,11 +112,11 @@ 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]); + ringHeader.set([0x4e574159, 2, 1024, 40]); 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-init", controlPtr: 0, controlVersion: 1, schemaVersion: 2 }); 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");