diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d81571c1..d227940c 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, @@ -246,6 +249,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); painter.rect_filled(rect, CornerRadius::ZERO, bg); @@ -343,14 +347,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 fa9d426e..873e762a 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)] @@ -62,6 +66,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). @@ -107,7 +113,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. @@ -141,13 +147,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. @@ -155,8 +169,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. @@ -172,6 +185,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))) { @@ -203,6 +236,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; @@ -213,7 +280,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); @@ -223,8 +290,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; } @@ -250,12 +323,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 } } @@ -265,6 +339,8 @@ impl Worker { pub struct Inline { ctx: egui::Context, pending: Option, + /// Requests submitted so far. + submitted: u64, previewed: bool, renders: Renders, } @@ -274,10 +350,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 { @@ -288,11 +365,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 5d720b7a..ab0b54da 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) { @@ -226,9 +230,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)); @@ -236,7 +240,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()); @@ -247,6 +251,103 @@ 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: 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"); + // 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] @@ -261,24 +362,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)); } diff --git a/docs/gaps.md b/docs/gaps.md index 2ade4481..f020bf67 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -244,7 +244,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.