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()
    })
}