diff --git a/Cargo.lock b/Cargo.lock index 5e9c6f68..c97678db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1707,6 +1707,7 @@ dependencies = [ "serde_json", "toml", "wat", + "web-time", ] [[package]] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b14c04f2..65a3592d 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,7 @@ log = { workspace = true } base64 = { workspace = true } image = { workspace = true } egui_extras = { workspace = true } +web-time = "1.1.0" [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 90a99384..b42c940b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -24,11 +24,12 @@ pub struct CanvasState { /// The texture is a coarse preview (see [`crate::render_worker`]). pub tex_coarse: bool, submitted: Option, - /// The scale (device pixels per page unit) of the last render asked for. - want_scale: f64, + /// The view of the last render asked for. + want_view: Option, /// What the last canvas render had to do (shown by `ui.inspect`). pub render_work: Option, - /// From asking for a render to the frame that shows its preview, and its refined image (ms). + /// From asking for a render to its preview, and its refined image, handed to egui as the + /// canvas texture (ms; the texture is uploaded when that frame is painted). pub preview_latency_ms: Option, pub refine_latency_ms: Option, dragging: bool, @@ -58,7 +59,7 @@ impl Default for CanvasState { tex_of: None, tex_coarse: false, submitted: None, - want_scale: 0.0, + want_view: None, render_work: None, preview_latency_ms: None, refine_latency_ms: None, @@ -184,19 +185,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let own_cursor = input(app, ui, &resp); crate::live::track_pointer(app, &ctx); app.sync_preedit(); - let now = ctx.input(|i| i.time); - request_render(app, worker, ppp, now); + request_render(app, worker, ppp); // Every finished render is shown, even when a newer one is already on its way: renders finish // in order, and an edit every frame (a drag) would otherwise never show until it stopped. let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok(); if let Some(done) = worker.poll() && shows(&app.canvas, &done, shown) { - let latency = Some((now - done.at).max(0.0) * 1000.0); - if done.coarse { - app.canvas.preview_latency_ms = latency; - } else { - app.canvas.refine_latency_ms = latency; + if !done.coarse { app.canvas.render_work = Some(done.work); app.ui.render_ms = done.ms; } @@ -210,6 +206,12 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { Some(t) => t.set(done.image, filter), None => app.canvas.texture = Some(ctx.load_texture("figcraft-canvas", done.image, filter)), } + let latency = Some(done.at.elapsed().as_secs_f64() * 1000.0); + if done.coarse { + app.canvas.preview_latency_ms = latency; + } else { + app.canvas.refine_latency_ms = latency; + } app.canvas.tex_view = Some(done.view); app.canvas.tex_of = Some((done.uid, done.page)); app.canvas.tex_coarse = done.coarse; @@ -296,16 +298,30 @@ fn describe_box([x0, y0, x1, y1]: [f64; 4]) -> String { } /// Whether a finished render goes on screen: an image of the file and page shown (`shown`), and -/// not a coarse preview over a refined image already at the zoom asked for. +/// not a coarse preview over a refined image that already shows the whole view asked for at its +/// zoom (the same zoom elsewhere on the page is no such image). fn shows(c: &CanvasState, done: &Done, shown: Option<(u64, NodeId)>) -> bool { if shown != Some((done.uid, done.page)) { return false; } - let refined_here = !c.tex_coarse && c.tex_of == shown && c.tex_view.as_ref().is_some_and(|v| v.scale.to_bits() == c.want_scale.to_bits()); + let refined_here = !c.tex_coarse + && c.tex_of == shown + && c.tex_view + .as_ref() + .zip(c.want_view.as_ref()) + .is_some_and(|(have, want)| have.scale.to_bits() == want.scale.to_bits() && covers(have, want)); !(done.coarse && refined_here) } -fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, now: f64) { +/// Whether `outer` shows all of the page that `inner` does. +fn covers(outer: &figcraft_render::View, inner: &figcraft_render::View) -> bool { + let (o, i) = (outer.page_rect(), inner.page_rect()); + // Rounding in the snapped origin, well under a device pixel. + let eps = 1e-6 / inner.scale.max(1e-9); + o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps +} + +fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { let Ok(ds) = app.session.ds() else { return }; let rect = app.canvas.rect; let (mut ox, mut oy) = ds.view.origin(); @@ -352,9 +368,9 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, now: f64) { proof, uid: ds.uid, zoom_preview: ds.view.pixel_preview_scale().is_none(), - at: now, + at: web_time::Instant::now(), }; - app.canvas.want_scale = scale; + app.canvas.want_view = Some(job.view.clone()); worker.submit(job); app.canvas.submitted = Some(key); } diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 1c4938d7..0220b353 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -64,7 +64,7 @@ fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: proof: false, uid: 0, zoom_preview: false, - at: 0.0, + at: web_time::Instant::now(), } } diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 4a277ae2..0b848747 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -214,7 +214,7 @@ fn a_finished_background_render_wakes_the_ui() { proof: false, uid: 0, zoom_preview: false, - at: 0.0, + at: web_time::Instant::now(), }); let start = Instant::now(); while wakes.load(Ordering::SeqCst) == 0 { diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index ee978890..3ad9c54c 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -14,6 +14,8 @@ use std::sync::Arc; use figcraft_color::ColorProfile; use figcraft_doc::{Document, NodeId}; use figcraft_render::{Pixmap, Retained, View, Work}; +// A monotonic clock in the browser too: preview decisions rest on how long renders take there. +use web_time::Instant; /// A preview has this many times fewer pixels each way than the view it stands in for. pub const PREVIEW_FACTOR: f64 = 4.0; @@ -40,8 +42,8 @@ pub struct Job { /// A coarse preview may come first when the zoom changes (not in pixel preview, whose pixels /// are the point). pub zoom_preview: bool, - /// When the UI asked for it (`egui` input time, seconds): how long the image took to show. - pub at: f64, + /// When the UI asked for it: how long the image took to reach the screen. + pub at: Instant, } pub struct Done { @@ -57,7 +59,7 @@ pub struct Done { /// A coarse preview, refined by a later image unless a newer request replaced it. pub coarse: bool, /// [`Job::at`]. - pub at: f64, + pub at: Instant, } /// A render of a document in `profile` as a texture image for the screen (sRGB). @@ -84,8 +86,7 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP /// Render `view` of the job (its own view, or the coarse one standing in for it). fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option { - #[cfg(not(target_arch = "wasm32"))] - let t0 = std::time::Instant::now(); + let t0 = Instant::now(); // The canvas is a preview: what's too small to see in detail at this zoom is approximated. let opts = figcraft_render::Options { outline_mode: job.outline, @@ -101,10 +102,7 @@ fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option< if job.proof { mark_outside_srgb(&mut image, pix, job.doc.color_profile); } - #[cfg(not(target_arch = "wasm32"))] let ms = t0.elapsed().as_secs_f64() * 1000.0; - #[cfg(target_arch = "wasm32")] - let ms = 0.0; Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at }) } @@ -257,10 +255,10 @@ impl Worker { } } -/// Renders inline, one image a frame: a preview first when one is due, the refined image in the -/// next frame unless a newer request replaced the job. -#[cfg(target_arch = "wasm32")] -pub struct Worker { +/// The browser's worker: renders inline, one image a frame: a preview first when one is due, the +/// refined image in the next frame unless a newer request replaced the job. Built everywhere so +/// tests run it. +pub struct Inline { ctx: egui::Context, pending: Option, previewed: bool, @@ -268,7 +266,9 @@ pub struct Worker { } #[cfg(target_arch = "wasm32")] -impl Worker { +pub type Worker = Inline; + +impl Inline { pub fn new(ctx: egui::Context) -> Self { Self { ctx, pending: None, previewed: false, renders: Renders::default() } } diff --git a/crates/ui-egui/src/zoom_preview_tests.rs b/crates/ui-egui/src/zoom_preview_tests.rs index cd17a70a..68704571 100644 --- a/crates/ui-egui/src/zoom_preview_tests.rs +++ b/crates/ui-egui/src/zoom_preview_tests.rs @@ -1,7 +1,8 @@ //! Coarse previews while the zoom changes (#295): a new zoom first shows the whole view coarsely, //! then the refined image, which matches a render from scratch; pans and edits still build on the -//! kept image; previews never replace a refined image already at the zoom asked for, and no image -//! of another file or page goes on screen. +//! kept image; previews never replace a refined image that already shows the whole view asked +//! for, and no image of another file or page goes on screen. The browser's inline worker previews +//! by the same measure as the render thread. use std::sync::Arc; @@ -12,7 +13,7 @@ use figcraft_render::{View, Work}; use serde_json::json; use super::{CanvasState, shows}; -use crate::render_worker::{Done, Job, PREVIEW_FACTOR, Renders, coarse_view, render_now}; +use crate::render_worker::{Done, Inline, Job, PREVIEW_FACTOR, Renders, coarse_view, render_now}; /// A page of ellipses with a rectangle on top; the session and the rectangle. fn document() -> (Session, NodeId) { @@ -44,7 +45,7 @@ fn job(doc: &Arc, page: NodeId, uid: u64, view: View) -> Job { proof: false, uid, zoom_preview: true, - at: 0.0, + at: web_time::Instant::now(), } } @@ -189,7 +190,7 @@ fn outline_mode_proofing_and_glass_preview_and_settle_like_fresh_renders() { } #[test] -fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_at_its_zoom() { +fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_of_its_view() { let (s, _) = document(); let ds = s.ds().unwrap(); let here = Some((ds.uid, ds.page)); @@ -199,21 +200,85 @@ fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_at_its_zoom() let far = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 0.5, 200, 150)); let preview = r.preview(&far).unwrap(); - let mut c = CanvasState { want_scale: near.view.scale, ..Default::default() }; + let mut c = CanvasState { want_view: Some(near.view.clone()), ..Default::default() }; assert!(shows(&c, &refined, here)); assert!(!shows(&c, &refined, Some((ds.uid + 1, ds.page))), "another file"); assert!(!shows(&c, &refined, Some((ds.uid, NodeId(ds.page.0 + 1)))), "another page"); assert!(!shows(&c, &refined, None), "no file open"); - // Showing the refined image at the zoom asked for: a preview arriving late stays off. + // Showing the refined image of the view asked for: a preview arriving late stays off. (c.tex_view, c.tex_of, c.tex_coarse) = (Some(refined.view.clone()), here, false); assert!(!shows(&c, &preview, here)); - // Once the zoom asked for moves on, the preview is the better match. - c.want_scale = far.view.scale; + // Once the view asked for moves on, the preview is the better match. + c.want_view = Some(far.view.clone()); assert!(shows(&c, &preview, here)); // Over another preview, or a refined image of another page, it goes on. - c.want_scale = near.view.scale; + c.want_view = Some(near.view.clone()); c.tex_coarse = true; assert!(shows(&c, &preview, here)); (c.tex_coarse, c.tex_of) = (false, Some((ds.uid, NodeId(ds.page.0 + 1)))); assert!(shows(&c, &preview, here)); } + +#[test] +fn a_preview_replaces_a_refined_image_of_the_same_zoom_elsewhere_on_the_page() { + // 800 × 600: A at 100% from the page origin, B at 800% about its top-left, A' back at 100% + // about B's bottom-right corner. A shows 100 × 75 of A', 1/64 of it. + let (s, _) = document(); + let ds = s.ds().unwrap(); + let here = Some((ds.uid, ds.page)); + let view = |origin, scale| View { origin, scale, width: 800, height: 600 }; + let a = job(&ds.doc, ds.page, ds.uid, view((0.0, 0.0), 1.0)); + let b = job(&ds.doc, ds.page, ds.uid, view((0.0, 0.0), 8.0)); + let a2 = job(&ds.doc, ds.page, ds.uid, view((-700.0, -525.0), 1.0)); + let mut r = Renders::with_threshold(0.0); + let refined_a = r.refine(&a).unwrap(); + // The canvas shows A; B is asked for, then A' while B renders. The thread finishes B, then + // previews A' before the canvas polls: the queue holds [refined B, preview A'], and a poll + // takes the last. + let queue = [r.refine(&b).unwrap(), r.preview(&a2).expect("A' is a new zoom for the thread")]; + let polled = queue.last().unwrap(); + assert!(polled.coarse && covers(&polled.view, &a2.view)); + let c = CanvasState { + tex_view: Some(refined_a.view.clone()), + tex_of: here, + tex_coarse: false, + want_view: Some(a2.view.clone()), + ..Default::default() + }; + assert!(shows(&c, polled, here), "A at the same zoom shows 1/64 of A': the preview of all of it goes on"); + // Once A' itself is refined on screen, a preview of it would be a step down. + let refined_a2 = r.refine(&a2).unwrap(); + let c = CanvasState { tex_view: Some(refined_a2.view.clone()), ..c }; + assert!(!shows(&c, polled, here)); +} + +#[test] +fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_frame() { + // Big enough that a render from scratch takes well over a frame: blurs and glass on 2400². + let (mut s, rect) = document(); + s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap(); + s.execute("effect.add", &json!({"ids": [rect], "type": "BACKGROUND_BLUR"})).unwrap(); + let ds = s.ds().unwrap(); + let at = |scale| job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), scale, 2400, 2400)); + // The production threshold, as the browser has it. + let mut w = Inline::new(egui::Context::default()); + w.submit(at(2.0)); + let first = w.poll().expect("the first image"); + assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by"); + assert!(first.ms >= 12.0, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms); + // A new zoom: the preview this frame, the refined image the next. + w.submit(at(1.0)); + let p = w.poll().expect("a preview"); + assert!(p.coarse && w.in_flight()); + let d = w.poll().expect("the refined image"); + assert!(!d.coarse && !w.in_flight()); + assert!(same_pixels(&d, &render_now(&at(1.0)).unwrap())); + // A newer request before the refinement: refining the old one is skipped. + w.submit(at(0.5)); + assert!(w.poll().is_some_and(|d| d.coarse)); + w.submit(at(0.25)); + let p = w.poll().expect("the newer one's preview"); + assert!(p.coarse && (p.view.scale - 0.25 / PREVIEW_FACTOR).abs() < 1e-12); + assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 0.25)); + assert!(w.poll().is_none() && !w.in_flight()); +}