スポンサーリンク
UnityUnityメモ

【徹底解説】Unityのガベージコレクション(GC)を最適化してゲームの処理落ちを防ぐ方法

Unity

1. はじめに

Unityでゲームを開発していると、「急にカクついた!」「FPSが落ちてしまった…」なんて経験はありませんか? その原因のひとつが、スクリプト実行中に発生する GC Alloc(ガベージコレクター・アロケーション) です。

GC Allocとは、C#のヒープメモリに新しい領域を割り当てたときに増える値のこと。 Profilerで確認できるこの数値が積み重なると、ガベージコレクタ(GC)が発動し、フレーム処理が一瞬止まってしまうことがあります。 特にアクションゲームやVRのようにフレームレートが命のジャンルでは、ちょっとしたGCの動きでもプレイヤー体験に大きな影響が出てしまうんです。

本記事では、UnityにおけるGC Allocをゼロに近づけるための具体的なポイントを、事例を交えながら解説していきます。 「Stringの連結」や「Listの生成」「foreach」「Unity APIの使い方」など、日常のコーディングでつい見落としがちな落とし穴を取り上げ、すぐに実践できる回避策を紹介します。

また、効率よくデバッグ・検証するためのおすすめツールや、学習を深める書籍も紹介しますので、 「GC Alloc対策をしっかり理解してパフォーマンスを底上げしたい!」という方はぜひ最後までご覧ください。




2. GC Allocとは?なぜゼロを目指すのか

まずは基本から整理しておきましょう。
GC Alloc(ガベージコレクター・アロケーション)とは、C#スクリプトの実行中にヒープメモリへ新しく確保された領域を指します。 UnityのProfilerを開くと、CPU Usageの項目で「GC.Alloc」として確認することができます。

C#ではメモリの管理をガベージコレクタ(GC)が自動で行っています。
一見便利ですが、GCが発動すると「不要になったオブジェクトを回収する処理」が入るため、フレームの一部が一時的に止まることになります。 この処理が頻発すると、結果的にFPS低下やカクつきを引き起こすわけです。

特に注意したいのは、UnityのGCが世代別ではなく単純なヒープ管理を採用していること。 つまり小さな一時割り当てでも積み重なれば無視できない負荷となり、モバイル端末などでは一瞬のGCで体感的なラグが発生します。

理想は「1フレームあたりのGC Allocを0バイトにする」こと。
完全なゼロは難しい場合もありますが、ゼロに近い値を維持し続けることが快適なゲーム体験につながります。

そのためにも、どんなコードやAPIが割り当てを生みやすいのかを知り、事前に回避する工夫が必要になります。




3.GC Allocが発生しやすい典型的なケースと回避策

3-1. 文字列操作(Stringの連結)

最もありがちなGC Allocの原因が、文字列の連結です。 C#の文字列(String)は不変オブジェクトであり、+演算子を使って結合するたびに新しい文字列がヒープ上に生成されます。

例えば、Update内でスコアや情報を毎フレーム結合して表示すると、
毎回新しい文字列インスタンスが生成され、GC Allocが発生してしまいます。 Profilerで見るとフレームごとにGC.Allocが積み上がり、やがてGCが動いてフレーム落ちにつながります。

悪い例


void Update()
{
    string log = "";
    for (int i = 0; i < 1000; i++)
    {
        log += i.ToString() + ",";
    }
    Debug.Log(log);
}

このようなコードは1フレームごとに大量のGC Allocを発生させ、結果的にゲームのパフォーマンスを著しく低下させます。

回避策

  • StringBuilderを使用する
  • String.Joinを使ってまとめて結合する
  • 毎フレーム結合せず、値が変化したときのみ文字列を生成する
  • UI上で静的な部分と動的な部分を分けて管理する(例:「Score: 」は固定Text、スコア値は別Text)

改善例(StringBuilderを使う)


using System.Text;

public class StringBuilderSample : MonoBehaviour
{
    private StringBuilder sb = new StringBuilder();

    void Update()
    {
        sb.Clear();
        for (int i = 0; i < 1000; i++)
        {
            sb.Append(i).Append(",");
        }
        Debug.Log(sb.ToString());
    }
}

このようにStringBuilderを再利用することで、GC Allocを大幅に削減できます。 ただしToString()で文字列インスタンスを生成するため、完全にゼロにはできません。 そのため「値が変わったときだけ更新する」などの工夫を組み合わせるのが実務的です。




3-2. コレクション(ListやArray)

次に注意したいのがListやArrayの生成です。 Update内で毎回newしてしまうと、そのたびにヒープメモリが割り当てられ、GC Allocが積み重なってしまいます。

悪い例


void Update()
{
    List&lt;int&gt; numbers = new List&lt;int&gt;();
    for (int i = 0; i < 10000; i++)
    {
        numbers.Add(i);
    }
}

このコードでは毎フレームごとに新しいListを生成しているため、Profiler上ではGC Allocが常に発生します。 要素数が多いほど割り当て量も増えるため、処理が重くなりやすいです。

回避策

  • クラスのメンバとして一度だけ生成し、使い回す
  • Clear()を呼び出してリストを再利用する
  • あらかじめ必要な要素数を見積もり、Capacityを設定して拡張による割り当てを防ぐ
  • 要素数が固定ならArrayを使った方が安全

改善例


public class ListReuseSample : MonoBehaviour
{
    private List&lt;int&gt; numbers = new List&lt;int&gt;(10000);

    void Update()
    {
        numbers.Clear();
        for (int i = 0; i < 10000; i++)
        {
            numbers.Add(i);
        }
    }
}

このようにListを再利用することで、GC Allocを0バイトに抑えることができます。 Clear()は中身を消すだけで、確保済みのメモリ領域は保持するため、割り当ては発生しません。

補足

ただし、リストが自動的に拡張されるケースでは新しいメモリ割り当てが発生する点に注意してください。
また、メソッドの戻り値で新しい配列を返す実装は割り当てを増やす原因になります。 代わりに既存の配列を引数で受け取って再利用する構造にするのがベストです。




3-3. ボックス化(Boxing)

次に気をつけたいのがボックス化(Boxing)です。 これは、値型(intやfloatなど)が参照型(objectなど)として扱われるときに自動的に発生する仕組みで、 その変換時にヒープメモリへ割り当てが行われ、GC Allocが発生します。

悪い例


object GetObject(int value)
{
    return value; // intがobjectに変換されるときにボックス化
}

void Update()
{
    for (int i = 0; i < 10000; i++)
    {
        object obj = GetObject(i);
    }
}

このように値型をobjectとして扱うコードは、ループのたびに小さなGC Allocを発生させます。 Profilerで見ると「ちょっとだけだから平気」と思いがちですが、積み重なるとGC頻度が上がり、パフォーマンスを悪化させる原因になります。

回避策

  • 値型を直接objectで扱わないように設計する
  • 汎用的に扱いたい場合はジェネリックメソッドを使ってボックス化を避ける
  • 必要に応じて値をbyte配列などに格納し、固定領域で扱う

改善例(ジェネリックで回避)


T GetValue&lt;T&gt;(T value)
{
    return value; // 型に応じて処理されるのでボックス化しない
}

void Update()
{
    for (int i = 0; i < 10000; i++)
    {
        int num = GetValue(i); // GC Alloc発生なし
    }
}

このようにボックス化を意識して避けるだけで、無駄な割り当てを防ぎ、GCの発動を抑えることができます。 特にパフォーマンスに敏感な処理(数値計算や通信処理)では重要なポイントです。




3-4. foreachとIEnumerable

C#でおなじみのforeach文も、実は使い方によってGC Allocを発生させる落とし穴があります。 特にIEnumerableインターフェースを介してループを回すと、IEnumeratorが生成され、 その過程でヒープメモリの割り当てが発生してしまいます。

悪い例


IEnumerable&lt;int&gt; GetNumbers()
{
    return new int[] { 1, 2, 3, 4, 5 };
}

void Update()
{
    foreach (var num in GetNumbers())
    {
        Debug.Log(num);
    }
}

この場合、IEnumerableを返しているため、GetEnumerator()IEnumeratorが作られ、GC Allocが発生します。 Profilerで見ると、少量(数十バイト)ながら確実に積み上がっていくのが確認できます。

回避策

  • 配列やListを直接foreachする(この場合はGC Allocは発生しない)
  • インターフェース越しのforeachを避け、for文で明示的に回す
  • パフォーマンスが重要な処理では、IEnumerableを返す設計自体を見直す

改善例


private List&lt;int&gt; numbers = new List&lt;int&gt; { 1, 2, 3, 4, 5 };

void Update()
{
    // Listを直接foreachする → GC Allocなし
    foreach (var num in numbers)
    {
        Debug.Log(num);
    }

    // さらに高速化したいならfor文を使う
    for (int i = 0; i &lt; numbers.Count; i++)
    {
        Debug.Log(numbers[i]);
    }
}

このようにインターフェースを経由しない書き方にするだけで、 ループ処理のたびに発生していた小さなGC Allocを完全に防ぐことができます。 細かい差に見えますが、毎フレーム実行される処理では大きな違いにつながります。




3-5. Unity API呼び出し(配列コピーとNonAlloc活用)

Unityの一部APIは、呼び出すたびに新しい配列を返す=ヒープに割り当てが発生する仕様になっています。
タイトなループ内やUpdate()で多用すると、目に見えないGC Allocが積み重なり、フレーム落ちの原因になります。

よくある落とし穴

  • mesh.vertices / mesh.normals / mesh.uv:毎回「コピーされた配列」を返す
  • Input.touches:フレームごとに配列コピーを返す
  • Physics.RaycastAll / OverlapSphere:配列を新規割り当てして結果を返す
  • Renderer.materials / Renderer.sharedMaterials:配列を返すアクセスで割り当てが発生
  • GetComponents<T>():戻り値の新規配列が割り当てられる

基本方針(NonAlloc & キャッシュ)

  • 一度だけ取得してキャッシュし、繰り返し使う(リサイズが必要ない場合)
  • List/配列を事前に用意し、APIのNonAlloc系や「埋め込み」系に渡して再利用する
  • 頻度の高い処理では代替API(例:Input.GetTouch)に切り替える

Mesh:配列コピーを避ける

mesh.verticesは毎回コピーを返すため割り当てが発生します。
代わりに、GetVertices(List<Vector3>)再利用可能なListへ詰めるのが安全です。


public class MeshReadSample : MonoBehaviour
{
    private Mesh _mesh;
    private readonly List&lt;Vector3&gt; _verts = new List&lt;Vector3&gt;(1024); // 事前確保

    void Awake()
    {
        _mesh = GetComponent&lt;MeshFilter&gt;().mesh;
    }

    void Update()
    {
        _verts.Clear();
        _mesh.GetVertices(_verts); // ← 配列を新規割り当てしない
        // _vertsを使って処理……
    }
}

Input:touches配列ではなくGetTouch

Input.touchesは配列を返すため割り当てが発生します。
代わりにInput.touchCountInput.GetTouch(i)でアクセスすると、割り当てゼロで扱えます。


void Update()
{
    for (int i = 0; i &lt; Input.touchCount; i++)
    {
        Touch t = Input.GetTouch(i); // ← 配列割り当てなし
        // タッチ処理…
    }
}

Physics:NonAllocで結果を詰める

物理クエリの配列生成コストは地味に効きます。
RaycastAllOverlapSphereではなく、NonAlloc系を使って、あらかじめ用意した配列に結果を詰めましょう。


public class PhysicsNonAllocSample : MonoBehaviour
{
    private readonly RaycastHit[] _hits = new RaycastHit[64]; // 再利用バッファ

    void Update()
    {
        int count = Physics.RaycastNonAlloc(
            new Ray(transform.position, transform.forward),
            _hits, 100f, ~0, QueryTriggerInteraction.Ignore);

        for (int i = 0; i &lt; count; i++)
        {
            // _hits[i] を処理(余った要素は無視)
        }
    }
}

Renderer:materials配列のコピーに注意

renderer.materialssharedMaterialsは配列を返します。
頻繁に参照する場合は、一度だけ取得してキャッシュするか、必要に応じて一時Listへ詰めるヘルパーを用意しましょう。


public class MaterialsCache : MonoBehaviour
{
    private Renderer _renderer;
    private Material[] _cached;

    void Awake()
    {
        _renderer = GetComponent&lt;Renderer&gt;();
        _cached = _renderer.sharedMaterials; // 一度だけ取得してキャッシュ
    }

    // _cachedを使ってアクセス(頻繁な配列生成を回避)
}

コンポーネント取得:配列割り当てを抑える

GetComponents<T>()は戻り値配列を割り当てます。
可能ならGetComponents(List<T>)のような「埋め込み」オーバーロード(対応型のみ)や、TryGetComponent・事前キャッシュ・初期化時の走査で実行時の割り当てをゼロ化しましょう。

チェックリスト(API編)

  • 「配列を返す」プロパティ/メソッドは要注意(毎回コピーの可能性)
  • NonAlloc系があるなら必ず使う(Physics/Overlap/キャスト結果など)
  • 結果を入れる再利用バッファ(配列 or List)をクラスメンバで保持
  • ループ外で一度だけ取得→ループ内では参照・再利用

以上を徹底するだけでも、ProfilerのGC.Allocは目に見えて静かになります。
次は、これらの修正をProfilerでどう検証するかを具体的に見ていきましょう。




4. GC Alloc回避のベストプラクティス集

ここからは、日常の実装で「とりあえずコレだけ守れば安全」という再現性の高いテクニックをまとめてご紹介します。 どれもProfilerのグラフを静かにするのに即効性がありますよ。

4-1. オブジェクトプールを徹底する

弾丸・エフェクト・敵のスポーンなど、生成と破棄を繰り返すオブジェクトは、事前に必要数を作って再利用(プーリング)します。 Instantiate/Destroyの乱発は、割り当てとGCだけでなくCPU/メモリ/GPUの全方位に悪影響。
「最大同時出現数」を見積もって、起動時にまとめて生成→使用後は非アクティブに戻す設計にしましょう。

  • 最大数を現実的に余裕ある値にしてスパイクを防止
  • プールはDictionary<Prefab, Queue<GameObject>>等で管理すると拡張しやすい
  • 初期化時(ロード中)に温める:warm-upで起動後の割り当てゼロへ

4-2. 匿名メソッド/クロージャの使い過ぎに注意

ラムダ式や匿名メソッドは便利ですが、外側の変数をキャプチャすると匿名クラスが生成され、 ヒープ割り当てが発生します。フレームごとに新しいデリゲートを作るのは避け、事前にキャッシュしたり、 更新の少ない箇所に限定しましょう。

  • イベント購読はAwake/Startで一度だけ設定(毎フレームの購読/解除はNG)
  • Action/Funcはstaticメソッドを渡すとキャプチャを避けられる
  • ラムダ内で外部変数を参照しない工夫(必要値は引数で渡す等)

4-3. 空配列は「静的な0長インスタンス」を再利用

「要素なし」を返したいとき、毎回new T[0]を作るのは無駄です。 Array.Empty<T>()(.NET 4.x相当)や、static readonly T[] Empty = Array.Empty<T>(); を用意して再利用しましょう。

4-4. NonAlloc系APIと「埋め込み」オーバーロードを優先

配列を返すAPIは基本的に割り当てが発生します。代替のNonAllocや、 「呼び出し側のList/配列を埋める」オーバーロードがあれば、そちらを優先しましょう。

  • Physics:RaycastNonAlloc / SphereCastNonAlloc / Overlap…NonAlloc
  • Input:touchesではなく touchCount + GetTouch(i)
  • Mesh:GetVertices(List) / GetNormals(List) / GetUVs(List)
  • Renderer:頻繁アクセスは一度キャッシュして使い回す
  • GetComponents:戻り値配列よりList埋め込みTryGetComponentを検討

4-5. バッファは「クラスメンバ」で長寿命・再利用

検索・ソート・一時計算の作業用List/配列は、メンバにして再利用が鉄則。 毎フレームnewするのではなく、Clear()Array.Fillで中身だけリセットして使い続けましょう。 初期容量(Capacity)を十分に持たせると、途中拡張による割り当ても防げます。

4-6. 頻度の高い処理は「分岐の前倒し」と「回数の削減」

割り当てゼロでも、無駄な呼び出しが多ければ結局重いです。 「そもそもの回数を減らす」設計が、GCだけでなくCPU時間も下げます。

  • UI更新は値が変わったときだけ行う(ポーリング禁止)
  • Update内の処理をFixedUpdateコルーチンで間引く
  • 距離・角度・可視チェックなどは一定間隔でまとめて更新

4-7. 設計段階のチェックリスト

  • ループ内に new / 配列プロパティ呼び出し / ラムダ生成 はないか?
  • 外部APIは NonAlloc / 埋め込み オーバーロードに置き換えたか?
  • バッファ(List/配列)はメンバで再利用し、Capacityを見積もったか?
  • イベント購読のスコープは適切か(毎フレーム増殖していないか)?
  • UI・ログ・デバッグ出力は更新頻度をコントロールしているか?

この章の内容を適用すると、ProfilerのGC.Allocはほぼ沈黙します。 次は、Profilerで修正前後をどう比べるか、効率よく検証を回すワークフローを紹介します。




5. Profilerを使った検証フロー

GC Alloc対策は「直したつもり」でも、実際にProfilerで確認しないと効果が分かりません。 ここでは、修正前後を比べながらGC Allocをゼロに近づける検証手順を解説します。

5-1. ProfilerでGC Allocを確認する

UnityのProfilerを開き、CPU Usageモジュールを選択します。 HierarchyビューまたはTimelineビューに表示されるGC.Allocが、フレームごとに発生したメモリ割り当てです。 どのメソッドが原因かを掘り下げて確認できるので、まずはスパイクの出ている箇所を特定しましょう。

5-2. 修正前後を比較する

改善したコードを動かし、再びProfilerで計測します。 修正前にGC.Allocが数KB〜数MB発生していた箇所が、0バイトまたは限りなく少なくなっていれば成功です。 改善の有無が数字で分かるので、開発チーム内での共有にも役立ちます。

5-3. 検証を助けるおすすめツール

Profilerだけでも十分確認できますが、効率よく問題を見つけたいなら次のようなアセットを併用すると便利です。

これらをProfilerと組み合わせれば、「どこで割り当てが発生しているか」を素早く突き止められます。 デバッグ効率が上がるほど修正サイクルも短縮でき、結果的にプロジェクト全体のパフォーマンス改善につながります。




6. まとめ

ここまで、UnityでGC Allocをゼロに近づけるための実践テクニックを解説してきました。 文字列の連結、Listや配列の生成、ボックス化、foreachの使い方、そしてUnity API特有の配列コピーなど、 「知らないうちに割り当てが発生している」ポイントは意外と多いものです。

大切なのは、毎フレーム割り当てを発生させない設計に意識を向けること。 ・StringBuilderで文字列をまとめる ・List.Clear()で再利用する ・IEnumerable経由ではなく直接配列やListをループする ・NonAlloc APIを積極的に使う ・オブジェクトプールで生成/破棄をなくす こうした基本を守るだけで、Profiler上のGC.Allocは見違えるように減少します。

特にモバイルやVRのようにフレーム落ちが直感的に伝わる環境では、GC対策は必須のスキルです。 開発初期から意識しておけば、大規模化した後の修正コストも大幅に減らせます。

さらに効率的に学びたい方には、実際の開発フローを通して学べるこちらの書籍もおすすめです。
📘 作って学べる Unity本格入門[Unity 6対応版]
✅ Amazonでチェックする✅ 楽天でチェックする


あわせて読みたい

GC Alloc対策とあわせて、Unityのパフォーマンス最適化やメモリ管理に役立つ関連記事もチェックしてみてください。


よくある質問(FAQ)

Q
GC Allocは完全にゼロにできますか?
A

完全なゼロは難しいですが、日常の処理から不要な割り当てをなくすことで限りなく0に近づけることは可能です。 UI更新や文字列生成など「必要なときだけ処理する」設計がカギです。

Q
ProfilerでGC Allocが見つからないときは?
A

ProfilerのCPU Usageモジュールを開き、HierarchyかTimelineビューでGC.Allocを確認しましょう。 条件を絞り込むと、どの処理が割り当てを発生させているかが見つけやすくなります。

Q
GC対策はPC向けゲームでも重要ですか?
A

はい。PCは高性能ですが、GCの一瞬の停止はFPSやeスポーツ系のゲームでも顕著に表れます。 特にマルチプレイや高速アクションでは、安定性を保つためにもGC Alloc対策は欠かせません。

※当サイトはアフィリエイト広告を利用しています。リンクを経由して商品を購入された場合、当サイトに報酬が発生することがあります。

※本記事に記載しているAmazon商品情報(価格、在庫状況、割引、配送条件など)は、執筆時点のAmazon.co.jp上の情報に基づいています。
最新の価格・在庫・配送条件などの詳細は、Amazonの商品ページをご確認ください。

スポンサーリンク