はじめに
前々回の記事で Unity でリップシンクを行うためのアセットである uLipSync のタイムライン対応について紹介しました。
ここでは次のように現在どのリップシンクデータを喋らせているか分かるように色々装飾しています。
本記事では、カスタム Track および Clip の作り方と、その見た目のカスタマイズの方法について紹介します。それぞれの詳細を解説すると言うよりは、全体の勘所をつかめるような内容を目指しています。
デモ
こんな見た目のカラーをブレンドするタイムラインが作成できるようになります。プロジェクトは以下から取得できます。
タイムラインのおさらい
タイムラインは Playable API をもとに構築されています。ドキュメントの説明を借りると次のようなものです。Playable API はアニメーションなどにも使われている、複数のデータからの入力をミックス、ブレンド、ときには手を加えて、最終的に 1 つの出力として再生できるという抽象化された仕組みです。タイムラインでは、アニメーション以外にも、オーディオや、オブジェクトの操作、その他時間に関係あるいろいろなものがこの仕組みに落とし込まれて、それをタイムラインアセットという形にして保存・再利用できるようになっています。
このように適度に抽象化されていることもあり、カスタムトラックおよびクリップを作る際の登場人物はちょっと多くて主に 4 人います。ざっくりとはこんな感じです。
- Track
- タイムライン上に追加できる行のアセット。中に指定したクリップを配置できる。
- Clip
- Track 上に配置できる範囲を持つアセット。
- Playable Behaviour
- Clip のふるまい
- Mixer
- 複数の Clip(Playable Behaviour)のブレンド
見た目的にはトラックとクリップの 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
で内包された PlayableBehaviour
は GetBehaviour()
経由で取ってこれます。なので、ここで自身を与えてあげたり、必要な情報をセットしてあげたりすることで、ユーザがエディタ上で指定したパラメタを 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
に詰めておきます。ちなみに FrameData
は deltaTime
や再生スピードといったそのフレーム単位の情報が入ってきます。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
になっているという感じですね。アニメーションブレンドツリーでも同じように複数のアニメーションをまとめ上げるミキサーがいるのと同じような感じです。
では 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
にそれをもとに必要な変数を登録します。
- Timeline.IPropertyPreview-GatherProperties - Unity スクリプトリファレンス
- Timeline.IPropertyCollector - Unity スクリプトリファレンス
参考
今回はマテリアルをいじっていたのでそれを元に戻す処理をしてみます。コードを見てみましょう。
... 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
というエディタ拡張が用意されています。
ここでアイコンも指定できます。また先程 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
というものがあります。
こちらは色々と解説記事がありますのでとても参考になります。
具体的には、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
という便利なものがありましたので、それを利用して描画しています。
Clip の表示文字を変える
Clip に表示される文字列はデフォルトではその Clip の名前が表示されています。ここにはデータの名前など任意の文字列を自動挿入したいケースがあると思います。例えば今回のケース邪魔なので空白にしたいところです。これは、Clip の displayName
を変更することで可能なようです。
差し込む場所は色々あると思いますが、同じように Track 内でやってみます。
... public class CustomTrack : TrackAsset { public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount) { foreach (var clip in GetClips()) { clip.displayName = " "; } ... } }
あくまで表示される文字を強制的に書き換えるみたいな方法なので、ユーザーがクリップの名前を変更したあと、再度この Mixer が生成される(Timeline にフォーカスが合うなど)場合に、再度空白に置き換わってしまいます。これを避けたい場合は次のように、ClipEditor
の OnCreate()
を利用して 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
という機能にアクセスできるようになります。
- ブレンドできるかどうか(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 を提供してくれたり、いくつか便利なサンプルが含まれているパッケージです。
参考資料
公式の Unite Tokyo 2018 での講演がとても参考になります。
おわりに
便利なタイムライン要素を作成したり、見た目を工夫して利用する人のユーザビリティを上げるのは制作プロセスにおいて様々な恩恵がありそうですね。他にもいろいろな便利な機能があれば追記したいと思いますので、ご存じの方はぜひご教授ください。