diff --git a/frontend/playwright/scripts/render-budget-compare.py b/frontend/playwright/scripts/render-budget-compare.py new file mode 100644 index 0000000000..c09b452c63 --- /dev/null +++ b/frontend/playwright/scripts/render-budget-compare.py @@ -0,0 +1,248 @@ +"""Compare two render-budget-perf runs. + + python3 playwright/scripts/render-budget-compare.py \ + perf-traces/render-budget-before.json perf-traces/render-budget-after.json + +Reads the JSON written by playwright/ui/render-wasm-specs/render-budget-perf.spec.js. + +Two kinds of numbers, in this order of trust: + +COUNTERS are exact repaint counts read out of WASM (tiles painted, shape paints, +walker visits, paragraph builds, composited pixels...). On identical gestures +they do not drift, so any delta is a real algorithmic change. This is the section +to read when judging a render-pipeline change. + +SYNC / rAF rows are `_render` wall times: SYNC ran on the input/timer path +(pointerup, debounce), rAF inside a frame callback. Useful for latency, but noisy +across machines and thermal states — do not conclude anything from a <15% move. +""" + +import json +import sys + +KEYS = ("n", "mean", "p50", "p95", "max", "total") +# n is a count, the rest are milliseconds. +LOWER_IS_BETTER = ("mean", "p50", "p95", "max", "total") + +# Counters worth printing, in reading order. Everything else in the file is +# still there for ad-hoc digging. +COUNTER_ROWS = ( + ("tiles_painted", "tiles actually walked + composited"), + ("tiles_cache_hit", "tiles served from the texture cache"), + ("tiles_invalidated", "single-tile evictions"), + ("tile_cache_wipes", "whole-cache wipes"), + ("tiles_discarded_inflight", "partial tiles thrown away by a restart"), + ("shape_paints", "render_shape calls"), + ("shape_paints_direct", "of those, straight into the tile"), + ("walker_visits", "tree nodes visited"), + ("walker_culled", "visits that painted nothing"), + ("surface_stack_composites", "full surface-stack composites"), + ("surface_stack_draw_px", "pixels moved by those composites"), + ("surface_stack_clear_px", "pixels cleared after them"), + ("paragraph_builds", "paragraph builder groups built"), + ("text_layouts", "text build + Skia layout runs"), + ("doc_atlas_writes", "Current -> DocAtlas blits"), + ("tile_atlas_writes", "Current -> tile atlas blits"), + ("cache_surface_writes", "Current -> legacy Cache blits (dead)"), + ("tile_atlas_snapshots", "full tile-atlas snapshots"), + ("tile_atlas_snapshot_px", "pixels in those snapshots"), + ("render_loop_starts", "renders restarted from tile zero"), + ("render_loop_continues", "renders resumed"), + ("partial_yields", "budget yields"), + ("frame_presents", "frames presented"), +) + +# Direction of "good" per counter. Default is lower-is-better (less work). +# `higher`: more of this means work was avoided. `neutral`: the number is +# diagnostic, not a score — a move is worth looking at, not celebrating. +COUNTER_DIRECTION = { + "tiles_cache_hit": "higher", + "tile_cache_hit_ratio": "higher", + "culled_ratio": "higher", + "walker_culled": "higher", + "shape_paints_direct": "neutral", + "layered_paint_ratio": "neutral", + "empty_tile_ratio": "neutral", + "frame_presents": "neutral", + "render_loop_starts": "neutral", + "render_loop_continues": "neutral", + "partial_yields": "neutral", + "doc_atlas_writes": "neutral", + "tile_atlas_writes": "neutral", + "tile_atlas_snapshots": "neutral", + "tiles_painted_per_present": "neutral", + # Should hold steady across a change that only removes redundant work; if it + # moves, something is being skipped or repainted that was not before. + "tiles_painted": "neutral", +} + +RATIO_ROWS = ( + ("shape_paints_per_tile", "shape paints per tile painted"), + ("walker_visits_per_tile", "tree visits per tile painted"), + ("culled_ratio", "share of visits that painted nothing"), + ("layered_paint_ratio", "share of paints needing the surface stack"), + ("composite_px_per_tile", "composite px per tile (512 tile = 262144 px)"), + ("paragraph_builds_per_tile", "paragraph builds per tile"), + ("text_layouts_per_tile", "text layouts per tile"), + ("tile_cache_hit_ratio", "cache hits / (hits + repaints)"), + ("tiles_painted_per_present", "tiles painted per presented frame"), + ("empty_tile_ratio", "share of tiles with no shapes (>0.5 = bad gestures)"), +) + + +def load(path): + with open(path) as fh: + return json.load(fh) + + +def get(stat, key): + if not stat or not stat.get("n"): + return 0 + return stat.get(key, 0) + + +def pct(before, after): + """Signed percentage change, or None when there is no baseline.""" + if before == 0: + return None + return (after - before) / before * 100 + + +def num(v): + if isinstance(v, float) and not v.is_integer(): + return f"{v:.2f}" + if abs(v) >= 1e6: + return f"{v / 1e6:.1f}M" + return f"{v:g}" + + +def delta(before, after, key): + b, a = get(before, key), get(after, key) + if b == 0 and a == 0: + return "" + p = pct(b, a) + if p is None: + return f" (new {a:g})" + sign = "+" if p >= 0 else "" + mark = "" + if key in LOWER_IS_BETTER and abs(p) >= 10: + mark = " better" if p < 0 else " WORSE" + return f" {sign}{p:.0f}%{mark}" + + +def row(label, before, after): + cells = [] + for k in KEYS: + b, a = get(before, k), get(after, k) + cells.append(f"{b:>9g} -> {a:<9g}") + print(f" {label:<10}" + "".join(f"{c:<22}" for c in cells)) + print(f" {'':<10}" + "".join(f"{delta(before, after, k):<22}" for k in KEYS)) + + +def counter_table(title, rows, before, after, threshold): + """Prints one exact-count table. `before`/`after` are flat name -> number.""" + if not before and not after: + return + if before.get("error") or after.get("error"): + print(f" {title}: unavailable ({before.get('error') or after.get('error')})") + return + + print(f" {title}") + for key, description in rows: + b = before.get(key, 0) + a = after.get(key, 0) + if b == 0 and a == 0: + continue + p = pct(b, a) + direction = COUNTER_DIRECTION.get(key, "lower") + if p is None: + change = f"new {num(a)}" + else: + change = f"{'+' if p >= 0 else ''}{p:.0f}%" + # Counts are exact: a small move is a real move, not noise. Only + # call it out past `threshold` so the table stays readable. + if abs(p) >= threshold: + if direction == "neutral": + change += " check" + else: + improved = p < 0 if direction == "lower" else p > 0 + change += " better" if improved else " WORSE" + print( + f" {key:<28}{num(b):>12} -> {num(a):<12}{change:<16}{description}" + ) + print() + + +def main(): + if len(sys.argv) != 3: + print(__doc__) + sys.exit(2) + + a, b = load(sys.argv[1]), load(sys.argv[2]) + + print(f"\nBEFORE {a['label']:<12} rev={a['rev']:<18} {a['date']}") + print(f"AFTER {b['label']:<12} rev={b['rev']:<18} {b['date']}") + print(f"file {a['file']}") + if a["file"] != b["file"]: + print(f" !! AFTER used a different file: {b['file']}") + if a["options"] != b["options"]: + print(f" !! gesture options differ, runs are not comparable") + print(f" before {a['options']}") + print(f" after {b['options']}") + + names = list(dict.fromkeys(list(a["phases"]) + list(b["phases"]))) + for name in names: + pa = a["phases"].get(name, {}) + pb = b["phases"].get(name, {}) + sa, sb = pa.get("summary", {}), pb.get("summary", {}) + print(f"\n{'=' * 72}\n{name}\n{'=' * 72}") + + # A phase where WASM raised did less work than the gestures asked for, + # so its counters understate. Say so before anyone reads a delta off it. + for tag, p in (("before", pa), ("after", pb)): + errs = p.get("wasmErrors") or [] + if errs: + print(f" !! [{tag}] {len(errs)} wasm error(s): {errs[0][:120]}") + + counter_table( + "COUNTS (exact)", + COUNTER_ROWS, + pa.get("counters") or {}, + pb.get("counters") or {}, + threshold=5, + ) + counter_table( + "PER-TILE RATIOS (exact)", + RATIO_ROWS, + pa.get("ratios") or {}, + pb.get("ratios") or {}, + threshold=5, + ) + + print(" TIMES (noisy)") + header = "".join(f"{k:<22}" for k in KEYS) + print(f" {'':<10}{header}") + row("SYNC", sa.get("sync"), sb.get("sync")) + row("rAF", sa.get("raf"), sb.get("raf")) + + for tag, p in (("before", sa), ("after", sb)): + w = p.get("worstSync") + if w: + print( + f" worst sync [{tag}] {w['ms']}ms flags={w['flags']} " + f"frameType={w['frame']} @{w.get('caller') or '?'}" + ) + + sync_a, sync_b = get(sa.get("sync"), "total"), get(sb.get("sync"), "total") + raf_a, raf_b = get(sa.get("raf"), "total"), get(sb.get("raf"), "total") + print( + f" total render work {sync_a + raf_a:.1f}ms -> {sync_b + raf_b:.1f}ms" + f" (of which off the input path: " + f"{raf_a:.1f} -> {raf_b:.1f})" + ) + + print() + + +if __name__ == "__main__": + main() diff --git a/frontend/playwright/ui/render-wasm-specs/render-budget-perf.spec.js b/frontend/playwright/ui/render-wasm-specs/render-budget-perf.spec.js new file mode 100644 index 0000000000..2ce3083f6c --- /dev/null +++ b/frontend/playwright/ui/render-wasm-specs/render-budget-perf.spec.js @@ -0,0 +1,653 @@ +import { test } from "@playwright/test"; +import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; +import { execSync } from "node:child_process"; +import fs from "fs"; +import path from "path"; + +/** + * Render-budget and repaint-count capture. Not an assertion test: it drives a + * fixed set of gestures and records, per phase, how long each `_render` blocked + * (split by rAF vs the input/timer path) and the exact WASM render counters + * (render-wasm/src/render/counters.rs). + * + * Read the counters first — they are exact and do not drift between runs, while + * the millisecond numbers move with GPU/driver/thermal state. `empty_tile_ratio` + * says whether the gestures were over content at all. + * + * PERF_LABEL=before npx playwright test render-budget-perf \ + * --project render-wasm --workers=1 + * # ...change something, and rebuild the WASM... + * PERF_LABEL=after npx playwright test render-budget-perf \ + * --project render-wasm --workers=1 + * python3 playwright/scripts/render-budget-compare.py \ + * perf-traces/render-budget-before.json perf-traces/render-budget-after.json + * + * Without a rebuild between runs the second one silently measures the old .wasm. + * PERF_GET_FILE points at any `get-file` dump under playwright/data/, as long as + * it has no media assets: those stall on image fetches and + * `wasmSetObjectsFinished` never fires. + */ + +const LABEL = process.env.PERF_LABEL ?? "run"; +const GET_FILE = + process.env.PERF_GET_FILE ?? "render-wasm/get-file-shadows.json"; +const PAGE_NAME = process.env.PERF_PAGE_NAME ?? "Page 1"; +// `+` presses after zoom-to-fit, each exactly `min(z * 1.3, 200)` +// (data/workspace/zoom.cljs) => 5 puts the document at 3.7 viewports across. +// Not ctrl+wheel: a notch is 1.68x, `schedule-zoom!` compounds notches landing in +// the same rAF, and ~10 notches hit the 200x ceiling where the rest are no-ops. +const ZOOM_STEPS = Number(process.env.PERF_ZOOM_STEPS ?? 5); +const CYCLES = Number(process.env.PERF_CYCLES ?? 4); +const STEPS = Number(process.env.PERF_STEPS ?? 16); +const STEP_DELAY = Number(process.env.PERF_STEP_DELAY ?? 16); +const SETTLE = Number(process.env.PERF_SETTLE ?? 800); +// Pan amplitude per burst, as a fraction of the viewport: 0.4 crosses a 512px +// tile boundary while staying inside a document 3.7 viewports wide. +const PAN_TRAVEL = Number(process.env.PERF_PAN_TRAVEL ?? 0.4); +const ZOOM_NOTCHES = Number(process.env.PERF_ZOOM_NOTCHES ?? 3); +// Pinned: tile counts scale with viewport and DPR, so runs at different sizes +// are not comparable. +const VIEWPORT_W = Number(process.env.PERF_VIEWPORT_W ?? 1440); +const VIEWPORT_H = Number(process.env.PERF_VIEWPORT_H ?? 900); +const DPR = Number(process.env.PERF_DPR ?? 1); +// wheel-pan | drag-pan | zoom, comma separated. Default runs all three. +const PHASES = (process.env.PERF_PHASES ?? "wheel-pan,drag-pan,zoom") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + +// Headed is mandatory, not cosmetic: headless Chromium falls back to +// SwiftShader (CPU rasterizer), which makes every timing below meaningless. +// Same reasoning as zoom-perf.spec.js. +test.use({ + headless: false, + viewport: { width: VIEWPORT_W, height: VIEWPORT_H }, + deviceScaleFactor: DPR, +}); +test.setTimeout(Number(process.env.PERF_TIMEOUT ?? 300000)); + +/** + * Wraps the WASM `_render` export. Must be installed as an init script, before + * any page code runs. + * + * Hooks `WebAssembly.instantiate*` rather than the CLJS-side module object + * (`app.render_wasm.wasm.internal_module`): that path only resolves in a dev + * build, and `resources/public` may hold a release bundle, where shadow mangles + * every namespace. The FFI boundary is stable in both. + * + * Depth-counting rAF rather than a boolean: `request-render` schedules through + * timers/raf and a render can request the next one from inside the callback. + */ +const renderHook = () => { + const log = (globalThis.__renderLog = []); + globalThis.__renderHookInstalled = false; + + let depth = 0; + const raf = globalThis.requestAnimationFrame.bind(globalThis); + globalThis.requestAnimationFrame = (cb) => + raf((t) => { + depth++; + try { + return cb(t); + } finally { + depth--; + } + }); + + const seen = new WeakMap(); + const wrapExports = (exports) => { + if (!exports) return exports; + // Rust `#[no_mangle] pub extern "C" fn render` lands in the instance as + // `render`; the `_render` alias is added later by the Emscripten JS glue + // when it copies exports onto Module. + const key = ["render", "_render"].find( + (k) => typeof exports[k] === "function", + ); + if (!key) return exports; + if (seen.has(exports)) return seen.get(exports); + + // Keep the raw exports around so the test can read the WASM render + // counters (`perf_counter_*`). Same reason the hook lives here: this is the + // only handle on the instance that survives a release build. + globalThis.__wasmExports = exports; + + const orig = exports[key]; + const wrapped = function (ts, flags) { + const inRaf = depth > 0; + // Stack capture only for the calls under investigation; it is not free. + const stack = inRaf ? null : new Error().stack; + const t0 = performance.now(); + const r = orig.call(this, ts, flags); + log.push({ + ms: +(performance.now() - t0).toFixed(2), + flags, + raf: inRaf, + frame: r, + caller: stack + ?.split("\n") + .slice(2) + .find((l) => !/wrapped|renderHook/.test(l)) + ?.trim() + ?.replace(/^at\s+/, "") + ?.replace(/^.*\/(cljs-runtime|js)\//, ""), + }); + return r; + }; + + // A plain copy, never a Proxy: wasm exports are frozen, and a `get` trap must + // return the real value for a non-configurable data property, so proxying + // `render` throws a TypeError on every call. + const copy = Object.assign(Object.create(null), exports); + copy[key] = wrapped; + seen.set(exports, copy); + globalThis.__renderHookInstalled = true; + return copy; + }; + + const wrapInstance = (inst) => + inst instanceof WebAssembly.Instance + ? new Proxy(inst, { + get: (t, p, r) => + p === "exports" ? wrapExports(t.exports) : Reflect.get(t, p, r), + }) + : inst; + + const streaming = WebAssembly.instantiateStreaming; + if (streaming) { + WebAssembly.instantiateStreaming = (...args) => + streaming(...args).then((res) => ({ + module: res.module, + instance: wrapInstance(res.instance), + })); + } + const instantiate = WebAssembly.instantiate; + WebAssembly.instantiate = (...args) => + instantiate(...args).then((res) => + res instanceof WebAssembly.Instance + ? wrapInstance(res) + : { module: res.module, instance: wrapInstance(res.instance) }, + ); +}; + +const drain = (page) => + page.evaluate(() => { + const l = globalThis.__renderLog.slice(); + globalThis.__renderLog.length = 0; + return l; + }); + +/** + * Mirrors `render::counters::NAMES` (render-wasm/src/render/counters.rs), in + * order. `readCounters` asserts the length against `perf_counter_count()`, so a + * counter added on the Rust side without updating this list fails the run + * instead of silently shifting every label. + * + * Counts, unlike the millisecond stats above, are exact: they do not move + * between runs on the same gestures, which is what makes them the primary + * signal when comparing two builds. + */ +const COUNTER_NAMES = [ + "render_loop_starts", + "render_loop_continues", + "partial_yields", + "tiles_painted", + "tiles_cache_hit", + "tiles_empty_skipped", + "tiles_invalidated", + "tile_cache_wipes", + "tiles_discarded_inflight", + "walker_visits", + "walker_culled", + "shape_paints", + "shape_paints_direct", + "surface_stack_composites", + "surface_stack_draw_px", + "surface_stack_clear_px", + "paragraph_builds", + "text_layouts", + "doc_atlas_writes", + "tile_atlas_writes", + "cache_surface_writes", + "tile_atlas_snapshots", + "tile_atlas_snapshot_px", + "frame_presents", + "crop_entries_built", + "crop_blits", + "crop_rejected", + "shape_tile_updates", +]; + +// Emscripten exposes the Rust symbol as-is on the instance; the `_`-prefixed +// alias only exists on the Module object, which a release build mangles out of +// reach — hence the `?? _name` fallback everywhere below. +const readCounters = (page) => + page.evaluate((names) => { + const ex = globalThis.__wasmExports; + if (!ex) return { error: "no wasm exports captured" }; + const pick = (n) => ex[n] ?? ex[`_${n}`]; + const get = pick("perf_counter_get"); + const count = pick("perf_counter_count"); + if (typeof get !== "function" || typeof count !== "function") { + return { error: "perf_counter_* exports missing — rebuild the WASM" }; + } + const n = count(); + if (n !== names.length) { + return { + error: + `counter count mismatch: wasm=${n} spec=${names.length} — ` + + "COUNTER_NAMES is out of sync with render::counters::NAMES", + }; + } + const out = {}; + for (let i = 0; i < n; i++) out[names[i]] = get(i); + return out; + }, COUNTER_NAMES); + +const resetCounters = (page) => + page.evaluate(() => { + const ex = globalThis.__wasmExports; + const reset = ex?.perf_counters_reset ?? ex?._perf_counters_reset; + if (typeof reset === "function") reset(); + }); + +/** Reads the counters for a phase and zeroes them for the next one. */ +const drainCounters = async (page) => { + const counters = await readCounters(page); + await resetCounters(page); + return counters; +}; + +// Derived ratios: the numbers that actually answer "are we painting the same +// thing more than once". Kept out of the Rust side so they can change without +// a rebuild. +const derive = (c) => { + if (!c || c.error) return null; + const div = (a, b) => (b ? +(a / b).toFixed(2) : 0); + const tiles = c.tiles_painted; + return { + // Shape paints per tile painted. Grows with how much a shape's tile + // footprint is over-estimated (margin culling) and with per-tile root + // fan-out. + shape_paints_per_tile: div(c.shape_paints, tiles), + walker_visits_per_tile: div(c.walker_visits, tiles), + // Fraction of walked nodes that painted nothing. + culled_ratio: div(c.walker_culled, c.walker_visits), + // Shapes that needed the full 1024² surface stack instead of drawing + // straight into the tile. + layered_paint_ratio: div( + c.shape_paints - c.shape_paints_direct, + c.shape_paints, + ), + // Whole-surface pixels moved per tile painted (a 512² tile is 262144 px). + composite_px_per_tile: div( + c.surface_stack_draw_px + c.surface_stack_clear_px, + tiles, + ), + paragraph_builds_per_tile: div(c.paragraph_builds, tiles), + text_layouts_per_tile: div(c.text_layouts, tiles), + // Cache effectiveness: hits vs repaints, and how much was thrown away. + tile_cache_hit_ratio: div(c.tiles_cache_hit, c.tiles_cache_hit + tiles), + tiles_painted_per_present: div(tiles, c.frame_presents), + // Share of visited tiles that hold no shape at all. High means the gestures + // are running over blank canvas and the phase is not measuring anything — + // check the zoom/pan amplitudes before reading anything else. + empty_tile_ratio: div( + c.tiles_empty_skipped, + c.tiles_empty_skipped + c.tiles_cache_hit + tiles, + ), + }; +}; + +const stat = (v) => { + if (!v.length) return { n: 0 }; + const s = [...v].sort((a, b) => a - b); + const p = (q) => s[Math.min(s.length - 1, Math.round(q * (s.length - 1)))]; + return { + n: v.length, + mean: +(v.reduce((a, b) => a + b, 0) / v.length).toFixed(2), + p50: +p(0.5).toFixed(2), + p95: +p(0.95).toFixed(2), + max: +p(1).toFixed(2), + total: +v.reduce((a, b) => a + b, 0).toFixed(1), + }; +}; + +const summarize = (calls) => { + const sync = calls.filter((e) => !e.raf); + const rafs = calls.filter((e) => e.raf); + const byFlag = {}; + for (const e of sync) (byFlag[e.flags] ??= []).push(e.ms); + const worst = [...sync].sort((a, b) => b.ms - a.ms)[0] ?? null; + return { + sync: stat(sync.map((e) => e.ms)), + raf: stat(rafs.map((e) => e.ms)), + syncByFlag: Object.fromEntries( + Object.entries(byFlag).map(([f, v]) => [f, stat(v)]), + ), + worstSync: worst + ? { + ms: worst.ms, + flags: worst.flags, + frame: worst.frame, + caller: worst.caller, + } + : null, + }; +}; + +test(`render budget perf [${LABEL}]`, async ({ page }) => { + // A `_render` that raised did no work but still logs a cheap call, deflating + // the phase. Recorded per phase rather than failing: some are pre-existing. + const wasmErrors = []; + page.on("console", (msg) => { + if (msg.type() !== "error") return; + const text = msg.text(); + if (/wasm-error|wasm-critical|WASM Error/.test(text)) { + wasmErrors.push(text.slice(0, 300)); + } + }); + page.on("pageerror", (err) => { + if (/wasm/i.test(String(err))) wasmErrors.push(String(err).slice(0, 300)); + }); + const drainErrors = () => wasmErrors.splice(0, wasmErrors.length); + + await page.addInitScript(renderHook); + await WasmWorkspacePage.init(page); + await WasmWorkspacePage.mockConfigFlags(page, [ + "enable-feature-render-wasm", + "enable-render-wasm-dpr", + ]); + + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile(GET_FILE); + + await workspace.goToWorkspace({ pageName: PAGE_NAME }); + await workspace.waitForFirstRenderWithoutUI(); + // Not waitForIdle(): requestIdleCallback never fires while the progressive + // render loop keeps the main thread busy, and the test hangs to timeout. + await page.waitForTimeout(2000); + + const hooked = await page.evaluate(() => globalThis.__renderHookInstalled); + if (!hooked) { + throw new Error( + "_render was never wrapped — the renderer did not instantiate through " + + "WebAssembly.instantiate/instantiateStreaming, or the export was renamed", + ); + } + + // Everything up to here is the load and its first full render: every visible + // tile painted from an empty cache, no gesture, no cache hits. + const loadCounters = await drainCounters(page); + if (loadCounters.error) { + throw new Error(`render counters unavailable: ${loadCounters.error}`); + } + const loadCalls = await drain(page); + const loadErrors = drainErrors(); + const firstError = loadErrors.length ? `; first error: ${loadErrors[0]}` : ""; + if (loadCalls.length === 0) { + throw new Error( + `no _render calls recorded during load — the hook is not intercepting ` + + `the renderer${firstError}`, + ); + } + if (loadCounters.tiles_painted === 0) { + throw new Error( + "load phase painted no tiles — _render ran but the document has no " + + `shapes in any tile (fixture/page mismatch?)${firstError}`, + ); + } + + const box = await workspace.canvas.boundingBox(); + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + await page.mouse.move(cx, cy); + + // Same viewbox every run: fit the document, then zoom in a known factor + // toward the canvas centre (`increase-zoom` centres on the mouse, which is + // parked there). Content therefore surrounds the viewport on all sides and + // every gesture below stays over shapes. + await page.keyboard.press("Shift+1"); + await page.waitForTimeout(1200); + + // "=" rather than "+": both are bound to :increase-zoom (shortcuts.cljs), and + // "=" needs no shift modifier for mousetrap to match. + for (let i = 0; i < ZOOM_STEPS; i++) { + await page.keyboard.press("="); + await page.waitForTimeout(120); + } + await page.waitForTimeout(1500); + + await drain(page); // discard zoom-in setup + await resetCounters(page); + + // A plain wheel delta pans the vbox by `delta / zoom` doc units + // (`schedule-scroll!`), i.e. by `delta` screen pixels whatever the zoom. So + // amplitudes are expressed in screen pixels, derived from the viewport. + const panX = Math.round((box.width * PAN_TRAVEL) / STEPS); + const panY = Math.round((box.height * PAN_TRAVEL) / STEPS); + + const wheelBurst = async (dx, dy) => { + for (let i = 0; i < STEPS; i++) { + await page.mouse.wheel(dx, dy); + await page.waitForTimeout(STEP_DELAY); + } + await page.waitForTimeout(SETTLE); + }; + + const runners = { + // Plain wheel pan, ending via the debounced `render-finish`. Zoom is stable, + // so `allow_stop` is false and the progressive budget never applies. + // Down/right/up/left closes the cycle, so it cannot drift off the document. + "wheel-pan": async () => { + for (let c = 0; c < CYCLES; c++) { + await wheelBurst(0, panY); + await wheelBurst(panX, 0); + await wheelBurst(0, -panY); + await wheelBurst(-panX, 0); + } + }, + + // Space-drag pan. Ends on pointerup via `finish-panning` -> + // maybe-view-interaction-end!, i.e. straight on the input path. + // Drags out and returns, for the same reason as the wheel cycle. + "drag-pan": async () => { + const moves = 12; + const dx = (box.width * PAN_TRAVEL) / moves; + const dy = (box.height * PAN_TRAVEL) / moves; + for (let c = 0; c < CYCLES; c++) { + await page.keyboard.down("Space"); + await page.mouse.move(cx, cy); + await page.mouse.down(); + for (let i = 1; i <= moves; i++) { + await page.mouse.move( + Math.round(cx - i * dx), + Math.round(cy - i * dy), + ); + await page.waitForTimeout(STEP_DELAY); + } + for (let i = moves - 1; i >= 0; i--) { + await page.mouse.move( + Math.round(cx - i * dx), + Math.round(cy - i * dy), + ); + await page.waitForTimeout(STEP_DELAY); + } + await page.mouse.up(); + await page.keyboard.up("Space"); + await page.waitForTimeout(SETTLE); + } + }, + + // Ctrl+wheel zoom: `zoom_changed()` makes `allow_stop` true, so this is the + // phase that exercises the progressive budget. In then out, keeping the + // working zoom as the floor so it never zooms out into empty canvas. + zoom: async () => { + const ramp = async (delta) => { + await page.keyboard.down("Control"); + for (let i = 0; i < ZOOM_NOTCHES; i++) { + await page.mouse.wheel(0, delta); + await page.waitForTimeout(STEP_DELAY); + } + await page.keyboard.up("Control"); + await page.waitForTimeout(SETTLE); + }; + for (let c = 0; c < CYCLES; c++) { + await ramp(-120); // in + await ramp(120); // out, back to the working zoom + } + }, + }; + + const phases = { + load: { + calls: loadCalls, + summary: summarize(loadCalls), + counters: loadCounters, + ratios: derive(loadCounters), + wasmErrors: loadErrors, + }, + }; + let total = loadCalls.length; + for (const name of PHASES) { + const run = runners[name]; + if (!run) throw new Error(`unknown phase "${name}"`); + await run(); + const calls = await drain(page); + const counters = await drainCounters(page); + total += calls.length; + phases[name] = { + calls, + summary: summarize(calls), + counters, + ratios: derive(counters), + wasmErrors: drainErrors(), + }; + } + + // A run that recorded nothing is not a passing run — it means the gestures + // never reached the renderer (wrong branch built, render-wasm flag off, or + // the workspace fell back to the SVG viewport). Fail loudly rather than + // writing an all-zero file that looks like a legitimate comparison baseline. + if (total === 0) { + throw new Error( + "no _render calls recorded across any phase — the build under test is " + + "probably not rendering through render-wasm", + ); + } + + let rev = "unknown"; + try { + rev = execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim(); + const dirty = execSync("git status --porcelain", { + encoding: "utf-8", + }).trim(); + if (dirty) rev += "-dirty"; + } catch {} + + // Not test-results/: Playwright wipes outputDir at the start of every run, + // which would delete the first run's data before the second one lands. + const outDir = path.resolve("perf-traces"); + fs.mkdirSync(outDir, { recursive: true }); + const out = path.join(outDir, `render-budget-${LABEL}.json`); + fs.writeFileSync( + out, + JSON.stringify( + { + label: LABEL, + rev, + date: new Date().toISOString(), + file: GET_FILE, + options: { + ZOOM_STEPS, + ZOOM_NOTCHES, + CYCLES, + STEPS, + STEP_DELAY, + SETTLE, + PAN_TRAVEL, + // Tile counts scale with the viewport, so two runs are only + // comparable at the same size and DPR. The compare script refuses to + // read across a mismatch. + VIEWPORT: `${VIEWPORT_W}x${VIEWPORT_H}`, + DPR, + }, + counterNames: COUNTER_NAMES, + phases, + }, + null, + 2, + ), + ); + + const fmt = (s) => + s.n + ? `n=${s.n} mean=${s.mean}ms p50=${s.p50} p95=${s.p95} max=${s.max} total=${s.total}` + : "n=0"; + console.log( + `\n[PERF ${LABEL}] rev=${rev} file=${GET_FILE.split("/").pop()} ` + + `viewport=${VIEWPORT_W}x${VIEWPORT_H}@${DPR}x`, + ); + for (const [ + name, + { summary, counters, ratios, wasmErrors: errs }, + ] of Object.entries(phases)) { + console.log(`[PERF ${LABEL}] --- ${name}`); + if (errs?.length) { + console.log( + `[PERF ${LABEL}] !! ${errs.length} wasm error(s) in this phase, ` + + `counters are understated: ${errs[0]}`, + ); + } + console.log( + `[PERF ${LABEL}] SYNC (blocks main thread) ${fmt(summary.sync)}`, + ); + console.log( + `[PERF ${LABEL}] rAF ${fmt(summary.raf)}`, + ); + if (summary.worstSync) { + const w = summary.worstSync; + console.log( + `[PERF ${LABEL}] worst sync ${w.ms}ms flags=${w.flags} ` + + `frameType=${w.frame} @${w.caller ?? "?"}`, + ); + } + if (counters && !counters.error) { + console.log( + `[PERF ${LABEL}] tiles painted=${counters.tiles_painted} ` + + `cached=${counters.tiles_cache_hit} ` + + `invalidated=${counters.tiles_invalidated} ` + + `wipes=${counters.tile_cache_wipes} ` + + `discarded=${counters.tiles_discarded_inflight}`, + ); + console.log( + `[PERF ${LABEL}] shape paints=${counters.shape_paints} ` + + `(${counters.shape_paints_direct} direct) ` + + `walker visits=${counters.walker_visits} ` + + `culled=${counters.walker_culled}`, + ); + console.log( + `[PERF ${LABEL}] text builds=${counters.paragraph_builds} ` + + `layouts=${counters.text_layouts} | ` + + `composite px=${(counters.surface_stack_draw_px / 1e6).toFixed(1)}M ` + + `clear px=${(counters.surface_stack_clear_px / 1e6).toFixed(1)}M`, + ); + console.log( + `[PERF ${LABEL}] per tile: shapes=${ratios.shape_paints_per_tile} ` + + `visits=${ratios.walker_visits_per_tile} ` + + `composite=${(ratios.composite_px_per_tile / 1e6).toFixed(2)}M px ` + + `| layered=${(ratios.layered_paint_ratio * 100).toFixed(0)}% ` + + `cache hit=${(ratios.tile_cache_hit_ratio * 100).toFixed(0)}% ` + + `empty=${(ratios.empty_tile_ratio * 100).toFixed(0)}%`, + ); + if (ratios.empty_tile_ratio > 0.5) { + console.log( + `[PERF ${LABEL}] !! over half the tiles in this phase are empty — ` + + "the gestures are mostly over blank canvas, lower PERF_ZOOM_STEPS " + + "or PERF_PAN_TRAVEL, or use a denser fixture", + ); + } + } + } + console.log(`[PERF ${LABEL}] wrote ${out}`); +}); diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index f2ec88f412..cac16be160 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -154,6 +154,61 @@ (wasm.h/call module "_render_stats") (js/console.warn "[debug] render-wasm module not ready or missing _render_stats")))) +;; Mirrors `render::counters::NAMES` by index (render-wasm/src/render/counters.rs). +(def ^:private wasm-perf-counter-names + ["render_loop_starts" + "render_loop_continues" + "partial_yields" + "tiles_painted" + "tiles_cache_hit" + "tiles_empty_skipped" + "tiles_invalidated" + "tile_cache_wipes" + "tiles_discarded_inflight" + "walker_visits" + "walker_culled" + "shape_paints" + "shape_paints_direct" + "surface_stack_composites" + "surface_stack_draw_px" + "surface_stack_clear_px" + "paragraph_builds" + "text_layouts" + "doc_atlas_writes" + "tile_atlas_writes" + "cache_surface_writes" + "tile_atlas_snapshots" + "tile_atlas_snapshot_px" + "frame_presents" + "crop_entries_built" + "crop_blits" + "crop_rejected" + "shape_tile_updates"]) + +(defn ^:export wasmPerfCounters + "Snapshot of the render counters. Call `wasmPerfCountersReset` first to scope + them to one interaction." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_perf_counter_get"))] + (if (fn? f) + (let [total (wasm.h/call module "_perf_counter_count") + result #js {}] + (dotimes [i total] + (unchecked-set result + (nth wasm-perf-counter-names i (str "counter_" i)) + (wasm.h/call module "_perf_counter_get" i))) + result) + (js/console.warn "[debug] render-wasm module not ready or missing _perf_counter_get")))) + +(defn ^:export wasmPerfCountersReset + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_perf_counters_reset"))] + (if (fn? f) + (wasm.h/call module "_perf_counters_reset") + (js/console.warn "[debug] render-wasm module not ready or missing _perf_counters_reset")))) + (defn ^:export wasmAtlasConsole "Logs the current render-wasm atlas as an image in the JS console (if present)." [] diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 2d007d18e4..59d1f1975c 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -1016,6 +1016,21 @@ pub extern "C" fn render_stats() { get_render_state().print_stats(); } +#[no_mangle] +pub extern "C" fn perf_counter_count() -> u32 { + render::counters::COUNTER_COUNT as u32 +} + +#[no_mangle] +pub extern "C" fn perf_counter_get(index: u32) -> f64 { + render::counters::get(index as usize) +} + +#[no_mangle] +pub extern "C" fn perf_counters_reset() { + render::counters::reset(); +} + #[no_mangle] pub fn free_gpu_resources() { get_render_state().free_gpu_resources(); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 2752266436..ae887cc24e 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1,3 +1,4 @@ +pub mod counters; mod debug; mod fills; pub mod filters; @@ -22,6 +23,7 @@ use skia_safe::{self as skia, Matrix, RRect, Rect}; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; +use counters::Counter; use options::RenderOptions; pub use surfaces::{SurfaceId, Surfaces}; @@ -36,7 +38,7 @@ use crate::tiles::{self, PendingTiles, TileRect}; use crate::uuid::Uuid; use crate::view::Viewbox; use crate::wapi; -use crate::{get_gpu_state, get_resources, performance}; +use crate::{count, get_gpu_state, get_resources, performance}; pub use fonts::*; pub use images::*; @@ -956,6 +958,7 @@ impl RenderState { /// on top of Target, then present. Backbuffer is left clean so it can be reused /// as-is across interactive-transform frames without stale overlay pixels. pub fn present_frame(&mut self, tree: ShapesPoolRef) { + count!(Counter::FramePresents); self.compose_frame(tree); self.surfaces.flush_and_submit(SurfaceId::Target); } @@ -1122,15 +1125,23 @@ impl RenderState { pub fn draw_shape_surface_stack_into(&mut self, shape: Option<&Shape>, target: SurfaceId) { performance::begin_measure!("apply_drawing_to_render_canvas"); + count!(Counter::SurfaceStackComposites); + let surface_px = { + let (w, h) = self.surfaces.surface_size(SurfaceId::Fills); + (w as f64) * (h as f64) + }; + let paint = skia::Paint::default(); // Only draw surfaces that have content (dirty flag optimization) if self.surfaces.is_dirty(SurfaceId::TextDropShadows) { + count!(Counter::SurfaceStackDrawPx, surface_px); self.surfaces .draw_into(SurfaceId::TextDropShadows, target, Some(&paint)); } if self.surfaces.is_dirty(SurfaceId::Fills) { + count!(Counter::SurfaceStackDrawPx, surface_px); self.surfaces .draw_into(SurfaceId::Fills, target, Some(&paint)); } @@ -1141,16 +1152,19 @@ impl RenderState { } if render_overlay_below_strokes && self.surfaces.is_dirty(SurfaceId::InnerShadows) { + count!(Counter::SurfaceStackDrawPx, surface_px); self.surfaces .draw_into(SurfaceId::InnerShadows, target, Some(&paint)); } if self.surfaces.is_dirty(SurfaceId::Strokes) { + count!(Counter::SurfaceStackDrawPx, surface_px); self.surfaces .draw_into(SurfaceId::Strokes, target, Some(&paint)); } if !render_overlay_below_strokes && self.surfaces.is_dirty(SurfaceId::InnerShadows) { + count!(Counter::SurfaceStackDrawPx, surface_px); self.surfaces .draw_into(SurfaceId::InnerShadows, target, Some(&paint)); } @@ -1171,6 +1185,10 @@ impl RenderState { } if dirty_surfaces_to_clear != 0 { + count!( + Counter::SurfaceStackClearPx, + surface_px * dirty_surfaces_to_clear.count_ones() as f64 + ); self.surfaces.apply_mut(dirty_surfaces_to_clear, |s| { s.canvas().clear(skia::Color::TRANSPARENT); }); @@ -1339,6 +1357,8 @@ impl RenderState { #[cfg(feature = "stats")] self.stats.count(shape.id); + count!(Counter::ShapePaints); + let surface_ids = fills_surface_id as u32 | strokes_surface_id as u32 | innershadows_surface_id as u32 @@ -1413,6 +1433,7 @@ impl RenderState { && target_surface != SurfaceId::Export; if can_render_directly { + count!(Counter::ShapePaintsDirect); let translation = self .surfaces .get_render_context_translation(self.render_area, scale); @@ -2230,6 +2251,7 @@ impl RenderState { img }; + count!(Counter::CropEntriesBuilt); self.backbuffer_crop_cache.insert( id, InteractiveDragCrop { @@ -2294,6 +2316,7 @@ impl RenderState { self.surfaces.gc(); if self.current_tile.is_some() && !self.pending_nodes.is_empty() { + count!(Counter::TilesDiscardedInflight); } self.pending_nodes.clear(); @@ -2319,6 +2342,7 @@ impl RenderState { timestamp: i32, sync_render: bool, ) -> Result { + count!(Counter::RenderLoopStarts); self.clear(tree); let _start = performance::begin_timed_log!("start_render_loop"); @@ -2483,6 +2507,7 @@ impl RenderState { timestamp: i32, allow_stop: bool, ) -> Result { + count!(Counter::RenderLoopContinues); performance::begin_measure!("continue_render_loop"); let timestamp = self.render_budget_start(timestamp); let frame_type = @@ -3501,6 +3526,7 @@ impl RenderState { }; while let Some(node_render_state) = self.pending_nodes.pop() { + count!(Counter::WalkerVisits); let node_id = node_render_state.id; let visited_children = node_render_state.visited_children; let visited_mask = node_render_state.visited_mask; @@ -3593,6 +3619,7 @@ impl RenderState { } if !is_visible { + count!(Counter::WalkerCulled); continue; } } @@ -3615,10 +3642,12 @@ impl RenderState { ); if !use_cached && self.backbuffer_crop_cache.contains_key(&node_id) { + count!(Counter::CropRejected); } if use_cached { if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) { + count!(Counter::CropBlits); let crop_image = &crop.image; let crop_src_selrect = crop.src_selrect; @@ -3909,6 +3938,7 @@ impl RenderState { } if early_return { + count!(Counter::PartialYields); self.viewer_render_root = None; return Ok(FrameType::Partial); } @@ -3920,6 +3950,7 @@ impl RenderState { // (`current_tile_had_shapes` was set when we populated pending_nodes // for this tile). if !is_empty || self.current_tile_had_shapes { + count!(Counter::TilesPainted); if self.options.is_interactive_transform() { // During drag, avoid snapshot-based caching. Draw Current directly // into Target (and Cache) to reduce stalls. @@ -3966,6 +3997,7 @@ impl RenderState { let Some(ids) = self.tiles.get_shapes_at(next_tile) else { // If the tile is empty we do not need to render it. + count!(Counter::TilesEmptySkipped); continue; }; @@ -3973,6 +4005,7 @@ impl RenderState { if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) { // If the tile is cached, then we do not need to // render it. + count!(Counter::TilesCacheHit); continue; } @@ -4093,6 +4126,7 @@ impl RenderState { shape: &Shape, tree: ShapesPoolRef, ) -> HashSet { + count!(Counter::ShapeTileUpdates); let tile_rect = self.get_tiles_for_shape(shape, tree); // Collect old tiles to avoid borrow conflict with remove_shape_at diff --git a/render-wasm/src/render/counters.rs b/render-wasm/src/render/counters.rs new file mode 100644 index 0000000000..f2ba8c1ce1 --- /dev/null +++ b/render-wasm/src/render/counters.rs @@ -0,0 +1,105 @@ +//! Exact render counters for before/after comparisons. Read from the browser +//! through the `perf_counter_*` exports in `main.rs`; [`NAMES`] is mirrored by +//! index in `playwright/ui/render-wasm-specs/render-budget-perf.spec.js`. + +#[repr(usize)] +#[derive(Copy, Clone)] +#[allow(dead_code)] +pub enum Counter { + RenderLoopStarts = 0, + RenderLoopContinues, + PartialYields, + TilesPainted, + TilesCacheHit, + TilesEmptySkipped, + TilesInvalidated, + TileCacheWipes, + TilesDiscardedInflight, + WalkerVisits, + WalkerCulled, + ShapePaints, + ShapePaintsDirect, + SurfaceStackComposites, + SurfaceStackDrawPx, + SurfaceStackClearPx, + ParagraphBuilds, + TextLayouts, + DocAtlasWrites, + TileAtlasWrites, + /// Unused since the Cache blit was removed; kept so indices stay stable. + CacheSurfaceWrites, + TileAtlasSnapshots, + TileAtlasSnapshotPx, + FramePresents, + CropEntriesBuilt, + CropBlits, + CropRejected, + ShapeTileUpdates, +} + +pub const COUNTER_COUNT: usize = 28; + +pub const NAMES: [&str; COUNTER_COUNT] = [ + "render_loop_starts", + "render_loop_continues", + "partial_yields", + "tiles_painted", + "tiles_cache_hit", + "tiles_empty_skipped", + "tiles_invalidated", + "tile_cache_wipes", + "tiles_discarded_inflight", + "walker_visits", + "walker_culled", + "shape_paints", + "shape_paints_direct", + "surface_stack_composites", + "surface_stack_draw_px", + "surface_stack_clear_px", + "paragraph_builds", + "text_layouts", + "doc_atlas_writes", + "tile_atlas_writes", + "cache_surface_writes", + "tile_atlas_snapshots", + "tile_atlas_snapshot_px", + "frame_presents", + "crop_entries_built", + "crop_blits", + "crop_rejected", + "shape_tile_updates", +]; + +static mut COUNTERS: [f64; COUNTER_COUNT] = [0.0; COUNTER_COUNT]; + +/// `f64` so the JS side reads plain numbers; exact past any count we reach. +#[inline(always)] +pub fn add(counter: Counter, n: f64) { + unsafe { + COUNTERS[counter as usize] += n; + } +} + +#[inline(always)] +pub fn get(index: usize) -> f64 { + if index >= COUNTER_COUNT { + return 0.0; + } + unsafe { COUNTERS[index] } +} + +pub fn reset() { + unsafe { + COUNTERS = [0.0; COUNTER_COUNT]; + } +} + +#[macro_export] +macro_rules! count { + ($counter:expr) => { + $crate::render::counters::add($counter, 1.0) + }; + ($counter:expr, $n:expr) => { + $crate::render::counters::add($counter, $n as f64) + }; +} diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 78e8c9bbe7..ed45f48965 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -5,7 +5,9 @@ use crate::{get_gpu_state, performance}; use skia_safe::{self as skia, IRect, Paint, RRect, Rect}; +use super::counters::Counter; use super::{gpu_state::GpuState, tiles, tiles::Tile, tiles::TileRect, tiles::TileViewbox}; +use crate::count; use crate::math::Point; use base64::{engine::general_purpose, Engine as _}; @@ -552,6 +554,11 @@ impl Surfaces { ) { self.tiles.update(viewbox, tile_viewbox); if self.tiles.needs_snapshot() || self.tile_atlas_image.is_none() { + count!(Counter::TileAtlasSnapshots); + count!( + Counter::TileAtlasSnapshotPx, + (self.tile_atlas.width() as f64) * (self.tile_atlas.height() as f64) + ); self.tile_atlas_image = Some(self.tile_atlas.image_snapshot()); self.tiles.snapshot(); } @@ -1215,6 +1222,7 @@ impl Surfaces { let sampling = self.sampling_options; // DocAtlas + tile atlas via Surface::draw (no image_snapshot sync). + count!(Counter::DocAtlasWrites); let _ = self.atlas.blit_current_drawable_into_atlas( gpu_state, &mut self.current, @@ -1224,6 +1232,7 @@ impl Surfaces { ); self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect); + count!(Counter::TileAtlasWrites); let tile_ref = self.tiles.add(tile_viewbox, tile); let dst = tile_ref.rect; let mut current = self.current.clone(); @@ -1343,6 +1352,7 @@ impl Surfaces { } pub fn remove_cached_tile_surface(&mut self, tile: Tile) { + count!(Counter::TilesInvalidated); let gpu_state = get_gpu_state(); // Mark tile as invalid // Old content stays visible until new tile overwrites it atomically, @@ -1409,6 +1419,7 @@ impl Surfaces { /// Used by `rebuild_tiles` (full rebuild). For shallow rebuilds that preserve /// the cache canvas for scaled previews, use `invalidate_tile_cache` instead. pub fn remove_cached_tiles(&mut self, color: skia::Color) { + count!(Counter::TileCacheWipes); self.tiles.clear(); self.atlas.tile_doc_rects.clear(); self.cache.canvas().clear(color); @@ -1419,6 +1430,7 @@ impl Surfaces { /// so that `render_from_cache` can still show a scaled preview of the old /// content while new tiles are being rendered. pub fn invalidate_tile_cache(&mut self) { + count!(Counter::TileCacheWipes); self.tiles.clear(); self.atlas.tile_doc_rects.clear(); self.tile_atlas_image = None; diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index e027aebd21..46150991a4 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -1,5 +1,7 @@ +use super::counters::Counter; use super::{filters, RenderState, Shape, SurfaceId, DEFAULT_EMOJI_FONT}; use crate::{ + count, error::Result, math::Rect, shapes::{ @@ -21,6 +23,7 @@ pub fn stroke_paragraph_builder_group_from_text( bounds: &Rect, use_shadow: Option, ) -> (Vec, Option) { + count!(Counter::ParagraphBuilds); let fallback_fonts = get_fallback_fonts(); let fonts = get_font_collection(); let mut paragraph_group = Vec::new(); diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 47befc08f3..b84771da90 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1,5 +1,7 @@ +use crate::render::counters::Counter; use crate::render::text::calculate_decoration_metrics; use crate::{ + count, math::{Bounds, Matrix, Rect}, render::{default_font, DEFAULT_EMOJI_FONT}, utils::Browser, @@ -707,6 +709,7 @@ impl TextContent { &self, use_shadow: Option, ) -> Vec { + count!(Counter::ParagraphBuilds); let fonts = get_font_collection(); let fallback_fonts = get_fallback_fonts(); let mut paragraph_group = Vec::new(); @@ -742,6 +745,7 @@ impl TextContent { /// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255). /// Used as a clip mask for inner stroke rendering. pub fn paragraph_builder_group_opaque(&self) -> Vec { + count!(Counter::ParagraphBuilds); let fonts = get_font_collection(); let fallback_fonts = get_fallback_fonts(); let mut paragraph_group = Vec::new(); @@ -1487,6 +1491,7 @@ pub fn calculate_text_layout_data( paragraph_builder_groups: &mut [ParagraphBuilderGroup], skip_position_data: bool, ) -> TextLayoutData { + count!(Counter::TextLayouts); let selrect_width = shape.selrect().width(); let text_width = text_content.get_width(selrect_width); let selrect_height = shape.selrect().height();