Compare commits

...
13 Commits
Author SHA1 Message Date
doloro 2c3b670ab9 Add animation store for pre-made clips
aac.AnimationStore(folder) seeds a clip from an existing .anim asset and
returns a normal ClipBuilder, so pre-made clips work anywhere generated
clips do and can still be edited (looping, keyframes, toggles, blend
shapes). The Unity side clones the asset into the container and applies
the script's changes to the clone, leaving the original untouched.

- ClipData gains optional source; looping becomes Option<bool> so an
  untouched store clip keeps the asset's own looping setting.
- Add rust/examples/animation_store.rhai, a worked store example.
2026-09-18 14:52:02 +01:00
doloro 42a1234e7f shity basic one shot 2026-09-18 14:01:10 +01:00
doloro 5851077659 new read me 2026-09-18 13:06:03 +01:00
Haï~ ea45f7ef1a Remove UPM installation instructions:
The installation instructions incorrectly assumed that the state of the main branch is in a usable state, but this is a false assumption as all repositories in hai-vr are only tested to be working on release tags.
Redirect to the documentation website instead.
2026-01-31 15:18:07 +01:00
Haï~ 836adc8b60 V1.3.0-alpha.1 Add Modification API, named blend trees overload 2025-08-16 05:32:12 +02:00
Haï~ e16115d9a9 Add Modification API:
- The Modification API is designed specifically for the creation and distribution of modular prefabs, where a developer generates assets on their machine and distributes those generated assets to their users.
  - Add AacFlModification class.
  - Add (AacFlBase).Modification() which returns a AacFlModification.
2025-08-16 05:32:12 +02:00
Haï~ 399b721f4a Add named overload for NewBlendTree() and NewBlendTreeAsRaw():
- Add overload for NewBlendTree() which accepts a name.
- Add overload for NewBlendTreeAsRaw() which accepts a name.
- Addresses #56.
2025-07-11 01:51:45 +02:00
Haï~ 84f7f1d089 V1.2.0 Asset container provider & Final breaking changes 2025-06-23 17:34:24 +02:00
Haï~ 2abc34b61d V1.2.0-beta.2 Safeguard case where AssetContainer is null 2025-06-23 13:47:35 +02:00
Haï~ cfc6c5a9d6 Safeguard case where AssetContainer is null:
- Safeguard a case where AssetContainer is null.
- This covers a known case in Vixen where clips are generated in Edit mode for previews.
2025-06-23 13:46:45 +02:00
yewnyx c87904fbbb Add git url installation note for Unity projects to README (#52). 2025-06-02 02:19:38 +02:00
Tayou 114bea608b Include LICENSE.meta for installing through git url (#55):
- When installing AAC through git url in the Unity package manager (as opposed .unitypackage and ALCOM), the LICENSE file contained within the Packages/ folder would cause errors because files cannot be written there.
- This commit adds the meta file for that LICENSE file, and removes it and other meta files from the exclusion list.
- Original PR comments:

* include LICENSE.meta

generally all meta files should be included. For immutable packages like package registries (e.g. OpenUPM) this is required, as unity can't write a new meta file into the immutable directory. Therefore any files without a meta file will break. Luckily this is just the LICENSE file, no code. but unity still complains

* also include README.md.meta

there is no readme in the package directory. But if there was, its meta file should definitely not be excluded either.
2025-06-02 02:18:31 +02:00
Haï~ 6c16ef567a V1.2.0-beta.1 Asset container provider & Final breaking changes:
- Description for newly introduced code:
  - Add support for third-party asset container management.
    - In preparation for the introduction of [IAssetSaver in NDMF 1.6.0](https://github.com/bdunderscore/ndmf/releases/tag/1.6.0),
      add the ability to delegate the management of the asset container to a third party.
      - No new dependencies are added.
      - By default, the behaviour of Animator As Code is the same as V1.1.0.
    - Add new optional field `AacConfiguration.AssetContainerProvider` to specify an asset container provider.
    - Add new interface `IAacAssetContainerProvider` to abstract asset container management.
  - The above changes have been contributed by **[kb10uy (KOBAYASHI Yū)](https://github.com/kb10uy)** (first contribution).
- (BREAKING) As specified in the changelog for the official release of Animator As Code V1, breaking changes had been planned, and will be applied starting this version:
  - (BREAKING) AacFlSettingKeyframes constructor is now private.
    - For compatibility reasons, it was public for the duration of V1.1.x, and was already marked as obsolete in V1.1.x.
  - (BREAKING) The methods AacFlBase.InternalConfiguration and AacFlBase.InternalDoCreateLayer are now private.
    - For compatibility reasons, it was public for the duration of V1.1.x, and was already marked as obsolete in V1.1.x.
    - The class AacAccessorForExtensions replaced them.
  - These breaking changes are meant to be the last breaking changes for the lifetime of Animator As Code V1.
2024-11-29 12:06:32 +01:00
83 changed files with 4017 additions and 95 deletions
-55
View File
@@ -1,55 +0,0 @@
name: Build Release
on:
workflow_dispatch:
env:
packageName: "dev.hai-vr.animator-as-code.v1"
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: get version
id: version
uses: notiz-dev/github-action-json-property@7c8cf5cc36eb85d8d287a8086a39dac59628eb31
with:
path: "Packages/${{env.packageName}}/package.json"
prop_path: "version"
- name: Set Environment Variables
run: |
echo "zipFile=${{ env.packageName }}-${{ steps.version.outputs.prop }}".zip >> $GITHUB_ENV
echo "unityPackage=${{ env.packageName }}-${{ steps.version.outputs.prop }}.unitypackage" >> $GITHUB_ENV
- name: Create Zip
uses: thedoctor0/zip-release@09336613be18a8208dfa66bd57efafd9e2685657
with:
type: "zip"
directory: "Packages/${{env.packageName}}/"
filename: "../../${{env.zipFile}}" # make the zip file two directories up, since we start two directories in above
- run: find "Packages/${{env.packageName}}/" -name \*.meta >> metaList
- name: Create UnityPackage
uses: pCYSl5EDgo/create-unitypackage@cfcd3cf0391a5ef1306342794866a9897c32af0b
with:
package-path: ${{ env.unityPackage }}
include-files: metaList
- name: Make Release
uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5
with:
tag_name: ${{ steps.version.outputs.prop }}
files: |
${{ env.zipFile }}
${{ env.unityPackage }}
Packages/${{ env.packageName }}/package.json
+1
View File
@@ -0,0 +1 @@
/result
@@ -1,15 +0,0 @@
{
"name": "dev.hai-vr.animator-as-code.v1",
"displayName": "Animator As Code V1",
"version": "1.1.0",
"unity": "2019.4",
"description": "Base Animator As Code library. This library only requires Unity.",
"vrchatVersion" : "2022.1.1",
"author" : {
"name" : "Haï~"
},
"url" : "https://docs.hai-vr.dev/docs/products/animator-as-code/functions/base",
"documentationUrl": "https://docs.hai-vr.dev/docs/products/animator-as-code/functions/base",
"changelogUrl": "https://docs.hai-vr.dev/docs/changelogs/animator-as-code",
"license": "MIT"
}
+90 -22
View File
@@ -1,35 +1,103 @@
Animator As Code V1 ## Animator As Crab
====
**Animator As Code** is a small Unity Editor facility to generate Animator layers and animations from a [fluent builder](https://en.wikipedia.org/wiki/Fluent_interface) syntax written in C#. A hard fork of [Animator As Code](https://github.com/hai-vr/av3-animator-as-code) where animator
controllers are written in [Rhai](https://rhai.rs/) instead of C#.
Describing your animators as code provides the following advantages: A Rhai script is evaluated by a Rust library (`libaac`), which validates it and emits the whole
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.
- you do not need to edit your animations by hand every time you add remove or change the location of a component in your hierarchy ```
- you will not need to edit a hundred transitions by hand if you need to rectify your animator script.rhai --> libaac (Rust) --JSON--> AacCrabWindow (Unity Editor) --> AnimatorController
```
Initially designed for use in VRChat to create Avatars 3.0 animator controllers, this is now a generic package that does not require it. ## Layout
https://user-images.githubusercontent.com/60819407/157751278-475538c7-3310-4fa5-9a87-3651c85eaa1c.mp4 | Path | Contents |
| --- | --- |
| `rust/` | The Rhai DSL, graph model, validator, and the `libaac` cdylib. |
| `rust/examples/avatar.rhai` | The reference script, exercising every feature of the DSL. |
| `csharp/dev.doloro.animator-as-crab/` | The Unity package: the untouched Animator As Code V1 library and the bridge in `V1/Editor/Crab/`. |
| `flake.nix` | Dev shell with the Rust toolchain, `dotnet`, `mono`, and `jq`. |
Initially created by **[@hai-vr](https://github.com/hai-vr)**, ## Building
- with major contributions from **[@galister](https://github.com/galister)** for supporting sub-state machines and the generic use of nodes and parameters.
## Installation ```sh
nix develop
cargo test --manifest-path rust/Cargo.toml
cargo run --bin aac-dump -- rust/examples/avatar.rhai # print the generated JSON
nix build .#aac # libaac.so + aac-dump
```
If you are using Animator As Code in VRChat, install using the VRChat Creator Companion or ALCOM. Instructions are available in [this page](https://docs.hai-vr.dev/docs/products/animator-as-code/install). Copy `libaac.so` (or `libaac.dll`, `libaac.dylib`) into the Unity project's `Assets/Plugins` folder,
and add `csharp/dev.doloro.animator-as-crab` to the project's packages.
Otherwise, if you are **not** using VRChat, install it through the [Releases](https://github.com/hai-vr/av3-animator-as-code/releases). ## Writing a script
### Documentation ```rhai
let aac = AnimatorAsCode();
aac.system_name("MyAvatar");
aac.asset_key("AAC_");
- [Documentation](https://docs.hai-vr.dev/docs/products/animator-as-code) let speed = aac.float_param("Speed", 0.0);
- [V1 Reference manual](https://docs.hai-vr.dev/docs/products/animator-as-code/reference) let ctrl = aac.new_controller();
- [V1 Functions reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/base) let base = ctrl.layer("Base");
- [VRChat reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/vrchat)
- [VRChat (Destructive Workflow) reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/vrchat-destructive-workflow)
- [Modular Avatar As Code reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/modular-avatar)
## Animator As Code V0 let idle = base.state("Idle", 0, 0);
let walk = base.state("Walk", 1, 0);
- You can browse the repository in its **[V0](https://github.com/hai-vr/av3-animator-as-code/tree/V1-2022-04) state**. let walk_clip = aac.clip("walk_anim");
walk_clip.toggle("Body/Props", true);
walk.set_clip(walk_clip);
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);
```
Conditions are written on the parameters themselves (`speed > 0.1`, `is_sitting == true`).
There is no `&&` or `||` — Rhai's are short-circuit built-ins that cannot be overloaded — so
use `when_all([speed < 0.05, is_sitting == false])`.
See `rust/examples/avatar.rhai` for the full surface: parameters, clips, curves, blend trees,
sub-state machines, any-state transitions, and transition settings.
### Pre-made clips (animation store)
An animation store seeds clips from `AnimationClip` assets that already exist in the project.
`store.clip(name)` resolves to `<folder>/<name>.anim` and returns a motion usable anywhere a
generated clip is: as a state motion, as a blend tree child, or through `add_motion_direct`. A store
clip can be edited like any other clip.
```rhai
let store = aac.AnimationStore("Assets/Doloro/Clips");
let walk = store.clip("Walk"); // Assets/Doloro/Clips/Walk.anim
walk.looping(true);
walk.keyframe("Body/Props", "m_IsActive", 0.0, 0.0);
walk_state.set_clip(walk);
locomotion.add_motion(walk, 0.0);
```
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. A store clip with no `looping(...)` call keeps the source asset's own looping setting.
Requesting the same asset twice reuses the one reference, and a missing asset is a clear error.
## Generating
Open *Tools > Animator As Crab* and fill in the window:
- the path to the `.rhai` script (it is watched, so saving the script regenerates the controller),
- the `AnimatorController` to generate into,
- the animator root, the asset container, and the container mode,
- whether states should write defaults.
Nothing is defaulted silently, and the controller is rebuilt with Animator As Code's modification
API: the controller is cleared, and the clips and blend trees of the same asset key are removed
from the container before being recreated. Animator As Code's own C# API is untouched and still
usable on the same controller.
## Limitations
- The Unity side has not been run: there is no Unity project in this repository, so the bridge and
the generator are verified by reading, not by executing.
- Blend trees can only nest a blend tree that was declared earlier in the script.
- The graph carries no avatar masks, layer weights, state behaviours, or parameter drivers yet.
@@ -30,5 +30,3 @@ Assembly-CSharp-Editor.csproj
cfe-project.sln.DotSettings.user cfe-project.sln.DotSettings.user
VRC.SDKBase.Editor.BuildPipeline.csproj VRC.SDKBase.Editor.BuildPipeline.csproj
VRC.SDKBase.Editor.ShaderStripping.csproj VRC.SDKBase.Editor.ShaderStripping.csproj
LICENSE.meta
README.md.meta
@@ -523,6 +523,20 @@ namespace AnimatorAsCode.V1
return new AacFlClip(_configuration, clip); return new AacFlClip(_configuration, clip);
} }
/// Create a new BlendTree asset with a name. However, the name is only used as a suffix for the asset. The asset is generated into the container.<br/>
/// Added in 1.3.0.
public AacFlNonInitializedBlendTree NewBlendTree(string name)
{
return new AacFlNonInitializedBlendTree(AacInternals.NewBlendTreeAsRaw(_configuration, name));
}
/// Create a new BlendTree asset with a name and returns a native BlendTree object. However, the name is only used as a suffix for the asset. The asset is generated into the container. You may use NewBlendTree() instead to obtain a fluent interface.<br/>
/// Added in 1.3.0.
public BlendTree NewBlendTreeAsRaw(string name)
{
return AacInternals.NewBlendTreeAsRaw(_configuration, name);
}
/// Create a new clip which animates a dummy transform for a specific duration specified in an unit (Frames or Seconds). /// Create a new clip which animates a dummy transform for a specific duration specified in an unit (Frames or Seconds).
public AacFlClip DummyClipLasting(float numberOf, AacFlUnit unit) public AacFlClip DummyClipLasting(float numberOf, AacFlUnit unit)
{ {
@@ -628,6 +642,13 @@ namespace AnimatorAsCode.V1
{ {
return new AacFlNoAnimator(); return new AacFlNoAnimator();
} }
/// Returns a new AacFlModification instance, granting you access to this destructive modification API. You will need to reuse this object throughout.<br/>
/// Added in 1.3.0.
public AacFlModification Modification()
{
return new AacFlModification(_configuration, this);
}
} }
public class AacFlNoAnimator public class AacFlNoAnimator
@@ -23,16 +23,19 @@ namespace AnimatorAsCode.V1
public void SaveAsPersistenceRequired(Object objectToAdd) public void SaveAsPersistenceRequired(Object objectToAdd)
{ {
if (_configuration.AssetContainer == null) return;
if (_configuration.ContainerMode != AacConfiguration.Container.Never) AssetDatabase.AddObjectToAsset(objectToAdd, _configuration.AssetContainer); if (_configuration.ContainerMode != AacConfiguration.Container.Never) AssetDatabase.AddObjectToAsset(objectToAdd, _configuration.AssetContainer);
} }
public void SaveAsRegular(Object objectToAdd) public void SaveAsRegular(Object objectToAdd)
{ {
if (_configuration.AssetContainer == null) return;
if (_configuration.ContainerMode == AacConfiguration.Container.Everything) AssetDatabase.AddObjectToAsset(objectToAdd, _configuration.AssetContainer); if (_configuration.ContainerMode == AacConfiguration.Container.Everything) AssetDatabase.AddObjectToAsset(objectToAdd, _configuration.AssetContainer);
} }
public void ClearPreviousAssets() public void ClearPreviousAssets()
{ {
if (_configuration.AssetContainer == null) return;
var allSubAssets = AssetDatabase.LoadAllAssetsAtPath(AssetDatabase.GetAssetPath(_configuration.AssetContainer)); var allSubAssets = AssetDatabase.LoadAllAssetsAtPath(AssetDatabase.GetAssetPath(_configuration.AssetContainer));
foreach (var subAsset in allSubAssets) foreach (var subAsset in allSubAssets)
{ {
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.Animations;
using Object = UnityEngine.Object;
using UnityEngine;
// ReSharper disable once CheckNamespace
namespace AnimatorAsCode.V1
{
public class AacFlModification
{
private readonly AacConfiguration _configuration;
private readonly AacFlBase _base;
private readonly HashSet<Object> _objects = new();
internal AacFlModification(AacConfiguration configuration, AacFlBase originalBase)
{
_configuration = configuration;
_base = originalBase;
}
/// Immediately removes all layers and all parameters from the given AnimatorController, and returns a AacFlController that will edit the given AnimatorController.<br/>
/// This AnimatorController instance is memorized in the current AacFlModification instance memory.<br/>
/// Note: The AnimatorController class is editor-only, so they can't be referenced inside scene components or asset objects. If you have a RuntimeAnimatorController instance, you should cast it to AnimatorController.
public AacFlController ResetAnimatorController(AnimatorController controllerToReset)
{
_objects.Add(controllerToReset);
Internal_ClearAnimatorController(controllerToReset);
return new AacFlController(_configuration, controllerToReset, _base);
}
/// Immediately removes all curves on the clip, and returns a AacFlClip that will edit the given AnimationClip.<br/>
/// This does not reset any other attribute of the clip (e.g., is looping, etc.).<br/>
/// This AnimationClip instance is memorized in the current AacFlModification instance memory.
public AacFlClip ResetClip(AnimationClip clipToReset)
{
_objects.Add(clipToReset);
clipToReset.ClearCurves();
return new AacFlClip(_configuration, clipToReset);
}
/// Immediately clears the list of children in the given BlendTree, sets the parameters to empty strings, and returns a AacFlNonInitializedBlendTree that will edit the given BlendTree.<br/>
/// This does not reset any other attribute of the blend tree (e.g., automatic thresholds, etc.).<br/>
/// This BlendTree instance is memorized in the current AacFlModification instance memory.<br/>
/// Note: The BlendTree class is editor-only, so they can't be referenced inside scene components or asset objects. If you have a Motion instance that is a BlendTree instance, you should cast it to BlendTree.
public AacFlNonInitializedBlendTree ResetBlendTree(BlendTree blendTreeToReset)
{
_objects.Add(blendTreeToReset);
blendTreeToReset.children = Array.Empty<ChildMotion>();
blendTreeToReset.blendParameter = "";
blendTreeToReset.blendParameterY = "";
return new AacFlNonInitializedBlendTree(blendTreeToReset);
}
/// Immediately removes all layers and all parameters from the given AnimatorController.<br/>
/// This AnimatorController instance is memorized in the current AacFlModification instance memory.<br/>
/// Note: The AnimatorController class is editor-only, so they can't be referenced inside scene components or asset objects. If you have a RuntimeAnimatorController instance, you should cast it to AnimatorController.
public AacFlModification ClearAnimatorController(AnimatorController controllerToReset)
{
_objects.Add(controllerToReset);
Internal_ClearAnimatorController(controllerToReset);
return this;
}
/// Returns a AacFlController that will edit the given AnimatorController. This does not reset the AnimatorController.<br/>
/// This AnimatorController instance is memorized in the current AacFlModification instance memory.<br/>
/// Note: The AnimatorController class is editor-only, so they can't be referenced inside scene components or asset objects. If you have a RuntimeAnimatorController instance, you should cast it to AnimatorController.
public AacFlController EditAnimatorController(AnimatorController controllerToReset)
{
_objects.Add(controllerToReset);
return new AacFlController(_configuration, controllerToReset, _base);
}
/// Calls `EditorUtility.SetDirty(...)` on every single AnimatorController, AnimationClip, and BlendTree asset instances previously memorized by this AacFlModification instance.
public void SetDirtyAll()
{
foreach (var obj in _objects)
{
EditorUtility.SetDirty(obj);
}
}
private static void Internal_ClearAnimatorController(AnimatorController controllerToReset)
{
while (controllerToReset.layers.Length > 0)
{
controllerToReset.RemoveLayer(0);
}
var parameters = controllerToReset.parameters;
for (var i = parameters.Length - 1; i >= 0; i--)
{
controllerToReset.RemoveParameter(i);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 27af4c404c051cf48bc07b175b4afda7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -7,7 +7,9 @@
"excludePlatforms": [], "excludePlatforms": [],
"allowUnsafeCode": false, "allowUnsafeCode": false,
"overrideReferences": true, "overrideReferences": true,
"precompiledReferences": [], "precompiledReferences": [
"Newtonsoft.Json.dll"
],
"autoReferenced": true, "autoReferenced": true,
"defineConstraints": [], "defineConstraints": [],
"versionDefines": [], "versionDefines": [],
@@ -0,0 +1,427 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AnimatorAsCode.V1;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace AnimatorAsCrab.V1
{
/// <summary>
/// Turns an <see cref="AacCrabGraph"/> into an AnimatorController by driving Animator As Code V1.
/// This uses the modification workflow: the controller is cleared and rebuilt, and no layer of the
/// controller is touched beforehand.
/// </summary>
public static class AacCrabGenerator
{
/// <summary>Clears the AnimatorController and the assets of the same asset key, then rebuilds them.</summary>
public static void Generate(AacCrabGraph graph, AacConfiguration configuration, AnimatorController controller)
{
if (graph == null) throw new ArgumentNullException(nameof(graph));
if (controller == null) throw new ArgumentNullException(nameof(controller));
if (graph.Controller == null) throw new InvalidOperationException("The graph has no controller.");
var aac = AacV1.Create(configuration);
var modification = aac.Modification();
aac.ClearPreviousAssets();
var aacController = modification.ResetAnimatorController(controller);
var layers = new Dictionary<string, AacFlLayer>();
foreach (var layer in graph.Controller.Layers)
{
layers[layer.Name] = aacController.NewLayer(layer.Name);
}
var floatingParameters = CreateParameters(graph, layers, controller);
var clips = new Dictionary<string, Motion>();
foreach (var clip in graph.Clips)
{
clips[clip.Name] = CreateClip(aac, clip);
}
// Blend trees are created in declaration order, so a child motion can only refer to a tree
// that was declared before its parent. This also makes cycles impossible.
var blendTrees = new Dictionary<string, AacFlBlendTree>();
foreach (var blendTree in graph.BlendTrees)
{
blendTrees[blendTree.Name] = CreateBlendTree(aac, blendTree, clips, blendTrees, floatingParameters);
}
foreach (var layer in graph.Controller.Layers)
{
BuildLayer(layers[layer.Name], layer, clips, blendTrees);
}
modification.SetDirtyAll();
EditorUtility.SetDirty(controller);
}
private static IReadOnlyDictionary<string, AacFlFloatParameter> CreateParameters(AacCrabGraph graph, IReadOnlyDictionary<string, AacFlLayer> layers, AnimatorController controller)
{
var floatingParameters = new Dictionary<string, AacFlFloatParameter>();
if (graph.Parameters.Count == 0)
{
return floatingParameters;
}
if (layers.Count == 0)
{
throw new InvalidOperationException("The controller declares parameters but no layer to hold them.");
}
// Parameters are controller-wide, so any layer can create them.
var layer = layers.First().Value;
foreach (var parameter in graph.Parameters)
{
switch (parameter.Type)
{
case AacCrabParameterType.Float:
floatingParameters[parameter.Name] = layer.FloatParameter(parameter.Name);
break;
case AacCrabParameterType.Int:
layer.IntParameter(parameter.Name);
break;
case AacCrabParameterType.Bool:
layer.BoolParameter(parameter.Name);
break;
default:
throw new InvalidOperationException($"Unknown parameter type {parameter.Type}.");
}
}
ApplyParameterDefaults(controller, graph.Parameters);
return floatingParameters;
}
// Animator As Code creates parameters with Unity's defaults; the graph's defaults are applied
// afterwards by mutating the controller's own parameter list.
private static void ApplyParameterDefaults(AnimatorController controller, IEnumerable<AacCrabParameter> parameters)
{
var wanted = parameters.ToDictionary(parameter => parameter.Name);
var current = controller.parameters;
foreach (var parameter in current)
{
if (!wanted.TryGetValue(parameter.name, out var declared))
{
continue;
}
switch (declared.Type)
{
case AacCrabParameterType.Float:
parameter.defaultFloat = declared.DefaultFloat;
break;
case AacCrabParameterType.Int:
parameter.defaultInt = declared.DefaultInt;
break;
case AacCrabParameterType.Bool:
parameter.defaultBool = declared.DefaultBool;
break;
}
}
controller.parameters = current;
}
// A generated clip is created empty; a store clip starts from a clone of a pre-made asset.
// Either way the script's curves and looping override are applied to the clip owned by this
// generation, so a pre-made asset is never modified.
private static Motion CreateClip(AacFlBase aac, AacCrabClip graph)
{
AacFlClip clip;
if (!string.IsNullOrEmpty(graph.Source))
{
var original = AssetDatabase.LoadAssetAtPath<AnimationClip>(graph.Source);
if (original == null)
{
throw new InvalidOperationException(
$"Animation store clip '{graph.Source}' does not exist. Check the store folder and the clip name.");
}
clip = new AacFlClip(AacAccessorForExtensions.AccessConfiguration(aac), aac.DuplicateAsset(original));
}
else
{
clip = aac.NewClip(graph.Name);
}
if (graph.Looping == true)
{
clip.Looping();
}
else if (graph.Looping == false)
{
clip.NonLooping();
}
clip.Animating(edit =>
{
foreach (var curve in graph.Curves)
{
var keys = curve.Keys
.Select(key => new Keyframe(key.Time, key.Value, key.InTangent, key.OutTangent))
.ToArray();
edit.Animates(curve.Path, UnityType(curve.Target), curve.Property)
.WithAnimationCurve(new AnimationCurve(keys));
}
});
return clip.Clip;
}
private static Type UnityType(AacCrabTargetType target)
{
switch (target)
{
case AacCrabTargetType.GameObject:
return typeof(GameObject);
case AacCrabTargetType.SkinnedMeshRenderer:
return typeof(SkinnedMeshRenderer);
default:
throw new InvalidOperationException($"Unknown curve target {target}.");
}
}
private static AacFlBlendTree CreateBlendTree(
AacFlBase aac,
AacCrabBlendTree graph,
IReadOnlyDictionary<string, Motion> clips,
IReadOnlyDictionary<string, AacFlBlendTree> trees,
IReadOnlyDictionary<string, AacFlFloatParameter> floatingParameters)
{
Motion Resolve(AacCrabMotionRef reference) => MotionOf(reference, clips, trees);
AacFlFloatParameter FloatParameter(string name)
{
if (name == null || !floatingParameters.TryGetValue(name, out var parameter))
{
throw new InvalidOperationException($"Blend tree '{graph.Name}' uses '{name}', which is not a Float parameter.");
}
return parameter;
}
var uninitialized = aac.NewBlendTree(graph.Name);
switch (graph.BlendType)
{
case AacCrabBlendType.Simple1D:
{
var tree = uninitialized.Simple1D(FloatParameter(graph.ParamX));
tree.BlendTree.useAutomaticThresholds = graph.UseAutomaticThresholds;
foreach (var child in graph.Children)
{
tree.WithAnimation(Resolve(child.Motion), child.Threshold ?? 0f);
}
return tree;
}
case AacCrabBlendType.SimpleDirectional2D:
case AacCrabBlendType.FreeformDirectional2D:
case AacCrabBlendType.FreeformCartesian2D:
{
var x = FloatParameter(graph.ParamX);
var y = FloatParameter(graph.ParamY);
var tree = graph.BlendType == AacCrabBlendType.SimpleDirectional2D
? uninitialized.SimpleDirectional2D(x, y)
: graph.BlendType == AacCrabBlendType.FreeformDirectional2D
? uninitialized.FreeformDirectional2D(x, y)
: uninitialized.FreeformCartesian2D(x, y);
foreach (var child in graph.Children)
{
tree.WithAnimation(Resolve(child.Motion), child.Threshold ?? 0f, child.ThresholdY ?? 0f);
}
return tree;
}
case AacCrabBlendType.Direct:
{
var tree = uninitialized.Direct();
foreach (var child in graph.Children)
{
tree.WithAnimation(Resolve(child.Motion), FloatParameter(child.DirectParam));
}
return tree;
}
default:
throw new InvalidOperationException($"Unknown blend type {graph.BlendType}.");
}
}
private static Motion MotionOf(
AacCrabMotionRef reference,
IReadOnlyDictionary<string, Motion> clips,
IReadOnlyDictionary<string, AacFlBlendTree> trees)
{
switch (reference.Type)
{
case AacCrabMotionType.Clip:
if (!clips.TryGetValue(reference.Name, out var clip))
{
throw new InvalidOperationException($"Clip '{reference.Name}' is not declared.");
}
return clip;
case AacCrabMotionType.BlendTree:
// Blend trees are built in declaration order, so a tree can only refer to an earlier one.
if (!trees.TryGetValue(reference.Name, out var tree))
{
throw new InvalidOperationException($"Blend tree '{reference.Name}' is not declared yet; declare it before the motion that uses it.");
}
return tree.BlendTree;
default:
throw new InvalidOperationException($"Unknown motion type {reference.Type}.");
}
}
private sealed class MachineScope
{
public AacFlStateMachine Machine;
public AacCrabStateMachine Graph;
}
private static void BuildLayer(
AacFlLayer layer,
AacCrabLayer graph,
IReadOnlyDictionary<string, Motion> clips,
IReadOnlyDictionary<string, AacFlBlendTree> trees)
{
if (graph.StateMachine == null)
{
throw new InvalidOperationException($"Layer '{graph.Name}' has no state machine.");
}
Motion Resolve(AacCrabMotionRef reference) => MotionOf(reference, clips, trees);
// State names are unique within a layer, so a single dictionary resolves every transition,
// including the ones that cross state machines.
var states = new Dictionary<string, AacFlState>();
var scopes = new List<MachineScope>();
AacFlStateMachine CreateMachine(AacFlStateMachine machine, AacCrabStateMachine machineGraph)
{
scopes.Add(new MachineScope { Machine = machine, Graph = machineGraph });
foreach (var state in machineGraph.States)
{
var aacState = machine.NewState(state.Name, state.Position.X, state.Position.Y);
if (state.Motion != null)
{
aacState.WithAnimation(Resolve(state.Motion));
}
states[state.Name] = aacState;
}
foreach (var subMachine in machineGraph.SubMachines)
{
CreateMachine(machine.NewSubStateMachine(subMachine.Name, subMachine.Position.X, subMachine.Position.Y), subMachine);
}
return machine;
}
// Create every state first: transitions may point forward.
CreateMachine(layer.StateMachine, graph.StateMachine);
foreach (var scope in scopes)
{
foreach (var state in scope.Graph.States)
{
foreach (var transition in state.Transitions)
{
ApplyTransition(states[state.Name].TransitionsTo(Destination(states, transition)), transition);
}
}
foreach (var transition in scope.Graph.AnyStateTransitions)
{
ApplyTransition(scope.Machine.AnyTransitionsTo(Destination(states, transition)), transition);
}
}
}
private static AacFlState Destination(IReadOnlyDictionary<string, AacFlState> states, AacCrabTransition transition)
{
if (!states.TryGetValue(transition.To, out var destination))
{
throw new InvalidOperationException($"Transition target '{transition.To}' is not a state in this layer.");
}
return destination;
}
private static void ApplyTransition(AacFlTransition transition, AacCrabTransition graph)
{
transition.WithTransitionDurationSeconds(graph.Duration);
if (graph.OrderedInterruption)
{
transition.WithOrderedInterruption();
}
else
{
transition.WithNoOrderedInterruption();
}
if (graph.SourceInterruption)
{
transition.WithSourceInterruption();
}
if (graph.CanTransitionToSelf)
{
transition.WithTransitionToSelf();
}
if (graph.HasExitTime)
{
transition.AfterAnimationIsAtLeastAtNormalized(graph.ExitTime);
}
// Conditions are applied last: Animator As Code forbids configuring a transition afterwards.
if (graph.Conditions.Count == 0)
{
return;
}
var continuation = transition.When(Condition(graph.Conditions[0]));
for (var index = 1; index < graph.Conditions.Count; index++)
{
continuation = continuation.And(Condition(graph.Conditions[index]));
}
}
// Animator As Code only exposes typed comparisons for some parameter types, so conditions are
// built the same way the library builds them internally.
private static IAacFlCondition Condition(AacCrabCondition condition)
{
var parameter = condition.Parameter;
var mode = UnityConditionMode(condition.Mode);
var threshold = condition.Threshold;
return AacFlConditionSimple.Just(appender => appender.Add(parameter, mode, threshold));
}
private static AnimatorConditionMode UnityConditionMode(AacCrabCondMode mode)
{
switch (mode)
{
case AacCrabCondMode.Greater:
return AnimatorConditionMode.Greater;
case AacCrabCondMode.Less:
return AnimatorConditionMode.Less;
case AacCrabCondMode.Equals:
return AnimatorConditionMode.Equals;
case AacCrabCondMode.NotEqual:
return AnimatorConditionMode.NotEqual;
case AacCrabCondMode.If:
return AnimatorConditionMode.If;
case AacCrabCondMode.IfNot:
return AnimatorConditionMode.IfNot;
default:
throw new InvalidOperationException($"Unknown condition mode {mode}.");
}
}
}
}
@@ -0,0 +1,195 @@
using System.Collections.Generic;
using System.Runtime.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
namespace AnimatorAsCrab.V1
{
/// <summary>
/// The wire format produced by the Rust core. Keep in sync with rust/src/graph.rs.
/// Property names are snake_cased by the serializer settings, so C# names must only differ
/// from the wire format by casing; enum values are explicit.
/// </summary>
public static class AacCrabJson
{
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
// A graph the generator does not understand is a bug, not something to ignore.
MissingMemberHandling = MissingMemberHandling.Error,
ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy() },
Converters = { new StringEnumConverter() },
};
public static AacCrabGraph Parse(string json)
{
return JsonConvert.DeserializeObject<AacCrabGraph>(json, Settings);
}
}
public sealed class AacCrabGraph
{
public string SystemName { get; set; }
public string AssetKey { get; set; }
public List<AacCrabParameter> Parameters { get; set; }
public List<AacCrabClip> Clips { get; set; }
public List<AacCrabBlendTree> BlendTrees { get; set; }
public AacCrabController Controller { get; set; }
}
public sealed class AacCrabParameter
{
public AacCrabParameterType Type { get; set; }
public string Name { get; set; }
public JToken Default { get; set; }
public float DefaultFloat => Default.Value<float>();
public int DefaultInt => Default.Value<int>();
public bool DefaultBool => Default.Value<bool>();
}
public enum AacCrabParameterType
{
[EnumMember(Value = "float")] Float,
[EnumMember(Value = "int")] Int,
[EnumMember(Value = "bool")] Bool,
}
public sealed class AacCrabClip
{
public string Name { get; set; }
/// <summary>Looping override; null for a store clip that keeps the source asset's setting.</summary>
public bool? Looping { get; set; }
/// <summary>Asset path of a pre-made clip seeded into a store clip; null for generated clips.</summary>
public string Source { get; set; }
public List<AacCrabCurve> Curves { get; set; }
}
public sealed class AacCrabCurve
{
public string Path { get; set; }
public AacCrabTargetType Target { get; set; }
public string Property { get; set; }
public List<AacCrabKeyframe> Keys { get; set; }
}
public enum AacCrabTargetType
{
[EnumMember(Value = "game_object")] GameObject,
[EnumMember(Value = "skinned_mesh_renderer")] SkinnedMeshRenderer,
}
public sealed class AacCrabKeyframe
{
public float Time { get; set; }
public float Value { get; set; }
public float InTangent { get; set; }
public float OutTangent { get; set; }
}
public sealed class AacCrabBlendTree
{
public string Name { get; set; }
public AacCrabBlendType BlendType { get; set; }
public string ParamX { get; set; }
public string ParamY { get; set; }
public List<AacCrabBlendChild> Children { get; set; }
public bool UseAutomaticThresholds { get; set; }
}
public enum AacCrabBlendType
{
[EnumMember(Value = "simple_1d")] Simple1D,
[EnumMember(Value = "simple_directional_2d")] SimpleDirectional2D,
[EnumMember(Value = "freeform_directional_2d")] FreeformDirectional2D,
[EnumMember(Value = "freeform_cartesian_2d")] FreeformCartesian2D,
[EnumMember(Value = "direct")] Direct,
}
public sealed class AacCrabBlendChild
{
public AacCrabMotionRef Motion { get; set; }
public float? Threshold { get; set; }
public float? ThresholdY { get; set; }
public string DirectParam { get; set; }
}
/// <summary>A motion is always a reference: clips and blend trees are declared at the top level.</summary>
public sealed class AacCrabMotionRef
{
public AacCrabMotionType Type { get; set; }
public string Name { get; set; }
}
public enum AacCrabMotionType
{
[EnumMember(Value = "clip")] Clip,
[EnumMember(Value = "blend_tree")] BlendTree,
}
public sealed class AacCrabController
{
public List<AacCrabLayer> Layers { get; set; }
}
public sealed class AacCrabLayer
{
public string Name { get; set; }
public AacCrabStateMachine StateMachine { get; set; }
}
/// <summary>Name is null for the root state machine of a layer.</summary>
public sealed class AacCrabStateMachine
{
public string Name { get; set; }
public AacCrabGridPos Position { get; set; }
public List<AacCrabState> States { get; set; }
public List<AacCrabStateMachine> SubMachines { get; set; }
public List<AacCrabTransition> AnyStateTransitions { get; set; }
}
public sealed class AacCrabState
{
public string Name { get; set; }
public AacCrabGridPos Position { get; set; }
public AacCrabMotionRef Motion { get; set; }
public List<AacCrabTransition> Transitions { get; set; }
}
public sealed class AacCrabTransition
{
/// <summary>The name of the destination state, within the same layer.</summary>
public string To { get; set; }
public List<AacCrabCondition> Conditions { get; set; }
public bool HasExitTime { get; set; }
public float ExitTime { get; set; }
public float Duration { get; set; }
public bool OrderedInterruption { get; set; }
public bool SourceInterruption { get; set; }
public bool CanTransitionToSelf { get; set; }
}
public sealed class AacCrabCondition
{
public string Parameter { get; set; }
public AacCrabCondMode Mode { get; set; }
public float Threshold { get; set; }
}
public enum AacCrabCondMode
{
[EnumMember(Value = "greater")] Greater,
[EnumMember(Value = "less")] Less,
[EnumMember(Value = "equals")] Equals,
[EnumMember(Value = "not_equal")] NotEqual,
[EnumMember(Value = "if")] If,
[EnumMember(Value = "if_not")] IfNot,
}
public struct AacCrabGridPos
{
public int X { get; set; }
public int Y { get; set; }
}
}
@@ -0,0 +1,115 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace AnimatorAsCrab.V1
{
/// <summary>
/// P/Invoke bindings for `libaac` (see rust/src/lib.rs). The native library is expected to live
/// in the project's Assets/Plugins folder; place libaac.so, libaac.dll or libaac.dylib there.
/// </summary>
public static class AacCrabNative
{
private const string Library = "libaac";
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr aac_create();
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
private static extern int aac_eval_rhai(IntPtr context, byte[] script);
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr aac_to_json(IntPtr context);
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr aac_last_error(IntPtr context);
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
private static extern void aac_free_string(IntPtr value);
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
private static extern void aac_destroy(IntPtr context);
/// <summary>
/// Evaluate a Rhai script and return the graph as JSON, or null with a message in
/// <paramref name="error"/>. The context lives only for the duration of this call.
/// </summary>
public static string Evaluate(string script, out string error)
{
IntPtr context;
try
{
context = aac_create();
}
catch (DllNotFoundException exception)
{
error = $"Could not load {Library}: {exception.Message}. Place the native library in Assets/Plugins.";
return null;
}
if (context == IntPtr.Zero)
{
error = "aac_create returned null.";
return null;
}
try
{
var status = aac_eval_rhai(context, Utf8Z(script));
if (status != 0)
{
error = ReadUtf8(aac_last_error(context)) ?? $"The script failed with status {status}.";
return null;
}
var json = aac_to_json(context);
if (json == IntPtr.Zero)
{
error = ReadUtf8(aac_last_error(context)) ?? "The graph could not be serialized.";
return null;
}
try
{
error = null;
return ReadUtf8(json);
}
finally
{
aac_free_string(json);
}
}
finally
{
aac_destroy(context);
}
}
private static byte[] Utf8Z(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var terminated = new byte[bytes.Length + 1];
Array.Copy(bytes, terminated, bytes.Length);
return terminated;
}
private static string ReadUtf8(IntPtr pointer)
{
if (pointer == IntPtr.Zero)
{
return null;
}
// Marshal.PtrToStringUTF8 is not available on all Unity runtimes, so the bytes are copied by hand.
var length = 0;
while (Marshal.ReadByte(pointer, length) != 0)
{
length++;
}
var bytes = new byte[length];
Marshal.Copy(pointer, bytes, 0, length);
return Encoding.UTF8.GetString(bytes);
}
}
}
@@ -0,0 +1,201 @@
using System;
using System.IO;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace AnimatorAsCrab.V1
{
/// <summary>
/// Evaluates a Rhai script with libaac and generates the Animator Controller through Animator As Code.
/// The Unity-side configuration is never guessed: every field below is supplied by the user, and the
/// system name and asset key are declared by the script itself.
/// </summary>
public class AacCrabWindow : EditorWindow
{
[SerializeField] private string _scriptPath = "";
[SerializeField] private bool _generateOnScriptChange = true;
[SerializeField] private AnimatorController _controller;
[SerializeField] private Transform _animatorRoot;
[SerializeField] private UnityEngine.Object _assetContainer;
[SerializeField] private AacConfiguration.Container _containerMode = AacConfiguration.Container.Everything;
[SerializeField] private bool _writeDefaults;
[SerializeField] private long _lastWriteUtcTicks;
private string _error;
private string _status;
private bool _scriptChanged;
[MenuItem("Tools/Animator As Crab")]
public static void Open()
{
var window = GetWindow<AacCrabWindow>();
window.titleContent = new GUIContent("Animator As Crab");
window.Show();
}
private void OnEnable()
{
EditorApplication.update += OnUpdate;
_scriptChanged = _lastWriteUtcTicks != 0 && _lastWriteUtcTicks != LastWriteUtcTicks();
_lastWriteUtcTicks = LastWriteUtcTicks();
}
private void OnDisable()
{
EditorApplication.update -= OnUpdate;
}
private void OnUpdate()
{
if (string.IsNullOrEmpty(_scriptPath) || !File.Exists(_scriptPath))
{
return;
}
var ticks = LastWriteUtcTicks();
if (ticks == _lastWriteUtcTicks)
{
return;
}
_lastWriteUtcTicks = ticks;
_scriptChanged = true;
if (_generateOnScriptChange)
{
Generate();
}
else
{
Repaint();
}
}
private void OnGUI()
{
EditorGUILayout.LabelField("Rhai script", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
_scriptPath = EditorGUILayout.TextField(_scriptPath);
if (GUILayout.Button("...", GUILayout.Width(28)))
{
var directory = string.IsNullOrEmpty(_scriptPath) ? null : Path.GetDirectoryName(_scriptPath);
var picked = EditorUtility.OpenFilePanel("Rhai script", directory ?? string.Empty, "rhai");
if (!string.IsNullOrEmpty(picked))
{
_scriptPath = picked;
_lastWriteUtcTicks = LastWriteUtcTicks();
_scriptChanged = false;
Generate();
}
}
EditorGUILayout.EndHorizontal();
_generateOnScriptChange = EditorGUILayout.Toggle("Generate when the script changes", _generateOnScriptChange);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Unity side", EditorStyles.boldLabel);
_controller = (AnimatorController)EditorGUILayout.ObjectField("Animator Controller", _controller, typeof(AnimatorController), false);
_animatorRoot = (Transform)EditorGUILayout.ObjectField("Animator Root", _animatorRoot, typeof(Transform), true);
_assetContainer = EditorGUILayout.ObjectField("Asset Container", _assetContainer, typeof(UnityEngine.Object), false);
_containerMode = (AacConfiguration.Container)EditorGUILayout.EnumPopup("Container Mode", _containerMode);
_writeDefaults = EditorGUILayout.Toggle("Write Defaults", _writeDefaults);
EditorGUILayout.Space();
if (GUILayout.Button("Generate", GUILayout.Height(24)))
{
Generate();
}
EditorGUILayout.Space();
if (_error != null)
{
EditorGUILayout.HelpBox(_error, MessageType.Error);
}
else if (_scriptChanged)
{
EditorGUILayout.HelpBox("The script changed since the last generation.", MessageType.Warning);
}
if (_status != null)
{
EditorGUILayout.HelpBox(_status, MessageType.Info);
}
}
private void Generate()
{
_error = null;
_status = null;
_scriptChanged = false;
try
{
_status = GenerateOrThrow();
}
catch (Exception exception)
{
_error = exception.Message;
Debug.LogException(exception);
}
Repaint();
}
private string GenerateOrThrow()
{
if (string.IsNullOrEmpty(_scriptPath))
{
throw new InvalidOperationException("Select a Rhai script.");
}
if (!File.Exists(_scriptPath))
{
throw new InvalidOperationException($"'{_scriptPath}' does not exist.");
}
if (_controller == null)
{
throw new InvalidOperationException("Select the Animator Controller to generate into.");
}
if (_animatorRoot == null)
{
throw new InvalidOperationException("Select the animator root Transform.");
}
if (_assetContainer == null)
{
throw new InvalidOperationException("Select the asset container that will hold the generated clips and blend trees.");
}
var json = AacCrabNative.Evaluate(File.ReadAllText(_scriptPath), out var nativeError);
if (json == null)
{
throw new InvalidOperationException(nativeError);
}
var graph = AacCrabJson.Parse(json);
var configuration = new AacConfiguration
{
SystemName = graph.SystemName,
AssetKey = graph.AssetKey,
AnimatorRoot = _animatorRoot,
AssetContainer = _assetContainer,
ContainerMode = _containerMode,
DefaultsProvider = new AacDefaultsProvider(_writeDefaults),
};
AacCrabGenerator.Generate(graph, configuration, _controller);
AssetDatabase.SaveAssets();
return $"Generated '{graph.SystemName}' with asset key '{graph.AssetKey}': " +
$"{graph.Controller.Layers.Count} layer(s), {graph.Clips.Count} clip(s), {graph.BlendTrees.Count} blend tree(s).";
}
private long LastWriteUtcTicks()
{
return string.IsNullOrEmpty(_scriptPath) || !File.Exists(_scriptPath)
? 0
: File.GetLastWriteTimeUtc(_scriptPath).Ticks;
}
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c04d39cdc2e2392a5b51bfa466760178
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,15 @@
{
"name": "dev.doloro.animator-as-crab",
"displayName": "Animator As Crab",
"version": "0.1.0",
"unity": "2019.4",
"description": "Writes Animator Controllers from a Rhai script, evaluated by the libaac native library.",
"vrchatVersion" : "2022.1.1",
"dependencies": {
"com.unity.nuget.newtonsoft-json": "3.2.1"
},
"author" : {
"name" : "doloro"
},
"license": "MIT"
}
Generated
+64
View File
@@ -0,0 +1,64 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1788465171,
"narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=",
"owner": "ipetkov",
"repo": "crane",
"rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1789546076,
"narHash": "sha256-zVxLZiSnmaaPLwnhj7pwmqe3axBg/C6nG5JZsJMh2g4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b1b875982b17dabde9b4a37f3e229e74913e6db3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1789715798,
"narHash": "sha256-hWw9vlrsFm9kOw8MT5k6IX/7xCzgROuJ6ZvQOrkq66Q=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "fbdb2de9e7619d660ae7e8f752f38b261020b701",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+77
View File
@@ -0,0 +1,77 @@
{
description = "animator-as-crab: Animator Controllers described in Rhai, generated from Rust into Unity";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
nixpkgs,
crane,
rust-overlay,
}:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
perSystem =
system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
};
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [
"rust-src"
"rust-analyzer"
"clippy"
"rustfmt"
];
};
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
# The Rust core: evaluates the Rhai DSL and emits the graph as JSON for Unity.
aac = craneLib.buildPackage {
src = ./rust;
strictDeps = true;
doCheck = true;
};
in
{
packages = {
inherit aac;
default = aac;
};
devShells.default = craneLib.devShell {
inputsFrom = [ aac ];
packages = [
rustToolchain
pkgs.dotnet-sdk
pkgs.mono
pkgs.jq
];
};
formatter = pkgs.nixfmt-rfc-style;
};
in
{
packages = forAllSystems (system: (perSystem system).packages);
devShells = forAllSystems (system: (perSystem system).devShells);
formatter = forAllSystems (system: (perSystem system).formatter);
};
}
+33
View File
@@ -0,0 +1,33 @@
Animator As Code V1
====
**Animator As Code** is a small Unity Editor facility to generate Animator layers and animations from a [fluent builder](https://en.wikipedia.org/wiki/Fluent_interface) syntax written in C#.
Describing your animators as code provides the following advantages:
- you do not need to edit your animations by hand every time you add remove or change the location of a component in your hierarchy
- you will not need to edit a hundred transitions by hand if you need to rectify your animator
Initially designed for use in VRChat to create Avatars 3.0 animator controllers, this is now a generic package that does not require it.
https://user-images.githubusercontent.com/60819407/157751278-475538c7-3310-4fa5-9a87-3651c85eaa1c.mp4
Initially created by **[@hai-vr](https://github.com/hai-vr)**,
- with major contributions from **[@galister](https://github.com/galister)** for supporting sub-state machines and the generic use of nodes and parameters.
## Installation
Instructions to install are available in [this page](https://docs.hai-vr.dev/docs/products/animator-as-code/install).
### Documentation
- [Documentation](https://docs.hai-vr.dev/docs/products/animator-as-code)
- [V1 Reference manual](https://docs.hai-vr.dev/docs/products/animator-as-code/reference)
- [V1 Functions reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/base)
- [VRChat reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/vrchat)
- [VRChat (Destructive Workflow) reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/vrchat-destructive-workflow)
- [Modular Avatar As Code reference](https://docs.hai-vr.dev/docs/products/animator-as-code/functions/modular-avatar)
## Animator As Code V0
- You can browse the repository in its **[V0](https://github.com/hai-vr/av3-animator-as-code/tree/V1-2022-04) state**.
+1
View File
@@ -0,0 +1 @@
target/
+463
View File
@@ -0,0 +1,463 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aac"
version = "0.1.0"
dependencies = [
"rhai",
"serde",
"serde_json",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"const-random",
"getrandom 0.3.4",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"portable-atomic",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rhai"
version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0334639972c0ea5a3fd366aa36116754a11431b619fec3ed559b3f73bcbcebf5"
dependencies = [
"ahash",
"bitflags",
"num-traits",
"once_cell",
"rhai_codegen",
"smallvec",
"smartstring",
"thin-vec",
"web-time",
]
[[package]]
name = "rhai_codegen"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"static_assertions",
"version_check",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thin-vec"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748"
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.6",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "aac"
version = "0.1.0"
edition = "2021"
[lib]
name = "aac"
crate-type = ["cdylib", "rlib"]
[dependencies]
rhai = "1.26"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+115
View File
@@ -0,0 +1,115 @@
// Pre-made animation clips: a store seeds a clip from an `.anim` asset in the project.
//
// cargo run --bin aac-dump -- rust/examples/animation_store.rhai
//
// A store clip is a starting point. Requesting `Assets/.../<name>.anim` produces a clip that is
// usable anywhere a generated clip is; anything written in the script (looping, keyframes, toggles,
// blend shapes) is applied to a clone, so the pre-made asset itself is never modified. A clip the
// script does not touch keeps the asset's own settings.
//
// Combine conditions with `when_all([...])`: Rhai's `&&` cannot be overloaded.
let aac = AnimatorAsCode();
aac.system_name("AnimationStore");
aac.asset_key("AAC_STORE_");
let speed = aac.float_param("Speed", 0.0);
let vertical = aac.float_param("Vertical", 0.0);
let grip = aac.float_param("Grip", 0.0);
let crouching = aac.bool_param("Crouching", false);
let gesture = aac.int_param("Gesture", 0);
let ctrl = aac.new_controller();
let locomotion_layer = ctrl.layer("Locomotion");
let props_layer = ctrl.layer("Props");
// A store per folder. The name is joined as written, so it may contain subfolders, and the `.anim`
// extension is optional.
let locomotion = aac.AnimationStore("Assets/Doloro/Clips/Locomotion");
let props = aac.AnimationStore("Assets/Doloro/Clips/Props/");
let directions = aac.AnimationStore("Assets/Doloro/Clips/Directions");
let idle_clip = locomotion.clip("Idle");
let walk_clip = locomotion.clip("Walk");
let run_clip = locomotion.clip("Run");
// Requesting the same asset again -- with the extension this time -- returns the same clip, so it is
// only declared once and only one clone is generated.
let walk_again = locomotion.clip("Walk.anim");
// Untouched: `Idle` is used as authored, with no looping override and no curves added here.
let idle = locomotion_layer.state("Idle", 0, 0);
idle.set_clip(idle_clip);
// Edited: `Walk` loops and toggles a prop on top of whatever the asset already animates.
walk_clip.looping(true);
walk_clip.toggle("Body/Props/Umbrella", true);
let walk = locomotion_layer.state("Walk", 1, 0);
walk.set_clip(walk_clip);
// Edited: `Run` forces non-looping and blends a smile in over one second.
run_clip.looping(false);
run_clip.blend_shape("Body", "Smile", 0.0, 0.0);
run_clip.blend_shape("Body", "Smile", 1.0, 1.0);
let run = locomotion_layer.state("Run", 2, 0);
run.set_clip(run_clip);
// --- Blend trees over store clips
// A 1D tree over three store clips. `idle_clip` is shared with the `Idle` state above.
let movement = aac.blend_tree("movement");
movement.simple_1d(speed);
movement.add_motion(idle_clip, 0.0);
movement.add_motion(walk_clip, 2.0);
movement.add_motion(run_clip, 5.0);
// A 2D tree over four directional assets.
let forward = directions.clip("Forward");
let back = directions.clip("Back");
let left = directions.clip("Left");
let right = directions.clip("Right");
let strafe = aac.blend_tree("strafe");
strafe.freeform_directional_2d(speed, vertical);
strafe.add_motion(forward, 0.0, 1.0);
strafe.add_motion(back, 0.0, -1.0);
strafe.add_motion(left, -1.0, 0.0);
strafe.add_motion(right, 1.0, 0.0);
// A tree may nest trees declared before it, so the two above blend into one full-body motion.
let full_body = aac.blend_tree("full_body");
full_body.simple_1d(speed);
full_body.add_motion(movement, 0.0);
full_body.add_motion(strafe, 5.0);
let move = locomotion_layer.state("Move", 3, 0);
move.set_motion(full_body);
// A direct tree addresses a store clip by parameter, here a clip nested in a store subfolder.
let umbrella = props.clip("Weapons/Umbrella");
umbrella.looping(true);
let hold = aac.blend_tree("hold");
hold.direct();
hold.add_motion_direct(umbrella, grip);
let holding = props_layer.state("Hold", 0, 0);
holding.set_motion(hold);
// --- Transitions, any-state, and a sub-state machine
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.2);
walk.transition_to(run).when(speed > 4.0).no_exit_time().duration(0.2);
run.transition_to(walk).when(speed < 4.0).no_exit_time().duration(0.2);
walk.transition_to(idle).when_all([speed < 0.05, crouching == false]).duration(0.2);
let sit_clip = locomotion.clip("Sit");
let sit = locomotion_layer.state("Sit", 4, 0);
sit.set_clip(sit_clip);
locomotion_layer.any_state().transition_to(sit).when(crouching == true).no_exit_time().duration(0.15);
let gestures = locomotion_layer.sub_machine("Gestures", 5, 0);
let wave = gestures.state("Wave", 0, 0);
let point = gestures.state("Point", 1, 0);
wave.set_clip(props.clip("Gestures/Wave"));
point.set_clip(props.clip("Gestures/Point"));
wave.transition_to(point).when(gesture == 1).duration(0.1);
point.transition_to(wave).when(gesture == 0).duration(0.1);
+66
View File
@@ -0,0 +1,66 @@
// An Animator Controller described as Rhai.
//
// cargo run --bin aac-dump -- rust/examples/avatar.rhai
//
// Combine conditions with `when_all([...])`: Rhai's `&&` cannot be overloaded, so
// `when(a > 0.1 && b)` is not valid.
let aac = AnimatorAsCode();
aac.system_name("MyAvatar");
aac.asset_key("AAC_");
let speed = aac.float_param("Speed", 0.0);
let is_sitting = aac.bool_param("IsSitting", false);
let gesture = aac.int_param("Gesture", 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 run = base.state("Run", 2, 0);
let sit = base.state("Sit", 0, 1);
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);
// A pre-made clip: it is cloned into the container at generation time, so it can be edited here
// without touching the asset stored in Assets/Doloro/Clips.
let store = aac.AnimationStore("Assets/Doloro/Clips");
let crouch_clip = store.clip("Crouch");
crouch_clip.looping(true);
crouch_clip.toggle("Body/Props", true);
sit.set_clip(crouch_clip);
idle.set_clip(idle_clip);
walk.set_clip(walk_clip);
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);
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);
base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2);
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);
+26
View File
@@ -0,0 +1,26 @@
//! Evaluate a `.rhai` script and print the generated JSON.
//!
//! ```text
//! cargo run --bin aac-dump -- rust/examples/avatar.rhai
//! ```
fn main() {
let Some(path) = std::env::args().nth(1) else {
eprintln!("usage: aac-dump <script.rhai>");
std::process::exit(2);
};
let script = match std::fs::read_to_string(&path) {
Ok(script) => script,
Err(error) => {
eprintln!("cannot read {path}: {error}");
std::process::exit(2);
}
};
match aac::evaluate_to_json(&script) {
Ok(json) => println!("{json}"),
Err(error) => {
eprintln!("{error}");
std::process::exit(1);
}
}
}
+279
View File
@@ -0,0 +1,279 @@
use crate::graph::*;
use std::sync::{Arc, Mutex, MutexGuard};
/// Shared, thread-safe handle to the graph being built. Every builder carries a clone.
#[derive(Clone)]
pub struct Aac {
pub(crate) graph: Arc<Mutex<ControllerGraph>>,
}
impl Default for Aac {
fn default() -> Self {
Self::new()
}
}
impl Aac {
pub fn new() -> Self {
Self {
graph: Arc::new(Mutex::new(ControllerGraph::default())),
}
}
/// A poisoned lock still holds a usable graph; recovering avoids panicking across the FFI boundary.
fn lock(&self) -> MutexGuard<'_, ControllerGraph> {
self.graph.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn write<R>(&self, f: impl FnOnce(&mut ControllerGraph) -> R) -> R {
f(&mut self.lock())
}
}
/// Parameter handles only carry the name; conditions read nothing else from them.
#[derive(Clone)]
pub struct FloatParam {
pub(crate) name: String,
}
#[derive(Clone)]
pub struct IntParam {
pub(crate) name: String,
}
#[derive(Clone)]
pub struct BoolParam {
pub(crate) name: String,
}
#[derive(Clone)]
pub struct ControllerBuilder {
pub(crate) aac: Aac,
}
#[derive(Clone)]
pub struct LayerBuilder {
pub(crate) aac: Aac,
pub(crate) layer: usize,
}
/// A state machine. `machine` is the chain of sub-machine indices from the layer root, so the empty
/// chain is the layer's root machine and indices stay stable because machines are only ever appended.
#[derive(Clone)]
pub struct MachineRef {
pub(crate) aac: Aac,
pub(crate) layer: usize,
pub(crate) machine: Vec<usize>,
}
#[derive(Clone)]
pub struct StateRef {
pub(crate) aac: Aac,
pub(crate) layer: usize,
pub(crate) machine: Vec<usize>,
pub(crate) state: usize,
}
#[derive(Clone)]
pub struct AnyStateRef {
pub(crate) aac: Aac,
pub(crate) layer: usize,
pub(crate) machine: Vec<usize>,
}
#[derive(Clone)]
pub struct ClipBuilder {
pub(crate) aac: Aac,
pub(crate) name: String,
}
/// A folder of pre-made `AnimationClip` assets, resolved on the Unity side at generation time.
#[derive(Clone)]
pub struct AnimationStore {
pub(crate) aac: Aac,
pub(crate) folder: String,
}
#[derive(Clone)]
pub struct BlendTreeBuilder {
pub(crate) aac: Aac,
pub(crate) name: String,
}
/// `source` is the originating state, or `None` for an any-state transition.
#[derive(Clone)]
pub struct TransitionRef {
pub(crate) aac: Aac,
pub(crate) layer: usize,
pub(crate) machine: Vec<usize>,
pub(crate) source: Option<usize>,
pub(crate) index: usize,
}
pub(crate) fn machine_mut<'a>(
graph: &'a mut ControllerGraph,
layer: usize,
path: &[usize],
) -> &'a mut StateMachine {
let mut machine = &mut graph.controller.layers[layer].state_machine;
for &index in path {
machine = &mut machine.sub_machines[index];
}
machine
}
pub(crate) fn clip_mut<'a>(graph: &'a mut ControllerGraph, name: &str) -> Option<&'a mut ClipData> {
graph.clips.iter_mut().find(|clip| clip.name == name)
}
pub(crate) fn blend_tree_mut<'a>(
graph: &'a mut ControllerGraph,
name: &str,
) -> Option<&'a mut BlendTreeData> {
graph.blend_trees.iter_mut().find(|tree| tree.name == name)
}
impl StateRef {
pub fn state_name(&self) -> String {
self.aac
.write(|graph| machine_mut(graph, self.layer, &self.machine).states[self.state].name.clone())
}
pub fn set_motion(&self, motion: MotionRef) {
self.aac.write(|graph| {
machine_mut(graph, self.layer, &self.machine).states[self.state].motion = Some(motion);
});
}
pub fn transition_to(&self, target: &StateRef) -> TransitionRef {
let to = target.state_name();
let index = self.aac.write(|graph| {
let state = &mut machine_mut(graph, self.layer, &self.machine).states[self.state];
state.transitions.push(Transition::new(to));
state.transitions.len() - 1
});
TransitionRef {
aac: self.aac.clone(),
layer: self.layer,
machine: self.machine.clone(),
source: Some(self.state),
index,
}
}
}
impl AnyStateRef {
pub fn transition_to(&self, target: &StateRef) -> TransitionRef {
let to = target.state_name();
let index = self.aac.write(|graph| {
let machine = machine_mut(graph, self.layer, &self.machine);
machine.any_state_transitions.push(Transition::new(to));
machine.any_state_transitions.len() - 1
});
TransitionRef {
aac: self.aac.clone(),
layer: self.layer,
machine: self.machine.clone(),
source: None,
index,
}
}
}
impl TransitionRef {
/// Apply a mutation to the transition this handle points at.
pub fn update(&self, f: impl FnOnce(&mut Transition)) {
self.aac.write(|graph| {
let machine = machine_mut(graph, self.layer, &self.machine);
let transitions = match self.source {
Some(state) => &mut machine.states[state].transitions,
None => &mut machine.any_state_transitions,
};
f(&mut transitions[self.index]);
});
}
pub fn add_conditions(&self, conditions: impl IntoIterator<Item = Condition>) {
self.update(|transition| transition.conditions.extend(conditions));
}
}
impl ClipBuilder {
pub fn data_mut<R>(&self, f: impl FnOnce(&mut ClipData) -> R) -> Option<R> {
let name = self.name.clone();
self.aac.write(|graph| clip_mut(graph, &name).map(f))
}
/// Override the clip's looping setting. A store clip with no override keeps the source asset's own setting.
pub fn set_looping(&self, value: bool) -> Result<(), String> {
self.data_mut(|data| data.looping = Some(value));
Ok(())
}
/// Append one keyframe to the curve for (path, property), creating the curve if needed.
pub fn push_key(&self, path: &str, property: &str, key: Keyframe) -> Result<(), String> {
let target = infer_target(property).ok_or_else(|| {
format!(
"cannot tell which component `{property}` belongs to; \
known properties are `m_IsActive` and `blendShape.*`"
)
})?;
self.data_mut(|clip| {
clip.curve_mut(path, target, property).keys.push(key);
})
.ok_or_else(|| format!("unknown clip `{}`", self.name))
}
}
impl AnimationStore {
/// Resolve `folder/<name>.anim` and declare it as a clip the first time it is requested. The clip
/// name is the asset path, so the same asset seen through different names is still one clip.
pub fn clip(&self, name: &str) -> Result<ClipBuilder, String> {
if name.trim().is_empty() {
return Err("animation store clip name is empty".to_string());
}
let mut path = format!(
"{}/{}",
self.folder.trim_end_matches('/'),
name.trim_start_matches('/')
);
if !path.ends_with(".anim") {
path.push_str(".anim");
}
self.aac.write(|graph| {
if !graph.clips.iter().any(|clip| clip.source.as_deref() == Some(path.as_str())) {
graph.clips.push(ClipData {
name: path.clone(),
looping: None,
curves: Vec::new(),
source: Some(path.clone()),
});
}
});
Ok(ClipBuilder {
aac: self.aac.clone(),
name: path,
})
}
}
impl BlendTreeBuilder {
pub fn data_mut<R>(&self, f: impl FnOnce(&mut BlendTreeData) -> R) -> Option<R> {
let name = self.name.clone();
self.aac.write(|graph| blend_tree_mut(graph, &name).map(f))
}
pub fn configure(&self, blend_type: BlendType, param_x: &str, param_y: Option<&str>) -> Result<(), String> {
self.data_mut(|tree| {
tree.blend_type = blend_type;
tree.param_x = param_x.to_string();
tree.param_y = param_y.map(str::to_string);
})
.ok_or_else(|| format!("unknown blend tree `{}`", self.name))
}
pub fn push_child(&self, child: BlendChild) -> Result<(), String> {
self.data_mut(|tree| tree.children.push(child))
.ok_or_else(|| format!("unknown blend tree `{}`", self.name))
}
}
+325
View File
@@ -0,0 +1,325 @@
use crate::graph::*;
use std::collections::{HashMap, HashSet};
/// Sort keyframes into time order, then validate, then serialize.
pub fn to_json(graph: &ControllerGraph) -> Result<String, String> {
let mut graph = graph.clone();
normalize(&mut graph);
validate(&graph)?;
serde_json::to_string_pretty(&graph).map_err(|e| format!("serialization failed: {e}"))
}
fn normalize(graph: &mut ControllerGraph) {
for clip in &mut graph.clips {
for curve in &mut clip.curves {
curve
.keys
.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap_or(std::cmp::Ordering::Equal));
}
}
}
fn validate(graph: &ControllerGraph) -> Result<(), String> {
let mut errors = Vec::new();
if graph.system_name.trim().is_empty() {
errors.push("system_name is not set (call `aac.system_name(\"...\")`)".to_string());
}
if graph.asset_key.trim().is_empty() {
errors.push("asset_key is not set (call `aac.asset_key(\"...\")`)".to_string());
}
let parameters = parameter_types(graph, &mut errors);
validate_clips(graph, &mut errors);
validate_blend_trees(graph, &parameters, &mut errors);
validate_layers(graph, &parameters, &mut errors);
if errors.is_empty() {
Ok(())
} else {
Err(format!(
"graph is invalid ({} problem{}):\n- {}",
errors.len(),
if errors.len() == 1 { "" } else { "s" },
errors.join("\n- ")
))
}
}
#[derive(Clone, Copy, PartialEq)]
enum ParamType {
Float,
Int,
Bool,
}
fn parameter_types(graph: &ControllerGraph, errors: &mut Vec<String>) -> HashMap<String, ParamType> {
let mut seen = HashSet::new();
let mut map = HashMap::new();
for parameter in &graph.parameters {
let name = parameter.name();
if name.trim().is_empty() {
errors.push("a parameter has an empty name".to_string());
continue;
}
if !seen.insert(name.to_string()) {
errors.push(format!("parameter `{name}` is declared more than once"));
}
let ty = match parameter {
Parameter::Float { .. } => ParamType::Float,
Parameter::Int { .. } => ParamType::Int,
Parameter::Bool { .. } => ParamType::Bool,
};
map.insert(name.to_string(), ty);
}
map
}
fn validate_clips(graph: &ControllerGraph, errors: &mut Vec<String>) {
let mut seen = HashSet::new();
for clip in &graph.clips {
if clip.name.trim().is_empty() {
errors.push("a clip has an empty name".to_string());
} else if !seen.insert(clip.name.clone()) {
errors.push(format!("clip `{}` is declared more than once", clip.name));
}
for curve in &clip.curves {
if curve.path.trim().is_empty() {
errors.push(format!("clip `{}` has a curve with an empty path", clip.name));
}
if curve.keys.is_empty() {
errors.push(format!(
"clip `{}` curve `{}` on `{}` has no keyframes",
clip.name, curve.property, curve.path
));
}
for key in &curve.keys {
if !key.time.is_finite() || !key.value.is_finite() {
errors.push(format!(
"clip `{}` curve `{}` on `{}` has a non-finite keyframe",
clip.name, curve.property, curve.path
));
}
}
}
}
}
fn validate_blend_trees(
graph: &ControllerGraph,
parameters: &HashMap<String, ParamType>,
errors: &mut Vec<String>,
) {
let mut seen = HashSet::new();
for tree in &graph.blend_trees {
if tree.name.trim().is_empty() {
errors.push("a blend tree has an empty name".to_string());
} else if !seen.insert(tree.name.clone()) {
errors.push(format!("blend tree `{}` is declared more than once", tree.name));
}
let has_y = tree.param_y.as_ref().is_some_and(|y| !y.trim().is_empty());
if tree.blend_type == BlendType::Direct {
if !tree.param_x.trim().is_empty() || tree.param_y.is_some() {
errors.push(format!(
"blend tree `{}` is Direct and must not have blend parameters",
tree.name
));
}
} else {
if tree.param_x.trim().is_empty() {
errors.push(format!("blend tree `{}` has no x parameter", tree.name));
} else {
expect_param(parameters, &tree.param_x, ParamType::Float, &tree.name, "x", errors);
}
if tree.blend_type.is_2d() && !has_y {
errors.push(format!(
"blend tree `{}` is 2D and needs a y parameter",
tree.name
));
}
if !tree.blend_type.is_2d() && has_y {
errors.push(format!(
"blend tree `{}` is {:?} and must not have a y parameter",
tree.name, tree.blend_type
));
}
if tree.blend_type.is_2d() {
if let Some(y) = &tree.param_y {
expect_param(parameters, y, ParamType::Float, &tree.name, "y", errors);
}
}
}
if tree.children.is_empty() {
errors.push(format!("blend tree `{}` has no children", tree.name));
}
for child in &tree.children {
expect_motion(&child.motion, graph, errors);
if tree.blend_type == BlendType::Direct {
match &child.direct_param {
None => errors.push(format!(
"blend tree `{}` is Direct and a child has no direct parameter",
tree.name
)),
Some(p) => expect_param(parameters, p, ParamType::Float, &tree.name, "direct", errors),
}
} else if child.direct_param.is_some() {
errors.push(format!(
"blend tree `{}` is {:?} and thresholds are used, so a child must not have a direct parameter",
tree.name, tree.blend_type
));
}
}
}
}
fn validate_layers(
graph: &ControllerGraph,
parameters: &HashMap<String, ParamType>,
errors: &mut Vec<String>,
) {
let mut layer_names = HashSet::new();
for layer in &graph.controller.layers {
let label = if layer.name.trim().is_empty() {
errors.push("a layer has an empty name".to_string());
"<unnamed>".to_string()
} else {
if !layer_names.insert(layer.name.clone()) {
errors.push(format!("layer `{}` is declared more than once", layer.name));
}
layer.name.clone()
};
// Transitions cannot cross layers, so resolution is per-layer.
let mut machines = Vec::new();
walk_machines(&layer.state_machine, &label, &mut machines);
let mut state_names = HashSet::new();
for (machine_label, machine) in &machines {
for state in &machine.states {
if state.name.trim().is_empty() {
errors.push(format!("machine `{machine_label}` has a state with an empty name"));
} else if !state_names.insert(state.name.clone()) {
errors.push(format!(
"state `{}` is declared more than once in layer `{label}`",
state.name
));
}
}
}
for (machine_label, machine) in &machines {
for state in &machine.states {
if let Some(motion) = &state.motion {
expect_motion(motion, graph, errors);
}
for transition in &state.transitions {
validate_transition(
transition,
&format!("state `{}`", state.name),
&state_names,
parameters,
errors,
);
}
}
for transition in &machine.any_state_transitions {
validate_transition(
transition,
&format!("machine `{machine_label}` any-state"),
&state_names,
parameters,
errors,
);
}
}
}
}
/// State names are collected in a first pass so that forward references resolve.
fn walk_machines<'a>(machine: &'a StateMachine, label: &str, out: &mut Vec<(String, &'a StateMachine)>) {
out.push((label.to_string(), machine));
for sub in &machine.sub_machines {
let name = sub.name.as_deref().unwrap_or("<unnamed>");
walk_machines(sub, &format!("{label}/{name}"), out);
}
}
fn validate_transition(
transition: &Transition,
origin: &str,
state_names: &HashSet<String>,
parameters: &HashMap<String, ParamType>,
errors: &mut Vec<String>,
) {
if !state_names.contains(&transition.to) {
errors.push(format!(
"{origin} transitions to `{}`, which is not a state in that layer",
transition.to
));
}
if !transition.duration.is_finite() || !transition.exit_time.is_finite() {
errors.push(format!("{origin} has a non-finite duration or exit time"));
}
if !transition.conditions.is_empty() && transition.has_exit_time {
errors.push(format!(
"{origin} has both conditions and an exit time; Unity will ignore the conditions"
));
}
for condition in &transition.conditions {
let Some(ty) = parameters.get(&condition.parameter) else {
errors.push(format!(
"{origin} uses parameter `{}`, which is not declared",
condition.parameter
));
continue;
};
if !condition.threshold.is_finite() {
errors.push(format!("{origin} has a non-finite condition threshold"));
}
let ok = match condition.mode {
CondMode::If | CondMode::IfNot => *ty == ParamType::Bool,
CondMode::Greater | CondMode::Less => *ty != ParamType::Bool,
CondMode::Equals | CondMode::NotEqual => true,
};
if !ok {
errors.push(format!(
"{origin} uses {:?} on `{}`, but that parameter cannot be compared that way",
condition.mode, condition.parameter
));
}
}
}
fn expect_motion(motion: &MotionRef, graph: &ControllerGraph, errors: &mut Vec<String>) {
let (kind, name, exists) = match motion {
MotionRef::Clip { name } => ("clip", name, graph.clips.iter().any(|c| &c.name == name)),
MotionRef::BlendTree { name } => (
"blend tree",
name,
graph.blend_trees.iter().any(|t| &t.name == name),
),
};
if !exists {
errors.push(format!("reference to {kind} `{name}`, which is not declared"));
}
}
fn expect_param(
parameters: &HashMap<String, ParamType>,
name: &str,
expected: ParamType,
owner: &str,
role: &str,
errors: &mut Vec<String>,
) {
match parameters.get(name) {
None => errors.push(format!(
"blend tree `{owner}` uses `{name}` as its {role} parameter, which is not declared"
)),
Some(actual) if *actual != expected => errors.push(format!(
"blend tree `{owner}` uses `{name}` as its {role} parameter, which is not a float parameter"
)),
Some(_) => {}
}
}
+269
View File
@@ -0,0 +1,269 @@
use serde::{Deserialize, Serialize};
/// The complete description of one animator controller, produced entirely by a Rhai script.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ControllerGraph {
pub system_name: String,
pub asset_key: String,
pub parameters: Vec<Parameter>,
pub clips: Vec<ClipData>,
pub blend_trees: Vec<BlendTreeData>,
pub controller: Controller,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Controller {
pub layers: Vec<Layer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Layer {
/// The layer suffix as written in the script. Unity's layer name is derived from it by the C# side.
pub name: String,
pub state_machine: StateMachine,
}
/// Grid position in Unity's animator graph. Serialized as an object so C# can bind it directly.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct GridPos {
pub x: i32,
pub y: i32,
}
impl GridPos {
pub fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct StateMachine {
/// `None` for a layer's root machine, `Some` for a sub-state machine.
pub name: Option<String>,
pub position: GridPos,
pub states: Vec<State>,
pub sub_machines: Vec<StateMachine>,
pub any_state_transitions: Vec<Transition>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct State {
pub name: String,
pub position: GridPos,
pub motion: Option<MotionRef>,
pub transitions: Vec<Transition>,
}
/// References to entries in `ControllerGraph::clips` / `ControllerGraph::blend_trees`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MotionRef {
Clip { name: String },
BlendTree { name: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Transition {
pub to: String,
pub conditions: Vec<Condition>,
pub has_exit_time: bool,
pub exit_time: f32,
pub duration: f32,
pub ordered_interruption: bool,
pub source_interruption: bool,
pub can_transition_to_self: bool,
}
impl Transition {
/// Mirrors `AacDefaultsProvider.ConfigureTransition`, so unspecified fields match library defaults.
pub fn new(to: impl Into<String>) -> Self {
Self {
to: to.into(),
conditions: Vec::new(),
has_exit_time: false,
exit_time: 0.0,
duration: 0.0,
ordered_interruption: true,
source_interruption: false,
can_transition_to_self: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Condition {
pub parameter: String,
pub mode: CondMode,
pub threshold: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CondMode {
Greater,
Less,
Equals,
NotEqual,
If,
IfNot,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClipData {
pub name: String,
/// `None` for a store clip that leaves the source asset's looping setting alone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub looping: Option<bool>,
pub curves: Vec<Curve>,
/// Set when the clip is seeded from an asset (see `AnimationStore`): the project-relative asset
/// path. Omitted for generated clips so their JSON stays unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Curve {
pub path: String,
pub target: TargetType,
pub property: String,
pub keys: Vec<Keyframe>,
}
/// The Unity component a curve binds to. Closed set: an unknown property is an error, not a guess.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetType {
GameObject,
SkinnedMeshRenderer,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Keyframe {
pub time: f32,
pub value: f32,
pub in_tangent: f32,
pub out_tangent: f32,
}
impl Keyframe {
pub fn linear(time: f32, value: f32) -> Self {
Self {
time,
value,
in_tangent: 0.0,
out_tangent: 0.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlendTreeData {
pub name: String,
pub blend_type: BlendType,
pub param_x: String,
pub param_y: Option<String>,
pub children: Vec<BlendChild>,
pub use_automatic_thresholds: bool,
}
/// `rename_all = "snake_case"` would turn `SimpleDirectional2D` into `simple_directional2_d`,
/// so every variant is renamed explicitly.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum BlendType {
#[serde(rename = "simple_1d")]
Simple1D,
#[serde(rename = "simple_directional_2d")]
SimpleDirectional2D,
#[serde(rename = "freeform_directional_2d")]
FreeformDirectional2D,
#[serde(rename = "freeform_cartesian_2d")]
FreeformCartesian2D,
#[serde(rename = "direct")]
Direct,
}
impl BlendType {
pub fn is_2d(self) -> bool {
matches!(
self,
BlendType::SimpleDirectional2D
| BlendType::FreeformDirectional2D
| BlendType::FreeformCartesian2D
)
}
/// Blend trees that place children by threshold rather than by a direct blend parameter.
pub fn uses_thresholds(self) -> bool {
self != BlendType::Direct
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BlendChild {
pub motion: MotionRef,
pub threshold: f32,
pub threshold_y: Option<f32>,
/// Only meaningful on `Direct` blend trees.
pub direct_param: Option<String>,
}
impl BlendChild {
pub fn motion(motion: MotionRef) -> Self {
Self {
motion,
threshold: 0.0,
threshold_y: None,
direct_param: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Parameter {
Float { name: String, default: f32 },
Int { name: String, default: i32 },
Bool { name: String, default: bool },
}
impl Parameter {
pub fn name(&self) -> &str {
match self {
Parameter::Float { name, .. } | Parameter::Int { name, .. } | Parameter::Bool { name, .. } => name,
}
}
}
/// Property-name -> component-type inference. Deliberately strict; an unmapped property is an error.
pub fn infer_target(property: &str) -> Option<TargetType> {
if property.starts_with("blendShape.") {
return Some(TargetType::SkinnedMeshRenderer);
}
match property {
"m_IsActive" => Some(TargetType::GameObject),
_ => None,
}
}
impl ClipData {
/// Find-or-create the curve for one (path, target, property) binding.
pub fn curve_mut(&mut self, path: &str, target: TargetType, property: &str) -> &mut Curve {
let index = match self
.curves
.iter()
.position(|c| c.path == path && c.target == target && c.property == property)
{
Some(index) => index,
None => {
self.curves.push(Curve {
path: path.to_string(),
target,
property: property.to_string(),
keys: Vec::new(),
});
self.curves.len() - 1
}
};
&mut self.curves[index]
}
}
+167
View File
@@ -0,0 +1,167 @@
//! Evaluates a Rhai script that describes an Animator Controller, and hands the result to C# as JSON.
//!
//! The C# side never sees Rhai: it receives a `ControllerGraph` and drives the Animator As Code
//! modification API with it.
pub mod builder;
pub mod export;
pub mod graph;
pub mod motion_api;
pub mod rhai_api;
use builder::Aac;
use rhai::{Dynamic, Engine, Scope};
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::ptr;
pub use graph::ControllerGraph;
/// Opaque to C#. Owns the graph, the Rhai engine, and the last error message.
pub struct AacContext {
aac: Aac,
engine: Engine,
last_error: Option<CString>,
}
impl AacContext {
fn set_error(&mut self, message: String) {
// Interior NUL bytes would make CString::new fail; they can only come from a script string.
self.last_error = Some(CString::new(message.replace('\0', "\\0")).unwrap_or_default());
}
}
/// Evaluate a script and serialize the resulting graph. The testable core of the FFI.
pub fn evaluate_to_json(script: &str) -> Result<String, String> {
let aac = Aac::new();
let engine = rhai_api::engine(aac.clone());
let mut scope = Scope::new();
let _ = engine
.eval_with_scope::<Dynamic>(&mut scope, script)
.map_err(|error| error.to_string())?;
let graph = aac.write(|graph| graph.clone());
export::to_json(&graph)
}
/// Create a context. The returned pointer owns everything; release it with `aac_destroy`.
#[no_mangle]
pub extern "C" fn aac_create() -> *mut AacContext {
let aac = Aac::new();
let engine = rhai_api::engine(aac.clone());
Box::into_raw(Box::new(AacContext {
aac,
engine,
last_error: None,
}))
}
/// Evaluate a Rhai script. Returns 0 on success; on failure, call `aac_last_error`.
#[no_mangle]
pub extern "C" fn aac_eval_rhai(handle: *mut AacContext, script: *const c_char) -> i32 {
if handle.is_null() || script.is_null() {
return 1;
}
// SAFETY: the caller guarantees `handle` came from `aac_create` and has not been destroyed.
let context = unsafe { &mut *handle };
let script = match unsafe { CStr::from_ptr(script) }.to_str() {
Ok(script) => script,
Err(error) => {
context.set_error(format!("script is not valid UTF-8: {error}"));
return 1;
}
};
let outcome = {
let engine = &context.engine;
catch_unwind(AssertUnwindSafe(|| {
let mut scope = Scope::new();
engine.eval_with_scope::<Dynamic>(&mut scope, script)
}))
};
match outcome {
Ok(Ok(_)) => {
context.last_error = None;
0
}
Ok(Err(error)) => {
context.set_error(error.to_string());
1
}
Err(_) => {
context.set_error("internal error: the evaluator panicked".to_string());
2
}
}
}
/// Serialize the graph to JSON. Returns null on error; release the result with `aac_free_string`.
#[no_mangle]
pub extern "C" fn aac_to_json(handle: *mut AacContext) -> *mut c_char {
if handle.is_null() {
return ptr::null_mut();
}
// SAFETY: the caller guarantees `handle` came from `aac_create` and has not been destroyed.
let context = unsafe { &mut *handle };
let outcome = catch_unwind(AssertUnwindSafe(|| {
let graph = context.aac.write(|graph| graph.clone());
export::to_json(&graph)
}));
match outcome {
Ok(Ok(json)) => match CString::new(json) {
Ok(json) => {
context.last_error = None;
json.into_raw()
}
Err(_) => {
context.set_error("internal error: serialized JSON contained a NUL byte".to_string());
ptr::null_mut()
}
},
Ok(Err(error)) => {
context.set_error(error);
ptr::null_mut()
}
Err(_) => {
context.set_error("internal error: serialization panicked".to_string());
ptr::null_mut()
}
}
}
/// The last error message, or null if the last call succeeded. Borrowed: valid until the next call
/// on this context, and must not be freed.
#[no_mangle]
pub extern "C" fn aac_last_error(handle: *mut AacContext) -> *const c_char {
if handle.is_null() {
return ptr::null();
}
// SAFETY: the caller guarantees `handle` came from `aac_create` and has not been destroyed.
let context = unsafe { &*handle };
match &context.last_error {
Some(message) => message.as_ptr(),
None => ptr::null(),
}
}
/// Free a string returned by `aac_to_json`.
#[no_mangle]
pub extern "C" fn aac_free_string(string: *mut c_char) {
if string.is_null() {
return;
}
// SAFETY: `string` must come from `aac_to_json`, which uses `CString::into_raw`.
unsafe { drop(CString::from_raw(string)) };
}
/// Destroy a context created by `aac_create`.
#[no_mangle]
pub extern "C" fn aac_destroy(handle: *mut AacContext) {
if handle.is_null() {
return;
}
// SAFETY: `handle` must come from `aac_create`, which uses `Box::into_raw`.
unsafe { drop(Box::from_raw(handle)) };
}
+212
View File
@@ -0,0 +1,212 @@
use crate::builder::*;
use crate::graph::*;
use crate::rhai_api::err;
use rhai::{Engine, EvalAltResult};
pub fn register(engine: &mut Engine) {
register_clips(engine);
register_animation_store(engine);
register_blend_trees(engine);
register_state_motion(engine);
}
fn clip_ref(clip: &ClipBuilder) -> MotionRef {
MotionRef::Clip {
name: clip.name.clone(),
}
}
fn tree_ref(tree: &BlendTreeBuilder) -> MotionRef {
MotionRef::BlendTree {
name: tree.name.clone(),
}
}
fn register_clips(engine: &mut Engine) {
engine.register_fn("clip", |a: &mut Aac, name: &str| -> Result<ClipBuilder, Box<EvalAltResult>> {
if name.trim().is_empty() {
return Err(err("clip name is empty".to_string()));
}
if a.write(|graph| graph.clips.iter().any(|clip| clip.name == name)) {
return Err(err(format!("clip `{name}` is already declared")));
}
a.write(|graph| {
graph.clips.push(ClipData {
name: name.to_string(),
looping: Some(false),
curves: Vec::new(),
source: None,
})
});
Ok(ClipBuilder {
aac: a.clone(),
name: name.to_string(),
})
});
engine.register_fn("looping", |clip: ClipBuilder, value: bool| -> Result<ClipBuilder, Box<EvalAltResult>> {
clip.set_looping(value).map_err(err)?;
Ok(clip)
});
engine.register_fn(
"keyframe",
|clip: ClipBuilder, path: &str, property: &str, time: f64, value: f64| -> Result<ClipBuilder, Box<EvalAltResult>> {
clip.push_key(path, property, Keyframe::linear(time as f32, value as f32))
.map_err(err)?;
Ok(clip)
},
);
engine.register_fn("toggle", |clip: ClipBuilder, path: &str, value: bool| -> Result<ClipBuilder, Box<EvalAltResult>> {
let value = if value { 1.0 } else { 0.0 };
// A one-frame constant, matching AAC's toggling semantics.
clip.push_key(path, "m_IsActive", Keyframe::linear(0.0, value))
.map_err(err)?;
clip.push_key(path, "m_IsActive", Keyframe::linear(1.0 / 60.0, value))
.map_err(err)?;
Ok(clip)
});
engine.register_fn(
"blend_shape",
|clip: ClipBuilder, path: &str, shape: &str, time: f64, value: f64| -> Result<ClipBuilder, Box<EvalAltResult>> {
let property = format!("blendShape.{shape}");
clip.push_key(path, &property, Keyframe::linear(time as f32, value as f32))
.map_err(err)?;
Ok(clip)
},
);
}
fn register_animation_store(engine: &mut Engine) {
engine.register_fn(
"AnimationStore",
|a: &mut Aac, folder: &str| -> Result<AnimationStore, Box<EvalAltResult>> {
if folder.trim().is_empty() {
return Err(err("animation store folder is empty".to_string()));
}
Ok(AnimationStore {
aac: a.clone(),
folder: folder.to_string(),
})
},
);
engine.register_fn(
"clip",
|store: AnimationStore, name: &str| -> Result<ClipBuilder, Box<EvalAltResult>> {
store.clip(name).map_err(err)
},
);
}
fn register_blend_trees(engine: &mut Engine) {
engine.register_fn("blend_tree", |a: &mut Aac, name: &str| -> Result<BlendTreeBuilder, Box<EvalAltResult>> {
if name.trim().is_empty() {
return Err(err("blend tree name is empty".to_string()));
}
if a.write(|graph| graph.blend_trees.iter().any(|tree| tree.name == name)) {
return Err(err(format!("blend tree `{name}` is already declared")));
}
a.write(|graph| {
graph.blend_trees.push(BlendTreeData {
name: name.to_string(),
blend_type: BlendType::Simple1D,
param_x: String::new(),
param_y: None,
children: Vec::new(),
use_automatic_thresholds: false,
})
});
Ok(BlendTreeBuilder {
aac: a.clone(),
name: name.to_string(),
})
});
engine.register_fn("simple_1d", |tree: BlendTreeBuilder, x: FloatParam| {
tree.configure(BlendType::Simple1D, &x.name, None).map_err(err)?;
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
});
engine.register_fn("simple_directional_2d", |tree: BlendTreeBuilder, x: FloatParam, y: FloatParam| {
tree.configure(BlendType::SimpleDirectional2D, &x.name, Some(&y.name))
.map_err(err)?;
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
});
engine.register_fn("freeform_directional_2d", |tree: BlendTreeBuilder, x: FloatParam, y: FloatParam| {
tree.configure(BlendType::FreeformDirectional2D, &x.name, Some(&y.name))
.map_err(err)?;
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
});
engine.register_fn("freeform_cartesian_2d", |tree: BlendTreeBuilder, x: FloatParam, y: FloatParam| {
tree.configure(BlendType::FreeformCartesian2D, &x.name, Some(&y.name))
.map_err(err)?;
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
});
engine.register_fn("direct", |tree: BlendTreeBuilder| {
tree.configure(BlendType::Direct, "", None).map_err(err)?;
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
});
engine.register_fn("automatic_thresholds", |tree: BlendTreeBuilder, value: bool| {
tree.data_mut(|data| data.use_automatic_thresholds = value);
tree
});
engine.register_fn("add_motion", |tree: BlendTreeBuilder, clip: ClipBuilder, threshold: f64| {
push_child(&tree, clip_ref(&clip), threshold, None, None)?;
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
});
engine.register_fn("add_motion", |tree: BlendTreeBuilder, child: BlendTreeBuilder, threshold: f64| {
push_child(&tree, tree_ref(&child), threshold, None, None)?;
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
});
engine.register_fn("add_motion", |tree: BlendTreeBuilder, clip: ClipBuilder, x: f64, y: f64| {
push_child(&tree, clip_ref(&clip), x, Some(y), None)?;
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
});
engine.register_fn("add_motion", |tree: BlendTreeBuilder, child: BlendTreeBuilder, x: f64, y: f64| {
push_child(&tree, tree_ref(&child), x, Some(y), None)?;
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
});
engine.register_fn("add_motion_direct", |tree: BlendTreeBuilder, clip: ClipBuilder, parameter: FloatParam| {
push_child(&tree, clip_ref(&clip), 0.0, None, Some(&parameter.name))?;
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
});
engine.register_fn("add_motion_direct", |tree: BlendTreeBuilder, child: BlendTreeBuilder, parameter: FloatParam| {
push_child(&tree, tree_ref(&child), 0.0, None, Some(&parameter.name))?;
Ok::<BlendTreeBuilder, Box<EvalAltResult>>(tree)
});
}
fn register_state_motion(engine: &mut Engine) {
engine.register_fn("set_clip", |state: StateRef, clip: ClipBuilder| {
state.set_motion(clip_ref(&clip));
state
});
engine.register_fn("set_motion", |state: StateRef, clip: ClipBuilder| {
state.set_motion(clip_ref(&clip));
state
});
engine.register_fn("set_motion", |state: StateRef, tree: BlendTreeBuilder| {
state.set_motion(tree_ref(&tree));
state
});
}
fn push_child(
tree: &BlendTreeBuilder,
motion: MotionRef,
threshold: f64,
threshold_y: Option<f64>,
direct_param: Option<&str>,
) -> Result<(), Box<EvalAltResult>> {
let child = BlendChild {
motion,
threshold: threshold as f32,
threshold_y: threshold_y.map(|y| y as f32),
direct_param: direct_param.map(str::to_string),
};
tree.push_child(child).map_err(err)
}
+272
View File
@@ -0,0 +1,272 @@
use crate::builder::*;
use crate::graph::*;
use rhai::{Array, Engine, EvalAltResult, Position};
pub(crate) fn err(message: String) -> Box<EvalAltResult> {
Box::new(EvalAltResult::ErrorRuntime(message.into(), Position::NONE))
}
fn condition(parameter: &str, mode: CondMode, threshold: f32) -> Condition {
Condition {
parameter: parameter.to_string(),
mode,
threshold,
}
}
fn grid(value: i64) -> Result<i32, Box<EvalAltResult>> {
i32::try_from(value).map_err(|_| err(format!("grid position {value} does not fit in a 32-bit integer")))
}
fn add_state(aac: &Aac, layer: usize, machine: &[usize], name: &str, x: i64, y: i64) -> Result<StateRef, Box<EvalAltResult>> {
let position = GridPos::new(grid(x)?, grid(y)?);
let state = aac.write(|graph| {
let states = &mut machine_mut(graph, layer, machine).states;
states.push(State {
name: name.to_string(),
position,
..Default::default()
});
states.len() - 1
});
Ok(StateRef {
aac: aac.clone(),
layer,
machine: machine.to_vec(),
state,
})
}
fn add_sub_machine(
aac: &Aac,
layer: usize,
machine: &[usize],
name: &str,
x: i64,
y: i64,
) -> Result<MachineRef, Box<EvalAltResult>> {
let position = GridPos::new(grid(x)?, grid(y)?);
let index = aac.write(|graph| {
let sub_machines = &mut machine_mut(graph, layer, machine).sub_machines;
sub_machines.push(StateMachine {
name: Some(name.to_string()),
position,
..Default::default()
});
sub_machines.len() - 1
});
let mut path = machine.to_vec();
path.push(index);
Ok(MachineRef {
aac: aac.clone(),
layer,
machine: path,
})
}
fn assert_new_parameter(aac: &Aac, name: &str) -> Result<(), Box<EvalAltResult>> {
if name.trim().is_empty() {
return Err(err("parameter name is empty".to_string()));
}
let duplicate = aac.write(|graph| graph.parameters.iter().any(|p| p.name() == name));
if duplicate {
return Err(err(format!("parameter `{name}` is already declared")));
}
Ok(())
}
pub fn engine(aac: Aac) -> Engine {
let mut engine = Engine::new();
engine.register_type_with_name::<Aac>("Aac");
engine.register_type_with_name::<FloatParam>("FloatParam");
engine.register_type_with_name::<IntParam>("IntParam");
engine.register_type_with_name::<BoolParam>("BoolParam");
engine.register_type_with_name::<Condition>("Condition");
engine.register_type_with_name::<ControllerBuilder>("Controller");
engine.register_type_with_name::<LayerBuilder>("Layer");
engine.register_type_with_name::<MachineRef>("Machine");
engine.register_type_with_name::<StateRef>("State");
engine.register_type_with_name::<TransitionRef>("Transition");
engine.register_type_with_name::<ClipBuilder>("Clip");
engine.register_type_with_name::<AnimationStore>("AnimationStore");
engine.register_type_with_name::<BlendTreeBuilder>("BlendTree");
register_conditions(&mut engine);
register_setup(&mut engine, aac);
register_navigation(&mut engine);
register_transitions(&mut engine);
crate::motion_api::register(&mut engine);
engine
}
/// Leaf conditions come from overloaded comparison operators, because Rhai's `&&`/`||` cannot be
/// overloaded. Combine several conditions with `when_all([...])`.
fn register_conditions(engine: &mut Engine) {
engine.register_fn(">", |p: FloatParam, v: f64| condition(&p.name, CondMode::Greater, v as f32));
engine.register_fn("<", |p: FloatParam, v: f64| condition(&p.name, CondMode::Less, v as f32));
engine.register_fn("==", |p: FloatParam, v: f64| condition(&p.name, CondMode::Equals, v as f32));
engine.register_fn("!=", |p: FloatParam, v: f64| condition(&p.name, CondMode::NotEqual, v as f32));
engine.register_fn(">", |p: IntParam, v: i64| condition(&p.name, CondMode::Greater, v as f32));
engine.register_fn("<", |p: IntParam, v: i64| condition(&p.name, CondMode::Less, v as f32));
engine.register_fn("==", |p: IntParam, v: i64| condition(&p.name, CondMode::Equals, v as f32));
engine.register_fn("!=", |p: IntParam, v: i64| condition(&p.name, CondMode::NotEqual, v as f32));
engine.register_fn("==", |p: BoolParam, v: bool| {
condition(&p.name, if v { CondMode::If } else { CondMode::IfNot }, 0.0)
});
engine.register_fn("!=", |p: BoolParam, v: bool| {
condition(&p.name, if v { CondMode::IfNot } else { CondMode::If }, 0.0)
});
}
fn register_setup(engine: &mut Engine, aac: Aac) {
engine.register_fn("AnimatorAsCode", move || aac.clone());
engine.register_fn("system_name", |a: &mut Aac, name: &str| {
a.write(|graph| graph.system_name = name.to_string());
});
engine.register_fn("asset_key", |a: &mut Aac, key: &str| {
a.write(|graph| graph.asset_key = key.to_string());
});
engine.register_fn("float_param", |a: &mut Aac, name: &str, default: f64| -> Result<FloatParam, Box<EvalAltResult>> {
assert_new_parameter(a, name)?;
a.write(|graph| {
graph.parameters.push(Parameter::Float {
name: name.to_string(),
default: default as f32,
})
});
Ok(FloatParam {
name: name.to_string(),
})
});
engine.register_fn("int_param", |a: &mut Aac, name: &str, default: i64| -> Result<IntParam, Box<EvalAltResult>> {
assert_new_parameter(a, name)?;
a.write(|graph| {
graph.parameters.push(Parameter::Int {
name: name.to_string(),
default: default as i32,
})
});
Ok(IntParam {
name: name.to_string(),
})
});
engine.register_fn(
"bool_param",
|a: &mut Aac, name: &str, default: bool| -> Result<BoolParam, Box<EvalAltResult>> {
assert_new_parameter(a, name)?;
a.write(|graph| {
graph.parameters.push(Parameter::Bool {
name: name.to_string(),
default,
})
});
Ok(BoolParam {
name: name.to_string(),
})
},
);
engine.register_fn("new_controller", |a: &mut Aac| ControllerBuilder { aac: a.clone() });
}
fn register_navigation(engine: &mut Engine) {
// There is exactly one controller, so this is the only place layers are attached.
engine.register_fn("layer", |c: ControllerBuilder, name: &str| -> LayerBuilder {
let layer = c.aac.write(|graph| {
graph.controller.layers.push(Layer {
name: name.to_string(),
..Default::default()
});
graph.controller.layers.len() - 1
});
LayerBuilder {
aac: c.aac.clone(),
layer,
}
});
engine.register_fn("state", |l: LayerBuilder, name: &str, x: i64, y: i64| {
add_state(&l.aac, l.layer, &[], name, x, y)
});
engine.register_fn("state", |m: MachineRef, name: &str, x: i64, y: i64| {
add_state(&m.aac, m.layer, &m.machine, name, x, y)
});
engine.register_fn("sub_machine", |l: LayerBuilder, name: &str, x: i64, y: i64| {
add_sub_machine(&l.aac, l.layer, &[], name, x, y)
});
engine.register_fn("sub_machine", |m: MachineRef, name: &str, x: i64, y: i64| {
add_sub_machine(&m.aac, m.layer, &m.machine, name, x, y)
});
engine.register_fn("any_state", |l: LayerBuilder| AnyStateRef {
aac: l.aac.clone(),
layer: l.layer,
machine: Vec::new(),
});
engine.register_fn("any_state", |m: MachineRef| AnyStateRef {
aac: m.aac.clone(),
layer: m.layer,
machine: m.machine.clone(),
});
}
fn register_transitions(engine: &mut Engine) {
engine.register_fn("transition_to", |from: StateRef, to: StateRef| from.transition_to(&to));
engine.register_fn("transition_to", |from: AnyStateRef, to: StateRef| from.transition_to(&to));
engine.register_fn("when", |t: TransitionRef, c: Condition| {
t.add_conditions([c]);
t
});
engine.register_fn(
"when_all",
|t: TransitionRef, conditions: Array| -> Result<TransitionRef, Box<EvalAltResult>> {
let mut collected = Vec::with_capacity(conditions.len());
for value in conditions {
collected.push(
value
.try_cast::<Condition>()
.ok_or_else(|| err("when_all expects a list of conditions".to_string()))?,
);
}
t.add_conditions(collected);
Ok(t)
},
);
engine.register_fn("duration", |t: TransitionRef, seconds: f64| {
t.update(|transition| transition.duration = seconds as f32);
t
});
engine.register_fn("no_exit_time", |t: TransitionRef| {
t.update(|transition| {
transition.has_exit_time = false;
transition.exit_time = 0.0;
});
t
});
engine.register_fn("exit_time", |t: TransitionRef, normalized: f64| {
t.update(|transition| {
transition.has_exit_time = true;
transition.exit_time = normalized as f32;
});
t
});
engine.register_fn("ordered_interruption", |t: TransitionRef, value: bool| {
t.update(|transition| transition.ordered_interruption = value);
t
});
engine.register_fn("source_interruption", |t: TransitionRef| {
t.update(|transition| transition.source_interruption = true);
t
});
engine.register_fn("to_self", |t: TransitionRef| {
t.update(|transition| transition.can_transition_to_self = true);
t
});
}
+438
View File
@@ -0,0 +1,438 @@
use aac::graph::*;
use serde_json::Value;
const AVATAR: &str = include_str!("../examples/avatar.rhai");
const ANIMATION_STORE: &str = include_str!("../examples/animation_store.rhai");
fn json(script: &str) -> Value {
serde_json::from_str(&aac::evaluate_to_json(script).expect("script should evaluate")).expect("valid json")
}
fn error(script: &str) -> String {
aac::evaluate_to_json(script).expect_err("script should fail")
}
/// A minimal but valid graph, for mutating into invalid shapes.
fn valid_graph() -> ControllerGraph {
let script = r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let speed = aac.float_param("Speed", 0.0);
let ctrl = aac.new_controller();
let base = ctrl.layer("Base");
let a = base.state("A", 0, 0);
let b = base.state("B", 1, 0);
let clip = aac.clip("clip");
clip.looping(true);
a.set_clip(clip);
a.transition_to(b).when(speed > 0.5).duration(0.1);
"#;
serde_json::from_str(&aac::evaluate_to_json(script).expect("valid")).expect("deserializes")
}
#[test]
fn example_script_describes_the_whole_controller() {
let value = json(AVATAR);
assert_eq!(value["system_name"], "MyAvatar");
assert_eq!(value["asset_key"], "AAC_");
assert_eq!(value["parameters"].as_array().unwrap().len(), 3);
assert_eq!(value["clips"].as_array().unwrap().len(), 4);
assert_eq!(value["blend_trees"].as_array().unwrap().len(), 1);
let layers = value["controller"]["layers"].as_array().unwrap();
assert_eq!(layers.len(), 1);
assert_eq!(layers[0]["name"], "Base");
let states = layers[0]["state_machine"]["states"].as_array().unwrap();
assert_eq!(states.len(), 4);
assert_eq!(states[0]["name"], "Idle");
assert_eq!(states[0]["position"]["x"], 0);
// A clip used both as a state motion and as a blend tree child.
assert_eq!(states[0]["motion"]["type"], "clip");
assert_eq!(states[0]["motion"]["name"], "idle_anim");
assert_eq!(states[2]["motion"]["type"], "blend_tree");
assert_eq!(states[2]["motion"]["name"], "locomotion");
// `speed > 0.1` becomes a greater-than condition.
let transition = &states[0]["transitions"][0];
assert_eq!(transition["conditions"][0]["parameter"], "Speed");
assert_eq!(transition["conditions"][0]["mode"], "greater");
assert_eq!(transition["conditions"][0]["threshold"], 0.1);
assert_eq!(transition["duration"], 0.25);
assert_eq!(transition["has_exit_time"], false);
// `when_all([...])` produces two conditions on one transition.
let both = states[1]["transitions"][1]["conditions"].as_array().unwrap();
assert_eq!(both.len(), 2);
assert_eq!(both[1]["parameter"], "IsSitting");
assert_eq!(both[1]["mode"], "if_not");
let any_state = layers[0]["state_machine"]["any_state_transitions"].as_array().unwrap();
assert_eq!(any_state.len(), 1);
assert_eq!(any_state[0]["to"], "Sit");
let sub = layers[0]["state_machine"]["sub_machines"].as_array().unwrap();
assert_eq!(sub.len(), 1);
assert_eq!(sub[0]["name"], "Gestures");
assert_eq!(sub[0]["states"].as_array().unwrap().len(), 2);
// Blend shape keys landed in time order on one curve.
let clip = find_clip(&value, "idle_anim");
let curve = curve_of(clip, "blendShape.Smile");
assert_eq!(curve["target"], "skinned_mesh_renderer");
assert_eq!(curve["keys"].as_array().unwrap().len(), 2);
assert_eq!(curve["keys"][0]["time"], 0.0);
assert_eq!(curve["keys"][1]["time"], 1.0);
// A toggle is a one-frame constant: two keys with the same value.
let toggle = curve_of(find_clip(&value, "walk_anim"), "m_IsActive");
assert_eq!(toggle["target"], "game_object");
let keys = toggle["keys"].as_array().unwrap();
assert_eq!(keys.len(), 2);
assert_eq!(keys[0]["value"], keys[1]["value"]);
}
fn find_clip<'a>(value: &'a Value, name: &str) -> &'a Value {
value["clips"].as_array().unwrap().iter().find(|c| c["name"] == name).unwrap()
}
fn curve_of<'a>(clip: &'a Value, property: &str) -> &'a Value {
clip["curves"]
.as_array()
.unwrap()
.iter()
.find(|c| c["property"] == property)
.unwrap()
}
#[test]
fn animation_store_clips_are_referenced_not_generated() {
let value = json(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let store = aac.AnimationStore("Assets/Doloro/Clips/");
let walk = store.clip("Walk");
let ctrl = aac.new_controller();
let base = ctrl.layer("Base");
let s = base.state("S", 0, 0);
s.set_clip(walk);
"#,
);
let clips = value["clips"].as_array().unwrap();
assert_eq!(clips.len(), 1);
assert_eq!(clips[0]["name"], "Assets/Doloro/Clips/Walk.anim");
assert_eq!(clips[0]["source"], "Assets/Doloro/Clips/Walk.anim");
assert!(clips[0]["curves"].as_array().unwrap().is_empty());
// Untouched, it keeps the source asset's looping setting.
assert!(clips[0].get("looping").is_none());
// A generated clip keeps its old shape: no `source` key at all.
let generated = json(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let c = aac.clip("c");
"#,
);
assert!(generated["clips"][0].get("source").is_none());
}
#[test]
fn animation_store_resolves_one_clip_per_asset() {
// The same asset asked for twice, with and without the extension, stays a single clip.
let value = json(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let store = aac.AnimationStore("Assets/Doloro/Clips");
let x = store.clip("Walk");
let y = store.clip("Walk.anim");
let ctrl = aac.new_controller();
let base = ctrl.layer("Base");
let s = base.state("S", 0, 0);
s.set_clip(x);
"#,
);
assert_eq!(value["clips"].as_array().unwrap().len(), 1);
}
#[test]
fn animation_store_clips_can_be_edited_on_a_clone() {
let value = json(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let store = aac.AnimationStore("Assets/Doloro/Clips");
let walk = store.clip("Walk");
walk.looping(true);
walk.keyframe("Body", "m_IsActive", 0.0, 1.0);
let ctrl = aac.new_controller();
let base = ctrl.layer("Base");
let s = base.state("S", 0, 0);
s.set_clip(walk);
"#,
);
let clip = &value["clips"][0];
// The source is still carried, so the Unity side knows which asset to clone.
assert_eq!(clip["source"], "Assets/Doloro/Clips/Walk.anim");
assert_eq!(clip["looping"], true);
assert_eq!(clip["curves"][0]["property"], "m_IsActive");
}
/// The store example is a whole controller, so evaluating it exercises every store path end to end.
#[test]
fn animation_store_example_sources_every_clip() {
let value = json(ANIMATION_STORE);
assert_eq!(value["system_name"], "AnimationStore");
assert_eq!(value["parameters"].as_array().unwrap().len(), 5);
assert_eq!(value["controller"]["layers"].as_array().unwrap().len(), 2);
// Every clip comes from an asset, and each asset is declared exactly once.
let clips = value["clips"].as_array().unwrap();
let sources: Vec<&str> = clips
.iter()
.map(|clip| clip["source"].as_str().expect("every clip has a source"))
.collect();
let unique: std::collections::HashSet<&str> = sources.iter().copied().collect();
assert_eq!(unique.len(), sources.len(), "the same asset must not be declared twice");
assert_eq!(clips.len(), 11);
// An edited clip carries its override and curves; an untouched one carries neither.
let walk = find_clip(&value, "Assets/Doloro/Clips/Locomotion/Walk.anim");
assert_eq!(walk["looping"], true);
assert_eq!(curve_of(walk, "m_IsActive")["target"], "game_object");
let idle = find_clip(&value, "Assets/Doloro/Clips/Locomotion/Idle.anim");
assert!(idle.get("looping").is_none());
assert!(idle["curves"].as_array().unwrap().is_empty());
// A store clip feeds a state and a blend tree, and a tree may nest a tree declared before it.
let trees = value["blend_trees"].as_array().unwrap();
assert_eq!(trees.len(), 4);
let movement = trees.iter().find(|t| t["name"] == "movement").unwrap();
assert_eq!(movement["children"].as_array().unwrap().len(), 3);
let full_body = trees.iter().find(|t| t["name"] == "full_body").unwrap();
assert_eq!(full_body["children"][0]["motion"]["type"], "blend_tree");
assert_eq!(full_body["children"][0]["motion"]["name"], "movement");
}
#[test]
fn keyframes_are_sorted_by_time() {
let value = json(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let c = aac.clip("c");
c.keyframe("Body", "m_IsActive", 2.0, 1.0);
c.keyframe("Body", "m_IsActive", 0.5, 0.0);
c.keyframe("Body", "m_IsActive", 1.0, 1.0);
"#,
);
let times: Vec<f64> = value["clips"][0]["curves"][0]["keys"]
.as_array()
.unwrap()
.iter()
.map(|k| k["time"].as_f64().unwrap())
.collect();
assert_eq!(times, vec![0.5, 1.0, 2.0]);
}
#[test]
fn blend_tree_shapes() {
let value = json(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let x = aac.float_param("X", 0.0);
let y = aac.float_param("Y", 0.0);
let direct = aac.float_param("Direct", 0.0);
let ctrl = aac.new_controller();
let base = ctrl.layer("Base");
let s = base.state("S", 0, 0);
let one = aac.clip("one");
let two = aac.clip("two");
let inner = aac.blend_tree("inner");
inner.simple_1d(x);
inner.add_motion(one, 0.0);
let outer = aac.blend_tree("outer");
outer.simple_directional_2d(x, y);
outer.add_motion(two, 0.0, 1.0);
outer.add_motion(inner, 2.0, 3.0);
outer.automatic_thresholds(true);
s.set_motion(outer);
let flat = aac.blend_tree("flat");
flat.direct();
flat.add_motion_direct(one, direct);
"#,
);
let trees = value["blend_trees"].as_array().unwrap();
let outer = trees.iter().find(|t| t["name"] == "outer").unwrap();
assert_eq!(outer["blend_type"], "simple_directional_2d");
assert_eq!(outer["param_x"], "X");
assert_eq!(outer["param_y"], "Y");
assert_eq!(outer["use_automatic_thresholds"], true);
let children = outer["children"].as_array().unwrap();
assert_eq!(children.len(), 2);
assert_eq!(children[0]["threshold_y"], 1.0);
assert_eq!(children[1]["motion"]["type"], "blend_tree");
assert_eq!(children[1]["motion"]["name"], "inner");
let flat = trees.iter().find(|t| t["name"] == "flat").unwrap();
assert_eq!(flat["blend_type"], "direct");
assert_eq!(flat["param_x"], "");
assert_eq!(flat["children"][0]["direct_param"], "Direct");
}
#[test]
fn json_round_trips_through_the_graph_type() {
// Compare text, not `Value`: `serde_json::Value` widens f32 to f64, which changes the digits.
let json = aac::evaluate_to_json(AVATAR).expect("evaluates");
let graph: ControllerGraph = serde_json::from_str(&json).expect("deserializes");
let reserialized = serde_json::to_string_pretty(&graph).expect("serializes");
assert_eq!(reserialized, json);
}
#[test]
fn missing_configuration_is_reported() {
assert!(error("let aac = AnimatorAsCode(); aac.asset_key(\"K\");").contains("system_name is not set"));
assert!(error("let aac = AnimatorAsCode(); aac.system_name(\"S\");").contains("asset_key is not set"));
}
#[test]
fn duplicate_names_are_rejected_at_the_call_site() {
assert!(error(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let a = aac.float_param("Speed", 0.0);
let b = aac.float_param("Speed", 0.0);
"#
)
.contains("already declared"));
assert!(error(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let a = aac.clip("c");
let b = aac.clip("c");
"#
)
.contains("already declared"));
}
#[test]
fn unknown_curve_property_is_rejected() {
let message = error(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let c = aac.clip("c");
c.keyframe("Body", "m_NoSuchThing", 0.0, 1.0);
"#,
);
assert!(message.contains("cannot tell which component"), "unexpected: {message}");
}
#[test]
fn and_operator_is_not_available() {
// The one DSL limitation: Rhai's `&&` cannot be overloaded, so use `when_all([...])`.
let message = error(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let a = aac.float_param("A", 0.0);
let b = aac.bool_param("B", false);
let ctrl = aac.new_controller();
let base = ctrl.layer("Base");
let s = base.state("S", 0, 0);
let t = base.state("T", 1, 0);
s.transition_to(t).when(a > 0.0 && b == true);
"#,
);
assert!(message.contains("bool"), "unexpected: {message}");
}
/// The validator is a safety net for graphs that do not come from the typed Rhai DSL.
#[test]
fn validator_rejects_broken_graphs() {
let mut graph = valid_graph();
graph.controller.layers[0].state_machine.states[0].transitions[0].to = "Nowhere".to_string();
let message = aac::export::to_json(&graph).expect_err("dangling target");
assert!(message.contains("not a state in that layer"), "unexpected: {message}");
let mut graph = valid_graph();
graph.controller.layers[0].state_machine.states[0].transitions[0].conditions[0].parameter = "Ghost".to_string();
let message = aac::export::to_json(&graph).expect_err("undeclared parameter");
assert!(message.contains("not declared"), "unexpected: {message}");
let mut graph = valid_graph();
graph.clips.push(graph.clips[0].clone());
let message = aac::export::to_json(&graph).expect_err("duplicate clip");
assert!(message.contains("declared more than once"), "unexpected: {message}");
let mut graph = valid_graph();
graph.controller.layers[0].state_machine.states[0].motion = Some(MotionRef::Clip { name: "ghost".into() });
let message = aac::export::to_json(&graph).expect_err("dangling motion");
assert!(message.contains("not declared"), "unexpected: {message}");
// Forward references across states must resolve.
let graph = valid_graph();
aac::export::to_json(&graph).expect("a valid graph stays valid");
}
/// The Unity side deserializes this JSON with `MissingMemberHandling.Error`, so a field added or
/// renamed here breaks it at runtime, where no test could catch it. Pin every object's key set.
#[test]
fn json_keys_are_the_ones_the_unity_dtos_expect() {
let value = json(AVATAR);
fn keys(value: &Value) -> Vec<&str> {
value.as_object().expect("object").keys().map(String::as_str).collect()
}
assert_eq!(keys(&value), ["asset_key", "blend_trees", "clips", "controller", "parameters", "system_name"]);
assert_eq!(keys(&value["parameters"][0]), ["default", "name", "type"]);
assert_eq!(keys(&value["clips"][0]), ["curves", "looping", "name"]);
assert_eq!(keys(&value["clips"][0]["curves"][0]), ["keys", "path", "property", "target"]);
assert_eq!(keys(&value["clips"][0]["curves"][0]["keys"][0]), ["in_tangent", "out_tangent", "time", "value"]);
let tree = &value["blend_trees"][0];
assert_eq!(keys(tree), ["blend_type", "children", "name", "param_x", "param_y", "use_automatic_thresholds"]);
assert_eq!(keys(&tree["children"][0]), ["direct_param", "motion", "threshold", "threshold_y"]);
assert_eq!(keys(&tree["children"][0]["motion"]), ["name", "type"]);
let layer = &value["controller"]["layers"][0];
assert_eq!(keys(layer), ["name", "state_machine"]);
assert_eq!(keys(&layer["state_machine"]), ["any_state_transitions", "name", "position", "states", "sub_machines"]);
assert_eq!(keys(&layer["state_machine"]["position"]), ["x", "y"]);
assert_eq!(keys(&layer["state_machine"]["states"][0]), ["motion", "name", "position", "transitions"]);
assert_eq!(
keys(&layer["state_machine"]["states"][0]["transitions"][0]),
[
"can_transition_to_self",
"conditions",
"duration",
"exit_time",
"has_exit_time",
"ordered_interruption",
"source_interruption",
"to"
]
);
assert_eq!(keys(&layer["state_machine"]["states"][0]["transitions"][0]["conditions"][0]), ["mode", "parameter", "threshold"]);
}