Skip to main content

ferroui_base/media/
color_matrix.rs

1//! A colour matrix: the `Matrix5x4` of WinUI's colour matrix effect
2//! (Win2D `ColorMatrixEffect.ColorMatrix`), which the drawing contexts apply
3//! through [`IDrawingContextImplWithLayerEffects::push_color_matrix_layer`](crate::platform::IDrawingContextImplWithLayerEffects::push_color_matrix_layer).
4
5/// A colour transform of the form `out = in * M`: `in` is the row
6/// `[R, G, B, A, 1]` of a colour with straight (not premultiplied) alpha,
7/// each channel from 0 to 1, and `M` has five rows of four columns.
8///
9/// Row `i` holds what input channel `i` (red, green, blue, alpha) adds to
10/// each output channel (red, green, blue, alpha); the fifth row is the
11/// offset of each output channel. This is the layout of the `Matrix5x4` of
12/// WinUI (`M11` .. `M54`).
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct ColorMatrix {
15    /// The rows: red, green, blue and alpha in, then the offsets.
16    pub rows: [[f32; 4]; 5],
17}
18
19impl Default for ColorMatrix {
20    fn default() -> Self {
21        Self::IDENTITY
22    }
23}
24
25/// How a [`ColorMatrix`] treats the premultiplied colours it is applied to
26/// (Win2D `CanvasAlphaMode` of `ColorMatrixEffect.AlphaMode`, Direct2D's
27/// `D2D1_COLORMATRIX_ALPHA_MODE`).
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
29pub enum ColorMatrixAlphaMode {
30    /// The colour is un-premultiplied, the matrix applied to `[R, G, B, a, 1]`
31    /// and the result premultiplied again (the Win2D default).
32    #[default]
33    Premultiplied,
34    /// The matrix is applied to the premultiplied colour as it is,
35    /// `[R a, G a, B a, a, 1]`, and the result taken as premultiplied (its
36    /// colour clamped to its alpha).
37    Straight,
38}
39
40/// The weights of the luminance of WinUI's luminance-to-alpha matrix.
41pub const LUMINANCE_WEIGHTS: [f32; 3] = [0.2125, 0.7154, 0.0721];
42
43impl ColorMatrix {
44    /// The matrix that changes nothing.
45    pub const IDENTITY: ColorMatrix = ColorMatrix {
46        rows: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], [0.0; 4]],
47    };
48
49    /// A matrix from its rows.
50    pub const fn new(rows: [[f32; 4]; 5]) -> ColorMatrix {
51        ColorMatrix { rows }
52    }
53
54    /// A matrix from the twenty members of WinUI's `Matrix5x4`, in the order
55    /// `M11, M12, M13, M14, M21, ..., M54`.
56    pub fn from_matrix5x4(m: [f32; 20]) -> ColorMatrix {
57        let mut rows = [[0.0; 4]; 5];
58        for (index, value) in m.into_iter().enumerate() {
59            rows[index / 4][index % 4] = value;
60        }
61        ColorMatrix { rows }
62    }
63
64    /// The saturation matrix of Win2D's `SaturationEffect` (the
65    /// `saturate` colour matrix of SVG): 0 is grey, 1 changes nothing,
66    /// values above 1 saturate more.
67    pub fn saturation(saturation: f32) -> ColorMatrix {
68        let s = saturation;
69        ColorMatrix::new([
70            [0.213 + 0.787 * s, 0.213 - 0.213 * s, 0.213 - 0.213 * s, 0.0],
71            [0.715 - 0.715 * s, 0.715 + 0.285 * s, 0.715 - 0.715 * s, 0.0],
72            [0.072 - 0.072 * s, 0.072 - 0.072 * s, 0.072 + 0.928 * s, 0.0],
73            [0.0, 0.0, 0.0, 1.0],
74            [0.0; 4],
75        ])
76    }
77
78    /// Win2D's `LuminanceToAlphaEffect`: the luminance of the colour becomes
79    /// its alpha, the colour black.
80    pub fn luminance_to_alpha() -> ColorMatrix {
81        let [r, g, b] = LUMINANCE_WEIGHTS;
82        ColorMatrix::new([[0.0, 0.0, 0.0, r], [0.0, 0.0, 0.0, g], [0.0, 0.0, 0.0, b], [0.0; 4], [0.0; 4]])
83    }
84
85    /// Win2D's `TintEffect`: each channel multiplied by the channel of
86    /// `tint` (red, green, blue, alpha from 0 to 1).
87    pub fn tint(tint: [f32; 4]) -> ColorMatrix {
88        ColorMatrix::new([
89            [tint[0], 0.0, 0.0, 0.0],
90            [0.0, tint[1], 0.0, 0.0],
91            [0.0, 0.0, tint[2], 0.0],
92            [0.0, 0.0, 0.0, tint[3]],
93            [0.0; 4],
94        ])
95    }
96
97    /// Win2D's `InvertEffect`: each colour channel `c` becomes `1 - c`; the
98    /// alpha is kept.
99    pub fn invert() -> ColorMatrix {
100        ColorMatrix::new([
101            [-1.0, 0.0, 0.0, 0.0],
102            [0.0, -1.0, 0.0, 0.0],
103            [0.0, 0.0, -1.0, 0.0],
104            [0.0, 0.0, 0.0, 1.0],
105            [1.0, 1.0, 1.0, 0.0],
106        ])
107    }
108
109    /// Win2D's `GrayscaleEffect`: each colour channel becomes
110    /// `0.299 R + 0.587 G + 0.114 B`.
111    pub fn grayscale() -> ColorMatrix {
112        let (r, g, b) = (0.299, 0.587, 0.114);
113        ColorMatrix::new([[r, r, r, 0.0], [g, g, g, 0.0], [b, b, b, 0.0], [0.0, 0.0, 0.0, 1.0], [0.0; 4]])
114    }
115
116    /// The matrix that scales each channel and adds an offset:
117    /// `out = in * scale + offset`, channel by channel.
118    pub fn scale_and_offset(scale: [f32; 4], offset: [f32; 4]) -> ColorMatrix {
119        let mut rows = [[0.0; 4]; 5];
120        for channel in 0..4 {
121            rows[channel][channel] = scale[channel];
122        }
123        rows[4] = offset;
124        ColorMatrix { rows }
125    }
126
127    /// Whether the matrix changes nothing.
128    pub fn is_identity(&self) -> bool {
129        *self == ColorMatrix::IDENTITY
130    }
131
132    /// The matrix that applies `self`, then `next`.
133    pub fn then(&self, next: &ColorMatrix) -> ColorMatrix {
134        let mut rows = [[0.0; 4]; 5];
135        for (row, out) in rows.iter_mut().enumerate() {
136            for (column, value) in out.iter_mut().enumerate() {
137                let mut sum = if row == 4 { next.rows[4][column] } else { 0.0 };
138                for k in 0..4 {
139                    sum += self.rows[row][k] * next.rows[k][column];
140                }
141                *value = sum;
142            }
143        }
144        ColorMatrix { rows }
145    }
146
147    /// Applies the matrix to a colour with straight alpha, each channel from
148    /// 0 to 1. The result is clamped to 0..1.
149    pub fn apply(&self, color: [f32; 4]) -> [f32; 4] {
150        let mut out = self.rows[4];
151        for (channel, value) in color.into_iter().enumerate() {
152            for (column, sum) in out.iter_mut().enumerate() {
153                *sum += value * self.rows[channel][column];
154            }
155        }
156        out.map(|value| if value.is_nan() { 0.0 } else { value.clamp(0.0, 1.0) })
157    }
158
159    /// Applies the matrix to a premultiplied colour, each channel from 0 to
160    /// 1, in `mode`; the result is premultiplied, clamped to 0..1, its colour
161    /// channels no larger than its alpha.
162    pub fn apply_premultiplied(&self, color: [f32; 4], mode: ColorMatrixAlphaMode) -> [f32; 4] {
163        match mode {
164            ColorMatrixAlphaMode::Straight => {
165                let [r, g, b, a] = self.apply(color);
166                [r.min(a), g.min(a), b.min(a), a]
167            }
168            ColorMatrixAlphaMode::Premultiplied => {
169                let alpha = color[3];
170                let straight = |value: f32| if alpha > 0.0 { (value / alpha).min(1.0) } else { 0.0 };
171                let [r, g, b, a] = self.apply([straight(color[0]), straight(color[1]), straight(color[2]), alpha]);
172                [r * a, g * a, b * a, a]
173            }
174        }
175    }
176
177    /// Applies the matrix in `mode` to premultiplied RGBA pixels of eight
178    /// bits a channel, in place.
179    pub fn apply_to_rgba8(&self, pixels: &mut [u8], mode: ColorMatrixAlphaMode) {
180        if mode == ColorMatrixAlphaMode::Premultiplied {
181            return self.apply_to_premultiplied_rgba8(pixels);
182        }
183        for pixel in pixels.chunks_exact_mut(4) {
184            let color = [0, 1, 2, 3].map(|channel| pixel[channel] as f32 / 255.0);
185            let out = self.apply_premultiplied(color, mode);
186            for channel in 0..4 {
187                pixel[channel] = (out[channel] * 255.0).round().clamp(0.0, 255.0) as u8;
188            }
189        }
190    }
191
192    /// Applies the matrix to premultiplied RGBA pixels of eight bits a
193    /// channel, in place: each pixel is unpremultiplied, transformed and
194    /// premultiplied again (the straight alpha mode).
195    pub fn apply_to_premultiplied_rgba8(&self, pixels: &mut [u8]) {
196        for pixel in pixels.chunks_exact_mut(4) {
197            let alpha = pixel[3] as f32 / 255.0;
198            let straight = |value: u8| if alpha > 0.0 { (value as f32 / 255.0 / alpha).min(1.0) } else { 0.0 };
199            let [r, g, b, a] = self.apply([straight(pixel[0]), straight(pixel[1]), straight(pixel[2]), alpha]);
200            let premultiplied = |value: f32| (value * a * 255.0).round().clamp(0.0, 255.0) as u8;
201            pixel[0] = premultiplied(r);
202            pixel[1] = premultiplied(g);
203            pixel[2] = premultiplied(b);
204            pixel[3] = (a * 255.0).round().clamp(0.0, 255.0) as u8;
205        }
206    }
207
208    /// The matrix in the row-major 4 x 5 form of Skia and SVG: a row per
209    /// output channel, its last column the offset.
210    pub fn to_row_major_4x5(&self) -> [f32; 20] {
211        let mut out = [0.0; 20];
212        for output in 0..4 {
213            for input in 0..5 {
214                out[output * 5 + input] = self.rows[input][output];
215            }
216        }
217        out
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn close(a: [f32; 4], b: [f32; 4]) -> bool {
226        a.iter().zip(b.iter()).all(|(a, b)| (a - b).abs() < 1e-4)
227    }
228
229    #[test]
230    fn identity_changes_nothing() {
231        let color = [0.2, 0.4, 0.6, 0.8];
232        assert!(close(ColorMatrix::IDENTITY.apply(color), color));
233        assert!(ColorMatrix::default().is_identity());
234    }
235
236    #[test]
237    fn rows_follow_the_matrix5x4_of_winui() {
238        // M14 (red in to alpha out) and M54 (alpha offset).
239        let mut members = [0.0; 20];
240        members[3] = 1.0;
241        members[19] = 0.25;
242        let matrix = ColorMatrix::from_matrix5x4(members);
243        assert_eq!(matrix.rows[0][3], 1.0);
244        assert_eq!(matrix.rows[4][3], 0.25);
245        assert!(close(matrix.apply([0.5, 0.0, 0.0, 0.0]), [0.0, 0.0, 0.0, 0.75]));
246    }
247
248    #[test]
249    fn saturation_zero_is_grey_and_one_is_unchanged() {
250        let color = [1.0, 0.0, 0.0, 1.0];
251        let grey = ColorMatrix::saturation(0.0).apply(color);
252        assert!((grey[0] - grey[1]).abs() < 1e-4 && (grey[1] - grey[2]).abs() < 1e-4);
253        assert!(close(ColorMatrix::saturation(1.0).apply(color), color));
254    }
255
256    #[test]
257    fn luminance_to_alpha_uses_the_weights_of_winui() {
258        let out = ColorMatrix::luminance_to_alpha().apply([1.0, 1.0, 1.0, 1.0]);
259        assert!(close(out, [0.0, 0.0, 0.0, 1.0]));
260        let out = ColorMatrix::luminance_to_alpha().apply([0.0, 1.0, 0.0, 0.3]);
261        assert!(close(out, [0.0, 0.0, 0.0, 0.7154]));
262    }
263
264    #[test]
265    fn invert_keeps_alpha() {
266        assert!(close(ColorMatrix::invert().apply([0.25, 0.5, 1.0, 0.5]), [0.75, 0.5, 0.0, 0.5]));
267    }
268
269    #[test]
270    fn then_applies_in_order() {
271        let a = ColorMatrix::scale_and_offset([0.5, 1.0, 1.0, 1.0], [0.0; 4]);
272        let b = ColorMatrix::scale_and_offset([1.0; 4], [0.25, 0.0, 0.0, 0.0]);
273        let color = [1.0, 0.0, 0.0, 1.0];
274        assert!(close(a.then(&b).apply(color), b.apply(a.apply(color))));
275        assert!(close(a.then(&b).apply(color), [0.75, 0.0, 0.0, 1.0]));
276    }
277
278    #[test]
279    fn premultiplied_pixels_are_transformed_straight() {
280        // Half transparent red, premultiplied.
281        let mut pixels = [128u8, 0, 0, 128];
282        ColorMatrix::invert().apply_to_premultiplied_rgba8(&mut pixels);
283        // Straight (0, 1, 1) at alpha 0.5.
284        assert_eq!(pixels[0], 0);
285        assert!((pixels[1] as i32 - 128).abs() <= 1);
286        assert_eq!(pixels[3], 128);
287    }
288
289    #[test]
290    fn premultiplied_mode_applies_to_premultiplied_values() {
291        // Doubling every channel of half transparent grey.
292        let matrix = ColorMatrix::scale_and_offset([2.0; 4], [0.0; 4]);
293        let out = matrix.apply_premultiplied([0.25, 0.25, 0.25, 0.5], ColorMatrixAlphaMode::Straight);
294        assert!(close(out, [0.5, 0.5, 0.5, 1.0]));
295        // Colour channels never exceed the alpha.
296        let out = ColorMatrix::scale_and_offset([1.0, 1.0, 1.0, 0.5], [0.0; 4])
297            .apply_premultiplied([0.5, 0.5, 0.5, 0.5], ColorMatrixAlphaMode::Straight);
298        assert!(close(out, [0.25, 0.25, 0.25, 0.25]));
299        let mut pixels = [64u8, 64, 64, 128];
300        matrix.apply_to_rgba8(&mut pixels, ColorMatrixAlphaMode::Straight);
301        assert_eq!(pixels, [128, 128, 128, 255]);
302    }
303
304    #[test]
305    fn row_major_form_is_a_row_per_output_channel() {
306        let matrix = ColorMatrix::luminance_to_alpha();
307        let row_major = matrix.to_row_major_4x5();
308        // The alpha output row: 0.2125 R + 0.7154 G + 0.0721 B.
309        assert_eq!(&row_major[15..18], &LUMINANCE_WEIGHTS);
310        assert_eq!(row_major[0], 0.0);
311    }
312}