Skip to main content

cranpose_render_common/
brush_sampling.rs

1use cranpose_ui_graphics::{Brush, Color, Point, 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
87fn sample_tiled_gradient_rgba(
88    t: f32,
89    tile_mode: TileMode,
90    colors: &[Color],
91    stops: Option<&[f32]>,
92    x: f32,
93    y: f32,
94) -> [f32; 4] {
95    match normalize_gradient_t(t, tile_mode) {
96        Some(sample_t) => dither_gradient(
97            color_to_rgba(interpolate_colors(colors, stops, sample_t)),
98            x,
99            y,
100        ),
101        None => color_to_rgba(TRANSPARENT),
102    }
103}
104
105#[doc(hidden)]
106pub fn sample_brush_rgba(
107    brush: &Brush,
108    rect: Rect,
109    x: f32,
110    y: f32,
111    dither_origin: Point,
112) -> [f32; 4] {
113    let dither = Point::new(x - dither_origin.x, y - dither_origin.y);
114    match brush {
115        Brush::Solid(color) => color_to_rgba(*color),
116        Brush::LinearGradient {
117            colors,
118            stops,
119            start,
120            end,
121            tile_mode,
122        } => {
123            let sx = resolve_gradient_point(rect.x, rect.width, start.x);
124            let sy = resolve_gradient_point(rect.y, rect.height, start.y);
125            let ex = resolve_gradient_point(rect.x, rect.width, end.x);
126            let ey = resolve_gradient_point(rect.y, rect.height, end.y);
127            let dx = ex - sx;
128            let dy = ey - sy;
129            let denom = (dx * dx + dy * dy).max(f32::EPSILON);
130            let t = ((x - sx) * dx + (y - sy) * dy) / denom;
131            sample_tiled_gradient_rgba(t, *tile_mode, colors, stops.as_deref(), dither.x, dither.y)
132        }
133        Brush::RadialGradient {
134            colors,
135            stops,
136            center,
137            radius,
138            tile_mode,
139        } => {
140            let cx = rect.x + center.x;
141            let cy = rect.y + center.y;
142            let radius = (*radius).max(f32::EPSILON);
143            let dx = x - cx;
144            let dy = y - cy;
145            let distance = (dx * dx + dy * dy).sqrt();
146            let t = distance / radius;
147            sample_tiled_gradient_rgba(t, *tile_mode, colors, stops.as_deref(), dither.x, dither.y)
148        }
149        Brush::SweepGradient {
150            colors,
151            stops,
152            center,
153        } => {
154            let cx = rect.x + center.x;
155            let cy = rect.y + center.y;
156            let dx = x - cx;
157            let dy = y - cy;
158            let angle = dy.atan2(dx);
159            let t = (angle / std::f32::consts::TAU + 0.5).clamp(0.0, 1.0);
160            dither_gradient(
161                color_to_rgba(interpolate_colors(colors, stops.as_deref(), t)),
162                dither.x,
163                dither.y,
164            )
165        }
166    }
167}
168
169fn resolve_gradient_point(origin: f32, extent: f32, value: f32) -> f32 {
170    if value.is_finite() {
171        origin + value
172    } else if value.is_sign_positive() {
173        origin + extent
174    } else {
175        origin
176    }
177}
178
179#[doc(hidden)]
180pub fn normalize_gradient_t(t: f32, tile_mode: TileMode) -> Option<f32> {
181    match tile_mode {
182        TileMode::Clamp => Some(t.clamp(0.0, 1.0)),
183        TileMode::Decal => {
184            if (0.0..=1.0).contains(&t) {
185                Some(t)
186            } else {
187                None
188            }
189        }
190        TileMode::Repeated => Some(t.rem_euclid(1.0)),
191        TileMode::Mirror => {
192            let wrapped = t.rem_euclid(2.0);
193            if wrapped <= 1.0 {
194                Some(wrapped)
195            } else {
196                Some(2.0 - wrapped)
197            }
198        }
199    }
200}
201
202fn interpolate_colors(colors: &[Color], stops: Option<&[f32]>, t: f32) -> Color {
203    if colors.is_empty() {
204        return TRANSPARENT;
205    }
206    if colors.len() == 1 {
207        return colors[0];
208    }
209    let clamped = t.clamp(0.0, 1.0);
210
211    if let Some(stops) = stops
212        && stops.len() == colors.len()
213    {
214        if clamped <= stops[0] {
215            return colors[0];
216        }
217        for index in 0..(stops.len() - 1) {
218            let start = stops[index];
219            let end = stops[index + 1];
220            if clamped <= end {
221                let span = (end - start).max(f32::EPSILON);
222                let frac = ((clamped - start) / span).clamp(0.0, 1.0);
223                return lerp_color(colors[index], colors[index + 1], frac);
224            }
225        }
226        return last_color(colors);
227    }
228
229    let segments = (colors.len() - 1) as f32;
230    let scaled = clamped * segments;
231    let index = scaled.floor() as usize;
232    if index >= colors.len() - 1 {
233        return last_color(colors);
234    }
235    let frac = scaled - index as f32;
236    lerp_color(colors[index], colors[index + 1], frac)
237}
238
239fn last_color(colors: &[Color]) -> Color {
240    colors.last().copied().unwrap_or(TRANSPARENT)
241}
242
243fn lerp_color(a: Color, b: Color, t: f32) -> Color {
244    let lerp = |start: f32, end: f32| start + (end - start) * t;
245    Color(
246        lerp(a.0, b.0),
247        lerp(a.1, b.1),
248        lerp(a.2, b.2),
249        lerp(a.3, b.3),
250    )
251}
252
253#[cfg(test)]
254#[path = "tests/brush_sampling_tests.rs"]
255mod tests;