Skip to main content

ass_renderer/backends/software/
mod.rs

1//! Software (CPU) rendering backend using tiny-skia
2
3#[cfg(feature = "nostd")]
4use alloc::{boxed::Box, format, sync::Arc, vec::Vec};
5#[cfg(not(feature = "nostd"))]
6use std::{boxed::Box, sync::Arc, vec::Vec};
7
8use crate::backends::{BackendFeature, BackendType, RenderBackend};
9use crate::pipeline::{IntermediateLayer, Pipeline, SoftwarePipeline};
10use crate::renderer::RenderContext;
11use crate::utils::{DirtyRegion, RenderError};
12use tiny_skia::Pixmap;
13
14mod cache;
15mod dirty;
16mod text;
17mod vector;
18#[cfg(not(feature = "nostd"))]
19use cache::{DIRTY_BBOX, EMIT_SINK};
20#[cfg(not(feature = "nostd"))]
21use dirty::{clear_region, crop_pixmap};
22
23/// Software rendering backend using tiny-skia
24pub struct SoftwareBackend {
25    pixmap: Pixmap,
26    font_database: Arc<fontdb::Database>,
27    glyph_renderer: crate::pipeline::shaping::GlyphRenderer,
28    /// Reused scratch pixmap into which a vector-path layer is rendered when
29    /// collecting a bitmap list (`render_to_bitmaps`), then cropped to a tile.
30    #[cfg(not(feature = "nostd"))]
31    scratch: Pixmap,
32    #[cfg(feature = "backend-metrics")]
33    metrics: super::BackendMetrics,
34}
35
36impl SoftwareBackend {
37    /// Create a new software backend
38    pub fn new(context: &RenderContext) -> Result<Self, RenderError> {
39        let pixmap =
40            Pixmap::new(context.width(), context.height()).ok_or(RenderError::InvalidDimensions)?;
41
42        // Share the process-wide, lazily-loaded system font database. A fresh
43        // backend is built every frame, so re-scanning system fonts here (the old
44        // behaviour) dominated frame time; cloning the shared Arc is ~free.
45        #[cfg(not(feature = "nostd"))]
46        let font_database = crate::pipeline::font_loader::shared_system_fonts();
47        #[cfg(feature = "nostd")]
48        let font_database = Arc::new(fontdb::Database::new());
49
50        #[cfg(not(feature = "nostd"))]
51        let scratch =
52            Pixmap::new(context.width(), context.height()).ok_or(RenderError::InvalidDimensions)?;
53
54        Ok(Self {
55            pixmap,
56            font_database,
57            glyph_renderer: crate::pipeline::shaping::GlyphRenderer::new(),
58            #[cfg(not(feature = "nostd"))]
59            scratch,
60            #[cfg(feature = "backend-metrics")]
61            metrics: super::BackendMetrics::new(),
62        })
63    }
64
65    /// Resize the backend pixmap
66    pub fn resize(&mut self, width: u32, height: u32) -> Result<(), RenderError> {
67        self.pixmap = Pixmap::new(width, height).ok_or(RenderError::InvalidDimensions)?;
68        #[cfg(not(feature = "nostd"))]
69        {
70            self.scratch = Pixmap::new(width, height).ok_or(RenderError::InvalidDimensions)?;
71        }
72        Ok(())
73    }
74
75    /// Render layers into a positioned bitmap list (libass `ASS_Image` style)
76    /// instead of compositing into a frame buffer.
77    ///
78    /// Coverage-path layers emit cheap A8 [`RenderBitmap::Coverage`] tiles (an
79    /// `Arc` clone of the cached coverage); vector-path layers (blur, swept
80    /// karaoke, clip, drawings) are rendered into a scratch pixmap and cropped to
81    /// an [`RenderBitmap::Rgba`] tile. This skips the full-frame clear and the
82    /// final copy entirely — the caller (or a GPU) composites the list.
83    #[cfg(not(feature = "nostd"))]
84    fn render_to_bitmaps(
85        &mut self,
86        layers: &[IntermediateLayer],
87        context: &RenderContext,
88    ) -> Result<Vec<crate::backends::coverage::RenderBitmap>, RenderError> {
89        if self.pixmap.width() != context.width() || self.pixmap.height() != context.height() {
90            self.resize(context.width(), context.height())?;
91        }
92
93        // The scratch starts (and stays) clear; only vector-path layers draw into
94        // it, after which it is cropped and cleared again. Coverage-path layers
95        // emit into the sink and never touch it — so we avoid a per-layer clear
96        // and full-frame scan, which would dwarf the bitmap emit.
97        self.scratch.fill(tiny_skia::Color::TRANSPARENT);
98        let mut out = Vec::new();
99        for layer in layers {
100            EMIT_SINK.with(|sink| *sink.borrow_mut() = Some(Vec::new()));
101            DIRTY_BBOX.with(|b| *b.borrow_mut() = None);
102            std::mem::swap(&mut self.pixmap, &mut self.scratch);
103            let result = self.composite_layer(layer, context);
104            std::mem::swap(&mut self.pixmap, &mut self.scratch);
105            result?;
106
107            let coverage = EMIT_SINK.with(|sink| sink.borrow_mut().take().unwrap_or_default());
108            if coverage.is_empty() {
109                // Vector / raster / drawing layer: it rendered into the scratch.
110                let hint = DIRTY_BBOX.with(|b| *b.borrow());
111                if let Some(bitmap) = crop_pixmap(&self.scratch, hint) {
112                    // Clear only the cropped extent (all non-zero pixels lie within
113                    // it) to restore a transparent scratch for the next layer,
114                    // rather than memset-ing the whole frame per drawing.
115                    if let crate::backends::coverage::RenderBitmap::Rgba {
116                        x,
117                        y,
118                        width,
119                        height,
120                        ..
121                    } = &bitmap
122                    {
123                        clear_region(&mut self.scratch, (*x, *y, *width, *height));
124                    }
125                    out.push(bitmap);
126                }
127            } else {
128                out.extend(coverage);
129            }
130        }
131        EMIT_SINK.with(|sink| *sink.borrow_mut() = None);
132        Ok(out)
133    }
134
135    fn composite_layer(
136        &mut self,
137        layer: &IntermediateLayer,
138        _context: &RenderContext,
139    ) -> Result<(), RenderError> {
140        match layer {
141            IntermediateLayer::Raster(raster_data) => {
142                self.draw_raster_layer(raster_data)?;
143            }
144            IntermediateLayer::Vector(path_data) => {
145                self.draw_vector_layer(path_data)?;
146            }
147            IntermediateLayer::Text(text_data) => {
148                self.draw_text_layer(text_data)?;
149            }
150        }
151        Ok(())
152    }
153}
154
155/// Per-layer composite colours: `(outline, shadow (colour + screen displacement),
156/// fill)`. Outline and shadow are `None` when absent.
157#[cfg(not(feature = "nostd"))]
158type LayerColors = (Option<[u8; 4]>, Option<([u8; 4], (i32, i32))>, [u8; 4]);
159
160impl RenderBackend for SoftwareBackend {
161    fn backend_type(&self) -> BackendType {
162        BackendType::Software
163    }
164
165    fn create_pipeline(&self) -> Result<Box<dyn Pipeline>, RenderError> {
166        Ok(Box::new(SoftwarePipeline::new()))
167    }
168
169    fn composite_layers(
170        &mut self,
171        layers: &[IntermediateLayer],
172        context: &RenderContext,
173    ) -> Result<Vec<u8>, RenderError> {
174        // The backend persists across frames, so the per-glyph outline cache and
175        // font-data cache in `glyph_renderer` (and the pixmap allocation) survive
176        // instead of being rebuilt each frame. Match the pixmap to the current
177        // context size, then clear and redraw.
178        if self.pixmap.width() != context.width() || self.pixmap.height() != context.height() {
179            self.resize(context.width(), context.height())?;
180        }
181
182        self.pixmap.fill(tiny_skia::Color::TRANSPARENT);
183
184        for layer in layers {
185            self.composite_layer(layer, context)?;
186        }
187
188        Ok(self.pixmap.data().to_vec())
189    }
190
191    fn render_layers_to_bitmaps(
192        &mut self,
193        layers: &[IntermediateLayer],
194        context: &RenderContext,
195    ) -> Result<Vec<crate::backends::coverage::RenderBitmap>, RenderError> {
196        self.render_to_bitmaps(layers, context)
197    }
198
199    fn composite_layers_incremental(
200        &mut self,
201        layers: &[IntermediateLayer],
202        dirty_regions: &[DirtyRegion],
203        previous_frame: &[u8],
204        context: &RenderContext,
205    ) -> Result<Vec<u8>, RenderError> {
206        if self.pixmap.width() != context.width() || self.pixmap.height() != context.height() {
207            self.resize(context.width(), context.height())?;
208        }
209
210        // Seed from the previous frame, then redraw only the dirty regions.
211        if previous_frame.len() == self.pixmap.data().len() {
212            self.pixmap.data_mut().copy_from_slice(previous_frame);
213        } else {
214            self.pixmap.fill(tiny_skia::Color::TRANSPARENT);
215        }
216
217        // Only redraw dirty regions
218        for region in dirty_regions {
219            // TODO: Create clip mask for dirty region
220            // tiny_skia doesn't expose ClipMask publicly
221            let _ = region; // TODO: Apply clipping
222
223            // Composite layers within this region
224            for layer in layers {
225                if layer.intersects_region(region) {
226                    self.composite_layer(layer, context)?;
227                }
228            }
229        }
230
231        Ok(self.pixmap.data().to_vec())
232    }
233
234    fn supports_feature(&self, feature: BackendFeature) -> bool {
235        match feature {
236            BackendFeature::IncrementalRendering => true,
237            BackendFeature::HardwareAcceleration => false,
238            BackendFeature::ComputeShaders => false,
239            BackendFeature::AsyncRendering => false,
240        }
241    }
242
243    #[cfg(feature = "backend-metrics")]
244    fn metrics(&self) -> Option<super::BackendMetrics> {
245        Some(self.metrics.clone())
246    }
247}