Merge pull request #320 from tylerjenningsw/perf/zoom-preview-cold

perf(canvas): preview cold zoom and page transitions once they run long (M8)
This commit is contained in:
BFlat
2026-10-27 00:50:49 +07:00
committed by GitHub
4 changed files with 223 additions and 34 deletions
+12 -4
View File
@@ -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<JobKey>,
/// The view of the last render asked for.
want_view: Option<figcraft_render::View>,
@@ -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())
+96 -19
View File
@@ -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<Done> {
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<Done>, 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<Done>) -> Option<Done> {
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<Done> {
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<Job>,
/// 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))
}
}
+112 -10
View File
@@ -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<Done>| 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<Document>, 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));
}
+3 -1
View File
@@ -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.