feat: compile pipeline-specific render pass cohorts

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

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-29 14:49:22 +00:00
co-authored by heaust
parent 27e3dd64ae
commit 780f194be4
16 changed files with 1519 additions and 376 deletions
+563 -58
View File
@@ -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<usize>],
ordering_outgoing: &[Vec<usize>],
edges: &[DependencyEdge],
live: &HashSet<usize>,
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<NormalizedParameters, GraphError> {
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<NormalizedParameters, GraphError> {
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<CompiledGraph, GraphError> {
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::<OutputKey, Vec<usize>>::new();
let mut ordinary_consumers = HashMap::<OutputKey, Vec<usize>>::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<AttachmentRoot, GraphError> {
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::<OutputKey, OutputKey>::new();
for ordinal in 0..=1u16 {
let mut colors = HashMap::new();
let mut roots = HashMap::new();
let mut cohorts = BTreeMap::<AttachmentRoot, Vec<usize>>::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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
let mut default_roots: Vec<DefaultDraft> = 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<CompiledGraph, GraphError> {
),
]
.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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
i,
next.writer_node,
&outgoing_edges,
&ordering_outgoing,
&edges,
&live,
&mut reachability,
@@ -1587,7 +1898,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
// 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,11 +2091,22 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
}
// 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()
+ ordering_edges
.iter()
.filter(|(from, to)| *to == node && live.contains(from))
.count();
}
let mut queue = BinaryHeap::new();
@@ -1803,6 +2125,12 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
fn cycle_dfs(
node: usize,
outgoing_edges: &[Vec<usize>],
ordering_outgoing: &[Vec<usize>],
edges: &[DependencyEdge],
residual: &[bool],
colors: &mut [u8],
@@ -1829,6 +2158,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
if let Some(cycle) = cycle_dfs(
to,
outgoing_edges,
ordering_outgoing,
edges,
residual,
colors,
@@ -1848,6 +2178,31 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
cycle = cycle_dfs(
node,
&outgoing_edges,
&ordering_outgoing,
&edges,
&residual,
&mut colors,
@@ -2189,15 +2545,15 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
.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 &params[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<CompiledGraph, GraphError> {
},
});
}
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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
}
}
}
// Dense lifetimes touch bindings, outputs, and accesses.
let render_passes = build_render_passes(&executions, &resources, &families);
let execution_pass: Vec<u32> = 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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
})
}
pub(crate) fn execution_attachments(
execution: &CompiledExecution,
) -> (Vec<ColorAttachmentPlan>, Option<DepthStencilAttachmentPlan>) {
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<PhysicalRenderPass> {
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<PhysicalRenderPass> = 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 {
+18 -3
View File
@@ -95,6 +95,11 @@ pub struct Contract {
#[serde(skip)]
pub fullscreen_policy: Option<FullscreenPolicy>,
}
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,
+1
View File
@@ -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;
+23 -9
View File
@@ -11,6 +11,7 @@ pub struct CompiledGraph {
pub node_count: u32,
pub resources: Vec<CompiledResource>,
pub executions: Vec<CompiledExecution>,
pub render_passes: Vec<PhysicalRenderPass>,
pub texture_families: Vec<TextureFamily>,
pub allocation_classes: Vec<AllocationClass>,
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<u32>,
pub kind: PhysicalRenderPassKind,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PhysicalRenderPassKind {
Texture {
color_attachments: Vec<ColorAttachmentPlan>,
depth_stencil: Option<DepthStencilAttachmentPlan>,
},
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<TypedLiteral>,
},
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})
}
}
+196 -32
View File
@@ -182,6 +182,7 @@ pub struct RuntimeAllocationPlan {
pub struct RuntimePlan {
pub allocations: RuntimeAllocationPlan,
pub executions: Vec<RuntimeExecution>,
pub render_passes: Vec<PhysicalRenderPass>,
pub instance_traversal: Option<InstanceTraversalPlan>,
pub surface: RuntimeSurfaceContract,
}
@@ -365,17 +366,9 @@ fn invalid(message: impl Into<String>, path: impl Into<String>) -> 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<u32> = 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,
})
+452 -28
View File
@@ -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::<Vec<_>>(),
["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::<Vec<_>>(),
["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::<std::collections::BTreeSet<_>>()
.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::<Vec<_>>(),
["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::<Vec<_>>(),
[(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::<Vec<_>>(),
["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();
+84 -97
View File
@@ -54,7 +54,7 @@ pub(crate) fn encode_compiled<T: Scene>(
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,104 +71,55 @@ pub(crate) fn encode_compiled<T: Scene>(
.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(*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");
.get(first)
.ok_or("execution out of bounds")?
.id
.clone()
} else {
format!(
"{}..{}",
active
.graph
.executions
.get(first)
.ok_or("execution out of bounds")?
.id,
active
.graph
.executions
.get(last)
.ok_or("execution out of bounds")?
.id
)
};
(
surface,
wgpu::Operations {
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,
},
)
} 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],
})
}
},
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);
}
PreparedExecution::Pipeline {
execution,
base,
predicate_ordinal,
variant,
} => {
let execution = active
.graph
.executions
.get(*execution)
.ok_or(" execution out of bounds")?;
let ExecutionKind::Render {
None,
),
crate::render_graph::PhysicalRenderPassKind::Texture {
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 {
} => {
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()?,
@@ -190,13 +141,20 @@ pub(crate) fn encode_compiled<T: Scene>(
wgpu::StoreOp::Discard
},
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
}))
})
.collect::<Result<Vec<_>, &'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),
NormalizedDepthLoad::Clear { value } => {
wgpu::LoadOp::Clear(value)
}
},
store: if depth.store == StoreOp::Store {
wgpu::StoreOp::Store
@@ -205,12 +163,40 @@ pub(crate) fn encode_compiled<T: Scene>(
},
}),
stencil_ops: None,
}),
})
})
.transpose()?;
(colors, depth)
}
};
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(&execution.id)),
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);
}
PreparedExecution::Pipeline {
base,
predicate_ordinal,
variant,
..
} => {
for (i, group) in scene.bind_groups().iter().enumerate() {
pass.set_bind_group(i as u32, group, &[]);
}
@@ -246,6 +232,7 @@ pub(crate) fn encode_compiled<T: Scene>(
}
}
}
}
Ok(())
}
+15 -24
View File
@@ -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<T: Scene + 'static> Renderer<T> {
.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<T: Scene + 'static> Renderer<T> {
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<T: Scene + 'static> Renderer<T> {
],
});
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<T: Scene + 'static> Renderer<T> {
.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<T: Scene + 'static> Renderer<T> {
)
.map_err(|e| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", e))?;
executions.push(PreparedExecution::Pipeline {
execution: index,
base,
predicate_ordinal: runtime
.instance_traversal
+8 -3
View File
@@ -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() {
+2 -2
View File
@@ -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 })),
),
);
+31 -18
View File
@@ -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: [
+8 -8
View File
@@ -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", (() => {
+4 -2
View File
@@ -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"), []);
+2 -2
View File
@@ -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,
+25 -9
View File
@@ -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", () => {
+18 -12
View File
@@ -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" }]);
});