Unityタイムラインのおさらいとゲーム開発導入で考えておきたいいくつかのこと

CyberAgent Developers Advent Calendar 2018 の 17日目の記事です。

こんにちは、ゲームのクライアントエンジニアをしている向井です。

ここ最近は業務でUnityのタイムラインについて触る機会が多かったため、振り返りという意味も込めて、本稿はタイムラインについてです。

内容としては、Unityのタイムラインについて、今一度おさらいと、実際にゲーム開発にて導入しようとしたときに、どういうことを考え、検証し、解決しようとしているかについてです。

タイムラインとは

タイムラインはマルチトラックで演出を作成することのできるツールで、Unity 2017.1から追加されました。

ゲームの演出は(ゲームに限らないですが)、モーションや、エフェクト、サウンドなどの再生や、カメラワークなど、それぞれ単体ではなく、それらを組みあわせ、調整を重ねてクオリティを上げていきます。

Unityはいままで、標準でこれら複数の要素をまとめて再生・確認するツールやエディタは存在しませんでした。
なので、例えばアニメーションにあるアニメーションイベントを拡張して、エフェクトやサウンドなどの再生タイミングを仕込んだり、または自作でツールを作ることが多かったと思います。

しかし、ただアニメーションイベントを打つだけでは当然、エディタ上での確認がし辛いため、エディタの作り込みをしていくことになるかと思います。

私も過去のプロジェクトにて、ASM(Animate State Machine)というツールを演出作成の基盤として実装したことがあります。これについては、Unite 2018の発表にて少しご紹介させていただきました。

このように、演出要件としてはほぼ必須だったが、要件を満たすツールが標準で存在しなかったため、タイムラインの登場の存在は大きく、登場後、様々なプロジェクトで導入検討が進んでいるのではないかと思います。

タイムラインの良さ

Unityのタイムラインは、アニメーションやサウンド・エフェクトの再生する仕組みと、一連のカットシーンを確認するためのエディタが備わっています。少ないスクリプト記述で(場合によってはそれも不要)、簡単に品質の高いカットシーンを作成することができます。

デザイナーさん(もしかしたらプロジェクトによってはプランナーさんもやるかもですが)は、カットシーンの作成にプログラマーを介することなく、イテレーションを回すことができます。

ツール制作の必要性について。Unite 2018の発表から

個人的にクオリティを上げるためには、企画から検証のサイクルを如何に減らせるかが重要だと考えていて、この特性は重要であると考えます。

また、Unity標準でツールが提供されることで独自のカットシーンが減ってくるのであれば、Unityプロジェクトに限っては、デザイナーさんがカットシーンの制作のために覚えるのがタイムラインエディタのみなので、学習コストが減るのもメリットの1つだと感じています。

タイムラインの構成要素

ここから、タイムラインの基本的な要素についておさらいしていきたいと思います。

まずはタイムラインの構成要素について整理していきます。

タイムラインアセット

タイムラインは、このあと説明するトラックとクリップで構成されます。タイムラインエディタビュー上では、それぞれは下記で表示されます。

エディタビューとタイムラインの要素の関係。スクリーンショットはUnityの公式から。
エディタビューとタイムラインの要素の関係。スクリーンショットはUnityの公式から。

トラック

トラックは、シーン中のオブジェクトをどのように操作するかを定義したものです。操作する対象を、タイムラインインスタンスと呼びます。

Unity標準では、GameObjectの表示や非表示を切り替えるActivation Track、Animatorを経由してアニメーションを制御するためのAnimation Track、Unityのオーディオを制御するAudio Trackが用意されています。

また、独自拡張するためのトラックとして、ITimeControlを制御するためのControl Trackと独自のトラックを実装するためのPlayable Trackがあります。

Control Trackは、ITimeControlインターフェイスを実装しているMonoBehaviourを操作するためのトラックになります。トラック挙動の独自拡張のために用いられますが、Unity標準のParticle Systemはこのインターフェイスを実装しているため、スクリプティングをしなくても、エフェクトの制御を行うことができるようです。

Unityのタイムラインでは、実際に操作する対象のタイムラインインスタンスとタイムラインアセットを分離して設計しているため、ゲーム中で例えば、ガチャの演出仕様として、レアリティごとには同じ演出を使いたい場合など、カットシーンを使いまわしたいことがあったときに、タイムラインインスタンスの差し替えを行うだけで、これを実現することができます。

クリップ

クリップは、トラックで定義された操作を、どのタイミングで、どのようなパラメータで実施するかを定義します。

タイムラインでできること

これらの要素を用いると、GameObjectの表示・非表示や、アニメーションやサウンド・エフェクトの再生など、演出において必要な多くのことが、Unity標準で実装できることがわかりました。

ですが、これ以外のコンポーネント(そもそもここにないコンポーネントや、ゲームで独自に実装したコンポーネント)をタイムラインから操作しようとすると、タイムラインを拡張する必要が出てきます。

タイムラインの拡張方法

タイムラインの拡張方法は大きく分けて2つ存在します。

ControlTrackとITimeControlを用いた拡張方法

ITimeControlインターフェイスを継承するMonoBehaviourは、Control TrackにそのコンポーネントをアタッチしたオブジェクトをBindingとして登録することで制御できます。

ITimeControlの定義は以下のとおりです(参考: https://docs.unity3d.com/ScriptReference/Timeline.ITimeControl.html)。

interface ITimeControl
{
    void OnControlTimeStart();
    void OnControlTimeStop();
    void SetTime(double time);
}

OnControlTimeStartおよびOnControlTimeEndは、それぞれクリップの開始・終了に差し掛かったときに呼び出されます。

SetTime(double time)は、クリップを再生中に毎フレーム呼び出されます。引数のtimeは、クリップ内での正規化時間をとります。例えば、クリップの開始が20フレーム目、終了が40フレーム目のときに、30フレーム目を再生したときには、time=0.5 となります。

ただし、正規化時間は取得できますが、クリップの開始フレーム・終了フレームを取得することはできません。

ITimeControlによるクリップの拡張は、MonoBehaviourITimeControlを継承して、Bindingとして登録してやるだけで拡張できますが、上記の通りクリップの再生・終了時と、再生中の正規化時間しか受け取れません。
例えば移動アニメーションなどを補間関数によって行いたい、簡単なフェードイン・アウトを実装するなど、正規化時間のみで十分な場合においては便利ですが、複雑なことはできません。

特に、クリップ間でのブレンディングが行えないため、ブレンディングを行いたい場合は、Playable Trackを用いた拡張を行う必要があります。

独自のPlayableTrackを用いた拡張方法

独自のPlayableTrackを実装することでタイムラインを拡張する方法もあります。
拡張は少し複雑なのですが、こちらを利用すると、ITimeControlよりも柔軟な拡張を行うことができます。

また、Unityが公式で提供しているDefault PlayablesのTimeline Playable Wizardを用いることで、拡張のテンプレートが手軽に作成できます。

独自のPlayableTrackでは、主に4つの要素からなります。

  • Track
  • Clip
  • Behaviour
  • MixerBehaviour

Default PlayablesScreeFaderTrackの実装を見ながら、各要素を紐解いてみます。

このクラスは、指定したUnityEngine.UI.ImageのImage.colorを、クリップ情報をもとに変化させます。

ScreenFaderの設定例

例えば、上記のようなクリップを設定したときの挙動は、下記のようになります。

ScreenFaderの動作サンプル
ScreenFaderの動作サンプル

Track

Trackは、タイムラインのトラックを表現するクラスです。TrackAssetを継承します。

using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
using System.Collections.Generic;
using UnityEngine.UI;

[TrackColor(0.875f, 0.5944853f, 0.1737132f)]
[TrackClipType(typeof(ScreenFaderClip))]
[TrackBindingType(typeof(Image))]
public class ScreenFaderTrack : TrackAsset
{
    public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
    {
        return ScriptPlayable<ScreenFaderMixerBehaviour>.Create (graph, inputCount);
    }
}

TrackClipTypeでは、後述するClipの型を指定します。ここでは対応するScreenFaderClipを指定しています。

TrackBindingTypeでは、Bindingするインスタンスの型を指定します。ScreenFaderTrackは、UnityEngine.UI.Imageを操作するため、ここでは、TrackBindingType(typeof(Image))として指定しています。
Bindingするインスタンスは、Trackごとに1つしか定義できないことに注意が必要です。

もし、2つ以上のインスタンスをセットで操作したいトラックを作成する場合は、後述するExposedReferenceを用いる必要があります。

Trackでは、対応MixerBehaviourのインスタンスを作成する、CreateTrackMixerを実装してやる必要があります。上記では、ScriptPlayable<T>.Createメソッドを用いて、ScreenFaderMixerBehaviourのインスタンスを作成しています。

この辺は、ウィザードが作成する実装をそのまま利用すれば十分かと思います。

Clip

ClipはPlayableAssetおよびITimelineClipAssetを継承し、以下の2つの実装をします。

  1. Clipの機能についての記述
    • Clip間でのブレンドが可能かなどを定義します
  2. このTrackが利用するBehaviourインスタンスの作成
using System;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

[Serializable]
public class ScreenFaderClip : PlayableAsset, ITimelineClipAsset
{
    public ScreenFaderBehaviour template = new ScreenFaderBehaviour ();

    public ClipCaps clipCaps
    {
        get { return ClipCaps.Blending; }
    }

    public override Playable CreatePlayable (PlayableGraph graph, GameObject owner)
    {
        var playable = ScriptPlayable<ScreenFaderBehaviour>.Create (graph, template);
        return playable;
    }
}

1.は、ITimelieClipAssetcilpCapsで定義します。上記の例では、ブレンドのみサポートしていることを表しています。

2.は、PlayableAssetCreatePlayableをオーバーラードすることで実装します。こちらは、ウィザードで作成されるものをそのまま利用するので問題ないかと思います。

1点注意として、Clipのインスタンスは、タイムラインアセットとして保存されるため、シリアライズ可能である必要があります。なので、[Serializable]アトリビュートを設定することを忘れないようにします。

Behaviour

Behaviourでは、クリップごとの振る舞いを実装するクラスで、PlayableBehaviourを継承します。

しかし、トラックの振る舞いの実装は、後述するMixerBehaviourで実装することが大半なので、このクラスでは、クリップごとのパラメータを保持するのみに留めるのがポピュラーなようです。

using System;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
using UnityEngine.UI;

[Serializable]
public class ScreenFaderBehaviour : PlayableBehaviour
{
    public Color color = Color.black;
}

ScreenFaderBehaviourでは、Screenの色をパラメータとして保持しています。

こちらも注意点として、クリップ情報としてタイムラインアセットとして保存されるため、シリアライズ可能である必要があります。なので、[Serializable]アトリビュートを設定することを忘れないようにします。

MixerBehaviour

MixerBehaviourは、カスタムトラックにとって一番大事で、トラックの振る舞いそのものを実装します。このクラスはPlayableBehaviourを継承します。

トラックの振る舞いの実装は、ProcessFrameによって行います。ScreenFaderMixerBehaviourにおけるProcessFrameは、下記のように実装されています。

Color m_DefaultColor;
Image m_TrackBinding;

public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
    m_TrackBinding = playerData as Image;

    if (m_TrackBinding == null)
        return;

    int inputCount = playable.GetInputCount ();

    Color blendedColor = Color.clear;
    float totalWeight = 0f;

    for (int i = 0; i < inputCount; i++)
    {
        float inputWeight = playable.GetInputWeight(i);
        ScriptPlayable<ScreenFaderBehaviour> inputPlayable = (ScriptPlayable<ScreenFaderBehaviour>)playable.GetInput(i);
        ScreenFaderBehaviour input = inputPlayable.GetBehaviour ();

        blendedColor += input.color * inputWeight;
        totalWeight += inputWeight;
    }

    m_TrackBinding.color = blendedColor + m_DefaultColor * (1f - totalWeight);
}

ProcessFrame は、PlayableBehaviourに定義されているメソッドです。
MixerBehaviourの場合、タイムラインを再生している間、毎フレーム呼び出されます。

PlayableBehaviourは、その他にも様々なイベントをフックするためのメソッドを実装しています。

詳細は、ドキュメントを確認してください。必要に応じてオーバーライドして利用します。

ProcessFrameでは毎フレームごとに、クリップ情報をもとに、Bingingされたインスタンスを操作します。

ScreenFaderのフレームごとでの振る舞いの説明

ScreenFaderTrackの場合は、UnityEngine.UI.Imageの色をクリップの情報をもとに変更します。
例えば、上記のクリップの配置の場合は、10フレーム付近では、UI.Imageの色は、他のクリップがかかっていないため、(0, 0, 0, 0)となります。一方で、30フレームから60フレームで、もう1つのクリップがかかっているため、その中間の45フレーム付近では線形補間の場合、2つのクリップの色の中間である(0, 0, 0, 0.5)となります。

それを実装しているのが、下記のコードになります。

m_TrackBinding = playerData as Image;
int inputCount = playable.GetInputCount ();

Color blendedColor = Color.clear;
float totalWeight = 0f;

for (int i = 0; i < inputCount; i++)
{
    // 対象のクリップの重みを取得します
    float inputWeight = playable.GetInputWeight(i);

    // 対象のクリップのBehaviourを取得します     
    ScriptPlayable<ScreenFaderBehaviour> inputPlayable = (ScriptPlayable<ScreenFaderBehaviour>)playable.GetInput(i);
    ScreenFaderBehaviour input = inputPlayable.GetBehaviour ();

    // 重みとクリップの色情報から、ブレンドされた色情報を計算します
    blendedColor += input.color * inputWeight;
    totalWeight += inputWeight;
}
// バインドされたUI.Imageのcolorに、ブレンドされた色を適用します
m_TrackBinding.color = blendedColor + m_DefaultColor * (1f - totalWeight);

playable.GetInputCountで、そのトラックに配置されたクリップ数を、playable.GetInputWeight(int index)及びplayable.GetInput(int index)で、そのフレームでのクリップの重み、及び、Behaviour(つまり、ScreenFaderBehaviour)が取得できます。ScreenFaderTrackでは、その重みにScreenFaderBehaviourのプロパティのcolorをかけ合わせて、色を計算しています。

クリップの重みは、パラメータ(各クリップのBlend Curvesなど)を元にUnityのタイムラインが自動的に計算します。

外部のオブジェクトをTrackに渡す方法

ゲームの要件として、タイムラインアセット内のオブジェクト参照を、動的に差し替えたいケースがあるかと思います(例えばガチャ演出はレアリティごとに共通な仕様として、登場するキャラクターのみを差し替えたい場合など)。

オブジェクトの差し替えには、大きく2つの方法が存在します。

PlayableBindingによるオブジェクトの渡し方

先程から、独自のPlayable Track作成などで登場している、トラックにBindingされたインスタンスを外部から渡すには、そのタイムラインを再生しているPlayableDirectorSetGenericBinding(Object key, Object value)メソッドを利用します。

引数のvalueは、設定したいコンポーネント(Animation TrackであればAnimator)を渡してやります。keyには対象のトラックの参照を渡してやります。

では、どうやってトラックの参照を取得するのかというと、PlayableDirector.playableAsset.outputsプロパティを経由して取得できます。
このプロパティは、このPlayableDirectorが再生しているタイムラインアセット内の全トラックの情報をPlayableBindingのインスタンスとして取得できます。

void Start()
{
    var director = GetComponent<PlayableDirector>();
    foreach (var trackBinding in director.playableAsset.outputs)
    {
        // トラック名を表示します
        Debug.Log("Name: " + trackBinding.streamName);
        // TrackBindingの型情報も取得できます
        Debug.Log("Name: " + trackBinding.outputTargetType.AssemblyQualifiedName);
    }
}

上記のforeachの中で、該当するトラックを選択して、SetGenericBindingでバインドします。

foreach (var trackBinding in director.playableAsset.outputs)
{
    if (trackBinding.streamName == "Animation Track")
    {
        // AddされているAnimatorを、"Animation Track"という名前のトラックにバインド
        director.SetGenericBinding(trackBinding, GetComponent<Animator>());
        break;
    }
}

ただし注意点として、タイムラインアセットからトラックを検索するときに取得できる、PlayableBindingは、トラック名と、要求するインスタンスの型情報のみしか取得できません。
なので、事前にアセットを作成する人と、トラックの命名規則を取り決めておく必要があります。
そうしないと、どのトラックにどのインスタンスをバインドすればわからなくなるためです。

ExposedReferenceへのオブジェクトの渡し方

各クリップのプロパティにExposedReferenceを定義することで、シーンのオブジェクトの参照をランタイムで行う事ができます。PlayableDirectorが再生するタイムラインアセットの、各ExposedReferenceへの参照を渡すには、PlayableDirector.SetReferenceValue(PropertyName name, Object value)を利用します。

valueには渡したいオブジェクトを指定するのですが、nameにはExposedReference.exposedNameを指定します。

しかし、複数のClipで同名のexposedNameを指定していると、その名前でSetReferenceValueをすると、当然同じ参照が入ってしまいます。なので、各Clipごとに、何かしらの方法でユニークな名前をつけてやる必要があります。

ちなみに、特定のトラック内のClipの一覧は、以下の方法で取得できます。

foreach (var trackBinding in director.playableAsset.outputs)
{
    if (trackBinding.streamName == "Sample Exposed Reference Track")
    {
        var track = (SampleExposedReferenceTrack)trackBinding.sourceObject;
        if (track != null)
        {
            foreach (var clip in track.GetClips())
            {
                var sampleClip = clip.asset as SampleExposedReferenceClip;
                Debug.Log("ClipName: " + sampleClip.name);
                Debug.Log("ExposedName: " + sampleClip.newExposedReference.exposedName);
            }
        }
    }
}

ここまででタイムラインの基本的な要素と、拡張方法について説明させていただきました。

タイムラインをゲームで活用するために検討すべき項目

Unityのタイムラインは、アニメーションやサウンド・エフェクトの再生する仕組みと、一連の映像を確認するためのエディタが備わっているため、簡単に品質の高いカットシーンを作成することができます。

ですが、ゲームのバトル中のスキル演出など、ゲームプレイからカットシーンにシームレスに遷移するような要件をタイムラインで実装したい場合は、一工夫必要になるかもしれません。

エフェクトやサウンド(SE)のメモリ管理

ゲーム上でのエフェクトやSEの管理では、メモリフラグメントやGCの発生を抑えるために、ゲームシステムとしてメモリプールを実装し、必要なデータを一括でロードしておき、データを使い回すなどの工夫をすることが多いかと思います。

Unity標準のトラックを用いる場合、エフェクトやSEはタイムラインアセット単位でメモリ上に展開されるため(エフェクトの場合は、PlayableDirectorを保持したPrefab内にエフェクトを含める、SEの場合はオーディオトラックが実際のSEへの参照を保持しており、Playableをロード時にセットで読み込まれる)、これらのメモリ管理はゲームシステムのメモリ管理を外れてしまうことを前提に、設計をしていかないといけません。

また、複数のタイムライン間で、同じデータを再生するとしても、タイムラインごとに重複してデータをロードしてしまうことにも注意が必要です。

では、メモリプールからエフェクトやSEを再生するトラックを作成すればすべて解決かというと、そうではありません。カットシーンの動作を確認するために、ゲームシステムをカットシーンエディタ上で正確に動作するように実装をする必要があります。

つまり、動作確認の手軽さなど「デザイナーさんにとっての使いやすさ」と、メモリ管理などのシステム設計の厳密さなど「プログラマーとして使いやすさ」はトレードオフになります。

キャラクターの管理

ゲーム中のキャラクターは、他のモデルなどと比べるとハイポリなものが多く、メモリ設計上、カットシーンとプレイアブルな用途で2つ読み込む余裕がないことが多いと思います。

その場合は、プレイアブルなキャラクターとカットシーンのキャラクターは、同じものを操作するこのになります。

Unity標準のタイムラインでキャラクターのアニメーションを行うには、Animation Trackを利用します。

Animation Trackを再生している最中は、そのキャラクターを操作するAnimation Controllerを上書きするような形で制御されます。特に、Animation TrackとAnimation Cotrollerで制御がスイッチする際には、注意深く実装を行う必要があるかと思います。

デザイナー用のタイムラインとプログラマー用のタイムラインを用いる方法

これらの問題の解決策の1つとしてデザイナー用のタイムラインと、プログラマー用のタイムラインを併用することを検討しています。

デザイナー用のタイムラインは、主にデザイナーさんがカットシーンを作成・編集するためのタイムラインになります。このタイムラインでは、Unity標準で用意されているアニメーショントラックやコントロールトラックを用いて、キャラクターのモーションやエフェクト、SEを再生します。

プログラマー用のタイムラインは、ゲーム内に実装として取り込むときに利用します。
このタイムラインは、カスタムトラックを用いることで、こちらの用意したゲームシステムを経由して、キャラクターアニメーション制御やエフェクト・SE再生を行うように構成されています。

このプログラマー用のタイムラインは、コンバーターを用意し、デザイナー用のタイムラインから各種トラック一覧を取り出し、こちらの用意したカスタムトラックに変換することで実現することを検討しています。

このような仕組みを取ることで、デザイナーさんは、Unityのタイムラインの知識のみでカットシーンの作成を行え、また、ゲームのカットシーンエディターを作り込む工数を割かなくても、制作を始める事ができます。

また、プログラマー用のタイムラインに変換することで、キャラクター制御やエフェクト・SE再生をゲームシステムを経由して実行できるため、メモリ管理なども柔軟に行うことができます。

2つのタイムラインを用意することで、Unityのタイムラインエディターの利便性と、ゲーム内での最適化のし易さの、両方のメリットを享受できればと考えています。

まとめ

本稿では、タイムラインの構成から、拡張方法についての基礎的な要素と、それをゲームで導入するにあたって、どのような課題が存在するのか、そしてどのように解決しようとしているのか、その1例を紹介させていただきました。

当然、ゲームの特性やプロジェクトの人数規模や構成によっても、この課題の解決方法は違うと思いますが、1つの方法として、参考になればなと思っています。

参考