弾幕シューティングで敵弾を毎フレーム出す。タワーディフェンスで敵が100体を超える。攻撃のたびにダメージ数字がポップする。どれも同じことをしています。 Spawn Actor from Class で出して、用が済んだら Destroy Actor で消す、を高い頻度で繰り返しています。
小さいうちは問題になりません。ただ、出 し消しの回数が増えると、生成と破棄そのものがCPUの負荷になり、カクつき(ヒッチ)の原因になります。これを避けるのが オブジェクトプーリング です。作っては消すのをやめて、決まった数を 使い回す 考え方です。この記事では、Spawn/Destroyが重い理由から、眠らせるときに止めるもの、取り出しと返却、そして「まず計測してから導入する」という順番までを解説します。
この記事でわかること
Spawn Actor/Destroy Actorが重い理由は メモリ確保とGC- プーリングの発想= 消さずに眠らせて使い回す
- 眠らせるときに止めるもの( 表示・コリジョン・Tick・動き )
- 在庫から 取り出す /状態を戻して 返却する
- プールが空になったとき の2つの方針
- まず計測してから 導入する(早すぎる最適化を避ける)
- 実践: 弾をプール化して、撃ってもSpawnが増えないことを確かめる
なぜSpawnとDestroyは重いのか
Spawn Actor from Class は、見た目には1ノードです。ただ、内部ではそれなりの仕事をしています。

生成のときに起きているのは、おおよそ次のことです。
| タイミング | 起きていること |
|---|---|
| Spawn | メモリを新しく確保し、コンポーネントを登録し、Event BeginPlay を実行する |
| Destroy | アクターを「破棄予定」に印を付ける。実際のメモリ解放は後回し |
問題は破棄のほうです。 Destroy Actor を呼んでも、その場でメモリが片付くわけではありません。UEは破棄したオブジェクトをまとめて回収する ガベージコレクション(GC) を、一定間隔で走らせます。撃ちっぱなしで大量のアクターを消し続けると、このGCが動いた瞬間に処理が固まり、 数フレームぶんのカクつき として見えます。
1個2個なら誤差ですが、弾幕のように毎フレーム何十個も出し消しすると、次の2つが積み重なります。
- 生成・破棄そのもののCPUコスト がフレームごとに乗る(Gameスレッド)
- GCが走るたびのスパイク が周期的に出る
どちらも stat unit の Game に現れます。 stat unit の読み方は計測の記事にまとめてあります。プーリングは、この 生成と破棄を最初の一度だけに減らす ことで、両方をまとめて消す手立てです。
プーリングの発想:消さずに眠らせる
プーリングの考え方はシンプルです。 必要な数だけ最初に作っておき、あとは消さずに使い回す。 使い終わったアクターは、破棄せずに「見えなくして止めておく」だけにします。

倉庫にたとえると分かりやすいです。弾を撃つたびに新品を工場に発注して、当たったら捨てる。これがSpawn/Destroyです。プーリングは、倉庫に30個の弾を用意しておき、撃つときに1個持ち出し、当たったら 倉庫へ戻す 。工場(メモリ確保)も廃棄(GC)も、最初の30個を作るとき以外は動きません。
このとき、倉庫にある弾は消えているのではなく、 眠っている だけです。見えず、当たらず、動かない状態でその場に残しておきます。呼び出すときは、この眠りを解いて所定の位置へ置き直します。
| Spawn / Destroy 方式 | プーリング方式 | |
|---|---|---|
| 弾を出す | 毎回 Spawn Actor | 在庫から1個 起こす |
| 弾を消す | 毎回 Destroy Actor | 倉庫へ 返して眠らせる |
| メモリ確保 | 出すたびに発生 | 最初の1回だけ |
| GC | 消すたびに溜まる | 発生しない |
眠らせて起こすという発想は、Tickを使わないBlueprint設計の「動かす必要がない間は止める」と地続きです。
眠らせる:止めるべき4 つ
「眠らせる」を具体的なノードに落とすと、止めるものは大きく4つです。ここを1つでも忘れると、眠っているはずの弾が見えたり、当たったり、動いたりします。

| 止めるもの | ノード | 眠らせる(返却) | 起こす(取り出し) |
|---|---|---|---|
| 表示 | Set Actor Hidden In Game | New Hidden オン | New Hidden オフ |
| コリジョン | Set Actor Enable Collision | オフ | オン |
| Tick | Set Actor Tick Enabled | オフ | オン |
| 動き(弾のみ) | Projectile Movement | Stop Movement Immediately +コンポーネントTickオフ | Velocityを設定し直す |
最初の3つは、どんなプール対象にも共通する 3点セット です。ここまでで、見た目・当たり判定・毎フレーム処理が止まります。
Tickを切ってもコンポーネントは止まらない
4 つ目が、初心者が必ずはまるところです。 Set Actor Tick Enabled を切っても、Projectile Movement Componentは動き続けます。
Set Actor Tick Enabled が止めるのは アクター自身のTick だけで、コンポーネントはそれぞれ独立したTickを持っているためです。弾を眠らせたつもりでも、Projectile Movementが動き続けていると、見えないまま飛んでいって、起こしたときに変な位置から出てきます。
弾のように動くコンポーネントを持つアクターでは、そのコンポーネントを別に止めてください。Projectile Movementなら、 Stop Movement Immediately で速度を0にし、 Set Component Tick Enabled(対象はProjectile Movement Component)をオフにします。起こすときは逆に、Tickをオンへ戻し、進行方向へVelocityを入れ直します。
補足: 返却のときに 状態のリセットを忘れる のが、プーリング最大のバグ源です。速度・エフェクト・HP・タイマーが前回のまま残っていると、再利用した弾が 前回の方向へ飛んだり、出た瞬間に消えたり します。「眠らせる処理」に、使った変数を初期値へ戻す処理も必ず含めてください。
取り出しと返却
プールの中身は、 配列(Array) で持ちます。 BP_Bullet の配列を1つ用意し、そこに在庫を並べてお くだけです。配列そのものの扱いは配列・Map・Setの記事にまとめてあります。

処理は2つの関数に分かれます。
取り出す(GetBullet) は、配列から いま眠っている1個 を探して起こします。眠っているかどうかは、 Get Actor Hidden In Game(隠れている=眠っている)で判定するのが手軽です。見つかったら、位置と向きを撃つ場所に合わせ、前の節の「起こす」処理を実行します。
返却する(ReturnBullet) は、弾が当たったときや、一定時間が過ぎたときに呼びます。前の節の「眠らせる」処理を実行し、位置は動かさず、その場に伏せさせるだけです。
ここで1つ注意があります。プールした弾には、 Initial Life Span を使えません 。Life Spanは時間が来ると Destroy Actor を呼ぶ仕組みで、プールの在庫を勝手に破棄してしまうからです。代わりに、起こすときに Timer を仕掛けて、時間が来たら Destroy ではなく ReturnBullet を呼ぶ ようにします。撃ちっぱなしの弾を掃除する目的は同じでも、行き先が「破棄」から「返却」に変わります。 Initial Life Span の基本は弾の記事を参照してください。
プールが空になったら
在庫30個を全部使っている最中に、31個目を撃とうとしたらどうするか。プーリングで必ず決めておくのが、この 枯渇時の方針 です。方針は2つあります。
| 方針 | 挙動 | 向いている場面 |
|---|---|---|
| プールを伸ばす | 新しく1個 Spawn して配列に足す | 溢れるのがたまになら手軽。ピーク時だけ在庫が増える |
| いちばん古いのを再利用 | 使用中で最も古い1個を強制的に返却して起こし直す | 数を上限に固定したい。弾幕で「画面上は常に最大N発」にできる |

弾幕シューティングのように 同時に出る数の上限を決めたい なら、後者が向いています。古い弾が新しい弾に押し出されて消えるので、アクター数が青天井にならず、負荷が読めます。
最初は「伸ばす」で組んでおき、動かしながら適切な初期サイズを見つけるのが現実的です。ピーク時に配列がいくつまで伸びたかを見れば、次からの初期サイズの目安になります。
プールをどこに置 くか
プールの管理役(配列と2つの関数)を、どこに持たせるかも決めます。
| 置き場所 | 特徴 |
|---|---|
専用のプールActor(BP_BulletPool) | レベルに1つ置くだけ。仕組みが目に見えて分かりやすい。まずはこれで十分 |
| GameInstance Subsystem | レベルをまたいでプールを保持できる。複数のシステムから使い回すとき向き |
個人開発では、まず 専用のActorを1つ 置く形が分かりやすくおすすめです。撃つ側(キャラクター)は、レベルからそのプールActorを取得して GetBullet を呼ぶだけになります。プール全体を1つの部品として切り出したくなったら、コンポーネント化して別のActorへ付け替える形にもできます。
まず測る。プールは計測してから
ここまで読んで「弾を出すゲームだから、最初からプールにしておこう」と思ったなら、いったん止まってください。 プーリングは、計測して必要だと分かってから導入する最適化 です。
理由は2つあります。
- コードが増える : Spawn/Destroyの素直な実装に比べ、配列・取り出し・返却・状態リセットと、管理する部品 が増えます。バグの入る場所も増えます
- 効かない場面がある : 同時に数個しか出ないゲームでは、生成・破棄のコストはそもそも小さく、プールにしても体感は変わりません。管理コストだけが残ります
測り方は、まず statコマンド で当たりを付け、Spawn Actor が本当に効いているかを Unreal Insights で確定させる、という順番が確実です。
正しい順番は、弾の記事の素直な実装でまず作り、 stat unit で測ることです。Gameスレッドがボトルネックで、その原因が毎フレームの生成・破棄だと確認できたときに、初めてプールへ置き換えます。 「重そうだから」ではなく「重いと測れたから」導入する。 これは弾に限らず、最適化すべてに共通する姿勢です(→ 計測の記事)。
実践:弾をプール化してSpawnを増やさない
弾幕シューティングの敵弾、タワーディフェンスの矢、見下ろしARPGの魔法弾。 大量に出し消しする弾 は、ジャンルを問わずプーリングの効果が出やすい題材です。ここでは、素直なSpawn/Destroy版とプール版を並べて、 stat unit の数字がどう変わるかまで確かめます。
完成形
マウス左を押しっぱなしにすると、キャラクターの前方へ弾が連射されます。素直な実装では、撃ち続けるとGameスレッドが波打ち、GCのたびにスパイクが出ます。プール版では、同じ連射でも Gameス レッドが平ら で、World Outlinerの弾の数も プールサイズのまま増えません 。

再現条件
Third Person テンプレート (Blueprint)で新規プロジェクトを作り、次の2つを用意します。弾そのものの作り方は弾の記事と同じなので、ここでは差分だけ示します。
1. 弾(BP_Bullet)
| コンポーネント | 設定 |
|---|---|
| Sphere Collision(ルート) | Sphere Radius 16.0 / Simulation Generates Hit Events オン |
| Static Mesh(子) | Shape_Sphere / Scale 0.3 / Collision Presets NoCollision |
| Projectile Movement | Initial Speed 3000.0 / Max Speed 3000.0 / Gravity Scale 0.0 |
BP_Bullet に、次の変数を追加します。
| 変数名 | 型 | 初期値 | 用途 |
|---|---|---|---|
OwningPool | BP_BulletPool(オブジェクト参照) | なし | 返却先のプール |
ReturnHandle | Timer Handle | — | 自動返却タイマーの管理用 |
Initial Life Span は 0.0 のまま にしておきます(プールではLife Spanを使いません)。
2. プール(BP_BulletPool)
Actorを親クラスに作り、次の変数を持たせます。
| 変数名 | 型 | 初期値 | 用途 |
|---|---|---|---|
Pool | BP_Bullet の 配列 | 空 | 在庫 |
PoolSize | Integer | 30 | 初期在庫数 |
このプールActorをレベルに1つ配置しておきます。
プール側のグラフ
BP_BulletPool に、初期化・取り出し・眠らせるの3つを組みます。

BP_BulletPool
Event BeginPlay
→ ForLoop(First Index 0, Last Index PoolSize - 1)
Loop Body →
Spawn Actor from Class(Class: BP_Bullet, Collision Handling: Always Spawn, Ignore Collisions)
Return Value →
Set(OwningPool = Self) ※弾側の変数へ
→ Sleep(後述) ※作った直後に眠らせる
→ Add(Pool 配列へ追加)
関数 GetBullet → 戻り値 BP_Bullet
→ ForEachLoop(Pool)
Loop Body →
Get Actor Hidden In Game(Array Element)
True(=眠っている)→ Return Node(Return Value: Array Element)
Completed(誰も眠っていない=枯渇)→
Spawn Actor from Class(BP_Bullet)→ Set OwningPool → Add(Pool)→ Return
関数 Sleep(引数 Target: BP_Bullet)
→ Set Actor Hidden In Game(Target, New Hidden: true)
→ Set Actor Enable Collision(Target, false)
→ Set Actor Tick Enabled(Target, false)
→ Stop Movement Immediately(Target → Projectile Movement)
→ Set Component Tick Enabled(Target → Projectile Movement, false)
BeginPlay で30個をまとめてSpawnし、その場で眠らせて配列へ入れます。 生成コストとGCの元になるSpawnは、ここで一度きり です。以降は GetBullet が眠っている在庫を起こすだけになります。
撃つ側と弾側のグラフ
BP_ThirdPersonCharacter で、プールから弾を取り出して起こします。
BP_ThirdPersonCharacter
左マウス(Pressed)
→ プールActorを取得(レベルに置いた BP_BulletPool への参照)
→ GetBullet
Return Value(BP_Bullet)→ Activate(下記)を呼ぶ
弾側に、起こす Activate と、当たり/時間切れで戻す処理を組みます。
BP_Bullet
関数 Activate(引数 SpawnLocation, SpawnRotation)
→ Set Actor Location And Rotation(SpawnLocation, SpawnRotation)
→ Set Actor Hidden In Game(false)
→ Set Actor Enable Collision(true)
→ Set Actor Tick Enabled(true)
→ Set Component Tick Enabled(Projectile Movement, true)
→ Set Velocity(Projectile Movement)
= Get Actor Forward Vector × 3000.0
→ Set Timer by Function Name(Function: ReturnToPool, Time: 3.0)
Return Value → ReturnHandle へ保存
On Component Hit(Sphere)
→ ReturnToPool
関数 ReturnToPool
→ Clear and Invalidate Timer by Handle(ReturnHandle)
→ OwningPool → Sleep(Target: Self)
当たったとき(On Component Hit)と、3秒たっても当たらなかったとき(Timer)の、どちらでも ReturnToPool を通ります。行き先が Destroy ではなく プールへの返却 になっているのが、素直な実装との唯一の違いです。
確認する
まず比較用に、弾の記事の 素直なSpawn/Destroy版 を用意し、Standalone Game で起動して stat unitgraph を実行します。左マウスを押しっぱなしにして撃ち続けてください。
- 素直な実装 : Gameスレッドの折れ線が常に高めで波打ち、GCが走るたびに大きなスパイクが出ます。World Outlinerを見ると、弾のアクター数が増減を繰り返します
- プール実装 : 同じ連射でも、Gameスレッドの折れ線が 平ら です。弾のアクター数は 30のまま 動きません
たとえばGameスレッドが、素直な実装では 2.5ms を中心に 7〜9ms のスパイクが周期的に出ていたのが、プール実装では 2.5ms 前後で平らになります(値は環境で変わります)。 スパイクが消えたこと が、GCを止められた証拠です。
うまくいかないときの切り分けです。
- 弾が最初から見えている →
BeginPlayの生成直後にSleepを呼んでいない。作った30個がそのまま画面に出ている - 2発目以降が出ない →
GetBulletが眠っている弾を見つけられていない。SleepでSet Actor Hidden In Gameをオンにできているか確認する - 起こした弾が飛ばない、または止まっている →
ActivateでSet Velocityを入れ直していない。Projectile MovementのTickをオンに戻したかも確認する - 弾が前回の方向へ飛ぶ → 返却時に速度をリセットしていない。
Stop Movement ImmediatelyをSleepに入れる - 見えないのに何か に当たる →
Set Actor Enable Collisionをオフにし忘れている。眠っている弾のコリジョンが残っている - プール版でもスパイクが出る →
ReturnToPoolがDestroy Actorを呼んでいないか確認する。1つでもDestroyが残るとGCが復活する
ポイントは2つです。
- 生成と破棄を「最初の一度」に閉じ込める: プールの本質は、
SpawnをBeginPlayの初期化だけに、Destroyをゼロにすることです。毎フレームの出し消しが消えれば、そのぶんのGameスレッド負荷とGCスパイクがまとめて消えます - 眠らせる/起こすは対称に書く:
Sleepで止めたもの(表示・コリジョン・Tick・動き・タイマー)は、Activateで1つずつ戻します。片方だけ書くと、見えない弾が当たる・戻した弾が飛ばない、といったバグになります。2つの関数を並べて、止めたものと戻すものが一致しているか見比べてください
当たったときの火花や爆発も、プールと相性が良い題材です。エフェクト用のアクターやNiagaraも同じ発想で使い回せます(→ Niagaraの記事)。
おまけ:先に知っておくと良いこと
- Niagaraやサウンドには専用のプールがある: パーティクルは、毎回
Spawn System at Locationするより、Niagara側の非アクティブ化で使い回すほうが軽くなります。「出し消しが多い部品」を見つけたら、まずエンジン標準の再利用の仕組みがないかを確認してください - プールのウォームアップは読み込みの谷でやる:
BeginPlayで30個を一気に作ると、その瞬間だけ生成コストがまとまって出ます。レベル開始直後やローディング画面の裏で作っておくと、プレイ中に生成の山が来ません - AIやキャラクターのプールは慎重に: 弾のように単純な状態しか持たないものはプール向きですが、AIコントローラーやビヘイビアツリーを抱えるキャラクターは、状態のリセット漏れが起きやすくなります。まずは弾・エフェクト・ダメージ数字のような 軽くて数が多いもの から始めてください
- 返却漏れは静かなバグになる: どこかの経路で
ReturnToPoolを呼び忘れると、その弾は眠らないまま在庫から外れ、プールが少しずつ枯れていきます。返却を通る経路(当たった/時間切れ/画面外/レベル遷移)を洗い出して、すべてがReturnToPoolへ合流するようにしておきます - 測ってから、また測る: プール化したら、もう一度
stat unitを回してください。Gameが下がった結果、今度はGPUや描画がボトルネックになっているかもしれません。最適化は「測る→直す→また測る」の繰り返しです(→ 計測の記事)
まとめ
Spawn Actor/Destroy Actorの繰り返しは、 メモリ確保とGC でGameスレッドを 重くする- プーリングは、決まった数を 消さずに眠らせて使い回す 。生成と破棄を最初の一度に閉じ込める
- 眠らせるときは 表示・コリジョン・Tick を止め、弾では Projectile Movementの動き も別に止める(Tickを切ってもコンポーネントは動き続ける)
- 在庫は 配列 で持ち、 返却時に状態をリセット する。忘れると前回の状態が残る
- プールが空のときは 伸ばす か 古いのを再利用する かを決めておく
- そして何より、 まず計測してから 導入する。数が少ないなら不要
いま作っているゲームで、毎フレーム出し消ししているものは何でしょうか。まずは素直に作って stat unit を打ち、Gameスレッドが本当に重いのかを確かめるところから始めてください。重さの正体が「読み込みの遅さ・メモリの膨らみ」だったなら、次はソフト参照と非同期ロードへ進みます。