mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
The browser-worker test assumed a fixed 2400x2400 glass scene renders in at least 12 ms; a faster machine or renderer (the glass speedups) would make it quick and the test fail. The scene now gets more and more blurred rectangles until renders from scratch of the views the test uses take three times the threshold. Refs #295
299 lines
11 KiB
Rust
299 lines
11 KiB
Rust
//! Canvas rendering off the UI thread (desktop) or inline (web). The latest request wins (queued
|
|
//! older ones are skipped); the UI shows each finished image, re-projected to the current view,
|
|
//! 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.
|
|
pub const PREVIEW_AFTER_MS: f64 = 12.0;
|
|
|
|
#[derive(Clone)]
|
|
pub struct Job {
|
|
pub doc: Arc<Document>,
|
|
pub page: NodeId,
|
|
pub view: View,
|
|
pub outline: bool,
|
|
/// Outline mode's options (hidden layers, object bounds).
|
|
pub outline_extras: figcraft_render::Outline,
|
|
/// Layers drawn as outlines (outlines for the selection).
|
|
pub outlined: Vec<NodeId>,
|
|
pub skip: Vec<NodeId>,
|
|
/// Only this layer (the focus view).
|
|
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 {
|
|
/// The image, ready for a texture (converted off the UI thread).
|
|
pub image: egui::ColorImage,
|
|
/// The view the image shows (the requested one snapped outwards to whole device pixels).
|
|
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).
|
|
pub fn screen_image(pix: &Pixmap, profile: ColorProfile) -> egui::ColorImage {
|
|
let size = [pix.width() as usize, pix.height() as usize];
|
|
if profile.is_srgb() {
|
|
return egui::ColorImage::from_rgba_premultiplied(size, pix.data());
|
|
}
|
|
let mut data = pix.data().to_vec();
|
|
profile.conversion_to(ColorProfile::Srgb).apply_pixels(&mut data);
|
|
egui::ColorImage::from_rgba_premultiplied(size, &data)
|
|
}
|
|
|
|
/// Soft proof: grey diagonal stripes over the pixels whose colours sRGB can't show.
|
|
fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorProfile) {
|
|
let w = image.size[0].max(1);
|
|
let clipped = profile.conversion_to(ColorProfile::Srgb).clipped_pixels(pix.data());
|
|
for (i, (px, out)) in image.pixels.iter_mut().zip(clipped).enumerate() {
|
|
if out && ((i % w) + (i / w)) % 8 < 4 {
|
|
*px = egui::Color32::from_gray(128);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
outline: job.outline_extras,
|
|
outlined: job.outlined.clone(),
|
|
skip: job.skip.clone(),
|
|
solo: job.solo,
|
|
lod: true,
|
|
..Default::default()
|
|
};
|
|
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);
|
|
}
|
|
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 })
|
|
}
|
|
|
|
/// `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"))]
|
|
pub struct Worker {
|
|
tx: std::sync::mpsc::Sender<(u64, Job)>,
|
|
rx: std::sync::mpsc::Receiver<Done>,
|
|
/// Jobs submitted so far.
|
|
submitted: u64,
|
|
/// The newest job the thread is done with: rendered (or skipped for a newer one) and the UI
|
|
/// woken for it.
|
|
finished: Arc<std::sync::atomic::AtomicU64>,
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
impl Worker {
|
|
pub fn new(ctx: egui::Context) -> Self {
|
|
let (tx, jobs) = std::sync::mpsc::channel::<(u64, Job)>();
|
|
let (done_tx, rx) = std::sync::mpsc::channel::<Done>();
|
|
let finished = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
|
let done_with = finished.clone();
|
|
// 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 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;
|
|
}
|
|
if let Some(d) = renders.preview(&job) {
|
|
if done_tx.send(d).is_err() {
|
|
break;
|
|
}
|
|
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;
|
|
}
|
|
ctx.request_repaint_of(viewport);
|
|
done_with.store(seq, std::sync::atomic::Ordering::Release);
|
|
}
|
|
});
|
|
if let Err(e) = spawned {
|
|
log::error!("render thread: {e}");
|
|
}
|
|
Self { tx, rx, submitted: 0, finished }
|
|
}
|
|
|
|
pub fn submit(&mut self, job: Job) {
|
|
if self.tx.send((self.submitted + 1, job)).is_ok() {
|
|
self.submitted += 1;
|
|
}
|
|
}
|
|
|
|
/// Whether a submitted job is still rendering or hasn't woken the UI yet (a finished image
|
|
/// may still wait for [`Self::poll`]).
|
|
pub fn in_flight(&self) -> bool {
|
|
self.finished.load(std::sync::atomic::Ordering::Acquire) < self.submitted
|
|
}
|
|
|
|
pub fn poll(&mut self) -> Option<Done> {
|
|
let mut last = None;
|
|
while let Ok(d) = self.rx.try_recv() {
|
|
last = Some(d);
|
|
}
|
|
last
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
previewed: bool,
|
|
renders: Renders,
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
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()?;
|
|
self.renders.refine(&job)
|
|
}
|
|
}
|
|
|
|
/// Render synchronously from scratch (tests, screenshots).
|
|
pub fn render_now(job: &Job) -> Option<Done> {
|
|
run(job, &job.view, &mut Retained::default(), false)
|
|
}
|