【Unity】Unityゲームカクつきの原因!ガベージコレクション(GC)を徹底解説

作成: 2025-12-07最終更新: 2026-07-12

数秒おきにゲームがガクッと止まる——その正体は多くの場合ガベージコレクション(GC)です。GCがなぜカクつきを引き起こすのかをスタック/ヒープの仕組みから解説し、ガベージを出さない具体的なコーディングテクニックとIncremental GCを紹介します。

プレイ中、数秒おきにゲームがガクッと一瞬止まる。敵や弾が多い場面ほど頻発する。Profiler を開くと、GC.Collectという巨大な山——それが ガベージコレクション(GC) によるスパイクです。

C#では、不要になったメモリをガベージコレクタが自動で片付けてくれます。手動でのメモリ解放が不要になる便利な仕組みですが、その「片付け」の間、ゲームの実行は止まります。この記事では、GCがカクつきを生む仕組みと、そもそもゴミ(ガベージ)を出さないコーディングを解説します。

GCのイメージ。青い粘土の掃除ロボットが散らかったゴミを片付けている間、走っていたキャラクターたちが台座の上で動きを止めてフリーズしている

この記事でわかること

  • GCがカクつきを生む仕組み——スタックとヒープ、Stop-The-World
  • ガベージを生む典型コード4つとその書き換え方
  • Incremental GC——停止を分割払いにする設定
  • ProfilerのGC Alloc列で発生源を特定する方法

Sponsored

なぜGCでゲームが止まるのか

C#のメモリ置き場は、大きく2つに分かれています。机の上(スタック)倉庫(ヒープ) をイメージしてください。

スタックとヒープの図。作業机の上(スタック)にはint・floatなどの小物が置かれ作業が終わると即片付く。倉庫(ヒープ)にはclassのインスタンス・文字列・配列の大きな箱が積まれ、掃除ロボットが不要な箱を回収している
  • スタック(机の上): intfloatなどの値型やローカル変数の置き場。関数が終わった瞬間に自動で片付き、掃除は不要です。
  • ヒープ(倉庫): newしたクラスのインスタンス、文字列(string)、配列の置き場。使われなくなったものは「ガベージ(ゴミ)」として倉庫に残り続けます

倉庫が手狭になるとガベージコレクタが起動し、ゴミを探して回収します。この掃除中、ゲームの実行は Stop-The-World——文字どおり完全に停止します。掃除に10msかかれば、そのフレームは10ms余計にかかり、プレイヤーには「カクッ」として体感されます。

Stop-The-Worldの図。16msのフレームが並ぶタイムラインの途中に赤いGCブロックが挟まり、そのフレームだけ+30msかかってカクつく

つまり対策の本丸はGCそのものではありません。 「毎フレーム、ゴミを出さないこと」 です。

ガベージを生む典型コードと対策

Update()のように毎フレーム走る場所が主戦場です。1回60バイトの小さなゴミでも、60fpsなら毎秒3,600バイト——ちりが積もって定期的なGCを呼び込みます。犯人はほぼこの4パターンです。

Update内の4大ガベージの図。文字列結合は変わった時だけ更新、new Collider配列はNonAllocで使い回す、new WaitForSecondsはキャッシュする、LINQとラムダは毎フレームでは使わない

1. 文字列の結合

悪い例: string+演算子は、結合のたびに新しい文字列をヒープに生成します。スコア表示のような毎フレーム更新のUIで頻出する罠です。

void Update()
{
    // 毎フレーム新しい文字列がヒープに生成される
    scoreText.text = "Score: " + currentScore;
}

対策1(最善): そもそも値が変わったときだけ更新する。スコアが変わるのは取得の瞬間だけなら、Update()で毎フレーム組み立てる必要はありません。

対策2: 毎フレーム更新が必要な場合(タイマー表示など)はStringBuilderを使い回します。

private StringBuilder sb = new StringBuilder();

void UpdateTimerText(float time)
{
    sb.Clear();
    sb.Append("Time: ");
    sb.Append(Mathf.CeilToInt(time));
    timerText.text = sb.ToString(); // ToString()の分だけは発生する
}
Sponsored

2. newによるクラス・配列の生成

悪い例: 毎フレーム配列やクラスインスタンスをnewすると、そのすべてがガベージになります。

void Update()
{
    // 索敵のたびに新しい配列が生成される
    Collider[] enemies = Physics.OverlapSphere(transform.position, searchRadius);
}

対策: バッファを1回だけ確保して使い回すNonAllocパターンが定石です。

private Collider[] buffer = new Collider[32]; // 1回だけ確保

void Update()
{
    // 結果は使い回しのbufferに書き込まれる。ガベージゼロ
    int count = Physics.OverlapSphereNonAlloc(transform.position, searchRadius, buffer);
}

弾・敵・エフェクトのような「生成と破棄」自体が主役の場合は、オブジェクトプーリングInstantiate/Destroyごと撲滅します。

3. コルーチンのnew WaitForSeconds()

悪い例: ループ内でnew WaitForSeconds()すると、周回のたびにガベージが出ます。

IEnumerator SpawnLoop()
{
    while (true)
    {
        SpawnEnemy();
        yield return new WaitForSeconds(2f); // 2秒ごとに新品を生成
    }
}

対策: 一度だけ生成してキャッシュします。

private WaitForSeconds spawnDelay = new WaitForSeconds(2f);

IEnumerator SpawnLoop()
{
    while (true)
    {
        SpawnEnemy();
        yield return spawnDelay; // 使い回し
    }
}

4. LINQとラムダ式

LINQ(WhereSelectOrderByなど)は簡潔で便利ですが、内部でヒープアロケーションを伴うことが多く、外部の変数を捕まえるラムダ式(クロージャ)も同様です。ゲーム初期化やメニュー画面では自由に使ってよく、毎フレーム走る場所では避ける、と場所で使い分けてください。

判断基準は1行です。 「毎フレーム走る場所で、newと文字列結合をしない」

Incremental GC——停止の分割払い

Unityには、GCの掃除を複数フレームに分割して少しずつ実行する「Incremental GC」があります。「Project Settings > Player > Other Settings」の「Use Incremental GC」で切り替えられ、近年のバージョンでは既定で有効です。

一括で10ms止まる代わりに毎フレーム1msずつ払うイメージで、スパイクは大幅に目立たなくなります。ただしガベージの総量が減るわけではなく、分割払いのコストは毎フレーム発生し続けます。「Incremental GCがあるからゴミを出してもいい」ではなく、アロケーション削減が本命、Incremental GCは保険と考えてください。

Incremental GCの比較図。一括では大きなGC停止がドカンと入るが、Incremental GCでは毎フレーム少しずつ払う。総量は減らないので、減らすべきはアロケーション

ProfilerでGCアロケーションを特定する

どこでガベージが出ているかは、推測ではなく Profiler で特定します。

  1. CPU Usageモジュールでゲームを記録し、フレームをクリックしてHierarchyビューを開く
  2. GC Alloc列で降順ソートする
  3. 毎フレームゼロ以外の値を出している自作メソッドが犯人。展開して具体的な行の処理まで掘り下げる

「一度だけ」のアロケーション(初期化時など)は問題ありません。狙うのは毎フレーム・毎秒のように繰り返し出続けているものです。

実践:弾幕シューティングの索敵をゼロアロケにする

敵が数十体・弾が数百発の弾幕シューティング、ホーミングミサイルが飛び交うタワーディフェンス、乱戦のサバイバー系——「毎フレーム、近くの敵を探す」処理は、ガベージ対策の効果がもっとも劇的に出る場所です。典型的な「ゴミだらけのホーミング弾」を、4大パターンの合わせ技で直してみましょう。

索敵をゼロアロケにするビフォーアフターの図。修正前は「new配列・LINQ・文字列結合」の3つのゴミが毎フレーム出るが、修正後はバッファ使い回し・forループ・数が変わった時だけ更新に変え、GC Allocが0 Bになる
// ── 修正前:毎フレーム3種類のゴミが出る ──
void Update()
{
    Collider[] hits = Physics.OverlapSphere(transform.position, 10f);   // ①毎フレーム新品の配列
    var targets = hits.Where(h => h.CompareTag("Enemy"))                 // ②LINQ+ラムダ
                      .OrderBy(h => Vector3.Distance(transform.position, h.transform.position));
    debugText.text = "targets: " + targets.Count();                      // ③毎フレーム文字列生成
}
// ── 修正後:定常状態でアロケーションゼロ ──
private readonly Collider[] buffer = new Collider[64];   // ①バッファを1回だけ確保

void Update()
{
    // NonAllocで使い回しのバッファに書き込む(ゴミゼロ)
    int count = Physics.OverlapSphereNonAlloc(transform.position, 10f, buffer);

    // ②LINQをやめて、forループで最寄りの敵を探す
    Collider nearest = null;
    float nearestSqr = float.MaxValue;
    for (int i = 0; i < count; i++)
    {
        if (!buffer[i].CompareTag("Enemy")) continue;
        // 距離比較はsqrMagnitudeで(平方根の計算も省ける)
        float sqr = (buffer[i].transform.position - transform.position).sqrMagnitude;
        if (sqr < nearestSqr) { nearestSqr = sqr; nearest = buffer[i]; }
    }

    // ③デバッグ表示は「数が変わったときだけ」更新
    if (count != lastCount) { debugText.text = $"targets: {count}"; lastCount = count; }
}
private int lastCount = -1;

ポイントは2つです。

  • 「1回だけ確保して、毎フレーム使い回す」が背骨: バッファ・WaitForSecondsStringBuilder——形は違っても、対策はすべてこの1つの原則の応用です。フィールドに逃がしてUpdate内からnewを消す、と覚えてください。
  • ProfilerのGC Alloc列で「0 B」を確認して完了: 直した気になって終わらせず、Profilerの記事 の手順で定常状態のGC Allocがゼロになったことを数字で確認します。弾や敵の生成破棄そのものが重い場合は、次の一手が オブジェクトプーリング です。

おまけ:先に知っておくと良いこと

  • ロード画面でわざと掃除する: System.GC.Collect()を手動で呼ぶと任意のタイミングでGCを実行できます。シーン切り替えやロード画面など「止まっても気づかれない瞬間」に掃除を済ませておくのは実戦的なテクニックです。
  • stringが毎回新品になる理由: C#の文字列はイミュータブル(変更不可) で、「一部を書き換える」ことができません。だから結合のたびに丸ごと新しい文字列が作られます。
  • foreachの過去の汚名: 昔のUnity(2020.1以前のコンパイラ)ではforeach自体がガベージを出すことがあり、「foreach禁止」が定番Tipsでした。現在はほぼ解消されているため、コレクション自体の列挙は気にしなくて構いません(ただしLINQとの組み合わせは別問題です)。
  • structはスタック派: 自作の小さなデータ型をclassではなくstruct(値型)にすると、ヒープを使わずに済む場面があります。Vector3が大量に使ってもガベージを出さないのはこのためです。

まとめ

  • GCはヒープ(倉庫)のゴミ掃除。掃除中はゲームが止まるため、カクつきとして体感される。
  • 対策の本命はGCの抑制ではなく、 「毎フレーム走る場所でゴミを出さない」 こと。
  • 典型犯は文字列結合・new・キャッシュしないWaitForSeconds・LINQの4つ。
  • Incremental GCは停止の分割払い。保険にはなるが、アロケーション削減の代わりにはならない。
  • 発生源はProfilerのGC Alloc列で特定する。推測しない。

「ガベージを意識したコーディング」は、一度習慣になれば書くコストはほぼゼロです。毎フレーム走るコードを書くとき、頭の片隅で「これ、ゴミ出してないか?」と一度だけ確認するクセをつけてください。