From 5517873275c6f1953eaeebabfa4a4eebc2613cdb Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 19:01:15 +0300 Subject: [PATCH] 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 --- packages/ludic.anim/README.md | 16 ++- packages/ludic.anim/index.ludic | 6 + packages/ludic.anim/set_bind.ludic | 53 +++++++++ packages/ludic.anim/set_follow.ludic | 40 +++++++ packages/ludic.anim/set_pick.ludic | 56 +++++++++ packages/ludic.anim/set_player.ludic | 29 +++++ packages/ludic.anim/set_records.ludic | 55 +++++++++ packages/ludic.anim/set_tick.ludic | 52 +++++++++ packages/ludic.anim/tests/animset_test.ludic | 116 +++++++++++++++++++ packages/ludic.anim/tests/sets.lres | 20 ++++ 10 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 packages/ludic.anim/set_bind.ludic create mode 100644 packages/ludic.anim/set_follow.ludic create mode 100644 packages/ludic.anim/set_pick.ludic create mode 100644 packages/ludic.anim/set_player.ludic create mode 100644 packages/ludic.anim/set_records.ludic create mode 100644 packages/ludic.anim/set_tick.ludic create mode 100644 packages/ludic.anim/tests/animset_test.ludic create mode 100644 packages/ludic.anim/tests/sets.lres diff --git a/packages/ludic.anim/README.md b/packages/ludic.anim/README.md index 8007b113..8d1ce047 100644 --- a/packages/ludic.anim/README.md +++ b/packages/ludic.anim/README.md @@ -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. diff --git a/packages/ludic.anim/index.ludic b/packages/ludic.anim/index.ludic index 5292772e..b0bc8189 100644 --- a/packages/ludic.anim/index.ludic +++ b/packages/ludic.anim/index.ludic @@ -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" diff --git a/packages/ludic.anim/set_bind.ludic b/packages/ludic.anim/set_bind.ludic new file mode 100644 index 00000000..d82b41fd --- /dev/null +++ b/packages/ludic.anim/set_bind.ludic @@ -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 +} diff --git a/packages/ludic.anim/set_follow.ludic b/packages/ludic.anim/set_follow.ludic new file mode 100644 index 00000000..299bd6ac --- /dev/null +++ b/packages/ludic.anim/set_follow.ludic @@ -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 +} diff --git a/packages/ludic.anim/set_pick.ludic b/packages/ludic.anim/set_pick.ludic new file mode 100644 index 00000000..b44efe02 --- /dev/null +++ b/packages/ludic.anim/set_pick.ludic @@ -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) +} diff --git a/packages/ludic.anim/set_player.ludic b/packages/ludic.anim/set_player.ludic new file mode 100644 index 00000000..5c7b0b92 --- /dev/null +++ b/packages/ludic.anim/set_player.ludic @@ -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 } diff --git a/packages/ludic.anim/set_records.ludic b/packages/ludic.anim/set_records.ludic new file mode 100644 index 00000000..357f6e3f --- /dev/null +++ b/packages/ludic.anim/set_records.ludic @@ -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 `_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 diff --git a/packages/ludic.anim/set_tick.ludic b/packages/ludic.anim/set_tick.ludic new file mode 100644 index 00000000..2fc64123 --- /dev/null +++ b/packages/ludic.anim/set_tick.ludic @@ -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 } +} diff --git a/packages/ludic.anim/tests/animset_test.ludic b/packages/ludic.anim/tests/animset_test.ludic new file mode 100644 index 00000000..9e1a849c --- /dev/null +++ b/packages/ludic.anim/tests/animset_test.ludic @@ -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") + } +} diff --git a/packages/ludic.anim/tests/sets.lres b/packages/ludic.anim/tests/sets.lres new file mode 100644 index 00000000..c4d2128e --- /dev/null +++ b/packages/ludic.anim/tests/sets.lres @@ -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 }] +}