diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 02de56a76..8e30081d7 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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 } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f1c62dfc0..e501273d5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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; diff --git a/crates/ui-egui/src/canvas/fill.rs b/crates/ui-egui/src/canvas/fill.rs index f68c17558..2f45f23ba 100644 --- a/crates/ui-egui/src/canvas/fill.rs +++ b/crates/ui-egui/src/canvas/fill.rs @@ -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) { diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index a799bbe29..178a216ec 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -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 = 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), diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 6345dff0c..836049278 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -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"); +}