mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Merge #312: perf(canvas): refresh full-viewport previews during zoom (M8) (@tylerjenningsw)
This commit is contained in:
Generated
+1
@@ -1708,6 +1708,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"toml",
|
||||
"wat",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user