feat: add color grading graph nodes

Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-28 14:18:04 +00:00
co-authored by heaust
parent 67cf3a6555
commit 972fadc635
14 changed files with 648 additions and 16 deletions
+115
View File
@@ -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<f32, GraphError
}
}
fn components<const N: usize>(
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<NormalizedParameters, GraphError> {
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)?;
+36
View File
@@ -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,
+39
View File
@@ -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 {
+66
View File
@@ -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)
+9 -1
View File
@@ -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)),
@@ -21,6 +21,25 @@ fn linear_to_srgb(x: vec3<f32>) -> vec3<f32> {
return select(high, low, x <= vec3(0.0031308));
}
@fragment fn fs_tone_map(in: VertexOut) -> @location(0) vec4<f32> { 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<f32>, graded: vec3<f32>, factor: f32) -> vec4<f32> { return vec4(mix(source.rgb, graded, vec3(factor)), source.a); }
@fragment fn fs_color_balance(in: VertexOut) -> @location(0) vec4<f32> {
let c=sample_source(in.uv); var graded: vec3<f32>;
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<f32> {
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<f32> { 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<f32> { 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<f32> {
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);
}
+252 -2
View File
@@ -38,12 +38,84 @@ fn pack_fullscreen_uniforms(
parameters: &crate::render_graph::NormalizedParameters,
) -> Option<FullscreenUniforms> {
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, &parameters).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 {