diff --git a/renderer/src/render_graph/compiler.rs b/renderer/src/render_graph/compiler.rs index 298ff17..26e55e1 100644 --- a/renderer/src/render_graph/compiler.rs +++ b/renderer/src/render_graph/compiler.rs @@ -40,6 +40,7 @@ struct PipelineParameters { #[serde(deny_unknown_fields)] #[serde(rename_all = "camelCase")] struct FrameOutParameters { + surface_format: SurfaceFormatRequest, hdr_enabled: bool, tone_mapper: ToneMapper, exposure_stops: f32, @@ -544,6 +545,7 @@ fn decode(node: &Node, i: usize) -> Result { &format!("{base}.backgroundColor"), )?; NormalizedParameters::FrameOut { + surface_format: p.surface_format, dynamic_range: if p.hdr_enabled { FrameDynamicRange::Hdr { tone_mapper: p.tone_mapper, diff --git a/renderer/src/render_graph/contracts.rs b/renderer/src/render_graph/contracts.rs index cc3cfb7..408f706 100644 --- a/renderer/src/render_graph/contracts.rs +++ b/renderer/src/render_graph/contracts.rs @@ -408,7 +408,7 @@ pub static CONTRACTS: &[Contract] = &[ }, Contract { key: "frame_out", - version: 2, + version: 3, execution: ExecutionClass::Frame, inputs: FRAME_OUT_IN, outputs: NONE_OUT, diff --git a/renderer/src/render_graph/plan.rs b/renderer/src/render_graph/plan.rs index 32f6de8..33b910f 100644 --- a/renderer/src/render_graph/plan.rs +++ b/renderer/src/render_graph/plan.rs @@ -256,6 +256,7 @@ pub enum NormalizedParameters { strength: f32, }, FrameOut { + surface_format: SurfaceFormatRequest, dynamic_range: FrameDynamicRange, output_transfer: OutputTransfer, scale_mode: ScaleMode, @@ -264,6 +265,15 @@ pub enum NormalizedParameters { }, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SurfaceFormatRequest { + Preferred, + Rgba8Unorm, + Bgra8Unorm, + Rgba16Float, +} + #[derive(Clone, Copy, Debug, PartialEq, Serialize, serde::Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum FrameDynamicRange { diff --git a/renderer/src/render_graph/runtime.rs b/renderer/src/render_graph/runtime.rs index 6f39068..5329f86 100644 --- a/renderer/src/render_graph/runtime.rs +++ b/renderer/src/render_graph/runtime.rs @@ -26,7 +26,131 @@ pub struct RuntimeSurfaceContract { pub width: u32, pub height: u32, pub usage: wgpu::TextureUsages, + pub present_mode: wgpu::PresentMode, + pub alpha_mode: wgpu::CompositeAlphaMode, pub view_formats: Vec, + pub desired_maximum_frame_latency: u32, +} + +fn frame_out_surface_request( + graph: &CompiledGraph, +) -> Result<(SurfaceFormatRequest, u32), GraphError> { + graph + .executions + .iter() + .find_map(|execution| match execution.parameters { + NormalizedParameters::FrameOut { surface_format, .. } => { + Some((surface_format, execution.original_node_index)) + } + _ => None, + }) + .ok_or_else(|| { + error( + "GRAPH_EXECUTION_UNSUPPORTED", + "exactly one frame_out is required", + "executions", + ) + }) +} + +fn surface_format_path(original_node_index: u32) -> String { + format!("nodes[{original_node_index}].parameters.surfaceFormat") +} + +pub fn resolve_graph_surface_contract( + graph: &CompiledGraph, + capabilities: &wgpu::SurfaceCapabilities, + width: u32, + height: u32, +) -> Result { + let (request, frame_out_index) = frame_out_surface_request(graph)?; + resolve_surface_contract( + request, + capabilities, + width, + height, + &surface_format_path(frame_out_index), + ) +} + +/// Resolves the authored surface request against current adapter/surface capabilities. +/// The presentation policy is intentionally fixed and is not graph-authored. +pub fn resolve_surface_contract( + request: SurfaceFormatRequest, + capabilities: &wgpu::SurfaceCapabilities, + width: u32, + height: u32, + authored_path: &str, +) -> Result { + if width == 0 || height == 0 { + return Err(error( + "GRAPH_SURFACE_INCOMPATIBLE", + "surface extent is zero", + "surface", + )); + } + if !capabilities + .usages + .contains(wgpu::TextureUsages::RENDER_ATTACHMENT) + { + return Err(error( + "GRAPH_SURFACE_INCOMPATIBLE", + "surface lacks render attachment usage", + "surface.usage", + )); + } + if !capabilities + .present_modes + .contains(&wgpu::PresentMode::Fifo) + { + return Err(error( + "GRAPH_SURFACE_INCOMPATIBLE", + "fixed surface present mode is unsupported", + "surface", + )); + } + if !capabilities + .alpha_modes + .contains(&wgpu::CompositeAlphaMode::Opaque) + { + return Err(error( + "GRAPH_SURFACE_INCOMPATIBLE", + "fixed surface alpha mode is unsupported", + "surface", + )); + } + let requested = match request { + SurfaceFormatRequest::Preferred => capabilities.formats.iter().copied().find(|format| { + matches!( + format, + wgpu::TextureFormat::Rgba8Unorm + | wgpu::TextureFormat::Bgra8Unorm + | wgpu::TextureFormat::Rgba16Float + ) + }), + SurfaceFormatRequest::Rgba8Unorm => Some(wgpu::TextureFormat::Rgba8Unorm), + SurfaceFormatRequest::Bgra8Unorm => Some(wgpu::TextureFormat::Bgra8Unorm), + SurfaceFormatRequest::Rgba16Float => Some(wgpu::TextureFormat::Rgba16Float), + }; + let format = requested + .filter(|format| capabilities.formats.contains(format)) + .ok_or_else(|| { + error( + "GRAPH_SURFACE_INCOMPATIBLE", + "requested surface format is unsupported", + authored_path, + ) + })?; + Ok(RuntimeSurfaceContract { + format, + width, + height, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + present_mode: wgpu::PresentMode::Fifo, + alpha_mode: wgpu::CompositeAlphaMode::Opaque, + view_formats: vec![], + desired_maximum_frame_latency: 2, + }) } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1467,6 +1591,7 @@ pub fn prepare_runtime_plan( let frame_out = &graph.executions[frame_out_index]; let NormalizedParameters::FrameOut { + surface_format, dynamic_range, output_transfer, scale_mode: _, @@ -1479,6 +1604,22 @@ pub fn prepare_runtime_plan( format!("executions[{frame_out_index}].parameters"), )); }; + let requested_format = match surface_format { + SurfaceFormatRequest::Preferred => None, + SurfaceFormatRequest::Rgba8Unorm => Some(wgpu::TextureFormat::Rgba8Unorm), + SurfaceFormatRequest::Bgra8Unorm => Some(wgpu::TextureFormat::Bgra8Unorm), + SurfaceFormatRequest::Rgba16Float => Some(wgpu::TextureFormat::Rgba16Float), + }; + if requested_format.is_some_and(|format| format != surface.format) { + return Err(error( + "GRAPH_SURFACE_INCOMPATIBLE", + "resolved surface format does not match frame output request", + format!( + "nodes[{}].parameters.surfaceFormat", + frame_out.original_node_index + ), + )); + } let parameters_valid = background_color .iter() .all(|v| v.is_finite() && (0.0..=1.0).contains(v)) @@ -1836,12 +1977,39 @@ pub fn prepare_runtime_plan( } pub fn validate_activatable(graph: &CompiledGraph) -> Result<(), GraphError> { - let surface = RuntimeSurfaceContract { - format: wgpu::TextureFormat::Bgra8Unorm, - width: 1, - height: 1, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - view_formats: Vec::new(), + // Runtime validation must diagnose noncanonical compiled plans before the + // live-capability resolver. The validator below remains authoritative for + // missing or duplicated Frame Out work, so a missing request gets a + // harmless synthetic default solely for constructing test capabilities. + let (request, frame_out_index) = graph + .executions + .iter() + .find_map(|execution| match execution.parameters { + NormalizedParameters::FrameOut { surface_format, .. } => { + Some((surface_format, execution.original_node_index)) + } + _ => None, + }) + .unwrap_or((SurfaceFormatRequest::Preferred, 0)); + let format = match request { + SurfaceFormatRequest::Preferred | SurfaceFormatRequest::Bgra8Unorm => { + wgpu::TextureFormat::Bgra8Unorm + } + SurfaceFormatRequest::Rgba8Unorm => wgpu::TextureFormat::Rgba8Unorm, + SurfaceFormatRequest::Rgba16Float => wgpu::TextureFormat::Rgba16Float, }; + let capabilities = wgpu::SurfaceCapabilities { + formats: vec![format], + present_modes: vec![wgpu::PresentMode::Fifo], + alpha_modes: vec![wgpu::CompositeAlphaMode::Opaque], + usages: wgpu::TextureUsages::RENDER_ATTACHMENT, + }; + let surface = resolve_surface_contract( + request, + &capabilities, + 1, + 1, + &surface_format_path(frame_out_index), + )?; prepare_runtime_plan(graph, surface, None).map(|_| ()) } diff --git a/renderer/src/render_graph/tests.rs b/renderer/src/render_graph/tests.rs index b2e18d6..218ea20 100644 --- a/renderer/src/render_graph/tests.rs +++ b/renderer/src/render_graph/tests.rs @@ -8,9 +8,9 @@ fn input(node: &str, socket: &str) -> Value { } fn node(id: &str, key: &str, mut parameters: Value, inputs: Value) -> Value { if key == "frame_out" && parameters.as_object().is_some_and(|p| p.is_empty()) { - parameters = json!({"hdrEnabled":false,"toneMapper":"aces","exposureStops":0,"outputTransfer":"srgb","scaleMode":"stretch","filter":"linear","backgroundColor":[0,0,0,1]}); + parameters = json!({"surfaceFormat":"preferred","hdrEnabled":false,"toneMapper":"aces","exposureStops":0,"outputTransfer":"srgb","scaleMode":"stretch","filter":"linear","backgroundColor":[0,0,0,1]}); } - json!({"id":id,"state":"enabled","executor":{"key":key,"version":if key == "frame_out" { 2 } else { 1 }},"parameters":parameters,"inputs":inputs}) + json!({"id":id,"state":"enabled","executor":{"key":key,"version":if key == "frame_out" { 3 } else { 1 }},"parameters":parameters,"inputs":inputs}) } fn texture(format: &str, residency: &str) -> Value { json!({"texture":{"dimension":"d2","format":format,"extent":{"kind":"surface_relative","width":{"numerator":1,"denominator":1},"height":{"numerator":1,"denominator":1},"depthOrArrayLayers":1},"mipLevelCount":1,"sampleCount":1,"viewFormats":[]},"residency":residency}) @@ -850,7 +850,7 @@ fn exact_phase_four_contract_catalog_and_mesh_metadata() { ("bloom_blur", 1), ("bloom_composite", 1), ("luminance_edge", 1), - ("frame_out", 2), + ("frame_out", 3), ] ); assert_eq!( @@ -2971,14 +2971,15 @@ fn runtime_rejects_coordinated_executor_frustum_and_fullscreen_mutations() { } fn frame_parameters(hdr: bool) -> Value { - json!({"hdrEnabled":hdr,"toneMapper":"reinhard","exposureStops":2, + json!({"surfaceFormat":"preferred","hdrEnabled":hdr,"toneMapper":"reinhard","exposureStops":2, "outputTransfer":"srgb","scaleMode":"contain","filter":"nearest", "backgroundColor":[0.1,0.2,0.3,0.4]}) } #[test] -fn frame_out_v2_has_exact_seven_fields_normalizes_sdr_and_rejects_v1() { +fn frame_out_v3_has_exact_eight_fields_normalizes_sdr_and_rejects_v2() { let fields = [ + "surfaceFormat", "hdrEnabled", "toneMapper", "exposureStops", @@ -3033,7 +3034,7 @@ fn frame_out_v2_has_exact_seven_fields_normalizes_sdr_and_rejects_v1() { )); let mut g = full_cull_graph(); let i = node_index(&g, "frame_out"); - g["nodes"][i]["executor"]["version"] = json!(1); + g["nodes"][i]["executor"]["version"] = json!(2); assert_eq!(compile_error(g).code, "GRAPH_EXECUTOR_VERSION_UNSUPPORTED"); } @@ -3044,7 +3045,10 @@ fn frame_out_source_format_matrix_is_exact() { width: 1280, height: 720, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + present_mode: wgpu::PresentMode::Fifo, + alpha_mode: wgpu::CompositeAlphaMode::Opaque, view_formats: vec![], + desired_maximum_frame_latency: 2, }; for (format, sdr, hdr) in [ ("rgba8_unorm", true, false), @@ -3092,6 +3096,174 @@ fn frame_out_source_format_matrix_is_exact() { } } +#[test] +fn surface_contract_resolution_is_capability_driven_and_policy_is_fixed() { + let capabilities = wgpu::SurfaceCapabilities { + formats: vec![ + wgpu::TextureFormat::Bgra8UnormSrgb, + wgpu::TextureFormat::Rgba16Float, + wgpu::TextureFormat::Bgra8Unorm, + ], + present_modes: vec![wgpu::PresentMode::Immediate, wgpu::PresentMode::Fifo], + alpha_modes: vec![ + wgpu::CompositeAlphaMode::PreMultiplied, + wgpu::CompositeAlphaMode::Opaque, + ], + usages: wgpu::TextureUsages::RENDER_ATTACHMENT, + }; + let preferred = resolve_surface_contract( + SurfaceFormatRequest::Preferred, + &capabilities, + 640, + 480, + "format", + ) + .unwrap(); + assert_eq!(preferred.format, wgpu::TextureFormat::Rgba16Float); + assert_eq!(preferred.present_mode, wgpu::PresentMode::Fifo); + assert_eq!(preferred.alpha_mode, wgpu::CompositeAlphaMode::Opaque); + assert_eq!(preferred.desired_maximum_frame_latency, 2); + assert_eq!((preferred.width, preferred.height), (640, 480)); + assert!(resolve_surface_contract( + SurfaceFormatRequest::Bgra8Unorm, + &capabilities, + 1, + 1, + "format" + ) + .is_ok()); + for (width, height) in [(0, 1), (1, 0)] { + assert_eq!( + resolve_surface_contract( + SurfaceFormatRequest::Preferred, + &capabilities, + width, + height, + "format", + ) + .unwrap_err() + .details["path"], + "surface" + ); + } + let no_attachment = wgpu::SurfaceCapabilities { + formats: capabilities.formats.clone(), + present_modes: capabilities.present_modes.clone(), + alpha_modes: capabilities.alpha_modes.clone(), + usages: wgpu::TextureUsages::COPY_DST, + }; + assert_eq!( + resolve_surface_contract( + SurfaceFormatRequest::Preferred, + &no_attachment, + 1, + 1, + "format", + ) + .unwrap_err() + .details["path"], + "surface.usage" + ); + for request in [ + SurfaceFormatRequest::Rgba8Unorm, + SurfaceFormatRequest::Preferred, + ] { + let caps = wgpu::SurfaceCapabilities { + formats: vec![wgpu::TextureFormat::Bgra8UnormSrgb], + present_modes: capabilities.present_modes.clone(), + alpha_modes: capabilities.alpha_modes.clone(), + usages: capabilities.usages, + }; + assert_eq!( + resolve_surface_contract(request, &caps, 1, 1, "format") + .unwrap_err() + .code, + "GRAPH_SURFACE_INCOMPATIBLE" + ); + } + for caps in [ + wgpu::SurfaceCapabilities { + formats: capabilities.formats.clone(), + present_modes: vec![wgpu::PresentMode::Immediate], + alpha_modes: capabilities.alpha_modes.clone(), + usages: capabilities.usages, + }, + wgpu::SurfaceCapabilities { + formats: capabilities.formats.clone(), + present_modes: capabilities.present_modes.clone(), + alpha_modes: vec![wgpu::CompositeAlphaMode::PreMultiplied], + usages: capabilities.usages, + }, + ] { + assert_eq!( + resolve_surface_contract(SurfaceFormatRequest::Preferred, &caps, 1, 1, "format") + .unwrap_err() + .code, + "GRAPH_SURFACE_INCOMPATIBLE" + ); + } + for (present_modes, alpha_modes, message) in [ + ( + vec![wgpu::PresentMode::Immediate], + vec![wgpu::CompositeAlphaMode::Opaque], + "fixed surface present mode is unsupported", + ), + ( + vec![wgpu::PresentMode::Fifo], + vec![wgpu::CompositeAlphaMode::PreMultiplied], + "fixed surface alpha mode is unsupported", + ), + ] { + let caps = wgpu::SurfaceCapabilities { + formats: vec![wgpu::TextureFormat::Bgra8UnormSrgb], + present_modes, + alpha_modes, + usages: capabilities.usages, + }; + let error = resolve_surface_contract( + SurfaceFormatRequest::Rgba8Unorm, + &caps, + 1, + 1, + "nodes[7].parameters.surfaceFormat", + ) + .unwrap_err(); + assert_eq!(error.code, "GRAPH_SURFACE_INCOMPATIBLE"); + assert_eq!(error.details["path"], "surface"); + assert_eq!(error.message, message); + } +} + +#[test] +fn graph_surface_resolution_uses_canonical_authored_path_and_all_requests_activate() { + for (request, format) in [ + ("preferred", wgpu::TextureFormat::Bgra8Unorm), + ("rgba8_unorm", wgpu::TextureFormat::Rgba8Unorm), + ("bgra8_unorm", wgpu::TextureFormat::Bgra8Unorm), + ("rgba16_float", wgpu::TextureFormat::Rgba16Float), + ] { + let mut authored = full_cull_graph(); + let index = node_index(&authored, "frame_out"); + authored["nodes"][index]["parameters"]["surfaceFormat"] = json!(request); + let graph = compile_graph(authored); + validate_activatable(&graph).unwrap(); + let caps = wgpu::SurfaceCapabilities { + formats: vec![format], + present_modes: vec![wgpu::PresentMode::Fifo], + alpha_modes: vec![wgpu::CompositeAlphaMode::Opaque], + usages: wgpu::TextureUsages::RENDER_ATTACHMENT, + }; + resolve_graph_surface_contract(&graph, &caps, 1, 1).unwrap(); + let mut unsupported = caps; + unsupported.formats.clear(); + let error = resolve_graph_surface_contract(&graph, &unsupported, 1, 1).unwrap_err(); + assert_eq!( + error.details["path"], + format!("nodes[{index}].parameters.surfaceFormat") + ); + } +} + #[test] fn runtime_rejects_every_frame_parameter_lane_and_coordinated_source_mutations() { let baseline = compile_graph(full_cull_graph()); @@ -3100,7 +3272,7 @@ fn runtime_rejects_every_frame_parameter_lane_and_coordinated_source_mutations() .iter() .position(|e| e.executor.key == "frame_out") .unwrap(); - for version in [1, 3] { + for version in [1, 2, 4] { let mut g = baseline.clone(); g.executions[i].executor.version = version; assert_runtime_path(&g, format!("executions[{i}].executor.version")); @@ -3187,7 +3359,10 @@ fn runtime_rejects_every_frame_parameter_lane_and_coordinated_source_mutations() width: 1280, height: 720, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + present_mode: wgpu::PresentMode::Fifo, + alpha_mode: wgpu::CompositeAlphaMode::Opaque, view_formats: vec![], + desired_maximum_frame_latency: 2, }; prepare_runtime_plan(&hdr_to_sdr, linear_surface.clone(), None).unwrap(); let error = prepare_runtime_plan( diff --git a/renderer/src/renderer/mod.rs b/renderer/src/renderer/mod.rs index dbb081f..50a54bd 100644 --- a/renderer/src/renderer/mod.rs +++ b/renderer/src/renderer/mod.rs @@ -154,6 +154,7 @@ fn pack_frame_out_uniforms( ) -> Option { use crate::render_graph::*; let NormalizedParameters::FrameOut { + surface_format: _, dynamic_range, output_transfer, scale_mode, @@ -301,7 +302,10 @@ mod fullscreen_tests { width: 1920, height: 1080, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + present_mode: wgpu::PresentMode::Fifo, + alpha_mode: wgpu::CompositeAlphaMode::Opaque, view_formats: vec![], + desired_maximum_frame_latency: 2, } } @@ -312,6 +316,7 @@ mod fullscreen_tests { filter: FrameFilter, ) -> NormalizedParameters { NormalizedParameters::FrameOut { + surface_format: SurfaceFormatRequest::Preferred, dynamic_range, output_transfer: transfer, scale_mode, @@ -775,6 +780,57 @@ enum UploadGraph { Compiled(crate::render_graph::MeshQueryRuntimeKey), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FrameTargetSource { + PendingSwitch, + PendingResize, + Active, + Immediate, +} + +fn select_frame_target_source( + pending_switch: bool, + pending_resize: bool, + active: bool, +) -> FrameTargetSource { + if pending_switch { + FrameTargetSource::PendingSwitch + } else if pending_resize { + FrameTargetSource::PendingResize + } else if active { + FrameTargetSource::Active + } else { + FrameTargetSource::Immediate + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AcquisitionAction { + RejectSwitch, + DropResize, + ReconfigureAndSkip, + Skip, + Halt, +} + +fn acquisition_action(source: FrameTargetSource, error: &wgpu::SurfaceError) -> AcquisitionAction { + if matches!(error, wgpu::SurfaceError::OutOfMemory) { + return AcquisitionAction::Halt; + } + match source { + FrameTargetSource::PendingSwitch => AcquisitionAction::RejectSwitch, + FrameTargetSource::PendingResize => AcquisitionAction::DropResize, + FrameTargetSource::Active | FrameTargetSource::Immediate => match error { + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated => { + AcquisitionAction::ReconfigureAndSkip + } + wgpu::SurfaceError::Timeout => AcquisitionAction::Skip, + wgpu::SurfaceError::Other => AcquisitionAction::Halt, + wgpu::SurfaceError::OutOfMemory => unreachable!(), + }, + } +} + fn classify_upload_graph(graph: &ActiveCompiledGraph) -> UploadGraph { UploadGraph::Compiled(graph.runtime.allocations.query) } @@ -890,6 +946,29 @@ fn resolve_switch_request( } } +fn drop_graph_request( + registry: &mut crate::render_graph::Registry, + id: crate::render_graph::CompiledGraphId, + active: Option, + pending_switch: Option, + pending_resize: Option, + in_flight: Option, +) -> Result<(), crate::render_graph::GraphError> { + if active == Some(id) { + Err(crate::render_graph::GraphError::new( + "GRAPH_ACTIVE", + "compiled graph is active", + )) + } else if [pending_switch, pending_resize, in_flight].contains(&Some(id)) { + Err(crate::render_graph::GraphError::new( + "GRAPH_SWITCH_PENDING", + "compiled graph switch is pending", + )) + } else { + registry.drop_graph(id) + } +} + #[cfg(test)] mod switch_request_tests { fn valid_compile_graph(graph_id: &str, revision: u64) -> Vec { @@ -934,6 +1013,55 @@ mod switch_request_tests { assert_eq!(selected(Some(query(Any)), None), Some(Any)); } + #[test] + fn frame_target_precedence_and_pending_resize_upload_are_exact() { + use crate::render_graph::RuntimePredicate::{RequiredFalse, RequiredTrue}; + assert_eq!( + select_frame_target_source(true, true, true), + FrameTargetSource::PendingSwitch + ); + assert_eq!( + select_frame_target_source(false, true, true), + FrameTargetSource::PendingResize + ); + assert_eq!( + select_frame_target_source(false, false, true), + FrameTargetSource::Active + ); + assert_eq!( + select_frame_target_source(false, false, false), + FrameTargetSource::Immediate + ); + let pending_resize = query(RequiredFalse); + let active = query(RequiredTrue); + assert_eq!( + upload_query_for_render(Some(pending_resize), Some(active)) + .unwrap() + .visible, + RequiredFalse + ); + } + + #[test] + fn acquisition_policy_covers_every_source_and_surface_error() { + use wgpu::SurfaceError::*; + use AcquisitionAction::*; + use FrameTargetSource::*; + for error in [Lost, Outdated, Timeout, Other] { + assert_eq!(acquisition_action(PendingSwitch, &error), RejectSwitch); + assert_eq!(acquisition_action(PendingResize, &error), DropResize); + } + for source in [Active, Immediate] { + assert_eq!(acquisition_action(source, &Lost), ReconfigureAndSkip); + assert_eq!(acquisition_action(source, &Outdated), ReconfigureAndSkip); + assert_eq!(acquisition_action(source, &Timeout), Skip); + assert_eq!(acquisition_action(source, &Other), Halt); + } + for source in [PendingSwitch, PendingResize, Active, Immediate] { + assert_eq!(acquisition_action(source, &OutOfMemory), Halt); + } + } + #[test] fn frustum_preflight_skips_any_and_distinguishes_missing_from_invalid() { use crate::render_graph::RuntimePredicate::{Any, RequiredFalse, RequiredTrue}; @@ -1000,6 +1128,24 @@ mod switch_request_tests { assert_eq!(registry.get(id).unwrap_err().code, "STALE_GRAPH_ID"); } + #[test] + fn pending_resize_keeps_registry_ownership_through_commit() { + let mut registry = crate::render_graph::Registry::default(); + let (id, _) = registry.compile(&valid_compile_graph("resize", 1)).unwrap(); + + let error = drop_graph_request(&mut registry, id, None, None, Some(id), None) + .expect_err("a completed resize candidate still owns its registry entry"); + assert_eq!(error.code, "GRAPH_SWITCH_PENDING"); + assert!(registry.contains(id)); + + let active = Some(id); + assert_eq!(registry.get(active.unwrap()).unwrap().revision, 1); + + let (other, _) = registry.compile(&valid_compile_graph("other", 1)).unwrap(); + drop_graph_request(&mut registry, other, active, None, None, None).unwrap(); + assert_eq!(registry.get(other).unwrap_err().code, "STALE_GRAPH_ID"); + } + #[test] fn resize_restart_snapshot_remains_bound_to_its_immutable_registry_revision() { let mut registry = crate::render_graph::Registry::default(); @@ -1098,10 +1244,12 @@ fn render_data_error_code(error: &crate::render_data::RenderDataError) -> &'stat } pub struct RendererContext { + pub adapter: wgpu::Adapter, pub device: wgpu::Device, pub queue: wgpu::Queue, pub surface_config: wgpu::SurfaceConfiguration, pub surface: wgpu::Surface<'static>, + initial_surface_config: wgpu::SurfaceConfiguration, pub depth_texture: wgpu::Texture, pub depth_view: wgpu::TextureView, } @@ -1125,6 +1273,7 @@ pub struct Renderer { graph_registry: crate::render_graph::Registry, active_compiled: Option, pending_switch: Option, + pending_resize: Option, in_flight: Option, next_preparation_token: u64, preparation_completions: Rc>>, @@ -1133,6 +1282,28 @@ pub struct Renderer { } impl Renderer { + fn surface_config( + contract: &crate::render_graph::RuntimeSurfaceContract, + ) -> wgpu::SurfaceConfiguration { + wgpu::SurfaceConfiguration { + usage: contract.usage, + format: contract.format, + width: contract.width, + height: contract.height, + present_mode: contract.present_mode, + alpha_mode: contract.alpha_mode, + view_formats: contract.view_formats.clone(), + desired_maximum_frame_latency: contract.desired_maximum_frame_latency, + } + } + + fn configure_surface(&mut self, config: wgpu::SurfaceConfiguration) { + self.context + .surface + .configure(&self.context.device, &config); + self.context.surface_config = config; + } + fn reply(&mut self, request: u32, result: Result) { let (ok, code, value, details) = match result { Ok(value) => (true, "OK", value, JsValue::UNDEFINED), @@ -1187,23 +1358,20 @@ impl Renderer { slot: words[2], generation: words[3], }; - let outcome = - if self.active_compiled.as_ref().is_some_and(|a| a.id() == id) { - Err(crate::render_graph::GraphError::new( - "GRAPH_ACTIVE", - "compiled graph is active", - )) - } else if self.pending_switch.as_ref().is_some_and( - |p| matches!(&p.target, SwitchTarget::Compiled(a) if a.id() == id), - ) || self.in_flight.as_ref().is_some_and(|p| p.id == id) - { - Err(crate::render_graph::GraphError::new( - "GRAPH_SWITCH_PENDING", - "compiled graph switch is pending", - )) - } else { - self.graph_registry.drop_graph(id) - }; + let outcome = drop_graph_request( + &mut self.graph_registry, + id, + self.active_compiled.as_ref().map(ActiveCompiledGraph::id), + self.pending_switch.as_ref().and_then(|pending| { + if let SwitchTarget::Compiled(active) = &pending.target { + Some(active.id()) + } else { + None + } + }), + self.pending_resize.as_ref().map(ActiveCompiledGraph::id), + self.in_flight.as_ref().map(|preparation| preparation.id), + ); match outcome { Ok(()) => self.reply(request, Ok(JsValue::UNDEFINED)), Err(error) => self.reply(request, Err(error.into())), @@ -1411,14 +1579,14 @@ impl Renderer { "gltf_standard", &layout, include_str!("../gltf.wgsl"), - context.surface_config.format, + context.initial_surface_config.format, ); let double_sided = resources.get_or_create_pipeline( &context.device, "gltf_standard_double_sided", &layout, include_str!("../gltf.wgsl"), - context.surface_config.format, + context.initial_surface_config.format, ); [culled, double_sided] } @@ -1426,16 +1594,19 @@ impl Renderer { fn plan_compiled( &self, graph: &crate::render_graph::CompiledGraph, + width: u32, + height: u32, ) -> Result { + let capabilities = self.context.surface.get_capabilities(&self.context.adapter); + let surface = crate::render_graph::resolve_graph_surface_contract( + graph, + &capabilities, + width, + height, + )?; crate::render_graph::prepare_runtime_plan( graph, - crate::render_graph::RuntimeSurfaceContract { - format: self.context.surface_config.format, - width: self.context.surface_config.width, - height: self.context.surface_config.height, - usage: self.context.surface_config.usage, - view_formats: self.context.surface_config.view_formats.clone(), - }, + surface, Some(&self.context.device.limits()), ) } @@ -1860,7 +2031,21 @@ impl Renderer { graph: crate::render_graph::CompiledGraph, purpose: PreparationPurpose, ) -> Result<(), crate::render_graph::GraphError> { - let runtime = self.plan_compiled(&graph)?; + let runtime = self.plan_compiled( + &graph, + self.context.surface_config.width, + self.context.surface_config.height, + )?; + self.begin_compiled_preparation_with_runtime(id, graph, runtime, purpose) + } + + fn begin_compiled_preparation_with_runtime( + &mut self, + id: crate::render_graph::CompiledGraphId, + graph: crate::render_graph::CompiledGraph, + runtime: crate::render_graph::RuntimePlan, + purpose: PreparationPurpose, + ) -> Result<(), crate::render_graph::GraphError> { // Candidate construction allocates GPU resources, so the live scene preflight // belongs here: this is the earliest boundary with both the runtime query and // scene access, and precedes GPU work and all pending/in-flight mutation. @@ -1932,7 +2117,7 @@ impl Renderer { self.reply(request, Err(error.into())); } (PreparationPurpose::Resize, Ok(candidate)) => { - self.active_compiled = Some(candidate); + self.pending_resize = Some(candidate); } (PreparationPurpose::Resize, Err(error)) => { log::error!( @@ -2012,16 +2197,15 @@ impl Renderer { })); let surface_caps = surface.get_capabilities(&adapter); - let surface_config = wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - format: surface_caps.formats[0], - width: canvas.clone().width().max(1), - height: canvas.clone().height().max(1), - present_mode: surface_caps.present_modes[0], - alpha_mode: surface_caps.alpha_modes[0], - view_formats: vec![], - desired_maximum_frame_latency: 2, - }; + let initial_contract = crate::render_graph::resolve_surface_contract( + crate::render_graph::SurfaceFormatRequest::Preferred, + &surface_caps, + canvas.clone().width().max(1), + canvas.clone().height().max(1), + "initialSurfaceFormat", + ) + .expect("surface must support the fixed presentation contract"); + let surface_config = Self::surface_config(&initial_contract); info!( "suface size: {} x {}", surface_config.width, surface_config.height @@ -2032,9 +2216,11 @@ impl Renderer { let mut resources = PipelineLibrary::new(); let context = RendererContext { + adapter, surface, device, queue, + initial_surface_config: surface_config.clone(), surface_config, depth_texture, depth_view, @@ -2066,6 +2252,7 @@ impl Renderer { graph_registry: Default::default(), active_compiled: None, pending_switch: None, + pending_resize: None, in_flight: None, next_preparation_token: 0, preparation_completions: Default::default(), @@ -2128,20 +2315,38 @@ impl Renderer { SwitchTarget::Immediate => UploadGraph::Immediate, SwitchTarget::Compiled(graph) => classify_upload_graph(graph), }); - let query = upload_query_for_render( - pending, - self.active_compiled.as_ref().map(classify_upload_graph), + let target_source = select_frame_target_source( + self.pending_switch.is_some(), + self.pending_resize.is_some(), + self.active_compiled.is_some(), ); + let selected = match target_source { + FrameTargetSource::PendingSwitch => pending, + FrameTargetSource::PendingResize => { + self.pending_resize.as_ref().map(classify_upload_graph) + } + FrameTargetSource::Active => self.active_compiled.as_ref().map(classify_upload_graph), + FrameTargetSource::Immediate => Some(UploadGraph::Immediate), + }; + let query = upload_query_for_render(selected, None); // Resolve again immediately before every active frame. Do this before scene // upload so an invalid camera cannot mutate GPU state or produce a frame. let planes = match update_validate_write_scene(&mut self.scene, &self.context.queue, query) { Ok(planes) => planes, Err(error) => { - if let Some(pending) = self.pending_switch.take() { - self.reply(pending.request, Err(error.into())); - } else { - self.post_fatal("GRAPH_EXECUTION_FAILED", &error.message); + match target_source { + FrameTargetSource::PendingSwitch => { + let pending = self.pending_switch.take().unwrap(); + self.reply(pending.request, Err(error.into())); + } + FrameTargetSource::PendingResize => { + self.pending_resize = None; + log::error!("compiled graph resize preflight failed: {}", error.message); + } + FrameTargetSource::Active | FrameTargetSource::Immediate => { + self.post_fatal("GRAPH_EXECUTION_FAILED", &error.message); + } } return; } @@ -2167,10 +2372,68 @@ impl Renderer { .write_culling_params(&self.context.queue, planes, query); } + // Candidate publication is transactional: configure its complete contract at + // the last possible point before acquisition, but retain the known-good + // configuration and active graph identity until presentation succeeds. + let candidate_config = match target_source { + FrameTargetSource::PendingSwitch => match &self.pending_switch.as_ref().unwrap().target + { + SwitchTarget::Compiled(active) => Self::surface_config(&active.runtime.surface), + SwitchTarget::Immediate => { + let mut config = self.context.initial_surface_config.clone(); + config.width = self.context.surface_config.width; + config.height = self.context.surface_config.height; + config + } + }, + FrameTargetSource::PendingResize => { + Self::surface_config(&self.pending_resize.as_ref().unwrap().runtime.surface) + } + FrameTargetSource::Active | FrameTargetSource::Immediate => { + self.context.surface_config.clone() + } + }; + let restore_config = (candidate_config != self.context.surface_config) + .then(|| self.context.surface_config.clone()); + if restore_config.is_some() { + self.configure_surface(candidate_config); + } let surface_texture = match self.context.surface.get_current_texture() { Ok(value) => value, Err(error) => { - self.post_fatal("SURFACE_FRAME_FAILED", &error.to_string()); + match acquisition_action(target_source, &error) { + AcquisitionAction::RejectSwitch => { + if let Some(config) = restore_config { + self.configure_surface(config); + } + let pending = self.pending_switch.take().unwrap(); + self.reply( + pending.request, + Err(crate::render_graph::GraphError::new( + "GRAPH_SURFACE_RECONFIGURE_FAILED", + error.to_string(), + ) + .into()), + ); + } + AcquisitionAction::DropResize => { + if let Some(config) = restore_config { + self.configure_surface(config); + } + self.pending_resize = None; + log::error!("compiled graph resize acquisition failed: {error}"); + } + AcquisitionAction::ReconfigureAndSkip => { + self.context + .surface + .configure(&self.context.device, &self.context.surface_config); + } + AcquisitionAction::Skip => log::warn!("surface acquisition timed out"), + AcquisitionAction::Halt => { + self.halted = true; + self.post_fatal("SURFACE_FRAME_FAILED", &error.to_string()); + } + } return; } }; @@ -2182,10 +2445,15 @@ impl Renderer { label: Some("Render command encoder"), }); - let rendering_compiled = match self.pending_switch.as_ref().map(|p| &p.target) { - Some(SwitchTarget::Compiled(active)) => Some(active), - Some(SwitchTarget::Immediate) => None, - None => self.active_compiled.as_ref(), + let rendering_compiled = match target_source { + FrameTargetSource::PendingSwitch => match &self.pending_switch.as_ref().unwrap().target + { + SwitchTarget::Compiled(active) => Some(active), + SwitchTarget::Immediate => None, + }, + FrameTargetSource::PendingResize => self.pending_resize.as_ref(), + FrameTargetSource::Active => self.active_compiled.as_ref(), + FrameTargetSource::Immediate => None, }; let mut profile_frame = self.profiler.begin(|| match rendering_compiled { None => "immediate".to_owned(), @@ -2222,7 +2490,15 @@ impl Renderer { if let Some(frame) = profile_frame.take() { self.profiler.cancel(frame); } + // A surface must have no acquired texture (or objects retaining its view) + // when configure is called to roll back a transactional candidate. + drop(encoder); + drop(texture_view); + drop(surface_texture); if let Some(pending) = self.pending_switch.take() { + if let Some(config) = restore_config { + self.configure_surface(config); + } self.reply( pending.request, Err( @@ -2230,6 +2506,10 @@ impl Renderer { .into(), ), ); + } else if self.pending_resize.take().is_some() { + if let Some(config) = restore_config { + self.configure_surface(config); + } } return; } @@ -2240,6 +2520,10 @@ impl Renderer { } surface_texture.present(); if let Some(pending) = self.pending_switch.take() { + // A successful user switch supersedes any resize recreation of the + // previously active graph. Retain that resize candidate only as a + // fallback while the switch is being prepared or attempted. + self.pending_resize = None; let result = match pending.target { SwitchTarget::Immediate => { self.active_compiled = None; @@ -2258,6 +2542,8 @@ impl Renderer { } }; self.reply(pending.request, Ok(result)); + } else if let Some(candidate) = self.pending_resize.take() { + self.active_compiled = Some(candidate); } let global = js_sys::global().unchecked_into::(); for reply in self.pending_replies.drain(..) { @@ -2288,6 +2574,16 @@ impl Renderer { ("indices", index_count.into()), ("width", self.context.surface_config.width.into()), ("height", self.context.surface_config.height.into()), + ( + "surfaceFormat", + match self.context.surface_config.format { + wgpu::TextureFormat::Rgba8Unorm => "rgba8_unorm", + wgpu::TextureFormat::Bgra8Unorm => "bgra8_unorm", + wgpu::TextureFormat::Rgba16Float => "rgba16_float", + _ => "unknown", + } + .into(), + ), ("framingRadius", self.framing_radius.into()), ( "renderMode", @@ -2540,14 +2836,6 @@ impl Renderer { if new_width != self.context.surface_config.width || new_height != self.context.surface_config.height { - self.context.surface_config.width = new_width; - self.context.surface_config.height = new_height; - self.context - .surface - .configure(&self.context.device, &self.context.surface_config); - self.recreate_depth_texture(); - // The executable subset uses surface-relative transients exclusively. - // Dropping old buckets prevents stale-size reuse and bounds resize growth. if let Some(pending) = self.pending_switch.take() { self.reply( pending.request, @@ -2575,38 +2863,80 @@ impl Renderer { } PreparationPurpose::Resize => Some((preparation.id, preparation.graph)), }); - let mut restarted = false; - if let Some(old) = self.active_compiled.take() { - let id = old.id(); - // Keep immediate resources live and fall back for this frame if recreation fails. - restarted = true; - self.begin_compiled_preparation(id, old.graph, PreparationPurpose::Resize) - .unwrap_or_else(|error| { - log::error!( - "compiled graph resize preparation failed: {}", - error.message - ) - }); - } - if !restarted { - if let Some((id, graph)) = interrupted_resize { - if let Err(error) = - self.begin_compiled_preparation(id, graph, PreparationPurpose::Resize) - { - log::error!( - "compiled graph resize preparation failed: {}", - error.message - ); - } - } - } + let restore = self + .active_compiled + .take() + .map(|active| (active.id(), active.graph)) + .or_else(|| { + self.pending_resize + .take() + .map(|active| (active.id(), active.graph)) + }) + .or(interrupted_resize); + self.context.initial_surface_config.width = new_width; + self.context.initial_surface_config.height = new_height; + let initial_request = match self.context.initial_surface_config.format { + wgpu::TextureFormat::Rgba8Unorm => { + crate::render_graph::SurfaceFormatRequest::Rgba8Unorm + } + wgpu::TextureFormat::Bgra8Unorm => { + crate::render_graph::SurfaceFormatRequest::Bgra8Unorm + } + wgpu::TextureFormat::Rgba16Float => { + crate::render_graph::SurfaceFormatRequest::Rgba16Float + } + _ => { + self.halted = true; + self.post_fatal( + "SURFACE_FRAME_FAILED", + "immediate surface format is unsupported", + ); + return; + } + }; + let capabilities = self.context.surface.get_capabilities(&self.context.adapter); + if let Err(error) = crate::render_graph::resolve_surface_contract( + initial_request, + &capabilities, + new_width, + new_height, + "surface", + ) { + self.halted = true; + self.post_fatal("SURFACE_FRAME_FAILED", &error.message); + return; + } + // Resize always establishes the exact immediate fallback first. Compiled + // configuration is transactional and is applied only by the commit frame. + self.configure_surface(self.context.initial_surface_config.clone()); + self.recreate_depth_texture(); self.scene.resize( new_width as f64, new_height as f64, msg.scale_factor, &self.context.queue, ); + if let Some((id, graph)) = restore { + match self.plan_compiled(&graph, new_width, new_height) { + Ok(runtime) => { + if let Err(error) = self.begin_compiled_preparation_with_runtime( + id, + graph, + runtime, + PreparationPurpose::Resize, + ) { + log::error!( + "compiled graph resize preparation failed: {}", + error.message + ); + } + } + Err(error) => { + log::error!("compiled graph resize planning failed: {}", error.message) + } + } + } info!( "Resized: ({}, {}), scale: {}", diff --git a/static/render-graph/catalog.js b/static/render-graph/catalog.js index bca2585..5cdd3d1 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 = 6; +export const CATALOG_VERSION = 7; const exact = (type) => ({ kind: "exact", types: [type] }); const i = (type, required = true, authoringType) => ({ accepted: typeof type === "string" ? exact(type) : type, @@ -188,11 +188,11 @@ export const semanticCatalog = Object.freeze({ parameters: { strength: 2 }, }, frame_out: { - version: 2, + version: 3, execution: "frame", inputs: { color: i("texture") }, outputs: {}, - parameters: { hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] }, + parameters: { surfaceFormat: "preferred", hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] }, }, }); const socketColors = [ @@ -389,6 +389,7 @@ const parameterSchemas = { bloom_composite: { intensity: number(1, 0, 16) }, luminance_edge: { strength: number(2, 0, 16) }, frame_out: { + surfaceFormat: enumeration("preferred", ["preferred", "rgba8_unorm", "bgra8_unorm", "rgba16_float"]), hdrEnabled: boolean(true), toneMapper: enumeration("aces", ["aces", "reinhard", "none"]), exposureStops: number(0, -10, 10), @@ -469,6 +470,8 @@ nodeDefinitions.color_balance.ui = [ { kind: "socket", socket: "source" }, { kind: "socket", socket: "colorTarget" }, { kind: "socket", socket: "color" }, ]; nodeDefinitions.frame_out.ui = [ + { kind: "text", variant: "section", title: "Canvas Presentation" }, + { kind: "parameter", parameter: "surfaceFormat", title: "Surface Format" }, { kind: "text", variant: "section", title: "Display Transform" }, { kind: "parameter", parameter: "hdrEnabled", title: "HDR" }, { kind: "parameter", parameter: "toneMapper", title: "Tone Mapper", visibleWhen: { parameter: "hdrEnabled", equals: true } }, diff --git a/static/render-graph/presets.js b/static/render-graph/presets.js index 49add56..c811852 100644 --- a/static/render-graph/presets.js +++ b/static/render-graph/presets.js @@ -12,7 +12,7 @@ const texture = (format, scale = 1, heightScale = scale) => ({ }, residency: "transient", }); -const frameOut = (hdr, options = {}) => ({ hdrEnabled: hdr, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1], ...options }); +const frameOut = (hdr, options = {}) => ({ surfaceFormat: "preferred", hdrEnabled: hdr, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1], ...options }); const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1) => [ node("hdr", "texture", texture("rgba16_float", 1, heightScale)), node("depth", "texture", texture("depth32_float", 1, heightScale)), diff --git a/tests/fxnode-composition.test.js b/tests/fxnode-composition.test.js index 59ec0f4..5dd05f4 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, 6); + assert.equal(fxNodeComposition.version, 7); assert.equal(Object.keys(fxNodeComposition.nodes).length, 16); assert.ok( Object.values(fxNodeComposition.nodes).every( diff --git a/tests/render-graph-authoring.test.js b/tests/render-graph-authoring.test.js index a54956a..dad7c45 100644 --- a/tests/render-graph-authoring.test.js +++ b/tests/render-graph-authoring.test.js @@ -96,7 +96,7 @@ 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, key === "frame_out" ? 2 : 1); + assert.equal(semantic.version, key === "frame_out" ? 3 : 1); assert.equal(nodeDefinitions[key].version, semantic.version); assert.equal(descriptors[key].version, semantic.version); } @@ -133,7 +133,7 @@ test("catalog exhaustively mirrors all current contracts", () => { Object.keys(contract.parameters).sort(), key, ); - assert.equal(CATALOG_VERSION, 6); + assert.equal(CATALOG_VERSION, 7); assert.deepEqual(nodeDefinitions.pipeline.parameters, { pipeline: { type: "string", @@ -346,14 +346,15 @@ test("adapter rejects hostile shape, IDs, duplicates, catalog, sockets and type link.fromSocketId = "mesh:mesh"; }, "AUTHORING_LINK_TYPE"); }); -test("Frame Out has the exact v2 schema, defaults, UI, and strict authoring validation", () => { - const fields = ["hdrEnabled", "toneMapper", "exposureStops", "outputTransfer", "scaleMode", "filter", "backgroundColor"]; - assert.equal(CATALOG_VERSION, 6); +test("Frame Out has the exact v3 schema, defaults, UI, and strict authoring validation", () => { + const fields = ["surfaceFormat", "hdrEnabled", "toneMapper", "exposureStops", "outputTransfer", "scaleMode", "filter", "backgroundColor"]; + assert.equal(CATALOG_VERSION, 7); assert.deepEqual(semanticCatalog.frame_out, { - version: 2, execution: "frame", inputs: { color: semanticCatalog.frame_out.inputs.color }, outputs: {}, - parameters: { hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] }, + version: 3, execution: "frame", inputs: { color: semanticCatalog.frame_out.inputs.color }, outputs: {}, + parameters: { surfaceFormat: "preferred", hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] }, }); assert.deepEqual(nodeDefinitions.frame_out.parameters, { + surfaceFormat: { type: "string", default: { kind: "string", value: "preferred" }, enum: ["preferred", "rgba8_unorm", "bgra8_unorm", "rgba16_float"] }, hdrEnabled: { type: "boolean", default: { kind: "boolean", value: true } }, toneMapper: { type: "string", default: { kind: "string", value: "aces" }, enum: ["aces", "reinhard", "none"] }, exposureStops: { type: "number", default: { kind: "number", value: 0 }, minimum: -10, maximum: 10 }, @@ -363,6 +364,8 @@ test("Frame Out has the exact v2 schema, defaults, UI, and strict authoring vali backgroundColor: { type: "color", default: { kind: "color", value: [0, 0, 0, 1] }, minimum: 0, maximum: 1 }, }); assert.deepEqual(nodeDefinitions.frame_out.ui, [ + { kind: "text", variant: "section", title: "Canvas Presentation" }, + { kind: "parameter", parameter: "surfaceFormat", title: "Surface Format" }, { kind: "text", variant: "section", title: "Display Transform" }, { kind: "parameter", parameter: "hdrEnabled", title: "HDR" }, { kind: "parameter", parameter: "toneMapper", title: "Tone Mapper", visibleWhen: { parameter: "hdrEnabled", equals: true } }, @@ -379,11 +382,11 @@ test("Frame Out has the exact v2 schema, defaults, UI, and strict authoring vali assert.throws(() => adaptFxNodeSnapshot(x), (e) => e instanceof AuthoringGraphError && e.code === code && (!parameter || e.details.nodeId === n.id && e.details.parameter === parameter)); }; reject((x) => x.catalogVersion = 5, "AUTHORING_CATALOG"); - reject((x, n) => n.typeVersion = 1, "AUTHORING_NODE_INVALID"); + reject((x, n) => n.typeVersion = 2, "AUTHORING_NODE_INVALID"); for (const field of fields) reject((x, n) => delete n.parameters[field], "AUTHORING_PARAMETER_SET"); reject((x, n) => n.parameters.extra = { kind: "number", value: 0 }, "AUTHORING_PARAMETER_SET"); for (const [field, value] of [ - ["hdrEnabled", 1], ["toneMapper", "bad"], ["outputTransfer", "bad"], ["scaleMode", "bad"], ["filter", "bad"], + ["surfaceFormat", "bad"], ["hdrEnabled", 1], ["toneMapper", "bad"], ["outputTransfer", "bad"], ["scaleMode", "bad"], ["filter", "bad"], ["exposureStops", NaN], ["exposureStops", -10.01], ["exposureStops", 10.01], ["backgroundColor", [0, 0, 0]], ["backgroundColor", [0, 0, Infinity, 1]], ["backgroundColor", [-0.01, 0, 0, 1]], ["backgroundColor", [0, 0, 0, 1.01]], ]) reject((x, n) => n.parameters[field].value = value, "AUTHORING_PARAMETER", field);