Compare commits
14
Commits
continuous
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9041776554
|
||
|
|
6f6af41245
|
||
|
|
e45928b82d
|
||
|
|
4c7a4574ab | ||
|
|
1ca4e0bba5
|
||
|
|
ceae378040 | ||
|
|
701d9dc731
|
||
|
|
d9ce28b5fa | ||
|
|
105f9359e3
|
||
|
|
bc2f1354c0 | ||
|
|
13b8d62370
|
||
|
|
8159442e9d | ||
|
|
3d640ebd4d
|
||
|
|
86f42b6552 |
@@ -2,7 +2,7 @@ name: ci
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
tags: ["v*"]
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
name: continuous
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
continuous:
|
|
||||||
name: Publish continuous release
|
|
||||||
runs-on: nix
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Build Rust core
|
|
||||||
run: nix build .#aac --print-build-logs
|
|
||||||
|
|
||||||
- name: Package VPM zip
|
|
||||||
env:
|
|
||||||
VPM_VERSION: continuous
|
|
||||||
VPM_RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download/continuous
|
|
||||||
run: nix develop -c bash ./vpm/package.sh
|
|
||||||
|
|
||||||
- name: Publish release and upload zip
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.VPM_TOKEN || secrets.GITHUB_TOKEN }}
|
|
||||||
SERVER: ${{ github.server_url }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
TAG: continuous
|
|
||||||
VPM_VERSION: continuous
|
|
||||||
run: nix develop -c bash ./vpm/publish.sh
|
|
||||||
|
|
||||||
# The index carries the zip's sha256, which only exists after packaging.
|
|
||||||
- name: Commit updated VPM index
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.VPM_TOKEN || secrets.GITHUB_TOKEN }}
|
|
||||||
SERVER: ${{ github.server_url }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git config user.name gitea-actions
|
|
||||||
git config user.email actions@git.scug.io
|
|
||||||
git add vpm/index.json
|
|
||||||
git diff --cached --quiet && exit 0
|
|
||||||
git commit -m "vpm: continuous index update"
|
|
||||||
git push "https://x-access-token:$TOKEN@${SERVER#https://}/${REPO}.git" HEAD:main
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
# Writing Animator Scripts in Rhai
|
||||||
|
|
||||||
|
A practical guide to writing `.rhai` files that generate Unity Animator Controllers.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Create a file called `avatar.rhai`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let aac = AnimatorAsCode();
|
||||||
|
aac.system_name("MyAvatar");
|
||||||
|
aac.asset_key("AAC_");
|
||||||
|
|
||||||
|
let speed = aac.float_param("Speed", 0.0);
|
||||||
|
|
||||||
|
let ctrl = aac.new_controller();
|
||||||
|
let base = ctrl.layer("Base");
|
||||||
|
|
||||||
|
let idle = base.state("Idle", 0, 0);
|
||||||
|
let walk = base.state("Walk", 1, 0);
|
||||||
|
|
||||||
|
let idle_clip = aac.clip("idle_anim");
|
||||||
|
idle_clip.looping(true);
|
||||||
|
idle_clip.toggle("Body/Props", true);
|
||||||
|
|
||||||
|
idle.set_clip(idle_clip);
|
||||||
|
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **Tools > Animator As Crab** in Unity, point it at this file, pick your controller and
|
||||||
|
animator root, and hit Generate.
|
||||||
|
|
||||||
|
## How Scripts Are Structured
|
||||||
|
|
||||||
|
Every script follows the same pattern:
|
||||||
|
|
||||||
|
1. **Create the context and set metadata** — `AnimatorAsCode()`, `system_name`, `asset_key`.
|
||||||
|
2. **Declare parameters** — floats, ints, bools. These become the controller's parameters.
|
||||||
|
3. **Create the controller and layers** — one controller, one or more layers.
|
||||||
|
4. **Declare clips and blend trees** — the motions your states will use.
|
||||||
|
5. **Create states and assign motions** — place them on the grid.
|
||||||
|
6. **Wire up transitions** — conditions, timing, interruption settings.
|
||||||
|
|
||||||
|
The order within each group matters (parameters before layers, clips before states that use
|
||||||
|
them), but the groups themselves can be interleaved freely — you can declare a clip right before
|
||||||
|
the state that uses it, or batch all clips at the top.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
Parameters are the knobs your animator exposes at runtime. Declare them early — layers and
|
||||||
|
transitions reference them by name.
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let speed = aac.float_param("Speed", 0.0); // FloatParam, default 0.0
|
||||||
|
let gesture = aac.int_param("Gesture", 0); // IntParam, default 0
|
||||||
|
let is_sitting = aac.bool_param("IsSitting", false); // BoolParam, default false
|
||||||
|
```
|
||||||
|
|
||||||
|
You get typed handles back. The same handle is used both in transition conditions and as blend
|
||||||
|
tree parameters — the type is enforced at validation time, not at declaration time.
|
||||||
|
|
||||||
|
Duplicate names are rejected immediately with a clear error.
|
||||||
|
|
||||||
|
## States and Grid Position
|
||||||
|
|
||||||
|
States live on a layer. The last two arguments are the `(x, y)` position in Unity's animator
|
||||||
|
graph editor — purely cosmetic, but useful for keeping things organized:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let idle = base.state("Idle", 0, 0); // top-left
|
||||||
|
let walk = base.state("Walk", 1, 0); // one column right
|
||||||
|
let run = base.state("Run", 2, 0); // further right
|
||||||
|
```
|
||||||
|
|
||||||
|
State names must be unique within a layer (including across sub-state machines).
|
||||||
|
|
||||||
|
## Clips
|
||||||
|
|
||||||
|
### Generated Clips
|
||||||
|
|
||||||
|
Create a clip, configure it, then assign it to a state:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let idle_clip = aac.clip("idle_anim");
|
||||||
|
idle_clip.looping(true);
|
||||||
|
idle_clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0);
|
||||||
|
idle_clip.blend_shape("Face", "Smile", 0.0, 0.0);
|
||||||
|
idle_clip.blend_shape("Face", "Smile", 1.0, 0.8);
|
||||||
|
|
||||||
|
idle.set_clip(idle_clip);
|
||||||
|
```
|
||||||
|
|
||||||
|
Clip names must be unique across the entire script.
|
||||||
|
|
||||||
|
### Keyframes
|
||||||
|
|
||||||
|
Keyframes are always linear (tangents are zero). You add them by specifying a path, a property,
|
||||||
|
a time, and a value:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0);
|
||||||
|
clip.keyframe("Body/Hand", "m_IsActive", 1.0, 0.0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple keyframes on the same curve are sorted by time automatically — the order you write
|
||||||
|
them doesn't matter.
|
||||||
|
|
||||||
|
### Toggles
|
||||||
|
|
||||||
|
The most common pattern: turning a GameObject on or off. `toggle` is a shortcut that writes a
|
||||||
|
two-keyframe constant on `m_IsActive`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
clip.toggle("Body/Props", true); // enable at frame 0, hold
|
||||||
|
clip.toggle("Body/Props", false); // disable
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blend Shapes
|
||||||
|
|
||||||
|
Animate a blend shape weight:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
clip.blend_shape("Face", "Smile", 0.0, 0.0); // start at 0
|
||||||
|
clip.blend_shape("Face", "Smile", 1.0, 0.8); // end at 0.8
|
||||||
|
```
|
||||||
|
|
||||||
|
### Curve Targets
|
||||||
|
|
||||||
|
The generator infers the Unity component type from the property name:
|
||||||
|
|
||||||
|
| Property | Component |
|
||||||
|
| --- | --- |
|
||||||
|
| `m_IsActive` | `GameObject` |
|
||||||
|
| `blendShape.*` | `SkinnedMeshRenderer` |
|
||||||
|
|
||||||
|
Any other property name is rejected. If you need a different component type, extend
|
||||||
|
`infer_target` in `rust/src/graph.rs`.
|
||||||
|
|
||||||
|
## Animation Store
|
||||||
|
|
||||||
|
If you already have `.anim` files in your project, you can use them instead of generating clips
|
||||||
|
from scratch:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let store = aac.AnimationStore("Assets/Doloro/Clips");
|
||||||
|
let walk = store.clip("Walk"); // loads Assets/Doloro/Clips/Walk.anim
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.anim` extension is optional — `store.clip("Walk")` and `store.clip("Walk.anim")` resolve
|
||||||
|
to the same asset and produce the same clip.
|
||||||
|
|
||||||
|
### Editing Store Clips
|
||||||
|
|
||||||
|
Store clips are starting points. Anything you write on them — looping, keyframes, toggles — is
|
||||||
|
applied to a **clone**. The original asset is never modified:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let walk = store.clip("Walk");
|
||||||
|
walk.looping(true); // override the clone's looping
|
||||||
|
walk.toggle("Body/Props/Umbrella", true); // add a keyframe on the clone
|
||||||
|
```
|
||||||
|
|
||||||
|
A store clip with no `looping(...)` call keeps the source asset's own looping setting.
|
||||||
|
|
||||||
|
### Using Store Clips
|
||||||
|
|
||||||
|
Store clips work exactly like generated clips everywhere:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
walk_state.set_clip(walk); // assign to a state
|
||||||
|
locomotion.add_motion(walk, 0.0); // add to a blend tree
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subfolders
|
||||||
|
|
||||||
|
Store paths can contain subfolders. The folder argument to `AnimationStore` and the clip name
|
||||||
|
are joined directly:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let props = aac.AnimationStore("Assets/Doloro/Clips/Props");
|
||||||
|
let umbrella = props.clip("Weapons/Umbrella");
|
||||||
|
// → Assets/Doloro/Clips/Props/Weapons/Umbrella.anim
|
||||||
|
```
|
||||||
|
|
||||||
|
### Missing Assets
|
||||||
|
|
||||||
|
If a store clip references an asset that doesn't exist, you'll get a clear error at generation
|
||||||
|
time — not a silent failure.
|
||||||
|
|
||||||
|
## Blend Trees
|
||||||
|
|
||||||
|
Blend trees mix multiple clips (or other blend trees) based on parameter values.
|
||||||
|
|
||||||
|
### 1D Blend Tree
|
||||||
|
|
||||||
|
The simplest kind — one parameter controls which clip plays:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let locomotion = aac.blend_tree("locomotion");
|
||||||
|
locomotion.simple_1d(speed);
|
||||||
|
locomotion.add_motion(idle_clip, 0.0); // speed 0 → idle
|
||||||
|
locomotion.add_motion(walk_clip, 2.0); // speed 2 → walk
|
||||||
|
locomotion.add_motion(run_clip, 5.0); // speed 5 → run
|
||||||
|
```
|
||||||
|
|
||||||
|
The thresholds define the blend points. Unity interpolates between them.
|
||||||
|
|
||||||
|
### 2D Blend Trees
|
||||||
|
|
||||||
|
Two parameters control a 2D blend space:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let strafe = aac.blend_tree("strafe");
|
||||||
|
strafe.freeform_directional_2d(speed, vertical);
|
||||||
|
strafe.add_motion(forward_clip, 0.0, 1.0); // (x, y) threshold
|
||||||
|
strafe.add_motion(back_clip, 0.0, -1.0);
|
||||||
|
strafe.add_motion(left_clip, -1.0, 0.0);
|
||||||
|
strafe.add_motion(right_clip, 1.0, 0.0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Three 2D types are available:
|
||||||
|
|
||||||
|
| Type | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| `simple_directional_2d` | Clips are evenly distributed around a circle |
|
||||||
|
| `freeform_directional_2d` | Clips can be at any angle, but magnitudes are normalized |
|
||||||
|
| `freeform_cartesian_2d` | Clips can be at any position in the (x, y) plane |
|
||||||
|
|
||||||
|
### Direct Blend Tree
|
||||||
|
|
||||||
|
Each child is addressed by its own float parameter — useful for layered motion:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let hold = aac.blend_tree("hold");
|
||||||
|
hold.direct();
|
||||||
|
hold.add_motion_direct(umbrella_clip, grip); // grip parameter drives this child
|
||||||
|
hold.add_motion_direct(sword_clip, sword_param);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nesting Blend Trees
|
||||||
|
|
||||||
|
A blend tree can reference another blend tree that was declared **earlier** in the script:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let movement = aac.blend_tree("movement");
|
||||||
|
movement.simple_1d(speed);
|
||||||
|
movement.add_motion(walk_clip, 0.0);
|
||||||
|
movement.add_motion(run_clip, 5.0);
|
||||||
|
|
||||||
|
let strafe = aac.blend_tree("strafe");
|
||||||
|
strafe.freeform_directional_2d(speed, vertical);
|
||||||
|
strafe.add_motion(forward_clip, 0.0, 1.0);
|
||||||
|
|
||||||
|
let full_body = aac.blend_tree("full_body");
|
||||||
|
full_body.simple_1d(speed);
|
||||||
|
full_body.add_motion(movement, 0.0); // nests the movement tree
|
||||||
|
full_body.add_motion(strafe, 5.0); // nests the strafe tree
|
||||||
|
```
|
||||||
|
|
||||||
|
Cycles are impossible — you can only reference trees that exist at the point you reference them.
|
||||||
|
|
||||||
|
### Automatic Thresholds
|
||||||
|
|
||||||
|
Let Unity figure out the blend thresholds:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
tree.automatic_thresholds(true);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transitions
|
||||||
|
|
||||||
|
Transitions connect states. You create one by calling `transition_to` on the source state:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
idle.transition_to(walk); // creates a transition from idle to walk
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Conditions
|
||||||
|
|
||||||
|
Chain `.when(...)` to add conditions. Conditions are written as comparisons on parameter
|
||||||
|
handles:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
idle.transition_to(walk).when(speed > 0.1);
|
||||||
|
walk.transition_to(run).when(speed > 4.0);
|
||||||
|
```
|
||||||
|
|
||||||
|
The comparison operators (`>`, `<`, `==`, `!=`) are overloaded on parameter handles to produce
|
||||||
|
conditions, not booleans. This is the core of the DSL — you write conditions exactly the way
|
||||||
|
you'd think about them.
|
||||||
|
|
||||||
|
**Bool parameters** use `==` and `!=`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
any_state.transition_to(sit).when(is_sitting == true);
|
||||||
|
any_state.transition_to(stand).when(is_sitting != true);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combining Conditions with when_all
|
||||||
|
|
||||||
|
Rhai's `&&` and `||` cannot be overloaded — they're language built-ins. To combine multiple
|
||||||
|
conditions on one transition, use `when_all`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
walk.transition_to(idle).when_all([speed < 0.05, is_sitting == false]);
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the only DSL limitation with no workaround. Every multi-condition transition uses
|
||||||
|
`when_all`.
|
||||||
|
|
||||||
|
### Transition Timing
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// Instant transition, no blending:
|
||||||
|
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.0);
|
||||||
|
|
||||||
|
// 250ms cross-fade, fires immediately:
|
||||||
|
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);
|
||||||
|
|
||||||
|
// Transition fires after 80% of the source animation plays:
|
||||||
|
idle.transition_to(walk).when(speed > 0.1).exit_time(0.8).duration(0.25);
|
||||||
|
```
|
||||||
|
|
||||||
|
`no_exit_time()` and `exit_time(normalized)` are mutually exclusive — the last one called wins.
|
||||||
|
|
||||||
|
### Self-Transitions
|
||||||
|
|
||||||
|
By default, a transition to the same state is ignored. Enable it explicitly:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
walk.transition_to(walk).when(speed > 4.0).to_self().no_exit_time().duration(0.0);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interruption Settings
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
transition.ordered_interruption(false); // disable ordered interruption
|
||||||
|
transition.source_interruption(); // enable source interruption
|
||||||
|
```
|
||||||
|
|
||||||
|
## Any-State Transitions
|
||||||
|
|
||||||
|
Any-state transitions fire from every state in a machine, regardless of which state is active:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2);
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the standard way to implement global interrupts — sit down from any animation, reset
|
||||||
|
from any gesture, etc.
|
||||||
|
|
||||||
|
Any-state can also be scoped to a sub-state machine:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let gestures = base.sub_machine("Gestures", 2, 0);
|
||||||
|
gestures.any_state().transition_to(reset).when(gesture == 99);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sub-State Machines
|
||||||
|
|
||||||
|
Group related states into a sub-state machine:
|
||||||
|
|
||||||
|
```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);
|
||||||
|
```
|
||||||
|
|
||||||
|
Sub-state machines work exactly like the root machine — you can add states, transitions,
|
||||||
|
any-state, and even nested sub-state machines to them.
|
||||||
|
|
||||||
|
## Assigning Motions to States
|
||||||
|
|
||||||
|
States can hold a clip or a blend tree as their motion:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// From a generated clip:
|
||||||
|
idle.set_clip(idle_clip);
|
||||||
|
walk.set_motion(walk_clip);
|
||||||
|
|
||||||
|
// From a blend tree:
|
||||||
|
run.set_motion(locomotion);
|
||||||
|
|
||||||
|
// From a store clip:
|
||||||
|
sit.set_clip(store_clip);
|
||||||
|
```
|
||||||
|
|
||||||
|
`set_clip` and `set_motion` are interchangeable — use whichever reads better.
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Here's a full script that demonstrates every feature:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let aac = AnimatorAsCode();
|
||||||
|
aac.system_name("MyAvatar");
|
||||||
|
aac.asset_key("AAC_");
|
||||||
|
|
||||||
|
// Parameters
|
||||||
|
let speed = aac.float_param("Speed", 0.0);
|
||||||
|
let is_sitting = aac.bool_param("IsSitting", false);
|
||||||
|
let gesture = aac.int_param("Gesture", 0);
|
||||||
|
|
||||||
|
// Controller and layer
|
||||||
|
let ctrl = aac.new_controller();
|
||||||
|
let base = ctrl.layer("Base");
|
||||||
|
|
||||||
|
// States
|
||||||
|
let idle = base.state("Idle", 0, 0);
|
||||||
|
let walk = base.state("Walk", 1, 0);
|
||||||
|
let run = base.state("Run", 2, 0);
|
||||||
|
let sit = base.state("Sit", 0, 1);
|
||||||
|
|
||||||
|
// Generated clips
|
||||||
|
let idle_clip = aac.clip("idle_anim");
|
||||||
|
idle_clip.looping(true);
|
||||||
|
idle_clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0);
|
||||||
|
idle_clip.blend_shape("Body", "Smile", 0.0, 0.0);
|
||||||
|
idle_clip.blend_shape("Body", "Smile", 1.0, 0.8);
|
||||||
|
|
||||||
|
let walk_clip = aac.clip("walk_anim");
|
||||||
|
walk_clip.looping(true);
|
||||||
|
walk_clip.toggle("Body/Props", true);
|
||||||
|
|
||||||
|
let run_clip = aac.clip("run_anim");
|
||||||
|
run_clip.looping(true);
|
||||||
|
run_clip.toggle("Body/Props", false);
|
||||||
|
|
||||||
|
// Store clip
|
||||||
|
let store = aac.AnimationStore("Assets/Doloro/Clips");
|
||||||
|
let crouch_clip = store.clip("Crouch");
|
||||||
|
crouch_clip.looping(true);
|
||||||
|
|
||||||
|
// Assign clips
|
||||||
|
idle.set_clip(idle_clip);
|
||||||
|
walk.set_clip(walk_clip);
|
||||||
|
sit.set_clip(crouch_clip);
|
||||||
|
|
||||||
|
// Blend tree
|
||||||
|
let locomotion = aac.blend_tree("locomotion");
|
||||||
|
locomotion.simple_1d(speed);
|
||||||
|
locomotion.add_motion(walk_clip, 0.0);
|
||||||
|
locomotion.add_motion(run_clip, 5.0);
|
||||||
|
run.set_motion(locomotion);
|
||||||
|
|
||||||
|
// Transitions
|
||||||
|
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);
|
||||||
|
walk.transition_to(run).when(speed > 4.0).no_exit_time().duration(0.25);
|
||||||
|
run.transition_to(walk).when(speed < 4.0).no_exit_time().duration(0.25);
|
||||||
|
walk.transition_to(idle).when_all([speed < 0.05, is_sitting == false]).duration(0.25);
|
||||||
|
|
||||||
|
// Any-state interrupt
|
||||||
|
base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2);
|
||||||
|
|
||||||
|
// Sub-state machine
|
||||||
|
let gestures = base.sub_machine("Gestures", 2, 0);
|
||||||
|
let peace = gestures.state("Peace", 0, 0);
|
||||||
|
let point = gestures.state("Point", 1, 0);
|
||||||
|
peace.transition_to(point).when(gesture == 1).duration(0.1);
|
||||||
|
point.transition_to(peace).when(gesture == 0).duration(0.1);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- **Start minimal.** One layer, two states, one transition. Get it generating. Then add
|
||||||
|
complexity incrementally.
|
||||||
|
- **Use the grid positions.** `(0, 0)` is top-left. Spread states horizontally for logical
|
||||||
|
flow, vertically for alternative branches.
|
||||||
|
- **Name clips descriptively.** They become the asset names in Unity. `"walk_anim"` is clearer
|
||||||
|
than `"clip1"`.
|
||||||
|
- **Store clips for reused animations.** If multiple avatars share the same `.anim` assets, a
|
||||||
|
store avoids duplicating keyframes in every script.
|
||||||
|
- **`when_all` is your `&&`.** Every multi-condition transition uses it. There's no way around
|
||||||
|
this — it's a Rhai language limitation.
|
||||||
|
- **Test with `aac-dump`.** Run `cargo run --bin aac-dump -- your_script.rhai` to see the JSON
|
||||||
|
output without opening Unity. Catches script errors instantly.
|
||||||
@@ -96,6 +96,7 @@
|
|||||||
pkgs.curl
|
pkgs.curl
|
||||||
pkgs.jq
|
pkgs.jq
|
||||||
pkgs.zip
|
pkgs.zip
|
||||||
|
pkgs.just
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Import the locally built VPM package into a Unity project.
|
||||||
|
# Usage: just import /path/to/unity-project
|
||||||
|
import unity-proj:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
pkg="dev.doloro.animator-as-crab"
|
||||||
|
zip=$(ls vpm/dist/$pkg-*.zip 2>/dev/null | head -1)
|
||||||
|
[ -n "$zip" ] || { echo "no zip in vpm/dist/ — run vpm/package.sh first"; exit 1; }
|
||||||
|
target="$unity-proj/Packages/$pkg"
|
||||||
|
rm -rf "$target"
|
||||||
|
mkdir -p "$target"
|
||||||
|
unzip -q -o "$zip" -d "$target"
|
||||||
|
echo "imported $pkg -> $target"
|
||||||
Reference in New Issue
Block a user