diff --git a/packages/README.md b/packages/README.md index 3b5067fd..92e5a063 100644 --- a/packages/README.md +++ b/packages/README.md @@ -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`, 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) | diff --git a/packages/ludic.anim/README.md b/packages/ludic.anim/README.md new file mode 100644 index 00000000..8cf0127e --- /dev/null +++ b/packages/ludic.anim/README.md @@ -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. diff --git a/packages/ludic.anim/clip.ludic b/packages/ludic.anim/clip.ludic new file mode 100644 index 00000000..27c37b98 --- /dev/null +++ b/packages/ludic.anim/clip.ludic @@ -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 +} diff --git a/packages/ludic.anim/index.ludic b/packages/ludic.anim/index.ludic new file mode 100644 index 00000000..fb1f8300 --- /dev/null +++ b/packages/ludic.anim/index.ludic @@ -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" diff --git a/packages/ludic.anim/mix.ludic b/packages/ludic.anim/mix.ludic new file mode 100644 index 00000000..825c12d7 --- /dev/null +++ b/packages/ludic.anim/mix.ludic @@ -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) + } +} diff --git a/packages/ludic.anim/package.ludic b/packages/ludic.anim/package.ludic new file mode 100644 index 00000000..110b00c5 --- /dev/null +++ b/packages/ludic.anim/package.ludic @@ -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 diff --git a/packages/ludic.anim/pose.ludic b/packages/ludic.anim/pose.ludic new file mode 100644 index 00000000..a96f6c92 --- /dev/null +++ b/packages/ludic.anim/pose.ludic @@ -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 +} diff --git a/packages/ludic.anim/read.ludic b/packages/ludic.anim/read.ludic new file mode 100644 index 00000000..e2f3a518 --- /dev/null +++ b/packages/ludic.anim/read.ludic @@ -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 +} diff --git a/packages/ludic.anim/tests/anim_test.ludic b/packages/ludic.anim/tests/anim_test.ludic new file mode 100644 index 00000000..6cfe5c1e --- /dev/null +++ b/packages/ludic.anim/tests/anim_test.ludic @@ -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) + } +}