From c817563cf0446b5e011c0962f737e078be262e64 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Thu, 5 Mar 2026 03:04:33 -0800 Subject: [PATCH] feat: GLSLPreviewEngine + GLSL utility functions (#9200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Standalone WebGL2 rendering engine for client-side GLSL shader preview, with utility functions that mirror the backend `nodes_glsl.py` detection logic. ## Changes - **What**: New `GLSLPreviewEngine` class (OffscreenCanvas + WebGL2), `glslUtils.ts` (detectOutputCount, detectPassCount, hasVersionDirective), and unit tests - **GLSLPreviewEngine**: Fullscreen triangle via `gl_VertexID` (no VBO), ping-pong FBOs for multi-pass rendering, MRT via `gl.drawBuffers()`, blob output via `canvas.convertToBlob()` - **glslUtils**: Pure functions ported from backend Python to TypeScript, regex-based detection matching `_detect_output_count()` and `_detect_pass_count()` ## Review Focus - WebGL2 resource lifecycle (context loss, texture cleanup, FBO teardown in `dispose()`) - Ping-pong FBO logic for multi-pass shaders - Engine tests are WebGL2-gated (`describe.skip` in happy-dom) — they run in real browser environments ## Stacked PR PR 2 of 3. Stacked on #9198 (fix: GLSLShader preview promotion). PR 3: `feat/glsl-live-preview` (composable + LGraphNode.vue integration) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9200-feat-GLSLPreviewEngine-GLSL-utility-functions-3126d73d3650812fadc6df4a26387d0e) by [Unito](https://www.unito.io) --- knip.config.ts | 2 + src/renderer/glsl/glslUtils.test.ts | 77 +++++ src/renderer/glsl/glslUtils.ts | 32 ++ src/renderer/glsl/useGLSLRenderer.ts | 428 +++++++++++++++++++++++++++ 4 files changed, 539 insertions(+) create mode 100644 src/renderer/glsl/glslUtils.test.ts create mode 100644 src/renderer/glsl/glslUtils.ts create mode 100644 src/renderer/glsl/useGLSLRenderer.ts diff --git a/knip.config.ts b/knip.config.ts index 5ab310e2b7..cd8e7ffad0 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -40,6 +40,8 @@ const config: KnipConfig = { 'packages/registry-types/src/comfyRegistryTypes.ts', // Used by a custom node (that should move off of this) 'src/scripts/ui/components/splitButton.ts', + // Used by stacked PR (feat/glsl-live-preview) + 'src/renderer/glsl/useGLSLRenderer.ts', // Workflow files contain license names that knip misinterprets as binaries '.github/workflows/ci-oss-assets-validation.yaml', // Pending integration in stacked PR diff --git a/src/renderer/glsl/glslUtils.test.ts b/src/renderer/glsl/glslUtils.test.ts new file mode 100644 index 0000000000..a2f7958f42 --- /dev/null +++ b/src/renderer/glsl/glslUtils.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' + +import { + detectOutputCount, + detectPassCount, + hasVersionDirective +} from '@/renderer/glsl/glslUtils' + +describe('detectOutputCount', () => { + it('returns 1 when no fragColor declarations found', () => { + expect(detectOutputCount('void main() { gl_FragColor = vec4(1); }')).toBe(1) + }) + + it('returns 1 for fragColor0 only', () => { + expect(detectOutputCount('fragColor0 = vec4(1.0);')).toBe(1) + }) + + it('returns 2 for fragColor0 and fragColor1', () => { + expect( + detectOutputCount('fragColor0 = vec4(1.0);\nfragColor1 = vec4(0.0);') + ).toBe(2) + }) + + it('returns 4 for all four outputs', () => { + const src = ` + fragColor0 = vec4(1.0); + fragColor1 = vec4(0.5); + fragColor2 = vec4(0.3); + fragColor3 = vec4(0.0); + ` + expect(detectOutputCount(src)).toBe(4) + }) + + it('caps at 4 even if higher indices appear', () => { + expect(detectOutputCount('fragColor5 = vec4(1.0);')).toBe(4) + }) + + it('handles non-sequential output indices', () => { + expect( + detectOutputCount('fragColor0 = vec4(1.0); fragColor3 = vec4(0.0);') + ).toBe(4) + }) +}) + +describe('detectPassCount', () => { + it('returns 1 when no pragma found', () => { + expect(detectPassCount('void main() {}')).toBe(1) + }) + + it('parses #pragma passes N', () => { + expect(detectPassCount('#pragma passes 5\nvoid main() {}')).toBe(5) + }) + + it('returns at least 1 even with #pragma passes 0', () => { + expect(detectPassCount('#pragma passes 0')).toBe(1) + }) + + it('handles extra whitespace in pragma', () => { + expect(detectPassCount('#pragma passes 10')).toBe(10) + }) +}) + +describe('hasVersionDirective', () => { + it('returns true for #version 300 es', () => { + expect(hasVersionDirective('#version 300 es\nprecision highp float;')).toBe( + true + ) + }) + + it('returns false for desktop GLSL', () => { + expect(hasVersionDirective('#version 330 core\nvoid main() {}')).toBe(false) + }) + + it('returns false for no version directive', () => { + expect(hasVersionDirective('void main() { }')).toBe(false) + }) +}) diff --git a/src/renderer/glsl/glslUtils.ts b/src/renderer/glsl/glslUtils.ts new file mode 100644 index 0000000000..eb2a02d2e1 --- /dev/null +++ b/src/renderer/glsl/glslUtils.ts @@ -0,0 +1,32 @@ +/** + * Detect how many fragColor outputs are used in a GLSL shader source. + * Mirrors backend _detect_output_count() in nodes_glsl.py. + */ +export function detectOutputCount(source: string): number { + const matches = source.match(/fragColor(\d+)/g) + if (!matches) return 1 + + let maxIndex = 0 + for (const match of matches) { + const index = parseInt(match.replace('fragColor', ''), 10) + if (index > maxIndex) maxIndex = index + } + return Math.min(maxIndex + 1, 4) +} + +/** + * Parse `#pragma passes N` to get multi-pass count. + * Mirrors backend _detect_pass_count() in nodes_glsl.py. + */ +export function detectPassCount(source: string): number { + const match = source.match(/#pragma\s+passes\s+(\d+)/) + if (match) return Math.max(1, parseInt(match[1], 10)) + return 1 +} + +/** + * Check if a shader source string contains a GLSL ES 3.00 version directive. + */ +export function hasVersionDirective(source: string): boolean { + return /^#version\s+300\s+es\s*$/m.test(source) +} diff --git a/src/renderer/glsl/useGLSLRenderer.ts b/src/renderer/glsl/useGLSLRenderer.ts new file mode 100644 index 0000000000..d6cb02698f --- /dev/null +++ b/src/renderer/glsl/useGLSLRenderer.ts @@ -0,0 +1,428 @@ +import { onScopeDispose } from 'vue' + +import { detectPassCount } from '@/renderer/glsl/glslUtils' + +const VERTEX_SHADER_SOURCE = `#version 300 es +out vec2 v_texCoord; +void main() { + vec2 verts[3] = vec2[](vec2(-1, -1), vec2(3, -1), vec2(-1, 3)); + v_texCoord = verts[gl_VertexID] * 0.5 + 0.5; + gl_Position = vec4(verts[gl_VertexID], 0, 1); +} +` + +const MAX_PASSES = 32 + +export interface GLSLRendererConfig { + maxInputs: number + maxFloatUniforms: number + maxIntUniforms: number +} + +const DEFAULT_CONFIG: GLSLRendererConfig = { + maxInputs: 5, + maxFloatUniforms: 5, + maxIntUniforms: 5 +} + +interface CompileResult { + success: boolean + log: string +} + +function compileShader( + gl: WebGL2RenderingContext, + type: GLenum, + source: string +): WebGLShader { + const shader = gl.createShader(type) + if (!shader) throw new Error('Failed to create shader') + + gl.shaderSource(shader, source) + gl.compileShader(shader) + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(shader) ?? 'Compilation failed' + gl.deleteShader(shader) + throw new Error(log) + } + return shader +} + +export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) { + const { maxInputs, maxFloatUniforms, maxIntUniforms } = config + + const uniformNames = [ + 'u_resolution', + 'u_pass', + 'u_prevPass', + ...Array.from({ length: maxInputs }, (_, i) => `u_image${i}`), + ...Array.from({ length: maxFloatUniforms }, (_, i) => `u_float${i}`), + ...Array.from({ length: maxIntUniforms }, (_, i) => `u_int${i}`) + ] + + let canvas: OffscreenCanvas | null = null + let gl: WebGL2RenderingContext | null = null + let vertexShader: WebGLShader | null = null + let program: WebGLProgram | null = null + let fragmentShader: WebGLShader | null = null + let pingPongFBOs: [WebGLFramebuffer, WebGLFramebuffer] | null = null + let pingPongTextures: [WebGLTexture, WebGLTexture] | null = null + let fallbackTexture: WebGLTexture | null = null + const inputTextures: (WebGLTexture | null)[] = Array.from({ + length: maxInputs + }).fill(null) + const uniformLocations = new Map() + let passCount = 1 + let disposed = false + + function initPingPongFBOs( + ctx: WebGL2RenderingContext, + width: number, + height: number + ): void { + const fbos: WebGLFramebuffer[] = [] + const textures: WebGLTexture[] = [] + + try { + for (let i = 0; i < 2; i++) { + const tex = ctx.createTexture() + if (!tex) throw new Error('Failed to create ping-pong texture') + ctx.bindTexture(ctx.TEXTURE_2D, tex) + ctx.texImage2D( + ctx.TEXTURE_2D, + 0, + ctx.RGBA8, + width, + height, + 0, + ctx.RGBA, + ctx.UNSIGNED_BYTE, + null + ) + ctx.texParameteri(ctx.TEXTURE_2D, ctx.TEXTURE_MIN_FILTER, ctx.LINEAR) + ctx.texParameteri(ctx.TEXTURE_2D, ctx.TEXTURE_MAG_FILTER, ctx.LINEAR) + ctx.texParameteri(ctx.TEXTURE_2D, ctx.TEXTURE_WRAP_S, ctx.CLAMP_TO_EDGE) + ctx.texParameteri(ctx.TEXTURE_2D, ctx.TEXTURE_WRAP_T, ctx.CLAMP_TO_EDGE) + + const fbo = ctx.createFramebuffer() + if (!fbo) throw new Error('Failed to create ping-pong framebuffer') + ctx.bindFramebuffer(ctx.FRAMEBUFFER, fbo) + ctx.framebufferTexture2D( + ctx.FRAMEBUFFER, + ctx.COLOR_ATTACHMENT0, + ctx.TEXTURE_2D, + tex, + 0 + ) + const status = ctx.checkFramebufferStatus(ctx.FRAMEBUFFER) + if (status !== ctx.FRAMEBUFFER_COMPLETE) + throw new Error(`Ping-pong framebuffer incomplete: ${status}`) + + fbos.push(fbo) + textures.push(tex) + } + } catch (error) { + for (const fbo of fbos) ctx.deleteFramebuffer(fbo) + for (const tex of textures) ctx.deleteTexture(tex) + ctx.bindFramebuffer(ctx.FRAMEBUFFER, null) + ctx.bindTexture(ctx.TEXTURE_2D, null) + throw error + } + + ctx.bindFramebuffer(ctx.FRAMEBUFFER, null) + ctx.bindTexture(ctx.TEXTURE_2D, null) + + pingPongFBOs = fbos as [WebGLFramebuffer, WebGLFramebuffer] + pingPongTextures = textures as [WebGLTexture, WebGLTexture] + } + + function destroyPingPongFBOs(): void { + if (!gl) return + if (pingPongFBOs) { + for (const fbo of pingPongFBOs) gl.deleteFramebuffer(fbo) + pingPongFBOs = null + } + if (pingPongTextures) { + for (const tex of pingPongTextures) gl.deleteTexture(tex) + pingPongTextures = null + } + } + + function cacheUniformLocations(): void { + if (!program || !gl) return + for (const name of uniformNames) { + uniformLocations.set(name, gl.getUniformLocation(program, name)) + } + } + + function getFallbackTexture(): WebGLTexture { + if (!gl) throw new Error('Renderer not initialized') + if (!fallbackTexture) { + const tex = gl.createTexture() + if (!tex) throw new Error('Failed to create fallback texture') + fallbackTexture = tex + gl.bindTexture(gl.TEXTURE_2D, fallbackTexture) + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + 1, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + new Uint8Array([0, 0, 0, 255]) + ) + } + return fallbackTexture + } + + function init(width: number, height: number): boolean { + if (disposed) return false + + try { + canvas = new OffscreenCanvas(width, height) + const ctx = canvas.getContext('webgl2', { + alpha: true, + premultipliedAlpha: false, + preserveDrawingBuffer: true + }) + if (!ctx) return false + + gl = ctx + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) + vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE) + initPingPongFBOs(gl, width, height) + return true + } catch { + dispose() + return false + } + } + + function compileFragment(source: string): CompileResult { + if (disposed || !gl) return { success: false, log: 'Engine disposed' } + + passCount = Math.min(detectPassCount(source), MAX_PASSES) + + if (fragmentShader) { + gl.deleteShader(fragmentShader) + fragmentShader = null + } + if (program) { + gl.deleteProgram(program) + program = null + } + uniformLocations.clear() + + try { + fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, source) + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e) + return { success: false, log: msg } + } + + const prog = gl.createProgram() + if (!prog) return { success: false, log: 'Failed to create program' } + + gl.attachShader(prog, vertexShader!) + gl.attachShader(prog, fragmentShader) + gl.linkProgram(prog) + + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(prog) ?? 'Link failed' + gl.deleteProgram(prog) + return { success: false, log } + } + + program = prog + cacheUniformLocations() + return { success: true, log: '' } + } + + function setResolution(width: number, height: number): void { + if (disposed || !gl || !canvas) return + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width + canvas.height = height + gl.viewport(0, 0, width, height) + destroyPingPongFBOs() + initPingPongFBOs(gl, width, height) + } + } + + function setFloatUniform(index: number, value: number): void { + if (disposed || !program || !gl) return + const loc = uniformLocations.get(`u_float${index}`) + if (loc != null) { + gl.useProgram(program) + gl.uniform1f(loc, value) + } + } + + function setIntUniform(index: number, value: number): void { + if (disposed || !program || !gl) return + const loc = uniformLocations.get(`u_int${index}`) + if (loc != null) { + gl.useProgram(program) + gl.uniform1i(loc, value) + } + } + + function bindInputImage( + index: number, + image: HTMLImageElement | ImageBitmap + ): void { + if (disposed || !gl) return + if (index < 0 || index >= maxInputs) { + throw new Error( + `Input index ${index} out of range (max ${maxInputs - 1})` + ) + } + + if (inputTextures[index]) { + gl.deleteTexture(inputTextures[index]) + inputTextures[index] = null + } + + const texture = gl.createTexture() + if (!texture) return + + gl.activeTexture(gl.TEXTURE0 + index) + gl.bindTexture(gl.TEXTURE_2D, texture) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) + + inputTextures[index] = texture + } + + function render(): void { + if (disposed || !program || !pingPongFBOs || !gl || !canvas) return + + gl.useProgram(program) + + const resLoc = uniformLocations.get('u_resolution') + if (resLoc != null) { + gl.uniform2f(resLoc, canvas.width, canvas.height) + } + + for (let i = 0; i < maxInputs; i++) { + const loc = uniformLocations.get(`u_image${i}`) + if (loc != null) { + gl.activeTexture(gl.TEXTURE0 + i) + gl.bindTexture(gl.TEXTURE_2D, inputTextures[i] ?? getFallbackTexture()) + gl.uniform1i(loc, i) + } + } + + const prevPassUnit = maxInputs + const prevPassLoc = uniformLocations.get('u_prevPass') + + for (let pass = 0; pass < passCount; pass++) { + const passLoc = uniformLocations.get('u_pass') + if (passLoc != null) gl.uniform1i(passLoc, pass) + + const isLastPass = pass === passCount - 1 + const writeIdx = pass % 2 + const readIdx = 1 - writeIdx + + if (isLastPass) { + gl.bindFramebuffer(gl.FRAMEBUFFER, null) + } else { + gl.bindFramebuffer(gl.FRAMEBUFFER, pingPongFBOs[writeIdx]) + } + + // Note: u_prevPass uses ping-pong FBOs rather than overwriting the input + // texture in-place as the backend does for single-input iteration. + if (pass > 0 && prevPassLoc != null) { + gl.activeTexture(gl.TEXTURE0 + prevPassUnit) + gl.bindTexture(gl.TEXTURE_2D, pingPongTextures![readIdx]) + gl.uniform1i(prevPassLoc, prevPassUnit) + } + + // Ping-pong FBOs have a single color attachment, so intermediate + // passes always target COLOR_ATTACHMENT0. MRT is only possible on + // the default framebuffer (last pass). + if (isLastPass) { + gl.drawBuffers([gl.BACK]) + } else { + gl.drawBuffers([gl.COLOR_ATTACHMENT0]) + } + + gl.drawArrays(gl.TRIANGLES, 0, 3) + } + } + + function readPixels(): ImageData { + if (!gl || !canvas) throw new Error('Renderer not initialized') + const w = canvas.width + const h = canvas.height + const pixels = new Uint8ClampedArray(w * h * 4) + + gl.pixelStorei(gl.PACK_ROW_LENGTH, 0) + gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels) + + return new ImageData(pixels, w, h) + } + + async function toBlob(): Promise { + if (!canvas) throw new Error('Renderer not initialized') + return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.92 }) + } + + function dispose(): void { + if (disposed) return + disposed = true + if (!gl) return + + for (const tex of inputTextures) { + if (tex) gl.deleteTexture(tex) + } + inputTextures.fill(null) + + if (fallbackTexture) { + gl.deleteTexture(fallbackTexture) + fallbackTexture = null + } + + destroyPingPongFBOs() + + if (fragmentShader) { + gl.deleteShader(fragmentShader) + fragmentShader = null + } + if (vertexShader) { + gl.deleteShader(vertexShader) + vertexShader = null + } + + if (program) { + gl.deleteProgram(program) + program = null + } + + uniformLocations.clear() + + const ext = gl.getExtension('WEBGL_lose_context') + ext?.loseContext() + } + + onScopeDispose(dispose) + + return { + init, + compileFragment, + setResolution, + setFloatUniform, + setIntUniform, + bindInputImage, + render, + readPixels, + toBlob, + dispose + } +}