//! Colour operations: vibrance, saturation, and brilliance. //! //! # Vibrance versus saturation //! //! Saturation scales every colour's distance from grey equally. Vibrance //! scales it *more for muted colours than for already-saturated ones*, and //! protects skin tones. The difference matters: pushing saturation on a //! portrait turns faces orange long before the background improves, which is //! precisely the problem vibrance was invented to solve. //! //! # Brilliance //! //! Apple's control, and a genuinely different idea from either: it lifts //! shadows and pulls highlights *simultaneously*, applying the opposite //! correction at each end of the range while leaving mid-tones alone. The //! result reads as "more light in the scene" rather than "less contrast", //! because local relationships survive where a plain contrast reduction //! flattens them. //! //! It overlaps with highlights/shadows deliberately — one control doing both //! in a fixed relationship is easier to reach for than two controls needing //! to be balanced against each other. use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId}; use crate::operation::{Helper, Operation, Uniform}; use crate::ops::tone::TONE_HELPERS; // --------------------------------------------------------------------------- // Saturation // --------------------------------------------------------------------------- pub const SATURATION_ID: OpId = OpId("saturation"); pub const SATURATION: ParamId = ParamId("saturation"); static SAT_DESCRIPTOR: OpDescriptor = OpDescriptor { id: SATURATION_ID, label: LocalizedKey("op.saturation"), params: &[ParamDescriptor::amount("saturation", "param.saturation")], }; #[derive(Debug, Default, Clone)] pub struct Saturation { amount: f32, } impl Saturation { pub fn new() -> Self { Self::default() } } impl Operation for Saturation { fn descriptor(&self) -> &'static OpDescriptor { &SAT_DESCRIPTOR } fn set_param(&mut self, id: ParamId, value: f32) { match id { SATURATION => self.amount = value, _ => log::warn!("saturation: unknown parameter {id}"), } } fn param(&self, id: ParamId) -> f32 { match id { SATURATION => self.amount, _ => 0.0, } } fn is_active(&self) -> bool { self.amount != 0.0 } fn wgsl_body(&self) -> String { "\ // Interpolate away from the luminance-preserving grey. A factor of 0 is // monochrome, 1 is unchanged, above 1 is more saturated. let luma = luminance(c); c = mix(vec3(luma), c, factor); c = max(c, vec3(0.0));" .into() } fn uniforms(&self) -> Vec { // -100 reaches exactly monochrome; +100 doubles the distance from // grey. The floor at zero matters: a negative factor would push a // colour past grey into its complement, inverting hues. vec![Uniform { name: "factor", value: (1.0 + self.amount / 100.0).max(0.0), }] } fn helpers(&self) -> &'static [Helper] { TONE_HELPERS } } // --------------------------------------------------------------------------- // Vibrance // --------------------------------------------------------------------------- pub const VIBRANCE_ID: OpId = OpId("vibrance"); pub const VIBRANCE: ParamId = ParamId("vibrance"); static VIB_DESCRIPTOR: OpDescriptor = OpDescriptor { id: VIBRANCE_ID, label: LocalizedKey("op.vibrance"), params: &[ParamDescriptor::amount("vibrance", "param.vibrance")], }; #[derive(Debug, Default, Clone)] pub struct Vibrance { amount: f32, } impl Vibrance { pub fn new() -> Self { Self::default() } } impl Operation for Vibrance { fn descriptor(&self) -> &'static OpDescriptor { &VIB_DESCRIPTOR } fn set_param(&mut self, id: ParamId, value: f32) { match id { VIBRANCE => self.amount = value, _ => log::warn!("vibrance: unknown parameter {id}"), } } fn param(&self, id: ParamId) -> f32 { match id { VIBRANCE => self.amount, _ => 0.0, } } fn is_active(&self) -> bool { self.amount != 0.0 } fn wgsl_body(&self) -> String { "\ let luma = luminance(c); let sat = colour_saturation(c); // The vibrance curve: full effect on grey, tapering to nothing on colours // that are already saturated. Squaring the falloff keeps the mid-range // responsive while still protecting the extremes. let falloff = (1.0 - sat) * (1.0 - sat); // Skin protection. Skin sits in a narrow band of hue where red leads green // leads blue; pushing it is what makes vibrance look wrong on portraits. // Detected by channel ordering rather than a hue angle, which costs a // conversion and buys nothing here. let is_skin = f32(c.r > c.g && c.g > c.b); let skin_guard = 1.0 - is_skin * 0.5; let strength = amount * falloff * skin_guard; c = mix(vec3(luma), c, 1.0 + strength); c = max(c, vec3(0.0));" .into() } fn uniforms(&self) -> Vec { vec![Uniform { name: "amount", value: self.amount / 100.0, }] } fn helpers(&self) -> &'static [Helper] { // Needs both luminance (from tone) and the saturation measure. // Duplicates across the two lists are deduplicated by the composer. COLOUR_AND_TONE } } /// The helper set vibrance needs: luminance plus the saturation measure. static COLOUR_AND_TONE: &[Helper] = crate::ops::helpers::COLOUR; // --------------------------------------------------------------------------- // Brilliance // --------------------------------------------------------------------------- pub const BRILLIANCE_ID: OpId = OpId("brilliance"); pub const BRILLIANCE: ParamId = ParamId("brilliance"); static BRIL_DESCRIPTOR: OpDescriptor = OpDescriptor { id: BRILLIANCE_ID, label: LocalizedKey("op.brilliance"), params: &[ParamDescriptor::amount("brilliance", "param.brilliance")], }; #[derive(Debug, Default, Clone)] pub struct Brilliance { amount: f32, } impl Brilliance { pub fn new() -> Self { Self::default() } } impl Operation for Brilliance { fn descriptor(&self) -> &'static OpDescriptor { &BRIL_DESCRIPTOR } fn set_param(&mut self, id: ParamId, value: f32) { match id { BRILLIANCE => self.amount = value, _ => log::warn!("brilliance: unknown parameter {id}"), } } fn param(&self, id: ParamId) -> f32 { match id { BRILLIANCE => self.amount, _ => 0.0, } } fn is_active(&self) -> bool { self.amount != 0.0 } fn wgsl_body(&self) -> String { "\ let luma = luminance(c); let pos = tone_position(luma); // Opposite corrections at the two ends: shadows up, highlights down, both // tapering to nothing at the mid-point. This is what separates brilliance // from a contrast control — mid-tones keep their local relationships, so // the image gains apparent light rather than losing structure. let lift = (1.0 - smoothstep(0.0, 0.5, pos)) * amount; let pull = smoothstep(0.5, 1.0, pos) * amount; // A mild saturation compensation. Flattening the tonal range washes colour // out; without this, brilliance looks faded at useful settings. let gain = exp2(lift - pull); c = c * gain; let luma_after = luminance(c); c = mix(vec3(luma_after), c, 1.0 + max(amount, 0.0) * 0.15); c = max(c, vec3(0.0));" .into() } fn uniforms(&self) -> Vec { vec![Uniform { name: "amount", // Half a stop at each end at full travel — the two ends move // apart by a stop in total, which is a strong but not // destructive flattening. value: self.amount / 100.0 * 0.5, }] } fn helpers(&self) -> &'static [Helper] { TONE_HELPERS } } #[cfg(test)] mod tests { use super::*; use crate::operation::compose; #[test] fn all_three_start_neutral() { assert!(!Saturation::new().is_active()); assert!(!Vibrance::new().is_active()); assert!(!Brilliance::new().is_active()); } #[test] fn full_negative_saturation_reaches_monochrome() { // The property that makes -100 meaningful: it must land exactly on // grey, not merely near it. let mut s = Saturation::new(); s.set_param(SATURATION, -100.0); assert_eq!(s.uniforms()[0].value, 0.0); } #[test] fn positive_saturation_increases_the_factor() { let mut s = Saturation::new(); s.set_param(SATURATION, 100.0); assert!((s.uniforms()[0].value - 2.0).abs() < 1e-6); } #[test] fn the_saturation_factor_never_goes_negative() { // A negative factor would invert hues — a colour past monochrome // becomes its complement, which is never wanted here. let mut s = Saturation::new(); s.set_param(SATURATION, -200.0); assert!(s.uniforms()[0].value >= 0.0); } #[test] fn vibrance_protects_skin_in_its_fragment() { // The distinguishing behaviour; if the guard is dropped, portraits // go orange and the control is indistinguishable from saturation. let mut v = Vibrance::new(); v.set_param(VIBRANCE, 50.0); let body = v.wgsl_body(); assert!(body.contains("skin_guard"), "skin protection must survive"); assert!( body.contains("falloff"), "the roll-off is what makes it vibrance" ); } #[test] fn vibrance_and_saturation_are_distinct_operations() { // They must not share an id, or the composer would emit one and the // UI would show one control for two behaviours. assert_ne!(VIBRANCE_ID, SATURATION_ID); } #[test] fn brilliance_moves_the_ends_in_opposite_directions() { let mut b = Brilliance::new(); b.set_param(BRILLIANCE, 100.0); let body = b.wgsl_body(); assert!(body.contains("lift"), "shadows must rise"); assert!(body.contains("pull"), "highlights must fall"); assert!( body.contains("lift - pull"), "the two must oppose, or this is just an exposure control" ); } #[test] fn brilliance_travel_is_bounded() { let mut b = Brilliance::new(); b.set_param(BRILLIANCE, 100.0); let v = b.uniforms()[0].value; assert!((0.0..=0.6).contains(&v), "amount {v} is too aggressive"); } #[test] fn helpers_are_shared_across_every_colour_operation() { // Vibrance declares its own helper list; if its luminance source // drifted from tone's, the composer would emit whichever came first // and the two operations would disagree about luminance. let ops: Vec> = vec![ Box::new({ let mut o = Vibrance::new(); o.set_param(VIBRANCE, 40.0); o }), Box::new({ let mut o = Saturation::new(); o.set_param(SATURATION, 20.0); o }), Box::new({ let mut o = Brilliance::new(); o.set_param(BRILLIANCE, 30.0); o }), ]; let shader = compose(&ops); assert_eq!( shader.source.matches("fn luminance(").count(), 1, "luminance must be declared exactly once" ); assert_eq!(shader.source.matches("fn colour_saturation(").count(), 1); } #[test] fn tone_and_colour_agree_on_luminance() { // Both sets reference the shared definition. If someone reintroduces // a local copy, the composer would emit whichever operation came // first and the two would compute luminance differently. let from_tone = TONE_HELPERS .iter() .find(|h| h.name == "luminance") .expect("tone declares luminance"); let from_colour = COLOUR_AND_TONE .iter() .find(|h| h.name == "luminance") .expect("colour declares luminance"); assert_eq!(from_tone.source, from_colour.source); } }