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:
parent
ab5584f421
commit
c446c07a4c
9 changed files with 530 additions and 0 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
49
packages/ludic.anim/README.md
Normal file
49
packages/ludic.anim/README.md
Normal 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.
|
||||
66
packages/ludic.anim/clip.ludic
Normal file
66
packages/ludic.anim/clip.ludic
Normal 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
|
||||
}
|
||||
9
packages/ludic.anim/index.ludic
Normal file
9
packages/ludic.anim/index.ludic
Normal 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"
|
||||
69
packages/ludic.anim/mix.ludic
Normal file
69
packages/ludic.anim/mix.ludic
Normal 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)
|
||||
}
|
||||
}
|
||||
6
packages/ludic.anim/package.ludic
Normal file
6
packages/ludic.anim/package.ludic
Normal 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
|
||||
78
packages/ludic.anim/pose.ludic
Normal file
78
packages/ludic.anim/pose.ludic
Normal 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
|
||||
}
|
||||
112
packages/ludic.anim/read.ludic
Normal file
112
packages/ludic.anim/read.ludic
Normal 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
|
||||
}
|
||||
140
packages/ludic.anim/tests/anim_test.ludic
Normal file
140
packages/ludic.anim/tests/anim_test.ludic
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue