Files
figcraft/crates/render/src/lib.rs
T
BFlatandClaude Opus 5.5 a5d5c265fd M7: performance: retained canvas renders, virtual layers panel, shared text layouts, cargo xtask bench (#35)
Large documents stay interactive: the canvas re-renders only what a pan uncovers or an edit
touches, the layers panel lays out only the rows in view, and per-frame work no longer walks the
whole page.

- Benchmark: `cargo xtask bench [SIZES]` builds synthetic pages (`figcraft_engine::synthetic`:
  screens with an auto layout header, a card group, button instances and shapes/text) at
  1k/10k/50k layers and prints engine/render timings (`figcraft-cli perf --layers 1000,10000`) and
  headless whole-UI frame timings (an ignored ui-egui test).
- Retained canvas rendering (`figcraft_render::Retained`, used by the render worker): views snap
  to whole device pixels, so a pan shifts the previous image in place and renders only the
  uncovered strips; an edit renders only where changed layers were and are
  (`Document::changed_ids` diffs the two versions skipping shared structure; ancestors that paint
  from their children's pixels - booleans, effects, masks - count whole; background blur reach
  and noise are allowed for). Zoom, page, options, font or page-level changes, or large changes,
  render the whole view as before. `ui.inspect` reports `canvas.renderWork`.
- The texture image is converted on the render thread, not the UI thread.
- Layers panel: only visible rows are laid out (stable ids while scrolling); rows are cached per
  document version, page, expanded set and filter.
- Frame labels and layout grids of off-screen top-level frames are skipped; the Prototype tab's
  connection sources are cached per document version.
- Text: the layout cache keys by a cheap fingerprint confirmed by `TextData` equality (no Debug
  formatting per lookup, 32k entries) and hands out shared layouts; a layout generation marks
  renders stale when fonts arrive. Overflowing text is culled by its glyphs, so it no longer
  vanishes while its box is off screen.

Before -> after (release, Windows, 1440x900 view, ms; 10k / 50k layers):
- move one layer, re-render @100%:     7.65 / 6.49  -> 0.18 / 0.81
- move one layer, re-render zoomed out: 11.95 / 24.10 -> 0.16 / 0.77
- pan 40 px @100%:                     7.53 / 6.82  -> 0.69 / 0.81
- pan 40 px zoomed out:                12.07 / 24.30 -> 1.62 / 4.17
- full render @100% / zoomed to fit:   7.78 / 44.22 -> 6.56 / 38.83 (10k @100%, 50k fit)
- UI frame, every screen expanded in layers: 6.16 / 39.07 -> 0.24 / 0.57
- UI frame, scrolling that panel:      6.11 / 40.18 -> 0.43 / 0.40
- UI frame, layers filtered by name:   1.03 / 6.92  -> 0.17 / 0.36
- UI frame, Prototype tab:             0.89 / 6.45  -> 0.18 / 0.80
- UI frame, idle:                      0.31 / 1.43  -> 0.11 / 0.39
Unchanged and within budget: single-layer edits through the engine ~0.01 ms, normalize after a
one-node edit < 0.01 ms, hit test 26 / 93 us. Left: save/load at 50k (~0.7 s each), zoomed-out
renders at 50k (~40 ms).

Tests: retained renders match full renders after pans, moves, recolours, hide/remove/add, undo,
masks, overflowing text, edits under a background blur and an inner-shadow/noise layer, a drop-
shadowed group, and a blur panned in from the view's edge; zoom/page/options/page background
render full; junk views don't panic. Layer-row and prototype-source caches follow edits, undo,
page switches, expansion and the filter. `changed_ids`, the text cache, and the perf CLI.

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

251 lines
9.7 KiB
Rust

//! The FigCraft renderer.
//!
//! [`render_view`] draws a page region at any zoom into a premultiplied RGBA pixmap (the canvas);
//! [`render_node`] rasterises one node for export at a scale. Everything is CPU (tiny-skia) so it
//! runs identically on desktop, in tests and in the browser.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod draw;
pub mod fx;
pub mod images;
pub mod paint;
pub mod retained;
pub use draw::{node_outline, paint_bounds, subtree_outline, ts_path};
pub use retained::{Retained, Work};
pub use tiny_skia::{FillRule, Pixmap};
use figcraft_color::Color;
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_geom::{Affine, Rect};
/// Which part of a page to draw, and how big.
#[derive(Clone, Debug)]
pub struct View {
/// Page coordinate shown at the pixmap's top-left.
pub origin: (f64, f64),
/// Device pixels per page unit.
pub scale: f64,
pub width: u32,
pub height: u32,
}
impl View {
pub fn transform(&self) -> Affine {
Affine::scale(self.scale) * Affine::translate((-self.origin.0, -self.origin.1))
}
pub fn page_rect(&self) -> Rect {
let s = self.scale.max(1e-9);
Rect::new(self.origin.0, self.origin.1, self.origin.0 + f64::from(self.width) / s, self.origin.1 + f64::from(self.height) / s)
}
}
#[derive(Clone, Debug, Default)]
pub struct Options {
/// Fill the background (None = transparent, e.g. export without page colour).
pub background: Option<Color>,
pub outline_mode: bool,
/// Nodes to leave out (an editor drawing them itself).
pub skip: Vec<NodeId>,
}
fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
let w = w.clamp(1, 16_384);
let h = h.clamp(1, 16_384);
Pixmap::new(w, h)
}
/// The largest visible background blur radius in the document (0 without any).
pub(crate) fn max_backdrop_blur(doc: &Document) -> f64 {
doc.nodes()
.flat_map(|n| n.effects.iter())
.filter(|e| e.visible)
.filter_map(|e| match e.kind {
figcraft_doc::EffectKind::BackgroundBlur { radius } if radius.is_finite() => Some(radius),
_ => None,
})
.fold(0.0, f64::max)
}
/// How far (device pixels) a background blur of `radius` reads its backdrop at `scale`.
pub(crate) fn backdrop_reach(radius: f64, scale: f64) -> f64 {
// The three box passes of a blur with sigma = radius / 2 reach at most 3 sigma + 3 pixels.
if radius > 0.0 { radius * 1.5 * scale + 3.0 } else { 0.0 }
}
/// Render a page region, splitting it into horizontal bands rendered in parallel (desktop).
pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
#[cfg(not(target_arch = "wasm32"))]
{
let threads = rayon::current_num_threads().clamp(1, 16) as u32;
let bands = threads.min(view.height / 96).max(1);
if bands > 1
&& view.width.saturating_mul(view.height) > 300_000
&& let Some(p) = render_bands(doc, page, view, opts, bands)
{
return Some(p);
}
}
render_view_single(doc, page, view, opts)
}
/// Render `view` as `bands` horizontal bands in parallel. Bands overlap by what background blurs
/// read and each keeps only its own rows, so a backdrop never ends at a band's edge. `None` when a
/// blur reaches past a whole band (every band would render most of the view: render it in one
/// piece instead).
#[cfg(not(target_arch = "wasm32"))]
fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands: u32) -> Option<Pixmap> {
use rayon::prelude::*;
let band_h = view.height.div_ceil(bands.max(1));
let overlap = backdrop_reach(max_backdrop_blur(doc), view.scale).ceil();
if !overlap.is_finite() || overlap > f64::from(band_h) {
return None;
}
let overlap = overlap as u32;
let s = view.scale.max(1e-9);
let parts: Vec<(u32, Option<Pixmap>)> = (0..bands)
.into_par_iter()
.map(|i| {
let y0 = i * band_h;
let h = band_h.min(view.height.saturating_sub(y0)).max(1);
let top = y0.saturating_sub(overlap);
let bottom = (y0 + h + overlap).min(view.height).max(y0 + h);
let v = View { origin: (view.origin.0, view.origin.1 + f64::from(top) / s), scale: view.scale, width: view.width, height: bottom - top };
let part = render_view_single(doc, page, &v, opts);
let own = tiny_skia::IntRect::from_xywh(0, (y0 - top) as i32, view.width.clamp(1, 16_384), h);
(y0, if overlap == 0 { part } else { part.zip(own).and_then(|(p, r)| p.clone_rect(r)) })
})
.collect();
let mut out = new_pixmap(view.width, view.height)?;
for (y0, part) in parts {
let Some(part) = part else { continue };
out.draw_pixmap(
0,
y0 as i32,
part.as_ref(),
&tiny_skia::PixmapPaint { blend_mode: tiny_skia::BlendMode::Source, ..Default::default() },
tiny_skia::Transform::identity(),
None,
);
}
Some(out)
}
/// Render a page region on the calling thread.
pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
let mut pix = new_pixmap(view.width, view.height)?;
let bg = opts.background.or_else(|| match doc.node(page).map(|p| &p.kind) {
Some(NodeKind::Page { background, .. }) => Some(*background),
_ => None,
});
if let Some(bg) = bg {
pix.fill(paint::ts_color(bg));
}
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, skip: &opts.skip };
let xf = view.transform();
let Some(p) = doc.node(page) else { return Some(pix) };
for c in &p.children {
draw::draw_node(&ctx, &mut pix, *c, xf, 0);
}
Some(pix)
}
/// Render several nodes (in z-order) into an existing pixmap through `view`.
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, skip: &[] };
for id in ids {
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
draw::draw_node(&ctx, pix, *id, view.transform() * parent_xf, 0);
}
}
/// The page-space rectangle a node exports: a clipping frame's box (plus its own effects), else
/// everything it paints.
pub fn export_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
let n = doc.node(id)?;
if n.kind.is_frame_like() && n.clip {
let reach = n.paint_reach();
return Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), n.local_rect().inflate(reach, reach)));
}
doc.world_render_bounds(id)
}
/// Rasterise one node at `scale` (export). Transparent background.
pub fn render_node(doc: &Document, id: NodeId, scale: f64) -> Option<Pixmap> {
let b = export_bounds(doc, id)?;
let scale = if scale.is_finite() && scale > 0.0 { scale.min(64.0) } else { 1.0 };
let w = (b.width() * scale - 1e-6).ceil().max(1.0);
let h = (b.height() * scale - 1e-6).ceil().max(1.0);
if w > 16_384.0 || h > 16_384.0 {
return None;
}
let view = View { origin: (b.x0, b.y0), scale, width: w as u32, height: h as u32 };
let mut pix = new_pixmap(view.width, view.height)?;
render_nodes_into(doc, &[id], &view, &mut pix);
Some(pix)
}
/// Un-premultiply into straight RGBA8 bytes.
pub fn to_rgba8(p: &Pixmap) -> Vec<u8> {
let mut out = Vec::with_capacity(p.data().len());
for px in p.data().as_chunks::<4>().0 {
let a = px.get(3).copied().unwrap_or(0);
if a == 0 {
out.extend_from_slice(&[0, 0, 0, 0]);
} else {
let un = |c: u8| ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8;
out.extend_from_slice(&[
un(px.first().copied().unwrap_or(0)),
un(px.get(1).copied().unwrap_or(0)),
un(px.get(2).copied().unwrap_or(0)),
a,
]);
}
}
out
}
/// Encode as PNG.
pub fn encode_png(p: &Pixmap) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let mut out = Vec::new();
let enc = image::codecs::png::PngEncoder::new(&mut out);
image::ImageEncoder::write_image(enc, &rgba, p.width(), p.height(), image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?;
Ok(out)
}
/// Encode as JPEG over `background` (JPEG has no alpha).
pub fn encode_jpeg(p: &Pixmap, background: Color, quality: u8) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let bg = background.to_rgba8();
let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3);
for px in rgba.as_chunks::<4>().0 {
let a = u32::from(px.get(3).copied().unwrap_or(0));
for c in 0..3 {
let s = u32::from(px.get(c).copied().unwrap_or(0));
let b = u32::from(bg.get(c).copied().unwrap_or(255));
rgb.push(((s * a + b * (255 - a) + 127) / 255) as u8);
}
}
let mut out = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
image::ImageEncoder::write_image(enc, &rgb, p.width(), p.height(), image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
Ok(out)
}
/// Encode as lossless WebP.
pub fn encode_webp(p: &Pixmap) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let mut out = Vec::new();
let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut out);
image::ImageEncoder::write_image(enc, &rgba, p.width(), p.height(), image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?;
Ok(out)
}
#[cfg(test)]
mod band_tests;
#[cfg(test)]
mod retained_tests;
#[cfg(test)]
mod tests;