From 4484c2d1b023e7b058231dda20a157badd19f42b Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:24:37 +0300 Subject: [PATCH 1/2] perf(render): work out paint bounds once per render Culling a layer asked for its paint bounds, which walk its whole subtree, and then did the same for each child: every layer's subtree was walked again at each level above it, and once more in each parallel band. The bounds worked out during a render are now kept for it (layers with children only; bounds the 64-level cap cut short are not kept, and kept ones are read only where their levels still fit under the cap, so culling is exactly as before). Every render also looked through every layer of the document for the largest background blur, to size the overlap of its bands: 900 000 layers on one large file, on the main thread before the bands start. The document now works it out when first asked after a change, like whether it has masks. A 1440x900 render on a 900 000-layer file, page of 118 358 layers: at 100% 13.0 -> 0.6 ms, at 25% 16.5 -> 3.4 ms, fitted to the page 56 -> 31 ms; page of 246 204 layers: 12.6 -> 2.5 ms, 12.1 -> 2.0 ms, 69 -> 62 ms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/doc/src/lib.rs | 13 ++++-- crates/render/src/bounds_memo_tests.rs | 58 +++++++++++++++++++++++ crates/render/src/draw.rs | 65 ++++++++++++++++++++------ crates/render/src/lib.rs | 8 +++- crates/render/src/lod_tests.rs | 2 + crates/render/src/pattern.rs | 4 +- 6 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 crates/render/src/bounds_memo_tests.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 71c6da39..a757a6af 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -77,17 +77,19 @@ pub enum DocError { pub type DocResult = Result; -/// The set of touched node ids, and whether any layer is a mask (worked out when first asked -/// after a change: hit testing skips its mask checks without one). Never part of equality or +/// The set of touched node ids, whether any layer is a mask (hit testing skips its mask checks +/// without one) and the largest background blur radius (renders keep that much overlap between +/// their bands), both worked out when first asked after a change. Never part of equality or /// serialisation. #[derive(Clone, Debug, Default)] -pub struct Dirty(pub std::collections::BTreeSet, std::sync::OnceLock); +pub struct Dirty(pub std::collections::BTreeSet, std::sync::OnceLock, std::sync::OnceLock); impl Dirty { /// Record node `id` as touched (edited, added or removed). fn touch(&mut self, id: NodeId) { self.0.insert(id); self.1 = std::sync::OnceLock::new(); + self.2 = std::sync::OnceLock::new(); } } @@ -254,6 +256,11 @@ impl Document { *self.dirty.1.get_or_init(|| self.nodes.values().any(|n| n.is_mask)) } + /// The largest background blur or glass radius of any layer's effects (0 without any). + pub fn max_backdrop_radius(&self) -> f64 { + *self.dirty.2.get_or_init(|| self.nodes.values().flat_map(|n| n.effects.iter()).map(Effect::backdrop_radius).fold(0.0, f64::max)) + } + /// Node ids touched so far (without clearing). pub fn dirty(&self) -> &std::collections::BTreeSet { &self.dirty.0 diff --git a/crates/render/src/bounds_memo_tests.rs b/crates/render/src/bounds_memo_tests.rs new file mode 100644 index 00000000..d8539807 --- /dev/null +++ b/crates/render/src/bounds_memo_tests.rs @@ -0,0 +1,58 @@ +//! Paint bounds worked out once per render: the bounds kept for a layer are the ones worked out +//! without keeping any, and the largest background blur follows the document's edits. + +use super::*; +use figcraft_doc::{Effect, EffectKind}; + +#[test] +fn kept_paint_bounds_are_the_bounds_worked_out_afresh() { + // Unclipped frames nested past the 64-level walk, a rectangle at the bottom: bounds asked for + // deep first and then from the top, and the other way round, are those worked out alone. + let mut d = Document::new("deep"); + let p = d.first_page().unwrap(); + let mut chain = Vec::new(); + let mut parent = p; + for k in 0..80 { + let f = d.create(parent, NodeKind::Frame, &format!("F{k}"), 1.0, 1.0, 10.0, 10.0).unwrap(); + d.node_mut(f).unwrap().clip = false; + chain.push(f); + parent = f; + } + d.create(parent, NodeKind::Rectangle, "R", 500.0, 0.0, 10.0, 10.0).unwrap(); + 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 = draw::bounds_within(&d, n, depth, None, Some(&memo)).0; + assert_eq!(kept, draw::local_paint_bounds(&d, n, depth, None), "{} at depth {depth}", n.name); + } + } + } +} + +#[test] +fn the_largest_background_blur_follows_edits() { + let mut d = Document::new("blur"); + let p = d.first_page().unwrap(); + let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + assert_eq!(max_backdrop_blur(&d), 0.0); + let mut blur = Effect::background_blur(); + if let EffectKind::BackgroundBlur(b) = &mut blur.kind { + b.radius = 12.0; + } + d.node_mut(r).unwrap().effects.push(blur); + assert_eq!(max_backdrop_blur(&d), 12.0); + // A copy of the document keeps it, and an edit of the copy changes only the copy's. + let mut copy = d.clone(); + if let Some(EffectKind::BackgroundBlur(b)) = copy.node_mut(r).unwrap().effects.first_mut().map(|e| &mut e.kind) { + b.radius = 30.0; + } + assert_eq!(max_backdrop_blur(©), 30.0); + assert_eq!(max_backdrop_blur(&d), 12.0); + copy.node_mut(r).unwrap().effects.clear(); + assert_eq!(max_backdrop_blur(©), 0.0); + // A deleted layer's blur no longer counts. + d.remove(r).unwrap(); + assert_eq!(max_backdrop_blur(&d), 0.0); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 7bfafd33..0d53f390 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -33,8 +33,22 @@ pub struct Ctx<'a> { pub under: Option<&'a Under<'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, } +/// 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. +#[derive(Default)] +pub struct Bounds(std::cell::RefCell>); + +/// A layer, and the scale its text is greeked at (none: drawn in full). +type BoundsKey = (NodeId, Option); + +/// Deepest nesting the paint bounds walk follows. +const MAX_BOUNDS_DEPTH: usize = 64; + pub fn ts_transform(a: Affine) -> Transform { let c = a.as_coeffs(); Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32) @@ -279,32 +293,57 @@ fn text_box_bounds(n: &Node) -> Rect { /// Local bounds a node paints into (its box grown by stroke/effect reach, plus unclipped children). /// Text that overflows its box counts where its glyphs are, so it isn't culled while visible; /// text greeked at `greek_scale` (see [`lod::greeked`]) paints only inside [`text_box_bounds`]. -fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option) -> Rect { +pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option) -> Rect { + bounds_within(doc, n, depth, greek_scale, None).0 +} + +/// [`local_paint_bounds`], kept in and read from `memo` when there is one. Also returns how many +/// 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) { + // 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()) + && depth + levels <= MAX_BOUNDS_DEPTH + { + return (r, Some(levels)); + } let reach = n.paint_reach(); let mut r = n.local_rect().inflate(reach, reach); + let mut levels = Some(0); let clips = n.clip && n.kind.is_frame_like(); - if (!clips || n.kind.bounds_follow_children()) && depth < 64 && !n.hides_contents() { - for c in n.children.iter().filter_map(|c| doc.node(*c)) { - if !c.visible { - continue; + 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)) { + if !c.visible { + continue; + } + let (cb, c_levels) = bounds_within(doc, c, depth + 1, greek_scale, memo); + below = below.zip(c_levels).map(|(b, l)| b.max(l)); + r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb)); } - let cb = local_paint_bounds(doc, c, depth + 1, greek_scale); - r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb)); + levels = below.map(|b| b + 1); + } else if !n.children.is_empty() { + levels = None; } } // Text can overflow its box. if let NodeKind::Text(_) = n.kind { r = r.union(text_box_bounds(n).inflate(reach, reach)); - if greek_scale.is_some_and(|s| lod::greeked(n, s)) { - return r; - } - if let Some((l, dy)) = figcraft_text::layout_node(n) + if !greek_scale.is_some_and(|s| lod::greeked(n, s)) + && let Some((l, dy)) = figcraft_text::layout_node(n) && let Some(ink) = l.ink_bounds() { r = r.union(ink + figcraft_geom::kurbo::Vec2::new(0.0, dy)); } } - r + if let (Some(m), Some(l)) = (memo, levels) { + m.0.borrow_mut().insert(key, (r, l)); + } + (r, levels) } /// How far (document units) effects that read outside a node's paint bounds reach: blurs, glass, @@ -385,7 +424,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep } let xf = parent_xf * n.transform; let (pw, ph) = (pix.width(), pix.height()); - let local = local_paint_bounds(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale)); + let local = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)).0; let dev = device_rect(xf, local); // Effects that read outside (blur) need a margin when culling. Absurd effects (a spread or // blur of millions of pixels) keep a sane layer: what lies further off reads as empty. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9969a597..2a911076 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -90,7 +90,7 @@ fn new_pixmap(w: u32, h: u32) -> Option { /// The largest backdrop radius (background blur, glass: see [`figcraft_doc::Effect::backdrop_radius`]) /// in the document (0 without any). pub(crate) fn max_backdrop_blur(doc: &Document) -> f64 { - doc.nodes().flat_map(|n| n.effects.iter()).map(figcraft_doc::Effect::backdrop_radius).fold(0.0, f64::max) + doc.max_backdrop_radius() } /// How far (device pixels) a background blur of `radius` reads its backdrop at `scale`. @@ -170,6 +170,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti pix.fill(paint::ts_color(bg)); } let lod = opts.lod && !opts.outline_mode && view.scale <= lod::MAX_SCALE; + let bounds = draw::Bounds::default(); let ctx = draw::Ctx { doc, scale: view.scale, @@ -180,6 +181,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti lod, under: None, time: 0.0, + bounds: &bounds, }; let xf = view.transform(); let Some(p) = doc.node(page) else { return Some(pix) }; @@ -201,6 +203,7 @@ pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut /// [`render_nodes_into`] `time` seconds into animated GIFs. fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) { + let bounds = draw::Bounds::default(); let ctx = draw::Ctx { doc, scale: view.scale, @@ -211,6 +214,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim lod: false, under: None, time, + bounds: &bounds, }; for id in ids { draw::draw_node(&ctx, pix, *id, view.transform() * parent_transform(doc, *id), 0); @@ -434,6 +438,8 @@ mod band_tests; #[cfg(test)] mod boolean_cache_tests; #[cfg(test)] +mod bounds_memo_tests; +#[cfg(test)] mod effect_order_tests; #[cfg(test)] mod effects_behaviour_tests; diff --git a/crates/render/src/lod_tests.rs b/crates/render/src/lod_tests.rs index de8b52b4..a573ee7c 100644 --- a/crates/render/src/lod_tests.rs +++ b/crates/render/src/lod_tests.rs @@ -154,6 +154,7 @@ fn exports_never_use_lod() { crate::render_nodes_into(&d, &[card], &v, &mut exact); assert_eq!(png.data(), exact.data()); let mut preview = Pixmap::new(v.width, v.height).unwrap(); + let bounds = crate::draw::Bounds::default(); let ctx = crate::draw::Ctx { doc: &d, scale, @@ -164,6 +165,7 @@ fn exports_never_use_lod() { lod: true, under: None, time: 0.0, + bounds: &bounds, }; let parent = d.world_transform(p); crate::draw::draw_node(&ctx, &mut preview, card, v.transform() * parent, 0); diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index bfcf8e32..2489766c 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId}; use figcraft_geom::Affine; use tiny_skia::{FilterQuality, Pixmap, SpreadMode, Transform}; -use crate::draw::{Ctx, draw_node}; +use crate::draw::{Bounds, Ctx, draw_node}; use crate::paint::Prepared; /// The largest side of a cell's pixmap; a cell showing larger is drawn coarser. @@ -46,6 +46,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64 let (pw, ph) = ((cell.size.0 * k).ceil().clamp(1.0, MAX_CELL_PX), (cell.size.1 * k).ceil().clamp(1.0, MAX_CELL_PX)); let (kx, ky) = (pw / cell.size.0, ph / cell.size.1); let mut pix = Pixmap::new(pw as u32, ph as u32)?; + let bounds = Bounds::default(); let ctx = Ctx { doc, scale: kx.max(ky) * cell.scale, @@ -56,6 +57,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64 lod: false, under: None, time, + bounds: &bounds, }; let unrotated = src.transform.inverse(); nested(|| { 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 2/2] 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); } }