第12章 Tuning Practice - Third Party

この章では、Unityでゲームを開発する際によく使用されるサードパーティライブラリを導入する上で、パフォーマンスの観点から気をつけるべきことを紹介します。

12.1 DOTween

DOTween*1は、スクリプトで滑らかなアニメーションを実現できるライブラリです。たとえば、拡大して縮小するアニメーションは以下のコードのように簡単に記述できます。

リスト12.1: DOTween使用例

public class Example : MonoBehaviour {
    public void Play() {
        DOTween.Sequence()
            .Append(transform.DOScale(Vector3.one * 1.5f, 0.25f))
            .Append(transform.DOScale(Vector3.one, 0.125f));
    }
}

12.1.1 SetAutoKill

DOTween.Sequence()transform.DOScale(...)など、Tweenを生成する処理は基本的にメモリアロケーションを伴うため、頻繁に再生されるアニメーションはインスタンスの再利用を検討しましょう。

デフォルトではアニメーション完了時に自動的にTweenが破棄されてしまうので、SetAutoKill(false)でこれを抑制します。最初の使用例は、次のコードに置き換えることができます。

リスト12.2: Tweenインスタンスを再利用する

    private Tween _tween;

    private void Awake() {
        _tween = DOTween.Sequence()
            .Append(transform.DOScale(Vector3.one * 1.5f, 0.25f))
            .Append(transform.DOScale(Vector3.one, 0.125f))
            .SetAutoKill(false)
            .Pause();
    }

    public void Play() {
        _tween.Restart();
    }

SetAutoKill(false)を呼び出したTweenは、明示的に破棄しなければリークしてしまうため注意が必要です。不要になったタイミングでKill()を呼び出すか、後述するSetLinkを使用するとよいでしょう。

リスト12.3: Tweenを明示的に破棄する

    private void OnDestroy() {
        _tween.Kill();
    }

SetAutoKill(false)を呼び出したTweenや、SetLoops(-1)で無限に繰り返し再生されるようにしたTweenは自動的には破棄されなくなるため、そのライフタイムを自前で管理する必要があります。そのようなTweenにはSetLink(gameObject)で関連するGameObjectと紐付け、GameObjectがDestroyされると同時にTweenも破棄されるようにするとよいでしょう。

リスト12.4: TweenをGameObjectのライフタイムに紐付ける

    private void Awake() {
        _tween = DOTween.Sequence()
            .Append(transform.DOScale(Vector3.one * 1.5f, 0.25f))
            .Append(transform.DOScale(Vector3.one, 0.125f))
            .SetAutoKill(false)
            .SetLink(gameObject)
            .Pause();
    }

12.1.3 DOTween Inspector

Unity Editorで再生中に、[DOTween]という名前のGameObjectを選択することで、Inspector上からDOTweenの状態や設定を確認できます。

[DOTween] GameObject

図12.1: [DOTween] GameObject

DOTween Inspector

図12.2: DOTween Inspector

関連するGameObjectが破棄されているにもかかわらず動き続けているTweenがないか、また、Pause状態で破棄されずにリークしているTweenがないかなど調査するときに役立ちます。

12.2 UniRx

UniRx*2はUnityに最適化されたReactive Extensionsを実装したライブラリです。豊富なオペレーター群やUnity向けのヘルパーにより、複雑な条件のイベントハンドリングを簡潔に記述できます。

12.2.1 購読の解除

UniRxでは、ストリーム発行元のIObservableに対して購読 (Subscribe) することで、そのメッセージの通知を受け取ることができます。

この購読時に、通知を受け取るためのオブジェクトや、メッセージを処理するコールバックなどのインスタンスが生成されます。これらのインスタンスがSubscribeした側の寿命を超えてメモリに残り続けるのを避けるため、通知を受け取る必要がなくなった場合は、基本的にSubscribeした側の責任で購読を解除しましょう。

購読を解除する方法はいくつかありますが、パフォーマンスを考慮する場合Subscribeの戻り値のIDisposableを保持して明示的にDisposeするのがよいでしょう。

リスト12.5:

public class Example : MonoBehaviour {
    private IDisposable _disposable;

    private void Awake() {
        _disposable = Observable.EveryUpdate()
            .Subscribe(_ => {
                // 毎フレーム実行する処理
            });
    }

    private void OnDestroy() {
        _disposable.Dispose();
    }
}

またMonoBehaviourを継承したクラスであればAddTo(this)を呼ぶことで、自身がDestroyされるタイミングで自動的に解除することもできます。Destroyを監視するため内部的にAddComponentが呼ばれるオーバーヘッドがありますが、記述が簡潔になるこちらを利用するのもよいでしょう。

リスト12.6:

    private void Awake() {
        Observable.EveryUpdate()
            .Subscribe(_ => {
                // 毎フレーム実行する処理
            })
            .AddTo(this);
    }

12.3 UniTask

UniTaskはUnityでハイパフォーマンスな非同期処理を実現するための強力なライブラリで、値型ベースのUniTask型によりゼロアロケーションで非同期処理を行えることが特徴です。またUnityのPlayerLoopに沿った実行タイミングの制御も可能なため、従来のコルーチンを完全に置き換えることができます。

12.3.1 UniTask v2

UniTaskは2020年6月、メジャーバージョンアップになるUniTask v2がリリースされました。UniTask v2はasyncメソッド全体のゼロアロケーション化など大幅な性能改善と、非同期LINQ対応や外部アセットのawaitサポートなどの機能追加が行われています。*3

一方で、UniTask.Delay(...)などFactoryで返すタスクが呼び出し時に起動されたり、通常のUniTaskインスタンスへの複数回awaitが禁止*4されるなど、破壊的な変更も含まれるためUniTask v1からアップデートする際は注意が必要です。しかしながらアグレッシブな最適化によりさらにパフォーマンスが向上しているため、基本的にはUniTask v2を使用するとよいでしょう。

[*4] UniTask.Preserveを使用することで複数回awaitできるUniTaskに変換することが可能です。

12.3.2 UniTask Tracker

UniTask Trackerを使用することで待機中のUniTaskと、その生成時のスタックトレースを可視化できます。

UniTask Tracker

図12.3: UniTask Tracker

たとえば、何かに衝突したら_hpが1ずつ減っていくMonoBehaviourがあるとします。

リスト12.7:

public class Example : MonoBehaviour {
    private int _hp = 10;

    public UniTask WaitForDeadAsync() {
        return UniTask.WaitUntil(() => _hp <= 0);
    }

    private void OnCollisionEnter(Collision collision) {
        _hp -= 1;
    }
}

このMonoBehaviourの_hpが減り切る前にDestroyされた場合、それ以上_hpが減ることはないためWaitForDeadAsyncの戻り値のUniTaskは完了する機会を失い、そのままずっと待機し続けてしまいます。

このように終了条件の設定ミスなどでリークしているUniTaskがないか、このツールを用いて確認するとよいでしょう。

タスクのリークを防ぐ

例示したコードでタスクがリークするのは、終了条件を満たす前に自身がDestroyされるケースを考慮できていないのが原因でした。

これには、シンプルに自身がDestroyされていないかチェックする。または自身へのthis.GetCancellationTokenOnDestroy()で得られるCancellationTokenWaitForDeadAsyncへ渡し、Destroy時にタスクがキャンセルされるようにする、といった対応が考えられます。

リスト12.8:

    // 自身がDestroyされているかチェックするパターン
    public UniTask WaitForDeadAsync() {
        return UniTask.WaitUntil(() => this == null || _hp <= 0);
    }

    // CancellationTokenを渡すパターン
    public UniTask WaitForDeadAsync(CancellationToken token) {
        return UniTask.WaitUntil(
            () => _hp <= 0,
            cancellationToken: token);
    }

リスト12.9: WaitForDeadAsync(CancellationToken) 呼び出し例

    Example example = ...
    var token = example.GetCancellationTokenOnDestroy();
    await example.WaitForDeadAsync(token);

Destroy時に前者のUniTaskは何事もなく完了しますが、後者はOperationCanceledExceptionが投げられます。どちらの挙動が望ましいかは状況によって異なりますので、適切な実装を選択するとよいでしょう。