diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 94b06bba..969fae8c 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -18,6 +18,10 @@ //! ([`Renderer::pixel_content`]). The result is cached per object while it and the zoom don't //! change. //! +//! While [`crate::RenderOptions::interacting`], an object whose content only moved draws its +//! cached shadows, glows and filtered content at its new place instead of running the effects +//! again ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]). +//! //! The same applies to one fill or stroke's own raster effects (around that item alone) and to //! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is //! drawn offscreen in the filter layers, reshaped first by their geometry effects. Groups and @@ -27,9 +31,10 @@ use std::sync::Arc; -use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind}; +use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId, NodeKind}; +use vectorcraft_effects::pixel::{Channels, PixelFx}; use vectorcraft_effects::{self as effects, GeomContext, RasterFx}; -use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape, Vec2}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathEl, Point, Rect, Shape, Size, Vec2}; use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_cpu::RenderContext; use vello_cpu::peniko; @@ -230,9 +235,21 @@ pub(crate) struct PixelEntry { rel: (f64, f64), k: f64, image: std::sync::Arc, + made: Made, + /// Made for the object before it moved ([`Renderer::move_pixel_content`]), or made over + /// rasters made before their objects moved: only a frame rendered while interacting draws + /// it. + moved: bool, pub(crate) stamp: u64, } +impl PixelEntry { + /// The bytes it holds, for [`FX_RASTER_BUDGET`]. + pub(crate) fn bytes(&self) -> usize { + raster_bytes(&self.image) + self.made.bytes() + } +} + /// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys. fn linear_key(view: Affine) -> [u64; 4] { let c = view.as_coeffs(); @@ -260,9 +277,87 @@ pub(crate) struct ShadowEntry { /// Pixel offset of the raster's top-left from the anchor's view position. rel: (f64, f64), image: std::sync::Arc, + made: Made, + /// Made for the object before it moved ([`Renderer::move_shadow`]), or made over rasters + /// made before their objects moved: only a frame rendered while interacting draws it. + moved: bool, pub(crate) stamp: u64, } +impl ShadowEntry { + /// The bytes it holds, for [`FX_RASTER_BUDGET`]. + pub(crate) fn bytes(&self) -> usize { + raster_bytes(&self.image) + self.made.bytes() + } +} + +/// What a cached shadow, glow or filtered raster was made from. While +/// [`crate::RenderOptions::interacting`], an object whose content only moved after the raster +/// was made draws from that raster ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]). +pub(crate) struct Made { + /// The content's pixels as drawn into the raster before the effects ran: premultiplied RGBA, + /// or their alpha alone under a shadow or glow. + pixels: Vec, + /// The effects run on them. + fx: Vec, + /// The size of the content's document-space bounds. + size: Size, + /// The color channels the Photoshop-style filters were told of (none for shadows and glows). + channels: Option, + /// Feather's clip outline, moved to put the raster's anchor at the origin. + outline: Option<(BezPath, FillRule)>, +} + +/// How far (document units) the size of a moved object's content and the points of its Feather +/// outline may lie from those of the content a raster was made from. +const MOVED_SLACK: f64 = 1e-6; + +impl Made { + /// The bytes it holds beyond the raster. + fn bytes(&self) -> usize { + self.pixels.len() + self.outline.as_ref().map_or(0, |(g, _)| crate::lru::path_bytes(g)) + } + + /// Whether content of size `size` under the effects `fx`, told of `channels`, with Feather's + /// `outline` (moved to put its anchor at the origin) may be this content moved: the same + /// effects, channels and outline rule, and the size and outline within [`MOVED_SLACK`]. + fn fits(&self, fx: &[&RasterFx], size: Size, channels: Option, outline: Option<&(BezPath, FillRule)>) -> bool { + let near = |a: f64, b: f64| (a - b).abs() <= MOVED_SLACK; + self.fx.len() == fx.len() + && self.fx.iter().zip(fx).all(|(a, b)| a == *b) + && near(self.size.width, size.width) + && near(self.size.height, size.height) + && self.channels == channels + && match (&self.outline, outline) { + (None, None) => true, + (Some((a, ra)), Some((b, rb))) => ra == rb && same_path(a, b), + _ => false, + } + } +} + +/// Whether `a` and `b` have the same elements with points within [`MOVED_SLACK`] of each other. +fn same_path(a: &BezPath, b: &BezPath) -> bool { + let near = |p: &Point, q: &Point| (*p - *q).hypot() <= MOVED_SLACK; + a.elements().len() == b.elements().len() + && a.elements().iter().zip(b.elements()).all(|pair| match pair { + (PathEl::MoveTo(p), PathEl::MoveTo(q)) | (PathEl::LineTo(p), PathEl::LineTo(q)) => near(p, q), + (PathEl::QuadTo(p0, p1), PathEl::QuadTo(q0, q1)) => near(p0, q0) && near(p1, q1), + (PathEl::CurveTo(p0, p1, p2), PathEl::CurveTo(q0, q1, q2)) => near(p0, q0) && near(p1, q1) && near(p2, q2), + (PathEl::ClosePath, PathEl::ClosePath) => true, + _ => false, + }) +} + +/// Feather's clip outline of `content` when `chain` feathers it, moved to put `anchor` at the +/// origin ([`Made::outline`]). +fn feather_outline(chain: &[&RasterFx], content: &Content, anchor: Point) -> Option<(BezPath, FillRule)> { + let (g, rule) = content.outline.filter(|_| chain.iter().any(|fx| matches!(fx, RasterFx::Feather { .. })))?; + let mut g = g.clone(); + g.apply_affine(Affine::translate(-anchor.to_vec2())); + Some((g, rule)) +} + /// The object a cached raster was made for: the drawn `Arc` itself when known (kept alive, so its /// address, the cache key, names it alone and a key match is exact), else a copy compared by value. pub(crate) enum Drawn { @@ -285,10 +380,18 @@ impl Drawn { Self::Copy(c) => **c == *n, } } + + /// The id of the object drawn. + fn id(&self) -> NodeId { + match self { + Self::Shared(a) => a.id, + Self::Copy(c) => c.id, + } + } } -/// Most bytes the shadow and filtered-content rasters of one renderer hold together: the least -/// recently drawn go beyond it. +/// Most bytes the shadow and filtered-content rasters of one renderer hold together, with what each +/// was made from ([`Made`]): the least recently drawn go beyond it. pub(crate) const FX_RASTER_BUDGET: usize = 512 << 20; fn raster_bytes(pm: &vello_cpu::Pixmap) -> usize { @@ -308,8 +411,9 @@ pub(crate) struct FxGeom { 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 - /// while the object and the zoom/rotation are unchanged (pans only move it). Returns false when - /// the effect isn't a shadow/glow or the raster would be too large (caller falls back). + /// while the object and the zoom/rotation are unchanged (pans only move it), and while + /// interacting after the object only moved ([`Self::move_shadow`]). Returns false when the + /// effect isn't a shadow/glow or the raster would be too large (caller falls back). #[allow(clippy::too_many_arguments)] fn draw_shadow_cached( &mut self, @@ -330,10 +434,16 @@ impl Renderer { let linear = linear_key(f.view); let key = (n as *const Node as usize, i, f.ink); let stamp = self.stamp; - let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node.is(n)); + let interacting = f.opts.interacting; + let exact = |e: &ShadowEntry| e.linear == linear && e.node.is(n) && (interacting || !e.moved); + if interacting && !self.shadows.get(&key).is_some_and(exact) { + self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, paint); + } + let hit = self.shadows.get_mut(&key).filter(|e| exact(e)); let (image, x, y) = match hit { Some(e) => { e.stamp = stamp; + self.stats.moved += usize::from(e.moved); let p = f.view * e.anchor; (e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round()) } @@ -350,10 +460,16 @@ impl Renderer { // Silhouette (the object's painted alpha), offset by the shadow distance. let mut off = f.offscreen_context(w, h); let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; + let before = self.stats.moved; self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); + // A silhouette drawn over moved rasters makes a moved shadow. + let moved = self.stats.moved > before; off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); off.render(&mut pm, &mut self.resources); + // What the raster is made from, for its object once it moves (`move_shadow`). + let made = + Made { pixels: pm.data().iter().map(|p| p.a).collect(), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None }; let (wu, hu) = (w as usize, h as usize); let mut a: Vec = pm.data().iter().map(|p| p.a as f32).collect(); effects::pixel::blur_plane(&mut a, wu, hu, blur / 2.0 / f.px); @@ -367,7 +483,7 @@ impl Renderer { let anchor = reach.origin(); let p = f.view * anchor; let node = Drawn::of(n, owner); - self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel: (x0 - p.x, y0 - p.y), image: image.clone(), stamp }); + self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel: (x0 - p.x, y0 - p.y), image: image.clone(), made, moved, stamp }); self.trim_fx_rasters(FX_RASTER_BUDGET); (image, x0, y0) } @@ -385,6 +501,54 @@ impl Renderer { true } + /// While interacting, give `n` (shadow-cache key `key`) the shadow or glow raster an earlier + /// frame made for its object (the same id, effect slot and ink, at this zoom and rotation) when + /// only the silhouette's place changed after that frame: made by the same effect `fx` from a + /// silhouette whose bounds have the size of `reach` (the content's bounds moved by the + /// shadow's offset), and whose alpha is the alpha of `n`'s silhouette drawn into a raster + /// placed as that one was against its anchor. The raster is kept as `n`'s, marked moved, and + /// is drawn at the nearest whole pixel. + #[allow(clippy::too_many_arguments)] + fn move_shadow( + &mut self, + f: &Frame, + n: &Node, + key: (usize, usize, Ink), + fx: &RasterFx, + reach: Rect, + owner: Option<&Arc>, + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) { + let (dx, dy) = match fx { + RasterFx::DropShadow { dx, dy, .. } => (*dx, *dy), + _ => (0.0, 0.0), + }; + let (linear, stamp, size) = (linear_key(f.view), self.stamp, reach.size()); + let fits = |k: &(usize, usize, Ink), e: &ShadowEntry| { + (k.1, k.2) == (key.1, key.2) && e.stamp < stamp && e.linear == linear && e.node.id() == n.id && e.made.fits(&[fx], size, None, None) + }; + let Some((old, rel, w, h)) = self.shadows.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height())) + else { + return; + }; + // The silhouette drawn where the old one lies in its raster. + let anchor = reach.origin(); + let p = f.view * anchor; + let mut off = f.offscreen_context(w, h); + let crop = Frame { mt: false, view: Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view, ..*f }; + self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + if !self.shadows.get(&old).is_some_and(|e| pm.data().iter().map(|p| p.a).eq(e.made.pixels.iter().copied())) { + return; + } + if let Some(mut e) = self.shadows.remove(&old) { + (e.node, e.anchor, e.moved) = (Drawn::of(n, owner), anchor, true); + self.shadows.insert(key, e); + } + } + /// `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) { // The node `draw_arc` is drawing gets its geometry from the cache: `bp` is its own path, @@ -628,7 +792,8 @@ impl Renderer { /// reach, filtered on the CPU and drawn back. The raster holds at most [`PIXEL_BUDGET`] pixels: /// larger content is cropped to the view when every effect only looks nearby, else rendered /// at a lower resolution. A whole raster at full resolution is cached while the object and the - /// zoom stay the same (panning only moves it). + /// zoom stay the same (panning only moves it), and while interacting after the content only + /// moved ([`Self::move_pixel_content`]). fn pixel_content( &mut self, ctx: &mut RenderContext, @@ -664,8 +829,23 @@ impl Renderer { let linear = linear_key(f.view); let key = content.cache.filter(|_| fits && k >= 1.0).map(|slot| (content.node as *const Node as usize, slot, f.ink)); let stamp = self.stamp; - if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| e.linear == linear && e.node.is(content.node)) { + let channels = match f.ink { + Ink::Cmy => effects::pixel::Channels::CmyPlane, + Ink::K => effects::pixel::Channels::KPlane, + Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk, + Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb, + }; + let interacting = f.opts.interacting; + let exact = |e: &PixelEntry| e.linear == linear && e.node.is(content.node) && (interacting || !e.moved); + if interacting + && let Some(key) = key + && !self.pixel_fx.get(&key).is_some_and(exact) + { + self.move_pixel_content(f, content, key, &chain, channels, paint); + } + if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| exact(e)) { e.stamp = stamp; + self.stats.moved += usize::from(e.moved); let p = f.view * e.anchor; let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k); draw_scaled(ctx, image, at, k); @@ -677,16 +857,21 @@ impl Renderer { let px = f.px / k; let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f }; let mut off = f.offscreen_context(wu, hu); + let before = self.stats.moved; self.inside_layer(|r| paint(r, &mut off, &frame)); + // Content drawn over moved rasters makes a moved raster. + let moved = self.stats.moved > before; off.flush(); let mut pm = vello_cpu::Pixmap::new(wu, hu); off.render(&mut pm, &mut self.resources); - let channels = match f.ink { - Ink::Cmy => effects::pixel::Channels::CmyPlane, - Ink::K => effects::pixel::Channels::KPlane, - Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk, - Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb, - }; + // What the raster is made from, for its object once it moves (`move_pixel_content`). + let made = key.map(|_| Made { + pixels: pm.data_as_u8_slice().to_vec(), + fx: chain.iter().map(|fx| (*fx).clone()).collect(), + size: content.reach.size(), + channels: Some(channels), + outline: feather_outline(&chain, content, content.reach.origin()), + }); // De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi). let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0); let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height())); @@ -706,23 +891,77 @@ impl Renderer { } } let image = std::sync::Arc::new(pm); - if let Some(key) = key { + if let Some((key, made)) = key.zip(made) { let anchor = content.reach.origin(); let p = f.view * anchor; let rel = (x0 - p.x, y0 - p.y); let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); let node = Drawn::of(content.node, owner.as_ref()); - self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), stamp }); + self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, moved, stamp }); self.trim_fx_rasters(FX_RASTER_BUDGET); } draw_scaled(ctx, image, (x0, y0), k); } + /// While interacting, give `content` (cache key `key`) the filtered raster an earlier frame + /// made at full size for its object (the same id, slot and ink, at this zoom and rotation) + /// when only the content's place changed after that frame: made by the same effects `chain`, + /// told of the same `channels`, from content whose bounds have the same size (and the same + /// Feather outline against its anchor), and whose pixels are the pixels of `content` drawn + /// into a raster placed as that one was against its anchor. The raster is kept as + /// `content`'s, marked moved, and is drawn at the nearest whole pixel. Content under + /// De-Interlace takes none. + fn move_pixel_content( + &mut self, + f: &Frame, + content: &Content, + key: (usize, usize, Ink), + chain: &[&RasterFx], + channels: Channels, + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) { + if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) { + return; + } + let (linear, stamp, anchor) = (linear_key(f.view), self.stamp, content.reach.origin()); + let outline = feather_outline(chain, content, anchor); + let fits = |k: &(usize, usize, Ink), e: &PixelEntry| { + (k.1, k.2) == (key.1, key.2) + && e.stamp < stamp + && e.linear == linear + && e.k >= 1.0 + && e.node.id() == content.node.id + && e.made.fits(chain, content.reach.size(), Some(channels), outline.as_ref()) + }; + let Some((old, rel, w, h)) = self.pixel_fx.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height())) + else { + return; + }; + // The content drawn where the old content lies in its raster. + let p = f.view * anchor; + let view = Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view; + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); + let frame = Frame { mt: false, view, visible, ..*f }; + let mut off = f.offscreen_context(w, h); + self.inside_layer(|r| paint(r, &mut off, &frame)); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + if !self.pixel_fx.get(&old).is_some_and(|e| e.made.pixels.as_slice() == pm.data_as_u8_slice()) { + return; + } + let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); + if let Some(mut e) = self.pixel_fx.remove(&old) { + (e.node, e.anchor, e.moved) = (Drawn::of(content.node, owner.as_ref()), anchor, true); + self.pixel_fx.insert(key, e); + } + } + /// Drop the least recently drawn shadow and filtered-content rasters beyond `budget` bytes /// ([`FX_RASTER_BUDGET`]). fn trim_fx_rasters(&mut self, budget: usize) { - let shadows = self.shadows.iter().map(|(k, e)| (e.stamp, false, *k, raster_bytes(&e.image))); - let pixels = self.pixel_fx.iter().map(|(k, e)| (e.stamp, true, *k, raster_bytes(&e.image))); + let shadows = self.shadows.iter().map(|(k, e)| (e.stamp, false, *k, e.bytes())); + let pixels = self.pixel_fx.iter().map(|(k, e)| (e.stamp, true, *k, e.bytes())); let mut all: Vec<_> = shadows.chain(pixels).collect(); let mut total: usize = all.iter().map(|e| e.3).sum(); if total <= budget { @@ -891,7 +1130,7 @@ mod tests { use vectorcraft_doc::{Appearance, Document, Effect, Node}; use vectorcraft_geom::{Affine, Rect, shapes}; - use super::{Drawn, raster_bytes}; + use super::Drawn; use crate::{RenderOptions, Rendered, Renderer}; fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document { @@ -976,8 +1215,8 @@ mod tests { // A frame of other objects makes rasters of its own; the older ones go first. let _ = r.render(&effects_page(), 400, 400, Affine::scale(2.0), &opts); let newest = r.stamp; - let bytes = |r: &Renderer| r.shadows.values().map(|e| raster_bytes(&e.image)).sum::(); - let latest = r.shadows.values().filter(|e| e.stamp == newest).map(|e| raster_bytes(&e.image)).sum::(); + let bytes = |r: &Renderer| r.shadows.values().map(|e| e.bytes()).sum::(); + let latest = r.shadows.values().filter(|e| e.stamp == newest).map(|e| e.bytes()).sum::(); assert!(bytes(&r) > latest); r.trim_fx_rasters(latest); assert_eq!(bytes(&r), latest); @@ -1202,7 +1441,163 @@ mod tests { assert_eq!(b.pixels, c.pixels); } - /// Reproduce #1257's moving, filtered rectangle without reusing a static frame's raster. + /// While interacting, an object that only moved draws its drop shadow and filtered content + /// from the rasters of an earlier frame, at the nearest whole pixel, without running the + /// effects again (#1257). The frame after the interaction runs them again: it is what a fresh + /// renderer draws. + #[test] + fn moved_objects_draw_their_effect_rasters_while_interacting() { + let effects = + vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2, "opacity": 80})), ("texture.craquelure", json!({"crackSpacing": 6}))]; + let base = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), effects); + let id = base.layers[0].children().unwrap()[0].id; + // The object moved from where it started, as each frame of a drag moves it. + let moved = |dx: f64, dy: f64| { + let mut d = base.clone(); + d.node_mut(id).unwrap().transform(Affine::translate((dx, dy)), false); + d + }; + let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let live = RenderOptions { interacting: true, ..still.clone() }; + let fresh = |d: &Document| Renderer::new().render(d, 200, 200, view, &still).pixels; + let mut r = Renderer::new(); + assert!(r.render(&base, 200, 200, view, &still).pixels == fresh(&base)); + assert_eq!(r.stats.moved, 0); + let (mut d, mut last) = (base.clone(), vec![]); + for (dx, dy) in [(3.0, 0.0), (3.3, 1.2), (5.15, 2.5), (6.3, 2.2)] { + d = moved(dx, dy); + last = r.render(&d, 200, 200, view, &live).pixels; + assert_eq!(r.stats.moved, 2, "the shadow and the filtered content moved by ({dx}, {dy})"); + // At the nearest whole pixel (2 a point): what a fresh renderer draws there. + let whole = moved((dx * 2.0).round() / 2.0, (dy * 2.0).round() / 2.0); + assert!(last == fresh(&whole), "moved by ({dx}, {dy})"); + } + // The frame after the drag, of the same document: the effects run again where the object is. + let after = r.render(&d, 200, 200, view, &still).pixels; + assert_eq!(r.stats.moved, 0); + assert!(after == fresh(&d), "the frame after the drag"); + assert!(after != last, "6.3 is not on a whole pixel"); + } + + /// While interacting, a raster moves with its object only when the content's pixels (for a + /// shadow, their alpha) and the effects stayed the same (#1257): a new fill color under + /// Craquelure, other effect settings, a pattern fill that stays on the page, a resized object, + /// art added beyond the old raster, a changed Feather outline and De-Interlace run the effects + /// again. A shadow keeps its raster under a new fill color. + #[test] + fn moved_rasters_need_the_same_content_and_effects() { + let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let live = RenderOptions { interacting: true, ..still.clone() }; + // `before` drawn, then `after` while interacting: the rasters drawn moved, and whether the + // frame is what a fresh renderer draws. + let drag = |before: &Document, after: &Document| { + let mut r = Renderer::new(); + let _ = r.render(before, 200, 200, view, &still); + let frame = r.render(after, 200, 200, view, &live).pixels; + (r.stats.moved, frame == Renderer::new().render(after, 200, 200, view, &still).pixels) + }; + // `d`'s object moved 5, 3 (10, 6 pixels), then changed by `edit`. + let moved = |d: &Document, patterns: bool, edit: &dyn Fn(&mut Node)| { + let mut d = d.clone(); + let id = d.layers[0].children().unwrap()[0].id; + let n = d.node_mut(id).unwrap(); + n.transform(Affine::translate((5.0, 3.0)), vectorcraft_doc::Scaling { patterns, ..Default::default() }); + edit(n); + d + }; + let cracks = || vec![("texture.craquelure", json!({"crackSpacing": 6}))]; + let base = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), cracks()); + assert_eq!(drag(&base, &moved(&base, false, &|_| {})), (1, true), "moved"); + let recolor = |n: &mut Node| n.appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.1, 0.4, 0.9)); + assert_eq!(drag(&base, &moved(&base, false, &recolor)), (0, true), "recolored"); + let spacing = |n: &mut Node| n.appearance.effects[0].params = json!({"crackSpacing": 9}); + assert_eq!(drag(&base, &moved(&base, false, &spacing)), (0, true), "other effect settings"); + let resize = |n: &mut Node| n.transform(Affine::scale_about(1.5, vectorcraft_geom::Point::new(25.0, 23.0)), false); + assert_eq!(drag(&base, &moved(&base, false, &resize)), (0, true), "resized"); + // A square added beyond the old raster leaves the content inside it as it was. + let beyond = |n: &mut Node| { + if let vectorcraft_doc::NodeKind::Path { path, .. } = &mut n.kind { + path.subpaths.extend(shapes::rectangle(Rect::new(75.0, 30.0, 80.0, 35.0)).subpaths); + } + }; + assert_eq!(drag(&base, &moved(&base, false, &beyond)), (0, true), "art added beyond the old raster"); + // At 72 ppi, De-Interlace works on lines 1 point tall. The object moves 3 points down, so + // De-Interlace makes its other field again. + let fields = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("video.deinterlace", json!({}))]); + assert_eq!(drag(&fields, &moved(&fields, false, &|_| {})), (0, true), "De-Interlace"); + // A pattern fill: its tiles stay on the page unless they move with the object. + let mut tiled = base.clone(); + let red = Node::path( + vectorcraft_doc::NodeId(900), + shapes::rectangle(Rect::new(0.0, 0.0, 3.0, 3.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + let mut def = vectorcraft_doc::pattern::PatternDef::new("Dots", vec![std::sync::Arc::new(red)]); + def.tile = Rect::new(0.0, 0.0, 7.0, 7.0); + tiled.patterns.push(def); + let id = tiled.layers[0].children().unwrap()[0].id; + tiled.node_mut(id).unwrap().appearance.fill_mut().unwrap().paint = vectorcraft_doc::pattern::pattern_paint("Dots"); + assert_eq!(drag(&tiled, &moved(&tiled, false, &|_| {})), (0, true), "tiles that stay on the page"); + assert_eq!(drag(&tiled, &moved(&tiled, true, &|_| {})), (1, true), "tiles that move with the object"); + // Feather clips to the outline: a notch in the top edge where the tiles paint nothing + // (x 29 to 33 and y 20 to 24 once moved) leaves the content's pixels as they were. + let mut feathered = tiled.clone(); + feathered.node_mut(id).unwrap().appearance.effects = + vec![Effect { id: "stylize.feather".into(), params: json!({"radius": 3}), visible: true }]; + assert_eq!(drag(&feathered, &moved(&feathered, true, &|_| {})), (1, true), "feathered"); + let notch = |n: &mut Node| { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((25.0, 23.0)); + for p in [(30.0, 23.0), (30.5, 23.6), (31.5, 23.6), (32.0, 23.0), (55.0, 23.0), (55.0, 49.0), (25.0, 49.0)] { + bp.line_to(p); + } + bp.close_path(); + if let vectorcraft_doc::NodeKind::Path { path, .. } = &mut n.kind { + *path = vectorcraft_geom::PathData::from_bezpath(&bp); + } + }; + assert_eq!(drag(&feathered, &moved(&feathered, true, ¬ch)), (0, true), "a notch in the feathered outline"); + // A shadow is made from the silhouette's alpha: recolored, the object takes its shadow + // along; notched, the object makes its shadow again. + let shadowed = + doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2}))]); + assert_eq!(drag(&shadowed, &moved(&shadowed, false, &recolor)), (1, true), "a shadow under a new color"); + assert_eq!(drag(&shadowed, &moved(&shadowed, false, ¬ch)), (0, true), "a shadow under a notch"); + } + + /// A raster made over moved rasters counts as moved itself (#1257): a group with a drop shadow + /// and Gaussian Blur of its own, whose filtered member moves by parts of a pixel while the + /// other member stays, draws after the drag what a fresh renderer draws. + #[test] + fn rasters_made_over_moved_rasters_count_as_moved() { + let mut d = Document::new(100.0, 100.0); + let fill = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0); + let (sid, mid, gid) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let still = Node::path(sid, shapes::rectangle(Rect::new(60.0, 60.0, 80.0, 80.0)), fill(Color::rgb(0.1, 0.5, 0.2))); + let mut member = Node::path(mid, shapes::rectangle(Rect::new(20.0, 20.0, 50.0, 46.0)), fill(Color::rgb(0.9, 0.4, 0.1))); + member.appearance.effects = vec![Effect { id: "texture.craquelure".into(), params: json!({"crackSpacing": 6}), visible: true }]; + let mut group = Node::group(gid, vec![std::sync::Arc::new(still), std::sync::Arc::new(member)]); + group.appearance.effects = vec![ + Effect { id: "stylize.dropShadow".into(), params: json!({"x": 4, "y": 3, "blur": 2}), visible: true }, + Effect { id: "blur.gaussian".into(), params: json!({"radius": 1}), visible: true }, + ]; + let l = d.layers[0].id; + d.insert(Some(l), 0, group).unwrap(); + let (view, opts) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let mut r = Renderer::new(); + let _ = r.render(&d, 200, 200, view, &opts); + let mut moved = d.clone(); + moved.node_mut(mid).unwrap().transform(Affine::translate((3.3, 1.2)), false); + let _ = r.render(&moved, 200, 200, view, &RenderOptions { interacting: true, ..opts.clone() }); + assert!(r.stats.moved > 0, "the member's filtered content moved"); + let after = r.render(&moved, 200, 200, view, &opts).pixels; + assert!(after == Renderer::new().render(&moved, 200, 200, view, &opts).pixels, "the frame after the drag"); + } + + /// Reproduce #1257's moving, filtered rectangle: the first frame renders as before a drag, + /// and the next eight render as the canvas renders a drag's frames (`interacting`). Then the + /// eighth position renders as the canvas renders the frame after the drag, and that frame + /// renders once more with every raster cached. /// Run with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`. #[test] #[ignore = "manual timing probe; wall-clock performance depends on machine load"] @@ -1222,23 +1617,33 @@ mod tests { ]; base.insert(Some(layer), 0, n).unwrap(); let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let live = RenderOptions { interacting: true, ..opts.clone() }; let view = Affine::scale(1.9007 * 1.5); let mut r = Renderer::new(); r.threads = crate::default_threads(); - let mut ms = vec![]; + let (mut ms, mut doc) = (vec![], base.clone()); for frame in 0..9 { - let mut doc = base.clone(); + doc = base.clone(); doc.node_mut(id).unwrap().transform(Affine::translate((3.0 + f64::from(frame) * 0.75, f64::from(frame) * 0.5)), false); let start = std::time::Instant::now(); - let image = r.render(&doc, 1800, 1500, view, &opts); + let image = r.render(&doc, 1800, 1500, view, if frame > 0 { &live } else { &opts }); let time = start.elapsed().as_secs_f64() * 1000.0; if frame > 0 { ms.push(time); } std::hint::black_box(image); } + let mut time = |o: &RenderOptions| { + let start = std::time::Instant::now(); + std::hint::black_box(r.render(&doc, 1800, 1500, view, o)); + start.elapsed().as_secs_f64() * 1000.0 + }; + let (after, again) = (time(&opts), time(&opts)); ms.sort_by(f64::total_cmp); - println!("{effect}: moved CMYK rounded rectangle at 190.07%, DPI 1.5, 1800x1500: median {:.2} ms; samples {ms:?}", (ms[3] + ms[4]) * 0.5); + println!( + "{effect}: moved CMYK rounded rectangle at 190.07%, DPI 1.5, 1800x1500: median {:.2} ms; samples {ms:?}; the frame after the drag {after:.2} ms, that frame again {again:.2} ms", + (ms[3] + ms[4]) * 0.5 + ); } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 238a25cc..74f0e9fc 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -127,6 +127,12 @@ pub struct RenderOptions { /// (box-filtered from the full image, kept with the decoded images), so the full image needn't /// stay in memory or be sampled. Off, images always sample their full pixels (exports). pub image_mips: bool, + /// Screen views while an interaction is in progress (a drag, a dialog's preview): an object + /// whose content only moved since an earlier frame draws its cached drop shadows, outer glows + /// and filtered content from the rasters made then, at the nearest whole pixel, without + /// running the effects again (see `fx`). Off, no raster made that way, or made over one, is + /// drawn: the effects run again. + pub interacting: bool, /// Appearance of Black's Rich Black: pure CMYK black (black ink alone at 100%) shows as RGB /// black. Off, it shows through the CMYK profile, a dark grey, as other colours do. pub rich_black: bool, @@ -204,6 +210,7 @@ impl Default for RenderOptions { precise: false, supersample: false, image_mips: false, + interacting: false, rich_black: false, } } @@ -444,6 +451,9 @@ static MISSING_INLINE_LOGGED: std::sync::atomic::AtomicBool = std::sync::atomic: pub struct FrameStats { pub drawn: usize, pub culled: usize, + /// The drop shadows, outer glows and filtered contents the frame drew from rasters made before + /// their object moved, or made over such rasters ([`RenderOptions::interacting`]). + pub moved: usize, pub micros: u64, } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1546da4e..373e6939 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -237,6 +237,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let key = CacheKey { doc: st.uid as usize, revision: st.revision, + interacting: st.interaction.is_some(), zoom: v.zoom, cx: v.center.x, cy: v.center.y, @@ -273,6 +274,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.canvas.key = Some(done.key); app.perf.render_ms = done.ms; app.canvas.last_ms = done.ms; + app.canvas.moved = done.moved > 0; } if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() { let (w, h, view) = match pixel { @@ -307,13 +309,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { isolated: key.isolated.map(vectorcraft_doc::NodeId), // Images far smaller on screen than they are sample their mip levels (#1244). image_mips: true, + // During a drag or a dialog's preview, objects that only moved draw their raster + // effects from the cached rasters of an earlier frame (#1257). + interacting: key.interacting, rich_black: key.rich_black, ..opts }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. // A document switch renders synchronously so another document's frame is never shown. + // The first frame after an interaction, when the frame shown drew moved rasters, also goes + // to the worker. let same_doc = app.canvas.key.as_ref().is_some_and(|k| k.doc == key.doc); - let heavy = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some() && same_doc; + let settling = app.canvas.moved && !key.interacting; + let heavy = (app.canvas.last_ms > 8.0 || settling) && app.canvas.texture.is_some() && same_doc; match (&mut app.canvas.worker, heavy) { (Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), _ => { @@ -323,6 +331,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.canvas.key = Some(key.clone()); app.perf.render_ms = now_ms() - t0; app.canvas.last_ms = app.perf.render_ms; + app.canvas.moved = app.canvas.renderer.stats.moved > 0; } } } @@ -2882,6 +2891,91 @@ mod tests { assert!(partial(&alphas(&mut app).expect("re-rendered")) > 50, "smooth again"); } + /// A drag of an object with raster effects (#1257): the drag's frames render while + /// interacting and draw the object's drop shadow and filtered content from the rasters of the + /// frame before; the frame after the drag renders without `interacting`, on the render worker, + /// and draws what a fresh renderer draws. + #[test] + fn dragging_an_object_with_raster_effects_draws_its_rasters_moved() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone(); + for effect in ["stylize.dropShadow", "texture.craquelure"] { + app.run("effect.apply", json!({"effect": effect, "ids": [id]})).unwrap(); + } + let ctx = egui::Context::default(); + // The art's raster uploaded by one frame, None when it was not re-rendered. + let upload = |app: &mut VectorcraftApp| -> Option> { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + img.pixels.clone() + }); + delta.clear(); + px + }; + upload(&mut app).expect("the first frame renders the art"); + app.select_tool("selection"); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap(); + for step in [1.0, 2.0, 3.0] { + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 160.0 + 7.3 * step, 125.0 + 2.1 * step), view).unwrap(); + upload(&mut app).expect("each frame of the drag renders"); + assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting), "step {step}"); + assert_eq!(app.canvas.renderer.stats.moved, 2, "the shadow and the filtered content, step {step}"); + assert!(app.canvas.moved); + } + // The frame after the drag runs the effects again: on the worker, while the canvas shows + // the drag's last frame. + app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 181.9, 131.3), view).unwrap(); + assert!(upload(&mut app).is_none(), "not rendered on this thread"); + assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| !k.interacting), "sent to the worker"); + let until = std::time::Instant::now() + std::time::Duration::from_secs(10); + let mut after = None; + while after.is_none() && std::time::Instant::now() < until { + std::thread::sleep(std::time::Duration::from_millis(5)); + after = upload(&mut app); + } + let after = after.expect("the worker's frame arrives"); + assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting)); + assert!(!app.canvas.moved); + app.canvas.worker = None; + app.canvas.renderer = vectorcraft_render::Renderer::new(); + app.canvas.key = None; + assert!(upload(&mut app) == Some(after), "the frame after the drag is what a fresh renderer draws"); + } + + /// The frame after an interaction renders without `interacting` also when the interaction + /// leaves the document as it was (#1257): here a drag back to where it started. + #[test] + fn a_drag_back_to_its_start_renders_again_after_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone(); + app.run("effect.apply", json!({"effect": "texture.craquelure", "ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + shapes(&mut app, &ctx); + app.select_tool("selection"); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap(); + for (x, y) in [(173.3, 129.1), (160.0, 125.0)] { + app.session.pointer(&PointerEvent::new(PointerKind::Drag, x, y), view).unwrap(); + shapes(&mut app, &ctx); + assert_eq!(app.canvas.renderer.stats.moved, 1, "the filtered content at ({x}, {y})"); + } + let revision = app.session.active().unwrap().revision; + app.session.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 125.0), view).unwrap(); + assert_eq!(app.session.active().unwrap().revision, revision, "the drag left the document as it was"); + shapes(&mut app, &ctx); + assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting), "rendered again"); + assert_eq!(app.canvas.renderer.stats.moved, 0); + assert!(!app.canvas.moved); + } + /// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (#394): off, Pixel /// Preview draws images with each pixel taking its nearest image pixel; on, smoothly, as the /// canvas always does out of Pixel Preview. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 25fc715d..30ecb89d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -267,6 +267,10 @@ pub struct CanvasCache { pub texture: Option, pub key: Option, pub last_ms: f64, + /// The frame shown drew raster effects from rasters made before their objects moved + /// ([`vectorcraft_render::FrameStats::moved`]): the next frame outside an interaction runs + /// them again, on the worker when there is one. + pub moved: bool, pub worker: Option, pub worker_started: bool, /// The slices as laid out for (document uid, revision): the canvas draws them every frame. @@ -291,6 +295,8 @@ pub type PrintTilingCache = ((u64, u64), std::sync::Arc