feat: replace mesh queries with typed predicates

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

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-28 20:37:44 +00:00
co-authored by heaust
parent c7ffb0543e
commit f6d6af7a17
36 changed files with 2793 additions and 6973 deletions
+4 -3
View File
@@ -6,7 +6,7 @@ use wasm_bindgen::prelude::*;
use renderer::app_setup::WebApp; use renderer::app_setup::WebApp;
use renderer::camera::Camera; use renderer::camera::Camera;
use renderer::message::WindowEvent; 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 as gpu_renderer;
use renderer::renderer::gpu_scene::vertex_layouts; use renderer::renderer::gpu_scene::vertex_layouts;
use renderer::renderer::scene::FrameMetadata; use renderer::renderer::scene::FrameMetadata;
@@ -193,8 +193,9 @@ impl EditorScene {
indices: Self::INDICES, indices: Self::INDICES,
pipeline: pipeline_index, pipeline: pipeline_index,
material: renderer::render_data::MaterialKey::DEFAULT, material: renderer::render_data::MaterialKey::DEFAULT,
flags: RenderFlags::VISIBLE, default_instance_type: InstanceType {
default_instance_flags: RenderFlags::VISIBLE, words: [1 | 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
default_transform: transform, default_transform: transform,
}) })
.expect("ground plane geometry is valid"); .expect("ground plane geometry is valid");
+5 -5
View File
@@ -2,12 +2,12 @@
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
pub const MAGIC: u32 = u32::from_le_bytes(*b"YAWN"); 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 CAPACITY: usize = 1024;
pub const SLOT_WORDS: usize = 24; pub const SLOT_WORDS: usize = 40;
pub const SLOT_BYTES: usize = 96; pub const SLOT_BYTES: usize = 160;
pub const HEADER_BYTES: usize = 64; 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_OPEN: u32 = 0;
const STATE_CORRUPT: u32 = 1; const STATE_CORRUPT: u32 = 1;
@@ -121,7 +121,7 @@ mod tests {
#[test] #[test]
fn malformed_slot_fails_closed() { fn malformed_slot_fails_closed() {
for (version, request, expected) in [ for (version, request, expected) in [
(2, 1, RingError::SlotVersion), (SLOT_VERSION + 1, 1, RingError::SlotVersion),
(SLOT_VERSION, 0, RingError::ZeroRequest), (SLOT_VERSION, 0, RingError::ZeroRequest),
] { ] {
let ring = CommandRing::new(); let ring = CommandRing::new();
+25 -6
View File
@@ -4,8 +4,8 @@ use gltf::Gltf;
use ultraviolet::{Mat4, Vec3}; use ultraviolet::{Mat4, Vec3};
use crate::render_data::{ use crate::render_data::{
InstanceHandle, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey, InstanceHandle, InstanceType, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform,
RenderData, RenderDataError, RenderFlags, PipelineKey, RenderData, RenderDataError,
}; };
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -549,8 +549,26 @@ pub fn install_imported(
indices: &geometry.indices, indices: &geometry.indices,
pipeline: pipelines[usize::from(geometry.double_sided)], pipeline: pipelines[usize::from(geometry.double_sided)],
material: geometry.material, material: geometry.material,
flags: RenderFlags::VISIBLE, default_instance_type: InstanceType {
default_instance_flags: RenderFlags::VISIBLE, 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, default_transform: transform,
})?; })?;
handles.insert(geometry.key, created.mesh); handles.insert(geometry.key, created.mesh);
@@ -570,10 +588,11 @@ pub fn install_imported(
.get(&occurrence.key) .get(&occurrence.key)
.ok_or_else(|| ImportError::InvalidPrimitive("occurrence has no geometry".into()))?; .ok_or_else(|| ImportError::InvalidPrimitive("occurrence has no geometry".into()))?;
if consumed.insert(occurrence.key, ()).is_some() { if consumed.insert(occurrence.key, ()).is_some() {
let instance_type = stage.mesh(mesh).unwrap().default_instance_type;
instance_handles.push(stage.create_instance( instance_handles.push(stage.create_instance(
mesh, mesh,
occurrence.transform, occurrence.transform,
RenderFlags::VISIBLE, instance_type,
)?); )?);
} }
let geometry = geometries let geometry = geometries
@@ -584,7 +603,7 @@ pub fn install_imported(
let point = transform.transform_point3(Vec3::from(*position)); let point = transform.transform_point3(Vec3::from(*position));
[point.x, point.y, point.z] [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 x in [local.min[0], local.max[0]] {
for y in [local.min[1], local.max[1]] { for y in [local.min[1], local.max[1]] {
for z in [local.min[2], local.max[2]] { for z in [local.min[2], local.max[2]] {
+82 -70
View File
@@ -4,7 +4,7 @@ mod range_allocator;
pub use handle::{InstanceHandle, MeshHandle}; pub use handle::{InstanceHandle, MeshHandle};
use std::{ use std::{
ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, Deref}, ops::Deref,
sync::atomic::{AtomicU64, Ordering}, sync::atomic::{AtomicU64, Ordering},
}; };
@@ -58,50 +58,36 @@ impl MaterialKey {
} }
} }
#[repr(transparent)] #[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct RenderFlags(u32); pub struct InstanceType {
pub words: [u32; 16],
}
impl RenderFlags { impl InstanceType {
pub const NONE: Self = Self(0); pub const VISIBLE_MASK: u32 = 1;
pub const VISIBLE: Self = Self(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 { pub const fn is_visible(self) -> bool {
Self(bits) self.words[0] & Self::VISIBLE_MASK != 0
} }
pub fn set_visible(&mut self, visible: bool) {
pub const fn bits(self) -> u32 { self.words[0] = (self.words[0] & !Self::VISIBLE_MASK) | visible as u32;
self.0
}
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
} }
} }
impl BitOr for RenderFlags { impl Default for InstanceType {
type Output = Self; fn default() -> Self {
fn bitor(self, rhs: Self) -> Self { Self::VISIBLE
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;
} }
} }
const _: [(); 64] = [(); std::mem::size_of::<InstanceType>()];
const _: [(); 4] = [(); std::mem::align_of::<InstanceType>()];
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct Aabb { pub struct Aabb {
pub min: [f32; 3], pub min: [f32; 3],
@@ -124,8 +110,7 @@ pub struct MeshCreateInfo<'a> {
pub indices: &'a [u32], pub indices: &'a [u32],
pub pipeline: PipelineKey, pub pipeline: PipelineKey,
pub material: MaterialKey, pub material: MaterialKey,
pub flags: RenderFlags, pub default_instance_type: InstanceType,
pub default_instance_flags: RenderFlags,
pub default_transform: ModelTransform, pub default_transform: ModelTransform,
} }
@@ -141,8 +126,8 @@ pub struct MeshView {
pub geometry: GeometryRange, pub geometry: GeometryRange,
pub pipeline: PipelineKey, pub pipeline: PipelineKey,
pub material: MaterialKey, pub material: MaterialKey,
pub flags: RenderFlags, pub default_instance_type: InstanceType,
pub aabb: Aabb, pub local_aabb: Aabb,
pub default_instance: InstanceHandle, pub default_instance: InstanceHandle,
} }
@@ -152,7 +137,7 @@ pub struct InstanceView {
pub mesh: MeshHandle, pub mesh: MeshHandle,
pub model: ModelTransform, pub model: ModelTransform,
pub normal: NormalMatrix, pub normal: NormalMatrix,
pub flags: RenderFlags, pub instance_type: InstanceType,
pub is_default: bool, pub is_default: bool,
} }
@@ -311,7 +296,7 @@ struct MeshSoa {
index_counts: Vec<u32>, index_counts: Vec<u32>,
pipeline_keys: Vec<PipelineKey>, pipeline_keys: Vec<PipelineKey>,
material_keys: Vec<MaterialKey>, material_keys: Vec<MaterialKey>,
flags: Vec<RenderFlags>, default_instance_types: Vec<InstanceType>,
aabb_mins: Vec<[f32; 3]>, aabb_mins: Vec<[f32; 3]>,
aabb_maxs: Vec<[f32; 3]>, aabb_maxs: Vec<[f32; 3]>,
default_instance_slots: Vec<u32>, default_instance_slots: Vec<u32>,
@@ -329,7 +314,7 @@ struct InstanceSoa {
normal_col_0: Vec<[f32; 3]>, normal_col_0: Vec<[f32; 3]>,
normal_col_1: Vec<[f32; 3]>, normal_col_1: Vec<[f32; 3]>,
normal_col_2: Vec<[f32; 3]>, normal_col_2: Vec<[f32; 3]>,
flags: Vec<RenderFlags>, instance_types: Vec<InstanceType>,
} }
pub struct RenderData { pub struct RenderData {
@@ -369,9 +354,9 @@ impl ReplacementStage {
&mut self, &mut self,
mesh: MeshHandle, mesh: MeshHandle,
model: ModelTransform, model: ModelTransform,
flags: RenderFlags, instance_type: InstanceType,
) -> Result<InstanceHandle, RenderDataError> { ) -> Result<InstanceHandle, RenderDataError> {
self.data.create_instance(mesh, model, flags) self.data.create_instance(mesh, model, instance_type)
} }
} }
@@ -595,7 +580,7 @@ impl RenderData {
}, },
info.pipeline, info.pipeline,
info.material, info.material,
info.flags, info.default_instance_type,
bounds, bounds,
default_instance, default_instance,
); );
@@ -604,7 +589,7 @@ impl RenderData {
mesh, mesh,
info.default_transform, info.default_transform,
normal, normal,
info.default_instance_flags, info.default_instance_type,
); );
self.revision = next_revision; self.revision = next_revision;
Ok(CreatedMesh { Ok(CreatedMesh {
@@ -617,19 +602,20 @@ impl RenderData {
&mut self, &mut self,
mesh: MeshHandle, mesh: MeshHandle,
model: ModelTransform, model: ModelTransform,
flags: RenderFlags, instance_type: InstanceType,
) -> Result<InstanceHandle, RenderDataError> { ) -> Result<InstanceHandle, RenderDataError> {
if !self.meshes.slots.contains(mesh.slot(), mesh.generation()) { if !self.meshes.slots.contains(mesh.slot(), mesh.generation()) {
return Err(RenderDataError::InvalidMeshHandle); return Err(RenderDataError::InvalidMeshHandle);
} }
let normal = normal_matrix(model)?; 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 next_revision = self.next_revision()?;
let required = self.instances.slots.required_len_for_prepare()?; let required = self.instances.slots.required_len_for_prepare()?;
self.instances.reserve(required)?; self.instances.reserve(required)?;
let prepared = self.instances.slots.prepare()?; let prepared = self.instances.slots.prepare()?;
let handle = InstanceHandle::from_parts(prepared.slot, prepared.generation); 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; self.revision = next_revision;
Ok(handle) Ok(handle)
} }
@@ -699,10 +685,10 @@ impl RenderData {
Ok(()) Ok(())
} }
pub fn set_mesh_flags( pub fn set_mesh_visible(
&mut self, &mut self,
handle: MeshHandle, handle: MeshHandle,
flags: RenderFlags, visible: bool,
) -> Result<(), RenderDataError> { ) -> Result<(), RenderDataError> {
if !self if !self
.meshes .meshes
@@ -711,16 +697,24 @@ impl RenderData {
{ {
return Err(RenderDataError::InvalidMeshHandle); return Err(RenderDataError::InvalidMeshHandle);
} }
let owned: Vec<u32> = self
.instances
.slots
.occupied()
.filter_map(|(slot, _)| (self.instances.mesh_handle(slot) == handle).then_some(slot))
.collect();
let next_revision = self.next_revision()?; 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; self.revision = next_revision;
Ok(()) Ok(())
} }
pub fn set_instance_flags( pub fn set_instance_type(
&mut self, &mut self,
handle: InstanceHandle, handle: InstanceHandle,
flags: RenderFlags, instance_type: InstanceType,
) -> Result<(), RenderDataError> { ) -> Result<(), RenderDataError> {
if !self if !self
.instances .instances
@@ -730,7 +724,25 @@ impl RenderData {
return Err(RenderDataError::InvalidInstanceHandle); return Err(RenderDataError::InvalidInstanceHandle);
} }
let next_revision = self.next_revision()?; 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; self.revision = next_revision;
Ok(()) Ok(())
} }
@@ -749,7 +761,7 @@ impl RenderData {
} }
let normal = normal_matrix(model)?; let normal = normal_matrix(model)?;
let mesh = self.instances.mesh_handle(handle.slot()); 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()?; let next_revision = self.next_revision()?;
self.instances.set_transform(handle.slot(), model, normal); self.instances.set_transform(handle.slot(), model, normal);
self.revision = next_revision; self.revision = next_revision;
@@ -890,7 +902,7 @@ impl MeshSoa {
index_counts: Vec::new(), index_counts: Vec::new(),
pipeline_keys: Vec::new(), pipeline_keys: Vec::new(),
material_keys: Vec::new(), material_keys: Vec::new(),
flags: Vec::new(), default_instance_types: Vec::new(),
aabb_mins: Vec::new(), aabb_mins: Vec::new(),
aabb_maxs: Vec::new(), aabb_maxs: Vec::new(),
default_instance_slots: 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.index_counts, target, "meshes")?;
reserve_vec(&mut self.pipeline_keys, target, "meshes")?; reserve_vec(&mut self.pipeline_keys, target, "meshes")?;
reserve_vec(&mut self.material_keys, target, "meshes")?; reserve_vec(&mut self.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_mins, target, "meshes")?;
reserve_vec(&mut self.aabb_maxs, target, "meshes")?; reserve_vec(&mut self.aabb_maxs, target, "meshes")?;
reserve_vec(&mut self.default_instance_slots, target, "meshes")?; reserve_vec(&mut self.default_instance_slots, target, "meshes")?;
@@ -925,7 +937,7 @@ impl MeshSoa {
geometry: GeometryRange, geometry: GeometryRange,
pipeline: PipelineKey, pipeline: PipelineKey,
material: MaterialKey, material: MaterialKey,
flags: RenderFlags, default_instance_type: InstanceType,
bounds: Aabb, bounds: Aabb,
default: InstanceHandle, default: InstanceHandle,
) { ) {
@@ -936,7 +948,7 @@ impl MeshSoa {
resize_column(&mut self.index_counts, len, 0); resize_column(&mut self.index_counts, len, 0);
resize_column(&mut self.pipeline_keys, len, PipelineKey::new(0)); resize_column(&mut self.pipeline_keys, len, PipelineKey::new(0));
resize_column(&mut self.material_keys, len, MaterialKey::DEFAULT); resize_column(&mut self.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_mins, len, [0.0; 3]);
resize_column(&mut self.aabb_maxs, len, [0.0; 3]); resize_column(&mut self.aabb_maxs, len, [0.0; 3]);
resize_column(&mut self.default_instance_slots, len, 0); resize_column(&mut self.default_instance_slots, len, 0);
@@ -948,7 +960,7 @@ impl MeshSoa {
self.index_counts[index] = geometry.index_count; self.index_counts[index] = geometry.index_count;
self.pipeline_keys[index] = pipeline; self.pipeline_keys[index] = pipeline;
self.material_keys[index] = material; self.material_keys[index] = material;
self.flags[index] = flags; self.default_instance_types[index] = default_instance_type;
self.aabb_mins[index] = bounds.min; self.aabb_mins[index] = bounds.min;
self.aabb_maxs[index] = bounds.max; self.aabb_maxs[index] = bounds.max;
self.default_instance_slots[index] = default.slot(); self.default_instance_slots[index] = default.slot();
@@ -968,8 +980,8 @@ impl MeshSoa {
}, },
pipeline: self.pipeline_keys[index], pipeline: self.pipeline_keys[index],
material: self.material_keys[index], material: self.material_keys[index],
flags: self.flags[index], default_instance_type: self.default_instance_types[index],
aabb: Aabb { local_aabb: Aabb {
min: self.aabb_mins[index], min: self.aabb_mins[index],
max: self.aabb_maxs[index], max: self.aabb_maxs[index],
}, },
@@ -994,7 +1006,7 @@ impl InstanceSoa {
normal_col_0: Vec::new(), normal_col_0: Vec::new(),
normal_col_1: Vec::new(), normal_col_1: Vec::new(),
normal_col_2: Vec::new(), normal_col_2: Vec::new(),
flags: Vec::new(), instance_types: Vec::new(),
}; };
soa.reserve(initial)?; soa.reserve(initial)?;
Ok(soa) Ok(soa)
@@ -1013,7 +1025,7 @@ impl InstanceSoa {
reserve_vec(&mut self.normal_col_0, target, "instances")?; reserve_vec(&mut self.normal_col_0, target, "instances")?;
reserve_vec(&mut self.normal_col_1, target, "instances")?; reserve_vec(&mut self.normal_col_1, target, "instances")?;
reserve_vec(&mut self.normal_col_2, 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")?; self.slots.reserve_for_len(target, "instances")?;
Ok(()) Ok(())
} }
@@ -1024,7 +1036,7 @@ impl InstanceSoa {
mesh: MeshHandle, mesh: MeshHandle,
model: ModelTransform, model: ModelTransform,
normal: NormalMatrix, normal: NormalMatrix,
flags: RenderFlags, instance_type: InstanceType,
) { ) {
let len = prepared.slot as usize + 1; let len = prepared.slot as usize + 1;
resize_column(&mut self.mesh_slots, len, 0); 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_0, len, [0.0; 3]);
resize_column(&mut self.normal_col_1, 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.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; let index = prepared.slot as usize;
self.mesh_slots[index] = mesh.slot(); self.mesh_slots[index] = mesh.slot();
self.mesh_generations[index] = mesh.generation(); self.mesh_generations[index] = mesh.generation();
self.flags[index] = flags; self.instance_types[index] = instance_type;
self.set_transform(prepared.slot, model, normal); self.set_transform(prepared.slot, model, normal);
self.slots.commit(prepared); self.slots.commit(prepared);
} }
@@ -1077,7 +1089,7 @@ impl InstanceSoa {
self.normal_col_1[index], self.normal_col_1[index],
self.normal_col_2[index], self.normal_col_2[index],
], ],
flags: self.flags[index], instance_type: self.instance_types[index],
is_default, is_default,
} }
} }
+35 -21
View File
@@ -16,8 +16,9 @@ fn info() -> MeshCreateInfo<'static> {
indices: &INDICES, indices: &INDICES,
pipeline: PipelineKey::new(7), pipeline: PipelineKey::new(7),
material: MaterialKey::new(11), material: MaterialKey::new(11),
flags: RenderFlags::from_bits_retain(2), default_instance_type: InstanceType {
default_instance_flags: RenderFlags::VISIBLE, words: [3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
default_transform: IDENTITY_MODEL_TRANSFORM, default_transform: IDENTITY_MODEL_TRANSFORM,
} }
} }
@@ -81,29 +82,42 @@ fn world_bounds_reject_projective_and_overflowing_transforms() {
} }
#[test] #[test]
fn default_instance_is_protected_and_flags_are_separate() { fn default_instance_is_protected_and_preserves_its_type() {
let mut data = data(); let mut data = data();
let created = data.create_mesh(info()).unwrap(); let created = data.create_mesh(info()).unwrap();
assert!(data.instance(created.default_instance).unwrap().is_default); assert!(data.instance(created.default_instance).unwrap().is_default);
assert_eq!(data.mesh(created.mesh).unwrap().flags.bits(), 2); assert_eq!(
data.mesh(created.mesh).unwrap().default_instance_type.words[0],
3
);
assert_eq!( assert_eq!(
data.mesh(created.mesh).unwrap().material, data.mesh(created.mesh).unwrap().material,
MaterialKey::new(11) MaterialKey::new(11)
); );
assert_eq!( assert_eq!(
data.instance(created.default_instance).unwrap().flags, data.instance(created.default_instance)
RenderFlags::VISIBLE .unwrap()
.instance_type,
info().default_instance_type
); );
assert_eq!( assert_eq!(
data.destroy_instance(created.default_instance), data.destroy_instance(created.default_instance),
Err(RenderDataError::CannotDestroyDefaultInstance) Err(RenderDataError::CannotDestroyDefaultInstance)
); );
data.set_mesh_flags(created.mesh, RenderFlags::NONE) data.set_mesh_visible(created.mesh, false).unwrap();
.unwrap();
assert_eq!(data.mesh(created.mesh).unwrap().flags, RenderFlags::NONE);
assert_eq!( assert_eq!(
data.instance(created.default_instance).unwrap().flags, data.mesh(created.mesh).unwrap().default_instance_type,
RenderFlags::VISIBLE 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 mut data = data();
let first = data.create_mesh(info()).unwrap(); let first = data.create_mesh(info()).unwrap();
let old_instance = data let old_instance = data
.create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) .create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
.unwrap(); .unwrap();
data.destroy_instance(old_instance).unwrap(); data.destroy_instance(old_instance).unwrap();
let replacement = data let replacement = data
.create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) .create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
.unwrap(); .unwrap();
assert_eq!(old_instance.slot(), replacement.slot()); assert_eq!(old_instance.slot(), replacement.slot());
assert_ne!(old_instance.generation(), replacement.generation()); 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 mut data = data();
let mesh = data.create_mesh(info()).unwrap(); let mesh = data.create_mesh(info()).unwrap();
let vacant = data let vacant = data
.create_instance(mesh.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) .create_instance(mesh.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
.unwrap(); .unwrap();
data.destroy_instance(vacant).unwrap(); data.destroy_instance(vacant).unwrap();
data.instances data.instances
@@ -188,7 +202,7 @@ fn all_storage_classes_grow_and_retired_slots_force_max_checked_append() {
instances: 1, instances: 1,
} }
); );
data.create_instance(mesh.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) data.create_instance(mesh.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
.unwrap(); .unwrap();
assert_eq!(data.capacities().instances, 2); assert_eq!(data.capacities().instances, 2);
@@ -302,7 +316,7 @@ fn aabb_supports_one_point_and_multiple_points() {
let mut data = data(); let mut data = data();
let mesh = data.create_mesh(one).unwrap(); let mesh = data.create_mesh(one).unwrap();
assert_eq!( assert_eq!(
data.mesh(mesh.mesh).unwrap().aabb, data.mesh(mesh.mesh).unwrap().local_aabb,
Aabb { Aabb {
min: point[0], min: point[0],
max: point[0] max: point[0]
@@ -310,7 +324,7 @@ fn aabb_supports_one_point_and_multiple_points() {
); );
let mesh = data.create_mesh(info()).unwrap(); let mesh = data.create_mesh(info()).unwrap();
assert_eq!( assert_eq!(
data.mesh(mesh.mesh).unwrap().aabb, data.mesh(mesh.mesh).unwrap().local_aabb,
Aabb { Aabb {
min: [-1.0, -2.0, -3.0], min: [-1.0, -2.0, -3.0],
max: [4.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); assert_eq!(data.instance(mesh.default_instance).unwrap(), old);
let count = data.instance_count(); let count = data.instance_count();
assert_eq!( assert_eq!(
data.create_instance(mesh.mesh, invalid, RenderFlags::NONE), data.create_instance(mesh.mesh, invalid, InstanceType::ZERO),
Err(RenderDataError::InvalidTransform) Err(RenderDataError::InvalidTransform)
); );
assert_eq!(data.instance_count(), count); 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 first = data.create_mesh(info()).unwrap();
let second = data.create_mesh(info()).unwrap(); let second = data.create_mesh(info()).unwrap();
let first_extra = data let first_extra = data
.create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) .create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
.unwrap(); .unwrap();
let second_extra = data let second_extra = data
.create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) .create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
.unwrap(); .unwrap();
data.destroy_instance(first_extra).unwrap(); data.destroy_instance(first_extra).unwrap();
let reused = data let reused = data
.create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) .create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
.unwrap(); .unwrap();
assert_eq!(first_extra.slot(), reused.slot()); assert_eq!(first_extra.slot(), reused.slot());
data.destroy_mesh(first.mesh).unwrap(); data.destroy_mesh(first.mesh).unwrap();
+419 -212
View File
@@ -21,20 +21,13 @@ struct CullParameters {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")] #[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 { struct PipelineParameters {
pipeline: String, pipeline: String,
depth_compare: CompareFunction, depth_compare: CompareFunction,
depth_write_enabled: bool, depth_write_enabled: bool,
clear_depth: f32, clear_depth: f32,
clear_color: [f64; 4], clear_color: [f64; 4],
predicate_default: bool,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -146,7 +139,6 @@ struct OutputKey(usize, u16);
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct BoundInput { struct BoundInput {
producer: OutputKey, producer: OutputKey,
active: bool,
} }
#[derive(Clone)] #[derive(Clone)]
struct DependencyEdge { struct DependencyEdge {
@@ -232,15 +224,6 @@ fn validate_name_grammar(s: &str, path: impl Into<String>) -> 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<CompiledGraph, GraphError> { pub fn parse_and_compile(bytes: &[u8]) -> Result<CompiledGraph, GraphError> {
if bytes.len() > MAX_JSON_BYTES { if bytes.len() > MAX_JSON_BYTES {
return Err(GraphError::new( return Err(GraphError::new(
@@ -424,6 +407,89 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
$variant $variant
}}; }};
} }
fn literal(value: &serde_json::Value, ty: SemanticType) -> Option<TypedLiteral> {
let floats = |value: &serde_json::Value, n: usize| -> Option<Vec<f32>> {
let values = value.as_array()?;
if values.len() != n {
return None;
}
values
.iter()
.map(|value| value.as_f64().map(|value| value as f32))
.collect::<Option<Vec<_>>>()
.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::<Option<Vec<u32>>>()?
.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::<Option<Vec<_>>>()?;
match ty {
SemanticType::Mat2 => TypedLiteral::Mat2(
columns
.into_iter()
.map(|v| v.try_into().ok())
.collect::<Option<Vec<_>>>()?
.try_into()
.ok()?,
),
SemanticType::Mat3 => TypedLiteral::Mat3(
columns
.into_iter()
.map(|v| v.try_into().ok())
.collect::<Option<Vec<_>>>()?
.try_into()
.ok()?,
),
_ => TypedLiteral::Mat4(
columns
.into_iter()
.map(|v| v.try_into().ok())
.collect::<Option<Vec<_>>>()?
.try_into()
.ok()?,
),
}
}
SemanticType::MeshData | SemanticType::Texture => return None,
})
}
Ok(match node.executor.key.as_str() { Ok(match node.executor.key.as_str() {
"mesh" => empty!(NormalizedParameters::Mesh), "mesh" => empty!(NormalizedParameters::Mesh),
"frustum_cull" => { "frustum_cull" => {
@@ -607,37 +673,6 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
descriptor: normalize_texture(p.texture, &base)?, 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" => { "pipeline" => {
let p: PipelineParameters = let p: PipelineParameters =
serde_json::from_value(node.parameters.clone()).map_err(invalid)?; serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
@@ -673,8 +708,50 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
depth_write_enabled: p.depth_write_enabled, depth_write_enabled: p.depth_write_enabled,
clear_depth: p.clear_depth, clear_depth: p.clear_depth,
clear_color: p.clear_color, 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!(), _ => unreachable!(),
}) })
} }
@@ -846,13 +923,8 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
} }
for (i, n) in graph.nodes.iter().enumerate() { for (i, n) in graph.nodes.iter().enumerate() {
for input in contracts[i].inputs { for input in contracts[i].inputs {
let inactive = matches!(&params[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 !n.inputs.contains_key(input.name) {
if input.cardinality == InputCardinality::RequiredOne if input.cardinality == InputCardinality::RequiredOne {
|| (!inactive
&& matches!(params[i], NormalizedParameters::MeshQuery { .. })
&& input.name != "mesh")
{
return Err(error( return Err(error(
"GRAPH_SOCKET_CARDINALITY", "GRAPH_SOCKET_CARDINALITY",
"required input is missing", "required input is missing",
@@ -866,7 +938,6 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
let mut bound: Vec<BTreeMap<&str, BoundInput>> = vec![BTreeMap::new(); graph.nodes.len()]; let mut bound: Vec<BTreeMap<&str, BoundInput>> = vec![BTreeMap::new(); graph.nodes.len()];
for (i, n) in graph.nodes.iter().enumerate() { for (i, n) in graph.nodes.iter().enumerate() {
for input in contracts[i].inputs { for input in contracts[i].inputs {
let inactive = matches!(&params[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 { let Some(r) = n.inputs.get(input.name) else {
continue; continue;
}; };
@@ -884,97 +955,18 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
format!("nodes[{i}].inputs.{}", input.name), 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( bound[i].insert(
input.name, input.name,
BoundInput { BoundInput {
producer: OutputKey(pn, ordinal as u16), producer: OutputKey(pn, ordinal as u16),
active: !inactive,
}, },
); );
} }
} }
let root = |key: OutputKey,
bound: &Vec<BTreeMap<&str, BoundInput>>,
contracts: &Vec<&Contract>|
-> Option<OutputKey> {
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(); let mut edges = Vec::new();
for i in 0..graph.nodes.len() { for i in 0..graph.nodes.len() {
for (input_ordinal, input) in contracts[i].inputs.iter().enumerate() { 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 { edges.push(DependencyEdge {
from_node: b.producer.0, from_node: b.producer.0,
from_socket: contracts[b.producer.0].outputs[b.producer.1 as usize] from_socket: contracts[b.producer.0].outputs[b.producer.1 as usize]
@@ -1030,6 +1022,9 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
for i in 0..graph.nodes.len() { for i in 0..graph.nodes.len() {
if live.contains(&i) { if live.contains(&i) {
for (o, out) in contracts[i].outputs.iter().enumerate() { for (o, out) in contracts[i].outputs.iter().enumerate() {
if out.semantic_type.is_virtual() {
continue;
}
let key = OutputKey(i, o as u16); let key = OutputKey(i, o as u16);
if contracts[i].execution == ExecutionClass::Source if contracts[i].execution == ExecutionClass::Source
&& !referenced_outputs.contains(&key) && !referenced_outputs.contains(&key)
@@ -1586,7 +1581,6 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
for (i, o, out) in resource_meta { for (i, o, out) in resource_meta {
let key = OutputKey(i, o); let key = OutputKey(i, o);
let id = output_ids[&key]; let id = output_ids[&key];
let mesh = || output_ids[&root(key, &bound, &contracts).unwrap()];
let plan = match out.semantic_type { let plan = match out.semantic_type {
SemanticType::Texture if matches!(params[i], NormalizedParameters::Texture { .. }) => { SemanticType::Texture if matches!(params[i], NormalizedParameters::Texture { .. }) => {
if let NormalizedParameters::Texture { if let NormalizedParameters::Texture {
@@ -1615,19 +1609,19 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
} }
} }
SemanticType::MeshData => ResourcePlan::MeshData, SemanticType::MeshData => ResourcePlan::MeshData,
SemanticType::LocalAabbBuffer => ResourcePlan::LocalAabbBuffer { mesh: mesh() }, SemanticType::Bool
SemanticType::BooleanFlagBuffer => { | SemanticType::F32
if let OutputMetadata::BooleanFlag { flag } = out.metadata { | SemanticType::U32
ResourcePlan::BooleanFlagBuffer { mesh: mesh(), flag } | SemanticType::Vec2
} else { | SemanticType::Vec3
unreachable!() | 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 { resources.push(CompiledResource {
original_node_index: i as u32, original_node_index: i as u32,
@@ -1644,17 +1638,20 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
let _ = id; let _ = id;
} }
let mut executions = Vec::new(); let mut executions = Vec::new();
let mut node_execution = HashMap::new();
for &i in &order { for &i in &order {
if contracts[i].execution == ExecutionClass::Source { if matches!(
contracts[i].execution,
ExecutionClass::Source | ExecutionClass::Expression
) {
continue; continue;
} }
let ordinal = executions.len() as u32;
node_execution.insert(i, ordinal);
let input_resource = |s: &str| output_ids[&bound[i][s].producer]; let input_resource = |s: &str| output_ids[&bound[i][s].producer];
let mut inputs = Vec::new(); let mut inputs = Vec::new();
for s in contracts[i].inputs { 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 { inputs.push(CompiledSocketInput {
socket: s.name.into(), socket: s.name.into(),
resource: output_ids[&b.producer], resource: output_ids[&b.producer],
@@ -1665,6 +1662,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
.outputs .outputs
.iter() .iter()
.enumerate() .enumerate()
.filter(|(_, s)| !s.semantic_type.is_virtual())
.map(|(o, s)| CompiledSocketOutput { .map(|(o, s)| CompiledSocketOutput {
socket: s.name.into(), socket: s.name.into(),
resource: output_ids[&OutputKey(i, o as u16)], resource: output_ids[&OutputKey(i, o as u16)],
@@ -1672,58 +1670,6 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
.collect(); .collect();
let mut accesses = Vec::new(); let mut accesses = Vec::new();
let kind = match contracts[i].key { 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" => { "pipeline" => {
let color = output_ids[&OutputKey(i, 0)]; let color = output_ids[&OutputKey(i, 0)];
let depth = output_ids[&OutputKey(i, 1)]; let depth = output_ids[&OutputKey(i, 1)];
@@ -1747,15 +1693,11 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
} else { } else {
NormalizedDepthLoad::Load NormalizedDepthLoad::Load
}; };
for s in ["mesh", "draws", "activation"] { for s in ["mesh"] {
accesses.push(CompiledAccess { accesses.push(CompiledAccess {
socket: s.into(), socket: s.into(),
resource: input_resource(s), resource: input_resource(s),
mode: if s == "draws" { mode: AccessMode::SemanticRead,
AccessMode::IndirectRead
} else {
AccessMode::SemanticRead
},
}); });
} }
accesses.push(CompiledAccess { accesses.push(CompiledAccess {
@@ -1849,6 +1791,270 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
accesses, accesses,
}); });
} }
// Lower virtual values into a stable, device-independent expression plan.
let mut expression_plan = ExpressionPlan::default();
let mut expression_ids = HashMap::<OutputKey, ExprId>::new();
let mut expression_provenance = HashMap::<OutputKey, Option<u32>>::new();
let mut cse = HashMap::<String, ExprId>::new();
let mut requires_camera = false;
let mut mesh_root = None;
let mut intern = |semantic_type: SemanticType,
op: ExpressionOp,
origin: NodeOutputRef,
mesh_provenance: Option<u32>| {
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 &params[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 (ordinal, e) in executions.iter().enumerate() {
for o in &e.outputs { for o in &e.outputs {
resources[o.resource as usize].producer_execution = Some(ordinal as u32); resources[o.resource as usize].producer_execution = Some(ordinal as u32);
@@ -1917,6 +2123,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
culled_node_count: (graph.nodes.len() - live.len()) as u32, culled_node_count: (graph.nodes.len() - live.len()) as u32,
culled_resource_count: (all_outputs - output_ids.len()) as u32, culled_resource_count: (all_outputs - output_ids.len()) as u32,
transient_slot_count: transient, transient_slot_count: transient,
instance_traversal,
}) })
} }
+278 -318
View File
@@ -1,27 +1,35 @@
use super::MeshFlag; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum SemanticType { pub enum SemanticType {
MeshData, MeshData,
Texture, Texture,
LocalAabbBuffer, Bool,
BooleanFlagBuffer, F32,
PipelineIndexStream, U32,
PipelineActivation, Vec2,
DrawStream, 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)] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ExecutionClass { pub enum ExecutionClass {
Source, Source,
CpuPreparation, Expression,
Compute,
Render, Render,
Frame, Frame,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FullscreenPolicy { pub enum FullscreenPolicy {
Copy, Copy,
@@ -29,21 +37,18 @@ pub enum FullscreenPolicy {
BloomExtract, BloomExtract,
BloomComposite, BloomComposite,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum InputCardinality { pub enum InputCardinality {
RequiredOne, RequiredOne,
OptionalOne, OptionalOne,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "kind", content = "types", rename_all = "snake_case")] #[serde(tag = "kind", content = "types", rename_all = "snake_case")]
pub enum TypeConstraint { pub enum TypeConstraint {
Exact(SemanticType), Exact(SemanticType),
OneOf(&'static [SemanticType]), OneOf(&'static [SemanticType]),
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum InputRole { pub enum InputRole {
@@ -54,15 +59,8 @@ pub enum InputRole {
SampledTexture, SampledTexture,
ColorTarget { location: u32 }, ColorTarget { location: u32 },
DepthTarget, 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)] #[derive(Clone, Copy, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct InputSocketContract { pub struct InputSocketContract {
@@ -71,15 +69,12 @@ pub struct InputSocketContract {
pub cardinality: InputCardinality, pub cardinality: InputCardinality,
pub role: InputRole, pub role: InputRole,
} }
#[derive(Clone, Copy, Debug, serde::Serialize)] #[derive(Clone, Copy, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct OutputSocketContract { pub struct OutputSocketContract {
pub name: &'static str, pub name: &'static str,
pub semantic_type: SemanticType, pub semantic_type: SemanticType,
pub metadata: OutputMetadata,
} }
#[derive(Clone, Debug, serde::Serialize)] #[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Contract { pub struct Contract {
@@ -94,329 +89,294 @@ pub struct Contract {
} }
use SemanticType::*; use SemanticType::*;
const R: InputCardinality = InputCardinality::RequiredOne;
const fn input( const O: InputCardinality = InputCardinality::OptionalOne;
const fn i(
name: &'static str, name: &'static str,
accepted: TypeConstraint, ty: SemanticType,
cardinality: InputCardinality, cardinality: InputCardinality,
role: InputRole, role: InputRole,
) -> InputSocketContract { ) -> InputSocketContract {
InputSocketContract { InputSocketContract {
name, name,
accepted, accepted: TypeConstraint::Exact(ty),
cardinality, cardinality,
role, role,
} }
} }
const fn o(name: &'static str, semantic_type: SemanticType) -> OutputSocketContract {
const fn output(
name: &'static str,
semantic_type: SemanticType,
metadata: OutputMetadata,
) -> OutputSocketContract {
OutputSocketContract { OutputSocketContract {
name, name,
semantic_type, semantic_type,
metadata,
} }
} }
const NONE_I: &[InputSocketContract] = &[];
const REQUIRED: InputCardinality = InputCardinality::RequiredOne; const NONE_O: &[OutputSocketContract] = &[];
const OPTIONAL: InputCardinality = InputCardinality::OptionalOne; const MESH_O: &[OutputSocketContract] = &[
const NONE_IN: &[InputSocketContract] = &[]; o("mesh", MeshData),
const NONE_OUT: &[OutputSocketContract] = &[]; o("type", U32x16),
const TEXTURE_OUT: &[OutputSocketContract] = &[output("texture", Texture, OutputMetadata::None)]; o("localAabb", LocalAabb),
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 CULLED_OUT: &[OutputSocketContract] = &[output( const TEXTURE_O: &[OutputSocketContract] = &[o("texture", Texture)];
"isFrustumCulled", const PIPE_I: &[InputSocketContract] = &[
BooleanFlagBuffer, i("mesh", MeshData, R, InputRole::SemanticRead),
OutputMetadata::BooleanFlag { i("predicate", Bool, O, InputRole::Expression),
flag: MeshFlag::IsFrustumCulled, i(
},
)];
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(
"colorTarget", "colorTarget",
TypeConstraint::Exact(Texture), Texture,
REQUIRED, R,
InputRole::ColorTarget { location: 0 }, InputRole::ColorTarget { location: 0 },
), ),
input( i("depthTarget", Texture, R, InputRole::DepthTarget),
"depthTarget",
TypeConstraint::Exact(Texture),
REQUIRED,
InputRole::DepthTarget,
),
]; ];
const FULLSCREEN_COPY_IN: &[InputSocketContract] = &[ const PIPE_O: &[OutputSocketContract] = &[o("color", Texture), o("depth", Texture)];
input( const CULL_I: &[InputSocketContract] = &[
"source", i("mesh", MeshData, R, InputRole::Expression),
TypeConstraint::Exact(Texture), i("localAabb", LocalAabb, R, InputRole::Expression),
REQUIRED, ];
InputRole::SampledTexture, const CULL_O: &[OutputSocketContract] = &[o("isFrustumCulled", Bool)];
), const COPY_I: &[InputSocketContract] = &[
input( i("source", Texture, R, InputRole::SampledTexture),
i(
"colorTarget", "colorTarget",
TypeConstraint::Exact(Texture), Texture,
REQUIRED, R,
InputRole::ColorTarget { location: 0 }, InputRole::ColorTarget { location: 0 },
), ),
]; ];
const BLOOM_COMPOSITE_IN: &[InputSocketContract] = &[ const BLOOM_I: &[InputSocketContract] = &[
input( i("source", Texture, R, InputRole::SampledTexture),
"source", i("bloom", Texture, R, InputRole::SampledTexture),
TypeConstraint::Exact(Texture), i(
REQUIRED,
InputRole::SampledTexture,
),
input(
"bloom",
TypeConstraint::Exact(Texture),
REQUIRED,
InputRole::SampledTexture,
),
input(
"colorTarget", "colorTarget",
TypeConstraint::Exact(Texture), Texture,
REQUIRED, R,
InputRole::ColorTarget { location: 0 }, InputRole::ColorTarget { location: 0 },
), ),
]; ];
const FRAME_OUT_IN: &[InputSocketContract] = &[input( const COLOR_O: &[OutputSocketContract] = &[o("color", Texture)];
"color", const FRAME_I: &[InputSocketContract] = &[i("color", Texture, R, InputRole::SampledTexture)];
TypeConstraint::Exact(Texture), macro_rules! ins { ($($n:literal:$t:ident),*) => { &[$(i($n,$t,O,InputRole::Expression)),*] } }
REQUIRED, macro_rules! outs { ($($n:literal:$t:ident),*) => { &[$(o($n,$t)),*] } }
InputRole::SampledTexture, 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] = &[ pub static CONTRACTS: &[Contract] = &[
Contract { c!("mesh", 2, Source, NONE_I, MESH_O, false, None),
key: "mesh", c!("texture", 1, Source, NONE_I, TEXTURE_O, false, None),
version: 1, c!("frustum_cull", 2, Expression, CULL_I, CULL_O, false, None),
execution: ExecutionClass::Source, c!("pipeline", 2, Render, PIPE_I, PIPE_O, false, None),
inputs: NONE_IN, ex!("and", ins!("left":Bool,"right":Bool), outs!("value":Bool)),
outputs: MESH_OUT, ex!("or", ins!("left":Bool,"right":Bool), outs!("value":Bool)),
inherently_observable: false, ex!("not", ins!("operand":Bool), outs!("value":Bool)),
fullscreen_policy: None, ex!("xor", ins!("left":Bool,"right":Bool), outs!("value":Bool)),
}, ex!("xnor", ins!("left":Bool,"right":Bool), outs!("value":Bool)),
Contract { ex!(
key: "texture", "greater_than_f32",
version: 1, ins!("left":F32,"right":F32),
execution: ExecutionClass::Source, outs!("value":Bool)
inputs: NONE_IN, ),
outputs: TEXTURE_OUT, ex!(
inherently_observable: false, "less_than_f32",
fullscreen_policy: None, ins!("left":F32,"right":F32),
}, outs!("value":Bool)
Contract { ),
key: "frustum_cull", ex!(
version: 1, "equals_f32",
execution: ExecutionClass::Compute, ins!("left":F32,"right":F32),
inputs: CULL_IN, outs!("value":Bool)
outputs: CULLED_OUT, ),
inherently_observable: false, ex!(
fullscreen_policy: None, "greater_than_u32",
}, ins!("left":U32,"right":U32),
Contract { outs!("value":Bool)
key: "mesh_query", ),
version: 1, ex!(
execution: ExecutionClass::Compute, "less_than_u32",
inputs: QUERY_IN, ins!("left":U32,"right":U32),
outputs: DRAW_OUT, outs!("value":Bool)
inherently_observable: false, ),
fullscreen_policy: None, ex!(
}, "equals_u32",
Contract { ins!("left":U32,"right":U32),
key: "pipeline_registry", outs!("value":Bool)
version: 1, ),
execution: ExecutionClass::CpuPreparation, ex!("separate_vec2", ins!("vector":Vec2), outs!("x":F32,"y":F32)),
inputs: REGISTRY_IN, ex!("combine_vec2", ins!("x":F32,"y":F32), outs!("vector":Vec2)),
outputs: ACTIVATION_OUT, ex!(
inherently_observable: false, "separate_vec3",
fullscreen_policy: None, ins!("vector":Vec3),
}, outs!("x":F32,"y":F32,"z":F32)
Contract { ),
key: "pipeline", ex!(
version: 1, "combine_vec3",
execution: ExecutionClass::Render, ins!("x":F32,"y":F32,"z":F32),
inputs: PIPELINE_IN, outs!("vector":Vec3)
outputs: PIPELINE_OUT, ),
inherently_observable: false, ex!(
fullscreen_policy: None, "separate_vec4",
}, ins!("vector":Vec4),
Contract { outs!("x":F32,"y":F32,"z":F32,"w":F32)
key: "fullscreen_copy", ),
version: 1, ex!(
execution: ExecutionClass::Render, "combine_vec4",
inputs: FULLSCREEN_COPY_IN, ins!("x":F32,"y":F32,"z":F32,"w":F32),
outputs: FULLSCREEN_COPY_OUT, outs!("vector":Vec4)
inherently_observable: false, ),
fullscreen_policy: Some(FullscreenPolicy::Copy), ex!(
}, "separate_mat2",
Contract { ins!("matrix":Mat2),
key: "color_balance", outs!("column0":Vec2,"column1":Vec2)
version: 1, ),
execution: ExecutionClass::Render, ex!(
inputs: FULLSCREEN_COPY_IN, "combine_mat2",
outputs: FULLSCREEN_COPY_OUT, ins!("column0":Vec2,"column1":Vec2),
inherently_observable: false, outs!("matrix":Mat2)
fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), ),
}, ex!(
Contract { "separate_mat3",
key: "exposure_contrast", ins!("matrix":Mat3),
version: 1, outs!("column0":Vec3,"column1":Vec3,"column2":Vec3)
execution: ExecutionClass::Render, ),
inputs: FULLSCREEN_COPY_IN, ex!(
outputs: FULLSCREEN_COPY_OUT, "combine_mat3",
inherently_observable: false, ins!("column0":Vec3,"column1":Vec3,"column2":Vec3),
fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), outs!("matrix":Mat3)
}, ),
Contract { ex!(
key: "saturation", "separate_mat4",
version: 1, ins!("matrix":Mat4),
execution: ExecutionClass::Render, outs!("column0":Vec4,"column1":Vec4,"column2":Vec4,"column3":Vec4)
inputs: FULLSCREEN_COPY_IN, ),
outputs: FULLSCREEN_COPY_OUT, ex!(
inherently_observable: false, "combine_mat4",
fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), ins!("column0":Vec4,"column1":Vec4,"column2":Vec4,"column3":Vec4),
}, outs!("matrix":Mat4)
Contract { ),
key: "channel_mixer", ex!(
version: 1, "separate_u32x16",
execution: ExecutionClass::Render, ins!("value":U32x16),
inputs: FULLSCREEN_COPY_IN, 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)
outputs: FULLSCREEN_COPY_OUT, ),
inherently_observable: false, ex!(
fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), "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),
Contract { outs!("value":U32x16)
key: "bloom_extract", ),
version: 1, ex!(
execution: ExecutionClass::Render, "separate_u32_bits",
inputs: FULLSCREEN_COPY_IN, ins!("value":U32),
outputs: FULLSCREEN_COPY_OUT, 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)
inherently_observable: false, ),
fullscreen_policy: Some(FullscreenPolicy::BloomExtract), ex!(
}, "combine_u32_bits",
Contract { 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),
key: "bloom_blur", outs!("value":U32)
version: 1, ),
execution: ExecutionClass::Render, ex!(
inputs: FULLSCREEN_COPY_IN, "separate_local_aabb",
outputs: FULLSCREEN_COPY_OUT, ins!("value":LocalAabb),
inherently_observable: false, outs!("min":Vec3,"max":Vec3)
fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), ),
}, c!(
Contract { "fullscreen_copy",
key: "bloom_composite", 1,
version: 1, Render,
execution: ExecutionClass::Render, COPY_I,
inputs: BLOOM_COMPOSITE_IN, COLOR_O,
outputs: FULLSCREEN_COPY_OUT, false,
inherently_observable: false, Some(FullscreenPolicy::Copy)
fullscreen_policy: Some(FullscreenPolicy::BloomComposite), ),
}, c!(
Contract { "color_balance",
key: "luminance_edge", 1,
version: 1, Render,
execution: ExecutionClass::Render, COPY_I,
inputs: FULLSCREEN_COPY_IN, COLOR_O,
outputs: FULLSCREEN_COPY_OUT, false,
inherently_observable: false, Some(FullscreenPolicy::HdrSameExtent)
fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), ),
}, c!(
Contract { "exposure_contrast",
key: "frame_out", 1,
version: 3, Render,
execution: ExecutionClass::Frame, COPY_I,
inputs: FRAME_OUT_IN, COLOR_O,
outputs: NONE_OUT, false,
inherently_observable: true, Some(FullscreenPolicy::HdrSameExtent)
fullscreen_policy: None, ),
}, 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> { pub fn contract(key: &str) -> Option<&'static Contract> {
CONTRACTS.iter().find(|contract| contract.key == key) CONTRACTS.iter().find(|c| c.key == key)
} }
+181
View File
@@ -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<ExprId>,
},
MatrixColumn {
matrix: ExprId,
index: u8,
},
MatrixConstruct {
columns: Vec<ExprId>,
},
TypeWord {
value: ExprId,
index: u8,
},
TypeConstruct {
words: Vec<ExprId>,
},
U32Bit {
value: ExprId,
index: u8,
},
U32Construct {
bits: Vec<ExprId>,
},
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<u32>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExpressionPlan {
pub expressions: Vec<Expression>,
}
#[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<PipelinePredicatePlan>,
pub requires_camera: bool,
}
+3 -1
View File
@@ -2,13 +2,15 @@
mod compiler; mod compiler;
mod contracts; mod contracts;
mod expression;
mod plan; mod plan;
mod registry; mod registry;
mod runtime; mod runtime;
mod schema; mod schema;
pub use compiler::{compile, mesh_predicate_matches, parse_and_compile}; pub use compiler::{compile, parse_and_compile};
pub use contracts::*; pub use contracts::*;
pub use expression::*;
pub use plan::*; pub use plan::*;
pub use registry::{CompiledGraphId, Registry}; pub use registry::{CompiledGraphId, Registry};
pub use runtime::*; pub use runtime::*;
+5 -31
View File
@@ -16,6 +16,8 @@ pub struct CompiledGraph {
pub culled_node_count: u32, pub culled_node_count: u32,
pub culled_resource_count: u32, pub culled_resource_count: u32,
pub transient_slot_count: u32, pub transient_slot_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_traversal: Option<InstanceTraversalPlan>,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@@ -47,22 +49,6 @@ pub enum ResourcePlan {
allocation: Option<AllocationRef>, allocation: Option<AllocationRef>,
}, },
MeshData, MeshData,
LocalAabbBuffer {
mesh: u32,
},
BooleanFlagBuffer {
mesh: u32,
flag: MeshFlag,
},
PipelineIndexStream {
mesh: u32,
},
PipelineActivation {
pipeline_indices: u32,
},
DrawStream {
mesh: u32,
},
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@@ -95,10 +81,6 @@ pub struct CompiledSocketOutput {
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum ExecutionKind { pub enum ExecutionKind {
CpuPreparation,
Compute {
work: ComputeWork,
},
Render { Render {
color_attachments: Vec<ColorAttachmentPlan>, color_attachments: Vec<ColorAttachmentPlan>,
depth_stencil: Option<DepthStencilAttachmentPlan>, depth_stencil: Option<DepthStencilAttachmentPlan>,
@@ -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)] #[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ColorAttachmentPlan { pub struct ColorAttachmentPlan {
@@ -196,17 +171,16 @@ pub enum NormalizedParameters {
FrustumCull { FrustumCull {
camera: ActiveCamera, camera: ActiveCamera,
}, },
MeshQuery { ExpressionDefaults {
visible_predicate: RuntimePredicate, defaults: Vec<TypedLiteral>,
frustum_culled_predicate: RuntimePredicate,
}, },
PipelineRegistry,
Pipeline { Pipeline {
pipeline: String, pipeline: String,
depth_compare: CompareFunction, depth_compare: CompareFunction,
depth_write_enabled: bool, depth_write_enabled: bool,
clear_depth: f32, clear_depth: f32,
clear_color: [f64; 4], clear_color: [f64; 4],
predicate_default: bool,
}, },
FullscreenCopy, FullscreenCopy,
ColorBalance { ColorBalance {
+302 -416
View File
@@ -166,12 +166,6 @@ pub struct RuntimeAllocationClass {
pub slots: Vec<RuntimeAllocationSlot>, pub slots: Vec<RuntimeAllocationSlot>,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MeshQueryRuntimeKey {
pub visible: RuntimePredicate,
pub frustum_culled: RuntimePredicate,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeExecution { pub struct RuntimeExecution {
pub execution: u32, pub execution: u32,
@@ -182,13 +176,13 @@ pub struct RuntimeExecution {
pub struct RuntimeAllocationPlan { pub struct RuntimeAllocationPlan {
pub classes: Vec<RuntimeAllocationClass>, pub classes: Vec<RuntimeAllocationClass>,
pub resource_allocations: Vec<Option<AllocationRef>>, pub resource_allocations: Vec<Option<AllocationRef>>,
pub query: MeshQueryRuntimeKey,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq)]
pub struct RuntimePlan { pub struct RuntimePlan {
pub allocations: RuntimeAllocationPlan, pub allocations: RuntimeAllocationPlan,
pub executions: Vec<RuntimeExecution>, pub executions: Vec<RuntimeExecution>,
pub instance_traversal: Option<InstanceTraversalPlan>,
pub surface: RuntimeSurfaceContract, pub surface: RuntimeSurfaceContract,
} }
@@ -381,11 +375,7 @@ fn valid_pipeline_name(name: &str) -> bool {
fn execution_supported(key: &str) -> bool { fn execution_supported(key: &str) -> bool {
contract(key).is_some_and(|contract| { contract(key).is_some_and(|contract| {
contract.fullscreen_policy.is_some() contract.fullscreen_policy.is_some() || matches!(key, "pipeline" | "frame_out")
|| matches!(
key,
"frustum_cull" | "mesh_query" | "pipeline_registry" | "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>( fn texture_descriptor<'a>(
graph: &'a CompiledGraph, graph: &'a CompiledGraph,
id: u32, id: u32,
@@ -704,181 +681,15 @@ fn validate_fullscreen_execution(
Ok(()) 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> { 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() { for (i, execution) in graph.executions.iter().enumerate() {
if !execution_supported(&execution.executor.key) { if !execution_supported(&execution.executor.key) {
return Err(error( return Err(error(
@@ -939,7 +750,6 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
} }
referenced.insert(access.resource); referenced.insert(access.resource);
} }
validate_compute_execution(graph, i, execution)?;
validate_fullscreen_execution(graph, i, execution, contract)?; validate_fullscreen_execution(graph, i, execution, contract)?;
for resource in referenced { for resource in referenced {
uses.get_mut(resource as usize) uses.get_mut(resource as usize)
@@ -1071,6 +881,291 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
Ok(()) 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<ExprId> = 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<ExprId> {
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( pub fn prepare_runtime_plan(
graph: &CompiledGraph, graph: &CompiledGraph,
surface: RuntimeSurfaceContract, surface: RuntimeSurfaceContract,
@@ -1095,35 +1190,13 @@ pub fn prepare_runtime_plan(
)); ));
} }
let mut frame_out_index = None; let mut frame_out_index = None;
let mut query = None;
let mut executions = Vec::with_capacity(graph.executions.len()); let mut executions = Vec::with_capacity(graph.executions.len());
for (i, execution) in graph.executions.iter().enumerate() { for (i, execution) in graph.executions.iter().enumerate() {
let path = format!("executions[{i}]"); let path = format!("executions[{i}]");
match execution.executor.key.as_str() { match execution.executor.key.as_str() {
"mesh_query" => { "pipeline" => {}
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" => {}
_ if contract(&execution.executor.key) _ if contract(&execution.executor.key)
.is_some_and(|contract| contract.fullscreen_policy.is_some()) => {} .is_some_and(|contract| contract.fullscreen_policy.is_some()) => {}
"frustum_cull" => {}
"frame_out" => { "frame_out" => {
if frame_out_index.replace(i).is_some() { if frame_out_index.replace(i).is_some() {
return Err(error( return Err(error(
@@ -1153,115 +1226,7 @@ pub fn prepare_runtime_plan(
"executions", "executions",
) )
})?; })?;
let query = query.ok_or_else(|| { validate_instance_traversal(graph)?;
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"),
));
}
}
for (i, execution) in graph.executions.iter().enumerate() { for (i, execution) in graph.executions.iter().enumerate() {
if execution.executor.key != "pipeline" { if execution.executor.key != "pipeline" {
@@ -1305,9 +1270,7 @@ pub fn prepare_runtime_plan(
format!("executions[{i}].kind"), format!("executions[{i}].kind"),
)); ));
}; };
let [mesh_input, draws_input, activation_input, color_input, depth_input] = let [mesh_input, color_input, depth_input] = execution.inputs.as_slice() else {
execution.inputs.as_slice()
else {
return Err(invalid( return Err(invalid(
"pipeline input shape mismatch", "pipeline input shape mismatch",
format!("executions[{i}].inputs"), format!("executions[{i}].inputs"),
@@ -1315,11 +1278,9 @@ pub fn prepare_runtime_plan(
}; };
if [ if [
mesh_input.socket.as_str(), mesh_input.socket.as_str(),
draws_input.socket.as_str(),
activation_input.socket.as_str(),
color_input.socket.as_str(), color_input.socket.as_str(),
depth_input.socket.as_str(), depth_input.socket.as_str(),
] != ["mesh", "draws", "activation", "colorTarget", "depthTarget"] ] != ["mesh", "colorTarget", "depthTarget"]
{ {
return Err(invalid( return Err(invalid(
"pipeline input sockets mismatch", "pipeline input sockets mismatch",
@@ -1394,9 +1355,7 @@ pub fn prepare_runtime_plan(
format!("executions[{i}].kind"), format!("executions[{i}].kind"),
)); ));
} }
let [mesh_access, draws_access, activation_access, color_access, depth_access] = let [mesh_access, color_access, depth_access] = execution.accesses.as_slice() else {
execution.accesses.as_slice()
else {
return Err(invalid( return Err(invalid(
"pipeline access shape mismatch", "pipeline access shape mismatch",
format!("executions[{i}].accesses"), format!("executions[{i}].accesses"),
@@ -1405,12 +1364,6 @@ pub fn prepare_runtime_plan(
if mesh_access.socket != "mesh" if mesh_access.socket != "mesh"
|| mesh_access.resource != mesh_input.resource || mesh_access.resource != mesh_input.resource
|| !matches!(mesh_access.mode, AccessMode::SemanticRead) || !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( return Err(invalid(
"pipeline semantic accesses mismatch", "pipeline semantic accesses mismatch",
@@ -1459,73 +1412,6 @@ pub fn prepare_runtime_plan(
format!("resources[{}].plan", mesh_input.resource), 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 [ for (output, target) in [
(color_output.resource, color_input.resource), (color_output.resource, color_input.resource),
(depth_output.resource, depth_input.resource), (depth_output.resource, depth_input.resource),
@@ -1969,9 +1855,9 @@ pub fn prepare_runtime_plan(
allocations: RuntimeAllocationPlan { allocations: RuntimeAllocationPlan {
classes, classes,
resource_allocations, resource_allocations,
query,
}, },
executions, executions,
instance_traversal: graph.instance_traversal.clone(),
surface, surface,
}) })
} }
-35
View File
@@ -106,41 +106,6 @@ pub struct TextureDescriptor {
pub view_formats: Vec<TextureFormat>, pub view_formats: Vec<TextureFormat>,
} }
#[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)] #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum CompareFunction { pub enum CompareFunction {
File diff suppressed because it is too large Load Diff
-56
View File
@@ -1,56 +0,0 @@
struct Params { planes: array<vec4<f32>, 6>, count: u32, visible_predicate: u32, frustum_predicate: u32, _pad: u32 }
struct Instance { model: mat4x4<f32>, n0: vec4<f32>, n1: vec4<f32>, n2: vec4<f32> }
struct Aabb { min: vec4<f32>, max: vec4<f32> }
struct Meta { index_count: u32, first_index: u32, base_vertex: i32, instance_index: u32 }
struct Command { index_count: u32, instance_count: u32, first_index: u32, base_vertex: i32, first_instance: u32 }
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read> instances: array<Instance>;
@group(0) @binding(2) var<storage, read> bounds: array<Aabb>;
@group(0) @binding(3) var<storage, read> authored_visible: array<u32>;
@group(0) @binding(4) var<storage, read> metadata: array<Meta>;
@group(0) @binding(5) var<storage, read_write> frustum_flags: array<u32>;
@group(0) @binding(6) var<storage, read_write> commands: array<Command>;
@compute @workgroup_size(64)
fn frustum_cull(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i >= params.count) { return; }
let b = bounds[i];
let center = (b.min.xyz + b.max.xyz) * 0.5;
let extent = (b.max.xyz - b.min.xyz) * 0.5;
let m = instances[i].model;
let wc = (m * vec4<f32>(center, 1.0)).xyz;
let ax = m[0].xyz * extent.x;
let ay = m[1].xyz * extent.y;
let az = m[2].xyz * extent.z;
var inside = 1u;
for (var p = 0u; p < 6u; p++) {
let plane = params.planes[p];
let radius = abs(dot(plane.xyz, ax)) + abs(dot(plane.xyz, ay)) + abs(dot(plane.xyz, az));
if (dot(plane.xyz, wc) + plane.w + radius < 0.0) { inside = 0u; }
}
frustum_flags[i] = 1u - inside;
}
fn matches(value: u32, predicate: u32) -> bool {
return predicate == 0u || (predicate == 1u && value != 0u) || (predicate == 2u && value == 0u);
}
@compute @workgroup_size(64)
fn mesh_query(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i >= params.count) { return; }
let draw_meta = metadata[i];
var selected = true;
if (params.visible_predicate == 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);
}
+13 -24
View File
@@ -30,7 +30,7 @@ fn encode_scene<'a, T: Scene>(
pass.set_vertex_buffer(4, t.slice(..)); pass.set_vertex_buffer(4, t.slice(..));
pass.set_index_buffer(i.slice(..), wgpu::IndexFormat::Uint32); pass.set_index_buffer(i.slice(..), wgpu::IndexFormat::Uint32);
for draw in &gpu.draws { for draw in &gpu.draws {
if !draw.effective_visible { if !draw.instance_type.is_visible() {
continue; continue;
} }
pass.set_pipeline(pipelines.get_pipeline(draw.pipeline)); pass.set_pipeline(pipelines.get_pipeline(draw.pipeline));
@@ -54,6 +54,7 @@ pub(crate) fn encode_compiled<T: Scene>(
gpu: &GpuSceneCache, gpu: &GpuSceneCache,
pipelines: &PipelineLibrary, pipelines: &PipelineLibrary,
materials: &MaterialResources, materials: &MaterialResources,
indirect_commands: &wgpu::Buffer,
mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>, mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
) -> Result<(), &'static str> { ) -> Result<(), &'static str> {
use crate::render_graph::{ExecutionKind, NormalizedColorLoad, NormalizedDepthLoad, StoreOp}; use crate::render_graph::{ExecutionKind, NormalizedColorLoad, NormalizedDepthLoad, StoreOp};
@@ -73,16 +74,8 @@ pub(crate) fn encode_compiled<T: Scene>(
.map(|s| &s.view) .map(|s| &s.view)
.ok_or(" allocation out of bounds") .ok_or(" allocation out of bounds")
}; };
for (execution_index, prepared) in active.executions.iter().enumerate() { for prepared in &active.executions {
let profile_id = &active.graph.executions[execution_index].id;
match prepared { 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 { PreparedExecution::Fullscreen {
execution, execution,
frame_out, frame_out,
@@ -159,6 +152,7 @@ pub(crate) fn encode_compiled<T: Scene>(
PreparedExecution::Pipeline { PreparedExecution::Pipeline {
execution, execution,
base, base,
predicate_ordinal,
variant, variant,
} => { } => {
let execution = active let execution = active
@@ -236,24 +230,19 @@ pub(crate) fn encode_compiled<T: Scene>(
pass.set_vertex_buffer(2, u.slice(..)); pass.set_vertex_buffer(2, u.slice(..));
pass.set_vertex_buffer(4, t.slice(..)); pass.set_vertex_buffer(4, t.slice(..));
pass.set_index_buffer(ix.slice(..), wgpu::IndexFormat::Uint32); pass.set_index_buffer(ix.slice(..), wgpu::IndexFormat::Uint32);
for draw in &gpu.draws { pass.set_vertex_buffer(3, inst.slice(..));
if draw.pipeline != *base { for (draw_index, draw) in gpu.draws.iter().enumerate() {
continue;
}
let slot = draw.instances.start as u64;
let start = slot
* std::mem::size_of::<crate::renderer::gpu_scene::GpuInstance>() as u64;
pass.set_vertex_buffer(3, inst.slice(start..start + 112));
pass.set_pipeline(variant); 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.set_bind_group(2, materials.group(draw.material), &[]);
} }
pass.draw_indexed_indirect( pass.draw_indexed_indirect(
gpu.indirect_commands indirect_commands,
.buffer crate::renderer::instance_traversal::command_offset(
.as_ref() *predicate_ordinal,
.ok_or("indirect command buffer missing")?, gpu.draws.len(),
slot * 20, draw_index,
),
); );
} }
} }
+218 -638
View File
@@ -1,10 +1,11 @@
use std::mem::size_of; use std::mem::size_of;
use bytemuck::{Pod, Zeroable};
use crate::{ use crate::{
render_data::{MaterialKey, MeshHandle, PipelineKey, RenderFlags}, render_data::{InstanceType, MaterialKey, MeshHandle, PipelineKey},
renderer::scene_frame::SceneFramePlan, renderer::scene_frame::SceneFramePlan,
}; };
use bytemuck::{Pod, Zeroable};
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq)] #[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq)]
@@ -23,7 +24,7 @@ pub struct DrawItem {
pub indices: std::ops::Range<u32>, pub indices: std::ops::Range<u32>,
pub base_vertex: i32, pub base_vertex: i32,
pub instances: std::ops::Range<u32>, pub instances: std::ops::Range<u32>,
pub effective_visible: bool, pub instance_type: InstanceType,
} }
#[repr(C)] #[repr(C)]
@@ -62,162 +63,142 @@ pub struct GpuScenePlan {
pub instances: Vec<GpuInstance>, pub instances: Vec<GpuInstance>,
pub draws: Vec<DrawItem>, pub draws: Vec<DrawItem>,
pub local_aabbs: Vec<GpuLocalAabb>, pub local_aabbs: Vec<GpuLocalAabb>,
pub effective_visibility: Vec<u32>, pub instance_types: Vec<[u32; 16]>,
pub draw_metadata: Vec<DrawSlotMetadata>, pub draw_metadata: Vec<DrawSlotMetadata>,
pub commands: Vec<DrawIndexedIndirect>,
}
fn visibility_matches(
predicate: crate::render_graph::TriStatePredicate,
mesh: RenderFlags,
instance: RenderFlags,
) -> bool {
let effective = mesh.contains(RenderFlags::VISIBLE) && instance.contains(RenderFlags::VISIBLE);
match predicate {
crate::render_graph::TriStatePredicate::Any => true,
crate::render_graph::TriStatePredicate::RequiredTrue => effective,
crate::render_graph::TriStatePredicate::RequiredFalse => !effective,
}
} }
impl GpuScenePlan { impl GpuScenePlan {
pub fn build(data: &SceneFramePlan) -> Result<Self, &'static str> { pub fn build(data: &SceneFramePlan) -> Result<Self, &'static str> {
self::GpuScenePlan::build_with_query( let mut p = Self::default();
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<Self, &'static str> {
let _ = query; // Packing is canonical; predicates are evaluated by the GPU.
let mut plan = Self::default();
let mut meshes: Vec<_> = data.meshes.iter().collect(); let mut meshes: Vec<_> = data.meshes.iter().collect();
meshes.sort_by_key(|mesh| { meshes.sort_by_key(|m| {
( (
mesh.pipeline.get(), m.pipeline.get(),
mesh.material.get(), m.material.get(),
mesh.handle.slot(), m.handle.slot(),
mesh.handle.generation(), m.handle.generation(),
) )
}); });
for mesh in meshes { for mesh in meshes {
let occurrences: Vec<_> = data.mesh_occurrence_indices[mesh.occurrence_range.clone()] let occurrences: Vec<_> = data.mesh_occurrence_indices[mesh.occurrence_range.clone()]
.iter() .iter()
.map(|&index| &data.occurrences[index]) .map(|&i| &data.occurrences[i])
.collect(); .collect();
if occurrences.is_empty() { if occurrences.is_empty() {
continue; continue;
} }
let vertex_start = plan.positions.len(); let vs = mesh.geometry.vertex_start as usize;
let source_start = mesh.geometry.vertex_start as usize; let ve = vs
let source_end = source_start
.checked_add(mesh.geometry.vertex_count as usize) .checked_add(mesh.geometry.vertex_count as usize)
.ok_or("vertex range overflow")?; .ok_or("vertex range overflow")?;
plan.positions.extend_from_slice( let vertex_start = p.positions.len();
data.positions p.positions
.get(source_start..source_end) .extend_from_slice(data.positions.get(vs..ve).ok_or("invalid vertex range")?);
.ok_or("invalid vertex range")?, p.normals
); .extend_from_slice(data.normals.get(vs..ve).ok_or("invalid normal range")?);
plan.normals.extend_from_slice( p.uvs
data.normals .extend_from_slice(data.uvs.get(vs..ve).ok_or("invalid uv range")?);
.get(source_start..source_end) p.tangents
.ok_or("invalid normal range")?, .extend_from_slice(data.tangents.get(vs..ve).ok_or("invalid tangent range")?);
); let first_index =
plan.uvs.extend_from_slice( u32::try_from(p.indices.len()).map_err(|_| "index start exceeds u32")?;
data.uvs let is = mesh.geometry.index_start as usize;
.get(source_start..source_end) let ie = is
.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
.checked_add(mesh.geometry.index_count as usize) .checked_add(mesh.geometry.index_count as usize)
.ok_or("index range overflow")?; .ok_or("index range overflow")?;
plan.indices.extend_from_slice( p.indices
data.indices .extend_from_slice(data.indices.get(is..ie).ok_or("invalid index range")?);
.get(source_index..source_index_end) for occurrence in occurrences {
.ok_or("invalid index range")?, let instance_index =
); u32::try_from(p.instances.len()).map_err(|_| "instance start exceeds u32")?;
for instance in occurrences { let m = &occurrence.model;
let instance_start = u32::try_from(plan.instances.len()) let det = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2])
.map_err(|_| "instance start exceeds u32")?;
let m = &instance.model;
let determinant = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2])
- m[1][0] * (m[0][1] * m[2][2] - m[2][1] * m[0][2]) - m[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]); + m[2][0] * (m[0][1] * m[1][2] - m[1][1] * m[0][2]);
plan.instances.push(GpuInstance { p.instances.push(GpuInstance {
model: instance.model, model: occurrence.model,
normal_0: [ normal_0: [
instance.normal[0][0], occurrence.normal[0][0],
instance.normal[0][1], occurrence.normal[0][1],
instance.normal[0][2], occurrence.normal[0][2],
if determinant < 0.0 { -1.0 } else { 1.0 }, if det < 0. { -1. } else { 1. },
], ],
normal_1: [ normal_1: [
instance.normal[1][0], occurrence.normal[1][0],
instance.normal[1][1], occurrence.normal[1][1],
instance.normal[1][2], occurrence.normal[1][2],
0.0, 0.,
], ],
normal_2: [ normal_2: [
instance.normal[2][0], occurrence.normal[2][0],
instance.normal[2][1], occurrence.normal[2][1],
instance.normal[2][2], occurrence.normal[2][2],
0.0, 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 = let base_vertex =
i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?; i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?;
let end = index_start p.draw_metadata.push(DrawSlotMetadata {
.checked_add(mesh.geometry.index_count)
.ok_or("draw index range overflow")?;
let effective_visible = mesh.flags.contains(RenderFlags::VISIBLE)
&& instance.flags.contains(RenderFlags::VISIBLE);
plan.local_aabbs.push(GpuLocalAabb {
min: [mesh.aabb.min[0], mesh.aabb.min[1], mesh.aabb.min[2], 0.],
max: [mesh.aabb.max[0], mesh.aabb.max[1], mesh.aabb.max[2], 0.],
});
plan.effective_visibility.push(effective_visible as u32);
plan.draw_metadata.push(DrawSlotMetadata {
index_count: mesh.geometry.index_count, index_count: mesh.geometry.index_count,
first_index: index_start, first_index,
base_vertex, base_vertex,
instance_index: instance_start, instance_index,
}); });
plan.commands.push(DrawIndexedIndirect { p.draws.push(DrawItem {
index_count: mesh.geometry.index_count,
instance_count: 0,
first_index: index_start,
base_vertex,
first_instance: 0,
});
plan.draws.push(DrawItem {
pipeline: mesh.pipeline, pipeline: mesh.pipeline,
material: mesh.material, material: mesh.material,
mesh: mesh.handle, 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, base_vertex,
instances: instance_start..instance_start + 1, instances: instance_index..instance_index + 1,
effective_visible, instance_type: occurrence.instance_type,
}); });
} }
} }
Ok(plan) Ok(p)
} }
} }
#[derive(Default)]
pub struct BufferSlot {
pub buffer: Option<wgpu::Buffer>,
capacity: u64,
}
#[derive(Default)]
pub struct GpuSceneCache {
revision: Option<u64>,
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<DrawItem>,
}
pub fn required_buffer_capacity( pub fn required_buffer_capacity(
current: u64, current: u64,
required: u64, required: u64,
@@ -229,92 +210,19 @@ pub fn required_buffer_capacity(
if required == 0 || current >= required { if required == 0 || current >= required {
return Ok(current); return Ok(current);
} }
let grown = current Ok(current
.checked_mul(2) .checked_mul(2)
.ok_or("buffer capacity overflow")? .ok_or("buffer capacity overflow")?
.max(1) .max(1)
.max(required); .max(required)
Ok(grown.min(maximum)) .min(maximum))
} }
pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 5] { fn logical_or_zero<'a, T: Pod>(values: &'a [T], zero: &'a T) -> &'a [u8] {
const INSTANCE_ATTRIBUTES: [wgpu::VertexAttribute; 7] = wgpu::vertex_attr_array![3 => Float32x4, 4 => Float32x4, 5 => Float32x4, 6 => Float32x4, 7 => Float32x4, 8 => Float32x4, 9 => Float32x4]; if values.is_empty() {
[ bytemuck::bytes_of(zero)
wgpu::VertexBufferLayout { } else {
array_stride: 12, bytemuck::cast_slice(values)
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3],
},
wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![1 => Float32x3],
},
wgpu::VertexBufferLayout {
array_stride: 8,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![2 => Float32x2],
},
wgpu::VertexBufferLayout {
array_stride: size_of::<GpuInstance>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &INSTANCE_ATTRIBUTES,
},
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<wgpu::Buffer>,
capacity: u64,
}
#[derive(Default)]
pub struct GpuSceneCache {
revision: Option<u64>,
pub positions: BufferSlot,
pub normals: BufferSlot,
pub uvs: BufferSlot,
pub tangents: BufferSlot,
pub indices: BufferSlot,
pub instances: BufferSlot,
pub local_aabbs: BufferSlot,
pub effective_visibility: BufferSlot,
pub draw_metadata: BufferSlot,
pub frustum_flags: BufferSlot,
pub indirect_commands: BufferSlot,
pub draws: Vec<DrawItem>,
compute: Option<CullingCompute>,
}
struct CullingCompute {
params: wgpu::Buffer,
bind_group: wgpu::BindGroup,
frustum_pipeline: wgpu::ComputePipeline,
query_pipeline: wgpu::ComputePipeline,
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct CullingParams {
planes: [[f32; 4]; 6],
count: u32,
visible_predicate: u32,
frustum_predicate: u32,
_pad: u32,
}
fn predicate_code(value: crate::render_graph::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,
} }
} }
@@ -324,73 +232,40 @@ impl GpuSceneCache {
device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, queue: &wgpu::Queue,
data: &SceneFramePlan, 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> { ) -> Result<(), String> {
if self.revision == Some(data.revision) { if self.revision == Some(data.revision) {
return Ok(()); return Ok(());
} }
let plan = GpuScenePlan::build_with_query(data, query).map_err(str::to_owned)?; let p = GpuScenePlan::build(data).map_err(str::to_owned)?;
if plan.draws.is_empty() { let max = device.limits().max_buffer_size;
self.draws.clear(); fn bytes<T>(v: &[T]) -> Result<u64, String> {
self.revision = Some(data.revision); (v.len() as u64)
return Ok(());
}
let maximum = device.limits().max_buffer_size;
fn bytes<T>(values: &[T]) -> Result<u64, String> {
u64::try_from(values.len())
.map_err(|_| "buffer length overflow".to_owned())?
.checked_mul(size_of::<T>() as u64) .checked_mul(size_of::<T>() as u64)
.ok_or_else(|| "buffer byte size overflow".to_owned()) .ok_or("buffer byte size overflow".into())
} }
let required = [ let required = [
bytes(&plan.positions)?, bytes(&p.positions)?,
bytes(&plan.normals)?, bytes(&p.normals)?,
bytes(&plan.uvs)?, bytes(&p.uvs)?,
bytes(&plan.tangents)?, bytes(&p.tangents)?,
bytes(&plan.indices)?, bytes(&p.indices)?,
bytes(&plan.instances)?, bytes(&p.instances)?.max(size_of::<GpuInstance>() as u64),
bytes(&plan.local_aabbs)?, bytes(&p.local_aabbs)?.max(size_of::<GpuLocalAabb>() as u64),
bytes(&plan.effective_visibility)?, bytes(&p.instance_types)?.max(size_of::<[u32; 16]>() as u64),
bytes(&plan.draw_metadata)?, bytes(&p.draw_metadata)?.max(size_of::<DrawSlotMetadata>() as u64),
bytes(&plan.effective_visibility)?,
bytes(&plan.commands)?,
]; ];
let old = [ let slots = [
self.positions.capacity, &mut self.positions,
self.normals.capacity, &mut self.normals,
self.uvs.capacity, &mut self.uvs,
self.tangents.capacity, &mut self.tangents,
self.indices.capacity, &mut self.indices,
self.instances.capacity, &mut self.instances,
self.local_aabbs.capacity, &mut self.local_aabbs,
self.effective_visibility.capacity, &mut self.instance_types,
self.draw_metadata.capacity, &mut self.draw_metadata,
self.frustum_flags.capacity,
self.indirect_commands.capacity,
]; ];
let mut capacities = [0; 11]; let usage = [
for i in 0..11 {
capacities[i] =
required_buffer_capacity(old[i], required[i], maximum).map_err(str::to_owned)?;
}
let usages = [
wgpu::BufferUsages::VERTEX, wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::VERTEX, wgpu::BufferUsages::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::STORAGE,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDIRECT,
]; ];
let labels = [ let mut replaced = false;
"scene positions", for ((slot, &need), use_) in slots.into_iter().zip(&required).zip(usage) {
"scene normals", let cap = required_buffer_capacity(slot.capacity, need, max).map_err(str::to_owned)?;
"scene uvs", if cap != slot.capacity {
"scene tangents", slot.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
"scene indices", label: Some("scene buffer"),
"scene instances", size: cap,
"scene local aabbs", usage: use_ | wgpu::BufferUsages::COPY_DST,
"scene effective visibility",
"scene draw metadata",
"scene frustum flags",
"scene indirect commands",
];
let mut replacements: [Option<wgpu::Buffer>; 11] = Default::default();
for i in 0..11 {
if capacities[i] != old[i] {
replacements[i] = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some(labels[i]),
size: capacities[i],
usage: usages[i] | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false, mapped_at_creation: false,
})); }));
slot.capacity = cap;
replaced = true
} }
} }
let slots = [ let zero_instance = GpuInstance::zeroed();
&mut self.positions, let zero_aabb = GpuLocalAabb::zeroed();
&mut self.normals, let zero_type = [0u32; 16];
&mut self.uvs, let zero_metadata = DrawSlotMetadata::zeroed();
&mut self.tangents, let contents: [&[u8]; 9] = [
&mut self.indices, bytemuck::cast_slice(&p.positions),
&mut self.instances, bytemuck::cast_slice(&p.normals),
&mut self.local_aabbs, bytemuck::cast_slice(&p.uvs),
&mut self.effective_visibility, bytemuck::cast_slice(&p.tangents),
&mut self.draw_metadata, bytemuck::cast_slice(&p.indices),
&mut self.frustum_flags, logical_or_zero(&p.instances, &zero_instance),
&mut self.indirect_commands, logical_or_zero(&p.local_aabbs, &zero_aabb),
]; logical_or_zero(&p.instance_types, &zero_type),
for (i, slot) in slots.into_iter().enumerate() { logical_or_zero(&p.draw_metadata, &zero_metadata),
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 slots = [ let slots = [
&self.positions, &self.positions,
@@ -467,355 +313,89 @@ impl GpuSceneCache {
&self.indices, &self.indices,
&self.instances, &self.instances,
&self.local_aabbs, &self.local_aabbs,
&self.effective_visibility, &self.instance_types,
&self.draw_metadata, &self.draw_metadata,
&self.frustum_flags,
&self.indirect_commands,
]; ];
for (slot, contents) in slots.into_iter().zip(contents) { for (s, c) in slots.into_iter().zip(contents) {
if !contents.is_empty() { if !c.is_empty() {
queue.write_buffer( queue.write_buffer(s.buffer.as_ref().unwrap(), 0, c)
slot.buffer.as_ref().expect("nonempty slot allocated"),
0,
contents,
);
} }
} }
self.draws = plan.draws; if replaced {
self.rebuild_compute(device)?; self.buffer_epoch = self.buffer_epoch.wrapping_add(1).max(1)
}
self.draws = p.draws;
self.revision = Some(data.revision); self.revision = Some(data.revision);
Ok(()) Ok(())
} }
}
fn rebuild_compute(&mut self, device: &wgpu::Device) -> Result<(), String> { pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 5] {
if self.draws.is_empty() { const IA: [wgpu::VertexAttribute; 7] = wgpu::vertex_attr_array![3=>Float32x4,4=>Float32x4,5=>Float32x4,6=>Float32x4,7=>Float32x4,8=>Float32x4,9=>Float32x4];
self.compute = None; [
return Ok(()); wgpu::VertexBufferLayout {
} array_stride: 12,
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { step_mode: wgpu::VertexStepMode::Vertex,
label: Some("scene culling compute"), attributes: &wgpu::vertex_attr_array![0=>Float32x3],
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, wgpu::VertexBufferLayout {
min_binding_size: None, array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![1=>Float32x3],
}, },
count: None, wgpu::VertexBufferLayout {
}) array_stride: 8,
.collect::<Vec<_>>(), step_mode: wgpu::VertexStepMode::Vertex,
}); attributes: &wgpu::vertex_attr_array![2=>Float32x2],
let params = device.create_buffer(&wgpu::BufferDescriptor { },
label: Some("scene culling params"), wgpu::VertexBufferLayout {
size: size_of::<CullingParams>() as u64, array_stride: 112,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, step_mode: wgpu::VertexStepMode::Instance,
mapped_at_creation: false, attributes: &IA,
}); },
let buffers = [ wgpu::VertexBufferLayout {
&self.instances, array_stride: 16,
&self.local_aabbs, step_mode: wgpu::VertexStepMode::Vertex,
&self.effective_visibility, attributes: &wgpu::vertex_attr_array![10=>Float32x4],
&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);
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn mesh_query_source_guards_optional_flag_buffer_reads() { fn abi() {
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() {
assert_eq!(size_of::<GpuInstance>(), 112); assert_eq!(size_of::<GpuInstance>(), 112);
let value = GpuInstance {
model: [[1.0; 4]; 4],
normal_0: [1., 2., 3., 0.],
normal_1: [4., 5., 6., 0.],
normal_2: [7., 8., 9., 0.],
};
assert_eq!(value.normal_2[3], 0.0);
}
#[test]
fn capacity_grows_and_checks_limit() {
assert_eq!(required_buffer_capacity(8, 9, 32), Ok(16));
assert!(required_buffer_capacity(0, 33, 32).is_err());
}
#[test]
fn 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::<Vec<_>>(),
[12, 12, 8, 112, 16]
);
assert_eq!(
layouts[3]
.attributes
.iter()
.map(|attribute| attribute.shader_location)
.collect::<Vec<_>>(),
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<_>>(),
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::<GpuLocalAabb>(), 32); assert_eq!(size_of::<GpuLocalAabb>(), 32);
assert_eq!(size_of::<[u32; 16]>(), 64);
assert_eq!(size_of::<DrawSlotMetadata>(), 16); assert_eq!(size_of::<DrawSlotMetadata>(), 16);
assert_eq!(size_of::<DrawIndexedIndirect>(), 20); assert_eq!(size_of::<DrawIndexedIndirect>(), 20)
assert!(plan }
.commands #[test]
.iter() fn command_offset() {
.all(|command| command.first_instance == 0)); 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>(&[], &GpuInstance::zeroed()),
[0; 112]
);
assert_eq!(
logical_or_zero::<GpuLocalAabb>(&[], &GpuLocalAabb::zeroed()),
[0; 32]
);
assert_eq!(logical_or_zero::<[u32; 16]>(&[], &[0; 16]), [0; 64]);
assert_eq!(
logical_or_zero::<DrawSlotMetadata>(&[], &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));
} }
} }
+427
View File
@@ -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<String, String> {
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<String, String> {
let mut s = String::from("struct Params { planes: array<vec4<f32>,6>, instance_count:u32, pipeline_count:u32, _pad:vec2<u32> };\nstruct Inst{model:mat4x4<f32>,n0:vec4<f32>,n1:vec4<f32>,n2:vec4<f32>}; struct Aabb{min:vec4<f32>,max:vec4<f32>}; struct Type16{words:array<u32,16>}; 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)var<uniform>p:Params; @group(0) @binding(1)var<storage,read>instances:array<Inst>; @group(0) @binding(2)var<storage,read>aabbs:array<Aabb>; @group(0) @binding(3)var<storage,read>types:array<Type16>; @group(0) @binding(4)var<storage,read>metadata:array<Meta>; @group(0) @binding(5)var<storage,read_write>commands:array<Cmd>;\nstruct LocalAabb{min:vec3<f32>,max:vec3<f32>}; 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<f32>(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<f32>(v,1.0))<0.0);}outside=outside||all;}return outside;} @compute @workgroup_size(64) fn main(@builtin(global_invocation_id)gid:vec3<u32>){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<f32>({},{})", f(v[0])?, f(v[1])?),
TypedLiteral::Vec3(v) => {
format!("vec3<f32>({},{},{})", f(v[0])?, f(v[1])?, f(v[2])?)
}
TypedLiteral::Vec4(v) => format!(
"vec4<f32>({},{},{},{})",
f(v[0])?,
f(v[1])?,
f(v[2])?,
f(v[3])?
),
TypedLiteral::U32x16(v) => format!(
"Type16(array<u32,16>({}))",
v.iter()
.map(|x| format!("{x}u"))
.collect::<Vec<_>>()
.join(",")
),
TypedLiteral::LocalAabb { min, max } => format!(
"LocalAabb(vec3<f32>({},{},{}),vec3<f32>({},{},{}))",
f(min[0])?,
f(min[1])?,
f(min[2])?,
f(max[0])?,
f(max[1])?,
f(max[2])?
),
TypedLiteral::Mat2(v) => matrix_literal("mat2x2<f32>", v)?,
TypedLiteral::Mat3(v) => matrix_literal("mat3x3<f32>", v)?,
TypedLiteral::Mat4(v) => matrix_literal("mat4x4<f32>", 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::<Vec<_>>()
.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::<Vec<_>>()
.join(",")
),
ExpressionOp::TypeWord { value, index } => {
fixed_index(*index, 16, "u32x16 projection")?;
format!("{}.words[{}]", x(*value), index)
}
ExpressionOp::TypeConstruct { words } => format!(
"Type16(array<u32,16>({}))",
words.iter().map(|id| x(*id)).collect::<Vec<_>>().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::<Vec<_>>()
.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<u8> {
match ty {
SemanticType::Vec2 => Some(2),
SemanticType::Vec3 => Some(3),
SemanticType::Vec4 => Some(4),
_ => None,
}
}
fn matrix_width(ty: &SemanticType) -> Option<u8> {
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<f32>"),
SemanticType::Vec3 => Ok("vec3<f32>"),
SemanticType::Vec4 => Ok("vec4<f32>"),
SemanticType::Mat2 => Ok("mat2x2<f32>"),
SemanticType::Mat3 => Ok("mat3x3<f32>"),
SemanticType::Mat4 => Ok("mat4x4<f32>"),
_ => Err("invalid combine result type".into()),
}
}
fn matrix_literal<const N: usize>(name: &str, columns: &[[f32; N]; N]) -> Result<String, String> {
let values = columns
.iter()
.flatten()
.map(|v| f(*v))
.collect::<Result<Vec<_>, _>>()?;
Ok(format!("{name}({})", values.join(",")))
}
trait Pipe: Sized {
fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
f(self)
}
}
impl<T> 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::<DrawIndexedIndirect>() 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<Self, String> {
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::<Params>() 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::<DrawIndexedIndirect>() as u64))
.ok_or("indirect command size overflow")?
.max(std::mem::size_of::<DrawIndexedIndirect>() 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<f32>", &[[1.0, 2.0], [3.0, 4.0]]).unwrap(),
"mat2x2<f32>(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<f32>");
}
}
+221 -113
View File
@@ -11,12 +11,13 @@ use crate::{
command_ring::CommandRing, command_ring::CommandRing,
gltf::{install_imported, ModelBounds}, gltf::{install_imported, ModelBounds},
message::{camera_drag, CameraDrag, DrainEventError, MouseMessage, ResizeMessage, WindowEvent}, 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, renderer::scene::Scene,
}; };
pub mod executors; pub mod executors;
pub mod gpu_scene; pub mod gpu_scene;
pub mod instance_traversal;
pub mod material; pub mod material;
pub mod pipeline_library; pub mod pipeline_library;
pub mod profiler; pub mod profiler;
@@ -27,6 +28,43 @@ pub use pipeline_library::PipelineLibrary;
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; 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))] #[repr(C, align(16))]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
struct FullscreenUniforms { struct FullscreenUniforms {
@@ -748,12 +786,10 @@ struct GpuTextureSlot {
} }
enum PreparedExecution { enum PreparedExecution {
FrustumCull,
MeshQuery,
PipelineRegistry,
Pipeline { Pipeline {
execution: usize, execution: usize,
base: crate::render_data::PipelineKey, base: crate::render_data::PipelineKey,
predicate_ordinal: u32,
variant: wgpu::RenderPipeline, variant: wgpu::RenderPipeline,
}, },
Fullscreen { Fullscreen {
@@ -777,7 +813,7 @@ struct ActiveCompiledGraph {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum UploadGraph { enum UploadGraph {
Immediate, Immediate,
Compiled(crate::render_graph::MeshQueryRuntimeKey), Compiled(bool),
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[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 { 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( fn upload_query_for_render(pending: Option<UploadGraph>, active: Option<UploadGraph>) -> bool {
pending: Option<UploadGraph>,
active: Option<UploadGraph>,
) -> Option<crate::render_graph::MeshQueryRuntimeKey> {
match pending.or(active) { match pending.or(active) {
Some(UploadGraph::Compiled(query)) => Some(query), Some(UploadGraph::Compiled(value)) => value,
Some(UploadGraph::Immediate) | None => None, Some(UploadGraph::Immediate) | None => false,
} }
} }
fn resolve_culling_frustum( fn resolve_culling_frustum(
query: crate::render_graph::MeshQueryRuntimeKey, required: bool,
read: impl FnOnce() -> Option<Result<[[f32; 4]; 6], crate::camera::FrustumError>>, read: impl FnOnce() -> Option<Result<[[f32; 4]; 6], crate::camera::FrustumError>>,
) -> Result<Option<[[f32; 4]; 6]>, crate::render_graph::GraphError> { ) -> Result<Option<[[f32; 4]; 6]>, crate::render_graph::GraphError> {
if matches!( if !required {
query.frustum_culled,
crate::render_graph::RuntimePredicate::Any | crate::render_graph::RuntimePredicate::Never
) {
return Ok(None); return Ok(None);
} }
match read() { match read() {
@@ -871,13 +907,10 @@ fn resolve_culling_frustum(
fn update_validate_write_scene<S: scene::Scene>( fn update_validate_write_scene<S: scene::Scene>(
scene: &mut S, scene: &mut S,
queue: &wgpu::Queue, queue: &wgpu::Queue,
query: Option<crate::render_graph::MeshQueryRuntimeKey>, requires_camera: bool,
) -> Result<Option<[[f32; 4]; 6]>, crate::render_graph::GraphError> { ) -> Result<Option<[[f32; 4]; 6]>, crate::render_graph::GraphError> {
scene.update_cpu(); scene.update_cpu();
let planes = match query { let planes = resolve_culling_frustum(requires_camera, || scene.frustum_planes())?;
Some(query) => resolve_culling_frustum(query, || scene.frustum_planes())?,
None => None,
};
scene.write_uniforms(queue); scene.write_uniforms(queue);
Ok(planes) Ok(planes)
} }
@@ -935,7 +968,7 @@ fn resolve_switch_request(
1 => { 1 => {
let id = crate::render_graph::CompiledGraphId { slot, generation }; let id = crate::render_graph::CompiledGraphId { slot, generation };
// Resolve the registry entry here, before any GPU preparation or pending // 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)?; registry.get(id)?;
Ok(ResolvedSwitchRequest::Compiled(id)) Ok(ResolvedSwitchRequest::Compiled(id))
} }
@@ -980,42 +1013,29 @@ mod switch_request_tests {
use super::*; use super::*;
fn query(visible: crate::render_graph::RuntimePredicate) -> UploadGraph { fn graph(requires_camera: bool) -> UploadGraph {
UploadGraph::Compiled(crate::render_graph::MeshQueryRuntimeKey { UploadGraph::Compiled(requires_camera)
visible,
frustum_culled: crate::render_graph::RuntimePredicate::Any,
})
} }
#[test] #[test]
fn upload_selection_follows_the_graph_rendered_for_the_commit_frame() { fn upload_selection_follows_the_graph_rendered_for_the_commit_frame() {
use crate::render_graph::RuntimePredicate::{Any, RequiredFalse, RequiredTrue}; let selected = upload_query_for_render;
let selected = assert!(!selected(Some(graph(false)), Some(graph(true))));
|pending, active| upload_query_for_render(pending, active).map(|query| query.visible);
assert_eq!( assert_eq!(
selected(Some(query(RequiredFalse)), Some(query(RequiredTrue))), selected(Some(UploadGraph::Immediate), Some(graph(true))),
Some(RequiredFalse) false
); );
assert_eq!( assert_eq!(
selected(Some(UploadGraph::Immediate), Some(query(RequiredTrue))), selected(Some(UploadGraph::Immediate), Some(graph(true))),
None false
); );
assert_eq!( assert!(selected(None, Some(graph(true))));
selected(Some(UploadGraph::Immediate), Some(query(RequiredTrue))), assert!(!selected(None, Some(UploadGraph::Immediate)));
None assert!(!selected(None, 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));
} }
#[test] #[test]
fn frame_target_precedence_and_pending_resize_upload_are_exact() { fn frame_target_precedence_and_pending_resize_upload_are_exact() {
use crate::render_graph::RuntimePredicate::{RequiredFalse, RequiredTrue};
assert_eq!( assert_eq!(
select_frame_target_source(true, true, true), select_frame_target_source(true, true, true),
FrameTargetSource::PendingSwitch FrameTargetSource::PendingSwitch
@@ -1032,14 +1052,10 @@ mod switch_request_tests {
select_frame_target_source(false, false, false), select_frame_target_source(false, false, false),
FrameTargetSource::Immediate FrameTargetSource::Immediate
); );
let pending_resize = query(RequiredFalse); assert!(!upload_query_for_render(
let active = query(RequiredTrue); Some(graph(false)),
assert_eq!( Some(graph(true))
upload_query_for_render(Some(pending_resize), Some(active)) ));
.unwrap()
.visible,
RequiredFalse
);
} }
#[test] #[test]
@@ -1063,15 +1079,10 @@ mod switch_request_tests {
} }
#[test] #[test]
fn frustum_preflight_skips_any_and_distinguishes_missing_from_invalid() { fn frustum_preflight_uses_boolean_traversal_requirement() {
use crate::render_graph::RuntimePredicate::{Any, RequiredFalse, RequiredTrue};
let query = |frustum_culled| crate::render_graph::MeshQueryRuntimeKey {
visible: RequiredTrue,
frustum_culled,
};
let mut reads = 0; let mut reads = 0;
assert_eq!( assert_eq!(
resolve_culling_frustum(query(Any), || { resolve_culling_frustum(false, || {
reads += 1; reads += 1;
None None
}) })
@@ -1082,9 +1093,9 @@ mod switch_request_tests {
reads, 0, reads, 0,
"inactive frustum filtering must not read the camera" "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")); 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 })) Some(Err(crate::camera::FrustumError::Degenerate { plane: 2 }))
}) })
.unwrap_err(); .unwrap_err();
@@ -1265,10 +1276,11 @@ pub struct Renderer<T: scene::Scene> {
snapshot_init_sent: bool, snapshot_init_sent: bool,
scene_frame: scene_frame::SceneFrameCache, scene_frame: scene_frame::SceneFrameCache,
gpu_scene: gpu_scene::GpuSceneCache, gpu_scene: gpu_scene::GpuSceneCache,
instance_traversal: Option<instance_traversal::TraversalGpu>,
materials: material::MaterialResources, materials: material::MaterialResources,
pub(crate) command_ring: Option<&'static CommandRing>, pub(crate) command_ring: Option<&'static CommandRing>,
pending_replies: Vec<JsValue>, pending_replies: Vec<JsValue>,
gpu_error: std::sync::Arc<std::sync::atomic::AtomicBool>, gpu_error: std::sync::Arc<std::sync::Mutex<Option<String>>>,
framing_radius: f32, framing_radius: f32,
graph_registry: crate::render_graph::Registry, graph_registry: crate::render_graph::Registry,
active_compiled: Option<ActiveCompiledGraph>, active_compiled: Option<ActiveCompiledGraph>,
@@ -1471,19 +1483,39 @@ impl<T: Scene + 'static> Renderer<T> {
let result = js_sys::Object::new(); let result = js_sys::Object::new();
let meshes = js_sys::Array::new(); let meshes = js_sys::Array::new();
for h in installed.meshes { for h in installed.meshes {
meshes.push(&js_sys::Array::of2( let item = js_sys::Object::new();
&h.slot().into(), js_sys::Reflect::set(
&h.generation().into(), &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(); js_sys::Reflect::set(&result, &"meshes".into(), &meshes).unwrap();
Ok(result.into()) Ok(result.into())
} }
2 => { 2 => {
self.render_data self.render_data
.set_mesh_flags( .set_mesh_visible(
MeshHandle::from_parts(words[2], words[3]), 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))?; .map_err(|e| render_data_error_code(&e))?;
Ok(JsValue::UNDEFINED) Ok(JsValue::UNDEFINED)
@@ -1496,15 +1528,25 @@ impl<T: Scene + 'static> Renderer<T> {
} }
let h = self let h = self
.render_data .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))?; .map_err(|e| render_data_error_code(&e))?;
Ok(js_sys::Array::of2(&h.slot().into(), &h.generation().into()).into()) Ok(js_sys::Array::of2(&h.slot().into(), &h.generation().into()).into())
} }
4 => { 4 => {
self.render_data self.render_data
.set_instance_flags( .set_instance_visible(
InstanceHandle::from_parts(words[2], words[3]), 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))?; .map_err(|e| render_data_error_code(&e))?;
Ok(JsValue::UNDEFINED) Ok(JsValue::UNDEFINED)
@@ -1526,6 +1568,17 @@ impl<T: Scene + 'static> Renderer<T> {
.map_err(|e| render_data_error_code(&e))?; .map_err(|e| render_data_error_code(&e))?;
Ok(JsValue::UNDEFINED) 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"), _ => Err("UNKNOWN_OPCODE"),
})(); })();
match outcome { match outcome {
@@ -1765,8 +1818,7 @@ impl<T: Scene + 'static> Renderer<T> {
let contract = crate::render_graph::contract(&execution.executor.key) let contract = crate::render_graph::contract(&execution.executor.key)
.ok_or_else(|| fail("executor contract missing"))?; .ok_or_else(|| fail("executor contract missing"))?;
match execution.executor.key.as_str() { match execution.executor.key.as_str() {
"frustum_cull" => executions.push(PreparedExecution::FrustumCull), "frustum_cull" => continue,
"mesh_query" => executions.push(PreparedExecution::MeshQuery),
_ if execution.executor.key == "frame_out" _ if execution.executor.key == "frame_out"
|| contract.fullscreen_policy.is_some() => || contract.fullscreen_policy.is_some() =>
{ {
@@ -1924,7 +1976,6 @@ impl<T: Scene + 'static> Renderer<T> {
_uniform: uniform, _uniform: uniform,
}); });
} }
"pipeline_registry" => executions.push(PreparedExecution::PipelineRegistry),
"pipeline" => { "pipeline" => {
let ExecutionKind::Render { let ExecutionKind::Render {
color_attachments, color_attachments,
@@ -2009,6 +2060,14 @@ impl<T: Scene + 'static> Renderer<T> {
executions.push(PreparedExecution::Pipeline { executions.push(PreparedExecution::Pipeline {
execution: index, execution: index,
base, 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, variant,
}); });
} }
@@ -2049,7 +2108,13 @@ impl<T: Scene + 'static> Renderer<T> {
// Candidate construction allocates GPU resources, so the live scene preflight // Candidate construction allocates GPU resources, so the live scene preflight
// belongs here: this is the earliest boundary with both the runtime query and // 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. // 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(); let restart_graph = graph.clone();
self.next_preparation_token = self.next_preparation_token.wrapping_add(1).max(1); self.next_preparation_token = self.next_preparation_token.wrapping_add(1).max(1);
let token = self.next_preparation_token; let token = self.next_preparation_token;
@@ -2153,9 +2218,13 @@ impl<T: Scene + 'static> Renderer<T> {
.await .await
.unwrap(); .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 { let descriptor = wgpu::DeviceDescriptor {
required_features: optional_features, required_features: feature_plan.initial,
required_limits: wgpu::Limits::default(), required_limits: wgpu::Limits::default(),
label: None, label: None,
memory_hints: wgpu::MemoryHints::default(), memory_hints: wgpu::MemoryHints::default(),
@@ -2164,7 +2233,7 @@ impl<T: Scene + 'static> Renderer<T> {
let (device, queue) = match adapter.request_device(&descriptor).await { let (device, queue) = match adapter.request_device(&descriptor).await {
Ok(result) => result, 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}"); log::warn!("timestamp-enabled device request failed, retrying baseline: {error}");
adapter = instance adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions { .request_adapter(&wgpu::RequestAdapterOptions {
@@ -2174,8 +2243,12 @@ impl<T: Scene + 'static> Renderer<T> {
}) })
.await .await
.expect("surface-compatible adapter required for baseline device"); .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 { let baseline = wgpu::DeviceDescriptor {
required_features: wgpu::Features::empty(), required_features: feature_plan.hard,
required_limits: wgpu::Limits::default(), required_limits: wgpu::Limits::default(),
label: None, label: None,
memory_hints: wgpu::MemoryHints::default(), memory_hints: wgpu::MemoryHints::default(),
@@ -2185,15 +2258,23 @@ impl<T: Scene + 'static> Renderer<T> {
} }
Err(error) => panic!("baseline WebGPU device request failed: {error}"), 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 info: {:?}", adapter.get_info());
info!("Adapter features: {:?}", adapter.features()); info!("Adapter features: {:?}", adapter.features());
info!("Adapter limits: {:?}", adapter.limits()); info!("Adapter limits: {:?}", adapter.limits());
let profiler = profiler::Profiler::new(profile, &device, &queue).await; 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(); let error_flag = gpu_error.clone();
device.on_uncaptured_error(Box::new(move |error| { device.on_uncaptured_error(Box::new(move |error| {
error_flag.store(true, std::sync::atomic::Ordering::Relaxed); let message = error.to_string();
log::error!("Uncaptured GPU error: {error}"); 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); let surface_caps = surface.get_capabilities(&adapter);
@@ -2244,6 +2325,7 @@ impl<T: Scene + 'static> Renderer<T> {
snapshot_init_sent: false, snapshot_init_sent: false,
scene_frame: Default::default(), scene_frame: Default::default(),
gpu_scene: Default::default(), gpu_scene: Default::default(),
instance_traversal: None,
materials, materials,
command_ring: None, command_ring: None,
pending_replies: Vec::new(), pending_replies: Vec::new(),
@@ -2266,12 +2348,10 @@ impl<T: Scene + 'static> Renderer<T> {
return; return;
} }
self.drain_preparation_completions(); self.drain_preparation_completions();
if self let gpu_error = self.gpu_error.lock().unwrap().take();
.gpu_error if let Some(error) = gpu_error {
.swap(false, std::sync::atomic::Ordering::AcqRel)
{
self.halted = true; self.halted = true;
self.post_fatal("GPU_VALIDATION_FAILED", "uncaptured WebGPU error"); self.post_fatal("GPU_VALIDATION_FAILED", &error);
return; return;
} }
if !self.drain_commands() { if !self.drain_commands() {
@@ -2295,8 +2375,16 @@ impl<T: Scene + 'static> Renderer<T> {
&"controlPtr".into(), &"controlPtr".into(),
&self.snapshot.control_ptr().into(), &self.snapshot.control_ptr().into(),
); );
let _ = js_sys::Reflect::set(&message, &"controlVersion".into(), &1.into()); let _ = js_sys::Reflect::set(
let _ = js_sys::Reflect::set(&message, &"schemaVersion".into(), &1.into()); &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); let _ = global.post_message(&message);
self.snapshot_init_sent = true; self.snapshot_init_sent = true;
} }
@@ -2351,26 +2439,14 @@ impl<T: Scene + 'static> Renderer<T> {
return; return;
} }
}; };
let upload = if let Some(query) = query { let upload = self
self.gpu_scene.upload_with_query( .gpu_scene
&self.context.device, .upload(&self.context.device, &self.context.queue, frame_plan);
&self.context.queue,
frame_plan,
query,
)
} else {
self.gpu_scene
.upload(&self.context.device, &self.context.queue, frame_plan)
};
if let Err(error) = upload { if let Err(error) = upload {
log::error!("GPU scene upload failed: {error}"); log::error!("GPU scene upload failed: {error}");
self.post_fatal("GPU_UPLOAD_FAILED", &error); self.post_fatal("GPU_UPLOAD_FAILED", &error);
return; 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 // Candidate publication is transactional: configure its complete contract at
// the last possible point before acquisition, but retain the known-good // the last possible point before acquisition, but retain the known-good
@@ -2463,6 +2539,41 @@ impl<T: Scene + 'static> Renderer<T> {
), ),
}); });
let encode_result = if let Some(active) = rendering_compiled { let encode_result = if let Some(active) = rendering_compiled {
(|| -> 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( executors::encode_compiled(
&mut encoder, &mut encoder,
&texture_view, &texture_view,
@@ -2471,8 +2582,10 @@ impl<T: Scene + 'static> Renderer<T> {
&self.gpu_scene, &self.gpu_scene,
&self.resources, &self.resources,
&self.materials, &self.materials,
&self.instance_traversal.as_ref().unwrap().commands,
profile_frame.as_mut(), profile_frame.as_mut(),
) )
})()
} else { } else {
executors::encode_immediate( executors::encode_immediate(
&mut encoder, &mut encoder,
@@ -2620,12 +2733,7 @@ impl<T: Scene + 'static> Renderer<T> {
.unwrap_or(0) .unwrap_or(0)
.into(), .into(),
), ),
( ("gpuError", self.gpu_error.lock().unwrap().is_some().into()),
"gpuError",
self.gpu_error
.load(std::sync::atomic::Ordering::Relaxed)
.into(),
),
] { ] {
let _ = js_sys::Reflect::set(&telemetry, &key.into(), &value); let _ = js_sys::Reflect::set(&telemetry, &key.into(), &value);
} }
+6 -7
View File
@@ -120,7 +120,7 @@ pub struct PipelineLibrary {
default_layout: Option<PipelineLayoutKey>, default_layout: Option<PipelineLayoutKey>,
material_layout: Option<PipelineLayoutKey>, material_layout: Option<PipelineLayoutKey>,
next_layout: u64, next_layout: u64,
pipeline_registry: HashMap<String, (PipelineKey, RenderPipelineKey)>, named_bases: HashMap<String, (PipelineKey, RenderPipelineKey)>,
descriptor_cache: HashMap<RenderPipelineKey, PipelineKey>, descriptor_cache: HashMap<RenderPipelineKey, PipelineKey>,
} }
@@ -134,7 +134,7 @@ impl PipelineLibrary {
default_layout: None, default_layout: None,
material_layout: None, material_layout: None,
next_layout: 0, next_layout: 0,
pipeline_registry: HashMap::new(), named_bases: HashMap::new(),
descriptor_cache: HashMap::new(), descriptor_cache: HashMap::new(),
} }
} }
@@ -328,7 +328,7 @@ impl PipelineLibrary {
) -> Result<PipelineKey, String> { ) -> Result<PipelineKey, String> {
let spec = self.compatibility_spec(name, layouts, shader, format); let spec = self.compatibility_spec(name, layouts, shader, format);
let descriptor = spec.key(); 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 { return Err(if existing == &descriptor {
format!("Pipeline '{name}' already exists") format!("Pipeline '{name}' already exists")
} else { } else {
@@ -336,13 +336,12 @@ impl PipelineLibrary {
}); });
} }
let key = self.get_or_create_from_spec(device, &spec, Some(name)); let key = self.get_or_create_from_spec(device, &spec, Some(name));
self.pipeline_registry self.named_bases.insert(name.to_owned(), (key, descriptor));
.insert(name.to_owned(), (key, descriptor));
Ok(key) Ok(key)
} }
pub fn find_pipeline(&self, name: &str) -> Option<PipelineKey> { pub fn find_pipeline(&self, name: &str) -> Option<PipelineKey> {
self.pipeline_registry.get(name).map(|v| v.0) self.named_bases.get(name).map(|v| v.0)
} }
pub fn get_or_create_pipeline( pub fn get_or_create_pipeline(
&mut self, &mut self,
@@ -353,7 +352,7 @@ impl PipelineLibrary {
format: wgpu::TextureFormat, format: wgpu::TextureFormat,
) -> PipelineKey { ) -> PipelineKey {
let wanted = self.compatibility_spec(name, layouts, shader, format).key(); 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!( assert_eq!(
existing, &wanted, existing, &wanted,
"Pipeline '{name}' requested with a different descriptor" "Pipeline '{name}' requested with a different descriptor"
+18 -20
View File
@@ -3,8 +3,8 @@ use std::collections::HashMap;
use thiserror::Error; use thiserror::Error;
use crate::render_data::{ use crate::render_data::{
affine_world_aabb, Aabb, GeometryRange, InstanceHandle, MaterialKey, MeshHandle, affine_world_aabb, Aabb, GeometryRange, InstanceHandle, InstanceType, MaterialKey, MeshHandle,
ModelTransform, NormalMatrix, PipelineKey, RenderData, RenderFlags, ModelTransform, NormalMatrix, PipelineKey, RenderData,
}; };
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -13,8 +13,8 @@ pub struct SceneFrameMesh {
pub geometry: GeometryRange, pub geometry: GeometryRange,
pub pipeline: PipelineKey, pub pipeline: PipelineKey,
pub material: MaterialKey, pub material: MaterialKey,
pub flags: RenderFlags, pub instance_type: InstanceType,
pub aabb: Aabb, pub local_aabb: Aabb,
pub default_instance: InstanceHandle, pub default_instance: InstanceHandle,
pub occurrence_range: std::ops::Range<usize>, pub occurrence_range: std::ops::Range<usize>,
} }
@@ -26,7 +26,7 @@ pub struct SceneFrameOccurrence {
pub mesh_index: usize, pub mesh_index: usize,
pub model: ModelTransform, pub model: ModelTransform,
pub normal: NormalMatrix, pub normal: NormalMatrix,
pub flags: RenderFlags, pub instance_type: InstanceType,
pub is_default: bool, pub is_default: bool,
pub world_aabb: Aabb, pub world_aabb: Aabb,
} }
@@ -84,9 +84,9 @@ impl SceneFramePlan {
mesh_index, mesh_index,
model: occurrence.model, model: occurrence.model,
normal: occurrence.normal, normal: occurrence.normal,
flags: occurrence.flags, instance_type: occurrence.instance_type,
is_default: handle == mesh.default_instance, 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)?, .map_err(|_| SceneFrameError::InvalidWorldBounds)?,
}); });
} }
@@ -117,8 +117,8 @@ impl SceneFramePlan {
geometry: mesh.geometry, geometry: mesh.geometry,
pipeline: mesh.pipeline, pipeline: mesh.pipeline,
material: mesh.material, material: mesh.material,
flags: mesh.flags, instance_type: mesh.default_instance_type,
aabb: mesh.aabb, local_aabb: mesh.local_aabb,
default_instance: mesh.default_instance, default_instance: mesh.default_instance,
occurrence_range: offsets[dense]..offsets[dense + 1], occurrence_range: offsets[dense]..offsets[dense + 1],
}) })
@@ -170,12 +170,11 @@ mod tests {
indices: &[0, 1, 2], indices: &[0, 1, 2],
pipeline: PipelineKey::new(0), pipeline: PipelineKey::new(0),
material: crate::render_data::MaterialKey::DEFAULT, material: crate::render_data::MaterialKey::DEFAULT,
flags: if visible { default_instance_type: if visible {
RenderFlags::VISIBLE InstanceType::VISIBLE
} else { } else {
RenderFlags::NONE InstanceType::ZERO
}, },
default_instance_flags: RenderFlags::VISIBLE,
default_transform: IDENTITY_MODEL_TRANSFORM, default_transform: IDENTITY_MODEL_TRANSFORM,
}) })
.unwrap() .unwrap()
@@ -190,8 +189,7 @@ mod tests {
let created = mesh(&mut data, true); let created = mesh(&mut data, true);
let second = cache.get_or_build(&data).unwrap() as *const _; let second = cache.get_or_build(&data).unwrap() as *const _;
assert_ne!(first, second); assert_ne!(first, second);
data.set_mesh_flags(created.mesh, RenderFlags::NONE) data.set_mesh_visible(created.mesh, false).unwrap();
.unwrap();
let third = cache.get_or_build(&data).unwrap() as *const _; let third = cache.get_or_build(&data).unwrap() as *const _;
assert_ne!(second, third); assert_ne!(second, third);
let mut moved = IDENTITY_MODEL_TRANSFORM; let mut moved = IDENTITY_MODEL_TRANSFORM;
@@ -213,7 +211,7 @@ mod tests {
translated[3][0] = 5.; translated[3][0] = 5.;
translated[3][1] = -2.; translated[3][1] = -2.;
let extra = data let extra = data
.create_instance(hidden.mesh, translated, RenderFlags::NONE) .create_instance(hidden.mesh, translated, InstanceType::ZERO)
.unwrap(); .unwrap();
let plan = SceneFramePlan::build(&data).unwrap(); let plan = SceneFramePlan::build(&data).unwrap();
assert_eq!((plan.meshes.len(), plan.occurrences.len()), (2, 3)); assert_eq!((plan.meshes.len(), plan.occurrences.len()), (2, 3));
@@ -247,13 +245,13 @@ mod tests {
let doomed = mesh(&mut data, true); let doomed = mesh(&mut data, true);
let c = mesh(&mut data, true); let c = mesh(&mut data, true);
let a_extra = data let a_extra = data
.create_instance(a.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE) .create_instance(a.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::VISIBLE)
.unwrap(); .unwrap();
let doomed_extra = data let doomed_extra = data
.create_instance(doomed.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE) .create_instance(doomed.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::VISIBLE)
.unwrap(); .unwrap();
let c_extra = data let c_extra = data
.create_instance(c.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE) .create_instance(c.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::VISIBLE)
.unwrap(); .unwrap();
data.destroy_instance(a_extra).unwrap(); data.destroy_instance(a_extra).unwrap();
data.destroy_mesh(doomed.mesh).unwrap(); data.destroy_mesh(doomed.mesh).unwrap();
@@ -262,7 +260,7 @@ mod tests {
.create_instance( .create_instance(
replacement.mesh, replacement.mesh,
IDENTITY_MODEL_TRANSFORM, IDENTITY_MODEL_TRANSFORM,
RenderFlags::VISIBLE, InstanceType::VISIBLE,
) )
.unwrap(); .unwrap();
assert_eq!(replacement.mesh.slot(), doomed.mesh.slot()); assert_eq!(replacement.mesh.slot(), doomed.mesh.slot());
+59 -259
View File
@@ -1,12 +1,12 @@
//! Triple-buffered, immutable packed scene snapshot shared with JavaScript. //! Triple-buffered, immutable packed scene snapshot shared with JavaScript.
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use crate::{render_data::RenderFlags, renderer::scene_frame::SceneFramePlan}; use crate::renderer::scene_frame::SceneFramePlan;
pub const MAGIC: u32 = u32::from_le_bytes(*b"YSNP"); pub const MAGIC: u32 = u32::from_le_bytes(*b"YSNP");
pub const BLOB_MAGIC: u32 = u32::from_le_bytes(*b"RDS1"); pub const BLOB_MAGIC: u32 = u32::from_le_bytes(*b"RDS2");
pub const CONTROL_VERSION: u32 = 1; pub const CONTROL_VERSION: u32 = 1;
pub const SCHEMA: u32 = 1; pub const SCHEMA: u32 = 2;
pub const SLOT_COUNT: usize = 3; pub const SLOT_COUNT: usize = 3;
pub const INIT: u32 = 0; pub const INIT: u32 = 0;
pub const OPEN: u32 = 1; pub const OPEN: u32 = 1;
@@ -23,9 +23,9 @@ pub const ERROR_PUBLICATION: u32 = 4;
const CONTROL_BYTES: u32 = 256; const CONTROL_BYTES: u32 = 256;
const SLOT_BYTES: u32 = 64; const SLOT_BYTES: u32 = 64;
const SNAPSHOT_HEADER_BYTES: usize = 64; const SNAPSHOT_HEADER_BYTES: usize = 64;
const DATA_OFFSET: usize = 512; const DATA_OFFSET: usize = 448;
const DESCRIPTOR_BYTES: usize = 32; const DESCRIPTOR_BYTES: usize = 32;
const STREAMS: usize = 14; const STREAMS: usize = 12;
const SCHEMA_FLAGS: u32 = 3; // dense arrays | affine transforms const SCHEMA_FLAGS: u32 = 3; // dense arrays | affine transforms
#[repr(C, align(64))] #[repr(C, align(64))]
@@ -247,41 +247,40 @@ fn wasm_pages(minimum_end: usize) -> Result<u32, u32> {
fn pack(data: &SceneFramePlan, epoch: u32) -> Result<Vec<u8>, u32> { fn pack(data: &SceneFramePlan, epoch: u32) -> Result<Vec<u8>, u32> {
let meshes = &data.meshes; let meshes = &data.meshes;
let instances = &data.occurrences; let instances = &data.occurrences;
let strides = [4usize, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4]; let strides = [4usize, 4, 12, 12, 4, 4, 4, 4, 64, 12, 12, 64];
let components = [1u32, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1]; let components = [1u32, 1, 3, 3, 1, 1, 1, 1, 16, 3, 3, 16];
let scalar = [1u32, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1]; let scalar = [1u32, 1, 2, 2, 1, 1, 1, 1, 2, 2, 2, 1];
let counts = [meshes.len(); 5] let counts = [meshes.len(); 4]
.into_iter() .into_iter()
.chain([instances.len(); 9]) .chain([instances.len(); 8])
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut offsets = [0usize; STREAMS]; let mut offsets = [0usize; STREAMS];
let mut cursor = DATA_OFFSET; let mut cursor = DATA_OFFSET;
for i in 0..STREAMS { for i in 0..STREAMS {
offsets[i] = cursor; offsets[i] = cursor;
let bytes = strides[i].checked_mul(counts[i]).ok_or(ERROR_OVERFLOW)?; cursor = align16(
cursor = align16(cursor.checked_add(bytes).ok_or(ERROR_OVERFLOW)?)?; 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 mut out = vec![0u8; cursor];
let put32 = |out: &mut [u8], at: usize, value: u32| { let put32 =
out[at..at + 4].copy_from_slice(&value.to_le_bytes()) |out: &mut [u8], at: usize, v: u32| out[at..at + 4].copy_from_slice(&v.to_le_bytes());
};
let revision = data.revision; let revision = data.revision;
for (i, value) in [ for (i, v) in [
BLOB_MAGIC, BLOB_MAGIC,
SCHEMA, SCHEMA,
SNAPSHOT_HEADER_BYTES as u32, 64,
total, cursor as u32,
epoch, epoch,
revision as u32, revision as u32,
(revision >> 32) as u32, (revision >> 32) as u32,
STREAMS as u32, STREAMS as u32,
SNAPSHOT_HEADER_BYTES as u32, 64,
DESCRIPTOR_BYTES as u32, 32,
mesh_count, meshes.len() as u32,
instance_count, instances.len() as u32,
0x0102_0304, 0x0102_0304,
SCHEMA_FLAGS, SCHEMA_FLAGS,
0, 0,
@@ -290,11 +289,11 @@ fn pack(data: &SceneFramePlan, epoch: u32) -> Result<Vec<u8>, u32> {
.into_iter() .into_iter()
.enumerate() .enumerate()
{ {
put32(&mut out, i * 4, value); put32(&mut out, i * 4, v);
} }
for i in 0..STREAMS { for i in 0..STREAMS {
let at = SNAPSHOT_HEADER_BYTES + i * DESCRIPTOR_BYTES; let at = 64 + i * 32;
for (j, value) in [ for (j, v) in [
i as u32 + 1, i as u32 + 1,
scalar[i], scalar[i],
offsets[i] as u32, offsets[i] as u32,
@@ -307,75 +306,61 @@ fn pack(data: &SceneFramePlan, epoch: u32) -> Result<Vec<u8>, u32> {
.into_iter() .into_iter()
.enumerate() .enumerate()
{ {
put32(&mut out, at + j * 4, value); put32(&mut out, at + j * 4, v);
} }
} }
for (dense, mesh) in meshes.iter().enumerate() { for (d, m) in meshes.iter().enumerate() {
for (i, value) in [ put32(&mut out, offsets[0] + d * 4, m.handle.slot());
mesh.handle.slot(), put32(&mut out, offsets[1] + d * 4, m.handle.generation());
mesh.handle.generation(),
mesh.flags.bits(),
]
.into_iter()
.enumerate()
{
put32(&mut out, offsets[i] + dense * 4, value);
}
for i in 0..3 { for i in 0..3 {
put32( put32(
&mut out, &mut out,
offsets[3] + dense * 12 + i * 4, offsets[2] + d * 12 + i * 4,
mesh.aabb.min[i].to_bits(), m.local_aabb.min[i].to_bits(),
); );
put32( put32(
&mut out, &mut out,
offsets[4] + dense * 12 + i * 4, offsets[3] + d * 12 + i * 4,
mesh.aabb.max[i].to_bits(), m.local_aabb.max[i].to_bits(),
); );
} }
} }
for (dense, instance) in instances.iter().enumerate() { for (d, x) in instances.iter().enumerate() {
let mesh = data for (i, v) in [
.meshes x.handle.slot(),
.get(instance.mesh_index) x.handle.generation(),
.ok_or(ERROR_INVARIANT)?; x.mesh.slot(),
for (i, value) in [ x.mesh.generation(),
instance.handle.slot(),
instance.handle.generation(),
instance.mesh.slot(),
instance.mesh.generation(),
instance.flags.bits(),
] ]
.into_iter() .into_iter()
.enumerate() .enumerate()
{ {
put32(&mut out, offsets[5 + i] + dense * 4, value); put32(&mut out, offsets[4 + i] + d * 4, v);
} }
for i in 0..16 { for i in 0..16 {
put32( put32(
&mut out, &mut out,
offsets[10] + dense * 64 + i * 4, offsets[8] + d * 64 + i * 4,
instance.model[i / 4][i % 4].to_bits(), 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 { for i in 0..3 {
put32( put32(
&mut out, &mut out,
offsets[11] + dense * 12 + i * 4, offsets[9] + d * 12 + i * 4,
instance.world_aabb.min[i].to_bits(), x.world_aabb.min[i].to_bits(),
); );
put32( put32(
&mut out, &mut out,
offsets[12] + dense * 12 + i * 4, offsets[10] + d * 12 + i * 4,
instance.world_aabb.max[i].to_bits(), 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) Ok(out)
} }
@@ -393,199 +378,14 @@ const _: [(); 256] = [(); std::mem::size_of::<SnapshotControl>()];
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::render_data::{
MeshCreateInfo, PipelineKey, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM,
};
#[test] #[test]
fn exact_control_layout_and_initial_values() { fn exact_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::<SnapshotControl>(), 256); assert_eq!(std::mem::size_of::<SnapshotControl>(), 256);
assert_eq!(std::mem::size_of::<SnapshotDescriptor>(), 64);
let snapshot = SharedSnapshot::new(); let snapshot = SharedSnapshot::new();
let values: Vec<_> = snapshot assert_eq!(snapshot.control.header[5].load(Ordering::Relaxed), 2);
.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::<Vec<_>>();
let mut offsets = Vec::new();
let mut cursor = DATA_OFFSET;
for i in 0..STREAMS {
let at = SNAPSHOT_HEADER_BYTES + i * DESCRIPTOR_BYTES;
offsets.push(cursor);
assert_eq!(
[
word(at),
word(at + 4),
word(at + 8),
word(at + 12),
word(at + 16),
word(at + 20),
word(at + 24),
word(at + 28)
],
[
i as u32 + 1,
scalars[i],
cursor as u32,
counts[i] as u32,
components[i],
strides[i] as u32,
4,
0
]
);
assert_eq!(cursor % 16, 0);
cursor = (cursor + strides[i] as usize * counts[i] + 15) & !15;
}
assert_eq!(cursor, blob.len());
for (dense, mesh) in plan.meshes.iter().enumerate() {
assert_eq!(word(offsets[0] + dense * 4), mesh.handle.slot());
assert_eq!(word(offsets[1] + dense * 4), mesh.handle.generation());
assert_eq!(word(offsets[2] + dense * 4), mesh.flags.bits());
for axis in 0..3 {
assert_eq!(
word(offsets[3] + dense * 12 + axis * 4),
mesh.aabb.min[axis].to_bits()
);
assert_eq!(
word(offsets[4] + dense * 12 + axis * 4),
mesh.aabb.max[axis].to_bits()
);
}
}
for (dense, occurrence) in plan.occurrences.iter().enumerate() {
assert_eq!(
[
word(offsets[5] + dense * 4),
word(offsets[6] + dense * 4),
word(offsets[7] + dense * 4),
word(offsets[8] + dense * 4),
word(offsets[9] + dense * 4)
],
[
occurrence.handle.slot(),
occurrence.handle.generation(),
occurrence.mesh.slot(),
occurrence.mesh.generation(),
occurrence.flags.bits()
]
);
for i in 0..16 {
assert_eq!(
word(offsets[10] + dense * 64 + i * 4),
occurrence.model[i / 4][i % 4].to_bits()
);
}
for axis in 0..3 {
assert_eq!(
word(offsets[11] + dense * 12 + axis * 4),
occurrence.world_aabb.min[axis].to_bits()
);
assert_eq!(
word(offsets[12] + dense * 12 + axis * 4),
occurrence.world_aabb.max[axis].to_bits()
);
}
let mesh_visible = plan.meshes[occurrence.mesh_index]
.flags
.contains(RenderFlags::VISIBLE);
assert_eq!(
word(offsets[13] + dense * 4),
(mesh_visible && occurrence.flags.contains(RenderFlags::VISIBLE)) as u32
);
}
assert!(plan
.meshes
.windows(2)
.all(|w| w[0].handle.slot() < w[1].handle.slot()));
assert!(plan
.occurrences
.windows(2)
.all(|w| w[0].handle.slot() < w[1].handle.slot()));
assert_eq!(
plan.occurrences
.iter()
.find(|o| o.handle == extra)
.unwrap()
.model,
model
);
assert!(plan.occurrences.iter().any(|o| o.mesh == visible.mesh));
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@ export class DerivedBvh {
let changed = n !== this.count; 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; } 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); 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(); changed ? this.rebuild() : this.refit();
} }
rebuild() { rebuild() {
+1 -1
View File
@@ -19,7 +19,7 @@ function coalescedUpdate(hint = 0) {
addEventListener("message", event => { addEventListener("message", event => {
const m = event.data; const m = event.data;
try { 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 === "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 === "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(); else if (m.type === "dispose") close();
+10 -10
View File
@@ -1,11 +1,11 @@
export const SNAPSHOT = Object.freeze({ export const SNAPSHOT = Object.freeze({
MAGIC: 0x504e5359, BLOB_MAGIC: 0x31534452, VERSION: 1, BYTES: 256, MAGIC: 0x504e5359, BLOB_MAGIC: 0x32534452, VERSION: 1, BYTES: 256,
SLOTS: 3, SLOT_BYTES: 64, SCHEMA: 1, INIT: 0, OPEN: 1, FAILED: 2, SLOTS: 3, SLOT_BYTES: 64, SCHEMA: 2, INIT: 0, OPEN: 1, FAILED: 2,
CLOSED: 3, FREE: 0, WRITING: 1, READY: 2, READING: 3, 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"]; export const STREAM_NAMES = ["meshSlot", "meshGeneration", "meshLocalMin", "meshLocalMax", "instanceSlot", "instanceGeneration", "instanceMeshSlot", "instanceMeshGeneration", "instanceModel", "instanceWorldMin", "instanceWorldMax", "instanceType"];
const COMPONENTS = [1, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1]; const COMPONENTS = [1, 1, 3, 3, 1, 1, 1, 1, 16, 3, 3, 16];
const SCALARS = [1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1]; const SCALARS = [1, 1, 2, 2, 1, 1, 1, 1, 2, 2, 2, 1];
const STRIDES = COMPONENTS.map(n => n * 4); const STRIDES = COMPONENTS.map(n => n * 4);
export class SnapshotProtocolError extends Error { 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[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"); if (slot.slice(11).some(Boolean)) bad("BAD_SLOT_RESERVED");
const ptr = slot[3], bytes = slot[4]; 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); 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"); if (u32[14] || u32[15]) bad("BAD_BLOB_RESERVED");
const ranges = [], streams = {}; 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 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]; 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 < 512 || offset % 16) bad("BAD_DESCRIPTOR"); 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)); const end = add(offset, mul(stride, count));
if (end > bytes) bad("BAD_DESCRIPTOR_RANGE"); if (end > bytes) bad("BAD_DESCRIPTOR_RANGE");
if (count) ranges.push([offset, end]); if (count) ranges.push([offset, end]);
+37 -14
View File
@@ -94,7 +94,7 @@ const mapValuePaths = (paths, path, source, value) => {
mapValuePaths(paths, `${path}.${key}`, source, value[key]); 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) if (!exactKeys(raw, ["kind", "value"]) || raw.kind !== schema.type)
fail("AUTHORING_PARAMETER", { nodeId, parameter: key }); fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
const value = raw.value; const value = raw.value;
@@ -112,13 +112,33 @@ function parameterValue(raw, schema, nodeId, key) {
? typeof value === "boolean" ? typeof value === "boolean"
: schema.type === "vector" || schema.type === "color" : schema.type === "vector" || schema.type === "color"
? Array.isArray(value) && ? 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) value.every(bounded)
: schema.type === "json" && finiteJson(value); : schema.type === "json" && finiteJson(value) && validSemanticValue(value, semanticType);
if (!valid) fail("AUTHORING_PARAMETER", { nodeId, parameter: key }); if (!valid) fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
return canonical(structuredClone(raw.value)); 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) { export function adaptFxNodeSnapshot(raw, revision = 1) {
try { try {
const rootKeys = [ const rootKeys = [
@@ -300,6 +320,7 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
socketDefinition.value, socketDefinition.value,
n.id, n.id,
s.key, s.key,
input.accepted.types[0],
); );
return false; return false;
} catch { } catch {
@@ -327,13 +348,16 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
} }
if (new Set(n.sockets.map((s) => s.key)).size !== expected.length) if (new Set(n.sockets.map((s) => s.key)).size !== expected.length)
fail("AUTHORING_SOCKET_SET", { nodeId: n.id }); fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
if (n.typeId === "mesh_query") { for (const key of Object.keys(descriptor.inputs)) {
parameters.visibleDefault = sockets.get( const authoredDefault = sockets.get(`${n.id}:${key}`).defaultValue;
`${n.id}:isVisible`, if (authoredDefault)
).defaultValue.value; parameters[`${key}Default`] = parameterValue(
parameters.frustumCulledDefault = sockets.get( authoredDefault,
`${n.id}:isFrustumCulled`, definition.sockets[key].value,
).defaultValue.value; n.id,
key,
descriptor.inputs[key].accepted.types[0],
);
} }
nodes.set(n.id, { nodes.set(n.id, {
ordinal, ordinal,
@@ -507,13 +531,12 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
mapValuePaths( mapValuePaths(
paths, paths,
`${base}.parameters.${key}`, `${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", kind: "input",
nodeId: item.value.id, nodeId: item.value.id,
input: input: key.slice(0, -7),
key === "visibleDefault" ? "isVisible" : "isFrustumCulled", socketId: `${item.value.id}:${key.slice(0, -7)}`,
socketId: `${item.value.id}:${key === "visibleDefault" ? "isVisible" : "isFrustumCulled"}`,
unconnected: true, unconnected: true,
} }
: parameterSource( : parameterSource(
+2 -1
View File
@@ -2,13 +2,14 @@ import { semanticCatalog } from "./catalog.js";
const GROUPS = Object.freeze([ const GROUPS = Object.freeze([
["source", "Source"], ["source", "Source"],
["expression", "Expression"],
["compute", "Compute"], ["compute", "Compute"],
["cpu_preparation", "CPU preparation"], ["cpu_preparation", "CPU preparation"],
["render", "Render / post"], ["render", "Render / post"],
["frame", "Frame"], ["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. */ /** Application-owned, immutable add-node catalog model. */
export const addNodeItems = Object.freeze( export const addNodeItems = Object.freeze(
+89 -74
View File
@@ -1,5 +1,5 @@
export const GRAPH_ID = "authored_gpu_culling"; 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 exact = (type) => ({ kind: "exact", types: [type] });
const i = (type, required = true, authoringType) => ({ const i = (type, required = true, authoringType) => ({
accepted: typeof type === "string" ? exact(type) : type, accepted: typeof type === "string" ? exact(type) : type,
@@ -7,6 +7,45 @@ const i = (type, required = true, authoringType) => ({
...(authoringType ? { authoringType } : {}), ...(authoringType ? { authoringType } : {}),
}); });
const o = (type) => ({ type }); 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 = { const texture = {
residency: "transient", residency: "transient",
texture: { texture: {
@@ -25,17 +64,13 @@ const texture = {
}; };
export const semanticCatalog = Object.freeze({ export const semanticCatalog = Object.freeze({
mesh: { mesh: {
version: 1, version: 2,
execution: "source", execution: "source",
inputs: {}, inputs: {},
outputs: { outputs: {
mesh: o("mesh_data"), mesh: o("mesh_data"),
localAabbs: o("local_aabb_buffer"), type: o("u32x16"),
isVisible: { localAabb: o("local_aabb"),
...o("boolean_flag_buffer"),
authoringType: "visibility_flag_buffer",
},
pipelineIndices: o("pipeline_index_stream"),
}, },
parameters: {}, parameters: {},
}, },
@@ -62,54 +97,28 @@ export const semanticCatalog = Object.freeze({
}, },
}, },
frustum_cull: { frustum_cull: {
version: 1, version: 2,
execution: "compute", execution: "expression",
inputs: { inputs: {
mesh: i("mesh_data"), mesh: i("mesh_data"),
localAabbs: i("local_aabb_buffer"), localAabb: i("local_aabb"),
},
outputs: {
isFrustumCulled: {
...o("boolean_flag_buffer"),
authoringType: "frustum_flag_buffer",
},
}, },
outputs: { isFrustumCulled: o("bool") },
parameters: { cameraSelection: "active" }, 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: { pipeline: {
version: 1, version: 2,
execution: "render", execution: "render",
inputs: { inputs: {
mesh: i("mesh_data"), mesh: i("mesh_data"),
draws: i("draw_stream"), predicate: i("bool", false),
activation: i("pipeline_activation"),
colorTarget: i("texture"), colorTarget: i("texture"),
depthTarget: i("texture"), depthTarget: i("texture"),
}, },
outputs: { color: o("texture"), depth: o("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] }, parameters: { pipeline: "gltf_standard", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1] },
}, },
...expressionCatalog,
fullscreen_copy: { fullscreen_copy: {
version: 1, version: 1,
execution: "render", execution: "render",
@@ -210,13 +219,8 @@ export const socketTypes = Object.fromEntries(
[ [
"texture", "texture",
"mesh_data", "mesh_data",
"local_aabb_buffer", "bool", "f32", "u32", "vec2", "vec3", "vec4",
"boolean_flag_buffer", "mat2", "mat3", "mat4", "u32x16", "local_aabb",
"pipeline_index_stream",
"draw_stream",
"pipeline_activation",
"visibility_flag_buffer",
"frustum_flag_buffer",
].map((type, index) => [ ].map((type, index) => [
type, 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 = { export const theme = {
background: "#151820", background: "#151820",
grid: "#292e3a", grid: "#292e3a",
@@ -266,6 +265,7 @@ export const theme = {
export const styles = { export const styles = {
source: { header: "#3977a8" }, source: { header: "#3977a8" },
compute: { header: "#725a9b" }, compute: { header: "#725a9b" },
expression: { header: "#725a9b" },
cpu_preparation: { header: "#8a6d3b" }, cpu_preparation: { header: "#8a6d3b" },
render: { header: "#426b43" }, render: { header: "#426b43" },
frame: { header: "#a75d37" }, frame: { header: "#a75d37" },
@@ -283,8 +283,8 @@ const tagged = (kind, value) => ({ kind, value: structuredClone(value) });
const number = (value, minimum, maximum) => ({ const number = (value, minimum, maximum) => ({
type: "number", type: "number",
default: tagged("number", value), default: tagged("number", value),
minimum, ...(minimum !== undefined ? { minimum } : {}),
maximum, ...(maximum !== undefined ? { maximum } : {}),
}); });
const enumeration = (value, values) => ({ const enumeration = (value, values) => ({
type: "string", type: "string",
@@ -302,8 +302,38 @@ const color = (value, minimum = 0, maximum = 1) => ({
minimum, minimum,
maximum, 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 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 = { const parameterSchemas = {
texture: { texture: {
residency: enumeration("transient", ["transient", "persistent"]), residency: enumeration("transient", ["transient", "persistent"]),
@@ -343,19 +373,6 @@ const parameterSchemas = {
}, },
mesh: {}, mesh: {},
frustum_cull: { cameraSelection: enumeration("active", ["active"]) }, 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: {
pipeline: string("gltf_standard"), pipeline: string("gltf_standard"),
depthCompare: enumeration("less_equal", [ depthCompare: enumeration("less_equal", [
@@ -399,6 +416,7 @@ const parameterSchemas = {
backgroundColor: color([0, 0, 0, 1]), backgroundColor: color([0, 0, 0, 1]),
}, },
}; };
for (const key of Object.keys(expressionCatalog)) parameterSchemas[key] = {};
export const nodeDefinitions = Object.fromEntries( export const nodeDefinitions = Object.fromEntries(
Object.entries(semanticCatalog).map(([key, c]) => { Object.entries(semanticCatalog).map(([key, c]) => {
const sockets = { const sockets = {
@@ -409,11 +427,7 @@ export const nodeDefinitions = Object.fromEntries(
n, n,
"input", "input",
v.authoringType ?? v.accepted.types[0], v.authoringType ?? v.accepted.types[0],
key === "mesh_query" && n === "isVisible" !v.required ? socketDefault(v.accepted.types[0], defaultForInput(key, n, v.accepted.types[0])) : null,
? boolean(true)
: key === "mesh_query" && n === "isFrustumCulled"
? boolean(false)
: null,
), ),
]), ]),
), ),
@@ -458,6 +472,7 @@ export const nodeDefinitions = Object.fromEntries(
]; ];
}), }),
); );
nodeDefinitions.mesh.sockets.localAabb.title = "Local AABB";
nodeDefinitions.color_balance.ui = [ nodeDefinitions.color_balance.ui = [
{ kind: "parameter", parameter: "mode" }, { kind: "parameter", parameter: "mode" },
{ kind: "widget", widget: "grading-wheels", bindings: [ { kind: "widget", widget: "grading-wheels", bindings: [
+43 -39
View File
@@ -3,19 +3,8 @@ import { CATALOG_VERSION, GRAPH_ID, fxNodeComposition } from "./catalog.js";
import { prepareBrowserHost } from "./browser-host.js"; import { prepareBrowserHost } from "./browser-host.js";
import { createAddNodeMenu } from "./add-node-menu.js"; import { createAddNodeMenu } from "./add-node-menu.js";
import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.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) { async function seed(root) {
await root.setState({ await root.setState({
graphId: GRAPH_ID, graphId: GRAPH_ID,
@@ -24,28 +13,11 @@ async function seed(root) {
links: [], links: [],
metadata: {}, metadata: {},
}); });
for (const [nodeId, nodeType, position] of spec) for (const [index, item] of culling.nodes.entries())
await root.dispatch({ type: "node.add", nodeId, nodeType, position }); await root.dispatch({ type: "node.add", nodeId: item.id, nodeType: item.executor.key,
const links = [ position: { x: 40 + (index % 6) * 280, y: 120 + Math.floor(index / 6) * 260 } });
["mesh", "mesh", "cull", "mesh"], const links = culling.nodes.flatMap((item) => Object.entries(item.inputs).map(([socket, from]) =>
["mesh", "localAabbs", "cull", "localAabbs"], [from.node, from.socket, item.id, socket]));
["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 [a, as, b, bs] of links) { for (const [a, as, b, bs] of links) {
const id = `${a}_${as}_${b}_${bs}`; const id = `${a}_${as}_${b}_${bs}`;
await root.dispatch({ await root.dispatch({
@@ -61,11 +33,43 @@ async function seed(root) {
}, },
}); });
} }
const authored = await root.getState(), const authored = await root.getState();
depth = authored.nodes.find((node) => node.id === "depth"); for (const item of culling.nodes) {
depth.parameters.format = { kind: "string", value: "depth32_float" }; const target = authored.nodes.find((candidate) => candidate.id === item.id);
for (const [id, name] of [["ground", "ground_plane"], ["pbr", "gltf_standard"], ["pbr_double", "gltf_standard_double_sided"]]) if (item.executor.key === "texture") {
authored.nodes.find((node) => node.id === id).parameters.pipeline = { kind: "string", value: name }; 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); await root.setState(authored);
} }
export async function createRenderGraphEditor(canvas) { export async function createRenderGraphEditor(canvas) {
+28 -12
View File
@@ -13,16 +13,37 @@ const texture = (format, scale = 1, heightScale = scale) => ({
residency: "transient", 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 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("hdr", "texture", texture("rgba16_float", 1, heightScale)),
node("depth", "texture", texture("depth32_float", 1, heightScale)), node("depth", "texture", texture("depth32_float", 1, heightScale)),
node("mesh", "mesh"), node("mesh", "mesh"),
node("query", "mesh_query", { visiblePredicate: "required_true", visibleDefault: true, frustumCulledPredicate: "any", frustumCulledDefault: false }, { mesh: input("mesh", "mesh"), isVisible: input("mesh", "isVisible") }), ...classification.nodes,
node("registry", "pipeline_registry", {}, { pipelineIndices: input("mesh", "pipelineIndices") }), 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("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, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.pbr, "value"), colorTarget: input("ground", "color"), depthTarget: input("ground", "depth") }),
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, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.pbr_double, "value"), colorTarget: input("pbr", "color"), depthTarget: input("pbr", "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") }),
]; ];
};
const graph = (graphId, nodes) => Object.freeze({ schemaVersion: 2, graphId, revision: 1, nodes }); const graph = (graphId, nodes) => Object.freeze({ schemaVersion: 2, graphId, revision: 1, nodes });
const direct = (graphId, clearColor) => graph(graphId, [ const direct = (graphId, clearColor) => graph(graphId, [
node("ldr", "texture", texture("rgba8_unorm")), 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") }), node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") }),
]); ]);
export const culling = graph("preset_gpu_culling", (() => { export const culling = graph("preset_gpu_culling", (() => {
const nodes = structuredClone(hdr.nodes); return [...scene("hdr", undefined, 1, true), node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") })];
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;
})()); })());
const postPreset = (graphId, kind) => { const postPreset = (graphId, kind) => {
const nodes = [...scene("hdr")]; const nodes = [...scene("hdr")];
+21 -16
View File
@@ -1,8 +1,8 @@
import { SnapshotReader } from "./render-data-snapshot.js"; import { SnapshotReader } from "./render-data-snapshot.js";
export const VISIBLE = 1; export const VISIBLE = 1;
const HEADER_WORDS = 16, SLOT_WORDS = 24, CAPACITY = 1024, SLOT_VERSION = 1; const HEADER_WORDS = 16, SLOT_WORDS = 40, CAPACITY = 1024, SLOT_VERSION = 2;
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 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"); const HANDLE_TOKEN = Symbol("renderer handle");
export class RendererError extends Error { export class RendererError extends Error {
@@ -20,7 +20,7 @@ export class RendererClient {
this.#bridge = bridge; this.#bridge = bridge;
this.#worker = bridge.worker; this.#worker = bridge.worker;
this.#refreshViews(); 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 { this.#worker?.terminate?.(); } catch { /* best effort */ }
try { bridge?.free?.(); } catch { /* best effort */ } try { bridge?.free?.(); } catch { /* best effort */ }
throw new RendererError("PROTOCOL_MISMATCH"); throw new RendererError("PROTOCOL_MISMATCH");
@@ -70,9 +70,9 @@ export class RendererClient {
this.#fail(message.code || "WORKER_FATAL"); this.#fail(message.code || "WORKER_FATAL");
} else if (message?.type === "snapshot-init") { } else if (message?.type === "snapshot-init") {
try { 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.#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"); } } catch { this.#disablePicking("PICK_PROTOCOL_MISMATCH"); }
} else if (message?.type === "snapshot-published") { } 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"); } 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; return promise;
} }
#mesh(handle) { #mesh(handle, defaultType = Array(16).fill(0)) {
return new Mesh(HANDLE_TOKEN, return new Mesh(HANDLE_TOKEN,
visible => this.#enqueue(OP.MESH_FLAGS, [...handle, visible ? VISIBLE : 0]), visible => { validateVisible(visible); return this.#enqueue(OP.MESH_FLAGS, [...handle, visible ? 1 : 0]); },
async (transform, visible) => { async (transform, {type = defaultType, visible} = {}) => {
const result = await this.#enqueue(OP.CREATE_INSTANCE, [...handle, ...floatWords(transform), visible ? VISIBLE : 0]); 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); return this.#instance(result);
}); });
} }
#instance(handle) { #instance(handle) {
return new Instance(HANDLE_TOKEN, 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)]), transform => this.#enqueue(OP.INSTANCE_TRANSFORM, [...handle, ...floatWords(transform)]),
() => this.#enqueue(OP.DESTROY_INSTANCE, [...handle])); () => 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"); else throw new TypeError("GLB source must be URL, File, or ArrayBuffer");
if (this.#disposed) throw new RendererError("DISPOSED"); if (this.#disposed) throw new RendererError("DISPOSED");
const result = await this.#withPayload(buffer, OP.IMPORT_GLB, [framing === "interior" ? 1 : 0]); 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 = []) { async #withPayload(buffer, opcode, words = []) {
if (this.#disposed) throw new RendererError("DISPOSED"); 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"); 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) { function floatWords(matrix) {
if (!matrix || matrix.length !== 16) throw new TypeError("transform must contain 16 numbers"); if (!matrix || matrix.length !== 16) throw new TypeError("transform must contain 16 numbers");
return [...new Int32Array(new Float32Array(matrix).buffer)]; return [...new Int32Array(new Float32Array(matrix).buffer)];
@@ -273,19 +276,21 @@ class Mesh {
this.#createInstance = createInstance; this.#createInstance = createInstance;
} }
setVisible(visible) { return this.#setVisible(visible); } 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 { class Instance {
#setVisible; #setTransform; #destroy; #dead = false; #setVisible; #setType; #setTransform; #destroy; #dead = false;
constructor(token, setVisible, setTransform, destroy) { constructor(token, setVisible, setType, setTransform, destroy) {
if (token !== HANDLE_TOKEN) throw new TypeError("Instance cannot be constructed directly"); if (token !== HANDLE_TOKEN) throw new TypeError("Instance cannot be constructed directly");
this.#setVisible = setVisible; this.#setVisible = setVisible;
this.#setType = setType;
this.#setTransform = setTransform; this.#setTransform = setTransform;
this.#destroy = destroy; this.#destroy = destroy;
} }
#live() { if (this.#dead) throw new RendererError("STALE_HANDLE"); } #live() { if (this.#dead) throw new RendererError("STALE_HANDLE"); }
setVisible(visible) { this.#live(); return this.#setVisible(visible); } setVisible(visible) { this.#live(); return this.#setVisible(visible); }
setType(words) { this.#live(); return this.#setType(words); }
setTransform(transform) { this.#live(); return this.#setTransform(transform); } setTransform(transform) { this.#live(); return this.#setTransform(transform); }
async destroy() { this.#live(); await this.#destroy(); this.#dead = true; } async destroy() { this.#live(); await this.#destroy(); this.#dead = true; }
} }
+7 -5
View File
@@ -10,13 +10,15 @@ import {
spawnRequestedNode, spawnRequestedNode,
} from "../static/render-graph/node-spawn.js"; } from "../static/render-graph/node-spawn.js";
test("add-node model contains all 16 catalog types in application groups", () => { test("add-node model contains all final catalog types in application groups", () => {
assert.equal(addNodeItems.length, 16); assert.equal(addNodeItems.length, 42);
assert.deepEqual( assert.deepEqual(
[...new Set(addNodeItems.map((item) => item.group))], [...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"), []); 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, let revision = 5,
expectedType; expectedType;
const request = { compositionRevision: 5, viewPosition: { x: 12.25, y: -4 } }; const request = { compositionRevision: 5, viewPosition: { x: 12.25, y: -4 } };
+19 -2
View File
@@ -36,13 +36,30 @@ test("production render graph composition passes fxnode's public validator", asy
result.ok ? undefined : JSON.stringify(result.issues, null, 2), result.ok ? undefined : JSON.stringify(result.issues, null, 2),
); );
assert.equal(fxNodeComposition.schemaVersion, 2); assert.equal(fxNodeComposition.schemaVersion, 2);
assert.equal(fxNodeComposition.version, 7); assert.equal(fxNodeComposition.version, 8);
assert.equal(Object.keys(fxNodeComposition.nodes).length, 16); assert.equal(Object.keys(fxNodeComposition.nodes).length, 42);
assert.ok( assert.ok(
Object.values(fxNodeComposition.nodes).every( Object.values(fxNodeComposition.nodes).every(
(definition) => definition.migrations.length === 0, (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 { } finally {
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
} }
+31 -651
View File
@@ -1,660 +1,40 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import {
adaptFxNodeSnapshot,
AuthoringGraphError,
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 { culling } from "../static/render-graph/presets.js";
import { AuthoringController } from "../static/render-graph/authoring-controller.js"; import {
function fixture() { CATALOG_VERSION, semanticCatalog, nodeDefinitions, descriptors,
const nodes = culling.nodes.map((n) => { } from "../static/render-graph/catalog.js";
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);
assert.deepEqual(nodeDefinitions.color_balance.ui.slice(0, 4), [ test("catalog v8 exposes the final mesh, pipeline, and typed-expression contracts", () => {
{ kind: "parameter", parameter: "mode" }, assert.equal(CATALOG_VERSION, 8);
{ kind: "widget", widget: "grading-wheels", bindings: [ assert.deepEqual(semanticCatalog.mesh.outputs, {
{ title: "Lift", scalar: "lift", color: "liftColor" }, mesh: { type: "mesh_data" }, type: { type: "u32x16" }, localAabb: { type: "local_aabb" },
{ 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("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()); test("current culling fixture uses type-bit predicates and final socket versions", () => {
const original = new RendererError("GRAPH_INPUT", { const byId = Object.fromEntries(culling.nodes.map((node) => [node.id, node]));
message: "bad", assert.deepEqual(byId.cull.inputs.localAabb, { node: "mesh", socket: "localAabb" });
field: "nodes[0].executor.key.more", assert.deepEqual(byId.type_words.inputs.value, { node: "mesh", socket: "type" });
nested: { x: 1 }, assert.equal(byId.ground.executor.version, 2);
}); assert.equal(byId.ground.inputs.predicate.node, "ground_final");
const mapped = mapAuthoringDiagnostic(ir, original);
assert.notStrictEqual(mapped, original);
assert.equal(mapped.code, original.code);
assert.equal(mapped.source.kind, "node");
assert.equal(mapped.diagnostic, undefined);
assert.ok(Object.isFrozen(mapped) && Object.isFrozen(mapped.details.nested));
assert.equal(Object.isFrozen(original), false);
original.details.nested.x = 2;
assert.equal(mapped.details.nested.x, 1);
const unmatchedOriginal = new RendererError("GRAPH_INPUT", {
message: "unmapped",
path: "resources[0]",
});
const unmatched = mapAuthoringDiagnostic(ir, unmatchedOriginal);
assert.notStrictEqual(unmatched, unmatchedOriginal);
assert.equal(unmatched.source, undefined);
assert.ok(Object.isFrozen(unmatched) && Object.isFrozen(unmatched.details));
assert.equal(Object.isFrozen(unmatchedOriginal), false);
}); });
test("controller keeps last-good through failures and only drops after successful switch", async () => {
let fail = false; test("removed architecture is absent from the authoring catalog", () => {
const calls = []; for (const removed of ["mesh_query", "pipeline_registry"])
const renderer = { assert.equal(semanticCatalog[removed], undefined);
compileGraph: async (ir) => ({ const serialized = JSON.stringify(semanticCatalog);
compiledId: [ir.revision, 1], for (const removedSocket of ["isVisible", "localAabbs", "activation"])
revision: ir.revision, assert.equal(serialized.includes(`\"${removedSocket}\"`), false);
}),
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);
}); });
+28 -293
View File
@@ -3,304 +3,39 @@ import assert from "node:assert/strict";
import * as presets from "../static/render-graph/presets.js"; import * as presets from "../static/render-graph/presets.js";
import { descriptors } from "../static/render-graph/catalog.js"; import { descriptors } from "../static/render-graph/catalog.js";
const order = [ test("all presets use current schemas, versions, and one frame output", () => {
"midnight", assert.equal(Object.keys(presets.renderGraphPresets).length, 12);
"ember", for (const [name, graph] of Object.entries(presets.renderGraphPresets)) {
"hdr", assert.deepEqual([graph.schemaVersion, graph.revision], [2, 1], name);
"culling", assert.equal(new Set(graph.nodes.map((node) => node.id)).size, graph.nodes.length, name);
"tone", assert.equal(graph.nodes.filter((node) => node.executor.key === "frame_out").length, 1, name);
"contain", for (const node of graph.nodes)
"reinhard", assert.equal(node.executor.version, descriptors[node.executor.key].version, `${name}:${node.id}`);
"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,
),
);
} }
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", () => { test("presets classify visibility and material through type.words[0] predicates", () => {
const removed = [
"texture_spec",
"scene_table",
"local_aabb_buffer",
"camera_frustum",
"visibility_flags",
];
for (const [name, graph] of Object.entries(presets.renderGraphPresets)) { for (const [name, graph] of Object.entries(presets.renderGraphPresets)) {
const byId = Object.fromEntries(graph.nodes.map((node) => [node.id, node])); const byId = Object.fromEntries(graph.nodes.map((node) => [node.id, node]));
assert.deepEqual(byId.query.parameters, { assert.deepEqual(byId.type_words.inputs.value, { node: "mesh", socket: "type" }, name);
visiblePredicate: "required_true", assert.deepEqual(byId.type_bits.inputs.value, { node: "type_words", socket: "word0" }, name);
visibleDefault: true, const suffix = name === "culling" ? "_final" : "_class";
frustumCulledPredicate: name === "culling" ? "required_false" : "any", assert.deepEqual(byId.ground.inputs.predicate, { node: `ground${suffix}`, socket: "value" }, name);
frustumCulledDefault: false, 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);
assert.deepEqual(byId.query.inputs.mesh, { node: "mesh", socket: "mesh" }); for (const pipeline of [byId.ground, byId.pbr, byId.pbr_double]) {
assert.deepEqual(byId.query.inputs.isVisible, { assert.deepEqual(pipeline.inputs.mesh, { node: "mesh", socket: "mesh" });
node: "mesh", assert.equal(pipeline.executor.version, 2);
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)),
);
} }
const cull = Object.fromEntries( });
presets.culling.nodes.map((node) => [node.id, node]),
); test("culling adds a local-AABB expression to each material predicate", () => {
assert.deepEqual(cull.cull.parameters, { camera: "active" }); const byId = Object.fromEntries(presets.culling.nodes.map((node) => [node.id, node]));
assert.deepEqual(cull.cull.inputs, { assert.deepEqual(byId.cull.inputs, {
mesh: { node: "mesh", socket: "mesh" }, mesh: { node: "mesh", socket: "mesh" }, localAabb: { node: "mesh", socket: "localAabb" },
localAabbs: { node: "mesh", socket: "localAabbs" },
}); });
assert.deepEqual(cull.query.inputs.isFrustumCulled, { assert.deepEqual(byId.not_culled.inputs.operand, { node: "cull", socket: "isFrustumCulled" });
node: "cull", for (const id of ["ground", "pbr", "pbr_double"])
socket: "isFrustumCulled", assert.equal(byId[id].inputs.predicate.node.endsWith("_final"), true);
});
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",
});
}); });
+17 -19
View File
@@ -10,43 +10,41 @@ class WorkerMock extends EventTarget {
reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); } reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); }
} }
function fixture() { 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); 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 worker = new WorkerMock();
const bridge = {memory,ringPtr:0,worker,freed:false,free(){this.freed=true;}}; const bridge = {memory,ringPtr:0,worker,freed:false,free(){this.freed=true;}};
const client = new RendererClient(bridge); const client = new RendererClient(bridge);
return {memory,header,worker,bridge,client}; return {memory,header,worker,bridge,client};
} }
async function imported(f) { 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}); f.worker.reply({type:"payload-ready",id:1});
await Promise.resolve(); 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]; return (await loading)[0];
} }
test("replaceSceneGlb is opcode 1 and importGlb remains an alias", async()=>{ 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,[]);});
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,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("scene replacement carries the framing mode in opcode 1",async()=>{const f=fixture(),pending=f.client.replaceSceneGlb(new ArrayBuffer(8),{framing:"interior"});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();assert.deepEqual([...new Int32Array(f.memory.buffer,64,24).slice(1,5)],[1,1,1,1]);f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[]}});await pending;await assert.rejects(f.client.replaceSceneGlb(new ArrayBuffer(8),{framing:"bad"}),TypeError)});
test("writes tagged fixed-slot protocol and resolves reply", async () => { test("writes tagged fixed-slot protocol and resolves reply", async () => {
const f=fixture(); const mesh=await imported(f); const f=fixture(); const mesh=await imported(f);
const pending=mesh.setVisible(true); const pending=mesh.setVisible(true);
const {memory,header,worker}=f; const {memory,header,worker}=f;
assert.equal(Atomics.load(header,5),2); assert.equal(Atomics.load(header,5),2);
const slot=new Int32Array(memory.buffer,64+96,24); const slot=new Int32Array(memory.buffer,64+160,40);
assert.deepEqual([...slot.slice(0,6)],[1,2,2,7,3,1]); assert.deepEqual([...slot.slice(0,6)],[2,2,2,7,3,1]);
worker.reply({type:"reply",request:2,ok:true,code:"OK"}); await pending; worker.reply({type:"reply",request:2,ok:true,code:"OK"}); await pending;
}); });
test("maps stable errors and gates destroyed instances", async () => { test("maps stable errors and gates destroyed instances", async () => {
const f=fixture(), mesh=await imported(f); const {worker}=f; 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; 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; 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"); assert.throws(()=>instance.setVisible(true), error=>error instanceof RendererError&&error.code==="STALE_HANDLE");
}); });
test("rejects protocol mismatch", () => { 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/); assert.throws(()=>new RendererClient({memory,ringPtr:0,worker}), /PROTOCOL_MISMATCH/);
}); });
test("pending reply exists before ring publication", async () => { 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 () => { test("import always releases staged payload when ring is full", async () => {
const {header,worker,client}=fixture(); Atomics.store(header,5,1024); 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}); worker.reply({type:"payload-ready",id:1});
await assert.rejects(loading,/RING_FULL/); await assert.rejects(loading,/RING_FULL/);
assert.equal(worker.messages.at(-1).type,"payload-release"); 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 () => { test("corrupt backlog closes and terminates the transport", async () => {
const f=fixture(); Atomics.store(f.header,5,1025); 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. // Payload staging must first acknowledge before enqueue sees corruption.
f.worker.reply({type:"payload-ready",id:1}); f.worker.reply({type:"payload-ready",id:1});
await assert.rejects(loading,/RING_CORRUPT/); 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 () => { test("import rejects immediately after disposal", async () => {
const f=fixture(); const f=fixture();
f.client.dispose(); 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); assert.equal(f.worker.messages.length,0);
}); });
test("import rejects when disposed during asynchronous source loading", async () => { 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; let finishFetch;
globalThis.fetch=()=>new Promise(resolve=>{finishFetch=resolve;}); globalThis.fetch=()=>new Promise(resolve=>{finishFetch=resolve;});
try { try {
const loading=f.client.importGlb("model.glb"); const loading=f.client.replaceSceneGlb("model.glb");
f.client.dispose(); f.client.dispose();
finishFetch({arrayBuffer:async()=>new ArrayBuffer(8)}); finishFetch({arrayBuffer:async()=>new ArrayBuffer(8)});
await assert.rejects(loading,/DISPOSED/); 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}); 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); 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(); 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]}}); f.worker.reply({type:"reply",request:1,ok:true,result:{compiledId:[2,3]}});
assert.deepEqual(await pending,{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 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 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("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("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("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("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("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("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("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/);}); 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/);});
+17 -17
View File
@@ -5,37 +5,37 @@ import { DerivedBvh } from "../static/bvh-core.js";
import { RendererClient } from "../static/renderer-client.js"; import { RendererClient } from "../static/renderer-client.js";
const align16 = value => (value + 15) & ~15; const align16 = value => (value + 15) & ~15;
const componentCounts = [1, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1]; const componentCounts = [1, 1, 3, 3, 1, 1, 1, 1, 16, 3, 3, 16];
const scalarTypes = [1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1]; const scalarTypes = [1, 1, 2, 2, 1, 1, 1, 1, 2, 2, 2, 1];
function snapshotFixture({ instances = 1 } = {}) { function snapshotFixture({ instances = 1 } = {}) {
const memory = new WebAssembly.Memory({ initial: 4, maximum: 8, shared: true }); const memory = new WebAssembly.Memory({ initial: 4, maximum: 8, shared: true });
const words = new Uint32Array(memory.buffer); const words = new Uint32Array(memory.buffer);
const control = new Int32Array(memory.buffer, 0, 64); const control = new Int32Array(memory.buffer, 0, 64);
const ptr = 256; 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 = []; const offsets = [];
let cursor = 512; let cursor = 448;
for (let i = 0; i < 14; i++) { for (let i = 0; i < 12; i++) {
offsets.push(cursor); offsets.push(cursor);
cursor = align16(cursor + counts[i] * componentCounts[i] * 4); 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([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, 1, 64, 0, 0, 0, 0, 0], 16); 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); 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]); blob.set([0x32534452, 2, 64, cursor, 1, 7, 0, 12, 64, 32, 1, instances, 0x01020304, 3, 0, 0]);
for (let i = 0; i < 14; i++) { 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); 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 const stream = i => scalarTypes[i] === 2
? new Float32Array(memory.buffer, ptr + offsets[i], counts[i] * componentCounts[i]) ? new Float32Array(memory.buffer, ptr + offsets[i], counts[i] * componentCounts[i])
: new Uint32Array(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(0)[0] = 4; stream(1)[0] = 2;
stream(3).set([-1, -1, -1]); stream(4).set([1, 1, 1]); stream(2).set([-1, -1, -1]); stream(3).set([1, 1, 1]);
for (let i = 0; i < instances; i++) { 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(4)[i] = 10 + i; stream(5)[i] = 3; stream(6)[i] = 4; stream(7)[i] = 2;
stream(9)[i] = 1; stream(13)[i] = 1; stream(11)[i * 16] = 1;
stream(11).set([i * 4, -1, -1], i * 3); stream(12).set([i * 4 + 2, 1, 1], i * 3); stream(9).set([i * 4, -1, -1], i * 3); stream(10).set([i * 4 + 2, 1, 1], i * 3);
} }
return { memory, control, ptr, cursor }; return { memory, control, ptr, cursor };
} }
@@ -84,7 +84,7 @@ function bvhSnapshot({ pickable = [1, 1], shifted = false } = {}) {
return { instanceCount: count, streams: { return { instanceCount: count, streams: {
instanceSlot: Uint32Array.from([5, 6]), instanceGeneration: Uint32Array.from([1, 1]), instanceSlot: Uint32Array.from([5, 6]), instanceGeneration: Uint32Array.from([1, 1]),
instanceMeshSlot: Uint32Array.from([2, 2]), instanceMeshGeneration: Uint32Array.from([4, 4]), 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]), 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]), 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 scene = snapshotFixture();
const ring = 8192; const ring = 8192;
const ringHeader = new Int32Array(scene.memory.buffer, ring, 16); 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 rendererWorker = new WorkerMock(), bvhWorker = new WorkerMock();
const bridge = { memory: scene.memory, ringPtr: ring, worker: rendererWorker, workerFactory: () => bvhWorker, free() {} }; const bridge = { memory: scene.memory, ringPtr: ring, worker: rendererWorker, workerFactory: () => bvhWorker, free() {} };
const client = new RendererClient(bridge); 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 }); rendererWorker.reply({ type: "snapshot-published", epoch: 1 });
const picking = client.pickRay([0, 0, 0], [1, 0, 0]); const picking = client.pickRay([0, 0, 0], [1, 0, 0]);
const request = bvhWorker.messages.find(message => message.type === "pick"); const request = bvhWorker.messages.find(message => message.type === "pick");