Skip to main content

dinamika_cpu/paint/
gradient.rs

1//! Gradient shaders: linear, radial and conic, plus stop sampling.
2//!
3//! Stop interpolation is linear over the sRGB (gamma-encoded) components (see
4//! the limitation in the `paint` module documentation): a light-correct
5//! gradient would require converting to linear space.
6
7use core::fmt;
8use std::sync::Arc;
9
10use crate::color::Color;
11use crate::geometry::{Point, Transform};
12
13use super::Shader;
14
15/// Number of entries in a gradient's precomputed color lookup table.
16const LUT_SIZE: usize = 256;
17
18/// A gradient's color ramp baked into a fixed-size lookup table.
19///
20/// The stops are sampled once, at construction, into [`LUT_SIZE`] evenly spaced
21/// entries. At render time looking up a color is then an O(1) indexed read with
22/// a linear blend between the two neighbouring entries, instead of the previous
23/// O(stops) linear scan on every pixel (the hot path for large gradient fills).
24///
25/// The table is shared behind an [`Arc`] so cloning a gradient is cheap.
26#[derive(Clone)]
27struct ColorRamp {
28    lut: Arc<[Color; LUT_SIZE]>,
29}
30
31impl ColorRamp {
32    /// Bakes the table from already-sorted `stops` (each gradient constructor
33    /// sorts them before calling this).
34    fn new(stops: &[GradientStop]) -> ColorRamp {
35        let mut lut = [Color::TRANSPARENT; LUT_SIZE];
36        for (i, slot) in lut.iter_mut().enumerate() {
37            let t = i as f32 / (LUT_SIZE - 1) as f32;
38            *slot = sample_stops(stops, t);
39        }
40        ColorRamp { lut: Arc::new(lut) }
41    }
42
43    /// Looks up the color at `t` (clamped to `0..=1`), linearly interpolating
44    /// between the two nearest table entries to avoid visible banding.
45    #[inline]
46    fn sample(&self, t: f32) -> Color {
47        let x = t.clamp(0.0, 1.0) * (LUT_SIZE - 1) as f32;
48        let i = x as usize; // floor; x >= 0
49        if i >= LUT_SIZE - 1 {
50            return self.lut[LUT_SIZE - 1];
51        }
52        let frac = x - i as f32;
53        let a = self.lut[i];
54        let b = self.lut[i + 1];
55        Color::from_rgba(
56            a.red() + (b.red() - a.red()) * frac,
57            a.green() + (b.green() - a.green()) * frac,
58            a.blue() + (b.blue() - a.blue()) * frac,
59            a.alpha() + (b.alpha() - a.alpha()) * frac,
60        )
61    }
62}
63
64impl fmt::Debug for ColorRamp {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        write!(f, "ColorRamp({LUT_SIZE} entries)")
67    }
68}
69
70/// How coordinates outside `0..=1` are handled for a gradient.
71#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
72pub enum SpreadMode {
73    /// Clamp to the edge colors.
74    #[default]
75    Pad,
76    /// Repeat.
77    Repeat,
78    /// Reflect.
79    Reflect,
80}
81
82/// A gradient stop.
83#[derive(Copy, Clone, Debug, PartialEq)]
84pub struct GradientStop {
85    pub position: f32,
86    pub color: Color,
87}
88
89impl GradientStop {
90    pub fn new(position: f32, color: Color) -> Self {
91        GradientStop { position: position.clamp(0.0, 1.0), color }
92    }
93}
94
95/// A linear gradient along the segment `start`–`end`.
96#[derive(Clone, Debug)]
97pub struct LinearGradient {
98    start: Point,
99    end: Point,
100    ramp: ColorRamp,
101    spread: SpreadMode,
102    inv_transform: Transform,
103}
104
105impl LinearGradient {
106    /// Creates a linear gradient. `None` if there are fewer than two stops or
107    /// the `transform` matrix is singular.
108    #[allow(clippy::new_ret_no_self)] // the constructor returns a Shader
109    pub fn new(
110        start: Point,
111        end: Point,
112        stops: Vec<GradientStop>,
113        spread: SpreadMode,
114        transform: Transform,
115    ) -> Option<Shader> {
116        if stops.len() < 2 {
117            return None;
118        }
119        let inv_transform = transform.invert()?;
120        let mut stops = stops;
121        stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap_or(std::cmp::Ordering::Equal));
122        let ramp = ColorRamp::new(&stops);
123        Some(Shader::Linear(LinearGradient { start, end, ramp, spread, inv_transform }))
124    }
125
126    /// Gradient parameter `t` (before spread) at an already-mapped point `p`.
127    #[inline]
128    fn param(&self, p: Point) -> f32 {
129        let dir = self.end - self.start;
130        let len_sq = dir.dot(dir);
131        if len_sq <= 1e-12 {
132            0.0
133        } else {
134            (p - self.start).dot(dir) / len_sq
135        }
136    }
137
138    pub(super) fn color_at(&self, p: Point) -> Color {
139        let p = self.inv_transform.map_point(p);
140        self.ramp.sample(apply_spread(self.param(p), self.spread))
141    }
142
143    /// Shades a horizontal run of `len` pixels starting at pixel `(x, y)`,
144    /// appending one [`Color`] per pixel to `out`. The inverse transform is
145    /// applied once at the run start; each step then advances the mapped point
146    /// by a constant delta — see [`super::Shader::shade_span`].
147    pub(super) fn shade_span(&self, x: usize, y: usize, len: usize, out: &mut Vec<Color>) {
148        let mut p = self.inv_transform.map_point(Point::new(x as f32 + 0.5, y as f32 + 0.5));
149        let step = column_step(&self.inv_transform);
150        for _ in 0..len {
151            out.push(self.ramp.sample(apply_spread(self.param(p), self.spread)));
152            p = p + step;
153        }
154    }
155}
156
157/// A radial gradient around a center.
158#[derive(Clone, Debug)]
159pub struct RadialGradient {
160    center: Point,
161    radius: f32,
162    ramp: ColorRamp,
163    spread: SpreadMode,
164    inv_transform: Transform,
165}
166
167impl RadialGradient {
168    #[allow(clippy::new_ret_no_self)] // the constructor returns a Shader
169    pub fn new(
170        center: Point,
171        radius: f32,
172        stops: Vec<GradientStop>,
173        spread: SpreadMode,
174        transform: Transform,
175    ) -> Option<Shader> {
176        if stops.len() < 2 || !radius.is_finite() || radius <= 0.0 {
177            return None;
178        }
179        let inv_transform = transform.invert()?;
180        let mut stops = stops;
181        stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap_or(std::cmp::Ordering::Equal));
182        let ramp = ColorRamp::new(&stops);
183        Some(Shader::Radial(RadialGradient { center, radius, ramp, spread, inv_transform }))
184    }
185
186    pub(super) fn color_at(&self, p: Point) -> Color {
187        let p = self.inv_transform.map_point(p);
188        let t = (p - self.center).length() / self.radius;
189        self.ramp.sample(apply_spread(t, self.spread))
190    }
191
192    /// See [`super::Shader::shade_span`].
193    pub(super) fn shade_span(&self, x: usize, y: usize, len: usize, out: &mut Vec<Color>) {
194        let mut p = self.inv_transform.map_point(Point::new(x as f32 + 0.5, y as f32 + 0.5));
195        let step = column_step(&self.inv_transform);
196        for _ in 0..len {
197            let t = (p - self.center).length() / self.radius;
198            out.push(self.ramp.sample(apply_spread(t, self.spread)));
199            p = p + step;
200        }
201    }
202}
203
204/// A conic (sweep) gradient: the color changes by angle around the center.
205///
206/// The angle is measured from the `+X` direction clockwise (in a screen
207/// coordinate system with the Y axis pointing down), position `0` corresponds
208/// to `start_angle`, and `1` to a full turn. Outside `0..=1` the behavior is
209/// determined by `spread` (the default [`SpreadMode::Repeat`] gives a seamless
210/// ring).
211#[derive(Clone, Debug)]
212pub struct ConicGradient {
213    center: Point,
214    /// Start angle in radians.
215    start_angle: f32,
216    ramp: ColorRamp,
217    spread: SpreadMode,
218    inv_transform: Transform,
219}
220
221impl ConicGradient {
222    /// Creates a conic gradient. `start_angle` is given in degrees. `None` if
223    /// there are fewer than two stops or the `transform` matrix is singular.
224    #[allow(clippy::new_ret_no_self)] // the constructor returns a Shader
225    pub fn new(
226        center: Point,
227        start_angle: f32,
228        stops: Vec<GradientStop>,
229        spread: SpreadMode,
230        transform: Transform,
231    ) -> Option<Shader> {
232        if stops.len() < 2 {
233            return None;
234        }
235        let inv_transform = transform.invert()?;
236        let mut stops = stops;
237        stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap_or(std::cmp::Ordering::Equal));
238        let ramp = ColorRamp::new(&stops);
239        Some(Shader::Conic(ConicGradient {
240            center,
241            start_angle: start_angle.to_radians(),
242            ramp,
243            spread,
244            inv_transform,
245        }))
246    }
247
248    /// Gradient parameter `a` (before spread) at an already-mapped point `p`.
249    #[inline]
250    fn param(&self, p: Point) -> f32 {
251        let d = p - self.center;
252        // atan2 gives an angle in (-π, π]; normalize to [0, 1) from start_angle.
253        let mut a = (d.y.atan2(d.x) - self.start_angle) / (2.0 * std::f32::consts::PI);
254        a -= a.floor();
255        a
256    }
257
258    pub(super) fn color_at(&self, p: Point) -> Color {
259        let p = self.inv_transform.map_point(p);
260        self.ramp.sample(apply_spread(self.param(p), self.spread))
261    }
262
263    /// See [`super::Shader::shade_span`].
264    pub(super) fn shade_span(&self, x: usize, y: usize, len: usize, out: &mut Vec<Color>) {
265        let mut p = self.inv_transform.map_point(Point::new(x as f32 + 0.5, y as f32 + 0.5));
266        let step = column_step(&self.inv_transform);
267        for _ in 0..len {
268            out.push(self.ramp.sample(apply_spread(self.param(p), self.spread)));
269            p = p + step;
270        }
271    }
272}
273
274/// The change in mapped (pre-image) position when stepping one pixel to the
275/// right. For an affine `inv_transform`, mapping is linear, so a unit step in
276/// the screen-space X adds the matrix's first column — letting a whole row be
277/// shaded with one `map_point` plus a running add per pixel.
278#[inline]
279fn column_step(inv_transform: &Transform) -> Point {
280    Point::new(inv_transform.sx, inv_transform.ky)
281}
282
283#[inline]
284pub(super) fn apply_spread(t: f32, spread: SpreadMode) -> f32 {
285    match spread {
286        SpreadMode::Pad => t.clamp(0.0, 1.0),
287        SpreadMode::Repeat => t - t.floor(),
288        SpreadMode::Reflect => {
289            let u = (t.abs()) % 2.0;
290            if u > 1.0 {
291                2.0 - u
292            } else {
293                u
294            }
295        }
296    }
297}
298
299/// Samples a sorted list of stops at position `t` (`0..=1`).
300///
301/// Used once per gradient to bake the [`ColorRamp`] lookup table; the per-pixel
302/// path goes through [`ColorRamp::sample`] instead. Interpolation is linear over
303/// the sRGB components (see the limitation in the module documentation): a
304/// light-correct gradient would require converting to linear space.
305fn sample_stops(stops: &[GradientStop], t: f32) -> Color {
306    if t <= stops[0].position {
307        return stops[0].color;
308    }
309    let last = &stops[stops.len() - 1];
310    if t >= last.position {
311        return last.color;
312    }
313    for w in stops.windows(2) {
314        let (a, b) = (&w[0], &w[1]);
315        if t >= a.position && t <= b.position {
316            let span = b.position - a.position;
317            let local = if span <= 1e-6 { 0.0 } else { (t - a.position) / span };
318            return Color::from_rgba(
319                a.color.red() + (b.color.red() - a.color.red()) * local,
320                a.color.green() + (b.color.green() - a.color.green()) * local,
321                a.color.blue() + (b.color.blue() - a.color.blue()) * local,
322                a.color.alpha() + (b.color.alpha() - a.color.alpha()) * local,
323            );
324        }
325    }
326    last.color
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn conic_gradient_sweeps_by_angle() {
335        let shader = ConicGradient::new(
336            Point::new(0.0, 0.0),
337            0.0,
338            vec![
339                GradientStop::new(0.0, Color::from_rgba8(0, 0, 0, 255)),
340                GradientStop::new(1.0, Color::from_rgba8(255, 255, 255, 255)),
341            ],
342            SpreadMode::Repeat,
343            Transform::identity(),
344        )
345        .unwrap();
346        // Angle 0 (along +X) — the start of the gradient, ~black.
347        assert!(shader.color_at(10.0, 0.0).red() < 0.05);
348        // Halfway (angle π) — the middle, ~gray.
349        assert!((shader.color_at(-10.0, 0.0).red() - 0.5).abs() < 0.1);
350    }
351
352    /// The batched [`Shader::shade_span`] must agree with per-pixel
353    /// [`Shader::color_at`] — including under a non-trivial transform, since the
354    /// span path replaces `map_point` with an incremental add.
355    #[test]
356    fn shade_span_matches_color_at() {
357        let stops = || {
358            vec![
359                GradientStop::new(0.0, Color::from_rgba8(255, 0, 0, 255)),
360                GradientStop::new(0.5, Color::from_rgba8(0, 255, 0, 255)),
361                GradientStop::new(1.0, Color::from_rgba8(0, 0, 255, 255)),
362            ]
363        };
364        let transform = Transform::from_translate(3.0, -2.0)
365            .pre_concat(Transform::from_rotate(25.0))
366            .pre_concat(Transform::from_scale(1.7, 0.8));
367        let shaders = [
368            LinearGradient::new(
369                Point::new(1.0, 2.0),
370                Point::new(30.0, 12.0),
371                stops(),
372                SpreadMode::Repeat,
373                transform,
374            )
375            .unwrap(),
376            RadialGradient::new(Point::new(10.0, 8.0), 14.0, stops(), SpreadMode::Reflect, transform)
377                .unwrap(),
378            ConicGradient::new(Point::new(9.0, 7.0), 30.0, stops(), SpreadMode::Repeat, transform)
379                .unwrap(),
380        ];
381
382        let (x0, y, len) = (4usize, 11usize, 24usize);
383        for shader in &shaders {
384            let mut span = Vec::new();
385            shader.shade_span(x0, y, len, &mut span);
386            assert_eq!(span.len(), len);
387            for (i, c) in span.iter().enumerate() {
388                let want = shader.color_at((x0 + i) as f32 + 0.5, y as f32 + 0.5);
389                assert!((c.red() - want.red()).abs() < 1e-4, "red @{i}: {c:?} vs {want:?}");
390                assert!((c.green() - want.green()).abs() < 1e-4, "green @{i}");
391                assert!((c.blue() - want.blue()).abs() < 1e-4, "blue @{i}");
392                assert!((c.alpha() - want.alpha()).abs() < 1e-4, "alpha @{i}");
393            }
394        }
395    }
396
397    #[test]
398    fn linear_gradient_midpoint() {
399        let shader = LinearGradient::new(
400            Point::new(0.0, 0.0),
401            Point::new(10.0, 0.0),
402            vec![
403                GradientStop::new(0.0, Color::from_rgba8(0, 0, 0, 255)),
404                GradientStop::new(1.0, Color::from_rgba8(255, 255, 255, 255)),
405            ],
406            SpreadMode::Pad,
407            Transform::identity(),
408        )
409        .unwrap();
410        let mid = shader.color_at(5.0, 0.0);
411        assert!((mid.red() - 0.5).abs() < 0.05, "{}", mid.red());
412    }
413}