diff --git a/crates/render/src/frame.rs b/crates/render/src/frame.rs index 41a53201a..df71c6da9 100644 --- a/crates/render/src/frame.rs +++ b/crates/render/src/frame.rs @@ -1,6 +1,6 @@ use super::options::{AntiAlias, RenderOptions}; use super::threads::single_threaded_context; -use super::{Affine, Arc, BezPath, Document, FillRule, HashMap, Ink, Node, Rect, RenderContext, StrokeLayer, SymbolBoxes, TextObject, live}; +use super::{Affine, Arc, BezPath, Document, FillRule, HashMap, Ink, Node, Rect, RenderContext, StrokeLayer, SymbolBoxes, TextObject, kurbo, live}; /// Hasher for pointer-keyed caches: a multiply-xorshift (SipHash showed up in frame profiles). #[derive(Default, Clone, Copy)] @@ -51,10 +51,25 @@ pub(crate) struct StrokeEntry { /// The slices of a stroke whose gradient runs along or across it, each with its paint and bounds. pub(crate) type Slices = Arc>; -/// [`Slices`] cached for a stroke: (owning node, slices, last frame used). +/// The key of cached [`Slices`]: ([`Renderer::stroke_owner`] of the stroke's object, stroke layer +/// address, tolerance level). +pub(crate) type SliceKey = (usize, usize, i32); + +/// What the [`SliceKey`] of the latest slices of an object's stroke is found under once the object +/// has changed: (the object's id, the stroke's place in its appearance items, tolerance level). +pub(crate) type MovedSliceKey = (u64, usize, i32); + +/// [`Slices`] cached for a stroke: (owning node, the node whose path and stroke the slices were +/// made from, either the owning node or an earlier copy of it that the owning node is moved from, +/// the slices, the offset they are drawn moved by, the fill rule they were made with, whether they +/// were made along the path [`Node::stroke_path`] makes from that node, last frame used). pub(crate) struct SliceEntry { pub(crate) node: Arc, + pub(crate) made: Arc, pub(crate) slices: Slices, + pub(crate) by: kurbo::Vec2, + pub(crate) rule: FillRule, + pub(crate) own: bool, pub(crate) stamp: u64, } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b9852d0a1..cf9114846 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -168,7 +168,11 @@ pub struct Renderer { clip_paints: FrameCache, /// Gradients along or across strokes (see [`Self::fill_path_gradient`]), keyed by /// ([`Self::stroke_owner`], stroke layer address, tolerance level). - stroke_slices: PtrMap<(usize, usize, i32), SliceEntry>, + stroke_slices: PtrMap, + /// The key in `stroke_slices` of the latest slices of each stroke of an object, under its + /// [`frame::MovedSliceKey`]: a moved copy of the object draws those slices moved + /// ([`Self::moved_slices`]). + moved_slices: PtrMap, /// What the image keys colour adjustments give their recoloured images name: the image /// recoloured and the adjustment, with its hash (see [`Self::adjusted_image`]). adjusts: HashMap, diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index e8aa40d3e..2f69551c1 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -19,7 +19,7 @@ use super::{ }; #[cfg(test)] -pub(crate) use paint::{STROKES_OUTLINED, anchors_moved, offset_between}; +pub(crate) use paint::{SLICES_MADE, STROKES_OUTLINED, anchors_moved, offset_between}; mod images; mod nodes; @@ -76,6 +76,7 @@ impl Renderer { twice_drawn: 0, clip_paints: FrameCache::new(lru::CLIP_BUDGET, 256), stroke_slices: PtrMap::default(), + moved_slices: PtrMap::default(), adjusts: HashMap::new(), dim_images: None, inline_depth: 0, @@ -195,6 +196,8 @@ impl Renderer { } if self.stroke_slices.len() > 256 { self.stroke_slices.retain(|_, e| g - e.stamp <= 3); + let slices = &self.stroke_slices; + self.moved_slices.retain(|_, k| slices.contains_key(k)); } if self.shadows.len() > 256 { self.shadows.retain(|_, e| g - e.stamp <= 3); diff --git a/crates/render/src/renderer/paint.rs b/crates/render/src/renderer/paint.rs index 5228cc881..6d9f0ea3e 100644 --- a/crates/render/src/renderer/paint.rs +++ b/crates/render/src/renderer/paint.rs @@ -3,7 +3,7 @@ use super::{ Slices, StrokeAlign, StrokeEntry, StrokeLayer, TextGeom, blend_mode, drawable, effects, fill_rule, fx, kurbo, lru, owns_stroke, peniko, rects_overlap, }; -use crate::frame::{MovedKey, StrokeKey}; +use crate::frame::{MovedKey, MovedSliceKey, StrokeKey}; use crate::paint; impl Renderer { @@ -164,7 +164,9 @@ impl Renderer { /// Paint the line and arrowheads (`pieces`) of stroke `st` of `bp` (filled with `rule`) with /// its gradient `g` laid along or across the path ([`effects::stroke::gradient_slices`]). Each /// piece clips the slices, grown a pixel into each other and each replacing what it covers, so - /// neighbours meet without seams and translucent colours paint once. + /// neighbours meet without seams and translucent colours paint once. The slices of a stroke + /// of `self.cur` are kept in `stroke_slices`, and a moved copy of the object draws the slices + /// made before it moved, moved by the same offset ([`Self::moved_slices`]). #[allow(clippy::too_many_arguments)] fn fill_path_gradient( &mut self, @@ -180,6 +182,8 @@ impl Renderer { ) { let level = (f.px * 0.25).log2().floor() as i32; let make = || -> Slices { + #[cfg(test)] + SLICES_MADE.with(|n| n.set(n.get() + 1)); let tol = 2f64.powi(level); // Reach a pixel past the stroke, so its antialiased edge has colour under it. let slices = effects::stroke::gradient_slices(bp, rule, st, tol, f.px + tol, f.px); @@ -193,22 +197,37 @@ impl Renderer { .collect(), ) }; - let slices = match self.cur.clone().filter(|n| owns_stroke(n, st)) { + let (slices, by) = match self.cur.clone().filter(|n| owns_stroke(n, st)) { Some(node) => { let key = (self.stroke_owner(Arc::as_ptr(&node) as usize), st as *const StrokeLayer as usize, level); match self.stroke_slices.get_mut(&key) { Some(e) if Arc::ptr_eq(&e.node, &node) => { e.stamp = self.stamp; - e.slices.clone() + (e.slices.clone(), e.by) } _ => { - let slices = make(); - self.stroke_slices.insert(key, SliceEntry { node, slices: slices.clone(), stamp: self.stamp }); - slices + // Under its object's id, the stroke's place and the tolerance level, the key + // of the stroke's latest slices is kept, except for art made again in every + // frame. + let place = node.appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(s) if std::ptr::eq(s, st))); + let moved = place.filter(|_| !self.made_again).map(|place| (node.id.0, place, level)); + let kept = moved.and_then(|mk| self.moved_slices(mk, &node, st, rule, bp)); + let (made, slices, by, own) = match kept { + Some((made, slices, by)) => (made, slices, by, true), + None => { + let own = moved.is_some() && self.own_path(&node, bp); + (node.clone(), make(), kurbo::Vec2::ZERO, own) + } + }; + self.stroke_slices.insert(key, SliceEntry { node, made, slices: slices.clone(), by, rule, own, stamp: self.stamp }); + if let Some(mk) = moved { + self.moved_slices.insert(mk, key); + } + (slices, by) } } } - None => make(), + None => (make(), kurbo::Vec2::ZERO), }; let line = match self.cached_stroke(f, chars, bp, &pieces.line, st, width) { Some(o) => o, @@ -223,8 +242,18 @@ impl Renderer { ctx.push_clip_layer(clip); ctx.set_blend_mode(BlendMode::new(Mix::Normal, Compose::Copy)); for (shape, paint, b) in slices.iter() { - if rects_overlap(*b, cb) && paint::set_paint(ctx, paint, *b, f) { - ctx.fill_path(shape); + let b = *b + by; + if !rects_overlap(b, cb) { + continue; + } + if by == kurbo::Vec2::ZERO { + if paint::set_paint(ctx, paint, b, f) { + ctx.fill_path(shape); + } + } else if paint::set_paint(ctx, &moved_paint(paint, by), b, f) { + let mut shape = shape.clone(); + shape.apply_affine(Affine::translate(by)); + ctx.fill_path(&shape); } } ctx.set_blend_mode(BlendMode::default()); @@ -364,6 +393,38 @@ impl Renderer { Some((Arc::new(outline), crop)) } + /// The slices of stroke `st` of `node` along `bp` (filled with `rule`), made from the latest + /// slices kept under `mk` ([`MovedSliceKey`]) when those were made along their object's own + /// path, their object is `node` changed, its stroke there is `st` and its fill rule `rule`, and + /// `bp` is its path moved (to within rounding): the node they were made from, those slices, + /// and the offset from that node to `bp`, which the slices are drawn moved by. The anchors of + /// the two objects are compared first ([`anchors_moved`]), and the earlier path is made again + /// from its object only when they match. `None` when there are no such slices. + fn moved_slices( + &self, + mk: MovedSliceKey, + node: &Node, + st: &StrokeLayer, + rule: FillRule, + bp: &BezPath, + ) -> Option<(Arc, Slices, kurbo::Vec2)> { + let e = self.stroke_slices.get(self.moved_slices.get(&mk)?)?; + if !e.own || e.rule != rule || e.made.id != node.id { + return None; + } + let Some(AppearanceItem::Stroke(old)) = e.made.appearance.items.get(mk.1) else { + return None; + }; + if !std::ptr::eq(old, st) && old != st { + return None; + } + if !anchors_moved(&e.made, node) { + return None; + } + let by = offset_between(&e.made.stroke_path()?, bp)?; + Some((e.made.clone(), e.slices.clone(), by)) + } + /// What the `strokes`, `char_strokes` and `stroke_slices` keys hold for a stroke owner at /// `address` (`self.cur` or kept glyph geometry): the address, or 0 while art made again in /// every frame is drawn ([`Self::made_again`]). @@ -382,6 +443,24 @@ thread_local! { pub(crate) static CROP_STROKES: std::cell::Cell = const { std::cell::Cell::new(true) }; /// How many stroke outlines of nodes [`Renderer::cached_stroke`] has made on this thread. pub(crate) static STROKES_OUTLINED: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// How many sets of gradient stroke slices [`Renderer::fill_path_gradient`] has made on this + /// thread. + pub(crate) static SLICES_MADE: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Paint `p` of a slice ([`Slices`]) moved by `by`: a gradient's start, end and focal point moved. +fn moved_paint(p: &vectorcraft_color::Paint, by: kurbo::Vec2) -> vectorcraft_color::Paint { + let mut p = p.clone(); + if let vectorcraft_color::Paint::Gradient(g) = &mut p + && let Some(geom) = &mut g.geom + { + geom.start += by; + geom.end += by; + if let Some(focal) = &mut geom.focal { + *focal += by; + } + } + p } /// The offset that moves path `was` onto path `now`, when `now` is `was` moved (each point within diff --git a/crates/render/src/tests/movedstroke.rs b/crates/render/src/tests/movedstroke.rs index de35aa592..81fa40da8 100644 --- a/crates/render/src/tests/movedstroke.rs +++ b/crates/render/src/tests/movedstroke.rs @@ -1,14 +1,16 @@ -//! A moved object draws its stroke from the outline made for it before it moved, moved by the same +//! A moved object draws its stroke from the outline made for it before it moved, and a gradient +//! along or across its stroke from the slices made for it before it moved, moved by the same //! offset, with the pixels a new renderer draws. A change other than a move outlines the stroke -//! again. +//! and makes the slices again. -use vectorcraft_color::{Color, Gradient, GradientPaint, Paint}; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{ - Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, WidthProfile, + Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeGradientMode, StrokeLayer, + WidthProfile, }; use vectorcraft_geom::{PathData, Point, Vec2}; -use crate::renderer::{STROKES_OUTLINED, anchors_moved, offset_between}; +use crate::renderer::{SLICES_MADE, STROKES_OUTLINED, anchors_moved, offset_between}; use crate::*; const SIZE: u32 = 200; @@ -326,6 +328,119 @@ fn points_each_near_the_one_before_are_checked_against_one_offset() { assert!(!anchors_moved(&node(&was), &node(&edited))); } +/// How many sets of gradient stroke slices the renderers on this thread have made. +fn sliced() -> usize { + SLICES_MADE.with(std::cell::Cell::get) +} + +/// A stroke of `width` whose black to blue gradient runs `mode` (along or across the path). +fn gradient_stroke(width: f64, mode: StrokeGradientMode) -> StrokeLayer { + let g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]); + let mut st = stroke(width, LineJoin::Round, LineCap::Butt); + st.paint = Paint::Gradient(Box::new(GradientPaint::new(g))); + st.gradient_mode = mode; + st +} + +/// The documents of the gradient cases: open and closed paths, short and long (their line outlined +/// near the view), each with a gradient along and across the stroke, with the point the view is +/// centered on. +fn gradient_cases() -> Vec<(String, (Document, NodeId), Point)> { + let mut out = vec![]; + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + let only = || vec![AppearanceItem::Stroke(gradient_stroke(12.0, mode))]; + out.push((format!("a short open path, {mode:?}"), doc(&zigzag(20), only()), Point::new(70.0, 15.0))); + out.push((format!("a short closed path, {mode:?}"), doc(&ring(40, 80.0), only()), Point::new(80.0, 0.0))); + out.push((format!("a long open path, {mode:?}"), doc(&zigzag(3000), only()), Point::new(12_000.0, 15.0))); + out.push((format!("a long closed path, {mode:?}"), doc(&ring(4000, 5000.0), only()), Point::new(0.0, 5000.0))); + } + out +} + +/// Draws `d` after `change` with `r` and with a new renderer, checks that the two draw the same +/// pixels, and returns how many sets of slices `r` made. +fn after_sliced(r: &mut Renderer, d: &mut Document, id: NodeId, at: Point, change: impl FnOnce(&mut Node)) -> usize { + change(d.node_mut(id).unwrap()); + let before = sliced(); + let img = render(r, d, at); + let made = sliced() - before; + assert!(img.pixels == render(&mut Renderer::new(), d, at).pixels, "the same pixels as a new renderer"); + assert!(img.pixels.chunks(4).any(|p| p[0] < 128), "the stroke shows"); + made +} + +/// Sets the color of the last stop of each stroke's gradient in `n`. +fn recolor(n: &mut Node, color: Color) { + restroke(n, |s| { + if let Paint::Gradient(g) = &mut s.paint + && let Some(stop) = g.gradient.stops.last_mut() + { + stop.color = color; + } + }) +} + +#[test] +fn a_moved_path_draws_its_gradient_stroke_from_the_slices_made_before() { + for (what, (mut d, id), at) in gradient_cases() { + let mut r = Renderer::new(); + render(&mut r, &d, at); + for by in [Vec2::new(1.0, 0.0), Vec2::new(0.25, -0.5), Vec2::new(-3.0, 2.0), Vec2::new(0.1, 0.0)] { + let made = after_sliced(&mut r, &mut d, id, at, |n| n.transform(Affine::translate(by), true)); + assert_eq!(made, 0, "{what}, moved by {by:?}: no new slices"); + } + assert_eq!(after_sliced(&mut r, &mut d, id, at, |_| {}), 0, "{what}: drawn again after the moves"); + } +} + +#[test] +fn a_path_changed_otherwise_makes_its_gradient_stroke_slices_again() { + let edit_anchor = |n: &mut Node| { + let NodeKind::Path { path, .. } = &mut n.kind else { + panic!("not a path"); + }; + let mut els = path.to_bezpath().elements().to_vec(); + if let Some(kurbo::PathEl::LineTo(p)) = els.get_mut(2) { + *p += Vec2::new(0.0, 3.0); + } + *path = PathData::from_bezpath(&BezPath::from_vec(els)); + }; + for (what, (mut d, id), at) in gradient_cases() { + let mut r = Renderer::new(); + render(&mut r, &d, at); + let nudge = |n: &mut Node| n.transform(Affine::translate((0.5, 0.0)), true); + assert_eq!(after_sliced(&mut r, &mut d, id, at, nudge), 0, "{what}: moved"); + assert_eq!(after_sliced(&mut r, &mut d, id, at, edit_anchor), 1, "{what}: an anchor moved"); + // A move after the edit uses the slices made for the edited path. + assert_eq!(after_sliced(&mut r, &mut d, id, at, nudge), 0, "{what}: moved after the edit"); + // Scaled and rotated about the view's center, stroke weights kept. + assert_eq!(after_sliced(&mut r, &mut d, id, at, |n| n.transform(Affine::scale_about(1.001, at), false)), 1, "{what}: scaled"); + assert_eq!(after_sliced(&mut r, &mut d, id, at, |n| n.transform(Affine::rotate_about(0.001, at), false)), 1, "{what}: rotated"); + assert_eq!(after_sliced(&mut r, &mut d, id, at, |n| recolor(n, Color::rgb(0.0, 0.5, 0.0))), 1, "{what}: another gradient"); + // Moved with the gradient changed back: the slices of that gradient made before are not used. + assert_eq!( + after_sliced(&mut r, &mut d, id, at, |n| { + recolor(n, Color::rgb(0.0, 0.0, 1.0)); + nudge(n); + }), + 1, + "{what}: the gradient changed back and moved" + ); + // Moved with the gradient laid the other way. + let turn = |s: &mut StrokeLayer| { + s.gradient_mode = if s.gradient_mode == StrokeGradientMode::Along { StrokeGradientMode::Across } else { StrokeGradientMode::Along } + }; + assert_eq!( + after_sliced(&mut r, &mut d, id, at, |n| { + restroke(n, turn); + nudge(n); + }), + 1, + "{what}: the gradient laid the other way and moved" + ); + } +} + #[test] fn a_path_moved_out_of_the_region_its_outline_was_cropped_to_is_outlined_again() { let (mut d, id) = doc(&zigzag(3000), vec![AppearanceItem::Stroke(stroke(6.0, LineJoin::Miter, LineCap::Square))]); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4b90d9967..53c26cd35 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1280,6 +1280,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`). When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 123 ms instead of 403 ms with an open path of 200,000 anchors in view (103 ms with nothing changed), and 13 ms instead of 26 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile. When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 128 ms instead of 405 ms with an open path of 200,000 anchors in view (104 ms with nothing changed), and 14 ms instead of 28 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile. + When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs). The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`). Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms. A selection with more than 100,000 points to outline shows its bounds instead of each object's outline and anchors. Paths count their anchors. Area type and type on a path count the anchors of their frame or path, and point type counts six: its box's corners and the ends of its baseline marker. Images, symbol instances, placed documents, and area type or type on a path in perspective count the four corners of their box, and gradient meshes and envelopes count their points and the ends of their mesh lines. The hover highlight leaves out an object past that count, and such an object shows its bounds as the key object. With 32,768 type objects, images, symbol instances, gradient meshes or envelopes selected, the selection's outlines and anchors add fewer than 100 vertices to the canvas's mesh (#2460).