Merge pull request #1667 from dotCooCoo/feat/stroke-outlines-per-object

Render: objects that share an appearance keep their own stroke outlines between frames
This commit is contained in:
BFlat 2026-10-14 05:01:35 +07:00 committed by GitHub
commit 1b54bc335d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 248 additions and 29 deletions

View File

@ -754,6 +754,10 @@ impl Renderer {
/// What [`Self::draw_object_fx`] draws inside the object's transparency group.
fn draw_object_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, cache: bool) {
let art = self.fx_art(f.doc, a, cache);
// Art the cache did not keep is made again in the next frame.
let kept = cache && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, &art));
let outer = self.made_again;
self.made_again = outer || !kept;
// With Crop Marks, the object inside the art paints its own raster effects.
let rfx = if effects::has_crop_marks(a) { vec![] } else { effects::raster_effects(&a.appearance.effects) };
match cull_bounds(&art) {
@ -769,6 +773,7 @@ impl Renderer {
}
_ => self.draw_node(ctx, f, &art, true),
}
self.made_again = outer;
}
/// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`):

View File

@ -395,11 +395,15 @@ pub struct Renderer {
/// Worker threads for the multithreaded rasterizer (0 = single-threaded).
pub threads: u16,
geom: PtrMap<usize, GeomEntry>,
/// Expanded stroke outlines keyed by (stroke layer address, width bits, tolerance level):
/// panning re-renders reuse them instead of re-expanding every stroke.
strokes: PtrMap<(usize, u64, i32), StrokeEntry>,
/// Expanded stroke outlines keyed by ([`Self::stroke_owner`], stroke layer address, width bits,
/// tolerance level): panning re-renders reuse them instead of re-expanding every stroke.
strokes: PtrMap<(usize, usize, u64, i32), StrokeEntry>,
/// The node being drawn on the plain-path fast path (owner of cached stroke outlines).
cur: Option<Arc<Node>>,
/// Whether the art being drawn is made again in every frame: symbol art that
/// [`Self::placed_art`] did not keep, and evaluations of live objects and effect art that their
/// caches did not keep.
made_again: bool,
/// The node `draw_shape_fx` may cache the live-effect geometry of (the one `draw_arc` is drawing).
fx_owner: Option<Arc<Node>>,
/// Live-effect geometry per node ([`fx::FxGeom`]), keyed by `Arc` identity like `geom`.
@ -445,8 +449,9 @@ pub struct Renderer {
clip_paths: Vec<(BezPath, FillRule, Affine)>,
/// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]).
clip_paints: FrameCache<usize, ClipPaintEntry>,
/// Gradients along or across strokes, keyed like `strokes` (see [`Self::fill_path_gradient`]).
stroke_slices: PtrMap<(usize, i32), SliceEntry>,
/// Gradients along or across strokes (see [`Self::fill_path_gradient`]), keyed by
/// ([`Self::stroke_owner`], stroke layer address, tolerance level).
stroke_slices: PtrMap<(usize, usize, i32), SliceEntry>,
/// What the image keys colour adjustments give their recoloured images name: the image
/// recoloured and the adjustment, with its hash (see [`Self::adjusted_image`]).
adjusts: HashMap<String, (String, u64, vectorcraft_effects::ColorMap)>,
@ -529,6 +534,7 @@ impl Renderer {
geom: PtrMap::default(),
strokes: PtrMap::default(),
cur: None,
made_again: false,
fx_owner: None,
fx_geom: PtrMap::default(),
stamp: 0,
@ -1190,8 +1196,10 @@ impl Renderer {
NodeKind::Image(im) => self.draw_image(ctx, f, im),
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) {
let art = self.placed_art(&sym.art, *xf, false, Some(n));
let (art, kept) = self.placed_art(&sym.art, *xf, false, Some(n));
let outer = std::mem::replace(&mut self.made_again, !kept);
self.draw_node(ctx, f, &art, true);
self.made_again = outer;
}
}
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
@ -1443,7 +1451,7 @@ impl Renderer {
};
let slices = match self.cur.clone() {
Some(node) => {
let key = (st as *const StrokeLayer as usize, level);
let key = (self.stroke_owner(&node), st as *const StrokeLayer as usize, level);
match self.stroke_slices.get_mut(&key) {
Some(e) if Arc::ptr_eq(&e.node, &node) => {
e.stamp = self.stamp;
@ -1481,13 +1489,13 @@ impl Renderer {
}
/// The line part of a stroke of the fast-path node `self.cur` (see
/// [`effects::stroke::line_outline`]), cached across frames. The tolerance (vello's 0.25
/// device px) is bucketed to powers of two, never coarser than needed, so zooming reuses
/// outlines until the level changes.
/// [`effects::stroke::line_outline`]), cached across frames under [`Self::stroke_owner`] and
/// the stroke layer. The tolerance (vello's 0.25 device px) is bucketed to powers of two, never
/// coarser than needed, so zooming reuses outlines until the level changes.
fn cached_stroke(&mut self, f: &Frame, line: &BezPath, st: &StrokeLayer, width: f64) -> Option<Arc<BezPath>> {
let node = self.cur.clone()?;
let level = (f.px * 0.25).log2().floor() as i32;
let key = (st as *const StrokeLayer as usize, width.to_bits(), level);
let key = (self.stroke_owner(&node), st as *const StrokeLayer as usize, width.to_bits(), level);
if let Some(e) = self.strokes.get_mut(&key)
&& Arc::ptr_eq(&e.node, &node)
{
@ -1499,6 +1507,12 @@ impl Renderer {
Some(outline)
}
/// What the `strokes` and `stroke_slices` keys hold for the fast-path node `node`: its address,
/// or 0 while art made again in every frame is drawn ([`Self::made_again`]).
fn stroke_owner(&self, node: &Arc<Node>) -> usize {
if self.made_again { 0 } else { Arc::as_ptr(node) as usize }
}
fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) {
let t = &*f.doc.inline_resolved(t);
let g = text_geom_snapped(t, f.text_snap(t));
@ -1525,23 +1539,26 @@ impl Renderer {
let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue };
let Some(sym) = f.doc.symbols.iter().find(|s| s.name == art.symbol) else { continue };
// Strokes scale with the art (it is sized to the type, as in the SVG `<use>`).
let node = self.placed_art(&sym.art, t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true, None);
let (node, kept) = self.placed_art(&sym.art, t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true, None);
let outer = std::mem::replace(&mut self.made_again, !kept);
self.draw_node(ctx, f, &node, true);
self.made_again = outer;
}
self.inline_depth -= 1;
}
/// Symbol art `art` as an instance or an inline graphic draws it ([`place`]). Art that is a
/// group or a layer without an appearance of its own is kept between frames under the art's
/// `Arc`, `xf`, `scale_strokes` and the stain of instance `inst`: its members keep their `Arc`s
/// from frame to frame, and the caches keyed by a node's address (geometry, glyphs, live-effect
/// geometry) hit for them. Other art is placed again in every frame, and so is art that holds
/// symbol instances, type with inline graphics, images, placed documents, live blends,
/// envelopes or repeats, or raster effects ([`depends_on_document`]). Art that holds type is
/// Symbol art `art` as an instance or an inline graphic draws it ([`place`]), and whether it is
/// kept. Art that is a group or a layer without an appearance of its own is kept between frames
/// under the art's `Arc`, `xf`, `scale_strokes` and the stain of instance `inst`: its members
/// keep their `Arc`s from frame to frame, and the caches keyed by a node's address (geometry,
/// stroke outlines, glyphs, live-effect geometry) hit for them. Other art is placed again in
/// every frame, and so is art that holds symbol instances, type with inline graphics, images,
/// placed documents, live blends, envelopes or repeats, or raster effects
/// ([`depends_on_document`]), and art that the cache has no room for. Art that holds type is
/// placed again when the fonts change.
fn placed_art(&mut self, art: &Arc<Node>, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> Arc<Node> {
fn placed_art(&mut self, art: &Arc<Node>, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> (Arc<Node>, bool) {
if !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || fx::has_object_fx(art) {
return place(art, xf, scale_strokes, inst);
return (place(art, xf, scale_strokes, inst), false);
}
let stain = stain_bits(inst.and_then(vectorcraft_brush::stain));
let key = (Arc::as_ptr(art) as usize, xf.as_coeffs().map(f64::to_bits), scale_strokes, stain);
@ -1550,18 +1567,19 @@ impl Renderer {
&& e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation())
{
return match &e.placed {
Some(placed) => placed.clone(),
None => place(art, xf, scale_strokes, inst),
Some(placed) => (placed.clone(), true),
None => (place(art, xf, scale_strokes, inst), false),
};
}
let placed = place(art, xf, scale_strokes, inst);
let kept = (!depends_on_document(art)).then(|| placed.clone());
let fonts = (kept.is_some() && holds_type(art)).then(|| vectorcraft_text::FontDb::global().generation());
let keeps = kept.is_some();
let fonts = (keeps && holds_type(art)).then(|| vectorcraft_text::FontDb::global().generation());
// The bytes count the key and the entry, the art's allocation that the weak reference
// keeps, and the kept art.
let bytes = size_of::<(PlacedKey, PlacedEntry, Node)>() + kept.as_deref().map_or(0, lru::node_bytes);
self.placed_arts.insert(key, PlacedEntry { art: Arc::downgrade(art), placed: kept, fonts }, bytes, self.stamp);
placed
let inserted = self.placed_arts.insert(key, PlacedEntry { art: Arc::downgrade(art), placed: kept, fonts }, bytes, self.stamp);
(placed, keeps && inserted)
}
/// Cached glyph geometry for a text node (keyed by Arc identity like paths).

View File

@ -42,6 +42,12 @@ impl LiveCache {
self.expanded.sweep(stamp);
self.meshes.sweep(stamp);
}
/// A cache whose evaluations take at most `budget` bytes.
#[cfg(test)]
pub(crate) fn with_budget(budget: usize) -> Self {
Self { expanded: FrameCache::new(budget, 256), ..Default::default() }
}
}
/// One level of evaluation of a live node (blend steps, envelope result with type outlined run by
@ -146,6 +152,10 @@ impl Renderer {
NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache),
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => {
let items = self.live_expanded(f.doc, a, cache);
// An evaluation the cache did not keep is made again in the next frame.
let kept = cache && self.live.expanded.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, &items));
let outer = self.made_again;
self.made_again = outer || !kept;
// The flag holds while this blend is drawn (see `draw_live`).
if self.knockout && matches!(a.kind, NodeKind::Blend { .. }) {
self.draw_knockout(ctx, f, &items);
@ -154,6 +164,7 @@ impl Renderer {
self.draw_arc(ctx, f, c);
}
}
self.made_again = outer;
}
_ => {}
}

View File

@ -1,7 +1,7 @@
//! What renderers keep between frames: decoded images shared by all of them, image mips, symbol
//! art placed for instances and inline graphics, raster effects made again when what they read
//! changes, and caches swept by every kind of render. Each test holds [`fonts_steady`]: fonts added
//! while it runs would make its renderers lay type out again.
//! art placed for instances and inline graphics, stroke outlines, raster effects made again when
//! what they read changes, and caches swept by every kind of render. Each test holds [`fonts_steady`]:
//! fonts added while it runs would make its renderers lay type out again.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
@ -244,6 +244,191 @@ fn symbol_instances_keep_their_placed_art_between_frames() {
assert_eq!(Arc::strong_count(&d.symbols[0].art), 1, "only the document holds the symbol's art");
}
/// The stroke outlines and the gradient slices of strokes that `r` keeps and that its last frame made
/// or used.
fn stroke_outlines(r: &Renderer) -> (Vec<Arc<BezPath>>, Vec<Slices>) {
let outlines = r.strokes.values().filter(|e| e.stamp == r.stamp).map(|e| e.outline.clone()).collect();
(outlines, r.stroke_slices.values().filter(|e| e.stamp == r.stamp).map(|e| e.slices.clone()).collect())
}
/// How many of the outlines and of the slices in `before` (see [`stroke_outlines`]) the last frame of
/// `r` used again.
fn used_again(r: &Renderer, before: &(Vec<Arc<BezPath>>, Vec<Slices>)) -> (usize, usize) {
fn again<T>(was: &[Arc<T>], now: &[Arc<T>]) -> usize {
was.iter().filter(|a| now.iter().any(|b| Arc::ptr_eq(a, b))).count()
}
let (outlines, slices) = stroke_outlines(r);
(again(&before.0, &outlines), again(&before.1, &slices))
}
/// Copies can share the original's appearance: three copies of [`symbol_art`] made as
/// `edit.duplicate` makes them, and a wave whose stroke has a gradient along it with a copy made as
/// the Layers panel's Duplicate Selection makes it. Each object keeps its own stroke outlines and
/// gradient slices from frame to frame, a copy moved gets new ones while the others keep theirs,
/// and each frame draws what a new renderer draws.
#[test]
fn copies_sharing_an_appearance_keep_their_own_stroke_outlines() {
let _fonts = fonts_steady();
use vectorcraft_doc::{Appearance, StrokeGradientMode};
let mut d = Document::new(200.0, 100.0);
let art = symbol_art(&mut d);
let l = d.layers[0].id;
let mut copies = vec![];
for x in [30.0, 80.0, 130.0] {
let mut copy = d.reid(&art);
copy.transform(Affine::translate((x, 30.0)), true);
copies.push(d.insert(Some(l), usize::MAX, copy).unwrap());
}
let mut st = StrokeLayer::new(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default()))), 6.0);
st.gradient_mode = StrokeGradientMode::Along;
let mut wave = BezPath::new();
wave.move_to((20.0, 75.0));
wave.curve_to((60.0, 55.0), (100.0, 95.0), (180.0, 75.0));
let ap = Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() };
let wave = Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&wave), ap);
let duplicate = d.reid(&wave);
d.insert(Some(l), usize::MAX, wave).unwrap();
d.insert(Some(l), usize::MAX, duplicate).unwrap();
let opts = RenderOptions::default();
let frame = |r: &mut Renderer, d: &Document| r.render(d, 200, 100, Affine::IDENTITY, &opts).pixels;
let mut r = Renderer::new();
frame(&mut r, &d);
let before = stroke_outlines(&r);
assert_eq!((before.0.len(), before.1.len()), (8, 2), "the strokes of three copies and two waves, and the waves' slices");
assert!(frame(&mut r, &d) == frame(&mut Renderer::new(), &d));
assert_eq!(used_again(&r, &before), (8, 2), "the second frame draws the outlines the first made");
// The first copy moves 5 pt right.
if let Some(n) = d.node_mut(copies[0]) {
n.transform(Affine::translate((5.0, 0.0)), true);
}
assert!(frame(&mut r, &d) == frame(&mut Renderer::new(), &d));
assert_eq!(used_again(&r, &before), (6, 2), "the other copies and the waves keep theirs");
}
/// The art placed for unstained symbol instances shares its appearance with the symbol's art (the
/// first and the third instance of [`symbol_doc`]). Each instance keeps its own stroke outlines from
/// frame to frame, and the second frame draws what a new renderer draws.
#[test]
fn symbol_instances_keep_their_own_stroke_outlines_between_frames() {
let _fonts = fonts_steady();
let d = symbol_doc();
let opts = RenderOptions::default();
let frame = |r: &mut Renderer| r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels;
let mut r = Renderer::new();
frame(&mut r);
let before = stroke_outlines(&r);
assert_eq!(before.0.len(), 6, "the square's and the line's strokes of three instances");
assert!(frame(&mut r) == frame(&mut Renderer::new()));
assert_eq!(used_again(&r, &before), (6, 0), "the second frame draws the outlines the first made");
}
/// The art of symbol `I` ([`symbol_art`] with an image over it) is placed again in every frame.
/// The copies placed for its three unstained instances and for an inline graphic at the art's own
/// size replace each other's stroke outlines: the renderer keeps one outline for each of the art's
/// two strokes. Two copies of [`symbol_art`] made as `edit.duplicate` makes them and two instances
/// of symbol `S` ([`symbol_art`]), drawn after those, keep their own outlines. With no room in the
/// placed-art cache, `S`'s art is placed again in every frame as well, and its copies keep one
/// outline for each stroke. Each frame draws what a new renderer draws.
#[test]
fn art_placed_again_in_every_frame_keeps_one_outline_per_stroke() {
let _fonts = fonts_steady();
use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun};
let mut d = Document::new(200.0, 100.0);
d.images.insert("dot".into(), ImageBlob::new("image/png", png(4, 4, 108)));
let mut art = symbol_art(&mut d);
let im = ImageObject { key: "dot".into(), width: 4, height: 4, xf: Affine::translate((-2.0, -2.0)), link: None, placement: Default::default() };
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
if let Some(c) = art.children_mut() {
c.push(Arc::new(image));
}
d.symbols.push(vectorcraft_doc::Symbol { name: "I".into(), art: Arc::new(art) });
let art = symbol_art(&mut d);
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
let l = d.layers[0].id;
let instance = |d: &mut Document, symbol: &str, x: f64, y: f64| {
let id = d.alloc_id();
d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: symbol.into(), xf: Affine::translate((x, y)) })).unwrap();
};
for x in [25.0, 65.0, 105.0] {
instance(&mut d, "I", x, 25.0);
}
// Type as tall as `I`'s art shows the inline graphic at scale 1.
let size = d.symbol_art_bounds("I").map_or(0.0, |b| b.height());
let style = CharStyle { size, ..Default::default() };
let mut t = TextObject::point(vectorcraft_geom::Point::new(140.0, 32.0), "A", style.clone());
t.runs.push(TextRun::inline(InlineArt::new("I"), style));
let id = d.alloc_id();
d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
let art = symbol_art(&mut d);
for x in [105.0, 145.0] {
let mut copy = d.reid(&art);
copy.transform(Affine::translate((x, 72.0)), true);
d.insert(Some(l), usize::MAX, copy).unwrap();
}
for x in [25.0, 65.0] {
instance(&mut d, "S", x, 72.0);
}
let opts = RenderOptions::default();
let frame = |r: &mut Renderer| r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels;
let counts = |r: &Renderer| (r.strokes.len(), r.stroke_slices.len());
let mut r = Renderer::new();
let mut full = Renderer::new();
full.placed_arts = FrameCache::new(0, 0);
let mut before = (vec![], vec![]);
for n in 1..=4 {
let want = frame(&mut Renderer::new());
assert!(frame(&mut r) == want && frame(&mut full) == want, "frame {n}");
// Two outlines for the art placed for `I`, two for each copy and two for each instance of
// `S`; with no room in the cache, two for the art placed for `S`.
assert_eq!((counts(&r), counts(&full)), ((10, 0), (8, 0)), "frame {n}");
if n == 1 {
before = stroke_outlines(&r);
}
}
assert_eq!(used_again(&r, &before), (8, 0), "the instances of `S` and the copies keep theirs");
}
/// The document holds a Grid Repeat of [`symbol_art`] (nine groups) and a group with a Transform
/// effect around two copies of [`symbol_art`] made as `edit.duplicate` makes them. With no room in
/// the caches of evaluated live objects and of effect art, the repeat is evaluated again and the
/// group's art is made again in every frame. Their copies then replace each other's stroke
/// outlines: the renderer keeps two outlines for the repeat and two for the copies. With room, each
/// copy keeps its own. Each frame draws what a new renderer draws.
#[test]
fn art_evaluated_again_in_every_frame_keeps_one_outline_per_stroke() {
let _fonts = fonts_steady();
let mut d = Document::new(200.0, 100.0);
let l = d.layers[0].id;
let mut source = symbol_art(&mut d);
source.transform(Affine::translate((20.0, 20.0)), true);
let repeat = vectorcraft_doc::pattern::RepeatSpec::grid(vec![Arc::new(source)], 4.0, 4.0);
let id = d.alloc_id();
d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::Repeat(repeat))).unwrap();
let art = symbol_art(&mut d);
let mut copies = vec![];
for x in [130.0, 170.0] {
let mut copy = d.reid(&art);
copy.transform(Affine::translate((x, 30.0)), true);
copies.push(Arc::new(copy));
}
let mut moved = Node::group(d.alloc_id(), copies);
moved.appearance.effects.push(effect("distort.transform", serde_json::json!({"moveV": 30.0})));
d.insert(Some(l), usize::MAX, moved).unwrap();
let opts = RenderOptions::default();
let frame = |r: &mut Renderer| r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels;
let mut r = Renderer::new();
let mut full = Renderer::new();
full.live = live::LiveCache::with_budget(0);
full.fx_arts = FrameCache::new(0, 0);
for n in 1..=4 {
let want = frame(&mut Renderer::new());
assert!(frame(&mut r) == want && frame(&mut full) == want, "frame {n}");
// Two outlines for each of the nine repeated groups and each copy; with no room in the
// caches, two for the repeat and two for the copies.
assert_eq!((r.strokes.len(), full.strokes.len()), (22, 4), "frame {n}");
}
}
/// Moving an instance places its art again, and editing a symbol's art places the art of its
/// instances again: the other instances keep theirs, and each frame draws what a new renderer
/// draws.