feat: GLSLPreviewEngine + GLSL utility functions (#9200)

## 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)
This commit is contained in:
Christian Byrne
2026-03-05 03:04:33 -08:00
committed by GitHub
parent 5376b7ed1e
commit c817563cf0
4 changed files with 539 additions and 0 deletions

View File

@@ -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

View File

@@ -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)
})
})

View File

@@ -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)
}

View File

@@ -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<null>({
length: maxInputs
}).fill(null)
const uniformLocations = new Map<string, WebGLUniformLocation | null>()
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<Blob> {
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
}
}