はじめに
C++や他言語でネイティブ開発を行ってきたエンジニアがUnityに参入した際、最初に陥りやすい思考の罠があります。
それは、 「メモリ効率を高めるために、画面やUI要素は必要なときに生成(Instantiate)し、不要になったら即座に破棄(Destroy)する」「画面(機能)ごとに細かくシーンを分割して読み込む(LoadScene)」 という設計をしてしまうことです。
ネイティブな世界では至極まっとうな「RAII(Resource Acquisition Is Initialization)」の観点に基づいた設計ですが、これをそのままUnityでやると 深刻なパフォーマンス破壊(カクつき・GCスパイク・ロード待ち地獄) を引き起こします。
この記事では、Unity特有のメモリと処理の仕組み、歴史的な背景を紐解きながら、現代のUnity開発における最適解のひとつである 「1シーン+枠組み常駐+アセット動的ロード」 アーキテクチャについて解説します。
1. なぜUnityでは「都度生成・都度破棄」がアンチパターンなのか?
C++などの感覚で「必要なときに生成し、不要になったら即座に破棄してメモリを解放しよう」と考えるのはごく自然です。しかし、 Unity(C#環境)においてこの「都度確保・都度解放」という設計は、言語仕様的に実現不可能 です。
① 参照を切っても「その場では解放されない」言語仕様
C++では delete やスコープの終了によってその場でメモリが返却されます。しかし、C#でオブジェクトの参照を切り、Unityの Destroy() を呼んでも、マネージドメモリ(C#領域) 上に確保されたC#ラッパー群は 即座には解放されません。
「不要になったから破棄した」つもりでも、マネージドメモリ上には ただの「参照が切れただけのゴミ」として残り続けます。
② C#側の「マネージドメモリ」も無視できないサイズになる
実はUnityのオブジェクトの実体データ(メッシュ、物理、レンダリング情報等)の9割以上は、C++が直接管理する ネイティブメモリ(C++領域) に存在します。C#側のマネージドメモリには、それを操作するための小さなラッパー(アラインメント込みで32バイトらしい)しか存在しません。
これを聞くと「じゃあマネージドメモリ側は数10バイト程度で軽いから、都度生成・破棄しても平気では?」と思ってしまいますが、現実は異なります。
-
階層構造による膨張: UI画面や複雑なキャラなどのプレハブを1つ生成すると、
GameObjectの親子のみならずそれぞれが持つ 全コンポーネント の「小さなラッパー」と管理用配列が数十〜数百単位で一括生成されます。 - マネージドヒープの制約: 数GB使える物理RAM(ネイティブメモリ領域)に対して、Unityのマネージドメモリ領域は限られたサイズで運用を開始するため、1回の生成で発生する数百KB単位のC#オブジェクト群だけでもヒープ圧迫の十分な要因になります。
そのため、1回の生成で発生するマネージドメモリの消費だけでも、C#側にとっては無視できないインパクトになります。
③ メモリを食いつぶし、閾値を超えた瞬間に「強制ぷちフリーズ(GCスパイク)」
生成と破棄を繰り返すと、マネージドメモリ上に解放されないゴミがどんどん溜まり、小さなヒープ領域を圧迫していきます。そして メモリの使用量が限界(閾値)に達した瞬間、Unityのランタイムは強制的にガベージコレクション(GC)を発動 させます。
- C++開発のモデル: 小さな解放処理が随時発生する(制御可能)
- Unity開発の現実: ゴミが限界(閾値)に達するまで溜まり続け、 限界を超えた瞬間にすべての処理を止めて一括解放する(制御不能なぷちフリーズ=GCスパイク)
「都度解放」が機能しない言語仕様だからこそ、Unityでは 「そもそも生成と破棄を発生させず、最初に確保して常駐(プール)させる」 ことがパフォーマンス最適化の鍵になります。
2. なぜ入門書や公式チュートリアルは「シーン切り替え」を教えるのか?
ここまでの知識があると「それなら最初からプレハブ単位でコンポーネント化して常駐させればいいのでは?」と思うのではないでしょうか。しかし、なぜか世の中の入門書やYouTubeチュートリアルの多くは SceneManager.LoadScene による画面切り替えを教えています。(GCを加速させるにも関わらず)
これには Unityの歴史的背景 が関係しています。
2018年まで「プレハブのネスト(Nested Prefabs)」ができなかった
Unityが誕生した2005年から約13年間(Unity 2018.2まで)、 「プレハブの中にプレハブを置いて保存する」ことが技術的に不可能 でした(※2018.3でようやく解禁)。
- 当時の問題: UIウィンドウのプレハブ内に「ボタンプレハブ」を置くと、ボタンのリンクが破棄されて単なるGameObjectに展開されてしまっていた。
- 当時の唯一の解決策: 画面やUIの構成を丸ごと保存して使い回せる最小単位が 「シーン(.unity)」 しか存在しなかった。
結果として「マイページ画面=マイページシーン」「ショップ画面=ショップシーン」と作り、シーン切り替えで遷移させる手法がUnityの標準文化として定着しました。
ネストプレハブ(Nested Prefabs)が実装された現代のUnityにおいては、過去の制約に引きずられた画面管理(シーン切り替え)を無理に行う必要はなくなっています。
3. 最適解:「1シーン+枠組み常駐+アセット動的ロード」
ここまでの課題と歴史を踏まえて導かれるアーキテクチャが 「1シーン+枠組み常駐+アセット動的ロード」 です。
全体像
【 1つだけのメインシーン 】
├── SystemManager(ゲーム全体管理)
├── UI Framework(★UI枠組みプレハブ:常駐・非表示)
│ └── ShopWindow(枠組み・UIコンポーネントのみ)
│ └── ItemIconImage(★空のImage) ── (開いた瞬間に「アセット」だけロードしてセット)
└── Stage Framework(★ステージ枠組みプレハブ:常駐・非表示)
└── BossArea (枠組み・スポーン地点のみ)
└── BossMesh / Texture ─────── (エンカウント時に「アセット」だけロード)
コンポーネントとデータの責務分離
| 要素 | 扱い方 | 理由 |
|---|---|---|
|
枠組み(GameObject構造) (RectTransform, Canvas, Buttonなど) |
シーンに1つ常駐(事前に配置)SetActive(), Canvas.enabled 等で非表示化 |
C#側の生成・破棄(Instantiate/Destroy)に伴うマネージドメモリの再割り当て(GCゴミの増殖)を防ぐため。 |
|
中身(アセットデータ) (高解像度テクスチャ、3Dモデル、音声など) |
Addressables 等で動的にロード&アンロード | 表示・差し替え時は C#側で参照を代入するだけ(GC Allocゼロ) で済み、アンロード時はネイティブメモリ(VRAM/RAM)を即座に解放できるため。 |
💡 なぜ「データ」の差し替えならGCが発生しないのか?
UIプレハブを扱う場合、どれだけ小さな構成であっても Instantiate(生成)を呼ぶ限り、C#ヒープ上に新しいラッパーオブジェクトが生成され、破棄後もGC発動までゴミとして残り続けます。
一方で、SpriteやMeshといった純粋なアセットデータは、 事前にロードしたデータへの「参照をコンポーネント(Image.sprite 等)にセット(代入)するだけ」 で利用できます。
C#側で行われるのは単なるポインタの差し替えのみであるため、表示切り替えを何度繰り返しても C#マネージドメモリの割り当て(GC Alloc)は発生しません。
💡 開発効率を高める「プレハブ単位のアセット動的ロード」
なお、「枠組みには純粋なデータしか流し込めない」と厳格にしすぎると、複雑なUIカードや特殊な演出付きユニットをスクリプト制御だけで組み立てることになり、開発効率が落ちる場合があります。
その場合は要素自体をアセット(ItemCard.prefab など)としてプレハブ化し、Addressables等で動的にロード&生成する手法も有効です。
この場合は使用するのに Instantiate(生成)を呼ぶ必要があるため、その頻度が高くGCが懸念される場合は 「使い終わったら Destroy せず『オブジェクトプール(Object Pool)』に返却して再利用する」 設計の導入がオススメです。
4. 実装のイメージ(C#)
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class ShopWindow : MonoBehaviour
{
[SerializeField] private Canvas targetCanvas;
[SerializeField] private Image itemIconImage;
private AsyncOperationHandle<Sprite> spriteHandle;
// 画面を開く(生成ではなく表示切り替えのみ)
public async void OpenShop(string spriteAddress)
{
// 1. 先にCanvasを有効化(処理負荷はほぼゼロ)
targetCanvas.enabled = true;
// 2. アセット(Sprite)だけをAddressablesで非同期ロードして割り当て
spriteHandle = Addressables.LoadAssetAsync<Sprite>(spriteAddress);
itemIconImage.sprite = await spriteHandle.Task;
}
// 画面を閉じる
public void CloseShop()
{
// 1. Canvasを非表示化(GameObjectは破壊しない)
targetCanvas.enabled = false;
// 2. 参照を外してアセットだけメモリから解放
itemIconImage.sprite = null;
if (spriteHandle.IsValid())
{
Addressables.Release(spriteHandle);
}
}
}
まとめ:Unityにおけるメンタルモデルの切り替え
C++などのネイティブ開発からUnityへ参入する際は、思考のパラダイムシフトが必要です。
- ❌ C++的思考: 「必要に応じてこまめに確保(生成)/ 解放(破棄)した方がメモリ効率は良い(順序にもよるが)」
- ⭕ Unity(C#)的思考: 「破棄して生成し直してもメモリを食い潰すため 必要なものは最初から最後まで常駐させた方がメモリ効率は良い 。純粋なアセット(データ)のみ必要に応じて読み込んで解放する」
この構造を意識すると、Unity開発でありがちな「原因不明のカクつき」や「ローディング地獄」の改善が見込めます。 「1シーン+枠組み常駐+アセット動的ロード」 のアーキテクチャは公式では明文化されていないため、この記事で少しでも罠にハマる「Unity初心者」が減れば幸いです。