diff --git a/crates/ui-egui/src/canvas/render.rs b/crates/ui-egui/src/canvas/render.rs index 07983944e..f33fc9ea0 100644 --- a/crates/ui-egui/src/canvas/render.rs +++ b/crates/ui-egui/src/canvas/render.rs @@ -24,7 +24,10 @@ pub fn color_at(app: &mut DesignApp, ctx: &egui::Context, pos: Pos2) -> Option<[ let placed: Vec = layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, xf: s.xf }).collect(); let ppp = ctx.pixels_per_point() as f64; let view = Affine::scale(ppp) * Affine::scale(xf.zoom) * Affine::translate((-at.x, -at.y)); - let opts = screen_options(app, &layout, app.ui.screen_mode != ScreenMode::Normal, ppp); + let mut opts = screen_options(app, &layout, app.ui.screen_mode != ScreenMode::Normal, ppp); + // The colour picked is applied: an image still decoding is decoded for it here, rather than + // sampling the placeholder or a coarser stand-in the canvas shows meanwhile. + opts.defer_images = false; let mut img = app.canvas.patcher.render(&doc, &app.session.cache, &placed, (1, 1), view, &opts); // The colour as the canvas shows it (proofed). screen_post(app, &mut img); @@ -53,6 +56,7 @@ pub(super) fn screen_options(app: &DesignApp, layout: &CanvasLayout, preview: bo overprint_preview: app.ui.overprint_preview, blend_space_view: !app.ui.proof_colors, document_grid: if app.ui.document_grid && !preview { grid_areas(layout) } else { vec![] }, + defer_images: true, ..Default::default() } } @@ -120,6 +124,26 @@ pub(super) fn invalidate_on_display_change(app: &mut DesignApp) { } } +/// How often the canvas renders again for images decoded in the background: a page of photos +/// decoding one after another renders a few times, not once per photo. +pub(super) const IMAGE_BATCH_MS: f64 = 250.0; + +/// The image generation the canvas renders with, given the `live` one: `seen` (generation, when +/// taken) moves to `live` at most every [`IMAGE_BATCH_MS`], and not while a render is in +/// flight (it would be dropped for the newer one and never shown; finishing it repaints). Also +/// returns how long to wait (ms) before asking again when `live` is newer. +pub(super) fn image_generation(seen: &mut (u64, f64), live: u64, now: f64, rendering: bool) -> (u64, Option) { + if live == seen.0 || rendering { + return (seen.0, None); + } + let wait = seen.1 + IMAGE_BATCH_MS - now; + if wait <= 0.0 { + *seen = (live, now); + return (live, None); + } + (seen.0, Some(wait)) +} + /// Keep the canvas texture current. Panning inside the overscanned region just shifts the /// texture; zooming shows the old texture scaled until the background render arrives; document /// edits render synchronously so editing never lags. @@ -129,26 +153,6 @@ pub(super) fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rec let ppp = ctx.pixels_per_point() as f64; let doc_key = (st.uid, std::sync::Arc::as_ptr(&st.doc) as u64, st.revision, preview); let editing_parents = st.editing_parents; - // Overscan: 25% of the viewport on every side, within the GPU's maximum texture size. - let max_side = ctx.input(|i| i.max_texture_side) as f32 / ppp as f32; - let (ow, oh) = - ((rect.width() * 0.25).min((max_side - rect.width()) / 2.0).max(0.0), (rect.height() * 0.25).min((max_side - rect.height()) / 2.0).max(0.0)); - let want = |origin: Point| crate::Shown { - doc: doc_key, - editing_parents, - origin: Point::new(origin.x - ow as f64 / xf.zoom, origin.y - oh as f64 / xf.zoom), - zoom: xf.zoom, - size: (rect.width() + 2.0 * ow, rect.height() + 2.0 * oh), - }; - let target = want(xf.origin); - // Does the current texture cover the viewport at this zoom and document state? - let covers = |s: &crate::Shown| { - s.doc == doc_key && s.editing_parents == editing_parents && (s.zoom - xf.zoom).abs() < 1e-9 && { - let x0 = (xf.origin.x - s.origin.x) * s.zoom; - let y0 = (xf.origin.y - s.origin.y) * s.zoom; - x0 >= -0.5 && y0 >= -0.5 && x0 + rect.width() as f64 <= s.size.0 as f64 + 0.5 && y0 + rect.height() as f64 <= s.size.1 as f64 + 0.5 - } - }; // Collect finished background renders. #[cfg(not(target_arch = "wasm32"))] if let (Some(w), Some((tok, shown))) = (app.canvas.worker.as_ref(), app.canvas.pending) @@ -161,6 +165,35 @@ pub(super) fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rec app.canvas.pending = None; app.perf.render_ms = done.ms; } + let images = { + let live = designcraft_render::images::generation(); + let (images, wait) = image_generation(&mut app.canvas.images, live, crate::now_ms(), app.canvas.pending.is_some()); + if let Some(after) = wait.and_then(|ms| std::time::Duration::try_from_secs_f64(ms / 1000.0).ok()) { + ctx.request_repaint_after(after); + } + images + }; + // Overscan: 25% of the viewport on every side, within the GPU's maximum texture size. + let max_side = ctx.input(|i| i.max_texture_side) as f32 / ppp as f32; + let (ow, oh) = + ((rect.width() * 0.25).min((max_side - rect.width()) / 2.0).max(0.0), (rect.height() * 0.25).min((max_side - rect.height()) / 2.0).max(0.0)); + let want = |origin: Point| crate::Shown { + doc: doc_key, + editing_parents, + origin: Point::new(origin.x - ow as f64 / xf.zoom, origin.y - oh as f64 / xf.zoom), + zoom: xf.zoom, + size: (rect.width() + 2.0 * ow, rect.height() + 2.0 * oh), + images, + }; + let target = want(xf.origin); + // Does the current texture cover the viewport at this zoom and document state? + let covers = |s: &crate::Shown| { + s.doc == doc_key && s.editing_parents == editing_parents && s.images == images && (s.zoom - xf.zoom).abs() < 1e-9 && { + let x0 = (xf.origin.x - s.origin.x) * s.zoom; + let y0 = (xf.origin.y - s.origin.y) * s.zoom; + x0 >= -0.5 && y0 >= -0.5 && x0 + rect.width() as f64 <= s.size.0 as f64 + 0.5 && y0 + rect.height() as f64 <= s.size.1 as f64 + 0.5 + } + }; if app.canvas.shown.is_some_and(|s| covers(&s)) { return; } @@ -209,7 +242,7 @@ pub(super) fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rec { // Already rendering this view? if app.canvas.pending.is_some_and(|(_, p)| { - p.doc == doc_key && p.editing_parents == editing_parents && (p.zoom - xf.zoom).abs() < 1e-9 && { + p.doc == doc_key && p.editing_parents == editing_parents && p.images == images && (p.zoom - xf.zoom).abs() < 1e-9 && { let x0 = (xf.origin.x - p.origin.x) * p.zoom; let y0 = (xf.origin.y - p.origin.y) * p.zoom; x0 >= 0.0 && y0 >= 0.0 && x0 + rect.width() as f64 <= p.size.0 as f64 && y0 + rect.height() as f64 <= p.size.1 as f64 @@ -293,6 +326,9 @@ fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: #[cfg(not(target_arch = "wasm32"))] fn render_worker_spawn(ctx: &egui::Context) -> Option { + // Images decoded in the background are drawn by the next render. + let wake = ctx.clone(); + designcraft_render::images::set_waker(move || wake.request_repaint()); if std::env::var_os("DESIGNCRAFT_SYNC_RENDER").is_some() { return None; } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 3e9040050..be46f72b0 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -410,10 +410,24 @@ fn harness_with_step(app: DesignApp, step_dt: f32) -> Harness<'static, DesignApp h } +/// Background decodes finishing one after another are taken up in batches, and not while a +/// render is in flight (a newer request would drop it before it is shown). +#[test] +fn finished_image_decodes_rerender_the_canvas_in_batches() { + use render::{IMAGE_BATCH_MS, image_generation}; + let mut seen = (0, f64::NEG_INFINITY); + assert_eq!(image_generation(&mut seen, 1, 1000.0, false), (1, None), "the first is taken at once"); + assert_eq!(image_generation(&mut seen, 2, 1010.0, false), (1, Some(IMAGE_BATCH_MS - 10.0))); + assert_eq!(image_generation(&mut seen, 9, 1100.0, false).0, 1, "more finish meanwhile"); + assert_eq!(image_generation(&mut seen, 9, 1000.0 + IMAGE_BATCH_MS, true), (1, None), "a render is in flight"); + assert_eq!(image_generation(&mut seen, 9, 1000.0 + IMAGE_BATCH_MS, false), (9, None), "all taken by one render"); +} + #[test] fn display_option_change_drops_the_render_in_flight() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - let shown = crate::Shown { doc: (1, 2, 3, false), origin: Point::new(0.0, 0.0), zoom: 1.0, size: (10.0, 10.0), editing_parents: false }; + let shown = + crate::Shown { doc: (1, 2, 3, false), origin: Point::new(0.0, 0.0), zoom: 1.0, size: (10.0, 10.0), editing_parents: false, images: 0 }; invalidate_on_display_change(&mut app); app.canvas.shown = Some(shown); app.canvas.pending = Some((7, shown)); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e05686103..c544f31ae 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -540,6 +540,9 @@ pub struct Shown { pub origin: Point, pub zoom: f64, pub size: (f32, f32), + /// [`designcraft_render::images::generation`] when rendered: images decoded since are + /// drawn by rendering again. + pub images: u64, } impl CanvasCache { @@ -562,6 +565,7 @@ impl CanvasCache { patcher: designcraft_render::Renderer::new(), patches: 0, separations: separations::Preview::default(), + images: (0, f64::NEG_INFINITY), } } } @@ -595,6 +599,9 @@ pub struct CanvasCache { pub patches: u64, /// View › Separations Preview: the visible spreads' plates. pub separations: separations::Preview, + /// The [`designcraft_render::images::generation`] the canvas renders with, and when it took + /// it (ms): finished background decodes are taken up in batches, one render for many. + pub images: (u64, f64), } #[derive(Default, Clone, Copy, Debug, Serialize)] diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 8f2eef43d..fe359b713 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -2295,3 +2295,26 @@ fn design_generation_native_drop_batch_without_a_change_still_places_every_file( let _ = std::fs::remove_file(first); let _ = std::fs::remove_file(second); } + +/// The eyedropper samples an image that hasn't been decoded yet as it is, not the placeholder +/// the canvas shows while it decodes in the background. +#[test] +fn the_eyedropper_samples_an_image_still_decoding() { + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + let red = designcraft_render::Rendered { width: 40, height: 40, pixels: [255u8, 0, 0, 255].repeat(1600) }.to_png(); + let b64 = designcraft_engine::cmd::base64_encode(&red); + app.run("file.place", json!({"base64": b64, "name": "red.png", "x": 100, "y": 100, "width": 200})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); + let _ = app.view_mut(); + let at = { + let (view, rect) = (*app.view().unwrap(), app.canvas_rect.unwrap()); + let st = app.session.active().unwrap(); + let layout = designcraft_tools::CanvasLayout::new(&st.doc, false); + canvas::Xf::new(rect, &view).to_screen(layout.slots[0].xf * designcraft_geom::Point::new(200.0, 200.0)) + }; + // Nothing decoded: the canvas would draw the grey placeholder here. + designcraft_render::images::clear(); + let ctx = egui::Context::default(); + assert_eq!(canvas::color_at(&mut app, &ctx, at), Some([255, 0, 0])); +}