Files
DarkRoom/core/dr-pipeline/src/ops/colour_mixer.rs
T
dtourolleandClaude Opus 5 ca833b6d2b
🐳 Android image / Build and push (push) Successful in 5s
Build and test / android-image (push) Successful in 5s
Build and test / Desktop (Linux) (push) Failing after 57m33s
Build and test / Layer separation (push) Successful in 35s
Traceability / Requirement traces (push) Failing after 35s
Build and test / Android (aarch64) (push) Failing after 9m42s
Give every colour band its own compiled shader
The colour mixer emits a code block and a uniform only for the bands that
are set, so which bands are adjusted is part of the shader's structure. The
pipeline cache key was not: it hashed the set of *active operations*, which
is "colour_mixer" whichever band that is.

So a red adjustment and a blue one hashed alike. The second render was handed
the first's compiled pipeline while its uniform was uploaded into a slot that
shader had assigned to another band — whichever band compiled first kept
acting on every subsequent move, and every other slider did nothing at all.
Red is the first band declared, and the one reported as the only one working.

The hash is now taken over the generated WGSL, because the source is what
gets compiled and therefore is the structure. A summary of what went into it
has to be kept in step with every operation's code generation by hand, and
this one had fallen out of step. Values still do not enter it: no operation
writes a parameter value into its source, so a slider drag regenerates
identical text and reuses the pipeline, and one that did inline a value would
have to recompile to be correct anyway.

`each_colour_band_gets_its_own_pipeline` in dr-gpu renders a blue pixel
through one pass with red set first and then blue, and fails on the old hash
with the reported symptom — the blue slider returning the pixel unchanged to
the byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:23:04 +02:00

825 lines
28 KiB
Rust

//! The colour mixer — twelve hue bands, each with hue, saturation and
//! luminance.
//!
//! The control photographers mean by "per-colour adjustment": pick a colour
//! range, then shift its hue, deepen or mute it, or lighten it, without
//! touching the rest of the image. Thirty-six parameters in one operation.
//!
//! # Why bands overlap
//!
//! Each band has a centre hue and influences colours near it with a weight
//! that falls smoothly to zero at its neighbours' centres. A hard assignment
//! — "this pixel is orange, that one is yellow" — puts a visible seam through
//! any gradient crossing a boundary, and skies and skin are exactly where
//! that shows. Overlapping weights mean adjacent bands blend, and a colour
//! halfway between two centres receives half of each.
//!
//! # Why the weights sum to one
//!
//! The falloff window is exactly the band spacing, so at any hue the twelve
//! weights sum to one no matter where that hue falls — a partition of unity.
//! That is what lets each band's gain be applied and added with no further
//! scaling: setting every band's saturation to +100 gives the same result as
//! setting the global saturation to +100 rather than something far stronger,
//! and a band pushed on its own reaches its full documented travel.
//!
//! Dividing by the weight of the *adjusted* bands instead — which is what
//! this used to do — breaks both halves of that. A single adjusted band
//! divides by its own weight and cancels it, so the falloff disappears and
//! the band acts at full strength right up to a hard edge; and with two bands
//! adjusted, each one's share depends on what the other is set to, so turning
//! up one colour's saturation quietly weakened its neighbour's hue shift.
use crate::descriptor::{Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Helper, Operation, Uniform};
use crate::ops::helpers;
pub const ID: OpId = OpId("colour_mixer");
/// The twelve bands, in hue order starting at red.
///
/// Twelve rather than Lightroom's eight: the extra bands fall between the
/// primaries and secondaries, which is where skin (orange-to-red) and
/// foliage (yellow-to-green) actually sit, and where eight bands force a
/// compromise.
pub struct Band {
/// Stable id fragment, used to build parameter ids.
pub key: &'static str,
/// Centre hue in degrees.
pub hue: f32,
}
pub static BANDS: [Band; 12] = [
Band {
key: "red",
hue: 0.0,
},
Band {
key: "orange",
hue: 30.0,
},
Band {
key: "yellow",
hue: 60.0,
},
Band {
key: "chartreuse",
hue: 90.0,
},
Band {
key: "green",
hue: 120.0,
},
Band {
key: "spring",
hue: 150.0,
},
Band {
key: "cyan",
hue: 180.0,
},
Band {
key: "azure",
hue: 210.0,
},
Band {
key: "blue",
hue: 240.0,
},
Band {
key: "violet",
hue: 270.0,
},
Band {
key: "magenta",
hue: 300.0,
},
Band {
key: "rose",
hue: 330.0,
},
];
/// The three adjustments each band carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Channel {
Hue,
Saturation,
Luminance,
}
impl Channel {
pub const ALL: [Channel; 3] = [Channel::Hue, Channel::Saturation, Channel::Luminance];
pub const fn suffix(self) -> &'static str {
match self {
Channel::Hue => "hue",
Channel::Saturation => "sat",
Channel::Luminance => "lum",
}
}
}
// Parameter descriptors, one per band per channel. Written out rather than
// generated because `ParamDescriptor` must be `const` to live in a `static`,
// and a const loop cannot build a slice. The macro keeps it honest.
//
// **Every one of them is faceted**, and that is what makes the operation
// legible in a panel. Thirty-six parameters presented as a flat list are
// thirty-six sliders reading "Hue / Sat / Lum" twelve times with nothing
// saying which band any row belongs to — the identity is right here in the
// descriptor and used to be discarded on the way out. The facet carries it:
// the channel as the aspect, the band as the subject, and the band's centre
// hue so a frontend can identify the row by the colour it edits rather than
// by a word. What a frontend *does* with 30° is its own business (ARCH
// §4.3a); this only says the parameter acts on the band centred there.
macro_rules! band_params {
($(($key:literal, $hue:literal)),* $(,)?) => {
&[
$(
ParamDescriptor::amount(
concat!($key, "_hue"),
concat!("param.mixer.", $key, ".hue"),
)
.faceted(Facet {
aspect: LocalizedKey("param.channel.hue"),
subject: LocalizedKey(concat!("band.", $key)),
subject_hue: Some($hue),
}),
ParamDescriptor::amount(
concat!($key, "_sat"),
concat!("param.mixer.", $key, ".sat"),
)
.faceted(Facet {
aspect: LocalizedKey("param.channel.sat"),
subject: LocalizedKey(concat!("band.", $key)),
subject_hue: Some($hue),
}),
ParamDescriptor::amount(
concat!($key, "_lum"),
concat!("param.mixer.", $key, ".lum"),
)
.faceted(Facet {
aspect: LocalizedKey("param.channel.lum"),
subject: LocalizedKey(concat!("band.", $key)),
subject_hue: Some($hue),
}),
)*
]
};
}
// A fourth table parallel to `BANDS` and the three uniform-name tables, for
// the same reason as those: `concat!` needs literals, so the keys and hues
// cannot be read out of `BANDS` here. `facets_match_their_bands` below is
// what keeps them from drifting.
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.colour_mixer"),
params: band_params![
("red", 0.0),
("orange", 30.0),
("yellow", 60.0),
("chartreuse", 90.0),
("green", 120.0),
("spring", 150.0),
("cyan", 180.0),
("azure", 210.0),
("blue", 240.0),
("violet", 270.0),
("magenta", 300.0),
("rose", 330.0),
],
};
static MIXER_HELPERS: &[Helper] = &[
helpers::LUMINANCE,
Helper {
name: "rgb_to_hcl",
source: "\
// Hue (degrees), chroma, and the max channel, in one pass.
//
// Not a full HSL conversion: the mixer needs hue to weight the bands and
// chroma to know how much colour there is to adjust, and computing lightness
// separately from Rec. 709 luminance gives a better-behaved result than
// HSL's (max+min)/2.
fn rgb_to_hcl(c: vec3<f32>) -> vec3<f32> {
let hi = max(c.r, max(c.g, c.b));
let lo = min(c.r, min(c.g, c.b));
let chroma = hi - lo;
var hue = 0.0;
if (chroma > 0.00001) {
if (hi == c.r) {
// The only branch that can come out negative, hence the wrap.
hue = 60.0 * (((c.g - c.b) / chroma) % 6.0);
if (hue < 0.0) { hue = hue + 360.0; }
} else if (hi == c.g) {
hue = 60.0 * (((c.b - c.r) / chroma) + 2.0);
} else {
hue = 60.0 * (((c.r - c.g) / chroma) + 4.0);
}
}
return vec3<f32>(hue, chroma, hi);
}",
},
Helper {
name: "band_weight",
source: "\
// How strongly a hue belongs to a band centred at `centre`.
//
// Cosine falloff over +/-30 degrees — the band spacing — so a band reaches
// zero exactly at its neighbours' centres and every hue's twelve weights sum
// to one. A narrower window would leave hues between bands weakly covered; a
// wider one makes the weights sum to more than one, and then no adjustment
// can be applied without scaling it by something that depends on which
// *other* bands are set.
fn band_weight(hue: f32, centre: f32) -> f32 {
// Shortest angular distance, accounting for the wrap at 360.
var d = abs(hue - centre);
if (d > 180.0) { d = 360.0 - d; }
if (d >= 30.0) { return 0.0; }
// cos ramp: 1 at the centre, 0 at 30 degrees.
return 0.5 + 0.5 * cos(d * 3.14159265 / 30.0);
}",
},
Helper {
name: "hue_to_rgb_scale",
source: "\
// Rebuild a colour after shifting its hue, preserving chroma and level.
//
// Reconstructing from HSV rather than rotating in RGB: an RGB rotation
// matrix desaturates as it turns, which is visible as colours going pale
// mid-shift.
fn hue_to_rgb_scale(hue: f32, chroma: f32, hi: f32) -> vec3<f32> {
let h = fract(hue / 360.0) * 6.0;
let x = chroma * (1.0 - abs((h % 2.0) - 1.0));
var rgb = vec3<f32>(0.0);
if (h < 1.0) { rgb = vec3<f32>(chroma, x, 0.0); }
else if (h < 2.0) { rgb = vec3<f32>(x, chroma, 0.0); }
else if (h < 3.0) { rgb = vec3<f32>(0.0, chroma, x); }
else if (h < 4.0) { rgb = vec3<f32>(0.0, x, chroma); }
else if (h < 5.0) { rgb = vec3<f32>(x, 0.0, chroma); }
else { rgb = vec3<f32>(chroma, 0.0, x); }
return rgb + vec3<f32>(hi - chroma);
}",
},
];
/// Twelve hue bands, each with hue, saturation and luminance.
#[derive(Debug, Clone)]
pub struct ColourMixer {
/// `[band][channel]`, matching [`BANDS`] and [`Channel::ALL`].
values: [[f32; 3]; 12],
}
impl Default for ColourMixer {
fn default() -> Self {
Self {
values: [[0.0; 3]; 12],
}
}
}
impl ColourMixer {
pub fn new() -> Self {
Self::default()
}
/// The parameter id for one band and channel.
///
/// Ids are `"<band>_<channel>"`, matching the descriptors above.
fn index_of(id: ParamId) -> Option<(usize, usize)> {
let (band, channel) = id.0.rsplit_once('_')?;
let b = BANDS.iter().position(|x| x.key == band)?;
let c = Channel::ALL.iter().position(|x| x.suffix() == channel)?;
Some((b, c))
}
/// Whether any band has a non-zero setting.
fn any_set(&self) -> bool {
self.values.iter().flatten().any(|v| *v != 0.0)
}
}
impl Operation for ColourMixer {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match Self::index_of(id) {
Some((b, c)) => self.values[b][c] = value,
None => log::warn!("colour_mixer: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
Self::index_of(id).map_or(0.0, |(b, c)| self.values[b][c])
}
fn is_active(&self) -> bool {
self.any_set()
}
fn wgsl_body(&self) -> String {
// Only the bands the user actually touched contribute code. A single
// adjusted band therefore costs one weight evaluation rather than
// twelve — the composition property applied within an operation.
let mut lines = String::from(
"\
let hcl = rgb_to_hcl(c);
let hue = hcl.x;
let chroma = hcl.y;
let hi = hcl.z;
// Achromatic pixels have no hue to match, and adjusting them would tint
// neutrals — the most visible way a mixer can go wrong.
if (chroma > 0.0001) {
var w_total = 0.0;
var d_hue = 0.0;
var d_sat = 0.0;
var d_lum = 0.0;
",
);
for (b, band) in BANDS.iter().enumerate() {
let v = self.values[b];
if v.iter().all(|x| *x == 0.0) {
continue;
}
let key = band.key;
lines.push_str(&format!(
"\n // {key}\n {{\n let w = band_weight(hue, {:.1});\n w_total = w_total + w;\n",
band.hue
));
if v[0] != 0.0 {
lines.push_str(&format!(" d_hue = d_hue + w * {key}_hue;\n"));
}
if v[1] != 0.0 {
lines.push_str(&format!(" d_sat = d_sat + w * {key}_sat;\n"));
}
if v[2] != 0.0 {
lines.push_str(&format!(" d_lum = d_lum + w * {key}_lum;\n"));
}
lines.push_str(" }\n");
}
lines.push_str(
"
// The deltas are used as accumulated: the twelve band weights sum to one
// at every hue, so a weighted sum over the adjusted bands is already on
// the right scale, and each band contributes independently of the others.
// `w_total` only says whether any adjusted band reaches this pixel —
// dividing by it would cancel the falloff and couple the bands together.
if (w_total > 0.0001) {
// Hue: up to 30 degrees at full travel. Enough to move foliage from
// yellow-green to green, not enough to turn it blue by accident.
let new_hue = hue + d_hue * 30.0;
// Saturation scales chroma; luminance scales the whole colour.
let new_chroma = clamp(chroma * (1.0 + d_sat), 0.0, hi);
c = hue_to_rgb_scale(new_hue, new_chroma, hi);
c = c * exp2(d_lum);
}
}
c = max(c, vec3<f32>(0.0));",
);
lines
}
fn uniforms(&self) -> Vec<Uniform> {
// Only the bands that contributed code declare uniforms, and in the
// same order the fragment references them.
let mut out = Vec::new();
for b in 0..BANDS.len() {
let v = self.values[b];
if v.iter().all(|x| *x == 0.0) {
continue;
}
// Names must match those the fragment emitted.
if v[0] != 0.0 {
out.push(Uniform {
name: HUE_NAMES[b],
value: v[0] / 100.0,
});
}
if v[1] != 0.0 {
out.push(Uniform {
name: SAT_NAMES[b],
value: v[1] / 100.0,
});
}
if v[2] != 0.0 {
out.push(Uniform {
name: LUM_NAMES[b],
// Up to half a stop per band.
value: v[2] / 100.0 * 0.5,
});
}
}
out
}
fn helpers(&self) -> &'static [Helper] {
MIXER_HELPERS
}
}
// Uniform names must be `&'static str`, and they are built from the band
// keys. Declared as tables rather than formatted at runtime, so the fragment
// and the uniform list cannot disagree.
static HUE_NAMES: [&str; 12] = [
"red_hue",
"orange_hue",
"yellow_hue",
"chartreuse_hue",
"green_hue",
"spring_hue",
"cyan_hue",
"azure_hue",
"blue_hue",
"violet_hue",
"magenta_hue",
"rose_hue",
];
static SAT_NAMES: [&str; 12] = [
"red_sat",
"orange_sat",
"yellow_sat",
"chartreuse_sat",
"green_sat",
"spring_sat",
"cyan_sat",
"azure_sat",
"blue_sat",
"violet_sat",
"magenta_sat",
"rose_sat",
];
static LUM_NAMES: [&str; 12] = [
"red_lum",
"orange_lum",
"yellow_lum",
"chartreuse_lum",
"green_lum",
"spring_lum",
"cyan_lum",
"azure_lum",
"blue_lum",
"violet_lum",
"magenta_lum",
"rose_lum",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn there_are_twelve_bands_with_thirty_six_parameters() {
assert_eq!(BANDS.len(), 12);
assert_eq!(DESCRIPTOR.params.len(), 36);
}
#[test]
fn bands_are_evenly_spaced_around_the_wheel() {
// Uneven spacing would leave some hues weakly covered, since the
// weight window is a fixed 60 degrees.
for (i, band) in BANDS.iter().enumerate() {
assert!(
(band.hue - i as f32 * 30.0).abs() < 1e-6,
"{} is at {}, expected {}",
band.key,
band.hue,
i as f32 * 30.0
);
}
}
#[test]
fn every_descriptor_id_resolves_to_a_band_and_channel() {
// The link between the descriptor list and the value array. A
// mismatch would make a slider silently adjust nothing.
for p in DESCRIPTOR.params {
assert!(
ColourMixer::index_of(p.id).is_some(),
"{} does not map to a band",
p.id
);
}
}
#[test]
fn every_band_and_channel_has_a_descriptor() {
// The reverse direction: a band with no descriptor is unreachable
// from the UI.
for band in BANDS.iter() {
for ch in Channel::ALL {
let id = format!("{}_{}", band.key, ch.suffix());
assert!(
DESCRIPTOR.params.iter().any(|p| p.id.0 == id),
"{id} has no descriptor"
);
}
}
}
#[test]
fn the_uniform_name_tables_match_the_band_keys() {
// Three parallel tables and a band list; if they drift, the fragment
// references a uniform that was never declared and the shader fails
// to compile.
for (i, band) in BANDS.iter().enumerate() {
assert_eq!(HUE_NAMES[i], format!("{}_hue", band.key));
assert_eq!(SAT_NAMES[i], format!("{}_sat", band.key));
assert_eq!(LUM_NAMES[i], format!("{}_lum", band.key));
}
}
#[test]
fn facets_match_their_bands() {
// The macro's `(key, hue)` list is a fourth table parallel to `BANDS`,
// and a hue mistyped there would put a row's swatch on a colour the
// band does not act on — a control that lies about what it edits,
// which is worse than one with no swatch at all.
for p in DESCRIPTOR.params {
let facet = p.facet.expect("every mixer parameter is faceted");
let (band_key, _) = p.id.0.rsplit_once('_').expect("id is band_channel");
let band = BANDS
.iter()
.find(|b| b.key == band_key)
.expect("the id names a band");
assert_eq!(
facet.subject.0,
format!("band.{band_key}"),
"{} is subject to the wrong band",
p.id
);
assert_eq!(
facet.subject_hue,
Some(band.hue),
"{} claims a hue its band does not have",
p.id
);
}
}
#[test]
fn each_channel_is_one_aspect_across_every_band() {
// What lets a panel name the run once instead of twelve times: the
// twelve hue parameters must agree they are the same control. Were
// the aspect keyed per band, grouping by it would produce thirty-six
// groups of one and nothing would have been gained.
let mut per_aspect = std::collections::BTreeMap::new();
for p in DESCRIPTOR.params {
let facet = p.facet.expect("faceted");
*per_aspect.entry(facet.aspect.0).or_insert(0) += 1;
}
assert_eq!(per_aspect.len(), Channel::ALL.len());
for (aspect, count) in per_aspect {
assert_eq!(count, BANDS.len(), "{aspect} does not cover every band");
}
}
#[test]
fn a_fresh_mixer_is_inactive() {
assert!(!ColourMixer::new().is_active());
}
#[test]
fn setting_any_band_activates_it() {
let mut m = ColourMixer::new();
m.set_param(ParamId("blue_sat"), 40.0);
assert!(m.is_active());
assert_eq!(m.param(ParamId("blue_sat")), 40.0);
}
#[test]
fn only_adjusted_bands_reach_the_shader() {
// The composition property applied within an operation: adjusting
// one band must not cost twelve weight evaluations.
let mut m = ColourMixer::new();
m.set_param(ParamId("blue_sat"), 40.0);
let body = m.wgsl_body();
assert!(body.contains("blue_sat"), "the adjusted band must appear");
assert!(!body.contains("red_sat"), "untouched bands must not");
assert_eq!(
body.matches("band_weight(").count(),
1,
"one adjusted band means one weight evaluation"
);
}
#[test]
fn only_adjusted_channels_within_a_band_reach_the_shader() {
let mut m = ColourMixer::new();
m.set_param(ParamId("green_lum"), -25.0);
let body = m.wgsl_body();
assert!(body.contains("green_lum"));
assert!(!body.contains("green_hue"));
assert!(!body.contains("green_sat"));
}
#[test]
fn the_fragment_and_uniforms_agree_on_names() {
// The failure this prevents is a compile error in generated code,
// which is far harder to read than a failed assertion here.
let mut m = ColourMixer::new();
m.set_param(ParamId("orange_hue"), 20.0);
m.set_param(ParamId("orange_sat"), -30.0);
m.set_param(ParamId("azure_lum"), 15.0);
let body = m.wgsl_body();
for u in m.uniforms() {
assert!(
body.contains(u.name),
"uniform {} is declared but never used",
u.name
);
}
// And nothing referenced without being declared.
let declared: Vec<&str> = m.uniforms().iter().map(|u| u.name).collect();
for band in BANDS.iter() {
for ch in Channel::ALL {
let name = format!("{}_{}", band.key, ch.suffix());
if body.contains(&name) {
assert!(
declared.contains(&name.as_str()),
"{name} is used but not declared"
);
}
}
}
}
#[test]
fn achromatic_pixels_are_excluded() {
// Adjusting a hue-less pixel would tint neutrals, which is the most
// visible way a mixer misbehaves.
let mut m = ColourMixer::new();
m.set_param(ParamId("red_sat"), 50.0);
assert!(m.wgsl_body().contains("chroma > 0.0001"));
}
#[test]
fn the_deltas_are_not_scaled_by_the_adjusted_bands_weight() {
// The bug this closes: dividing each delta by the summed weight of
// the bands that happen to be adjusted made the channels exclusive.
// One band divided by its own weight, cancelling the falloff; two
// bands split a fixed budget, so raising one colour's saturation cut
// its neighbour's hue shift. `band_weights_sum_to_one` is why no
// scaling is needed at all.
let mut m = ColourMixer::new();
m.set_param(ParamId("red_hue"), 50.0);
m.set_param(ParamId("orange_sat"), 50.0);
let body = m.wgsl_body();
for delta in ["d_hue", "d_sat", "d_lum"] {
assert!(
!body.contains(&format!("{delta} / w_total")),
"{delta} is scaled by the adjusted bands' weight"
);
}
// Still guarded, so a pixel no adjusted band reaches is left alone.
assert!(body.contains("w_total > 0.0001"));
}
#[test]
fn two_bands_are_two_shaders() {
// The bug this closes: the shader cache was keyed on the set of
// active operations, and this operation is active whichever band is
// set. A red adjustment and a blue one therefore shared a compiled
// pipeline — the first one to compile — and the second was rendered
// with the first's code while its uniform was uploaded into the
// first's slot. Every band but the one compiled first appeared to do
// nothing, and moving its slider moved the other band's colour.
//
// Asserted here rather than only in `operation.rs` because this is
// the operation that generates per-value code, and so the one whose
// shaders must not collide.
let compose_with = |id, v| {
let mut m = ColourMixer::new();
m.set_param(ParamId(id), v);
let ops: Vec<Box<dyn Operation>> = vec![Box::new(m)];
crate::operation::compose(&ops)
};
let red = compose_with("red_sat", 60.0);
let blue = compose_with("blue_sat", 60.0);
assert_ne!(
red.source, blue.source,
"two bands emit different code, which is the premise"
);
assert_ne!(
red.structure_hash, blue.structure_hash,
"two bands must not share a compiled pipeline"
);
// The same band at a different setting is the same shader, which is
// what keeps a slider drag from recompiling once per frame.
let red_harder = compose_with("red_sat", 90.0);
assert_eq!(red.structure_hash, red_harder.structure_hash);
assert_ne!(red.uniforms, red_harder.uniforms);
}
#[test]
fn every_band_and_channel_is_its_own_shader() {
// All thirty-six, because the collision above was not special to red
// and blue: any two settings that generate different code and hash
// alike put one of them on the other's pipeline.
let mut seen = std::collections::HashMap::new();
for band in BANDS.iter() {
for ch in Channel::ALL {
let id = format!("{}_{}", band.key, ch.suffix());
let mut m = ColourMixer::new();
m.set_param(ParamId(Box::leak(id.clone().into_boxed_str())), 50.0);
let ops: Vec<Box<dyn Operation>> = vec![Box::new(m)];
let hash = crate::operation::compose(&ops).structure_hash;
if let Some(other) = seen.insert(hash, id.clone()) {
panic!("{id} and {other} share a pipeline");
}
}
}
}
#[test]
fn band_weights_sum_to_one_at_every_hue() {
// The property the shader relies on to add band deltas unscaled, and
// the one that ties the falloff window to the band spacing: widen or
// narrow `band_weight`'s window without moving the centres and this
// fails, which is the point — the sum would no longer be one and
// every adjustment would come out over- or under-strength.
//
// A mirror of the WGSL helper. It is nine lines, and the alternative
// is asserting nothing about the arithmetic that matters most here.
fn band_weight(hue: f32, centre: f32) -> f32 {
let mut d = (hue - centre).abs();
if d > 180.0 {
d = 360.0 - d;
}
if d >= 30.0 {
return 0.0;
}
0.5 + 0.5 * (d * std::f32::consts::PI / 30.0).cos()
}
for step in 0..3600 {
let hue = step as f32 / 10.0;
let sum: f32 = BANDS.iter().map(|b| band_weight(hue, b.hue)).sum();
assert!(
(sum - 1.0).abs() < 1e-5,
"weights at {hue} degrees sum to {sum}"
);
}
}
#[test]
fn the_falloff_window_is_the_band_spacing() {
// The Rust mirror above only proves the sum for the window it copies;
// this is what keeps the copy honest about the shader's own numbers.
let src = MIXER_HELPERS
.iter()
.find(|h| h.name == "band_weight")
.expect("the helper exists")
.source;
assert!(src.contains("d >= 30.0"), "the window is not +/-30 degrees");
assert!(
src.contains("/ 30.0"),
"the cos ramp is not over 30 degrees"
);
}
#[test]
fn a_bands_channels_are_independent_of_its_neighbours() {
// Two adjacent bands, one adjusted for hue and one for saturation.
// Each must emit its own weighted term and nothing that mixes them.
let mut m = ColourMixer::new();
m.set_param(ParamId("red_hue"), 50.0);
m.set_param(ParamId("orange_sat"), 50.0);
let body = m.wgsl_body();
assert!(body.contains("d_hue = d_hue + w * red_hue"));
assert!(body.contains("d_sat = d_sat + w * orange_sat"));
assert!(!body.contains("red_sat"), "red's saturation is untouched");
assert!(!body.contains("orange_hue"), "orange's hue is untouched");
}
#[test]
fn unknown_parameters_are_ignored() {
let mut m = ColourMixer::new();
m.set_param(ParamId("puce_sat"), 50.0);
m.set_param(ParamId("malformed"), 50.0);
assert!(!m.is_active());
}
#[test]
fn luminance_travel_is_bounded_to_half_a_stop() {
let mut m = ColourMixer::new();
m.set_param(ParamId("blue_lum"), 100.0);
let v = m.uniforms()[0].value;
assert!((v - 0.5).abs() < 1e-6, "got {v}");
}
}