feat(ludic.anim): ozz-animation 0.17.0 underneath - a skin's skeleton and each clip built at load, sampled to 1e-4 of anim_mix (phase 19.1)

No bake and no new file: the skeleton comes from the skin's parents and rest pose (its own joints
and their ancestors - a kit holds several rigs), a clip from anim_read_doc's channels. Built by
native/build.sh from the pinned release; the Windows DLL imports KERNEL32 alone and passes there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-28 12:10:41 +03:00
parent 22d1e2d66a
commit 9a56ee9b9d
14 changed files with 419 additions and 1 deletions

9
changes/anim-ozz.md Normal file
View file

@ -0,0 +1,9 @@
bump: minor
type: feature
**`ludic.anim` carries ozz-animation (0.17.0, MIT) as a native library**, the second package to do
so after `ludic.physics`. Its skeleton and each clip are built at LOAD from the numbers the package
already reads - a skin's parents and rest pose, a clip's flattened channels - so there is no bake
step and no new file. `anim_oz_skel(sk)`, `anim_oz_clip(c)`, `anim_oz_ctx(s)` and
`anim_oz_sample(ctx, clip, t, rot, pos, n)` are the first step (Maroon Lake's phase 19.1): a sampled
rotation agrees with `anim_mix` to within 1e-4 a component. `anim_play` is unchanged. The library is
built by `native/build.sh` from the pinned release, and on Windows it imports KERNEL32 alone.

View file

@ -14,7 +14,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.aim](ludic.aim/README.md) | what the crosshair is on: a forgiving ray against cylinders, the ground in front, reach floors, which thing the use key means |
| [ludic.anim](ludic.anim/README.md) | glTF animation clips sampled and cross-faded onto a render3d Skin (uses `ludic_render3d`) |
| [ludic.anim](ludic.anim/README.md) | glTF animation clips sampled and cross-faded onto a render3d Skin (uses `ludic_render3d`); ozz-animation underneath as a native library (phase 19) |
| [ludic.character](ludic.character/README.md) | a third-person walking body: gait, steps and slopes, a jump, wading, swimming, sitting, a safe put-down, the orbit camera |
| [ludic.audio](ludic.audio/README.md) | world sounds with a gain, pan and pitch from a listener port, flat interface sounds, packed clips loaded, the one door to `Audio.*` |
| [ludic.compass](ludic.compass/README.md) | bearing marks on a strip: gathered from registered providers, gated by tier, sorted by priority, bearings, distances and a radar |

View file

@ -27,6 +27,24 @@ anim_play(sk, walk, t, idle, t2, 0.3) # 70% walk, 30% idle, into th
| `skin_joint(sk, name)`, `skin_joint_quiet(sk, name)` | a bone by name in the skin's OWN joints: a kit packs several rigs with the same bone names, and render3d's `skin_find` answers the file's first, which belongs to another rig (the pose is then stored and never drawn) |
| `anim_first_key(c) -> float` | the earliest key; a read warns when it is not zero |
## ozz-animation underneath
[ozz-animation](https://github.com/guillaumeblanc/ozz-animation) 0.17.0 (MIT, `native/LICENSE-ozz-animation.md`)
is compiled into `libludicozz` by `native/build.sh` from the pinned release: its base, its runtime jobs and
the offline builders for a skeleton and a clip. There is no bake and no new file. The skeleton is
built from a skin's parents and rest pose, taking only its own joints and their ancestors, because a
kit holds several rigs. Each clip is built from the channels `anim_read_doc` already read.
| | |
| --- | --- |
| `anim_oz_skel(sk) -> pointer`, `anim_oz_joints(s)`, `anim_oz_skel_free(s)` | a skin's skeleton |
| `anim_oz_clip(c) -> pointer`, `anim_oz_clip_bytes(c)`, `anim_oz_clip_free(c)` | a clip for that skeleton, compressed (translations only with `anim_set_positions`) |
| `anim_oz_ctx(s)`, `anim_oz_ctx_free(c)` | what one actor samples with, made once |
| `anim_oz_sample(ctx, clip, t, rot, pos, n) -> bool` | the clip at t (wrapped), each node's local rotation (4) and translation (3) at its file node |
A sampled rotation agrees with `anim_mix` to within 1e-4 a component (ozz keeps rotations in 16 bits).
`anim_play` still runs the Ludic sampler until the callers move (Maroon Lake's phase 19).
## Rules it keeps
- **Clips are named per rig.** A channel addresses its FILE's node indices, two files number their

View file

@ -3,8 +3,10 @@
module ludic_anim uses ludic_render3d
numbers float
import "ludic.render3d/r3d.ludic"
import "native.ludic"
import "clip.ludic"
import "read.ludic"
import "mix.ludic"
import "pose.ludic"
import "bones.ludic"
import "ozz.ludic"

BIN
packages/ludic.anim/lib/macos-arm64/libludicozz.dylib (Stored with Git LFS) Executable file

Binary file not shown.

BIN
packages/ludic.anim/lib/windows-x64/ludicozz.dll (Stored with Git LFS) Normal file

Binary file not shown.

BIN
packages/ludic.anim/lib/windows-x64/ludicozz.lib (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,12 @@
# native.ludic - the shim's symbols (native/shim/ozz_shim.cpp). A skeleton, a clip and a context are
# handles the package keeps. None of this is exported.
extern function ozz_skel_new(n: int, par: pointer, rest: pointer) -> pointer = "ozz_skel_new"
extern function ozz_skel_free(s: pointer) -> void = "ozz_skel_free"
extern function ozz_skel_joints(s: pointer) -> int = "ozz_skel_joints"
extern function ozz_skel_joint_of(s: pointer, node: int) -> int = "ozz_skel_joint_of"
extern function ozz_clip_new(s: pointer, dur: float, nch: int, cnode: pointer, cpath: pointer, ckeys: pointer, coff: pointer, cvoff: pointer, times: pointer, vals: pointer, positions: int) -> pointer = "ozz_clip_new"
extern function ozz_clip_free(c: pointer) -> void = "ozz_clip_free"
extern function ozz_clip_bytes(c: pointer) -> int = "ozz_clip_bytes"
extern function ozz_ctx_new(s: pointer) -> pointer = "ozz_ctx_new"
extern function ozz_ctx_free(c: pointer) -> void = "ozz_ctx_free"
extern function ozz_sample(ctx: pointer, clip: pointer, t: float, rot: pointer, pos: pointer, n: int) -> int = "ozz_sample"

View file

@ -0,0 +1,23 @@
ozz-animation is hosted at http://github.com/guillaumeblanc/ozz-animation and
distributed under the MIT License (MIT).
---
Copyright (c) 2020 Guillaume Blanc
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,29 @@
#!/bin/sh
# builds lib/<target>/ for ludic.anim: ozz-animation at a pinned tag - its base, its runtime jobs and
# the offline builders that make a skeleton and a clip at load - and the shim over them. No CMake,
# no glTF importer: the game reads the glTF and hands the shim its numbers. Objects go to build/native
set -eu
PKG="$(cd "$(dirname "$0")/.." && pwd)"
. "$PKG/../../tools/native/lib.sh"
OZZ_TAG=0.17.0
OZZ_SHA=22b4401c77a2be68acb0a8aa2e857f94dbaccfed3ab25e304a1ddea4c546e86b
SRC="$PKG/build/src/ozz-$OZZ_TAG"
native_fetch "$SRC" "https://github.com/guillaumeblanc/ozz-animation/archive/refs/tags/$OZZ_TAG.tar.gz" "$OZZ_SHA"
OBJ="$PKG/build/native/$(native_target)"
mkdir -p "$OBJ"
CXX="$(native_cxx)"
FLAGS="$(native_cflags) -std=c++17 -DNDEBUG -fno-exceptions -fno-rtti -I$SRC/include -I$SRC/src"
case "$(native_target)" in windows-*) FLAGS="$FLAGS -D_CRT_SECURE_NO_WARNINGS" ;; esac
JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)"
# the base and the runtime whole, and of the offline library only what builds a skeleton and a clip
( cd "$SRC" && find src/base src/animation/runtime -name '*.cc'
for f in raw_skeleton skeleton_builder raw_animation raw_animation_utils animation_builder; do echo "src/animation/offline/$f.cc"; done
) | while read -r f; do
o="$OBJ/$(echo "$f" | tr '/' '_' | sed 's/\.cc$/.o/')"
[ "$o" -nt "$SRC/$f" ] || echo "$f $o"
done | xargs -P "$JOBS" -n 2 sh -c '"$0" '"$FLAGS"' -c "'"$SRC"'/$1" -o "$2"' "$CXX"
"$CXX" $FLAGS -I"$PKG/native/shim" -c "$PKG/native/shim/ozz_shim.cpp" -o "$OBJ/ozz_shim.o"
native_link "$PKG" ludicozz "$OBJ"/*.o

View file

@ -0,0 +1,186 @@
// ozz_shim.cpp - ludic.anim's door into ozz-animation (phase 19). It keeps the shim rules of
// packages/README.md: int, float and opaque handles cross; no struct by value, no callback into
// Ludic; errors are a null handle or a 0. A skeleton and a clip are built at load from the numbers
// the package already holds (a skin's parents and rest pose, a clip's flattened channels); what a
// frame needs is made once per actor (a context) and reused.
#include "ozz/animation/offline/raw_skeleton.h"
#include "ozz/animation/offline/skeleton_builder.h"
#include "ozz/animation/offline/raw_animation.h"
#include "ozz/animation/offline/animation_builder.h"
#include "ozz/animation/runtime/skeleton.h"
#include "ozz/animation/runtime/animation.h"
#include "ozz/animation/runtime/sampling_job.h"
#include "ozz/base/maths/soa_transform.h"
#include "ozz/base/maths/simd_math.h"
#include <cstdlib>
#include <string>
#include <vector>
#if defined(_WIN32)
#define OZZ_SHIM extern "C" __declspec(dllexport)
#else
#define OZZ_SHIM extern "C" __attribute__((visibility("default")))
#endif
namespace oa = ozz::animation;
namespace off = ozz::animation::offline;
// a skeleton, and the map between its joints (ozz orders them depth first) and the file's nodes
struct Skel {
ozz::unique_ptr<oa::Skeleton> s;
std::vector<int> node_of; // per joint: the file's node
std::vector<int> joint_of; // per node: its joint, -1 for none
std::vector<float> rest; // per node: t3 r4 s3, what a joint no channel moves holds
};
struct Clip {
ozz::unique_ptr<oa::Animation> a;
float dur;
};
struct Ctx {
const Skel *sk;
oa::SamplingJob::Context ctx;
std::vector<ozz::math::SoaTransform> locals;
};
static void add_children(off::RawSkeleton::Joint &j, int node, int n, const int *par, const float *rest) {
j.name = std::to_string(node).c_str();
const float *r = rest + node * 10;
j.transform.translation = ozz::math::Float3(r[0], r[1], r[2]);
j.transform.rotation = ozz::math::Quaternion(r[3], r[4], r[5], r[6]);
j.transform.scale = ozz::math::Float3(r[7], r[8], r[9]);
for (int c = 0; c < n; c++) {
if (par[c] != node) continue;
j.children.emplace_back();
add_children(j.children.back(), c, n, par, rest);
}
}
// n nodes, each's parent (-1 a root, -2 not in this skeleton) and rest pose (10 floats: translation, rotation x y z w, scale)
OZZ_SHIM void *ozz_skel_new(int n, const int *par, const float *rest) {
if (n <= 0 || n > oa::Skeleton::kMaxJoints) return nullptr;
off::RawSkeleton raw;
for (int i = 0; i < n; i++) {
if (par[i] != -1) continue;
raw.roots.emplace_back();
add_children(raw.roots.back(), i, n, par, rest);
}
if (!raw.Validate()) return nullptr;
off::SkeletonBuilder build;
Skel *sk = new Skel();
sk->s = build(raw);
if (!sk->s) { delete sk; return nullptr; }
int nj = sk->s->num_joints();
sk->node_of.assign(nj, -1);
sk->joint_of.assign(n, -1);
auto names = sk->s->joint_names();
for (int j = 0; j < nj; j++) {
int node = std::atoi(names[j]);
sk->node_of[j] = node;
sk->joint_of[node] = j;
}
sk->rest.assign(rest, rest + n * 10);
return sk;
}
OZZ_SHIM void ozz_skel_free(void *p) { delete static_cast<Skel *>(p); }
OZZ_SHIM int ozz_skel_joints(void *p) { return p ? static_cast<Skel *>(p)->s->num_joints() : 0; }
OZZ_SHIM int ozz_skel_joint_of(void *p, int node) {
Skel *sk = static_cast<Skel *>(p);
if (!sk || node < 0 || node >= (int)sk->joint_of.size()) return -1;
return sk->joint_of[node];
}
static float clampt(float t, float dur) { return t < 0.f ? 0.f : (t > dur ? dur : t); }
// a clip from ludic.anim's flattened channels (clip.ludic): a channel's keys are
// times[coff .. coff + ckeys], its values 4 (rotation, path 0) or 3 (translation, path 1) from
// cvoff; translations only when `positions`. A joint no channel moves holds its rest pose.
OZZ_SHIM void *ozz_clip_new(void *skp, float dur, int nch, const int *cnode, const int *cpath, const int *ckeys,
const int *coff, const int *cvoff, const float *times, const float *vals, int positions) {
Skel *sk = static_cast<Skel *>(skp);
if (!sk) return nullptr;
off::RawAnimation raw;
raw.duration = dur > 0.0001f ? dur : 0.0001f;
int nj = sk->s->num_joints();
raw.tracks.resize(nj);
for (int ch = 0; ch < nch; ch++) {
int node = cnode[ch];
if (node < 0 || node >= (int)sk->joint_of.size() || sk->joint_of[node] < 0) continue;
auto &tr = raw.tracks[sk->joint_of[node]];
const float *v = vals + cvoff[ch];
for (int k = 0; k < ckeys[ch]; k++) {
float t = clampt(times[coff[ch] + k], raw.duration);
if (k > 0 && t <= times[coff[ch] + k - 1] && t < raw.duration) continue;
if (cpath[ch] == 0) {
const float *q = v + k * 4;
tr.rotations.push_back({t, ozz::math::Quaternion(q[0], q[1], q[2], q[3])});
} else if (positions) {
const float *p = v + k * 3;
tr.translations.push_back({t, ozz::math::Float3(p[0], p[1], p[2])});
}
}
}
for (int j = 0; j < nj; j++) {
auto &tr = raw.tracks[j];
const float *r = &sk->rest[sk->node_of[j] * 10];
if (tr.translations.empty()) tr.translations.push_back({0.f, ozz::math::Float3(r[0], r[1], r[2])});
if (tr.rotations.empty()) tr.rotations.push_back({0.f, ozz::math::Quaternion(r[3], r[4], r[5], r[6])});
if (tr.scales.empty()) tr.scales.push_back({0.f, ozz::math::Float3(r[7], r[8], r[9])});
}
if (!raw.Validate()) return nullptr;
off::AnimationBuilder build;
Clip *c = new Clip();
c->a = build(raw);
if (!c->a) { delete c; return nullptr; }
c->dur = raw.duration;
return c;
}
OZZ_SHIM void ozz_clip_free(void *p) { delete static_cast<Clip *>(p); }
OZZ_SHIM int ozz_clip_bytes(void *p) { return p ? (int)static_cast<Clip *>(p)->a->size() : 0; }
// what one actor samples with, made once
OZZ_SHIM void *ozz_ctx_new(void *skp) {
Skel *sk = static_cast<Skel *>(skp);
if (!sk) return nullptr;
Ctx *c = new Ctx();
c->sk = sk;
c->ctx.Resize(sk->s->num_joints());
c->locals.resize(sk->s->num_soa_joints());
return c;
}
OZZ_SHIM void ozz_ctx_free(void *p) { delete static_cast<Ctx *>(p); }
// clip at t seconds (wrapped on its duration), each joint's local rotation (4 per node) and
// translation (3 per node) written at its FILE node, into the caller's buffers of n nodes
OZZ_SHIM int ozz_sample(void *cp, void *clp, float t, float *rot, float *pos, int n) {
Ctx *c = static_cast<Ctx *>(cp);
Clip *cl = static_cast<Clip *>(clp);
if (!c || !cl) return 0;
float tt = t;
if (cl->dur > 0.f) { tt = std::fmod(t, cl->dur); if (tt < 0.f) tt += cl->dur; }
oa::SamplingJob job;
job.animation = cl->a.get();
job.context = &c->ctx;
job.ratio = tt / cl->dur;
job.output = ozz::make_span(c->locals);
if (!job.Run()) return 0;
const Skel *sk = c->sk;
int nj = sk->s->num_joints();
float lane[4][10];
for (int s = 0; s * 4 < nj; s++) {
const ozz::math::SoaTransform &x = c->locals[s];
ozz::math::SimdFloat4 comp[7] = {x.translation.x, x.translation.y, x.translation.z,
x.rotation.x, x.rotation.y, x.rotation.z, x.rotation.w};
for (int k = 0; k < 7; k++) {
float v[4];
ozz::math::StorePtrU(comp[k], v);
for (int l = 0; l < 4; l++) lane[l][k] = v[l];
}
for (int l = 0; l < 4 && s * 4 + l < nj; l++) {
int node = sk->node_of[s * 4 + l];
if (node < 0 || node >= n) continue;
for (int k = 0; k < 3; k++) pos[node * 3 + k] = lane[l][k];
for (int k = 0; k < 4; k++) rot[node * 4 + k] = lane[l][3 + k];
}
}
return 1;
}

View file

@ -0,0 +1,46 @@
# ozz.ludic - a skin's skeleton and a clip built in ozz-animation at load, and a clip sampled through
# it (phase 19.1): the numbers are the ones the package already holds, the skin's parents and rest
# pose and a clip's flattened channels, so nothing is baked and no file is added
# the skeleton of a skin: its joints and their ancestors, and no other node - a kit's file holds
# several rigs, and each actor samples only its own
export function anim_oz_skel(sk: Skin) -> pointer {
if sk == null or sk.n_nodes <= 0 { return null }
let n = sk.n_nodes
let rest = floats(n * 10)
let par = words(n)
for i in 0 .. n { par[i] = -2 }
for j in 0 .. sk.n_joints {
var at = sk.joints[j]
while at >= 0 and par[at] == -2 {
par[at] = sk.par[at]
at = sk.par[at]
}
}
for i in 0 .. n {
for k in 0 .. 3 { rest[i * 10 + k] = sk.rest_t[i * 3 + k] }
for k in 0 .. 4 { rest[i * 10 + 3 + k] = sk.rest_r[i * 4 + k] }
for k in 0 .. 3 { rest[i * 10 + 7 + k] = sk.rest_s[i * 3 + k] }
}
return ozz_skel_new(n, par, rest)
}
export function anim_oz_skel_free(s: pointer) -> void { if s != null { ozz_skel_free(s) } }
export function anim_oz_joints(s: pointer) -> int { return ozz_skel_joints(s) }
# a clip for that skeleton; its translations only when the table plays them (anim_set_positions)
export function anim_oz_clip(anim_st: AnimState, s: pointer, c: Clip) -> pointer {
if s == null or c == null { return null }
var p = 0
if anim_st.positions { p = 1 }
return ozz_clip_new(s, c.dur, c.n, c.cnode, c.cpath, c.ckeys, c.coff, c.cvoff, c.times, c.vals, p)
}
export function anim_oz_clip_free(c: pointer) -> void { if c != null { ozz_clip_free(c) } }
export function anim_oz_clip_bytes(c: pointer) -> int { return ozz_clip_bytes(c) }
# what one actor samples with, made once; a clip sampled at t into rot (4 a node) and pos (3 a node)
export function anim_oz_ctx(s: pointer) -> pointer { return ozz_ctx_new(s) }
export function anim_oz_ctx_free(c: pointer) -> void { if c != null { ozz_ctx_free(c) } }
export function anim_oz_sample(ctx: pointer, clip: pointer, t: float, rot: floats, pos: floats, n: int) -> bool {
if ctx == null or clip == null or len(rot) < n * 4 or len(pos) < n * 3 { return false }
return ozz_sample(ctx, clip, t, rot, pos, n) == 1
}

View file

@ -4,3 +4,5 @@
package "ludic.anim"
version "0.1.0"
kind source
native "macos-arm64" "lib/macos-arm64/libludicozz.dylib"
native "windows-x64" "lib/windows-x64/ludicozz.dll"

View file

@ -0,0 +1,82 @@
# ozz_test.ludic - a skin and a clip built in ozz at load (phase 19.1): sampled, every rotation agrees
# with today's sampler (anim_mix) to 1e-4 a component, a joint no channel moves holds its rest, and
# a node outside the skin is no joint
import "ludic.render3d/r3d.ludic"
import "ludic.anim"
program OzzTest {
numbers float
const HALF: float = 0.70710678
function links_runtime() -> bool { return Input.key_pressed(0) }
function skin(render3d_st: mut Render3dState) -> Skin {
render3d_st.gltf_doc = Json.parse("{\"nodes\": [{\"name\": \"root\", \"children\": [1]}, {\"name\": \"bone\", \"translation\": [0, 1, 0], \"rotation\": [0, 0.2588190, 0, 0.9659258]}, {\"name\": \"other\"}], \"skins\": [{\"joints\": [0, 1]}]}")
return skin_load(render3d_st, 0)
}
# node 1 turns a quarter about Z over a second, in three keys, the middle one off the straight path
function turn() -> Clip {
let c = new Clip
c.name = "t"
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] = 3
c.times = floats(3)
c.times[1] = 0.4
c.times[2] = 1.0
c.vals = floats(12)
c.vals[3] = 1.0
c.vals[4] = 0.3
c.vals[6] = 0.3
c.vals[7] = 0.9055385
c.vals[10] = HALF
c.vals[11] = HALF
c.dur = 1.0
return c
}
# the largest component difference of two rotations, one turned to the other's sign first
function apart(a: floats, b: floats, i: int) -> float {
var d = 0.0
for k in 0 .. 4 { d += a[i * 4 + k] * b[i * 4 + k] }
var sgn = 1.0
if d < 0.0 { sgn = -1.0 }
var worst = 0.0
for k in 0 .. 4 { worst = Math.max(worst, Math.abs(a[i * 4 + k] - sgn * b[i * 4 + k])) }
return worst
}
test "sampled in ozz, a clip lands where anim_mix puts it, and a joint left alone holds its rest" (anim_st: mut AnimState, render3d_st: mut Render3dState) {
let sk = skin(render3d_st)
let c = turn()
let s = anim_oz_skel(sk)
expect(s != null)
expect_eq(anim_oz_joints(s), 2)
let clip = anim_oz_clip(anim_st, s, c)
expect(clip != null)
let ctx = anim_oz_ctx(s)
let rot = floats(12)
let pos = floats(9)
let want = floats(12)
let wpos = floats(9)
let hit = words(3)
var worst = 0.0
for k in 0 .. 61 {
let t = float(k) * 0.0371
expect(anim_oz_sample(ctx, clip, t, rot, pos, 3))
for i in 0 .. 3 { hit[i] = 0 }
anim_mix(anim_st, c, sk, t, 1.0, want, wpos, hit)
worst = Math.max(worst, apart(rot, want, 1))
}
print(`ozz: node 1 over 61 samples, worst component {worst}; the clip is {anim_oz_clip_bytes(clip)} bytes`)
expect(worst < 0.0001)
expect_near(rot[3], 1.0, 0.0001)
expect_near(pos[4], 1.0, 0.001)
anim_oz_ctx_free(ctx)
anim_oz_clip_free(clip)
anim_oz_skel_free(s)
}
}