Skip to main content

ass_renderer/backends/coverage/
mod.rs

1//! A8 coverage tiles for the software backend.
2//!
3//! A text layer's fill and outline are rasterized to 8-bit coverage once, then
4//! composited with the current colour/alpha at the current screen position. This
5//! separates the expensive vector rasterization (which depends only on the
6//! glyph *geometry*) from the cheap per-frame compositing (which carries colour,
7//! alpha and position). Layers whose geometry is unchanged between frames — the
8//! common animated case (`\move`, `\fad`, colour `\t`, karaoke) — can then reuse
9//! a cached tile and only re-composite, the way libass does, instead of
10//! re-rasterizing every frame.
11
12#[cfg(feature = "nostd")]
13use alloc::{sync::Arc, vec, vec::Vec};
14#[cfg(not(feature = "nostd"))]
15use std::sync::Arc;
16
17use tiny_skia::{Path, PathSegment};
18
19use crate::backends::raster::Rasterizer;
20
21mod blend;
22mod composite;
23
24pub use composite::{composite, composite_bitmap};
25
26/// An 8-bit coverage tile: `width * height` alpha samples, row-major.
27///
28/// The coverage is `Arc`-shared so a cached tile can be emitted as a
29/// [`RenderBitmap`] (or composited) without copying the buffer.
30#[derive(Clone)]
31pub struct CoverageTile {
32    /// Tile width in pixels.
33    pub width: u32,
34    /// Tile height in pixels.
35    pub height: u32,
36    /// `width * height` coverage bytes (0 = empty, 255 = fully covered).
37    pub data: Arc<Vec<u8>>,
38}
39
40/// A positioned bitmap — the renderer's libass-`ASS_Image`-style output unit. A
41/// frame is a list of these; the caller (or [`composite_bitmap`]) blends them.
42///
43/// The common case is `Coverage` (A8 + one colour): producing it from a cached
44/// tile is an `Arc` clone, so geometry-static animated layers cost almost nothing
45/// per frame. Complex effects that mix colours within a layer (blur, swept
46/// karaoke, clip) are pre-composited into an `Rgba` tile.
47#[derive(Clone)]
48pub enum RenderBitmap {
49    /// An 8-bit coverage mask plus a single straight RGBA colour.
50    Coverage {
51        /// Bitmap width in pixels.
52        width: u32,
53        /// Bitmap height in pixels.
54        height: u32,
55        /// `width * height` A8 coverage bytes.
56        coverage: Arc<Vec<u8>>,
57        /// Destination x of the top-left, in frame pixels.
58        x: i32,
59        /// Destination y of the top-left, in frame pixels.
60        y: i32,
61        /// Straight (non-premultiplied) RGBA colour applied through the coverage.
62        color: [u8; 4],
63    },
64    /// A pre-composited premultiplied-RGBA tile (`width * height * 4` bytes).
65    Rgba {
66        /// Bitmap width in pixels.
67        width: u32,
68        /// Bitmap height in pixels.
69        height: u32,
70        /// `width * height * 4` premultiplied RGBA bytes.
71        pixels: Arc<Vec<u8>>,
72        /// Destination x of the top-left, in frame pixels.
73        x: i32,
74        /// Destination y of the top-left, in frame pixels.
75        y: i32,
76    },
77}
78
79impl CoverageTile {
80    /// Rasterize a screen-space `path` to a coverage tile.
81    ///
82    /// Returns the tile plus the integer `(x, y)` at which it must be
83    /// composited. The path's sub-pixel position is baked into the coverage, so
84    /// compositing at the returned integer offset reproduces the anti-aliasing
85    /// of a direct fill at the original position. Returns `None` for an empty or
86    /// unrasterizable path.
87    #[must_use]
88    pub fn rasterize(path: &Path) -> Option<(Self, i32, i32)> {
89        let bounds = path.bounds();
90        // Pad by one pixel so anti-aliased edges are not clipped at the border.
91        let min_x = bounds.left().floor() as i32 - 1;
92        let min_y = bounds.top().floor() as i32 - 1;
93        let max_x = bounds.right().ceil() as i32 + 1;
94        let max_y = bounds.bottom().ceil() as i32 + 1;
95        let width = u32::try_from((max_x - min_x).max(1)).ok()?;
96        let height = u32::try_from((max_y - min_y).max(1)).ok()?;
97
98        // Feed the path's contours to the in-house scanline rasterizer, in tile
99        // coordinates (origin at the padded bbox corner). Each contour is closed
100        // (back to its start) so non-zero-winding coverage is correct.
101        let ox = min_x as f32;
102        let oy = min_y as f32;
103        let mut raster = Rasterizer::new(width as usize, height as usize);
104        let mut start = (0.0_f32, 0.0_f32);
105        let mut cur = (0.0_f32, 0.0_f32);
106        let mut open = false;
107        for segment in path.segments() {
108            match segment {
109                PathSegment::MoveTo(p) => {
110                    if open {
111                        raster.line(cur.0, cur.1, start.0, start.1);
112                    }
113                    start = (p.x - ox, p.y - oy);
114                    cur = start;
115                    open = true;
116                }
117                PathSegment::LineTo(p) => {
118                    let next = (p.x - ox, p.y - oy);
119                    raster.line(cur.0, cur.1, next.0, next.1);
120                    cur = next;
121                }
122                PathSegment::QuadTo(c, p) => {
123                    let cc = (c.x - ox, c.y - oy);
124                    let next = (p.x - ox, p.y - oy);
125                    raster.quad(cur.0, cur.1, cc.0, cc.1, next.0, next.1);
126                    cur = next;
127                }
128                PathSegment::CubicTo(c1, c2, p) => {
129                    let a = (c1.x - ox, c1.y - oy);
130                    let b = (c2.x - ox, c2.y - oy);
131                    let next = (p.x - ox, p.y - oy);
132                    raster.cubic(cur.0, cur.1, a.0, a.1, b.0, b.1, next.0, next.1);
133                    cur = next;
134                }
135                PathSegment::Close => {
136                    raster.line(cur.0, cur.1, start.0, start.1);
137                    cur = start;
138                }
139            }
140        }
141        if open {
142            raster.line(cur.0, cur.1, start.0, start.1);
143        }
144
145        Some((
146            Self {
147                width,
148                height,
149                data: Arc::new(raster.finish()),
150            },
151            min_x,
152            min_y,
153        ))
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::{composite, Arc, CoverageTile};
160
161    #[test]
162    fn composite_blends_known_pixel() {
163        // 1x1 tile, half coverage, opaque red over opaque black.
164        let tile = CoverageTile {
165            width: 1,
166            height: 1,
167            data: Arc::new(vec![128]),
168        };
169        let mut dst = vec![0u8, 0, 0, 255]; // premultiplied black, opaque
170        composite(&mut dst, 1, 1, &tile, 0, 0, [255, 0, 0, 255]);
171        // src alpha = 255*128/255 = 128; inv = 127.
172        // r = 128 + 0; a = 128 + 255*127/255 = 128 + 127 = 255.
173        assert_eq!(dst[0], 128, "red");
174        assert_eq!(dst[1], 0, "green");
175        assert_eq!(dst[2], 0, "blue");
176        assert_eq!(dst[3], 255, "alpha");
177    }
178
179    #[test]
180    fn composite_clips_offscreen() {
181        let tile = CoverageTile {
182            width: 4,
183            height: 4,
184            data: Arc::new(vec![255; 16]),
185        };
186        let mut dst = vec![0u8; 2 * 2 * 4];
187        // Place mostly off the top-left; only the bottom-right tile pixel lands.
188        composite(&mut dst, 2, 2, &tile, -3, -3, [10, 20, 30, 255]);
189        assert_eq!(&dst[0..3], &[10, 20, 30], "the one in-bounds pixel blended");
190        assert_eq!(&dst[4..8], &[0, 0, 0, 0], "neighbours untouched");
191    }
192}