この章では、Unityでゲームを開発する際によく使用されるサードパーティライブラリを導入する上で、パフォーマンスの観点から気をつけるべきことを紹介します。
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)); } } |
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(); } |
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:
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); } |
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:
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()で得られるCancellationTokenをWaitForDeadAsyncへ渡し、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が投げられます。どちらの挙動が望ましいかは状況によって異なりますので、適切な実装を選択するとよいでしょう。