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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
This commit is contained in:
Andrii Volkov
2026-10-16 13:20:53 +03:00
co-authored by Claude Opus 5.5
parent 4484c2d1b0
commit 52eb736241
4 changed files with 155 additions and 25 deletions
+9 -3
View File
@@ -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<std::ops::Range<usize>> {
let kids = &n.children;
let node = |i: usize| kids.get(i).and_then(|k| self.node(*k));
let kids: Vec<Option<&Node>> = 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<std::ops::Range<usize>> {
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
+34
View File
@@ -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::<Vec<_>>(), 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);
}
}
}
}
+78 -17
View File
@@ -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<HashMap<BoundsKey, (Rect, usize)>>);
pub struct Bounds<'a> {
paint: std::cell::RefCell<IdMap<BoundsKey, (Rect, usize)>>,
pub(crate) splat: std::cell::RefCell<IdMap<NodeId, (bool, usize)>>,
nodes: std::cell::RefCell<IdMap<NodeId, Option<&'a Node>>>,
}
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<K, V> = HashMap<K, V, std::hash::BuildHasherDefault<IdHash>>;
/// 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<u64>);
@@ -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<f64>, memo: Option<&Bounds>) -> (Rect, Option<usize>) {
pub(crate) fn bounds_within<'a>(
doc: &'a Document,
n: &Node,
depth: usize,
greek_scale: Option<f64>,
memo: Option<&Bounds<'a>>,
) -> (Rect, Option<usize>) {
// 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<Option<&Node>> = 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);
}
}
}
+34 -5
View File
@@ -60,18 +60,47 @@ fn text_paints<'a>(n: &'a Node, t: &'a TextData) -> impl Iterator<Item = &'a Pai
/// Whether the subtree of `n` can be splatted: no image or pattern paints (their colours aren't known
/// without sampling them) and no masks (which change what their siblings show).
pub fn splattable(doc: &Document, n: &Node, depth: usize) -> 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<usize>) {
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);
}
}