Files
vectorcraft/crates/render/src/pattern.rs
T
dotCooCoo f4e72fe6a5 Render: raster effects are made again when patterns, symbols, images, settings or fonts change
The renderer kept the rasters of an object's drop shadows, outer glows
and filtered content (Gaussian Blur, Feather and the Photoshop-style
filters), and drew them again while the object, the zoom and rotation
and the ink plane stayed the same. The content drawn into a raster also
reads things outside the object: patterns, symbols, brushes, images,
document, layer and color settings, the knockout a group inherits, the
fonts, and the options the frame is drawn with. When one of them
changed, a renderer that had drawn the object went on drawing the
raster it had kept, made from the old values.

Each kept raster now records what its content was drawn from: which of
those things the object reads, found by walking the object when the
raster is made, and a hash of their values then. The renderer draws
the raster only while that hash is the same in the current frame. Once
a frame, the renderer hashes the document's patterns, symbols and
images. It hashes their art and pixels by the addresses of their
shared values, and it keeps weak references to those values while a
raster made with them is kept, so no other value can take one of those
addresses. A filtered raster also records the color channels its
filters split, and under De-Interlace the raster resolution.

A temporary release test timed the inputs of 400 kept rasters at about
10 microseconds a frame.
2026-10-13 00:34:41 +07:00

393 lines
16 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.
//! Pattern fills and the pattern-editing preview.
//!
//! A pattern paint is drawn as a repeating image: the tiling's rectangular super-tile
//! ([`PatternDef::period`]) is rasterized once at the current device scale (rounded to a power of
//! two, so it is re-rasterized only when the zoom changes by more than 2×) and used as an image
//! paint with `Extend::Repeat`, with the paint transform mapping pixels → pattern space →
//! document. Tiles are cached per pattern name (and ink plane, see [`crate::ink`]) and invalidated
//! when the definition changes (art `Arc` identity, tile, tile type, overlap) — the pattern's
//! "revision".
//!
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
use std::cell::RefCell;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType};
use vectorcraft_doc::{Document, Node};
use vectorcraft_geom::{Affine, Rect, Shape};
use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
use vello_cpu::{Pixmap, RenderContext};
use crate::ink::Ink;
use crate::lru::{self, FrameCache};
use crate::{Frame, RenderOptions, Rendered, Renderer};
/// Largest super-tile raster side (pixels).
const MAX_TILE_PX: f64 = 2048.0;
struct TileEntry {
art: Vec<Arc<Node>>,
params: (Rect, TileType, Overlap),
scale: f64,
sx: f64,
sy: f64,
pixmap: Arc<Pixmap>,
}
struct PatternCache {
/// Per ink ([`Ink`] as index), each within [`lru::PATTERN_BUDGET`], least recently used first out.
tiles: [FrameCache<String, TileEntry>; 4],
renderer: Option<Box<Renderer>>,
depth: u32,
}
impl Default for PatternCache {
fn default() -> Self {
Self { tiles: std::array::from_fn(|_| FrameCache::new(lru::PATTERN_BUDGET, usize::MAX)), renderer: None, depth: 0 }
}
}
thread_local! {
static CACHE: RefCell<PatternCache> = RefCell::new(PatternCache::default());
}
fn same_art(a: &[Arc<Node>], b: &[Arc<Node>]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y))
}
/// Hash what pattern `def`'s tiling is drawn from, as the tile cache checks it: its name, the
/// addresses of its art, its tile, tile type and overlap.
pub(crate) fn hash_tiling(def: &PatternDef, h: &mut impl Hasher) {
def.name.hash(h);
def.art.iter().for_each(|a| (Arc::as_ptr(a) as usize).hash(h));
[def.tile.x0, def.tile.y0, def.tile.x1, def.tile.y1].map(f64::to_bits).hash(h);
std::mem::discriminant(&def.tile_type).hash(h);
if let TileType::BrickByRow { offset } | TileType::BrickByColumn { offset } = def.tile_type {
offset.to_bits().hash(h);
}
(def.overlap.right_in_front, def.overlap.bottom_in_front).hash(h);
}
/// Power-of-two raster scale for a pattern seen at device scale `s`, capped so the super-tile
/// stays under [`MAX_TILE_PX`].
pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 {
let s = s.clamp(1e-3, 1e3);
let p2 = 2f64.powf(s.log2().ceil());
let cap = MAX_TILE_PX / period.0.max(period.1).max(1e-6);
p2.min(cap).max(1e-3)
}
/// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap, colours as
/// `ink` paints them.
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16, ink: Ink) -> Pixmap {
let (w, h) = (w.max(1), h.max(1));
let mut ctx = crate::single_threaded_context(w, h);
let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9))
* Affine::translate(-region.origin().to_vec2());
let opts = RenderOptions::default();
r.stamp += 1;
let px_rect = Rect::new(0.0, 0.0, w as f64, h as f64);
for o in def.offsets_covering(region) {
let v = view * def.instance_xf(o);
let frame = Frame {
mt: false,
doc,
view: v,
visible: v.inverse().transform_rect_bbox(px_rect),
px: 1.0 / v.determinant().abs().sqrt().max(1e-12),
opts: &opts,
ink,
};
for a in &def.art {
r.draw_arc(&mut ctx, &frame, a);
}
}
ctx.flush();
let mut pm = Pixmap::new(w, h);
ctx.render_with(&mut pm, &mut r.resources, r.raster);
pm
}
/// Run `f` with the shared off-screen renderer (None when nested too deep: a pattern whose art
/// uses a pattern whose art uses a pattern…).
fn with_tile_renderer<T>(f: impl FnOnce(&mut Renderer) -> T) -> Option<T> {
let r = CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.depth >= 3 {
return None;
}
c.depth += 1;
Some(c.renderer.take().unwrap_or_else(|| {
let mut r = Renderer::new();
r.threads = 0;
Box::new(r)
}))
})?;
let mut r = r;
let out = f(&mut r);
CACHE.with(|c| {
let mut c = c.borrow_mut();
c.depth -= 1;
if c.renderer.is_none() {
c.renderer = Some(r);
}
});
Some(out)
}
/// Set a pattern paint on `ctx` (its current transform maps the paint's user space to pixels), as
/// frame `f` paints it.
pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, f: &Frame) -> bool {
let (doc, ink) = (f.doc, f.ink);
let missing = || ink.fixed([128, 128, 128, 255]);
let Some(def) = doc.pattern(name) else {
ctx.set_paint(missing());
return true;
};
if def.art.is_empty() {
return false;
}
let dev = *ctx.transform() * xf;
let s = dev.determinant().abs().sqrt();
let (pw, ph) = def.period();
let scale = raster_scale(s, (pw, ph));
let params = (def.tile, def.tile_type, def.overlap);
let hit = CACHE.with(|c| {
let mut c = c.borrow_mut();
let tiles = &mut c.tiles[ink as usize];
let t = tiles.tick();
tiles.get(name, t).filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy))
});
let (pm, sx, sy) = match hit {
Some(h) => h,
None => {
let w = (pw * scale).ceil().clamp(1.0, 4096.0) as u16;
let h = (ph * scale).ceil().clamp(1.0, 4096.0) as u16;
let Some(pm) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h, ink)) else {
ctx.set_paint(missing());
return true;
};
let pm = Arc::new(pm);
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
CACHE.with(|c| {
let mut c = c.borrow_mut();
let tiles = &mut c.tiles[ink as usize];
let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() }, bytes, t);
});
(pm, sx, sy)
}
};
let sampler = ImageSampler { x_extend: Extend::Repeat, y_extend: Extend::Repeat, quality: ImageQuality::Medium, alpha: 1.0 };
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
ctx.set_paint_transform(xf * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy));
true
}
/// A swatch thumbnail of pattern `name`: the tiling around the tile, fitted into `size`².
pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option<Rendered> {
let def = doc.pattern(name)?;
let (w, h) = (def.width(), def.height());
let side = w.max(h);
let region = Rect::from_center_size((w / 2.0, h / 2.0), (side, side));
let px = size.clamp(1, 1024) as u16;
let pm = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px, Ink::Display))?;
Some(Rendered { width: px as u32, height: px as u32, pixels: pm.data_as_u8_slice().to_vec() })
}
impl Renderer {
/// Pattern editing mode: draw the tile layer, its dimmed copies and the tile edge instead of
/// the document. Returns false when not in pattern editing mode.
pub(crate) fn draw_pattern_edit(&mut self, ctx: &mut RenderContext, f: &Frame) -> bool {
let Some(pe) = &f.doc.pattern_edit else { return false };
let Some(layer) = f.doc.layers.iter().find(|l| l.id == pe.layer) else { return false };
let Some(def) = f.doc.pattern(&pe.pattern) else { return false };
let children: Vec<Arc<Node>> = layer.children().cloned().unwrap_or_default();
if layer.visible && !children.is_empty() {
let dim = (def.dim_copies / 100.0).clamp(0.0, 1.0);
let comp = crate::group::Composite { opacity: dim, ..Default::default() };
self.group(ctx, f, comp, &mut |r, c, fr| {
for o in def.preview_offsets() {
let frame = Frame { view: fr.view * Affine::translate(o), visible: fr.visible - o, ..*fr };
for a in &children {
r.draw_arc(c, &frame, a);
}
}
});
}
self.draw_arc(ctx, f, layer);
let [r, g, b] = f.opts.tile_edge;
if def.show_tile_edge {
self.hairline(ctx, f, &def.tile.to_path(0.1), [r, g, b, 255]);
}
if def.show_swatch_bounds {
let mut bounds = def.swatch_bounds().to_path(0.1);
bounds.apply_affine(f.view);
ctx.set_transform(Affine::IDENTITY);
ctx.set_stroke(vello_cpu::kurbo::Stroke::new(1.0).with_dashes(0.0, [4.0, 3.0]));
ctx.set_paint(f.ink.fixed([r, g, b, 255]));
ctx.stroke_path(&bounds);
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::pattern::{PatternEdit, RepeatSpec, pattern_paint};
use vectorcraft_doc::{Appearance, NodeId, NodeKind};
use vectorcraft_geom::shapes;
/// A 20×20 tile with a red 10×10 square in its top-left corner.
fn doc_with_pattern(tt: TileType) -> Document {
let mut d = Document::new(200.0, 200.0);
let red = Node::path(
NodeId(900),
shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)),
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
);
let mut def = PatternDef::new("Checks", vec![Arc::new(red)]);
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
def.tile_type = tt;
d.patterns.push(def);
let id = d.alloc_id();
let n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn render(d: &Document, scale: f64) -> Rendered {
let px = (200.0 * scale) as u32;
Renderer::new().render(d, px, px, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
fn is_red(p: [u8; 4]) -> bool {
p[0] > 200 && p[1] < 60 && p[2] < 60
}
fn is_white(p: [u8; 4]) -> bool {
p[0] > 200 && p[1] > 200 && p[2] > 200
}
#[test]
fn pattern_fill_is_periodic() {
let d = doc_with_pattern(TileType::Grid);
let r = render(&d, 1.0);
for (x, y) in [(5, 5), (25, 5), (125, 145), (185, 65)] {
assert!(is_red(r.pixel(x, y)), "({x},{y}) {:?}", r.pixel(x, y));
}
for (x, y) in [(15, 5), (5, 15), (135, 135), (175, 195)] {
assert!(is_white(r.pixel(x, y)), "({x},{y}) {:?}", r.pixel(x, y));
}
// Periodic: pixel (x, y) equals (x + 20k, y + 20m).
for y in (1..20).step_by(3) {
for x in (1..20).step_by(3) {
if x == 10 || y == 10 {
continue;
}
assert_eq!(r.pixel(x, y), r.pixel(x + 60, y + 100));
}
}
}
#[test]
fn pattern_fill_is_clipped_to_shape() {
let mut d = doc_with_pattern(TileType::Grid);
let id = d.layers[0].children().unwrap()[0].id;
*d.node_mut(id).unwrap().path_data_mut().unwrap() = shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0));
let r = render(&d, 1.0);
assert!(is_red(r.pixel(45, 45)));
assert!(is_white(r.pixel(65, 65)));
}
#[test]
fn brick_pattern_shifts_rows() {
let d = doc_with_pattern(TileType::BrickByRow { offset: 0.5 });
let r = render(&d, 1.0);
assert!(is_red(r.pixel(5, 5)));
// Row 1 is shifted by half a tile.
assert!(is_white(r.pixel(5, 25)) && is_red(r.pixel(15, 25)), "{:?} {:?}", r.pixel(5, 25), r.pixel(15, 25));
assert!(is_red(r.pixel(5, 45)));
}
#[test]
fn pattern_follows_zoom_and_paint_transform() {
let d = doc_with_pattern(TileType::Grid);
let r = render(&d, 2.0);
assert!(is_red(r.pixel(10, 10)) && is_white(r.pixel(30, 10)) && is_red(r.pixel(50, 10)));
let mut d2 = d.clone();
let id = d2.layers[0].children().unwrap()[0].id;
vectorcraft_doc::pattern::transform_pattern_paints(d2.node_mut(id).unwrap(), Affine::translate((10.0, 0.0)));
let r = render(&d2, 1.0);
assert!(is_white(r.pixel(5, 5)) && is_red(r.pixel(15, 5)));
}
#[test]
fn missing_pattern_is_grey() {
let mut d = doc_with_pattern(TileType::Grid);
d.patterns.clear();
let p = render(&d, 1.0).pixel(50, 50);
assert_eq!(p, [128, 128, 128, 255]);
}
#[test]
fn swatch_thumbnail_renders() {
let d = doc_with_pattern(TileType::Grid);
let r = render_pattern_swatch(&d, "Checks", 20).unwrap();
assert!(is_red(r.pixel(3, 3)));
assert_eq!(r.pixel(15, 15)[3], 0);
assert!(render_pattern_swatch(&d, "Nope", 20).is_none());
}
#[test]
fn edit_mode_draws_only_tile_layer_with_copies() {
let mut d = doc_with_pattern(TileType::Grid);
let l = d.add_layer(Some("Pattern Editing Mode"));
let art = d.patterns[0].art[0].clone();
d.insert(Some(l), 0, (*art).clone()).unwrap();
d.pattern_edit = Some(PatternEdit { pattern: "Checks".into(), layer: l, original: None });
d.patterns[0].dim_copies = 50.0;
d.patterns[0].show_tile_edge = false;
let r = render(&d, 1.0);
// The tile itself at full strength, copies dimmed, the filled document rect hidden.
assert!(is_red(r.pixel(5, 5)));
let c = r.pixel(25, 5);
assert!(c[0] > 200 && c[1] > 100 && c[1] < 160, "{c:?}");
assert!(is_white(r.pixel(15, 5)));
assert!(is_white(r.pixel(125, 125)), "outside the copies");
}
#[test]
fn repeat_renders_instances() {
let mut d = Document::new(200.0, 200.0);
let sq = Node::path(
NodeId(50),
shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
let id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, Node::new(id, NodeKind::Repeat(RepeatSpec::grid(vec![Arc::new(sq)], 10.0, 10.0)))).unwrap();
let r = render(&d, 1.0);
for (x, y) in [(5, 5), (25, 5), (45, 45)] {
assert_eq!(r.pixel(x, y), [0, 0, 0, 255], "({x},{y})");
}
assert!(is_white(r.pixel(15, 5)) && is_white(r.pixel(65, 5)));
}
#[test]
fn raster_scale_is_power_of_two_and_capped() {
assert_eq!(raster_scale(1.0, (10.0, 10.0)), 1.0);
assert_eq!(raster_scale(1.5, (10.0, 10.0)), 2.0);
assert_eq!(raster_scale(3.0, (10.0, 10.0)), 4.0);
// A change within 2× keeps the raster.
assert_eq!(raster_scale(2.1, (10.0, 10.0)), raster_scale(3.9, (10.0, 10.0)));
assert!(raster_scale(64.0, (1000.0, 10.0)) * 1000.0 <= MAX_TILE_PX);
}
}