feat: compile multisampled graph attachments

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 00:48:35 +00:00
co-authored by heaust
parent eb640f0183
commit b5366d8b77
14 changed files with 985 additions and 179 deletions
+273 -60
View File
@@ -723,9 +723,6 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
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<CompiledGraph, GraphError> {
});
}
// 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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
{
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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
}
}
// 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::<OutputKey>::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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
_ => 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<CompiledGraph, GraphError> {
}
});
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<CompiledGraph, GraphError> {
let NormalizedParameters::FrameOut { dynamic_range, .. } = &params[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<CompiledGraph, GraphError> {
},
});
}
// 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<CompiledGraph, GraphError> {
) {
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<CompiledGraph, GraphError> {
};
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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
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<CompiledGraph, GraphError> {
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);
}
_ => {}
}
}
+2 -2
View File
@@ -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)),
+17
View File
@@ -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<u32>,
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)]
+297 -61
View File
@@ -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<u32>],
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<u32>],
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<u32>],
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 {
+209 -7
View File
@@ -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,
+1 -1
View File
@@ -174,7 +174,7 @@ pub(crate) fn encode_compiled<T: Scene>(
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,
+103
View File
@@ -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<T: Scene + 'static> Renderer<T> {
})
})
.collect::<Result<Vec<_>, _>>()?;
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<T: Scene + 'static> Renderer<T> {
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 {
+57 -35
View File
@@ -96,11 +96,17 @@ fn target_variant_spec(
depth_format: Option<wgpu::TextureFormat>,
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<wgpu::TextureFormat>,
depth_compare: wgpu::CompareFunction,
depth_write: bool,
sample_count: u32,
) -> Result<wgpu::RenderPipeline, String> {
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]
+1
View File
@@ -175,6 +175,7 @@
<option value="midnight">Midnight</option>
<option value="ember">Ember</option>
<option value="hdr">HDR Fullscreen</option>
<option value="msaa">4× MSAA</option>
<option value="culling">GPU frustum culling</option>
<option value="tone">ACES display</option>
<option value="contain">SDR contain</option>
+3 -3
View File
@@ -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"),
+8 -3
View File
@@ -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 });
+1 -1
View File
@@ -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(
+5 -4
View File
@@ -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");
});
+8 -2
View File
@@ -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);
}
}
});