preview-surface.ts

PURPOSE

Self-contained WebGL2 surface that compiles a fragment shader (either a named template from the shader-template registry or raw GLSL) and renders it to a fullscreen quad. Used by the VFX workbench to preview shader templates with uniform controls and capture pixels for tests/thumbnails. Lifecycle: construct sized canvas, useTemplate or useRawFragment, setUniforms, render(t), optionally readPixels, then destroy.

OWNS

  • canvas: HTMLCanvasElement | OffscreenCanvas — backing surface, sized from constructor width/height.
  • gl: WebGL2RenderingContext — context acquired from canvas; throws WebGL2 unavailable if getContext('webgl2') returns null.
  • program: WebGLProgram | null — currently linked program; replaced on every useTemplate/useRawFragment call (old program deleted first).
  • currentTemplate: TemplateId | null — id of the active registry template, or null when running via useRawFragment.
  • uniformLocs: Record<string, WebGLUniformLocation | null> — cached locations for shared uniforms (u_time, u_resolution, u_palette, u_intensity) plus template-declared uniforms.
  • vao: WebGLVertexArrayObject | null — single VAO covering the fullscreen quad attributes.
  • vbo: WebGLBuffer | null — vertex buffer holding QUAD_VERTICES (4 NDC corners as Float32Array).
  • pendingUniforms: Record<string, number | number[]> — staged uniform values; flushed to the program by flushUniforms.
  • destroyed: boolean — guard flag; methods (other than destroy itself and render) throw 'Surface destroyed' after teardown; render becomes a no-op.

READS FROM

  • ./shader-templates/registryTEMPLATES map and TemplateId type. useTemplate(id) looks up tpl.fragGlsl and tpl.uniformSchema (each entry has name and default).
  • ./component-schemaUniformDecl type used by useRawFragment’s uniformSchema argument.
  • globalThis.document / OffscreenCanvas — environment probing in the constructor selects a backend.

PUSHES TO

  • WebGL2 GPU state on its own context: shader compile, program link, attribute bind, uniform upload, drawArrays(TRIANGLE_STRIP, 0, 4).
  • Uint8Array returned from readPixels()width * height * 4 bytes, RGBA8, top-down GL convention.
  • Throws Error on: missing canvas backend, no WebGL2, unknown template id, shader compile failure (with formatted source context), program link failure, render before any shader compiled, calls after destroy.

DOES NOT

  • Does not own a render loop — caller supplies t and decides when to call render.
  • Does not handle resize — width/height are fixed at construction; gl.viewport is set once in the constructor and not re-applied per frame.
  • Does not manage textures, framebuffers, depth, or blending state beyond the implicit defaults (clears to transparent black, no depth test enabled).
  • Does not validate uniform types against the schema — setUniforms accepts any numbers or number arrays and flushUniforms dispatches by typeof/length heuristics.
  • Does not retain compiled shaders — vs/fs shader objects are deleted immediately after linkProgram.
  • Does not coalesce uniform updates — every setUniforms call triggers an immediate flushUniforms.
  • Does not deduplicate program deletion between useTemplate (which nulls the field) and useRawFragment (which deletes without nulling before reassign).
  • Does not export formatShaderCompileError — it’s a module-private helper.

Signals

  • 'Surface destroyed' — thrown by useTemplate, useRawFragment, setUniforms (indirectly via flushUniforms no-op), readPixels if called after destroy().
  • 'No shader compiled — call useTemplate or useRawFragment first' — thrown by render when program is null.
  • 'Unknown template: <id>' — thrown by useTemplate when TEMPLATES[id] is missing.
  • 'WebGL2 unavailable' — thrown by constructor when the context cannot be acquired.
  • 'PreviewSurface: no canvas backend available (need document or OffscreenCanvas)' — thrown when neither document nor OffscreenCanvas exist.
  • 'Shader compile failed (vertex|fragment)\n<info>\n--- source context around line N ---\n<annotated source>' — thrown by compileShader via formatShaderCompileError, with >> marker on the offending line and ±2 lines of context.
  • 'Program link failed' / 'Live shader program link failed' — thrown by useTemplate / useRawFragment respectively on LINK_STATUS === false.

Entry points

  • constructor(width: number, height: number) — picks OffscreenCanvas in worker-like environments, document.createElement('canvas') when a DOM exists; acquires WebGL2, sets viewport, creates VAO/VBO, uploads QUAD_VERTICES.
  • useTemplate(id: TemplateId): void — compile + link the named template’s fragment shader against QUAD_VS, cache shared + schema uniform locations, seed pendingUniforms from tpl.uniformSchema[i].default, flush.
  • useRawFragment(fragGlsl: string, uniformSchema: UniformDecl[]): void — same flow as useTemplate but with caller-provided GLSL and schema; clears currentTemplate to null; does not seed defaults from the schema (caller is responsible).
  • setUniforms(values: Record<string, number | number[]>): void — merges into pendingUniforms and immediately flushes.
  • render(t: number): void — uploads u_time = t, u_resolution = [width, height], u_intensity = 1.0, default u_palette (vec3[4] flattened to 12 floats) if caller hasn’t supplied one, clears to (0,0,0,0), draws the quad.
  • readPixels(): Uint8Array — RGBA8 readback, width * height * 4 bytes.
  • destroy(): void — deletes program, VBO, VAO; sets destroyed = true; idempotent.

Pattern notes

  • Shared uniform contract. Every shader is expected to accept (or ignore) u_time (float), u_resolution (vec2), u_palette (vec3[4] / 12 floats), u_intensity (float). These are always looked up; missing-uniform locations (null) are skipped silently in render and flushUniforms.
  • Default palette. When u_palette is declared but never set by the caller, render uploads the IQ-style cosine palette base [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 1, 1, 1, 0, 0.33, 0.67]. Despite the comment “vec3[4] flattened (12 elements)”, the code uploads it via uniform3fv rather than uniform3fv against an array — relies on driver accepting a 12-float payload for a vec3[4] location.
  • Uniform value dispatch in flushUniforms. Scalar numbers go to uniform1f; arrays dispatch by length: 2 → uniform2fv, 3 → uniform3fv, 4 → uniform4fv, anything else → uniform3fv (intended for the 12-float palette).
  • Shader compile error formatting. formatShaderCompileError parses driver logs like ERROR: 0:14: 'foo' : undeclared identifier, extracts the line number with regex /(?:^|[:\s])(?:0:)?(\d+):/m, and splices in lines[lineNum-3 .. lineNum+2] annotated with >> on the offending line. Improves error legibility when shaders are authored in templates or the live workbench.
  • VAO is bound twice. Constructor binds and unbinds the VAO. useTemplate/useRawFragment rebind it to set up the a_pos vertex attribute pointer; render rebinds it again before drawArrays. The VAO retains attribute state once vertexAttribPointer is called, so subsequent renders don’t re-issue pointer setup.
  • useRawFragment doesn’t seed defaults. Unlike useTemplate, raw-fragment mode leaves pendingUniforms untouched — caller must setUniforms before render or accept whatever the previous program’s stale state was. (Note: pendingUniforms is not cleared between template switches either, so values can carry across template changes if names collide.)
  • Canvas-backend probing. The constructor prefers OffscreenCanvas only when document is undefined; in a real browser the DOM path always wins even if OffscreenCanvas is available. Tests in jsdom hit the DOM path.
  • No texture support. This file does not bind, allocate, or sample any textures — templates that need samplers cannot run here without extension.

EXTRACT-CANDIDATE

  • formatShaderCompileError is generic — could move to a shared engine/vfx-workbench/shader-error.ts so non-preview compilers (e.g., production shader build pipeline, live-edit overlay) can reuse the line-annotated error format.
  • The fullscreen-quad scaffold (VAO + VBO + QUAD_VS + QUAD_VERTICES) is duplicated across any other WebGL2 surface in the project; if a second user appears, extract a FullscreenQuad helper.
  • The shared-uniform contract (u_time/u_resolution/u_palette/u_intensity) is implicit; consider hoisting the list and the default palette literal into the shader-templates registry so the preview surface and shipping VFX runtime stay in sync.
  • The uniform3fv-of-12-floats palette upload should be replaced with the explicit vec3[4] upload pattern (uniform3fv(loc, flatArray) does work in WebGL2 against array uniforms, but the default-length fallthrough in flushUniforms papers over the intent — name and document it).
  • setUniforms flushes synchronously per call; if a UI binds many sliders, a deferred-flush mode (set during the frame, flush once in render) would avoid redundant glUniform* calls.