Files
av3-animation-as-crab/DOCUMENTATION.md
T

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

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 .rhai file and the controller rebuilds automatically.
  • Version control friendly. A .rhai script 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:

  1. Rhai evaluation (libaac). The engine registers builder types (Aac, ClipBuilder, StateRef, etc.) and Rhai-callable functions. The script mutates a shared ControllerGraph through these builders.

  2. 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.

  3. C# generation (Unity Editor). AacCrabWindow reads the JSON, deserializes it into AacCrabGraph DTOs, and drives Animator As Code V1's modification API: clears the controller, creates parameters, builds clips (cloning store assets via AssetDatabase), 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

  1. Copy libaac.so / libaac.dll / libaac.dylib into your Unity project's Assets/Plugins/.
  2. Add the csharp/dev.doloro.animator-as-crab folder to your project's Packages/ directory.
  3. The package depends on com.unity.nuget.newtonsoft-json (3.2.1); Unity's Package Manager resolves this automatically.

Usage

  1. Write a .rhai script (see DSL Reference).
  2. Open Tools > Animator As Crab in the Unity Editor.
  3. Fill in the window:
    • Rhai script — path to your .rhai file.
    • Animator Controller — the controller asset to generate into.
    • Animator Root — the Transform that owns the Animator component.
    • 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.
  4. 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 == true will not compile. Use when_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 around libaac. Creates a context, evaluates the script, reads the JSON, and destroys the context in a single Evaluate call. Handles DllNotFoundException with a clear message.

  • AacCrabGraph — DTO classes mirroring rust/src/graph.rs. Deserialized with Newtonsoft.Json using snake_case naming and StringEnumConverter. 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 via AssetDatabase), 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 updates vpm/index.json with 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. Requires TOKEN, SERVER, REPO, and TAG environment 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) and SkinnedMeshRenderer (blendShape.*). Unknown properties are rejected.
  • Single-threaded FFI. The AacContext is 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.