Skip to main content

ashlar_motion/
color.rs

1//! Colour on linear floats: OKLab and OKLCH, premultiplied mixing, sRGB bytes and WCAG contrast.
2//!
3//! [`Rgba`] is linear sRGB with straight (unmultiplied) alpha. OKLab is the perceptual space of
4//! Björn Ottosson (2020): equal steps look about equally different, and a mix of two colours does
5//! not pass through grey or shift hue the way an sRGB mix does.
6
7use crate::lanes::{Animatable, Lanes};
8
9/// A colour in linear sRGB with straight alpha, channels nominally 0..=1.
10#[derive(Clone, Copy, Debug, PartialEq, Default)]
11pub struct Rgba {
12    /// Red, linear.
13    pub r: f32,
14    /// Green, linear.
15    pub g: f32,
16    /// Blue, linear.
17    pub b: f32,
18    /// Alpha (coverage), not premultiplied.
19    pub a: f32,
20}
21
22impl Rgba {
23    /// Fully transparent black.
24    pub const TRANSPARENT: Rgba = Rgba::new(0.0, 0.0, 0.0, 0.0);
25    /// Opaque black.
26    pub const BLACK: Rgba = Rgba::new(0.0, 0.0, 0.0, 1.0);
27    /// Opaque white.
28    pub const WHITE: Rgba = Rgba::new(1.0, 1.0, 1.0, 1.0);
29
30    /// A colour from linear channels.
31    pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Rgba {
32        Rgba { r, g, b, a }
33    }
34
35    /// From sRGB-encoded bytes (as in `#rrggbbaa`); alpha is linear.
36    pub fn from_srgb8(r: u8, g: u8, b: u8, a: u8) -> Rgba {
37        let c = |v: u8| srgb_to_linear(f32::from(v) / 255.0);
38        Rgba { r: c(r), g: c(g), b: c(b), a: f32::from(a) / 255.0 }
39    }
40
41    /// To sRGB-encoded bytes, rounded; channels are clamped to 0..=1 first.
42    pub fn to_srgb8(self) -> [u8; 4] {
43        let s = self.sanitized();
44        let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
45        [byte(linear_to_srgb(s.r)), byte(linear_to_srgb(s.g)), byte(linear_to_srgb(s.b)), byte(s.a)]
46    }
47
48    /// Non-finite channels replaced by 0 and alpha clamped to 0..=1.
49    pub fn sanitized(self) -> Rgba {
50        let f = |v: f32| if v.is_finite() { v } else { 0.0 };
51        Rgba { r: f(self.r), g: f(self.g), b: f(self.b), a: f(self.a).clamp(0.0, 1.0) }
52    }
53
54    /// Every channel clamped to 0..=1 (NaN to 0).
55    pub fn clamped(self) -> Rgba {
56        let s = self.sanitized();
57        Rgba { r: s.r.clamp(0.0, 1.0), g: s.g.clamp(0.0, 1.0), b: s.b.clamp(0.0, 1.0), a: s.a }
58    }
59
60    /// Whether r, g and b are within 0..=1 (allowing `tolerance` either side).
61    pub fn in_gamut(self, tolerance: f32) -> bool {
62        let ok = |v: f32| v >= -tolerance && v <= 1.0 + tolerance;
63        ok(self.r) && ok(self.g) && ok(self.b)
64    }
65
66    /// The same colour with alpha `a` (clamped to 0..=1).
67    pub fn with_alpha(self, a: f32) -> Rgba {
68        Rgba { a: if a.is_nan() { 0.0 } else { a.clamp(0.0, 1.0) }, ..self }
69    }
70
71    /// WCAG 2 relative luminance (alpha ignored), 0..=1.
72    pub fn luminance(self) -> f32 {
73        let c = self.clamped();
74        0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b
75    }
76}
77
78/// sRGB transfer function: an encoded channel 0..=1 to linear.
79pub fn srgb_to_linear(c: f32) -> f32 {
80    if c.is_nan() {
81        0.0
82    } else if c <= 0.04045 {
83        c / 12.92
84    } else {
85        ((c + 0.055) / 1.055).powf(2.4)
86    }
87}
88
89/// Inverse sRGB transfer function: a linear channel to encoded.
90pub fn linear_to_srgb(c: f32) -> f32 {
91    if c.is_nan() {
92        0.0
93    } else if c <= 0.003_130_8 {
94        c * 12.92
95    } else {
96        1.055 * c.powf(1.0 / 2.4) - 0.055
97    }
98}
99
100/// A colour in OKLab with straight alpha: `l` lightness 0..=1, `a` green–red, `b` blue–yellow.
101#[derive(Clone, Copy, Debug, PartialEq, Default)]
102pub struct Oklab {
103    /// Lightness, 0..=1.
104    pub l: f32,
105    /// Green (negative) to red (positive).
106    pub a: f32,
107    /// Blue (negative) to yellow (positive).
108    pub b: f32,
109    /// Alpha, not premultiplied.
110    pub alpha: f32,
111}
112
113impl Oklab {
114    /// The OKLab form of `c`.
115    pub fn from_rgba(c: Rgba) -> Oklab {
116        let [l, a, b] = to_oklab(c);
117        Oklab { l, a, b, alpha: c.sanitized().a }
118    }
119
120    /// Back to linear RGB (unclamped: may be out of gamut).
121    pub fn to_rgba(self) -> Rgba {
122        from_oklab([self.l, self.a, self.b], self.alpha)
123    }
124}
125
126impl Animatable for Oklab {
127    const LANES: usize = 4;
128    fn to_lanes(self) -> Lanes {
129        [self.l, self.a, self.b, self.alpha]
130    }
131    fn from_lanes([l, a, b, alpha]: Lanes) -> Self {
132        Oklab { l, a, b, alpha }
133    }
134}
135
136/// An `Rgba` animates in premultiplied OKLab, so a fade to transparent does not darken and a
137/// colour change does not pass through grey. Samples are clamped into gamut.
138impl Animatable for Rgba {
139    const LANES: usize = 4;
140    fn to_lanes(self) -> Lanes {
141        let c = self.sanitized();
142        let [l, a, b] = to_oklab(c);
143        [l * c.a, a * c.a, b * c.a, c.a]
144    }
145    fn from_lanes([l, a, b, alpha]: Lanes) -> Self {
146        let alpha = if alpha.is_nan() { 0.0 } else { alpha.clamp(0.0, 1.0) };
147        if alpha <= 1e-6 {
148            return Rgba::TRANSPARENT;
149        }
150        from_oklab([l / alpha, a / alpha, b / alpha], alpha).clamped()
151    }
152}
153
154/// Linear sRGB to OKLab `[l, a, b]` (alpha ignored). Non-finite channels count as 0.
155pub fn to_oklab(c: Rgba) -> [f32; 3] {
156    let c = c.sanitized();
157    let l = 0.412_221_46 * c.r + 0.536_332_55 * c.g + 0.051_445_995 * c.b;
158    let m = 0.211_903_5 * c.r + 0.680_699_5 * c.g + 0.107_396_96 * c.b;
159    let s = 0.088_302_46 * c.r + 0.281_718_85 * c.g + 0.629_978_7 * c.b;
160    let (l, m, s) = (l.cbrt(), m.cbrt(), s.cbrt());
161    [
162        0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s,
163        1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s,
164        0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s,
165    ]
166}
167
168/// OKLab `[l, a, b]` and an alpha to linear sRGB. Unclamped: the result may be out of gamut
169/// (see [`oklch`] for a mapped colour), but it is always finite.
170pub fn from_oklab(lab: [f32; 3], alpha: f32) -> Rgba {
171    let f = |v: f32| if v.is_finite() { v.clamp(-10.0, 10.0) } else { 0.0 };
172    let [ll, a, b] = lab.map(f);
173    let l = ll + 0.396_337_78 * a + 0.215_803_76 * b;
174    let m = ll - 0.105_561_346 * a - 0.063_854_17 * b;
175    let s = ll - 0.089_484_18 * a - 1.291_485_5 * b;
176    let (l, m, s) = (l * l * l, m * m * m, s * s * s);
177    Rgba {
178        r: 4.076_741_7 * l - 3.307_711_6 * m + 0.230_969_94 * s,
179        g: -1.268_438 * l + 2.609_757_4 * m - 0.341_319_38 * s,
180        b: -0.004_196_086_3 * l - 0.703_418_6 * m + 1.707_614_7 * s,
181        a: if alpha.is_nan() { 0.0 } else { alpha.clamp(0.0, 1.0) },
182    }
183}
184
185/// The largest chroma `oklch` accepts; no sRGB colour has more.
186pub const MAX_CHROMA: f32 = 0.4;
187
188/// An opaque colour from OKLCH: lightness 0..=1, chroma 0..=0.4, hue in degrees. Out-of-gamut
189/// requests keep their lightness and hue and lose chroma until they fit. Never NaN.
190pub fn oklch(l: f32, c: f32, h_deg: f32) -> Rgba {
191    let l = if l.is_nan() { 0.0 } else { l.clamp(0.0, 1.0) };
192    let c = if c.is_nan() { 0.0 } else { c.clamp(0.0, MAX_CHROMA) };
193    let h = if h_deg.is_finite() { h_deg.rem_euclid(360.0) } else { 0.0 }.to_radians();
194    let (sin, cos) = h.sin_cos();
195    let at = |c: f32| from_oklab([l, c * cos, c * sin], 1.0);
196    let full = at(c);
197    if full.in_gamut(1e-5) {
198        return full.clamped();
199    }
200    // chroma 0 is a grey, always in gamut: bisect for the most chroma that fits
201    let (mut lo, mut hi) = (0.0_f32, c);
202    for _ in 0..24 {
203        let mid = (lo + hi) * 0.5;
204        if at(mid).in_gamut(0.0) {
205            lo = mid;
206        } else {
207            hi = mid;
208        }
209    }
210    at(lo).clamped()
211}
212
213/// The OKLCH form `[l, c, h_deg]` of `c` (hue 0 for greys).
214pub fn to_oklch(c: Rgba) -> [f32; 3] {
215    let [l, a, b] = to_oklab(c);
216    let chroma = (a * a + b * b).sqrt();
217    let h = if chroma < 1e-6 { 0.0 } else { b.atan2(a).to_degrees().rem_euclid(360.0) };
218    [l, chroma, h]
219}
220
221/// `a` and `b` mixed `t` of the way (0 = `a`, 1 = `b`, clamped) in premultiplied OKLab; the
222/// result is clamped into gamut.
223pub fn mix(a: Rgba, b: Rgba, t: f32) -> Rgba {
224    let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
225    let (la, lb) = (a.to_lanes(), b.to_lanes());
226    let mut out = [0.0; 4];
227    for (o, (x, y)) in out.iter_mut().zip(la.iter().zip(lb.iter())) {
228        *o = x + (y - x) * t;
229    }
230    Rgba::from_lanes(out)
231}
232
233/// WCAG 2 contrast ratio of `fg` on `bg`, 1..=21. A translucent `fg` is composited over `bg`
234/// first (in linear light); `bg` counts as opaque.
235pub fn contrast_ratio(fg: Rgba, bg: Rgba) -> f32 {
236    let (fg, bg) = (fg.clamped(), bg.clamped());
237    let over = |f: f32, b: f32| f * fg.a + b * (1.0 - fg.a);
238    let shown = Rgba::new(over(fg.r, bg.r), over(fg.g, bg.g), over(fg.b, bg.b), 1.0);
239    let (l1, l2) = (shown.luminance(), bg.luminance());
240    let (hi, lo) = if l1 >= l2 { (l1, l2) } else { (l2, l1) };
241    (hi + 0.05) / (lo + 0.05)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn known_values() {
250        let w = to_oklab(Rgba::WHITE);
251        assert!((w[0] - 1.0).abs() < 1e-3 && w[1].abs() < 1e-3 && w[2].abs() < 1e-3);
252        assert!((contrast_ratio(Rgba::BLACK, Rgba::WHITE) - 21.0).abs() < 1e-3);
253        assert!((contrast_ratio(Rgba::WHITE, Rgba::WHITE) - 1.0).abs() < 1e-6);
254        assert_eq!(Rgba::from_srgb8(0x12, 0x34, 0x56, 0x78).to_srgb8(), [0x12, 0x34, 0x56, 0x78]);
255        // #777 on white is the classic 4.48
256        let grey = Rgba::from_srgb8(0x77, 0x77, 0x77, 255);
257        assert!((contrast_ratio(grey, Rgba::WHITE) - 4.48).abs() < 0.01);
258    }
259
260    #[test]
261    fn mix_ends_and_transparent() {
262        let red = Rgba::from_srgb8(255, 0, 0, 255);
263        let blue = Rgba::from_srgb8(0, 0, 255, 255);
264        let close = |a: Rgba, b: Rgba| a.to_srgb8().iter().zip(b.to_srgb8().iter()).all(|(x, y)| x.abs_diff(*y) <= 1);
265        assert!(close(mix(red, blue, 0.0), red));
266        assert!(close(mix(red, blue, 1.0), blue));
267        // fading to transparent keeps the colour, only alpha drops
268        let half = mix(red, Rgba::TRANSPARENT, 0.5);
269        assert!((half.a - 0.5).abs() < 1e-5);
270        assert!(close(half.with_alpha(1.0), red));
271        assert!(mix(red, blue, f32::NAN).r.is_finite());
272    }
273
274    #[test]
275    fn oklch_maps_into_gamut() {
276        for &(l, c, h) in &[(0.7, 0.4, 145.0), (0.0, 0.4, 0.0), (1.0, 0.4, 270.0), (f32::NAN, f32::INFINITY, f32::NAN), (0.5, -1.0, -720.0)] {
277            let col = oklch(l, c, h);
278            assert!(col.in_gamut(0.0) && col.r.is_finite(), "{l} {c} {h} {col:?}");
279        }
280        // hue kept when mapping
281        let col = oklch(0.7, 0.4, 145.0);
282        let [_, _, h] = to_oklch(col);
283        assert!((h - 145.0).abs() < 1.0, "{h}");
284    }
285}