Merge pull request #3001 from ariymarkowitz/claude/changes

#2558: a drag over a wide blur draws it from the still frame's raster
This commit is contained in:
BFlat
2026-10-19 23:13:46 +07:00
committed by GitHub
6 changed files with 451 additions and 34 deletions
+173 -31
View File
@@ -637,6 +637,11 @@ pub(crate) struct PixelEntry {
rel: (f64, f64),
k: f64,
image: std::sync::Arc<vello_cpu::Pixmap>,
/// For a raster of the content's whole reach at full size, `None`: it draws after a pan too.
/// For one cropped to the view, or made at a lower resolution, the view and the rect it was
/// made for: it draws only there, so dragging other art over the object keeps it (a wide blur,
/// or any blur zoomed in, reaches past the pixel budget).
view: Option<ViewKey>,
made: Made,
/// What the content read when the raster was made, with the channels the effects ran on and,
/// under De-Interlace, the raster resolution.
@@ -665,6 +670,15 @@ fn linear_key(view: Affine) -> [u64; 4] {
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
}
/// A view's six coefficients (its pan included) and the device-pixel rect a frame draws in, as a
/// cache key: a raster cropped to that rect, or made at a lower resolution than the view's, draws
/// only in that view of that rect ([`PixelEntry::view`], [`ShadowEntry::view`]).
type ViewKey = ([u64; 6], [u64; 4]);
fn view_key(view: Affine, screen: Rect) -> ViewKey {
(view.as_coeffs().map(f64::to_bits), [screen.x0, screen.y0, screen.x1, screen.y1].map(f64::to_bits))
}
/// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while
/// [`crate::RenderOptions::dragging`] (a 1000 × 1000 raster's), when the frame does not draw a raster
/// kept from an earlier frame instead (#2558).
@@ -749,6 +763,10 @@ pub(crate) struct ShadowEntry {
image: std::sync::Arc<vello_cpu::Pixmap>,
/// View pixels per raster pixel: more than 1 for a glow made coarser to fit the budget.
scale: f64,
/// `None` for a raster of the whole silhouette's reach: it draws after a pan too. For one
/// cropped to the view, or made over such a raster, the view and the rect it was made for: it
/// draws only there ([`PixelEntry::view`]).
view: Option<ViewKey>,
made: Made,
/// What the silhouette read when the raster was made.
inputs: Inputs,
@@ -766,6 +784,8 @@ struct Shadow {
mode: vectorcraft_doc::color::BlendMode,
/// Made before its object moved ([`ShadowEntry::moved`]).
moved: bool,
/// Cropped to the view, or made over a raster that is ([`ShadowEntry::view`]).
cropped: bool,
}
impl ShadowEntry {
@@ -1029,8 +1049,13 @@ impl Renderer {
/// silhouette `paint` draws is rendered once into a small crop, blurred and tinted on the CPU,
/// and reused while the object, the zoom/rotation and what the silhouette reads ([`Inputs`]) are
/// unchanged (pans only move it), and while interacting after the object only moved
/// ([`Self::move_shadow`]). `None` when the effect isn't a shadow/glow or the raster would be
/// too large (caller falls back to [`Self::draw_shadow_filter`]).
/// ([`Self::move_shadow`]). A raster of the whole reach too large for the budget is cropped to
/// what can show in the `screen` (device pixels): the view, and around it the blur's spread and
/// `around` (document units: how far the shadows and glows stacked over this one look), made
/// coarser to fit the budget where the blur hides the coarser grid. Such a raster, and one
/// made over it (`over_cropped`), is reused only in the same view. `None` when the effect isn't
/// a shadow/glow or the raster would still be too large (caller falls back to
/// [`Self::draw_shadow_filter`]).
/// While dragging on a multithreaded context, a raster that has to be made again counts as too
/// large when it would hold more than [`DRAG_PIXEL_BUDGET`] pixels.
#[allow(clippy::too_many_arguments)]
@@ -1042,6 +1067,7 @@ impl Renderer {
fx: &RasterFx,
reach: Rect,
owner: Option<&Arc<Node>>,
(screen, around, over_cropped): (Rect, f64, bool),
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) -> Option<Shadow> {
let (mode, opacity, dx, dy, blur, color, darkness) = match fx {
@@ -1056,37 +1082,54 @@ impl Renderer {
// What the silhouette reads: as the raster kept for `n` found it, else found now.
let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads);
let inputs = self.raster_inputs(f, n, reads);
let exact = |e: &ShadowEntry| e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved);
let in_view = view_key(f.view, screen);
let exact = |e: &ShadowEntry| {
e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v == in_view)
};
if interacting && !self.shadows.get(&key).is_some_and(exact) {
self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint);
}
let hit = self.shadows.get_mut(&key).filter(|e| exact(e));
let (image, x, y, scale, moved) = match hit {
let (image, x, y, scale, moved, cropped) = 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(), e.scale, e.moved)
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved, e.view.is_some())
}
None => {
// An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first.
let grow = if matches!(fx, RasterFx::OuterGlow { .. }) { 18.0 / doc_raster_ppi(f.doc) } else { 0.0 };
let spread = (blur.max(0.0) * 1.5 + grow) / f.px + 2.0;
let reach = reach + Vec2::new(dx, dy);
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread);
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
let whole = f.view.transform_rect_bbox(reach).inflate(spread, spread);
// The raster's region and scale within `budget` pixels: `None` when over the limits.
let region = |r: Rect, coarser: bool, budget: f64| {
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
let scale = if coarser { (w * h / budget).sqrt().ceil().max(1.0) } else { 1.0 };
if !scale.is_finite() || scale > 1.0 && blur / 2.0 / f.px / scale < 2.0 {
return None;
}
let (w, h) = ((w / scale).ceil(), (h / scale).ceil());
((1.0..=8192.0).contains(&w) && (1.0..=8192.0).contains(&h) && w * h <= budget).then_some((x0, y0, w, h, scale))
};
// A glow too large for the budget is made coarser, where its blur hides the coarser grid: the filter
// layer the caller falls back to can't make its silhouette solid.
let scale = if matches!(fx, RasterFx::OuterGlow { .. }) { (w * h / 4.0e6).sqrt().ceil().max(1.0) } else { 1.0 };
if scale > 1.0 && blur / 2.0 / f.px / scale < 2.0 {
return None;
}
let (w, h) = ((w / scale).ceil(), (h / scale).ceil());
if !(1.0..=8192.0).contains(&w) || !(1.0..=8192.0).contains(&h) || w * h > 4.0e6 {
return None;
}
let full = region(whole, matches!(fx, RasterFx::OuterGlow { .. }), 4.0e6).filter(|_| !over_cropped);
// Else it is cropped to what can show in the view, within the budget of the filter
// layer it would otherwise be drawn through, and made coarser in the same way.
let ((x0, y0, w, h, scale), cropped) = match full {
Some(r) => (r, false),
None => {
let m = spread + around / f.px;
let r = whole.intersect(screen.inflate(m, m));
if r.width() <= 0.0 || r.height() <= 0.0 {
return None;
}
(region(r, true, PIXEL_BUDGET)?, true)
}
};
// During a drag, a larger shadow on a multithreaded context is filtered within the
// drag's budget (`with_filters`), and no raster is kept.
if f.opts.dragging && f.mt && w * h > DRAG_PIXEL_BUDGET {
@@ -1145,12 +1188,13 @@ impl Renderer {
let p = f.view * anchor;
let node = Drawn::of(n, owner);
let rel = (x0 - p.x, y0 - p.y);
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, made, inputs, moved, stamp });
let view = cropped.then_some(in_view);
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, stamp });
self.trim_fx_rasters(FX_RASTER_BUDGET);
(image, x0, y0, scale, moved)
(image, x0, y0, scale, moved, cropped)
}
};
Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved })
Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved, cropped })
}
/// Draw shadow raster `s` through `to`, from the view the raster was made for to the one `ctx`
@@ -1241,6 +1285,7 @@ impl Renderer {
(k.1, k.2) == (key.1, key.2)
&& e.stamp < stamp
&& e.linear == linear
&& e.view.is_none()
&& e.node.id() == n.id
&& e.inputs == *inputs
&& e.made.fits(&[fx], size, None, None)
@@ -1583,7 +1628,17 @@ impl Renderer {
// through a filter layer from the content alone, and goes into the later silhouettes that way.
let mut made: Vec<(&RasterFx, Option<Shadow>)> = vec![];
let mut stacked = reach;
let screen = Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height()));
// How far (document units) a shadow or glow looks from a pixel to the silhouette under it.
let looks = |fx: &RasterFx| match fx {
RasterFx::DropShadow { dx, dy, blur, .. } => blur.max(0.0) * 1.5 + dx.abs() + dy.abs(),
RasterFx::OuterGlow { blur, .. } => blur.max(0.0) * 1.5,
_ => 0.0,
};
for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) {
// A raster cropped to the view covers what the shadows and glows stacked over it read.
let around: f64 = rfx.iter().skip(i + 1).filter(|x| x.is_below()).map(looks).sum();
let over_cropped = made.iter().any(|(_, s)| s.as_ref().is_some_and(|s| s.cropped));
let shadow = {
let earlier = &made;
let view = f.view;
@@ -1600,7 +1655,8 @@ impl Renderer {
}
paint(r, c, fr);
};
content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), &mut art))
let view = (screen, around, over_cropped);
content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), view, &mut art))
};
stacked = match fx {
RasterFx::DropShadow { dx, dy, blur, .. } => stacked.union((stacked + Vec2::new(*dx, *dy)).inflate(blur * 1.5, blur * 1.5)),
@@ -1670,7 +1726,10 @@ impl Renderer {
/// every effect only looks nearby, and content still over either limit is rendered at a lower
/// resolution. A whole raster at full resolution is cached while the object, the
/// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and
/// while interacting after the content only moved ([`Self::move_pixel_content`]).
/// while interacting after the content only moved ([`Self::move_pixel_content`]). A cropped or
/// lower-resolution raster is cached while the object, the view (its pan included) and what the
/// content reads stay the same ([`PixelEntry::view`]); a lower-resolution one is kept resampled to
/// the view's pixels ([`Self::resampled`]).
/// While dragging, content without such a raster to draw renders within [`DRAG_PIXEL_BUDGET`]
/// pixels and is not cached.
fn pixel_content(
@@ -1720,9 +1779,11 @@ impl Renderer {
let side = f64::from(crate::MAX_RASTER_SIDE);
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side));
// The cached raster, when the whole content was rendered at full size at this zoom.
// The cached raster: of the whole content at full size at this zoom, else of this view.
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 whole = fits && k >= 1.0;
let in_view = (!whole).then(|| view_key(f.view, screen));
let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink));
let stamp = self.stamp;
let channels = match f.ink {
Ink::Cmy => effects::pixel::Channels::CmyPlane,
@@ -1740,8 +1801,11 @@ impl Renderer {
self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits())))
});
let interacting = f.opts.interacting;
let exact = |e: &PixelEntry| e.linear == linear && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved);
let exact = |e: &PixelEntry| {
e.linear == linear && e.view == in_view && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved)
};
if interacting
&& whole
&& let Some(key) = key
&& let Some(inputs) = &inputs
&& !self.pixel_fx.get(&key).is_some_and(exact)
@@ -1815,9 +1879,10 @@ impl Renderer {
let failed = crate::paint::decode_failures() - decoded;
// Content drawn over moved rasters makes a moved raster.
let moved = self.stats.moved > before;
// What the raster is made from, for its object once it moves (`move_pixel_content`).
// What the raster is made from, for its object once it moves (`move_pixel_content`): a
// cropped or coarser raster never moves.
let made = key.map(|_| Made {
pixels: pm.data_as_u8_slice().to_vec(),
pixels: if whole { pm.data_as_u8_slice().to_vec() } else { Vec::new() },
fx: chain.iter().map(|fx| (*fx).clone()).collect(),
size: content.reach.size(),
channels: Some(channels),
@@ -1863,6 +1928,14 @@ impl Renderer {
}
}
let image = std::sync::Arc::new(pm);
// A raster kept for this view alone at a lower resolution than the view's is resampled to the
// view's pixels once: each frame that draws it then copies it pixel for pixel, where sampling
// it again across the whole view cost several times as much as a plain fill.
let view_raster = key.is_some() && in_view.is_some() && k < 1.0 && content.kept.is_none();
let (image, x0, y0, k) = match view_raster.then(|| self.resampled(f, image.clone(), (x0, y0), k, screen)).flatten() {
Some((image, x, y)) => (image, x, y, 1.0),
None => (image, x0, y0, k),
};
// A cancelled export drew part of the content: that raster isn't kept for later frames.
if !vectorcraft_doc::cancel::is_cancelled()
&& let Some(((key, made), inputs)) = key.zip(made).zip(inputs)
@@ -1872,7 +1945,8 @@ impl Renderer {
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(), made, inputs, moved, failed, stamp });
let entry = PixelEntry { node, linear, anchor, rel, k, image: image.clone(), view: in_view, made, inputs, moved, failed, stamp };
self.pixel_fx.insert(key, entry);
self.trim_fx_rasters(FX_RASTER_BUDGET);
}
// An image over the limits was drawn at a frame's resolution: its raster is kept.
@@ -1886,6 +1960,33 @@ impl Renderer {
}
}
/// `image` as [`draw_scaled`] draws it with its top-left at (`x`, `y`) device pixels, `1 / k`
/// device pixels a pixel, resampled to the device pixels it covers of `screen`, with their
/// top-left. `None` when it covers none of them.
fn resampled(
&mut self,
f: &Frame,
image: std::sync::Arc<vello_cpu::Pixmap>,
(x, y): (f64, f64),
k: f64,
screen: Rect,
) -> Option<(std::sync::Arc<vello_cpu::Pixmap>, f64, f64)> {
let covered = Rect::new(x, y, x + f64::from(image.width()) / k, y + f64::from(image.height()) / k).intersect(screen);
let (x0, y0) = (covered.x0.floor(), covered.y0.floor());
let (w, h) = (covered.x1.ceil() - x0, covered.y1.ceil() - y0);
let side = f64::from(crate::MAX_RASTER_SIDE);
if !((1.0..=side).contains(&w) && (1.0..=side).contains(&h)) {
return None;
}
let (w, h) = (w as u16, h as u16);
let mut off = f.offscreen_context(w, h);
draw_scaled(&mut off, image, (x - x0, y - y0), k);
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
off.render(&mut pm, &mut self.resources);
Some((std::sync::Arc::new(pm), x0, y0))
}
/// 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, with
/// the same `inputs`) when only the content's place changed after that frame: made by the same
@@ -1916,6 +2017,7 @@ impl Renderer {
&& e.stamp < stamp
&& e.linear == linear
&& e.k >= 1.0
&& e.view.is_none()
&& e.node.id() == content.node.id
&& e.inputs == *inputs
&& e.made.fits(chain, content.reach.size(), Some(channels), outline.as_ref())
@@ -2390,6 +2492,45 @@ mod tests {
assert!(r.render(&d, 400, 400, Affine::scale(2.0), &opts).pixels == first.pixels, "made again alike");
}
/// An Outer Glow whose whole reach is over the budget is made coarser over that whole reach and
/// kept for any pan: its grid starts at the reach's first whole device pixel, which a pan by whole
/// pixels moves with the art, so the raster drawn after the pan is the one a fresh renderer makes,
/// also for pans that aren't multiples of the raster's scale. A pan by a fraction of a pixel
/// places the raster at the nearest whole pixel, as a full-size raster kept whole is placed, and
/// here draws as a fresh renderer draws too. A 100 pt glow at 500% (scale 2) and a 2 pt glow
/// round the page at 800% (scale 3, near the narrowest blur made coarser).
#[test]
fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() {
let size = (640, 360);
for (rect, blur, zoom, center, scale) in [
(Rect::new(200.0, 300.0, 400.0, 500.0), 100.0, 5.0, (440.0, 400.0), 2.0),
(Rect::new(0.0, 0.0, 595.0, 842.0), 2.0, 8.0, (595.0, 421.0), 3.0),
] {
let mut d = Document::new(595.0, 842.0);
let layer = d.layers[0].id;
let mut n =
Node::path(d.alloc_id(), shapes::rectangle(rect), Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0));
n.appearance.effects = vec![Effect { id: "stylize.outerGlow".into(), params: json!({"blur": blur}), visible: true }];
d.insert(Some(layer), 0, n).unwrap();
let base = Affine::translate((320.0, 180.0)) * Affine::scale(zoom) * Affine::translate((-center.0, -center.1));
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, base, &opts);
let image = |r: &Renderer| r.shadows.values().map(|e| std::sync::Arc::as_ptr(&e.image)).collect::<Vec<_>>();
let kept = image(&r);
assert!(r.shadows.values().all(|e| e.scale == scale && e.view.is_none()), "blur {blur}: coarser, over the whole reach");
for pan in [(37.0, -21.0), (1.0, 0.0), (-3.0, 5.0), (0.5, 0.0), (37.3, -21.6)] {
let xf = Affine::translate(pan) * base;
let panned = r.render(&d, size.0, size.1, xf, &opts);
assert_eq!(image(&r), kept, "blur {blur}, pan {pan:?}: the raster kept is drawn");
let mut fresh = Renderer::new();
fresh.threads = 2;
assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}");
}
}
}
/// The multithreaded rasterizer draws the same pixels with any number of workers, so the
/// automatic thread count can follow the machine.
#[test]
@@ -2872,8 +3013,9 @@ mod tests {
/// While dragging (#2558), filtered content and a drop shadow that the frame does not draw from a
/// raster of an earlier frame render within the drag's budget and keep no raster. At 6400% a
/// rectangle with Drop Shadow and Texturizer covers more than the raster budget, so no raster is
/// kept for either, and a 1600 × 1000 view near its corner needs more than the drag's budget. The
/// rectangle with Drop Shadow and Texturizer covers more than the raster budget, so the still
/// frame keeps rasters cropped to its view, which the moved rectangle can't take along, and a
/// 1600 × 1000 view near its corner needs more than the drag's budget. The
/// frame after the drag, of the same document, is what a fresh renderer draws, as is a frame
/// rendered while interacting without a drag (a dialog's preview).
#[test]
@@ -2903,7 +3045,7 @@ mod tests {
let dragged = r.render(&d, 1600, 1000, view, &drag).pixels;
assert_eq!(r.stats.previewed, 2, "the shadow and the filtered content");
assert_eq!(r.stats.moved, 2, "counted as moved: the frame after the drag renders them again");
assert!(r.pixel_fx.is_empty() && r.shadows.is_empty(), "no raster kept");
assert!(r.pixel_fx.is_empty() && r.shadows.values().all(|e| e.stamp < r.stamp), "no raster kept");
assert!(dragged != fresh, "at the drag's lower resolution");
let after = r.render(&d, 1600, 1000, view, &still).pixels;
assert_eq!((r.stats.previewed, r.stats.moved), (0, 0));
+2
View File
@@ -2528,3 +2528,5 @@ mod tests_setup;
mod tests_strokegradient;
#[cfg(test)]
mod tests_tileedge;
#[cfg(test)]
mod tests_viewraster;
+161
View File
@@ -0,0 +1,161 @@
//! Effect rasters cropped to the view, or made at a lower resolution, are kept while the view
//! stays as it is: dragging other art over an object whose effects reach far past the view (a wide
//! blur, or any blur zoomed in) draws its raster from the last frame.
use serde_json::json;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId};
use vectorcraft_geom::{Affine, Rect, shapes};
use crate::{RenderOptions, Renderer};
/// An A4 page filled by a blue rectangle under `effects`, and a small orange square over it
/// (returned with the document): the square is the art a drag moves.
fn backdrop(effects: &[(&str, serde_json::Value)]) -> (Document, NodeId) {
let mut d = Document::new(595.0, 842.0);
let layer = d.layers[0].id;
let mut back = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 595.0, 842.0)),
Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0),
);
back.appearance.effects = effects.iter().map(|(id, p)| Effect { id: (*id).into(), params: p.clone(), visible: true }).collect();
d.insert(Some(layer), 0, back).unwrap();
let id = d.alloc_id();
let square = Node::path(
id,
shapes::rectangle(Rect::new(280.0, 400.0, 300.0, 420.0)),
Appearance::basic(Paint::solid(Color::rgb(0.9, 0.4, 0.1)), Paint::None, 0.0),
);
d.insert(Some(layer), 1, square).unwrap();
(d, id)
}
/// A `w` × `h` view at `zoom`, centered on the page.
fn view(zoom: f64, (w, h): (u32, u32)) -> Affine {
Affine::translate((f64::from(w) / 2.0, f64::from(h) / 2.0)) * Affine::scale(zoom) * Affine::translate((-297.5, -421.0))
}
/// `base` with the square moved by `step` steps of a drag.
fn moved(base: &Document, id: NodeId, step: u32, zoom: f64) -> Document {
let mut d = base.clone();
let by = (f64::from(step) * 3.0 / zoom, f64::from(step) * 2.0 / zoom);
d.node_mut(id).unwrap().transform(Affine::translate(by), false);
d
}
/// A case's name, its effects (id and params) and its zoom.
type Case = (&'static str, Vec<(&'static str, serde_json::Value)>, f64);
/// Every effect whose raster can be cropped to the view or made coarser, with a zoom at which it
/// is: wide blurs at the fit zoom, narrow ones zoomed in, a Photoshop-style filter, a shadow and a
/// glow.
fn cases() -> Vec<Case> {
vec![
("Gaussian Blur 500 pt", vec![("blur.gaussian", json!({"radius": 500}))], 1.6),
("Gaussian Blur 100 pt", vec![("blur.gaussian", json!({"radius": 100}))], 1.6),
("Gaussian Blur 20 pt", vec![("blur.gaussian", json!({"radius": 20}))], 4.0),
("Gaussian Blur 8 pt", vec![("blur.gaussian", json!({"radius": 8}))], 16.0),
("Feather 20 pt", vec![("stylize.feather", json!({"radius": 20}))], 4.0),
("Craquelure", vec![("texture.craquelure", json!({}))], 4.0),
("Drop Shadow blur 100 pt", vec![("stylize.dropShadow", json!({"blur": 100, "x": 20, "y": 20}))], 1.6),
("Drop Shadow blur 10 pt", vec![("stylize.dropShadow", json!({"blur": 10, "x": 5, "y": 5}))], 8.0),
("Outer Glow blur 100 pt", vec![("stylize.outerGlow", json!({"blur": 100}))], 1.6),
]
}
/// A drag of the square over the backdrop draws the backdrop's effects from the rasters of the
/// frame before the drag, at full resolution: no frame of the drag previews them at a drag's lower
/// resolution, and each draws as a fresh renderer draws its still frame.
#[test]
fn a_drag_over_art_with_effects_past_the_view_draws_their_rasters_kept() {
let size = (640, 360);
for (name, effects, zoom) in cases() {
let (base, id) = backdrop(&effects);
let xf = view(zoom, size);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let live = RenderOptions { interacting: true, dragging: true, ..opts.clone() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&base, size.0, size.1, xf, &opts);
for step in 1..4 {
let d = moved(&base, id, step, zoom);
let drag = r.render(&d, size.0, size.1, xf, &live);
assert_eq!(r.stats.previewed, 0, "{name}: drag frame {step} drew the backdrop's effects from the rasters kept");
let mut fresh = Renderer::new();
fresh.threads = 2;
let still = fresh.render(&d, size.0, size.1, xf, &opts);
assert!(drag.pixels == still.pixels, "{name}: drag frame {step} draws as a still frame");
}
}
}
/// A pan by whole pixels or a zoom makes the rasters cropped to the view again: the frame after it draws as a
/// fresh renderer draws it.
#[test]
fn rasters_cropped_to_the_view_follow_a_pan_and_a_zoom() {
let size = (640, 360);
for (name, effects, zoom) in cases() {
let (d, _) = backdrop(&effects);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, view(zoom, size), &opts);
for xf in [Affine::translate((37.0, -21.0)) * view(zoom, size), view(zoom * 1.25, size)] {
let image = r.render(&d, size.0, size.1, xf, &opts);
let mut fresh = Renderer::new();
fresh.threads = 2;
assert!(image.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "{name}");
}
}
}
/// Time a drag of the square over each backdrop in a 2560 × 1440 view: the first frame renders as
/// before a drag, the next eight as a drag's frames (`interacting` and `dragging`, as the canvas
/// renders them), then the frame after the drag. The hashes of that frame and of a fresh renderer's
/// frame compare builds.
/// Run with `cargo test --release -p vectorcraft-render backdrop_drag_perf -- --ignored --nocapture`.
#[test]
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
fn backdrop_drag_perf() {
let hash = |px: &[u8]| {
use std::hash::{Hash, Hasher};
let mut h = std::hash::DefaultHasher::new();
px.hash(&mut h);
h.finish()
};
let size = (2560, 1440);
for (name, effects, zoom) in cases() {
let (base, id) = backdrop(&effects);
let xf = view(zoom, size);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let live = RenderOptions { interacting: true, dragging: true, ..opts.clone() };
let mut r = Renderer::new();
r.threads = crate::default_threads();
let start = std::time::Instant::now();
std::hint::black_box(r.render(&base, size.0, size.1, xf, &opts));
let first = start.elapsed().as_secs_f64() * 1000.0;
let (mut ms, mut previewed, mut doc) = (vec![], 0, base.clone());
for step in 1..9 {
doc = moved(&base, id, step, zoom);
let start = std::time::Instant::now();
std::hint::black_box(r.render(&doc, size.0, size.1, xf, &live));
ms.push(start.elapsed().as_secs_f64() * 1000.0);
previewed += r.stats.previewed;
}
let start = std::time::Instant::now();
let still = r.render(&doc, size.0, size.1, xf, &opts);
let after = start.elapsed().as_secs_f64() * 1000.0;
let fresh = Renderer::new().render(&doc, size.0, size.1, xf, &opts);
ms.sort_by(f64::total_cmp);
println!(
"{name} at {:.0}%: first frame {first:.1} ms; drag frames median {:.1} ms (min {:.1}, max {:.1}), {previewed} previewed; the frame after the drag {after:.1} ms; hashes {:016x} {:016x}",
zoom * 100.0,
(ms[3] + ms[4]) * 0.5,
ms[0],
ms[7],
hash(&still.pixels),
hash(&fresh.pixels)
);
}
}
+108 -2
View File
@@ -339,6 +339,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.perf.render_ms = done.ms;
app.canvas.last_ms = done.ms;
app.canvas.moved = done.moved > 0;
app.canvas.from_worker = true;
}
if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() {
let (w, h, view) = match pixel {
@@ -389,10 +390,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// 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.
// to the worker. An interaction's frames stay on the worker once it drew the frame shown:
// its renderer keeps the rasters of the art's effects that make those frames fast, and this
// thread's renderer has none, so a frame fast on the worker would be slow here.
let same_doc = app.canvas.key.as_ref().is_some_and(|k| k.doc == key.doc);
let settling = app.canvas.moved && !key.interacting;
let heavy = (app.canvas.last_ms > 8.0 || settling) && app.canvas.texture.is_some() && same_doc;
let staying = key.interacting && app.canvas.from_worker;
let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && 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 }),
_ => {
@@ -403,6 +407,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.perf.render_ms = now_ms() - t0;
app.canvas.last_ms = app.perf.render_ms;
app.canvas.moved = app.canvas.renderer.stats.moved > 0;
app.canvas.from_worker = false;
}
}
}
@@ -4727,6 +4732,107 @@ mod tests {
assert_eq!(app.canvas.renderer.stats.previewed, 0);
}
/// Dragging a small square over an A4 page filled by a rectangle blurred by 500 pt, at 200%:
/// the blur reaches far past the raster budget, so the still frame keeps the backdrop's raster
/// cropped to the view, and each frame of a Selection tool drag draws it from there instead of
/// previewing it at the drag's lower resolution: on this thread, and on the worker, where the
/// frames of a heavy document render.
#[test]
fn a_drag_over_a_wide_blur_draws_the_blur_from_the_still_frame() {
for on_worker in [false, true] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 595, "height": 842})).unwrap();
app.canvas.worker_started = true; // no worker unless `on_worker`
let back = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 595, "height": 842})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [back], "color": "#1a4ce6"})).unwrap();
app.run("effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 500}, "ids": [back]})).unwrap();
let square = app.run("shape.rectangle", json!({"x": 260, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [square]})).unwrap();
app.select_tool("selection");
let ctx = egui::Context::default();
// One headless frame with `events`, then, on the worker, frames until the frame it renders
// for them is shown.
let run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let frame = |app: &mut VectorcraftApp, events| {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 870.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
};
frame(app, events);
let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
while app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) && std::time::Instant::now() < until {
std::thread::sleep(std::time::Duration::from_millis(5));
frame(app, vec![]);
}
};
let moved = |app: &VectorcraftApp| if on_worker { app.canvas.moved } else { app.canvas.renderer.stats.moved > 0 };
run(&mut app, vec![]);
if let Some(vm) = app.view_mut() {
(vm.zoom, vm.center) = (2.0, Point::new(290.0, 410.0));
}
if on_worker {
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
app.canvas.last_ms = 50.0;
}
run(&mut app, vec![]);
assert_eq!(app.canvas.from_worker, on_worker);
assert!(!moved(&app), "a still frame, on the worker: {on_worker}");
let at = app.canvas_rect.unwrap().center();
let button =
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
run(&mut app, vec![egui::Event::PointerMoved(at)]);
run(&mut app, vec![button(at, true)]);
for step in [1.0, 2.0, 3.0] {
run(&mut app, vec![egui::Event::PointerMoved(at + vec2(13.0, 7.0) * step)]);
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && k.dragging), "step {step}, on the worker: {on_worker}");
assert_eq!(app.canvas.from_worker, on_worker, "step {step}");
assert!(
!moved(&app),
"the blur drawn from the still frame's raster, step {step}, on the worker: {on_worker} ({} ms)",
app.perf.render_ms
);
}
run(&mut app, vec![button(at + vec2(39.0, 21.0), false)]);
let info = app.run("document.inspect", json!({})).unwrap();
let children = info["layers"][0]["children"].as_array().unwrap().clone();
let x0 = |id: &serde_json::Value| children.iter().find(|c| c["id"] == *id).unwrap()["bounds"]["x"].as_f64().unwrap();
assert!(x0(&square) > 270.0 && x0(&back) == 0.0, "the square moved, the backdrop did not");
app.canvas.worker = None;
}
}
/// An interaction's frames stay on the worker once it drew the frame shown, however fast that
/// frame was: the worker's renderer keeps the effect rasters that made it fast, and this
/// thread's renderer, without them, would draw the next frame slowly. Without a frame from the
/// worker, a fast frame renders on this thread as before.
#[test]
fn an_interaction_stays_on_the_worker_that_drew_the_frame_shown() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
app.canvas.worker_started = true; // the first frame renders on this thread
let ctx = egui::Context::default();
shapes(&mut app, &ctx);
for from_worker in [true, false] {
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
(app.canvas.from_worker, app.canvas.last_ms) = (from_worker, 1.0);
app.session.begin_interaction("Preview").unwrap();
shapes(&mut app, &ctx);
let pending = app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| k.interacting);
assert_eq!(pending, from_worker, "sent to the worker: {from_worker}");
if !from_worker {
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting), "rendered on this thread");
}
app.session.cancel_interaction().unwrap();
app.canvas.worker = None;
shapes(&mut app, &ctx);
}
}
/// 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]
+4
View File
@@ -311,6 +311,9 @@ pub struct CanvasCache {
/// drag's lower resolution ([`vectorcraft_render::FrameStats::moved`]): the next frame outside
/// an interaction runs them again, on the worker when there is one.
pub moved: bool,
/// The frame shown was rendered on the worker: an interaction's frames stay there
/// ([`canvas::show`]).
pub from_worker: bool,
pub worker: Option<render_worker::Worker>,
pub worker_started: bool,
/// The slices as laid out for (document uid, revision): the canvas draws them every frame.
@@ -525,6 +528,7 @@ impl VectorcraftApp {
key: None,
last_ms: 0.0,
moved: false,
from_worker: false,
worker: None,
worker_started: false,
slices: None,
File diff suppressed because one or more lines are too long