弾幕シューティングで弾を撃ちまくった瞬間、敵がドロップしたコインが一気に湧いた瞬間——「その一瞬だけ」ガクッとフレームが落ちる。この 突発的なカクつき(スパイク) に悩まされたことはないでしょうか。
原因の多くは、オブジェクトの 生成(instantiate())と破棄(queue_free())が短時間に集中する ことです。1個の生成コストは小さくても、1秒に数百回繰り返せばCPUに無視できない負荷がかかります。これを根本から解決するのが Object Pooling(オブジェクトプーリング) です。この記事では、その仕組みから、つまずきやすい罠、弾プールの実装、そして「いつ導入すべきか」の判断までを解説します。
この記事でわかること
- スパイクの原因と、 再利用でそれを消す Object Poolingの仕組み
- 再利用時に
_ready()が呼ばれない 罠と、spawn()/reset()での初期化hide()だけでは足りない—— 処理と衝突を完全に止める 方法- 弾プールの実装と、 導入すべきかの判断 (代替手段との比較)
Object Poolingの仕組み:使い捨てず再利用する
Object Poolingの考え方はシンプルで、 「オブジェクトを使い捨てにせず、再利用する」 だけです。レストランが皿を毎回作らず、洗って使い回すのと同じ。最もコストのかかる「生成」と「破棄」を、ゲームプレイの最中から追い出します。

動作は4ステップの循環です。
- 事前生成 :ゲーム開始時など負荷が許される場面で、一定数を生成して「プール(待機リスト)」に格納し、非表示にしておく
- 取り出し :弾を撃つなどオブジェクトが必要になったら、
instantiate()せず プールから未使用の1つを取り出して 位置を初期化し、表示する - 使用 :取り出したオブジェクトがゲーム内で役割を果たす(弾が飛んで敵に当たる)
- 返却 :役割を終えたら
queue_free()で 壊さず 、非表示にしてプールに戻す
このサイクルにより、プレイ中の負荷は「位置や状態をリセットするだけ」の軽い処理に置き換わります。結果、生成・破棄によるスパイクが消え、フレームレートが安定します。

つまずきやすい罠とベストプラクティス
Object Poolingは一見簡単ですが、いくつか落とし穴があります。特に 「再利用ならではの初期化」 が要注意です。

| よくある間違い | ベストプラクティス |
|---|---|
_ready() で初期化する | _ready() は 最初の1回し か呼ばれない 。再利用時の初期化は spawn(position, direction) のような 専用メソッド で行う |
hide() だけで戻す | hide() しても _process や衝突は動き続ける。set_process_mode(Node.PROCESS_MODE_DISABLED) で全処理を止め、CollisionShapeも無効化する |
物理中に remove_child() する | 物理演算中のツリー操作は不安定になる。call_deferred() で安全なタイミングに遅延させる |
| プールの枯渇を考えない | 激しい場面で空になると破綻する。空ならその場で生成する フォールバック + printerr で不足を検知する |
| 状態のリセット漏れ | 位置・速度だけでなく、色(modulate)・スケール・カスタム変数まで すべて初期値に戻す 。漏れは奇妙なバグの温床 |

特に 「見えないだけで動き続ける」 のは要注意です。hide() した弾が裏で移動と衝突判定を続けていると、負荷が減らないどころか 見えない当たり判定 でバグを生みます。処理と衝突をきちんと止めるのが肝心です。
実践:撃ちまくっても重くならない弾プールを組む
弾幕シューティングの弾、ヒット時の火花エフェクト、敵が落とすコイン——「高頻度で湧いては消える」ものはすべて、この弾プールの応用です。プール全体を管理する PoolManager(Autoloadに登録)と、プールされる PooledBullet の2つで組みます。
プール管理の心臓部:PoolManager
PoolManager は、どこからでも呼べるよう Autoload に登録します。取り出し(get_object)と返却(return_object)、そして有効化・無効化を担います。
# pool_manager.gd(Autoloadに登録)
extends Node
@export var scene_templates: Dictionary = {} # キー: シーンパス, 値: PackedScene
@export var initial_pool_sizes: Dictionary = {} # キー: シーンパス, 値: 初期数
var pools: Dictionary = {}
func _ready() -> void:
for scene_path in scene_templates:
var scene: PackedScene = scene_templates[scene_path]
var size: int = initial_pool_sizes.get(scene_path, 10)
pools[scene_path] = []
for i in size:
var obj := scene.instantiate()
if obj.has_method("set_pool_manager"):
obj.set_pool_manager(self)
add_child(obj)
_deactivate(obj) # 生成直後は待機状態に
pools[scene_path].append(obj)
func get_object(scene_path: String) -> Node:
# プールが空ならその場で生成(フォールバック+警告)
if not pools.has(scene_path) or pools[scene_path].is_empty():
printerr("プール '%s' が空です。新規生成します(サイズ不足の可能性)" % scene_path)
var new_obj := (scene_templates[scene_path] as PackedScene).instantiate()
if new_obj.has_method("set_pool_manager"):
new_obj.set_pool_manager(self)
add_child(new_obj)
return new_obj
var obj: Node = pools[scene_path].pop_back()
_activate(obj) # 取り出したら有効化
return obj
func return_object(obj: Node, scene_path: String) -> void:
if obj in pools.get(scene_path, []): # 二重返却を防ぐ
return
_deactivate(obj) # 止めてから戻す
pools[scene_path].append(obj)
func _activate(obj: Node) -> void:
obj.show()
obj.process_mode = Node.PROCESS_MODE_INHERIT # 処理を再開
_set_collisions_disabled(obj, false)
func _deactivate(obj: Node) -> void:
obj.hide()
obj.process_mode = Node.PROCESS_MODE_DISABLED # 処理を完全停止
_set_collisions_disabled(obj, true)
if obj.has_method("reset"):
obj.reset() # 状態を初期値へ
# 子孫のCollisionShapeをまとめて有効/無効にする
func _set_collisions_disabled(node: Node, disabled: bool) -> void:
for child in node.get_children():
if child is CollisionShape2D or child is CollisionShape3D:
child.set_deferred("disabled", disabled) # 物理中は遅延で安全に
_set_collisions_disabled(child, disabled)
プールされる側:PooledBullet
弾側の鍵は、_ready() の代わりの spawn()(初期化) と reset()(状態戻し) 、そして 自分をプールに返す 処理です。画面外に出たら自動で返すよう、VisibleOnScreenNotifier2D の シグナル を使います。
# pooled_bullet.gd
extends CharacterBody2D
const SPEED := 800.0
var direction := Vector2.RIGHT
var _pool_manager: Node
@onready var notifier: VisibleOnScreenNotifier2D = $VisibleOnScreenNotifier2D
func _ready() -> void:
# 画面外に出たら自分をプールへ戻す(_ready()は初回のみ・接続はここでOK)
notifier.screen_exited.connect(return_to_pool)
func set_pool_manager(manager: Node) -> void:
_pool_manager = manager
# _ready()の代わりの初期化。取り出すたびに呼ぶ
func spawn(start_position: Vector2, travel_direction: Vector2) -> void:
global_position = start_position
direction = travel_direction.normalized()
rotation = direction.angle()
# 返却時に状態を初期値へ戻す(リセット漏れに注意)
func reset() -> void:
velocity = Vector2.ZERO
func _physics_process(_delta: float) -> void:
velocity = direction * SPEED
move_and_slide()
func return_to_pool() -> void:
if _pool_manager:
# 物理中の返却を避け、遅延で安全に戻す
_pool_manager.call_deferred("return_object", self, scene_file_path)
else:
queue_free()
撃つ側は、instantiate() の代わりに get_object() して spawn() するだけです。
# 発射(PoolManagerはAutoload名)
var bullet := PoolManager.get_object("res://bullet.tscn")
bullet.spawn($Muzzle.global_position, Vector2.RIGHT)
ポイントは2つです。
- 初期化は
spawn()、後始末はreset():_ready()は再利用時に呼ばれないので、取り出すたびにspawn()で位置・向きを、返すときにreset()で速度などを初期化します。この2つが再利用の生命線です。 - 返却は
call_deferred()で安全に :弾が敵に当たるのは物理演算の最中です。その場でreturn_object()するとツリー操作が不安定になるため、call_deferred()で物理ステップの完了後に予約します。
導入すべきか:計測と代替手段
Object Poolingは強力ですが、 万能薬ではありません 。Godot 4はノード生成がかなり速くなったので、数フレームに1回程度の生成なら、プールを組む手間の方が大きいこともあります。

導入は必ず プロファイラで計測 し、 本当にインスタンス化がボトルネックだと確認してから 行います。そのうえで、問題の性質に応じて手を選びます。
- Object Pooling :弾・エフェクト・コインなど 短命・高頻度 なノードのスパイク対策
MultiMesh/RenderingServer:ロジックが単純で 見た目だけ の物量(数千〜数万)。ノードを使わず直接描画するので圧倒的に速いが、衝突などは自前実装が必要Thread(スレッド) :経路探索や大規模計算など CPU負荷の高い処理 を別スレッドへ逃がす。スパイクとは別種の負荷への対策
「同じ見た目のノードが高頻度で湧いて消える」ならプール、「見た目だけの大量描画」ならMultiMesh、「重い計算」ならスレッド——と、症状で使い分けます。
おまけ:先に知っておくと良いこと
- プールサイズは計測で決める :
printerrのフォールバック警告が頻発するなら、初期サイズが不足しています。最も激しい場面のピーク数を目安に調整します。 - エフェクトもプールできる :
GPUParticles2Dのワンショットエフェクトも、emittingを切り替えて再利用すればプールに乗せられます。 - カリングと併用する :画面外のオブジェクトの処理を止める発想(
VisibleOnScreenEnabler2Dなど)は、プーリングと相性が良く、さらに負荷を減らせます。
まとめ
- Object Poolingは、
instantiate()/queue_free()の集中による スパイクを解消 し、FPSを安定させる - 対象は 弾・エフェクトなど短命・高頻度 なオブジェクト
- 再利用時は
_ready()が呼ばれないので、spawn()/reset()で 手動初期化 する hide()だけでなくPROCESS_MODE_DISABLED+衝突無効化で 処理を完全に止める 。返却はcall_deferred()で安全に- 導入は プロファイラで確認してから 。物量描画は
MultiMesh、重い計算はThreadと使い分ける
まずは弾1種類でプールを組み、撃ちまくってもフレームタイムが平坦なままなのを確認してみてください。スパイクが消える手応えを掴めれば、Object Poolingはあなたの定番ツールになります。
さらに学ぶために
- GodotのFPSを安定させるフレームレート管理と最適化 ——計測の仕方とジッター対策
- _processと_physics_processの正しい使い分け ——プール対象の処理を止める前提知識
- Autoloadによるシーンをまたぐデータ管理 ——PoolManagerをグローバルに常駐させる
- Godot公式ドキュメント:Optimization using Servers ——RenderingServer等による更なる最適化