【Unreal Engine】UE5のオブジェクトプーリング:Spawnし続けて重くなる前に使い回す

作成: 2026-07-20

弾幕や大量の敵でSpawn Actor / Destroy Actorを繰り返すとカクつく理由を図解。プーリングの発想(消さずに眠らせて使い回す)、眠らせるときに止める4つ、取り出しと返却、プール枯渇時の方針、そして「まず計測してから導入する」姿勢まで。

弾幕シューティングで敵弾を毎フレーム出す。タワーディフェンスで敵が100体を超える。攻撃のたびにダメージ数字がポップする。どれも同じことをしています。 Spawn Actor from Class で出して、用が済んだら Destroy Actor で消す、を高い頻度で繰り返しています。

小さいうちは問題になりません。ただ、出し消しの回数が増えると、生成と破棄そのものがCPUの負荷になり、カクつき(ヒッチ)の原因になります。これを避けるのが オブジェクトプーリング です。作っては消すのをやめて、決まった数を 使い回す 考え方です。この記事では、Spawn/Destroyが重い理由から、眠らせるときに止めるもの、取り出しと返却、そして「まず計測してから導入する」という順番までを解説します。

倉庫から弾を取り出し、用が済んだら倉庫へ戻す循環を見るソフトブルーの人形

この記事でわかること

  • Spawn Actor / Destroy Actor が重い理由は メモリ確保とGC
  • プーリングの発想= 消さずに眠らせて使い回す
  • 眠らせるときに止めるもの( 表示・コリジョン・Tick・動き
  • 在庫から 取り出す /状態を戻して 返却する
  • プールが空になったとき の2つの方針
  • まず計測してから 導入する(早すぎる最適化を避ける)
  • 実践: 弾をプール化して、撃ってもSpawnが増えないことを確かめる

Sponsored

なぜSpawnとDestroyは重いのか

Spawn Actor from Class は、見た目には1ノードです。ただ、内部ではそれなりの仕事をしています。

Spawn時はメモリ確保・コンポーネント登録・BeginPlay、Destroy時はGC待ちが発生することを示した図

生成のときに起きているのは、おおよそ次のことです。

タイミング起きていること
Spawnメモリを新しく確保し、コンポーネントを登録し、Event BeginPlay を実行する
Destroyアクターを「破棄予定」に印を付ける。実際のメモリ解放は後回し

問題は破棄のほうです。 Destroy Actor を呼んでも、その場でメモリが片付くわけではありません。UEは破棄したオブジェクトをまとめて回収する ガベージコレクション(GC) を、一定間隔で走らせます。撃ちっぱなしで大量のアクターを消し続けると、このGCが動いた瞬間に処理が固まり、 数フレームぶんのカクつき として見えます。

1個2個なら誤差ですが、弾幕のように毎フレーム何十個も出し消しすると、次の2つが積み重なります。

  • 生成・破棄そのもののCPUコスト がフレームごとに乗る(Gameスレッド)
  • GCが走るたびのスパイク が周期的に出る

どちらも stat unitGame に現れます。 stat unit の読み方は計測の記事にまとめてあります。プーリングは、この 生成と破棄を最初の一度だけに減らす ことで、両方をまとめて消す手立てです。

Sponsored

プーリングの発想:消さずに眠らせる

プーリングの考え方はシンプルです。 必要な数だけ最初に作っておき、あとは消さずに使い回す。 使い終わったアクターは、破棄せずに「見えなくして止めておく」だけにします。

使うたびに作って消す方式と、決まった数を眠らせて起こす方式を対比した図

倉庫にたとえると分かりやすいです。弾を撃つたびに新品を工場に発注して、当たったら捨てる。これがSpawn/Destroyです。プーリングは、倉庫に30個の弾を用意しておき、撃つときに1個持ち出し、当たったら 倉庫へ戻す 。工場(メモリ確保)も廃棄(GC)も、最初の30個を作るとき以外は動きません。

このとき、倉庫にある弾は消えているのではなく、 眠っている だけです。見えず、当たらず、動かない状態でその場に残しておきます。呼び出すときは、この眠りを解いて所定の位置へ置き直します。

Spawn / Destroy 方式プーリング方式
弾を出す毎回 Spawn Actor在庫から1個 起こす
弾を消す毎回 Destroy Actor倉庫へ 返して眠らせる
メモリ確保出すたびに発生最初の1回だけ
GC消すたびに溜まる発生しない

眠らせて起こすという発想は、Tickを使わないBlueprint設計の「動かす必要がない間は止める」と地続きです。


眠らせる:止めるべき4つ

「眠らせる」を具体的なノードに落とすと、止めるものは大きく4つです。ここを1つでも忘れると、眠っているはずの弾が見えたり、当たったり、動いたりします。

表示・コリジョン・Tick・Projectile Movementの4つを止めて眠らせ、起こすときに戻すことを示した図
止めるものノード眠らせる(返却)起こす(取り出し)
表示Set Actor Hidden In GameNew Hidden オンNew Hidden オフ
コリジョンSet Actor Enable Collisionオフオン
TickSet Actor Tick Enabledオフオン
動き(弾のみ)Projectile MovementStop 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・タイマーが前回のまま残っていると、再利用した弾が 前回の方向へ飛んだり、出た瞬間に消えたり します。「眠らせる処理」に、使った変数を初期値へ戻す処理も必ず含めてください。

Sponsored

取り出しと返却

プールの中身は、 配列(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発」にできる
プールを伸ばす方式は在庫が1個増え、いちばん古いのを再利用する方式は数が一定のまま古い1個が押し出されることを左右で比較した図

弾幕シューティングのように 同時に出る数の上限を決めたい なら、後者が向いています。古い弾が新しい弾に押し出されて消えるので、アクター数が青天井にならず、負荷が読めます。

最初は「伸ばす」で組んでおき、動かしながら適切な初期サイズを見つけるのが現実的です。ピーク時に配列がいくつまで伸びたかを見れば、次からの初期サイズの目安になります。


プールをどこに置くか

プールの管理役(配列と2つの関数)を、どこに持たせるかも決めます。

置き場所特徴
専用のプールActorBP_BulletPoolレベルに1つ置くだけ。仕組みが目に見えて分かりやすい。まずはこれで十分
GameInstance Subsystemレベルをまたいでプールを保持できる。複数のシステムから使い回すとき向き

個人開発では、まず 専用のActorを1つ 置く形が分かりやすくおすすめです。撃つ側(キャラクター)は、レベルからそのプールActorを取得して GetBullet を呼ぶだけになります。プール全体を1つの部品として切り出したくなったら、コンポーネント化して別のActorへ付け替える形にもできます。

Sponsored

まず測る。プールは計測してから

ここまで読んで「弾を出すゲームだから、最初からプールにしておこう」と思ったなら、いったん止まってください。 プーリングは、計測して必要だと分かってから導入する最適化 です。

理由は2つあります。

  • コードが増える : Spawn/Destroyの素直な実装に比べ、配列・取り出し・返却・状態リセットと、管理する部品が増えます。バグの入る場所も増えます
  • 効かない場面がある : 同時に数個しか出ないゲームでは、生成・破棄のコストはそもそも小さく、プールにしても体感は変わりません。管理コストだけが残ります

測り方は、まず statコマンド で当たりを付け、Spawn Actor が本当に効いているかを Unreal Insights で確定させる、という順番が確実です。

正しい順番は、弾の記事の素直な実装でまず作り、 stat unit で測ることです。Gameスレッドがボトルネックで、その原因が毎フレームの生成・破棄だと確認できたときに、初めてプールへ置き換えます。 「重そうだから」ではなく「重いと測れたから」導入する。 これは弾に限らず、最適化すべてに共通する姿勢です(→ 計測の記事)。


実践:弾をプール化してSpawnを増やさない

弾幕シューティングの敵弾、タワーディフェンスの矢、見下ろしARPGの魔法弾。 大量に出し消しする弾 は、ジャンルを問わずプーリングの効果が出やすい題材です。ここでは、素直なSpawn/Destroy版とプール版を並べて、 stat unit の数字がどう変わるかまで確かめます。

完成形

マウス左を押しっぱなしにすると、キャラクターの前方へ弾が連射されます。素直な実装では、撃ち続けるとGameスレッドが波打ち、GCのたびにスパイクが出ます。プール版では、同じ連射でも Gameスレッドが平ら で、World Outlinerの弾の数も プールサイズのまま増えません

素直な実装はGameスレッドにGCスパイクが出るが、プール版は平らになるstat unitgraphの前後比較

再現条件

Third Person テンプレート (Blueprint)で新規プロジェクトを作り、次の2つを用意します。弾そのものの作り方は弾の記事と同じなので、ここでは差分だけ示します。

1. 弾(BP_Bullet

コンポーネント設定
Sphere Collision(ルート)Sphere Radius 16.0Simulation Generates Hit Events オン
Static Mesh(子)Shape_Sphere / Scale 0.3 / Collision Presets NoCollision
Projectile MovementInitial Speed 3000.0 / Max Speed 3000.0 / Gravity Scale 0.0

BP_Bullet に、次の変数を追加します。

変数名初期値用途
OwningPoolBP_BulletPool(オブジェクト参照)なし返却先のプール
ReturnHandleTimer Handle自動返却タイマーの管理用

Initial Life Span は 0.0 のまま にしておきます(プールではLife Spanを使いません)。

2. プール(BP_BulletPool

Actorを親クラスに作り、次の変数を持たせます。

変数名初期値用途
PoolBP_Bullet の 配列在庫
PoolSizeInteger30初期在庫数

このプールActorをレベルに1つ配置しておきます。

プール側のグラフ

BP_BulletPool に、初期化・取り出し・眠らせるの3つを組みます。

InitializePoolでSpawnして眠らせ、GetBulletで眠っている弾を探して起こすノードグラフ
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 が眠っている弾を見つけられていない。SleepSet Actor Hidden In Game をオンにできているか確認する
  • 起こした弾が飛ばない、または止まっているActivateSet Velocity を入れ直していない。Projectile MovementのTickをオンに戻したかも確認する
  • 弾が前回の方向へ飛ぶ → 返却時に速度をリセットしていない。Stop Movement ImmediatelySleep に入れる
  • 見えないのに何かに当たるSet Actor Enable Collision をオフにし忘れている。眠っている弾のコリジョンが残っている
  • プール版でもスパイクが出るReturnToPoolDestroy Actor を呼んでいないか確認する。1つでもDestroyが残るとGCが復活する

ポイントは2つです。

  • 生成と破棄を「最初の一度」に閉じ込める: プールの本質は、SpawnBeginPlay の初期化だけに、Destroy をゼロにすることです。毎フレームの出し消しが消えれば、そのぶんのGameスレッド負荷とGCスパイクがまとめて消えます
  • 眠らせる/起こすは対称に書く: Sleep で止めたもの(表示・コリジョン・Tick・動き・タイマー)は、Activate で1つずつ戻します。片方だけ書くと、見えない弾が当たる・戻した弾が飛ばない、といったバグになります。2つの関数を並べて、止めたものと戻すものが一致しているか見比べてください

当たったときの火花や爆発も、プールと相性が良い題材です。エフェクト用のアクターやNiagaraも同じ発想で使い回せます(→ Niagaraの記事)。

Sponsored

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

  • 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スレッドが本当に重いのかを確かめるところから始めてください。重さの正体が「読み込みの遅さ・メモリの膨らみ」だったなら、次はソフト参照と非同期ロードへ進みます。