From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- 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 insertions(+) create mode 100644 apps/web/src/services/renderer/webgl/jfa.ts create mode 100644 apps/web/src/services/renderer/webgl/webgl-context.ts create mode 100644 apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts create 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 new file mode 100644 index 00000000..b69a9d06 --- /dev/null +++ b/apps/web/src/services/renderer/webgl/jfa.ts @@ -0,0 +1,194 @@ +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 new file mode 100644 index 00000000..aeefc503 --- /dev/null +++ b/apps/web/src/services/renderer/webgl/webgl-context.ts @@ -0,0 +1,49 @@ +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 new file mode 100644 index 00000000..89dcb2ae --- /dev/null +++ b/apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 00000000..834c652e --- /dev/null +++ b/apps/web/src/services/renderer/webgl/webgl-utils.ts @@ -0,0 +1,298 @@ +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