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}