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 legacyshader/params/paletteinto a one-entrylayersarray taggedschemaVersion: 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 checksname+typepresence on uniform decls. - Does not validate
MaskSpec,LayerTransform,LayerAnimation, orPostEffect.paramscontent beyond their presence;validateLayerdoes not descend intomaskortransform. - Does not validate the legacy
shader/params/palettefields whenschemaVersion === 2andlayersis present — those fields become ignored. - Does not enforce that
live.previewSampleInputskeys matchuniformSchemanames. migrateBakedToV2does 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 viavalidateComponentDef.
Signals
validateComponentDefis total overunknownand never throws — failure modes are returned as{ok:false, reason}.- A
bakedcomponent is schema-v2 iffbaked.schemaVersion === 2 && Array.isArray(baked.layers). In that casebaked.layers.length >= 1, layer ids are unique, andpostChain(if present) must be an array. Layers are ordered back-to-front;layers[0]is the bottom. - A legacy
bakedcomponent must carryshader: string(non-empty),params: object, and apaletteof exactly 4 vec3 entries (each entry length 3, all finite numbers). bake.frameCountclamped to 1..128.bake.tileSizemust be one of {16, 32, 64, 128, 256, 512}.bake.loopMode∈ {oneshot, loop, parameterized}.bake.fps> 0 and finite.- Layer
opacityis clamped 0..1 at validate time. Layerblendmust be one of the 5LayerBlendModevalues, which is a strict superset of runtimeBlendMode(additive | alpha). live_shaderrequires non-emptyfragGlsl, an arrayuniformSchema, andblendMode∈ {additive, alpha}.vertGlslis optional;estCostMsis optional.MaskSpec.source∈ {sdf, layerAlpha, noise, register}. Theregistersource is reserved for Phase 4+ (named mask register buffers).LayerAnimation.phaseis 0..1;LayerAnimation.freqis a multiplier onu_timepassed into the shader.migrateBakedToV2produces a synthetic layer withid: 'l0',kind: 'fill',blend: 'additive',opacity: 1, name taken from the legacyshaderstring.
Entry points
validateComponentDef(d: unknown): {ok: true} | {ok: false, reason: string}— call before persisting or instantiating any externally-sourcedComponentDef.migrateBakedToV2(baked: BakedSection): BakedSection— call at load time on anybakedsection 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 — keepsvalidateComponentDeftotal and lets callers branch onreasonfor user-facing error surfaces. - Schema-v2 introduction is additive-and-tagged: a missing
schemaVersionfield means legacy single-shader, and the legacy-path branch coexists with the v2 branch in the same validator.migrateBakedToV2collapses 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 runtimeBlendMode— 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 useslayer.idas a map key (e.g.,MaskSpec.targetIdreferencing 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 acrossvalidateComponentDef,validateLayer,validateBakeSettings, andvalidatePalette. Could be hoisted into a sharedValidationResultalias 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 sharedbake-limits.tswould keep the validator and the pipeline in lockstep. LayerBlendModevsBlendMode(compositor-internal vs runtime-visible) is a recurring shape worth pulling into a smallblend-modes.tsif the compositor or post-chain modules grow their own enums.PostEffectKindis currently a flat union of bloom / chromatic-aberration / warp-* / grain / vignette / barrel. As the warp family grows it could move into its ownpost-effects.tswith per-effect param schemas; today the schema is justparams: Record<string, number>with no per-kind validation.