M9: background canvas rendering with overscan (pan without re-render, scaled preview while zooming)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 11:43:50 -04:00
parent 91c1051b63
commit c2f80260a3
3 changed files with 208 additions and 23 deletions

View File

@ -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<designcraft_render::Placed> =
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<designcraft_render::Placed> =
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<crate::render_worker::Worker> {
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) {

View File

@ -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<egui::TextureHandle>,
pub key: Option<(u64, u64, u64, u64, u64, u32, u32, u8)>,
pub shown: Option<Shown>,
/// The request in flight on the worker.
pub pending: Option<(u64, Shown)>,
pub token: u64,
#[cfg(not(target_arch = "wasm32"))]
pub worker: Option<render_worker::Worker>,
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![],

View File

@ -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<Document>,
pub cache: Arc<Cache>,
pub placed: Vec<Placed>,
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<Job>,
rx: std::sync::mpsc::Receiver<Done>,
}
#[cfg(not(target_arch = "wasm32"))]
impl Worker {
pub fn spawn(ctx: egui::Context) -> Option<Self> {
let (tx, jobs) = std::sync::mpsc::channel::<Job>();
let (done_tx, rx) = std::sync::mpsc::channel::<Done>();
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<Done> {
let mut last = None;
while let Ok(d) = self.rx.try_recv() {
last = Some(d);
}
last
}
}
#[cfg(target_arch = "wasm32")]
pub struct Worker;