shity basic one shot

This commit is contained in:
2026-09-18 14:01:10 +01:00
parent 5851077659
commit 42a1234e7f
81 changed files with 3484 additions and 76 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.3.0-alpha.1",
"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"
}
+79 -5
View File
@@ -1,8 +1,82 @@
## Animation As Crab
## Animator As Crab
This is a hard fork of https://github.com/hai-vr/av3-animator-as-code
We will be using [rhai](https://rhai.rs/) for writing animation controllers instead of 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#.
the idea is, (rhai + rust) will output a big eval'd json file that the C# side will parse and generate a native unity animation controller from.
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.
Original Creator is [hai-vr](https://github.com/hai-vr)
```
script.rhai --> libaac (Rust) --JSON--> AacCrabWindow (Unity Editor) --> AnimatorController
```
## Layout
| 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`. |
## Building
```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
```
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.
## Writing a script
```rhai
let aac = AnimatorAsCode();
aac.system_name("MyAvatar");
aac.asset_key("AAC_");
let speed = aac.float_param("Speed", 0.0);
let ctrl = aac.new_controller();
let base = ctrl.layer("Base");
let idle = base.state("Idle", 0, 0);
let walk = base.state("Walk", 1, 0);
let 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.
## 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.
@@ -7,7 +7,9 @@
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [],
"precompiledReferences": [
"Newtonsoft.Json.dll"
],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
@@ -0,0 +1,407 @@
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, AacFlClip>();
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;
}
private static AacFlClip CreateClip(AacFlBase aac, AacCrabClip graph)
{
var clip = aac.NewClip(graph.Name);
if (graph.Looping)
{
clip.Looping();
}
else
{
clip.NonLooping();
}
return 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));
}
});
}
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, AacFlClip> 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, AacFlClip> 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.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, AacFlClip> 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,192 @@
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; }
public bool Looping { 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,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);
};
}
+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"
+58
View File
@@ -0,0 +1,58 @@
// 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);
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);
}
}
}
+234
View File
@@ -0,0 +1,234 @@
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,
}
#[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))
}
/// 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 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(_) => {}
}
}
+263
View File
@@ -0,0 +1,263 @@
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,
pub looping: bool,
pub curves: Vec<Curve>,
}
#[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)) };
}
+188
View File
@@ -0,0 +1,188 @@
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_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: false,
curves: Vec::new(),
})
});
Ok(ClipBuilder {
aac: a.clone(),
name: name.to_string(),
})
});
engine.register_fn("looping", |clip: ClipBuilder, value: bool| {
clip.data_mut(|data| data.looping = value);
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_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)
}
+271
View File
@@ -0,0 +1,271 @@
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::<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
});
}
+321
View File
@@ -0,0 +1,321 @@
use aac::graph::*;
use serde_json::Value;
const AVATAR: &str = include_str!("../examples/avatar.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(), 3);
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 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"]);
}