//! tiny-skia (software) render backend for guiduck scenes. //! //! Renders a fragment tree into an owned CPU pixmap. Needs no GPU or display, //! which makes it the backend for golden-image tests and the fallback for //! machines without usable GPU acceleration; the platform shell can present //! the pixmap via softbuffer. pub mod convert; pub mod damage; pub mod diff; pub mod glyph; pub mod golden; use guiduck_scene::geom::{Affine, Rect}; use guiduck_scene::paint::{Brush, Color, ImageBrush}; use guiduck_scene::{ DisplayItem, FragmentId, FragmentStore, RenderBackend, RenderError, ScopeTracker, Shape, }; use damage::{Damage, DamageTracker}; /// Nesting deeper than this is treated as a cycle in the fragment graph. pub(crate) const MAX_DEPTH: usize = 256; /// Software render backend drawing into an owned [`tiny_skia::Pixmap`]. /// /// The pixmap persists across renders: repeated renders of the same store /// repaint only what changed since the previous call (see [`damage`]), which /// is transparent to callers — the pixmap after `render` is always the /// complete frame. pub struct TinySkiaBackend { pixmap: tiny_skia::Pixmap, /// Partial repaints render here — full-viewport coordinates, so the /// rasterization is bit-identical to a full render — and only the /// damaged rects are copied into `pixmap`. scratch: tiny_skia::Pixmap, base_color: Color, /// Rasterized hinted glyphs, reused across frames. glyphs: glyph::GlyphCache, /// What the previous render drew, for incremental repaints. damage: DamageTracker, } impl TinySkiaBackend { pub fn new() -> Self { Self { // Placeholder allocations; render() resizes to the real viewport. pixmap: tiny_skia::Pixmap::new(1, 1).expect("1x1 pixmap"), scratch: tiny_skia::Pixmap::new(1, 1).expect("1x1 pixmap"), base_color: Color::WHITE, glyphs: glyph::GlyphCache::default(), damage: DamageTracker::default(), } } /// Set the background color the target is cleared to before drawing. pub fn set_base_color(&mut self, color: Color) { self.base_color = color; } /// The most recently rendered frame. pub fn pixmap(&self) -> &tiny_skia::Pixmap { &self.pixmap } } impl Default for TinySkiaBackend { fn default() -> Self { Self::new() } } impl RenderBackend for TinySkiaBackend { fn render( &mut self, root: FragmentId, store: &FragmentStore, width_px: u32, height_px: u32, scale: f64, ) -> Result<(), RenderError> { if self.pixmap.width() != width_px || self.pixmap.height() != height_px { self.pixmap = tiny_skia::Pixmap::new(width_px, height_px).ok_or_else(|| { RenderError::Backend(format!("bad target size {width_px}x{height_px}").into()) })?; self.scratch = tiny_skia::Pixmap::new(width_px, height_px) .expect("scratch matches a size that just allocated"); } let plan = self.damage.plan( root, store, width_px, height_px, scale, self.base_color, &mut self.glyphs, )?; match plan { Damage::Full => { self.pixmap.fill(convert::color(self.base_color)); let mut walker = Walker { store, base: &mut self.pixmap, glyphs: &mut self.glyphs, layers: Vec::new(), clips: Vec::new(), width: width_px, height: height_px, damage: None, }; walker.fragment(root, Affine::scale(scale), 0) } // Partial repaint: render into the full-viewport scratch pixmap // with exactly the draw calls a full render would make for the // content that can touch the damage — same coordinates, same // unmasked pipeline — then copy the damaged rects into the // persistent pixmap. Identical calls give bit-identical pixels; // anything cheaper does not (a damage *mask* takes a different // blitter with different AA rounding, and rendering translated // into a small scratch shifts f32 rasterization, which is not // translation-invariant). Damage::Partial(rects) => { if rects.is_empty() { // Nothing changed; the persistent pixmap is the frame. return Ok(()); } for rect in &rects { // Reset each damaged region to the base color; scratch // content outside the rects is stale and never copied. let cleared = tiny_skia::Rect::from_ltrb( rect.x0 as f32, rect.y0 as f32, rect.x1 as f32, rect.y1 as f32, ) .ok_or_else(|| RenderError::Backend(format!("bad rect {rect:?}").into()))?; self.scratch.fill_rect( cleared, &tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(convert::color(self.base_color)), blend_mode: tiny_skia::BlendMode::Source, anti_alias: false, ..Default::default() }, tiny_skia::Transform::identity(), None, ); } let mut walker = Walker { store, base: &mut self.scratch, glyphs: &mut self.glyphs, layers: Vec::new(), clips: Vec::new(), width: width_px, height: height_px, damage: Some(ActiveDamage { rects: &rects, tracker: &self.damage, }), }; walker.fragment(root, Affine::scale(scale), 0)?; for rect in &rects { copy_region( &self.scratch, &mut self.pixmap, rect.x0 as u32, rect.y0 as u32, (rect.x1 - rect.x0) as u32, (rect.y1 - rect.y0) as u32, ); } Ok(()) } } } } /// Copy a `width` × `height` block of premultiplied pixels between two /// same-sized pixmaps, at the same position in both. fn copy_region( src: &tiny_skia::Pixmap, dst: &mut tiny_skia::Pixmap, x: u32, y: u32, width: u32, height: u32, ) { let stride = src.width() as usize * 4; let row_bytes = width as usize * 4; let src_data = src.data(); let dst_data = dst.data_mut(); for row in 0..height as usize { let at = (y as usize + row) * stride + x as usize * 4; dst_data[at..at + row_bytes].copy_from_slice(&src_data[at..at + row_bytes]); } } /// The damage context a partial repaint draws under. struct ActiveDamage<'a> { /// The pixel-aligned damaged rects this walk repaints, in device space. rects: &'a [Rect], /// Previous-frame records answering "can this subtree touch the damage?". tracker: &'a DamageTracker, } /// An offscreen composition target created by `PushLayer`. struct Layer { pixmap: tiny_skia::Pixmap, alpha: f32, blend: tiny_skia::BlendMode, /// Layer bounds in device space, applied as a mask when compositing. bounds: Option, } struct Walker<'a> { store: &'a FragmentStore, base: &'a mut tiny_skia::Pixmap, glyphs: &'a mut glyph::GlyphCache, layers: Vec, clips: Vec, width: u32, height: u32, /// `Some` during a partial repaint into the scratch pixmap: fragments /// whose recorded bounds miss every damaged rect are skipped. damage: Option>, } impl Walker<'_> { /// The current draw target (innermost layer, else the base pixmap) and /// clip mask, borrowed together. fn target(&mut self) -> (&mut tiny_skia::Pixmap, Option<&tiny_skia::Mask>) { let pixmap = match self.layers.last_mut() { Some(layer) => &mut layer.pixmap, None => &mut *self.base, }; (pixmap, self.clips.last()) } fn fragment(&mut self, id: FragmentId, base: Affine, depth: usize) -> Result<(), RenderError> { if depth > MAX_DEPTH { return Err(RenderError::ExcessiveDepth(id)); } let fragment = self.store.get(id).ok_or(RenderError::MissingFragment(id))?; // Skip gate: leaf items whose recorded bounds miss the damage land // only on scratch pixels that are never copied — don't pay for // rasterizing them. Scopes and children are still processed. let draw_own = match &self.damage { Some(damage) => damage.tracker.own_intersects(id, damage.rects), None => true, }; let mut transform = base; let mut transform_stack: Vec = Vec::new(); let mut scopes = ScopeTracker::default(); let unbalanced = || RenderError::UnbalancedFragment(id); for item in &fragment.items { if !scopes.apply(item) { return Err(unbalanced()); } match item { DisplayItem::Fill { shape, brush, rule } => { if !draw_own { continue; } let Some(path) = convert::path(shape) else { continue; }; self.fill_path(&path, brush, convert::fill_rule(*rule), transform)?; } DisplayItem::Stroke { shape, brush, style, } => { if !draw_own { continue; } let Some(path) = convert::path(shape) else { continue; }; let stroke = convert::stroke(style); let mut storage = None; let paint = brush_paint(brush, &mut storage)?; let (pixmap, mask) = self.target(); pixmap.stroke_path(&path, &paint, &stroke, convert::transform(transform), mask); } DisplayItem::GlyphRun(run) => { if !draw_own { continue; } let placement = glyph::RunPlacement::of(run, transform); match (&placement, glyph::solid_color(&run.brush)) { // Hinted + solid color: rasterized once, blitted at // device positions (y snapped, x subpixel-bucketed). (glyph::RunPlacement::Hinted { scale, tx, ty }, Some(color)) => { let (scale, tx, ty) = (*scale, *tx, *ty); let glyphs = &mut *self.glyphs; let pixmap = match self.layers.last_mut() { Some(layer) => &mut layer.pixmap, None => &mut *self.base, }; glyphs.draw_hinted_run( run, scale, tx, ty, color, pixmap, self.clips.last(), )?; } // Otherwise fill outlines: hinted ones land in // device space (identity transform), raw ones in // run-local space under the current transform. _ => { if let Some(path) = glyph::run_to_path(run, &placement, self.glyphs)? { let fill_transform = match placement { glyph::RunPlacement::Hinted { .. } => Affine::IDENTITY, glyph::RunPlacement::Raw => transform, }; self.fill_path( &path, &run.brush, tiny_skia::FillRule::Winding, fill_transform, )?; } } } } DisplayItem::Image { image, dest } => { if !draw_own { continue; } self.draw_image(image, *dest, transform)?; } DisplayItem::PushClip(shape) => { self.push_clip(shape, transform)?; } DisplayItem::PopClip => { self.clips.pop().map(|_| ()).ok_or_else(unbalanced)?; } DisplayItem::PushTransform(t) => { transform_stack.push(transform); transform *= *t; } DisplayItem::PopTransform => { transform = transform_stack.pop().ok_or_else(unbalanced)?; } DisplayItem::PushLayer { alpha, blend, bounds, } => { let pixmap = tiny_skia::Pixmap::new(self.width, self.height) .ok_or_else(|| RenderError::Backend("zero-sized layer target".into()))?; let bounds = convert::path(bounds) .and_then(|p| p.transform(convert::transform(transform))); self.layers.push(Layer { pixmap, alpha: *alpha, blend: convert::blend_mode(*blend), bounds, }); } DisplayItem::PopLayer => { let layer = self.layers.pop().ok_or_else(unbalanced)?; self.composite_layer(layer); } DisplayItem::Child { transform: placement, fragment, } => { // Skip gate: a subtree whose recorded bounds miss the // damage cannot contribute pixels to this repaint. if let Some(damage) = &self.damage && !damage.tracker.subtree_intersects(*fragment, damage.rects) { continue; } self.fragment(*fragment, transform * *placement, depth + 1)?; } } } if !scopes.is_closed() || !transform_stack.is_empty() { return Err(unbalanced()); } Ok(()) } fn fill_path( &mut self, path: &tiny_skia::Path, brush: &Brush, rule: tiny_skia::FillRule, transform: Affine, ) -> Result<(), RenderError> { let mut storage = None; let paint = brush_paint(brush, &mut storage)?; let (pixmap, mask) = self.target(); pixmap.fill_path(path, &paint, rule, convert::transform(transform), mask); Ok(()) } fn draw_image( &mut self, image: &ImageBrush, dest: guiduck_scene::geom::Rect, transform: Affine, ) -> Result<(), RenderError> { let (w, h) = (image.image.width, image.image.height); if w == 0 || h == 0 || dest.width() <= 0.0 || dest.height() <= 0.0 { return Ok(()); } let pixels = convert::image_to_pixmap(&image.image)?; // Map the image's natural pixel grid into the destination rectangle; // the painter transform then maps local space to the device. let fit = Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w as f64, dest.height() / h as f64); let shader = tiny_skia::Pattern::new( pixels.as_ref(), convert::spread_mode(image.sampler.x_extend), convert::filter_quality(image.sampler.quality), image.sampler.alpha, convert::transform(fit), ); let paint = tiny_skia::Paint { shader, ..Default::default() }; let Some(dest_path) = convert::path(&Shape::Rect(dest)) else { return Ok(()); }; let (pixmap, mask) = self.target(); pixmap.fill_path( &dest_path, &paint, tiny_skia::FillRule::Winding, convert::transform(transform), mask, ); Ok(()) } fn push_clip(&mut self, shape: &Shape, transform: Affine) -> Result<(), RenderError> { let ts = convert::transform(transform); let mut mask = match self.clips.last() { Some(top) => top.clone(), None => tiny_skia::Mask::new(self.width, self.height) .ok_or_else(|| RenderError::Backend("zero-sized clip mask".into()))?, }; match convert::path(shape) { Some(path) if self.clips.is_empty() => { mask.fill_path(&path, tiny_skia::FillRule::Winding, true, ts); } Some(path) => { mask.intersect_path(&path, tiny_skia::FillRule::Winding, true, ts); } // A degenerate clip shape clips everything away. None => mask.clear(), } self.clips.push(mask); Ok(()) } fn composite_layer(&mut self, layer: Layer) { let bounds_mask = layer.bounds.map(|path| { let mut mask = tiny_skia::Mask::new(self.width, self.height).expect("target-sized mask"); mask.fill_path( &path, tiny_skia::FillRule::Winding, true, tiny_skia::Transform::identity(), ); mask }); let paint = tiny_skia::PixmapPaint { opacity: layer.alpha.clamp(0.0, 1.0), blend_mode: layer.blend, quality: tiny_skia::FilterQuality::Nearest, }; let (pixmap, _) = self.target(); pixmap.draw_pixmap( 0, 0, layer.pixmap.as_ref(), &paint, tiny_skia::Transform::identity(), bounds_mask.as_ref(), ); } } /// Build a tiny-skia paint for a brush. Image brushes decode into `storage`, /// which must outlive the returned paint. fn brush_paint<'p>( brush: &'p Brush, storage: &'p mut Option, ) -> Result, RenderError> { let shader = match brush { Brush::Image(image) => { *storage = Some(convert::image_to_pixmap(&image.image)?); tiny_skia::Pattern::new( storage.as_ref().expect("just stored").as_ref(), convert::spread_mode(image.sampler.x_extend), convert::filter_quality(image.sampler.quality), image.sampler.alpha, tiny_skia::Transform::identity(), ) } other => convert::shader(other, Affine::IDENTITY)?, }; Ok(tiny_skia::Paint { shader, ..Default::default() }) }