【Godot】Object Pooling完全ガイド - Godotのカクつきを消す再利用パターン

作成: 2025-12-10最終更新: 2026-07-09

Godotのインスタンス化スパイクを解消するObject Poolingを解説。生成・破棄せず再利用する仕組み、_ready()が呼ばれない罠、処理と衝突の完全停止、弾プールの実装、導入判断まで図とコードで学びます。

弾幕シューティングで弾を撃ちまくった瞬間、敵がドロップしたコインが一気に湧いた瞬間——「その一瞬だけ」ガクッとフレームが落ちる。この 突発的なカクつき(スパイク) に悩まされたことはないでしょうか。

原因の多くは、オブジェクトの 生成(instantiate())と破棄(queue_free())が短時間に集中する ことです。1個の生成コストは小さくても、1秒に数百回繰り返せばCPUに無視できない負荷がかかります。これを根本から解決するのが Object Pooling(オブジェクトプーリング) です。この記事では、その仕組みから、つまずきやすい罠、弾プールの実装、そして「いつ導入すべきか」の判断までを解説します。

Object Poolingのイメージ。使い終わった弾やエフェクトを破棄せず、プール(待機所)に戻して再利用する循環

この記事でわかること

  • スパイクの原因と、 再利用でそれを消す Object Poolingの仕組み
  • 再利用時に _ready() が呼ばれない 罠と、spawn() / reset() での初期化
  • hide() だけでは足りない—— 処理と衝突を完全に止める 方法
  • 弾プールの実装と、 導入すべきかの判断 (代替手段との比較)

Sponsored

Object Poolingの仕組み:使い捨てず再利用する

Object Poolingの考え方はシンプルで、 「オブジェクトを使い捨てにせず、再利用する」 だけです。レストランが皿を毎回作らず、洗って使い回すのと同じ。最もコストのかかる「生成」と「破棄」を、ゲームプレイの最中から追い出します。

Object Poolingの4ステップ循環図。1.事前生成でプールに待機、2.取り出して表示・有効化、3.ゲーム内で使用(弾が飛ぶ)、4.役割を終えたら破棄せず非表示にしてプールへ返却、という循環を矢印で示す

動作は4ステップの循環です。

  1. 事前生成 :ゲーム開始時など負荷が許される場面で、一定数を生成して「プール(待機リスト)」に格納し、非表示にしておく
  2. 取り出し :弾を撃つなどオブジェクトが必要になったら、instantiate() せず プールから未使用の1つを取り出して 位置を初期化し、表示する
  3. 使用 :取り出したオブジェクトがゲーム内で役割を果たす(弾が飛んで敵に当たる)
  4. 返却 :役割を終えたら queue_free()壊さず 、非表示にしてプールに戻す

このサイクルにより、プレイ中の負荷は「位置や状態をリセットするだけ」の軽い処理に置き換わります。結果、生成・破棄によるスパイクが消え、フレームレートが安定します。

生成破棄とプーリングのフレームタイム比較図。左のinstantiate/queue_freeは、弾を撃つたびにフレームタイムが跳ね上がるスパイクが多発。右のObject Poolingは、フレームタイムが低く平坦に安定している

つまずきやすい罠とベストプラクティス

Object Poolingは一見簡単ですが、いくつか落とし穴があります。特に 「再利用ならではの初期化」 が要注意です。

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

特に 「見えないだけで動き続ける」 のは要注意です。hide() した弾が裏で移動と衝突判定を続けていると、負荷が減らないどころか 見えない当たり判定 でバグを生みます。処理と衝突をきちんと止めるのが肝心です。

Sponsored

実践:撃ちまくっても重くならない弾プールを組む

弾幕シューティングの弾、ヒット時の火花エフェクト、敵が落とすコイン——「高頻度で湧いては消える」ものはすべて、この弾プールの応用です。プール全体を管理する 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() で物理ステップの完了後に予約します。
Sponsored

導入すべきか:計測と代替手段

Object Poolingは強力ですが、 万能薬ではありません 。Godot 4はノード生成がかなり速くなったので、数フレームに1回程度の生成なら、プールを組む手間の方が大きいこともあります。

Object Poolingの導入判断と代替手段の図。まずProfilerでインスタンス化がボトルネックか確認。短命・高頻度ならObject Pooling、数千の見た目重視ならMultiMesh/RenderingServer、重い計算ならThreadと、性質に応じて手を選ぶ

導入は必ず プロファイラで計測 し、 本当にインスタンス化がボトルネックだと確認してから 行います。そのうえで、問題の性質に応じて手を選びます。

  • 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はあなたの定番ツールになります。

さらに学ぶために