Render: the strokes of brush art are no longer drawn with old outlines

While the renderer drew a brushed path on its fast path, it drew the
pieces of brush art with that path still set as the object being
drawn. cached_stroke and fill_path_gradient then kept the outlines and
gradient slices of the pieces' strokes under the brushed path and the
address of each piece's stroke. The entries held the brushed path and
not the pieces, so a piece made later could take the address of an
earlier piece's stroke and be drawn with that piece's outline. The
instances of a symbol and the copies of a live repeat replace each
other's brush art, so it is made again for each of them in every
frame, and every brushed path gets new brush art when the document's
brushes change. In a test, a renderer drawing three instances of a
symbol whose art holds a path brushed with a scatter brush of a
stroked square drew frames that differed from a new renderer's in 316
to 1,140 pixels.

The two caches now keep only the strokes of the object being drawn. With
vectorcraft-cli bench at 2880x1800 on 8 threads, 2,000 instances of a
symbol of eight brushed paths took 203.4 ms a frame on main and 155.9 ms
with this change. 2,000 groups of the same paths, for which main keeps
the brush art and the outlines of its strokes between frames, took 133.7
ms on main and 156.9 ms with this change. The peak working set of a
bench run at 2880x1800 went from 609 MiB to 127 MiB on the instances and
from 331 MiB to 210 MiB on the groups.
This commit is contained in:
dotCooCoo
2026-10-16 06:16:35 +07:00
committed by bflatastic
parent 67a278b053
commit a8e612d3e2
2 changed files with 71 additions and 3 deletions
+9 -3
View File
@@ -1591,7 +1591,7 @@ impl Renderer {
.collect(),
)
};
let slices = match self.cur.clone() {
let slices = match self.cur.clone().filter(|n| owns_stroke(n, st)) {
Some(node) => {
let key = (self.stroke_owner(&node), st as *const StrokeLayer as usize, level);
match self.stroke_slices.get_mut(&key) {
@@ -1633,9 +1633,10 @@ impl Renderer {
/// The line part of a stroke of the fast-path node `self.cur` (see
/// [`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.
/// coarser than needed, so zooming reuses outlines until the level changes. Only the node's own
/// strokes are cached: for the strokes of the brush art drawn for it, this returns `None`.
fn cached_stroke(&mut self, f: &Frame, line: &BezPath, st: &StrokeLayer, width: f64) -> Option<Arc<BezPath>> {
let node = self.cur.clone()?;
let node = self.cur.clone().filter(|n| owns_stroke(n, st))?;
let level = (f.px * 0.25).log2().floor() as i32;
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)
@@ -2101,6 +2102,11 @@ fn painted_items(n: &Node) -> usize {
.max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && !matches!(s.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None))) { 2 } else { 0 })
}
/// Whether `st` is one of `n`'s own strokes (the strokes of the brush art drawn for `n` are not).
fn owns_stroke(n: &Node, st: &StrokeLayer) -> bool {
n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if std::ptr::eq(s, st)))
}
/// Symbol art `art` stained by instance `inst` ([`instance_art`]) and placed by `xf`, with
/// strokes scaled by `xf` when `scale_strokes`.
fn place(art: &Node, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> Arc<Node> {
+62
View File
@@ -545,6 +545,68 @@ fn art_evaluated_again_in_every_frame_keeps_one_outline_per_stroke() {
}
}
/// The instances of a symbol and the copies of a repeat replace each other's brush art, so their
/// brush art is made again in every frame. A brushed path gets new brush art each time its brush
/// changes. The renderer keeps no outline or gradient slices for the squares' strokes, and each
/// frame draws what a new renderer draws.
#[test]
fn strokes_of_brush_art_made_again_draw_as_a_new_renderer_does() {
let _fonts = fonts_steady();
use vectorcraft_brush::{Brush, BrushKind, Scatter};
use vectorcraft_doc::{Appearance, StrokeGradientMode};
let mut d = Document::new(200.0, 140.0);
// Brush `Squares` scatters 8 × 8 squares stroked 2 pt with a gradient along the stroke and
// 0.5 pt in black over it, with a spacing of `spacing` percent of the square's width.
let squares = |d: &mut Document, spacing: f64| {
let mut st = StrokeLayer::new(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default()))), 2.0);
st.gradient_mode = StrokeGradientMode::Along;
let black = StrokeLayer::new(Paint::solid(Color::BLACK), 0.5);
let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 8.0, 8.0));
let strokes = Appearance { items: vec![AppearanceItem::Stroke(st), AppearanceItem::Stroke(black)], ..Default::default() };
let art = Node::path(NodeId(0), square, strokes);
let mut lib = vectorcraft_brush::library(d);
lib.retain(|b| b.name != "Squares");
lib.push(Brush { name: "Squares".into(), kind: BrushKind::Scatter(Scatter { art, spacing: (spacing, spacing), ..Default::default() }) });
vectorcraft_brush::store(d, &lib);
};
// A wave around `at`, brushed with `Squares`.
let wave = |d: &mut Document, at: (f64, f64)| {
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
if let Some(s) = ap.stroke_mut() {
s.brush = Some("Squares".into());
}
let (x, y) = at;
let mut wave = BezPath::new();
wave.move_to((x - 40.0, y));
wave.curve_to((x - 15.0, y - 20.0), (x + 15.0, y + 20.0), (x + 40.0, y));
Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&wave), ap)
};
// Three instances show symbol `B`, a group of a wave. A fourth wave is a path of the document,
// and a Mirror repeat draws a fifth wave and its reflection.
let art = Node::group(d.alloc_id(), vec![Arc::new(wave(&mut d, (0.0, 0.0)))]);
d.symbols.push(vectorcraft_doc::Symbol { name: "B".into(), art: Arc::new(art) });
let l = d.layers[0].id;
for at in [(50.0, 30.0), (150.0, 30.0), (50.0, 70.0)] {
let id = d.alloc_id();
d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "B".into(), xf: Affine::translate(at) })).unwrap();
}
let path = wave(&mut d, (150.0, 70.0));
d.insert(Some(l), usize::MAX, path).unwrap();
let source = wave(&mut d, (50.0, 110.0));
let mirror = vectorcraft_doc::pattern::RepeatSpec::mirror(vec![Arc::new(source)], 90.0, 10.0);
let id = d.alloc_id();
d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::Repeat(mirror))).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
// Six frames of the same document, then a frame after each change of the brush's spacing.
for (frame, spacing) in [150.0, 150.0, 150.0, 150.0, 150.0, 150.0, 120.0, 150.0, 90.0, 150.0, 120.0, 135.0].into_iter().enumerate() {
squares(&mut d, spacing);
let want = Renderer::new().render(&d, 200, 140, Affine::IDENTITY, &opts).pixels;
assert!(r.render(&d, 200, 140, Affine::IDENTITY, &opts).pixels == want, "frame {frame}");
}
assert_eq!((r.strokes.len(), r.stroke_slices.len()), (0, 0), "no outline or slices kept for the strokes of brush art");
}
/// 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.