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)に「共通で便利な機能」を少しずつ足していくと、プロジェクト全体の再利用性がどんどん上がっていきますね。