Unity では割と最近、Timeline という機能が提供されるようになりました。
現在、PackageManager 経由でインストールすることができます。
これは、シーン上の色々なオブジェクトや機能について、いつ誰に何をしてほしいのかをあらかじめ時系列に設定しておき、保存したものを後から「再生」することによって、複数のオブジェクトたちの動作を編集したとおりに再現できる、といったツールです。
アニメーションを編集する機能がついているオーサリングツールではよくみかける形なUIがついています。
ドキュメントでは、ゲームのカットシーンや 映像制作に使うことを想定していると紹介されてます。
ゲームでは、自動でストーリーや出来事がすすむようすをプレイヤがただ見る、みたいなフェイズはよくあらわれると思うので、そういったものをこれまでのUnityのエディタ機能たちと親和性の高い形で設定することができるようになった点は便利です。
Timelineを使ったワークフローは、動画での紹介を見るとわかりやすいです。
一応、マニュアルも存在しています。
あらかじめ搭載されている動作だけでなく、自分で呼び出したい機能をプログラミングしておく という使い方も想定されています。
以下の記事では、カスタムのTrackやClipを作る方法が説明されています。
また、以下のUnity Blog の記事では、特定のタイミングでTimelineの再生を止めたり、ユーザ入力を待ってから再開したり、マーカを置いた場所までジャンプしたり、などなど、Timelineの時系列を操るような拡張を施すやりかたが解説されています。
昔の Flash のようなつくりかたに近いことができそうっすね
カスタマイズしずらい点
さわってみたところ、Unityが用意しているeasyな路線からはずれると そこそこ手を入れる必要があるなあ、という感想を持っています。
すべてが ScripatableObject
Timelineは、Unityが公式で提供する機能の例に漏れず、「Unityのフォーマットでシリアライズして保存できるもの」、つまり ScriptableObject
や Component
の類だけで全ての振舞いを構築しようとしている世界です。
Unityに依存しないオブジェクトを そのまま Timeline で操作することは想定されていないし、再生時に注入することもできません。
やりたければ自前である程度がんばる必要があります。
( 自分のプロジェクトでは、ゲーム上の状態の変化をすべてC#のプリミティブなオブジェクトで表現していて、ゲーム中の出来事もまた すべてMessagePackでシリアライズ可能な値を伝搬させる、といった設計にしていたたため、Timeline とどう統合するかけっこう悩みました。
すべてが Playable
また、Timeline上のトラックやクリップは、一律に Playable API
で綺麗に表現されようとしている、という点は非常に特徴的だとおもいます。
「トラック」や「クリップ」、これらは直感的には別の概念に思えますが、実は、内部的には、ツリー上に構築され親子関係にされた Playableとして表現されています。
これは綺麗だとはおもいますが、めんどうなことも起きています。
たとえば、実質、 (エディタ上では) 「クリップが置かれていないフレーム」においてもなんらかの状態変化をしないといけないのですが、クリップ=Playableに対してのプログラミングだけでそれを行うことができません。
結局、サンプルをみると、エディタ上で確認可能な機能は、クリップに何も実装はなく、トラック側にすべて実装されている、というような、ちょっと直感的でない方法がとられています。
これは Playable のAPI の実装によって、すべてそ完結させようとしている皺寄せではないかなあと感じました。
Timelineをさわりはじめると遭遇する用語の説明
Playable
Playable
は以前のUnityにはなかった、そこそこ新しい概念です。
これは、Timeline だけに限らず、ゲーム中の色々なものを抽象化しようとするUnityの汎用的な仕組みのようです。
「Playableって、そもそも何ですか?」 といったことについての説明はほとんど世の中にない気がするのですが、僕の理解では、これは、「始まりがあって終わりがある」&「毎フレーム状態を変化させる」といった振舞いを抽象化したものだと捉えています。
たとえば、3Dモーションやオーディオは、機能的にはまったくもって別物ながら、「始まりがあって終わりがあって再生できる」し、「毎フレーム刻々と状態が変わる」、ついでに「ファイルに保存しておいて使う」みたいな部分は共通しています。こういったものはゲームには山ほど出てくるため、Playable という抽象化によって共通のインターフェイスで扱えることを目指している、というのがUnityの意図なんだと思います。
Timeline もまた Playable です。 (正確に言うと PlayableAsset
として保存され、再生時に Playable
を生成する)
再生可能で、毎フレームなんらかの状態変化を起こすことができるためです。
特徴的なのは、Timelineに置かれたTrackもまた Playableであることです。
Trackに注目してみれば、これ自体もまた毎フレームなんらかの状態変化を起こすオブジェクトとして表現可能だし、さらにさらに、Trackに置いたClipもまたPlayable (PlayableAsset) になってます。
PlayableAsset
、 Playable
、 PlayableBehaviour
PlayableAsset
は、Playable として振舞えるものをシリアライズして保存できるようにしたオブジェクトです。
これは、 ScriptableObject
として実装されています。
さて、ひとたびPlayable的なものの再生/実行がはじまれば、毎フレーム刻々と状態変化を発生させたり、やりたい放題破壊的な操作を実行しまくりたいわけですが、 PlayableAsset
それ自体の状態を書き換わえるようなことはできません。それは、シリアライズされたファイルに変更を加えるということを意味するためです。
実は、PlayableAsset
そのものが再生対象なのではなく、 PlayableAsset
の役割は、実行時にPlayable
を生成する、というところまでに留まっています。
そう、PlayableAsset
は、再生のための必要なデータを保存しておくただの箱で、唯一、要求されている機能は、「再生時にどんな Playable
を生成するか」というファクトリとしての役割です。
カスタムの PlayableAsset
をつくったら、カスタムの Playable
をつくる必要がでてくるのはこういう事情があるためです。
Timeline においては、カスタムクリップをつくりたい場合、 PlayableAsset
を継承しますが、ここに実装を書くのではなく、ここではどんな Playable
を生成するか、だけを主に実装するようになっています。クリップの挙動そのものは、カスタムの Playable
を用意してあげないといけないようになっています。このAPI は、Timeline が Playable の上にできるだけ薄く乗っかろうとしているためです。
ExposedReference<T>
、 IExposedPropertyTable
もうひとつ、Timeline の特徴な機能は、再生時に必要なオブジェクトの依存関係を解決する仕組みを持っていることです。
Unity は、依存するオブジェクトをシリアライズして保存することができますが、
これまでは、別々のシーンのオブジェクト同士の関連をシリアライズしたり、動的に生成されるオブジェクトの関連を事前に保存しておくことはできませんでした。
しかし、Timeline は、TimelineAsset
を好きなシーンで再生するとか、動的に生成したキャラクタを制御したいとか、そういった要件がでてきます。
この要件を満たすするため、 ExposedReference<T>
という、新しい参照のシリアライズ方法が提供されています。
ちなみに、この仕組みも、Timelineに依存しているわけではなく、汎用的な仕組みを意図している模様です。
ExposedReference<T>
の仕組みはこうです。
- 従来のようにエディタから Object をシリアライズすると、Objectそのものをシリアライズしているとみせかけて、実はそれを指すキー、
PropertyName
が シリアライズされるようになっている - 実行時には、
PropertyName
を元に 、対象のObject
を探す。実は、IExposedPropertyTable
を実装したオブジェクトにすべての関連するオブジェクトがコンテナのごとくすべて入っている
[UsedByNativeCode(Name = "ExposedReference")]
[Serializable]
public struct ExposedReference<T> where T : Object
{
[SerializeField]
public PropertyName exposedName;
[SerializeField]
public Object defaultValue;
public T Resolve(IExposedPropertyTable resolver)
{
if (resolver != null)
{
bool idValid;
Object referenceValue = resolver.GetReferenceValue(this.exposedName, out idValid);
if (idValid)
return referenceValue as T;
}
return this.defaultValue as T;
}
}
つまり、ExposedReference<T>
があるところには、かならず IExposedPropertyTable
があって、IExposedPropertyTable
に参照したいオブジェクトの参照がぜんぶつっこまれています。
ただし、上の定義からもわかるように、 ExposedReference<>
で保持できるのは、 UnityEngine.Object
のみです。 [Serializable]
をつけた自前の値を好きに入れることは (現時点では) できないようになってしまってしまっています。
IExposedPropertyTableは、別な言い方をすると Unityオブジェクトしか入らない DIコンテナ(IoCコンテナ) のようなものと言っても良いかもしれません。
さて、Timeline においても、依存するオブジェクトをひっぱってくる IExposedPropertyTable
が存在するわけですが、具体的にどのオブジェクトがそれにあたるかというと、 PlayableDirector
です。
エディタ上では、以下のような振舞いをするようです。
- Trackの
ExposedReference<T>
に入れたオブジェクトは、再生時に自動で解決される。(トラックの名前を 元に PropertyName が自動で設定される仕組みがある - クリップなど自分の好きな場所に
ExposedRefernece<T>
をつくった場合、自動では解決されず、自前でどうにか PropertyName を設定する必要があると思われる(?)
エディタを介さずとも、PlayableDirecotr に好きな PropertyName で 好きな UnityEngine.Object
がつっこまれさえいれば、Timeline再生時に依存を解決することが可能です。
tips
ここからは、いくつか自分が試している小技を紹介してみようと思います。
Zenject と組み合わせる
自分のプロジェクトでは、Zenject を使ってますが、色々検討した結果、Timeline中に再生される Playableに対して、ZenjectのInjectが動くような方向で併用することにしました。
ちなみに、Zenject は、いわゆる DIコンテナと呼ばれる代物。オブジェクト同士の依存関係を、半自動的に「注入」してくれる仕組みを提供してくれるライブラリです。
Zenjectのくわしい説明は他に譲りますが、
DI について興味がある方は Dependency Injection in .NET を開いてみるのがおすすめです。
さて、Zenject をつかって、必要なオブジェクトは自動で注入される世界でゲーム開発をしていたのですが、Timeline 経由でスクリプトが実行される場合、Zenject経由で注入したオブジェクトを使うことはできません。
たとえば、ZenjectのFactoryなどを元にGameObjectを生成するような仕組みにしていた場合、これはけっこう不便です。
そこで、Timeline が 搭載している ExposedReference<T>
や IExposedPropertyTable
の機能を無視し、代わりに、Timelineのなかの世界からも Zenject.DiContainer
経由で依存を解決する仕組みをとることにしました。
具体的には、DiContainer
を保持するだけの Component
をつくり、Timeline再生時にはかならずそれを IExposedPropertyTable
につっこむようにします。
public class ContainerHandle : MonoBehaviour
{
public const string Id = "Zenject.DiContainer";
public DiContainer Container;
}
Zenject.DiContainer
を IExposedPropertyTable
へ セットするための Extensions も生やしておきましょう。
public static class PlayableGraphExtensions
{
public static DiContainer GetContainer(this IExposedPropertyTable resolver)
{
var containerHandle = (ContainerHandle)resolver.GetReferenceValue(ContainerHandle.Id, out var isValid);
if (containerHandle == null || !isValid)
{
throw new InvalidOperationException("Container cannot resolved");
}
return containerHandle.Container;
}
public static void SetContainer(this IExposedPropertyTable resolver, ContainerHandle containerHandle)
{
resolver.SetReferenceValue(ContainerHandle.Id, containerHandle);
}
}
Timeline再生時は、たとえば以下のように、かならず DiContainer をセットしてから再生されるようにしておきます。
var go = new GameObject("PlayableDirector");
var containerHandle = go.AddComponent<ContainerHandle>();
containerHandle.Container = container;
var playableDirector = go.AddComponent<PlayableDirector>();
playableDirector.playableAsset = timeline;
playableDirector.SetReferenceValue(ContainerHandle.Id, containerHandle);
これで、Timeline再生時にも、たとえば、トラックから Playable
を生成する際にとりあえず Zenject.DiContainer
を取得できるので、 Playable に対して Inject
を行うことができます。
public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
{
var container = graph.GetResolver().GetContainer();
var template = new HogehogeMixerBehaviour();
container.Inject(template);
return ScriptPlayable<HogeMixerBehaviour>.Create(graph, template, inputCount);
}
DiContainerに依存するTimelineを編集するときは、 ZenjectEditorWindow
をつかうと、そのWindowが生きている間に有効な DiContainer
をエディタ上で動かすことができます。
Timelineを使っていると、エディタ上での挙動と、ランタイムでの挙動をちょっと変えたい、というケースがままありますが、その場合、DiContainerに注入する実装を変更すると非常に便利です。
自分はそんなかんじのことをしています。
ExposedReference<T>
を使わず、 自作の参照をつかう
自分のプロジェクトでは、ゲーム内のキャラクタは内部的には Unity に依存しないデータ表現をもっていて、独自のユニークなIDを振っています。
そこで、Timeline においても、他のゲームのコードと同様なしくみで、独自のID によって、どのキャラクタの状態を変えるか指定したい、というモチベーションがあったので、ExposedReference<T>
ではない独自の 参照をつくってつかっています。
以下はほんのサンプルです。
このように、Guidを stringとして保持するシリアライズ可能な型をつくっておき、
[Serializable]
public struct GuidReference : IEquatable<GuidReference>
{
[SerializeField]
public string Value;
public Guid Guid
{
get
{
if (Guid.TryParse(Value, out var guid))
{
return guid;
}
return Guid.Empty;
}
}
public bool IsEmpty => string.IsNullOrEmpty(Value);
public static implicit operator GuidReference(Guid value) => new GuidReference(value);
public static implicit operator GuidReference(string value) => new GuidReference(value);
public GuidReference(string value)
{
Value = value;
}
public GuidReference(Guid value)
{
Value = value.ToString();
}
public bool Equals(GuidReference other) => Value.Equals(other.Value);
public override bool Equals(object obj) => obj is GuidReference other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value;
}
専用の CustomPropertyDrawerをつくることによって、ただ単に手でIDを入力するだけではなく、エディタ上でもっと直感的に参照を選択するUIにすることができます。
[CustomPropertyDrawer(typeof(GuidReference))]
public class GuidReferencePropertyDrawer : PropertyDrawer
{
string[] ids;
string[] actorNames;
public override void OnGUI(Rect rect, SerializedProperty prop, GUIContent label)
{
using (new EditorGUI.PropertyScope(rect, label, prop))
{
var stringProp = prop.FindPropertyRelative("Value");
// たとえば、このように、特定のエディタが開いているかどうかで分岐をつくり、
var fieldEditorWindow = FieldEditorWindow.GetCurrent();
if (fieldEditorWindow == null || !fieldEditorWindow.SceneLoaded)
{
// 通常はただのテキストフィールド
stringProp.stringValue = EditorGUI.TextField(rect, label, stringProp.stringValue);
return;
}
// 選択対象のオブジェクトをシーンから選択できる場合は、がんばってドロップダウンを出す
var actorRepository = fieldEditorWindow.GetActorRepository();
if (ids == null)
{
ids = new[] { "" }.Concat(actorRepository.Ids.Select(x => x.ToString())).ToArray();
}
if (actorNames == null)
{
actorNames = new[] { "(None)" }.Concat(actorRepository.Actors.Select(x => x.name)).ToArray();
}
var labelRect = new Rect(rect.x, rect.y, rect.width, 18f);
var helpRect = new Rect(rect.x + 5f, rect.y + labelRect.height, rect.width, 24f);
var popupRect = new Rect(rect.x, rect.y + labelRect.height + helpRect.height, rect.width, 18f);
// Draw label
EditorGUI.PrefixLabel(labelRect, GUIUtility.GetControlID(FocusType.Passive), label);
// シーン上のオブジェクトから選択するとかできます
// Draw popup
var selectedIndex = EditorGUI.Popup(popupRect, Array.IndexOf(ids, stringProp.stringValue), actorNames);
stringProp.stringValue = selectedIndex > -1 ? ids[selectedIndex] : "";
}
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
var fieldEditorWindow = FieldEditorWindow.GetCurrent();
if (fieldEditorWindow == null || !fieldEditorWindow.SceneLoaded)
{
return base.GetPropertyHeight(property, label);
}
return 18f + 24f + 18f + 4f;
}
といったことをぐちゃぐちゃやると便利です。
ExposedReference<T>
も、中身をみると、PropertyDrawer によって色々やる形になっています。
Play中、エディタ上、両方で同じ処理を実行する
Timeline の非常に便利な点は、エディタ上で、タイムラインの特定の位置をポイントすると、その時刻の状態をシーンビュー上で確認できるところ。
つまり、再生して、Update()
がまわっていなくても、動く、これがタイムライン、もといPlayableの便利なところだとおもいます。
ところが、自分でつくったカスタムのクリップに対して、 実行中でもエディタ上でも同じように動いてもらおうとすると、意外と苦労しました。
これは、前述しましたが、クリップのPlayableをプログラミングするだけでは、「クリップが置かれていないフレーム」の挙動を指定することができないため、といった理由が大きいと思ってます。
そこで、自分は、基本的には クリップからカスタムのPlayableを生成するということは一切せず、トラック側で毎フレーム、全クリップのデータを見ることで フレームごとの状態を決める、というやりかたにすべて統一することにしました。
これをやりはじめると、トラックごとに似たような処理が頻出してくるため、トラックは、以下のようなベースクラスを元に Playable をつくるみたいなやりかたをとってます。
public class MixerBehaviourBase : PlayableBehaviour
{
protected int ActiveIndex = -1;
public override void OnBehaviourPlay(Playable playable, FrameData info)
{
var time = playable.GetTime();
if (time <= 0)
{
OnTrackStart(playable);
}
}
public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
if (ActiveIndex >= 0)
{
var inputPlayable = playable.GetInput(ActiveIndex);
if (inputPlayable.GetPlayState() == PlayState.Paused && inputPlayable.GetTime() > 0f)
{
OnClipEnded(inputPlayable);
}
}
var presented = false;
var inputCount = playable.GetInputCount();
for (var i = 0; i < inputCount; i++)
{
var inputWeight = playable.GetInputWeight(i);
if (inputWeight > 0f)
{
if (ActiveIndex != i)
{
ActiveIndex = i;
OnClipEnable(playable, ActiveIndex, inputWeight);
}
presented = true;
break;
}
}
// クリップのないフレーム
if (!presented && ActiveIndex >= 0)
{
OnClipDisable(playable, ActiveIndex);
ActiveIndex = -1;
}
}
// トラックの最初のフレームで呼ばれる
protected virtual void OnTrackStart(Playable playable)
{
}
// あるクリップが再生された最初のフレームで呼ばれる
// エディタ上では、指しているクリップが代わった場合に都度呼ばれる
protected virtual void OnClipEnable(Playable playable, int enabledIndex, float weight)
{
}
// クリップのあるフレームから、クリップのないフレームへ移った場合に呼ばれる
// エディタ上では、指しているクリップがなくなった場合に都度呼ばれる
protected virtual void OnClipDisable(Playable playable, int disabledIndex)
{
}
// あるクリップの最後のフレームで呼ばれる
protected virtual void OnClipEnded(Playable inputPlayable)
{
}
}
}
そんなかんじです
自分がさわってみて不明瞭だった点の仕組みの説明と、
自分が試している tips の紹介をこころみました。
Timeline のような、カスタマイズが想定される機能は、もうすこしUnityに依存しないシリアライゼーションやデータも想定して開いたAPIになってくれると嬉しいな- という感想をもっています。