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 with wrap: 'repeat' and resolveTemplate: getTileTemplate, sized to opts.tileSize square.
  • gl: WebGL2RenderingContext — pulled from the compositor (compositor.getGl()).
  • displayProgram: WebGLProgram — the tile-and-scroll fullscreen pass program (compiled in the constructor from TILE_DISPLAY_VS + TILE_DISPLAY_FS).
  • displayUniforms — cached locations for u_tile, u_tileScale, u_scroll, u_seamDebug.
  • vao: WebGLVertexArrayObject, vbo: WebGLBuffer — fullscreen QUAD Float32Array [-1,-1, 1,-1, -1,1, 1,1] uploaded STATIC_DRAW.
  • frame: number — animation step counter, drives tNorm and scroll.

READS FROM

  • ../vfx-workbench/compositorLayerCompositor class (its result texture is the input to the display pass).
  • ./shader-templates/registrygetTileTemplate, passed into the compositor as resolveTemplate.
  • ./background-schemaBackgroundDef type (input to render()).
  • def.bake.frameCount — clamped via Math.max(1, ...) to compute tNorm.
  • def.layers, def.palette — forwarded to compositor.renderStack.

PUSHES TO

  • The compositor canvas’s default framebuffer (binds null, clears black, runs fullscreen TRIANGLE_STRIP with 4 verts).
  • The compositor’s tile FBO — indirectly, via compositor.renderStack(def.layers, tNorm, def.palette).
  • getCanvas() returns compositor.canvas (HTMLCanvasElement | OffscreenCanvas) for the host screen to mount in the DOM.

DOES NOT

  • Does not call readPixels or 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 = truetNorm is 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 at tileSize.
  • Does not enable blending — gl.disable(gl.BLEND) is set per draw.
  • Does not depend on canvas resolution for tiling — tilesAcross is independent of canvas pixel size.

Signals

Public mutable state the caller (the screen) flips between frames:

FieldDefaultMeaning
parallax0Multiplier on scrollSpeed; 0 freezes scroll.
seamDebugfalseWhen true, sets u_seamDebug = 1, drawing red borders at every tile cell edge (bw = 0.004 in UV space) to make wrap discontinuities pop.
scrollSpeed0.0008Tiles-per-frame at parallax = 1. Gameplay layer overrides.
tileSizefrom optsFBO size in pixels (square).
tilesAcross2How 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)).
staticModetrueFreezes 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'), pulls gl, 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 to compositor.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. Increments frame at the end.
  • destroy(): void — deletes program, VBO, VAO, then compositor.destroy().

Pattern notes

  • Y-flip in the display FS. GPU readPixels output is bottom-up; the compositor’s FBO texture is therefore upside-down vs. canvas draw orientation. The display shader flips Y inline via vec2 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 LayerCompositor at FBO creation; the display pass relies on it for seamless tiling — UVs > 1.0 wrap automatically, and texture() with bilinear sampling hides the seam.
  • Scroll asymmetryu_scroll = (scroll, scroll * 0.4) deliberately scrolls X faster than Y to feel like parallax motion rather than a straight vertical/diagonal drift.
  • tilesAcross = 2 is 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 staticMode is onframe still increments and scroll still advances (so parallax motion is visible), but tNorm stays pinned to 0, which keeps the layer composite identical frame-to-frame. To see baked animation, the caller must flip staticMode = false.
  • Per-frame attribute bindinggl.getAttribLocation + vertexAttribPointer run every render() call instead of being baked into the VAO. Harmless but redundant; an extract candidate.

EXTRACT-CANDIDATE

  • TILE_DISPLAY_VS, TILE_DISPLAY_FS, and the QUAD constant are a generic “tile-and-scroll fullscreen pass” — pull into a shared tile-display-pass.ts if 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 in vfx-workbench/compositor and other GL call sites — extract to a shared gl/program.ts compileProgram(gl, vsSrc, fsSrc, name) once it’s repeated a third time.
  • The fullscreen quad VAO/VBO setup is also generic (same QUAD literal pattern shows up wherever a clip-space pass is used) — candidate for a shared gl/fullscreen-quad.ts factory returning { vao, vbo, draw(gl) }.
  • The per-frame getAttribLocation + vertexAttribPointer calls inside render() should move into VAO setup once at construction time (bind VAO, enable + point a_pos, unbind) so render() just rebinds the VAO.
  • displayUniforms cache pattern (one object literal of getUniformLocation calls) is the same pattern every GL pass uses; would fold into a compileProgram helper that takes a uniform-name list and returns { program, uniforms }.