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(); +const counters = new Map(); +const pendingCountersThisFrame = new Map(); + +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({ + name, + fn, +}: { + name: string; + fn: () => Promise; +}): Promise { + if (!isRenderPerfEnabled()) return fn(); + const start = performance.now(); + try { + return await fn(); + } finally { + recordSpan({ name, durationMs: performance.now() - start }); + } +} + +export function measureSpanSync({ + 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> = []; + 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> = []; + 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(null); + const canvasMountRef = useRef(null); const viewportRef = useRef(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef(null); @@ -158,35 +158,51 @@ function PreviewCanvas({ }); }, [nativeWidth, nativeHeight, activeProject.settings.fps]); - 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 ( - 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; - }); + // 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 (!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]); 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" > - +
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, - width: Math.round(transform.width), - 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; + if ( + shouldRenderMaskDirectly && + definition.renderer.renderMaskHandlesFeather + ) { + feather = 0; } - 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`; - textures.set(maskTextureId, { - id: maskTextureId, - source: fullMaskCanvas, - width: renderer.width, - height: renderer.height, - }); - - let strokeLayer: FrameItemDescriptor | null = null; - if ( - mask.params.strokeWidth > 0 && - (strokePath || definition.renderer.renderStroke) - ) { - const strokeCanvas = createOffscreenCanvas({ + 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), }); - const strokeCtx = strokeCanvas.getContext("2d") as + const elementMaskCtx = elementMaskCanvas.getContext("2d") as | CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | 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) { 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(); +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,12 +1,201 @@ 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"; + +/** + * 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(); + + 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({ source, @@ -36,92 +225,3 @@ function ensureOffscreenCanvas({ context.drawImage(source, 0, 0, width, height); 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(); - 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(); 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::() + .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> = 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::*;