From add27cb8fcb70f590b6960ed0d79ad7a4ab7fafa Mon Sep 17 00:00:00 2001 From: arhammusheer Date: Sun, 11 Oct 2026 16:20:52 +0530 Subject: [PATCH] #1172: keep live-effect geometry across frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc --- crates/render/src/fx.rs | 129 ++++++++++++++++++++++++++++++++++++--- crates/render/src/lib.rs | 12 ++++ 2 files changed, 131 insertions(+), 10 deletions(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 266bfe0b..cc79f6a3 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -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, + g: Arc, + items: Vec>>, + 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>>>, + ) { 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>>>, + ) { 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 = 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. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e2f93205..50d11558 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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>, + /// The node `draw_shape_fx` may cache the live-effect geometry of (the one `draw_arc` is drawing). + fx_owner: Option>, + /// Live-effect geometry per node ([`fx::FxGeom`]), keyed by `Arc` identity like `geom`. + fx_geom: PtrMap, 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) {