Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Animation

Flint’s animation system provides two tiers of animation through the flint-animation crate: property tweens for simple transform animations defined in TOML, and skeletal animation for character rigs imported from glTF files with GPU vertex skinning.

Tier 1: Property Animation

Property animations are the simplest form — animate any transform property (position, rotation, scale) or custom float field over time using keyframes. No 3D modeling tool required; clips are defined entirely in TOML.

Animation Clips

Clips are .anim.toml files stored in the demo/animations/ directory:

# animations/door_swing.anim.toml
name = "door_swing"
duration = 0.8

[[tracks]]
interpolation = "Linear"

[tracks.target]
type = "Rotation"

[[tracks.keyframes]]
time = 0.0
value = [0.0, 0.0, 0.0]

[[tracks.keyframes]]
time = 0.8
value = [0.0, 90.0, 0.0]

[[events]]
time = 0.0
event_name = "door_creak"

Interpolation Modes

ModeBehavior
StepJumps instantly to the next keyframe value
LinearLinearly interpolates between keyframes
CubicSplineSmooth interpolation with in/out tangents (matches glTF spec)

Track Targets

Each track animates a specific property:

TargetDescription
PositionEntity position [x, y, z]
RotationEntity rotation in euler degrees [x, y, z]
ScaleEntity scale [x, y, z]
CustomFloatAny numeric component field (specify component and field)

Animation Events

Clips can fire game events at specific times — useful for triggering sounds (footstep at a specific frame), spawning particles, or notifying scripts. Events fire once per loop cycle.

Attaching an Animation

Add an animator component to any entity in your scene:

[entities.platform]
archetype = "furniture"

[entities.platform.transform]
position = [2.0, 0.5, 3.0]

[entities.platform.animator]
clip = "platform_bob"
autoplay = true
loop = true
speed = 1.0

The animation system scans for .anim.toml files at startup and matches clip names to animator components.

Tier 2: Skeletal Animation

For characters and complex articulated meshes, skeletal animation imports bone hierarchies from glTF files and drives them with GPU vertex skinning.

Pipeline

glTF file (.glb)
  ├── Skin: joint hierarchy + inverse bind matrices
  ├── Mesh: positions, normals, UVs, joint_indices, joint_weights
  └── Animations: per-joint translation/rotation/scale channels
         │
         ▼
  ┌──────────────────────┐
  │   flint-import        │  Extract skeleton, clips, skinned vertices
  └──────────┬───────────┘
             │
  ┌──────────▼───────────┐
  │   flint-animation     │  Evaluate keyframes → bone matrices each frame
  └──────────┬───────────┘
             │
  ┌──────────▼───────────┐
  │   flint-render        │  Upload bone matrices → vertex shader skinning
  └──────────────────────┘

How It Works

  1. Importflint-import extracts the skeleton (joint hierarchy, inverse bind matrices) and animation clips (per-joint keyframe channels) from glTF files
  2. Evaluate — each frame, flint-animation samples the current clip time to produce local joint poses, walks the bone hierarchy to compute global transforms, and multiplies by inverse bind matrices to get final bone matrices
  3. Render — bone matrices are uploaded to a per-entity GPU storage buffer. The skinned vertex shader transforms each vertex by its weighted bone influences. Because each entity owns its buffer, two entities instancing the same skinned asset animate independently: a crowd of the same model no longer shows whichever skeleton uploaded last.

The importer handles all three glTF interpolation modes. CUBICSPLINE samplers store three outputs per timestamp (in_tangent, value, out_tangent) and are consumed as triples; a sampler with fewer than 3 x timestamps outputs warns and degrades to LINEAR. Rotation tracks are also made hemisphere-continuous at clip load: Blender exports adjacent keys as q then -q on large joint rotations, and the Hermite curve between them would collapse through zero and snap the joint. Such keys (value and both tangents) are negated so the curve stays on one side of the sphere.

Skinned Vertices

Skeletal meshes use a separate SkinnedVertex type with 6 attributes (vs. 4 for static geometry), avoiding 32 bytes of wasted bone data on every static vertex in the scene:

AttributeTypeDescription
positionvec3Vertex position
normalvec3Vertex normal
colorvec4Vertex color
uvvec2Texture coordinates
joint_indicesuvec4Indices of 4 influencing bones
joint_weightsvec4Weights for each bone (sum to 1.0)

Crossfade Blending

Smooth transitions between skeletal clips (e.g., idle to walk) use crossfade blending controlled by the animator component:

[entities.character.animator]
clip = "idle"
playing = true
loop = true
blend_target = "walk"      # Crossfade into this clip
blend_duration = 0.3       # Over 0.3 seconds

Blending uses slerp for rotation quaternions and lerp for translation/scale, producing smooth pose interpolation.

The engine clears blend_target when the crossfade completes. It is a request, not a state — once the fade lands, the field is retired and the clip you faded into becomes the plain clip. Scripts must not treat a non-empty blend_target as “currently blending to X” beyond the fade, and must not re-assert it every frame: a target that never retires re-arms its own crossfade forever, and the clip plays only its first blend_duration seconds on loop.

Calling blend_to with the clip that is already playing is a deliberate restart, not a no-op. That is what lets a held key chain discrete steps — each press re-triggers the same clip from the top.

flint edit <model.glb> plays clips directly and never goes through the blend path, so a clip that previews correctly can still be broken in play. Verify crossfades in flint play.

Animation Layers

Layers run extra clips on their own clocks and compose them onto the base pose, in array order, after any crossfade. Each layer has a weight, a mode, and an optional bone mask:

[entities.starthing.animator]
clip = "WalkCycle"
layers = [
  { clip = "StarCower", weight = 1.0, mode = "additive", mask = "head" },
]
KeyTypeDefaultDescription
clipstring“”Clip to play on this layer (empty = inactive slot; indices stay stable)
weightf321.0Live dial, 0 = off
modestring“additive”additive or override (see below)
maskstring“”Root joint name; the layer only touches that joint and its descendants. A name that is not in the skeleton masks out every joint, so a typo makes the layer contribute nothing rather than everything
speedf321.0Multiplier on the entity’s base speed
fade_targetf32Weight to ramp toward (see the fade section below)
fade_durationf320.0Seconds for the ramp; the engine zeroes it when the ramp lands

Layer indices run from 0 to 254; the script API silently ignores anything at 255 or above (layer IDs travel as a byte in runtime bookkeeping).

Additive layers contribute each keyed joint’s delta from rest, scaled by weight. They suit overlays authored as “rest plus a gesture” — a breathing chest, the starthing’s cowering star-arms. Override layers blend each keyed joint toward the clip’s pose by weight — “upper body aims while the legs keep walking”, usually paired with a mask like mask = "spine".

Either way a layer only touches joints its clip actually keys (composing identity onto an un-keyed joint would corrupt one whose rest rotation is not identity), and because layers are composed after blending, they survive base crossfades — the character keeps breathing through the transition from idle to walk rather than holding its breath for 0.3 seconds.

Layers are ordered: an additive layer under an override is replaced where the override keys; an additive layer on top of an override adds to it.

The older single-layer fields still work and are treated as layers[0]:

layer_clip = "breathe"     # legacy alias for layers = [{ clip = "breathe", weight = ... }]
layer_weight = 1.0

When layers is non-empty it wins and the legacy pair is ignored. The script API (set_anim_layer & co.) migrates the legacy pair into layers[0] the first time it touches an entity.

Previewing layers

flint edit <model.gltf> has a Layers stack under the timeline: add rows, pick a clip per layer, drag weights (they work while paused), flip Add/Over, choose a mask joint, and solo/mute rows. The Skeleton colour combo under View paints the armature overlay and the node tree:

  • Last writer — yellow = base clip, one colour per layer, grey = rest pose
  • Ln weight — grey → layer colour by the weight that layer applied
  • Ln mask / Ln keyed joints — which bones the mask / clip reaches

Hover a joint in the node tree for a per-layer breakdown. The same setup can be rendered headlessly:

flint edit models/starthing.gltf --render out.png --clip WalkCycle --layer StarCower:1.0:head
# --layer clip[:weight[:mask[:mode]]], repeatable, in order

Layer fades

A layer’s weight is a live dial, so a script that sets it pops the pose. To ramp instead, set fade_target and fade_duration on the layer table (or call fade_anim_layer(entity, index, weight, seconds)). The engine owns the weight while the ramp runs: it writes the ramped value back to layers[i].weight every frame and zeroes fade_duration when it arrives, so the next sync_from_world doesn’t re-arm it — the same contract as blend_target. Any plain set_anim_layer_weight cancels a ramp in flight. The previewer’s Layers row shows → target (seconds left) while a fade runs.

Sequences

A *.sequence.toml is a list of timestamped animator events — everything the script API can do to an animator, written down with times so it can be played, scrubbed and rendered identically in the previewer and the player:

name = "starthing_showcase"
# duration = 6.0   # optional; default = last event time + its transition
loop = false

[[events]]
time = 0.0
kind = "blend"            # crossfade the base clip (duration 0 = hard cut)
clip = "BreathingIdle"
duration = 0.0

[[events]]
time = 1.5
kind = "blend"
clip = "WalkCycle"
duration = 0.4

[[events]]
time = 2.5
kind = "layer"            # set a layer slot; omitted keys keep their value
index = 0
clip = "StarCower"
mode = "additive"
mask = "head"
weight = 1.0
fade = 0.3                # ramp the weight over 0.3 s (omit = instant)

[[events]]
time = 4.0
kind = "speed"
value = 1.3

[[events]]
time = 5.5
kind = "layer"
index = 0
weight = 0.0
fade = 0.4

[[events]]
time = 6.0
kind = "cue"              # named marker for scripts
name = "done"

Event kinds: blend { clip, duration }, layer { index, clip?, weight?, fade, mode?, mask? }, speed { value }, cue { name }. Events are sorted by time (stable, so same-time events keep authored order) and each fires exactly once when the playhead reaches its time. Sequences run before the skeletal tier each frame, so their writes land the same frame.

A looping sequence fires everything up to min(time, duration), then wraps, re-arms every event (including those at t = 0) and fires from zero again, repeating while the frame still overruns. A large dt therefore neither skips events nor delays the next pass by a frame: a 1 s loop with a cue at 0.8 s, advanced by 0.5 then 0.6, fires start, tail, start. A looping sequence whose resolved duration is zero is rejected at load (looping sequence has zero duration); a non-looping one with only t = 0 events fires them and completes on its first advance.

Base-clip changes always go through blend_target (a tracked skeletal entity never re-reads clip); a duration of 0 is clamped to 1 ms because the crossfade path ignores non-positive durations.

Previewing a sequence

flint edit models/starthing.gltf --sequence animations/starthing_showcase.sequence.toml
flint edit models/starthing.gltf --sequence animations/starthing_showcase.sequence.toml \
    --render t3.png --anim-time 3.0        # headless: pose at t = 3 s

The bottom panel gains a Sequence section: play/pause (P), Restart (R / Home), a Loop toggle (--sequence-loop sets it from the CLI; ticking it after the end restarts), a seek slider, and a marker strip with one tick per event (orange = blend, layer colour = layer, grey = speed, cyan = cue; dim = not yet fired; hover for details, click to seek). Seeking and --anim-time both replay the sequence from t = 0 in 1/120 s steps after restoring the animator to its pre-sequence state, so the pose at any time is deterministic — rendering the same --anim-time twice yields identical pixels. Cues fired during playback print [sequence] cue '<name>' at <t>s.

Sequences at runtime

The player loads every *.sequence.toml from the scene’s animations/ directory (or ../animations/) and registers it by name. Scripts start one with play_sequence(entity, name) and stop it with stop_sequence(entity); both just write animator.sequence, so a scene can also autoplay one:

[entities.hero.animator]
clip = "Idle"
playing = true
sequence = "intro_bow"

When a non-looping sequence ends the engine clears animator.sequence, so play_sequence with the same name is a fresh start. Cues reach the owning entity’s script:

#![allow(unused)]
fn main() {
fn on_sequence_cue(sequence, cue) {
    if cue == "done" { play_sequence(self_entity(), "idle_loop"); }
}
}

Rest Poses

Pose buffers are seeded from the glTF bind-local TRS, not from identity. This matters for sparse clips: a clip that keys only an arm would otherwise collapse every un-keyed limb onto its parent, and your character would fold up the moment it played.

Skeleton Schema

The skeleton component references a glTF skin:

[entities.character.skeleton]
skin = "Armature"           # Name of the glTF skin

Entities with both animator and skeleton components use the skeletal animation path. Entities with only animator use property tweens.

Animator Schema

The animator component controls playback for both tiers:

FieldTypeDefaultDescription
clipstring“”Current animation clip name
playingboolfalseWhether the animation is playing
autoplayboolfalseStart playing on scene load
loopbooltrueLoop when the clip ends
speedf321.0Playback speed (-10.0 to 10.0)
blend_targetstring“”Clip to crossfade into (cleared by the engine when the fade completes)
blend_durationf320.3Crossfade duration in seconds
layersarray of tables[]Animation layers { clip, weight, mode, mask, speed, fade_target, fade_duration }, composed in order
sequencestring“”Name of a *.sequence.toml from the scene’s animations/ directory driving this animator. Set by play_sequence(), cleared by the engine when a non-looping sequence finishes
layer_clipstring“”Legacy alias for layers[0] (additive, unmasked)
layer_weightf321.0Legacy alias for layers[0].weight

Architecture

  • AnimationPlayer — clip registry and per-entity playback state for property tweens
  • AnimationSync — bridges ECS animator components to property animation playback, auto-discovers new entities each frame
  • SkeletalSync — bridges ECS to skeletal animation, manages per-entity skeleton state and bone matrix computation
  • AnimationSystem — top-level RuntimeSystem implementation that ticks both tiers

Animation runs in update() (variable-rate), not fixed_update(), because smooth interpolation benefits from matching the rendering frame rate rather than the physics tick rate.

Scripting Integration

Animations can be controlled from Rhai scripts by writing directly to the animator component. The AnimationSync system picks up changes on the next frame:

FunctionDescription
play_clip(entity_id, clip_name)Start playing a named animation clip
stop_clip(entity_id)Stop the current animation
blend_to(entity_id, clip, duration)Crossfade to another clip over the given duration
set_anim_speed(entity_id, speed)Set animation playback speed
set_anim_layer(entity_id, index, clip, weight)Play clip on a layer (additive, unmasked)
set_anim_layer_ex(entity_id, index, clip, weight, mode, mask)Same with "additive"/"override" and a root-joint mask
set_anim_layer_weight(entity_id, index, weight)Set a layer’s weight instantly (cancels a fade)
fade_anim_layer(entity_id, index, weight, seconds)Ramp a layer’s weight over seconds
clear_anim_layer(entity_id, index)Leave an inactive slot
play_sequence(entity_id, name) / stop_sequence(entity_id)Drive the animator from a *.sequence.toml
on_sequence_cue(sequence, cue)Callback: a sequence passed a cue event
#![allow(unused)]
fn main() {
// In a Rhai script:
fn on_interact() {
    let me = self_entity();
    play_clip(me, "door_swing");
}

fn on_init() {
    let me = self_entity();
    blend_to(me, "idle", 0.3);  // Smooth transition to idle
}
}

Further Reading

  • Scripting — full scripting API including animation functions
  • Audio — audio system that responds to animation events
  • Rendering — the skinned mesh GPU pipeline
  • Physics and Runtime — the game loop that drives animation
  • File Formats.anim.toml format reference