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
WebGL2RenderingContextover anHTMLCanvasElement(whendocumentis present) orOffscreenCanvas(worker contexts). - Three FBOs at
(width, height):accA,accB(ping-pong accumulators),scratch(single-layer output). curr/nextpointers that alias eitheraccAoraccBand swap after every layer.- A fullscreen-quad VAO + VBO (
QUAD_VERTICES=[-1,-1, 1,-1, -1,1, 1,1], TRIANGLE_STRIP). composeProgram(built fromQUAD_VS+composePassFrag) + cached uniform locationsu_dst,u_src,u_mode,u_opacity.programCache: Map<string, WebGLProgram>— one compiled program per shader-template id.wrapMode—gl.CLAMP_TO_EDGE(default) orgl.REPEAT(whenopts.wrap === 'repeat').- Optional
resolveTemplatecallback overriding the defaultTEMPLATES[id]lookup.
READS FROM
./shader-templates/registry—TEMPLATES,TemplateId(default template lookup)../component-schema—Layer,LayerBlendMode,PostEffecttypes../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.globalPalettearg (fallback whenlayer.paletteis missing).
PUSHES TO
- Its own FBO color attachments (RGBA8 textures).
this.curr.texture— the final composite, exposed viagetResultTexture().- A
Uint8Array(width*height*4)returned byreadPixels()(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/runtimeimports it. - Use GL blending (
gl.disable(gl.BLEND)is called in both passes). All compositing math is handled insidecomposePassFragagainstu_dst+u_srctextures. - Wire up the post-effect chain.
runPostChain()is a Phase-3 stub that returns early whenpostChain.length === 0and otherwise does nothing (void t). - Generate mipmaps. FBO textures use
LINEARmin/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.paramskeys against template uniform names; unknown uniforms are silently skipped viagetUniformLocation()returning null.
Signals
- Throws
"LayerCompositor: no canvas backend available"if neitherdocumentnorOffscreenCanvasis reachable. - Throws
"WebGL2 unavailable for LayerCompositor"ifgetContext('webgl2', { premultipliedAlpha: true })returns null. - Throws
"Unknown shader template: <id>"fromgetLayerProgram()when neitherresolveTemplatenorTEMPLATESresolveslayer.shader. - Throws
"FBO incomplete: 0x<status>"fromcreateFbo()whencheckFramebufferStatus !== 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 (clearsaccA), iterates enabled layers, ends with composite inthis.curr.runPostChain(postChain, t)— stub; no-op for non-empty chains in Phase 2.readPixels()— bindsthis.curr, returns RGBA8Uint8Array.getResultTexture()— returnsthis.curr.texturefor 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 backingHTMLCanvasElement/OffscreenCanvasonly; FBOs untouched, viewport restored on nextbindFbo().destroy()— deletes all FBOs (framebuffer + texture), VBO, VAO, compose program, and every cached layer program; clears the program cache.
Pattern notes
- Ping-pong invariant.
curralways holds the running composite at function boundaries.renderStack()starts by aliasingcurr = accA(cleared to transparent black) andnext = 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.
LayerBlendModestrings are mapped to integers byBLEND_MODE_CODEand uploaded asu_mode(additive=0, alpha=1, multiply=2, screen=3, erase=4); branch logic lives incompose-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), andu_palette[4]fromlayer.palette ?? globalPalettefalling back toDEFAULT_PALETTE(IQ cosine-palette coefficients0.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.paramsis walked withObject.entries; numbers go touniform1f, arrays of length 2/3/4 dispatch touniform{2,3,4}fv, arrays of any other length fall through touniform3fv. - Program caching is keyed on
layer.shader(template id string), not on resolved source — calling withresolveTemplateonce and then without will return the same cached program. premultipliedAlpha: trueis set at context creation, so all composed textures are expected to be premultiplied.- FBO/canvas decoupling.
width/heightlock 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 —
createFboreadsthis.wrapModeforTEXTURE_WRAP_S/T.'repeat'enables seamless bilinear sampling at the [0,1] boundary ongetResultTexture(); it has no effect onreadPixels()output. - No
gl.viewportcall inrenderStack()— it relies onbindFbo()callinggl.viewport(0, 0, width, height)on every bind.
EXTRACT-CANDIDATE
BLEND_MODE_CODEis the canonical string↔int mapping for layer blend modes. Any code that emits or consumesu_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 forshader-templates/palette-defaults.tsif 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 smallgl-pingpong.tshelper. Today’s only consumer is this file. QUAD_VSandQUAD_VERTICESare the standard fullscreen-quad pair — shared candidate with any other WebGL2 pass in the engine.