Skip to main content

cranpose_render_common/
brush_sampling.rs

1use cranpose_ui_graphics::{BrushSampler, Color, Point, whole};
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    dither_offset(dither_cell(x), dither_cell(y))
67}
68
69// The column or row of the pattern a coordinate falls in, of which only
70// the two low bits count.
71fn dither_cell(coordinate: f32) -> u32 {
72    whole::floor(coordinate).max(0.0) as u32 + 1
73}
74
75fn dither_offset(column: u32, row: u32) -> f32 {
76    let m = ((row & 1) << 3) | ((column & 1) << 2) | (row & 2) | ((column & 2) >> 1);
77    m as f32 * (1.0 / 16.0) - (15.0 / 32.0)
78}
79
80fn dither_by(rgba: [f32; 4], offset: f32) -> [f32; 4] {
81    if rgba[3] <= 0.0 {
82        return rgba;
83    }
84    let offset = offset * LEVEL;
85    [
86        (rgba[0] + offset).clamp(0.0, 1.0),
87        (rgba[1] + offset).clamp(0.0, 1.0),
88        (rgba[2] + offset).clamp(0.0, 1.0),
89        rgba[3],
90    ]
91}
92
93/// The brush's color at `(x, y)`, a gradient's dithered from
94/// `dither_origin` as Skia dithers it.
95#[doc(hidden)]
96pub fn sample_brush_rgba(
97    brush: &BrushSampler<'_>,
98    x: f32,
99    y: f32,
100    dither_origin: Point,
101) -> [f32; 4] {
102    let rgba = color_to_rgba(brush.color_at(Point::new(x, y)));
103    if brush.is_solid() {
104        return rgba;
105    }
106    dither_by(
107        rgba,
108        gradient_dither_offset(x - dither_origin.x, y - dither_origin.y),
109    )
110}
111
112/// The colors a brush gives the pixels of one row or one column along which
113/// it does not change: what [`sample_brush_rgba`] gives each, worked out
114/// once for each of the four cells of the dither pattern along the line.
115#[doc(hidden)]
116pub struct DitheredLine {
117    by_cell: [[f32; 4]; 4],
118}
119
120impl DitheredLine {
121    /// The row at `y` of `brush`, which [`BrushSampler::has_uniform_rows`],
122    /// sampled at its pixel centre `x`: its colors by the column of the
123    /// pattern a pixel falls in.
124    pub fn row(brush: &BrushSampler<'_>, x: f32, y: f32, dither_origin: Point) -> Self {
125        let row = dither_cell(y - dither_origin.y);
126        Self::dithered(brush, Point::new(x, y), |column| dither_offset(column, row))
127    }
128
129    /// The column at `x` of `brush`, which
130    /// [`BrushSampler::has_uniform_columns`], sampled at its pixel centre
131    /// `y`: its colors by the row of the pattern a pixel falls in.
132    pub fn column(brush: &BrushSampler<'_>, x: f32, y: f32, dither_origin: Point) -> Self {
133        let column = dither_cell(x - dither_origin.x);
134        Self::dithered(brush, Point::new(x, y), |row| dither_offset(column, row))
135    }
136
137    fn dithered(brush: &BrushSampler<'_>, at: Point, offset: impl Fn(u32) -> f32) -> Self {
138        let rgba = color_to_rgba(brush.color_at(at));
139        if brush.is_solid() {
140            return Self { by_cell: [rgba; 4] };
141        }
142        Self {
143            by_cell: [0, 1, 2, 3].map(|cell| dither_by(rgba, offset(cell))),
144        }
145    }
146
147    /// The four colors, by the cell of the pattern a pixel falls in.
148    pub fn colors(&self) -> &[[f32; 4]; 4] {
149        &self.by_cell
150    }
151}
152
153/// An axis of the dither pattern measured from `origin`: which of its four
154/// cells along the axis each pixel falls in.
155#[doc(hidden)]
156#[derive(Clone, Copy)]
157pub struct DitherAxis {
158    origin: f32,
159    // The origin as a whole number, when it is one that a pixel's centre
160    // subtracts from exactly.
161    whole_origin: Option<i32>,
162}
163
164impl DitherAxis {
165    pub fn new(origin: f32) -> Self {
166        Self {
167            origin,
168            whole_origin: (origin.fract() == 0.0 && origin.abs() < WHOLE_COORDINATES)
169                .then_some(origin as i32),
170        }
171    }
172
173    /// The cell of the pixel at `pixel`, which [`DitheredLine::colors`]
174    /// are indexed by.
175    pub fn cell(self, pixel: i32) -> usize {
176        // A whole origin puts the centre of the pixel at
177        // `pixel - origin + 0.5`, exactly, so its cell is
178        // `max(pixel - origin, 0) + 1` without the float steps.
179        let cell = match self.whole_origin {
180            Some(origin) if pixel.abs() < WHOLE_PIXELS => (pixel - origin).max(0) as u32 + 1,
181            _ => dither_cell(pixel as f32 + 0.5 - self.origin),
182        };
183        (cell & 3) as usize
184    }
185
186    /// The cell of `start`, when the cells of the pixels `start..end` step
187    /// by one from it and start over after the fourth: from a whole origin
188    /// on, they do.
189    pub fn stepping_cell(self, start: i32, end: i32) -> Option<usize> {
190        let origin = self.whole_origin?;
191        (start >= origin && start > -WHOLE_PIXELS && end <= WHOLE_PIXELS).then(|| self.cell(start))
192    }
193}
194
195// Below this magnitude a pixel's centre and a whole origin subtract exactly.
196const WHOLE_COORDINATES: f32 = 4_194_304.0;
197const WHOLE_PIXELS: i32 = 4_194_304;
198
199#[cfg(test)]
200#[path = "tests/brush_sampling_tests.rs"]
201mod tests;