Godot 4 でシューティングゲームやエフェクト盛り盛りのゲームを作っていると、弾やパーティクルをどんどん queue_free() して、また PackedScene.instantiate() して…というコードになりがちですよね。
小規模なら問題ありませんが、弾幕ゲームや大量の敵を出すようなシーンになると、GC(ガベージコレクション)やメモリアロケーションのコストがジリジリ効いてきます。
さらに、Godot の標準的な作り方だと、
- 弾シーンを毎回
load()/instantiate()する - 消えるときは
queue_free()で破棄する - 弾ごとにちょっと違う挙動をさせたいときに、弾クラスを継承で増やしていく
…という「インスタンス生成&破棄」と「継承ツリー地獄」のダブルパンチになりがちです。
そこで今回は、「一度作った弾オブジェクトを破棄せず、非表示にして再利用する」ためのコンポーネント、「ObjectPool」コンポーネントを用意しました。
継承ベースで弾クラスを増やすのではなく、「弾はただのシーン」「管理は ObjectPool コンポーネントに任せる」という合成(Composition)スタイルで、スッキリした設計にしていきましょう。
【Godot 4】弾もエフェクトも使い回そう!「ObjectPool」コンポーネント
この ObjectPool コンポーネントは、指定したシーンをあらかじめ複数インスタンス化しておき、
- 必要になったら「未使用オブジェクト」を貸し出す
- 使い終わったら非表示にしてプールに戻す
という仕組みを提供します。
弾、敵、エフェクト、落ちてくる石、動く床の一時インスタンスなど、何でも再利用できます。
ObjectPool.gd – フルコード
extends Node
class_name ObjectPool
## 汎用オブジェクトプールコンポーネント
## - 指定した PackedScene をまとめてインスタンス化して保持
## - 使用中/未使用を切り替えながら再利用する
## - 弾、エフェクト、敵など、なんでもプール可能
## プールするシーン(弾やエフェクトなど)
@export var pooled_scene: PackedScene
## 最初に生成しておくオブジェクト数
@export_range(0, 10_000, 1)
var initial_size: int = 20
## 必要数が足りないときに自動で増やすかどうか
@export var auto_expand: bool = true
## 自動で増やすときの増分
@export_range(1, 1_000, 1)
var expand_step: int = 10
## プールしたオブジェクトをまとめてぶら下げる親ノード
## 空のままなら、自分自身(ObjectPool ノード)を親にする
@export var container: Node
## 生成したインスタンスを非表示にしておくか
## - true: 初期状態では見えない/コリジョンもオフにしておき、貸し出し時に有効化する前提
## - false: プール生成直後から表示される(特殊用途向け)
@export var hide_on_create: bool = true
## デバッグ用に、現在の使用数をエディタ上で確認したいときに便利
@export var debug_print_on_expand: bool = false
## 実際にプールしているすべてのオブジェクト
var _all_instances: Array[Node] = []
## プール内で未使用として扱うオブジェクト
var _available: Array[Node] = []
func _ready() -> void:
if not pooled_scene:
push_warning("ObjectPool: 'pooled_scene' が設定されていません。エディタで PackedScene を指定してください。")
return
if not container:
container = self
_create_instances(initial_size)
## 指定数だけインスタンスを生成してプールに追加する
func _create_instances(count: int) -> void:
if count <= 0:
return
for i in count:
var instance := pooled_scene.instantiate()
if not instance:
push_error("ObjectPool: インスタンス生成に失敗しました。pooled_scene を確認してください。")
return
container.add_child(instance)
_setup_pooled_object(instance)
_all_instances.append(instance)
_available.append(instance)
if debug_print_on_expand:
print("ObjectPool: expanded by %d, total=%d, available=%d" % [
count,
_all_instances.size(),
_available.size()
])
## プールされたオブジェクトに初期設定を行う
func _setup_pooled_object(obj: Node) -> void:
# 初期状態では無効化(非表示+コリジョン無効)しておく
if hide_on_create:
_set_active(obj, false)
# 利用側が「自分で戻す」実装をしやすいように、
# obj に "return_to_pool" メソッドが無い場合は、動的にメソッドを生やすこともできるが
# Godot 4 では動的メソッド追加は非推奨なので、
# ここではシグナル経由で戻してもらうことを想定する。
#
# 例:
# 弾側スクリプトから:
# get_parent().emit_signal("request_return_to_pool", self)
#
# ただし、このサンプルではシグナルまでは用意せず、
# 利用側が直接 pool.return_instance(self) を呼ぶ前提にしている。
## プールから 1 つオブジェクトを取得して返す
## - 無ければ auto_expand 設定に応じて増やす
## - それでも無ければ null を返す
func get_instance() -> Node:
if _available.is_empty():
if auto_expand:
_create_instances(expand_step)
else:
return null
if _available.is_empty():
# auto_expand=false か、expand_step=0 の場合など
return null
var obj: Node = _available.pop_back()
_set_active(obj, true)
return obj
## 使用済みオブジェクトをプールに戻す
## - obj はこのプールが管理しているインスタンスである必要がある
func return_instance(obj: Node) -> void:
if not _all_instances.has(obj):
push_warning("ObjectPool: 渡されたオブジェクトはこのプールの管理対象ではありません。")
return
# 既に available に入っているなら何もしない(二重返却防止)
if _available.has(obj):
return
_set_active(obj, false)
_available.append(obj)
## プール対象オブジェクトの有効/無効を切り替える
## - Node2D, Node3D, CanvasItem, CollisionObject2D/3D などをざっくりサポート
func _set_active(obj: Node, active: bool) -> void:
# 表示の ON/OFF
if obj is CanvasItem:
(obj as CanvasItem).visible = active
elif obj.has_method("set_visible"):
obj.call("set_visible", active)
# コリジョンの ON/OFF(2D/3D)
if obj is CollisionObject2D:
(obj as CollisionObject2D).disabled = not active
elif obj is CollisionObject3D:
(obj as CollisionObject3D).disabled = not active
# 子ノードにも同様の処理を適用したい場合はここで再帰的に処理する
# (必要に応じてコメントアウトを外してください)
# for child in obj.get_children():
# if child is CanvasItem:
# (child as CanvasItem).visible = active
# if child is CollisionObject2D:
# (child as CollisionObject2D).disabled = not active
# if child is CollisionObject3D:
# (child as CollisionObject3D).disabled = not active
## 現在のプール状況を返すユーティリティ
func get_total_count() -> int:
return _all_instances.size()
func get_available_count() -> int:
return _available.size()
func get_in_use_count() -> int:
return _all_instances.size() - _available.size()
使い方の手順
ここでは 2D シューティングを例に、プレイヤーの弾をプールするケースで説明します。
手順①:弾シーンを用意する
まずは、プレイヤー弾のシーンを作っておきます。
Bullet (Area2D) ├── Sprite2D └── CollisionShape2D
弾のスクリプト例(Bullet.gd):
extends Area2D
@export var speed: float = 600.0
var _velocity: Vector2 = Vector2.ZERO
# この弾がどのプールから借りられたかを覚えておく
var pool: ObjectPool
func shoot(from_position: Vector2, direction: Vector2) -> void:
global_position = from_position
_velocity = direction.normalized() * speed
func _process(delta: float) -> void:
position += _velocity * delta
# 画面外に出たらプールに返却する例
if not get_viewport_rect().has_point(global_position):
_return_to_pool()
func _return_to_pool() -> void:
if pool:
pool.return_instance(self)
else:
# 最悪プールが無い場合は queue_free() で自己完結
queue_free()
func _on_body_entered(body: Node) -> void:
# 何かに当たったらプールに戻る例
_return_to_pool()
ポイントは、弾自身が pool: ObjectPool を持ち、_return_to_pool() でプールに返却できるようにしている点です。
手順②:シーンに ObjectPool コンポーネントを配置する
プレイヤーシーンの構成例:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── ObjectPool (Node)
ObjectPool ノードに上の ObjectPool.gd をアタッチし、インスペクターで以下を設定します。
- pooled_scene : 先ほど作った
Bullet.tscn - initial_size : 例えば 50(同時に最大 50 発くらい出す想定)
- auto_expand : true(足りなくなったら自動で増やす)
- expand_step : 10(足りないときに 10 個ずつ追加)
- hide_on_create : true(借りるまで非表示&無効)
手順③:プレイヤーから弾を借りて撃つ
プレイヤーのスクリプト例(Player.gd):
extends CharacterBody2D
@export var move_speed: float = 300.0
@export var fire_interval: float = 0.1
var _fire_cooldown: float = 0.0
var _bullet_pool: ObjectPool
func _ready() -> void:
# 同じシーン階層内の ObjectPool を取得
_bullet_pool = $ObjectPool
func _process(delta: float) -> void:
_handle_move(delta)
_handle_shoot(delta)
func _handle_move(delta: float) -> void:
var input_vector := Vector2.ZERO
input_vector.x = Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left")
input_vector.y = Input.get_action_strength("ui_down") - Input.get_action_strength("ui_up")
input_vector = input_vector.normalized()
velocity = input_vector * move_speed
move_and_slide()
func _handle_shoot(delta: float) -> void:
_fire_cooldown -= delta
if Input.is_action_pressed("shoot") and _fire_cooldown <= 0.0:
_fire_cooldown = fire_interval
_shoot_bullet()
func _shoot_bullet() -> void:
if not _bullet_pool:
return
var bullet_instance := _bullet_pool.get_instance()
if bullet_instance == null:
# プールがいっぱいで借りられない場合は、今回は撃たない
return
# Bullet.gd をアタッチしている前提
# 弾に自分のプールを教えておく
bullet_instance.pool = _bullet_pool
bullet_instance.shoot(global_position, Vector2.UP)
これで、プレイヤーが撃つたびに get_instance() で弾を借り、弾側のスクリプトで画面外や衝突時に return_instance() でプールに戻す、という流れになります。
手順④:敵用・エフェクト用など、複数プールを使い分ける
同じシーン内に複数の ObjectPool を置いて、それぞれ別のシーンをプールすることもできます。
Main (Node2D) ├── Player (CharacterBody2D) │ └── BulletPool (ObjectPool) ※ Bullet.tscn をプール ├── EnemySpawner (Node2D) │ └── EnemyPool (ObjectPool) ※ Enemy.tscn をプール └── EffectPool (ObjectPool) ※ ExplosionEffect.tscn をプール
こうしておくと、
- プレイヤーは
BulletPoolから弾を借りる - 敵スポナーは
EnemyPoolから敵を借りる - 死亡時エフェクトは
EffectPoolから借りる
という具合に、役割ごとにきれいに分離できます。
どのノードも「自分専用のプール」を持つだけでよく、継承で「弾付きプレイヤー」「弾付き敵」みたいなクラスを増やす必要はありません。
メリットと応用
この ObjectPool コンポーネントを使うことで、次のようなメリットがあります。
- メモリアロケーションの削減
弾やエフェクトを毎回instantiate()/queue_free()しないので、GC 負荷が下がり、フレーム落ちしにくくなります。 - シーン構造がシンプル
「弾を撃てるキャラ」の共通機能を継承で作る必要がなく、
各キャラは「自分のObjectPoolを 1 個持つだけ」で済みます。 - 使い回しが容易
弾だけでなく、敵、落石、ダメージポップアップ、ヒットエフェクトなど、
「一時的に出て消えるもの」なら何でもプール化できます。 - レベルデザインの自由度アップ
ステージごとに「弾の最大数」「敵の同時出現数」をinitial_sizeで調整するだけで、
パフォーマンスと難易度のバランスを簡単にチューニングできます。
「深いノード階層+継承ツリー」で機能を盛るのではなく、
「シンプルなノード+必要なコンポーネント(ObjectPool など)をアタッチ」という構成にすると、後からの改造が圧倒的に楽になりますね。
改造案:一定時間後に自動でプールに戻すヘルパー
「弾やエフェクトを n 秒後に自動でプールに戻したい」というケースはよくあります。
そんなときのために、ObjectPool に簡単なヘルパー関数を足してもよいでしょう。
## ObjectPool.gd に追記する例
## 指定オブジェクトを delay 秒後に自動でプールへ返却する
func return_later(obj: Node, delay: float) -> void:
if delay <= 0.0:
return_instance(obj)
return
var timer := get_tree().create_timer(delay)
timer.timeout.connect(func():
# まだシーン上に存在していて、このプールの管理対象なら返却
if is_instance_valid(obj) and _all_instances.has(obj):
return_instance(obj)
)
これを使えば、例えばヒットエフェクトのスクリプト側では
func play_and_return(pool: ObjectPool, position: Vector2) -> void:
global_position = position
visible = true
# 0.5 秒後に自動でプールへ
pool.return_later(self, 0.5)
のように書けて、かなりスッキリします。
このように、コンポーネント側(ObjectPool)に「共通で便利な機能」を少しずつ足していくと、プロジェクト全体の再利用性がどんどん上がっていきますね。
