Merge #312: perf(canvas): refresh full-viewport previews during zoom (M8) (@tylerjenningsw)

This commit is contained in:
bflatastic
2026-10-15 18:33:29 +07:00
10 changed files with 511 additions and 34 deletions
Generated
+1
View File
@@ -1708,6 +1708,7 @@ dependencies = [
"serde_json",
"toml",
"wat",
"web-time",
]
[[package]]
+1
View File
@@ -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 }
+66 -5
View File
@@ -11,16 +11,27 @@ use serde_json::json;
use crate::FigApp;
use crate::i18n::{tr, trf, trn};
use crate::render_worker::{Job, Worker};
use crate::render_worker::{Done, Job, Worker};
use crate::theme::{ACCENT, COMPONENT, Tokens, doc_c32, regular, semibold};
pub struct CanvasState {
pub rect: Rect,
texture: Option<egui::TextureHandle>,
tex_view: Option<figcraft_render::View>,
/// The file ([`figcraft_engine::DocState::uid`]) and page the texture shows: it is drawn only
/// on them.
tex_of: Option<(u64, NodeId)>,
/// The texture is a coarse preview (see [`crate::render_worker`]).
pub tex_coarse: bool,
submitted: Option<JobKey>,
/// The view of the last render asked for.
want_view: Option<figcraft_render::View>,
/// What the last canvas render had to do (shown by `ui.inspect`).
pub render_work: Option<figcraft_render::Work>,
/// 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<f64>,
pub refine_latency_ms: Option<f64>,
dragging: bool,
space_pan: bool,
middle_pan: Option<(Pos2, f64, f64)>,
@@ -45,8 +56,13 @@ impl Default for CanvasState {
rect: Rect::NOTHING,
texture: None,
tex_view: None,
tex_of: None,
tex_coarse: false,
submitted: None,
want_view: None,
render_work: None,
preview_latency_ms: None,
refine_latency_ms: None,
dragging: false,
space_pan: false,
middle_pan: None,
@@ -172,7 +188,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
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.
if let Some(done) = worker.poll() {
let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok();
if let Some(done) = worker.poll()
&& shows(&app.canvas, &done, shown)
{
if !done.coarse {
app.canvas.render_work = Some(done.work);
app.ui.render_ms = done.ms;
}
// A pixel preview render is magnified with hard pixel edges.
let filter = if app.session.ds().ok().and_then(|d| d.view.pixel_preview_scale()) == Some(done.view.scale) {
egui::TextureOptions::NEAREST
@@ -183,9 +206,15 @@ 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.render_work = Some(done.work);
app.ui.render_ms = done.ms;
app.canvas.tex_of = Some((done.uid, done.page));
app.canvas.tex_coarse = done.coarse;
}
let painter = ui.painter_at(rect);
let t = Tokens::get(&ctx);
@@ -200,7 +229,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
})
.unwrap_or(t.canvas);
painter.rect_filled(rect, CornerRadius::ZERO, bg);
if let (Some(tex), Some(v)) = (&app.canvas.texture, &app.canvas.tex_view) {
if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) {
// Re-project the last render onto the current view.
let tl = app.to_screen(v.origin.0, v.origin.1);
let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0);
@@ -268,6 +297,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 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()
.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)
}
/// 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;
@@ -313,7 +366,11 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
skip: vec![],
solo: key.solo,
proof,
uid: ds.uid,
zoom_preview: ds.view.pixel_preview_scale().is_none(),
at: web_time::Instant::now(),
};
app.canvas.want_view = Some(job.view.clone());
worker.submit(job);
app.canvas.submitted = Some(key);
}
@@ -1364,3 +1421,7 @@ pub fn fmt(v: f64) -> String {
let r = (v * 100.0).round() / 100.0;
if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") }
}
#[cfg(test)]
#[path = "zoom_preview_tests.rs"]
mod zoom_preview_tests;
+3
View File
@@ -63,6 +63,9 @@ fn big_job(doc: &Arc<figcraft_doc::Document>, page: figcraft_doc::NodeId, scale:
skip: vec![],
solo: None,
proof: false,
uid: 0,
zoom_preview: false,
at: web_time::Instant::now(),
}
}
+3
View File
@@ -212,6 +212,9 @@ fn a_finished_background_render_wakes_the_ui() {
skip: vec![],
solo: None,
proof: false,
uid: 0,
zoom_preview: false,
at: web_time::Instant::now(),
});
let start = Instant::now();
while wakes.load(Ordering::SeqCst) == 0 {
+3
View File
@@ -1213,6 +1213,9 @@ impl FigApp {
figcraft_render::Work::Partial { pixels } => json!({"kind": "partial", "pixels": pixels}),
figcraft_render::Work::Full => json!({"kind": "full"}),
}),
"quality": if self.canvas.tex_coarse { "preview" } else { "refined" },
"previewLatencyMs": self.canvas.preview_latency_ms,
"refineLatencyMs": self.canvas.refine_latency_ms,
},
"live": live::inspect(self),
"window": self.windows.current,
+141 -27
View File
@@ -3,12 +3,24 @@
//! until the next one arrives.
//! The renderer keeps its last image ([`figcraft_render::Retained`]): a pan renders only the
//! uncovered strips and an edit only the areas of the layers that changed.
//! A new zoom can't reuse that image, and the old one, re-projected, covers little of the view
//! after a zoom out (an eighth of the width from 800% to 100%). When full renders are slow, the
//! 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.
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;
/// Full renders quicker than this (about a frame) show without a preview first.
const PREVIEW_AFTER_MS: f64 = 12.0;
#[derive(Clone)]
pub struct Job {
@@ -25,6 +37,13 @@ pub struct Job {
pub solo: Option<NodeId>,
/// Soft-proof sRGB: stripe the colours sRGB can't show.
pub proof: bool,
/// The document's [`figcraft_engine::DocState::uid`]: images of other files are never shown.
pub uid: u64,
/// 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: how long the image took to reach the screen.
pub at: Instant,
}
pub struct Done {
@@ -34,6 +53,13 @@ pub struct Done {
pub view: View,
pub ms: f64,
pub work: Work,
/// What it is an image of ([`Job::uid`], [`Job::page`]).
pub uid: u64,
pub page: NodeId,
/// A coarse preview, refined by a later image unless a newer request replaced it.
pub coarse: bool,
/// [`Job::at`].
pub at: Instant,
}
/// A render of a document in `profile` as a texture image for the screen (sRGB).
@@ -58,9 +84,9 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP
}
}
fn run(job: &Job, retained: &mut Retained) -> Option<Done> {
#[cfg(not(target_arch = "wasm32"))]
let t0 = std::time::Instant::now();
/// 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<Done> {
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,
@@ -71,16 +97,86 @@ fn run(job: &Job, retained: &mut Retained) -> Option<Done> {
lod: true,
..Default::default()
};
let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?;
let (view, pix, work) = retained.render(&job.doc, job.page, view, &opts)?;
let mut image = screen_image(pix, job.doc.color_profile);
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 })
Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at })
}
/// `view` with [`PREVIEW_FACTOR`] times fewer pixels each way, showing at least the same area.
pub fn coarse_view(view: &View) -> View {
let shrink = |n: u32| (f64::from(n) / PREVIEW_FACTOR).ceil().max(1.0) as u32;
View { origin: view.origin, scale: view.scale / PREVIEW_FACTOR, width: shrink(view.width), height: shrink(view.height) }
}
/// What the render thread keeps between jobs: the canvas image, the previews standing in for it
/// while the zoom changes, and what the last full render drew and took.
pub struct Renders {
full: Retained,
preview: Retained,
/// The last full render's document, page and scale.
last: Option<(u64, NodeId, f64)>,
/// How long the last render from scratch took.
full_ms: f64,
/// Previews come first only when renders from scratch take at least this long.
preview_after_ms: f64,
}
impl Default for Renders {
fn default() -> Self {
Self::with_threshold(PREVIEW_AFTER_MS)
}
}
impl Renders {
/// Previews when renders from scratch take at least `ms` (tests: 0, always).
pub fn with_threshold(ms: f64) -> Self {
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.
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())
}
/// The coarse preview of the whole of `job`'s view, when one should come first.
pub fn preview(&mut self, job: &Job) -> Option<Done> {
if !self.wants_preview(job) {
return None;
}
let view = coarse_view(&job.view);
guarded(&mut self.preview, |r| run(job, &view, r, true))
}
/// The image of `job`'s view, built on the kept one where it can be.
pub fn refine(&mut self, job: &Job) -> Option<Done> {
let done = guarded(&mut self.full, |r| run(job, &job.view, r, false));
if let Some(d) = &done {
self.last = Some((job.uid, job.page, job.view.scale));
if d.work == Work::Full {
self.full_ms = d.ms;
}
}
done
}
}
/// 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))) {
Ok(d) => d,
Err(_) => {
retained.clear();
None
}
}
}
#[cfg(not(target_arch = "wasm32"))]
@@ -104,22 +200,26 @@ impl Worker {
// The window that renders with it (each window has its own worker).
let viewport = ctx.viewport_id();
let spawned = std::thread::Builder::new().name("figcraft-render".into()).spawn(move || {
let mut retained = Retained::default();
while let Ok((mut seq, mut job)) = jobs.recv() {
let mut renders = Renders::default();
let mut next = None;
loop {
let Some((mut seq, mut job)) = next.take().or_else(|| jobs.recv().ok()) else { break };
// Skip to the newest queued job.
while let Ok(newer) = jobs.try_recv() {
(seq, job) = newer;
}
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&job, &mut retained)));
let done = match res {
Ok(d) => d,
Err(_) => {
// Don't build on an image a panic may have left half drawn.
retained.clear();
None
if let Some(d) = renders.preview(&job) {
if done_tx.send(d).is_err() {
break;
}
};
if let Some(d) = done
ctx.request_repaint_of(viewport);
// A newer request makes refining this one moot: it gets its own preview.
if let Ok(newer) = jobs.try_recv() {
next = Some(newer);
continue;
}
}
if let Some(d) = renders.refine(&job)
&& done_tx.send(d).is_err()
{
break;
@@ -155,30 +255,44 @@ impl Worker {
}
}
#[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<Job>,
retained: Retained,
previewed: bool,
renders: Renders,
}
#[cfg(target_arch = "wasm32")]
impl Worker {
pub fn new(_ctx: egui::Context) -> Self {
Self { pending: None, retained: Retained::default() }
pub type Worker = Inline;
impl Inline {
pub fn new(ctx: egui::Context) -> Self {
Self { ctx, pending: None, previewed: false, renders: Renders::default() }
}
pub fn submit(&mut self, job: Job) {
self.pending = Some(job);
self.previewed = false;
}
pub fn in_flight(&self) -> bool {
self.pending.is_some()
}
pub fn poll(&mut self) -> Option<Done> {
if !self.previewed {
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);
}
}
let job = self.pending.take()?;
run(&job, &mut self.retained)
self.renders.refine(&job)
}
}
/// Render synchronously from scratch (tests, screenshots).
pub fn render_now(job: &Job) -> Option<Done> {
run(job, &mut Retained::default())
run(job, &job.view, &mut Retained::default(), false)
}
+284
View File
@@ -0,0 +1,284 @@
//! 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 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;
use figcraft_doc::{Document, NodeId};
use figcraft_engine::Session;
use figcraft_engine::params::id_of;
use figcraft_render::{View, Work};
use serde_json::json;
use super::{CanvasState, shows};
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) {
let mut s = Session::new();
for k in 0..12 {
let k = f64::from(k);
s.execute("shape.ellipse", &json!({"x": k * 60.0, "y": k * 40.0, "width": 120, "height": 90})).unwrap();
}
let made = s.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 300, "height": 200})).unwrap();
let rect = id_of(&made["id"]).unwrap();
(s, rect)
}
/// A `w` × `h` device-pixel view at `scale` centred on page point `c`.
fn view_at(c: (f64, f64), scale: f64, w: u32, h: u32) -> View {
View { origin: (c.0 - f64::from(w) / scale / 2.0, c.1 - f64::from(h) / scale / 2.0), scale, width: w, height: h }
}
fn job(doc: &Arc<Document>, page: NodeId, uid: u64, view: View) -> Job {
Job {
doc: doc.clone(),
page,
view,
outline: false,
outline_extras: Default::default(),
outlined: vec![],
skip: vec![],
solo: None,
proof: false,
uid,
zoom_preview: true,
at: web_time::Instant::now(),
}
}
/// Every pixel of `a` and `b` alike.
fn same_pixels(a: &Done, b: &Done) -> bool {
a.image.size == b.image.size && a.image.pixels == b.image.pixels
}
fn covers(outer: &View, inner: &View) -> bool {
let (o, i) = (outer.page_rect(), inner.page_rect());
let eps = 1e-6;
o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps
}
#[test]
fn a_zoom_out_previews_the_whole_view_then_refines_to_a_fresh_render() {
let (s, _) = document();
let ds = s.ds().unwrap();
let mut r = Renders::with_threshold(0.0);
let zoomed_in = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 8.0, 400, 300));
assert!(r.preview(&zoomed_in).is_none(), "the first render has nothing to measure previews by");
assert!(r.refine(&zoomed_in).is_some());
// 800% → 100% about the same centre: the old image covers 1/64 of the new view.
let out = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 1.0, 400, 300));
let p = r.preview(&out).expect("a preview first");
assert!(p.coarse && (p.uid, p.page) == (ds.uid, ds.page));
assert!(covers(&p.view, &out.view), "the preview shows the whole view asked for");
let pixels = |d: &Done| d.image.size[0] * d.image.size[1];
let fresh = render_now(&out).unwrap();
assert!(
(pixels(&p) as f64) * PREVIEW_FACTOR * PREVIEW_FACTOR < 1.5 * pixels(&fresh) as f64,
"a sixteenth of the pixels, give or take the snapping"
);
let refined = r.refine(&out).unwrap();
assert!(!refined.coarse && refined.work == Work::Full);
assert!(same_pixels(&refined, &fresh), "the settled image is a render from scratch");
}
/// Zoom, pan and edit with `r`; whether each step was previewed, and the pan's and the edit's
/// images.
fn zoom_pan_edit(r: &mut Renders) -> (Vec<bool>, Done, Done) {
let (mut s, rect) = document();
let (doc, page, uid) = {
let ds = s.ds().unwrap();
(ds.doc.clone(), ds.page, ds.uid)
};
r.refine(&job(&doc, page, uid, view_at((300.0, 200.0), 4.0, 1280, 960))).unwrap();
let mut previewed = vec![];
// A zoom; the preview is kept apart from the full image.
let zoomed = job(&doc, page, uid, view_at((300.0, 200.0), 2.0, 1280, 960));
previewed.push(r.preview(&zoomed).is_some());
r.refine(&zoomed).unwrap();
// A pan at that zoom.
let mut pan = zoomed;
pan.view.origin.0 += 10.0;
previewed.push(r.preview(&pan).is_some());
let panned = r.refine(&pan).unwrap();
// An edit there.
s.execute("node.set", &json!({"id": rect, "props": {"x": 120}})).unwrap();
let edit = job(&s.ds().unwrap().doc, page, uid, pan.view);
previewed.push(r.preview(&edit).is_some());
let edited = r.refine(&edit).unwrap();
(previewed, panned, edited)
}
#[test]
fn pans_and_edits_get_no_preview_and_build_on_the_kept_image() {
let (previewed, panned, edited) = zoom_pan_edit(&mut Renders::with_threshold(0.0));
assert_eq!(previewed, [true, false, false], "the zoom only");
assert!(matches!(panned.work, Work::Partial { .. }), "a pan after a previewed zoom renders strips only: {:?}", panned.work);
assert!(matches!(edited.work, Work::Partial { .. }), "an edit renders its area only: {:?}", edited.work);
// The same steps without previews: the same work and the same pixels.
let (none, panned_alone, edited_alone) = zoom_pan_edit(&mut Renders::with_threshold(f64::INFINITY));
assert_eq!(none, [false, false, false]);
assert_eq!((panned.work, edited.work), (panned_alone.work, edited_alone.work));
assert!(same_pixels(&panned, &panned_alone) && same_pixels(&edited, &edited_alone));
}
#[test]
fn previews_come_for_other_pages_and_files_but_not_in_pixel_preview_or_when_renders_are_quick() {
let (mut s, _) = document();
let (doc, page, uid) = {
let ds = s.ds().unwrap();
(ds.doc.clone(), ds.page, ds.uid)
};
let v = view_at((300.0, 200.0), 1.0, 200, 150);
let mut r = Renders::with_threshold(0.0);
r.refine(&job(&doc, page, uid, v.clone())).unwrap();
s.execute("page.add", &json!({})).unwrap();
let ds = s.ds().unwrap();
assert!(r.preview(&job(&ds.doc, ds.page, uid, v.clone())).is_some(), "another page");
assert!(r.preview(&job(&doc, page, uid + 1, v.clone())).is_some(), "another file");
let zoom = view_at((300.0, 200.0), 2.0, 200, 150);
let mut pixel_preview = job(&doc, page, uid, zoom.clone());
pixel_preview.zoom_preview = false;
assert!(r.preview(&pixel_preview).is_none(), "pixel preview shows real pixels only");
let mut quick = Renders::with_threshold(f64::INFINITY);
quick.refine(&job(&doc, page, uid, v)).unwrap();
assert!(quick.preview(&job(&doc, page, uid, zoom)).is_none(), "quick renders show without one");
}
#[test]
fn previews_cover_the_view_for_any_zoom_and_density() {
let (s, _) = document();
let ds = s.ds().unwrap();
let mut r = Renders::with_threshold(0.0);
r.refine(&job(&ds.doc, ds.page, ds.uid, view_at((0.0, 0.0), 0.37, 64, 64))).unwrap();
let mut n = 0;
// Zooms from 2% to 6400% at 1x, 1.5x and 2x displays, odd sizes, centres off the pixel grid.
for zoom in [0.02, 0.1, 0.333, 1.0, 2.5, 8.0, 64.0] {
for ppp in [1.0, 1.5, 2.0] {
for (w, h) in [(1, 1), (3, 7), (401, 299), (1280, 720)] {
let v = view_at((123.456, -78.9), zoom * ppp, w, h);
let c = coarse_view(&v);
assert!(covers(&c, &v), "coarse view of {v:?}");
let p = r.preview(&job(&ds.doc, ds.page, ds.uid, v.clone())).expect("a preview");
assert!(covers(&p.view, &v), "preview of {v:?} shows {:?}", p.view);
n += 1;
}
}
}
assert_eq!(n, 84);
}
#[test]
fn outline_mode_proofing_and_glass_preview_and_settle_like_fresh_renders() {
let (mut s, rect) = document();
s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap();
let ds = s.ds().unwrap();
for (outline, proof) in [(false, false), (true, false), (false, true)] {
let mut r = Renders::with_threshold(0.0);
let mut first = job(&ds.doc, ds.page, ds.uid, view_at((250.0, 150.0), 3.0, 300, 200));
(first.outline, first.proof) = (outline, proof);
r.refine(&first).unwrap();
let mut next = first.clone();
next.view = view_at((250.0, 150.0), 0.75, 300, 200);
let p = r.preview(&next).expect("a preview");
assert!(p.coarse && covers(&p.view, &next.view));
let d = r.refine(&next).unwrap();
assert!(same_pixels(&d, &render_now(&next).unwrap()), "outline {outline}, proof {proof}");
}
}
#[test]
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));
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 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_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 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 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_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());
}
+6 -1
View File
@@ -95,7 +95,12 @@ ellipse, its arc handles `arcHandles`, for a selected rectangle, frame, polygon
an idle window draws none) with the most frequent repaint requests behind them (`repaintCauses`:
`[{at: "file:line", count}]`, every request made in the frame before each one, delayed ones such as
the autosave deadline included; egui's `context.rs` begin-pass line means input events) and what the last canvas render had to do (`canvas.renderWork`: `full`, `partial` with
the `pixels` re-rendered after a pan or an edit, or `reused`). While a prototype plays, `presenter`
the `pixels` re-rendered after a pan or an edit, or `reused`), whether the canvas shows a coarse
zoom preview or the refined image (`canvas.quality`: `preview` or `refined`) and the time from
asking for a render to its preview and its refined image being handed to egui as the canvas
texture (`canvas.previewLatencyMs`, `canvas.refineLatencyMs`, on a monotonic clock, rendering
included in the browser too; the GPU upload and presenting the frame come after; `null` until
there was one). While a prototype plays, `presenter`
(full window) and `preview` (the preview pane) hold the player's state: the `current` frame and its
`name`, `start`, `history`, open `overlays`, how many delayed mouse triggers are `waiting`, the screen's `scroll` offset and `scrollRange`, the
`device` (and whether it is turned to `landscape`, its `deviceColor`) and the `viewport` of the frame
+3 -1
View File
@@ -194,7 +194,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
### 7. CPU canvas where Figma uses the GPU
- **Missing:** GPU rendering of the canvas. FigCraft renders with tiny-skia on the CPU (banded,
parallel, retained) and shows the image through wgpu.
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.
- **Evidence (measured, `cargo xtask bench`):** a 50k-layer page ~8 ms zoomed out with levels of
detail, single-layer edits at 10k layers ~0.2 ms; full 1440×900 renders 7–15 ms at 10k layers.
Heavy effects (glass, background blur, noise) and 4K/5K canvases at 2x are where CPU time grows.