compositor.ts — Layer-stack FBO ping-pong compositor

PURPOSE

WebGL2 layer-stack compositor for Schema v2 baked VFX/background components. Renders each layer’s fragment shader into a scratch FBO, then composes it onto a running accumulator using a per-layer blend mode + opacity. Workbench-only — runtime never sees this; output is consumed via readPixels() (bake to bytes) or getResultTexture() (sample as texture).

OWNS

  • A WebGL2RenderingContext over an HTMLCanvasElement (when document is present) or OffscreenCanvas (worker contexts).
  • Three FBOs at (width, height): accA, accB (ping-pong accumulators), scratch (single-layer output).
  • curr / next pointers that alias either accA or accB and swap after every layer.
  • A fullscreen-quad VAO + VBO (QUAD_VERTICES = [-1,-1, 1,-1, -1,1, 1,1], TRIANGLE_STRIP).
  • composeProgram (built from QUAD_VS + composePassFrag) + cached uniform locations u_dst, u_src, u_mode, u_opacity.
  • programCache: Map<string, WebGLProgram> — one compiled program per shader-template id.
  • wrapModegl.CLAMP_TO_EDGE (default) or gl.REPEAT (when opts.wrap === 'repeat').
  • Optional resolveTemplate callback overriding the default TEMPLATES[id] lookup.

READS FROM

  • ./shader-templates/registryTEMPLATES, TemplateId (default template lookup).
  • ./component-schemaLayer, LayerBlendMode, PostEffect types.
  • ./compose-pass.frag — imported fragment GLSL source for the compose program.
  • layer.shader (id), layer.params, layer.blend, layer.opacity, layer.enabled, layer.palette, layer.animation.{phase,freq} per layer.
  • globalPalette arg (fallback when layer.palette is missing).

PUSHES TO

  • Its own FBO color attachments (RGBA8 textures).
  • this.curr.texture — the final composite, exposed via getResultTexture().
  • A Uint8Array(width*height*4) returned by readPixels() (RGBA8 byte readback).
  • The backing canvas drawing-buffer when resizeCanvas() is called (display-surface use case).

DOES NOT

  • Run at game runtime — workbench-only abstraction; nothing in src/starship-survivors/engine/runtime imports it.
  • Use GL blending (gl.disable(gl.BLEND) is called in both passes). All compositing math is handled inside composePassFrag against u_dst + u_src textures.
  • Wire up the post-effect chain. runPostChain() is a Phase-3 stub that returns early when postChain.length === 0 and otherwise does nothing (void t).
  • Generate mipmaps. FBO textures use LINEAR min/mag filters at level 0 only.
  • Reallocate FBOs on resizeCanvas() — FBO size is locked to constructor (width, height) and decoupled from canvas size on purpose.
  • Validate layer.params keys against template uniform names; unknown uniforms are silently skipped via getUniformLocation() returning null.

Signals

  • Throws "LayerCompositor: no canvas backend available" if neither document nor OffscreenCanvas is reachable.
  • Throws "WebGL2 unavailable for LayerCompositor" if getContext('webgl2', { premultipliedAlpha: true }) returns null.
  • Throws "Unknown shader template: <id>" from getLayerProgram() when neither resolveTemplate nor TEMPLATES resolves layer.shader.
  • Throws "FBO incomplete: 0x<status>" from createFbo() when checkFramebufferStatus !== FRAMEBUFFER_COMPLETE.
  • Throws "Compositor program link failed: <info>" / "Compositor shader compile failed: <info>" on GLSL build failures.

Entry points

  • new LayerCompositor(width, height, opts?) — constructs canvas, GL context, FBOs, compose program, VAO/VBO. opts.wrap: 'clamp' | 'repeat'. opts.resolveTemplate(id): optional template resolver returning { fragGlsl } | null | undefined.
  • renderStack(layers, t, globalPalette?) — main entry. Resets ping-pong (clears accA), iterates enabled layers, ends with composite in this.curr.
  • runPostChain(postChain, t) — stub; no-op for non-empty chains in Phase 2.
  • readPixels() — binds this.curr, returns RGBA8 Uint8Array.
  • getResultTexture() — returns this.curr.texture for sampling (used by backgrounds-workbench tile-and-scroll passes).
  • getGl() / getTileWidth() / getTileHeight() — accessors for callers that need to share the GL context or read the locked FBO dimensions.
  • resizeCanvas(canvasW, canvasH) — resizes the backing HTMLCanvasElement / OffscreenCanvas only; FBOs untouched, viewport restored on next bindFbo().
  • destroy() — deletes all FBOs (framebuffer + texture), VBO, VAO, compose program, and every cached layer program; clears the program cache.

Pattern notes

  • Ping-pong invariant. curr always holds the running composite at function boundaries. renderStack() starts by aliasing curr = accA (cleared to transparent black) and next = accB, then per-layer: scratch ← layer shader; next ← compose(curr, scratch); swap. The compose pass is non-blending — the shader produces the final blended pixel and writes it directly.
  • Blend mode integer encoding. LayerBlendMode strings are mapped to integers by BLEND_MODE_CODE and uploaded as u_mode (additive=0, alpha=1, multiply=2, screen=3, erase=4); branch logic lives in compose-pass.frag, not on the CPU.
  • Shared layer uniforms. Every layer program is fed u_time = (t + layer.animation.phase) * layer.animation.freq (defaults phase=0, freq=1), u_resolution = (width, height), u_intensity = 1.0 (hard-coded), and u_palette[4] from layer.palette ?? globalPalette falling back to DEFAULT_PALETTE (IQ cosine-palette coefficients 0.5,0.5,0.5 / 0.5,0.5,0.5 / 1,1,1 / 0,0.33,0.67). Missing uniforms (null location) are skipped.
  • Template-param uniforms. layer.params is walked with Object.entries; numbers go to uniform1f, arrays of length 2/3/4 dispatch to uniform{2,3,4}fv, arrays of any other length fall through to uniform3fv.
  • Program caching is keyed on layer.shader (template id string), not on resolved source — calling with resolveTemplate once and then without will return the same cached program.
  • premultipliedAlpha: true is set at context creation, so all composed textures are expected to be premultiplied.
  • FBO/canvas decoupling. width/height lock the FBO viewport; resizeCanvas() only changes the display surface size, which is what backgrounds-workbench uses when mounting the compositor canvas as a larger display target than its tile size.
  • Wrap mode is wired into FBO creation, not just sampling — createFbo reads this.wrapMode for TEXTURE_WRAP_S/T. 'repeat' enables seamless bilinear sampling at the [0,1] boundary on getResultTexture(); it has no effect on readPixels() output.
  • No gl.viewport call in renderStack() — it relies on bindFbo() calling gl.viewport(0, 0, width, height) on every bind.

EXTRACT-CANDIDATE

  • BLEND_MODE_CODE is the canonical string↔int mapping for layer blend modes. Any code that emits or consumes u_mode (compose-pass shader, debugging tools, other workbench passes) should import this constant rather than redeclaring the order.
  • DEFAULT_PALETTE (IQ cosine palette defaults) is duplicated wherever a 4-stop palette fallback is needed. Candidate for shader-templates/palette-defaults.ts if a second consumer appears.
  • The FBO ping-pong + scratch trio is a generic primitive — if a non-workbench surface ever needs the same pattern (e.g. runtime post-FX), extract Fbo + createFbo + bindFbo + the curr/next swap into a small gl-pingpong.ts helper. Today’s only consumer is this file.
  • QUAD_VS and QUAD_VERTICES are the standard fullscreen-quad pair — shared candidate with any other WebGL2 pass in the engine.