Canvas: a document's first frame renders on the render worker (#4155)

A document's first frame (opened, or its tab chosen) rendered on the UI thread: only a
document with a frame already shown sent its frames to the worker. A heavy file froze the
window until it was drawn, 8.7 s for the file of #4155 and 1.4 s for a 50,000-path SVG.

The first frame now goes to the worker too, and the window shows the document's paper and
artboards until it lands. The UI thread waits for it up to FIRST_FRAME_GRACE (16 ms) when it
is queued (`Worker::wait`; `submit` says whether it queued), so a light document's art still
shows in the same frame. The longest UI-thread stall opening those files is now 64 ms and
62 ms (M3 Pro, one run each). The web build, which has no worker, is unchanged.
This commit is contained in:
Simon Hull
2026-11-06 04:55:02 +07:00
committed by bflatastic
parent d04585f6d2
commit af31e1464c
5 changed files with 148 additions and 35 deletions
+49 -26
View File
@@ -550,25 +550,28 @@ fn poll_worker(app: &mut VectorcraftApp, ui: &Ui, key_doc: usize) {
app.canvas.fill_worker = crate::render_worker::Worker::spawn(ui.ctx().clone());
}
}
// Upload finished background renders whenever they are not older than what is currently
// displayed on screen. This prevents canvas freezes during continuous edits (drags, arrow
// nudges, MCP command bursts) while ensuring obsolete out-of-order frames are dropped.
if let Some(done) = app.canvas.worker.as_mut().and_then(|w| w.poll())
&& done.key.doc == key_doc
{
let not_older = app.canvas.key.as_ref().filter(|k| k.doc == done.key.doc).is_none_or(|k| done.key.revision >= k.revision);
if not_older {
upload(app, ui.ctx(), done.img, done.key.pixel.is_some());
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;
app.canvas.moved = done.moved > 0;
app.canvas.from_worker = true;
}
if let Some(done) = app.canvas.worker.as_mut().and_then(|w| w.poll()) {
show_worker_frame(app, ui.ctx(), done, key_doc);
}
}
/// Upload frame `done` from the render worker when it is of document `key_doc` and not older than
/// the frame shown. This prevents canvas freezes during continuous edits (drags, arrow nudges, MCP
/// command bursts) while ensuring obsolete out-of-order frames are dropped.
fn show_worker_frame(app: &mut VectorcraftApp, ctx: &egui::Context, done: crate::render_worker::Done, key_doc: usize) {
let not_older = app.canvas.key.as_ref().filter(|k| k.doc == done.key.doc).is_none_or(|k| done.key.revision >= k.revision);
if done.key.doc != key_doc || !not_older {
return;
}
upload(app, ctx, done.img, done.key.pixel.is_some());
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;
app.canvas.moved = done.moved > 0;
app.canvas.from_worker = true;
}
/// The size and view matrix of the frame to render: the pixel region in Pixel Preview, else the whole canvas.
fn frame_view(pixel: Option<[i64; 4]>, (w, h): (u32, u32), v: &View, ppp: f32) -> (u32, u32, Affine) {
match pixel {
@@ -645,16 +648,26 @@ struct Frame<'a> {
pixel: Option<[i64; 4]>,
}
/// How long the UI thread waits for a document's first frame from the render worker before it
/// shows the document's paper without the art: about a frame at 60 Hz.
pub(crate) const FIRST_FRAME_GRACE: std::time::Duration = std::time::Duration::from_millis(16);
fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
let Frame { key, doc, size: (w, h), view, opts, pixel } = frame;
// Frames that would be slow on this thread (the last one took over 8 ms) go
// 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 staying = key.interacting && app.canvas.from_worker;
let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc;
// A document's first frame (opened, or its tab chosen) renders on the worker too: how long it
// takes is not known yet, and a heavy one froze the window for seconds (#4155). Its paper and
// artboards show meanwhile.
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);
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
// rendered again, here, into the texture. `last_ms` stays the whole frame's.
@@ -671,15 +684,22 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
app.canvas.shown = Some((doc.clone(), opts.clone()));
app.perf.render_ms = now_ms() - t0;
}
(Some(worker), true, None) => worker.submit(crate::render_worker::Job {
key: key.clone(),
doc: doc.clone(),
w,
h,
view,
opts,
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
}),
(Some(worker), true, None) => {
let queued = worker.submit(crate::render_worker::Job {
key: key.clone(),
doc: doc.clone(),
w,
h,
view,
opts,
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
});
// 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.
if first && queued {
landed = worker.wait(FIRST_FRAME_GRACE);
}
}
_ => {
let t0 = now_ms();
let img = app.canvas.renderer.render(doc, w, h, view, &opts);
@@ -692,6 +712,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
app.canvas.from_worker = false;
}
}
if let Some(done) = landed {
show_worker_frame(app, ui.ctx(), done, key.doc);
}
}
/// The pasteboard, the artboards' shadows and paper, and the outline mode's white artboards, of
+68
View File
@@ -9198,3 +9198,71 @@ fn the_contour_bar_sets_corners_and_bakes() {
click_contour_bar(&mut app, &ctx, "Bake");
assert!(offsets(&app).is_empty(), "baked");
}
/// 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 }
}
/// #4155: a document's first frame renders on the render worker, not on the UI thread, where a
/// heavy one froze the window for seconds while it opened. The canvas shows the document's paper
/// meanwhile, does not queue the frame again while it renders, and shows it once it lands.
#[test]
fn a_documents_first_frame_renders_on_the_worker() {
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 raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
let frame = |app: &mut VectorcraftApp| ctx.run_ui(raw.clone(), |ui| show(app, ui)).textures_delta.clear();
frame(&mut app);
let job = jobs.try_recv().expect("the first frame is queued on the worker");
assert!(app.canvas.texture.is_none(), "not rendered on the UI thread");
frame(&mut app);
assert!(jobs.try_recv().is_err(), "not queued again while it renders");
assert!(app.canvas.texture.is_none());
done_tx.send(finished(&job)).unwrap();
frame(&mut app);
assert!(app.canvas.texture.is_some(), "shown once it lands");
assert_eq!(app.canvas.key.as_ref(), Some(&job.key));
assert!(app.canvas.from_worker);
}
/// The canvas waits for a document's first frame only briefly (`FIRST_FRAME_GRACE`): `wait`
/// returns a frame finished before or during the wait, clearing the pending job, and nothing once
/// the time is up.
#[test]
fn the_render_worker_waits_for_a_frame_only_as_long_as_asked() {
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, _) = crate::render_worker::Worker::test_channel();
app.canvas.worker = Some(worker);
let ctx = egui::Context::default();
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(200.0, 150.0))), ..Default::default() };
ctx.run_ui(raw, |ui| show(&mut app, ui)).textures_delta.clear();
let job = jobs.try_recv().expect("a job to finish");
let (mut w, _, done_tx) = crate::render_worker::Worker::test_channel();
let t0 = std::time::Instant::now();
assert!(w.wait(std::time::Duration::from_millis(20)).is_none());
assert!(t0.elapsed() >= std::time::Duration::from_millis(20), "waited the time asked");
w.pending = Some(job.key.clone());
done_tx.send(finished(&job)).unwrap();
assert_eq!(w.wait(std::time::Duration::from_secs(5)).map(|d| d.key), Some(job.key.clone()), "finished before the wait");
assert!(w.pending.is_none());
let late = finished(&job);
let sender = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(30));
done_tx.send(late).unwrap();
});
assert!(w.wait(std::time::Duration::from_secs(5)).is_some(), "finished during the wait");
sender.join().unwrap();
}
+21 -5
View File
@@ -1,7 +1,8 @@
//! Background rendering (native): heavy documents render off the UI thread while the canvas shows
//! the previous frame reprojected to the current view. A second worker renders a low-resolution
//! frame for the parts that frame does not reach (`canvas::fill_uncovered`). Light documents
//! render synchronously so edits never lag behind overlays.
//! render synchronously so edits never lag behind overlays, after a first frame that renders here
//! too: the UI thread waits for it only briefly (`canvas::FIRST_FRAME_GRACE`).
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
@@ -80,16 +81,31 @@ impl Worker {
(Self { tx, rx, pending: None }, jobs, done_tx)
}
pub fn submit(&mut self, job: Job) {
/// Queue `job`, unless the job pending is the same frame. Whether it was queued.
pub fn submit(&mut self, job: Job) -> bool {
if self.pending.as_ref() == Some(&job.key) {
return;
return false;
}
self.pending = Some(job.key.clone());
let _ = self.tx.send(job);
// A worker that has stopped renders nothing: the canvas keeps the frame it shows.
self.tx.send(job).is_ok()
}
/// The newest finished frame, if any.
pub fn poll(&mut self) -> Option<Done> {
let mut last = None;
self.newest(None)
}
/// The newest finished frame, waiting up to `timeout` for one when none has finished yet.
pub fn wait(&mut self, timeout: std::time::Duration) -> Option<Done> {
let first = self.rx.try_recv().ok().or_else(|| self.rx.recv_timeout(timeout).ok())?;
self.newest(Some(first))
}
/// The newest of `first` and the frames finished since, the pending job cleared when it is
/// the one.
fn newest(&mut self, first: Option<Done>) -> Option<Done> {
let mut last = first;
while let Ok(d) = self.rx.try_recv() {
last = Some(d);
}
+7 -3
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -649,11 +649,14 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G15 Performance budgets and a GPU renderer
- **Improved (#4155):** a document's first frame renders on the render worker, not the UI thread: opening the file
of #4155 kept the UI thread busy for 8.7 s at a stretch, now at most 64 ms, and a 50,000-path SVG 1.4 s, now
62 ms (measured in the app, M3 Pro, one run each). A light document's frame, ready within 16 ms, still shows at once.
- **Improved (#4155):** the canvas shrinks a reduced image to the mip it draws before separating it into ink planes,
colorizing it or proofing it, so that work is done at the size the image shows, not at its own. On the
141-megapixel image of #4155 the first frame went from 9.2 s to 1.6 s and the sharp frame after a pan from 3.9 s
to 8 ms (measured in the app, M3 Pro, one run each). Still to do: the first frame still renders on the UI thread,
and at 100% zoom or more an image is still separated whole; tiles would bound that by the view.
to 8 ms (measured in the app, M3 Pro, one run each). Still to do: at 100% zoom or more an image is still separated
whole; tiles would bound that by the view.
- **Improved (2026-11-02):** the renderer keeps a compound path's assembled path with the node, as it keeps a plain
path's, instead of assembling its members again in every frame; the path is assembled into a buffer sized up front.
Unused geometry entries are dropped after a few frames however few there are (before, only past 1,024 entries), so
@@ -764,6 +767,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | minor | G15: a document's first frame renders on the render worker (#4155); the longest UI-thread stall opening that file went from 8.7 s to 64 ms. Estimate unchanged. |
| 2026-11-05 | minor | G15: a reduced image is shrunk before it is separated into ink planes (#4155); first frame 9.2 s → 1.6 s, sharp frame after a pan 3.9 s → 8 ms on that file. Estimate unchanged. |
| 2026-11-05 | minor | G7: document tabs are dragged along the tab strip to reorder them (`document.move`, #5663). Estimate unchanged. |
| 2026-11-02 | minor | G15: the renderer keeps a compound path's assembled path between frames and drops unused geometry entries after a few frames however few there are. Estimate unchanged. |
+3 -1
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Control bar Paragraph section with type selected, #5372; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -31,6 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | minor | Canvas: a document's first frame (opened, or its tab chosen) renders on the render worker instead of the UI thread; the window shows the document's paper and artboards until the art lands, and a frame ready within 16 ms shows at once. Opening the file of #4155 kept the UI thread busy for 8.7 s at a stretch, now at most 64 ms; a 50,000-path SVG 1.4 s, now 62 ms (measured, M3 Pro, one run each). Scores and estimates unchanged. |
| 2026-11-05 | minor | Canvas: an image drawn reduced in a CMYK document is shrunk to the size it shows before it is separated into ink planes, not separated at full size and then shrunk (#4155). On the 141-megapixel image of #4155 the first frame went from 9.2 s to 1.6 s, and the sharp frame after a pan from 3.9 s to 8 ms (measured, M3 Pro, one run each). The same pixels show within 5 levels; fine detail such as 1-pixel lines now shows its true average. Scores and estimates unchanged. |
| 2026-11-05 | minor | Effect dialogs: a hex colour parameter (Outer Glow's Color, Inner Glow's, Drop Shadow's) shows a swatch beside the field that follows typed hex values; a double-click opens the Color Picker over the dialog on that colour, and its OK fills the field while Cancel comes back unchanged (#5365); scores and estimates unchanged. |
| 2026-11-05 | minor | Libraries panel: a spot colour added to a library (from the selection or dragged from the Swatches panel) keeps its ink name, values and tint in the library file; used in any document, also a new one or after the original was closed, it links the paint to that document's spot swatch of the same name, adding it when the document has none of that colour, in one undo step (#5812). Test: `tests::library::a_spot_colour_stays_spot_in_another_document`. Scores and estimates unchanged. |
@@ -1365,6 +1366,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Dragging objects with raster effects when zoomed in (#2558):** during a drag with a tool on the canvas, the renderer draws filtered content (Gaussian Blur, Feather and the Photoshop-style filters) within 1,000,000 pixels, at a lower resolution where it would need more, unless it can draw the content from an earlier frame's raster. In the desktop app it does the same for drop shadows, outer glows and inner glows. No raster made that way is kept. The frame after the drag renders them as a still frame does, and a dialog's preview, exports and printing render as before. `zoomed_drag_perf` times a drag's frames at 100% to 6400%.
- **Dragging other art over a wide blur (#2558):** an effect raster too large to keep whole (a wide blur, a shadow or glow, or any blur zoomed in) is cropped to what can show in the view, or made at a lower resolution and resampled to the view's pixels, and kept while the view stays the same, so dragging other art over the object draws its effects from the still frame instead of re-rendering them at the drag's lower resolution. A drag stays on the render worker once the worker drew the frame shown, since only that renderer holds the rasters. `tests_viewraster` covers each effect, and `a_drag_over_a_wide_blur_draws_the_blur_from_the_still_frame` the canvas on and off the worker. Not yet: a worker frame shows one display frame after the overlays, and the first frame of an interaction can land on the renderer that has not made the rasters yet (follow-up: render every canvas frame on the worker, waiting briefly for fast ones).
- **Opening a heavy document (#4155):** the canvas renders a document's first frame (when it opens, or its tab is chosen) on the render worker, as it renders the frames of a slow document, instead of on the UI thread, where a heavy one froze the window until it was done. The UI thread waits for it up to 16 ms (`FIRST_FRAME_GRACE`), so a light document's art shows in the same frame as before, without a frame of bare paper; otherwise the document's paper and artboards show, the window stays live, and the art shows when it lands. The web build, which has no worker, renders it as before. Measured on an M3 Pro, one run each, from launch with the file: the file of #4155 kept the UI thread busy for 8.7 s at a stretch, now at most 64 ms (its art lands as before, after about 9 s of rendering); a 50,000-path SVG 1.4 s, now 62 ms. `a_documents_first_frame_renders_on_the_worker` and `the_render_worker_waits_for_a_frame_only_as_long_as_asked` (`crates/ui-egui/src/canvas/tests.rs`).
- **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.