docs: add comprehensive documentation and Rhai scripting guide

This commit is contained in:
2026-09-19 18:51:45 +01:00
parent 6f6af41245
commit 9041776554
2 changed files with 1037 additions and 0 deletions
+557
View File
@@ -0,0 +1,557 @@
# Animator As Crab
A hard fork of [Animator As Code](https://github.com/hai-vr/av3-animator-as-code) where Animator
Controllers are described in [Rhai](https://rhai.rs/) 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](#why)
- [Architecture](#architecture)
- [Layout](#layout)
- [Building](#building)
- [Installation](#installation)
- [Usage](#usage)
- [DSL Reference](#dsl-reference)
- [Setup](#setup)
- [Parameters](#parameters)
- [Controller and Layers](#controller-and-layers)
- [States](#states)
- [Clips](#clips)
- [Animation Store](#animation-store)
- [Blend Trees](#blend-trees)
- [Transitions](#transitions)
- [Any-State Transitions](#any-state-transitions)
- [Sub-State Machines](#sub-state-machines)
- [Transition Settings](#transition-settings)
- [JSON Wire Format](#json-wire-format)
- [Native FFI API](#native-ffi-api)
- [C# Bridge](#c-bridge)
- [VPM Distribution](#vpm-distribution)
- [Nix Development](#nix-development)
- [Limitations](#limitations)
- [License](#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 `.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
```sh
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.
```sh
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
```sh
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:
```sh
# 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](#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:
```rhai
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
```rhai
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:
```rhai
let idle = base.state("Idle", 0, 0);
let walk = base.state("Walk", 1, 0);
```
### Clips
Generated clips are declared on the `Aac` context:
```rhai
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:**
```rhai
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.
```rhai
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:
```rhai
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:
```rhai
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:**
```rhai
// 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:**
```rhai
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:**
```rhai
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:
```rhai
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([...])`:
```rhai
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:
```rhai
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:
```rhai
let gestures = base.sub_machine("Gestures", 2, 0);
gestures.any_state().transition_to(reset).when(reset_all == true);
```
### Sub-State Machines
```rhai
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):
```json
{
"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`):
```sh
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](LICENSE).
Originally created by [@hai-vr](https://github.com/hai-vr) with major contributions from
[@galister](https://github.com/galister). Forked and extended by doloro.