Package github.com/matjam/bunyip/anim
anim
Package anim animates entities. A Curve interpolates keyframes of any value type. A Track applies a curve to one property of one component. A Clip bundles tracks with a loop mode. A Player component plays clips on an entity and crossfades between them. The same types animate a 2D sprite's position and tint, a 3D transform's rotation and scale, and any field of your own component. One System drives every player, sprite-sheet Flipbook and skeletal Skeleton in the world.
For skeletons, BlendSpace1D, BlendSpace2D and BlendTree are data that turn parameters (a speed, a strafe direction) into clip weights, and a Blend plays them on a gfx.AnimPlayer with their cycles in step. TwoBoneIK and LookAt are the solvers behind SolveTwoBoneIK and LookAtNode, which adjust the player's pose to place feet and aim heads.
bounce := anim.NewClip("bounce", anim.Loop,
anim.Position2(anim.Vec2s(
anim.At(0, lin.V2(100, 300)),
anim.AtEased(0.5, lin.V2(100, 100), tween.OutQuad),
anim.AtEased(1, lin.V2(100, 300), tween.InQuad),
)),
)
e := w.SpawnWith(gfx.Sprite{...}, anim.Player{})
anim.PlayerOf(w, e).Play(bounce)
w.AddSystem("anim", anim.System)
Index
func LerpColor(a, b gfx.Color, t float32) gfx.Colorfunc LerpFloat(a, b, t float32) float32func LerpVec2(a, b lin.Vec2, t float32) lin.Vec2func LerpVec3(a, b lin.Vec3, t float32) lin.Vec3func LookAt(from, to lin.Vec3, limit float32) lin.Quatfunc LookAtNode(p *gfx.AnimPlayer, node int, forward, target lin.Vec3, limit float32)func SlerpQuat(a, b lin.Quat, t float32) lin.Quatfunc SolveTwoBoneIK(p *gfx.AnimPlayer, root, mid, end int, target, pole lin.Vec3)func System(w *ecs.World, dt float64)func TwoBoneIK(root, mid, end, target, pole lin.Vec3) (upper, lower lin.Quat)- type Blend
- type BlendChild
- type BlendPoint1D
- type BlendPoint2D
- type BlendSpace1D
- type BlendSpace2D
- type BlendTree
- type Blender
- type Clip
- type ClipWeight
- type Curve
func Colors(keys ...Key[gfx.Color]) Curve[gfx.Color]func Floats(keys ...Key[float32]) Curve[float32]func NewCurve[V any](lerp Lerper[V], keys ...Key[V]) Curve[V]func Quats(keys ...Key[lin.Quat]) Curve[lin.Quat]func Vec2s(keys ...Key[lin.Vec2]) Curve[lin.Vec2]func Vec3s(keys ...Key[lin.Vec3]) Curve[lin.Vec3]func (c Curve[V]) Duration() float32func (curve Curve[V]) Field[C any](field func(*C) *V) Trackfunc (curve Curve[V]) Property[C any](get func(*C) V, set func(*C, V)) Trackfunc (c Curve[V]) Sample(t float32) V
- type Finished
- type Flipbook
- type Key
- type Lerper
- type LoopMode
- type Player
- type Skeleton
- type SkeletonEvent
- type Track
Examples
Example
package main
import (
"fmt"
"github.com/matjam/bunyip/anim"
"github.com/matjam/bunyip/ecs"
"github.com/matjam/bunyip/gfx"
"github.com/matjam/bunyip/lin"
"github.com/matjam/bunyip/tween"
)
func main() {
w := ecs.NewWorld()
w.AddSystem("anim", anim.System) // after the systems that choose clips, before drawing
// A 3D entity: rise and spin over two seconds, then hold.
rise := anim.NewClip("rise", anim.Once,
anim.Position(anim.Vec3s(anim.At(0, lin.V3(0, 0, 0)), anim.AtEased(2, lin.V3(0, 4, 0), tween.OutCubic))),
anim.Rotation(anim.Quats(anim.At(0, lin.QuatIdentity()), anim.At(2, lin.AxisAngle(lin.V3(0, 1, 0), lin.Radians(180))))),
)
cube := w.SpawnWith(gfx.Transform{}, anim.Player{})
anim.PlayerOf(w, cube).Play(rise)
// A 2D entity: a looping bob on a sprite.
bob := anim.NewClip("bob", anim.PingPong,
anim.Position2(anim.Vec2s(anim.At(0, lin.V2(100, 100)), anim.At(0.5, lin.V2(100, 80)))),
)
sprite := w.SpawnWith(gfx.Sprite{Size: lin.V2(32, 32), Color: gfx.White}, anim.Player{})
anim.PlayerOf(w, sprite).Play(bob)
for range 4 {
w.Update(0.5)
}
t, _ := w.Get[gfx.Transform](cube)
s, _ := w.Get[gfx.Sprite](sprite)
fmt.Printf("cube at y=%.0f, sprite at y=%.0f\n", t.Position.Y, s.Pos.Y)
for _, ev := range w.Events[anim.Finished]() {
fmt.Println("finished:", ev.Clip.Name)
}
}
cube at y=4, sprite at y=100 finished: rise
Functions
LerpColor source
func LerpColor(a, b gfx.Color, t float32) gfx.Color
LerpColor interpolates colours channel by channel.
LookAt source
func LookAt(from, to lin.Vec3, limit float32) lin.Quat
LookAt returns the rotation that turns direction from towards direction to along the shortest arc, by at most limit radians; a zero limit means all the way. It is the maths under LookAtNode.
LookAtNode source
func LookAtNode(p *gfx.AnimPlayer, node int, forward, target lin.Vec3, limit float32)
LookAtNode turns a node so that forward, the node's own axis that should face things (often +Z or -Z; check the model), points at target, a point in model space, turning by at most limit radians from the pose's own rotation. A head following the player, a turret tracking a ship. Call it from PostPose or after Advance.
SlerpQuat source
func SlerpQuat(a, b lin.Quat, t float32) lin.Quat
SlerpQuat interpolates rotations along the shortest arc; a zero quaternion counts as no rotation.
SolveTwoBoneIK source
func SolveTwoBoneIK(p *gfx.AnimPlayer, root, mid, end int, target, pole lin.Vec3)
SolveTwoBoneIK turns three of a player's nodes so the end node reaches target, a point in model space, with the middle joint bending towards pole. Call it from the player's PostPose, or after Advance, every frame the target matters.
System source
func System(w *ecs.World, dt float64)
System advances every Player, Flipbook and Skeleton by dt seconds and writes the results into their components. Register it after the systems that decide what to play and before drawing:
w.AddSystem("anim", anim.System)
TwoBoneIK source
func TwoBoneIK(root, mid, end, target, pole lin.Vec3) (upper, lower lin.Quat)
TwoBoneIK solves a chain of two bones so its end reaches a target: a leg (hip, knee, foot) planted on uneven ground, an arm (shoulder, elbow, hand) reaching a handle. root, mid and end are the joints' current positions, in any one space; target is where the end should be, in the same space, and pole is a point the middle joint bends towards (in front of a knee, behind an elbow). A target out of reach straightens the chain towards it.
The result is two rotations in that space: turn the middle joint by lower about its own position first, then the root joint by upper about its position, and the end lands on the target. SolveTwoBoneIK does this on an AnimPlayer's nodes.
Types
type Blend source
type Blend struct {
// Tree is what is evaluated: a BlendSpace1D, BlendSpace2D or
// BlendTree.
Tree Blender
// Params holds the parameter values by name; Set writes them.
Params map[string]float32
// contains filtered or unexported fields
}
Blend drives a gfx.AnimPlayer from a blend space or tree: it holds the parameters the game sets, evaluates the tree every Advance and keeps the mixed clips in step by playing them all at one phase of their own length, so a walk's and a run's feet land together. Clips in a blend loop. Make one with NewBlend; a Skeleton with a Blend set drives it from the ECS.
NewBlend source
func NewBlend(tree Blender) *Blend
NewBlend makes a Blend over a space or tree with every parameter at 0.
Advance source
func (b *Blend) Advance(p *gfx.AnimPlayer, dt float64)
Advance moves the blend on by dt seconds, scaled by the player's speed, sets the player's clips and advances the player. The phase moves at the blended cycle's rate: with walk at 1 s and run at 0.5 s mixed evenly, one cycle takes 0.75 s and each clip is sampled at the same fraction of its own length.
Get source
func (b *Blend) Get(name string) float32
Get is a parameter's value; unset parameters are 0.
Phase source
func (b *Blend) Phase() float64
Phase is how far through their cycle the blended clips are, 0 to 1.
type BlendChild source
type BlendChild struct {
At float32 `json:"at"`
Tree BlendTree `json:"tree"`
}
BlendChild is a subtree placed at a parameter value in its parent.
type BlendPoint1D source
type BlendPoint1D struct {
Clip string `json:"clip"`
At float32 `json:"at"`
}
BlendPoint1D is a clip placed at a parameter value.
type BlendPoint2D source
type BlendPoint2D struct {
Clip string `json:"clip"`
At lin.Vec2 `json:"at"`
}
BlendPoint2D is a clip placed at a point in a 2D space.
type BlendSpace1D source
type BlendSpace1D struct {
// Parameter names the value the space reads, as set on a Blend.
Parameter string `json:"parameter"`
// Clips are the placed clips, in any order.
Clips []BlendPoint1D `json:"clips"`
}
BlendSpace1D places clips along one parameter and blends the two on either side of its value: idle at 0, walk at 1, run at 2, with speed 1.5 half walk and half run. Outside the clips' range the nearest clip plays alone. It is plain data, buildable in code or from JSON.
type BlendSpace2D source
type BlendSpace2D struct {
// X and Y name the parameters the space reads, as set on a Blend.
X string `json:"x"`
Y string `json:"y"`
// Clips are the placed clips, in any order.
Clips []BlendPoint2D `json:"clips"`
}
BlendSpace2D places clips at points in a plane of two parameters and blends the ones around the current point: a strafe set with forward, back, left and right around an idle at the centre, read from the velocity's x and y. Weights are gradient bands: a clip at the current point plays alone, points on a line between two clips blend them linearly, and clips fall out as the point moves past them. It is plain data, buildable in code or from JSON.
type BlendTree source
type BlendTree struct {
Clip string `json:"clip,omitempty"`
Space1D *BlendSpace1D `json:"space1d,omitempty"`
Space2D *BlendSpace2D `json:"space2d,omitempty"`
Parameter string `json:"parameter,omitempty"`
Children []BlendChild `json:"children,omitempty"`
}
BlendTree is a node in a tree of blends. Exactly one part is used, checked in this order: a Clip plays alone, a Space1D or Space2D plays its blend, and Children are subtrees placed along Parameter and mixed like a 1D space, so a crouch amount can fade a standing locomotion space into a crouched one. The whole tree shares one phase, so the clips it mixes stay in step. It is plain data, buildable in code or from JSON.
type Blender source
type Blender interface {
Weights(params map[string]float32, out []ClipWeight) []ClipWeight
}
Blender turns parameters into clip weights: a blend space, a blend tree or a single clip. Weights appends the clips to play to out and returns it; the weights sum to 1 unless there is nothing to play.
type Clip source
type Clip struct {
Name string
Tracks []Track
Mode LoopMode
// Length, when positive, overrides the duration in seconds; otherwise
// the longest track supplies it.
Length float32
// contains filtered or unexported fields
}
Clip is a named set of tracks that play together. The zero clip has no tracks and finishes on its first update. Mode defaults to Once.
NewClip source
func NewClip(name string, mode LoopMode, tracks ...Track) *Clip
NewClip bundles tracks into a clip.
AddTrack source
func (c *Clip) AddTrack(tracks ...Track)
AddTrack appends tracks to the clip and rebuilds what it caches about them. Assigning to Tracks directly works too as long as the number of tracks changes; replacing a track in place needs this call with no arguments for the change to be seen.
Apply source
func (c *Clip) Apply(w *ecs.World, e ecs.Entity, t, weight float32)
Apply samples every track at clip time t in seconds with the given weight. It does not wrap t according to Mode; Player handles looping. The tracks are grouped by the component they write, so a clip that animates three fields of one component looks that component up once. Tracks written by hand, which cannot be grouped, are applied last.
type ClipWeight source
type ClipWeight struct {
Clip string
Weight float32
}
ClipWeight is a clip's share of a blended pose.
type Curve source
type Curve[V any] struct {
Keys []Key[V]
Lerp Lerper[V]
}
Curve interpolates keys over time. Before the first key it holds the first value; after the last it holds the last. The zero curve samples to V's zero value. Keep Keys sorted by Time when editing them directly. A nil Lerp holds the previous key until the next key is reached.
NewCurve source
func NewCurve[V any](lerp Lerper[V], keys ...Key[V]) Curve[V]
NewCurve copies the keys and sorts them stably by time in seconds.
Duration source
func (c Curve[V]) Duration() float32
Duration is the last key's time in seconds, or zero for an empty curve.
Field source
func (curve Curve[V]) Field[C any](field func(*C) *V) Track
Field makes a track over a component field of the curve's value type. The non-nil accessor must return the field's non-nil address in the component supplied to it. The address is used only during that application; it is not retained across structural changes. Missing components are skipped. Use Property when reading or writing needs conversion or normalization.
anim.Floats(anim.Num(0, 1), anim.Num(0.3, 0)).Field(
func(h *Health) *float32 { return &h.Opacity })
Example
package main
import (
"fmt"
"github.com/matjam/bunyip/anim"
"github.com/matjam/bunyip/ecs"
)
func main() {
type Light struct{ Intensity float32 }
w := ecs.NewWorld()
e := w.SpawnWith(Light{Intensity: 1})
fade := anim.Floats(anim.Num(0, 1), anim.Num(1, 0)).Field(
func(l *Light) *float32 { return &l.Intensity })
fade.Apply(w, e, 0.25, 1)
l, _ := w.Get[Light](e)
fmt.Println(l.Intensity)
}
0.75
Property source
func (curve Curve[V]) Property[C any](get func(*C) V, set func(*C, V)) Track
Property makes a track over any component field: get reads the field so crossfades can blend from it, set writes the animated value. The entity must already have C; a missing component is skipped. A curve without a Lerper replaces the value even during a crossfade.
anim.Floats(anim.Num(0, 0), anim.Num(1, 100)).Property(
func(h *Health) float32 { return float32(h.HP) },
func(h *Health, v float32) { h.HP = int(v) })
type Finished source
type Finished struct {
Entity ecs.Entity
Clip *Clip // nil for a Flipbook
}
Finished is emitted once when a Player's Once clip or a non-looping Flipbook ends. Skeleton does not emit it; poll gfx.AnimPlayer.Finished.
type Flipbook source
type Flipbook struct {
Sheet *gfx.Sheet
Frames []int
FPS float32 // frames per second; nonpositive means 10
Loop bool
Time float64 // elapsed seconds; keep nonnegative
Done bool
}
Flipbook plays sprite-sheet frames into the entity's gfx.Sprite. The zero value is inactive: Sheet and Frames must both be supplied.
type Key source
type Key[V any] struct {
Time float32
Value V
Ease tween.Ease
}
Key is a value at a time in seconds. Ease shapes the approach to this key from the previous one; nil is linear.
AtEased source
func AtEased[V any](time float32, v V, ease tween.Ease) Key[V]
AtEased makes a key reached along an easing curve.
type Lerper source
type Lerper[V any] func(a, b V, t float32) V
Lerper interpolates between two values; t runs from 0 to 1.
type Player source
type Player struct {
Clip *Clip
Time float32 // playback clock in seconds; loops fold it back into their cycle
Speed float32 // playback rate; zero means 1
Playing bool
// contains filtered or unexported fields
}
Player is the component that plays clips on an entity. Add an empty one and call Play through PlayerOf. When setting Clip directly, also set Playing to true. The zero Player is stopped.
PlayerOf source
func PlayerOf(w *ecs.World, e ecs.Entity) *Player
PlayerOf returns the entity's Player, adding one when it has none.
CrossFade source
func (p *Player) CrossFade(c *Clip, seconds float32)
CrossFade starts a clip while blending out the current one over the
given seconds. The fade uses unscaled update time; Speed only scales
clip playback. A nonpositive duration or a stopped player uses Play.
Example
package main
import (
"fmt"
"github.com/matjam/bunyip/anim"
"github.com/matjam/bunyip/ecs"
"github.com/matjam/bunyip/gfx"
"github.com/matjam/bunyip/lin"
)
func main() {
w := ecs.NewWorld()
w.AddSystem("anim", anim.System)
idle := anim.NewClip("idle", anim.Loop, anim.Scale(anim.Vec3s(anim.At(0, lin.V3(1, 1, 1)), anim.At(1, lin.V3(1, 1, 1)))))
jump := anim.NewClip("jump", anim.Once, anim.Scale(anim.Vec3s(anim.At(0, lin.V3(2, 2, 2)), anim.At(1, lin.V3(2, 2, 2)))))
e := w.SpawnWith(gfx.Transform{}, anim.Player{})
p := anim.PlayerOf(w, e)
p.Play(idle)
w.Update(0.1)
p.CrossFade(jump, 0.5) // blend from idle's scale to jump's over half a second
w.Update(0.25)
t, _ := w.Get[gfx.Transform](e)
fmt.Printf("%.1f\n", t.Scale.X)
}
1.5
Play source
func (p *Player) Play(c *Clip)
Play starts a clip from the beginning, replacing any playing one.
type Skeleton source
type Skeleton struct {
Player *gfx.AnimPlayer
Speed float64 // zero means 1
// KeepRootMotion leaves the transform alone so the game reads
// Player.RootMotion itself, for a physics-driven body.
KeepRootMotion bool
// Blend, when set, chooses and times the player's clips from its
// parameters every update instead of Play and CrossFade: a
// locomotion blend space driven by SetParameter. Nil plays whatever
// the player was told to.
Blend *Blend
}
Skeleton plays a glTF model's animation clips through a gfx.AnimPlayer; draw the entity with gfx.DrawModelAnimated. Events the player crosses are emitted as SkeletonEvent, and with root motion on the player, the movement is applied to the entity's gfx.Transform when it has one.
Parameter source
func (s *Skeleton) Parameter(name string) float32
Parameter reads a blend parameter; 0 without a Blend or when unset.
SetParameter source
func (s *Skeleton) SetParameter(name string, v float32)
SetParameter sets a blend parameter, such as the speed a locomotion space reads; without a Blend it does nothing.
type SkeletonEvent source
type SkeletonEvent struct {
Entity ecs.Entity
Event gfx.AnimEvent
}
SkeletonEvent is emitted when a Skeleton's player crosses an event added with AnimPlayer.AddEvent.
type Track source
type Track interface {
// Apply samples t seconds into the track and blends into the component.
Apply(w *ecs.World, e ecs.Entity, t, weight float32)
// Duration reports the final key time in seconds.
Duration() float32
}
Track applies an animated value to one component of an entity. The weight blends the sampled value with what the component holds, which is how crossfades and layered clips mix; 1 replaces outright.
Position source
func Position(curve Curve[lin.Vec3]) Track
Position animates a gfx.Transform's position.
Position2 source
func Position2(curve Curve[lin.Vec2]) Track
Position2 animates a gfx.Sprite's position.
Rotation source
func Rotation(curve Curve[lin.Quat]) Track
Rotation animates a gfx.Transform's rotation.
Source files
anim_test.go bench_test.go blend.go blend_alloc_test.go blend_test.go clip.go curve.go example_test.go field_test.go ik.go ik_test.go system.go