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