component-schema.ts

PURPOSE

Defines the canonical TypeScript types and validator for VFX-workbench ComponentDef records — the unit of authored visual content (muzzle flash, beam body, impact burst, persistent aura, connection trail). Splits a component into baked (offline-rendered sprite atlas) vs live_shader (runtime fragment shader) shapes, formalizes the schema-v2 layer stack + post chain, and provides a migration helper that lifts legacy single-shader baked components into the v2 layer-stack shape so the compositor only sees one format.

OWNS

  • Enums: ComponentCategory (muzzle | body | impact | persistent | connection), ComponentKind (baked | live_shader), UniformKind (float | int | vec2 | vec3 | vec4 | sampler2D), BlendMode (additive | alpha), LayerBlendMode (additive | alpha | multiply | screen | erase), LayerKind (shape | fill | mask | stroke | glow | post), PostEffectKind (bloom | chromaticAberration | warpRadial | warpShockwave | warpTurbulence | warpPolarSwirl | warpBlackHole | grain | vignette | barrel).
  • Interfaces: UniformDecl, MaskSpec, LayerTransform, LayerAnimation, Layer, PostEffect, BakedSection, LiveSection, ComponentDef.
  • Validator: validateComponentDef(d: unknown) returning a discriminated {ok:true} | {ok:false, reason:string}.
  • Migration: migrateBakedToV2(baked: BakedSection): BakedSection — non-mutating expansion of legacy shader/params/palette into a one-entry layers array tagged schemaVersion: 2.
  • Bake bounds constants: BAKE_MIN_FRAMES = 1, BAKE_MAX_FRAMES = 128, BAKE_TILE_SIZES = [16, 32, 64, 128, 256, 512], PALETTE_VEC3_LEN = 3, PALETTE_STOP_COUNT = 4.
  • Internal helpers: validateLayer, validateBakeSettings, validatePalette.

READS FROM

  • Nothing. Pure types + validator with no imports.

PUSHES TO

  • Anything that loads or persists a ComponentDef: the VFX-workbench UI authoring screens, the bake pipeline, the runtime compositor, and any JSON loader for shipped component definitions. The validator is the single chokepoint that rejects malformed component JSON at load time instead of letting it silently corrupt the render.

DOES NOT

  • Does not render, bake, compile shaders, or read files. No side effects.
  • Does not register or look up components — that’s the responsibility of the registry / loader layer.
  • Does not validate uniform default values against range — only checks name + type presence on uniform decls.
  • Does not validate MaskSpec, LayerTransform, LayerAnimation, or PostEffect.params content beyond their presence; validateLayer does not descend into mask or transform.
  • Does not validate the legacy shader / params / palette fields when schemaVersion === 2 and layers is present — those fields become ignored.
  • Does not enforce that live.previewSampleInputs keys match uniformSchema names.
  • migrateBakedToV2 does not mutate the input and returns the input unchanged when there’s nothing to migrate from (missing shader/params/palette) — caller is expected to have already rejected via validateComponentDef.

Signals

  • validateComponentDef is total over unknown and never throws — failure modes are returned as {ok:false, reason}.
  • A baked component is schema-v2 iff baked.schemaVersion === 2 && Array.isArray(baked.layers). In that case baked.layers.length >= 1, layer ids are unique, and postChain (if present) must be an array. Layers are ordered back-to-front; layers[0] is the bottom.
  • A legacy baked component must carry shader: string (non-empty), params: object, and a palette of exactly 4 vec3 entries (each entry length 3, all finite numbers).
  • bake.frameCount clamped to 1..128. bake.tileSize must be one of {16, 32, 64, 128, 256, 512}. bake.loopMode ∈ {oneshot, loop, parameterized}. bake.fps > 0 and finite.
  • Layer opacity is clamped 0..1 at validate time. Layer blend must be one of the 5 LayerBlendMode values, which is a strict superset of runtime BlendMode (additive | alpha).
  • live_shader requires non-empty fragGlsl, an array uniformSchema, and blendMode ∈ {additive, alpha}. vertGlsl is optional; estCostMs is optional.
  • MaskSpec.source ∈ {sdf, layerAlpha, noise, register}. The register source is reserved for Phase 4+ (named mask register buffers).
  • LayerAnimation.phase is 0..1; LayerAnimation.freq is a multiplier on u_time passed into the shader.
  • migrateBakedToV2 produces a synthetic layer with id: 'l0', kind: 'fill', blend: 'additive', opacity: 1, name taken from the legacy shader string.

Entry points

  • validateComponentDef(d: unknown): {ok: true} | {ok: false, reason: string} — call before persisting or instantiating any externally-sourced ComponentDef.
  • migrateBakedToV2(baked: BakedSection): BakedSection — call at load time on any baked section before handing it to the compositor; idempotent for already-v2 inputs.
  • All exported types are consumed structurally by authoring UI, bake pipeline, and runtime.

Pattern notes

  • Validator uses a discriminated-result pattern ({ok: true} | {ok: false, reason}) rather than throwing — keeps validateComponentDef total and lets callers branch on reason for user-facing error surfaces.
  • Schema-v2 introduction is additive-and-tagged: a missing schemaVersion field means legacy single-shader, and the legacy-path branch coexists with the v2 branch in the same validator. migrateBakedToV2 collapses the two formats so the compositor only has to support v2.
  • Constants for bake bounds are named (BAKE_MIN_FRAMES, BAKE_TILE_SIZES, PALETTE_STOP_COUNT, PALETTE_VEC3_LEN) so the failure messages match the source-of-truth values and there are no magic numbers in the function bodies — matches the project’s “every number traces to a named constant” rule.
  • Layer-stack blend modes (multiply, screen, erase) are a deliberate superset of runtime BlendMode — those modes are only meaningful when the compositor combines layers inside one baked component; the resulting baked sprite then composites with the world using the runtime two-mode set.
  • Layer id uniqueness is enforced via a Set<string> at validate time so downstream code that uses layer.id as a map key (e.g., MaskSpec.targetId referencing an earlier layer) can rely on uniqueness without rechecking.
  • “Crash on bad data” applies inside the workbench / load boundary: a malformed component JSON is rejected at load via the validator rather than silently rendering wrong, matching the project’s “fail at load, not silently at render time” stance documented inline at the bake-bounds constants.

EXTRACT-CANDIDATE

  • The {ok: true} | {ok: false, reason: string} discriminated-result type repeats across validateComponentDef, validateLayer, validateBakeSettings, and validatePalette. Could be hoisted into a shared ValidationResult alias in an engine-wide validation utility module if other VFX-workbench (or content-pipeline) validators adopt the same pattern.
  • The bake-bounds constants (BAKE_MIN_FRAMES, BAKE_MAX_FRAMES, BAKE_TILE_SIZES) describe properties of the bake target (atlas tile constraints, animation length cap) and likely co-evolve with the bake pipeline and atlas packer — a shared bake-limits.ts would keep the validator and the pipeline in lockstep.
  • LayerBlendMode vs BlendMode (compositor-internal vs runtime-visible) is a recurring shape worth pulling into a small blend-modes.ts if the compositor or post-chain modules grow their own enums.
  • PostEffectKind is currently a flat union of bloom / chromatic-aberration / warp-* / grain / vignette / barrel. As the warp family grows it could move into its own post-effects.ts with per-effect param schemas; today the schema is just params: Record<string, number> with no per-kind validation.