perf: improve rendering performance by ~20x
This commit is contained in:
parent
f1b45b4328
commit
cceb835a84
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 "@?";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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({
|
||||
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<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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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::*;
|
||||
|
|
|
|||
Loading…
Reference in New Issue