プレイ中、数秒おきにゲームがガクッと一瞬止まる。敵や弾が多い場面ほど頻発する。Profiler を開くと、GC.Collectという巨大な山——それが ガベージコレクション(GC) によるスパイクです。
C#では、不要になったメモリをガベージコレクタが自動で片付けてくれます。手動でのメモリ解放が不要になる便利な仕組みですが、その「片付け」の間、ゲームの実行は止まります。この記事では、GCがカクつきを生む仕組みと、そもそもゴミ(ガベージ)を出さないコーディングを解説します。
この記事でわかること
- GCがカクつきを生む仕組み——スタックとヒープ、Stop-The-World
- ガベージを生む典型コード4つとその書き換え方
- Incremental GC——停止を分割払いにする設定
- ProfilerのGC Alloc列で発生源 を特定する方法
なぜGCでゲームが止まるのか
C#のメモリ置き場は、大きく2つに分かれています。机の上(スタック) と 倉庫(ヒープ) をイメージしてください。

- スタック(机の上):
intやfloatなどの値型やローカル変数の置き場。関数が終わった瞬間に自動で片付き、掃除は不要です。 - ヒープ(倉庫):
newしたクラスのインスタンス、文字列(string)、配列の置き場。使われなくなったものは「ガベージ(ゴミ)」として倉庫に残り続けます。
倉庫が手狭になるとガベージコレクタが起動し、ゴミを探して回収します。この掃除中、ゲームの実行は Stop-The-World——文字どおり完全に停止します。掃除に10msかかれば、そのフレームは10ms余計にかかり、プレイヤーには「カクッ」として体感されます。

つまり対策の本丸はGCそのものではありません。 「毎フレーム、ゴミを出さないこと」 です。
ガベージを生む典型コードと対策
Update()のように毎フレーム走る場所が主戦場です。1回60バイトの小さなゴミでも、60fpsなら毎秒3,600バイト——ちりが積もって定期的なGCを呼び込みます。犯人はほぼこの4パターンです。

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()の分だけは発生する
}
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(Where・Select・OrderByなど)は簡潔で便利ですが、内部でヒープアロケーションを伴うことが多く、外部の変数を捕まえるラムダ式(クロージャ)も同様です。ゲーム初期化やメニュー画面では自由に使ってよく、毎フレーム走る場所では避ける、と場所で使い分けてください。
判断基準は1行です。 「毎フレーム走る場所で、newと文字列結合をしない」。
Incremental GC——停止の分割払い
Unityには、GCの掃除を複数フレームに分割して少しずつ実行する「Incremental GC」があります。「Project Settings > Player > Other Settings」の「Use Incremental GC」で切り替えられ、近年のバージョンでは既定で有効です。
一括で10ms止まる代わりに毎フレーム1msずつ払うイメージで、スパイクは大幅に目立たなくなります。ただしガベージの総量が減るわけではなく、分割払いのコストは毎フレーム発生し続けます。「Incremental GCがあるからゴミを出してもいい」ではなく、アロケーション削減が本命、Incremental GCは保険と考えてください。

ProfilerでGCアロケーションを特定する
どこでガベージが出ているかは、推測ではなく Profiler で特定します。
- CPU Usageモジュールでゲームを記 録し、フレームをクリックしてHierarchyビューを開く
- GC Alloc列で降順ソートする
- 毎フレームゼロ以外の値を出している自作メソッドが犯人。展開して具体的な行の処理まで掘り下げる
「一度だけ」のアロケーション(初期化時など)は問題ありません。狙うのは毎フレーム・毎秒のように繰り返し出続けているものです。
実践:弾幕シューティングの索敵をゼロアロケにする
敵が数十体・弾が数百発の弾幕シューティング、ホーミングミサイルが飛び交うタワーディフェンス、乱戦のサバイバー系——「毎フレーム、近くの敵を探す」処理は、ガベージ対策の効果がもっとも劇的に出る場所です。典型的な「ゴミだらけのホーミング弾」を、4大パターンの合わせ技で直してみましょう。

// ── 修正前:毎フレーム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回だけ確保して、毎フレーム使い回す」が背骨: バッファ・
WaitForSeconds・StringBuilder——形は違っても、対策はすべてこの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列で特定する。推測しない。
「ガベージを意識したコーディング」は、一度習慣になれば書くコストはほぼゼロです。毎フレーム走るコードを書くとき、頭の片隅で「これ、ゴミ出してないか?」と一度だけ確認するクセをつけてください。