engine/vfx-workbench

PURPOSE — Workbench-only WebGL2 VFX authoring stack. The off-line authoring tool that turns weapon-VFX design intent into ready-to-ship runtime artifacts: per-component baked PNG flipbook atlases, per-component live-shader GLSL files, and per-weapon kit graphs that describe how components attach to one another. Runs in the browser inside the dev pane (/dev/vfx-workbench) and the per-component editor — never on the player’s device during a real mission. Produces atlas PNGs + manifest JSON + handoff markdown that the production rendering layer (engine/rendering/sprite-batch.ts, engine/rendering/vfx-component-runtime.ts, engine/rendering/live-shader-runtime.ts) consumes at boot. Two organizing concepts: Components (one baked atlas or one live shader, with anchors) and Kits (a graph of components per weapon with relationships and a preview motion). Two parallel shader-template registries: baked templates (composited into PNG atlases via the layer-stack compositor) and live templates (shipped as GLSL strings to runtime). Distinct from production rendering: this system bakes; runtime samples.

OWNS

  • Component schema — ComponentDef (id / name / category / kind / anchors plus a BakedSection and/or LiveSection), the five ComponentCategory values (muzzle / body / impact / persistent / connection), the two ComponentKind values (baked / live_shader), BakedSection.schemaVersion 2 layer-stack vs legacy single-shader fields, LiveSection.fragGlsl + uniformSchema + previewSampleInputs + blendMode, Layer shape with LayerKind (shape / fill / mask / stroke / glow / post) + LayerBlendMode (additive / alpha / multiply / screen / erase) + LayerTransform + LayerAnimation, MaskSpec with four sources (sdf / layerAlpha / noise / register), PostEffect chain with ten PostEffectKind values (bloom / chromatic / four warp variants / grain / vignette / barrel), UniformDecl shape with handle annotations for the editor (point / radius / angle), validators (validateComponentDef, validateLayer, validateBakeSettings, validatePalette), and the migrateBakedToV2 legacy-shim that wraps a pre-v2 single-shader baked component as a one-layer stack at load time. Bake bounds enforced at validation: 1–128 frames, tile sizes ∈ {16, 32, 64, 128, 256, 512}, palette = exactly 4 vec3 stops.
  • Kit schema — KitDef (id / name / forWeaponId / entries / relationships / previewMotion / notes), five KitEntryRole values (spawn / body / connection / persistent / impact), two KitInstanceCount values (fixed / runtime) for how many copies the kit spawns vs how many the runtime decides at fire time, five RelationshipKind values (attachPivot / spanBetween / orbitAround / trailBehind / stackOnto) for parent-child anchor wiring, PreviewMotionRef (template id + params) used only in the shooting-range preview — never shipped, and validateKitDef (rejects duplicate entry ids, missing parent/child refs, unknown roles / kinds).
  • Anchor system — AnchorMap (named-anchor table on every component), AnchorSpec with three AnchorKind values (pivot / edge / radial) and two AnchorDirection values (forward / backward), the [a-z][a-z0-9_]{0,31} name regex, the reserved center anchor (cannot be removed or renamed), and the immutable-update helpers (addAnchor / removeAnchor / renameAnchor / updateAnchor).
  • Atlas format — AtlasCategory (the four bake-able categories, connection is live-only), AtlasManifest shape (category / format png-grid | ktx2-astc | ktx2-etc2 / atlasDir / layers), AtlasLayer shape (componentId / layerIndex / frame grid params / loopMode / pivot / anchors / palette / shaderTemplate / shaderParams / bakedAt / atlasPath), and the BakeResult shape (pngBytes + dims + manifestEntry).
  • Baked shader-templates registry — TEMPLATES: Record<TemplateId, TemplateSpec> mapping ~60 baked template ids to their GLSL frag source + UniformEntry[] schema, grouped by family: 17 legacy baked (disc / ring / star / diamond / cross / teardrop / arc / bolt / streak / spiral / crescent / sparkle / chevron / plasma_ball / smoke_puff / fire_flame / beam), 7 shape SDFs (sdf_capsule / sdf_cone / sdf_pie / sdf_polygon_n / sdf_rose / sdf_heart / sdf_ellipse), 13 fills (solid / two gradients / fbm / domain_warped_fbm / worley / voronoi_cracks / fire_rising / smoke_rising / plasma_hue_wobble / nebula_2stop / hex_grid / mandala_n_fold), 4 masks (sdf_primitive_mask / noise_threshold_mask / scrolling_flow_mask / kaleidoscope_mask), 3 strokes (sdf_outline / pulse_outline / fuzzy_outline), 1 glow (inverse_distance_halo), 6 energy (fresnel_rim / electric_arc / shield_hex / portal_swirl / reactor_core / hologram_stack), 3 impacts (shockwave_ring / explosion_fireball / muzzle_starburst), 10 post passes (bloom / chromatic_aberration / 5 warp variants / grain / vignette / barrel). All baked templates share the same shared-uniform contract (u_time / u_resolution / u_palette[4] / u_intensity) so they are interchangeable inside a layer stack.
  • Live shader-templates registry — LIVE_TEMPLATES: Record<LiveTemplateId, LiveTemplateSpec> carrying three live-template starting points (lightning_beam / adaptive_beam / chain_arc_dynamic), each with its own GLSL frag, locally-declared UniformDecl (subset of the baked one), previewSampleInputs for the editor’s preview pane, and the same shared uniform contract (u_resolution / u_palette / u_intensity) augmented with per-template uniforms (endpoint vec2s for span-between, per-instance seeds, per-frame uTimeSec, beam widths).
  • LayerCompositor — workbench-only WebGL2 three-FBO ping-pong renderer (accA / accB accumulators + scratch for the current layer’s shader output). Per-layer cycle: render layer shader into scratch → bind next-acc → compose-pass blends currAcc with scratch under the layer’s blend mode + opacity → swap. Owns one shared compose-pass.frag GLSL string with five blend modes encoded by u_mode int (0 additive / 1 alpha-Porter-Duff-over / 2 multiply / 3 screen / 4 erase), a per-shader-template program cache, the wrap option (clamp / repeatrepeat is used by the backgrounds-workbench for tileable bakes), and the resolveTemplate injection point that lets the backgrounds-workbench substitute its own template registry without merging. Stub runPostChain reserved for Phase-3 post passes.
  • PreviewSurface — workbench-only WebGL2 single-fullscreen-quad renderer used as the legacy single-shader fast path inside bakeComponent and as the editor’s live preview surface. Owns one program per TemplateId (lazy-compiled, cached), useTemplate switches the active program, setUniforms dispatches uniform1i/uniform1f/uniform2fv/uniform3fv/uniform4fv/uniform3fv (palette) by JS type + length, render(tNorm) draws the fullscreen quad at a given normalized time, readPixels returns the bottom-up RGBA buffer, getCanvas/getDataUrl for the editor surface, and a multi-line shader-compile error formatter that annotates the offending source line.
  • bakeComponent(def): Promise<BakeResult> — the canonical single-resolution baker. Validates kind, runs migrateBakedToV2, picks the multi-layer (LayerCompositor) or single-shader (PreviewSurface) path by baked.layers.length > 1, walks frameCount frames at the correct tNorm for the loop mode (loop / parameterized use i / frameCount, oneshot uses i / (frameCount-1)), packs each frame into a cols × rows grid where cols = ceil(sqrt(frameCount)), encodes to PNG via canvasToPngBytes (prefers canvas.toBlob, falls back to chunked toDataURL-decode for jsdom), and returns a BakeResult with the legacy-compatible manifest entry. Strict order of document.createElement('canvas') calls is load-bearing — downstream tests mock by call index.
  • bakeComponentMultiRes(def, tileSizes) — multi-resolution wrapper that re-bakes the same component at each tileSize in DEFAULT_BAKE_RESOLUTIONS ([64, 128, 256]). Caller writes each PNG with a size-suffixed filename (_t64 / _t128 / _t256) so the runtime can pick by DPR × visual size.
  • frame-select — runtime-agnostic frame-index picker (pickFrameIndex({ loopMode, frameCount, fps, ageMs, param })). Three loop modes: oneshot clamps to frameCount-1, loop wraps modulo, parameterized floors param × frameCount. Used by both the workbench preview and (via copy-paste of the same arithmetic) the runtime sampler.
  • gif-export — GIF89a flipbook exporter for Discord/doc previews. renderFramesForGif(def, opts) walks all frames of a baked component (via the same compositor / single-shader paths as bakeComponent) and returns RGBA frame buffers + delay; downstream wraps them in a gif89a writer (in-house ~200 LOC, intentionally no new npm dep). Y-flipped to image space at output time.
  • atlas-loader — runtime-side companion that reads a category’s <category>.manifest.json + the per-component PNG files (one HTTP fetch per component, all in parallel) and uploads them into a WebGL2 TEXTURE_2D_ARRAY via the runtime sprite batch’s createEmptyAtlas / uploadLayer hooks. Returns null on missing manifest (graceful boot when no atlas has been baked yet).
  • component-loader — Vite import.meta.glob index over data/vfx/components/*.component.json with eager-load. Exports ALL_COMPONENTS (sorted by id), COMPONENT_MAP (id → def), loadAllComponents / getComponent accessors. Crashes loudly on unknown id with the available-id list in the error.
  • SpriteBatchArray — workbench-only TEXTURE_2D_ARRAY sprite batcher used by the in-pane preview pass. Per-instance attributes carry iLayer (which atlas layer = which component) + iFrame (which tile in the grid) + iGrid (cols / rows for UV math) + tint; vertex shader resolves vUvLayer into the correct tile UV via mod(iFrame, iGrid.x) + floor(iFrame / iGrid.x). MAX_SPRITES = 4096, instance stride 14 floats. Does NOT replace engine/rendering/sprite-batch.ts — that is the production batcher with its own atlas and atlas-region map; this is the workbench’s own scratch.
  • paletteVec3 / Palette4 types, flattenPalette (returns the 12-number u_palette[4] payload), evalPalette(p, t) (Inigo Quilez cosine palette: a + b * cos(2π * (c*t + d))), hex ↔ vec3 conversion helpers, and the DEFAULT_PALETTE (grey-grey-white-RGB-phase, the workbench fallback).
  • compose-pass.frag — workbench-only GLSL string used by LayerCompositor. Premultiplied-alpha math at every blend mode. The u_mode int encoding must stay in sync with LayerBlendMode in component-schema.ts.
  • png-base64bytesToBase64 chunked String.fromCharCode + btoa helper. Replaces the naive spread-into-fromCharCode pattern that crashes on >~1 MB buffers due to V8’s argument-count limit. Used by every place that round-trips a PNG payload through a data:image/png;base64,... URL.
  • handoff-package — the cross-system contract generator. buildHandoffPackage(kit, componentsById) produces a HandoffPackage (markdownPath: docs/vfx-handoff/<weaponId>.md + markdown text + HandoffManifest). Markdown sections: per-component summary block with kind / category / role / instance count / atlas-or-live-shader path / anchor list / notes; prose relationships list with parent anchor + child anchor; preview-motion summary; runtime wiring checklist (init via initVfxComponents(), atlas reserve + patch via engine/rendering/atlas-builder, live-shader init via initLiveShaderRuntimes(), weapon-data kit reference, drawBullet archetype replacement); convenience atlas + live-shader path lists. HandoffManifest (machine-readable): weaponId / kitId / generatedAt / per-component entries with atlasPath (src/starship-survivors/data/vfx/atlas/<category>/<id>.png) or liveShaderPath (src/starship-survivors/data/vfx/live-shaders/<id>.live.ts) / anchors / relationships (verbatim from kit) / previewMotion / entries.

READS FROM

  • data/vfx/components/*.component.json — every published ComponentDef (via component-loader import.meta.glob). Eager-loaded at module init.
  • data/vfx/atlas/<category>.manifest.json — per-category AtlasManifestFile (via atlas-loader.loadAtlas, HTTP fetch).
  • data/vfx/atlas/<category>/<componentId>.png — per-component baked PNG (via atlas-loader.loadAtlas, HTTP fetch and Image decode).
  • data/vfx/live-shaders/<id>.live.ts — per-live-component GLSL source (referenced by path in handoff manifests; this system does not load them — runtime does).
  • shader-templates/{baked,shape,fill,mask,stroke,glow,energy,impact,post,live}/*.frag — every GLSL source string baked into the registries at module-load time via plain import statements. There is no late-binding registry — TEMPLATES and LIVE_TEMPLATES are frozen Record<id, spec> objects at module init.
  • The browser HTMLCanvasElement + WebGL2RenderingContext (real DOM in the pane, jsdom + document.createElement('canvas') mocks in tests, OffscreenCanvas in the worker path) — every bake / preview is fundamentally a fullscreen-quad render into an offscreen GL context that reads back via gl.readPixels.

PUSHES TO

  • data/vfx/atlas/<category>/<id>.png — the per-component baked PNG flipbook grid. Written by the editor / pane via a dev-only /__dev/atlas-write POST endpoint with the pngBytes from BakeResult (this system produces the bytes; the dev endpoint persists them). One file per component, plus per-resolution suffixed siblings (_t64.png / _t128.png / _t256.png) when bakeComponentMultiRes is used.
  • data/vfx/atlas/<category>.manifest.json — the per-category manifest assembled from each manifestEntry in BakeResult. The runtime sampler boots from this manifest, not from individual files.
  • data/vfx/live-shaders/<id>.live.ts — for live-shader components, the GLSL source + uniformSchema + previewSampleInputs exported as a TS module. Written by the editor save flow (not by this system’s pure functions).
  • data/vfx/components/<id>.component.json — the ComponentDef JSON itself. Written by the editor save flow.
  • data/vfx/kits/<weaponId>.kit.json — the KitDef JSON. Written by the kit editor save flow.
  • docs/vfx-handoff/<weaponId>.md — the handoff markdown emitted by buildHandoffPackage. Read by the next-session Claude that wires the weapon into the gameplay code.
  • The browser canvas inside the workbench pane — every preview / bake call ends with a drawImage from the WebGL2 offscreen onto a Canvas 2D context that the React pane displays.

DOES NOT

  • Run inside a real mission. The workbench is loaded only by the React dev pane (/dev/vfx-workbench) and the per-component editor screens. No code in engine/rendering, engine/world, or engine/combat imports from engine/vfx-workbench. The runtime contract is one-way: workbench writes atlas PNGs / manifests / GLSL files / component JSONs / kit JSONs / handoff markdown into data/vfx/ and docs/vfx-handoff/; runtime reads them at boot via separate loaders (engine/rendering/vfx-component-runtime.ts, engine/rendering/atlas-builder.ts, engine/rendering/live-shader-runtime.ts).
  • Touch the production engine/rendering/sprite-batch.ts. SpriteBatchArray is a separate workbench-only TEXTURE_2D_ARRAY batcher with its own atlas upload path. Production uses a single 4096² 2D atlas with per-region UVs; the workbench uses a 2D array with one layer per component.
  • Persist anything itself. Every “write” is performed by the dev-only /__dev/atlas-write POST endpoint or by the editor screen’s save flow — this system’s pure functions return Uint8Array PNG bytes and JSON-serializable manifests and let the caller decide where they go.
  • Ship live-shader templates as anything other than GLSL strings. The LiveSection.fragGlsl field is the actual source the runtime compiles; there is no bake step for live components. The atlas pipeline is bypassed entirely.
  • Define a runtime VFX render loop. The runtime per-frame draw lives in engine/rendering/vfx-component-runtime.ts (for baked components) and engine/rendering/live-shader-runtime.ts (for live shaders). The workbench’s preview render is a one-shot per editor interaction, not a 60 Hz tick.
  • Apply art-style post-FX presets (Borderlands cel, synthwave, VHS, etc.). Those are screen-space passes in engine/rendering/post-processing.ts and the post-FX store. The workbench’s PostEffect chain is per-component-bake — a chromatic-aberration on a muzzle flash, a bloom on an impact ring — applied once at bake time and pre-burned into the PNG.
  • Validate that a kit’s component refs are actually published. validateKitDef checks shape only; missing componentRefs surface in buildHandoffPackage as a MISSING placeholder in the markdown, deferring resolution to the runtime-wiring session.

Signals fired / Signals watched — none. The workbench has no engine signal-bus traffic. Cross-system communication is exclusively via file artifacts under data/vfx/ + docs/vfx-handoff/. The runtime never gets a runtime callback from the workbench; it loads files at boot.

Entry points

  • Schema barrel — component-schema (ComponentDef / BakedSection / LiveSection / Layer / MaskSpec / PostEffect / UniformDecl / LayerBlendMode / LayerKind / PostEffectKind / ComponentCategory / ComponentKind / validateComponentDef / migrateBakedToV2); kit-schema (KitDef / KitEntry / Relationship / KitEntryRole / KitInstanceCount / RelationshipKind / PreviewMotionRef / validateKitDef); anchor-types (AnchorMap / AnchorSpec / AnchorKind / AnchorDirection / addAnchor / removeAnchor / renameAnchor / updateAnchor / isValidAnchorName); atlas-format (AtlasCategory / AtlasManifest / AtlasLayer / BakeResult).
  • Template registries — shader-templates/registry (TEMPLATES / TemplateId / TemplateFamily / TemplateSpec / UniformEntry); shader-templates/live-registry (LIVE_TEMPLATES / LiveTemplateId / LiveTemplateSpec).
  • Bake — bake (bakeComponent); bake-multi-res (bakeComponentMultiRes / DEFAULT_BAKE_RESOLUTIONS).
  • Render surfaces — compositor (LayerCompositor class with renderStack / runPostChain / readPixels / getResultTexture / destroy); preview-surface (PreviewSurface class with useTemplate / setUniforms / render / readPixels / getCanvas / getDataUrl / destroy).
  • Sampling — frame-select (pickFrameIndex).
  • Loaders — component-loader (ALL_COMPONENTS / COMPONENT_MAP / loadAllComponents / getComponent); atlas-loader (loadAtlas / uploadAtlasToTexture).
  • Export — gif-export (renderFramesForGif and the gif89a encoder helpers); handoff-package (buildHandoffPackage / HandoffPackage / HandoffManifest).
  • Workbench preview batcher — sprite-batch-array (SpriteBatchArray class with the workbench-only TEXTURE_2D_ARRAY sprite pipeline).
  • Palette utilities — palette (flattenPalette / evalPalette / vec3ToHex / hexToVec3 / DEFAULT_PALETTE).
  • Encoding helpers — png-base64 (bytesToBase64).
  • Shader source — compose-pass.frag (default-exported GLSL string consumed by LayerCompositor).

Pattern notes

  • Two-axis content model. Component × Kit. A Component is one self-contained visual unit with anchors and either a baked atlas or a live shader. A Kit is the per-weapon graph that says “use this muzzle, this body, this impact, snap the body’s tail anchor to the muzzle’s forward anchor, span this beam between these two anchors.” Components are reusable; Kits are weapon-scoped. The runtime resolves the graph at fire time — the workbench only authors and previews it.
  • Two parallel registries reflect the bake/live split. The baked registry (TEMPLATES) is the dominant authoring surface — ~60 templates grouped into families (shape / fill / mask / stroke / glow / energy / impact / post) that combine into multi-layer baked atlases via the compositor. The live registry (LIVE_TEMPLATES) is small — three templates that need per-frame uniforms the bake pipeline can’t capture (per-instance lightning seeds, per-frame endpoint positions for span-between beams, per-frame chain-arc dynamics). Live shaders ship as GLSL strings, baked components ship as PNGs. Both go to runtime via different loaders.
  • The schema-v2 layer-stack supersedes legacy single-shader baked components but the legacy path is preserved as a fast path. migrateBakedToV2 wraps a pre-v2 component as a one-layer stack on load, and bakeComponent picks the PreviewSurface (single-shader, no compose pass overhead) path when baked.layers.length <= 1. New components are always authored as multi-layer.
  • The compositor is a three-FBO ping-pong: accA and accB alternate as “current accumulator” and “next accumulator” while scratch holds the freshly-rendered layer’s shader output. Each layer cycle does one render-layer-to-scratch + one compose-pass blending currAcc with scratch under the layer’s blend mode + opacity, then swaps. Premultiplied-alpha math at every step. The same compose-pass GLSL is reused for the (stubbed) post-chain.
  • All baked templates share the same shared-uniform contract (u_time / u_resolution / u_palette[4] / u_intensity) so any template can be slotted into any layer. Per-template uniforms come from layer.params. The compositor compiles one program per TemplateId and caches it in a per-instance programCache — no global program cache.
  • Inigo Quilez cosine palettes are the canonical color model. A palette is four vec3 stops (a / b / c / d) that evaluate to a + b * cos(2π * (c*t + d)) — one GPU instruction per channel. The DEFAULT_PALETTE ([[0.5,0.5,0.5], [0.5,0.5,0.5], [1,1,1], [0,0.33,0.67]]) is grey-grey-white-RGB-phase, the standard rainbow starting point. The runtime’s palette system in engine/rendering/palette/ is a separate process-global palette for biome chrome — these workbench palettes are per-component.
  • Anchor names are case-insensitive [a-z][a-z0-9_]{0,31} with center reserved (cannot be removed / renamed). Three anchor kinds: pivot (default attach point), edge (a directional point on the silhouette, with forward / backward), radial (a radius-parameterized ring of points). Kits reference anchors by name; the runtime resolves coordinates at fire time from the component’s pivot + sprite size.
  • Kit relationships are a small vocabulary: attachPivot (snap child pivot to parent’s named anchor), spanBetween (stretch child between two anchors — used for beams), orbitAround (child orbits parent’s anchor), trailBehind (child ribbons behind parent’s motion), stackOnto (child draws on top of parent’s pivot, no offset). previewMotion describes how the kit moves in the editor’s shooting range (straight_line / orbit / tracking / beam_sustain / arc_ballistic) but is never shipped — runtime motion is owned by the weapon’s existing combat code.
  • instanceCount: 'runtime' is the chain-jumps / shotgun-pellet-count escape hatch. The kit declares that the runtime decides how many copies of a component to spawn (e.g. a chain-lightning weapon spawns N body components, one per chain jump). 'fixed' with fixedCount is the static case (e.g. one muzzle flash per shot).
  • Bake validation is enforced at load time. validateComponentDef rejects frameCount outside 1–128, tileSize outside the canonical set {16, 32, 64, 128, 256, 512}, palettes not exactly 4 vec3 stops, and layers with invalid blend / kind / opacity. A bad component JSON fails at load, not silently at render time.
  • The handoff package is the cross-session contract. The workbench session ends with buildHandoffPackage(kit, componentsById) and writes the markdown to docs/vfx-handoff/<weaponId>.md + a machine-readable HandoffManifest. The next session — runtime wiring — reads the markdown for prose context and the manifest for paths. The markdown explicitly lists the runtime hooks to call (initVfxComponents, reserveAtlasRegion + patchAtlasRegion per component, initLiveShaderRuntimes per live shader, drawBullet replacement with spriteBatch.add + vfxFrameRegion) so the wiring session knows exactly which production-rendering touch-points to update.
  • Multi-resolution baking is opt-in. DEFAULT_BAKE_RESOLUTIONS = [64, 128, 256] produces three PNGs per component (_t64 / _t128 / _t256 suffixed); the runtime picks based on DPR × visual size. Components that don’t need it bake at the single declared tileSize and the runtime samples that one regardless of DPR.
  • The GIF exporter is preview-only — Discord posts, doc embeds, internal review. It uses an in-house ~200 LOC gif89a encoder rather than pulling gifenc as a dependency. Not a shipping asset format — the only asset the player ever loads is the PNG atlas.
  • The SpriteBatchArray and the production engine/rendering/sprite-batch.ts are deliberately separate code paths. The workbench batcher is a TEXTURE_2D_ARRAY with one layer per component (frame-index inside the layer’s grid via the vertex shader’s mod / floor math); the production batcher is a single 4096² 2D atlas with per-region UVs and reserveAtlasRegion / patchAtlasRegion / reuploadAtlas hooks. They share neither shaders, atlas formats, nor instance buffers. The workbench preview pane runs SpriteBatchArray; in-game frames run sprite-batch.ts.
  • jsdom + the test mock matrix is load-bearing. Tests mock document.createElement('canvas') with a call-index switch that expects exactly two canvases per bake (render-surface first, gridCanvas second). bake.ts documents this ordering in a comment, and canvasToPngBytes has the toBlobtoDataURL fallback path specifically so jsdom (which doesn’t implement toBlob) still passes tests. Reordering canvas creation breaks the test mocks.
  • Naive base64 round-trips crash on >~1 MB PNG buffers because String.fromCharCode(...bytes) spreads into V8’s argument-count limit. Every place that takes a PNG buffer to a base64 string routes through bytesToBase64’s 32 KB chunked path.
  • The workbench is the only system in the engine that intentionally writes files to the source tree. The /__dev/atlas-write endpoint persists baked PNGs into src/starship-survivors/data/vfx/atlas/, and the editor save flows write JSON component / kit definitions next to them. This is by design — atlas PNGs are shipped assets, not generated build artifacts. Tests do not run the write endpoint; they read returned Uint8Array PNG bytes in memory only.
  • The connection ComponentCategory is live-only (no atlas folder for it). Span-between beams and dynamic chain arcs need per-frame endpoint uniforms that a PNG atlas can’t encode, so the schema reflects that asymmetry: AtlasCategory is the four bake-able categories (muzzle / body / impact / persistent); ComponentCategory is those four plus connection.
  • Removed legacy slots are preserved as back-compat shims rather than ripped. BakedSection.shader / params / palette (the single-shader fields) are kept alongside layers / postChain for legacy components — the compositor reads either path. bake.ts re-exports ComponentDef from itself for old callers that used to import it from there (new code imports from component-schema.ts). The legacy fast path through PreviewSurface is preserved both for single-layer v2 stacks (skips compose-pass overhead) and for legacy-shape components.