live-preview.ts
PURPOSE
BackgroundLivePreview is the interactive renderer for BackgroundsViewerScreen. It drives the same LayerCompositor used for PNG bakes, but instead of readPixels + CPU stitch it runs an additional “tile-and-scroll” display pass to the compositor canvas’s default framebuffer. The display pass samples the compositor’s REPEAT-wrapped result texture, so a single tile fills the entire screen seamlessly via the GPU’s bilinear filter — zero CPU readback per frame. The compositor canvas resizes to display size; FBOs stay at tile size.
OWNS
compositor: LayerCompositor— constructed withwrap: 'repeat'andresolveTemplate: getTileTemplate, sized toopts.tileSizesquare.gl: WebGL2RenderingContext— pulled from the compositor (compositor.getGl()).displayProgram: WebGLProgram— the tile-and-scroll fullscreen pass program (compiled in the constructor fromTILE_DISPLAY_VS+TILE_DISPLAY_FS).displayUniforms— cached locations foru_tile,u_tileScale,u_scroll,u_seamDebug.vao: WebGLVertexArrayObject,vbo: WebGLBuffer— fullscreenQUADFloat32Array[-1,-1, 1,-1, -1,1, 1,1]uploadedSTATIC_DRAW.frame: number— animation step counter, drivestNormandscroll.
READS FROM
../vfx-workbench/compositor—LayerCompositorclass (its result texture is the input to the display pass)../shader-templates/registry—getTileTemplate, passed into the compositor asresolveTemplate../background-schema—BackgroundDeftype (input torender()).def.bake.frameCount— clamped viaMath.max(1, ...)to computetNorm.def.layers,def.palette— forwarded tocompositor.renderStack.
PUSHES TO
- The compositor canvas’s default framebuffer (binds
null, clears black, runs fullscreenTRIANGLE_STRIPwith 4 verts). - The compositor’s tile FBO — indirectly, via
compositor.renderStack(def.layers, tNorm, def.palette). getCanvas()returnscompositor.canvas(HTMLCanvasElement | OffscreenCanvas) for the host screen to mount in the DOM.
DOES NOT
- Does not call
readPixelsor stitch PNG output — that path lives in the bake side. - Does not own its own GL context — it reuses the compositor’s
gl. - Does not advance time when
staticMode = true—tNormis pinned to 0 so the user iterates on static tile quality first (motion returns in a later phase). - Does not resize FBOs on
resize(canvasW, canvasH)— only the canvas/display surface resizes; the tile FBOs stay attileSize. - Does not enable blending —
gl.disable(gl.BLEND)is set per draw. - Does not depend on canvas resolution for tiling —
tilesAcrossis independent of canvas pixel size.
Signals
Public mutable state the caller (the screen) flips between frames:
| Field | Default | Meaning |
|---|---|---|
parallax | 0 | Multiplier on scrollSpeed; 0 freezes scroll. |
seamDebug | false | When true, sets u_seamDebug = 1, drawing red borders at every tile cell edge (bw = 0.004 in UV space) to make wrap discontinuities pop. |
scrollSpeed | 0.0008 | Tiles-per-frame at parallax = 1. Gameplay layer overrides. |
tileSize | from opts | FBO size in pixels (square). |
tilesAcross | 2 | How many tile-copies wide to render on screen. 2 = 2×2 grid so both horizontal and vertical seams are inside the viewport. Aspect-corrected so tiles stay square (tileScaleY = tilesAcross * (ch / cw)). |
staticMode | true | Freezes u_time = 0 (via tNorm = 0) so the bake renders a single static frame regardless of def.bake.frameCount. |
Entry points
constructor({ tileSize })— builds the compositor (wrap: 'repeat'), pullsgl, compiles + links the display program (throws on compile/link failure with the info log), uploads the fullscreen quad VBO, caches uniform locations.getCanvas(): HTMLCanvasElement | OffscreenCanvas— returns the compositor canvas to mount in the DOM.resize(canvasW, canvasH): void— forwards tocompositor.resizeCanvas(canvasW, canvasH). FBO size unchanged.render(def: BackgroundDef): void— one animation step; call from a RAF loop. Composites the layer stack into the tile FBO, then runs the tile-and-scroll display pass. Incrementsframeat the end.destroy(): void— deletes program, VBO, VAO, thencompositor.destroy().
Pattern notes
- Y-flip in the display FS. GPU
readPixelsoutput is bottom-up; the compositor’s FBO texture is therefore upside-down vs. canvas draw orientation. The display shader flips Y inline viavec2 uv = vec2(v_uv.x, 1.0 - v_uv.y) * u_tileScale + u_scroll;so the live preview matches what the PNG bake will write. - REPEAT wrap on the result texture is configured by
LayerCompositorat FBO creation; the display pass relies on it for seamless tiling — UVs> 1.0wrap automatically, andtexture()with bilinear sampling hides the seam. - Scroll asymmetry —
u_scroll = (scroll, scroll * 0.4)deliberately scrolls X faster than Y to feel like parallax motion rather than a straight vertical/diagonal drift. tilesAcross = 2is a tooling choice, not a render budget: it ensures the user always sees an interior seam in the viewport so wrap discontinuities are caught immediately, instead of being hidden off-screen.- Display VS is trivial:
v_uv = a_pos * 0.5 + 0.5; gl_Position = vec4(a_pos, 0, 1);— clip-space quad, UV in[0,1]. - Failure mode — the constructor throws on shader compile or program link failure, surfacing the GL info log. There’s no silent fallback (matches “crash on bad data” rule).
- No animation when
staticModeis on —framestill increments andscrollstill advances (soparallaxmotion is visible), buttNormstays pinned to0, which keeps the layer composite identical frame-to-frame. To see baked animation, the caller must flipstaticMode = false. - Per-frame attribute binding —
gl.getAttribLocation+vertexAttribPointerrun everyrender()call instead of being baked into the VAO. Harmless but redundant; an extract candidate.
EXTRACT-CANDIDATE
TILE_DISPLAY_VS,TILE_DISPLAY_FS, and theQUADconstant are a generic “tile-and-scroll fullscreen pass” — pull into a sharedtile-display-pass.tsif the vfx-workbench or another preview surface needs the same display path.- The shader compile/link helper inlined in the constructor (compile both stages, attach, link, check
LINK_STATUS, throw with info log, delete shaders) duplicates logic likely present invfx-workbench/compositorand other GL call sites — extract to a sharedgl/program.tscompileProgram(gl, vsSrc, fsSrc, name)once it’s repeated a third time. - The fullscreen quad VAO/VBO setup is also generic (same
QUADliteral pattern shows up wherever a clip-space pass is used) — candidate for a sharedgl/fullscreen-quad.tsfactory returning{ vao, vbo, draw(gl) }. - The per-frame
getAttribLocation+vertexAttribPointercalls insiderender()should move into VAO setup once at construction time (bind VAO, enable + pointa_pos, unbind) sorender()just rebinds the VAO. displayUniformscache pattern (one object literal ofgetUniformLocationcalls) is the same pattern every GL pass uses; would fold into acompileProgramhelper that takes a uniform-name list and returns{ program, uniforms }.