0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

上半身に武器構えモーションをブレンドするとよたよたするのをPlayableAPIで解決する

Last updated at Posted at 2025-07-06

確かにAnimatorControllerは不便だけど、数百のAnimationClip、4-5段重ねのBlendTree、アニメーションの性質ごとにまちまちな遷移時間の設定、これらを全部スクリプトで書かなきゃいけないとなったら、それよりは絶対我慢してAnimatorController使ったほうがマシでしょ、と思っていたので「これがあればAnimatorControllerとはおさらば!」という部分だけがやたら強調されて喧伝されていたPlayable APIには今まで興味がなかったのですが、今回使ってみたらちゃんと役に立ったのでご報告。

Animation C# Jobs をつかった独自アニメーションブレンドの実装例として参考にしてもらえるかと。

課題:上半身に武器構えモーションをブレンドするとよたよたする

よたよた…

普通にAvatarMaskで上半身のボーンだけを指定して武器構えのモーションのブレンドしつつ歩いたり走ったりすると、着ぐるみを着慣れてない人が中に入ってる着ぐるみ、みたいなモーションになってしまいます。

Unreal Engineであればブレンドノードに Mesh Space Rotation Blend というオプションがあるので標準の機能だけでこれを解決できるんですが、残念ながらUnityのアニメーションブレンド機能にはこれがありません。

Unreal Engineのブレンドノード

解決後: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を評価した結果のワールド空間での回転で置き換える例

WorldSpaceBlender.cs
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) { }
}

Gist

WorldSpaceRotationRecordingJobreferenceAnimatorController を評価した結果のワールド座標系での回転値を rotations 配列に保存し、その後 WorldSpaceBlendingJob が共有している rotations 配列の内容を使ってメインのAnimatorControllerの結果に対してAvatarMaskで指定された対象だけブレンドした結果で置き換える、というようなことをやっています。

使用方法

まず上のコンポーネントをAnimatorと同じ階層に貼ります。

本来、weight は必要な時に1になり不要なときは0になるようにスクリプトで制御してもらうものなんですが、テストの段階では手動で1にして常に作動させてしまえばよいでしょう。

targetMaskには、今回であれば上半身の根元のボーンだけを含んだAvatarMaskを作ってそれを指定します。

targetMask

referenceAnimatorControllerには、ベースとなるAnimatorControllerを複製して、上半身へのブレンドレイヤーだけを残したAnimatorControllerを作ってそれを指定してやります

オリジナル
referenceAnimatorController

オリジナルからコピーしたので上半身モーションレイヤーには上半身だけのAvatarMaskが設定されていると思うんですが、このままだと

スクリーンショット 2025-07-06 150955.png

スクリーンショット 2025-07-06 150914.png

このようにおかしな姿勢になってしまうと思います。これはAvatarMaskがwaistより上の階層しか含んでいないせいでwaistボーンのワールド座標がちゃんと求まらないからです。なので、referenceAnimatorControllerに渡す上半身モーションだけのAnimatorControllerの各レイヤーのAvatarMaskは設定を解除してNoneにするか、目的のボーンまでの階層を含む逆マスクを作って指定してやる必要があります。

スクリーンショット 2025-07-06 151614.png

これを作ってる最中はまった箇所

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だと入力ノードの評価をそもそもスキップする、ということもしないで普通に評価されるようです。

TraversalModePlayableTraversalMode.Mix (デフォルト。なので上記コードにはSetTraversalModeで明示している箇所はなし) である場合、後続の入力で上書きされた結果がノードに流れてくるようで、

        _blendingScriptPlayable.AddInput(recordingScriptPlayable, 0);
        _blendingScriptPlayable.AddInput(_mainPlayableController, 0);

こう書いた場合recordingScriptPlayableの出力(上半身レイヤーブレンド用のアニメーション)を _mainPlayableController の出力で上書きされたものが AnimationStream streamとして
WorldSpaceBlendingJob.ProcessAnimation に渡されてくるようです。

この入力の依存関係によりWorldSpaceBlendingJobより先にWorldSpaceRotationRecordingJobが実行されているので NativeArray<Quaternion> rotationsにはちゃんと有効な値が入っていることが保証されるようです。

ちなみにTraversalModePlayableTraversalMode.Passthrough(デフォルトではない、もう一個のモード)にしてみたところ、これは「最初の有効な入力だけを受け取り他を無視する」というモードだそうで、入力1のrecordingScriptPlayableの結果、つまり上半身レイヤーブレンド用のアニメーションがそのままWorldSpaceBlendingJobにも流れてきて、_mainPlayableControllerで再生されているであろう歩きモーションなどは全部無視されて武器構えモーションだけが最終結果として表示されました。

なるほどね…でもいつ使うんだろ(どういうときに便利なのかさっぱりわからない)

ウェイトが0だったらreferenceAnimatorControllerの評価自体無駄なのだからスキップしたい、と思うわけなんですがこれはウェイト値の設定では実現できず、本当にグラフの接続を切るという方法でしか実現できないようでした。

面倒だったので今回は必要な時だけグラフに接続してそうでないなら切る、ということしていません。(代わりに_recordingScriptPlayable.SetProcessInputs(weight > 0f)とはしてみました)

PlayableAPIでUnreal EngineのAnimation Blueprintのまねごとをしようとした

せっかくPlayableAPIを使うのだから、と、いきなり上半身ブレンドと加算ブレンドをメインのAnimationControllerから追い出して別AnimationController化し三つのAnimationControllerPlayableを数珠繋ぎするといった構成をPlayableGraph上で実現しようとしたのですが、これが大きな間違いでした。

_mainPlayableControllerの出力に加算ブレンド用AnimationControllerの評価結果を AnimationLayerMixerPlayable で加算ブレンドするようにしてみたんですが、どうやればうまくいくのかさっぱりわからず。

スクリーンショット 2025-07-06 164547.png

武器の表示のオンオフはアニメーションカーブで武器を持ってるアニメーションが再生されてる間1になることで表示され、それ以外のアニメーションでは0になるので消える、という仕組みで実現していたのですが、

スクリーンショット 2025-07-06 164719.png

これがあらかじめAnimatorに設定されていたAnimatorControllerではないAnimatorControllerから製作したAnimatorControllerPlayableでは、グラフの評価がされてもAniamtorのAnimationParameterに反映されないようで、武器構えモーションレイヤーを完全に別AnimatorControllerに独立させてしまうと(トリガーやほかのAnimationParametorの読み込みには支障がないので)アニメーション自体は正しく再生されていても武器が出てこない、という状態になり、これを解決できませんでした。

AniamtionClipPlayableではAnimatorCurveも正しく反映されるようなので、AnimatorControllerを使わずLayerMixerやBlendTreeや状態遷移なども全部再現したPlayableGraphをスクリプトで構築すればよい、ということらしんですが、大変すぎてとてもやる気にならず。

オーサリングツールが公式に出てこないことにはPlayableAPIでAnimation Blueprintと同等のことができる、とは考えないほうがよさそうでした。

一応 PlayableGraph Visualizer も導入してみたんですが、スクロールも拡縮もできないのでClipが500ほどあるグラフに対しては全く無意味でした。

スクリーンショット 2025-07-06 170640.png

最初からAnimatorControllerPlayableベースでやることを考えている人には、役に立たないので導入しなくてよさそうです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?