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

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

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

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

Android のメモリ管理のアップデート

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

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

Unity では、エンジンが内部メモリプール(ネイティブ ブロック アロケータ と Managed Heap )を拡張してピーク時の高負荷に対応すると、それらのメモリページを OS にすぐに返すのではなく保持します。 そのため、サイズの大きいアセットがアンロードされた後でも、ベースラインの常駐メモリ が膨張したままになり、アプリが Android OS プロセスの終了(LMK や MemoryLimiter など)に対して非常に脆弱になります。

OS レベルでの適用を防ぐには、次の 3 つの重要な柱に基づいてメモリの最適化を行う必要があります。

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

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

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

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

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

主な問題

  • メモリのオーバーヘッドが大きい: 1 つの小さなアセットをリクエストすると、Unity は バンドル ファイル全体(ヘッダー、メタデータ、ストリーミング バッファを含む)を RAM に読み込みます。
  • アンロードのトラップ: バンドル内のアセットが 1 つでも使用中の場合、 バンドル全体をアンロードできず 、未使用のデータが 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(バックグラウンド ミュージック) ストリーミング ディスクから小さなバッファでオーディオをストリーミングして、メモリのスパイクを解消します。
長い SFX メモリ内で圧縮 RAM のオーバーヘッドを抑え、再生中にオーディオをオンザフライで解凍します。
短い SFX と頻繁な SFX 読み込み時に解凍 読み込み時にオーディオを RAM に解凍し、再生中のランタイム CPU のオーバーヘッドを回避します。

読み込みのタイプに加えて、次のオーディオ構成を適用して、メモリのオーバーヘッドをさらに削減します。

  • 非指向性オーディオで [Force To Mono] を有効にする: UI 効果音、ボイスオーバー、3D 空間化効果音など、多くのオーディオ アセットではステレオ チャンネルの分離は必要ありません。これらのアセットの AudioClipインポート設定で [Force To Mono]を有効にすると、 メモリ使用量とバッファサイズが半分になります(50% 削減)。
  • デバイス階層ごとにアクティブなオーディオ チャンネルをスロットリングする: アクティブなオーディオ ボイスごとに 、ネイティブ DSP ミキサーと解凍バッファが割り当てられます。メモリ容量の少ないデバイスでは、 同時にアクティブなチャンネルの最大数を減らします。たとえば、 [Max Real Voices] を減らす(32 から 16 など)には、Unity で AudioSettings.Reset を使用するか、サードパーティのオーディオ エンジンでソフトウェア再生チャンネルを制限します。

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

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

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

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

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

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

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

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

カテゴリ 最適化ガイドライン
テクスチャ圧縮 常にターゲット プラットフォームの圧縮形式(Android の場合は ASTC など)を適用します。
読み取り/書き込みを有効にする 必要な場合を除き、無効のままにします。これを有効にすると、CPU と GPU の RAM 間でテクスチャ メモリが重複します。
ミップマップ UI テクスチャまたは一定のカメラ距離で固定されたオブジェクトのミップマップを無効にすると、テクスチャ メモリを約 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 バリアントは、使用状況に関係なく強制的に含まれます。ただし、レンダリング機能やポスト プロセスの機能(追加ライト、リアルタイム シャドウ、未使用の UberPost エフェクトなど)が URP または パイプラインの設定で有効になっている場合、Unity はコンテンツで実際に使用されているかどうかを判断できず、対応するすべての #multi_compile バリアントを生成します。未使用の パイプライン設定を監査して無効にし、実際のレンダリング要件に合わせて、不要なバリアントの生成を防ぎます。

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

D. 冗長なシェーダーを統合する: Lit や Simple Lit などの Uber シェーダーには多くの #multi_compile ディレクティブが含まれており、多数のバリアントを生成すると大量のメモリを消費します。

  • 同時使用状況をプロファイリングする: メモリ使用量をプロファイリングして、 両方のシェーダーが同時に大量のメモリを消費しているかどうかを確認し、 プロジェクトで両方が実際に必要かどうかを確認します。
  • 単一のシェーダーで標準化する: 物理ベース レンダリング(PBR) が必要ない場合は、冗長な Lit シェーダー の使用を排除し、Simple Lit で標準化するようにアセットを構成します(たとえば、 エクスポートされた FBX モデルに、インポート時にデフォルトの Lit マテリアルが誤って適用されていないかどうかを確認します)。

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

Managed Heap でのガベージ コレクション(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 アクセサーによって発生するボクシングと Enumerator ヒープ割り当てを回避します。

// 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 ループを使用します。
  • コルーチン オブジェクトをキャッシュに保存する: WaitForSeconds を繰り返しyield return new WaitForSeconds(time);インスタンス化するのではなく、 インスタンスをキャッシュに保存します。
  • ディクショナリのカスタム構造体キー: カスタム構造体を Dictionary キーとして使用すると、デフォルトの Equals が呼び出され、オブジェクトのボクシングがトリガーされます。 IEqualityComparer<T> を実装して、ディクショナリ コンストラクタに渡します。
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 などのリフレクションを使用するメソッドは、本質的に遅く、ランタイム中にヒープ 割り当てが発生することがよくあります。
  • おすすめの方法: ジェネリックは慎重に使用し、広範で無差別な適用ではなく、 アーキテクチャの明確化を優先します。パフォーマンスが重要な場合は、具体的な型またはインターフェース ベースのポリモーフィズムを使用します。リフレクションの場合は、更新ループでクエリを実行するのではなく、初期化時に MethodInfo や FieldInfo などの結果をキャッシュに保存します。

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

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

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

7. Addressables にバイナリ カタログを使用する

Unity 2022 以前では、Addressables はバイナリ カタログではなく JSON カタログがデフォルトになっています(Unity 2023 以降ではバイナリ カタログがデフォルトです)。プロジェクトで JSON カタログを使用している場合、起動時に大きなカタログを読み込むと、ファイル全体が 1 つの巨大な C# 文字列に読み込まれ、JSON の解析と Base64 のデコード中に数千ものマネージド文字列と一時的な byte[] 配列が割り当てられます。これにより、大きな GC 割り当てスパイクが発生し、Managed Heap が永続的に拡張されるため、バイナリ カタログに切り替えてコンパクトなデータを直接ストリーミングします。