53
39

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Unity の Timeline をカスタマイズするための詳細

Last updated at Posted at 2019-10-13

Unity では割と最近、Timeline という機能が提供されるようになりました。
現在、PackageManager 経由でインストールすることができます。

これは、シーン上の色々なオブジェクトや機能について、いつ誰に何をしてほしいのかをあらかじめ時系列に設定しておき、保存したものを後から「再生」することによって、複数のオブジェクトたちの動作を編集したとおりに再現できる、といったツールです。
アニメーションを編集する機能がついているオーサリングツールではよくみかける形なUIがついています。

ドキュメントでは、ゲームのカットシーンや 映像制作に使うことを想定していると紹介されてます。
ゲームでは、自動でストーリーや出来事がすすむようすをプレイヤがただ見る、みたいなフェイズはよくあらわれると思うので、そういったものをこれまでのUnityのエディタ機能たちと親和性の高い形で設定することができるようになった点は便利です。

Timelineを使ったワークフローは、動画での紹介を見るとわかりやすいです。

一応、マニュアルも存在しています。

あらかじめ搭載されている動作だけでなく、自分で呼び出したい機能をプログラミングしておく という使い方も想定されています。
以下の記事では、カスタムのTrackやClipを作る方法が説明されています。

また、以下のUnity Blog の記事では、特定のタイミングでTimelineの再生を止めたり、ユーザ入力を待ってから再開したり、マーカを置いた場所までジャンプしたり、などなど、Timelineの時系列を操るような拡張を施すやりかたが解説されています。
昔の Flash のようなつくりかたに近いことができそうっすね

カスタマイズしずらい点

さわってみたところ、Unityが用意しているeasyな路線からはずれると そこそこ手を入れる必要があるなあ、という感想を持っています。

すべてが ScripatableObject

Timelineは、Unityが公式で提供する機能の例に漏れず、「Unityのフォーマットでシリアライズして保存できるもの」、つまり ScriptableObjectComponent の類だけで全ての振舞いを構築しようとしている世界です。
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) になってます。

PlayableAssetPlayablePlayableBehaviour

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> の仕組みはこうです。

  1. 従来のようにエディタから Object をシリアライズすると、Objectそのものをシリアライズしているとみせかけて、実はそれを指すキー、PropertyName が シリアライズされるようになっている
  2. 実行時には、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.DiContainerIExposedPropertyTableへ セットするための 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になってくれると嬉しいな- という感想をもっています。

53
39
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
53
39

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?