Animation
Modoki's animation stack: reusable keyframe clips that drive arbitrary trait fields, Three.js skeletal playback for rigged GLBs (with hand-posable bones, per-mesh renderers, and a cross-model clip library), and 2D sprite flipbooks. It follows Unity's vocabulary throughout — clip, Animator, mixer, animset, retarget — but every "desired state" is a pure ECS trait and every live THREE object lives in the render sync, never in the trait.
What it is
Five loosely-coupled subsystems, split by what they drive:
- Keyframe clips (
Animator+.anim.json) — a reusable asset of tangent-curve tracks that animate any trait field (Transform, colour, UI, enum, boolean) on the Animator's entity and its descendants, bound by relative name-path. This is the generic "animate a number over time" engine; it's evaluated by pure, headless-testable functions shared between playback and the editor scrub. - 3D skeletal (
SkinnedModel+SkeletalAnimator) — a rigged GLB kept as a live SkinnedMesh hierarchy, cloned per entity, driven by aTHREE.AnimationMixer. The trait is desired state; the render sync owns the mixer/actions and mirrors live playback back onto the trait for Percept. - Hand-posable bones (
Boneentities +syncBones) — ECS entities that bridge two-way into the cloned skeleton, so a bone is gizmo/inspector-editable when no clip drives it, and a keyframeAnimatoror a game LateUpdate can layer overrides on top of the mixer pose. - Rig accessories —
SkinnedMeshRenderer(per-mesh material/visibility on the shared clone),BoneAttachment(pin a prop to a bone socket), andAnimationLibrary(merge clips from other GLBs into this rig's mixer, optionally retargeted). - 2D flipbook (
SpriteAnimator+.spriteanim.json) — plays named frame sequences by drivingRenderable2D.sprite; once/loop/pingpong.
The load-bearing split, everywhere: traits are pure data, the render sync owns the live THREE objects. SkeletalAnimator/SkinnedModel/Bone/AnimationLibrary never hold a mixer, action, or THREE.Bone — those live in RenderState.skinned keyed by entity id, so the trait serializes clean and hot-reloads.
Key files
runtime/animation/types.ts— the.anim.jsondata model:Keyframe,AnimationTrack,AnimationClipDef, tangent modes,STEPPED,normalizeAnimationClip.runtime/animation/curveEval.ts— pure curve math:evalTrack(weighted-bezier Hermite),evalColorTrack/evalBooleanTrack/evalSteppedTrack,applyTangentMode. No THREE, no ECS.runtime/animation/sampleClip.ts—applyClipAtTime(sample every track, name-path resolve, batch-write onto bound entities),applyClipAtTimeBlended,advanceClipTime. Shared by playback + scrub.runtime/ecs/entityIndex.ts— engine core, not animation: the per-frameEntityIndex(buildEntityIndex), relative name-path resolution (resolveTrackTarget) and the active-in-hierarchy predicate (isEntityActiveInHierarchy). Born here for clip binding, but the renderer, the sequencer and 2D deform all build the same index — so it lives in core and the dependency arrow points the right way (a physics/zone system can use the predicate without importing an animation module).runtime/animation/animationSystem.ts— the pipeline system that advancesAnimator.timeand poses.runtime/traits/{Animator,SkeletalAnimator,SkinnedModel,Bone,BoneAttachment,SkinnedMeshRenderer,AnimationLibrary,SpriteAnimator}.ts— the eight data traits (each doc-commented with its exact field contract).runtime/rendering/scene3DSync.ts— the skeletal engine:syncSkinnedModels,driveAnimator,mergeAnimationLibrary,syncBones,syncBoneAttachments,syncSkinnedMeshRenderers.runtime/animation/spriteAnimationSystem.ts— the 2D flipbook driver.runtime/loaders/{animationClipCache,animSetCache,spriteAnimCache}.ts— the three lazy asset caches (.anim.json/.animset.json/.spriteanim.json), all the same fetch-once-retry shape.
How it works
Keyframe clips (Animator + .anim.json)
A .anim.json (AnimationClipDef) is { id, name, duration, frameRate, loop, tracks[] }. Each AnimationTrack binds by { path, trait, field, type, keys[] }: path is a relative name-path from the Animator's entity ("" = the root itself, "body/arm" = descendants matched by EntityAttributes.name), so a clip is reusable across prefab instances — the Unity AnimationClip + Animator model. type is one of number | color | boolean | enum; values are always plain numbers on disk (color = packed 0xRRGGBB, boolean = 0|1, enum = the option index into the field's static option list).
Animator holds a NAMED LIST of clips and plays one at a time: { clips, clip, time, speed, playing, loop, activeClip }. clips is a JSON-string bank ([{ name, clip: guid, speed?, loop?, fadeDuration? }], decoded by animation/animClipBank.ts — the ONE decoder, shared by the play loop, the resource collector, and the tree-shaker, same pattern as AudioSource.clips); clip is the active clip NAME (empty → first entry), so switching tracks at runtime is entity.set(Animator, { clip: 'walk' }) — mirroring SkeletalAnimator / SpriteAnimator. resolveActiveClip(anim) maps the active name → its .anim.json GUID + per-clip overrides; activeClip (runtimeOnly) mirrors what's actually playing. The active clip is a name, NOT a fetchable ref, so the .anim.json GUIDs are collected from the clips bank explicitly in collectResourceRefsFromEntities + the tree-shaker's probeTraitRefs (Animator has no entry in REF_FIELDS_BY_TRAIT). Runtime switch API: the engine.playClip action (by name, guarded by animatorHasClip). Per-clip speed/loop override the trait-level fallbacks.
animationSystem (runs at SYSTEM_PRIORITY.ANIMATION, before transform propagation, so posed local transforms propagate the same frame) does two passes: it resolves the active clip, restarts time on an active-name change (instant cut — only between two known names, so the initial bind keeps an authored time), advances anim.time via advanceClipTime (loop-wrap or clamp against duration), collects {rootId, clip, t}, then builds the entity index once per frame and calls applyClipAtTime for each animator — so N animators cost O(N + entities), not O(N × entities).
Crossfade (fadeDuration). When the active clip changes and a fadeDuration applies (trait default or the incoming clip's per-clip override; 0 = instant cut = the behavior above), the switch captures the OUTGOING clip's name + playhead as the fade-from source (runtimeOnly fadeFrom / fadeFromTime / fadeElapsed, advanced each frame — the outgoing clip keeps moving as it fades so it blends mid-motion, not frozen). The blend weight ramps 0→1 over fadeDuration; while a fade is active the pose comes from applyClipAtTimeBlended (in sampleClip.ts) instead of applyClipAtTime. That path samples BOTH clips' RAW track values into a PoseMap, blends per field by type — numbers lerp (Transform rotation rx/ry/rz use a shortest-arc angle lerp so ±180° wraps the short way; there are no quaternion tracks, so coupled-axis nlerp is a deliberate non-goal), colors lerp per-channel, stepped boolean/enum snap to the dominant side — then coerces + writes once. The single-clip applyClipAtTime stays the allocation-free hot path; the blended path allocates but only runs during a fade. Two documented limitations: a field only ONE clip animates is applied at full strength (no captured bind pose to fade a mismatched track toward — harmless when both clips animate the same fields), and deform channels don't crossfade (the incoming clip drives them at full). A pause freezes the blend in place. The bone-layer re-pose (scene3DSync.applyBoneAnimators) mirrors the same blend so a bone-targeting clip switch crossfades too.
Switching clips at runtime (one API for all three animators). Animator, SpriteAnimator, and SkeletalAnimator all model "the active clip is a NAME", so a single engine action drives whichever the entity carries: engine.playClip (runtime/ui/engineActions.ts) — the unified twin of engine.toggleAnimator. It takes a clip NAME param and, per present trait, resets time + sets playing (keyframe/sprite, guarded by animatorHasClip/spriteAnimHasClip) or writes the name for the mixer to crossfade (skeletal — validated at the render layer, no synchronous guard). Bind it to a button, dispatch it from game code, or call the modoki_play_clip MCP tool. This is distinct from modoki_anim_set_clip, which edits a clip's DATA (the .anim.json), not which clip is active. The switchable NAMES are discoverable without opening the asset via switchableClipNames (animation/switchableClips.ts), surfaced as a clipNames field on the animator traits in get_scene_state (Percept).
The three animators deliberately keep different storage for their clip lists — unified only at the switch API, not the storage layer. Animator holds its list inline as the JSON clips bank (keyframe clip lists are light and entity-local, so an inline scalar keeps them serialize/undo/prefab-safe with no extra asset). SpriteAnimator/SkeletalAnimator reference a shared asset (.spriteanim.json / .animset.json over a GLB), because sprite sheets and rig clips are heavier and meant to be authored once and reused across entities. Harmonizing storage (e.g. an inline-bank Animator onto a shared .anim-set.json) is a much larger migration and a deliberate non-goal.
applyClipAtTime (in sampleClip.ts) resolves each track's target by name-path, evaluates the value via evalTrackValue, and batches field writes per (entity, trait) so a Transform animated on px/py/pz reads-and-writes once, not three spread-copies. It dirties the UI tree (markUIDirty) when it touches a UI trait — otherwise a UI clip plays in ECS but never repaints (the DOM only rebuilds on the dirty flag).
Curve evaluation (curveEval.ts) mirrors Unity's AnimationCurve: constant clamp outside the key range, weighted cubic-bezier Hermite between keys using each key's out/in tangents + weights, and a STEPPED (+Infinity) out-tangent holds the left value until the next key. evalSegment solves the time→parameter map with Newton + a real bisection fallback (bracketed, can't diverge to the wrong root) and clamps the tangent-weight sum to keep x(u) monotonic. applyTangentMode implements the right-click tangent menu (auto/linear/constant/free) and records the mode on the key.
3D skeletal (SkinnedModel + SkeletalAnimator)
SkinnedModel { model, isVisible } references the raw GLB (not a baked .mesh.json) and keeps the whole scene graph — bones, Skeleton, bind matrices, the GLB's AnimationClips — intact. It's loaded + scene-scope-refcounted through riggedModelCache. SkeletalAnimator is the desired playback state: { animSet, clip, playing, speed, loop, fadeDuration } plus runtime-read-back mirror fields (activeClip, time, normalizedTime, weight, effectivePaused — see Percept below).
syncSkinnedModels (once per frame, per 3D viewport) is the engine:
- Clone per entity —
SkeletonUtils.clone(prototype)gives each instance its own pose + Skeleton, added to the scene; aTHREE.AnimationMixeris built on the clone and oneAnimationActionper GLB clip cached inentry.actions. All this lives in aSkinnedEntryinRenderState.skinned, never in the trait. driveAnimator— resolves the desired clip (a.clip || entry.firstClip; warns once and falls back if the name isn't in the rig), crossfades on a clip change (crossFadeFromwith the incoming clip's fade), and each frame setspaused/timeScale/loop/clampWhenFinished. Per-clip params (speed/loop/fade) come from the entity'sanimSet; the trait's own fields are per-entity overrides — a field left at its trait default inherits the animset's per-clip value, a non-default value wins (resolveAnimSetParamsreturns engine defaults with no animset, so the formula collapses to "trait field always wins" in the legacy case).- Advance — every live mixer advances by
mixerAdvanceDelta: playing → engine visual delta (smoothed cadence ×timeScale, so skeletal respects pause/slow-mo/time-stop); stopped/paused → frozen (dt 0), except while the Animation editor previews (skeletalPreviewDelta). No wall-clock read — "not playing → no animation" — which is whyscene3DSyncleft the determinism wall-clock allowlist.
A SkinnedModel with no SkeletalAnimator autoplays its first clip on a loop.
Percept read-back: after advancing, syncSkinnedModels mirrors each rig's live mixer state onto its SkeletalAnimator's runtimeOnly fields (activeClip, time, normalizedTime = phase 0..1, weight, effectivePaused) so get_scene_state reports what's actually playing, not just the desired state. It also emits @anim-start/@anim-loop/@anim-finish journal events — but only from the primary surface (emitLifecycle), because the editor runs two viewports on one world and the journal is per-world.
Hand-posable bones (Bone + syncBones)
A Bone { name } entity is a node of a SkinnedModel's skeleton — a (transitive) child of the model-root entity, whose Transform is the bone's local transform. It's resolved to its rig by walking up parentId to the nearest ancestor with a SkinnedModel; name selects the THREE.Bone.
syncBones runs each frame after syncSkinnedModels, as a four-step bridge:
- read-back + baseline — capture every bone's posed transform as the baseline (in the entity's own pos/euler/scale representation). For a clip-driven bone while Playing, copy that baseline into the entity
Transform(so children under the bone follow, and a LateUpdate can layer). - layer (Play only) —
applyBoneAnimatorsre-poses any bone-targeting keyframeAnimatoron top of the mixer pose (Unity's override-layer / avatar-mask shape), then game LateUpdate systems run on top of that. - write-back — copy a bone's
Transformback into theTHREE.Boneonly if it diverged from its baseline (a per-bone dirty gate). Untouched clip-driven bones are byte-equal → skipped, so the mixer pose renders verbatim; a gizmo/Animator/LateUpdate/hand-pose edit shifts a component past the noise floor → written. - re-propagate — re-run transform propagation and re-place any renderable parented under a moved bone, so a sword in a hand tracks this frame, not one late.
So the THREE.Bone is the source of truth until something moves the entity Transform off baseline, then the Transform wins. A no-clip rig (SkinnedModel + Bone entities, no animation) has no read-back → the entity Transform is the pose and always writes back → dragging a bone deforms the mesh and sticks. Read-back never runs while Stopped, so the authored bind pose serializes clean.
Rig accessories
SkinnedMeshRenderer { node, materials, visible }— a child entity of the rig root that configures one mesh node of the shared clone (it adds no THREE objects of its own — the skeleton stays a single instance).nodeis the GLB mesh-node name;materialsmaps original material-slot NAME → a.mat.jsonguid (a node reuses a few materials across many primitives — the 148-primitive eyes collapse to 2 slots).syncSkinnedMeshRenderersresolves the root viaparentIdand applies overrides + visibility, restoring the baked material when a slot is cleared. A rig root with no renderer children renders the GLB's baked materials (back-compat); the import pipeline expands a GLB into root + renderer entities as a generated prefab.BoneAttachment { target, bone }— an Unreal-style socket. Put it on a normal Renderable3D+Transform entity;syncBoneAttachmentsfindsboneintarget's animated skeleton and drives the entity to the bone's world position+rotation, seating the prop via its own Transform (position = world-unit offset rotated into the bone's orientation, rotation composes, scale is the prop's own — never inherited from the rig, so a prop stays its authored size on a heavily-scaled model). Cheap:O(attachments), notO(bones)— the bones stay plain THREE objects, never ECS entities.AnimationLibrary { animSets, retarget, boneMaps }— extra skeletal clips a rig can play that live in other GLBs (Unity's shared clip library / Animator Override). Put it on the rig root next toSkeletalAnimator.mergeAnimationLibrarybuilds the mixer's actions as own clips ∪ library clips, own clips winning on a name conflict: each.animset.json'ssourceGLB is loaded viariggedModelCacheand its clips bound into this rig's mixer by track/bone name (cheap + correct for a shared skeleton).retarget:true(or a non-emptyboneMapsentry) runs each clip throughSkeletonUtils.retargetClipfirst — for a source rig with a different bind pose/proportions, or differently-named bones (boneMaps[animSetRef] = { targetBone: sourceBone }) — which is what makes a foreign clip pack (e.g. Mixamo) play on your own rig. Merge is lazy + idempotent (a source whose GLB hasn't loaded is retried next frame; once merged it's recorded inentry.libraryMerged).
Note effectiveLibrary: the SkeletalAnimator's own animSet is appended to the library's animSets, so assigning an animSet to the animator brings its source GLB's clips into a bare rig, not only per-clip params.
2D flipbook (SpriteAnimator + .spriteanim.json)
SpriteAnimator { clipSet, clip, time, playing } goes on the same entity as a Renderable2D. The clips live in a reusable .spriteanim.json holding multiple named SpriteClips ({ frames: sprite-slice-guid[], fps, mode, cycles }, mode ∈ once|loop|pingpong, cycles 0 = infinite). The trait plays one at a time, chosen by clip (empty → first).
spriteAnimationSystem (at SYSTEM_PRIORITY.ANIMATION, advancing on the visual delta, skipped per-entity when Paused) computes step = floor(time·fps), maps it to a frame via the shared spriteIndexFromStep math (the same loop/pingpong logic the GPU particle sprite-sheets use), and writes Renderable2D.sprite to that slice GUID. Scene2D's per-entity spriteRef change-detection rebuilds the framed texture. The frame is (re)applied every tick before advancing, so frame 0 shows at time 0 and an externally-set time (scrubbing) resolves correctly even while paused. characterAnimationSystem sits on top of this: it maps a 2D platformer's controller state (idle/walk/jump) to clip names and flips facing via Renderable2D.flipX.
The three asset caches
animationClipCache (.anim.json), animSetCache (.animset.json), and spriteAnimCache (.spriteanim.json) are all the same shape: first access kicks off a lazy fetch, returns null/undefined until it resolves (the per-frame driver simply retries next frame), resolves GUIDs through the asset manifest, and lets the editor seed/invalidate by path for live preview. A failed fetch is remembered and NOT retried at runtime — only invalidate/clear resets it. All three are plain DATA (nothing to GPU-dispose); clear*Cache bumps a generation so an in-flight load from a swapped-away scene is dropped.
Gotchas
- STEPPED (
+Infinity) doesn't survive JSON —JSON.stringify(Infinity) === "null", so a saved stepped key would reload as linear. The persistent marker istangentMode:'constant';normalizeAnimationClipreconstructsoutTangent = STEPPEDfrom it on load. If you build a clip in code, set the mode, not just the raw tangent. - A missing/NaN tangent means flat (0), a genuine
+Infinitymeans hold —normTangentkeeps the two distinct. Legacy/partial keys with an absentoutTangentmust read as 0, orevalTrack(which treats non-finite as STEPPED) andevalSegment(which reads 0) would disagree. - Animating a UI trait needs the dirty flag —
applyClipAtTimecallsmarkUIDirty()when it writes a UI trait; a bareentity.seton a UI field won't repaint on its own. SkeletalAnimator.speed/loop/fadeDurationare OVERRIDES, and "equals the trait default" is the sentinel for "inherit the animset value."ANIMSET_DEFAULTSinanimSetCacheMUST stay equal to the trait defaults (speed:1, loop:true, fadeDuration:0) — that equality is the inherit switch. Change one without the other and every entity silently stops inheriting animset params.SkinnedMeshRenderer.materialsslot keys are material NAMES, not indices — a node reuses a few materials across many primitives, so the override map keys on the material's.name. A slot left unset keeps the baked GLB material; clearing an override restores the exact baked element.- The bone bridge bakes a wrapper prefix for ROOT bones only — a Blender/FBX export puts a non-bone "Armature" (Z-up→Y-up rotation + 100× scale) above the skeleton. A root bone's entity
Transformlives in clone-root space, so read/write-back multiply byboneWrapperPrefix.fwd/.inv; child bones use parent-local TRS directly. Without this a root bone collapses ~100× small at the origin every frame. - Write-back is per-bone dirty-gated, not global — the old
playing && hasClipecho round-tripped the mixer pose through compose→decompose every frame, dropping the shear a wrapper-baked non-uniform scale produces → visible jitter on a fast clip. Now an untouched clip-driven bone is skipped (mixer pose verbatim) and only bones a layer actually moved write back; touching one bone never drags its clip-driven siblings through the lossy echo. retargetClipsilently drops scale (and non-hip position) tracks — it resamples only position(hip)+quaternion per bone.carryOverScaleTracksre-attaches the source clip's.scaletracks (renamed via the inverted bone map) so a scale-only shrink/stretch clip still animates on a retargeted rig. It also emits skeleton-relative track names (.bones[Name].prop) that only bind to a SkinnedMesh; the merge rewrites them to node-name form (Name.prop) because the mixer drives the clone's root Group.get_scene_stateshows the desired clip until you read the runtime fields —SkeletalAnimator.clipis the request;activeClip/normalizedTime/weightare the mirrored live mixer state. A bare-rig sourcing clips from anAnimationLibraryhas an empty action set for the first frames (GLB lazy-loading), which is whydriveAnimatorsuppresses its "clip not found" warning until the rig actually has clips.- A failed asset fetch is not retried — a typo'd/500'd
.anim.json/.animset.json/.spriteanim.jsonis remembered infailedand stays broken until an invalidate/clear (or scene swap). It won't self-heal by waiting.
Related
- 2d-skinning.md — 2D sprite skinning (bones deforming a mesh:
SkinnedSprite2D,Rig2DPart, deform tracks, theBillboard3D/FlatSprite3D2.5D bridge). ThedeformTracksonAnimationClipDefandapplyClipDeformbelong to that doc, not this one. - rendering.md — the 3 render layers,
scene3DSync, the WebGPU renderer. - engine-concepts.md — trait/system/projection vocabulary; the "traits are data, the sync owns the live objects" split.
- prefabs.md — how the import pipeline expands a rigged GLB into root +
SkinnedMeshRendererentities as a generated prefab. CLAUDE.md— "Time, Determinism & the Verification Harness" (why skeletal keys off play state and the visual delta, never the wall clock) and the Percept/modoki_watchtools for tuning motion feel.