color/gradient.rs
1// Copyright 2024 the Color Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4use crate::{
5 ColorSpace, ColorSpaceTag, DynamicColor, HueDirection, Interpolator, Oklab, PremulColor,
6};
7
8/// The iterator for gradient approximation.
9///
10/// This will yield a value for each gradient stop, including `t` values
11/// of 0 and 1 at the endpoints.
12///
13/// Use the [`gradient`] function to generate this iterator.
14#[expect(missing_debug_implementations, reason = "it's an iterator")]
15pub struct GradientIter<CS: ColorSpace> {
16 interpolator: Interpolator,
17 // This is in deltaEOK units
18 tolerance: f32,
19 // The adaptive subdivision logic is lifted from the stroke expansion paper.
20 t0: u32,
21 dt: f32,
22 target0: PremulColor<CS>,
23 target1: PremulColor<CS>,
24 end_color: PremulColor<CS>,
25}
26
27/// Generate a piecewise linear approximation to a gradient ramp.
28///
29/// The target gradient ramp is the linear interpolation from `color0` to `color1` in the target
30/// color space specified by `interp_cs`. For efficiency, this function returns an
31/// [iterator over color stops](GradientIter) in the `CS` color space, such that the gradient ramp
32/// created by linearly interpolating between those stops in the `CS` color space is equal within
33/// the specified `tolerance` to the target gradient ramp.
34///
35/// When the target interpolation color space is cylindrical, the hue can be interpolated in
36/// multiple ways. The [`direction`](`HueDirection`) parameter controls the way in which the hue is
37/// interpolated.
38///
39/// The given `tolerance` value specifies the maximum perceptual error in the approximation
40/// measured as the [Euclidean distance][euclidean-distance] in the [Oklab] color space (see also
41/// [`PremulColor::difference`][crate::PremulColor::difference]). This metric is known as
42/// [deltaEOK][delta-eok]. A reasonable value is 0.01, which in testing is nearly indistinguishable
43/// from the exact ramp. The number of stops scales roughly as the inverse square root of the
44/// tolerance.
45///
46/// The error is measured at the midpoint of each segment, which in some cases may underestimate
47/// the error.
48///
49/// For regular interpolation between two colors, see [`DynamicColor::interpolate`].
50///
51/// [euclidean-distance]: https://en.wikipedia.org/wiki/Euclidean_distance
52/// [delta-eok]: https://www.w3.org/TR/css-color-4/#color-difference-OK
53///
54/// # Motivation
55///
56/// A major feature of CSS Color 4 is the ability to specify color interpolation in any
57/// interpolation color space [CSS Color Module Level 4 ยง 12.1][css-sec], which may be quite a bit
58/// better than simple linear interpolation in sRGB (for example).
59///
60/// One strategy for implementing these gradients is to interpolate in the appropriate
61/// (premultiplied) space, then map each resulting color to the space used for compositing. That
62/// can be expensive. An alternative strategy is to precompute a piecewise linear ramp that closely
63/// approximates the desired ramp, then render that using high performance techniques. This method
64/// computes such an approximation.
65///
66/// [css-sec]: https://www.w3.org/TR/css-color-4/#interpolation-space
67///
68/// # Example
69///
70/// The following compares interpolating in the target color space Oklab with interpolating
71/// piecewise in the color space sRGB.
72///
73/// ```rust
74/// use color::{AlphaColor, ColorSpaceTag, DynamicColor, HueDirection, Oklab, Srgb};
75///
76/// let start = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([1., 0., 0., 1.]));
77/// let end = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([0., 1., 0., 1.]));
78///
79/// // Interpolation in a target interpolation color space.
80/// let interp = start.interpolate(end, ColorSpaceTag::Oklab, HueDirection::default());
81/// // Piecewise-approximated interpolation in a compositing color space.
82/// let mut gradient = color::gradient::<Srgb>(
83/// start,
84/// end,
85/// ColorSpaceTag::Oklab,
86/// HueDirection::default(),
87/// 0.01,
88/// );
89///
90/// let (mut t0, mut stop0) = gradient.next().unwrap();
91/// for (t1, stop1) in gradient {
92/// // Compare a few points between the piecewise stops.
93/// for point in [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9] {
94/// let interpolated_point = interp
95/// .eval(t0 + (t1 - t0) * point)
96/// .to_alpha_color::<Srgb>()
97/// .discard_alpha();
98/// let approximated_point = stop0.lerp_rect(stop1, point).discard_alpha();
99///
100/// // The perceptual deltaEOK between the two is lower than the tolerance.
101/// assert!(
102/// approximated_point
103/// .convert::<Oklab>()
104/// .difference(interpolated_point.convert::<Oklab>())
105/// < 0.01
106/// );
107/// }
108///
109/// t0 = t1;
110/// stop0 = stop1;
111/// }
112/// ```
113pub fn gradient<CS: ColorSpace>(
114 mut color0: DynamicColor,
115 mut color1: DynamicColor,
116 interp_cs: ColorSpaceTag,
117 direction: HueDirection,
118 tolerance: f32,
119) -> GradientIter<CS> {
120 let interpolator = color0.interpolate(color1, interp_cs, direction);
121 if !color0.flags.missing().is_empty() {
122 color0 = interpolator.eval(0.0);
123 }
124 let target0 = color0.to_alpha_color().premultiply();
125 if !color1.flags.missing().is_empty() {
126 color1 = interpolator.eval(1.0);
127 }
128 let target1 = color1.to_alpha_color().premultiply();
129 let end_color = target1;
130 GradientIter {
131 interpolator,
132 tolerance,
133 t0: 0,
134 dt: 0.0,
135 target0,
136 target1,
137 end_color,
138 }
139}
140
141impl<CS: ColorSpace> Iterator for GradientIter<CS> {
142 type Item = (f32, PremulColor<CS>);
143
144 fn next(&mut self) -> Option<Self::Item> {
145 if self.dt == 0.0 {
146 self.dt = 1.0;
147 return Some((0.0, self.target0));
148 }
149 let t0 = self.t0 as f32 * self.dt;
150 if t0 == 1.0 {
151 return None;
152 }
153 loop {
154 // compute midpoint color
155 let midpoint = self.interpolator.eval(t0 + 0.5 * self.dt);
156 let midpoint_oklab: PremulColor<Oklab> = midpoint.to_alpha_color().premultiply();
157 let approx = self.target0.lerp_rect(self.target1, 0.5);
158 let error = midpoint_oklab.difference(approx.convert());
159 if error <= self.tolerance {
160 let t1 = t0 + self.dt;
161 self.t0 += 1;
162 let shift = self.t0.trailing_zeros();
163 self.t0 >>= shift;
164 self.dt *= (1 << shift) as f32;
165 self.target0 = self.target1;
166 let new_t1 = t1 + self.dt;
167 if new_t1 < 1.0 {
168 self.target1 = self
169 .interpolator
170 .eval(new_t1)
171 .to_alpha_color()
172 .premultiply();
173 } else {
174 self.target1 = self.end_color;
175 }
176 return Some((t1, self.target0));
177 }
178 self.t0 *= 2;
179 self.dt *= 0.5;
180 self.target1 = midpoint.to_alpha_color().premultiply();
181 }
182 }
183}