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}