Animation
The anim package animates entities in the
ECS. It has three layers, each useful on its own: curves
that interpolate keyframes, clips of tracks that write curves into
components, and a Player component plus one system that play clips,
sprite-sheet flipbooks and glTF skeletons every update.
Curves
A curve is a list of keys, each a time, a value and an optional easing
into that key. Curves exist for numbers, 2D and 3D vectors, rotations
(interpolated along the shortest arc) and colours; NewCurve takes any
type and its own interpolation function. At and AtEased make keys
of any type; Num and NumEased are their number-typed forms, since a
bare 0 would otherwise be an int.
height := anim.Floats(
anim.Num(0, 0),
anim.NumEased(0.5, 120, tween.OutQuad),
anim.NumEased(1.0, 0, tween.OutBounce),
)
height.Sample(0.25) // 90, on the way up
Tracks and clips
A track applies a curve to one property of one component. Tracks exist
for a 3D gfx.Transform (Position, Rotation, Scale) and for a 2D
gfx.Sprite (Position2, Size2, Rotation2, Tint). A curve's
Field method animates a custom component field through an accessor
returning its address, with no reflection:
anim.Floats(anim.Num(0, 1), anim.Num(0.3, 0)).Field(
func(l *Light) *float32 { return &l.Intensity })
The entity must already have the component; missing components are
skipped. The accessor uses the current component on each application,
so the track retains no pointer across structural changes. Use the
curve's Property(get, set) method when a field needs conversion or
normalization, such as animating an integer through a float curve.
A clip bundles tracks with a loop mode: Once stops at the end and
reports it, Loop starts over, PingPong runs back and forth.
jump := anim.NewClip("jump", anim.Once,
anim.Position(anim.Vec3s(
anim.At(0, lin.V3(0, 0.5, 0)),
anim.AtEased(0.4, lin.V3(0, 3, 0), tween.OutQuad),
anim.AtEased(0.8, lin.V3(0, 0.5, 0), tween.InQuad),
)),
anim.Scale(anim.Vec3s(
anim.At(0, lin.V3(1.3, 0.7, 1.3)),
anim.At(0.2, lin.V3(0.8, 1.4, 0.8)),
anim.At(1, lin.V3(1, 1, 1)),
)),
)
The same clip type animates a sprite: swap Position for Position2
and the vectors for 2D ones. A clip built once can play on any number of
entities.
To add tracks to a clip after it is built, call AddTrack. A clip works
out its duration and groups its tracks by the component they write once
and keeps both, so playing it costs one component lookup per component
rather than one per track; AddTrack throws that away and works it out
again. Assigning to Tracks directly works too as long as the number of
tracks changes, and replacing a track in place needs AddTrack() with
no arguments for the change to be seen.
Playing
Give an entity a Player component (or let PlayerOf add one), play a
clip, and register anim.System on the world after the systems that
decide what plays and before drawing:
hero := w.SpawnWith(gfx.Transform{}, anim.Player{})
anim.PlayerOf(w, hero).Play(idle)
w.AddSystem("anim", anim.System)
CrossFade(clip, seconds) starts a clip while the previous one blends
out, so a jump does not snap out of the idle pose. Speed scales
playback, Stop freezes it, and Progress reports where it is. The
same Player works for a 2D entity; only the tracks differ. Speed zero
means normal speed, so use Stop to pause. A manually initialized
Player{Clip: clip} also needs Playing: true; Play sets both.
Crossfade durations use update seconds independently of playback speed.
Finishing and sequencing
When a Once clip ends the system emits anim.Finished with the
entity and the clip. To chain animations, add a system that reads the
event: land, then fade back to idle; explode, then despawn.
w.AddSystem("return", func(w *ecs.World, dt float64) {
for _, ev := range w.Events[anim.Finished]() {
anim.PlayerOf(w, ev.Entity).CrossFade(idle, 0.3)
}
})
Flipbooks
A Flipbook component plays sprite-sheet frames into the entity's
gfx.Sprite by rewriting its texture window each update. It holds a
sheet, the frame indices, a rate and whether to loop. A finished
non-looping flipbook also emits Finished.
w.SpawnWith(gfx.Sprite{Size: lin.V2(64, 64), Color: gfx.White}, spriteTexture{tex},
anim.Flipbook{Sheet: gfx.NewSheet(tex, 16, 16), Frames: []int{0, 1, 2, 3}, FPS: 8, Loop: true})
Skeletons
A Skeleton component wraps a gfx.AnimPlayer for a glTF model's
clips; the system advances it, and the entity draws with
gfx.DrawModelAnimated. Clip selection stays on the player, so a
character controller system names the clip to play. Play(name, loop)
snaps to a clip, CrossFade(name, loop, seconds) blends into it from
whatever is playing. Poll player.Finished() to detect a one-shot clip's
end; anim.System does not emit anim.Finished for a Skeleton.
player := model.NewAnimPlayer()
player.Play("idle", true)
hero := w.SpawnWith(gfx.Transform{}, anim.Skeleton{Player: player})
// later, from the controller
player.CrossFade("run", true, 0.2)
The player is also usable on its own, without the ECS: call Advance
with the frame's seconds and draw.
Animation events
To mark a moment in a clip, call AddEvent(clip, time, name): a
footstep sound, the frame a punch connects, the moment a spell spawns
its effect. Every Advance reports the events playback crossed,
through Events afterwards or OnEvent as they happen. An event fires
on every loop, on the outgoing clip of a crossfade and on layers, so a
walk's footsteps keep landing while it fades into a run. Through
the Skeleton component the same events arrive as anim.SkeletonEvent
with the entity, beside Finished.
player.AddEvent("walk", 0.3, "step")
player.AddEvent("walk", 0.8, "step")
w.AddSystem("footsteps", func(w *ecs.World, dt float64) {
for _, ev := range w.Events[anim.SkeletonEvent]() {
if ev.Event.Name == "step" {
mixer.Play(footstep, audio.PlayOptions{})
}
}
})
Root motion
A walk cycle whose root translates moves the visible character without
moving the entity, so collisions and the camera do not follow it.
SetRootMotion("Hips") takes that authored movement out of the pose and
reports it instead. RootMotion returns how far the root moved and how
far it turned about +Y during the last Advance, in model space,
blended through crossfades and added up across a loop point. The
Skeleton component applies it to the entity's gfx.Transform
automatically; set KeepRootMotion to read it yourself, for a body
that physics moves:
delta, yaw := player.RootMotion()
body.Velocity = tr.Rotation.Rotate(delta).Mul(1 / float32(dt))
tr.Rotation = lin.AxisAngle(lin.V3(0, 1, 0), yaw).Mul(tr.Rotation)
The root's translation and yaw are held at the rest pose; pitch and roll, and every other node, animate as before. Vertical movement is reported too, so a jump's rise comes through the delta.
An in-place cycle has no authored root travel to extract. Move that entity through the controller or physics system instead.
Layers and masks
A layer plays a second clip over part of the skeleton: a wave over the
arms while the legs keep walking, a flinch over the spine. A mask lists
the nodes the layer applies to; Model.MaskSubtree("Spine1") takes a
node and everything under it,
Model.MaskNodes exactly the named nodes, and nil means the whole
skeleton.
wave := player.Layer("wave", 1, model.MaskSubtree("RightShoulder"))
wave.Loop = false // hold the last frame when it ends; RemoveLayer takes it off
Layers blend in order after the main clip and its crossfade. By default
a layer replaces the pose of its nodes, scaled by Weight; with
Additive set it adds the clip's difference from the rest pose to
whatever is underneath, which is how a breathing loop or a recoil plays
over any base clip. Change Weight over time to fade a layer in and
out; Play and CrossFade leave layers alone.
Blend spaces
A blend space places clips along a parameter and mixes the ones around its current value. Use one where a single clip will not do: the speed the controller asks for falls between a walk and a run, and a strafe is a mix of forward and sideways. A blend space is plain data with JSON tags, so a game can build one in code or load it from a file beside the model.
A BlendSpace1D places clips along one parameter: idle at 0, walk at
1, run at 2. At 1.5 the walk and the run each get half the pose; past
the ends the nearest clip plays alone.
locomotion := &anim.BlendSpace1D{Parameter: "speed", Clips: []anim.BlendPoint1D{
{Clip: "idle", At: 0}, {Clip: "walk", At: 1}, {Clip: "run", At: 2},
}}
A BlendSpace2D places clips at points in a plane of two parameters,
for a strafe set read from the velocity: idle at the centre, forward,
back, left and right around it. Weights are gradient bands, so a clip
at the current point plays alone, a point on the line between two clips
blends them linearly, and clips drop out as the point moves past them.
strafe := &anim.BlendSpace2D{X: "vx", Y: "vy", Clips: []anim.BlendPoint2D{
{Clip: "idle", At: lin.V2(0, 0)}, {Clip: "forward", At: lin.V2(0, 1)}, {Clip: "back", At: lin.V2(0, -1)},
{Clip: "left", At: lin.V2(-1, 0)}, {Clip: "right", At: lin.V2(1, 0)},
}}
A BlendTree composes them: a node is a clip, a 1D or 2D space, or
children placed along a parameter and mixed like a 1D space. A crouch
amount fading a standing locomotion space into a crouched one is a tree
of two children.
tree := &anim.BlendTree{Parameter: "crouch", Children: []anim.BlendChild{
{At: 0, Tree: anim.BlendTree{Space1D: locomotion}},
{At: 1, Tree: anim.BlendTree{Space1D: crouched}},
}}
A Blend plays a space or tree on a gfx.AnimPlayer. It holds the
parameters, evaluates the tree every update and keeps the mixed clips in
step: all of them run at one phase of their own length, and the phase
advances at the rate of the blended cycle, so a walk's and a run's feet
land together instead of sliding. Clips in a blend loop. The Skeleton
component drives a Blend set on it from the ECS, with SetParameter
to feed it; on its own, call Advance in place of the player's.
hero := w.SpawnWith(gfx.Transform{}, anim.Skeleton{Player: player, Blend: anim.NewBlend(tree)})
// from the controller, each update
skel, _ := w.Get[anim.Skeleton](hero)
skel.SetParameter("speed", velocity.Len())
skel.SetParameter("crouch", crouchAmount)
Below Blend is the player's SetBlend, which plays a list of clips
with weights and times in place of the main clip; events fire and root
motion accrues for every clip in the blend by its weight, and layers
play over it as over a clip. Play and CrossFade drop a blend, so a
jump from a locomotion blend snaps into its clip; when a game needs the
blend to fade out it can keep a second player in the blend and mix the
poses itself.
Inverse kinematics and node overrides
PostPose runs after the clips are blended and the pose is built, and
before the joint matrices are made. Use it to plant a foot on uneven
ground, reach a hand to a handle or turn a head. SolveTwoBoneIK takes
three node indices (hip, knee, foot), a target and a pole point the
middle joint bends towards; LookAtNode turns a node so its forward
axis faces a point, within an angle limit. Both are built on the
solvers TwoBoneIK and LookAt, plain functions over positions and
directions that work outside the ECS as well.
hip, knee, foot := model.NodeIndex("LeftUpLeg"), model.NodeIndex("LeftLeg"), model.NodeIndex("LeftFoot")
head := model.NodeIndex("Head")
player.PostPose = func(p *gfx.AnimPlayer) {
anim.SolveTwoBoneIK(p, hip, knee, foot, groundUnderLeftFoot, kneeForward)
anim.LookAtNode(p, head, lin.V3(0, 0, 1), playerPosition, lin.Radians(70))
}
Below those helpers are the player's node overrides, usable from
PostPose or after Advance: NodePosition and NodeRotation read a
node in model space, NodeLocal and SetNodeLocal read and replace
its transform relative to its parent, SetNodeRotation sets only the
rotation, and RotateNode turns a node by a model-space rotation about
its own position so its children follow. An override lasts until the
next Advance samples the clips again, so set it every frame; while
nothing plays, it stays.
Morph targets
Blend shapes in a glTF file (a smile, a blink, a bent leaf) load as
morph targets with their default weights, and a clip's weights
channel animates them like any other. The player holds the weights in
its pose and DrawModelAnimated blends them in; SetMorphWeights on
the player holds weights that no clip is driving, for an expression
chosen by the game, and Model.SetMorphWeights blends a model that is
drawn without a player. Model.MorphTargets lists a node's target
names.
face := model.NodeIndex("Face")
smile := slices.Index(model.MorphTargets(face), "smile")
weights := make([]float32, len(model.MorphTargets(face)))
weights[smile] = 0.8
player.SetMorphWeights(face, weights)
Blending runs in the vertex shader while no more than
gfx.MaxGPUMorphTargets (eight) of a mesh's targets carry a weight at
once. A model's target deltas go into a storage buffer when it loads,
and a draw names the open ones with their weights in its instance
record, so a face driven every frame uploads nothing at all and costs a
few reads a vertex. A mesh with twenty targets is fine so long as no
more than eight are open together.
Past that cap the blend falls back to the processor for that mesh: the rest vertices plus each open target are summed and the result uploaded, one pass over the vertices per open target and one upload each time the weights change. It is correct at any count and slower at every one, so keep the open targets few. Each queued draw captures its weights and uploaded geometry, so characters sharing one model can show different expressions through separate animation players or weight changes between draws. This also holds when successive draws switch between the shader and processor paths.
Two things follow from the shader doing the work. Mesh.Vertices gives
the geometry as uploaded, which is the rest pose while the shader
blends, so picking and physics against a morphed mesh see the shape
before its targets. And culling bounds the mesh by that same rest
geometry, so a target that moves vertices a long way wants
Mesh.SetBounds to say how far.
Where the values come from
Clips are Go values, so they can be written by hand as above, built
from data (a JSON list of keys is a loop over At), or generated: the
animation example builds eight orbit clips from a formula. Blend spaces
and trees decode straight from JSON. Curves also work outside the ECS;
Sample is a plain function, handy for camera moves and UI
transitions.