Unity Timeline の拡張パターン

Unity の Timeline はスクリプトで拡張できるのも良いところですが、その度合いによってさまざまな方法があります。そこで、できることとできないことを明示しながらそれぞれの方法をまとめます。

Unity 公式記事とそっくりにも見えますが、公式記事のシンタックスハイライトが最近効いていなくてちょっと読みづらいこと、現在のバージョンでは通じない部分があること、各構成のデメリットを示すことで、少なくとも自分が開発する際の資料としてより有用なものとするために筆を取りました。

環境

短いまとめ

構成 こんな時に
構成 1: ITimeControl クリップの出入りと時間さえわかれば良い時
構成 2: PlayableBehaviour と PlayableAsset クリップの Inspector で値を変更したい時
構成 3: 構成 2 に TrackAsset を加える 構成 2 に加えて: 1つのコンポーネントに複数のクリップで操作をしたい時
構成 4: 構成 3 にミキサーを加える 構成 3 に加えて: クリップのブレンディングや初期値の復元を行いたい時
構成 5: PlayableBehaviour をテンプレート化する Timeline 上でアニメーションを作りたい時

以下でそれぞれの構成について、実装を含めて詳しく見ていきます。

構成 1: ITimeControl

最もシンプルな方法です。ITimeControl を継承した MonoBehaviour を用意し、Timeline の Control Track に配置します。

できること

クリップの開始、再生中、終了のコールバックを受け取ることができます。実装もシンプルです。

できないこと

クリップの Inspector を拡張することはできません。Timeline 上でパラメータを変えたい場合は選択肢から外れます。

上のキャプチャは次のようなコードで動いています。

using TMPro;
using UnityEngine;
using UnityEngine.Timeline;

public class TimeTextSetter : MonoBehaviour, ITimeControl
{
    [SerializeField]
    TMP_Text tmp;

    public void SetTime(double time)
    {
        tmp.text = time.ToString(@"F2");
    }

    public void OnControlTimeStart()
    {
        gameObject.SetActive(true);
    }

    public void OnControlTimeStop()
    {
        gameObject.SetActive(false);
    }
}

構成 2: PlayableBehaviour と PlayableAsset

クリップの Inspector に値や参照を露出させることができるようになります。 PlayableBehaviour に振る舞いを書き、PlayableAsset から値や参照を注入します。

できること

クリップ毎に Inspector でパラメータを操作できます。また、利用できるコールバックが多く、細かい制御が行えます。

できないこと

シーン上の単一のコンポーネントを複数のクリップから操作する場合には向いていません。その場合は構成 3 を検討します。次のようなデメリットがあるからです。

上のキャプチャは次のようなコードで動いています。PlayableBehaviour は振る舞いを定義するもので、

using TMPro;
using UnityEngine;
using UnityEngine.Playables;

public class TextGradientPlayableBehaviour : PlayableBehaviour
{
    public TMP_Text Target;
    public Color StartColor;
    public Color EndColor;

    public override void OnGraphStop(Playable playable)
    {
        if (!Application.isPlaying)
        {
            // エディタ上でプレビューを解除した時に、初期値に戻す
            // 不要な差分が出づらくなってバージョン管理的に嬉しいです
            Target.color = StartColor;
        }
    }

    public override void PrepareFrame(Playable playable, FrameData info)
    {
        var normalizedTime = (float)(playable.GetTime() / playable.GetDuration());
        Target.color = Color.Lerp(StartColor, EndColor, normalizedTime);
    }
}

PlayableAsset は Timeline 上に置くクリップやその Inspector を定義し、PlayableBehaviour に値や参照を渡すものです。

using TMPro;
using UnityEngine;
using UnityEngine.Playables;

[System.Serializable]
public class TextGradientPlayableAsset : PlayableAsset
{
    [SerializeField, Header("操作対象のテキスト")]
    ExposedReference<TMP_Text> target;

    [SerializeField, Header("フェード設定")]
    Color startColor;

    [SerializeField]
    Color endColor;

    public override Playable CreatePlayable(PlayableGraph graph, GameObject go)
    {
        var behaviour = new TextGradientPlayableBehaviour
        {
            Target = target.Resolve(graph.GetResolver()),
            StartColor = startColor,
            EndColor = endColor,
        };

        return ScriptPlayable<TextGradientPlayableBehaviour>.Create(graph, behaviour);
    }
}

構成 3: 構成 2 に TrackAsset を加える

トラックに刺した参照を PlayableBehaviour から取得できます。

できること

トラックに参照を刺したり、配置できるクリップの種類、帯の色を指定できます。これにより、トラックの見た目のわかりやすさも向上します。1 つのコンポーネントをたくさんのクリップで操作したい場合に選択肢となります。

できないこと

構成 2 と同じく、トラック全体について 1 度のコールバックを受け取ること。そのため、プレビュー終了時に初期値を復元することが難しいか不可能になります。この理由で、個人的には構成 4 にせざるを得ないことが多いと考えています。

TrackAsset を継承して、カスタムトラックを定義します。

using TMPro;
using UnityEngine.Timeline;

[TrackClipType(typeof(TextGradientPlayableAsset))]
[TrackBindingType(typeof(TMP_Text))]
[TrackColor(0.7f, 0.2f, 0.7f)]
public class TextGradientTrack : TrackAsset { }

トラックにバインドしたコンポーネントは、ProcessFrame で取得します。

using TMPro;
using UnityEngine;
using UnityEngine.Playables;

public class TextGradientPlayableBehaviour : PlayableBehaviour
{
    TMP_Text target; // private に変更

    public Color StartColor;
    public Color EndColor;

    // OnGraphStop での初期値復元は、最後のトラックが適用されてしまい、目的を達成できないので削除

    // バインドされたコンポーネントを取得するために playerData が必要なので PrepareFrame を ProcessFrame に
    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        target = playerData as TMP_Text;

        if (!target) return;

        var normalizedTime = (float)(playable.GetTime() / playable.GetDuration());
        target.color = Color.Lerp(StartColor, EndColor, normalizedTime);
    }
}

次のコードでは、TrackAsset から刺すことになったので TMP_Text を削除します。このサンプルではその箇所をコメントアウトで示しています。

// using TMPro;

using UnityEngine;
using UnityEngine.Playables;

[System.Serializable]
public class TextGradientPlayableAsset : PlayableAsset
{
    // [SerializeField, Header("操作対象のテキスト")]
    // ExposedReference<TMP_Text> target;

    [SerializeField, Header("フェード設定")]
    Color startColor;

    [SerializeField]
    Color endColor;

    public override Playable CreatePlayable(PlayableGraph graph, GameObject go)
    {
        var behaviour = new TextGradientPlayableBehaviour
        {
            // Target = target.Resolve(graph.GetResolver()),
            StartColor = startColor,
            EndColor = endColor,
        };

        return ScriptPlayable<TextGradientPlayableBehaviour>.Create(graph, behaviour);
    }
}

構成 4: 構成 3 にミキサーを加える

できること

トラック全体に対して 1 回だけ呼び出されるコールバックを受け取ったり、クリップのブレンディングができます。

できないこと

Timeline 上でキーフレームを使って値をアニメートすることはできません。その場合は構成 5 を検討します。

TrackAsset でミキサーとなるスクリプトを指定することで、対象のスクリプトがミキサーとして動作するようになります。

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

[TrackClipType(typeof(TextGradientPlayableAsset))]
[TrackBindingType(typeof(TMP_Text))]
[TrackColor(0.7f, 0.2f, 0.7f)]
public class TextGradientTrack : TrackAsset
{
    public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
    {
        return ScriptPlayable<TextGradientMixerBehaviour>.Create(graph, inputCount);
    }
}

そしてミキサーの本体は以下です。これも PlayableBehaviour を継承しており、各種コールバックを使用できます。そのため、初期値の復元が可能になります。

using TMPro;
using UnityEngine;
using UnityEngine.Playables;

public class TextGradientMixerBehaviour : PlayableBehaviour
{
    Color? initialColor;
    TMP_Text tmp;

    // エディタでプレビュー終了時に初期値を復元
    public override void OnGraphStop(Playable playable)
    {
        if (initialColor.HasValue)
        {
            tmp.color = initialColor.Value;
        }
    }

    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        tmp = playerData as TMP_Text;
        if (!tmp) return;

        var clipCount = playable.GetInputCount();
        var color = new Color();

        // エディタで初期値を復元するために、初期値を保全
        var firstClip = (ScriptPlayable<TextGradientPlayableBehaviour>)playable.GetInput(0);
        var firstBehaviour = firstClip.GetBehaviour();
        initialColor = firstBehaviour.StartColor;

        // クリップがないタイミングで無色が適用されてしまうことを防ぐ
        var weightSum = CalculateWeightSum(playable);
        if (weightSum == 0f) return;

        // クリップをミックスして色を作成
        for (var i = 0; i < clipCount; i++)
        {
            var clipWeight = playable.GetInputWeight(i);
            var clipPlayable = (ScriptPlayable<TextGradientPlayableBehaviour>)playable.GetInput(i);
            var behaviour = clipPlayable.GetBehaviour();

            var normalizedTime = (float)(clipPlayable.GetTime() / clipPlayable.GetDuration());
            var currentClipColor = Color.Lerp(behaviour.StartColor, behaviour.EndColor, normalizedTime);
            color += currentClipColor * clipWeight;
        }

        // 色を適用
        tmp.color = color;
    }

    float CalculateWeightSum(Playable playable)
    {
        var sum = 0f;

        var clipCount = playable.GetInputCount();
        for (var i = 0; i < clipCount; i++)
        {
            sum += playable.GetInputWeight(i);
        }

        return sum;
    }
}

反対に、元々の PlayableBehaviour は値を持つだけのコンポーネントになります。

using UnityEngine;
using UnityEngine.Playables;

public class TextGradientPlayableBehaviour : PlayableBehaviour
{
    public Color StartColor;
    public Color EndColor;
}

PlayableAsset は変更されません。

using UnityEngine;
using UnityEngine.Playables;

[System.Serializable]
public class TextGradientPlayableAsset : PlayableAsset
{
    [SerializeField, Header("フェード設定")]
    Color startColor;

    [SerializeField]
    Color endColor;

    public override Playable CreatePlayable(PlayableGraph graph, GameObject go)
    {
        var behaviour = new TextGradientPlayableBehaviour
        {
            StartColor = startColor,
            EndColor = endColor,
        };

        return ScriptPlayable<TextGradientPlayableBehaviour>.Create(graph, behaviour);
    }
}

構成 5: PlayableBehaviour をテンプレート化する

Timeline 上で、アニメーションを使ってパラメータを調整できるようになります。
公式の記事で十分なのでそちらを参照して下さい。

参考資料

2024年2月23日