mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 08:20:13 +00:00
Canvas: a pan by whole pixels copies the pixels that stay on screen
Squashed from #6070 onto main.
This commit is contained in:
@@ -137,6 +137,13 @@ pub struct FrameStats {
|
||||
/// The filtered contents, shadows and glows the frame drew at a drag's lower resolution
|
||||
/// ([`RenderOptions::dragging`]). [`Self::moved`] counts them too.
|
||||
pub previewed: usize,
|
||||
/// What the frame drew in a way that depends on the frame's size: effect rasters and filter
|
||||
/// layers cropped to it or made at a lower resolution, blending groups drawn as layers past its
|
||||
/// offscreen budget, gradient meshes drawn quad by quad past the mesh buffer, and every filter
|
||||
/// layer drawn on a single-threaded context: on a single-threaded renderer, or offscreen in a
|
||||
/// group or an effect's raster. A part of the frame rendered on its own can draw that art with
|
||||
/// other pixels than the whole frame does.
|
||||
pub view_sized: usize,
|
||||
/// How many threads composed the frame from its two ink planes, in a CMYK document that shows
|
||||
/// transparency; 0 for other frames.
|
||||
pub ink_threads: usize,
|
||||
|
||||
@@ -100,6 +100,7 @@ impl Renderer {
|
||||
// The cached raster: of the whole content at full size at this zoom, else of this view (and
|
||||
// the margin around it).
|
||||
let whole = fits && k >= 1.0;
|
||||
self.stats.view_sized += usize::from(!whole);
|
||||
let in_view = (!whole).then(|| ViewCrop::of(f.view, room));
|
||||
let stamp = self.stamp;
|
||||
let channels = match f.ink.base() {
|
||||
@@ -574,10 +575,15 @@ impl Renderer {
|
||||
composite: Option<(vectorcraft_doc::color::BlendMode, f32)>,
|
||||
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame, Option<(vectorcraft_doc::color::BlendMode, f32)>),
|
||||
) {
|
||||
// A filter layer's coarser grid starts where its context does: at the corner of a context
|
||||
// drawn single-threaded, and at the frame's edge for a region cropped to the frame.
|
||||
let screen = Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height()));
|
||||
self.stats.view_sized += usize::from(!f.mt || f.view.transform_rect_bbox(reach).union(screen) != screen);
|
||||
if !f.mt {
|
||||
return self.inside_layer(|r| draw(r, ctx, f, composite));
|
||||
}
|
||||
let Some((x0, y0, w, h, k)) = filter_region(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px) else { return };
|
||||
self.stats.view_sized += usize::from(k < 1.0);
|
||||
// During a drag, the region holds at most `DRAG_PIXEL_BUDGET` pixels (#2558).
|
||||
let drag = || filter_region_within(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px, DRAG_PIXEL_BUDGET);
|
||||
let (x0, y0, w, h, k) = match f.opts.dragging.then(drag).flatten().filter(|r| r.4 < k) {
|
||||
|
||||
@@ -183,6 +183,7 @@ impl Renderer {
|
||||
(image, x0, y0, scale, moved, cropped)
|
||||
}
|
||||
};
|
||||
self.stats.view_sized += usize::from(cropped.is_some() || scale != 1.0);
|
||||
Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved, cropped })
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ impl Renderer {
|
||||
if c.mask.is_some() || self.offscreen_fits(ctx, r, c.isolated) {
|
||||
return self.group_offscreen(ctx, f, c, r, draw);
|
||||
}
|
||||
self.stats.view_sized += 1;
|
||||
}
|
||||
if c.mask.is_some() {
|
||||
// Its backdrop out of reach (or not needed), a masked group starts from transparency.
|
||||
|
||||
@@ -582,6 +582,7 @@ impl Renderer {
|
||||
ctx.set_transform(f.view);
|
||||
return;
|
||||
}
|
||||
self.stats.view_sized += 1;
|
||||
ctx.set_transform(f.view);
|
||||
// Where a patch narrows to slivers, many quads share each pixel, each covering a little of it: their edges
|
||||
// composite to part coverage, and hard edges drop each one. An opaque patch is backed by its own outline in
|
||||
|
||||
@@ -498,4 +498,13 @@ fn nested_isolated_blending_groups_stay_within_the_offscreen_budget() {
|
||||
assert!(images <= budget, "{images} pixels drawn from offscreen groups, budget {budget}");
|
||||
assert_near(frame.pixel(50, 50), blend_rgb(Blend::Multiply, B, S), "inside the groups");
|
||||
assert_near(frame.pixel(10, 10), B, "outside the groups");
|
||||
let mut r = Renderer::new();
|
||||
render_with(&mut r, &d, &RenderOptions::default());
|
||||
assert!(r.stats.view_sized > 0, "levels past the budget depend on the frame's size");
|
||||
let within = doc(|d| {
|
||||
let n = multiply(d);
|
||||
group(d, vec![n], 1.0, Blend::Multiply, true)
|
||||
});
|
||||
render_with(&mut r, &within, &RenderOptions::default());
|
||||
assert_eq!(r.stats.view_sized, 0, "a group within the budget");
|
||||
}
|
||||
|
||||
@@ -180,6 +180,72 @@ fn pans_within_a_cropped_rasters_margin_draw_it_kept() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame counts the art it draws in a way that depends on its size: an effect raster cropped to
|
||||
/// it or made at a lower resolution, drawn from the raster kept as well as made; a filter layer
|
||||
/// cropped to it or made at a lower resolution; any filter layer drawn on a single-threaded context,
|
||||
/// as on a single-threaded renderer or in a group drawn offscreen; and a mesh past the mesh buffer.
|
||||
/// Art with a raster of its own whole, and a filter layer that stays inside the frame, are not
|
||||
/// counted.
|
||||
#[test]
|
||||
fn a_frame_counts_the_art_it_draws_by_its_size() {
|
||||
let effect = |id: &str, params: serde_json::Value| backdrop(&[(id, params)]).0;
|
||||
let mesh = |side: f64| {
|
||||
let mut d = Document::new(595.0, 842.0);
|
||||
let m = vectorcraft_doc::GradientMesh::from_surface(2, 2, &|u, v| vectorcraft_geom::Point::new(u * side, v * side), &|u, v| {
|
||||
Color::rgb(u as f32, v as f32, 0.5)
|
||||
});
|
||||
let layer = d.layers[0].id;
|
||||
let node = Node::new(d.alloc_id(), NodeKind::Mesh(m));
|
||||
d.insert(Some(layer), 0, node).unwrap();
|
||||
d
|
||||
};
|
||||
let blur = effect("blur.gaussian", json!({"radius": 8}));
|
||||
let shadow = effect("stylize.dropShadow", json!({"blur": 8, "x": 4, "y": 4}));
|
||||
let outer_glow = effect("stylize.outerGlow", json!({"blur": 8}));
|
||||
let glow = effect("stylize.innerGlow", json!({"blur": 8}));
|
||||
let wide_glow = effect("stylize.innerGlow", json!({"blur": 2000}));
|
||||
let group_glow = {
|
||||
let mut d = Document::new(595.0, 842.0);
|
||||
let basic = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0);
|
||||
let wide = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 595.0, 842.0)), basic(Color::rgb(0.9, 0.8, 0.3)));
|
||||
let mut child = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(200.0, 200.0, 330.0, 500.0)), basic(Color::rgb(0.1, 0.3, 0.9)));
|
||||
child.appearance.effects = vec![Effect { id: "stylize.innerGlow".into(), params: json!({"blur": 40}), visible: true }];
|
||||
child.blend = vectorcraft_doc::color::BlendMode::Multiply;
|
||||
let mut group = Node::group(d.alloc_id(), vec![std::sync::Arc::new(wide), std::sync::Arc::new(child)]);
|
||||
group.isolate = true;
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, group).unwrap();
|
||||
d
|
||||
};
|
||||
let cases = [
|
||||
("a whole blur raster", &blur, 1.0, (640, 900), 2, false),
|
||||
("a blur raster cropped to the frame", &blur, 10.0, (256, 128), 2, true),
|
||||
("a blur raster at a lower resolution", &blur, 5.0, (3200, 4400), 2, true),
|
||||
("a whole shadow raster", &shadow, 1.0, (640, 900), 2, false),
|
||||
("a shadow raster cropped to the frame", &shadow, 40.0, (256, 128), 2, true),
|
||||
("an outer glow raster at a lower resolution", &outer_glow, 5.0, (3200, 4400), 2, true),
|
||||
("a filter layer inside the frame", &glow, 1.0, (640, 900), 2, false),
|
||||
("a filter layer past the frame's edge", &glow, 1.0, (256, 128), 2, true),
|
||||
("a filter layer at a lower resolution", &wide_glow, 1.0, (640, 900), 2, true),
|
||||
("a filter layer on a single-threaded renderer", &glow, 1.0, (640, 900), 0, true),
|
||||
("a filter layer in a group drawn offscreen", &group_glow, 1.0, (640, 900), 2, true),
|
||||
];
|
||||
for (name, d, zoom, size, threads, counted) in cases {
|
||||
let opts = RenderOptions::default();
|
||||
let mut r = Renderer::new();
|
||||
r.threads = threads;
|
||||
r.render(d, size.0, size.1, view(zoom, size), &opts);
|
||||
assert_eq!(r.stats.view_sized > 0, counted, "{name}");
|
||||
r.render(d, size.0, size.1, view(zoom, size), &opts);
|
||||
assert_eq!(r.stats.view_sized > 0, counted, "{name}, drawn again");
|
||||
}
|
||||
let mut r = Renderer::new();
|
||||
r.render(&mesh(100.0), 2100, 2100, Affine::IDENTITY, &RenderOptions::default());
|
||||
assert_eq!(r.stats.view_sized, 0, "a mesh within the mesh buffer");
|
||||
r.render(&mesh(3000.0), 2100, 2100, Affine::IDENTITY, &RenderOptions::default());
|
||||
assert!(r.stats.view_sized > 0, "a mesh past the mesh buffer");
|
||||
}
|
||||
|
||||
/// Scrolls by amounts that are not whole pixels, as the wheel and a trackpad give them, moved in whole pixels with the part left
|
||||
/// over carried to the next, as the canvas moves them: after the scroll that makes the Drop Shadow's cropped raster
|
||||
/// again with its margin, every later scroll draws it kept. The same scrolls moved as given make it again in every
|
||||
|
||||
@@ -70,6 +70,7 @@ impl VectorcraftApp {
|
||||
last_ms: 0.0,
|
||||
moved: false,
|
||||
from_worker: false,
|
||||
reused: None,
|
||||
waits_for_still: cfg!(target_arch = "wasm32"),
|
||||
still: None,
|
||||
fill: None,
|
||||
|
||||
@@ -17,6 +17,10 @@ pub struct CanvasCache {
|
||||
/// The frame shown was rendered on the worker: an interaction's frames stay there
|
||||
/// ([`canvas::show`]).
|
||||
pub from_worker: bool,
|
||||
/// The frame shown holds pixels the worker copied from its last frame, and when it was shown
|
||||
/// (seconds, egui's input time): it renders whole once it has been shown for
|
||||
/// [`canvas::STILL_SECS`] with the view unmoved.
|
||||
pub reused: Option<f64>,
|
||||
/// Set in the web build, which has no render worker: while the view changes on a document whose
|
||||
/// last render took more than 8 ms, the canvas draws the frame shown reprojected and renders
|
||||
/// once the view has been still for [`canvas::STILL_SECS`].
|
||||
|
||||
@@ -65,7 +65,7 @@ use overlays::{HandleLook, draw_inside_overlay, draw_overlays, hover_highlight,
|
||||
pub use panes::Panes;
|
||||
pub(crate) use panes::{consolidate, pane, tile, tiled_area};
|
||||
use raster::{changed_pixels, pixel_region, upload};
|
||||
pub(crate) use raster::{color_image, color_image_owned};
|
||||
pub(crate) use raster::{color_image, color_image_owned, render_job};
|
||||
pub use selection_marks::SelectionMarks;
|
||||
use task_bar::task_bar;
|
||||
pub(crate) use task_bar::{has_colors, task_bar_rect};
|
||||
@@ -297,7 +297,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if let Some(secs) = wait {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(secs));
|
||||
}
|
||||
if wait.is_none() && (app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none()) {
|
||||
// A frame with copied pixels renders whole once it has been shown for STILL_SECS with the view unmoved.
|
||||
let same_frame = app.canvas.key.as_ref() == Some(&key);
|
||||
let settle = app.canvas.reused.filter(|_| same_frame).is_some_and(|at| {
|
||||
let waited = ui.input(|i| i.time) - at;
|
||||
// The clock went back (a new egui context): don't wait for it to catch up.
|
||||
let waiting = (0.0..STILL_SECS).contains(&waited);
|
||||
if waiting {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(STILL_SECS - waited));
|
||||
}
|
||||
!waiting
|
||||
});
|
||||
if wait.is_none() && (!same_frame || app.canvas.texture.is_none() || settle) {
|
||||
let (w, h, view) = frame_view(pixel, (w, h), &v, ppp);
|
||||
let opts = frame_options(app, &key, mask_view, smooth_images);
|
||||
draw_frame(app, ui, Frame { key: &key, doc: &doc, size: (w, h), view, opts, pixel });
|
||||
@@ -571,7 +582,11 @@ fn show_worker_frame(app: &mut VectorcraftApp, ctx: &egui::Context, done: crate:
|
||||
app.canvas.key = Some(done.key);
|
||||
app.canvas.shown = Some((done.doc, done.opts));
|
||||
app.perf.render_ms = done.ms;
|
||||
app.canvas.last_ms = done.ms;
|
||||
// A frame with copied pixels leaves `last_ms` at the whole frame's.
|
||||
if !done.reused {
|
||||
app.canvas.last_ms = done.ms;
|
||||
}
|
||||
app.canvas.reused = done.reused.then(|| ctx.input(|i| i.time));
|
||||
app.canvas.moved = done.moved > 0;
|
||||
app.canvas.from_worker = true;
|
||||
}
|
||||
@@ -671,6 +686,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
|
||||
let first = app.canvas.texture.is_none() || !same_doc;
|
||||
let heavy = first || app.canvas.last_ms > 8.0 || settling || staying;
|
||||
let changed = changed_pixels(app, key, doc, &opts, view, w, h);
|
||||
// A new view can copy from the worker's last frame. The frame shown asked for again renders
|
||||
// whole. The worker keeps either one for the next pan to copy from.
|
||||
let may_copy = app.canvas.key.as_ref() != Some(key);
|
||||
let mut landed = None;
|
||||
match (&mut app.canvas.worker, heavy, changed) {
|
||||
// Only the document changed since the frame shown, in a part of it: that part is
|
||||
@@ -697,6 +715,8 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
|
||||
view,
|
||||
opts,
|
||||
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
|
||||
may_copy,
|
||||
may_keep: true,
|
||||
});
|
||||
// A light document's first frame is ready within the grace and shows in this frame,
|
||||
// with no frame of bare paper. Waited for once, when the frame is queued.
|
||||
@@ -714,6 +734,7 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
|
||||
app.canvas.last_ms = app.perf.render_ms;
|
||||
app.canvas.moved = app.canvas.renderer.stats.moved > 0;
|
||||
app.canvas.from_worker = false;
|
||||
app.canvas.reused = None;
|
||||
}
|
||||
}
|
||||
if let Some(done) = landed {
|
||||
|
||||
@@ -106,6 +106,8 @@ pub(super) fn fill_uncovered(
|
||||
view,
|
||||
opts,
|
||||
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
|
||||
may_copy: false,
|
||||
may_keep: false,
|
||||
});
|
||||
}
|
||||
let Some(f) = app.canvas.fill.as_ref().filter(|f| usable(&f.key)) else { return };
|
||||
|
||||
@@ -82,6 +82,153 @@ fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) {
|
||||
(a, a + len)
|
||||
}
|
||||
|
||||
/// The most bytes of the last frame the render worker keeps: 16 million pixels. A larger frame is
|
||||
/// not kept, and every frame after it renders whole.
|
||||
const MAX_KEPT_BYTES: usize = 64 << 20;
|
||||
|
||||
/// How far the view of a frame may be from a whole device pixel, in device pixels, for the frame's
|
||||
/// pixels to be copied.
|
||||
const WHOLE_PIXEL: f64 = 1e-6;
|
||||
|
||||
/// The render worker's last frame, which its next frame can copy pixels from when it holds them.
|
||||
pub(crate) struct KeptFrame {
|
||||
key: CacheKey,
|
||||
opts: vectorcraft_render::RenderOptions,
|
||||
view: Affine,
|
||||
pub(crate) pixels: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A frame the render worker finished.
|
||||
pub(crate) struct WorkerFrame {
|
||||
pub(crate) img: vectorcraft_render::Rendered,
|
||||
/// [`vectorcraft_render::FrameStats::moved`] of the renders that made the frame.
|
||||
pub(crate) moved: usize,
|
||||
/// The frame holds pixels copied from the last frame.
|
||||
pub(crate) reused: bool,
|
||||
}
|
||||
|
||||
/// Nothing but the position of the view differs between the frame `kept` and the job `job`, in
|
||||
/// their keys, render options and views, and the frame is not Pixel Preview's and shows no opacity
|
||||
/// mask.
|
||||
fn only_position_differs(kept: &KeptFrame, job: &crate::render_worker::Job) -> bool {
|
||||
let (a, b) = (&kept.key, &job.key);
|
||||
let same_scale = a.zoom == b.zoom && a.rot == b.rot && a.ppp == b.ppp && (a.w, a.h) == (b.w, b.h) && (job.w, job.h) == (b.w, b.h);
|
||||
same_scale
|
||||
&& super::cursor::only_view_differs(a, b)
|
||||
&& a.pixel.is_none()
|
||||
&& b.pixel.is_none()
|
||||
&& kept.opts == job.opts
|
||||
&& job.opts.mask_view.is_none()
|
||||
&& kept.view.as_coeffs()[..4] == job.view.as_coeffs()[..4]
|
||||
}
|
||||
|
||||
/// How many whole device pixels the art moves between the frame `kept` and the job `job`, when
|
||||
/// `job` can copy pixels from it: `job` allows it, nothing but the position of the view differs
|
||||
/// ([`only_position_differs`]), and the art moves by whole device pixels.
|
||||
fn whole_pixel_pan(kept: &KeptFrame, job: &crate::render_worker::Job) -> Option<(i64, i64)> {
|
||||
if !job.may_copy || !only_position_differs(kept, job) {
|
||||
return None;
|
||||
}
|
||||
let (from, to) = (kept.view.as_coeffs(), job.view.as_coeffs());
|
||||
let whole = |d: f64| (d.is_finite() && (d - d.round()).abs() <= WHOLE_PIXEL && d.abs() < f64::from(u16::MAX)).then_some(d.round() as i64);
|
||||
Some((whole(to[4] - from[4])?, whole(to[5] - from[5])?))
|
||||
}
|
||||
|
||||
/// The parts `[x0, y0, x1, y1]` of a `w`×`h` frame that a pan by `(dx, dy)` device pixels exposes,
|
||||
/// grown to whole eighths of the frame. None when they cover more than half of it.
|
||||
fn exposed_parts(w: u32, h: u32, (dx, dy): (i64, i64)) -> Option<Vec<[u32; 4]>> {
|
||||
// How much of 0..n a move by `d` exposes, at the start for a positive `d`, else at the end.
|
||||
let span = |d: i64, n: u32| {
|
||||
let len = u32::try_from(d.unsigned_abs()).ok()?.min(n);
|
||||
Some(in_steps(if d > 0 { 0 } else { n - len }, if d > 0 { len } else { n }, n))
|
||||
};
|
||||
let mut parts = Vec::with_capacity(2);
|
||||
if dx != 0 {
|
||||
let (x0, x1) = span(dx, w)?;
|
||||
parts.push([x0, 0, x1, h]);
|
||||
}
|
||||
if dy != 0 {
|
||||
let (y0, y1) = span(dy, h)?;
|
||||
parts.push([0, y0, w, y1]);
|
||||
}
|
||||
let area: u64 = parts.iter().map(|[x0, y0, x1, y1]| u64::from(x1 - x0) * u64::from(y1 - y0)).sum();
|
||||
(area * 2 <= u64::from(w) * u64::from(h)).then_some(parts)
|
||||
}
|
||||
|
||||
/// `pixels` of a `w`×`h` frame with the art moved by `(dx, dy)` device pixels. The pixels the art
|
||||
/// does not reach stay transparent.
|
||||
fn moved_pixels(pixels: &[u8], w: u32, h: u32, (dx, dy): (i64, i64)) -> Vec<u8> {
|
||||
let (w, h) = (i64::from(w), i64::from(h));
|
||||
let mut out = vec![0; pixels.len()];
|
||||
let (x0, x1) = (dx.max(0), (w + dx).min(w));
|
||||
if x1 > x0 {
|
||||
for y in dy.max(0)..(h + dy).min(h) {
|
||||
let (from, to, n) = (((y - dy) * w + x0 - dx) as usize * 4, (y * w + x0) as usize * 4, (x1 - x0) as usize * 4);
|
||||
out[to..to + n].copy_from_slice(&pixels[from..from + n]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The frame of `job` from `pixels`, the last frame's moved by the pan, and the exposed `parts`
|
||||
/// rendered on their own, with its [`vectorcraft_render::FrameStats::moved`]. None when a part draws
|
||||
/// art in a way that depends on the frame's size ([`vectorcraft_render::FrameStats::view_sized`]).
|
||||
fn composed_frame(
|
||||
r: &mut vectorcraft_render::Renderer,
|
||||
mut pixels: Vec<u8>,
|
||||
parts: Vec<[u32; 4]>,
|
||||
job: &crate::render_worker::Job,
|
||||
) -> Option<(vectorcraft_render::Rendered, usize)> {
|
||||
let mut moved = 0;
|
||||
let stride = job.w as usize * 4;
|
||||
for [x0, y0, x1, y1] in parts {
|
||||
let view = Affine::translate((-f64::from(x0), -f64::from(y0))) * job.view;
|
||||
let part = r.render(&job.doc, x1 - x0, y1 - y0, view, &job.opts);
|
||||
if r.stats.view_sized > 0 {
|
||||
return None;
|
||||
}
|
||||
moved += r.stats.moved;
|
||||
let row = (x1 - x0) as usize * 4;
|
||||
for (i, line) in part.pixels.chunks_exact(row).enumerate() {
|
||||
let to = (y0 as usize + i) * stride + x0 as usize * 4;
|
||||
pixels[to..to + row].copy_from_slice(line);
|
||||
}
|
||||
}
|
||||
Some((vectorcraft_render::Rendered { width: job.w, height: job.h, pixels }, moved))
|
||||
}
|
||||
|
||||
/// Render the frame of `job`. A pan by whole device pixels copies the pixels that stay on screen
|
||||
/// from `kept`, the frame before it, and renders only the parts it exposes. Any other frame renders
|
||||
/// whole, and so does a pan when a part it exposes draws art in a way that depends on the frame's
|
||||
/// size ([`vectorcraft_render::FrameStats::view_sized`]). A copied frame can differ from the same
|
||||
/// frame rendered whole (by at most one level in the tests), so the canvas renders a frame whole
|
||||
/// once it has been shown for [`super::STILL_SECS`] with the view unmoved
|
||||
/// ([`crate::CanvasCache::reused`]). `kept` becomes this frame when `job` allows keeping it, it is
|
||||
/// at most [`MAX_KEPT_BYTES`], it draws no raster from before its object moved, and it draws nothing
|
||||
/// in a way that depends on its size. That whole frame is kept too, so the first step of the next
|
||||
/// pan copies from it. A kept frame holds its pixels only when the worker kept no frame before it or
|
||||
/// nothing but the position of the view differs from that frame. A frame after a zoom or an edit
|
||||
/// holds none, so it costs no copy, and the first pan step after it renders whole.
|
||||
pub(crate) fn render_job(r: &mut vectorcraft_render::Renderer, kept: &mut Option<KeptFrame>, job: &crate::render_worker::Job) -> WorkerFrame {
|
||||
let holds_pixels = kept.as_ref().is_none_or(|k| only_position_differs(k, job));
|
||||
let copied = kept.as_ref().and_then(|k| {
|
||||
let pan = whole_pixel_pan(k, job)?;
|
||||
let parts = exposed_parts(job.w, job.h, pan)?;
|
||||
composed_frame(r, moved_pixels(k.pixels.as_ref()?, job.w, job.h, pan), parts, job)
|
||||
});
|
||||
let reused = copied.is_some();
|
||||
let (img, moved) = copied.unwrap_or_else(|| {
|
||||
let img = r.render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||||
let moved = r.stats.moved;
|
||||
(img, moved)
|
||||
});
|
||||
let keeps =
|
||||
job.may_keep && moved == 0 && r.stats.view_sized == 0 && img.pixels.len() <= MAX_KEPT_BYTES && (img.width, img.height) == (job.w, job.h);
|
||||
*kept =
|
||||
keeps.then(|| KeptFrame { key: job.key.clone(), opts: job.opts.clone(), view: job.view, pixels: holds_pixels.then(|| img.pixels.clone()) });
|
||||
WorkerFrame { img, moved, reused }
|
||||
}
|
||||
|
||||
/// Upload the art's raster directly into the canvas texture without reallocating or copying pixels;
|
||||
/// Pixel Preview's document pixels keep hard edges when magnified.
|
||||
pub(super) fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: vectorcraft_render::Rendered, pixel: bool) {
|
||||
|
||||
@@ -2438,7 +2438,7 @@ fn cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes() {
|
||||
// Deliver the actual replacement and verify that it replaces the cached key.
|
||||
let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||||
let expected = job.key.clone();
|
||||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0 }).unwrap();
|
||||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0, reused: false }).unwrap();
|
||||
frame(&mut app, size, new_ppp);
|
||||
assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived");
|
||||
}
|
||||
@@ -2581,11 +2581,12 @@ fn pan_fills_the_area_the_last_frame_leaves() {
|
||||
let fill_job = fill_jobs.try_recv().expect("the fill is on the fill worker");
|
||||
let (fw, fh, _) = fill_view(&job.key);
|
||||
assert!(fill_job.opts.kept_images_only && (fill_job.w, fill_job.h) == (fw, fh), "{:?}", (fill_job.w, fill_job.h));
|
||||
assert!(!fill_job.may_copy && !fill_job.may_keep, "the fill worker copies nothing and holds no frame");
|
||||
assert!(app.canvas.fill.is_none(), "nothing to draw until the fill arrives");
|
||||
// The fill arrives; the next frame draws it, and asks for no other fill.
|
||||
let img = app.canvas.renderer.render(&fill_job.doc, fill_job.w, fill_job.h, fill_job.view, &fill_job.opts);
|
||||
let (doc, opts) = (fill_job.doc, fill_job.opts);
|
||||
fill_completed.send(crate::render_worker::Done { key: fill_job.key, doc, opts, img, ms: 5.0, moved: 0 }).unwrap();
|
||||
fill_completed.send(crate::render_worker::Done { key: fill_job.key, doc, opts, img, ms: 5.0, moved: 0, reused: false }).unwrap();
|
||||
let (meshes, pixels) = frame(&mut app);
|
||||
assert!(fill_jobs.try_recv().is_err(), "the fill covers the canvas");
|
||||
assert_eq!(app.canvas.key.as_ref(), Some(&old), "the last frame is still shown");
|
||||
@@ -2630,11 +2631,11 @@ fn pan_fills_the_area_the_last_frame_leaves() {
|
||||
app.view_mut().unwrap().center = Point::new(250.0, 150.0);
|
||||
// That fill finishes after the worker's frame: it is taken and not kept.
|
||||
let img = app.canvas.renderer.render(&next.doc, next.w, next.h, next.view, &next.opts);
|
||||
fill_completed.send(crate::render_worker::Done { key: next.key, doc: next.doc, opts: next.opts, img, ms: 5.0, moved: 0 }).unwrap();
|
||||
fill_completed.send(crate::render_worker::Done { key: next.key, doc: next.doc, opts: next.opts, img, ms: 5.0, moved: 0, reused: false }).unwrap();
|
||||
// The worker's frame arrives: it covers the canvas, and the fill is no longer drawn or kept.
|
||||
let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||||
let expected = job.key.clone();
|
||||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 50.0, moved: 0 }).unwrap();
|
||||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 50.0, moved: 0, reused: false }).unwrap();
|
||||
let (meshes, _) = frame(&mut app);
|
||||
assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame is shown");
|
||||
assert!(app.canvas.fill.is_none() && meshes.iter().all(|m| m.texture_id != fill_tex), "the fill is dropped");
|
||||
@@ -9055,6 +9056,7 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra
|
||||
img: img5,
|
||||
ms: 2.5,
|
||||
moved: 0,
|
||||
reused: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -9077,6 +9079,7 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra
|
||||
img: img3,
|
||||
ms: 2.5,
|
||||
moved: 0,
|
||||
reused: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -9098,6 +9101,7 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra
|
||||
img: img8,
|
||||
ms: 2.5,
|
||||
moved: 0,
|
||||
reused: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -9119,6 +9123,7 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra
|
||||
img: img10,
|
||||
ms: 2.5,
|
||||
moved: 0,
|
||||
reused: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -9206,7 +9211,493 @@ fn the_contour_bar_sets_corners_and_bakes() {
|
||||
/// A frame the render worker finished for `job`: white pixels of the job's size.
|
||||
fn finished(job: &crate::render_worker::Job) -> crate::render_worker::Done {
|
||||
let img = vectorcraft_render::Rendered { width: job.w, height: job.h, pixels: vec![255; job.w as usize * job.h as usize * 4] };
|
||||
crate::render_worker::Done { key: job.key.clone(), doc: job.doc.clone(), opts: job.opts.clone(), img, ms: 40.0, moved: 0 }
|
||||
crate::render_worker::Done { key: job.key.clone(), doc: job.doc.clone(), opts: job.opts.clone(), img, ms: 40.0, moved: 0, reused: false }
|
||||
}
|
||||
|
||||
/// A job for the render worker to pan over a document of `doc`: a view at 100% moved by `(dx, dy)`
|
||||
/// device pixels, in a 256×128 frame.
|
||||
fn pan_job(doc: &std::sync::Arc<vectorcraft_doc::Document>, key: &CacheKey, (dx, dy): (f64, f64)) -> crate::render_worker::Job {
|
||||
crate::render_worker::Job {
|
||||
key: CacheKey { cx: 128.0 - dx, cy: 64.0 - dy, w: 256, h: 128, ..key.clone() },
|
||||
doc: doc.clone(),
|
||||
w: 256,
|
||||
h: 128,
|
||||
view: Affine::translate((dx, dy)),
|
||||
opts: vectorcraft_render::RenderOptions::default(),
|
||||
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
|
||||
may_copy: true,
|
||||
may_keep: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The document and the canvas key the render worker tests start from: rectangles on whole pixels.
|
||||
fn pan_doc() -> (std::sync::Arc<vectorcraft_doc::Document>, CacheKey) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 256, "height": 128})).unwrap();
|
||||
for (x, color) in [(16, "#c02040"), (96, "#2040c0"), (176, "#20a040")] {
|
||||
let id = app.run("shape.rectangle", json!({"x": x, "y": 16, "width": 64, "height": 96})).unwrap()["id"].clone();
|
||||
app.run("paint.setFill", json!({"ids": [id], "color": color})).unwrap();
|
||||
}
|
||||
app.canvas.worker_started = true;
|
||||
shapes(&mut app, &egui::Context::default());
|
||||
let key = app.canvas.key.clone().expect("a frame was rendered");
|
||||
(app.session.active().unwrap().doc.clone(), CacheKey { zoom: 1.0, rot: 0.0, ppp: 1.0, pixel: None, ..key })
|
||||
}
|
||||
|
||||
/// A pan by whole device pixels takes the pixels that stay on screen from the last frame: a mark
|
||||
/// put in that frame moves with the pan, and the frame matches a whole render where the mark is not.
|
||||
#[test]
|
||||
fn a_whole_pixel_pan_copies_the_pixels_that_stay_on_screen() {
|
||||
let (doc, key) = pan_doc();
|
||||
for pan in [(10.0, 0.0), (-10.0, 0.0), (0.0, 7.0), (0.0, -7.0), (-13.0, 5.0)] {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
let first = render_job(&mut r, &mut kept, &pan_job(&doc, &key, (0.0, 0.0)));
|
||||
assert!(!first.reused, "the first frame renders whole");
|
||||
let k = kept.as_mut().and_then(|k| k.pixels.as_mut()).expect("the frame holds its pixels");
|
||||
// The middle of the frame, which stays on screen.
|
||||
k[(64 * 256 + 128) * 4..(64 * 256 + 128) * 4 + 4].copy_from_slice(&[1, 2, 3, 4]);
|
||||
let job = pan_job(&doc, &key, pan);
|
||||
let frame = render_job(&mut r, &mut kept, &job);
|
||||
assert!(frame.reused, "{pan:?}");
|
||||
let at = ((64 + pan.1 as i64) * 256 + 128 + pan.0 as i64) as usize * 4;
|
||||
assert_eq!(frame.img.pixels[at..at + 4], [1, 2, 3, 4], "{pan:?}: the marked pixel moved with the art");
|
||||
let whole = vectorcraft_render::Renderer::new().render(&doc, 256, 128, job.view, &job.opts);
|
||||
let mut img = frame.img.pixels;
|
||||
img[at..at + 4].copy_from_slice(&whole.pixels[at..at + 4]);
|
||||
assert_eq!(img, whole.pixels, "{pan:?}: the same picture as a whole render");
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame does not copy from the last one when the view moves by part of a pixel or over half the
|
||||
/// frame, when copying is not allowed, or when a listed key field, render option, zoom, rotation,
|
||||
/// canvas size or device scale differs. Each such frame has the job's size. The session test
|
||||
/// compares such frames with a fresh render pixel by pixel.
|
||||
#[test]
|
||||
fn a_frame_renders_whole_when_anything_but_a_whole_pixel_pan_changed() {
|
||||
let (doc, key) = pan_doc();
|
||||
let other_opts = |f: &dyn Fn(&mut vectorcraft_render::RenderOptions)| {
|
||||
let mut job = pan_job(&doc, &key, (10.0, 0.0));
|
||||
f(&mut job.opts);
|
||||
job
|
||||
};
|
||||
let other_key = |f: &dyn Fn(&mut CacheKey)| {
|
||||
let mut job = pan_job(&doc, &key, (10.0, 0.0));
|
||||
f(&mut job.key);
|
||||
job
|
||||
};
|
||||
let mut other_doc = (*doc).clone();
|
||||
other_doc.layers.clear();
|
||||
let scaled = {
|
||||
let mut job = pan_job(&doc, &key, (10.0, 0.0));
|
||||
job.key.zoom = 2.0;
|
||||
job.view = Affine::translate((10.0, 0.0)) * Affine::scale(2.0);
|
||||
job
|
||||
};
|
||||
let turned = {
|
||||
let mut job = pan_job(&doc, &key, (10.0, 0.0));
|
||||
job.key.rot = 30.0;
|
||||
job.view = Affine::translate((10.0, 0.0)) * Affine::rotate(30f64.to_radians());
|
||||
job
|
||||
};
|
||||
let resized = {
|
||||
let mut job = pan_job(&doc, &key, (10.0, 0.0));
|
||||
(job.key.w, job.w) = (200, 200);
|
||||
job
|
||||
};
|
||||
let denser = other_key(&|k| k.ppp = 2.0);
|
||||
let not_allowed = crate::render_worker::Job { may_copy: false, ..pan_job(&doc, &key, (10.0, 0.0)) };
|
||||
let cases: Vec<(&str, crate::render_worker::Job)> = vec![
|
||||
("part of a pixel across", pan_job(&doc, &key, (10.5, 0.0))),
|
||||
("part of a pixel down", pan_job(&doc, &key, (0.0, 0.25))),
|
||||
("too far to keep half", pan_job(&doc, &key, (250.0, 0.0))),
|
||||
("zoom", scaled),
|
||||
("rotation", turned),
|
||||
("canvas size", resized),
|
||||
("device scale", denser),
|
||||
("not allowed", not_allowed),
|
||||
("document", other_key(&|k| k.doc += 1)),
|
||||
("revision", crate::render_worker::Job { doc: std::sync::Arc::new(other_doc), ..other_key(&|k| k.revision += 1) }),
|
||||
("interaction", other_key(&|k| k.interacting = true)),
|
||||
("outline key", other_key(&|k| k.outline = true)),
|
||||
("Pixel Preview", other_key(&|k| k.pixel = Some([0, 0, 256, 128]))),
|
||||
("placed bitmaps", other_key(&|k| k.placed += 1)),
|
||||
("outline", other_opts(&|o| o.outline = true)),
|
||||
("proof", other_opts(&|o| o.proof = Some(Default::default()))),
|
||||
("overprint preview", other_opts(&|o| o.overprint_preview = true)),
|
||||
("rich black", other_opts(&|o| o.rich_black = true)),
|
||||
("trim", other_opts(&|o| o.trim = true)),
|
||||
("anti-alias", other_opts(&|o| o.anti_alias = vectorcraft_render::AntiAlias::None)),
|
||||
("smooth images", other_opts(&|o| o.smooth_images = false)),
|
||||
("opacity mask view", other_opts(&|o| o.mask_view = Some(vectorcraft_doc::NodeId(1)))),
|
||||
];
|
||||
let mut copied = vec![];
|
||||
for (name, job) in cases {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
render_job(&mut r, &mut kept, &pan_job(&doc, &key, (0.0, 0.0)));
|
||||
let frame = render_job(&mut r, &mut kept, &job);
|
||||
if frame.reused {
|
||||
copied.push(name);
|
||||
}
|
||||
let whole = vectorcraft_render::Renderer::new().render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||||
assert_eq!((frame.img.width, frame.img.height), (whole.width, whole.height), "{name}");
|
||||
}
|
||||
assert!(copied.is_empty(), "copied from the last frame: {copied:?}");
|
||||
}
|
||||
|
||||
/// The worker keeps a frame only when a later one can copy from it: a frame that does not allow
|
||||
/// keeping, a frame over the limit and a frame that drew a raster from before its object moved are
|
||||
/// not kept. A frame that may not copy can still be kept.
|
||||
#[test]
|
||||
fn the_render_worker_keeps_one_frame_up_to_its_limit() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 256, "height": 128})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 16, "y": 16, "width": 64, "height": 64})).unwrap()["id"].clone();
|
||||
app.run("effect.apply", json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap();
|
||||
let after = app.session.active().unwrap().doc.clone();
|
||||
let (_, key) = pan_doc();
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
render_job(&mut r, &mut kept, &pan_job(&before, &key, (0.0, 0.0)));
|
||||
assert!(kept.is_some(), "kept");
|
||||
let mut moved = pan_job(&after, &CacheKey { revision: key.revision + 1, interacting: true, ..key.clone() }, (0.0, 0.0));
|
||||
moved.opts.interacting = true;
|
||||
let frame = render_job(&mut r, &mut kept, &moved);
|
||||
assert!(frame.moved > 0, "the shadow drew from the raster made before the move");
|
||||
let mut wrong = vec![];
|
||||
if kept.is_some() {
|
||||
wrong.push("a frame that drew a raster from before its object moved");
|
||||
}
|
||||
|
||||
let (doc, key) = pan_doc();
|
||||
render_job(&mut r, &mut kept, &pan_job(&doc, &key, (0.0, 0.0)));
|
||||
assert!(kept.is_some(), "kept");
|
||||
let off = crate::render_worker::Job { may_keep: false, ..pan_job(&doc, &key, (0.0, 0.0)) };
|
||||
render_job(&mut r, &mut kept, &off);
|
||||
if kept.is_some() {
|
||||
wrong.push("a frame that does not allow keeping");
|
||||
}
|
||||
let whole = crate::render_worker::Job { may_copy: false, ..pan_job(&doc, &key, (0.0, 0.0)) };
|
||||
render_job(&mut r, &mut kept, &whole);
|
||||
assert!(kept.is_some(), "a frame that may not copy is kept");
|
||||
let mut big = pan_job(&doc, &key, (0.0, 0.0));
|
||||
(big.key.w, big.key.h, big.w, big.h) = (4097, 4097, 4097, 4097);
|
||||
render_job(&mut r, &mut kept, &big);
|
||||
if kept.is_some() {
|
||||
wrong.push("a frame over 64 MiB");
|
||||
}
|
||||
assert!(wrong.is_empty(), "kept: {wrong:?}");
|
||||
}
|
||||
|
||||
/// Through a pan, an edit, an undo, a zoom, a resize, a view mode and a color setting, each frame the
|
||||
/// worker renders whole matches a fresh render, and each copied frame is within one level of it.
|
||||
#[test]
|
||||
fn worker_frames_match_a_fresh_render_through_a_session() {
|
||||
let (doc, key) = pan_doc();
|
||||
let mut edited = (*doc).clone();
|
||||
edited.layers.reverse();
|
||||
let edited = std::sync::Arc::new(edited);
|
||||
let pan = |at: (f64, f64)| pan_job(&doc, &key, at);
|
||||
let changed = |f: &dyn Fn(&mut crate::render_worker::Job)| {
|
||||
let mut job = pan((4.5, -4.0));
|
||||
f(&mut job);
|
||||
job
|
||||
};
|
||||
let steps: Vec<(&str, crate::render_worker::Job, bool)> = vec![
|
||||
("still", pan((0.0, 0.0)), false),
|
||||
("pan", pan((10.0, 0.0)), true),
|
||||
("pan back", pan((3.0, -4.0)), true),
|
||||
("part of a pixel", pan((3.5, -4.0)), false),
|
||||
("whole again", pan((4.5, -4.0)), true),
|
||||
("settled", changed(&|j| j.may_copy = false), false),
|
||||
("pan after settling", pan((5.5, -4.0)), true),
|
||||
("back before the edit", pan((4.5, -4.0)), true),
|
||||
(
|
||||
"edit",
|
||||
changed(&|j| {
|
||||
j.doc = edited.clone();
|
||||
j.key.revision += 1;
|
||||
}),
|
||||
false,
|
||||
),
|
||||
("undo", pan((4.5, -4.0)), false),
|
||||
(
|
||||
"zoom",
|
||||
changed(&|j| {
|
||||
j.key.zoom = 2.0;
|
||||
j.view = Affine::translate((4.5, -4.0)) * Affine::scale(2.0);
|
||||
}),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"resize",
|
||||
changed(&|j| {
|
||||
(j.key.w, j.key.h, j.w, j.h) = (200, 100, 200, 100);
|
||||
}),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"outline",
|
||||
changed(&|j| {
|
||||
j.key.outline = true;
|
||||
j.opts.outline = true;
|
||||
}),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"rich black",
|
||||
changed(&|j| {
|
||||
j.key.rich_black = true;
|
||||
j.opts.rich_black = true;
|
||||
}),
|
||||
false,
|
||||
),
|
||||
("setting back", pan((4.5, -4.0)), false),
|
||||
("first pan after the setting", pan((5.5, -4.0)), false),
|
||||
("next pan", pan((6.5, -4.0)), true),
|
||||
];
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
for (name, job, copies) in steps {
|
||||
let frame = render_job(&mut r, &mut kept, &job);
|
||||
assert_eq!(frame.reused, copies, "{name}");
|
||||
let whole = vectorcraft_render::Renderer::new().render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||||
if copies {
|
||||
let off = frame.img.pixels.iter().zip(&whole.pixels).map(|(a, b)| a.abs_diff(*b)).max();
|
||||
assert!(off.is_some_and(|d| d <= 1), "{name}: {off:?}");
|
||||
} else {
|
||||
assert_eq!(frame.img.pixels, whole.pixels, "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pan over art the frame's size decides stays within one level of a fresh render of the frame,
|
||||
/// because it renders whole: a full-page raster effect whose raster a part of the frame would make
|
||||
/// at another scale, and a group drawn offscreen across the exposed part's edge with an Inner Glow
|
||||
/// child inside the part, whose filter layer starts at another pixel in the part. The worker does
|
||||
/// not keep such a frame for the pan to copy from.
|
||||
#[test]
|
||||
fn a_pan_over_art_the_frame_size_decides_renders_whole() {
|
||||
let (_, key) = pan_doc();
|
||||
let effect = |effect: &str, params: serde_json::Value| {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 595, "height": 842})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 595, "height": 842})).unwrap()["id"].clone();
|
||||
app.run("paint.setFill", json!({"ids": [id], "color": "#1a4de6"})).unwrap();
|
||||
app.run("effect.apply", json!({"effect": effect, "ids": [id], "params": params})).unwrap();
|
||||
app.session.active().unwrap().doc.clone()
|
||||
};
|
||||
let group_glow = {
|
||||
use vectorcraft_doc::Appearance;
|
||||
use vectorcraft_doc::color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_geom::{Rect, shapes::rectangle};
|
||||
let mut d = vectorcraft_doc::Document::new(1600.0, 1200.0);
|
||||
let basic = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0);
|
||||
let wide = Node::path(d.alloc_id(), rectangle(Rect::new(100.0, 100.0, 1590.0, 900.0)), basic(Color::rgb(0.9, 0.8, 0.3)));
|
||||
let mut child = Node::path(d.alloc_id(), rectangle(Rect::new(1453.0, 200.0, 1583.0, 500.0)), basic(Color::rgb(0.1, 0.3, 0.9)));
|
||||
child.appearance.effects = vec![vectorcraft_doc::Effect { id: "stylize.innerGlow".into(), params: json!({"blur": 40}), visible: true }];
|
||||
child.blend = BlendMode::Multiply;
|
||||
let mut group = Node::group(d.alloc_id(), vec![std::sync::Arc::new(wide), std::sync::Arc::new(child)]);
|
||||
group.isolate = true;
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, group).unwrap();
|
||||
std::sync::Arc::new(d)
|
||||
};
|
||||
let cases = [
|
||||
("Gaussian Blur 500 pt", effect("blur.gaussian", json!({"radius": 500})), (-200.0, -200.0), 2.0, (1440, 870)),
|
||||
("Craquelure", effect("texture.craquelure", json!({})), (-200.0, -200.0), 10.0, (3840, 2160)),
|
||||
("Inner Glow in a group drawn offscreen", group_glow, (0.0, 0.0), 1.0, (1600, 1200)),
|
||||
];
|
||||
for (name, doc, (x, y), zoom, (w, h)) in cases {
|
||||
let job = |dx: f64| {
|
||||
let mut job = pan_job(&doc, &key, (dx, 0.0));
|
||||
(job.key.w, job.key.h, job.w, job.h, job.key.zoom) = (w, h, w, h, zoom);
|
||||
job.view = Affine::translate((x + dx, y)) * Affine::scale(zoom);
|
||||
job
|
||||
};
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
render_job(&mut r, &mut kept, &job(0.0));
|
||||
let pan = job(-40.0);
|
||||
let frame = render_job(&mut r, &mut kept, &pan);
|
||||
let whole = vectorcraft_render::Renderer::new().render(&pan.doc, pan.w, pan.h, pan.view, &pan.opts);
|
||||
let off = frame.img.pixels.iter().zip(&whole.pixels).map(|(a, b)| a.abs_diff(*b)).max();
|
||||
assert!(off.is_some_and(|d| d <= 1), "{name}: a channel differs by {off:?}");
|
||||
assert!(!frame.reused, "{name}: the pan renders whole");
|
||||
assert!(kept.is_none(), "{name}: the frame is not kept");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `test`, whose canvas key holds the global placed-document bitmap clock, which tests on
|
||||
/// other threads move: a frame whose key changed by more than the view renders whole. An attempt
|
||||
/// that fails while the clock moved runs again; one that fails while it stood still is a real
|
||||
/// failure.
|
||||
fn while_the_placed_clock_stands_still(test: fn()) {
|
||||
for _ in 0..20 {
|
||||
let clock = vectorcraft_render::placed_document::generation();
|
||||
match std::panic::catch_unwind(test) {
|
||||
Ok(()) => return,
|
||||
Err(_) if vectorcraft_render::placed_document::generation() != clock => {}
|
||||
Err(e) => std::panic::resume_unwind(e),
|
||||
}
|
||||
}
|
||||
test();
|
||||
}
|
||||
|
||||
/// The canvas asks the worker for a frame that may copy pixels when the view moves, keeps the whole
|
||||
/// frame's time, and renders the frame whole once the view has been still for STILL_SECS, not
|
||||
/// before and only once.
|
||||
#[test]
|
||||
fn a_frame_with_copied_pixels_renders_whole_once_the_view_stops() {
|
||||
while_the_placed_clock_stands_still(frame_with_copied_pixels_renders_whole_once_the_view_stops);
|
||||
}
|
||||
|
||||
fn frame_with_copied_pixels_renders_whole_once_the_view_stops() {
|
||||
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;
|
||||
let (worker, jobs, done_tx) = crate::render_worker::Worker::test_channel();
|
||||
app.canvas.worker = Some(worker);
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut VectorcraftApp, t: f64| {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(t), ..Default::default() };
|
||||
ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear();
|
||||
};
|
||||
frame(&mut app, 1.0);
|
||||
done_tx.send(finished(&jobs.try_recv().expect("the first frame"))).unwrap();
|
||||
frame(&mut app, 1.0);
|
||||
assert_eq!(app.canvas.last_ms, 40.0);
|
||||
assert!(app.canvas.reused.is_none());
|
||||
|
||||
app.view_mut().unwrap().center.x += 5.0;
|
||||
frame(&mut app, 2.0);
|
||||
let pan = jobs.try_recv().expect("the pan's frame goes to the worker");
|
||||
assert!(pan.may_copy, "a new view may copy");
|
||||
done_tx.send(crate::render_worker::Done { ms: 2.0, reused: true, ..finished(&pan) }).unwrap();
|
||||
frame(&mut app, 3.0);
|
||||
assert_eq!(app.canvas.reused, Some(3.0));
|
||||
assert_eq!(app.canvas.last_ms, 40.0, "a frame with copied pixels keeps the whole frame's time");
|
||||
assert_eq!(app.perf.render_ms, 2.0);
|
||||
|
||||
frame(&mut app, 3.0 + STILL_SECS / 2.0);
|
||||
assert!(jobs.try_recv().is_err(), "the view has not been still long enough");
|
||||
frame(&mut app, 3.0 + STILL_SECS);
|
||||
let settle = jobs.try_recv().expect("the frame renders whole");
|
||||
assert!(!settle.may_copy, "the settling frame copies nothing");
|
||||
assert_eq!(settle.key, pan.key);
|
||||
frame(&mut app, 3.0 + STILL_SECS * 2.0);
|
||||
assert!(jobs.try_recv().is_err(), "queued once");
|
||||
done_tx.send(finished(&settle)).unwrap();
|
||||
frame(&mut app, 4.0);
|
||||
assert!(app.canvas.reused.is_none());
|
||||
frame(&mut app, 5.0);
|
||||
assert!(jobs.try_recv().is_err(), "nothing to render after it");
|
||||
}
|
||||
|
||||
/// The frame that renders whole after a pan stops copies nothing and stays on the worker, so the
|
||||
/// first step of the next pan copies from it. The jobs are the ones the canvas asks for.
|
||||
#[test]
|
||||
fn a_pan_after_the_settling_frame_copies() {
|
||||
while_the_placed_clock_stands_still(pan_after_the_settling_frame_copies);
|
||||
}
|
||||
|
||||
fn pan_after_the_settling_frame_copies() {
|
||||
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;
|
||||
let (worker, jobs, done_tx) = crate::render_worker::Worker::test_channel();
|
||||
app.canvas.worker = Some(worker);
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut VectorcraftApp, t: f64| {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(t), ..Default::default() };
|
||||
ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear();
|
||||
};
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
let mut render = |name: &str, copies: bool| {
|
||||
let job = jobs.try_recv().unwrap_or_else(|_| panic!("{name}: the canvas asks for a frame"));
|
||||
let f = render_job(&mut r, &mut kept, &job);
|
||||
assert_eq!(f.reused, copies, "{name}");
|
||||
let done = crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img: f.img, ms: 40.0, moved: f.moved, reused: f.reused };
|
||||
done_tx.send(done).unwrap();
|
||||
};
|
||||
frame(&mut app, 1.0);
|
||||
render("the first frame", false);
|
||||
frame(&mut app, 1.0);
|
||||
// At 100% the view moves by whole device pixels.
|
||||
app.view_mut().unwrap().zoom = 1.0;
|
||||
frame(&mut app, 1.5);
|
||||
render("the zoom", false);
|
||||
frame(&mut app, 1.5);
|
||||
|
||||
app.view_mut().unwrap().center.x += 5.0;
|
||||
frame(&mut app, 1.75);
|
||||
render("the first pan after the zoom", false);
|
||||
frame(&mut app, 1.75);
|
||||
|
||||
app.view_mut().unwrap().center.x += 5.0;
|
||||
frame(&mut app, 2.0);
|
||||
render("the pan", true);
|
||||
frame(&mut app, 3.0);
|
||||
frame(&mut app, 3.0 + STILL_SECS);
|
||||
render("the settling frame", false);
|
||||
frame(&mut app, 4.0);
|
||||
|
||||
app.view_mut().unwrap().center.x += 5.0;
|
||||
frame(&mut app, 5.0);
|
||||
render("the next pan", true);
|
||||
}
|
||||
|
||||
/// A frame after a zoom or an edit holds no pixels, and the frame before it lets its pixels go. The
|
||||
/// first pan step after it renders whole and holds its pixels, and the next step copies them.
|
||||
#[test]
|
||||
fn a_frame_after_a_zoom_or_an_edit_holds_no_pixels() {
|
||||
let (doc, key) = pan_doc();
|
||||
let mut edited = (*doc).clone();
|
||||
edited.layers.reverse();
|
||||
let edited = std::sync::Arc::new(edited);
|
||||
let zoomed = |key: &CacheKey, at: f64, zoom: f64| {
|
||||
let mut job = pan_job(&doc, key, (at, 0.0));
|
||||
job.key.zoom = zoom;
|
||||
job.view = Affine::translate((at, 0.0)) * Affine::scale(zoom);
|
||||
job
|
||||
};
|
||||
let revised = CacheKey { revision: key.revision + 1, ..key.clone() };
|
||||
let steps: Vec<(&str, crate::render_worker::Job, bool, bool)> = vec![
|
||||
("the first frame", pan_job(&doc, &key, (0.0, 0.0)), false, true),
|
||||
("a zoom", zoomed(&key, 0.0, 2.0), false, false),
|
||||
("another zoom", zoomed(&key, 0.0, 1.5), false, false),
|
||||
("the first pan after the zoom", zoomed(&key, 10.0, 1.5), false, true),
|
||||
("the next pan", zoomed(&key, 20.0, 1.5), true, true),
|
||||
("an edit", crate::render_worker::Job { doc: edited.clone(), ..pan_job(&doc, &revised, (0.0, 0.0)) }, false, false),
|
||||
("the first pan after the edit", crate::render_worker::Job { doc: edited.clone(), ..pan_job(&doc, &revised, (10.0, 0.0)) }, false, true),
|
||||
("the next pan after the edit", crate::render_worker::Job { doc: edited.clone(), ..pan_job(&doc, &revised, (20.0, 0.0)) }, true, true),
|
||||
];
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut kept = None;
|
||||
for (name, job, copies, holds) in steps {
|
||||
assert_eq!(render_job(&mut r, &mut kept, &job).reused, copies, "{name}: copied");
|
||||
assert_eq!(kept.as_ref().is_some_and(|k| k.pixels.is_some()), holds, "{name}: holds its pixels");
|
||||
}
|
||||
}
|
||||
|
||||
/// The worker thread itself copies pixels on a pan and reports it.
|
||||
#[test]
|
||||
fn the_render_worker_thread_copies_a_pan() {
|
||||
let (doc, key) = pan_doc();
|
||||
let mut worker = crate::render_worker::Worker::spawn(egui::Context::default()).expect("a worker");
|
||||
// Blocks until the worker sends its frame. A worker thread that stopped gives None.
|
||||
let wait = |worker: &mut crate::render_worker::Worker| worker.wait(std::time::Duration::MAX).expect("the worker sends the frame");
|
||||
worker.submit(pan_job(&doc, &key, (0.0, 0.0)));
|
||||
assert!(!wait(&mut worker).reused);
|
||||
worker.submit(pan_job(&doc, &key, (10.0, 0.0)));
|
||||
assert!(wait(&mut worker).reused);
|
||||
}
|
||||
|
||||
/// #4155: a document's first frame renders on the render worker, not on the UI thread, where a
|
||||
|
||||
@@ -24,6 +24,12 @@ pub struct Job {
|
||||
/// The automation roots in force where the job was made: the render reads linked files under
|
||||
/// them, as it would on that thread.
|
||||
pub scope: ScopeHandle,
|
||||
/// The render may copy pixels from the worker's last frame ([`crate::canvas::render_job`]).
|
||||
/// Off: it renders the whole frame.
|
||||
pub may_copy: bool,
|
||||
/// The worker may keep the frame for its next frame to copy from
|
||||
/// ([`crate::canvas::render_job`]). Off: the worker holds no frame after this one.
|
||||
pub may_keep: bool,
|
||||
}
|
||||
|
||||
pub struct Done {
|
||||
@@ -35,6 +41,9 @@ pub struct Done {
|
||||
pub ms: f64,
|
||||
/// [`vectorcraft_render::FrameStats::moved`] of the frame.
|
||||
pub moved: usize,
|
||||
/// The frame holds pixels copied from the worker's last frame ([`crate::canvas::render_job`]),
|
||||
/// and `ms` is the time of the copy and of the parts rendered.
|
||||
pub reused: bool,
|
||||
}
|
||||
|
||||
pub struct Worker {
|
||||
@@ -55,15 +64,17 @@ impl Worker {
|
||||
.name("vectorcraft-render".into())
|
||||
.spawn(move || {
|
||||
let mut r = Renderer::new();
|
||||
let mut kept = None;
|
||||
while let Ok(mut job) = jobs.recv() {
|
||||
// Skip stale jobs: only the newest matters.
|
||||
while let Ok(newer) = jobs.try_recv() {
|
||||
job = newer;
|
||||
}
|
||||
let t0 = crate::now_ms();
|
||||
let img = job.scope.clone().run(|| r.render(&job.doc, job.w, job.h, job.view, &job.opts));
|
||||
if done_tx.send(Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: crate::now_ms() - t0, moved: r.stats.moved }).is_err()
|
||||
{
|
||||
let frame = job.scope.clone().run(|| crate::canvas::render_job(&mut r, &mut kept, &job));
|
||||
let ms = crate::now_ms() - t0;
|
||||
let done = Done { key: job.key, doc: job.doc, opts: job.opts, img: frame.img, ms, moved: frame.moved, reused: frame.reused };
|
||||
if done_tx.send(done).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
|
||||
@@ -1416,6 +1416,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Zooming in on large images (#4155):** when an image is drawn from pixels (its mip, or its full pixels) more than 1,024 pixels on a side, and their colours are made again for the frame (separated into a CMYK document's ink planes, colorized, or shown through Separations Preview's plates), only the 1,024-pixel tiles in view are made, each kept with the images under its own key, and the tiles in view are drawn as one pixmap so their edges don't show. The colours are made pixel by pixel, so the result is the same as making them for the whole image (`a_tiled_image_draws_what_the_whole_image_draws`, across a tile edge); `a_zoomed_in_image_is_separated_only_where_it_shows` checks the tiles out of view are not made. A CMYK image painting its own inks is still made whole. On the file of #4155 (an 11,339 × 12,424 photo, M3 Pro, one run each), the sharp frame after zooming in to 50%, 100% and 200% went from 9.0, 8.6 and 8.7 s to 0.78, 0.05 and 0.05 s, and the app's memory after zooming from 4.4 GB to 2.6 GB; the screens at 100% match (p99 difference 0).
|
||||
- **Large images in CMYK documents (#4155):** the canvas draws a reduced image from a mip (halved while still at least as fine as the screen) and, in a CMYK document, separates that mip into its ink planes, colorizes it or shows it through Separations Preview's plates, instead of doing that to the full pixels and shrinking the result. A CMYK image's own inks are halved with its pixels, so it still paints them. Exports draw without mips and are unchanged. On the file of #4155 (one 11,339 × 12,424 image), measured on an M3 Pro under load, one run each: first frame 9.2 s → 1.6 s, opening to the first frame 11.7 s → 3.9 s, the sharp frame after a pan 3.9 s → 8 ms and after a zoom 3.5 s → 0.16 s; the fitted view's pixels differ by at most 5 levels (p99 2). `a_reduced_image_is_separated_from_its_mip_not_its_full_pixels`, `a_reduced_cmyk_image_paints_its_own_inks` and `a_reduced_image_on_the_ink_planes_shows_its_average` (`crates/render/src/tests/memory.rs`) cover the order, the inks and the colour: 1-pixel black and white lines show as the mid grey they average to, where separating first showed them 44 levels darker.
|
||||
- **Panning over a cropped effect raster:** when a pan makes a shadow, glow or filtered content raster cropped to the view again at the same zoom and rotation, the renderer makes it over a margin around the view too (up to half the view's longer side, within 8.4 million pixels), and later pans that keep the view inside that margin draw the raster instead of making it again. Zooming and the first frame at a zoom make the raster without the margin. The canvas pans by whole device pixels: the wheel, a trackpad, a touch screen and the scroll bars add the part of a pixel left over to the next scroll of the same view, and Hand tool and scroll bar thumb drags round the distance dragged. A Drop Shadow or Outer Glow raster holds the shadow or glow of group members outside the view, so a pan that shows a member draws its shadow or glow from the raster. `pans_within_a_cropped_rasters_margin_draw_it_kept` covers Drop Shadow, Gaussian Blur and Feather on a large image at 100%. `a_groups_shadow_raster_holds_the_members_outside_the_view` covers a group's members, `fractional_scrolls_moved_in_whole_pixels_draw_a_cropped_raster_kept` covers scrolls by fractions of a pixel, and `scrolls_and_pans_move_the_view_by_whole_device_pixels` covers the canvas.
|
||||
- **Panning a heavy document:** when the render worker draws a frame for a view moved by whole device pixels at the same zoom and rotation, it copies the pixels that stay on screen from its last frame and renders only the parts the pan exposes, grown to whole eighths of the frame, as long as those parts are at most half of it. A pan renders whole when the last frame or a part it exposes draws art in a way that depends on the frame's size, such as an effect raster cropped to the view or made at a lower resolution. A copied frame can differ from a whole render of the view (by at most one level in the tests), so the canvas renders the frame whole once it has been shown for 0.2 s with the view unmoved. The worker keeps one extra frame, up to 16 million pixels (64 MiB). A frame after a zoom or an edit holds no pixels, so the first pan step after one renders whole. Scores and estimates unchanged. `a_whole_pixel_pan_copies_the_pixels_that_stay_on_screen`, `a_frame_renders_whole_when_anything_but_a_whole_pixel_pan_changed`, `worker_frames_match_a_fresh_render_through_a_session`, `a_pan_over_art_the_frame_size_decides_renders_whole`, `a_frame_after_a_zoom_or_an_edit_holds_no_pixels` and `a_frame_with_copied_pixels_renders_whole_once_the_view_stops` (`crates/ui-egui/src/canvas/tests.rs`) cover the copy, the frames that render whole, the frames that hold no pixels and the frame after the view stops.
|
||||
|
||||
- **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user