【Unity】Playables APIを使ってUnity非再生時にモーションをプレビューする

Playables APIを使ってUnity非再生時にモーションをプレビューする方法です。

Unity2018.4.0

やりたいことと問題点

UnityでAnimationの再生を非ランタイムで行いたいとします。
Particle Systemのシミュレーションのようなイメージです。

f:id:halya_11:20190607161919p:plain

これが意外と面倒で、やりたい事次第ではTimelineで解決出来たりはしますが、
自分で実装する必要があればPlayables APIを使う必要があります。

またその場合、下記の記事のように保存時にPlayable Graphが消える問題などにも対策を打たないといけません。

light11.hatenadiary.com

今回はこれらを解決して非ランタイムでモーションのシミュレーションができるコンポーネントを作ってみます。

PlayableBehaviourを作る

まずPlayableBehaviourを継承したクラスを作ります。

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

public class AnimationClipPlayableBehaviour : PlayableBehaviour
{
    public float Time { get; set; } = 0.0f;

    private AnimationClipPlayable _clipPlayable;
    private PlayableGraph _graph;
    private Playable _parentPlayable;

    public void Setup(Playable scriptPlayable, PlayableGraph graph)
    {
        _graph = graph;
        _parentPlayable = scriptPlayable;
        scriptPlayable.SetInputCount(1);
        scriptPlayable.SetInputWeight(0, 1.0f);
    }

    public void SetClip(AnimationClip clip)
    {
        _clipPlayable = AnimationClipPlayable.Create(_graph, clip);
        if (_parentPlayable.GetInputCount() >= 1) {
            _parentPlayable.DisconnectInput(0);
        }
        _graph.Connect(_clipPlayable, 0, _parentPlayable, 0);
    }

    public override void PrepareFrame(Playable scriptPlayable, FrameData info)
    {
        if (!_clipPlayable.IsValid()) {
            return;
        }
        // info.deltaTimeではなくてTimeを使って処理する
        _clipPlayable.SetTime(Time);
    }
}

ここで、Timeというpublicな変数を用意し、PrepareFrameではPlayableGraphのdeltaTimeではなく
このTimeを使ってAnimationClipを処理している点がポイントです。

引数のFrameDataに入っているdeltaTimeを使えば1Frameずつ進んでいくような処理は実装できるのですが、
特定時間の状態にしたり、タイムラインのシークバーのようなものが実装できません。

そのためTimeを定義して、これを外側から与えられるようにします。
このあたりはTimelineの拡張によく似ています。

light11.hatenadiary.com

Playable Graphを作る

次にPlayable Graphを作って再生を管理するためのクラスを作ります。

#if UNITY_EDITOR
#define IS_EDITOR
#endif
#if IS_EDITOR
using UnityEditor;
#endif
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;

[RequireComponent(typeof(Animator))]
[ExecuteAlways]
public class AnimationPlayer : MonoBehaviour
{
    private float _time = default;
    private bool _isPlaying = false;
    private PlayableGraph _playableGraph;
    private ScriptPlayable<AnimationClipPlayableBehaviour> _playable;
    private AnimationClip _currentClip;

#if IS_EDITOR
    private double _editorTimeSinceStartup;
#endif

    public void Play()
    {
        _isPlaying = true;
    }

    public void Stop()
    {
        _isPlaying = false;
    }

    /// <summary>
    /// 時間を指定して状態を評価する
    /// </summary>
    public void SetTime(float time)
    {
        CreateGraphIfNeeded();

        // 時間が更新されてなかったら処理しない
        if (_time == time) {
            return;
        }

        _time = time;
        _playable.GetBehaviour().Time = time;
        _playableGraph.Evaluate();
    }

    public void SetClip(AnimationClip clip)
    {
        CreateGraphIfNeeded();
        _playable.GetBehaviour().SetClip(clip);
        _currentClip = clip;
    }

    /// <summary>
    /// PlayableGraphが生成されてなかったら生成する
    /// </summary>
    private void CreateGraphIfNeeded()
    {
        if (!_playableGraph.IsValid()) {
            _playableGraph = PlayableGraph.Create("Animation Playable");
            AnimationPlayableOutput.Create(_playableGraph, "Animation", GetComponent<Animator>());
            _playable = ScriptPlayable<AnimationClipPlayableBehaviour>.Create(_playableGraph);
            var behaviour = _playable.GetBehaviour();
            behaviour.Setup(_playable, _playableGraph);
            _playableGraph.GetOutput(0).SetSourcePlayable(_playable);
            _playableGraph.SetTimeUpdateMode(DirectorUpdateMode.Manual);
            _playable.Play();
            if (_currentClip != null) {
                SetClip(_currentClip);
            }
        }
    }

    private void OnEnable()
    {
#if IS_EDITOR
        EditorApplication.update += EditorUpdate;
#endif
    }

    private void OnDisable()
    {
#if IS_EDITOR
        EditorApplication.update -= EditorUpdate;
#endif
    }

    private void Update()
    {
        // 再生時はUpdateで時間を更新
        if (Application.isPlaying) {
            var time = _time;
            if (_isPlaying) {
                time = _time + Time.deltaTime;
            }
            SetTime(time);
        }
    }

#if IS_EDITOR
    private void EditorUpdate()
    {
        // DeltaTimeを計算
        var deltaTime = EditorApplication.timeSinceStartup - _editorTimeSinceStartup;
        _editorTimeSinceStartup = EditorApplication.timeSinceStartup;

        // 非再生時はEditorUpdateで時間を更新
        if (!Application.isPlaying) {
            var time = _time;
            if (_isPlaying) {
                time = _time + (float)deltaTime;
            }
            SetTime(time);
        }
    }
#endif

    public void OnDestroy()
    {
        if (_playableGraph.IsValid()) {
            _playableGraph.Destroy();
        }
    }
}

使い方はAnimationClipをセットしてPlay()Stop()を呼ぶだけです。
自身で時間を指定して更新したい場合にはそれらを呼ばず、SetTime()を呼びます。

Unity非再生時でも正常に動くようにしているのがポイントです。

light11.hatenadiary.com

使ってみる

それではこのクラスを下記のようなコンポーネントで適当に操作してみます。

#if UNITY_EDITOR
#define IS_EDITOR
#endif
#if IS_EDITOR
using UnityEditor;
#endif
using UnityEngine;

[ExecuteAlways]
public class Example : MonoBehaviour
{
    [SerializeField]
    private AnimationPlayer _player;
    [SerializeField]
    private float _time;
    [SerializeField]
    private AnimationClip _clip;
    private AnimationClip _currentClip;

    private void Update()
    {
        if (_player != null && Application.isPlaying) {
            _player.SetTime(_time);
            if (_clip != _currentClip) {
                _player.SetClip(_clip);
                _currentClip = _clip;
            }
        }
    }

    private void OnEnable()
    {
#if IS_EDITOR
        EditorApplication.update += EditorUpdate;
#endif
    }

    private void OnDisable()
    {
#if IS_EDITOR
        EditorApplication.update -= EditorUpdate;
#endif
    }

#if IS_EDITOR
    private void EditorUpdate()
    {
        if (_player != null && !Application.isPlaying) {
            _player.SetTime(_time);
            if (_clip != _currentClip) {
                _player.SetClip(_clip);
                _currentClip = _clip;
            }
        }
    }
#endif
}

今回は指定時間のモーションをプレビューできるものを作ってみました。

f:id:halya_11:20190610010724g:plain

正常に動作していることが確認できました。

関連

light11.hatenadiary.com