bake.ts

PURPOSE

Bakes a ComponentDef (kind 'baked') into a frame-grid PNG atlas plus a manifest entry. Renders each animation frame off-screen via either the multi-layer LayerCompositor (when baked.layers.length > 1) or the legacy single-shader PreviewSurface fast path, packs frames into a cols × rows grid sized by ceil(sqrt(frameCount)), encodes to PNG, and returns a BakeResult consumable by the atlas pipeline.

OWNS

  • bakeComponent(def: ComponentDef): Promise<BakeResult> — sole public export (plus type re-export of ComponentDef).
  • Module-private canvasToPngBytes(canvas)toBlob-preferred PNG encoder with toDataURL + chunked-decode fallback for jsdom.
  • Module-private blitFramePixels(ctx, px, tileSize, dstX, dstY) — Y-flipping RGBA blit from WebGL bottom-up pixels into the 2D grid canvas at tile coordinates.
  • Re-export of ComponentDef (back-compat shim; new code imports from component-schema.ts).

READS FROM

  • ./preview-surfacePreviewSurface (single-shader path).
  • ./compositorLayerCompositor (multi-layer path).
  • ./component-schemamigrateBakedToV2, ComponentDef (forces v2 baked schema).
  • ./shader-templates/registryTemplateId (type only).
  • ./atlas-formatBakeResult (return type).
  • def.kind, def.baked, def.id, def.name, def.category, def.anchors from the input def.
  • After migration: baked.bake.{frameCount, fps, tileSize, loopMode}, baked.layers[], baked.palette, baked.postChain, plus legacy baked.shader / baked.params.

PUSHES TO

Returns a BakeResult:

  • pngBytes: Uint8Array — PNG-encoded grid atlas.
  • gridW, gridH — atlas pixel dimensions (cols * tileSize, rows * tileSize).
  • manifestEntry{ componentId, name, category, kind: 'baked', frameCount, fps, tileSize, gridCols, gridRows, loopMode, pivot: { x: tileSize/2, y: tileSize/2 }, anchors, palette, shaderTemplate, shaderParams, atlasPath: "${category}/${id}.png" }.

No filesystem writes, no global state mutation, no network I/O. Caller is responsible for persisting pngBytes at atlasPath.

DOES NOT

  • Does not write files or upload to any store.
  • Does not validate that the def is well-formed beyond the kind === 'baked' && baked guard and the shader-presence check on the single-shader path.
  • Does not handle kind !== 'baked' components — throws.
  • Does not retry on transient WebGL/canvas failures; surface/compositor errors propagate.
  • Does not deduplicate atlas paths or check for collisions.
  • Does not honor any pivot override — pivot is hard-coded to tile center.

Signals

  • frameCount, fps, tileSize, loopMode drive grid sizing and the tNorm schedule.
  • loopMode === 'loop' || 'parameterized'tNorm = i / frameCount (open interval, last frame ≠ first).
  • Otherwise (e.g. 'once') → tNorm = i / max(frameCount - 1, 1) (closed interval, last frame at t = 1).
  • useCompositor = Array.isArray(baked.layers) && baked.layers.length > 1 selects the path.
  • Single-layer v2 stacks intentionally fall through to the PreviewSurface fast path to skip compose overhead.

Entry points

  • bakeComponent(def) — called by the bake pipeline (CLI / build script / workbench preview “bake all”).

Pattern notes

  • Canvas creation order is load-bearing. Tests mock document.createElement('canvas') with a call-index switch that expects (render-surface first, gridCanvas second). The two branches preserve this order — do not reorder.
  • PNG encoding avoids String.fromCharCode(...spread) because spreading a >~1MB byte buffer blows the call stack. Prefers canvas.toBlobarrayBuffer(); jsdom fallback decodes base64 byte-by-byte in a loop.
  • Y-flip in blitFramePixels compensates for WebGL readPixels returning bottom-up rows; the destination 2D canvas is top-down.
  • Migration on entry. migrateBakedToV2(def.baked) runs unconditionally — downstream code only sees v2 shape.
  • Manifest back-compat. shaderTemplate and shaderParams mirror the bottom layer (baked.layers[0]) for v2 stacks, falling back to legacy baked.shader / baked.params. Consumers expecting the legacy single-shader fields keep working.
  • Try/finally around surface/compositor. Both paths wrap rendering in try { ... } finally { surface.destroy() / compositor.destroy() } to release GL resources even on throw.
  • gridCanvas is allocated inside each branch, not hoisted, to keep the createElement call order documented above.
  • Single-shader path tolerates array uniforms (vec2/3/4) because PreviewSurface.setUniforms dispatches by .length — even though stack-originated params can include arrays, the legacy path accepts them.

EXTRACT-CANDIDATE

  • canvasToPngBytes — generic enough that other off-screen-canvas → PNG callers (e.g. screenshot, atlas debug dump) would benefit. Candidate for engine/vfx-workbench/canvas-png.ts or a broader engine/util/.
  • blitFramePixels — Y-flipping WebGL→2D blit is reusable wherever WebGL readback meets a 2D atlas; same extraction target.
  • tNorm frame-schedule formula (loop/parameterized vs. closed interval) is duplicated between the two branches — candidate for a helper frameTime(i, frameCount, loopMode).