cranpose_render_common/brush_sampling.rs
1use cranpose_ui_graphics::{Brush, Color, Point, Rect};
2
3#[doc(hidden)]
4pub fn color_to_rgba(color: Color) -> [f32; 4] {
5 [
6 color.0.clamp(0.0, 1.0),
7 color.1.clamp(0.0, 1.0),
8 color.2.clamp(0.0, 1.0),
9 color.3.clamp(0.0, 1.0),
10 ]
11}
12
13const LEVEL: f32 = 1.0 / 255.0;
14
15/// The ordered-dither offset a gradient gets at device pixel `(x, y)`, in
16/// output levels — the same value Skia adds, so a Cranpose gradient lands on
17/// the same bytes as the Jetpack Compose gradient it is standing in for.
18///
19/// Skia dithers a gradient it draws to an 8-bit target. The pattern is not
20/// noise: it is a 4x4 Bayer matrix built by striping the low two bits of the
21/// device coordinate — `(X:a1a2, Y:b1b2)` becomes `b1 a1 b2 a2` — and mapped
22/// onto `[-15/32, +15/32]`, half a level either way. Undithered, a slow ramp
23/// quantises into visible bands; dithered, the band edges break into the
24/// checkerboard every Android gradient has.
25///
26/// "A gradient", not "every gradient ever": this is the behaviour of the
27/// platforms Cranpose targets, and it has a floor. Captured on one emulator
28/// host from one APK, a Compose `radialGradient` over black comes back as
29/// `round(255*v + m/16 − 15/32)` of the analytic ramp on an android-34 Wear
30/// image at both 454x454 and 384x384, and as bare `round(255*v)` — no spatial
31/// structure, residual variance exactly the 1/12 of a plain rounding — on an
32/// android-30 one. Only the system image moves between those. So an
33/// android-30 capture is not a reference for this function and never was; a
34/// build compared against one reads as half a level wrong over most of every
35/// gradient it draws, which is exactly what it should read as.
36///
37/// ```text
38/// x→ 0 1 2 3
39/// y 0 0 4 1 5
40/// 1 8 12 9 13
41/// 2 2 6 3 7
42/// 3 10 14 11 15
43/// ```
44///
45/// Recovered from device captures rather than from memory: binning
46/// `compose − cranpose` over a radial gradient by `(x % 4, y % 4)` reproduces
47/// this matrix, and the per-cell means track `m / 16 − 15 / 32` to within the
48/// estimator's own bias.
49///
50/// Confirmed since without cranpose in the loop at all, which is the reading
51/// that matters — the difference of two builds cannot say which one carries
52/// the pattern. Against the gradient's own analytic ramp the Compose frame's
53/// residual, binned the same way, IS this table: rms 0.005 of a level per
54/// cell over 109k pixels, against 0.285 for the flat table an undithered
55/// build gives.
56///
57/// The pattern is anchored one pixel on from the coordinate handed in, and
58/// that is measured too. Evaluated at the fragment's own coordinate the
59/// dither came out as the mirror of Skia's — two dithers disagreeing is worse
60/// than one, and a captured frame went from 39.5% identical pixels against
61/// the Compose build to 23.6%. A probe shader that painted `floor(position)`
62/// straight into the frame said why: the fragment that lands on captured
63/// column N reports column N-1. The pattern is a phase as much as a matrix,
64/// so the phase is part of what has to match.
65pub fn gradient_dither_offset(x: f32, y: f32) -> f32 {
66 let px = x.floor().max(0.0) as u32 + 1;
67 let py = y.floor().max(0.0) as u32 + 1;
68 let m = ((py & 1) << 3) | ((px & 1) << 2) | (py & 2) | ((px & 2) >> 1);
69 m as f32 * (1.0 / 16.0) - (15.0 / 32.0)
70}
71
72fn dither_gradient(rgba: [f32; 4], x: f32, y: f32) -> [f32; 4] {
73 if rgba[3] <= 0.0 {
74 return rgba;
75 }
76 let offset = gradient_dither_offset(x, y) * LEVEL;
77 [
78 (rgba[0] + offset).clamp(0.0, 1.0),
79 (rgba[1] + offset).clamp(0.0, 1.0),
80 (rgba[2] + offset).clamp(0.0, 1.0),
81 rgba[3],
82 ]
83}
84
85/// The brush's color at `(x, y)` in `rect`, a gradient's dithered from
86/// `dither_origin` as Skia dithers it.
87#[doc(hidden)]
88pub fn sample_brush_rgba(
89 brush: &Brush,
90 rect: Rect,
91 x: f32,
92 y: f32,
93 dither_origin: Point,
94) -> [f32; 4] {
95 let rgba = color_to_rgba(brush.color_at(rect, Point::new(x, y)));
96 match brush {
97 Brush::Solid(_) => rgba,
98 _ => dither_gradient(rgba, x - dither_origin.x, y - dither_origin.y),
99 }
100}
101
102#[cfg(test)]
103#[path = "tests/brush_sampling_tests.rs"]
104mod tests;