Android での Unity ゲームのメモリ最適化

最近のモバイル デバイスの物理 RAM 容量は増え続けていますが、高解像度のアセットや複雑なレンダリング パイプラインにより、ゲームのメモリ使用量はその増加を上回っています。

メモリ使用量が多すぎる場合に発生する主な問題

  • ローメモリ キラー(LMK)による終了: OS がバックグラウンド アプリやフォアグラウンド アプリを強制的に終了し、システム全体のメモリ不足を解消します。
  • フレーム落ちとカクつき(ジャンク): マネージド ヒープでのガベージ コレクション(GC)の頻繁なスパイク、または OS レベルのメモリ スワッピングにより、処理のボトルネックが発生します。
  • サーマル スロットリングとバッテリーの消耗: メモリ割り当て、割り当て解除、メモリページのコミットを継続的に行うと、CPU のオーバーヘッドが大きくなり、発熱が増加してバッテリーの消耗が速くなります。

Android のメモリ管理に関するアップデート

  • Android 17: MemoryLimiter の導入: Android 17 では、デバイス固有のしきい値に対してアプリのメモリ使用量を積極的にモニタリングする MemoryLimiter が導入されています。メモリ制限を超えたアプリは、システムレベルで直ちに終了されます。このメカニズムは従来の LMK よりも厳しいため、ピークメモリ使用量の管理がこれまで以上に重要になっています

Unity でのメモリ使用量を減らす

Unity では、エンジンが内部メモリプール(ネイティブ ブロック アロケータマネージド ヒープ)を拡張してピーク負荷に対応すると、それらのメモリページは OS にすぐに返されず、保持されます。その結果、重いアセットがアンロードされた後でも、ベースラインの常駐メモリは膨張したままになり、アプリケーションは Android OS プロセスの終了(LMKMemoryLimiter など)に対して非常に脆弱になります。

OS レベルの強制を回避するには、メモリ最適化を次の 3 つの柱からアプローチする必要があります。

  • カテゴリ A: ピーク時のメモリ使用量を削減する
  • カテゴリ B: 不要なアセットとシステムのフットプリントを排除する
  • カテゴリ C: 不要な GC 割り当てを排除する

カテゴリ A: ピーク時のメモリ使用量を削減する

Unity のアロケータは、解放されたメモリページを OS にすぐに返すのではなく、再利用のために保持するため、ベースライン メモリは現在の使用量ではなく、到達したピーク時の最大値を反映する傾向があります。したがって、スパイクを事前に防止する方が、事後のクリーンアップに頼るよりも効果的です。

1. AssetBundle が大きくなりすぎないようにする

Unity のバンドル読み込みメカニズムにより、巨大な AssetBundle はメモリの肥大化と OOM クラッシュにつながります。リソースを効率的に管理するため、バンドルをモジュール化します。

主な問題

  • メモリ オーバーヘッドが大きい: 1 つの小さなアセットをリクエストすると、Unity はバンドル ファイル全体(ヘッダー、メタデータ、ストリーミング バッファを含む)を RAM に読み込む必要があります。
  • アンロード トラップ: バンドル内のいずれかのアセットがアクティブに使用されている場合、バンドル全体をアンロードすることはできません。これにより、未使用のデータが RAM にトラップされます。

おすすめの方法

  • バンドルをモジュール化する: シーンまたはライフサイクルごとにアセットを論理的にグループ化します。
  • Unity 6.6 以降のヒント: コンテンツ ディレクトリを使用して、意図しないクロスバンドル依存関係を防ぎます。

2. ScriptableObjects のアセット参照を最適化

ScriptableObject の直接 UnityEngine.Object シリアル化フィールドは直接ハード参照を作成し、ScriptableObject 自体が読み込まれるかインスタンス化されるとすぐに、参照されるすべてのアセットを RAM に読み込むように強制します。

// BEFORE: Loading SceneRequiredAssets forces _worldAsset and _spawnSettings into RAM immediately
public class SceneRequiredAssets : ScriptableObject
{
    public string sceneName;
    public Object _worldAsset;
    public Object _spawnSettings;
}

// AFTER: Use AssetReference to enable asynchronous, on-demand loading using Addressables
public class SceneRequiredAssets : ScriptableObject
{
    public string sceneName;
    public AssetReference _worldAsset;
    public AssetReference _spawnSettings;
}

3. オーディオ クリップの読み込みタイプを設定する

すべての音声クリップを非圧縮でメモリに直接読み込むと、メモリ使用量が大きく、永続的にピークに達します。オーディオ負荷設定は、使用プロファイルに基づいて構成する必要があります。

音声のカテゴリ 読み込みのタイプ 理由
BGM(バックグラウンド ミュージック) ストリーミング メモリの急増を避けるため、ディスクから小さなバッファで音声をストリーミングします。
Long SFX メモリに圧縮 RAM のオーバーヘッドを低く抑え、再生中にオーディオをオンザフライで解凍します。
短い SFX を頻繁に使用する 読み込み時に解凍 読み込み時にオーディオを RAM に解凍し、再生時のランタイム CPU オーバーヘッドを回避します。

4. オブジェクト プーリングとリリース戦略を実装する

シーンの切り替え時にオブジェクト プール内に残っているリリースされていないインスタンスは、予約済みメモリを無期限に保持し、ベースラインのメモリ使用量を不必要に膨張させます。

  • アクション: シーンの切り替え時やアクティビティの少ない期間に、未使用のプールされたオブジェクトを定期的にクリアまたはトリミングします。これにより、Unity はその予約済みスペースを他の割り当てのために返却または再利用できます。

カテゴリ B: 不要なメモリ使用量を削減する

冗長な画像および映像を排除し、ターゲット バッファを直接レンダリングすることで、ベースラインのメモリ使用量が削減されます。

1. レンダリング テクスチャとカメラの深度を最適化する

  • 深度/ステンシル バッファを削除: 色データのみを必要とするレンダー テクスチャの深度ステンシル形式を [なし] に設定します。
  • UI カメラの深度テクスチャを無効にする: 深度データが不要な UI カメラでは、URP カメラ設定で深度テクスチャの生成を無効にして、CopyDepth パスと関連する GPU テクスチャ メモリを排除します。

2. テクスチャとメッシュを最適化する

カテゴリ 最適化ガイドライン
テクスチャの圧縮 常にターゲット プラットフォームの圧縮形式(Android の場合は ASTC など)を適用します。
読み取り/書き込みが有効 必要な場合を除き、無効のままにしてください。これを有効にすると、CPU と GPU の RAM 間でテクスチャ メモリが複製されます。
ミップマップ 一定のカメラ距離で固定された UI テクスチャまたはオブジェクトの Mipmap を無効にして、テクスチャ メモリを約 33% 節約します。
メッシュの複雑さ 不要なポリゴン数と頂点ストリームを減らして、GPU とネイティブ メモリのメモリ使用量を削減します。

3. シェーダー バリアントを削除してメモリを最適化

Uber シェーダー(URP Lit Shader など)は、#multi_compile キーワードと shader_feature キーワードを使用して、多数の機能をカプセル化します。最適化しないと、組み合わせ爆発によって数万もの一意のシェーダー バリアントが作成され、ビルドサイズが肥大化し、ネイティブ メモリの消費量が膨大になり、ゲームプレイ中に GPU ドライバのコンパイルが遅延します。

A. シェーダー バリアントのメモリ オーバーヘッドのメカニズム

  • 組み合わせ爆発: キーワード グループを追加するたびに、可能なバリエーションの合計数が指数関数的に増加します。
  • チャンク割り当てアーキテクチャ: Unity は、コンパイルされたバイナリ バリアントをチャンクと呼ばれる圧縮メモリ ブロックにグループ化します(デフォルト: 4 MB)。
  • ネイティブ メモリの肥大化: ランタイム コードがチャンク内の 1 つのバリアントをリクエストすると、4 MB のチャンク全体が RAM に解凍されます。最適化されていない場合、これらのチャンクにパッケージ化された未使用のバリアントが数千個もネイティブ メモリを占有し続けます。

B. Unity の組み込みのマルチステージ ストリッピング パイプライン: Unity は、ターゲット プラットフォームのグラフィック設定と未使用のエンジン機能(フォグ、ライトマップ、XR 設定など)に基づいて、ビルド時に不要なバリアントを自動的に削除します。また、shader_feature バリエーションは、プロジェクト内のマテリアルでキーワードがアクティブに使用されていない場合、自動的に除外されます。一方、#multi_compile バリエーションは、使用状況に関係なく強制的に含まれます。

C. カスタム自動ストリッピング アーキテクチャ(IPreprocessShaders: 静的分析では、ランタイム C# スクリプト(Material.EnableKeyword)を使用して動的に変更されたキーワードを検出できないため、標準のストリッピングでは不十分なことがよくあります。実際に使用されているバリアントのみを含めるには、QA テストスイートで Player.log(エディタ設定で [Log Shader Compilation] を有効にする)または Profiler TracesShader.CreateGPUProgram マーカー)を使用してバリアントを収集します。次に、エディタ スクリプトで IPreprocessShaders.OnProcessShader を実装して、実行時に一度も実行されなかったバリアントをフィルタで除外し、必要なバリアントのみをビルドに残します。

カテゴリ C: 不要な GC 割り当てを排除する

マネージド ヒープでのガベージ コレクション(GC)の割り当ては、メモリの断片化、縮小されないヒープの拡張、GC の一時停止中のフレームの著しい低下につながります。

1. ラムダ クロージャの割り当てを防ぐ

ラムダ式が外部のローカル変数をキャプチャすると、C# はヒープに暗黙的なディスプレイ クラスを生成します。これを Update 内で実行すると、フレームごとにクロージャ インスタンスが割り当てられます。

// Bad: Capturing local variable 'targetId' allocates a new closure object on the Heap every frame
void Update()
{
    int targetId = 100;
    Monster target = monsterList.Find(m => m.Id == targetId);
}

// Good 1: Replace with a standard 'for' loop (Recommended: 0 B allocation)
void Update()
{
    int targetId = 100;
    Monster target = null;
    for (int i = 0; i < monsterList.Count; i++)
    {
        if (monsterList[i].Id == targetId)
        {
            target = monsterList[i];
            break;
        }
    }
}

// Good 2: Use a static lambda (C# 9.0+) if no outer variables are captured
Monster target = monsterList.Find(static m => m.Id == 100);

2. stackalloc と Span を使用する

スタック メモリを使用して、有効期間の短い一時配列のヒープ割り当てを回避します。

// Before: Allocates an array on the Heap every call (GC Target)
Vector2[] pos = new Vector2[4];

// After: Utilizes Stack memory using System.Span (0 B Heap Allocation)
System.Span<Vector2> pos = stackalloc Vector2[4];

3. コレクションの反復処理を最適化する

LINQ 拡張機能または ReadOnlyCollection アクセサーによって発生するボクシングと列挙子ヒープ割り当てを回避します。

// Before: LINQ Count() causes internal GetEnumerator() heap allocations
bool hasData = component != null && component.parameters.Count(parameter => parameter.overrideState) > 0;

// After: Replaced with indexer and direct loop iteration
bool hasData = HasDataOptimized(component);

private bool HasDataOptimized(TestComponent component)
{
    if (component == null) return false;

    var count = component.parameters.Count;
    for (var i = 0; i < count; ++i)
    {
        if (component.parameters[i].overrideState)
            return true;
    }
    return false;
}

4. 追加の GC 割り当て防止ルール

  • Camera.allCameras は呼び出しごとにヒープに新しい Camera[] 配列を生成するため、使用は避けてください。代わりに、カメラ アレイをキャッシュに保存して Camera.GetAllCameras(_allCameras) に渡します。
  • IReadOnlyList<T> での foreach を避ける: インターフェースを反復処理すると、構造体列挙子のボクシングが発生し、GC 割り当てが生成されます。代わりに標準の for ループを使用してください。
  • コルーチン オブジェクトをキャッシュに保存する: yield return new WaitForSeconds(time); を繰り返しインスタンス化するのではなく、WaitForSeconds インスタンスをキャッシュに保存します。
  • 辞書のカスタム構造体キー: カスタム構造体を Dictionary キーとして使用すると、デフォルトの Equals が呼び出され、オブジェクトのボクシングがトリガーされます。IEqualityComparer<T> を実装し、Dictionary コンストラクタに渡します。
public struct TypeKey
{
    public int v1;
    public int v2;

    public class TypeKeyComparer : IEqualityComparer<TypeKey>
    {
        public bool Equals(TypeKey x, TypeKey y) => x.v1 == y.v1 && x.v2 == y.v2;
        public int GetHashCode(TypeKey obj) => obj.v1.GetHashCode() ^ obj.v2.GetHashCode();
    }
}

// Pass custom comparer during Dictionary initialization to prevent boxing
public readonly Dictionary<TypeKey, int> _typeKeyDictionary = new(new TypeKey.TypeKeyComparer());

5. ジェネリックとリフレクションの使用を最小限に抑える

ジェネリック メソッドはコードの再利用性と保守性に優れていますが、Unity IL2CPP(Intermediate Language to C++)バックエンドのコンテキストでは、過度に使用するとプロジェクトに悪影響を及ぼす可能性があります。

  • IL2CPP コードの肥大化: IL2CPP は、一意のジェネリック型組み合わせごとに、コードの特殊バージョンを生成します。複雑なジェネリクスを過度に使用すると、生成される C++ コードの「組み合わせ爆発」が発生し、アプリケーションのバイナリ サイズとネイティブ メモリ フットプリントが大幅に増加する可能性があります。
  • リフレクションのオーバーヘッド: System.Reflection API などのリフレクションを使用するメソッドは、本質的に遅く、実行時にヒープ割り当てを引き起こすことがよくあります。
  • ベスト プラクティス: ジェネリクスは慎重に使用します。広範で無差別な適用ではなく、アーキテクチャの明確さを優先します。パフォーマンスが重要な場合は、具象型またはインターフェース ベースのポリモーフィズムを使用します。リフレクションでは、更新ループでクエリを実行するのではなく、初期化時に MethodInfoFieldInfo などの結果をキャッシュに保存します。

6. リークされたマネージド シェルを回避する

MonoBehaviourTextureGameObject などのすべての UnityEngine.Object には、ネイティブ C++ エンジンと通信する C# の「マネージド シェル」ラッパーがあります。

  • 問題: Managed Shell が静的参照、永続イベント サブスクリプション、またはクリーンアップされていないクロージャによってメモリに保持されている場合、GC はメモリを回収できません。ネイティブ オブジェクトが破棄されても、マネージド ラッパーは存続するため、マネージド ヒープを肥大化させる「ゴースト」メモリリークが発生します。
  • 解決策: 常に堅牢なクリーンアップ パターンを実装します。オブジェクトを破棄したり、シーンを切り替えたりするときは、-= 演算子を使用してイベントの登録を明示的に解除し、UnityEngine.Object 型への静的参照を null にします。このクリーンアップにより、ネイティブ エンジンがハンドルを解放した後に GC がラッパーを正常に収集できるようになります。