Merge pull request #4961 from el-j/perf/canvas-decoupled-worker-pipeline

perf(canvas): zero-copy texture upload and progressive worker frame acceptance (#4615)
This commit is contained in:
BFlat
2026-11-02 14:56:23 +07:00
committed by GitHub
5 changed files with 154 additions and 17 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ egui = { workspace = true, features = ["bytemuck", "default_fonts", "monochrome_
# `default_fonts`: egui 0.37 makes its bundled faces opt-in, and a dependency's dev-features do not
# reach our tests; `theme::install_fonts` starts from `FontDefinitions::default()`, which is empty
# without them (production would lay out tofu, and the no-interface-fonts tests would bind nothing).
bytemuck = "1"
bytemuck = { version = "1", features = ["extern_crate_alloc"] }
egui_extras = { workspace = true }
image = { workspace = true }
serde = { workspace = true }
+15 -10
View File
@@ -62,8 +62,8 @@ use overlays::selected_anchor;
use overlays::{HandleLook, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay};
pub use panes::Panes;
pub(crate) use panes::{consolidate, tile, tiled_area};
pub(crate) use raster::color_image;
use raster::{changed_pixels, pixel_region, upload};
pub(crate) use raster::{color_image, color_image_owned};
use task_bar::task_bar;
pub(crate) use task_bar::{has_colors, task_bar_rect};
pub use tool_overlays::HiddenChars;
@@ -532,17 +532,22 @@ 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.
// 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
{
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;
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;
}
}
}
@@ -652,7 +657,7 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
_ => {
let t0 = now_ms();
let img = app.canvas.renderer.render(doc, w, h, view, &opts);
upload(app, ui.ctx(), &img, pixel.is_some());
upload(app, ui.ctx(), img, pixel.is_some());
app.canvas.key = Some(key.clone());
app.canvas.shown = Some((doc.clone(), opts.clone()));
app.perf.render_ms = now_ms() - t0;
+4 -3
View File
@@ -1,5 +1,5 @@
use super::cursor::only_view_differs;
use super::{Affine, CacheKey, Color32, Point, Pos2, Shape, VectorcraftApp, Xf, color_image, pos2, vec2};
use super::{Affine, CacheKey, Color32, Point, Pos2, Shape, VectorcraftApp, Xf, color_image_owned, pos2, vec2};
/// The fraction of the canvas's resolution, each way, that [`Fill`] is rendered at.
const FILL_SCALE: f64 = 0.25;
@@ -83,7 +83,8 @@ pub(super) fn fill_uncovered(
let opts = vectorcraft_render::RenderOptions { kept_images_only: true, image_mips: true, interacting: false, dragging: true, ..opts.clone() };
let gaps = shown_frame(k, rect, xf).map_or_else(Vec::new, |shown| uncovered(shown, rect));
if let Some(done) = done.filter(|d| usable(&d.key)) {
let color = color_image(&done.img);
let (fw, fh) = (done.img.width, done.img.height);
let color = color_image_owned(done.img);
let tex = match app.canvas.fill.take() {
Some(mut f) => {
f.tex.set(color, egui::TextureOptions::LINEAR);
@@ -92,7 +93,7 @@ pub(super) fn fill_uncovered(
None => ctx.load_texture("canvas-fill", color, egui::TextureOptions::LINEAR),
};
let (_, _, view) = fill_view(&done.key);
app.canvas.fill = Some(Fill { key: done.key, view, size: (done.img.width, done.img.height), tex });
app.canvas.fill = Some(Fill { key: done.key, view, size: (fw, fh), tex });
}
let Some(worker) = app.canvas.fill_worker.as_mut().filter(|_| !gaps.is_empty()) else { return };
if !app.canvas.fill.as_ref().is_some_and(|f| covers(&f.key)) && !worker.pending.as_ref().is_some_and(covers) {
+18 -3
View File
@@ -14,6 +14,20 @@ pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImag
}
}
/// Convert an owned [`vectorcraft_render::Rendered`] raster directly into an [`egui::ColorImage`].
/// Reuses the raster's heap allocation in place via zero-copy buffer casting when size and
/// alignment permit, eliminating redundant multi-megabyte allocations on texture upload.
pub(crate) fn color_image_owned(img: vectorcraft_render::Rendered) -> egui::ColorImage {
let size = [img.width as usize, img.height as usize];
let expected = size[0].saturating_mul(size[1]).saturating_mul(4);
if expected == img.pixels.len() {
let pixels: Vec<egui::Color32> = bytemuck::allocation::try_cast_vec(img.pixels).unwrap_or_else(|(_, v)| bytemuck::cast_slice(&v).to_vec());
egui::ColorImage::new(size, pixels)
} else {
egui::ColorImage::new(size, vec![egui::Color32::TRANSPARENT; size[0].saturating_mul(size[1])])
}
}
/// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document
/// changed, or placed documents' bitmaps were made, since the frame shown (its key `key` but for
/// the revision and [`CacheKey::placed`], and its render options `opts`), which this thread
@@ -68,9 +82,10 @@ fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) {
(a, a + len)
}
/// Upload the art's raster; 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) {
let color = color_image(img);
/// 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) {
let color = color_image_owned(img);
let options = if pixel { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR };
match &mut app.canvas.texture {
Some(tex) => tex.set(color, options),
+116
View File
@@ -41,8 +41,10 @@ fn rendered_rasters_become_the_same_egui_images() {
assert!(img.pixels.as_chunks::<4>().0.iter().any(|p| p[3] != 0 && p[3] != 255), "partly covered pixels");
let egui = egui::ColorImage::from_rgba_premultiplied([97, 61], &img.pixels);
assert_eq!(color_image(&img), egui);
assert_eq!(color_image_owned(img), egui);
let short = vectorcraft_render::Rendered { width: 3, height: 2, pixels: vec![255; 5] };
assert_eq!(color_image(&short), egui::ColorImage::new([3, 2], vec![egui::Color32::TRANSPARENT; 6]));
assert_eq!(color_image_owned(short), egui::ColorImage::new([3, 2], vec![egui::Color32::TRANSPARENT; 6]));
}
/// With VECTORCRAFT_SYSTEM_CURSORS set the glyphs aren't drawn and the system's cursors stand in: those of
@@ -8538,3 +8540,117 @@ fn hidden_artboards_leave_a_paper_canvas_without_edges() {
app.run("view.artboards", json!({})).unwrap();
assert_eq!(look(&mut app), (false, true));
}
/// Finished worker renders are uploaded whenever they are not older than what is currently
/// displayed on screen. During continuous edit streams (drag previews, held arrow nudges, or
/// command bursts) where the document revision advances every frame, intermediate worker frames
/// are uploaded progressively to prevent the canvas from freezing. Obsolete frames older than what
/// is already displayed are discarded.
#[test]
fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_frames() {
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 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 mut out = ctx.run_ui(raw.clone(), |ui| show(&mut app, ui));
out.textures_delta.clear();
let doc = app.session.active().unwrap().doc.clone();
let initial_key = app.canvas.key.clone().expect("first frame initializes key");
let key_doc = initial_key.doc;
// Displayed canvas texture is initially at revision 2.
let mut displayed_key = initial_key.clone();
displayed_key.revision = 2;
app.canvas.key = Some(displayed_key);
let (worker, _jobs, done_tx) = crate::render_worker::Worker::test_channel();
app.canvas.worker = Some(worker);
// Worker completes revision 5 (intermediate frame from an edit stream).
// It is newer than displayed revision 2: it MUST be accepted and uploaded.
let mut rev5_key = initial_key.clone();
rev5_key.revision = 5;
let img5 = vectorcraft_render::Rendered { width: 10, height: 10, pixels: vec![200; 400] };
done_tx
.send(crate::render_worker::Done {
key: rev5_key,
doc: doc.clone(),
opts: vectorcraft_render::RenderOptions::default(),
img: img5,
ms: 2.5,
moved: 0,
})
.unwrap();
let mut out = ctx.run_ui(raw.clone(), |ui| poll_worker(&mut app, ui, key_doc));
let set_count = out.textures_delta.set.len();
out.textures_delta.clear();
assert_eq!(set_count, 1, "intermediate frame 5 is uploaded to texture");
assert_eq!(app.canvas.key.as_ref().unwrap().revision, 5, "intermediate frame 5 is uploaded");
assert!(app.canvas.from_worker);
// Worker completes an older revision 3 (out of order). It is older than displayed 5, so discarded.
let mut rev3_key = initial_key.clone();
rev3_key.revision = 3;
let img3 = vectorcraft_render::Rendered { width: 10, height: 10, pixels: vec![150; 400] };
done_tx
.send(crate::render_worker::Done {
key: rev3_key,
doc: doc.clone(),
opts: vectorcraft_render::RenderOptions::default(),
img: img3,
ms: 2.5,
moved: 0,
})
.unwrap();
let mut out = ctx.run_ui(raw.clone(), |ui| poll_worker(&mut app, ui, key_doc));
let set_count = out.textures_delta.set.len();
out.textures_delta.clear();
assert_eq!(set_count, 0, "older out-of-order frame 3 emits NO texture upload delta, leaving displayed texture intact");
assert_eq!(app.canvas.key.as_ref().unwrap().revision, 5, "older out-of-order frame 3 is discarded");
// Worker completes another intermediate revision 8. Newer than displayed 5, so uploaded.
let mut rev8_key = initial_key.clone();
rev8_key.revision = 8;
let img8 = vectorcraft_render::Rendered { width: 10, height: 10, pixels: vec![180; 400] };
done_tx
.send(crate::render_worker::Done {
key: rev8_key,
doc: doc.clone(),
opts: vectorcraft_render::RenderOptions::default(),
img: img8,
ms: 2.5,
moved: 0,
})
.unwrap();
let mut out = ctx.run_ui(raw.clone(), |ui| poll_worker(&mut app, ui, key_doc));
let set_count = out.textures_delta.set.len();
out.textures_delta.clear();
assert_eq!(set_count, 1, "newer frame 8 is uploaded to texture");
assert_eq!(app.canvas.key.as_ref().unwrap().revision, 8, "newer frame 8 is uploaded");
// Finally, the settling frame at revision 10 completes: it is accepted and uploaded.
let mut rev10_key = initial_key;
rev10_key.revision = 10;
let img10 = vectorcraft_render::Rendered { width: 10, height: 10, pixels: vec![255; 400] };
done_tx
.send(crate::render_worker::Done {
key: rev10_key,
doc: doc.clone(),
opts: vectorcraft_render::RenderOptions::default(),
img: img10,
ms: 2.5,
moved: 0,
})
.unwrap();
let mut out = ctx.run_ui(raw, |ui| poll_worker(&mut app, ui, key_doc));
let set_count = out.textures_delta.set.len();
out.textures_delta.clear();
assert_eq!(set_count, 1, "settled frame 10 is uploaded to texture");
assert_eq!(app.canvas.key.as_ref().unwrap().revision, 10, "settled frame 10 is uploaded");
}