凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity で Timeline のカスタムトラックおよびクリップを作成して見た目をキレイにしてみた

はじめに

前々回の記事で Unityリップシンクを行うためのアセットである uLipSync のタイムライン対応について紹介しました。

tips.hecomi.com

ここでは次のように現在どのリップシンクデータを喋らせているか分かるように色々装飾しています。

本記事では、カスタム Track および Clip の作り方と、その見た目のカスタマイズの方法について紹介します。それぞれの詳細を解説すると言うよりは、全体の勘所をつかめるような内容を目指しています。

デモ

こんな見た目のカラーをブレンドするタイムラインが作成できるようになります。プロジェクトは以下から取得できます。

github.com

タイムラインのおさらい

タイムラインは Playable API をもとに構築されています。ドキュメントの説明を借りると次のようなものです。Playable API はアニメーションなどにも使われている、複数のデータからの入力をミックス、ブレンド、ときには手を加えて、最終的に 1 つの出力として再生できるという抽象化された仕組みです。タイムラインでは、アニメーション以外にも、オーディオや、オブジェクトの操作、その他時間に関係あるいろいろなものがこの仕組みに落とし込まれて、それをタイムラインアセットという形にして保存・再利用できるようになっています。

このように適度に抽象化されていることもあり、カスタムトラックおよびクリップを作る際の登場人物はちょっと多くて主に 4 人います。ざっくりとはこんな感じです。

  • Track
    • タイムライン上に追加できる行のアセット。中に指定したクリップを配置できる。
  • Clip
    • Track 上に配置できる範囲を持つアセット。
  • Playable Behaviour
    • Clip のふるまい
  • Mixer

見た目的にはトラックとクリップの 2 人かな?と思うと倍がいるので混乱してしまうと思いますが、往々にして、こういうのは説明を見ても良くわからないことが多いので、具体的にひとつずつボトムアップで作成して見ていくことにしましょう。

カスタムトラックとクリップの作成

まずは、とてもシンプルなトラックとクリップを作成する手順を経て、それぞれの役割を理解します。

トラックの作成

まずはトラックを作成していきます。次のようなスクリプトを作成してみましょう。

using UnityEngine.Timeline;

public class CustomTrack : TrackAsset
{
}

すると Timeline 上で左上の + を押下または右クリックをした際に、このトラックが項目として出てきて追加できるようになります(ちなみに名前空間でくくると階層メニューが作れます)。

クリップの作成

次にここに配置できるクリップを作成しましょう。

using UnityEngine;
using UnityEngine.Playables;

public class CustomClip : PlayableAsset
{
    public override Playable CreatePlayable(PlayableGraph graph, GameObject go)
    {
        return ScriptPlayable<CustomBehaviour>.Create(graph);
    }
}

ここでは、CustomBehaviour というこれから作成する PlayableBehaviour 派生クラスを内包した ScriptPlayable という Playable を作っています。ではこの CustomBehaviour を作成しましょう。

using UnityEngine;
using UnityEngine.Playables;

public class CustomBehaviour : PlayableBehaviour
{
    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        Debug.Log($"Time: {playable.GetTime()}");
    }
}

中身は現在の Playable の時間を出力するだけのシンプルなものになっています。

最後に、トラックにこのクリップを追加できるようにします。次の属性をトラックに付けます。

...
[TrackClipType(typeof(CustomClip))]
public class CustomTrack : TrackAsset
{
}

これで準備が整いました。エディタに戻って先ほど作成したカスタムトラック上で右クリックをしてみてください。「Add CustomClip」という項目が追加され、クリップが追加できるようになりました。

これでタイムライン上をなぞってみると、Console に時間が出力されているのが分かると思います。

整理

既に少し分かりづらくなってるかもしれないのでここで一度整理します。まず、Track は指定したクラスの Clip を配置できる箱で、ここは比較的問題ないと思います。Clip は PlayableAsset を継承していることからも分かるようにアセットです。ただ、コードを見ると分かるように Playable を生成する役割も担っています。そして、この Playable で処理する実体が PlayableBehaviour です。

Track と Clip に変数を追加

さて、Track と Clip はアセットと言ったようにこれらには通常の MonoBehaviour と同じように public または [SerializeField] 属性をつけた変数を用意することでインスペクタにシリアライズ可能なフィールドを表示できます。

...
public class CustomTrack : TrackAsset
{
    public float floatValue = 0f;
    public string textValue = "Test";
}

属性もそのまま使えます。

using UnityEngine;
using UnityEngine.Playables;

public class CustomClip : PlayableAsset
{
    [Range(0f, 1f)] public float value = 0f;
    ...
}

ミキサーの作成

次に 4 人目の登場人物の Mixer を見ていきましょう。ただこのままでは何を合成すれば良いか分からない感じなので、サンプルとして分かりやすいように次のようにしてみましょう。

  • 色のブレンドを行う
  • Clip には Gradient をセット
  • Behaviour は現在の時間をもとに Gradient を評価した色を返す

Clip / Behaviour の修正

Clip には Gradient を格納できるようにします。また、PlayableBehaviour 側でこの値を使えるように準備します。

using UnityEngine;
using UnityEngine.Playables;

public class CustomClip : PlayableAsset
{
    public Gradient gradient;

    public override Playable CreatePlayable(PlayableGraph graph, GameObject go)
    {
        var playable = ScriptPlayable<CustomBehaviour>.Create(graph);
        var behaviour = playable.GetBehaviour();
        behaviour.clip = this;
        return playable;
    }
}

ScriptPlayable で内包された PlayableBehaviourGetBehaviour() 経由で取ってこれます。なので、ここで自身を与えてあげたり、必要な情報をセットしてあげたりすることで、ユーザがエディタ上で指定したパラメタを PlayableBehaviour のコードで使える形に持っていくことができます。MonoBehaviour の場合は自身がシリアライズ機能とロジックを兼ね備えていたので分かりやすかったですが、ここが別れているのでちょっと分かりづらいですね。

次に PlayableBehaviour 側を修正します。

using UnityEngine;
using UnityEngine.Playables;

public class CustomBehaviour : PlayableBehaviour
{
    public CustomClip clip { get; set; }
    public Color outputColor { get; private set; }

    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        var t = playable.GetTime();
        var d = playable.GetDuration();
        var a = (float)(t / d);
        outputColor = clip.gradient.Evaluate(a);
    }
}

Clip がもらえたので Gradient も確認できます。先程は詳しい説明を割愛しましたが ProcessFrame() では Playable が渡ってきます。ここに現在再生している時間や、合計の時間といった再生に関する情報が色々含まれています。これらを使い、Gradient から時間経過に応じた色を得て、outputColor に詰めておきます。ちなみに FrameDatadeltaTime や再生スピードといったそのフレーム単位の情報が入ってきます。playerData に関しては後述します。

これで準備完了です。あとは Mixer からこの outputColor を参照し、ブレンドを行いましょう。

Mixer の作成

Mixer は Track から生成しますので、Track のコードを修正しましょう。

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

[TrackClipType(typeof(CustomClip))]
public class CustomTrack : TrackAsset
{
    public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
    {
        return ScriptPlayable<CustomMixer>.Create(graph, inputCount);
    }
}

こちらも Clip のときと同じように ScriptPlayable を生成しています。Playables の世界では Playable たちが PlayableGraph 上でノード接続されて最終的にアウトプットされる形になっており、Mixer は inputCount 個の Clip から生成された Playable をまとめ上げる Playable になっているという感じですね。アニメーションブレンドツリーでも同じように複数のアニメーションをまとめ上げるミキサーがいるのと同じような感じです。

docs.unity3d.com

では Mixer のコードを書いていきましょう。

using UnityEngine;
using UnityEngine.Playables;

public class CustomMixer : PlayableBehaviour
{
    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        var color = Color.clear;
        for (int i = 0; i < playable.GetInputCount(); i++)
        {
            var sp = (ScriptPlayable<CustomBehaviour>)playable.GetInput(i);
            var behaviour = sp.GetBehaviour();
            var weight = playable.GetInputWeight(i);
            color += behaviour.outputColor * weight;
        }
        var code = ColorUtility.ToHtmlStringRGBA(color);
        Debug.Log($"<color=#{code}>{code}</color>");
    }
}

先程の PlayableGraph の図を見ても分かるように、Mixer には複数個の Playable が入力として与えられます。GetInputCount() ではこの数を知ることができ、Timeline の世界では Track に配置された Clip がそれに当たります。GetInput(int index) でインデックスを与えてあげると、その入力の Playable を取得することができます。このトラックには CustomBehaviour しか配置できないのでキャストし、ここから GetBehaviour()PlayableBehaviour を取り出します。更に、GetInputWeight(int index)ブレンド時のウェイトが取得できるので、これを outputColor にかけてブレンドしてあげれば、ブレンドした色を取り出すことができます。

GameObject とのバインディング

マテリアルの色を変えてみる

Timeline はそのままではアセットです。なのでシーン中のどのオブジェクトをどのように変更するかは、紐付けをする必要があります。これを行うのが、TrackBindingType 属性です。具体的に見てみましょう。Renderer のマテリアルに先程のカラーを指定するようにしてみます。まずは Track に次のように属性を追加します。

...
[TrackClipType(typeof(CustomClip))]
[TrackBindingType(typeof(Renderer))] // これ
public class CustomTrack : TrackAsset
{
    ...
}

するとトラックの項目に指定した型のオブジェクトを指定できるフィールドが追加されます。

ここに適当な(Renderer を持っている)ゲームオブジェクトをドラッグドロップしましょう。そして Mixer 側でこのバインディングされた情報を使います。マテリアルをいじる際は、直接 renderer.material をいじると代入のたびに Material のコピーが生じてしまい、更にエディタモードではメモリリークを産むことからエラーが出力されてしまうので、専用のマテリアルを作ってそれを使う形にしておきます。また、OnBehaviourPause() というタイムラインの再生が終わった際に呼び出されるコールバックのタイミングで破棄します。

...
public class CustomMixer : PlayableBehaviour
{
    Material _mat = null;

    public override void OnBehaviourPause(Playable playable, FrameData info)
    {
        if (_mat) Object.DestroyImmediate(_mat);
    }

    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        var renderer = playerData as Renderer;
        if (!renderer) return;
        
        if (!_mat) 
        {
            _mat = new Material(renderer.sharedMaterial);
            renderer.material = _mat;
        }
        ...
        _mat.color = color;
    }
}

なお、このバインディング情報はシーンに配置された PlayableDirector 単位で保存されています。先程、Timeline はアセットと言いましたが、このアセットをもとに実際に再生を行うのが PlayableDirector で、何を動かすのかみたいな情報はここに保存されるわけですね。どのようなバインディングがなされているかはインスペクタで確認できます(Bindings に自動で表示されるようになります)。

変える前の情報を覚えておく

タイムラインの再生が終わったら再生中に変更されたパラメタは元に戻したくなると思います。そのために予めこれらのパラメタを登録できる仕組みが用意されています。Track の GatherProperties() という関数を override し、引数として渡ってくる PlayableDirector からバインディング情報を抜き出し、 IPropertyCollector にそれをもとに必要な変数を登録します。

参考

qiita.com

今回はマテリアルをいじっていたのでそれを元に戻す処理をしてみます。コードを見てみましょう。

...
public class CustomTrack : TrackAsset
{
   ...
    public override void GatherProperties(PlayableDirector director, IPropertyCollector driver)
    {
        var renderer = director.GetGenericBinding(this) as Renderer;
        driver.AddFromName<Renderer>(renderer.gameObject, "m_Materials.Array.data[0]");
        base.GatherProperties(director, driver);
    }
}

...、m_Materials.Array.data[0] ってなんだ?と思うかもしれませんが、これはシリアライズされるフィールド名で、.meta ファイルやシーンである .unity を見ると確認できます。他にも例えば m_IsActive など普段は見ない文字列がこれに該当します。

...
MeshRenderer:
  m_ObjectHideFlags: 0
  m_CorrespondingSourceObject: {fileID: 0}
  ...
  m_Materials:
  - {fileID: 2100000, guid: 08899ed4ee530452f9a7a13d31f4556a, type: 2}
  m_StaticBatchInfo:
    firstSubMesh: 0
    subMeshCount: 0
  ...

配列のアクセスは先程のように Array.data[x] のようにアクセスするようですね。

ただ、調べるのはしんどいですしドキュメントにも乗っていないので、次のように自分で予め保存しておいたものに差し替えるほうが分かりやすいかもしれません(今回のケースでは Material 生成もしてましたしむしろこちらのほうがコードは短い)。

using UnityEngine;
using UnityEngine.Playables;

public class CustomMixer : PlayableBehaviour
{
    Renderer _renderer = null;
    Material _origMat = null;
    Material _mat = null;

    public override void OnBehaviourPause(Playable playable, FrameData info)
    {
        if (_mat) Object.DestroyImmediate(_mat);
        if (_renderer) _renderer.material = _origMat;
    }

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

        if (!_mat) 
        {
            _renderer = renderer;
            _origMat = renderer.sharedMaterial;
            _mat = new Material(_origMat);
            renderer.material = _mat;
        }
        ...
        _mat.color = color;
    }
}

Track と Clip の見た目をきれいにする

さて、このままでは実際にタイムラインをシークしてみないとどんな色になるか分かりません。また、カスタム Track が増えてくると味気ない見た目が並んでしまい、どれがどれか分かりづらくなってしまいます。そこで、色々装飾する方法を見ていきましょう。

色をつける

Track の左端や Clip の下側に帯があるのですが、ここの色は次のように TrackColor 属性をつけることで指定できます。

...
[TrackColor(0.8f, 0.2f, 0.2f)]
public class CustomTrack : TrackAsset
{
    ...
}

アイコンを変える

Track には TrackEditor というエディタ拡張が用意されています。

docs.unity3d.com

ここでアイコンも指定できます。また先程 TrackColor 属性で与えた色も、代わりにここでも指定することができます(Clip には適用されずに Track のみ変わります)。その他は高さやエラーテキスト、トラックの名前といったものも指定可能です。

using UnityEngine;
using UnityEngine.Timeline;
using UnityEditor.Timeline;

[CustomTimelineEditor(typeof(CustomTrack))]
public class CustomTrackEditor : TrackEditor
{
    Texture2D _iconTexture;

    public override TrackDrawOptions GetTrackOptions(TrackAsset track, Object binding)
    {
        track.name = "CustomTrack";

        if (!_iconTexture)
        {
            _iconTexture = Resources.Load<Texture2D>("CustomTrack-Icon");
        }

        var options = base.GetTrackOptions(track, binding);
        options.trackColor = Color.magenta;
        options.icon = _iconTexture;
        return options;
    }
}

Clip の背景を変える

Track と同様に Clip にも ClipEditor というものがあります。

docs.unity3d.com

こちらは色々と解説記事がありますのでとても参考になります。

qiita.com

light11.hatenadiary.com

具体的には、DrawBackground() をオーバーライドすることで背景描画を拡張することができたり、GetClipOptions()ClipDrawOptions を返すことで見た目に関する様々なカスタマイズを行うことができます。

次のようなコードを書けば背景にテクスチャを描画することができます。テクスチャでなくとも、例えば、Handles などを使って○を書いたりグラフを書いたり(参考)することができます。

...
[CustomTimelineEditor(typeof(CustomClip))]
public class CustomClipEditor : ClipEditor
{
    public override void DrawBackground(TimelineClip clip, ClipBackgroundRegion region)
    {
        var tex = new Texture2D(...);
        GUI.DrawTexture(region.position, tex);
    }
}

実際は、作成した画像をキャッシュしたり、今回のケースではグラデーション用のテクスチャを作ったりとやることが増えます。少しコードが長くなってしまいますが、 - ハイライトカラーは透明 - ブレンド部分がアルファでオーバーラップするようにグラデーションテクスチャを生成 といった感じでコードを書いてみます。なお、この Editor は複数の Clip で共有されるようなので、生成したテクスチャは Dictionary で持つようにしています。

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

[CustomTimelineEditor(typeof(CustomClip))]
public class CustomClipEditor : ClipEditor
{
    Dictionary<CustomClip, Texture2D> _textures = new Dictionary<CustomClip, Texture2D>();

    public override void DrawBackground(TimelineClip clip, ClipBackgroundRegion region)
    {
        var tex = GetGradientTexture(clip);
        if (tex) GUI.DrawTexture(region.position, tex);
    }

    public override ClipDrawOptions GetClipOptions(TimelineClip clip)
    {
        var options = base.GetClipOptions(clip);
        options.highlightColor = Color.clear;
        return options;
    }

    public override void OnClipChanged(TimelineClip clip)
    {
        GetGradientTexture(clip, true);
    }

    Texture2D GetGradientTexture(TimelineClip clip, bool update = false)
    {
        Texture2D tex = Texture2D.whiteTexture;

        var customClip = clip.asset as CustomClip;
        if (!customClip) return tex;

        var gradient = customClip.gradient;
        if (gradient == null) return tex;

        if (update) 
        {
            _textures.Remove(customClip);
        }
        else
        {
            _textures.TryGetValue(customClip, out tex);
            if (tex) return tex;
        }

        var b = (float)(clip.blendInDuration / clip.duration);
        tex = new Texture2D(128, 1);
        for (int i = 0; i < tex.width; ++i)
        {
            var t = (float)i / tex.width;
            var color = customClip.gradient.Evaluate(t);
            if (b > 0f) color.a = Mathf.Min(t / b, 1f);
            tex.SetPixel(i, 0, color);
        }
        tex.Apply();
        _textures.Add(customClip, tex);

        return tex;
    }
}

なお、冒頭のリップシンクでの波形描画に関しては AudioCurveRendering という便利なものがありましたので、それを利用して描画しています。

qiita.com

Clip の表示文字を変える

Clip に表示される文字列はデフォルトではその Clip の名前が表示されています。ここにはデータの名前など任意の文字列を自動挿入したいケースがあると思います。例えば今回のケース邪魔なので空白にしたいところです。これは、Clip の displayName を変更することで可能なようです。

qiita.com

差し込む場所は色々あると思いますが、同じように Track 内でやってみます。

...
public class CustomTrack : TrackAsset
{
    public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
    {
        foreach (var clip in GetClips())
        {
            clip.displayName = " ";
        }
        ...
    }
}

あくまで表示される文字を強制的に書き換えるみたいな方法なので、ユーザーがクリップの名前を変更したあと、再度この Mixer が生成される(Timeline にフォーカスが合うなど)場合に、再度空白に置き換わってしまいます。これを避けたい場合は次のように、ClipEditorOnCreate() を利用して Clip が生成されたときのみに空白指定するようにしてみます。

...
public class CustomClipEditor : ClipEditor
{
    ...
    public override void OnCreate(TimelineClip clip, TrackAsset track, TimelineClip clonedFrom)
    {
        clip.displayName = " ";
    }
    ...
}

これならば後で名前を書き換えたときにその名前もキープされるようになります。他にも生成タイミングにフックできる箇所は色々あるのでそこで指定しても良いと思います。

追加する際の文字列を変えたい

Track や Clip を追加する際に表示される "Custom Track" や "Add Custom Clip" といった文字列は、そのクラス名から自動生成されます。クラス名によっては長すぎたり、変なところで空白区切りが入ってしまうケースも有り、明示的に指定したいケースもあると思います。これは DisplayName 属性を各クラスにつけることで実現できます。

...
#if UNITY_EDITOR
using System.ComponentModel;
#endif

...
#if UNITY_EDITOR
[DisplayName("Color Gradation Track")]
#endif
public class CustomTrack : TrackAsset
{
    ...
}

...
#if UNITY_EDITOR
using System.ComponentModel;
#endif

#if UNITY_EDITOR
[DisplayName("Color Gradation Clip")]
#endif
public class CustomClip : PlayableAsset
{
    ...
}

インスペクタの表示を変える

Track や Clip などはインスペクタにそのままパラメタを出せる話を先程しましたが、同様にこのエディタ拡張も MonoBehaviour のときと完全に同じく Editor 派生の CustomEditor 属性を付けたクラスを作ることで可能です。詳細は割愛します。

Clip の振る舞いを変更する

ブレンドやクリップなどのオプションを指定

ITimelineClipAsset を派生元に追加することで clipCaps という機能にアクセスできるようになります。

docs.unity3d.com

  • ブレンドできるかどうか(Blend)
  • スピード変更できるかどうか(SpeedMultiplier)
  • ループできるかどうか(Looping)
  • トリムできるかどうか(ClipIn)
  • クリップのない範囲外の挙動(Extrapolation)
  • クリップをスケールしたときの挙動は SpeedMultipler になるかどうか(AutoScale)

といったことが指定できます。これらは次のように OR でつないで clipCaps プロパティで返すようにします。

...
public class CustomClip : PlayableAsset, ITimelineClipAsset
{
    ...
    public ClipCaps clipCaps
    {
        get => 
            ClipCaps.Blending |
            ClipCaps.Extrapolation |
            ClipCaps.ClipIn;
    }
    ...
}

例えば Blending を除外してみます。するとこのようにブレンドの表示がなくなり、実際に Mixer に入ってくるブレンド値も 0 / 1 になります。

また、Extrapolation を追加してみます。すると、UI に Animation Extrapolation という項目が追加され、ここで範囲外の挙動を Hold(端の値をキープ)したり Loop(同じ挙動を繰り返し)したり、PingPong(逆再生、順再生を繰り返し)できるようになります。これまでは、範囲外に行った際は合計 weight が 0 となり、色が真っ黒になってしまっていましたが、その挙動をどうするか選択できるようになります。選択した挙動はタイムライン上でアイコンによって表現されています。

1 点、Loop や PingPong した際には Playable.GetDudration() していたところで Infinity がやってきてしまうので、代わりに直接 TimelineClip から duration を取得するように修正が必要です。Playable からなぜ取得できないかは謎ですが…(もう少しきれいな方法もあるかもしれません)。

...
public class CustomTrack : TrackAsset
{
    public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
    {
        foreach (var timelineClip in GetClips())
        {
            var clip = timelineClip.asset as CustomClip;
            clip.durationInTrack = (float)timelineClip.duration;
        }
        ...
    }
}
...
public class CustomClip : PlayableAsset, ITimelineClipAsset
{
    ...
    public float durationInTrack { get; set; }
    ...
}
...
public class CustomBehaviour : PlayableBehaviour
{
    ...
    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        var t = playable.GetTime();
        var d = clip.durationInTrack;
        var a = (float)(t / d);
        outputColor = clip.gradient.Evaluate(a);
    }
}

データを指定してクリップを追加

Clip に public なオブジェクトの変数を追加すると、オブジェクトをドラッグドロップしたり右クリックからそのオブジェクトを指定して Clip を作成することが可能になります(そのオブジェクトがデフォルトでセットされた状態の Clip が作成される)。今回のサンプルだとあまり恩恵が無いですが、データを配置するような Clip を生成する際は結構便利だと思います。

...
public class CustomClip : PlayableAsset
{
    public GameObject hoge;
    public Material fuga;
    ...
}

追加する際のデフォルトの長さを指定

duration プロパティをオーバーライドすると配置時の長さを指定できます。データなどで長さを指定できる場合はここで値をセットしてしまうのが良いかと思います。

...
public class CustomClip : PlayableAsset
{
    ...
    public override double duration { get => data ? data.duration : base.duration; }
    ...
}

その他

Default Playables

解説した Track や Clip などの雛形を生成する Wizard を提供してくれたり、いくつか便利なサンプルが含まれているパッケージです。

assetstore.unity.com

参考資料

公式の Unite Tokyo 2018 での講演がとても参考になります。

おわりに

便利なタイムライン要素を作成したり、見た目を工夫して利用する人のユーザビリティを上げるのは制作プロセスにおいて様々な恩恵がありそうですね。他にもいろいろな便利な機能があれば追記したいと思いますので、ご存じの方はぜひご教授ください。