mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
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:
+173
-31
@@ -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));
|
||||
|
||||
@@ -2528,3 +2528,5 @@ mod tests_setup;
|
||||
mod tests_strokegradient;
|
||||
#[cfg(test)]
|
||||
mod tests_tileedge;
|
||||
#[cfg(test)]
|
||||
mod tests_viewraster;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user