オブジェクトプールとデータ指向設計
フレーム落ちやGCスパイクに悩む人へ。実行時アロケーションをプールで消し、データをSoAで並べてキャッシュに載せれば、16.6msのフレーム予算を安定して守れます。
- オブジェクトプールは生成・破棄をあらかじめ確保した固定配列の使い回しに置き換え、実行時のヒープ確保とGC対象の生成を消す。確保・解放は空きリスト(フリーリスト)からのpop/pushでO(1)、断片化も起きない。
- データ指向設計はAoS(構造体の配列)ではなくSoA(配列の構造体)でフィールドごとに連続配置し、走査時に使う成分だけをキャッシュラインに載せる。ミス率が下がりSIMDも効くため、同じ計算でも数倍速くなりうる。
- GCスパイクはフレーム内の突発的な停止時間として現れフレーム落ちの主因になる。プールでゴミを出さず、SoAで局所性を上げ、処理をフレーム予算内に収めるのが実時間ゲームの設計原則。
なぜ「毎フレーム生成・破棄」がゲームを止めるのか
60fpsのゲームは1フレームあたり約16.6ms、120fpsなら約8.3msというフレーム予算の中で、入力処理・ゲームロジック・物理・描画をすべて終えなければなりません。この予算を1回でも超えれば、その瞬間に表示は間に合わずカクつき(フレーム落ち)として観測されます。問題は、弾丸・パーティクル・エフェクトのように毎フレーム大量に生まれては消えるオブジェクトを素直に new / delete で扱うと、予算を食い潰す二つのコストが発生することです。
一つはアロケーションそのもののコストです。汎用ヒープ確保(メモリアロケータの内部動作 が扱う領域)は空き領域の探索・分割・メタデータ更新を伴い、生成破棄を繰り返すとヒープが断片化して確保がさらに遅くなります。もう一つがガベージコレクション(GC)です。C#(Unity)やJavaScript/TypeScriptなどマネージド言語では、捨てたオブジェクトはGCが後でまとめて回収します。この回収は往々にしてワールドを止める停止(stop-the-world)を伴い、あるフレームだけ突発的に数ミリ秒〜数十ミリ秒奪う——これがGCスパイクであり、フレーム落ちの典型的な原因です。
GCの平均コストが小さくても、実時間ゲームで効くのは最悪ケースです。60fpsの16.6ms予算のうち描画とロジックで15msを使っていれば、残り1.6msを超えるGC停止が1回入るだけでそのフレームは落ちます。平均フレームタイムではなく「1%tile・0.1%tileの遅いフレーム(フレームタイムスパイク)」を指標にするのはこのためです。GCは"いつか必ず来る"ので、来ても痛くないようそもそもゴミ(garbage)を出さない設計が王道になります。
オブジェクトプール:確保を「使い回し」に置き換える
オブジェクトプールは、必要になるたびに確保するのをやめ、起動時やシーン開始時に上限個数ぶんまとめて確保しておき、以後はその中から「貸し出す/返す」だけで運用する手法です。生きているオブジェクトを実際に消すのではなく、非アクティブ状態にしてプールへ戻す。次に必要になったら戻ってきたものを再初期化して使う。これにより実行時のヒープ確保がゼロになり、マネージド環境ならGC対象の新規生成も止まります。
空き管理にはフリーリストを使うのが定石です。空きスロットの添字を積んだスタック(またはリンクリスト)を持ち、確保は先頭をpop、解放は末尾へpushするだけ。どちらも O(1) で、汎用ヒープのような探索も断片化も起きません。
// 固定長プール:確保・解放は O(1)、実行時アロケーションなし
public sealed class BulletPool {
readonly Bullet[] items; // 事前確保した実体
readonly int[] freeStack; // 空きスロットの添字を積むスタック
int top; // フリーリストの先頭
public BulletPool(int capacity) {
items = new Bullet[capacity];
freeStack = new int[capacity];
for (int i = 0; i < capacity; i++) {
items[i] = new Bullet();
freeStack[i] = i; // 最初は全スロットが空き
}
top = capacity;
}
public int Rent() { // 貸し出し
if (top == 0) return -1; // 枯渇:拡張するか要求を捨てる
return freeStack[--top]; // pop
}
public void Return(int id) { // 返却
items[id].Reset(); // 状態を初期化して再利用可能に
freeStack[top++] = id; // push
}
}
プールには実装上の注意点があります。(1) 枯渇——上限を超える要求が来たとき、動的拡張するのか最古を再利用するのか要求を捨てるのかを決めておく。無制限拡張はプールの意義(確保ゼロ)を損ないます。(2) 二重返却/解放後使用——同じスロットを二重にReturnするとフリーリストが壊れる。世代カウンタ付きハンドルで無効化を検出する設計が堅牢です。(3) 状態リーク——返却時に前回の状態(速度・タイマ・参照)を確実にリセットしないと、次に貸し出したときバグの温床になります。プールは"確保を消す"代わりに"状態管理の責任"を持ち込む、と理解してください。
データ指向設計:AoSからSoAへ
横にスクロール
アロケーションを消しても、データの並べ方が悪ければキャッシュミスで遅いままです。ここで効くのがデータ指向設計(Data-Oriented Design、DOD)です。オブジェクト指向が自然に導く配置はAoS(Array of Structures、構造体の配列)——1体ぶんのフィールドをまとめた構造体を並べます。対してSoA(Structure of Arrays、配列の構造体)は、同じフィールドを別々の連続配列に集めます。
AoS(構造体の配列): 1体ぶんが連続。使わない成分も同じ行に混ざる
[x0 y0 z0 hp0 tag0][x1 y1 z1 hp1 tag1][x2 y2 z2 hp2 tag2]...
SoA(配列の構造体): 成分ごとに連続。位置更新は x,y,z 配列だけ触る
x: [x0 x1 x2 x3 ...]
y: [y0 y1 y2 y3 ...]
z: [z0 z1 z2 z3 ...]
hp: [hp0 hp1 hp2 ...] ← 位置更新ループでは読み込まれない
CPUはメモリを1バイト単位ではなくキャッシュライン(多くのアーキテクチャで64バイト)単位でロードします。位置を全体更新するループを考えると、AoSでは1体読むたびに hp や tag など使わない成分まで同じラインに載って運ばれ、キャッシュラインの実効利用率が下がります。SoAなら x, y, z の配列だけを頭から舐めるので、ロードしたラインの全バイトが有効データになり、キャッシュミス率が下がる。さらに連続した同型データはハードウェアプリフェッチャが先読みしやすく、SIMD/SIMTによる並列化 にも素直に載ります。同じ計算式でも、配置を変えるだけで数倍の差が出るのはこのためです。
| 観点 | AoS(構造体の配列) | SoA(配列の構造体) |
|---|---|---|
| メモリ配置 | 1エンティティのフィールドが連続 | 同一フィールドが連続 |
| 部分走査の効率 | 使わない成分もラインに載り無駄 | 触る成分だけ連続で高効率 |
| キャッシュミス | 多くなりがち | 少ない(実効利用率が高い) |
| SIMD/ベクトル化 | 詰め替えが必要で効きにくい | そのまま並列ロードでき効きやすい |
| 1体まるごとアクセス | 1回のロードで揃い有利 | 複数配列に分散しやや不利 |
| 向く処理 | オブジェクト単位のランダム操作 | 多数を同種処理する更新ループ |
SoAが常に勝つわけではありません。「1体の全フィールドをまとめて読む」アクセスパターンではAoSが有利で、実務ではホットな更新ループで触る成分だけをSoA化し、残りはまとめて持つ折衷(AoSoA、ブロック化SoA)も使われます。判断軸は「そのループが実際にどのフィールドを、どの順で触るか」です。
GCスパイク回避とフレーム予算の管理
プールとSoAは、実時間ゲームの二大要求——ゴミを出さないこととキャッシュに載せること——に同時に効きます。マネージド言語では特に、更新ループ内の何気ない記述がゴミを生みます。ボックス化(値型をobjectに入れる暗黙変換)、クロージャやイテレータの内部生成、params配列、文字列連結、List の再確保などは、毎フレーム回れば確実にGC圧を上げます。ホットパスでは確保ゼロ(zero-allocation)を原則に、事前確保したバッファとプールだけで回すのが鉄則です。
フレーム予算の管理は「平均を速くする」だけでは足りず、最悪フレームを予算内に収める発想が要ります。重い処理を1フレームに集中させず複数フレームへ分割する(time-slicing)、生成のバースト(爆発で弾が一斉に出る等)をプールの事前確保で吸収する、GCが動くタイミングをローディング画面など見えない箇所へ誘導する(世代別GCの世代境界やインクリメンタルGCの活用)、といった手を組み合わせます。
最適化の効果は平均フレームタイムではなくスパイクで見ます。プロファイラで「最も遅い1%のフレーム(1%tile)」「0.1%tile」やフレームタイムのヒストグラムを追い、GCの発生回数と1回あたりの停止時間、確保バイト数(allocations/frame)を指標にします。"平均60fps"でも0.1%tileが40msなら体感はガクガクです。まず確保バイト数をゼロに近づけ(プール化・zero-alloc)、次にキャッシュミスを減らす(SoA化)と、スパイクが構造的に消えていきます。
- プールの本質: 実行時の生成破棄を事前確保した固定配列の使い回しに置換。確保・解放はフリーリストのpop/pushで
O(1)、断片化なし。代償として枯渇・二重返却・状態リセットの管理責任を負う。 - AoS対SoA: 多数を同種処理する更新ループでは、成分ごとに連続配置するSoAがキャッシュライン利用率とSIMD適性で有利。1体まるごと読むならAoS有利。触るフィールドで選ぶ。
- なぜ速い: CPUはキャッシュライン単位でロードするため、走査で使う成分だけが連続していればミス率が下がりプリフェッチも効く。
- GCスパイク: 停止時間は最悪ケースで効く。ホットパスはzero-allocを原則にゴミを出さない。ボックス化・クロージャ・文字列連結・コレクション再確保に注意。
- 予算管理: 平均でなく1%tile/0.1%tileで評価。time-slicing・事前確保・GCタイミング誘導で最悪フレームを16.6ms以内に収める。
まとめ
実時間ゲームの最適化は、フレーム予算という厳格な締め切りを毎フレーム守り続ける戦いです。毎フレームの生成破棄はヒープ確保コストとGCスパイクという二重の負担を生み、フレーム落ちを招きます。オブジェクトプールは確保を事前確保配列の使い回しに置き換え、フリーリストによる O(1) の貸出返却で実行時アロケーションとGC対象の生成をなくします。データ指向設計はAoSをSoAへ組み替え、走査で触る成分だけをキャッシュラインに連続して載せることでミス率を下げ、SIMDにも道を開きます。仕上げは計測で、平均ではなく1%tile・0.1%tileのスパイクを指標に、確保バイト数をゼロへ、キャッシュミスを最小へと追い込む。プールでゴミを出さず、SoAで局所性を上げ、処理を予算内に収める——この三点が、カクつかないゲームを支えるデータ指向の実装原則です。
ゲーム開発の記事ガイド
オブジェクトプールとデータ指向設計を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
ゲーム開発
比較で見る軸
難易度: advanced / カテゴリ: ゲーム開発 / タグ数: 6
導入後に効く点
データ指向設計はAoS(構造体の配列)ではなくSoA(配列の構造体)でフィールドごとに連続配置し、走査時に使う成分だけをキャッシュラインに載せる。ミス率が下がりSIMDも効くため、同じ計算でも数倍速くなりうる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- ゲーム開発
- タグ数
- 6
判断チェックリスト
- 自社の用途が「ゲーム開発 / データ指向設計」に近いか確認する。
- 強みである「オブジェクトプールは生成・破棄をあらかじめ確保した固定配列の使い回しに置き換え、実行時のヒープ確保とGC対象の生成を消す。確保・解放は空きリスト(フリーリスト)からのpop/pushでO(1)、断片化も起きない。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。