Files
figcraft/crates/ui-egui/src/render_worker.rs
T
BFlatandClaude Opus 5.5 5f54fecca2 M8: 50k-layer zoomed-out renders and faster save/load (#63)
Canvas previews zoomed out to 50% or less draw levels of detail
(crates/render/src/lod.rs): subtrees under 2 device px are splatted as
their averaged paint colours (box filter, no paths or effects), text
under 4 px is greeked into bars, small solid rectangles are splatted and
rectangular clip layers are composited without the blend pipeline.
Image paints and masks keep the exact path. Exports, thumbnails and
every other render stay exact (Options::lod is off by default); render
bands are assembled with a row copy instead of a pixmap draw.

.figcraft documents are serialized straight into the compressor and
parsed straight from the text (a header pass reads version and image
index without building a value tree); layer and text fields at their
default values are left out (63 -> 22 MB of JSON at 50k layers, older
files still load); JSON members use deflate level 3.

At 50k layers: zoomed-to-fit render ~58 -> ~8 ms, save ~1.0 s -> ~0.09 s,
load ~0.86 s -> ~0.16 s.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:51 +07:00

144 lines
4.7 KiB
Rust

//! Canvas rendering off the UI thread (desktop) or inline (web). The latest request wins; the UI
//! keeps showing the previous image, re-projected to the current view, until the new 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.
use std::sync::Arc;
use figcraft_color::ColorProfile;
use figcraft_doc::{Document, NodeId};
use figcraft_render::{Pixmap, Retained, View, Work};
#[derive(Clone)]
pub struct Job {
pub doc: Arc<Document>,
pub page: NodeId,
pub view: View,
pub outline: bool,
pub skip: Vec<NodeId>,
pub generation: u64,
}
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 generation: u64,
pub ms: f64,
pub work: Work,
}
/// 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)
}
fn run(job: &Job, retained: &mut Retained) -> Option<Done> {
#[cfg(not(target_arch = "wasm32"))]
let t0 = std::time::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, skip: job.skip.clone(), lod: true, ..Default::default() };
let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?;
let image = screen_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, generation: job.generation, ms, work })
}
#[cfg(not(target_arch = "wasm32"))]
pub struct Worker {
tx: std::sync::mpsc::Sender<Job>,
rx: std::sync::mpsc::Receiver<Done>,
pub in_flight: bool,
}
#[cfg(not(target_arch = "wasm32"))]
impl Worker {
pub fn new(ctx: egui::Context) -> Self {
let (tx, jobs) = std::sync::mpsc::channel::<Job>();
let (done_tx, rx) = std::sync::mpsc::channel::<Done>();
let spawned = std::thread::Builder::new().name("figcraft-render".into()).spawn(move || {
let mut retained = Retained::default();
while let Ok(mut job) = jobs.recv() {
// Skip to the newest queued job.
while let Ok(newer) = jobs.try_recv() {
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) = done
&& done_tx.send(d).is_err()
{
break;
}
ctx.request_repaint();
}
});
if let Err(e) = spawned {
log::error!("render thread: {e}");
}
Self { tx, rx, in_flight: false }
}
pub fn submit(&mut self, job: Job) {
if self.tx.send(job).is_ok() {
self.in_flight = true;
}
}
pub fn poll(&mut self) -> Option<Done> {
let mut last = None;
while let Ok(d) = self.rx.try_recv() {
last = Some(d);
}
if last.is_some() {
self.in_flight = false;
}
last
}
}
#[cfg(target_arch = "wasm32")]
pub struct Worker {
pending: Option<Job>,
retained: Retained,
pub in_flight: bool,
}
#[cfg(target_arch = "wasm32")]
impl Worker {
pub fn new(_ctx: egui::Context) -> Self {
Self { pending: None, retained: Retained::default(), in_flight: false }
}
pub fn submit(&mut self, job: Job) {
self.pending = Some(job);
self.in_flight = true;
}
pub fn poll(&mut self) -> Option<Done> {
let job = self.pending.take()?;
self.in_flight = false;
run(&job, &mut self.retained)
}
}
/// Render synchronously from scratch (tests, screenshots).
pub fn render_now(job: &Job) -> Option<Done> {
run(job, &mut Retained::default())
}