確かにAnimatorControllerは不便だけど、数百のAnimationClip、4-5段重ねのBlendTree、アニメーションの性質ごとにまちまちな遷移時間の設定、これらを全部スクリプトで書かなきゃいけないとなったら、それよりは絶対我慢してAnimatorController使ったほうがマシでしょ、と思っていたので「これがあればAnimatorControllerとはおさらば!」という部分だけがやたら強調されて喧伝されていたPlayable APIには今まで興味がなかったのですが、今回使ってみたらちゃんと役に立ったのでご報告。
Animation C# Jobs をつかった独自アニメーションブレンドの実装例として参考にしてもらえるかと。
課題:上半身に武器構えモーションをブレンドするとよたよたする
普通にAvatarMaskで上半身のボーンだけを指定して武器構えのモーションのブレンドしつつ歩いたり走ったりすると、着ぐるみを着慣れてない人が中に入ってる着ぐるみ、みたいなモーションになってしまいます。
Unreal Engineであればブレンドノードに Mesh Space Rotation Blend というオプションがあるので標準の機能だけでこれを解決できるんですが、残念ながらUnityのアニメーションブレンド機能にはこれがありません。
解決後:PlayableAPIでUnrealEngineでいうところの Mesh Space Rotation Blend みたいなことをする
だいぶ凛々しい感じなりました。これは上半身アニメーションを適用する根元のボーン(自分の場合はwaistボーン)だけ、ワールド座標系での回転を採用させる、という手法で実現しています。
AnimatorControllerPlayable + AnimationScriptPlayable + Animation C# Jobs
この腰ボーンにガクガクしないよう補正用の処理を施すスクリプトを張る、という手法でもよいのですが、その補正用の回転値を手動で打ち込んだりしなきゃいけないのは嫌ですし、構えモーション中の腰ボーンの回転値が欲しいだけなのに別のGameObject階層を作ってそこに武器構えモーションだけのAnimatorを適用するというような無駄が大きい手法も取りたくない、Animation Rigging の何かでうまくやれるかも?いや、まったく具体的な手法が思いつかない……ということで Playable API を使って何とかしよう、としたわけですが、AnimatorControllerPlayable + AnimationScriptPlayable + IAnimationJob を実際に使用した例というのが全然ネットに上がっておらずCopilot君も全然要領を得ないことしか言ってくれず、で、PlayableAPI初挑戦でだいぶつまずいたので記事にしました。
AvatarMaskで示されたTransformを指定のAnimatorControllerを評価した結果のワールド空間での回転で置き換える例
using System.Collections.Generic;
using Unity.Burst;
using Unity.Collections;
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;
public class WorldSpaceBlender : MonoBehaviour
{
public AvatarMask targetMask;
public RuntimeAnimatorController referenceAnimatorController;
[Range(0, 180)]
public float maxAngle = 45f;
[Range(0, 1)]
public float weight;
public AnimatorControllerPlayable MainPlayableController => _mainPlayableController;
Animator _animator;
PlayableGraph _graph;
AnimatorControllerPlayable _mainPlayableController;
AnimationScriptPlayable _recordingScriptPlayable;
AnimationScriptPlayable _blendingScriptPlayable;
WorldSpaceBlendingJob _blendingJob;
void Awake()
{
_animator = GetComponent<Animator>();
}
void Start()
{
_mainPlayableController = AnimationPlayableUtilities.PlayAnimatorController(_animator, _animator.runtimeAnimatorController, out _graph);
var output = _graph.GetOutput(0);
var handles = GetBindedHandles(_animator, targetMask);
_blendingJob = new WorldSpaceBlendingJob {
handles = new NativeArray<TransformStreamHandle>(handles, Allocator.Persistent),
rotations = new NativeArray<Quaternion>(handles.Length, Allocator.Persistent),
maxAngle = maxAngle
};
_blendingScriptPlayable = AnimationScriptPlayable.Create(_graph, _blendingJob);
_recordingScriptPlayable = AnimationScriptPlayable.Create(_graph, new WorldSpaceRotationRecordingJob {
handles = _blendingJob.handles,
rotations = _blendingJob.rotations
});
_recordingScriptPlayable.AddInput(AnimatorControllerPlayable.Create(_graph, referenceAnimatorController), 0);
_blendingScriptPlayable.AddInput(_recordingScriptPlayable, 0);
_blendingScriptPlayable.AddInput(_mainPlayableController, 0);
output.SetSourcePlayable(_blendingScriptPlayable);
}
void Update()
{
if(!_blendingScriptPlayable.IsValid()) return;
bool needsUpdate = false;
if(_blendingJob.weight != weight) {
_blendingJob.weight = weight;
needsUpdate = true;
}
if(_blendingJob.maxAngle != maxAngle) {
_blendingJob.maxAngle = maxAngle;
needsUpdate = true;
}
if(needsUpdate) {
_blendingScriptPlayable.SetJobData(_blendingJob);
}
_recordingScriptPlayable.SetProcessInputs(weight > 0f);
}
void OnDestroy()
{
if(_graph.IsValid()) _graph.Destroy();
}
static TransformStreamHandle[] GetBindedHandles(Animator animator, AvatarMask mask)
{
var handles = new List<TransformStreamHandle>();
for(int i = 0; i < mask.transformCount; i++) {
if(mask.GetTransformActive(i)) {
var path = mask.GetTransformPath(i);
if(!string.IsNullOrEmpty(path)) {
var transform = animator.transform.Find(path);
if(transform) {
handles.Add(animator.BindStreamTransform(transform));
}
}
}
}
return handles.ToArray();
}
}
[BurstCompile]
public struct WorldSpaceRotationRecordingJob : IAnimationJob
{
[ReadOnly]
public NativeArray<TransformStreamHandle> handles;
[WriteOnly]
public NativeArray<Quaternion> rotations;
public void ProcessAnimation(AnimationStream stream)
{
if(stream.isValid) {
for(int i = 0; i < handles.Length; i++) {
var handle = handles[i];
rotations[i] = handle.IsValid(stream) ? handle.GetRotation(stream) : new Quaternion(0, 0, 0, 0);
}
}
else {
for(int i = 0; i < rotations.Length; i++) {
rotations[i] = new Quaternion(0, 0, 0, 0);
}
}
}
public readonly void ProcessRootMotion(AnimationStream stream) { }
}
[BurstCompile]
public struct WorldSpaceBlendingJob : IAnimationJob
{
[ReadOnly]
public NativeArray<TransformStreamHandle> handles;
[ReadOnly]
public NativeArray<Quaternion> rotations;
public float maxAngle;
public float weight;
public void ProcessAnimation(AnimationStream stream)
{
for(int i = 0; i < handles.Length; i++) {
var handle = handles[i];
if(!handle.IsValid(stream)) continue;
var targetRotation = rotations[i];
if(Mathf.Approximately(targetRotation.x, 0f)
&& Mathf.Approximately(targetRotation.y, 0f)
&& Mathf.Approximately(targetRotation.z, 0f)
&& Mathf.Approximately(targetRotation.w, 0f)
) continue;
var rotation = handle.GetRotation(stream);
float angle = Quaternion.Angle(rotation, targetRotation);
float weight = this.weight;
if(angle > maxAngle) {
targetRotation = Quaternion.RotateTowards(rotation, targetRotation, maxAngle);
weight *= Mathf.Lerp(1f, 0f, (angle - maxAngle) / (180 - maxAngle));
}
handle.SetRotation(stream, Quaternion.Slerp(rotation, targetRotation, weight));
}
}
public readonly void ProcessRootMotion(AnimationStream stream) { }
}
WorldSpaceRotationRecordingJob
が referenceAnimatorController
を評価した結果のワールド座標系での回転値を rotations
配列に保存し、その後 WorldSpaceBlendingJob
が共有している rotations
配列の内容を使ってメインのAnimatorControllerの結果に対してAvatarMaskで指定された対象だけブレンドした結果で置き換える、というようなことをやっています。
使用方法
まず上のコンポーネントをAnimatorと同じ階層に貼ります。
本来、weight
は必要な時に1になり不要なときは0になるようにスクリプトで制御してもらうものなんですが、テストの段階では手動で1にして常に作動させてしまえばよいでしょう。
targetMaskには、今回であれば上半身の根元のボーンだけを含んだAvatarMaskを作ってそれを指定します。
referenceAnimatorControllerには、ベースとなるAnimatorControllerを複製して、上半身へのブレンドレイヤーだけを残したAnimatorControllerを作ってそれを指定してやります
オリジナルからコピーしたので上半身モーションレイヤーには上半身だけのAvatarMaskが設定されていると思うんですが、このままだと
このようにおかしな姿勢になってしまうと思います。これはAvatarMaskがwaistより上の階層しか含んでいないせいでwaistボーンのワールド座標がちゃんと求まらないからです。なので、referenceAnimatorControllerに渡す上半身モーションだけのAnimatorControllerの各レイヤーのAvatarMaskは設定を解除してNoneにするか、目的のボーンまでの階層を含む逆マスクを作って指定してやる必要があります。
これを作ってる最中はまった箇所
AIが把握してる情報が古い
PlayableAPI登場当時(2017年)くらいの情報で止まっててやたらgraph.ConnetやらCreateしてSetInputCountして…と冗長な記述を示してくるんですが、現在は上記のように AnimationPlayableUtilities.PlayAnimatorController
というヘルパー関数があったりPlayableにはAddInput
メソッドがあったりでずっとすっきりした書き方ができます。
AIがジョブの依存関係を制御する方法をご存じなかった
正解は上記の通り
_blendingScriptPlayable.AddInput(recordingScriptPlayable, 0);
_blendingScriptPlayable.AddInput(_mainPlayableController, 0);
と、BlendingJobより先に実行が完了していてほしいRecordingJobを先に接続(input index 0)し、ブレンド対象の_mainPlayableControllerを後から接続(input index 1)するというのが正解でした。
AIに適当に依存ジョブを入力に繋げておくという方法じゃダメなのかと聞くと「ダメです!IAnimationJobは入力を一つしか受け取れませんから!(キリッ)」と自信満々で断言してくるので、だいぶ迷わされました。
AddInput
はInputWeightを引数で指定することもできるのですが、これのデフォルトは0で、上記の呼び出しはweight指定を省略している(明示している0
はウェイト値ではなく接続するPlayableの出力ポートインデックス)のでウェイトは0で入力ポートが製作されています。が、これでも意図通り正しく動作します。
入力に指定するウェイトはPlayableの実装の中身でウェイト値に従って何かする、という処理が書かれていない限り動作に影響を与えないようで、ウェイト0だと入力ノードの評価をそもそもスキップする、ということもしないで普通に評価されるようです。
TraversalMode
が PlayableTraversalMode.Mix
(デフォルト。なので上記コードにはSetTraversalMode
で明示している箇所はなし) である場合、後続の入力で上書きされた結果がノードに流れてくるようで、
_blendingScriptPlayable.AddInput(recordingScriptPlayable, 0);
_blendingScriptPlayable.AddInput(_mainPlayableController, 0);
こう書いた場合recordingScriptPlayableの出力(上半身レイヤーブレンド用のアニメーション)を _mainPlayableController
の出力で上書きされたものが AnimationStream stream
として
WorldSpaceBlendingJob.ProcessAnimation
に渡されてくるようです。
この入力の依存関係によりWorldSpaceBlendingJobより先にWorldSpaceRotationRecordingJobが実行されているので NativeArray<Quaternion> rotations
にはちゃんと有効な値が入っていることが保証されるようです。
ちなみにTraversalMode
をPlayableTraversalMode.Passthrough
(デフォルトではない、もう一個のモード)にしてみたところ、これは「最初の有効な入力だけを受け取り他を無視する」というモードだそうで、入力1のrecordingScriptPlayableの結果、つまり上半身レイヤーブレンド用のアニメーションがそのままWorldSpaceBlendingJobにも流れてきて、_mainPlayableControllerで再生されているであろう歩きモーションなどは全部無視されて武器構えモーションだけが最終結果として表示されました。
なるほどね…でもいつ使うんだろ(どういうときに便利なのかさっぱりわからない)
ウェイトが0だったらreferenceAnimatorControllerの評価自体無駄なのだからスキップしたい、と思うわけなんですがこれはウェイト値の設定では実現できず、本当にグラフの接続を切るという方法でしか実現できないようでした。
面倒だったので今回は必要な時だけグラフに接続してそうでないなら切る、ということしていません。(代わりに_recordingScriptPlayable.SetProcessInputs(weight > 0f)
とはしてみました)
PlayableAPIでUnreal EngineのAnimation Blueprintのまねごとをしようとした
せっかくPlayableAPIを使うのだから、と、いきなり上半身ブレンドと加算ブレンドをメインのAnimationControllerから追い出して別AnimationController化し三つのAnimationControllerPlayableを数珠繋ぎするといった構成をPlayableGraph上で実現しようとしたのですが、これが大きな間違いでした。
_mainPlayableController
の出力に加算ブレンド用AnimationControllerの評価結果を AnimationLayerMixerPlayable で加算ブレンドするようにしてみたんですが、どうやればうまくいくのかさっぱりわからず。
武器の表示のオンオフはアニメーションカーブで武器を持ってるアニメーションが再生されてる間1になることで表示され、それ以外のアニメーションでは0になるので消える、という仕組みで実現していたのですが、
これがあらかじめAnimatorに設定されていたAnimatorControllerではないAnimatorControllerから製作したAnimatorControllerPlayableでは、グラフの評価がされてもAniamtorのAnimationParameterに反映されないようで、武器構えモーションレイヤーを完全に別AnimatorControllerに独立させてしまうと(トリガーやほかのAnimationParametorの読み込みには支障がないので)アニメーション自体は正しく再生されていても武器が出てこない、という状態になり、これを解決できませんでした。
AniamtionClipPlayableではAnimatorCurveも正しく反映されるようなので、AnimatorControllerを使わずLayerMixerやBlendTreeや状態遷移なども全部再現したPlayableGraphをスクリプトで構築すればよい、ということらしんですが、大変すぎてとてもやる気にならず。
オーサリングツールが公式に出てこないことにはPlayableAPIでAnimation Blueprintと同等のことができる、とは考えないほうがよさそうでした。
一応 PlayableGraph Visualizer も導入してみたんですが、スクロールも拡縮もできないのでClipが500ほどあるグラフに対しては全く無意味でした。
最初からAnimatorControllerPlayableベースでやることを考えている人には、役に立たないので導入しなくてよさそうです。