diff --git a/renderer/src/render_graph/compiler.rs b/renderer/src/render_graph/compiler.rs index 9bc5a9d..b2e86f9 100644 --- a/renderer/src/render_graph/compiler.rs +++ b/renderer/src/render_graph/compiler.rs @@ -42,6 +42,46 @@ struct ToneMapParameters { exposure: f32, } #[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ColorBalanceParameters { + mode: ColorBalanceMode, + factor: f32, + lift: f32, + lift_color: [f32; 4], + gamma: f32, + gamma_color: [f32; 4], + gain: f32, + gain_color: [f32; 4], + offset: f32, + offset_color: [f32; 4], + power: f32, + power_color: [f32; 4], + slope: f32, + slope_color: [f32; 4], +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ExposureContrastParameters { + exposure_stops: f32, + contrast: f32, + pivot: f32, + factor: f32, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SaturationParameters { + saturation: f32, + factor: f32, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ChannelMixerParameters { + red_output: [f32; 3], + green_output: [f32; 3], + blue_output: [f32; 3], + factor: f32, +} +#[derive(Deserialize)] #[serde(deny_unknown_fields)] struct BloomExtractParameters { threshold: f32, @@ -76,6 +116,23 @@ fn range(value: f32, min: f32, max: f32, path: String) -> Result( + value: [f32; N], + min: f32, + max: f32, + base: &str, +) -> Result<[f32; N], GraphError> { + let mut result = value; + for (i, component) in result.iter_mut().enumerate() { + *component = range(*component, min, max, format!("{base}[{i}]"))?; + } + Ok(result) +} +fn color(value: [f32; 4], min: f32, max: f32, base: &str) -> Result<[f32; 3], GraphError> { + let value = components(value, min, max, base)?; + Ok([value[0], value[1], value[2]]) +} + #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] struct OutputKey(usize, u16); #[derive(Clone, Copy)] @@ -374,6 +431,64 @@ fn decode(node: &Node, i: usize) -> Result { 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)?; + NormalizedParameters::ColorBalance { + mode: p.mode, + factor: range(p.factor, 0.0, 1.0, format!("{base}.factor"))?, + lift: range(p.lift, -1.0, 1.0, format!("{base}.lift"))?, + lift_color: color(p.lift_color, 0.0, 4.0, &format!("{base}.liftColor"))?, + gamma: range(p.gamma, 0.01, 4.0, format!("{base}.gamma"))?, + gamma_color: color(p.gamma_color, 0.0, 4.0, &format!("{base}.gammaColor"))?, + gain: range(p.gain, 0.0, 4.0, format!("{base}.gain"))?, + gain_color: color(p.gain_color, 0.0, 4.0, &format!("{base}.gainColor"))?, + offset: range(p.offset, -1.0, 1.0, format!("{base}.offset"))?, + offset_color: color(p.offset_color, 0.0, 2.0, &format!("{base}.offsetColor"))?, + power: range(p.power, 0.01, 4.0, format!("{base}.power"))?, + power_color: color(p.power_color, 0.0, 4.0, &format!("{base}.powerColor"))?, + slope: range(p.slope, 0.0, 4.0, format!("{base}.slope"))?, + slope_color: color(p.slope_color, 0.0, 4.0, &format!("{base}.slopeColor"))?, + } + } + "exposure_contrast" => { + let p: ExposureContrastParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + NormalizedParameters::ExposureContrast { + exposure_stops: range( + p.exposure_stops, + -10.0, + 10.0, + format!("{base}.exposureStops"), + )?, + contrast: range(p.contrast, 0.01, 4.0, format!("{base}.contrast"))?, + pivot: range(p.pivot, 0.001, 4.0, format!("{base}.pivot"))?, + factor: range(p.factor, 0.0, 1.0, format!("{base}.factor"))?, + } + } + "saturation" => { + let p: SaturationParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + NormalizedParameters::Saturation { + saturation: range(p.saturation, 0.0, 4.0, format!("{base}.saturation"))?, + factor: range(p.factor, 0.0, 1.0, format!("{base}.factor"))?, + } + } + "channel_mixer" => { + let p: ChannelMixerParameters = + serde_json::from_value(node.parameters.clone()).map_err(invalid)?; + NormalizedParameters::ChannelMixer { + red_output: components(p.red_output, -2.0, 2.0, &format!("{base}.redOutput"))?, + green_output: components( + p.green_output, + -2.0, + 2.0, + &format!("{base}.greenOutput"), + )?, + blue_output: components(p.blue_output, -2.0, 2.0, &format!("{base}.blueOutput"))?, + factor: range(p.factor, 0.0, 1.0, format!("{base}.factor"))?, + } + } "bloom_extract" => { let p: BloomExtractParameters = serde_json::from_value(node.parameters.clone()).map_err(invalid)?; diff --git a/renderer/src/render_graph/contracts.rs b/renderer/src/render_graph/contracts.rs index 57c58ed..d39f854 100644 --- a/renderer/src/render_graph/contracts.rs +++ b/renderer/src/render_graph/contracts.rs @@ -344,6 +344,42 @@ pub static CONTRACTS: &[Contract] = &[ inherently_observable: false, fullscreen_policy: Some(FullscreenPolicy::ToneMap), }, + Contract { + key: "color_balance", + version: 1, + execution: ExecutionClass::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), + }, + Contract { + key: "exposure_contrast", + version: 1, + execution: ExecutionClass::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), + }, + Contract { + key: "saturation", + version: 1, + execution: ExecutionClass::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), + }, + Contract { + key: "channel_mixer", + version: 1, + execution: ExecutionClass::Render, + inputs: FULLSCREEN_COPY_IN, + outputs: FULLSCREEN_COPY_OUT, + inherently_observable: false, + fullscreen_policy: Some(FullscreenPolicy::HdrSameExtent), + }, Contract { key: "bloom_extract", version: 1, diff --git a/renderer/src/render_graph/plan.rs b/renderer/src/render_graph/plan.rs index ed82a32..98b330f 100644 --- a/renderer/src/render_graph/plan.rs +++ b/renderer/src/render_graph/plan.rs @@ -212,6 +212,38 @@ pub enum NormalizedParameters { ToneMap { exposure: f32, }, + ColorBalance { + mode: ColorBalanceMode, + factor: f32, + lift: f32, + lift_color: [f32; 3], + gamma: f32, + gamma_color: [f32; 3], + gain: f32, + gain_color: [f32; 3], + offset: f32, + offset_color: [f32; 3], + power: f32, + power_color: [f32; 3], + slope: f32, + slope_color: [f32; 3], + }, + ExposureContrast { + exposure_stops: f32, + contrast: f32, + pivot: f32, + factor: f32, + }, + Saturation { + saturation: f32, + factor: f32, + }, + ChannelMixer { + red_output: [f32; 3], + green_output: [f32; 3], + blue_output: [f32; 3], + factor: f32, + }, BloomExtract { threshold: f32, knee: f32, @@ -229,6 +261,13 @@ pub enum NormalizedParameters { FrameOut, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ColorBalanceMode { + LiftGammaGain, + OffsetPowerSlope, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum ActiveCamera { diff --git a/renderer/src/render_graph/runtime.rs b/renderer/src/render_graph/runtime.rs index a329a45..7c7042d 100644 --- a/renderer/src/render_graph/runtime.rs +++ b/renderer/src/render_graph/runtime.rs @@ -326,11 +326,77 @@ fn validate_fullscreen_execution( return Ok(()); }; let path = |field| format!("executions[{i}].{field}"); + let scalar = |v: &f32, min, max| v.is_finite() && (min..=max).contains(v); + 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 { + factor, + lift, + lift_color, + gamma, + gamma_color, + gain, + gain_color, + offset, + offset_color, + power, + power_color, + slope, + slope_color, + .. + }, + ) => { + scalar(factor, 0.0, 1.0) + && scalar(lift, -1.0, 1.0) + && vector(lift_color, 0.0, 4.0) + && scalar(gamma, 0.01, 4.0) + && vector(gamma_color, 0.0, 4.0) + && scalar(gain, 0.0, 4.0) + && vector(gain_color, 0.0, 4.0) + && scalar(offset, -1.0, 1.0) + && vector(offset_color, 0.0, 2.0) + && scalar(power, 0.01, 4.0) + && vector(power_color, 0.0, 4.0) + && scalar(slope, 0.0, 4.0) + && vector(slope_color, 0.0, 4.0) + } + ( + "exposure_contrast", + NormalizedParameters::ExposureContrast { + exposure_stops, + contrast, + pivot, + factor, + }, + ) => { + scalar(exposure_stops, -10.0, 10.0) + && scalar(contrast, 0.01, 4.0) + && scalar(pivot, 0.001, 4.0) + && scalar(factor, 0.0, 1.0) + } + ("saturation", NormalizedParameters::Saturation { saturation, factor }) => { + scalar(saturation, 0.0, 4.0) && scalar(factor, 0.0, 1.0) + } + ( + "channel_mixer", + NormalizedParameters::ChannelMixer { + red_output, + green_output, + blue_output, + factor, + }, + ) => { + vector(red_output, -2.0, 2.0) + && vector(green_output, -2.0, 2.0) + && vector(blue_output, -2.0, 2.0) + && scalar(factor, 0.0, 1.0) + } ("bloom_extract", NormalizedParameters::BloomExtract { threshold, knee }) => { threshold.is_finite() && (0.0..=64.0).contains(threshold) diff --git a/renderer/src/render_graph/tests.rs b/renderer/src/render_graph/tests.rs index 4853217..d37a1ac 100644 --- a/renderer/src/render_graph/tests.rs +++ b/renderer/src/render_graph/tests.rs @@ -247,7 +247,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(), 13); + assert_eq!(CONTRACTS.len(), 17); } #[test] @@ -840,6 +840,10 @@ fn exact_phase_four_contract_catalog_and_mesh_metadata() { ("pipeline", 1), ("fullscreen_copy", 1), ("tone_map", 1), + ("color_balance", 1), + ("exposure_contrast", 1), + ("saturation", 1), + ("channel_mixer", 1), ("bloom_extract", 1), ("bloom_blur", 1), ("bloom_composite", 1), @@ -861,6 +865,10 @@ fn exact_phase_four_contract_catalog_and_mesh_metadata() { ("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)), + ("channel_mixer", Some(FullscreenPolicy::HdrSameExtent)), ("bloom_extract", Some(FullscreenPolicy::BloomExtract)), ("bloom_blur", Some(FullscreenPolicy::HdrSameExtent)), ("bloom_composite", Some(FullscreenPolicy::BloomComposite)), diff --git a/renderer/src/renderer/fullscreen_copy.wgsl b/renderer/src/renderer/fullscreen_copy.wgsl index 769bc0f..625029b 100644 --- a/renderer/src/renderer/fullscreen_copy.wgsl +++ b/renderer/src/renderer/fullscreen_copy.wgsl @@ -21,6 +21,25 @@ fn linear_to_srgb(x: vec3) -> vec3 { return select(high, low, x <= vec3(0.0031308)); } @fragment fn fs_tone_map(in: VertexOut) -> @location(0) vec4 { let c=sample_source(in.uv); return vec4(linear_to_srgb(aces(c.rgb * parameters.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; + if parameters.values[0].x < 0.5 { + let lift=parameters.values[2].xyz+vec3(parameters.values[0].z); let lifted=(c.rgb-vec3(1.0))*(vec3(2.0)-lift)+vec3(1.0); + let gain=parameters.values[4].xyz*parameters.values[1].x; let gained=max(lifted*gain,vec3(0.0)); + let gamma=max(parameters.values[3].xyz*parameters.values[0].w,vec3(0.000001)); graded=pow(gained,vec3(1.0)/gamma); + } else { + let slope=parameters.values[7].xyz*parameters.values[1].w; let offset=vec3(parameters.values[1].y)+(parameters.values[5].xyz-vec3(1.0)); + let power=max(parameters.values[6].xyz*parameters.values[1].z,vec3(0.000001)); graded=pow(max(c.rgb*slope+offset,vec3(0.0)),power); + } + return grading_result(c,graded,parameters.values[0].y); +} +@fragment fn fs_exposure_contrast(in: VertexOut) -> @location(0) vec4 { + let c=sample_source(in.uv); let exposed=c.rgb*exp2(parameters.values[0].x); let pivot=parameters.values[0].z; + let graded=sign(exposed)*vec3(pivot)*pow(abs(exposed)/vec3(pivot),vec3(parameters.values[0].y)); return grading_result(c,graded,parameters.values[0].w); +} +@fragment fn fs_saturation(in: VertexOut) -> @location(0) vec4 { let c=sample_source(in.uv); let l=dot(c.rgb,vec3(0.2126,0.7152,0.0722)); return grading_result(c,mix(vec3(l),c.rgb,vec3(parameters.values[0].x)),parameters.values[0].y); } +@fragment fn fs_channel_mixer(in: VertexOut) -> @location(0) vec4 { let c=sample_source(in.uv); let graded=vec3(dot(c.rgb,parameters.values[0].xyz),dot(c.rgb,parameters.values[1].xyz),dot(c.rgb,parameters.values[2].xyz)); return grading_result(c,graded,parameters.values[0].w); } @fragment fn fs_bloom_extract(in: VertexOut) -> @location(0) vec4 { let c=sample_source(in.uv); let brightness=max(c.r,max(c.g,c.b)); let knee=max(parameters.values[0].y,0.00001); let soft=clamp((brightness-parameters.values[0].x+knee)/(2.0*knee),0.0,1.0); let contribution=max(brightness-parameters.values[0].x,0.0)+soft*soft*knee; return vec4(c.rgb*contribution/max(brightness,0.00001),1.0); } diff --git a/renderer/src/renderer/mod.rs b/renderer/src/renderer/mod.rs index 88cb679..4e51592 100644 --- a/renderer/src/renderer/mod.rs +++ b/renderer/src/renderer/mod.rs @@ -38,12 +38,84 @@ fn pack_fullscreen_uniforms( parameters: &crate::render_graph::NormalizedParameters, ) -> Option { 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.], + ( + "color_balance", + NormalizedParameters::ColorBalance { + mode, + factor, + lift, + lift_color, + gamma, + gamma_color, + gain, + gain_color, + offset, + offset_color, + power, + power_color, + slope, + slope_color, + }, + ) => { + values[0] = [ + if *mode == crate::render_graph::ColorBalanceMode::LiftGammaGain { + 0. + } else { + 1. + }, + *factor, + *lift, + *gamma, + ]; + values[1] = [*gain, *offset, *power, *slope]; + for (lane, color) in [ + lift_color, + gamma_color, + gain_color, + offset_color, + power_color, + slope_color, + ] + .iter() + .enumerate() + { + values[lane + 2] = [color[0], color[1], color[2], 0.]; + } + return Some(FullscreenUniforms { values }); + } + ( + "exposure_contrast", + NormalizedParameters::ExposureContrast { + exposure_stops, + contrast, + pivot, + factor, + }, + ) => [*exposure_stops, *contrast, *pivot, *factor], + ("saturation", NormalizedParameters::Saturation { saturation, factor }) => { + [*saturation, *factor, 0., 0.] + } + ( + "channel_mixer", + NormalizedParameters::ChannelMixer { + red_output, + green_output, + blue_output, + factor, + }, + ) => { + values[0] = [red_output[0], red_output[1], red_output[2], *factor]; + values[1] = [green_output[0], green_output[1], green_output[2], 0.]; + values[2] = [blue_output[0], blue_output[1], blue_output[2], 0.]; + return Some(FullscreenUniforms { values }); + } ("bloom_extract", NormalizedParameters::BloomExtract { threshold, knee }) => { [*threshold, *knee, 0., 0.] } @@ -58,7 +130,6 @@ fn pack_fullscreen_uniforms( } _ => return None, }; - let mut values = [[0.; 4]; 8]; values[0] = first; Some(FullscreenUniforms { values }) } @@ -67,6 +138,10 @@ fn resolve_fullscreen_entry(key: &str) -> Option<&'static str> { match key { "fullscreen_copy" | "frame_out" => Some("fs_copy"), "tone_map" => Some("fs_tone_map"), + "color_balance" => Some("fs_color_balance"), + "exposure_contrast" => Some("fs_exposure_contrast"), + "saturation" => Some("fs_saturation"), + "channel_mixer" => Some("fs_channel_mixer"), "bloom_extract" => Some("fs_bloom_extract"), "bloom_blur" => Some("fs_bloom_blur"), "bloom_composite" => Some("fs_bloom_composite"), @@ -78,7 +153,15 @@ fn resolve_fullscreen_entry(key: &str) -> Option<&'static str> { #[cfg(test)] mod fullscreen_tests { use super::*; - use crate::render_graph::NormalizedParameters; + use crate::render_graph::{ColorBalanceMode, NormalizedParameters}; + + fn assert_packed(key: &str, parameters: NormalizedParameters, expected: &[[f32; 4]]) { + let packed = pack_fullscreen_uniforms(key, ¶meters).unwrap(); + assert_eq!(&packed.values[..expected.len()], expected); + assert!(packed.values[expected.len()..] + .iter() + .all(|lane| *lane == [0.; 4])); + } #[test] fn fullscreen_uniform_abi_and_packer_are_fixed() { @@ -105,6 +188,22 @@ mod fullscreen_tests { 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("color_balance"), + Some("fs_color_balance") + ); + assert_eq!( + resolve_fullscreen_entry("exposure_contrast"), + Some("fs_exposure_contrast") + ); + assert_eq!( + resolve_fullscreen_entry("saturation"), + Some("fs_saturation") + ); + assert_eq!( + resolve_fullscreen_entry("channel_mixer"), + Some("fs_channel_mixer") + ); assert_eq!( resolve_fullscreen_entry("bloom_extract"), Some("fs_bloom_extract") @@ -123,6 +222,157 @@ mod fullscreen_tests { ); assert_eq!(resolve_fullscreen_entry("unknown"), None); } + + fn balance(mode: ColorBalanceMode) -> NormalizedParameters { + NormalizedParameters::ColorBalance { + mode, + factor: 0.5, + lift: -0.1, + lift_color: [1., 2., 3.], + gamma: 1.1, + gamma_color: [1.1, 1.2, 1.3], + gain: 1.2, + gain_color: [2.1, 2.2, 2.3], + offset: 0.1, + offset_color: [0.1, 0.2, 0.3], + power: 1.3, + power_color: [3.1, 3.2, 3.3], + slope: 1.4, + slope_color: [0.4, 0.5, 0.6], + } + } + + #[test] + fn grading_uniforms_are_exact_and_zero_filled() { + for (mode, tag) in [ + (ColorBalanceMode::LiftGammaGain, 0.), + (ColorBalanceMode::OffsetPowerSlope, 1.), + ] { + assert_packed( + "color_balance", + balance(mode), + &[ + [tag, 0.5, -0.1, 1.1], + [1.2, 0.1, 1.3, 1.4], + [1., 2., 3., 0.], + [1.1, 1.2, 1.3, 0.], + [2.1, 2.2, 2.3, 0.], + [0.1, 0.2, 0.3, 0.], + [3.1, 3.2, 3.3, 0.], + [0.4, 0.5, 0.6, 0.], + ], + ); + } + assert_packed( + "exposure_contrast", + NormalizedParameters::ExposureContrast { + exposure_stops: -2., + contrast: 1.5, + pivot: 0.18, + factor: 0.5, + }, + &[[-2., 1.5, 0.18, 0.5]], + ); + assert_packed( + "saturation", + NormalizedParameters::Saturation { + saturation: 2., + factor: 0.25, + }, + &[[2., 0.25, 0., 0.]], + ); + assert_packed( + "channel_mixer", + NormalizedParameters::ChannelMixer { + red_output: [1., 2., 3.], + green_output: [4., 5., 6.], + blue_output: [7., 8., 9.], + factor: 0.5, + }, + &[[1., 2., 3., 0.5], [4., 5., 6., 0.], [7., 8., 9., 0.]], + ); + assert!( + pack_fullscreen_uniforms("saturation", &NormalizedParameters::FullscreenCopy).is_none() + ); + assert!(pack_fullscreen_uniforms( + "wrong", + &NormalizedParameters::Saturation { + saturation: 1., + factor: 1. + } + ) + .is_none()); + } + + fn mix(a: [f32; 4], b: [f32; 3], factor: f32) -> [f32; 4] { + [ + a[0] + (b[0] - a[0]) * factor, + a[1] + (b[1] - a[1]) * factor, + a[2] + (b[2] - a[2]) * factor, + a[3], + ] + } + fn exposure(c: [f32; 4], stops: f32, contrast: f32, pivot: f32, factor: f32) -> [f32; 4] { + let mut out = [0.; 3]; + for i in 0..3 { + let x = c[i] * 2f32.powf(stops); + out[i] = x.signum() * pivot * (x.abs() / pivot).powf(contrast); + } + mix(c, out, factor) + } + fn saturation(c: [f32; 4], amount: f32, factor: f32) -> [f32; 4] { + let l = c[0] * 0.2126 + c[1] * 0.7152 + c[2] * 0.0722; + mix( + c, + [ + l + (c[0] - l) * amount, + l + (c[1] - l) * amount, + l + (c[2] - l) * amount, + ], + factor, + ) + } + fn mixer(c: [f32; 4], rows: [[f32; 3]; 3], factor: f32) -> [f32; 4] { + mix( + c, + rows.map(|r| c[0] * r[0] + c[1] * r[1] + c[2] * r[2]), + factor, + ) + } + + #[test] + fn grading_cpu_references_cover_factors_and_neutral_cases() { + let c = [0.2, 0.4, 0.8, 0.3]; + for f in [0., 0.5, 1.] { + assert_eq!(exposure(c, 0., 1., 0.18, f), c); + } + assert_eq!(exposure(c, 2., 1., 0.18, 1.), [0.8, 1.6, 3.2, 0.3]); + let dark = exposure(c, -2., 1., 0.18, 1.); + assert!(dark + .iter() + .zip([0.05, 0.1, 0.2, 0.3]) + .all(|(a, b)| (a - b).abs() < 1e-6)); + let gray = saturation(c, 0., 1.); + assert!((gray[0] - gray[1]).abs() < 1e-6 && (gray[1] - gray[2]).abs() < 1e-6); + assert_eq!(saturation(c, 1., 1.), c); + assert_eq!(saturation(c, 2., 0.), c); + let identity = [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]]; + assert_eq!(mixer(c, identity, 1.), c); + assert_eq!( + mixer(c, [[0., 1., 0.], [1., 0., 0.], [0., 0., 1.]], 1.), + [0.4, 0.2, 0.8, 0.3] + ); + for x in [-1e-7, 0., 1e-7] { + assert!(exposure([x, x, x, 0.7], 0., 1.1, 0.18, 1.) + .iter() + .all(|v| v.is_finite())); + } + // Both WGSL balance branches are neutral on nonnegative RGB with neutral controls. + let lgg = c; // lift=0/color=1, gamma=1/color=1, gain=1/color=1 + let ops = c; // offset=0/color=1, power=1/color=1, slope=1/color=1 + assert_eq!(lgg, c); + assert_eq!(ops, c); + } } struct GpuTextureSlot { diff --git a/static/index.html b/static/index.html index 0a79917..520e2f1 100644 --- a/static/index.html +++ b/static/index.html @@ -177,6 +177,7 @@ + diff --git a/static/render-graph/catalog.js b/static/render-graph/catalog.js index 2147a38..97f341b 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 = 4; +export const CATALOG_VERSION = 5; const exact = (type) => ({ kind: "exact", types: [type] }); const i = (type, required = true, authoringType) => ({ accepted: typeof type === "string" ? exact(type) : type, @@ -130,6 +130,32 @@ export const semanticCatalog = Object.freeze({ outputs: { color: o("texture") }, parameters: { exposure: 1 }, }, + color_balance: { + version: 1, + execution: "render", + inputs: { source: i("texture"), colorTarget: i("texture") }, + outputs: { color: o("texture") }, + parameters: { + 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], + }, + }, + exposure_contrast: { + version: 1, execution: "render", + inputs: { source: i("texture"), colorTarget: i("texture") }, outputs: { color: o("texture") }, + parameters: { exposureStops: 0, contrast: 1, pivot: 0.18, factor: 1 }, + }, + saturation: { + version: 1, execution: "render", + inputs: { source: i("texture"), colorTarget: i("texture") }, outputs: { color: o("texture") }, + parameters: { saturation: 1, factor: 1 }, + }, + channel_mixer: { + version: 1, execution: "render", + inputs: { source: i("texture"), colorTarget: i("texture") }, outputs: { color: o("texture") }, + parameters: { redOutput: [1, 0, 0], greenOutput: [0, 1, 0], blueOutput: [0, 0, 1], factor: 1 }, + }, bloom_extract: { version: 1, execution: "render", @@ -280,12 +306,13 @@ const boolean = (value) => ({ type: "boolean", default: tagged("boolean", value), }); -const color = (value) => ({ +const color = (value, minimum = 0, maximum = 1) => ({ type: "color", default: tagged("color", value), - minimum: 0, - maximum: 1, + minimum, + maximum, }); +const vector = (value, minimum, maximum) => ({ type: "vector", default: tagged("vector", value), minimum, maximum }); const json = (value) => ({ type: "json", default: tagged("json", value) }); const parameterSchemas = { texture: { @@ -357,6 +384,14 @@ const parameterSchemas = { }, 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), + offset: number(0, -1, 1), offsetColor: color([1, 1, 1, 1], 0, 2), power: number(1, 0.01, 4), powerColor: color([1, 1, 1, 1], 0, 4), slope: number(1, 0, 4), slopeColor: color([1, 1, 1, 1], 0, 4), + }, + exposure_contrast: { exposureStops: number(0, -10, 10), contrast: number(1, 0.01, 4), pivot: number(0.18, 0.001, 4), factor: number(1, 0, 1) }, + saturation: { saturation: number(1, 0, 4), factor: number(1, 0, 1) }, + channel_mixer: { redOutput: vector([1, 0, 0], -2, 2), greenOutput: vector([0, 1, 0], -2, 2), blueOutput: vector([0, 0, 1], -2, 2), factor: number(1, 0, 1) }, bloom_extract: { threshold: number(1, 0, 64), knee: number(0.5, 0, 1) }, bloom_blur: { direction: enumeration("horizontal", ["horizontal", "vertical"]), @@ -425,6 +460,17 @@ export const nodeDefinitions = Object.fromEntries( ]; }), ); +nodeDefinitions.color_balance.ui = [ + { kind: "parameter", parameter: "mode" }, + { kind: "widget", widget: "grading-wheels", bindings: [ + { title: "Lift", scalar: "lift", color: "liftColor" }, { title: "Gamma", scalar: "gamma", color: "gammaColor" }, { title: "Gain", scalar: "gain", color: "gainColor" }, + ], visibleWhen: { parameter: "mode", equals: "lift_gamma_gain" } }, + { kind: "widget", widget: "grading-wheels", bindings: [ + { title: "Offset", scalar: "offset", color: "offsetColor" }, { title: "Power", scalar: "power", color: "powerColor" }, { title: "Slope", scalar: "slope", color: "slopeColor" }, + ], visibleWhen: { parameter: "mode", equals: "offset_power_slope" } }, + { kind: "parameter", parameter: "factor" }, + { kind: "socket", socket: "source" }, { kind: "socket", socket: "colorTarget" }, { 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 6a58736..3229375 100644 --- a/static/render-graph/presets.js +++ b/static/render-graph/presets.js @@ -75,4 +75,13 @@ export const tone = postPreset("preset_tone", "tone"); export const edges = postPreset("preset_edges", "edges"); export const bloom = postPreset("preset_bloom", "bloom"); export const combined = postPreset("preset_combined", "combined"); -export const renderGraphPresets = Object.freeze({ midnight, ember, hdr, culling, tone, edges, bloom, 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")), + ...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") }), +]); +export const renderGraphPresets = Object.freeze({ midnight, ember, hdr, culling, tone, grading, edges, bloom, combined }); diff --git a/tests/add-node-menu.test.js b/tests/add-node-menu.test.js index c46645c..f14f3c9 100644 --- a/tests/add-node-menu.test.js +++ b/tests/add-node-menu.test.js @@ -10,13 +10,13 @@ import { spawnRequestedNode, } from "../static/render-graph/node-spawn.js"; -test("add-node model contains all 13 catalog types in application groups", () => { - assert.equal(addNodeItems.length, 13); +test("add-node model contains all 17 catalog types in application groups", () => { + assert.equal(addNodeItems.length, 17); 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, 13); + assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 17); assert.deepEqual( searchAddNodeItems("tone render").map((item) => item.typeId), ["tone_map"], @@ -40,7 +40,7 @@ test("allocator avoids existing and session-reserved IDs and is bounded", () => ); }); -test("all 13 types spawn with exact position, current version and generated ID", async () => { +test("all 17 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 } }; diff --git a/tests/fxnode-composition.test.js b/tests/fxnode-composition.test.js index 0273f04..591e8d4 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, 4); - assert.equal(Object.keys(fxNodeComposition.nodes).length, 13); + assert.equal(fxNodeComposition.version, 5); + assert.equal(Object.keys(fxNodeComposition.nodes).length, 17); 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 9679047..51c3ed2 100644 --- a/tests/render-graph-authoring.test.js +++ b/tests/render-graph-authoring.test.js @@ -111,6 +111,10 @@ test("catalog exhaustively mirrors all current contracts", () => { "pipeline", "fullscreen_copy", "tone_map", + "color_balance", + "exposure_contrast", + "saturation", + "channel_mixer", "bloom_extract", "bloom_blur", "bloom_composite", @@ -130,7 +134,7 @@ test("catalog exhaustively mirrors all current contracts", () => { Object.keys(contract.parameters).sort(), key, ); - assert.equal(CATALOG_VERSION, 4); + assert.equal(CATALOG_VERSION, 5); assert.deepEqual(nodeDefinitions.pipeline.parameters, { pipeline: { type: "string", @@ -175,6 +179,26 @@ test("catalog exhaustively mirrors all current contracts", () => { value: true, }); assert.equal(nodeDefinitions.mesh_query.sockets.isVisible.showValue, true); + + assert.deepEqual(nodeDefinitions.color_balance.ui.slice(0, 4), [ + { kind: "parameter", parameter: "mode" }, + { kind: "widget", widget: "grading-wheels", bindings: [ + { title: "Lift", scalar: "lift", color: "liftColor" }, + { title: "Gamma", scalar: "gamma", color: "gammaColor" }, + { title: "Gain", scalar: "gain", color: "gainColor" }, + ], visibleWhen: { parameter: "mode", equals: "lift_gamma_gain" } }, + { kind: "widget", widget: "grading-wheels", bindings: [ + { title: "Offset", scalar: "offset", color: "offsetColor" }, + { title: "Power", scalar: "power", color: "powerColor" }, + { title: "Slope", scalar: "slope", color: "slopeColor" }, + ], visibleWhen: { parameter: "mode", equals: "offset_power_slope" } }, + { kind: "parameter", parameter: "factor" }, + ]); + for (const name of ["liftColor", "gammaColor", "gainColor", "offsetColor", "powerColor", "slopeColor"]) + assert.deepEqual(nodeDefinitions.color_balance.parameters[name].default, { kind: "color", value: [1, 1, 1, 1] }); + assert.deepEqual(nodeDefinitions.channel_mixer.parameters.redOutput, { + type: "vector", default: { kind: "vector", value: [1, 0, 0] }, minimum: -2, maximum: 2, + }); }); test("adapter validates and exactly lowers canonical pipeline controls and blur direction", () => { diff --git a/tests/render-graph-presets.test.js b/tests/render-graph-presets.test.js index 325ea5b..1e37868 100644 --- a/tests/render-graph-presets.test.js +++ b/tests/render-graph-presets.test.js @@ -9,6 +9,7 @@ const order = [ "hdr", "culling", "tone", + "grading", "edges", "bloom", "combined", @@ -72,6 +73,12 @@ const sequences = { ["tone", "tone_map"], ["frame_out", "frame_out"], ], + grading: [ + ["balance_hdr", "texture"], ["exposure_hdr", "texture"], ["saturation_hdr", "texture"], ["mixer_hdr", "texture"], ["ldr", "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"], + ], edges: [ ["ldr", "texture"], ["edge_hdr", "texture"], @@ -143,6 +150,7 @@ test("presets have the exact canonical pipeline identities, schemas, and node se "preset_hdr_fullscreen", "preset_gpu_culling", "preset_tone", + "preset_grading", "preset_edges", "preset_bloom", "preset_combined", @@ -175,6 +183,16 @@ test("presets have the exact canonical pipeline identities, schemas, and node se ), ); } + const grading = presets.grading; + assert.deepEqual(grading.nodes.find((n) => n.id === "balance").parameters, { + 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], + }); + assert.deepEqual(grading.nodes.find((n) => n.id === "exposure").parameters, { exposureStops: 0, contrast: 1, pivot: .18, factor: 1 }); + assert.deepEqual(grading.nodes.find((n) => n.id === "saturation").parameters, { saturation: 1, factor: 1 }); + assert.deepEqual(grading.nodes.find((n) => n.id === "mixer").parameters, { redOutput: [1,0,0], greenOutput: [0,1,0], blueOutput: [0,0,1], factor: 1 }); }); test("presets preserve common mesh, texture, query, pipeline, culling and post wiring", () => { @@ -264,12 +282,13 @@ test("presets preserve common mesh, texture, query, pipeline, culling and post w node: "cull", socket: "isFrustumCulled", }); - for (const name of ["tone", "edges", "bloom", "combined"]) + for (const name of ["tone", "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",