engine/backgrounds-workbench

PURPOSE — Authoring pipeline for tile-able procedural background atlases. The workbench takes a BackgroundDef (a layer stack over a tile fragment-shader registry), composites it into a REPEAT-wrapped FBO via the shared LayerCompositor, drives a live tile-and-scroll preview pass for in-browser iteration, and bakes the same composite into multi-resolution flipbook PNGs (frame grid per size) that ship to public/backgrounds/ for runtime sampling. Distinct from engine/vfx-workbench: this side authors infinitely tile-able backgrounds (one tile fills the screen, seam wrap is a contract), while VFX authors transient weapon-effect sprite components.

OWNS

  • The BackgroundDef schema and boundary validator (background-schema.ts): id, name, category, tileMode, layer stack, 4-stop cosine palette, bake config (sizes, frameCount, fps, seed). Crash-on-bad-data at the gate, no coerced defaults.
  • The tile fragment-shader template registry (shader-templates/registry.ts): 15 templates total — tile_fbm_warp, tile_ridged_streaks, tile_sparkle, tile_gradient, tile_solid, tile_voronoi, tile_lines, tile_hex, tile_radial_burst, tile_dots, tile_curl_flow, tile_cracks, tile_threshold_blobs, tile_polar_swirl, tile_kaleidoscope. Plus the shared GLSL primitives library (tile-noise.glsl.ts / TILE_NOISE_LIB) the baker prepends before compile.
  • Module-load catalog loader (background-loader.ts): two sources — per-file *.background.json via Vite import.meta.glob and the bulk v2-catalog.json array — validated, sorted by id, exposed as ALL_BACKGROUNDS / BACKGROUND_MAP / getBackground.
  • The multi-resolution flipbook baker (bake-background.ts): per-size LayerCompositor lifecycle, frame loop with closed-loop tNorm = i / frameCount, grid math (cols = ceil(sqrt(N)), rows = ceil(N/cols)), Y-flip blit from WebGL bottom-up to canvas top-down, PNG encoding (toBlob with toDataURL+base64 fallback).
  • The interactive renderer (live-preview.ts, class BackgroundLivePreview): owns the compositor, the tile-and-scroll display program (TILE_DISPLAY_VS + TILE_DISPLAY_FS), fullscreen quad VAO/VBO, animation frame counter, plus public mutable controls (parallax, seamDebug, scrollSpeed, tilesAcross, staticMode).
  • Exported tuning constants: BACKGROUND_BAKE_SIZES = [512, 1024, 2048], FRAME_MIN = 1, FRAME_MAX = 128, PALETTE_STOP_COUNT = 4, PALETTE_VEC3_LEN = 3.

READS FROM

  • ../vfx-workbench/compositor — the LayerCompositor class (FBO stack + layer dispatch). Backgrounds construct it with wrap: 'repeat' so accumulator FBOs sample seamlessly across edges, and pass resolveTemplate: getTileTemplate so layer shader ids resolve into this side’s registry instead of the VFX one.
  • ../vfx-workbench/component-schema — the shared Layer and LayerBlendMode types. The background layer stack reuses the same shape as VFX components.
  • ../../data/backgrounds/*.background.json — per-file BackgroundDef JSON, eager-globbed at build time.
  • ../../data/backgrounds/v2-catalog.json — bulk array of BackgroundDef for the ~100-entry v2 wave so each background doesn’t need its own file.
  • Per-template GLSL .frag modules under shader-templates/ — each a default-exported #version 300 es fragment string with the toroidal-tile + loop-closure contract.

PUSHES TO

  • BackgroundsViewerScreen (src/metagame/screens/BackgroundsViewerScreen.tsx, route /backgrounds-viewer) — the sole consumer. Mounts a BackgroundLivePreview for the viewport, drives parallax / seamDebug / staticMode / tilesAcross from UI controls, walks ALL_BACKGROUNDS by index (URL ?n=…), and on bake invokes bakeBackgroundAllSizes and triggers per-size browser downloads named <id>-<size>.png.
  • Authored PNG files dropped into public/backgrounds/ after bake — the runtime artifact the game samples from. (The baker itself returns bytes; the screen wires the download.)
  • The compositor canvas’s default framebuffer during live preview (tile-and-scroll display pass) — no CPU readback per frame, GPU bilinear filter hides the seam.

File types produced

  • *.background.json — single-def authoring input (per-background file), validated by validateBackgroundDef.
  • v2-catalog.json — bulk authoring input, array of BackgroundDef, validated entry-by-entry with v2-catalog[<i>] (<id-or-?>) invalid: <reason> errors.
  • <id>-<size>.png — baked flipbook output, one per size in def.bake.sizes ([512, 1024, 2048] allowed), each a gridW x gridH grid of frameCount cells.

DOES NOT

  • Does not implement tileMode: 'wang'. The validator accepts the literal but ships 'seamless' only; v1 backgrounds are toroidal.
  • Does not enforce that Layer.shader references a tile template (vs a VFX template) — the schema is permissive and the template registry is the gate. A typo’d id throws at bake.
  • Does not write PNGs to disk or POST to /__dev/atlas-write. The baker returns bytes; the consumer (the screen) decides how to persist.
  • Does not parallelize across sizes. bakeBackgroundAllSizes awaits each size sequentially to avoid concurrent GL contexts.
  • Does not animate in staticMode. The preview pins tNorm = 0 so the user iterates on static tile quality first; scroll still moves so parallax can be verified independently of frame motion.
  • Does not load lazily. Every BackgroundDef is parsed + validated at module load — a bad JSON throws the moment the workbench imports.
  • Does not deduplicate ids across the two catalog sources. Per-file vs catalog id collisions leave both entries in ALL_BACKGROUNDS and the later one wins in BACKGROUND_MAP.
  • Does not author transient weapon effects, spawn particles, or write to the runtime VFX pool. That is engine/vfx-workbench’s job; this side only shares its LayerCompositor and Layer schema shape.
  • Does not validate uniforms, palette length inside templates, or layer-param contents. The schema gates struct shape; the GLSL contract (toroidal wrap + loop closure) is doc-only.
  • Does not own runtime sampling of the baked PNGs. The atlas consumer (the runtime background renderer) reads public/backgrounds/<id>-<size>.png outside this module.

Signals fired / Signals watched — none. The workbench is an authoring-time module: no engine signals, no event bus. All cross-system contact is by direct call (consumer screen → loader / preview / baker; baker / preview → compositor; registry → template GLSL imports).

Entry points

  • ALL_BACKGROUNDS / BACKGROUND_MAP / getBackground(id) — catalog access. getBackground throws with the full available-id list on miss.
  • validateBackgroundDef(def: unknown): ValidationResult — boundary validator. Discriminated union {ok: true} | {ok: false, reason}; first failure short-circuits with a field-path message.
  • BACKGROUND_BAKE_SIZES — runtime allowlist + source of the BackgroundBakeSize literal type.
  • getTileTemplate(id) / TILE_TEMPLATES / TileTemplateId — shader resolver, registry map (for thumbnail-strip tooling), and the literal-union id type for authoring.
  • class BackgroundLivePreview — constructor ({ tileSize }), getCanvas(), resize(canvasW, canvasH), render(def) per RAF tick, destroy(). Mutable fields parallax / seamDebug / scrollSpeed / tileSize / tilesAcross / staticMode.
  • bakeBackgroundOneSize(def, size): Promise<BackgroundBakeResult> — single-resolution bake.
  • bakeBackgroundAllSizes(def): Promise<BackgroundBakeResult[]> — full multi-resolution bake; iterates def.bake.sizes sequentially.
  • BackgroundBakeResult{ size, pngBytes, gridW, gridH, cols, rows, frameCount, fps } returned to the caller.

Pattern notes

  • Tileability is a two-axis contract: spatial (uv ∈ [0,1] wraps with no derivative discontinuity at seams) and temporal (frame at u_time = 1 equals frame at u_time = 0). The schema and registry don’t enforce it; the GLSL must honor it. The LayerCompositor is constructed with wrap: 'repeat' so accumulator FBO sampling respects the spatial side automatically.
  • Frame timing uses closed-loop tNorm = i / frameCount, not the open-loop i / (N-1). Frame N would equal frame 0, so playback wraps cleanly.
  • Live preview avoids CPU readback. The compositor renders into its tile FBO, then the display pass samples that texture across tilesAcross x tilesAcross of the viewport via clip-space quad + REPEAT wrap + GPU bilinear filter. Bake uses the same compositor but with readPixels + 2D-canvas blit to assemble the flipbook grid.
  • tilesAcross = 2 is the default for a tooling reason, not a budget: the user always sees an interior seam in the viewport so wrap discontinuities surface immediately rather than hiding off-screen.
  • seamDebug overlays red borders at every tile cell edge (bw = 0.004 in UV space) for fast seam verification before bake.
  • Layer schema is duplicated between this side and VFX (same Layer struct, blend modes, opacity bounds). The validateLayer helper inside background-schema.ts inlines the same checks rather than importing the VFX validator — known extract candidate, would consolidate when a new blend mode lands.
  • The catalog is two-source intentionally: the per-file *.background.json path keeps legacy entries inspectable, while v2-catalog.json ships ~100 entries as one array so new backgrounds don’t require minting one file each.
  • Bake is sequential per size with try/finally on LayerCompositor.destroy(). Each size gets its own compositor lifecycle; GL resources never leak even if readPixels or PNG encoding throws.
  • Crash-on-bad-data at every authoring boundary: validator throws on bad def, loader throws at module init on bad JSON, registry throws on unknown shader id with the full available-id list in the message. Matches the project rule that internal config crashes loudly while only IO boundaries swallow.
  • The bake output is large by design (default 1024). A single tile fills the screen at runtime via the same REPEAT-wrap convention, so the resolution-per-tile matters more than tiling many small cells.