この章では、Unityでゲームを開発する際によく使用されるサードパーティライブラリを導入する上で、パフォーマンスの観点から気をつけるべきことを紹介します。
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: }
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: }
Unity Editorで再生中に、[DOTween]という名前のGameObjectを選択することで、Inspector上からDOTweenの状態や設定を確認できます。
図12.1: [DOTween] GameObject
図12.2: DOTween Inspector
関連するGameObjectが破棄されているにもかかわらず動き続けているTweenがないか、また、Pause状態で破棄されずにリークしているTweenがないかなど調査するときに役立ちます。
UniRx*2はUnityに最適化されたReactive Extensionsを実装したライブラリです。豊富なオペレーター群やUnity向けのヘルパーにより、複雑な条件のイベントハンドリングを簡潔に記述できます。
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: }
UniTaskはUnityでハイパフォーマンスな非同期処理を実現するための強力なライブラリで、値型ベースのUniTask型によりゼロアロケーションで非同期処理を行えることが特徴です。またUnityのPlayerLoopに沿った実行タイミングの制御も可能なため、従来のコルーチンを完全に置き換えることができます。
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に変換することが可能です。
UniTask Trackerを使用することで待機中のUniTaskと、その生成時のスタックトレースを可視化できます。
図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()で得られるCancellationTokenをWaitForDeadAsyncへ渡し、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が投げられます。どちらの挙動が望ましいかは状況によって異なりますので、適切な実装を選択するとよいでしょう。