Skip to main content

cranpose_render_common/
brush_sampling.rs

1use cranpose_ui_graphics::{Brush, Color, Rect, TileMode};
2
3const TRANSPARENT: Color = Color(0.0, 0.0, 0.0, 0.0);
4
5#[doc(hidden)]
6pub fn color_to_rgba(color: Color) -> [f32; 4] {
7    [
8        color.0.clamp(0.0, 1.0),
9        color.1.clamp(0.0, 1.0),
10        color.2.clamp(0.0, 1.0),
11        color.3.clamp(0.0, 1.0),
12    ]
13}
14
15/// One output level, the step an 8-bit render target quantises to.
16const LEVEL: f32 = 1.0 / 255.0;
17
18/// The ordered-dither offset a gradient gets at device pixel `(x, y)`, in
19/// output levels — the same value Skia adds, so a Cranpose gradient lands on
20/// the same bytes as the Jetpack Compose gradient it is standing in for.
21///
22/// Skia dithers every gradient it draws to an 8-bit target. The pattern is not
23/// noise: it is a 4x4 Bayer matrix built by striping the low two bits of the
24/// device coordinate — `(X:a1a2, Y:b1b2)` becomes `b1 a1 b2 a2` — and mapped
25/// onto `[-15/32, +15/32]`, half a level either way. Undithered, a slow ramp
26/// quantises into visible bands; dithered, the band edges break into the
27/// checkerboard every Android gradient has.
28///
29/// ```text
30///  x→   0   1   2   3
31/// y 0   0   4   1   5
32///   1   8  12   9  13
33///   2   2   6   3   7
34///   3  10  14  11  15
35/// ```
36///
37/// Recovered from device captures rather than from memory: binning
38/// `compose − cranpose` over a radial gradient by `(x % 4, y % 4)` reproduces
39/// this matrix, and the per-cell means track `m / 16 − 15 / 32` to within the
40/// estimator's own bias.
41///
42/// The pattern is anchored one pixel on from the coordinate handed in, and
43/// that is measured too. Evaluated at the fragment's own coordinate the
44/// dither came out as the mirror of Skia's — two dithers disagreeing is worse
45/// than one, and a captured frame went from 39.5% identical pixels against
46/// the Compose build to 23.6%. A probe shader that painted `floor(position)`
47/// straight into the frame said why: the fragment that lands on captured
48/// column N reports column N-1. The pattern is a phase as much as a matrix,
49/// so the phase is part of what has to match.
50pub fn gradient_dither_offset(x: f32, y: f32) -> f32 {
51    let px = x.floor().max(0.0) as u32 + 1;
52    let py = y.floor().max(0.0) as u32 + 1;
53    let m = ((py & 1) << 3) | ((px & 1) << 2) | (py & 2) | ((px & 2) >> 1);
54    m as f32 * (1.0 / 16.0) - (15.0 / 32.0)
55}
56
57/// `rgba` with the gradient dither for `(x, y)` folded in.
58///
59/// Alpha is left alone — Skia perturbs only the colour channels — and the
60/// result is clamped, so a stop already at black or white cannot be pushed
61/// outside the gamut by half a level. A pixel the gradient did not paint at
62/// all is returned untouched: half a level of colour under zero alpha is
63/// invisible on screen but not in a buffer, and an empty gradient has to stay
64/// exactly transparent.
65fn dither_gradient(rgba: [f32; 4], x: f32, y: f32) -> [f32; 4] {
66    if rgba[3] <= 0.0 {
67        return rgba;
68    }
69    let offset = gradient_dither_offset(x, y) * LEVEL;
70    [
71        (rgba[0] + offset).clamp(0.0, 1.0),
72        (rgba[1] + offset).clamp(0.0, 1.0),
73        (rgba[2] + offset).clamp(0.0, 1.0),
74        rgba[3],
75    ]
76}
77
78#[doc(hidden)]
79pub fn sample_brush_rgba(brush: &Brush, rect: Rect, x: f32, y: f32) -> [f32; 4] {
80    match brush {
81        Brush::Solid(color) => color_to_rgba(*color),
82        Brush::LinearGradient {
83            colors,
84            stops,
85            start,
86            end,
87            tile_mode,
88        } => {
89            let sx = resolve_gradient_point(rect.x, rect.width, start.x);
90            let sy = resolve_gradient_point(rect.y, rect.height, start.y);
91            let ex = resolve_gradient_point(rect.x, rect.width, end.x);
92            let ey = resolve_gradient_point(rect.y, rect.height, end.y);
93            let dx = ex - sx;
94            let dy = ey - sy;
95            let denom = (dx * dx + dy * dy).max(f32::EPSILON);
96            let t = ((x - sx) * dx + (y - sy) * dy) / denom;
97            match normalize_gradient_t(t, *tile_mode) {
98                Some(sample_t) => dither_gradient(
99                    color_to_rgba(interpolate_colors(colors, stops.as_deref(), sample_t)),
100                    x,
101                    y,
102                ),
103                None => color_to_rgba(TRANSPARENT),
104            }
105        }
106        Brush::RadialGradient {
107            colors,
108            stops,
109            center,
110            radius,
111            tile_mode,
112        } => {
113            let cx = rect.x + center.x;
114            let cy = rect.y + center.y;
115            let radius = (*radius).max(f32::EPSILON);
116            let dx = x - cx;
117            let dy = y - cy;
118            let distance = (dx * dx + dy * dy).sqrt();
119            let t = distance / radius;
120            match normalize_gradient_t(t, *tile_mode) {
121                Some(sample_t) => dither_gradient(
122                    color_to_rgba(interpolate_colors(colors, stops.as_deref(), sample_t)),
123                    x,
124                    y,
125                ),
126                None => color_to_rgba(TRANSPARENT),
127            }
128        }
129        Brush::SweepGradient {
130            colors,
131            stops,
132            center,
133        } => {
134            let cx = rect.x + center.x;
135            let cy = rect.y + center.y;
136            let dx = x - cx;
137            let dy = y - cy;
138            let angle = dy.atan2(dx);
139            let t = (angle / std::f32::consts::TAU + 0.5).clamp(0.0, 1.0);
140            dither_gradient(
141                color_to_rgba(interpolate_colors(colors, stops.as_deref(), t)),
142                x,
143                y,
144            )
145        }
146    }
147}
148
149fn resolve_gradient_point(origin: f32, extent: f32, value: f32) -> f32 {
150    if value.is_finite() {
151        origin + value
152    } else if value.is_sign_positive() {
153        origin + extent
154    } else {
155        origin
156    }
157}
158
159#[doc(hidden)]
160pub fn normalize_gradient_t(t: f32, tile_mode: TileMode) -> Option<f32> {
161    match tile_mode {
162        TileMode::Clamp => Some(t.clamp(0.0, 1.0)),
163        TileMode::Decal => {
164            if (0.0..=1.0).contains(&t) {
165                Some(t)
166            } else {
167                None
168            }
169        }
170        TileMode::Repeated => Some(t.rem_euclid(1.0)),
171        TileMode::Mirror => {
172            let wrapped = t.rem_euclid(2.0);
173            if wrapped <= 1.0 {
174                Some(wrapped)
175            } else {
176                Some(2.0 - wrapped)
177            }
178        }
179    }
180}
181
182fn interpolate_colors(colors: &[Color], stops: Option<&[f32]>, t: f32) -> Color {
183    if colors.is_empty() {
184        return TRANSPARENT;
185    }
186    if colors.len() == 1 {
187        return colors[0];
188    }
189    let clamped = t.clamp(0.0, 1.0);
190
191    if let Some(stops) = stops {
192        if stops.len() == colors.len() {
193            if clamped <= stops[0] {
194                return colors[0];
195            }
196            for index in 0..(stops.len() - 1) {
197                let start = stops[index];
198                let end = stops[index + 1];
199                if clamped <= end {
200                    let span = (end - start).max(f32::EPSILON);
201                    let frac = ((clamped - start) / span).clamp(0.0, 1.0);
202                    return lerp_color(colors[index], colors[index + 1], frac);
203                }
204            }
205            return last_color(colors);
206        }
207    }
208
209    let segments = (colors.len() - 1) as f32;
210    let scaled = clamped * segments;
211    let index = scaled.floor() as usize;
212    if index >= colors.len() - 1 {
213        return last_color(colors);
214    }
215    let frac = scaled - index as f32;
216    lerp_color(colors[index], colors[index + 1], frac)
217}
218
219fn last_color(colors: &[Color]) -> Color {
220    colors.last().copied().unwrap_or(TRANSPARENT)
221}
222
223fn lerp_color(a: Color, b: Color, t: f32) -> Color {
224    let lerp = |start: f32, end: f32| start + (end - start) * t;
225    Color(
226        lerp(a.0, b.0),
227        lerp(a.1, b.1),
228        lerp(a.2, b.2),
229        lerp(a.3, b.3),
230    )
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use cranpose_ui_graphics::Point;
237
238    fn sample_rect() -> Rect {
239        Rect {
240            x: 0.0,
241            y: 0.0,
242            width: 100.0,
243            height: 40.0,
244        }
245    }
246
247    #[test]
248    fn empty_gradient_samples_transparent_instead_of_panicking() {
249        let brush =
250            Brush::linear_gradient_range(Vec::new(), Point::new(0.0, 0.0), Point::new(100.0, 0.0));
251        assert_eq!(
252            sample_brush_rgba(&brush, sample_rect(), 50.0, 10.0),
253            [0.0, 0.0, 0.0, 0.0]
254        );
255    }
256
257    #[test]
258    fn clamped_gradient_samples_last_color_at_end() {
259        let brush = Brush::linear_gradient_range(
260            vec![Color::RED, Color::BLUE],
261            Point::new(0.0, 0.0),
262            Point::new(100.0, 0.0),
263        );
264        // Past the end the stop is pure blue. The dither moves every channel
265        // by less than half a level, so the colour that reaches an 8-bit
266        // target is still pure blue whatever cell the pixel falls in.
267        for y in 0..4 {
268            for x in 0..4 {
269                let sampled = sample_brush_rgba(&brush, sample_rect(), 120.0 + x as f32, y as f32);
270                let bytes: Vec<u8> = sampled.iter().map(|c| (c * 255.0).round() as u8).collect();
271                assert_eq!(bytes, vec![0, 0, 255, 255], "cell ({x}, {y})");
272            }
273        }
274    }
275
276    #[test]
277    fn mirror_tile_mode_normalizes_across_repeated_segments() {
278        assert_eq!(normalize_gradient_t(1.25, TileMode::Mirror), Some(0.75));
279        assert_eq!(normalize_gradient_t(1.75, TileMode::Mirror), Some(0.25));
280    }
281
282    /// The matrix, spelled out. Written down rather than recomputed from the
283    /// same bit-twiddle it is checking, so a "simplification" of the striping
284    /// has something to fail against.
285    const BAYER_4X4: [[u32; 4]; 4] = [[0, 4, 1, 5], [8, 12, 9, 13], [2, 6, 3, 7], [10, 14, 11, 15]];
286
287    #[test]
288    fn the_dither_lays_out_skias_bayer_matrix() {
289        for y in 0..4u32 {
290            for x in 0..4u32 {
291                let expected = BAYER_4X4[y as usize][x as usize] as f32 / 16.0 - 15.0 / 32.0;
292                // The one-pixel phase is undone here, so this test is about the
293                // matrix and `the_dither_is_a_pixel_ahead_of_the_fragment` is
294                // about where it sits.
295                assert_eq!(
296                    gradient_dither_offset(x as f32 - 1.0 + 4.0, y as f32 - 1.0 + 4.0),
297                    expected,
298                    "cell ({x}, {y})"
299                );
300            }
301        }
302    }
303
304    #[test]
305    fn the_dither_is_a_pixel_ahead_of_the_fragment() {
306        assert_eq!(
307            gradient_dither_offset(4.0, 4.0),
308            gradient_dither_offset(5.0 - 1.0, 5.0 - 1.0),
309        );
310        assert_eq!(
311            gradient_dither_offset(3.0, 3.0),
312            BAYER_4X4[0][0] as f32 / 16.0 - 15.0 / 32.0,
313        );
314    }
315
316    #[test]
317    fn the_dither_repeats_every_four_pixels_and_never_moves_a_whole_level() {
318        for y in 0..16u32 {
319            for x in 0..16u32 {
320                assert_eq!(
321                    gradient_dither_offset(x as f32, y as f32),
322                    gradient_dither_offset((x % 4) as f32, (y % 4) as f32),
323                );
324            }
325        }
326        let offsets: Vec<f32> = (0..4)
327            .flat_map(|y| (0..4).map(move |x| gradient_dither_offset(x as f32, y as f32)))
328            .collect();
329        assert!(offsets.iter().all(|offset| offset.abs() < 0.5));
330        // Half a level either way, and centred: the dither must not shift a
331        // gradient's average brightness, only break up where it steps.
332        let mean = offsets.iter().sum::<f32>() / offsets.len() as f32;
333        assert!(mean.abs() < 1e-6, "mean offset {mean}");
334    }
335
336    #[test]
337    fn a_solid_brush_is_left_alone() {
338        let brush = Brush::Solid(Color(0.25, 0.5, 0.75, 1.0));
339        for y in 0..4 {
340            for x in 0..4 {
341                assert_eq!(
342                    sample_brush_rgba(&brush, sample_rect(), x as f32, y as f32),
343                    [0.25, 0.5, 0.75, 1.0],
344                );
345            }
346        }
347    }
348
349    #[test]
350    fn the_dither_moves_a_flat_gradient_off_one_value_onto_two() {
351        // A ramp so slow that a whole 4x4 block samples the same colour is
352        // exactly where undithered output bands. Rounded to bytes, the block
353        // has to come out as two neighbouring levels, not one flat one.
354        let grey = 100.4 / 255.0;
355        let brush = Brush::linear_gradient_range(
356            vec![Color(grey, grey, grey, 1.0), Color(grey, grey, grey, 1.0)],
357            Point::new(0.0, 0.0),
358            Point::new(100.0, 0.0),
359        );
360        let mut levels = std::collections::BTreeSet::new();
361        for y in 0..4 {
362            for x in 0..4 {
363                let sampled = sample_brush_rgba(&brush, sample_rect(), x as f32, y as f32);
364                levels.insert((sampled[0] * 255.0).round() as u8);
365            }
366        }
367        assert_eq!(levels.into_iter().collect::<Vec<_>>(), vec![100, 101]);
368    }
369}