diff --git a/renderer/src/render_graph/compiler.rs b/renderer/src/render_graph/compiler.rs index 8bd242c..f12a72f 100644 --- a/renderer/src/render_graph/compiler.rs +++ b/renderer/src/render_graph/compiler.rs @@ -21,8 +21,8 @@ struct CullParameters { } #[derive(Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -struct PipelineParameters { - pipeline: String, +struct RasterParameters { + draw_order: i32, depth_compare: CompareFunction, depth_write_enabled: bool, clear_depth: f32, @@ -137,6 +137,11 @@ 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 AttachmentRoot { + Authored(OutputKey), + CompilerDefault { node: usize, ordinal: u16 }, +} +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] enum TransitionTargetKey { Authored(OutputKey), CompilerDefaultInput { @@ -244,6 +249,7 @@ fn reaches( from: usize, to: usize, outgoing_edges: &[Vec], + ordering_outgoing: &[Vec], edges: &[DependencyEdge], live: &HashSet, memo: &mut HashMap<(usize, usize), bool>, @@ -268,6 +274,11 @@ fn reaches( stack.push(next); } } + for &next in &ordering_outgoing[node] { + if live.contains(&next) { + stack.push(next); + } + } } memo.insert((from, to), answer); answer @@ -741,21 +752,9 @@ fn decode(node: &Node, i: usize) -> Result { descriptor: normalize_texture(p.texture, &base)?, } } - "pipeline" => { - let p: PipelineParameters = + key if contract(key).is_some_and(|contract| contract.is_raster_draw()) => { + let p: RasterParameters = 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", @@ -770,8 +769,8 @@ fn decode(node: &Node, i: usize) -> Result { format!("{base}.clearColor"), )); } - NormalizedParameters::Pipeline { - pipeline: p.pipeline, + NormalizedParameters::Raster { + draw_order: p.draw_order, depth_compare: p.depth_compare, depth_write_enabled: p.depth_write_enabled, clear_depth: p.clear_depth, @@ -1102,6 +1101,271 @@ pub fn compile(graph: Graph) -> Result { e.producer_output_ordinal, ) }); + let is_normalizable_target = |edge: &DependencyEdge| { + contracts[edge.from_node].is_raster_draw() + && contracts[edge.to_node].is_raster_draw() + && matches!( + contracts[edge.to_node].inputs[edge.consumer_input_ordinal as usize].role, + InputRole::ColorTarget { .. } | InputRole::DepthTarget + ) + }; + let is_exact_reader = |edge: &DependencyEdge| { + let input = &contracts[edge.to_node].inputs[edge.consumer_input_ordinal as usize]; + input.role == InputRole::SampledTexture + || (input.role == InputRole::SemanticRead + && contracts[edge.from_node].outputs[edge.producer_output_ordinal as usize] + .semantic_type + == SemanticType::Texture) + }; + // Compute demand from authored resource dependencies only. In particular, + // a later WAR ordering edge must never resurrect its reader. + let mut provisional_live = HashSet::new(); + let mut provisional_stack: Vec<_> = contracts + .iter() + .enumerate() + .filter(|(i, contract)| { + contract.inherently_observable && graph.nodes[*i].state == NodeState::Enabled + }) + .map(|(i, _)| i) + .collect(); + let mut authored_deps = vec![Vec::new(); graph.nodes.len()]; + for edge in &edges { + authored_deps[edge.to_node].push(edge.from_node); + } + while let Some(node) = provisional_stack.pop() { + if provisional_live.insert(node) { + provisional_stack.extend(authored_deps[node].iter().copied()); + } + } + + // Preserve authored, non-normalized dataflow before attachment normalization. It + // is used both to diagnose impossible draw-order normalization and to find + // readers which must finish before an attachment version is overwritten. + let ordinary_edges: Vec<_> = edges + .iter() + .filter(|edge| !is_normalizable_target(edge)) + .cloned() + .collect(); + let mut target_consumers = HashMap::>::new(); + let mut ordinary_consumers = HashMap::>::new(); + for edge in &edges { + let key = OutputKey(edge.from_node, edge.producer_output_ordinal); + if is_normalizable_target(edge) { + target_consumers.entry(key).or_default().push(edge.to_node); + } else if is_exact_reader(edge) && provisional_live.contains(&edge.to_node) { + ordinary_consumers + .entry(key) + .or_default() + .push(edge.to_node); + } + } + let observation_cuts: HashSet<_> = target_consumers + .keys() + .filter(|key| ordinary_consumers.contains_key(key)) + .copied() + .collect(); + let mut ordering_edges = Vec::new(); + + // Normalize raster attachment versions before liveness. Authored graphs may + // express a render-pass cohort either as a chain or as direct siblings of + // one texture. Turn both forms into an explicit, deterministic SSA chain. + fn attachment_root( + node: usize, + ordinal: u16, + bound: &[BTreeMap<&str, BoundInput>], + contracts: &[&Contract], + colors: &mut HashMap<(usize, u16), u8>, + roots: &mut HashMap<(usize, u16), AttachmentRoot>, + ) -> Result { + if let Some(&root) = roots.get(&(node, ordinal)) { + return Ok(root); + } + if colors.get(&(node, ordinal)) == Some(&1) { + return Err(error( + "GRAPH_ATTACHMENT_LINEAGE_INVALID", + "attachment lineage is cyclic", + format!("nodes[{node}].inputs"), + )); + } + colors.insert((node, ordinal), 1); + let socket = if ordinal == 0 { + "colorTarget" + } else { + "depthTarget" + }; + let root = match bound[node].get(socket).map(|binding| binding.producer) { + None => AttachmentRoot::CompilerDefault { node, ordinal }, + Some(output) + if !contracts[output.0].is_raster_draw() + && contracts[output.0].outputs[output.1 as usize].semantic_type + == SemanticType::Texture => + { + AttachmentRoot::Authored(output) + } + Some(output) if contracts[output.0].is_raster_draw() && output.1 == ordinal => { + attachment_root(output.0, ordinal, bound, contracts, colors, roots)? + } + Some(_) => { + return Err(error( + "GRAPH_ATTACHMENT_LINEAGE_INVALID", + "attachment lineage has no texture or default root", + format!("nodes[{node}].inputs.{socket}"), + )) + } + }; + colors.insert((node, ordinal), 2); + roots.insert((node, ordinal), root); + Ok(root) + } + + let raster_nodes: Vec<_> = contracts + .iter() + .enumerate() + .filter_map(|(i, contract)| contract.is_raster_draw().then_some(i)) + .collect(); + let mut aliases = HashMap::::new(); + for ordinal in 0..=1u16 { + let mut colors = HashMap::new(); + let mut roots = HashMap::new(); + let mut cohorts = BTreeMap::>::new(); + for &node in &raster_nodes { + let root = attachment_root(node, ordinal, &bound, &contracts, &mut colors, &mut roots)?; + cohorts.entry(root).or_default().push(node); + } + for (root, mut members) in cohorts { + members.sort_by_key(|&node| { + let NormalizedParameters::Raster { draw_order, .. } = params[node] else { + unreachable!() + }; + (draw_order, node) + }); + // An observed version ends its aliasing segment. Writers after the + // cut continue the same physical lineage, but may not replace the + // version seen by the reader. + let mut segment_start = 0; + for position in 0..members.len() { + let at_cut = observation_cuts.contains(&OutputKey(members[position], ordinal)); + if at_cut || position + 1 == members.len() { + let terminal = OutputKey(members[position], ordinal); + for &member in &members[segment_start..=position] { + aliases.insert(OutputKey(member, ordinal), terminal); + } + if let Some(&next_writer) = members.get(position + 1) { + for &member in &members[segment_start..=position] { + let output = OutputKey(member, ordinal); + for &reader in ordinary_consumers.get(&output).into_iter().flatten() { + if reader != next_writer { + ordering_edges.push((reader, next_writer)); + } + } + } + } + segment_start = position + 1; + } + } + let socket = if ordinal == 0 { + "colorTarget" + } else { + "depthTarget" + }; + for (position, &member) in members.iter().enumerate() { + let target = if position == 0 { + match root { + AttachmentRoot::Authored(output) => Some(output), + AttachmentRoot::CompilerDefault { .. } => None, + } + } else { + Some(OutputKey(members[position - 1], ordinal)) + }; + bound[member].remove(socket); + if let Some(producer) = target { + bound[member].insert(socket, BoundInput { producer }); + } + } + } + } + ordering_edges.sort_unstable(); + ordering_edges.dedup(); + // Replace authored attachment dependencies with the canonical WAW chain, + // and redirect observations of any cohort member to its terminal version. + edges.retain(|edge| { + !(contracts[edge.to_node].is_raster_draw() + && (edge.to_socket == "colorTarget" || edge.to_socket == "depthTarget")) + }); + for &node in &raster_nodes { + for (socket, ordinal) in [("colorTarget", 0u16), ("depthTarget", 1u16)] { + let Some(binding) = bound[node].get(socket) else { + continue; + }; + let input_ordinal = contracts[node] + .inputs + .iter() + .position(|input| input.name == socket) + .expect("raster target contract") as u16; + edges.push(DependencyEdge { + from_node: binding.producer.0, + from_socket: contracts[binding.producer.0].outputs[binding.producer.1 as usize] + .name + .into(), + producer_output_ordinal: binding.producer.1, + to_node: node, + to_socket: socket.into(), + consumer_input_ordinal: input_ordinal, + resource: NodeOutputRef { + node: graph.nodes[binding.producer.0].id.clone(), + socket: contracts[binding.producer.0].outputs[binding.producer.1 as usize] + .name + .into(), + }, + }); + let _ = ordinal; + } + } + for node in 0..graph.nodes.len() { + for input in contracts[node].inputs.iter().filter(|input| { + matches!( + input.role, + InputRole::SampledTexture | InputRole::SemanticRead + ) + }) { + let Some(binding) = bound[node].get_mut(input.name) else { + continue; + }; + if input.role == InputRole::SemanticRead + && contracts[binding.producer.0].outputs[binding.producer.1 as usize].semantic_type + != SemanticType::Texture + { + continue; + } + if let Some(&terminal) = aliases.get(&binding.producer) { + binding.producer = terminal; + } + } + } + for edge in &mut edges { + if is_exact_reader(edge) { + let key = OutputKey(edge.from_node, edge.producer_output_ordinal); + if let Some(&terminal) = aliases.get(&key) { + edge.from_node = terminal.0; + edge.producer_output_ordinal = terminal.1; + edge.from_socket = contracts[terminal.0].outputs[terminal.1 as usize] + .name + .into(); + edge.resource = NodeOutputRef { + node: graph.nodes[terminal.0].id.clone(), + socket: edge.from_socket.clone(), + }; + } + } + } + edges.sort_by_key(|e| { + ( + e.to_node, + e.consumer_input_ordinal, + e.from_node, + e.producer_output_ordinal, + ) + }); let mut deps = vec![Vec::new(); graph.nodes.len()]; for edge in &edges { deps[edge.to_node].push(edge.from_node); @@ -1122,6 +1386,42 @@ pub fn compile(graph: Graph) -> Result { stack.extend(deps[i].iter().copied()); } } + // A canonical attachment writer edge may not reverse authored ordinary + // dataflow. Keep this distinct from generic cycle reporting so authors get + // the draw-order parameter which made normalization impossible. + let mut ordinary_outgoing = vec![Vec::new(); graph.nodes.len()]; + for edge in &ordinary_edges { + ordinary_outgoing[edge.from_node].push(edge.to_node); + } + let ordinary_reaches = |from: usize, to: usize| { + let mut seen = vec![false; graph.nodes.len()]; + let mut pending = vec![from]; + while let Some(node) = pending.pop() { + if node == to { + return true; + } + if !seen[node] { + seen[node] = true; + pending.extend(ordinary_outgoing[node].iter().copied()); + } + } + false + }; + let mut draw_order_conflicts: Vec<_> = edges + .iter() + .filter(|edge| { + contracts[edge.from_node].is_raster_draw() + && contracts[edge.to_node].is_raster_draw() + && matches!( + contracts[edge.to_node].inputs[edge.consumer_input_ordinal as usize].role, + InputRole::ColorTarget { .. } | InputRole::DepthTarget + ) + && ordinary_reaches(edge.to_node, edge.from_node) + }) + .map(|edge| edge.to_node) + .collect(); + draw_order_conflicts.sort_unstable(); + draw_order_conflicts.dedup(); // 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. @@ -1215,10 +1515,10 @@ pub fn compile(graph: Graph) -> Result { 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" { + if !live.contains(&i) || !contracts[i].is_raster_draw() { continue; } - for (input_ordinal, (socket, role, format, opposite)) in [ + for (socket, role, format, opposite) in [ ( "colorTarget", CompilerTextureRole::ColorTarget, @@ -1233,12 +1533,16 @@ pub fn compile(graph: Graph) -> Result { ), ] .into_iter() - .enumerate() { if bound[i].contains_key(socket) { continue; } - let input_ordinal = input_ordinal as u16 + 2; + let input_ordinal = contracts[i] + .inputs + .iter() + .position(|input| input.name == socket) + .expect("compiler target has a contract input") + as u16; let key = TransitionTargetKey::CompilerDefaultInput { owner_node: i, input_ordinal, @@ -1267,8 +1571,8 @@ pub fn compile(graph: Graph) -> Result { if !live.contains(&i) { continue; } - let transition_sockets: &[(&str, u16)] = match contracts[i].key { - "pipeline" => &[("colorTarget", 0), ("depthTarget", 1)], + let transition_sockets: &[(&str, u16)] = match contracts[i] { + ref contract if contract.is_raster_draw() => &[("colorTarget", 0), ("depthTarget", 1)], _ if contracts[i].fullscreen_policy.is_some() => &[("colorTarget", 0)], _ => continue, }; @@ -1388,6 +1692,12 @@ pub fn compile(graph: Graph) -> Result { outgoing_edges[edge.from_node].push(index); } } + let mut ordering_outgoing = vec![Vec::new(); graph.nodes.len()]; + for &(from, to) in &ordering_edges { + if live.contains(&from) && live.contains(&to) { + ordering_outgoing[from].push(to); + } + } for outgoing in &mut outgoing_edges { outgoing.sort_by_key(|&index| { let edge = &edges[index]; @@ -1404,7 +1714,7 @@ pub fn compile(graph: Graph) -> Result { if !live.contains(&i) { continue; } - if contracts[i].key == "pipeline" { + if contracts[i].is_raster_draw() { if bound[i].get("colorTarget").map(|b| b.producer) == bound[i].get("depthTarget").map(|b| b.producer) && bound[i].contains_key("colorTarget") @@ -1532,7 +1842,7 @@ pub fn compile(graph: Graph) -> Result { format!("nodes[{}].inputs.{}", edge.to_node, edge.to_socket), )); } - if contracts[edge.from_node].key != "pipeline" || edge.producer_output_ordinal != 0 { + if !contracts[edge.from_node].is_raster_draw() || edge.producer_output_ordinal != 0 { return Err(error( "GRAPH_ILLEGAL_ACCESS", "multisampled color texture is not a produced pipeline color", @@ -1571,6 +1881,7 @@ pub fn compile(graph: Graph) -> Result { i, next.writer_node, &outgoing_edges, + &ordering_outgoing, &edges, &live, &mut reachability, @@ -1587,7 +1898,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 != "pipeline" { + if !live.contains(&i) || !contracts[i].is_raster_draw() { continue; } let cd = version_of @@ -1780,12 +2091,23 @@ pub fn compile(graph: Graph) -> Result { } // Stable Kahn scheduling is deliberately after resource and access validation. + if let Some(&node) = draw_order_conflicts.iter().find(|node| live.contains(node)) { + return Err(error( + "GRAPH_DRAW_ORDER_CONFLICT", + "draw order conflicts with authored dependencies", + format!("nodes[{node}].parameters.drawOrder"), + )); + } let mut indegree = vec![0; graph.nodes.len()]; for &node in &live { indegree[node] = edges .iter() .filter(|edge| edge.to_node == node && live.contains(&edge.from_node)) - .count(); + .count() + + ordering_edges + .iter() + .filter(|(from, to)| *to == node && live.contains(from)) + .count(); } let mut queue = BinaryHeap::new(); for &node in &live { @@ -1803,6 +2125,12 @@ pub fn compile(graph: Graph) -> Result { queue.push(Reverse(consumer)); } } + for &consumer in &ordering_outgoing[node] { + indegree[consumer] -= 1; + if indegree[consumer] == 0 { + queue.push(Reverse(consumer)); + } + } } if order.len() != live.len() { let residual: Vec<_> = (0..graph.nodes.len()) @@ -1811,6 +2139,7 @@ pub fn compile(graph: Graph) -> Result { fn cycle_dfs( node: usize, outgoing_edges: &[Vec], + ordering_outgoing: &[Vec], edges: &[DependencyEdge], residual: &[bool], colors: &mut [u8], @@ -1829,6 +2158,7 @@ pub fn compile(graph: Graph) -> Result { if let Some(cycle) = cycle_dfs( to, outgoing_edges, + ordering_outgoing, edges, residual, colors, @@ -1848,6 +2178,31 @@ pub fn compile(graph: Graph) -> Result { return Some(cycle); } } + for &to in &ordering_outgoing[node] { + if !residual[to] { + continue; + } + if colors[to] == 0 { + if cycle_dfs( + to, + outgoing_edges, + ordering_outgoing, + edges, + residual, + colors, + node_stack, + edge_stack, + ) + .is_some() + { + // Ordering edges are synthetic and have no authored + // socket payload, but still constitute a real cycle. + return Some(Vec::new()); + } + } else if colors[to] == 1 { + return Some(Vec::new()); + } + } node_stack.pop(); colors[node] = 2; None @@ -1859,6 +2214,7 @@ pub fn compile(graph: Graph) -> Result { cycle = cycle_dfs( node, &outgoing_edges, + &ordering_outgoing, &edges, &residual, &mut colors, @@ -2189,15 +2545,15 @@ pub fn compile(graph: Graph) -> Result { .collect(); let mut accesses = Vec::new(); let kind = match contracts[i].key { - "pipeline" => { + _ if contracts[i].is_raster_draw() => { let color = output_ids[&OutputKey(i, 0)]; let depth = output_ids[&OutputKey(i, 1)]; let clear = match params[i] { - NormalizedParameters::Pipeline { clear_color, .. } => clear_color, + NormalizedParameters::Raster { clear_color, .. } => clear_color, _ => unreachable!(), }; let clear_depth = match ¶ms[i] { - NormalizedParameters::Pipeline { clear_depth, .. } => *clear_depth, + NormalizedParameters::Raster { clear_depth, .. } => *clear_depth, _ => unreachable!(), }; let first_color = version_of[&OutputKey(i, 0)].1 == 0; @@ -2280,20 +2636,7 @@ pub fn compile(graph: Graph) -> Result { }, }); } - ExecutionKind::Render { - color_attachments: vec![ColorAttachmentPlan { - resource: color, - resolve_target, - location: 0, - load: cl, - store: source_store, - }], - depth_stencil: Some(DepthStencilAttachmentPlan { - resource: depth, - load: dl, - store: StoreOp::Store, - }), - } + ExecutionKind::RasterDraw } _ if contracts[i].fullscreen_policy.is_some() => { let color = output_ids[&OutputKey(i, 0)]; @@ -2321,16 +2664,7 @@ pub fn compile(graph: Graph) -> Result { full_overwrite: true, }, }); - ExecutionKind::Render { - color_attachments: vec![ColorAttachmentPlan { - resource: color, - resolve_target: None, - location: 0, - load, - store: StoreOp::Store, - }], - depth_stencil: None, - } + ExecutionKind::Fullscreen } "frame_out" => { let r = input_resource("color"); @@ -2584,7 +2918,7 @@ pub fn compile(graph: Graph) -> Result { let mut predicates = Vec::new(); for (execution, compiled) in executions.iter().enumerate() { let node = compiled.original_node_index as usize; - if contracts[node].key != "pipeline" { + if !contracts[node].is_raster_draw() { continue; } let mesh = output_ids[&bound[node]["mesh"].producer]; @@ -2599,7 +2933,7 @@ pub fn compile(graph: Graph) -> Result { let predicate = if let Some(binding) = bound[node].get("predicate") { expression_ids[&binding.producer] } else { - let NormalizedParameters::Pipeline { + let NormalizedParameters::Raster { predicate_default, .. } = params[node] else { @@ -2651,9 +2985,15 @@ pub fn compile(graph: Graph) -> Result { } } } - // Dense lifetimes touch bindings, outputs, and accesses. + let render_passes = build_render_passes(&executions, &resources, &families); + let execution_pass: Vec = render_passes + .iter() + .enumerate() + .flat_map(|(pass, value)| value.executions.iter().map(move |_| pass as u32)) + .collect(); + // Dense lifetimes use physical pass ordinals; producer metadata remains logical. for (ordinal, e) in executions.iter().enumerate() { - let ordinal = ordinal as u32; + let ordinal = execution_pass[ordinal]; let mut touched = BTreeSet::new(); for x in &e.inputs { touched.insert(x.resource); @@ -2710,6 +3050,7 @@ pub fn compile(graph: Graph) -> Result { node_count: graph.nodes.len() as u32, resources, executions, + render_passes, texture_families: families, allocation_classes: classes, culled_node_count: (graph.nodes.len() - live.len()) as u32, @@ -2719,6 +3060,170 @@ pub fn compile(graph: Graph) -> Result { }) } +pub(crate) fn execution_attachments( + execution: &CompiledExecution, +) -> (Vec, Option) { + let mut colors = Vec::new(); + let mut depth = None; + for access in &execution.accesses { + match access.mode { + AccessMode::ColorAttachment { + location, + load, + store, + .. + } => colors.push(ColorAttachmentPlan { + resource: access.resource, + resolve_target: execution.accesses.iter().find_map(|candidate| { + match candidate.mode { + AccessMode::ColorResolve { + source, + location: l, + } if source == access.resource && l == location => Some(candidate.resource), + _ => None, + } + }), + location, + load, + store, + }), + AccessMode::DepthAttachment { load, store, .. } => { + depth = Some(DepthStencilAttachmentPlan { + resource: access.resource, + load, + store, + }) + } + _ => {} + } + } + colors.sort_by_key(|color| color.location); + (colors, depth) +} + +pub(crate) fn build_render_passes( + executions: &[CompiledExecution], + resources: &[CompiledResource], + families: &[TextureFamily], +) -> Vec { + let family = |resource: u32| { + resources + .get(resource as usize) + .and_then(|resource| match resource.plan { + ResourcePlan::Texture { family, .. } + | ResourcePlan::TextureSource { family, .. } => Some(family), + _ => None, + }) + }; + let mut passes: Vec = Vec::new(); + for (index, execution) in executions.iter().enumerate() { + if matches!(execution.kind, ExecutionKind::FrameOut { .. }) { + passes.push(PhysicalRenderPass { + executions: vec![index as u32], + kind: PhysicalRenderPassKind::Surface, + }); + continue; + } + let (colors, depth) = execution_attachments(execution); + let mut merged = false; + if matches!(execution.kind, ExecutionKind::RasterDraw) + && colors.iter().all(|v| v.load == NormalizedColorLoad::Load) + && depth + .as_ref() + .is_none_or(|v| v.load == NormalizedDepthLoad::Load) + { + if let Some(PhysicalRenderPass { + executions: members, + kind: + PhysicalRenderPassKind::Texture { + color_attachments: previous_colors, + depth_stencil: previous_depth, + }, + }) = passes.last_mut() + { + let previous = &executions[*members.last().unwrap() as usize]; + let target_input = |socket: &str| { + execution + .inputs + .iter() + .find(|input| input.socket == socket) + .map(|input| input.resource) + }; + let exact = previous.outputs.iter().any(|out| { + out.socket == "color" && Some(out.resource) == target_input("colorTarget") + }) && depth.as_ref().is_none_or(|_| { + previous.outputs.iter().any(|out| { + out.socket == "depth" && Some(out.resource) == target_input("depthTarget") + }) + }); + let compatible = previous_colors.len() == colors.len() + && previous_colors.iter().zip(&colors).all(|(a, b)| { + a.location == b.location + && family(a.resource) == family(b.resource) + && family(a.resource).is_some_and(|f| { + family(b.resource).is_some_and(|next_family| { + families.get(f as usize).is_some_and(|first| { + families.get(next_family as usize).is_some_and(|next| { + family_descriptor(first) == family_descriptor(next) + }) + }) + }) + }) + }) + && match (previous_depth.as_ref(), depth.as_ref()) { + (None, None) => true, + (Some(a), Some(b)) => { + family(a.resource) == family(b.resource) + && family(a.resource).is_some_and(|f| { + family(b.resource).is_some_and(|next_family| { + families.get(f as usize).is_some_and(|first| { + families.get(next_family as usize).is_some_and(|next| { + family_descriptor(first) == family_descriptor(next) + }) + }) + }) + }) + } + _ => false, + }; + let no_resolve = previous_colors.iter().all(|v| v.resolve_target.is_none()); + let no_external = previous.outputs.iter().all(|out| { + executions + .iter() + .enumerate() + .filter(|(_, e)| e.inputs.iter().any(|v| v.resource == out.resource)) + .all(|(consumer, _)| consumer == index) + }); + if exact && compatible && no_resolve && no_external { + for (physical, final_value) in previous_colors.iter_mut().zip(&colors) { + physical.resource = final_value.resource; + physical.resolve_target = final_value.resolve_target; + physical.store = final_value.store; + } + if let (Some(physical), Some(final_value)) = + (previous_depth.as_mut(), depth.as_ref()) + { + physical.resource = final_value.resource; + physical.store = final_value.store; + } + members.push(index as u32); + merged = true; + } + } + } + if !merged { + passes.push(PhysicalRenderPass { + executions: vec![index as u32], + kind: PhysicalRenderPassKind::Texture { + color_attachments: colors, + depth_stencil: depth, + }, + }); + } + } + passes +} + fn extent_layers(e: &NormalizedTextureExtent) -> u32 { match e { NormalizedTextureExtent::Absolute { diff --git a/renderer/src/render_graph/contracts.rs b/renderer/src/render_graph/contracts.rs index 50f2dfa..59d28d5 100644 --- a/renderer/src/render_graph/contracts.rs +++ b/renderer/src/render_graph/contracts.rs @@ -95,6 +95,11 @@ pub struct Contract { #[serde(skip)] pub fullscreen_policy: Option, } +impl Contract { + pub const fn is_raster_draw(&self) -> bool { + matches!(self.execution, ExecutionClass::Render) && self.fullscreen_policy.is_none() + } +} use SemanticType::*; const R: InputCardinality = InputCardinality { min: 1, max: 1 }; @@ -138,7 +143,7 @@ const MESH_O: &[OutputSocketContract] = &[ o("localAabb", LocalAabb), ]; const TEXTURE_O: &[OutputSocketContract] = &[o("texture", Texture)]; -const PIPE_I: &[InputSocketContract] = &[ +const RASTER_I: &[InputSocketContract] = &[ i("mesh", MeshData, R, InputRole::SemanticRead), i("predicate", Bool, O, InputRole::Expression), i( @@ -149,7 +154,7 @@ const PIPE_I: &[InputSocketContract] = &[ ), i("depthTarget", Texture, O, InputRole::DepthTarget), ]; -const PIPE_O: &[OutputSocketContract] = &[o("color", Texture), o("depth", Texture)]; +const RASTER_O: &[OutputSocketContract] = &[o("color", Texture), o("depth", Texture)]; const CULL_I: &[InputSocketContract] = &[ i("mesh", MeshData, R, InputRole::Expression), i("localAabb", LocalAabb, R, InputRole::Expression), @@ -202,7 +207,17 @@ pub static CONTRACTS: &[Contract] = &[ c!("mesh", 2, Source, NONE_I, MESH_O, false, None), c!("texture", 2, Source, NONE_I, TEXTURE_O, false, None), c!("frustum_cull", 2, Expression, CULL_I, CULL_O, false, None), - c!("pipeline", 4, Render, PIPE_I, PIPE_O, false, None), + c!("ground_plane", 1, Render, RASTER_I, RASTER_O, false, None), + c!("gltf_standard", 1, Render, RASTER_I, RASTER_O, false, None), + c!( + "gltf_standard_double_sided", + 1, + Render, + RASTER_I, + RASTER_O, + false, + None + ), c!( "and", 2, diff --git a/renderer/src/render_graph/mod.rs b/renderer/src/render_graph/mod.rs index a323b19..de30f26 100644 --- a/renderer/src/render_graph/mod.rs +++ b/renderer/src/render_graph/mod.rs @@ -1,6 +1,7 @@ //! Device-free render graph compiler and compiled graph registry. mod compiler; +pub(crate) use compiler::execution_attachments; mod contracts; mod expression; mod plan; diff --git a/renderer/src/render_graph/plan.rs b/renderer/src/render_graph/plan.rs index 44e8f10..03749b6 100644 --- a/renderer/src/render_graph/plan.rs +++ b/renderer/src/render_graph/plan.rs @@ -11,6 +11,7 @@ pub struct CompiledGraph { pub node_count: u32, pub resources: Vec, pub executions: Vec, + pub render_passes: Vec, pub texture_families: Vec, pub allocation_classes: Vec, pub culled_node_count: u32, @@ -108,16 +109,29 @@ pub struct CompiledSocketOutput { #[derive(Clone, Debug, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ExecutionKind { - Render { + RasterDraw, + Fullscreen, + FrameOut { color: u32 }, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PhysicalRenderPass { + pub executions: Vec, + pub kind: PhysicalRenderPassKind, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PhysicalRenderPassKind { + Texture { color_attachments: Vec, depth_stencil: Option, }, - FrameOut { - color: u32, - }, + Surface, } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ColorAttachmentPlan { pub resource: u32, @@ -127,7 +141,7 @@ pub struct ColorAttachmentPlan { pub store: StoreOp, } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct DepthStencilAttachmentPlan { pub resource: u32, @@ -206,8 +220,8 @@ pub enum NormalizedParameters { ExpressionDefaults { defaults: Vec, }, - Pipeline { - pipeline: String, + Raster { + draw_order: i32, depth_compare: CompareFunction, depth_write_enabled: bool, clear_depth: f32, @@ -472,6 +486,6 @@ pub enum TextureUsage { impl CompiledGraph { pub fn summary(&self, id: [u32; 2]) -> serde_json::Value { - serde_json::json!({"compiledId":id,"graphId":self.graph_id,"revision":self.revision,"schemaVersion":self.schema_version,"nodeCount":self.node_count,"executionCount":self.executions.len(),"resourceCount":self.resources.len(),"culledNodeCount":self.culled_node_count,"culledResourceCount":self.culled_resource_count,"transientSlotCount":self.transient_slot_count}) + serde_json::json!({"compiledId":id,"graphId":self.graph_id,"revision":self.revision,"schemaVersion":self.schema_version,"nodeCount":self.node_count,"executionCount":self.executions.len(),"physicalPassCount":self.render_passes.len(),"resourceCount":self.resources.len(),"culledNodeCount":self.culled_node_count,"culledResourceCount":self.culled_resource_count,"transientSlotCount":self.transient_slot_count}) } } diff --git a/renderer/src/render_graph/runtime.rs b/renderer/src/render_graph/runtime.rs index eb8bf0b..98f8bdf 100644 --- a/renderer/src/render_graph/runtime.rs +++ b/renderer/src/render_graph/runtime.rs @@ -182,6 +182,7 @@ pub struct RuntimeAllocationPlan { pub struct RuntimePlan { pub allocations: RuntimeAllocationPlan, pub executions: Vec, + pub render_passes: Vec, pub instance_traversal: Option, pub surface: RuntimeSurfaceContract, } @@ -365,17 +366,9 @@ 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 { contract(key).is_some_and(|contract| { - contract.fullscreen_policy.is_some() || matches!(key, "pipeline" | "frame_out") + contract.fullscreen_policy.is_some() || contract.is_raster_draw() || key == "frame_out" }) } @@ -524,13 +517,13 @@ fn validate_fullscreen_execution( if output.socket != "color" { return Err(invalid("fullscreen outputs mismatch", path("outputs"))); } - let ExecutionKind::Render { - color_attachments, - depth_stencil: None, - } = &execution.kind - else { + if !matches!(execution.kind, ExecutionKind::Fullscreen) { return Err(invalid("fullscreen render kind mismatch", path("kind"))); - }; + } + let (color_attachments, depth_stencil) = super::compiler::execution_attachments(execution); + if depth_stencil.is_some() { + return Err(invalid("fullscreen depth mismatch", path("kind"))); + } let [attachment] = color_attachments.as_slice() else { return Err(invalid("fullscreen attachment mismatch", path("kind"))); }; @@ -749,7 +742,7 @@ fn validate_pipeline_resolve( .filter(|access| matches!(access.mode, AccessMode::ColorResolve { .. })) .count() == 1; - if producer.executor.key != "pipeline" + if !contract(&producer.executor.key).is_some_and(Contract::is_raster_draw) || producer.original_node_index != *producer_node_index || !exact_output || !matches!(&source.origin, @@ -808,6 +801,123 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { "schemaVersion", )); } + let mut expected_execution = 0usize; + for (pass_index, pass) in graph.render_passes.iter().enumerate() { + if pass.executions.is_empty() { + return Err(invalid( + "physical pass is empty", + format!("renderPasses[{pass_index}]"), + )); + } + for &member in &pass.executions { + if member as usize != expected_execution || expected_execution >= graph.executions.len() + { + return Err(invalid( + "physical passes must partition logical executions in order", + format!("renderPasses[{pass_index}].executions"), + )); + } + expected_execution += 1; + } + let singleton = pass.executions.len() == 1; + let kinds_valid = match &pass.kind { + PhysicalRenderPassKind::Surface => { + singleton + && matches!( + graph.executions[pass.executions[0] as usize].kind, + ExecutionKind::FrameOut { .. } + ) + } + PhysicalRenderPassKind::Texture { + color_attachments, + depth_stencil, + } => { + pass.executions.iter().all(|&member| { + !matches!( + graph.executions[member as usize].kind, + ExecutionKind::FrameOut { .. } + ) + }) && (singleton + || pass.executions.iter().all(|&member| { + matches!( + graph.executions[member as usize].kind, + ExecutionKind::RasterDraw + ) + })) + && color_attachments.iter().all(|attachment| { + (attachment.resource as usize) < graph.resources.len() + && attachment + .resolve_target + .is_none_or(|resource| (resource as usize) < graph.resources.len()) + }) + && depth_stencil.as_ref().is_none_or(|attachment| { + (attachment.resource as usize) < graph.resources.len() + }) + } + }; + if !kinds_valid { + return Err(invalid( + "physical pass kind or attachment is invalid", + format!("renderPasses[{pass_index}]"), + )); + } + } + if expected_execution != graph.executions.len() { + return Err(invalid( + "physical passes omit logical executions", + "renderPasses", + )); + } + for (resource_index, resource) in graph.resources.iter().enumerate() { + if let ResourcePlan::Texture { family, .. } | ResourcePlan::TextureSource { family, .. } = + resource.plan + { + if family as usize >= graph.texture_families.len() { + return Err(invalid( + "texture family is out of bounds", + format!("resources[{resource_index}].plan.family"), + )); + } + } + } + let canonical_passes = super::compiler::build_render_passes( + &graph.executions, + &graph.resources, + &graph.texture_families, + ); + if graph.render_passes != canonical_passes { + return Err(invalid( + "physical render pass plan is not canonical", + "renderPasses", + )); + } + for (pass_index, pass) in graph.render_passes.iter().enumerate() { + if !matches!(pass.kind, PhysicalRenderPassKind::Texture { .. }) { + continue; + } + let mut previous = None; + for &member in &pass.executions { + let execution = &graph.executions[member as usize]; + let NormalizedParameters::Raster { draw_order, .. } = &execution.parameters else { + previous = None; + continue; + }; + let key = (*draw_order, execution.original_node_index); + if previous.is_some_and(|value| value > key) { + return Err(invalid( + "raster pass members are not in canonical draw order", + format!("renderPasses[{pass_index}].executions"), + )); + } + previous = Some(key); + } + } + let execution_pass: Vec = graph + .render_passes + .iter() + .enumerate() + .flat_map(|(pass, value)| value.executions.iter().map(move |_| pass as u32)) + .collect(); let mut producers = vec![None; graph.resources.len()]; let mut uses = vec![BTreeSet::new(); graph.resources.len()]; @@ -878,7 +988,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { format!("executions[{i}]"), ) })? - .insert(i as u32); + .insert(execution_pass[i]); } } for (ri, resource) in graph.resources.iter().enumerate() { @@ -910,6 +1020,52 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { format!("executions[{i}].inputs"), )); } + let consumer_contract = + contract(&execution.executor.key).expect("supported executor has contract"); + let is_attachment = consumer_contract + .inputs + .iter() + .find(|candidate| candidate.name == input.socket) + .is_some_and(|candidate| { + matches!( + candidate.role, + InputRole::ColorTarget { .. } | InputRole::DepthTarget + ) + }); + let predecessor = &graph.executions[producer as usize]; + if is_attachment + && matches!(execution.kind, ExecutionKind::RasterDraw) + && matches!(predecessor.kind, ExecutionKind::RasterDraw) + { + let NormalizedParameters::Raster { + draw_order: predecessor_order, + .. + } = predecessor.parameters + else { + return Err(invalid( + "raster predecessor parameters mismatch", + format!("executions[{producer}].parameters"), + )); + }; + let NormalizedParameters::Raster { + draw_order: consumer_order, + .. + } = execution.parameters + else { + return Err(invalid( + "raster execution parameters mismatch", + format!("executions[{i}].parameters"), + )); + }; + if (predecessor_order, predecessor.original_node_index) + > (consumer_order, execution.original_node_index) + { + return Err(invalid( + "raster attachment predecessors must have canonical draw order", + format!("executions[{i}].parameters.drawOrder"), + )); + } + } } } } @@ -1004,8 +1160,9 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { .count() == 1 }) - && owner_executions[0].executor.key == "pipeline" - && owner_executions[0].executor.version == 4 + && contract(&owner_executions[0].executor.key) + .is_some_and(Contract::is_raster_draw) + && owner_executions[0].executor.version == 1 && graph .executions .iter() @@ -1013,7 +1170,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { .filter(|input| input.resource == source) .count() == 1 - && contract("pipeline") + && contract(&owner_executions[0].executor.key) .and_then(|contract| contract.inputs.get(*input_ordinal as usize)) .is_some_and(|input| { input.name == *socket @@ -1242,7 +1399,11 @@ fn validate_instance_traversal(graph: &CompiledGraph) -> Result<(), GraphError> .executions .iter() .enumerate() - .filter_map(|(i, e)| (e.executor.key == "pipeline").then_some(i as u32)) + .filter_map(|(i, e)| { + contract(&e.executor.key) + .is_some_and(Contract::is_raster_draw) + .then_some(i as u32) + }) .collect(); let Some(plan) = &graph.instance_traversal else { return if pipeline_indices.is_empty() { @@ -1552,7 +1713,7 @@ pub fn prepare_runtime_plan( for (i, execution) in graph.executions.iter().enumerate() { let path = format!("executions[{i}]"); match execution.executor.key.as_str() { - "pipeline" => {} + key if contract(key).is_some_and(Contract::is_raster_draw) => {} _ if contract(&execution.executor.key) .is_some_and(|contract| contract.fullscreen_policy.is_some()) => {} "frame_out" => { @@ -1587,11 +1748,11 @@ pub fn prepare_runtime_plan( validate_instance_traversal(graph)?; for (i, execution) in graph.executions.iter().enumerate() { - if execution.executor.key != "pipeline" { + if !contract(&execution.executor.key).is_some_and(Contract::is_raster_draw) { continue; } - let NormalizedParameters::Pipeline { - pipeline, + let NormalizedParameters::Raster { + draw_order: _, clear_depth, clear_color, .. @@ -1602,8 +1763,7 @@ pub fn prepare_runtime_plan( format!("executions[{i}].parameters"), )); }; - if !valid_pipeline_name(pipeline) - || !clear_depth.is_finite() + if !clear_depth.is_finite() || !(0.0..=1.0).contains(clear_depth) || clear_color.iter().any(|value| !value.is_finite()) { @@ -1612,15 +1772,18 @@ pub fn prepare_runtime_plan( format!("executions[{i}].parameters"), )); } - let ExecutionKind::Render { - color_attachments, - depth_stencil: Some(depth_attachment), - } = &execution.kind - else { + if !matches!(execution.kind, ExecutionKind::RasterDraw) { return Err(invalid( "pipeline render kind mismatch", format!("executions[{i}].kind"), )); + } + let (color_attachments, depth_stencil) = super::compiler::execution_attachments(execution); + let Some(depth_attachment) = depth_stencil.as_ref() else { + return Err(invalid( + "pipeline depth attachment missing", + format!("executions[{i}].kind"), + )); }; let [color_attachment] = color_attachments.as_slice() else { return Err(invalid( @@ -2307,6 +2470,7 @@ pub fn prepare_runtime_plan( resource_allocations, }, executions, + render_passes: graph.render_passes.clone(), instance_traversal: graph.instance_traversal.clone(), surface, }) diff --git a/renderer/src/render_graph/tests.rs b/renderer/src/render_graph/tests.rs index c0b1fd0..669c6b3 100644 --- a/renderer/src/render_graph/tests.rs +++ b/renderer/src/render_graph/tests.rs @@ -49,8 +49,8 @@ pub(crate) fn full_cull_graph() -> Value { node("visible", "not", 1, json!({"operandDefault":false}), json!({"operand":input("cull","isFrustumCulled")})), node("class", "and", 2, json!({}), json!({"inputs":[input("bits","bit0")[0].clone(),input("visible","value")[0].clone()]})), - node("pipeline", "pipeline", 4, - json!({"pipeline":"gltf_standard","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), + node("pipeline", "gltf_standard", 1, + json!({"drawOrder":0,"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), json!({"mesh":input("mesh","mesh"),"predicate":input("class","value"),"colorTarget":input("color","texture"),"depthTarget":input("depth","texture")})), node("frame", "frame_out", 3, json!({"surfaceFormat":"preferred","hdrEnabled":true,"toneMapper":"aces","exposureStops":0, @@ -62,7 +62,16 @@ 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, 4); + assert!(contract("pipeline").is_none()); + for key in [ + "ground_plane", + "gltf_standard", + "gltf_standard_double_sided", + ] { + let contract = contract(key).unwrap(); + assert_eq!(contract.version, 1); + assert!(contract.is_raster_draw()); + } assert_eq!( contract("mesh") .unwrap() @@ -76,7 +85,7 @@ fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() { ("localAabb", SemanticType::LocalAabb) ] ); - let predicate = contract("pipeline") + let predicate = contract("gltf_standard") .unwrap() .inputs .iter() @@ -348,7 +357,7 @@ fn pipeline_predicate_defaults_true_and_expression_edges_are_validated() { .unwrap(); assert!(matches!( pipeline.parameters, - NormalizedParameters::Pipeline { + NormalizedParameters::Raster { predicate_default: true, .. } @@ -359,6 +368,93 @@ fn pipeline_predicate_defaults_true_and_expression_edges_are_validated() { assert_eq!(compile_value(cycle).unwrap_err().code, "GRAPH_CYCLE"); } +#[test] +fn raster_executors_preserve_identity_and_signed_draw_order() { + for (key, draw_order) in [ + ("ground_plane", i32::MIN), + ("gltf_standard", 0), + ("gltf_standard_double_sided", i32::MAX), + ] { + let mut graph = full_cull_graph(); + graph["nodes"][8]["executor"] = json!({"key":key,"version":1}); + graph["nodes"][8]["parameters"]["drawOrder"] = json!(draw_order); + let compiled = compile_value(graph).unwrap(); + let execution = compiled + .executions + .iter() + .find(|e| e.id == "pipeline") + .unwrap(); + assert_eq!(execution.executor.key, key); + assert_eq!(execution.executor.version, 1); + assert!(matches!( + execution.parameters, + NormalizedParameters::Raster { draw_order: value, .. } if value == draw_order + )); + } +} + +#[test] +fn raster_executors_reject_removed_pipeline_parameter() { + let mut graph = full_cull_graph(); + graph["nodes"][8]["parameters"]["pipeline"] = json!("gltf_standard"); + let error = compile_value(graph).unwrap_err(); + assert_eq!(error.code, "GRAPH_PARAMETERS_INVALID"); + assert_eq!(error.details["path"], "nodes[8].parameters"); +} + +#[test] +fn removed_generic_pipeline_executor_is_unknown() { + let mut graph = full_cull_graph(); + graph["nodes"][8]["executor"] = json!({"key":"pipeline","version":4}); + let error = compile_value(graph).unwrap_err(); + assert_eq!(error.code, "GRAPH_UNKNOWN_EXECUTOR"); + assert_eq!(error.details["path"], "nodes[8].executor.key"); +} + +#[test] +fn sibling_raster_writers_form_one_ordered_physical_pass() { + let mut graph = full_cull_graph(); + let nodes = graph["nodes"].as_array_mut().unwrap(); + nodes.insert( + 9, + node( + "sibling", + "ground_plane", + 1, + json!({"drawOrder":1,"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), + json!({"mesh":input("mesh","mesh"),"colorTarget":input("color","texture"),"depthTarget":input("depth","texture")}), + ), + ); + nodes.insert(10, texture("composite", "rgba16_float")); + nodes.insert( + 11, + node( + "combine", + "bloom_composite", + 1, + json!({"intensity":1.0}), + json!({"source":input("pipeline","color"),"bloom":input("sibling","color"),"colorTarget":input("composite","texture")}), + ), + ); + nodes[12]["inputs"]["color"] = input("combine", "color"); + let compiled = compile_value(graph).unwrap(); + assert_eq!( + compiled + .executions + .iter() + .map(|execution| execution.id.as_str()) + .collect::>(), + ["pipeline", "sibling", "combine", "frame"] + ); + assert_eq!(compiled.render_passes[0].executions, [0, 1]); + assert!(matches!( + compiled.render_passes[0].kind, + PhysicalRenderPassKind::Texture { .. } + )); + assert_eq!(compiled.texture_families[0].versions.len(), 2); + assert_eq!(compiled.texture_families[1].versions.len(), 2); +} + #[test] fn expression_provenance_rejects_cross_mesh_values() { let mut graph = full_cull_graph(); @@ -375,11 +471,11 @@ fn expression_provenance_rejects_cross_mesh_values() { fn implicit_pipeline_graph() -> Value { json!({ "schemaVersion": 3, "graphId": "implicit", "revision": 1, "nodes": [ node("mesh", "mesh", 2, json!({}), json!({})), - node("first", "pipeline", 4, - json!({"pipeline":"ground_plane","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), + node("first", "ground_plane", 1, + json!({"drawOrder":0,"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), json!({"mesh":input("mesh","mesh")})), - node("second", "pipeline", 4, - json!({"pipeline":"gltf_standard","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), + node("second", "gltf_standard", 1, + json!({"drawOrder":1,"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]}), @@ -387,10 +483,294 @@ fn implicit_pipeline_graph() -> Value { ]}) } +fn three_raster_graph() -> Value { + let mut graph = full_cull_graph(); + let nodes = graph["nodes"].as_array_mut().unwrap(); + nodes[8]["executor"] = json!({"key":"ground_plane","version":1}); + nodes[8]["parameters"]["drawOrder"] = json!(20); + nodes.insert( + 9, + node( + "standard", + "gltf_standard", + 1, + json!({"drawOrder":10,"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[1,0,0,1],"predicateDefault":true}), + json!({"mesh":input("mesh","mesh"),"colorTarget":input("pipeline","color"),"depthTarget":input("pipeline","depth")}), + ), + ); + nodes.insert( + 10, + node( + "double", + "gltf_standard_double_sided", + 1, + json!({"drawOrder":0,"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":0.5,"clearColor":[0,1,0,1],"predicateDefault":true}), + json!({"mesh":input("mesh","mesh"),"colorTarget":input("standard","color"),"depthTarget":input("standard","depth")}), + ), + ); + nodes[11]["inputs"]["color"] = input("double", "color"); + graph +} + +fn direct_raster_graph(frame_source: &str) -> Value { + let mut graph = three_raster_graph(); + for node in [8, 9, 10] { + graph["nodes"][node]["inputs"]["colorTarget"] = input("color", "texture"); + graph["nodes"][node]["inputs"]["depthTarget"] = input("depth", "texture"); + } + graph["nodes"][11]["inputs"]["color"] = input(frame_source, "color"); + graph +} + +#[test] +fn direct_raster_outputs_all_observe_the_terminal_cohort() { + let mut terminal = None; + for source in ["pipeline", "standard", "double"] { + let graph = compile_value(direct_raster_graph(source)).unwrap(); + assert_eq!( + graph + .executions + .iter() + .map(|execution| execution.id.as_str()) + .collect::>(), + ["double", "standard", "pipeline", "frame"] + ); + assert_eq!(graph.render_passes[0].executions, [0, 1, 2]); + let frame_color = match graph.executions[3].kind { + ExecutionKind::FrameOut { color } => color, + _ => panic!("expected frame output"), + }; + assert_eq!(*terminal.get_or_insert(frame_color), frame_color); + let producers: Vec<_> = graph.executions[..3] + .iter() + .flat_map(|execution| execution.outputs.iter().map(|output| output.resource)) + .collect(); + assert_eq!( + producers + .iter() + .collect::>() + .len(), + 6 + ); + assert!(validate_activatable(&graph).is_ok()); + } +} + +#[test] +fn equal_draw_order_uses_authored_node_order() { + let mut value = direct_raster_graph("pipeline"); + for node in [8, 9, 10] { + value["nodes"][node]["parameters"]["drawOrder"] = json!(7); + } + let graph = compile_value(value).unwrap(); + assert_eq!( + graph.executions[..3] + .iter() + .map(|execution| execution.id.as_str()) + .collect::>(), + ["pipeline", "standard", "double"] + ); + assert_eq!(graph.render_passes[0].executions, [0, 1, 2]); +} + +#[test] +fn dead_fullscreen_attachment_writer_of_observed_raster_output_stays_dead() { + let mut value = direct_raster_graph("pipeline"); + value["nodes"].as_array_mut().unwrap().insert( + 11, + node( + "dead_writer", + "fullscreen_copy", + 1, + json!({}), + json!({ + "source": input("color", "texture"), + "colorTarget": input("pipeline", "color") + }), + ), + ); + + let graph = compile_value(value).unwrap(); + assert!(!graph + .executions + .iter() + .any(|execution| execution.id == "dead_writer")); +} + +#[test] +fn dead_cyclic_fullscreen_reader_does_not_become_live_through_war() { + let mut value = direct_raster_graph("pipeline"); + value["nodes"].as_array_mut().unwrap().insert( + 11, + node( + "dead_reader", + "fullscreen_copy", + 1, + json!({}), + json!({ + "source": input("pipeline", "color"), + "colorTarget": input("dead_reader", "color") + }), + ), + ); + + let graph = compile_value(value).unwrap(); + assert!(!graph + .executions + .iter() + .any(|execution| execution.id == "dead_reader")); +} + +#[test] +fn explicit_raster_chain_compiles_to_one_physical_pass() { + let graph = compile_value(three_raster_graph()).unwrap(); + assert_eq!(graph.executions.len(), 4); + assert!(graph.executions[..3] + .iter() + .all(|execution| matches!(execution.kind, ExecutionKind::RasterDraw))); + assert_eq!(graph.render_passes.len(), 2); + assert_eq!(graph.render_passes[0].executions, [0, 1, 2]); + assert_eq!(graph.render_passes[1].executions, [3]); + let PhysicalRenderPassKind::Texture { + color_attachments, + depth_stencil: Some(depth), + } = &graph.render_passes[0].kind + else { + panic!("expected raster pass") + }; + let first = execution_attachments(&graph.executions[0]); + let final_attachments = execution_attachments(&graph.executions[2]); + assert_eq!(color_attachments[0].load, first.0[0].load); + assert_eq!( + color_attachments[0].resource, + final_attachments.0[0].resource + ); + assert_eq!(color_attachments[0].store, final_attachments.0[0].store); + assert_eq!( + depth.resource, + final_attachments.1.as_ref().unwrap().resource + ); + assert_eq!(depth.store, final_attachments.1.as_ref().unwrap().store); + for execution in &graph.executions[1..3] { + let (color, depth) = execution_attachments(execution); + assert_eq!(color[0].load, NormalizedColorLoad::Load); + assert_eq!(depth.unwrap().load, NormalizedDepthLoad::Load); + } + for socket in ["color", "depth"] { + let resources: Vec<_> = graph.executions[..3] + .iter() + .map(|execution| { + execution + .outputs + .iter() + .find(|output| output.socket == socket) + .unwrap() + .resource + }) + .collect(); + let family = |resource| match graph.resources[resource as usize].plan { + ResourcePlan::Texture { family, .. } => family, + _ => panic!("expected texture version"), + }; + let allocation = |resource| match graph.resources[resource as usize].plan { + ResourcePlan::Texture { allocation, .. } => allocation, + _ => panic!("expected texture version"), + }; + assert!(resources + .iter() + .all(|&resource| family(resource) == family(resources[0]))); + assert!(resources + .iter() + .all(|&resource| allocation(resource) == allocation(resources[0]))); + assert!(resources[..2] + .iter() + .all(|&resource| graph.resources[resource as usize].lifetime + == Some(Lifetime { + first_use: 0, + last_use: 0 + }))); + } + let traversal = graph.instance_traversal.as_ref().unwrap(); + assert_eq!( + traversal + .pipelines + .iter() + .map(|pipeline| (pipeline.execution, pipeline.ordinal)) + .collect::>(), + [(0, 0), (1, 1), (2, 2)] + ); + assert!(validate_activatable(&graph).is_ok()); + assert_eq!( + serde_json::to_value(&graph).unwrap(), + serde_json::to_value(compile_value(three_raster_graph()).unwrap()).unwrap() + ); +} + +#[test] +fn final_msaa_resolve_stays_on_merged_raster_boundary() { + let mut value = three_raster_graph(); + set_sample_count(&mut value["nodes"][0], 4); + set_sample_count(&mut value["nodes"][1], 4); + let graph = compile_value(value).unwrap(); + assert_eq!(graph.render_passes[0].executions, [0, 1, 2]); + let PhysicalRenderPassKind::Texture { + color_attachments, .. + } = &graph.render_passes[0].kind + else { + panic!("expected texture pass") + }; + assert!(color_attachments[0].resolve_target.is_some()); + assert!(execution_attachments(&graph.executions[0]).0[0] + .resolve_target + .is_none()); + assert!(execution_attachments(&graph.executions[1]).0[0] + .resolve_target + .is_none()); + assert!(validate_activatable(&graph).is_ok()); +} + +#[test] +fn runtime_rejects_noncanonical_physical_passes() { + let graph = compile_value(three_raster_graph()).unwrap(); + for mutate in [ + |graph: &mut CompiledGraph| { + graph.render_passes[0].executions.pop(); + }, + |graph: &mut CompiledGraph| { + graph.render_passes[0].executions.push(1); + }, + |graph: &mut CompiledGraph| { + graph.render_passes[0].executions.swap(0, 1); + }, + ] { + let mut invalid = graph.clone(); + mutate(&mut invalid); + assert_runtime_plan_invalid(&invalid); + } + let mut wrong_resource = graph.clone(); + let first_color = execution_attachments(&wrong_resource.executions[0]).0[0].resource; + let PhysicalRenderPassKind::Texture { + color_attachments, .. + } = &mut wrong_resource.render_passes[0].kind + else { + panic!() + }; + color_attachments[0].resource = first_color; + assert_runtime_plan_invalid(&wrong_resource); + + let mut wrong_lifetime = graph; + let intermediate = wrong_lifetime.executions[1].outputs[0].resource; + wrong_lifetime.resources[intermediate as usize].lifetime = Some(Lifetime { + first_use: 1, + last_use: 1, + }); + assert_runtime_plan_invalid(&wrong_lifetime); +} + #[test] fn contract_v4_declares_strict_default_policies() { - let pipeline = contract("pipeline").unwrap(); - assert_eq!(pipeline.version, 4); + let pipeline = contract("gltf_standard").unwrap(); + assert_eq!(pipeline.version, 1); assert_eq!(pipeline.inputs[0].default_policy, InputDefaultPolicy::None); assert_eq!( pipeline.inputs[1].default_policy, @@ -445,13 +825,9 @@ fn disconnected_targets_have_tagged_roots_and_clear_version_zero() { .iter() .find(|e| e.id == "first") .unwrap(); - let ExecutionKind::Render { - color_attachments, - depth_stencil: Some(depth), - } = &first.kind - else { - panic!() - }; + assert!(matches!(first.kind, ExecutionKind::RasterDraw)); + let (color_attachments, depth_stencil) = execution_attachments(first); + let depth = depth_stencil.unwrap(); assert!(matches!( color_attachments[0].load, NormalizedColorLoad::Clear { .. } @@ -468,13 +844,9 @@ fn implicit_chain_is_deterministic_and_loads_successors() { 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!(matches!(second.kind, ExecutionKind::RasterDraw)); + let (color_attachments, depth_stencil) = execution_attachments(second); + let depth = depth_stencil.unwrap(); assert_eq!(color_attachments[0].load, NormalizedColorLoad::Load); assert_eq!(depth.load, NormalizedDepthLoad::Load); } @@ -586,7 +958,8 @@ fn descriptor_dependency_cycle_keeps_graph_cycle_priority() { .as_object_mut() .unwrap() .remove("depthTarget"); - assert_eq!(compile_value(graph).unwrap_err().code, "GRAPH_CYCLE"); + // The backwards authored attachment is normalized by draw order. + assert!(compile_value(graph).is_ok()); } #[test] @@ -595,8 +968,8 @@ fn known_attachment_error_precedes_cycle() { 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"); + assert_eq!(error.code, "GRAPH_ATTACHMENT_LINEAGE_INVALID"); + assert_eq!(error.details["path"], "nodes[8].inputs"); } fn self_targeting_copy(source_format: &str) -> Value { @@ -633,6 +1006,57 @@ fn known_invalid_fullscreen_source_precedes_target_cycle() { ); } +#[test] +fn fullscreen_output_is_a_valid_raster_attachment_root() { + let mut graph = full_cull_graph(); + let nodes = graph["nodes"].as_array_mut().unwrap(); + nodes.insert(9, texture("copy_target", "rgba16_float")); + nodes.insert( + 10, + node( + "copy", + "fullscreen_copy", + 1, + json!({}), + json!({ + "source": input("pipeline", "color"), + "colorTarget": input("copy_target", "texture") + }), + ), + ); + nodes.insert( + 11, + node( + "later", + "ground_plane", + 1, + json!({"drawOrder":1,"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), + json!({ + "mesh": input("mesh", "mesh"), + "colorTarget": input("copy", "color"), + "depthTarget": input("pipeline", "depth") + }), + ), + ); + nodes[12]["inputs"]["color"] = input("later", "color"); + + let compiled = compile_value(graph).unwrap(); + assert_eq!( + compiled + .executions + .iter() + .map(|execution| execution.id.as_str()) + .collect::>(), + ["pipeline", "copy", "later", "frame"] + ); + assert_eq!(compiled.render_passes.len(), 4); + assert!(compiled + .render_passes + .iter() + .all(|pass| pass.executions.len() == 1)); + assert!(validate_activatable(&compiled).is_ok()); +} + #[test] fn known_invalid_fullscreen_target_precedes_source_cycle() { let mut graph = implicit_pipeline_graph(); diff --git a/renderer/src/renderer/executors/pipeline.rs b/renderer/src/renderer/executors/pipeline.rs index 6acfadb..9482030 100644 --- a/renderer/src/renderer/executors/pipeline.rs +++ b/renderer/src/renderer/executors/pipeline.rs @@ -54,7 +54,7 @@ pub(crate) fn encode_compiled( indirect_commands: &wgpu::Buffer, mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>, ) -> Result<(), &'static str> { - use crate::render_graph::{ExecutionKind, NormalizedColorLoad, NormalizedDepthLoad, StoreOp}; + use crate::render_graph::{NormalizedColorLoad, NormalizedDepthLoad, StoreOp}; let view = |resource: u32| -> Result<&wgpu::TextureView, &'static str> { let a = active .runtime @@ -71,176 +71,163 @@ pub(crate) fn encode_compiled( .map(|s| &s.view) .ok_or(" allocation out of bounds") }; - for prepared in &active.executions { - match prepared { - PreparedExecution::Fullscreen { - execution, - frame_out, - bind_group, - pipeline, - .. - } => { - let execution = active + for physical in &active.runtime.render_passes { + let first = *physical.executions.first().ok_or("empty physical pass")? as usize; + let last = *physical.executions.last().ok_or("empty physical pass")? as usize; + let label = if first == last { + active + .graph + .executions + .get(first) + .ok_or("execution out of bounds")? + .id + .clone() + } else { + format!( + "{}..{}", + active .graph .executions - .get(*execution) - .ok_or(" execution out of bounds")?; - let (target, operations) = if *frame_out { - let ExecutionKind::FrameOut { .. } = execution.kind else { - return Err("frame_out kind mismatch"); - }; - ( - surface, - wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - 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 } => { - wgpu::LoadOp::Clear(wgpu::Color { - r: value[0], - g: value[1], - b: value[2], - a: value[3], - }) - } + .get(first) + .ok_or("execution out of bounds")? + .id, + active + .graph + .executions + .get(last) + .ok_or("execution out of bounds")? + .id + ) + }; + let (colors, depth) = match &physical.kind { + crate::render_graph::PhysicalRenderPassKind::Surface => ( + vec![Some(wgpu::RenderPassColorAttachment { + view: surface, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + None, + ), + crate::render_graph::PhysicalRenderPassKind::Texture { + color_attachments, + depth_stencil, + } => { + let colors = color_attachments + .iter() + .map(|color| { + Ok(Some(wgpu::RenderPassColorAttachment { + view: view(color.resource)?, + depth_slice: None, + resolve_target: color.resolve_target.map(view).transpose()?, + ops: wgpu::Operations { + load: match color.load { + NormalizedColorLoad::Load => wgpu::LoadOp::Load, + NormalizedColorLoad::Clear { value } => { + wgpu::LoadOp::Clear(wgpu::Color { + r: value[0], + g: value[1], + b: value[2], + a: value[3], + }) + } + }, + store: if color.store == StoreOp::Store { + wgpu::StoreOp::Store + } else { + wgpu::StoreOp::Discard + }, }, - store: if color.store == StoreOp::Store { - wgpu::StoreOp::Store - } else { - 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, - timestamp_writes: profile - .as_deref_mut() - .and_then(|p| p.render_writes(&execution.id)), - }); - pass.set_pipeline(pipeline); - pass.set_bind_group(0, bind_group, &[]); - pass.draw(0..3, 0..1); + })) + }) + .collect::, &'static str>>()?; + let depth = depth_stencil + .as_ref() + .map(|depth| -> Result<_, &'static str> { + Ok(wgpu::RenderPassDepthStencilAttachment { + view: view(depth.resource)?, + depth_ops: Some(wgpu::Operations { + load: match depth.load { + NormalizedDepthLoad::Load => wgpu::LoadOp::Load, + NormalizedDepthLoad::Clear { value } => { + wgpu::LoadOp::Clear(value) + } + }, + store: if depth.store == StoreOp::Store { + wgpu::StoreOp::Store + } else { + wgpu::StoreOp::Discard + }, + }), + stencil_ops: None, + }) + }) + .transpose()?; + (colors, depth) } - PreparedExecution::Pipeline { - execution, - base, - predicate_ordinal, - variant, - } => { - let execution = active - .graph - .executions - .get(*execution) - .ok_or(" execution out of bounds")?; - let ExecutionKind::Render { - color_attachments, - depth_stencil, - } = &execution.kind - else { - return Err("pipeline is not render"); - }; - 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 { - view: view(color.resource)?, - depth_slice: None, - resolve_target: color.resolve_target.map(view).transpose()?, - ops: wgpu::Operations { - load: match color.load { - NormalizedColorLoad::Load => wgpu::LoadOp::Load, - NormalizedColorLoad::Clear { value } => { - wgpu::LoadOp::Clear(wgpu::Color { - r: value[0], - g: value[1], - b: value[2], - a: value[3], - }) - } - }, - store: if color.store == StoreOp::Store { - wgpu::StoreOp::Store - } else { - wgpu::StoreOp::Discard - }, - }, - })], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: view(depth.resource)?, - depth_ops: Some(wgpu::Operations { - load: match depth.load { - NormalizedDepthLoad::Load => wgpu::LoadOp::Load, - NormalizedDepthLoad::Clear { value } => wgpu::LoadOp::Clear(value), - }, - store: if depth.store == StoreOp::Store { - wgpu::StoreOp::Store - } else { - wgpu::StoreOp::Discard - }, - }), - stencil_ops: None, - }), - occlusion_query_set: None, - timestamp_writes: profile - .as_deref_mut() - .and_then(|p| p.render_writes(&execution.id)), - }); - for (i, group) in scene.bind_groups().iter().enumerate() { - pass.set_bind_group(i as u32, group, &[]); + }; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(&label), + color_attachments: &colors, + depth_stencil_attachment: depth, + occlusion_query_set: None, + timestamp_writes: profile.as_deref_mut().and_then(|p| p.render_writes(&label)), + }); + for &member in &physical.executions { + match active + .executions + .get(member as usize) + .ok_or("prepared execution out of bounds")? + { + PreparedExecution::Fullscreen { + bind_group, + pipeline, + .. + } => { + pass.set_pipeline(pipeline); + pass.set_bind_group(0, bind_group, &[]); + pass.draw(0..3, 0..1); } - if let (Some(p), Some(n), Some(u), Some(t), Some(ix), Some(inst)) = ( - &gpu.positions.buffer, - &gpu.normals.buffer, - &gpu.uvs.buffer, - &gpu.tangents.buffer, - &gpu.indices.buffer, - &gpu.instances.buffer, - ) { - pass.set_vertex_buffer(0, p.slice(..)); - pass.set_vertex_buffer(1, n.slice(..)); - pass.set_vertex_buffer(2, u.slice(..)); - pass.set_vertex_buffer(4, t.slice(..)); - pass.set_index_buffer(ix.slice(..), wgpu::IndexFormat::Uint32); - pass.set_vertex_buffer(3, inst.slice(..)); - for (draw_index, draw) in gpu.draws.iter().enumerate() { - pass.set_pipeline(variant); - if pipelines.requires_material(*base) { - pass.set_bind_group(2, materials.group(draw.material), &[]); + PreparedExecution::Pipeline { + base, + predicate_ordinal, + variant, + .. + } => { + for (i, group) in scene.bind_groups().iter().enumerate() { + pass.set_bind_group(i as u32, group, &[]); + } + if let (Some(p), Some(n), Some(u), Some(t), Some(ix), Some(inst)) = ( + &gpu.positions.buffer, + &gpu.normals.buffer, + &gpu.uvs.buffer, + &gpu.tangents.buffer, + &gpu.indices.buffer, + &gpu.instances.buffer, + ) { + pass.set_vertex_buffer(0, p.slice(..)); + pass.set_vertex_buffer(1, n.slice(..)); + pass.set_vertex_buffer(2, u.slice(..)); + pass.set_vertex_buffer(4, t.slice(..)); + pass.set_index_buffer(ix.slice(..), wgpu::IndexFormat::Uint32); + pass.set_vertex_buffer(3, inst.slice(..)); + for (draw_index, draw) in gpu.draws.iter().enumerate() { + pass.set_pipeline(variant); + if pipelines.requires_material(*base) { + pass.set_bind_group(2, materials.group(draw.material), &[]); + } + pass.draw_indexed_indirect( + indirect_commands, + crate::renderer::instance_traversal::command_offset( + *predicate_ordinal, + gpu.draws.len(), + draw_index, + ), + ); } - pass.draw_indexed_indirect( - indirect_commands, - crate::renderer::instance_traversal::command_offset( - *predicate_ordinal, - gpu.draws.len(), - draw_index, - ), - ); } } } diff --git a/renderer/src/renderer/mod.rs b/renderer/src/renderer/mod.rs index dc98a0b..0cf47be 100644 --- a/renderer/src/renderer/mod.rs +++ b/renderer/src/renderer/mod.rs @@ -815,14 +815,11 @@ struct GpuTextureSlot { enum PreparedExecution { Pipeline { - execution: usize, base: crate::render_data::PipelineKey, predicate_ordinal: u32, variant: wgpu::RenderPipeline, }, Fullscreen { - execution: usize, - frame_out: bool, bind_group: wgpu::BindGroup, pipeline: wgpu::RenderPipeline, _uniform: wgpu::Buffer, @@ -1687,17 +1684,17 @@ impl Renderer { .iter() .enumerate() .map(|(index, execution)| { - let NormalizedParameters::Pipeline { pipeline, .. } = &execution.parameters else { + let NormalizedParameters::Raster { .. } = &execution.parameters else { return Ok(None); }; self.resources - .find_pipeline(pipeline) + .find_pipeline(&execution.executor.key) .map(Some) .ok_or_else(|| { GraphError::at( "GRAPH_EXECUTION_UNSUPPORTED", - format!("pipeline '{pipeline}' is not registered"), - format!("executions[{index}].parameters.pipeline"), + format!("pipeline '{}' is not registered", execution.executor.key), + format!("executions[{index}].executor.key"), ) }) }) @@ -1952,12 +1949,11 @@ impl Renderer { let target_format = if frame_out { runtime.surface.format } else { - let ExecutionKind::Render { - color_attachments, .. - } = &execution.kind - else { + if !matches!(execution.kind, ExecutionKind::Fullscreen) { return Err(fail("fullscreen execution is not render")); - }; + } + let (color_attachments, _) = + crate::render_graph::execution_attachments(execution); let target = color_attachments .first() .ok_or_else(|| fail("fullscreen target missing"))? @@ -2045,21 +2041,17 @@ impl Renderer { ], }); executions.push(PreparedExecution::Fullscreen { - execution: index, - frame_out, bind_group, pipeline, _uniform: uniform, }); } - "pipeline" => { - let ExecutionKind::Render { - color_attachments, - depth_stencil, - } = &execution.kind - else { + _ if contract.is_raster_draw() => { + if !matches!(execution.kind, ExecutionKind::RasterDraw) { return Err(fail("pipeline is not render")); - }; + } + let (color_attachments, depth_stencil) = + crate::render_graph::execution_attachments(execution); let color = color_attachments .first() .ok_or_else(|| fail("pipeline color missing"))?; @@ -2098,8 +2090,8 @@ impl Renderer { .ok_or_else(|| fail("depth allocation invalid")) }) .transpose()?; - let NormalizedParameters::Pipeline { - pipeline: _, + let NormalizedParameters::Raster { + draw_order: _, depth_compare, depth_write_enabled, .. @@ -2145,7 +2137,6 @@ impl Renderer { ) .map_err(|e| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", e))?; executions.push(PreparedExecution::Pipeline { - execution: index, base, predicate_ordinal: runtime .instance_traversal diff --git a/renderer/src/renderer/profiler.rs b/renderer/src/renderer/profiler.rs index b2d367d..69a1ee8 100644 --- a/renderer/src/renderer/profiler.rs +++ b/renderer/src/renderer/profiler.rs @@ -4,13 +4,16 @@ use std::{ }; use wasm_bindgen::JsValue; -pub const MAX_PROFILE_PASSES: usize = crate::render_graph::MAX_EXECUTIONS; +// A frame can contain every logical execution as a singleton physical pass and +// one instance-traversal compute pass. +pub const MAX_PROFILE_PASSES: usize = crate::render_graph::MAX_EXECUTIONS + 1; #[cfg(any(target_arch = "wasm32", test))] const SLOT_COUNT: usize = 4; #[cfg(any(target_arch = "wasm32", test))] const QUERY_COUNT: u32 = (MAX_PROFILE_PASSES * 2) as u32; #[cfg(any(target_arch = "wasm32", test))] -const RESOLVE_SIZE: u64 = QUERY_COUNT as u64 * 8; +const USED_RESOLVE_SIZE: u64 = QUERY_COUNT as u64 * 8; +const RESOLVE_SIZE: u64 = USED_RESOLVE_SIZE.next_multiple_of(wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT); #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SlotState { @@ -396,7 +399,9 @@ mod tests { #[test] fn capacity_is_aligned() { assert_eq!(RESOLVE_SIZE % wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, 0); - assert_eq!(QUERY_COUNT, 2048) + assert!(RESOLVE_SIZE >= USED_RESOLVE_SIZE); + assert!(RESOLVE_SIZE - USED_RESOLVE_SIZE < wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT); + assert_eq!(QUERY_COUNT as usize, MAX_PROFILE_PASSES * 2) } #[test] fn lazy_identity_when_disabled_unavailable_or_full() { diff --git a/static/render-graph/add-node-menu.js b/static/render-graph/add-node-menu.js index 3963ca7..829a331 100644 --- a/static/render-graph/add-node-menu.js +++ b/static/render-graph/add-node-menu.js @@ -1,4 +1,4 @@ -import { semanticCatalog } from "./catalog.js"; +import { NODE_TITLE_OVERRIDES, semanticCatalog } from "./catalog.js"; const GROUPS = Object.freeze([ ["source", "Source"], @@ -16,7 +16,7 @@ export const addNodeItems = Object.freeze( GROUPS.flatMap(([execution, group]) => Object.entries(semanticCatalog) .filter(([, definition]) => definition.execution === execution) - .map(([typeId]) => Object.freeze({ typeId, title: title(typeId), group })), + .map(([typeId]) => Object.freeze({ typeId, title: NODE_TITLE_OVERRIDES[typeId] ?? title(typeId), group })), ), ); diff --git a/static/render-graph/catalog.js b/static/render-graph/catalog.js index f32c5fa..49afc3b 100644 --- a/static/render-graph/catalog.js +++ b/static/render-graph/catalog.js @@ -1,5 +1,5 @@ export const GRAPH_ID = "authored_gpu_culling"; -export const CATALOG_VERSION = 11; +export const CATALOG_VERSION = 12; const exact = (type) => ({ kind: "exact", types: [type] }); const i = (type, minimum = 1, authoringType, defaultPolicy = minimum ? "none" : "parameter_literal", maximum = 1) => ({ accepted: typeof type === "string" ? exact(type) : type, @@ -63,6 +63,20 @@ const texture = { viewFormats: [], }, }; +const rasterInputs = () => ({ + mesh: i("mesh_data"), + predicate: i("bool", 0), + colorTarget: i("texture", 0, undefined, "compiler_texture"), + depthTarget: i("texture", 0, undefined, "compiler_texture"), +}); +const rasterOutputs = () => ({ color: o("texture"), depth: o("texture") }); +const rasterParameters = () => ({ drawOrder: 0, depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1] }); +const raster = () => ({ version: 1, execution: "render", inputs: rasterInputs(), outputs: rasterOutputs(), parameters: rasterParameters() }); +export const NODE_TITLE_OVERRIDES = Object.freeze({ + ground_plane: "Ground Plane", + gltf_standard: "glTF Standard", + gltf_standard_double_sided: "glTF Standard — Double-Sided", +}); export const semanticCatalog = Object.freeze({ mesh: { version: 2, @@ -107,18 +121,9 @@ export const semanticCatalog = Object.freeze({ outputs: { isFrustumCulled: o("bool") }, parameters: { cameraSelection: "active" }, }, - pipeline: { - version: 4, - execution: "render", - inputs: { - mesh: i("mesh_data"), - predicate: i("bool", 0), - colorTarget: i("texture", 0, undefined, "compiler_texture"), - depthTarget: i("texture", 0, 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] }, - }, + ground_plane: raster(), + gltf_standard: raster(), + gltf_standard_double_sided: raster(), ...expressionCatalog, fullscreen_copy: { version: 1, @@ -292,7 +297,6 @@ const enumeration = (value, values) => ({ default: tagged("string", value), enum: values, }); -const string = (value) => ({ type: "string", default: tagged("string", value) }); const boolean = (value) => ({ type: "boolean", default: tagged("boolean", value), @@ -327,7 +331,7 @@ const zero = (type) => { Array.from({ length: size }, (_, row) => Number(column === row))); }; const defaultForInput = (key, name, type) => { - if (key === "pipeline" && name === "predicate") return true; + if (["ground_plane", "gltf_standard", "gltf_standard_double_sided"].includes(key) && name === "predicate") return true; if (key === "and") return true; if (/^combine_mat[234]$/.test(key)) { const index = Number(name.replace("column", "")); @@ -374,8 +378,8 @@ const parameterSchemas = { }, mesh: {}, frustum_cull: { cameraSelection: enumeration("active", ["active"]) }, - pipeline: { - pipeline: string("gltf_standard"), + ground_plane: { + drawOrder: { ...number(0, -2147483648, 2147483647), integer: true }, depthCompare: enumeration("less_equal", [ "never", "less", @@ -418,6 +422,8 @@ const parameterSchemas = { }, }; for (const key of Object.keys(expressionCatalog)) parameterSchemas[key] = {}; +parameterSchemas.gltf_standard = structuredClone(parameterSchemas.ground_plane); +parameterSchemas.gltf_standard_double_sided = structuredClone(parameterSchemas.ground_plane); export const nodeDefinitions = Object.fromEntries( Object.entries(semanticCatalog).map(([key, c]) => { const sockets = { @@ -453,7 +459,7 @@ export const nodeDefinitions = Object.fromEntries( key, { version: c.version, - title: key.replaceAll("_", " "), + title: NODE_TITLE_OVERRIDES[key] ?? key.replaceAll("_", " "), behavior: "standard", style: c.execution, parameters, @@ -475,6 +481,13 @@ export const nodeDefinitions = Object.fromEntries( }), ); nodeDefinitions.mesh.sockets.localAabb.title = "Local AABB"; +for (const key of Object.keys(NODE_TITLE_OVERRIDES)) { + for (const item of nodeDefinitions[key].ui) { + if (item.parameter === "drawOrder") item.title = "Draw Order"; + if (item.parameter === "clearColor") item.title = "Initial Color"; + if (item.parameter === "clearDepth") item.title = "Initial Depth"; + } +} nodeDefinitions.color_balance.ui = [ { kind: "parameter", parameter: "mode" }, { kind: "widget", widget: "grading-wheels", bindings: [ diff --git a/static/render-graph/presets.js b/static/render-graph/presets.js index 25fa3cf..315e194 100644 --- a/static/render-graph/presets.js +++ b/static/render-graph/presets.js @@ -31,20 +31,20 @@ const predicates = (withCulling = false) => { result.find((item) => item.id === id).inputs.inputs.push(...input("not_culled", "value")); return { nodes: result, classes: { ground: "ground_class", pbr: "standard_class", pbr_double: "double_class" } }; }; -const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1, withCulling = false) => { +const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1, withCulling = false, sampleCount = 1) => { const classification = predicates(withCulling); - const explicit = colorTarget || heightScale !== 1; const target = colorTarget || "hdr"; return [ - ...(explicit && !colorTarget ? [node("hdr", "texture", texture("rgba16_float", 1, heightScale))] : []), + ...(!colorTarget ? [node("hdr", "texture", texture("rgba16_float", 1, heightScale, sampleCount))] : []), + node("scene_depth", "texture", texture("depth32_float", 1, heightScale, sampleCount)), 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"), ...(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") }), + node("ground", "ground_plane", { drawOrder: 0, depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.ground, "value"), colorTarget: input(target, "texture"), depthTarget: input("scene_depth", "texture") }), + node("pbr", "gltf_standard", { drawOrder: 1, depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.pbr, "value"), colorTarget: input(target, "texture"), depthTarget: input("scene_depth", "texture") }), + node("pbr_double", "gltf_standard_double_sided", { drawOrder: 2, depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.pbr_double, "value"), colorTarget: input(target, "texture"), depthTarget: input("scene_depth", "texture") }), ]; }; -const graph = (graphId, nodes) => Object.freeze({ schemaVersion: 3, graphId, revision: 2, nodes }); +const graph = (graphId, nodes) => Object.freeze({ schemaVersion: 3, graphId, revision: 3, nodes }); const direct = (graphId, clearColor) => graph(graphId, [ node("ldr", "texture", texture("rgba8_unorm")), ...scene("ldr", clearColor), @@ -58,7 +58,7 @@ export const hdr = graph("preset_hdr_fullscreen", [ ]); export const msaa = graph("preset_msaa", [ node("msaa_hdr", "texture", texture("rgba16_float", 1, 1, 4)), - ...scene("msaa_hdr"), + ...scene("msaa_hdr", undefined, 1, false, 4), node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") }), ]); export const culling = graph("preset_gpu_culling", (() => { diff --git a/tests/add-node-menu.test.js b/tests/add-node-menu.test.js index 70a51a0..fc2ce50 100644 --- a/tests/add-node-menu.test.js +++ b/tests/add-node-menu.test.js @@ -11,12 +11,14 @@ import { } from "../static/render-graph/node-spawn.js"; test("add-node model contains all final catalog types in application groups", () => { - assert.equal(addNodeItems.length, 42); + assert.equal(addNodeItems.length, 44); assert.deepEqual( [...new Set(addNodeItems.map((item) => item.group))], ["Source", "Expression", "Render / post", "Frame"], ); - assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 42); + assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 44); + assert.deepEqual(addNodeItems.filter((item) => ["ground_plane", "gltf_standard", "gltf_standard_double_sided"].includes(item.typeId)).map((item) => item.title), ["Ground Plane", "glTF Standard", "glTF Standard — Double-Sided"]); + assert.ok(!addNodeItems.some((item) => item.typeId === "pipeline" || item.title === "Pipeline")); assert.ok(addNodeItems.some((item) => item.typeId === "separate_u32_bits" && item.group === "Expression")); assert.ok(!addNodeItems.some((item) => ["mesh_query", "pipeline_registry"].includes(item.typeId))); assert.deepEqual(searchAddNodeItems("no such node"), []); diff --git a/tests/fxnode-composition.test.js b/tests/fxnode-composition.test.js index c4f4443..262f349 100644 --- a/tests/fxnode-composition.test.js +++ b/tests/fxnode-composition.test.js @@ -36,8 +36,8 @@ 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, 11); - assert.equal(Object.keys(fxNodeComposition.nodes).length, 42); + assert.equal(fxNodeComposition.version, 12); + assert.equal(Object.keys(fxNodeComposition.nodes).length, 44); assert.ok( Object.values(fxNodeComposition.nodes).every( (definition) => definition.migrations.length === 0, diff --git a/tests/render-graph-authoring.test.js b/tests/render-graph-authoring.test.js index bf4acbf..fe526ed 100644 --- a/tests/render-graph-authoring.test.js +++ b/tests/render-graph-authoring.test.js @@ -30,15 +30,18 @@ const authoredLink = (id, from, to = "target", muted = false) => ({ toNodeId: to, toSocketId: `${to}:inputs`, muted, extensions: {}, }); -test("catalog v11 exposes the final mesh, pipeline, and typed-expression contracts", () => { - assert.equal(CATALOG_VERSION, 11); +test("catalog v12 exposes raster and typed-expression contracts", () => { + assert.equal(CATALOG_VERSION, 12); 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, 4); - assert.deepEqual(semanticCatalog.pipeline.inputs.predicate.cardinality, { minimum: 0, maximum: 1 }); + assert.equal(semanticCatalog.pipeline, undefined); + for (const key of ["ground_plane", "gltf_standard", "gltf_standard_double_sided"]) { + assert.equal(semanticCatalog[key].version, 1); + assert.deepEqual(semanticCatalog[key].inputs.predicate.cardinality, { minimum: 0, maximum: 1 }); + } assert.deepEqual(semanticCatalog.and.inputs.inputs.cardinality, { minimum: 0, maximum: 8 }); assert.equal(nodeDefinitions.and.sockets.inputs.maxIncomingLinks, 8); assert.equal(nodeDefinitions.and.sockets.inputs.value, null); @@ -52,21 +55,34 @@ test("catalog v11 exposes the final mesh, pipeline, and typed-expression contrac } }); +test("raster declarations own their defaults and sockets", () => { + const keys = ["ground_plane", "gltf_standard", "gltf_standard_double_sided"]; + assert.deepEqual(keys.map((key) => nodeDefinitions[key].title), [ + "Ground Plane", "glTF Standard", "glTF Standard — Double-Sided", + ]); + assert.notStrictEqual(semanticCatalog.ground_plane.inputs, semanticCatalog.gltf_standard.inputs); + assert.notStrictEqual(nodeDefinitions.ground_plane.sockets, nodeDefinitions.gltf_standard.sockets); + assert.notStrictEqual(nodeDefinitions.ground_plane.parameters, nodeDefinitions.gltf_standard.parameters); + assert.notStrictEqual(nodeDefinitions.ground_plane.parameters.clearColor.default.value, + nodeDefinitions.gltf_standard.parameters.clearColor.default.value); + assert.equal("pipeline" in nodeDefinitions.gltf_standard.parameters, false); +}); + test("current culling fixture uses type-bit predicates and final socket versions", () => { const byId = Object.fromEntries(culling.nodes.map((node) => [node.id, node])); assert.deepEqual(byId.cull.inputs.localAabb, [{ node: "mesh", socket: "localAabb" }]); assert.deepEqual(byId.type_words.inputs.value, [{ node: "mesh", socket: "type" }]); - assert.equal(byId.ground.executor.version, 4); + assert.equal(byId.ground.executor.version, 1); assert.equal(byId.ground.inputs.predicate[0].node, "ground_class"); }); 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.gltf_standard.inputs[socket].defaultPolicy, "compiler_texture"); + assert.equal(nodeDefinitions.gltf_standard.sockets[socket].default, undefined); } - assert.equal(semanticCatalog.pipeline.inputs.predicate.defaultPolicy, "parameter_literal"); - assert.notEqual(nodeDefinitions.pipeline.sockets.predicate.default, null); + assert.equal(semanticCatalog.gltf_standard.inputs.predicate.defaultPolicy, "parameter_literal"); + assert.notEqual(nodeDefinitions.gltf_standard.sockets.predicate.default, null); }); test("removed architecture is absent from the authoring catalog", () => { diff --git a/tests/render-graph-presets.test.js b/tests/render-graph-presets.test.js index 9d56fb3..46e763b 100644 --- a/tests/render-graph-presets.test.js +++ b/tests/render-graph-presets.test.js @@ -6,7 +6,7 @@ import { descriptors } from "../static/render-graph/catalog.js"; test("all presets use current schemas, versions, and one frame output", () => { assert.equal(Object.keys(presets.renderGraphPresets).length, 13); for (const [name, graph] of Object.entries(presets.renderGraphPresets)) { - assert.deepEqual([graph.schemaVersion, graph.revision], [3, 2], name); + assert.deepEqual([graph.schemaVersion, graph.revision], [3, 3], name); assert.equal(new Set(graph.nodes.map((node) => node.id)).size, graph.nodes.length, name); assert.equal(graph.nodes.filter((node) => node.executor.key === "frame_out").length, 1, name); for (const node of graph.nodes) @@ -15,7 +15,7 @@ test("all presets use current schemas, versions, and one frame output", () => { const authoredX4 = Object.entries(presets.renderGraphPresets).flatMap(([name, graph]) => graph.nodes.filter((node) => node.parameters?.texture?.sampleCount === 4) .map((node) => `${name}:${node.id}`)); - assert.deepEqual(authoredX4, ["msaa:msaa_hdr"]); + assert.deepEqual(authoredX4, ["msaa:msaa_hdr", "msaa:scene_depth"]); assert.equal(typeof presets.msaa.nodes.find((node) => node.id === "msaa_hdr") .parameters.texture.sampleCount, "number"); }); @@ -30,7 +30,7 @@ test("presets classify demo-owned enable and material bits through type.words[0] assert.deepEqual(byId.pbr_double.inputs.predicate, [{ node: "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, 4); + assert.equal(pipeline.executor.version, 1); } } }); @@ -45,14 +45,20 @@ test("culling adds a local-AABB expression to each material predicate", () => { assert.equal(byId[id].inputs.inputs.at(-1).node, "not_culled"); }); -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); +test("scene pipelines directly share matching explicit color and depth targets", () => { + for (const [name, graph] of Object.entries(presets.renderGraphPresets)) { + const byId = Object.fromEntries(graph.nodes.map((node) => [node.id, node])); + const color = byId.ground.inputs.colorTarget; + const depth = byId.ground.inputs.depthTarget; + for (const id of ["ground", "pbr", "pbr_double"]) { + assert.deepEqual(byId[id].inputs.colorTarget, color, `${name}:${id}`); + assert.deepEqual(byId[id].inputs.depthTarget, depth, `${name}:${id}`); + assert.equal(["ground", "pbr", "pbr_double"].includes(color[0].node), false, name); + } + const colorDescriptor = byId[color[0].node].parameters.texture; + const depthDescriptor = byId[depth[0].node].parameters.texture; + assert.equal(depthDescriptor.format, "depth32_float", name); + assert.deepEqual(depthDescriptor.extent, colorDescriptor.extent, name); + assert.equal(depthDescriptor.sampleCount, colorDescriptor.sampleCount, name); } - const midnight = Object.fromEntries(presets.midnight.nodes.map((node) => [node.id, node])); - assert.deepEqual(midnight.ground.inputs.colorTarget, [{ node: "ldr", socket: "texture" }]); });