feat(packages): ludic.anim - glTF animation clips read out of the parsed document, sampled by halving, cross-faded two at a time and folded into a render3d Skin's pose; clips per rig by name, translations off unless asked, a late first key reported

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 08:12:29 +03:00
parent ab5584f421
commit c446c07a4c
9 changed files with 530 additions and 0 deletions

View file

@ -13,6 +13,7 @@ section. The rules are in [ludic.base](ludic.base/README.md).
| | |
| --- | --- |
| [ludic.base](ludic.base/README.md) | the shared vocabulary: Tick and phases, `Queue<T>`, rng streams, the save tree, the system runner (`Systems`, an open registry) |
| [ludic.anim](ludic.anim/README.md) | glTF animation clips sampled and cross-faded onto a render3d Skin (uses `ludic_render3d`) |
| [ludic.clock](ludic.clock/README.md) | the hour, the day, the moon and a calendar of seasons |
| [ludic.crafting](ludic.crafting/README.md) | recipes: what goes in and out, where and how long, making, refunding, hold-to-make |
| [ludic.effects](ludic.effects/README.md) | timed modifiers that run down in game time (a meal's warmth, a drink's legs) |

View file

@ -0,0 +1,49 @@
# ludic.anim
The animation clips an animator keyed into a glTF, played on a render3d `Skin` and cross-faded two
at a time. `ludic.render3d` parses no animations and offers a pose api (`skin_set_quat`,
`skin_set_offset`, `skin_pose`) rather than a player, so the sampler lives here. Uses
`ludic_render3d` and nothing else: it reads the document `gltf_load` just parsed and writes a
Skin's pose. It asks the game nothing, so it has no port.
```ludic
import "ludic.anim"
let m = gltf_load("assets/kit/hiker2", "hiker_m.gltf", "hiker_m")
anim_read_doc("assets/kit/hiker2") # straight after the load, before the next
let walk = anim_find("hiker_m_walk")
anim_play(sk, walk, t, idle, t2, 0.3) # 70% walk, 30% idle, into the joint matrices
```
## API
| | |
| --- | --- |
| `Clip { name, dur, n, cnode, cpath, ckeys, coff, cvoff, times, vals }`, `AC_ROT`, `AC_POS` | a clip, its channels flattened: a channel's keys are `times[coff..coff+ckeys]`, its values 4 (rotation) or 3 (translation) per key from `cvoff` |
| `anim_read_doc(dir) -> int` | every rotation and translation channel of every animation in `gltf_doc`, reading the `.bin` beside it in `dir`; how many clips were new |
| `anim_find(name) -> Clip`, `anim_count()`, `anim_add(c) -> bool`, `anim_clear()` | the table, by name; a name already held is skipped on read and refused on add |
| `anim_play(sk, a, ta, b, tb, blend) -> bool` | `a` at `ta`, cross-faded toward `b` at `tb` by `blend` (0 all a, 1 all b; `b` may be null), folded into the joint matrices |
| `anim_mix(c, sk, t, w, rot, pos, hit)`, `anim_to_pose(sk, rot, pos, hit)`, `anim_key_at(c, ch, t)` | the pieces `anim_play` is made of, for a caller that mixes more than two |
| `anim_set_positions(on)`, `anim_positions()` | keyed translations applied or not (off: baking keys location on every bone, and rounding down an eighteen-bone chain is a collapsed animal) |
| `anim_first_key(c) -> float` | the earliest key; a read warns when it is not zero |
## Rules it keeps
- **Clips are named per rig.** A channel addresses its FILE's node indices, two files number their
nodes differently, and a name already held is skipped - so one shared name would hand the
second body the first one's skeleton. Name them `hiker_m_walk` / `hiker_f_walk`, `bear_walk`.
- **Read straight after the load.** `gltf_doc` is whichever file was parsed last, and `gltf_load`
closes the `.bin`, which `anim_read_doc` reopens (a read through the closed handle is zeros).
- **Key from frame zero.** A clip wraps on its duration and holds below its first key, so a clip
keyed from frame 1 holds its opening pose for 1/30 s every cycle. `anim_read_doc` prints it.
- **The math.** `skin_pose` takes a delta D in the model frame, `local = (G_p^-1 D G_p) R_rest`,
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.
## Tests
```bash
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.

View file

@ -0,0 +1,66 @@
# clip.ludic - a clip: its channels flattened into one run of key times and one of values, and the
# table of every clip read so far, found by name
export const AC_ROT: int = 0
export const AC_POS: int = 1
export property Clip {
name: string = "",
dur: float = 0.0, # seconds: the last key of any channel
n: int = 0, # channels
cnode: words, # target node per channel (the FILE's node index)
cpath: words, # AC_ROT or AC_POS
ckeys: words, # keys per channel
coff: words, # where this channel starts in `times`
cvoff: words, # where it starts in `vals`
times: floats,
vals: floats
}
var clips: []Clip = null
# Keyed translations are off unless asked for: baking keys location on every bone, and a skeleton
# does not translate its joints, so applying them turns rounding into a pose.
var positions: bool = false
function clips_init() -> void {
if clips == null { clips = new []Clip }
}
# the clip of that name, or null; names are per file (per body, per species), never shared
export function anim_find(name: string) -> Clip {
clips_init()
for i in 0 .. len(clips) { if clips[i].name == name { return clips[i] } }
return null
}
export function anim_count() -> int {
clips_init()
return len(clips)
}
# a clip made outside a glTF (a test, a generated clip); a name already held is refused
export function anim_add(c: Clip) -> bool {
if c == null or anim_find(c.name) != null { return false }
push(clips, c)
return true
}
export function anim_clear() -> void { clips = new []Clip }
export function anim_set_positions(on: bool) -> void { positions = on }
export function anim_positions() -> bool { return positions }
# Which key pair straddles time t, by halving rather than walking: a grazing clip is three
# hundred keys long and a valley can have dozens of animals posed in a frame.
export function anim_key_at(c: Clip, ch: int, t: float) -> int {
let n = c.ckeys[ch]
let o = c.coff[ch]
if n <= 1 { return 0 }
if t < c.times[o] { return 0 }
if t > c.times[o + n - 1] { return n - 2 }
var lo = 0
var hi = n - 1
while hi - lo > 1 {
let mid = (lo + hi) / 2
if c.times[o + mid] > t { hi = mid } else { lo = mid }
}
return lo
}

View file

@ -0,0 +1,9 @@
# ludic.anim - playing the clips an animator keyed into a glTF, and blending two of them.
# render3d parses no animations and offers a POSE api rather than a player, so the sampler is here
module ludic_anim uses ludic_render3d
numbers float
import "ludic.render3d/r3d.ludic"
import "clip.ludic"
import "read.ludic"
import "mix.ludic"
import "pose.ludic"

View file

@ -0,0 +1,69 @@
# mix.ludic - sample one clip at a time into per-node buffers, mixed in at a weight. `rot` is 4 per
# node, `pos` 3 per node, and `hit` says which nodes have received a value (bit 0 a rotation, bit 1
# a translation), so a node the clips never mention stays at rest
var q_a: floats = null
var q_b: floats = null
var q_cur: floats = null
export function anim_mix(c: Clip, sk: Skin, t: float, w: float, rot: floats, pos: floats, hit: words) -> void {
if c == null or sk == null { return }
if q_a == null {
q_a = floats(4)
q_b = floats(4)
q_cur = floats(4)
}
var tt = t
if c.dur > 0.0 { tt = t % c.dur }
for ch in 0 .. c.n {
let node = c.cnode[ch]
if node < 0 or node >= sk.n_nodes { continue }
let n = c.ckeys[ch]
let o = c.coff[ch]
let i0 = anim_key_at(c, ch, tt)
var i1 = i0 + 1
if i1 > n - 1 { i1 = n - 1 }
var f = 0.0
if i1 > i0 {
let span = c.times[o + i1] - c.times[o + i0]
if span > 0.000001 { f = Math.clamp((tt - c.times[o + i0]) / span, 0.0, 1.0) }
}
if c.cpath[ch] == AC_ROT { mix_rot(c, ch, node, i0, i1, f, w, rot, hit) } else { mix_pos(c, ch, node, i0, i1, f, w, pos, hit) }
}
}
# Across clips the first contributor lands and the rest are nlerped onto it by their share, which
# is what makes a cross-fade rather than a jump. Test the ROTATION bit, not the whole word: glTF
# writes a node's translation before its rotation, and `hit == 0` then skipped every rotation.
function mix_rot(c: Clip, ch: int, node: int, i0: int, i1: int, f: float, w: float, rot: floats, hit: words) -> void {
let vo = c.cvoff[ch]
for e in 0 .. 4 {
q_a[e] = c.vals[vo + i0 * 4 + e]
q_b[e] = c.vals[vo + i1 * 4 + e]
}
q_nlerp(q_a, q_a, q_b, f)
if (hit[node] & 1) == 0 {
q_store(rot, node, q_a)
hit[node] = hit[node] | 1
} else {
q_load(q_cur, rot, node)
q_nlerp(q_cur, q_cur, q_a, w)
q_store(rot, node, q_cur)
}
}
function mix_pos(c: Clip, ch: int, node: int, i0: int, i1: int, f: float, w: float, pos: floats, hit: words) -> void {
let vo = c.cvoff[ch]
let ax = Math.lerp(c.vals[vo + i0 * 3], c.vals[vo + i1 * 3], f)
let ay = Math.lerp(c.vals[vo + i0 * 3 + 1], c.vals[vo + i1 * 3 + 1], f)
let az = Math.lerp(c.vals[vo + i0 * 3 + 2], c.vals[vo + i1 * 3 + 2], f)
if (hit[node] & 2) == 0 {
pos[node * 3] = ax
pos[node * 3 + 1] = ay
pos[node * 3 + 2] = az
hit[node] = hit[node] | 2
} else {
pos[node * 3] = Math.lerp(pos[node * 3], ax, w)
pos[node * 3 + 1] = Math.lerp(pos[node * 3 + 1], ay, w)
pos[node * 3 + 2] = Math.lerp(pos[node * 3 + 2], az, w)
}
}

View file

@ -0,0 +1,6 @@
# ludic.anim - the animation clips that ride inside a glTF, read out of the document render3d
# just parsed, sampled at a time and cross-faded, and folded into a Skin's pose. Uses
# ludic.render3d (the Skin, the glTF document and the quaternions). See README.md.
package "ludic.anim"
version "0.1.0"
kind source

View file

@ -0,0 +1,78 @@
# pose.ludic - the sampled LOCAL transforms turned into the model-frame deltas `skin_pose` wants.
# skin_pose folds a delta D in as local = (G_p^-1 . D . G_p) . R_rest, and a channel gives the
# absolute local L, so D = G_p . (L . R_rest^-1) . G_p^-1 (a root: D = L . R_rest^-1)
var parts: []floats = null
var rot_s: floats = null
var pos_s: floats = null
var hit_s: words = null
var cap: int = 0
function part(i: int) -> floats {
if parts == null {
parts = new []floats
for k in 0 .. 8 { push(parts, floats(4)) }
}
return parts[i]
}
export function anim_to_pose(sk: Skin, rot: floats, pos: floats, hit: words) -> void {
let l = part(0)
let rinv = part(1)
let gp = part(2)
let gpi = part(3)
let tmp = part(4)
let d = part(5)
let v = part(6)
for i in 0 .. sk.n_nodes {
let p = sk.par[i]
if (hit[i] & 1) != 0 {
q_load(l, rot, i)
q_load(rinv, sk.rest_r, i)
q_conj(rinv, rinv)
q_mul(tmp, l, rinv)
if p >= 0 {
q_load(gp, sk.rest_g, p)
q_conj(gpi, gp)
q_mul(d, gp, tmp)
q_mul(d, d, gpi)
} else { q_copy(d, tmp) }
q_normalize(d)
skin_set_quat(sk, i, d)
}
if (hit[i] & 2) != 0 and positions {
let dx = pos[i * 3] - sk.rest_t[i * 3]
let dy = pos[i * 3 + 1] - sk.rest_t[i * 3 + 1]
let dz = pos[i * 3 + 2] - sk.rest_t[i * 3 + 2]
if p >= 0 {
q_load(gp, sk.rest_g, p)
v3_set(v, dx, dy, dz)
q_rotate(v, gp, v)
skin_set_offset(sk, i, v[0], v[1], v[2])
} else { skin_set_offset(sk, i, dx, dy, dz) }
}
}
}
# The scratch is made ONCE and grown, never allocated per call: a call is per actor per frame.
function scratch(n: int) -> void {
if cap >= n { return }
rot_s = floats(n * 4)
pos_s = floats(n * 3)
hit_s = words(n)
cap = n
}
# Play `a` at time `ta`, cross-faded toward `b` at `tb` by `blend` (0 is all a, 1 is all b), and
# fold it into the joint matrices. `b` may be null. False when there is no skin or no clip.
export function anim_play(sk: Skin, a: Clip, ta: float, b: Clip, tb: float, blend: float) -> bool {
if sk == null or a == null { return false }
let n = sk.n_nodes
scratch(n)
for i in 0 .. n { hit_s[i] = 0 }
skin_reset(sk)
anim_mix(a, sk, ta, 1.0, rot_s, pos_s, hit_s)
if b != null and blend > 0.001 { anim_mix(b, sk, tb, blend, rot_s, pos_s, hit_s) }
anim_to_pose(sk, rot_s, pos_s, hit_s)
skin_pose(sk)
return true
}

View file

@ -0,0 +1,112 @@
# read.ludic - every animation out of the document `gltf_load` just parsed: rotation and
# translation channels, flattened into a Clip each; a name already held is skipped
# Call it straight after a load and before the next one: `gltf_doc` and `gltf_bin` point at
# whichever file was read last, so a clip that is not taken now cannot be taken later.
export function anim_read_doc(dir: string) -> int {
clips_init()
if gltf_doc == null { return 0 }
if value_has(gltf_doc, "animations") == 0 { return 0 }
# REOPEN THE .bin. `gltf_load` closes it before it returns, and an accessor read through a closed
# handle returns a buffer of the right size full of zeros: right names, right counts, no motion.
let buffers = value_get(gltf_doc, "buffers")
if value_count(buffers) == 0 { return 0 }
let bin_uri = value_as_str(value_get(value_at(buffers, 0), "uri"))
gltf_bin = file_open(dir + "/" + bin_uri, "rb")
if gltf_bin == null {
print(`anim: cannot reopen {dir}/{bin_uri}`)
return 0
}
let arr = value_get(gltf_doc, "animations")
var got = 0
for ai in 0 .. value_count(arr) {
let c = read_one(value_at(arr, ai))
if c != null {
push(clips, c)
got += 1
}
}
file_close(gltf_bin)
gltf_bin = null
return got
}
function kept_path(ch: Val) -> string {
let tgt = value_get(ch, "target")
if value_has(tgt, "node") == 0 { return "" }
let path = value_as_str(value_get(tgt, "path"))
if path != "rotation" and path != "translation" { return "" }
return path
}
function read_one(a: Val) -> Clip {
var nm = ""
if value_has(a, "name") != 0 { nm = value_as_str(value_get(a, "name")) }
if anim_find(nm) != null { return null } # a second rig's file, same clip
let chans = value_get(a, "channels")
let samps = value_get(a, "samplers")
let nc = value_count(chans)
# count first: the flat arrays are sized once rather than grown per channel
var n_keep = 0
var n_t = 0
var n_v = 0
for ci in 0 .. nc {
let ch = value_at(chans, ci)
let path = kept_path(ch)
if path == "" { continue }
let sp = value_at(samps, value_as_int(value_get(ch, "sampler")))
let cnt = gltf_accessor_count(value_as_int(value_get(sp, "input")))
n_keep += 1
n_t += cnt
if path == "rotation" { n_v += cnt * 4 } else { n_v += cnt * 3 }
}
if n_keep == 0 { return null }
let c = new Clip
c.name = nm
c.n = n_keep
c.cnode = words(n_keep)
c.cpath = words(n_keep)
c.ckeys = words(n_keep)
c.coff = words(n_keep)
c.cvoff = words(n_keep)
c.times = floats(n_t)
c.vals = floats(n_v)
var k = 0
var at = 0
var av = 0
for ci in 0 .. nc {
let ch = value_at(chans, ci)
let path = kept_path(ch)
if path == "" { continue }
let sp = value_at(samps, value_as_int(value_get(ch, "sampler")))
c.cnode[k] = value_as_int(value_get(value_get(ch, "target"), "node"))
if path == "rotation" { c.cpath[k] = AC_ROT } else { c.cpath[k] = AC_POS }
let tb = gltf_accessor_floats(value_as_int(value_get(sp, "input")))
let nk = gltf_count
c.ckeys[k] = nk
c.coff[k] = at
for i in 0 .. nk { c.times[at + i] = tb[i] }
if nk > 0 and c.times[at + nk - 1] > c.dur { c.dur = c.times[at + nk - 1] }
at += nk
let vb = gltf_accessor_floats(value_as_int(value_get(sp, "output")))
var comps = 3
if c.cpath[k] == AC_ROT { comps = 4 }
c.cvoff[k] = av
for i in 0 .. nk * comps { c.vals[av + i] = vb[i] }
av += nk * comps
k += 1
}
if anim_first_key(c) > 0.001 { print(`anim: {nm} starts at {anim_first_key(c)} s - key from frame zero, or every cycle holds its opening pose`) }
return c
}
# the earliest key of any channel: a clip keyed from frame 1 has 1/30 here, and a hitch every cycle
export function anim_first_key(c: Clip) -> float {
var t = 0.0
for ch in 0 .. c.n {
if c.ckeys[ch] == 0 { continue }
let s = c.times[c.coff[ch]]
if ch == 0 or s < t { t = s }
}
return t
}

View file

@ -0,0 +1,140 @@
# anim_test.ludic - clips on a two-bone skin made from a glTF document with no GPU: sampling,
# looping, the cross-fade, translations off by default, and reading clips out of a .bin
import "ludic.render3d/r3d.ludic"
import "ludic.anim"
program AnimTest {
numbers float
const HALF: float = 0.70710678
# never called: naming Input links the engine's runtime, which render3d's textures stand on
function links_runtime() -> bool { return Input.key_pressed(0) }
# a root and one child 1 m up, no inverse bind matrices, one skin of both
function skin() -> Skin {
gltf_doc = Json.parse("{\"nodes\": [{\"name\": \"root\", \"children\": [1]}, {\"name\": \"bone\", \"translation\": [0, 1, 0]}], \"skins\": [{\"joints\": [0, 1]}]}")
return skin_load(0)
}
# one channel on node 1: identity at 0, a quarter turn about Z at 1 s
function turn(name: string) -> 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] = 1.0
c.vals = floats(8)
c.vals[3] = 1.0
c.vals[6] = HALF
c.vals[7] = HALF
c.dur = 1.0
return c
}
# where the child's origin lands after posing: rest is (0, 1, 0), a quarter turn of the root would move it
function pose_z(sk: Skin) -> float {
let q = floats(4)
q_load(q, sk.pose_r, 1)
return q[2]
}
test "a clip is found by name, once" {
anim_clear()
expect(anim_add(turn("hiker_m_walk")))
expect(not anim_add(turn("hiker_m_walk")))
expect(anim_add(turn("hiker_f_walk")))
expect_eq(anim_count(), 2)
expect(anim_find("hiker_f_walk") != null)
expect(anim_find("bear_walk") == null)
}
test "a key pair is found by halving" {
let c = turn("t")
expect_eq(anim_key_at(c, 0, 0.5), 0)
expect_eq(anim_key_at(c, 0, 2.0), 0)
expect_eq(anim_key_at(c, 0, -1.0), 0)
}
test "playing samples between keys, and wraps on the duration" {
let sk = skin()
let c = turn("t")
expect(anim_play(sk, c, 0.0, null, 0.0, 0.0))
expect_near(pose_z(sk), 0.0, 0.001)
anim_play(sk, c, 1.0 - 0.0001, null, 0.0, 0.0)
expect_near(pose_z(sk), HALF, 0.01)
anim_play(sk, c, 0.5, null, 0.0, 0.0)
let mid = pose_z(sk)
anim_play(sk, c, 1.5, null, 0.0, 0.0)
expect_near(pose_z(sk), mid, 0.001)
expect(mid > 0.3 and mid < 0.45)
}
test "a cross-fade lands between the two clips" {
let sk = skin()
let a = turn("a")
let b = turn("b")
anim_play(sk, a, 0.0, b, 0.999, 0.5)
let z = pose_z(sk)
expect(z > 0.3 and z < 0.45)
anim_play(sk, a, 0.0, b, 0.999, 0.0)
expect_near(pose_z(sk), 0.0, 0.001)
}
test "no skin or no clip plays nothing" {
expect(not anim_play(null, turn("t"), 0.0, null, 0.0, 0.0))
expect(not anim_play(skin(), null, 0.0, null, 0.0, 0.0))
}
test "keyed translations are ignored until asked for" {
let sk = skin()
let c = turn("t")
c.cpath[0] = AC_POS
c.vals[0] = 0.0
c.vals[1] = 3.0
c.vals[3] = 0.0
c.vals[4] = 3.0
anim_play(sk, c, 0.0, null, 0.0, 0.0)
expect_near(sk.pose_t[4], 0.0, 0.0001)
anim_set_positions(true)
anim_play(sk, c, 0.0, null, 0.0, 0.0)
expect_near(sk.pose_t[4], 2.0, 0.0001)
}
function put_f(b: []byte, at: int, x: float) -> void {
let v = float_bits(x)
b[at] = v & 255
b[at + 1] = (v >> 8) & 255
b[at + 2] = (v >> 16) & 255
b[at + 3] = (v >> 24) & 255
}
test "clips are read out of a glTF's .bin, and a late first key is measured" {
anim_clear()
let dir = Os.temp_dir() + "/ludic-anim-test"
Fs.mkdir(dir)
let b = buffer(40)
put_f(b, 0, 0.0)
put_f(b, 4, 1.0)
put_f(b, 8 + 12, 1.0)
put_f(b, 8 + 24, HALF)
put_f(b, 8 + 28, HALF)
expect(Fs.write_bytes(dir + "/t.bin", b, 40))
gltf_doc = Json.parse("{\"buffers\": [{\"uri\": \"t.bin\"}], \"bufferViews\": [{\"byteOffset\": 0}, {\"byteOffset\": 8}], \"accessors\": [{\"bufferView\": 0, \"count\": 2, \"type\": \"SCALAR\"}, {\"bufferView\": 1, \"count\": 2, \"type\": \"VEC4\"}], \"animations\": [{\"name\": \"bear_walk\", \"samplers\": [{\"input\": 0, \"output\": 1}], \"channels\": [{\"sampler\": 0, \"target\": {\"node\": 1, \"path\": \"rotation\"}}, {\"sampler\": 0, \"target\": {\"node\": 1, \"path\": \"scale\"}}]}]}")
expect_eq(anim_read_doc(dir), 1)
expect_eq(anim_read_doc(dir), 0)
let c = anim_find("bear_walk")
expect(c != null)
expect_eq(c.n, 1)
expect_near(c.dur, 1.0, 0.0001)
expect_near(c.vals[7], HALF, 0.0001)
expect_near(anim_first_key(c), 0.0, 0.0001)
c.times[0] = 1.0 / 30.0
expect(anim_first_key(c) > 0.03)
}
}