Files
figcraft/crates/render/src/paint.rs
T
BFlat e32f8f2e07 M8: large images on the canvas draw from a smaller copy (#1483) (#1518)
* M8: large images on the canvas draw from a smaller copy (#1483)

An image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, cached beside the image in the same budget. Two 16000 x 10000 images (640 MB each) evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out. Moving one over the other: about 2.1 s a frame before, 11 ms at fit and 15 ms at 30% after. Exports, thumbnails and views at 100% and closer draw from the image itself. Tests in large_image_tests; timings in large_image_bench_tests (ignored).

* M8: drop the ignored large-image benchmark; its numbers are in the PR (#1483)
2026-10-29 18:14:18 +07:00

237 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Paints → tiny-skia shaders, in a node's local space (width × height box).
use std::sync::Arc;
use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImagePaint, ImageScaleMode, Paint, PaintKind};
use figcraft_doc::{Document, ImageData};
use tiny_skia::{FilterQuality, GradientStop, Pixmap, Point, Shader, SpreadMode, Transform};
pub fn ts_color(c: Color) -> tiny_skia::Color {
tiny_skia::Color::from_rgba(c.r, c.g, c.b, c.a).unwrap_or(tiny_skia::Color::BLACK)
}
pub fn ts_blend(b: BlendMode) -> tiny_skia::BlendMode {
use tiny_skia::BlendMode as T;
match b {
BlendMode::PassThrough | BlendMode::Normal => T::SourceOver,
BlendMode::Darken => T::Darken,
BlendMode::Multiply => T::Multiply,
// Plus darker has no tiny-skia mode: `composite` draws it; elsewhere the nearest one.
BlendMode::PlusDarker => T::Multiply,
BlendMode::ColorBurn => T::ColorBurn,
BlendMode::Lighten => T::Lighten,
BlendMode::Screen => T::Screen,
BlendMode::PlusLighter => T::Plus,
BlendMode::ColorDodge => T::ColorDodge,
BlendMode::Overlay => T::Overlay,
BlendMode::SoftLight => T::SoftLight,
BlendMode::HardLight => T::HardLight,
BlendMode::Difference => T::Difference,
BlendMode::Exclusion => T::Exclusion,
BlendMode::Hue => T::Hue,
BlendMode::Saturation => T::Saturation,
BlendMode::Color => T::Color,
BlendMode::Luminosity => T::Luminosity,
}
}
/// Draw `src` (top-left at `at`) onto `dst` at `opacity`, blended by `blend`.
pub fn composite(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, opacity: f32, blend: BlendMode) {
if blend == BlendMode::PlusDarker {
crate::fx::plus_darker(dst, at, src, opacity);
} else {
let paint = tiny_skia::PixmapPaint { opacity, blend_mode: ts_blend(blend), ..Default::default() };
dst.draw_pixmap(at.0, at.1, src.as_ref(), &paint, Transform::identity(), None);
}
}
fn stops(g: &Gradient, opacity: f32) -> Vec<GradientStop> {
g.sorted_stops().into_iter().map(|s| GradientStop::new(s.position, ts_color(s.color.with_alpha(s.color.a * opacity)))).collect()
}
/// The unit-box → local mapping for a gradient's handles: maps (0,0)→from, (1,0)→to,
/// (0,1)→width (relative to from), in a w×h box.
fn handle_frame(g: &Gradient, w: f64, h: f64) -> Transform {
let f = (g.from.0 * w, g.from.1 * h);
let t = (g.to.0 * w, g.to.1 * h);
let k = (g.width.0 * w, g.width.1 * h);
Transform::from_row((t.0 - f.0) as f32, (t.1 - f.1) as f32, (k.0 - f.0) as f32, (k.1 - f.1) as f32, f.0 as f32, f.1 as f32)
}
/// What a paint needs beyond a shader: an owned pixmap backing an image/diamond pattern.
pub enum Prepared {
Shader(Shader<'static>),
Pattern { pixmap: Arc<Pixmap>, mode: SpreadMode, quality: FilterQuality, opacity: f32, transform: Transform },
}
impl Prepared {
pub fn shader(&self) -> Shader<'_> {
match self {
Prepared::Shader(s) => s.clone(),
Prepared::Pattern { pixmap, mode, quality, opacity, transform } => {
tiny_skia::Pattern::new(pixmap.as_ref().as_ref(), *mode, *quality, *opacity, *transform)
}
}
}
}
fn diamond_pixmap(g: &Gradient, opacity: f32) -> Option<Arc<Pixmap>> {
const N: u32 = 256;
let mut p = Pixmap::new(N, N)?;
let half = f64::from(N) / 2.0;
for (i, px) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().enumerate() {
let x = ((i as u32 % N) as f64 + 0.5 - half) / half;
let y = ((i as u32 / N) as f64 + 0.5 - half) / half;
let t = (x.abs() + y.abs()) as f32;
let c = g.sample(t);
let a = c.a * opacity;
let q = |v: f32| (v * a * 255.0).round().clamp(0.0, 255.0) as u8;
px.copy_from_slice(&[q(c.r), q(c.g), q(c.b), (a * 255.0).round().clamp(0.0, 255.0) as u8]);
}
Some(Arc::new(p))
}
/// Build the shader for `paint` on a `w`×`h` local box shown at `scale` device pixels per unit
/// (the resolution pattern tiles are drawn at). Returns `None` when the paint draws nothing
/// (invisible, fully transparent, missing image or pattern source).
pub fn prepare(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document) -> Option<Prepared> {
prepare_at(paint, w, h, scale, doc, 0.0, false)
}
/// [`prepare`] `time` seconds into animated GIFs (0: their first frame); on the canvas (`preview`)
/// a large image shown smaller than its pixels draws from a smaller copy (see [`still`]).
pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, time: f64, preview: bool) -> Option<Prepared> {
if !paint.visible || paint.opacity <= 0.0 {
return None;
}
let op = paint.opacity.clamp(0.0, 1.0);
match &paint.kind {
PaintKind::Solid { color } => {
let c = color.with_alpha(color.a * op);
(c.a > 0.0).then(|| Prepared::Shader(Shader::SolidColor(ts_color(c))))
}
PaintKind::Gradient(g) => {
let frame = handle_frame(g, w.max(1e-6), h.max(1e-6));
let st = stops(g, op);
let shader = match g.kind {
GradientKind::Linear => {
tiny_skia::LinearGradient::new(Point::from_xy(0.0, 0.0), Point::from_xy(1.0, 0.0), st, SpreadMode::Pad, frame)
}
GradientKind::Radial => {
tiny_skia::RadialGradient::new(Point::from_xy(0.0, 0.0), 0.0, Point::from_xy(0.0, 0.0), 1.0, st, SpreadMode::Pad, frame)
}
GradientKind::Angular => tiny_skia::SweepGradient::new(Point::from_xy(0.0, 0.0), 0.0, 360.0, st, SpreadMode::Pad, frame),
GradientKind::Diamond => {
let pm = diamond_pixmap(g, op)?;
// Pattern pixel space (0..256) → unit square (-1..1) → handle frame.
let to_unit = Transform::from_row(2.0 / 256.0, 0.0, 0.0, 2.0 / 256.0, -1.0, -1.0);
return Some(Prepared::Pattern {
pixmap: pm,
mode: SpreadMode::Pad,
quality: FilterQuality::Bilinear,
opacity: 1.0,
transform: to_unit.post_concat(frame),
});
}
};
shader.map(Prepared::Shader)
}
PaintKind::Image(img) => {
let data = doc.images.get(&img.image);
let moving = crate::images::frame_at(&img.image, data, &img.filters, doc.color_profile, time);
let pm = match &moving {
Some(f) => f.clone(),
None => still(img, data, w, h, scale, doc, preview)?,
};
let transform = image_transform(img, f64::from(pm.width()), f64::from(pm.height()), w, h);
if img.scale_mode == ImageScaleMode::Tile {
return Some(Prepared::Pattern { pixmap: pm, mode: SpreadMode::Repeat, quality: FilterQuality::Bicubic, opacity: op, transform });
}
// An image covering the whole box fills it to its edges (a transparent border would
// fade them where the image is scaled up).
if covers_box(img, f64::from(pm.width()), f64::from(pm.height()), w, h) {
return Some(Prepared::Pattern { pixmap: pm, mode: SpreadMode::Pad, quality: FilterQuality::Bicubic, opacity: op, transform });
}
// Fit/Crop: a transparent 1-px border so Pad spreads transparency, not edge pixels.
// A GIF's later frames each show once per loop: not worth caching padded.
let padded = match moving {
Some(_) => Arc::new(crate::images::pad(&pm)?),
None => crate::images::padded(&img.image, &img.filters, doc.color_profile, &pm)?,
};
let transform = Transform::from_translate(-1.0, -1.0).post_concat(transform);
Some(Prepared::Pattern { pixmap: padded, mode: SpreadMode::Pad, quality: FilterQuality::Bicubic, opacity: op, transform })
}
PaintKind::Pattern(pt) => crate::pattern::prepare(doc, pt, w, h, scale, time, op),
}
}
/// The pixmap image paint `img` draws in a `w` × `h` box at `scale` device pixels per unit: on the
/// canvas (`preview`) a copy no larger than the image shows ([`crate::images::shown`]), else the
/// image itself. Tiles keep the image: their size is in its pixels.
fn still(img: &ImagePaint, data: Option<&ImageData>, w: f64, h: f64, scale: f64, doc: &Document, preview: bool) -> Option<Arc<Pixmap>> {
let (iw, ih) = data.map_or((0, 0), |d| (d.width, d.height));
if preview && img.scale_mode != ImageScaleMode::Tile && iw > 0 && ih > 0 {
let t = image_transform(img, f64::from(iw), f64::from(ih), w, h);
let side = scale * f64::from(t.sx * t.sy - t.kx * t.ky).abs().sqrt() * f64::from(iw.max(ih));
return crate::images::shown(&img.image, data, &img.filters, doc.color_profile, side);
}
crate::images::get(&img.image, data, &img.filters, doc.color_profile)
}
/// Whether image paint `img` paints its whole `w` × `h` box opaque: an image without transparent
/// pixels that covers the box (or tiles it).
/// shortcut: always looks at the image itself (exact, also on the canvas), so a large image with
/// shadows still decodes in full there; pass the canvas scale here if that turns up (#1483).
pub(crate) fn fills_box_opaque(doc: &Document, img: &ImagePaint, w: f64, h: f64) -> bool {
let Some(pm) = crate::images::get(&img.image, doc.images.get(&img.image), &img.filters, doc.color_profile) else { return false };
(img.scale_mode == ImageScaleMode::Tile || covers_box(img, f64::from(pm.width()), f64::from(pm.height()), w, h))
&& crate::images::opaque(&img.image, &pm)
}
/// Whether an image paint covers its whole `w` × `h` box: Fill always, Crop when its frame does.
fn covers_box(img: &ImagePaint, iw: f64, ih: f64, w: f64, h: f64) -> bool {
match img.scale_mode {
ImageScaleMode::Fill => true,
ImageScaleMode::Crop => {
let m = img.effective_crop(iw, ih, w, h);
let (x0, y0, x1, y1) = ImagePaint::crop_rect(m);
m[1] == 0.0 && m[2] == 0.0 && x0.min(x1) <= 0.0 && y0.min(y1) <= 0.0 && x0.max(x1) >= 1.0 && y0.max(y1) >= 1.0
}
ImageScaleMode::Fit | ImageScaleMode::Tile => false,
}
}
/// Mapping from image pixels to the node's local box for each scale mode.
pub fn image_transform(img: &ImagePaint, iw: f64, ih: f64, w: f64, h: f64) -> Transform {
let iw = iw.max(1.0);
let ih = ih.max(1.0);
let rot = img.rotation.rem_euclid(4);
let (rw, rh) = if rot % 2 == 1 { (ih, iw) } else { (iw, ih) };
// Rotation about the image centre, then translate so the rotated image's box starts at 0.
let rot_t = Transform::from_translate(-(iw / 2.0) as f32, -(ih / 2.0) as f32)
.post_rotate(90.0 * rot as f32)
.post_translate((rw / 2.0) as f32, (rh / 2.0) as f32);
let fitted = match img.scale_mode {
ImageScaleMode::Fill => {
let s = (w / rw).max(h / rh);
Transform::from_scale(s as f32, s as f32).post_translate(((w - rw * s) / 2.0) as f32, ((h - rh * s) / 2.0) as f32)
}
ImageScaleMode::Fit => {
let s = (w / rw).min(h / rh);
Transform::from_scale(s as f32, s as f32).post_translate(((w - rw * s) / 2.0) as f32, ((h - rh * s) / 2.0) as f32)
}
ImageScaleMode::Tile => {
let s = if img.scale.is_finite() && img.scale > 0.0 { img.scale } else { 1.0 };
Transform::from_scale(s as f32, s as f32)
}
ImageScaleMode::Crop => {
// crop = unit box → image uv (a, b, c, d, e, f). Inverse maps uv → unit box.
let m = img.effective_crop(iw, ih, w, h);
let uv_to_unit =
Transform::from_row(m[0] as f32, m[1] as f32, m[2] as f32, m[3] as f32, m[4] as f32, m[5] as f32).invert().unwrap_or_default();
Transform::from_scale((1.0 / rw) as f32, (1.0 / rh) as f32).post_concat(uv_to_unit).post_scale(w as f32, h as f32)
}
};
rot_t.post_concat(fitted)
}