見出し画像

Timelineアニメーションの移動先を外部から指定する(その2、Unityメモ)

その1からの続きです。字数だけ見るとすごいことになっていますが、ほとんどコードの分です。

前回のまとめ:PlayableDirectorにキーと値を設定してデータを渡す

Timeline上でユニットを固定の目標地点ではなく任意のターゲットに向かって動かすため、Transform Tween Trackを拡張します。
やりたいことは以下の通りです。
・動かすGameObject(行動者とかターゲット)とは別に、動きのパラメータとして地点を指すGameObjectを指定
・シーンから切り離されたアセットに対してシーン上のオブジェクトを与える
・実行時にその都度シーン上のオブジェクトを与える

そのためにPlayableDirectorにキーと値を設定する方法をとり、PlayableDirectorを介してデータを渡すことにしました。

Transform Tween Trackの拡張に使った方法(再掲)

テスト用にPlayableDirectorにキーと値を設定するためのコンポーネントを別に実装しました。実際のゲームでは、ゲームシステムがPlayableDirectorにアクセスすることになります。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

// PlayableDirectorが保持するExposedReferenceのキーと値を外部から設定。
// Clip側でGetReferenceValueすることで、Timeline外部から与えた値を取得できる。
public class ReferenceValueSetter : MonoBehaviour
{
    //テスト用にペア数を固定
    //public Dictionary<PropertyName, Object> RefValue;
    public PropertyName id1;
    public Transform value1;
    public PropertyName id2;
    public Transform value2;

    private PlayableDirector m_Director;

    // Start is called before the first frame update
    void Start()
    {
        m_Director = gameObject.GetComponent<PlayableDirector>();

        //値をセット
        m_Director.SetReferenceValue(id1, value1);
        m_Director.SetReferenceValue(id2, value2);
    }
}

Transform Tween Trackを拡張する

Clip, Track, BoundObjectの関係

Transform Tween Trackの拡張では、Timeline上のオブジェクトを扱う必要があります。特にトラック・クリップ・バウンドオブジェクト(Timelineで動かすオブジェクト。Bindされたオブジェクト)の関係を知ることが重要でした。以下の記事内の図がわかりやすかったです。

ClipとそのPlayableBehaviour、TrackとそのPlayableBehaviour

Transform Tween Trackの具体的な拡張方法は、まさに公式のTransform Tween Trackの解説記事を参考にしました。カスタムトラック全般の作り方も順を追ってつかめます。

要するに、ClipとそのPlayableBehaviourを実装し、さらにTrackとそのPlayableBehaviourを実装する、ということです。ClipやTrackはインスタンスの生成を中心に実装し、PlayableBehaviourの方に実際の動きの処理を書きます。いずれもイベントハンドラを実装する形で書きます。
ClipのPlayableBehaviourとTrackのPlayableBehaviourの使い分けですが、1クリップ内で完結する処理はClip用のPlayableBehaviourに書き、クリップのブレンド(モーフィング)など、クリップが重なる場合の処理はTrack用のPlayableBehaviourに書きます。

バウンドオブジェクトの取得

ただ、上の記事にはTimeline側からバウンドオブジェクト(動かすオブジェクト)を取得する方法が載っていないので調べました。

バウンドオブジェクトはTrackやClipの生成メソッド内で取得します。PlayableAsset.CreatePlayable()、TrackAsset.CreateTrackMixer() がそれぞれの生成メソッドです。
生成メソッドの第二引数で与えられるGameObjectがPlayableDirectorの親となるGameObjectとなっています。そこからPlayableDirectorを参照し、PlayableDirector.GetGenericBinding()でバウンドオブジェクトを取得します。トラックが複数ある場合は、トラック名をキーとして指定します。なのでトラック名は規約として決めておく必要があります。
取得したバウンドオブジェクトは、TrackだけでなくPlayableBehaviourのメンバにも定義・設定し、PlayableBehaviourの処理で使用できるようにしておきます。
なお、初期化処理は以下の順で行われます。実装の説明もこの順で行います。
1. TrackのCreateTrackMixer()
2. ClipのCreatePlayable()
3. TrackのPlayableBehaviourのOnGraphStart()
4. ClipのPlayableBehaviourのOnGraphStart() 

Trackの初期化処理

まずTrackの初期化処理の実装です。Track内で生成したPlayableBehaviourに、PlayableDirectorから取得したバウンドオブジェクトを設定します。

using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

/* Timeline外部からGameObjectを基準位置として与えてTweenする。Unityのサンプルから改変
 * https://docs.unity3d.com/Packages/com.unity.timeline@1.8/manual/smpl_custom_tween.html
 */

[TrackBindingType(typeof(Transform))]
[TrackClipType(typeof(InjectionTweenClip))]
public class InjectionTweenTrack : TrackAsset
{
    /*
     * インスタンス生成
     */

    public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
    {
        var playable = ScriptPlayable<InjectionTweenMixerBehaviour>.Create(graph, inputCount);

        // 動かすオブジェクトをtrackBindingを通して取得し、InjectionTweenMixerBehaviourにセットする。
        // https://qiita.com/00riono/items/11b32c537b67fb6dd6c5 を参考に実装
        var director = go.GetComponent<PlayableDirector>();
        if (director is not null)
        {
            var trackBinding = director.GetGenericBinding(this) as Transform;
            playable.GetBehaviour().trackBinding = trackBinding;
        }

        return playable;
    }
}

ClipからのPlayableBehaviourの生成と初期化

Clipオブジェクトの役割はPlayableBehaviourオブジェクトを生成して初期化することと、そのためのパラメータを保持することです。シリアライズもClipのインスタンスを対象とします。Clipの初期化処理は、Clip自体の初期化というよりは、Trackに紐づくPlayableBehaviourを初期化することがメインです。
なお開始地点・終了地点の指定について、当初はキーだけを持たせていたのですが、結局はGameObjectを直接持つExposedReferenceも追加しました。というのも、エディタ編集時にはPlayableDirectorが生成されていないため、キーを介してGameObjectを得ることができません。そのため、エディタ編集時のプレビューではExposedReferenceから地点を指すGameObjectを参照し、ゲーム実行時はキーを使ってPlayableDirectorからオブジェクトを取得するようにしました。

結局はExposedReferenceも持たせることにしたのですが、そうすると規約で決め打ちするIDとは別に、演出データ内部だけで使える途中地点用のGameObjectを自由に追加できるので、そこまで悪くはないのかなと。
コードは以下の通りにしました。

using UnityEngine;
using UnityEngine.Playables;


/* PlayableDirectorはExposedReferenceのキーと値のペアを保持している。
 * あらかじめ外部コンポーネントからPlayableDirectorにIDとGameObjectを登録しておき、
 * Clip生成時に所定のIDを使って対応するGameObjectを得る。
 */

[System.Serializable]
public class InjectionTweenClip : PlayableAsset
{
    /*
     * PlayableBehaviourにパラメータを設定するためのプレースホルダ 
     */

    //基準位置のGameObjectを参照するためのID
    public PropertyName startID;
    public PropertyName endID;

    public bool shouldTweenPosition;
    public bool shouldTweenRotation;
    public bool shouldTweenScale;

    public AnimationCurve curve;

    //Editor時のみ有効
    [SerializeField]
    private ExposedReference<Transform> startRef;
    [SerializeField]
    private ExposedReference<Transform> endRef;


    /*
     * インスタンス生成
     */

    // Factory method that generates a playable based on this asset
    public override Playable CreatePlayable(PlayableGraph graph, GameObject go)
    {
        // create a new behaviour
        var playable = ScriptPlayable<InjectionTweenBehaviour>.Create(graph);
        var tween = playable.GetBehaviour();

        tween.startID = startID;
        tween.endID = endID;
        // ExposedReferenceを直接読む。Editor時のみ有効
        tween.startLocation = startRef.Resolve(graph.GetResolver());
        tween.endLocation = endRef.Resolve(graph.GetResolver());

        // set the behaviour member
        tween.shouldTweenPosition = shouldTweenPosition;
        tween.shouldTweenRotation = shouldTweenRotation;
        tween.shouldTweenScale = shouldTweenScale;

        tween.curve = curve;

        return playable;
    }
}

TrackのPlayableBehaviourの処理

OnGraphStart()で初期化処理をし、PrepareFrame()でフレームごとの更新処理、主にクリップ間のブレンド処理を行います。(開始地点と終了地点の間のブレンドはClip側で実装、後述)
OnGraphStop()の処理ですが、プレビュー後に初期位置に戻しています。特にプレビュー時に手でカーソルを動かした後にゲームモードで再生すると、そのままだと再生時にあった位置で初期位置を上書きされてしまうため、OnGraphStopの中で初期位置を直接再設定しています。TrackオブジェクトでのGatherProperties()も試してみたのですが、あまり上手く動かなかったので上のコードからは外しています。
クリップ間のブレンドは単純に重みの線形和、つまり線形補間にしています。ブレンドカーブを適用できると演出の幅が広がりそう。
ちなみに、回転のブレンドはクォータニオンを回転軸と回転量に変換して普通の線形補間をしています。クリップごとに反復して加算できるような球面線形補間の計算方法がなさそうなので…

using UnityEngine;
using UnityEngine.Playables;


// A behaviour that is attached to a playable
public class InjectionTweenMixerBehaviour : PlayableBehaviour
{
    public Transform trackBinding { get; set; }  //動かすオブジェクトをTrackからセットする

    private Vector3 init_pos;
    private Vector3 init_scale;
    private Quaternion init_rot;
    private float init_rot_angle; //クォータニオンを線形補間するための一時変数
    private Vector3 init_rot_axis; //クォータニオンを線形補間するための一時変数

    // Called when the owning graph starts playing
    public override void OnGraphStart(Playable playable)
    {
        //初期値を設定
        init_pos = trackBinding.localPosition;
        init_scale = trackBinding.localScale;
        init_rot = trackBinding.localRotation;
        init_rot.ToAngleAxis(out init_rot_angle, out init_rot_axis);

        //Track上の全Clipについて補間
        int inputCount = playable.GetInputCount(); //Clipの個数
        for (int i = 0; i < inputCount; i++)
        {
            // get the input connected to the mixer
            // Playableは構造体のため、as演算子は使えない
            ScriptPlayable<InjectionTweenBehaviour> input = (ScriptPlayable<InjectionTweenBehaviour>)playable.GetInput(i);
            InjectionTweenBehaviour tweenInput = input.GetBehaviour();
            if (tweenInput is null) continue;

            tweenInput.trackBinding = trackBinding;
        }

    }

    // Called when the owning graph stops playing
    public override void OnGraphStop(Playable playable)
    {
        //初期値に戻す
#if UNITY_EDITOR
        trackBinding.localPosition = init_pos;
        trackBinding.localScale = init_scale;
        trackBinding.localRotation = init_rot;
#endif
    }

    // Called each frame while the state is set to Play
    // TrackのPlayableBehaviourの場合、playableはTrackを表す
    public override void PrepareFrame(Playable playable, FrameData info)
    {
        Vector3 diff_pos = Vector3.zero;
        Vector3 diff_scale = Vector3.zero;
        //クォータニオンを線形補間するための一時変数
        float diff_rot_angle = 0.0f;
        Vector3 diff_rot_axis = Vector3.zero;

        //Track上の全Clipについて補間
        int inputCount = playable.GetInputCount(); //Clipの個数
        for (int i = 0; i < inputCount; i++)
        {
            // get the input connected to the mixer
            // Playableは構造体のため、as演算子は使えない
            ScriptPlayable<InjectionTweenBehaviour> input = (ScriptPlayable<InjectionTweenBehaviour>)playable.GetInput(i);

            // get the weight of the connection
            // Clip同士でブレンドされる場合は、それぞれのClipの重みが(0, 1)の間の値になる。またClip間のinputWeightの総和は1.0
            float inputWeight = playable.GetInputWeight(i);
            if (inputWeight <= 0.0f) continue;

            // get the clip's behaviour
            InjectionTweenBehaviour tweenInput = input.GetBehaviour();
            if (tweenInput is null) continue;

            // クリップの内部状態に対して重みをかける
            if (tweenInput.shouldTweenPosition)
            {
                diff_pos += inputWeight * tweenInput.diff_pos;
            }
            if (tweenInput.shouldTweenScale)
            {
                diff_scale += inputWeight * tweenInput.diff_scale;
            }
            if (tweenInput.shouldTweenRotation)
            {
                //クリップ間の重みをかけて加算=線形補間(lerp)
                //TODO: 逐次的に球面線形補間(slerp)できる方法があれば採用
                float lerp_rot_angle;
                Vector3 lerp_rot_axis;
                tweenInput.diff_rot.ToAngleAxis(out lerp_rot_angle, out lerp_rot_axis);
                diff_rot_angle += inputWeight * lerp_rot_angle;
                diff_rot_axis += inputWeight * lerp_rot_axis;
            }
        }
        //動かすオブジェクトの位置を変更
        trackBinding.localPosition = init_pos + diff_pos;
        trackBinding.localScale = init_scale + diff_scale;
        trackBinding.localRotation = Quaternion.AngleAxis(diff_rot_angle, diff_rot_axis);
    }
}

ClipのPlayableBehaviourの処理

オブジェクトの初期化処理や、1クリップ内で完結するフレームごとの処理を書きます。開始地点と終了地点の間のTransformのブレンドはこのオブジェクトで行います。
初期化処理では、Clipから与えられたIDを使って対応するGameObjectを取得し、始点と終点の情報を設定します。
開始地点と終了地点とのブレンドは線形補間にしています。カーブに対応する場合はここにカーブを適用します。ここでの回転の補間は球面線形補間を使っています。

using UnityEngine;
using UnityEngine.Playables;


// A behaviour that is attached to a playable
public class InjectionTweenBehaviour : PlayableBehaviour
{
    public Transform startLocation;
    public Transform endLocation;
    public PropertyName startID;
    public PropertyName endID;
    public Transform trackBinding { get; set; }  //動かすオブジェクトをTrackからセットする

    public bool shouldTweenPosition;
    public bool shouldTweenRotation;
    public bool shouldTweenScale;

    public AnimationCurve curve;

    //Timeline再生時の変化量
    public Vector3 diff_pos { get; private set; } = Vector3.zero;
    public Vector3 diff_scale { get; private set; } = Vector3.zero;
    public Quaternion diff_rot { get; private set; } = Quaternion.identity;

    //開始地点・終了地点
    Vector3 start_pos = Vector3.zero;
    Vector3 end_pos = Vector3.zero;
    Vector3 start_scale = Vector3.zero;
    Vector3 end_scale = Vector3.zero;
    Quaternion start_rot = Quaternion.identity;
    Quaternion end_rot = Quaternion.identity;

    // Behaviourの生成時にLocationを取得。ClipのCreatePlayableのタイミングでは早すぎる
    // Called when the owning graph starts playing
    public override void OnGraphStart(Playable playable)
    {
        var graph = playable.GetGraph();

        // IDに対応するGameObjectを得る
        bool startIsValid;
        Transform start = graph.GetResolver().GetReferenceValue(startID, out startIsValid) as Transform;
        if (startIsValid)
        {
            startLocation = start;
        }

        bool endIsValid;
        Transform end = graph.GetResolver().GetReferenceValue(endID, out endIsValid) as Transform;
        if (endIsValid)
        {
            endLocation = end;
        }

        //始点と終点を設定
        start_pos = (startLocation is null) ? Vector3.zero : startLocation.localPosition - trackBinding.localPosition;
        end_pos = (endLocation is null) ? Vector3.zero : endLocation.localPosition - trackBinding.localPosition;
        start_scale = (startLocation is null) ? Vector3.zero : startLocation.localScale - trackBinding.localScale;
        end_scale = (endLocation is null) ? Vector3.zero : endLocation.localScale - trackBinding.localScale;
        start_rot = (startLocation is null) ? trackBinding.localRotation : startLocation.localRotation;
        end_rot = (endLocation is null) ? trackBinding.localRotation : endLocation.localRotation;
    }

    // Called each frame while the state is set to Play
    // ClipのPlayableBehaviourの場合、playableはClipを表す
    public override void PrepareFrame(Playable playable, FrameData info)
    {
        // フレーム内の進捗に従って線形補間
        // TODO:カーブを適用
        float progress = (float)(playable.GetTime() / playable.GetDuration());
        if (shouldTweenPosition)
        {
            diff_pos = Vector3.Lerp(start_pos, end_pos, progress);
        }
        if (shouldTweenScale)
        {
            diff_scale = Vector3.Lerp(start_scale, end_scale, progress);
        }
        if (shouldTweenRotation)
        {
            //クォータニオンをフレーム内の進捗に従って球面線形補間(slerp)
            diff_rot = Quaternion.Slerp(start_rot, end_rot, progress);
        }
    }
}

画像を動かしてみる

テスト用に立ち絵を動かしました。画像はタイムラインエディタのプレビューですが、ゲーム実行時も同様に動きました。元々プレビューで動作確認で
きることが目標だったのでこれで一応OKです。

立ち絵の初期位置と地点を指すGameObject。移動・変形用のTransformを設定
1つ目のクリップの開始。開始地点と終了地点を両方とも指定
1つ目のクリップの終了地点付近。移動しながら絵が反転し、縮尺も変化
2つ目のクリップの開始。開始地点は指定なし、終了地点にStartのオブジェクトを指定。初期位置→Startに向けて移動
2つ目のクリップから3つ目のクリップへ変化中
3つ目のクリップへ移行。開始地点にEndのオブジェクトを指定し、終了地点は指定なし。End→初期位置に向けて移動

2つ目のクリップと3つ目のクリップは途中でブレンドしています。この場合、前のクリップの中のモーフィングと次のクリップ内のモーフィングがさらにモーフィングされるので、複雑な動きになっています。

このテストでは地点をStart,Endだけ指定しましたが、2点に限らずPlayableDirectorに登録できるので、キーと地点のオブジェクトの情報はDictionaryで持たせるのが良いかもしれません。その辺はエフェクト演出を組んでみて決めようと思います。

この記事が気に入ったらサポートをしてみませんか?