color/
gradient.rs

1// Copyright 2024 the Color Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4use crate::{
5    AlphaColor, ColorSpace, ColorSpaceTag, DynamicColor, HueDirection, Interpolator, Oklab,
6    PremulColor, UnpremultipliedInterpolator,
7};
8
9/// The iterator for gradient approximation.
10///
11/// This will yield a value for each gradient stop, including `t` values
12/// of 0 and 1 at the endpoints.
13///
14/// Use the [`gradient`] function to generate this iterator.
15#[expect(missing_debug_implementations, reason = "it's an iterator")]
16pub struct GradientIter<CS: ColorSpace> {
17    interpolator: Interpolator,
18    // This is in deltaEOK units
19    tolerance: f32,
20    // The adaptive subdivision logic is lifted from the stroke expansion paper.
21    t0: u32,
22    dt: f32,
23    target0: PremulColor<CS>,
24    target1: PremulColor<CS>,
25    end_color: PremulColor<CS>,
26}
27
28/// Generate a piecewise linear approximation to a gradient ramp.
29///
30/// The target gradient ramp is the linear interpolation from `color0` to `color1` in the target
31/// color space specified by `interp_cs`. For efficiency, this function returns an
32/// [iterator over color stops](GradientIter) in the `CS` color space, such that the gradient ramp
33/// created by linearly interpolating between those stops in the `CS` color space is equal within
34/// the specified `tolerance` to the target gradient ramp.
35///
36/// When the target interpolation color space is cylindrical, the hue can be interpolated in
37/// multiple ways. The [`direction`](`HueDirection`) parameter controls the way in which the hue is
38/// interpolated.
39///
40/// The given `tolerance` value specifies the maximum perceptual error in the approximation
41/// measured as the [Euclidean distance][euclidean-distance] in the [Oklab] color space (see also
42/// [`PremulColor::difference`][crate::PremulColor::difference]). This metric is known as
43/// [deltaEOK][delta-eok]. A reasonable value is 0.01, which in testing is nearly indistinguishable
44/// from the exact ramp. The number of stops scales roughly as the inverse square root of the
45/// tolerance.
46///
47/// The error is measured at the midpoint of each segment, which in some cases may underestimate
48/// the error.
49///
50/// For regular interpolation between two colors, see [`DynamicColor::interpolate`].
51///
52/// [euclidean-distance]: https://en.wikipedia.org/wiki/Euclidean_distance
53/// [delta-eok]: https://www.w3.org/TR/css-color-4/#color-difference-OK
54///
55/// # Motivation
56///
57/// A major feature of CSS Color 4 is the ability to specify color interpolation in any
58/// interpolation color space [CSS Color Module Level 4 § 12.1][css-sec], which may be quite a bit
59/// better than simple linear interpolation in sRGB (for example).
60///
61/// One strategy for implementing these gradients is to interpolate in the appropriate
62/// (premultiplied) space, then map each resulting color to the space used for compositing. That
63/// can be expensive. An alternative strategy is to precompute a piecewise linear ramp that closely
64/// approximates the desired ramp, then render that using high performance techniques. This method
65/// computes such an approximation.
66///
67/// [css-sec]: https://www.w3.org/TR/css-color-4/#interpolation-space
68///
69/// # Example
70///
71/// The following compares interpolating in the target color space Oklab with interpolating
72/// piecewise in the color space sRGB.
73///
74/// ```rust
75/// use color::{AlphaColor, ColorSpaceTag, DynamicColor, HueDirection, Oklab, Srgb};
76///
77/// let start = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([1., 0., 0., 1.]));
78/// let end = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([0., 1., 0., 1.]));
79///
80/// // Interpolation in a target interpolation color space.
81/// let interp = start.interpolate(end, ColorSpaceTag::Oklab, HueDirection::default());
82/// // Piecewise-approximated interpolation in a compositing color space.
83/// let mut gradient = color::gradient::<Srgb>(
84///     start,
85///     end,
86///     ColorSpaceTag::Oklab,
87///     HueDirection::default(),
88///     0.01,
89/// );
90///
91/// let (mut t0, mut stop0) = gradient.next().unwrap();
92/// for (t1, stop1) in gradient {
93///     // Compare a few points between the piecewise stops.
94///     for point in [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9] {
95///         let interpolated_point = interp
96///             .eval(t0 + (t1 - t0) * point)
97///             .to_alpha_color::<Srgb>()
98///             .discard_alpha();
99///         let approximated_point = stop0.lerp_rect(stop1, point).discard_alpha();
100///
101///         // The perceptual deltaEOK between the two is lower than the tolerance.
102///         assert!(
103///             approximated_point
104///                 .convert::<Oklab>()
105///                 .difference(interpolated_point.convert::<Oklab>())
106///                 < 0.01
107///         );
108///     }
109///
110///     t0 = t1;
111///     stop0 = stop1;
112/// }
113/// ```
114pub fn gradient<CS: ColorSpace>(
115    mut color0: DynamicColor,
116    mut color1: DynamicColor,
117    interp_cs: ColorSpaceTag,
118    direction: HueDirection,
119    tolerance: f32,
120) -> GradientIter<CS> {
121    let interpolator = color0.interpolate(color1, interp_cs, direction);
122    if !color0.flags.missing().is_empty() {
123        color0 = interpolator.eval(0.0);
124    }
125    let target0 = color0.to_alpha_color().premultiply();
126    if !color1.flags.missing().is_empty() {
127        color1 = interpolator.eval(1.0);
128    }
129    let target1 = color1.to_alpha_color().premultiply();
130    let end_color = target1;
131    GradientIter {
132        interpolator,
133        tolerance,
134        t0: 0,
135        dt: 0.0,
136        target0,
137        target1,
138        end_color,
139    }
140}
141
142impl<CS: ColorSpace> Iterator for GradientIter<CS> {
143    type Item = (f32, PremulColor<CS>);
144
145    fn next(&mut self) -> Option<Self::Item> {
146        if self.dt == 0.0 {
147            self.dt = 1.0;
148            return Some((0.0, self.target0));
149        }
150        let t0 = self.t0 as f32 * self.dt;
151        if t0 == 1.0 {
152            return None;
153        }
154        loop {
155            // compute midpoint color
156            let midpoint = self.interpolator.eval(t0 + 0.5 * self.dt);
157            let error = {
158                let midpoint_oklab: PremulColor<Oklab> = midpoint.to_alpha_color().premultiply();
159                let approx = self.target0.lerp_rect(self.target1, 0.5);
160                midpoint_oklab.difference(approx.convert())
161            };
162            if error <= self.tolerance {
163                let t1 = t0 + self.dt;
164                self.t0 += 1;
165                let shift = self.t0.trailing_zeros();
166                self.t0 >>= shift;
167                self.dt *= (1 << shift) as f32;
168                self.target0 = self.target1;
169                let new_t1 = t1 + self.dt;
170                if new_t1 < 1.0 {
171                    self.target1 = self
172                        .interpolator
173                        .eval(new_t1)
174                        .to_alpha_color()
175                        .premultiply();
176                } else {
177                    self.target1 = self.end_color;
178                }
179                return Some((t1, self.target0));
180            }
181            self.t0 *= 2;
182            self.dt *= 0.5;
183            self.target1 = midpoint.to_alpha_color().premultiply();
184        }
185    }
186}
187
188/// The iterator for gradient approximation.
189///
190/// This will yield a value for each gradient stop, including `t` values
191/// of 0 and 1 at the endpoints.
192///
193/// Use the [`gradient_unpremultiplied`] function to generate this iterator.
194///
195/// Similar to [`GradientIter`], but does interpolation in unpremultiplied (straight) alpha space
196/// as specified in [HTML 2D Canvas].
197///
198/// [HTML 2D Canvas]: https://html.spec.whatwg.org/multipage/#interpolation
199#[expect(missing_debug_implementations, reason = "it's an iterator")]
200pub struct UnpremultipliedGradientIter<CS: ColorSpace> {
201    interpolator: UnpremultipliedInterpolator,
202    // This is in deltaEOK units
203    tolerance: f32,
204    // The adaptive subdivision logic is lifted from the stroke expansion paper.
205    t0: u32,
206    dt: f32,
207    target0: AlphaColor<CS>,
208    target1: AlphaColor<CS>,
209    end_color: AlphaColor<CS>,
210}
211
212/// Generate a piecewise linear approximation to a gradient ramp without alpha premultiplication.
213///
214/// Similar to [`gradient`], but colors are interpolated without premultiplying their color
215/// channels by the alpha channel. This is almost never what you want.
216///
217/// This causes color information to leak out of transparent colors. For example, when
218/// interpolating from a fully transparent red to a fully opaque blue in sRGB, this
219/// method will go through an intermediate purple.
220///
221/// This matches behavior of gradients in the HTML `canvas` element.
222/// See [The 2D rendering context § Fill and stroke styles][HTML 2D Canvas] of the
223/// HTML 2D Canvas specification.
224///
225/// [HTML 2D Canvas]: <https://html.spec.whatwg.org/multipage/#interpolation>
226///
227/// # Example
228///
229/// The following compares interpolating in the target color space Oklab with interpolating
230/// piecewise in the color space sRGB.
231///
232/// ```rust
233/// use color::{AlphaColor, ColorSpaceTag, DynamicColor, HueDirection, Oklab, Srgb};
234///
235/// let start = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([1., 0., 0., 1.]));
236/// let end = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([0., 1., 0., 1.]));
237///
238/// // Interpolation in a target interpolation color space.
239/// let interp = start.interpolate_unpremultiplied(end, ColorSpaceTag::Oklab, HueDirection::default());
240/// // Piecewise-approximated interpolation in a compositing color space.
241/// let mut gradient = color::gradient_unpremultiplied::<Srgb>(
242///     start,
243///     end,
244///     ColorSpaceTag::Oklab,
245///     HueDirection::default(),
246///     0.01,
247/// );
248///
249/// let (mut t0, mut stop0) = gradient.next().unwrap();
250/// for (t1, stop1) in gradient {
251///     // Compare a few points between the piecewise stops.
252///     for point in [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9] {
253///         let interpolated_point = interp
254///             .eval(t0 + (t1 - t0) * point)
255///             .to_alpha_color::<Srgb>()
256///             .discard_alpha();
257///         let approximated_point = stop0.lerp_rect(stop1, point).discard_alpha();
258///
259///         // The perceptual deltaEOK between the two is lower than the tolerance.
260///         assert!(
261///             approximated_point
262///                 .convert::<Oklab>()
263///                 .difference(interpolated_point.convert::<Oklab>())
264///                 < 0.01
265///         );
266///     }
267///
268///     t0 = t1;
269///     stop0 = stop1;
270/// }
271/// ```
272pub fn gradient_unpremultiplied<CS: ColorSpace>(
273    mut color0: DynamicColor,
274    mut color1: DynamicColor,
275    interp_cs: ColorSpaceTag,
276    direction: HueDirection,
277    tolerance: f32,
278) -> UnpremultipliedGradientIter<CS> {
279    let interpolator = color0.interpolate_unpremultiplied(color1, interp_cs, direction);
280    if !color0.flags.missing().is_empty() {
281        color0 = interpolator.eval(0.0);
282    }
283    let target0 = color0.to_alpha_color();
284    if !color1.flags.missing().is_empty() {
285        color1 = interpolator.eval(1.0);
286    }
287    let target1 = color1.to_alpha_color();
288    let end_color = target1;
289    UnpremultipliedGradientIter {
290        interpolator,
291        tolerance,
292        t0: 0,
293        dt: 0.0,
294        target0,
295        target1,
296        end_color,
297    }
298}
299
300impl<CS: ColorSpace> Iterator for UnpremultipliedGradientIter<CS> {
301    type Item = (f32, AlphaColor<CS>);
302
303    fn next(&mut self) -> Option<Self::Item> {
304        if self.dt == 0.0 {
305            self.dt = 1.0;
306            return Some((0.0, self.target0));
307        }
308        let t0 = self.t0 as f32 * self.dt;
309        if t0 == 1.0 {
310            return None;
311        }
312        loop {
313            // compute midpoint color
314            let midpoint = self.interpolator.eval(t0 + 0.5 * self.dt);
315            let error = {
316                let midpoint_oklab: AlphaColor<Oklab> = midpoint.to_alpha_color();
317                let approx = self.target0.lerp_rect(self.target1, 0.5);
318                midpoint_oklab.difference(approx.convert())
319            };
320            if error <= self.tolerance {
321                let t1 = t0 + self.dt;
322                self.t0 += 1;
323                let shift = self.t0.trailing_zeros();
324                self.t0 >>= shift;
325                self.dt *= (1 << shift) as f32;
326                self.target0 = self.target1;
327                let new_t1 = t1 + self.dt;
328                if new_t1 < 1.0 {
329                    self.target1 = self.interpolator.eval(new_t1).to_alpha_color();
330                } else {
331                    self.target1 = self.end_color;
332                }
333                return Some((t1, self.target0));
334            }
335            self.t0 *= 2;
336            self.dt *= 0.5;
337            self.target1 = midpoint.to_alpha_color();
338        }
339    }
340}