Skip to main content

dinamika_cpu/paint/
blend.rs

1//! Blend modes ([`BlendMode`]) and Porter–Duff / W3C compositing.
2//!
3//! The arithmetic is done directly over the sRGB (gamma-encoded) components,
4//! without switching to linear light (see the limitation in the `paint` module
5//! documentation).
6
7use crate::color::PremultipliedColor;
8
9/// Blend mode of the source and the background.
10#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
11pub enum BlendMode {
12    Clear,
13    Source,
14    Destination,
15    #[default]
16    SourceOver,
17    DestinationOver,
18    SourceIn,
19    DestinationIn,
20    SourceOut,
21    DestinationOut,
22    SourceAtop,
23    DestinationAtop,
24    Xor,
25    /// Additive blending with saturation.
26    Plus,
27    Multiply,
28    Screen,
29    Overlay,
30    Darken,
31    Lighten,
32    HardLight,
33    /// Soft light (like [`HardLight`](BlendMode::HardLight), but softer) — W3C compositing.
34    SoftLight,
35    /// Absolute difference of channels.
36    Difference,
37    /// Similar to [`Difference`](BlendMode::Difference), but with lower contrast.
38    Exclusion,
39    /// Dodge by division: brings out the background according to the source.
40    ColorDodge,
41    /// Burn by division: darkens the background according to the source.
42    ColorBurn,
43}
44
45impl BlendMode {
46    /// Porter–Duff coefficients `(Fa, Fb)` for the separable components, or
47    /// `None` for non-separable blend modes.
48    fn porter_duff(self, sa: f32, da: f32) -> Option<(f32, f32)> {
49        let f = match self {
50            BlendMode::Clear => (0.0, 0.0),
51            BlendMode::Source => (1.0, 0.0),
52            BlendMode::Destination => (0.0, 1.0),
53            BlendMode::SourceOver => (1.0, 1.0 - sa),
54            BlendMode::DestinationOver => (1.0 - da, 1.0),
55            BlendMode::SourceIn => (da, 0.0),
56            BlendMode::DestinationIn => (0.0, sa),
57            BlendMode::SourceOut => (1.0 - da, 0.0),
58            BlendMode::DestinationOut => (0.0, 1.0 - sa),
59            BlendMode::SourceAtop => (da, 1.0 - sa),
60            BlendMode::DestinationAtop => (1.0 - da, sa),
61            BlendMode::Xor => (1.0 - da, 1.0 - sa),
62            BlendMode::Plus => (1.0, 1.0),
63            _ => return None,
64        };
65        Some(f)
66    }
67}
68
69/// Blends a premultiplied source `src` with a premultiplied background `dst`.
70///
71/// The arithmetic is done directly over the sRGB components, without switching
72/// to linear light (see the limitation in the `paint` module documentation).
73pub(crate) fn blend(
74    mode: BlendMode,
75    src: PremultipliedColor,
76    dst: PremultipliedColor,
77) -> PremultipliedColor {
78    if let Some((fa, fb)) = mode.porter_duff(src.a, dst.a) {
79        let mut out = PremultipliedColor {
80            r: src.r * fa + dst.r * fb,
81            g: src.g * fa + dst.g * fb,
82            b: src.b * fa + dst.b * fb,
83            a: src.a * fa + dst.a * fb,
84        };
85        // Plus may exceed 1.0 — saturate.
86        out.r = out.r.clamp(0.0, 1.0);
87        out.g = out.g.clamp(0.0, 1.0);
88        out.b = out.b.clamp(0.0, 1.0);
89        out.a = out.a.clamp(0.0, 1.0);
90        return out;
91    }
92
93    // Non-separable modes by the W3C compositing formula.
94    // We work with non-premultiplied background/source colors.
95    let sa = src.a;
96    let da = dst.a;
97    let unpre = |c: f32, a: f32| if a > 0.0 { c / a } else { 0.0 };
98    let cs = (unpre(src.r, sa), unpre(src.g, sa), unpre(src.b, sa));
99    let cb = (unpre(dst.r, da), unpre(dst.g, da), unpre(dst.b, da));
100
101    let blend_ch = |s: f32, b: f32| -> f32 {
102        match mode {
103            BlendMode::Multiply => s * b,
104            BlendMode::Screen => s + b - s * b,
105            BlendMode::Darken => s.min(b),
106            BlendMode::Lighten => s.max(b),
107            BlendMode::Overlay => hard_light(b, s),
108            BlendMode::HardLight => hard_light(s, b),
109            BlendMode::SoftLight => soft_light(b, s),
110            BlendMode::Difference => (s - b).abs(),
111            BlendMode::Exclusion => s + b - 2.0 * s * b,
112            BlendMode::ColorDodge => color_dodge(b, s),
113            BlendMode::ColorBurn => color_burn(b, s),
114            _ => s,
115        }
116    };
117
118    let ao = sa + da * (1.0 - sa);
119    let mix = |s: f32, b: f32| -> f32 {
120        // Co = αs·(1-αb)·Cs + αs·αb·B(Cb,Cs) + (1-αs)·αb·Cb  (premultiplied)
121        sa * (1.0 - da) * s + sa * da * blend_ch(s, b) + (1.0 - sa) * da * b
122    };
123
124    PremultipliedColor {
125        r: mix(cs.0, cb.0).clamp(0.0, 1.0),
126        g: mix(cs.1, cb.1).clamp(0.0, 1.0),
127        b: mix(cs.2, cb.2).clamp(0.0, 1.0),
128        a: ao.clamp(0.0, 1.0),
129    }
130}
131
132#[inline]
133fn hard_light(s: f32, b: f32) -> f32 {
134    if s <= 0.5 {
135        2.0 * s * b
136    } else {
137        1.0 - 2.0 * (1.0 - s) * (1.0 - b)
138    }
139}
140
141/// Soft light (W3C): `b` — background, `s` — source.
142#[inline]
143fn soft_light(b: f32, s: f32) -> f32 {
144    if s <= 0.5 {
145        b - (1.0 - 2.0 * s) * b * (1.0 - b)
146    } else {
147        let d = if b <= 0.25 {
148            ((16.0 * b - 12.0) * b + 4.0) * b
149        } else {
150            b.sqrt()
151        };
152        b + (2.0 * s - 1.0) * (d - b)
153    }
154}
155
156/// Color Dodge (W3C): `b` — background, `s` — source.
157#[inline]
158fn color_dodge(b: f32, s: f32) -> f32 {
159    if b <= 0.0 {
160        0.0
161    } else if s >= 1.0 {
162        1.0
163    } else {
164        (b / (1.0 - s)).min(1.0)
165    }
166}
167
168/// Color Burn (W3C): `b` — background, `s` — source.
169#[inline]
170fn color_burn(b: f32, s: f32) -> f32 {
171    if b >= 1.0 {
172        1.0
173    } else if s <= 0.0 {
174        0.0
175    } else {
176        1.0 - ((1.0 - b) / s).min(1.0)
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::color::Color;
184
185    #[test]
186    fn source_over_opaque_replaces() {
187        let src = Color::from_rgba8(255, 0, 0, 255).premultiply();
188        let dst = Color::from_rgba8(0, 0, 255, 255).premultiply();
189        let out = blend(BlendMode::SourceOver, src, dst);
190        assert!((out.r - 1.0).abs() < 1e-3 && out.b < 1e-3);
191    }
192
193    #[test]
194    fn clear_zeroes() {
195        let src = Color::WHITE.premultiply();
196        let dst = Color::WHITE.premultiply();
197        let out = blend(BlendMode::Clear, src, dst);
198        assert!(out.a < 1e-6);
199    }
200
201    #[test]
202    fn difference_of_equal_is_zero() {
203        // The difference of equal opaque colors gives black.
204        let c = Color::from_rgba8(200, 100, 50, 255).premultiply();
205        let out = blend(BlendMode::Difference, c, c);
206        assert!(out.r < 1e-3 && out.g < 1e-3 && out.b < 1e-3, "{out:?}");
207    }
208
209    #[test]
210    fn color_dodge_white_source_saturates() {
211        let src = Color::WHITE.premultiply();
212        let dst = Color::from_rgba8(128, 128, 128, 255).premultiply();
213        let out = blend(BlendMode::ColorDodge, src, dst);
214        assert!(out.r > 0.99, "{out:?}");
215    }
216}