mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 16:52:47 +00:00
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:
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user