From 79d436a469da35088041de73906c26de8ec38d71 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 12:23:58 -0400 Subject: [PATCH 1/3] perf(canvas): preview cold zoom and page transitions once they run long (M8) Whether a zoom got a preview rested on the previous render from scratch, so the first render, another page or file, and a zoom from a quick view to a heavy one came without one: the page colour showed until the full render was done. Rendering every such preview up front would cost 8-56% of a full render (bench pages of 1k-50k layers at 2880x1800; per-layer costs dominate large pages). Instead the render thread arms a watchdog thread as it starts a render from scratch without a preview; once that render has run past 12 ms the watchdog renders the preview beside it, on one core while the full render keeps the others, and sends it unless the full image is done. Quick renders cost nothing and never flash a preview. Warm slow zooms keep the preview first, which keeps up with a gesture. With two threads sending, finished images carry their request's number: a poll takes the newest request's, and of one request the refined image; the canvas never shows a preview over the refined image of its own or a newer request. Refs #295 --- crates/ui-egui/src/canvas.rs | 16 +++- crates/ui-egui/src/render_worker.rs | 115 +++++++++++++++++++---- crates/ui-egui/src/zoom_preview_tests.rs | 115 +++++++++++++++++++++-- 3 files changed, 213 insertions(+), 33 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 49163986..6cd68c34 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -23,6 +23,8 @@ pub struct CanvasState { tex_of: Option<(u64, NodeId)>, /// The texture is a coarse preview (see [`crate::render_worker`]). pub tex_coarse: bool, + /// The request the texture answers ([`crate::render_worker::Done::seq`]). + tex_seq: u64, submitted: Option, /// The view of the last render asked for. want_view: Option, @@ -58,6 +60,7 @@ impl Default for CanvasState { tex_view: None, tex_of: None, tex_coarse: false, + tex_seq: 0, submitted: None, want_view: None, render_work: None, @@ -219,6 +222,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { app.canvas.tex_view = Some(done.view); app.canvas.tex_of = Some((done.uid, done.page)); app.canvas.tex_coarse = done.coarse; + app.canvas.tex_seq = done.seq; } let painter = ui.painter_at(rect); let t = Tokens::get(&ctx); @@ -321,14 +325,18 @@ 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 that already shows the whole view asked for at its -/// zoom (the same zoom elsewhere on the page is no such image). +/// not a coarse preview over a refined image of the same or a newer request, or one 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 + let refined = !c.tex_coarse && c.tex_of == shown; + if done.coarse && refined && done.seq <= c.tex_seq { + return false; + } + let refined_here = refined && c.tex_view .as_ref() .zip(c.want_view.as_ref()) diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index e2fb9a20..4929e3ac 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -8,6 +8,10 @@ //! whole view is first rendered coarsely ([`PREVIEW_FACTOR`] times fewer pixels each way, kept //! apart from the full image so pans and edits still build on it), then refined unless a newer //! request is already waiting. +//! Without a measure of the new render (the first one, another page or file, a zoom after quick +//! renders) nothing is spent up front: on the desktop a watchdog thread renders the preview once +//! the render from scratch has run past [`PREVIEW_AFTER_MS`], beside it (the full render keeps the +//! other cores), and drops it if the full image is there first. use std::sync::Arc; @@ -19,7 +23,7 @@ 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; -/// Full renders quicker than this (about a frame) show without a preview first. +/// Full renders quicker than this (about a frame) show without a preview. pub const PREVIEW_AFTER_MS: f64 = 12.0; #[derive(Clone)] @@ -60,6 +64,8 @@ pub struct Done { pub coarse: bool, /// [`Job::at`]. pub at: Instant, + /// The request it answers, numbered by the worker in order (0 outside a worker). + pub seq: u64, } /// A render of a document in `profile` as a texture image for the screen (sRGB). @@ -103,7 +109,7 @@ fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option< mark_outside_srgb(&mut image, pix, job.doc.color_profile); } let ms = t0.elapsed().as_secs_f64() * 1000.0; - Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at }) + Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at, seq: 0 }) } /// `view` with [`PREVIEW_FACTOR`] times fewer pixels each way, showing at least the same area. @@ -137,13 +143,21 @@ impl Renders { Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms } } - /// Whether `job` gets a preview first: the kept image is of another zoom, page or file (so the - /// full render starts from scratch) and those have been slow. The first render has no - /// measure yet and comes without one. + /// Whether `job` is a render from scratch: the kept image is of another zoom, page or file, or + /// there is none. + fn starts_over(&self, job: &Job) -> bool { + self.last.is_none_or(|(uid, page, scale)| uid != job.uid || page != job.page || scale.to_bits() != job.view.scale.to_bits()) + } + + /// Whether `job` gets a preview first: it renders from scratch and those have been slow on a + /// page already drawn (the first render has no measure). fn wants_preview(&self, job: &Job) -> bool { - job.zoom_preview - && self.full_ms >= self.preview_after_ms - && self.last.is_some_and(|(uid, page, scale)| uid != job.uid || page != job.page || scale.to_bits() != job.view.scale.to_bits()) + job.zoom_preview && self.last.is_some() && self.full_ms >= self.preview_after_ms && self.starts_over(job) + } + + /// Whether `job` should get a preview should its render run long: from scratch, with none first. + pub fn wants_late_preview(&self, job: &Job) -> bool { + job.zoom_preview && self.starts_over(job) && !self.wants_preview(job) } /// The coarse preview of the whole of `job`'s view, when one should come first. @@ -151,8 +165,7 @@ impl Renders { if !self.wants_preview(job) { return None; } - let view = coarse_view(&job.view); - guarded(&mut self.preview, |r| run(job, &view, r, true)) + preview_with(job, &mut self.preview) } /// The image of `job`'s view, built on the kept one where it can be. @@ -168,6 +181,26 @@ impl Renders { } } +/// The coarse preview of the whole of `job`'s view, kept in `retained`. +pub fn preview_with(job: &Job, retained: &mut Retained) -> Option { + let view = coarse_view(&job.view); + guarded(retained, |r| run(job, &view, r, true)) +} + +/// `d` numbered as the answer to request `seq`. +fn answering(d: Done, seq: u64) -> Done { + Done { seq, ..d } +} + +/// Of two finished renders, the one to show: the newer request's, and of one request's the refined +/// image (the watchdog's preview can arrive after it). +pub fn better(a: Option, b: Done) -> Done { + match a { + Some(a) if a.seq > b.seq || (a.seq == b.seq && !a.coarse && b.coarse) => a, + _ => b, + } +} + /// Run `f` on `retained`; after a panic, forget the image it may have left half drawn. fn guarded(retained: &mut Retained, f: impl FnOnce(&mut Retained) -> Option) -> Option { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(retained))) { @@ -199,6 +232,40 @@ impl Worker { let done_with = finished.clone(); // The window that renders with it (each window has its own worker). let viewport = ctx.viewport_id(); + // The request being rendered from scratch without a preview, and when it started, for the + // watchdog; the newest request refined so far. + let (watch, watched) = std::sync::mpsc::channel::<(u64, Job, std::time::Instant)>(); + let refined = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let refined_seen = refined.clone(); + let (preview_tx, preview_ctx) = (done_tx.clone(), ctx.clone()); + let watchdog = std::thread::Builder::new().name("figcraft-render-preview".into()).spawn(move || { + let mut retained = Retained::default(); + let after = std::time::Duration::from_secs_f64(PREVIEW_AFTER_MS / 1000.0); + while let Ok((mut seq, mut job, mut started)) = watched.recv() { + // Wait out the threshold; a newer arming means that render is over. + loop { + match watched.recv_timeout(after.saturating_sub(started.elapsed())) { + Ok(newer) => (seq, job, started) = newer, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return, + } + } + let still_rendering = || refined_seen.load(std::sync::atomic::Ordering::Acquire) < seq; + if !still_rendering() { + continue; + } + let Some(d) = preview_with(&job, &mut retained) else { continue }; + if still_rendering() { + if preview_tx.send(answering(d, seq)).is_err() { + break; + } + preview_ctx.request_repaint_of(viewport); + } + } + }); + if let Err(e) = watchdog { + log::error!("render preview thread: {e}"); + } let spawned = std::thread::Builder::new().name("figcraft-render".into()).spawn(move || { let mut renders = Renders::default(); let mut next = None; @@ -209,7 +276,7 @@ impl Worker { (seq, job) = newer; } if let Some(d) = renders.preview(&job) { - if done_tx.send(d).is_err() { + if done_tx.send(answering(d, seq)).is_err() { break; } ctx.request_repaint_of(viewport); @@ -219,8 +286,14 @@ impl Worker { continue; } } - if let Some(d) = renders.refine(&job) - && done_tx.send(d).is_err() + if renders.wants_late_preview(&job) { + // Without the watchdog (it failed to start) there is just no preview. + let _ = watch.send((seq, job.clone(), std::time::Instant::now())); + } + let done = renders.refine(&job); + refined.store(seq, std::sync::atomic::Ordering::Release); + if let Some(d) = done + && done_tx.send(answering(d, seq)).is_err() { break; } @@ -246,12 +319,13 @@ impl Worker { self.finished.load(std::sync::atomic::Ordering::Acquire) < self.submitted } + /// The best of the renders finished since the last poll ([`better`]). pub fn poll(&mut self) -> Option { - let mut last = None; + let mut best = None; while let Ok(d) = self.rx.try_recv() { - last = Some(d); + best = Some(better(best, d)); } - last + best } } @@ -261,6 +335,8 @@ impl Worker { pub struct Inline { ctx: egui::Context, pending: Option, + /// Requests submitted so far. + submitted: u64, previewed: bool, renders: Renders, } @@ -270,10 +346,11 @@ pub type Worker = Inline; impl Inline { pub fn new(ctx: egui::Context) -> Self { - Self { ctx, pending: None, previewed: false, renders: Renders::default() } + Self { ctx, pending: None, submitted: 0, previewed: false, renders: Renders::default() } } pub fn submit(&mut self, job: Job) { self.pending = Some(job); + self.submitted += 1; self.previewed = false; } pub fn in_flight(&self) -> bool { @@ -284,11 +361,11 @@ impl Inline { self.previewed = true; if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) { self.ctx.request_repaint(); - return Some(d); + return Some(answering(d, self.submitted)); } } let job = self.pending.take()?; - self.renders.refine(&job) + self.renders.refine(&job).map(|d| answering(d, self.submitted)) } } diff --git a/crates/ui-egui/src/zoom_preview_tests.rs b/crates/ui-egui/src/zoom_preview_tests.rs index 0fcd9a29..f297277c 100644 --- a/crates/ui-egui/src/zoom_preview_tests.rs +++ b/crates/ui-egui/src/zoom_preview_tests.rs @@ -2,7 +2,9 @@ //! 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 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. +//! by the same measure as the render thread. Renders from scratch with no measure to go by (the +//! first, another page, a zoom after quick renders) get a preview on the desktop only if they run +//! long, from the watchdog beside the render thread. use std::sync::Arc; @@ -13,7 +15,9 @@ use figcraft_render::{View, Work}; use serde_json::json; use super::{CanvasState, shows}; -use crate::render_worker::{Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, coarse_view, render_now}; +use figcraft_render::Retained; + +use crate::render_worker::{Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, Worker, better, coarse_view, preview_with, render_now}; /// A page of ellipses with a rectangle on top; the session and the rectangle. fn document() -> (Session, NodeId) { @@ -225,9 +229,9 @@ fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_of_its_view() let here = Some((ds.uid, ds.page)); let mut r = Renders::with_threshold(0.0); let near = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 2.0, 200, 150)); - let refined = r.refine(&near).unwrap(); + let refined = Done { seq: 1, ..r.refine(&near).unwrap() }; 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 preview = Done { seq: 2, ..r.preview(&far).unwrap() }; let mut c = CanvasState { want_view: Some(near.view.clone()), ..Default::default() }; assert!(shows(&c, &refined, here)); @@ -235,7 +239,7 @@ fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_of_its_view() 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 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); + (c.tex_view, c.tex_of, c.tex_coarse, c.tex_seq) = (Some(refined.view.clone()), here, false, refined.seq); assert!(!shows(&c, &preview, here)); // Once the view asked for moves on, the preview is the better match. c.want_view = Some(far.view.clone()); @@ -246,6 +250,96 @@ fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_of_its_view() 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)); + // Never over the refined image of its own request or a newer one (the watchdog's preview can + // come after the refined image), even once the view asked for moved on. + (c.tex_of, c.want_view) = (here, Some(far.view.clone())); + for seq in [preview.seq, preview.seq + 1] { + c.tex_seq = seq; + assert!(!shows(&c, &preview, here), "refined image of request {seq}"); + } +} + +#[test] +fn a_poll_takes_the_newest_request_and_of_one_request_the_refined_image() { + let (s, _) = document(); + let ds = s.ds().unwrap(); + let v = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 0.5, 64, 48)); + let preview = |seq| Done { seq, ..preview_with(&v, &mut Retained::default()).unwrap() }; + let refined = |seq| Done { seq, ..render_now(&v).unwrap() }; + let pick = |order: Vec| order.into_iter().fold(None, |best, d| Some(better(best, d))).map(|d| (d.seq, d.coarse)); + assert_eq!(pick(vec![refined(3), preview(4)]), Some((4, true)), "a newer request's preview over an older refined image"); + assert_eq!(pick(vec![preview(3), refined(3)]), Some((3, false))); + assert_eq!(pick(vec![refined(3), preview(3)]), Some((3, false)), "the watchdog's preview after its refined image"); + assert_eq!(pick(vec![refined(4), preview(4), preview(3)]), Some((4, false))); + assert_eq!(pick(vec![preview(4)]), Some((4, true))); +} + +#[test] +fn renders_from_scratch_with_nothing_to_go_by_get_a_late_preview() { + let (mut s, _) = document(); + let (doc, page, uid) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.page, ds.uid) + }; + let at = |doc: &Arc, page, uid, scale| job(doc, page, uid, view_at((300.0, 200.0), scale, 200, 150)); + // Renders that have been quick: nothing up front, a preview only should one run long. + let mut r = Renders::with_threshold(f64::INFINITY); + let first = at(&doc, page, uid, 1.0); + assert!(r.wants_late_preview(&first) && r.preview(&first).is_none(), "the first render"); + r.refine(&first).unwrap(); + assert!(r.wants_late_preview(&at(&doc, page, uid, 4.0)), "a zoom after quick renders"); + assert!(r.wants_late_preview(&at(&doc, page, uid + 1, 1.0)), "another file"); + let mut panned = first.clone(); + panned.view.origin.0 += 5.0; + assert!(!r.wants_late_preview(&panned), "a pan builds on the kept image"); + let mut pixels = at(&doc, page, uid, 4.0); + pixels.zoom_preview = false; + assert!(!r.wants_late_preview(&pixels), "pixel preview"); + s.execute("page.add", &json!({})).unwrap(); + let ds = s.ds().unwrap(); + assert!(r.wants_late_preview(&at(&ds.doc, ds.page, uid, 1.0)), "another page"); + // Renders that have been slow: the preview comes first instead. + let mut slow = Renders::with_threshold(0.0); + slow.refine(&first).unwrap(); + let zoom = at(&doc, page, uid, 4.0); + assert!(!slow.wants_late_preview(&zoom) && slow.preview(&zoom).is_some()); +} + +/// Every image `w` hands out until it has nothing left in flight: (request, coarse). +fn drain(w: &mut Worker) -> Vec<(u64, bool)> { + let start = std::time::Instant::now(); + let mut got = vec![]; + while w.in_flight() || got.is_empty() { + assert!(start.elapsed() < std::time::Duration::from_secs(60), "the render never finished"); + if let Some(d) = w.poll() { + got.push((d.seq, d.coarse)); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + got.extend(w.poll().map(|d| (d.seq, d.coarse))); + got +} + +#[test] +fn a_slow_first_render_gets_a_preview_beside_it_and_a_quick_one_none() { + // Slow: 2400² with glass and a background blur over the page, rendered from scratch. + 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 slow = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 2.0, 2400, 2400)); + let fresh = render_now(&slow).unwrap(); + assert!(fresh.ms > 50.0, "slow enough for a preview to beat it: {} ms", fresh.ms); + let mut w = Worker::new(egui::Context::default()); + w.submit(slow); + assert_eq!(drain(&mut w), [(1, true), (1, false)], "the preview while it renders, then the refined image"); + // Quick: a small view of a few shapes on a fresh worker (the slow render above would make the + // next zoom get its preview first). + let (s, _) = document(); + let ds = s.ds().unwrap(); + let mut w = Worker::new(egui::Context::default()); + w.submit(job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 0.5, 120, 90))); + assert_eq!(drain(&mut w), [(1, false)], "done before the threshold: no preview"); } #[test] @@ -260,24 +354,25 @@ fn a_preview_replaces_a_refined_image_of_the_same_zoom_elsewhere_on_the_page() { 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(); + let refined_a = Done { seq: 1, ..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(); + // takes the newer request's. + let queue = [Done { seq: 2, ..r.refine(&b).unwrap() }, Done { seq: 3, ..r.preview(&a2).expect("A' is a new zoom for the thread") }]; + let polled = &queue.into_iter().fold(None, |best, d| Some(better(best, d))).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, + tex_seq: refined_a.seq, 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 }; + let c = CanvasState { tex_view: Some(refined_a2.view.clone()), tex_seq: 3, ..c }; assert!(!shows(&c, polled, here)); } From f80bdb634d1f3fc5ef81f536e0f017be2143d84b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 12:23:59 -0400 Subject: [PATCH 2/3] docs: zoom previews for cold transitions (M8) Refs #295 --- docs/gaps.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/gaps.md b/docs/gaps.md index 9b1a770c..9081759e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -236,7 +236,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Missing:** GPU rendering of the canvas. FigCraft renders with tiny-skia on the CPU (banded, parallel, retained) and shows the image through wgpu. A new zoom can't reuse the retained image; when renders from scratch take a frame or more, the whole view is previewed with a - sixteenth of the pixels first, then refined. + sixteenth of the pixels first, then refined. With no measure to go by (the first render, another + page, a zoom after quick renders), a watchdog thread previews any such render still running + after 12 ms (desktop only; the browser renders inline). - **Landed (2026-10-15):** a bounded render margin is reused during panning and refilled before the visible view reaches its edge. The extra rendered area is capped at 1.6× visible pixels; this reduces blank strips without changing the CPU renderer. From 20f7d4eb2c58206478224a57b1bd6c3a0915576b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 15 Oct 2026 10:08:41 -0400 Subject: [PATCH 3/3] test(canvas): grow the cold-preview scene instead of assuming a 50 ms render (M8) The threaded test asserted a fixed glass scene renders in over 50 ms, which would flake on a fast machine or once the glass speedups land. It now grows the scene until the full render takes at least twice the threshold plus the preview's own time, so the watchdog's preview comes first with a margin wherever it runs. Refs #295 --- crates/ui-egui/src/zoom_preview_tests.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/zoom_preview_tests.rs b/crates/ui-egui/src/zoom_preview_tests.rs index f297277c..36da28f8 100644 --- a/crates/ui-egui/src/zoom_preview_tests.rs +++ b/crates/ui-egui/src/zoom_preview_tests.rs @@ -322,14 +322,21 @@ fn drain(w: &mut Worker) -> Vec<(u64, bool)> { #[test] fn a_slow_first_render_gets_a_preview_beside_it_and_a_quick_one_none() { - // Slow: 2400² with glass and a background blur over the page, rendered from scratch. - 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 slow = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 2.0, 2400, 2400)); - let fresh = render_now(&slow).unwrap(); - assert!(fresh.ms > 50.0, "slow enough for a preview to beat it: {} ms", fresh.ms); + // Slow: a render from scratch that runs past the threshold, then the preview, with as much + // again to spare, so the preview comes first however fast this machine and the renderer are. + let view = view_at((300.0, 200.0), 2.0, 1200, 1200); + let mut want = 4.0 * PREVIEW_AFTER_MS; + let slow = loop { + let s = slow_scene(std::slice::from_ref(&view), want); + let ds = s.ds().unwrap(); + let slow = job(&ds.doc, ds.page, ds.uid, view.clone()); + let (full, preview) = (render_now(&slow).unwrap().ms, preview_with(&slow, &mut Retained::default()).unwrap().ms); + if full >= 2.0 * (PREVIEW_AFTER_MS + preview) { + break slow; + } + assert!(want < 64.0 * PREVIEW_AFTER_MS, "previews keep up with full renders: {preview} ms of {full} ms"); + want *= 2.0; + }; let mut w = Worker::new(egui::Context::default()); w.submit(slow); assert_eq!(drain(&mut w), [(1, true), (1, false)], "the preview while it renders, then the refined image");