Bunyip a game engine in Go GitHub

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.