diff --git a/renderer/src/render_graph/compiler.rs b/renderer/src/render_graph/compiler.rs index b2e86f9..298ff17 100644 --- a/renderer/src/render_graph/compiler.rs +++ b/renderer/src/render_graph/compiler.rs @@ -38,8 +38,15 @@ struct PipelineParameters { } #[derive(Deserialize)] #[serde(deny_unknown_fields)] -struct ToneMapParameters { - exposure: f32, +#[serde(rename_all = "camelCase")] +struct FrameOutParameters { + hdr_enabled: bool, + tone_mapper: ToneMapper, + exposure_stops: f32, + output_transfer: OutputTransfer, + scale_mode: ScaleMode, + filter: FrameFilter, + background_color: [f32; 4], } #[derive(Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] @@ -424,13 +431,6 @@ fn decode(node: &Node, i: usize) -> Result { NormalizedParameters::FrustumCull { camera: p.camera } } "fullscreen_copy" => empty!(NormalizedParameters::FullscreenCopy), - "tone_map" => { - let p: ToneMapParameters = - serde_json::from_value(node.parameters.clone()).map_err(invalid)?; - NormalizedParameters::ToneMap { - exposure: range(p.exposure, 0.0, 32.0, format!("{base}.exposure"))?, - } - } "color_balance" => { let p: ColorBalanceParameters = serde_json::from_value(node.parameters.clone()).map_err(invalid)?; @@ -528,7 +528,36 @@ fn decode(node: &Node, i: usize) -> Result { strength: range(p.strength, 0.0, 16.0, format!("{base}.strength"))?, } } - "frame_out" => empty!(NormalizedParameters::FrameOut), + "frame_out" => { + let p: FrameOutParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + let exposure_stops = range( + p.exposure_stops, + -10.0, + 10.0, + format!("{base}.exposureStops"), + )?; + let background_color = components( + p.background_color, + 0.0, + 1.0, + &format!("{base}.backgroundColor"), + )?; + NormalizedParameters::FrameOut { + dynamic_range: if p.hdr_enabled { + FrameDynamicRange::Hdr { + tone_mapper: p.tone_mapper, + exposure_stops, + } + } else { + FrameDynamicRange::Sdr + }, + output_transfer: p.output_transfer, + scale_mode: p.scale_mode, + filter: p.filter, + background_color, + } + } "texture" => { let p: TextureParameters = serde_json::from_value(node.parameters.clone()).map_err(invalid)?; @@ -1378,12 +1407,6 @@ pub fn compile(graph: Graph) -> Result { && descriptor.extent == source_descriptor.extent }) } - 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 - }), Some(FullscreenPolicy::BloomExtract) => authored_target_ok, Some(FullscreenPolicy::HdrSameExtent) => authored_target_ok && target_matches_source, Some(FullscreenPolicy::BloomComposite) => { @@ -1418,10 +1441,19 @@ pub fn compile(graph: Graph) -> Result { }; let TextureFamilySource::AuthoredTexture { descriptor, .. } = &families[family as usize].source; - if !is_filterable_frame_color(descriptor) { + let NormalizedParameters::FrameOut { dynamic_range, .. } = ¶ms[i] else { + unreachable!() + }; + if !frame_out_source_compatible(descriptor, dynamic_range) { + let message = match dynamic_range { + FrameDynamicRange::Hdr { .. } => "HDR frame output requires rgba16_float", + FrameDynamicRange::Sdr => { + "SDR frame output requires a linear filterable color texture" + } + }; return Err(error( "GRAPH_ILLEGAL_ACCESS", - "frame output requires a filterable single-view d2 color texture", + message, format!("nodes[{i}].inputs.color"), )); } @@ -1903,17 +1935,20 @@ pub(super) fn is_single_view_d2(descriptor: &NormalizedTextureDescriptor) -> boo && descriptor.sample_count == 1 && descriptor.mip_level_count == 1 && extent_layers(&descriptor.extent) == 1 + && descriptor.view_formats.is_empty() } -pub(super) fn is_filterable_frame_color(descriptor: &NormalizedTextureDescriptor) -> bool { +pub(super) fn frame_out_source_compatible( + descriptor: &NormalizedTextureDescriptor, + dynamic_range: &FrameDynamicRange, +) -> bool { is_single_view_d2(descriptor) - && matches!( - descriptor.format, - TextureFormat::Rgba8Unorm - | TextureFormat::Rgba8UnormSrgb - | TextureFormat::Bgra8Unorm - | TextureFormat::Bgra8UnormSrgb - | TextureFormat::Rgba16Float - ) + && match dynamic_range { + FrameDynamicRange::Hdr { .. } => descriptor.format == TextureFormat::Rgba16Float, + FrameDynamicRange::Sdr => matches!( + descriptor.format, + TextureFormat::Rgba8Unorm | TextureFormat::Bgra8Unorm | TextureFormat::Rgba16Float + ), + } } pub(super) fn texture_usage( f: &TextureFamily, diff --git a/renderer/src/render_graph/contracts.rs b/renderer/src/render_graph/contracts.rs index d39f854..cc3cfb7 100644 --- a/renderer/src/render_graph/contracts.rs +++ b/renderer/src/render_graph/contracts.rs @@ -25,7 +25,6 @@ pub enum ExecutionClass { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FullscreenPolicy { Copy, - ToneMap, HdrSameExtent, BloomExtract, BloomComposite, @@ -335,15 +334,6 @@ pub static CONTRACTS: &[Contract] = &[ inherently_observable: false, fullscreen_policy: Some(FullscreenPolicy::Copy), }, - Contract { - key: "tone_map", - version: 1, - execution: ExecutionClass::Render, - inputs: FULLSCREEN_COPY_IN, - outputs: FULLSCREEN_COPY_OUT, - inherently_observable: false, - fullscreen_policy: Some(FullscreenPolicy::ToneMap), - }, Contract { key: "color_balance", version: 1, @@ -418,7 +408,7 @@ pub static CONTRACTS: &[Contract] = &[ }, Contract { key: "frame_out", - version: 1, + version: 2, 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 98b330f..32f6de8 100644 --- a/renderer/src/render_graph/plan.rs +++ b/renderer/src/render_graph/plan.rs @@ -209,9 +209,6 @@ pub enum NormalizedParameters { clear_color: [f64; 4], }, FullscreenCopy, - ToneMap { - exposure: f32, - }, ColorBalance { mode: ColorBalanceMode, factor: f32, @@ -258,7 +255,50 @@ pub enum NormalizedParameters { LuminanceEdge { strength: f32, }, - FrameOut, + FrameOut { + dynamic_range: FrameDynamicRange, + output_transfer: OutputTransfer, + scale_mode: ScaleMode, + filter: FrameFilter, + background_color: [f32; 4], + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, serde::Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum FrameDynamicRange { + Sdr, + Hdr { + tone_mapper: ToneMapper, + exposure_stops: f32, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToneMapper { + Aces, + Reinhard, + None, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OutputTransfer { + Srgb, + Linear, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScaleMode { + Stretch, + Contain, + Cover, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FrameFilter { + Linear, + Nearest, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] diff --git a/renderer/src/render_graph/runtime.rs b/renderer/src/render_graph/runtime.rs index 7c7042d..6f39068 100644 --- a/renderer/src/render_graph/runtime.rs +++ b/renderer/src/render_graph/runtime.rs @@ -330,9 +330,6 @@ fn validate_fullscreen_execution( let vector = |v: &[f32], min, max| v.iter().all(|x| scalar(x, min, max)); let valid_parameters = match (key, &execution.parameters) { ("fullscreen_copy", NormalizedParameters::FullscreenCopy) => true, - ("tone_map", NormalizedParameters::ToneMap { exposure }) => { - exposure.is_finite() && (0.0..=32.0).contains(exposure) - } ( "color_balance", NormalizedParameters::ColorBalance { @@ -566,14 +563,6 @@ fn validate_fullscreen_execution( && target_d.format != TextureFormat::Depth32Float && target_d.extent == source.extent } - FullscreenPolicy::ToneMap => { - single_view_d2(target_d) - && !matches!( - target_d.format, - TextureFormat::Depth32Float | TextureFormat::R32Float - ) - && target_d.extent == source.extent - } FullscreenPolicy::BloomExtract => hdr(target_d), FullscreenPolicy::HdrSameExtent => hdr(target_d) && target_d.extent == source.extent, FullscreenPolicy::BloomComposite => { @@ -1477,11 +1466,40 @@ pub fn prepare_runtime_plan( } let frame_out = &graph.executions[frame_out_index]; - if !matches!(frame_out.parameters, NormalizedParameters::FrameOut) { + let NormalizedParameters::FrameOut { + dynamic_range, + output_transfer, + scale_mode: _, + filter: _, + background_color, + } = &frame_out.parameters + else { return Err(invalid( "frame_out parameters mismatch", format!("executions[{frame_out_index}].parameters"), )); + }; + let parameters_valid = background_color + .iter() + .all(|v| v.is_finite() && (0.0..=1.0).contains(v)) + && match dynamic_range { + FrameDynamicRange::Sdr => true, + FrameDynamicRange::Hdr { exposure_stops, .. } => { + exposure_stops.is_finite() && (-10.0..=10.0).contains(exposure_stops) + } + }; + if !parameters_valid { + return Err(invalid( + "frame_out parameters are out of range", + format!("executions[{frame_out_index}].parameters"), + )); + } + if *output_transfer == OutputTransfer::Linear && surface.format.is_srgb() { + return Err(error( + "GRAPH_SURFACE_INCOMPATIBLE", + "linear output transfer cannot target an sRGB surface", + format!("executions[{frame_out_index}].parameters.outputTransfer"), + )); } let ExecutionKind::FrameOut { color } = frame_out.kind else { return Err(invalid( @@ -1546,7 +1564,7 @@ pub fn prepare_runtime_plan( ) })?; let TextureFamilySource::AuthoredTexture { descriptor, .. } = &family.source; - if !super::compiler::is_filterable_frame_color(descriptor) { + if !super::compiler::frame_out_source_compatible(descriptor, dynamic_range) { return Err(invalid( "frame_out texture descriptor is incompatible", format!("textureFamilies[{}].source.descriptor", family.id), diff --git a/renderer/src/render_graph/tests.rs b/renderer/src/render_graph/tests.rs index d37a1ac..b2e18d6 100644 --- a/renderer/src/render_graph/tests.rs +++ b/renderer/src/render_graph/tests.rs @@ -6,8 +6,11 @@ use serde_json::{json, Value}; fn input(node: &str, socket: &str) -> Value { json!({"node":node,"socket":socket}) } -fn node(id: &str, key: &str, parameters: Value, inputs: Value) -> Value { - json!({"id":id,"state":"enabled","executor":{"key":key,"version":1},"parameters":parameters,"inputs":inputs}) +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]}); + } + json!({"id":id,"state":"enabled","executor":{"key":key,"version":if key == "frame_out" { 2 } 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}) @@ -247,7 +250,7 @@ fn fullscreen_copy_parameters_are_exactly_empty() { let copy = node_index(&g, "copy"); g["nodes"][copy]["parameters"] = json!({"obsolete":true}); assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); - assert_eq!(CONTRACTS.len(), 17); + assert_eq!(CONTRACTS.len(), 16); } #[test] @@ -839,7 +842,6 @@ fn exact_phase_four_contract_catalog_and_mesh_metadata() { ("pipeline_registry", 1), ("pipeline", 1), ("fullscreen_copy", 1), - ("tone_map", 1), ("color_balance", 1), ("exposure_contrast", 1), ("saturation", 1), @@ -848,7 +850,7 @@ fn exact_phase_four_contract_catalog_and_mesh_metadata() { ("bloom_blur", 1), ("bloom_composite", 1), ("luminance_edge", 1), - ("frame_out", 1), + ("frame_out", 2), ] ); assert_eq!( @@ -864,7 +866,6 @@ fn exact_phase_four_contract_catalog_and_mesh_metadata() { ("pipeline_registry", None), ("pipeline", None), ("fullscreen_copy", Some(FullscreenPolicy::Copy)), - ("tone_map", Some(FullscreenPolicy::ToneMap)), ("color_balance", Some(FullscreenPolicy::HdrSameExtent)), ("exposure_contrast", Some(FullscreenPolicy::HdrSameExtent)), ("saturation", Some(FullscreenPolicy::HdrSameExtent)), @@ -2083,6 +2084,41 @@ fn assert_runtime_path(graph: &CompiledGraph, path: impl AsRef) { assert_eq!(error.details["path"], path.as_ref()); } +fn mutate_texture_descriptor( + graph: &mut CompiledGraph, + resource: u32, + mutate: impl FnOnce(&mut NormalizedTextureDescriptor), +) { + let (family, allocation) = match graph.resources[resource as usize].plan { + ResourcePlan::Texture { + family, allocation, .. + } => (family as usize, allocation.unwrap()), + _ => unreachable!(), + }; + let TextureFamilySource::AuthoredTexture { + resource: source, + descriptor, + .. + } = &mut graph.texture_families[family].source; + mutate(descriptor); + let descriptor = descriptor.clone(); + let ResourcePlan::TextureSource { + descriptor: source_descriptor, + .. + } = &mut graph.resources[*source as usize].plan + else { + unreachable!() + }; + *source_descriptor = descriptor.clone(); + let key = &mut graph.allocation_classes[allocation.class as usize].key; + key.dimension = descriptor.dimension; + key.format = descriptor.format; + key.extent = descriptor.extent; + key.mip_level_count = descriptor.mip_level_count; + key.sample_count = descriptor.sample_count; + key.view_formats = descriptor.view_formats; +} + #[test] fn runtime_rejects_coordinated_contract_and_texture_target_mutations() { let baseline = compile_graph(full_cull_graph()); @@ -2920,15 +2956,6 @@ fn runtime_rejects_coordinated_executor_frustum_and_fullscreen_mutations() { .position(|execution| execution.executor.key == "fullscreen_copy") .unwrap(); let mut graph = post.clone(); - graph.executions[copy].executor.key = "tone_map".into(); - assert_runtime_path(&graph, format!("executions[{copy}].parameters")); - for exposure in [f32::NAN, -0.1, 32.1] { - let mut graph = post.clone(); - graph.executions[copy].executor.key = "tone_map".into(); - graph.executions[copy].parameters = NormalizedParameters::ToneMap { exposure }; - assert_runtime_path(&graph, format!("executions[{copy}].parameters")); - } - let mut graph = post.clone(); let ExecutionKind::Render { color_attachments, .. } = &mut graph.executions[copy].kind @@ -2943,6 +2970,242 @@ fn runtime_rejects_coordinated_executor_frustum_and_fullscreen_mutations() { assert_runtime_path(&graph, format!("executions[{copy}].kind")); } +fn frame_parameters(hdr: bool) -> Value { + json!({"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() { + let fields = [ + "hdrEnabled", + "toneMapper", + "exposureStops", + "outputTransfer", + "scaleMode", + "filter", + "backgroundColor", + ]; + for field in fields { + let mut g = full_cull_graph(); + let i = node_index(&g, "frame_out"); + g["nodes"][i]["parameters"] = frame_parameters(false); + g["nodes"][i]["parameters"] + .as_object_mut() + .unwrap() + .remove(field); + assert_eq!( + compile_error(g).code, + "GRAPH_PARAMETERS_INVALID", + "missing {field}" + ); + } + let mut g = full_cull_graph(); + let i = node_index(&g, "frame_out"); + g["nodes"][i]["parameters"] = frame_parameters(false); + g["nodes"][i]["parameters"]["extra"] = json!(0); + assert_eq!(compile_error(g).code, "GRAPH_PARAMETERS_INVALID"); + for (field, bad) in [ + ("toneMapper", json!("bad")), + ("exposureStops", json!(11)), + ("backgroundColor", json!([0, 0, -0.1, 1])), + ] { + let mut g = full_cull_graph(); + let i = node_index(&g, "frame_out"); + g["nodes"][i]["parameters"] = frame_parameters(false); + g["nodes"][i]["parameters"][field] = bad; + assert_eq!( + compile_error(g).code, + "GRAPH_PARAMETERS_INVALID", + "hidden {field}" + ); + } + let mut g = full_cull_graph(); + let i = node_index(&g, "frame_out"); + g["nodes"][i]["parameters"] = frame_parameters(false); + assert!(matches!( + execution(&compile_graph(g), "frame_out").parameters, + NormalizedParameters::FrameOut { + dynamic_range: FrameDynamicRange::Sdr, + .. + } + )); + let mut g = full_cull_graph(); + let i = node_index(&g, "frame_out"); + g["nodes"][i]["executor"]["version"] = json!(1); + assert_eq!(compile_error(g).code, "GRAPH_EXECUTOR_VERSION_UNSUPPORTED"); +} + +#[test] +fn frame_out_source_format_matrix_is_exact() { + let surface = RuntimeSurfaceContract { + format: wgpu::TextureFormat::Bgra8Unorm, + width: 1280, + height: 720, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: vec![], + }; + for (format, sdr, hdr) in [ + ("rgba8_unorm", true, false), + ("bgra8_unorm", true, false), + ("rgba16_float", true, true), + ("rgba8_unorm_srgb", false, false), + ("bgra8_unorm_srgb", false, false), + ("r32_float", false, false), + ("depth32_float", false, false), + ] { + for (hdr_enabled, accepted) in [(false, sdr), (true, hdr)] { + let mut g = full_cull_graph(); + let i = node_index(&g, "frame_out"); + if format == "depth32_float" { + g["nodes"][i]["inputs"]["color"] = input("pipeline_main", "depth"); + } else { + g["nodes"][0]["parameters"]["texture"]["format"] = json!(format); + } + g["nodes"][i]["parameters"] = frame_parameters(hdr_enabled); + match compile(serde_json::from_value(g).unwrap()) { + Ok(compiled) => { + assert!( + accepted, + "unexpected accept: hdr={hdr_enabled} format={format}" + ); + prepare_runtime_plan(&compiled, surface.clone(), None).unwrap(); + } + Err(error) => { + assert!( + !accepted, + "unexpected reject: hdr={hdr_enabled} format={format}" + ); + let expected_message = if hdr_enabled { + "HDR frame output requires rgba16_float" + } else { + "SDR frame output requires a linear filterable color texture" + }; + assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); + assert_eq!(error.details["path"], format!("nodes[{i}].inputs.color")); + assert_eq!(error.message, expected_message); + assert_eq!(error.details["message"], expected_message); + } + } + } + } +} + +#[test] +fn runtime_rejects_every_frame_parameter_lane_and_coordinated_source_mutations() { + let baseline = compile_graph(full_cull_graph()); + let i = baseline + .executions + .iter() + .position(|e| e.executor.key == "frame_out") + .unwrap(); + for version in [1, 3] { + let mut g = baseline.clone(); + g.executions[i].executor.version = version; + assert_runtime_path(&g, format!("executions[{i}].executor.version")); + } + for lane in 0..4 { + for value in [f32::NAN, -0.1, 1.1] { + let mut g = baseline.clone(); + let NormalizedParameters::FrameOut { + background_color, .. + } = &mut g.executions[i].parameters + else { + unreachable!() + }; + background_color[lane] = value; + assert_runtime_path(&g, format!("executions[{i}].parameters")); + } + } + for exposure in [f32::NAN, -10.1, 10.1] { + let mut g = baseline.clone(); + let NormalizedParameters::FrameOut { dynamic_range, .. } = &mut g.executions[i].parameters + else { + unreachable!() + }; + *dynamic_range = FrameDynamicRange::Hdr { + tone_mapper: ToneMapper::Aces, + exposure_stops: exposure, + }; + assert_runtime_path(&g, format!("executions[{i}].parameters")); + } + let ExecutionKind::FrameOut { color } = baseline.executions[i].kind else { + unreachable!() + }; + let family = match baseline.resources[color as usize].plan { + ResourcePlan::Texture { family, .. } => family, + _ => unreachable!(), + } as usize; + for format in [ + TextureFormat::Rgba8UnormSrgb, + TextureFormat::Bgra8UnormSrgb, + TextureFormat::R32Float, + ] { + let mut g = baseline.clone(); + mutate_texture_descriptor(&mut g, color, |descriptor| descriptor.format = format); + assert_runtime_path(&g, format!("textureFamilies[{family}].source.descriptor")); + } + + let mut sdr_to_hdr = baseline.clone(); + let NormalizedParameters::FrameOut { dynamic_range, .. } = + &mut sdr_to_hdr.executions[i].parameters + else { + unreachable!() + }; + *dynamic_range = FrameDynamicRange::Hdr { + tone_mapper: ToneMapper::Aces, + exposure_stops: 0., + }; + assert_runtime_path( + &sdr_to_hdr, + format!("textureFamilies[{family}].source.descriptor"), + ); + + let mut hdr_value = full_cull_graph(); + hdr_value["nodes"][0]["parameters"]["texture"]["format"] = json!("rgba16_float"); + let frame_node = node_index(&hdr_value, "frame_out"); + hdr_value["nodes"][frame_node]["parameters"] = frame_parameters(true); + let mut hdr_to_sdr = compile_graph(hdr_value); + let hdr_frame = hdr_to_sdr + .executions + .iter() + .position(|e| e.executor.key == "frame_out") + .unwrap(); + let NormalizedParameters::FrameOut { + dynamic_range, + output_transfer, + .. + } = &mut hdr_to_sdr.executions[hdr_frame].parameters + else { + unreachable!() + }; + *dynamic_range = FrameDynamicRange::Sdr; + *output_transfer = OutputTransfer::Linear; + let linear_surface = RuntimeSurfaceContract { + format: wgpu::TextureFormat::Bgra8Unorm, + width: 1280, + height: 720, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: vec![], + }; + prepare_runtime_plan(&hdr_to_sdr, linear_surface.clone(), None).unwrap(); + let error = prepare_runtime_plan( + &hdr_to_sdr, + RuntimeSurfaceContract { + format: wgpu::TextureFormat::Bgra8UnormSrgb, + ..linear_surface + }, + None, + ) + .unwrap_err(); + assert_eq!(error.code, "GRAPH_SURFACE_INCOMPATIBLE"); + assert_eq!( + error.details["path"], + format!("executions[{hdr_frame}].parameters.outputTransfer") + ); +} + #[test] fn runtime_rejects_mesh_query_predicate_shape_mutations() { let baseline = compile_graph(full_cull_graph()); @@ -2997,12 +3260,6 @@ fn runtime_rejects_fullscreen_parameter_and_sample_order_mutations() { .position(|execution| execution.executor.key == "fullscreen_copy") .unwrap(); let invalid_parameters = [ - ( - "tone_map", - NormalizedParameters::ToneMap { - exposure: f32::INFINITY, - }, - ), ( "bloom_extract", NormalizedParameters::BloomExtract { @@ -3130,55 +3387,23 @@ fn runtime_rechecks_pipeline_attachment_descriptors() { .unwrap() .resource }; - let mutate_descriptor = - |graph: &mut CompiledGraph, resource: u32, mutate: fn(&mut NormalizedTextureDescriptor)| { - let (family, allocation) = match graph.resources[resource as usize].plan { - ResourcePlan::Texture { - family, allocation, .. - } => (family as usize, allocation.unwrap()), - _ => unreachable!(), - }; - let TextureFamilySource::AuthoredTexture { - resource: source, - descriptor, - .. - } = &mut graph.texture_families[family].source; - mutate(descriptor); - let descriptor = descriptor.clone(); - let ResourcePlan::TextureSource { - descriptor: source_descriptor, - .. - } = &mut graph.resources[*source as usize].plan - else { - unreachable!() - }; - *source_descriptor = descriptor.clone(); - let key = &mut graph.allocation_classes[allocation.class as usize].key; - key.dimension = descriptor.dimension; - key.format = descriptor.format; - key.extent = descriptor.extent; - key.mip_level_count = descriptor.mip_level_count; - key.sample_count = descriptor.sample_count; - key.view_formats = descriptor.view_formats; - }; - let mut graph = baseline.clone(); let color = attachment(&graph, "color"); - mutate_descriptor(&mut graph, color, |descriptor| { + mutate_texture_descriptor(&mut graph, color, |descriptor| { descriptor.format = TextureFormat::Depth32Float; }); assert_runtime_path(&graph, format!("executions[{pipeline}].inputs")); let mut graph = baseline.clone(); let depth = attachment(&graph, "depth"); - mutate_descriptor(&mut graph, depth, |descriptor| { + mutate_texture_descriptor(&mut graph, depth, |descriptor| { descriptor.format = TextureFormat::Rgba16Float; }); assert_runtime_path(&graph, format!("executions[{pipeline}].inputs")); let mut graph = baseline; let color = attachment(&graph, "color"); - mutate_descriptor(&mut graph, color, |descriptor| { + mutate_texture_descriptor(&mut graph, color, |descriptor| { descriptor.extent = NormalizedTextureExtent::Absolute { width: 4, height: 4, diff --git a/renderer/src/renderer/executors/pipeline.rs b/renderer/src/renderer/executors/pipeline.rs index 0bd1b5a..a79b7eb 100644 --- a/renderer/src/renderer/executors/pipeline.rs +++ b/renderer/src/renderer/executors/pipeline.rs @@ -102,7 +102,7 @@ pub(crate) fn encode_compiled( ( surface, wgpu::Operations { - load: wgpu::LoadOp::Load, + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store, }, ) diff --git a/renderer/src/renderer/fullscreen_copy.wgsl b/renderer/src/renderer/fullscreen_copy.wgsl index 625029b..6bcc9fb 100644 --- a/renderer/src/renderer/fullscreen_copy.wgsl +++ b/renderer/src/renderer/fullscreen_copy.wgsl @@ -17,10 +17,29 @@ fn aces(x: vec3) -> vec3 { return clamp((x * (2.51 * x + vec3(0.03))) / (x * (2.43 * x + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0)); } 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)); + let safe = clamp(x, vec3(0.0), vec3(1.0)); + let low = safe * 12.92; let high = 1.055 * pow(safe, vec3(1.0 / 2.4)) - vec3(0.055); + return select(high, low, safe <= vec3(0.0031308)); +} +struct FrameCoordinates { uv: vec2, contained: bool } +fn frame_coordinates(position: vec2, surface: vec2, source: vec2, mode: f32) -> FrameCoordinates { + if mode < 0.5 { return FrameCoordinates(position / surface, true); } + let surface_aspect = surface.x / surface.y; let source_aspect = source.x / source.y; var size = surface; + if (mode < 1.5 && source_aspect > surface_aspect) || (mode > 1.5 && source_aspect < surface_aspect) { size.y = surface.x / source_aspect; } else { size.x = surface.y * source_aspect; } + let origin = (surface - size) * 0.5; + return FrameCoordinates((position - origin) / size, mode > 1.5 || (all(position >= origin) && all(position < origin + size))); +} +@fragment fn fs_frame_out(in: VertexOut) -> @location(0) vec4 { + let surface=parameters.values[1].yz; let source=vec2(textureDimensions(source_texture)); + let coordinates=frame_coordinates(in.position.xy,surface,source,parameters.values[1].x); + if !coordinates.contained { + var bg=parameters.values[2]; if parameters.values[0].w > 0.5 { bg=vec4(linear_to_srgb(bg.rgb),clamp(bg.a,0.0,1.0)); } return bg; + } + let sampled=sample_source(coordinates.uv); var rgb: vec3; + if parameters.values[0].x > 0.5 { rgb=max(sampled.rgb*exp2(parameters.values[0].z),vec3(0.0)); if parameters.values[0].y > 1.5 { rgb=aces(rgb); } else if parameters.values[0].y > 0.5 { rgb=rgb/(vec3(1.0)+rgb); } else { rgb=clamp(rgb,vec3(0.0),vec3(1.0)); } } else { rgb=clamp(sampled.rgb,vec3(0.0),vec3(1.0)); } + if parameters.values[0].w > 0.5 { rgb=linear_to_srgb(rgb); } + return vec4(clamp(rgb,vec3(0.0),vec3(1.0)),clamp(sampled.a,0.0,1.0)); } -@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); } fn grading_result(source: vec4, graded: vec3, factor: f32) -> vec4 { return vec4(mix(source.rgb, graded, vec3(factor)), source.a); } @fragment fn fs_color_balance(in: VertexOut) -> @location(0) vec4 { let c=sample_source(in.uv); var graded: vec3; diff --git a/renderer/src/renderer/mod.rs b/renderer/src/renderer/mod.rs index 4e51592..dbb081f 100644 --- a/renderer/src/renderer/mod.rs +++ b/renderer/src/renderer/mod.rs @@ -33,6 +33,24 @@ struct FullscreenUniforms { values: [[f32; 4]; 8], } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FullscreenSamplerChoice { + Linear, + Nearest, +} + +fn frame_out_sampler_choice( + parameters: &crate::render_graph::NormalizedParameters, +) -> Option { + let crate::render_graph::NormalizedParameters::FrameOut { filter, .. } = parameters else { + return None; + }; + Some(match filter { + crate::render_graph::FrameFilter::Linear => FullscreenSamplerChoice::Linear, + crate::render_graph::FrameFilter::Nearest => FullscreenSamplerChoice::Nearest, + }) +} + fn pack_fullscreen_uniforms( key: &str, parameters: &crate::render_graph::NormalizedParameters, @@ -40,11 +58,7 @@ fn pack_fullscreen_uniforms( use crate::render_graph::NormalizedParameters; let mut values = [[0.; 4]; 8]; let first = match (key, parameters) { - ( - "fullscreen_copy" | "frame_out", - NormalizedParameters::FullscreenCopy | NormalizedParameters::FrameOut, - ) => [0.; 4], - ("tone_map", NormalizedParameters::ToneMap { exposure }) => [*exposure, 0., 0., 0.], + ("fullscreen_copy", NormalizedParameters::FullscreenCopy) => [0.; 4], ( "color_balance", NormalizedParameters::ColorBalance { @@ -134,10 +148,68 @@ fn pack_fullscreen_uniforms( Some(FullscreenUniforms { values }) } +fn pack_frame_out_uniforms( + parameters: &crate::render_graph::NormalizedParameters, + surface: &crate::render_graph::RuntimeSurfaceContract, +) -> Option { + use crate::render_graph::*; + let NormalizedParameters::FrameOut { + dynamic_range, + output_transfer, + scale_mode, + background_color, + .. + } = parameters + else { + return None; + }; + if *output_transfer == OutputTransfer::Linear && surface.format.is_srgb() { + return None; + } + let (hdr, mapper, exposure) = match dynamic_range { + FrameDynamicRange::Sdr => (0., 0., 0.), + FrameDynamicRange::Hdr { + tone_mapper, + exposure_stops, + } => ( + 1., + match tone_mapper { + ToneMapper::None => 0., + ToneMapper::Reinhard => 1., + ToneMapper::Aces => 2., + }, + *exposure_stops, + ), + }; + let mut values = [[0.; 4]; 8]; + values[0] = [ + hdr, + mapper, + exposure, + if *output_transfer == OutputTransfer::Srgb && !surface.format.is_srgb() { + 1. + } else { + 0. + }, + ]; + values[1] = [ + match scale_mode { + ScaleMode::Stretch => 0., + ScaleMode::Contain => 1., + ScaleMode::Cover => 2., + }, + surface.width as f32, + surface.height as f32, + 0., + ]; + values[2] = *background_color; + 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"), + "fullscreen_copy" => Some("fs_copy"), + "frame_out" => Some("fs_frame_out"), "color_balance" => Some("fs_color_balance"), "exposure_contrast" => Some("fs_exposure_contrast"), "saturation" => Some("fs_saturation"), @@ -153,7 +225,7 @@ fn resolve_fullscreen_entry(key: &str) -> Option<&'static str> { #[cfg(test)] mod fullscreen_tests { use super::*; - use crate::render_graph::{ColorBalanceMode, NormalizedParameters}; + use crate::render_graph::*; fn assert_packed(key: &str, parameters: NormalizedParameters, expected: &[[f32; 4]]) { let packed = pack_fullscreen_uniforms(key, ¶meters).unwrap(); @@ -179,15 +251,15 @@ mod fullscreen_tests { 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() + pack_fullscreen_uniforms("frame_out", &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("frame_out"), Some("fs_frame_out")); + assert_eq!(resolve_fullscreen_entry("tone_map"), None); assert_eq!( resolve_fullscreen_entry("color_balance"), Some("fs_color_balance") @@ -223,6 +295,296 @@ mod fullscreen_tests { assert_eq!(resolve_fullscreen_entry("unknown"), None); } + fn surface(format: wgpu::TextureFormat) -> RuntimeSurfaceContract { + RuntimeSurfaceContract { + format, + width: 1920, + height: 1080, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: vec![], + } + } + + fn frame( + dynamic_range: FrameDynamicRange, + transfer: OutputTransfer, + scale_mode: ScaleMode, + filter: FrameFilter, + ) -> NormalizedParameters { + NormalizedParameters::FrameOut { + dynamic_range, + output_transfer: transfer, + scale_mode, + filter, + background_color: [0.1, 0.2, 0.3, 0.4], + } + } + + #[test] + fn frame_out_packer_tags_all_lanes_and_sampler_is_gpu_independent() { + let expected = |first, scale| { + [ + first, + [scale, 1920., 1080., 0.], + [0.1, 0.2, 0.3, 0.4], + [0.; 4], + [0.; 4], + [0.; 4], + [0.; 4], + [0.; 4], + ] + }; + for (mapper, mapper_tag, scale, scale_tag, filter) in [ + ( + ToneMapper::None, + 0., + ScaleMode::Stretch, + 0., + FrameFilter::Linear, + ), + ( + ToneMapper::Reinhard, + 1., + ScaleMode::Contain, + 1., + FrameFilter::Nearest, + ), + ( + ToneMapper::Aces, + 2., + ScaleMode::Cover, + 2., + FrameFilter::Linear, + ), + ] { + let p = frame( + FrameDynamicRange::Hdr { + tone_mapper: mapper, + exposure_stops: -2., + }, + OutputTransfer::Srgb, + scale, + filter, + ); + assert_eq!( + pack_frame_out_uniforms(&p, &surface(wgpu::TextureFormat::Rgba8Unorm)) + .unwrap() + .values, + expected([1., mapper_tag, -2., 1.], scale_tag) + ); + assert_eq!( + frame_out_sampler_choice(&p), + Some(if filter == FrameFilter::Nearest { + FullscreenSamplerChoice::Nearest + } else { + FullscreenSamplerChoice::Linear + }) + ); + } + let sdr = frame( + FrameDynamicRange::Sdr, + OutputTransfer::Srgb, + ScaleMode::Stretch, + FrameFilter::Linear, + ); + for format in [ + wgpu::TextureFormat::Bgra8UnormSrgb, + wgpu::TextureFormat::Rgba8UnormSrgb, + ] { + assert_eq!( + pack_frame_out_uniforms(&sdr, &surface(format)) + .unwrap() + .values, + expected([0.; 4], 0.) + ); + } + assert_eq!( + pack_frame_out_uniforms(&sdr, &surface(wgpu::TextureFormat::Bgra8Unorm)) + .unwrap() + .values, + expected([0., 0., 0., 1.], 0.) + ); + let linear = frame( + FrameDynamicRange::Sdr, + OutputTransfer::Linear, + ScaleMode::Stretch, + FrameFilter::Linear, + ); + assert_eq!( + pack_frame_out_uniforms(&linear, &surface(wgpu::TextureFormat::Bgra8Unorm)) + .unwrap() + .values, + expected([0.; 4], 0.) + ); + assert!( + pack_frame_out_uniforms(&linear, &surface(wgpu::TextureFormat::Bgra8UnormSrgb)) + .is_none() + ); + assert_eq!( + frame_out_sampler_choice(&NormalizedParameters::FullscreenCopy), + None + ); + } + + fn coordinates( + p: [f32; 2], + surface: [f32; 2], + source: [f32; 2], + mode: ScaleMode, + ) -> ([f32; 2], bool) { + if mode == ScaleMode::Stretch { + return ([p[0] / surface[0], p[1] / surface[1]], true); + } + let (sa, ia) = (surface[0] / surface[1], source[0] / source[1]); + let mut size = surface; + if (mode == ScaleMode::Contain && ia > sa) || (mode == ScaleMode::Cover && ia < sa) { + size[1] = surface[0] / ia; + } else { + size[0] = surface[1] * ia; + } + let origin = [(surface[0] - size[0]) * 0.5, (surface[1] - size[1]) * 0.5]; + ( + [(p[0] - origin[0]) / size[0], (p[1] - origin[1]) / size[1]], + mode == ScaleMode::Cover + || (p[0] >= origin[0] + && p[1] >= origin[1] + && p[0] < origin[0] + size[0] + && p[1] < origin[1] + size[1]), + ) + } + + #[test] + fn frame_coordinates_reference_half_open_centered_crop_and_stretch() { + let contain = |p| coordinates(p, [4., 4.], [4., 2.], ScaleMode::Contain); + assert_eq!(contain([0., 1.]), ([0., 0.], true)); + assert_eq!(contain([4., 1.]), ([1., 0.], false)); + assert_eq!(contain([0., 3.]), ([0., 1.], false)); + assert!(contain([4. - 0.0001, 3. - 0.0001]).1); + for point in [[-1., 2.], [5., 2.], [2., 0.], [2., 4.]] { + assert!(!contain(point).1, "outside point {point:?}"); + } + let cover = |p| coordinates(p, [4., 4.], [4., 2.], ScaleMode::Cover); + assert_eq!(cover([2., 2.]), ([0.5, 0.5], true)); + assert_eq!(cover([0., 2.]), ([0.25, 0.5], true)); + assert_eq!(cover([4., 2.]), ([0.75, 0.5], true)); + assert_eq!( + coordinates([2., 2.], [4., 4.], [1., 9.], ScaleMode::Stretch), + ([0.5, 0.5], true) + ); + } + + fn srgb(x: f32) -> f32 { + let x = x.clamp(0., 1.); + if x <= 0.0031308 { + 12.92 * x + } else { + 1.055 * x.powf(1. / 2.4) - 0.055 + } + } + fn shade(c: [f32; 4], mapper: Option, exposure: f32, transfer: bool) -> [f32; 4] { + let mut rgb = [0.; 3]; + for i in 0..3 { + let x = if mapper.is_some() { + (c[i] * 2f32.powf(exposure)).max(0.) + } else { + c[i] + }; + rgb[i] = match mapper { + Some(ToneMapper::Aces) => { + ((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14)).clamp(0., 1.) + } + Some(ToneMapper::Reinhard) => x / (1. + x), + _ => x.clamp(0., 1.), + }; + if transfer { + rgb[i] = srgb(rgb[i]); + } + } + [rgb[0], rgb[1], rgb[2], c[3].clamp(0., 1.)] + } + + fn shade_background( + c: [f32; 4], + _hdr_mapper: ToneMapper, + _hdr_exposure_stops: f32, + output_transfer: OutputTransfer, + ) -> [f32; 4] { + let mut rgb = [c[0].clamp(0., 1.), c[1].clamp(0., 1.), c[2].clamp(0., 1.)]; + if output_transfer == OutputTransfer::Srgb { + rgb.iter_mut().for_each(|value| *value = srgb(*value)); + } + [rgb[0], rgb[1], rgb[2], c[3].clamp(0., 1.)] + } + + #[test] + fn frame_cpu_goldens_process_rgb_with_straight_clamped_alpha_and_background_once() { + let close = |actual: f32, expected: f32| { + assert!((actual - expected).abs() < 1e-6, "{actual} != {expected}") + }; + for (input, mapper, expected) in [ + (0., ToneMapper::Aces, 0.), + (1., ToneMapper::Aces, 0.8037975), + (1., ToneMapper::Reinhard, 0.5), + (4., ToneMapper::Reinhard, 0.8), + (-1., ToneMapper::None, 0.), + (2., ToneMapper::None, 1.), + ] { + close( + shade([input, 0., 0., 0.25], Some(mapper), 0., false)[0], + expected, + ); + } + for (input, expected) in [(0., 0.), (0.0031308, 0.0404499), (0.18, 0.461356), (1., 1.)] { + close(srgb(input), expected); + } + let low_alpha = shade([0.18, 0.5, 1., 0.25], Some(ToneMapper::Reinhard), 1., true); + let opaque = shade([0.18, 0.5, 1., 1.], Some(ToneMapper::Reinhard), 1., true); + assert_eq!(&low_alpha[..3], &opaque[..3]); + assert_eq!((low_alpha[3], opaque[3]), (0.25, 1.)); + assert_eq!(shade([0.18, 0.5, 1., 2.], None, 0., false)[3], 1.); + let background = [0.18, 0.5, 1., 0.25]; + let once = shade_background(background, ToneMapper::Aces, 10., OutputTransfer::Srgb); + for (actual, expected) in once.into_iter().zip([0.461356, 0.7353569, 1., 0.25]) { + close(actual, expected); + } + assert_eq!( + once, + shade_background(background, ToneMapper::Reinhard, -10., OutputTransfer::Srgb) + ); + assert_eq!( + shade_background(background, ToneMapper::Aces, 10., OutputTransfer::Linear), + background + ); + assert_eq!( + shade_background( + [0.18, 0.5, 1., 1.2], + ToneMapper::Aces, + 10., + OutputTransfer::Srgb + )[3], + 1. + ); + assert_eq!( + shade_background( + [0.18, 0.5, 1., -0.2], + ToneMapper::Aces, + 10., + OutputTransfer::Srgb + )[3], + 0. + ); + let white = shade_background( + [1., 1., 1., 0.25], + ToneMapper::Aces, + 10., + OutputTransfer::Srgb, + ); + white[..3].iter().for_each(|channel| close(*channel, 1.)); + assert_eq!(white[3], 0.25); + let tone_mapped_white = shade([1., 1., 1., 1.], Some(ToneMapper::Aces), 0., false); + assert!(tone_mapped_white[0] < 0.81 && white[0] > tone_mapped_white[0]); + } + fn balance(mode: ColorBalanceMode) -> NormalizedParameters { NormalizedParameters::ColorBalance { mode, @@ -1218,6 +1580,15 @@ impl Renderer { min_filter: wgpu::FilterMode::Linear, ..Default::default() }); + let nearest_sampler = self + .context + .device + .create_sampler(&wgpu::SamplerDescriptor { + label: Some(" frame nearest clamp"), + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); let mut executions = Vec::new(); for (index, execution) in graph.executions.iter().enumerate() { let contract = crate::render_graph::contract(&execution.executor.key) @@ -1256,9 +1627,20 @@ impl Renderer { _ => return Err(fail("fullscreen inputs mismatch")), } }; - let values = + let values = if frame_out { + pack_frame_out_uniforms(&execution.parameters, &runtime.surface) + } else { pack_fullscreen_uniforms(&execution.executor.key, &execution.parameters) - .ok_or_else(|| fail("executor parameters mismatch"))?; + } + .ok_or_else(|| fail("executor parameters mismatch"))?; + let sampler_choice = if frame_out { + Some( + frame_out_sampler_choice(&execution.parameters) + .ok_or_else(|| fail("executor parameters mismatch"))?, + ) + } else { + None + }; use wgpu::util::DeviceExt; let uniform = self.context @@ -1347,7 +1729,15 @@ impl Renderer { }, wgpu::BindGroupEntry { binding: 2, - resource: wgpu::BindingResource::Sampler(&sampler), + resource: wgpu::BindingResource::Sampler( + if sampler_choice + == Some(FullscreenSamplerChoice::Nearest) + { + &nearest_sampler + } else { + &sampler + }, + ), }, wgpu::BindGroupEntry { binding: 3, diff --git a/static/index.html b/static/index.html index 520e2f1..6db4595 100644 --- a/static/index.html +++ b/static/index.html @@ -176,7 +176,10 @@ - + + + + diff --git a/static/render-graph/catalog.js b/static/render-graph/catalog.js index 97f341b..bca2585 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 = 5; +export const CATALOG_VERSION = 6; const exact = (type) => ({ kind: "exact", types: [type] }); const i = (type, required = true, authoringType) => ({ accepted: typeof type === "string" ? exact(type) : type, @@ -120,16 +120,6 @@ export const semanticCatalog = Object.freeze({ outputs: { color: o("texture") }, parameters: {}, }, - tone_map: { - version: 1, - execution: "render", - inputs: { - source: i("texture"), - colorTarget: i("texture"), - }, - outputs: { color: o("texture") }, - parameters: { exposure: 1 }, - }, color_balance: { version: 1, execution: "render", @@ -198,11 +188,11 @@ export const semanticCatalog = Object.freeze({ parameters: { strength: 2 }, }, frame_out: { - version: 1, + version: 2, execution: "frame", inputs: { color: i("texture") }, outputs: {}, - parameters: {}, + parameters: { hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] }, }, }); const socketColors = [ @@ -383,7 +373,6 @@ const parameterSchemas = { clearColor: color([0.015, 0.02, 0.03, 1]), }, fullscreen_copy: {}, - tone_map: { exposure: number(1, 0, 32) }, color_balance: { mode: enumeration("lift_gamma_gain", ["lift_gamma_gain", "offset_power_slope"]), factor: number(1, 0, 1), lift: number(0, -1, 1), liftColor: color([1, 1, 1, 1], 0, 4), gamma: number(1, 0.01, 4), gammaColor: color([1, 1, 1, 1], 0, 4), gain: number(1, 0, 4), gainColor: color([1, 1, 1, 1], 0, 4), @@ -399,7 +388,15 @@ const parameterSchemas = { }, bloom_composite: { intensity: number(1, 0, 16) }, luminance_edge: { strength: number(2, 0, 16) }, - frame_out: {}, + frame_out: { + hdrEnabled: boolean(true), + toneMapper: enumeration("aces", ["aces", "reinhard", "none"]), + exposureStops: number(0, -10, 10), + outputTransfer: enumeration("srgb", ["srgb", "linear"]), + scaleMode: enumeration("stretch", ["stretch", "contain", "cover"]), + filter: enumeration("linear", ["linear", "nearest"]), + backgroundColor: color([0, 0, 0, 1]), + }, }; export const nodeDefinitions = Object.fromEntries( Object.entries(semanticCatalog).map(([key, c]) => { @@ -471,6 +468,17 @@ nodeDefinitions.color_balance.ui = [ { kind: "parameter", parameter: "factor" }, { kind: "socket", socket: "source" }, { kind: "socket", socket: "colorTarget" }, { kind: "socket", socket: "color" }, ]; +nodeDefinitions.frame_out.ui = [ + { 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 } }, + { kind: "parameter", parameter: "exposureStops", title: "Exposure", visibleWhen: { parameter: "hdrEnabled", equals: true } }, + { kind: "parameter", parameter: "outputTransfer", title: "Transfer" }, + { kind: "parameter", parameter: "scaleMode", title: "Scale" }, + { kind: "parameter", parameter: "filter" }, + { kind: "parameter", parameter: "backgroundColor", title: "Background", visibleWhen: { parameter: "scaleMode", equals: "contain" } }, + { kind: "socket", socket: "color" }, +]; export const fxNodeComposition = Object.freeze({ schemaVersion: 2, id: "yawn.render-graph", diff --git a/static/render-graph/presets.js b/static/render-graph/presets.js index 3229375..49add56 100644 --- a/static/render-graph/presets.js +++ b/static/render-graph/presets.js @@ -4,17 +4,18 @@ 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) => ({ +const texture = (format, scale = 1, heightScale = scale) => ({ texture: { dimension: "d2", format, - extent: { kind: "surface_relative", width: { numerator: 1, denominator: scale }, height: { numerator: 1, denominator: scale }, depthOrArrayLayers: 1 }, + extent: { kind: "surface_relative", width: { numerator: 1, denominator: scale }, height: { numerator: 1, denominator: heightScale }, depthOrArrayLayers: 1 }, mipLevelCount: 1, sampleCount: 1, viewFormats: [], }, residency: "transient", }); -const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1]) => [ - node("hdr", "texture", texture("rgba16_float")), - node("depth", "texture", texture("depth32_float")), +const frameOut = (hdr, options = {}) => ({ 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)), node("mesh", "mesh"), node("query", "mesh_query", { visiblePredicate: "required_true", visibleDefault: true, frustumCulledPredicate: "any", frustumCulledDefault: false }, { mesh: input("mesh", "mesh"), isVisible: input("mesh", "isVisible") }), node("registry", "pipeline_registry", {}, { pipelineIndices: input("mesh", "pipelineIndices") }), @@ -26,13 +27,13 @@ const graph = (graphId, nodes) => Object.freeze({ schemaVersion: 2, graphId, rev const direct = (graphId, clearColor) => graph(graphId, [ node("ldr", "texture", texture("rgba8_unorm")), ...scene("ldr", clearColor).filter((item) => item.id !== "hdr"), - node("frame_out", "frame_out", {}, { color: input("pbr_double", "color") }), + node("frame_out", "frame_out", frameOut(false), { color: input("pbr_double", "color") }), ]); export const midnight = direct("preset_midnight", [0.015, 0.06, 0.18, 1]); export const ember = direct("preset_ember", [0.18, 0.035, 0.012, 1]); export const hdr = graph("preset_hdr_fullscreen", [ ...scene("hdr"), - node("frame_out", "frame_out", {}, { color: input("pbr_double", "color") }), + node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") }), ]); export const culling = graph("preset_gpu_culling", (() => { const nodes = structuredClone(hdr.nodes); @@ -43,15 +44,15 @@ export const culling = graph("preset_gpu_culling", (() => { return nodes; })()); const postPreset = (graphId, kind) => { - const nodes = [node("ldr", "texture", texture("rgba8_unorm")), ...scene("hdr")]; + const nodes = [...scene("hdr")]; let source = "pbr_double"; if (kind === "edges") { - nodes.splice(1, 0, node("edge_hdr", "texture", texture("rgba16_float"))); + nodes.splice(0, 0, node("edge_hdr", "texture", texture("rgba16_float"))); nodes.push(node("edges", "luminance_edge", { strength: 2 }, { source: input(source, "color"), colorTarget: input("edge_hdr", "texture") })); source = "edges"; } if (kind === "bloom" || kind === "combined") { - nodes.splice(1, 0, + nodes.splice(0, 0, node("half_a", "texture", texture("rgba16_float", 2)), node("half_b", "texture", texture("rgba16_float", 2)), node("half_c", "texture", texture("rgba16_float", 2)), node("composite_hdr", "texture", texture("rgba16_float"))); nodes.push( @@ -62,26 +63,32 @@ const postPreset = (graphId, kind) => { ); source = "composite"; if (kind === "combined") { - nodes.splice(1, 0, node("edge_hdr", "texture", texture("rgba16_float"))); + nodes.splice(0, 0, node("edge_hdr", "texture", texture("rgba16_float"))); nodes.push(node("edges", "luminance_edge", { strength: 2 }, { source: input(source, "color"), colorTarget: input("edge_hdr", "texture") })); source = "edges"; } } - nodes.push(node("tone", "tone_map", { exposure: 1 }, { source: input(source, "color"), colorTarget: input("ldr", "texture") })); - nodes.push(node("frame_out", "frame_out", {}, { color: input("tone", "color") })); + nodes.push(node("frame_out", "frame_out", frameOut(true), { color: input(source, "color") })); return graph(graphId, nodes); }; export const tone = postPreset("preset_tone", "tone"); +const displayPreset = (id, parameters, heightScale = 1) => graph(id, [ + ...scene("hdr", undefined, heightScale), + node("frame_out", "frame_out", parameters, { color: input("pbr_double", "color") }), +]); +export const contain = displayPreset("preset_contain", frameOut(false, { scaleMode: "contain", filter: "nearest", backgroundColor: [0.18, 0.18, 0.18, 0.25] }), 2); +export const reinhard = displayPreset("preset_reinhard", frameOut(true, { toneMapper: "reinhard", exposureStops: 2, scaleMode: "cover", filter: "nearest" }), 2); +export const linear = displayPreset("preset_linear", frameOut(true, { toneMapper: "none", exposureStops: 2, outputTransfer: "linear" })); export const edges = postPreset("preset_edges", "edges"); export const bloom = postPreset("preset_bloom", "bloom"); export const combined = postPreset("preset_combined", "combined"); export const grading = graph("preset_grading", [ - node("balance_hdr", "texture", texture("rgba16_float")), node("exposure_hdr", "texture", texture("rgba16_float")), node("saturation_hdr", "texture", texture("rgba16_float")), node("mixer_hdr", "texture", texture("rgba16_float")), node("ldr", "texture", texture("rgba8_unorm")), + node("balance_hdr", "texture", texture("rgba16_float")), node("exposure_hdr", "texture", texture("rgba16_float")), node("saturation_hdr", "texture", texture("rgba16_float")), node("mixer_hdr", "texture", texture("rgba16_float")), ...scene("hdr"), node("balance", "color_balance", { mode: "lift_gamma_gain", factor: 1, lift: 0, liftColor: [1,1,1,1], gamma: 1, gammaColor: [1,1,1,1], gain: 1, gainColor: [1,1,1,1], offset: 0, offsetColor: [1,1,1,1], power: 1, powerColor: [1,1,1,1], slope: 1, slopeColor: [1,1,1,1] }, { source: input("pbr_double", "color"), colorTarget: input("balance_hdr", "texture") }), node("exposure", "exposure_contrast", { exposureStops: 0, contrast: 1, pivot: 0.18, factor: 1 }, { source: input("balance", "color"), colorTarget: input("exposure_hdr", "texture") }), node("saturation", "saturation", { saturation: 1, factor: 1 }, { source: input("exposure", "color"), colorTarget: input("saturation_hdr", "texture") }), 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("tone", "tone_map", { exposure: 1 }, { source: input("mixer", "color"), colorTarget: input("ldr", "texture") }), node("frame_out", "frame_out", {}, { color: input("tone", "color") }), + node("frame_out", "frame_out", frameOut(true), { color: input("mixer", "color") }), ]); -export const renderGraphPresets = Object.freeze({ midnight, ember, hdr, culling, tone, grading, edges, bloom, combined }); +export const renderGraphPresets = Object.freeze({ midnight, ember, hdr, culling, tone, contain, reinhard, linear, grading, edges, bloom, combined }); diff --git a/tests/add-node-menu.test.js b/tests/add-node-menu.test.js index f14f3c9..6637176 100644 --- a/tests/add-node-menu.test.js +++ b/tests/add-node-menu.test.js @@ -10,17 +10,13 @@ import { spawnRequestedNode, } from "../static/render-graph/node-spawn.js"; -test("add-node model contains all 17 catalog types in application groups", () => { - assert.equal(addNodeItems.length, 17); +test("add-node model contains all 16 catalog types in application groups", () => { + assert.equal(addNodeItems.length, 16); assert.deepEqual( [...new Set(addNodeItems.map((item) => item.group))], ["Source", "Compute", "CPU preparation", "Render / post", "Frame"], ); - assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 17); - assert.deepEqual( - searchAddNodeItems("tone render").map((item) => item.typeId), - ["tone_map"], - ); + assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 16); assert.deepEqual(searchAddNodeItems("no such node"), []); }); @@ -40,7 +36,7 @@ test("allocator avoids existing and session-reserved IDs and is bounded", () => ); }); -test("all 17 types spawn with exact position, current version and generated ID", async () => { +test("all 16 types spawn with exact position, current version and generated ID", async () => { let revision = 5, expectedType; const request = { compositionRevision: 5, viewPosition: { x: 12.25, y: -4 } }; @@ -91,13 +87,13 @@ test("spawn rechecks composition after getState and propagates add errors", asyn }, }; assert.equal( - await spawnRequestedNode(root, view, request, "tone_map", allocate), + await spawnRequestedNode(root, view, request, "fullscreen_copy", allocate), false, ); revision = 2; root.getState = async () => ({ version: 3, nodes: [] }); await assert.rejects( - spawnRequestedNode(root, view, request, "tone_map", allocate), + spawnRequestedNode(root, view, request, "fullscreen_copy", allocate), /must not add/, ); }); @@ -124,7 +120,7 @@ test("spawn cancels when a pending getState becomes mutated or dead", async () = root, view, request, - "tone_map", + "fullscreen_copy", () => "node_a", () => alive, ); @@ -136,7 +132,7 @@ test("spawn cancels when a pending getState becomes mutated or dead", async () = root, view, request, - "tone_map", + "fullscreen_copy", () => "node_b", () => alive, ); @@ -161,7 +157,7 @@ test("spawn has a final liveness guard after ID allocation", async () => { root, view, request, - "tone_map", + "fullscreen_copy", () => { alive = false; return "node_reserved"; @@ -190,7 +186,7 @@ test("spawn suppresses teardown RPC rejections but propagates genuine live add e root, view, request, - "tone_map", + "fullscreen_copy", () => "node_a", () => alive, ), @@ -208,7 +204,7 @@ test("spawn suppresses teardown RPC rejections but propagates genuine live add e root, view, request, - "tone_map", + "fullscreen_copy", () => "node_b", () => alive, ), @@ -224,7 +220,7 @@ test("spawn suppresses teardown RPC rejections but propagates genuine live add e root, view, request, - "tone_map", + "fullscreen_copy", () => "node_c", () => alive, ), diff --git a/tests/fxnode-composition.test.js b/tests/fxnode-composition.test.js index 591e8d4..59ec0f4 100644 --- a/tests/fxnode-composition.test.js +++ b/tests/fxnode-composition.test.js @@ -36,8 +36,8 @@ test("production render graph composition passes fxnode's public validator", asy result.ok ? undefined : JSON.stringify(result.issues, null, 2), ); assert.equal(fxNodeComposition.schemaVersion, 2); - assert.equal(fxNodeComposition.version, 5); - assert.equal(Object.keys(fxNodeComposition.nodes).length, 17); + assert.equal(fxNodeComposition.version, 6); + assert.equal(Object.keys(fxNodeComposition.nodes).length, 16); assert.ok( Object.values(fxNodeComposition.nodes).every( (definition) => definition.migrations.length === 0, diff --git a/tests/render-graph-authoring.test.js b/tests/render-graph-authoring.test.js index 51c3ed2..a54956a 100644 --- a/tests/render-graph-authoring.test.js +++ b/tests/render-graph-authoring.test.js @@ -24,7 +24,7 @@ function fixture() { return { id: n.id, typeId: n.executor.key, - typeVersion: 1, + typeVersion: d.version, known: true, muted: n.state !== "enabled", position: { x: 10, y: 20 }, @@ -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, 1); + assert.equal(semantic.version, key === "frame_out" ? 2 : 1); assert.equal(nodeDefinitions[key].version, semantic.version); assert.equal(descriptors[key].version, semantic.version); } @@ -110,7 +110,6 @@ test("catalog exhaustively mirrors all current contracts", () => { "pipeline_registry", "pipeline", "fullscreen_copy", - "tone_map", "color_balance", "exposure_contrast", "saturation", @@ -134,7 +133,7 @@ test("catalog exhaustively mirrors all current contracts", () => { Object.keys(contract.parameters).sort(), key, ); - assert.equal(CATALOG_VERSION, 5); + assert.equal(CATALOG_VERSION, 6); assert.deepEqual(nodeDefinitions.pipeline.parameters, { pipeline: { type: "string", @@ -238,6 +237,7 @@ test("adapter validates and exactly lowers canonical pipeline controls and blur visible: socket.visible, }), ); + blur.typeVersion = descriptors.bloom_blur.version; x.nodes.push(blur); assert.deepEqual( adaptFxNodeSnapshot(x).nodes.find((node) => node.id === "blur").parameters @@ -346,6 +346,51 @@ 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); + 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] }, + }); + assert.deepEqual(nodeDefinitions.frame_out.parameters, { + 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 }, + outputTransfer: { type: "string", default: { kind: "string", value: "srgb" }, enum: ["srgb", "linear"] }, + scaleMode: { type: "string", default: { kind: "string", value: "stretch" }, enum: ["stretch", "contain", "cover"] }, + filter: { type: "string", default: { kind: "string", value: "linear" }, enum: ["linear", "nearest"] }, + 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: "Display Transform" }, + { kind: "parameter", parameter: "hdrEnabled", title: "HDR" }, + { kind: "parameter", parameter: "toneMapper", title: "Tone Mapper", visibleWhen: { parameter: "hdrEnabled", equals: true } }, + { kind: "parameter", parameter: "exposureStops", title: "Exposure", visibleWhen: { parameter: "hdrEnabled", equals: true } }, + { kind: "parameter", parameter: "outputTransfer", title: "Transfer" }, + { kind: "parameter", parameter: "scaleMode", title: "Scale" }, + { kind: "parameter", parameter: "filter" }, + { kind: "parameter", parameter: "backgroundColor", title: "Background", visibleWhen: { parameter: "scaleMode", equals: "contain" } }, + { kind: "socket", socket: "color" }, + ]); + const reject = (mutate, code, parameter) => { + const x = fixture(), n = x.nodes.find((node) => node.typeId === "frame_out"); + mutate(x, n); + 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"); + 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"], + ["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); + for (const [hidden, value] of [["toneMapper", "bad"], ["exposureStops", Infinity]]) + reject((x, n) => { n.parameters.hdrEnabled.value = false; n.parameters[hidden].value = value; }, "AUTHORING_PARAMETER", hidden); + reject((x, n) => { n.parameters.scaleMode.value = "stretch"; n.parameters.backgroundColor.value = [2, 0, 0, 1]; }, "AUTHORING_PARAMETER", "backgroundColor"); +}); test("adapter counts only active incoming links and reports socket overflow", () => { const x = fixture(); const active = x.links.find((link) => link.toSocketId === "frame_out:color"); diff --git a/tests/render-graph-presets.test.js b/tests/render-graph-presets.test.js index 1e37868..212d7a0 100644 --- a/tests/render-graph-presets.test.js +++ b/tests/render-graph-presets.test.js @@ -9,6 +9,9 @@ const order = [ "hdr", "culling", "tone", + "contain", + "reinhard", + "linear", "grading", "edges", "bloom", @@ -61,7 +64,6 @@ const sequences = { ["frame_out", "frame_out"], ], tone: [ - ["ldr", "texture"], ["hdr", "texture"], ["depth", "texture"], ["mesh", "mesh"], @@ -70,17 +72,15 @@ const sequences = { ["ground", "pipeline"], ["pbr", "pipeline"], ["pbr_double", "pipeline"], - ["tone", "tone_map"], ["frame_out", "frame_out"], ], grading: [ - ["balance_hdr", "texture"], ["exposure_hdr", "texture"], ["saturation_hdr", "texture"], ["mixer_hdr", "texture"], ["ldr", "texture"], + ["balance_hdr", "texture"], ["exposure_hdr", "texture"], ["saturation_hdr", "texture"], ["mixer_hdr", "texture"], ["hdr", "texture"], ["depth", "texture"], ["mesh", "mesh"], ["query", "mesh_query"], ["registry", "pipeline_registry"], ["ground", "pipeline"], ["pbr", "pipeline"], ["pbr_double", "pipeline"], ["balance", "color_balance"], ["exposure", "exposure_contrast"], - ["saturation", "saturation"], ["mixer", "channel_mixer"], ["tone", "tone_map"], ["frame_out", "frame_out"], + ["saturation", "saturation"], ["mixer", "channel_mixer"], ["frame_out", "frame_out"], ], edges: [ - ["ldr", "texture"], ["edge_hdr", "texture"], ["hdr", "texture"], ["depth", "texture"], @@ -91,11 +91,9 @@ const sequences = { ["pbr", "pipeline"], ["pbr_double", "pipeline"], ["edges", "luminance_edge"], - ["tone", "tone_map"], ["frame_out", "frame_out"], ], bloom: [ - ["ldr", "texture"], ["half_a", "texture"], ["half_b", "texture"], ["half_c", "texture"], @@ -112,11 +110,9 @@ const sequences = { ["blur_h", "bloom_blur"], ["blur_v", "bloom_blur"], ["composite", "bloom_composite"], - ["tone", "tone_map"], ["frame_out", "frame_out"], ], combined: [ - ["ldr", "texture"], ["edge_hdr", "texture"], ["half_a", "texture"], ["half_b", "texture"], @@ -135,10 +131,11 @@ const sequences = { ["blur_v", "bloom_blur"], ["composite", "bloom_composite"], ["edges", "luminance_edge"], - ["tone", "tone_map"], ["frame_out", "frame_out"], ], }; +for (const name of ["contain", "reinhard", "linear"]) + sequences[name] = sequences.tone; test("presets have the exact canonical pipeline identities, schemas, and node sequences", () => { assert.deepEqual(Object.keys(presets.renderGraphPresets), order); @@ -150,6 +147,9 @@ test("presets have the exact canonical pipeline identities, schemas, and node se "preset_hdr_fullscreen", "preset_gpu_culling", "preset_tone", + "preset_contain", + "preset_reinhard", + "preset_linear", "preset_grading", "preset_edges", "preset_bloom", @@ -282,16 +282,19 @@ test("presets preserve common mesh, texture, query, pipeline, culling and post w node: "cull", socket: "isFrustumCulled", }); - for (const name of ["tone", "grading", "edges", "bloom", "combined"]) + for (const name of ["tone", "contain", "reinhard", "linear", "grading", "edges", "bloom", "combined"]) assert.ok(!presets[name].nodes.some((node) => node.id === "copy")); const finalSource = { hdr: "pbr_double", culling: "pbr_double", - tone: "tone", - grading: "tone", - edges: "tone", - bloom: "tone", - combined: "tone", + tone: "pbr_double", + contain: "pbr_double", + reinhard: "pbr_double", + linear: "pbr_double", + grading: "mixer", + edges: "edges", + bloom: "composite", + combined: "edges", midnight: "pbr_double", ember: "pbr_double", }; @@ -300,21 +303,4 @@ test("presets preserve common mesh, texture, query, pipeline, culling and post w node: finalSource[name], socket: "color", }); - assert.equal( - presets.tone.nodes.find((node) => node.id === "tone").inputs.source.node, - "pbr_double", - ); - assert.equal( - presets.edges.nodes.find((node) => node.id === "tone").inputs.source.node, - "edges", - ); - assert.equal( - presets.bloom.nodes.find((node) => node.id === "tone").inputs.source.node, - "composite", - ); - assert.equal( - presets.combined.nodes.find((node) => node.id === "tone").inputs.source - .node, - "edges", - ); });