ludic.anim: animation sets - the game's src/animset moved in as it stood (records, AnimPlayer, animset_bind / request / param / tick and the accessors); AnimSets is an open @ByKey registry a game fills with def AnimSets from; a test on a rig of hand-made clips

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 19:01:15 +03:00
parent d9a73d63bd
commit 5517873275
10 changed files with 442 additions and 1 deletions

View file

@ -58,6 +58,18 @@ A sampled rotation agrees with `anim_mix` to within 1e-4 a component (ozz keeps
and a channel gives the absolute local L, so D = G_p (L R_rest^-1) G_p^-1.
- The scratch is made once and grown: a call is per actor per frame.
## Animation sets
Which clip plays when, as data: `AnimSets` is an open registry (`@ByKey`, `ASET_*`) a game fills with
`def AnimSets from "assets/data/anim_sets.lres"`. A set is a graph - states (nodes) each with an
ordered list of choices, the first whose `need` bands all hold and whose clip the rig carries playing
(`rate`, or `rate_by` a parameter over the `ground` a cycle covers, held to `rate_min` / `rate_max`),
and transitions (from a state or `"*"`, with a fade; with `@frame when: fn() -> bool`, taken on their
own the frame it answers true). The code binds an `AnimPlayer` to a set and a rig
(`animset_bind`), sets parameters (`animset_param`), asks for a state (`animset_request`, taken once
however often it is asked) and ticks it into a skin (`animset_tick`). A one-shot (`loop: false`)
holds its last pose and hands on to `next`. Clip names are made once per rig; a tick makes nothing.
## Tests
```bash
@ -65,4 +77,6 @@ ludic test packages/ludic.anim
```
A two-bone skin made from a glTF document with no GPU: sampling, wrapping, the cross-fade,
translations off by default, and clips read out of a `.bin` written to the temp directory.
translations off by default, and clips read out of a `.bin` written to the temp directory. And an
animation set on a rig of hand-made clips (`animset_test`, `sets.lres`): need bands, the rate clamp,
a one-shot's `next`, a `when` edge, and a request taken once.

View file

@ -10,3 +10,9 @@ import "mix.ludic"
import "pose.ludic"
import "bones.ludic"
import "ozz.ludic"
import "set_records.ludic"
import "set_player.ludic"
import "set_bind.ludic"
import "set_pick.ludic"
import "set_follow.ludic"
import "set_tick.ludic"

View file

@ -0,0 +1,53 @@
# set_bind.ludic - a player given a set and a rig: the clip names are made once per rig, and
# looked up again (without making anything) until the model has loaded and read its clips
@alloc_ok("a rig's clip names and tables, made once when the rig changes")
function as_names(pl: AnimPlayer, set: int, rig: string) -> void {
let s = AnimSets[set]
pl.set = set
pl.rig = intern(rig)
pl.names = new []string
pl.clips = new []Clip
pl.base = new []int
pl.vals = new []float
for i in 0 .. len(s.states) {
push(pl.base, len(pl.names))
let d = s.states[i]
for c in 0 .. len(d.choices) {
push(pl.names, intern(rig + "_" + d.choices[c].clip))
push(pl.clips, null)
}
}
if s.params != null { for i in 0 .. len(s.params) { push(pl.vals, 0.0) } }
}
# Bind (or look again). A new rig is a different skeleton: nothing of the last one's cross-fade
# may carry over. Ready once the entry state's default - its last choice - is found.
export function animset_bind(anim_st: AnimState, pl: AnimPlayer, set: int, rig: string) -> bool {
if animset_bound(pl, set, rig) and pl.ready { return true }
if not animset_bound(pl, set, rig) {
as_names(pl, set, rig)
pl.ready = false
}
for i in 0 .. len(pl.names) { pl.clips[i] = anim_find(anim_st, pl.names[i]) }
let s = AnimSets[set]
pl.state = as_entry(s)
pl.want = pl.state
pl.asked = ""
pl.clip = null
pl.prev = null
let d = s.states[pl.state]
let n = len(d.choices)
pl.ready = n > 0 and pl.clips[pl.base[pl.state] + n - 1] != null
return pl.ready
}
function as_entry(s: AnimSet) -> int {
let i = as_state(s, s.entry)
if i < 0 { return 0 }
return i
}
export function as_state(s: AnimSet, key: string) -> int {
for i in 0 .. len(s.states) { if s.states[i].key == key { return i } }
return -1
}

View file

@ -0,0 +1,40 @@
# set_follow.ludic - moving between states: a `when` edge out of the current state that answers
# true, else the state the code asked for, faded by the edge between them or the target's own blend
function as_follow(pl: AnimPlayer) -> void {
pl.fresh = false
let s = AnimSets[pl.set]
var to = pl.want
var blend = -1.0
if s.transitions != null {
for i in 0 .. len(s.transitions) {
let tr = s.transitions[i]
if tr.when == null or not as_leaves(s, tr, pl.state) { continue }
if tr.when() {
to = as_state(s, tr.to)
blend = tr.blend
break
}
}
}
if to < 0 or to == pl.state { return }
if blend < 0.0 { blend = as_blend(s, pl.state, to) }
pl.state = to
pl.want = to
pl.fresh = true
pl.fade_next = blend
}
function as_leaves(s: AnimSet, tr: AnimTransition, from: int) -> bool {
return tr.from == "*" or tr.from == s.states[from].key
}
# the edge the code's request crosses, if the graph has one; else the target's own fade
function as_blend(s: AnimSet, from: int, to: int) -> float {
if s.transitions != null {
for i in 0 .. len(s.transitions) {
let tr = s.transitions[i]
if tr.when == null and tr.to == s.states[to].key and as_leaves(s, tr, from) { return tr.blend }
}
}
return s.states[to].blend
}

View file

@ -0,0 +1,56 @@
# set_pick.ludic - the code's side: a parameter set, a state asked for; and the choice a state
# makes from them. Nothing here makes anything: it runs every frame.
export function animset_param(pl: AnimPlayer, name: string, v: float) -> void {
let i = as_param_at(pl, name)
if i >= 0 { pl.vals[i] = v }
}
# a state by name, taken once: the code may ask every frame, and a `when` edge that moved the
# player on since is not undone by the same request again
export function animset_request(pl: AnimPlayer, key: string) -> void {
if pl.set < 0 or key == pl.asked { return }
let i = as_state(AnimSets[pl.set], key)
if i < 0 { return }
pl.asked = key
pl.want = i
}
function as_param_at(pl: AnimPlayer, name: string) -> int {
if pl.set < 0 { return -1 }
let s = AnimSets[pl.set]
if s.params == null { return -1 }
for i in 0 .. len(s.params) { if s.params[i] == name { return i } }
return -1
}
function as_value(pl: AnimPlayer, name: string) -> float {
let i = as_param_at(pl, name)
if i < 0 { return 0.0 }
return pl.vals[i]
}
function as_holds(pl: AnimPlayer, c: AnimChoice) -> bool {
if c.need == null { return true }
for k in 0 .. len(c.need) {
let n = c.need[k]
let v = as_value(pl, n.param)
if not (v > n.over and v < n.under) { return false }
}
return true
}
# the state's first choice that holds and whose clip the rig has; -1 none
function as_pick(pl: AnimPlayer, d: AnimStateDef) -> int {
if d.choices == null { return -1 }
let b = pl.base[pl.state]
for c in 0 .. len(d.choices) {
if pl.clips[b + c] != null and as_holds(pl, d.choices[c]) { return c }
}
return -1
}
function as_rate(pl: AnimPlayer, d: AnimStateDef, c: AnimChoice) -> float {
var r = c.rate
if c.rate_by != "" and c.ground > 0.0 { r = as_value(pl, c.rate_by) / c.ground }
return Math.clamp(r, d.rate_min, d.rate_max)
}

View file

@ -0,0 +1,29 @@
# set_player.ludic - one actor playing a set: which rig its clips were found for, the state it
# is in and the one the code last asked for, and the two clips being cross-faded
export property AnimPlayer {
set: int = -1
rig: string = "" # the clips' prefix it was bound with ("hiker_m")
names: []string = null # every choice's full clip name, state by state, made once per rig
clips: []Clip = null # the same, found; null where the rig has no such clip
base: []int = null # where each state's choices start in names and clips
vals: []float = null # the set's parameters, in its order
ready: bool = false # the entry state's default clip is found: the model has loaded
state: int = 0
want: int = 0
asked: string = "" # the last state asked for: a request is taken once, not every frame
fresh: bool = false # the state changed this frame: its first clip starts at zero
fade_next: float = -1.0 # the fade a state change asked for
clip: Clip = null
t: float = 0.0
prev: Clip = null
pt: float = 0.0
mix: float = 1.0
fade: float = 0.2
rate: float = 1.0
}
export function animset_clip(pl: AnimPlayer) -> Clip { return pl.clip }
export function animset_time(pl: AnimPlayer) -> float { return pl.t }
export function animset_rate(pl: AnimPlayer) -> float { return pl.rate }
export function animset_ready(pl: AnimPlayer) -> bool { return pl.ready }
export function animset_bound(pl: AnimPlayer, set: int, rig: string) -> bool { return pl.set == set and pl.rig == rig and pl.names != null }

View file

@ -0,0 +1,55 @@
# set_records.ludic - a rig's animation graph as the studio draws it: states are nodes,
# transitions edges, and inside a state an ordered list of choices (a 1D blend by bands)
# a band's test on one parameter the code sets each frame: holds while over < value < under
export property AnimCond {
param: string = ""
over: float = -1000000.0
under: float = 1000000.0
}
# one clip a state may play: `clip` follows the rig's prefix (`walk` is `<rig>_walk`) and plays
# when every `need` holds (none: the state's default)
export property AnimChoice {
clip: string = ""
need: []AnimCond = null
rate: float = 1.0
rate_by: string = "" # or the rate is this parameter over `ground`,
ground: float = 0.0 # the m/s one cycle covers, so the feet cover the ground the body does
}
# a node: the first choice that holds AND whose clip the rig carries plays. `blend` is the fade
# between its own choices and into it; `keep_phase` carries a cycle's phase across such a change
export property AnimStateDef {
key: string = ""
choices: []AnimChoice = null
rate_min: float = 0.0
rate_max: float = 1000000.0
blend: float = 0.2
keep_phase: bool = true
loop: bool = true
next: string = "" # a one-shot's state once it has played through (loop: false)
}
# an edge: from a state (or "*", any) to another, with its fade. With no `when` it is the fade taken
# when the code asks for `to`; with one, it is taken on its own the frame the function answers true
export property AnimTransition {
from: string = ""
to: string = ""
blend: float = 0.2
@frame when: fn() -> bool = null
}
# a graph for a kind of rig: its parameters, its states (the first is where it starts unless
# `entry` says) and its transitions
export property AnimSet {
key: string = ""
entry: string = ""
params: []string = null
states: []AnimStateDef = null
transitions: []AnimTransition = null
}
# open: a game fills it (`def AnimSets from "assets/data/anim_sets.lres"`), a set's place its ASET_ number
@ByKey
export open registry AnimSets of AnimSet as ASET

View file

@ -0,0 +1,52 @@
# set_tick.ludic - a frame of a player: follow the graph, pick the clip, cross-fade on a change,
# advance both clips by the same step and play them into the skin (ludic.anim)
export function animset_tick(anim_st: mut AnimState, pl: AnimPlayer, sk: Skin, dt: float) -> bool {
if not pl.ready or sk == null { return false }
as_follow(pl)
let d = AnimSets[pl.set].states[pl.state]
let k = as_pick(pl, d)
if k < 0 { return false }
pl.rate = as_rate(pl, d, d.choices[k])
as_switch(pl, d, pl.clips[pl.base[pl.state] + k])
let adv = dt * pl.rate
pl.t = pl.t + adv
pl.pt = pl.pt + adv
as_one_shot(pl, d)
if pl.mix < 1.0 { pl.mix = Math.min(1.0, pl.mix + dt / pl.fade) }
# anim_play blends TOWARD its second clip, so the weight is how much of the old one is left
return anim_play(anim_st, sk, pl.clip, pl.t, pl.prev, pl.pt, 1.0 - pl.mix)
}
# a new clip fades in from the old one. Within a state it carries the PHASE across: two leg cycles
# restarted at zero put the far leg forward for the length of the fade, which reads as a stumble.
function as_switch(pl: AnimPlayer, d: AnimStateDef, want: Clip) -> void {
var fade = d.blend
if pl.fade_next > 0.0 { fade = pl.fade_next }
pl.fade_next = -1.0
if pl.clip == null {
pl.clip = want
pl.t = 0.0
pl.prev = null
pl.mix = 1.0
return
}
if want == pl.clip { return }
var ph = 0.0
if d.keep_phase and not pl.fresh and pl.clip.dur > 0.0 { ph = pl.t % pl.clip.dur / pl.clip.dur }
pl.prev = pl.clip
pl.pt = pl.t
pl.clip = want
pl.t = ph * want.dur
pl.fade = fade
pl.mix = 0.0
if not (fade > 0.0) { pl.mix = 1.0 }
}
# a clip that does not loop holds its last pose, and hands over to its `next` state when played
function as_one_shot(pl: AnimPlayer, d: AnimStateDef) -> void {
if d.loop or pl.clip.dur <= 0.0 or pl.t < pl.clip.dur { return }
pl.t = pl.clip.dur * 0.999
if d.next == "" { return }
let i = as_state(AnimSets[pl.set], d.next)
if i >= 0 { pl.want = i }
}

View file

@ -0,0 +1,116 @@
# animset_test.ludic - an animation set played on a fake rig (clips made by hand, a two-bone skin
# from a glTF document with no GPU): a choice by its need bands, rate_by held to the state's range,
# a one-shot handing on to `next`, a `when` edge, and a request taken once
import "ludic.render3d/r3d.ludic"
import "ludic.anim"
program AnimSetTest {
numbers float
# never called: naming Input links the engine's runtime, which render3d's textures stand on
function links_runtime() -> bool { return Input.key_pressed(0) }
def AnimSets from "sets.lres"
state ToyState {
hop: bool = false
}
function toy_hops(toy_st: ToyState) -> bool { return toy_st.hop }
function skin(render3d_st: mut Render3dState) -> Skin {
render3d_st.gltf_doc = Json.parse("{\"nodes\": [{\"name\": \"root\", \"children\": [1]}, {\"name\": \"bone\", \"translation\": [0, 1, 0]}], \"skins\": [{\"joints\": [0, 1]}]}")
return skin_load(render3d_st, 0)
}
# one rotation channel on the bone, `dur` seconds long
function clip(name: string, dur: float) -> Clip {
let c = new Clip
c.name = name
c.n = 1
c.cnode = words(1)
c.cpath = words(1)
c.ckeys = words(1)
c.coff = words(1)
c.cvoff = words(1)
c.cnode[0] = 1
c.cpath[0] = AC_ROT
c.ckeys[0] = 2
c.times = floats(2)
c.times[1] = dur
c.vals = floats(8)
c.vals[3] = 1.0
c.vals[7] = 1.0
c.dur = dur
return c
}
# the rig "rig": every clip the set names, bound and ready
function rig(anim_st: mut AnimState) -> AnimPlayer {
anim_clear(anim_st)
anim_add(anim_st, clip("rig_idle", 1.0))
anim_add(anim_st, clip("rig_walk", 1.0))
anim_add(anim_st, clip("rig_run", 1.0))
anim_add(anim_st, clip("rig_wave", 0.5))
anim_add(anim_st, clip("rig_hop", 1.0))
let pl = new AnimPlayer
expect(animset_bind(anim_st, pl, ASET_TOY, "rig"))
return pl
}
function playing(pl: AnimPlayer) -> string { return animset_clip(pl).name }
test "a state plays its first choice whose need bands hold" (anim_st: mut AnimState, render3d_st: mut Render3dState) {
let sk = skin(render3d_st)
let pl = rig(anim_st)
expect(animset_tick(anim_st, pl, sk, 0.016))
expect(playing(pl) == "rig_idle")
animset_param(pl, "speed", 1.0)
expect(animset_tick(anim_st, pl, sk, 0.016))
expect(playing(pl) == "rig_walk")
animset_param(pl, "speed", 5.0)
expect(animset_tick(anim_st, pl, sk, 0.016))
expect(playing(pl) == "rig_run")
animset_param(pl, "speed", 4.0)
expect(animset_tick(anim_st, pl, sk, 0.016))
expect(playing(pl) == "rig_walk")
}
test "rate_by is the parameter over the ground a cycle covers, held to the state's range" (anim_st: mut AnimState, render3d_st: mut Render3dState) {
let sk = skin(render3d_st)
let pl = rig(anim_st)
animset_param(pl, "speed", 3.0)
animset_tick(anim_st, pl, sk, 0.016)
expect_eq(animset_rate(pl), 0.75)
animset_param(pl, "speed", 1.0)
animset_tick(anim_st, pl, sk, 0.016)
expect_eq(animset_rate(pl), 0.5)
animset_param(pl, "speed", 9.0)
animset_tick(anim_st, pl, sk, 0.016)
expect_eq(animset_rate(pl), 2.0)
}
test "a one-shot plays through and hands on to its next state" (anim_st: mut AnimState, render3d_st: mut Render3dState) {
let sk = skin(render3d_st)
let pl = rig(anim_st)
animset_request(pl, "wave")
animset_tick(anim_st, pl, sk, 0.1)
expect(playing(pl) == "rig_wave")
animset_tick(anim_st, pl, sk, 0.3)
animset_tick(anim_st, pl, sk, 0.3)
expect(playing(pl) == "rig_wave")
animset_tick(anim_st, pl, sk, 0.016)
expect(playing(pl) == "rig_idle")
}
test "a when edge is taken the frame its function says so" (anim_st: mut AnimState, render3d_st: mut Render3dState, toy_st: mut ToyState) {
let sk = skin(render3d_st)
let pl = rig(anim_st)
toy_st.hop = false
animset_tick(anim_st, pl, sk, 0.016)
expect(playing(pl) == "rig_idle")
toy_st.hop = true
animset_tick(anim_st, pl, sk, 0.016)
expect(playing(pl) == "rig_hop")
toy_st.hop = false
}
test "a request asked every frame is taken once, not re-applied after the graph moves on" (anim_st: mut AnimState, render3d_st: mut Render3dState) {
let sk = skin(render3d_st)
let pl = rig(anim_st)
for i in 0 .. 12 {
animset_request(pl, "wave")
animset_tick(anim_st, pl, sk, 0.1)
}
expect(playing(pl) == "rig_idle")
}
}

View file

@ -0,0 +1,20 @@
# sets.lres - the test's one set: a ground state picking by speed bands, a one-shot that hands back,
# and a hop the code never asks for, taken when toy_hops says so
toy {
entry: "ground"
params: ["speed"]
states: [
{
key: "ground"
rate_min: 0.5, rate_max: 2.0, blend: 0.2
choices: [
{ clip: "run", rate_by: "speed", ground: 2.0, need: [{ param: "speed", over: 4.0 }] },
{ clip: "walk", rate_by: "speed", ground: 4.0, need: [{ param: "speed", over: 0.5 }] },
{ clip: "idle" }
]
},
{ key: "wave", loop: false, next: "ground", choices: [{ clip: "wave" }] },
{ key: "hop", choices: [{ clip: "hop" }] }
]
transitions: [{ from: "ground", to: "hop", blend: 0.1, when: fn toy_hops }]
}