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