# BlendSpace2D
2D-coordinate-driven mix of N sample nodes (Node subclass). Each
sample is anchored at a `(x, y)` coordinate; the current parameter
`(px, py)` lands inside one of the Delaunay triangles formed over the
anchors, and barycentric weights for that triangle drive a Mixer-style
weighted blend. Parameter outside the hull → nearest sample with
weight 1.
## Exports
- `BlendSpace2D.new(layout: Layout, samples: { Sample }) -> BlendSpace2D` — construct a BlendSpace2D with N anchored sample nodes. Triangulation runs once at construction.
- `BlendSpace2D:setParams(x: number, y: number)` — set the parameter that drives the per-frame blend.
- `BlendSpace2D:update(dt: number)` — cascade `update(dt)` to every sample source.
- `BlendSpace2D:evaluate() -> TypedBuffer` — compute weights and blend. Caller must NOT destroy the returned buffer.
- `BlendSpace2D:destroy()` — destroy the internal Mixer and clear state. Sample sources are not owned and not destroyed.
Types:
- `Layout = { boneOrder: { string }, stride: number?, slotLayout: { any }? }`
- `Sample = { x: number, y: number, source: any }`
## Usage
```luau
local BlendSpace2D = require("@builtin::systems.anim.AnimGraph.Node.BlendSpace2D")
local bs = BlendSpace2D.new(layout, {
{ x = 0, y = 0, source = idleClip },
{ x = 1, y = 0, source = walkFwd },
{ x = 1, y = 1, source = runFwd },
})
bs:setParams(0.5, 0.0)
bs:update(dt)
local pose = bs:evaluate()
```
## Notes
- Triangulation is a brute-force O(n⁴) Delaunay check. Animation
blend-spaces are tiny (n ≤ 20 in practice) so the cost is < 1ms in
Luau at construction; runtime cost is just triangle containment plus
one Mixer evaluate per frame.
- Internal Mixer is owned by the BlendSpace2D and destroyed on
`destroy()`. Sample sources are owned by the surrounding `AnimGraph`,
not the BlendSpace2D.
- When the parameter is outside the triangulated hull, the
closest sample (by squared XY distance) gets weight 1 and the rest
weight 0 — there is no extrapolation.
# Clip
Single animation clip wrapper (Node subclass). Wraps one `Channel` per
animation channel in the asset, plus a pose buffer and a playhead.
`evaluate()` samples every channel at the current time into the buffer.
The pose buffer is initialised to rest pose
(`translation = 0`, `rotation = identity quat`, `scale = 1`) at
construction. Each `evaluate()` overwrites only the slots authored by
channels; unauthored slots stay at rest pose. This means a Mixer
slerping two clips that don't author rotations produces identity, not
`(0,0,0,0)`.
## Exports
- `Clip.new(animAsset: AnimAsset, layout: Layout, looping: boolean, speed: number?) -> Clip` — construct a Clip from an animation asset.
- `Clip:update(dt: number)` — advance the playhead. Wraps when `looping`, clamps + finishes otherwise.
- `Clip:evaluate() -> TypedBuffer` — sample every channel into the pose buffer and return it. Caller must NOT destroy.
- `Clip:setPlaying(p: boolean)` — pause/resume the playhead.
- `Clip:rewind()` — rewind to t=0, clear `finished`, resume.
- `Clip:destroy()` — free every Channel handle and the pose buffer.
Types:
- `Layout = { boneOrder: { string }, stride: number? }`
- `AnimChannel = { target_bone: string, path: string, times: { number }, values: { number }, interp: string? }`
- `AnimAsset = { duration: number?, channels: { AnimChannel }? }`
## Usage
```luau
local Clip = require("@builtin::systems.anim.AnimGraph.Node.Clip")
local clip = Clip.new(asset, layout, true, 1.0)
clip:update(dt)
local pose = clip:evaluate()
```
## Notes
- The Clip owns its pose buffer and all `Channel` handles. `destroy()`
is required to release them — Lua's GC does not free engine resources.
- Only channels with a known `path` (`translation` / `rotation` / `scale`)
and a `target_bone` present in `layout.boneOrder` are wired up; others
are silently dropped.
- Looping uses modulo wrap. Non-looping clips clamp to `duration` and
set `finished = true`, `playing = false`.
# Mixer
N-input weighted-blend Node subclass. Combines N source nodes into a
single pose buffer via `Blend.weightedInto` using a slot layout
(translation lerp + rotation slerp + scale lerp by default). Inputs
with non-positive weights are skipped at evaluate time.
## Exports
- `Mixer.new(layout: Layout, inputs: { MixerInput }) -> Mixer` — construct a Mixer with N weighted inputs. Allocates an output pose buffer.
- `Mixer:setWeight(i: number, w: number)` — update an input's weight (no-op if `i` is out of range).
- `Mixer:update(dt: number)` — cascade `update(dt)` to every input source.
- `Mixer:evaluate() -> TypedBuffer` — blend inputs into the output buffer and return it. Caller must NOT destroy.
- `Mixer:destroy()` — free the output buffer and blend layout. Does not destroy child sources.
Types:
- `Layout = { boneOrder: { string }, stride: number?, slotLayout: { any }? }`
- `MixerInput = { source: any, weight: number }`
## Usage
```luau
local Mixer = require("@builtin::systems.anim.AnimGraph.Node.Mixer")
local mix = Mixer.new(layout, {
{ source = clipA, weight = 1.0 },
{ source = clipB, weight = 0.0 },
})
mix:setWeight(2, 0.5)
mix:update(dt)
local pose = mix:evaluate()
```
## Notes
- The mixer owns its output buffer and `Blend.layout` handle. Child
source nodes are owned by the surrounding `AnimGraph`, not the
Mixer — `Mixer:destroy()` does not recurse into them.
- Default slot layout assumes a 10-stride bone record:
`translation.xyz` (lerp), `rotation.xyzw` (slerp), `scale.xyz` (lerp).
Override via `layout.slotLayout`.
- Inputs with `weight <= 0` are silently skipped — there is no error
for "no active input"; the output buffer is zeroed instead.
# `retarget` module
`require("modules.retarget")` — skeletal animation retargeting in readable Luau.
Map a clip authored on one humanoid rig onto another, preserving the target's
shape. This is the runtime retarget path; the behaviour lives here, in Luau, so
an agent can follow and tweak it. (`__retarget.oracleBake` is the Rust numerical
oracle this is validated against, not the runtime path.)
## How it works
Each `.rig` carries a **profile** — a canonical-role → bone map (the driver).
Retarget is two steps:
1. **Bone map** — source bone → its role → the target bone filling that role.
Roles are the shared vocabulary, so any two rigs interoperate through their
profiles without a per-pair mapping.
2. **Shape-preserving transfer** — the target keeps its own bone lengths and rest
orientations; the animation contributes only delta-from-rest motion. Rotations
are bind-pose-corrected (the target reproduces the source's world-space motion
relative to its own rest). Translation is re-expressed in the target parent's
frame and size-scaled, then applied RELATIVE to the target's bind: every bone
starts at the target's own offset and the clip adds its displacement from rest
on top — a bone with no translation motion stays put (proportions preserved),
while a bone that moves (the hips' vertical bob, the root's stride) carries that
motion across, size-scaled to the target.
Retarget is **cold**: `bake` once per (clip, target rig) and cache; the hot path
just samples the baked clip, exactly like a native one.
## Surface
- `parseRig(rig)` → enriched rig. Accepts a parsed `.rig` table, a `.rig` JSON
string, or an already-parsed rig (idempotent).
- `plan(srcRig, tgtRig)` → `{ mapped, unmappedSource, unmappedTarget, … }` —
which roles map across the rigs, and which don't (the diagnostic).
- `bake(clip, srcRig, tgtRig)` → a decoded clip table in the target's bone space.
`clip` is a decoded clip (`{ name, duration, channels, bone_names }`).
- `bakeBytes(clipBytes, srcRig, tgtRig, cacheKey?)` → retargeted clip `zanim`
bytes, with an in-memory cache keyed by `cacheKey`.
- `loadRig(ref)` → parsed rig from a `.rig` asset.
- `clearCache(cacheKey?)` → drop cached bakes (call after editing a rig profile).
## Example
```lua
local retarget = require("modules.retarget")
local src = retarget.loadRig(asset.ref("synty_character", "rig"))
local tgt = retarget.loadRig(asset.ref("hero", "rig"))
-- inspect the mapping
local plan = retarget.plan(src, tgt)
print(plan.mapped.leftarm.source, "->", plan.mapped.leftarm.target)
-- bake a walk clip onto the hero rig (cold, cached), then sample it like any clip
local walkBytes = vfs.read(asset.source("walk", "animation") .. "/data.zanim")
local walkOnHero = retarget.bakeBytes(walkBytes, src, tgt, "walk|hero")
local bind = skeleton.bindClip(walkOnHero, heroBoneOrder)
```
# AnimGraph
DAG of animation nodes implemented in pure Luau. Nodes are stored in
a slot vector; freed slots are reused on the next `addNode` (mirrors
the legacy Rust crate's `Vec<Option<AnimNode>>` pattern). An AnimGraph
has exactly one designated "output" node. Each frame the engine calls
`graph:update(dt)` followed by `graph:evaluate()`, which recursively
walks reachable nodes and returns the pose buffer for the output node.
The module re-exports the Node hierarchy on the returned table so
callers can reach `AnimGraph.Clip.new(...)`, `AnimGraph.Mixer.new(...)`,
etc. without separate requires.
## Exports
- `AnimGraph.new(opts: { layout: Layout? }?) -> AnimGraph` — construct a new graph; `layout` defaults to a sensible default.
- `AnimGraph:addNode(node: Node) -> number` — insert a node, return its slot id (reuses freed slots).
- `AnimGraph:removeNode(id: number)` — free the node at slot `id` and call `:destroy()` on it.
- `AnimGraph:setOutput(id: number)` — designate which node is the graph's final output.
- `AnimGraph:update(dt: number)` — advance every reachable node.
- `AnimGraph:evaluate() -> TypedBuffer?` — return the output node's pose buffer (caller MUST NOT destroy).
- `AnimGraph:crossfade(from: number, to: number, duration: number)` — sugar that wires a temporary Mixer between two nodes, ramps weights over `duration`, then collapses to the new output when the ramp completes.
- `AnimGraph.Clip`, `AnimGraph.Mixer`, `AnimGraph.BlendSpace2D`, `AnimGraph.Node` — re-exports of the node constructors.
Types:
- `Layout = { ... }` — pose layout used by the graph and its nodes.
## Usage
```luau
local AnimGraph = require("@builtin::systems.anim.AnimGraph")
local graph = AnimGraph.new({ layout = myLayout })
local idle = graph:addNode(AnimGraph.Clip.new("idle"))
local walk = graph:addNode(AnimGraph.Clip.new("walk"))
graph:setOutput(idle)
graph:crossfade(idle, walk, 0.25) -- blend to walk over 250ms
-- Each frame:
graph:update(dt)
local pose = graph:evaluate()
```
## Notes
- The output node MUST be set before `evaluate` is called; otherwise
the return value is `nil`.
- `crossfade` collapses the temporary mixer when the ramp completes,
so long-running graphs don't accumulate orphan mixers.
- Buffer ownership: nodes own their internal buffers and free them in
`:destroy()`. Callers of `evaluate` borrow the returned buffer — do
NOT destroy it.
- `removeNode` frees the slot id and calls `:destroy()` on the node;
the next `addNode` may reuse the same id.
# json
JSON encode/decode library for Luau. Encodes Lua values to JSON
strings and decodes JSON strings back to Lua values. Used for
communication with the Rust side of the engine, the VFS read/write
bridge, and any wire-format that needs JSON.
Pure Luau, no engine dependencies. Compact and pretty-printed
encoders, plus a hand-rolled decoder that streams the input by position
so it works under WASM as well as native.
## Exports
- `Json.encode(value: any, indent?: string, currentIndent?: string) -> string` — compact encode. Functions / unknown types and NaN/Inf encode as `null`.
- `Json.encodePretty(value: any, indentStr?: string) -> string` — pretty-printed encode with sorted object keys (diff-friendly).
- `Json.encodeArgs(...: any) -> string` — encode varargs as a JSON array.
- `Json.decode(str: string) -> any` — decode a JSON string. Returns the decoded value, or `nil` + error message on failure.
## Usage
```luau
local Json = require("@builtin::modules.json")
local widget = { type = "button", text = "Click Me" }
local compact = Json.encode(widget) -- '{"text":"Click Me","type":"button"}'
local pretty = Json.encodePretty(widget, " ")
local decoded = Json.decode(compact)
local v, err = Json.decode("oops") -- v = nil, err = error message
```
## Notes
- Object keys are sorted alphabetically in both encoders for consistent
output across runs.
- Numeric keys on objects are stringified at encode time (JSON has no
numeric keys). Pure-integer key sets get detected as arrays via
`isArray` and encoded with brackets.
- NaN, +Inf, -Inf encode as `null` — JSON has no representation. Round
trips through `decode` recover `null` (Lua `nil`), so they don't
preserve.
- Unicode `\uXXXX` escapes decode to UTF-8 by hand to stay WASM-safe.
Only the BMP is covered; supplementary planes via surrogate pairs
are not.
- Functions encode as `null`.
- Decode is character-streamed — no regex, no `string.match` patterns
on the whole input — so the line-and-column information needs to be
reconstructed from the position offset.
# rigmath
Quaternion algebra, vector helpers, and forward kinematics over a bone
hierarchy. One definition of the math, shared by retargeting and IK.
Quaternions are `{ x, y, z, w }` arrays and vectors are `{ x, y, z }` arrays,
matching the conventions the engine's rig data already uses, so values read
straight out of `ecs.Skeleton.bones` or a parsed `.rig` need no conversion.
## Exports
Vectors:
- `vdot(a, b) -> number` — dot product.
- `vcross(a, b) -> { number }` — cross product.
- `vlen(v) -> number` — Euclidean length.
- `vsub(a, b)`, `vadd(a, b)`, `vscale(v, s) -> { number }` — component-wise arithmetic.
- `vnormalize(v) -> { number }` — unit vector; a zero-length input returns zero.
- `vperpendicular(v) -> { number }` — a deterministic unit vector at right angles to `v`.
Quaternions:
- `IDENTITY` — `{ 0, 0, 0, 1 }`.
- `qmul(a, b) -> { number }` — Hamilton product; applies `b`, then `a`.
- `qnormalize(q)`, `qinverse(q) -> { number }`.
- `qrotvec(q, v) -> { number }` — rotate a vector.
- `shortestArc(a, b) -> { number }` — the rotation carrying unit vector `a` onto `b`.
- `axisAngle(axis, angle) -> { number }` — from an axis and radians.
- `qslerp(a, b, t) -> { number }` — shortest-arc interpolation.
- `qangle(q) -> number` — rotation magnitude in radians, `[0, pi]`.
- `signedAngle(a, b, axis) -> number` — roll from `a` to `b` about `axis`, in radians.
- `swingTwist(q, axis) -> ({ number }, { number })` — twist about `axis`, then the remaining swing.
Scalars:
- `isFinite(n) -> boolean`, `clamp(v, lo, hi) -> number`.
Forward kinematics:
- `computeGlobals(bones) -> (gRot, gPos)` — global rest transforms from local ones.
- `computeBoneLengths(bones, gPos) -> { number }` — each bone's distance to its farthest child.
## Usage
```luau
local rigmath = require("modules.rigmath")
-- Point a bone's forward axis at a target.
local dir = rigmath.vnormalize(rigmath.vsub(targetPos, bonePos))
local swing = rigmath.shortestArc(rigmath.qrotvec(boneRot, { 0, 0, 1 }), dir)
local aimed = rigmath.qmul(swing, boneRot)
-- Blend the result in at a weight.
local final = rigmath.qslerp(boneRot, aimed, 0.5)
```
## Notes
- Degenerate input never produces NaN. A zero-length vector normalizes to zero,
a degenerate quaternion normalizes to identity, and `shortestArc` on
antiparallel vectors resolves to a half turn about a perpendicular axis.
- `computeGlobals` tolerates any bone ordering, including a parent listed after
its child, and falls back to the local transform for a bone left unresolved by
a cyclic parent.
- Bone `parent` indices are 0-based with -1 for a root, matching the rig format;
the returned arrays are 1-based and parallel to the input.