perf: improve rendering performance by ~20x

This commit is contained in:
Maze Winther 2026-04-22 16:48:37 +02:00
parent f1b45b4328
commit cceb835a84
11 changed files with 780 additions and 357 deletions

View File

@ -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;
}

View File

@ -132,7 +132,7 @@ function PreviewCanvas({
isVisible: boolean; isVisible: boolean;
}) => void; }) => void;
}) { }) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasMountRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null); const viewportRef = useRef<HTMLDivElement>(null);
const lastFrameRef = useRef(-1); const lastFrameRef = useRef(-1);
const lastSceneRef = useRef<RootNode | null>(null); const lastSceneRef = useRef<RootNode | null>(null);
@ -158,35 +158,51 @@ function PreviewCanvas({
}); });
}, [nativeWidth, nativeHeight, activeProject.settings.fps]); }, [nativeWidth, nativeHeight, activeProject.settings.fps]);
const render = useCallback(() => { // Mount the compositor's output canvas directly into the preview. wgpu
if (canvasRef.current && renderTree && !renderingRef.current) { // renders straight into this element, so there is no intermediate copy —
const renderTime = Math.min( // the container div owns positioning/styling, the canvas itself fills it.
editor.playback.getCurrentTime(), useEffect(() => {
editor.timeline.getLastFrameTime(), const mount = canvasMountRef.current;
); if (!mount) return;
const ticksPerFrame = Math.round( const outputCanvas = renderer.getOutputCanvas();
(TICKS_PER_SECOND * renderer.fps.denominator) / renderer.fps.numerator, outputCanvas.style.display = "block";
); outputCanvas.style.width = "100%";
const frame = Math.floor(renderTime / ticksPerFrame); outputCanvas.style.height = "100%";
mount.appendChild(outputCanvas);
if ( return () => {
frame !== lastFrameRef.current || if (outputCanvas.parentElement === mount) {
renderTree !== lastSceneRef.current mount.removeChild(outputCanvas);
) {
renderingRef.current = true;
lastSceneRef.current = renderTree;
lastFrameRef.current = frame;
renderer
.renderToCanvas({
node: renderTree,
time: renderTime,
targetCanvas: canvasRef.current,
})
.then(() => {
renderingRef.current = false;
});
} }
};
}, [renderer]);
const render = useCallback(() => {
if (!renderTree || renderingRef.current) return;
const renderTime = Math.min(
editor.playback.getCurrentTime(),
editor.timeline.getLastFrameTime(),
);
const ticksPerFrame = Math.round(
(TICKS_PER_SECOND * renderer.fps.denominator) / renderer.fps.numerator,
);
const frame = Math.floor(renderTime / ticksPerFrame);
if (
frame === lastFrameRef.current &&
renderTree === lastSceneRef.current
) {
return;
} }
renderingRef.current = true;
lastSceneRef.current = renderTree;
lastFrameRef.current = frame;
renderer
.render({ node: renderTree, time: renderTime })
.then(() => {
renderingRef.current = false;
});
}, [renderer, renderTree, editor.playback, editor.timeline]); }, [renderer, renderTree, editor.playback, editor.timeline]);
useRafLoop(render); useRafLoop(render);
@ -286,22 +302,20 @@ function PreviewCanvas({
ref={viewportRef} ref={viewportRef}
className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden" className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden"
> >
<canvas <div
ref={canvasRef} ref={canvasMountRef}
width={nativeWidth} className="absolute block border"
height={nativeHeight} style={{
className="absolute block border" left: viewport.sceneLeft,
style={{ top: viewport.sceneTop,
left: viewport.sceneLeft, width: viewport.sceneWidth,
top: viewport.sceneTop, height: viewport.sceneHeight,
width: viewport.sceneWidth, background:
height: viewport.sceneHeight, activeProject.settings.background.type === "blur"
background: ? "transparent"
activeProject.settings.background.type === "blur" : activeProject?.settings.background.color,
? "transparent" }}
: activeProject?.settings.background.color, />
}}
/>
<PreviewOverlayLayer <PreviewOverlayLayer
instances={overlayInstances} instances={overlayInstances}
plane="under-interaction" plane="under-interaction"

View File

@ -3,6 +3,11 @@ import type { AnyBaseNode } from "./nodes/base-node";
import { buildFrameDescriptor } from "./compositor/frame-descriptor"; import { buildFrameDescriptor } from "./compositor/frame-descriptor";
import { wasmCompositor } from "./compositor/wasm-compositor"; import { wasmCompositor } from "./compositor/wasm-compositor";
import { resolveRenderTree } from "./resolve"; import { resolveRenderTree } from "./resolve";
import {
measureSpanAsync,
measureSpanSync,
onRenderPerfFrameComplete,
} from "@/diagnostics/render-perf";
export type CanvasRendererParams = { export type CanvasRendererParams = {
width: number; width: number;
@ -69,17 +74,26 @@ export class CanvasRenderer {
} }
async render({ node, time }: { node: AnyBaseNode; time: number }) { async render({ node, time }: { node: AnyBaseNode; time: number }) {
await resolveRenderTree({ node, renderer: this, time }); await measureSpanAsync({
const { frame, textures } = await buildFrameDescriptor({ name: "resolve",
node, fn: () => resolveRenderTree({ node, renderer: this, time }),
renderer: this, });
const { frame, textures } = await measureSpanAsync({
name: "buildFrame",
fn: () => buildFrameDescriptor({ node, renderer: this }),
}); });
wasmCompositor.ensureInitialized({ wasmCompositor.ensureInitialized({
width: this.width, width: this.width,
height: this.height, height: this.height,
}); });
wasmCompositor.syncTextures(textures); measureSpanSync({
wasmCompositor.render(frame); name: "syncTextures",
fn: () => wasmCompositor.syncTextures(textures),
});
measureSpanSync({
name: "renderFrame",
fn: () => wasmCompositor.render(frame),
});
} }
async renderToCanvas({ async renderToCanvas({
@ -98,12 +112,17 @@ export class CanvasRenderer {
throw new Error("Failed to get target canvas context"); throw new Error("Failed to get target canvas context");
} }
ctx.drawImage( measureSpanSync({
wasmCompositor.getCanvas(), name: "drawImage",
0, fn: () =>
0, ctx.drawImage(
targetCanvas.width, wasmCompositor.getCanvas(),
targetCanvas.height, 0,
); 0,
targetCanvas.width,
targetCanvas.height,
),
});
onRenderPerfFrameComplete();
} }
} }

View File

@ -1,5 +1,6 @@
import { drawCssBackground } from "@/gradients"; import { drawCssBackground } from "@/gradients";
import { masksRegistry } from "@/masks"; import { masksRegistry } from "@/masks";
import { incrementCounter } from "@/diagnostics/render-perf";
import type { AnyBaseNode } from "../nodes/base-node"; import type { AnyBaseNode } from "../nodes/base-node";
import type { CanvasRenderer } from "../canvas-renderer"; import type { CanvasRenderer } from "../canvas-renderer";
import { createOffscreenCanvas } from "../canvas-utils"; import { createOffscreenCanvas } from "../canvas-utils";
@ -21,16 +22,11 @@ import type {
FrameItemDescriptor, FrameItemDescriptor,
LayerMaskDescriptor, LayerMaskDescriptor,
QuadTransformDescriptor, QuadTransformDescriptor,
TextureCanvasDrawFn,
TextureUploadDescriptor,
} from "./types"; } from "./types";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics"; import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
export type TextureUploadDescriptor = {
id: string;
source: CanvasImageSource;
width: number;
height: number;
};
export async function buildFrameDescriptor({ export async function buildFrameDescriptor({
node, node,
renderer, renderer,
@ -52,6 +48,9 @@ export async function buildFrameDescriptor({
textures, textures,
}); });
incrementCounter({ name: "frameItems", by: items.length });
incrementCounter({ name: "frameTextures", by: textures.size });
return { return {
frame: { frame: {
width: renderer.width, width: renderer.width,
@ -93,31 +92,21 @@ async function collectNode({
if (node instanceof ColorNode) { if (node instanceof ColorNode) {
const textureId = `${path}:color`; const textureId = `${path}:color`;
const canvas = createOffscreenCanvas({ const { width, height } = renderer;
width: renderer.width,
height: renderer.height,
});
const ctx = canvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!ctx) return;
if (/gradient\(/i.test(node.params.color)) {
drawCssBackground({
ctx,
width: renderer.width,
height: renderer.height,
css: node.params.color,
});
} else {
ctx.fillStyle = node.params.color;
ctx.fillRect(0, 0, renderer.width, renderer.height);
}
textures.set(textureId, { textures.set(textureId, {
kind: "rendered",
id: textureId, id: textureId,
source: canvas, contentHash: `color:${node.params.color}:${width}x${height}`,
width: renderer.width, width,
height: renderer.height, height,
draw: (ctx) => {
if (/gradient\(/i.test(node.params.color)) {
drawCssBackground({ ctx, width, height, css: node.params.color });
} else {
ctx.fillStyle = node.params.color;
ctx.fillRect(0, 0, width, height);
}
},
}); });
items.push({ items.push({
type: "layer", type: "layer",
@ -147,36 +136,35 @@ async function collectNode({
return; return;
} }
const textureId = `${path}:blur-background`; const textureId = `${path}:blur-background`;
const backdropCanvas = createOffscreenCanvas({ const { width, height } = renderer;
width: renderer.width,
height: renderer.height,
});
const backdropCtx = backdropCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!backdropCtx) return;
const { backdropSource, passes } = node.resolved; const { backdropSource, passes } = node.resolved;
const coverScale = Math.max( // Backdrop pixels come from a decoded video/image frame whose identity
renderer.width / backdropSource.width, // already changes when it changes. Hashing the source reference is
renderer.height / backdropSource.height, // enough to let us skip redraws on frozen frames.
); const contentHash = `blur:${identityKey(backdropSource.source)}:${backdropSource.width}x${backdropSource.height}:${width}x${height}`;
const scaledWidth = backdropSource.width * coverScale;
const scaledHeight = backdropSource.height * coverScale;
const offsetX = (renderer.width - scaledWidth) / 2;
const offsetY = (renderer.height - scaledHeight) / 2;
backdropCtx.drawImage(
backdropSource.source,
offsetX,
offsetY,
scaledWidth,
scaledHeight,
);
textures.set(textureId, { textures.set(textureId, {
kind: "rendered",
id: textureId, id: textureId,
source: backdropCanvas, contentHash,
width: renderer.width, width,
height: renderer.height, height,
draw: (ctx) => {
const coverScale = Math.max(
width / backdropSource.width,
height / backdropSource.height,
);
const scaledWidth = backdropSource.width * coverScale;
const scaledHeight = backdropSource.height * coverScale;
const offsetX = (width - scaledWidth) / 2;
const offsetY = (height - scaledHeight) / 2;
ctx.drawImage(
backdropSource.source,
offsetX,
offsetY,
scaledWidth,
scaledHeight,
);
},
}); });
items.push({ items.push({
type: "layer", type: "layer",
@ -253,6 +241,7 @@ async function collectVisualSourceNode({
const textureId = `${path}:source`; const textureId = `${path}:source`;
textures.set(textureId, { textures.set(textureId, {
kind: "external",
id: textureId, id: textureId,
source, source,
width: sourceWidth, width: sourceWidth,
@ -305,28 +294,24 @@ function collectTextNode({
} }
const textureId = `${path}:text`; const textureId = `${path}:text`;
const canvas = createOffscreenCanvas({ const { width, height } = renderer;
width: renderer.width, // Text output is fully determined by node.params + node.resolved. Both are
height: renderer.height, // plain data we can stringify cheaply; the resolved measured layout is the
}); // expensive part of text setup, so stringifying it here is orders of
const ctx = canvas.getContext("2d") as // magnitude cheaper than re-rasterizing when nothing changed.
| CanvasRenderingContext2D const contentHash = `text:${width}x${height}:${JSON.stringify({
| OffscreenCanvasRenderingContext2D params: node.params,
| null; resolved: node.resolved,
if (!ctx) { })}`;
return;
}
renderTextToContext({
node,
ctx,
});
textures.set(textureId, { textures.set(textureId, {
kind: "rendered",
id: textureId, id: textureId,
source: canvas, contentHash,
width: renderer.width, width,
height: renderer.height, height,
draw: (ctx) => {
renderTextToContext({ node, ctx });
},
}); });
items.push({ items.push({
type: "layer", type: "layer",
@ -411,20 +396,6 @@ function buildMaskArtifacts({
return { mask: null, strokeLayer: null }; return { mask: null, strokeLayer: null };
} }
const elementMaskCanvas = createOffscreenCanvas({
width: Math.round(transform.width),
height: Math.round(transform.height),
});
const elementMaskCtx = elementMaskCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!elementMaskCtx) {
return { mask: null, strokeLayer: null };
}
elementMaskCtx.clearRect(0, 0, transform.width, transform.height);
let strokePath: Path2D | null = null;
let feather = mask.params.feather; let feather = mask.params.feather;
const canRenderMaskDirectly = Boolean(definition.renderer.renderMask); const canRenderMaskDirectly = Boolean(definition.renderer.renderMask);
const shouldRenderMaskDirectly = const shouldRenderMaskDirectly =
@ -432,81 +403,78 @@ function buildMaskArtifacts({
(!definition.renderer.buildPath || (!definition.renderer.buildPath ||
(mask.params.feather > 0 && (mask.params.feather > 0 &&
definition.renderer.renderMaskHandlesFeather)); definition.renderer.renderMaskHandlesFeather));
if (shouldRenderMaskDirectly && definition.renderer.renderMask) { if (
definition.renderer.renderMask({ shouldRenderMaskDirectly &&
resolvedParams: mask.params, definition.renderer.renderMaskHandlesFeather
ctx: elementMaskCtx, ) {
width: Math.round(transform.width), feather = 0;
height: Math.round(transform.height),
feather: mask.params.feather,
});
if (definition.renderer.renderMaskHandlesFeather) {
feather = 0;
}
strokePath =
definition.renderer.buildStrokePath?.({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
}) ?? null;
} else {
if (!definition.renderer.buildPath) {
return { mask: null, strokeLayer: null };
}
const path2d = definition.renderer.buildPath({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
});
elementMaskCtx.fillStyle = "white";
elementMaskCtx.fill(path2d);
strokePath =
definition.renderer.buildStrokePath?.({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
}) ?? path2d;
} }
const fullMaskCanvas = createOffscreenCanvas({
width: renderer.width,
height: renderer.height,
});
const fullMaskCtx = fullMaskCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!fullMaskCtx) {
return { mask: null, strokeLayer: null };
}
drawTransformedCanvas({
ctx: fullMaskCtx,
source: elementMaskCanvas,
transform,
});
const maskTextureId = `${path}:mask`; const maskTextureId = `${path}:mask`;
textures.set(maskTextureId, { const { width: canvasWidth, height: canvasHeight } = renderer;
id: maskTextureId, const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`;
source: fullMaskCanvas, const drawMask: TextureCanvasDrawFn = (ctx) => {
width: renderer.width, const elementMaskCanvas = createOffscreenCanvas({
height: renderer.height,
});
let strokeLayer: FrameItemDescriptor | null = null;
if (
mask.params.strokeWidth > 0 &&
(strokePath || definition.renderer.renderStroke)
) {
const strokeCanvas = createOffscreenCanvas({
width: Math.round(transform.width), width: Math.round(transform.width),
height: Math.round(transform.height), height: Math.round(transform.height),
}); });
const strokeCtx = strokeCanvas.getContext("2d") as const elementMaskCtx = elementMaskCanvas.getContext("2d") as
| CanvasRenderingContext2D | CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
| null; | null;
if (strokeCtx) { if (!elementMaskCtx) return;
if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
definition.renderer.renderMask({
resolvedParams: mask.params,
ctx: elementMaskCtx,
width: Math.round(transform.width),
height: Math.round(transform.height),
feather: mask.params.feather,
});
} else if (definition.renderer.buildPath) {
const path2d = definition.renderer.buildPath({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
});
elementMaskCtx.fillStyle = "white";
elementMaskCtx.fill(path2d);
} else {
return;
}
drawTransformedCanvas({ ctx, source: elementMaskCanvas, transform });
};
textures.set(maskTextureId, {
kind: "rendered",
id: maskTextureId,
contentHash: maskContentHash,
width: canvasWidth,
height: canvasHeight,
draw: drawMask,
});
const hasStroke =
mask.params.strokeWidth > 0 &&
(definition.renderer.renderStroke ||
definition.renderer.buildStrokePath ||
definition.renderer.buildPath);
let strokeLayer: FrameItemDescriptor | null = null;
if (hasStroke) {
const strokeTextureId = `${path}:mask-stroke`;
const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`;
const drawStroke: TextureCanvasDrawFn = (ctx) => {
const strokeCanvas = createOffscreenCanvas({
width: Math.round(transform.width),
height: Math.round(transform.height),
});
const strokeCtx = strokeCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!strokeCtx) return;
if (definition.renderer.renderStroke) { if (definition.renderer.renderStroke) {
definition.renderer.renderStroke({ definition.renderer.renderStroke({
resolvedParams: mask.params, resolvedParams: mask.params,
@ -514,44 +482,44 @@ function buildMaskArtifacts({
width: transform.width, width: transform.width,
height: transform.height, height: transform.height,
}); });
} else if (strokePath) { } else {
const strokePath =
definition.renderer.buildStrokePath?.({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
}) ??
definition.renderer.buildPath?.({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
}) ??
null;
if (!strokePath) return;
strokeCtx.strokeStyle = mask.params.strokeColor; strokeCtx.strokeStyle = mask.params.strokeColor;
strokeCtx.lineWidth = mask.params.strokeWidth; strokeCtx.lineWidth = mask.params.strokeWidth;
strokeCtx.stroke(strokePath); strokeCtx.stroke(strokePath);
} }
const fullStrokeCanvas = createOffscreenCanvas({ drawTransformedCanvas({ ctx, source: strokeCanvas, transform });
width: renderer.width, };
height: renderer.height, textures.set(strokeTextureId, {
}); kind: "rendered",
const fullStrokeCtx = fullStrokeCanvas.getContext("2d") as id: strokeTextureId,
| CanvasRenderingContext2D contentHash: strokeContentHash,
| OffscreenCanvasRenderingContext2D width: canvasWidth,
| null; height: canvasHeight,
if (fullStrokeCtx) { draw: drawStroke,
drawTransformedCanvas({ });
ctx: fullStrokeCtx, strokeLayer = {
source: strokeCanvas, type: "layer",
transform, textureId: strokeTextureId,
}); transform: fullCanvasTransform(renderer),
const strokeTextureId = `${path}:mask-stroke`; opacity: 1,
textures.set(strokeTextureId, { blendMode: "normal",
id: strokeTextureId, effectPassGroups: [],
source: fullStrokeCanvas, mask: null,
width: renderer.width, };
height: renderer.height,
});
strokeLayer = {
type: "layer",
textureId: strokeTextureId,
transform: fullCanvasTransform(renderer),
opacity: 1,
blendMode: "normal",
effectPassGroups: [],
mask: null,
};
}
}
} }
return { return {
@ -590,3 +558,23 @@ function drawTransformedCanvas({
ctx.drawImage(source, x, y, transform.width, transform.height); ctx.drawImage(source, x, y, transform.width, transform.height);
ctx.restore(); ctx.restore();
} }
function transformHash(transform: QuadTransformDescriptor): string {
return `${transform.centerX}:${transform.centerY}:${transform.width}:${transform.height}:${transform.rotationDegrees}:${transform.flipX ? 1 : 0}:${transform.flipY ? 1 : 0}`;
}
// Stable identity key for CanvasImageSource. Using a WeakMap → counter keeps
// hash string length bounded and avoids holding sources alive.
const identityKeys = new WeakMap<object, number>();
let nextIdentity = 1;
function identityKey(source: CanvasImageSource): string {
if (typeof source === "object" && source !== null) {
let key = identityKeys.get(source);
if (key === undefined) {
key = nextIdentity++;
identityKeys.set(source, key);
}
return `@${key}`;
}
return "@?";
}

View File

@ -40,3 +40,39 @@ export type LayerMaskDescriptor = {
feather: number; feather: number;
inverted: boolean; inverted: boolean;
}; };
export type TextureCanvasDrawFn = (
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
) => void;
/**
* A layer texture whose pixels come from somewhere outside the renderer
* typically a decoded video/image frame or a sticker. Cached by reference
* identity of the source object.
*/
export type ExternalTextureDescriptor = {
kind: "external";
id: string;
source: CanvasImageSource;
width: number;
height: number;
};
/**
* A layer texture that the renderer rasterizes from scene state (color fill,
* text layout, mask shape, blur backdrop). Cached by `contentHash`: when it
* matches the previous frame's hash for this id, the upload is skipped
* entirely and the persistent canvas is not even cleared.
*/
export type RenderedTextureDescriptor = {
kind: "rendered";
id: string;
contentHash: string;
width: number;
height: number;
draw: TextureCanvasDrawFn;
};
export type TextureUploadDescriptor =
| ExternalTextureDescriptor
| RenderedTextureDescriptor;

View File

@ -1,12 +1,201 @@
import { import {
getCompositorCanvas, getCompositorCanvas,
getLastFrameProfile,
initCompositor, initCompositor,
releaseTexture, releaseTexture,
renderFrame, renderFrame,
resizeCompositor, resizeCompositor,
uploadTexture, uploadTexture,
} from "opencut-wasm"; } from "opencut-wasm";
import type { FrameDescriptor } from "./types"; import {
incrementCounter,
isRenderPerfEnabled,
recordWasmFrameProfile,
} from "@/diagnostics/render-perf";
import type {
ExternalTextureDescriptor,
FrameDescriptor,
RenderedTextureDescriptor,
TextureUploadDescriptor,
} from "./types";
/**
* One slot in the derived-texture cache. The OffscreenCanvas is persistent
* we reuse and redraw into it across frames, which is the change that takes
* the WebGL upload path off the per-frame critical path for static content.
*/
type RenderedCacheEntry = {
kind: "rendered";
canvas: OffscreenCanvas;
contentHash: string;
width: number;
height: number;
};
type ExternalCacheEntry = {
kind: "external";
source: CanvasImageSource;
width: number;
height: number;
};
class WasmCompositor {
private canvas: HTMLCanvasElement | null = null;
private initializedSize: { width: number; height: number } | null = null;
private cache = new Map<string, RenderedCacheEntry | ExternalCacheEntry>();
ensureInitialized({ width, height }: { width: number; height: number }) {
if (!this.canvas) {
initCompositor(width, height);
this.canvas = getCompositorCanvas();
this.initializedSize = { width, height };
return;
}
if (
!this.initializedSize ||
this.initializedSize.width !== width ||
this.initializedSize.height !== height
) {
resizeCompositor(width, height);
this.initializedSize = { width, height };
}
}
getCanvas(): HTMLCanvasElement {
if (!this.canvas) {
throw new Error("Compositor is not initialized");
}
return this.canvas;
}
syncTextures(textures: TextureUploadDescriptor[]) {
const nextIds = new Set(textures.map((texture) => texture.id));
for (const previousId of this.cache.keys()) {
if (!nextIds.has(previousId)) {
releaseTexture(previousId);
this.cache.delete(previousId);
}
}
for (const texture of textures) {
if (texture.kind === "external") {
this.syncExternalTexture(texture);
} else {
this.syncRenderedTexture(texture);
}
}
}
render(frame: FrameDescriptor) {
renderFrame(frame);
if (isRenderPerfEnabled()) {
recordWasmFrameProfile(
getLastFrameProfile() as Array<{ name: string; durationMs: number }>,
);
}
}
private syncExternalTexture(texture: ExternalTextureDescriptor) {
const previous = this.cache.get(texture.id);
if (
previous?.kind === "external" &&
previous.source === texture.source &&
previous.width === texture.width &&
previous.height === texture.height
) {
incrementCounter({ name: "textureCacheHit" });
return;
}
incrementCounter({ name: "textureUpload" });
incrementCounter({
name: "textureUploadPixels",
by: texture.width * texture.height,
});
uploadTexture({
id: texture.id,
source: ensureOffscreenCanvas({
source: texture.source,
width: texture.width,
height: texture.height,
label: `texture upload ${texture.id}`,
}),
width: texture.width,
height: texture.height,
});
this.cache.set(texture.id, {
kind: "external",
source: texture.source,
width: texture.width,
height: texture.height,
});
}
private syncRenderedTexture(texture: RenderedTextureDescriptor) {
const previous = this.cache.get(texture.id);
if (
previous?.kind === "rendered" &&
previous.contentHash === texture.contentHash &&
previous.width === texture.width &&
previous.height === texture.height
) {
incrementCounter({ name: "textureCacheHit" });
return;
}
const canvas =
previous?.kind === "rendered" &&
previous.width === texture.width &&
previous.height === texture.height
? previous.canvas
: createBackingCanvas({
width: texture.width,
height: texture.height,
});
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error(`Failed to get 2d context for texture ${texture.id}`);
}
ctx.clearRect(0, 0, texture.width, texture.height);
texture.draw(ctx);
incrementCounter({ name: "textureUpload" });
incrementCounter({
name: "textureUploadPixels",
by: texture.width * texture.height,
});
uploadTexture({
id: texture.id,
source: canvas,
width: texture.width,
height: texture.height,
});
this.cache.set(texture.id, {
kind: "rendered",
canvas,
contentHash: texture.contentHash,
width: texture.width,
height: texture.height,
});
}
}
export const wasmCompositor = new WasmCompositor();
function createBackingCanvas({
width,
height,
}: {
width: number;
height: number;
}): OffscreenCanvas {
if (typeof OffscreenCanvas === "undefined") {
throw new Error("OffscreenCanvas is not supported in this environment");
}
return new OffscreenCanvas(width, height);
}
function ensureOffscreenCanvas({ function ensureOffscreenCanvas({
source, source,
@ -36,92 +225,3 @@ function ensureOffscreenCanvas({
context.drawImage(source, 0, 0, width, height); context.drawImage(source, 0, 0, width, height);
return canvas; return canvas;
} }
export type TextureUploadDescriptor = {
id: string;
source: CanvasImageSource;
width: number;
height: number;
};
class WasmCompositor {
private canvas: HTMLCanvasElement | null = null;
private initializedSize: { width: number; height: number } | null = null;
private retainedTextureIds = new Set<string>();
private uploadedTextures = new Map<
string,
{ source: CanvasImageSource; width: number; height: number }
>();
ensureInitialized({ width, height }: { width: number; height: number }) {
if (!this.canvas) {
initCompositor(width, height);
this.canvas = getCompositorCanvas();
this.initializedSize = { width, height };
return;
}
if (
!this.initializedSize ||
this.initializedSize.width !== width ||
this.initializedSize.height !== height
) {
resizeCompositor(width, height);
this.initializedSize = { width, height };
}
}
getCanvas(): HTMLCanvasElement {
if (!this.canvas) {
throw new Error("Compositor is not initialized");
}
return this.canvas;
}
syncTextures(textures: TextureUploadDescriptor[]) {
const nextIds = new Set(textures.map((texture) => texture.id));
for (const previousId of this.retainedTextureIds) {
if (!nextIds.has(previousId)) {
releaseTexture(previousId);
this.uploadedTextures.delete(previousId);
}
}
for (const texture of textures) {
const previousTexture = this.uploadedTextures.get(texture.id);
if (
previousTexture?.source === texture.source &&
previousTexture.width === texture.width &&
previousTexture.height === texture.height
) {
continue;
}
const sourceCanvas = ensureOffscreenCanvas({
source: texture.source,
width: texture.width,
height: texture.height,
label: `texture upload ${texture.id}`,
});
uploadTexture({
id: texture.id,
source: sourceCanvas,
width: texture.width,
height: texture.height,
});
this.uploadedTextures.set(texture.id, {
source: texture.source,
width: texture.width,
height: texture.height,
});
}
this.retainedTextureIds = nextIds;
}
render(frame: FrameDescriptor) {
renderFrame(frame);
}
}
export const wasmCompositor = new WasmCompositor();

View File

@ -353,6 +353,14 @@ impl GpuContext {
self.supports_external_texture_copies self.supports_external_texture_copies
} }
/// The HTML canvas that owns the backing WebGL context, if running on the
/// WebGL fallback. Callers on that path can mount this canvas directly
/// instead of copying pixels out of it every frame.
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
pub fn gl_canvas(&self) -> Option<&web_sys::HtmlCanvasElement> {
self.gl_canvas.as_ref()
}
pub fn render_texture_to_surface( pub fn render_texture_to_surface(
&self, &self,
texture: &wgpu::Texture, texture: &wgpu::Texture,
@ -571,7 +579,7 @@ impl GpuContext {
} }
#[cfg(all(feature = "wasm", target_arch = "wasm32"))] #[cfg(all(feature = "wasm", target_arch = "wasm32"))]
fn render_texture_to_gl_canvas_surface( pub fn render_texture_to_gl_canvas_surface(
&self, &self,
texture: &wgpu::Texture, texture: &wgpu::Texture,
width: u32, width: u32,

View File

@ -24,7 +24,7 @@ serde-wasm-bindgen = "0.6.5"
time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] } time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] }
wasm-bindgen = "0.2.116" wasm-bindgen = "0.2.116"
wasm-bindgen-futures = "0.4.66" wasm-bindgen-futures = "0.4.66"
web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window"] } web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window", "Performance"] }
[features] [features]
default = ["wasm"] default = ["wasm"]

View File

@ -11,6 +11,7 @@ use crate::gpu::{
import_canvas_texture, read_offscreen_canvas_property, read_serde_property, read_u32_property, import_canvas_texture, read_offscreen_canvas_property, read_serde_property, read_u32_property,
with_gpu_runtime, with_gpu_runtime,
}; };
use crate::perf;
struct CompositorRuntime { struct CompositorRuntime {
canvas: web_sys::HtmlCanvasElement, canvas: web_sys::HtmlCanvasElement,
@ -24,13 +25,21 @@ thread_local! {
#[wasm_bindgen(js_name = initCompositor)] #[wasm_bindgen(js_name = initCompositor)]
pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> { pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> {
with_gpu_runtime(|gpu_runtime| { with_gpu_runtime(|gpu_runtime| {
let document = web_sys::window() // On WebGL, wgpu is bound to a specific canvas; reuse it so the UI
.and_then(|window| window.document()) // can mount the output directly instead of copying pixels through
.ok_or_else(|| JsValue::from_str("Document is not available"))?; // an intermediate 2D canvas every frame. On WebGPU, surface rendering
let canvas: web_sys::HtmlCanvasElement = document // works against any canvas so we create a fresh one.
.create_element("canvas")? let canvas = if let Some(gl_canvas) = gpu_runtime.context.gl_canvas() {
.dyn_into() gl_canvas.clone()
.map_err(|_| JsValue::from_str("Failed to create compositor canvas"))?; } else {
let document = web_sys::window()
.and_then(|window| window.document())
.ok_or_else(|| JsValue::from_str("Document is not available"))?;
document
.create_element("canvas")?
.dyn_into::<web_sys::HtmlCanvasElement>()
.map_err(|_| JsValue::from_str("Failed to create compositor canvas"))?
};
canvas.set_width(width); canvas.set_width(width);
canvas.set_height(height); canvas.set_height(height);
@ -119,8 +128,12 @@ pub fn release_texture(id: String) -> Result<(), JsValue> {
#[wasm_bindgen(js_name = renderFrame)] #[wasm_bindgen(js_name = renderFrame)]
pub fn render_frame(options: JsValue) -> Result<(), JsValue> { pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
perf::reset();
let t_deserialize = perf::now_ms();
let frame: FrameDescriptor = serde_wasm_bindgen::from_value(options) let frame: FrameDescriptor = serde_wasm_bindgen::from_value(options)
.map_err(|error| JsValue::from_str(&format!("Invalid frame descriptor: {error}")))?; .map_err(|error| JsValue::from_str(&format!("Invalid frame descriptor: {error}")))?;
perf::record("wasm.deserialize", perf::now_ms() - t_deserialize);
with_gpu_runtime(|gpu_runtime| { with_gpu_runtime(|gpu_runtime| {
COMPOSITOR_RUNTIME.with(|runtime| { COMPOSITOR_RUNTIME.with(|runtime| {
@ -132,13 +145,16 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
}; };
if gpu_runtime.context.supports_surface_rendering() { if gpu_runtime.context.supports_surface_rendering() {
let t_surface = perf::now_ms();
let surface = gpu_runtime let surface = gpu_runtime
.context .context
.instance() .instance()
.create_surface(wgpu::SurfaceTarget::Canvas(runtime.canvas.clone())) .create_surface(wgpu::SurfaceTarget::Canvas(runtime.canvas.clone()))
.map_err(|error| JsValue::from_str(&error.to_string()))?; .map_err(|error| JsValue::from_str(&error.to_string()))?;
perf::record("wasm.surfaceCreate", perf::now_ms() - t_surface);
runtime let t_render = perf::now_ms();
let result = runtime
.compositor .compositor
.render_frame( .render_frame(
&gpu_runtime.context, &gpu_runtime.context,
@ -147,24 +163,29 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
surface: &surface, surface: &surface,
}, },
) )
.map_err(|error| JsValue::from_str(&error.to_string())) .map_err(|error| JsValue::from_str(&error.to_string()));
perf::record("wasm.renderFrameToSurface", perf::now_ms() - t_render);
result
} else { } else {
// WebGL cannot surface-render to an arbitrary canvas element. // WebGL surface-renders to the canvas its GL context was created
// Composite to a texture, then blit to the canvas via the 2D context. // on; `init_compositor` already pointed `runtime.canvas` at that
// same canvas, so presenting writes directly to the canvas the
// UI mounts. No intermediate copy.
let t_composite = perf::now_ms();
let texture = runtime let texture = runtime
.compositor .compositor
.render_frame_to_texture(&gpu_runtime.context, &frame) .render_frame_to_texture(&gpu_runtime.context, &frame)
.map_err(|error| JsValue::from_str(&error.to_string()))?; .map_err(|error| JsValue::from_str(&error.to_string()))?;
perf::record("wasm.compositeToTexture", perf::now_ms() - t_composite);
let t_present = perf::now_ms();
gpu_runtime gpu_runtime
.context .context
.render_texture_via_gl_canvas( .render_texture_to_gl_canvas_surface(&texture, frame.width, frame.height)
&texture, .map_err(|error| JsValue::from_str(&error.to_string()))?;
&runtime.canvas, perf::record("wasm.presentToGlCanvas", perf::now_ms() - t_present);
frame.width,
frame.height, Ok(())
)
.map_err(|error| JsValue::from_str(&error.to_string()))
} }
}) })
}) })

51
rust/wasm/src/perf.rs Normal file
View File

@ -0,0 +1,51 @@
#![cfg(target_arch = "wasm32")]
//! Per-frame profile buffer for the render pipeline.
//!
//! Sub-span timings are recorded into a thread-local during `renderFrame`
//! and drained by JS via `getLastFrameProfile()`.
use std::cell::RefCell;
use js_sys::{Array, Object, Reflect};
use wasm_bindgen::{JsValue, prelude::wasm_bindgen};
thread_local! {
static LAST_FRAME_PROFILE: RefCell<Vec<(&'static str, f64)>> = const { RefCell::new(Vec::new()) };
}
pub(crate) fn now_ms() -> f64 {
web_sys::window()
.and_then(|window| window.performance())
.map(|performance| performance.now())
.unwrap_or(0.0)
}
pub(crate) fn reset() {
LAST_FRAME_PROFILE.with(|cell| cell.borrow_mut().clear());
}
pub(crate) fn record(name: &'static str, duration_ms: f64) {
LAST_FRAME_PROFILE.with(|cell| cell.borrow_mut().push((name, duration_ms)));
}
#[wasm_bindgen(js_name = getLastFrameProfile)]
pub fn get_last_frame_profile() -> Array {
LAST_FRAME_PROFILE.with(|cell| {
let entries = cell.borrow();
let array = Array::new_with_length(entries.len() as u32);
for (index, (name, duration_ms)) in entries.iter().enumerate() {
let entry = Object::new();
Reflect::set(&entry, &JsValue::from_str("name"), &JsValue::from_str(name))
.expect("set name");
Reflect::set(
&entry,
&JsValue::from_str("durationMs"),
&JsValue::from_f64(*duration_ms),
)
.expect("set durationMs");
array.set(index as u32, entry.into());
}
array
})
}

View File

@ -6,6 +6,8 @@ mod effects;
mod gpu; mod gpu;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
mod masks; mod masks;
#[cfg(target_arch = "wasm32")]
mod perf;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub use compositor::*; pub use compositor::*;
@ -15,4 +17,6 @@ pub use effects::*;
pub use gpu::*; pub use gpu::*;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub use masks::*; pub use masks::*;
#[cfg(target_arch = "wasm32")]
pub use perf::*;
pub use time::*; pub use time::*;