第12章 Tuning Practice - Third Party

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

12.1 DOTween

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

リスト12.1: DOTween使用例

 1: public class Example : MonoBehaviour {
 2:     public void Play() {
 3:         DOTween.Sequence()
 4:             .Append(transform.DOScale(Vector3.one * 1.5f, 0.25f))
 5:             .Append(transform.DOScale(Vector3.one, 0.125f));
 6:     }
 7: }

12.1.1 SetAutoKill

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

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

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

 1:     private Tween _tween;
 2: 
 3:     private void Awake() {
 4:         _tween = DOTween.Sequence()
 5:             .Append(transform.DOScale(Vector3.one * 1.5f, 0.25f))
 6:             .Append(transform.DOScale(Vector3.one, 0.125f))
 7:             .SetAutoKill(false)
 8:             .Pause();
 9:     }
10: 
11:     public void Play() {
12:         _tween.Restart();
13:     }

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

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

 1:     private void OnDestroy() {
 2:         _tween.Kill();
 3:     }

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

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

 1:     private void Awake() {
 2:         _tween = DOTween.Sequence()
 3:             .Append(transform.DOScale(Vector3.one * 1.5f, 0.25f))
 4:             .Append(transform.DOScale(Vector3.one, 0.125f))
 5:             .SetAutoKill(false)
 6:             .SetLink(gameObject)
 7:             .Pause();
 8:     }

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:

 1: public class Example : MonoBehaviour {
 2:     private IDisposable _disposable;
 3: 
 4:     private void Awake() {
 5:         _disposable = Observable.EveryUpdate()
 6:             .Subscribe(_ => {
 7:                 // 毎フレーム実行する処理
 8:             });
 9:     }
10: 
11:     private void OnDestroy() {
12:         _disposable.Dispose();
13:     }
14: }

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

リスト12.6:

 1:     private void Awake() {
 2:         Observable.EveryUpdate()
 3:             .Subscribe(_ => {
 4:                 // 毎フレーム実行する処理
 5:             })
 6:             .AddTo(this);
 7:     }

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:

 1: public class Example : MonoBehaviour {
 2:     private int _hp = 10;
 3: 
 4:     public UniTask WaitForDeadAsync() {
 5:         return UniTask.WaitUntil(() => _hp <= 0);
 6:     }
 7: 
 8:     private void OnCollisionEnter(Collision collision) {
 9:         _hp -= 1;
10:     }
11: }

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

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

タスクのリークを防ぐ

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

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

リスト12.8:

 1:     // 自身がDestroyされているかチェックするパターン
 2:     public UniTask WaitForDeadAsync() {
 3:         return UniTask.WaitUntil(() => this == null || _hp <= 0);
 4:     }
 5: 
 6:     // CancellationTokenを渡すパターン
 7:     public UniTask WaitForDeadAsync(CancellationToken token) {
 8:         return UniTask.WaitUntil(
 9:             () => _hp <= 0,
10:             cancellationToken: token);
11:     }

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

 1:     Example example = ...
 2:     var token = example.GetCancellationTokenOnDestroy();
 3:     await example.WaitForDeadAsync(token);

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