Files
figcraft/crates/ui-egui/src/perf_tests.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

200 lines
8.8 KiB
Rust

//! UI frame benchmarks on large documents (ignored by default; `cargo xtask bench` runs them in
//! release): the whole UI drawn headless through egui, timing idle frames, hovering, panning and a
//! layers panel with every screen expanded. Also the per-document-version caches that keep those
//! frames cheap: they must follow edits, undo, page switches and the layers panel's state.
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Instant;
use egui::{Event, Pos2, RawInput, Rect, vec2};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::Session;
use serde_json::json;
use crate::{FigApp, NoServices};
const SCREEN: (f32, f32) = (1920.0, 1080.0);
struct Bench {
ctx: egui::Context,
app: FigApp,
time: f64,
}
impl Bench {
fn new(layers: usize) -> Self {
figcraft_text::scan_system_fonts();
let (doc, _) = figcraft_engine::synthetic::large_document(layers).unwrap();
let mut b = Self { ctx: egui::Context::default(), app: FigApp::new(Session::with_document(doc), Box::new(NoServices)), time: 0.0 };
// Fonts install on the first frame; the next ones lay out and settle.
for _ in 0..4 {
b.frame(vec![]);
}
b
}
/// One UI frame with `events`; returns its wall time in ms.
fn frame(&mut self, events: Vec<Event>) -> f64 {
self.time += 1.0 / 60.0;
let input = RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(SCREEN.0, SCREEN.1))),
time: Some(self.time),
events,
..Default::default()
};
let t = Instant::now();
let app = &mut self.app;
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui));
let ms = t.elapsed().as_secs_f64() * 1000.0;
// No GPU here to take the texture uploads.
out.textures_delta.clear();
ms
}
/// Mean frame ms over `n` frames, events from `ev(i)`.
fn mean(&mut self, n: usize, ev: impl Fn(usize) -> Vec<Event>) -> f64 {
(0..n).map(|i| self.frame(ev(i))).sum::<f64>() / n as f64
}
fn canvas_point(&self, i: usize) -> Pos2 {
let r = self.app.canvas.rect;
let (fx, fy) = ((i * 7919 % 1000) as f32 / 1000.0, (i * 104_729 % 1000) as f32 / 1000.0);
Pos2::new(r.min.x + 20.0 + (r.width() - 40.0) * fx, r.min.y + 20.0 + (r.height() - 40.0) * fy)
}
}
fn report(layers: usize) {
let mut b = Bench::new(layers);
let mut rows: Vec<(&str, f64)> = Vec::new();
rows.push(("idle frame, nothing selected", b.mean(30, |_| vec![])));
let hover = |b: &Bench, i: usize| vec![Event::PointerMoved(b.canvas_point(i))];
let pts: Vec<Vec<Event>> = (0..60).map(|i| hover(&b, i)).collect();
rows.push(("hover over the canvas", b.mean(60, |i| pts[i].clone())));
let wheel = |d: f32| Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, d),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
};
let centre = b.app.canvas.rect.center();
rows.push(("pan (wheel) over the canvas", b.mean(60, |i| vec![Event::PointerMoved(centre), wheel(if i % 20 < 10 { -40.0 } else { 40.0 })])));
// One layer selected: the design panel shows its properties.
let screen = b.app.session.doc().ok().and_then(|d| d.node(b.app.session.page().ok()?)?.children.get(1).copied());
let first = screen.and_then(|s| b.app.session.doc().ok()?.node(s)?.children.last().copied());
b.app.session.set_selection(first.into_iter().collect());
rows.push(("idle frame, one layer selected", b.mean(30, |_| vec![])));
b.app.session.set_selection(vec![]);
b.app.ui.right_tab = crate::RightTab::Prototype;
rows.push(("idle frame, Prototype tab", b.mean(30, |_| vec![])));
b.app.ui.right_tab = crate::RightTab::Design;
// Every screen expanded in the layers panel.
let page = b.app.session.page().unwrap();
let screens = b.app.session.doc().unwrap().node(page).unwrap().children.clone();
b.app.ui.expanded.extend(screens);
b.frame(vec![]);
rows.push(("idle frame, every screen expanded in layers", b.mean(30, |_| vec![])));
let panel = Pos2::new(150.0, 600.0);
rows.push(("scroll the expanded layers panel", b.mean(60, |i| vec![Event::PointerMoved(panel), wheel(if i % 30 < 15 { -60.0 } else { 60.0 })])));
b.app.ui.layer_search = true;
b.app.ui.layer_filter = "item".into();
b.frame(vec![]);
rows.push(("idle frame, layers filtered by name", b.mean(30, |_| vec![])));
println!("UI frame ms — {layers} layers ({})", if cfg!(debug_assertions) { "debug" } else { "release" });
for (name, v) in rows {
println!("| {name:<44} | {v:>8.2} |");
}
}
#[test]
#[ignore = "benchmark: cargo xtask bench"]
fn perf_ui_frames() {
let sizes: Vec<usize> = std::env::var("FIGCRAFT_BENCH_LAYERS")
.ok()
.map(|s| s.split(',').filter_map(|v| v.trim().parse().ok()).collect())
.unwrap_or_else(|| vec![1000, 10_000]);
for n in sizes {
report(n);
}
}
#[test]
fn bench_harness_draws_frames() {
let mut b = Bench::new(200);
assert!(b.frame(vec![]) >= 0.0);
assert!(b.app.canvas.rect.width() > 100.0, "the canvas was laid out");
}
/// Layer rows through the panel's cache, in a frame of `ctx` (the cache lives in its memory).
fn rows(ctx: &egui::Context, doc: &Arc<Document>, page: NodeId, expanded: &BTreeSet<NodeId>, filter: &str) -> Arc<Vec<crate::layers::Row>> {
let mut out = Arc::new(Vec::new());
ctx.run_ui(RawInput::default(), |ui| out = crate::layers::rows_for(ui, doc, page, expanded, filter)).textures_delta.clear();
out
}
fn ids(rows: &[crate::layers::Row]) -> Vec<NodeId> {
rows.iter().map(|r| r.id).collect()
}
#[test]
fn layer_rows_follow_edits_undo_pages_expansion_and_filter() {
let ctx = egui::Context::default();
let mut s = Session::new();
let page = s.page().unwrap();
let frame = s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
let frame = NodeId(frame);
let rect = NodeId(
s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"]
.as_u64()
.unwrap(),
);
let mut expanded = BTreeSet::new();
let doc1 = s.ds().unwrap().doc.clone();
let first = rows(&ctx, &doc1, page, &expanded, "");
assert_eq!(ids(&first), vec![frame]);
assert!(Arc::ptr_eq(&first, &rows(&ctx, &doc1, page, &expanded, "")), "same inputs: the cached rows");
// Expanding shows the children.
expanded.insert(frame);
assert_eq!(ids(&rows(&ctx, &doc1, page, &expanded, "")), vec![frame, rect]);
// An edit adds a row; undo takes it away again.
let ell =
NodeId(s.execute("node.create", &json!({"type": "ellipse", "x": 200, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap());
let doc2 = s.ds().unwrap().doc.clone();
assert_eq!(ids(&rows(&ctx, &doc2, page, &expanded, "")), vec![ell, frame, rect]);
s.undo().unwrap();
let undone = s.ds().unwrap().doc.clone();
assert_eq!(ids(&rows(&ctx, &undone, page, &expanded, "")), vec![frame, rect]);
// The filter lists matches only.
assert_eq!(ids(&rows(&ctx, &doc2, page, &expanded, "ellipse")), vec![ell]);
assert_eq!(ids(&rows(&ctx, &doc2, page, &expanded, "")), vec![ell, frame, rect]);
// Another page (added after the undo: no ellipse).
s.execute("page.add", &json!({})).unwrap();
let p2 = s.page().unwrap();
assert_ne!(p2, page);
let doc3 = s.ds().unwrap().doc.clone();
assert!(rows(&ctx, &doc3, p2, &expanded, "").is_empty());
assert_eq!(ids(&rows(&ctx, &doc3, page, &expanded, "")), vec![frame, rect]);
// Collapsing hides the children again.
expanded.clear();
assert_eq!(ids(&rows(&ctx, &doc3, page, &expanded, "")), vec![frame]);
}
#[test]
fn prototype_sources_follow_edits_and_undo() {
let ctx = egui::Context::default();
let mut s = Session::new();
let page = s.page().unwrap();
let id = |v: serde_json::Value| NodeId(v["id"].as_u64().unwrap());
let a = id(s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
let b = id(s.execute("node.create", &json!({"type": "frame", "x": 200, "y": 0, "width": 100, "height": 100})).unwrap());
let before = s.ds().unwrap().doc.clone();
assert!(crate::proto_canvas::sources(&ctx, &before, page).is_empty());
s.execute("proto.interaction.add", &json!({"ids": [a], "dest": b})).unwrap();
let after = s.ds().unwrap().doc.clone();
assert_eq!(*crate::proto_canvas::sources(&ctx, &after, page), vec![a]);
assert!(Arc::ptr_eq(&crate::proto_canvas::sources(&ctx, &after, page), &crate::proto_canvas::sources(&ctx, &after, page)));
s.undo().unwrap();
let undone = s.ds().unwrap().doc.clone();
assert!(crate::proto_canvas::sources(&ctx, &undone, page).is_empty());
}