19 KiB
Animator As Crab
A hard fork of Animator As Code where Animator
Controllers are described in Rhai instead of C#. A Rust library (libaac)
evaluates the script, validates it, and emits the controller as JSON. A Unity Editor window
deserializes that JSON and builds a real AnimatorController through the original Animator As Code
V1 library, which is left untouched.
script.rhai → libaac (Rust) ──JSON──→ AacCrabWindow (Unity Editor) → AnimatorController
Table of Contents
- Why
- Architecture
- Layout
- Building
- Installation
- Usage
- DSL Reference
- JSON Wire Format
- Native FFI API
- C# Bridge
- VPM Distribution
- Nix Development
- Limitations
- License
Why
Animator As Code V1 lets you build Animator Controllers from a fluent C# API. That works, but every change requires recompilation. Animator As Crab replaces the C# builder with a Rhai script that is evaluated at edit time:
- No recompilation. Save the
.rhaifile and the controller rebuilds automatically. - Version control friendly. A
.rhaiscript is a single readable file; no binary assets. - Validates before generation. The Rust core checks the graph for dangling references, duplicate names, mismatched parameter types, and missing configuration before Unity ever sees it.
- Untouched upstream. The original Animator As Code V1 C# library ships as-is; the generator drives its public API.
Architecture
The system is a pipeline with three stages:
-
Rhai evaluation (
libaac). The engine registers builder types (Aac,ClipBuilder,StateRef, etc.) and Rhai-callable functions. The script mutates a sharedControllerGraphthrough these builders. -
Validation and serialization (
libaac). After the script runs, keyframes are sorted by time, then the graph is validated: every name is unique, every reference resolves, every parameter type matches its usage, every transition targets a real state, and required fields (system_name,asset_key) are non-empty. The validated graph is serialized to JSON. -
C# generation (Unity Editor).
AacCrabWindowreads the JSON, deserializes it intoAacCrabGraphDTOs, and drives Animator As Code V1's modification API: clears the controller, creates parameters, builds clips (cloning store assets viaAssetDatabase), builds blend trees in declaration order (so forward references are impossible), and wires up states and transitions.
The Rust and C# sides share no runtime dependency. The contract is a JSON document whose shape is
pinned by a test (json_keys_are_the_ones_the_unity_dtos_expect in rust/tests/dsl.rs).
Layout
| Path | Contents |
|---|---|
rust/ |
The Rhai DSL, graph model, validator, FFI exports, and the libaac cdylib. |
rust/examples/avatar.rhai |
Reference script exercising every feature of the DSL. |
rust/examples/animation_store.rhai |
Reference script for the animation store feature. |
rust/tests/dsl.rs |
Integration tests: round-trip JSON, validation errors, API surface. |
csharp/dev.doloro.animator-as-crab/ |
Unity package: the untouched Animator As Code V1 library and the bridge in V1/Editor/Crab/. |
csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabNative.cs |
P/Invoke bindings for libaac. |
csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGraph.cs |
JSON DTOs matching rust/src/graph.rs. |
csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGenerator.cs |
Turns the graph into an AnimatorController via AAC V1. |
csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabWindow.cs |
Unity Editor window with file watching. |
vpm/ |
VPM packaging scripts and the package index. |
flake.nix |
Dev shell with Rust, dotnet, jq, zip, and just. |
justfile |
Shortcuts for importing the VPM package into a Unity project. |
Building
With Nix
nix develop # enter the dev shell
cargo test --manifest-path rust/Cargo.toml # run tests
nix build .#aac # produces libaac.so + aac-dump
nix build .#bundle # produces a Unity-importable zip
Without Nix
You need Rust (stable) and a C-compatible linker.
cd rust
cargo build --release
# Output: target/release/libaac.so (Linux), libaac.dylib (macOS), or aac.dll (Windows)
# Binary: target/release/aac-dump
Standalone dump tool
cargo run --bin aac-dump -- rust/examples/avatar.rhai # prints the generated JSON
Installation
Unity package (VPM)
Add the VPM repository URL to the VRChat Creator Companion or ALCOM:
https://git.scug.io/doloro/av3-animation-as-crab/raw/branch/main/vpm/index.json
Or import manually:
# Build the package zip first
./vpm/package.sh
# Then import into a Unity project
just import /path/to/unity-project
Manual
- Copy
libaac.so/libaac.dll/libaac.dylibinto your Unity project'sAssets/Plugins/. - Add the
csharp/dev.doloro.animator-as-crabfolder to your project'sPackages/directory. - The package depends on
com.unity.nuget.newtonsoft-json(3.2.1); Unity's Package Manager resolves this automatically.
Usage
- Write a
.rhaiscript (see DSL Reference). - Open Tools > Animator As Crab in the Unity Editor.
- Fill in the window:
- Rhai script — path to your
.rhaifile. - Animator Controller — the controller asset to generate into.
- Animator Root — the
Transformthat owns theAnimatorcomponent. - Asset Container — the folder/object that will hold generated clips and blend trees.
- Container Mode — how assets are organized (
Everything,ChildrenOfRoot, etc.). - Write Defaults — whether states write defaults.
- Rhai script — path to your
- Click Generate, or enable Generate when the script changes for automatic regeneration on save.
DSL Reference
Setup
Every script starts by creating an Aac context and configuring the system name and asset key:
let aac = AnimatorAsCode();
aac.system_name("MyAvatar"); // required — identifies the system
aac.asset_key("AAC_"); // required — prefix for generated assets
The system name and asset key are written by the script, not guessed by the Unity side. The validator rejects empty values.
Parameters
Parameters are controller-wide. Declare them before building layers.
| Function | Return type | Default |
|---|---|---|
aac.float_param("Name", 0.0) |
FloatParam |
0.0 |
aac.int_param("Name", 0) |
IntParam |
0 |
aac.bool_param("Name", false) |
BoolParam |
false |
Duplicate parameter names are rejected at the call site.
Controller and Layers
let ctrl = aac.new_controller(); // exactly one controller
let base = ctrl.layer("Base"); // adds a layer; returns a LayerBuilder
let props = ctrl.layer("Props");
States
States live on a layer or inside a sub-state machine. The last two arguments are the grid position in the Unity animator graph:
let idle = base.state("Idle", 0, 0);
let walk = base.state("Walk", 1, 0);
Clips
Generated clips are declared on the Aac context:
let clip = aac.clip("idle_anim"); // name must be unique
clip.looping(true); // optional override
clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0);
clip.blend_shape("Body", "Smile", 0.0, 0.0);
clip.blend_shape("Body", "Smile", 1.0, 0.8);
clip.toggle("Body/Props", true); // shortcut for a one-frame m_IsActive constant
Keyframes are sorted by time automatically. All keyframes are linear (tangents are 0).
Supported curve targets (inferred from the property name):
| Property | Component type |
|---|---|
m_IsActive |
GameObject |
blendShape.* |
SkinnedMeshRenderer |
Any other property is rejected with a clear error.
Assigning clips to states:
idle.set_clip(clip); // same as set_motion
walk.set_motion(clip);
Animation Store
An animation store seeds clips from .anim assets that already exist in the project. The
generator loads the asset with AssetDatabase.LoadAssetAtPath, clones it into the asset
container, and applies the script's keyframes and looping on the clone. The pre-made asset is
never modified.
let store = aac.AnimationStore("Assets/Doloro/Clips");
let walk = store.clip("Walk"); // resolves to Assets/Doloro/Clips/Walk.anim
walk.looping(true); // override on the clone
walk.keyframe("Body/Props", "m_IsActive", 0.0, 0.0);
A store clip with no looping(...) call keeps the source asset's own looping setting. Requesting
the same asset twice (with or without .anim) reuses the same reference. A missing asset is a
clear error at generation time.
Store clips are usable anywhere a generated clip is:
walk_state.set_clip(walk);
locomotion.add_motion(walk, 0.0);
Blend Trees
Blend trees are declared on the Aac context and configured with a blend type and parameters:
let tree = aac.blend_tree("locomotion");
tree.simple_1d(speed); // 1D blend
tree.add_motion(walk_clip, 0.0); // (clip, threshold)
tree.add_motion(run_clip, 5.0);
Blend types:
| Function | Parameters | Use case |
|---|---|---|
tree.simple_1d(x) |
1 float param | Walk speed, locomotion blends |
tree.simple_directional_2d(x, y) |
2 float params | Strafe direction |
tree.freeform_directional_2d(x, y) |
2 float params | Complex directional |
tree.freeform_cartesian_2d(x, y) |
2 float params | Full 2D control |
tree.direct() |
none | Per-parameter clip addressing |
Adding children:
// 1D / 2D threshold-based:
tree.add_motion(clip, threshold);
tree.add_motion(clip, x, y);
// 2D also works with blend trees as children:
tree.add_motion(inner_tree, 0.0, 1.0);
// Direct blend:
tree.add_motion_direct(clip, parameter);
Automatic thresholds:
tree.automatic_thresholds(true);
Blend trees can nest other blend trees that were declared earlier in the script. Cycles are impossible because trees are created in declaration order.
Assigning blend trees to states:
run.set_motion(tree);
Transitions
Transitions are created by calling transition_to on a state (or any-state ref) and chaining
condition and setting methods:
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);
Conditions are written on the parameter objects using comparison operators:
| Operator | Float/Int | Bool |
|---|---|---|
> |
Greater | — |
< |
Less | — |
== |
Equals | If / IfNot |
!= |
NotEqual | IfNot / If |
Combine multiple conditions with when_all([...]):
walk.transition_to(idle).when_all([speed < 0.05, is_sitting == false]).duration(0.25);
Why not
&&? Rhai's&&and||are language built-ins that cannot be overloaded.speed > 0.1 && is_sitting == truewill not compile. Usewhen_all.
Any-State Transitions
Any-state transitions fire from every state in a machine:
base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2);
Any-state can also be used inside a sub-state machine:
let gestures = base.sub_machine("Gestures", 2, 0);
gestures.any_state().transition_to(reset).when(reset_all == true);
Sub-State Machines
let gestures = base.sub_machine("Gestures", 2, 0);
let wave = gestures.state("Wave", 0, 0);
let point = gestures.state("Point", 1, 0);
wave.transition_to(point).when(gesture == 1).duration(0.1);
point.transition_to(wave).when(gesture == 0).duration(0.1);
States and transitions inside a sub-state machine work exactly like the root machine.
Transition Settings
All settings are chainable on a TransitionRef:
| Method | Effect | Default |
|---|---|---|
.duration(seconds) |
Cross-fade duration | 0.0 |
.no_exit_time() |
Transition fires immediately | — |
.exit_time(normalized) |
Transition fires after this normalized time | — |
.to_self() |
Allow self-transitions | false |
.ordered_interruption(bool) |
Ordered interruption | true |
.source_interruption() |
Source interruption | false |
Default transition values match Animator As Code V1's AacDefaultsProvider.ConfigureTransition.
JSON Wire Format
The Rust core serializes the graph as JSON. The C# side deserializes it with
MissingMemberHandling.Error, so the shape is a strict contract. Every object's key set is
pinned by the test json_keys_are_the_ones_the_unity_dtos_expect in rust/tests/dsl.rs.
Top-level keys: system_name, asset_key, parameters, clips, blend_trees, controller.
Example (trimmed):
{
"system_name": "MyAvatar",
"asset_key": "AAC_",
"parameters": [
{ "type": "float", "name": "Speed", "default": 0.0 }
],
"clips": [
{
"name": "idle_anim",
"looping": true,
"curves": [
{
"path": "Body/Hand",
"target": "game_object",
"property": "m_IsActive",
"keys": [{ "time": 0.0, "value": 1.0, "in_tangent": 0.0, "out_tangent": 0.0 }]
}
]
}
],
"blend_trees": [
{
"name": "locomotion",
"blend_type": "simple_1d",
"param_x": "Speed",
"param_y": null,
"children": [
{ "motion": { "type": "clip", "name": "walk_anim" }, "threshold": 0.0, "threshold_y": null, "direct_param": null }
],
"use_automatic_thresholds": false
}
],
"controller": {
"layers": [
{
"name": "Base",
"state_machine": {
"name": null,
"position": { "x": 0, "y": 0 },
"states": [
{
"name": "Idle",
"position": { "x": 0, "y": 0 },
"motion": { "type": "clip", "name": "idle_anim" },
"transitions": [
{
"to": "Walk",
"conditions": [{ "parameter": "Speed", "mode": "greater", "threshold": 0.1 }],
"has_exit_time": false,
"exit_time": 0.0,
"duration": 0.25,
"ordered_interruption": true,
"source_interruption": false,
"can_transition_to_self": false
}
]
}
],
"sub_machines": [],
"any_state_transitions": []
}
}
]
}
}
Store clips include a "source" field with the project-relative asset path; generated clips
omit it.
Native FFI API
libaac exposes a C ABI for Unity's P/Invoke:
| Function | Signature | Description |
|---|---|---|
aac_create |
() → *mut AacContext |
Create a context. Owns the graph and engine. |
aac_eval_rhai |
(handle, script) → i32 |
Evaluate a script. Returns 0 on success. |
aac_to_json |
(handle) → *mut c_char |
Serialize the graph to JSON. Free with aac_free_string. |
aac_last_error |
(handle) → *const c_char |
Last error message, or null. Borrowed until next call. |
aac_free_string |
(string) |
Free a string returned by aac_to_json. |
aac_destroy |
(handle) |
Destroy a context. |
The context is single-threaded. Panics across the FFI boundary are caught and reported as errors.
Standalone dump tool (aac-dump):
cargo run --bin aac-dump -- path/to/script.rhai
Prints the generated JSON to stdout. Exits with a non-zero status and prints the error to stderr on failure.
C# Bridge
The Unity side lives in csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/:
-
AacCrabNative— P/Invoke wrapper aroundlibaac. Creates a context, evaluates the script, reads the JSON, and destroys the context in a singleEvaluatecall. HandlesDllNotFoundExceptionwith a clear message. -
AacCrabGraph— DTO classes mirroringrust/src/graph.rs. Deserialized with Newtonsoft.Json usingsnake_casenaming andStringEnumConverter. Missing fields are an error, not silently ignored. -
AacCrabGenerator— The core generator. Clears the controller via AAC V1's modification API, creates parameters (applying defaults by mutating the controller's parameter list), builds clips (cloning store assets viaAssetDatabase), builds blend trees in declaration order, and wires states and transitions. Forward references within a layer resolve because all states are created before any transitions. -
AacCrabWindow— Editor window (Tools > Animator As Crab). Watches the script file for changes and regenerates automatically. All Unity-side configuration (controller, root, container, container mode, write defaults) is supplied by the user; nothing is defaulted silently.
VPM Distribution
The vpm/ directory contains the packaging and publishing pipeline:
-
package.sh— Builds a VPM release zip containing the C# package and the native library, then updatesvpm/index.jsonwith the new version's URL and SHA-256. Environment variables override the host, project, and version. -
publish.sh— Creates a Gitea release for a given tag and attaches the VPM zip. RequiresTOKEN,SERVER,REPO, andTAGenvironment variables. -
index.json— The VPM repository index read by VCC and ALCOM.
To add the repository to VCC/ALCOM, paste the index URL:
https://git.scug.io/doloro/av3-animation-as-crab/raw/branch/main/vpm/index.json
Nix Development
The flake.nix provides:
- Dev shell (
nix develop): Rust stable with clippy, rustfmt, rust-analyzer;dotnet,mono,jq,zip,unzip,just,curl. - Packages:
nix build .#aac(native library + dump binary),nix build .#bundle(Unity-importable zip). - Formatter:
nix fmt(nixfmt-rfc-style).
The dev shell also provides .direnv integration via .envrc.
Limitations
- No Unity project in this repo. The bridge and generator are verified by reading and testing the Rust side, not by running in Unity.
- Blend tree nesting. A blend tree can only reference a tree declared earlier in the script.
- No avatar masks, layer weights, state behaviours, or parameter drivers. The graph model does not carry them yet.
- Linear keyframes only. All tangent values are 0; no easing curves.
- Two curve target types. Only
GameObject(m_IsActive) andSkinnedMeshRenderer(blendShape.*). Unknown properties are rejected. - Single-threaded FFI. The
AacContextis not safe to share across threads.
License
MIT. See LICENSE.
Originally created by @hai-vr with major contributions from @galister. Forked and extended by doloro.