LMK をデバッグする

Unity ゲームで LMK を解決するには、体系的なプロセスが必要です。

図 1. Unity ゲームでメモリ不足による強制終了(LMK)を解決する手順。

メモリ スナップショットを取得する

Unity Profiler を使用して、Unity 管理メモリのスナップショットを取得します。図 2 は、Unity がゲーム内のメモリを処理するために使用するメモリ管理レイヤを示しています。

図 2. Unity のメモリ管理の概要。

マネージド メモリ

Unity のメモリ管理は、マネージド ヒープとガベージ コレクタを使用してメモリを自動的に割り当て、割り当てる制御されたメモリレイヤを実装します。マネージド メモリ システムは、Mono または IL2CPP に基づく C# スクリプト環境です。マネージド メモリ システムの利点は、ガベージ コレクタを使用してメモリ割り当てを自動的に解放することです。

C# のアンマネージド メモリ

アンマネージ C# メモリレイヤは、ネイティブ メモリレイヤへのアクセスを提供し、C# コードを使用しながらメモリ割り当てを正確に制御できるようにします。このメモリ管理レイヤには、Unity.Collections 名前空間と、UnsafeUtility.MallocUnsafeUtility.Free などの関数を介してアクセスできます。

ネイティブ メモリ

Unity の内部 C/C++ コアは、ネイティブ メモリシステムを使用して、シーン、アセット、グラフィック API、ドライバ、サブシステム、プラグイン バッファを管理します。直接アクセスは制限されますが、Unity の C# API を使用してデータを安全に操作し、効率的なネイティブ コードを活用できます。ネイティブ メモリは直接操作する必要はほとんどありませんが、Profiler を使用してパフォーマンスに対するネイティブ メモリの影響をモニタリングし、設定を調整してパフォーマンスを最適化できます。

図 3 に示すように、メモリは C# とネイティブ コードの間で共有されません。C# で必要なデータは、必要になるたびにマネージド メモリ空間に割り当てられます。

マネージド ゲームのコード(C#)がエンジンのネイティブ メモリデータにアクセスするには、たとえば、GameObject.transform の呼び出しでネイティブ呼び出しを行い、ネイティブ領域のメモリデータにアクセスしてから、Bindings を使用して C# に値を返します。バインディングにより、各プラットフォームの適切な呼び出し規約が保証され、マネージド型のネイティブ型への自動マーシャリングが処理されます。

これは、変換プロパティにアクセスするためのマネージド シェルがネイティブ コードに保持されるため、初回のみ発生します。transform プロパティをキャッシュに保存すると、マネージド コードとネイティブ コード間の呼び出しの回数を減らすことができますが、キャッシュ保存の有用性は、プロパティが使用される頻度によって異なります。また、これらの API にアクセスするときに、Unity はネイティブ メモリの一部をマネージド メモリにコピーしません。

図 3. C# マネージ コードからネイティブ メモリにアクセスする。

詳しくは、Unity のメモリの概要をご覧ください。

また、ゲームをスムーズに実行するためには、メモリ予算を設定することが重要です。メモリ消費量に関する分析システムやレポート システムを実装することで、新しいリリースごとにメモリ予算を超えないようにすることができます。プレイモード テストを継続的インテグレーション(CI)に統合して、ゲームの特定の領域でのメモリ使用量を確認することも、より詳細な分析情報を得るための戦略の一つです。

アセットを管理する

これは、メモリ消費量の中で最も影響が大きく、対応可能な部分です。できるだけ早い段階でプロファイリングします。

Android ゲームのメモリ使用量は、ゲームの種類、アセットの数と種類、メモリ最適化戦略によって大きく異なります。ただし、メモリ使用量の一般的な要因には、テクスチャ、メッシュ、音声ファイル、シェーダー、アニメーション、スクリプトなどがあります。

重複するアセットを検出する

最初の手順は、メモリ プロファイラ、ビルド レポートツール、または Project Auditor を使用して、構成が不適切なアセットと重複したアセットを検出することです。

テクスチャ

ゲームのデバイス サポートを分析し、適切なテクスチャ形式を決定します。Play Asset DeliveryAddressable、または AssetBundle を使用したより手動のプロセスを使用して、ハイエンド デバイスとローエンド デバイスのテクスチャ バンドルを分割できます。

モバイルゲームのパフォーマンスを最適化するUnity のテクスチャのインポート設定を最適化するのディスカッション投稿で紹介されている、最もよく知られている推奨事項に従ってください。次の解決策をお試しください。

  • ASTC 形式でテクスチャを圧縮してメモリ フットプリントを削減し、8x8 などのブロックレートを上げて試してみます。

    ETC2 の使用が必要な場合は、テクスチャをアトラスにパックします。複数のテクスチャを 1 つのテクスチャに配置すると、2 のべき乗(POT)が保証され、ドローコールを減らし、レンダリングを高速化できます。

  • RenderTarget のテクスチャの形式とサイズを最適化します。不必要に高解像度のテクスチャは避けてください。モバイル デバイスで小さいテクスチャを使用すると、メモリを節約できます。

  • テクスチャ チャネル パッキングを使用してテクスチャ メモリを節約します。

メッシュとモデル

まず、基本設定(27 ページ)を確認し、メッシュのインポート設定が次のようになっていることを確認します。

  • 冗長な小さなメッシュを統合します。
  • シーン内のオブジェクト(静的オブジェクトや遠くのオブジェクトなど)の頂点数を減らします。
  • 高ジオメトリ アセットの詳細レベル(LOD)グループを生成します。

マテリアルとシェーダー

  • ビルドプロセス中に、未使用のシェーダー バリアントをプログラムで削除します。
  • シェーダーの重複を避けるため、よく使用されるシェーダー バリアントをウーバー シェーダーに統合します。
  • VRAM/RAM 内のプリロードされたシェーダーの大きなメモリ フットプリントに対処するため、動的シェーダー読み込みを有効にします。ただし、シェーダーのコンパイルがフレームのヒカップの原因になっている場合は注意してください。
  • 動的シェーダー読み込みを使用して、すべてのバリアントが読み込まれないようにします。詳しくは、シェーダーのビルド時間とメモリ使用量の改善に関するブログ投稿をご覧ください。
  • MaterialPropertyBlocks を活用してマテリアル インスタンシングを適切に使用します。

音声

まず、基本設定(41 ページ)を確認し、メッシュのインポート設定が次のようになっていることを確認します。

  • FMOD や Wwise などのサードパーティ製オーディオ エンジンを使用する場合は、未使用または冗長な AudioClip 参照を削除します。
  • 音声データをプリロードします。実行時やシーンの起動時にすぐに必要にならないクリップのプリロードを無効にします。これにより、シーンの初期化時のメモリ オーバーヘッドを削減できます。

アニメーション

  • Unity のアニメーション圧縮設定を調整して、キーフレームの数を最小限に抑え、冗長なデータを削除します。
    • キーフレームの削減: 不要なキーフレームを自動的に削除します
    • クォータニオン圧縮: 回転データを圧縮してメモリ使用量を削減します

圧縮設定は、[Rig] タブまたは [Animation] タブの [Animation Import Settings] で調整できます。

  • 異なるオブジェクトに対してアニメーション クリップを複製するのではなく、アニメーション クリップを再利用します。

    Animator Override Controller を使用して、Animator Controller を再利用し、異なるキャラクターの特定のクリップを置き換えます。

  • 物理学ベースのアニメーションをベイク処理する: アニメーションが物理学ベースまたはプロシージャルな場合は、実行時の計算を回避するためにアニメーション クリップにベイク処理します。

  • スケルトン リグを最適化する: リグで使用するボーンの数を減らして、複雑さとメモリ使用量を削減します。

    • 小さいオブジェクトや静的なオブジェクトに過剰なボーンを使用しないでください。
    • 特定のボーンがアニメーション化されていない場合や不要な場合は、リグから削除します。
  • アニメーション クリップの長さを短縮します。

    • アニメーション クリップをトリミングして、必要なフレームのみを含めます。未使用のアニメーションや長すぎるアニメーションは保存しないようにします。
    • 繰り返される動きの長いクリップを作成する代わりに、ループ アニメーションを使用します。
  • アニメーション コンポーネントが 1 つだけアタッチまたはアクティブ化されていることを確認します。たとえば、Animator を使用している場合は、Legacy animation コンポーネントを無効にするか削除します。

  • 不要な場合は Animator の使用を避けます。シンプルな VFX の場合は、トゥイーン ライブラリを使用するか、スクリプトで視覚効果を実装します。アニメーション システムは、特にローエンドのモバイル デバイスではリソースを大量に消費する可能性があります。

  • 多数のアニメーションを処理する場合は、アニメーションに Job System を使用します。このシステムは、メモリ効率を高めるために完全に再設計されています。

シーン

新しいシーンが読み込まれると、アセットが依存関係として取り込まれます。ただし、アセットのライフサイクル管理が適切に行われていないと、これらの依存関係は参照カウンタによってモニタリングされません。その結果、使用されていないシーンがアンロードされた後もアセットがメモリに残ることがあり、メモリの断片化が発生します。

  • オブジェクト プーリングは、再利用するオブジェクト インスタンスのコレクションを保持するためにスタックを使用し、スレッドセーフではないため、繰り返し発生するゲームプレイ要素に Unity のオブジェクト プーリングを使用して GameObject インスタンスを再利用します。InstantiateDestroy を最小限に抑えると、CPU のパフォーマンスとメモリの安定性の両方が向上します。
  • アセットをアンロードします。
    • スプラッシュ画面や読み込み画面など、重要度の低いタイミングでアセットを戦略的にアンロードします。
    • Resources.UnloadUnusedAssets を頻繁に使用すると、内部依存関係のモニタリング オペレーションが大きくなるため、CPU 処理が急増します。
    • GC.MarkDependencies プロファイル マーカーで CPU の大きなスパイクを確認します。実行頻度を削除または減らし、包括的な Resources.UnloadUnusedAssets() に依存するのではなく、Resources.UnloadAsset を使用して特定のリソースを手動でアンロードします。
  • Resources.UnloadUnusedAssets を常に使用するのではなく、シーンを再構築します。
  • Addressables に対して Resources.UnloadUnusedAssets() を呼び出すと、動的に読み込まれたバンドルが意図せずアンロードされる可能性があります。動的に読み込まれたアセットのライフサイクルを慎重に管理します。

その他

  • シーン遷移によるフラグメンテーション - Resources.UnloadUnusedAssets() メソッドが呼び出されると、Unity は次の処理を行います。

    • 使用されなくなったアセットのメモリを解放
    • ガベージ コレクタのようなオペレーションを実行して、管理対象オブジェクトとネイティブ オブジェクトのヒープで未使用のアセットをチェックし、アンロードします。
    • アクティブな参照が存在しない場合に、テクスチャ、メッシュ、アセットのメモリをクリーンアップします
  • AssetBundle または Addressable - この領域の変更は複雑で、戦略を実装するにはチームの共同作業が必要です。ただし、これらの戦略を習得すると、メモリ使用量が大幅に改善され、ダウンロード サイズが縮小し、クラウド費用が削減されます。Unity での Addressables を使用したアセット管理の詳細については、Addressables をご覧ください。

  • 共有依存関係の集中管理 &mdash: シェーダー、テクスチャ、フォントなどの共有依存関係を、専用のバンドルまたは Addressable グループに体系的にグループ化します。これにより、重複が減り、不要なアセットが効率的にアンロードされます。

  • 依存関係の追跡に Addressables を使用する - Addressables を使用すると、読み込みとアンロードが簡素化され、参照されなくなった依存関係を自動的にアンロードできます。ゲームの具体的なケースによっては、コンテンツ管理と依存関係の解決に Addressables に移行することが有効な解決策となる場合があります。分析ツールを使用して依存関係チェーンを分析し、不要な重複や依存関係を特定します。AssetBundle を使用している場合は、Unity Data Tools を参照してください。

  • TypeTrees - ゲームの AddressablesAssetBundles がプレーヤーと同じバージョンの Unity を使用してビルド、デプロイされ、他のプレーヤー ビルドとの下位互換性を必要としない場合は、TypeTree の書き込みを無効にすることを検討してください。これにより、バンドルサイズとシリアル化されたファイル オブジェクトのメモリ フットプリントを削減できます。ローカルの Addressables パッケージ設定の ContentBuildFlagsDisableWriteTypeTree に変更して、ビルドプロセスを変更します。

ガベージ コレクタに優しいコードを記述する

Unity は、ガベージ コレクション(GC)を利用して、未使用のメモリを自動的に特定して解放することでメモリを管理します。GC は不可欠ですが、適切に処理されないと、パフォーマンスの問題(フレームレートの急上昇など)を引き起こす可能性があります。このプロセスではゲームが一時的に一時停止されるため、パフォーマンスの低下やユーザー エクスペリエンスの低下につながる可能性があります。

マネージド ヒープ割り当ての頻度を減らすための便利な手法については、Unity マニュアルを参照してください。例については、UnityPerformanceTuningBible の 271 ページを参照してください。

  • ガベージ コレクタの割り当てを減らす:

    • ヒープメモリを割り当てる LINQ、ラムダ、クロージャは避けてください。
    • 文字列の連結の代わりに、可変文字列に StringBuilder を使用します。
    • コレクションを再インスタンス化するのではなく、COLLECTIONS.Clear() を呼び出して再利用します。

    詳しくは、Unity ゲームのプロファイリングに関する究極のガイドの電子書籍をご覧ください。

  • UI キャンバスの更新を管理する:

    • UI 要素の動的な変更 - テキスト、画像、RectTransform プロパティなどの UI 要素が更新されると(テキスト コンテンツの変更、要素のサイズ変更、位置のアニメーションなど)、エンジンは一時オブジェクトのメモリを割り当てることがあります。
    • 文字列の割り当て - ほとんどのプログラミング言語では文字列は不変であるため、Text などの UI 要素では文字列の更新が頻繁に必要になります。
    • ダーティ キャンバス - キャンバス上の何かが変更されると(サイズ変更、要素の有効化と無効化、レイアウト プロパティの変更など)、キャンバス全体またはその一部がダーティとしてマークされ、再構築されることがあります。これにより、一時的なデータ構造(メッシュデータ、頂点バッファ、レイアウト計算など)の作成がトリガーされ、ガベージの生成が増加する可能性があります。
    • 複雑な更新や頻繁な更新 - キャンバスに多数の要素がある場合や、頻繁に更新される場合(フレームごとなど)、これらの再構築によってメモリのチャーンが大幅に発生する可能性があります。
  • 増分 GC を有効にすると、割り当てクリーンアップを複数のフレームに分散することで、大規模なコレクション スパイクを減らすことができます。プロファイルして、このオプションがゲームのパフォーマンスとメモリ フットプリントを改善するかどうかを確認します。

  • ゲームで制御されたアプローチが必要な場合は、ガベージ コレクション モードを手動に設定します。その後、レベルの変更時や、ゲームプレイがアクティブでない別のタイミングで、ガベージ コレクションを呼び出します。

  • ゲームの状態遷移(レベルの切り替えなど)で、手動ガベージ コレクション GC.Collect() 呼び出しを呼び出します。

  • 簡単なコードのプラクティスから始めて、必要に応じて、大きな配列にはネイティブ配列や他のネイティブ コンテナを使用して、配列を最適化します。

  • Unity Memory Profiler などのツールを使用してマネージド オブジェクトをモニタリングし、破棄後も残るアンマネージド オブジェクト参照を追跡します。

    Profiler マーカーを使用して、パフォーマンス レポート ツールに送信し、自動化されたアプローチを実現します。

メモリリークと断片化を回避する

メモリのリーク

C# コードでは、オブジェクトが破棄された後も Unity オブジェクトへの参照が存在する場合、Managed Shell と呼ばれるマネージド ラッパー オブジェクトがメモリに残ります。参照に関連付けられたネイティブ メモリは、シーンがアンロードされたとき、またはメモリがアタッチされている GameObject、またはその親オブジェクトのいずれかが Destroy() メソッドによって破棄されたときに解放されます。ただし、Scene または GameObject への他の参照がクリアされていない場合、マネージド メモリはリークしたシェル オブジェクトとして残る可能性があります。マネージド シェル オブジェクトの詳細については、マネージド シェル オブジェクトのマニュアルをご覧ください。

また、メモリリークは、イベント サブスクリプション、ラムダとクロージャ、文字列連結、プールされたオブジェクトの不適切な管理によって発生する可能性があります。

  • まず、メモリリークを見つけるを参照して、Unity メモリ スナップショットを適切に比較してください。
  • イベント サブスクリプションとメモリリークを確認します。オブジェクトがイベント(デリゲートや UnityEvent など)をサブスクライブしているにもかかわらず、破棄される前に適切にサブスクライブ解除されていない場合、イベント マネージャーまたはパブリッシャーがそれらのオブジェクトへの参照を保持している可能性があります。これにより、これらのオブジェクトがガベージ コレクションされなくなり、メモリリークが発生します。
  • オブジェクトの破棄時に登録解除されていないグローバル クラスまたはシングルトン クラスのイベントをモニタリングします。たとえば、オブジェクト デストラクタでデリゲートの登録解除やフック解除を行います。
  • プールされたオブジェクトの破棄により、テキスト メッシュ コンポーネント、テクスチャ、親 GameObject への参照が完全に null になるようにします。
  • Unity Memory Profiler のスナップショットを比較して、メモリ使用量の差が明確な理由もなく発生している場合は、グラフィック ドライバまたはオペレーティング システム自体が原因である可能性があります。

メモリのフラグメンテーション

メモリの断片化は、多くの小さな割り当てがランダムな順序で解放された場合に発生します。ヒープ割り当ては順次行われます。つまり、前のチャンクのスペースがなくなると、新しいメモリチャンクが作成されます。その結果、新しいオブジェクトは古いチャンクの空の領域を埋めないため、断片化が発生します。また、一時的な割り当てが大きいと、ゲーム セッションの期間中、永続的なフラグメンテーションが発生する可能性があります。

この問題は、存続期間の短い大きな割り当てが存続期間の長い割り当ての近くで行われる場合に特に問題になります。

割り当てをライフスパンに基づいてグループ化します。理想的には、ライフスパンの長い割り当ては、アプリケーションのライフサイクルの早い段階でまとめて行う必要があります。

オブザーバーとイベント マネージャー

  • (メモリリーク)77 セクションで説明した問題に加えて、メモリリークは、使用されなくなったオブジェクトに割り当てられた未使用のメモリを残すことで、時間の経過とともにフラグメンテーションの原因となる可能性があります。
  • プールされたオブジェクトの破棄により、テキスト メッシュ コンポーネント、テクスチャ、親 GameObjects への参照が完全に無効化されるようにします。
  • イベント マネージャーは、イベント サブスクリプションを管理するために、リストやディクショナリを作成して保存することがよくあります。実行時にこれらが動的に拡大縮小すると、割り当てと割り当て解除が頻繁に行われるため、メモリの断片化につながる可能性があります。

コード

  • コルーチンはメモリを割り当てることがありますが、毎回新しいものを宣言するのではなく、IEnumerator の return ステートメントをキャッシュに保存することで、簡単に回避できます。
  • プールされたオブジェクトのライフサイクル状態を継続的にモニタリングして、UnityEngine.Object ゴースト参照を保持しないようにします。

アセット

  • テキスト主導のゲーム エクスペリエンスに動的フォールバック システムを使用して、多言語の場合にすべてのフォントをプリロードすることを回避します。
  • アセット(テクスチャやパーティクルなど)をタイプと想定されるライフサイクルごとに整理します。
  • 冗長な UI 画像や静的メッシュなど、ライフサイクル属性がアイドル状態のアセットを圧縮します。

ライフタイムに基づく割り当て

  • アプリケーション ライフサイクルの開始時に長期間存続するアセットを割り当てて、コンパクトな割り当てを確保します。
  • メモリを大量に消費するデータ構造や一時的なデータ構造(物理クラスタなど)には、NativeCollections またはカスタム アロケータを使用します。

ゲームの実行可能ファイルとプラグインもメモリ使用量に影響します。

IL2CPP メタデータ

IL2CPP は、ビルド時にすべての型(クラス、ジェネリック、デリゲートなど)のメタデータを生成し、実行時にリフレクション、型チェック、その他の実行時固有のオペレーションに使用します。このメタデータはメモリに保存され、アプリケーションのメモリ フットプリントの合計に大きく影響する可能性があります。IL2CPP のメタデータ キャッシュは、初期化と読み込み時間に大きく貢献します。また、IL2CPP では特定のメタデータ要素(ジェネリック型やシリアル化された情報など)が重複排除されないため、メモリ使用量が増大する可能性があります。これは、プロジェクトで型が繰り返し使用されたり、冗長な型が使用されたりすることで悪化します。

IL2CPP メタデータは、次の方法で削減できます。

  • リフレクション API の使用を避ける。これらは IL2CPP メタデータの割り当てに大きく影響する可能性があるためです。
  • 組み込みパッケージを無効にする
  • Unity 2022 の完全なジェネリック共有を実装します。これにより、ジェネリックによるオーバーヘッドを削減できるはずです。ただし、割り当てをさらに減らすには、ジェネリクスの使用を減らします。

コード ストリッピング

コードのストリッピングは、ビルドのサイズを縮小するだけでなく、メモリ使用量も削減します。IL2CPP スクリプト バックエンドに対してビルドする場合、マネージド バイトコード ストリッピング(デフォルトで有効)は、マネージド アセンブリから未使用のコードを削除します。このプロセスでは、ルート アセンブリを定義し、静的コード解析を使用して、それらのルート アセンブリが使用する他のマネージド コードを特定します。到達できないコードはすべて削除されます。マネージド コード ストリッピングの詳細については、最適化の最前線からの報告: Unity 2020 LTS でのマネージド コード ストリッピングの改善のブログ投稿と、マネージド コード ストリッピングのドキュメントをご覧ください。

ネイティブ アロケータ

ネイティブのメモリ アロケータを試して、メモリ アロケータをファインチューニングします。ゲームのメモリが不足している場合は、アロケータが遅くなっても、より小さなメモリブロックを使用します。詳しくは、動的ヒープ アロケータの例をご覧ください。

ネイティブ プラグインと SDK を管理する

  • 問題のあるプラグインを見つける - 各プラグインを削除し、ゲームのメモリ スナップショットを比較します。これには、Scripting Define Symbols を使用して多くのコード機能を無効にすることや、インターフェースを使用して結合度の高いクラスをリファクタリングすることが含まれます。ゲーム プログラミング パターンでコードをレベルアップするを確認して、ゲームをプレイ不能にすることなく外部依存関係を無効にするプロセスを容易にしてください。

  • プラグインまたは SDK の作成者に問い合わせる - ほとんどのプラグインはオープンソースではありません。

  • プラグインのメモリ使用量を再現する - メモリ割り当てを行う簡単なプラグインを作成できます(この Unity プラグインを参考にしてください)。Android Studio を使用してメモリ スナップショットを検査します(Unity ではこれらの割り当ては追跡されません)。または、同じプロジェクトで MemoryInfo クラスと Runtime.totalMemory() メソッドを呼び出します。

Unity プラグインは Java メモリとネイティブ メモリを割り当てます。その方法は次のとおりです。

Java

byte[] largeObject = new byte[1024 * 1024 * megaBytes];
list.add(largeObject);

ネイティブ

char* buffer = new char[megabytes * 1024 * 1024];

// Random data to fill the buffer
for (int i = 1; i < megabytes * 1024 * 1024; ++i) {
   buffer[i] = 'A' + (i % 26); // Fill with letters A-Z
}