summaryrefslogtreecommitdiff
path: root/apps/web/src/services/renderer/compositor/frame-descriptor.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-22 16:48:37 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-22 16:48:37 +0200
commitcceb835a84c125990ad8e49784e01a7c52636bb3 (patch)
tree8520cce9a203c72c7bcdc72ca138a36316a5a110 /apps/web/src/services/renderer/compositor/frame-descriptor.ts
parentf1b45b4328cff6a016720ce7b38b87ac2992dc2d (diff)
downloadopencut-cceb835a84c125990ad8e49784e01a7c52636bb3.tar.gz
perf: improve rendering performance by ~20x
Diffstat (limited to 'apps/web/src/services/renderer/compositor/frame-descriptor.ts')
-rw-r--r--apps/web/src/services/renderer/compositor/frame-descriptor.ts360
1 files changed, 174 insertions, 186 deletions
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 "@?";
+}