From e579ae120290329fd697b50ea9138ffccb65a805 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 1 Apr 2026 13:57:32 +0200 Subject: feat: migrate GPU renderer from WebGL to wgpu/WASM --- apps/web/next.config.ts | 8 - apps/web/package.json | 1 - .../src/components/providers/editor-provider.tsx | 268 +++++++++--------- .../web/src/lib/effects/definitions/blur.frag.glsl | 25 -- apps/web/src/lib/effects/definitions/blur.ts | 18 +- apps/web/src/lib/effects/effect.vert.glsl | 7 - apps/web/src/lib/effects/index.ts | 6 +- apps/web/src/lib/effects/types.ts | 83 +++--- .../src/lib/masks/shaders/jfa-distance.frag.glsl | 36 --- apps/web/src/lib/shaders/jfa-init.frag.glsl | 25 -- apps/web/src/lib/shaders/jfa-step.frag.glsl | 60 ----- apps/web/src/services/renderer/effect-preview.ts | 53 +--- apps/web/src/services/renderer/gpu-renderer.ts | 118 ++++++++ apps/web/src/services/renderer/mask-feather.ts | 74 ++--- .../renderer/nodes/blur-background-node.ts | 4 +- .../services/renderer/nodes/effect-layer-node.ts | 162 +++++------ apps/web/src/services/renderer/nodes/text-node.ts | 4 +- .../web/src/services/renderer/nodes/visual-node.ts | 4 +- apps/web/src/services/renderer/webgl/jfa.ts | 194 -------------- .../src/services/renderer/webgl/webgl-context.ts | 49 ---- .../renderer/webgl/webgl-effect-renderer.ts | 35 --- .../web/src/services/renderer/webgl/webgl-utils.ts | 298 --------------------- apps/web/src/types/glsl.d.ts | 4 - 23 files changed, 419 insertions(+), 1117 deletions(-) delete mode 100644 apps/web/src/lib/effects/definitions/blur.frag.glsl delete mode 100644 apps/web/src/lib/effects/effect.vert.glsl delete mode 100644 apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl delete mode 100644 apps/web/src/lib/shaders/jfa-init.frag.glsl delete mode 100644 apps/web/src/lib/shaders/jfa-step.frag.glsl create mode 100644 apps/web/src/services/renderer/gpu-renderer.ts delete mode 100644 apps/web/src/services/renderer/webgl/jfa.ts delete mode 100644 apps/web/src/services/renderer/webgl/webgl-context.ts delete mode 100644 apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts delete mode 100644 apps/web/src/services/renderer/webgl/webgl-utils.ts delete mode 100644 apps/web/src/types/glsl.d.ts (limited to 'apps') diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 08db3ead..71d96c23 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -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", }, diff --git a/apps/web/package.json b/apps/web/package.json index 0c3a1f39..c7d55572 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx index 79f1809a..20476a13 100644 --- a/apps/web/src/components/providers/editor-provider.tsx +++ b/apps/web/src/components/providers/editor-provider.tsx @@ -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(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 ( -
-
-

{error}

-
-
- ); - } - - if (isLoading) { - return ( -
-
- -

Loading project...

-
-
- ); - } - - if (!activeProject) { - return ( -
-
- -

Exiting project...

-
-
- ); - } - - return ( - <> - - {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(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 ( +
+
+

{error}

+
+
+ ); + } + + if (isLoading) { + return ( +
+
+ +

Loading project...

+
+
+ ); + } + + if (!activeProject) { + return ( +
+
+ +

Exiting project...

+
+
+ ); + } + + return ( + <> + + {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; +} diff --git a/apps/web/src/lib/effects/definitions/blur.frag.glsl b/apps/web/src/lib/effects/definitions/blur.frag.glsl deleted file mode 100644 index c9ad2eae..00000000 --- a/apps/web/src/lib/effects/definitions/blur.frag.glsl +++ /dev/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; -} \ No newline at end of file diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts index 54934402..3bfd5e3c 100644 --- a/apps/web/src/lib/effects/definitions/blur.ts +++ b/apps/web/src/lib/effects/definitions/blur.ts @@ -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, diff --git a/apps/web/src/lib/effects/effect.vert.glsl b/apps/web/src/lib/effects/effect.vert.glsl deleted file mode 100644 index 770faf0c..00000000 --- a/apps/web/src/lib/effects/effect.vert.glsl +++ /dev/null @@ -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); -} diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts index 549811f5..92484ee2 100644 --- a/apps/web/src/lib/effects/index.ts +++ b/apps/web/src/lib/effects/index.ts @@ -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 }), })); } diff --git a/apps/web/src/lib/effects/types.ts b/apps/web/src/lib/effects/types.ts index 5573ef12..1862b440 100644 --- a/apps/web/src/lib/effects/types.ts +++ b/apps/web/src/lib/effects/types.ts @@ -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; -} - -export interface WebGLEffectPass { - fragmentShader: string; - uniforms(params: { - effectParams: ParamValues; - width: number; - height: number; - }): Record; -} - -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; +} + +export interface EffectPassTemplate { + shader: string; + uniforms(params: { + effectParams: ParamValues; + width: number; + height: number; + }): Record; +} + +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; +} diff --git a/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl b/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl deleted file mode 100644 index 9d9a8773..00000000 --- a/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl +++ /dev/null @@ -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); -} diff --git a/apps/web/src/lib/shaders/jfa-init.frag.glsl b/apps/web/src/lib/shaders/jfa-init.frag.glsl deleted file mode 100644 index e465752c..00000000 --- a/apps/web/src/lib/shaders/jfa-init.frag.glsl +++ /dev/null @@ -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); - } -} diff --git a/apps/web/src/lib/shaders/jfa-step.frag.glsl b/apps/web/src/lib/shaders/jfa-step.frag.glsl deleted file mode 100644 index 9b76a70b..00000000 --- a/apps/web/src/lib/shaders/jfa-step.frag.glsl +++ /dev/null @@ -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); - } -} diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts index b2f6f51a..8e081039 100644 --- a/apps/web/src/services/renderer/effect-preview.ts +++ b/apps/web/src/services/renderer/effect-preview.ts @@ -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(); 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; }): 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; } } diff --git a/apps/web/src/services/renderer/gpu-renderer.ts b/apps/web/src/services/renderer/gpu-renderer.ts new file mode 100644 index 00000000..91b03603 --- /dev/null +++ b/apps/web/src/services/renderer/gpu-renderer.ts @@ -0,0 +1,118 @@ +import { + applyEffectPasses, + applyMaskFeather as applyMaskFeatherWasm, + initializeGpu, +} from "opencut-wasm"; +import type { EffectPass, EffectUniformValue } from "@/lib/effects/types"; + +let initializeGpuRendererPromise: Promise | null = null; + +export function initializeGpuRenderer(): Promise { + 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; +} diff --git a/apps/web/src/services/renderer/mask-feather.ts b/apps/web/src/services/renderer/mask-feather.ts index f1515117..4ea7eb2b 100644 --- a/apps/web/src/services/renderer/mask-feather.ts +++ b/apps/web/src/services/renderer/mask-feather.ts @@ -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; +} diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts index 7f4d06fc..f790ebb4 100644 --- a/apps/web/src/services/renderer/nodes/blur-background-node.ts +++ b/apps/web/src/services/renderer/nodes/blur-background-node.ts @@ -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 { 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, diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts index 51de2398..d8389fb5 100644 --- a/apps/web/src/services/renderer/nodes/effect-layer-node.ts +++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts @@ -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 { - async render({ - renderer, - time, - }: { - renderer: CanvasRenderer; - time: number; - }): Promise { - 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 { + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise { + 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(); + } +} diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index 031c43b3..643e538a 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -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 { width: renderer.width, height: renderer.height, }); - currentSource = webglEffectRenderer.applyEffect({ + currentSource = gpuRenderer.applyEffect({ source: currentSource, width: renderer.width, height: renderer.height, diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index e0f87c52..82093da4 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -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), diff --git a/apps/web/src/services/renderer/webgl/jfa.ts b/apps/web/src/services/renderer/webgl/jfa.ts deleted file mode 100644 index 9c87e7fa..00000000 --- a/apps/web/src/services/renderer/webgl/jfa.ts +++ /dev/null @@ -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; - 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; - 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; - 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 }); - }, - }; -} diff --git a/apps/web/src/services/renderer/webgl/webgl-context.ts b/apps/web/src/services/renderer/webgl/webgl-context.ts deleted file mode 100644 index 39869a02..00000000 --- a/apps/web/src/services/renderer/webgl/webgl-context.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createOffscreenCanvas } from "../canvas-utils"; - -let gl: WebGLRenderingContext | null = null; -let webglCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; -const programCache = new Map(); - -export function getWebGLContext({ - width, - height, -}: { - width: number; - height: number; -}): { - context: WebGLRenderingContext; - programCache: Map; -} { - 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; -} diff --git a/apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts b/apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts deleted file mode 100644 index 95ef990b..00000000 --- a/apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts +++ /dev/null @@ -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, -}; diff --git a/apps/web/src/services/renderer/webgl/webgl-utils.ts b/apps/web/src/services/renderer/webgl/webgl-utils.ts deleted file mode 100644 index ec207546..00000000 --- a/apps/web/src/services/renderer/webgl/webgl-utils.ts +++ /dev/null @@ -1,298 +0,0 @@ -import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl"; - -export interface EffectPassData { - fragmentShader: string; - uniforms: Record; -} - -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; -}): 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; -}): 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; -}): 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); -} diff --git a/apps/web/src/types/glsl.d.ts b/apps/web/src/types/glsl.d.ts deleted file mode 100644 index 2a5de52c..00000000 --- a/apps/web/src/types/glsl.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "*.glsl" { - const value: string; - export default value; -} -- cgit v1.3.1