えんじにあ雑記!

開発していて学んだことをまとめていきます!

ポーズ近似を考慮したモーションブレンドの実装

任意のモーションAからBへの遷移時に、現在ポーズに最も近似するBのモーション位置からブレンドを行う仕組みの実装方法について解説します。

左: 0秒からのクロスフェード 右: ポーズ近似時間からのクロスフェード

使用環境

  • Unity 2022.3.12f1

プロジェクトの作成

キャラクターにはUnityChanを使用します。

ポーズの事前解析

AnimationClipの各フレームでのポーズを事前解析します。

HumanPoseHandlerを作成しAnimationClip.SampleAnimationを呼び出しHumanPoseを介して全マッスル値を保存します。

実行中にキャラクターの現在ポーズを取得し、モーション中で最も近似するポーズの再生時間を取得します。 ポーズの近似判定には各マッスル値の二乗誤差の合計を計算し、最も低い値となるものとします。

using System;
using UnityEngine;
using UnityEngine.UIElements;
# if UNITY_EDITOR
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.UIElements;
#endif

namespace AnimationPerformance.PoseSearchBlend
{
    [CreateAssetMenu(fileName = "AnalyzedAnimation", menuName = "PoseSearchBlend/AnalyzedAnimation")]
    public class AnalyzedAnimation : ScriptableObject
    {
        [Serializable]
        public sealed class PoseAnalyzedData
        {
            [SerializeField]
            public float _Time;

            [SerializeField]
            public float[] _Muscles;
        }
        
        [SerializeField]
        public AnimationClip _AnimationClip;

        [SerializeField]
        public PoseAnalyzedData[] _PoseAnalyzedDataArray;

        public float MatchTime(ref HumanPose humanPose)
        {
            float[] muscles = humanPose.muscles;
            int minimumCostIndex = 0;
            float minimumCost = float.MaxValue;
            for (var i = 0; i < _PoseAnalyzedDataArray.Length; i++)
            {
                var data = _PoseAnalyzedDataArray[i];
                float cost = 0f;
                for (int muscleIndex = 0; muscleIndex < muscles.Length; muscleIndex++)
                {
                    float diff = muscles[muscleIndex] - data._Muscles[muscleIndex];
                    cost += diff * diff;
                }

                if (cost < minimumCost)
                {
                    minimumCost = cost;
                    minimumCostIndex = i;
                }
            }

            Debug.Log($"最も近似したポーズへの遷移時間: {_PoseAnalyzedDataArray[minimumCostIndex]._Time}sec");
            return _PoseAnalyzedDataArray[minimumCostIndex]._Time;
        }
    }
    
#if UNITY_EDITOR
    [CustomEditor(typeof(AnalyzedAnimation))]
    public sealed class AnalyzedAnimationInspectorView : Editor
    {
        private ObjectField _animatorObjectField;
        
        public override VisualElement CreateInspectorGUI()
        {
            var root = new VisualElement();
            InspectorElement.FillDefaultInspector(root, serializedObject, this);

            _animatorObjectField = new("Animator Object")
            {
                objectType = typeof(GameObject), allowSceneObjects = true,
                value = GameObject.Find("AnalyzeAnimatorObject")
            };
            Button analyzedButton = new() {text = "Analyze"};
            analyzedButton.clicked += OnClickAnalyzeButton;

            root.Add(_animatorObjectField);
            root.Add(analyzedButton);
            return root;
        }

        private void OnClickAnalyzeButton()
        {
            var animatorObject = _animatorObjectField.value as GameObject;
            if (target is not AnalyzedAnimation analyzedAnimation || animatorObject == null)
            {
                return;
            }

            Animator animator = animatorObject.GetComponent<Animator>();
            AnimationClip clip = analyzedAnimation._AnimationClip;
            int totalFrames = Mathf.CeilToInt(clip.length * clip.frameRate);
            HumanPoseHandler humanPoseHandler = new(animator.avatar, animator.avatarRoot);
            HumanPose humanPose = new();
            
            // 全フレームのポーズを事前計算
            List<AnalyzedAnimation.PoseAnalyzedData> poseAnalyzedDataList = new();
            for (int frame = 0; frame < totalFrames; frame++)
            {
                float time = frame / clip.frameRate;
                clip.SampleAnimation(animatorObject, time);
                humanPoseHandler.GetHumanPose(ref humanPose);
                poseAnalyzedDataList.Add(new() {_Time = time, _Muscles = (float[])humanPose.muscles.Clone()});
            }
            analyzedAnimation._PoseAnalyzedDataArray = poseAnalyzedDataList.ToArray();
            
            EditorUtility.SetDirty(target);
            AssetDatabase.SaveAssetIfDirty(target);
            AssetDatabase.Refresh();
        }
    }
#endif
}

アニメーション制御

上記の事前解析アセットが実装出来たら、その情報をもとにアニメーション制御を実装します。 今回はPlayable APIを利用して実装します。 遷移先のアニメーションの0秒から遷移を開始するDefaultMotionBlendControllerと、遷移先モーションの再生起点時間を近似ポーズの位置にするPoseSearchMotionBlendControllerを実装し、動作を比較します。 それぞれの実装は以下に記載している通りになります。検証のため、遷移時間は任意の設定値に出来るように実装しています。

DefaultMotionBlendController.cs

using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;

namespace AnimationPerformance.PoseSearchBlend
{
    public class DefaultMotionBlendController : IMotionBlendController
    {
        private readonly Animator _animator;
        private readonly AnimationClip[] _animationClips;
        private PlayableGraph _graph;
        private AnimationMixerPlayable _mixerPlayable;
        private int _activeInputIndex;
        private float _playingTime1 = 0f;
        private float _playingTime2 = 0f;
        
        public DefaultMotionBlendController(Animator animator, AnimationClip[] animationClips)
        {
            _animator = animator;
            _animationClips = animationClips;
        }
        
        public void Build()
        {
            _graph = PlayableGraph.Create("DefaultMotionBlendController");
            _graph.SetTimeUpdateMode(DirectorUpdateMode.Manual);
            
            var output = AnimationPlayableOutput.Create(_graph, "Animation", _animator);

            _mixerPlayable = AnimationMixerPlayable.Create(_graph, 2);
            output.SetSourcePlayable(_mixerPlayable);
            
            // 1st motion
            AnimationClipPlayable clip1Playable = AnimationClipPlayable.Create(_graph, _animationClips[0]);
            _mixerPlayable.ConnectInput(0, clip1Playable, 0, 1.0f);
            _activeInputIndex = 0;
            
            _graph.Play();
        }

        public void Dispose()
        {
            if (_graph.IsValid())
            {
                _graph.Destroy();
            }
        }

        public void OnUpdate(float deltaTime)
        {
            // Weight更新
            float weight1 = _mixerPlayable.GetInputWeight(0);
            float weight2 = _mixerPlayable.GetInputWeight(1);
            weight1 = _activeInputIndex == 0 ? Mathf.Clamp01(weight1 + deltaTime / PoseSearchBlendScene.BlendTime) : Mathf.Clamp01(weight1 - deltaTime / PoseSearchBlendScene.BlendTime);
            weight2 = 1 - weight1;
            _mixerPlayable.SetInputWeight(0, weight1);
            _mixerPlayable.SetInputWeight(1, weight2);
            
            // 時間更新
            _playingTime1 += deltaTime;
            _playingTime2 += deltaTime;
            var clip1Playable = _mixerPlayable.GetInput(0);
            if (clip1Playable.IsValid())
            {
                clip1Playable.SetTime(_playingTime1);
            }
            
            var clip2Playable = _mixerPlayable.GetInput(1);
            if (clip2Playable.IsValid())
            {
                clip2Playable.SetTime(_playingTime2);
            }

            _graph.Evaluate();
        }

        public void OnLateUpdate(float deltaTime)
        {
        }

        public void ChangeMotionTo(int index)
        {
            int nextInputIndex = _activeInputIndex == 0 ? 1 : 0;
            var swapPlayable = _mixerPlayable.GetInput(nextInputIndex);
            if (swapPlayable.IsValid())
            {
                _graph.DestroyPlayable(swapPlayable);
            }
            
            AnimationClipPlayable clipPlayable = AnimationClipPlayable.Create(_graph, _animationClips[index]);
            _mixerPlayable.ConnectInput(nextInputIndex, clipPlayable, 0, 0f);
            _activeInputIndex = nextInputIndex;
            if (_activeInputIndex == 0)
            {
                _playingTime1 = 0f;
            }
            else
            {
                _playingTime2 = 0f;
            }
        }
    }
}

PoseSearchMotionBlendController.cs

using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;

namespace AnimationPerformance.PoseSearchBlend
{
    public class PoseSearchMotionBlendController : IMotionBlendController
    {
        private readonly Animator _animator;
        private readonly AnalyzedAnimation[] _analyzedAnimationClips;
        private readonly HumanPoseHandler _humanPoseHandler;
        private PlayableGraph _graph;
        private AnimationMixerPlayable _mixerPlayable;
        private int _activeInputIndex;
        private float _playingTime1 = 0f;
        private float _playingTime2 = 0f;
        private HumanPose _humanPose;
        
        public PoseSearchMotionBlendController(Animator animator, AnalyzedAnimation[] analyzedAnimationClips)
        {
            _animator = animator;
            _analyzedAnimationClips = analyzedAnimationClips;
            _humanPoseHandler = new(_animator.avatar, _animator.avatarRoot);
            _humanPose = new();
        }

        public void Build()
        {
            _graph = PlayableGraph.Create("PoseSearchMotionBlendController");
            _graph.SetTimeUpdateMode(DirectorUpdateMode.Manual);
            
            var output = AnimationPlayableOutput.Create(_graph, "Animation", _animator);

            _mixerPlayable = AnimationMixerPlayable.Create(_graph, 2);
            output.SetSourcePlayable(_mixerPlayable);
            
            // 1st motion
            AnimationClipPlayable clip1Playable = AnimationClipPlayable.Create(_graph, _analyzedAnimationClips[0]._AnimationClip);
            _mixerPlayable.ConnectInput(0, clip1Playable, 0, 1.0f);
            _activeInputIndex = 0;
            
            _graph.Play();
        }
        
        public void Dispose()
        {
            if (_graph.IsValid())
            {
                _graph.Destroy();
            }
        }

        public void OnUpdate(float deltaTime)
        {
            // Weight更新
            float weight1 = _mixerPlayable.GetInputWeight(0);
            float weight2 = _mixerPlayable.GetInputWeight(1);
            weight1 = _activeInputIndex == 0 ? Mathf.Clamp01(weight1 + deltaTime / PoseSearchBlendScene.BlendTime) : Mathf.Clamp01(weight1 - deltaTime / PoseSearchBlendScene.BlendTime);
            weight2 = 1 - weight1;
            _mixerPlayable.SetInputWeight(0, weight1);
            _mixerPlayable.SetInputWeight(1, weight2);
            
            // 時間更新
            _playingTime1 += deltaTime;
            _playingTime2 += deltaTime;
            var clip1Playable = _mixerPlayable.GetInput(0);
            if (clip1Playable.IsValid())
            {
                clip1Playable.SetTime(_playingTime1);
            }
            
            var clip2Playable = _mixerPlayable.GetInput(1);
            if (clip2Playable.IsValid())
            {
                clip2Playable.SetTime(_playingTime2);
            }

            _graph.Evaluate();
        }

        public void OnLateUpdate(float deltaTime)
        {
            _humanPoseHandler.GetHumanPose(ref _humanPose);
        }

        public void ChangeMotionTo(int index)
        {
            int nextInputIndex = _activeInputIndex == 0 ? 1 : 0;
            var swapPlayable = _mixerPlayable.GetInput(nextInputIndex);
            if (swapPlayable.IsValid())
            {
                _graph.DestroyPlayable(swapPlayable);
            }
            
            AnimationClipPlayable clipPlayable = AnimationClipPlayable.Create(_graph, _analyzedAnimationClips[index]._AnimationClip);
            _mixerPlayable.ConnectInput(nextInputIndex, clipPlayable, 0, 0f);
            _activeInputIndex = nextInputIndex;
            float startTime = _analyzedAnimationClips[index].MatchTime(ref _humanPose);
            if (_activeInputIndex == 0)
            {
                _playingTime1 = startTime;
            }
            else
            {
                _playingTime2 = startTime;
            }
        }
    }
}

動作比較

左がDefaultMotionBlendController右がPoseSearchMotionBlendControllerになります。 WalkモーションからRunモーションへの遷移時に遷移前のポーズに近似した位置を計算し、Runモーションの再生開始位置をずらす処理が適切に動いているため足がばたつかず遷移出来ていることがわかります。

動作比較

まとめ

モーションマッチングに関心があり考え方を流用して「近似ポーズへの遷移を行えば単純なモーションブレンド時にも違和感を減らせるのでは?」と思い立って検証してみました。 もう少し丁寧にやるなら各マッスルの加速度を計算し、近似するポーズかつ変化方向も類似しているもの、といった条件を組み合わせるとさらに良い見た目になるかもしれませんがそれはまたの機会に。。 (今回の実装では右足を引くのか、前に出すのか、の判断はせず「ポーズが似ている」ことのみを遷移先の判断材料としているのでそのあたりが改良ポイントになるかなと、)