From bba0618f552944a1edc5f387668f7493accb56a2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 20:30:59 -0700 Subject: [PATCH] Render: brush art of symbol instances, inline graphics and repeat copies is kept between frames The renderer kept brush art per node id and stroke. The copies drawn for symbol instances, inline graphics and repeat copies share their art's ids, so they replaced each other's brush art and it was made again in every frame. The pieces were drawn as plain nodes, so brush art that is a live object, such as an envelope that a scatter brush places, was evaluated again in every frame, for a path on the page too. The brush cache now also keys brush art by the stroked path, so each copy keeps its own. Kept pieces are drawn through the renderer's caches: their stroke outlines, live-effect geometry and live object evaluations are kept between frames. Each frame draws what a new renderer draws. On one thread at 1000 x 1000 pixels, 2,000 paths on the page whose scatter brush art is an envelope took 400 ms a frame on main and take 94 ms, and the fastest frames of 2,000 instances of a symbol whose art is a brushed path went from 16 ms to 12 ms, with the same pixels. Docs: docs/target-app-parity.md --- crates/render/src/brush_fx.rs | 95 ++++++++++++++---- crates/render/src/lib.rs | 23 ++--- crates/render/src/live.rs | 5 +- crates/render/src/tests_memory.rs | 158 ++++++++++++++++++++++++++++-- docs/target-app-parity.md | 2 + 5 files changed, 247 insertions(+), 36 deletions(-) diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index e62e68418..a2aa34e9f 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -1,9 +1,12 @@ //! Brushed strokes and symbol-instance staining in the renderer. //! //! A stroke with `brush: Some(name)` is painted as the filled art `vectorcraft_brush::stroke_pieces` -//! generates. Pieces are cached per (node, stroke item) and validated by the node's path, the +//! generates. Pieces are cached per (node id, stroke item, path) and validated by the path, the //! stroke layer and the brush definition (an `Arc` from the per-frame parsed library), so edits to -//! any of them regenerate the art and nothing else does. +//! any of them regenerate the art and nothing else does. The copies of an object that share its id +//! (symbol instances, inline graphics, repeat copies) have their own paths, so each keeps its own +//! pieces. The pieces keep their `Arc`s while they are cached, so their stroke outlines, live-effect +//! geometry and the evaluations of live objects come from the renderer's caches too. use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; @@ -11,7 +14,7 @@ use std::sync::Arc; use vectorcraft_brush::Brush; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind, StrokeLayer, SymbolBoxes}; -use vectorcraft_geom::{BezPath, Rect}; +use vectorcraft_geom::{BezPath, PathEl, Point, Rect}; use vello_cpu::RenderContext; use crate::lru::{self, FrameCache}; @@ -21,7 +24,7 @@ pub(crate) struct Entry { path: BezPath, stroke: StrokeLayer, brush: Arc, - pieces: Arc>, + pieces: Arc>>, } /// Parsed brush library and generated brush art. @@ -34,12 +37,24 @@ pub(crate) struct BrushCache { /// (frame stamp, document address) the library was last validated for. checked: (u64, usize), lib: HashMap>, - pub(crate) entries: FrameCache<(u64, usize), Entry>, + /// Brush art per (node id, stroke item index, [`path_key`] of the stroked path). + pub(crate) entries: FrameCache<(u64, usize, u64), Entry>, + /// How many times [`Renderer::draw_brush`] made brush art. + #[cfg(test)] + pub(crate) made: usize, } impl Default for BrushCache { fn default() -> Self { - Self { src: None, key: 0, checked: (0, 0), lib: HashMap::new(), entries: FrameCache::new(lru::BRUSH_BUDGET, 512) } + Self { + src: None, + key: 0, + checked: (0, 0), + lib: HashMap::new(), + entries: FrameCache::new(lru::BRUSH_BUDGET, 512), + #[cfg(test)] + made: 0, + } } } @@ -94,6 +109,26 @@ pub(crate) fn pad(symbols: &SymbolBoxes, n: &Node) -> f64 { pub use vectorcraft_brush::instance_art; +/// A hash of the kinds and points of `bp`'s elements. With the node id and the stroke item, it +/// forms the key of the brush art made along `bp`. +fn path_key(bp: &BezPath) -> u64 { + let mut h = DefaultHasher::new(); + for el in bp.elements() { + let (kind, points) = match *el { + PathEl::MoveTo(p) => (0u8, [p, Point::ZERO, Point::ZERO]), + PathEl::LineTo(p) => (1, [p, Point::ZERO, Point::ZERO]), + PathEl::QuadTo(a, b) => (2, [a, b, Point::ZERO]), + PathEl::CurveTo(a, b, c) => (3, [a, b, c]), + PathEl::ClosePath => (4, [Point::ZERO; 3]), + }; + kind.hash(&mut h); + for p in points { + (p.x.to_bits(), p.y.to_bits()).hash(&mut h); + } + } + h.finish() +} + impl Renderer { /// Paint stroke `st` of `n` (outline `bp`) with its brush. Returns false when the brush is /// unknown (the caller then draws a plain stroke). @@ -102,22 +137,24 @@ impl Renderer { self.brushes.library(f.doc, self.stamp); let Some(brush) = self.brushes.lib.get(name).cloned() else { return false }; let idx = n.appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(s) if std::ptr::eq(s, st))).unwrap_or(usize::MAX); - let key = (n.id.0, idx); + let key = (n.id.0, idx, path_key(bp)); let stamp = self.stamp; - let pieces = match self.brushes.entries.get(&key, stamp) { - Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => e.pieces.clone(), + let (pieces, kept) = match self.brushes.entries.get(&key, stamp) { + Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => (e.pieces.clone(), true), _ => { - let pieces = Arc::new(vectorcraft_brush::stroke_pieces(f.doc, &brush, bp, st)); - let bytes = size_of::() + lru::path_bytes(bp) + pieces.iter().map(lru::node_bytes).sum::(); - self.brushes.entries.insert(key, Entry { path: bp.clone(), stroke: st.clone(), brush, pieces: pieces.clone() }, bytes, stamp); - pieces - } - }; - let mut draw = |r: &mut Self, c: &mut RenderContext, fr: &Frame| { - for p in pieces.iter() { - r.draw_node(c, fr, p, true); + #[cfg(test)] + { + self.brushes.made += 1; + } + let pieces: Arc>> = + Arc::new(vectorcraft_brush::stroke_pieces(f.doc, &brush, bp, st).into_iter().map(Arc::new).collect()); + let bytes = size_of::() + lru::path_bytes(bp) + pieces.iter().map(|p| lru::node_bytes(p)).sum::(); + let kept = + self.brushes.entries.insert(key, Entry { path: bp.clone(), stroke: st.clone(), brush, pieces: pieces.clone() }, bytes, stamp); + (pieces, kept) } }; + let mut draw = |r: &mut Self, c: &mut RenderContext, fr: &Frame| r.draw_brush_pieces(c, fr, &pieces, kept); if st.opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal { self.group(ctx, f, crate::group::Composite { blend: st.blend, opacity: st.opacity, ..Default::default() }, &mut draw); } else { @@ -125,6 +162,28 @@ impl Renderer { } true } + + /// Draw brush art `pieces`, each whatever its visibility and bounds. A piece that is a path + /// without effects is drawn as `cur`. When the brush cache keeps the pieces (`kept`), their + /// `Arc`s stay the same from frame to frame: such a path piece takes its stroke outlines from the + /// stroke cache as on the fast path ([`Self::cached_stroke`]), and each piece is also drawn as + /// `fx_owner` through [`Self::draw_kept_node`], so its live-effect geometry and, when it is a + /// live object, its evaluation come from the caches. Pieces the cache does not keep are drawn as + /// art made again ([`Self::made_again`]). + fn draw_brush_pieces(&mut self, ctx: &mut RenderContext, f: &Frame, pieces: &[Arc], kept: bool) { + let outer = (std::mem::replace(&mut self.made_again, !kept), self.cur.take()); + for p in pieces { + self.cur = (matches!(p.kind, NodeKind::Path { guide: false, .. }) && !crate::fx::has_fx(p)).then(|| p.clone()); + if kept { + let owner = self.fx_owner.replace(p.clone()); + self.draw_kept_node(ctx, f, p); + self.fx_owner = owner; + } else { + self.draw_node(ctx, f, p, true); + } + } + (self.made_again, self.cur) = outer; + } } #[cfg(test)] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 98356862d..f87832fb4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -466,11 +466,12 @@ pub struct Renderer { /// 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). + /// The node being drawn on the plain-path fast path, or the path of brush art being drawn + /// ([`Self::draw_brush_pieces`]): the owner of cached stroke outlines. cur: Option>, /// 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. + /// [`Self::placed_art`] did not keep, evaluations of live objects and effect art that their + /// caches did not keep, and brush art that the brush cache did not keep. made_again: bool, /// The node `draw_shape_fx` may cache the live-effect geometry of (the one `draw_arc` is drawing). /// In Outline view its path comes from `geom` too ([`Self::outline_path`]), and @@ -478,8 +479,8 @@ pub struct Renderer { /// 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. + /// a member of a clip group in Outline view, kept brush art): [`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, @@ -1736,11 +1737,11 @@ 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. Only the node's own - /// strokes are cached: for the strokes of the brush art drawn for it, this returns `None`. + /// The line part of a stroke of `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. Only `self.cur`'s own strokes are cached: for other + /// strokes, this returns `None`. fn cached_stroke(&mut self, f: &Frame, line: &BezPath, st: &StrokeLayer, width: f64) -> Option> { let node = self.cur.clone().filter(|n| owns_stroke(n, st))?; let level = (f.px * 0.25).log2().floor() as i32; @@ -1756,7 +1757,7 @@ impl Renderer { Some(outline) } - /// What the `strokes` and `stroke_slices` keys hold for the fast-path node `node`: its address, + /// What the `strokes` and `stroke_slices` keys hold for `node` (`self.cur`): its address, /// or 0 while art made again in every frame is drawn ([`Self::made_again`]). fn stroke_owner(&self, node: &Arc) -> usize { if self.made_again { 0 } else { Arc::as_ptr(node) as usize } diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 992a7051d..a31bbcdb9 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -565,7 +565,10 @@ impl Renderer { } } - /// Draw a live node reached without its `Arc` (thumbnails, clip fallbacks): uncached. + /// Draw a live node that [`Self::draw_content`] reached without its kept `Arc` + /// ([`Self::live_root`]): the node of a thumbnail ([`Self::render_node_thumbnail_with`]), symbol + /// art placed again in every frame, color glyph art, and brush art that the brush cache did not + /// keep. It evaluates a copy of the node and does not use the live cache. pub(crate) fn draw_live_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) { let a = Arc::new(n.clone()); self.draw_live_body(ctx, f, &a, false); diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index 8b1751c85..69ebd726b 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -576,12 +576,13 @@ 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. +/// The instances of a symbol, the copies of a repeat and a path of the document keep their own +/// brush art, and a brushed path gets new brush art each time its brush changes. A frame of the +/// unchanged document draws the stroke outlines and gradient slices that the frame before it made +/// for the squares' strokes, and each frame draws what a new renderer draws, with or without room +/// in the brush cache. #[test] -fn strokes_of_brush_art_made_again_draw_as_a_new_renderer_does() { +fn strokes_of_brush_art_draw_as_a_new_renderer_does() { let _fonts = fonts_steady(); use vectorcraft_brush::{Brush, BrushKind, Scatter}; use vectorcraft_doc::{Appearance, StrokeGradientMode}; @@ -629,13 +630,158 @@ fn strokes_of_brush_art_made_again_draw_as_a_new_renderer_does() { 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(); + let mut full = Renderer::new(); + full.brushes.entries = FrameCache::new(0, 0); + let mut before = (vec![], vec![]); // 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!(full.render(&d, 200, 140, Affine::IDENTITY, &opts).pixels == want, "no room, frame {frame}"); + if frame == 4 { + before = stroke_outlines(&r); + assert!(!before.0.is_empty() && !before.1.is_empty(), "the squares' strokes keep outlines and slices"); + } else if frame == 5 { + assert_eq!(used_again(&r, &before), (before.0.len(), before.1.len()), "the sixth frame draws those the fifth used"); + } } - assert_eq!((r.strokes.len(), r.stroke_slices.len()), (0, 0), "no outline or slices kept for the strokes of brush art"); +} + +/// A line 24 × 12 pt around the origin, stroked 1 pt in black with the 10 pt. Oval brush. +fn brushed_line(d: &mut Document) -> Node { + let mut ap = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + if let Some(s) = ap.stroke_mut() { + s.brush = Some("10 pt. Oval".into()); + } + Node::path(d.alloc_id(), vectorcraft_geom::shapes::line((-12.0, -6.0).into(), (12.0, 6.0).into()), ap) +} + +/// Copies of [`brushed_line`] that share an id: three instances of symbol `Line` (the line), two +/// of symbol `Held` (a group holding the line), type showing `Line` as two inline graphics, and a +/// Grid Repeat of the line (nine copies). +fn brushed_copies_doc() -> Document { + use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun}; + let mut d = Document::new(200.0, 100.0); + let line = brushed_line(&mut d); + d.symbols.push(vectorcraft_doc::Symbol { name: "Line".into(), art: Arc::new(line) }); + let line = brushed_line(&mut d); + let held = Node::group(d.alloc_id(), vec![Arc::new(line)]); + d.symbols.push(vectorcraft_doc::Symbol { name: "Held".into(), art: Arc::new(held) }); + let l = d.layers[0].id; + for (symbol, at) in [("Line", (20.0, 15.0)), ("Line", (60.0, 15.0)), ("Line", (100.0, 15.0)), ("Held", (20.0, 45.0)), ("Held", (60.0, 45.0))] { + let id = d.alloc_id(); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: symbol.into(), xf: Affine::translate(at) })).unwrap(); + } + let style = CharStyle { size: 24.0, ..Default::default() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 85.0), "A", style.clone()); + t.runs.push(TextRun::inline(InlineArt::new("Line"), style.clone())); + t.runs.push(TextRun::new("B", style.clone())); + t.runs.push(TextRun::inline(InlineArt::new("Line"), style)); + let id = d.alloc_id(); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + let mut source = brushed_line(&mut d); + source.transform(Affine::translate((140.0, 20.0)), true); + let grid = 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(grid))).unwrap(); + d +} + +/// The copies of [`brushed_copies_doc`] keep their own brush art: the second frame makes none, +/// and draws what the first drew and what a new renderer draws. With no room in the brush cache, +/// every frame makes the brush art of each copy again and draws the same. +#[test] +fn brushed_copies_keep_their_own_brush_art_between_frames() { + let _fonts = fonts_steady(); + let d = brushed_copies_doc(); + 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 made = r.brushes.made; + assert_eq!(made, 16, "the first frame makes brush art for each of the 16 copies"); + let second = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + assert_eq!(r.brushes.made, made, "the second frame makes none"); + 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"); + let mut full = Renderer::new(); + full.brushes.entries = FrameCache::new(0, 0); + for n in 1..=2 { + assert!(full.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == first, "no room, frame {n}: the same pixels"); + assert_eq!(full.brushes.made, 16 * n, "no room, frame {n}: the brush art of each copy is made again"); + } +} + +/// After a frame of [`brushed_copies_doc`], the next frame draws an edit of `Line`'s art (the line +/// turns), of the brush (its nib grows to 20 pt) and of the stroke of `Held`'s line (red instead +/// of black) as a new renderer draws it. +#[test] +fn brush_art_of_copies_draws_symbol_brush_and_stroke_edits() { + let _fonts = fonts_steady(); + edit_draws_as_new(brushed_copies_doc(), |d| { + let mut line = brushed_line(d); + if let NodeKind::Path { path, .. } = &mut line.kind { + *path = vectorcraft_geom::shapes::line((-12.0, 6.0).into(), (12.0, -6.0).into()); + } + d.symbols[0].art = Arc::new(line); + }); + edit_draws_as_new(brushed_copies_doc(), |d| { + let mut lib = vectorcraft_brush::library(d); + if let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "10 pt. Oval").map(|b| &mut b.kind) { + c.size = 20.0; + } + vectorcraft_brush::store(d, &lib); + }); + edit_draws_as_new(brushed_copies_doc(), |d| { + let mut held = (*d.symbols[1].art).clone(); + if let Some([line]) = held.children_mut().map(Vec::as_mut_slice) + && let Some(st) = Arc::make_mut(line).appearance.stroke_mut() + { + st.paint = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + } + d.symbols[1].art = Arc::new(held); + }); +} + +/// A path of the document and an instance of symbol `Wave` (a group holding another path) are +/// stroked with scatter brush `Bent`, whose art is an arc envelope around a green band: the second +/// frame evaluates none of the envelopes in their brush art again, and draws what the first drew +/// and what a new renderer draws. +#[test] +fn live_objects_in_brush_art_keep_their_evaluations_between_frames() { + let _fonts = fonts_steady(); + use vectorcraft_brush::{Brush, BrushKind, Scatter}; + use vectorcraft_geom::shapes::{line, rectangle}; + let mut d = Document::new(200.0, 100.0); + let band = filled(&mut d, rectangle(Rect::new(-4.0, -3.0, 4.0, 3.0)), Paint::solid(Color::rgb(0.0, 0.5, 0.0)), vec![]); + let art = arc_envelope(&mut d, vec![band], false); + let mut lib = vectorcraft_brush::library(&d); + lib.push(Brush { name: "Bent".into(), kind: BrushKind::Scatter(Scatter { art, spacing: (150.0, 150.0), ..Default::default() }) }); + vectorcraft_brush::store(&mut d, &lib); + let bent = |d: &mut Document, from: (f64, f64), to: (f64, f64)| { + let mut ap = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + if let Some(s) = ap.stroke_mut() { + s.brush = Some("Bent".into()); + } + Node::path(d.alloc_id(), line(from.into(), to.into()), ap) + }; + let held = bent(&mut d, (-60.0, 0.0), (60.0, 0.0)); + let held = Node::group(d.alloc_id(), vec![Arc::new(held)]); + d.symbols.push(vectorcraft_doc::Symbol { name: "Wave".into(), art: Arc::new(held) }); + let path = bent(&mut d, (20.0, 25.0), (180.0, 25.0)); + let l = d.layers[0].id; + d.insert(Some(l), usize::MAX, path).unwrap(); + let id = d.alloc_id(); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Wave".into(), xf: Affine::translate((100.0, 70.0)) })).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 >= 2, "the first frame evaluates the envelopes of both paths' brush art: {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"); } /// Moving an instance places its art again, and editing a symbol's art places the art of its diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 77ec3230b..4fff1312d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -989,6 +989,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. +- **Brush art of copies:** symbol instances, inline graphics and repeat copies of a brushed path keep their own brush art between frames, as the same path on the page does. The renderer also keeps the stroke outlines of brush art and the evaluation of a live object in it, such as an envelope that a scatter brush places. The brush art is made again when the path, its stroke or the brush changes. On one thread at 1000 × 1000 pixels, 2,000 paths on the page whose scatter brush art is an envelope take 94 ms a frame instead of 400 ms, and the fastest frames of 2,000 instances of a symbol whose art is a brushed path take 12 ms instead of 16 ms, 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.