diff --git a/renderer/src/render_graph/compiler.rs b/renderer/src/render_graph/compiler.rs index d266e5e..97a0e2c 100644 --- a/renderer/src/render_graph/compiler.rs +++ b/renderer/src/render_graph/compiler.rs @@ -15,15 +15,25 @@ struct TextureParameters { texture: TextureDescriptor, } #[derive(Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct DepthParameters { - depth_compare: CompareFunction, - depth_write_enabled: bool, - clear_depth: f32, +#[serde(deny_unknown_fields)] +struct CullParameters { + camera: ActiveCamera, } #[derive(Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -struct ForwardParameters { +struct QueryParameters { + visible_predicate: TriStatePredicate, + visible_default: bool, + frustum_culled_predicate: TriStatePredicate, + frustum_culled_default: bool, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PipelineParameters { + pipeline: String, + depth_compare: CompareFunction, + depth_write_enabled: bool, + clear_depth: f32, clear_color: [f64; 4], } #[derive(Deserialize)] @@ -157,11 +167,12 @@ fn validate_name_grammar(s: &str, path: impl Into) -> Result<(), GraphEr } } -pub fn mesh_predicate_matches(predicate: TriStatePredicate, flag: bool) -> bool { +pub fn mesh_predicate_matches(predicate: RuntimePredicate, flag: bool) -> bool { match predicate { - TriStatePredicate::Any => true, - TriStatePredicate::RequiredTrue => flag, - TriStatePredicate::RequiredFalse => !flag, + RuntimePredicate::Any => true, + RuntimePredicate::RequiredTrue => flag, + RuntimePredicate::RequiredFalse => !flag, + RuntimePredicate::Never => false, } } @@ -349,12 +360,12 @@ fn decode(node: &Node, i: usize) -> Result { }}; } 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), + "mesh" => empty!(NormalizedParameters::Mesh), + "frustum_cull" => { + let p: CullParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + NormalizedParameters::FrustumCull { camera: p.camera } + } "fullscreen_copy" => empty!(NormalizedParameters::FullscreenCopy), "tone_map" => { let p: ToneMapParameters = @@ -402,8 +413,8 @@ fn decode(node: &Node, i: usize) -> Result { strength: range(p.strength, 0.0, 16.0, format!("{base}.strength"))?, } } - "present" => empty!(NormalizedParameters::Present), - "texture_spec" => { + "frame_out" => empty!(NormalizedParameters::FrameOut), + "texture" => { let p: TextureParameters = serde_json::from_value(node.parameters.clone()).map_err(invalid)?; if matches!( @@ -416,100 +427,86 @@ fn decode(node: &Node, i: usize) -> Result { format!("{base}.residency"), )); } - NormalizedParameters::TextureSpec { + let unsupported = |suffix: &str| { + error( + "GRAPH_UNSUPPORTED_FEATURE", + "texture feature is unsupported", + format!("{base}.texture.{suffix}"), + ) + }; + if p.texture.dimension != TextureDimension::D2 { + return Err(unsupported("dimension")); + } + if p.texture.mip_level_count != 1 { + return Err(unsupported("mipLevelCount")); + } + if p.texture.sample_count != 1 { + return Err(unsupported("sampleCount")); + } + let depth_or_array_layers = match &p.texture.extent { + TextureExtent::Absolute { + depth_or_array_layers, + .. + } + | TextureExtent::SurfaceRelative { + depth_or_array_layers, + .. + } => *depth_or_array_layers, + }; + if depth_or_array_layers != 1 { + return Err(unsupported("extent.depthOrArrayLayers")); + } + NormalizedParameters::Texture { residency: p.residency, - texture: normalize_texture(p.texture, &base)?, + descriptor: 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"), - )); + let p: QueryParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + let fold = |predicate, default, linked| match (predicate, linked, default) { + (TriStatePredicate::Any, _, _) => RuntimePredicate::Any, + (TriStatePredicate::RequiredTrue, true, _) => RuntimePredicate::RequiredTrue, + (TriStatePredicate::RequiredFalse, true, _) => RuntimePredicate::RequiredFalse, + (TriStatePredicate::RequiredTrue, false, true) + | (TriStatePredicate::RequiredFalse, false, false) => RuntimePredicate::Any, + _ => RuntimePredicate::Never, + }; + let mut visible = fold( + p.visible_predicate, + p.visible_default, + node.inputs.contains_key("isVisible"), + ); + let mut culled = fold( + p.frustum_culled_predicate, + p.frustum_culled_default, + node.inputs.contains_key("isFrustumCulled"), + ); + if visible == RuntimePredicate::Never || culled == RuntimePredicate::Never { + visible = RuntimePredicate::Never; + culled = RuntimePredicate::Never; } NormalizedParameters::MeshQuery { - filters: [ - NormalizedMeshFilter { - flag: MeshFlag::IsVisible, - predicate: found[0].unwrap(), - }, - NormalizedMeshFilter { - flag: MeshFlag::IsFrustumCulled, - predicate: found[1].unwrap(), - }, - ], + visible_predicate: visible, + frustum_culled_predicate: culled, } } - "depth_stencil_config" => { - let p: DepthParameters = + "pipeline_registry" => empty!(NormalizedParameters::PipelineRegistry), + "pipeline" => { + let p: PipelineParameters = serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + let valid_name = !p.pipeline.is_empty() + && p.pipeline.len() <= 64 + && p.pipeline.bytes().enumerate().all(|(i, c)| { + c == b'_' || c.is_ascii_alphanumeric() && (i > 0 || c.is_ascii_alphabetic()) + }); + if !valid_name { + return Err(error( + "GRAPH_PARAMETERS_INVALID", + "pipeline must be a 1-64 byte identifier", + format!("{base}.pipeline"), + )); + } if !p.clear_depth.is_finite() || !(0.0..=1.0).contains(&p.clear_depth) { return Err(error( "GRAPH_PARAMETERS_INVALID", @@ -517,17 +514,6 @@ fn decode(node: &Node, i: usize) -> Result { 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", @@ -535,7 +521,11 @@ fn decode(node: &Node, i: usize) -> Result { format!("{base}.clearColor"), )); } - NormalizedParameters::LegacyForward { + NormalizedParameters::Pipeline { + pipeline: p.pipeline, + depth_compare: p.depth_compare, + depth_write_enabled: p.depth_write_enabled, + clear_depth: p.clear_depth, clear_color: p.clear_color, } } @@ -569,19 +559,6 @@ pub fn compile(graph: Graph) -> Result { )); } } - 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", @@ -673,8 +650,21 @@ pub fn compile(graph: Graph) -> Result { .enumerate() .map(|(i, n)| decode(n, i)) .collect::>()?; + if graph + .nodes + .iter() + .filter(|node| node.executor.key == "frame_out" && node.state == NodeState::Enabled) + .count() + != 1 + { + return Err(error( + "GRAPH_EXECUTION_UNSUPPORTED", + "exactly one frame_out is required", + "nodes", + )); + } for (i, n) in graph.nodes.iter().enumerate() { - if n.state != NodeState::Enabled { + if n.state != NodeState::Enabled && n.executor.key != "frame_out" { return Err(error( "GRAPH_NODE_STATE_INVALID", "muted nodes are unsupported", @@ -710,12 +700,12 @@ pub fn compile(graph: Graph) -> Result { } for (i, n) in graph.nodes.iter().enumerate() { for input in contracts[i].inputs { - let inactive = matches!(¶ms[i], NormalizedParameters::MeshQuery { filters } if filters.iter().any(|f| f.flag.input_socket() == input.name && f.predicate == TriStatePredicate::Any)); + let inactive = matches!(¶ms[i], NormalizedParameters::MeshQuery { visible_predicate, frustum_culled_predicate } if (input.name == "isVisible" && matches!(visible_predicate, RuntimePredicate::Any | RuntimePredicate::Never)) || (input.name == "isFrustumCulled" && matches!(frustum_culled_predicate, RuntimePredicate::Any | RuntimePredicate::Never))); if !n.inputs.contains_key(input.name) { if input.cardinality == InputCardinality::RequiredOne || (!inactive && matches!(params[i], NormalizedParameters::MeshQuery { .. }) - && input.name != "scene") + && input.name != "mesh") { return Err(error( "GRAPH_SOCKET_CARDINALITY", @@ -730,7 +720,7 @@ pub fn compile(graph: Graph) -> Result { let mut bound: Vec> = vec![BTreeMap::new(); graph.nodes.len()]; for (i, n) in graph.nodes.iter().enumerate() { for input in contracts[i].inputs { - let inactive = matches!(¶ms[i], NormalizedParameters::MeshQuery { filters } if filters.iter().any(|f| f.flag.input_socket() == input.name && f.predicate == TriStatePredicate::Any)); + let inactive = matches!(¶ms[i], NormalizedParameters::MeshQuery { visible_predicate, frustum_culled_predicate } if (input.name == "isVisible" && matches!(visible_predicate, RuntimePredicate::Any | RuntimePredicate::Never)) || (input.name == "isFrustumCulled" && matches!(frustum_culled_predicate, RuntimePredicate::Any | RuntimePredicate::Never))); let Some(r) = n.inputs.get(input.name) else { continue; }; @@ -741,10 +731,7 @@ pub fn compile(graph: Graph) -> Result { .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 { + if !accepts(input.accepted, out.semantic_type) { return Err(error( "GRAPH_SOCKET_TYPE_MISMATCH", "socket type mismatch", @@ -782,15 +769,26 @@ pub fn compile(graph: Graph) -> Result { if !seen.insert(k.0) { return None; } - if contracts[k.0].outputs[k.1 as usize].semantic_type == SemanticType::SceneTable { + if contracts[k.0].key == "mesh" { + let ordinal = contracts[k.0] + .outputs + .iter() + .position(|output| output.semantic_type == SemanticType::MeshData)?; + return Some(OutputKey(k.0, ordinal as u16)); + } + if contracts[k.0].outputs[k.1 as usize].semantic_type == SemanticType::MeshData { return Some(k); } - k = bound[k.0].get("scene")?.producer; + k = bound[k.0] + .get("mesh") + .or_else(|| bound[k.0].get("pipelineIndices")) + .or_else(|| bound[k.0].get("activation"))? + .producer; } }; for (i, c) in contracts.iter().enumerate() { if c.key == "frustum_cull" - && root(bound[i]["scene"].producer, &bound, &contracts) + && root(bound[i]["mesh"].producer, &bound, &contracts) != root(bound[i]["localAabbs"].producer, &bound, &contracts) { return Err(error( @@ -799,11 +797,23 @@ pub fn compile(graph: Graph) -> Result { format!("nodes[{i}].inputs.localAabbs"), )); } - if matches!(c.key, "mesh_query" | "legacy_forward") { - let scene = root(bound[i]["scene"].producer, &bound, &contracts); + if matches!(c.key, "mesh_query" | "pipeline_registry" | "pipeline") { + let scene_socket = if c.key == "pipeline_registry" { + "pipelineIndices" + } else { + "mesh" + }; + let scene = root(bound[i][scene_socket].producer, &bound, &contracts); for (s, b) in &bound[i] { if b.active - && matches!(*s, "isVisible" | "isFrustumCulled" | "draws") + && matches!( + *s, + "isVisible" + | "isFrustumCulled" + | "draws" + | "pipelineIndices" + | "activation" + ) && root(b.producer, &bound, &contracts) != scene { return Err(error( @@ -853,7 +863,7 @@ pub fn compile(graph: Graph) -> Result { let mut stack: Vec<_> = contracts .iter() .enumerate() - .filter(|(_, c)| c.inherently_observable) + .filter(|(i, c)| c.inherently_observable && graph.nodes[*i].state == NodeState::Enabled) .map(|(i, _)| i) .collect(); while let Some(i) = stack.pop() { @@ -862,13 +872,26 @@ pub fn compile(graph: Graph) -> Result { } } // IDs are independent of scheduling: original node order, then contract output order. + // Source nodes expose only outputs that survived active-edge/liveness analysis; + // executable nodes retain their complete output shape for runtime lowering. + let referenced_outputs: HashSet<_> = edges + .iter() + .filter(|edge| live.contains(&edge.to_node)) + .map(|edge| OutputKey(edge.from_node, edge.producer_output_ordinal)) + .collect(); 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 key = OutputKey(i, o as u16); + if contracts[i].execution == ExecutionClass::Source + && !referenced_outputs.contains(&key) + { + continue; + } let id = resource_meta.len() as u32; - output_ids.insert(OutputKey(i, o as u16), id); + output_ids.insert(key, id); resource_meta.push((i, o as u16, *out)); } } @@ -884,28 +907,10 @@ pub fn compile(graph: Graph) -> Result { } 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, - }, - 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 } => { + NormalizedParameters::Texture { + residency, + descriptor, + } => { let id = families.len() as u32; let r = source.unwrap(); source_family.insert(OutputKey(i, 0), id); @@ -918,7 +923,7 @@ pub fn compile(graph: Graph) -> Result { source: TextureFamilySource::AuthoredTexture { resource: r, residency: *residency, - descriptor: texture.clone(), + descriptor: descriptor.clone(), }, lifetime: Lifetime { first_use: 0, @@ -940,7 +945,7 @@ pub fn compile(graph: Graph) -> Result { continue; } let transition_sockets: &[(&str, u16)] = match contracts[i].key { - "legacy_forward" => &[("colorTarget", 0), ("depthTarget", 1)], + "pipeline" => &[("colorTarget", 0), ("depthTarget", 1)], "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" | "luminance_edge" => &[("colorTarget", 0)], _ => continue, @@ -1066,7 +1071,7 @@ pub fn compile(graph: Graph) -> Result { for input in contract .inputs .iter() - .filter(|input| matches!(input.role, InputRole::Present | InputRole::SampledTexture)) + .filter(|input| matches!(input.role, InputRole::SampledTexture)) { let key = bound[i][input.name].producer; if !version_of.contains_key(&key) { @@ -1103,7 +1108,7 @@ pub fn compile(graph: Graph) -> Result { if !live.contains(&i) { continue; } - if contracts[i].key == "legacy_forward" { + if contracts[i].key == "pipeline" { 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) { @@ -1113,10 +1118,11 @@ pub fn compile(graph: Graph) -> Result { format!("nodes[{i}].inputs"), )); } - } else if contracts[i] - .inputs - .iter() - .any(|input| matches!(input.role, InputRole::SampledTexture)) + } else if contracts[i].key != "frame_out" + && 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 { @@ -1176,7 +1182,7 @@ pub fn compile(graph: Graph) -> Result { // 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" { + if !live.contains(&i) || contracts[i].key != "pipeline" { continue; } let (Some(&(cf, _, _)), Some(&(df, _, _))) = ( @@ -1185,33 +1191,19 @@ pub fn compile(graph: Graph) -> Result { ) 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"), - )) - } - }; + let TextureFamilySource::AuthoredTexture { descriptor: cd, .. } = + &families[cf as usize].source; + let TextureFamilySource::AuthoredTexture { descriptor: dd, .. } = + &families[df as usize].source; 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 { + let ok_color = cd.format != TextureFormat::Depth32Float + && cd.dimension == dd.dimension + && cd.extent == dd.extent + && cd.sample_count == 1; + if !ok_depth || !ok_color { return Err(error( "GRAPH_ILLEGAL_ACCESS", "attachments are incompatible", @@ -1222,6 +1214,7 @@ pub fn compile(graph: Graph) -> Result { for i in 0..graph.nodes.len() { if !live.contains(&i) + || contracts[i].key == "frame_out" || !contracts[i] .inputs .iter() @@ -1242,19 +1235,11 @@ pub fn compile(graph: Graph) -> Result { }; 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) @@ -1272,13 +1257,6 @@ pub fn compile(graph: Graph) -> Result { 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 @@ -1295,7 +1273,12 @@ pub fn compile(graph: Graph) -> Result { && descriptor.extent == source_descriptor.extent }) } - "tone_map" => target_descriptor.is_none() && source_is_full_surface, + "tone_map" => target_descriptor.is_some_and(|descriptor| { + descriptor.format != TextureFormat::Depth32Float + && descriptor.format != TextureFormat::R32Float + && is_single_view_d2(descriptor) + && descriptor.extent == source_descriptor.extent + }), "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, @@ -1310,30 +1293,29 @@ pub fn compile(graph: Graph) -> Result { } } - // Initialization and presentation legality are later than attachment compatibility. + // Frame output must consume an initialized, produced, filterable color texture. for (i, contract) in contracts.iter().enumerate() { - if !live.contains(&i) || contract.key != "present" { + if !live.contains(&i) || contract.key != "frame_out" { continue; } - let key = bound[i]["surface"].producer; + let key = bound[i]["color"].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"), + "frame output source is not produced", + format!("nodes[{i}].inputs.color"), )); } continue; }; - if !matches!( - families[family as usize].source, - TextureFamilySource::ImportedSurface { .. } - ) { + let TextureFamilySource::AuthoredTexture { descriptor, .. } = + &families[family as usize].source; + if !is_filterable_frame_color(descriptor) { return Err(error( "GRAPH_ILLEGAL_ACCESS", - "offscreen textures cannot be presented", - format!("nodes[{i}].inputs.surface"), + "frame output requires a filterable single-view d2 color texture", + format!("nodes[{i}].inputs.color"), )); } } @@ -1463,17 +1445,18 @@ pub fn compile(graph: Graph) -> Result { 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 mesh = || output_ids[&root(key, &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 { + SemanticType::Texture if matches!(params[i], NormalizedParameters::Texture { .. }) => { + if let NormalizedParameters::Texture { + residency, + descriptor, + } = ¶ms[i] + { + ResourcePlan::TextureSource { family: source_family[&key], residency: *residency, - descriptor: texture.clone(), + descriptor: descriptor.clone(), } } else { unreachable!() @@ -1490,27 +1473,20 @@ pub fn compile(graph: Graph) -> Result { allocation: None, } } - SemanticType::SceneTable => ResourcePlan::SceneTable, - SemanticType::LocalAabbBuffer => ResourcePlan::LocalAabbBuffer { scene: scene() }, - SemanticType::CameraFrustum => ResourcePlan::CameraFrustum, + SemanticType::MeshData => ResourcePlan::MeshData, + SemanticType::LocalAabbBuffer => ResourcePlan::LocalAabbBuffer { mesh: mesh() }, 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 } + ResourcePlan::BooleanFlagBuffer { mesh: mesh(), flag } } else { unreachable!() } } + SemanticType::PipelineIndexStream => ResourcePlan::PipelineIndexStream { mesh: mesh() }, + SemanticType::PipelineActivation => ResourcePlan::PipelineActivation { + pipeline_indices: output_ids[&bound[i]["pipelineIndices"].producer], + }, + SemanticType::DrawStream => ResourcePlan::DrawStream { mesh: mesh() }, }; resources.push(CompiledResource { original_node_index: i as u32, @@ -1557,9 +1533,8 @@ pub fn compile(graph: Graph) -> Result { let kind = match contracts[i].key { "frustum_cull" => { for (s, m) in [ - ("scene", AccessMode::StorageRead), + ("mesh", AccessMode::StorageRead), ("localAabbs", AccessMode::StorageRead), - ("frustum", AccessMode::UniformRead), ] { accesses.push(CompiledAccess { socket: s.into(), @@ -1569,7 +1544,7 @@ pub fn compile(graph: Graph) -> Result { } let r = output_ids[&OutputKey(i, 0)]; accesses.push(CompiledAccess { - socket: "flags".into(), + socket: "isFrustumCulled".into(), resource: r, mode: AccessMode::StorageWrite { full_overwrite: true, @@ -1580,7 +1555,7 @@ pub fn compile(graph: Graph) -> Result { } } "mesh_query" => { - for s in ["scene", "isVisible", "isFrustumCulled"] { + for s in ["mesh", "isVisible", "isFrustumCulled"] { if let Some(b) = bound[i].get(s).filter(|b| b.active) { accesses.push(CompiledAccess { socket: s.into(), @@ -1600,23 +1575,38 @@ pub fn compile(graph: Graph) -> Result { work: ComputeWork::MeshQuery, } } - "legacy_forward" => { + "pipeline_registry" => { + accesses.push(CompiledAccess { + socket: "pipelineIndices".into(), + resource: input_resource("pipelineIndices"), + mode: AccessMode::SemanticRead, + }); + ExecutionKind::CpuPreparation + } + "pipeline" => { let color = output_ids[&OutputKey(i, 0)]; let depth = output_ids[&OutputKey(i, 1)]; let clear = match params[i] { - NormalizedParameters::LegacyForward { clear_color } => clear_color, + NormalizedParameters::Pipeline { clear_color, .. } => clear_color, _ => unreachable!(), }; - let config_node = bound[i]["depthStencil"].producer.0; - let dc = match params[config_node] { - NormalizedParameters::DepthStencilConfig { config } => config, + let clear_depth = match ¶ms[i] { + NormalizedParameters::Pipeline { clear_depth, .. } => *clear_depth, _ => unreachable!(), }; - let cl = NormalizedColorLoad::Clear { value: clear }; - let dl = NormalizedDepthLoad::Clear { - value: dc.clear_depth, + let first_color = version_of[&OutputKey(i, 0)].1 == 0; + let first_depth = version_of[&OutputKey(i, 1)].1 == 0; + let cl = if first_color { + NormalizedColorLoad::Clear { value: clear } + } else { + NormalizedColorLoad::Load }; - for s in ["scene", "draws"] { + let dl = if first_depth { + NormalizedDepthLoad::Clear { value: clear_depth } + } else { + NormalizedDepthLoad::Load + }; + for s in ["mesh", "draws", "activation"] { accesses.push(CompiledAccess { socket: s.into(), resource: input_resource(s), @@ -1634,7 +1624,7 @@ pub fn compile(graph: Graph) -> Result { location: 0, load: cl, store: StoreOp::Store, - full_overwrite: true, + full_overwrite: first_color, }, }); accesses.push(CompiledAccess { @@ -1643,7 +1633,7 @@ pub fn compile(graph: Graph) -> Result { mode: AccessMode::DepthAttachment { load: dl, store: StoreOp::Store, - full_overwrite: true, + full_overwrite: first_depth, }, }); ExecutionKind::Render { @@ -1698,14 +1688,14 @@ pub fn compile(graph: Graph) -> Result { depth_stencil: None, } } - "present" => { - let r = input_resource("surface"); + "frame_out" => { + let r = input_resource("color"); accesses.push(CompiledAccess { - socket: "surface".into(), + socket: "color".into(), resource: r, - mode: AccessMode::Present, + mode: AccessMode::SampledTexture, }); - ExecutionKind::Present { surface: r } + ExecutionKind::FrameOut { color: r } } _ => unreachable!(), }; @@ -1803,13 +1793,27 @@ fn extent_layers(e: &NormalizedTextureExtent) -> u32 { } => *depth_or_array_layers, } } -fn is_single_view_d2(descriptor: &NormalizedTextureDescriptor) -> bool { +pub(super) 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 { +pub(super) fn is_filterable_frame_color(descriptor: &NormalizedTextureDescriptor) -> bool { + is_single_view_d2(descriptor) + && matches!( + descriptor.format, + TextureFormat::Rgba8Unorm + | TextureFormat::Rgba8UnormSrgb + | TextureFormat::Bgra8Unorm + | TextureFormat::Bgra8UnormSrgb + | TextureFormat::Rgba16Float + ) +} +pub(super) 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 { @@ -1842,19 +1846,18 @@ fn allocate( ) -> (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 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 transient = 0; diff --git a/renderer/src/render_graph/contracts.rs b/renderer/src/render_graph/contracts.rs index dbcda93..1ba3dda 100644 --- a/renderer/src/render_graph/contracts.rs +++ b/renderer/src/render_graph/contracts.rs @@ -3,15 +3,13 @@ use super::MeshFlag; #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] pub enum SemanticType { - SurfaceTarget, - TextureSpec, + MeshData, Texture, - SceneTable, LocalAabbBuffer, - CameraFrustum, BooleanFlagBuffer, + PipelineIndexStream, + PipelineActivation, DrawStream, - DepthStencilConfig, } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] @@ -21,7 +19,7 @@ pub enum ExecutionClass { CpuPreparation, Compute, Render, - Present, + Frame, } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] @@ -48,8 +46,6 @@ pub enum InputRole { SampledTexture, ColorTarget { location: u32 }, DepthTarget, - Present, - Configuration, } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] @@ -119,48 +115,42 @@ 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 TEXTURE_OUT: &[OutputSocketContract] = &[output("texture", Texture, OutputMetadata::None)]; +const MESH_OUT: &[OutputSocketContract] = &[ + output("mesh", MeshData, OutputMetadata::None), + output("localAabbs", LocalAabbBuffer, OutputMetadata::None), + output( + "isVisible", + BooleanFlagBuffer, + OutputMetadata::BooleanFlag { + flag: MeshFlag::IsVisible, + }, + ), + output("pipelineIndices", PipelineIndexStream, OutputMetadata::None), +]; const CULLED_OUT: &[OutputSocketContract] = &[output( - "flags", + "isFrustumCulled", 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] = &[ +const ACTIVATION_OUT: &[OutputSocketContract] = &[output( + "activation", + PipelineActivation, + OutputMetadata::None, +)]; +const PIPELINE_OUT: &[OutputSocketContract] = &[ output("color", Texture, OutputMetadata::None), output("depth", Texture, OutputMetadata::None), ]; const FULLSCREEN_COPY_OUT: &[OutputSocketContract] = &[output("color", Texture, OutputMetadata::None)]; -const 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), + "mesh", + TypeConstraint::Exact(MeshData), REQUIRED, InputRole::StorageRead, ), @@ -170,17 +160,11 @@ const CULL_IN: &[InputSocketContract] = &[ REQUIRED, InputRole::StorageRead, ), - input( - "frustum", - TypeConstraint::Exact(CameraFrustum), - REQUIRED, - InputRole::UniformRead, - ), ]; const QUERY_IN: &[InputSocketContract] = &[ input( - "scene", - TypeConstraint::Exact(SceneTable), + "mesh", + TypeConstraint::Exact(MeshData), REQUIRED, InputRole::StorageRead, ), @@ -197,10 +181,16 @@ const QUERY_IN: &[InputSocketContract] = &[ InputRole::StorageRead, ), ]; -const FORWARD_IN: &[InputSocketContract] = &[ +const REGISTRY_IN: &[InputSocketContract] = &[input( + "pipelineIndices", + TypeConstraint::Exact(PipelineIndexStream), + REQUIRED, + InputRole::SemanticRead, +)]; +const PIPELINE_IN: &[InputSocketContract] = &[ input( - "scene", - TypeConstraint::Exact(SceneTable), + "mesh", + TypeConstraint::Exact(MeshData), REQUIRED, InputRole::SemanticRead, ), @@ -210,24 +200,24 @@ const FORWARD_IN: &[InputSocketContract] = &[ REQUIRED, InputRole::IndirectRead, ), + input( + "activation", + TypeConstraint::Exact(PipelineActivation), + REQUIRED, + InputRole::SemanticRead, + ), input( "colorTarget", - TypeConstraint::OneOf(&[SurfaceTarget, TextureSpec, Texture]), + TypeConstraint::Exact(Texture), REQUIRED, InputRole::ColorTarget { location: 0 }, ), input( "depthTarget", - TypeConstraint::OneOf(&[TextureSpec, Texture]), + TypeConstraint::Exact(Texture), REQUIRED, InputRole::DepthTarget, ), - input( - "depthStencil", - TypeConstraint::Exact(DepthStencilConfig), - REQUIRED, - InputRole::Configuration, - ), ]; const FULLSCREEN_COPY_IN: &[InputSocketContract] = &[ input( @@ -238,7 +228,7 @@ const FULLSCREEN_COPY_IN: &[InputSocketContract] = &[ ), input( "colorTarget", - TypeConstraint::OneOf(&[SurfaceTarget, TextureSpec, Texture]), + TypeConstraint::Exact(Texture), REQUIRED, InputRole::ColorTarget { location: 0 }, ), @@ -258,65 +248,33 @@ const BLOOM_COMPOSITE_IN: &[InputSocketContract] = &[ ), input( "colorTarget", - TypeConstraint::OneOf(&[SurfaceTarget, TextureSpec, Texture]), + TypeConstraint::Exact(Texture), REQUIRED, InputRole::ColorTarget { location: 0 }, ), ]; -const PRESENT_IN: &[InputSocketContract] = &[input( - "surface", +const FRAME_OUT_IN: &[InputSocketContract] = &[input( + "color", TypeConstraint::Exact(Texture), REQUIRED, - InputRole::Present, + InputRole::SampledTexture, )]; pub static CONTRACTS: &[Contract] = &[ Contract { - key: "surface_target", + key: "mesh", version: 1, execution: ExecutionClass::Source, inputs: NONE_IN, - outputs: SURFACE_OUT, + outputs: MESH_OUT, inherently_observable: false, }, Contract { - key: "texture_spec", + key: "texture", 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, + outputs: TEXTURE_OUT, inherently_observable: false, }, Contract { @@ -336,19 +294,19 @@ pub static CONTRACTS: &[Contract] = &[ inherently_observable: false, }, Contract { - key: "depth_stencil_config", + key: "pipeline_registry", version: 1, - execution: ExecutionClass::Source, - inputs: NONE_IN, - outputs: CONFIG_OUT, + execution: ExecutionClass::CpuPreparation, + inputs: REGISTRY_IN, + outputs: ACTIVATION_OUT, inherently_observable: false, }, Contract { - key: "legacy_forward", + key: "pipeline", version: 1, execution: ExecutionClass::Render, - inputs: FORWARD_IN, - outputs: FORWARD_OUT, + inputs: PIPELINE_IN, + outputs: PIPELINE_OUT, inherently_observable: false, }, Contract { @@ -400,10 +358,10 @@ pub static CONTRACTS: &[Contract] = &[ inherently_observable: false, }, Contract { - key: "present", + key: "frame_out", version: 1, - execution: ExecutionClass::Present, - inputs: PRESENT_IN, + execution: ExecutionClass::Frame, + inputs: FRAME_OUT_IN, outputs: NONE_OUT, inherently_observable: true, }, diff --git a/renderer/src/render_graph/mod.rs b/renderer/src/render_graph/mod.rs index 1add0d8..e5efa98 100644 --- a/renderer/src/render_graph/mod.rs +++ b/renderer/src/render_graph/mod.rs @@ -49,4 +49,4 @@ impl GraphError { } #[cfg(test)] -mod tests; +pub(crate) mod tests; diff --git a/renderer/src/render_graph/plan.rs b/renderer/src/render_graph/plan.rs index 2bfe2c0..ed82a32 100644 --- a/renderer/src/render_graph/plan.rs +++ b/renderer/src/render_graph/plan.rs @@ -33,10 +33,7 @@ pub struct CompiledResource { #[derive(Clone, Debug, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ResourcePlan { - SurfaceTarget { - family: u32, - }, - TextureSpec { + TextureSource { family: u32, residency: TextureResidency, descriptor: NormalizedTextureDescriptor, @@ -49,20 +46,22 @@ pub enum ResourcePlan { stored: bool, allocation: Option, }, - SceneTable, + MeshData, LocalAabbBuffer { - scene: u32, + mesh: u32, }, - CameraFrustum, BooleanFlagBuffer { - scene: u32, + mesh: u32, flag: MeshFlag, }, - DrawStream { - scene: u32, + PipelineIndexStream { + mesh: u32, }, - DepthStencilConfig { - config: NormalizedDepthStencil, + PipelineActivation { + pipeline_indices: u32, + }, + DrawStream { + mesh: u32, }, } @@ -104,12 +103,12 @@ pub enum ExecutionKind { color_attachments: Vec, depth_stencil: Option, }, - Present { - surface: u32, + FrameOut { + color: u32, }, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum ComputeWork { FrustumCull, @@ -184,29 +183,29 @@ pub enum AccessMode { store: StoreOp, full_overwrite: bool, }, - Present, } #[derive(Clone, Debug, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum NormalizedParameters { - SurfaceTarget, - TextureSpec { + Texture { residency: TextureResidency, - texture: NormalizedTextureDescriptor, + descriptor: NormalizedTextureDescriptor, + }, + Mesh, + FrustumCull { + camera: ActiveCamera, }, - SceneTable, - LocalAabbBuffer, - CameraFrustum, - VisibilityFlags, - FrustumCull, MeshQuery { - filters: [NormalizedMeshFilter; 2], + visible_predicate: RuntimePredicate, + frustum_culled_predicate: RuntimePredicate, }, - DepthStencilConfig { - config: NormalizedDepthStencil, - }, - LegacyForward { + PipelineRegistry, + Pipeline { + pipeline: String, + depth_compare: CompareFunction, + depth_write_enabled: bool, + clear_depth: f32, clear_color: [f64; 4], }, FullscreenCopy, @@ -227,22 +226,13 @@ pub enum NormalizedParameters { LuminanceEdge { strength: f32, }, - Present, + FrameOut, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct NormalizedMeshFilter { - pub flag: MeshFlag, - pub predicate: TriStatePredicate, -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct NormalizedDepthStencil { - pub depth_compare: CompareFunction, - pub depth_write_enabled: bool, - pub clear_depth: f32, +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActiveCamera { + Active, } #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] @@ -288,9 +278,6 @@ pub struct TextureFamilyKey { #[derive(Clone, Debug, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum TextureFamilySource { - ImportedSurface { - resource: u32, - }, AuthoredTexture { resource: u32, residency: TextureResidency, diff --git a/renderer/src/render_graph/runtime.rs b/renderer/src/render_graph/runtime.rs index 936c162..1095a33 100644 --- a/renderer/src/render_graph/runtime.rs +++ b/renderer/src/render_graph/runtime.rs @@ -1,3 +1,5 @@ +use std::collections::{BTreeSet, HashSet}; + use super::*; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -42,8 +44,8 @@ pub struct RuntimeAllocationClass { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct MeshQueryRuntimeKey { - pub visible: TriStatePredicate, - pub frustum_culled: TriStatePredicate, + pub visible: RuntimePredicate, + pub frustum_culled: RuntimePredicate, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -56,8 +58,6 @@ pub struct RuntimeExecution { pub struct RuntimeAllocationPlan { pub classes: Vec, pub resource_allocations: Vec>, - pub surface_family: u32, - pub surface_resource: u32, pub query: MeshQueryRuntimeKey, } @@ -247,11 +247,665 @@ fn invalid(message: impl Into, path: impl Into) -> GraphError { error("GRAPH_RUNTIME_PLAN_INVALID", message, path) } +fn valid_pipeline_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name.bytes().enumerate().all(|(i, byte)| { + byte == b'_' || byte.is_ascii_alphanumeric() && (i > 0 || byte.is_ascii_alphabetic()) + }) +} + +fn execution_supported(key: &str) -> bool { + matches!( + key, + "frustum_cull" + | "mesh_query" + | "pipeline_registry" + | "pipeline" + | "fullscreen_copy" + | "tone_map" + | "bloom_extract" + | "bloom_blur" + | "bloom_composite" + | "luminance_edge" + | "frame_out" + ) +} + +fn resource_is_mesh(graph: &CompiledGraph, id: u32) -> bool { + graph.resources.get(id as usize).is_some_and(|resource| { + resource.semantic_type == SemanticType::MeshData + && matches!(resource.plan, ResourcePlan::MeshData) + }) +} + +fn has_exact_producer( + graph: &CompiledGraph, + consumer: usize, + resource: u32, + executor: &str, + socket: &str, +) -> bool { + graph.executions[..consumer].iter().any(|execution| { + execution.executor.key == executor + && matches!(execution.outputs.as_slice(), [output] if output.socket == socket && output.resource == resource) + }) +} + +fn texture_descriptor<'a>( + graph: &'a CompiledGraph, + id: u32, +) -> Option<&'a NormalizedTextureDescriptor> { + let family = match graph.resources.get(id as usize)?.plan { + ResourcePlan::Texture { family, .. } | ResourcePlan::TextureSource { family, .. } => family, + _ => return None, + }; + match &graph.texture_families.get(family as usize)?.source { + TextureFamilySource::AuthoredTexture { descriptor, .. } => Some(descriptor), + } +} + +fn single_view_d2(d: &NormalizedTextureDescriptor) -> bool { + d.dimension == TextureDimension::D2 + && d.mip_level_count == 1 + && d.sample_count == 1 + && d.view_formats.is_empty() + && matches!( + d.extent, + NormalizedTextureExtent::Absolute { + depth_or_array_layers: 1, + .. + } | NormalizedTextureExtent::SurfaceRelative { + depth_or_array_layers: 1, + .. + } + ) +} + +fn validate_fullscreen_execution( + graph: &CompiledGraph, + i: usize, + execution: &CompiledExecution, +) -> Result<(), GraphError> { + let key = execution.executor.key.as_str(); + if !matches!( + key, + "fullscreen_copy" + | "tone_map" + | "bloom_extract" + | "bloom_blur" + | "bloom_composite" + | "luminance_edge" + ) { + return Ok(()); + } + let path = |field| format!("executions[{i}].{field}"); + let valid_parameters = match (key, &execution.parameters) { + ("fullscreen_copy", NormalizedParameters::FullscreenCopy) => true, + ("tone_map", NormalizedParameters::ToneMap { exposure }) => { + exposure.is_finite() && (0.0..=32.0).contains(exposure) + } + ("bloom_extract", NormalizedParameters::BloomExtract { threshold, knee }) => { + threshold.is_finite() + && (0.0..=64.0).contains(threshold) + && knee.is_finite() + && (0.0..=1.0).contains(knee) + } + ("bloom_blur", NormalizedParameters::BloomBlur { direction, radius }) => { + direction.iter().all(|v| v.is_finite()) + && (direction[0].abs() + direction[1].abs() - 1.0).abs() <= 0.0001 + && direction.iter().all(|v| (-1.0..=1.0).contains(v)) + && radius.is_finite() + && (1.0..=16.0).contains(radius) + } + ("bloom_composite", NormalizedParameters::BloomComposite { intensity }) => { + intensity.is_finite() && (0.0..=16.0).contains(intensity) + } + ("luminance_edge", NormalizedParameters::LuminanceEdge { strength }) => { + strength.is_finite() && (0.0..=16.0).contains(strength) + } + _ => false, + }; + if !valid_parameters { + return Err(invalid( + "fullscreen parameters mismatch", + path("parameters"), + )); + } + let expected_inputs = if key == "bloom_composite" { + &["source", "bloom", "colorTarget"][..] + } else { + &["source", "colorTarget"][..] + }; + if execution.inputs.len() != expected_inputs.len() + || execution + .inputs + .iter() + .zip(expected_inputs) + .any(|(v, s)| v.socket != *s) + { + return Err(invalid("fullscreen inputs mismatch", path("inputs"))); + } + let [output] = execution.outputs.as_slice() else { + return Err(invalid("fullscreen outputs mismatch", path("outputs"))); + }; + if output.socket != "color" { + return Err(invalid("fullscreen outputs mismatch", path("outputs"))); + } + let ExecutionKind::Render { + color_attachments, + depth_stencil: None, + } = &execution.kind + else { + return Err(invalid("fullscreen render kind mismatch", path("kind"))); + }; + let [attachment] = color_attachments.as_slice() else { + return Err(invalid("fullscreen attachment mismatch", path("kind"))); + }; + let clear = NormalizedColorLoad::Clear { value: [0.0; 4] }; + if attachment.resource != output.resource + || attachment.location != 0 + || attachment.load != clear + || attachment.store != StoreOp::Store + { + return Err(invalid("fullscreen attachment mismatch", path("kind"))); + } + let sampled_count = expected_inputs.len() - 1; + if execution.accesses.len() != sampled_count + 1 + || execution.inputs[..sampled_count] + .iter() + .zip(&execution.accesses) + .any(|(input, access)| { + access.socket != input.socket + || access.resource != input.resource + || !matches!(access.mode, AccessMode::SampledTexture) + }) + || !matches!(&execution.accesses[sampled_count], CompiledAccess { socket, resource, mode: AccessMode::ColorAttachment { location: 0, load, store: StoreOp::Store, full_overwrite: true } } if socket == "color" && *resource == output.resource && *load == clear) + { + return Err(invalid("fullscreen accesses mismatch", path("accesses"))); + } + let target = execution.inputs[sampled_count].resource; + if !matches!(graph.resources.get(output.resource as usize), Some(CompiledResource { semantic_type: SemanticType::Texture, plan: ResourcePlan::Texture { target: t, initialized: true, stored: true, allocation: Some(_), .. }, .. }) if *t == target) + { + return Err(invalid( + "fullscreen output transition mismatch", + format!("resources[{}].plan", output.resource), + )); + } + for input in &execution.inputs[..sampled_count] { + let Some(CompiledResource { + semantic_type: SemanticType::Texture, + plan: + ResourcePlan::Texture { + family, + version, + initialized: true, + stored: true, + allocation: Some(_), + .. + }, + .. + }) = graph.resources.get(input.resource as usize) + else { + return Err(invalid( + "fullscreen sampled texture is invalid", + format!("resources[{}].plan", input.resource), + )); + }; + let output_family = match graph + .resources + .get(output.resource as usize) + .map(|r| &r.plan) + { + Some(ResourcePlan::Texture { family, .. }) => *family, + _ => unreachable!("output transition was validated above"), + }; + if *family == output_family { + return Err(invalid( + "fullscreen samples its output family", + path("inputs"), + )); + } + let producer = graph.executions[..i].iter().position(|candidate| { + candidate + .outputs + .iter() + .any(|value| value.resource == input.resource) + }); + if producer.is_none() { + return Err(invalid( + "fullscreen sampled producer must precede execution", + path("inputs"), + )); + } + let stale = graph.resources.iter().any(|resource| { + matches!(resource.plan, ResourcePlan::Texture { family: f, version: v, .. } if f == *family && v > *version) + && graph.executions[..=i].iter().any(|candidate| { + candidate.outputs.iter().any(|value| { + std::ptr::eq(resource, &graph.resources[value.resource as usize]) + }) + }) + }); + if stale { + return Err(invalid( + "fullscreen sampled texture version is stale", + path("inputs"), + )); + } + } + let source = texture_descriptor(graph, execution.inputs[0].resource) + .ok_or_else(|| invalid("fullscreen source descriptor missing", path("inputs")))?; + let target_d = texture_descriptor(graph, output.resource) + .ok_or_else(|| invalid("fullscreen target descriptor missing", path("inputs")))?; + let hdr = |d: &NormalizedTextureDescriptor| { + single_view_d2(d) && d.format == TextureFormat::Rgba16Float + }; + let descriptors_valid = hdr(source) + && match key { + "fullscreen_copy" => { + single_view_d2(target_d) + && target_d.format != TextureFormat::Depth32Float + && target_d.extent == source.extent + } + "tone_map" => { + single_view_d2(target_d) + && !matches!( + target_d.format, + TextureFormat::Depth32Float | TextureFormat::R32Float + ) + && target_d.extent == source.extent + } + "bloom_extract" => hdr(target_d), + "bloom_blur" | "luminance_edge" => hdr(target_d) && target_d.extent == source.extent, + "bloom_composite" => { + hdr(target_d) + && target_d.extent == source.extent + && texture_descriptor(graph, execution.inputs[1].resource).is_some_and(hdr) + } + _ => false, + }; + if !descriptors_valid { + return Err(invalid( + "fullscreen texture descriptors mismatch", + path("inputs"), + )); + } + Ok(()) +} + +fn validate_compute_execution( + graph: &CompiledGraph, + i: usize, + execution: &CompiledExecution, +) -> Result<(), GraphError> { + let path = |field| format!("executions[{i}].{field}"); + match execution.executor.key.as_str() { + "frustum_cull" => { + if !matches!( + execution.parameters, + NormalizedParameters::FrustumCull { + camera: ActiveCamera::Active + } + ) { + return Err(invalid( + "frustum cull parameters mismatch", + path("parameters"), + )); + } + if !matches!( + execution.kind, + ExecutionKind::Compute { + work: ComputeWork::FrustumCull + } + ) { + return Err(invalid("frustum cull work mismatch", path("kind"))); + } + let [mesh, aabbs] = execution.inputs.as_slice() else { + return Err(invalid("frustum cull inputs mismatch", path("inputs"))); + }; + let [flags] = execution.outputs.as_slice() else { + return Err(invalid("frustum cull outputs mismatch", path("outputs"))); + }; + if mesh.socket != "mesh" + || aabbs.socket != "localAabbs" + || flags.socket != "isFrustumCulled" + { + return Err(invalid( + "frustum cull socket order mismatch", + path("inputs"), + )); + } + if !matches!(execution.accesses.as_slice(), + [CompiledAccess { socket: s0, resource: r0, mode: AccessMode::StorageRead }, + CompiledAccess { socket: s1, resource: r1, mode: AccessMode::StorageRead }, + CompiledAccess { socket: s2, resource: r2, mode: AccessMode::StorageWrite { full_overwrite: true } }] + if s0 == "mesh" && *r0 == mesh.resource && s1 == "localAabbs" && *r1 == aabbs.resource + && s2 == "isFrustumCulled" && *r2 == flags.resource) + { + return Err(invalid("frustum cull accesses mismatch", path("accesses"))); + } + if !resource_is_mesh(graph, mesh.resource) + || !matches!(graph.resources[aabbs.resource as usize], CompiledResource { semantic_type: SemanticType::LocalAabbBuffer, plan: ResourcePlan::LocalAabbBuffer { mesh: m }, .. } if m == mesh.resource) + || !matches!(graph.resources[flags.resource as usize], CompiledResource { semantic_type: SemanticType::BooleanFlagBuffer, plan: ResourcePlan::BooleanFlagBuffer { mesh: m, flag: MeshFlag::IsFrustumCulled }, .. } if m == mesh.resource) + { + return Err(invalid( + "frustum cull mesh provenance mismatch", + path("inputs"), + )); + } + } + "mesh_query" => { + let NormalizedParameters::MeshQuery { + visible_predicate, + frustum_culled_predicate, + } = execution.parameters + else { + return Err(invalid( + "mesh query parameters mismatch", + path("parameters"), + )); + }; + if (visible_predicate == RuntimePredicate::Never) + != (frustum_culled_predicate == RuntimePredicate::Never) + { + return Err(invalid( + "mesh query never predicates must be paired", + path("parameters"), + )); + } + if !matches!( + execution.kind, + ExecutionKind::Compute { + work: ComputeWork::MeshQuery + } + ) { + return Err(invalid("mesh query work mismatch", path("kind"))); + } + let active = |p| { + matches!( + p, + RuntimePredicate::RequiredTrue | RuntimePredicate::RequiredFalse + ) + }; + let mut sockets = vec!["mesh"]; + if active(visible_predicate) { + sockets.push("isVisible"); + } + if active(frustum_culled_predicate) { + sockets.push("isFrustumCulled"); + } + if execution.inputs.len() != sockets.len() + || execution + .inputs + .iter() + .zip(&sockets) + .any(|(v, s)| v.socket != *s) + || !matches!(execution.outputs.as_slice(), [CompiledSocketOutput { socket, .. }] if socket == "draws") + { + return Err(invalid("mesh query socket order mismatch", path("inputs"))); + } + let output = execution.outputs[0].resource; + if execution.accesses.len() != sockets.len() + 1 + || execution + .inputs + .iter() + .zip(&execution.accesses) + .any(|(input, access)| { + access.socket != input.socket + || access.resource != input.resource + || !matches!(access.mode, AccessMode::StorageRead) + }) + || !matches!(&execution.accesses[sockets.len()], CompiledAccess { socket, resource, mode: AccessMode::StorageWrite { full_overwrite: true } } if socket == "draws" && *resource == output) + { + return Err(invalid("mesh query accesses mismatch", path("accesses"))); + } + let mesh = execution.inputs[0].resource; + if !resource_is_mesh(graph, mesh) { + return Err(invalid( + "mesh query mesh provenance mismatch", + path("inputs"), + )); + } + for input in execution.inputs.iter().skip(1) { + let flag = if input.socket == "isVisible" { + MeshFlag::IsVisible + } else { + MeshFlag::IsFrustumCulled + }; + if !matches!(graph.resources[input.resource as usize], CompiledResource { semantic_type: SemanticType::BooleanFlagBuffer, plan: ResourcePlan::BooleanFlagBuffer { mesh: m, flag: f }, .. } if m == mesh && f == flag) + { + return Err(invalid( + "mesh query flag provenance mismatch", + path("inputs"), + )); + } + if flag == MeshFlag::IsFrustumCulled + && !has_exact_producer( + graph, + i, + input.resource, + "frustum_cull", + "isFrustumCulled", + ) + { + return Err(invalid( + "mesh query frustum flag producer mismatch", + path("inputs"), + )); + } + } + if !matches!(graph.resources[output as usize], CompiledResource { semantic_type: SemanticType::DrawStream, plan: ResourcePlan::DrawStream { mesh: m }, .. } if m == mesh) + { + return Err(invalid( + "mesh query output provenance mismatch", + path("outputs"), + )); + } + } + _ => {} + } + Ok(()) +} + +fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { + for (i, execution) in graph.executions.iter().enumerate() { + if !execution_supported(&execution.executor.key) { + return Err(error( + "GRAPH_EXECUTION_UNSUPPORTED", + "unsupported execution", + format!("executions[{i}]"), + )); + } + } + if graph.schema_version != 2 { + return Err(invalid( + "compiled graph schema version must be 2", + "schemaVersion", + )); + } + + let mut producers = vec![None; graph.resources.len()]; + let mut uses = vec![BTreeSet::new(); graph.resources.len()]; + for (i, execution) in graph.executions.iter().enumerate() { + let contract = contract(&execution.executor.key).expect("supported executor has contract"); + if execution.executor.version != contract.version { + return Err(invalid( + "executor version does not match its contract", + format!("executions[{i}].executor.version"), + )); + } + for output in &execution.outputs { + let producer = producers.get_mut(output.resource as usize).ok_or_else(|| { + invalid( + "execution output is out of bounds", + format!("executions[{i}].outputs"), + ) + })?; + if producer.replace(i as u32).is_some() { + return Err(invalid( + "resource has duplicate producers", + format!("executions[{i}].outputs"), + )); + } + } + let mut referenced = HashSet::new(); + for input in &execution.inputs { + if input.resource as usize >= graph.resources.len() { + return Err(invalid( + "execution input is out of bounds", + format!("executions[{i}].inputs"), + )); + } + referenced.insert(input.resource); + } + referenced.extend(execution.outputs.iter().map(|v| v.resource)); + for access in &execution.accesses { + if access.resource as usize >= graph.resources.len() { + return Err(invalid( + "execution access is out of bounds", + format!("executions[{i}].accesses"), + )); + } + referenced.insert(access.resource); + } + validate_compute_execution(graph, i, execution)?; + validate_fullscreen_execution(graph, i, execution)?; + for resource in referenced { + uses.get_mut(resource as usize) + .ok_or_else(|| { + invalid( + "execution resource is out of bounds", + format!("executions[{i}]"), + ) + })? + .insert(i as u32); + } + } + for (ri, resource) in graph.resources.iter().enumerate() { + if resource.producer_execution != producers[ri] { + return Err(invalid( + "resource producer metadata is not canonical", + format!("resources[{ri}].producerExecution"), + )); + } + let lifetime = uses[ri].iter().next().zip(uses[ri].iter().next_back()).map( + |(&first_use, &last_use)| Lifetime { + first_use, + last_use, + }, + ); + if resource.lifetime != lifetime { + return Err(invalid( + "resource lifetime metadata is not canonical", + format!("resources[{ri}].lifetime"), + )); + } + } + for (i, execution) in graph.executions.iter().enumerate() { + for input in &execution.inputs { + if let Some(producer) = producers[input.resource as usize] { + if producer >= i as u32 { + return Err(invalid( + "input producer must precede consumer", + format!("executions[{i}].inputs"), + )); + } + } + } + } + + for (fi, family) in graph.texture_families.iter().enumerate() { + let TextureFamilySource::AuthoredTexture { + resource: source, + residency, + descriptor, + } = &family.source; + let source_resource = graph.resources.get(*source as usize).ok_or_else(|| { + invalid( + "texture source resource is out of bounds", + format!("textureFamilies[{fi}].source.resource"), + ) + })?; + if source_resource.semantic_type != SemanticType::Texture + || source_resource.producer_execution.is_some() + || !matches!(&source_resource.plan, ResourcePlan::TextureSource { family: f, residency: r, descriptor: d } + if *f == family.id && r == residency && d == descriptor) + { + return Err(invalid( + "texture family source is not canonical", + format!("textureFamilies[{fi}].source"), + )); + } + if family.versions.is_empty() { + return Err(invalid( + "texture family has no versions", + format!("textureFamilies[{fi}].versions"), + )); + } + let mut first = u32::MAX; + let mut last = 0; + let mut all_initialized = true; + for (vi, version) in family.versions.iter().enumerate() { + let expected_target = if vi == 0 { + *source + } else { + family.versions[vi - 1].resource + }; + let resource = graph + .resources + .get(version.resource as usize) + .ok_or_else(|| { + invalid( + "texture version resource is out of bounds", + format!("textureFamilies[{fi}].versions[{vi}].resource"), + ) + })?; + if version.version as usize != vi + || version.target != expected_target + || resource.semantic_type != SemanticType::Texture + || resource.producer_execution.is_none() + || resource.lifetime != Some(version.lifetime) + || !matches!(resource.plan, ResourcePlan::Texture { family: f, version: v, target, initialized, stored, allocation } + if f == family.id && v == vi as u32 && target == expected_target + && initialized == version.initialized && stored == version.stored && allocation == family.allocation) + { + return Err(invalid( + "texture version is not canonical", + format!("textureFamilies[{fi}].versions[{vi}]"), + )); + } + first = first.min(version.lifetime.first_use); + last = last.max(version.lifetime.last_use); + all_initialized &= version.initialized; + } + if family.lifetime + != (Lifetime { + first_use: first, + last_use: last, + }) + { + return Err(invalid( + "texture family lifetime is not canonical", + format!("textureFamilies[{fi}].lifetime"), + )); + } + let expected_aliasable = *residency == TextureResidency::Transient && all_initialized; + if family.aliasable != expected_aliasable { + return Err(invalid( + "texture family aliasability is not canonical", + format!("textureFamilies[{fi}].aliasable"), + )); + } + } + Ok(()) +} + pub fn prepare_runtime_plan( graph: &CompiledGraph, surface: RuntimeSurfaceContract, limits: Option<&wgpu::Limits>, ) -> Result { + validate_canonical_plan(graph)?; if surface.width == 0 || surface.height == 0 { return Err(error( "GRAPH_SURFACE_INCOMPATIBLE", @@ -269,20 +923,23 @@ pub fn prepare_runtime_plan( "surface.usage", )); } - - let mut present_count = 0; + let mut frame_out_index = None; let mut query = None; let mut executions = Vec::with_capacity(graph.executions.len()); for (i, execution) in graph.executions.iter().enumerate() { let path = format!("executions[{i}]"); match execution.executor.key.as_str() { "mesh_query" => { - let NormalizedParameters::MeshQuery { filters } = &execution.parameters else { + let NormalizedParameters::MeshQuery { + visible_predicate, + frustum_culled_predicate, + } = &execution.parameters + else { return Err(invalid("mesh query parameters mismatch", &path)); }; let key = MeshQueryRuntimeKey { - visible: filters[0].predicate, - frustum_culled: filters[1].predicate, + visible: *visible_predicate, + frustum_culled: *frustum_culled_predicate, }; if query.replace(key).is_some() { return Err(error( @@ -292,11 +949,19 @@ pub fn prepare_runtime_plan( )); } } - "legacy_forward" => {} + "pipeline_registry" | "pipeline" => {} "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" | "luminance_edge" => {} "frustum_cull" => {} - "present" => present_count += 1, + "frame_out" => { + if frame_out_index.replace(i).is_some() { + return Err(error( + "GRAPH_EXECUTION_UNSUPPORTED", + "exactly one frame_out is required", + "executions", + )); + } + } _ => { return Err(error( "GRAPH_EXECUTION_UNSUPPORTED", @@ -310,13 +975,13 @@ pub fn prepare_runtime_plan( executor: execution.executor.key.clone(), }); } - if present_count != 1 { - return Err(error( + let frame_out_index = frame_out_index.ok_or_else(|| { + error( "GRAPH_EXECUTION_UNSUPPORTED", - "exactly one present is required", + "exactly one frame_out is required", "executions", - )); - } + ) + })?; let query = query.ok_or_else(|| { error( "GRAPH_EXECUTION_UNSUPPORTED", @@ -325,7 +990,511 @@ pub fn prepare_runtime_plan( ) })?; - let mut surface_pair = None; + for (i, execution) in graph.executions.iter().enumerate() { + if execution.executor.key != "pipeline_registry" { + continue; + } + if !matches!(execution.parameters, NormalizedParameters::PipelineRegistry) { + return Err(invalid( + "pipeline registry parameters mismatch", + format!("executions[{i}].parameters"), + )); + } + if !matches!(execution.kind, ExecutionKind::CpuPreparation) { + return Err(invalid( + "pipeline registry kind mismatch", + format!("executions[{i}].kind"), + )); + } + let [CompiledSocketInput { + socket: input_socket, + resource: pipeline_indices, + }] = execution.inputs.as_slice() + else { + return Err(invalid( + "pipeline registry input shape mismatch", + format!("executions[{i}].inputs"), + )); + }; + if input_socket != "pipelineIndices" { + return Err(invalid( + "pipeline registry input socket mismatch", + format!("executions[{i}].inputs"), + )); + } + let [CompiledSocketOutput { + socket: output_socket, + resource: activation, + }] = execution.outputs.as_slice() + else { + return Err(invalid( + "pipeline registry output shape mismatch", + format!("executions[{i}].outputs"), + )); + }; + if output_socket != "activation" { + return Err(invalid( + "pipeline registry output socket mismatch", + format!("executions[{i}].outputs"), + )); + } + if !matches!( + execution.accesses.as_slice(), + [CompiledAccess { socket, resource, mode: AccessMode::SemanticRead }] + if socket == "pipelineIndices" && resource == pipeline_indices + ) { + return Err(invalid( + "pipeline registry access mismatch", + format!("executions[{i}].accesses"), + )); + } + let indices_resource = + graph + .resources + .get(*pipeline_indices as usize) + .ok_or_else(|| { + invalid( + "pipeline index stream is out of bounds", + format!("executions[{i}].inputs"), + ) + })?; + let ResourcePlan::PipelineIndexStream { mesh } = indices_resource.plan else { + return Err(invalid( + "pipeline registry input is not a pipeline index stream", + format!("resources[{pipeline_indices}].plan"), + )); + }; + if indices_resource.semantic_type != SemanticType::PipelineIndexStream + || !graph.resources.get(mesh as usize).is_some_and(|resource| { + resource.semantic_type == SemanticType::MeshData + && matches!(resource.plan, ResourcePlan::MeshData) + }) + { + return Err(invalid( + "pipeline index stream mesh provenance is invalid", + format!("resources[{pipeline_indices}].plan"), + )); + } + let activation_resource = graph.resources.get(*activation as usize).ok_or_else(|| { + invalid( + "pipeline activation is out of bounds", + format!("executions[{i}].outputs"), + ) + })?; + if activation_resource.semantic_type != SemanticType::PipelineActivation + || !matches!(activation_resource.plan, ResourcePlan::PipelineActivation { pipeline_indices: source } if source == *pipeline_indices) + || activation_resource.producer_execution != Some(i as u32) + { + return Err(invalid( + "pipeline activation provenance is invalid", + format!("resources[{activation}].plan"), + )); + } + } + + for (i, execution) in graph.executions.iter().enumerate() { + if execution.executor.key != "pipeline" { + continue; + } + let NormalizedParameters::Pipeline { + pipeline, + clear_depth, + clear_color, + .. + } = &execution.parameters + else { + return Err(invalid( + "pipeline parameters mismatch", + format!("executions[{i}].parameters"), + )); + }; + if !valid_pipeline_name(pipeline) + || !clear_depth.is_finite() + || !(0.0..=1.0).contains(clear_depth) + || clear_color.iter().any(|value| !value.is_finite()) + { + return Err(invalid( + "pipeline parameters are invalid", + format!("executions[{i}].parameters"), + )); + } + let ExecutionKind::Render { + color_attachments, + depth_stencil: Some(depth_attachment), + } = &execution.kind + else { + return Err(invalid( + "pipeline render kind mismatch", + format!("executions[{i}].kind"), + )); + }; + let [color_attachment] = color_attachments.as_slice() else { + return Err(invalid( + "pipeline color attachment shape mismatch", + format!("executions[{i}].kind"), + )); + }; + let [mesh_input, draws_input, activation_input, color_input, depth_input] = + execution.inputs.as_slice() + else { + return Err(invalid( + "pipeline input shape mismatch", + format!("executions[{i}].inputs"), + )); + }; + if [ + mesh_input.socket.as_str(), + draws_input.socket.as_str(), + activation_input.socket.as_str(), + color_input.socket.as_str(), + depth_input.socket.as_str(), + ] != ["mesh", "draws", "activation", "colorTarget", "depthTarget"] + { + return Err(invalid( + "pipeline input sockets mismatch", + format!("executions[{i}].inputs"), + )); + } + let [color_output, depth_output] = execution.outputs.as_slice() else { + return Err(invalid( + "pipeline output shape mismatch", + format!("executions[{i}].outputs"), + )); + }; + if color_output.socket != "color" + || depth_output.socket != "depth" + || color_output.resource != color_attachment.resource + || depth_output.resource != depth_attachment.resource + { + return Err(invalid( + "pipeline outputs disagree with attachments", + format!("executions[{i}].outputs"), + )); + } + let color_version = match graph + .resources + .get(color_output.resource as usize) + .map(|r| &r.plan) + { + Some(ResourcePlan::Texture { version, .. }) => *version, + _ => { + return Err(invalid( + "pipeline color output kind is invalid", + format!("resources[{}].plan", color_output.resource), + )) + } + }; + let depth_version = match graph + .resources + .get(depth_output.resource as usize) + .map(|r| &r.plan) + { + Some(ResourcePlan::Texture { version, .. }) => *version, + _ => { + return Err(invalid( + "pipeline depth output kind is invalid", + format!("resources[{}].plan", depth_output.resource), + )) + } + }; + let expected_color_load = if color_version == 0 { + NormalizedColorLoad::Clear { + value: *clear_color, + } + } else { + NormalizedColorLoad::Load + }; + let expected_depth_load = if depth_version == 0 { + NormalizedDepthLoad::Clear { + value: *clear_depth, + } + } else { + NormalizedDepthLoad::Load + }; + if color_attachment.load != expected_color_load { + return Err(invalid( + "pipeline color load is not canonical", + format!("executions[{i}].kind"), + )); + } + if depth_attachment.load != expected_depth_load { + return Err(invalid( + "pipeline depth load is not canonical", + format!("executions[{i}].kind"), + )); + } + let [mesh_access, draws_access, activation_access, color_access, depth_access] = + execution.accesses.as_slice() + else { + return Err(invalid( + "pipeline access shape mismatch", + format!("executions[{i}].accesses"), + )); + }; + if mesh_access.socket != "mesh" + || mesh_access.resource != mesh_input.resource + || !matches!(mesh_access.mode, AccessMode::SemanticRead) + || draws_access.socket != "draws" + || draws_access.resource != draws_input.resource + || !matches!(draws_access.mode, AccessMode::IndirectRead) + || activation_access.socket != "activation" + || activation_access.resource != activation_input.resource + || !matches!(activation_access.mode, AccessMode::SemanticRead) + { + return Err(invalid( + "pipeline semantic accesses mismatch", + format!("executions[{i}].accesses"), + )); + } + let color_clear = matches!(color_attachment.load, NormalizedColorLoad::Clear { .. }); + let depth_clear = matches!(depth_attachment.load, NormalizedDepthLoad::Clear { .. }); + if color_access.socket != "color" + || color_access.resource != color_output.resource + || !matches!( + color_access.mode, + AccessMode::ColorAttachment { location: 0, load, store: StoreOp::Store, full_overwrite } + if load == color_attachment.load && full_overwrite == color_clear + ) + || color_attachment.location != 0 + || color_attachment.store != StoreOp::Store + || depth_access.socket != "depth" + || depth_access.resource != depth_output.resource + || !matches!( + depth_access.mode, + AccessMode::DepthAttachment { load, store: StoreOp::Store, full_overwrite } + if load == depth_attachment.load && full_overwrite == depth_clear + ) + || depth_attachment.store != StoreOp::Store + { + return Err(invalid( + "pipeline attachment accesses mismatch", + format!("executions[{i}].accesses"), + )); + } + let mesh_resource = graph + .resources + .get(mesh_input.resource as usize) + .ok_or_else(|| { + invalid( + "pipeline mesh is out of bounds", + format!("executions[{i}].inputs"), + ) + })?; + if mesh_resource.semantic_type != SemanticType::MeshData + || !matches!(mesh_resource.plan, ResourcePlan::MeshData) + { + return Err(invalid( + "pipeline mesh is invalid", + format!("resources[{}].plan", mesh_input.resource), + )); + } + let draws_mesh = match graph.resources.get(draws_input.resource as usize) { + Some(CompiledResource { + semantic_type: SemanticType::DrawStream, + plan: ResourcePlan::DrawStream { mesh }, + .. + }) => *mesh, + _ => { + return Err(invalid( + "pipeline draw stream is invalid", + format!("resources[{}].plan", draws_input.resource), + )) + } + }; + let activation_resource = graph + .resources + .get(activation_input.resource as usize) + .ok_or_else(|| { + invalid( + "pipeline activation is out of bounds", + format!("executions[{i}].inputs"), + ) + })?; + let indices = match activation_resource.plan { + ResourcePlan::PipelineActivation { pipeline_indices } + if activation_resource.semantic_type == SemanticType::PipelineActivation => + { + pipeline_indices + } + _ => { + return Err(invalid( + "pipeline activation is invalid", + format!("resources[{}].plan", activation_input.resource), + )) + } + }; + let activation_mesh = match graph.resources.get(indices as usize) { + Some(CompiledResource { + semantic_type: SemanticType::PipelineIndexStream, + plan: ResourcePlan::PipelineIndexStream { mesh }, + .. + }) => *mesh, + _ => { + return Err(invalid( + "pipeline activation index stream is invalid", + format!("resources[{indices}].plan"), + )) + } + }; + let valid_activation_producer = activation_resource + .producer_execution + .and_then(|producer| graph.executions.get(producer as usize)) + .is_some_and(|producer| producer.executor.key == "pipeline_registry"); + if draws_mesh != mesh_input.resource + || activation_mesh != mesh_input.resource + || !valid_activation_producer + { + return Err(invalid( + "pipeline mesh provenance disagrees", + format!("executions[{i}].inputs"), + )); + } + if !has_exact_producer(graph, i, draws_input.resource, "mesh_query", "draws") { + return Err(invalid( + "pipeline draw stream producer mismatch", + format!("executions[{i}].inputs"), + )); + } + for (output, target) in [ + (color_output.resource, color_input.resource), + (depth_output.resource, depth_input.resource), + ] { + let resource = graph.resources.get(output as usize).ok_or_else(|| { + invalid( + "pipeline output is out of bounds", + format!("executions[{i}].outputs"), + ) + })?; + if resource.semantic_type != SemanticType::Texture { + return Err(invalid( + "pipeline output is not a texture", + format!("resources[{output}].semanticType"), + )); + } + if resource.producer_execution != Some(i as u32) + || !matches!(resource.plan, ResourcePlan::Texture { target: actual, .. } if actual == target) + { + return Err(invalid( + "pipeline texture transition is invalid", + format!("resources[{output}].plan"), + )); + } + } + let color_descriptor = + texture_descriptor(graph, color_output.resource).ok_or_else(|| { + invalid( + "pipeline color attachment descriptor is invalid", + format!("executions[{i}].inputs"), + ) + })?; + if color_descriptor.format == TextureFormat::Depth32Float + || !super::compiler::is_single_view_d2(color_descriptor) + { + return Err(invalid( + "pipeline color attachment descriptor is invalid", + format!("executions[{i}].inputs"), + )); + } + let depth_descriptor = + texture_descriptor(graph, depth_output.resource).ok_or_else(|| { + invalid( + "pipeline depth attachment descriptor is invalid", + format!("executions[{i}].inputs"), + ) + })?; + if depth_descriptor.format != TextureFormat::Depth32Float + || !super::compiler::is_single_view_d2(depth_descriptor) + { + return Err(invalid( + "pipeline depth attachment descriptor is invalid", + format!("executions[{i}].inputs"), + )); + } + if color_descriptor.extent != depth_descriptor.extent { + return Err(invalid( + "pipeline attachment extents mismatch", + format!("executions[{i}].inputs"), + )); + } + } + + let frame_out = &graph.executions[frame_out_index]; + if !matches!(frame_out.parameters, NormalizedParameters::FrameOut) { + return Err(invalid( + "frame_out parameters mismatch", + format!("executions[{frame_out_index}].parameters"), + )); + } + let ExecutionKind::FrameOut { color } = frame_out.kind else { + return Err(invalid( + "frame_out execution kind mismatch", + format!("executions[{frame_out_index}].kind"), + )); + }; + if !frame_out.outputs.is_empty() { + return Err(invalid( + "frame_out must not have outputs", + format!("executions[{frame_out_index}].outputs"), + )); + } + if !matches!( + frame_out.inputs.as_slice(), + [CompiledSocketInput { socket, resource }] if socket == "color" && *resource == color + ) { + return Err(invalid( + "frame_out input does not match its color resource", + format!("executions[{frame_out_index}].inputs"), + )); + } + if !matches!( + frame_out.accesses.as_slice(), + [CompiledAccess { socket, resource, mode: AccessMode::SampledTexture }] + if socket == "color" && *resource == color + ) { + return Err(invalid( + "frame_out access does not match its color resource", + format!("executions[{frame_out_index}].accesses"), + )); + } + let color_resource = graph.resources.get(color as usize).ok_or_else(|| { + invalid( + "frame_out resource is out of bounds", + format!("executions[{frame_out_index}].kind.color"), + ) + })?; + if color_resource.semantic_type != SemanticType::Texture { + return Err(invalid( + "frame_out resource is not a texture", + format!("resources[{color}].semanticType"), + )); + } + let ResourcePlan::Texture { + family, + initialized: true, + stored: true, + allocation: Some(_), + .. + } = color_resource.plan + else { + return Err(invalid( + "frame_out color is not a stored initialized allocated texture", + format!("resources[{color}].plan"), + )); + }; + let family = graph.texture_families.get(family as usize).ok_or_else(|| { + invalid( + "frame_out texture family is out of bounds", + format!("resources[{color}].plan.family"), + ) + })?; + let TextureFamilySource::AuthoredTexture { descriptor, .. } = &family.source; + if !super::compiler::is_filterable_frame_color(descriptor) { + return Err(invalid( + "frame_out texture descriptor is incompatible", + format!("textureFamilies[{}].source.descriptor", family.id), + )); + } + let mut resource_allocations = vec![None; graph.resources.len()]; for (fi, family) in graph.texture_families.iter().enumerate() { if family.id as usize != fi { @@ -334,17 +1503,13 @@ pub fn prepare_runtime_plan( format!("textureFamilies[{fi}].id"), )); } + if family.usage != super::compiler::texture_usage(family, &graph.executions) { + return Err(invalid( + "texture family usage is not canonical", + format!("textureFamilies[{fi}].usage"), + )); + } 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, @@ -418,9 +1583,6 @@ pub fn prepare_runtime_plan( 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() { @@ -460,7 +1622,21 @@ pub fn prepare_runtime_plan( class: ci as u32, slot: si as u32, }; + if slot.occupants.is_empty() { + return Err(invalid( + "allocation slot has no occupants", + format!("allocationClasses[{ci}].slots[{si}].occupants"), + )); + } + let mut expected_usage = BTreeSet::new(); + let mut occupants = HashSet::new(); for &family_id in &slot.occupants { + if !occupants.insert(family_id) { + return Err(invalid( + "allocation slot has duplicate occupants", + format!("allocationClasses[{ci}].slots[{si}].occupants"), + )); + } let family = graph .texture_families .get(family_id as usize) @@ -476,12 +1652,33 @@ pub fn prepare_runtime_plan( 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}]"), - )); + let TextureFamilySource::AuthoredTexture { + descriptor, + residency, + .. + } = &family.source; + expected_usage.extend(family.usage.iter().copied()); + let kind_valid = match slot.kind { + AllocationKind::AliasedTransient => { + *residency == TextureResidency::Transient && family.aliasable + } + AllocationKind::DedicatedTransient => { + slot.occupants.len() == 1 + && *residency == TextureResidency::Transient + && !family.aliasable + } + AllocationKind::Persistent => { + slot.occupants.len() == 1 + && *residency == TextureResidency::Persistent + && !family.aliasable + } }; + if !kind_valid { + return Err(invalid( + "allocation slot kind disagrees with occupant", + format!("allocationClasses[{ci}].slots[{si}].occupants"), + )); + } if descriptor.dimension != class.key.dimension || descriptor.format != class.key.format || descriptor.extent != class.key.extent @@ -495,6 +1692,26 @@ pub fn prepare_runtime_plan( )); } } + if slot.usage != expected_usage.into_iter().collect::>() { + return Err(invalid( + "allocation slot usage is not canonical", + format!("allocationClasses[{ci}].slots[{si}].usage"), + )); + } + if slot.kind == AllocationKind::AliasedTransient { + for (a, &left) in slot.occupants.iter().enumerate() { + for &right in &slot.occupants[a + 1..] { + let l = graph.texture_families[left as usize].lifetime; + let r = graph.texture_families[right as usize].lifetime; + if l.first_use <= r.last_use && r.first_use <= l.last_use { + return Err(invalid( + "aliased occupant lifetimes overlap", + format!("allocationClasses[{ci}].slots[{si}].occupants"), + )); + } + } + } + } slots.push(RuntimeAllocationSlot { kind: slot.kind, descriptor: runtime_texture_descriptor( @@ -531,60 +1748,10 @@ pub fn prepare_runtime_plan( } } } - 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, diff --git a/renderer/src/render_graph/schema.rs b/renderer/src/render_graph/schema.rs index bf19976..ce089ab 100644 --- a/renderer/src/render_graph/schema.rs +++ b/renderer/src/render_graph/schema.rs @@ -114,11 +114,13 @@ pub enum TriStatePredicate { 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 = "snake_case")] +pub enum RuntimePredicate { + Any, + RequiredTrue, + RequiredFalse, + Never, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] diff --git a/renderer/src/render_graph/tests.rs b/renderer/src/render_graph/tests.rs index 5a9f575..4d8dd0f 100644 --- a/renderer/src/render_graph/tests.rs +++ b/renderer/src/render_graph/tests.rs @@ -12,70 +12,74 @@ fn node(id: &str, key: &str, parameters: Value, inputs: Value) -> Value { 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 { +pub(crate) 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")})) + node("color","texture",texture("rgba8_unorm","transient"),json!({})), + node("depth","texture",texture("depth32_float","transient"),json!({})), + node("mesh","mesh",json!({}),json!({})), + node("cull","frustum_cull",json!({"camera":"active"}),json!({"mesh":input("mesh","mesh"),"localAabbs":input("mesh","localAabbs")})), + node("query","mesh_query",json!({"visiblePredicate":"required_true","visibleDefault":true,"frustumCulledPredicate":"required_false","frustumCulledDefault":false}),json!({"mesh":input("mesh","mesh"),"isVisible":input("mesh","isVisible"),"isFrustumCulled":input("cull","isFrustumCulled")})), + node("registry","pipeline_registry",json!({}),json!({"pipelineIndices":input("mesh","pipelineIndices")})), + node("pipeline_main","pipeline",json!({"pipeline":"gltf_standard","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1]}),json!({"mesh":input("mesh","mesh"),"draws":input("query","draws"),"activation":input("registry","activation"),"colorTarget":input("color","texture"),"depthTarget":input("depth","texture")})), + node("frame_out","frame_out",json!({}),json!({"color":input("pipeline_main","color")})) ]}) } -fn forward(id: &str, color: Value, depth: Value) -> Value { +fn pipeline_node(id: &str, color: Value, depth: Value) -> Value { node( id, - "legacy_forward", - json!({"clearColor":[0,0,0,1]}), + "pipeline", + json!({"pipeline":"gltf_standard","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1]}), json!({ - "scene":input("scene","scene"), + "mesh":input("mesh","mesh"), "draws":input("query","draws"), "colorTarget":color, "depthTarget":depth, - "depthStencil":input("depth_config","config") + "activation":input("registry","activation") }), ) } fn render_support_nodes() -> Vec { vec![ - node("scene", "scene_table", json!({}), json!({})), - node( - "visible", - "visibility_flags", - json!({}), - json!({"scene":input("scene","scene")}), - ), + node("mesh", "mesh", json!({}), json!({})), node( "query", "mesh_query", - json!({"filters":[ - {"flag":"isVisible","predicate":"required_true"}, - {"flag":"isFrustumCulled","predicate":"any"} - ]}), - json!({"scene":input("scene","scene"),"isVisible":input("visible","flags")}), + json!({"visiblePredicate":"required_true","visibleDefault":true,"frustumCulledPredicate":"any","frustumCulledDefault":false}), + json!({"mesh":input("mesh","mesh"),"isVisible":input("mesh","isVisible")}), ), node( - "depth_config", - "depth_stencil_config", - json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0}), + "registry", + "pipeline_registry", json!({}), + json!({"pipelineIndices":input("mesh","pipelineIndices")}), ), ] } fn graph(nodes: Vec) -> Value { json!({"schemaVersion":2,"graphId":"hazards","revision":1,"nodes":nodes}) } +fn node_index(graph: &Value, id: &str) -> usize { + graph["nodes"] + .as_array() + .unwrap() + .iter() + .position(|node| node["id"] == id) + .unwrap() +} +fn node_path(graph: &Value, id: &str, suffix: &str) -> String { + format!("nodes[{}].{suffix}", node_index(graph, id)) +} fn hdr_copy_graph() -> Value { let mut nodes = vec![ - node("surface", "surface_target", json!({}), json!({})), + node( + "color", + "texture", + texture("rgba8_unorm", "transient"), + json!({}), + ), node( "hdr", - "texture_spec", + "texture", texture("rgba16_float", "transient"), json!({}), ), @@ -83,26 +87,94 @@ fn hdr_copy_graph() -> Value { ]; nodes.extend(render_support_nodes()); nodes.extend([ - forward("forward", input("hdr", "spec"), input("depth", "spec")), + pipeline_node( + "pipeline_main", + input("hdr", "texture"), + input("depth", "texture"), + ), node( "copy", "fullscreen_copy", json!({}), - json!({"source":input("forward","color"),"colorTarget":input("surface","surface")}), + json!({"source":input("pipeline_main","color"),"colorTarget":input("color","texture")}), ), node( - "present", - "present", + "frame_out", + "frame_out", json!({}), - json!({"surface":input("copy","color")}), + json!({"color":input("copy","color")}), ), ]); graph(nodes) } -fn cyclic_forwards() -> Vec { + +fn bloom_composite_graph() -> Value { + let mut half = texture("rgba16_float", "transient"); + half["texture"]["extent"]["width"] = json!({"numerator":1,"denominator":2}); + half["texture"]["extent"]["height"] = json!({"numerator":1,"denominator":2}); + let mut bloom_depth = texture("depth32_float", "transient"); + bloom_depth["texture"]["extent"] = half["texture"]["extent"].clone(); + let mut nodes = vec![ + node( + "color", + "texture", + texture("rgba8_unorm", "transient"), + json!({}), + ), + node( + "source", + "texture", + texture("rgba16_float", "transient"), + json!({}), + ), + node("bloom", "texture", half, json!({})), + node( + "target", + "texture", + texture("rgba16_float", "transient"), + json!({}), + ), + depth_spec("source_depth", "transient"), + node("bloom_depth", "texture", bloom_depth, json!({})), + ]; + nodes.extend(render_support_nodes()); + nodes.extend([ + pipeline_node( + "source_writer", + input("source", "texture"), + input("source_depth", "texture"), + ), + pipeline_node( + "bloom_writer", + input("bloom", "texture"), + input("bloom_depth", "texture"), + ), + node( + "composite", + "bloom_composite", + json!({"intensity":1.0}), + json!({"source":input("source_writer","color"),"bloom":input("bloom_writer","color"),"colorTarget":input("target","texture")}), + ), + node( + "to_surface", + "fullscreen_copy", + json!({}), + json!({"source":input("composite","color"),"colorTarget":input("color","texture")}), + ), + node( + "frame_out", + "frame_out", + json!({}), + json!({"color":input("to_surface","color")}), + ), + ]); + graph(nodes) +} + +fn cyclic_pipelines() -> Vec { vec![ - forward("A", input("B", "color"), input("B", "depth")), - forward("B", input("A", "color"), input("A", "depth")), + pipeline_node("A", input("B", "color"), input("B", "depth")), + pipeline_node("B", input("A", "color"), input("A", "depth")), ] } fn compile_graph(v: Value) -> CompiledGraph { @@ -117,11 +189,11 @@ fn fullscreen_copy_hdr_graph_lowers_versions_accesses_and_usage() { .iter() .map(|execution| execution.id.as_str()) .collect::>(), - ["query", "forward", "copy", "present"] + ["query", "registry", "pipeline_main", "copy", "frame_out"] ); for (node, socket) in [ - ("forward", "color"), - ("forward", "depth"), + ("pipeline_main", "color"), + ("pipeline_main", "depth"), ("copy", "color"), ] { assert!(matches!( @@ -130,7 +202,7 @@ fn fullscreen_copy_hdr_graph_lowers_versions_accesses_and_usage() { )); } let copy = execution(&p, "copy"); - let source = resource_by_origin(&p, "forward", "color"); + let source = resource_by_origin(&p, "pipeline_main", "color"); let color = resource_by_origin(&p, "copy", "color"); assert!(copy.accesses.iter().any(|access| access.resource == p.resources @@ -163,176 +235,212 @@ fn fullscreen_copy_hdr_graph_lowers_versions_accesses_and_usage() { .into_iter() .collect() ); - let surface = p + assert!(p .texture_families .iter() - .find(|family| matches!(family.source, TextureFamilySource::ImportedSurface { .. })) - .unwrap(); - assert_eq!(surface.versions[0].resource, color_id); + .all(|family| matches!(family.source, TextureFamilySource::AuthoredTexture { .. }))); } #[test] fn fullscreen_copy_parameters_are_exactly_empty() { let mut g = hdr_copy_graph(); - g["nodes"][8]["parameters"] = json!({"obsolete":true}); + let copy = node_index(&g, "copy"); + g["nodes"][copy]["parameters"] = json!({"obsolete":true}); assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); - assert_eq!(CONTRACTS.len(), 17); + assert_eq!(CONTRACTS.len(), 13); } #[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 copy = node_index(&g, "copy"); + let path = node_path(&g, "copy", "inputs"); + g["nodes"][copy]["inputs"]["colorTarget"] = input("pipeline_main", "color"); let error = compile_error(g); assert_eq!(error.code, "GRAPH_SAME_PASS_HAZARD"); - assert_eq!(error.details["path"], "nodes[8].inputs"); + assert_eq!(error.details["path"], path); } #[test] fn duplicate_texture_writer_reports_second_color_target() { - let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; + let mut nodes = vec![ + node( + "color", + "texture", + texture("rgba16_float", "transient"), + json!({}), + ), + node( + "output", + "texture", + texture("rgba16_float", "transient"), + json!({}), + ), + ]; nodes.extend(render_support_nodes()); nodes.extend([ node( "depth_a", - "texture_spec", + "texture", texture("depth32_float", "transient"), json!({}), ), node( "depth_b", - "texture_spec", + "texture", texture("depth32_float", "transient"), json!({}), ), - forward("F0", input("surface", "surface"), input("depth_a", "spec")), + pipeline_node("F0", input("color", "texture"), input("depth_a", "texture")), + pipeline_node("F1", input("color", "texture"), input("depth_b", "texture")), node( - "P0", - "present", - json!({}), - json!({"surface":input("F0","color")}), + "join", + "bloom_composite", + json!({"intensity":1.0}), + json!({"source":input("F0","color"),"bloom":input("F1","color"),"colorTarget":input("output","texture")}), ), - forward("F1", input("surface", "surface"), input("depth_b", "spec")), node( - "P1", - "present", + "frame_out", + "frame_out", json!({}), - json!({"surface":input("F1","color")}), + json!({"color":input("join","color")}), ), ]); - let error = compile_error(graph(nodes)); + let graph = graph(nodes); + let path = node_path(&graph, "F1", "inputs.colorTarget"); + let error = compile_error(graph); assert_eq!(error.code, "GRAPH_DUPLICATE_WRITER"); - assert_eq!(error.details["path"], "nodes[9].inputs.colorTarget"); + assert_eq!(error.details["path"], path); } #[test] fn same_output_bound_to_both_attachments_is_a_same_pass_hazard() { let mut nodes = vec![node( "target", - "texture_spec", + "texture", texture("rgba8_unorm", "transient"), json!({}), )]; nodes.extend(render_support_nodes()); nodes.extend([ - forward("F", input("target", "spec"), input("target", "spec")), + pipeline_node("F", input("target", "texture"), input("target", "texture")), node( "P", - "present", + "frame_out", json!({}), - json!({"surface":input("F","color")}), + json!({"color":input("F","color")}), ), ]); let error = compile_error(graph(nodes)); assert_eq!(error.code, "GRAPH_SAME_PASS_HAZARD"); - assert_eq!(error.details["path"], "nodes[5].inputs"); + assert_eq!(error.details["path"], "nodes[4].inputs"); } #[test] fn unordered_old_texture_version_read_is_rejected_before_scheduling() { let mut nodes = vec![ - node("surface", "surface_target", json!({}), json!({})), + node( + "color", + "texture", + texture("rgba16_float", "transient"), + json!({}), + ), + node( + "output", + "texture", + texture("rgba16_float", "transient"), + json!({}), + ), node( "depth_0", - "texture_spec", + "texture", texture("depth32_float", "transient"), json!({}), ), node( "depth_1", - "texture_spec", + "texture", 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")), + pipeline_node("F0", input("color", "texture"), input("depth_0", "texture")), + pipeline_node("F1", input("F0", "color"), input("depth_1", "texture")), node( - "P0", - "present", - json!({}), - json!({"surface":input("F0","color")}), + "join", + "bloom_composite", + json!({"intensity":1.0}), + json!({"source":input("F0","color"),"bloom":input("F1","color"),"colorTarget":input("output","texture")}), ), node( - "P1", - "present", + "frame_out", + "frame_out", json!({}), - json!({"surface":input("F1","color")}), + json!({"color":input("join","color")}), ), ]); - let error = compile_error(graph(nodes)); + let graph = graph(nodes); + let path = node_path(&graph, "join", "inputs.source"); + let error = compile_error(graph); assert_eq!(error.code, "GRAPH_RESOURCE_VERSION_INVALID"); - assert_eq!(error.details["path"], "nodes[9].inputs.surface"); + assert_eq!(error.details["path"], path); } #[test] fn duplicate_successors_defer_old_version_reachability() { let mut nodes = vec![ - node("surface", "surface_target", json!({}), json!({})), + node( + "color", + "texture", + texture("rgba16_float", "transient"), + json!({}), + ), + node( + "output", + "texture", + texture("rgba16_float", "transient"), + json!({}), + ), depth_spec("depth_0", "transient"), depth_spec("depth_1", "transient"), depth_spec("depth_2", "transient"), ]; nodes.extend(render_support_nodes()); nodes.extend([ - 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")), + pipeline_node("F0", input("color", "texture"), input("depth_0", "texture")), + pipeline_node("F1", input("F0", "color"), input("depth_1", "texture")), + pipeline_node("F2", input("F0", "color"), input("depth_2", "texture")), node( - "P0", - "present", - json!({}), - json!({"surface":input("F0","color")}), - ), - node( - "P1", - "present", - json!({}), - json!({"surface":input("F1","color")}), + "join", + "bloom_composite", + json!({"intensity":1.0}), + json!({"source":input("F1","color"),"bloom":input("F2","color"),"colorTarget":input("output","texture")}), ), node( "P2", - "present", + "frame_out", json!({}), - json!({"surface":input("F2","color")}), + json!({"color":input("join","color")}), ), ]); - let error = compile_error(graph(nodes)); + let graph = graph(nodes); + let path = node_path(&graph, "F2", "inputs.colorTarget"); + let error = compile_error(graph); assert_eq!(error.code, "GRAPH_DUPLICATE_WRITER"); - assert_eq!(error.details["path"], "nodes[10].inputs.colorTarget"); + assert_eq!(error.details["path"], path); } #[test] fn live_texture_cycle_reports_the_exact_first_cycle() { let mut nodes = render_support_nodes(); - nodes.extend(cyclic_forwards()); + nodes.extend(cyclic_pipelines()); nodes.push(node( - "present", - "present", + "frame_out", + "frame_out", json!({}), - json!({"surface":input("A","color")}), + json!({"color":input("A","color")}), )); let error = compile_error(graph(nodes)); assert_eq!(error.code, "GRAPH_CYCLE"); @@ -355,9 +463,9 @@ fn dead_texture_cycle_is_culled_without_cycle_execution() { value["nodes"] .as_array_mut() .unwrap() - .extend(cyclic_forwards()); + .extend(cyclic_pipelines()); let plan = compile_graph(value); - assert_eq!(plan.node_count, 13); + assert_eq!(plan.node_count, 10); assert_eq!(plan.culled_node_count, 2); assert_eq!(plan.culled_resource_count, 4); assert!(!plan @@ -379,9 +487,9 @@ fn resource_by_origin<'a>(p: &'a CompiledGraph, node: &str, socket: &str) -> &'a } fn family_by_source<'a>(p: &'a CompiledGraph, node: &str) -> &'a TextureFamily { - let source = resource_by_origin(p, node, "spec"); + let source = resource_by_origin(p, node, "texture"); let family = match source.plan { - ResourcePlan::TextureSpec { family, .. } => family, + ResourcePlan::TextureSource { family, .. } => family, _ => panic!("{node} is not a texture specification"), }; &p.texture_families[family as usize] @@ -393,18 +501,23 @@ fn allocation_slot<'a>(p: &'a CompiledGraph, allocation: AllocationRef) -> &'a A fn independent_depth_graph( depth_specs: Vec, - forwards: Vec, + pipelines: Vec, present_from: &str, ) -> Value { - let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; + let mut nodes = vec![node( + "color", + "texture", + texture("rgba8_unorm", "transient"), + json!({}), + )]; nodes.extend(render_support_nodes()); nodes.extend(depth_specs); - nodes.extend(forwards); + nodes.extend(pipelines); nodes.push(node( - "present", - "present", + "frame_out", + "frame_out", json!({}), - json!({"surface":input(present_from,"color")}), + json!({"color":input(present_from,"color")}), )); graph(nodes) } @@ -412,7 +525,7 @@ fn independent_depth_graph( fn depth_spec(id: &str, residency: &str) -> Value { node( id, - "texture_spec", + "texture", texture("depth32_float", residency), json!({}), ) @@ -426,14 +539,15 @@ fn dense_lifetimes_exclude_authored_source_ordinals() { .iter() .map(|e| e.id.as_str()) .collect::>(), - ["cull", "query", "forward", "present"] + ["cull", "query", "registry", "pipeline_main", "frame_out"] ); 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), + ("mesh", "mesh", 0, 3), + ("mesh", "localAabbs", 0, 0), + ("cull", "isFrustumCulled", 0, 1), + ("mesh", "isVisible", 1, 1), + ("mesh", "pipelineIndices", 2, 2), + ("registry", "activation", 2, 3), ] { assert_eq!( resource_by_origin(&p, node, socket).lifetime, @@ -444,30 +558,30 @@ fn dense_lifetimes_exclude_authored_source_ordinals() { "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)); + let color = resource_by_origin(&p, "pipeline_main", "color"); + let depth = resource_by_origin(&p, "pipeline_main", "depth"); + assert_eq!(color.producer_execution, Some(3)); + assert_eq!(depth.producer_execution, Some(3)); assert_eq!( color.lifetime, Some(Lifetime { - first_use: 2, - last_use: 3 + first_use: 3, + last_use: 4 }) ); assert_eq!( depth.lifetime, Some(Lifetime { - first_use: 2, - last_use: 2 + first_use: 3, + last_use: 3 }) ); let depth_family = family_by_source(&p, "depth"); assert_eq!( depth_family.lifetime, Lifetime { - first_use: 2, - last_use: 2 + first_use: 3, + last_use: 3 } ); assert_eq!(depth_family.versions[0].lifetime, depth_family.lifetime); @@ -481,16 +595,16 @@ fn transient_aliasing_is_declaration_order_independent() { depth_spec("depth_first", "transient"), ], vec![ - forward( + pipeline_node( "F0", - input("surface", "surface"), - input("depth_first", "spec"), + input("color", "texture"), + input("depth_first", "texture"), ), - forward("F1", input("F0", "color"), input("depth_second", "spec")), + pipeline_node("F1", input("F0", "color"), input("depth_second", "texture")), ], "F1", )); - assert_eq!(execution(&p, "F1").original_node_index, 8); + assert_eq!(execution(&p, "F1").original_node_index, 7); let f0_ordinal = p.executions.iter().position(|e| e.id == "F0").unwrap() as u32; let f1_ordinal = p.executions.iter().position(|e| e.id == "F1").unwrap() as u32; assert_eq!(f1_ordinal, f0_ordinal + 1); @@ -530,9 +644,9 @@ fn overlapping_family_lifetimes_prevent_transient_reuse() { 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")), + pipeline_node("F0", input("color", "texture"), input("depth_a", "texture")), + pipeline_node("F1", input("F0", "color"), input("depth_b", "texture")), + pipeline_node("F2", input("F1", "color"), input("F0", "depth")), ], "F2", )); @@ -553,13 +667,13 @@ fn persistent_textures_are_dedicated_and_follow_transient_slots() { depth_spec("persistent_a", "persistent"), ], vec![ - forward( + pipeline_node( "F0", - input("surface", "surface"), - input("persistent_a", "spec"), + input("color", "texture"), + input("persistent_a", "texture"), ), - forward("F1", input("F0", "color"), input("transient", "spec")), - forward("F2", input("F1", "color"), input("persistent_b", "spec")), + pipeline_node("F1", input("F0", "color"), input("transient", "texture")), + pipeline_node("F2", input("F1", "color"), input("persistent_b", "texture")), ], "F2", )); @@ -587,35 +701,48 @@ fn persistent_textures_are_dedicated_and_follow_transient_slots() { 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); + assert_eq!(p.transient_slot_count, 2); } #[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); +fn unsupported_texture_features_are_rejected_during_decode() { + let cases = [ + ("dimension", json!("d1"), "dimension"), + ("dimension", json!("d3"), "dimension"), + ("mipLevelCount", json!(2), "mipLevelCount"), + ("sampleCount", json!(4), "sampleCount"), + ]; + for (field, value, suffix) in cases { + let mut graph = full_cull_graph(); + graph["nodes"][1]["parameters"]["texture"][field] = value; + let error = compile_error(graph); + assert_eq!(error.code, "GRAPH_UNSUPPORTED_FEATURE", "{field}"); + assert_eq!( + error.details["path"], + format!("nodes[1].parameters.texture.{suffix}"), + "{field}" + ); + } + for kind in ["absolute", "surface_relative"] { + let mut graph = full_cull_graph(); + if kind == "absolute" { + graph["nodes"][1]["parameters"]["texture"]["extent"] = + json!({"kind":"absolute","width":64,"height":64,"depthOrArrayLayers":2}); + } else { + graph["nodes"][1]["parameters"]["texture"]["extent"]["depthOrArrayLayers"] = json!(2); + } + let error = compile_error(graph); + assert_eq!(error.code, "GRAPH_UNSUPPORTED_FEATURE", "{kind}"); + assert_eq!( + error.details["path"], "nodes[1].parameters.texture.extent.depthOrArrayLayers", + "{kind}" + ); + } } #[test] fn parser_and_registry_accept_only_canonical_schema() { - let bytes = - serde_json::to_vec(&json!({"schemaVersion":2,"graphId":"empty","revision":1,"nodes":[]})) - .unwrap(); + let bytes = serde_json::to_vec(&full_cull_graph()).unwrap(); assert!(parse_and_compile(&bytes).is_ok()); assert_eq!( parse_and_compile(br#"{"schemaVersion":1}"#) @@ -625,20 +752,43 @@ fn parser_and_registry_accept_only_canonical_schema() { ); let mut r = Registry::default(); let (id, _) = r.compile(&bytes).unwrap(); - assert_eq!(r.get(id).unwrap().graph_id, "empty"); + assert_eq!(r.get(id).unwrap().graph_id, "full"); } #[test] -fn authoritative_eleven_node_graph_lowers_exactly() { +fn authoritative_eight_node_graph_lowers_exactly() { let p = compile_graph(full_cull_graph()); - assert_eq!(p.node_count, 11); + assert_eq!(p.node_count, 8); assert_eq!(p.resources.len(), 11); + assert_eq!(p.culled_resource_count, 0); + assert_eq!( + p.resources + .iter() + .map(|resource| ( + resource.origin.node.as_str(), + resource.origin.socket.as_str() + )) + .collect::>(), + [ + ("color", "texture"), + ("depth", "texture"), + ("mesh", "mesh"), + ("mesh", "localAabbs"), + ("mesh", "isVisible"), + ("mesh", "pipelineIndices"), + ("cull", "isFrustumCulled"), + ("query", "draws"), + ("registry", "activation"), + ("pipeline_main", "color"), + ("pipeline_main", "depth"), + ] + ); assert_eq!( p.executions .iter() .map(|e| e.id.as_str()) .collect::>(), - ["cull", "query", "forward", "present"] + ["cull", "query", "registry", "pipeline_main", "frame_out"] ); for resource in &p.resources { if let ResourcePlan::Texture { version, .. } = resource.plan { @@ -647,25 +797,156 @@ fn authoritative_eleven_node_graph_lowers_exactly() { } assert!(p.executions.iter().all(|e| !matches!( e.executor.key.as_str(), - "surface_target" | "texture_spec" | "scene_table" + "surface_target" | "texture" | "mesh" ))); + let frame = execution(&p, "frame_out"); + let color = resource_by_origin(&p, "pipeline_main", "color"); + let color_id = p + .resources + .iter() + .position(|resource| std::ptr::eq(resource, color)) + .unwrap() as u32; + assert!(matches!(frame.kind, ExecutionKind::FrameOut { color } if color == color_id)); + assert!(frame.outputs.is_empty()); + assert_eq!(frame.inputs.len(), 1); + assert_eq!(frame.accesses.len(), 1); + assert_eq!(frame.accesses[0].socket, "color"); + assert_eq!(frame.accesses[0].resource, color_id); + assert_eq!(frame.accesses[0].mode, AccessMode::SampledTexture); + let family = family_by_source(&p, "color"); + assert_eq!(family.lifetime.last_use, 4); + assert!(family.allocation.is_some()); + assert_eq!( + family.usage.iter().copied().collect::>(), + [TextureUsage::Sampled, TextureUsage::ColorAttachment] + .into_iter() + .collect() + ); +} + +#[test] +fn exact_phase_four_contract_catalog_and_mesh_metadata() { + assert_eq!( + CONTRACTS + .iter() + .map(|contract| (contract.key, contract.version)) + .collect::>(), + [ + ("mesh", 1), + ("texture", 1), + ("frustum_cull", 1), + ("mesh_query", 1), + ("pipeline_registry", 1), + ("pipeline", 1), + ("fullscreen_copy", 1), + ("tone_map", 1), + ("bloom_extract", 1), + ("bloom_blur", 1), + ("bloom_composite", 1), + ("luminance_edge", 1), + ("frame_out", 1), + ] + ); + let mesh = contract("mesh").unwrap(); + assert_eq!(mesh.execution, ExecutionClass::Source); + assert!(mesh.inputs.is_empty()); + assert_eq!( + mesh.outputs + .iter() + .map(|output| (output.name, output.semantic_type, output.metadata)) + .collect::>(), + [ + ("mesh", SemanticType::MeshData, OutputMetadata::None), + ( + "localAabbs", + SemanticType::LocalAabbBuffer, + OutputMetadata::None, + ), + ( + "isVisible", + SemanticType::BooleanFlagBuffer, + OutputMetadata::BooleanFlag { + flag: MeshFlag::IsVisible, + }, + ), + ( + "pipelineIndices", + SemanticType::PipelineIndexStream, + OutputMetadata::None, + ), + ] + ); + let cull = contract("frustum_cull").unwrap(); + assert_eq!(cull.inputs.len(), 2); + assert_eq!( + (cull.outputs[0].name, cull.outputs[0].metadata), + ( + "isFrustumCulled", + OutputMetadata::BooleanFlag { + flag: MeshFlag::IsFrustumCulled, + }, + ) + ); + let query = contract("mesh_query").unwrap(); + assert_eq!( + query + .inputs + .iter() + .map(|input| (input.name, input.cardinality)) + .collect::>(), + [ + ("mesh", InputCardinality::RequiredOne), + ("isVisible", InputCardinality::OptionalOne), + ("isFrustumCulled", InputCardinality::OptionalOne), + ] + ); + let frame = contract("frame_out").unwrap(); + assert_eq!(frame.execution, ExecutionClass::Frame); + assert!(frame.inherently_observable); + assert!(frame.outputs.is_empty()); + assert_eq!(frame.inputs.len(), 1); + assert_eq!(frame.inputs[0].name, "color"); + assert_eq!( + frame.inputs[0].accepted, + TypeConstraint::Exact(SemanticType::Texture) + ); + assert_eq!(frame.inputs[0].cardinality, InputCardinality::RequiredOne); + assert_eq!(frame.inputs[0].role, InputRole::SampledTexture); +} + +#[test] +fn removed_executor_keys_are_rejected_without_aliases() { + let cases = [ + "texture_spec", + "scene_table", + "local_aabb_buffer", + "camera_frustum", + "visibility_flags", + "surface_target", + "present", + "legacy_forward", + "depth_stencil_config", + ]; + for key in cases { + let mut g = full_cull_graph(); + g["nodes"][0]["executor"]["key"] = json!(key); + let error = compile_error(g); + assert_eq!(error.code, "GRAPH_UNKNOWN_EXECUTOR", "{key}"); + assert_eq!(error.details["path"], "nodes[0].executor.key", "{key}"); + } } #[test] fn exact_wire_catalog_rejections() { - let cases = [ - ("local_aabb", 0, "GRAPH_UNKNOWN_EXECUTOR"), - ("frustum", 4, "GRAPH_UNKNOWN_EXECUTOR"), - ("cull", 6, "GRAPH_UNKNOWN_EXECUTOR"), - ]; - for (key, i, code) in cases { + for field in [ + "pipeline", + "depthCompare", + "depthWriteEnabled", + "clearDepth", + "clearColor", + ] { 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 }; + let i = node_index(&g, "pipeline_main"); g["nodes"][i]["parameters"] .as_object_mut() .unwrap() @@ -680,48 +961,163 @@ fn exact_wire_catalog_rejections() { } #[test] -fn mesh_filters_are_closed_and_any_removes_dependency() { +fn mesh_predicates_are_normalized_and_any_removes_dependency() { let p = compile_graph(full_cull_graph()); - let NormalizedParameters::MeshQuery { filters } = execution(&p, "query").parameters.clone() + let NormalizedParameters::MeshQuery { + visible_predicate, + frustum_culled_predicate, + } = 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"); - } + assert_eq!(visible_predicate, RuntimePredicate::RequiredTrue); + assert_eq!(frustum_culled_predicate, RuntimePredicate::RequiredFalse); 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"); + g["nodes"][4]["parameters"]["frustumCulledPredicate"] = json!("any"); let p = compile_graph(g); - let q = execution(&p, "query"); - assert!(!q.inputs.iter().any(|x| x.socket == "isFrustumCulled")); + assert!(!execution(&p, "query") + .inputs + .iter() + .any(|x| x.socket == "isFrustumCulled")); assert!(!p.executions.iter().any(|e| e.id == "cull")); - assert!(!p +} + +#[test] +fn mesh_query_predicate_defaults_have_a_complete_truth_matrix() { + for field in ["visible", "frustum"] { + for predicate in ["any", "required_true", "required_false"] { + for linked in [false, true] { + for default in [false, true] { + let mut graph = full_cull_graph(); + let query = &mut graph["nodes"][4]; + query["parameters"]["visiblePredicate"] = json!("any"); + query["parameters"]["frustumCulledPredicate"] = json!("any"); + let (parameter, default_parameter, socket) = if field == "visible" { + ("visiblePredicate", "visibleDefault", "isVisible") + } else { + ( + "frustumCulledPredicate", + "frustumCulledDefault", + "isFrustumCulled", + ) + }; + query["parameters"][parameter] = json!(predicate); + query["parameters"][default_parameter] = json!(default); + if !linked { + query["inputs"].as_object_mut().unwrap().remove(socket); + } + let expected = match (predicate, linked, default) { + ("any", _, _) => RuntimePredicate::Any, + ("required_true", true, _) => RuntimePredicate::RequiredTrue, + ("required_false", true, _) => RuntimePredicate::RequiredFalse, + ("required_true", false, true) | ("required_false", false, false) => { + RuntimePredicate::Any + } + _ => RuntimePredicate::Never, + }; + let plan = compile_graph(graph); + let execution = execution(&plan, "query"); + let NormalizedParameters::MeshQuery { + visible_predicate, + frustum_culled_predicate, + } = &execution.parameters + else { + panic!() + }; + let (actual, other) = if field == "visible" { + (*visible_predicate, *frustum_culled_predicate) + } else { + (*frustum_culled_predicate, *visible_predicate) + }; + let active = matches!( + expected, + RuntimePredicate::RequiredTrue | RuntimePredicate::RequiredFalse + ); + let label = format!("{field}/{predicate}/linked={linked}/default={default}"); + if expected == RuntimePredicate::Never { + assert_eq!(actual, RuntimePredicate::Never, "{label}"); + assert_eq!(other, RuntimePredicate::Never, "{label}"); + } else { + assert_eq!(actual, expected, "{label}"); + assert_eq!(other, RuntimePredicate::Any, "{label}"); + } + assert_eq!( + execution.inputs.iter().any(|input| input.socket == socket), + active, + "{label}" + ); + assert_eq!( + execution + .accesses + .iter() + .any(|access| access.socket == socket), + active, + "{label}" + ); + if expected == RuntimePredicate::Never { + assert!(!plan + .executions + .iter() + .any(|execution| execution.id == "cull")); + } + } + } + } + } +} + +#[test] +fn inactive_mesh_source_outputs_are_pruned_but_executable_outputs_remain() { + let mut graph = full_cull_graph(); + graph["nodes"][4]["parameters"]["visiblePredicate"] = json!("any"); + graph["nodes"][4]["parameters"]["frustumCulledPredicate"] = json!("any"); + let plan = compile_graph(graph); + for socket in ["localAabbs", "isVisible"] { + assert!(!plan + .resources + .iter() + .any(|resource| resource.origin.node == "mesh" && resource.origin.socket == socket)); + } + assert!(!plan + .executions + .iter() + .any(|execution| execution.id == "cull")); + for socket in ["color", "depth"] { + assert!(plan + .resources + .iter() + .any(|resource| resource.origin.node == "pipeline_main" + && resource.origin.socket == socket)); + } + + let mut graph = full_cull_graph(); + graph["nodes"][4]["parameters"]["visiblePredicate"] = json!("any"); + let plan = compile_graph(graph); + assert!(!plan .resources .iter() - .any(|r| r.origin.node == "cull" && r.origin.socket == "flags")); + .any(|resource| resource.origin.node == "mesh" && resource.origin.socket == "isVisible")); + assert!(plan + .resources + .iter() + .any(|resource| resource.origin.node == "mesh" && resource.origin.socket == "localAabbs")); } #[test] fn provenance_and_lowering_are_consistent() { let p = compile_graph(full_cull_graph()); - let scene = resource_by_origin(&p, "scene", "scene"); - for id in ["aabbs", "visible", "cull", "query"] { - let r = p.resources.iter().find(|r| r.origin.node == id).unwrap(); + let scene = resource_by_origin(&p, "mesh", "mesh"); + for (id, socket) in [ + ("mesh", "localAabbs"), + ("mesh", "isVisible"), + ("cull", "isFrustumCulled"), + ("query", "draws"), + ] { + let r = resource_by_origin(&p, id, socket); match r.plan { - ResourcePlan::LocalAabbBuffer { scene: s } - | ResourcePlan::BooleanFlagBuffer { scene: s, .. } - | ResourcePlan::DrawStream { scene: s } => { + ResourcePlan::LocalAabbBuffer { mesh: s } + | ResourcePlan::BooleanFlagBuffer { mesh: s, .. } + | ResourcePlan::DrawStream { mesh: s } => { assert_eq!( s, p.resources @@ -733,7 +1129,7 @@ fn provenance_and_lowering_are_consistent() { _ => {} } } - let f = execution(&p, "forward"); + let f = execution(&p, "pipeline_main"); let color_in = f .inputs .iter() @@ -756,11 +1152,90 @@ fn provenance_and_lowering_are_consistent() { .iter() .any(|a| a.resource == color_in && matches!(a.mode, AccessMode::ColorAttachment { .. }))); for (socket, expected) in [ - ("scene", AccessMode::SemanticRead), + ("mesh", 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"); + assert_eq!(access.mode, expected, "pipeline {socket} access"); + } + + let registry = execution(&p, "registry"); + assert!(matches!( + registry.parameters, + NormalizedParameters::PipelineRegistry + )); + assert!(matches!(registry.kind, ExecutionKind::CpuPreparation)); + assert_eq!(registry.inputs.len(), 1); + assert_eq!(registry.outputs.len(), 1); + assert_eq!(registry.accesses.len(), 1); + assert_eq!(registry.inputs[0].socket, "pipelineIndices"); + assert_eq!(registry.outputs[0].socket, "activation"); + assert_eq!(registry.accesses[0].mode, AccessMode::SemanticRead); + let activation = &p.resources[registry.outputs[0].resource as usize]; + assert_eq!(activation.producer_execution, Some(2)); + assert_eq!( + activation.lifetime, + Some(Lifetime { + first_use: 2, + last_use: 3 + }) + ); + assert!(matches!( + activation.plan, + ResourcePlan::PipelineActivation { pipeline_indices } + if pipeline_indices == registry.inputs[0].resource + )); +} + +#[test] +fn pipeline_clear_then_chained_load_is_independent_for_color_and_depth() { + let p = compile_graph(independent_depth_graph( + vec![depth_spec("depth", "transient")], + vec![ + pipeline_node( + "pipeline_first", + input("color", "texture"), + input("depth", "texture"), + ), + pipeline_node( + "pipeline_second", + input("pipeline_first", "color"), + input("pipeline_first", "depth"), + ), + ], + "pipeline_second", + )); + let ExecutionKind::Render { + color_attachments, + depth_stencil: Some(depth), + } = &execution(&p, "pipeline_first").kind + else { + panic!() + }; + assert!(matches!( + color_attachments[0].load, + NormalizedColorLoad::Clear { .. } + )); + assert!(matches!(depth.load, NormalizedDepthLoad::Clear { .. })); + let ExecutionKind::Render { + color_attachments, + depth_stencil: Some(depth), + } = &execution(&p, "pipeline_second").kind + else { + panic!() + }; + assert_eq!(color_attachments[0].load, NormalizedColorLoad::Load); + assert_eq!(depth.load, NormalizedDepthLoad::Load); + + for pipeline in ["", "bad name", "pipeline/name"] { + let mut graph = full_cull_graph(); + let i = node_index(&graph, "pipeline_main"); + graph["nodes"][i]["parameters"]["pipeline"] = json!(pipeline); + assert_eq!( + compile_error(graph).code, + "GRAPH_PARAMETERS_INVALID", + "{pipeline:?}" + ); } } @@ -769,32 +1244,27 @@ 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"); + assert_eq!(compile_error(g).code, "GRAPH_UNSUPPORTED_FEATURE"); } let mut g = full_cull_graph(); g["nodes"][1]["parameters"]["texture"]["extent"] = json!({"kind":"absolute","width":1,"height":1,"depthOrArrayLayers":1}); g["nodes"][1]["parameters"]["texture"]["mipLevelCount"] = json!(2); - assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); + assert_eq!(compile_error(g).code, "GRAPH_UNSUPPORTED_FEATURE"); let mut g = full_cull_graph(); g["nodes"][1]["parameters"]["texture"]["mipLevelCount"] = json!(99); - assert_eq!( - resource_by_origin(&compile_graph(g), "depth", "spec").semantic_type, - SemanticType::TextureSpec - ); + assert_eq!(compile_error(g).code, "GRAPH_UNSUPPORTED_FEATURE"); for residency in ["history", "readback"] { let mut g = full_cull_graph(); g["nodes"][1]["parameters"]["residency"] = json!(residency); assert_eq!(compile_error(g).code, "GRAPH_UNSUPPORTED_FEATURE"); } let p = compile_graph(full_cull_graph()); - let surface = p + assert!(p .texture_families .iter() - .find(|f| matches!(f.source, TextureFamilySource::ImportedSurface { .. })) - .unwrap(); - assert!(surface.allocation.is_none()); + .all(|family| matches!(family.source, TextureFamilySource::AuthoredTexture { .. }))); } #[test] @@ -818,65 +1288,59 @@ fn validation_precedence_and_identifier_limits() { 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)); + invalid_grammar["nodes"][4]["executor"]["key"] = json!("x".repeat(65)); let error = compile_error(invalid_grammar); assert_eq!(error.code, "GRAPH_LIMIT_EXCEEDED"); - assert_eq!(error.details["path"], "nodes[10].executor.key"); + assert_eq!(error.details["path"], "nodes[4].executor.key"); let mut duplicate = full_cull_graph(); duplicate["nodes"][1]["id"] = duplicate["nodes"][0]["id"].clone(); - duplicate["nodes"][10]["executor"]["key"] = json!("x".repeat(65)); + duplicate["nodes"][4]["executor"]["key"] = json!("x".repeat(65)); let error = compile_error(duplicate); assert_eq!(error.code, "GRAPH_LIMIT_EXCEEDED"); - assert_eq!(error.details["path"], "nodes[10].executor.key"); + assert_eq!(error.details["path"], "nodes[4].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 { + for field in ["visiblePredicate", "frustumCulledPredicate"] { let mut g = full_cull_graph(); - g["nodes"][7]["parameters"]["filters"] = filters; + g["nodes"][4]["parameters"][field] = json!("invalid"); 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"); + assert_eq!(e.details["path"], "nodes[4].parameters"); } 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"); + g["nodes"][4]["parameters"]["frustumCulledPredicate"] = json!("required_true"); + g["nodes"][4]["inputs"] + .as_object_mut() + .unwrap() + .remove("isFrustumCulled"); + let p = compile_graph(g); + assert!(matches!( + execution(&p, "query").parameters, + NormalizedParameters::MeshQuery { + frustum_culled_predicate: RuntimePredicate::Never, + .. + } + )); + assert!(!p.executions.iter().any(|e| e.id == "cull")); let mut g = full_cull_graph(); - g["nodes"][7]["parameters"]["filters"][0]["predicate"] = json!("any"); + g["nodes"][4]["inputs"] + .as_object_mut() + .unwrap() + .remove("isFrustumCulled"); + let p = compile_graph(g); + assert!(!p.executions.iter().any(|e| e.id == "cull")); + let mut g = full_cull_graph(); + g["nodes"][4]["inputs"]["isVisible"] = input("cull", "isFrustumCulled"); + let e = compile_error(g); + assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); + assert_eq!(e.details["path"], "nodes[4].inputs.isVisible"); + + let mut g = full_cull_graph(); + g["nodes"][4]["parameters"]["frustumCulledPredicate"] = json!("any"); let p = compile_graph(g); let q = execution(&p, "query"); assert!(!q.inputs.iter().any(|i| i.socket == "isFrustumCulled")); @@ -887,46 +1351,46 @@ fn strict_mesh_diagnostics_and_inactive_any_edges() { #[test] fn transitive_scene_roots_are_vector_resource_ids() { let p = compile_graph(full_cull_graph()); - let scene_id = p + let mesh_id = p .resources .iter() - .position(|r| r.origin.node == "scene") + .position(|r| r.origin.node == "mesh") .unwrap() as u32; - for origin in ["aabbs", "visible", "cull", "query"] { - let resource = p - .resources - .iter() - .find(|r| r.origin.node == origin) - .unwrap(); + for (origin, socket) in [ + ("mesh", "localAabbs"), + ("mesh", "isVisible"), + ("cull", "isFrustumCulled"), + ("query", "draws"), + ] { + let resource = resource_by_origin(&p, origin, socket); let rooted = match resource.plan { - ResourcePlan::LocalAabbBuffer { scene } - | ResourcePlan::BooleanFlagBuffer { scene, .. } - | ResourcePlan::DrawStream { scene } => Some(scene), + ResourcePlan::LocalAabbBuffer { mesh } + | ResourcePlan::BooleanFlagBuffer { mesh, .. } + | ResourcePlan::DrawStream { mesh } => Some(mesh), _ => None, }; - assert_eq!(rooted, Some(scene_id)); + assert_eq!(rooted, Some(mesh_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"); + .push(node("sceneB", "mesh", json!({}), json!({}))); + g["nodes"][3]["inputs"]["mesh"] = input("sceneB", "mesh"); let e = compile_error(g); assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); - assert_eq!(e.details["path"], "nodes[6].inputs.localAabbs"); + assert_eq!(e.details["path"], "nodes[3].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"); + .push(node("sceneB", "mesh", json!({}), json!({}))); + g["nodes"][3]["inputs"]["mesh"] = input("sceneB", "mesh"); + g["nodes"][3]["inputs"]["localAabbs"] = input("sceneB", "localAabbs"); + g["nodes"][4]["inputs"]["mesh"] = input("sceneB", "mesh"); let e = compile_error(g); assert_eq!(e.code, "GRAPH_SOCKET_TYPE_MISMATCH"); - assert_eq!(e.details["path"], "nodes[9].inputs.draws"); + assert_eq!(e.details["path"], "nodes[4].inputs.isVisible"); } #[test] @@ -940,7 +1404,7 @@ fn descriptor_exact_paths_and_normalization() { 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.code, "GRAPH_UNSUPPORTED_FEATURE"); assert_eq!( e.details["path"], format!("nodes[1].parameters.texture.{suffix}") @@ -979,11 +1443,11 @@ fn descriptor_exact_paths_and_normalization() { #[test] fn descriptor_multi_error_precedence_is_exact() { let cases = [ - (json!(3), json!(0), 9000, "sampleCount"), + (json!(3), json!(0), 9000, "mipLevelCount"), (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]"), + (json!(1), json!(14), 9000, "mipLevelCount"), + (json!(1), json!(14), 8192, "mipLevelCount"), ]; for (sample_count, mip_count, width, expected) in cases { let mut g = full_cull_graph(); @@ -999,7 +1463,7 @@ fn descriptor_multi_error_precedence_is_exact() { d["mipLevelCount"] = mip_count; d["viewFormats"] = json!(["rgba8_unorm"]); let e = compile_error(g); - assert_eq!(e.code, "GRAPH_PARAMETERS_INVALID"); + assert_eq!(e.code, "GRAPH_UNSUPPORTED_FEATURE"); assert_eq!( e.details["path"], format!("nodes[1].parameters.texture.{expected}") @@ -1029,26 +1493,26 @@ fn global_raw_limits_have_stable_narrow_paths() { ( { let mut g = full_cull_graph(); - g["nodes"][9]["inputs"]["x".repeat(65)] = input("scene", "scene"); + g["nodes"][3]["inputs"]["x".repeat(65)] = input("mesh", "mesh"); g }, - "nodes[9].inputs.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "nodes[3].inputs.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", ), ( { let mut g = full_cull_graph(); - g["nodes"][9]["inputs"]["scene"]["node"] = json!("x".repeat(65)); + g["nodes"][3]["inputs"]["mesh"]["node"] = json!("x".repeat(65)); g }, - "nodes[9].inputs.scene.node", + "nodes[3].inputs.mesh.node", ), ( { let mut g = full_cull_graph(); - g["nodes"][9]["inputs"]["scene"]["socket"] = json!("x".repeat(65)); + g["nodes"][3]["inputs"]["mesh"]["socket"] = json!("x".repeat(65)); g }, - "nodes[9].inputs.scene.socket", + "nodes[3].inputs.mesh.socket", ), ] { let e = compile_error(g); @@ -1071,27 +1535,786 @@ fn global_raw_limits_have_stable_narrow_paths() { } #[test] -fn empty_plan_has_no_lowered_objects() { - let p = compile_graph(json!({"schemaVersion":2,"graphId":"empty","revision":1,"nodes":[]})); +fn empty_graph_is_rejected_without_frame_out() { + let error = compile_error(json!({"schemaVersion":2,"graphId":"empty","revision":1,"nodes":[]})); + assert_eq!(error.code, "GRAPH_EXECUTION_UNSUPPORTED"); assert_eq!( + error.details, + json!({"message":"exactly one frame_out is required","path":"nodes"}) + ); +} + +#[test] +fn multiple_frame_outputs_and_uninitialized_sources_are_rejected() { + let mut value = full_cull_graph(); + value["nodes"].as_array_mut().unwrap().push(node( + "frame_out_2", + "frame_out", + json!({}), + json!({"color":input("pipeline_main","color")}), + )); + let error = compile_error(value); + assert_eq!(error.code, "GRAPH_EXECUTION_UNSUPPORTED"); + assert_eq!(error.details["path"], "nodes"); + + let error = compile_error(graph(vec![ + node( + "color", + "texture", + texture("rgba8_unorm", "transient"), + json!({}), + ), + node( + "frame_out", + "frame_out", + json!({}), + json!({"color":input("color","texture")}), + ), + ])); + assert_eq!(error.code, "GRAPH_UNINITIALIZED_RESOURCE"); + assert_eq!(error.details["path"], "nodes[1].inputs.color"); +} + +#[test] +fn frame_out_cardinality_counts_only_enabled_nodes() { + let mut value = full_cull_graph(); + value["nodes"][7]["state"] = json!("muted"); + let error = compile_error(value); + assert_eq!(error.code, "GRAPH_EXECUTION_UNSUPPORTED"); + assert_eq!( + error.details, + json!({"message":"exactly one frame_out is required","path":"nodes"}) + ); + + let mut value = full_cull_graph(); + let mut muted = node( + "muted_frame_out", + "frame_out", + json!({}), + json!({"color":input("pipeline_main","color")}), + ); + muted["state"] = json!("muted"); + value["nodes"].as_array_mut().unwrap().push(muted); + let compiled = compile_graph(value); + assert_eq!( + compiled + .executions + .iter() + .filter(|execution| execution.executor.key == "frame_out") + .count(), + 1 + ); + assert!(!compiled + .executions + .iter() + .any(|execution| execution.id == "muted_frame_out")); + + let mut value = full_cull_graph(); + value["nodes"][3]["state"] = json!("muted"); + let error = compile_error(value); + assert_eq!(error.code, "GRAPH_NODE_STATE_INVALID"); + assert_eq!(error.details["path"], "nodes[3].state"); + + let mut value = full_cull_graph(); + value["nodes"][7]["state"] = json!("muted"); + value["nodes"][6]["parameters"]["clearColor"] = json!("bad"); + assert_eq!(compile_error(value).code, "GRAPH_PARAMETERS_INVALID"); + + let mut value = full_cull_graph(); + value["nodes"][7]["state"] = json!("muted"); + value["nodes"][7]["inputs"] = json!({}); + assert_eq!(compile_error(value).code, "GRAPH_EXECUTION_UNSUPPORTED"); +} + +#[test] +fn runtime_rejects_noncanonical_frame_out_mutations() { + let baseline = compile_graph(full_cull_graph()); + validate_activatable(&baseline).unwrap(); + let frame_index = baseline + .executions + .iter() + .position(|execution| execution.executor.key == "frame_out") + .unwrap(); + let ExecutionKind::FrameOut { color } = baseline.executions[frame_index].kind else { + unreachable!() + }; + let family_id = match baseline.resources[color as usize].plan { + ResourcePlan::Texture { family, .. } => family, + _ => unreachable!(), + }; + let assert_invalid = |graph: &CompiledGraph, path: &str| { + let error = validate_activatable(graph).unwrap_err(); + assert_eq!(error.code, "GRAPH_RUNTIME_PLAN_INVALID"); + assert_eq!(error.details["path"], path); + }; + + let mut graph = baseline.clone(); + graph.executions.remove(frame_index); + let error = validate_activatable(&graph).unwrap_err(); + assert_eq!(error.code, "GRAPH_RUNTIME_PLAN_INVALID"); + assert_eq!(error.details["path"], "resources[9].lifetime"); + + let mut graph = baseline.clone(); + graph.executions.push(graph.executions[frame_index].clone()); + let error = validate_activatable(&graph).unwrap_err(); + assert_eq!(error.code, "GRAPH_RUNTIME_PLAN_INVALID"); + assert_eq!(error.details["path"], "resources[9].lifetime"); + + let mut graph = baseline.clone(); + graph.executions[frame_index].parameters = NormalizedParameters::FullscreenCopy; + assert_invalid(&graph, &format!("executions[{frame_index}].parameters")); + + let mut graph = baseline.clone(); + graph.executions[frame_index].kind = ExecutionKind::CpuPreparation; + assert_invalid(&graph, &format!("executions[{frame_index}].kind")); + + let mut graph = baseline.clone(); + graph.executions[frame_index] + .outputs + .push(CompiledSocketOutput { + socket: "color".into(), + resource: color, + }); + assert_invalid(&graph, &format!("executions[{frame_index}].outputs")); + + for mutate in [ + |inputs: &mut Vec| inputs.clear(), + |inputs: &mut Vec| inputs.push(inputs[0].clone()), + |inputs: &mut Vec| inputs[0].socket = "source".into(), + |inputs: &mut Vec| inputs[0].resource = u32::MAX - 1, + ] { + let mut graph = baseline.clone(); + mutate(&mut graph.executions[frame_index].inputs); + assert_invalid(&graph, &format!("executions[{frame_index}].inputs")); + } + for mutate in [ + |accesses: &mut Vec| accesses.clear(), + |accesses: &mut Vec| accesses.push(accesses[0].clone()), + |accesses: &mut Vec| accesses[0].socket = "source".into(), + |accesses: &mut Vec| accesses[0].resource = u32::MAX - 1, + |accesses: &mut Vec| accesses[0].mode = AccessMode::StorageRead, + ] { + let mut graph = baseline.clone(); + mutate(&mut graph.executions[frame_index].accesses); + assert_invalid(&graph, &format!("executions[{frame_index}].accesses")); + } + + let mut graph = baseline.clone(); + graph.executions[frame_index].kind = ExecutionKind::FrameOut { color: u32::MAX }; + graph.executions[frame_index].inputs[0].resource = u32::MAX; + graph.executions[frame_index].accesses[0].resource = u32::MAX; + assert_invalid(&graph, &format!("executions[{frame_index}].inputs")); + + let mut graph = baseline.clone(); + graph.resources[color as usize].semantic_type = SemanticType::MeshData; + assert_invalid(&graph, &format!("textureFamilies[{family_id}].versions[0]")); + + for mutate in [ + |plan: &mut ResourcePlan| *plan = ResourcePlan::MeshData, + |plan: &mut ResourcePlan| { + let ResourcePlan::Texture { initialized, .. } = plan else { + unreachable!() + }; + *initialized = false; + }, + |plan: &mut ResourcePlan| { + let ResourcePlan::Texture { stored, .. } = plan else { + unreachable!() + }; + *stored = false; + }, + |plan: &mut ResourcePlan| { + let ResourcePlan::Texture { allocation, .. } = plan else { + unreachable!() + }; + *allocation = None; + }, + ] { + let mut graph = baseline.clone(); + mutate(&mut graph.resources[color as usize].plan); + assert_invalid(&graph, &format!("textureFamilies[{family_id}].versions[0]")); + } + + let descriptor_path = format!("textureFamilies[{family_id}].source"); + for mutate in [ + |descriptor: &mut NormalizedTextureDescriptor| descriptor.dimension = TextureDimension::D1, + |descriptor: &mut NormalizedTextureDescriptor| descriptor.mip_level_count = 2, + |descriptor: &mut NormalizedTextureDescriptor| descriptor.sample_count = 4, + |descriptor: &mut NormalizedTextureDescriptor| match &mut descriptor.extent { + NormalizedTextureExtent::Absolute { + depth_or_array_layers, + .. + } + | NormalizedTextureExtent::SurfaceRelative { + depth_or_array_layers, + .. + } => *depth_or_array_layers = 2, + }, + |descriptor: &mut NormalizedTextureDescriptor| descriptor.format = TextureFormat::R32Float, + |descriptor: &mut NormalizedTextureDescriptor| { + descriptor.format = TextureFormat::Depth32Float + }, + ] { + let mut graph = baseline.clone(); + let TextureFamilySource::AuthoredTexture { descriptor, .. } = + &mut graph.texture_families[family_id as usize].source; + mutate(descriptor); + assert_invalid(&graph, &descriptor_path); + } +} + +#[test] +fn runtime_rejects_noncanonical_pipeline_registry_plan() { + let baseline = compile_graph(full_cull_graph()); + validate_activatable(&baseline).unwrap(); + let registry = baseline + .executions + .iter() + .position(|execution| execution.executor.key == "pipeline_registry") + .unwrap(); + let indices = baseline.executions[registry].inputs[0].resource as usize; + let activation = baseline.executions[registry].outputs[0].resource as usize; + let activation_path = format!("resources[{activation}].plan"); + let indices_path = format!("resources[{indices}].plan"); + let cases: Vec<(&str, Box)> = vec![ ( - p.node_count, - p.resources.len(), - p.executions.len(), - p.texture_families.len(), - p.allocation_classes.len() + "parameters", + Box::new(move |g| { + g.executions[registry].parameters = NormalizedParameters::FullscreenCopy + }), + ), + ( + "kind", + Box::new( + move |g| g.executions[registry].kind = ExecutionKind::CpuPreparation, /* replaced below */ + ), + ), + ( + "inputs", + Box::new(move |g| g.executions[registry].inputs.clear()), + ), + ( + "inputs", + Box::new(move |g| { + let duplicate = g.executions[registry].inputs[0].clone(); + g.executions[registry].inputs.push(duplicate) + }), + ), + ( + "inputs", + Box::new(move |g| g.executions[registry].inputs[0].socket = "mesh".into()), + ), + ( + "resources[8].producerExecution", + Box::new(move |g| g.executions[registry].outputs.clear()), + ), + ( + "outputs", + Box::new(move |g| { + let duplicate = g.executions[registry].outputs[0].clone(); + g.executions[registry].outputs.push(duplicate) + }), + ), + ( + "outputs", + Box::new(move |g| g.executions[registry].outputs[0].socket = "draws".into()), + ), + ( + "accesses", + Box::new(move |g| g.executions[registry].accesses[0].mode = AccessMode::StorageRead), + ), + ( + "accesses", + Box::new(move |g| g.executions[registry].accesses.clear()), + ), + ( + &activation_path, + Box::new(move |g| g.resources[activation].semantic_type = SemanticType::DrawStream), + ), + ( + &activation_path, + Box::new(move |g| { + g.resources[activation].plan = ResourcePlan::PipelineActivation { + pipeline_indices: u32::MAX, + } + }), + ), + ( + "resources[8].producerExecution", + Box::new(move |g| g.resources[activation].producer_execution = None), + ), + ( + &indices_path, + Box::new(move |g| { + g.resources[indices].plan = ResourcePlan::PipelineIndexStream { mesh: u32::MAX } + }), + ), + ]; + for (suffix, mutate) in cases { + let mut graph = baseline.clone(); + if suffix == "kind" { + graph.executions[registry].kind = ExecutionKind::FrameOut { color: 0 }; + } else { + mutate(&mut graph); + } + let error = validate_activatable(&graph).unwrap_err(); + assert_eq!(error.code, "GRAPH_RUNTIME_PLAN_INVALID", "{suffix}"); + let expected = if suffix.starts_with("resources") { + suffix.to_owned() + } else { + format!("executions[{registry}].{suffix}") + }; + assert_eq!(error.details["path"], expected, "{suffix}"); + } +} + +#[test] +fn runtime_rejects_noncanonical_pipeline_plan() { + let baseline = compile_graph(full_cull_graph()); + validate_activatable(&baseline).unwrap(); + let pipeline = baseline + .executions + .iter() + .position(|execution| execution.executor.key == "pipeline") + .unwrap(); + let outputs = baseline.executions[pipeline] + .outputs + .iter() + .map(|output| output.resource as usize) + .collect::>(); + let mesh = baseline.executions[pipeline].inputs[0].resource as usize; + let draws = baseline.executions[pipeline].inputs[1].resource as usize; + let activation = baseline.executions[pipeline].inputs[2].resource as usize; + let indices = match baseline.resources[activation].plan { + ResourcePlan::PipelineActivation { pipeline_indices } => pipeline_indices as usize, + _ => unreachable!(), + }; + let color_family = match baseline.resources[outputs[0]].plan { + ResourcePlan::Texture { family, .. } => family, + _ => unreachable!(), + }; + let color_path = format!("textureFamilies[{color_family}].versions[0]"); + let depth_path = format!("resources[{}].producerExecution", outputs[1]); + let invalid_activation_path = format!("resources[{activation}].lifetime"); + let invalid_mesh_path = format!("resources[{mesh}].lifetime"); + let indices_path = format!("resources[{indices}].plan"); + let draws_producer = baseline.resources[draws].producer_execution.unwrap(); + let draws_path = format!("executions[{draws_producer}].outputs"); + let cases: Vec<(&str, Box)> = vec![ + ( + "parameters", + Box::new(move |g| { + g.executions[pipeline].parameters = NormalizedParameters::FullscreenCopy + }), + ), + ( + "parameters", + Box::new(move |g| { + if let NormalizedParameters::Pipeline { pipeline: name, .. } = + &mut g.executions[pipeline].parameters + { + *name = "1bad".into() + } + }), + ), + ( + "parameters", + Box::new(move |g| { + if let NormalizedParameters::Pipeline { clear_depth, .. } = + &mut g.executions[pipeline].parameters + { + *clear_depth = f32::NAN + } + }), + ), + ( + "kind", + Box::new(move |g| g.executions[pipeline].kind = ExecutionKind::CpuPreparation), + ), + ( + "resources[1].lifetime", + Box::new(move |g| g.executions[pipeline].inputs.pop().map(|_| ()).unwrap()), + ), + ( + "inputs", + Box::new(move |g| g.executions[pipeline].inputs.swap(0, 1)), + ), + ( + "resources[10].producerExecution", + Box::new(move |g| g.executions[pipeline].outputs.pop().map(|_| ()).unwrap()), + ), + ( + "outputs", + Box::new(move |g| g.executions[pipeline].outputs.swap(0, 1)), + ), + ( + "accesses", + Box::new(move |g| g.executions[pipeline].accesses.pop().map(|_| ()).unwrap()), + ), + ( + "accesses", + Box::new(move |g| g.executions[pipeline].accesses.swap(0, 1)), + ), + ( + "accesses", + Box::new(move |g| g.executions[pipeline].accesses[2].mode = AccessMode::IndirectRead), + ), + ( + "kind", + Box::new(move |g| { + if let ExecutionKind::Render { + color_attachments, .. + } = &mut g.executions[pipeline].kind + { + color_attachments[0].load = NormalizedColorLoad::Load + } + }), + ), + ( + "accesses", + Box::new(move |g| { + if let AccessMode::ColorAttachment { full_overwrite, .. } = + &mut g.executions[pipeline].accesses[3].mode + { + *full_overwrite = false + } + }), + ), + ( + &color_path, + Box::new({ + let output = outputs[0]; + move |g| { + if let ResourcePlan::Texture { target, .. } = &mut g.resources[output].plan { + *target = u32::MAX + } + } + }), + ), + ( + &depth_path, + Box::new({ + let output = outputs[1]; + move |g| g.resources[output].producer_execution = None + }), + ), + ( + &invalid_activation_path, + Box::new(move |g| { + g.executions[pipeline].inputs[2].resource = draws as u32; + g.executions[pipeline].accesses[2].resource = draws as u32; + }), + ), + ( + &indices_path, + Box::new(move |g| g.resources[indices].semantic_type = SemanticType::DrawStream), + ), + ( + &draws_path, + Box::new(move |g| { + g.resources[draws].plan = ResourcePlan::DrawStream { mesh: u32::MAX } + }), + ), + ( + &invalid_mesh_path, + Box::new(move |g| { + g.executions[pipeline].inputs[0].resource = draws as u32; + g.executions[pipeline].accesses[0].resource = draws as u32; + }), + ), + ]; + for (path, mutate) in cases { + let mut graph = baseline.clone(); + mutate(&mut graph); + let error = validate_activatable(&graph).unwrap_err(); + assert_eq!(error.code, "GRAPH_RUNTIME_PLAN_INVALID", "{path}"); + let expected = if path.starts_with("executions") + || path.starts_with("resources") + || path.starts_with("textureFamilies") + { + path.to_owned() + } else { + format!("executions[{pipeline}].{path}") + }; + assert_eq!(error.details["path"], expected, "{path}"); + } + assert!(matches!( + baseline.resources[mesh].plan, + ResourcePlan::MeshData + )); +} + +fn assert_runtime_path(graph: &CompiledGraph, path: impl AsRef) { + let error = validate_activatable(graph).unwrap_err(); + assert_eq!(error.code, "GRAPH_RUNTIME_PLAN_INVALID"); + assert_eq!(error.details["path"], path.as_ref()); +} + +#[test] +fn runtime_rejects_coordinated_contract_and_texture_target_mutations() { + let baseline = compile_graph(full_cull_graph()); + validate_activatable(&baseline).unwrap(); + + let mut graph = baseline.clone(); + graph.schema_version = 1; + assert_runtime_path(&graph, "schemaVersion"); + + for key in ["frame_out", "pipeline_registry", "pipeline"] { + let i = baseline + .executions + .iter() + .position(|execution| execution.executor.key == key) + .unwrap(); + let mut graph = baseline.clone(); + graph.executions[i].executor.version += 1; + assert_runtime_path(&graph, format!("executions[{i}].executor.version")); + } + + let pipeline = baseline + .executions + .iter() + .position(|execution| execution.executor.key == "pipeline") + .unwrap(); + let color = baseline.executions[pipeline] + .outputs + .iter() + .find(|output| output.socket == "color") + .unwrap() + .resource as usize; + let depth = baseline.executions[pipeline] + .outputs + .iter() + .find(|output| output.socket == "depth") + .unwrap() + .resource; + let (color_family, color_version) = match baseline.resources[color].plan { + ResourcePlan::Texture { + family, version, .. + } => (family as usize, version as usize), + _ => unreachable!(), + }; + let depth_target = match baseline.resources[depth as usize].plan { + ResourcePlan::Texture { target, .. } => target, + _ => unreachable!(), + }; + let mut graph = baseline.clone(); + graph.executions[pipeline] + .inputs + .iter_mut() + .find(|input| input.socket == "colorTarget") + .unwrap() + .resource = depth_target; + if let ResourcePlan::Texture { target, .. } = &mut graph.resources[color].plan { + *target = depth_target; + } + graph.texture_families[color_family].versions[color_version].target = depth_target; + graph.resources[match baseline.resources[color].plan { + ResourcePlan::Texture { target, .. } => target as usize, + _ => unreachable!(), + }] + .lifetime = None; + assert_runtime_path( + &graph, + format!("textureFamilies[{color_family}].versions[{color_version}]"), + ); +} + +#[test] +fn runtime_rejects_coordinated_execution_metadata_mutations() { + let baseline = compile_graph(full_cull_graph()); + let consumer = baseline + .executions + .iter() + .position(|execution| execution.executor.key == "frame_out") + .unwrap(); + let mut graph = baseline.clone(); + let producer = graph.executions[consumer].inputs[0].resource as usize; + let producer_execution = graph.resources[producer].producer_execution.unwrap() as usize; + graph.executions.swap(producer_execution, consumer); + for resource in &mut graph.resources { + if resource.producer_execution == Some(producer_execution as u32) { + resource.producer_execution = Some(consumer as u32); + } else if resource.producer_execution == Some(consumer as u32) { + resource.producer_execution = Some(producer_execution as u32); + } + } + let swap_ordinal = |ordinal: &mut u32| { + if *ordinal == producer_execution as u32 { + *ordinal = consumer as u32; + } else if *ordinal == consumer as u32 { + *ordinal = producer_execution as u32; + } + }; + for resource in &mut graph.resources { + if let Some(lifetime) = &mut resource.lifetime { + swap_ordinal(&mut lifetime.first_use); + swap_ordinal(&mut lifetime.last_use); + if lifetime.first_use > lifetime.last_use { + std::mem::swap(&mut lifetime.first_use, &mut lifetime.last_use); + } + } + } + for family in &mut graph.texture_families { + swap_ordinal(&mut family.lifetime.first_use); + swap_ordinal(&mut family.lifetime.last_use); + if family.lifetime.first_use > family.lifetime.last_use { + std::mem::swap( + &mut family.lifetime.first_use, + &mut family.lifetime.last_use, + ); + } + for version in &mut family.versions { + swap_ordinal(&mut version.lifetime.first_use); + swap_ordinal(&mut version.lifetime.last_use); + if version.lifetime.first_use > version.lifetime.last_use { + std::mem::swap( + &mut version.lifetime.first_use, + &mut version.lifetime.last_use, + ); + } + } + } + assert_runtime_path(&graph, format!("executions[{producer_execution}].inputs")); + + let pipeline = baseline + .executions + .iter() + .position(|execution| execution.executor.key == "pipeline") + .unwrap(); + let mut graph = baseline.clone(); + let ExecutionKind::Render { + color_attachments, + depth_stencil: Some(depth), + } = &mut graph.executions[pipeline].kind + else { + unreachable!() + }; + color_attachments[0].load = NormalizedColorLoad::Load; + depth.load = NormalizedDepthLoad::Load; + for access in &mut graph.executions[pipeline].accesses { + match &mut access.mode { + AccessMode::ColorAttachment { + load, + full_overwrite, + .. + } => { + *load = NormalizedColorLoad::Load; + *full_overwrite = false; + } + AccessMode::DepthAttachment { + load, + full_overwrite, + .. + } => { + *load = NormalizedDepthLoad::Load; + *full_overwrite = false; + } + _ => {} + } + } + assert_runtime_path(&graph, format!("executions[{pipeline}].kind")); + + let mut graph = baseline.clone(); + let ExecutionKind::Render { + color_attachments, + depth_stencil: Some(depth), + } = &mut graph.executions[pipeline].kind + else { + unreachable!() + }; + let changed_color = NormalizedColorLoad::Clear { value: [0.5; 4] }; + let changed_depth = NormalizedDepthLoad::Clear { value: 0.5 }; + color_attachments[0].load = changed_color; + depth.load = changed_depth; + for access in &mut graph.executions[pipeline].accesses { + match &mut access.mode { + AccessMode::ColorAttachment { load, .. } => *load = changed_color, + AccessMode::DepthAttachment { load, .. } => *load = changed_depth, + _ => {} + } + } + assert_runtime_path(&graph, format!("executions[{pipeline}].kind")); +} + +#[test] +fn runtime_rejects_coordinated_usage_and_alias_mutations() { + let baseline = compile_graph(hdr_copy_graph()); + let family = family_by_source(&baseline, "hdr").id as usize; + let allocation = baseline.texture_families[family].allocation.unwrap(); + let mut graph = baseline.clone(); + graph.texture_families[family].usage = vec![TextureUsage::ColorAttachment]; + graph.allocation_classes[allocation.class as usize].slots[allocation.slot as usize].usage = + vec![TextureUsage::ColorAttachment]; + assert_runtime_path(&graph, format!("textureFamilies[{family}].usage")); + + let mut graph = baseline.clone(); + graph.texture_families[family] + .usage + .push(TextureUsage::Sampled); + graph.allocation_classes[allocation.class as usize].slots[allocation.slot as usize] + .usage + .push(TextureUsage::Sampled); + assert_runtime_path(&graph, format!("textureFamilies[{family}].usage")); + + let mut graph = baseline.clone(); + graph.allocation_classes[allocation.class as usize].slots[allocation.slot as usize] + .usage + .clear(); + assert_runtime_path( + &graph, + format!( + "allocationClasses[{}].slots[{}].usage", + allocation.class, allocation.slot + ), + ); + + let mut overlap = compile_graph(independent_depth_graph( + vec![ + depth_spec("depth_a", "transient"), + depth_spec("depth_b", "transient"), + ], + vec![ + pipeline_node("F0", input("color", "texture"), input("depth_a", "texture")), + pipeline_node("F1", input("F0", "color"), input("depth_b", "texture")), + pipeline_node("F2", input("F1", "color"), input("F0", "depth")), + ], + "F2", + )); + let a = family_by_source(&overlap, "depth_a").id as usize; + let b = family_by_source(&overlap, "depth_b").id as usize; + let destination = overlap.texture_families[a].allocation.unwrap(); + let old = overlap.texture_families[b].allocation.unwrap(); + overlap.texture_families[b].allocation = Some(destination); + for version in overlap.texture_families[b].versions.clone() { + if let ResourcePlan::Texture { allocation, .. } = + &mut overlap.resources[version.resource as usize].plan + { + *allocation = Some(destination); + } + } + overlap.allocation_classes[old.class as usize].slots[old.slot as usize] + .occupants + .retain(|id| *id != b as u32); + let slot = &mut overlap.allocation_classes[destination.class as usize].slots + [destination.slot as usize]; + slot.kind = AllocationKind::AliasedTransient; + slot.occupants.push(b as u32); + assert_runtime_path( + &overlap, + format!( + "allocationClasses[{}].slots[{}].occupants", + destination.class, destination.slot ), - (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 graph = full_cull_graph(); + graph["graphId"] = json!("registry"); + graph["revision"] = json!(revision); + serde_json::to_vec(&graph).unwrap() }; let mut r = Registry::new(2); let (id, _) = r.compile(&bytes(1)).unwrap(); @@ -1110,10 +2333,10 @@ fn registry_revision_handles_are_immutable_and_drop_is_transactional() { } #[test] -fn legacy_schema_is_rejected() { - let legacy = br#"{"schemaVersion":1,"graphId":"legacy","revision":1,"nodes":[]}"#; +fn old_schema_is_rejected() { + let old = br#"{"schemaVersion":1,"graphId":"old","revision":1,"nodes":[]}"#; assert_eq!( - parse_and_compile(legacy).unwrap_err().code, + parse_and_compile(old).unwrap_err().code, "GRAPH_SCHEMA_UNSUPPORTED" ); } @@ -1124,44 +2347,42 @@ fn socket_validation_is_globally_phased() { g["nodes"][3]["inputs"] .as_object_mut() .unwrap() - .remove("scene"); - g["nodes"][9]["inputs"]["bogus"] = input("scene", "scene"); + .remove("mesh"); + g["nodes"][3]["inputs"]["bogus"] = input("mesh", "mesh"); let e = compile_error(g); assert_eq!( (e.code, e.details["path"].as_str()), - ("GRAPH_UNKNOWN_SOCKET", Some("nodes[9].inputs.bogus")) + ("GRAPH_UNKNOWN_SOCKET", Some("nodes[3].inputs.bogus")) ); let mut g = full_cull_graph(); - g["nodes"][3]["inputs"]["scene"] = input("frustum", "frustum"); - g["nodes"][9]["inputs"]["colorTarget"]["socket"] = json!("bogus"); + g["nodes"][6]["inputs"]["colorTarget"]["socket"] = json!("bogus"); let e = compile_error(g); assert_eq!( (e.code, e.details["path"].as_str()), ( "GRAPH_UNKNOWN_SOCKET", - Some("nodes[9].inputs.colorTarget.socket") + Some("nodes[6].inputs.colorTarget.socket") ) ); let mut g = full_cull_graph(); - g["nodes"][3]["inputs"]["scene"] = input("frustum", "frustum"); - g["nodes"][9]["inputs"] + g["nodes"][6]["inputs"] .as_object_mut() .unwrap() .remove("draws"); let e = compile_error(g); assert_eq!( (e.code, e.details["path"].as_str()), - ("GRAPH_SOCKET_CARDINALITY", Some("nodes[9].inputs.draws")) + ("GRAPH_SOCKET_CARDINALITY", Some("nodes[6].inputs.draws")) ); let mut g = full_cull_graph(); - g["nodes"][3]["inputs"]["scene"] = input("frustum", "frustum"); + g["nodes"][6]["inputs"]["mesh"] = input("depth", "texture"); let e = compile_error(g); assert_eq!( (e.code, e.details["path"].as_str()), - ("GRAPH_SOCKET_TYPE_MISMATCH", Some("nodes[3].inputs.scene")) + ("GRAPH_SOCKET_TYPE_MISMATCH", Some("nodes[6].inputs.mesh")) ); } @@ -1169,7 +2390,7 @@ fn socket_validation_is_globally_phased() { 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"); + g["nodes"][4]["executor"]["key"] = json!("unknown"); let e = compile_error(g); assert_eq!( (e.code, e.details["path"].as_str()), @@ -1179,25 +2400,25 @@ fn executor_version_parameter_and_state_precedence_is_global() { 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"); + g["nodes"][2]["inputs"]["bad"] = input("mesh", "bad"); + g["nodes"][4]["executor"]["key"] = json!("unknown"); let e = compile_error(g); assert_eq!( (e.code, e.details["path"].as_str()), - ("GRAPH_UNKNOWN_EXECUTOR", Some("nodes[10].executor.key")) + ("GRAPH_UNKNOWN_EXECUTOR", Some("nodes[4].executor.key")) ); let mut g = full_cull_graph(); g["nodes"][0]["parameters"] = json!({"bad":1}); g["nodes"][1]["state"] = json!("muted"); - g["nodes"][2]["inputs"]["bad"] = input("scene", "bad"); - g["nodes"][10]["executor"]["version"] = json!(2); + g["nodes"][2]["inputs"]["bad"] = input("mesh", "bad"); + g["nodes"][4]["executor"]["version"] = json!(2); let e = compile_error(g); assert_eq!( (e.code, e.details["path"].as_str()), ( "GRAPH_EXECUTOR_VERSION_UNSUPPORTED", - Some("nodes[10].executor.version") + Some("nodes[4].executor.version") ) ); @@ -1250,13 +2471,18 @@ fn attachment_compatibility_matrix_is_enforced() { d["format"] = json!(format); d["dimension"] = json!(dimension); d["sampleCount"] = json!(samples); - assert_eq!(compile_error(g).code, "GRAPH_ILLEGAL_ACCESS"); + let error = compile_error(g); + if error.details["path"] == "nodes[1].parameters.texture.extent.depthOrArrayLayers" { + assert_eq!(error.code, "GRAPH_UNSUPPORTED_FEATURE"); + } else { + assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); + } } let mut g = full_cull_graph(); g["nodes"][1]["parameters"] = texture("rgba8_unorm", "transient"); - g["nodes"][9]["inputs"]["colorTarget"] = input("depth", "spec"); - g["nodes"][9]["inputs"]["depthTarget"] = input("surface", "surface"); + g["nodes"][6]["inputs"]["colorTarget"] = input("depth", "texture"); + g["nodes"][6]["inputs"]["depthTarget"] = input("color", "texture"); assert_eq!(compile_error(g).code, "GRAPH_ILLEGAL_ACCESS"); for field in ["dimension", "extent", "sampleCount"] { @@ -1277,26 +2503,34 @@ fn attachment_compatibility_matrix_is_enforced() { 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}"); + .insert(2, node("test_color", "texture", color, json!({}))); + g["nodes"][7]["inputs"]["colorTarget"] = input("test_color", "texture"); + assert_eq!( + compile_error(g).code, + if field == "extent" { + "GRAPH_ILLEGAL_ACCESS" + } else { + "GRAPH_UNSUPPORTED_FEATURE" + }, + "{field}" + ); } let mut g = full_cull_graph(); g["nodes"].as_array_mut().unwrap().insert( 2, node( - "color", - "texture_spec", - texture("rgba8_unorm", "transient"), + "test_color", + "texture", + texture("r32_float", "transient"), json!({}), ), ); - g["nodes"][10]["inputs"]["colorTarget"] = input("color", "spec"); + g["nodes"][7]["inputs"]["colorTarget"] = input("test_color", "texture"); let e = compile_error(g); assert_eq!( (e.code, e.details["path"].as_str()), - ("GRAPH_ILLEGAL_ACCESS", Some("nodes[11].inputs.surface")) + ("GRAPH_ILLEGAL_ACCESS", Some("nodes[8].inputs.color")) ); } @@ -1308,12 +2542,12 @@ fn wire_rejects_old_and_unknown_fields_exactly() { cases.push(g); for old in ["compare", "writeEnabled", "clear"] { let mut g = full_cull_graph(); - g["nodes"][8]["parameters"][old] = json!(1); + g["nodes"][6]["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 }; + let i = 6; g["nodes"][i]["parameters"] .as_object_mut() .unwrap() @@ -1361,36 +2595,41 @@ fn raw_limits_precede_malformed_content_and_cover_live_resources() { .map(|i| { node( &format!("p{i}"), - "present", + "frame_out", json!({}), - json!({"surface":input("missing","bad")}), + json!({"color":input("missing","bad")}), ) }) .collect(); assert_eq!( compile_error(graph(std::mem::take(&mut nodes))).details["path"], - "nodes" + "nodes[0].inputs.color.node" ); - let mut nodes = vec![node("surface", "surface_target", json!({}), json!({}))]; + let mut nodes = vec![node( + "color", + "texture", + texture("rgba8_unorm", "transient"), + json!({}), + )]; nodes.extend(render_support_nodes()); - let mut color = input("surface", "surface"); + let mut color = input("color", "texture"); for i in 0..508 { let d = format!("d{i}"); let f = format!("f{i}"); nodes.push(node( &d, - "texture_spec", + "texture", texture("depth32_float", "transient"), json!({}), )); - nodes.push(forward(&f, color, input(&d, "spec"))); + nodes.push(pipeline_node(&f, color, input(&d, "texture"))); color = input(&f, "color"); } nodes.push(node( - "present", - "present", + "frame_out", + "frame_out", json!({}), - json!({"surface":color}), + json!({"color":color}), )); let e = compile_error(graph(nodes)); assert_eq!( @@ -1402,33 +2641,38 @@ fn raw_limits_precede_malformed_content_and_cover_live_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!({}))]; + let mut nodes = vec![node( + "color", + "texture", + texture("rgba8_unorm", "transient"), + json!({}), + )]; nodes.extend(render_support_nodes()); - let mut color = input("surface", "surface"); + let mut color = input("color", "texture"); for i in 0..508 { let d = format!("d{i}"); let f = format!("f{i}"); nodes.push(node( &d, - "texture_spec", + "texture", texture("depth32_float", "transient"), json!({}), )); - nodes.push(forward(&f, color, input(&d, "spec"))); + nodes.push(pipeline_node(&f, color, input(&d, "texture"))); color = input(&f, "color"); } nodes.push(node( - "present", - "present", + "frame_out", + "frame_out", json!({}), - json!({"surface":color}), + json!({"color":color}), )); if old_present { nodes.push(node( "old_present", - "present", + "frame_out", json!({}), - json!({"surface":input("f0","color")}), + json!({"color":input("f0","color")}), )); } assert!(nodes.len() <= 1024); @@ -1441,8 +2685,8 @@ fn generated_resource_limit_is_only_final() { ("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"); + assert_eq!(polluted.code, "GRAPH_EXECUTION_UNSUPPORTED"); + assert_eq!(polluted.details["path"], "nodes"); } #[test] @@ -1454,36 +2698,36 @@ fn bloom_composite_rejects_each_stale_sampled_texture_version() { 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( + "color", + "texture", + texture("rgba8_unorm", "transient"), + json!({}), + ), node( "source_target", - "texture_spec", + "texture", texture("rgba16_float", "transient"), json!({}), ), - node("bloom_target", "texture_spec", half, json!({})), + node("bloom_target", "texture", half, json!({})), node( "output", - "texture_spec", + "texture", 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!({})), + node("bloom_depth_0", "texture", half_depth.clone(), json!({})), + node("bloom_depth_1", "texture", 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")), + pipeline_node("source_f0", input("source_target","texture"), input("source_depth_0","texture")), + pipeline_node("source_f1", input("source_f0","color"), input("source_depth_1","texture")), + pipeline_node("bloom_f0", input("bloom_target","texture"), input("bloom_depth_0","texture")), + pipeline_node("bloom_f1", input("bloom_f0","color"), input("bloom_depth_1","texture")), node( "composite", "bloom_composite", @@ -1491,98 +2735,44 @@ fn bloom_composite_rejects_each_stale_sampled_texture_version() { 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") + "colorTarget":input("output","texture") }), ), node( "to_surface", "fullscreen_copy", json!({}), - json!({"source":input("composite","color"),"colorTarget":input("surface","surface")}), + json!({"source":input("composite","color"),"colorTarget":input("color","texture")}), ), node( - "present", - "present", + "frame_out", + "frame_out", json!({}), - json!({"surface":input("to_surface","color")}), + json!({"color":input("to_surface","color")}), ), ]); let error = compile_error(graph(nodes)); assert_eq!(error.code, "GRAPH_RESOURCE_VERSION_INVALID"); assert_eq!( error.details["path"], - format!("nodes[16].inputs.{stale_socket}") + format!("nodes[15].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()); + compile_graph(bloom_composite_graph()); - let mut invalid = make_graph(); + let mut invalid = bloom_composite_graph(); invalid["nodes"][2]["parameters"]["texture"]["format"] = json!("rgba8_unorm"); invalid["nodes"][2]["parameters"]["texture"]["mipLevelCount"] = json!(2); let error = compile_error(invalid); - assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); - assert_eq!(error.details["path"], "nodes[12].inputs"); + assert_eq!(error.code, "GRAPH_UNSUPPORTED_FEATURE"); + assert_eq!( + error.details["path"], + "nodes[2].parameters.texture.mipLevelCount" + ); } #[test] @@ -1592,60 +2782,65 @@ fn fullscreen_copy_rejects_incompatible_authored_targets_at_copy_inputs() { "format", json!("depth32_float"), "GRAPH_ILLEGAL_ACCESS", - "nodes[9].inputs", + "nodes[8].inputs", ), ( "dimension", json!("d3"), - "GRAPH_PARAMETERS_INVALID", - "nodes[2].parameters.texture.extent", + "GRAPH_UNSUPPORTED_FEATURE", + "nodes[2].parameters.texture.dimension", ), ( "sampleCount", json!(4), - "GRAPH_ILLEGAL_ACCESS", - "nodes[9].inputs", + "GRAPH_UNSUPPORTED_FEATURE", + "nodes[2].parameters.texture.sampleCount", ), ( "mipLevelCount", json!(2), - "GRAPH_ILLEGAL_ACCESS", - "nodes[9].inputs", + "GRAPH_UNSUPPORTED_FEATURE", + "nodes[2].parameters.texture.mipLevelCount", ), ] { let mut target = texture("rgba16_float", "transient"); target["texture"][field] = value; let mut nodes = vec![ - node("surface", "surface_target", json!({}), json!({})), + node( + "color", + "texture", + texture("rgba8_unorm", "transient"), + json!({}), + ), node( "source", - "texture_spec", + "texture", texture("rgba16_float", "transient"), json!({}), ), - node("target", "texture_spec", target, json!({})), + node("target", "texture", target, json!({})), depth_spec("depth", "transient"), ]; nodes.extend(render_support_nodes()); nodes.extend([ - forward("source_writer", input("source","spec"), input("depth","spec")), + pipeline_node("source_writer", input("source","texture"), input("depth","texture")), node( "copy", "fullscreen_copy", json!({}), - json!({"source":input("source_writer","color"),"colorTarget":input("target","spec")}), + json!({"source":input("source_writer","color"),"colorTarget":input("target","texture")}), ), node( "to_surface", "fullscreen_copy", json!({}), - json!({"source":input("copy","color"),"colorTarget":input("surface","surface")}), + json!({"source":input("copy","color"),"colorTarget":input("color","texture")}), ), node( - "present", - "present", + "frame_out", + "frame_out", json!({}), - json!({"surface":input("to_surface","color")}), + json!({"color":input("to_surface","color")}), ), ]); let error = compile_error(graph(nodes)); @@ -1653,3 +2848,309 @@ fn fullscreen_copy_rejects_incompatible_authored_targets_at_copy_inputs() { assert_eq!(error.details["path"], expected_path, "field {field}"); } } + +#[test] +fn runtime_rejects_coordinated_executor_frustum_and_fullscreen_mutations() { + let baseline = compile_graph(full_cull_graph()); + validate_activatable(&baseline).unwrap(); + + let mut graph = baseline.clone(); + graph.executions[0].executor.key = "unknown_executor".into(); + graph.executions[0].inputs.push(CompiledSocketInput { + socket: "bad".into(), + resource: u32::MAX, + }); + let error = validate_activatable(&graph).unwrap_err(); + assert_eq!(error.code, "GRAPH_EXECUTION_UNSUPPORTED"); + assert_eq!(error.details["path"], "executions[0]"); + + let cull = baseline + .executions + .iter() + .position(|execution| execution.executor.key == "frustum_cull") + .unwrap(); + let mut graph = baseline.clone(); + graph.executions[cull].kind = ExecutionKind::Compute { + work: ComputeWork::MeshQuery, + }; + assert_runtime_path(&graph, format!("executions[{cull}].kind")); + let mut graph = baseline.clone(); + graph.executions[cull].inputs.swap(0, 1); + graph.executions[cull].accesses.swap(0, 1); + assert_runtime_path(&graph, format!("executions[{cull}].inputs")); + + let post = compile_graph(hdr_copy_graph()); + validate_activatable(&post).unwrap(); + let copy = post + .executions + .iter() + .position(|execution| execution.executor.key == "fullscreen_copy") + .unwrap(); + let mut graph = post.clone(); + graph.executions[copy].executor.key = "tone_map".into(); + assert_runtime_path(&graph, format!("executions[{copy}].parameters")); + for exposure in [f32::NAN, -0.1, 32.1] { + let mut graph = post.clone(); + graph.executions[copy].executor.key = "tone_map".into(); + graph.executions[copy].parameters = NormalizedParameters::ToneMap { exposure }; + assert_runtime_path(&graph, format!("executions[{copy}].parameters")); + } + let mut graph = post.clone(); + let ExecutionKind::Render { + color_attachments, .. + } = &mut graph.executions[copy].kind + else { + unreachable!() + }; + color_attachments[0].store = StoreOp::Discard; + if let AccessMode::ColorAttachment { store, .. } = &mut graph.executions[copy].accesses[1].mode + { + *store = StoreOp::Discard; + } + assert_runtime_path(&graph, format!("executions[{copy}].kind")); +} + +#[test] +fn runtime_rejects_mesh_query_predicate_shape_mutations() { + let baseline = compile_graph(full_cull_graph()); + validate_activatable(&baseline).unwrap(); + let query = baseline + .executions + .iter() + .position(|execution| execution.executor.key == "mesh_query") + .unwrap(); + + let mut graph = baseline.clone(); + let removed = graph.executions[query].inputs.pop().unwrap().resource; + graph.executions[query].accesses.remove(2); + let producer = graph.resources[removed as usize] + .producer_execution + .unwrap(); + graph.resources[removed as usize].lifetime = Some(Lifetime { + first_use: producer, + last_use: producer, + }); + assert_runtime_path(&graph, format!("executions[{query}].inputs")); + + let mut graph = baseline.clone(); + let NormalizedParameters::MeshQuery { + frustum_culled_predicate, + .. + } = &mut graph.executions[query].parameters + else { + unreachable!() + }; + *frustum_culled_predicate = RuntimePredicate::Any; + assert_runtime_path(&graph, format!("executions[{query}].inputs")); + + let mut graph = baseline; + let NormalizedParameters::MeshQuery { + visible_predicate, .. + } = &mut graph.executions[query].parameters + else { + unreachable!() + }; + *visible_predicate = RuntimePredicate::Never; + assert_runtime_path(&graph, format!("executions[{query}].parameters")); +} + +#[test] +fn runtime_rejects_fullscreen_parameter_and_sample_order_mutations() { + let baseline = compile_graph(hdr_copy_graph()); + validate_activatable(&baseline).unwrap(); + let copy = baseline + .executions + .iter() + .position(|execution| execution.executor.key == "fullscreen_copy") + .unwrap(); + let invalid_parameters = [ + ( + "tone_map", + NormalizedParameters::ToneMap { + exposure: f32::INFINITY, + }, + ), + ( + "bloom_extract", + NormalizedParameters::BloomExtract { + threshold: -0.1, + knee: 0.5, + }, + ), + ( + "bloom_extract", + NormalizedParameters::BloomExtract { + threshold: 1.0, + knee: f32::NAN, + }, + ), + ( + "bloom_blur", + NormalizedParameters::BloomBlur { + direction: [0.5, 0.5], + radius: 0.9, + }, + ), + ( + "bloom_blur", + NormalizedParameters::BloomBlur { + direction: [f32::NAN, 0.0], + radius: 1.0, + }, + ), + ( + "bloom_composite", + NormalizedParameters::BloomComposite { intensity: 16.1 }, + ), + ( + "luminance_edge", + NormalizedParameters::LuminanceEdge { strength: -0.1 }, + ), + ]; + for (key, parameters) in invalid_parameters { + let mut graph = baseline.clone(); + graph.executions[copy].executor.key = key.into(); + graph.executions[copy].parameters = parameters; + assert_runtime_path(&graph, format!("executions[{copy}].parameters")); + } + + let mut graph = compile_graph(bloom_composite_graph()); + validate_activatable(&graph).unwrap(); + let composite = graph + .executions + .iter() + .position(|execution| execution.executor.key == "bloom_composite") + .unwrap(); + graph.executions[composite].accesses.swap(0, 1); + assert_runtime_path(&graph, format!("executions[{composite}].accesses")); +} + +#[test] +fn runtime_requires_exact_query_and_draw_stream_producers() { + let baseline = compile_graph(full_cull_graph()); + validate_activatable(&baseline).unwrap(); + let query = baseline + .executions + .iter() + .position(|execution| execution.executor.key == "mesh_query") + .unwrap(); + let pipeline = baseline + .executions + .iter() + .position(|execution| execution.executor.key == "pipeline") + .unwrap(); + + let mut graph = baseline.clone(); + let original = graph.executions[query].inputs[2].resource; + let mut synthetic = graph.resources[original as usize].clone(); + synthetic.producer_execution = None; + synthetic.lifetime = Some(Lifetime { + first_use: query as u32, + last_use: query as u32, + }); + let replacement = graph.resources.len() as u32; + graph.resources.push(synthetic); + graph.executions[query].inputs[2].resource = replacement; + graph.executions[query].accesses[2].resource = replacement; + let producer = graph.resources[original as usize] + .producer_execution + .unwrap(); + graph.resources[original as usize].lifetime = Some(Lifetime { + first_use: producer, + last_use: producer, + }); + assert_runtime_path(&graph, format!("executions[{query}].inputs")); + + let mut graph = baseline; + let original = graph.executions[pipeline].inputs[1].resource; + let mut synthetic = graph.resources[original as usize].clone(); + synthetic.producer_execution = None; + synthetic.lifetime = Some(Lifetime { + first_use: pipeline as u32, + last_use: pipeline as u32, + }); + let replacement = graph.resources.len() as u32; + graph.resources.push(synthetic); + graph.executions[pipeline].inputs[1].resource = replacement; + graph.executions[pipeline].accesses[1].resource = replacement; + graph.resources[original as usize].lifetime = Some(Lifetime { + first_use: query as u32, + last_use: query as u32, + }); + assert_runtime_path(&graph, format!("executions[{pipeline}].inputs")); +} + +#[test] +fn runtime_rechecks_pipeline_attachment_descriptors() { + let baseline = compile_graph(full_cull_graph()); + validate_activatable(&baseline).unwrap(); + let pipeline = baseline + .executions + .iter() + .position(|execution| execution.executor.key == "pipeline") + .unwrap(); + let attachment = |graph: &CompiledGraph, socket: &str| { + graph.executions[pipeline] + .outputs + .iter() + .find(|output| output.socket == socket) + .unwrap() + .resource + }; + let mutate_descriptor = + |graph: &mut CompiledGraph, resource: u32, mutate: fn(&mut NormalizedTextureDescriptor)| { + let (family, allocation) = match graph.resources[resource as usize].plan { + ResourcePlan::Texture { + family, allocation, .. + } => (family as usize, allocation.unwrap()), + _ => unreachable!(), + }; + let TextureFamilySource::AuthoredTexture { + resource: source, + descriptor, + .. + } = &mut graph.texture_families[family].source; + mutate(descriptor); + let descriptor = descriptor.clone(); + let ResourcePlan::TextureSource { + descriptor: source_descriptor, + .. + } = &mut graph.resources[*source as usize].plan + else { + unreachable!() + }; + *source_descriptor = descriptor.clone(); + let key = &mut graph.allocation_classes[allocation.class as usize].key; + key.dimension = descriptor.dimension; + key.format = descriptor.format; + key.extent = descriptor.extent; + key.mip_level_count = descriptor.mip_level_count; + key.sample_count = descriptor.sample_count; + key.view_formats = descriptor.view_formats; + }; + + let mut graph = baseline.clone(); + let color = attachment(&graph, "color"); + mutate_descriptor(&mut graph, color, |descriptor| { + descriptor.format = TextureFormat::Depth32Float; + }); + assert_runtime_path(&graph, format!("executions[{pipeline}].inputs")); + + let mut graph = baseline.clone(); + let depth = attachment(&graph, "depth"); + mutate_descriptor(&mut graph, depth, |descriptor| { + descriptor.format = TextureFormat::Rgba16Float; + }); + assert_runtime_path(&graph, format!("executions[{pipeline}].inputs")); + + let mut graph = baseline; + let color = attachment(&graph, "color"); + mutate_descriptor(&mut graph, color, |descriptor| { + descriptor.extent = NormalizedTextureExtent::Absolute { + width: 4, + height: 4, + depth_or_array_layers: 1, + }; + }); + assert_runtime_path(&graph, format!("executions[{pipeline}].inputs")); +} diff --git a/renderer/src/renderer/culling.wgsl b/renderer/src/renderer/culling.wgsl index a065adb..e20420b 100644 --- a/renderer/src/renderer/culling.wgsl +++ b/renderer/src/renderer/culling.wgsl @@ -42,10 +42,14 @@ fn mesh_query(@builtin(global_invocation_id) id: vec3) { if (i >= params.count) { return; } let draw_meta = metadata[i]; var selected = true; - if (params.visible_predicate != 0u) { + if (params.visible_predicate == 3u) { + selected = false; + } else if (params.visible_predicate != 0u) { selected = matches(authored_visible[i], params.visible_predicate); } - if (params.frustum_predicate != 0u) { + if (selected && params.frustum_predicate == 3u) { + selected = false; + } else if (selected && params.frustum_predicate != 0u) { selected = selected && matches(frustum_flags[i], params.frustum_predicate); } commands[i] = Command(draw_meta.index_count, select(0u, 1u, selected), draw_meta.first_index, draw_meta.base_vertex, 0u); diff --git a/renderer/src/renderer/executors/mod.rs b/renderer/src/renderer/executors/mod.rs index a7ce3ce..acee9f2 100644 --- a/renderer/src/renderer/executors/mod.rs +++ b/renderer/src/renderer/executors/mod.rs @@ -1,3 +1,3 @@ -mod legacy_forward; +mod pipeline; -pub(super) use legacy_forward::{encode_compiled, encode_immediate}; +pub(super) use pipeline::{encode_compiled, encode_immediate}; diff --git a/renderer/src/renderer/executors/legacy_forward.rs b/renderer/src/renderer/executors/pipeline.rs similarity index 85% rename from renderer/src/renderer/executors/legacy_forward.rs rename to renderer/src/renderer/executors/pipeline.rs index defeb73..0bd1b5a 100644 --- a/renderer/src/renderer/executors/legacy_forward.rs +++ b/renderer/src/renderer/executors/pipeline.rs @@ -56,25 +56,8 @@ pub(crate) fn encode_compiled( materials: &MaterialResources, mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>, ) -> Result<(), &'static str> { - use crate::render_graph::{ - ExecutionKind, NormalizedColorLoad, NormalizedDepthLoad, ResourcePlan, StoreOp, - }; + use crate::render_graph::{ExecutionKind, NormalizedColorLoad, NormalizedDepthLoad, StoreOp}; let view = |resource: u32| -> Result<&wgpu::TextureView, &'static str> { - let is_surface = active - .graph - .resources - .get(resource as usize) - .is_some_and(|resource| { - matches!( - resource.plan, - ResourcePlan::SurfaceTarget { family } - | ResourcePlan::Texture { family, .. } - if family == active.runtime.allocations.surface_family - ) - }); - if is_surface { - return Ok(surface); - } let a = active .runtime .allocations @@ -93,15 +76,16 @@ pub(crate) fn encode_compiled( for (execution_index, prepared) in active.executions.iter().enumerate() { let profile_id = &active.graph.executions[execution_index].id; match prepared { + PreparedExecution::PipelineRegistry => {} PreparedExecution::FrustumCull => { gpu.encode_frustum_cull(encoder, profile.as_deref_mut(), profile_id); } PreparedExecution::MeshQuery => { gpu.encode_mesh_query(encoder, profile.as_deref_mut(), profile_id); } - PreparedExecution::Present => {} PreparedExecution::Fullscreen { execution, + frame_out, bind_group, pipeline, .. @@ -111,22 +95,30 @@ pub(crate) fn encode_compiled( .executions .get(*execution) .ok_or(" execution out of bounds")?; - let ExecutionKind::Render { - color_attachments, .. - } = &execution.kind - else { - return Err("fullscreen is not render"); - }; - let color = color_attachments - .first() - .ok_or("fullscreen target missing")?; - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some(&execution.id), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: view(color.resource)?, - depth_slice: None, - resolve_target: None, - ops: wgpu::Operations { + let (target, operations) = if *frame_out { + let ExecutionKind::FrameOut { .. } = execution.kind else { + return Err("frame_out kind mismatch"); + }; + ( + surface, + wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + ) + } else { + let ExecutionKind::Render { + color_attachments, .. + } = &execution.kind + else { + return Err("fullscreen is not render"); + }; + let color = color_attachments + .first() + .ok_or("fullscreen target missing")?; + ( + view(color.resource)?, + wgpu::Operations { load: match color.load { NormalizedColorLoad::Load => wgpu::LoadOp::Load, NormalizedColorLoad::Clear { value } => { @@ -144,6 +136,15 @@ pub(crate) fn encode_compiled( wgpu::StoreOp::Discard }, }, + ) + }; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(&execution.id), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: operations, })], depth_stencil_attachment: None, occlusion_query_set: None, @@ -155,9 +156,10 @@ pub(crate) fn encode_compiled( pass.set_bind_group(0, bind_group, &[]); pass.draw(0..3, 0..1); } - PreparedExecution::LegacyForward { + PreparedExecution::Pipeline { execution, - variants, + base, + variant, } => { let execution = active .graph @@ -169,10 +171,10 @@ pub(crate) fn encode_compiled( depth_stencil, } = &execution.kind else { - return Err("legacy forward is not render"); + return Err("pipeline is not render"); }; - let color = color_attachments.first().ok_or("legacy color missing")?; - let depth = depth_stencil.as_ref().ok_or("legacy depth missing")?; + let color = color_attachments.first().ok_or("pipeline color missing")?; + let depth = depth_stencil.as_ref().ok_or("pipeline depth missing")?; let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some(&execution.id), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -235,16 +237,14 @@ pub(crate) fn encode_compiled( pass.set_vertex_buffer(4, t.slice(..)); pass.set_index_buffer(ix.slice(..), wgpu::IndexFormat::Uint32); for draw in &gpu.draws { + if draw.pipeline != *base { + continue; + } let slot = draw.instances.start as u64; let start = slot * std::mem::size_of::() as u64; pass.set_vertex_buffer(3, inst.slice(start..start + 112)); - let key = variants - .iter() - .find(|(base, _)| *base == draw.pipeline) - .map(|x| &x.1) - .ok_or("pipeline variant missing")?; - pass.set_pipeline(key); + pass.set_pipeline(variant); if pipelines.requires_material(draw.pipeline) { pass.set_bind_group(2, materials.group(draw.material), &[]); } @@ -274,7 +274,7 @@ pub(crate) fn encode_immediate( profile: Option<&mut crate::renderer::profiler::ProfileFrame>, ) { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Render pass"), + label: Some("Immediate pipeline pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { depth_slice: None, view: color, @@ -298,7 +298,7 @@ pub(crate) fn encode_immediate( stencil_ops: None, }), occlusion_query_set: None, - timestamp_writes: profile.and_then(|p| p.render_writes("immediate.forward")), + timestamp_writes: profile.and_then(|p| p.render_writes("immediate.pipeline")), }); encode_scene(&mut pass, scene, gpu, pipelines, materials); } diff --git a/renderer/src/renderer/gpu_scene.rs b/renderer/src/renderer/gpu_scene.rs index d567ca6..a4b7d52 100644 --- a/renderer/src/renderer/gpu_scene.rs +++ b/renderer/src/renderer/gpu_scene.rs @@ -85,8 +85,8 @@ impl GpuScenePlan { self::GpuScenePlan::build_with_query( data, crate::render_graph::MeshQueryRuntimeKey { - visible: crate::render_graph::TriStatePredicate::RequiredTrue, - frustum_culled: crate::render_graph::TriStatePredicate::Any, + visible: crate::render_graph::RuntimePredicate::RequiredTrue, + frustum_culled: crate::render_graph::RuntimePredicate::Any, }, ) } @@ -277,7 +277,6 @@ pub struct BufferSlot { #[derive(Default)] pub struct GpuSceneCache { revision: Option, - query: Option, pub positions: BufferSlot, pub normals: BufferSlot, pub uvs: BufferSlot, @@ -310,11 +309,12 @@ struct CullingParams { _pad: u32, } -fn predicate_code(value: crate::render_graph::TriStatePredicate) -> u32 { +fn predicate_code(value: crate::render_graph::RuntimePredicate) -> u32 { match value { - crate::render_graph::TriStatePredicate::Any => 0, - crate::render_graph::TriStatePredicate::RequiredTrue => 1, - crate::render_graph::TriStatePredicate::RequiredFalse => 2, + crate::render_graph::RuntimePredicate::Any => 0, + crate::render_graph::RuntimePredicate::RequiredTrue => 1, + crate::render_graph::RuntimePredicate::RequiredFalse => 2, + crate::render_graph::RuntimePredicate::Never => 3, } } @@ -330,8 +330,8 @@ impl GpuSceneCache { queue, data, crate::render_graph::MeshQueryRuntimeKey { - visible: crate::render_graph::TriStatePredicate::RequiredTrue, - frustum_culled: crate::render_graph::TriStatePredicate::Any, + visible: crate::render_graph::RuntimePredicate::RequiredTrue, + frustum_culled: crate::render_graph::RuntimePredicate::Any, }, ) } @@ -343,14 +343,13 @@ impl GpuSceneCache { data: &SceneFramePlan, query: crate::render_graph::MeshQueryRuntimeKey, ) -> Result<(), String> { - if self.revision == Some(data.revision) && self.query == Some(query) { + if self.revision == Some(data.revision) { return Ok(()); } let plan = GpuScenePlan::build_with_query(data, query).map_err(str::to_owned)?; if plan.draws.is_empty() { self.draws.clear(); self.revision = Some(data.revision); - self.query = Some(query); return Ok(()); } let maximum = device.limits().max_buffer_size; @@ -485,7 +484,6 @@ impl GpuSceneCache { self.draws = plan.draws; self.rebuild_compute(device)?; self.revision = Some(data.revision); - self.query = Some(query); Ok(()) } @@ -642,11 +640,28 @@ mod tests { #[test] fn mesh_query_source_guards_optional_flag_buffer_reads() { let source = include_str!("culling.wgsl"); - let visible_guard = source.find("if (params.visible_predicate != 0u)").unwrap(); + let visible_never = source.find("if (params.visible_predicate == 3u)").unwrap(); + let visible_guard = source + .find("else if (params.visible_predicate != 0u)") + .unwrap(); let visible_load = source.find("matches(authored_visible[i]").unwrap(); - let frustum_guard = source.find("if (params.frustum_predicate != 0u)").unwrap(); + let frustum_never = source + .find("if (selected && params.frustum_predicate == 3u)") + .unwrap(); + let frustum_guard = source + .find("else if (selected && params.frustum_predicate != 0u)") + .unwrap(); let frustum_load = source.find("matches(frustum_flags[i]").unwrap(); - assert!(visible_guard < visible_load && frustum_guard < frustum_load); + assert!(visible_never < visible_guard && visible_guard < visible_load); + assert!(frustum_never < frustum_guard && frustum_guard < frustum_load); + assert!( + visible_load < frustum_load, + "visible rejection must precede the frustum load" + ); + assert!( + source.contains("predicate == 0u ||"), + "predicate zero is handled without a load by the guards" + ); for binding in 0..=6 { assert!(source.contains(&format!("@binding({binding})"))); } diff --git a/renderer/src/renderer/mod.rs b/renderer/src/renderer/mod.rs index 038b6eb..31f5d97 100644 --- a/renderer/src/renderer/mod.rs +++ b/renderer/src/renderer/mod.rs @@ -35,17 +35,19 @@ struct GpuTextureSlot { enum PreparedExecution { FrustumCull, MeshQuery, - LegacyForward { + PipelineRegistry, + Pipeline { execution: usize, - variants: Vec<(crate::render_data::PipelineKey, wgpu::RenderPipeline)>, + base: crate::render_data::PipelineKey, + variant: wgpu::RenderPipeline, }, Fullscreen { execution: usize, + frame_out: bool, bind_group: wgpu::BindGroup, pipeline: wgpu::RenderPipeline, _uniform: wgpu::Buffer, }, - Present, } struct ActiveCompiledGraph { @@ -81,7 +83,10 @@ fn resolve_culling_frustum( query: crate::render_graph::MeshQueryRuntimeKey, read: impl FnOnce() -> Option>, ) -> Result, crate::render_graph::GraphError> { - if query.frustum_culled == crate::render_graph::TriStatePredicate::Any { + if matches!( + query.frustum_culled, + crate::render_graph::RuntimePredicate::Any | crate::render_graph::RuntimePredicate::Never + ) { return Ok(None); } match read() { @@ -177,18 +182,25 @@ fn resolve_switch_request( #[cfg(test)] mod switch_request_tests { + fn valid_compile_graph(graph_id: &str, revision: u64) -> Vec { + let mut graph = crate::render_graph::tests::full_cull_graph(); + graph["graphId"] = serde_json::json!(graph_id); + graph["revision"] = serde_json::json!(revision); + serde_json::to_vec(&graph).unwrap() + } + use super::*; - fn query(visible: crate::render_graph::TriStatePredicate) -> UploadGraph { + fn query(visible: crate::render_graph::RuntimePredicate) -> UploadGraph { UploadGraph::Compiled(crate::render_graph::MeshQueryRuntimeKey { visible, - frustum_culled: crate::render_graph::TriStatePredicate::Any, + frustum_culled: crate::render_graph::RuntimePredicate::Any, }) } #[test] fn upload_selection_follows_the_graph_rendered_for_the_commit_frame() { - use crate::render_graph::TriStatePredicate::{Any, RequiredFalse, RequiredTrue}; + use crate::render_graph::RuntimePredicate::{Any, RequiredFalse, RequiredTrue}; let selected = |pending, active| upload_query_for_render(pending, active).map(|query| query.visible); assert_eq!( @@ -214,7 +226,7 @@ mod switch_request_tests { #[test] fn frustum_preflight_skips_any_and_distinguishes_missing_from_invalid() { - use crate::render_graph::TriStatePredicate::{Any, RequiredFalse, RequiredTrue}; + use crate::render_graph::RuntimePredicate::{Any, RequiredFalse, RequiredTrue}; let query = |frustum_culled| crate::render_graph::MeshQueryRuntimeKey { visible: RequiredTrue, frustum_culled, @@ -245,8 +257,10 @@ mod switch_request_tests { #[test] fn resolves_at_command_boundary_before_gpu_work() { let mut registry = crate::render_graph::Registry::default(); - let bytes = br#"{"schemaVersion":2,"graphId":"switch","revision":1,"nodes":[]}"#; - let (id, _) = registry.compile(bytes).unwrap(); + let mut graph = crate::render_graph::tests::full_cull_graph(); + graph["graphId"] = serde_json::json!("switch"); + let bytes = serde_json::to_vec(&graph).unwrap(); + let (id, _) = registry.compile(&bytes).unwrap(); let active = "existing_graph"; let pending: Option<&str> = None; assert_eq!( @@ -279,9 +293,7 @@ mod switch_request_tests { #[test] fn resize_restart_snapshot_remains_bound_to_its_immutable_registry_revision() { let mut registry = crate::render_graph::Registry::default(); - let (id, _) = registry - .compile(br#"{"schemaVersion":2,"graphId":"resize","revision":1,"nodes":[]}"#) - .unwrap(); + let (id, _) = registry.compile(&valid_compile_graph("resize", 1)).unwrap(); let revision_one = registry.get(id).unwrap().clone(); let in_flight = InFlightPreparation { token: 1, @@ -289,9 +301,7 @@ mod switch_request_tests { purpose: PreparationPurpose::Resize, graph: revision_one, }; - let (revision_two_id, _) = registry - .compile(br#"{"schemaVersion":2,"graphId":"resize","revision":2,"nodes":[]}"#) - .unwrap(); + let (revision_two_id, _) = registry.compile(&valid_compile_graph("resize", 2)).unwrap(); let original = registry.get(id).unwrap(); let revision_two = registry.get(revision_two_id).unwrap(); assert_eq!(in_flight.graph.revision, 1); @@ -728,6 +738,26 @@ impl Renderer { ) -> Result { use crate::render_graph::*; let fail = |message| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", message); + let resolved_pipelines = graph + .executions + .iter() + .enumerate() + .map(|(index, execution)| { + let NormalizedParameters::Pipeline { pipeline, .. } = &execution.parameters else { + return Ok(None); + }; + self.resources + .find_pipeline(pipeline) + .map(Some) + .ok_or_else(|| { + GraphError::at( + "GRAPH_EXECUTION_UNSUPPORTED", + format!("pipeline '{pipeline}' is not registered"), + format!("executions[{index}].parameters.pipeline"), + ) + }) + }) + .collect::, _>>()?; let mut textures = Vec::with_capacity(runtime.allocations.classes.len()); for class in &runtime.allocations.classes { let mut gpu_class = Vec::with_capacity(class.slots.len()); @@ -845,37 +875,53 @@ impl Renderer { match execution.executor.key.as_str() { "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" + "frame_out" | "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" | "luminance_edge" => { - let sampled: Vec<_> = execution - .accesses - .iter() - .filter(|a| matches!(a.mode, AccessMode::SampledTexture)) - .map(|a| a.resource) - .collect(); - let source = *sampled - .first() - .ok_or_else(|| fail("fullscreen source missing"))?; - let second = *sampled.get(1).unwrap_or(&source); - let values: [f32; 8] = match execution.parameters { - NormalizedParameters::ToneMap { exposure } => { - [exposure, 0., 0., 0., 0., 0., 0., 0.] + let frame_out = execution.executor.key == "frame_out"; + let (source, second) = if frame_out { + let ExecutionKind::FrameOut { color } = execution.kind else { + return Err(fail("frame_out kind mismatch")); + }; + (color, color) + } else { + match execution.inputs.as_slice() { + [source, _color_target] => (source.resource, source.resource), + [source, bloom, _color_target] + if execution.executor.key == "bloom_composite" => + { + (source.resource, bloom.resource) + } + _ => return Err(fail("fullscreen inputs mismatch")), } - NormalizedParameters::BloomExtract { threshold, knee } => { - [threshold, knee, 0., 0., 0., 0., 0., 0.] - } - NormalizedParameters::BloomBlur { direction, radius } => { - [direction[0], direction[1], radius, 0., 0., 0., 0., 0.] - } - NormalizedParameters::BloomComposite { intensity } => { - [intensity, 0., 0., 0., 0., 0., 0., 0.] - } - NormalizedParameters::LuminanceEdge { strength } => { - [strength, 0., 0., 0., 0., 0., 0., 0.] - } - _ => [0.; 8], }; + let values: [f32; 8] = + match (execution.executor.key.as_str(), &execution.parameters) { + ( + "fullscreen_copy" | "frame_out", + NormalizedParameters::FullscreenCopy + | NormalizedParameters::FrameOut, + ) => [0.; 8], + ("tone_map", NormalizedParameters::ToneMap { exposure }) => { + [*exposure, 0., 0., 0., 0., 0., 0., 0.] + } + ( + "bloom_extract", + NormalizedParameters::BloomExtract { threshold, knee }, + ) => [*threshold, *knee, 0., 0., 0., 0., 0., 0.], + ( + "bloom_blur", + NormalizedParameters::BloomBlur { direction, radius }, + ) => [direction[0], direction[1], *radius, 0., 0., 0., 0., 0.], + ( + "bloom_composite", + NormalizedParameters::BloomComposite { intensity }, + ) => [*intensity, 0., 0., 0., 0., 0., 0., 0.], + ( + "luminance_edge", + NormalizedParameters::LuminanceEdge { strength }, + ) => [*strength, 0., 0., 0., 0., 0., 0., 0.], + _ => return Err(fail("executor parameters mismatch")), + }; use wgpu::util::DeviceExt; let uniform = self.context @@ -885,17 +931,35 @@ impl Renderer { contents: bytemuck::cast_slice(&values), usage: wgpu::BufferUsages::UNIFORM, }); - let ExecutionKind::Render { - color_attachments, .. - } = &execution.kind - else { - return Err(fail("fullscreen execution is not render")); + let target_format = if frame_out { + runtime.surface.format + } else { + let ExecutionKind::Render { + color_attachments, .. + } = &execution.kind + else { + return Err(fail("fullscreen execution is not render")); + }; + let target = color_attachments + .first() + .ok_or_else(|| fail("fullscreen target missing"))? + .resource; + let a = runtime + .allocations + .resource_allocations + .get(target as usize) + .copied() + .flatten() + .ok_or_else(|| fail("fullscreen target allocation missing"))?; + runtime + .allocations + .classes + .get(a.class as usize) + .and_then(|class| class.slots.get(a.slot as usize)) + .ok_or_else(|| fail("fullscreen target allocation is invalid"))? + .descriptor + .format }; - let target = color_attachments - .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, 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", @@ -903,7 +967,8 @@ impl Renderer { "bloom_blur" => "fs_bloom_blur", "bloom_composite" => "fs_bloom_composite", "luminance_edge" => "fs_luminance_edge", - _ => unreachable!(), + "frame_out" => "fs_copy", + _ => return Err(fail("fullscreen executor mismatch")), }; let pipeline = self.context.device.create_render_pipeline( &wgpu::RenderPipelineDescriptor { @@ -963,36 +1028,25 @@ impl Renderer { }); executions.push(PreparedExecution::Fullscreen { execution: index, + frame_out, bind_group, pipeline, _uniform: uniform, }); } - "legacy_forward" => { + "pipeline_registry" => executions.push(PreparedExecution::PipelineRegistry), + "pipeline" => { let ExecutionKind::Render { color_attachments, depth_stencil, } = &execution.kind else { - return Err(fail("legacy forward is not render")); + return Err(fail("pipeline is not render")); }; let color = color_attachments .first() - .ok_or_else(|| fail("legacy color missing"))?; - let color_is_surface = graph - .resources - .get(color.resource as usize) - .is_some_and(|resource| { - matches!( - resource.plan, - ResourcePlan::SurfaceTarget { family } - | ResourcePlan::Texture { family, .. } - if family == runtime.allocations.surface_family - ) - }); - let color_format = if color_is_surface { - runtime.surface.format - } else { + .ok_or_else(|| fail("pipeline color missing"))?; + let color_format = { let a = runtime .allocations .resource_allocations @@ -1027,19 +1081,21 @@ impl Renderer { .ok_or_else(|| fail("depth allocation invalid")) }) .transpose()?; - let config = execution - .inputs - .iter() - .filter_map(|i| graph.resources.get(i.resource as usize)) - .find_map(|r| { - if let ResourcePlan::DepthStencilConfig { config } = r.plan { - Some(config) - } else { - None - } - }) - .ok_or_else(|| fail("depth config missing"))?; - let compare = match config.depth_compare { + let NormalizedParameters::Pipeline { + pipeline: _, + depth_compare, + depth_write_enabled, + .. + } = &execution.parameters + else { + return Err(fail("pipeline parameters mismatch")); + }; + let base = resolved_pipelines + .get(index) + .copied() + .flatten() + .ok_or_else(|| fail("resolved pipeline missing"))?; + let compare = match depth_compare { CompareFunction::Never => wgpu::CompareFunction::Never, CompareFunction::Less => wgpu::CompareFunction::Less, CompareFunction::LessEqual => wgpu::CompareFunction::LessEqual, @@ -1049,25 +1105,21 @@ impl Renderer { CompareFunction::NotEqual => wgpu::CompareFunction::NotEqual, CompareFunction::Always => wgpu::CompareFunction::Always, }; - let mut variants = Vec::new(); - let bases: Vec<_> = self.resources.pipeline_keys().collect(); - for base in bases { - let variant = self - .resources - .create_target_variant( - &self.context.device, - base, - color_format, - depth_format, - compare, - config.depth_write_enabled, - ) - .map_err(|e| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", e))?; - variants.push((base, variant)); - } - executions.push(PreparedExecution::LegacyForward { + let variant = self + .resources + .create_target_variant( + &self.context.device, + base, + color_format, + depth_format, + compare, + *depth_write_enabled, + ) + .map_err(|e| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", e))?; + executions.push(PreparedExecution::Pipeline { execution: index, - variants, + base, + variant, }); } _ => return Err(fail("unsupported prepared execution")), diff --git a/renderer/src/renderer/pipeline_library.rs b/renderer/src/renderer/pipeline_library.rs index 148bc6f..2955c4f 100644 --- a/renderer/src/renderer/pipeline_library.rs +++ b/renderer/src/renderer/pipeline_library.rs @@ -372,36 +372,6 @@ impl PipelineLibrary { && self.specs[key.get() as usize].layout == self.material_layout } - pub fn pipeline_keys(&self) -> impl Iterator + '_ { - let mut keys = self - .pipeline_registry - .values() - .map(|entry| entry.0) - .collect::>(); - keys.sort_by_key(|key| key.get()); - keys.dedup(); - keys.into_iter() - } - - pub fn get_or_create_target_variant( - &mut self, - device: &wgpu::Device, - base: PipelineKey, - color_format: wgpu::TextureFormat, - depth_format: Option, - depth_compare: wgpu::CompareFunction, - depth_write: bool, - ) -> Result { - let spec = self - .specs - .get(base.get() as usize) - .cloned() - .ok_or_else(|| "unknown base pipeline".to_owned())?; - let spec = - target_variant_spec(spec, color_format, depth_format, depth_compare, depth_write); - Ok(self.get_or_create_from_spec(device, &spec, Some("target variant"))) - } - pub fn create_target_variant( &self, device: &wgpu::Device, diff --git a/static/render-graph/adapter.js b/static/render-graph/adapter.js index 0125dcd..1852e0a 100644 --- a/static/render-graph/adapter.js +++ b/static/render-graph/adapter.js @@ -56,14 +56,20 @@ const sourceMaps = new WeakMap(); export const getSourceMap = (ir) => sourceMaps.get(ir); export const mapAuthoringDiagnostic = (ir, diagnostic) => { const details = diagnostic?.details; - const path = [details?.path, diagnostic?.path, details?.field, diagnostic?.field] - .find((value) => typeof value === "string"); + const path = [ + details?.path, + diagnostic?.path, + details?.field, + diagnostic?.field, + ].find((value) => typeof value === "string"); const map = getSourceMap(ir); let match; if (path && map) for (const key of Object.keys(map)) if ( - (path === key || path.startsWith(`${key}.`) || path.startsWith(`${key}[`)) && + (path === key || + path.startsWith(`${key}.`) || + path.startsWith(`${key}[`)) && (!match || key.length > match.length) ) match = key; @@ -80,33 +86,49 @@ export const mapAuthoringDiagnostic = (ir, diagnostic) => { const mapValuePaths = (paths, path, source, value) => { paths[path] = source; if (Array.isArray(value)) - value.forEach((child, index) => mapValuePaths(paths, `${path}[${index}]`, source, child)); + value.forEach((child, index) => + mapValuePaths(paths, `${path}[${index}]`, source, child), + ); else if (object(value)) for (const key of Object.keys(value)) mapValuePaths(paths, `${path}.${key}`, source, value[key]); }; function parameterValue(raw, schema, nodeId, key) { - const expected = schema.type === "json" ? "json" : schema.type; - if ( - !exactKeys(raw, ["kind", "value"]) || - raw.kind !== expected || - !finiteJson(raw.value) - ) - fail("AUTHORING_PARAMETER", { nodeId, parameter: key }); - if ( - (expected === "number" && typeof raw.value !== "number") || - (expected === "string" && typeof raw.value !== "string") || - (expected === "boolean" && typeof raw.value !== "boolean") || - (expected === "json" && !finiteJson(raw.value)) - ) + if (!exactKeys(raw, ["kind", "value"]) || raw.kind !== schema.type) fail("AUTHORING_PARAMETER", { nodeId, parameter: key }); + const value = raw.value; + const bounded = (number) => + Number.isFinite(number) && + (schema.minimum === undefined || number >= schema.minimum) && + (schema.maximum === undefined || number <= schema.maximum); + const valid = + schema.type === "number" + ? bounded(value) && (!schema.integer || Number.isSafeInteger(value)) + : schema.type === "string" + ? typeof value === "string" && + (!schema.enum || schema.enum.includes(value)) + : schema.type === "boolean" + ? typeof value === "boolean" + : schema.type === "vector" || schema.type === "color" + ? Array.isArray(value) && + value.length === (schema.type === "vector" ? 3 : 4) && + value.every(bounded) + : schema.type === "json" && finiteJson(value); + if (!valid) fail("AUTHORING_PARAMETER", { nodeId, parameter: key }); return canonical(structuredClone(raw.value)); } export function adaptFxNodeSnapshot(raw, revision = 1) { try { - const rootKeys = ["graphId", "catalogVersion", "nodes", "links", "metadata", "version"]; + const rootKeys = [ + "graphId", + "catalogVersion", + "nodes", + "links", + "metadata", + "version", + ]; if ( !exactKeys(raw, rootKeys) || !Array.isArray(raw.nodes) || @@ -117,9 +139,7 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { fail("AUTHORING_SHAPE"); if (raw.graphId !== GRAPH_ID || raw.catalogVersion !== CATALOG_VERSION) fail("AUTHORING_CATALOG"); - if ( - !Number.isSafeInteger(raw.version) || raw.version < 0 - ) + if (!Number.isSafeInteger(raw.version) || raw.version < 0) fail("AUTHORING_SHAPE"); if (!Number.isInteger(revision) || revision < 1 || revision > 0xffffffff) fail("AUTHORING_REVISION"); @@ -134,7 +154,20 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { definition = nodeDefinitions[n.typeId]; if (!descriptor) fail("AUTHORING_NODE_TYPE", { nodeId: n.id, typeId: n.typeId }); - const nodeKeys = ["id", "typeId", "typeVersion", "position", "size", "label", "parameters", "sockets", "muted", "collapsed", "extensions", "known"]; + const nodeKeys = [ + "id", + "typeId", + "typeVersion", + "position", + "size", + "label", + "parameters", + "sockets", + "muted", + "collapsed", + "extensions", + "known", + ]; if (Object.hasOwn(n, "parentId")) nodeKeys.push("parentId"); if ( !exactKeys(n, nodeKeys) || @@ -143,10 +176,17 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { typeof n.muted !== "boolean" || typeof n.collapsed !== "boolean" || typeof n.label !== "string" || - !exactKeys(n.position, ["x", "y"]) || !Number.isFinite(n.position.x) || !Number.isFinite(n.position.y) || - !exactKeys(n.size, ["x", "y"]) || !Number.isFinite(n.size.x) || !Number.isFinite(n.size.y) || n.size.x <= 0 || n.size.y <= 0 || + !exactKeys(n.position, ["x", "y"]) || + !Number.isFinite(n.position.x) || + !Number.isFinite(n.position.y) || + !exactKeys(n.size, ["x", "y"]) || + !Number.isFinite(n.size.x) || + !Number.isFinite(n.size.y) || + n.size.x <= 0 || + n.size.y <= 0 || (Object.hasOwn(n, "parentId") && !identifier(n.parentId)) || - !object(n.extensions) || !finiteJson(n.extensions) || + !object(n.extensions) || + !finiteJson(n.extensions) || !Array.isArray(n.sockets) || !object(n.parameters) ) @@ -168,6 +208,48 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { ), ]), ); + if (n.typeId === "bloom_blur") + parameters.direction = + parameters.direction === "horizontal" ? [1, 0] : [0, 1]; + if (n.typeId === "frustum_cull") { + parameters.camera = parameters.cameraSelection; + delete parameters.cameraSelection; + } + if (n.typeId === "texture") { + const extent = + parameters.extentMode === "absolute" + ? { + kind: "absolute", + width: parameters.absoluteWidth, + height: parameters.absoluteHeight, + depthOrArrayLayers: parameters.depthOrArrayLayers, + } + : { + kind: "surface_relative", + width: { + numerator: parameters.relativeWidthNumerator, + denominator: parameters.relativeWidthDenominator, + }, + height: { + numerator: parameters.relativeHeightNumerator, + denominator: parameters.relativeHeightDenominator, + }, + depthOrArrayLayers: parameters.depthOrArrayLayers, + }; + const flat = structuredClone(parameters); + Object.keys(parameters).forEach((key) => delete parameters[key]); + Object.assign(parameters, { + residency: flat.residency, + texture: { + dimension: flat.dimension, + format: flat.format, + extent, + mipLevelCount: flat.mipLevelCount, + sampleCount: Number(flat.sampleCount), + viewFormats: flat.viewFormat === "none" ? [] : [flat.viewFormat], + }, + }); + } const expected = [ ...Object.keys(descriptor.inputs), ...Object.keys(descriptor.outputs), @@ -181,17 +263,49 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { socketDefinition = definition.sockets[s.key], direction = input ? "input" : "output", dataType = socketDefinition.type, - socketKeys = ["id", "key", "label", "direction", "dataType", "accepts", "maxIncomingLinks", ...(socketDefinition.value ? ["defaultValue"] : []), "visible"]; + socketKeys = [ + "id", + "key", + "label", + "direction", + "dataType", + "accepts", + "maxIncomingLinks", + ...(socketDefinition.value ? ["defaultValue"] : []), + "visible", + ]; if ( !exactKeys(s, socketKeys) || s.id !== `${n.id}:${s.key}` || s.label !== socketDefinition.title || s.direction !== direction || s.dataType !== dataType || - !Array.isArray(s.accepts) || s.accepts.length !== (direction === "input" ? socketTypes[dataType].acceptsFrom.length : 0) || - !s.accepts.every((v, i) => v === (direction === "input" ? socketTypes[dataType].acceptsFrom[i] : undefined)) || + !Array.isArray(s.accepts) || + s.accepts.length !== + (direction === "input" + ? socketTypes[dataType].acceptsFrom.length + : 0) || + !s.accepts.every( + (v, i) => + v === + (direction === "input" + ? socketTypes[dataType].acceptsFrom[i] + : undefined), + ) || (socketDefinition.value - ? !exactKeys(s.defaultValue, ["kind", "value"]) || !finiteJson(s.defaultValue.value) + ? (() => { + try { + parameterValue( + s.defaultValue, + socketDefinition.value, + n.id, + s.key, + ); + return false; + } catch { + return true; + } + })() : s.defaultValue !== undefined) || s.visible !== socketDefinition.visible || s.maxIncomingLinks !== socketDefinition.maxIncomingLinks @@ -206,10 +320,21 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { : descriptor.outputs[s.key].type, authoringType: s.dataType, maxIncomingLinks: s.maxIncomingLinks, + defaultValue: socketDefinition.value + ? structuredClone(s.defaultValue) + : undefined, }); } if (new Set(n.sockets.map((s) => s.key)).size !== expected.length) fail("AUTHORING_SOCKET_SET", { nodeId: n.id }); + if (n.typeId === "mesh_query") { + parameters.visibleDefault = sockets.get( + `${n.id}:isVisible`, + ).defaultValue.value; + parameters.frustumCulledDefault = sockets.get( + `${n.id}:isFrustumCulled`, + ).defaultValue.value; + } nodes.set(n.id, { ordinal, value: { @@ -230,8 +355,18 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { !object(link) || !identifier(link.id) || linkIds.has(link.id) || - !exactKeys(link, ["id", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted", "extensions"]) || - typeof link.muted !== "boolean" || !object(link.extensions) || !finiteJson(link.extensions) + !exactKeys(link, [ + "id", + "fromNodeId", + "fromSocketId", + "toNodeId", + "toSocketId", + "muted", + "extensions", + ]) || + typeof link.muted !== "boolean" || + !object(link.extensions) || + !finiteJson(link.extensions) ) fail("AUTHORING_LINK", { linkId: link?.id }); linkIds.add(link.id); @@ -244,21 +379,33 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { link.toNodeId !== to.node || from.direction !== "output" || to.direction !== "input" || - (!link.muted && (incoming.get(link.toSocketId) ?? 0) >= to.maxIncomingLinks) + (!link.muted && + (incoming.get(link.toSocketId) ?? 0) >= to.maxIncomingLinks) ) fail( - !link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity) + !link.muted && + (incoming.get(link.toSocketId) ?? 0) >= + (to?.maxIncomingLinks ?? Infinity) ? "AUTHORING_LINK_INCOMING" : "AUTHORING_LINK", - !link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity) + !link.muted && + (incoming.get(link.toSocketId) ?? 0) >= + (to?.maxIncomingLinks ?? Infinity) ? { socketId: link.toSocketId } : { linkId: link.id }, ); const accepted = descriptors[nodes.get(to.node).value.executor.key].inputs[to.key] .accepted.types; - const authoringAccepted = socketTypes[nodeDefinitions[nodes.get(to.node).value.executor.key].sockets[to.key].type].acceptsFrom; - if (!accepted.includes(from.semanticType) || !authoringAccepted.includes(from.authoringType)) + const authoringAccepted = + socketTypes[ + nodeDefinitions[nodes.get(to.node).value.executor.key].sockets[to.key] + .type + ].acceptsFrom; + if ( + !accepted.includes(from.semanticType) || + !authoringAccepted.includes(from.authoringType) + ) fail("AUTHORING_LINK_TYPE", { linkId: link.id }); const linkSource = { kind: "link", @@ -299,13 +446,92 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { const base = `nodes[${wireOrdinal}]`; const nodeSource = { kind: "node", nodeId: item.value.id }; paths[base] = nodeSource; - for (const field of ["id", "state", "executor", "executor.key", "executor.version"]) + for (const field of [ + "id", + "state", + "executor", + "executor.key", + "executor.version", + ]) paths[`${base}.${field}`] = nodeSource; paths[`${base}.parameters`] = nodeSource; - for (const key of Object.keys(item.value.parameters)) - mapValuePaths(paths, `${base}.parameters.${key}`, { kind: "parameter", nodeId: item.value.id, parameter: key }, item.value.parameters[key]); - for (const key of Object.keys(descriptors[item.value.executor.key].inputs)) { - const link = raw.links.find((x) => !x.muted && x.toNodeId === item.value.id && sockets.get(x.toSocketId)?.key === key); + const parameterSource = (parameter) => ({ + kind: "parameter", + nodeId: item.value.id, + parameter, + }); + if (item.value.executor.key === "texture") { + const root = `${base}.parameters`; + const texture = item.value.parameters.texture; + paths[`${root}.residency`] = parameterSource("residency"); + paths[`${root}.texture`] = nodeSource; + paths[`${root}.texture.dimension`] = parameterSource("dimension"); + paths[`${root}.texture.format`] = parameterSource("format"); + paths[`${root}.texture.extent`] = parameterSource("extentMode"); + paths[`${root}.texture.extent.kind`] = parameterSource("extentMode"); + paths[`${root}.texture.extent.depthOrArrayLayers`] = + parameterSource("depthOrArrayLayers"); + if (texture.extent.kind === "absolute") { + paths[`${root}.texture.extent.width`] = + parameterSource("absoluteWidth"); + paths[`${root}.texture.extent.height`] = + parameterSource("absoluteHeight"); + } else { + paths[`${root}.texture.extent.width`] = parameterSource("extentMode"); + paths[`${root}.texture.extent.width.numerator`] = parameterSource( + "relativeWidthNumerator", + ); + paths[`${root}.texture.extent.width.denominator`] = parameterSource( + "relativeWidthDenominator", + ); + paths[`${root}.texture.extent.height`] = + parameterSource("extentMode"); + paths[`${root}.texture.extent.height.numerator`] = parameterSource( + "relativeHeightNumerator", + ); + paths[`${root}.texture.extent.height.denominator`] = parameterSource( + "relativeHeightDenominator", + ); + } + paths[`${root}.texture.mipLevelCount`] = + parameterSource("mipLevelCount"); + paths[`${root}.texture.sampleCount`] = parameterSource("sampleCount"); + mapValuePaths( + paths, + `${root}.texture.viewFormats`, + parameterSource("viewFormat"), + texture.viewFormats, + ); + } else + for (const key of Object.keys(item.value.parameters)) + mapValuePaths( + paths, + `${base}.parameters.${key}`, + item.value.executor.key === "mesh_query" && key.endsWith("Default") + ? { + kind: "input", + nodeId: item.value.id, + input: + key === "visibleDefault" ? "isVisible" : "isFrustumCulled", + socketId: `${item.value.id}:${key === "visibleDefault" ? "isVisible" : "isFrustumCulled"}`, + unconnected: true, + } + : parameterSource( + item.value.executor.key === "frustum_cull" && key === "camera" + ? "cameraSelection" + : key, + ), + item.value.parameters[key], + ); + for (const key of Object.keys( + descriptors[item.value.executor.key].inputs, + )) { + const link = raw.links.find( + (x) => + !x.muted && + x.toNodeId === item.value.id && + sockets.get(x.toSocketId)?.key === key, + ); const source = linkSources.get(link?.id) ?? { kind: "input", nodeId: item.value.id, diff --git a/static/render-graph/add-node-menu.js b/static/render-graph/add-node-menu.js index 836a748..7d9217f 100644 --- a/static/render-graph/add-node-menu.js +++ b/static/render-graph/add-node-menu.js @@ -3,8 +3,9 @@ import { semanticCatalog } from "./catalog.js"; const GROUPS = Object.freeze([ ["source", "Source"], ["compute", "Compute"], + ["cpu_preparation", "CPU preparation"], ["render", "Render / post"], - ["present", "Present"], + ["frame", "Frame"], ]); const title = (typeId) => typeId.replaceAll("_", " "); diff --git a/static/render-graph/catalog.js b/static/render-graph/catalog.js index 03aaee9..d6735e4 100644 --- a/static/render-graph/catalog.js +++ b/static/render-graph/catalog.js @@ -1,7 +1,6 @@ export const GRAPH_ID = "authored_gpu_culling"; -export const CATALOG_VERSION = 2; +export const CATALOG_VERSION = 4; const exact = (type) => ({ kind: "exact", types: [type] }); -const oneOf = (...types) => ({ kind: "one_of", types }); const i = (type, required = true, authoringType) => ({ accepted: typeof type === "string" ? exact(type) : type, required, @@ -25,104 +24,91 @@ const texture = { }, }; export const semanticCatalog = Object.freeze({ - surface_target: { + mesh: { execution: "source", inputs: {}, - outputs: { surface: o("surface_target") }, - parameters: {}, - }, - texture_spec: { - execution: "source", - inputs: {}, - outputs: { spec: o("texture_spec") }, - parameters: structuredClone(texture), - }, - scene_table: { - execution: "source", - inputs: {}, - outputs: { scene: o("scene_table") }, - parameters: {}, - }, - local_aabb_buffer: { - execution: "source", - inputs: { scene: i("scene_table") }, - outputs: { localAabbs: o("local_aabb_buffer") }, - parameters: {}, - }, - camera_frustum: { - execution: "source", - inputs: {}, - outputs: { frustum: o("camera_frustum") }, - parameters: {}, - }, - visibility_flags: { - execution: "source", - inputs: { scene: i("scene_table") }, outputs: { - flags: { + mesh: o("mesh_data"), + localAabbs: o("local_aabb_buffer"), + isVisible: { ...o("boolean_flag_buffer"), authoringType: "visibility_flag_buffer", }, + pipelineIndices: o("pipeline_index_stream"), }, parameters: {}, }, + texture: { + execution: "source", + inputs: {}, + outputs: { texture: o("texture") }, + parameters: { + residency: "transient", + format: "rgba16_float", + dimension: "d2", + extentMode: "surface_relative", + absoluteWidth: 1, + absoluteHeight: 1, + relativeWidthNumerator: 1, + relativeWidthDenominator: 1, + relativeHeightNumerator: 1, + relativeHeightDenominator: 1, + depthOrArrayLayers: 1, + mipLevelCount: 1, + sampleCount: "1", + viewFormat: "none", + }, + }, frustum_cull: { execution: "compute", inputs: { - scene: i("scene_table"), + mesh: i("mesh_data"), localAabbs: i("local_aabb_buffer"), - frustum: i("camera_frustum"), }, outputs: { - flags: { + isFrustumCulled: { ...o("boolean_flag_buffer"), authoringType: "frustum_flag_buffer", }, }, - parameters: {}, + parameters: { cameraSelection: "active" }, }, mesh_query: { execution: "compute", inputs: { - scene: i("scene_table"), + mesh: i("mesh_data"), isVisible: i("boolean_flag_buffer", false, "visibility_flag_buffer"), isFrustumCulled: i("boolean_flag_buffer", false, "frustum_flag_buffer"), }, outputs: { draws: o("draw_stream") }, parameters: { - filters: [ - { flag: "isVisible", predicate: "required_true" }, - { flag: "isFrustumCulled", predicate: "required_false" }, - ], + visiblePredicate: "required_true", + frustumCulledPredicate: "required_false", }, }, - depth_stencil_config: { - execution: "source", - inputs: {}, - outputs: { config: o("depth_stencil_config") }, - parameters: { - depthCompare: "less_equal", - depthWriteEnabled: true, - clearDepth: 1, - }, + pipeline_registry: { + execution: "cpu_preparation", + inputs: { pipelineIndices: i("pipeline_index_stream") }, + outputs: { activation: o("pipeline_activation") }, + parameters: {}, }, - legacy_forward: { + pipeline: { execution: "render", inputs: { - scene: i("scene_table"), + mesh: i("mesh_data"), draws: i("draw_stream"), - colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), - depthTarget: i(oneOf("texture_spec", "texture")), - depthStencil: i("depth_stencil_config"), + activation: i("pipeline_activation"), + colorTarget: i("texture"), + depthTarget: i("texture"), }, outputs: { color: o("texture"), depth: o("texture") }, - parameters: { clearColor: [0.015, 0.02, 0.03, 1] }, + parameters: { pipeline: "gltf_standard", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1] }, }, fullscreen_copy: { execution: "render", inputs: { source: i("texture"), - colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), + colorTarget: i("texture"), }, outputs: { color: o("texture") }, parameters: {}, @@ -131,7 +117,7 @@ export const semanticCatalog = Object.freeze({ execution: "render", inputs: { source: i("texture"), - colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), + colorTarget: i("texture"), }, outputs: { color: o("texture") }, parameters: { exposure: 1 }, @@ -140,7 +126,7 @@ export const semanticCatalog = Object.freeze({ execution: "render", inputs: { source: i("texture"), - colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), + colorTarget: i("texture"), }, outputs: { color: o("texture") }, parameters: { threshold: 1, knee: 0.5 }, @@ -149,7 +135,7 @@ export const semanticCatalog = Object.freeze({ execution: "render", inputs: { source: i("texture"), - colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), + colorTarget: i("texture"), }, outputs: { color: o("texture") }, parameters: { direction: [1, 0], radius: 1 }, @@ -159,7 +145,7 @@ export const semanticCatalog = Object.freeze({ inputs: { source: i("texture"), bloom: i("texture"), - colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), + colorTarget: i("texture"), }, outputs: { color: o("texture") }, parameters: { intensity: 1 }, @@ -168,14 +154,14 @@ export const semanticCatalog = Object.freeze({ execution: "render", inputs: { source: i("texture"), - colorTarget: i(oneOf("surface_target", "texture_spec", "texture")), + colorTarget: i("texture"), }, outputs: { color: o("texture") }, parameters: { strength: 2 }, }, - present: { - execution: "present", - inputs: { surface: i("texture") }, + frame_out: { + execution: "frame", + inputs: { color: i("texture") }, outputs: {}, parameters: {}, }, @@ -193,15 +179,13 @@ const socketColors = [ ]; export const socketTypes = Object.fromEntries( [ - "surface_target", - "texture_spec", "texture", - "scene_table", + "mesh_data", "local_aabb_buffer", - "camera_frustum", "boolean_flag_buffer", + "pipeline_index_stream", "draw_stream", - "depth_stencil_config", + "pipeline_activation", "visibility_flag_buffer", "frustum_flag_buffer", ].map((type, index) => [ @@ -213,12 +197,6 @@ export const socketTypes = Object.fromEntries( }, ]), ); -socketTypes.surface_target.acceptsFrom = [ - "surface_target", - "texture_spec", - "texture", -]; -socketTypes.texture_spec.acceptsFrom = ["texture_spec", "texture"]; socketTypes.boolean_flag_buffer.acceptsFrom = [ "boolean_flag_buffer", "visibility_flag_buffer", @@ -259,33 +237,138 @@ export const theme = { export const styles = { source: { header: "#3977a8" }, compute: { header: "#725a9b" }, + cpu_preparation: { header: "#8a6d3b" }, render: { header: "#426b43" }, - present: { header: "#a75d37" }, + frame: { header: "#a75d37" }, }; -const socket = (title, direction, type) => ({ +const socket = (title, direction, type, value = null) => ({ title, direction, type, maxIncomingLinks: direction === "input" ? 1 : 0, visible: true, - value: null, - showValue: false, + value, + showValue: value !== null, }); -const parameterSchema = (value) => - typeof value === "number" - ? { type: "number", default: { kind: "number", value } } - : typeof value === "string" - ? { type: "string", default: { kind: "string", value } } - : typeof value === "boolean" - ? { type: "boolean", default: { kind: "boolean", value } } - : { type: "json", default: { kind: "json", value } }; +const tagged = (kind, value) => ({ kind, value: structuredClone(value) }); +const number = (value, minimum, maximum) => ({ + type: "number", + default: tagged("number", value), + minimum, + maximum, +}); +const enumeration = (value, values) => ({ + type: "string", + default: tagged("string", value), + enum: values, +}); +const string = (value) => ({ type: "string", default: tagged("string", value) }); +const boolean = (value) => ({ + type: "boolean", + default: tagged("boolean", value), +}); +const color = (value) => ({ + type: "color", + default: tagged("color", value), + minimum: 0, + maximum: 1, +}); +const json = (value) => ({ type: "json", default: tagged("json", value) }); +const parameterSchemas = { + texture: { + residency: enumeration("transient", ["transient", "persistent"]), + format: enumeration("rgba16_float", [ + "rgba8_unorm", + "rgba8_unorm_srgb", + "bgra8_unorm", + "bgra8_unorm_srgb", + "rgba16_float", + "r32_float", + "depth32_float", + ]), + dimension: enumeration("d2", ["d1", "d2", "d3"]), + extentMode: enumeration("surface_relative", [ + "surface_relative", + "absolute", + ]), + absoluteWidth: { ...number(1, 1, 0xffffffff), integer: true }, + absoluteHeight: { ...number(1, 1, 0xffffffff), integer: true }, + relativeWidthNumerator: { ...number(1, 1, 0xffffffff), integer: true }, + relativeWidthDenominator: { ...number(1, 1, 0xffffffff), integer: true }, + relativeHeightNumerator: { ...number(1, 1, 0xffffffff), integer: true }, + relativeHeightDenominator: { ...number(1, 1, 0xffffffff), integer: true }, + depthOrArrayLayers: { ...number(1, 1, 0xffffffff), integer: true }, + mipLevelCount: { ...number(1, 1, 0xffffffff), integer: true }, + sampleCount: enumeration("1", ["1", "4"]), + viewFormat: enumeration("none", [ + "none", + "rgba8_unorm", + "rgba8_unorm_srgb", + "bgra8_unorm", + "bgra8_unorm_srgb", + "rgba16_float", + "r32_float", + "depth32_float", + ]), + }, + mesh: {}, + frustum_cull: { cameraSelection: enumeration("active", ["active"]) }, + mesh_query: { + visiblePredicate: enumeration("required_true", [ + "any", + "required_true", + "required_false", + ]), + frustumCulledPredicate: enumeration("required_false", [ + "any", + "required_true", + "required_false", + ]), + }, + pipeline_registry: {}, + pipeline: { + pipeline: string("gltf_standard"), + depthCompare: enumeration("less_equal", [ + "never", + "less", + "equal", + "less_equal", + "greater", + "not_equal", + "greater_equal", + "always", + ]), + depthWriteEnabled: boolean(true), + clearDepth: number(1, 0, 1), + clearColor: color([0.015, 0.02, 0.03, 1]), + }, + fullscreen_copy: {}, + tone_map: { exposure: number(1, 0, 32) }, + bloom_extract: { threshold: number(1, 0, 64), knee: number(0.5, 0, 1) }, + bloom_blur: { + direction: enumeration("horizontal", ["horizontal", "vertical"]), + radius: number(1, 1, 16), + }, + bloom_composite: { intensity: number(1, 0, 16) }, + luminance_edge: { strength: number(2, 0, 16) }, + frame_out: {}, +}; export const nodeDefinitions = Object.fromEntries( Object.entries(semanticCatalog).map(([key, c]) => { const sockets = { ...Object.fromEntries( Object.entries(c.inputs).map(([n, v]) => [ n, - socket(n, "input", v.authoringType ?? v.accepted.types[0]), + socket( + n, + "input", + v.authoringType ?? v.accepted.types[0], + key === "mesh_query" && n === "isVisible" + ? boolean(true) + : key === "mesh_query" && n === "isFrustumCulled" + ? boolean(false) + : null, + ), ]), ), ...Object.fromEntries( @@ -295,12 +378,15 @@ export const nodeDefinitions = Object.fromEntries( ]), ), }, - parameters = Object.fromEntries( - Object.entries(c.parameters).map(([name, value]) => [ - name, - parameterSchema(value), - ]), - ); + parameters = parameterSchemas[key]; + if ( + !parameters || + Object.keys(parameters).length !== Object.keys(c.parameters).length || + !Object.keys(c.parameters).every((name) => + Object.hasOwn(parameters, name), + ) + ) + throw new Error(`parameter schema mismatch for ${key}`); return [ key, { @@ -314,6 +400,9 @@ export const nodeDefinitions = Object.fromEntries( ...Object.keys(parameters).map((parameter) => ({ kind: "parameter", parameter, + ...(key === "frustum_cull" && parameter === "cameraSelection" + ? { title: "Camera" } + : {}), })), ...Object.keys(sockets).map((socket) => ({ kind: "socket", socket })), ], diff --git a/static/render-graph/fxnode-editor.js b/static/render-graph/fxnode-editor.js index db454b0..744bb1a 100644 --- a/static/render-graph/fxnode-editor.js +++ b/static/render-graph/fxnode-editor.js @@ -5,19 +5,16 @@ import { createAddNodeMenu } from "./add-node-menu.js"; import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.js"; const spec = [ - ["surface", "surface_target", { x: 40, y: 40 }], - ["hdr", "texture_spec", { x: 40, y: 170 }], - ["depth", "texture_spec", { x: 40, y: 300 }], - ["scene", "scene_table", { x: 40, y: 470 }], - ["aabbs", "local_aabb_buffer", { x: 290, y: 430 }], - ["frustum", "camera_frustum", { x: 290, y: 590 }], - ["visible", "visibility_flags", { x: 290, y: 300 }], + ["hdr", "texture", { x: 40, y: 170 }], + ["depth", "texture", { x: 40, y: 300 }], + ["mesh", "mesh", { x: 40, y: 470 }], ["cull", "frustum_cull", { x: 540, y: 480 }], ["query", "mesh_query", { x: 790, y: 330 }], - ["depth_config", "depth_stencil_config", { x: 790, y: 620 }], - ["forward", "legacy_forward", { x: 1040, y: 290 }], - ["copy", "fullscreen_copy", { x: 1300, y: 250 }], - ["present", "present", { x: 1540, y: 250 }], + ["registry", "pipeline_registry", { x: 790, y: 620 }], + ["ground", "pipeline", { x: 1040, y: 290 }], + ["pbr", "pipeline", { x: 1300, y: 290 }], + ["pbr_double", "pipeline", { x: 1560, y: 290 }], + ["frame_out", "frame_out", { x: 1820, y: 250 }], ]; async function seed(root) { await root.setState({ @@ -30,22 +27,24 @@ async function seed(root) { for (const [nodeId, nodeType, position] of spec) await root.dispatch({ type: "node.add", nodeId, nodeType, position }); const links = [ - ["scene", "scene", "aabbs", "scene"], - ["scene", "scene", "visible", "scene"], - ["scene", "scene", "cull", "scene"], - ["aabbs", "localAabbs", "cull", "localAabbs"], - ["frustum", "frustum", "cull", "frustum"], - ["scene", "scene", "query", "scene"], - ["visible", "flags", "query", "isVisible"], - ["cull", "flags", "query", "isFrustumCulled"], - ["scene", "scene", "forward", "scene"], - ["query", "draws", "forward", "draws"], - ["hdr", "spec", "forward", "colorTarget"], - ["depth", "spec", "forward", "depthTarget"], - ["depth_config", "config", "forward", "depthStencil"], - ["forward", "color", "copy", "source"], - ["surface", "surface", "copy", "colorTarget"], - ["copy", "color", "present", "surface"], + ["mesh", "mesh", "cull", "mesh"], + ["mesh", "localAabbs", "cull", "localAabbs"], + ["mesh", "mesh", "query", "mesh"], + ["mesh", "isVisible", "query", "isVisible"], + ["cull", "isFrustumCulled", "query", "isFrustumCulled"], + ["mesh", "pipelineIndices", "registry", "pipelineIndices"], + ...["ground", "pbr", "pbr_double"].flatMap((pipeline) => [ + ["mesh", "mesh", pipeline, "mesh"], + ["query", "draws", pipeline, "draws"], + ["registry", "activation", pipeline, "activation"], + ]), + ["hdr", "texture", "ground", "colorTarget"], + ["depth", "texture", "ground", "depthTarget"], + ["ground", "color", "pbr", "colorTarget"], + ["ground", "depth", "pbr", "depthTarget"], + ["pbr", "color", "pbr_double", "colorTarget"], + ["pbr", "depth", "pbr_double", "depthTarget"], + ["pbr_double", "color", "frame_out", "color"], ]; for (const [a, as, b, bs] of links) { const id = `${a}_${as}_${b}_${bs}`; @@ -64,34 +63,44 @@ async function seed(root) { } const authored = await root.getState(), depth = authored.nodes.find((node) => node.id === "depth"); - depth.parameters.texture = { - kind: "json", - value: { - dimension: "d2", - format: "depth32_float", - extent: { - kind: "surface_relative", - width: { numerator: 1, denominator: 1 }, - height: { numerator: 1, denominator: 1 }, - depthOrArrayLayers: 1, - }, - mipLevelCount: 1, - sampleCount: 1, - viewFormats: [], - }, - }; + depth.parameters.format = { kind: "string", value: "depth32_float" }; + for (const [id, name] of [["ground", "ground_plane"], ["pbr", "gltf_standard"], ["pbr_double", "gltf_standard_double_sided"]]) + authored.nodes.find((node) => node.id === id).parameters.pipeline = { kind: "string", value: name }; await root.setState(authored); } export async function createRenderGraphEditor(canvas) { const allocateId = createNodeIdAllocator(); - let root, view, menu, destroying, dead = false; - const requestAddNode = Object.assign(async (request, point, isCurrent = () => true) => { - let typeId; - try { typeId = await menu?.open(point); } catch (error) { if (!dead && isCurrent()) console.error(error); return; } - if (dead || !isCurrent() || !root || !view) return; - const alive = () => !dead && isCurrent(); - try { await spawnRequestedNode(root, view, request, typeId, allocateId, alive); } catch (error) { if (!dead) console.error(error); } - }, { close: () => menu?.close() }); + let root, + view, + menu, + destroying, + dead = false; + const requestAddNode = Object.assign( + async (request, point, isCurrent = () => true) => { + let typeId; + try { + typeId = await menu?.open(point); + } catch (error) { + if (!dead && isCurrent()) console.error(error); + return; + } + if (dead || !isCurrent() || !root || !view) return; + const alive = () => !dead && isCurrent(); + try { + await spawnRequestedNode( + root, + view, + request, + typeId, + allocateId, + alive, + ); + } catch (error) { + if (!dead) console.error(error); + } + }, + { close: () => menu?.close() }, + ); const host = prepareBrowserHost(canvas, { requestAddNode }); const destroy = () => (destroying ??= (async () => { diff --git a/static/render-graph/presets.js b/static/render-graph/presets.js index b1b7f57..f5c0cbb 100644 --- a/static/render-graph/presets.js +++ b/static/render-graph/presets.js @@ -1,278 +1,76 @@ const input = (node, socket) => ({ node, socket }); const node = (id, key, parameters = {}, inputs = {}) => ({ - id, - state: "enabled", - executor: { key, version: 1 }, - parameters, - inputs, + id, state: "enabled", executor: { key, version: 1 }, parameters, inputs, }); -const texture = (format) => ({ +const texture = (format, scale = 1) => ({ texture: { - dimension: "d2", - format, - extent: { - kind: "surface_relative", - width: { numerator: 1, denominator: 1 }, - height: { numerator: 1, denominator: 1 }, - depthOrArrayLayers: 1, - }, - mipLevelCount: 1, - sampleCount: 1, - viewFormats: [], + dimension: "d2", format, + extent: { kind: "surface_relative", width: { numerator: 1, denominator: scale }, height: { numerator: 1, denominator: scale }, depthOrArrayLayers: 1 }, + mipLevelCount: 1, sampleCount: 1, viewFormats: [], }, 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") }), - ], -}); +const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1]) => [ + node("hdr", "texture", texture("rgba16_float")), + node("depth", "texture", texture("depth32_float")), + node("mesh", "mesh"), + node("query", "mesh_query", { visiblePredicate: "required_true", visibleDefault: true, frustumCulledPredicate: "any", frustumCulledDefault: false }, { mesh: input("mesh", "mesh"), isVisible: input("mesh", "isVisible") }), + node("registry", "pipeline_registry", {}, { pipelineIndices: input("mesh", "pipelineIndices") }), + node("ground", "pipeline", { pipeline: "ground_plane", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor }, { mesh: input("mesh", "mesh"), draws: input("query", "draws"), activation: input("registry", "activation"), colorTarget: input(colorTarget, "texture"), depthTarget: input("depth", "texture") }), + node("pbr", "pipeline", { pipeline: "gltf_standard", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor }, { mesh: input("mesh", "mesh"), draws: input("query", "draws"), activation: input("registry", "activation"), colorTarget: input("ground", "color"), depthTarget: input("ground", "depth") }), + node("pbr_double", "pipeline", { pipeline: "gltf_standard_double_sided", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor }, { mesh: input("mesh", "mesh"), draws: input("query", "draws"), activation: input("registry", "activation"), colorTarget: input("pbr", "color"), depthTarget: input("pbr", "depth") }), +]; +const graph = (graphId, nodes) => Object.freeze({ schemaVersion: 2, graphId, revision: 1, nodes }); +const direct = (graphId, clearColor) => graph(graphId, [ + node("ldr", "texture", texture("rgba8_unorm")), + ...scene("ldr", clearColor).filter((item) => item.id !== "hdr"), + node("frame_out", "frame_out", {}, { color: input("pbr_double", "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", - revision: 1, - nodes: [ - node("surface", "surface_target"), - node("hdr", "texture_spec", texture("rgba16_float")), - node("depth", "texture_spec", texture("depth32_float")), - node("scene", "scene_table"), - node("visible", "visibility_flags", {}, { scene: input("scene", "scene") }), - node( - "query", - "mesh_query", - { - filters: [ - { flag: "isVisible", predicate: "required_true" }, - { flag: "isFrustumCulled", predicate: "any" }, - ], - }, - { scene: input("scene", "scene"), isVisible: input("visible", "flags") }, - ), - node("depth_config", "depth_stencil_config", { - depthCompare: "less_equal", - depthWriteEnabled: true, - clearDepth: 1, - }), - node( - "forward", - "legacy_forward", - { clearColor: [0.015, 0.02, 0.03, 1] }, - { - scene: input("scene", "scene"), - draws: input("query", "draws"), - colorTarget: input("hdr", "spec"), - depthTarget: input("depth", "spec"), - depthStencil: input("depth_config", "config"), - }, - ), - node( - "copy", - "fullscreen_copy", - {}, - { - source: input("forward", "color"), - colorTarget: input("surface", "surface"), - }, - ), - node("present", "present", {}, { surface: input("copy", "color") }), - ], -}); -export const culling = Object.freeze((() => { - const graph = structuredClone(hdr); - graph.graphId = "preset_gpu_culling"; - graph.nodes.splice( - 5, - 0, - node("aabbs", "local_aabb_buffer", {}, { scene: input("scene", "scene") }), - node("frustum", "camera_frustum"), - node("cull", "frustum_cull", {}, { - scene: input("scene", "scene"), - localAabbs: input("aabbs", "localAabbs"), - frustum: input("frustum", "frustum"), - }), - ); - const query = graph.nodes.find((x) => x.id === "query"); - query.parameters.filters[1].predicate = "required_false"; - query.inputs.isFrustumCulled = input("cull", "flags"); - return graph; +export const hdr = graph("preset_hdr_fullscreen", [ + ...scene("hdr"), + node("frame_out", "frame_out", {}, { color: input("pbr_double", "color") }), +]); +export const culling = graph("preset_gpu_culling", (() => { + const nodes = structuredClone(hdr.nodes); + nodes.splice(3, 0, node("cull", "frustum_cull", { camera: "active" }, { mesh: input("mesh", "mesh"), localAabbs: input("mesh", "localAabbs") })); + const query = nodes.find((item) => item.id === "query"); + query.parameters.frustumCulledPredicate = "required_false"; + query.inputs.isFrustumCulled = input("cull", "isFrustumCulled"); + return nodes; })()); const postPreset = (graphId, kind) => { - const nodes = hdr.nodes.slice(0, 8).map((x) => structuredClone(x)); - if (kind === "tone") - nodes.push( - node( - "tone", - "tone_map", - { exposure: 1 }, - { - source: input("forward", "color"), - colorTarget: input("surface", "surface"), - }, - ), - ); + const nodes = [node("ldr", "texture", texture("rgba8_unorm")), ...scene("hdr")]; + let source = "pbr_double"; if (kind === "edges") { - nodes.splice(1, 0, node("edge_hdr", "texture_spec", texture("rgba16_float"))); - nodes.push( - node( - "edges", - "luminance_edge", - { strength: 2 }, - { - source: input("forward", "color"), - colorTarget: input("edge_hdr", "spec"), - }, - ), - ); - nodes.push( - node( - "tone", - "tone_map", - { exposure: 1 }, - { - source: input("edges", "color"), - colorTarget: input("surface", "surface"), - }, - ), - ); + nodes.splice(1, 0, node("edge_hdr", "texture", texture("rgba16_float"))); + nodes.push(node("edges", "luminance_edge", { strength: 2 }, { source: input(source, "color"), colorTarget: input("edge_hdr", "texture") })); + source = "edges"; } if (kind === "bloom" || kind === "combined") { - const half = { - texture: { - ...texture("rgba16_float").texture, - extent: { - kind: "surface_relative", - width: { numerator: 1, denominator: 2 }, - height: { numerator: 1, denominator: 2 }, - depthOrArrayLayers: 1, - }, - }, - residency: "transient", - }; - nodes.splice( - 1, - 0, - node("half_a", "texture_spec", structuredClone(half)), - node("half_b", "texture_spec", structuredClone(half)), - node("half_c", "texture_spec", structuredClone(half)), - node("composite_hdr", "texture_spec", texture("rgba16_float")), - ); + nodes.splice(1, 0, + node("half_a", "texture", texture("rgba16_float", 2)), node("half_b", "texture", texture("rgba16_float", 2)), + node("half_c", "texture", texture("rgba16_float", 2)), node("composite_hdr", "texture", texture("rgba16_float"))); nodes.push( - node( - "extract", - "bloom_extract", - { threshold: 1, knee: 0.5 }, - { - source: input("forward", "color"), - colorTarget: input("half_a", "spec"), - }, - ), + node("extract", "bloom_extract", { threshold: 1, knee: 0.5 }, { source: input("pbr_double", "color"), colorTarget: input("half_a", "texture") }), + node("blur_h", "bloom_blur", { direction: [1, 0], radius: 1 }, { source: input("extract", "color"), colorTarget: input("half_b", "texture") }), + node("blur_v", "bloom_blur", { direction: [0, 1], radius: 1 }, { source: input("blur_h", "color"), colorTarget: input("half_c", "texture") }), + node("composite", "bloom_composite", { intensity: 0.8 }, { source: input("pbr_double", "color"), bloom: input("blur_v", "color"), colorTarget: input("composite_hdr", "texture") }), ); - nodes.push( - node( - "blur_h", - "bloom_blur", - { direction: [1, 0], radius: 1 }, - { - source: input("extract", "color"), - colorTarget: input("half_b", "spec"), - }, - ), - ); - nodes.push( - node( - "blur_v", - "bloom_blur", - { direction: [0, 1], radius: 1 }, - { - source: input("blur_h", "color"), - colorTarget: input("half_c", "spec"), - }, - ), - ); - nodes.push( - node( - "composite", - "bloom_composite", - { intensity: 0.8 }, - { - source: input("forward", "color"), - bloom: input("blur_v", "color"), - colorTarget: input("composite_hdr", "spec"), - }, - ), - ); - let toneSource = "composite"; + source = "composite"; if (kind === "combined") { - nodes.splice(1, 0, node("edge_hdr", "texture_spec", texture("rgba16_float"))); - nodes.push( - node( - "edges", - "luminance_edge", - { strength: 2 }, - { - source: input("composite", "color"), - colorTarget: input("edge_hdr", "spec"), - }, - ), - ); - toneSource = "edges"; + nodes.splice(1, 0, node("edge_hdr", "texture", texture("rgba16_float"))); + nodes.push(node("edges", "luminance_edge", { strength: 2 }, { source: input(source, "color"), colorTarget: input("edge_hdr", "texture") })); + source = "edges"; } - nodes.push( - node( - "tone", - "tone_map", - { exposure: 1 }, - { - source: input(toneSource, "color"), - colorTarget: input("surface", "surface"), - }, - ), - ); } - const last = nodes.at(-1); - nodes.push( - node("present", "present", {}, { surface: input(last.id, "color") }), - ); - return Object.freeze({ schemaVersion: 2, graphId, revision: 1, nodes }); + nodes.push(node("tone", "tone_map", { exposure: 1 }, { source: input(source, "color"), colorTarget: input("ldr", "texture") })); + nodes.push(node("frame_out", "frame_out", {}, { color: input("tone", "color") })); + return graph(graphId, nodes); }; -export const tone = postPreset("preset_tone", "tone"), - edges = postPreset("preset_edges", "edges"), - bloom = postPreset("preset_bloom", "bloom"), - combined = postPreset("preset_combined", "combined"); -export const renderGraphPresets = Object.freeze({ - midnight, - ember, - hdr, - culling, - tone, - edges, - bloom, - combined, -}); +export const tone = postPreset("preset_tone", "tone"); +export const edges = postPreset("preset_edges", "edges"); +export const bloom = postPreset("preset_bloom", "bloom"); +export const combined = postPreset("preset_combined", "combined"); +export const renderGraphPresets = Object.freeze({ midnight, ember, hdr, culling, tone, edges, bloom, combined }); diff --git a/tests/add-node-menu.test.js b/tests/add-node-menu.test.js index 36550ab..c46645c 100644 --- a/tests/add-node-menu.test.js +++ b/tests/add-node-menu.test.js @@ -1,13 +1,26 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { addNodeItems, moveAddNodeSelection, searchAddNodeItems } from "../static/render-graph/add-node-menu.js"; -import { createNodeIdAllocator, spawnRequestedNode } from "../static/render-graph/node-spawn.js"; +import { + addNodeItems, + moveAddNodeSelection, + searchAddNodeItems, +} from "../static/render-graph/add-node-menu.js"; +import { + createNodeIdAllocator, + spawnRequestedNode, +} from "../static/render-graph/node-spawn.js"; -test("add-node model contains all 17 catalog types in application groups", () => { - assert.equal(addNodeItems.length, 17); - assert.deepEqual([...new Set(addNodeItems.map((item) => item.group))], ["Source", "Compute", "Render / post", "Present"]); - assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 17); - assert.deepEqual(searchAddNodeItems("tone render").map((item) => item.typeId), ["tone_map"]); +test("add-node model contains all 13 catalog types in application groups", () => { + assert.equal(addNodeItems.length, 13); + assert.deepEqual( + [...new Set(addNodeItems.map((item) => item.group))], + ["Source", "Compute", "CPU preparation", "Render / post", "Frame"], + ); + assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 13); + assert.deepEqual( + searchAddNodeItems("tone render").map((item) => item.typeId), + ["tone_map"], + ); assert.deepEqual(searchAddNodeItems("no such node"), []); }); @@ -21,13 +34,19 @@ test("allocator avoids existing and session-reserved IDs and is bounded", () => const values = ["a-a", "a-a", "b-b"]; const allocate = createNodeIdAllocator(() => values.shift()); assert.equal(allocate(["node_aa"]), "node_bb"); - assert.throws(() => createNodeIdAllocator(() => "bad id")([]), /Unable to allocate/); + assert.throws( + () => createNodeIdAllocator(() => "bad id")([]), + /Unable to allocate/, + ); }); -test("all 17 types spawn with exact position, current version and generated ID", async () => { - let revision = 5, expectedType; +test("all 13 types spawn with exact position, current version and generated ID", async () => { + let revision = 5, + expectedType; const request = { compositionRevision: 5, viewPosition: { x: 12.25, y: -4 } }; - const root = { getState: async () => ({ version: 91, nodes: [{ id: "existing" }] }) }; + const root = { + getState: async () => ({ version: 91, nodes: [{ id: "existing" }] }), + }; const view = { getHostSnapshot: () => ({ compositionRevision: revision }), addNode: async (params, options) => { @@ -38,41 +57,89 @@ test("all 17 types spawn with exact position, current version and generated ID", }, }; let id = 0; - const allocate = createNodeIdAllocator(() => `00000000-0000-0000-0000-${String(++id).padStart(12, "0")}`); + const allocate = createNodeIdAllocator( + () => `00000000-0000-0000-0000-${String(++id).padStart(12, "0")}`, + ); for (const item of addNodeItems) { expectedType = item.typeId; - assert.equal(await spawnRequestedNode(root, view, request, item.typeId, allocate), true); + assert.equal( + await spawnRequestedNode(root, view, request, item.typeId, allocate), + true, + ); } revision = 6; - assert.equal(await spawnRequestedNode(root, view, request, "tone_map", allocate), false); + assert.equal( + await spawnRequestedNode(root, view, request, "tone_map", allocate), + false, + ); }); test("spawn rechecks composition after getState and propagates add errors", async () => { let revision = 2; const request = { compositionRevision: 2, viewPosition: { x: 1, y: 2 } }; - const root = { getState: async () => { revision++; return { version: 3, nodes: [] }; } }; + const root = { + getState: async () => { + revision++; + return { version: 3, nodes: [] }; + }, + }; const allocate = createNodeIdAllocator(() => "a"); - const view = { getHostSnapshot: () => ({ compositionRevision: revision }), addNode: async () => { throw Error("must not add"); } }; - assert.equal(await spawnRequestedNode(root, view, request, "tone_map", allocate), false); + const view = { + getHostSnapshot: () => ({ compositionRevision: revision }), + addNode: async () => { + throw Error("must not add"); + }, + }; + assert.equal( + await spawnRequestedNode(root, view, request, "tone_map", allocate), + false, + ); revision = 2; root.getState = async () => ({ version: 3, nodes: [] }); - await assert.rejects(spawnRequestedNode(root, view, request, "tone_map", allocate), /must not add/); + await assert.rejects( + spawnRequestedNode(root, view, request, "tone_map", allocate), + /must not add/, + ); }); test("spawn cancels when a pending getState becomes mutated or dead", async () => { const request = { compositionRevision: 2, viewPosition: { x: 1, y: 2 } }; - let resolveState, revision = 2, alive = true, adds = 0; - const root = { getState: () => new Promise((resolve) => { resolveState = resolve; }) }; + let resolveState, + revision = 2, + alive = true, + adds = 0; + const root = { + getState: () => + new Promise((resolve) => { + resolveState = resolve; + }), + }; const view = { getHostSnapshot: () => ({ compositionRevision: revision }), - addNode: async () => { adds++; }, + addNode: async () => { + adds++; + }, }; - const pendingMutation = spawnRequestedNode(root, view, request, "tone_map", () => "node_a", () => alive); + const pendingMutation = spawnRequestedNode( + root, + view, + request, + "tone_map", + () => "node_a", + () => alive, + ); revision++; resolveState({ version: 1, nodes: [] }); assert.equal(await pendingMutation, false); revision = 2; - const pendingDestroy = spawnRequestedNode(root, view, request, "tone_map", () => "node_b", () => alive); + const pendingDestroy = spawnRequestedNode( + root, + view, + request, + "tone_map", + () => "node_b", + () => alive, + ); alive = false; resolveState({ version: 1, nodes: [] }); assert.equal(await pendingDestroy, false); @@ -80,14 +147,27 @@ test("spawn cancels when a pending getState becomes mutated or dead", async () = }); test("spawn has a final liveness guard after ID allocation", async () => { - let alive = true, adds = 0; + let alive = true, + adds = 0; const request = { compositionRevision: 1, viewPosition: { x: 0, y: 0 } }; const root = { getState: async () => ({ version: 1, nodes: [] }) }; - const view = { getHostSnapshot: () => ({ compositionRevision: 1 }), addNode: async () => { adds++; } }; - const result = await spawnRequestedNode(root, view, request, "tone_map", () => { - alive = false; - return "node_reserved"; - }, () => alive); + const view = { + getHostSnapshot: () => ({ compositionRevision: 1 }), + addNode: async () => { + adds++; + }, + }; + const result = await spawnRequestedNode( + root, + view, + request, + "tone_map", + () => { + alive = false; + return "node_reserved"; + }, + () => alive, + ); assert.equal(result, false); assert.equal(adds, 0); }); @@ -95,19 +175,59 @@ test("spawn has a final liveness guard after ID allocation", async () => { test("spawn suppresses teardown RPC rejections but propagates genuine live add errors", async () => { const request = { compositionRevision: 1, viewPosition: { x: 0, y: 0 } }; let alive = true; - const view = { getHostSnapshot: () => ({ compositionRevision: 1 }), addNode: async () => {} }; - const root = { getState: async () => { alive = false; throw Error("detached state"); } }; - assert.equal(await spawnRequestedNode(root, view, request, "tone_map", () => "node_a", () => alive), false); + const view = { + getHostSnapshot: () => ({ compositionRevision: 1 }), + addNode: async () => {}, + }; + const root = { + getState: async () => { + alive = false; + throw Error("detached state"); + }, + }; + assert.equal( + await spawnRequestedNode( + root, + view, + request, + "tone_map", + () => "node_a", + () => alive, + ), + false, + ); alive = true; root.getState = async () => ({ version: 1, nodes: [] }); - view.addNode = async () => { alive = false; throw Error("detached add"); }; - assert.equal(await spawnRequestedNode(root, view, request, "tone_map", () => "node_b", () => alive), false); + view.addNode = async () => { + alive = false; + throw Error("detached add"); + }; + assert.equal( + await spawnRequestedNode( + root, + view, + request, + "tone_map", + () => "node_b", + () => alive, + ), + false, + ); alive = true; - view.addNode = async () => { throw Error("live add failure"); }; + view.addNode = async () => { + throw Error("live add failure"); + }; await assert.rejects( - spawnRequestedNode(root, view, request, "tone_map", () => "node_c", () => alive), + spawnRequestedNode( + root, + view, + request, + "tone_map", + () => "node_c", + () => alive, + ), /live add failure/, ); }); diff --git a/tests/demo-loadouts.test.js b/tests/demo-loadouts.test.js index c18c347..a70be27 100644 --- a/tests/demo-loadouts.test.js +++ b/tests/demo-loadouts.test.js @@ -7,6 +7,6 @@ test("procedural cube and sphere have complete indexed vertex streams",()=>{cons test("every cube triangle has counter-clockwise outward winding",()=>{const g=createCubeGeometry();for(let i=0;ig.positions.slice(id*3,id*3+3)),a=p[1].map((v,j)=>v-p[0][j]),b=p[2].map((v,j)=>v-p[0][j]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],normal=g.normals.slice(ids[0]*3,ids[0]*3+3);assert.ok(cross.reduce((sum,v,j)=>sum+v*normal[j],0)>0)}}); test("loadouts return fresh deterministic GLBs with one mesh and nine nodes",async()=>{for(const name of ["cubes","spheres"]){const a=await loadDemoLoadout(name),b=await loadDemoLoadout(name);assert.notStrictEqual(a,b);assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.meshes.length,1);assert.equal(json.nodes.length,9);assert.ok(json.nodes.every(n=>n.mesh===0));assert.deepEqual(json.meshes[0].primitives[0].attributes,{POSITION:0,NORMAL:1,TEXCOORD_0:2});assert.equal(json.accessors[3].componentType,5125);}}); test("Phase 6 gallery is deterministic and covers core PBR shader semantics",async()=>{const a=createMaterialGalleryGlb(),b=await loadDemoLoadout("materials");assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.asset.generator,"yawn-phase6-pbr-gallery");assert.deepEqual(json.extensionsUsed,["KHR_materials_ior"]);assert.equal(json.materials.length,16);assert.equal(json.nodes.length,16);assert.equal(json.images.length,3);assert.ok(json.images.every(image=>image.mimeType==="image/png"&&image.bufferView!==undefined));assert.ok(json.meshes.every(mesh=>JSON.stringify(mesh.primitives[0].attributes)===JSON.stringify({POSITION:0,NORMAL:1,TEXCOORD_0:2})));assert.deepEqual(json.materials.slice(0,4).map(x=>x.pbrMetallicRoughness.roughnessFactor),[.08,.3,.6,1]);assert.ok(json.materials.slice(0,4).every(x=>x.pbrMetallicRoughness.metallicFactor===0));assert.ok(json.materials.slice(4,8).every(x=>x.pbrMetallicRoughness.metallicFactor===1));assert.deepEqual(json.materials.slice(8,11).map(x=>x.extensions.KHR_materials_ior.ior),[1,1.5,2]);assert.equal(json.materials[11].normalTexture.index,2);assert.equal(json.materials[12].occlusionTexture.index,1);assert.equal(json.materials[13].emissiveTexture.index,0);assert.equal(json.materials[14].alphaMode,"MASK");assert.equal(json.materials[15].doubleSided,true);assert.ok(json.nodes[15].scale[0]<0);assert.equal(loadouts.materials.label,"Phase 6 deterministic PBR gallery");}); -test("loadout dropdown preserves legacy scenes and exposes the material gallery",async()=>{const html=await (await import("node:fs/promises")).readFile(new URL("../static/index.html",import.meta.url),"utf8");for(const id of ["cubes","spheres","materials","manor","sponza"])assert.match(html,new RegExp(`