diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f4e9307..241c654 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -177,20 +177,21 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } // Rendered content. render_texture(app, ui.ctx(), rect, &xf, &layout, preview); - if let Some(tex) = &app.canvas.texture { + if let (Some(tex), Some(sh)) = (&app.canvas.texture, app.canvas.shown) { + // Place the texture where its region is in the current view (shifted while panning, + // scaled while a zoom re-render is pending). + let k = (xf.zoom / sh.zoom) as f32; + let tl = xf.to_screen(sh.origin); + let tex_rect = Rect::from_min_size(tl, vec2(sh.size.0 * k, sh.size.1 * k)); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); if preview { // Preview: only page areas (trim) show content. for slot in &layout.slots { let r = xf.rect(slot.bounds); - let ruv = Rect::from_min_max( - pos2((r.min.x - rect.min.x) / rect.width(), (r.min.y - rect.min.y) / rect.height()), - pos2((r.max.x - rect.min.x) / rect.width(), (r.max.y - rect.min.y) / rect.height()), - ); - painter.with_clip_rect(r.intersect(rect)).image(tex.id(), r, ruv, Color32::WHITE); + painter.with_clip_rect(r.intersect(rect)).image(tex.id(), tex_rect, uv, Color32::WHITE); } } else { - painter.image(tex.id(), rect, uv, Color32::WHITE); + painter.image(tex.id(), tex_rect, uv, Color32::WHITE); } } let hair = 1.0 / ui.ctx().pixels_per_point(); @@ -240,29 +241,112 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { } } +/// Keep the canvas texture current. Panning inside the overscanned region just shifts the +/// texture; zooming shows the old texture scaled until the background render arrives; document +/// edits render synchronously so editing never lags. fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, layout: &CanvasLayout, preview: bool) { let Some(st) = app.session.active() else { return }; let ppp = ctx.pixels_per_point() as f64; - let w = (rect.width() as f64 * ppp).round().max(1.0) as u32; - let h = (rect.height() as f64 * ppp).round().max(1.0) as u32; - let doc_ptr = std::sync::Arc::as_ptr(&st.doc) as u64; - let key = (st.uid, doc_ptr, xf.zoom.to_bits(), xf.origin.x.to_bits() ^ xf.origin.y.to_bits().rotate_left(17), st.revision, w, h, preview as u8); - if app.canvas.key == Some(key) && app.canvas.texture.is_some() { + let doc_key = (st.uid, std::sync::Arc::as_ptr(&st.doc) as u64, st.revision, preview); + // Overscan: 25% of the viewport on every side. + let (ow, oh) = (rect.width() * 0.25, rect.height() * 0.25); + let want = |origin: Point| crate::Shown { + doc: doc_key, + origin: Point::new(origin.x - ow as f64 / xf.zoom, origin.y - oh as f64 / xf.zoom), + zoom: xf.zoom, + size: (rect.width() + 2.0 * ow, rect.height() + 2.0 * oh), + }; + let target = want(xf.origin); + // Does the current texture cover the viewport at this zoom and document state? + let covers = |s: &crate::Shown| { + s.doc == doc_key && (s.zoom - xf.zoom).abs() < 1e-9 && { + let x0 = (xf.origin.x - s.origin.x) * s.zoom; + let y0 = (xf.origin.y - s.origin.y) * s.zoom; + x0 >= -0.5 && y0 >= -0.5 && x0 + rect.width() as f64 <= s.size.0 as f64 + 0.5 && y0 + rect.height() as f64 <= s.size.1 as f64 + 0.5 + } + }; + // Collect finished background renders. + #[cfg(not(target_arch = "wasm32"))] + if let (Some(w), Some((tok, shown))) = (app.canvas.worker.as_ref(), app.canvas.pending) + && let Some(done) = w.poll() + && done.token == tok + { + upload(app, ctx, done.image); + app.canvas.shown = Some(shown); + app.canvas.pending = None; + app.perf.render_ms = done.ms; + } + if app.canvas.shown.is_some_and(|s| covers(&s)) { return; } - let t0 = crate::now_ms(); - let placed: Vec = - layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, offset: s.offset }).collect(); - let view = xf.affine(ppp, rect.min); - let opts = designcraft_render::RenderOptions { printing_only: preview, greek_below_px: 3.0 * ppp, ..Default::default() }; - let img = app.canvas.renderer.render(&st.doc, &app.session.cache, &placed, w, h, view, &opts); - let ci = egui::ColorImage::from_rgba_premultiplied([w as usize, h as usize], &img.pixels); + let doc_changed = app.canvas.shown.is_none_or(|s| s.doc != doc_key); + let zoom_same = app.canvas.shown.is_some_and(|s| (s.zoom - xf.zoom).abs() < 1e-9); + let job = |app: &DesignApp| { + let st = app.session.active().expect("doc"); + let placed: Vec = + layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, offset: s.offset }).collect(); + let w = (target.size.0 as f64 * ppp).round().max(1.0) as u32; + let h = (target.size.1 as f64 * ppp).round().max(1.0) as u32; + let view = Affine::scale(ppp) * Affine::scale(xf.zoom) * Affine::translate((-target.origin.x, -target.origin.y)); + let opts = designcraft_render::RenderOptions { printing_only: preview, greek_below_px: 3.0 * ppp, ..Default::default() }; + (st.doc.clone(), placed, w, h, view, opts) + }; + // Synchronous path: document edits (keep editing crisp), first frame, or no worker. + #[cfg(not(target_arch = "wasm32"))] + if !app.canvas.worker_started { + app.canvas.worker_started = true; + app.canvas.worker = render_worker_spawn(ctx); + } + #[cfg(not(target_arch = "wasm32"))] + let has_worker = app.canvas.worker.is_some(); + #[cfg(target_arch = "wasm32")] + let has_worker = false; + if !has_worker || app.canvas.shown.is_none() || (doc_changed && zoom_same) { + let t0 = crate::now_ms(); + let (doc, placed, w, h, view, opts) = job(app); + let img = app.canvas.renderer.render(&doc, &app.session.cache, &placed, w, h, view, &opts); + upload(app, ctx, img); + app.canvas.shown = Some(target); + app.canvas.pending = None; + app.perf.render_ms = crate::now_ms() - t0; + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + // Already rendering this view? + if app.canvas.pending.is_some_and(|(_, p)| { + p.doc == doc_key && (p.zoom - xf.zoom).abs() < 1e-9 && { + let x0 = (xf.origin.x - p.origin.x) * p.zoom; + let y0 = (xf.origin.y - p.origin.y) * p.zoom; + x0 >= 0.0 && y0 >= 0.0 && x0 + rect.width() as f64 <= p.size.0 as f64 && y0 + rect.height() as f64 <= p.size.1 as f64 + } + }) { + return; + } + let (doc, placed, w, h, view, opts) = job(app); + app.canvas.token += 1; + let token = app.canvas.token; + if let Some(wk) = app.canvas.worker.as_ref() { + wk.submit(crate::render_worker::Job { token, doc, cache: app.session.cache.clone(), placed, w, h, view, opts }); + } + app.canvas.pending = Some((token, target)); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn render_worker_spawn(ctx: &egui::Context) -> Option { + if std::env::var_os("DESIGNCRAFT_SYNC_RENDER").is_some() { + return None; + } + crate::render_worker::Worker::spawn(ctx.clone()) +} + +fn upload(app: &mut DesignApp, ctx: &egui::Context, img: designcraft_render::Rendered) { + let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); match &mut app.canvas.texture { Some(tex) => tex.set(ci, egui::TextureOptions::LINEAR), None => app.canvas.texture = Some(ctx.load_texture("canvas", ci, egui::TextureOptions::LINEAR)), } - app.canvas.key = Some(key); - app.perf.render_ms = crate::now_ms() - t0; } fn dashed(painter: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke, dash: f32, gap: f32) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5240fc0..e148ae2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod dock; pub mod icons; pub mod menus; pub mod panels; +pub mod render_worker; pub mod story_editor; pub mod theme; pub mod toolbar; @@ -141,10 +142,27 @@ impl Default for View { } } +/// What the canvas texture currently shows. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Shown { + /// (doc uid, doc pointer, revision, preview). + pub doc: (u64, u64, u64, bool), + /// Canvas point at the texture's top-left, zoom, and texture size in screen points. + pub origin: Point, + pub zoom: f64, + pub size: (f32, f32), +} + pub struct CanvasCache { pub renderer: designcraft_render::Renderer, pub texture: Option, - pub key: Option<(u64, u64, u64, u64, u64, u32, u32, u8)>, + pub shown: Option, + /// The request in flight on the worker. + pub pending: Option<(u64, Shown)>, + pub token: u64, + #[cfg(not(target_arch = "wasm32"))] + pub worker: Option, + pub worker_started: bool, pub last_ms: f64, } @@ -190,7 +208,17 @@ impl DesignApp { ui: UiState::default(), services, views: HashMap::new(), - canvas: CanvasCache { renderer, texture: None, key: None, last_ms: 0.0 }, + canvas: CanvasCache { + renderer, + texture: None, + shown: None, + pending: None, + token: 0, + #[cfg(not(target_arch = "wasm32"))] + worker: None, + worker_started: false, + last_ms: 0.0, + }, canvas_rect: None, perf: Perf::default(), synthetic: vec![], diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs new file mode 100644 index 0000000..36c8016 --- /dev/null +++ b/crates/ui-egui/src/render_worker.rs @@ -0,0 +1,73 @@ +//! Background canvas rendering (native): a thread that owns its own `Renderer` and renders +//! requested regions while the UI keeps drawing the previous texture (shifted/scaled). +//! On wasm the canvas renders synchronously. + +use std::sync::Arc; + +use designcraft_compose::Cache; +use designcraft_doc::Document; +use designcraft_geom::Affine; +use designcraft_render::{Placed, RenderOptions, Rendered}; + +pub struct Job { + pub token: u64, + pub doc: Arc, + pub cache: Arc, + pub placed: Vec, + pub w: u32, + pub h: u32, + pub view: Affine, + pub opts: RenderOptions, +} + +pub struct Done { + pub token: u64, + pub image: Rendered, + pub ms: f64, +} + +#[cfg(not(target_arch = "wasm32"))] +pub struct Worker { + tx: std::sync::mpsc::Sender, + rx: std::sync::mpsc::Receiver, +} + +#[cfg(not(target_arch = "wasm32"))] +impl Worker { + pub fn spawn(ctx: egui::Context) -> Option { + let (tx, jobs) = std::sync::mpsc::channel::(); + let (done_tx, rx) = std::sync::mpsc::channel::(); + std::thread::Builder::new() + .name("designcraft-render".into()) + .spawn(move || { + let mut r = designcraft_render::Renderer::new(); + while let Ok(mut job) = jobs.recv() { + // Only the newest job matters. + while let Ok(newer) = jobs.try_recv() { + job = newer; + } + let t0 = crate::now_ms(); + let image = r.render(&job.doc, &job.cache, &job.placed, job.w, job.h, job.view, &job.opts); + if done_tx.send(Done { token: job.token, image, ms: crate::now_ms() - t0 }).is_err() { + break; + } + ctx.request_repaint(); + } + }) + .ok()?; + Some(Worker { tx, rx }) + } + pub fn submit(&self, job: Job) { + let _ = self.tx.send(job); + } + pub fn poll(&self) -> Option { + let mut last = None; + while let Ok(d) = self.rx.try_recv() { + last = Some(d); + } + last + } +} + +#[cfg(target_arch = "wasm32")] +pub struct Worker;