#1172: keep live-effect geometry across frames
A node with live geometry effects (Offset Path, Outline Stroke, Warp, Transform, Roughen…) had them computed again on every frame: Offset Path's curve offsetting and boolean sweep for every object, every time the canvas redrew. Rendering 2,000 paths that each have Offset Path, warm: 175 → 11.8 ms. The renderer keeps each node's effected geometry, and each appearance item's own geometry effects on it, keyed by the node's Arc identity like its other per-node caches. The entry keeps the Arc alive, so a hit is exact and an edited object (a new node) gets new geometry. Only the node draw_arc is drawing uses the cache: its path comes from the node alone, so its effects give the same result every frame. Entries not drawn for three frames are swept like the rest. cached_effect_geometry_draws_the_same_pixels renders a page of Offset Path, Roughen + Drop Shadow, Warp + Outline Stroke and Transform objects (with item-level Offset Path) cold, warm, after a pan and with a fresh renderer, and gets the same pixels each time; after an edit, the warm renderer draws what a fresh one does. Keying the cache by node id instead of identity fails it. The render suite (216 tests, golden images included) passes unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
This commit is contained in:
parent
4b260c364d
commit
add27cb8fc
|
|
@ -261,6 +261,16 @@ pub(crate) struct ShadowEntry {
|
|||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
/// Live-effect geometry of a node, kept across frames: the object-level result and each
|
||||
/// appearance item's own result on it (filled in as items are drawn). The node is kept alive, so
|
||||
/// its address names it alone and a hit is exact.
|
||||
pub(crate) struct FxGeom {
|
||||
node: Arc<Node>,
|
||||
g: Arc<BezPath>,
|
||||
items: Vec<Option<Arc<BezPath>>>,
|
||||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster:
|
||||
/// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused
|
||||
|
|
@ -340,12 +350,43 @@ impl Renderer {
|
|||
|
||||
/// `draw_shape` for nodes with live effects.
|
||||
pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) {
|
||||
let g = effected_path(n, bp);
|
||||
// The node `draw_arc` is drawing gets its geometry from the cache: `bp` is its own path,
|
||||
// made from the node alone, so the effects give the same result every frame.
|
||||
let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned();
|
||||
let mut cached = owner.map(|o| {
|
||||
let key = Arc::as_ptr(&o) as usize;
|
||||
let stamp = self.stamp;
|
||||
let e = match self.fx_geom.remove(&key) {
|
||||
Some(e) if Arc::ptr_eq(&e.node, &o) => e,
|
||||
_ => FxGeom { g: Arc::new(effected_path(n, bp)), items: vec![None; n.appearance.items.len()], node: o, stamp },
|
||||
};
|
||||
(key, FxGeom { stamp, ..e })
|
||||
});
|
||||
let g = match &cached {
|
||||
Some((_, e)) => e.g.clone(),
|
||||
None => Arc::new(effected_path(n, bp)),
|
||||
};
|
||||
self.draw_shape_fx_geom(ctx, f, n, &g, rule, cached.as_mut().map(|(_, e)| &mut e.items));
|
||||
if let Some((key, e)) = cached {
|
||||
self.fx_geom.insert(key, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry.
|
||||
fn draw_shape_fx_geom(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
n: &Node,
|
||||
g: &BezPath,
|
||||
rule: FillRule,
|
||||
items: Option<&mut Vec<Option<Arc<BezPath>>>>,
|
||||
) {
|
||||
if g.elements().is_empty() {
|
||||
return;
|
||||
}
|
||||
if f.opts.outline {
|
||||
self.hairline(ctx, f, &g, [0, 0, 0, 255]);
|
||||
self.hairline(ctx, f, g, [0, 0, 0, 255]);
|
||||
return;
|
||||
}
|
||||
let gctx = GeomContext::of(n);
|
||||
|
|
@ -353,8 +394,9 @@ impl Renderer {
|
|||
// Document-space reach of the painted geometry (strokes, arrowheads…).
|
||||
let outset = n.appearance.outset();
|
||||
let reach = g.bounding_box().inflate(outset, outset);
|
||||
let content = Content { node: n, cache: Some(0), reach, outline: Some((&g, rule)) };
|
||||
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, &g, rule, &gctx));
|
||||
let content = Content { node: n, cache: Some(0), reach, outline: Some((g, rule)) };
|
||||
let mut items = items;
|
||||
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, g, rule, &gctx, items.as_deref_mut()));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
|
||||
|
|
@ -698,32 +740,49 @@ impl Renderer {
|
|||
|
||||
/// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects and
|
||||
/// painting per-item raster effects around that item alone.
|
||||
fn paint_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, g: &BezPath, rule: FillRule, gctx: &GeomContext) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_items(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
n: &Node,
|
||||
g: &BezPath,
|
||||
rule: FillRule,
|
||||
gctx: &GeomContext,
|
||||
mut cache: Option<&mut Vec<Option<Arc<BezPath>>>>,
|
||||
) {
|
||||
let bounds = g.bounding_box();
|
||||
for (j, item) in n.appearance.items.iter().enumerate() {
|
||||
if !item.visible() || item.paint().is_none() {
|
||||
continue;
|
||||
}
|
||||
let ig = apply(item_effects(item), g, &gctx.item(item));
|
||||
// The item's own geometry effects on `g`, from the cache when there is one.
|
||||
let slot = cache.as_deref_mut().and_then(|c| c.get_mut(j));
|
||||
let ig: Arc<BezPath> = match slot {
|
||||
Some(Some(ig)) => ig.clone(),
|
||||
Some(s) => s.insert(Arc::new(apply(item_effects(item), g, &gctx.item(item)))).clone(),
|
||||
None => Arc::new(apply(item_effects(item), g, &gctx.item(item))),
|
||||
};
|
||||
let ig = &*ig;
|
||||
let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds };
|
||||
let rfx = effects::raster_effects(item_effects(item));
|
||||
if rfx.is_empty() {
|
||||
self.paint_item(ctx, f, n, item, &ig, rule, ib);
|
||||
self.paint_item(ctx, f, n, item, ig, rule, ib);
|
||||
continue;
|
||||
}
|
||||
// The item's silhouette: its fill area, or the outline of its stroke.
|
||||
let outline = match item {
|
||||
AppearanceItem::Fill(_) => ig.clone(),
|
||||
AppearanceItem::Stroke(st) => {
|
||||
let w = effects::stroke::aligned_width(st, effects::stroke::is_closed(&ig));
|
||||
effects::stroke::line_outline(&effects::stroke::stroke_pieces(&ig, st).line, st, w, f.px * 0.25)
|
||||
let w = effects::stroke::aligned_width(st, effects::stroke::is_closed(ig));
|
||||
effects::stroke::line_outline(&effects::stroke::stroke_pieces(ig, st).line, st, w, f.px * 0.25)
|
||||
}
|
||||
};
|
||||
let outset = n.appearance.outset();
|
||||
let orule = if item.is_fill() { rule } else { FillRule::NonZero };
|
||||
// Each item's shadows get their own cache slots.
|
||||
let content = Content { node: n, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) };
|
||||
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, &ig, rule, ib));
|
||||
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, ig, rule, ib));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
}
|
||||
|
|
@ -790,6 +849,56 @@ mod tests {
|
|||
|
||||
const WHITE: u32 = 765;
|
||||
|
||||
/// A page of objects with object-level and item-level geometry effects (Offset Path, Roughen,
|
||||
/// Warp, Outline Stroke, Transform) under drop shadows and in groups.
|
||||
fn effects_page() -> Document {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let fx = |id: &str, p: serde_json::Value| Effect { id: id.into(), params: p, visible: true };
|
||||
for i in 0..24 {
|
||||
let (x, y) = (10.0 + (i % 6) as f64 * 30.0, 10.0 + (i / 6) as f64 * 45.0);
|
||||
let id = d.alloc_id();
|
||||
let r = Rect::new(x, y, x + 20.0, y + 30.0);
|
||||
let shape = if i % 2 == 0 { shapes::ellipse(r) } else { shapes::star(r.center(), 12.0, 5.0, 5, 0.0) };
|
||||
let mut n = Node::path(id, shape, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.4, 0.9)), Paint::solid(Color::BLACK), 1.5));
|
||||
n.appearance.effects = match i % 4 {
|
||||
0 => vec![fx("path.offsetPath", json!({"offset": 3}))],
|
||||
1 => vec![fx("distort.roughen", json!({"size": 4, "relative": true})), fx("stylize.dropShadow", json!({}))],
|
||||
2 => vec![fx("warp.arc", json!({"bend": 30})), fx("path.outlineStroke", json!({}))],
|
||||
_ => vec![fx("path.offsetPath", json!({"offset": -2})), fx("distort.transform", json!({"moveH": 4, "rotate": 10}))],
|
||||
};
|
||||
if let Some(f) = n.appearance.fill_mut() {
|
||||
f.effects.push(fx("path.offsetPath", json!({"offset": 2})));
|
||||
}
|
||||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Cached live-effect geometry draws exactly what computing it afresh draws: a second (warm)
|
||||
/// frame, a frame after panning, and a fresh renderer all give the same pixels, and an edited
|
||||
/// object (a new node) draws its new geometry, not the cached one.
|
||||
#[test]
|
||||
fn cached_effect_geometry_draws_the_same_pixels() {
|
||||
let d = effects_page();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let view = Affine::scale(2.0);
|
||||
let mut r = Renderer::new();
|
||||
let cold = r.render(&d, 400, 400, view, &opts);
|
||||
let warm = r.render(&d, 400, 400, view, &opts);
|
||||
assert!(cold.pixels == warm.pixels, "a warm frame differs from the cold one");
|
||||
let _ = r.render(&d, 400, 400, Affine::translate((15.0, 0.0)) * view, &opts);
|
||||
assert!(r.render(&d, 400, 400, view, &opts).pixels == cold.pixels, "after a pan");
|
||||
assert!(Renderer::new().render(&d, 400, 400, view, &opts).pixels == cold.pixels, "a fresh renderer");
|
||||
// Edit one object's effect: the warm renderer must draw the new result.
|
||||
let mut e = d.clone();
|
||||
let id = e.layers[0].children().unwrap()[0].id;
|
||||
e.node_mut(id).unwrap().appearance.effects = vec![Effect { id: "path.offsetPath".into(), params: json!({"offset": 9}), visible: true }];
|
||||
let edited = r.render(&e, 400, 400, view, &opts);
|
||||
assert!(edited.pixels != cold.pixels, "the edit shows");
|
||||
assert!(edited.pixels == Renderer::new().render(&e, 400, 400, view, &opts).pixels, "and matches a fresh render");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnails_of_glowing_objects_render_without_panicking() {
|
||||
// Regression: thumbnails used vello's default (multithreaded) context, which panics on filters.
|
||||
|
|
|
|||
|
|
@ -358,6 +358,10 @@ pub struct Renderer {
|
|||
strokes: PtrMap<(usize, u64, i32), StrokeEntry>,
|
||||
/// The node being drawn on the plain-path fast path (owner of cached stroke outlines).
|
||||
cur: Option<Arc<Node>>,
|
||||
/// 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`.
|
||||
fx_geom: PtrMap<usize, fx::FxGeom>,
|
||||
stamp: u64,
|
||||
/// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer).
|
||||
alpha: f32,
|
||||
|
|
@ -480,6 +484,8 @@ impl Renderer {
|
|||
geom: PtrMap::default(),
|
||||
strokes: PtrMap::default(),
|
||||
cur: None,
|
||||
fx_owner: None,
|
||||
fx_geom: PtrMap::default(),
|
||||
stamp: 0,
|
||||
alpha: 1.0,
|
||||
ctx: None,
|
||||
|
|
@ -572,6 +578,9 @@ impl Renderer {
|
|||
if self.shadows.len() > 256 {
|
||||
self.shadows.retain(|_, e| g - e.stamp <= 3);
|
||||
}
|
||||
if self.fx_geom.len() > 256 {
|
||||
self.fx_geom.retain(|_, e| g - e.stamp <= 3);
|
||||
}
|
||||
// Filtered rasters are large: only those of the last frame stay.
|
||||
self.pixel_fx.retain(|_, e| e.stamp == g);
|
||||
if !inks {
|
||||
|
|
@ -911,7 +920,10 @@ impl Renderer {
|
|||
if vectorcraft_doc::live::is_live(a) || effects::has_pathfinder(a) {
|
||||
return self.draw_live(ctx, f, a);
|
||||
}
|
||||
// Its live-effect geometry depends on the node alone, so it can be kept across frames.
|
||||
let outer = self.fx_owner.replace(a.clone());
|
||||
self.draw_node(ctx, f, a, true);
|
||||
self.fx_owner = outer;
|
||||
}
|
||||
|
||||
fn draw_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, force: bool) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue