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
BackgroundDefschema 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.jsonvia Viteimport.meta.globand the bulkv2-catalog.jsonarray — validated, sorted by id, exposed asALL_BACKGROUNDS/BACKGROUND_MAP/getBackground. - The multi-resolution flipbook baker (
bake-background.ts): per-sizeLayerCompositorlifecycle, frame loop with closed-looptNorm = 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 (toBlobwithtoDataURL+base64 fallback). - The interactive renderer (
live-preview.ts, classBackgroundLivePreview): 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— theLayerCompositorclass (FBO stack + layer dispatch). Backgrounds construct it withwrap: 'repeat'so accumulator FBOs sample seamlessly across edges, and passresolveTemplate: getTileTemplateso layer shader ids resolve into this side’s registry instead of the VFX one.../vfx-workbench/component-schema— the sharedLayerandLayerBlendModetypes. The background layer stack reuses the same shape as VFX components.../../data/backgrounds/*.background.json— per-fileBackgroundDefJSON, eager-globbed at build time.../../data/backgrounds/v2-catalog.json— bulk array ofBackgroundDeffor the ~100-entry v2 wave so each background doesn’t need its own file.- Per-template GLSL
.fragmodules undershader-templates/— each a default-exported#version 300 esfragment string with the toroidal-tile + loop-closure contract.
PUSHES TO
BackgroundsViewerScreen(src/metagame/screens/BackgroundsViewerScreen.tsx, route/backgrounds-viewer) — the sole consumer. Mounts aBackgroundLivePreviewfor the viewport, drivesparallax/seamDebug/staticMode/tilesAcrossfrom UI controls, walksALL_BACKGROUNDSby index (URL?n=…), and on bake invokesbakeBackgroundAllSizesand 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 byvalidateBackgroundDef.v2-catalog.json— bulk authoring input, array ofBackgroundDef, validated entry-by-entry withv2-catalog[<i>] (<id-or-?>) invalid: <reason>errors.<id>-<size>.png— baked flipbook output, one per size indef.bake.sizes([512, 1024, 2048]allowed), each agridW x gridHgrid offrameCountcells.
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.shaderreferences 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.
bakeBackgroundAllSizesawaits each size sequentially to avoid concurrent GL contexts. - Does not animate in
staticMode. The preview pinstNorm = 0so the user iterates on static tile quality first; scroll still moves soparallaxcan be verified independently of frame motion. - Does not load lazily. Every
BackgroundDefis 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_BACKGROUNDSand the later one wins inBACKGROUND_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 itsLayerCompositorandLayerschema 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>.pngoutside 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.getBackgroundthrows 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 theBackgroundBakeSizeliteral 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 fieldsparallax/seamDebug/scrollSpeed/tileSize/tilesAcross/staticMode.bakeBackgroundOneSize(def, size): Promise<BackgroundBakeResult>— single-resolution bake.bakeBackgroundAllSizes(def): Promise<BackgroundBakeResult[]>— full multi-resolution bake; iteratesdef.bake.sizessequentially.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 = 1equals frame atu_time = 0). The schema and registry don’t enforce it; the GLSL must honor it. TheLayerCompositoris constructed withwrap: 'repeat'so accumulator FBO sampling respects the spatial side automatically. - Frame timing uses closed-loop
tNorm = i / frameCount, not the open-loopi / (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 tilesAcrossof the viewport via clip-space quad + REPEAT wrap + GPU bilinear filter. Bake uses the same compositor but withreadPixels+ 2D-canvas blit to assemble the flipbook grid. tilesAcross = 2is 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.seamDebugoverlays red borders at every tile cell edge (bw = 0.004in UV space) for fast seam verification before bake.- Layer schema is duplicated between this side and VFX (same
Layerstruct, blend modes, opacity bounds). ThevalidateLayerhelper insidebackground-schema.tsinlines 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.jsonpath keeps legacy entries inspectable, whilev2-catalog.jsonships ~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 ifreadPixelsor 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.