diff --git a/renderer/src/render_graph/compiler.rs b/renderer/src/render_graph/compiler.rs index 9c561ba..d266e5e 100644 --- a/renderer/src/render_graph/compiler.rs +++ b/renderer/src/render_graph/compiler.rs @@ -1,232 +1,171 @@ use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet}; -use serde::Serialize; +use serde::Deserialize; -use super::schema::*; -use super::{GraphError, MAX_JSON_BYTES, MAX_OUTPUTS, MAX_PASSES, MAX_RESOURCES, MAX_USES}; +use super::*; -pub enum ExecutorResolution<'a> { - Found(&'a dyn ExecutorContract), - UnknownKey, - UnsupportedVersion, +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Empty {} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TextureParameters { + residency: TextureResidency, + texture: TextureDescriptor, } -pub trait ExecutorRegistry { - fn resolve(&self, executor: &ExecutorRef) -> ExecutorResolution<'_>; +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct DepthParameters { + depth_compare: CompareFunction, + depth_write_enabled: bool, + clear_depth: f32, } -pub trait ExecutorContract { - fn inherently_observable(&self) -> bool; - fn normalize_parameters( - &self, - parameters: &serde_json::Value, - ) -> Result; - fn validate_bindings( - &self, - pass: &Pass, - resources: &HashMap, - ) -> Result<(), String>; +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ForwardParameters { + clear_color: [f64; 4], } -pub struct SceneForwardExecutors; -static SCENE_FORWARD: SceneForward = SceneForward; -struct SceneForward; -impl ExecutorRegistry for SceneForwardExecutors { - fn resolve(&self, e: &ExecutorRef) -> ExecutorResolution<'_> { - if e.key != "scene_forward" { - ExecutorResolution::UnknownKey - } else if e.version != 1 { - ExecutorResolution::UnsupportedVersion - } else { - ExecutorResolution::Found(&SCENE_FORWARD) - } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ToneMapParameters { + exposure: f32, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BloomExtractParameters { + threshold: f32, + knee: f32, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BloomBlurParameters { + direction: [f32; 2], + radius: f32, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BloomCompositeParameters { + intensity: f32, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LuminanceEdgeParameters { + strength: f32, +} + +fn range(value: f32, min: f32, max: f32, path: String) -> Result { + if value.is_finite() && (min..=max).contains(&value) { + Ok(value) + } else { + Err(error( + "GRAPH_PARAMETERS_INVALID", + &format!("value must be finite and in [{min},{max}]"), + path, + )) } } -impl ExecutorContract for SceneForward { - fn inherently_observable(&self) -> bool { - false + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +struct OutputKey(usize, u16); +#[derive(Clone, Copy)] +struct BoundInput { + producer: OutputKey, + active: bool, +} +#[derive(Clone)] +struct DependencyEdge { + from_node: usize, + from_socket: String, + producer_output_ordinal: u16, + to_node: usize, + to_socket: String, + consumer_input_ordinal: u16, + resource: NodeOutputRef, +} + +#[derive(Clone, Copy)] +struct TextureTransition { + writer_node: usize, + input_socket: &'static str, + target: OutputKey, + output: OutputKey, +} + +#[derive(Clone, Copy)] +enum ResolvedTransition { + Resolved { + family: u32, + version: u32, + target: OutputKey, + }, + Cyclic, +} + +fn reaches( + from: usize, + to: usize, + outgoing_edges: &[Vec], + edges: &[DependencyEdge], + live: &HashSet, + memo: &mut HashMap<(usize, usize), bool>, +) -> bool { + if let Some(&answer) = memo.get(&(from, to)) { + return answer; } - fn normalize_parameters( - &self, - value: &serde_json::Value, - ) -> Result { - if value == &serde_json::json!({}) { - Ok(NormalizedParameters::SceneForward) - } else { - Err("requires parameters {}".into()) + let mut stack = vec![from]; + let mut visited = HashSet::new(); + let mut answer = false; + while let Some(node) = stack.pop() { + if !visited.insert(node) { + continue; + } + if node == to { + answer = true; + break; + } + for &edge_index in &outgoing_edges[node] { + let next = edges[edge_index].to_node; + if live.contains(&next) { + stack.push(next); + } } } - fn validate_bindings( - &self, - p: &Pass, - r: &HashMap, - ) -> Result<(), String> { - if !p.reads.is_empty() || p.writes.len() != 2 { - return Err( - "requires parameters {}, no reads, and exactly color and depth bindings".into(), - ); - } - let color = p.writes.iter().find(|x| x.binding == "color"); - let depth = p.writes.iter().find(|x| x.binding == "depth"); - let (Some(c), Some(d)) = (color, depth) else { - return Err("requires bindings named color and depth".into()); - }; - if !matches!(c.access, WriteAccess::ColorAttachment { location: 0, .. }) - || !matches!(d.access, WriteAccess::DepthAttachment { .. }) - { - return Err("color must be attachment location 0 and depth a depth attachment".into()); - } - let (c, d) = (r[&c.resource], r[&d.resource]); - if d.texture.format != Format::Depth32Float - || c.texture.format == Format::Depth32Float - || c.texture.extent != d.texture.extent - || c.texture.sample_count != d.texture.sample_count - { - return Err( - "attachments must match extent/sample count and depth must be depth32_float".into(), - ); - } + memo.insert((from, to), answer); + answer +} + +fn error(code: &'static str, message: &str, path: impl Into) -> GraphError { + GraphError::at(code, message, path) +} +fn validate_name_length(s: &str, path: impl Into) -> Result<(), GraphError> { + if s.len() > 64 { + Err(error( + "GRAPH_LIMIT_EXCEEDED", + "identifier exceeds 64 bytes", + path.into(), + )) + } else { + Ok(()) + } +} +fn validate_name_grammar(s: &str, path: impl Into) -> Result<(), GraphError> { + if s.is_empty() || !identifier(s) { + Err(error("GRAPH_INVALID_ID", "invalid identifier", path)) + } else { Ok(()) } } -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum NormalizedParameters { - SceneForward, -} - -#[derive(Debug, Clone, Serialize)] -pub struct CompiledRead { - pub binding: String, - pub resource: u32, - pub access: ReadAccess, -} -#[derive(Debug, Clone, Serialize)] -pub struct CompiledWrite { - pub binding: String, - pub resource: u32, - pub access: WriteAccess, -} - -#[derive(Debug, Clone, Serialize)] -pub struct CompiledPass { - pub id: String, - pub original_index: u32, - pub executor: ExecutorRef, - pub parameters: NormalizedParameters, - pub reads: Vec, - pub writes: Vec, -} -#[derive(Debug, Clone, Copy, Serialize)] -pub struct Lifetime { - pub first_use: u32, - pub last_use: u32, -} -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] -pub enum TextureUsage { - Sampled, - Storage, - CopySrc, - CopyDst, - ColorAttachment, - DepthAttachment, -} -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] -pub struct TextureAllocationKey { - pub descriptor: TextureDescriptor, - pub usage: Vec, - #[serde(rename = "viewFormats")] - pub view_formats: Vec, -} -#[derive(Debug, Clone, Serialize)] -pub struct CompiledResource { - pub original_index: u32, - #[serde(rename = "ref")] - pub resource_ref: ResourceRef, - pub residency: Residency, - pub descriptor: TextureDescriptor, - pub writer: Option, - pub lifetime: Lifetime, - pub allocation: Option, -} -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -pub struct TransientAllocation { - pub class: u32, - pub slot: u32, -} -#[derive(Debug, Clone, Serialize)] -pub struct CompiledOutput { - pub name: String, - pub resource: u32, -} -#[derive(Debug, Clone, Serialize)] -pub struct AllocationClass { - pub key: TextureAllocationKey, - pub slot_count: u32, -} -#[derive(Debug, Clone, Serialize)] -pub struct CompiledGraph { - pub schema_version: u32, - pub graph_id: String, - pub revision: u32, - pub passes: Vec, - pub resources: Vec, - pub outputs: Vec, - pub allocation_classes: Vec, - pub culled_pass_count: u32, - pub culled_resource_count: u32, - pub transient_slot_count: u32, -} -impl CompiledGraph { - pub fn summary(&self, id: [u32; 2]) -> serde_json::Value { - serde_json::json!({"compiledId":id,"graphId":self.graph_id,"revision":self.revision,"schemaVersion":self.schema_version,"passCount":self.passes.len(),"resourceCount":self.resources.len(),"culledPassCount":self.culled_pass_count,"culledResourceCount":self.culled_resource_count,"transientSlotCount":self.transient_slot_count}) - } -} - -fn fail(code: &'static str, msg: impl Into, path: impl Into) -> GraphError { - GraphError::at(code, msg, path) -} -fn norm(mut d: TextureDescriptor) -> TextureDescriptor { - fn gcd(mut a: u32, mut b: u32) -> u32 { - while b != 0 { - let n = a % b; - a = b; - b = n - } - a - } - if let Extent::SurfaceRelative { width, height, .. } = &mut d.extent { - let g = gcd(width.numerator, width.denominator); - width.numerator /= g; - width.denominator /= g; - let g = gcd(height.numerator, height.denominator); - height.numerator /= g; - height.denominator /= g - } - d -} -fn usage_read(a: ReadAccess) -> TextureUsage { - match a { - ReadAccess::Sampled => TextureUsage::Sampled, - ReadAccess::Storage => TextureUsage::Storage, - ReadAccess::CopySrc => TextureUsage::CopySrc, - } -} -fn usage_write(a: &WriteAccess) -> TextureUsage { - match a { - WriteAccess::Storage => TextureUsage::Storage, - WriteAccess::CopyDst => TextureUsage::CopyDst, - WriteAccess::ColorAttachment { .. } => TextureUsage::ColorAttachment, - WriteAccess::DepthAttachment { .. } => TextureUsage::DepthAttachment, +pub fn mesh_predicate_matches(predicate: TriStatePredicate, flag: bool) -> bool { + match predicate { + TriStatePredicate::Any => true, + TriStatePredicate::RequiredTrue => flag, + TriStatePredicate::RequiredFalse => !flag, } } pub fn parse_and_compile(bytes: &[u8]) -> Result { - compile_with(bytes, &SceneForwardExecutors) -} -pub fn compile_with( - bytes: &[u8], - executors: &dyn ExecutorRegistry, -) -> Result { if bytes.len() > MAX_JSON_BYTES { return Err(GraphError::new( "GRAPH_PAYLOAD_TOO_LARGE", @@ -235,865 +174,1778 @@ pub fn compile_with( } let text = std::str::from_utf8(bytes) .map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph payload is not UTF-8"))?; - let value: serde_json::Value = serde_json::from_str(text) + let probe: serde_json::Value = serde_json::from_str(text) .map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?; - let version = value.get("schemaVersion").and_then(|v| v.as_u64()); - if version != Some(1) { + if probe.get("schemaVersion").and_then(|v| v.as_u64()) != Some(2) { return Err(GraphError::new( "GRAPH_SCHEMA_UNSUPPORTED", - "schemaVersion must be 1", + "schemaVersion must be 2", )); } - let g: GraphV1 = serde_json::from_str(text) + let graph = serde_json::from_str(text) .map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?; - compile(g, executors) + compile(graph) } -pub fn compile( - mut g: GraphV1, - executors: &dyn ExecutorRegistry, -) -> Result { - if g.schema_version != 1 { + +fn gcd(mut a: u32, mut b: u32) -> u32 { + while b != 0 { + (a, b) = (b, a % b); + } + a +} +fn compatible_view(a: TextureFormat, b: TextureFormat) -> bool { + matches!( + (a, b), + (TextureFormat::Rgba8Unorm, TextureFormat::Rgba8UnormSrgb) + | (TextureFormat::Rgba8UnormSrgb, TextureFormat::Rgba8Unorm) + | (TextureFormat::Bgra8Unorm, TextureFormat::Bgra8UnormSrgb) + | (TextureFormat::Bgra8UnormSrgb, TextureFormat::Bgra8Unorm) + ) +} +fn normalize_texture( + d: TextureDescriptor, + base: &str, +) -> Result { + let bad = |message: &str, suffix: &str| { + error( + "GRAPH_PARAMETERS_INVALID", + message, + format!("{base}.texture.{suffix}"), + ) + }; + let (extent, w, h, layers, relative) = match d.extent { + TextureExtent::Absolute { + width, + height, + depth_or_array_layers, + } => ( + NormalizedTextureExtent::Absolute { + width, + height, + depth_or_array_layers, + }, + width, + height, + depth_or_array_layers, + false, + ), + TextureExtent::SurfaceRelative { + mut width, + mut height, + depth_or_array_layers, + } => { + if width.numerator == 0 + || width.denominator == 0 + || height.numerator == 0 + || height.denominator == 0 + || depth_or_array_layers == 0 + { + return Err(bad( + "extent components and ratio terms must be nonzero", + "extent", + )); + } + let g = gcd(width.numerator, width.denominator); + width.numerator /= g; + width.denominator /= g; + let g = gcd(height.numerator, height.denominator); + height.numerator /= g; + height.denominator /= g; + ( + NormalizedTextureExtent::SurfaceRelative { + width, + height, + depth_or_array_layers, + }, + 1, + 1, + depth_or_array_layers, + true, + ) + } + }; + if w == 0 || h == 0 || layers == 0 { + return Err(bad("extent components must be nonzero", "extent")); + } + if relative && d.dimension != TextureDimension::D2 { + return Err(bad("surface-relative textures must be d2", "extent")); + } + if d.dimension == TextureDimension::D1 && (h != 1 || layers != 1) { + return Err(bad( + "d1 textures require height and layers equal to one", + "extent", + )); + } + if d.format == TextureFormat::Depth32Float && d.dimension != TextureDimension::D2 { + return Err(bad("depth textures must be d2", "dimension")); + } + if !matches!(d.sample_count, 1 | 4) { + return Err(bad("sampleCount must be 1 or 4", "sampleCount")); + } + if d.mip_level_count == 0 { + return Err(bad("mipLevelCount must be at least one", "mipLevelCount")); + } + if d.sample_count == 4 + && (d.dimension != TextureDimension::D2 || d.mip_level_count != 1 || layers != 1) + { + return Err(bad( + "multisampled textures must be d2, single-mip, single-layer", + "sampleCount", + )); + } + let max_dim = w.max(h).max(if d.dimension == TextureDimension::D3 { + layers + } else { + 1 + }); + let max_mips = 32 - max_dim.leading_zeros(); + if !relative && d.mip_level_count > max_mips { + return Err(bad( + "mipLevelCount exceeds the full mip chain", + "mipLevelCount", + )); + } + let limit = if d.dimension == TextureDimension::D3 { + 2048 + } else { + 8192 + }; + if w > limit + || h > limit + || (d.dimension == TextureDimension::D3 && layers > 2048) + || (d.dimension != TextureDimension::D3 && layers > 256) + { + return Err(bad("texture exceeds dimension limits", "extent")); + } + for (j, &view) in d.view_formats.iter().enumerate() { + if view == d.format || !compatible_view(d.format, view) { + return Err(bad( + "view format must be compatible and exclude the base format", + &format!("viewFormats[{j}]"), + )); + } + } + let mut views = d.view_formats; + views.sort(); + views.dedup(); + Ok(NormalizedTextureDescriptor { + dimension: d.dimension, + format: d.format, + extent, + mip_level_count: d.mip_level_count, + sample_count: d.sample_count, + view_formats: views, + }) +} + +fn decode(node: &Node, i: usize) -> Result { + let base = format!("nodes[{i}].parameters"); + let invalid = + |e: serde_json::Error| error("GRAPH_PARAMETERS_INVALID", &e.to_string(), base.clone()); + macro_rules! empty { + ($variant:expr) => {{ + serde_json::from_value::(node.parameters.clone()).map_err(invalid)?; + $variant + }}; + } + Ok(match node.executor.key.as_str() { + "surface_target" => empty!(NormalizedParameters::SurfaceTarget), + "scene_table" => empty!(NormalizedParameters::SceneTable), + "local_aabb_buffer" => empty!(NormalizedParameters::LocalAabbBuffer), + "camera_frustum" => empty!(NormalizedParameters::CameraFrustum), + "visibility_flags" => empty!(NormalizedParameters::VisibilityFlags), + "frustum_cull" => empty!(NormalizedParameters::FrustumCull), + "fullscreen_copy" => empty!(NormalizedParameters::FullscreenCopy), + "tone_map" => { + let p: ToneMapParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + NormalizedParameters::ToneMap { + exposure: range(p.exposure, 0.0, 32.0, format!("{base}.exposure"))?, + } + } + "bloom_extract" => { + let p: BloomExtractParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + NormalizedParameters::BloomExtract { + threshold: range(p.threshold, 0.0, 64.0, format!("{base}.threshold"))?, + knee: range(p.knee, 0.0, 1.0, format!("{base}.knee"))?, + } + } + "bloom_blur" => { + let p: BloomBlurParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + let x = range(p.direction[0], -1.0, 1.0, format!("{base}.direction[0]"))?; + let y = range(p.direction[1], -1.0, 1.0, format!("{base}.direction[1]"))?; + if (x.abs() + y.abs() - 1.0).abs() > 0.0001 { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "direction must be a unit axis", + format!("{base}.direction"), + )); + } + NormalizedParameters::BloomBlur { + direction: [x, y], + radius: range(p.radius, 1.0, 16.0, format!("{base}.radius"))?, + } + } + "bloom_composite" => { + let p: BloomCompositeParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + NormalizedParameters::BloomComposite { + intensity: range(p.intensity, 0.0, 16.0, format!("{base}.intensity"))?, + } + } + "luminance_edge" => { + let p: LuminanceEdgeParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + NormalizedParameters::LuminanceEdge { + strength: range(p.strength, 0.0, 16.0, format!("{base}.strength"))?, + } + } + "present" => empty!(NormalizedParameters::Present), + "texture_spec" => { + let p: TextureParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + if matches!( + p.residency, + TextureResidency::History | TextureResidency::Readback + ) { + return Err(error( + "GRAPH_UNSUPPORTED_FEATURE", + "history and readback textures are unsupported", + format!("{base}.residency"), + )); + } + NormalizedParameters::TextureSpec { + residency: p.residency, + texture: normalize_texture(p.texture, &base)?, + } + } + "mesh_query" => { + let object = node.parameters.as_object().ok_or_else(|| { + error( + "GRAPH_PARAMETERS_INVALID", + "parameters must be an object", + base.clone(), + ) + })?; + if object.len() != 1 || !object.contains_key("filters") { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "mesh query parameters must contain only filters", + base.clone(), + )); + } + let filters = object["filters"].as_array().ok_or_else(|| { + error( + "GRAPH_PARAMETERS_INVALID", + "filters must be an array", + format!("{base}.filters"), + ) + })?; + let mut found = [None, None]; + for (j, value) in filters.iter().enumerate() { + let filter = value.as_object().ok_or_else(|| { + error( + "GRAPH_PARAMETERS_INVALID", + "filter must be an object", + format!("{base}.filters[{j}]"), + ) + })?; + if filter.len() != 2 + || !filter.contains_key("flag") + || !filter.contains_key("predicate") + { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "filter must contain flag and predicate", + format!("{base}.filters[{j}]"), + )); + } + let flag: MeshFlag = + serde_json::from_value(filter["flag"].clone()).map_err(|e| { + error( + "GRAPH_PARAMETERS_INVALID", + &e.to_string(), + format!("{base}.filters[{j}].flag"), + ) + })?; + let predicate: TriStatePredicate = + serde_json::from_value(filter["predicate"].clone()).map_err(|e| { + error( + "GRAPH_PARAMETERS_INVALID", + &e.to_string(), + format!("{base}.filters[{j}].predicate"), + ) + })?; + let index = if flag == MeshFlag::IsVisible { 0 } else { 1 }; + if found[index].replace(predicate).is_some() { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "duplicate mesh flag", + format!("{base}.filters[{j}].flag"), + )); + } + } + if found.iter().any(Option::is_none) { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "both mesh flags are required", + format!("{base}.filters"), + )); + } + NormalizedParameters::MeshQuery { + filters: [ + NormalizedMeshFilter { + flag: MeshFlag::IsVisible, + predicate: found[0].unwrap(), + }, + NormalizedMeshFilter { + flag: MeshFlag::IsFrustumCulled, + predicate: found[1].unwrap(), + }, + ], + } + } + "depth_stencil_config" => { + let p: DepthParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + if !p.clear_depth.is_finite() || !(0.0..=1.0).contains(&p.clear_depth) { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "clearDepth must be finite and in [0,1]", + format!("{base}.clearDepth"), + )); + } + NormalizedParameters::DepthStencilConfig { + config: NormalizedDepthStencil { + depth_compare: p.depth_compare, + depth_write_enabled: p.depth_write_enabled, + clear_depth: p.clear_depth, + }, + } + } + "legacy_forward" => { + let p: ForwardParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + if p.clear_color.iter().any(|x| !x.is_finite()) { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "clearColor must be finite", + format!("{base}.clearColor"), + )); + } + NormalizedParameters::LegacyForward { + clear_color: p.clear_color, + } + } + _ => unreachable!(), + }) +} + +fn accepts(c: TypeConstraint, ty: SemanticType) -> bool { + match c { + TypeConstraint::Exact(x) => x == ty, + TypeConstraint::OneOf(xs) => xs.contains(&ty), + } +} + +pub fn compile(graph: Graph) -> Result { + if graph.nodes.len() > MAX_EXECUTIONS { + return Err(error( + "GRAPH_LIMIT_EXCEEDED", + "node count exceeds 1024", + "nodes", + )); + } + let mut input_count = 0usize; + for (i, node) in graph.nodes.iter().enumerate() { + input_count = input_count.saturating_add(node.inputs.len()); + if input_count > 8192 { + return Err(error( + "GRAPH_LIMIT_EXCEEDED", + "input count exceeds 8192", + format!("nodes[{i}].inputs"), + )); + } + } + if graph + .nodes + .iter() + .filter(|n| n.executor.key == "present") + .count() + > 64 + { + return Err(error( + "GRAPH_LIMIT_EXCEEDED", + "present count exceeds 64", + "nodes", + )); + } + if graph.schema_version != 2 { return Err(GraphError::new( "GRAPH_SCHEMA_UNSUPPORTED", - "schemaVersion must be 1", + "schemaVersion must be 2", )); } - if g.resources.len() > MAX_RESOURCES - || g.passes.len() > MAX_PASSES - || g.outputs.len() > MAX_OUTPUTS - || g.passes - .iter() - .map(|p| p.reads.len() + p.writes.len()) - .sum::() - > MAX_USES - { - return Err(GraphError::new( - "GRAPH_LIMIT_EXCEEDED", - "graph limit exceeded", - )); - } - let identifiers = std::iter::once(&g.graph_id) - .chain(g.resources.iter().map(|resource| &resource.id)) - .chain(g.passes.iter().flat_map(|pass| { - std::iter::once(&pass.id) - .chain(std::iter::once(&pass.executor.key)) - .chain( - pass.reads - .iter() - .flat_map(|binding| [&binding.binding, &binding.resource.id]), - ) - .chain( - pass.writes - .iter() - .flat_map(|binding| [&binding.binding, &binding.resource.id]), - ) - })) - .chain( - g.outputs - .iter() - .flat_map(|output| [&output.name, &output.resource.id]), - ); - if identifiers - .into_iter() - .any(|identifier| identifier.len() > 64) - { - return Err(GraphError::new( - "GRAPH_LIMIT_EXCEEDED", - "identifier exceeds 64 UTF-8 bytes", - )); - } - if !identifier(&g.graph_id) || g.revision == 0 { - return Err(fail( - "GRAPH_INVALID_ID", - "invalid graphId or revision", - "graphId", - )); - } - let mut resource_ids = HashSet::new(); - let mut external = HashSet::new(); - for (i, r) in g.resources.iter().enumerate() { - let rr = ResourceRef { - id: r.id.clone(), - version: r.version, - }; - if !identifier(&r.id) { - return Err(fail( - "GRAPH_INVALID_ID", - "invalid resource id", - format!("resources[{i}].id"), - )); + validate_name_length(&graph.graph_id, "graphId")?; + for (i, n) in graph.nodes.iter().enumerate() { + for (value, path) in [ + (&n.id, format!("nodes[{i}].id")), + (&n.executor.key, format!("nodes[{i}].executor.key")), + ] { + validate_name_length(value, path)?; } - if !resource_ids.insert(rr) { - return Err(fail( + for (socket, r) in &n.inputs { + for (value, path) in [ + (socket, format!("nodes[{i}].inputs.{socket}")), + (&r.node, format!("nodes[{i}].inputs.{socket}.node")), + (&r.socket, format!("nodes[{i}].inputs.{socket}.socket")), + ] { + validate_name_length(value, path)?; + } + } + } + + validate_name_grammar(&graph.graph_id, "graphId")?; + let mut ids = HashMap::new(); + for (i, n) in graph.nodes.iter().enumerate() { + for (value, path) in [ + (&n.id, format!("nodes[{i}].id")), + (&n.executor.key, format!("nodes[{i}].executor.key")), + ] { + validate_name_grammar(value, path)?; + } + if ids.insert(n.id.as_str(), i).is_some() { + return Err(error( "GRAPH_DUPLICATE_ID", - "duplicate resource id/version", - format!("resources[{i}]"), + "duplicate node id", + format!("nodes[{i}].id"), )); } - if let Residency::External { source } = r.residency { - if !external.insert(source) { - return Err(fail( - "GRAPH_DUPLICATE_ID", - "duplicate external source", - format!("resources[{i}].residency"), + for (socket, r) in &n.inputs { + for (value, path) in [ + (socket, format!("nodes[{i}].inputs.{socket}")), + (&r.node, format!("nodes[{i}].inputs.{socket}.node")), + (&r.socket, format!("nodes[{i}].inputs.{socket}.socket")), + ] { + validate_name_grammar(value, path)?; + } + } + } + for (i, n) in graph.nodes.iter().enumerate() { + for (s, r) in &n.inputs { + if !ids.contains_key(r.node.as_str()) { + return Err(error( + "GRAPH_UNKNOWN_NODE", + "unknown input node", + format!("nodes[{i}].inputs.{s}.node"), )); } } } - let mut pass_ids = HashSet::new(); - for (pi, p) in g.passes.iter().enumerate() { - if !identifier(&p.id) || !identifier(&p.executor.key) { - return Err(fail( - "GRAPH_INVALID_ID", - "invalid pass or executor id", - format!("passes[{pi}]"), - )); - } - if !pass_ids.insert(&p.id) { - return Err(fail( - "GRAPH_DUPLICATE_ID", - "duplicate pass id", - format!("passes[{pi}].id"), - )); - } - } - let mut output_names = HashSet::new(); - for (i, o) in g.outputs.iter().enumerate() { - if !identifier(&o.name) || !output_names.insert(&o.name) { - return Err(fail( - if identifier(&o.name) { - "GRAPH_DUPLICATE_ID" - } else { - "GRAPH_INVALID_ID" - }, - "invalid or duplicate output", - format!("outputs[{i}]"), - )); - } - } - for (i, resource) in g.resources.iter_mut().enumerate() { - let valid_ratio = match &resource.texture.extent { - Extent::SurfaceRelative { width, height, .. } => { - width.numerator != 0 - && width.denominator != 0 - && height.numerator != 0 - && height.denominator != 0 - } - Extent::Absolute { .. } => true, - }; - if !valid_ratio { - return Err(fail( - "GRAPH_ILLEGAL_ACCESS", - "invalid extent", - format!("resources[{i}].texture.extent"), - )); - } - resource.texture = norm(resource.texture.clone()); - } - for (i, r) in g.resources.iter().enumerate() { - if r.texture.mip_level_count != 1 || r.texture.sample_count != 1 { - return Err(fail( - "GRAPH_UNSUPPORTED_FEATURE", - "V1 mipLevels and sampleCount must be 1", - format!("resources[{i}].texture"), - )); - } - let valid = match &r.texture.extent { - Extent::Absolute { - width, - height, - depth_or_array_layers, - } => *width > 0 && *height > 0 && *depth_or_array_layers > 0, - Extent::SurfaceRelative { - width, - height, - depth_or_array_layers, - } => { - width.numerator > 0 - && width.denominator > 0 - && height.numerator > 0 - && height.denominator > 0 - && *depth_or_array_layers > 0 - } - }; - if !valid { - return Err(fail( - "GRAPH_ILLEGAL_ACCESS", - "invalid extent", - format!("resources[{i}].texture.extent"), - )); - } - match r.residency { - Residency::External { .. } => { - let surface = TextureDescriptor { - dimension: Dimension::D2, - format: Format::Surface, - extent: Extent::SurfaceRelative { - width: Ratio { - numerator: 1, - denominator: 1, - }, - height: Ratio { - numerator: 1, - denominator: 1, - }, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - }; - if r.texture != surface { - return Err(fail( - "GRAPH_ILLEGAL_ACCESS", - "external source must use the exact surface descriptor", - format!("resources[{i}]"), - )); - } - } - Residency::Transient => { - if r.texture.format == Format::Surface { - return Err(fail( - "GRAPH_ILLEGAL_ACCESS", - "transient cannot use surface format", - format!("resources[{i}]"), - )); - } - } - } - if matches!(r.texture.extent, Extent::SurfaceRelative { .. }) - && r.texture.dimension != Dimension::D2 - { - return Err(fail( - "GRAPH_ILLEGAL_ACCESS", - "surface-relative extent requires d2", - format!("resources[{i}].texture"), - )); - } - if r.texture.format == Format::Depth32Float && r.texture.dimension != Dimension::D2 { - return Err(fail( - "GRAPH_ILLEGAL_ACCESS", - "depth32_float requires d2", - format!("resources[{i}].texture"), - )); - } - if r.texture.dimension == Dimension::D1 - && !matches!( - r.texture.extent, - Extent::Absolute { - height: 1, - depth_or_array_layers: 1, - .. - } - ) - { - return Err(fail( - "GRAPH_ILLEGAL_ACCESS", - "d1 textures require height and depthOrArrayLayers to be 1", - format!("resources[{i}].texture.extent"), - )); - } - } - let map: HashMap = g - .resources + let contracts: Vec<_> = graph + .nodes .iter() - .map(|r| { - ( - ResourceRef { - id: r.id.clone(), - version: r.version, - }, - r, - ) + .enumerate() + .map(|(i, n)| { + contract(&n.executor.key).ok_or_else(|| { + error( + "GRAPH_UNKNOWN_EXECUTOR", + "unknown executor", + format!("nodes[{i}].executor.key"), + ) + }) }) - .collect(); - for (pi, pass) in g.passes.iter().enumerate() { - for resource_ref in pass - .reads - .iter() - .map(|binding| &binding.resource) - .chain(pass.writes.iter().map(|binding| &binding.resource)) + .collect::>()?; + for (i, n) in graph.nodes.iter().enumerate() { + if n.executor.version != contracts[i].version { + return Err(error( + "GRAPH_EXECUTOR_VERSION_UNSUPPORTED", + "unsupported executor version", + format!("nodes[{i}].executor.version"), + )); + } + } + let params: Vec<_> = graph + .nodes + .iter() + .enumerate() + .map(|(i, n)| decode(n, i)) + .collect::>()?; + for (i, n) in graph.nodes.iter().enumerate() { + if n.state != NodeState::Enabled { + return Err(error( + "GRAPH_NODE_STATE_INVALID", + "muted nodes are unsupported", + format!("nodes[{i}].state"), + )); + } + } + + // Socket validation is intentionally global and phased. In particular, no + // cardinality or semantic error may hide a later structural socket error. + for (i, n) in graph.nodes.iter().enumerate() { + for name in n.inputs.keys() { + if !contracts[i].inputs.iter().any(|s| s.name == name) { + return Err(error( + "GRAPH_UNKNOWN_SOCKET", + "unknown input socket", + format!("nodes[{i}].inputs.{name}"), + )); + } + } + } + for (i, n) in graph.nodes.iter().enumerate() { + for (name, r) in &n.inputs { + let pn = ids[r.node.as_str()]; + if !contracts[pn].outputs.iter().any(|out| out.name == r.socket) { + return Err(error( + "GRAPH_UNKNOWN_SOCKET", + "unknown output socket", + format!("nodes[{i}].inputs.{name}.socket"), + )); + } + } + } + for (i, n) in graph.nodes.iter().enumerate() { + for input in contracts[i].inputs { + let inactive = matches!(¶ms[i], NormalizedParameters::MeshQuery { filters } if filters.iter().any(|f| f.flag.input_socket() == input.name && f.predicate == TriStatePredicate::Any)); + if !n.inputs.contains_key(input.name) { + if input.cardinality == InputCardinality::RequiredOne + || (!inactive + && matches!(params[i], NormalizedParameters::MeshQuery { .. }) + && input.name != "scene") + { + return Err(error( + "GRAPH_SOCKET_CARDINALITY", + "required input is missing", + format!("nodes[{i}].inputs.{}", input.name), + )); + } + } + } + } + + let mut bound: Vec> = vec![BTreeMap::new(); graph.nodes.len()]; + for (i, n) in graph.nodes.iter().enumerate() { + for input in contracts[i].inputs { + let inactive = matches!(¶ms[i], NormalizedParameters::MeshQuery { filters } if filters.iter().any(|f| f.flag.input_socket() == input.name && f.predicate == TriStatePredicate::Any)); + let Some(r) = n.inputs.get(input.name) else { + continue; + }; + let pn = ids[r.node.as_str()]; + let (ordinal, out) = contracts[pn] + .outputs + .iter() + .enumerate() + .find(|(_, o)| o.name == r.socket) + .expect("producer sockets were globally validated"); + let attachment_shape_checked_later = contracts[i].key == "legacy_forward" + && input.name == "depthTarget" + && out.semantic_type == SemanticType::SurfaceTarget; + if !accepts(input.accepted, out.semantic_type) && !attachment_shape_checked_later { + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "socket type mismatch", + format!("nodes[{i}].inputs.{}", input.name), + )); + } + if let Some(flag) = MeshFlag::ORDERED + .iter() + .find(|f| f.input_socket() == input.name) + { + if out.metadata != (OutputMetadata::BooleanFlag { flag: *flag }) { + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "mesh flag metadata mismatch", + format!("nodes[{i}].inputs.{}", input.name), + )); + } + } + bound[i].insert( + input.name, + BoundInput { + producer: OutputKey(pn, ordinal as u16), + active: !inactive, + }, + ); + } + } + let root = |key: OutputKey, + bound: &Vec>, + contracts: &Vec<&Contract>| + -> Option { + let mut k = key; + let mut seen = HashSet::new(); + loop { + if !seen.insert(k.0) { + return None; + } + if contracts[k.0].outputs[k.1 as usize].semantic_type == SemanticType::SceneTable { + return Some(k); + } + k = bound[k.0].get("scene")?.producer; + } + }; + for (i, c) in contracts.iter().enumerate() { + if c.key == "frustum_cull" + && root(bound[i]["scene"].producer, &bound, &contracts) + != root(bound[i]["localAabbs"].producer, &bound, &contracts) { - if !identifier(&resource_ref.id) { - return Err(fail( - "GRAPH_INVALID_ID", - "invalid resource reference id", - format!("passes[{pi}]"), - )); - } - if !resource_ids.contains(resource_ref) { - return Err(fail( - "GRAPH_UNKNOWN_RESOURCE", - "unknown resource reference", - format!("passes[{pi}]"), - )); + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "scene roots differ", + format!("nodes[{i}].inputs.localAabbs"), + )); + } + if matches!(c.key, "mesh_query" | "legacy_forward") { + let scene = root(bound[i]["scene"].producer, &bound, &contracts); + for (s, b) in &bound[i] { + if b.active + && matches!(*s, "isVisible" | "isFrustumCulled" | "draws") + && root(b.producer, &bound, &contracts) != scene + { + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "scene roots differ", + format!("nodes[{i}].inputs.{s}"), + )); + } } } } - for (i, output) in g.outputs.iter().enumerate() { - if !identifier(&output.resource.id) { - return Err(fail( - "GRAPH_INVALID_ID", - "invalid output resource id", - format!("outputs[{i}].resource.id"), - )); - } - if !resource_ids.contains(&output.resource) { - return Err(fail( - "GRAPH_UNKNOWN_RESOURCE", - "unknown output resource", - format!("outputs[{i}]"), - )); + let mut edges = Vec::new(); + for i in 0..graph.nodes.len() { + for (input_ordinal, input) in contracts[i].inputs.iter().enumerate() { + if let Some(b) = bound[i].get(input.name).filter(|b| b.active) { + edges.push(DependencyEdge { + from_node: b.producer.0, + from_socket: contracts[b.producer.0].outputs[b.producer.1 as usize] + .name + .into(), + producer_output_ordinal: b.producer.1, + to_node: i, + to_socket: input.name.into(), + consumer_input_ordinal: input_ordinal as u16, + resource: graph.nodes[i].inputs[input.name].clone(), + }); + } } } - let mut observable = vec![false; g.passes.len()]; - let mut parameters = Vec::with_capacity(g.passes.len()); - // Validate executor signatures and pass-local binding identity before building - // the graph-wide writer map. Access legality is deliberately checked later. - for (pi, p) in g.passes.iter().enumerate() { - let contract = match executors.resolve(&p.executor) { - ExecutorResolution::Found(contract) => contract, - resolution => { - return Err(GraphError::at( - if matches!(resolution, ExecutorResolution::UnsupportedVersion) { - "GRAPH_EXECUTOR_VERSION_UNSUPPORTED" - } else { - "GRAPH_UNKNOWN_EXECUTOR" + edges.sort_by_key(|e| { + ( + e.to_node, + e.consumer_input_ordinal, + e.from_node, + e.producer_output_ordinal, + ) + }); + let mut deps = vec![Vec::new(); graph.nodes.len()]; + for edge in &edges { + deps[edge.to_node].push(edge.from_node); + } + for node_deps in &mut deps { + node_deps.sort(); + node_deps.dedup(); + } + let mut live = HashSet::new(); + let mut stack: Vec<_> = contracts + .iter() + .enumerate() + .filter(|(_, c)| c.inherently_observable) + .map(|(i, _)| i) + .collect(); + while let Some(i) = stack.pop() { + if live.insert(i) { + stack.extend(deps[i].iter().copied()); + } + } + // IDs are independent of scheduling: original node order, then contract output order. + let mut output_ids = BTreeMap::new(); + let mut resource_meta = Vec::new(); + for i in 0..graph.nodes.len() { + if live.contains(&i) { + for (o, out) in contracts[i].outputs.iter().enumerate() { + let id = resource_meta.len() as u32; + output_ids.insert(OutputKey(i, o as u16), id); + resource_meta.push((i, o as u16, *out)); + } + } + } + let all_outputs: usize = contracts.iter().map(|c| c.outputs.len()).sum(); + + // Establish families and transitions without relying on a schedule. + let mut families = Vec::new(); + let mut source_family = HashMap::new(); + for i in 0..graph.nodes.len() { + if !live.contains(&i) { + continue; + } + let source = output_ids.get(&OutputKey(i, 0)).copied(); + match ¶ms[i] { + NormalizedParameters::SurfaceTarget => { + let id = families.len() as u32; + let r = source.unwrap(); + source_family.insert(OutputKey(i, 0), id); + families.push(TextureFamily { + id, + key: TextureFamilyKey { + source_node: i as u32, + source_socket: 0, }, - "executor is not registered", - format!("passes[{pi}].executor"), + source: TextureFamilySource::ImportedSurface { resource: r }, + lifetime: Lifetime { + first_use: 0, + last_use: 0, + }, + versions: vec![], + usage: vec![], + allocation: None, + aliasable: false, + }); + } + NormalizedParameters::TextureSpec { residency, texture } => { + let id = families.len() as u32; + let r = source.unwrap(); + source_family.insert(OutputKey(i, 0), id); + families.push(TextureFamily { + id, + key: TextureFamilyKey { + source_node: i as u32, + source_socket: 0, + }, + source: TextureFamilySource::AuthoredTexture { + resource: r, + residency: *residency, + descriptor: texture.clone(), + }, + lifetime: Lifetime { + first_use: 0, + last_use: 0, + }, + versions: vec![], + usage: vec![], + allocation: None, + aliasable: false, + }); + } + _ => {} + } + } + let mut transitions: Vec = Vec::new(); + let mut transitions_by_target: BTreeMap> = BTreeMap::new(); + for i in 0..graph.nodes.len() { + if !live.contains(&i) { + continue; + } + let transition_sockets: &[(&str, u16)] = match contracts[i].key { + "legacy_forward" => &[("colorTarget", 0), ("depthTarget", 1)], + "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" + | "luminance_edge" => &[("colorTarget", 0)], + _ => continue, + }; + for &(input_socket, output_ordinal) in transition_sockets { + let transition = TextureTransition { + writer_node: i, + input_socket, + target: bound[i][input_socket].producer, + output: OutputKey(i, output_ordinal), + }; + let index = transitions.len(); + transitions.push(transition); + transitions_by_target + .entry(transition.target) + .or_default() + .push(index); + } + } + + fn resolve_transition( + output: OutputKey, + transitions: &[TextureTransition], + transition_for_output: &HashMap, + source_family: &HashMap, + colors: &mut HashMap, + resolved: &mut HashMap, + ) -> ResolvedTransition { + if let Some(&value) = resolved.get(&output) { + return value; + } + if colors.get(&output) == Some(&1) { + return ResolvedTransition::Cyclic; + } + colors.insert(output, 1); + let transition = transitions[transition_for_output[&output]]; + let value = if let Some(&family) = source_family.get(&transition.target) { + ResolvedTransition::Resolved { + family, + version: 0, + target: transition.target, + } + } else if transition_for_output.contains_key(&transition.target) { + match resolve_transition( + transition.target, + transitions, + transition_for_output, + source_family, + colors, + resolved, + ) { + ResolvedTransition::Resolved { + family, version, .. + } => ResolvedTransition::Resolved { + family, + version: version + 1, + target: transition.target, + }, + ResolvedTransition::Cyclic => ResolvedTransition::Cyclic, + } + } else { + ResolvedTransition::Cyclic + }; + colors.insert(output, 2); + resolved.insert(output, value); + value + } + + let transition_for_output: HashMap<_, _> = transitions + .iter() + .enumerate() + .map(|(index, transition)| (transition.output, index)) + .collect(); + let mut resolved = HashMap::new(); + let mut colors = HashMap::new(); + for transition in &transitions { + resolve_transition( + transition.output, + &transitions, + &transition_for_output, + &source_family, + &mut colors, + &mut resolved, + ); + } + let mut version_of: HashMap = HashMap::new(); + for transition in &transitions { + if let ResolvedTransition::Resolved { + family, + version, + target, + } = resolved[&transition.output] + { + let target_id = output_ids[&target]; + version_of.insert(transition.output, (family, version, target_id)); + } + } + + let mut outgoing_edges = vec![Vec::new(); graph.nodes.len()]; + for (index, edge) in edges.iter().enumerate() { + if live.contains(&edge.from_node) && live.contains(&edge.to_node) { + outgoing_edges[edge.from_node].push(index); + } + } + for outgoing in &mut outgoing_edges { + outgoing.sort_by_key(|&index| { + let edge = &edges[index]; + ( + edge.to_node, + edge.producer_output_ordinal, + edge.consumer_input_ordinal, + ) + }); + } + + // Every texture reader must execute before a successor overwrites the + // physical allocation backing the older symbolic version. + let mut reachability = HashMap::new(); + for (i, contract) in contracts.iter().enumerate() { + if !live.contains(&i) { + continue; + } + for input in contract + .inputs + .iter() + .filter(|input| matches!(input.role, InputRole::Present | InputRole::SampledTexture)) + { + let key = bound[i][input.name].producer; + if !version_of.contains_key(&key) { + continue; + } + let Some(next_indices) = transitions_by_target.get(&key) else { + continue; + }; + let [next_index] = next_indices.as_slice() else { + continue; + }; + let next = transitions[*next_index]; + if i != next.writer_node + && !reaches( + i, + next.writer_node, + &outgoing_edges, + &edges, + &live, + &mut reachability, + ) + { + return Err(error( + "GRAPH_RESOURCE_VERSION_INVALID", + "older texture version may be read after its successor", + format!("nodes[{i}].inputs.{}", input.name), + )); + } + } + } + + // Same-pass hazards are global and precede every duplicate-writer diagnostic. + for i in 0..graph.nodes.len() { + if !live.contains(&i) { + continue; + } + if contracts[i].key == "legacy_forward" { + if bound[i]["colorTarget"].producer == bound[i]["depthTarget"].producer + || matches!((version_of.get(&OutputKey(i, 0)), version_of.get(&OutputKey(i, 1))), (Some((cf, _, _)), Some((df, _, _))) if cf == df) + { + return Err(error( + "GRAPH_SAME_PASS_HAZARD", + "color and depth use one texture family", + format!("nodes[{i}].inputs"), + )); + } + } else if contracts[i] + .inputs + .iter() + .any(|input| matches!(input.role, InputRole::SampledTexture)) + { + let hazard = contracts[i].inputs.iter().filter(|input| matches!(input.role, InputRole::SampledTexture)).any(|input| matches!((version_of.get(&bound[i][input.name].producer), version_of.get(&OutputKey(i, 0))), (Some((sf, _, _)), Some((tf, _, _))) if sf == tf)); + if hazard { + return Err(error( + "GRAPH_SAME_PASS_HAZARD", + "copy source and target use one texture family", + format!("nodes[{i}].inputs"), + )); + } + } + } + let mut first_writer = BTreeMap::new(); + for transition in &transitions { + if first_writer + .insert(transition.target, transition.writer_node) + .is_some_and(|writer| writer != transition.writer_node) + { + return Err(error( + "GRAPH_DUPLICATE_WRITER", + "texture version has multiple writers", + format!( + "nodes[{}].inputs.{}", + transition.writer_node, transition.input_socket + ), + )); + } + } + + // Materialize versions only after hazard and writer precedence has been settled. + for transition in &transitions { + if let Some(&(family, version, target)) = version_of.get(&transition.output) { + families[family as usize].versions.push(TextureVersion { + version, + resource: output_ids[&transition.output], + target, + initialized: true, + stored: true, + lifetime: Lifetime { + first_use: 0, + last_use: 0, + }, + }); + } + } + for family in &mut families { + family.versions.sort_by_key(|version| version.version); + for (index, version) in family.versions.iter().enumerate() { + if version.version != index as u32 { + return Err(error( + "GRAPH_RESOURCE_VERSION_INVALID", + "texture versions must form a dense linear chain", + "resources", + )); + } + } + } + + // Validate every independently resolved attachment before graph cycle reporting. + for i in 0..graph.nodes.len() { + if !live.contains(&i) || contracts[i].key != "legacy_forward" { + continue; + } + let (Some(&(cf, _, _)), Some(&(df, _, _))) = ( + version_of.get(&OutputKey(i, 0)), + version_of.get(&OutputKey(i, 1)), + ) else { + continue; + }; + let cd = match &families[cf as usize].source { + TextureFamilySource::AuthoredTexture { descriptor, .. } => Some(descriptor), + _ => None, + }; + let dd = match &families[df as usize].source { + TextureFamilySource::AuthoredTexture { descriptor, .. } => descriptor, + _ => { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "depth target must be authored", + format!("nodes[{i}].inputs.depthTarget"), )) } }; - observable[pi] = contract.inherently_observable(); - parameters.push(contract.normalize_parameters(&p.parameters).map_err(|m| { - GraphError::at( - "GRAPH_PARAMETERS_INVALID", - m, - format!("passes[{pi}].parameters"), - ) - })?); - let mut names = HashSet::new(); - let mut refs = HashSet::new(); - let mut color_locations = HashSet::new(); - for b in &p.reads { - if !identifier(&b.binding) { - return Err(fail( - "GRAPH_INVALID_ID", - "invalid binding", - format!("passes[{pi}].reads"), - )); - } - if !names.insert(&b.binding) || !refs.insert(&b.resource) { - return Err(fail( - "GRAPH_BINDING_INVALID", - "duplicate binding or resource use", - format!("passes[{pi}]"), - )); - } + let ok_depth = dd.dimension == TextureDimension::D2 + && dd.format == TextureFormat::Depth32Float + && dd.sample_count == 1 + && extent_layers(&dd.extent) == 1; + let ok_color = cd.is_none_or(|d| { + d.format != TextureFormat::Depth32Float + && d.dimension == dd.dimension + && d.extent == dd.extent + && d.sample_count == 1 + }); + let surface_ok = cd.is_some() + || matches!(&dd.extent,NormalizedTextureExtent::SurfaceRelative{width,height,..} if *width==Ratio{numerator:1,denominator:1}&&*height==Ratio{numerator:1,denominator:1}); + if !ok_depth || !ok_color || !surface_ok { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "attachments are incompatible", + format!("nodes[{i}].inputs"), + )); } - for b in &p.writes { - if !identifier(&b.binding) { - return Err(fail( - "GRAPH_INVALID_ID", - "invalid binding", - format!("passes[{pi}].writes"), - )); - } - if !names.insert(&b.binding) || !refs.insert(&b.resource) { - return Err(fail( - "GRAPH_BINDING_INVALID", - "duplicate binding or resource use", - format!("passes[{pi}]"), - )); - } - if let WriteAccess::ColorAttachment { location, .. } = b.access { - if !color_locations.insert(location) { - return Err(fail( - "GRAPH_BINDING_INVALID", - "duplicate color location", - format!("passes[{pi}].writes"), - )); - } - } - } - contract - .validate_bindings(p, &map) - .map_err(|m| GraphError::at("GRAPH_BINDING_INVALID", m, format!("passes[{pi}]")))?; } - let mut writer: HashMap = HashMap::new(); - for (pi, pass) in g.passes.iter().enumerate() { - if pass.state != PassState::Enabled { + for i in 0..graph.nodes.len() { + if !live.contains(&i) + || !contracts[i] + .inputs + .iter() + .any(|input| matches!(input.role, InputRole::SampledTexture)) + { continue; } - for binding in &pass.writes { - if writer.insert(binding.resource.clone(), pi).is_some() { - return Err(fail( - "GRAPH_DUPLICATE_WRITER", - "resource has multiple enabled writers", - format!("passes[{pi}]"), - )); + let source_key = bound[i]["source"].producer; + let Some(&(source_family_id, _, _)) = version_of.get(&source_key) else { + return Err(error( + "GRAPH_UNINITIALIZED_RESOURCE", + "copy source is not produced", + format!("nodes[{i}].inputs.source"), + )); + }; + let Some(&(target_family_id, _, _)) = version_of.get(&OutputKey(i, 0)) else { + continue; + }; + let source_descriptor = match &families[source_family_id as usize].source { + TextureFamilySource::AuthoredTexture { descriptor, .. } => descriptor, + TextureFamilySource::ImportedSurface { .. } => { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "copy source must be an authored texture", + format!("nodes[{i}].inputs.source"), + )) } + }; + let source_ok = source_descriptor.format == TextureFormat::Rgba16Float + && is_single_view_d2(source_descriptor); + let target_descriptor = match &families[target_family_id as usize].source { + TextureFamilySource::AuthoredTexture { descriptor, .. } => Some(descriptor), + TextureFamilySource::ImportedSurface { .. } => None, + }; + let authored_target_ok = target_descriptor.is_some_and(|descriptor| { + descriptor.format == TextureFormat::Rgba16Float && is_single_view_d2(descriptor) + }); + let bloom_input_ok = if contracts[i].key == "bloom_composite" { + let bloom_key = bound[i]["bloom"].producer; + let Some(&(bloom_family_id, _, _)) = version_of.get(&bloom_key) else { + return Err(error( + "GRAPH_UNINITIALIZED_RESOURCE", + "bloom source is not produced", + format!("nodes[{i}].inputs.bloom"), + )); + }; + match &families[bloom_family_id as usize].source { + TextureFamilySource::AuthoredTexture { descriptor, .. } => { + descriptor.format == TextureFormat::Rgba16Float && is_single_view_d2(descriptor) + } + TextureFamilySource::ImportedSurface { .. } => { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "bloom source must be an authored texture", + format!("nodes[{i}].inputs.bloom"), + )) + } + } + } else { + true + }; + let source_is_full_surface = matches!(&source_descriptor.extent, NormalizedTextureExtent::SurfaceRelative { width, height, depth_or_array_layers: 1 } if *width == Ratio { numerator:1, denominator:1 } && *height == Ratio { numerator:1, denominator:1 }); + let target_matches_source = target_descriptor + .is_some_and(|descriptor| descriptor.extent == source_descriptor.extent); + let descriptor_ok = match contracts[i].key { + "fullscreen_copy" => { + target_descriptor.is_none() && source_is_full_surface + || target_descriptor.is_some_and(|descriptor| { + descriptor.format != TextureFormat::Depth32Float + && is_single_view_d2(descriptor) + && descriptor.extent == source_descriptor.extent + }) + } + "tone_map" => target_descriptor.is_none() && source_is_full_surface, + "bloom_extract" => authored_target_ok, + "bloom_blur" | "luminance_edge" => authored_target_ok && target_matches_source, + "bloom_composite" => authored_target_ok && target_matches_source && bloom_input_ok, + _ => false, + }; + if !source_ok || !descriptor_ok { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "fullscreen textures are incompatible", + format!("nodes[{i}].inputs"), + )); } } - for (pi, p) in g.passes.iter().enumerate() { - for b in &p.reads { - let r = map[&b.resource]; - if (r.texture.format.depth() && matches!(b.access, ReadAccess::Storage)) - || (matches!(b.access, ReadAccess::Storage) - && !matches!( - r.texture.format, - Format::Rgba8Unorm | Format::Rgba16Float | Format::R32Float - )) - || (r.texture.format == Format::Surface && !matches!(b.access, ReadAccess::CopySrc)) - { - return Err(fail( - "GRAPH_ILLEGAL_ACCESS", - "format/access mismatch", - format!("passes[{pi}].reads"), + // Initialization and presentation legality are later than attachment compatibility. + for (i, contract) in contracts.iter().enumerate() { + if !live.contains(&i) || contract.key != "present" { + continue; + } + let key = bound[i]["surface"].producer; + let Some(&(family, _, _)) = version_of.get(&key) else { + if !matches!(resolved.get(&key), Some(ResolvedTransition::Cyclic)) { + return Err(error( + "GRAPH_UNINITIALIZED_RESOURCE", + "present source is not produced", + format!("nodes[{i}].inputs.surface"), )); } + continue; + }; + if !matches!( + families[family as usize].source, + TextureFamilySource::ImportedSurface { .. } + ) { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "offscreen textures cannot be presented", + format!("nodes[{i}].inputs.surface"), + )); + } + } + + // Stable Kahn scheduling is deliberately after resource and access validation. + let mut indegree = vec![0; graph.nodes.len()]; + for &node in &live { + indegree[node] = edges + .iter() + .filter(|edge| edge.to_node == node && live.contains(&edge.from_node)) + .count(); + } + let mut queue = BinaryHeap::new(); + for &node in &live { + if indegree[node] == 0 { + queue.push(Reverse(node)); + } + } + let mut order = Vec::new(); + while let Some(Reverse(node)) = queue.pop() { + order.push(node); + for &edge_index in &outgoing_edges[node] { + let consumer = edges[edge_index].to_node; + indegree[consumer] -= 1; + if indegree[consumer] == 0 { + queue.push(Reverse(consumer)); + } } - let mut attachment_descriptor: Option<&TextureDescriptor> = None; - for b in &p.writes { - let r = map[&b.resource]; - let depth = r.texture.format.depth(); - let is_attachment = matches!( - b.access, - WriteAccess::ColorAttachment { .. } | WriteAccess::DepthAttachment { .. } - ); - if (matches!(b.access, WriteAccess::DepthAttachment { .. }) && !depth) - || (matches!(b.access, WriteAccess::ColorAttachment { .. }) && depth) - || (matches!(b.access, WriteAccess::Storage) && depth) - || (matches!(b.access, WriteAccess::Storage) - && !matches!( - r.texture.format, - Format::Rgba8Unorm | Format::Rgba16Float | Format::R32Float - )) - || (is_attachment && r.texture.dimension != Dimension::D2) - { - return Err(fail( - "GRAPH_ILLEGAL_ACCESS", - "format/access mismatch", - format!("passes[{pi}].writes"), - )); + } + if order.len() != live.len() { + let residual: Vec<_> = (0..graph.nodes.len()) + .map(|node| live.contains(&node) && indegree[node] != 0) + .collect(); + fn cycle_dfs( + node: usize, + outgoing_edges: &[Vec], + edges: &[DependencyEdge], + residual: &[bool], + colors: &mut [u8], + node_stack: &mut Vec, + edge_stack: &mut Vec, + ) -> Option> { + colors[node] = 1; + node_stack.push(node); + for &edge_index in &outgoing_edges[node] { + let to = edges[edge_index].to_node; + if !residual[to] { + continue; + } + if colors[to] == 0 { + edge_stack.push(edge_index); + if let Some(cycle) = cycle_dfs( + to, + outgoing_edges, + edges, + residual, + colors, + node_stack, + edge_stack, + ) { + return Some(cycle); + } + edge_stack.pop(); + } else if colors[to] == 1 { + let position = node_stack + .iter() + .position(|&stacked| stacked == to) + .unwrap(); + let mut cycle = edge_stack[position..].to_vec(); + cycle.push(edge_index); + return Some(cycle); + } } - if is_attachment { - if let Some(first) = attachment_descriptor { - if first.dimension != r.texture.dimension - || first.extent != r.texture.extent - || first.sample_count != r.texture.sample_count - { - return Err(fail( - "GRAPH_ILLEGAL_ACCESS", - "attachments must have matching dimensions, extents, and sample counts", - format!("passes[{pi}].writes"), - )); + node_stack.pop(); + colors[node] = 2; + None + } + let mut colors = vec![0; graph.nodes.len()]; + let mut cycle = None; + for node in 0..graph.nodes.len() { + if residual[node] && colors[node] == 0 { + cycle = cycle_dfs( + node, + &outgoing_edges, + &edges, + &residual, + &mut colors, + &mut Vec::new(), + &mut Vec::new(), + ); + if cycle.is_some() { + break; + } + } + } + let mut graph_error = GraphError::new("GRAPH_CYCLE", "live graph contains a cycle"); + let payload: Vec<_> = cycle + .unwrap_or_default() + .into_iter() + .map(|index| { + let edge = &edges[index]; + serde_json::json!({ + "fromNode": graph.nodes[edge.from_node].id, + "fromSocket": edge.from_socket, + "toNode": graph.nodes[edge.to_node].id, + "toSocket": edge.to_socket, + "resource": edge.resource, + }) + }) + .collect(); + graph_error.details = + serde_json::json!({"message":graph_error.message,"kind":"cycle","edges":payload}); + return Err(graph_error); + } + if transitions + .iter() + .any(|transition| matches!(resolved[&transition.output], ResolvedTransition::Cyclic)) + { + return Err(error( + "GRAPH_RESOURCE_VERSION_INVALID", + "texture predecessor is unresolved in an acyclic graph", + "resources", + )); + } + + let mut resources = Vec::new(); + for (i, o, out) in resource_meta { + let key = OutputKey(i, o); + let id = output_ids[&key]; + let scene = || output_ids[&root(bound[i]["scene"].producer, &bound, &contracts).unwrap()]; + let plan = match out.semantic_type { + SemanticType::SurfaceTarget => ResourcePlan::SurfaceTarget { + family: source_family[&key], + }, + SemanticType::TextureSpec => { + if let NormalizedParameters::TextureSpec { residency, texture } = ¶ms[i] { + ResourcePlan::TextureSpec { + family: source_family[&key], + residency: *residency, + descriptor: texture.clone(), } } else { - attachment_descriptor = Some(&r.texture); + unreachable!() } } - match &b.access { - WriteAccess::ColorAttachment { load, .. } => { - if let ColorLoad::Clear { value } = load { - if value.iter().any(|x| !x.is_finite()) { - return Err(fail( - "GRAPH_ILLEGAL_ACCESS", - "clear values must be finite", - format!("passes[{pi}]"), - )); - } + SemanticType::Texture => { + let (f, v, t) = version_of[&key]; + ResourcePlan::Texture { + family: f, + version: v, + target: t, + initialized: true, + stored: true, + allocation: None, + } + } + SemanticType::SceneTable => ResourcePlan::SceneTable, + SemanticType::LocalAabbBuffer => ResourcePlan::LocalAabbBuffer { scene: scene() }, + SemanticType::CameraFrustum => ResourcePlan::CameraFrustum, + SemanticType::BooleanFlagBuffer => { + if let OutputMetadata::BooleanFlag { flag } = out.metadata { + ResourcePlan::BooleanFlagBuffer { + scene: scene(), + flag, + } + } else { + unreachable!() + } + } + SemanticType::DrawStream => ResourcePlan::DrawStream { scene: scene() }, + SemanticType::DepthStencilConfig => { + if let NormalizedParameters::DepthStencilConfig { config } = ¶ms[i] { + ResourcePlan::DepthStencilConfig { config: *config } + } else { + unreachable!() + } + } + }; + resources.push(CompiledResource { + original_node_index: i as u32, + output_ordinal: o, + origin: NodeOutputRef { + node: graph.nodes[i].id.clone(), + socket: out.name.into(), + }, + semantic_type: out.semantic_type, + producer_execution: None, + lifetime: None, + plan, + }); + let _ = id; + } + let mut executions = Vec::new(); + let mut node_execution = HashMap::new(); + for &i in &order { + if contracts[i].execution == ExecutionClass::Source { + continue; + } + let ordinal = executions.len() as u32; + node_execution.insert(i, ordinal); + let input_resource = |s: &str| output_ids[&bound[i][s].producer]; + let mut inputs = Vec::new(); + for s in contracts[i].inputs { + if let Some(b) = bound[i].get(s.name).filter(|b| b.active) { + inputs.push(CompiledSocketInput { + socket: s.name.into(), + resource: output_ids[&b.producer], + }); + } + } + let outputs: Vec<_> = contracts[i] + .outputs + .iter() + .enumerate() + .map(|(o, s)| CompiledSocketOutput { + socket: s.name.into(), + resource: output_ids[&OutputKey(i, o as u16)], + }) + .collect(); + let mut accesses = Vec::new(); + let kind = match contracts[i].key { + "frustum_cull" => { + for (s, m) in [ + ("scene", AccessMode::StorageRead), + ("localAabbs", AccessMode::StorageRead), + ("frustum", AccessMode::UniformRead), + ] { + accesses.push(CompiledAccess { + socket: s.into(), + resource: input_resource(s), + mode: m, + }); + } + let r = output_ids[&OutputKey(i, 0)]; + accesses.push(CompiledAccess { + socket: "flags".into(), + resource: r, + mode: AccessMode::StorageWrite { + full_overwrite: true, + }, + }); + ExecutionKind::Compute { + work: ComputeWork::FrustumCull, + } + } + "mesh_query" => { + for s in ["scene", "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, + }); } } - WriteAccess::DepthAttachment { load, .. } => { - if let DepthLoad::Clear { value } = load { - if !value.is_finite() || !(0.0..=1.0).contains(value) { - return Err(fail( - "GRAPH_ILLEGAL_ACCESS", - "depth clear must be finite and in [0,1]", - format!("passes[{pi}]"), - )); - } - } + accesses.push(CompiledAccess { + socket: "draws".into(), + resource: output_ids[&OutputKey(i, 0)], + mode: AccessMode::StorageWrite { + full_overwrite: true, + }, + }); + ExecutionKind::Compute { + work: ComputeWork::MeshQuery, + } + } + "legacy_forward" => { + let color = output_ids[&OutputKey(i, 0)]; + let depth = output_ids[&OutputKey(i, 1)]; + let clear = match params[i] { + NormalizedParameters::LegacyForward { clear_color } => clear_color, + _ => unreachable!(), + }; + let config_node = bound[i]["depthStencil"].producer.0; + let dc = match params[config_node] { + NormalizedParameters::DepthStencilConfig { config } => config, + _ => unreachable!(), + }; + let cl = NormalizedColorLoad::Clear { value: clear }; + let dl = NormalizedDepthLoad::Clear { + value: dc.clear_depth, + }; + for s in ["scene", "draws"] { + accesses.push(CompiledAccess { + socket: s.into(), + resource: input_resource(s), + mode: if s == "draws" { + AccessMode::IndirectRead + } else { + AccessMode::SemanticRead + }, + }); + } + accesses.push(CompiledAccess { + socket: "color".into(), + resource: color, + mode: AccessMode::ColorAttachment { + location: 0, + load: cl, + store: StoreOp::Store, + full_overwrite: true, + }, + }); + accesses.push(CompiledAccess { + socket: "depth".into(), + resource: depth, + mode: AccessMode::DepthAttachment { + load: dl, + store: StoreOp::Store, + full_overwrite: true, + }, + }); + ExecutionKind::Render { + color_attachments: vec![ColorAttachmentPlan { + resource: color, + location: 0, + load: cl, + store: StoreOp::Store, + }], + depth_stencil: Some(DepthStencilAttachmentPlan { + resource: depth, + load: dl, + store: StoreOp::Store, + }), + } + } + "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" + | "luminance_edge" => { + let color = output_ids[&OutputKey(i, 0)]; + let load = NormalizedColorLoad::Clear { + value: [0.0, 0.0, 0.0, 0.0], + }; + accesses.push(CompiledAccess { + socket: "source".into(), + resource: input_resource("source"), + mode: AccessMode::SampledTexture, + }); + if contracts[i].key == "bloom_composite" { + accesses.push(CompiledAccess { + socket: "bloom".into(), + resource: input_resource("bloom"), + mode: AccessMode::SampledTexture, + }); + } + accesses.push(CompiledAccess { + socket: "color".into(), + resource: color, + mode: AccessMode::ColorAttachment { + location: 0, + load, + store: StoreOp::Store, + full_overwrite: true, + }, + }); + ExecutionKind::Render { + color_attachments: vec![ColorAttachmentPlan { + resource: color, + location: 0, + load, + store: StoreOp::Store, + }], + depth_stencil: None, + } + } + "present" => { + let r = input_resource("surface"); + accesses.push(CompiledAccess { + socket: "surface".into(), + resource: r, + mode: AccessMode::Present, + }); + ExecutionKind::Present { surface: r } + } + _ => unreachable!(), + }; + executions.push(CompiledExecution { + id: graph.nodes[i].id.clone(), + original_node_index: i as u32, + executor: graph.nodes[i].executor.clone(), + parameters: params[i].clone(), + kind, + inputs, + outputs, + accesses, + }); + } + for (ordinal, e) in executions.iter().enumerate() { + for o in &e.outputs { + resources[o.resource as usize].producer_execution = Some(ordinal as u32); + } + } + // Dense lifetimes touch bindings, outputs, and accesses. + for (ordinal, e) in executions.iter().enumerate() { + let ordinal = ordinal as u32; + let mut touched = BTreeSet::new(); + for x in &e.inputs { + touched.insert(x.resource); + } + for x in &e.outputs { + touched.insert(x.resource); + } + for x in &e.accesses { + touched.insert(x.resource); + } + for r in touched { + let life = resources[r as usize].lifetime.get_or_insert(Lifetime { + first_use: ordinal, + last_use: ordinal, + }); + life.first_use = life.first_use.min(ordinal); + life.last_use = life.last_use.max(ordinal); + } + } + for f in &mut families { + let mut first = None; + let mut last = 0; + for v in &mut f.versions { + v.lifetime = resources[v.resource as usize].lifetime.unwrap(); + first = Some(first.map_or(v.lifetime.first_use, |x: u32| x.min(v.lifetime.first_use))); + last = last.max(v.lifetime.last_use); + } + f.lifetime = Lifetime { + first_use: first.unwrap_or(0), + last_use: last, + }; + f.usage = texture_usage(f, &executions); + f.aliasable = matches!( + f.source, + TextureFamilySource::AuthoredTexture { + residency: TextureResidency::Transient, + .. + } + ) && f.versions.iter().all(|v| v.initialized); + } + let (classes, transient) = allocate(&mut families, &mut resources); + if resources.len() > 1024 { + return Err(error( + "GRAPH_LIMIT_EXCEEDED", + "too many final resources", + "resources", + )); + } + Ok(CompiledGraph { + schema_version: 2, + graph_id: graph.graph_id, + revision: graph.revision, + node_count: graph.nodes.len() as u32, + resources, + executions, + texture_families: families, + allocation_classes: classes, + culled_node_count: (graph.nodes.len() - live.len()) as u32, + culled_resource_count: (all_outputs - output_ids.len()) as u32, + transient_slot_count: transient, + }) +} + +fn extent_layers(e: &NormalizedTextureExtent) -> u32 { + match e { + NormalizedTextureExtent::Absolute { + depth_or_array_layers, + .. + } + | NormalizedTextureExtent::SurfaceRelative { + depth_or_array_layers, + .. + } => *depth_or_array_layers, + } +} +fn is_single_view_d2(descriptor: &NormalizedTextureDescriptor) -> bool { + descriptor.dimension == TextureDimension::D2 + && descriptor.sample_count == 1 + && descriptor.mip_level_count == 1 + && extent_layers(&descriptor.extent) == 1 +} +fn texture_usage(f: &TextureFamily, executions: &[CompiledExecution]) -> Vec { + let rs: HashSet<_> = f.versions.iter().map(|v| v.resource).collect(); + let mut u = BTreeSet::new(); + for e in executions { + for a in &e.accesses { + if !rs.contains(&a.resource) { + continue; + } + match a.mode { + AccessMode::SampledTexture => { + u.insert(TextureUsage::Sampled); + } + AccessMode::StorageRead | AccessMode::StorageWrite { .. } => { + u.insert(TextureUsage::Storage); + } + AccessMode::ColorAttachment { .. } => { + u.insert(TextureUsage::ColorAttachment); + } + AccessMode::DepthAttachment { .. } => { + u.insert(TextureUsage::DepthAttachment); } _ => {} } } } - let mut roots = Vec::new(); - for (i, o) in g.outputs.iter().enumerate() { - let Some(r) = map.get(&o.resource) else { - return Err(fail( - "GRAPH_UNKNOWN_RESOURCE", - "unknown output resource", - format!("outputs[{i}]"), - )); - }; - if writer.get(&o.resource).is_none() { - return Err(fail( - "GRAPH_UNINITIALIZED_RESOURCE", - if matches!(r.residency, Residency::Transient) { - "transient output is uninitialized" - } else { - "external output requires a live writer" - }, - format!("outputs[{i}]"), - )); - } - roots.push(o.resource.clone()) - } - for (pi, p) in g.passes.iter().enumerate() { - if p.state == PassState::Enabled { - for b in &p.reads { - if matches!(map[&b.resource].residency, Residency::Transient) - && !writer.contains_key(&b.resource) - { - return Err(fail( - "GRAPH_UNINITIALIZED_RESOURCE", - "transient read is uninitialized", - format!("passes[{pi}]"), - )); - } - } - } - } - for (pi, pass) in g.passes.iter().enumerate() { - for binding in &pass.writes { - if matches!(map[&binding.resource].residency, Residency::Transient) - && matches!( - binding.access, - WriteAccess::ColorAttachment { - load: ColorLoad::Load, - .. - } | WriteAccess::DepthAttachment { - load: DepthLoad::Load, - .. - } - ) - { - return Err(fail( - "GRAPH_ILLEGAL_ACCESS", - "transient attachment load is illegal", - format!("passes[{pi}]"), - )); - } - } - } - let mut live: Vec = observable - .iter() - .enumerate() - .map(|(i, x)| *x && g.passes[i].state == PassState::Enabled) - .collect(); - let mut stack = roots.clone(); - for (i, is_live) in live.iter().enumerate() { - if *is_live { - stack.extend(g.passes[i].reads.iter().map(|b| b.resource.clone())); - } - } - while let Some(r) = stack.pop() { - if let Some(&p) = writer.get(&r) { - if !live[p] { - live[p] = true; - stack.extend(g.passes[p].reads.iter().map(|b| b.resource.clone())) - } - } - } - let resource_indices: HashMap = g - .resources - .iter() - .enumerate() - .map(|(index, resource)| { - ( - ResourceRef { - id: resource.id.clone(), - version: resource.version, - }, - index, - ) - }) - .collect(); - let mut edges = vec![Vec::<(usize, usize, ResourceRef)>::new(); g.passes.len()]; - let mut indeg = vec![0u32; g.passes.len()]; - for (b, p) in g.passes.iter().enumerate() { - if live[b] { - for u in &p.reads { - if let Some(&a) = writer.get(&u.resource) { - if live[a] { - edges[a].push((b, resource_indices[&u.resource], u.resource.clone())); - indeg[b] += 1 - } - } - } - } - } - for e in &mut edges { - e.sort_by_key(|edge| (edge.0, edge.1)) - } - let mut q = BinaryHeap::new(); - for i in 0..g.passes.len() { - if live[i] && indeg[i] == 0 { - q.push(Reverse(i)) - } - } - let mut order = Vec::new(); - while let Some(Reverse(a)) = q.pop() { - order.push(a); - for (b, _, _) in &edges[a] { - indeg[*b] -= 1; - if indeg[*b] == 0 { - q.push(Reverse(*b)) - } - } - } - if order.len() != live.iter().filter(|x| **x).count() { - fn visit( - node: usize, - edges: &[Vec<(usize, usize, ResourceRef)>], - residual: &[bool], - colors: &mut [u8], - stack: &mut Vec, - incoming: &mut Vec, - ) -> Option<(Vec, Vec)> { - colors[node] = 1; - stack.push(node); - for (next, _, resource) in &edges[node] { - if !residual[*next] { - continue; - } - if colors[*next] == 0 { - incoming.push(resource.clone()); - if let Some(cycle) = visit(*next, edges, residual, colors, stack, incoming) { - return Some(cycle); - } - incoming.pop(); - } else if colors[*next] == 1 { - let start = stack.iter().position(|pass| pass == next)?; - let mut resources = incoming[start..].to_vec(); - resources.push(resource.clone()); - return Some((stack[start..].to_vec(), resources)); - } - } - stack.pop(); - colors[node] = 2; - None - } - let residual: Vec = (0..g.passes.len()) - .map(|i| live[i] && indeg[i] > 0) - .collect(); - let mut colors = vec![0; g.passes.len()]; - let mut stack = Vec::new(); - let mut incoming = Vec::new(); - let mut found = None; - for node in 0..g.passes.len() { - if residual[node] && colors[node] == 0 { - found = visit( - node, - &edges, - &residual, - &mut colors, - &mut stack, - &mut incoming, - ); - if found.is_some() { - break; - } - } - } - let (cycle_passes, cycle_resources) = found.unwrap_or_default(); - let cycle_edges: Vec<_> = cycle_resources.iter().enumerate().map(|(i, r)| serde_json::json!({"from":g.passes[cycle_passes[i]].id,"resource":r,"to":g.passes[cycle_passes[(i+1)%cycle_passes.len()]].id})).collect(); - return Err(GraphError { - code: "GRAPH_CYCLE", - message: "live graph contains a cycle".into(), - details: serde_json::json!({"message":"live graph contains a cycle","kind":"cycle","edges":cycle_edges}), - }); - } - let pos: HashMap = order - .iter() - .enumerate() - .map(|(i, p)| u32::try_from(i).map(|i| (*p, i))) - .collect::>() - .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "pass index overflow"))?; - let boundary = u32::try_from(order.len()) - .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "pass index overflow"))?; - let output_refs: HashSet<_> = roots.into_iter().collect(); - let mut resources = Vec::new(); - let mut keys = Vec::new(); - for (ri, r) in g.resources.iter().enumerate() { - let rr = ResourceRef { - id: r.id.clone(), - version: r.version, - }; - let mut points = Vec::new(); - let mut usage = BTreeSet::new(); - for (pi, p) in g.passes.iter().enumerate() { - if let Some(&x) = pos.get(&pi) { - for b in &p.reads { - if b.resource == rr { - points.push(x); - usage.insert(usage_read(b.access)); - } - } - for b in &p.writes { - if b.resource == rr { - points.push(x); - usage.insert(usage_write(&b.access)); - } - } - } - } - if output_refs.contains(&rr) { - points.push(boundary) - } - if points.is_empty() { - continue; - } - let key = TextureAllocationKey { - descriptor: norm(r.texture.clone()), - usage: usage.into_iter().collect(), - view_formats: vec![], - }; - keys.push(if matches!(r.residency, Residency::Transient) { - Some(key) - } else { - None - }); - resources.push(CompiledResource { - original_index: u32::try_from(ri) - .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "resource index overflow"))?, - resource_ref: rr.clone(), - residency: r.residency.clone(), - descriptor: norm(r.texture.clone()), - writer: writer.get(&rr).and_then(|p| pos.get(p)).copied(), - lifetime: Lifetime { - first_use: *points.iter().min().unwrap(), - last_use: *points.iter().max().unwrap(), - }, - allocation: None, - }) - } - let resource_remap: HashMap = resources - .iter() - .enumerate() - .map(|(i, r)| u32::try_from(i).map(|i| (r.resource_ref.clone(), i))) - .collect::>() - .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "resource index overflow"))?; - let mut groups: BTreeMap> = BTreeMap::new(); - for (i, k) in keys.into_iter().enumerate() { - if let Some(k) = k { - groups.entry(k).or_default().push(i) + u.into_iter().collect() +} +fn allocate( + families: &mut [TextureFamily], + resources: &mut [CompiledResource], +) -> (Vec, u32) { + let mut grouped: BTreeMap> = BTreeMap::new(); + for (i, f) in families.iter().enumerate() { + if let TextureFamilySource::AuthoredTexture { descriptor, .. } = &f.source { + grouped + .entry(TextureCompatibilityKey { + dimension: descriptor.dimension, + format: descriptor.format, + extent: descriptor.extent.clone(), + mip_level_count: descriptor.mip_level_count, + sample_count: descriptor.sample_count, + view_formats: descriptor.view_formats.clone(), + }) + .or_default() + .push(i); } } let mut classes = Vec::new(); - let mut next = 0u32; - for (class_index, (k, mut ix)) in groups.into_iter().enumerate() { - ix.sort_by_key(|i| { + let mut transient = 0; + for (key, ids) in grouped { + let class = classes.len() as u32; + let mut slots: Vec = Vec::new(); + let mut aliasable = Vec::new(); + let mut dedicated = Vec::new(); + let mut persistent_ids = Vec::new(); + for fi in ids { + let persistent = matches!( + families[fi].source, + TextureFamilySource::AuthoredTexture { + residency: TextureResidency::Persistent, + .. + } + ); + let alias = families[fi].aliasable && !persistent; + if persistent { + persistent_ids.push(fi); + } else if alias { + aliasable.push(fi); + } else { + dedicated.push(fi); + } + } + aliasable.sort_by_key(|&fi| { ( - resources[*i].lifetime.first_use, - resources[*i].lifetime.last_use, - resources[*i].original_index, + families[fi].lifetime.first_use, + families[fi].lifetime.last_use, + families[fi].key.clone(), ) }); - let mut active = BinaryHeap::>::new(); - let mut free = BinaryHeap::>::new(); - let mut count = 0; - for i in ix { - while let Some(Reverse((last, slot))) = active.peek().copied() { - if last < resources[i].lifetime.first_use { - active.pop(); - free.push(Reverse(slot)) - } else { - break; + dedicated.sort_by_key(|&fi| families[fi].key.clone()); + persistent_ids.sort_by_key(|&fi| families[fi].key.clone()); + for fi in aliasable.into_iter().chain(dedicated).chain(persistent_ids) { + let persistent = matches!( + families[fi].source, + TextureFamilySource::AuthoredTexture { + residency: TextureResidency::Persistent, + .. + } + ); + let alias = families[fi].aliasable && !persistent; + let found = if alias { + slots.iter().position(|s| { + s.kind == AllocationKind::AliasedTransient + && s.occupants.iter().all(|&old| { + families[old as usize].lifetime.last_use + < families[fi].lifetime.first_use + }) + }) + } else { + None + }; + let slot = found.unwrap_or_else(|| { + let s = slots.len(); + if !persistent { + transient += 1; + } + slots.push(AllocationSlot { + kind: if persistent { + AllocationKind::Persistent + } else if alias { + AllocationKind::AliasedTransient + } else { + AllocationKind::DedicatedTransient + }, + usage: Vec::new(), + occupants: Vec::new(), + }); + s + }); + slots[slot].occupants.push(fi as u32); + slots[slot].usage.extend(families[fi].usage.iter().copied()); + slots[slot].usage.sort(); + slots[slot].usage.dedup(); + let a = AllocationRef { + class, + slot: slot as u32, + }; + families[fi].allocation = Some(a); + for v in &families[fi].versions { + if let ResourcePlan::Texture { allocation, .. } = + &mut resources[v.resource as usize].plan + { + *allocation = Some(a); } } - let slot = free.pop().map(|x| x.0).unwrap_or_else(|| { - let x = count; - count += 1; - x - }); - resources[i].allocation = Some(TransientAllocation { - class: u32::try_from(class_index) - .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "class index overflow"))?, - slot, - }); - active.push(Reverse((resources[i].lifetime.last_use, slot))) } - next = next - .checked_add(count) - .ok_or_else(|| GraphError::new("GRAPH_LIMIT_EXCEEDED", "allocation overflow"))?; - classes.push(AllocationClass { - key: k, - slot_count: count, - }) + classes.push(AllocationClass { key, slots }); } - Ok(CompiledGraph { - schema_version: 1, - graph_id: g.graph_id, - revision: g.revision, - passes: order - .into_iter() - .map(|i| { - Ok(CompiledPass { - id: g.passes[i].id.clone(), - original_index: u32::try_from(i).map_err(|_| { - GraphError::new("GRAPH_LIMIT_EXCEEDED", "pass index overflow") - })?, - executor: g.passes[i].executor.clone(), - parameters: parameters[i].clone(), - reads: g.passes[i] - .reads - .iter() - .map(|b| { - Ok(CompiledRead { - binding: b.binding.clone(), - resource: *resource_remap.get(&b.resource).ok_or_else(|| { - GraphError::new( - "GRAPH_UNKNOWN_RESOURCE", - "compiled read remap missing", - ) - })?, - access: b.access, - }) - }) - .collect::>()?, - writes: g.passes[i] - .writes - .iter() - .map(|b| { - Ok(CompiledWrite { - binding: b.binding.clone(), - resource: *resource_remap.get(&b.resource).ok_or_else(|| { - GraphError::new( - "GRAPH_UNKNOWN_RESOURCE", - "compiled write remap missing", - ) - })?, - access: b.access.clone(), - }) - }) - .collect::>()?, - }) - }) - .collect::>()?, - culled_pass_count: u32::try_from(g.passes.len() - live.iter().filter(|x| **x).count()) - .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "pass count overflow"))?, - culled_resource_count: u32::try_from(g.resources.len() - resources.len()) - .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "resource count overflow"))?, - resources, - outputs: g - .outputs - .into_iter() - .map(|o| { - Ok(CompiledOutput { - name: o.name, - resource: *resource_remap.get(&o.resource).ok_or_else(|| { - GraphError::new("GRAPH_UNKNOWN_RESOURCE", "compiled output remap missing") - })?, - }) - }) - .collect::>()?, - allocation_classes: classes, - transient_slot_count: next, - }) + (classes, transient) } diff --git a/renderer/src/render_graph/compiler_v2.rs b/renderer/src/render_graph/compiler_v2.rs deleted file mode 100644 index 0ff5962..0000000 --- a/renderer/src/render_graph/compiler_v2.rs +++ /dev/null @@ -1,1953 +0,0 @@ -use std::cmp::Reverse; -use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet}; - -use serde::Deserialize; - -use super::*; - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Empty {} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct TextureParameters { - residency: TextureResidencyV2, - texture: TextureDescriptorV2, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct DepthParameters { - depth_compare: CompareFunctionV2, - depth_write_enabled: bool, - clear_depth: f32, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct ForwardParameters { - clear_color: [f64; 4], -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct ToneMapParameters { - exposure: f32, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct BloomExtractParameters { - threshold: f32, - knee: f32, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct BloomBlurParameters { - direction: [f32; 2], - radius: f32, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct BloomCompositeParameters { - intensity: f32, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct LuminanceEdgeParameters { - strength: f32, -} - -fn range(value: f32, min: f32, max: f32, path: String) -> Result { - if value.is_finite() && (min..=max).contains(&value) { - Ok(value) - } else { - Err(error( - "GRAPH_PARAMETERS_INVALID", - &format!("value must be finite and in [{min},{max}]"), - path, - )) - } -} - -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] -struct OutputKey(usize, u16); -#[derive(Clone, Copy)] -struct BoundInput { - producer: OutputKey, - active: bool, -} -#[derive(Clone)] -struct DependencyEdge { - from_node: usize, - from_socket: String, - producer_output_ordinal: u16, - to_node: usize, - to_socket: String, - consumer_input_ordinal: u16, - resource: NodeOutputRef, -} - -#[derive(Clone, Copy)] -struct TextureTransition { - writer_node: usize, - input_socket: &'static str, - target: OutputKey, - output: OutputKey, -} - -#[derive(Clone, Copy)] -enum ResolvedTransition { - Resolved { - family: u32, - version: u32, - target: OutputKey, - }, - Cyclic, -} - -fn reaches( - from: usize, - to: usize, - outgoing_edges: &[Vec], - edges: &[DependencyEdge], - live: &HashSet, - memo: &mut HashMap<(usize, usize), bool>, -) -> bool { - if let Some(&answer) = memo.get(&(from, to)) { - return answer; - } - let mut stack = vec![from]; - let mut visited = HashSet::new(); - let mut answer = false; - while let Some(node) = stack.pop() { - if !visited.insert(node) { - continue; - } - if node == to { - answer = true; - break; - } - for &edge_index in &outgoing_edges[node] { - let next = edges[edge_index].to_node; - if live.contains(&next) { - stack.push(next); - } - } - } - memo.insert((from, to), answer); - answer -} - -fn error(code: &'static str, message: &str, path: impl Into) -> GraphError { - GraphError::at(code, message, path) -} -fn validate_name_length(s: &str, path: impl Into) -> Result<(), GraphError> { - if s.len() > 64 { - Err(error( - "GRAPH_LIMIT_EXCEEDED", - "identifier exceeds 64 bytes", - path.into(), - )) - } else { - Ok(()) - } -} -fn validate_name_grammar(s: &str, path: impl Into) -> Result<(), GraphError> { - if s.is_empty() || !identifier(s) { - Err(error("GRAPH_INVALID_ID", "invalid identifier", path)) - } else { - Ok(()) - } -} - -pub fn mesh_predicate_matches(predicate: TriStatePredicate, flag: bool) -> bool { - match predicate { - TriStatePredicate::Any => true, - TriStatePredicate::RequiredTrue => flag, - TriStatePredicate::RequiredFalse => !flag, - } -} - -pub fn parse_and_compile_v2(bytes: &[u8]) -> Result { - if bytes.len() > MAX_JSON_BYTES { - return Err(GraphError::new( - "GRAPH_PAYLOAD_TOO_LARGE", - "graph payload exceeds 1 MiB", - )); - } - let text = std::str::from_utf8(bytes) - .map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph payload is not UTF-8"))?; - let probe: serde_json::Value = serde_json::from_str(text) - .map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?; - if probe.get("schemaVersion").and_then(|v| v.as_u64()) != Some(2) { - return Err(GraphError::new( - "GRAPH_SCHEMA_UNSUPPORTED", - "schemaVersion must be 2", - )); - } - let graph = serde_json::from_str(text) - .map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?; - compile_v2(graph) -} - -fn gcd(mut a: u32, mut b: u32) -> u32 { - while b != 0 { - (a, b) = (b, a % b); - } - a -} -fn compatible_view(a: TextureFormatV2, b: TextureFormatV2) -> bool { - matches!( - (a, b), - (TextureFormatV2::Rgba8Unorm, TextureFormatV2::Rgba8UnormSrgb) - | (TextureFormatV2::Rgba8UnormSrgb, TextureFormatV2::Rgba8Unorm) - | (TextureFormatV2::Bgra8Unorm, TextureFormatV2::Bgra8UnormSrgb) - | (TextureFormatV2::Bgra8UnormSrgb, TextureFormatV2::Bgra8Unorm) - ) -} -fn normalize_texture( - d: TextureDescriptorV2, - base: &str, -) -> Result { - let bad = |message: &str, suffix: &str| { - error( - "GRAPH_PARAMETERS_INVALID", - message, - format!("{base}.texture.{suffix}"), - ) - }; - let (extent, w, h, layers, relative) = match d.extent { - TextureExtentV2::Absolute { - width, - height, - depth_or_array_layers, - } => ( - NormalizedTextureExtentV2::Absolute { - width, - height, - depth_or_array_layers, - }, - width, - height, - depth_or_array_layers, - false, - ), - TextureExtentV2::SurfaceRelative { - mut width, - mut height, - depth_or_array_layers, - } => { - if width.numerator == 0 - || width.denominator == 0 - || height.numerator == 0 - || height.denominator == 0 - || depth_or_array_layers == 0 - { - return Err(bad( - "extent components and ratio terms must be nonzero", - "extent", - )); - } - let g = gcd(width.numerator, width.denominator); - width.numerator /= g; - width.denominator /= g; - let g = gcd(height.numerator, height.denominator); - height.numerator /= g; - height.denominator /= g; - ( - NormalizedTextureExtentV2::SurfaceRelative { - width, - height, - depth_or_array_layers, - }, - 1, - 1, - depth_or_array_layers, - true, - ) - } - }; - if w == 0 || h == 0 || layers == 0 { - return Err(bad("extent components must be nonzero", "extent")); - } - if relative && d.dimension != TextureDimensionV2::D2 { - return Err(bad("surface-relative textures must be d2", "extent")); - } - if d.dimension == TextureDimensionV2::D1 && (h != 1 || layers != 1) { - return Err(bad( - "d1 textures require height and layers equal to one", - "extent", - )); - } - if d.format == TextureFormatV2::Depth32Float && d.dimension != TextureDimensionV2::D2 { - return Err(bad("depth textures must be d2", "dimension")); - } - if !matches!(d.sample_count, 1 | 4) { - return Err(bad("sampleCount must be 1 or 4", "sampleCount")); - } - if d.mip_level_count == 0 { - return Err(bad("mipLevelCount must be at least one", "mipLevelCount")); - } - if d.sample_count == 4 - && (d.dimension != TextureDimensionV2::D2 || d.mip_level_count != 1 || layers != 1) - { - return Err(bad( - "multisampled textures must be d2, single-mip, single-layer", - "sampleCount", - )); - } - let max_dim = w.max(h).max(if d.dimension == TextureDimensionV2::D3 { - layers - } else { - 1 - }); - let max_mips = 32 - max_dim.leading_zeros(); - if !relative && d.mip_level_count > max_mips { - return Err(bad( - "mipLevelCount exceeds the full mip chain", - "mipLevelCount", - )); - } - let limit = if d.dimension == TextureDimensionV2::D3 { - 2048 - } else { - 8192 - }; - if w > limit - || h > limit - || (d.dimension == TextureDimensionV2::D3 && layers > 2048) - || (d.dimension != TextureDimensionV2::D3 && layers > 256) - { - return Err(bad("texture exceeds dimension limits", "extent")); - } - for (j, &view) in d.view_formats.iter().enumerate() { - if view == d.format || !compatible_view(d.format, view) { - return Err(bad( - "view format must be compatible and exclude the base format", - &format!("viewFormats[{j}]"), - )); - } - } - let mut views = d.view_formats; - views.sort(); - views.dedup(); - Ok(NormalizedTextureDescriptorV2 { - dimension: d.dimension, - format: d.format, - extent, - mip_level_count: d.mip_level_count, - sample_count: d.sample_count, - view_formats: views, - }) -} - -fn decode(node: &NodeV2, i: usize) -> Result { - let base = format!("nodes[{i}].parameters"); - let invalid = - |e: serde_json::Error| error("GRAPH_PARAMETERS_INVALID", &e.to_string(), base.clone()); - macro_rules! empty { - ($variant:expr) => {{ - serde_json::from_value::(node.parameters.clone()).map_err(invalid)?; - $variant - }}; - } - Ok(match node.executor.key.as_str() { - "surface_target" => empty!(NormalizedParametersV2::SurfaceTarget), - "scene_table" => empty!(NormalizedParametersV2::SceneTable), - "local_aabb_buffer" => empty!(NormalizedParametersV2::LocalAabbBuffer), - "camera_frustum" => empty!(NormalizedParametersV2::CameraFrustum), - "visibility_flags" => empty!(NormalizedParametersV2::VisibilityFlags), - "frustum_cull" => empty!(NormalizedParametersV2::FrustumCull), - "fullscreen_copy" => empty!(NormalizedParametersV2::FullscreenCopy), - "tone_map" => { - let p: ToneMapParameters = - serde_json::from_value(node.parameters.clone()).map_err(invalid)?; - NormalizedParametersV2::ToneMap { - exposure: range(p.exposure, 0.0, 32.0, format!("{base}.exposure"))?, - } - } - "bloom_extract" => { - let p: BloomExtractParameters = - serde_json::from_value(node.parameters.clone()).map_err(invalid)?; - NormalizedParametersV2::BloomExtract { - threshold: range(p.threshold, 0.0, 64.0, format!("{base}.threshold"))?, - knee: range(p.knee, 0.0, 1.0, format!("{base}.knee"))?, - } - } - "bloom_blur" => { - let p: BloomBlurParameters = - serde_json::from_value(node.parameters.clone()).map_err(invalid)?; - let x = range(p.direction[0], -1.0, 1.0, format!("{base}.direction[0]"))?; - let y = range(p.direction[1], -1.0, 1.0, format!("{base}.direction[1]"))?; - if (x.abs() + y.abs() - 1.0).abs() > 0.0001 { - return Err(error( - "GRAPH_PARAMETERS_INVALID", - "direction must be a unit axis", - format!("{base}.direction"), - )); - } - NormalizedParametersV2::BloomBlur { - direction: [x, y], - radius: range(p.radius, 1.0, 16.0, format!("{base}.radius"))?, - } - } - "bloom_composite" => { - let p: BloomCompositeParameters = - serde_json::from_value(node.parameters.clone()).map_err(invalid)?; - NormalizedParametersV2::BloomComposite { - intensity: range(p.intensity, 0.0, 16.0, format!("{base}.intensity"))?, - } - } - "luminance_edge" => { - let p: LuminanceEdgeParameters = - serde_json::from_value(node.parameters.clone()).map_err(invalid)?; - NormalizedParametersV2::LuminanceEdge { - strength: range(p.strength, 0.0, 16.0, format!("{base}.strength"))?, - } - } - "present" => empty!(NormalizedParametersV2::Present), - "texture_spec" => { - let p: TextureParameters = - serde_json::from_value(node.parameters.clone()).map_err(invalid)?; - if matches!( - p.residency, - TextureResidencyV2::History | TextureResidencyV2::Readback - ) { - return Err(error( - "GRAPH_UNSUPPORTED_FEATURE", - "history and readback textures are unsupported", - format!("{base}.residency"), - )); - } - NormalizedParametersV2::TextureSpec { - residency: p.residency, - texture: normalize_texture(p.texture, &base)?, - } - } - "mesh_query" => { - let object = node.parameters.as_object().ok_or_else(|| { - error( - "GRAPH_PARAMETERS_INVALID", - "parameters must be an object", - base.clone(), - ) - })?; - if object.len() != 1 || !object.contains_key("filters") { - return Err(error( - "GRAPH_PARAMETERS_INVALID", - "mesh query parameters must contain only filters", - base.clone(), - )); - } - let filters = object["filters"].as_array().ok_or_else(|| { - error( - "GRAPH_PARAMETERS_INVALID", - "filters must be an array", - format!("{base}.filters"), - ) - })?; - let mut found = [None, None]; - for (j, value) in filters.iter().enumerate() { - let filter = value.as_object().ok_or_else(|| { - error( - "GRAPH_PARAMETERS_INVALID", - "filter must be an object", - format!("{base}.filters[{j}]"), - ) - })?; - if filter.len() != 2 - || !filter.contains_key("flag") - || !filter.contains_key("predicate") - { - return Err(error( - "GRAPH_PARAMETERS_INVALID", - "filter must contain flag and predicate", - format!("{base}.filters[{j}]"), - )); - } - let flag: MeshFlagV2 = - serde_json::from_value(filter["flag"].clone()).map_err(|e| { - error( - "GRAPH_PARAMETERS_INVALID", - &e.to_string(), - format!("{base}.filters[{j}].flag"), - ) - })?; - let predicate: TriStatePredicate = - serde_json::from_value(filter["predicate"].clone()).map_err(|e| { - error( - "GRAPH_PARAMETERS_INVALID", - &e.to_string(), - format!("{base}.filters[{j}].predicate"), - ) - })?; - let index = if flag == MeshFlagV2::IsVisible { 0 } else { 1 }; - if found[index].replace(predicate).is_some() { - return Err(error( - "GRAPH_PARAMETERS_INVALID", - "duplicate mesh flag", - format!("{base}.filters[{j}].flag"), - )); - } - } - if found.iter().any(Option::is_none) { - return Err(error( - "GRAPH_PARAMETERS_INVALID", - "both mesh flags are required", - format!("{base}.filters"), - )); - } - NormalizedParametersV2::MeshQuery { - filters: [ - NormalizedMeshFilterV2 { - flag: MeshFlagV2::IsVisible, - predicate: found[0].unwrap(), - }, - NormalizedMeshFilterV2 { - flag: MeshFlagV2::IsFrustumCulled, - predicate: found[1].unwrap(), - }, - ], - } - } - "depth_stencil_config" => { - let p: DepthParameters = - serde_json::from_value(node.parameters.clone()).map_err(invalid)?; - if !p.clear_depth.is_finite() || !(0.0..=1.0).contains(&p.clear_depth) { - return Err(error( - "GRAPH_PARAMETERS_INVALID", - "clearDepth must be finite and in [0,1]", - format!("{base}.clearDepth"), - )); - } - NormalizedParametersV2::DepthStencilConfig { - config: NormalizedDepthStencilV2 { - depth_compare: p.depth_compare, - depth_write_enabled: p.depth_write_enabled, - clear_depth: p.clear_depth, - }, - } - } - "legacy_forward" => { - let p: ForwardParameters = - serde_json::from_value(node.parameters.clone()).map_err(invalid)?; - if p.clear_color.iter().any(|x| !x.is_finite()) { - return Err(error( - "GRAPH_PARAMETERS_INVALID", - "clearColor must be finite", - format!("{base}.clearColor"), - )); - } - NormalizedParametersV2::LegacyForward { - clear_color: p.clear_color, - } - } - _ => unreachable!(), - }) -} - -fn accepts(c: TypeConstraintV2, ty: SemanticTypeV2) -> bool { - match c { - TypeConstraintV2::Exact(x) => x == ty, - TypeConstraintV2::OneOf(xs) => xs.contains(&ty), - } -} - -pub fn compile_v2(graph: GraphV2) -> Result { - if graph.nodes.len() > 1024 { - return Err(error( - "GRAPH_LIMIT_EXCEEDED", - "node count exceeds 1024", - "nodes", - )); - } - let mut input_count = 0usize; - for (i, node) in graph.nodes.iter().enumerate() { - input_count = input_count.saturating_add(node.inputs.len()); - if input_count > 8192 { - return Err(error( - "GRAPH_LIMIT_EXCEEDED", - "input count exceeds 8192", - format!("nodes[{i}].inputs"), - )); - } - } - if graph - .nodes - .iter() - .filter(|n| n.executor.key == "present") - .count() - > 64 - { - return Err(error( - "GRAPH_LIMIT_EXCEEDED", - "present count exceeds 64", - "nodes", - )); - } - if graph.schema_version != 2 { - return Err(GraphError::new( - "GRAPH_SCHEMA_UNSUPPORTED", - "schemaVersion must be 2", - )); - } - validate_name_length(&graph.graph_id, "graphId")?; - for (i, n) in graph.nodes.iter().enumerate() { - for (value, path) in [ - (&n.id, format!("nodes[{i}].id")), - (&n.executor.key, format!("nodes[{i}].executor.key")), - ] { - validate_name_length(value, path)?; - } - for (socket, r) in &n.inputs { - for (value, path) in [ - (socket, format!("nodes[{i}].inputs.{socket}")), - (&r.node, format!("nodes[{i}].inputs.{socket}.node")), - (&r.socket, format!("nodes[{i}].inputs.{socket}.socket")), - ] { - validate_name_length(value, path)?; - } - } - } - - validate_name_grammar(&graph.graph_id, "graphId")?; - let mut ids = HashMap::new(); - for (i, n) in graph.nodes.iter().enumerate() { - for (value, path) in [ - (&n.id, format!("nodes[{i}].id")), - (&n.executor.key, format!("nodes[{i}].executor.key")), - ] { - validate_name_grammar(value, path)?; - } - if ids.insert(n.id.as_str(), i).is_some() { - return Err(error( - "GRAPH_DUPLICATE_ID", - "duplicate node id", - format!("nodes[{i}].id"), - )); - } - for (socket, r) in &n.inputs { - for (value, path) in [ - (socket, format!("nodes[{i}].inputs.{socket}")), - (&r.node, format!("nodes[{i}].inputs.{socket}.node")), - (&r.socket, format!("nodes[{i}].inputs.{socket}.socket")), - ] { - validate_name_grammar(value, path)?; - } - } - } - for (i, n) in graph.nodes.iter().enumerate() { - for (s, r) in &n.inputs { - if !ids.contains_key(r.node.as_str()) { - return Err(error( - "GRAPH_UNKNOWN_NODE", - "unknown input node", - format!("nodes[{i}].inputs.{s}.node"), - )); - } - } - } - let contracts: Vec<_> = graph - .nodes - .iter() - .enumerate() - .map(|(i, n)| { - contract(&n.executor.key).ok_or_else(|| { - error( - "GRAPH_UNKNOWN_EXECUTOR", - "unknown executor", - format!("nodes[{i}].executor.key"), - ) - }) - }) - .collect::>()?; - for (i, n) in graph.nodes.iter().enumerate() { - if n.executor.version != contracts[i].version { - return Err(error( - "GRAPH_EXECUTOR_VERSION_UNSUPPORTED", - "unsupported executor version", - format!("nodes[{i}].executor.version"), - )); - } - } - let params: Vec<_> = graph - .nodes - .iter() - .enumerate() - .map(|(i, n)| decode(n, i)) - .collect::>()?; - for (i, n) in graph.nodes.iter().enumerate() { - if n.state != NodeStateV2::Enabled { - return Err(error( - "GRAPH_NODE_STATE_INVALID", - "muted nodes are unsupported", - format!("nodes[{i}].state"), - )); - } - } - - // Socket validation is intentionally global and phased. In particular, no - // cardinality or semantic error may hide a later structural socket error. - for (i, n) in graph.nodes.iter().enumerate() { - for name in n.inputs.keys() { - if !contracts[i].inputs.iter().any(|s| s.name == name) { - return Err(error( - "GRAPH_UNKNOWN_SOCKET", - "unknown input socket", - format!("nodes[{i}].inputs.{name}"), - )); - } - } - } - for (i, n) in graph.nodes.iter().enumerate() { - for (name, r) in &n.inputs { - let pn = ids[r.node.as_str()]; - if !contracts[pn].outputs.iter().any(|out| out.name == r.socket) { - return Err(error( - "GRAPH_UNKNOWN_SOCKET", - "unknown output socket", - format!("nodes[{i}].inputs.{name}.socket"), - )); - } - } - } - for (i, n) in graph.nodes.iter().enumerate() { - for input in contracts[i].inputs { - let inactive = matches!(¶ms[i], NormalizedParametersV2::MeshQuery { filters } if filters.iter().any(|f| f.flag.input_socket() == input.name && f.predicate == TriStatePredicate::Any)); - if !n.inputs.contains_key(input.name) { - if input.cardinality == InputCardinalityV2::RequiredOne - || (!inactive - && matches!(params[i], NormalizedParametersV2::MeshQuery { .. }) - && input.name != "scene") - { - return Err(error( - "GRAPH_SOCKET_CARDINALITY", - "required input is missing", - format!("nodes[{i}].inputs.{}", input.name), - )); - } - } - } - } - - let mut bound: Vec> = vec![BTreeMap::new(); graph.nodes.len()]; - for (i, n) in graph.nodes.iter().enumerate() { - for input in contracts[i].inputs { - let inactive = matches!(¶ms[i], NormalizedParametersV2::MeshQuery { filters } if filters.iter().any(|f| f.flag.input_socket() == input.name && f.predicate == TriStatePredicate::Any)); - let Some(r) = n.inputs.get(input.name) else { - continue; - }; - let pn = ids[r.node.as_str()]; - let (ordinal, out) = contracts[pn] - .outputs - .iter() - .enumerate() - .find(|(_, o)| o.name == r.socket) - .expect("producer sockets were globally validated"); - let attachment_shape_checked_later = contracts[i].key == "legacy_forward" - && input.name == "depthTarget" - && out.semantic_type == SemanticTypeV2::SurfaceTarget; - if !accepts(input.accepted, out.semantic_type) && !attachment_shape_checked_later { - return Err(error( - "GRAPH_SOCKET_TYPE_MISMATCH", - "socket type mismatch", - format!("nodes[{i}].inputs.{}", input.name), - )); - } - if let Some(flag) = MeshFlagV2::ORDERED - .iter() - .find(|f| f.input_socket() == input.name) - { - if out.metadata != (OutputMetadataV2::BooleanFlag { flag: *flag }) { - return Err(error( - "GRAPH_SOCKET_TYPE_MISMATCH", - "mesh flag metadata mismatch", - format!("nodes[{i}].inputs.{}", input.name), - )); - } - } - bound[i].insert( - input.name, - BoundInput { - producer: OutputKey(pn, ordinal as u16), - active: !inactive, - }, - ); - } - } - let root = |key: OutputKey, - bound: &Vec>, - contracts: &Vec<&ContractV2>| - -> Option { - let mut k = key; - let mut seen = HashSet::new(); - loop { - if !seen.insert(k.0) { - return None; - } - if contracts[k.0].outputs[k.1 as usize].semantic_type == SemanticTypeV2::SceneTable { - return Some(k); - } - k = bound[k.0].get("scene")?.producer; - } - }; - for (i, c) in contracts.iter().enumerate() { - if c.key == "frustum_cull" - && root(bound[i]["scene"].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" | "legacy_forward") { - let scene = root(bound[i]["scene"].producer, &bound, &contracts); - for (s, b) in &bound[i] { - if b.active - && matches!(*s, "isVisible" | "isFrustumCulled" | "draws") - && root(b.producer, &bound, &contracts) != scene - { - return Err(error( - "GRAPH_SOCKET_TYPE_MISMATCH", - "scene roots differ", - format!("nodes[{i}].inputs.{s}"), - )); - } - } - } - } - let mut edges = Vec::new(); - for i in 0..graph.nodes.len() { - for (input_ordinal, input) in contracts[i].inputs.iter().enumerate() { - if let Some(b) = bound[i].get(input.name).filter(|b| b.active) { - edges.push(DependencyEdge { - from_node: b.producer.0, - from_socket: contracts[b.producer.0].outputs[b.producer.1 as usize] - .name - .into(), - producer_output_ordinal: b.producer.1, - to_node: i, - to_socket: input.name.into(), - consumer_input_ordinal: input_ordinal as u16, - resource: graph.nodes[i].inputs[input.name].clone(), - }); - } - } - } - edges.sort_by_key(|e| { - ( - e.to_node, - e.consumer_input_ordinal, - e.from_node, - e.producer_output_ordinal, - ) - }); - let mut deps = vec![Vec::new(); graph.nodes.len()]; - for edge in &edges { - deps[edge.to_node].push(edge.from_node); - } - for node_deps in &mut deps { - node_deps.sort(); - node_deps.dedup(); - } - let mut live = HashSet::new(); - let mut stack: Vec<_> = contracts - .iter() - .enumerate() - .filter(|(_, c)| c.inherently_observable) - .map(|(i, _)| i) - .collect(); - while let Some(i) = stack.pop() { - if live.insert(i) { - stack.extend(deps[i].iter().copied()); - } - } - // IDs are independent of scheduling: original node order, then contract output order. - let mut output_ids = BTreeMap::new(); - let mut resource_meta = Vec::new(); - for i in 0..graph.nodes.len() { - if live.contains(&i) { - for (o, out) in contracts[i].outputs.iter().enumerate() { - let id = resource_meta.len() as u32; - output_ids.insert(OutputKey(i, o as u16), id); - resource_meta.push((i, o as u16, *out)); - } - } - } - let all_outputs: usize = contracts.iter().map(|c| c.outputs.len()).sum(); - - // Establish families and transitions without relying on a schedule. - let mut families = Vec::new(); - let mut source_family = HashMap::new(); - for i in 0..graph.nodes.len() { - if !live.contains(&i) { - continue; - } - let source = output_ids.get(&OutputKey(i, 0)).copied(); - match ¶ms[i] { - NormalizedParametersV2::SurfaceTarget => { - let id = families.len() as u32; - let r = source.unwrap(); - source_family.insert(OutputKey(i, 0), id); - families.push(TextureFamilyV2 { - id, - key: TextureFamilyKeyV2 { - source_node: i as u32, - source_socket: 0, - }, - source: TextureFamilySourceV2::ImportedSurface { resource: r }, - lifetime: LifetimeV2 { - first_use: 0, - last_use: 0, - }, - versions: vec![], - usage: vec![], - allocation: None, - aliasable: false, - }); - } - NormalizedParametersV2::TextureSpec { residency, texture } => { - let id = families.len() as u32; - let r = source.unwrap(); - source_family.insert(OutputKey(i, 0), id); - families.push(TextureFamilyV2 { - id, - key: TextureFamilyKeyV2 { - source_node: i as u32, - source_socket: 0, - }, - source: TextureFamilySourceV2::AuthoredTexture { - resource: r, - residency: *residency, - descriptor: texture.clone(), - }, - lifetime: LifetimeV2 { - first_use: 0, - last_use: 0, - }, - versions: vec![], - usage: vec![], - allocation: None, - aliasable: false, - }); - } - _ => {} - } - } - let mut transitions: Vec = Vec::new(); - let mut transitions_by_target: BTreeMap> = BTreeMap::new(); - for i in 0..graph.nodes.len() { - if !live.contains(&i) { - continue; - } - let transition_sockets: &[(&str, u16)] = match contracts[i].key { - "legacy_forward" => &[("colorTarget", 0), ("depthTarget", 1)], - "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" - | "luminance_edge" => &[("colorTarget", 0)], - _ => continue, - }; - for &(input_socket, output_ordinal) in transition_sockets { - let transition = TextureTransition { - writer_node: i, - input_socket, - target: bound[i][input_socket].producer, - output: OutputKey(i, output_ordinal), - }; - let index = transitions.len(); - transitions.push(transition); - transitions_by_target - .entry(transition.target) - .or_default() - .push(index); - } - } - - fn resolve_transition( - output: OutputKey, - transitions: &[TextureTransition], - transition_for_output: &HashMap, - source_family: &HashMap, - colors: &mut HashMap, - resolved: &mut HashMap, - ) -> ResolvedTransition { - if let Some(&value) = resolved.get(&output) { - return value; - } - if colors.get(&output) == Some(&1) { - return ResolvedTransition::Cyclic; - } - colors.insert(output, 1); - let transition = transitions[transition_for_output[&output]]; - let value = if let Some(&family) = source_family.get(&transition.target) { - ResolvedTransition::Resolved { - family, - version: 0, - target: transition.target, - } - } else if transition_for_output.contains_key(&transition.target) { - match resolve_transition( - transition.target, - transitions, - transition_for_output, - source_family, - colors, - resolved, - ) { - ResolvedTransition::Resolved { - family, version, .. - } => ResolvedTransition::Resolved { - family, - version: version + 1, - target: transition.target, - }, - ResolvedTransition::Cyclic => ResolvedTransition::Cyclic, - } - } else { - ResolvedTransition::Cyclic - }; - colors.insert(output, 2); - resolved.insert(output, value); - value - } - - let transition_for_output: HashMap<_, _> = transitions - .iter() - .enumerate() - .map(|(index, transition)| (transition.output, index)) - .collect(); - let mut resolved = HashMap::new(); - let mut colors = HashMap::new(); - for transition in &transitions { - resolve_transition( - transition.output, - &transitions, - &transition_for_output, - &source_family, - &mut colors, - &mut resolved, - ); - } - let mut version_of: HashMap = HashMap::new(); - for transition in &transitions { - if let ResolvedTransition::Resolved { - family, - version, - target, - } = resolved[&transition.output] - { - let target_id = output_ids[&target]; - version_of.insert(transition.output, (family, version, target_id)); - } - } - - let mut outgoing_edges = vec![Vec::new(); graph.nodes.len()]; - for (index, edge) in edges.iter().enumerate() { - if live.contains(&edge.from_node) && live.contains(&edge.to_node) { - outgoing_edges[edge.from_node].push(index); - } - } - for outgoing in &mut outgoing_edges { - outgoing.sort_by_key(|&index| { - let edge = &edges[index]; - ( - edge.to_node, - edge.producer_output_ordinal, - edge.consumer_input_ordinal, - ) - }); - } - - // Every texture reader must execute before a successor overwrites the - // physical allocation backing the older symbolic version. - let mut reachability = HashMap::new(); - for (i, contract) in contracts.iter().enumerate() { - if !live.contains(&i) { - continue; - } - for input in contract.inputs.iter().filter(|input| { - matches!( - input.role, - InputRoleV2::Present | InputRoleV2::SampledTexture - ) - }) { - let key = bound[i][input.name].producer; - if !version_of.contains_key(&key) { - continue; - } - let Some(next_indices) = transitions_by_target.get(&key) else { - continue; - }; - let [next_index] = next_indices.as_slice() else { - continue; - }; - let next = transitions[*next_index]; - if i != next.writer_node - && !reaches( - i, - next.writer_node, - &outgoing_edges, - &edges, - &live, - &mut reachability, - ) - { - return Err(error( - "GRAPH_RESOURCE_VERSION_INVALID", - "older texture version may be read after its successor", - format!("nodes[{i}].inputs.{}", input.name), - )); - } - } - } - - // Same-pass hazards are global and precede every duplicate-writer diagnostic. - for i in 0..graph.nodes.len() { - if !live.contains(&i) { - continue; - } - if contracts[i].key == "legacy_forward" { - if bound[i]["colorTarget"].producer == bound[i]["depthTarget"].producer - || matches!((version_of.get(&OutputKey(i, 0)), version_of.get(&OutputKey(i, 1))), (Some((cf, _, _)), Some((df, _, _))) if cf == df) - { - return Err(error( - "GRAPH_SAME_PASS_HAZARD", - "color and depth use one texture family", - format!("nodes[{i}].inputs"), - )); - } - } else if contracts[i] - .inputs - .iter() - .any(|input| matches!(input.role, InputRoleV2::SampledTexture)) - { - let hazard = contracts[i].inputs.iter().filter(|input| matches!(input.role, InputRoleV2::SampledTexture)).any(|input| matches!((version_of.get(&bound[i][input.name].producer), version_of.get(&OutputKey(i, 0))), (Some((sf, _, _)), Some((tf, _, _))) if sf == tf)); - if hazard { - return Err(error( - "GRAPH_SAME_PASS_HAZARD", - "copy source and target use one texture family", - format!("nodes[{i}].inputs"), - )); - } - } - } - let mut first_writer = BTreeMap::new(); - for transition in &transitions { - if first_writer - .insert(transition.target, transition.writer_node) - .is_some_and(|writer| writer != transition.writer_node) - { - return Err(error( - "GRAPH_DUPLICATE_WRITER", - "texture version has multiple writers", - format!( - "nodes[{}].inputs.{}", - transition.writer_node, transition.input_socket - ), - )); - } - } - - // Materialize versions only after hazard and writer precedence has been settled. - for transition in &transitions { - if let Some(&(family, version, target)) = version_of.get(&transition.output) { - families[family as usize].versions.push(TextureVersionV2 { - version, - resource: output_ids[&transition.output], - target, - initialized: true, - stored: true, - lifetime: LifetimeV2 { - first_use: 0, - last_use: 0, - }, - }); - } - } - for family in &mut families { - family.versions.sort_by_key(|version| version.version); - for (index, version) in family.versions.iter().enumerate() { - if version.version != index as u32 { - return Err(error( - "GRAPH_RESOURCE_VERSION_INVALID", - "texture versions must form a dense linear chain", - "resources", - )); - } - } - } - - // Validate every independently resolved attachment before graph cycle reporting. - for i in 0..graph.nodes.len() { - if !live.contains(&i) || contracts[i].key != "legacy_forward" { - continue; - } - let (Some(&(cf, _, _)), Some(&(df, _, _))) = ( - version_of.get(&OutputKey(i, 0)), - version_of.get(&OutputKey(i, 1)), - ) else { - continue; - }; - let cd = match &families[cf as usize].source { - TextureFamilySourceV2::AuthoredTexture { descriptor, .. } => Some(descriptor), - _ => None, - }; - let dd = match &families[df as usize].source { - TextureFamilySourceV2::AuthoredTexture { descriptor, .. } => descriptor, - _ => { - return Err(error( - "GRAPH_ILLEGAL_ACCESS", - "depth target must be authored", - format!("nodes[{i}].inputs.depthTarget"), - )) - } - }; - let ok_depth = dd.dimension == TextureDimensionV2::D2 - && dd.format == TextureFormatV2::Depth32Float - && dd.sample_count == 1 - && extent_layers(&dd.extent) == 1; - let ok_color = cd.is_none_or(|d| { - d.format != TextureFormatV2::Depth32Float - && d.dimension == dd.dimension - && d.extent == dd.extent - && d.sample_count == 1 - }); - let surface_ok = cd.is_some() - || matches!(&dd.extent,NormalizedTextureExtentV2::SurfaceRelative{width,height,..} if *width==RatioV2{numerator:1,denominator:1}&&*height==RatioV2{numerator:1,denominator:1}); - if !ok_depth || !ok_color || !surface_ok { - return Err(error( - "GRAPH_ILLEGAL_ACCESS", - "attachments are incompatible", - format!("nodes[{i}].inputs"), - )); - } - } - - for i in 0..graph.nodes.len() { - if !live.contains(&i) - || !contracts[i] - .inputs - .iter() - .any(|input| matches!(input.role, InputRoleV2::SampledTexture)) - { - continue; - } - let source_key = bound[i]["source"].producer; - let Some(&(source_family_id, _, _)) = version_of.get(&source_key) else { - return Err(error( - "GRAPH_UNINITIALIZED_RESOURCE", - "copy source is not produced", - format!("nodes[{i}].inputs.source"), - )); - }; - let Some(&(target_family_id, _, _)) = version_of.get(&OutputKey(i, 0)) else { - continue; - }; - let source_descriptor = match &families[source_family_id as usize].source { - TextureFamilySourceV2::AuthoredTexture { descriptor, .. } => descriptor, - TextureFamilySourceV2::ImportedSurface { .. } => { - return Err(error( - "GRAPH_ILLEGAL_ACCESS", - "copy source must be an authored texture", - format!("nodes[{i}].inputs.source"), - )) - } - }; - let source_ok = source_descriptor.format == TextureFormatV2::Rgba16Float - && is_single_view_d2(source_descriptor); - let target_descriptor = match &families[target_family_id as usize].source { - TextureFamilySourceV2::AuthoredTexture { descriptor, .. } => Some(descriptor), - TextureFamilySourceV2::ImportedSurface { .. } => None, - }; - let authored_target_ok = target_descriptor.is_some_and(|descriptor| { - descriptor.format == TextureFormatV2::Rgba16Float && is_single_view_d2(descriptor) - }); - let bloom_input_ok = if contracts[i].key == "bloom_composite" { - let bloom_key = bound[i]["bloom"].producer; - let Some(&(bloom_family_id, _, _)) = version_of.get(&bloom_key) else { - return Err(error( - "GRAPH_UNINITIALIZED_RESOURCE", - "bloom source is not produced", - format!("nodes[{i}].inputs.bloom"), - )); - }; - match &families[bloom_family_id as usize].source { - TextureFamilySourceV2::AuthoredTexture { descriptor, .. } => { - descriptor.format == TextureFormatV2::Rgba16Float - && is_single_view_d2(descriptor) - } - TextureFamilySourceV2::ImportedSurface { .. } => { - return Err(error( - "GRAPH_ILLEGAL_ACCESS", - "bloom source must be an authored texture", - format!("nodes[{i}].inputs.bloom"), - )) - } - } - } else { - true - }; - let source_is_full_surface = matches!(&source_descriptor.extent, NormalizedTextureExtentV2::SurfaceRelative { width, height, depth_or_array_layers: 1 } if *width == RatioV2 { numerator:1, denominator:1 } && *height == RatioV2 { numerator:1, denominator:1 }); - let target_matches_source = target_descriptor - .is_some_and(|descriptor| descriptor.extent == source_descriptor.extent); - let descriptor_ok = match contracts[i].key { - "fullscreen_copy" => { - target_descriptor.is_none() && source_is_full_surface - || target_descriptor.is_some_and(|descriptor| { - descriptor.format != TextureFormatV2::Depth32Float - && is_single_view_d2(descriptor) - && descriptor.extent == source_descriptor.extent - }) - } - "tone_map" => target_descriptor.is_none() && source_is_full_surface, - "bloom_extract" => authored_target_ok, - "bloom_blur" | "luminance_edge" => authored_target_ok && target_matches_source, - "bloom_composite" => authored_target_ok && target_matches_source && bloom_input_ok, - _ => false, - }; - if !source_ok || !descriptor_ok { - return Err(error( - "GRAPH_ILLEGAL_ACCESS", - "fullscreen textures are incompatible", - format!("nodes[{i}].inputs"), - )); - } - } - - // Initialization and presentation legality are later than attachment compatibility. - for (i, contract) in contracts.iter().enumerate() { - if !live.contains(&i) || contract.key != "present" { - continue; - } - let key = bound[i]["surface"].producer; - let Some(&(family, _, _)) = version_of.get(&key) else { - if !matches!(resolved.get(&key), Some(ResolvedTransition::Cyclic)) { - return Err(error( - "GRAPH_UNINITIALIZED_RESOURCE", - "present source is not produced", - format!("nodes[{i}].inputs.surface"), - )); - } - continue; - }; - if !matches!( - families[family as usize].source, - TextureFamilySourceV2::ImportedSurface { .. } - ) { - return Err(error( - "GRAPH_ILLEGAL_ACCESS", - "offscreen textures cannot be presented", - format!("nodes[{i}].inputs.surface"), - )); - } - } - - // Stable Kahn scheduling is deliberately after resource and access validation. - let mut indegree = vec![0; graph.nodes.len()]; - for &node in &live { - indegree[node] = edges - .iter() - .filter(|edge| edge.to_node == node && live.contains(&edge.from_node)) - .count(); - } - let mut queue = BinaryHeap::new(); - for &node in &live { - if indegree[node] == 0 { - queue.push(Reverse(node)); - } - } - let mut order = Vec::new(); - while let Some(Reverse(node)) = queue.pop() { - order.push(node); - for &edge_index in &outgoing_edges[node] { - let consumer = edges[edge_index].to_node; - indegree[consumer] -= 1; - if indegree[consumer] == 0 { - queue.push(Reverse(consumer)); - } - } - } - if order.len() != live.len() { - let residual: Vec<_> = (0..graph.nodes.len()) - .map(|node| live.contains(&node) && indegree[node] != 0) - .collect(); - fn cycle_dfs( - node: usize, - outgoing_edges: &[Vec], - edges: &[DependencyEdge], - residual: &[bool], - colors: &mut [u8], - node_stack: &mut Vec, - edge_stack: &mut Vec, - ) -> Option> { - colors[node] = 1; - node_stack.push(node); - for &edge_index in &outgoing_edges[node] { - let to = edges[edge_index].to_node; - if !residual[to] { - continue; - } - if colors[to] == 0 { - edge_stack.push(edge_index); - if let Some(cycle) = cycle_dfs( - to, - outgoing_edges, - edges, - residual, - colors, - node_stack, - edge_stack, - ) { - return Some(cycle); - } - edge_stack.pop(); - } else if colors[to] == 1 { - let position = node_stack - .iter() - .position(|&stacked| stacked == to) - .unwrap(); - let mut cycle = edge_stack[position..].to_vec(); - cycle.push(edge_index); - return Some(cycle); - } - } - node_stack.pop(); - colors[node] = 2; - None - } - let mut colors = vec![0; graph.nodes.len()]; - let mut cycle = None; - for node in 0..graph.nodes.len() { - if residual[node] && colors[node] == 0 { - cycle = cycle_dfs( - node, - &outgoing_edges, - &edges, - &residual, - &mut colors, - &mut Vec::new(), - &mut Vec::new(), - ); - if cycle.is_some() { - break; - } - } - } - let mut graph_error = GraphError::new("GRAPH_CYCLE", "live graph contains a cycle"); - let payload: Vec<_> = cycle - .unwrap_or_default() - .into_iter() - .map(|index| { - let edge = &edges[index]; - serde_json::json!({ - "fromNode": graph.nodes[edge.from_node].id, - "fromSocket": edge.from_socket, - "toNode": graph.nodes[edge.to_node].id, - "toSocket": edge.to_socket, - "resource": edge.resource, - }) - }) - .collect(); - graph_error.details = - serde_json::json!({"message":graph_error.message,"kind":"cycle","edges":payload}); - return Err(graph_error); - } - if transitions - .iter() - .any(|transition| matches!(resolved[&transition.output], ResolvedTransition::Cyclic)) - { - return Err(error( - "GRAPH_RESOURCE_VERSION_INVALID", - "texture predecessor is unresolved in an acyclic graph", - "resources", - )); - } - - let mut resources = Vec::new(); - for (i, o, out) in resource_meta { - let key = OutputKey(i, o); - let id = output_ids[&key]; - let scene = || output_ids[&root(bound[i]["scene"].producer, &bound, &contracts).unwrap()]; - let plan = match out.semantic_type { - SemanticTypeV2::SurfaceTarget => ResourcePlanV2::SurfaceTarget { - family: source_family[&key], - }, - SemanticTypeV2::TextureSpec => { - if let NormalizedParametersV2::TextureSpec { residency, texture } = ¶ms[i] { - ResourcePlanV2::TextureSpec { - family: source_family[&key], - residency: *residency, - descriptor: texture.clone(), - } - } else { - unreachable!() - } - } - SemanticTypeV2::Texture => { - let (f, v, t) = version_of[&key]; - ResourcePlanV2::Texture { - family: f, - version: v, - target: t, - initialized: true, - stored: true, - allocation: None, - } - } - SemanticTypeV2::SceneTable => ResourcePlanV2::SceneTable, - SemanticTypeV2::LocalAabbBuffer => ResourcePlanV2::LocalAabbBuffer { scene: scene() }, - SemanticTypeV2::CameraFrustum => ResourcePlanV2::CameraFrustum, - SemanticTypeV2::BooleanFlagBuffer => { - if let OutputMetadataV2::BooleanFlag { flag } = out.metadata { - ResourcePlanV2::BooleanFlagBuffer { - scene: scene(), - flag, - } - } else { - unreachable!() - } - } - SemanticTypeV2::DrawStream => ResourcePlanV2::DrawStream { scene: scene() }, - SemanticTypeV2::DepthStencilConfig => { - if let NormalizedParametersV2::DepthStencilConfig { config } = ¶ms[i] { - ResourcePlanV2::DepthStencilConfig { config: *config } - } else { - unreachable!() - } - } - }; - resources.push(CompiledResourceV2 { - original_node_index: i as u32, - output_ordinal: o, - origin: NodeOutputRef { - node: graph.nodes[i].id.clone(), - socket: out.name.into(), - }, - semantic_type: out.semantic_type, - producer_execution: None, - lifetime: None, - plan, - }); - let _ = id; - } - let mut executions = Vec::new(); - let mut node_execution = HashMap::new(); - for &i in &order { - if contracts[i].execution == ExecutionClassV2::Source { - continue; - } - let ordinal = executions.len() as u32; - node_execution.insert(i, ordinal); - let input_resource = |s: &str| output_ids[&bound[i][s].producer]; - let mut inputs = Vec::new(); - for s in contracts[i].inputs { - if let Some(b) = bound[i].get(s.name).filter(|b| b.active) { - inputs.push(CompiledSocketInputV2 { - socket: s.name.into(), - resource: output_ids[&b.producer], - }); - } - } - let outputs: Vec<_> = contracts[i] - .outputs - .iter() - .enumerate() - .map(|(o, s)| CompiledSocketOutputV2 { - socket: s.name.into(), - resource: output_ids[&OutputKey(i, o as u16)], - }) - .collect(); - let mut accesses = Vec::new(); - let kind = match contracts[i].key { - "frustum_cull" => { - for (s, m) in [ - ("scene", AccessModeV2::StorageRead), - ("localAabbs", AccessModeV2::StorageRead), - ("frustum", AccessModeV2::UniformRead), - ] { - accesses.push(CompiledAccessV2 { - socket: s.into(), - resource: input_resource(s), - mode: m, - }); - } - let r = output_ids[&OutputKey(i, 0)]; - accesses.push(CompiledAccessV2 { - socket: "flags".into(), - resource: r, - mode: AccessModeV2::StorageWrite { - full_overwrite: true, - }, - }); - ExecutionKindV2::Compute { - work: ComputeWorkV2::FrustumCull, - } - } - "mesh_query" => { - for s in ["scene", "isVisible", "isFrustumCulled"] { - if let Some(b) = bound[i].get(s).filter(|b| b.active) { - accesses.push(CompiledAccessV2 { - socket: s.into(), - resource: output_ids[&b.producer], - mode: AccessModeV2::StorageRead, - }); - } - } - accesses.push(CompiledAccessV2 { - socket: "draws".into(), - resource: output_ids[&OutputKey(i, 0)], - mode: AccessModeV2::StorageWrite { - full_overwrite: true, - }, - }); - ExecutionKindV2::Compute { - work: ComputeWorkV2::MeshQuery, - } - } - "legacy_forward" => { - let color = output_ids[&OutputKey(i, 0)]; - let depth = output_ids[&OutputKey(i, 1)]; - let clear = match params[i] { - NormalizedParametersV2::LegacyForward { clear_color } => clear_color, - _ => unreachable!(), - }; - let config_node = bound[i]["depthStencil"].producer.0; - let dc = match params[config_node] { - NormalizedParametersV2::DepthStencilConfig { config } => config, - _ => unreachable!(), - }; - let cl = NormalizedColorLoadV2::Clear { value: clear }; - let dl = NormalizedDepthLoadV2::Clear { - value: dc.clear_depth, - }; - for s in ["scene", "draws"] { - accesses.push(CompiledAccessV2 { - socket: s.into(), - resource: input_resource(s), - mode: if s == "draws" { - AccessModeV2::IndirectRead - } else { - AccessModeV2::SemanticRead - }, - }); - } - accesses.push(CompiledAccessV2 { - socket: "color".into(), - resource: color, - mode: AccessModeV2::ColorAttachment { - location: 0, - load: cl, - store: StoreOpV2::Store, - full_overwrite: true, - }, - }); - accesses.push(CompiledAccessV2 { - socket: "depth".into(), - resource: depth, - mode: AccessModeV2::DepthAttachment { - load: dl, - store: StoreOpV2::Store, - full_overwrite: true, - }, - }); - ExecutionKindV2::Render { - color_attachments: vec![ColorAttachmentPlanV2 { - resource: color, - location: 0, - load: cl, - store: StoreOpV2::Store, - }], - depth_stencil: Some(DepthStencilAttachmentPlanV2 { - resource: depth, - load: dl, - store: StoreOpV2::Store, - }), - } - } - "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" - | "luminance_edge" => { - let color = output_ids[&OutputKey(i, 0)]; - let load = NormalizedColorLoadV2::Clear { - value: [0.0, 0.0, 0.0, 0.0], - }; - accesses.push(CompiledAccessV2 { - socket: "source".into(), - resource: input_resource("source"), - mode: AccessModeV2::SampledTexture, - }); - if contracts[i].key == "bloom_composite" { - accesses.push(CompiledAccessV2 { - socket: "bloom".into(), - resource: input_resource("bloom"), - mode: AccessModeV2::SampledTexture, - }); - } - accesses.push(CompiledAccessV2 { - socket: "color".into(), - resource: color, - mode: AccessModeV2::ColorAttachment { - location: 0, - load, - store: StoreOpV2::Store, - full_overwrite: true, - }, - }); - ExecutionKindV2::Render { - color_attachments: vec![ColorAttachmentPlanV2 { - resource: color, - location: 0, - load, - store: StoreOpV2::Store, - }], - depth_stencil: None, - } - } - "present" => { - let r = input_resource("surface"); - accesses.push(CompiledAccessV2 { - socket: "surface".into(), - resource: r, - mode: AccessModeV2::Present, - }); - ExecutionKindV2::Present { surface: r } - } - _ => unreachable!(), - }; - executions.push(CompiledExecutionV2 { - id: graph.nodes[i].id.clone(), - original_node_index: i as u32, - executor: graph.nodes[i].executor.clone(), - parameters: params[i].clone(), - kind, - inputs, - outputs, - accesses, - }); - } - for (ordinal, e) in executions.iter().enumerate() { - for o in &e.outputs { - resources[o.resource as usize].producer_execution = Some(ordinal as u32); - } - } - // Dense lifetimes touch bindings, outputs, and accesses. - for (ordinal, e) in executions.iter().enumerate() { - let ordinal = ordinal as u32; - let mut touched = BTreeSet::new(); - for x in &e.inputs { - touched.insert(x.resource); - } - for x in &e.outputs { - touched.insert(x.resource); - } - for x in &e.accesses { - touched.insert(x.resource); - } - for r in touched { - let life = resources[r as usize].lifetime.get_or_insert(LifetimeV2 { - first_use: ordinal, - last_use: ordinal, - }); - life.first_use = life.first_use.min(ordinal); - life.last_use = life.last_use.max(ordinal); - } - } - for f in &mut families { - let mut first = None; - let mut last = 0; - for v in &mut f.versions { - v.lifetime = resources[v.resource as usize].lifetime.unwrap(); - first = Some(first.map_or(v.lifetime.first_use, |x: u32| x.min(v.lifetime.first_use))); - last = last.max(v.lifetime.last_use); - } - f.lifetime = LifetimeV2 { - first_use: first.unwrap_or(0), - last_use: last, - }; - f.usage = texture_usage(f, &executions); - f.aliasable = matches!( - f.source, - TextureFamilySourceV2::AuthoredTexture { - residency: TextureResidencyV2::Transient, - .. - } - ) && f.versions.iter().all(|v| v.initialized); - } - let (classes, transient) = allocate(&mut families, &mut resources); - if resources.len() > 1024 { - return Err(error( - "GRAPH_LIMIT_EXCEEDED", - "too many final resources", - "resources", - )); - } - Ok(CompiledGraphV2 { - schema_version: 2, - graph_id: graph.graph_id, - revision: graph.revision, - node_count: graph.nodes.len() as u32, - resources, - executions, - texture_families: families, - allocation_classes: classes, - culled_node_count: (graph.nodes.len() - live.len()) as u32, - culled_resource_count: (all_outputs - output_ids.len()) as u32, - transient_slot_count: transient, - }) -} - -fn extent_layers(e: &NormalizedTextureExtentV2) -> u32 { - match e { - NormalizedTextureExtentV2::Absolute { - depth_or_array_layers, - .. - } - | NormalizedTextureExtentV2::SurfaceRelative { - depth_or_array_layers, - .. - } => *depth_or_array_layers, - } -} -fn is_single_view_d2(descriptor: &NormalizedTextureDescriptorV2) -> bool { - descriptor.dimension == TextureDimensionV2::D2 - && descriptor.sample_count == 1 - && descriptor.mip_level_count == 1 - && extent_layers(&descriptor.extent) == 1 -} -fn texture_usage(f: &TextureFamilyV2, executions: &[CompiledExecutionV2]) -> Vec { - let rs: HashSet<_> = f.versions.iter().map(|v| v.resource).collect(); - let mut u = BTreeSet::new(); - for e in executions { - for a in &e.accesses { - if !rs.contains(&a.resource) { - continue; - } - match a.mode { - AccessModeV2::SampledTexture => { - u.insert(TextureUsageV2::Sampled); - } - AccessModeV2::StorageRead | AccessModeV2::StorageWrite { .. } => { - u.insert(TextureUsageV2::Storage); - } - AccessModeV2::ColorAttachment { .. } => { - u.insert(TextureUsageV2::ColorAttachment); - } - AccessModeV2::DepthAttachment { .. } => { - u.insert(TextureUsageV2::DepthAttachment); - } - _ => {} - } - } - } - u.into_iter().collect() -} -fn allocate( - families: &mut [TextureFamilyV2], - resources: &mut [CompiledResourceV2], -) -> (Vec, u32) { - let mut grouped: BTreeMap> = BTreeMap::new(); - for (i, f) in families.iter().enumerate() { - if let TextureFamilySourceV2::AuthoredTexture { descriptor, .. } = &f.source { - grouped - .entry(TextureCompatibilityKeyV2 { - dimension: descriptor.dimension, - format: descriptor.format, - extent: descriptor.extent.clone(), - mip_level_count: descriptor.mip_level_count, - sample_count: descriptor.sample_count, - view_formats: descriptor.view_formats.clone(), - }) - .or_default() - .push(i); - } - } - let mut classes = Vec::new(); - let mut transient = 0; - for (key, ids) in grouped { - let class = classes.len() as u32; - let mut slots: Vec = Vec::new(); - let mut aliasable = Vec::new(); - let mut dedicated = Vec::new(); - let mut persistent_ids = Vec::new(); - for fi in ids { - let persistent = matches!( - families[fi].source, - TextureFamilySourceV2::AuthoredTexture { - residency: TextureResidencyV2::Persistent, - .. - } - ); - let alias = families[fi].aliasable && !persistent; - if persistent { - persistent_ids.push(fi); - } else if alias { - aliasable.push(fi); - } else { - dedicated.push(fi); - } - } - aliasable.sort_by_key(|&fi| { - ( - families[fi].lifetime.first_use, - families[fi].lifetime.last_use, - families[fi].key.clone(), - ) - }); - dedicated.sort_by_key(|&fi| families[fi].key.clone()); - persistent_ids.sort_by_key(|&fi| families[fi].key.clone()); - for fi in aliasable.into_iter().chain(dedicated).chain(persistent_ids) { - let persistent = matches!( - families[fi].source, - TextureFamilySourceV2::AuthoredTexture { - residency: TextureResidencyV2::Persistent, - .. - } - ); - let alias = families[fi].aliasable && !persistent; - let found = if alias { - slots.iter().position(|s| { - s.kind == AllocationKindV2::AliasedTransient - && s.occupants.iter().all(|&old| { - families[old as usize].lifetime.last_use - < families[fi].lifetime.first_use - }) - }) - } else { - None - }; - let slot = found.unwrap_or_else(|| { - let s = slots.len(); - if !persistent { - transient += 1; - } - slots.push(AllocationSlotV2 { - kind: if persistent { - AllocationKindV2::Persistent - } else if alias { - AllocationKindV2::AliasedTransient - } else { - AllocationKindV2::DedicatedTransient - }, - usage: Vec::new(), - occupants: Vec::new(), - }); - s - }); - slots[slot].occupants.push(fi as u32); - slots[slot].usage.extend(families[fi].usage.iter().copied()); - slots[slot].usage.sort(); - slots[slot].usage.dedup(); - let a = AllocationRefV2 { - class, - slot: slot as u32, - }; - families[fi].allocation = Some(a); - for v in &families[fi].versions { - if let ResourcePlanV2::Texture { allocation, .. } = - &mut resources[v.resource as usize].plan - { - *allocation = Some(a); - } - } - } - classes.push(AllocationClassV2 { key, slots }); - } - (classes, transient) -} diff --git a/renderer/src/render_graph/contracts.rs b/renderer/src/render_graph/contracts.rs new file mode 100644 index 0000000..dbcda93 --- /dev/null +++ b/renderer/src/render_graph/contracts.rs @@ -0,0 +1,414 @@ +use super::MeshFlag; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SemanticType { + SurfaceTarget, + TextureSpec, + Texture, + SceneTable, + LocalAabbBuffer, + CameraFrustum, + BooleanFlagBuffer, + DrawStream, + DepthStencilConfig, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ExecutionClass { + Source, + CpuPreparation, + Compute, + Render, + Present, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InputCardinality { + RequiredOne, + OptionalOne, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "kind", content = "types", rename_all = "snake_case")] +pub enum TypeConstraint { + Exact(SemanticType), + OneOf(&'static [SemanticType]), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum InputRole { + SemanticRead, + UniformRead, + StorageRead, + IndirectRead, + SampledTexture, + ColorTarget { location: u32 }, + DepthTarget, + Present, + Configuration, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum OutputMetadata { + None, + BooleanFlag { flag: MeshFlag }, +} + +#[derive(Clone, Copy, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InputSocketContract { + pub name: &'static str, + pub accepted: TypeConstraint, + pub cardinality: InputCardinality, + pub role: InputRole, +} + +#[derive(Clone, Copy, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OutputSocketContract { + pub name: &'static str, + pub semantic_type: SemanticType, + pub metadata: OutputMetadata, +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Contract { + pub key: &'static str, + pub version: u32, + pub execution: ExecutionClass, + pub inputs: &'static [InputSocketContract], + pub outputs: &'static [OutputSocketContract], + pub inherently_observable: bool, +} + +use SemanticType::*; + +const fn input( + name: &'static str, + accepted: TypeConstraint, + cardinality: InputCardinality, + role: InputRole, +) -> InputSocketContract { + InputSocketContract { + name, + accepted, + cardinality, + role, + } +} + +const fn output( + name: &'static str, + semantic_type: SemanticType, + metadata: OutputMetadata, +) -> OutputSocketContract { + OutputSocketContract { + name, + semantic_type, + metadata, + } +} + +const REQUIRED: InputCardinality = InputCardinality::RequiredOne; +const OPTIONAL: InputCardinality = InputCardinality::OptionalOne; +const NONE_IN: &[InputSocketContract] = &[]; +const NONE_OUT: &[OutputSocketContract] = &[]; +const SURFACE_OUT: &[OutputSocketContract] = + &[output("surface", SurfaceTarget, OutputMetadata::None)]; +const SPEC_OUT: &[OutputSocketContract] = &[output("spec", TextureSpec, OutputMetadata::None)]; +const SCENE_OUT: &[OutputSocketContract] = &[output("scene", SceneTable, OutputMetadata::None)]; +const AABB_OUT: &[OutputSocketContract] = + &[output("localAabbs", LocalAabbBuffer, OutputMetadata::None)]; +const FRUSTUM_OUT: &[OutputSocketContract] = + &[output("frustum", CameraFrustum, OutputMetadata::None)]; +const VISIBLE_OUT: &[OutputSocketContract] = &[output( + "flags", + BooleanFlagBuffer, + OutputMetadata::BooleanFlag { + flag: MeshFlag::IsVisible, + }, +)]; +const CULLED_OUT: &[OutputSocketContract] = &[output( + "flags", + BooleanFlagBuffer, + OutputMetadata::BooleanFlag { + flag: MeshFlag::IsFrustumCulled, + }, +)]; +const DRAW_OUT: &[OutputSocketContract] = &[output("draws", DrawStream, OutputMetadata::None)]; +const CONFIG_OUT: &[OutputSocketContract] = + &[output("config", DepthStencilConfig, OutputMetadata::None)]; +const FORWARD_OUT: &[OutputSocketContract] = &[ + output("color", Texture, OutputMetadata::None), + output("depth", Texture, OutputMetadata::None), +]; +const FULLSCREEN_COPY_OUT: &[OutputSocketContract] = + &[output("color", Texture, OutputMetadata::None)]; +const LOCAL_IN: &[InputSocketContract] = &[input( + "scene", + TypeConstraint::Exact(SceneTable), + REQUIRED, + InputRole::SemanticRead, +)]; +const VISIBILITY_IN: &[InputSocketContract] = LOCAL_IN; +const CULL_IN: &[InputSocketContract] = &[ + input( + "scene", + TypeConstraint::Exact(SceneTable), + REQUIRED, + InputRole::StorageRead, + ), + input( + "localAabbs", + TypeConstraint::Exact(LocalAabbBuffer), + REQUIRED, + InputRole::StorageRead, + ), + input( + "frustum", + TypeConstraint::Exact(CameraFrustum), + REQUIRED, + InputRole::UniformRead, + ), +]; +const QUERY_IN: &[InputSocketContract] = &[ + input( + "scene", + TypeConstraint::Exact(SceneTable), + REQUIRED, + InputRole::StorageRead, + ), + input( + "isVisible", + TypeConstraint::Exact(BooleanFlagBuffer), + OPTIONAL, + InputRole::StorageRead, + ), + input( + "isFrustumCulled", + TypeConstraint::Exact(BooleanFlagBuffer), + OPTIONAL, + InputRole::StorageRead, + ), +]; +const FORWARD_IN: &[InputSocketContract] = &[ + input( + "scene", + TypeConstraint::Exact(SceneTable), + REQUIRED, + InputRole::SemanticRead, + ), + input( + "draws", + TypeConstraint::Exact(DrawStream), + REQUIRED, + InputRole::IndirectRead, + ), + input( + "colorTarget", + TypeConstraint::OneOf(&[SurfaceTarget, TextureSpec, Texture]), + REQUIRED, + InputRole::ColorTarget { location: 0 }, + ), + input( + "depthTarget", + TypeConstraint::OneOf(&[TextureSpec, Texture]), + REQUIRED, + InputRole::DepthTarget, + ), + input( + "depthStencil", + TypeConstraint::Exact(DepthStencilConfig), + REQUIRED, + InputRole::Configuration, + ), +]; +const FULLSCREEN_COPY_IN: &[InputSocketContract] = &[ + input( + "source", + TypeConstraint::Exact(Texture), + REQUIRED, + InputRole::SampledTexture, + ), + input( + "colorTarget", + TypeConstraint::OneOf(&[SurfaceTarget, TextureSpec, Texture]), + REQUIRED, + InputRole::ColorTarget { location: 0 }, + ), +]; +const BLOOM_COMPOSITE_IN: &[InputSocketContract] = &[ + input( + "source", + TypeConstraint::Exact(Texture), + REQUIRED, + InputRole::SampledTexture, + ), + input( + "bloom", + TypeConstraint::Exact(Texture), + REQUIRED, + InputRole::SampledTexture, + ), + input( + "colorTarget", + TypeConstraint::OneOf(&[SurfaceTarget, TextureSpec, Texture]), + REQUIRED, + InputRole::ColorTarget { location: 0 }, + ), +]; +const PRESENT_IN: &[InputSocketContract] = &[input( + "surface", + TypeConstraint::Exact(Texture), + REQUIRED, + InputRole::Present, +)]; + +pub static CONTRACTS: &[Contract] = &[ + Contract { + key: "surface_target", + version: 1, + execution: ExecutionClass::Source, + inputs: NONE_IN, + outputs: SURFACE_OUT, + inherently_observable: false, + }, + Contract { + key: "texture_spec", + version: 1, + execution: ExecutionClass::Source, + inputs: NONE_IN, + outputs: SPEC_OUT, + inherently_observable: false, + }, + Contract { + key: "scene_table", + version: 1, + execution: ExecutionClass::Source, + inputs: NONE_IN, + outputs: SCENE_OUT, + inherently_observable: false, + }, + Contract { + key: "local_aabb_buffer", + version: 1, + execution: ExecutionClass::Source, + inputs: LOCAL_IN, + outputs: AABB_OUT, + inherently_observable: false, + }, + Contract { + key: "camera_frustum", + version: 1, + execution: ExecutionClass::Source, + inputs: NONE_IN, + outputs: FRUSTUM_OUT, + inherently_observable: false, + }, + Contract { + key: "visibility_flags", + version: 1, + execution: ExecutionClass::Source, + inputs: VISIBILITY_IN, + outputs: VISIBLE_OUT, + inherently_observable: false, + }, + Contract { + key: "frustum_cull", + version: 1, + execution: ExecutionClass::Compute, + inputs: CULL_IN, + outputs: CULLED_OUT, + inherently_observable: false, + }, + Contract { + key: "mesh_query", + version: 1, + execution: ExecutionClass::Compute, + inputs: QUERY_IN, + outputs: DRAW_OUT, + inherently_observable: false, + }, + Contract { + key: "depth_stencil_config", + version: 1, + execution: ExecutionClass::Source, + inputs: NONE_IN, + outputs: CONFIG_OUT, + inherently_observable: false, + }, + Contract { + key: "legacy_forward", + version: 1, + execution: ExecutionClass::Render, + inputs: FORWARD_IN, + outputs: FORWARD_OUT, + inherently_observable: false, + }, + Contract { + key: "fullscreen_copy", + version: 1, + execution: ExecutionClass::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + }, + Contract { + key: "tone_map", + version: 1, + execution: ExecutionClass::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + }, + Contract { + key: "bloom_extract", + version: 1, + execution: ExecutionClass::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + }, + Contract { + key: "bloom_blur", + version: 1, + execution: ExecutionClass::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + }, + Contract { + key: "bloom_composite", + version: 1, + execution: ExecutionClass::Render, + inputs: BLOOM_COMPOSITE_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + }, + Contract { + key: "luminance_edge", + version: 1, + execution: ExecutionClass::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + }, + Contract { + key: "present", + version: 1, + execution: ExecutionClass::Present, + inputs: PRESENT_IN, + outputs: NONE_OUT, + inherently_observable: true, + }, +]; + +pub fn contract(key: &str) -> Option<&'static Contract> { + CONTRACTS.iter().find(|contract| contract.key == key) +} diff --git a/renderer/src/render_graph/contracts_v2.rs b/renderer/src/render_graph/contracts_v2.rs deleted file mode 100644 index 7ca998d..0000000 --- a/renderer/src/render_graph/contracts_v2.rs +++ /dev/null @@ -1,417 +0,0 @@ -use super::MeshFlagV2; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -pub enum SemanticTypeV2 { - SurfaceTarget, - TextureSpec, - Texture, - SceneTable, - LocalAabbBuffer, - CameraFrustum, - BooleanFlagBuffer, - DrawStream, - DepthStencilConfig, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ExecutionClassV2 { - Source, - CpuPreparation, - Compute, - Render, - Present, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -pub enum InputCardinalityV2 { - RequiredOne, - OptionalOne, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(tag = "kind", content = "types", rename_all = "snake_case")] -pub enum TypeConstraintV2 { - Exact(SemanticTypeV2), - OneOf(&'static [SemanticTypeV2]), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum InputRoleV2 { - SemanticRead, - UniformRead, - StorageRead, - IndirectRead, - SampledTexture, - ColorTarget { location: u32 }, - DepthTarget, - Present, - Configuration, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum OutputMetadataV2 { - None, - BooleanFlag { flag: MeshFlagV2 }, -} - -#[derive(Clone, Copy, Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct InputSocketContractV2 { - pub name: &'static str, - pub accepted: TypeConstraintV2, - pub cardinality: InputCardinalityV2, - pub role: InputRoleV2, -} - -#[derive(Clone, Copy, Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct OutputSocketContractV2 { - pub name: &'static str, - pub semantic_type: SemanticTypeV2, - pub metadata: OutputMetadataV2, -} - -#[derive(Clone, Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ContractV2 { - pub key: &'static str, - pub version: u32, - pub execution: ExecutionClassV2, - pub inputs: &'static [InputSocketContractV2], - pub outputs: &'static [OutputSocketContractV2], - pub inherently_observable: bool, -} - -use SemanticTypeV2::*; - -const fn input( - name: &'static str, - accepted: TypeConstraintV2, - cardinality: InputCardinalityV2, - role: InputRoleV2, -) -> InputSocketContractV2 { - InputSocketContractV2 { - name, - accepted, - cardinality, - role, - } -} - -const fn output( - name: &'static str, - semantic_type: SemanticTypeV2, - metadata: OutputMetadataV2, -) -> OutputSocketContractV2 { - OutputSocketContractV2 { - name, - semantic_type, - metadata, - } -} - -const REQUIRED: InputCardinalityV2 = InputCardinalityV2::RequiredOne; -const OPTIONAL: InputCardinalityV2 = InputCardinalityV2::OptionalOne; -const NONE_IN: &[InputSocketContractV2] = &[]; -const NONE_OUT: &[OutputSocketContractV2] = &[]; -const SURFACE_OUT: &[OutputSocketContractV2] = - &[output("surface", SurfaceTarget, OutputMetadataV2::None)]; -const SPEC_OUT: &[OutputSocketContractV2] = &[output("spec", TextureSpec, OutputMetadataV2::None)]; -const SCENE_OUT: &[OutputSocketContractV2] = &[output("scene", SceneTable, OutputMetadataV2::None)]; -const AABB_OUT: &[OutputSocketContractV2] = &[output( - "localAabbs", - LocalAabbBuffer, - OutputMetadataV2::None, -)]; -const FRUSTUM_OUT: &[OutputSocketContractV2] = - &[output("frustum", CameraFrustum, OutputMetadataV2::None)]; -const VISIBLE_OUT: &[OutputSocketContractV2] = &[output( - "flags", - BooleanFlagBuffer, - OutputMetadataV2::BooleanFlag { - flag: MeshFlagV2::IsVisible, - }, -)]; -const CULLED_OUT: &[OutputSocketContractV2] = &[output( - "flags", - BooleanFlagBuffer, - OutputMetadataV2::BooleanFlag { - flag: MeshFlagV2::IsFrustumCulled, - }, -)]; -const DRAW_OUT: &[OutputSocketContractV2] = &[output("draws", DrawStream, OutputMetadataV2::None)]; -const CONFIG_OUT: &[OutputSocketContractV2] = - &[output("config", DepthStencilConfig, OutputMetadataV2::None)]; -const FORWARD_OUT: &[OutputSocketContractV2] = &[ - output("color", Texture, OutputMetadataV2::None), - output("depth", Texture, OutputMetadataV2::None), -]; -const FULLSCREEN_COPY_OUT: &[OutputSocketContractV2] = - &[output("color", Texture, OutputMetadataV2::None)]; -const LOCAL_IN: &[InputSocketContractV2] = &[input( - "scene", - TypeConstraintV2::Exact(SceneTable), - REQUIRED, - InputRoleV2::SemanticRead, -)]; -const VISIBILITY_IN: &[InputSocketContractV2] = LOCAL_IN; -const CULL_IN: &[InputSocketContractV2] = &[ - input( - "scene", - TypeConstraintV2::Exact(SceneTable), - REQUIRED, - InputRoleV2::StorageRead, - ), - input( - "localAabbs", - TypeConstraintV2::Exact(LocalAabbBuffer), - REQUIRED, - InputRoleV2::StorageRead, - ), - input( - "frustum", - TypeConstraintV2::Exact(CameraFrustum), - REQUIRED, - InputRoleV2::UniformRead, - ), -]; -const QUERY_IN: &[InputSocketContractV2] = &[ - input( - "scene", - TypeConstraintV2::Exact(SceneTable), - REQUIRED, - InputRoleV2::StorageRead, - ), - input( - "isVisible", - TypeConstraintV2::Exact(BooleanFlagBuffer), - OPTIONAL, - InputRoleV2::StorageRead, - ), - input( - "isFrustumCulled", - TypeConstraintV2::Exact(BooleanFlagBuffer), - OPTIONAL, - InputRoleV2::StorageRead, - ), -]; -const FORWARD_IN: &[InputSocketContractV2] = &[ - input( - "scene", - TypeConstraintV2::Exact(SceneTable), - REQUIRED, - InputRoleV2::SemanticRead, - ), - input( - "draws", - TypeConstraintV2::Exact(DrawStream), - REQUIRED, - InputRoleV2::IndirectRead, - ), - input( - "colorTarget", - TypeConstraintV2::OneOf(&[SurfaceTarget, TextureSpec, Texture]), - REQUIRED, - InputRoleV2::ColorTarget { location: 0 }, - ), - input( - "depthTarget", - TypeConstraintV2::OneOf(&[TextureSpec, Texture]), - REQUIRED, - InputRoleV2::DepthTarget, - ), - input( - "depthStencil", - TypeConstraintV2::Exact(DepthStencilConfig), - REQUIRED, - InputRoleV2::Configuration, - ), -]; -const FULLSCREEN_COPY_IN: &[InputSocketContractV2] = &[ - input( - "source", - TypeConstraintV2::Exact(Texture), - REQUIRED, - InputRoleV2::SampledTexture, - ), - input( - "colorTarget", - TypeConstraintV2::OneOf(&[SurfaceTarget, TextureSpec, Texture]), - REQUIRED, - InputRoleV2::ColorTarget { location: 0 }, - ), -]; -const BLOOM_COMPOSITE_IN: &[InputSocketContractV2] = &[ - input( - "source", - TypeConstraintV2::Exact(Texture), - REQUIRED, - InputRoleV2::SampledTexture, - ), - input( - "bloom", - TypeConstraintV2::Exact(Texture), - REQUIRED, - InputRoleV2::SampledTexture, - ), - input( - "colorTarget", - TypeConstraintV2::OneOf(&[SurfaceTarget, TextureSpec, Texture]), - REQUIRED, - InputRoleV2::ColorTarget { location: 0 }, - ), -]; -const PRESENT_IN: &[InputSocketContractV2] = &[input( - "surface", - TypeConstraintV2::Exact(Texture), - REQUIRED, - InputRoleV2::Present, -)]; - -pub static CONTRACTS_V2: &[ContractV2] = &[ - ContractV2 { - key: "surface_target", - version: 1, - execution: ExecutionClassV2::Source, - inputs: NONE_IN, - outputs: SURFACE_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "texture_spec", - version: 1, - execution: ExecutionClassV2::Source, - inputs: NONE_IN, - outputs: SPEC_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "scene_table", - version: 1, - execution: ExecutionClassV2::Source, - inputs: NONE_IN, - outputs: SCENE_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "local_aabb_buffer", - version: 1, - execution: ExecutionClassV2::Source, - inputs: LOCAL_IN, - outputs: AABB_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "camera_frustum", - version: 1, - execution: ExecutionClassV2::Source, - inputs: NONE_IN, - outputs: FRUSTUM_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "visibility_flags", - version: 1, - execution: ExecutionClassV2::Source, - inputs: VISIBILITY_IN, - outputs: VISIBLE_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "frustum_cull", - version: 1, - execution: ExecutionClassV2::Compute, - inputs: CULL_IN, - outputs: CULLED_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "mesh_query", - version: 1, - execution: ExecutionClassV2::Compute, - inputs: QUERY_IN, - outputs: DRAW_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "depth_stencil_config", - version: 1, - execution: ExecutionClassV2::Source, - inputs: NONE_IN, - outputs: CONFIG_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "legacy_forward", - version: 1, - execution: ExecutionClassV2::Render, - inputs: FORWARD_IN, - outputs: FORWARD_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "fullscreen_copy", - version: 1, - execution: ExecutionClassV2::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "tone_map", - version: 1, - execution: ExecutionClassV2::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "bloom_extract", - version: 1, - execution: ExecutionClassV2::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "bloom_blur", - version: 1, - execution: ExecutionClassV2::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "bloom_composite", - version: 1, - execution: ExecutionClassV2::Render, - inputs: BLOOM_COMPOSITE_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "luminance_edge", - version: 1, - execution: ExecutionClassV2::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - }, - ContractV2 { - key: "present", - version: 1, - execution: ExecutionClassV2::Present, - inputs: PRESENT_IN, - outputs: NONE_OUT, - inherently_observable: true, - }, -]; - -pub fn contract(key: &str) -> Option<&'static ContractV2> { - CONTRACTS_V2.iter().find(|contract| contract.key == key) -} diff --git a/renderer/src/render_graph/mod.rs b/renderer/src/render_graph/mod.rs index 9e15f85..1add0d8 100644 --- a/renderer/src/render_graph/mod.rs +++ b/renderer/src/render_graph/mod.rs @@ -1,59 +1,21 @@ -//! Device-free V1 render graph compiler and compiled graph registry. +//! Device-free render graph compiler and compiled graph registry. mod compiler; -mod compiler_v2; -mod contracts_v2; -mod plan_v2; +mod contracts; +mod plan; mod registry; mod runtime; -mod runtime_v2; mod schema; -mod schema_v2; -pub use compiler::{ - compile, compile_with, parse_and_compile, AllocationClass, CompiledGraph, CompiledOutput, - CompiledPass, CompiledRead, CompiledResource, CompiledWrite, ExecutorContract, - ExecutorRegistry, ExecutorResolution, Lifetime, NormalizedParameters, SceneForwardExecutors, - TextureAllocationKey, TextureUsage, TransientAllocation, -}; -pub use compiler_v2::{compile_v2, mesh_predicate_matches, parse_and_compile_v2}; -pub use contracts_v2::*; -pub use plan_v2::*; -pub use registry::{CompiledGraphId, RegisteredGraph, Registry}; -pub use runtime::{ - class_offsets, resolve_extent, runtime_texture_key, validate_activatable, ResolvedExtent, - RuntimeTextureKey, -}; -pub use runtime_v2::*; +pub use compiler::{compile, mesh_predicate_matches, parse_and_compile}; +pub use contracts::*; +pub use plan::*; +pub use registry::{CompiledGraphId, Registry}; +pub use runtime::*; pub use schema::*; -pub use schema_v2::*; - -pub fn parse_and_compile_any(bytes: &[u8]) -> Result { - if bytes.len() > MAX_JSON_BYTES { - return Err(GraphError::new( - "GRAPH_PAYLOAD_TOO_LARGE", - "graph payload exceeds 1 MiB", - )); - } - let text = std::str::from_utf8(bytes) - .map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph payload is not UTF-8"))?; - let value: serde_json::Value = serde_json::from_str(text) - .map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?; - match value.get("schemaVersion").and_then(|v| v.as_u64()) { - Some(1) => parse_and_compile(bytes).map(RegisteredGraph::V1), - Some(2) => parse_and_compile_v2(bytes).map(RegisteredGraph::V2), - _ => Err(GraphError::new( - "GRAPH_SCHEMA_UNSUPPORTED", - "schemaVersion must be exactly 1 or 2", - )), - } -} pub const MAX_JSON_BYTES: usize = 1024 * 1024; -pub const MAX_RESOURCES: usize = 1024; -pub const MAX_PASSES: usize = 1024; -pub const MAX_USES: usize = 8192; -pub const MAX_OUTPUTS: usize = 64; +pub const MAX_EXECUTIONS: usize = 1024; #[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] pub struct GraphError { @@ -71,6 +33,7 @@ impl GraphError { message, } } + pub(crate) fn at( code: &'static str, message: impl Into, @@ -87,5 +50,3 @@ impl GraphError { #[cfg(test)] mod tests; -#[cfg(test)] -mod tests_v2; diff --git a/renderer/src/render_graph/plan_v2.rs b/renderer/src/render_graph/plan.rs similarity index 66% rename from renderer/src/render_graph/plan_v2.rs rename to renderer/src/render_graph/plan.rs index 189bc7d..2bfe2c0 100644 --- a/renderer/src/render_graph/plan_v2.rs +++ b/renderer/src/render_graph/plan.rs @@ -4,15 +4,15 @@ use super::*; #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct CompiledGraphV2 { +pub struct CompiledGraph { pub schema_version: u32, pub graph_id: String, pub revision: u32, pub node_count: u32, - pub resources: Vec, - pub executions: Vec, - pub texture_families: Vec, - pub allocation_classes: Vec, + pub resources: Vec, + pub executions: Vec, + pub texture_families: Vec, + pub allocation_classes: Vec, pub culled_node_count: u32, pub culled_resource_count: u32, pub transient_slot_count: u32, @@ -20,26 +20,26 @@ pub struct CompiledGraphV2 { #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct CompiledResourceV2 { +pub struct CompiledResource { pub original_node_index: u32, pub output_ordinal: u16, pub origin: NodeOutputRef, - pub semantic_type: SemanticTypeV2, + pub semantic_type: SemanticType, pub producer_execution: Option, - pub lifetime: Option, - pub plan: ResourcePlanV2, + pub lifetime: Option, + pub plan: ResourcePlan, } #[derive(Clone, Debug, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] -pub enum ResourcePlanV2 { +pub enum ResourcePlan { SurfaceTarget { family: u32, }, TextureSpec { family: u32, - residency: TextureResidencyV2, - descriptor: NormalizedTextureDescriptorV2, + residency: TextureResidency, + descriptor: NormalizedTextureDescriptor, }, Texture { family: u32, @@ -47,7 +47,7 @@ pub enum ResourcePlanV2 { target: u32, initialized: bool, stored: bool, - allocation: Option, + allocation: Option, }, SceneTable, LocalAabbBuffer { @@ -56,53 +56,53 @@ pub enum ResourcePlanV2 { CameraFrustum, BooleanFlagBuffer { scene: u32, - flag: MeshFlagV2, + flag: MeshFlag, }, DrawStream { scene: u32, }, DepthStencilConfig { - config: NormalizedDepthStencilV2, + config: NormalizedDepthStencil, }, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct CompiledExecutionV2 { +pub struct CompiledExecution { pub id: String, pub original_node_index: u32, - pub executor: ExecutorRefV2, - pub parameters: NormalizedParametersV2, - pub kind: ExecutionKindV2, - pub inputs: Vec, - pub outputs: Vec, - pub accesses: Vec, + pub executor: ExecutorRef, + pub parameters: NormalizedParameters, + pub kind: ExecutionKind, + pub inputs: Vec, + pub outputs: Vec, + pub accesses: Vec, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct CompiledSocketInputV2 { +pub struct CompiledSocketInput { pub socket: String, pub resource: u32, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct CompiledSocketOutputV2 { +pub struct CompiledSocketOutput { pub socket: String, pub resource: u32, } #[derive(Clone, Debug, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] -pub enum ExecutionKindV2 { +pub enum ExecutionKind { CpuPreparation, Compute { - work: ComputeWorkV2, + work: ComputeWork, }, Render { - color_attachments: Vec, - depth_stencil: Option, + color_attachments: Vec, + depth_stencil: Option, }, Present { surface: u32, @@ -111,60 +111,60 @@ pub enum ExecutionKindV2 { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] -pub enum ComputeWorkV2 { +pub enum ComputeWork { FrustumCull, MeshQuery, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct ColorAttachmentPlanV2 { +pub struct ColorAttachmentPlan { pub resource: u32, pub location: u32, - pub load: NormalizedColorLoadV2, - pub store: StoreOpV2, + pub load: NormalizedColorLoad, + pub store: StoreOp, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct DepthStencilAttachmentPlanV2 { +pub struct DepthStencilAttachmentPlan { pub resource: u32, - pub load: NormalizedDepthLoadV2, - pub store: StoreOpV2, + pub load: NormalizedDepthLoad, + pub store: StoreOp, } #[derive(Clone, Copy, Debug, PartialEq, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] -pub enum NormalizedColorLoadV2 { +pub enum NormalizedColorLoad { Load, Clear { value: [f64; 4] }, } #[derive(Clone, Copy, Debug, PartialEq, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] -pub enum NormalizedDepthLoadV2 { +pub enum NormalizedDepthLoad { Load, Clear { value: f32 }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] -pub enum StoreOpV2 { +pub enum StoreOp { Store, Discard, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct CompiledAccessV2 { +pub struct CompiledAccess { pub socket: String, pub resource: u32, - pub mode: AccessModeV2, + pub mode: AccessMode, } #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] -pub enum AccessModeV2 { +pub enum AccessMode { SemanticRead, UniformRead, StorageRead, @@ -175,13 +175,13 @@ pub enum AccessModeV2 { SampledTexture, ColorAttachment { location: u32, - load: NormalizedColorLoadV2, - store: StoreOpV2, + load: NormalizedColorLoad, + store: StoreOp, full_overwrite: bool, }, DepthAttachment { - load: NormalizedDepthLoadV2, - store: StoreOpV2, + load: NormalizedDepthLoad, + store: StoreOp, full_overwrite: bool, }, Present, @@ -189,11 +189,11 @@ pub enum AccessModeV2 { #[derive(Clone, Debug, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] -pub enum NormalizedParametersV2 { +pub enum NormalizedParameters { SurfaceTarget, TextureSpec { - residency: TextureResidencyV2, - texture: NormalizedTextureDescriptorV2, + residency: TextureResidency, + texture: NormalizedTextureDescriptor, }, SceneTable, LocalAabbBuffer, @@ -201,10 +201,10 @@ pub enum NormalizedParametersV2 { VisibilityFlags, FrustumCull, MeshQuery { - filters: [NormalizedMeshFilterV2; 2], + filters: [NormalizedMeshFilter; 2], }, DepthStencilConfig { - config: NormalizedDepthStencilV2, + config: NormalizedDepthStencil, }, LegacyForward { clear_color: [f64; 4], @@ -232,117 +232,117 @@ pub enum NormalizedParametersV2 { #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[serde(rename_all = "camelCase")] -pub struct NormalizedMeshFilterV2 { - pub flag: MeshFlagV2, +pub struct NormalizedMeshFilter { + pub flag: MeshFlag, pub predicate: TriStatePredicate, } #[derive(Clone, Copy, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub struct NormalizedDepthStencilV2 { - pub depth_compare: CompareFunctionV2, +pub struct NormalizedDepthStencil { + pub depth_compare: CompareFunction, pub depth_write_enabled: bool, pub clear_depth: f32, } #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] #[serde(rename_all = "camelCase")] -pub struct NormalizedTextureDescriptorV2 { - pub dimension: TextureDimensionV2, - pub format: TextureFormatV2, - pub extent: NormalizedTextureExtentV2, +pub struct NormalizedTextureDescriptor { + pub dimension: TextureDimension, + pub format: TextureFormat, + pub extent: NormalizedTextureExtent, pub mip_level_count: u32, pub sample_count: u32, - pub view_formats: Vec, + pub view_formats: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] -pub enum NormalizedTextureExtentV2 { +pub enum NormalizedTextureExtent { Absolute { width: u32, height: u32, depth_or_array_layers: u32, }, SurfaceRelative { - width: RatioV2, - height: RatioV2, + width: Ratio, + height: Ratio, depth_or_array_layers: u32, }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] -pub struct LifetimeV2 { +pub struct Lifetime { pub first_use: u32, pub last_use: u32, } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[serde(rename_all = "camelCase")] -pub struct TextureFamilyKeyV2 { +pub struct TextureFamilyKey { pub source_node: u32, pub source_socket: u16, } #[derive(Clone, Debug, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] -pub enum TextureFamilySourceV2 { +pub enum TextureFamilySource { ImportedSurface { resource: u32, }, AuthoredTexture { resource: u32, - residency: TextureResidencyV2, - descriptor: NormalizedTextureDescriptorV2, + residency: TextureResidency, + descriptor: NormalizedTextureDescriptor, }, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct TextureFamilyV2 { +pub struct TextureFamily { pub id: u32, - pub key: TextureFamilyKeyV2, - pub source: TextureFamilySourceV2, - pub lifetime: LifetimeV2, - pub versions: Vec, - pub usage: Vec, - pub allocation: Option, + pub key: TextureFamilyKey, + pub source: TextureFamilySource, + pub lifetime: Lifetime, + pub versions: Vec, + pub usage: Vec, + pub allocation: Option, pub aliasable: bool, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct TextureVersionV2 { +pub struct TextureVersion { pub version: u32, pub resource: u32, pub target: u32, pub initialized: bool, pub stored: bool, - pub lifetime: LifetimeV2, + pub lifetime: Lifetime, } #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] #[serde(rename_all = "camelCase")] -pub struct TextureCompatibilityKeyV2 { - pub dimension: TextureDimensionV2, - pub format: TextureFormatV2, - pub extent: NormalizedTextureExtentV2, +pub struct TextureCompatibilityKey { + pub dimension: TextureDimension, + pub format: TextureFormat, + pub extent: NormalizedTextureExtent, pub mip_level_count: u32, pub sample_count: u32, - pub view_formats: Vec, + pub view_formats: Vec, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AllocationClassV2 { - pub key: TextureCompatibilityKeyV2, - pub slots: Vec, +pub struct AllocationClass { + pub key: TextureCompatibilityKey, + pub slots: Vec, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] -pub enum AllocationKindV2 { +pub enum AllocationKind { AliasedTransient, DedicatedTransient, Persistent, @@ -350,22 +350,22 @@ pub enum AllocationKindV2 { #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AllocationSlotV2 { - pub kind: AllocationKindV2, - pub usage: Vec, +pub struct AllocationSlot { + pub kind: AllocationKind, + pub usage: Vec, pub occupants: Vec, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AllocationRefV2 { +pub struct AllocationRef { pub class: u32, pub slot: u32, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] #[serde(rename_all = "snake_case")] -pub enum TextureUsageV2 { +pub enum TextureUsage { Sampled, Storage, CopySrc, @@ -374,7 +374,7 @@ pub enum TextureUsageV2 { DepthAttachment, } -impl CompiledGraphV2 { +impl CompiledGraph { pub fn summary(&self, id: [u32; 2]) -> serde_json::Value { serde_json::json!({"compiledId":id,"graphId":self.graph_id,"revision":self.revision,"schemaVersion":self.schema_version,"nodeCount":self.node_count,"executionCount":self.executions.len(),"resourceCount":self.resources.len(),"culledNodeCount":self.culled_node_count,"culledResourceCount":self.culled_resource_count,"transientSlotCount":self.transient_slot_count}) } diff --git a/renderer/src/render_graph/registry.rs b/renderer/src/render_graph/registry.rs index 7e61eb2..295b1fe 100644 --- a/renderer/src/render_graph/registry.rs +++ b/renderer/src/render_graph/registry.rs @@ -1,51 +1,39 @@ -use super::{parse_and_compile_any, CompiledGraph, CompiledGraphV2, GraphError}; use std::collections::HashMap; -#[derive(Debug, Clone)] -pub enum RegisteredGraph { - V1(CompiledGraph), - V2(CompiledGraphV2), -} -impl RegisteredGraph { - fn identity(&self) -> (&str, u32) { - match self { - Self::V1(g) => (g.graph_id.as_str(), g.revision), - Self::V2(g) => (g.graph_id.as_str(), g.revision), - } - } - fn summary(&self, id: [u32; 2]) -> serde_json::Value { - match self { - Self::V1(g) => g.summary(id), - Self::V2(g) => g.summary(id), - } - } -} + +use super::{parse_and_compile, CompiledGraph, GraphError}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CompiledGraphId { pub slot: u32, pub generation: u32, } + impl From for [u32; 2] { - fn from(x: CompiledGraphId) -> Self { - [x.slot, x.generation] + fn from(id: CompiledGraphId) -> Self { + [id.slot, id.generation] } } + #[derive(Debug)] struct Slot { generation: u32, - value: Option, + value: Option, retired: bool, } + #[derive(Debug)] pub struct Registry { slots: Vec, capacity: u32, latest_revisions: HashMap, } + impl Default for Registry { fn default() -> Self { Self::new(16) } } + impl Registry { pub fn new(capacity: u32) -> Self { Self { @@ -54,28 +42,28 @@ impl Registry { latest_revisions: HashMap::new(), } } + pub fn compile( &mut self, bytes: &[u8], ) -> Result<(CompiledGraphId, serde_json::Value), GraphError> { - let graph = parse_and_compile_any(bytes)?; - let (graph_id, revision) = graph.identity(); + let graph = parse_and_compile(bytes)?; if self .latest_revisions - .get(graph_id) - .is_some_and(|latest| revision <= *latest) + .get(&graph.graph_id) + .is_some_and(|latest| graph.revision <= *latest) { return Err(GraphError::new( "GRAPH_REVISION_CONFLICT", "revision must increase", )); } - let i = if let Some(i) = self + let index = if let Some(index) = self .slots .iter() - .position(|s| s.value.is_none() && !s.retired) + .position(|slot| slot.value.is_none() && !slot.retired) { - i + index } else { if u32::try_from(self.slots.len()).map_or(true, |len| len >= self.capacity) { return Err(GraphError::new( @@ -91,51 +79,40 @@ impl Registry { self.slots.len() - 1 }; let id = CompiledGraphId { - slot: u32::try_from(i) + slot: u32::try_from(index) .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "registry slot overflow"))?, - generation: self.slots[i].generation, + generation: self.slots[index].generation, }; let summary = graph.summary(id.into()); - self.latest_revisions.insert(graph_id.to_owned(), revision); - self.slots[i].value = Some(graph); + self.latest_revisions + .insert(graph.graph_id.clone(), graph.revision); + self.slots[index].value = Some(graph); Ok((id, summary)) } + pub fn get(&self, id: CompiledGraphId) -> Result<&CompiledGraph, GraphError> { - match self - .slots - .get(id.slot as usize) - .filter(|s| s.generation == id.generation) - .and_then(|s| s.value.as_ref()) - .ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))? - { - RegisteredGraph::V1(g) => Ok(g), - RegisteredGraph::V2(_) => Err(GraphError::new( - "GRAPH_EXECUTION_UNSUPPORTED", - "schemaVersion 2 activation is unavailable until Phase 4", - )), - } - } - pub fn get_registered(&self, id: CompiledGraphId) -> Result<&RegisteredGraph, GraphError> { self.slots .get(id.slot as usize) - .filter(|s| s.generation == id.generation) - .and_then(|s| s.value.as_ref()) + .filter(|slot| slot.generation == id.generation) + .and_then(|slot| slot.value.as_ref()) .ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id")) } + pub fn contains(&self, id: CompiledGraphId) -> bool { - self.get_registered(id).is_ok() + self.get(id).is_ok() } + pub fn drop_graph(&mut self, id: CompiledGraphId) -> Result<(), GraphError> { - let s = self + let slot = self .slots .get_mut(id.slot as usize) - .filter(|s| s.generation == id.generation && s.value.is_some()) + .filter(|slot| slot.generation == id.generation && slot.value.is_some()) .ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))?; - s.value = None; - if s.generation == u32::MAX { - s.retired = true + slot.value = None; + if slot.generation == u32::MAX { + slot.retired = true } else { - s.generation += 1 + slot.generation += 1 } Ok(()) } diff --git a/renderer/src/render_graph/runtime.rs b/renderer/src/render_graph/runtime.rs index 8183679..936c162 100644 --- a/renderer/src/render_graph/runtime.rs +++ b/renderer/src/render_graph/runtime.rs @@ -1,215 +1,604 @@ -use std::collections::BTreeMap; +use super::*; -use super::{ - CompiledGraph, Dimension, Extent, ExternalSource, Format, GraphError, Residency, - TextureAllocationKey, TextureUsage, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ResolvedExtent { pub width: u32, pub height: u32, pub depth_or_array_layers: u32, } -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct RuntimeTextureKey { - pub dimension: Dimension, - pub format: Format, +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeTextureDescriptor { + pub dimension: wgpu::TextureDimension, + pub format: wgpu::TextureFormat, pub extent: ResolvedExtent, pub mip_level_count: u32, pub sample_count: u32, - pub usage: Vec, - pub view_formats: Vec, + pub usage: wgpu::TextureUsages, + pub view_formats: Vec, } -fn scaled(value: u32, numerator: u32, denominator: u32) -> Result { - if denominator == 0 { - return Err(GraphError::new( - "GRAPH_EXECUTION_UNSUPPORTED", +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeSurfaceContract { + pub format: wgpu::TextureFormat, + pub width: u32, + pub height: u32, + pub usage: wgpu::TextureUsages, + pub view_formats: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeAllocationSlot { + pub kind: AllocationKind, + pub descriptor: RuntimeTextureDescriptor, + pub occupants: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeAllocationClass { + pub key: TextureCompatibilityKey, + pub slots: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MeshQueryRuntimeKey { + pub visible: TriStatePredicate, + pub frustum_culled: TriStatePredicate, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeExecution { + pub execution: u32, + pub executor: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeAllocationPlan { + pub classes: Vec, + pub resource_allocations: Vec>, + pub surface_family: u32, + pub surface_resource: u32, + pub query: MeshQueryRuntimeKey, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimePlan { + pub allocations: RuntimeAllocationPlan, + pub executions: Vec, + pub surface: RuntimeSurfaceContract, +} + +fn error(code: &'static str, message: impl Into, path: impl Into) -> GraphError { + GraphError::at(code, message, path) +} + +pub const fn texture_dimension(value: TextureDimension) -> wgpu::TextureDimension { + match value { + TextureDimension::D1 => wgpu::TextureDimension::D1, + TextureDimension::D2 => wgpu::TextureDimension::D2, + TextureDimension::D3 => wgpu::TextureDimension::D3, + } +} + +pub const fn texture_format(value: TextureFormat) -> wgpu::TextureFormat { + match value { + TextureFormat::Rgba8Unorm => wgpu::TextureFormat::Rgba8Unorm, + TextureFormat::Rgba8UnormSrgb => wgpu::TextureFormat::Rgba8UnormSrgb, + TextureFormat::Bgra8Unorm => wgpu::TextureFormat::Bgra8Unorm, + TextureFormat::Bgra8UnormSrgb => wgpu::TextureFormat::Bgra8UnormSrgb, + TextureFormat::Rgba16Float => wgpu::TextureFormat::Rgba16Float, + TextureFormat::R32Float => wgpu::TextureFormat::R32Float, + TextureFormat::Depth32Float => wgpu::TextureFormat::Depth32Float, + } +} + +pub const fn texture_usage(value: TextureUsage) -> wgpu::TextureUsages { + match value { + TextureUsage::Sampled => wgpu::TextureUsages::TEXTURE_BINDING, + TextureUsage::Storage => wgpu::TextureUsages::STORAGE_BINDING, + TextureUsage::CopySrc => wgpu::TextureUsages::COPY_SRC, + TextureUsage::CopyDst => wgpu::TextureUsages::COPY_DST, + TextureUsage::ColorAttachment | TextureUsage::DepthAttachment => { + wgpu::TextureUsages::RENDER_ATTACHMENT + } + } +} + +pub fn texture_usages(values: &[TextureUsage]) -> wgpu::TextureUsages { + values + .iter() + .fold(wgpu::TextureUsages::empty(), |usage, value| { + usage | texture_usage(*value) + }) +} + +fn scaled(value: u32, ratio: Ratio, path: &str) -> Result { + if ratio.denominator == 0 { + return Err(error( + "GRAPH_RESOURCE_LIMIT", "zero extent denominator", + path, )); } let product = u64::from(value) - .checked_mul(u64::from(numerator)) - .ok_or_else(|| GraphError::new("GRAPH_EXECUTION_UNSUPPORTED", "extent overflow"))?; + .checked_mul(u64::from(ratio.numerator)) + .ok_or_else(|| error("GRAPH_RESOURCE_LIMIT", "extent arithmetic overflow", path))?; let result = product - .checked_add(u64::from(denominator) - 1) - .ok_or_else(|| GraphError::new("GRAPH_EXECUTION_UNSUPPORTED", "extent overflow"))? - / u64::from(denominator); + .checked_add(u64::from(ratio.denominator) - 1) + .ok_or_else(|| error("GRAPH_RESOURCE_LIMIT", "extent arithmetic overflow", path))? + / u64::from(ratio.denominator); u32::try_from(result.max(1)) - .map_err(|_| GraphError::new("GRAPH_EXECUTION_UNSUPPORTED", "extent overflow")) + .map_err(|_| error("GRAPH_RESOURCE_LIMIT", "extent exceeds u32", path)) } -pub fn resolve_extent(extent: &Extent, surface: [u32; 2]) -> Result { - let (width, height, depth_or_array_layers) = match extent { - Extent::Absolute { +pub fn resolve_extent( + extent: &NormalizedTextureExtent, + surface: [u32; 2], +) -> Result { + let resolved = match extent { + NormalizedTextureExtent::Absolute { width, height, depth_or_array_layers, - } => (*width, *height, *depth_or_array_layers), - Extent::SurfaceRelative { + } => ResolvedExtent { + width: *width, + height: *height, + depth_or_array_layers: *depth_or_array_layers, + }, + NormalizedTextureExtent::SurfaceRelative { width, height, depth_or_array_layers, - } => ( - scaled(surface[0], width.numerator, width.denominator)?, - scaled(surface[1], height.numerator, height.denominator)?, - *depth_or_array_layers, - ), + } => ResolvedExtent { + width: scaled(surface[0], *width, "extent.width")?, + height: scaled(surface[1], *height, "extent.height")?, + depth_or_array_layers: *depth_or_array_layers, + }, }; - if width == 0 || height == 0 || depth_or_array_layers == 0 { - return Err(GraphError::new( - "GRAPH_EXECUTION_UNSUPPORTED", - "texture extent must be nonzero", + if resolved.width == 0 || resolved.height == 0 || resolved.depth_or_array_layers == 0 { + return Err(error( + "GRAPH_RESOURCE_LIMIT", + "texture extent is zero", + "extent", )); } - Ok(ResolvedExtent { - width, - height, - depth_or_array_layers, - }) + Ok(resolved) } -pub fn runtime_texture_key( - key: &TextureAllocationKey, - surface: [u32; 2], -) -> Result { - Ok(RuntimeTextureKey { - dimension: key.descriptor.dimension, - format: key.descriptor.format, - extent: resolve_extent(&key.descriptor.extent, surface)?, - mip_level_count: key.descriptor.mip_level_count, - sample_count: key.descriptor.sample_count, - usage: key.usage.clone(), - view_formats: key.view_formats.clone(), - }) +pub fn resolved_mip_level_count(extent: ResolvedExtent) -> u32 { + 32 - extent + .width + .max(extent.height) + .max(extent.depth_or_array_layers) + .leading_zeros() } -/// Assigns disjoint physical ranges after merging symbolic allocation classes that -/// resolve to the same concrete descriptor key. -pub fn class_offsets( - classes: &[(TextureAllocationKey, u32)], - surface: [u32; 2], -) -> Result, GraphError> { - let mut next = BTreeMap::new(); - let mut offsets = Vec::with_capacity(classes.len()); - for (key, count) in classes { - let concrete = runtime_texture_key(key, surface)?; - let offset = next.entry(concrete).or_insert(0u32); - offsets.push(*offset); - *offset = offset.checked_add(*count).ok_or_else(|| { - GraphError::new("GRAPH_EXECUTION_UNSUPPORTED", "transient slot overflow") - })?; +fn validate_limits( + dimension: TextureDimension, + extent: ResolvedExtent, + mip_count: u32, + limits: Option<&wgpu::Limits>, + path: &str, +) -> Result<(), GraphError> { + let max_mips = resolved_mip_level_count(extent); + if mip_count == 0 || mip_count > max_mips { + return Err(error( + "GRAPH_RESOURCE_LIMIT", + "invalid mip level count", + path, + )); } - Ok(offsets) -} - -pub fn validate_activatable(graph: &CompiledGraph) -> Result<(), GraphError> { - let unsupported = || { - GraphError::new( - "GRAPH_EXECUTION_UNSUPPORTED", - "graph is outside the activatable Phase 6 subset", - ) - }; - if graph.passes.is_empty() || graph.outputs.is_empty() { - return Err(unsupported()); - } - let surface_outputs = graph - .outputs - .iter() - .filter(|o| { - matches!( - graph.resources[o.resource as usize].residency, - Residency::External { - source: ExternalSource::SurfaceColor - } - ) - }) - .count(); - if surface_outputs == 0 { - return Err(unsupported()); - } - for pass in &graph.passes { - if pass.executor.key != "scene_forward" - || pass.executor.version != 1 - || !pass.reads.is_empty() - { - return Err(unsupported()); - } - let color = pass - .writes - .iter() - .find(|w| w.binding == "color") - .ok_or_else(&unsupported)?; - let depth = pass - .writes - .iter() - .find(|w| w.binding == "depth") - .ok_or_else(&unsupported)?; - let c = &graph.resources[color.resource as usize]; - let d = &graph.resources[depth.resource as usize]; - if !matches!( - c.residency, - Residency::External { - source: ExternalSource::SurfaceColor + if let Some(l) = limits { + let valid = match dimension { + TextureDimension::D1 => extent.width <= l.max_texture_dimension_1d, + TextureDimension::D2 => { + extent.width <= l.max_texture_dimension_2d + && extent.height <= l.max_texture_dimension_2d + && extent.depth_or_array_layers <= l.max_texture_array_layers } - ) || !matches!(d.residency, Residency::Transient) - || d.descriptor.format != Format::Depth32Float - || d.descriptor.dimension != Dimension::D2 - || d.descriptor.mip_level_count != 1 - || d.descriptor.sample_count != 1 - || d.descriptor.extent != c.descriptor.extent - { - return Err(unsupported()); + TextureDimension::D3 => { + extent.width <= l.max_texture_dimension_3d + && extent.height <= l.max_texture_dimension_3d + && extent.depth_or_array_layers <= l.max_texture_dimension_3d + } + }; + if !valid { + return Err(error( + "GRAPH_RESOURCE_LIMIT", + "texture exceeds device limits", + path, + )); } } Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - use crate::render_graph::{Dimension, Ratio, TextureDescriptor, TextureUsage}; - fn key(n: u32, d: u32) -> TextureAllocationKey { - TextureAllocationKey { - descriptor: TextureDescriptor { - dimension: Dimension::D2, - format: Format::Depth32Float, - extent: Extent::SurfaceRelative { - width: Ratio { - numerator: n, - denominator: d, - }, - height: Ratio { - numerator: n, - denominator: d, - }, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - }, - usage: vec![TextureUsage::DepthAttachment], - view_formats: vec![], +pub fn runtime_texture_descriptor( + key: &TextureCompatibilityKey, + usage: &[TextureUsage], + surface: [u32; 2], + limits: Option<&wgpu::Limits>, +) -> Result { + let extent = resolve_extent(&key.extent, surface)?; + validate_limits( + key.dimension, + extent, + key.mip_level_count, + limits, + "allocationClasses.key", + )?; + Ok(RuntimeTextureDescriptor { + dimension: texture_dimension(key.dimension), + format: texture_format(key.format), + extent, + mip_level_count: key.mip_level_count, + sample_count: key.sample_count, + usage: texture_usages(usage), + view_formats: key + .view_formats + .iter() + .copied() + .map(texture_format) + .collect(), + }) +} + +fn invalid(message: impl Into, path: impl Into) -> GraphError { + error("GRAPH_RUNTIME_PLAN_INVALID", message, path) +} + +pub fn prepare_runtime_plan( + graph: &CompiledGraph, + surface: RuntimeSurfaceContract, + limits: Option<&wgpu::Limits>, +) -> Result { + if surface.width == 0 || surface.height == 0 { + return Err(error( + "GRAPH_SURFACE_INCOMPATIBLE", + "surface extent is zero", + "surface", + )); + } + if !surface + .usage + .contains(wgpu::TextureUsages::RENDER_ATTACHMENT) + { + return Err(error( + "GRAPH_SURFACE_INCOMPATIBLE", + "surface lacks render attachment usage", + "surface.usage", + )); + } + + let mut present_count = 0; + let mut query = None; + let mut executions = Vec::with_capacity(graph.executions.len()); + for (i, execution) in graph.executions.iter().enumerate() { + let path = format!("executions[{i}]"); + match execution.executor.key.as_str() { + "mesh_query" => { + let NormalizedParameters::MeshQuery { filters } = &execution.parameters else { + return Err(invalid("mesh query parameters mismatch", &path)); + }; + let key = MeshQueryRuntimeKey { + visible: filters[0].predicate, + frustum_culled: filters[1].predicate, + }; + if query.replace(key).is_some() { + return Err(error( + "GRAPH_EXECUTION_UNSUPPORTED", + "multiple draw stream queries", + &path, + )); + } + } + "legacy_forward" => {} + "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" + | "luminance_edge" => {} + "frustum_cull" => {} + "present" => present_count += 1, + _ => { + return Err(error( + "GRAPH_EXECUTION_UNSUPPORTED", + "unsupported execution", + &path, + )) + } + } + executions.push(RuntimeExecution { + execution: u32::try_from(i).map_err(|_| invalid("execution index overflow", &path))?, + executor: execution.executor.key.clone(), + }); + } + if present_count != 1 { + return Err(error( + "GRAPH_EXECUTION_UNSUPPORTED", + "exactly one present is required", + "executions", + )); + } + let query = query.ok_or_else(|| { + error( + "GRAPH_EXECUTION_UNSUPPORTED", + "one mesh query is required", + "executions", + ) + })?; + + let mut surface_pair = None; + let mut resource_allocations = vec![None; graph.resources.len()]; + for (fi, family) in graph.texture_families.iter().enumerate() { + if family.id as usize != fi { + return Err(invalid( + "texture family id does not match index", + format!("textureFamilies[{fi}].id"), + )); + } + match &family.source { + TextureFamilySource::ImportedSurface { resource } => { + if family.allocation.is_some() + || surface_pair.replace((family.id, *resource)).is_some() + { + return Err(invalid( + "invalid imported surface allocation", + format!("textureFamilies[{fi}]"), + )); + } + } + TextureFamilySource::AuthoredTexture { + residency, + descriptor, + .. + } => { + if !matches!( + residency, + TextureResidency::Transient | TextureResidency::Persistent + ) || descriptor.dimension != TextureDimension::D2 + || descriptor.mip_level_count != 1 + || descriptor.sample_count != 1 + || !matches!( + descriptor.extent, + NormalizedTextureExtent::Absolute { + depth_or_array_layers: 1, + .. + } | NormalizedTextureExtent::SurfaceRelative { + depth_or_array_layers: 1, + .. + } + ) + { + return Err(error( + "GRAPH_EXECUTION_UNSUPPORTED", + "unsupported runtime texture descriptor", + format!("textureFamilies[{fi}]"), + )); + } + if family.allocation.is_none() { + return Err(invalid( + "authored family has no allocation", + format!("textureFamilies[{fi}].allocation"), + )); + } + } + } + for (vi, version) in family.versions.iter().enumerate() { + if version.version as usize != vi { + return Err(invalid( + "texture version does not match index", + format!("textureFamilies[{fi}].versions[{vi}]"), + )); + } + let resource = graph + .resources + .get(version.resource as usize) + .ok_or_else(|| { + invalid( + "version resource is out of bounds", + format!("textureFamilies[{fi}].versions[{vi}].resource"), + ) + })?; + let ResourcePlan::Texture { + family: rf, + version: rv, + allocation, + .. + } = &resource.plan + else { + return Err(invalid( + "version resource is not a texture", + format!("resources[{}].plan", version.resource), + )); + }; + if *rf != family.id || *rv != version.version || *allocation != family.allocation { + return Err(invalid( + "texture resource and family disagree", + format!("resources[{}].plan", version.resource), + )); + } + resource_allocations[version.resource as usize] = *allocation; } } - #[test] - fn extent_uses_checked_ceil_and_minimum_one() { - assert_eq!( - resolve_extent(&key(1, 2).descriptor.extent, [3, 1]).unwrap(), - ResolvedExtent { - width: 2, - height: 1, - depth_or_array_layers: 1 + let (surface_family, surface_resource) = surface_pair + .ok_or_else(|| invalid("missing imported surface family", "textureFamilies"))?; + + // Validate the resource-to-family direction as well; compiled plans are public and may be + // cloned and modified by callers. + for (ri, resource) in graph.resources.iter().enumerate() { + if let ResourcePlan::Texture { + family, + version, + allocation, + .. + } = resource.plan + { + let family_plan = graph.texture_families.get(family as usize).ok_or_else(|| { + invalid( + "texture resource family is out of bounds", + format!("resources[{ri}].plan.family"), + ) + })?; + let family_version = family_plan.versions.get(version as usize).ok_or_else(|| { + invalid( + "texture resource version is out of bounds", + format!("resources[{ri}].plan.version"), + ) + })?; + if family_version.resource as usize != ri || allocation != family_plan.allocation { + return Err(invalid( + "texture resource is inconsistent with its family", + format!("resources[{ri}].plan"), + )); } - ); + } } - #[test] - fn equivalent_symbolic_classes_are_disjoint() { - assert_eq!( - class_offsets(&[(key(1, 2), 2), (key(2, 4), 3)], [100, 100]).unwrap(), - vec![0, 2] - ); + + let mut classes = Vec::with_capacity(graph.allocation_classes.len()); + for (ci, class) in graph.allocation_classes.iter().enumerate() { + let mut slots = Vec::with_capacity(class.slots.len()); + for (si, slot) in class.slots.iter().enumerate() { + let allocation = AllocationRef { + class: ci as u32, + slot: si as u32, + }; + for &family_id in &slot.occupants { + let family = graph + .texture_families + .get(family_id as usize) + .ok_or_else(|| { + invalid( + "slot occupant is out of bounds", + format!("allocationClasses[{ci}].slots[{si}].occupants"), + ) + })?; + if family.allocation != Some(allocation) { + return Err(invalid( + "slot occupant allocation disagrees", + format!("allocationClasses[{ci}].slots[{si}].occupants"), + )); + } + let TextureFamilySource::AuthoredTexture { descriptor, .. } = &family.source else { + return Err(invalid( + "imported family occupies a slot", + format!("allocationClasses[{ci}].slots[{si}]"), + )); + }; + if descriptor.dimension != class.key.dimension + || descriptor.format != class.key.format + || descriptor.extent != class.key.extent + || descriptor.mip_level_count != class.key.mip_level_count + || descriptor.sample_count != class.key.sample_count + || descriptor.view_formats != class.key.view_formats + { + return Err(invalid( + "occupant descriptor does not match class key", + format!("allocationClasses[{ci}].key"), + )); + } + } + slots.push(RuntimeAllocationSlot { + kind: slot.kind, + descriptor: runtime_texture_descriptor( + &class.key, + &slot.usage, + [surface.width, surface.height], + limits, + )?, + occupants: slot.occupants.clone(), + }); + } + classes.push(RuntimeAllocationClass { + key: class.key.clone(), + slots, + }); } + for (fi, family) in graph.texture_families.iter().enumerate() { + if let Some(allocation) = family.allocation { + let slot = graph + .allocation_classes + .get(allocation.class as usize) + .and_then(|c| c.slots.get(allocation.slot as usize)) + .ok_or_else(|| { + invalid( + "family allocation is out of bounds", + format!("textureFamilies[{fi}].allocation"), + ) + })?; + if slot.occupants.iter().filter(|&&id| id == family.id).count() != 1 { + return Err(invalid( + "family is not exactly once in allocation occupants", + format!("textureFamilies[{fi}].allocation"), + )); + } + } + } + let imported = graph + .resources + .get(surface_resource as usize) + .ok_or_else(|| invalid("surface resource is out of bounds", "textureFamilies"))?; + if !matches!(imported.plan, ResourcePlan::SurfaceTarget { family } if family == surface_family) + { + return Err(invalid( + "surface source resource mismatch", + format!("resources[{surface_resource}]"), + )); + } + // The imported family may only flow through texture versions and the single present. + for (ri, resource) in graph.resources.iter().enumerate() { + if let ResourcePlan::Texture { + family, allocation, .. + } = resource.plan + { + if family == surface_family && allocation.is_some() { + return Err(invalid( + "surface texture has an allocation", + format!("resources[{ri}].plan"), + )); + } + } + } + let present = graph + .executions + .iter() + .find(|execution| execution.executor.key == "present") + .ok_or_else(|| invalid("present execution disappeared", "executions"))?; + let ExecutionKind::Present { surface: presented } = present.kind else { + return Err(invalid("present execution kind mismatch", "executions")); + }; + let presented_resource = graph.resources.get(presented as usize).ok_or_else(|| { + invalid( + "present resource is out of bounds", + "executions.present.surface", + ) + })?; + if !matches!(presented_resource.plan, ResourcePlan::Texture { family, .. } if family == surface_family) + { + return Err(error( + "GRAPH_SURFACE_INCOMPATIBLE", + "present does not resolve to the imported surface", + "executions.present.surface", + )); + } + + Ok(RuntimePlan { + allocations: RuntimeAllocationPlan { + classes, + resource_allocations, + surface_family, + surface_resource, + query, + }, + executions, + surface, + }) +} + +pub fn validate_activatable(graph: &CompiledGraph) -> Result<(), GraphError> { + let surface = RuntimeSurfaceContract { + format: wgpu::TextureFormat::Bgra8Unorm, + width: 1, + height: 1, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: Vec::new(), + }; + prepare_runtime_plan(graph, surface, None).map(|_| ()) } diff --git a/renderer/src/render_graph/runtime_v2.rs b/renderer/src/render_graph/runtime_v2.rs deleted file mode 100644 index 53b2777..0000000 --- a/renderer/src/render_graph/runtime_v2.rs +++ /dev/null @@ -1,605 +0,0 @@ -use super::*; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ResolvedExtentV2 { - pub width: u32, - pub height: u32, - pub depth_or_array_layers: u32, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RuntimeTextureDescriptorV2 { - pub dimension: wgpu::TextureDimension, - pub format: wgpu::TextureFormat, - pub extent: ResolvedExtentV2, - pub mip_level_count: u32, - pub sample_count: u32, - pub usage: wgpu::TextureUsages, - pub view_formats: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RuntimeSurfaceContractV2 { - pub format: wgpu::TextureFormat, - pub width: u32, - pub height: u32, - pub usage: wgpu::TextureUsages, - pub view_formats: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RuntimeAllocationSlotV2 { - pub kind: AllocationKindV2, - pub descriptor: RuntimeTextureDescriptorV2, - pub occupants: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RuntimeAllocationClassV2 { - pub key: TextureCompatibilityKeyV2, - pub slots: Vec, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct MeshQueryRuntimeKeyV2 { - pub visible: TriStatePredicate, - pub frustum_culled: TriStatePredicate, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RuntimeExecutionV2 { - pub execution: u32, - pub executor: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RuntimeAllocationPlanV2 { - pub classes: Vec, - pub resource_allocations: Vec>, - pub surface_family: u32, - pub surface_resource: u32, - pub query: MeshQueryRuntimeKeyV2, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RuntimePlanV2 { - pub allocations: RuntimeAllocationPlanV2, - pub executions: Vec, - pub surface: RuntimeSurfaceContractV2, -} - -fn error(code: &'static str, message: impl Into, path: impl Into) -> GraphError { - GraphError::at(code, message, path) -} - -pub const fn texture_dimension_v2(value: TextureDimensionV2) -> wgpu::TextureDimension { - match value { - TextureDimensionV2::D1 => wgpu::TextureDimension::D1, - TextureDimensionV2::D2 => wgpu::TextureDimension::D2, - TextureDimensionV2::D3 => wgpu::TextureDimension::D3, - } -} - -pub const fn texture_format_v2(value: TextureFormatV2) -> wgpu::TextureFormat { - match value { - TextureFormatV2::Rgba8Unorm => wgpu::TextureFormat::Rgba8Unorm, - TextureFormatV2::Rgba8UnormSrgb => wgpu::TextureFormat::Rgba8UnormSrgb, - TextureFormatV2::Bgra8Unorm => wgpu::TextureFormat::Bgra8Unorm, - TextureFormatV2::Bgra8UnormSrgb => wgpu::TextureFormat::Bgra8UnormSrgb, - TextureFormatV2::Rgba16Float => wgpu::TextureFormat::Rgba16Float, - TextureFormatV2::R32Float => wgpu::TextureFormat::R32Float, - TextureFormatV2::Depth32Float => wgpu::TextureFormat::Depth32Float, - } -} - -pub const fn texture_usage_v2(value: TextureUsageV2) -> wgpu::TextureUsages { - match value { - TextureUsageV2::Sampled => wgpu::TextureUsages::TEXTURE_BINDING, - TextureUsageV2::Storage => wgpu::TextureUsages::STORAGE_BINDING, - TextureUsageV2::CopySrc => wgpu::TextureUsages::COPY_SRC, - TextureUsageV2::CopyDst => wgpu::TextureUsages::COPY_DST, - TextureUsageV2::ColorAttachment | TextureUsageV2::DepthAttachment => { - wgpu::TextureUsages::RENDER_ATTACHMENT - } - } -} - -pub fn texture_usages_v2(values: &[TextureUsageV2]) -> wgpu::TextureUsages { - values - .iter() - .fold(wgpu::TextureUsages::empty(), |usage, value| { - usage | texture_usage_v2(*value) - }) -} - -fn scaled(value: u32, ratio: RatioV2, path: &str) -> Result { - if ratio.denominator == 0 { - return Err(error( - "GRAPH_RESOURCE_LIMIT", - "zero extent denominator", - path, - )); - } - let product = u64::from(value) - .checked_mul(u64::from(ratio.numerator)) - .ok_or_else(|| error("GRAPH_RESOURCE_LIMIT", "extent arithmetic overflow", path))?; - let result = product - .checked_add(u64::from(ratio.denominator) - 1) - .ok_or_else(|| error("GRAPH_RESOURCE_LIMIT", "extent arithmetic overflow", path))? - / u64::from(ratio.denominator); - u32::try_from(result.max(1)) - .map_err(|_| error("GRAPH_RESOURCE_LIMIT", "extent exceeds u32", path)) -} - -pub fn resolve_extent_v2( - extent: &NormalizedTextureExtentV2, - surface: [u32; 2], -) -> Result { - let resolved = match extent { - NormalizedTextureExtentV2::Absolute { - width, - height, - depth_or_array_layers, - } => ResolvedExtentV2 { - width: *width, - height: *height, - depth_or_array_layers: *depth_or_array_layers, - }, - NormalizedTextureExtentV2::SurfaceRelative { - width, - height, - depth_or_array_layers, - } => ResolvedExtentV2 { - width: scaled(surface[0], *width, "extent.width")?, - height: scaled(surface[1], *height, "extent.height")?, - depth_or_array_layers: *depth_or_array_layers, - }, - }; - if resolved.width == 0 || resolved.height == 0 || resolved.depth_or_array_layers == 0 { - return Err(error( - "GRAPH_RESOURCE_LIMIT", - "texture extent is zero", - "extent", - )); - } - Ok(resolved) -} - -pub fn resolved_mip_level_count_v2(extent: ResolvedExtentV2) -> u32 { - 32 - extent - .width - .max(extent.height) - .max(extent.depth_or_array_layers) - .leading_zeros() -} - -fn validate_limits( - dimension: TextureDimensionV2, - extent: ResolvedExtentV2, - mip_count: u32, - limits: Option<&wgpu::Limits>, - path: &str, -) -> Result<(), GraphError> { - let max_mips = resolved_mip_level_count_v2(extent); - if mip_count == 0 || mip_count > max_mips { - return Err(error( - "GRAPH_RESOURCE_LIMIT", - "invalid mip level count", - path, - )); - } - if let Some(l) = limits { - let valid = match dimension { - TextureDimensionV2::D1 => extent.width <= l.max_texture_dimension_1d, - TextureDimensionV2::D2 => { - extent.width <= l.max_texture_dimension_2d - && extent.height <= l.max_texture_dimension_2d - && extent.depth_or_array_layers <= l.max_texture_array_layers - } - TextureDimensionV2::D3 => { - extent.width <= l.max_texture_dimension_3d - && extent.height <= l.max_texture_dimension_3d - && extent.depth_or_array_layers <= l.max_texture_dimension_3d - } - }; - if !valid { - return Err(error( - "GRAPH_RESOURCE_LIMIT", - "texture exceeds device limits", - path, - )); - } - } - Ok(()) -} - -pub fn runtime_texture_descriptor_v2( - key: &TextureCompatibilityKeyV2, - usage: &[TextureUsageV2], - surface: [u32; 2], - limits: Option<&wgpu::Limits>, -) -> Result { - let extent = resolve_extent_v2(&key.extent, surface)?; - validate_limits( - key.dimension, - extent, - key.mip_level_count, - limits, - "allocationClasses.key", - )?; - Ok(RuntimeTextureDescriptorV2 { - dimension: texture_dimension_v2(key.dimension), - format: texture_format_v2(key.format), - extent, - mip_level_count: key.mip_level_count, - sample_count: key.sample_count, - usage: texture_usages_v2(usage), - view_formats: key - .view_formats - .iter() - .copied() - .map(texture_format_v2) - .collect(), - }) -} - -fn invalid(message: impl Into, path: impl Into) -> GraphError { - error("GRAPH_RUNTIME_PLAN_INVALID", message, path) -} - -pub fn prepare_runtime_plan_v2( - graph: &CompiledGraphV2, - surface: RuntimeSurfaceContractV2, - limits: Option<&wgpu::Limits>, -) -> Result { - if surface.width == 0 || surface.height == 0 { - return Err(error( - "GRAPH_SURFACE_INCOMPATIBLE", - "surface extent is zero", - "surface", - )); - } - if !surface - .usage - .contains(wgpu::TextureUsages::RENDER_ATTACHMENT) - { - return Err(error( - "GRAPH_SURFACE_INCOMPATIBLE", - "surface lacks render attachment usage", - "surface.usage", - )); - } - - let mut present_count = 0; - let mut query = None; - let mut executions = Vec::with_capacity(graph.executions.len()); - for (i, execution) in graph.executions.iter().enumerate() { - let path = format!("executions[{i}]"); - match execution.executor.key.as_str() { - "mesh_query" => { - let NormalizedParametersV2::MeshQuery { filters } = &execution.parameters else { - return Err(invalid("mesh query parameters mismatch", &path)); - }; - let key = MeshQueryRuntimeKeyV2 { - visible: filters[0].predicate, - frustum_culled: filters[1].predicate, - }; - if query.replace(key).is_some() { - return Err(error( - "GRAPH_EXECUTION_UNSUPPORTED", - "multiple draw stream queries", - &path, - )); - } - } - "legacy_forward" => {} - "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" - | "luminance_edge" => {} - "frustum_cull" => {} - "present" => present_count += 1, - _ => { - return Err(error( - "GRAPH_EXECUTION_UNSUPPORTED", - "unsupported execution", - &path, - )) - } - } - executions.push(RuntimeExecutionV2 { - execution: u32::try_from(i).map_err(|_| invalid("execution index overflow", &path))?, - executor: execution.executor.key.clone(), - }); - } - if present_count != 1 { - return Err(error( - "GRAPH_EXECUTION_UNSUPPORTED", - "exactly one present is required", - "executions", - )); - } - let query = query.ok_or_else(|| { - error( - "GRAPH_EXECUTION_UNSUPPORTED", - "one mesh query is required", - "executions", - ) - })?; - - let mut surface_pair = None; - let mut resource_allocations = vec![None; graph.resources.len()]; - for (fi, family) in graph.texture_families.iter().enumerate() { - if family.id as usize != fi { - return Err(invalid( - "texture family id does not match index", - format!("textureFamilies[{fi}].id"), - )); - } - match &family.source { - TextureFamilySourceV2::ImportedSurface { resource } => { - if family.allocation.is_some() - || surface_pair.replace((family.id, *resource)).is_some() - { - return Err(invalid( - "invalid imported surface allocation", - format!("textureFamilies[{fi}]"), - )); - } - } - TextureFamilySourceV2::AuthoredTexture { - residency, - descriptor, - .. - } => { - if !matches!( - residency, - TextureResidencyV2::Transient | TextureResidencyV2::Persistent - ) || descriptor.dimension != TextureDimensionV2::D2 - || descriptor.mip_level_count != 1 - || descriptor.sample_count != 1 - || !matches!( - descriptor.extent, - NormalizedTextureExtentV2::Absolute { - depth_or_array_layers: 1, - .. - } | NormalizedTextureExtentV2::SurfaceRelative { - depth_or_array_layers: 1, - .. - } - ) - { - return Err(error( - "GRAPH_EXECUTION_UNSUPPORTED", - "unsupported runtime texture descriptor", - format!("textureFamilies[{fi}]"), - )); - } - if family.allocation.is_none() { - return Err(invalid( - "authored family has no allocation", - format!("textureFamilies[{fi}].allocation"), - )); - } - } - } - for (vi, version) in family.versions.iter().enumerate() { - if version.version as usize != vi { - return Err(invalid( - "texture version does not match index", - format!("textureFamilies[{fi}].versions[{vi}]"), - )); - } - let resource = graph - .resources - .get(version.resource as usize) - .ok_or_else(|| { - invalid( - "version resource is out of bounds", - format!("textureFamilies[{fi}].versions[{vi}].resource"), - ) - })?; - let ResourcePlanV2::Texture { - family: rf, - version: rv, - allocation, - .. - } = &resource.plan - else { - return Err(invalid( - "version resource is not a texture", - format!("resources[{}].plan", version.resource), - )); - }; - if *rf != family.id || *rv != version.version || *allocation != family.allocation { - return Err(invalid( - "texture resource and family disagree", - format!("resources[{}].plan", version.resource), - )); - } - resource_allocations[version.resource as usize] = *allocation; - } - } - let (surface_family, surface_resource) = surface_pair - .ok_or_else(|| invalid("missing imported surface family", "textureFamilies"))?; - - // Validate the resource-to-family direction as well; compiled plans are public and may be - // cloned and modified by callers. - for (ri, resource) in graph.resources.iter().enumerate() { - if let ResourcePlanV2::Texture { - family, - version, - allocation, - .. - } = resource.plan - { - let family_plan = graph.texture_families.get(family as usize).ok_or_else(|| { - invalid( - "texture resource family is out of bounds", - format!("resources[{ri}].plan.family"), - ) - })?; - let family_version = family_plan.versions.get(version as usize).ok_or_else(|| { - invalid( - "texture resource version is out of bounds", - format!("resources[{ri}].plan.version"), - ) - })?; - if family_version.resource as usize != ri || allocation != family_plan.allocation { - return Err(invalid( - "texture resource is inconsistent with its family", - format!("resources[{ri}].plan"), - )); - } - } - } - - let mut classes = Vec::with_capacity(graph.allocation_classes.len()); - for (ci, class) in graph.allocation_classes.iter().enumerate() { - let mut slots = Vec::with_capacity(class.slots.len()); - for (si, slot) in class.slots.iter().enumerate() { - let allocation = AllocationRefV2 { - class: ci as u32, - slot: si as u32, - }; - for &family_id in &slot.occupants { - let family = graph - .texture_families - .get(family_id as usize) - .ok_or_else(|| { - invalid( - "slot occupant is out of bounds", - format!("allocationClasses[{ci}].slots[{si}].occupants"), - ) - })?; - if family.allocation != Some(allocation) { - return Err(invalid( - "slot occupant allocation disagrees", - format!("allocationClasses[{ci}].slots[{si}].occupants"), - )); - } - let TextureFamilySourceV2::AuthoredTexture { descriptor, .. } = &family.source - else { - return Err(invalid( - "imported family occupies a slot", - format!("allocationClasses[{ci}].slots[{si}]"), - )); - }; - if descriptor.dimension != class.key.dimension - || descriptor.format != class.key.format - || descriptor.extent != class.key.extent - || descriptor.mip_level_count != class.key.mip_level_count - || descriptor.sample_count != class.key.sample_count - || descriptor.view_formats != class.key.view_formats - { - return Err(invalid( - "occupant descriptor does not match class key", - format!("allocationClasses[{ci}].key"), - )); - } - } - slots.push(RuntimeAllocationSlotV2 { - kind: slot.kind, - descriptor: runtime_texture_descriptor_v2( - &class.key, - &slot.usage, - [surface.width, surface.height], - limits, - )?, - occupants: slot.occupants.clone(), - }); - } - classes.push(RuntimeAllocationClassV2 { - key: class.key.clone(), - slots, - }); - } - for (fi, family) in graph.texture_families.iter().enumerate() { - if let Some(allocation) = family.allocation { - let slot = graph - .allocation_classes - .get(allocation.class as usize) - .and_then(|c| c.slots.get(allocation.slot as usize)) - .ok_or_else(|| { - invalid( - "family allocation is out of bounds", - format!("textureFamilies[{fi}].allocation"), - ) - })?; - if slot.occupants.iter().filter(|&&id| id == family.id).count() != 1 { - return Err(invalid( - "family is not exactly once in allocation occupants", - format!("textureFamilies[{fi}].allocation"), - )); - } - } - } - let imported = graph - .resources - .get(surface_resource as usize) - .ok_or_else(|| invalid("surface resource is out of bounds", "textureFamilies"))?; - if !matches!(imported.plan, ResourcePlanV2::SurfaceTarget { family } if family == surface_family) - { - return Err(invalid( - "surface source resource mismatch", - format!("resources[{surface_resource}]"), - )); - } - // The imported family may only flow through texture versions and the single present. - for (ri, resource) in graph.resources.iter().enumerate() { - if let ResourcePlanV2::Texture { - family, allocation, .. - } = resource.plan - { - if family == surface_family && allocation.is_some() { - return Err(invalid( - "surface texture has an allocation", - format!("resources[{ri}].plan"), - )); - } - } - } - let present = graph - .executions - .iter() - .find(|execution| execution.executor.key == "present") - .ok_or_else(|| invalid("present execution disappeared", "executions"))?; - let ExecutionKindV2::Present { surface: presented } = present.kind else { - return Err(invalid("present execution kind mismatch", "executions")); - }; - let presented_resource = graph.resources.get(presented as usize).ok_or_else(|| { - invalid( - "present resource is out of bounds", - "executions.present.surface", - ) - })?; - if !matches!(presented_resource.plan, ResourcePlanV2::Texture { family, .. } if family == surface_family) - { - return Err(error( - "GRAPH_SURFACE_INCOMPATIBLE", - "present does not resolve to the imported surface", - "executions.present.surface", - )); - } - - Ok(RuntimePlanV2 { - allocations: RuntimeAllocationPlanV2 { - classes, - resource_allocations, - surface_family, - surface_resource, - query, - }, - executions, - surface, - }) -} - -pub fn validate_activatable_v2(graph: &CompiledGraphV2) -> Result<(), GraphError> { - let surface = RuntimeSurfaceContractV2 { - format: wgpu::TextureFormat::Bgra8Unorm, - width: 1, - height: 1, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - view_formats: Vec::new(), - }; - prepare_runtime_plan_v2(graph, surface, None).map(|_| ()) -} diff --git a/renderer/src/render_graph/schema.rs b/renderer/src/render_graph/schema.rs index ed55540..bf19976 100644 --- a/renderer/src/render_graph/schema.rs +++ b/renderer/src/render_graph/schema.rs @@ -1,64 +1,58 @@ +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct GraphV1 { +pub struct Graph { pub schema_version: u32, pub graph_id: String, pub revision: u32, - pub resources: Vec, - pub passes: Vec, - pub outputs: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct ResourceRef { - pub id: String, - pub version: u32, + pub nodes: Vec, } #[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct Resource { +#[serde(deny_unknown_fields)] +pub struct Node { pub id: String, - pub version: u32, - pub residency: Residency, - pub texture: TextureDescriptor, + pub state: NodeState, + pub executor: ExecutorRef, + pub parameters: serde_json::Value, + pub inputs: BTreeMap, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NodeState { + Enabled, + Muted, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -pub enum Residency { - External { source: ExternalSource }, - Transient, -} -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum ExternalSource { - SurfaceColor, +#[serde(deny_unknown_fields)] +pub struct ExecutorRef { + pub key: String, + pub version: u32, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct TextureDescriptor { - pub dimension: Dimension, - pub format: Format, - pub extent: Extent, - pub mip_level_count: u32, - pub sample_count: u32, +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub struct NodeOutputRef { + pub node: String, + pub socket: String, } + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] #[serde(rename_all = "snake_case")] -pub enum Dimension { +pub enum TextureDimension { D1, D2, D3, } + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] #[serde(rename_all = "snake_case")] -pub enum Format { - Surface, +pub enum TextureFormat { Rgba8Unorm, Rgba8UnormSrgb, Bgra8Unorm, @@ -67,15 +61,10 @@ pub enum Format { R32Float, Depth32Float, } -impl Format { - pub(crate) fn depth(self) -> bool { - self == Self::Depth32Float - } -} #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -pub enum Extent { +pub enum TextureExtent { Absolute { width: u32, height: u32, @@ -96,93 +85,75 @@ pub struct Ratio { pub denominator: u32, } -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct Pass { - pub id: String, - pub state: PassState, - pub executor: ExecutorRef, - pub parameters: serde_json::Value, - pub reads: Vec, - pub writes: Vec, -} -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum PassState { - Enabled, - Muted, -} -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct ExecutorRef { - pub key: String, - pub version: u32, -} -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct ReadBinding { - pub binding: String, - pub resource: ResourceRef, - pub access: ReadAccess, -} #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] #[serde(rename_all = "snake_case")] -pub enum ReadAccess { - Sampled, - Storage, - CopySrc, -} -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct WriteBinding { - pub binding: String, - pub resource: ResourceRef, - pub access: WriteAccess, -} -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -pub enum WriteAccess { - Storage, - CopyDst, - ColorAttachment { - location: u32, - load: ColorLoad, - store: StoreOp, - }, - DepthAttachment { - load: DepthLoad, - store: StoreOp, - }, -} -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] -pub enum ColorLoad { - Clear { value: [f64; 4] }, - Load, -} -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] -pub enum DepthLoad { - Clear { value: f32 }, - Load, -} -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum StoreOp { - Store, - Discard, -} -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Output { - pub name: String, - pub resource: ResourceRef, +pub enum TextureResidency { + Transient, + Persistent, + History, + Readback, } -pub(crate) fn identifier(s: &str) -> bool { - s.as_bytes() - .first() - .is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_') - && s.bytes() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'/' | b'-')) +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TextureDescriptor { + pub dimension: TextureDimension, + pub format: TextureFormat, + pub extent: TextureExtent, + pub mip_level_count: u32, + pub sample_count: u32, + #[serde(default)] + pub view_formats: Vec, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum TriStatePredicate { + Any, + RequiredTrue, + RequiredFalse, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub struct MeshFilter { + pub flag: MeshFlag, + pub predicate: TriStatePredicate, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub enum MeshFlag { + IsVisible, + IsFrustumCulled, +} + +impl MeshFlag { + pub const ORDERED: [Self; 2] = [Self::IsVisible, Self::IsFrustumCulled]; + + pub const fn input_socket(self) -> &'static str { + match self { + Self::IsVisible => "isVisible", + Self::IsFrustumCulled => "isFrustumCulled", + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CompareFunction { + Never, + Less, + Equal, + LessEqual, + Greater, + NotEqual, + GreaterEqual, + Always, +} + +pub(crate) fn identifier(value: &str) -> bool { + let mut chars = value.chars(); + chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) } diff --git a/renderer/src/render_graph/schema_v2.rs b/renderer/src/render_graph/schema_v2.rs deleted file mode 100644 index 101507e..0000000 --- a/renderer/src/render_graph/schema_v2.rs +++ /dev/null @@ -1,153 +0,0 @@ -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct GraphV2 { - pub schema_version: u32, - pub graph_id: String, - pub revision: u32, - pub nodes: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct NodeV2 { - pub id: String, - pub state: NodeStateV2, - pub executor: ExecutorRefV2, - pub parameters: serde_json::Value, - pub inputs: BTreeMap, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum NodeStateV2 { - Enabled, - Muted, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct ExecutorRefV2 { - pub key: String, - pub version: u32, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(deny_unknown_fields)] -pub struct NodeOutputRef { - pub node: String, - pub socket: String, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[serde(rename_all = "snake_case")] -pub enum TextureDimensionV2 { - D1, - D2, - D3, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[serde(rename_all = "snake_case")] -pub enum TextureFormatV2 { - Rgba8Unorm, - Rgba8UnormSrgb, - Bgra8Unorm, - Bgra8UnormSrgb, - Rgba16Float, - R32Float, - Depth32Float, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -pub enum TextureExtentV2 { - Absolute { - width: u32, - height: u32, - #[serde(rename = "depthOrArrayLayers")] - depth_or_array_layers: u32, - }, - SurfaceRelative { - width: RatioV2, - height: RatioV2, - #[serde(rename = "depthOrArrayLayers")] - depth_or_array_layers: u32, - }, -} -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[serde(deny_unknown_fields)] -pub struct RatioV2 { - pub numerator: u32, - pub denominator: u32, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[serde(rename_all = "snake_case")] -pub enum TextureResidencyV2 { - Transient, - Persistent, - History, - Readback, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct TextureDescriptorV2 { - pub dimension: TextureDimensionV2, - pub format: TextureFormatV2, - pub extent: TextureExtentV2, - pub mip_level_count: u32, - pub sample_count: u32, - #[serde(default)] - pub view_formats: Vec, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "snake_case")] -pub enum TriStatePredicate { - Any, - RequiredTrue, - RequiredFalse, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(deny_unknown_fields)] -pub struct MeshFilterV2 { - pub flag: MeshFlagV2, - pub predicate: TriStatePredicate, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "camelCase")] -pub enum MeshFlagV2 { - IsVisible, - IsFrustumCulled, -} - -impl MeshFlagV2 { - pub const ORDERED: [Self; 2] = [Self::IsVisible, Self::IsFrustumCulled]; - - pub const fn input_socket(self) -> &'static str { - match self { - Self::IsVisible => "isVisible", - Self::IsFrustumCulled => "isFrustumCulled", - } - } -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum CompareFunctionV2 { - Never, - Less, - Equal, - LessEqual, - Greater, - NotEqual, - GreaterEqual, - Always, -} diff --git a/renderer/src/render_graph/tests.rs b/renderer/src/render_graph/tests.rs index 3e320b8..5a9f575 100644 --- a/renderer/src/render_graph/tests.rs +++ b/renderer/src/render_graph/tests.rs @@ -1,703 +1,1655 @@ +use std::collections::BTreeSet; + use super::*; +use serde_json::{json, Value}; -struct TestExecutors; -struct TestExecutor { - observable: bool, +fn input(node: &str, socket: &str) -> Value { + json!({"node":node,"socket":socket}) } - -static TEST_EXECUTOR: TestExecutor = TestExecutor { observable: false }; -static OBSERVABLE_EXECUTOR: TestExecutor = TestExecutor { observable: true }; - -impl ExecutorRegistry for TestExecutors { - fn resolve(&self, executor: &ExecutorRef) -> ExecutorResolution<'_> { - if executor.version != 1 { - return ExecutorResolution::UnsupportedVersion; - } - match executor.key.as_str() { - "test" => ExecutorResolution::Found(&TEST_EXECUTOR), - "observable" => ExecutorResolution::Found(&OBSERVABLE_EXECUTOR), - _ => ExecutorResolution::UnknownKey, - } - } +fn node(id: &str, key: &str, parameters: Value, inputs: Value) -> Value { + json!({"id":id,"state":"enabled","executor":{"key":key,"version":1},"parameters":parameters,"inputs":inputs}) } - -impl ExecutorContract for TestExecutor { - fn inherently_observable(&self) -> bool { - self.observable - } - - fn normalize_parameters( - &self, - parameters: &serde_json::Value, - ) -> Result { - if parameters == &serde_json::json!({}) { - Ok(NormalizedParameters::SceneForward) - } else { - Err("test parameters must be empty".into()) - } - } - - fn validate_bindings( - &self, - _pass: &Pass, - _resources: &std::collections::HashMap, - ) -> Result<(), String> { - Ok(()) - } +fn texture(format: &str, residency: &str) -> Value { + json!({"texture":{"dimension":"d2","format":format,"extent":{"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":1},"mipLevelCount":1,"sampleCount":1,"viewFormats":[]},"residency":residency}) } - -fn compile_json(value: serde_json::Value) -> Result { - compile_with(&serde_json::to_vec(&value).unwrap(), &TestExecutors) +fn full_cull_graph() -> Value { + json!({"schemaVersion":2,"graphId":"full","revision":1,"nodes":[ + node("surface","surface_target",json!({}),json!({})), + node("depth","texture_spec",texture("depth32_float","transient"),json!({})), + node("scene","scene_table",json!({}),json!({})), + node("aabbs","local_aabb_buffer",json!({}),json!({"scene":input("scene","scene")})), + node("frustum","camera_frustum",json!({}),json!({})), + node("visible","visibility_flags",json!({}),json!({"scene":input("scene","scene")})), + node("cull","frustum_cull",json!({}),json!({"scene":input("scene","scene"),"localAabbs":input("aabbs","localAabbs"),"frustum":input("frustum","frustum")})), + node("query","mesh_query",json!({"filters":[{"flag":"isFrustumCulled","predicate":"required_false"},{"flag":"isVisible","predicate":"required_true"}]}),json!({"scene":input("scene","scene"),"isVisible":input("visible","flags"),"isFrustumCulled":input("cull","flags")})), + node("depth_config","depth_stencil_config",json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0}),json!({})), + node("forward","legacy_forward",json!({"clearColor":[0,0,0,1]}),json!({"scene":input("scene","scene"),"draws":input("query","draws"),"colorTarget":input("surface","surface"),"depthTarget":input("depth","spec"),"depthStencil":input("depth_config","config")})), + node("present","present",json!({}),json!({"surface":input("forward","color")})) + ]}) } - -fn texture(format: &str) -> serde_json::Value { - serde_json::json!({ - "dimension": "d2", - "format": format, - "extent": {"kind":"absolute", "width":16, "height":16, "depthOrArrayLayers":1}, - "mipLevelCount": 1, - "sampleCount": 1 - }) +fn forward(id: &str, color: Value, depth: Value) -> Value { + node( + id, + "legacy_forward", + json!({"clearColor":[0,0,0,1]}), + json!({ + "scene":input("scene","scene"), + "draws":input("query","draws"), + "colorTarget":color, + "depthTarget":depth, + "depthStencil":input("depth_config","config") + }), + ) } - -fn transient(id: &str, format: &str) -> serde_json::Value { - serde_json::json!({ - "id": id, - "version": 0, - "residency": {"kind":"transient"}, - "texture": texture(format) - }) +fn render_support_nodes() -> Vec { + vec![ + node("scene", "scene_table", json!({}), json!({})), + node( + "visible", + "visibility_flags", + json!({}), + json!({"scene":input("scene","scene")}), + ), + node( + "query", + "mesh_query", + json!({"filters":[ + {"flag":"isVisible","predicate":"required_true"}, + {"flag":"isFrustumCulled","predicate":"any"} + ]}), + json!({"scene":input("scene","scene"),"isVisible":input("visible","flags")}), + ), + node( + "depth_config", + "depth_stencil_config", + json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0}), + json!({}), + ), + ] } - -fn pass( - id: &str, - executor: &str, - reads: serde_json::Value, - writes: serde_json::Value, -) -> serde_json::Value { - serde_json::json!({ - "id": id, - "state": "enabled", - "executor": {"key":executor, "version":1}, - "parameters": {}, - "reads": reads, - "writes": writes - }) +fn graph(nodes: Vec) -> Value { + json!({"schemaVersion":2,"graphId":"hazards","revision":1,"nodes":nodes}) } - -fn resource_ref(id: &str) -> serde_json::Value { - serde_json::json!({"id":id, "version":0}) +fn hdr_copy_graph() -> Value { + let mut nodes = vec![ + node("surface", "surface_target", json!({}), json!({})), + node( + "hdr", + "texture_spec", + texture("rgba16_float", "transient"), + json!({}), + ), + depth_spec("depth", "transient"), + ]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward("forward", input("hdr", "spec"), input("depth", "spec")), + node( + "copy", + "fullscreen_copy", + json!({}), + json!({"source":input("forward","color"),"colorTarget":input("surface","surface")}), + ), + node( + "present", + "present", + json!({}), + json!({"surface":input("copy","color")}), + ), + ]); + graph(nodes) } - -fn sampled(binding: &str, id: &str) -> serde_json::Value { - serde_json::json!({"binding":binding, "resource":resource_ref(id), "access":"sampled"}) +fn cyclic_forwards() -> Vec { + vec![ + forward("A", input("B", "color"), input("B", "depth")), + forward("B", input("A", "color"), input("A", "depth")), + ] } - -fn copy_write(binding: &str, id: &str) -> serde_json::Value { - serde_json::json!({"binding":binding, "resource":resource_ref(id), "access":{"kind":"copy_dst"}}) -} - -fn color_write(binding: &str, id: &str, location: u32) -> serde_json::Value { - serde_json::json!({ - "binding":binding, - "resource":resource_ref(id), - "access":{ - "kind":"color_attachment", - "location":location, - "load":{"op":"clear", "value":[0.0, 0.0, 0.0, 1.0]}, - "store":"store" - } - }) -} - -fn color_load(binding: &str, id: &str, location: u32) -> serde_json::Value { - serde_json::json!({ - "binding":binding, - "resource":resource_ref(id), - "access":{ - "kind":"color_attachment", - "location":location, - "load":{"op":"load"}, - "store":"store" - } - }) -} - -fn graph( - resources: serde_json::Value, - passes: serde_json::Value, - outputs: serde_json::Value, -) -> serde_json::Value { - serde_json::json!({ - "schemaVersion":1, - "graphId":"test_graph", - "revision":1, - "resources":resources, - "passes":passes, - "outputs":outputs - }) -} - -fn empty(id: &str, revision: u32) -> Vec { - format!(r#"{{"schemaVersion":1,"graphId":"{id}","revision":{revision},"resources":[],"passes":[],"outputs":[]}}"#).into_bytes() -} -fn error(bytes: &[u8]) -> &'static str { - parse_and_compile(bytes).unwrap_err().code +fn compile_graph(v: Value) -> CompiledGraph { + super::compile(serde_json::from_value(v).unwrap()).unwrap() } #[test] -fn size_precedes_encoding() { +fn fullscreen_copy_hdr_graph_lowers_versions_accesses_and_usage() { + let p = compile_graph(hdr_copy_graph()); assert_eq!( - error(&vec![0xff; MAX_JSON_BYTES + 1]), - "GRAPH_PAYLOAD_TOO_LARGE" - ); -} -#[test] -fn encoding_precedes_schema() { - assert_eq!(error(&[0xff]), "GRAPH_ENCODING_INVALID"); -} -#[test] -fn malformed_json() { - assert_eq!(error(b"{"), "GRAPH_JSON_INVALID"); -} -#[test] -fn missing_schema_probe() { - assert_eq!(error(b"{}"), "GRAPH_SCHEMA_UNSUPPORTED"); -} -#[test] -fn unsupported_schema_probe() { - assert_eq!(error(br#"{"schemaVersion":2}"#), "GRAPH_SCHEMA_UNSUPPORTED"); -} -#[test] -fn strict_unknown_field() { - assert_eq!(error(br#"{"schemaVersion":1,"graphId":"g","revision":1,"resources":[],"passes":[],"outputs":[],"extra":0}"#), "GRAPH_JSON_INVALID"); -} -#[test] -fn identifier_first_character() { - assert_eq!(error(&empty("1bad", 1)), "GRAPH_INVALID_ID"); -} -#[test] -fn underscore_identifier() { - assert_eq!(parse_and_compile(&empty("_ok", 1)).unwrap().graph_id, "_ok"); -} -#[test] -fn revision_required() { - assert_eq!(error(&empty("g", 0)), "GRAPH_INVALID_ID"); -} - -#[test] -fn resource_version_zero_and_wire_names() { - let json = br#"{"schemaVersion":1,"graphId":"g","revision":1,"resources":[{"id":"r","version":0,"residency":{"kind":"transient"},"texture":{"dimension":"d2","format":"rgba8_unorm","extent":{"kind":"absolute","width":1,"height":1,"depthOrArrayLayers":1},"mipLevelCount":1,"sampleCount":1}}],"passes":[],"outputs":[]}"#; - assert_eq!(parse_and_compile(json).unwrap().culled_resource_count, 1); -} -#[test] -fn old_mip_wire_rejected() { - let mut s = String::from_utf8(empty("g", 1)).unwrap(); - s=s.replace("\"resources\":[]", "\"resources\":[{\"id\":\"r\",\"version\":0,\"residency\":{\"kind\":\"transient\"},\"texture\":{\"dimension\":\"d2\",\"format\":\"rgba8_unorm\",\"extent\":{\"kind\":\"absolute\",\"width\":1,\"height\":1,\"depthOrArrayLayers\":1},\"mipLevels\":1,\"sampleCount\":1}}]"); - assert_eq!(error(s.as_bytes()), "GRAPH_JSON_INVALID"); -} - -#[test] -fn registry_transaction_on_parse_failure() { - let mut r = Registry::new(1); - assert!(r.compile(b"{").is_err()); - assert!(r.compile(&empty("g", 1)).is_ok()); -} -#[test] -fn registry_capacity() { - let mut r = Registry::new(1); - r.compile(&empty("a", 1)).unwrap(); - assert_eq!( - r.compile(&empty("b", 1)).unwrap_err().code, - "GRAPH_REGISTRY_FULL" - ); -} -#[test] -fn registry_revision_creates_immutable_handle() { - let mut r = Registry::new(2); - let (a, _) = r.compile(&empty("g", 1)).unwrap(); - let (b, _) = r.compile(&empty("g", 2)).unwrap(); - assert_ne!(a, b); - assert_eq!(r.get(a).unwrap().revision, 1); - assert_eq!(r.get(b).unwrap().revision, 2); -} -#[test] -fn registry_revision_conflict() { - let mut r = Registry::new(1); - r.compile(&empty("g", 2)).unwrap(); - assert_eq!( - r.compile(&empty("g", 2)).unwrap_err().code, - "GRAPH_REVISION_CONFLICT" - ); -} -#[test] -fn registry_drop_and_stale() { - let mut r = Registry::new(1); - let (id, _) = r.compile(&empty("g", 1)).unwrap(); - r.drop_graph(id).unwrap(); - assert_eq!(r.get(id).unwrap_err().code, "STALE_GRAPH_ID"); - assert_eq!(r.drop_graph(id).unwrap_err().code, "STALE_GRAPH_ID"); -} -#[test] -fn registry_reuse_increments_generation() { - let mut r = Registry::new(1); - let (a, _) = r.compile(&empty("a", 1)).unwrap(); - r.drop_graph(a).unwrap(); - let (b, _) = r.compile(&empty("b", 1)).unwrap(); - assert_eq!(a.slot, b.slot); - assert_eq!(a.generation + 1, b.generation); -} -#[test] -fn graph_error_details_always_have_message() { - let e = parse_and_compile(b"{}").unwrap_err(); - assert!(e.details["message"].is_string()); -} - -#[test] -fn zero_surface_ratio_is_rejected_without_panicking() { - for field in ["width", "height"] { - let mut resource = transient("r", "rgba8_unorm"); - resource["texture"]["extent"] = serde_json::json!({ - "kind":"surface_relative", - "width":{"numerator":1,"denominator":1}, - "height":{"numerator":1,"denominator":1}, - "depthOrArrayLayers":1 - }); - resource["texture"]["extent"][field] = serde_json::json!({"numerator":0,"denominator":0}); - let error = compile_json(graph( - serde_json::json!([resource]), - serde_json::json!([]), - serde_json::json!([]), - )) - .unwrap_err(); - assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); - } -} - -#[test] -fn depth_texture_accepts_copy_destination_access() { - let compiled = compile_json(graph( - serde_json::json!([transient("depth", "depth32_float")]), - serde_json::json!([pass( - "write_depth", - "observable", - serde_json::json!([]), - serde_json::json!([copy_write("destination", "depth")]) - )]), - serde_json::json!([]), - )) - .unwrap(); - assert_eq!(compiled.passes.len(), 1); -} - -#[test] -fn unknown_resources_precede_executor_and_parameter_errors() { - let mut invalid = pass( - "bad", - "missing_executor", - serde_json::json!([sampled("input", "missing_resource")]), - serde_json::json!([]), - ); - invalid["parameters"] = serde_json::json!({"also":"invalid"}); - let error = compile_json(graph( - serde_json::json!([]), - serde_json::json!([invalid]), - serde_json::json!([]), - )) - .unwrap_err(); - assert_eq!(error.code, "GRAPH_UNKNOWN_RESOURCE"); -} - -#[test] -fn executor_contract_normalizes_parameters_and_reports_invalid_parameters() { - let valid = compile_json(graph( - serde_json::json!([transient("r", "rgba8_unorm")]), - serde_json::json!([pass( - "p", - "observable", - serde_json::json!([]), - serde_json::json!([copy_write("out", "r")]) - )]), - serde_json::json!([]), - )) - .unwrap(); - assert_eq!( - valid.passes[0].parameters, - NormalizedParameters::SceneForward - ); - - let mut invalid = pass( - "p", - "observable", - serde_json::json!([]), - serde_json::json!([copy_write("out", "r")]), - ); - invalid["parameters"] = serde_json::json!({"unexpected":true}); - let error = compile_json(graph( - serde_json::json!([transient("r", "rgba8_unorm")]), - serde_json::json!([invalid]), - serde_json::json!([]), - )) - .unwrap_err(); - assert_eq!(error.code, "GRAPH_PARAMETERS_INVALID"); -} - -#[test] -fn culls_dead_branches_and_orders_live_dependencies_deterministically() { - let compiled = compile_json(graph( - serde_json::json!([ - transient("middle", "rgba8_unorm"), - transient("output", "rgba8_unorm"), - transient("dead", "rgba8_unorm") - ]), - serde_json::json!([ - pass( - "consumer", - "test", - serde_json::json!([sampled("input", "middle")]), - serde_json::json!([copy_write("out", "output")]) - ), - pass( - "producer", - "test", - serde_json::json!([]), - serde_json::json!([copy_write("out", "middle")]) - ), - pass( - "dead", - "test", - serde_json::json!([]), - serde_json::json!([copy_write("out", "dead")]) - ) - ]), - serde_json::json!([{"name":"present", "resource":resource_ref("output")}]), - )) - .unwrap(); - assert_eq!( - compiled - .passes + p.executions .iter() - .map(|p| p.id.as_str()) + .map(|execution| execution.id.as_str()) .collect::>(), - ["producer", "consumer"] + ["query", "forward", "copy", "present"] ); - assert_eq!(compiled.culled_pass_count, 1); - assert_eq!(compiled.culled_resource_count, 1); -} - -#[test] -fn output_extends_inclusive_lifetime_to_graph_boundary() { - let compiled = compile_json(graph( - serde_json::json!([transient("out", "rgba8_unorm")]), - serde_json::json!([pass( - "write", - "test", - serde_json::json!([]), - serde_json::json!([copy_write("out", "out")]) - )]), - serde_json::json!([{"name":"present", "resource":resource_ref("out")}]), - )) - .unwrap(); - assert_eq!(compiled.resources[0].lifetime.first_use, 0); - assert_eq!(compiled.resources[0].lifetime.last_use, 1); -} - -#[test] -fn transient_slots_reuse_only_for_non_overlapping_compatible_lifetimes() { - let compiled = compile_json(graph( - serde_json::json!([ - transient("first", "rgba8_unorm"), - transient("second", "rgba8_unorm"), - transient("incompatible", "rgba16_float") - ]), - serde_json::json!([ - pass( - "a", - "observable", - serde_json::json!([]), - serde_json::json!([copy_write("out", "first")]) - ), - pass( - "b", - "observable", - serde_json::json!([]), - serde_json::json!([copy_write("out", "second")]) - ), - pass( - "c", - "observable", - serde_json::json!([]), - serde_json::json!([copy_write("out", "incompatible")]) - ) - ]), - serde_json::json!([]), - )) - .unwrap(); - let allocations: Vec<_> = compiled + for (node, socket) in [ + ("forward", "color"), + ("forward", "depth"), + ("copy", "color"), + ] { + assert!(matches!( + resource_by_origin(&p, node, socket).plan, + ResourcePlan::Texture { version: 0, .. } + )); + } + let copy = execution(&p, "copy"); + let source = resource_by_origin(&p, "forward", "color"); + let color = resource_by_origin(&p, "copy", "color"); + assert!(copy.accesses.iter().any(|access| access.resource + == p.resources + .iter() + .position(|resource| std::ptr::eq(resource, source)) + .unwrap() as u32 + && access.mode == AccessMode::SampledTexture)); + let color_id = p .resources .iter() - .map(|resource| resource.allocation.unwrap()) - .collect(); - assert_eq!(allocations[0], allocations[1]); - assert_ne!(allocations[0].class, allocations[2].class); - assert_eq!(compiled.allocation_classes.len(), 2); -} - -#[test] -fn cycle_details_survive_a_non_cycle_dfs_branch() { - let result = compile_json(graph( - serde_json::json!([ - transient("ab", "rgba8_unorm"), - transient("bc", "rgba8_unorm"), - transient("ca", "rgba8_unorm"), - transient("branch", "rgba8_unorm") - ]), - serde_json::json!([ - pass( - "a", - "observable", - serde_json::json!([sampled("ca", "ca")]), - serde_json::json!([copy_write("ab", "ab"), copy_write("branch", "branch")]) - ), - pass( - "branch", - "observable", - serde_json::json!([sampled("input", "branch")]), - serde_json::json!([]) - ), - pass( - "b", - "observable", - serde_json::json!([sampled("ab", "ab")]), - serde_json::json!([copy_write("bc", "bc")]) - ), - pass( - "c", - "observable", - serde_json::json!([sampled("bc", "bc")]), - serde_json::json!([copy_write("ca", "ca")]) - ) - ]), - serde_json::json!([]), + .position(|resource| std::ptr::eq(resource, color)) + .unwrap() as u32; + assert!(matches!( + ©.kind, + ExecutionKind::Render { color_attachments, depth_stencil: None } + if color_attachments[0].resource == color_id + && color_attachments[0].load == NormalizedColorLoad::Clear { value: [0.0; 4] } )); - let error = result.unwrap_err(); - assert_eq!(error.code, "GRAPH_CYCLE"); - assert_eq!(error.details["kind"], "cycle"); - let edges = error.details["edges"].as_array().unwrap(); - assert_eq!(edges.len(), 3); - assert!(edges.iter().all(|edge| edge["from"] != "branch")); + assert!(copy.accesses.iter().any(|access| matches!( + access.mode, + AccessMode::ColorAttachment { + full_overwrite: true, + .. + } + ) && access.resource == color_id)); + let hdr = family_by_source(&p, "hdr"); + assert_eq!( + hdr.usage.iter().copied().collect::>(), + [TextureUsage::Sampled, TextureUsage::ColorAttachment] + .into_iter() + .collect() + ); + let surface = p + .texture_families + .iter() + .find(|family| matches!(family.source, TextureFamilySource::ImportedSurface { .. })) + .unwrap(); + assert_eq!(surface.versions[0].resource, color_id); } #[test] -fn duplicate_external_source_is_an_identity_error_before_descriptor_validation() { - let external = |id: &str, texture: serde_json::Value| { - serde_json::json!({ - "id":id, - "version":0, - "residency":{"kind":"external", "source":"surface_color"}, - "texture":texture - }) - }; - let surface = serde_json::json!({ - "dimension":"d2", - "format":"surface", - "extent":{ - "kind":"surface_relative", - "width":{"numerator":1,"denominator":1}, - "height":{"numerator":1,"denominator":1}, - "depthOrArrayLayers":1 - }, - "mipLevelCount":1, - "sampleCount":1 - }); - let error = compile_json(graph( - serde_json::json!([ - external("first", surface), - external("second", texture("rgba8_unorm")) - ]), - serde_json::json!([]), - serde_json::json!([]), - )) - .unwrap_err(); - assert_eq!(error.code, "GRAPH_DUPLICATE_ID"); +fn fullscreen_copy_parameters_are_exactly_empty() { + let mut g = hdr_copy_graph(); + g["nodes"][8]["parameters"] = json!({"obsolete":true}); + assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); + assert_eq!(CONTRACTS.len(), 17); } #[test] -fn duplicate_writer_precedes_illegal_access_on_the_second_writer() { - let illegal_depth_color = color_write("bad", "depth", 0); - let error = compile_json(graph( - serde_json::json!([transient("depth", "depth32_float")]), - serde_json::json!([ - pass( - "first", - "observable", - serde_json::json!([]), - serde_json::json!([copy_write("out", "depth")]) - ), - pass( - "second", - "observable", - serde_json::json!([]), - serde_json::json!([illegal_depth_color]) - ) - ]), - serde_json::json!([]), - )) - .unwrap_err(); +fn fullscreen_copy_rejects_same_source_and_target_family() { + let mut g = hdr_copy_graph(); + g["nodes"][8]["inputs"]["colorTarget"] = input("forward", "color"); + let error = compile_error(g); + assert_eq!(error.code, "GRAPH_SAME_PASS_HAZARD"); + assert_eq!(error.details["path"], "nodes[8].inputs"); +} + +#[test] +fn duplicate_texture_writer_reports_second_color_target() { + let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; + nodes.extend(render_support_nodes()); + nodes.extend([ + node( + "depth_a", + "texture_spec", + texture("depth32_float", "transient"), + json!({}), + ), + node( + "depth_b", + "texture_spec", + texture("depth32_float", "transient"), + json!({}), + ), + forward("F0", input("surface", "surface"), input("depth_a", "spec")), + node( + "P0", + "present", + json!({}), + json!({"surface":input("F0","color")}), + ), + forward("F1", input("surface", "surface"), input("depth_b", "spec")), + node( + "P1", + "present", + json!({}), + json!({"surface":input("F1","color")}), + ), + ]); + let error = compile_error(graph(nodes)); assert_eq!(error.code, "GRAPH_DUPLICATE_WRITER"); + assert_eq!(error.details["path"], "nodes[9].inputs.colorTarget"); } #[test] -fn rejects_device_invalid_dimensions_and_mismatched_attachments() { - let mut d1 = transient("d1", "rgba8_unorm"); - d1["texture"]["dimension"] = serde_json::json!("d1"); - assert_eq!( - compile_json(graph( - serde_json::json!([d1]), - serde_json::json!([]), - serde_json::json!([]) - )) - .unwrap_err() - .code, - "GRAPH_ILLEGAL_ACCESS" - ); - - let mut depth_d3 = transient("depth", "depth32_float"); - depth_d3["texture"]["dimension"] = serde_json::json!("d3"); - assert_eq!( - compile_json(graph( - serde_json::json!([depth_d3]), - serde_json::json!([]), - serde_json::json!([]) - )) - .unwrap_err() - .code, - "GRAPH_ILLEGAL_ACCESS" - ); - - let first = transient("first", "rgba8_unorm"); - let mut second = transient("second", "rgba8_unorm"); - second["texture"]["extent"]["width"] = serde_json::json!(32); - let error = compile_json(graph( - serde_json::json!([first, second]), - serde_json::json!([pass( - "attachments", - "observable", - serde_json::json!([]), - serde_json::json!([ - color_write("first", "first", 0), - color_write("second", "second", 1) - ]) - )]), - serde_json::json!([]), - )) - .unwrap_err(); - assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); +fn same_output_bound_to_both_attachments_is_a_same_pass_hazard() { + let mut nodes = vec![node( + "target", + "texture_spec", + texture("rgba8_unorm", "transient"), + json!({}), + )]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward("F", input("target", "spec"), input("target", "spec")), + node( + "P", + "present", + json!({}), + json!({"surface":input("F","color")}), + ), + ]); + let error = compile_error(graph(nodes)); + assert_eq!(error.code, "GRAPH_SAME_PASS_HAZARD"); + assert_eq!(error.details["path"], "nodes[5].inputs"); } #[test] -fn cycle_tie_breaks_parallel_edges_by_original_resource_index() { - let error = compile_json(graph( - serde_json::json!([ - transient("z_declared_first", "rgba8_unorm"), - transient("a_declared_second", "rgba8_unorm"), - transient("back", "rgba8_unorm") - ]), - serde_json::json!([ - pass( - "a", - "observable", - serde_json::json!([sampled("back", "back")]), - serde_json::json!([ - copy_write("first", "z_declared_first"), - copy_write("second", "a_declared_second") - ]) - ), - pass( - "b", - "observable", - serde_json::json!([ - sampled("first", "z_declared_first"), - sampled("second", "a_declared_second") - ]), - serde_json::json!([copy_write("back", "back")]) - ) - ]), - serde_json::json!([]), - )) - .unwrap_err(); +fn unordered_old_texture_version_read_is_rejected_before_scheduling() { + let mut nodes = vec![ + node("surface", "surface_target", json!({}), json!({})), + node( + "depth_0", + "texture_spec", + texture("depth32_float", "transient"), + json!({}), + ), + node( + "depth_1", + "texture_spec", + texture("depth32_float", "transient"), + json!({}), + ), + ]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward("F0", input("surface", "surface"), input("depth_0", "spec")), + forward("F1", input("F0", "color"), input("depth_1", "spec")), + node( + "P0", + "present", + json!({}), + json!({"surface":input("F0","color")}), + ), + node( + "P1", + "present", + json!({}), + json!({"surface":input("F1","color")}), + ), + ]); + let error = compile_error(graph(nodes)); + assert_eq!(error.code, "GRAPH_RESOURCE_VERSION_INVALID"); + assert_eq!(error.details["path"], "nodes[9].inputs.surface"); +} + +#[test] +fn duplicate_successors_defer_old_version_reachability() { + let mut nodes = vec![ + node("surface", "surface_target", json!({}), json!({})), + depth_spec("depth_0", "transient"), + depth_spec("depth_1", "transient"), + depth_spec("depth_2", "transient"), + ]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward("F0", input("surface", "surface"), input("depth_0", "spec")), + forward("F1", input("F0", "color"), input("depth_1", "spec")), + forward("F2", input("F0", "color"), input("depth_2", "spec")), + node( + "P0", + "present", + json!({}), + json!({"surface":input("F0","color")}), + ), + node( + "P1", + "present", + json!({}), + json!({"surface":input("F1","color")}), + ), + node( + "P2", + "present", + json!({}), + json!({"surface":input("F2","color")}), + ), + ]); + let error = compile_error(graph(nodes)); + assert_eq!(error.code, "GRAPH_DUPLICATE_WRITER"); + assert_eq!(error.details["path"], "nodes[10].inputs.colorTarget"); +} + +#[test] +fn live_texture_cycle_reports_the_exact_first_cycle() { + let mut nodes = render_support_nodes(); + nodes.extend(cyclic_forwards()); + nodes.push(node( + "present", + "present", + json!({}), + json!({"surface":input("A","color")}), + )); + let error = compile_error(graph(nodes)); assert_eq!(error.code, "GRAPH_CYCLE"); assert_eq!( - error.details["edges"][0]["resource"]["id"], - "z_declared_first" + error.details, + json!({ + "message":"live graph contains a cycle", + "kind":"cycle", + "edges":[ + {"fromNode":"A","fromSocket":"color","toNode":"B","toSocket":"colorTarget","resource":{"node":"A","socket":"color"}}, + {"fromNode":"B","fromSocket":"color","toNode":"A","toSocket":"colorTarget","resource":{"node":"B","socket":"color"}} + ] + }) ); } #[test] -fn identifier_byte_limit_precedes_reference_and_executor_resolution() { - let overlong = "a".repeat(65); - let invalid = pass( - "p", - "unknown_executor", - serde_json::json!([sampled(&overlong, &overlong)]), - serde_json::json!([]), +fn dead_texture_cycle_is_culled_without_cycle_execution() { + let mut value = full_cull_graph(); + value["nodes"] + .as_array_mut() + .unwrap() + .extend(cyclic_forwards()); + let plan = compile_graph(value); + assert_eq!(plan.node_count, 13); + assert_eq!(plan.culled_node_count, 2); + assert_eq!(plan.culled_resource_count, 4); + assert!(!plan + .executions + .iter() + .any(|execution| matches!(execution.id.as_str(), "A" | "B"))); +} +fn compile_error(v: Value) -> GraphError { + super::compile(serde_json::from_value(v).unwrap()).unwrap_err() +} +fn execution<'a>(p: &'a CompiledGraph, authored_id: &str) -> &'a CompiledExecution { + p.executions.iter().find(|e| e.id == authored_id).unwrap() +} +fn resource_by_origin<'a>(p: &'a CompiledGraph, node: &str, socket: &str) -> &'a CompiledResource { + p.resources + .iter() + .find(|r| r.origin.node == node && r.origin.socket == socket) + .unwrap() +} + +fn family_by_source<'a>(p: &'a CompiledGraph, node: &str) -> &'a TextureFamily { + let source = resource_by_origin(p, node, "spec"); + let family = match source.plan { + ResourcePlan::TextureSpec { family, .. } => family, + _ => panic!("{node} is not a texture specification"), + }; + &p.texture_families[family as usize] +} + +fn allocation_slot<'a>(p: &'a CompiledGraph, allocation: AllocationRef) -> &'a AllocationSlot { + &p.allocation_classes[allocation.class as usize].slots[allocation.slot as usize] +} + +fn independent_depth_graph( + depth_specs: Vec, + forwards: Vec, + present_from: &str, +) -> Value { + let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; + nodes.extend(render_support_nodes()); + nodes.extend(depth_specs); + nodes.extend(forwards); + nodes.push(node( + "present", + "present", + json!({}), + json!({"surface":input(present_from,"color")}), + )); + graph(nodes) +} + +fn depth_spec(id: &str, residency: &str) -> Value { + node( + id, + "texture_spec", + texture("depth32_float", residency), + json!({}), + ) +} + +#[test] +fn dense_lifetimes_exclude_authored_source_ordinals() { + let p = compile_graph(full_cull_graph()); + assert_eq!( + p.executions + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + ["cull", "query", "forward", "present"] ); - let error = compile_json(graph( - serde_json::json!([]), - serde_json::json!([invalid]), - serde_json::json!([]), - )) - .unwrap_err(); - assert_eq!(error.code, "GRAPH_LIMIT_EXCEEDED"); -} - -#[test] -fn uninitialized_resource_precedes_transient_attachment_load_legality() { - let error = compile_json(graph( - serde_json::json!([ - transient("loaded", "rgba8_unorm"), - transient("uninitialized", "rgba8_unorm") - ]), - serde_json::json!([pass( - "conflicting_errors", - "observable", - serde_json::json!([sampled("missing_writer", "uninitialized")]), - serde_json::json!([color_load("loaded", "loaded", 0)]) - )]), - serde_json::json!([]), - )) - .unwrap_err(); - assert_eq!(error.code, "GRAPH_UNINITIALIZED_RESOURCE"); -} - -#[test] -fn malformed_resource_reference_ids_precede_resolution() { - for bindings in ["reads", "writes"] { - let mut invalid = pass( - "p", - "unknown_executor", - serde_json::json!([]), - serde_json::json!([]), + for (node, socket, first, last) in [ + ("scene", "scene", 0, 2), + ("aabbs", "localAabbs", 0, 0), + ("frustum", "frustum", 0, 0), + ("visible", "flags", 1, 1), + ("depth_config", "config", 2, 2), + ] { + assert_eq!( + resource_by_origin(&p, node, socket).lifetime, + Some(Lifetime { + first_use: first, + last_use: last + }), + "lifetime for {node}.{socket}" ); - invalid[bindings] = if bindings == "reads" { - serde_json::json!([sampled("input", "1bad")]) - } else { - serde_json::json!([copy_write("output", "1bad")]) + } + let color = resource_by_origin(&p, "forward", "color"); + let depth = resource_by_origin(&p, "forward", "depth"); + assert_eq!(color.producer_execution, Some(2)); + assert_eq!(depth.producer_execution, Some(2)); + assert_eq!( + color.lifetime, + Some(Lifetime { + first_use: 2, + last_use: 3 + }) + ); + assert_eq!( + depth.lifetime, + Some(Lifetime { + first_use: 2, + last_use: 2 + }) + ); + let depth_family = family_by_source(&p, "depth"); + assert_eq!( + depth_family.lifetime, + Lifetime { + first_use: 2, + last_use: 2 + } + ); + assert_eq!(depth_family.versions[0].lifetime, depth_family.lifetime); +} + +#[test] +fn transient_aliasing_is_declaration_order_independent() { + let p = compile_graph(independent_depth_graph( + vec![ + depth_spec("depth_second", "transient"), + depth_spec("depth_first", "transient"), + ], + vec![ + forward( + "F0", + input("surface", "surface"), + input("depth_first", "spec"), + ), + forward("F1", input("F0", "color"), input("depth_second", "spec")), + ], + "F1", + )); + assert_eq!(execution(&p, "F1").original_node_index, 8); + let f0_ordinal = p.executions.iter().position(|e| e.id == "F0").unwrap() as u32; + let f1_ordinal = p.executions.iter().position(|e| e.id == "F1").unwrap() as u32; + assert_eq!(f1_ordinal, f0_ordinal + 1); + let first = family_by_source(&p, "depth_first"); + let second = family_by_source(&p, "depth_second"); + assert_eq!( + first.lifetime, + Lifetime { + first_use: f0_ordinal, + last_use: f0_ordinal + } + ); + assert_eq!( + second.lifetime, + Lifetime { + first_use: f1_ordinal, + last_use: f1_ordinal + } + ); + assert_eq!(first.versions[0].lifetime, first.lifetime); + assert_eq!(second.versions[0].lifetime, second.lifetime); + assert_eq!(first.allocation, second.allocation); + let slot = allocation_slot(&p, first.allocation.unwrap()); + assert_eq!(slot.kind, AllocationKind::AliasedTransient); + assert_eq!(slot.usage, [TextureUsage::DepthAttachment]); + assert_eq!( + slot.occupants.iter().copied().collect::>(), + [first.id, second.id].into_iter().collect() + ); +} + +#[test] +fn overlapping_family_lifetimes_prevent_transient_reuse() { + let p = compile_graph(independent_depth_graph( + vec![ + depth_spec("depth_a", "transient"), + depth_spec("depth_b", "transient"), + ], + vec![ + forward("F0", input("surface", "surface"), input("depth_a", "spec")), + forward("F1", input("F0", "color"), input("depth_b", "spec")), + forward("F2", input("F1", "color"), input("F0", "depth")), + ], + "F2", + )); + let a = family_by_source(&p, "depth_a"); + let b = family_by_source(&p, "depth_b"); + assert!(a.lifetime.first_use < b.lifetime.first_use); + assert!(a.lifetime.last_use > b.lifetime.last_use); + assert_ne!(a.allocation, b.allocation); + assert_ne!(a.allocation.unwrap().slot, b.allocation.unwrap().slot); +} + +#[test] +fn persistent_textures_are_dedicated_and_follow_transient_slots() { + let p = compile_graph(independent_depth_graph( + vec![ + depth_spec("persistent_b", "persistent"), + depth_spec("transient", "transient"), + depth_spec("persistent_a", "persistent"), + ], + vec![ + forward( + "F0", + input("surface", "surface"), + input("persistent_a", "spec"), + ), + forward("F1", input("F0", "color"), input("transient", "spec")), + forward("F2", input("F1", "color"), input("persistent_b", "spec")), + ], + "F2", + )); + let a = family_by_source(&p, "persistent_a"); + let b = family_by_source(&p, "persistent_b"); + assert_ne!(a.allocation, b.allocation); + for family in [a, b] { + let allocation = family.allocation.unwrap(); + assert_eq!( + allocation_slot(&p, allocation).kind, + AllocationKind::Persistent + ); + assert_eq!(family.usage, [TextureUsage::DepthAttachment]); + for version in &family.versions { + let ResourcePlan::Texture { + allocation: resource_allocation, + .. + } = p.resources[version.resource as usize].plan + else { + panic!() + }; + assert_eq!(resource_allocation, Some(allocation)); + } + } + let transient = family_by_source(&p, "transient").allocation.unwrap(); + assert!(transient.slot < a.allocation.unwrap().slot); + assert!(transient.slot < b.allocation.unwrap().slot); + assert_eq!(p.transient_slot_count, 1); +} + +#[test] +fn exact_texture_compatibility_separates_allocation_classes() { + let mut different = texture("depth32_float", "transient"); + different["texture"]["mipLevelCount"] = json!(2); + let p = compile_graph(independent_depth_graph( + vec![ + depth_spec("relative", "transient"), + node("absolute", "texture_spec", different, json!({})), + ], + vec![ + forward("F0", input("surface", "surface"), input("relative", "spec")), + forward("F1", input("F0", "color"), input("absolute", "spec")), + ], + "F1", + )); + let relative = family_by_source(&p, "relative").allocation.unwrap(); + let absolute = family_by_source(&p, "absolute").allocation.unwrap(); + assert_ne!(relative.class, absolute.class); + assert_ne!(relative, absolute); +} + +#[test] +fn parser_and_registry_accept_only_canonical_schema() { + let bytes = + serde_json::to_vec(&json!({"schemaVersion":2,"graphId":"empty","revision":1,"nodes":[]})) + .unwrap(); + assert!(parse_and_compile(&bytes).is_ok()); + assert_eq!( + parse_and_compile(br#"{"schemaVersion":1}"#) + .unwrap_err() + .code, + "GRAPH_SCHEMA_UNSUPPORTED" + ); + let mut r = Registry::default(); + let (id, _) = r.compile(&bytes).unwrap(); + assert_eq!(r.get(id).unwrap().graph_id, "empty"); +} + +#[test] +fn authoritative_eleven_node_graph_lowers_exactly() { + let p = compile_graph(full_cull_graph()); + assert_eq!(p.node_count, 11); + assert_eq!(p.resources.len(), 11); + assert_eq!( + p.executions + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + ["cull", "query", "forward", "present"] + ); + for resource in &p.resources { + if let ResourcePlan::Texture { version, .. } = resource.plan { + assert_eq!(version, 0, "every first produced texture is symbolic v0"); + } + } + assert!(p.executions.iter().all(|e| !matches!( + e.executor.key.as_str(), + "surface_target" | "texture_spec" | "scene_table" + ))); +} + +#[test] +fn exact_wire_catalog_rejections() { + let cases = [ + ("local_aabb", 0, "GRAPH_UNKNOWN_EXECUTOR"), + ("frustum", 4, "GRAPH_UNKNOWN_EXECUTOR"), + ("cull", 6, "GRAPH_UNKNOWN_EXECUTOR"), + ]; + for (key, i, code) in cases { + let mut g = full_cull_graph(); + g["nodes"][i]["executor"]["key"] = json!(key); + assert_eq!(compile_error(g).code, code); + } + for field in ["clearDepth", "clearColor"] { + let mut g = full_cull_graph(); + let i = if field == "clearDepth" { 8 } else { 9 }; + g["nodes"][i]["parameters"] + .as_object_mut() + .unwrap() + .remove(field); + assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); + } + let mut g = full_cull_graph(); + g["nodes"][0]["executor"]["version"] = json!(2); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_EXECUTOR_VERSION_UNSUPPORTED"); + assert_eq!(e.details["path"], "nodes[0].executor.version"); +} + +#[test] +fn mesh_filters_are_closed_and_any_removes_dependency() { + let p = compile_graph(full_cull_graph()); + let NormalizedParameters::MeshQuery { filters } = execution(&p, "query").parameters.clone() + else { + panic!() + }; + assert_eq!( + filters.map(|f| f.flag), + [MeshFlag::IsVisible, MeshFlag::IsFrustumCulled] + ); + for filters in [ + json!([{"flag":"isVisible","predicate":"any"}]), + json!([{"flag":"isVisible","predicate":"any"},{"flag":"isVisible","predicate":"required_true"}]), + json!([{"flag":"bogus","predicate":"any"},{"flag":"isVisible","predicate":"any"}]), + ] { + let mut g = full_cull_graph(); + g["nodes"][7]["parameters"]["filters"] = filters; + assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); + } + let mut g = full_cull_graph(); + // The authored order is [culled, visible], while normalization is catalog order. + g["nodes"][7]["parameters"]["filters"][0]["predicate"] = json!("any"); + let p = compile_graph(g); + let q = execution(&p, "query"); + assert!(!q.inputs.iter().any(|x| x.socket == "isFrustumCulled")); + assert!(!p.executions.iter().any(|e| e.id == "cull")); + assert!(!p + .resources + .iter() + .any(|r| r.origin.node == "cull" && r.origin.socket == "flags")); +} + +#[test] +fn provenance_and_lowering_are_consistent() { + let p = compile_graph(full_cull_graph()); + let scene = resource_by_origin(&p, "scene", "scene"); + for id in ["aabbs", "visible", "cull", "query"] { + let r = p.resources.iter().find(|r| r.origin.node == id).unwrap(); + match r.plan { + ResourcePlan::LocalAabbBuffer { scene: s } + | ResourcePlan::BooleanFlagBuffer { scene: s, .. } + | ResourcePlan::DrawStream { scene: s } => { + assert_eq!( + s, + p.resources + .iter() + .position(|r| std::ptr::eq(r, scene)) + .unwrap() as u32 + ) + } + _ => {} + } + } + let f = execution(&p, "forward"); + let color_in = f + .inputs + .iter() + .find(|x| x.socket == "colorTarget") + .unwrap() + .resource; + let color_out = f + .outputs + .iter() + .find(|x| x.socket == "color") + .unwrap() + .resource; + assert_ne!(color_in, color_out); + assert!(f + .accesses + .iter() + .any(|a| a.resource == color_out && matches!(a.mode, AccessMode::ColorAttachment { .. }))); + assert!(!f + .accesses + .iter() + .any(|a| a.resource == color_in && matches!(a.mode, AccessMode::ColorAttachment { .. }))); + for (socket, expected) in [ + ("scene", AccessMode::SemanticRead), + ("draws", AccessMode::IndirectRead), + ] { + let access = f.accesses.iter().find(|a| a.socket == socket).unwrap(); + assert_eq!(access.mode, expected, "legacy_forward {socket} access"); + } +} + +#[test] +fn descriptor_validation_and_normalization_table() { + for (field, value) in [("sampleCount", json!(3))] { + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["texture"][field] = value; + assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); + } + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["texture"]["extent"] = + json!({"kind":"absolute","width":1,"height":1,"depthOrArrayLayers":1}); + g["nodes"][1]["parameters"]["texture"]["mipLevelCount"] = json!(2); + assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); + + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["texture"]["mipLevelCount"] = json!(99); + assert_eq!( + resource_by_origin(&compile_graph(g), "depth", "spec").semantic_type, + SemanticType::TextureSpec + ); + for residency in ["history", "readback"] { + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["residency"] = json!(residency); + assert_eq!(compile_error(g).code, "GRAPH_UNSUPPORTED_FEATURE"); + } + let p = compile_graph(full_cull_graph()); + let surface = p + .texture_families + .iter() + .find(|f| matches!(f.source, TextureFamilySource::ImportedSurface { .. })) + .unwrap(); + assert!(surface.allocation.is_none()); +} + +#[test] +fn validation_precedence_and_identifier_limits() { + let mut g = full_cull_graph(); + g["nodes"][0]["id"] = json!("x".repeat(65)); + g["nodes"][1]["executor"]["key"] = json!("bad"); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_LIMIT_EXCEEDED"); + assert_eq!(e.details["path"], "nodes[0].id"); + let mut g = full_cull_graph(); + g["nodes"][0]["id"] = json!("bad id"); + assert_eq!(compile_error(g).code, "GRAPH_INVALID_ID"); + let mut g = full_cull_graph(); + g["nodes"][1]["executor"]["key"] = json!("bad"); + g["nodes"][0]["parameters"] = json!({"bad":1}); + assert_eq!(compile_error(g).details["path"], "nodes[1].executor.key"); +} + +#[test] +fn global_identifier_lengths_precede_grammar_and_duplicates() { + let mut invalid_grammar = full_cull_graph(); + invalid_grammar["nodes"][0]["id"] = json!("bad id"); + invalid_grammar["nodes"][10]["executor"]["key"] = json!("x".repeat(65)); + let error = compile_error(invalid_grammar); + assert_eq!(error.code, "GRAPH_LIMIT_EXCEEDED"); + assert_eq!(error.details["path"], "nodes[10].executor.key"); + + let mut duplicate = full_cull_graph(); + duplicate["nodes"][1]["id"] = duplicate["nodes"][0]["id"].clone(); + duplicate["nodes"][10]["executor"]["key"] = json!("x".repeat(65)); + let error = compile_error(duplicate); + assert_eq!(error.code, "GRAPH_LIMIT_EXCEEDED"); + assert_eq!(error.details["path"], "nodes[10].executor.key"); +} + +#[test] +fn strict_mesh_diagnostics_and_inactive_any_edges() { + let cases = [ + ( + json!([{"flag":"bogus","predicate":"any"},{"flag":"isVisible","predicate":"any"}]), + "nodes[7].parameters.filters[0].flag", + ), + ( + json!([{"flag":"isFrustumCulled","predicate":"nope"},{"flag":"isVisible","predicate":"any"}]), + "nodes[7].parameters.filters[0].predicate", + ), + ( + json!([{"flag":"isVisible","predicate":"any"},{"flag":"isVisible","predicate":"required_true"}]), + "nodes[7].parameters.filters[1].flag", + ), + ( + json!([{"flag":"isVisible","predicate":"any"}]), + "nodes[7].parameters.filters", + ), + ]; + for (filters, path) in cases { + let mut g = full_cull_graph(); + g["nodes"][7]["parameters"]["filters"] = filters; + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_PARAMETERS_INVALID"); + assert_eq!(e.details["path"], path); + } + for predicate in ["required_true", "required_false"] { + let mut g = full_cull_graph(); + g["nodes"][7]["parameters"]["filters"][0]["predicate"] = json!(predicate); + g["nodes"][7]["inputs"] + .as_object_mut() + .unwrap() + .remove("isFrustumCulled"); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_SOCKET_CARDINALITY"); + assert_eq!(e.details["path"], "nodes[7].inputs.isFrustumCulled"); + } + let mut g = full_cull_graph(); + g["nodes"][7]["inputs"]["isVisible"] = input("cull", "flags"); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); + assert_eq!(e.details["path"], "nodes[7].inputs.isVisible"); + + let mut g = full_cull_graph(); + g["nodes"][7]["parameters"]["filters"][0]["predicate"] = json!("any"); + let p = compile_graph(g); + let q = execution(&p, "query"); + assert!(!q.inputs.iter().any(|i| i.socket == "isFrustumCulled")); + assert!(!q.accesses.iter().any(|a| a.socket == "isFrustumCulled")); + assert!(!p.executions.iter().any(|e| e.id == "cull")); +} + +#[test] +fn transitive_scene_roots_are_vector_resource_ids() { + let p = compile_graph(full_cull_graph()); + let scene_id = p + .resources + .iter() + .position(|r| r.origin.node == "scene") + .unwrap() as u32; + for origin in ["aabbs", "visible", "cull", "query"] { + let resource = p + .resources + .iter() + .find(|r| r.origin.node == origin) + .unwrap(); + let rooted = match resource.plan { + ResourcePlan::LocalAabbBuffer { scene } + | ResourcePlan::BooleanFlagBuffer { scene, .. } + | ResourcePlan::DrawStream { scene } => Some(scene), + _ => None, }; - let error = compile_json(graph( - serde_json::json!([]), - serde_json::json!([invalid]), - serde_json::json!([]), - )) - .unwrap_err(); - assert_eq!(error.code, "GRAPH_INVALID_ID"); + assert_eq!(rooted, Some(scene_id)); + } + let mut g = full_cull_graph(); + g["nodes"] + .as_array_mut() + .unwrap() + .push(node("sceneB", "scene_table", json!({}), json!({}))); + g["nodes"][3]["inputs"]["scene"] = input("sceneB", "scene"); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); + assert_eq!(e.details["path"], "nodes[6].inputs.localAabbs"); + let mut g = full_cull_graph(); + g["nodes"] + .as_array_mut() + .unwrap() + .push(node("sceneB", "scene_table", json!({}), json!({}))); + g["nodes"][3]["inputs"]["scene"] = input("sceneB", "scene"); + g["nodes"][5]["inputs"]["scene"] = input("sceneB", "scene"); + g["nodes"][6]["inputs"]["scene"] = input("sceneB", "scene"); + g["nodes"][7]["inputs"]["scene"] = input("sceneB", "scene"); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); + assert_eq!(e.details["path"], "nodes[9].inputs.draws"); +} + +#[test] +fn descriptor_exact_paths_and_normalization() { + let cases = [ + ("sampleCount", json!(2), "sampleCount"), + ("sampleCount", json!(8), "sampleCount"), + ("mipLevelCount", json!(0), "mipLevelCount"), + ]; + for (field, value, suffix) in cases { + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["texture"][field] = value; + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_PARAMETERS_INVALID"); + assert_eq!( + e.details["path"], + format!("nodes[1].parameters.texture.{suffix}") + ); + } + for (view, index) in [("depth32_float", 0), ("rgba8_unorm", 0)] { + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["texture"]["viewFormats"] = json!([view]); + let e = compile_error(g); + assert_eq!( + e.details["path"], + format!("nodes[1].parameters.texture.viewFormats[{index}]") + ); + } + for residency in ["history", "readback"] { + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["residency"] = json!(residency); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_UNSUPPORTED_FEATURE"); + assert_eq!(e.details["path"], "nodes[1].parameters.residency"); + } + let mut g = full_cull_graph(); + let d = &mut g["nodes"][1]["parameters"]["texture"]; + d["extent"]["width"] = json!({"numerator":2,"denominator":2}); + let p = compile_graph(g); + let TextureFamilySource::AuthoredTexture { descriptor, .. } = + &family_by_source(&p, "depth").source + else { + panic!() + }; + assert!( + matches!(&descriptor.extent, NormalizedTextureExtent::SurfaceRelative { width, .. } if *width == Ratio { numerator: 1, denominator: 1 }) + ); +} + +#[test] +fn descriptor_multi_error_precedence_is_exact() { + let cases = [ + (json!(3), json!(0), 9000, "sampleCount"), + (json!(1), json!(0), 9000, "mipLevelCount"), + (json!(1), json!(30), 9000, "mipLevelCount"), + (json!(1), json!(14), 9000, "extent"), + (json!(1), json!(14), 8192, "viewFormats[0]"), + ]; + for (sample_count, mip_count, width, expected) in cases { + let mut g = full_cull_graph(); + let d = &mut g["nodes"][1]["parameters"]["texture"]; + d["format"] = json!("rgba8_unorm"); + d["extent"] = json!({ + "kind":"absolute", + "width":width, + "height":1, + "depthOrArrayLayers":1 + }); + d["sampleCount"] = sample_count; + d["mipLevelCount"] = mip_count; + d["viewFormats"] = json!(["rgba8_unorm"]); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_PARAMETERS_INVALID"); + assert_eq!( + e.details["path"], + format!("nodes[1].parameters.texture.{expected}") + ); + } +} + +#[test] +fn global_raw_limits_have_stable_narrow_paths() { + for (g, path) in [ + ( + { + let mut g = full_cull_graph(); + g["graphId"] = json!("x".repeat(65)); + g + }, + "graphId", + ), + ( + { + let mut g = full_cull_graph(); + g["nodes"][0]["executor"]["key"] = json!("x".repeat(65)); + g + }, + "nodes[0].executor.key", + ), + ( + { + let mut g = full_cull_graph(); + g["nodes"][9]["inputs"]["x".repeat(65)] = input("scene", "scene"); + g + }, + "nodes[9].inputs.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + ), + ( + { + let mut g = full_cull_graph(); + g["nodes"][9]["inputs"]["scene"]["node"] = json!("x".repeat(65)); + g + }, + "nodes[9].inputs.scene.node", + ), + ( + { + let mut g = full_cull_graph(); + g["nodes"][9]["inputs"]["scene"]["socket"] = json!("x".repeat(65)); + g + }, + "nodes[9].inputs.scene.socket", + ), + ] { + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_LIMIT_EXCEEDED"); + assert_eq!(e.details["path"], path); + } + let mut inputs = serde_json::Map::new(); + for i in 0..8193 { + inputs.insert(format!("s{i}"), input("n", "x")); + } + let g = graph(vec![node( + "n", + "surface_target", + json!({}), + Value::Object(inputs), + )]); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_LIMIT_EXCEEDED"); + assert_eq!(e.details["path"], "nodes[0].inputs"); +} + +#[test] +fn empty_plan_has_no_lowered_objects() { + let p = compile_graph(json!({"schemaVersion":2,"graphId":"empty","revision":1,"nodes":[]})); + assert_eq!( + ( + p.node_count, + p.resources.len(), + p.executions.len(), + p.texture_families.len(), + p.allocation_classes.len() + ), + (0, 0, 0, 0, 0) + ); +} + +#[test] +fn registry_revision_handles_are_immutable_and_drop_is_transactional() { + let bytes = |revision| { + serde_json::to_vec( + &json!({"schemaVersion":2,"graphId":"registry","revision":revision,"nodes":[]}), + ) + .unwrap() + }; + let mut r = Registry::new(2); + let (id, _) = r.compile(&bytes(1)).unwrap(); + assert_eq!( + r.compile(&bytes(1)).unwrap_err().message, + "revision must increase" + ); + let (second, _) = r.compile(&bytes(2)).unwrap(); + assert_ne!(id, second); + assert_eq!(r.get(id).unwrap().revision, 1); + r.drop_graph(id).unwrap(); + assert_eq!(r.get(id).unwrap_err().code, "STALE_GRAPH_ID"); + let (next, _) = r.compile(&bytes(3)).unwrap(); + assert_ne!(id, next); + assert!(r.get(second).is_ok()); +} + +#[test] +fn legacy_schema_is_rejected() { + let legacy = br#"{"schemaVersion":1,"graphId":"legacy","revision":1,"nodes":[]}"#; + assert_eq!( + parse_and_compile(legacy).unwrap_err().code, + "GRAPH_SCHEMA_UNSUPPORTED" + ); +} + +#[test] +fn socket_validation_is_globally_phased() { + let mut g = full_cull_graph(); + g["nodes"][3]["inputs"] + .as_object_mut() + .unwrap() + .remove("scene"); + g["nodes"][9]["inputs"]["bogus"] = input("scene", "scene"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_UNKNOWN_SOCKET", Some("nodes[9].inputs.bogus")) + ); + + let mut g = full_cull_graph(); + g["nodes"][3]["inputs"]["scene"] = input("frustum", "frustum"); + g["nodes"][9]["inputs"]["colorTarget"]["socket"] = json!("bogus"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ( + "GRAPH_UNKNOWN_SOCKET", + Some("nodes[9].inputs.colorTarget.socket") + ) + ); + + let mut g = full_cull_graph(); + g["nodes"][3]["inputs"]["scene"] = input("frustum", "frustum"); + g["nodes"][9]["inputs"] + .as_object_mut() + .unwrap() + .remove("draws"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_SOCKET_CARDINALITY", Some("nodes[9].inputs.draws")) + ); + + let mut g = full_cull_graph(); + g["nodes"][3]["inputs"]["scene"] = input("frustum", "frustum"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_SOCKET_TYPE_MISMATCH", Some("nodes[3].inputs.scene")) + ); +} + +#[test] +fn executor_version_parameter_and_state_precedence_is_global() { + let mut g = full_cull_graph(); + g["nodes"][2]["inputs"]["bad"] = input("missing", "bad"); + g["nodes"][10]["executor"]["key"] = json!("unknown"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_UNKNOWN_NODE", Some("nodes[2].inputs.bad.node")) + ); + + let mut g = full_cull_graph(); + g["nodes"][0]["parameters"] = json!({"bad":1}); + g["nodes"][1]["state"] = json!("muted"); + g["nodes"][2]["inputs"]["bad"] = input("scene", "bad"); + g["nodes"][10]["executor"]["key"] = json!("unknown"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_UNKNOWN_EXECUTOR", Some("nodes[10].executor.key")) + ); + + let mut g = full_cull_graph(); + g["nodes"][0]["parameters"] = json!({"bad":1}); + g["nodes"][1]["state"] = json!("muted"); + g["nodes"][2]["inputs"]["bad"] = input("scene", "bad"); + g["nodes"][10]["executor"]["version"] = json!(2); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ( + "GRAPH_EXECUTOR_VERSION_UNSUPPORTED", + Some("nodes[10].executor.version") + ) + ); + + let mut g = full_cull_graph(); + g["nodes"][0]["parameters"] = json!({"bad":1}); + g["nodes"][1]["state"] = json!("muted"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_PARAMETERS_INVALID", Some("nodes[0].parameters")) + ); +} + +#[test] +fn attachment_compatibility_matrix_is_enforced() { + let cases = [ + ( + "depth_surface", + json!({"kind":"absolute","width":4,"height":4,"depthOrArrayLayers":1}), + "depth32_float", + "d2", + 1, + ), + ( + "depth_half", + json!({"kind":"surface_relative","width":{"numerator":1,"denominator":2},"height":{"numerator":1,"denominator":2},"depthOrArrayLayers":1}), + "depth32_float", + "d2", + 1, + ), + ( + "depth_layers", + json!({"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":2}), + "depth32_float", + "d2", + 1, + ), + ( + "depth_format", + json!({"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":1}), + "rgba8_unorm", + "d2", + 1, + ), + ]; + for (_, extent, format, dimension, samples) in cases { + let mut g = full_cull_graph(); + let d = &mut g["nodes"][1]["parameters"]["texture"]; + d["extent"] = extent; + d["format"] = json!(format); + d["dimension"] = json!(dimension); + d["sampleCount"] = json!(samples); + assert_eq!(compile_error(g).code, "GRAPH_ILLEGAL_ACCESS"); } - let error = compile_json(graph( - serde_json::json!([]), - serde_json::json!([]), - serde_json::json!([{"name":"present", "resource":resource_ref("1bad")}]), - )) - .unwrap_err(); - assert_eq!(error.code, "GRAPH_INVALID_ID"); + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"] = texture("rgba8_unorm", "transient"); + g["nodes"][9]["inputs"]["colorTarget"] = input("depth", "spec"); + g["nodes"][9]["inputs"]["depthTarget"] = input("surface", "surface"); + assert_eq!(compile_error(g).code, "GRAPH_ILLEGAL_ACCESS"); + + for field in ["dimension", "extent", "sampleCount"] { + let mut g = full_cull_graph(); + let mut color = texture("rgba8_unorm", "transient"); + match field { + "dimension" => { + color["texture"][field] = json!("d3"); + color["texture"]["extent"] = + json!({"kind":"absolute","width":1,"height":1,"depthOrArrayLayers":1}); + } + "extent" => { + color["texture"][field] = + json!({"kind":"absolute","width":4,"height":4,"depthOrArrayLayers":1}) + } + _ => color["texture"][field] = json!(4), + } + g["nodes"] + .as_array_mut() + .unwrap() + .insert(2, node("color", "texture_spec", color, json!({}))); + g["nodes"][10]["inputs"]["colorTarget"] = input("color", "spec"); + assert_eq!(compile_error(g).code, "GRAPH_ILLEGAL_ACCESS", "{field}"); + } + + let mut g = full_cull_graph(); + g["nodes"].as_array_mut().unwrap().insert( + 2, + node( + "color", + "texture_spec", + texture("rgba8_unorm", "transient"), + json!({}), + ), + ); + g["nodes"][10]["inputs"]["colorTarget"] = input("color", "spec"); + let e = compile_error(g); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_ILLEGAL_ACCESS", Some("nodes[11].inputs.surface")) + ); +} + +#[test] +fn wire_rejects_old_and_unknown_fields_exactly() { + let mut cases = Vec::new(); + let mut g = full_cull_graph(); + g["nodes"][1]["parameters"]["descriptor"] = g["nodes"][1]["parameters"]["texture"].take(); + cases.push(g); + for old in ["compare", "writeEnabled", "clear"] { + let mut g = full_cull_graph(); + g["nodes"][8]["parameters"][old] = json!(1); + cases.push(g); + } + for missing in ["clearDepth", "clearColor"] { + let mut g = full_cull_graph(); + let i = if missing == "clearDepth" { 8 } else { 9 }; + g["nodes"][i]["parameters"] + .as_object_mut() + .unwrap() + .remove(missing); + cases.push(g); + } + for g in cases { + assert_eq!( + parse_and_compile(&serde_json::to_vec(&g).unwrap()) + .unwrap_err() + .code, + "GRAPH_PARAMETERS_INVALID" + ); + } + for (index, field) in [(0, "legacyOptional"), (0, "unknownNodeField")] { + let mut g = full_cull_graph(); + g["nodes"][index][field] = json!(null); + assert_eq!( + parse_and_compile(&serde_json::to_vec(&g).unwrap()) + .unwrap_err() + .code, + "GRAPH_JSON_INVALID" + ); + } + let mut g = full_cull_graph(); + g["unknownGraphField"] = json!(true); + assert_eq!( + parse_and_compile(&serde_json::to_vec(&g).unwrap()) + .unwrap_err() + .code, + "GRAPH_JSON_INVALID" + ); +} + +#[test] +fn raw_limits_precede_malformed_content_and_cover_live_resources() { + let mut g = graph( + (0..1025) + .map(|i| node(&format!("n{i}"), "surface_target", json!({}), json!({}))) + .collect(), + ); + g["graphId"] = json!("bad id"); + assert_eq!(compile_error(g).details["path"], "nodes"); + let mut nodes: Vec<_> = (0..65) + .map(|i| { + node( + &format!("p{i}"), + "present", + json!({}), + json!({"surface":input("missing","bad")}), + ) + }) + .collect(); + assert_eq!( + compile_error(graph(std::mem::take(&mut nodes))).details["path"], + "nodes" + ); + let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; + nodes.extend(render_support_nodes()); + let mut color = input("surface", "surface"); + for i in 0..508 { + let d = format!("d{i}"); + let f = format!("f{i}"); + nodes.push(node( + &d, + "texture_spec", + texture("depth32_float", "transient"), + json!({}), + )); + nodes.push(forward(&f, color, input(&d, "spec"))); + color = input(&f, "color"); + } + nodes.push(node( + "present", + "present", + json!({}), + json!({"surface":color}), + )); + let e = compile_error(graph(nodes)); + assert_eq!( + (e.code, e.details["path"].as_str()), + ("GRAPH_LIMIT_EXCEEDED", Some("resources")) + ); +} + +#[test] +fn generated_resource_limit_is_only_final() { + fn oversized(old_present: bool) -> Value { + let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; + nodes.extend(render_support_nodes()); + let mut color = input("surface", "surface"); + for i in 0..508 { + let d = format!("d{i}"); + let f = format!("f{i}"); + nodes.push(node( + &d, + "texture_spec", + texture("depth32_float", "transient"), + json!({}), + )); + nodes.push(forward(&f, color, input(&d, "spec"))); + color = input(&f, "color"); + } + nodes.push(node( + "present", + "present", + json!({}), + json!({"surface":color}), + )); + if old_present { + nodes.push(node( + "old_present", + "present", + json!({}), + json!({"surface":input("f0","color")}), + )); + } + assert!(nodes.len() <= 1024); + graph(nodes) + } + + let clean = compile_error(oversized(false)); + assert_eq!( + (clean.code, clean.details["path"].as_str()), + ("GRAPH_LIMIT_EXCEEDED", Some("resources")) + ); + let polluted = compile_error(oversized(true)); + assert_eq!(polluted.code, "GRAPH_RESOURCE_VERSION_INVALID"); + assert_eq!(polluted.details["path"], "nodes[1022].inputs.surface"); +} + +#[test] +fn bloom_composite_rejects_each_stale_sampled_texture_version() { + for stale_socket in ["source", "bloom"] { + let mut half = texture("rgba16_float", "transient"); + half["texture"]["extent"]["width"] = json!({"numerator":1,"denominator":2}); + half["texture"]["extent"]["height"] = json!({"numerator":1,"denominator":2}); + let mut half_depth = texture("depth32_float", "transient"); + half_depth["texture"]["extent"] = half["texture"]["extent"].clone(); + let mut nodes = vec![ + node("surface", "surface_target", json!({}), json!({})), + node( + "source_target", + "texture_spec", + texture("rgba16_float", "transient"), + json!({}), + ), + node("bloom_target", "texture_spec", half, json!({})), + node( + "output", + "texture_spec", + texture("rgba16_float", "transient"), + json!({}), + ), + depth_spec("source_depth_0", "transient"), + depth_spec("source_depth_1", "transient"), + node( + "bloom_depth_0", + "texture_spec", + half_depth.clone(), + json!({}), + ), + node("bloom_depth_1", "texture_spec", half_depth, json!({})), + ]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward("source_f0", input("source_target","spec"), input("source_depth_0","spec")), + forward("source_f1", input("source_f0","color"), input("source_depth_1","spec")), + forward("bloom_f0", input("bloom_target","spec"), input("bloom_depth_0","spec")), + forward("bloom_f1", input("bloom_f0","color"), input("bloom_depth_1","spec")), + node( + "composite", + "bloom_composite", + json!({"intensity":1.0}), + json!({ + "source":input(if stale_socket == "source" { "source_f0" } else { "source_f1" },"color"), + "bloom":input(if stale_socket == "bloom" { "source_f0" } else { "source_f1" },"color"), + "colorTarget":input("output","spec") + }), + ), + node( + "to_surface", + "fullscreen_copy", + json!({}), + json!({"source":input("composite","color"),"colorTarget":input("surface","surface")}), + ), + node( + "present", + "present", + json!({}), + json!({"surface":input("to_surface","color")}), + ), + ]); + let error = compile_error(graph(nodes)); + assert_eq!(error.code, "GRAPH_RESOURCE_VERSION_INVALID"); + assert_eq!( + error.details["path"], + format!("nodes[16].inputs.{stale_socket}") + ); + } +} + +#[test] +fn bloom_composite_requires_a_single_view_rgba16_half_resolution_bloom() { + let mut half = texture("rgba16_float", "transient"); + half["texture"]["extent"]["width"] = json!({"numerator":1,"denominator":2}); + half["texture"]["extent"]["height"] = json!({"numerator":1,"denominator":2}); + let make_graph = || { + let mut bloom_depth = texture("depth32_float", "transient"); + bloom_depth["texture"]["extent"] = half["texture"]["extent"].clone(); + let mut nodes = vec![ + node("surface", "surface_target", json!({}), json!({})), + node( + "source", + "texture_spec", + texture("rgba16_float", "transient"), + json!({}), + ), + node("bloom", "texture_spec", half.clone(), json!({})), + node( + "target", + "texture_spec", + texture("rgba16_float", "transient"), + json!({}), + ), + depth_spec("source_depth", "transient"), + node("bloom_depth", "texture_spec", bloom_depth, json!({})), + ]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward( + "source_writer", + input("source", "spec"), + input("source_depth", "spec"), + ), + forward( + "bloom_writer", + input("bloom", "spec"), + input("bloom_depth", "spec"), + ), + node( + "composite", + "bloom_composite", + json!({"intensity":1.0}), + json!({"source":input("source_writer","color"),"bloom":input("bloom_writer","color"),"colorTarget":input("target","spec")}), + ), + node( + "to_surface", + "fullscreen_copy", + json!({}), + json!({"source":input("composite","color"),"colorTarget":input("surface","surface")}), + ), + node( + "present", + "present", + json!({}), + json!({"surface":input("to_surface","color")}), + ), + ]); + graph(nodes) + }; + compile_graph(make_graph()); + + let mut invalid = make_graph(); + invalid["nodes"][2]["parameters"]["texture"]["format"] = json!("rgba8_unorm"); + invalid["nodes"][2]["parameters"]["texture"]["mipLevelCount"] = json!(2); + let error = compile_error(invalid); + assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); + assert_eq!(error.details["path"], "nodes[12].inputs"); +} + +#[test] +fn fullscreen_copy_rejects_incompatible_authored_targets_at_copy_inputs() { + for (field, value, expected_code, expected_path) in [ + ( + "format", + json!("depth32_float"), + "GRAPH_ILLEGAL_ACCESS", + "nodes[9].inputs", + ), + ( + "dimension", + json!("d3"), + "GRAPH_PARAMETERS_INVALID", + "nodes[2].parameters.texture.extent", + ), + ( + "sampleCount", + json!(4), + "GRAPH_ILLEGAL_ACCESS", + "nodes[9].inputs", + ), + ( + "mipLevelCount", + json!(2), + "GRAPH_ILLEGAL_ACCESS", + "nodes[9].inputs", + ), + ] { + let mut target = texture("rgba16_float", "transient"); + target["texture"][field] = value; + let mut nodes = vec![ + node("surface", "surface_target", json!({}), json!({})), + node( + "source", + "texture_spec", + texture("rgba16_float", "transient"), + json!({}), + ), + node("target", "texture_spec", target, json!({})), + depth_spec("depth", "transient"), + ]; + nodes.extend(render_support_nodes()); + nodes.extend([ + forward("source_writer", input("source","spec"), input("depth","spec")), + node( + "copy", + "fullscreen_copy", + json!({}), + json!({"source":input("source_writer","color"),"colorTarget":input("target","spec")}), + ), + node( + "to_surface", + "fullscreen_copy", + json!({}), + json!({"source":input("copy","color"),"colorTarget":input("surface","surface")}), + ), + node( + "present", + "present", + json!({}), + json!({"surface":input("to_surface","color")}), + ), + ]); + let error = compile_error(graph(nodes)); + assert_eq!(error.code, expected_code, "field {field}"); + assert_eq!(error.details["path"], expected_path, "field {field}"); + } } diff --git a/renderer/src/render_graph/tests_v2.rs b/renderer/src/render_graph/tests_v2.rs deleted file mode 100644 index c1e834f..0000000 --- a/renderer/src/render_graph/tests_v2.rs +++ /dev/null @@ -1,1676 +0,0 @@ -use std::collections::BTreeSet; - -use super::*; -use serde_json::{json, Value}; - -fn input(node: &str, socket: &str) -> Value { - json!({"node":node,"socket":socket}) -} -fn node(id: &str, key: &str, parameters: Value, inputs: Value) -> Value { - json!({"id":id,"state":"enabled","executor":{"key":key,"version":1},"parameters":parameters,"inputs":inputs}) -} -fn texture(format: &str, residency: &str) -> Value { - json!({"texture":{"dimension":"d2","format":format,"extent":{"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":1},"mipLevelCount":1,"sampleCount":1,"viewFormats":[]},"residency":residency}) -} -fn full_cull_graph() -> Value { - json!({"schemaVersion":2,"graphId":"full","revision":1,"nodes":[ - node("surface","surface_target",json!({}),json!({})), - node("depth","texture_spec",texture("depth32_float","transient"),json!({})), - node("scene","scene_table",json!({}),json!({})), - node("aabbs","local_aabb_buffer",json!({}),json!({"scene":input("scene","scene")})), - node("frustum","camera_frustum",json!({}),json!({})), - node("visible","visibility_flags",json!({}),json!({"scene":input("scene","scene")})), - node("cull","frustum_cull",json!({}),json!({"scene":input("scene","scene"),"localAabbs":input("aabbs","localAabbs"),"frustum":input("frustum","frustum")})), - node("query","mesh_query",json!({"filters":[{"flag":"isFrustumCulled","predicate":"required_false"},{"flag":"isVisible","predicate":"required_true"}]}),json!({"scene":input("scene","scene"),"isVisible":input("visible","flags"),"isFrustumCulled":input("cull","flags")})), - node("depth_config","depth_stencil_config",json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0}),json!({})), - node("forward","legacy_forward",json!({"clearColor":[0,0,0,1]}),json!({"scene":input("scene","scene"),"draws":input("query","draws"),"colorTarget":input("surface","surface"),"depthTarget":input("depth","spec"),"depthStencil":input("depth_config","config")})), - node("present","present",json!({}),json!({"surface":input("forward","color")})) - ]}) -} -fn forward(id: &str, color: Value, depth: Value) -> Value { - node( - id, - "legacy_forward", - json!({"clearColor":[0,0,0,1]}), - json!({ - "scene":input("scene","scene"), - "draws":input("query","draws"), - "colorTarget":color, - "depthTarget":depth, - "depthStencil":input("depth_config","config") - }), - ) -} -fn render_support_nodes() -> Vec { - vec![ - node("scene", "scene_table", json!({}), json!({})), - node( - "visible", - "visibility_flags", - json!({}), - json!({"scene":input("scene","scene")}), - ), - node( - "query", - "mesh_query", - json!({"filters":[ - {"flag":"isVisible","predicate":"required_true"}, - {"flag":"isFrustumCulled","predicate":"any"} - ]}), - json!({"scene":input("scene","scene"),"isVisible":input("visible","flags")}), - ), - node( - "depth_config", - "depth_stencil_config", - json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0}), - json!({}), - ), - ] -} -fn graph(nodes: Vec) -> Value { - json!({"schemaVersion":2,"graphId":"hazards","revision":1,"nodes":nodes}) -} -fn hdr_copy_graph() -> Value { - let mut nodes = vec![ - node("surface", "surface_target", json!({}), json!({})), - node( - "hdr", - "texture_spec", - texture("rgba16_float", "transient"), - json!({}), - ), - depth_spec("depth", "transient"), - ]; - nodes.extend(render_support_nodes()); - nodes.extend([ - forward("forward", input("hdr", "spec"), input("depth", "spec")), - node( - "copy", - "fullscreen_copy", - json!({}), - json!({"source":input("forward","color"),"colorTarget":input("surface","surface")}), - ), - node( - "present", - "present", - json!({}), - json!({"surface":input("copy","color")}), - ), - ]); - graph(nodes) -} -fn cyclic_forwards() -> Vec { - vec![ - forward("A", input("B", "color"), input("B", "depth")), - forward("B", input("A", "color"), input("A", "depth")), - ] -} -fn compile(v: Value) -> CompiledGraphV2 { - compile_v2(serde_json::from_value(v).unwrap()).unwrap() -} - -#[test] -fn fullscreen_copy_hdr_graph_lowers_versions_accesses_and_usage() { - let p = compile(hdr_copy_graph()); - assert_eq!( - p.executions - .iter() - .map(|execution| execution.id.as_str()) - .collect::>(), - ["query", "forward", "copy", "present"] - ); - for (node, socket) in [ - ("forward", "color"), - ("forward", "depth"), - ("copy", "color"), - ] { - assert!(matches!( - resource_by_origin(&p, node, socket).plan, - ResourcePlanV2::Texture { version: 0, .. } - )); - } - let copy = execution(&p, "copy"); - let source = resource_by_origin(&p, "forward", "color"); - let color = resource_by_origin(&p, "copy", "color"); - assert!(copy.accesses.iter().any(|access| access.resource - == p.resources - .iter() - .position(|resource| std::ptr::eq(resource, source)) - .unwrap() as u32 - && access.mode == AccessModeV2::SampledTexture)); - let color_id = p - .resources - .iter() - .position(|resource| std::ptr::eq(resource, color)) - .unwrap() as u32; - assert!(matches!( - ©.kind, - ExecutionKindV2::Render { color_attachments, depth_stencil: None } - if color_attachments[0].resource == color_id - && color_attachments[0].load == NormalizedColorLoadV2::Clear { value: [0.0; 4] } - )); - assert!(copy.accesses.iter().any(|access| matches!( - access.mode, - AccessModeV2::ColorAttachment { - full_overwrite: true, - .. - } - ) && access.resource == color_id)); - let hdr = family_by_source(&p, "hdr"); - assert_eq!( - hdr.usage.iter().copied().collect::>(), - [TextureUsageV2::Sampled, TextureUsageV2::ColorAttachment] - .into_iter() - .collect() - ); - let surface = p - .texture_families - .iter() - .find(|family| matches!(family.source, TextureFamilySourceV2::ImportedSurface { .. })) - .unwrap(); - assert_eq!(surface.versions[0].resource, color_id); -} - -#[test] -fn fullscreen_copy_parameters_are_exactly_empty() { - let mut g = hdr_copy_graph(); - g["nodes"][8]["parameters"] = json!({"obsolete":true}); - assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); - assert_eq!(CONTRACTS_V2.len(), 17); -} - -#[test] -fn fullscreen_copy_rejects_same_source_and_target_family() { - let mut g = hdr_copy_graph(); - g["nodes"][8]["inputs"]["colorTarget"] = input("forward", "color"); - let error = compile_error(g); - assert_eq!(error.code, "GRAPH_SAME_PASS_HAZARD"); - assert_eq!(error.details["path"], "nodes[8].inputs"); -} - -#[test] -fn duplicate_texture_writer_reports_second_color_target() { - let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; - nodes.extend(render_support_nodes()); - nodes.extend([ - node( - "depth_a", - "texture_spec", - texture("depth32_float", "transient"), - json!({}), - ), - node( - "depth_b", - "texture_spec", - texture("depth32_float", "transient"), - json!({}), - ), - forward("F0", input("surface", "surface"), input("depth_a", "spec")), - node( - "P0", - "present", - json!({}), - json!({"surface":input("F0","color")}), - ), - forward("F1", input("surface", "surface"), input("depth_b", "spec")), - node( - "P1", - "present", - json!({}), - json!({"surface":input("F1","color")}), - ), - ]); - let error = compile_error(graph(nodes)); - assert_eq!(error.code, "GRAPH_DUPLICATE_WRITER"); - assert_eq!(error.details["path"], "nodes[9].inputs.colorTarget"); -} - -#[test] -fn same_output_bound_to_both_attachments_is_a_same_pass_hazard() { - let mut nodes = vec![node( - "target", - "texture_spec", - texture("rgba8_unorm", "transient"), - json!({}), - )]; - nodes.extend(render_support_nodes()); - nodes.extend([ - forward("F", input("target", "spec"), input("target", "spec")), - node( - "P", - "present", - json!({}), - json!({"surface":input("F","color")}), - ), - ]); - let error = compile_error(graph(nodes)); - assert_eq!(error.code, "GRAPH_SAME_PASS_HAZARD"); - assert_eq!(error.details["path"], "nodes[5].inputs"); -} - -#[test] -fn unordered_old_texture_version_read_is_rejected_before_scheduling() { - let mut nodes = vec![ - node("surface", "surface_target", json!({}), json!({})), - node( - "depth_0", - "texture_spec", - texture("depth32_float", "transient"), - json!({}), - ), - node( - "depth_1", - "texture_spec", - texture("depth32_float", "transient"), - json!({}), - ), - ]; - nodes.extend(render_support_nodes()); - nodes.extend([ - forward("F0", input("surface", "surface"), input("depth_0", "spec")), - forward("F1", input("F0", "color"), input("depth_1", "spec")), - node( - "P0", - "present", - json!({}), - json!({"surface":input("F0","color")}), - ), - node( - "P1", - "present", - json!({}), - json!({"surface":input("F1","color")}), - ), - ]); - let error = compile_error(graph(nodes)); - assert_eq!(error.code, "GRAPH_RESOURCE_VERSION_INVALID"); - assert_eq!(error.details["path"], "nodes[9].inputs.surface"); -} - -#[test] -fn duplicate_successors_defer_old_version_reachability() { - let mut nodes = vec![ - node("surface", "surface_target", json!({}), json!({})), - depth_spec("depth_0", "transient"), - depth_spec("depth_1", "transient"), - depth_spec("depth_2", "transient"), - ]; - nodes.extend(render_support_nodes()); - nodes.extend([ - forward("F0", input("surface", "surface"), input("depth_0", "spec")), - forward("F1", input("F0", "color"), input("depth_1", "spec")), - forward("F2", input("F0", "color"), input("depth_2", "spec")), - node( - "P0", - "present", - json!({}), - json!({"surface":input("F0","color")}), - ), - node( - "P1", - "present", - json!({}), - json!({"surface":input("F1","color")}), - ), - node( - "P2", - "present", - json!({}), - json!({"surface":input("F2","color")}), - ), - ]); - let error = compile_error(graph(nodes)); - assert_eq!(error.code, "GRAPH_DUPLICATE_WRITER"); - assert_eq!(error.details["path"], "nodes[10].inputs.colorTarget"); -} - -#[test] -fn live_texture_cycle_reports_the_exact_first_cycle() { - let mut nodes = render_support_nodes(); - nodes.extend(cyclic_forwards()); - nodes.push(node( - "present", - "present", - json!({}), - json!({"surface":input("A","color")}), - )); - let error = compile_error(graph(nodes)); - assert_eq!(error.code, "GRAPH_CYCLE"); - assert_eq!( - error.details, - json!({ - "message":"live graph contains a cycle", - "kind":"cycle", - "edges":[ - {"fromNode":"A","fromSocket":"color","toNode":"B","toSocket":"colorTarget","resource":{"node":"A","socket":"color"}}, - {"fromNode":"B","fromSocket":"color","toNode":"A","toSocket":"colorTarget","resource":{"node":"B","socket":"color"}} - ] - }) - ); -} - -#[test] -fn dead_texture_cycle_is_culled_without_cycle_execution() { - let mut value = full_cull_graph(); - value["nodes"] - .as_array_mut() - .unwrap() - .extend(cyclic_forwards()); - let plan = compile(value); - assert_eq!(plan.node_count, 13); - assert_eq!(plan.culled_node_count, 2); - assert_eq!(plan.culled_resource_count, 4); - assert!(!plan - .executions - .iter() - .any(|execution| matches!(execution.id.as_str(), "A" | "B"))); -} -fn compile_error(v: Value) -> GraphError { - compile_v2(serde_json::from_value(v).unwrap()).unwrap_err() -} -fn execution<'a>(p: &'a CompiledGraphV2, authored_id: &str) -> &'a CompiledExecutionV2 { - p.executions.iter().find(|e| e.id == authored_id).unwrap() -} -fn resource_by_origin<'a>( - p: &'a CompiledGraphV2, - node: &str, - socket: &str, -) -> &'a CompiledResourceV2 { - p.resources - .iter() - .find(|r| r.origin.node == node && r.origin.socket == socket) - .unwrap() -} - -fn family_by_source<'a>(p: &'a CompiledGraphV2, node: &str) -> &'a TextureFamilyV2 { - let source = resource_by_origin(p, node, "spec"); - let family = match source.plan { - ResourcePlanV2::TextureSpec { family, .. } => family, - _ => panic!("{node} is not a texture specification"), - }; - &p.texture_families[family as usize] -} - -fn allocation_slot<'a>( - p: &'a CompiledGraphV2, - allocation: AllocationRefV2, -) -> &'a AllocationSlotV2 { - &p.allocation_classes[allocation.class as usize].slots[allocation.slot as usize] -} - -fn independent_depth_graph( - depth_specs: Vec, - forwards: Vec, - present_from: &str, -) -> Value { - let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; - nodes.extend(render_support_nodes()); - nodes.extend(depth_specs); - nodes.extend(forwards); - nodes.push(node( - "present", - "present", - json!({}), - json!({"surface":input(present_from,"color")}), - )); - graph(nodes) -} - -fn depth_spec(id: &str, residency: &str) -> Value { - node( - id, - "texture_spec", - texture("depth32_float", residency), - json!({}), - ) -} - -#[test] -fn dense_lifetimes_exclude_authored_source_ordinals() { - let p = compile(full_cull_graph()); - assert_eq!( - p.executions - .iter() - .map(|e| e.id.as_str()) - .collect::>(), - ["cull", "query", "forward", "present"] - ); - for (node, socket, first, last) in [ - ("scene", "scene", 0, 2), - ("aabbs", "localAabbs", 0, 0), - ("frustum", "frustum", 0, 0), - ("visible", "flags", 1, 1), - ("depth_config", "config", 2, 2), - ] { - assert_eq!( - resource_by_origin(&p, node, socket).lifetime, - Some(LifetimeV2 { - first_use: first, - last_use: last - }), - "lifetime for {node}.{socket}" - ); - } - let color = resource_by_origin(&p, "forward", "color"); - let depth = resource_by_origin(&p, "forward", "depth"); - assert_eq!(color.producer_execution, Some(2)); - assert_eq!(depth.producer_execution, Some(2)); - assert_eq!( - color.lifetime, - Some(LifetimeV2 { - first_use: 2, - last_use: 3 - }) - ); - assert_eq!( - depth.lifetime, - Some(LifetimeV2 { - first_use: 2, - last_use: 2 - }) - ); - let depth_family = family_by_source(&p, "depth"); - assert_eq!( - depth_family.lifetime, - LifetimeV2 { - first_use: 2, - last_use: 2 - } - ); - assert_eq!(depth_family.versions[0].lifetime, depth_family.lifetime); -} - -#[test] -fn transient_aliasing_is_declaration_order_independent() { - let p = compile(independent_depth_graph( - vec![ - depth_spec("depth_second", "transient"), - depth_spec("depth_first", "transient"), - ], - vec![ - forward( - "F0", - input("surface", "surface"), - input("depth_first", "spec"), - ), - forward("F1", input("F0", "color"), input("depth_second", "spec")), - ], - "F1", - )); - assert_eq!(execution(&p, "F1").original_node_index, 8); - let f0_ordinal = p.executions.iter().position(|e| e.id == "F0").unwrap() as u32; - let f1_ordinal = p.executions.iter().position(|e| e.id == "F1").unwrap() as u32; - assert_eq!(f1_ordinal, f0_ordinal + 1); - let first = family_by_source(&p, "depth_first"); - let second = family_by_source(&p, "depth_second"); - assert_eq!( - first.lifetime, - LifetimeV2 { - first_use: f0_ordinal, - last_use: f0_ordinal - } - ); - assert_eq!( - second.lifetime, - LifetimeV2 { - first_use: f1_ordinal, - last_use: f1_ordinal - } - ); - assert_eq!(first.versions[0].lifetime, first.lifetime); - assert_eq!(second.versions[0].lifetime, second.lifetime); - assert_eq!(first.allocation, second.allocation); - let slot = allocation_slot(&p, first.allocation.unwrap()); - assert_eq!(slot.kind, AllocationKindV2::AliasedTransient); - assert_eq!(slot.usage, [TextureUsageV2::DepthAttachment]); - assert_eq!( - slot.occupants.iter().copied().collect::>(), - [first.id, second.id].into_iter().collect() - ); -} - -#[test] -fn overlapping_family_lifetimes_prevent_transient_reuse() { - let p = compile(independent_depth_graph( - vec![ - depth_spec("depth_a", "transient"), - depth_spec("depth_b", "transient"), - ], - vec![ - forward("F0", input("surface", "surface"), input("depth_a", "spec")), - forward("F1", input("F0", "color"), input("depth_b", "spec")), - forward("F2", input("F1", "color"), input("F0", "depth")), - ], - "F2", - )); - let a = family_by_source(&p, "depth_a"); - let b = family_by_source(&p, "depth_b"); - assert!(a.lifetime.first_use < b.lifetime.first_use); - assert!(a.lifetime.last_use > b.lifetime.last_use); - assert_ne!(a.allocation, b.allocation); - assert_ne!(a.allocation.unwrap().slot, b.allocation.unwrap().slot); -} - -#[test] -fn persistent_textures_are_dedicated_and_follow_transient_slots() { - let p = compile(independent_depth_graph( - vec![ - depth_spec("persistent_b", "persistent"), - depth_spec("transient", "transient"), - depth_spec("persistent_a", "persistent"), - ], - vec![ - forward( - "F0", - input("surface", "surface"), - input("persistent_a", "spec"), - ), - forward("F1", input("F0", "color"), input("transient", "spec")), - forward("F2", input("F1", "color"), input("persistent_b", "spec")), - ], - "F2", - )); - let a = family_by_source(&p, "persistent_a"); - let b = family_by_source(&p, "persistent_b"); - assert_ne!(a.allocation, b.allocation); - for family in [a, b] { - let allocation = family.allocation.unwrap(); - assert_eq!( - allocation_slot(&p, allocation).kind, - AllocationKindV2::Persistent - ); - assert_eq!(family.usage, [TextureUsageV2::DepthAttachment]); - for version in &family.versions { - let ResourcePlanV2::Texture { - allocation: resource_allocation, - .. - } = p.resources[version.resource as usize].plan - else { - panic!() - }; - assert_eq!(resource_allocation, Some(allocation)); - } - } - let transient = family_by_source(&p, "transient").allocation.unwrap(); - assert!(transient.slot < a.allocation.unwrap().slot); - assert!(transient.slot < b.allocation.unwrap().slot); - assert_eq!(p.transient_slot_count, 1); -} - -#[test] -fn exact_texture_compatibility_separates_allocation_classes() { - let mut different = texture("depth32_float", "transient"); - different["texture"]["mipLevelCount"] = json!(2); - let p = compile(independent_depth_graph( - vec![ - depth_spec("relative", "transient"), - node("absolute", "texture_spec", different, json!({})), - ], - vec![ - forward("F0", input("surface", "surface"), input("relative", "spec")), - forward("F1", input("F0", "color"), input("absolute", "spec")), - ], - "F1", - )); - let relative = family_by_source(&p, "relative").allocation.unwrap(); - let absolute = family_by_source(&p, "absolute").allocation.unwrap(); - assert_ne!(relative.class, absolute.class); - assert_ne!(relative, absolute); -} - -#[test] -fn dispatch_and_registry_are_version_isolated() { - let bytes = - serde_json::to_vec(&json!({"schemaVersion":2,"graphId":"empty","revision":1,"nodes":[]})) - .unwrap(); - assert_eq!( - parse_and_compile(&bytes).unwrap_err().code, - "GRAPH_SCHEMA_UNSUPPORTED" - ); - assert!(matches!( - parse_and_compile_any(&bytes).unwrap(), - RegisteredGraph::V2(_) - )); - let mut r = Registry::default(); - let (id, _) = r.compile(&bytes).unwrap(); - assert!(matches!( - r.get_registered(id).unwrap(), - RegisteredGraph::V2(_) - )); - assert_eq!( - r.get(id).unwrap_err().message, - "schemaVersion 2 activation is unavailable until Phase 4" - ); -} - -#[test] -fn authoritative_eleven_node_graph_lowers_exactly() { - let p = compile(full_cull_graph()); - assert_eq!(p.node_count, 11); - assert_eq!(p.resources.len(), 11); - assert_eq!( - p.executions - .iter() - .map(|e| e.id.as_str()) - .collect::>(), - ["cull", "query", "forward", "present"] - ); - for resource in &p.resources { - if let ResourcePlanV2::Texture { version, .. } = resource.plan { - assert_eq!(version, 0, "every first produced texture is symbolic v0"); - } - } - assert!(p.executions.iter().all(|e| !matches!( - e.executor.key.as_str(), - "surface_target" | "texture_spec" | "scene_table" - ))); -} - -#[test] -fn exact_wire_catalog_rejections() { - let cases = [ - ("local_aabb", 0, "GRAPH_UNKNOWN_EXECUTOR"), - ("frustum", 4, "GRAPH_UNKNOWN_EXECUTOR"), - ("cull", 6, "GRAPH_UNKNOWN_EXECUTOR"), - ]; - for (key, i, code) in cases { - let mut g = full_cull_graph(); - g["nodes"][i]["executor"]["key"] = json!(key); - assert_eq!(compile_error(g).code, code); - } - for field in ["clearDepth", "clearColor"] { - let mut g = full_cull_graph(); - let i = if field == "clearDepth" { 8 } else { 9 }; - g["nodes"][i]["parameters"] - .as_object_mut() - .unwrap() - .remove(field); - assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); - } - let mut g = full_cull_graph(); - g["nodes"][0]["executor"]["version"] = json!(2); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_EXECUTOR_VERSION_UNSUPPORTED"); - assert_eq!(e.details["path"], "nodes[0].executor.version"); -} - -#[test] -fn mesh_filters_are_closed_and_any_removes_dependency() { - let p = compile(full_cull_graph()); - let NormalizedParametersV2::MeshQuery { filters } = execution(&p, "query").parameters.clone() - else { - panic!() - }; - assert_eq!( - filters.map(|f| f.flag), - [MeshFlagV2::IsVisible, MeshFlagV2::IsFrustumCulled] - ); - for filters in [ - json!([{"flag":"isVisible","predicate":"any"}]), - json!([{"flag":"isVisible","predicate":"any"},{"flag":"isVisible","predicate":"required_true"}]), - json!([{"flag":"bogus","predicate":"any"},{"flag":"isVisible","predicate":"any"}]), - ] { - let mut g = full_cull_graph(); - g["nodes"][7]["parameters"]["filters"] = filters; - assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); - } - let mut g = full_cull_graph(); - // The authored order is [culled, visible], while normalization is catalog order. - g["nodes"][7]["parameters"]["filters"][0]["predicate"] = json!("any"); - let p = compile(g); - let q = execution(&p, "query"); - assert!(!q.inputs.iter().any(|x| x.socket == "isFrustumCulled")); - assert!(!p.executions.iter().any(|e| e.id == "cull")); - assert!(!p - .resources - .iter() - .any(|r| r.origin.node == "cull" && r.origin.socket == "flags")); -} - -#[test] -fn provenance_and_lowering_are_consistent() { - let p = compile(full_cull_graph()); - let scene = resource_by_origin(&p, "scene", "scene"); - for id in ["aabbs", "visible", "cull", "query"] { - let r = p.resources.iter().find(|r| r.origin.node == id).unwrap(); - match r.plan { - ResourcePlanV2::LocalAabbBuffer { scene: s } - | ResourcePlanV2::BooleanFlagBuffer { scene: s, .. } - | ResourcePlanV2::DrawStream { scene: s } => { - assert_eq!( - s, - p.resources - .iter() - .position(|r| std::ptr::eq(r, scene)) - .unwrap() as u32 - ) - } - _ => {} - } - } - let f = execution(&p, "forward"); - let color_in = f - .inputs - .iter() - .find(|x| x.socket == "colorTarget") - .unwrap() - .resource; - let color_out = f - .outputs - .iter() - .find(|x| x.socket == "color") - .unwrap() - .resource; - assert_ne!(color_in, color_out); - assert!( - f.accesses - .iter() - .any(|a| a.resource == color_out - && matches!(a.mode, AccessModeV2::ColorAttachment { .. })) - ); - assert!(!f - .accesses - .iter() - .any(|a| a.resource == color_in && matches!(a.mode, AccessModeV2::ColorAttachment { .. }))); - for (socket, expected) in [ - ("scene", AccessModeV2::SemanticRead), - ("draws", AccessModeV2::IndirectRead), - ] { - let access = f.accesses.iter().find(|a| a.socket == socket).unwrap(); - assert_eq!(access.mode, expected, "legacy_forward {socket} access"); - } -} - -#[test] -fn descriptor_validation_and_normalization_table() { - for (field, value) in [("sampleCount", json!(3))] { - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["texture"][field] = value; - assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); - } - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["texture"]["extent"] = - json!({"kind":"absolute","width":1,"height":1,"depthOrArrayLayers":1}); - g["nodes"][1]["parameters"]["texture"]["mipLevelCount"] = json!(2); - assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); - - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["texture"]["mipLevelCount"] = json!(99); - assert_eq!( - resource_by_origin(&compile(g), "depth", "spec").semantic_type, - SemanticTypeV2::TextureSpec - ); - for residency in ["history", "readback"] { - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["residency"] = json!(residency); - assert_eq!(compile_error(g).code, "GRAPH_UNSUPPORTED_FEATURE"); - } - let p = compile(full_cull_graph()); - let surface = p - .texture_families - .iter() - .find(|f| matches!(f.source, TextureFamilySourceV2::ImportedSurface { .. })) - .unwrap(); - assert!(surface.allocation.is_none()); -} - -#[test] -fn validation_precedence_and_identifier_limits() { - let mut g = full_cull_graph(); - g["nodes"][0]["id"] = json!("x".repeat(65)); - g["nodes"][1]["executor"]["key"] = json!("bad"); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_LIMIT_EXCEEDED"); - assert_eq!(e.details["path"], "nodes[0].id"); - let mut g = full_cull_graph(); - g["nodes"][0]["id"] = json!("bad id"); - assert_eq!(compile_error(g).code, "GRAPH_INVALID_ID"); - let mut g = full_cull_graph(); - g["nodes"][1]["executor"]["key"] = json!("bad"); - g["nodes"][0]["parameters"] = json!({"bad":1}); - assert_eq!(compile_error(g).details["path"], "nodes[1].executor.key"); -} - -#[test] -fn global_identifier_lengths_precede_grammar_and_duplicates() { - let mut invalid_grammar = full_cull_graph(); - invalid_grammar["nodes"][0]["id"] = json!("bad id"); - invalid_grammar["nodes"][10]["executor"]["key"] = json!("x".repeat(65)); - let error = compile_error(invalid_grammar); - assert_eq!(error.code, "GRAPH_LIMIT_EXCEEDED"); - assert_eq!(error.details["path"], "nodes[10].executor.key"); - - let mut duplicate = full_cull_graph(); - duplicate["nodes"][1]["id"] = duplicate["nodes"][0]["id"].clone(); - duplicate["nodes"][10]["executor"]["key"] = json!("x".repeat(65)); - let error = compile_error(duplicate); - assert_eq!(error.code, "GRAPH_LIMIT_EXCEEDED"); - assert_eq!(error.details["path"], "nodes[10].executor.key"); -} - -#[test] -fn strict_mesh_diagnostics_and_inactive_any_edges() { - let cases = [ - ( - json!([{"flag":"bogus","predicate":"any"},{"flag":"isVisible","predicate":"any"}]), - "nodes[7].parameters.filters[0].flag", - ), - ( - json!([{"flag":"isFrustumCulled","predicate":"nope"},{"flag":"isVisible","predicate":"any"}]), - "nodes[7].parameters.filters[0].predicate", - ), - ( - json!([{"flag":"isVisible","predicate":"any"},{"flag":"isVisible","predicate":"required_true"}]), - "nodes[7].parameters.filters[1].flag", - ), - ( - json!([{"flag":"isVisible","predicate":"any"}]), - "nodes[7].parameters.filters", - ), - ]; - for (filters, path) in cases { - let mut g = full_cull_graph(); - g["nodes"][7]["parameters"]["filters"] = filters; - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_PARAMETERS_INVALID"); - assert_eq!(e.details["path"], path); - } - for predicate in ["required_true", "required_false"] { - let mut g = full_cull_graph(); - g["nodes"][7]["parameters"]["filters"][0]["predicate"] = json!(predicate); - g["nodes"][7]["inputs"] - .as_object_mut() - .unwrap() - .remove("isFrustumCulled"); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_SOCKET_CARDINALITY"); - assert_eq!(e.details["path"], "nodes[7].inputs.isFrustumCulled"); - } - let mut g = full_cull_graph(); - g["nodes"][7]["inputs"]["isVisible"] = input("cull", "flags"); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); - assert_eq!(e.details["path"], "nodes[7].inputs.isVisible"); - - let mut g = full_cull_graph(); - g["nodes"][7]["parameters"]["filters"][0]["predicate"] = json!("any"); - let p = compile(g); - let q = execution(&p, "query"); - assert!(!q.inputs.iter().any(|i| i.socket == "isFrustumCulled")); - assert!(!q.accesses.iter().any(|a| a.socket == "isFrustumCulled")); - assert!(!p.executions.iter().any(|e| e.id == "cull")); -} - -#[test] -fn transitive_scene_roots_are_vector_resource_ids() { - let p = compile(full_cull_graph()); - let scene_id = p - .resources - .iter() - .position(|r| r.origin.node == "scene") - .unwrap() as u32; - for origin in ["aabbs", "visible", "cull", "query"] { - let resource = p - .resources - .iter() - .find(|r| r.origin.node == origin) - .unwrap(); - let rooted = match resource.plan { - ResourcePlanV2::LocalAabbBuffer { scene } - | ResourcePlanV2::BooleanFlagBuffer { scene, .. } - | ResourcePlanV2::DrawStream { scene } => Some(scene), - _ => None, - }; - assert_eq!(rooted, Some(scene_id)); - } - let mut g = full_cull_graph(); - g["nodes"] - .as_array_mut() - .unwrap() - .push(node("sceneB", "scene_table", json!({}), json!({}))); - g["nodes"][3]["inputs"]["scene"] = input("sceneB", "scene"); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); - assert_eq!(e.details["path"], "nodes[6].inputs.localAabbs"); - let mut g = full_cull_graph(); - g["nodes"] - .as_array_mut() - .unwrap() - .push(node("sceneB", "scene_table", json!({}), json!({}))); - g["nodes"][3]["inputs"]["scene"] = input("sceneB", "scene"); - g["nodes"][5]["inputs"]["scene"] = input("sceneB", "scene"); - g["nodes"][6]["inputs"]["scene"] = input("sceneB", "scene"); - g["nodes"][7]["inputs"]["scene"] = input("sceneB", "scene"); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); - assert_eq!(e.details["path"], "nodes[9].inputs.draws"); -} - -#[test] -fn descriptor_exact_paths_and_normalization() { - let cases = [ - ("sampleCount", json!(2), "sampleCount"), - ("sampleCount", json!(8), "sampleCount"), - ("mipLevelCount", json!(0), "mipLevelCount"), - ]; - for (field, value, suffix) in cases { - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["texture"][field] = value; - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_PARAMETERS_INVALID"); - assert_eq!( - e.details["path"], - format!("nodes[1].parameters.texture.{suffix}") - ); - } - for (view, index) in [("depth32_float", 0), ("rgba8_unorm", 0)] { - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["texture"]["viewFormats"] = json!([view]); - let e = compile_error(g); - assert_eq!( - e.details["path"], - format!("nodes[1].parameters.texture.viewFormats[{index}]") - ); - } - for residency in ["history", "readback"] { - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["residency"] = json!(residency); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_UNSUPPORTED_FEATURE"); - assert_eq!(e.details["path"], "nodes[1].parameters.residency"); - } - let mut g = full_cull_graph(); - let d = &mut g["nodes"][1]["parameters"]["texture"]; - d["extent"]["width"] = json!({"numerator":2,"denominator":2}); - let p = compile(g); - let TextureFamilySourceV2::AuthoredTexture { descriptor, .. } = - &family_by_source(&p, "depth").source - else { - panic!() - }; - assert!( - matches!(&descriptor.extent, NormalizedTextureExtentV2::SurfaceRelative { width, .. } if *width == RatioV2 { numerator: 1, denominator: 1 }) - ); -} - -#[test] -fn descriptor_multi_error_precedence_is_exact() { - let cases = [ - (json!(3), json!(0), 9000, "sampleCount"), - (json!(1), json!(0), 9000, "mipLevelCount"), - (json!(1), json!(30), 9000, "mipLevelCount"), - (json!(1), json!(14), 9000, "extent"), - (json!(1), json!(14), 8192, "viewFormats[0]"), - ]; - for (sample_count, mip_count, width, expected) in cases { - let mut g = full_cull_graph(); - let d = &mut g["nodes"][1]["parameters"]["texture"]; - d["format"] = json!("rgba8_unorm"); - d["extent"] = json!({ - "kind":"absolute", - "width":width, - "height":1, - "depthOrArrayLayers":1 - }); - d["sampleCount"] = sample_count; - d["mipLevelCount"] = mip_count; - d["viewFormats"] = json!(["rgba8_unorm"]); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_PARAMETERS_INVALID"); - assert_eq!( - e.details["path"], - format!("nodes[1].parameters.texture.{expected}") - ); - } -} - -#[test] -fn global_raw_limits_have_stable_narrow_paths() { - for (mut g, path) in [ - ( - { - let mut g = full_cull_graph(); - g["graphId"] = json!("x".repeat(65)); - g - }, - "graphId", - ), - ( - { - let mut g = full_cull_graph(); - g["nodes"][0]["executor"]["key"] = json!("x".repeat(65)); - g - }, - "nodes[0].executor.key", - ), - ( - { - let mut g = full_cull_graph(); - g["nodes"][9]["inputs"]["x".repeat(65)] = input("scene", "scene"); - g - }, - "nodes[9].inputs.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - ), - ( - { - let mut g = full_cull_graph(); - g["nodes"][9]["inputs"]["scene"]["node"] = json!("x".repeat(65)); - g - }, - "nodes[9].inputs.scene.node", - ), - ( - { - let mut g = full_cull_graph(); - g["nodes"][9]["inputs"]["scene"]["socket"] = json!("x".repeat(65)); - g - }, - "nodes[9].inputs.scene.socket", - ), - ] { - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_LIMIT_EXCEEDED"); - assert_eq!(e.details["path"], path); - } - let mut inputs = serde_json::Map::new(); - for i in 0..8193 { - inputs.insert(format!("s{i}"), input("n", "x")); - } - let g = graph(vec![node( - "n", - "surface_target", - json!({}), - Value::Object(inputs), - )]); - let e = compile_error(g); - assert_eq!(e.code, "GRAPH_LIMIT_EXCEEDED"); - assert_eq!(e.details["path"], "nodes[0].inputs"); -} - -#[test] -fn empty_v2_plan_has_no_lowered_objects() { - let p = compile(json!({"schemaVersion":2,"graphId":"empty","revision":1,"nodes":[]})); - assert_eq!( - ( - p.node_count, - p.resources.len(), - p.executions.len(), - p.texture_families.len(), - p.allocation_classes.len() - ), - (0, 0, 0, 0, 0) - ); -} - -#[test] -fn registry_revision_handles_are_immutable_and_drop_is_transactional() { - let bytes = |revision| { - serde_json::to_vec( - &json!({"schemaVersion":2,"graphId":"registry","revision":revision,"nodes":[]}), - ) - .unwrap() - }; - let mut r = Registry::new(2); - let (id, _) = r.compile(&bytes(1)).unwrap(); - assert_eq!( - r.compile(&bytes(1)).unwrap_err().message, - "revision must increase" - ); - let (second, _) = r.compile(&bytes(2)).unwrap(); - assert_ne!(id, second); - assert!(matches!( - r.get_registered(id).unwrap(), - RegisteredGraph::V2(graph) if graph.revision == 1 - )); - r.drop_graph(id).unwrap(); - assert_eq!(r.get_registered(id).unwrap_err().code, "STALE_GRAPH_ID"); - let (next, _) = r.compile(&bytes(3)).unwrap(); - assert_ne!(id, next); - assert!(r.get_registered(second).is_ok()); -} - -#[test] -fn v1_parse_compile_regression() { - let v1=br#"{"schemaVersion":1,"graphId":"v1","revision":1,"resources":[],"passes":[],"outputs":[]}"#; - assert!(parse_and_compile(v1).is_ok()); - assert!(matches!( - parse_and_compile_any(v1).unwrap(), - RegisteredGraph::V1(_) - )); -} - -#[test] -fn socket_validation_is_globally_phased() { - let mut g = full_cull_graph(); - g["nodes"][3]["inputs"] - .as_object_mut() - .unwrap() - .remove("scene"); - g["nodes"][9]["inputs"]["bogus"] = input("scene", "scene"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_UNKNOWN_SOCKET", Some("nodes[9].inputs.bogus")) - ); - - let mut g = full_cull_graph(); - g["nodes"][3]["inputs"]["scene"] = input("frustum", "frustum"); - g["nodes"][9]["inputs"]["colorTarget"]["socket"] = json!("bogus"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ( - "GRAPH_UNKNOWN_SOCKET", - Some("nodes[9].inputs.colorTarget.socket") - ) - ); - - let mut g = full_cull_graph(); - g["nodes"][3]["inputs"]["scene"] = input("frustum", "frustum"); - g["nodes"][9]["inputs"] - .as_object_mut() - .unwrap() - .remove("draws"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_SOCKET_CARDINALITY", Some("nodes[9].inputs.draws")) - ); - - let mut g = full_cull_graph(); - g["nodes"][3]["inputs"]["scene"] = input("frustum", "frustum"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_SOCKET_TYPE_MISMATCH", Some("nodes[3].inputs.scene")) - ); -} - -#[test] -fn executor_version_parameter_and_state_precedence_is_global() { - let mut g = full_cull_graph(); - g["nodes"][2]["inputs"]["bad"] = input("missing", "bad"); - g["nodes"][10]["executor"]["key"] = json!("unknown"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_UNKNOWN_NODE", Some("nodes[2].inputs.bad.node")) - ); - - let mut g = full_cull_graph(); - g["nodes"][0]["parameters"] = json!({"bad":1}); - g["nodes"][1]["state"] = json!("muted"); - g["nodes"][2]["inputs"]["bad"] = input("scene", "bad"); - g["nodes"][10]["executor"]["key"] = json!("unknown"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_UNKNOWN_EXECUTOR", Some("nodes[10].executor.key")) - ); - - let mut g = full_cull_graph(); - g["nodes"][0]["parameters"] = json!({"bad":1}); - g["nodes"][1]["state"] = json!("muted"); - g["nodes"][2]["inputs"]["bad"] = input("scene", "bad"); - g["nodes"][10]["executor"]["version"] = json!(2); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ( - "GRAPH_EXECUTOR_VERSION_UNSUPPORTED", - Some("nodes[10].executor.version") - ) - ); - - let mut g = full_cull_graph(); - g["nodes"][0]["parameters"] = json!({"bad":1}); - g["nodes"][1]["state"] = json!("muted"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_PARAMETERS_INVALID", Some("nodes[0].parameters")) - ); -} - -#[test] -fn attachment_compatibility_matrix_is_enforced() { - let cases = [ - ( - "depth_surface", - json!({"kind":"absolute","width":4,"height":4,"depthOrArrayLayers":1}), - "depth32_float", - "d2", - 1, - ), - ( - "depth_half", - json!({"kind":"surface_relative","width":{"numerator":1,"denominator":2},"height":{"numerator":1,"denominator":2},"depthOrArrayLayers":1}), - "depth32_float", - "d2", - 1, - ), - ( - "depth_layers", - json!({"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":2}), - "depth32_float", - "d2", - 1, - ), - ( - "depth_format", - json!({"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":1}), - "rgba8_unorm", - "d2", - 1, - ), - ]; - for (_, extent, format, dimension, samples) in cases { - let mut g = full_cull_graph(); - let d = &mut g["nodes"][1]["parameters"]["texture"]; - d["extent"] = extent; - d["format"] = json!(format); - d["dimension"] = json!(dimension); - d["sampleCount"] = json!(samples); - assert_eq!(compile_error(g).code, "GRAPH_ILLEGAL_ACCESS"); - } - - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"] = texture("rgba8_unorm", "transient"); - g["nodes"][9]["inputs"]["colorTarget"] = input("depth", "spec"); - g["nodes"][9]["inputs"]["depthTarget"] = input("surface", "surface"); - assert_eq!(compile_error(g).code, "GRAPH_ILLEGAL_ACCESS"); - - for field in ["dimension", "extent", "sampleCount"] { - let mut g = full_cull_graph(); - let mut color = texture("rgba8_unorm", "transient"); - match field { - "dimension" => { - color["texture"][field] = json!("d3"); - color["texture"]["extent"] = - json!({"kind":"absolute","width":1,"height":1,"depthOrArrayLayers":1}); - } - "extent" => { - color["texture"][field] = - json!({"kind":"absolute","width":4,"height":4,"depthOrArrayLayers":1}) - } - _ => color["texture"][field] = json!(4), - } - g["nodes"] - .as_array_mut() - .unwrap() - .insert(2, node("color", "texture_spec", color, json!({}))); - g["nodes"][10]["inputs"]["colorTarget"] = input("color", "spec"); - assert_eq!(compile_error(g).code, "GRAPH_ILLEGAL_ACCESS", "{field}"); - } - - let mut g = full_cull_graph(); - g["nodes"].as_array_mut().unwrap().insert( - 2, - node( - "color", - "texture_spec", - texture("rgba8_unorm", "transient"), - json!({}), - ), - ); - g["nodes"][10]["inputs"]["colorTarget"] = input("color", "spec"); - let e = compile_error(g); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_ILLEGAL_ACCESS", Some("nodes[11].inputs.surface")) - ); -} - -#[test] -fn v2_wire_rejects_old_and_unknown_fields_exactly() { - let mut cases = Vec::new(); - let mut g = full_cull_graph(); - g["nodes"][1]["parameters"]["descriptor"] = g["nodes"][1]["parameters"]["texture"].take(); - cases.push(g); - for old in ["compare", "writeEnabled", "clear"] { - let mut g = full_cull_graph(); - g["nodes"][8]["parameters"][old] = json!(1); - cases.push(g); - } - for missing in ["clearDepth", "clearColor"] { - let mut g = full_cull_graph(); - let i = if missing == "clearDepth" { 8 } else { 9 }; - g["nodes"][i]["parameters"] - .as_object_mut() - .unwrap() - .remove(missing); - cases.push(g); - } - for mut g in cases { - assert_eq!( - parse_and_compile_v2(&serde_json::to_vec(&g).unwrap()) - .unwrap_err() - .code, - "GRAPH_PARAMETERS_INVALID" - ); - } - for (index, field) in [(0, "legacyOptional"), (0, "unknownNodeField")] { - let mut g = full_cull_graph(); - g["nodes"][index][field] = json!(null); - assert_eq!( - parse_and_compile_v2(&serde_json::to_vec(&g).unwrap()) - .unwrap_err() - .code, - "GRAPH_JSON_INVALID" - ); - } - let mut g = full_cull_graph(); - g["unknownGraphField"] = json!(true); - assert_eq!( - parse_and_compile_v2(&serde_json::to_vec(&g).unwrap()) - .unwrap_err() - .code, - "GRAPH_JSON_INVALID" - ); -} - -#[test] -fn raw_limits_precede_malformed_content_and_cover_live_resources() { - let mut g = graph( - (0..1025) - .map(|i| node(&format!("n{i}"), "surface_target", json!({}), json!({}))) - .collect(), - ); - g["graphId"] = json!("bad id"); - assert_eq!(compile_error(g).details["path"], "nodes"); - let mut nodes: Vec<_> = (0..65) - .map(|i| { - node( - &format!("p{i}"), - "present", - json!({}), - json!({"surface":input("missing","bad")}), - ) - }) - .collect(); - assert_eq!( - compile_error(graph(std::mem::take(&mut nodes))).details["path"], - "nodes" - ); - let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; - nodes.extend(render_support_nodes()); - let mut color = input("surface", "surface"); - for i in 0..508 { - let d = format!("d{i}"); - let f = format!("f{i}"); - nodes.push(node( - &d, - "texture_spec", - texture("depth32_float", "transient"), - json!({}), - )); - nodes.push(forward(&f, color, input(&d, "spec"))); - color = input(&f, "color"); - } - nodes.push(node( - "present", - "present", - json!({}), - json!({"surface":color}), - )); - let e = compile_error(graph(nodes)); - assert_eq!( - (e.code, e.details["path"].as_str()), - ("GRAPH_LIMIT_EXCEEDED", Some("resources")) - ); -} - -#[test] -fn generated_resource_limit_is_only_final() { - fn oversized(old_present: bool) -> Value { - let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; - nodes.extend(render_support_nodes()); - let mut color = input("surface", "surface"); - for i in 0..508 { - let d = format!("d{i}"); - let f = format!("f{i}"); - nodes.push(node( - &d, - "texture_spec", - texture("depth32_float", "transient"), - json!({}), - )); - nodes.push(forward(&f, color, input(&d, "spec"))); - color = input(&f, "color"); - } - nodes.push(node( - "present", - "present", - json!({}), - json!({"surface":color}), - )); - if old_present { - nodes.push(node( - "old_present", - "present", - json!({}), - json!({"surface":input("f0","color")}), - )); - } - assert!(nodes.len() <= 1024); - graph(nodes) - } - - let clean = compile_error(oversized(false)); - assert_eq!( - (clean.code, clean.details["path"].as_str()), - ("GRAPH_LIMIT_EXCEEDED", Some("resources")) - ); - let polluted = compile_error(oversized(true)); - assert_eq!(polluted.code, "GRAPH_RESOURCE_VERSION_INVALID"); - assert_eq!(polluted.details["path"], "nodes[1022].inputs.surface"); -} - -#[test] -fn bloom_composite_rejects_each_stale_sampled_texture_version() { - for stale_socket in ["source", "bloom"] { - let mut half = texture("rgba16_float", "transient"); - half["texture"]["extent"]["width"] = json!({"numerator":1,"denominator":2}); - half["texture"]["extent"]["height"] = json!({"numerator":1,"denominator":2}); - let mut half_depth = texture("depth32_float", "transient"); - half_depth["texture"]["extent"] = half["texture"]["extent"].clone(); - let mut nodes = vec![ - node("surface", "surface_target", json!({}), json!({})), - node( - "source_target", - "texture_spec", - texture("rgba16_float", "transient"), - json!({}), - ), - node("bloom_target", "texture_spec", half, json!({})), - node( - "output", - "texture_spec", - texture("rgba16_float", "transient"), - json!({}), - ), - depth_spec("source_depth_0", "transient"), - depth_spec("source_depth_1", "transient"), - node( - "bloom_depth_0", - "texture_spec", - half_depth.clone(), - json!({}), - ), - node("bloom_depth_1", "texture_spec", half_depth, json!({})), - ]; - nodes.extend(render_support_nodes()); - nodes.extend([ - forward("source_f0", input("source_target","spec"), input("source_depth_0","spec")), - forward("source_f1", input("source_f0","color"), input("source_depth_1","spec")), - forward("bloom_f0", input("bloom_target","spec"), input("bloom_depth_0","spec")), - forward("bloom_f1", input("bloom_f0","color"), input("bloom_depth_1","spec")), - node( - "composite", - "bloom_composite", - json!({"intensity":1.0}), - json!({ - "source":input(if stale_socket == "source" { "source_f0" } else { "source_f1" },"color"), - "bloom":input(if stale_socket == "bloom" { "source_f0" } else { "source_f1" },"color"), - "colorTarget":input("output","spec") - }), - ), - node( - "to_surface", - "fullscreen_copy", - json!({}), - json!({"source":input("composite","color"),"colorTarget":input("surface","surface")}), - ), - node( - "present", - "present", - json!({}), - json!({"surface":input("to_surface","color")}), - ), - ]); - let error = compile_error(graph(nodes)); - assert_eq!(error.code, "GRAPH_RESOURCE_VERSION_INVALID"); - assert_eq!( - error.details["path"], - format!("nodes[16].inputs.{stale_socket}") - ); - } -} - -#[test] -fn bloom_composite_requires_a_single_view_rgba16_half_resolution_bloom() { - let mut half = texture("rgba16_float", "transient"); - half["texture"]["extent"]["width"] = json!({"numerator":1,"denominator":2}); - half["texture"]["extent"]["height"] = json!({"numerator":1,"denominator":2}); - let make_graph = || { - let mut bloom_depth = texture("depth32_float", "transient"); - bloom_depth["texture"]["extent"] = half["texture"]["extent"].clone(); - let mut nodes = vec![ - node("surface", "surface_target", json!({}), json!({})), - node( - "source", - "texture_spec", - texture("rgba16_float", "transient"), - json!({}), - ), - node("bloom", "texture_spec", half.clone(), json!({})), - node( - "target", - "texture_spec", - texture("rgba16_float", "transient"), - json!({}), - ), - depth_spec("source_depth", "transient"), - node("bloom_depth", "texture_spec", bloom_depth, json!({})), - ]; - nodes.extend(render_support_nodes()); - nodes.extend([ - forward( - "source_writer", - input("source", "spec"), - input("source_depth", "spec"), - ), - forward( - "bloom_writer", - input("bloom", "spec"), - input("bloom_depth", "spec"), - ), - node( - "composite", - "bloom_composite", - json!({"intensity":1.0}), - json!({"source":input("source_writer","color"),"bloom":input("bloom_writer","color"),"colorTarget":input("target","spec")}), - ), - node( - "to_surface", - "fullscreen_copy", - json!({}), - json!({"source":input("composite","color"),"colorTarget":input("surface","surface")}), - ), - node( - "present", - "present", - json!({}), - json!({"surface":input("to_surface","color")}), - ), - ]); - graph(nodes) - }; - compile(make_graph()); - - let mut invalid = make_graph(); - invalid["nodes"][2]["parameters"]["texture"]["format"] = json!("rgba8_unorm"); - invalid["nodes"][2]["parameters"]["texture"]["mipLevelCount"] = json!(2); - let error = compile_error(invalid); - assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); - assert_eq!(error.details["path"], "nodes[12].inputs"); -} - -#[test] -fn fullscreen_copy_rejects_incompatible_authored_targets_at_copy_inputs() { - for (field, value, expected_code, expected_path) in [ - ( - "format", - json!("depth32_float"), - "GRAPH_ILLEGAL_ACCESS", - "nodes[9].inputs", - ), - ( - "dimension", - json!("d3"), - "GRAPH_PARAMETERS_INVALID", - "nodes[2].parameters.texture.extent", - ), - ( - "sampleCount", - json!(4), - "GRAPH_ILLEGAL_ACCESS", - "nodes[9].inputs", - ), - ( - "mipLevelCount", - json!(2), - "GRAPH_ILLEGAL_ACCESS", - "nodes[9].inputs", - ), - ] { - let mut target = texture("rgba16_float", "transient"); - target["texture"][field] = value; - let mut nodes = vec![ - node("surface", "surface_target", json!({}), json!({})), - node( - "source", - "texture_spec", - texture("rgba16_float", "transient"), - json!({}), - ), - node("target", "texture_spec", target, json!({})), - depth_spec("depth", "transient"), - ]; - nodes.extend(render_support_nodes()); - nodes.extend([ - forward("source_writer", input("source","spec"), input("depth","spec")), - node( - "copy", - "fullscreen_copy", - json!({}), - json!({"source":input("source_writer","color"),"colorTarget":input("target","spec")}), - ), - node( - "to_surface", - "fullscreen_copy", - json!({}), - json!({"source":input("copy","color"),"colorTarget":input("surface","surface")}), - ), - node( - "present", - "present", - json!({}), - json!({"surface":input("to_surface","color")}), - ), - ]); - let error = compile_error(graph(nodes)); - assert_eq!(error.code, expected_code, "field {field}"); - assert_eq!(error.details["path"], expected_path, "field {field}"); - } -} diff --git a/renderer/src/renderer/executors/legacy_forward.rs b/renderer/src/renderer/executors/legacy_forward.rs index 391912e..defeb73 100644 --- a/renderer/src/renderer/executors/legacy_forward.rs +++ b/renderer/src/renderer/executors/legacy_forward.rs @@ -1,6 +1,6 @@ use crate::renderer::{ - gpu_scene::GpuSceneCache, material::MaterialResources, ActiveCompiledV1, ActiveCompiledV2, - PipelineLibrary, PreparedExecutionV2, + gpu_scene::GpuSceneCache, material::MaterialResources, ActiveCompiledGraph, PipelineLibrary, + PreparedExecution, }; use super::super::scene::Scene; @@ -46,10 +46,10 @@ fn encode_scene<'a, T: Scene>( } } -pub(crate) fn encode_compiled_v2( +pub(crate) fn encode_compiled( encoder: &mut wgpu::CommandEncoder, surface: &wgpu::TextureView, - active: &ActiveCompiledV2, + active: &ActiveCompiledGraph, scene: &T, gpu: &GpuSceneCache, pipelines: &PipelineLibrary, @@ -57,7 +57,7 @@ pub(crate) fn encode_compiled_v2( mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>, ) -> Result<(), &'static str> { use crate::render_graph::{ - ExecutionKindV2, NormalizedColorLoadV2, NormalizedDepthLoadV2, ResourcePlanV2, StoreOpV2, + ExecutionKind, NormalizedColorLoad, NormalizedDepthLoad, ResourcePlan, StoreOp, }; let view = |resource: u32| -> Result<&wgpu::TextureView, &'static str> { let is_surface = active @@ -67,8 +67,8 @@ pub(crate) fn encode_compiled_v2( .is_some_and(|resource| { matches!( resource.plan, - ResourcePlanV2::SurfaceTarget { family } - | ResourcePlanV2::Texture { family, .. } + ResourcePlan::SurfaceTarget { family } + | ResourcePlan::Texture { family, .. } if family == active.runtime.allocations.surface_family ) }); @@ -82,25 +82,25 @@ pub(crate) fn encode_compiled_v2( .get(resource as usize) .copied() .flatten() - .ok_or("V2 resource has no allocation")?; + .ok_or(" resource has no allocation")?; active .textures .get(a.class as usize) .and_then(|c| c.get(a.slot as usize)) .map(|s| &s.view) - .ok_or("V2 allocation out of bounds") + .ok_or(" allocation out of bounds") }; for (execution_index, prepared) in active.executions.iter().enumerate() { let profile_id = &active.graph.executions[execution_index].id; match prepared { - PreparedExecutionV2::FrustumCull => { + PreparedExecution::FrustumCull => { gpu.encode_frustum_cull(encoder, profile.as_deref_mut(), profile_id); } - PreparedExecutionV2::MeshQuery => { + PreparedExecution::MeshQuery => { gpu.encode_mesh_query(encoder, profile.as_deref_mut(), profile_id); } - PreparedExecutionV2::Present => {} - PreparedExecutionV2::Fullscreen { + PreparedExecution::Present => {} + PreparedExecution::Fullscreen { execution, bind_group, pipeline, @@ -110,8 +110,8 @@ pub(crate) fn encode_compiled_v2( .graph .executions .get(*execution) - .ok_or("V2 execution out of bounds")?; - let ExecutionKindV2::Render { + .ok_or(" execution out of bounds")?; + let ExecutionKind::Render { color_attachments, .. } = &execution.kind else { @@ -128,8 +128,8 @@ pub(crate) fn encode_compiled_v2( resolve_target: None, ops: wgpu::Operations { load: match color.load { - NormalizedColorLoadV2::Load => wgpu::LoadOp::Load, - NormalizedColorLoadV2::Clear { value } => { + NormalizedColorLoad::Load => wgpu::LoadOp::Load, + NormalizedColorLoad::Clear { value } => { wgpu::LoadOp::Clear(wgpu::Color { r: value[0], g: value[1], @@ -138,7 +138,7 @@ pub(crate) fn encode_compiled_v2( }) } }, - store: if color.store == StoreOpV2::Store { + store: if color.store == StoreOp::Store { wgpu::StoreOp::Store } else { wgpu::StoreOp::Discard @@ -155,7 +155,7 @@ pub(crate) fn encode_compiled_v2( pass.set_bind_group(0, bind_group, &[]); pass.draw(0..3, 0..1); } - PreparedExecutionV2::LegacyForward { + PreparedExecution::LegacyForward { execution, variants, } => { @@ -163,8 +163,8 @@ pub(crate) fn encode_compiled_v2( .graph .executions .get(*execution) - .ok_or("V2 execution out of bounds")?; - let ExecutionKindV2::Render { + .ok_or(" execution out of bounds")?; + let ExecutionKind::Render { color_attachments, depth_stencil, } = &execution.kind @@ -181,8 +181,8 @@ pub(crate) fn encode_compiled_v2( resolve_target: None, ops: wgpu::Operations { load: match color.load { - NormalizedColorLoadV2::Load => wgpu::LoadOp::Load, - NormalizedColorLoadV2::Clear { value } => { + NormalizedColorLoad::Load => wgpu::LoadOp::Load, + NormalizedColorLoad::Clear { value } => { wgpu::LoadOp::Clear(wgpu::Color { r: value[0], g: value[1], @@ -191,7 +191,7 @@ pub(crate) fn encode_compiled_v2( }) } }, - store: if color.store == StoreOpV2::Store { + store: if color.store == StoreOp::Store { wgpu::StoreOp::Store } else { wgpu::StoreOp::Discard @@ -202,12 +202,10 @@ pub(crate) fn encode_compiled_v2( view: view(depth.resource)?, depth_ops: Some(wgpu::Operations { load: match depth.load { - NormalizedDepthLoadV2::Load => wgpu::LoadOp::Load, - NormalizedDepthLoadV2::Clear { value } => { - wgpu::LoadOp::Clear(value) - } + NormalizedDepthLoad::Load => wgpu::LoadOp::Load, + NormalizedDepthLoad::Clear { value } => wgpu::LoadOp::Clear(value), }, - store: if depth.store == StoreOpV2::Store { + store: if depth.store == StoreOp::Store { wgpu::StoreOp::Store } else { wgpu::StoreOp::Discard @@ -304,88 +302,3 @@ pub(crate) fn encode_immediate( }); encode_scene(&mut pass, scene, gpu, pipelines, materials); } - -pub(crate) fn encode_compiled_v1( - encoder: &mut wgpu::CommandEncoder, - color_view: &wgpu::TextureView, - active: &ActiveCompiledV1, - scene: &T, - gpu: &GpuSceneCache, - pipelines: &PipelineLibrary, - materials: &MaterialResources, - mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>, -) { - for pass in &active.graph.passes { - let depth_resource = pass - .writes - .iter() - .find(|w| w.binding == "depth") - .unwrap() - .resource as usize; - let allocation = active.graph.resources[depth_resource].allocation.unwrap(); - let depth_view = - &active.views[active.class_bases[allocation.class as usize] + allocation.slot as usize]; - let color = pass.writes.iter().find(|w| w.binding == "color").unwrap(); - let depth = pass.writes.iter().find(|w| w.binding == "depth").unwrap(); - let (color_load, color_store) = match &color.access { - crate::render_graph::WriteAccess::ColorAttachment { load, store, .. } => ( - match load { - crate::render_graph::ColorLoad::Clear { value } => { - wgpu::LoadOp::Clear(wgpu::Color { - r: value[0], - g: value[1], - b: value[2], - a: value[3], - }) - } - crate::render_graph::ColorLoad::Load => wgpu::LoadOp::Load, - }, - if matches!(store, crate::render_graph::StoreOp::Store) { - wgpu::StoreOp::Store - } else { - wgpu::StoreOp::Discard - }, - ), - _ => unreachable!(), - }; - let (depth_load, depth_store) = match &depth.access { - crate::render_graph::WriteAccess::DepthAttachment { load, store } => ( - match load { - crate::render_graph::DepthLoad::Clear { value } => wgpu::LoadOp::Clear(*value), - crate::render_graph::DepthLoad::Load => wgpu::LoadOp::Load, - }, - if matches!(store, crate::render_graph::StoreOp::Store) { - wgpu::StoreOp::Store - } else { - wgpu::StoreOp::Discard - }, - ), - _ => unreachable!(), - }; - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some(&pass.id), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - depth_slice: None, - view: color_view, - resolve_target: None, - ops: wgpu::Operations { - load: color_load, - store: color_store, - }, - })], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: depth_view, - depth_ops: Some(wgpu::Operations { - load: depth_load, - store: depth_store, - }), - stencil_ops: None, - }), - occlusion_query_set: None, - timestamp_writes: profile - .as_deref_mut() - .and_then(|p| p.render_writes(&pass.id)), - }); - encode_scene(&mut render_pass, scene, gpu, pipelines, materials); - } -} diff --git a/renderer/src/renderer/executors/mod.rs b/renderer/src/renderer/executors/mod.rs index fd7b6cd..a7ce3ce 100644 --- a/renderer/src/renderer/executors/mod.rs +++ b/renderer/src/renderer/executors/mod.rs @@ -1,3 +1,3 @@ mod legacy_forward; -pub(super) use legacy_forward::{encode_compiled_v1, encode_compiled_v2, encode_immediate}; +pub(super) use legacy_forward::{encode_compiled, encode_immediate}; diff --git a/renderer/src/renderer/gpu_scene.rs b/renderer/src/renderer/gpu_scene.rs index d4bc1f7..d567ca6 100644 --- a/renderer/src/renderer/gpu_scene.rs +++ b/renderer/src/renderer/gpu_scene.rs @@ -84,7 +84,7 @@ impl GpuScenePlan { pub fn build(data: &SceneFramePlan) -> Result { self::GpuScenePlan::build_with_query( data, - crate::render_graph::MeshQueryRuntimeKeyV2 { + crate::render_graph::MeshQueryRuntimeKey { visible: crate::render_graph::TriStatePredicate::RequiredTrue, frustum_culled: crate::render_graph::TriStatePredicate::Any, }, @@ -93,7 +93,7 @@ impl GpuScenePlan { pub fn build_with_query( data: &SceneFramePlan, - query: crate::render_graph::MeshQueryRuntimeKeyV2, + query: crate::render_graph::MeshQueryRuntimeKey, ) -> Result { let _ = query; // Packing is canonical; predicates are evaluated by the GPU. let mut plan = Self::default(); @@ -277,7 +277,7 @@ pub struct BufferSlot { #[derive(Default)] pub struct GpuSceneCache { revision: Option, - query: Option, + query: Option, pub positions: BufferSlot, pub normals: BufferSlot, pub uvs: BufferSlot, @@ -329,7 +329,7 @@ impl GpuSceneCache { device, queue, data, - crate::render_graph::MeshQueryRuntimeKeyV2 { + crate::render_graph::MeshQueryRuntimeKey { visible: crate::render_graph::TriStatePredicate::RequiredTrue, frustum_culled: crate::render_graph::TriStatePredicate::Any, }, @@ -341,7 +341,7 @@ impl GpuSceneCache { device: &wgpu::Device, queue: &wgpu::Queue, data: &SceneFramePlan, - query: crate::render_graph::MeshQueryRuntimeKeyV2, + query: crate::render_graph::MeshQueryRuntimeKey, ) -> Result<(), String> { if self.revision == Some(data.revision) && self.query == Some(query) { return Ok(()); @@ -580,7 +580,7 @@ impl GpuSceneCache { &self, queue: &wgpu::Queue, planes: Option<[[f32; 4]; 6]>, - query: crate::render_graph::MeshQueryRuntimeKeyV2, + query: crate::render_graph::MeshQueryRuntimeKey, ) { if let Some(compute) = &self.compute { if let Some(planes) = planes { diff --git a/renderer/src/renderer/mod.rs b/renderer/src/renderer/mod.rs index 539563b..038b6eb 100644 --- a/renderer/src/renderer/mod.rs +++ b/renderer/src/renderer/mod.rs @@ -1,4 +1,4 @@ -use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::mpsc::Receiver}; +use std::{cell::RefCell, rc::Rc, sync::mpsc::Receiver}; use futures::channel::oneshot; use log::info; @@ -24,24 +24,15 @@ pub mod scene; pub mod scene_frame; pub use pipeline_library::PipelineLibrary; -pub type GpuResources = PipelineLibrary; const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; -struct ActiveCompiledV1 { - id: crate::render_graph::CompiledGraphId, - graph: crate::render_graph::CompiledGraph, - _textures: Vec, - views: Vec, - class_bases: Vec, -} - -struct GpuTextureSlotV2 { +struct GpuTextureSlot { _texture: wgpu::Texture, view: wgpu::TextureView, } -enum PreparedExecutionV2 { +enum PreparedExecution { FrustumCull, MeshQuery, LegacyForward { @@ -57,50 +48,37 @@ enum PreparedExecutionV2 { Present, } -struct ActiveCompiledV2 { +struct ActiveCompiledGraph { id: crate::render_graph::CompiledGraphId, - graph: crate::render_graph::CompiledGraphV2, - runtime: crate::render_graph::RuntimePlanV2, - textures: Vec>, - executions: Vec, + graph: crate::render_graph::CompiledGraph, + runtime: crate::render_graph::RuntimePlan, + textures: Vec>, + executions: Vec, _fullscreen_layout: wgpu::BindGroupLayout, } -enum ActiveCompiledGraph { - V1(ActiveCompiledV1), - V2(ActiveCompiledV2), -} - #[derive(Clone, Copy)] enum UploadGraph { Immediate, - V1, - V2(crate::render_graph::MeshQueryRuntimeKeyV2), + Compiled(crate::render_graph::MeshQueryRuntimeKey), } fn classify_upload_graph(graph: &ActiveCompiledGraph) -> UploadGraph { - match graph { - ActiveCompiledGraph::V1(_) => UploadGraph::V1, - ActiveCompiledGraph::V2(active) => UploadGraph::V2(active.runtime.allocations.query), - } + UploadGraph::Compiled(graph.runtime.allocations.query) } fn upload_query_for_render( pending: Option, active: Option, -) -> Option { - match pending { - Some(UploadGraph::V2(query)) => Some(query), - Some(UploadGraph::V1 | UploadGraph::Immediate) => None, - None => match active { - Some(UploadGraph::V2(query)) => Some(query), - _ => None, - }, +) -> Option { + match pending.or(active) { + Some(UploadGraph::Compiled(query)) => Some(query), + Some(UploadGraph::Immediate) | None => None, } } fn resolve_culling_frustum( - query: crate::render_graph::MeshQueryRuntimeKeyV2, + query: crate::render_graph::MeshQueryRuntimeKey, read: impl FnOnce() -> Option>, ) -> Result, crate::render_graph::GraphError> { if query.frustum_culled == crate::render_graph::TriStatePredicate::Any { @@ -122,7 +100,7 @@ fn resolve_culling_frustum( fn update_validate_write_scene( scene: &mut S, queue: &wgpu::Queue, - query: Option, + query: Option, ) -> Result, crate::render_graph::GraphError> { scene.update_cpu(); let planes = match query { @@ -134,48 +112,25 @@ fn update_validate_write_scene( } impl ActiveCompiledGraph { fn id(&self) -> crate::render_graph::CompiledGraphId { - match self { - Self::V1(a) => a.id, - Self::V2(a) => a.id, - } + self.id } fn graph_id(&self) -> &str { - match self { - Self::V1(a) => &a.graph.graph_id, - Self::V2(a) => &a.graph.graph_id, - } + &self.graph.graph_id } fn revision(&self) -> u32 { - match self { - Self::V1(a) => a.graph.revision, - Self::V2(a) => a.graph.revision, - } + self.graph.revision } fn schema_version(&self) -> u32 { - match self { - Self::V1(_) => 1, - Self::V2(_) => 2, - } + self.graph.schema_version } fn execution_count(&self) -> usize { - match self { - Self::V1(a) => a.graph.passes.len(), - Self::V2(a) => a.graph.executions.len(), - } + self.graph.executions.len() } fn texture_slot_count(&self) -> usize { - match self { - Self::V1(a) => a.views.len(), - Self::V2(a) => a.textures.iter().map(Vec::len).sum(), - } + self.textures.iter().map(Vec::len).sum() } } -struct PooledTransient { - texture: wgpu::Texture, - view: wgpu::TextureView, -} - enum SwitchTarget { Immediate, Compiled(ActiveCompiledGraph), @@ -210,7 +165,7 @@ fn resolve_switch_request( let id = crate::render_graph::CompiledGraphId { slot, generation }; // Resolve the registry entry here, before any GPU preparation or pending // state mutation. Registry::get is also the Phase 4 activation gate. - registry.get_registered(id)?; + registry.get(id)?; Ok(ResolvedSwitchRequest::Compiled(id)) } _ => Err(crate::render_graph::GraphError::new( @@ -225,7 +180,7 @@ mod switch_request_tests { use super::*; fn query(visible: crate::render_graph::TriStatePredicate) -> UploadGraph { - UploadGraph::V2(crate::render_graph::MeshQueryRuntimeKeyV2 { + UploadGraph::Compiled(crate::render_graph::MeshQueryRuntimeKey { visible, frustum_culled: crate::render_graph::TriStatePredicate::Any, }) @@ -241,7 +196,7 @@ mod switch_request_tests { Some(RequiredFalse) ); assert_eq!( - selected(Some(UploadGraph::V1), Some(query(RequiredTrue))), + selected(Some(UploadGraph::Immediate), Some(query(RequiredTrue))), None ); assert_eq!( @@ -252,7 +207,7 @@ mod switch_request_tests { selected(None, Some(query(RequiredTrue))), Some(RequiredTrue) ); - assert_eq!(selected(None, Some(UploadGraph::V1)), None); + assert_eq!(selected(None, Some(UploadGraph::Immediate)), None); assert_eq!(selected(None, None), None); assert_eq!(selected(Some(query(Any)), None), Some(Any)); } @@ -260,7 +215,7 @@ mod switch_request_tests { #[test] fn frustum_preflight_skips_any_and_distinguishes_missing_from_invalid() { use crate::render_graph::TriStatePredicate::{Any, RequiredFalse, RequiredTrue}; - let query = |frustum_culled| crate::render_graph::MeshQueryRuntimeKeyV2 { + let query = |frustum_culled| crate::render_graph::MeshQueryRuntimeKey { visible: RequiredTrue, frustum_culled, }; @@ -288,42 +243,37 @@ mod switch_request_tests { } #[test] - fn v2_resolves_at_command_boundary_before_gpu_work() { + fn resolves_at_command_boundary_before_gpu_work() { let mut registry = crate::render_graph::Registry::default(); - let bytes = br#"{"schemaVersion":2,"graphId":"switch_v2","revision":1,"nodes":[]}"#; + let bytes = br#"{"schemaVersion":2,"graphId":"switch","revision":1,"nodes":[]}"#; let (id, _) = registry.compile(bytes).unwrap(); - let active = "existing_v1"; + let active = "existing_graph"; let pending: Option<&str> = None; assert_eq!( resolve_switch_request(®istry, false, 1, id.slot, id.generation).unwrap(), ResolvedSwitchRequest::Compiled(id) ); - assert_eq!(active, "existing_v1"); + assert_eq!(active, "existing_graph"); assert_eq!(pending, None); assert!(registry.contains(id)); let pending_error = resolve_switch_request(®istry, true, 1, id.slot, id.generation) .expect_err("an existing pending request must win"); assert_eq!(pending_error.code, "GRAPH_SWITCH_PENDING"); - assert_eq!(active, "existing_v1"); + assert_eq!(active, "existing_graph"); assert_eq!(pending, None); - let invalid_replacement = br#"{"schemaVersion":2,"graphId":"switch_v2","revision":2,"nodes":[],"unexpected":true}"#; + let invalid_replacement = + br#"{"schemaVersion":2,"graphId":"switch","revision":2,"nodes":[],"unexpected":true}"#; assert_eq!( registry.compile(invalid_replacement).unwrap_err().code, "GRAPH_JSON_INVALID" ); - let crate::render_graph::RegisteredGraph::V2(stored) = registry.get_registered(id).unwrap() - else { - panic!("the original V2 graph must remain registered") - }; + let stored = registry.get(id).unwrap(); assert_eq!(stored.revision, 1); registry.drop_graph(id).unwrap(); - assert_eq!( - registry.get_registered(id).unwrap_err().code, - "STALE_GRAPH_ID" - ); + assert_eq!(registry.get(id).unwrap_err().code, "STALE_GRAPH_ID"); } #[test] @@ -332,30 +282,18 @@ mod switch_request_tests { let (id, _) = registry .compile(br#"{"schemaVersion":2,"graphId":"resize","revision":1,"nodes":[]}"#) .unwrap(); - let crate::render_graph::RegisteredGraph::V2(revision_one) = - registry.get_registered(id).unwrap().clone() - else { - panic!("expected V2 graph") - }; - let in_flight = InFlightV2Preparation { + let revision_one = registry.get(id).unwrap().clone(); + let in_flight = InFlightPreparation { token: 1, id, - purpose: V2PreparationPurpose::Resize, + purpose: PreparationPurpose::Resize, graph: revision_one, }; let (revision_two_id, _) = registry .compile(br#"{"schemaVersion":2,"graphId":"resize","revision":2,"nodes":[]}"#) .unwrap(); - let crate::render_graph::RegisteredGraph::V2(original) = - registry.get_registered(id).unwrap() - else { - panic!("expected V2 graph") - }; - let crate::render_graph::RegisteredGraph::V2(revision_two) = - registry.get_registered(revision_two_id).unwrap() - else { - panic!("expected V2 graph") - }; + let original = registry.get(id).unwrap(); + let revision_two = registry.get(revision_two_id).unwrap(); assert_eq!(in_flight.graph.revision, 1); assert_eq!(original.revision, 1); assert_eq!(revision_two.revision, 2); @@ -369,22 +307,22 @@ struct PendingSwitch { } #[derive(Clone, Copy)] -enum V2PreparationPurpose { +enum PreparationPurpose { Switch { request: u32 }, Resize, } -struct InFlightV2Preparation { +struct InFlightPreparation { token: u64, id: crate::render_graph::CompiledGraphId, - purpose: V2PreparationPurpose, - graph: crate::render_graph::CompiledGraphV2, + purpose: PreparationPurpose, + graph: crate::render_graph::CompiledGraph, } -struct V2PreparationCompletion { +struct PreparationCompletion { token: u64, - purpose: V2PreparationPurpose, - candidate: Result, + purpose: PreparationPurpose, + candidate: Result, validation_error: Option, out_of_memory_error: Option, } @@ -439,160 +377,6 @@ fn render_data_error_code(error: &crate::render_data::RenderDataError) -> &'stat } } -/*pub struct GpuResources { - // Core resources - pipelines: Vec, - - // Layout management - pipeline_layouts: Vec, - bind_group_layouts: Vec, - - // Simple name-based pipeline lookup - pipeline_registry: HashMap, -} - -impl GpuResources { - pub fn new() -> Self { - Self { - pipelines: Vec::new(), - pipeline_layouts: Vec::new(), - bind_group_layouts: Vec::new(), - pipeline_registry: HashMap::new(), - } - } - - pub fn create_pipeline( - &mut self, - device: &wgpu::Device, - name: &str, - vertex_layout: &[wgpu::VertexBufferLayout], - shader_source: &str, - surface_format: wgpu::TextureFormat, - ) -> Result { - if self.pipeline_registry.contains_key(name) { - return Err(format!("Pipeline '{}' already exists", name)); - } - - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some(name), - source: wgpu::ShaderSource::Wgsl(shader_source.into()), - }); - - let layout = self.get_or_create_pipeline_layout(device, name); - - // Determine entry points based on pipeline name - let (vertex_entry, fragment_entry) = match name { - "triangle_colored" => ("v_main", "f_main"), - _ => ("vs_main", "fs_main"), - }; - - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some(name), - layout: Some(&layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some(vertex_entry), - compilation_options: wgpu::PipelineCompilationOptions::default(), - buffers: vertex_layout, - }, - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: if name == "gltf_standard_double_sided" { - None - } else { - Some(wgpu::Face::Back) - }, - unclipped_depth: false, - polygon_mode: wgpu::PolygonMode::Fill, - conservative: false, - }, - depth_stencil: Some(wgpu::DepthStencilState { - format: DEPTH_FORMAT, - depth_write_enabled: true, - depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), - bias: wgpu::DepthBiasState::default(), - }), - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some(fragment_entry), - compilation_options: wgpu::PipelineCompilationOptions::default(), - targets: &[Some(wgpu::ColorTargetState { - format: surface_format, - blend: Some(wgpu::BlendState::REPLACE), - write_mask: wgpu::ColorWrites::ALL, - })], - }), - multiview: None, - cache: None, - }); - - let index = self.pipelines.len(); - self.pipelines.push(pipeline); - let key = PipelineKey::new(index as u32); - self.pipeline_registry.insert(name.to_string(), key); - Ok(key) - } - - pub fn find_pipeline(&self, name: &str) -> Option { - self.pipeline_registry.get(name).copied() - } - - pub fn get_or_create_pipeline( - &mut self, - device: &wgpu::Device, - name: &str, - vertex_layout: &[wgpu::VertexBufferLayout], - shader_source: &str, - surface_format: wgpu::TextureFormat, - ) -> PipelineKey { - if let Some(index) = self.find_pipeline(name) { - return index; - } - - self.create_pipeline(device, name, vertex_layout, shader_source, surface_format) - .expect(&format!("Failed to create pipeline '{}'", name)) - } - - pub fn get_pipeline(&self, key: PipelineKey) -> &wgpu::RenderPipeline { - &self.pipelines[key.get() as usize] - } - - pub fn set_bind_group_layouts(&mut self, layouts: &[wgpu::BindGroupLayout; 2]) { - self.bind_group_layouts = layouts.to_vec(); - } - - fn get_or_create_pipeline_layout( - &mut self, - device: &wgpu::Device, - label: &str, - ) -> wgpu::PipelineLayout { - if self.pipeline_layouts.is_empty() { - let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some(label), - bind_group_layouts: &self.bind_group_layouts.iter().collect::>(), - push_constant_ranges: &[], - }); - self.pipeline_layouts.push(layout); - } - self.pipeline_layouts[0].clone() - } -} - -impl Default for GpuResources { - fn default() -> Self { - Self::new() - } -} -*/ - pub struct RendererContext { pub device: wgpu::Device, pub queue: wgpu::Queue, @@ -606,7 +390,7 @@ pub struct Renderer { canvas: web_sys::OffscreenCanvas, events_chan: Receiver, context: RendererContext, - resources: GpuResources, + resources: PipelineLibrary, scene: T, render_data: RenderData, snapshot: crate::shared_snapshot::SharedSnapshot, @@ -621,10 +405,9 @@ pub struct Renderer { graph_registry: crate::render_graph::Registry, active_compiled: Option, pending_switch: Option, - in_flight_v2: Option, - next_v2_preparation_token: u64, - v2_preparation_completions: Rc>>, - transient_pool: HashMap>, + in_flight: Option, + next_preparation_token: u64, + preparation_completions: Rc>>, halted: bool, profiler: profiler::Profiler, } @@ -692,7 +475,7 @@ impl Renderer { )) } else if self.pending_switch.as_ref().is_some_and( |p| matches!(&p.target, SwitchTarget::Compiled(a) if a.id() == id), - ) || self.in_flight_v2.as_ref().is_some_and(|p| p.id == id) + ) || self.in_flight.as_ref().is_some_and(|p| p.id == id) { Err(crate::render_graph::GraphError::new( "GRAPH_SWITCH_PENDING", @@ -709,7 +492,7 @@ impl Renderer { } else if opcode == 9 { let outcome = resolve_switch_request( &self.graph_registry, - self.pending_switch.is_some() || self.in_flight_v2.is_some(), + self.pending_switch.is_some() || self.in_flight.is_some(), words[2], words[3], words[4], @@ -723,24 +506,12 @@ impl Renderer { Ok(()) } ResolvedSwitchRequest::Compiled(id) => { - match self.graph_registry.get_registered(id)?.clone() { - crate::render_graph::RegisteredGraph::V1(graph) => { - self.prepare_compiled_snapshot(id, graph).map(|active| { - self.pending_switch = Some(PendingSwitch { - request, - target: SwitchTarget::Compiled(ActiveCompiledGraph::V1( - active, - )), - }) - }) - } - crate::render_graph::RegisteredGraph::V2(graph) => self - .begin_compiled_v2_preparation( - id, - graph, - V2PreparationPurpose::Switch { request }, - ), - } + let graph = self.graph_registry.get(id)?.clone(); + self.begin_compiled_preparation( + id, + graph, + PreparationPurpose::Switch { request }, + ) } }); if let Err(error) = outcome { @@ -911,7 +682,7 @@ impl Renderer { } fn ensure_gltf_pipelines( - resources: &mut GpuResources, + resources: &mut PipelineLibrary, context: &RendererContext, ) -> [crate::render_data::PipelineKey; 2] { let layout = gpu_scene::vertex_layouts(); @@ -932,110 +703,13 @@ impl Renderer { [culled, double_sided] } - fn prepare_compiled_snapshot( - &mut self, - id: crate::render_graph::CompiledGraphId, - graph: crate::render_graph::CompiledGraph, - ) -> Result { - crate::render_graph::validate_activatable(&graph)?; - let surface = [ - self.context.surface_config.width, - self.context.surface_config.height, - ]; - let classes: Vec<_> = graph - .allocation_classes - .iter() - .map(|class| (class.key.clone(), class.slot_count)) - .collect(); - let offsets = crate::render_graph::class_offsets(&classes, surface)?; - let mut textures = Vec::new(); - let mut views = Vec::new(); - let mut class_bases = Vec::new(); - for (class, offset) in graph.allocation_classes.iter().zip(offsets) { - class_bases.push(views.len()); - let key = crate::render_graph::runtime_texture_key(&class.key, surface)?; - let required = offset.checked_add(class.slot_count).ok_or_else(|| { - crate::render_graph::GraphError::new( - "GRAPH_RESOURCE_LIMIT", - "transient slot count overflow", - ) - })? as usize; - let bucket = self.transient_pool.entry(key.clone()).or_default(); - while bucket.len() < required { - let usage = key - .usage - .iter() - .fold(wgpu::TextureUsages::empty(), |usage, item| { - usage - | match item { - crate::render_graph::TextureUsage::Sampled => { - wgpu::TextureUsages::TEXTURE_BINDING - } - crate::render_graph::TextureUsage::Storage => { - wgpu::TextureUsages::STORAGE_BINDING - } - crate::render_graph::TextureUsage::CopySrc => { - wgpu::TextureUsages::COPY_SRC - } - crate::render_graph::TextureUsage::CopyDst => { - wgpu::TextureUsages::COPY_DST - } - crate::render_graph::TextureUsage::ColorAttachment - | crate::render_graph::TextureUsage::DepthAttachment => { - wgpu::TextureUsages::RENDER_ATTACHMENT - } - } - }); - let format = match key.format { - crate::render_graph::Format::Depth32Float => wgpu::TextureFormat::Depth32Float, - _ => { - return Err(crate::render_graph::GraphError::new( - "GRAPH_EXECUTION_UNSUPPORTED", - "unsupported transient texture format", - )) - } - }; - let texture = self - .context - .device - .create_texture(&wgpu::TextureDescriptor { - label: Some("render graph transient"), - size: wgpu::Extent3d { - width: key.extent.width, - height: key.extent.height, - depth_or_array_layers: key.extent.depth_or_array_layers, - }, - mip_level_count: key.mip_level_count, - sample_count: key.sample_count, - dimension: wgpu::TextureDimension::D2, - format, - usage, - view_formats: &[], - }); - let view = texture.create_view(&Default::default()); - bucket.push(PooledTransient { texture, view }); - } - for slot in offset as usize..required { - textures.push(bucket[slot].texture.clone()); - views.push(bucket[slot].view.clone()); - } - } - Ok(ActiveCompiledV1 { - id, - graph, - _textures: textures, - views, - class_bases, - }) - } - - fn plan_compiled_v2( + fn plan_compiled( &self, - graph: &crate::render_graph::CompiledGraphV2, - ) -> Result { - crate::render_graph::prepare_runtime_plan_v2( + graph: &crate::render_graph::CompiledGraph, + ) -> Result { + crate::render_graph::prepare_runtime_plan( graph, - crate::render_graph::RuntimeSurfaceContractV2 { + crate::render_graph::RuntimeSurfaceContract { format: self.context.surface_config.format, width: self.context.surface_config.width, height: self.context.surface_config.height, @@ -1046,12 +720,12 @@ impl Renderer { ) } - fn create_compiled_v2_candidate( + fn create_compiled_candidate( &mut self, id: crate::render_graph::CompiledGraphId, - graph: crate::render_graph::CompiledGraphV2, - runtime: crate::render_graph::RuntimePlanV2, - ) -> Result { + graph: crate::render_graph::CompiledGraph, + runtime: crate::render_graph::RuntimePlan, + ) -> Result { use crate::render_graph::*; let fail = |message| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", message); let mut textures = Vec::with_capacity(runtime.allocations.classes.len()); @@ -1063,7 +737,7 @@ impl Renderer { .context .device .create_texture(&wgpu::TextureDescriptor { - label: Some("V2 graph texture"), + label: Some(" graph texture"), size: wgpu::Extent3d { width: d.extent.width, height: d.extent.height, @@ -1077,7 +751,7 @@ impl Renderer { view_formats: &d.view_formats, }); let view = texture.create_view(&Default::default()); - gpu_class.push(GpuTextureSlotV2 { + gpu_class.push(GpuTextureSlot { _texture: texture, view, }); @@ -1102,7 +776,7 @@ impl Renderer { self.context .device .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("V2 fullscreen texture"), + label: Some(" fullscreen texture"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, @@ -1146,7 +820,7 @@ impl Renderer { self.context .device .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("V2 fullscreen"), + label: Some(" fullscreen"), bind_group_layouts: &[&fullscreen_layout], push_constant_ranges: &[], }); @@ -1154,14 +828,14 @@ impl Renderer { .context .device .create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("V2 fullscreen"), + label: Some(" fullscreen"), source: wgpu::ShaderSource::Wgsl(include_str!("fullscreen_copy.wgsl").into()), }); let sampler = self .context .device .create_sampler(&wgpu::SamplerDescriptor { - label: Some("V2 post linear clamp"), + label: Some(" post linear clamp"), mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, ..Default::default() @@ -1169,15 +843,15 @@ impl Renderer { let mut executions = Vec::new(); for (index, execution) in graph.executions.iter().enumerate() { match execution.executor.key.as_str() { - "frustum_cull" => executions.push(PreparedExecutionV2::FrustumCull), - "mesh_query" => executions.push(PreparedExecutionV2::MeshQuery), - "present" => executions.push(PreparedExecutionV2::Present), + "frustum_cull" => executions.push(PreparedExecution::FrustumCull), + "mesh_query" => executions.push(PreparedExecution::MeshQuery), + "present" => executions.push(PreparedExecution::Present), "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" | "luminance_edge" => { let sampled: Vec<_> = execution .accesses .iter() - .filter(|a| matches!(a.mode, AccessModeV2::SampledTexture)) + .filter(|a| matches!(a.mode, AccessMode::SampledTexture)) .map(|a| a.resource) .collect(); let source = *sampled @@ -1185,19 +859,19 @@ impl Renderer { .ok_or_else(|| fail("fullscreen source missing"))?; let second = *sampled.get(1).unwrap_or(&source); let values: [f32; 8] = match execution.parameters { - NormalizedParametersV2::ToneMap { exposure } => { + NormalizedParameters::ToneMap { exposure } => { [exposure, 0., 0., 0., 0., 0., 0., 0.] } - NormalizedParametersV2::BloomExtract { threshold, knee } => { + NormalizedParameters::BloomExtract { threshold, knee } => { [threshold, knee, 0., 0., 0., 0., 0., 0.] } - NormalizedParametersV2::BloomBlur { direction, radius } => { + NormalizedParameters::BloomBlur { direction, radius } => { [direction[0], direction[1], radius, 0., 0., 0., 0., 0.] } - NormalizedParametersV2::BloomComposite { intensity } => { + NormalizedParameters::BloomComposite { intensity } => { [intensity, 0., 0., 0., 0., 0., 0., 0.] } - NormalizedParametersV2::LuminanceEdge { strength } => { + NormalizedParameters::LuminanceEdge { strength } => { [strength, 0., 0., 0., 0., 0., 0., 0.] } _ => [0.; 8], @@ -1207,11 +881,11 @@ impl Renderer { self.context .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("V2 post parameters"), + label: Some(" post parameters"), contents: bytemuck::cast_slice(&values), usage: wgpu::BufferUsages::UNIFORM, }); - let ExecutionKindV2::Render { + let ExecutionKind::Render { color_attachments, .. } = &execution.kind else { @@ -1221,7 +895,7 @@ impl Renderer { .first() .ok_or_else(|| fail("fullscreen target missing"))? .resource; - let target_format = if graph.resources.get(target as usize).is_some_and(|r| matches!(r.plan, ResourcePlanV2::Texture { family, .. } if family == runtime.allocations.surface_family)) { runtime.surface.format } else { let a=runtime.allocations.resource_allocations[target as usize].ok_or_else(|| fail("fullscreen target allocation missing"))?; runtime.allocations.classes[a.class as usize].slots[a.slot as usize].descriptor.format }; + let target_format = if graph.resources.get(target as usize).is_some_and(|r| matches!(r.plan, ResourcePlan::Texture { family, .. } if family == runtime.allocations.surface_family)) { runtime.surface.format } else { let a=runtime.allocations.resource_allocations[target as usize].ok_or_else(|| fail("fullscreen target allocation missing"))?; runtime.allocations.classes[a.class as usize].slots[a.slot as usize].descriptor.format }; let entry = match execution.executor.key.as_str() { "fullscreen_copy" => "fs_copy", "tone_map" => "fs_tone_map", @@ -1233,7 +907,7 @@ impl Renderer { }; let pipeline = self.context.device.create_render_pipeline( &wgpu::RenderPipelineDescriptor { - label: Some("V2 post pipeline"), + label: Some(" post pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, @@ -1262,7 +936,7 @@ impl Renderer { self.context .device .create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("V2 fullscreen source"), + label: Some(" fullscreen source"), layout: &fullscreen_layout, entries: &[ wgpu::BindGroupEntry { @@ -1287,7 +961,7 @@ impl Renderer { }, ], }); - executions.push(PreparedExecutionV2::Fullscreen { + executions.push(PreparedExecution::Fullscreen { execution: index, bind_group, pipeline, @@ -1295,7 +969,7 @@ impl Renderer { }); } "legacy_forward" => { - let ExecutionKindV2::Render { + let ExecutionKind::Render { color_attachments, depth_stencil, } = &execution.kind @@ -1311,8 +985,8 @@ impl Renderer { .is_some_and(|resource| { matches!( resource.plan, - ResourcePlanV2::SurfaceTarget { family } - | ResourcePlanV2::Texture { family, .. } + ResourcePlan::SurfaceTarget { family } + | ResourcePlan::Texture { family, .. } if family == runtime.allocations.surface_family ) }); @@ -1358,7 +1032,7 @@ impl Renderer { .iter() .filter_map(|i| graph.resources.get(i.resource as usize)) .find_map(|r| { - if let ResourcePlanV2::DepthStencilConfig { config } = r.plan { + if let ResourcePlan::DepthStencilConfig { config } = r.plan { Some(config) } else { None @@ -1366,14 +1040,14 @@ impl Renderer { }) .ok_or_else(|| fail("depth config missing"))?; let compare = match config.depth_compare { - CompareFunctionV2::Never => wgpu::CompareFunction::Never, - CompareFunctionV2::Less => wgpu::CompareFunction::Less, - CompareFunctionV2::LessEqual => wgpu::CompareFunction::LessEqual, - CompareFunctionV2::Greater => wgpu::CompareFunction::Greater, - CompareFunctionV2::GreaterEqual => wgpu::CompareFunction::GreaterEqual, - CompareFunctionV2::Equal => wgpu::CompareFunction::Equal, - CompareFunctionV2::NotEqual => wgpu::CompareFunction::NotEqual, - CompareFunctionV2::Always => wgpu::CompareFunction::Always, + CompareFunction::Never => wgpu::CompareFunction::Never, + CompareFunction::Less => wgpu::CompareFunction::Less, + CompareFunction::LessEqual => wgpu::CompareFunction::LessEqual, + CompareFunction::Greater => wgpu::CompareFunction::Greater, + CompareFunction::GreaterEqual => wgpu::CompareFunction::GreaterEqual, + CompareFunction::Equal => wgpu::CompareFunction::Equal, + CompareFunction::NotEqual => wgpu::CompareFunction::NotEqual, + CompareFunction::Always => wgpu::CompareFunction::Always, }; let mut variants = Vec::new(); let bases: Vec<_> = self.resources.pipeline_keys().collect(); @@ -1391,7 +1065,7 @@ impl Renderer { .map_err(|e| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", e))?; variants.push((base, variant)); } - executions.push(PreparedExecutionV2::LegacyForward { + executions.push(PreparedExecution::LegacyForward { execution: index, variants, }); @@ -1399,7 +1073,7 @@ impl Renderer { _ => return Err(fail("unsupported prepared execution")), } } - Ok(ActiveCompiledV2 { + Ok(ActiveCompiledGraph { id, graph, runtime, @@ -1409,40 +1083,40 @@ impl Renderer { }) } - fn begin_compiled_v2_preparation( + fn begin_compiled_preparation( &mut self, id: crate::render_graph::CompiledGraphId, - graph: crate::render_graph::CompiledGraphV2, - purpose: V2PreparationPurpose, + graph: crate::render_graph::CompiledGraph, + purpose: PreparationPurpose, ) -> Result<(), crate::render_graph::GraphError> { - let runtime = self.plan_compiled_v2(&graph)?; + let runtime = self.plan_compiled(&graph)?; // Candidate construction allocates GPU resources, so the live scene preflight // belongs here: this is the earliest boundary with both the runtime query and // scene access, and precedes GPU work and all pending/in-flight mutation. resolve_culling_frustum(runtime.allocations.query, || self.scene.frustum_planes())?; let restart_graph = graph.clone(); - self.next_v2_preparation_token = self.next_v2_preparation_token.wrapping_add(1).max(1); - let token = self.next_v2_preparation_token; + self.next_preparation_token = self.next_preparation_token.wrapping_add(1).max(1); + let token = self.next_preparation_token; self.context .device .push_error_scope(wgpu::ErrorFilter::OutOfMemory); self.context .device .push_error_scope(wgpu::ErrorFilter::Validation); - let candidate = self.create_compiled_v2_candidate(id, graph, runtime); + let candidate = self.create_compiled_candidate(id, graph, runtime); let validation = self.context.device.pop_error_scope(); let out_of_memory = self.context.device.pop_error_scope(); - self.in_flight_v2 = Some(InFlightV2Preparation { + self.in_flight = Some(InFlightPreparation { token, id, purpose, graph: restart_graph, }); - let completions = self.v2_preparation_completions.clone(); + let completions = self.preparation_completions.clone(); spawn_local(async move { let validation_error = validation.await.map(|error| error.to_string()); let out_of_memory_error = out_of_memory.await.map(|error| error.to_string()); - completions.borrow_mut().push(V2PreparationCompletion { + completions.borrow_mut().push(PreparationCompletion { token, purpose, candidate, @@ -1453,16 +1127,16 @@ impl Renderer { Ok(()) } - fn drain_v2_preparation_completions(&mut self) { - let completions = std::mem::take(&mut *self.v2_preparation_completions.borrow_mut()); + fn drain_preparation_completions(&mut self) { + let completions = std::mem::take(&mut *self.preparation_completions.borrow_mut()); for completion in completions { - let Some(in_flight) = self.in_flight_v2.as_ref() else { + let Some(in_flight) = self.in_flight.as_ref() else { continue; }; if in_flight.token != completion.token { continue; } - self.in_flight_v2 = None; + self.in_flight = None; let result = if let Some(message) = completion.out_of_memory_error { Err(crate::render_graph::GraphError::new( "GRAPH_RESOURCE_LIMIT", @@ -1477,19 +1151,19 @@ impl Renderer { completion.candidate }; match (completion.purpose, result) { - (V2PreparationPurpose::Switch { request }, Ok(candidate)) => { + (PreparationPurpose::Switch { request }, Ok(candidate)) => { self.pending_switch = Some(PendingSwitch { request, - target: SwitchTarget::Compiled(ActiveCompiledGraph::V2(candidate)), + target: SwitchTarget::Compiled(candidate), }); } - (V2PreparationPurpose::Switch { request }, Err(error)) => { + (PreparationPurpose::Switch { request }, Err(error)) => { self.reply(request, Err(error.into())); } - (V2PreparationPurpose::Resize, Ok(candidate)) => { - self.active_compiled = Some(ActiveCompiledGraph::V2(candidate)); + (PreparationPurpose::Resize, Ok(candidate)) => { + self.active_compiled = Some(candidate); } - (V2PreparationPurpose::Resize, Err(error)) => { + (PreparationPurpose::Resize, Err(error)) => { log::error!( "compiled graph resize preparation failed: {}", error.message @@ -1585,7 +1259,7 @@ impl Renderer { let (depth_texture, depth_view) = Self::create_depth_texture(&device, &surface_config); - let mut resources = GpuResources::new(); + let mut resources = PipelineLibrary::new(); let context = RendererContext { surface, device, @@ -1621,10 +1295,9 @@ impl Renderer { graph_registry: Default::default(), active_compiled: None, pending_switch: None, - in_flight_v2: None, - next_v2_preparation_token: 0, - v2_preparation_completions: Default::default(), - transient_pool: HashMap::new(), + in_flight: None, + next_preparation_token: 0, + preparation_completions: Default::default(), halted: false, profiler, } @@ -1634,7 +1307,7 @@ impl Renderer { if self.halted { return; } - self.drain_v2_preparation_completions(); + self.drain_preparation_completions(); if self .gpu_error .swap(false, std::sync::atomic::Ordering::AcqRel) @@ -1745,41 +1418,22 @@ impl Renderer { }; let mut profile_frame = self.profiler.begin(|| match rendering_compiled { None => "immediate".to_owned(), - Some(ActiveCompiledGraph::V1(active)) => format!( - "v1:{}:{}:{}:{}", - active.graph.graph_id, active.graph.revision, active.id.slot, active.id.generation - ), - Some(ActiveCompiledGraph::V2(active)) => format!( - "v2:{}:{}:{}:{}", + Some(active) => format!( + "graph:{}:{}:{}:{}", active.graph.graph_id, active.graph.revision, active.id.slot, active.id.generation ), }); let encode_result = if let Some(active) = rendering_compiled { - match active { - ActiveCompiledGraph::V1(active) => { - executors::encode_compiled_v1( - &mut encoder, - &texture_view, - active, - &self.scene, - &self.gpu_scene, - &self.resources, - &self.materials, - profile_frame.as_mut(), - ); - Ok(()) - } - ActiveCompiledGraph::V2(active) => executors::encode_compiled_v2( - &mut encoder, - &texture_view, - active, - &self.scene, - &self.gpu_scene, - &self.resources, - &self.materials, - profile_frame.as_mut(), - ), - } + executors::encode_compiled( + &mut encoder, + &texture_view, + active, + &self.scene, + &self.gpu_scene, + &self.resources, + &self.materials, + profile_frame.as_mut(), + ) } else { executors::encode_immediate( &mut encoder, @@ -1885,13 +1539,6 @@ impl Renderer { "activeCompiledRevision", active.map(|a| a.revision()).unwrap_or(0).into(), ), - ( - "graphPasses", - active - .map(|a| a.execution_count() as u32) - .unwrap_or(0) - .into(), - ), ( "graphExecutions", active @@ -1906,10 +1553,6 @@ impl Renderer { .unwrap_or(0) .into(), ), - ( - "transientPoolTextures", - (self.transient_pool.values().map(Vec::len).sum::() as u32).into(), - ), ( "gpuError", self.gpu_error @@ -2134,7 +1777,6 @@ impl Renderer { self.recreate_depth_texture(); // The executable subset uses surface-relative transients exclusively. // Dropping old buckets prevents stale-size reuse and bounds resize growth. - self.transient_pool.clear(); if let Some(pending) = self.pending_switch.take() { self.reply( pending.request, @@ -2146,10 +1788,10 @@ impl Renderer { ); } let interrupted_resize = - self.in_flight_v2 + self.in_flight .take() .and_then(|preparation| match preparation.purpose { - V2PreparationPurpose::Switch { request } => { + PreparationPurpose::Switch { request } => { self.reply( request, Err(crate::render_graph::GraphError::new( @@ -2160,37 +1802,25 @@ impl Renderer { ); None } - V2PreparationPurpose::Resize => Some((preparation.id, preparation.graph)), + PreparationPurpose::Resize => Some((preparation.id, preparation.graph)), }); - let mut restarted_v2 = false; + let mut restarted = false; if let Some(old) = self.active_compiled.take() { let id = old.id(); // Keep immediate resources live and fall back for this frame if recreation fails. - match old { - ActiveCompiledGraph::V1(a) => self - .prepare_compiled_snapshot(id, a.graph) - .map(ActiveCompiledGraph::V1) - .map(|active| self.active_compiled = Some(active)), - ActiveCompiledGraph::V2(a) => { - restarted_v2 = true; - self.begin_compiled_v2_preparation( - id, - a.graph, - V2PreparationPurpose::Resize, + restarted = true; + self.begin_compiled_preparation(id, old.graph, PreparationPurpose::Resize) + .unwrap_or_else(|error| { + log::error!( + "compiled graph resize preparation failed: {}", + error.message ) - } - } - .unwrap_or_else(|error| { - log::error!( - "compiled graph resize preparation failed: {}", - error.message - ) - }); + }); } - if !restarted_v2 { + if !restarted { if let Some((id, graph)) = interrupted_resize { if let Err(error) = - self.begin_compiled_v2_preparation(id, graph, V2PreparationPurpose::Resize) + self.begin_compiled_preparation(id, graph, PreparationPurpose::Resize) { log::error!( "compiled graph resize preparation failed: {}", diff --git a/renderer/src/renderer/pipeline_library.rs b/renderer/src/renderer/pipeline_library.rs index 6dd0eb5..148bc6f 100644 --- a/renderer/src/renderer/pipeline_library.rs +++ b/renderer/src/renderer/pipeline_library.rs @@ -419,12 +419,12 @@ impl PipelineLibrary { let spec = target_variant_spec(spec, color_format, depth_format, depth_compare, depth_write); let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("V2 target variant"), + label: Some(" target variant"), source: wgpu::ShaderSource::Wgsl(spec.vertex.shader_source.as_str().into()), }); let fragment_shader = spec.fragment.as_ref().map(|stage| { device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("V2 target variant"), + label: Some(" target variant"), source: wgpu::ShaderSource::Wgsl(stage.shader_source.as_str().into()), }) }); @@ -469,7 +469,7 @@ impl PipelineLibrary { }); Ok( device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("V2 target variant"), + label: Some(" target variant"), layout, vertex: wgpu::VertexState { module: &vertex_shader, @@ -513,7 +513,7 @@ mod tests { } #[test] - fn v2_target_spec_disables_blending_without_mutating_base() { + fn target_spec_disables_blending_without_mutating_base() { let mut base = spec(); base.primitive.cull_mode = Some(wgpu::Face::Front); base.multisample.count = 4; diff --git a/renderer/src/renderer/profiler.rs b/renderer/src/renderer/profiler.rs index 537f2d0..dbe0315 100644 --- a/renderer/src/renderer/profiler.rs +++ b/renderer/src/renderer/profiler.rs @@ -4,7 +4,7 @@ use std::{ }; use wasm_bindgen::JsValue; -pub const MAX_PROFILE_PASSES: usize = crate::render_graph::MAX_PASSES; +pub const MAX_PROFILE_PASSES: usize = crate::render_graph::MAX_EXECUTIONS; const SLOT_COUNT: usize = 4; const QUERY_COUNT: u32 = (MAX_PROFILE_PASSES * 2) as u32; const RESOLVE_SIZE: u64 = QUERY_COUNT as u64 * 8; diff --git a/static/index.js b/static/index.js index 45ff96b..32e3730 100644 --- a/static/index.js +++ b/static/index.js @@ -71,7 +71,6 @@ function publish(telemetry) { activeCompiledGraph: telemetry.activeCompiledGraph, activeCompiledRevision: telemetry.activeCompiledRevision, activeCompiledSchemaVersion: telemetry.activeCompiledSchemaVersion, - graphPasses: telemetry.graphPasses, graphExecutions: telemetry.graphExecutions, graphTextureSlots: telemetry.graphTextureSlots, draws: telemetry.draws, diff --git a/static/render-graph/adapter.js b/static/render-graph/adapter.js index 13d6ac8..0125dcd 100644 --- a/static/render-graph/adapter.js +++ b/static/render-graph/adapter.js @@ -345,76 +345,3 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { fail("AUTHORING_SHAPE"); } } -export const adaptGraphSnapshot = adaptFxNodeSnapshot; - -/** Compatibility helper retained for V1 presets. */ -export function semanticProjectionToV1(p, revision = 1) { - const extent = { - kind: "surface_relative", - width: { numerator: 1, denominator: 1 }, - height: { numerator: 1, denominator: 1 }, - depthOrArrayLayers: 1, - }; - return { - schemaVersion: 1, - graphId: p.graphId, - revision, - resources: [ - { - id: "surface", - version: 0, - residency: { kind: "external", source: "surface_color" }, - texture: { - dimension: "d2", - format: "surface", - extent, - mipLevelCount: 1, - sampleCount: 1, - }, - }, - { - id: "depth", - version: 0, - residency: { kind: "transient" }, - texture: { - dimension: "d2", - format: "depth32_float", - extent, - mipLevelCount: 1, - sampleCount: 1, - }, - }, - ], - passes: [ - { - id: "forward", - state: p.passState, - executor: { key: "scene_forward", version: 1 }, - parameters: {}, - reads: [], - writes: [ - { - binding: "color", - resource: { id: "surface", version: 0 }, - access: { - kind: "color_attachment", - location: 0, - load: { op: "clear", value: p.clearColor }, - store: "store", - }, - }, - { - binding: "depth", - resource: { id: "depth", version: 0 }, - access: { - kind: "depth_attachment", - load: { op: "clear", value: p.clearDepth }, - store: "store", - }, - }, - ], - }, - ], - outputs: [{ name: "present", resource: { id: "surface", version: 0 } }], - }; -} diff --git a/static/render-graph/presets.js b/static/render-graph/presets.js index 8c2c3bf..b1b7f57 100644 --- a/static/render-graph/presets.js +++ b/static/render-graph/presets.js @@ -1,13 +1,3 @@ -import { semanticProjectionToV1 } from "./adapter.js"; -const make = (graphId, clearColor) => - Object.freeze( - semanticProjectionToV1( - { graphId, clearColor, clearDepth: 1, passState: "enabled" }, - 1, - ), - ); -export const midnight = make("preset_midnight", [0.015, 0.06, 0.18, 1]); -export const ember = make("preset_ember", [0.18, 0.035, 0.012, 1]); const input = (node, socket) => ({ node, socket }); const node = (id, key, parameters = {}, inputs = {}) => ({ id, @@ -32,6 +22,38 @@ const texture = (format) => ({ }, residency: "transient", }); +const direct = (graphId, clearColor) => Object.freeze({ + schemaVersion: 2, + graphId, + revision: 1, + nodes: [ + node("surface", "surface_target"), + node("depth", "texture_spec", texture("depth32_float")), + node("scene", "scene_table"), + node("visible", "visibility_flags", {}, { scene: input("scene", "scene") }), + node("query", "mesh_query", { + filters: [ + { flag: "isVisible", predicate: "required_true" }, + { flag: "isFrustumCulled", predicate: "any" }, + ], + }, { scene: input("scene", "scene"), isVisible: input("visible", "flags") }), + node("depth_config", "depth_stencil_config", { + depthCompare: "less_equal", + depthWriteEnabled: true, + clearDepth: 1, + }), + node("forward", "legacy_forward", { clearColor }, { + scene: input("scene", "scene"), + draws: input("query", "draws"), + colorTarget: input("surface", "surface"), + depthTarget: input("depth", "spec"), + depthStencil: input("depth_config", "config"), + }), + node("present", "present", {}, { surface: input("forward", "color") }), + ], +}); +export const midnight = direct("preset_midnight", [0.015, 0.06, 0.18, 1]); +export const ember = direct("preset_ember", [0.18, 0.035, 0.012, 1]); export const hdr = Object.freeze({ schemaVersion: 2, graphId: "preset_hdr_fullscreen", diff --git a/tests/render-graph-authoring.test.js b/tests/render-graph-authoring.test.js index f7f95b9..a3373fb 100644 --- a/tests/render-graph-authoring.test.js +++ b/tests/render-graph-authoring.test.js @@ -85,7 +85,7 @@ function fixture() { version: 1, }; } -test("catalog exhaustively mirrors all current V2 contracts", () => { +test("catalog exhaustively mirrors all current contracts", () => { assert.deepEqual( Object.keys(semanticCatalog).sort(), [ @@ -115,7 +115,7 @@ test("catalog exhaustively mirrors all current V2 contracts", () => { assert.ok(c.parameters); } }); -test("adapter deterministically emits strict V2, permits repeated types, omits muted links and maps sources", () => { +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(); diff --git a/tests/render-graph-presets.test.js b/tests/render-graph-presets.test.js index 10e2385..966c2b0 100644 --- a/tests/render-graph-presets.test.js +++ b/tests/render-graph-presets.test.js @@ -7,7 +7,7 @@ import { midnight, renderGraphPresets, } from "../static/render-graph/presets.js"; -test("Phase 4 unit 4 presets preserve V1 graphs and add the V2 HDR fullscreen topology", () => { +test("presets use canonical node graphs", () => { assert.deepEqual(Object.keys(renderGraphPresets), [ "midnight", "ember", @@ -22,20 +22,13 @@ test("Phase 4 unit 4 presets preserve V1 graphs and add the V2 HDR fullscreen to [midnight.graphId, ember.graphId], ["preset_midnight", "preset_ember"], ); - assert.notDeepEqual( - midnight.passes[0].writes[0].access.load.value, - ember.passes[0].writes[0].access.load.value, - ); + assert.notDeepEqual(midnight.nodes[6].parameters.clearColor, ember.nodes[6].parameters.clearColor); for (const graph of [midnight, ember]) { - assert.equal(graph.schemaVersion, 1); + assert.equal(graph.schemaVersion, 2); assert.equal(graph.revision, 1); - assert.equal(graph.passes.length, 1); - assert.equal(graph.passes[0].state, "enabled"); - assert.deepEqual(graph.passes[0].executor, { - key: "scene_forward", - version: 1, - }); - assert.equal(graph.outputs[0].name, "present"); + assert.equal(graph.nodes[6].executor.key, "legacy_forward"); + assert.deepEqual(graph.nodes[6].inputs.colorTarget, { node: "surface", socket: "surface" }); + assert.equal(graph.nodes.at(-1).executor.key, "present"); } assert.equal(hdr.schemaVersion, 2); assert.equal(hdr.revision, 1); diff --git a/tests/renderer-client.test.js b/tests/renderer-client.test.js index 742bc35..6a417ac 100644 --- a/tests/renderer-client.test.js +++ b/tests/renderer-client.test.js @@ -114,7 +114,7 @@ test("import rejects when disposed during asynchronous source loading", async () }); test("compile transfers payload and waits for ready before opcode 7", async()=>{ - const f=fixture(), pending=f.client.compileGraph({schemaVersion:1}); + const f=fixture(), pending=f.client.compileGraph({schemaVersion:2}); assert.equal(f.worker.transfers[0].length,1); assert.equal(Atomics.load(f.header,5),0); f.worker.reply({type:"payload-ready",id:1}); await Promise.resolve(); assert.equal(new Int32Array(f.memory.buffer,64,24)[1],7); @@ -133,7 +133,7 @@ test("compile rejects oversized encoding", async()=>{const f=fixture();await ass test("drop graph emits opcode 8 and validates id", async()=>{const f=fixture(),p=f.client.dropCompiledGraph([9,4]);const slot=new Int32Array(f.memory.buffer,64,24);assert.deepEqual([...slot.slice(1,5)],[8,1,9,4]);f.worker.reply({type:"reply",request:1,ok:true});await p;assert.throws(()=>f.client.dropCompiledGraph([1]),TypeError);}); test("ring-full graph compile releases staged payload", async()=>{const f=fixture();Atomics.store(f.header,5,1024);const p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await assert.rejects(p,e=>e.code==="RING_FULL");assert.equal(f.worker.messages.at(-1).type,"payload-release");}); test("disposal while graph payload is pending releases and rejects", async()=>{const f=fixture(),p=f.client.compileGraph({});f.client.dispose();await assert.rejects(p,e=>e.code==="DISPOSED");assert.equal(f.worker.messages.at(-1).type,"payload-release");}); -test("payload transfer uses the exact encoded ArrayBuffer", async()=>{const f=fixture(),graph={schemaVersion:1};const expected=new TextEncoder().encode(JSON.stringify(graph));const p=f.client.compileGraph(graph);const transferred=f.worker.transfers[0][0];assert.strictEqual(f.worker.messages[0].buffer,transferred);assert.deepEqual(new Uint8Array(transferred),expected);f.client.dispose();await assert.rejects(p);}); +test("payload transfer uses the exact encoded ArrayBuffer", async()=>{const f=fixture(),graph={schemaVersion:2};const expected=new TextEncoder().encode(JSON.stringify(graph));const p=f.client.compileGraph(graph);const transferred=f.worker.transfers[0][0];assert.strictEqual(f.worker.messages[0].buffer,transferred);assert.deepEqual(new Uint8Array(transferred),expected);f.client.dispose();await assert.rejects(p);}); test("cycle error details are preserved exactly", async()=>{const f=fixture(),p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();const details={message:"cycle",kind:"cycle",edges:[{from:"a",resource:{id:"r",version:0},to:"b"}]};f.worker.reply({type:"reply",request:1,ok:false,code:"GRAPH_CYCLE",details});await assert.rejects(p,e=>e.details===details&&e.details.edges[0].from==="a");}); test("error without details leaves details undefined", async()=>{const f=fixture(),p=f.client.dropCompiledGraph([1,1]);f.worker.reply({type:"reply",request:1,ok:false,code:"STALE_GRAPH_ID"});await assert.rejects(p,e=>e instanceof RendererError&&e.details===undefined&&e.message==="STALE_GRAPH_ID");}); test("switch methods emit exact opcode 9 words", async()=>{const f=fixture();const a=f.client.switchCompiledGraph([9,4]);assert.deepEqual([...new Int32Array(f.memory.buffer,64,24).slice(1,7)],[9,1,1,9,4,0]);f.worker.reply({type:"reply",request:1,ok:true});await a;await new Promise(queueMicrotask);const b=f.client.switchToImmediate();assert.deepEqual([...new Int32Array(f.memory.buffer,160,24).slice(1,7)],[9,2,0,0,0,0]);f.worker.reply({type:"reply",request:2,ok:true});await b;assert.throws(()=>f.client.switchCompiledGraph([0,0]),TypeError);});