lib.rs
raw
//! 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<tiny_skia::Path>,
}
struct Walker<'a> {
store: &'a FragmentStore,
base: &'a mut tiny_skia::Pixmap,
glyphs: &'a mut glyph::GlyphCache,
layers: Vec<Layer>,
clips: Vec<tiny_skia::Mask>,
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<ActiveDamage<'a>>,
}
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<Affine> = 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<tiny_skia::Pixmap>,
) -> Result<tiny_skia::Paint<'p>, 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()
})
}