モバイルゲームのパフォーマンスを最適化しよう:Unity のトップエンジニアが語るプロファイリング、メモリ、コードアーキテクチャのヒント

Unity のエンジニアたちがモバイルゲームの最適化に関する知見のシェアを始めるとすぐに、元々予定していた 1 本のブログ記事に収まりきらないほどの素晴らしい情報があることに気づきました。そこで私たちは、彼らの膨大な知識を e ブックとしてまとめ(ダウンロードはこちら)、そこに収録されている 75 以上の実用的なヒントの一部を紹介する一連のブログ記事を作成することにしました。
このシリーズの最初の記事では、プロファイリング、メモリ、コードアーキテクチャの観点から、ゲームのパフォーマンスを向上させる方法をご紹介します。今後 2、3 週間の間に、さらに 2 本の記事を公開します。1 本目の記事では UI の物理を、2 本目ではオーディオとアセット、プロジェクトの構成、グラフィックスについて説明します。
今すぐすべての内容に目を通したいという方は、e ブックを無料でダウンロードして内容をご覧ください。
それでは、今回の内容に入っていきましょう。
まず何よりも、プロファイリングや、モバイルゲームのパフォーマンスデータの収集・活用を行うプロセスからスタートさせるべきでしょう。モバイルゲームのパフォーマンス最適化は、まさにここから始まるからです。
早期に、頻繁に、そしてターゲットデバイス上でプロファイリングを行う
Unity プロファイラーは、アプリケーションに関する重要なパフォーマンス情報を提供してくれますが、当然ながら使わないことには役に立ちません。プロジェクトのプロファイリングは、ゲームをまさに出荷しようというタイミングではなく、開発の初期段階から着手するべきです。不具合やスパイクが発生したら、すぐに調査すべきです。プロジェクトの「パフォーマンスシグネチャー」を作成することで、新たな問題をより簡単に発見することができるようになります。
エディターでプロファイリングするだけでも、ゲームをさまざまなシステムで走らせた時の相対的なパフォーマンスが大まかに分かりますが、個別のデバイスでプロファイリングすることでより正確な知見を得ることができます。できる限りターゲットデバイス上で開発ビルドを使ってプロファイリングをするべきでしょう。サポートする予定のデバイスのうち、最高スペックのものと最低スペックのものの両方に対してプロファイリングと最適化を行うようにしてください。
Unity プロファイラーに加えて、iOS や Android のネイティブツールを活用して、各プラットフォームのエンジン上でさらにパフォーマンステストを行うことができます。
- iOS では、Xcode と Instruments を使用します。
- Android では、Android Studio と Android Profiler を使用します。
ハードウェアによっては、追加のプロファイリングツールを利用することができます(例えば、Arm Mobile Studio、Intel VTune、Snapdragon Profiler など)。詳しくは、Unity で作られたアプリケーションのプロファイリング をご覧ください。
正しい場所の最適化に注力する
ゲームのパフォーマンスを低下させている原因を当て推量や決めつけで導き出してはいけません。Unity プロファイラーやプラットフォームの専用ツールを使って、遅くなっている原因を正確に突き止めましょう。
もちろん、ここで説明した最適化手法のすべてが、皆さんご自身のアプリケーションに当てはまるわけではありません。あるプロジェクトではうまくいったことでも、ご自身のプロジェクトではうまくいかないかもしれません。真のボトルネックを見極め、自分の仕事に役立つことに力を注ぐようにしましょう。
Unity プロファイラーの仕組みを理解する
Unity プロファイラーは、実行時のラグやフリーズの原因を検出し、特定のフレームや時刻で何が起こっているかをよりよく理解するのに役立ちます。デフォルトでは、CPU と Memory のトラックが有効になっています。ゲームに応じて、Renderer、Audio、Physics などの補助的なプロファイラーモジュールを監視することができます(物理演算が多いゲームや、音楽ベースのゲームなどでの使用を想定)。

Development Build と Autoconnect Profiler をチェックして、デバイスにアプリケーションをビルドします。手動で接続してアプリの起動時間を短縮することもできます。

ターゲットとなるプラットフォームを選択してください。Record ボタンは、アプリケーション実行中の数秒間(デフォルトでは 300 フレーム)をトラッキングします。より長くキャプチャーする必要がある場合は Unity > Preferences > Analysis > Profiler > Frame Count から、フレーム数を 2000 まで増やすことができます。キャプチャーするフレーム数を増やすと、Unity エディターがより多くの CPU 処理とメモリを占有するようになりますが、これが役に立つシナリオもあります。
これは、ProfileMarkers で明示的にラップされたコードのタイミング(MonoBehaviour の Start メソッドや Update メソッド、特定の API コールなど)のプロファイリングを行う、命令ベースのプロファイラーです。また、Deep Profile 設定を使用すると、Unity はスクリプトコード内のすべての関数呼び出しの最初と最後をプロファイリングすることができ、アプリケーションのどの部分が速度低下の原因になっているかを正確に知ることができます。

ゲームをプロファイリングする際には、スパイクと平均的なフレームのコストの両方をカバーすることをお勧めします。各フレームで発生する高価な処理の把握とその最適化は、目標フレームレートが実現できていないアプリケーションの動作を改善する上で有用です。スパイクを探すときは、まず高価な処理(物理、AI、アニメーションなど)とガベージコレクションについて調べます。
ウィンドウ内をクリックすると、特定のフレームを解析することができます。次に、Timeline または Hierarchy ビューのいずれかを見てしかるべき対処を行います。
- Timeline は、特定のフレームで使われている時間の内訳を視覚化します。これにより、アクティビティ同士が互いにどのように関連しているか、また異なるスレッド間でどのように関連しているかを可視化することができます。このオプションは、CPU バウンドか GPU バウンドかを判断するために使います。
- Hierarchy は、ProfileMarker の階層をグループ化して表示します。これにより、ミリ秒単位の時間コスト(Time ms と Self ms)に基づいてサンプルをソートすることができます。また、関数に対する呼び出し(Calls)の数や、フレームのマネージヒープメモリ(GC Alloc)を数えることもできます。

Unity プロファイラーの概要の全文はこちらでご覧ください。プロファイリングを初めて行う方には、動画「Introduction to Unity Profiling」も参考になるでしょう。
プロジェクト内の何らかの要素を最適化する前に、プロファイラーの .data ファイルを保存します。その後、変更の実装を行い、保存した .data ファイルを使って、変更を行う前と後を比較します。プロファイリング、最適化、比較というサイクルでパフォーマンスを向上させることができます。これを繰り返して、アプリケーションを洗練させていきましょう。
Profile Analyzer を使う
このツールでは、複数のフレームのプロファイラーデータを集約し、参考になる情報を含むフレームを探し出すことができます。プロジェクトに変更を加えた後にプロファイラーがどうなるかを見てみたいと思いませんか。Compare ビューでは、2 つのデータセットを読み込み区別することで、変更をテストしてその結果を改善することができます。Profile Analyzer は、Unity のパッケージマネージャーから利用できます。

フレームごとに決められた時間予算で動かす
各フレームには、目標とする fps(毎秒ごとのフレーム数)に応じた時間予算が設定されています。理想的には、30 fps で動作するアプリケーションの場合、1 フレームあたり約 33.33 ms(1000ms / 30fps)使えることになります。同様に、60 fps を目標とすると、1 フレームあたり使える時間は約 16.66 ms となります。
限られた時間(カットシーンや読み込みシーケンスなど)であれば、デバイスがこの予算を超えても許容できますが、それを超えて予算を超えたままにしてはいけません。
デバイスの温度を考慮する
また、モバイル環境ではデバイスが過熱したり、OS が CPU や GPU にサーマルスロットルを掛けたりするため、ずっと時間予算を使えるだけ使うことはお勧めできません。フレーム間で冷却できるように、使える時間予算の約 65% くらいに抑えることをお勧めします。一般的なフレーム予算は、30 fps なら約 22 ms / フレーム、60 fps なら約 11 ms / フレームとなります。
ほとんどのモバイル機器は、デスクトップ機器のようなアクティブな冷却機能を持っていません。物理的な熱量はパフォーマンスに直接影響を与えます。
デバイスが高温で動作している場合、長期的な懸念材料ではなくても、プロファイラーがパフォーマンスの低下と認識して報告することがあります。プロファイリングによるオーバーヒートを防ぐには、短時間のプロファイリングを繰り返し行います。これにより、デバイスが冷却する時間を取ることができ、実際にアプリを走らせた時に近い状況をシミュレーションできます。一般的な推奨事項として、一度プロファイリングを行った後は、デバイスを 10~15 分ほど冷やしてから再度プロファイリングを行うことをお勧めします。
GPU バウンドか CPU バウンドかを見極める
プロファイラーは、CPU が割り当てられたフレーム予算を超えているのか、それとも GPU が原因なのかを明らかにしてくれます。これは、以下のように Gfx を先頭にしたマーカーを表示することで実現しています。
- Gfx.WaitForCommands マーカーが表示されている場合は、レンダリングスレッドの準備ができていることを意味しますが、メインスレッドのボトルネックを待っている可能性があります。
- Gfx.WaitForPresent が頻繁に出てくる場合は、メインスレッドの準備はできていたが、GPU がフレームを表示するのを待っていたことを意味します。
Unity では、ユーザーが作成したコードやスクリプトに対する自動メモリ管理を採用しています。値型のローカル変数のような小さなデータは、スタックに割り当てられます。サイズの大きなデータや長期間保存されるデータは、マネージヒープに割り当てられます。
ガベージコレクターは、使用されていないヒープメモリを特定し、定期的に解放します。これは自動的に実行されますが、ヒープ内のすべてのオブジェクトを調査するプロセスにより、ゲームが止まったり、動作が遅くなったりすることがあります。
メモリ使用量を最適化するには、ヒープメモリの割り当てと解放のタイミングを意識し、ガベージコレクションの影響を最小限に抑えることが必要です。 詳しくはマニュアルの「マネージヒープ」の項をご覧ください。

メモリプロファイラーを使う
この独立したアドオン(パッケージマネージャーで実験的パッケージまたはプレビューパッケージとして利用可能)は、管理されているヒープメモリのスナップショットを取り、断片化やメモリリークなどの問題を特定するのに役立ちます。
ツリーマップビューでクリックすると、変数をトレースして、メモリ上に保持されているネイティブオブジェクトをたどることができます。ここで、大き過ぎるテクスチャや重複したアセットなど、一般的なメモリ消費の問題を特定することができます。
Unity のメモリプロファイラーを活用すれば、より効率よくメモリを使えるようになります。また、公式のメモリプロファイラーのドキュメントもご覧ください。
ガベージコレクション(GC)の影響を軽減する
Unity は、Boehm-Demers-Weiser ガベージコレクターを使用しています。このガベージコレクターは、プログラムコードの実行を停止し、その作業が完了した後に通常の実行を再開します。
不必要なヒープの割り当てがあると、GC スパイクが発生する可能性があるので注意が必要です。
- 文字列: C# では、文字列は値型ではなく、参照型です。不要な文字列の作成や操作は無くしましょう。JSON や XML のような文字列ベースのデータファイルの解析は避け、代わりに ScriptableObject、MessagePack、Protobuf のようなフォーマットでデータを保存するようにしましょう。実行時に文字列を構築する必要がある場合は、StringBuilder クラスを使用してください。
- Unity の関数呼び出し:一部の関数ではヒープの割り当てが行われます。ループの途中で配列を割り当てるのではなく、配列への参照をキャッシュするようにしましょう。また、ゴミを発生させない関数を使うようにしましょう。例えば、GameObject.tag と文字列を手動で比較する代わりに、GameObject.CompareTag を使用します(新しい文字列を返すとゴミが発生するため)。
- ボクシング:参照型の変数の代わりに値型の変数を渡すことは避けてください。これにより、一時的なオブジェクトが作成され、それに伴う潜在的なゴミが、暗黙のうちに値型を object 型に変換します(例:int i = 123; object o = i )。その代わりに、渡したい値型の具体的なオーバーライドを提供するようにしてください。これらのオーバーライドにはジェネリックを使用することもできます。
- コルーチン:yield はゴミを出しませんが、新しい WaitForSeconds オブジェクトを作成するとゴミを出してしまいます。WaitForSeconds オブジェクトを yield 行で作成するのではなく、キャッシュして再利用しましょう。
- LINQ と正規表現:どちらも裏側でボクシングを行って、ゴミを発生させてしまいます。パフォーマンスを気にする場合は、LINQ と正規表現を避けてください。for ループを記述し、新しい配列を作成する代わりにリストを使用します。
可能ならガベージコレクションの時間を測る
ガベージコレクションのフリーズがゲームの特定のポイントで影響を与えないことが分かっているなら、System.GC.Collect でガベージコレクションをトリガーすることができます。
これを利用した例については、Understanding Automatic Memory Management を参照してください。
インクリメンタルガベージコレクターを使用して、GC の作業を分割する
インクリメンタルガベージコレクションでは、プログラムの実行中に 1 回長い中断を行うのではなく、それよりはるかに短い中断を複数回行い、ワークロードを多くのフレームに分散させます。ガベージコレクションがパフォーマンスに影響を与えている場合は、このオプションを有効にして、GC によるスパイクの問題を軽減できるかどうかを試してみてください。Profile Analyzer を使用して、アプリケーションにどのようなメリットがあるかを確認してください。

Unity の PlayerLoop には、ゲームエンジンのコアとやり取りするための関数が含まれています。この構造体には、初期化やフレームごとの更新を処理するいくつかのシステムが含まれています。すべてのスクリプトは、この PlayerLoop に依存してゲームプレイを生成します。
プロファイリングでは、PlayerLoop にプロジェクトのユーザーコードが表示されます(EditorLoop には Editor コンポーネントが表示されます)。


PlayerLoop とスクリプトのライフサイクルについては理解しておきましょう。
以下のヒントを参考にして、スクリプトを最適化することができます。
Unity PlayerLoop を理解する
Unity のフレームループの実行順序はしっかり理解しておきましょう。すべての Unity スクリプトは、いくつかのイベント関数をあらかじめ決められた順序で実行します。Awake、Start、Update など、スクリプトのライフサイクルを作る関数の違いを理解する必要があります。
イベント関数の具体的な実行順序については、スクリプトライフサイクルのフローチャート を参照してください。
毎フレーム実行されるコードを最小限に抑える
毎フレーム実行する必要があるコードはどれか、ということはよく吟味する必要があります。Update、LateUpdate、FixedUpdate から不要なロジックを削除します。これらのイベント関数は、フレームごとに更新しなければならないコードを置くのに便利な場所ではありますが、そんなに高い頻度で更新する必要のないロジックは排除する必要があります。可能な限り、状況が変化したときにのみロジックを実行するようにしましょう。
Update をどうしても使用する必要がある場合は、n フレームごとにコードを実行する方法も検討してください。これは、重いワークロードを複数のフレームに分散させる一般的な手法であるタイムスライシングを適用する方法の一つです。この例では、3 フレームに 1 回、ExampleExpensiveFunction を実行します。
Start/Awake に重いロジックを置くのを避ける
最初のシーンがロードされると、これらの関数が各オブジェクトに対して呼び出されます。
- Awake
- OnEnable
- Start
アプリケーションが最初のフレームをレンダリングするまでは、これらの関数で高価なロジックを使用しないようにしてください。そうしないと、必要以上に読み込み時間が長くなってしまうことがあります。
最初のシーンの読み込みに関する詳細については、イベント機能の実行順序 を参照してください。
空の Unity イベントを避ける
空の MonoBehaviour でもリソースが必要なので、空の Update や LateUpdate メソッドは削除してください。
こういうメソッドをテストする時は、以下のようにプリプロセッサーディレクティブを使用してください。
こうすると、Update をエディター内で自由に使ってテストすることができます。不要なオーバーヘッドがビルドに混入することはありません。
Debug.Log 命令の削除
Log 命令(特に Update、LateUpdate、FixedUpdate の中で使われるもの)は、パフォーマンスを大きく低下させる原因となります。ビルドを行う前に Log 命令を無効にしてください。
これを簡単に行うには、プリプロセッサーディレクティブを使う方法もありますが、Conditional 属性を作ることも検討してください。例えば、次のようなカスタムクラスを作成します。

カスタムクラスでログメッセージを生成します。ENABLE_LOG プリプロセッサーを Player Settings で無効にすると、Log ステートメントを一挙に除去することができます。
文字列パラメーターの代わりにハッシュ値を使用する
Unity は Animator、Material、Shader のプロパティを内部的に扱うのに文字列名を使いません。高速化のために、すべてのプロパティ名はハッシュ化されプロパティ ID となります。この ID は実際にプロパティを指すために使用されます。
Animator、Material、Shader の Set または Get メソッドを使用する場合、文字列値のメソッドではなく、整数値のメソッドを使用してください。文字列値メソッドは、単純に文字列のハッシュ化を行い、ハッシュ化された ID を整数値メソッドに転送します。
Animator のプロパティ名には Animator.StringToHash を、Material と Shader のプロパティ名には Shader.PropertyToID を使用してください。
正しいデータ構造を選ぶ
データ構造の選択は、1 フレームあたり数千回の反復処理を行う際の効率に影響します。コレクションに List、Array、Dictionary のどれを使うか迷っていませんか?正しい構造を選択するための一般的なガイドとして、MSDN の コレクションとデータ構造体に従うことをおすすめします。
実行時にコンポーネントを追加しない
実行時の AddComponent 呼び出しには、いくらかコストがかかります。Unity は、実行時にコンポーネントを追加する際に、重複するコンポーネントやその他の必須コンポーネントをチェックする必要があります。
必要なコンポーネントがすでにセットアップされているプレハブをインスタンス化するほうが、一般的にパフォーマンスが高くなります。
ゲームオブジェクトとコンポーネントをキャッシュする
GameObject.Find、GameObject.GetComponent、Camera.main(2020.2 より前のバージョンの場合)はコストがかかるので、Update メソッドの中では呼び出さないようにした方が良いでしょう。代わりに、Start で呼び出し、結果をキャッシュします。
以下のコードは、繰り返し行われる GetComponent 呼び出しの非効率的な使い方を示す例です。
その代わりに、GetComponent を一度だけ呼び、関数の結果をキャッシュするようにします。キャッシュされた結果は、Update で再利用することができます。その際、さらに GetComponent を呼び出す必要はありません。
オブジェクトプールを使う
Instantiate と Destroy は、ゴミやガベージコレクション(GC)のスパイクを発生させる可能性があり、また一般的に時間のかかる処理です。ゲームオブジェクトを常にインスタンス化して破壊するのではなく(例:銃で弾を撃つ)、再利用やリサイクルが可能な事前に割り当てられたオブジェクトのプール を使用します。

こうした再利用可能なインスタンスは、CPU のスパイクが目立たないゲーム中のある時点(例えば、メニュー画面にいる間)で作成します。このオブジェクトの「プール」をコレクションで追跡します。ゲームプレイ中は、必要に応じて次の利用可能なインスタンスを有効にし、オブジェクトを破壊するのではなく無効にし、プールに戻すだけです。

これにより、プロジェクト内のマネージ割り当ての数を減らし、ガベージコレクションの問題を防ぐことができます。
Unity でシンプルなオブジェクトプールシステムを作成する方法はこちらのチュートリアルで解説されています。
ScriptableObject を使う
不変の値や設定は MonoBehaviour ではなく、ScriptableObject に保存しましょう。ScriptableObject は、プロジェクト内に常駐するアセットで、一度だけ設定すればよいものです。ゲームオブジェクトに直接アタッチすることはできません。
ScriptableObject にフィールドを作成して値や設定を保存し、その ScriptableObject を MonoBehaviour で参照します。

これらのフィールドを ScriptableObject から使用することで、その MonoBehaviour を持つオブジェクトをインスタンス化するたびに発生する、不要なデータの重複を防ぐことができます。
こちらの「Introduction to ScriptableObjects」チュートリアルを見て、ScriptableObject をどのように皆さんのプロジェクトに役立てられるか、考えてみましょう。また、関連するドキュメントはこちらでご覧ください。
次回のブログ記事では、グラフィックスと GPU の最適化について詳しくご紹介します。今すぐチームからのヒントとコツをすべて乗せたリスト全体を見てみたい方は、こちらのページからフル版の e ブックをダウンロードしてご覧ください。

Integrated Support サービスについてもっと知りたい、あるいはチームにエンジニアと直接やりとりする窓口や、専門家のアドバイスやプロジェクトのベストプラクティスガイダンスを提供したいとお考えの方は、こちらのページでご案内している Unity のサクセスプランをご検討ください。
私たちは皆さんの Unity アプリケーションに最大限のパフォーマンスを発揮させるお手伝いをしたいと考えています。他にも取り上げてほしい最適化に関わるトピックがありましたら、コメントセクションでお知らせください。