From 67cf3a65558a4970c21d90ad3a6e16d1bef16b19 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 28 Jul 2026 13:45:10 +0000 Subject: [PATCH] refactor: centralize fullscreen graph policy Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab Co-authored-by: Heaust Azure --- renderer/src/render_graph/compiler.rs | 54 +++---- renderer/src/render_graph/contracts.rs | 24 +++ renderer/src/render_graph/runtime.rs | 74 ++++----- renderer/src/render_graph/tests.rs | 25 +++ renderer/src/renderer/fullscreen_copy.wgsl | 12 +- renderer/src/renderer/mod.rs | 177 +++++++++++++++------ static/render-graph/adapter.js | 4 +- static/render-graph/catalog.js | 17 +- static/render-graph/presets.js | 4 +- tests/render-graph-authoring.test.js | 8 + tests/render-graph-presets.test.js | 6 + 11 files changed, 272 insertions(+), 133 deletions(-) diff --git a/renderer/src/render_graph/compiler.rs b/renderer/src/render_graph/compiler.rs index 97a0e2c..9bc5a9d 100644 --- a/renderer/src/render_graph/compiler.rs +++ b/renderer/src/render_graph/compiler.rs @@ -946,8 +946,7 @@ pub fn compile(graph: Graph) -> Result { } let transition_sockets: &[(&str, u16)] = match contracts[i].key { "pipeline" => &[("colorTarget", 0), ("depthTarget", 1)], - "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" - | "luminance_edge" => &[("colorTarget", 0)], + _ if contracts[i].fullscreen_policy.is_some() => &[("colorTarget", 0)], _ => continue, }; for &(input_socket, output_ordinal) in transition_sockets { @@ -1118,12 +1117,7 @@ pub fn compile(graph: Graph) -> Result { format!("nodes[{i}].inputs"), )); } - } else if contracts[i].key != "frame_out" - && contracts[i] - .inputs - .iter() - .any(|input| matches!(input.role, InputRole::SampledTexture)) - { + } else if contracts[i].fullscreen_policy.is_some() { let hazard = contracts[i].inputs.iter().filter(|input| matches!(input.role, InputRole::SampledTexture)).any(|input| matches!((version_of.get(&bound[i][input.name].producer), version_of.get(&OutputKey(i, 0))), (Some((sf, _, _)), Some((tf, _, _))) if sf == tf)); if hazard { return Err(error( @@ -1213,13 +1207,7 @@ pub fn compile(graph: Graph) -> Result { } for i in 0..graph.nodes.len() { - if !live.contains(&i) - || contracts[i].key == "frame_out" - || !contracts[i] - .inputs - .iter() - .any(|input| matches!(input.role, InputRole::SampledTexture)) - { + if !live.contains(&i) || contracts[i].fullscreen_policy.is_none() { continue; } let source_key = bound[i]["source"].producer; @@ -1244,7 +1232,9 @@ pub fn compile(graph: Graph) -> Result { let authored_target_ok = target_descriptor.is_some_and(|descriptor| { descriptor.format == TextureFormat::Rgba16Float && is_single_view_d2(descriptor) }); - let bloom_input_ok = if contracts[i].key == "bloom_composite" { + let bloom_input_ok = if contracts[i].fullscreen_policy + == Some(FullscreenPolicy::BloomComposite) + { let bloom_key = bound[i]["bloom"].producer; let Some(&(bloom_family_id, _, _)) = version_of.get(&bloom_key) else { return Err(error( @@ -1264,8 +1254,8 @@ pub fn compile(graph: Graph) -> Result { let source_is_full_surface = matches!(&source_descriptor.extent, NormalizedTextureExtent::SurfaceRelative { width, height, depth_or_array_layers: 1 } if *width == Ratio { numerator:1, denominator:1 } && *height == Ratio { numerator:1, denominator:1 }); let target_matches_source = target_descriptor .is_some_and(|descriptor| descriptor.extent == source_descriptor.extent); - let descriptor_ok = match contracts[i].key { - "fullscreen_copy" => { + let descriptor_ok = match contracts[i].fullscreen_policy { + Some(FullscreenPolicy::Copy) => { target_descriptor.is_none() && source_is_full_surface || target_descriptor.is_some_and(|descriptor| { descriptor.format != TextureFormat::Depth32Float @@ -1273,15 +1263,17 @@ pub fn compile(graph: Graph) -> Result { && descriptor.extent == source_descriptor.extent }) } - "tone_map" => target_descriptor.is_some_and(|descriptor| { + Some(FullscreenPolicy::ToneMap) => target_descriptor.is_some_and(|descriptor| { descriptor.format != TextureFormat::Depth32Float && descriptor.format != TextureFormat::R32Float && is_single_view_d2(descriptor) && descriptor.extent == source_descriptor.extent }), - "bloom_extract" => authored_target_ok, - "bloom_blur" | "luminance_edge" => authored_target_ok && target_matches_source, - "bloom_composite" => authored_target_ok && target_matches_source && bloom_input_ok, + Some(FullscreenPolicy::BloomExtract) => authored_target_ok, + Some(FullscreenPolicy::HdrSameExtent) => authored_target_ok && target_matches_source, + Some(FullscreenPolicy::BloomComposite) => { + authored_target_ok && target_matches_source && bloom_input_ok + } _ => false, }; if !source_ok || !descriptor_ok { @@ -1650,21 +1642,19 @@ pub fn compile(graph: Graph) -> Result { }), } } - "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" - | "luminance_edge" => { + _ if contracts[i].fullscreen_policy.is_some() => { let color = output_ids[&OutputKey(i, 0)]; let load = NormalizedColorLoad::Clear { value: [0.0, 0.0, 0.0, 0.0], }; - accesses.push(CompiledAccess { - socket: "source".into(), - resource: input_resource("source"), - mode: AccessMode::SampledTexture, - }); - if contracts[i].key == "bloom_composite" { + for input in contracts[i] + .inputs + .iter() + .filter(|input| input.role == InputRole::SampledTexture) + { accesses.push(CompiledAccess { - socket: "bloom".into(), - resource: input_resource("bloom"), + socket: input.name.into(), + resource: input_resource(input.name), mode: AccessMode::SampledTexture, }); } diff --git a/renderer/src/render_graph/contracts.rs b/renderer/src/render_graph/contracts.rs index 1ba3dda..57c58ed 100644 --- a/renderer/src/render_graph/contracts.rs +++ b/renderer/src/render_graph/contracts.rs @@ -22,6 +22,15 @@ pub enum ExecutionClass { Frame, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FullscreenPolicy { + Copy, + ToneMap, + HdrSameExtent, + BloomExtract, + BloomComposite, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] pub enum InputCardinality { @@ -81,6 +90,8 @@ pub struct Contract { pub inputs: &'static [InputSocketContract], pub outputs: &'static [OutputSocketContract], pub inherently_observable: bool, + #[serde(skip)] + pub fullscreen_policy: Option, } use SemanticType::*; @@ -268,6 +279,7 @@ pub static CONTRACTS: &[Contract] = &[ inputs: NONE_IN, outputs: MESH_OUT, inherently_observable: false, + fullscreen_policy: None, }, Contract { key: "texture", @@ -276,6 +288,7 @@ pub static CONTRACTS: &[Contract] = &[ inputs: NONE_IN, outputs: TEXTURE_OUT, inherently_observable: false, + fullscreen_policy: None, }, Contract { key: "frustum_cull", @@ -284,6 +297,7 @@ pub static CONTRACTS: &[Contract] = &[ inputs: CULL_IN, outputs: CULLED_OUT, inherently_observable: false, + fullscreen_policy: None, }, Contract { key: "mesh_query", @@ -292,6 +306,7 @@ pub static CONTRACTS: &[Contract] = &[ inputs: QUERY_IN, outputs: DRAW_OUT, inherently_observable: false, + fullscreen_policy: None, }, Contract { key: "pipeline_registry", @@ -300,6 +315,7 @@ pub static CONTRACTS: &[Contract] = &[ inputs: REGISTRY_IN, outputs: ACTIVATION_OUT, inherently_observable: false, + fullscreen_policy: None, }, Contract { key: "pipeline", @@ -308,6 +324,7 @@ pub static CONTRACTS: &[Contract] = &[ inputs: PIPELINE_IN, outputs: PIPELINE_OUT, inherently_observable: false, + fullscreen_policy: None, }, Contract { key: "fullscreen_copy", @@ -316,6 +333,7 @@ pub static CONTRACTS: &[Contract] = &[ inputs: FULLSCREEN_COPY_IN, outputs: FULLSCREEN_COPY_OUT, inherently_observable: false, + fullscreen_policy: Some(FullscreenPolicy::Copy), }, Contract { key: "tone_map", @@ -324,6 +342,7 @@ pub static CONTRACTS: &[Contract] = &[ inputs: FULLSCREEN_COPY_IN, outputs: FULLSCREEN_COPY_OUT, inherently_observable: false, + fullscreen_policy: Some(FullscreenPolicy::ToneMap), }, Contract { key: "bloom_extract", @@ -332,6 +351,7 @@ pub static CONTRACTS: &[Contract] = &[ inputs: FULLSCREEN_COPY_IN, outputs: FULLSCREEN_COPY_OUT, inherently_observable: false, + fullscreen_policy: Some(FullscreenPolicy::BloomExtract), }, Contract { key: "bloom_blur", @@ -340,6 +360,7 @@ pub static CONTRACTS: &[Contract] = &[ inputs: FULLSCREEN_COPY_IN, outputs: FULLSCREEN_COPY_OUT, inherently_observable: false, + fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), }, Contract { key: "bloom_composite", @@ -348,6 +369,7 @@ pub static CONTRACTS: &[Contract] = &[ inputs: BLOOM_COMPOSITE_IN, outputs: FULLSCREEN_COPY_OUT, inherently_observable: false, + fullscreen_policy: Some(FullscreenPolicy::BloomComposite), }, Contract { key: "luminance_edge", @@ -356,6 +378,7 @@ pub static CONTRACTS: &[Contract] = &[ inputs: FULLSCREEN_COPY_IN, outputs: FULLSCREEN_COPY_OUT, inherently_observable: false, + fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), }, Contract { key: "frame_out", @@ -364,6 +387,7 @@ pub static CONTRACTS: &[Contract] = &[ inputs: FRAME_OUT_IN, outputs: NONE_OUT, inherently_observable: true, + fullscreen_policy: None, }, ]; diff --git a/renderer/src/render_graph/runtime.rs b/renderer/src/render_graph/runtime.rs index 1095a33..a329a45 100644 --- a/renderer/src/render_graph/runtime.rs +++ b/renderer/src/render_graph/runtime.rs @@ -256,20 +256,13 @@ fn valid_pipeline_name(name: &str) -> bool { } fn execution_supported(key: &str) -> bool { - matches!( - key, - "frustum_cull" - | "mesh_query" - | "pipeline_registry" - | "pipeline" - | "fullscreen_copy" - | "tone_map" - | "bloom_extract" - | "bloom_blur" - | "bloom_composite" - | "luminance_edge" - | "frame_out" - ) + contract(key).is_some_and(|contract| { + contract.fullscreen_policy.is_some() + || matches!( + key, + "frustum_cull" | "mesh_query" | "pipeline_registry" | "pipeline" | "frame_out" + ) + }) } fn resource_is_mesh(graph: &CompiledGraph, id: u32) -> bool { @@ -326,19 +319,12 @@ fn validate_fullscreen_execution( graph: &CompiledGraph, i: usize, execution: &CompiledExecution, + contract: &Contract, ) -> Result<(), GraphError> { let key = execution.executor.key.as_str(); - if !matches!( - key, - "fullscreen_copy" - | "tone_map" - | "bloom_extract" - | "bloom_blur" - | "bloom_composite" - | "luminance_edge" - ) { + let Some(policy) = contract.fullscreen_policy else { return Ok(()); - } + }; let path = |field| format!("executions[{i}].{field}"); let valid_parameters = match (key, &execution.parameters) { ("fullscreen_copy", NormalizedParameters::FullscreenCopy) => true, @@ -372,16 +358,12 @@ fn validate_fullscreen_execution( path("parameters"), )); } - let expected_inputs = if key == "bloom_composite" { - &["source", "bloom", "colorTarget"][..] - } else { - &["source", "colorTarget"][..] - }; + let expected_inputs: Vec<_> = contract.inputs.iter().map(|input| input.name).collect(); if execution.inputs.len() != expected_inputs.len() || execution .inputs .iter() - .zip(expected_inputs) + .zip(&expected_inputs) .any(|(v, s)| v.socket != *s) { return Err(invalid("fullscreen inputs mismatch", path("inputs"))); @@ -410,7 +392,18 @@ fn validate_fullscreen_execution( { return Err(invalid("fullscreen attachment mismatch", path("kind"))); } - let sampled_count = expected_inputs.len() - 1; + let sampled_inputs: Vec<_> = contract + .inputs + .iter() + .filter(|input| input.role == InputRole::SampledTexture) + .collect(); + let sampled_count = sampled_inputs.len(); + if contract.inputs[..sampled_count] + .iter() + .any(|input| input.role != InputRole::SampledTexture) + { + return Err(invalid("fullscreen inputs mismatch", path("inputs"))); + } if execution.accesses.len() != sampled_count + 1 || execution.inputs[..sampled_count] .iter() @@ -501,13 +494,13 @@ fn validate_fullscreen_execution( single_view_d2(d) && d.format == TextureFormat::Rgba16Float }; let descriptors_valid = hdr(source) - && match key { - "fullscreen_copy" => { + && match policy { + FullscreenPolicy::Copy => { single_view_d2(target_d) && target_d.format != TextureFormat::Depth32Float && target_d.extent == source.extent } - "tone_map" => { + FullscreenPolicy::ToneMap => { single_view_d2(target_d) && !matches!( target_d.format, @@ -515,14 +508,13 @@ fn validate_fullscreen_execution( ) && target_d.extent == source.extent } - "bloom_extract" => hdr(target_d), - "bloom_blur" | "luminance_edge" => hdr(target_d) && target_d.extent == source.extent, - "bloom_composite" => { + FullscreenPolicy::BloomExtract => hdr(target_d), + FullscreenPolicy::HdrSameExtent => hdr(target_d) && target_d.extent == source.extent, + FullscreenPolicy::BloomComposite => { hdr(target_d) && target_d.extent == source.extent && texture_descriptor(graph, execution.inputs[1].resource).is_some_and(hdr) } - _ => false, }; if !descriptors_valid { return Err(invalid( @@ -769,7 +761,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> { referenced.insert(access.resource); } validate_compute_execution(graph, i, execution)?; - validate_fullscreen_execution(graph, i, execution)?; + validate_fullscreen_execution(graph, i, execution, contract)?; for resource in referenced { uses.get_mut(resource as usize) .ok_or_else(|| { @@ -950,8 +942,8 @@ pub fn prepare_runtime_plan( } } "pipeline_registry" | "pipeline" => {} - "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite" - | "luminance_edge" => {} + _ if contract(&execution.executor.key) + .is_some_and(|contract| contract.fullscreen_policy.is_some()) => {} "frustum_cull" => {} "frame_out" => { if frame_out_index.replace(i).is_some() { diff --git a/renderer/src/render_graph/tests.rs b/renderer/src/render_graph/tests.rs index 4d8dd0f..4853217 100644 --- a/renderer/src/render_graph/tests.rs +++ b/renderer/src/render_graph/tests.rs @@ -847,6 +847,31 @@ fn exact_phase_four_contract_catalog_and_mesh_metadata() { ("frame_out", 1), ] ); + assert_eq!( + CONTRACTS + .iter() + .map(|contract| (contract.key, contract.fullscreen_policy)) + .collect::>(), + [ + ("mesh", None), + ("texture", None), + ("frustum_cull", None), + ("mesh_query", None), + ("pipeline_registry", None), + ("pipeline", None), + ("fullscreen_copy", Some(FullscreenPolicy::Copy)), + ("tone_map", Some(FullscreenPolicy::ToneMap)), + ("bloom_extract", Some(FullscreenPolicy::BloomExtract)), + ("bloom_blur", Some(FullscreenPolicy::HdrSameExtent)), + ("bloom_composite", Some(FullscreenPolicy::BloomComposite)), + ("luminance_edge", Some(FullscreenPolicy::HdrSameExtent)), + ("frame_out", None), + ] + ); + for contract in CONTRACTS { + let serialized = serde_json::to_value(contract).unwrap(); + assert!(serialized.get("fullscreenPolicy").is_none()); + } let mesh = contract("mesh").unwrap(); assert_eq!(mesh.execution, ExecutionClass::Source); assert!(mesh.inputs.is_empty()); diff --git a/renderer/src/renderer/fullscreen_copy.wgsl b/renderer/src/renderer/fullscreen_copy.wgsl index 72189d5..769bc0f 100644 --- a/renderer/src/renderer/fullscreen_copy.wgsl +++ b/renderer/src/renderer/fullscreen_copy.wgsl @@ -1,7 +1,7 @@ @group(0) @binding(0) var source_texture: texture_2d; @group(0) @binding(1) var second_texture: texture_2d; @group(0) @binding(2) var linear_clamp: sampler; -struct Parameters { a: vec4, b: vec4 } +struct Parameters { values: array, 8> } @group(0) @binding(3) var parameters: Parameters; struct VertexOut { @builtin(position) position: vec4, @location(0) uv: vec2 } @@ -20,19 +20,19 @@ fn linear_to_srgb(x: vec3) -> vec3 { let low = x * 12.92; let high = 1.055 * pow(x, vec3(1.0 / 2.4)) - vec3(0.055); return select(high, low, x <= vec3(0.0031308)); } -@fragment fn fs_tone_map(in: VertexOut) -> @location(0) vec4 { let c=sample_source(in.uv); return vec4(linear_to_srgb(aces(c.rgb * parameters.a.x)), c.a); } +@fragment fn fs_tone_map(in: VertexOut) -> @location(0) vec4 { let c=sample_source(in.uv); return vec4(linear_to_srgb(aces(c.rgb * parameters.values[0].x)), c.a); } @fragment fn fs_bloom_extract(in: VertexOut) -> @location(0) vec4 { - let c=sample_source(in.uv); let brightness=max(c.r,max(c.g,c.b)); let knee=max(parameters.a.y,0.00001); let soft=clamp((brightness-parameters.a.x+knee)/(2.0*knee),0.0,1.0); let contribution=max(brightness-parameters.a.x,0.0)+soft*soft*knee; return vec4(c.rgb*contribution/max(brightness,0.00001),1.0); + let c=sample_source(in.uv); let brightness=max(c.r,max(c.g,c.b)); let knee=max(parameters.values[0].y,0.00001); let soft=clamp((brightness-parameters.values[0].x+knee)/(2.0*knee),0.0,1.0); let contribution=max(brightness-parameters.values[0].x,0.0)+soft*soft*knee; return vec4(c.rgb*contribution/max(brightness,0.00001),1.0); } @fragment fn fs_bloom_blur(in: VertexOut) -> @location(0) vec4 { - let size=vec2(textureDimensions(source_texture)); let step=parameters.a.xy*parameters.a.z/size; + let size=vec2(textureDimensions(source_texture)); let step=parameters.values[0].xy*parameters.values[0].z/size; var c=sample_source(in.uv)*0.227027; c+=sample_source(in.uv+step*1.384615)*0.316216; c+=sample_source(in.uv-step*1.384615)*0.316216; c+=sample_source(in.uv+step*3.230769)*0.070270; c+=sample_source(in.uv-step*3.230769)*0.070270; return c; } -@fragment fn fs_bloom_composite(in: VertexOut) -> @location(0) vec4 { let c=sample_source(in.uv); return vec4(c.rgb+textureSampleLevel(second_texture,linear_clamp,in.uv,0.0).rgb*parameters.a.x,c.a); } +@fragment fn fs_bloom_composite(in: VertexOut) -> @location(0) vec4 { let c=sample_source(in.uv); return vec4(c.rgb+textureSampleLevel(second_texture,linear_clamp,in.uv,0.0).rgb*parameters.values[0].x,c.a); } fn luminance(c: vec3) -> f32 { return dot(c,vec3(0.2126,0.7152,0.0722)); } @fragment fn fs_luminance_edge(in: VertexOut) -> @location(0) vec4 { let d=1.0/vec2(textureDimensions(source_texture)); var gx=0.0; var gy=0.0; gx += -luminance(sample_source(in.uv+d*vec2(-1.0,-1.0)).rgb)+luminance(sample_source(in.uv+d*vec2(1.0,-1.0)).rgb); gx += -2.0*luminance(sample_source(in.uv+d*vec2(-1.0,0.0)).rgb)+2.0*luminance(sample_source(in.uv+d*vec2(1.0,0.0)).rgb); gx += -luminance(sample_source(in.uv+d*vec2(-1.0,1.0)).rgb)+luminance(sample_source(in.uv+d*vec2(1.0,1.0)).rgb); gy += -luminance(sample_source(in.uv+d*vec2(-1.0,-1.0)).rgb)-2.0*luminance(sample_source(in.uv+d*vec2(0.0,-1.0)).rgb)-luminance(sample_source(in.uv+d*vec2(1.0,-1.0)).rgb); gy += luminance(sample_source(in.uv+d*vec2(-1.0,1.0)).rgb)+2.0*luminance(sample_source(in.uv+d*vec2(0.0,1.0)).rgb)+luminance(sample_source(in.uv+d*vec2(1.0,1.0)).rgb); - let edge=clamp(length(vec2(gx,gy))*parameters.a.x,0.0,1.0); return vec4(vec3(edge),1.0); + let edge=clamp(length(vec2(gx,gy))*parameters.values[0].x,0.0,1.0); return vec4(vec3(edge),1.0); } diff --git a/renderer/src/renderer/mod.rs b/renderer/src/renderer/mod.rs index 31f5d97..88cb679 100644 --- a/renderer/src/renderer/mod.rs +++ b/renderer/src/renderer/mod.rs @@ -27,6 +27,104 @@ pub use pipeline_library::PipelineLibrary; const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; +#[repr(C, align(16))] +#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +struct FullscreenUniforms { + values: [[f32; 4]; 8], +} + +fn pack_fullscreen_uniforms( + key: &str, + parameters: &crate::render_graph::NormalizedParameters, +) -> Option { + use crate::render_graph::NormalizedParameters; + let first = match (key, parameters) { + ( + "fullscreen_copy" | "frame_out", + NormalizedParameters::FullscreenCopy | NormalizedParameters::FrameOut, + ) => [0.; 4], + ("tone_map", NormalizedParameters::ToneMap { exposure }) => [*exposure, 0., 0., 0.], + ("bloom_extract", NormalizedParameters::BloomExtract { threshold, knee }) => { + [*threshold, *knee, 0., 0.] + } + ("bloom_blur", NormalizedParameters::BloomBlur { direction, radius }) => { + [direction[0], direction[1], *radius, 0.] + } + ("bloom_composite", NormalizedParameters::BloomComposite { intensity }) => { + [*intensity, 0., 0., 0.] + } + ("luminance_edge", NormalizedParameters::LuminanceEdge { strength }) => { + [*strength, 0., 0., 0.] + } + _ => return None, + }; + let mut values = [[0.; 4]; 8]; + values[0] = first; + Some(FullscreenUniforms { values }) +} + +fn resolve_fullscreen_entry(key: &str) -> Option<&'static str> { + match key { + "fullscreen_copy" | "frame_out" => Some("fs_copy"), + "tone_map" => Some("fs_tone_map"), + "bloom_extract" => Some("fs_bloom_extract"), + "bloom_blur" => Some("fs_bloom_blur"), + "bloom_composite" => Some("fs_bloom_composite"), + "luminance_edge" => Some("fs_luminance_edge"), + _ => None, + } +} + +#[cfg(test)] +mod fullscreen_tests { + use super::*; + use crate::render_graph::NormalizedParameters; + + #[test] + fn fullscreen_uniform_abi_and_packer_are_fixed() { + assert_eq!(std::mem::size_of::(), 128); + assert_eq!(std::mem::align_of::(), 16); + let packed = pack_fullscreen_uniforms( + "bloom_blur", + &NormalizedParameters::BloomBlur { + direction: [0.0, 1.0], + radius: 3.0, + }, + ) + .unwrap(); + assert_eq!(packed.values[0], [0.0, 1.0, 3.0, 0.0]); + assert!(packed.values[1..].iter().all(|value| *value == [0.0; 4])); + assert_eq!(bytemuck::bytes_of(&packed).len(), 128); + assert!( + pack_fullscreen_uniforms("tone_map", &NormalizedParameters::FullscreenCopy).is_none() + ); + } + + #[test] + fn fullscreen_entries_are_explicit() { + assert_eq!(resolve_fullscreen_entry("fullscreen_copy"), Some("fs_copy")); + assert_eq!(resolve_fullscreen_entry("frame_out"), Some("fs_copy")); + assert_eq!(resolve_fullscreen_entry("tone_map"), Some("fs_tone_map")); + assert_eq!( + resolve_fullscreen_entry("bloom_extract"), + Some("fs_bloom_extract") + ); + assert_eq!( + resolve_fullscreen_entry("bloom_blur"), + Some("fs_bloom_blur") + ); + assert_eq!( + resolve_fullscreen_entry("bloom_composite"), + Some("fs_bloom_composite") + ); + assert_eq!( + resolve_fullscreen_entry("luminance_edge"), + Some("fs_luminance_edge") + ); + assert_eq!(resolve_fullscreen_entry("unknown"), None); + } +} + struct GpuTextureSlot { _texture: wgpu::Texture, view: wgpu::TextureView, @@ -840,7 +938,7 @@ impl Renderer { ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, - min_binding_size: None, + min_binding_size: wgpu::BufferSize::new(128), }, count: None, }, @@ -872,11 +970,14 @@ impl Renderer { }); let mut executions = Vec::new(); for (index, execution) in graph.executions.iter().enumerate() { + let contract = crate::render_graph::contract(&execution.executor.key) + .ok_or_else(|| fail("executor contract missing"))?; match execution.executor.key.as_str() { "frustum_cull" => executions.push(PreparedExecution::FrustumCull), "mesh_query" => executions.push(PreparedExecution::MeshQuery), - "frame_out" | "fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" - | "bloom_composite" | "luminance_edge" => { + _ if execution.executor.key == "frame_out" + || contract.fullscreen_policy.is_some() => + { let frame_out = execution.executor.key == "frame_out"; let (source, second) = if frame_out { let ExecutionKind::FrameOut { color } = execution.kind else { @@ -884,51 +985,37 @@ impl Renderer { }; (color, color) } else { - match execution.inputs.as_slice() { - [source, _color_target] => (source.resource, source.resource), - [source, bloom, _color_target] - if execution.executor.key == "bloom_composite" => - { - (source.resource, bloom.resource) - } + let sampled: Vec<_> = contract + .inputs + .iter() + .enumerate() + .filter(|(_, input)| { + input.role == crate::render_graph::InputRole::SampledTexture + }) + .map(|(index, _)| { + execution.inputs.get(index).map(|input| input.resource) + }) + .collect::>() + .ok_or_else(|| fail("fullscreen inputs mismatch"))?; + match (contract.fullscreen_policy, sampled.as_slice()) { + ( + Some(crate::render_graph::FullscreenPolicy::BloomComposite), + [source, second], + ) => (*source, *second), + (Some(_), [source]) => (*source, *source), _ => return Err(fail("fullscreen inputs mismatch")), } }; - let values: [f32; 8] = - match (execution.executor.key.as_str(), &execution.parameters) { - ( - "fullscreen_copy" | "frame_out", - NormalizedParameters::FullscreenCopy - | NormalizedParameters::FrameOut, - ) => [0.; 8], - ("tone_map", NormalizedParameters::ToneMap { exposure }) => { - [*exposure, 0., 0., 0., 0., 0., 0., 0.] - } - ( - "bloom_extract", - NormalizedParameters::BloomExtract { threshold, knee }, - ) => [*threshold, *knee, 0., 0., 0., 0., 0., 0.], - ( - "bloom_blur", - NormalizedParameters::BloomBlur { direction, radius }, - ) => [direction[0], direction[1], *radius, 0., 0., 0., 0., 0.], - ( - "bloom_composite", - NormalizedParameters::BloomComposite { intensity }, - ) => [*intensity, 0., 0., 0., 0., 0., 0., 0.], - ( - "luminance_edge", - NormalizedParameters::LuminanceEdge { strength }, - ) => [*strength, 0., 0., 0., 0., 0., 0., 0.], - _ => return Err(fail("executor parameters mismatch")), - }; + let values = + pack_fullscreen_uniforms(&execution.executor.key, &execution.parameters) + .ok_or_else(|| fail("executor parameters mismatch"))?; use wgpu::util::DeviceExt; let uniform = self.context .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(" post parameters"), - contents: bytemuck::cast_slice(&values), + contents: bytemuck::bytes_of(&values), usage: wgpu::BufferUsages::UNIFORM, }); let target_format = if frame_out { @@ -960,16 +1047,8 @@ impl Renderer { .descriptor .format }; - let entry = match execution.executor.key.as_str() { - "fullscreen_copy" => "fs_copy", - "tone_map" => "fs_tone_map", - "bloom_extract" => "fs_bloom_extract", - "bloom_blur" => "fs_bloom_blur", - "bloom_composite" => "fs_bloom_composite", - "luminance_edge" => "fs_luminance_edge", - "frame_out" => "fs_copy", - _ => return Err(fail("fullscreen executor mismatch")), - }; + let entry = resolve_fullscreen_entry(&execution.executor.key) + .ok_or_else(|| fail("fullscreen executor mismatch"))?; let pipeline = self.context.device.create_render_pipeline( &wgpu::RenderPipelineDescriptor { label: Some(" post pipeline"), diff --git a/static/render-graph/adapter.js b/static/render-graph/adapter.js index 1852e0a..471eeed 100644 --- a/static/render-graph/adapter.js +++ b/static/render-graph/adapter.js @@ -172,7 +172,7 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { if ( !exactKeys(n, nodeKeys) || n.known !== true || - n.typeVersion !== 1 || + n.typeVersion !== descriptor.version || typeof n.muted !== "boolean" || typeof n.collapsed !== "boolean" || typeof n.label !== "string" || @@ -340,7 +340,7 @@ export function adaptFxNodeSnapshot(raw, revision = 1) { value: { id: n.id, state: n.muted ? "muted" : "enabled", - executor: { key: n.typeId, version: 1 }, + executor: { key: n.typeId, version: descriptor.version }, parameters, inputs: {}, }, diff --git a/static/render-graph/catalog.js b/static/render-graph/catalog.js index d6735e4..2147a38 100644 --- a/static/render-graph/catalog.js +++ b/static/render-graph/catalog.js @@ -25,6 +25,7 @@ const texture = { }; export const semanticCatalog = Object.freeze({ mesh: { + version: 1, execution: "source", inputs: {}, outputs: { @@ -39,6 +40,7 @@ export const semanticCatalog = Object.freeze({ parameters: {}, }, texture: { + version: 1, execution: "source", inputs: {}, outputs: { texture: o("texture") }, @@ -60,6 +62,7 @@ export const semanticCatalog = Object.freeze({ }, }, frustum_cull: { + version: 1, execution: "compute", inputs: { mesh: i("mesh_data"), @@ -74,6 +77,7 @@ export const semanticCatalog = Object.freeze({ parameters: { cameraSelection: "active" }, }, mesh_query: { + version: 1, execution: "compute", inputs: { mesh: i("mesh_data"), @@ -87,12 +91,14 @@ export const semanticCatalog = Object.freeze({ }, }, pipeline_registry: { + version: 1, execution: "cpu_preparation", inputs: { pipelineIndices: i("pipeline_index_stream") }, outputs: { activation: o("pipeline_activation") }, parameters: {}, }, pipeline: { + version: 1, execution: "render", inputs: { mesh: i("mesh_data"), @@ -105,6 +111,7 @@ export const semanticCatalog = Object.freeze({ parameters: { pipeline: "gltf_standard", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1] }, }, fullscreen_copy: { + version: 1, execution: "render", inputs: { source: i("texture"), @@ -114,6 +121,7 @@ export const semanticCatalog = Object.freeze({ parameters: {}, }, tone_map: { + version: 1, execution: "render", inputs: { source: i("texture"), @@ -123,6 +131,7 @@ export const semanticCatalog = Object.freeze({ parameters: { exposure: 1 }, }, bloom_extract: { + version: 1, execution: "render", inputs: { source: i("texture"), @@ -132,6 +141,7 @@ export const semanticCatalog = Object.freeze({ parameters: { threshold: 1, knee: 0.5 }, }, bloom_blur: { + version: 1, execution: "render", inputs: { source: i("texture"), @@ -141,6 +151,7 @@ export const semanticCatalog = Object.freeze({ parameters: { direction: [1, 0], radius: 1 }, }, bloom_composite: { + version: 1, execution: "render", inputs: { source: i("texture"), @@ -151,6 +162,7 @@ export const semanticCatalog = Object.freeze({ parameters: { intensity: 1 }, }, luminance_edge: { + version: 1, execution: "render", inputs: { source: i("texture"), @@ -160,6 +172,7 @@ export const semanticCatalog = Object.freeze({ parameters: { strength: 2 }, }, frame_out: { + version: 1, execution: "frame", inputs: { color: i("texture") }, outputs: {}, @@ -390,7 +403,7 @@ export const nodeDefinitions = Object.fromEntries( return [ key, { - version: 1, + version: c.version, title: key.replaceAll("_", " "), behavior: "standard", style: c.execution, @@ -427,7 +440,7 @@ export const descriptors = Object.fromEntries( Object.entries(semanticCatalog).map(([key, c]) => [ key, { - version: 1, + version: c.version, inputs: c.inputs, outputs: c.outputs, parameters: c.parameters, diff --git a/static/render-graph/presets.js b/static/render-graph/presets.js index f5c0cbb..6a58736 100644 --- a/static/render-graph/presets.js +++ b/static/render-graph/presets.js @@ -1,6 +1,8 @@ +import { descriptors } from "./catalog.js"; + const input = (node, socket) => ({ node, socket }); const node = (id, key, parameters = {}, inputs = {}) => ({ - id, state: "enabled", executor: { key, version: 1 }, parameters, inputs, + id, state: "enabled", executor: { key, version: descriptors[key].version }, parameters, inputs, }); const texture = (format, scale = 1) => ({ texture: { diff --git a/tests/render-graph-authoring.test.js b/tests/render-graph-authoring.test.js index 8862bc0..9679047 100644 --- a/tests/render-graph-authoring.test.js +++ b/tests/render-graph-authoring.test.js @@ -12,6 +12,7 @@ import { nodeDefinitions, GRAPH_ID, CATALOG_VERSION, + descriptors, socketTypes, } from "../static/render-graph/catalog.js"; import { culling } from "../static/render-graph/presets.js"; @@ -93,6 +94,12 @@ function fixture() { }; } test("catalog exhaustively mirrors all current contracts", () => { + for (const [key, semantic] of Object.entries(semanticCatalog)) { + assert.ok(Object.hasOwn(semantic, "version")); + assert.equal(semantic.version, 1); + assert.equal(nodeDefinitions[key].version, semantic.version); + assert.equal(descriptors[key].version, semantic.version); + } assert.deepEqual( Object.keys(semanticCatalog), [ @@ -306,6 +313,7 @@ test("adapter rejects hostile shape, IDs, duplicates, catalog, sockets and type reject((x) => (x.nodes[0].id = "bad id"), "AUTHORING_ID"); reject((x) => (x.nodes[1].id = x.nodes[0].id), "AUTHORING_ID_DUPLICATE"); reject((x) => (x.nodes[0].typeId = "wat"), "AUTHORING_NODE_TYPE"); + reject((x) => (x.nodes[0].typeVersion = 2), "AUTHORING_NODE_INVALID"); reject((x) => (x.nodes[0].sockets = []), "AUTHORING_SOCKET_SET"); reject((x) => (x.links[0].toSocketId = "missing:x"), "AUTHORING_LINK"); reject((x) => { diff --git a/tests/render-graph-presets.test.js b/tests/render-graph-presets.test.js index a20d290..325ea5b 100644 --- a/tests/render-graph-presets.test.js +++ b/tests/render-graph-presets.test.js @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import * as presets from "../static/render-graph/presets.js"; +import { descriptors } from "../static/render-graph/catalog.js"; const order = [ "midnight", @@ -168,6 +169,11 @@ test("presets have the exact canonical pipeline identities, schemas, and node se ), ); assert.ok(!graph.nodes.some((node) => node.id === "copy")); + assert.ok( + graph.nodes.every( + (node) => node.executor.version === descriptors[node.executor.key].version, + ), + ); } });