summaryrefslogtreecommitdiff
path: root/apps/web/src/diagnostics/render-perf.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-26 20:44:07 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-26 20:44:07 +0200
commit6b1f38a9a31a0eedaa7abea70f232960469351d5 (patch)
tree4050d69056c1d9f14075b784535e2b33719f477b /apps/web/src/diagnostics/render-perf.ts
parent56ca09692a308b22455420d11376ff8d41244c47 (diff)
parent0b7597b31fa956c7f3d4838923b545ba5e341422 (diff)
downloadopencut-6b1f38a9a31a0eedaa7abea70f232960469351d5.tar.gz
refactor: split animation helpers by domain
Diffstat (limited to 'apps/web/src/diagnostics/render-perf.ts')
-rw-r--r--apps/web/src/diagnostics/render-perf.ts182
1 files changed, 182 insertions, 0 deletions
diff --git a/apps/web/src/diagnostics/render-perf.ts b/apps/web/src/diagnostics/render-perf.ts
new file mode 100644
index 00000000..7b0b7fd9
--- /dev/null
+++ b/apps/web/src/diagnostics/render-perf.ts
@@ -0,0 +1,182 @@
+/**
+ * Lightweight rolling perf instrumentation for the render pipeline.
+ *
+ * Toggle at runtime from the devtools console:
+ * window.__renderPerf = true
+ *
+ * Every FLUSH_EVERY frames the aggregator dumps:
+ * - per-span timing summary (count / mean / p50 / p95 / max, in ms)
+ * - per-counter totals (uploads, canvas allocations by kind, etc.)
+ *
+ * Zero overhead when disabled: `isRenderPerfEnabled()` short-circuits before
+ * any recording happens, so call sites only pay for a global read.
+ */
+
+type SpanSample = number;
+
+type SpanStats = {
+ samples: SpanSample[];
+};
+
+type CounterStats = {
+ total: number;
+ frames: number;
+};
+
+const FLUSH_EVERY = 60;
+
+const spans = new Map<string, SpanStats>();
+const counters = new Map<string, CounterStats>();
+const pendingCountersThisFrame = new Map<string, number>();
+
+let framesSinceFlush = 0;
+
+declare global {
+ interface Window {
+ __renderPerf?: boolean;
+ }
+}
+
+export function isRenderPerfEnabled(): boolean {
+ return typeof window !== "undefined" && window.__renderPerf === true;
+}
+
+export function recordSpan({
+ name,
+ durationMs,
+}: {
+ name: string;
+ durationMs: number;
+}): void {
+ if (!isRenderPerfEnabled()) return;
+ let stats = spans.get(name);
+ if (!stats) {
+ stats = { samples: [] };
+ spans.set(name, stats);
+ }
+ stats.samples.push(durationMs);
+}
+
+export async function measureSpanAsync<T>({
+ name,
+ fn,
+}: {
+ name: string;
+ fn: () => Promise<T>;
+}): Promise<T> {
+ if (!isRenderPerfEnabled()) return fn();
+ const start = performance.now();
+ try {
+ return await fn();
+ } finally {
+ recordSpan({ name, durationMs: performance.now() - start });
+ }
+}
+
+export function measureSpanSync<T>({
+ name,
+ fn,
+}: {
+ name: string;
+ fn: () => T;
+}): T {
+ if (!isRenderPerfEnabled()) return fn();
+ const start = performance.now();
+ try {
+ return fn();
+ } finally {
+ recordSpan({ name, durationMs: performance.now() - start });
+ }
+}
+
+export function incrementCounter({
+ name,
+ by = 1,
+}: {
+ name: string;
+ by?: number;
+}): void {
+ if (!isRenderPerfEnabled()) return;
+ pendingCountersThisFrame.set(
+ name,
+ (pendingCountersThisFrame.get(name) ?? 0) + by,
+ );
+}
+
+/**
+ * Pulls sub-span timings recorded inside the wasm `renderFrame` call and
+ * feeds them into the aggregator as ordinary spans.
+ */
+export function recordWasmFrameProfile(
+ entries: Array<{ name: string; durationMs: number }>,
+): void {
+ if (!isRenderPerfEnabled()) return;
+ for (const entry of entries) {
+ recordSpan({ name: entry.name, durationMs: entry.durationMs });
+ }
+}
+
+/**
+ * Called once per frame by the top of the render pipeline. Rolls the
+ * pending-frame counters into the aggregate and triggers a flush on cadence.
+ */
+export function onRenderPerfFrameComplete(): void {
+ if (!isRenderPerfEnabled()) return;
+ for (const [name, count] of pendingCountersThisFrame) {
+ let stats = counters.get(name);
+ if (!stats) {
+ stats = { total: 0, frames: 0 };
+ counters.set(name, stats);
+ }
+ stats.total += count;
+ stats.frames += 1;
+ }
+ pendingCountersThisFrame.clear();
+
+ framesSinceFlush += 1;
+ if (framesSinceFlush >= FLUSH_EVERY) {
+ flush();
+ }
+}
+
+function flush(): void {
+ const spanRows: Array<Record<string, number | string>> = [];
+ for (const [name, stats] of spans) {
+ if (stats.samples.length === 0) continue;
+ const sorted = [...stats.samples].sort((a, b) => a - b);
+ const p = (q: number) =>
+ sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))];
+ const sum = sorted.reduce((acc, v) => acc + v, 0);
+ spanRows.push({
+ span: name,
+ count: sorted.length,
+ meanMs: +(sum / sorted.length).toFixed(2),
+ p50Ms: +p(0.5).toFixed(2),
+ p95Ms: +p(0.95).toFixed(2),
+ maxMs: +sorted[sorted.length - 1].toFixed(2),
+ });
+ }
+ spanRows.sort((a, b) => Number(b.meanMs) - Number(a.meanMs));
+
+ const counterRows: Array<Record<string, number | string>> = [];
+ for (const [name, stats] of counters) {
+ counterRows.push({
+ counter: name,
+ perFrame: +(stats.total / Math.max(1, stats.frames)).toFixed(2),
+ total: stats.total,
+ frames: stats.frames,
+ });
+ }
+ counterRows.sort((a, b) => Number(b.perFrame) - Number(a.perFrame));
+
+ console.groupCollapsed(
+ `[render-perf] summary over ${framesSinceFlush} frames`,
+ );
+ if (spanRows.length > 0) console.table(spanRows);
+ if (counterRows.length > 0) console.table(counterRows);
+ console.groupEnd();
+
+ spans.clear();
+ counters.clear();
+ framesSinceFlush = 0;
+}