From b5366d8b77f9f6419faf12b2d56014c3ba16dc97 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 29 Jul 2026 00:48:35 +0000 Subject: [PATCH] feat: compile multisampled graph attachments Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab Co-authored-by: Heaust Azure --- renderer/src/render_graph/compiler.rs | 333 ++++++++++++++---- renderer/src/render_graph/contracts.rs | 4 +- renderer/src/render_graph/plan.rs | 17 + renderer/src/render_graph/runtime.rs | 358 ++++++++++++++++---- renderer/src/render_graph/tests.rs | 216 +++++++++++- renderer/src/renderer/executors/pipeline.rs | 2 +- renderer/src/renderer/mod.rs | 103 ++++++ renderer/src/renderer/pipeline_library.rs | 92 +++-- static/index.html | 1 + static/render-graph/catalog.js | 6 +- static/render-graph/presets.js | 11 +- tests/fxnode-composition.test.js | 2 +- tests/render-graph-authoring.test.js | 9 +- tests/render-graph-presets.test.js | 10 +- 14 files changed, 985 insertions(+), 179 deletions(-) diff --git a/renderer/src/render_graph/compiler.rs b/renderer/src/render_graph/compiler.rs index d5aa24d..1bfa7db 100644 --- a/renderer/src/render_graph/compiler.rs +++ b/renderer/src/render_graph/compiler.rs @@ -723,9 +723,6 @@ fn decode(node: &Node, i: usize) -> Result { if p.texture.mip_level_count != 1 { return Err(unsupported("mipLevelCount")); } - if p.texture.sample_count != 1 { - return Err(unsupported("sampleCount")); - } let depth_or_array_layers = match &p.texture.extent { TextureExtent::Absolute { depth_or_array_layers, @@ -1357,49 +1354,6 @@ pub fn compile(graph: Graph) -> Result { }); } - // Every texture reader must execute before a successor overwrites the - // physical allocation backing the older symbolic version. - let mut reachability = HashMap::new(); - for (i, contract) in contracts.iter().enumerate() { - if !live.contains(&i) { - continue; - } - for input in contract - .inputs - .iter() - .filter(|input| matches!(input.role, InputRole::SampledTexture)) - { - let key = bound[i][input.name].producer; - if !version_of.contains_key(&key) { - continue; - } - let Some(next_indices) = transitions_by_target.get(&TransitionTargetKey::Authored(key)) - else { - continue; - }; - let [next_index] = next_indices.as_slice() else { - continue; - }; - let next = transitions[*next_index]; - if i != next.writer_node - && !reaches( - i, - next.writer_node, - &outgoing_edges, - &edges, - &live, - &mut reachability, - ) - { - return Err(error( - "GRAPH_RESOURCE_VERSION_INVALID", - "older texture version may be read after its successor", - format!("nodes[{i}].inputs.{}", input.name), - )); - } - } - } - // Same-pass hazards are global and precede every duplicate-writer diagnostic. for i in 0..graph.nodes.len() { if !live.contains(&i) { @@ -1462,6 +1416,8 @@ pub fn compile(graph: Graph) -> Result { let Some(&(opposite_family, _, _)) = version_of.get(&opposite_output) else { continue; }; + let opposite_descriptor = + known_family_descriptor(opposite_family, &families, &default_roots); let extent = if opposite_family as usize >= authored_family_count && default_roots .get(opposite_family as usize - authored_family_count) @@ -1469,8 +1425,7 @@ pub fn compile(graph: Graph) -> Result { { Some(full_extent.clone()) } else { - known_family_descriptor(opposite_family, &families, &default_roots) - .map(|descriptor| descriptor.extent.clone()) + opposite_descriptor.map(|descriptor| descriptor.extent.clone()) }; if let Some(extent) = extent { default_roots[index].descriptor = @@ -1479,7 +1434,8 @@ pub fn compile(graph: Graph) -> Result { format: default_roots[index].format, extent, mip_level_count: 1, - sample_count: 1, + sample_count: opposite_descriptor + .map_or(1, |descriptor| descriptor.sample_count), view_formats: vec![], }); changed = true; @@ -1490,6 +1446,100 @@ pub fn compile(graph: Graph) -> Result { } } + // Classify sampled edges before descriptor and stale-version validation. A + // demanded resolve is keyed by the exact symbolic output, not its family. + let mut resolve_demands = BTreeSet::::new(); + for edge in &edges { + if !live.contains(&edge.to_node) + || contracts[edge.to_node].inputs[edge.consumer_input_ordinal as usize].role + != InputRole::SampledTexture + { + continue; + } + let key = OutputKey(edge.from_node, edge.producer_output_ordinal); + let Some(&(family, _, _)) = version_of.get(&key) else { + if matches!(resolved.get(&key), Some(ResolvedTransition::Cyclic)) { + continue; + } + if source_family + .get(&TransitionTargetKey::Authored(key)) + .and_then(|&family| known_family_descriptor(family, &families, &default_roots)) + .is_some_and(|descriptor| descriptor.sample_count == 4) + { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "unproduced multisampled texture cannot be sampled", + format!("nodes[{}].inputs.{}", edge.to_node, edge.to_socket), + )); + } + continue; + }; + let Some(descriptor) = known_family_descriptor(family, &families, &default_roots) else { + continue; + }; + if descriptor.sample_count == 1 { + continue; + } + if descriptor.format == TextureFormat::Depth32Float { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "multisampled depth texture cannot be sampled", + format!("nodes[{}].inputs.{}", edge.to_node, edge.to_socket), + )); + } + if contracts[edge.from_node].key != "pipeline" || edge.producer_output_ordinal != 0 { + return Err(error( + "GRAPH_ILLEGAL_ACCESS", + "multisampled color texture is not a produced pipeline color", + format!("nodes[{}].inputs.{}", edge.to_node, edge.to_socket), + )); + } + resolve_demands.insert(key); + } + + // Every ordinary texture reader must execute before a successor overwrites + // its allocation. Resolve demands read the attachment during its producer. + let mut reachability = HashMap::new(); + for (i, contract) in contracts.iter().enumerate() { + if !live.contains(&i) { + continue; + } + for input in contract + .inputs + .iter() + .filter(|input| input.role == InputRole::SampledTexture) + { + let key = bound[i][input.name].producer; + if resolve_demands.contains(&key) || !version_of.contains_key(&key) { + continue; + } + let Some(next_indices) = transitions_by_target.get(&TransitionTargetKey::Authored(key)) + else { + continue; + }; + let [next_index] = next_indices.as_slice() else { + continue; + }; + let next = transitions[*next_index]; + if i != next.writer_node + && !reaches( + i, + next.writer_node, + &outgoing_edges, + &edges, + &live, + &mut reachability, + ) + { + return Err(error( + "GRAPH_RESOURCE_VERSION_INVALID", + "older texture version may be read after its successor", + format!("nodes[{i}].inputs.{}", input.name), + )); + } + } + } + // Validate every independently resolved attachment before graph cycle reporting. for i in 0..graph.nodes.len() { if !live.contains(&i) || contracts[i].key != "pipeline" { @@ -1504,7 +1554,7 @@ pub fn compile(graph: Graph) -> Result { let ok_depth = dd.is_none_or(|dd| { dd.dimension == TextureDimension::D2 && dd.format == TextureFormat::Depth32Float - && dd.sample_count == 1 + && matches!(dd.sample_count, 1 | 4) && dd.mip_level_count == 1 && dd.view_formats.is_empty() && extent_layers(&dd.extent) == 1 @@ -1512,7 +1562,7 @@ pub fn compile(graph: Graph) -> Result { let ok_color = cd.is_none_or(|cd| { cd.dimension == TextureDimension::D2 && cd.format != TextureFormat::Depth32Float - && cd.sample_count == 1 + && matches!(cd.sample_count, 1 | 4) && cd.mip_level_count == 1 && cd.view_formats.is_empty() && extent_layers(&cd.extent) == 1 @@ -1588,7 +1638,9 @@ pub fn compile(graph: Graph) -> Result { _ => None, }; let source_ok = source_descriptor.is_none_or(|descriptor| { - descriptor.format == TextureFormat::Rgba16Float && is_single_view_d2(descriptor) + descriptor.format == TextureFormat::Rgba16Float + && (is_single_view_d2(descriptor) + || resolve_demands.contains(&bound[i]["source"].producer)) }); let target_ok = target_descriptor.is_none_or(|descriptor| { is_single_view_d2(descriptor) @@ -1605,7 +1657,9 @@ pub fn compile(graph: Graph) -> Result { } }); let bloom_ok = bloom_descriptor.is_none_or(|descriptor| { - descriptor.format == TextureFormat::Rgba16Float && is_single_view_d2(descriptor) + descriptor.format == TextureFormat::Rgba16Float + && (is_single_view_d2(descriptor) + || resolve_demands.contains(&bound[i]["bloom"].producer)) }); let extent_ok = match contracts[i].fullscreen_policy { Some(FullscreenPolicy::Copy) @@ -1661,7 +1715,11 @@ pub fn compile(graph: Graph) -> Result { let NormalizedParameters::FrameOut { dynamic_range, .. } = ¶ms[i] else { unreachable!() }; - if !frame_out_source_compatible(descriptor, dynamic_range) { + let mut effective = descriptor.clone(); + if resolve_demands.contains(&key) { + effective.sample_count = 1; + } + if !frame_out_source_compatible(&effective, dynamic_range) { let message = match dynamic_range { FrameDynamicRange::Hdr { .. } => "HDR frame output requires rgba16_float", FrameDynamicRange::Sdr => { @@ -1940,6 +1998,87 @@ pub fn compile(graph: Graph) -> Result { }, }); } + // A multisampled pipeline color remains the authored attachment version. Sampling + // that exact version instead addresses one compiler-owned fixed-function resolve. + let mut resolve_resources = BTreeMap::new(); + for producer in resolve_demands { + let (family, _, _) = version_of[&producer]; + let descriptor = family_descriptor(&families[family as usize]); + let source_resource = output_ids[&producer]; + let root = resources.len() as u32; + let resource = root + 1; + let family_id = families.len() as u32; + let mut resolved_descriptor = descriptor.clone(); + resolved_descriptor.sample_count = 1; + resolve_resources.insert(producer, resource); + resources.push(CompiledResource { + original_node_index: producer.0 as u32, + origin: ResourceOrigin::CompilerColorResolve { + producer_node_index: producer.0 as u32, + output_ordinal: producer.1, + source_resource, + }, + semantic_type: SemanticType::Texture, + producer_execution: None, + lifetime: None, + plan: ResourcePlan::TextureSource { + family: family_id, + residency: TextureResidency::Transient, + descriptor: resolved_descriptor.clone(), + }, + }); + resources.push(CompiledResource { + original_node_index: producer.0 as u32, + origin: ResourceOrigin::CompilerColorResolve { + producer_node_index: producer.0 as u32, + output_ordinal: producer.1, + source_resource, + }, + semantic_type: SemanticType::Texture, + producer_execution: None, + lifetime: None, + plan: ResourcePlan::Texture { + family: family_id, + version: 0, + target: root, + initialized: true, + stored: true, + allocation: None, + }, + }); + families.push(TextureFamily { + id: family_id, + key: TextureFamilyKey { + source_node: producer.0 as u32, + source_socket: producer.1, + }, + source: TextureFamilySource::CompilerColorResolve { + resource: root, + descriptor: resolved_descriptor, + producer_node_index: producer.0 as u32, + output_ordinal: producer.1, + source_resource, + }, + lifetime: Lifetime { + first_use: 0, + last_use: 0, + }, + versions: vec![TextureVersion { + version: 0, + resource, + target: root, + initialized: true, + stored: true, + lifetime: Lifetime { + first_use: 0, + last_use: 0, + }, + }], + usage: vec![], + allocation: None, + aliasable: false, + }); + } let mut executions = Vec::new(); for &i in &order { if matches!( @@ -1948,12 +2087,32 @@ pub fn compile(graph: Graph) -> Result { ) { continue; } - let input_resource = |s: &str| output_ids[&bound[i][s].producer]; + let input_resource = |s: &str| { + let key = bound[i][s].producer; + let resource = output_ids[&key]; + if contracts[i] + .inputs + .iter() + .any(|input| input.name == s && input.role == InputRole::SampledTexture) + { + resolve_resources.get(&key).copied().unwrap_or(resource) + } else { + resource + } + }; let mut inputs = Vec::new(); for (input_ordinal, s) in contracts[i].inputs.iter().enumerate() { if s.role != InputRole::Expression { let resource = if let Some(b) = bound[i].get(s.name) { - output_ids[&b.producer] + let resource = output_ids[&b.producer]; + if s.role == InputRole::SampledTexture { + resolve_resources + .get(&b.producer) + .copied() + .unwrap_or(resource) + } else { + resource + } } else if s.default_policy == InputDefaultPolicy::CompilerTexture { let key = TransitionTargetKey::CompilerDefaultInput { owner_node: i, @@ -1998,6 +2157,38 @@ pub fn compile(graph: Graph) -> Result { }; let first_color = version_of[&OutputKey(i, 0)].1 == 0; let first_depth = version_of[&OutputKey(i, 1)].1 == 0; + let resolve_target = resolve_resources.get(&OutputKey(i, 0)).copied(); + let source_store = if resolve_target.is_some() + && !edges.iter().any(|edge| { + edge.from_node == i + && edge.producer_output_ordinal == 0 + && matches!( + contracts[edge.to_node].inputs + [edge.consumer_input_ordinal as usize] + .role, + InputRole::ColorTarget { .. } + ) + }) { + StoreOp::Discard + } else { + StoreOp::Store + }; + let stored = source_store == StoreOp::Store; + if let ResourcePlan::Texture { + family, + version, + stored: resource_stored, + .. + } = &mut resources[color as usize].plan + { + *resource_stored = stored; + if let Some(texture_version) = families + .get_mut(*family as usize) + .and_then(|family| family.versions.get_mut(*version as usize)) + { + texture_version.stored = stored; + } + } let cl = if first_color { NormalizedColorLoad::Clear { value: clear } } else { @@ -2021,7 +2212,7 @@ pub fn compile(graph: Graph) -> Result { mode: AccessMode::ColorAttachment { location: 0, load: cl, - store: StoreOp::Store, + store: source_store, full_overwrite: first_color, }, }); @@ -2034,12 +2225,23 @@ pub fn compile(graph: Graph) -> Result { full_overwrite: first_depth, }, }); + if let Some(resource) = resolve_target { + accesses.push(CompiledAccess { + socket: "colorResolve".into(), + resource, + mode: AccessMode::ColorResolve { + source: color, + location: 0, + }, + }); + } ExecutionKind::Render { color_attachments: vec![ColorAttachmentPlan { resource: color, + resolve_target, location: 0, load: cl, - store: StoreOp::Store, + store: source_store, }], depth_stencil: Some(DepthStencilAttachmentPlan { resource: depth, @@ -2077,6 +2279,7 @@ pub fn compile(graph: Graph) -> Result { ExecutionKind::Render { color_attachments: vec![ColorAttachmentPlan { resource: color, + resolve_target: None, location: 0, load, store: StoreOp::Store, @@ -2374,6 +2577,11 @@ pub fn compile(graph: Graph) -> Result { for o in &e.outputs { resources[o.resource as usize].producer_execution = Some(ordinal as u32); } + for access in &e.accesses { + if matches!(access.mode, AccessMode::ColorResolve { .. }) { + resources[access.resource as usize].producer_execution = Some(ordinal as u32); + } + } } // Dense lifetimes touch bindings, outputs, and accesses. for (ordinal, e) in executions.iter().enumerate() { @@ -2414,7 +2622,8 @@ pub fn compile(graph: Graph) -> Result { TextureFamilySource::AuthoredTexture { residency, .. } => { residency == TextureResidency::Transient } - TextureFamilySource::CompilerDefaultInput { .. } => true, + TextureFamilySource::CompilerDefaultInput { .. } + | TextureFamilySource::CompilerColorResolve { .. } => true, }; f.aliasable = transient && f.versions.iter().all(|v| v.initialized); } @@ -2457,7 +2666,8 @@ fn extent_layers(e: &NormalizedTextureExtent) -> u32 { pub(super) fn family_descriptor(family: &TextureFamily) -> &NormalizedTextureDescriptor { match &family.source { TextureFamilySource::AuthoredTexture { descriptor, .. } - | TextureFamilySource::CompilerDefaultInput { descriptor, .. } => descriptor, + | TextureFamilySource::CompilerDefaultInput { descriptor, .. } + | TextureFamilySource::CompilerColorResolve { descriptor, .. } => descriptor, } } pub(super) fn is_single_view_d2(descriptor: &NormalizedTextureDescriptor) -> bool { @@ -2504,6 +2714,9 @@ pub(super) fn texture_usage( AccessMode::DepthAttachment { .. } => { u.insert(TextureUsage::DepthAttachment); } + AccessMode::ColorResolve { .. } => { + u.insert(TextureUsage::ColorAttachment); + } _ => {} } } diff --git a/renderer/src/render_graph/contracts.rs b/renderer/src/render_graph/contracts.rs index 9d5b51e..66b45fe 100644 --- a/renderer/src/render_graph/contracts.rs +++ b/renderer/src/render_graph/contracts.rs @@ -197,9 +197,9 @@ macro_rules! ex { pub static CONTRACTS: &[Contract] = &[ c!("mesh", 2, Source, NONE_I, MESH_O, false, None), - c!("texture", 1, Source, NONE_I, TEXTURE_O, false, None), + c!("texture", 2, Source, NONE_I, TEXTURE_O, false, None), c!("frustum_cull", 2, Expression, CULL_I, CULL_O, false, None), - c!("pipeline", 3, Render, PIPE_I, PIPE_O, false, None), + c!("pipeline", 4, Render, PIPE_I, PIPE_O, false, None), ex!("and", ins!("left":Bool,"right":Bool), outs!("value":Bool)), ex!("or", ins!("left":Bool,"right":Bool), outs!("value":Bool)), ex!("not", ins!("operand":Bool), outs!("value":Bool)), diff --git a/renderer/src/render_graph/plan.rs b/renderer/src/render_graph/plan.rs index 1bc3f17..44e8f10 100644 --- a/renderer/src/render_graph/plan.rs +++ b/renderer/src/render_graph/plan.rs @@ -45,6 +45,11 @@ pub enum ResourceOrigin { socket: String, role: CompilerTextureRole, }, + CompilerColorResolve { + producer_node_index: u32, + output_ordinal: u16, + source_resource: u32, + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] @@ -116,6 +121,7 @@ pub enum ExecutionKind { #[serde(rename_all = "camelCase")] pub struct ColorAttachmentPlan { pub resource: u32, + pub resolve_target: Option, pub location: u32, pub load: NormalizedColorLoad, pub store: StoreOp, @@ -169,6 +175,10 @@ pub enum AccessMode { }, IndirectRead, SampledTexture, + ColorResolve { + source: u32, + location: u32, + }, ColorAttachment { location: u32, load: NormalizedColorLoad, @@ -375,6 +385,13 @@ pub enum TextureFamilySource { role: CompilerTextureRole, descriptor: NormalizedTextureDescriptor, }, + CompilerColorResolve { + resource: u32, + descriptor: NormalizedTextureDescriptor, + producer_node_index: u32, + output_ordinal: u16, + source_resource: u32, + }, } #[derive(Clone, Debug, Serialize)] diff --git a/renderer/src/render_graph/runtime.rs b/renderer/src/render_graph/runtime.rs index 6eb6a89..8bafa57 100644 --- a/renderer/src/render_graph/runtime.rs +++ b/renderer/src/render_graph/runtime.rs @@ -396,7 +396,8 @@ fn texture_descriptor<'a>( }; match &graph.texture_families.get(family as usize)?.source { TextureFamilySource::AuthoredTexture { descriptor, .. } - | TextureFamilySource::CompilerDefaultInput { descriptor, .. } => Some(descriptor), + | TextureFamilySource::CompilerDefaultInput { descriptor, .. } + | TextureFamilySource::CompilerColorResolve { descriptor, .. } => Some(descriptor), } } @@ -419,6 +420,7 @@ fn single_view_d2(d: &NormalizedTextureDescriptor) -> bool { fn validate_fullscreen_execution( graph: &CompiledGraph, + producers: &[Option], i: usize, execution: &CompiledExecution, contract: &Contract, @@ -624,13 +626,8 @@ fn validate_fullscreen_execution( path("inputs"), )); } - let producer = graph.executions[..i].iter().position(|candidate| { - candidate - .outputs - .iter() - .any(|value| value.resource == input.resource) - }); - if producer.is_none() { + let producer = producers.get(input.resource as usize).copied().flatten(); + if !producer.is_some_and(|producer| producer < i as u32) { return Err(invalid( "fullscreen sampled producer must precede execution", path("inputs"), @@ -682,6 +679,115 @@ fn validate_fullscreen_execution( Ok(()) } +fn validate_pipeline_resolve( + graph: &CompiledGraph, + producers: &[Option], + family_index: usize, + family: &TextureFamily, +) -> Result<(), GraphError> { + let TextureFamilySource::CompilerColorResolve { + resource: root, + descriptor, + producer_node_index, + output_ordinal, + source_resource, + } = &family.source + else { + return Ok(()); + }; + let path = || format!("textureFamilies[{family_index}].source"); + let source = graph + .resources + .get(*source_resource as usize) + .ok_or_else(|| invalid("resolve source is out of bounds", path()))?; + let producer_index = producers + .get(*source_resource as usize) + .copied() + .flatten() + .ok_or_else(|| invalid("resolve source producer is missing", path()))? + as usize; + let producer = graph + .executions + .get(producer_index) + .ok_or_else(|| invalid("resolve producer is out of bounds", path()))?; + let source_family_id = match source.plan { + ResourcePlan::Texture { family, .. } => family, + _ => return Err(invalid("resolve source is not a texture version", path())), + }; + let source_family = graph + .texture_families + .get(source_family_id as usize) + .ok_or_else(|| invalid("resolve source family is out of bounds", path()))?; + let source_descriptor = super::compiler::family_descriptor(source_family); + let expected_descriptor = NormalizedTextureDescriptor { + sample_count: 1, + ..source_descriptor.clone() + }; + let origin_matches = |origin: &ResourceOrigin| { + matches!(origin, + ResourceOrigin::CompilerColorResolve { + producer_node_index: node, + output_ordinal: output, + source_resource: source, + } if node == producer_node_index && output == output_ordinal && source == source_resource) + }; + let [version] = family.versions.as_slice() else { + return Err(invalid("resolve family must have one version", path())); + }; + let output = graph + .resources + .get(version.resource as usize) + .ok_or_else(|| invalid("resolve output is out of bounds", path()))?; + let exact_output = producer + .outputs + .get(*output_ordinal as usize) + .is_some_and(|value| value.socket == "color" && value.resource == *source_resource) + && *output_ordinal == 0 + && producer + .outputs + .iter() + .filter(|value| value.resource == *source_resource) + .count() + == 1; + let exact_access = producer + .accesses + .iter() + .filter(|access| { + matches!(access.mode, + AccessMode::ColorResolve { source, location: 0 } if source == *source_resource) + && access.resource == version.resource + && access.socket == "colorResolve" + }) + .count() + == 1 + && producer + .accesses + .iter() + .filter(|access| matches!(access.mode, AccessMode::ColorResolve { .. })) + .count() + == 1; + if producer.executor.key != "pipeline" + || producer.original_node_index != *producer_node_index + || !exact_output + || !matches!(&source.origin, + ResourceOrigin::AuthoredOutput { node, socket, output_ordinal: 0 } + if node == &producer.id && socket == "color") + || source.original_node_index != *producer_node_index + || !matches!(graph.resources.get(*root as usize), Some(CompiledResource { plan: ResourcePlan::TextureSource { .. }, origin, .. }) if origin_matches(origin)) + || !origin_matches(&output.origin) + || version.version != 0 + || version.target != *root + || output.producer_execution != Some(producer_index as u32) + || !exact_access + || family.id == source_family_id + || family.allocation == source_family.allocation + || descriptor != &expected_descriptor + { + return Err(invalid("pipeline color resolve is not canonical", path())); + } + Ok(()) +} + fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { fn texture_family_for_resource<'a>( graph: &'a CompiledGraph, @@ -722,6 +828,20 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { let mut producers = vec![None; graph.resources.len()]; let mut uses = vec![BTreeSet::new(); graph.resources.len()]; + fn claim_producer( + producers: &mut [Option], + resource: u32, + execution: u32, + path: String, + ) -> Result<(), GraphError> { + let producer = producers + .get_mut(resource as usize) + .ok_or_else(|| invalid("execution producer is out of bounds", path.clone()))?; + if producer.replace(execution).is_some() { + return Err(invalid("resource has duplicate producers", path)); + } + Ok(()) + } for (i, execution) in graph.executions.iter().enumerate() { let contract = contract(&execution.executor.key).expect("supported executor has contract"); if execution.executor.version != contract.version { @@ -731,18 +851,12 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { )); } for output in &execution.outputs { - let producer = producers.get_mut(output.resource as usize).ok_or_else(|| { - invalid( - "execution output is out of bounds", - format!("executions[{i}].outputs"), - ) - })?; - if producer.replace(i as u32).is_some() { - return Err(invalid( - "resource has duplicate producers", - format!("executions[{i}].outputs"), - )); - } + claim_producer( + &mut producers, + output.resource, + i as u32, + format!("executions[{i}].outputs"), + )?; } let mut referenced = HashSet::new(); for input in &execution.inputs { @@ -763,8 +877,16 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { )); } referenced.insert(access.resource); + if matches!(access.mode, AccessMode::ColorResolve { .. }) { + claim_producer( + &mut producers, + access.resource, + i as u32, + format!("executions[{i}].accesses"), + )?; + } } - validate_fullscreen_execution(graph, i, execution, contract)?; + validate_fullscreen_execution(graph, &producers, i, execution, contract)?; for resource in referenced { uses.get_mut(resource as usize) .ok_or_else(|| { @@ -812,6 +934,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { let mut family_keys = HashSet::new(); let mut source_claims = vec![0u8; graph.resources.len()]; for (fi, family) in graph.texture_families.iter().enumerate() { + validate_pipeline_resolve(graph, &producers, fi, family)?; if family.id as usize != fi || !family_keys.insert(family.key.clone()) { return Err(invalid( "texture family id/key is not unique and canonical", @@ -829,6 +952,11 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { descriptor, .. } => (*resource, TextureResidency::Transient, descriptor), + TextureFamilySource::CompilerColorResolve { + resource, + descriptor, + .. + } => (*resource, TextureResidency::Transient, descriptor), }; let source_resource = graph.resources.get(source as usize).ok_or_else(|| { invalid( @@ -894,7 +1022,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { == 1 }) && owner_executions[0].executor.key == "pipeline" - && owner_executions[0].executor.version == 3 + && owner_executions[0].executor.version == 4 && graph .executions .iter() @@ -926,6 +1054,26 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { "depthTarget" } } + ( + TextureFamilySource::CompilerColorResolve { + producer_node_index, + output_ordinal, + source_resource: resolve_source, + .. + }, + ResourceOrigin::CompilerColorResolve { + producer_node_index: origin_node, + output_ordinal: origin_output, + source_resource: origin_source, + }, + ) => { + *producer_node_index == *origin_node + && *output_ordinal == *origin_output + && *resolve_source == *origin_source + && source_resource.semantic_type == SemanticType::Texture + && family.key.source_node == *producer_node_index + && family.key.source_socket == *output_ordinal + } _ => false, }; if !origin_ok { @@ -950,7 +1098,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { TextureFormat::Depth32Float } && descriptor.mip_level_count == 1 - && descriptor.sample_count == 1 + && matches!(descriptor.sample_count, 1 | 4) && descriptor.view_formats.is_empty() && matches!( descriptor.extent, @@ -975,7 +1123,12 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { .executions .iter() .find(|execution| execution.original_node_index == *owner_node_index) - .expect("origin validation found owner"); + .ok_or_else(|| { + invalid( + "compiler default owner is missing", + format!("textureFamilies[{fi}].source"), + ) + })?; let opposite_socket = if *role == CompilerTextureRole::ColorTarget { "depthTarget" } else { @@ -1000,27 +1153,31 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { ) })?; let both_defaults = matches!(&opposite_family.source, TextureFamilySource::CompilerDefaultInput { owner_node_index: opposite_owner, .. } if opposite_owner == owner_node_index); - let expected_extent = if both_defaults { - NormalizedTextureExtent::SurfaceRelative { - width: Ratio { - numerator: 1, - denominator: 1, + let (expected_extent, expected_sample_count) = if both_defaults { + ( + NormalizedTextureExtent::SurfaceRelative { + width: Ratio { + numerator: 1, + denominator: 1, + }, + height: Ratio { + numerator: 1, + denominator: 1, + }, + depth_or_array_layers: 1, }, - height: Ratio { - numerator: 1, - denominator: 1, - }, - depth_or_array_layers: 1, - } + 1, + ) } else { - super::compiler::family_descriptor(opposite_family) - .extent - .clone() + let opposite = super::compiler::family_descriptor(opposite_family); + (opposite.extent.clone(), opposite.sample_count) }; - if descriptor.extent != expected_extent { + if descriptor.extent != expected_extent + || descriptor.sample_count != expected_sample_count + { return Err(invalid( - "compiler default extent is not canonical", - format!("textureFamilies[{fi}].source.descriptor.extent"), + "compiler default descriptor inheritance is not canonical", + format!("textureFamilies[{fi}].source.descriptor"), )); } } @@ -1519,12 +1676,17 @@ pub fn prepare_runtime_plan( format!("executions[{i}].outputs"), )); } - let color_version = match graph + let (color_version, color_stored, color_target) = match graph .resources .get(color_output.resource as usize) .map(|r| &r.plan) { - Some(ResourcePlan::Texture { version, .. }) => *version, + Some(ResourcePlan::Texture { + version, + stored, + target, + .. + }) => (*version, *stored, *target), _ => { return Err(invalid( "pipeline color output kind is invalid", @@ -1532,12 +1694,17 @@ pub fn prepare_runtime_plan( )) } }; - let depth_version = match graph + let (depth_version, depth_stored, depth_target) = match graph .resources .get(depth_output.resource as usize) .map(|r| &r.plan) { - Some(ResourcePlan::Texture { version, .. }) => *version, + Some(ResourcePlan::Texture { + version, + stored, + target, + .. + }) => (*version, *stored, *target), _ => { return Err(invalid( "pipeline depth output kind is invalid", @@ -1571,12 +1738,45 @@ pub fn prepare_runtime_plan( format!("executions[{i}].kind"), )); } - let [mesh_access, color_access, depth_access] = execution.accesses.as_slice() else { - return Err(invalid( - "pipeline access shape mismatch", - format!("executions[{i}].accesses"), - )); + let stored_op = |stored| { + if stored { + StoreOp::Store + } else { + StoreOp::Discard + } }; + if color_attachment.store != stored_op(color_stored) + || depth_attachment.store != stored_op(depth_stored) + || (color_version > 0 + && !matches!( + graph.resources.get(color_target as usize).map(|r| &r.plan), + Some(ResourcePlan::Texture { stored: true, .. }) + )) + || (depth_version > 0 + && !matches!( + graph.resources.get(depth_target as usize).map(|r| &r.plan), + Some(ResourcePlan::Texture { stored: true, .. }) + )) + { + return Err(invalid( + "pipeline store metadata is not canonical", + format!("executions[{i}].kind"), + )); + } + let (base_accesses, resolve_access) = match execution.accesses.as_slice() { + [mesh, color, depth] => (&[mesh, color, depth][..], None), + [mesh, color, depth, resolve] => (&[mesh, color, depth][..], Some(resolve)), + _ => { + return Err(invalid( + "pipeline access shape mismatch", + format!("executions[{i}].accesses"), + )); + } + }; + let [mesh_access, color_access, depth_access] = base_accesses else { + unreachable!() + }; + let expected_store = stored_op(color_stored); if mesh_access.socket != "mesh" || mesh_access.resource != mesh_input.resource || !matches!(mesh_access.mode, AccessMode::SemanticRead) @@ -1592,25 +1792,37 @@ pub fn prepare_runtime_plan( || color_access.resource != color_output.resource || !matches!( color_access.mode, - AccessMode::ColorAttachment { location: 0, load, store: StoreOp::Store, full_overwrite } - if load == color_attachment.load && full_overwrite == color_clear + AccessMode::ColorAttachment { location: 0, load, store, full_overwrite } + if load == color_attachment.load && store == expected_store && full_overwrite == color_clear ) || color_attachment.location != 0 - || color_attachment.store != StoreOp::Store || depth_access.socket != "depth" || depth_access.resource != depth_output.resource || !matches!( depth_access.mode, - AccessMode::DepthAttachment { load, store: StoreOp::Store, full_overwrite } - if load == depth_attachment.load && full_overwrite == depth_clear + AccessMode::DepthAttachment { load, store, full_overwrite } + if load == depth_attachment.load && store == stored_op(depth_stored) && full_overwrite == depth_clear ) - || depth_attachment.store != StoreOp::Store { return Err(invalid( "pipeline attachment accesses mismatch", format!("executions[{i}].accesses"), )); } + match (color_attachment.resolve_target, resolve_access) { + (None, None) => {} + (Some(target), Some(access)) + if access.socket == "colorResolve" + && access.resource == target + && matches!(access.mode, AccessMode::ColorResolve { source, location: 0 } if source == color_attachment.resource) => + {} + _ => { + return Err(invalid( + "pipeline color resolve mismatch", + format!("executions[{i}].accesses"), + )) + } + } let mesh_resource = graph .resources .get(mesh_input.resource as usize) @@ -1661,7 +1873,10 @@ pub fn prepare_runtime_plan( ) })?; if color_descriptor.format == TextureFormat::Depth32Float - || !super::compiler::is_single_view_d2(color_descriptor) + || color_descriptor.dimension != TextureDimension::D2 + || !matches!(color_descriptor.sample_count, 1 | 4) + || color_descriptor.mip_level_count != 1 + || !color_descriptor.view_formats.is_empty() { return Err(invalid( "pipeline color attachment descriptor is invalid", @@ -1676,14 +1891,20 @@ pub fn prepare_runtime_plan( ) })?; if depth_descriptor.format != TextureFormat::Depth32Float - || !super::compiler::is_single_view_d2(depth_descriptor) + || depth_descriptor.dimension != TextureDimension::D2 + || !matches!(depth_descriptor.sample_count, 1 | 4) + || depth_descriptor.mip_level_count != 1 + || !depth_descriptor.view_formats.is_empty() { return Err(invalid( "pipeline depth attachment descriptor is invalid", format!("executions[{i}].inputs"), )); } - if color_descriptor.extent != depth_descriptor.extent { + if color_descriptor.extent != depth_descriptor.extent + || color_descriptor.sample_count != depth_descriptor.sample_count + || (color_descriptor.sample_count == 1 && color_attachment.resolve_target.is_some()) + { return Err(invalid( "pipeline attachment extents mismatch", format!("executions[{i}].inputs"), @@ -1839,7 +2060,7 @@ pub fn prepare_runtime_plan( TextureResidency::Transient | TextureResidency::Persistent ) || descriptor.dimension != TextureDimension::D2 || descriptor.mip_level_count != 1 - || descriptor.sample_count != 1 + || !matches!(descriptor.sample_count, 1 | 4) || !matches!( descriptor.extent, NormalizedTextureExtent::Absolute { @@ -1865,13 +2086,25 @@ pub fn prepare_runtime_plan( } } TextureFamilySource::CompilerDefaultInput { descriptor, .. } => { - if !super::compiler::is_single_view_d2(descriptor) || family.allocation.is_none() { + if descriptor.dimension != TextureDimension::D2 + || !matches!(descriptor.sample_count, 1 | 4) + || descriptor.mip_level_count != 1 + || family.allocation.is_none() + { return Err(invalid( "compiler default family is not canonical", format!("textureFamilies[{fi}]"), )); } } + TextureFamilySource::CompilerColorResolve { descriptor, .. } => { + if !super::compiler::is_single_view_d2(descriptor) || family.allocation.is_none() { + return Err(invalid( + "compiler resolve family is not canonical", + format!("textureFamilies[{fi}]"), + )); + } + } } for (vi, version) in family.versions.iter().enumerate() { if version.version as usize != vi { @@ -1988,6 +2221,9 @@ pub fn prepare_runtime_plan( TextureFamilySource::CompilerDefaultInput { descriptor, .. } => { (descriptor, TextureResidency::Transient) } + TextureFamilySource::CompilerColorResolve { descriptor, .. } => { + (descriptor, TextureResidency::Transient) + } }; expected_usage.extend(family.usage.iter().copied()); let kind_valid = match slot.kind { diff --git a/renderer/src/render_graph/tests.rs b/renderer/src/render_graph/tests.rs index f41afcc..3ece243 100644 --- a/renderer/src/render_graph/tests.rs +++ b/renderer/src/render_graph/tests.rs @@ -12,7 +12,7 @@ fn input(node: &str, socket: &str) -> Value { fn texture(id: &str, format: &str) -> Value { json!({ - "id": id, "state": "enabled", "executor": { "key": "texture", "version": 1 }, + "id": id, "state": "enabled", "executor": { "key": "texture", "version": 2 }, "parameters": { "residency": "transient", "texture": { "dimension": "d2", "format": format, "extent": { "kind": "surface_relative", "width": { "numerator": 1, "denominator": 1 }, @@ -28,6 +28,10 @@ fn set_extent_ratio(texture: &mut Value, numerator: u32, denominator: u32) { json!({"numerator":numerator,"denominator":denominator}); } +fn set_sample_count(texture: &mut Value, sample_count: u32) { + texture["parameters"]["texture"]["sampleCount"] = json!(sample_count); +} + fn node(id: &str, key: &str, version: u32, parameters: Value, inputs: Value) -> Value { json!({ "id": id, "state": "enabled", "executor": { "key": key, "version": version }, "parameters": parameters, "inputs": inputs }) @@ -45,7 +49,7 @@ pub(crate) fn full_cull_graph() -> Value { node("visible", "not", 1, json!({"operandDefault":false}), json!({"operand":input("cull","isFrustumCulled")})), node("class", "and", 1, json!({"leftDefault":true,"rightDefault":true}), json!({"left":input("bits","bit0"),"right":input("visible","value")})), - node("pipeline", "pipeline", 3, + node("pipeline", "pipeline", 4, json!({"pipeline":"gltf_standard","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), json!({"mesh":input("mesh","mesh"),"predicate":input("class","value"),"colorTarget":input("color","texture"),"depthTarget":input("depth","texture")})), node("frame", "frame_out", 3, @@ -58,7 +62,7 @@ pub(crate) fn full_cull_graph() -> Value { #[test] fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() { assert_eq!(contract("mesh").unwrap().version, 2); - assert_eq!(contract("pipeline").unwrap().version, 3); + assert_eq!(contract("pipeline").unwrap().version, 4); assert_eq!( contract("mesh") .unwrap() @@ -114,6 +118,204 @@ fn typed_graph_builds_one_dense_deterministic_traversal() { ); } +#[test] +fn msaa_pipeline_to_frame_out_materializes_distinct_canonical_resolve() { + let mut value = full_cull_graph(); + set_sample_count(&mut value["nodes"][0], 4); + set_sample_count(&mut value["nodes"][1], 4); + let graph = compile_value(value).unwrap(); + let family = graph + .texture_families + .iter() + .find(|family| { + matches!( + family.source, + TextureFamilySource::CompilerColorResolve { .. } + ) + }) + .unwrap(); + let TextureFamilySource::CompilerColorResolve { + resource: root, + source_resource, + .. + } = family.source + else { + unreachable!() + }; + assert_ne!(root, family.versions[0].resource); + assert_ne!(source_resource, family.versions[0].resource); + assert_eq!(family.versions[0].target, root); + assert!(matches!( + graph.resources[root as usize].plan, + ResourcePlan::TextureSource { .. } + )); + assert!( + validate_activatable(&graph).is_ok(), + "{:?}", + validate_activatable(&graph) + ); +} + +#[test] +fn msaa_resolve_can_feed_fullscreen_and_default_depth_inherits_four_samples() { + let mut value = full_cull_graph(); + set_sample_count(&mut value["nodes"][0], 4); + value["nodes"][8]["inputs"] + .as_object_mut() + .unwrap() + .remove("depthTarget"); + value["nodes"] + .as_array_mut() + .unwrap() + .insert(9, texture("post_target", "rgba16_float")); + value["nodes"].as_array_mut().unwrap().insert( + 10, + node( + "post", + "saturation", + 1, + json!({"saturation":1,"factor":1}), + json!({ + "source":input("pipeline","color"), + "colorTarget":input("post_target","texture") + }), + ), + ); + value["nodes"][11]["inputs"]["color"] = input("post", "color"); + + let graph = compile_value(value).unwrap(); + assert!(validate_activatable(&graph).is_ok()); + let default_depth = graph + .texture_families + .iter() + .find(|family| { + matches!( + family.source, + TextureFamilySource::CompilerDefaultInput { + role: CompilerTextureRole::DepthTarget, + .. + } + ) + }) + .unwrap(); + assert_eq!( + super::compiler::family_descriptor(default_depth).sample_count, + 4 + ); + let resolve_output = graph + .texture_families + .iter() + .find_map(|family| match family.source { + TextureFamilySource::CompilerColorResolve { .. } => Some(family.versions[0].resource), + _ => None, + }) + .unwrap(); + let post = graph + .executions + .iter() + .find(|execution| execution.id == "post") + .unwrap(); + assert_eq!(post.inputs[0].resource, resolve_output); +} + +#[test] +fn msaa_resolve_accepts_default_color_inferred_from_authored_depth() { + let mut value = full_cull_graph(); + set_sample_count(&mut value["nodes"][1], 4); + value["nodes"][8]["inputs"] + .as_object_mut() + .unwrap() + .remove("colorTarget"); + + let graph = compile_value(value).unwrap(); + let (resolve_descriptor, source_resource) = graph + .texture_families + .iter() + .find_map(|family| match &family.source { + TextureFamilySource::CompilerColorResolve { + descriptor, + source_resource, + .. + } => Some((descriptor, *source_resource)), + _ => None, + }) + .unwrap(); + assert_eq!(resolve_descriptor.sample_count, 1); + let source_family = match graph.resources[source_resource as usize].plan { + ResourcePlan::Texture { family, .. } => family, + _ => panic!("expected resolve source texture version"), + }; + assert!(matches!( + &graph.texture_families[source_family as usize].source, + TextureFamilySource::CompilerDefaultInput { + role: CompilerTextureRole::ColorTarget, + descriptor, + .. + } if descriptor.sample_count == 4 + )); + assert!( + validate_activatable(&graph).is_ok(), + "{:?}", + validate_activatable(&graph) + ); +} + +#[test] +fn runtime_rejects_resolve_origin_and_store_tampering() { + let mut value = full_cull_graph(); + set_sample_count(&mut value["nodes"][0], 4); + set_sample_count(&mut value["nodes"][1], 4); + let graph = compile_value(value).unwrap(); + assert!(validate_activatable(&graph).is_ok()); + let family = graph + .texture_families + .iter() + .find(|family| { + matches!( + family.source, + TextureFamilySource::CompilerColorResolve { .. } + ) + }) + .unwrap(); + let root = match family.source { + TextureFamilySource::CompilerColorResolve { resource, .. } => resource, + _ => unreachable!(), + }; + let source = match family.source { + TextureFamilySource::CompilerColorResolve { + source_resource, .. + } => source_resource, + _ => unreachable!(), + }; + + let mut bad_origin = graph.clone(); + bad_origin.resources[root as usize].origin = ResourceOrigin::CompilerColorResolve { + producer_node_index: u32::MAX, + output_ordinal: 0, + source_resource: source, + }; + assert_runtime_plan_invalid(&bad_origin); + + let mut bad_store = graph.clone(); + let ResourcePlan::Texture { stored, .. } = &mut bad_store.resources[source as usize].plan + else { + panic!("expected source texture version") + }; + *stored = !*stored; + assert_runtime_plan_invalid(&bad_store); +} + +#[test] +fn multisampled_depth_sample_is_rejected_at_exact_socket() { + let mut value = full_cull_graph(); + set_sample_count(&mut value["nodes"][0], 4); + set_sample_count(&mut value["nodes"][1], 4); + value["nodes"][9]["inputs"]["color"] = input("pipeline", "depth"); + let error = compile_value(value).unwrap_err(); + assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); + assert_eq!(error.details["path"], "nodes[9].inputs.color"); +} + #[test] fn pipeline_predicate_defaults_true_and_expression_edges_are_validated() { let mut graph = full_cull_graph(); @@ -156,10 +358,10 @@ fn expression_provenance_rejects_cross_mesh_values() { fn implicit_pipeline_graph() -> Value { json!({ "schemaVersion": 2, "graphId": "implicit", "revision": 1, "nodes": [ node("mesh", "mesh", 2, json!({}), json!({})), - node("first", "pipeline", 3, + node("first", "pipeline", 4, json!({"pipeline":"ground_plane","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), json!({"mesh":input("mesh","mesh")})), - node("second", "pipeline", 3, + node("second", "pipeline", 4, json!({"pipeline":"gltf_standard","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), json!({"mesh":input("mesh","mesh"),"colorTarget":input("first","color"),"depthTarget":input("first","depth")})), node("frame", "frame_out", 3, @@ -169,9 +371,9 @@ fn implicit_pipeline_graph() -> Value { } #[test] -fn contract_v3_declares_strict_default_policies() { +fn contract_v4_declares_strict_default_policies() { let pipeline = contract("pipeline").unwrap(); - assert_eq!(pipeline.version, 3); + assert_eq!(pipeline.version, 4); assert_eq!(pipeline.inputs[0].default_policy, InputDefaultPolicy::None); assert_eq!( pipeline.inputs[1].default_policy, diff --git a/renderer/src/renderer/executors/pipeline.rs b/renderer/src/renderer/executors/pipeline.rs index a9dabe2..8dc58b2 100644 --- a/renderer/src/renderer/executors/pipeline.rs +++ b/renderer/src/renderer/executors/pipeline.rs @@ -174,7 +174,7 @@ pub(crate) fn encode_compiled( color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: view(color.resource)?, depth_slice: None, - resolve_target: None, + resolve_target: color.resolve_target.map(view).transpose()?, ops: wgpu::Operations { load: match color.load { NormalizedColorLoad::Load => wgpu::LoadOp::Load, diff --git a/renderer/src/renderer/mod.rs b/renderer/src/renderer/mod.rs index f70deb3..c400699 100644 --- a/renderer/src/renderer/mod.rs +++ b/renderer/src/renderer/mod.rs @@ -45,6 +45,16 @@ fn device_feature_plan(profile: bool, supported: wgpu::Features) -> DeviceFeatur } } +fn supports_planned_x4_attachment( + is_depth: bool, + resolve_required: bool, + render_attachment: bool, + multisample_x4: bool, + multisample_resolve: bool, +) -> bool { + render_attachment && multisample_x4 && (is_depth || !resolve_required || multisample_resolve) +} + #[cfg(test)] mod device_feature_tests { use super::*; @@ -63,6 +73,22 @@ mod device_feature_tests { assert!(profiled.initial.contains(wgpu::Features::TIMESTAMP_QUERY)); assert!(profiled.profiling_enabled); } + + #[test] + fn adapter_x4_policy_distinguishes_depth_and_color_resolve() { + assert!(supports_planned_x4_attachment( + true, false, true, true, false + )); + assert!(!supports_planned_x4_attachment( + true, false, true, false, true + )); + assert!(!supports_planned_x4_attachment( + false, true, true, true, false + )); + assert!(supports_planned_x4_attachment( + false, true, true, true, true + )); + } } #[repr(C, align(16))] @@ -1692,6 +1718,72 @@ impl Renderer { }) }) .collect::, _>>()?; + let resolve_formats: std::collections::BTreeSet<_> = graph + .texture_families + .iter() + .filter_map(|family| match &family.source { + TextureFamilySource::CompilerColorResolve { + source_resource, .. + } => { + let source = graph.resources.get(*source_resource as usize)?; + let source_family = match source.plan { + ResourcePlan::Texture { family, .. } => family, + _ => return None, + }; + graph + .texture_families + .get(source_family as usize) + .map(|family| match &family.source { + TextureFamilySource::AuthoredTexture { descriptor, .. } + | TextureFamilySource::CompilerDefaultInput { descriptor, .. } + | TextureFamilySource::CompilerColorResolve { descriptor, .. } => { + descriptor.format + } + }) + } + _ => None, + }) + .collect(); + for (family_index, family) in graph.texture_families.iter().enumerate() { + let descriptor = match &family.source { + TextureFamilySource::AuthoredTexture { descriptor, .. } + | TextureFamilySource::CompilerDefaultInput { descriptor, .. } + | TextureFamilySource::CompilerColorResolve { descriptor, .. } => descriptor, + }; + if descriptor.sample_count != 4 { + continue; + } + let features = self + .context + .adapter + .get_texture_format_features(texture_format(descriptor.format)); + let resolve_required = resolve_formats.contains(&descriptor.format) + && descriptor.format != TextureFormat::Depth32Float; + if !supports_planned_x4_attachment( + descriptor.format == TextureFormat::Depth32Float, + resolve_required, + features + .allowed_usages + .contains(wgpu::TextureUsages::RENDER_ATTACHMENT), + features + .flags + .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_X4), + features + .flags + .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE), + ) { + let source_path = match &family.source { + TextureFamilySource::AuthoredTexture { .. } => "authored", + TextureFamilySource::CompilerDefaultInput { .. } => "default", + TextureFamilySource::CompilerColorResolve { .. } => "resolve", + }; + return Err(GraphError::at( + "GRAPH_UNSUPPORTED_FEATURE", + "adapter does not support planned 4x MSAA attachment", + format!("textureFamilies[{family_index}].source.{source_path}"), + )); + } + } let mut textures = Vec::with_capacity(runtime.allocations.classes.len()); for class in &runtime.allocations.classes { let mut gpu_class = Vec::with_capacity(class.slots.len()); @@ -2055,6 +2147,17 @@ impl Renderer { depth_format, compare, *depth_write_enabled, + runtime.allocations.resource_allocations[color.resource as usize] + .and_then(|a| { + runtime + .allocations + .classes + .get(a.class as usize)? + .slots + .get(a.slot as usize) + }) + .map(|slot| slot.descriptor.sample_count) + .ok_or_else(|| fail("color allocation invalid"))?, ) .map_err(|e| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", e))?; executions.push(PreparedExecution::Pipeline { diff --git a/renderer/src/renderer/pipeline_library.rs b/renderer/src/renderer/pipeline_library.rs index 056c358..3741bdd 100644 --- a/renderer/src/renderer/pipeline_library.rs +++ b/renderer/src/renderer/pipeline_library.rs @@ -96,11 +96,17 @@ fn target_variant_spec( depth_format: Option, depth_compare: wgpu::CompareFunction, depth_write: bool, + sample_count: u32, ) -> RenderPipelineSpec { + let write_mask = spec + .targets + .first() + .and_then(Option::as_ref) + .map_or(wgpu::ColorWrites::ALL, |target| target.write_mask); spec.targets = vec![Some(wgpu::ColorTargetState { format: color_format, blend: None, - write_mask: wgpu::ColorWrites::ALL, + write_mask, })]; spec.depth_stencil = depth_format.map(|format| wgpu::DepthStencilState { format, @@ -109,6 +115,7 @@ fn target_variant_spec( stencil: Default::default(), bias: Default::default(), }); + spec.multisample.count = sample_count; spec } @@ -379,14 +386,21 @@ impl PipelineLibrary { depth_format: Option, depth_compare: wgpu::CompareFunction, depth_write: bool, + sample_count: u32, ) -> Result { let spec = self .specs .get(base.get() as usize) .cloned() .ok_or_else(|| "unknown base pipeline".to_owned())?; - let spec = - target_variant_spec(spec, color_format, depth_format, depth_compare, depth_write); + let spec = target_variant_spec( + spec, + color_format, + depth_format, + depth_compare, + depth_write, + sample_count, + ); let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some(" target variant"), source: wgpu::ShaderSource::Wgsl(spec.vertex.shader_source.as_str().into()), @@ -482,38 +496,46 @@ mod tests { } #[test] - fn target_spec_disables_blending_without_mutating_base() { - let mut base = spec(); - base.primitive.cull_mode = Some(wgpu::Face::Front); - base.multisample.count = 4; - base.targets[0].as_mut().unwrap().write_mask = wgpu::ColorWrites::RED; - let preserved_vertex = StageKey::from_stage(&base.vertex); - let preserved_fragment = base.fragment.as_ref().map(StageKey::from_stage); - assert!(base.targets[0].as_ref().unwrap().blend.is_some()); - let variant = target_variant_spec( - base.clone(), - wgpu::TextureFormat::Rgba16Float, - None, - wgpu::CompareFunction::Always, - false, - ); - assert_eq!(variant.targets[0].as_ref().unwrap().blend, None); - assert_eq!( - variant.targets[0].as_ref().unwrap().format, - wgpu::TextureFormat::Rgba16Float - ); - assert!(base.targets[0].as_ref().unwrap().blend.is_some()); - assert_eq!(variant.primitive, base.primitive); - assert_eq!(variant.multisample, base.multisample); - assert_eq!(StageKey::from_stage(&variant.vertex), preserved_vertex); - assert_eq!( - variant.fragment.as_ref().map(StageKey::from_stage), - preserved_fragment - ); - assert_eq!( - variant.targets[0].as_ref().unwrap().write_mask, - wgpu::ColorWrites::ALL - ); + fn target_spec_preserves_base_state_for_both_sample_count_transitions() { + for (from, to) in [(1, 4), (4, 1)] { + let mut base = spec(); + base.primitive.cull_mode = Some(wgpu::Face::Front); + base.multisample.count = from; + base.multisample.alpha_to_coverage_enabled = true; + base.targets[0].as_mut().unwrap().write_mask = wgpu::ColorWrites::RED; + let base_key = base.key(); + let preserved_vertex = StageKey::from_stage(&base.vertex); + let preserved_fragment = base.fragment.as_ref().map(StageKey::from_stage); + assert!(base.targets[0].as_ref().unwrap().blend.is_some()); + let variant = target_variant_spec( + base.clone(), + wgpu::TextureFormat::Rgba16Float, + None, + wgpu::CompareFunction::Always, + false, + to, + ); + assert_eq!(variant.targets[0].as_ref().unwrap().blend, None); + assert_eq!( + variant.targets[0].as_ref().unwrap().format, + wgpu::TextureFormat::Rgba16Float + ); + assert!(base.targets[0].as_ref().unwrap().blend.is_some()); + assert_eq!(variant.primitive, base.primitive); + let mut expected_multisample = base.multisample; + expected_multisample.count = to; + assert_eq!(variant.multisample, expected_multisample); + assert_eq!(StageKey::from_stage(&variant.vertex), preserved_vertex); + assert_eq!( + variant.fragment.as_ref().map(StageKey::from_stage), + preserved_fragment + ); + assert_eq!( + variant.targets[0].as_ref().unwrap().write_mask, + wgpu::ColorWrites::RED + ); + assert_ne!(variant.key(), base_key); + } } #[test] diff --git a/static/index.html b/static/index.html index 6db4595..0ad68f9 100644 --- a/static/index.html +++ b/static/index.html @@ -175,6 +175,7 @@ + diff --git a/static/render-graph/catalog.js b/static/render-graph/catalog.js index 30a9790..ad917a8 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 = 9; +export const CATALOG_VERSION = 10; const exact = (type) => ({ kind: "exact", types: [type] }); const i = (type, required = true, authoringType, defaultPolicy = required ? "none" : "parameter_literal") => ({ accepted: typeof type === "string" ? exact(type) : type, @@ -76,7 +76,7 @@ export const semanticCatalog = Object.freeze({ parameters: {}, }, texture: { - version: 1, + version: 2, execution: "source", inputs: {}, outputs: { texture: o("texture") }, @@ -108,7 +108,7 @@ export const semanticCatalog = Object.freeze({ parameters: { cameraSelection: "active" }, }, pipeline: { - version: 3, + version: 4, execution: "render", inputs: { mesh: i("mesh_data"), diff --git a/static/render-graph/presets.js b/static/render-graph/presets.js index 1312189..b37ea41 100644 --- a/static/render-graph/presets.js +++ b/static/render-graph/presets.js @@ -4,11 +4,11 @@ const input = (node, socket) => ({ node, socket }); const node = (id, key, parameters = {}, inputs = {}) => ({ id, state: "enabled", executor: { key, version: descriptors[key].version }, parameters, inputs, }); -const texture = (format, scale = 1, heightScale = scale) => ({ +const texture = (format, scale = 1, heightScale = scale, sampleCount = 1) => ({ texture: { dimension: "d2", format, extent: { kind: "surface_relative", width: { numerator: 1, denominator: scale }, height: { numerator: 1, denominator: heightScale }, depthOrArrayLayers: 1 }, - mipLevelCount: 1, sampleCount: 1, viewFormats: [], + mipLevelCount: 1, sampleCount, viewFormats: [], }, residency: "transient", }); @@ -57,6 +57,11 @@ export const hdr = graph("preset_hdr_fullscreen", [ ...scene(), node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") }), ]); +export const msaa = graph("preset_msaa", [ + node("msaa_hdr", "texture", texture("rgba16_float", 1, 1, 4)), + ...scene("msaa_hdr"), + node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") }), +]); export const culling = graph("preset_gpu_culling", (() => { return [...scene(undefined, undefined, 1, true), node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") })]; })()); @@ -108,4 +113,4 @@ export const grading = graph("preset_grading", [ node("mixer", "channel_mixer", { redOutput: [1,0,0], greenOutput: [0,1,0], blueOutput: [0,0,1], factor: 1 }, { source: input("saturation", "color"), colorTarget: input("mixer_hdr", "texture") }), node("frame_out", "frame_out", frameOut(true), { color: input("mixer", "color") }), ]); -export const renderGraphPresets = Object.freeze({ midnight, ember, hdr, culling, tone, contain, reinhard, linear, grading, edges, bloom, combined }); +export const renderGraphPresets = Object.freeze({ midnight, ember, hdr, msaa, culling, tone, contain, reinhard, linear, grading, edges, bloom, combined }); diff --git a/tests/fxnode-composition.test.js b/tests/fxnode-composition.test.js index 8cb8148..1bf4ff1 100644 --- a/tests/fxnode-composition.test.js +++ b/tests/fxnode-composition.test.js @@ -36,7 +36,7 @@ test("production render graph composition passes fxnode's public validator", asy result.ok ? undefined : JSON.stringify(result.issues, null, 2), ); assert.equal(fxNodeComposition.schemaVersion, 2); - assert.equal(fxNodeComposition.version, 9); + assert.equal(fxNodeComposition.version, 10); assert.equal(Object.keys(fxNodeComposition.nodes).length, 42); assert.ok( Object.values(fxNodeComposition.nodes).every( diff --git a/tests/render-graph-authoring.test.js b/tests/render-graph-authoring.test.js index 4be63c9..b8a87f5 100644 --- a/tests/render-graph-authoring.test.js +++ b/tests/render-graph-authoring.test.js @@ -5,15 +5,16 @@ import { CATALOG_VERSION, semanticCatalog, nodeDefinitions, descriptors, } from "../static/render-graph/catalog.js"; -test("catalog v9 exposes the final mesh, pipeline, and typed-expression contracts", () => { - assert.equal(CATALOG_VERSION, 9); +test("catalog v10 exposes the final mesh, pipeline, and typed-expression contracts", () => { + assert.equal(CATALOG_VERSION, 10); 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, 3); + assert.equal(semanticCatalog.pipeline.version, 4); assert.equal(semanticCatalog.pipeline.inputs.predicate.required, false); + assert.deepEqual(nodeDefinitions.texture.parameters.sampleCount.enum, ["1", "4"]); for (const key of ["and", "xnor", "equals_f32", "greater_than_u32", "combine_vec4", "separate_mat4", "combine_u32_bits", "separate_u32x16", "separate_local_aabb"]) assert.equal(semanticCatalog[key].execution, "expression", key); @@ -27,7 +28,7 @@ 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, 3); + assert.equal(byId.ground.executor.version, 4); assert.equal(byId.ground.inputs.predicate.node, "ground_final"); }); diff --git a/tests/render-graph-presets.test.js b/tests/render-graph-presets.test.js index 933c2b6..4bda2b5 100644 --- a/tests/render-graph-presets.test.js +++ b/tests/render-graph-presets.test.js @@ -4,7 +4,7 @@ import * as presets from "../static/render-graph/presets.js"; 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, 12); + assert.equal(Object.keys(presets.renderGraphPresets).length, 13); for (const [name, graph] of Object.entries(presets.renderGraphPresets)) { assert.deepEqual([graph.schemaVersion, graph.revision], [2, 1], name); assert.equal(new Set(graph.nodes.map((node) => node.id)).size, graph.nodes.length, name); @@ -12,6 +12,12 @@ test("all presets use current schemas, versions, and one frame output", () => { for (const node of graph.nodes) assert.equal(node.executor.version, descriptors[node.executor.key].version, `${name}:${node.id}`); } + 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.equal(typeof presets.msaa.nodes.find((node) => node.id === "msaa_hdr") + .parameters.texture.sampleCount, "number"); }); test("presets classify visibility and material through type.words[0] predicates", () => { @@ -25,7 +31,7 @@ test("presets classify visibility and material through type.words[0] predicates" assert.deepEqual(byId.pbr_double.inputs.predicate, { node: name === "culling" ? "pbr_double_final" : "double_class", socket: "value" }, name); for (const pipeline of [byId.ground, byId.pbr, byId.pbr_double]) { assert.deepEqual(pipeline.inputs.mesh, { node: "mesh", socket: "mesh" }); - assert.equal(pipeline.executor.version, 3); + assert.equal(pipeline.executor.version, 4); } } });