Zig bindings for the ozz-animation runtime — skeletal animation sampling.
- Vendored, pinned upstream ozz (0.17.0). No fork, no patches. See UPSTREAM.md.
- A C ABI (
ffi/zozz.h) that exists because it must — ozz is C++ and Zig cannot call that — kept clean and tested as a real contract (the C smoke test consumes it with no Zig in the picture). The Zig wrapper is its one production consumer today. - Host allocator injection: every ozz allocation can go through your
std.mem.Allocator. - Drift between the C header and the Zig externs is a build failure, not a memory-corruption bug: every type, signature, enumerator and constant is cross-checked at comptime, with no hand-kept list of what to check.
What works today: load, sample, convert, local-to-model, blend, apply IK, skin a mesh, import glTF, and offline building — author a skeleton or clip in memory, optimise or extract motion from it, and build it into the same runtime objects the loaders produce. See Scope for what that covers and what is not exposed, and By the numbers for the version.
Every release so far has been a breaking change and there is no compatibility
layer: each job is a struct with a run method, mirroring ozz's own shape.
There is exactly one way to spell everything.
The block below is not written here: it is a region of examples/usage.zig, which zig build examples builds and RUNS, extracted by ci/readme_usage.sh and compared by CI. A snippet in a README is a claim about how the library is used, and this one is a claim something executes.
const zozz = @import("zozz");
try zozz.setAllocator(gpa);
// Runs last, and can fail: restoring ozz's own allocator is refused while
// blocks this one produced are still live.
defer zozz.resetAllocator() catch |e| std.debug.panic("zozz: {s}", .{@errorName(e)});
// A handle is destroyed through a pointer, so it is a `var`: `deinit`
// nulls it, which makes a second destroy a no-op and a use after it a
// checked panic rather than a read of freed memory.
var skeleton = try zozz.Skeleton.initFromFile("skeleton.ozz");
defer skeleton.deinit();
var clip = try zozz.Animation.initFromFile("walk.ozz");
defer clip.deinit();
// The caller owns the pose. It can be a stack array, an arena slice, or a
// sub-range of a batch; `soaBlocks` says how long it has to be.
const blocks = try zozz.soaBlocks(skeleton.numJoints());
const pose = try gpa.alloc(zozz.SoaTransform, blocks);
defer gpa.free(pose);
var context = try zozz.SamplingContext.initForSkeleton(skeleton);
defer context.deinit();
// Per frame:
try skeleton.restPoseSoa(pose);
try (zozz.SamplingJob{
.animation = clip,
.context = context,
// `.loop` wraps in both directions; `.clamp` holds the end poses.
.ratio = clip.ratioAt(time_seconds, .loop),
.out = pose,
}).run();
// Either read local transforms out...
const locals = try gpa.alloc(zozz.Transform, skeleton.numJoints());
defer gpa.free(locals);
try zozz.pose.toLocalTransforms(pose, locals);
// ...or flatten the hierarchy to model space. Note the 16-byte alignment.
const models = try gpa.alignedAlloc(zozz.Mat4, .@"16", skeleton.numJoints());
defer gpa.free(models);
try (zozz.LocalToModelJob{
.skeleton = skeleton,
.locals = pose,
.root = null,
.out = models,
}).run();
// Blending takes the same spans and allocates nothing per call. `walk`
// and `run` here are two more poses, sampled the same way as `pose`.
const walk = try gpa.alloc(zozz.SoaTransform, blocks);
defer gpa.free(walk);
const run = try gpa.alloc(zozz.SoaTransform, blocks);
defer gpa.free(run);
const rest = try gpa.alloc(zozz.SoaTransform, blocks);
defer gpa.free(rest);
@memcpy(walk, pose);
@memcpy(run, pose);
try skeleton.restPoseSoa(rest);
try (zozz.BlendingJob{
.layers = &.{ zozz.blending.layer(0.5, walk), zozz.blending.layer(0.5, run) },
.rest_pose = rest,
.out = pose,
}).run();
// A joint's skinning matrix is its model matrix times its inverse bind
// pose — the inverse of where the joint sat when the mesh was authored.
const joint = 1;
const inverse_bind = zozz.math.mat4.invert(models[joint], null);
const skinning = zozz.math.mat4.mul(models[joint], inverse_bind);Add it as a dependency and link the module:
const zozz_dep = b.dependency("zozz", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("zozz", zozz_dep.module("zozz"));A C or C++ host takes the library and its installed header instead:
exe.root_module.linkLibrary(zozz_dep.artifact("zozz")); // then #include <zozz.h>Forward optimize as shown. zozz does not turn on Zig's C sanitizer for you
(see Build hygiene), so a mismatched build mode is a size
difference rather than an unresolved __ubsan_handle_* symbol — but a Debug
library inside a release executable is still not what you meant.
ozz's job pipeline speaks structure-of-arrays: sampling writes it, blending
consumes and produces it, local-to-model reads it. A pose is a
[]SoaTransform — one element per four joints, soaBlocks(n) of them — and
every entry point takes that slice, which is exactly the ozz::span the C++
jobs take. Conversion to Transform (array-of-structs) happens only at the
edges, where you actually want it.
The layout is public and the memory is yours: a pose can live on the stack, in
an arena, inside a larger struct, or as a sub-range of a batch, and two poses
can be sub-ranges of one allocation. Blending is the same story — a
BlendingLayer is ozz::animation::BlendingJob::Layer field for field, so
BlendingJob.run hands your array straight to ozz with no copy and no
allocation. ffi/zozz_abi.cpp static_asserts every size, alignment and offset
of both against ozz's own types, so the reinterpretation cannot drift.
A C caller owns that memory too, and can therefore hand over a pointer ozz
would read with an aligned SIMD load. Every entry point taking SoA memory
checks the 16-byte boundary and returns ZOZZ_RESULT_INVALID_ARGUMENT rather
than faulting inside ozz.
setAllocator routes every ozz allocation through a std.mem.Allocator. It is
process-wide, because ozz's own allocator is —
that is surfaced rather than hidden behind a per-object parameter that could
not be honoured.
The seam has one wrinkle worth knowing: ozz frees with deallocate(block), no
size and no alignment, while Zig requires both. src/memory.zig bridges that
with a header stored ahead of each block. The C API keeps ozz's shape, so a
plain C host can still pass malloc/free in two lines.
That header also records the allocator the block came from, which is what makes
swapping one Zig allocator for another safe with blocks outstanding: each frees
through its own producer rather than through whoever happens to be installed at
destruction. Below the Zig layer the C seam cannot do that -- it holds one
ZozzAllocator -- so zozzSetAllocator refuses any call that would change
where a free lands, and reports ZOZZ_RESULT_ALLOCATOR_IN_USE, for as long as
zozzAllocatorLiveBlocks() is non-zero. A call that leaves the same allocator
in place is not such a change: reinstalling the identical one, or resetting
while none is installed, always succeeds. That counter doubles as a leak check
for a host whose own allocator has none.
The counter has to see every outstanding block for that refusal to mean
anything, including the ones ozz's own allocator handed out before any host was
installed -- ozz frees through whichever allocator is installed at destruction
time, not through the one that made the block, so a load before setAllocator
and a deinit after it would otherwise hand malloc'd memory to the Zig bridge.
So zozz takes ozz's global allocator slot at start-up and forwards to ozz's own
allocator until a host arrives, which is what makes the count total and the
refusal honest.
Thread safety, and what it rests on. Distinct handles may be used
concurrently as long as the installed allocator is thread-safe, because every
entry point that allocates arrives at this one seam; a single handle is not
internally synchronised, and neither is installing or resetting the allocator
-- do that from one thread before any other is inside zozz.
src/concurrency_test.zig runs the claim rather than restating it: four
threads, each with its own sampling context, poses and matrices, sharing one
skeleton and one clip, creating and destroying handles for two hundred frames
each against a DebugAllocator that accounts for every block.
ozz checks nothing in a release build. IArchive::operator>> for a primitive
is Read(&v, sizeof(v)) followed by assert(size == sizeof(v)) — and under
NDEBUG that assert is gone, so a short read leaves v holding stack garbage
and parsing continues. A count read that way decides how much ozz allocates and
copies. The tag check is an assert too. An unsupported archive version is
logged and leaves you an empty object with no error returned.
So the loaders here bracket ozz's parser with the checks it does not perform:
- Tag-test before parsing — wrong-type or non-ozz input is
BadFormat, not a parse of unrelated bytes. - Truncation detection — every archive, including files, is parsed
through
ConstMemoryStream, which satisfies each read in full, zero-fills past the end and latches a flag the loader checks. Zeros are the safe filler: a zeroed count is zero, so ozz's loops do nothing rather than run wild, and no partially-overwritten count can round upward. Files are buffered whole rather than handed to ozz's ownFilestream precisely because that stream returns the short reads ozz fails to check. - Sanity-check after parsing — joint counts within ozz's own limit, arrays
of consistent length, positive duration. This is what turns a
version-mismatched clip into
BadFormatinstead of a zero-duration animation that samples to garbage. - Real out-of-memory handling, as far as it goes —
ozz::Newis a placement-new on an unchecked allocation, so a failed allocation constructs at address zero.ffi/zozz_internal.hreplaces it with a checked version, which is what makes theOutOfMemorypaths reachable rather than decorative. What that does not cover is ozz's own internal allocations:SkeletonandAnimationeach build spans over an uncheckedAllocateand write through them, so a load under memory pressure is a null dereference inside ozz. That is not fixable from outside; it is documented with file and line in UPSTREAM.md. Sampling, once loaded, allocates nothing.
Every prefix of a valid archive, across both fixtures, is verified to be rejected, and the whole archives still load. NaN ratios and misaligned matrix buffers are refused too, rather than faulting inside SIMD code.
What this does not claim: ozz's parser is not hardened against arbitrary
hostile input, and zozz cannot make it so from the outside. The guarantee here
is that truncation and type confusion fail cleanly and deterministically, with
counts bounded rather than attacker-influenced. If you ever need to load .ozz
from an untrusted source, put a length-and-checksum container around it and
validate that first.
The Zig side hand-writes its extern declarations rather than running
translate-c, so the wrapper gets exactly the types it wants and the shipped
module never compiles C. Nothing in either compiler checks that those
declarations still agree with ffi/zozz.h, and a size_t narrowed to an int
links cleanly and corrupts. Three checks close that, on three different axes:
src/abi_check.zig— the header against the declarations. A comptime@cImportof the real header, compared againstsrc/c.zigby reflection: every struct field paired by name with its own offset, every scalar's size, alignment, signedness and int-versus-float, every function's arity and variadic-ness, every enumerator's value, every constant. There is no hand-maintained list — declarations are discovered by reflection and a declaration the check cannot classify is a compile error rather than a silent pass. It also sweeps the other direction, so a symbol the header exports butc.zignever declares (or declares as something that is not an extern) fails too. The@cImporthappens in a test only; the shipped module stays translate-c-free.zozzAbiLayout()— the compiled library against the declarations. The header is a source file and the library is a binary; they can describe different structs while looking identical. This one asks the linked translation unit what it really laid out. Neither check replaces the other.static_asserts inffi/zozz_abi.cpp— ozz against the C++. These fail the build if a vendored ozz upgrade changes a type this package casts to or from.
The first of those is the one that guards everything else, and it is the one
test here that cannot test itself: a refactor that quietly makes it vacuous
looks exactly like a passing build. ci/check-abi-drift.sh is the answer —
twelve deliberate drifts applied one at a time, each of which must be refused,
including the field swap that leaves every offset in the struct unchanged and
so defeats any positional or offsets-only comparison. It runs as its own CI job
and under ci/run.sh.
Because the check pairs an enum's fields to the header by name, the naming convention is load-bearing rather than cosmetic:
| Zig side | C side |
|---|---|
type Foo |
ZozzFoo |
function zozzFoo |
zozzFoo |
constant foo_bar |
ZOZZ_FOO_BAR |
enum Foo's field bar |
ZOZZ_FOO_BAR |
Enumerators take the full type name, always: ZOZZ_RESULT_IO, not
ZOZZ_ERR_IO. A C enum reaches Zig as a plain integer alias with no record of
which enumerators belonged to it, so the strict prefix is the only thing that
can pair one back to its enum. No enumerator may be negative, either — C
leaves an enum's underlying type to the implementation and the implementations
disagree, and every value being non-negative is what makes that unobservable.
The guard enforces both.
- Source lists are explicit, never globs — a re-vendor cannot silently change what compiles.
- No
-fno-access-control. The FFI layer uses only ozz's public API, so it has no reason to defeat C++ access checking and no coupling to ozz internals. - UBSan is not blanket-disabled, and it is not on by default either.
-Dsanitize_c=trueturns it on and zozz's own CI runs Debug that way, so real undefined behaviour surfaces instead of being suppressed. It stays off by default because Zig's C sanitizer emits calls into a runtime linked only into a compilation that is itself sanitized: a consumer who forgets to forwardoptimizewould get anundefined symbol: __ubsan_handle_*link failure naming nothing they can act on. A library does not get to decide that its consumers are running a sanitizer. - Build options are declared once and mirrored into a Zig
optionsmodule, so the wrapper cannot disagree with how the C++ was compiled. - One translation unit per concern on both sides of the boundary.
zig build testThe suite is self-contained: assets are built at test time through ozz's own
offline builders and serialised in memory (tests/fixture.cpp), so it is always
version-matched to the vendored runtime and ships no third-party clips. That
fixture doubles as proof the offline builders compile and link — the foundation
of a future cook.
zig build test-c runs the C-level smoke test on its own, proving the header
is a real C contract rather than a private detail of the Zig wrapper, and that
the allocator seam is genuinely in use (it asserts allocations balance).
zig build examples builds and runs everything in examples/, and zig build test includes it. An example that is only compiled proves the names still
resolve; running it is what proves the sequence still works — and the Usage
block at the top of this file is extracted from one, so it cannot drift from
code that executes.
zig build --build-file tests/consumer/build.zig runbuilds zozz the way a downstream package does — through b.dependency, which
resolves the artifact by scanning the dependency's install step and the header
by its installed spelling. Neither of those is exercised by anything in src/
or tests/, so both can break while the whole suite stays green. The Zig
module and the C artifact are each driven by a real consumer there.
Zig's C sanitizer is off by default, so a plain zig build test runs
everything. Turning it on skips one test:
zig build test -Dsanitize_c=true # skips the truncated-archive testNot because that test fails, but because upstream ozz forms member accesses on a null pointer when a keyframe array has zero entries — the exact shape a zero-filled count produces. That is real, if benign, undefined behaviour in ozz, and being able to see it is worth more than switching the sanitizer off globally to silence it. See UPSTREAM.md.
To additionally check a real asset on disk:
zig build test -Dskeleton_path=path/to/skeleton.ozz \
-Danimation_path=path/to/clip.ozzBoth are held to the same assertions: unit-length quaternions, finite transforms, parent-precedes-child joint ordering, root joints whose model matrix matches their local translation, a clip that demonstrably moves, and a NaN ratio that is refused.
| 0.5.0 | version, the same in build.zig.zon and ffi/zozz_core.h |
| 359 | C entry points (ZOZZ_API in ffi/*.h) |
| 359 | Zig externs (pub extern fn in src/c.zig) |
| 21 | installed public headers |
| 97 | ozz public names with a binding |
| 418 | ozz public names in the bound areas |
| 199 | Zig tests zig build test executes |
| 10 | tests it skips, each needing a build option or an on-disk asset |
| 206 | assertions in the standalone C smoke test |
| 41 | vendored ozz translation units build.zig compiles |
| 20 | zozz C++ translation units (ffi/*.cpp) |
| 14690 | Zig source lines (src/) |
| 9730 | C++ source lines (ffi/) |
| 18 | deliberate drifts ci/check-abi-drift.sh must refuse |
| 32 | steps ci/run.sh runs |
| 7 | further targets ci/run.sh cross-compiles |
Not one of those is typed into this file. ci/measurements.sh recomputes them
from the tree, ci/check-docs.sh regenerates the block and fails the build if
what is committed differs, and the same gate refuses any other hand-written
number in these documents unless tools/doc_numbers.txt says why it cannot go
stale. Adding a claim means adding its measurement.
What the numbers do not say. A count is a count. Names with a binding are
matched by spelling, which proves neither that a binding is correct nor that an
unbound name is worth having — ci/check-coverage.sh holds those verdicts one
by one. Source lines measure volume, not surface. And the gate itself has three
blind spots: a number spelled as a word, a number inside code — where it is
an identifier or a citation rather than a claim — and a sentence that is wrong
without containing a number at all.
CI runs the whole suite on Linux, macOS and Windows, in every optimize
mode — Debug twice, once with the C sanitizer on and once without it, because
the malformed-input test needs it off — plus the standalone C test, both
optional build halves in each of the three combinations that are not the
default, the downstream-consumer build, and on Windows the MSVC ABI as well as
the gnu one. It also cross-compiles the further targets listed in ci/run.sh,
verifies the vendored tree against upstream, and runs the ABI drift mutation
test twice — once on the Itanium ABI and once on MSVC's, because
src/abi_check.zig compares src/c.zig against the header as preprocessed
for a target, and a C enum is int under one and unsigned int under the
other. See .github/workflows/ci.yml.
Locally, ci/run.sh runs the drift proof on this host's ABI only; the second
arm is ci/run.sh --drift-target=<triple>, or ci/check-abi-drift.sh -Dtarget=<triple> on its own. It is opt-in because it rebuilds eighteen times
and ci/run.sh is installable as a pre-push hook — CI runs it on every push,
and a release should run both arms here.
The same matrix runs locally, so a failure is reproducible on your machine before it is a red mark on a pull request:
ci/run.sh # the full matrix
ci/run.sh --quick # native Debug only, for the inner loop
ci/install-hooks.sh # run it automatically before every pushIt reports every failure rather than stopping at the first.
| Suite executed by CI | Compile-checked by CI | |
|---|---|---|
| Linux | x86_64 (glibc) | + aarch64, musl |
| macOS | aarch64 | + x86_64 |
| Windows | x86_64, both gnu and MSVC ABI | + aarch64 |
Compiling proves the sources and build graph are portable; only an executed configuration proves behaviour, which is why the two are separate jobs.
That table describes the matrix, not a promise: the badge at the top of this file is the authority on whether those runs have actually happened and passed. The whole matrix has been executed — the suite on Linux, macOS and Windows, including the Windows MSVC ABI, alongside the cross-compilation and vendor-integrity jobs. What the badge tells you that this paragraph cannot is whether it still passes on the commit you are reading.
One header per concern, gathered by the umbrella ffi/zozz.h. Every C entry
point is mirrored by a Zig wrapper that a reflective cross-check pairs at build
time:
- Skeleton and animation loading, from file or memory
- Sampling with a frame-coherency context
- SoA pose storage, and SoA ↔ AoS conversion
- Local-to-model, over the whole hierarchy or over ozz's own
from/tojoint range, for re-running only the chain an IK correction touched - Pose blending (
BlendingJob): weighted, additive, and per-joint partial blending - Two-bone and aim IK, and folding a correction back into a pose
- Matrix-palette skinning (
SkinningJob) ozz::mathas Zig rather than as foreign calls: SimdFloat4 and SimdInt4 lane operations, quaternions,Float4x4including its*,+and-,Transformcomposition, andBox- Runtime tracks (
Track): five value types — float, float2, float3, float4, quaternion — plus edge-triggering over aFloatTrack - Root-motion blending
- Skeleton and animation utilities: single-joint rest pose, hierarchy traversal, name lookup, per-track keyframe counts
- Offline building: author a skeleton (
RawSkeleton) or clip (RawAnimation) at runtime and build it into the same runtime objects the loaders produce - Offline animation processing: the animation optimizer (key-frame reduction), raw-animation sampling and re-timing, the additive animation builder, and root-motion extraction
- Raw tracks: the same five value types on the authoring side, with their own builders and optimizers
- The archive: saving a skeleton, a clip or a track to a host-controlled
stream (or straight to a file) with
OArchive, and reading them back from one withIArchive— including a tag test that answers "is the next object a T?" without consuming it, for a host reading a mixed archive - Importing:
OzzImporterin both directions — a glTF-backed importer (Importer.initFromGltf,-Dgltf) and a host-implementableImporterInterfacefor a host with its own source format (-Doptions)
What the importer is and is not. -Dgltf compiles ozz's own
GltfImporter, which reads glTF and glb through the vendored tiny_gltf.h,
and hands back the same RawSkeleton and RawAnimation the offline builders
take — so a glTF file can be imported, optimised and built entirely in
process. Both options are off by default: each pulls in a large translation
unit, and a library must not charge every consumer for a feature it does not
use. With one off, the calls behind it return error.Unsupported rather than
failing to compile.
What is not there is gltf2ozz's command line. Its driver
(OzzImporter::operator(), Importer.run) is configured through jsoncpp,
which lives in upstream's excluded extern/, so run always reports
error.Unsupported. A cook is a host's own program over initFromGltf, the
optimizer and OArchive, not a vendored executable.
The FBX importer is not a candidate at all: it needs the proprietary Autodesk FBX SDK, and its sources are excluded for that reason.
Deliberately out of scope: a blend tree, a state machine, a clock, or an asset system. Those are a host's job, and keeping them out is what makes this package reusable.
Issues and pull requests are welcome. Two things to know before opening one:
-
libs/ozzis vendored verbatim and must not be edited. Changes there are lost at the next re-vendor. If upstream needs fixing, fix it upstream; if zozz needs to work around upstream, do it inffi/and record it in UPSTREAM.md. -
Run
ci/run.shbefore pushing — orci/install-hooks.shonce, and it runs itself. It is the same matrix CI runs. -
Comments state a contract, not a narrative.
ci/check-comments.shenforces two things and will fail a pull request over either. A block above one declaration is at most six lines; a file header, or a block under a//===---===//banner, at most fourteen. And the register is documentation, not conversation — no "we", "our", "note that", "which is why", "turns out", "you might expect".The cap never justifies dropping a fact. Units, ownership, lifetime, nullability, error conditions and ordering constraints come first; if a block cannot hold them in six lines, shorten the prose around them.
New source files are added to the explicit lists in build.zig deliberately;
there are no globs, so nothing starts compiling by accident.
MIT, see LICENSE, which covers this package's own code. Vendored
ozz-animation is MIT, copyright Guillaume Blanc and contributors; its licence
text ships with the package at libs/ozz/LICENSE.md and its authors at
libs/ozz/AUTHORS.md.