summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/web/src/diagnostics/render-perf.ts182
-rw-r--r--apps/web/src/preview/components/index.tsx100
-rw-r--r--apps/web/src/services/renderer/canvas-renderer.ts45
-rw-r--r--apps/web/src/services/renderer/compositor/frame-descriptor.ts360
-rw-r--r--apps/web/src/services/renderer/compositor/types.ts36
-rw-r--r--apps/web/src/services/renderer/compositor/wasm-compositor.ts220
-rw-r--r--rust/crates/gpu/src/context.rs10
-rw-r--r--rust/wasm/Cargo.toml2
-rw-r--r--rust/wasm/src/compositor.rs57
-rw-r--r--rust/wasm/src/perf.rs51
-rw-r--r--rust/wasm/src/wasm.rs4
11 files changed, 745 insertions, 322 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;
+}
diff --git a/apps/web/src/preview/components/index.tsx b/apps/web/src/preview/components/index.tsx
index d3cb2a49..e8f5c572 100644
--- a/apps/web/src/preview/components/index.tsx
+++ b/apps/web/src/preview/components/index.tsx
@@ -132,7 +132,7 @@ function PreviewCanvas({
isVisible: boolean;
}) => void;
}) {
- const canvasRef = useRef<HTMLCanvasElement>(null);
+ const canvasMountRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const lastFrameRef = useRef(-1);
const lastSceneRef = useRef<RootNode | null>(null);
@@ -158,35 +158,51 @@ function PreviewCanvas({
});
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
+ // Mount the compositor's output canvas directly into the preview. wgpu
+ // renders straight into this element, so there is no intermediate copy —
+ // the container div owns positioning/styling, the canvas itself fills it.
+ useEffect(() => {
+ const mount = canvasMountRef.current;
+ if (!mount) return;
+ const outputCanvas = renderer.getOutputCanvas();
+ outputCanvas.style.display = "block";
+ outputCanvas.style.width = "100%";
+ outputCanvas.style.height = "100%";
+ mount.appendChild(outputCanvas);
+ return () => {
+ if (outputCanvas.parentElement === mount) {
+ mount.removeChild(outputCanvas);
+ }
+ };
+ }, [renderer]);
+
const render = useCallback(() => {
- if (canvasRef.current && renderTree && !renderingRef.current) {
- 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 (!renderTree || renderingRef.current) return;
- if (
- frame !== lastFrameRef.current ||
- renderTree !== lastSceneRef.current
- ) {
- renderingRef.current = true;
- lastSceneRef.current = renderTree;
- lastFrameRef.current = frame;
- renderer
- .renderToCanvas({
- node: renderTree,
- time: renderTime,
- targetCanvas: canvasRef.current,
- })
- .then(() => {
- renderingRef.current = false;
- });
- }
+ 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]);
useRafLoop(render);
@@ -286,22 +302,20 @@ function PreviewCanvas({
ref={viewportRef}
className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden"
>
- <canvas
- ref={canvasRef}
- width={nativeWidth}
- height={nativeHeight}
- className="absolute block border"
- style={{
- left: viewport.sceneLeft,
- top: viewport.sceneTop,
- width: viewport.sceneWidth,
- height: viewport.sceneHeight,
- background:
- activeProject.settings.background.type === "blur"
- ? "transparent"
- : activeProject?.settings.background.color,
- }}
- />
+ <div
+ ref={canvasMountRef}
+ className="absolute block border"
+ style={{
+ left: viewport.sceneLeft,
+ top: viewport.sceneTop,
+ width: viewport.sceneWidth,
+ height: viewport.sceneHeight,
+ background:
+ activeProject.settings.background.type === "blur"
+ ? "transparent"
+ : activeProject?.settings.background.color,
+ }}
+ />
<PreviewOverlayLayer
instances={overlayInstances}
plane="under-interaction"
diff --git a/apps/web/src/services/renderer/canvas-renderer.ts b/apps/web/src/services/renderer/canvas-renderer.ts
index 50f7fd12..4a002675 100644
--- a/apps/web/src/services/renderer/canvas-renderer.ts
+++ b/apps/web/src/services/renderer/canvas-renderer.ts
@@ -3,6 +3,11 @@ import type { AnyBaseNode } from "./nodes/base-node";
import { buildFrameDescriptor } from "./compositor/frame-descriptor";
import { wasmCompositor } from "./compositor/wasm-compositor";
import { resolveRenderTree } from "./resolve";
+import {
+ measureSpanAsync,
+ measureSpanSync,
+ onRenderPerfFrameComplete,
+} from "@/diagnostics/render-perf";
export type CanvasRendererParams = {
width: number;
@@ -69,17 +74,26 @@ export class CanvasRenderer {
}
async render({ node, time }: { node: AnyBaseNode; time: number }) {
- await resolveRenderTree({ node, renderer: this, time });
- const { frame, textures } = await buildFrameDescriptor({
- node,
- renderer: this,
+ await measureSpanAsync({
+ name: "resolve",
+ fn: () => resolveRenderTree({ node, renderer: this, time }),
+ });
+ const { frame, textures } = await measureSpanAsync({
+ name: "buildFrame",
+ fn: () => buildFrameDescriptor({ node, renderer: this }),
});
wasmCompositor.ensureInitialized({
width: this.width,
height: this.height,
});
- wasmCompositor.syncTextures(textures);
- wasmCompositor.render(frame);
+ measureSpanSync({
+ name: "syncTextures",
+ fn: () => wasmCompositor.syncTextures(textures),
+ });
+ measureSpanSync({
+ name: "renderFrame",
+ fn: () => wasmCompositor.render(frame),
+ });
}
async renderToCanvas({
@@ -98,12 +112,17 @@ export class CanvasRenderer {
throw new Error("Failed to get target canvas context");
}
- ctx.drawImage(
- wasmCompositor.getCanvas(),
- 0,
- 0,
- targetCanvas.width,
- targetCanvas.height,
- );
+ measureSpanSync({
+ name: "drawImage",
+ fn: () =>
+ ctx.drawImage(
+ wasmCompositor.getCanvas(),
+ 0,
+ 0,
+ targetCanvas.width,
+ targetCanvas.height,
+ ),
+ });
+ onRenderPerfFrameComplete();
}
}
diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
index 666bea03..190a0237 100644
--- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts
+++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
@@ -1,5 +1,6 @@
import { drawCssBackground } from "@/gradients";
import { masksRegistry } from "@/masks";
+import { incrementCounter } from "@/diagnostics/render-perf";
import type { AnyBaseNode } from "../nodes/base-node";
import type { CanvasRenderer } from "../canvas-renderer";
import { createOffscreenCanvas } from "../canvas-utils";
@@ -21,16 +22,11 @@ import type {
FrameItemDescriptor,
LayerMaskDescriptor,
QuadTransformDescriptor,
+ TextureCanvasDrawFn,
+ TextureUploadDescriptor,
} from "./types";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
-export type TextureUploadDescriptor = {
- id: string;
- source: CanvasImageSource;
- width: number;
- height: number;
-};
-
export async function buildFrameDescriptor({
node,
renderer,
@@ -52,6 +48,9 @@ export async function buildFrameDescriptor({
textures,
});
+ incrementCounter({ name: "frameItems", by: items.length });
+ incrementCounter({ name: "frameTextures", by: textures.size });
+
return {
frame: {
width: renderer.width,
@@ -93,31 +92,21 @@ async function collectNode({
if (node instanceof ColorNode) {
const textureId = `${path}:color`;
- const canvas = createOffscreenCanvas({
- 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);
- }
+ const { width, height } = renderer;
textures.set(textureId, {
+ kind: "rendered",
id: textureId,
- source: canvas,
- width: renderer.width,
- height: renderer.height,
+ contentHash: `color:${node.params.color}:${width}x${height}`,
+ width,
+ 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({
type: "layer",
@@ -147,36 +136,35 @@ async function collectNode({
return;
}
const textureId = `${path}:blur-background`;
- const backdropCanvas = createOffscreenCanvas({
- width: renderer.width,
- height: renderer.height,
- });
- const backdropCtx = backdropCanvas.getContext("2d") as
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D
- | null;
- if (!backdropCtx) return;
+ const { width, height } = renderer;
const { backdropSource, passes } = node.resolved;
- const coverScale = Math.max(
- renderer.width / backdropSource.width,
- renderer.height / backdropSource.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,
- );
+ // Backdrop pixels come from a decoded video/image frame whose identity
+ // already changes when it changes. Hashing the source reference is
+ // enough to let us skip redraws on frozen frames.
+ const contentHash = `blur:${identityKey(backdropSource.source)}:${backdropSource.width}x${backdropSource.height}:${width}x${height}`;
textures.set(textureId, {
+ kind: "rendered",
id: textureId,
- source: backdropCanvas,
- width: renderer.width,
- height: renderer.height,
+ contentHash,
+ width,
+ 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({
type: "layer",
@@ -253,6 +241,7 @@ async function collectVisualSourceNode({
const textureId = `${path}:source`;
textures.set(textureId, {
+ kind: "external",
id: textureId,
source,
width: sourceWidth,
@@ -305,28 +294,24 @@ function collectTextNode({
}
const textureId = `${path}:text`;
- const canvas = createOffscreenCanvas({
- width: renderer.width,
- height: renderer.height,
- });
- const ctx = canvas.getContext("2d") as
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D
- | null;
- if (!ctx) {
- return;
- }
-
- renderTextToContext({
- node,
- ctx,
- });
-
+ const { width, height } = renderer;
+ // Text output is fully determined by node.params + node.resolved. Both are
+ // plain data we can stringify cheaply; the resolved measured layout is the
+ // expensive part of text setup, so stringifying it here is orders of
+ // magnitude cheaper than re-rasterizing when nothing changed.
+ const contentHash = `text:${width}x${height}:${JSON.stringify({
+ params: node.params,
+ resolved: node.resolved,
+ })}`;
textures.set(textureId, {
+ kind: "rendered",
id: textureId,
- source: canvas,
- width: renderer.width,
- height: renderer.height,
+ contentHash,
+ width,
+ height,
+ draw: (ctx) => {
+ renderTextToContext({ node, ctx });
+ },
});
items.push({
type: "layer",
@@ -411,20 +396,6 @@ function buildMaskArtifacts({
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;
const canRenderMaskDirectly = Boolean(definition.renderer.renderMask);
const shouldRenderMaskDirectly =
@@ -432,81 +403,78 @@ function buildMaskArtifacts({
(!definition.renderer.buildPath ||
(mask.params.feather > 0 &&
definition.renderer.renderMaskHandlesFeather));
- if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
- definition.renderer.renderMask({
- resolvedParams: mask.params,
- ctx: elementMaskCtx,
+ if (
+ shouldRenderMaskDirectly &&
+ definition.renderer.renderMaskHandlesFeather
+ ) {
+ feather = 0;
+ }
+
+ const maskTextureId = `${path}:mask`;
+ const { width: canvasWidth, height: canvasHeight } = renderer;
+ const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`;
+ const drawMask: TextureCanvasDrawFn = (ctx) => {
+ const elementMaskCanvas = createOffscreenCanvas({
width: Math.round(transform.width),
height: Math.round(transform.height),
- feather: mask.params.feather,
});
- if (definition.renderer.renderMaskHandlesFeather) {
- feather = 0;
- }
- strokePath =
- definition.renderer.buildStrokePath?.({
+ const elementMaskCtx = elementMaskCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!elementMaskCtx) return;
+
+ if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
+ definition.renderer.renderMask({
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?.({
+ 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,
- }) ?? 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,
- });
+ });
+ elementMaskCtx.fillStyle = "white";
+ elementMaskCtx.fill(path2d);
+ } else {
+ return;
+ }
- const maskTextureId = `${path}:mask`;
+ drawTransformedCanvas({ ctx, source: elementMaskCanvas, transform });
+ };
textures.set(maskTextureId, {
+ kind: "rendered",
id: maskTextureId,
- source: fullMaskCanvas,
- width: renderer.width,
- height: renderer.height,
+ contentHash: maskContentHash,
+ width: canvasWidth,
+ height: canvasHeight,
+ draw: drawMask,
});
- let strokeLayer: FrameItemDescriptor | null = null;
- if (
+ const hasStroke =
mask.params.strokeWidth > 0 &&
- (strokePath || definition.renderer.renderStroke)
- ) {
- const strokeCanvas = createOffscreenCanvas({
- width: Math.round(transform.width),
- height: Math.round(transform.height),
- });
- const strokeCtx = strokeCanvas.getContext("2d") as
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D
- | null;
- if (strokeCtx) {
+ (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) {
definition.renderer.renderStroke({
resolvedParams: mask.params,
@@ -514,44 +482,44 @@ function buildMaskArtifacts({
width: transform.width,
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.lineWidth = mask.params.strokeWidth;
strokeCtx.stroke(strokePath);
}
- const fullStrokeCanvas = createOffscreenCanvas({
- width: renderer.width,
- height: renderer.height,
- });
- const fullStrokeCtx = fullStrokeCanvas.getContext("2d") as
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D
- | null;
- if (fullStrokeCtx) {
- drawTransformedCanvas({
- ctx: fullStrokeCtx,
- source: strokeCanvas,
- transform,
- });
- const strokeTextureId = `${path}:mask-stroke`;
- textures.set(strokeTextureId, {
- id: strokeTextureId,
- source: fullStrokeCanvas,
- width: renderer.width,
- height: renderer.height,
- });
- strokeLayer = {
- type: "layer",
- textureId: strokeTextureId,
- transform: fullCanvasTransform(renderer),
- opacity: 1,
- blendMode: "normal",
- effectPassGroups: [],
- mask: null,
- };
- }
- }
+ drawTransformedCanvas({ ctx, source: strokeCanvas, transform });
+ };
+ textures.set(strokeTextureId, {
+ kind: "rendered",
+ id: strokeTextureId,
+ contentHash: strokeContentHash,
+ width: canvasWidth,
+ height: canvasHeight,
+ draw: drawStroke,
+ });
+ strokeLayer = {
+ type: "layer",
+ textureId: strokeTextureId,
+ transform: fullCanvasTransform(renderer),
+ opacity: 1,
+ blendMode: "normal",
+ effectPassGroups: [],
+ mask: null,
+ };
}
return {
@@ -590,3 +558,23 @@ function drawTransformedCanvas({
ctx.drawImage(source, x, y, transform.width, transform.height);
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 "@?";
+}
diff --git a/apps/web/src/services/renderer/compositor/types.ts b/apps/web/src/services/renderer/compositor/types.ts
index af5a58c2..1a509909 100644
--- a/apps/web/src/services/renderer/compositor/types.ts
+++ b/apps/web/src/services/renderer/compositor/types.ts
@@ -40,3 +40,39 @@ export type LayerMaskDescriptor = {
feather: number;
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;
diff --git a/apps/web/src/services/renderer/compositor/wasm-compositor.ts b/apps/web/src/services/renderer/compositor/wasm-compositor.ts
index 16b03853..10684f11 100644
--- a/apps/web/src/services/renderer/compositor/wasm-compositor.ts
+++ b/apps/web/src/services/renderer/compositor/wasm-compositor.ts
@@ -1,44 +1,39 @@
import {
getCompositorCanvas,
+ getLastFrameProfile,
initCompositor,
releaseTexture,
renderFrame,
resizeCompositor,
uploadTexture,
} from "opencut-wasm";
-import type { FrameDescriptor } from "./types";
+import {
+ incrementCounter,
+ isRenderPerfEnabled,
+ recordWasmFrameProfile,
+} from "@/diagnostics/render-perf";
+import type {
+ ExternalTextureDescriptor,
+ FrameDescriptor,
+ RenderedTextureDescriptor,
+ TextureUploadDescriptor,
+} from "./types";
-function ensureOffscreenCanvas({
- source,
- width,
- height,
- label,
-}: {
- source: CanvasImageSource;
+/**
+ * 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;
- label: string;
-}): OffscreenCanvas {
- if (source instanceof OffscreenCanvas) {
- return source;
- }
-
- if (typeof OffscreenCanvas === "undefined") {
- throw new Error(`OffscreenCanvas is required for ${label}`);
- }
-
- const canvas = new OffscreenCanvas(width, height);
- const context = canvas.getContext("2d");
- if (!context) {
- throw new Error(`Failed to get 2d context for ${label}`);
- }
- context.clearRect(0, 0, width, height);
- context.drawImage(source, 0, 0, width, height);
- return canvas;
-}
+};
-export type TextureUploadDescriptor = {
- id: string;
+type ExternalCacheEntry = {
+ kind: "external";
source: CanvasImageSource;
width: number;
height: number;
@@ -47,11 +42,7 @@ export type TextureUploadDescriptor = {
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 }
- >();
+ private cache = new Map<string, RenderedCacheEntry | ExternalCacheEntry>();
ensureInitialized({ width, height }: { width: number; height: number }) {
if (!this.canvas) {
@@ -80,48 +71,157 @@ class WasmCompositor {
syncTextures(textures: TextureUploadDescriptor[]) {
const nextIds = new Set(textures.map((texture) => texture.id));
- for (const previousId of this.retainedTextureIds) {
+ for (const previousId of this.cache.keys()) {
if (!nextIds.has(previousId)) {
releaseTexture(previousId);
- this.uploadedTextures.delete(previousId);
+ this.cache.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;
+ if (texture.kind === "external") {
+ this.syncExternalTexture(texture);
+ } else {
+ this.syncRenderedTexture(texture);
}
+ }
+ }
- const sourceCanvas = ensureOffscreenCanvas({
+ 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}`,
- });
- 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,
- });
+ }),
+ 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;
}
- this.retainedTextureIds = nextIds;
- }
+ const canvas =
+ previous?.kind === "rendered" &&
+ previous.width === texture.width &&
+ previous.height === texture.height
+ ? previous.canvas
+ : createBackingCanvas({
+ width: texture.width,
+ height: texture.height,
+ });
- render(frame: FrameDescriptor) {
- renderFrame(frame);
+ 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({
+ source,
+ width,
+ height,
+ label,
+}: {
+ source: CanvasImageSource;
+ width: number;
+ height: number;
+ label: string;
+}): OffscreenCanvas {
+ if (source instanceof OffscreenCanvas) {
+ return source;
+ }
+
+ if (typeof OffscreenCanvas === "undefined") {
+ throw new Error(`OffscreenCanvas is required for ${label}`);
+ }
+
+ const canvas = new OffscreenCanvas(width, height);
+ const context = canvas.getContext("2d");
+ if (!context) {
+ throw new Error(`Failed to get 2d context for ${label}`);
+ }
+ context.clearRect(0, 0, width, height);
+ context.drawImage(source, 0, 0, width, height);
+ return canvas;
+}
diff --git a/rust/crates/gpu/src/context.rs b/rust/crates/gpu/src/context.rs
index e38eff8c..0fcb2e8c 100644
--- a/rust/crates/gpu/src/context.rs
+++ b/rust/crates/gpu/src/context.rs
@@ -353,6 +353,14 @@ impl GpuContext {
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(
&self,
texture: &wgpu::Texture,
@@ -571,7 +579,7 @@ impl GpuContext {
}
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
- fn render_texture_to_gl_canvas_surface(
+ pub fn render_texture_to_gl_canvas_surface(
&self,
texture: &wgpu::Texture,
width: u32,
diff --git a/rust/wasm/Cargo.toml b/rust/wasm/Cargo.toml
index f12a8cbc..3fbfe5ba 100644
--- a/rust/wasm/Cargo.toml
+++ b/rust/wasm/Cargo.toml
@@ -24,7 +24,7 @@ serde-wasm-bindgen = "0.6.5"
time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] }
wasm-bindgen = "0.2.116"
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]
default = ["wasm"]
diff --git a/rust/wasm/src/compositor.rs b/rust/wasm/src/compositor.rs
index 126c7468..84467842 100644
--- a/rust/wasm/src/compositor.rs
+++ b/rust/wasm/src/compositor.rs
@@ -11,6 +11,7 @@ use crate::gpu::{
import_canvas_texture, read_offscreen_canvas_property, read_serde_property, read_u32_property,
with_gpu_runtime,
};
+use crate::perf;
struct CompositorRuntime {
canvas: web_sys::HtmlCanvasElement,
@@ -24,13 +25,21 @@ thread_local! {
#[wasm_bindgen(js_name = initCompositor)]
pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> {
with_gpu_runtime(|gpu_runtime| {
- let document = web_sys::window()
- .and_then(|window| window.document())
- .ok_or_else(|| JsValue::from_str("Document is not available"))?;
- let canvas: web_sys::HtmlCanvasElement = document
- .create_element("canvas")?
- .dyn_into()
- .map_err(|_| JsValue::from_str("Failed to create compositor canvas"))?;
+ // On WebGL, wgpu is bound to a specific canvas; reuse it so the UI
+ // can mount the output directly instead of copying pixels through
+ // an intermediate 2D canvas every frame. On WebGPU, surface rendering
+ // works against any canvas so we create a fresh one.
+ let canvas = if let Some(gl_canvas) = gpu_runtime.context.gl_canvas() {
+ gl_canvas.clone()
+ } 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_height(height);
@@ -119,8 +128,12 @@ pub fn release_texture(id: String) -> Result<(), JsValue> {
#[wasm_bindgen(js_name = renderFrame)]
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)
.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| {
COMPOSITOR_RUNTIME.with(|runtime| {
@@ -132,13 +145,16 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
};
if gpu_runtime.context.supports_surface_rendering() {
+ let t_surface = perf::now_ms();
let surface = gpu_runtime
.context
.instance()
.create_surface(wgpu::SurfaceTarget::Canvas(runtime.canvas.clone()))
.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
.render_frame(
&gpu_runtime.context,
@@ -147,24 +163,29 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
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 {
- // WebGL cannot surface-render to an arbitrary canvas element.
- // Composite to a texture, then blit to the canvas via the 2D context.
+ // WebGL surface-renders to the canvas its GL context was created
+ // 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
.compositor
.render_frame_to_texture(&gpu_runtime.context, &frame)
.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
.context
- .render_texture_via_gl_canvas(
- &texture,
- &runtime.canvas,
- frame.width,
- frame.height,
- )
- .map_err(|error| JsValue::from_str(&error.to_string()))
+ .render_texture_to_gl_canvas_surface(&texture, frame.width, frame.height)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ perf::record("wasm.presentToGlCanvas", perf::now_ms() - t_present);
+
+ Ok(())
}
})
})
diff --git a/rust/wasm/src/perf.rs b/rust/wasm/src/perf.rs
new file mode 100644
index 00000000..0c346a4d
--- /dev/null
+++ b/rust/wasm/src/perf.rs
@@ -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
+ })
+}
diff --git a/rust/wasm/src/wasm.rs b/rust/wasm/src/wasm.rs
index 1a33092a..0687ff4b 100644
--- a/rust/wasm/src/wasm.rs
+++ b/rust/wasm/src/wasm.rs
@@ -6,6 +6,8 @@ mod effects;
mod gpu;
#[cfg(target_arch = "wasm32")]
mod masks;
+#[cfg(target_arch = "wasm32")]
+mod perf;
#[cfg(target_arch = "wasm32")]
pub use compositor::*;
@@ -15,4 +17,6 @@ pub use effects::*;
pub use gpu::*;
#[cfg(target_arch = "wasm32")]
pub use masks::*;
+#[cfg(target_arch = "wasm32")]
+pub use perf::*;
pub use time::*;