From 52eb736241b7b1d33c4c24b2dc2e93cf54928230 Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:20:53 +0300 Subject: [PATCH] perf(render): look each layer up once per render Node lookups in the document's B-tree were still a third of a render on a 900 000-layer file: a small subtree was walked for its bounds, again to see whether it could be splatted, and again to splat it, and each child of a drawn layer was looked up by paint_runs, by the mask check and by draw_node. - The render's table also keeps the layers it looked up, keyed with a multiply hash instead of SipHash (ids are counters), and so do the bounds it keeps. - draw_children looks each child up once, works out the paint runs from those (Document::paint_runs_of) and draws the child it has. - Whether a layer can be splatted is kept like its bounds: layers with children only, answers the nesting cap decided aren't kept, and kept answers are read only where their levels fit under the cap. Ctx gets a second lifetime, for the document the kept layers come from. Single-threaded cycles per 1440x900 render fitted to the page: 0.39 -> 0.34 G on a page of 246 204 layers, 0.26 -> 0.22 G on one of 118 358. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/doc/src/lib.rs | 12 +++- crates/render/src/bounds_memo_tests.rs | 34 +++++++++ crates/render/src/draw.rs | 95 +++++++++++++++++++++----- crates/render/src/lod.rs | 39 +++++++++-- 4 files changed, 155 insertions(+), 25 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a757a6af..07700e8c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1124,8 +1124,14 @@ impl Document { /// mask with the layers above it up to the next mask, or a single layer. An auto layout frame /// whose first child paints on top paints the runs in reverse (see [`Node::paint_order`]). pub fn paint_runs(&self, n: &Node) -> Vec> { - let kids = &n.children; - let node = |i: usize| kids.get(i).and_then(|k| self.node(*k)); + let kids: Vec> = n.children.iter().map(|k| self.node(*k)).collect(); + Self::paint_runs_of(&kids, n.layout.as_ref().is_some_and(|l| l.reverse_z)) + } + + /// [`Self::paint_runs`] of children already looked up (`None`: not in the document), last run + /// first when `reverse_z`. + pub fn paint_runs_of(kids: &[Option<&Node>], reverse_z: bool) -> Vec> { + let node = |i: usize| kids.get(i).copied().flatten(); let mut runs = Vec::new(); let mut i = 0; while i < kids.len() { @@ -1138,7 +1144,7 @@ impl Document { runs.push(i..j); i = j; } - if n.layout.as_ref().is_some_and(|l| l.reverse_z) { + if reverse_z { runs.reverse(); } runs diff --git a/crates/render/src/bounds_memo_tests.rs b/crates/render/src/bounds_memo_tests.rs index d8539807..4d0b305b 100644 --- a/crates/render/src/bounds_memo_tests.rs +++ b/crates/render/src/bounds_memo_tests.rs @@ -56,3 +56,37 @@ fn the_largest_background_blur_follows_edits() { d.remove(r).unwrap(); assert_eq!(max_backdrop_blur(&d), 0.0); } + +#[test] +fn kept_splat_answers_are_the_answers_worked_out_afresh() { + use figcraft_color::{ImageScaleMode, Paint}; + // Groups nested past the 64-level walk, with an image-filled rectangle at the bottom of one + // branch and a plain one at the bottom of the other: answers asked for deep-first and + // top-first, at depths 0 and 20, are those worked out alone. + let mut d = Document::new("deep"); + let p = d.first_page().unwrap(); + let mut chain = Vec::new(); + for image in [false, true] { + let mut parent = p; + for k in 0..80 { + let g = d.create(parent, NodeKind::Frame, &format!("{image} {k}"), 0.0, 0.0, 10.0, 10.0).unwrap(); + d.node_mut(g).unwrap().clip = false; + chain.push(g); + parent = g; + } + let r = d.create(parent, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + if image { + d.node_mut(r).unwrap().fills = vec![Paint::image("missing", ImageScaleMode::Fill)]; + } + } + for order in [chain.iter().rev().copied().collect::>(), chain.clone()] { + let memo = draw::Bounds::default(); + for id in &order { + let n = d.node(*id).unwrap(); + for depth in [0, 20] { + let kept = lod::splattable_within(&d, n, depth, Some(&memo)).0; + assert_eq!(kept, lod::splattable(&d, n, depth), "{} at depth {depth}", n.name); + } + } + } +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 0d53f390..b1a81a56 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -16,8 +16,9 @@ use crate::{blur, grain, lod, shadow}; /// Shared state for one render. #[derive(Clone, Copy)] -pub struct Ctx<'a> { - pub doc: &'a Document, +/// `'d`: the document's (layers looked up for the render are kept that long), `'a`: the rest. +pub struct Ctx<'a, 'd> { + pub doc: &'d Document, /// Device pixels per document unit (for effect radii). pub scale: f64, pub outline_mode: bool, @@ -34,14 +35,59 @@ pub struct Ctx<'a> { /// Seconds into animated GIFs (0: their first frame; only the presenter plays them). pub time: f64, /// Paint bounds already worked out in this render. - pub bounds: &'a Bounds, + pub bounds: &'a Bounds<'d>, } -/// The local paint bounds worked out during one render, by node and greeking scale. Culling asks -/// for a node's bounds and then for each of its children's, so without them every layer's subtree -/// was walked again at each level above it. +/// What one render works out about layers with children, which a walk down their subtree finds: +/// their local paint bounds by greeking scale, and whether they can be splatted (see +/// [`lod::splattable`]). Culling asks for a node's and then for each of its children's, so without +/// them every layer's subtree was walked again at each level above it. Each answer keeps how many +/// levels its walk went down. +/// It also keeps the layers it looked up: the walks for culling, splatting and drawing go down the +/// same layers, and in a large document a lookup is a good part of a layer's cost. #[derive(Default)] -pub struct Bounds(std::cell::RefCell>); +pub struct Bounds<'a> { + paint: std::cell::RefCell>, + pub(crate) splat: std::cell::RefCell>, + nodes: std::cell::RefCell>>, +} + +impl<'a> Bounds<'a> { + /// Node `id` of `doc`, looked up once for the render. + pub(crate) fn node(&self, doc: &'a Document, id: NodeId) -> Option<&'a Node> { + if let Some(n) = self.nodes.borrow().get(&id) { + return *n; + } + let n = doc.node(id); + self.nodes.borrow_mut().insert(id, n); + n + } +} + +/// A hash map keyed by node ids, which need no SipHash: they are counters (or a peer's range of +/// them), and SipHash took a good part of a render. +type IdMap = HashMap>; + +/// Mixes each word in with a multiply (Fibonacci hashing), so ids that differ in low bits spread. +#[derive(Default)] +pub(crate) struct IdHash(u64); + +impl Hasher for IdHash { + fn finish(&self) -> u64 { + self.0 + } + fn write(&mut self, bytes: &[u8]) { + for b in bytes { + self.write_u64(u64::from(*b)); + } + } + fn write_u64(&mut self, v: u64) { + self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(0x9E37_79B9_7F4A_7C15); + } + fn write_usize(&mut self, v: usize) { + self.write_u64(v as u64); + } +} /// A layer, and the scale its text is greeked at (none: drawn in full). type BoundsKey = (NodeId, Option); @@ -301,11 +347,17 @@ pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_s /// levels the walk went down, or `None` when the nesting cap cut it short: such bounds depend on /// the depth they were asked at and aren't kept, and kept bounds are read only where their levels /// still fit under the cap. -pub(crate) fn bounds_within(doc: &Document, n: &Node, depth: usize, greek_scale: Option, memo: Option<&Bounds>) -> (Rect, Option) { +pub(crate) fn bounds_within<'a>( + doc: &'a Document, + n: &Node, + depth: usize, + greek_scale: Option, + memo: Option<&Bounds<'a>>, +) -> (Rect, Option) { // A layer without children is quicker to work out again than to look up. let memo = memo.filter(|_| !n.children.is_empty()); let key = (n.id, greek_scale.map(f64::to_bits)); - if let Some((r, levels)) = memo.and_then(|m| m.0.borrow().get(&key).copied()) + if let Some((r, levels)) = memo.and_then(|m| m.paint.borrow().get(&key).copied()) && depth + levels <= MAX_BOUNDS_DEPTH { return (r, Some(levels)); @@ -317,7 +369,7 @@ pub(crate) fn bounds_within(doc: &Document, n: &Node, depth: usize, greek_scale: if (!clips || n.kind.bounds_follow_children()) && !n.hides_contents() { if depth < MAX_BOUNDS_DEPTH { let mut below = Some(0); - for c in n.children.iter().filter_map(|c| doc.node(*c)) { + for c in n.children.iter().filter_map(|c| memo.map_or_else(|| doc.node(*c), |m| m.node(doc, *c))) { if !c.visible { continue; } @@ -341,7 +393,7 @@ pub(crate) fn bounds_within(doc: &Document, n: &Node, depth: usize, greek_scale: } } if let (Some(m), Some(l)) = (memo, levels) { - m.0.borrow_mut().insert(key, (r, l)); + m.paint.borrow_mut().insert(key, (r, l)); } (r, levels) } @@ -406,10 +458,17 @@ const MAX_EFFECT_MARGIN_PX: f64 = 2048.0; /// Draw node `id` onto `pix`, where `parent_xf` maps the parent's space to `pix` pixels. pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, depth: usize) { + if let Some(n) = ctx.doc.node(id) { + draw_layer(ctx, pix, n, parent_xf, depth); + } +} + +/// [`draw_node`] for a node already looked up. +fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: usize) { + let id = n.id; if depth > crate::MAX_EXPORT_DEPTH || ctx.skip.contains(&id) { return; } - let Some(n) = ctx.doc.node(id) else { return }; let outline = ctx.outline_mode || ctx.outlined.contains(&id); if n.is_mask || n.opacity <= 0.0 && n.visible { return; @@ -437,7 +496,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep draw_outline(&Ctx { outline_mode: true, ..*ctx }, pix, n, xf, depth); return; } - if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable(ctx.doc, n, 0) { + if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable_within(ctx.doc, n, 0, Some(ctx.bounds)).0 { lod::splat_subtree(ctx, pix, n, xf, None, 1.0, depth); return; } @@ -711,13 +770,15 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, fn draw_children(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) { let kids = &n.children; - for run in ctx.doc.paint_runs(n) { - let Some(&c) = kids.get(run.start) else { continue }; - if ctx.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) { + // Each child looked up once: in a large document a lookup is a good part of a layer's cost. + let nodes: Vec> = kids.iter().map(|c| ctx.bounds.node(ctx.doc, *c)).collect(); + for run in Document::paint_runs_of(&nodes, n.layout.as_ref().is_some_and(|l| l.reverse_z)) { + let (Some(&c), Some(Some(cn))) = (kids.get(run.start), nodes.get(run.start)) else { continue }; + if cn.is_mask && cn.visible { // Everything above the mask (until the next mask) is clipped by its alpha. draw_masked(ctx, pix, c, kids.get(run.start + 1..run.end).unwrap_or(&[]), xf, depth); } else { - draw_node(ctx, pix, c, xf, depth + 1); + draw_layer(ctx, pix, cn, xf, depth + 1); } } } diff --git a/crates/render/src/lod.rs b/crates/render/src/lod.rs index 6d742585..a5b3ddc1 100644 --- a/crates/render/src/lod.rs +++ b/crates/render/src/lod.rs @@ -60,18 +60,47 @@ fn text_paints<'a>(n: &'a Node, t: &'a TextData) -> impl Iterator bool { + splattable_within(doc, n, depth, None).0 +} + +/// [`splattable`], kept in and read from `memo` when there is one (layers with children only). +/// Also returns how many levels the walk went down, or `None` when the nesting cap decided it: such +/// answers depend on the depth they were asked at and aren't kept, and kept answers are read only +/// where their levels still fit under the cap. +pub(crate) fn splattable_within<'a>(doc: &'a Document, n: &Node, depth: usize, memo: Option<&crate::draw::Bounds<'a>>) -> (bool, Option) { if depth > MAX_DEPTH { - return false; + return (false, None); + } + let memo = memo.filter(|_| !n.children.is_empty()); + if let Some((ok, levels)) = memo.and_then(|m| m.splat.borrow().get(&n.id).copied()) + && depth + levels <= MAX_DEPTH + { + return (ok, Some(levels)); } let image = |p: &Paint| p.visible && matches!(p.kind, PaintKind::Image(_) | PaintKind::Pattern(_)); let own = match &n.kind { NodeKind::Text(t) => text_paints(n, t).any(image), _ => n.fills.iter().any(image), }; - if own || n.strokes.iter().any(image) { - return false; + let (mut ok, mut below) = (!(own || n.strokes.iter().any(image)), Some(0)); + if ok { + for c in n.children.iter().filter_map(|c| memo.map_or_else(|| doc.node(*c), |m| m.node(doc, *c))).filter(|c| c.visible) { + if c.is_mask { + ok = false; + break; + } + let (c_ok, c_levels) = splattable_within(doc, c, depth + 1, memo); + below = below.zip(c_levels).map(|(b, l)| b.max(l + 1)); + if !c_ok { + ok = false; + break; + } + } } - n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible).all(|c| !c.is_mask && splattable(doc, c, depth + 1)) + if let (Some(m), Some(l)) = (memo, below) { + m.splat.borrow_mut().insert(n.id, (ok, l)); + } + (ok, below) } /// A paint's average colour as straight RGBA (alpha times the paint's opacity); `None` for @@ -199,7 +228,7 @@ pub fn splat_subtree(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, clip: Op } else { clip }; - for c in n.paint_order().filter_map(|c| ctx.doc.node(c)) { + for c in n.paint_order().filter_map(|c| ctx.bounds.node(ctx.doc, c)) { splat_subtree(ctx, pix, c, xf * c.transform, clip, alpha, depth + 1); } }