From e313c93e2c9f8d2381d15aa21c30e6cd3c0b72a8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 00:10:41 -0700 Subject: [PATCH] Render: live objects in symbol art and opacity masks keep their evaluations between frames The renderer placed symbol art that holds a blend, an envelope or a repeat again for every instance in every frame. The live object in the placed copy was a new node each time, and the renderer evaluated it again. Symbol art that is itself a live object, an opacity mask whose art is one, and a live object in a clip group in Outline view were drawn from an uncached copy in every frame. At 1000 x 1000 pixels, 2,000 instances of a symbol holding an envelope took 126 ms a frame on main, and the same 2,000 envelopes on the page took 30 ms. The renderer now keeps the placed art of instances and inline graphics when it holds blends, envelopes or repeats, and when the art is itself a live object. It draws kept live art, opacity-mask art and Outline view clip members that are live objects from the live cache, which evaluates them again when the symbols, patterns, fonts or color settings they show change. The 2,000 instances take 28 ms a frame, with the same pixels. Docs: docs/target-app-parity.md --- crates/render/src/lib.rs | 73 +++++++++++------ crates/render/src/live.rs | 19 ++++- crates/render/src/tests_memory.rs | 127 +++++++++++++++++++++++++++++- docs/target-app-parity.md | 2 + 4 files changed, 192 insertions(+), 29 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a4eb30fcd..f923b2813 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -477,6 +477,10 @@ pub struct Renderer { /// [`Self::draw_outline_member`] sets it to each member of a clip group or shape of an Image /// Trace object that it draws. fx_owner: Option>, + /// The live object [`Self::draw_kept_node`] is drawing (kept symbol art, an opacity mask's art, + /// a member of a clip group in Outline view): [`Self::draw_content`] draws its evaluation from + /// the live cache. + live_root: Option>, /// Live-effect geometry per node ([`fx::FxGeom`]), keyed by `Arc` identity like `geom`. fx_geom: PtrMap, stamp: u64, @@ -637,6 +641,7 @@ impl Renderer { cur: None, made_again: false, fx_owner: None, + live_root: None, fx_geom: PtrMap::default(), stamp: 0, alpha: 1.0, @@ -1111,7 +1116,7 @@ impl Renderer { std::mem::take(&mut self.clip_paths), self.picture.take(), ); - self.draw_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art, true); + self.draw_kept_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art); let pm = self.finish(&mut mctx, self.raster); (self.knockout, self.nested, self.backdrop, self.clip_paths, self.picture) = outer; pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect() @@ -1365,9 +1370,7 @@ impl Renderer { NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) { 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; + self.draw_placed_art(ctx, f, &art, kept); } } NodeKind::Blend { .. } @@ -1375,10 +1378,35 @@ impl Renderer { | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } => self.draw_live_node(ctx, f, n), + | NodeKind::Intertwine { .. } => match self.live_root.clone().filter(|a| std::ptr::eq(&**a, n)) { + Some(a) => self.draw_live_body(ctx, f, &a, true), + None => self.draw_live_node(ctx, f, n), + }, } } + /// Draw symbol art `art` as [`Self::placed_art`] placed it for an instance or an inline graphic + /// (`kept`: kept between frames, see [`Self::draw_kept_node`]). + fn draw_placed_art(&mut self, ctx: &mut RenderContext, f: &Frame, art: &Arc, kept: bool) { + let outer = std::mem::replace(&mut self.made_again, !kept); + if kept { + self.draw_kept_node(ctx, f, art); + } else { + self.draw_node(ctx, f, art, true); + } + self.made_again = outer; + } + + /// [`Self::draw_node`] of `a` (drawn whatever its visibility and bounds), whose `Arc` stays the + /// same from frame to frame while the art does. When `a` is a live object, + /// [`Self::draw_content`] draws its evaluation from the live cache ([`Self::live_root`]). + fn draw_kept_node(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { + let live = vectorcraft_doc::live::is_live(a).then(|| a.clone()); + let outer = std::mem::replace(&mut self.live_root, live); + self.draw_node(ctx, f, a, true); + self.live_root = outer; + } + /// An Image Trace object's `children` (the hidden source image, then the traced shapes) as /// `view` shows them on screen. fn draw_trace_view(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc], view: TraceView, knockout: bool) { @@ -1400,7 +1428,8 @@ impl Renderer { /// [`Self::draw_node`] for `c`, a member of a clip group or a shape of an Image Trace object in /// Outline view, culled by its cached bounds ([`Self::bounds_of`]) and drawn with `c` as - /// `fx_owner`, so its path and live-effect geometry come from the caches. + /// `fx_owner`, so its path, its live-effect geometry and, through [`Self::draw_kept_node`], a + /// live object's evaluation come from the caches. fn draw_outline_member(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc) { if !c.visible || f.opts.hidden.contains(&c.id) || f.opts.hide_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) { return; @@ -1417,7 +1446,7 @@ impl Renderer { None => {} } let outer = self.fx_owner.replace(c.clone()); - self.draw_node(ctx, f, c, true); + self.draw_kept_node(ctx, f, c); self.fx_owner = outer; } @@ -1746,24 +1775,23 @@ impl Renderer { 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 ``). 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.draw_placed_art(ctx, f, &node, kept); } self.inline_depth -= 1; } /// 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 + /// kept. Art that is a group, a layer or a live object without an appearance of its own is kept + /// between frames under the art's `Arc`, `xf`, `scale_strokes` and the stain of instance + /// `inst`: it and 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, evaluated live + /// objects) 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 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, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> (Arc, bool) { - if !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || fx::has_object_fx(art) { + let kept_kind = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || vectorcraft_doc::live::is_live(art); + if !kept_kind || fx::has_object_fx(art) { return (place(art, xf, scale_strokes, inst), false); } let stain = stain_bits(inst.and_then(vectorcraft_brush::stain)); @@ -2287,19 +2315,14 @@ fn place(art: &Node, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> Ar } /// Does `n` hold, in it or in the opacity masks of the objects in it, a symbol instance, type -/// with an inline graphic, an image, a placed document, a blend, an envelope, a repeat, or an -/// object or a fill or stroke with a visible raster effect? +/// with an inline graphic, an image, a placed document, or an object or a fill or stroke with a +/// visible raster effect? fn depends_on_document(n: &Node) -> bool { let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && effects::is_raster(&e.id)); let mut depends = false; n.walk(&mut |c| { depends |= match &c.kind { - NodeKind::SymbolInstance { .. } - | NodeKind::Image(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Repeat(_) => true, + NodeKind::SymbolInstance { .. } | NodeKind::Image(_) | NodeKind::PlacedDocument(_) => true, NodeKind::Text(t) => t.runs.iter().any(|r| r.inline.is_some()), _ => false, } || raster(&c.appearance.effects) diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 014c8efa4..992a7051d 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -39,11 +39,20 @@ type MeshEntry = (Arc, Quads, Arc); pub(crate) struct LiveCache { expanded: FrameCache, Expanded, Found)>, meshes: FrameCache<(usize, usize), MeshEntry>, + /// How many live objects [`Renderer::live_expanded`] evaluated and how many meshes + /// [`Renderer::mesh_quads`] tessellated. + #[cfg(test)] + pub(crate) evaluated: usize, } impl Default for LiveCache { fn default() -> Self { - Self { expanded: FrameCache::new(lru::LIVE_BUDGET, 256), meshes: FrameCache::new(lru::MESH_BUDGET, 256) } + Self { + expanded: FrameCache::new(lru::LIVE_BUDGET, 256), + meshes: FrameCache::new(lru::MESH_BUDGET, 256), + #[cfg(test)] + evaluated: 0, + } } } @@ -400,6 +409,10 @@ impl Renderer { { return e.1.clone(); } + #[cfg(test)] + { + self.live.evaluated += 1; + } let v: Expanded = match crate::effects::pathfinder_children(a, crate::effects::text_outliner()) { Some(children) => Arc::new(children), None => Arc::new(crate::effects::expand_live(Some(doc), a).into_iter().map(Arc::new).collect()), @@ -423,6 +436,10 @@ impl Renderer { { return e.1.clone(); } + #[cfg(test)] + { + self.live.evaluated += 1; + } let q = Arc::new(m.quads(level)); if cache { let bytes = q.len() * size_of::(); diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index edbc5e028..9abe9836d 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -807,9 +807,8 @@ fn art_with_raster_effects_draws_pattern_and_resolution_edits() { }); } -/// Symbol art with a live object (an envelope that distorts a pattern fill) is placed again in -/// every frame: when the pattern's tile type changes, the next frame draws what a new renderer -/// draws. +/// Symbol art with a live object (an envelope that distorts a pattern fill) is kept between +/// frames: when the pattern's tile type changes, the next frame draws what a new renderer draws. #[test] fn art_with_live_objects_draws_pattern_layout_edits() { let _fonts = fonts_steady(); @@ -845,6 +844,128 @@ fn art_with_live_objects_draws_pattern_layout_edits() { }); } +/// Instances of four symbols whose art shows a live object (a group holding an envelope, an +/// envelope, a blend and a grid repeat), and an inline graphic of the envelope's symbol: the second +/// frame draws the evaluations the first made, and draws what a new renderer draws. +#[test] +fn live_objects_in_symbol_art_keep_their_evaluations_between_frames() { + let _fonts = fonts_steady(); + use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun}; + use vectorcraft_geom::shapes::rectangle; + let mut d = Document::new(200.0, 100.0); + let band = |d: &mut Document| filled(d, rectangle(Rect::new(-15.0, -8.0, 15.0, 8.0)), Paint::solid(Color::rgb(0.0, 0.5, 0.0)), vec![]); + let b = band(&mut d); + let bent = arc_envelope(&mut d, vec![b], false); + let bent = Node::group(d.alloc_id(), vec![Arc::new(bent)]); + let b = band(&mut d); + let envelope = arc_envelope(&mut d, vec![b], false); + let square = |d: &mut Document, x: f64, c: Color| Arc::new(filled(d, rectangle(Rect::new(x, -4.0, x + 8.0, 4.0)), Paint::solid(c), vec![])); + let ends = vec![square(&mut d, -16.0, Color::rgb(1.0, 0.0, 0.0)), square(&mut d, 8.0, Color::rgb(0.0, 0.0, 1.0))]; + let spec = vectorcraft_doc::live::BlendSpec { spacing: vectorcraft_doc::live::BlendSpacing::Steps(4), ..Default::default() }; + let blend = Node::new(d.alloc_id(), NodeKind::Blend { children: ends, spec: Box::new(spec) }); + let source = symbol_art(&mut d); + let grid = Node::new(d.alloc_id(), NodeKind::Repeat(vectorcraft_doc::pattern::RepeatSpec::grid(vec![Arc::new(source)], 4.0, 4.0))); + let l = d.layers[0].id; + for (name, art, at) in + [("Bent", bent, (40.0, 25.0)), ("Envelope", envelope, (100.0, 25.0)), ("Blend", blend, (160.0, 25.0)), ("Grid", grid, (20.0, 60.0))] + { + d.symbols.push(vectorcraft_doc::Symbol { name: name.into(), art: Arc::new(art) }); + let id = d.alloc_id(); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate(at) })).unwrap(); + } + let style = CharStyle { size: 24.0, ..Default::default() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(120.0, 85.0), "A", style.clone()); + t.runs.push(TextRun::inline(InlineArt::new("Envelope"), style)); + let id = d.alloc_id(); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let first = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + let evaluated = r.live.evaluated; + assert!(evaluated >= 5, "the first frame evaluates the four live objects and the inline graphic's envelope: {evaluated}"); + let second = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + assert_eq!(r.live.evaluated, evaluated, "the second frame evaluates none of them again"); + assert!(second == first, "the second frame draws the same"); + assert!(second == Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &opts).pixels, "a new renderer draws the same"); +} + +/// A rectangle whose opacity mask is an envelope, drawn in Preview, and an envelope in a clip +/// group, drawn in Outline view: the second frame of each draws the evaluation the first made, and +/// draws what a new renderer draws. +#[test] +fn live_mask_art_and_live_clip_group_members_keep_their_evaluations_between_frames() { + let _fonts = fonts_steady(); + use vectorcraft_geom::shapes::rectangle; + let mut d = Document::new(200.0, 100.0); + let white = filled(&mut d, rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::solid(Color::WHITE), vec![]); + let mask = arc_envelope(&mut d, vec![white], false); + let mut masked = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Paint::solid(Color::BLACK), vec![]); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask, true))); + let clip = filled(&mut d, rectangle(Rect::new(110.0, 10.0, 190.0, 90.0)), Paint::None, vec![]); + let band = filled(&mut d, rectangle(Rect::new(100.0, 30.0, 200.0, 70.0)), Paint::solid(Color::rgb(0.0, 0.5, 0.0)), vec![]); + let bent = arc_envelope(&mut d, vec![band], false); + let clipped = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(bent)], clip: true }); + let d = with_art(d, vec![masked, clipped]); + let preview = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let outline = RenderOptions { outline: true, ..preview.clone() }; + for (view, opts) in [("Preview", &preview), ("Outline", &outline)] { + let mut r = Renderer::new(); + let first = r.render(&d, 200, 100, Affine::IDENTITY, opts).pixels; + let evaluated = r.live.evaluated; + assert!(evaluated >= 1, "{view}: the first frame evaluates the envelope: {evaluated}"); + let second = r.render(&d, 200, 100, Affine::IDENTITY, opts).pixels; + assert_eq!(r.live.evaluated, evaluated, "{view}: the second frame evaluates nothing again"); + assert!(second == first, "{view}: the second frame draws the same"); + assert!(second == Renderer::new().render(&d, 200, 100, Affine::IDENTITY, opts).pixels, "{view}: a new renderer draws the same"); + } +} + +/// Symbol art that is an envelope, or a group holding one, bends a band filled with pattern `Dots` +/// (an instance of `S` at half size in each 20 × 20 tile). After two frames of its instance, the +/// next frame draws an edit of `S`'s art (a red bar) and a change of `Dots` to bricks as a new +/// renderer draws them. +#[test] +fn live_objects_in_symbol_art_draw_symbol_and_pattern_edits() { + let _fonts = fonts_steady(); + use vectorcraft_doc::pattern::{PatternDef, TileType, pattern_paint}; + use vectorcraft_geom::shapes::rectangle; + for grouped in [false, true] { + for bricks in [false, true] { + let mut d = Document::new(200.0, 100.0); + let art = symbol_art(&mut d); + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + let xf = Affine::translate((10.0, 10.0)) * Affine::scale(0.5); + let dot = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf }); + let mut dots = PatternDef::new("Dots", vec![Arc::new(dot)]); + dots.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + d.patterns.push(dots); + let band = filled(&mut d, rectangle(Rect::new(-80.0, -30.0, 80.0, 30.0)), pattern_paint("Dots"), vec![]); + let envelope = arc_envelope(&mut d, vec![band], true); + let art = if grouped { Node::group(d.alloc_id(), vec![Arc::new(envelope)]) } else { envelope }; + d.symbols.push(vectorcraft_doc::Symbol { name: "Bent".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bent".into(), xf: Affine::translate((100.0, 50.0)) })).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + r.render(&d, 200, 100, Affine::IDENTITY, &opts); + let before = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + if bricks { + if let Some(def) = d.pattern_mut("Dots") { + def.tile_type = TileType::BrickByRow { offset: 0.5 }; + } + } else { + let bar = rectangle(Rect::new(-20.0, -4.0, 20.0, 4.0)); + let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + d.symbols[0].art = Arc::new(Node::path(d.alloc_id(), bar, red)); + } + let after = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + assert!(after != before, "grouped {grouped}, bricks {bricks}: the edit changes the frame"); + let fresh = Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + assert!(after == fresh, "grouped {grouped}, bricks {bricks}: a new renderer draws the same"); + } + } +} + /// An envelope of `d` bending `content` with an arc warp (30%); with `patterns`, Distort /// Appearance and Distort Pattern Fills are on, and the tiles of its pattern fills bend with it. fn arc_envelope(d: &mut Document, content: Vec, patterns: bool) -> Node { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 66c06f9da..aa851732a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -966,6 +966,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Type with transparency or effects:** type at less than 100% opacity, in a blend mode other than Normal, or with raster effects such as a drop shadow keeps its glyph outlines between frames, as opaque type does, and is laid out again when it is edited or the fonts change. At 2880 × 1800 on 8 threads, a page of 1,000 type objects takes 13 ms a frame instead of 23 ms at 80% opacity, 31 ms instead of 41 ms in Multiply and 16 ms instead of 26 ms with a drop shadow, with the same pixels. +- **Live objects in symbols and masks:** symbol instances and inline graphics whose symbol is a blend, an envelope, a repeat, an Intertwine object or a gradient mesh, or a group that holds one, draw the evaluation the renderer keeps for that object between frames, as the same object on the page does. So do opacity masks whose art is such an object, and such objects in clip groups in Outline view. The object is evaluated again when the symbols, patterns, fonts or color settings it shows change. Symbol art with effects of its own, or that holds a symbol instance, an image, a placed file, type with an inline graphic or a raster effect, is still placed and evaluated again in every frame. At 1000 × 1000 pixels, a page of 2,000 instances of a symbol that holds an envelope takes 28 ms a frame instead of 126 ms, with the same pixels. + - **Type drawn as glyph outlines:** under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, flattened with Convert All Text to Outlines, and inside an envelope, type keeps its underline and strikethrough, the weight and dashes of its character strokes when it is scaled, and the placement of its gradients and patterns, as the canvas draws it. A group's or layer's own fills and strokes also paint the underline and strikethrough of the type in it. - **Texture redraws (#1257):** Craquelure, Mosaic Tiles and Stained Glass compare squared distances when finding their nearest cells; rounding/tie boundaries use the original distance ordering. Brute-force candidate tests and three 1800×1500 viewport comparisons preserve the old pixels. A CMYK rectangle move at 190.07%/DPI 1.5 is reproducible with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`; idle-machine Windows interaction is still unverified.