feat: migrate GPU renderer from WebGL to wgpu/WASM

This commit is contained in:
Maze Winther
2026-04-01 13:57:32 +02:00
parent b048b739bd
commit e579ae1202
44 changed files with 2745 additions and 1333 deletions
-8
View File
@@ -3,14 +3,6 @@ import { withBotId } from "botid/next/config";
import { withContentCollections } from "@content-collections/next";
const nextConfig: NextConfig = {
turbopack: {
rules: {
"*.glsl": {
loaders: [require.resolve("raw-loader")],
as: "*.js",
},
},
},
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
-1
View File
@@ -94,7 +94,6 @@
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.4",
"postcss": "^8",
"raw-loader": "^4.0.2",
"sharp": "^0.34.5",
"tailwindcss": "^4.2.1",
"typescript": "^5.8.3",
@@ -1,133 +1,135 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
import { useKeybindingsListener } from "@/hooks/use-keybindings";
import { useKeybindingsStore } from "@/stores/keybindings-store";
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
interface EditorProviderProps {
projectId: string;
children: React.ReactNode;
}
export function EditorProvider({ projectId, children }: EditorProviderProps) {
const activeProject = useEditor((e) => e.project.getActiveOrNull());
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { setLoadingProject } = useKeybindingsStore();
useEffect(() => {
setLoadingProject(isLoading);
}, [isLoading, setLoadingProject]);
useEffect(() => {
let cancelled = false;
const editor = EditorCore.getInstance();
const loadProject = async () => {
try {
setIsLoading(true);
await editor.project.loadProject({ id: projectId });
if (cancelled) return;
setIsLoading(false);
loadFontAtlas();
} catch (err) {
if (cancelled) return;
const isNotFound =
err instanceof Error &&
(err.message.includes("not found") ||
err.message.includes("does not exist"));
if (isNotFound) {
try {
const newProjectId = await editor.project.createNewProject({
name: "Untitled Project",
});
router.replace(`/editor/${newProjectId}`);
} catch (_createErr) {
setError("Failed to create project");
setIsLoading(false);
}
} else {
setError(
err instanceof Error ? err.message : "Failed to load project",
);
setIsLoading(false);
}
}
};
loadProject();
return () => {
cancelled = true;
};
}, [projectId, router]);
if (error) {
return (
<div className="bg-background flex h-screen w-screen items-center justify-center">
<div className="flex flex-col items-center gap-4">
<p className="text-destructive text-sm">{error}</p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="bg-background flex h-screen w-screen items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="text-muted-foreground size-8 animate-spin" />
<p className="text-muted-foreground text-sm">Loading project...</p>
</div>
</div>
);
}
if (!activeProject) {
return (
<div className="bg-background flex h-screen w-screen items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="text-muted-foreground size-8 animate-spin" />
<p className="text-muted-foreground text-sm">Exiting project...</p>
</div>
</div>
);
}
return (
<>
<EditorRuntimeBindings />
{children}
</>
);
}
function EditorRuntimeBindings() {
const editor = useEditor();
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!editor.save.getIsDirty()) return;
event.preventDefault();
(event as unknown as { returnValue: string }).returnValue = "";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [editor]);
useEditorActions();
useKeybindingsListener();
return null;
}
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
import { useKeybindingsListener } from "@/hooks/use-keybindings";
import { useKeybindingsStore } from "@/stores/keybindings-store";
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer";
interface EditorProviderProps {
projectId: string;
children: React.ReactNode;
}
export function EditorProvider({ projectId, children }: EditorProviderProps) {
const activeProject = useEditor((e) => e.project.getActiveOrNull());
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { setLoadingProject } = useKeybindingsStore();
useEffect(() => {
setLoadingProject(isLoading);
}, [isLoading, setLoadingProject]);
useEffect(() => {
let cancelled = false;
const editor = EditorCore.getInstance();
const loadProject = async () => {
try {
setIsLoading(true);
await initializeGpuRenderer();
await editor.project.loadProject({ id: projectId });
if (cancelled) return;
setIsLoading(false);
loadFontAtlas();
} catch (err) {
if (cancelled) return;
const isNotFound =
err instanceof Error &&
(err.message.includes("not found") ||
err.message.includes("does not exist"));
if (isNotFound) {
try {
const newProjectId = await editor.project.createNewProject({
name: "Untitled Project",
});
router.replace(`/editor/${newProjectId}`);
} catch (_createErr) {
setError("Failed to create project");
setIsLoading(false);
}
} else {
setError(
err instanceof Error ? err.message : "Failed to load project",
);
setIsLoading(false);
}
}
};
loadProject();
return () => {
cancelled = true;
};
}, [projectId, router]);
if (error) {
return (
<div className="bg-background flex h-screen w-screen items-center justify-center">
<div className="flex flex-col items-center gap-4">
<p className="text-destructive text-sm">{error}</p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="bg-background flex h-screen w-screen items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="text-muted-foreground size-8 animate-spin" />
<p className="text-muted-foreground text-sm">Loading project...</p>
</div>
</div>
);
}
if (!activeProject) {
return (
<div className="bg-background flex h-screen w-screen items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="text-muted-foreground size-8 animate-spin" />
<p className="text-muted-foreground text-sm">Exiting project...</p>
</div>
</div>
);
}
return (
<>
<EditorRuntimeBindings />
{children}
</>
);
}
function EditorRuntimeBindings() {
const editor = useEditor();
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!editor.save.getIsDirty()) return;
event.preventDefault();
(event as unknown as { returnValue: string }).returnValue = "";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [editor]);
useEditorActions();
useKeybindingsListener();
return null;
}
@@ -1,25 +0,0 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_sigma;
uniform float u_step;
uniform vec2 u_direction;
varying vec2 v_texCoord;
void main() {
vec2 texelSize = 1.0 / u_resolution;
vec4 color = vec4(0.0);
float totalWeight = 0.0;
for (int i = -30; i <= 30; i++) {
float pos = float(i) * u_step;
float weight = exp(-(pos * pos) / (2.0 * u_sigma * u_sigma));
color += texture2D(u_texture, v_texCoord + texelSize * u_direction * pos) * weight;
totalWeight += weight;
}
gl_FragColor = color / totalWeight;
}
+9 -9
View File
@@ -1,5 +1,6 @@
import type { EffectDefinition, ResolvedEffectPass } from "@/lib/effects/types";
import blurFragmentShader from "./blur.frag.glsl";
import type { EffectDefinition, EffectPass } from "@/lib/effects/types";
export const GAUSSIAN_BLUR_SHADER = "gaussian-blur";
const MAX_SINGLE_PASS_SIGMA = 10;
const MAX_STEP = 4;
@@ -17,7 +18,7 @@ export function buildGaussianBlurPasses({
}: {
sigmaX: number;
sigmaY: number;
}): ResolvedEffectPass[] {
}): EffectPass[] {
const maxSigma = Math.max(sigmaX, sigmaY);
if (maxSigma < 0.001) return [];
@@ -36,10 +37,10 @@ export function buildGaussianBlurPasses({
const stepX = Math.max(1, perPassSigmaX / MAX_SINGLE_PASS_SIGMA);
const stepY = Math.max(1, perPassSigmaY / MAX_SINGLE_PASS_SIGMA);
const passes: ResolvedEffectPass[] = [];
const passes: EffectPass[] = [];
for (let i = 0; i < iterations; i++) {
passes.push({
fragmentShader: blurFragmentShader,
shader: GAUSSIAN_BLUR_SHADER,
uniforms: {
u_sigma: perPassSigmaX,
u_step: stepX,
@@ -47,7 +48,7 @@ export function buildGaussianBlurPasses({
},
});
passes.push({
fragmentShader: blurFragmentShader,
shader: GAUSSIAN_BLUR_SHADER,
uniforms: {
u_sigma: perPassSigmaY,
u_step: stepY,
@@ -83,10 +84,9 @@ export const blurEffectDefinition: EffectDefinition = {
},
],
renderer: {
type: "webgl",
passes: [
{
fragmentShader: blurFragmentShader,
shader: GAUSSIAN_BLUR_SHADER,
uniforms: ({ effectParams, width }) => ({
u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), width, 1920), 0.001),
u_step: 1,
@@ -94,7 +94,7 @@ export const blurEffectDefinition: EffectDefinition = {
}),
},
{
fragmentShader: blurFragmentShader,
shader: GAUSSIAN_BLUR_SHADER,
uniforms: ({ effectParams, height }) => ({
u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), height, 1080), 0.001),
u_step: 1,
@@ -1,7 +0,0 @@
attribute vec2 a_position;
varying vec2 v_texCoord;
void main() {
v_texCoord = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}
+3 -3
View File
@@ -2,7 +2,7 @@ import { generateUUID } from "@/utils/id";
import { buildDefaultParamValues } from "@/lib/registry";
import { effectsRegistry } from "./registry";
import type { ParamValues } from "@/lib/params";
import type { Effect, EffectDefinition, ResolvedEffectPass } from "@/lib/effects/types";
import type { Effect, EffectDefinition, EffectPass } from "@/lib/effects/types";
import { VISUAL_ELEMENT_TYPES } from "@/lib/timeline";
export { effectsRegistry } from "./registry";
@@ -18,12 +18,12 @@ export function resolveEffectPasses({
effectParams: ParamValues;
width: number;
height: number;
}): ResolvedEffectPass[] {
}): EffectPass[] {
if (definition.renderer.buildPasses) {
return definition.renderer.buildPasses({ effectParams, width, height });
}
return definition.renderer.passes.map((pass) => ({
fragmentShader: pass.fragmentShader,
shader: pass.shader,
uniforms: pass.uniforms({ effectParams, width, height }),
}));
}
+41 -42
View File
@@ -1,42 +1,41 @@
import type { ParamDefinition, ParamValues } from "@/lib/params";
export interface Effect {
id: string;
type: string;
params: ParamValues;
enabled: boolean;
}
export interface ResolvedEffectPass {
fragmentShader: string;
uniforms: Record<string, number | number[]>;
}
export interface WebGLEffectPass {
fragmentShader: string;
uniforms(params: {
effectParams: ParamValues;
width: number;
height: number;
}): Record<string, number | number[]>;
}
export interface WebGLEffectRenderer {
type: "webgl";
passes: WebGLEffectPass[];
buildPasses?: (params: {
effectParams: ParamValues;
width: number;
height: number;
}) => ResolvedEffectPass[];
}
export type EffectRenderer = WebGLEffectRenderer;
export interface EffectDefinition {
type: string;
name: string;
keywords: string[];
params: ParamDefinition[];
renderer: EffectRenderer;
}
import type { ParamDefinition, ParamValues } from "@/lib/params";
export interface Effect {
id: string;
type: string;
params: ParamValues;
enabled: boolean;
}
export type EffectUniformValue = number | number[];
export interface EffectPass {
shader: string;
uniforms: Record<string, EffectUniformValue>;
}
export interface EffectPassTemplate {
shader: string;
uniforms(params: {
effectParams: ParamValues;
width: number;
height: number;
}): Record<string, EffectUniformValue>;
}
export interface EffectRendererConfig {
passes: EffectPassTemplate[];
buildPasses?: (params: {
effectParams: ParamValues;
width: number;
height: number;
}) => EffectPass[];
}
export interface EffectDefinition {
type: string;
name: string;
keywords: string[];
params: ParamDefinition[];
renderer: EffectRendererConfig;
}
@@ -1,36 +0,0 @@
precision mediump float;
uniform sampler2D u_texture;
uniform sampler2D u_jfa_outside;
uniform vec2 u_resolution;
uniform float u_feather_half;
varying vec2 v_texCoord;
vec2 decodeSeed(vec4 encoded) {
float x = floor(encoded.r * 255.0 + 0.5) * 256.0 + floor(encoded.g * 255.0 + 0.5);
float y = floor(encoded.b * 255.0 + 0.5) * 256.0 + floor(encoded.a * 255.0 + 0.5);
return vec2(x, y);
}
bool isNoSeed(vec4 encoded) {
return encoded.r > 0.99 && encoded.g > 0.99 && encoded.b > 0.99 && encoded.a > 0.99;
}
void main() {
vec2 pixelCoord = floor(v_texCoord * u_resolution);
vec4 insideEncoded = texture2D(u_texture, v_texCoord);
vec4 outsideEncoded = texture2D(u_jfa_outside, v_texCoord);
bool hasInside = !isNoSeed(insideEncoded);
bool hasOutside = !isNoSeed(outsideEncoded);
float distToInside = hasInside ? distance(pixelCoord, decodeSeed(insideEncoded)) : 1e5;
float distToOutside = hasOutside ? distance(pixelCoord, decodeSeed(outsideEncoded)) : 1e5;
float signedDist = distToOutside - distToInside;
float alpha = smoothstep(-u_feather_half, u_feather_half, signedDist);
gl_FragColor = vec4(alpha, alpha, alpha, alpha);
}
@@ -1,25 +0,0 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_invert;
varying vec2 v_texCoord;
void main() {
float mask = texture2D(u_texture, v_texCoord).r;
bool isSeed = u_invert > 0.5 ? mask < 0.5 : mask > 0.5;
if (isSeed) {
vec2 pixelCoord = floor(v_texCoord * u_resolution);
float x = pixelCoord.x;
float y = pixelCoord.y;
float xHi = floor(x / 256.0);
float xLo = x - xHi * 256.0;
float yHi = floor(y / 256.0);
float yLo = y - yHi * 256.0;
gl_FragColor = vec4(xHi / 255.0, xLo / 255.0, yHi / 255.0, yLo / 255.0);
} else {
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
}
@@ -1,60 +0,0 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_step_size;
varying vec2 v_texCoord;
vec2 decodeSeed(vec4 encoded) {
float x = floor(encoded.r * 255.0 + 0.5) * 256.0 + floor(encoded.g * 255.0 + 0.5);
float y = floor(encoded.b * 255.0 + 0.5) * 256.0 + floor(encoded.a * 255.0 + 0.5);
return vec2(x, y);
}
vec4 encodeSeed(vec2 seed) {
float xHi = floor(seed.x / 256.0);
float xLo = seed.x - xHi * 256.0;
float yHi = floor(seed.y / 256.0);
float yLo = seed.y - yHi * 256.0;
return vec4(xHi / 255.0, xLo / 255.0, yHi / 255.0, yLo / 255.0);
}
bool isNoSeed(vec4 encoded) {
return encoded.r > 0.99 && encoded.g > 0.99 && encoded.b > 0.99 && encoded.a > 0.99;
}
void main() {
vec2 pixelCoord = floor(v_texCoord * u_resolution);
vec2 texelSize = 1.0 / u_resolution;
float bestDist = 1e10;
vec2 bestSeed = vec2(65535.0);
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
vec2 offset = vec2(float(dx), float(dy)) * u_step_size;
vec2 sampleUV = v_texCoord + offset * texelSize;
if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0)
continue;
vec4 encoded = texture2D(u_texture, sampleUV);
if (isNoSeed(encoded))
continue;
vec2 seed = decodeSeed(encoded);
float dist = distance(pixelCoord, seed);
if (dist < bestDist) {
bestDist = dist;
bestSeed = seed;
}
}
}
if (bestDist < 1e9) {
gl_FragColor = encodeSeed(bestSeed);
} else {
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
}
@@ -2,18 +2,14 @@ import { createOffscreenCanvas } from "./canvas-utils";
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
import { buildDefaultParamValues } from "@/lib/registry";
import type { ParamValues } from "@/lib/params";
import { applyMultiPassEffect } from "./webgl/webgl-utils";
import type { EffectPassData } from "./webgl/webgl-utils";
import { gpuRenderer } from "./gpu-renderer";
const PREVIEW_SIZE = 160;
const PREVIEW_IMAGE_PATH = "/effects/preview.jpg";
class EffectPreviewService {
private previewGl: WebGLRenderingContext | null = null;
private previewCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
private testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
private previewImageElement: HTMLImageElement | null = null;
private programCache = new Map<string, WebGLProgram>();
private onReadyCallbacks = new Set<() => void>();
readonly PREVIEW_SIZE = PREVIEW_SIZE;
@@ -58,7 +54,7 @@ class EffectPreviewService {
width: uniformDimensions?.width ?? size,
height: uniformDimensions?.height ?? size,
});
const result = this.applyWebGlEffect({
const result = this.applyGpuEffect({
source,
width: size,
height: size,
@@ -114,29 +110,6 @@ class EffectPreviewService {
return canvas;
}
private getOrCreatePreviewContext({
width,
height,
}: {
width: number;
height: number;
}): { canvas: OffscreenCanvas | HTMLCanvasElement; gl: WebGLRenderingContext } {
if (!this.previewCanvas || !this.previewGl) {
this.previewCanvas = createOffscreenCanvas({ width, height });
this.previewGl = this.previewCanvas.getContext("webgl", {
premultipliedAlpha: false,
}) as WebGLRenderingContext | null;
if (!this.previewGl) {
throw new Error("WebGL not supported");
}
}
if (this.previewCanvas.width !== width || this.previewCanvas.height !== height) {
this.previewCanvas.width = width;
this.previewCanvas.height = height;
}
return { canvas: this.previewCanvas, gl: this.previewGl };
}
private getTestSource({
width,
height,
@@ -154,7 +127,7 @@ class EffectPreviewService {
return this.testSourceCanvas;
}
private applyWebGlEffect({
private applyGpuEffect({
source,
width,
height,
@@ -163,28 +136,14 @@ class EffectPreviewService {
source: CanvasImageSource;
width: number;
height: number;
passes: EffectPassData[];
passes: ReturnType<typeof resolveEffectPasses>;
}): OffscreenCanvas | HTMLCanvasElement {
const { canvas: glCanvas, gl } = this.getOrCreatePreviewContext({ width, height });
applyMultiPassEffect({
context: gl,
return gpuRenderer.applyEffect({
source,
width,
height,
passes,
programCache: this.programCache,
});
const outputCanvas = createOffscreenCanvas({ width, height });
const outputCtx = outputCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (outputCtx) {
outputCtx.drawImage(glCanvas, 0, 0, width, height);
}
return outputCanvas;
}) as OffscreenCanvas | HTMLCanvasElement;
}
}
@@ -0,0 +1,118 @@
import {
applyEffectPasses,
applyMaskFeather as applyMaskFeatherWasm,
initializeGpu,
} from "opencut-wasm";
import type { EffectPass, EffectUniformValue } from "@/lib/effects/types";
let initializeGpuRendererPromise: Promise<void> | null = null;
export function initializeGpuRenderer(): Promise<void> {
if (!initializeGpuRendererPromise) {
initializeGpuRendererPromise = initializeGpu();
}
const promise = initializeGpuRendererPromise;
if (!promise) {
throw new Error("GPU renderer initialization promise was not created");
}
return promise;
}
export const gpuRenderer = {
applyEffect({
source,
width,
height,
passes,
}: {
source: CanvasImageSource;
width: number;
height: number;
passes: EffectPass[];
}): CanvasImageSource {
if (passes.length === 0) {
return source;
}
const sourceCanvas = ensureOffscreenCanvas({
source,
width,
height,
label: "effect source",
});
return applyEffectPasses({
source: sourceCanvas,
width,
height,
passes: serializeEffectPasses(passes),
});
},
applyMaskFeather({
maskCanvas,
width,
height,
feather,
}: {
maskCanvas: CanvasImageSource;
width: number;
height: number;
feather: number;
}): CanvasImageSource {
const sourceCanvas = ensureOffscreenCanvas({
source: maskCanvas,
width,
height,
label: "mask source",
});
return applyMaskFeatherWasm({
mask: sourceCanvas,
width,
height,
feather,
});
},
};
function ensureOffscreenCanvas({
source,
width,
height,
label,
}: {
source: CanvasImageSource;
width: number;
height: number;
label: string;
}): OffscreenCanvas {
if (source instanceof OffscreenCanvas) {
return source;
}
if (typeof OffscreenCanvas === "undefined") {
throw new Error(`OffscreenCanvas is required for the GPU ${label}`);
}
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d");
if (!context) {
throw new Error(`Failed to get 2d context for the GPU ${label}`);
}
context.clearRect(0, 0, width, height);
context.drawImage(source, 0, 0, width, height);
return canvas;
}
function serializeEffectPasses(passes: EffectPass[]) {
return passes.map((pass) => ({
shader: pass.shader,
uniforms: Object.entries(pass.uniforms).map(([name, value]) => ({
name,
value: normalizeUniformValue(value),
})),
}));
}
function normalizeUniformValue(value: EffectUniformValue): number[] {
return typeof value === "number" ? [value] : value;
}
+20 -54
View File
@@ -1,54 +1,20 @@
import jfaDistanceShader from "@/lib/masks/shaders/jfa-distance.frag.glsl";
import { getWebGLContext, readResult } from "./webgl/webgl-context";
import { computeSignedDistanceField, runPass } from "./webgl/jfa";
import { compileProgram, createTexture } from "./webgl/webgl-utils";
export function applyMaskFeather({
maskCanvas,
width,
height,
feather,
}: {
maskCanvas: CanvasImageSource;
width: number;
height: number;
feather: number;
}): OffscreenCanvas | HTMLCanvasElement {
const { context, programCache } = getWebGLContext({ width, height });
const sourceTexture = createTexture({ context, source: maskCanvas });
const sdf = computeSignedDistanceField({
context,
programCache,
sourceTexture,
width,
height,
});
const distanceProgram = compileProgram({
context,
fragmentShaderSource: jfaDistanceShader,
programCache,
});
runPass({
context,
program: distanceProgram,
inputTexture: sdf.insideTexture,
target: null,
width,
height,
uniforms: { u_feather_half: feather / 2.0 },
extraBindings: [
{ unit: 1, texture: sdf.outsideTexture, name: "u_jfa_outside" },
],
});
context.deleteTexture(sourceTexture);
sdf.cleanup();
context.bindTexture(context.TEXTURE_2D, null);
context.bindFramebuffer(context.FRAMEBUFFER, null);
return readResult({ width, height });
}
import { gpuRenderer } from "./gpu-renderer";
export function applyMaskFeather({
maskCanvas,
width,
height,
feather,
}: {
maskCanvas: CanvasImageSource;
width: number;
height: number;
feather: number;
}): OffscreenCanvas | HTMLCanvasElement {
return gpuRenderer.applyMaskFeather({
maskCanvas,
width,
height,
feather,
}) as OffscreenCanvas | HTMLCanvasElement;
}
@@ -5,7 +5,7 @@ import { videoCache } from "@/services/video-cache/service";
import type { RetimeConfig } from "@/lib/timeline";
import type { CanvasRenderer } from "../canvas-renderer";
import { createOffscreenCanvas } from "../canvas-utils";
import { webglEffectRenderer } from "../webgl/webgl-effect-renderer";
import { gpuRenderer } from "../gpu-renderer";
import { BaseNode } from "./base-node";
import { loadImageSource, type CachedImageSource } from "./image-node";
@@ -131,7 +131,7 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
sigmaX: this.params.blurIntensity * (renderer.width / 1920),
sigmaY: this.params.blurIntensity * (renderer.height / 1080),
});
const effectResult = webglEffectRenderer.applyEffect({
const effectResult = gpuRenderer.applyEffect({
source: offscreen as CanvasImageSource,
width: renderer.width,
height: renderer.height,
@@ -1,81 +1,81 @@
import type { CanvasRenderer } from "../canvas-renderer";
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
import type { ParamValues } from "@/lib/params";
import { BaseNode } from "./base-node";
import { webglEffectRenderer } from "../webgl/webgl-effect-renderer";
const TIME_EPSILON = 1e-6;
export type EffectLayerNodeParams = {
effectType: string;
effectParams: ParamValues;
timeOffset: number;
duration: number;
};
function isInRange({
time,
timeOffset,
duration,
}: {
time: number;
timeOffset: number;
duration: number;
}): boolean {
return (
time >= timeOffset - TIME_EPSILON &&
time < timeOffset + duration + TIME_EPSILON
);
}
// snapshots whatever is currently on the canvas, applies the effect, draws it back
export class EffectLayerNode extends BaseNode<EffectLayerNodeParams> {
async render({
renderer,
time,
}: {
renderer: CanvasRenderer;
time: number;
}): Promise<void> {
if (
!isInRange({
time,
timeOffset: this.params.timeOffset,
duration: this.params.duration,
})
) {
return;
}
const source = renderer.context.canvas as CanvasImageSource;
const effectDefinition = effectsRegistry.get(this.params.effectType);
const passes = resolveEffectPasses({
definition: effectDefinition,
effectParams: this.params.effectParams,
width: renderer.width,
height: renderer.height,
});
if (passes.length === 0) {
return;
}
const effectResult = webglEffectRenderer.applyEffect({
source,
width: renderer.width,
height: renderer.height,
passes,
});
renderer.context.save();
renderer.context.clearRect(0, 0, renderer.width, renderer.height);
renderer.context.drawImage(
effectResult,
0,
0,
renderer.width,
renderer.height,
);
renderer.context.restore();
}
}
import type { CanvasRenderer } from "../canvas-renderer";
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
import type { ParamValues } from "@/lib/params";
import { BaseNode } from "./base-node";
import { gpuRenderer } from "../gpu-renderer";
const TIME_EPSILON = 1e-6;
export type EffectLayerNodeParams = {
effectType: string;
effectParams: ParamValues;
timeOffset: number;
duration: number;
};
function isInRange({
time,
timeOffset,
duration,
}: {
time: number;
timeOffset: number;
duration: number;
}): boolean {
return (
time >= timeOffset - TIME_EPSILON &&
time < timeOffset + duration + TIME_EPSILON
);
}
// snapshots whatever is currently on the canvas, applies the effect, draws it back
export class EffectLayerNode extends BaseNode<EffectLayerNodeParams> {
async render({
renderer,
time,
}: {
renderer: CanvasRenderer;
time: number;
}): Promise<void> {
if (
!isInRange({
time,
timeOffset: this.params.timeOffset,
duration: this.params.duration,
})
) {
return;
}
const source = renderer.context.canvas as CanvasImageSource;
const effectDefinition = effectsRegistry.get(this.params.effectType);
const passes = resolveEffectPasses({
definition: effectDefinition,
effectParams: this.params.effectParams,
width: renderer.width,
height: renderer.height,
});
if (passes.length === 0) {
return;
}
const effectResult = gpuRenderer.applyEffect({
source,
width: renderer.width,
height: renderer.height,
passes,
});
renderer.context.save();
renderer.context.clearRect(0, 0, renderer.width, renderer.height);
renderer.context.drawImage(
effectResult,
0,
0,
renderer.width,
renderer.height,
);
renderer.context.restore();
}
}
@@ -21,7 +21,7 @@ import {
} from "@/lib/animation";
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
import { webglEffectRenderer } from "../webgl/webgl-effect-renderer";
import { gpuRenderer } from "../gpu-renderer";
import { clamp } from "@/utils/math";
const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
@@ -272,7 +272,7 @@ export class TextNode extends BaseNode<TextNodeParams> {
width: renderer.width,
height: renderer.height,
});
currentSource = webglEffectRenderer.applyEffect({
currentSource = gpuRenderer.applyEffect({
source: currentSource,
width: renderer.width,
height: renderer.height,
@@ -16,7 +16,7 @@ import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
import { masksRegistry } from "@/lib/masks";
import { getSourceTimeAtClipTime } from "@/lib/retime";
import { webglEffectRenderer } from "../webgl/webgl-effect-renderer";
import { gpuRenderer } from "../gpu-renderer";
import { applyMaskFeather } from "../mask-feather";
export interface VisualNodeParams {
@@ -205,7 +205,7 @@ export abstract class VisualNode<
width,
height,
});
current = webglEffectRenderer.applyEffect({
current = gpuRenderer.applyEffect({
source: current,
width: Math.round(width),
height: Math.round(height),
-194
View File
@@ -1,194 +0,0 @@
import jfaInitShader from "@/lib/shaders/jfa-init.frag.glsl";
import jfaStepShader from "@/lib/shaders/jfa-step.frag.glsl";
import {
compileProgram,
createFramebufferTexture,
setUniforms,
drawFullscreenQuad,
} from "./webgl-utils";
interface FBPair {
texture: WebGLTexture;
framebuffer: WebGLFramebuffer;
}
function runPass({
context,
program,
inputTexture,
target,
width,
height,
uniforms,
extraBindings,
}: {
context: WebGLRenderingContext;
program: WebGLProgram;
inputTexture: WebGLTexture;
target: WebGLFramebuffer | null;
width: number;
height: number;
uniforms: Record<string, number | number[]>;
extraBindings?: Array<{ unit: number; texture: WebGLTexture; name: string }>;
}): void {
context.bindFramebuffer(context.FRAMEBUFFER, target);
// biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook
context.useProgram(program);
context.activeTexture(context.TEXTURE0);
context.bindTexture(context.TEXTURE_2D, inputTexture);
const uTexLoc = context.getUniformLocation(program, "u_texture");
if (uTexLoc) context.uniform1i(uTexLoc, 0);
if (extraBindings) {
for (const binding of extraBindings) {
context.activeTexture(context.TEXTURE0 + binding.unit);
context.bindTexture(context.TEXTURE_2D, binding.texture);
const loc = context.getUniformLocation(program, binding.name);
if (loc) context.uniform1i(loc, binding.unit);
}
}
setUniforms({
context,
program,
uniforms: { ...uniforms, u_resolution: [width, height] },
});
drawFullscreenQuad({ context, program, width, height });
}
function runJFA({
context,
programCache,
sourceTexture,
width,
height,
isInverted,
}: {
context: WebGLRenderingContext;
programCache: Map<string, WebGLProgram>;
sourceTexture: WebGLTexture;
width: number;
height: number;
isInverted: boolean;
}): {
resultTexture: WebGLTexture;
resultFB: WebGLFramebuffer;
tempFBs: FBPair[];
} {
const numSteps = Math.ceil(Math.log2(Math.max(width, height)));
const fbA = createFramebufferTexture({ context, width, height });
const fbB = createFramebufferTexture({ context, width, height });
const initProgram = compileProgram({
context,
fragmentShaderSource: jfaInitShader,
programCache,
});
runPass({
context,
program: initProgram,
inputTexture: sourceTexture,
target: fbA.framebuffer,
width,
height,
uniforms: { u_invert: isInverted ? 1.0 : 0.0 },
});
const stepProgram = compileProgram({
context,
fragmentShaderSource: jfaStepShader,
programCache,
});
let readFB = fbA;
let writeFB = fbB;
for (let i = numSteps - 1; i >= 0; i--) {
const stepSize = 2 ** i;
runPass({
context,
program: stepProgram,
inputTexture: readFB.texture,
target: writeFB.framebuffer,
width,
height,
uniforms: { u_step_size: stepSize },
});
const tmp = readFB;
readFB = writeFB;
writeFB = tmp;
}
return {
resultTexture: readFB.texture,
resultFB: readFB.framebuffer,
tempFBs: [writeFB],
};
}
function cleanupJFAResult({
context,
result,
}: {
context: WebGLRenderingContext;
result: {
resultTexture: WebGLTexture;
resultFB: WebGLFramebuffer;
tempFBs: FBPair[];
};
}): void {
context.deleteTexture(result.resultTexture);
context.deleteFramebuffer(result.resultFB);
for (const fb of result.tempFBs) {
context.deleteTexture(fb.texture);
context.deleteFramebuffer(fb.framebuffer);
}
}
export { runPass };
export function computeSignedDistanceField({
context,
programCache,
sourceTexture,
width,
height,
}: {
context: WebGLRenderingContext;
programCache: Map<string, WebGLProgram>;
sourceTexture: WebGLTexture;
width: number;
height: number;
}): {
insideTexture: WebGLTexture;
outsideTexture: WebGLTexture;
cleanup: () => void;
} {
const inside = runJFA({
context,
programCache,
sourceTexture,
width,
height,
isInverted: false,
});
const outside = runJFA({
context,
programCache,
sourceTexture,
width,
height,
isInverted: true,
});
return {
insideTexture: inside.resultTexture,
outsideTexture: outside.resultTexture,
cleanup: () => {
cleanupJFAResult({ context, result: inside });
cleanupJFAResult({ context, result: outside });
},
};
}
@@ -1,49 +0,0 @@
import { createOffscreenCanvas } from "../canvas-utils";
let gl: WebGLRenderingContext | null = null;
let webglCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
const programCache = new Map<string, WebGLProgram>();
export function getWebGLContext({
width,
height,
}: {
width: number;
height: number;
}): {
context: WebGLRenderingContext;
programCache: Map<string, WebGLProgram>;
} {
if (!webglCanvas) {
webglCanvas = createOffscreenCanvas({ width, height });
gl = webglCanvas.getContext("webgl", {
premultipliedAlpha: false,
}) as WebGLRenderingContext | null;
if (!gl) throw new Error("WebGL not supported");
}
if (webglCanvas.width !== width || webglCanvas.height !== height) {
webglCanvas.width = width;
webglCanvas.height = height;
}
if (!gl) throw new Error("WebGL context lost");
return { context: gl, programCache };
}
export function readResult({
width,
height,
}: {
width: number;
height: number;
}): OffscreenCanvas | HTMLCanvasElement {
if (!webglCanvas) throw new Error("WebGL canvas not initialized");
const outputCanvas = createOffscreenCanvas({ width, height });
const outputCtx = outputCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (outputCtx) {
outputCtx.drawImage(webglCanvas, 0, 0, width, height);
}
return outputCanvas;
}
@@ -1,35 +0,0 @@
import { getWebGLContext, readResult } from "./webgl-context";
import { applyMultiPassEffect } from "./webgl-utils";
import type { EffectPassData } from "./webgl-utils";
export interface ApplyEffectParams {
source: CanvasImageSource;
width: number;
height: number;
passes: EffectPassData[];
}
function applyEffect({
source,
width,
height,
passes,
}: ApplyEffectParams): CanvasImageSource {
if (passes.length === 0) {
return source;
}
const { context, programCache } = getWebGLContext({ width, height });
applyMultiPassEffect({
context,
source,
width,
height,
passes,
programCache,
});
return readResult({ width, height });
}
export const webglEffectRenderer = {
applyEffect,
};
@@ -1,298 +0,0 @@
import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl";
export interface EffectPassData {
fragmentShader: string;
uniforms: Record<string, number | number[]>;
}
export const QUAD_POSITIONS = new Float32Array([
-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1,
]);
export function compileProgram({
context,
fragmentShaderSource,
programCache,
}: {
context: WebGLRenderingContext;
fragmentShaderSource: string;
programCache: Map<string, WebGLProgram>;
}): WebGLProgram {
const cached = programCache.get(fragmentShaderSource);
if (cached) {
return cached;
}
const vertexShader = compileShader({
context,
source: VERTEX_SHADER_SOURCE,
type: context.VERTEX_SHADER,
});
const fragmentShader = compileShader({
context,
source: fragmentShaderSource,
type: context.FRAGMENT_SHADER,
});
const program = context.createProgram();
if (!program) {
throw new Error("Failed to create WebGL program");
}
context.attachShader(program, vertexShader);
context.attachShader(program, fragmentShader);
context.linkProgram(program);
if (!context.getProgramParameter(program, context.LINK_STATUS)) {
const info = context.getProgramInfoLog(program);
context.deleteProgram(program);
throw new Error(`WebGL program link failed: ${info}`);
}
context.deleteShader(vertexShader);
context.deleteShader(fragmentShader);
programCache.set(fragmentShaderSource, program);
return program;
}
export function compileShader({
context,
source,
type,
}: {
context: WebGLRenderingContext;
source: string;
type: number;
}): WebGLShader {
const shader = context.createShader(type);
if (!shader) {
throw new Error("Failed to create WebGL shader");
}
context.shaderSource(shader, source);
context.compileShader(shader);
if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) {
const info = context.getShaderInfoLog(shader);
context.deleteShader(shader);
throw new Error(`WebGL shader compile failed: ${info}`);
}
return shader;
}
export function createTexture({
context,
source,
}: {
context: WebGLRenderingContext;
source: CanvasImageSource;
}): WebGLTexture {
const texture = context.createTexture();
if (!texture) {
throw new Error("Failed to create WebGL texture");
}
context.activeTexture(context.TEXTURE0);
context.bindTexture(context.TEXTURE_2D, texture);
context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, 1);
context.texParameteri(
context.TEXTURE_2D,
context.TEXTURE_WRAP_S,
context.CLAMP_TO_EDGE,
);
context.texParameteri(
context.TEXTURE_2D,
context.TEXTURE_WRAP_T,
context.CLAMP_TO_EDGE,
);
context.texParameteri(
context.TEXTURE_2D,
context.TEXTURE_MIN_FILTER,
context.LINEAR,
);
context.texParameteri(
context.TEXTURE_2D,
context.TEXTURE_MAG_FILTER,
context.LINEAR,
);
context.texImage2D(
context.TEXTURE_2D,
0,
context.RGBA,
context.RGBA,
context.UNSIGNED_BYTE,
source as TexImageSource,
);
return texture;
}
export function setUniforms({
context,
program,
uniforms,
}: {
context: WebGLRenderingContext;
program: WebGLProgram;
uniforms: Record<string, number | number[]>;
}): void {
for (const [name, value] of Object.entries(uniforms)) {
const location = context.getUniformLocation(program, name);
if (location === null) continue;
if (typeof value === "number") {
context.uniform1f(location, value);
} else if (Array.isArray(value)) {
if (value.length === 2) {
context.uniform2fv(location, new Float32Array(value));
} else if (value.length === 3) {
context.uniform3fv(location, new Float32Array(value));
} else if (value.length === 4) {
context.uniform4fv(location, new Float32Array(value));
}
}
}
}
export function drawFullscreenQuad({
context,
program,
width,
height,
}: {
context: WebGLRenderingContext;
program: WebGLProgram;
width: number;
height: number;
}): void {
const positionLocation = context.getAttribLocation(program, "a_position");
const buffer = context.createBuffer();
context.bindBuffer(context.ARRAY_BUFFER, buffer);
context.bufferData(context.ARRAY_BUFFER, QUAD_POSITIONS, context.STATIC_DRAW);
context.enableVertexAttribArray(positionLocation);
context.vertexAttribPointer(positionLocation, 2, context.FLOAT, false, 0, 0);
context.viewport(0, 0, width, height);
context.clearColor(0, 0, 0, 0);
context.clear(context.COLOR_BUFFER_BIT);
context.drawArrays(context.TRIANGLES, 0, 6);
}
export function createFramebufferTexture({
context,
width,
height,
}: {
context: WebGLRenderingContext;
width: number;
height: number;
}): { texture: WebGLTexture; framebuffer: WebGLFramebuffer } {
const texture = context.createTexture();
if (!texture) throw new Error("Failed to create framebuffer texture");
context.bindTexture(context.TEXTURE_2D, texture);
context.texImage2D(
context.TEXTURE_2D,
0,
context.RGBA,
width,
height,
0,
context.RGBA,
context.UNSIGNED_BYTE,
null,
);
context.texParameteri(
context.TEXTURE_2D,
context.TEXTURE_WRAP_S,
context.CLAMP_TO_EDGE,
);
context.texParameteri(
context.TEXTURE_2D,
context.TEXTURE_WRAP_T,
context.CLAMP_TO_EDGE,
);
context.texParameteri(
context.TEXTURE_2D,
context.TEXTURE_MIN_FILTER,
context.LINEAR,
);
context.texParameteri(
context.TEXTURE_2D,
context.TEXTURE_MAG_FILTER,
context.LINEAR,
);
context.bindTexture(context.TEXTURE_2D, null);
const framebuffer = context.createFramebuffer();
if (!framebuffer) throw new Error("Failed to create framebuffer");
context.bindFramebuffer(context.FRAMEBUFFER, framebuffer);
context.framebufferTexture2D(
context.FRAMEBUFFER,
context.COLOR_ATTACHMENT0,
context.TEXTURE_2D,
texture,
0,
);
context.bindFramebuffer(context.FRAMEBUFFER, null);
return { texture, framebuffer };
}
export function applyMultiPassEffect({
context,
source,
width,
height,
passes,
programCache,
}: {
context: WebGLRenderingContext;
source: CanvasImageSource;
width: number;
height: number;
passes: EffectPassData[];
programCache: Map<string, WebGLProgram>;
}): void {
const sourceTexture = createTexture({ context, source });
let currentTexture: WebGLTexture = sourceTexture;
const intermediates: Array<{
texture: WebGLTexture;
framebuffer: WebGLFramebuffer;
}> = [];
for (let i = 0; i < passes.length - 1; i++) {
intermediates.push(createFramebufferTexture({ context, width, height }));
}
for (let i = 0; i < passes.length; i++) {
const pass = passes[i];
const program = compileProgram({
context,
fragmentShaderSource: pass.fragmentShader,
programCache,
});
const isLastPass = i === passes.length - 1;
const targetFramebuffer = isLastPass ? null : intermediates[i].framebuffer;
context.bindFramebuffer(context.FRAMEBUFFER, targetFramebuffer);
// biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook
context.useProgram(program);
context.activeTexture(context.TEXTURE0);
context.bindTexture(context.TEXTURE_2D, currentTexture);
const uTextureLocation = context.getUniformLocation(program, "u_texture");
if (uTextureLocation) {
context.uniform1i(uTextureLocation, 0);
}
setUniforms({
context,
program,
uniforms: { ...pass.uniforms, u_resolution: [width, height] },
});
drawFullscreenQuad({ context, program, width, height });
if (!isLastPass) {
currentTexture = intermediates[i].texture;
}
}
context.deleteTexture(sourceTexture);
for (const intermediate of intermediates) {
context.deleteTexture(intermediate.texture);
context.deleteFramebuffer(intermediate.framebuffer);
}
context.bindTexture(context.TEXTURE_2D, null);
context.bindFramebuffer(context.FRAMEBUFFER, null);
}
-4
View File
@@ -1,4 +0,0 @@
declare module "*.glsl" {
const value: string;
export default value;
}