diff --git a/renderer/src/render_graph/compiler.rs b/renderer/src/render_graph/compiler.rs index c618a86..d5aa24d 100644 --- a/renderer/src/render_graph/compiler.rs +++ b/renderer/src/render_graph/compiler.rs @@ -136,6 +136,14 @@ fn color(value: [f32; 4], min: f32, max: f32, base: &str) -> Result<[f32; 3], Gr #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] struct OutputKey(usize, u16); +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +enum TransitionTargetKey { + Authored(OutputKey), + CompilerDefaultInput { + owner_node: usize, + input_ordinal: u16, + }, +} #[derive(Clone, Copy)] struct BoundInput { producer: OutputKey, @@ -155,7 +163,7 @@ struct DependencyEdge { struct TextureTransition { writer_node: usize, input_socket: &'static str, - target: OutputKey, + target: TransitionTargetKey, output: OutputKey, } @@ -164,11 +172,74 @@ enum ResolvedTransition { Resolved { family: u32, version: u32, - target: OutputKey, + target: TransitionTargetKey, }, Cyclic, } +#[derive(Clone)] +struct DefaultDraft { + key: TransitionTargetKey, + resource: u32, + family: u32, + owner_node: usize, + input_ordinal: u16, + socket: &'static str, + role: CompilerTextureRole, + format: TextureFormat, + descriptor: DraftDescriptor, +} + +#[derive(Clone)] +enum DraftDescriptor { + Deferred, + Known(NormalizedTextureDescriptor), +} + +enum SampledDescriptor<'a> { + Known(&'a NormalizedTextureDescriptor), + Deferred, + Unproduced, +} + +fn known_family_descriptor<'a>( + family: u32, + authored_families: &'a [TextureFamily], + drafts: &'a [DefaultDraft], +) -> Option<&'a NormalizedTextureDescriptor> { + if let Some(family) = authored_families.get(family as usize) { + return Some(family_descriptor(family)); + } + let draft = drafts.get(family as usize - authored_families.len())?; + match &draft.descriptor { + DraftDescriptor::Known(descriptor) => Some(descriptor), + DraftDescriptor::Deferred => None, + } +} + +fn sampled_descriptor<'a>( + key: OutputKey, + version_of: &HashMap, + resolved: &HashMap, + authored_families: &'a [TextureFamily], + drafts: &'a [DefaultDraft], +) -> Result, GraphError> { + if let Some(&(family, _, _)) = version_of.get(&key) { + return Ok(known_family_descriptor(family, authored_families, drafts) + .map(SampledDescriptor::Known) + .unwrap_or(SampledDescriptor::Deferred)); + } + match resolved.get(&key) { + Some(ResolvedTransition::Cyclic) => Ok(SampledDescriptor::Deferred), + Some(ResolvedTransition::Resolved { .. }) => Err(error( + "GRAPH_RESOURCE_VERSION_INVALID", + "resolved texture has no version", + "resources", + )), + None => Ok(SampledDescriptor::Unproduced), + } +} + fn reaches( from: usize, to: usize, @@ -1037,7 +1108,12 @@ pub fn compile(graph: Graph) -> Result { } } } - let all_outputs: usize = contracts.iter().map(|c| c.outputs.len()).sum(); + let all_outputs: usize = contracts + .iter() + .flat_map(|contract| contract.outputs) + .filter(|output| !output.semantic_type.is_virtual()) + .count(); + let authored_materialized_output_count = output_ids.len(); // Establish families and transitions without relying on a schedule. let mut families = Vec::new(); @@ -1054,7 +1130,7 @@ pub fn compile(graph: Graph) -> Result { } => { let id = families.len() as u32; let r = source.unwrap(); - source_family.insert(OutputKey(i, 0), id); + source_family.insert(TransitionTargetKey::Authored(OutputKey(i, 0)), id); families.push(TextureFamily { id, key: TextureFamilyKey { @@ -1079,8 +1155,72 @@ pub fn compile(graph: Graph) -> Result { _ => {} } } + + // Reserve compiler-owned roots without exposing incomplete public plan entries. + // IDs remain stable while effective families and descriptors are resolved. + let full_extent = NormalizedTextureExtent::SurfaceRelative { + width: Ratio { + numerator: 1, + denominator: 1, + }, + height: Ratio { + numerator: 1, + denominator: 1, + }, + depth_or_array_layers: 1, + }; + let authored_family_count = families.len(); + let mut default_roots: Vec = Vec::new(); + let mut default_targets = HashMap::new(); + for i in 0..graph.nodes.len() { + if !live.contains(&i) || contracts[i].key != "pipeline" { + continue; + } + for (input_ordinal, (socket, role, format, opposite)) in [ + ( + "colorTarget", + CompilerTextureRole::ColorTarget, + TextureFormat::Rgba16Float, + "depthTarget", + ), + ( + "depthTarget", + CompilerTextureRole::DepthTarget, + TextureFormat::Depth32Float, + "colorTarget", + ), + ] + .into_iter() + .enumerate() + { + if bound[i].contains_key(socket) { + continue; + } + let input_ordinal = input_ordinal as u16 + 2; + let key = TransitionTargetKey::CompilerDefaultInput { + owner_node: i, + input_ordinal, + }; + let resource = (authored_materialized_output_count + default_roots.len()) as u32; + let family = (authored_family_count + default_roots.len()) as u32; + default_targets.insert((i, socket), key); + source_family.insert(key, family); + let _ = opposite; + default_roots.push(DefaultDraft { + key, + resource, + family, + owner_node: i, + input_ordinal, + socket, + role, + format, + descriptor: DraftDescriptor::Deferred, + }); + } + } let mut transitions: Vec = Vec::new(); - let mut transitions_by_target: BTreeMap> = BTreeMap::new(); + let mut transitions_by_target: BTreeMap> = BTreeMap::new(); for i in 0..graph.nodes.len() { if !live.contains(&i) { continue; @@ -1094,7 +1234,10 @@ pub fn compile(graph: Graph) -> Result { let transition = TextureTransition { writer_node: i, input_socket, - target: bound[i][input_socket].producer, + target: bound[i] + .get(input_socket) + .map(|b| TransitionTargetKey::Authored(b.producer)) + .unwrap_or_else(|| default_targets[&(i, input_socket)]), output: OutputKey(i, output_ordinal), }; let index = transitions.len(); @@ -1110,7 +1253,7 @@ pub fn compile(graph: Graph) -> Result { output: OutputKey, transitions: &[TextureTransition], transition_for_output: &HashMap, - source_family: &HashMap, + source_family: &HashMap, colors: &mut HashMap, resolved: &mut HashMap, ) -> ResolvedTransition { @@ -1128,23 +1271,27 @@ pub fn compile(graph: Graph) -> Result { version: 0, target: transition.target, } - } else if transition_for_output.contains_key(&transition.target) { - match resolve_transition( - transition.target, - transitions, - transition_for_output, - source_family, - colors, - resolved, - ) { - ResolvedTransition::Resolved { - family, version, .. - } => ResolvedTransition::Resolved { - family, - version: version + 1, - target: transition.target, - }, - ResolvedTransition::Cyclic => ResolvedTransition::Cyclic, + } else if let TransitionTargetKey::Authored(target_output) = transition.target { + if !transition_for_output.contains_key(&target_output) { + ResolvedTransition::Cyclic + } else { + match resolve_transition( + target_output, + transitions, + transition_for_output, + source_family, + colors, + resolved, + ) { + ResolvedTransition::Resolved { + family, version, .. + } => ResolvedTransition::Resolved { + family, + version: version + 1, + target: transition.target, + }, + ResolvedTransition::Cyclic => ResolvedTransition::Cyclic, + } } } else { ResolvedTransition::Cyclic @@ -1179,7 +1326,16 @@ pub fn compile(graph: Graph) -> Result { target, } = resolved[&transition.output] { - let target_id = output_ids[&target]; + let target_id = match target { + TransitionTargetKey::Authored(key) => output_ids[&key], + TransitionTargetKey::CompilerDefaultInput { .. } => { + default_roots + .iter() + .find(|root| root.key == target) + .unwrap() + .resource + } + }; version_of.insert(transition.output, (family, version, target_id)); } } @@ -1217,7 +1373,8 @@ pub fn compile(graph: Graph) -> Result { if !version_of.contains_key(&key) { continue; } - let Some(next_indices) = transitions_by_target.get(&key) else { + let Some(next_indices) = transitions_by_target.get(&TransitionTargetKey::Authored(key)) + else { continue; }; let [next_index] = next_indices.as_slice() else { @@ -1249,7 +1406,9 @@ pub fn compile(graph: Graph) -> Result { continue; } if contracts[i].key == "pipeline" { - if bound[i]["colorTarget"].producer == bound[i]["depthTarget"].producer + if bound[i].get("colorTarget").map(|b| b.producer) + == bound[i].get("depthTarget").map(|b| b.producer) + && bound[i].contains_key("colorTarget") || matches!((version_of.get(&OutputKey(i, 0)), version_of.get(&OutputKey(i, 1))), (Some((cf, _, _)), Some((df, _, _))) if cf == df) { return Err(error( @@ -1286,32 +1445,48 @@ pub fn compile(graph: Graph) -> Result { } } - // Materialize versions only after hazard and writer precedence has been settled. - for transition in &transitions { - if let Some(&(family, version, target)) = version_of.get(&transition.output) { - families[family as usize].versions.push(TextureVersion { - version, - resource: output_ids[&transition.output], - target, - initialized: true, - stored: true, - lifetime: Lifetime { - first_use: 0, - last_use: 0, - }, - }); - } - } - for family in &mut families { - family.versions.sort_by_key(|version| version.version); - for (index, version) in family.versions.iter().enumerate() { - if version.version != index as u32 { - return Err(error( - "GRAPH_RESOURCE_VERSION_INVALID", - "texture versions must form a dense linear chain", - "resources", - )); + // Infer draft descriptors without recursion. Unknown and invalid dependencies + // remain deferred so descriptor diagnostics never steal cycle diagnostics. + for _ in 0..default_roots.len() { + let mut changed = false; + for index in 0..default_roots.len() { + if matches!(default_roots[index].descriptor, DraftDescriptor::Known(_)) { + continue; } + let owner = default_roots[index].owner_node; + let opposite_output = if default_roots[index].role == CompilerTextureRole::ColorTarget { + OutputKey(owner, 1) + } else { + OutputKey(owner, 0) + }; + let Some(&(opposite_family, _, _)) = version_of.get(&opposite_output) else { + continue; + }; + let extent = if opposite_family as usize >= authored_family_count + && default_roots + .get(opposite_family as usize - authored_family_count) + .is_some_and(|draft| draft.owner_node == owner) + { + Some(full_extent.clone()) + } else { + known_family_descriptor(opposite_family, &families, &default_roots) + .map(|descriptor| descriptor.extent.clone()) + }; + if let Some(extent) = extent { + default_roots[index].descriptor = + DraftDescriptor::Known(NormalizedTextureDescriptor { + dimension: TextureDimension::D2, + format: default_roots[index].format, + extent, + mip_level_count: 1, + sample_count: 1, + view_formats: vec![], + }); + changed = true; + } + } + if !changed { + break; } } @@ -1320,102 +1495,146 @@ pub fn compile(graph: Graph) -> Result { if !live.contains(&i) || contracts[i].key != "pipeline" { continue; } - let (Some(&(cf, _, _)), Some(&(df, _, _))) = ( - version_of.get(&OutputKey(i, 0)), - version_of.get(&OutputKey(i, 1)), - ) else { - continue; - }; - 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.format != TextureFormat::Depth32Float - && cd.dimension == dd.dimension - && cd.extent == dd.extent - && cd.sample_count == 1; - if !ok_depth || !ok_color { + let cd = version_of + .get(&OutputKey(i, 0)) + .and_then(|&(family, _, _)| known_family_descriptor(family, &families, &default_roots)); + let dd = version_of + .get(&OutputKey(i, 1)) + .and_then(|&(family, _, _)| known_family_descriptor(family, &families, &default_roots)); + let ok_depth = dd.is_none_or(|dd| { + dd.dimension == TextureDimension::D2 + && dd.format == TextureFormat::Depth32Float + && dd.sample_count == 1 + && dd.mip_level_count == 1 + && dd.view_formats.is_empty() + && extent_layers(&dd.extent) == 1 + }); + let ok_color = cd.is_none_or(|cd| { + cd.dimension == TextureDimension::D2 + && cd.format != TextureFormat::Depth32Float + && cd.sample_count == 1 + && cd.mip_level_count == 1 + && cd.view_formats.is_empty() + && extent_layers(&cd.extent) == 1 + }); + if !ok_color { return Err(error( "GRAPH_ILLEGAL_ACCESS", - "attachments are incompatible", - format!("nodes[{i}].inputs"), + "color attachment is invalid", + format!("nodes[{i}].inputs.colorTarget"), )); } + if !ok_depth { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "depth attachment is invalid", + format!("nodes[{i}].inputs.depthTarget"), + )); + } + if let (Some(cd), Some(dd)) = (cd, dd) { + if cd.dimension != dd.dimension + || cd.extent != dd.extent + || cd.sample_count != dd.sample_count + { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "attachments are incompatible", + format!("nodes[{i}].inputs"), + )); + } + } } for i in 0..graph.nodes.len() { if !live.contains(&i) || contracts[i].fullscreen_policy.is_none() { continue; } - let source_key = bound[i]["source"].producer; - let Some(&(source_family_id, _, _)) = version_of.get(&source_key) else { + let source = sampled_descriptor( + bound[i]["source"].producer, + &version_of, + &resolved, + &families, + &default_roots, + )?; + let source_descriptor = match source { + SampledDescriptor::Known(descriptor) => Some(descriptor), + SampledDescriptor::Deferred | SampledDescriptor::Unproduced => None, + }; + let target_family_id = version_of + .get(&OutputKey(i, 0)) + .map(|&(family, _, _)| family) + .or_else(|| { + source_family + .get(&TransitionTargetKey::Authored( + bound[i]["colorTarget"].producer, + )) + .copied() + }); + let target_descriptor = target_family_id + .and_then(|family| known_family_descriptor(family, &families, &default_roots)); + let bloom = if contracts[i].fullscreen_policy == Some(FullscreenPolicy::BloomComposite) { + Some(sampled_descriptor( + bound[i]["bloom"].producer, + &version_of, + &resolved, + &families, + &default_roots, + )?) + } else { + None + }; + let bloom_descriptor = match &bloom { + Some(SampledDescriptor::Known(descriptor)) => Some(*descriptor), + _ => None, + }; + let source_ok = source_descriptor.is_none_or(|descriptor| { + descriptor.format == TextureFormat::Rgba16Float && is_single_view_d2(descriptor) + }); + let target_ok = target_descriptor.is_none_or(|descriptor| { + is_single_view_d2(descriptor) + && match contracts[i].fullscreen_policy { + Some(FullscreenPolicy::Copy) => { + descriptor.format != TextureFormat::Depth32Float + } + Some(FullscreenPolicy::BloomExtract) + | Some(FullscreenPolicy::HdrSameExtent) + | Some(FullscreenPolicy::BloomComposite) => { + descriptor.format == TextureFormat::Rgba16Float + } + _ => false, + } + }); + let bloom_ok = bloom_descriptor.is_none_or(|descriptor| { + descriptor.format == TextureFormat::Rgba16Float && is_single_view_d2(descriptor) + }); + let extent_ok = match contracts[i].fullscreen_policy { + Some(FullscreenPolicy::Copy) + | Some(FullscreenPolicy::HdrSameExtent) + | Some(FullscreenPolicy::BloomComposite) => { + !matches!((source_descriptor, target_descriptor), (Some(source), Some(target)) if source.extent != target.extent) + } + Some(FullscreenPolicy::BloomExtract) => true, + _ => false, + }; + if !source_ok || !target_ok || !bloom_ok || !extent_ok { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "fullscreen textures are incompatible", + format!("nodes[{i}].inputs"), + )); + } + if matches!(source, SampledDescriptor::Unproduced) { return Err(error( "GRAPH_UNINITIALIZED_RESOURCE", "copy source is not produced", format!("nodes[{i}].inputs.source"), )); - }; - let Some(&(target_family_id, _, _)) = version_of.get(&OutputKey(i, 0)) else { - continue; - }; - let source_descriptor = match &families[source_family_id as usize].source { - TextureFamilySource::AuthoredTexture { descriptor, .. } => descriptor, - }; - 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), - }; - let authored_target_ok = target_descriptor.is_some_and(|descriptor| { - descriptor.format == TextureFormat::Rgba16Float && is_single_view_d2(descriptor) - }); - let bloom_input_ok = if contracts[i].fullscreen_policy - == Some(FullscreenPolicy::BloomComposite) - { - let bloom_key = bound[i]["bloom"].producer; - let Some(&(bloom_family_id, _, _)) = version_of.get(&bloom_key) else { - return Err(error( - "GRAPH_UNINITIALIZED_RESOURCE", - "bloom source is not produced", - format!("nodes[{i}].inputs.bloom"), - )); - }; - match &families[bloom_family_id as usize].source { - TextureFamilySource::AuthoredTexture { descriptor, .. } => { - descriptor.format == TextureFormat::Rgba16Float && is_single_view_d2(descriptor) - } - } - } else { - true - }; - let source_is_full_surface = matches!(&source_descriptor.extent, NormalizedTextureExtent::SurfaceRelative { width, height, depth_or_array_layers: 1 } if *width == Ratio { numerator:1, denominator:1 } && *height == Ratio { numerator:1, denominator:1 }); - let target_matches_source = target_descriptor - .is_some_and(|descriptor| descriptor.extent == source_descriptor.extent); - let descriptor_ok = match contracts[i].fullscreen_policy { - Some(FullscreenPolicy::Copy) => { - target_descriptor.is_none() && source_is_full_surface - || target_descriptor.is_some_and(|descriptor| { - descriptor.format != TextureFormat::Depth32Float - && is_single_view_d2(descriptor) - && descriptor.extent == source_descriptor.extent - }) - } - Some(FullscreenPolicy::BloomExtract) => authored_target_ok, - Some(FullscreenPolicy::HdrSameExtent) => authored_target_ok && target_matches_source, - Some(FullscreenPolicy::BloomComposite) => { - authored_target_ok && target_matches_source && bloom_input_ok - } - _ => false, - }; - if !source_ok || !descriptor_ok { + } + if matches!(bloom, Some(SampledDescriptor::Unproduced)) { return Err(error( - "GRAPH_ILLEGAL_ACCESS", - "fullscreen textures are incompatible", - format!("nodes[{i}].inputs"), + "GRAPH_UNINITIALIZED_RESOURCE", + "bloom source is not produced", + format!("nodes[{i}].inputs.bloom"), )); } } @@ -1436,8 +1655,9 @@ pub fn compile(graph: Graph) -> Result { } continue; }; - let TextureFamilySource::AuthoredTexture { descriptor, .. } = - &families[family as usize].source; + let Some(descriptor) = known_family_descriptor(family, &families, &default_roots) else { + continue; + }; let NormalizedParameters::FrameOut { dynamic_range, .. } = ¶ms[i] else { unreachable!() }; @@ -1569,6 +1789,9 @@ pub fn compile(graph: Graph) -> Result { if transitions .iter() .any(|transition| matches!(resolved[&transition.output], ResolvedTransition::Cyclic)) + || default_roots + .iter() + .any(|draft| matches!(draft.descriptor, DraftDescriptor::Deferred)) { return Err(error( "GRAPH_RESOURCE_VERSION_INVALID", @@ -1577,6 +1800,64 @@ pub fn compile(graph: Graph) -> Result { )); } + for draft in &default_roots { + let DraftDescriptor::Known(descriptor) = &draft.descriptor else { + unreachable!("deferred drafts rejected above") + }; + families.push(TextureFamily { + id: draft.family, + key: TextureFamilyKey { + source_node: draft.owner_node as u32, + source_socket: draft.input_ordinal, + }, + source: TextureFamilySource::CompilerDefaultInput { + resource: draft.resource, + owner_node_index: draft.owner_node as u32, + input_ordinal: draft.input_ordinal, + role: draft.role, + descriptor: descriptor.clone(), + }, + lifetime: Lifetime { + first_use: 0, + last_use: 0, + }, + versions: vec![], + usage: vec![], + allocation: None, + aliasable: false, + }); + } + for transition in &transitions { + if let Some(&(family, version, target)) = version_of.get(&transition.output) { + families[family as usize].versions.push(TextureVersion { + version, + resource: output_ids[&transition.output], + target, + initialized: true, + stored: true, + lifetime: Lifetime { + first_use: 0, + last_use: 0, + }, + }); + } + } + for family in &mut families { + family.versions.sort_by_key(|version| version.version); + if family + .versions + .iter() + .enumerate() + .any(|(index, version)| version.version != index as u32) + { + return Err(error( + "GRAPH_RESOURCE_VERSION_INVALID", + "texture versions must form a dense linear chain", + "resources", + )); + } + } + let mut resources = Vec::new(); for (i, o, out) in resource_meta { let key = OutputKey(i, o); @@ -1589,7 +1870,7 @@ pub fn compile(graph: Graph) -> Result { } = ¶ms[i] { ResourcePlan::TextureSource { - family: source_family[&key], + family: source_family[&TransitionTargetKey::Authored(key)], residency: *residency, descriptor: descriptor.clone(), } @@ -1625,10 +1906,10 @@ pub fn compile(graph: Graph) -> Result { }; resources.push(CompiledResource { original_node_index: i as u32, - output_ordinal: o, - origin: NodeOutputRef { + origin: ResourceOrigin::AuthoredOutput { node: graph.nodes[i].id.clone(), socket: out.name.into(), + output_ordinal: o, }, semantic_type: out.semantic_type, producer_execution: None, @@ -1637,6 +1918,28 @@ pub fn compile(graph: Graph) -> Result { }); let _ = id; } + for draft in &default_roots { + let DraftDescriptor::Known(descriptor) = &draft.descriptor else { + unreachable!("deferred drafts rejected above") + }; + resources.push(CompiledResource { + original_node_index: draft.owner_node as u32, + origin: ResourceOrigin::CompilerDefaultInput { + owner_node_index: draft.owner_node as u32, + input_ordinal: draft.input_ordinal, + socket: draft.socket.into(), + role: draft.role, + }, + semantic_type: SemanticType::Texture, + producer_execution: None, + lifetime: None, + plan: ResourcePlan::TextureSource { + family: draft.family, + residency: TextureResidency::Transient, + descriptor: descriptor.clone(), + }, + }); + } let mut executions = Vec::new(); for &i in &order { if matches!( @@ -1647,14 +1950,26 @@ pub fn compile(graph: Graph) -> Result { } let input_resource = |s: &str| output_ids[&bound[i][s].producer]; let mut inputs = Vec::new(); - for s in contracts[i].inputs { + for (input_ordinal, s) in contracts[i].inputs.iter().enumerate() { if s.role != InputRole::Expression { - let Some(b) = bound[i].get(s.name) else { + let resource = if let Some(b) = bound[i].get(s.name) { + output_ids[&b.producer] + } else if s.default_policy == InputDefaultPolicy::CompilerTexture { + let key = TransitionTargetKey::CompilerDefaultInput { + owner_node: i, + input_ordinal: input_ordinal as u16, + }; + default_roots + .iter() + .find(|root| root.key == key) + .expect("default policy materialized a root") + .resource + } else { continue; }; inputs.push(CompiledSocketInput { socket: s.name.into(), - resource: output_ids[&b.producer], + resource, }); } } @@ -2095,13 +2410,13 @@ pub fn compile(graph: Graph) -> Result { last_use: last, }; f.usage = texture_usage(f, &executions); - f.aliasable = matches!( - f.source, - TextureFamilySource::AuthoredTexture { - residency: TextureResidency::Transient, - .. + let transient = match f.source { + TextureFamilySource::AuthoredTexture { residency, .. } => { + residency == TextureResidency::Transient } - ) && f.versions.iter().all(|v| v.initialized); + TextureFamilySource::CompilerDefaultInput { .. } => true, + }; + f.aliasable = transient && f.versions.iter().all(|v| v.initialized); } let (classes, transient) = allocate(&mut families, &mut resources); if resources.len() > 1024 { @@ -2121,7 +2436,7 @@ pub fn compile(graph: Graph) -> Result { texture_families: families, allocation_classes: classes, culled_node_count: (graph.nodes.len() - live.len()) as u32, - culled_resource_count: (all_outputs - output_ids.len()) as u32, + culled_resource_count: (all_outputs - authored_materialized_output_count) as u32, transient_slot_count: transient, instance_traversal, }) @@ -2139,6 +2454,12 @@ fn extent_layers(e: &NormalizedTextureExtent) -> u32 { } => *depth_or_array_layers, } } +pub(super) fn family_descriptor(family: &TextureFamily) -> &NormalizedTextureDescriptor { + match &family.source { + TextureFamilySource::AuthoredTexture { descriptor, .. } + | TextureFamilySource::CompilerDefaultInput { descriptor, .. } => descriptor, + } +} pub(super) fn is_single_view_d2(descriptor: &NormalizedTextureDescriptor) -> bool { descriptor.dimension == TextureDimension::D2 && descriptor.sample_count == 1 @@ -2195,7 +2516,7 @@ fn allocate( ) -> (Vec, u32) { let mut grouped: BTreeMap> = BTreeMap::new(); for (i, f) in families.iter().enumerate() { - let TextureFamilySource::AuthoredTexture { descriptor, .. } = &f.source; + let descriptor = family_descriptor(f); grouped .entry(TextureCompatibilityKey { dimension: descriptor.dimension, diff --git a/renderer/src/render_graph/contracts.rs b/renderer/src/render_graph/contracts.rs index b5951f2..9d5b51e 100644 --- a/renderer/src/render_graph/contracts.rs +++ b/renderer/src/render_graph/contracts.rs @@ -44,6 +44,13 @@ pub enum InputCardinality { OptionalOne, } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InputDefaultPolicy { + None, + ParameterLiteral, + CompilerTexture, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(tag = "kind", content = "types", rename_all = "snake_case")] pub enum TypeConstraint { Exact(SemanticType), @@ -67,6 +74,7 @@ pub struct InputSocketContract { pub name: &'static str, pub accepted: TypeConstraint, pub cardinality: InputCardinality, + pub default_policy: InputDefaultPolicy, pub role: InputRole, } #[derive(Clone, Copy, Debug, serde::Serialize)] @@ -97,10 +105,20 @@ const fn i( cardinality: InputCardinality, role: InputRole, ) -> InputSocketContract { + let default_policy = match cardinality { + InputCardinality::RequiredOne => InputDefaultPolicy::None, + InputCardinality::OptionalOne => match role { + InputRole::ColorTarget { .. } | InputRole::DepthTarget => { + InputDefaultPolicy::CompilerTexture + } + _ => InputDefaultPolicy::ParameterLiteral, + }, + }; InputSocketContract { name, accepted: TypeConstraint::Exact(ty), cardinality, + default_policy, role, } } @@ -124,10 +142,10 @@ const PIPE_I: &[InputSocketContract] = &[ i( "colorTarget", Texture, - R, + O, InputRole::ColorTarget { location: 0 }, ), - i("depthTarget", Texture, R, InputRole::DepthTarget), + i("depthTarget", Texture, O, InputRole::DepthTarget), ]; const PIPE_O: &[OutputSocketContract] = &[o("color", Texture), o("depth", Texture)]; const CULL_I: &[InputSocketContract] = &[ @@ -181,7 +199,7 @@ pub static CONTRACTS: &[Contract] = &[ c!("mesh", 2, Source, NONE_I, MESH_O, false, None), c!("texture", 1, Source, NONE_I, TEXTURE_O, false, None), c!("frustum_cull", 2, Expression, CULL_I, CULL_O, false, None), - c!("pipeline", 2, Render, PIPE_I, PIPE_O, false, None), + c!("pipeline", 3, Render, PIPE_I, PIPE_O, false, None), ex!("and", ins!("left":Bool,"right":Bool), outs!("value":Bool)), ex!("or", ins!("left":Bool,"right":Bool), outs!("value":Bool)), ex!("not", ins!("operand":Bool), outs!("value":Bool)), diff --git a/renderer/src/render_graph/plan.rs b/renderer/src/render_graph/plan.rs index c839618..1bc3f17 100644 --- a/renderer/src/render_graph/plan.rs +++ b/renderer/src/render_graph/plan.rs @@ -24,14 +24,36 @@ pub struct CompiledGraph { #[serde(rename_all = "camelCase")] pub struct CompiledResource { pub original_node_index: u32, - pub output_ordinal: u16, - pub origin: NodeOutputRef, + pub origin: ResourceOrigin, pub semantic_type: SemanticType, pub producer_execution: Option, pub lifetime: Option, pub plan: ResourcePlan, } +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ResourceOrigin { + AuthoredOutput { + node: String, + socket: String, + output_ordinal: u16, + }, + CompilerDefaultInput { + owner_node_index: u32, + input_ordinal: u16, + socket: String, + role: CompilerTextureRole, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompilerTextureRole { + ColorTarget, + DepthTarget, +} + #[derive(Clone, Debug, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ResourcePlan { @@ -331,7 +353,7 @@ pub struct Lifetime { pub last_use: u32, } -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] #[serde(rename_all = "camelCase")] pub struct TextureFamilyKey { pub source_node: u32, @@ -346,6 +368,13 @@ pub enum TextureFamilySource { residency: TextureResidency, descriptor: NormalizedTextureDescriptor, }, + CompilerDefaultInput { + resource: u32, + owner_node_index: u32, + input_ordinal: u16, + role: CompilerTextureRole, + descriptor: NormalizedTextureDescriptor, + }, } #[derive(Clone, Debug, Serialize)] diff --git a/renderer/src/render_graph/runtime.rs b/renderer/src/render_graph/runtime.rs index 416fe33..6eb6a89 100644 --- a/renderer/src/render_graph/runtime.rs +++ b/renderer/src/render_graph/runtime.rs @@ -395,7 +395,8 @@ fn texture_descriptor<'a>( _ => return None, }; match &graph.texture_families.get(family as usize)?.source { - TextureFamilySource::AuthoredTexture { descriptor, .. } => Some(descriptor), + TextureFamilySource::AuthoredTexture { descriptor, .. } + | TextureFamilySource::CompilerDefaultInput { descriptor, .. } => Some(descriptor), } } @@ -682,6 +683,19 @@ fn validate_fullscreen_execution( } fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { + fn texture_family_for_resource<'a>( + graph: &'a CompiledGraph, + resource: u32, + ) -> Option<&'a TextureFamily> { + let resource = graph.resources.get(resource as usize)?; + let family = match resource.plan { + ResourcePlan::TextureSource { family, .. } | ResourcePlan::Texture { family, .. } => { + family + } + _ => return None, + }; + graph.texture_families.get(family as usize) + } for (i, resource) in graph.resources.iter().enumerate() { if resource.semantic_type.is_virtual() { return Err(invalid( @@ -795,28 +809,221 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { } } + let mut family_keys = HashSet::new(); + let mut source_claims = vec![0u8; graph.resources.len()]; 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(|| { + if family.id as usize != fi || !family_keys.insert(family.key.clone()) { + return Err(invalid( + "texture family id/key is not unique and canonical", + format!("textureFamilies[{fi}]"), + )); + } + let (source, residency, descriptor) = match &family.source { + TextureFamilySource::AuthoredTexture { + resource, + residency, + descriptor, + } => (*resource, *residency, descriptor), + TextureFamilySource::CompilerDefaultInput { + resource, + descriptor, + .. + } => (*resource, TextureResidency::Transient, descriptor), + }; + 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"), ) })?; + source_claims[source as usize] = source_claims[source as usize].saturating_add(1); 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) + if *f == family.id && *r == residency && d == descriptor) { return Err(invalid( "texture family source is not canonical", format!("textureFamilies[{fi}].source"), )); } + let origin_ok = match (&family.source, &source_resource.origin) { + ( + TextureFamilySource::AuthoredTexture { resource, .. }, + ResourceOrigin::AuthoredOutput { + node: _, + socket, + output_ordinal, + }, + ) => { + family.key.source_node == source_resource.original_node_index + && family.key.source_socket == 0 + && *output_ordinal == 0 + && *socket == "texture" + && *resource == source + && source_resource.producer_execution.is_none() + } + ( + TextureFamilySource::CompilerDefaultInput { + owner_node_index, + input_ordinal, + role, + .. + }, + ResourceOrigin::CompilerDefaultInput { + owner_node_index: owner, + input_ordinal: input, + socket, + role: origin_role, + }, + ) => { + let owner_executions: Vec<_> = graph + .executions + .iter() + .filter(|execution| execution.original_node_index == *owner_node_index) + .collect(); + let owner_input_ok = owner_executions + .as_slice() + .first() + .is_some_and(|execution| { + execution + .inputs + .iter() + .filter(|input| input.socket == *socket && input.resource == source) + .count() + == 1 + }) + && owner_executions[0].executor.key == "pipeline" + && owner_executions[0].executor.version == 3 + && graph + .executions + .iter() + .flat_map(|execution| &execution.inputs) + .filter(|input| input.resource == source) + .count() + == 1 + && contract("pipeline") + .and_then(|contract| contract.inputs.get(*input_ordinal as usize)) + .is_some_and(|input| { + input.name == *socket + && matches!( + (input.role, role), + ( + InputRole::ColorTarget { location: 0 }, + CompilerTextureRole::ColorTarget + ) | (InputRole::DepthTarget, CompilerTextureRole::DepthTarget) + ) + }); + owner == owner_node_index + && owner_executions.len() == 1 + && input == input_ordinal + && origin_role == role + && owner_input_ok + && socket + == if *role == CompilerTextureRole::ColorTarget { + "colorTarget" + } else { + "depthTarget" + } + } + _ => false, + }; + if !origin_ok { + return Err(invalid( + "texture source origin is not canonical", + format!("resources[{source}].origin"), + )); + } + if let TextureFamilySource::CompilerDefaultInput { + owner_node_index, + input_ordinal, + role, + descriptor, + .. + } = &family.source + { + let fixed = descriptor.dimension == TextureDimension::D2 + && descriptor.format + == if *role == CompilerTextureRole::ColorTarget { + TextureFormat::Rgba16Float + } else { + TextureFormat::Depth32Float + } + && descriptor.mip_level_count == 1 + && descriptor.sample_count == 1 + && descriptor.view_formats.is_empty() + && matches!( + descriptor.extent, + NormalizedTextureExtent::Absolute { + depth_or_array_layers: 1, + .. + } | NormalizedTextureExtent::SurfaceRelative { + depth_or_array_layers: 1, + .. + } + ) + && family.key.source_node == *owner_node_index + && family.key.source_socket == *input_ordinal + && source_resource.original_node_index == *owner_node_index; + if !fixed { + return Err(invalid( + "compiler default descriptor is not canonical", + format!("textureFamilies[{fi}].source.descriptor"), + )); + } + let owner = graph + .executions + .iter() + .find(|execution| execution.original_node_index == *owner_node_index) + .expect("origin validation found owner"); + let opposite_socket = if *role == CompilerTextureRole::ColorTarget { + "depthTarget" + } else { + "colorTarget" + }; + let opposite_resource = owner + .inputs + .iter() + .find(|input| input.socket == opposite_socket) + .map(|input| input.resource) + .ok_or_else(|| { + invalid( + "compiler default opposite input is missing", + format!("executions[{owner_node_index}].inputs"), + ) + })?; + let opposite_family = texture_family_for_resource(graph, opposite_resource) + .ok_or_else(|| { + invalid( + "compiler default opposite input is invalid", + format!("executions[{owner_node_index}].inputs"), + ) + })?; + let both_defaults = matches!(&opposite_family.source, TextureFamilySource::CompilerDefaultInput { owner_node_index: opposite_owner, .. } if opposite_owner == owner_node_index); + let expected_extent = if both_defaults { + NormalizedTextureExtent::SurfaceRelative { + width: Ratio { + numerator: 1, + denominator: 1, + }, + height: Ratio { + numerator: 1, + denominator: 1, + }, + depth_or_array_layers: 1, + } + } else { + super::compiler::family_descriptor(opposite_family) + .extent + .clone() + }; + if descriptor.extent != expected_extent { + return Err(invalid( + "compiler default extent is not canonical", + format!("textureFamilies[{fi}].source.descriptor.extent"), + )); + } + } if family.versions.is_empty() { return Err(invalid( "texture family has no versions", @@ -828,7 +1035,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { let mut all_initialized = true; for (vi, version) in family.versions.iter().enumerate() { let expected_target = if vi == 0 { - *source + source } else { family.versions[vi - 1].resource }; @@ -870,7 +1077,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { format!("textureFamilies[{fi}].lifetime"), )); } - let expected_aliasable = *residency == TextureResidency::Transient && all_initialized; + let expected_aliasable = residency == TextureResidency::Transient && all_initialized; if family.aliasable != expected_aliasable { return Err(invalid( "texture family aliasability is not canonical", @@ -878,6 +1085,15 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { )); } } + for (ri, resource) in graph.resources.iter().enumerate() { + let is_source = matches!(resource.plan, ResourcePlan::TextureSource { .. }); + if is_source != (source_claims[ri] == 1) { + return Err(invalid( + "texture source must be claimed by exactly one family", + format!("resources[{ri}].plan"), + )); + } + } Ok(()) } @@ -1590,7 +1806,7 @@ pub fn prepare_runtime_plan( format!("resources[{color}].plan.family"), ) })?; - let TextureFamilySource::AuthoredTexture { descriptor, .. } = &family.source; + let descriptor = super::compiler::family_descriptor(family); if !super::compiler::frame_out_source_compatible(descriptor, dynamic_range) { return Err(invalid( "frame_out texture descriptor is incompatible", @@ -1648,6 +1864,14 @@ pub fn prepare_runtime_plan( )); } } + TextureFamilySource::CompilerDefaultInput { descriptor, .. } => { + if !super::compiler::is_single_view_d2(descriptor) || family.allocation.is_none() { + return Err(invalid( + "compiler default family is not canonical", + format!("textureFamilies[{fi}]"), + )); + } + } } for (vi, version) in family.versions.iter().enumerate() { if version.version as usize != vi { @@ -1755,24 +1979,29 @@ pub fn prepare_runtime_plan( format!("allocationClasses[{ci}].slots[{si}].occupants"), )); } - let TextureFamilySource::AuthoredTexture { - descriptor, - residency, - .. - } = &family.source; + let (descriptor, residency) = match &family.source { + TextureFamilySource::AuthoredTexture { + descriptor, + residency, + .. + } => (descriptor, *residency), + TextureFamilySource::CompilerDefaultInput { descriptor, .. } => { + (descriptor, TextureResidency::Transient) + } + }; expected_usage.extend(family.usage.iter().copied()); let kind_valid = match slot.kind { AllocationKind::AliasedTransient => { - *residency == TextureResidency::Transient && family.aliasable + residency == TextureResidency::Transient && family.aliasable } AllocationKind::DedicatedTransient => { slot.occupants.len() == 1 - && *residency == TextureResidency::Transient + && residency == TextureResidency::Transient && !family.aliasable } AllocationKind::Persistent => { slot.occupants.len() == 1 - && *residency == TextureResidency::Persistent + && residency == TextureResidency::Persistent && !family.aliasable } }; diff --git a/renderer/src/render_graph/tests.rs b/renderer/src/render_graph/tests.rs index 4d71c69..f41afcc 100644 --- a/renderer/src/render_graph/tests.rs +++ b/renderer/src/render_graph/tests.rs @@ -21,6 +21,13 @@ fn texture(id: &str, format: &str) -> Value { }) } +fn set_extent_ratio(texture: &mut Value, numerator: u32, denominator: u32) { + texture["parameters"]["texture"]["extent"]["width"] = + json!({"numerator":numerator,"denominator":denominator}); + texture["parameters"]["texture"]["extent"]["height"] = + json!({"numerator":numerator,"denominator":denominator}); +} + fn node(id: &str, key: &str, version: u32, parameters: Value, inputs: Value) -> Value { json!({ "id": id, "state": "enabled", "executor": { "key": key, "version": version }, "parameters": parameters, "inputs": inputs }) @@ -38,7 +45,7 @@ pub(crate) fn full_cull_graph() -> Value { node("visible", "not", 1, json!({"operandDefault":false}), json!({"operand":input("cull","isFrustumCulled")})), node("class", "and", 1, json!({"leftDefault":true,"rightDefault":true}), json!({"left":input("bits","bit0"),"right":input("visible","value")})), - node("pipeline", "pipeline", 2, + node("pipeline", "pipeline", 3, json!({"pipeline":"gltf_standard","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), json!({"mesh":input("mesh","mesh"),"predicate":input("class","value"),"colorTarget":input("color","texture"),"depthTarget":input("depth","texture")})), node("frame", "frame_out", 3, @@ -51,7 +58,7 @@ pub(crate) fn full_cull_graph() -> Value { #[test] fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() { assert_eq!(contract("mesh").unwrap().version, 2); - assert_eq!(contract("pipeline").unwrap().version, 2); + assert_eq!(contract("pipeline").unwrap().version, 3); assert_eq!( contract("mesh") .unwrap() @@ -145,3 +152,543 @@ fn expression_provenance_rejects_cross_mesh_values() { let error = compile_value(graph).unwrap_err(); assert_eq!(error.code, "GRAPH_SOCKET_TYPE_MISMATCH"); } + +fn implicit_pipeline_graph() -> Value { + json!({ "schemaVersion": 2, "graphId": "implicit", "revision": 1, "nodes": [ + node("mesh", "mesh", 2, json!({}), json!({})), + node("first", "pipeline", 3, + json!({"pipeline":"ground_plane","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), + json!({"mesh":input("mesh","mesh")})), + node("second", "pipeline", 3, + json!({"pipeline":"gltf_standard","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), + json!({"mesh":input("mesh","mesh"),"colorTarget":input("first","color"),"depthTarget":input("first","depth")})), + node("frame", "frame_out", 3, + json!({"surfaceFormat":"preferred","hdrEnabled":true,"toneMapper":"aces","exposureStops":0,"outputTransfer":"srgb","scaleMode":"stretch","filter":"linear","backgroundColor":[0,0,0,1]}), + json!({"color":input("second","color")})) + ]}) +} + +#[test] +fn contract_v3_declares_strict_default_policies() { + let pipeline = contract("pipeline").unwrap(); + assert_eq!(pipeline.version, 3); + assert_eq!(pipeline.inputs[0].default_policy, InputDefaultPolicy::None); + assert_eq!( + pipeline.inputs[1].default_policy, + InputDefaultPolicy::ParameterLiteral + ); + assert_eq!( + pipeline.inputs[2].default_policy, + InputDefaultPolicy::CompilerTexture + ); + assert_eq!( + pipeline.inputs[3].default_policy, + InputDefaultPolicy::CompilerTexture + ); + assert!(CONTRACTS + .iter() + .flat_map(|c| c.inputs) + .all(|input| matches!( + (input.cardinality, input.default_policy), + (InputCardinality::RequiredOne, InputDefaultPolicy::None) + | ( + InputCardinality::OptionalOne, + InputDefaultPolicy::ParameterLiteral + ) + | ( + InputCardinality::OptionalOne, + InputDefaultPolicy::CompilerTexture + ) + ))); +} + +#[test] +fn disconnected_targets_have_tagged_roots_and_clear_version_zero() { + let compiled = compile_value(implicit_pipeline_graph()).unwrap(); + let roots: Vec<_> = compiled + .resources + .iter() + .filter(|r| matches!(r.origin, ResourceOrigin::CompilerDefaultInput { .. })) + .collect(); + assert_eq!(roots.len(), 2); + assert_eq!(compiled.culled_resource_count, 0); + assert!(compiled + .texture_families + .iter() + .take(2) + .all(|f| f.aliasable)); + let first = compiled + .executions + .iter() + .find(|e| e.id == "first") + .unwrap(); + let ExecutionKind::Render { + color_attachments, + depth_stencil: Some(depth), + } = &first.kind + else { + panic!() + }; + assert!(matches!( + color_attachments[0].load, + NormalizedColorLoad::Clear { .. } + )); + assert!(matches!(depth.load, NormalizedDepthLoad::Clear { .. })); +} + +#[test] +fn implicit_chain_is_deterministic_and_loads_successors() { + let a = compile_value(implicit_pipeline_graph()).unwrap(); + let b = compile_value(implicit_pipeline_graph()).unwrap(); + assert_eq!( + serde_json::to_value(&a).unwrap(), + serde_json::to_value(&b).unwrap() + ); + let second = a.executions.iter().find(|e| e.id == "second").unwrap(); + let ExecutionKind::Render { + color_attachments, + depth_stencil: Some(depth), + } = &second.kind + else { + panic!() + }; + assert_eq!(color_attachments[0].load, NormalizedColorLoad::Load); + assert_eq!(depth.load, NormalizedDepthLoad::Load); +} + +#[test] +fn one_missing_attachment_copies_authored_opposite_extent() { + for missing in ["colorTarget", "depthTarget"] { + let mut graph = full_cull_graph(); + graph["nodes"][8]["inputs"] + .as_object_mut() + .unwrap() + .remove(missing); + let compiled = compile_value(graph).unwrap(); + let default = compiled + .texture_families + .iter() + .find(|f| matches!(f.source, TextureFamilySource::CompilerDefaultInput { .. })) + .unwrap(); + let explicit = compiled + .texture_families + .iter() + .find(|f| { + matches!(f.source, TextureFamilySource::AuthoredTexture { .. }) + && f.id != default.id + }) + .unwrap(); + assert_eq!( + super::compiler::family_descriptor(default).extent, + super::compiler::family_descriptor(explicit).extent + ); + } +} + +#[test] +fn default_extent_follows_half_surface_and_prior_default_families() { + let mut half = full_cull_graph(); + set_extent_ratio(&mut half["nodes"][0], 1, 2); + half["nodes"][8]["inputs"] + .as_object_mut() + .unwrap() + .remove("depthTarget"); + let compiled = compile_value(half).unwrap(); + let default = compiled + .texture_families + .iter() + .find(|family| { + matches!( + family.source, + TextureFamilySource::CompilerDefaultInput { .. } + ) + }) + .unwrap(); + assert_eq!( + super::compiler::family_descriptor(default).extent, + NormalizedTextureExtent::SurfaceRelative { + width: Ratio { + numerator: 1, + denominator: 2 + }, + height: Ratio { + numerator: 1, + denominator: 2 + }, + depth_or_array_layers: 1 + } + ); + + let compiled = compile_value(implicit_pipeline_graph()).unwrap(); + let defaults: Vec<_> = compiled + .texture_families + .iter() + .filter(|family| { + matches!( + family.source, + TextureFamilySource::CompilerDefaultInput { .. } + ) + }) + .collect(); + assert_eq!(defaults.len(), 2); + assert_eq!( + super::compiler::family_descriptor(defaults[0]).extent, + super::compiler::family_descriptor(defaults[1]).extent + ); +} + +#[test] +fn explicit_attachment_diagnostics_are_socket_specific_then_mutual() { + let mut color = full_cull_graph(); + color["nodes"][0]["parameters"]["texture"]["format"] = json!("depth32_float"); + let error = compile_value(color).unwrap_err(); + assert_eq!(error.details["path"], "nodes[8].inputs.colorTarget"); + + let mut depth = full_cull_graph(); + depth["nodes"][1]["parameters"]["texture"]["format"] = json!("rgba16_float"); + let error = compile_value(depth).unwrap_err(); + assert_eq!(error.details["path"], "nodes[8].inputs.depthTarget"); + + let mut mismatch = full_cull_graph(); + set_extent_ratio(&mut mismatch["nodes"][1], 1, 2); + let error = compile_value(mismatch).unwrap_err(); + assert_eq!(error.details["path"], "nodes[8].inputs"); +} + +#[test] +fn descriptor_dependency_cycle_keeps_graph_cycle_priority() { + let mut graph = implicit_pipeline_graph(); + graph["nodes"][1]["inputs"]["depthTarget"] = input("second", "depth"); + graph["nodes"][2]["inputs"] + .as_object_mut() + .unwrap() + .remove("depthTarget"); + assert_eq!(compile_value(graph).unwrap_err().code, "GRAPH_CYCLE"); +} + +#[test] +fn known_attachment_error_precedes_cycle() { + let mut graph = full_cull_graph(); + graph["nodes"][0]["parameters"]["texture"]["format"] = json!("depth32_float"); + graph["nodes"][8]["inputs"]["depthTarget"] = input("pipeline", "depth"); + let error = compile_value(graph).unwrap_err(); + assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); + assert_eq!(error.details["path"], "nodes[8].inputs.colorTarget"); +} + +fn self_targeting_copy(source_format: &str) -> Value { + let mut graph = full_cull_graph(); + graph["nodes"][0]["parameters"]["texture"]["format"] = json!(source_format); + graph["nodes"].as_array_mut().unwrap().insert( + 9, + node( + "copy", + "fullscreen_copy", + 1, + json!({}), + json!({ + "source": input("pipeline", "color"), + "colorTarget": input("copy", "color") + }), + ), + ); + graph["nodes"][10]["inputs"]["color"] = input("copy", "color"); + graph +} + +#[test] +fn known_invalid_fullscreen_source_precedes_target_cycle() { + let error = compile_value(self_targeting_copy("rgba8_unorm")).unwrap_err(); + assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); + assert_eq!(error.details["path"], "nodes[9].inputs"); + + assert_eq!( + compile_value(self_targeting_copy("rgba16_float")) + .unwrap_err() + .code, + "GRAPH_CYCLE" + ); +} + +#[test] +fn known_invalid_fullscreen_target_precedes_source_cycle() { + let mut graph = implicit_pipeline_graph(); + graph["nodes"][1]["inputs"]["depthTarget"] = input("second", "depth"); + graph["nodes"][2]["inputs"] + .as_object_mut() + .unwrap() + .remove("depthTarget"); + graph["nodes"] + .as_array_mut() + .unwrap() + .insert(3, texture("saturation_target", "rgba8_unorm")); + graph["nodes"].as_array_mut().unwrap().insert( + 4, + node( + "saturation", + "saturation", + 1, + json!({"saturation":1,"factor":1}), + json!({ + "source": input("second", "color"), + "colorTarget": input("saturation_target", "texture") + }), + ), + ); + graph["nodes"][5]["parameters"]["hdrEnabled"] = json!(false); + graph["nodes"][5]["inputs"]["color"] = input("saturation", "color"); + let error = compile_value(graph).unwrap_err(); + assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); + assert_eq!(error.details["path"], "nodes[4].inputs"); +} + +fn self_targeting_composite(bloom_format: &str) -> Value { + let mut graph = full_cull_graph(); + graph["nodes"] + .as_array_mut() + .unwrap() + .insert(9, texture("bloom_target", bloom_format)); + graph["nodes"].as_array_mut().unwrap().insert( + 10, + node( + "bloom_copy", + "fullscreen_copy", + 1, + json!({}), + json!({ + "source": input("pipeline", "color"), + "colorTarget": input("bloom_target", "texture") + }), + ), + ); + graph["nodes"].as_array_mut().unwrap().insert( + 11, + node( + "composite", + "bloom_composite", + 1, + json!({"intensity":1}), + json!({ + "source": input("pipeline", "color"), + "bloom": input("bloom_copy", "color"), + "colorTarget": input("composite", "color") + }), + ), + ); + graph["nodes"][12]["inputs"]["color"] = input("composite", "color"); + graph +} + +#[test] +fn known_invalid_bloom_input_precedes_target_cycle() { + let error = compile_value(self_targeting_composite("rgba8_unorm")).unwrap_err(); + assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); + assert_eq!(error.details["path"], "nodes[11].inputs"); + assert_eq!( + compile_value(self_targeting_composite("rgba16_float")) + .unwrap_err() + .code, + "GRAPH_CYCLE" + ); +} + +fn cyclic_fullscreen_source(target_format: &str) -> Value { + let mut graph = full_cull_graph(); + graph["nodes"] + .as_array_mut() + .unwrap() + .insert(9, texture("copy_target", target_format)); + graph["nodes"].as_array_mut().unwrap().insert( + 10, + node( + "source_cycle", + "fullscreen_copy", + 1, + json!({}), + json!({ + "source": input("pipeline", "color"), + "colorTarget": input("source_cycle", "color") + }), + ), + ); + graph["nodes"].as_array_mut().unwrap().insert( + 11, + node( + "copy", + "fullscreen_copy", + 1, + json!({}), + json!({ + "source": input("source_cycle", "color"), + "colorTarget": input("copy_target", "texture") + }), + ), + ); + graph["nodes"][12]["inputs"]["color"] = input("copy", "color"); + graph +} + +#[test] +fn cyclic_fullscreen_source_defers_uninitialized_error() { + let error = compile_value(cyclic_fullscreen_source("depth32_float")).unwrap_err(); + assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); + assert_eq!(error.details["path"], "nodes[11].inputs"); + assert_eq!( + compile_value(cyclic_fullscreen_source("rgba16_float")) + .unwrap_err() + .code, + "GRAPH_CYCLE" + ); +} + +fn cyclic_bloom_input(target_format: &str) -> Value { + let mut graph = full_cull_graph(); + graph["nodes"] + .as_array_mut() + .unwrap() + .insert(9, texture("composite_target", target_format)); + graph["nodes"].as_array_mut().unwrap().insert( + 10, + node( + "bloom_cycle", + "fullscreen_copy", + 1, + json!({}), + json!({ + "source": input("pipeline", "color"), + "colorTarget": input("bloom_cycle", "color") + }), + ), + ); + graph["nodes"].as_array_mut().unwrap().insert( + 11, + node( + "composite", + "bloom_composite", + 1, + json!({"intensity":1}), + json!({ + "source": input("pipeline", "color"), + "bloom": input("bloom_cycle", "color"), + "colorTarget": input("composite_target", "texture") + }), + ), + ); + graph["nodes"][12]["inputs"]["color"] = input("composite", "color"); + graph +} + +#[test] +fn cyclic_bloom_input_defers_uninitialized_error() { + let error = compile_value(cyclic_bloom_input("depth32_float")).unwrap_err(); + assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); + assert_eq!(error.details["path"], "nodes[11].inputs"); + assert_eq!( + compile_value(cyclic_bloom_input("rgba16_float")) + .unwrap_err() + .code, + "GRAPH_CYCLE" + ); +} + +fn compiler_default_source(graph: &CompiledGraph) -> (usize, u32, u32) { + graph + .texture_families + .iter() + .enumerate() + .find_map(|(family_index, family)| match family.source { + TextureFamilySource::CompilerDefaultInput { + resource, + owner_node_index, + .. + } => Some((family_index, resource, owner_node_index)), + _ => None, + }) + .unwrap() +} + +fn assert_runtime_plan_invalid(graph: &CompiledGraph) { + let error = validate_activatable(graph).unwrap_err(); + assert_eq!(error.code, "GRAPH_RUNTIME_PLAN_INVALID"); +} + +#[test] +fn runtime_rejects_duplicate_compiler_default_owner_identity() { + let mut graph = compile_value(implicit_pipeline_graph()).unwrap(); + assert!(validate_activatable(&graph).is_ok()); + let (_, _, owner) = compiler_default_source(&graph); + graph + .executions + .iter_mut() + .find(|execution| execution.executor.key == "frame_out") + .unwrap() + .original_node_index = owner; + assert_runtime_plan_invalid(&graph); +} + +#[test] +fn runtime_rejects_coherently_retagged_authored_texture_origin() { + let mut graph = compile_value(full_cull_graph()).unwrap(); + assert!(validate_activatable(&graph).is_ok()); + let family = graph + .texture_families + .iter_mut() + .find(|family| matches!(family.source, TextureFamilySource::AuthoredTexture { .. })) + .unwrap(); + let TextureFamilySource::AuthoredTexture { resource, .. } = family.source else { + unreachable!() + }; + family.key.source_node = 2; + family.key.source_socket = 1; + let source = &mut graph.resources[resource as usize]; + source.original_node_index = 2; + source.origin = ResourceOrigin::AuthoredOutput { + node: "mesh".into(), + socket: "type".into(), + output_ordinal: 1, + }; + assert_runtime_plan_invalid(&graph); +} + +#[test] +fn runtime_rejects_duplicate_compiler_default_input_occurrence() { + let mut graph = compile_value(implicit_pipeline_graph()).unwrap(); + assert!(validate_activatable(&graph).is_ok()); + let (_, resource, owner) = compiler_default_source(&graph); + graph + .executions + .iter_mut() + .find(|execution| execution.original_node_index == owner) + .unwrap() + .inputs + .push(CompiledSocketInput { + socket: "duplicate".into(), + resource, + }); + assert_runtime_plan_invalid(&graph); +} + +#[test] +fn runtime_rejects_out_of_range_opposite_default_family_without_panicking() { + let mut graph = compile_value(implicit_pipeline_graph()).unwrap(); + assert!(validate_activatable(&graph).is_ok()); + let (_, _, owner) = compiler_default_source(&graph); + let opposite_resource = graph + .executions + .iter() + .find(|execution| execution.original_node_index == owner) + .unwrap() + .inputs + .iter() + .find(|input| input.socket == "depthTarget") + .unwrap() + .resource; + let out_of_range = graph.texture_families.len() as u32; + let ResourcePlan::TextureSource { family, .. } = + &mut graph.resources[opposite_resource as usize].plan + else { + panic!("expected opposite texture source") + }; + *family = out_of_range; + assert_runtime_plan_invalid(&graph); +} diff --git a/static/render-graph/catalog.js b/static/render-graph/catalog.js index aafd841..30a9790 100644 --- a/static/render-graph/catalog.js +++ b/static/render-graph/catalog.js @@ -1,10 +1,11 @@ export const GRAPH_ID = "authored_gpu_culling"; -export const CATALOG_VERSION = 8; +export const CATALOG_VERSION = 9; const exact = (type) => ({ kind: "exact", types: [type] }); -const i = (type, required = true, authoringType) => ({ +const i = (type, required = true, authoringType, defaultPolicy = required ? "none" : "parameter_literal") => ({ accepted: typeof type === "string" ? exact(type) : type, required, ...(authoringType ? { authoringType } : {}), + defaultPolicy, }); const o = (type) => ({ type }); const expression = (inputs, outputs) => ({ @@ -107,13 +108,13 @@ export const semanticCatalog = Object.freeze({ parameters: { cameraSelection: "active" }, }, pipeline: { - version: 2, + version: 3, execution: "render", inputs: { mesh: i("mesh_data"), predicate: i("bool", false), - colorTarget: i("texture"), - depthTarget: i("texture"), + colorTarget: i("texture", false, undefined, "compiler_texture"), + depthTarget: i("texture", false, undefined, "compiler_texture"), }, outputs: { color: o("texture"), depth: o("texture") }, parameters: { pipeline: "gltf_standard", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1] }, @@ -427,7 +428,7 @@ export const nodeDefinitions = Object.fromEntries( n, "input", v.authoringType ?? v.accepted.types[0], - !v.required ? socketDefault(v.accepted.types[0], defaultForInput(key, n, v.accepted.types[0])) : null, + v.defaultPolicy === "parameter_literal" ? socketDefault(v.accepted.types[0], defaultForInput(key, n, v.accepted.types[0])) : null, ), ]), ), diff --git a/static/render-graph/presets.js b/static/render-graph/presets.js index 3f9777f..1312189 100644 --- a/static/render-graph/presets.js +++ b/static/render-graph/presets.js @@ -34,12 +34,13 @@ const predicates = (withCulling = false) => { }; const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1, withCulling = false) => { const classification = predicates(withCulling); + const explicit = colorTarget || heightScale !== 1; + const target = colorTarget || "hdr"; return [ - node("hdr", "texture", texture("rgba16_float", 1, heightScale)), - node("depth", "texture", texture("depth32_float", 1, heightScale)), + ...(explicit && !colorTarget ? [node("hdr", "texture", texture("rgba16_float", 1, heightScale))] : []), node("mesh", "mesh"), ...classification.nodes, - node("ground", "pipeline", { pipeline: "ground_plane", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.ground, "value"), colorTarget: input(colorTarget, "texture"), depthTarget: input("depth", "texture") }), + node("ground", "pipeline", { pipeline: "ground_plane", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.ground, "value"), ...(explicit ? { colorTarget: input(target, "texture") } : {}) }), node("pbr", "pipeline", { pipeline: "gltf_standard", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.pbr, "value"), colorTarget: input("ground", "color"), depthTarget: input("ground", "depth") }), node("pbr_double", "pipeline", { pipeline: "gltf_standard_double_sided", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.pbr_double, "value"), colorTarget: input("pbr", "color"), depthTarget: input("pbr", "depth") }), ]; @@ -47,20 +48,20 @@ const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1 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"), + ...scene("ldr", clearColor), node("frame_out", "frame_out", frameOut(false), { 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 = graph("preset_hdr_fullscreen", [ - ...scene("hdr"), + ...scene(), node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") }), ]); export const culling = graph("preset_gpu_culling", (() => { - return [...scene("hdr", undefined, 1, true), node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") })]; + return [...scene(undefined, undefined, 1, true), node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") })]; })()); const postPreset = (graphId, kind) => { - const nodes = [...scene("hdr")]; + const nodes = [...scene()]; let source = "pbr_double"; if (kind === "edges") { nodes.splice(0, 0, node("edge_hdr", "texture", texture("rgba16_float"))); @@ -89,7 +90,7 @@ const postPreset = (graphId, kind) => { }; export const tone = postPreset("preset_tone", "tone"); const displayPreset = (id, parameters, heightScale = 1) => graph(id, [ - ...scene("hdr", undefined, heightScale), + ...scene(undefined, undefined, heightScale), node("frame_out", "frame_out", parameters, { color: input("pbr_double", "color") }), ]); export const contain = displayPreset("preset_contain", frameOut(false, { scaleMode: "contain", filter: "nearest", backgroundColor: [0.18, 0.18, 0.18, 0.25] }), 2); @@ -100,7 +101,7 @@ export const bloom = postPreset("preset_bloom", "bloom"); export const combined = postPreset("preset_combined", "combined"); export const grading = graph("preset_grading", [ node("balance_hdr", "texture", texture("rgba16_float")), node("exposure_hdr", "texture", texture("rgba16_float")), node("saturation_hdr", "texture", texture("rgba16_float")), node("mixer_hdr", "texture", texture("rgba16_float")), - ...scene("hdr"), + ...scene(), node("balance", "color_balance", { mode: "lift_gamma_gain", factor: 1, lift: 0, liftColor: [1,1,1,1], gamma: 1, gammaColor: [1,1,1,1], gain: 1, gainColor: [1,1,1,1], offset: 0, offsetColor: [1,1,1,1], power: 1, powerColor: [1,1,1,1], slope: 1, slopeColor: [1,1,1,1] }, { source: input("pbr_double", "color"), colorTarget: input("balance_hdr", "texture") }), node("exposure", "exposure_contrast", { exposureStops: 0, contrast: 1, pivot: 0.18, factor: 1 }, { source: input("balance", "color"), colorTarget: input("exposure_hdr", "texture") }), node("saturation", "saturation", { saturation: 1, factor: 1 }, { source: input("exposure", "color"), colorTarget: input("saturation_hdr", "texture") }), diff --git a/tests/fxnode-composition.test.js b/tests/fxnode-composition.test.js index a36f8fb..8cb8148 100644 --- a/tests/fxnode-composition.test.js +++ b/tests/fxnode-composition.test.js @@ -36,7 +36,7 @@ test("production render graph composition passes fxnode's public validator", asy result.ok ? undefined : JSON.stringify(result.issues, null, 2), ); assert.equal(fxNodeComposition.schemaVersion, 2); - assert.equal(fxNodeComposition.version, 8); + assert.equal(fxNodeComposition.version, 9); assert.equal(Object.keys(fxNodeComposition.nodes).length, 42); assert.ok( Object.values(fxNodeComposition.nodes).every( diff --git a/tests/render-graph-authoring.test.js b/tests/render-graph-authoring.test.js index 780c622..4be63c9 100644 --- a/tests/render-graph-authoring.test.js +++ b/tests/render-graph-authoring.test.js @@ -5,14 +5,14 @@ import { CATALOG_VERSION, semanticCatalog, nodeDefinitions, descriptors, } from "../static/render-graph/catalog.js"; -test("catalog v8 exposes the final mesh, pipeline, and typed-expression contracts", () => { - assert.equal(CATALOG_VERSION, 8); +test("catalog v9 exposes the final mesh, pipeline, and typed-expression contracts", () => { + assert.equal(CATALOG_VERSION, 9); assert.deepEqual(semanticCatalog.mesh.outputs, { mesh: { type: "mesh_data" }, type: { type: "u32x16" }, localAabb: { type: "local_aabb" }, }); assert.equal(semanticCatalog.mesh.version, 2); assert.equal(nodeDefinitions.mesh.sockets.localAabb.title, "Local AABB"); - assert.equal(semanticCatalog.pipeline.version, 2); + assert.equal(semanticCatalog.pipeline.version, 3); assert.equal(semanticCatalog.pipeline.inputs.predicate.required, false); for (const key of ["and", "xnor", "equals_f32", "greater_than_u32", "combine_vec4", "separate_mat4", "combine_u32_bits", "separate_u32x16", "separate_local_aabb"]) @@ -27,10 +27,19 @@ test("current culling fixture uses type-bit predicates and final socket versions const byId = Object.fromEntries(culling.nodes.map((node) => [node.id, node])); assert.deepEqual(byId.cull.inputs.localAabb, { node: "mesh", socket: "localAabb" }); assert.deepEqual(byId.type_words.inputs.value, { node: "mesh", socket: "type" }); - assert.equal(byId.ground.executor.version, 2); + assert.equal(byId.ground.executor.version, 3); assert.equal(byId.ground.inputs.predicate.node, "ground_final"); }); +test("compiler texture sockets expose policy metadata without literal widgets", () => { + for (const socket of ["colorTarget", "depthTarget"]) { + assert.equal(semanticCatalog.pipeline.inputs[socket].defaultPolicy, "compiler_texture"); + assert.equal(nodeDefinitions.pipeline.sockets[socket].default, undefined); + } + assert.equal(semanticCatalog.pipeline.inputs.predicate.defaultPolicy, "parameter_literal"); + assert.notEqual(nodeDefinitions.pipeline.sockets.predicate.default, null); +}); + test("removed architecture is absent from the authoring catalog", () => { for (const removed of ["mesh_query", "pipeline_registry"]) assert.equal(semanticCatalog[removed], undefined); diff --git a/tests/render-graph-presets.test.js b/tests/render-graph-presets.test.js index d435ed6..933c2b6 100644 --- a/tests/render-graph-presets.test.js +++ b/tests/render-graph-presets.test.js @@ -25,7 +25,7 @@ test("presets classify visibility and material through type.words[0] predicates" assert.deepEqual(byId.pbr_double.inputs.predicate, { node: name === "culling" ? "pbr_double_final" : "double_class", socket: "value" }, name); for (const pipeline of [byId.ground, byId.pbr, byId.pbr_double]) { assert.deepEqual(pipeline.inputs.mesh, { node: "mesh", socket: "mesh" }); - assert.equal(pipeline.executor.version, 2); + assert.equal(pipeline.executor.version, 3); } } }); @@ -39,3 +39,15 @@ test("culling adds a local-AABB expression to each material predicate", () => { for (const id of ["ground", "pbr", "pbr_double"]) assert.equal(byId[id].inputs.predicate.node.endsWith("_final"), true); }); + +test("implicit presets start disconnected and then chain both attachments", () => { + for (const name of ["hdr", "culling", "grading"]) { + const byId = Object.fromEntries(presets[name].nodes.map((node) => [node.id, node])); + assert.equal("colorTarget" in byId.ground.inputs, false, name); + assert.equal("depthTarget" in byId.ground.inputs, false, name); + assert.deepEqual(byId.pbr.inputs.colorTarget, { node: "ground", socket: "color" }, name); + assert.deepEqual(byId.pbr.inputs.depthTarget, { node: "ground", socket: "depth" }, name); + } + const midnight = Object.fromEntries(presets.midnight.nodes.map((node) => [node.id, node])); + assert.deepEqual(midnight.ground.inputs.colorTarget, { node: "ldr", socket: "texture" }); +});