From 9a56ee9b9d94e5c7b6fe37e6801be1299c78d4ab Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Mon, 28 Sep 2026 12:10:41 +0300 Subject: [PATCH] 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 --- changes/anim-ozz.md | 9 + packages/README.md | 2 +- packages/ludic.anim/README.md | 18 ++ packages/ludic.anim/index.ludic | 2 + .../lib/macos-arm64/libludicozz.dylib | 3 + .../ludic.anim/lib/windows-x64/ludicozz.dll | 3 + .../ludic.anim/lib/windows-x64/ludicozz.lib | 3 + packages/ludic.anim/native.ludic | 12 ++ .../native/LICENSE-ozz-animation.md | 23 +++ packages/ludic.anim/native/build.sh | 29 +++ packages/ludic.anim/native/shim/ozz_shim.cpp | 186 ++++++++++++++++++ packages/ludic.anim/ozz.ludic | 46 +++++ packages/ludic.anim/package.ludic | 2 + packages/ludic.anim/tests/ozz_test.ludic | 82 ++++++++ 14 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 changes/anim-ozz.md create mode 100755 packages/ludic.anim/lib/macos-arm64/libludicozz.dylib create mode 100644 packages/ludic.anim/lib/windows-x64/ludicozz.dll create mode 100644 packages/ludic.anim/lib/windows-x64/ludicozz.lib create mode 100644 packages/ludic.anim/native.ludic create mode 100644 packages/ludic.anim/native/LICENSE-ozz-animation.md create mode 100755 packages/ludic.anim/native/build.sh create mode 100644 packages/ludic.anim/native/shim/ozz_shim.cpp create mode 100644 packages/ludic.anim/ozz.ludic create mode 100644 packages/ludic.anim/tests/ozz_test.ludic diff --git a/changes/anim-ozz.md b/changes/anim-ozz.md new file mode 100644 index 00000000..e895340c --- /dev/null +++ b/changes/anim-ozz.md @@ -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. diff --git a/packages/README.md b/packages/README.md index 44237109..62f7ecef 100644 --- a/packages/README.md +++ b/packages/README.md @@ -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`, 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 | diff --git a/packages/ludic.anim/README.md b/packages/ludic.anim/README.md index b2d6a54d..8007b113 100644 --- a/packages/ludic.anim/README.md +++ b/packages/ludic.anim/README.md @@ -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 diff --git a/packages/ludic.anim/index.ludic b/packages/ludic.anim/index.ludic index 8a6785aa..5292772e 100644 --- a/packages/ludic.anim/index.ludic +++ b/packages/ludic.anim/index.ludic @@ -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" diff --git a/packages/ludic.anim/lib/macos-arm64/libludicozz.dylib b/packages/ludic.anim/lib/macos-arm64/libludicozz.dylib new file mode 100755 index 00000000..7ffe8fba --- /dev/null +++ b/packages/ludic.anim/lib/macos-arm64/libludicozz.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:773868c0717c99d6f80e6433d65befeb46e4fc23c0a7067a5d4ad25cf71a47d3 +size 109616 diff --git a/packages/ludic.anim/lib/windows-x64/ludicozz.dll b/packages/ludic.anim/lib/windows-x64/ludicozz.dll new file mode 100644 index 00000000..f17d7e56 --- /dev/null +++ b/packages/ludic.anim/lib/windows-x64/ludicozz.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b9e08e1d468df5ba4bb03ed2d628e3ff91e6477c2cc7bb9aaba2ecbde3eab0bc +size 395264 diff --git a/packages/ludic.anim/lib/windows-x64/ludicozz.lib b/packages/ludic.anim/lib/windows-x64/ludicozz.lib new file mode 100644 index 00000000..34137638 --- /dev/null +++ b/packages/ludic.anim/lib/windows-x64/ludicozz.lib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1cf90526ea089d5320221075db734fef1fcd9a498aea118c572e62093b565c16 +size 3086 diff --git a/packages/ludic.anim/native.ludic b/packages/ludic.anim/native.ludic new file mode 100644 index 00000000..527863bf --- /dev/null +++ b/packages/ludic.anim/native.ludic @@ -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" diff --git a/packages/ludic.anim/native/LICENSE-ozz-animation.md b/packages/ludic.anim/native/LICENSE-ozz-animation.md new file mode 100644 index 00000000..38cd7c23 --- /dev/null +++ b/packages/ludic.anim/native/LICENSE-ozz-animation.md @@ -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. \ No newline at end of file diff --git a/packages/ludic.anim/native/build.sh b/packages/ludic.anim/native/build.sh new file mode 100755 index 00000000..e7c305c5 --- /dev/null +++ b/packages/ludic.anim/native/build.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# builds lib// 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 diff --git a/packages/ludic.anim/native/shim/ozz_shim.cpp b/packages/ludic.anim/native/shim/ozz_shim.cpp new file mode 100644 index 00000000..0c2f5fe5 --- /dev/null +++ b/packages/ludic.anim/native/shim/ozz_shim.cpp @@ -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 +#include +#include + +#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 s; + std::vector node_of; // per joint: the file's node + std::vector joint_of; // per node: its joint, -1 for none + std::vector rest; // per node: t3 r4 s3, what a joint no channel moves holds +}; +struct Clip { + ozz::unique_ptr a; + float dur; +}; +struct Ctx { + const Skel *sk; + oa::SamplingJob::Context ctx; + std::vector 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(p); } +OZZ_SHIM int ozz_skel_joints(void *p) { return p ? static_cast(p)->s->num_joints() : 0; } +OZZ_SHIM int ozz_skel_joint_of(void *p, int node) { + Skel *sk = static_cast(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(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(p); } +OZZ_SHIM int ozz_clip_bytes(void *p) { return p ? (int)static_cast(p)->a->size() : 0; } + +// what one actor samples with, made once +OZZ_SHIM void *ozz_ctx_new(void *skp) { + Skel *sk = static_cast(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(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(cp); + Clip *cl = static_cast(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; +} diff --git a/packages/ludic.anim/ozz.ludic b/packages/ludic.anim/ozz.ludic new file mode 100644 index 00000000..01d9a593 --- /dev/null +++ b/packages/ludic.anim/ozz.ludic @@ -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 +} diff --git a/packages/ludic.anim/package.ludic b/packages/ludic.anim/package.ludic index 110b00c5..26649898 100644 --- a/packages/ludic.anim/package.ludic +++ b/packages/ludic.anim/package.ludic @@ -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" diff --git a/packages/ludic.anim/tests/ozz_test.ludic b/packages/ludic.anim/tests/ozz_test.ludic new file mode 100644 index 00000000..5f60cb79 --- /dev/null +++ b/packages/ludic.anim/tests/ozz_test.ludic @@ -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) + } +}