mirror of
https://github.com/penpot/penpot.git
synced 2026-09-09 04:09:38 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30bfc4b8c0 | ||
|
|
128c0bb86e | ||
|
|
18941ed8e1 | ||
|
|
0e31516761 | ||
|
|
730a2ea918 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
|
||||
# render-wasm zoom perf captures (40MB+ each)
|
||||
perf-traces/
|
||||
@@ -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()
|
||||
@@ -0,0 +1,64 @@
|
||||
import json, sys, statistics as st
|
||||
|
||||
|
||||
def pct(v, p):
|
||||
v = sorted(v)
|
||||
return v[min(len(v) - 1, int(round((p / 100.0) * (len(v) - 1))))]
|
||||
|
||||
|
||||
WANT = {"FireAnimationFrame", "GPUTask", "PageAnimator::serviceScriptedAnimations"}
|
||||
|
||||
|
||||
def load(path):
|
||||
threads, ev, marks, dropped = {}, [], [], []
|
||||
with open(path) as fh:
|
||||
for line in fh:
|
||||
if '"cat"' not in line:
|
||||
continue
|
||||
s = line.strip().rstrip(",")
|
||||
if not s.startswith("{"):
|
||||
continue
|
||||
if '"thread_name"' in s:
|
||||
e = json.loads(s)
|
||||
threads[(e["pid"], e["tid"])] = e["args"]["name"]
|
||||
elif '"name":"DroppedFrame"' in s:
|
||||
try:
|
||||
dropped.append(json.loads(s)["ts"])
|
||||
except Exception:
|
||||
pass
|
||||
elif '"ph":"X"' in s and '"dur"' in s:
|
||||
if not any(w in s for w in
|
||||
('"FireAnimationFrame"', '"GPUTask"',
|
||||
'"serviceScriptedAnimations"')):
|
||||
continue
|
||||
e = json.loads(s)
|
||||
if e["name"] in WANT:
|
||||
ev.append((e["ts"], e["dur"], e["name"]))
|
||||
elif '"name":"set-view-box"' in s and '"ph":"b"' in s:
|
||||
marks.append(json.loads(s)["ts"])
|
||||
return ev, marks, dropped
|
||||
|
||||
|
||||
def report(tag, path):
|
||||
ev, marks, dropped = load(path)
|
||||
marks.sort()
|
||||
lo, hi = marks[0], marks[-1]
|
||||
span = (hi - lo) / 1e6
|
||||
nd = sum(1 for t in dropped if lo <= t <= hi)
|
||||
print(f"\n{'=' * 60}\n{tag}\n{'=' * 60}")
|
||||
print(f"window {span:.2f}s zooms {len(marks)} ({len(marks)/span:.1f}/s)"
|
||||
f" dropped frames {nd} ({nd/span:.1f}/s)")
|
||||
print(f"\n{'event':<38}{'n':>6}{'mean':>8}{'p50':>7}"
|
||||
f"{'p95':>8}{'p99':>8}{'max':>9}")
|
||||
for name in sorted(WANT):
|
||||
d = [dur for (ts, dur, nm) in ev if nm == name and lo <= ts <= hi]
|
||||
if not d:
|
||||
continue
|
||||
print(f"{name:<38}{len(d):>6}{st.mean(d)/1000:>8.2f}"
|
||||
f"{pct(d,50)/1000:>7.2f}{pct(d,95)/1000:>8.2f}"
|
||||
f"{pct(d,99)/1000:>8.2f}{max(d)/1000:>9.2f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for arg in sys.argv[1:]:
|
||||
report(arg.split("/")[-1].replace(".json", ""), arg)
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { test } from "@playwright/test";
|
||||
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Zoom performance capture. Not an assertion test — it drives a fixed zoom
|
||||
* sequence and writes a Chrome trace, so two builds can be compared under an
|
||||
* identical interaction.
|
||||
*
|
||||
* Run one build, stash/checkout the other, run again, then diff the traces:
|
||||
*
|
||||
* PERF_LABEL=branch npx playwright test zoom-perf --project render-wasm --workers=1
|
||||
* PERF_LABEL=develop npx playwright test zoom-perf --project render-wasm --workers=1
|
||||
* python3 playwright/scripts/trace-compare.py \
|
||||
* perf-traces/zoom-perf-branch.json perf-traces/zoom-perf-develop.json
|
||||
*
|
||||
* The bundled fixtures are small (<150 shapes, most with shadows) and will not
|
||||
* reproduce the dense-tile case that `max_per_tile` flagged. For the decisive
|
||||
* comparison, save the `get-file` response of the real document under
|
||||
* playwright/data/ and point PERF_GET_FILE at it (PERF_PAGE_NAME too, if its
|
||||
* first page is not called "Page 1").
|
||||
*/
|
||||
|
||||
const LABEL = process.env.PERF_LABEL ?? "run";
|
||||
const CYCLES = Number(process.env.PERF_CYCLES ?? 6);
|
||||
const STEPS = Number(process.env.PERF_STEPS ?? 20);
|
||||
const STEP_DELAY = Number(process.env.PERF_STEP_DELAY ?? 16);
|
||||
const SETTLE = Number(process.env.PERF_SETTLE ?? 600);
|
||||
const TRACE = process.env.PERF_TRACE !== "0";
|
||||
// zoom | pan | both. Penpot zooms on ctrl+wheel and pans on plain wheel
|
||||
// (viewport/actions.cljs: `if (or ctrl? mod?) schedule-zoom! else
|
||||
// schedule-scroll!`), so both gestures reuse the same wheel mechanism.
|
||||
const MODE = process.env.PERF_MODE ?? "both";
|
||||
const PAN_DELTA = Number(process.env.PERF_PAN_DELTA ?? 200);
|
||||
// Zoom in before panning: at zoom-to-fit the whole document is on screen and
|
||||
// panning just scrolls into empty space without crossing populated tiles.
|
||||
const PAN_ZOOM_IN = Number(process.env.PERF_PAN_ZOOM_IN ?? 12);
|
||||
const TIMEOUT = Number(process.env.PERF_TIMEOUT ?? 180000);
|
||||
|
||||
// File and page ids are read out of the payload by mockGetFile, so pointing
|
||||
// PERF_GET_FILE at a different dump is the only change needed.
|
||||
// Must be a fixture with no media assets: anything needing mockFileMediaAsset
|
||||
// stalls on image fetches and `wasmSetObjectsFinished` never fires.
|
||||
const GET_FILE =
|
||||
process.env.PERF_GET_FILE ?? "render-wasm/get-file-shapes-groups-boards.json";
|
||||
const PAGE_NAME = process.env.PERF_PAGE_NAME ?? "Page 1";
|
||||
|
||||
// Excludes v8.inspector and v8.cpu_profiler on purpose: in the hand-captured
|
||||
// traces those accounted for ~400k events and inflated main-thread cost on
|
||||
// both sides.
|
||||
const CATEGORIES = [
|
||||
"__metadata",
|
||||
"devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline.frame",
|
||||
"blink.user_timing",
|
||||
"benchmark",
|
||||
"cc",
|
||||
];
|
||||
|
||||
// Headed is mandatory, not cosmetic: headless Chromium falls back to
|
||||
// SwiftShader (CPU rasterizer), which makes every GPU measurement useless.
|
||||
// Verified renderer strings on this machine:
|
||||
// headless -> ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device ...))
|
||||
// headed -> ANGLE (Intel, Mesa Intel(R) Arc(tm) Graphics (MTL), ...)
|
||||
test.use({ headless: false });
|
||||
|
||||
test.setTimeout(TIMEOUT);
|
||||
|
||||
test(`zoom perf capture [${LABEL}]`, async ({ page }) => {
|
||||
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 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);
|
||||
|
||||
// Zoom to fit so every run starts from the same viewbox.
|
||||
await page.keyboard.press("Shift+1");
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Not test-results/: Playwright wipes outputDir at the start of every run,
|
||||
// which would delete the first build's trace before the second one lands.
|
||||
const outDir = path.resolve("perf-traces");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const out = path.join(outDir, `zoom-perf-${LABEL}.json`);
|
||||
|
||||
// browser.startTracing, not a raw CDPSession: Tracing is a browser-level
|
||||
// domain, and driving it from a page session deadlocks input — the
|
||||
// dataCollected flood blocks the same connection Playwright sends keys on.
|
||||
const browser = page.context().browser();
|
||||
if (TRACE) {
|
||||
await browser.startTracing(page, {
|
||||
path: out,
|
||||
categories: CATEGORIES,
|
||||
screenshots: false,
|
||||
});
|
||||
}
|
||||
|
||||
const burst = 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); // let the full-quality pass finish
|
||||
};
|
||||
|
||||
if (MODE === "zoom" || MODE === "both") {
|
||||
// Zoom OUT first, then back in. Starting from zoom-to-fit and zooming in
|
||||
// never goes below fit scale, so scale-dependent level-of-detail paths
|
||||
// (imperceptible shadows/strokes) would never activate and the run would
|
||||
// measure nothing. Going out first spends half the phase at low scale.
|
||||
await page.keyboard.down("Control");
|
||||
for (let c = 0; c < CYCLES; c++) {
|
||||
await burst(0, 120); // zoom out, below fit
|
||||
await burst(0, -120); // zoom back in
|
||||
}
|
||||
await page.keyboard.up("Control");
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
if (MODE === "pan" || MODE === "both") {
|
||||
await page.keyboard.down("Control");
|
||||
for (let i = 0; i < PAN_ZOOM_IN; i++) {
|
||||
await page.mouse.wheel(0, -120);
|
||||
await page.waitForTimeout(STEP_DELAY);
|
||||
}
|
||||
await page.keyboard.up("Control");
|
||||
await page.waitForTimeout(SETTLE);
|
||||
|
||||
for (let c = 0; c < CYCLES; c++) {
|
||||
await burst(0, PAN_DELTA); // down
|
||||
await burst(PAN_DELTA, 0); // right
|
||||
await burst(0, -PAN_DELTA); // up
|
||||
await burst(-PAN_DELTA, 0); // left
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
let events = [];
|
||||
if (TRACE) {
|
||||
const buf = await browser.stopTracing();
|
||||
const parsed = JSON.parse(buf.toString());
|
||||
events = Array.isArray(parsed) ? parsed : parsed.traceEvents;
|
||||
}
|
||||
|
||||
// Inline summary so a single run is readable without the python analyzer.
|
||||
const durs = (name) =>
|
||||
events
|
||||
.filter((e) => e.name === name && e.ph === "X" && e.dur != null)
|
||||
.map((e) => e.dur);
|
||||
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)))];
|
||||
const mean = v.reduce((a, b) => a + b, 0) / v.length;
|
||||
return (
|
||||
`n=${v.length} mean=${(mean / 1000).toFixed(2)}ms ` +
|
||||
`p50=${(p(0.5) / 1000).toFixed(2)} p95=${(p(0.95) / 1000).toFixed(2)} ` +
|
||||
`p99=${(p(0.99) / 1000).toFixed(2)} max=${(p(1) / 1000).toFixed(2)}`
|
||||
);
|
||||
};
|
||||
const dropped = events.filter((e) => e.name === "DroppedFrame").length;
|
||||
const zooms = events.filter(
|
||||
(e) => e.name === "set-view-box" && e.ph === "b",
|
||||
).length;
|
||||
|
||||
console.log(`\n[PERF ${LABEL}] trace -> ${out}`);
|
||||
console.log(`[PERF ${LABEL}] mode=${MODE} file=${GET_FILE.split("/").pop()} ` +
|
||||
`events=${events.length} viewbox=${zooms} droppedFrames=${dropped}`);
|
||||
console.log(`[PERF ${LABEL}] FireAnimationFrame ${stat(durs("FireAnimationFrame"))}`);
|
||||
console.log(`[PERF ${LABEL}] GPUTask ${stat(durs("GPUTask"))}`);
|
||||
});
|
||||
@@ -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)."
|
||||
[]
|
||||
|
||||
@@ -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();
|
||||
|
||||
+196
-80
@@ -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::*;
|
||||
@@ -44,6 +46,10 @@ pub(crate) use resources::RenderResources;
|
||||
|
||||
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
|
||||
|
||||
/// Above this many uncached tiles, a preserved-target render yields instead of
|
||||
/// painting everything in one blocking call.
|
||||
const MAX_SYNC_TILES_ON_PRESERVED_TARGET: usize = 4;
|
||||
|
||||
#[repr(u8)]
|
||||
pub enum FrameType {
|
||||
None = 0,
|
||||
@@ -358,9 +364,8 @@ pub(crate) struct RenderState {
|
||||
pending_nodes: Vec<NodeRenderState>,
|
||||
pub current_tile: Option<tiles::Tile>,
|
||||
pub render_area: Rect,
|
||||
// render_area expanded by surface margins — used for visibility checks so that
|
||||
// shapes in the margin zone are rendered (needed for background blur sampling).
|
||||
pub render_area_with_margins: Rect,
|
||||
/// Region a shape must touch to be painted for the current tile.
|
||||
pub cull_area: Rect,
|
||||
pub tile_viewbox: tiles::TileViewbox,
|
||||
pub tiles: tiles::TileHashMap,
|
||||
pub pending_tiles: PendingTiles,
|
||||
@@ -482,14 +487,17 @@ impl RenderState {
|
||||
/// - **Top-level only**: cache entries are built for direct children of the root.
|
||||
/// - **Moved node**: only allow cache reuse for *pure translations* (no scale/rotate/skew),
|
||||
/// because other transforms would require resampling and can diverge from the live render.
|
||||
/// - **Other cached nodes**: if the moving bounds overlap this cached crop, invalidate it so
|
||||
/// we don't show stale content while something moves over/inside it.
|
||||
/// - **Other cached nodes**: reusable unless the crop holds stale pixels of the moving
|
||||
/// content (`moved_bounds_before`), or the movers paint *under* this node and now
|
||||
/// overlap it (`movers_paint_above`).
|
||||
fn should_use_cached_top_level_during_interactive(
|
||||
&mut self,
|
||||
node_id: Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
moved_ids: &[Uuid],
|
||||
moved_bounds: Option<Rect>,
|
||||
moved_bounds_before: Option<Rect>,
|
||||
movers_paint_above: bool,
|
||||
) -> bool {
|
||||
if !self.backbuffer_crop_cache.contains_key(&node_id) {
|
||||
return false;
|
||||
@@ -526,19 +534,26 @@ impl RenderState {
|
||||
.is_some_and(|s| s.is_safe_for_drag_crop_cache(tree));
|
||||
}
|
||||
|
||||
let Some(src_doc_bounds) = self
|
||||
.backbuffer_crop_cache
|
||||
.get(&node_id)
|
||||
.map(|crop| crop.src_doc_bounds)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// The crop was captured before the gesture, so it still holds the movers where they
|
||||
// started: reusing it there would paint a ghost. This also covers a mover that is a
|
||||
// descendant of this node.
|
||||
if moved_bounds_before.is_some_and(|before| before.intersects(src_doc_bounds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
match moved_bounds {
|
||||
// Something is actually moving/resizing. If the moving content overlaps this
|
||||
// cached crop, do not use the cached pixels for this frame. We intentionally
|
||||
// keep the cache entry: overlap is typically transient during drag, and once
|
||||
// the moving content leaves the area the crop becomes valid again (stationary
|
||||
// shape unchanged).
|
||||
Some(moved) => {
|
||||
let intersects = self
|
||||
.backbuffer_crop_cache
|
||||
.get(&node_id)
|
||||
.is_some_and(|crop| moved.intersects(crop.src_doc_bounds));
|
||||
!intersects
|
||||
}
|
||||
// Overlap only matters when a mover paints *under* this node, where the crop
|
||||
// would cover it (the crop is a backbuffer crop, so it carries the backdrop as
|
||||
// it was). Movers that paint above are drawn after the blit and stay visible.
|
||||
Some(moved) => movers_paint_above || !moved.intersects(src_doc_bounds),
|
||||
|
||||
// Interactive-transform mode is active but nothing is moving (no modifiers):
|
||||
// e.g. editing a text shape inside this board reflows its content without
|
||||
@@ -576,7 +591,7 @@ impl RenderState {
|
||||
pending_nodes: vec![],
|
||||
current_tile: None,
|
||||
render_area: Rect::new_empty(),
|
||||
render_area_with_margins: Rect::new_empty(),
|
||||
cull_area: Rect::new_empty(),
|
||||
tiles,
|
||||
tile_viewbox: tiles::TileViewbox::new_with_interest(
|
||||
&viewbox,
|
||||
@@ -943,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);
|
||||
}
|
||||
@@ -1080,16 +1096,12 @@ impl RenderState {
|
||||
}
|
||||
|
||||
let fast_mode = self.options.is_fast_mode();
|
||||
// Decide *now* (at the first real cache blit) whether we need to clear Cache.
|
||||
// This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI),
|
||||
// while still preventing stale pixels from surviving across full-quality renders.
|
||||
// Nothing writes Cache any more; the flag is what `start_render_loop`
|
||||
// reads to decide whether `cached_viewbox` may advance.
|
||||
if !fast_mode && !self.cache_cleared_this_render {
|
||||
self.surfaces.clear_cache(self.background_color);
|
||||
self.cache_cleared_this_render = true;
|
||||
}
|
||||
// In fast mode the viewport is moving (pan/zoom) so Cache surface
|
||||
// positions would be wrong — only save to the tile HashMap.
|
||||
let tile_rect = self.get_current_aligned_tile_bounds()?;
|
||||
|
||||
let current_tile = *self
|
||||
.current_tile
|
||||
@@ -1103,8 +1115,6 @@ impl RenderState {
|
||||
self.surfaces.draw_current_tile_into_tile_atlas(
|
||||
&self.tile_viewbox,
|
||||
¤t_tile,
|
||||
&tile_rect,
|
||||
fast_mode,
|
||||
self.render_area,
|
||||
);
|
||||
|
||||
@@ -1115,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));
|
||||
}
|
||||
@@ -1134,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));
|
||||
}
|
||||
@@ -1164,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);
|
||||
});
|
||||
@@ -1332,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
|
||||
@@ -1406,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);
|
||||
@@ -1695,23 +1723,34 @@ impl RenderState {
|
||||
|
||||
let inner_shadows = shape.inner_shadow_paints();
|
||||
let blur_filter = shape.image_filter(1.);
|
||||
let mut paragraphs_with_shadows =
|
||||
text_content.paragraph_builder_group_from_text(Some(true));
|
||||
let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): (
|
||||
Vec<_>,
|
||||
Vec<_>,
|
||||
) = shape
|
||||
.visible_strokes()
|
||||
.rev()
|
||||
.map(|stroke| {
|
||||
text::stroke_paragraph_builder_group_from_text(
|
||||
text_content,
|
||||
stroke,
|
||||
&shape.selrect(),
|
||||
Some(true),
|
||||
)
|
||||
})
|
||||
.unzip();
|
||||
|
||||
// Safe to leave empty: every consumer is inside `for shadow in
|
||||
// <list>` or guarded by `!skip_drop_shadows`.
|
||||
let has_shadow_passes = if parent_shadows.is_some() {
|
||||
!skip_drop_shadows
|
||||
} else {
|
||||
!drop_shadows.is_empty() || !inner_shadows.is_empty()
|
||||
};
|
||||
|
||||
let mut paragraphs_with_shadows = Vec::new();
|
||||
let mut stroke_paragraphs_with_shadows_list = Vec::new();
|
||||
if has_shadow_passes {
|
||||
paragraphs_with_shadows =
|
||||
text_content.paragraph_builder_group_from_text(Some(true));
|
||||
stroke_paragraphs_with_shadows_list = shape
|
||||
.visible_strokes()
|
||||
.rev()
|
||||
.map(|stroke| {
|
||||
text::stroke_paragraph_builder_group_from_text(
|
||||
text_content,
|
||||
stroke,
|
||||
&shape.selrect(),
|
||||
Some(true),
|
||||
)
|
||||
.0
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if let Some(parent_shadows) = parent_shadows {
|
||||
if !skip_drop_shadows {
|
||||
@@ -2001,16 +2040,31 @@ impl RenderState {
|
||||
self.current_tile = Some(tile);
|
||||
let scale = self.get_scale();
|
||||
self.render_area = tiles::get_tile_rect(tile, scale);
|
||||
// One device pixel of slack for edges that land on the boundary. Callers
|
||||
// test bounds that already carry stroke/shadow/blur bleed.
|
||||
let epsilon = 1.0 / scale;
|
||||
self.cull_area = skia::Rect::from_ltrb(
|
||||
self.render_area.left - epsilon,
|
||||
self.render_area.top - epsilon,
|
||||
self.render_area.right + epsilon,
|
||||
self.render_area.bottom + epsilon,
|
||||
);
|
||||
self.surfaces.update_render_context(self.render_area, scale);
|
||||
}
|
||||
|
||||
/// Widens the cull area to the surface margins, for tiles whose content
|
||||
/// samples the backdrop (`render_background_blur` caps sigma to `margin / 3`).
|
||||
fn widen_cull_area_for_backdrop(&mut self) {
|
||||
let scale = self.get_scale();
|
||||
let margins = self.surfaces.margins();
|
||||
let margin_w = margins.width as f32 / scale;
|
||||
let margin_h = margins.height as f32 / scale;
|
||||
self.render_area_with_margins = skia::Rect::from_ltrb(
|
||||
self.cull_area = skia::Rect::from_ltrb(
|
||||
self.render_area.left - margin_w,
|
||||
self.render_area.top - margin_h,
|
||||
self.render_area.right + margin_w,
|
||||
self.render_area.bottom + margin_h,
|
||||
);
|
||||
self.surfaces.update_render_context(self.render_area, scale);
|
||||
}
|
||||
|
||||
fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) {
|
||||
@@ -2197,6 +2251,7 @@ impl RenderState {
|
||||
img
|
||||
};
|
||||
|
||||
count!(Counter::CropEntriesBuilt);
|
||||
self.backbuffer_crop_cache.insert(
|
||||
id,
|
||||
InteractiveDragCrop {
|
||||
@@ -2260,6 +2315,10 @@ impl RenderState {
|
||||
|
||||
self.surfaces.gc();
|
||||
|
||||
if self.current_tile.is_some() && !self.pending_nodes.is_empty() {
|
||||
count!(Counter::TilesDiscardedInflight);
|
||||
}
|
||||
|
||||
self.pending_nodes.clear();
|
||||
if self.pending_nodes.capacity() < tree.len() {
|
||||
self.pending_nodes
|
||||
@@ -2283,6 +2342,7 @@ impl RenderState {
|
||||
timestamp: i32,
|
||||
sync_render: bool,
|
||||
) -> Result<FrameType> {
|
||||
count!(Counter::RenderLoopStarts);
|
||||
self.clear(tree);
|
||||
|
||||
let _start = performance::begin_timed_log!("start_render_loop");
|
||||
@@ -2376,8 +2436,15 @@ impl RenderState {
|
||||
} else {
|
||||
// Keep progressive yielding, except for a localized shape edit on a
|
||||
// stable viewbox (e.g. recoloring) which renders in one frame.
|
||||
let allow_stop =
|
||||
!preserve_target || self.zoom_changed() || self.options.is_interactive_transform();
|
||||
// "Localized" is a tile count: a gesture commit invalidates the whole
|
||||
// viewport, and rendering that without yielding blocks the thread
|
||||
// handling pointerup until every tile is done.
|
||||
let queued_uncached = self.pending_tiles.visible_uncached.len()
|
||||
+ self.pending_tiles.interest_uncached.len();
|
||||
let allow_stop = !preserve_target
|
||||
|| self.zoom_changed()
|
||||
|| self.options.is_interactive_transform()
|
||||
|| queued_uncached > MAX_SYNC_TILES_ON_PRESERVED_TARGET;
|
||||
frame_type = self.continue_render_loop(base_object, tree, timestamp, allow_stop)?;
|
||||
|
||||
// This is an option to debug frames.
|
||||
@@ -2440,6 +2507,7 @@ impl RenderState {
|
||||
timestamp: i32,
|
||||
allow_stop: bool,
|
||||
) -> Result<FrameType> {
|
||||
count!(Counter::RenderLoopContinues);
|
||||
performance::begin_measure!("continue_render_loop");
|
||||
let timestamp = self.render_budget_start(timestamp);
|
||||
let frame_type =
|
||||
@@ -2530,7 +2598,7 @@ impl RenderState {
|
||||
let saved_focus_mode = self.focus_mode.clone();
|
||||
let saved_export_context = self.export_context;
|
||||
let saved_render_area = self.render_area;
|
||||
let saved_render_area_with_margins = self.render_area_with_margins;
|
||||
let saved_cull_area = self.cull_area;
|
||||
let saved_current_tile = self.current_tile;
|
||||
let saved_pending_nodes = std::mem::take(&mut self.pending_nodes);
|
||||
let saved_nested_fills = std::mem::take(&mut self.nested_fills);
|
||||
@@ -2560,7 +2628,7 @@ impl RenderState {
|
||||
|
||||
self.surfaces.resize_export_surface(scale, extrect);
|
||||
self.render_area = extrect;
|
||||
self.render_area_with_margins = extrect;
|
||||
self.cull_area = extrect;
|
||||
self.surfaces.update_render_context(extrect, scale);
|
||||
|
||||
// `resize_export_surface` swaps in a brand-new (zeroed, i.e.
|
||||
@@ -2601,7 +2669,7 @@ impl RenderState {
|
||||
self.focus_mode = saved_focus_mode;
|
||||
self.export_context = saved_export_context;
|
||||
self.render_area = saved_render_area;
|
||||
self.render_area_with_margins = saved_render_area_with_margins;
|
||||
self.cull_area = saved_cull_area;
|
||||
self.current_tile = saved_current_tile;
|
||||
self.pending_nodes = saved_pending_nodes;
|
||||
self.nested_fills = saved_nested_fills;
|
||||
@@ -3065,7 +3133,7 @@ impl RenderState {
|
||||
// Account for the shadow offset so the temporary surface fully contains the shifted blur.
|
||||
bounds.offset(world_offset);
|
||||
// Early cull if the shadow bounds are outside the render area.
|
||||
if !bounds.intersects(self.render_area_with_margins) && target_surface != SurfaceId::Export
|
||||
if !bounds.intersects(self.cull_area) && target_surface != SurfaceId::Export
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
@@ -3389,49 +3457,76 @@ impl RenderState {
|
||||
target_surface = SurfaceId::Export;
|
||||
}
|
||||
|
||||
// During interactive transforms we compute the union of the current bounds of all
|
||||
// modified shapes (doc-space @ 100% zoom, scale=1.0). This is used as a cheap overlap
|
||||
// guard to decide when cached top-level crops are unsafe to reuse (something is moving
|
||||
// over/inside them), without doing expensive ancestor walks per node.
|
||||
// Bounds of the moving shapes (doc space @ 100% zoom), as cheap overlap guards for
|
||||
// cached top-level crops. Current and pre-modifier bounds are kept apart: the
|
||||
// pre-modifier union says where the crops hold stale pixels, the current union says
|
||||
// what the movers now cover. Unioning them would make a drag that started far away
|
||||
// poison every crop between the start and the cursor.
|
||||
//
|
||||
// `modifier_ids` is pre-computed once here and reused throughout the loop to avoid
|
||||
// repeated allocations (formerly O(N_shapes) HashMap builds) per node.
|
||||
let modifier_ids = tree.modifier_ids();
|
||||
let moved_bounds = if self.options.is_interactive_transform() && !modifier_ids.is_empty() {
|
||||
let mut acc: Option<Rect> = None;
|
||||
let interactive_moving =
|
||||
self.options.is_interactive_transform() && !modifier_ids.is_empty();
|
||||
let mut moved_bounds: Option<Rect> = None;
|
||||
let mut moved_bounds_before: Option<Rect> = None;
|
||||
if interactive_moving {
|
||||
let join = |acc: &mut Option<Rect>, r: Rect| match acc {
|
||||
None => *acc = Some(r),
|
||||
Some(prev) => {
|
||||
prev.join(r);
|
||||
}
|
||||
};
|
||||
for id in modifier_ids.iter() {
|
||||
// Current (post-modifier) bounds
|
||||
if let Some(s) = tree.get(id) {
|
||||
let r = self.get_cached_extrect(s, tree, 1.0);
|
||||
acc = Some(match acc {
|
||||
None => r,
|
||||
Some(mut prev) => {
|
||||
prev.join(r);
|
||||
prev
|
||||
}
|
||||
});
|
||||
join(&mut moved_bounds, r);
|
||||
}
|
||||
|
||||
// Pre-modifier bounds: important so cached top-level crops that still contain the
|
||||
// shape at its original position are considered "unsafe" even after the shape
|
||||
// has moved away (e.g. dragging a child out of a clipped frame).
|
||||
if let Some(raw) = tree.get_raw(id) {
|
||||
let r0 = self.get_cached_extrect(raw, tree, 1.0);
|
||||
acc = Some(match acc {
|
||||
None => r0,
|
||||
Some(mut prev) => {
|
||||
prev.join(r0);
|
||||
prev
|
||||
}
|
||||
});
|
||||
join(&mut moved_bounds_before, r0);
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
// `children_ids(false)` reverses, so index 0 is the topmost root and a *lower* index
|
||||
// paints later. A crop stays reusable under an overlapping mover only when every
|
||||
// mover paints above it, i.e. sits at a strictly lower index.
|
||||
let root_paint_index: HashMap<Uuid, usize> = if interactive_moving {
|
||||
tree.get(&Uuid::nil())
|
||||
.map(|root| {
|
||||
root.children_ids(false)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, id)| (id, i))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let index_of_root = |id: &Uuid| root_paint_index.get(id).copied();
|
||||
let top_level_ancestor = |mut id: Uuid| -> Option<Uuid> {
|
||||
for _ in 0..64 {
|
||||
let parent = tree.get_raw(&id).and_then(|s| s.parent_id)?;
|
||||
if parent == Uuid::nil() {
|
||||
return Some(id);
|
||||
}
|
||||
id = parent;
|
||||
}
|
||||
None
|
||||
};
|
||||
let moved_max_root_index = if interactive_moving {
|
||||
modifier_ids
|
||||
.iter()
|
||||
.map(|id| top_level_ancestor(*id).and_then(|top| index_of_root(&top)))
|
||||
.try_fold(0usize, |acc, idx| idx.map(|i| acc.max(i)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -3510,11 +3605,11 @@ impl RenderState {
|
||||
|| if is_container || has_effects {
|
||||
let element_extrect =
|
||||
extrect.get_or_insert_with(|| transformed_element.extrect(tree, scale));
|
||||
element_extrect.intersects(self.render_area_with_margins)
|
||||
element_extrect.intersects(self.cull_area)
|
||||
&& !transformed_element.visually_insignificant(scale, tree)
|
||||
} else {
|
||||
let selrect = transformed_element.selrect();
|
||||
selrect.intersects(self.render_area_with_margins)
|
||||
selrect.intersects(self.cull_area)
|
||||
&& !transformed_element.visually_insignificant(scale, tree)
|
||||
};
|
||||
|
||||
@@ -3524,6 +3619,7 @@ impl RenderState {
|
||||
}
|
||||
|
||||
if !is_visible {
|
||||
count!(Counter::WalkerCulled);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -3532,15 +3628,26 @@ impl RenderState {
|
||||
// draw it directly from Backbuffer crop on the current tile surface and skip
|
||||
// traversing/rendering the subtree.
|
||||
if self.options.is_interactive_transform() {
|
||||
let movers_paint_above = match (moved_max_root_index, index_of_root(&node_id)) {
|
||||
(Some(moved_idx), Some(node_idx)) => moved_idx < node_idx,
|
||||
_ => false,
|
||||
};
|
||||
let use_cached = self.should_use_cached_top_level_during_interactive(
|
||||
node_id,
|
||||
tree,
|
||||
modifier_ids,
|
||||
moved_bounds,
|
||||
moved_bounds_before,
|
||||
movers_paint_above,
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
@@ -3831,6 +3938,7 @@ impl RenderState {
|
||||
}
|
||||
|
||||
if early_return {
|
||||
count!(Counter::PartialYields);
|
||||
self.viewer_render_root = None;
|
||||
return Ok(FrameType::Partial);
|
||||
}
|
||||
@@ -3842,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.
|
||||
@@ -3888,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;
|
||||
};
|
||||
|
||||
@@ -3895,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;
|
||||
}
|
||||
|
||||
@@ -3925,6 +4036,10 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
if tile_has_bg_blur {
|
||||
self.widen_cull_area_for_backdrop();
|
||||
}
|
||||
|
||||
if !valid_ids.is_empty() {
|
||||
self.current_tile_had_shapes = true;
|
||||
}
|
||||
@@ -4011,6 +4126,7 @@ impl RenderState {
|
||||
shape: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
) -> HashSet<tiles::Tile> {
|
||||
count!(Counter::ShapeTileUpdates);
|
||||
let tile_rect = self.get_tiles_for_shape(shape, tree);
|
||||
|
||||
// Collect old tiles to avoid borrow conflict with remove_shape_at
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -155,6 +155,12 @@ pub fn render_text_shadows(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Before `canvas_and_mark_dirty`: an empty-but-dirty layer still costs a
|
||||
// full surface composite.
|
||||
if shadows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let canvas = render_state
|
||||
.surfaces
|
||||
.canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::TextDropShadows));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1208,8 +1215,6 @@ impl Surfaces {
|
||||
&mut self,
|
||||
tile_viewbox: &TileViewbox,
|
||||
tile: &Tile,
|
||||
tile_rect: &skia::Rect,
|
||||
skip_cache_surface: bool,
|
||||
tile_doc_rect: skia::Rect,
|
||||
) {
|
||||
let gpu_state = get_gpu_state();
|
||||
@@ -1217,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,
|
||||
@@ -1226,23 +1232,12 @@ 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();
|
||||
draw_surface_src_rect_to_dst(&mut current, self.tile_atlas.canvas(), src, dst, sampling);
|
||||
|
||||
if !skip_cache_surface {
|
||||
// Optional legacy Cache surface fill (debug). Pan/zoom preview
|
||||
// uses DocAtlas + tile-atlas textures via render_from_cache.
|
||||
let mut current = self.current.clone();
|
||||
draw_surface_src_rect_to_dst(
|
||||
&mut current,
|
||||
self.cache.canvas(),
|
||||
src,
|
||||
*tile_rect,
|
||||
sampling,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_cached_tile_surface(&self, tile: Tile) -> bool {
|
||||
@@ -1357,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,
|
||||
@@ -1385,35 +1381,37 @@ impl Surfaces {
|
||||
);
|
||||
let src_rect_f = skia::Rect::from(src_rect);
|
||||
|
||||
let backbuffer_canvas = self.backbuffer.canvas();
|
||||
|
||||
// Draw background
|
||||
// let mut paint = skia::Paint::default();
|
||||
// paint.set_color(color);
|
||||
// backbuffer_canvas.draw_rect(tile_rect, &paint);
|
||||
|
||||
// Draw current surface directly to target (no snapshot)
|
||||
self.current.draw(
|
||||
backbuffer_canvas,
|
||||
(
|
||||
tile_rect.left - src_rect_f.left,
|
||||
tile_rect.top - src_rect_f.top,
|
||||
),
|
||||
sampling_options,
|
||||
None,
|
||||
let origin = (
|
||||
tile_rect.left - src_rect_f.left,
|
||||
tile_rect.top - src_rect_f.top,
|
||||
);
|
||||
|
||||
// Also draw to cache for render_from_cache
|
||||
// Clipped to the tile: `current` is a whole tile surface with margins on
|
||||
// every side, cleared to the background colour, so an unclipped draw
|
||||
// repaints a 256px halo of background over the neighbouring tiles.
|
||||
// Rounded out because the viewbox offset can be fractional, and a clip
|
||||
// that rounds inwards would leave a seam between adjacent tiles.
|
||||
let clip = skia::Rect::from_ltrb(
|
||||
tile_rect.left.floor(),
|
||||
tile_rect.top.floor(),
|
||||
tile_rect.right.ceil(),
|
||||
tile_rect.bottom.ceil(),
|
||||
);
|
||||
|
||||
let backbuffer_canvas = self.backbuffer.canvas();
|
||||
backbuffer_canvas.save();
|
||||
backbuffer_canvas.clip_rect(clip, None, false);
|
||||
self.current
|
||||
.draw(backbuffer_canvas, origin, sampling_options, None);
|
||||
backbuffer_canvas.restore();
|
||||
|
||||
if draw_on_cache == DrawOnCache::Yes {
|
||||
self.current.draw(
|
||||
self.cache.canvas(),
|
||||
(
|
||||
tile_rect.left - src_rect_f.left,
|
||||
tile_rect.top - src_rect_f.top,
|
||||
),
|
||||
sampling_options,
|
||||
None,
|
||||
);
|
||||
let cache_canvas = self.cache.canvas();
|
||||
cache_canvas.save();
|
||||
cache_canvas.clip_rect(clip, None, false);
|
||||
self.current
|
||||
.draw(cache_canvas, origin, sampling_options, None);
|
||||
cache_canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1421,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);
|
||||
@@ -1431,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;
|
||||
|
||||
@@ -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<bool>,
|
||||
) -> (Vec<ParagraphBuilderGroup>, Option<f32>) {
|
||||
count!(Counter::ParagraphBuilds);
|
||||
let fallback_fonts = get_fallback_fonts();
|
||||
let fonts = get_font_collection();
|
||||
let mut paragraph_group = Vec::new();
|
||||
|
||||
@@ -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<bool>,
|
||||
) -> Vec<ParagraphBuilderGroup> {
|
||||
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<ParagraphBuilderGroup> {
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user