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 `…
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;layoutdefaults 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 slotidand 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 overduration, 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
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
evaluateis called; otherwise the return value isnil. crossfadecollapses 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 ofevaluateborrow the returned buffer — do NOT destroy it. removeNodefrees the slot id and calls:destroy()on the node; the nextaddNodemay reuse the same id.
Interface
What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.
conforms to
zero/source-extract/v2AnimGraph Module A tree of animation nodes rooted at one output node. Composite nodes (Mixer, BlendSpace2D) OWN their children through `source` references and cascade update / evaluate / destroy down to them; leaf nodes (Clip) hold a playhead. The graph holds only the output root and the pose sink — each frame it updates and evaluates from the root and applies the result to the sink. There is no node registry and no id bookkeeping: you compose nodes directly and name the root with `:setOutput(node)`. A node is owned by exactly one parent (or by the graph, when it is the output), so destroying the output frees the whole tree once. local g = AnimGraph.new(AnimGraph.layoutForEntity(body)) local idle = AnimGraph.Clip.new(idleRef, g.layout, true, 1) local walk = AnimGraph.Clip.new(walkRef, g.layout, true, 1) local bs = AnimGraph.BlendSpace2D.new(g.layout, { { x = 0, y = 0, source = idle }, { x = 0, y = 1, source = walk }, }) g:setOutput(bs) g:bindSink(body) -- per frame: bs:setParams(strafeAngleDeg, speed) g:tick(dt) Crossfade is the one transient DAG: it inserts a 2-input Mixer over the current output and a new node, ramps weights, then collapses back to the new node — detaching the survivor before freeing the mixer so cascade destroy never frees a node that is still in use. The module re-exports the Node hierarchy so callers can reach `AnimGraph.Clip.new(...)`, `AnimGraph.Mixer.new(...)`, etc. without separate requires.
forEntity(bodyId: string) → any
The graph currently bound to drive `bodyId`'s armature, or nil. A layer component looks the base graph up here, then wraps its `output` in a Layer.
| arg | type | description |
|---|---|---|
| bodyId | string | Engine entity id of the skinned body a graph was bound to. |
examples
local g = AnimGraph.forEntity(skinnedBody.id)
subtreeBoneNames(rig: any, rootBoneName: string, out: { [string]: boolean }) → void
All bone names in the subtree rooted at `rootBoneName` (inclusive), by the rig's parent links. Iterate-to-fixpoint so bone order doesn't matter.
| arg | type | description |
|---|---|---|
| rig | any | |
| rootBoneName | string | |
| out | { [string]: boolean } |
mask(layout: Layout, spec: any, rig: any?) →
Build a per-bone mask (a weight per bone in `layout.boneOrder`, 0 where absent) for a Layer. `spec` is mesh-independent OR per-bone, mixing freely: • a REGION name — `"upperBody"`, `"arms"`, `"leftArm"`, `"rightArm"`, `"head"`, `"legs"` — the SUBTREE of its canonical-role roots (so a custom rig's bones are masked by what they ARE, not their names); • a single canonical role (`"righthand"`) or exact bone name; • an ARRAY of roles / bone names (all weight 1); • a MAP of role/bone-name → weight (soft per-bone masks for a specific rig). Roles resolve through the rig's role→bone map; names match the bone order directly, so a rig with custom bones is fully addressable.
| arg | type | description |
|---|---|---|
| layout | Layout | The graph layout (its `boneOrder`, and `targetRig` for role lookup). |
| spec | any | Region name | role | bone name | array | { name = weight } map. |
| rig | any? | Optional parsed rig (role→bone). Defaults to `layout.targetRig`. |
examples
local m = AnimGraph.mask(layout, "upperBody", layout.targetRig)
setName(name: string, w: number) → void
| arg | type | description |
|---|---|---|
| name | string | |
| w | number |
setRoleOrName(key: string, w: number) → void
| arg | type | description |
|---|---|---|
| key | string | |
| w | number |
addRegion(name: string, w: number) → boolean
| arg | type | description |
|---|---|---|
| name | string | |
| w | number |
layoutForEntity(body: EntityRef, opts: { symmetrize: boolean? }?) → Layout
Build the layout for a graph that drives a skinned body. Resolves the body's rig from its `ecs.Skeleton` and packs everything Clip nodes need to retarget clips onto it and apply poses relative to its canonical bind: `boneOrder`, the parsed `targetRig`, its stride-10 canonical `restPose`, and the bake-cache key. Raises when `body` has no rigged Skeleton — call it on a body you intend to animate, after its skeleton is hydrated.
| arg | type | description |
|---|---|---|
| body | EntityRef | The EntityRef of the body to drive (carries a Skeleton with a rig). |
| opts | { symmetrize: boolean? }? | `{ symmetrize }` — absolute (true) vs relative (default) bind correction. |
examples
local layout = AnimGraph.layoutForEntity(skinnedBody)
new(layout: Layout, driver: string?) → AnimGraph
Construct an empty AnimGraph with no output. `:setOutput` names the root node the graph drives; `:bindSink` binds the body the pose is applied to. by every node in the graph. system an author would recognise. `:tick` publishes it every frame, so it is what `animation.body(...).driver` names for the body this graph poses.
| arg | type | description |
|---|---|---|
| layout | Layout | `{ boneOrder, stride?, slotLayout? }` — the skeleton layout shared |
| driver | string? | A name for whatever owns this graph — the component, tool or |
examples
local g = AnimGraph.new(AnimGraph.layoutForEntity(body), "Locomotion")
bindSink(body: EntityRef) → void
Bind a pose sink targeting `body`'s armature, stored on the graph so `:tick(dt)` applies the evaluated pose to it. The bone order is the graph's layout. Re-binding replaces any prior sink. Raises when the sink cannot be bound (the body has no Skeleton + Model when the sink is created).
| arg | type | description |
|---|---|---|
| body | EntityRef | The EntityRef whose bones the graph drives (carries the Skeleton). |
examples
graph:bindSink(skinnedBody)
setOutput(node: any) → void
Name the node the graph drives. The node and the subtree it owns become the graph's output; `:update` / `:evaluate` / `:destroy` cascade from here. Replacing the output does NOT free the old one — detach or destroy it first if it is no longer used.
| arg | type | description |
|---|---|---|
| node | any | The root node (any Clip / Mixer / BlendSpace2D). |
examples
graph:setOutput(blendSpace)
setPlaying(p: boolean) → void
Set the playing flag explicitly. `true` resumes per-frame updates; `false` freezes them.
| arg | type | description |
|---|---|---|
| p | boolean | Whether the graph should run per-frame updates. |
examples
graph:setPlaying(false)
play( ) → void
Start the graph's per-frame update loop. Equivalent to `:setPlaying(true)`.
examples
graph:play()
stop( ) → void
Stop the graph's per-frame update loop. Equivalent to `:setPlaying(false)`.
examples
graph:stop()
update(dt: number) → void
Advance the output subtree and the active crossfade by `dt`. No-op when the graph is not playing. When a crossfade reaches the end the output collapses to the target node and the crossfade mixer (plus, by default, the faded-out source) is freed.
| arg | type | description |
|---|---|---|
| dt | number | Seconds to advance. |
examples
graph:update(1 / 60)
evaluate( ) → any
Evaluate the output subtree and return its pose buffer. Returns nil when there is no output.
examples
local pose = graph:evaluate()
crossfadeTo(toNode: any, duration: number, removeFromOnDone: boolean?) → void
Crossfade from the current output to `toNode` over `duration` seconds. Inserts a 2-input Mixer over the previous output and the new node and ramps weights from `(1, 0)` to `(0, 1)`; on completion the output collapses to `toNode`. Falls back to an instant swap when there is no active output or `duration <= 0` (the old output is freed unless `removeFromOnDone` is false). completes.
| arg | type | description |
|---|---|---|
| toNode | any | Target node (already constructed). |
| duration | number | Fade time in seconds (≥ 0). |
| removeFromOnDone | boolean? | When true (default), free the old output when the fade |
examples
graph:crossfadeTo(runClip, 0.25)
collectClips(node: any, weight: number, out: { any }) → void
Every Clip node under `node`, with the weight it reaches the output at — the product of the edge weights above it. A Mixer weights its inputs directly, a BlendSpace2D weights them through the mixer it drives, and a Layer weights its overlay against a base that always contributes fully.
| arg | type | description |
|---|---|---|
| node | any | |
| weight | number | |
| out | { any } |
publish(driver: string?) → void
Publish what this graph is running on the body it drives, so the engine's animation observation names the clips, their playheads and their retarget coverage beside the pose it measures. `:tick` calls this every frame; call it directly when advancing a graph by hand.
| arg | type | description |
|---|---|---|
| driver | string? | A name for whatever owns this graph, shown as the body's driver. |
examples
graph:publish("Locomotion")state( ) →
Snapshot of graph state — handy for tools and debugging. Walks the output subtree; no internal references are leaked. finished, children? } }`.
examples
local snap = graph:state()
walk(node: any) → any
| arg | type | description |
|---|---|---|
| node | any |
tick(dt: number, sink: any?) → any
One-call per-frame driver. Advances the graph + crossfade by `dt`, evaluates the output, and (when a sink is bound or passed) hands the pose buffer to `skeleton.applyPose`. Returns the pose buffer so callers can read it directly (e.g. screenshot tests). `:bindSink` is used; pass `false` to advance without applying.
| arg | type | description |
|---|---|---|
| dt | number | Seconds to advance. |
| sink | any? | A SinkHandle from `skeleton.bindPose`. Omitted, the sink bound via |
examples
graph:bindSink(skinnedId); graph:tick(dt)
destroy( ) → void
Free the whole graph: cascade-`destroy()` the output subtree, unbind the pose sink, and clear any active crossfade.
examples
graph:destroy()
Layout = {Crossfade = {AnimGraph = {Sub-parts
Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.
Problems
Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.
agent_score is exposed.+ quality × 0.35
+ performance × 0.25
± compat factor
Usability ratings
Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".
Scoped to this part · feeds back into the world's score.