Animator
Skeletal animation for 3D characters and models: play a GLB's clips, blend them by speed, layer one
over another. Every Model carries an Animator at model.anim; any node tree can take
one (node.aspect(Animator)). Tweening a value is animate(); 2D sprite-sheet
clips are SpriteAnimation.
- play a character's idle, walk and run animations
- blend walk into run by the character's speed
- play an attack or a jump once, then go back to idle
- wait until an animation finishes before starting the next
- use Mixamo clips from separate GLB files
- fire a hit event at a moment of an attack clip
- aim with the upper body while the legs keep walking
- move the character by the clip's root motion
Platforms: iOS, Android, desktop, the web, the web player, the le.codes viewer app, headless
tests. On the web (Web Lite) and in headless tests play({ turn }), play({ start, end }) and
layer.anticipate() do nothing: the whole clip plays, with its own turn and no wind-up.
At a glance
const main = async () => {
const scene = new Scene()
const hero = await Model.load(asset('./hero.glb')) // clips: Idle, Walk, Run, Jump, Kick, Aim
scene.add(hero)
scene.open()
hero.anim.playLoop('Idle', { fade: 0.2 }) // what it rests on
await hero.anim.play('Jump', { fadeIn: 0.1, fadeOut: 0.2 }) // a one-shot over it; back to Idle after
// a 1D blend as the loop: its value is a position on the axis (here: the speed, m/s)
const gait = hero.anim.playLoop({ Idle: 0, Walk: 1.5, Run: 5 }, { fade: 0.2 })!
gait.value = 3
// clips from other files (Mixamo: one GLB per animation), derived clips, events
const slash = await AnimationClip.load(asset('./attacks.glb'), 'Slash')
hero.anim
.addClip('slash', slash)
.addClip('kick', AnimationClip.from(hero.anim.clip('Kick')!, { from: 0.2, to: 1.1 }))
slash.addEvent(0.4, 'hit') // seconds into the clip
hero.anim.on('hit', (clip) => console.log(`${clip} landed`))
// a chain: the await resolves at the clip's end; the next clip transitions from its last pose
if (await hero.anim.play('slash', { fadeOut: 0.15 })) {
await hero.anim.play('kick', { fadeIn: 0.15, fadeOut: 0.3 }) // then back to the loop over 0.3
}
hero.anim.addLayer({ mask: 'Spine1' }).playLoop('Aim', { fade: 0.3 }) // arms aim, legs keep the gait
hero.anim.rootMotion = true // travelling clips move the node
}
main()How playback works: a loop and one-shots
The model is deliberately small. A layer has two kinds of things on it:
- its loop: what it shows when nothing else happens: one looping clip, or a blend of looping
clips (Idle / Walk / Run along a speed axis).
anim.playLoop(…)sets it (anim.loopreads it); setting another one transitions to it. - one-shots:
anim.play(…), a clip played once over the loop, returning to it when it ends.
Any call takes over from what the layer shows, over its own fade. That's the whole state machine. The engine runs the per-frame work (transitions, blend spaces, hand-overs, root motion); the SDK issues calls and hands you promises. What runs on top of the clips has pages of its own: IK and feet (limbs on targets, planted feet, footsteps), Locomotion (a character's movement, stride warping) and DynamicBone (tails, hair, cloaks).
Load clips, also from other GLB files — AnimationClip
AnimationClip.load(source, clip?: string | number): Promise<AnimationClip> // one clip of a GLB (default: its first)
AnimationClip.loadAll(source): Promise<Record<string, AnimationClip>> // every clip, by name
AnimationClip.fromCurves({ duration?, tracks }): AnimationClip // curves in code, bound by node NAME
AnimationClip.from(clip, { mirror?, from?, to? }): AnimationClip // DERIVED: the mirror, a window (seconds, re-timed to 0), or both
clip.addEvent(seconds, name): this // a moment on the clip's timeline
clip.name / clip.duration / clip.trackCount // source name, seconds, animated (node, channel) tracks
anim.clips // AnimationClip[] — the GLB's clips in file order, then the ones you added
anim.clip('Run') / anim.clip(0) // by name / by index into clips (undefined = none)
anim.addClip('slash', slash) // add under a name (default: clip.name) — chainable; an embedded name is replaced in placeA clip is immutable data whose tracks target bone names; an Animator binds it to its own hierarchy
when first used (a clip whose tracks match no node there warns). load takes an asset() path or a
fetch response and rejects when the clip is not in the file (the message lists the clips). Unnamed
embedded clips are animation_0, animation_1, …; clip.name is the name in the source file — the
Animator addresses a clip by the name you added it under.
Clip events: a hit at a moment of a clip — clip.addEvent
Events belong to the clip: addEvent marks a moment in seconds (clamped to the clip) and fires
anim.on(name, (clipName, layer) => …) in every animator that plays it, loops included; clipName
is the name the animator plays it under. A window (from(clip, { from, to })) keeps the events
inside it, re-timed, and keeps the source's name.
Mirror a clip left ↔ right — AnimationClip.from({ mirror })
mirror: true is the clip on the other side of the body — left ↔ right on the rig of the clip's
own file (joints pair by name, the sagittal plane comes from the rest pose), so the result is the
same on every model: its foot contacts swap sides, its baked heading turns the other way, its name
is <name>_M. A stop that brakes on the left foot becomes the one that brakes on the right —
register it under a name and it is a clip like any other
(anim.addClip('Stop_M', AnimationClip.from(stop, { mirror: true }))). Only clips from a GLB carry a
rig; a curve-built clip cannot be mirrored (from throws).
Keyframes in code, on any node — AnimationClip.fromCurves
fromCurves builds a clip without a DCC: tracks maps a node name to its curves — position /
scale keys [seconds, [x, y, z]], rotation keys [seconds, [x, y, z, w]] (a quaternion), plus
interpolation: 'linear' | 'step' (default linear). duration defaults to the last key; the clip's
name is 'clip'. It animates any node tree, not only a skeleton:
const rig = new Node().aspect(Animator)
const box = Mesh.box({ size: 0.2 })
box.name = 'Box' // tracks bind by name
rig.add(box)
const bob = AnimationClip.fromCurves({ tracks: { Box: { position: [[0, [0, 0, 0]], [1, [0, 1, 0]]] } } })
rig.anim.addClip('bob', bob).play('bob') // box.y 0 → 1 over a second, then holdsIdle, walk, run: looping clips and speed blends — anim.playLoop()
anim.playLoop('Idle', { fade?, speed?, phase? }): Loop | undefined // one clip (undefined = nothing to loop, warned)
anim.playLoop({ Idle: 0, Walking: 2, 'Fast Run': 6 }): Loop // a 1D blend — number positions
anim.playLoop({ idle: [0, 0], fwd: [0, 1], left: [-1, 0] }): Loop // a 2D blend — [x, y] positions
anim.playLoop({ Walk: { at: 1.5, offset: 0, cycles: 1 }, Run: { at: 5, offset: 0.5, cycles: 2 } }) // members with their gait cycle
anim.loop // the current one (undefined = none) — a read, not a call
loop.value = 3.2 | [x, y] // position on the axis / plane — live, even under a one-shot
loop.weights // { Idle: 0.6, Walking: 0.4, … } — each member's share of the pose now
loop.phase // 0–1 through the gait cycle (0 = a left-foot-down, 0.5 = a right); settable- A blend's members loop in phase with each other (one shared clock, weighted cycle length — Walk
and Run keep their feet aligned);
valuepicks the mix: 1D linear between the two neighbours (value clamped to the range), 2D gradient-band (exact at the samples, smooth between). - A member given as
{ at, offset, cycles }carries its gait cycle — its phase at t = 0 (0 = a left-foot-down, 0.5 = a right-foot-down) and how many cycles the clip holds (a two-stride loop: 2);anim.clipInfo(clip).cycle()measures both. A bare position runs in normalized time, with no cycle. fadedefaults to 0 (cut):playLoop(next, { fade })transitions from whatever the layer shows — the previous loop, or a one-shot still playing (it is cut short, its promise resolvesfalse).speedis the loop's rate (default 1).phaseenters the cycle there (0–1), or at'match'— the phase the layer shows now, so a gait taking over from a start clip keeps its feet.stop({ fade })removes the loop (and any one-shot) → the layer fades to rest. A layer without a loop holds a one-shot's last frame when it ends.- The
Loopobject returned by the call that set it is the handle; once replaced it goes dead (itsvaluewarns).
after stop(), the clips of the loop it removed still fire their clip events every
cycle, and play() still refuses them as members of the layer's loop (warns). Replace a loop with
playLoop(next) rather than stopping it when either matters.
Play a clip once (attack, jump) and wait for it — anim.play()
const p = anim.play('slash', { fade?, fadeIn?, fadeOut?, speed?, restart?, start?, end?, phase?, turn? }) // → Playback
await p // true = reached its hand-over (its end); false = cut short (replaced / stopped)
p.done // the same promise — return THIS from async functions, put it in Promise.all
p.clip / p.layer / p.time / p.progress / p.playing / p.weight // time in seconds, progress 0–1
p.phase / p.turned / p.turnLeft / p.travelled // gait phase 0–1 (-1 = none), root yaw so far / still to come (rad, + = left), root travel so far (m)
p.seek(t) / p.stop({ fade })
anim.busy // a one-shot hasn't handed over yet
anim.active // what every layer shows this frame: [{ name, clip, weight, time, loop }]
anim.time('slash') / anim.seek('slash', t) // a clip's playhead, seconds (-1 = not bound) / scrub it (pair with speed = 0)
anim.stop({ fade }) // everything, every layer → rest pose- Fades default to 0 (cut):
fadesets both ends,fadeIn/fadeOuteach one. A fade is an inertialized transition, not a crossfade: the new clip shows at once and the difference to the pose the layer showed (and its velocity) decays over the fade time. The loop gives way to the clip overfadeIn; after the clip's last frame it comes back overfadeOut. - Hand-over. The clip plays to its end — nothing is cut — and the promise resolves there.
Whatever you start right after the
awaitis what its last pose transitions into: anotherplay()(a chained one-shot),playLoop(next), or nothing (back to the current loop overfadeOut). A cancel window (start the next attack before this one has recovered) is game logic — a clip event,p.progress, or simplyplay(next)whenever the game says so — never a fade. start/end— play a window of the clip, in seconds of the clip: enter atstart(when the play rewinds — seerestart) and finish atend: the hand-over fires there, a loopless layer holds that frame, events beyond it never fire. The swing without the recovery, one move out of a packed take. A window that lives in the clip table belongs inAnimationClip.from(clip, { from, to });playLoophas nostart— its entry point isphase, a windowed loop is a derived clip.speedis this clip's rate (default 1);phaseenters a gait clip at a point of its cycle (0–1, or'match'— see loops; clips without a cycle ignore it).turn(degrees, + = left) — the TURN WARP: the node turns this much in total over the clip instead of what the clip's baked heading says (a 45° start played withturn: 60turns 60). NeedsrootMotion+rootRotationand a clip with a baked heading (lecodes assets marks/retarget); a clip that turns under 10° is left alone.anim.setTurn(clip, deg)re-aims it mid-play (undefined= off).- A new
play()always takes over; to not restart an attack mid-swing, guard withbusy:if (!anim.busy) anim.play('slash'). restart→ rewind even if the clip is already playing (default: rewind only if it isn't; a clip that already handed over always rewinds).- A clip that is a member of the layer's loop can't be a one-shot (warns) — use
playLoop(). An unknown name warns and returns a Playback that resolvesfalse. - Returning a
Playbackfrom anasyncfunction flattens it intoPromise<boolean>; returnp.doneor make the function sync when the caller needs the handle.
Upper-body and additive layers — anim.addLayer()
const upper = anim.addLayer({ mask?: 'Spine1' | ['LeftArm', 'RightArm'], additive?: boolean, weight?: number })
upper.playLoop(…) / upper.play(…) / upper.stop(…) / upper.loop / upper.busy / upper.active / upper.phase // the same verbs, scoped
upper.weight = 0.5 // contribution 0–1 (default 1)
upper.anticipate(0.5) // the NEXT play / playLoop on this layer starts with a wind-upThe Animator's own playLoop/play/stop are its base layer; layers stack over it in creation order. A
mask restricts a layer to the named bones' subtrees (override the upper body while the legs keep
the blend; a name not found under the model warns). additive: true blends each clip's delta from
its own first frame on top of the layers below (a recoil, a breath, a lean — author frame 0 as
neutral). A layer fades in over its first transition and, stopped, fades out to the layers below.
anticipate(amount) makes the next transition start with every joint moving against the new
clip's motion (amount × its velocity; 0.3–1 reads as a natural wind-up; ignored by hosts without
it). The object is the layer's only handle.
Slow motion, events and other animator settings — anim.speed, anim.on
anim.on('hit', (clip: string, layer: Layer) => …) / anim.off(…) // clip events (clip.addEvent) — chainable
anim.speed = 0.3 // global rate: slow-mo; 0 = pause
anim.rootMotion = true // see below
anim.rootRotation = true // with rootMotion: the root's turn moves the node too
anim.phase // where the base layer is in the gait cycle, 0–1 (-1 = no cycle)
anim.motion = velocity // the body's world velocity (m/s), for the stride warp — see Locomotion
anim.lod = 'full' // 'auto' (default) | 'full' — see Model, level of detail
anim.sockets(bone, { … }) // named frames on a bone, for anchored IK — see IK and feetRoot motion: the clip moves the character — anim.rootMotion
With rootMotion = true the root bone's horizontal travel comes off the pose (the character stays
in place in its own frame) and moves the node instead — or, when the node or an ancestor has a
CharacterController, feeds it as a velocity so the travel collides and slides like
any movement. The vertical part stays on the bone (a jump's height is the clip's). For clips whose
hips actually travel: Mixamo without "In Place", lunges, rolls; in-place locomotion contributes
nothing, so both kinds compose in one animator. rootRotation (off by default — a walk's hip sway is
a turn too) makes the root's yaw root motion as well, so a turn clip leaves the node facing where it
took it; it is for rigs whose root carries the heading (lecodes assets retarget --root-rotation yaw).
Measure a clip: travel, speed, foot contacts — anim.clipInfo()
const info = anim.clipInfo('Walk')! // undefined = no such clip; binds it on first ask
info.travel / info.speed / info.turn // root travel (m), mean root speed (m/s), total root yaw (rad, + = left)
info.inPlace / info.cyclic / info.hasPhase
info.contacts // [{ side: 'left' | 'right', from, to, at: [x, y, z], still? }] — seconds, model space
info.cycle() // { offset, cycles, residual, steps } — what a blend member's { offset, cycles } wants
info.phaseAt(t) / info.travelAt(t) / info.turnAt(t) / info.speedAt(t) / info.directionAt(t) // the baked curves at clip time t (s)What the engine measured on the clip once bound to this skeleton (unlike anim.clip(), which is
the file's data): the root's travel and turn, the foot contacts (from the clip's marks, or classified
from the motion), the gait phase. The rest of ClipInfo — hands, treads, pelvisYawAt,
timeAtTurn, fitTo / exitTo / alignTo (how far one clip's pose is from another's), kneePoleAt,
physicsAt, featuresAt — is the toolkit a locomotion controller is built from; the types say what
each returns.
Pitfalls
// ✗ a looping one-shot
anim.play('Run', { loop: true }) // no such option
// ✓ anim.playLoop('Run')
// ✗ a shorter fadeOut to skip a clip's recovery — fadeOut is the transition AFTER the end, it cuts nothing
anim.play('slash', { fadeOut: 0.05 })
// ✓ anim.play('slash', { end: 0.6, fadeOut: 0.2 }) — or AnimationClip.from(slash, { to: 0.6 }) in the clip table
// ✗ normalized event times
clip.addEvent(0.4, 'hit') // this is SECONDS
// ✓ clip.addEvent(0.4 * clip.duration, 'hit') for "40 % in"
// ✗ driving a replaced loop
const a = anim.playLoop('Idle'); anim.playLoop('Crouch'); a.value = 1 // a is dead (warns; anim.loop is the live one)Platform notes
One evaluator (creator-anim) runs everywhere the 3D engine does: natively on the desktop, mobile and
windowless (--sim) hosts and inside the web player's runtime module; as a separate wasm build on the
web (Web Lite fetches it when the bundle calls the animation API) and under the headless renderer
(lecodes render / lecodes test without a host flag). That wasm build has no turn warp, play window
or anticipation — see the platform line.
See also
- Model — loading,
model.anim, level of detail (model.lod,anim.lod) - IK and feet — two-bone / look-at chains, sockets, the foot lock, footsteps
- Locomotion — a character's movement; stride and orientation warping
- DynamicBone — secondary motion on bone chains
- Node — hierarchy,
node.bone(), aspects - Physics & shapes —
CharacterController(root motion's collider)