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 aBakedSectionand/orLiveSection), the fiveComponentCategoryvalues (muzzle/body/impact/persistent/connection), the twoComponentKindvalues (baked/live_shader),BakedSection.schemaVersion 2layer-stack vs legacy single-shader fields,LiveSection.fragGlsl+uniformSchema+previewSampleInputs+blendMode,Layershape withLayerKind(shape/fill/mask/stroke/glow/post) +LayerBlendMode(additive/alpha/multiply/screen/erase) +LayerTransform+LayerAnimation,MaskSpecwith four sources (sdf/layerAlpha/noise/register),PostEffectchain with tenPostEffectKindvalues (bloom / chromatic / four warp variants / grain / vignette / barrel),UniformDeclshape with handle annotations for the editor (point/radius/angle), validators (validateComponentDef,validateLayer,validateBakeSettings,validatePalette), and themigrateBakedToV2legacy-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), fiveKitEntryRolevalues (spawn/body/connection/persistent/impact), twoKitInstanceCountvalues (fixed/runtime) for how many copies the kit spawns vs how many the runtime decides at fire time, fiveRelationshipKindvalues (attachPivot/spanBetween/orbitAround/trailBehind/stackOnto) for parent-child anchor wiring,PreviewMotionRef(template id + params) used only in the shooting-range preview — never shipped, andvalidateKitDef(rejects duplicate entry ids, missing parent/child refs, unknown roles / kinds). - Anchor system —
AnchorMap(named-anchor table on every component),AnchorSpecwith threeAnchorKindvalues (pivot/edge/radial) and twoAnchorDirectionvalues (forward/backward), the[a-z][a-z0-9_]{0,31}name regex, the reservedcenteranchor (cannot be removed or renamed), and the immutable-update helpers (addAnchor/removeAnchor/renameAnchor/updateAnchor). - Atlas format —
AtlasCategory(the four bake-able categories,connectionis live-only),AtlasManifestshape (category / formatpng-grid|ktx2-astc|ktx2-etc2/ atlasDir / layers),AtlasLayershape (componentId / layerIndex / frame grid params / loopMode / pivot / anchors / palette / shaderTemplate / shaderParams / bakedAt / atlasPath), and theBakeResultshape (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-declaredUniformDecl(subset of the baked one),previewSampleInputsfor 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-frameuTimeSec, beam widths). LayerCompositor— workbench-only WebGL2 three-FBO ping-pong renderer (accA/accBaccumulators +scratchfor the current layer’s shader output). Per-layer cycle: render layer shader into scratch → bind next-acc → compose-pass blendscurrAccwithscratchunder the layer’s blend mode + opacity → swap. Owns one sharedcompose-pass.fragGLSL string with five blend modes encoded byu_modeint (0 additive / 1 alpha-Porter-Duff-over / 2 multiply / 3 screen / 4 erase), a per-shader-template program cache, thewrapoption (clamp/repeat—repeatis used by the backgrounds-workbench for tileable bakes), and theresolveTemplateinjection point that lets the backgrounds-workbench substitute its own template registry without merging. StubrunPostChainreserved for Phase-3 post passes.PreviewSurface— workbench-only WebGL2 single-fullscreen-quad renderer used as the legacy single-shader fast path insidebakeComponentand as the editor’s live preview surface. Owns one program perTemplateId(lazy-compiled, cached),useTemplateswitches the active program,setUniformsdispatchesuniform1i/uniform1f/uniform2fv/uniform3fv/uniform4fv/uniform3fv(palette) by JS type + length,render(tNorm)draws the fullscreen quad at a given normalized time,readPixelsreturns the bottom-up RGBA buffer,getCanvas/getDataUrlfor 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, runsmigrateBakedToV2, picks the multi-layer (LayerCompositor) or single-shader (PreviewSurface) path bybaked.layers.length > 1, walksframeCountframes at the correcttNormfor the loop mode (loop / parameterized usei / frameCount, oneshot usesi / (frameCount-1)), packs each frame into acols × rowsgrid wherecols = ceil(sqrt(frameCount)), encodes to PNG viacanvasToPngBytes(preferscanvas.toBlob, falls back to chunkedtoDataURL-decode for jsdom), and returns aBakeResultwith the legacy-compatible manifest entry. Strict order ofdocument.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 eachtileSizeinDEFAULT_BAKE_RESOLUTIONS([64, 128, 256]). Caller writes each PNG with a size-suffixed filename (_t64/_t128/_t256) so the runtime can pick byDPR × visual size.frame-select— runtime-agnostic frame-index picker (pickFrameIndex({ loopMode, frameCount, fps, ageMs, param })). Three loop modes:oneshotclamps toframeCount-1,loopwraps modulo,parameterizedfloorsparam × 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 asbakeComponent) and returns RGBA frame buffers + delay; downstream wraps them in agif89awriter (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 WebGL2TEXTURE_2D_ARRAYvia the runtime sprite batch’screateEmptyAtlas/uploadLayerhooks. Returnsnullon missing manifest (graceful boot when no atlas has been baked yet).component-loader— Viteimport.meta.globindex overdata/vfx/components/*.component.jsonwith eager-load. ExportsALL_COMPONENTS(sorted by id),COMPONENT_MAP(id → def),loadAllComponents/getComponentaccessors. Crashes loudly on unknown id with the available-id list in the error.SpriteBatchArray— workbench-onlyTEXTURE_2D_ARRAYsprite batcher used by the in-pane preview pass. Per-instance attributes carryiLayer(which atlas layer = which component) +iFrame(which tile in the grid) +iGrid(cols / rows for UV math) + tint; vertex shader resolvesvUvLayerinto the correct tile UV viamod(iFrame, iGrid.x)+floor(iFrame / iGrid.x).MAX_SPRITES = 4096, instance stride 14 floats. Does NOT replaceengine/rendering/sprite-batch.ts— that is the production batcher with its own atlas and atlas-region map; this is the workbench’s own scratch.palette—Vec3/Palette4types,flattenPalette(returns the 12-numberu_palette[4]payload),evalPalette(p, t)(Inigo Quilez cosine palette:a + b * cos(2π * (c*t + d))), hex ↔ vec3 conversion helpers, and theDEFAULT_PALETTE(grey-grey-white-RGB-phase, the workbench fallback).compose-pass.frag— workbench-only GLSL string used byLayerCompositor. Premultiplied-alpha math at every blend mode. Theu_modeint encoding must stay in sync withLayerBlendModeincomponent-schema.ts.png-base64—bytesToBase64chunkedString.fromCharCode+btoahelper. Replaces the naive spread-into-fromCharCodepattern that crashes on >~1 MB buffers due to V8’s argument-count limit. Used by every place that round-trips a PNG payload through adata:image/png;base64,...URL.handoff-package— the cross-system contract generator.buildHandoffPackage(kit, componentsById)produces aHandoffPackage(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 viainitVfxComponents(), atlas reserve + patch viaengine/rendering/atlas-builder, live-shader init viainitLiveShaderRuntimes(), weapon-data kit reference,drawBulletarchetype 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 publishedComponentDef(viacomponent-loaderimport.meta.glob). Eager-loaded at module init.data/vfx/atlas/<category>.manifest.json— per-categoryAtlasManifestFile(viaatlas-loader.loadAtlas, HTTP fetch).data/vfx/atlas/<category>/<componentId>.png— per-component baked PNG (viaatlas-loader.loadAtlas, HTTP fetch andImagedecode).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 plainimportstatements. There is no late-binding registry —TEMPLATESandLIVE_TEMPLATESare frozenRecord<id, spec>objects at module init.- The browser
HTMLCanvasElement+WebGL2RenderingContext(real DOM in the pane, jsdom +document.createElement('canvas')mocks in tests,OffscreenCanvasin the worker path) — every bake / preview is fundamentally a fullscreen-quad render into an offscreen GL context that reads back viagl.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-writePOST endpoint with thepngBytesfromBakeResult(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) whenbakeComponentMultiResis used.data/vfx/atlas/<category>.manifest.json— the per-category manifest assembled from eachmanifestEntryinBakeResult. 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+previewSampleInputsexported as a TS module. Written by the editor save flow (not by this system’s pure functions).data/vfx/components/<id>.component.json— theComponentDefJSON itself. Written by the editor save flow.data/vfx/kits/<weaponId>.kit.json— theKitDefJSON. Written by the kit editor save flow.docs/vfx-handoff/<weaponId>.md— the handoff markdown emitted bybuildHandoffPackage. 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
drawImagefrom 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 inengine/rendering,engine/world, orengine/combatimports fromengine/vfx-workbench. The runtime contract is one-way: workbench writes atlas PNGs / manifests / GLSL files / component JSONs / kit JSONs / handoff markdown intodata/vfx/anddocs/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.SpriteBatchArrayis a separate workbench-onlyTEXTURE_2D_ARRAYbatcher 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-writePOST endpoint or by the editor screen’s save flow — this system’s pure functions returnUint8ArrayPNG 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.fragGlslfield 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) andengine/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.tsand the post-FX store. The workbench’sPostEffectchain 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.
validateKitDefchecks shape only; missingcomponentRefs surface inbuildHandoffPackageas aMISSINGplaceholder 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(LayerCompositorclass withrenderStack/runPostChain/readPixels/getResultTexture/destroy);preview-surface(PreviewSurfaceclass withuseTemplate/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(renderFramesForGifand the gif89a encoder helpers);handoff-package(buildHandoffPackage/HandoffPackage/HandoffManifest). - Workbench preview batcher —
sprite-batch-array(SpriteBatchArrayclass with the workbench-onlyTEXTURE_2D_ARRAYsprite 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 byLayerCompositor).
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
tailanchor to the muzzle’sforwardanchor, 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.
migrateBakedToV2wraps a pre-v2 component as a one-layer stack on load, andbakeComponentpicks thePreviewSurface(single-shader, no compose pass overhead) path whenbaked.layers.length <= 1. New components are always authored as multi-layer. - The compositor is a three-FBO ping-pong:
accAandaccBalternate as “current accumulator” and “next accumulator” whilescratchholds the freshly-rendered layer’s shader output. Each layer cycle does one render-layer-to-scratch + one compose-pass blendingcurrAccwithscratchunder 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 fromlayer.params. The compositor compiles one program perTemplateIdand caches it in a per-instanceprogramCache— 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 toa + b * cos(2π * (c*t + d))— one GPU instruction per channel. TheDEFAULT_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 inengine/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}withcenterreserved (cannot be removed / renamed). Three anchor kinds:pivot(default attach point),edge(a directional point on the silhouette, withforward/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).previewMotiondescribes 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'withfixedCountis the static case (e.g. one muzzle flash per shot).- Bake validation is enforced at load time.
validateComponentDefrejectsframeCountoutside 1–128,tileSizeoutside 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 todocs/vfx-handoff/<weaponId>.md+ a machine-readableHandoffManifest. 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+patchAtlasRegionper component,initLiveShaderRuntimesper live shader,drawBulletreplacement withspriteBatch.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/_t256suffixed); the runtime picks based onDPR × visual size. Components that don’t need it bake at the single declaredtileSizeand 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
gifencas a dependency. Not a shipping asset format — the only asset the player ever loads is the PNG atlas. - The
SpriteBatchArrayand the productionengine/rendering/sprite-batch.tsare deliberately separate code paths. The workbench batcher is aTEXTURE_2D_ARRAYwith one layer per component (frame-index inside the layer’s grid via the vertex shader’smod/floormath); the production batcher is a single 4096² 2D atlas with per-region UVs andreserveAtlasRegion/patchAtlasRegion/reuploadAtlashooks. They share neither shaders, atlas formats, nor instance buffers. The workbench preview pane runsSpriteBatchArray; in-game frames runsprite-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.tsdocuments this ordering in a comment, andcanvasToPngByteshas thetoBlob→toDataURLfallback path specifically so jsdom (which doesn’t implementtoBlob) 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 throughbytesToBase64’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-writeendpoint persists baked PNGs intosrc/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 returnedUint8ArrayPNG bytes in memory only. - The
connectionComponentCategoryis 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:AtlasCategoryis the four bake-able categories (muzzle/body/impact/persistent);ComponentCategoryis those four plusconnection. - Removed legacy slots are preserved as back-compat shims rather than ripped.
BakedSection.shader/params/palette(the single-shader fields) are kept alongsidelayers/postChainfor legacy components — the compositor reads either path.bake.tsre-exportsComponentDeffrom itself for old callers that used to import it from there (new code imports fromcomponent-schema.ts). The legacy fast path throughPreviewSurfaceis preserved both for single-layer v2 stacks (skips compose-pass overhead) and for legacy-shape components.