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/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 --------------------- 4 files changed, 576 deletions(-) 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 (limited to 'apps/web/src/services/renderer/webgl') 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); -} -- cgit v1.3.1