Skip to main content

ass_renderer/backends/raster/
mod.rs

1//! Scanline coverage rasterizer: glyph outline → 8-bit coverage bitmap.
2//!
3//! This is the in-house replacement for tiny-skia's general path filler on the
4//! hot text path. It uses the signed-area accumulation technique (as in
5//! FreeType's smooth rasterizer and Raph Levien's font-rs): each edge deposits
6//! signed area/coverage deltas into an accumulation buffer, and a single per-row
7//! prefix sum yields exact-area anti-aliased coverage. Fill uses the non-zero
8//! winding rule (`coverage = min(|accumulated|, 1)`), which is what glyph
9//! outlines need.
10//!
11//! Working in coverage (A8) rather than RGBA lets a layer's geometry be
12//! rasterized once and cached, then colourized and blended cheaply every frame —
13//! the basis for beating libass on animation-heavy content.
14
15mod span;
16
17#[cfg(feature = "nostd")]
18use alloc::{vec, vec::Vec};
19
20use span::convert_span;
21
22/// Accumulation-buffer scanline rasterizer producing an A8 coverage bitmap of a
23/// fixed `width × height`.
24pub struct Rasterizer {
25    width: usize,
26    height: usize,
27    /// Row stride of `acc`, two cells wider than `width` so the `+1` cover writes
28    /// at the right edge land in guard cells instead of the next row.
29    stride: usize,
30    acc: Vec<f32>,
31    /// Per-row touched-column span `[row_min, row_max]`. `finish` resolves only
32    /// this span and leaves the (zero-initialised) rest untouched, so the prefix
33    /// sum runs over the live columns instead of the whole — usually mostly
34    /// empty — tile. `row_min[y] > row_max[y]` marks an untouched row.
35    row_min: Vec<u32>,
36    row_max: Vec<u32>,
37}
38
39impl Rasterizer {
40    /// Create a rasterizer for a `width × height` coverage bitmap.
41    #[must_use]
42    pub fn new(width: usize, height: usize) -> Self {
43        let stride = width + 2;
44        let rows = height.max(1);
45        Self {
46            width,
47            height,
48            stride,
49            acc: vec![0.0; stride * rows],
50            row_min: vec![width as u32; rows],
51            row_max: vec![0; rows],
52        }
53    }
54
55    /// Add a straight edge between two points in pixel coordinates.
56    ///
57    /// Edges may be supplied in any order; their signed contributions combine so
58    /// that a closed contour yields correct non-zero-winding coverage.
59    pub fn line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) {
60        // Orient the edge top-to-bottom; `dir` records the original winding.
61        let (dir, mut x, top_y, bot_x, bot_y) = if y0 <= y1 {
62            (1.0_f32, x0, y0, x1, y1)
63        } else {
64            (-1.0_f32, x1, y1, x0, y0)
65        };
66        let dy_total = bot_y - top_y;
67        if dy_total <= 0.0 {
68            return; // horizontal edge: no vertical coverage
69        }
70        let dxdy = (bot_x - x) / dy_total;
71
72        // Clip to the top of the bitmap.
73        let mut y_top = top_y;
74        if y_top < 0.0 {
75            x += -y_top * dxdy;
76            y_top = 0.0;
77        }
78        let y_bot = bot_y.min(self.height as f32);
79        if y_bot <= y_top {
80            return;
81        }
82
83        let w = self.width as f32;
84        let first_row = y_top.floor() as usize;
85        let last_row = (y_bot.ceil() as usize).min(self.height);
86        for y in first_row..last_row {
87            let line_start = y * self.stride;
88            let dy = ((y + 1) as f32).min(y_bot) - (y as f32).max(y_top);
89            if dy <= 0.0 {
90                continue;
91            }
92            let x_next = x + dxdy * dy;
93            let d = dy * dir;
94
95            // Clamp the x-span into the bitmap; with correct padding this never
96            // triggers, but it keeps a degenerate transform from indexing OOB.
97            let xc = x.clamp(0.0, w);
98            let xnc = x_next.clamp(0.0, w);
99            let (xa, xb) = if xc < xnc { (xc, xnc) } else { (xnc, xc) };
100
101            let xa_floor = xa.floor();
102            let x0i = xa_floor as usize;
103            let x1ceil = xb.ceil();
104            let x1i = x1ceil as usize;
105
106            // Record the touched column span for this row. The single-column
107            // branch always writes `x0i` and `x0i + 1`; the multi-column branch
108            // writes through `x1i`. `finish` resolves only `[row_min, row_max]`.
109            let rmin = &mut self.row_min[y];
110            *rmin = (*rmin).min(x0i as u32);
111            let rmax = &mut self.row_max[y];
112            *rmax = (*rmax).max(x1i.max(x0i + 1) as u32);
113
114            if x1i <= x0i + 1 {
115                // The edge stays within a single pixel column.
116                let xmf = 0.5 * (xc + xnc) - xa_floor;
117                let i = line_start + x0i;
118                self.acc[i] += d * (1.0 - xmf);
119                self.acc[i + 1] += d * xmf;
120            } else {
121                // The edge spans several columns: split d into the entry-cell
122                // area, a constant run, and the exit-cell area (trapezoid areas).
123                let s = (xb - xa).recip();
124                let x0f = xa - xa_floor;
125                let a_m = 1.0 - x0f;
126                let am = 0.5 * s * a_m * a_m;
127                let x1f = xb - x1ceil + 1.0;
128                let bm = 0.5 * s * x1f * x1f;
129
130                let i0 = line_start + x0i;
131                self.acc[i0] += d * am;
132                if x1i == x0i + 2 {
133                    self.acc[i0 + 1] += d * (1.0 - am - bm);
134                } else {
135                    let a0 = s * (1.5 - x0f);
136                    self.acc[i0 + 1] += d * (a0 - am);
137                    for xi in (x0i + 2)..(x1i - 1) {
138                        self.acc[line_start + xi] += d * s;
139                    }
140                    let a1 = a0 + ((x1i - x0i) as f32 - 3.0) * s;
141                    self.acc[line_start + x1i - 1] += d * (1.0 - a1 - bm);
142                }
143                self.acc[line_start + x1i] += d * bm;
144            }
145
146            x = x_next;
147        }
148    }
149
150    /// Add a quadratic Bézier, flattened to line segments.
151    pub fn quad(&mut self, x0: f32, y0: f32, cx: f32, cy: f32, x1: f32, y1: f32) {
152        // Subdivisions from the control-point deviation (~0.2px flatness).
153        let dev = ((x0 - 2.0 * cx + x1).powi(2) + (y0 - 2.0 * cy + y1).powi(2)).sqrt();
154        let n = (1 + (dev / 0.8).sqrt() as usize).clamp(1, 64);
155        let (mut px, mut py) = (x0, y0);
156        for i in 1..=n {
157            let t = i as f32 / n as f32;
158            let mt = 1.0 - t;
159            let a = mt * mt;
160            let b = 2.0 * mt * t;
161            let c = t * t;
162            let nx = a * x0 + b * cx + c * x1;
163            let ny = a * y0 + b * cy + c * y1;
164            self.line(px, py, nx, ny);
165            px = nx;
166            py = ny;
167        }
168    }
169
170    /// Add a cubic Bézier, flattened to line segments.
171    #[allow(clippy::too_many_arguments)]
172    pub fn cubic(
173        &mut self,
174        x0: f32,
175        y0: f32,
176        c1x: f32,
177        c1y: f32,
178        c2x: f32,
179        c2y: f32,
180        x1: f32,
181        y1: f32,
182    ) {
183        let d1 = ((x0 - 2.0 * c1x + c2x).powi(2) + (y0 - 2.0 * c1y + c2y).powi(2)).sqrt();
184        let d2 = ((c1x - 2.0 * c2x + x1).powi(2) + (c1y - 2.0 * c2y + y1).powi(2)).sqrt();
185        let n = (1 + ((d1 + d2) / 0.8).sqrt() as usize).clamp(1, 96);
186        let (mut px, mut py) = (x0, y0);
187        for i in 1..=n {
188            let t = i as f32 / n as f32;
189            let mt = 1.0 - t;
190            let a = mt * mt * mt;
191            let b = 3.0 * mt * mt * t;
192            let c = 3.0 * mt * t * t;
193            let e = t * t * t;
194            let nx = a * x0 + b * c1x + c * c2x + e * x1;
195            let ny = a * y0 + b * c1y + c * c2y + e * y1;
196            self.line(px, py, nx, ny);
197            px = nx;
198            py = ny;
199        }
200    }
201
202    /// Resolve the accumulation buffer into an A8 coverage bitmap
203    /// (`width * height` bytes, row-major).
204    #[must_use]
205    pub fn finish(&self) -> Vec<u8> {
206        let mut out = vec![0u8; self.width * self.height];
207        if self.width == 0 {
208            return out;
209        }
210        // Scratch for one row's prefix sums, reused across rows.
211        let mut psum = vec![0.0_f32; self.width];
212        for y in 0..self.height {
213            let lo = self.row_min[y] as usize;
214            // The right guard column (`width`) is never emitted, mirroring the
215            // original `0..width` scan, so clamp the span to the last real pixel.
216            let hi = (self.row_max[y] as usize).min(self.width - 1);
217            if lo > hi {
218                continue; // untouched row: stays transparent
219            }
220            let row = y * self.stride;
221            let out_row = y * self.width;
222            let psum_row = &mut psum[lo..=hi];
223            let mut sum = 0.0_f32;
224            for (p, &a) in psum_row.iter_mut().zip(&self.acc[row + lo..=row + hi]) {
225                sum += a;
226                *p = sum;
227            }
228            convert_span(psum_row, &mut out[out_row + lo..=out_row + hi]);
229        }
230        out
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::Rasterizer;
237    use tiny_skia::{FillRule, Mask, PathBuilder, Transform};
238
239    /// Fill `build` into an A8 mask with our rasterizer.
240    fn ours(width: usize, height: usize, build: impl Fn(&mut Rasterizer)) -> Vec<u8> {
241        let mut r = Rasterizer::new(width, height);
242        build(&mut r);
243        r.finish()
244    }
245
246    /// Fill the same path into an A8 mask with tiny-skia (the reference).
247    fn skia(width: u32, height: u32, path: &tiny_skia::Path) -> Vec<u8> {
248        let mut mask = Mask::new(width, height).unwrap();
249        mask.fill_path(path, FillRule::Winding, true, Transform::identity());
250        mask.data().to_vec()
251    }
252
253    /// Mean and max absolute per-pixel difference between two A8 buffers.
254    fn diff(a: &[u8], b: &[u8]) -> (f64, u8) {
255        let mut sum = 0u64;
256        let mut max = 0u8;
257        for (x, y) in a.iter().zip(b.iter()) {
258            let d = x.abs_diff(*y);
259            sum += u64::from(d);
260            max = max.max(d);
261        }
262        (sum as f64 / a.len() as f64, max)
263    }
264
265    #[test]
266    fn solid_rect_is_fully_covered() {
267        // A rectangle aligned to pixel centres should be ~255 inside.
268        let cov = ours(10, 10, |r| {
269            r.line(2.0, 2.0, 8.0, 2.0);
270            r.line(8.0, 2.0, 8.0, 8.0);
271            r.line(8.0, 8.0, 2.0, 8.0);
272            r.line(2.0, 8.0, 2.0, 2.0);
273        });
274        assert_eq!(cov[5 * 10 + 5], 255, "centre of rect must be solid");
275        assert_eq!(cov[0], 0, "outside the rect must be empty");
276    }
277
278    #[test]
279    fn matches_tiny_skia_on_rect() {
280        let mut pb = PathBuilder::new();
281        pb.move_to(2.3, 2.7);
282        pb.line_to(8.6, 2.7);
283        pb.line_to(8.6, 8.1);
284        pb.line_to(2.3, 8.1);
285        pb.close();
286        let path = pb.finish().unwrap();
287        let s = skia(12, 12, &path);
288        let o = ours(12, 12, |r| {
289            r.line(2.3, 2.7, 8.6, 2.7);
290            r.line(8.6, 2.7, 8.6, 8.1);
291            r.line(8.6, 8.1, 2.3, 8.1);
292            r.line(2.3, 8.1, 2.3, 2.7);
293        });
294        // Cross-engine AA conventions differ at edges; bound the discrepancy
295        // well below a structural error (a wrong rasterizer means mean >> 10).
296        let (mean, max) = diff(&o, &s);
297        assert!(mean < 4.0, "mean A8 diff vs tiny-skia too high: {mean}");
298        assert!(max < 40, "max A8 diff vs tiny-skia too high: {max}");
299    }
300
301    #[test]
302    fn matches_tiny_skia_on_triangle() {
303        let mut pb = PathBuilder::new();
304        pb.move_to(4.0, 1.5);
305        pb.line_to(14.2, 12.8);
306        pb.line_to(1.7, 13.3);
307        pb.close();
308        let path = pb.finish().unwrap();
309        let s = skia(16, 16, &path);
310        let o = ours(16, 16, |r| {
311            r.line(4.0, 1.5, 14.2, 12.8);
312            r.line(14.2, 12.8, 1.7, 13.3);
313            r.line(1.7, 13.3, 4.0, 1.5);
314        });
315        let (mean, max) = diff(&o, &s);
316        assert!(mean < 4.0, "mean A8 diff vs tiny-skia too high: {mean}");
317        assert!(max < 40, "max A8 diff vs tiny-skia too high: {max}");
318    }
319
320    #[test]
321    fn matches_tiny_skia_with_curves() {
322        // A rounded blob built from quadratics, in both engines.
323        let mut pb = PathBuilder::new();
324        pb.move_to(6.0, 2.0);
325        pb.quad_to(14.0, 3.0, 13.0, 10.0);
326        pb.quad_to(12.0, 17.0, 5.0, 16.0);
327        pb.quad_to(2.0, 15.0, 3.0, 8.0);
328        pb.quad_to(3.5, 3.0, 6.0, 2.0);
329        pb.close();
330        let path = pb.finish().unwrap();
331        let s = skia(18, 18, &path);
332        let o = ours(18, 18, |r| {
333            r.quad(6.0, 2.0, 14.0, 3.0, 13.0, 10.0);
334            r.quad(13.0, 10.0, 12.0, 17.0, 5.0, 16.0);
335            r.quad(5.0, 16.0, 2.0, 15.0, 3.0, 8.0);
336            r.quad(3.0, 8.0, 3.5, 3.0, 6.0, 2.0);
337        });
338        let (mean, max) = diff(&o, &s);
339        assert!(mean < 3.0, "mean A8 diff vs tiny-skia too high: {mean}");
340        assert!(max < 40, "max A8 diff vs tiny-skia too high: {max}");
341    }
342}