Godotでちょっとリッチなゲームを作り始めると、ステージ切り替え時の「一瞬固まる」感じ、気になりますよね。
特に 3D や大量のタイルマップ、重いシーンを change_scene_to_file() 一発で読み込んでいると、どうしてもフレームが止まりがちです。

典型的な実装だと:

  • 各ステージ用のシーンに直接スクリプトを書いて、_ready() でリソースを読み込む
  • ステージ管理用の「巨大な」マネージャシーンを作り、そこで全部のロード・切り替えをやる
  • もしくは、毎回 ResourceLoader.load() を直書きして場当たり的に対応

こういうやり方だと、

  • ステージが増えるたびにマネージャのスクリプトが肥大化
  • 「どこで何を読み込んでいるか」が分散して追いづらい
  • 将来「ロード画面をつけたい」「プログレスバーを出したい」ときに大改造が必要

…という、ありがちな「継承と巨大マネージャ問題」にハマりがちです。

そこで今回は、「シーンを切り替えるノード」と「シーンを先読みしておくノード」をきっちり分離して、どこにでもポン付けできるコンポーネントとして使えるようにした 「ScenePreloader」コンポーネント を紹介します。
ステージや UI のシーンにこのコンポーネントをアタッチしておくだけで、「裏でこっそり次のシーンを読み込んでおく」ことができるようになります。

【Godot 4】ロード待ちを先回り!「ScenePreloader」コンポーネント

このコンポーネントは、ResourceLoader.load_threaded_request() / load_threaded_get() をラップして、

  • 指定したシーンをバックグラウンドで先読み
  • 進捗率(0.0〜1.0)を取得
  • 読み込み完了時にシグナルを発火
  • 必要になったタイミングで即座に PackedScene を受け取る

といった処理を、どのノードにも後付けできる「合成ベース」のコンポーネントとして提供します。

フルコード(GDScript / Godot 4)


extends Node
class_name ScenePreloader
"""
ScenePreloader (シーン先読み) コンポーネント

・重いステージシーンなどをバックグラウンドで先読みしておく
・進捗率を取得できる
・読み込み完了をシグナルで通知
・必要になったタイミングで PackedScene を即取得

どのノードにもアタッチして使える「合成」志向のコンポーネントです。
"""

## --- エクスポートパラメータ ---

@export_file("*.tscn", "*.scn") var scene_path: String = "":
	## 先読みしたいシーンのパス
	## 例: "res://scenes/stage_02.tscn"
	set(value):
		scene_path = value
		# すでに読み込み済みだったらリセット
		if is_inside_tree():
			_reset_state()

@export var auto_preload_on_ready: bool = true:
	## ノードが ready になったタイミングで自動的に先読みを開始するか
	## false にしておけば、手動で preload_scene() を呼ぶ運用もできます
	set(value):
		auto_preload_on_ready = value

@export_range(0.0, 10.0, 0.1) var poll_interval_sec: float = 0.1:
	## バックグラウンドロードの進捗をポーリングする間隔(秒)
	## 0.0 にするとポーリングは行わず、手動で poll_progress() を呼ぶ前提になります
	set(value):
		poll_interval_sec = max(value, 0.0)


## --- シグナル ---

signal preload_started(path: String)
	## 先読み開始時に発火

signal preload_progress(path: String, progress: float)
	## 進捗更新時に発火(0.0〜1.0)

signal preload_completed(path: String, packed_scene: PackedScene)
	## 読み込み完了時に発火

signal preload_failed(path: String, error_code: int)
	## 読み込み失敗時に発火


## --- 内部状態 ---

var _is_preloading: bool = false
var _is_loaded: bool = false
var _loaded_scene: PackedScene = null
var _current_progress: float = 0.0
var _thread_request_id: int = 0
var _poll_timer: Timer = null


func _ready() -> void:
	# ポーリング用タイマーを内部で生成(シーンツリーにはぶら下げる)
	_poll_timer = Timer.new()
	_poll_timer.one_shot = false
	_poll_timer.autostart = false
	add_child(_poll_timer)
	_poll_timer.timeout.connect(_on_poll_timer_timeout)

	if auto_preload_on_ready and scene_path != "":
		preload_scene()


func _exit_tree() -> void:
	# シーンから抜ける際に、スレッドロードをキャンセル(Godot 4.3 以降)
	if _is_preloading:
		ResourceLoader.load_threaded_cancel(scene_path)


## --- 公開 API ---

func preload_scene() -> void:
	"""
	指定された scene_path のシーンをバックグラウンドで先読み開始する。

	・scene_path が空の場合は何もしない
	・すでに読み込み済みの場合は何もしない
	・進捗は get_progress() または preload_progress シグナルで取得可能
	"""
	if scene_path == "":
		push_warning("ScenePreloader: scene_path が設定されていません。")
		return

	if _is_loaded:
		# すでに読み込み済みなら何もしない
		return

	if _is_preloading:
		# すでに先読み中なら何もしない
		return

	_reset_state()

	var err := ResourceLoader.load_threaded_request(scene_path)
	if err != OK:
		push_error("ScenePreloader: load_threaded_request に失敗しました: %s (code=%d)" % [scene_path, err])
		emit_signal("preload_failed", scene_path, err)
		return

	_is_preloading = true
	emit_signal("preload_started", scene_path)

	# ポーリング開始
	if poll_interval_sec > 0.0:
		_poll_timer.wait_time = poll_interval_sec
		_poll_timer.start()


func is_loaded() -> bool:
	"""
	先読みが完了しているかどうか。
	"""
	return _is_loaded and _loaded_scene != null


func is_preloading() -> bool:
	"""
	現在バックグラウンドで読み込み中かどうか。
	"""
	return _is_preloading


func get_progress() -> float:
	"""
	現在の進捗率(0.0〜1.0)を返す。
	"""
	return _current_progress


func take_scene() -> PackedScene:
	"""
	読み込み済みの PackedScene を取得する。

	・取得後も内部には保持されたまま(何度でも呼び出せる)
	・未読み込みの場合は null を返す
	"""
	if not is_loaded():
		return null
	return _loaded_scene


func instantiate_scene() -> Node:
	"""
	読み込み済みシーンをインスタンス化して返すヘルパー。

	・未読み込みなら null を返す
	・通常は以下のように利用:
		- var inst = scene_preloader.instantiate_scene()
		- add_child(inst)
	"""
	if not is_loaded():
		return null
	if _loaded_scene == null:
		return null
	return _loaded_scene.instantiate()


func poll_progress() -> void:
	"""
	手動で進捗をポーリングしたい場合に呼び出す。

	・poll_interval_sec = 0.0 のときは自動ポーリングしないので、
	  任意のタイミングでこの関数を呼んでください。
	"""
	if not _is_preloading:
		return
	_update_progress()


## --- 内部処理 ---

func _reset_state() -> void:
	if _is_preloading:
		# 念のためキャンセル
		ResourceLoader.load_threaded_cancel(scene_path)

	_is_preloading = false
	_is_loaded = false
	_loaded_scene = null
	_current_progress = 0.0
	_thread_request_id = 0

	if _poll_timer:
		_poll_timer.stop()


func _on_poll_timer_timeout() -> void:
	_update_progress()


func _update_progress() -> void:
	if not _is_preloading:
		return

	var progress := PackedFloat32Array()
	var status := ResourceLoader.load_threaded_get_status(scene_path, progress)

	match status:
		ResourceLoader.ThreadLoadStatus.THREAD_LOAD_IN_PROGRESS:
			if progress.size() > 0:
				_current_progress = clamp(progress[0], 0.0, 1.0)
				emit_signal("preload_progress", scene_path, _current_progress)

		ResourceLoader.ThreadLoadStatus.THREAD_LOAD_FAILED:
			_is_preloading = false
			_poll_timer.stop()
			emit_signal("preload_failed", scene_path, ERR_CANT_OPEN)
		ResourceLoader.ThreadLoadStatus.THREAD_LOAD_LOADED:
			# 読み込み完了
			_is_preloading = false
			_poll_timer.stop()
			_current_progress = 1.0

			var res := ResourceLoader.load_threaded_get(scene_path)
			if res == null or not (res is PackedScene):
				push_error("ScenePreloader: 読み込んだリソースが PackedScene ではありません: %s" % scene_path)
				emit_signal("preload_failed", scene_path, ERR_FILE_UNRECOGNIZED)
				return

			_loaded_scene = res
			_is_loaded = true
			emit_signal("preload_progress", scene_path, 1.0)
			emit_signal("preload_completed", scene_path, _loaded_scene)

		_:
			# 予期しないステータス
			push_warning("ScenePreloader: 不明なステータス: %s" % str(status))

使い方の手順

ここからは、実際にプロジェクトに組み込む手順を見ていきましょう。

手順①: スクリプトを保存して、コンポーネントとして使えるようにする

  1. 上記のコードを res://components/scene_preloader.gd などに保存します。
  2. class_name ScenePreloader を定義しているので、エディタから「ノードを追加」で ScenePreloader が検索できるようになります。

手順②: 例1 – ステージ遷移用の「ハブシーン」にアタッチする

例えば、ステージ 1 からステージ 2 に移動するときに「次のステージを裏で先読み」したいケースを考えます。

シーン構成例:

StageHub (Node2D)
 ├── UI (CanvasLayer)
 │    └── Label
 └── ScenePreloader (Node)  <-- ★ これを追加
  • StageHub.tscn のルートに Node2D を置き、UI やボタンを配置。
  • 同じルートに ScenePreloader ノードを追加。
  • インスペクタで scene_path に「次のステージ」のシーンパス(例: res://scenes/stage_02.tscn)を設定。

StageHub.gd に、ロード完了を待ってからシーン切り替えする処理を書いてみます。


extends Node2D

@onready var preloader: ScenePreloader = $ScenePreloader
@onready var label: Label = $UI/Label

var _ready_to_go: bool = false

func _ready() -> void:
	# シグナル接続
	preloader.preload_progress.connect(_on_preload_progress)
	preloader.preload_completed.connect(_on_preload_completed)
	preloader.preload_failed.connect(_on_preload_failed)

	# 自動先読みがオフならここで明示的に開始
	if not preloader.auto_preload_on_ready:
		preloader.preload_scene()

	label.text = "次のステージを読み込み中..."

func _on_preload_progress(path: String, progress: float) -> void:
	label.text = "読み込み中: %d %%" % int(progress * 100.0)

func _on_preload_completed(path: String, packed_scene: PackedScene) -> void:
	label.text = "読み込み完了!ボタンで移動できます。"
	_ready_to_go = true

func _on_preload_failed(path: String, error_code: int) -> void:
	label.text = "読み込み失敗… :("
	push_error("Failed to preload scene: %s (code=%d)" % [path, error_code])

func _on_GoNextButton_pressed() -> void:
	# ボタンは UI 配下に置いている想定
	if not _ready_to_go:
		label.text = "まだロード中です…"
		return

	# すでに PackedScene は読み込み済みなので、即座に切り替え可能
	var packed := preloader.take_scene()
	if packed == null:
		push_error("ScenePreloader: take_scene() が null を返しました。")
		return

	get_tree().change_scene_to_packed(packed)

この構成にしておくと、ステージ 1 をプレイしている間に、次のステージ(ステージ 2)を裏で読み込んでおけるので、
切り替え時のカクつきをかなり抑えられます。

手順③: 例2 – タイトル画面で「ゲーム本編」を先読みする

タイトル画面の時点でゲーム本編シーンを先読みしておくと、
「スタートボタンを押してからのロード待ち」がほぼゼロになります。

シーン構成例:

TitleScreen (Control)
 ├── VBoxContainer
 │    ├── Label
 │    └── StartButton (Button)
 └── ScenePreloader (Node)  <-- ★

TitleScreen.gd の例:


extends Control

@onready var preloader: ScenePreloader = $ScenePreloader
@onready var start_button: Button = $VBoxContainer/StartButton
@onready var label: Label = $VBoxContainer/Label

func _ready() -> void:
	preloader.preload_completed.connect(_on_preload_completed)
	preloader.preload_failed.connect(_on_preload_failed)

	# タイトル表示中に先読み開始(auto_preload_on_ready = true でもOK)
	preloader.preload_scene()
	start_button.disabled = true
	label.text = "ゲームデータを読み込み中..."

func _on_preload_completed(path: String, packed_scene: PackedScene) -> void:
	label.text = "準備完了!スタートボタンで開始できます。"
	start_button.disabled = false

func _on_preload_failed(path: String, error_code: int) -> void:
	label.text = "ロードに失敗しました…"
	start_button.disabled = true

func _on_StartButton_pressed() -> void:
	var packed := preloader.take_scene()
	if packed == null:
		push_error("ScenePreloader: 本編シーンが読み込まれていません。")
		return
	get_tree().change_scene_to_packed(packed)

手順④: 例3 – ロード専用シーンでプログレスバーを表示する

最後に、いわゆる「ロード画面」シーンでプログレスバーを出すパターンです。
ここでも、巨大な「ロードマネージャ」を作るのではなく、ScenePreloader を 1 個アタッチするだけにしておきます。

シーン構成例:

LoadingScreen (Control)
 ├── ProgressBar
 ├── Label
 └── ScenePreloader (Node)

LoadingScreen.gd の例:


extends Control

@onready var preloader: ScenePreloader = $ScenePreloader
@onready var progress_bar: ProgressBar = $ProgressBar
@onready var label: Label = $Label

func _ready() -> void:
	progress_bar.value = 0
	label.text = "読み込み中..."

	preloader.preload_progress.connect(_on_preload_progress)
	preloader.preload_completed.connect(_on_preload_completed)
	preloader.preload_failed.connect(_on_preload_failed)

	preloader.preload_scene()

func _on_preload_progress(path: String, progress: float) -> void:
	progress_bar.value = progress * 100.0
	label.text = "読み込み中... %d%%" % int(progress * 100.0)

func _on_preload_completed(path: String, packed_scene: PackedScene) -> void:
	label.text = "完了!シーンを切り替えます..."
	await get_tree().create_timer(0.2).timeout
	get_tree().change_scene_to_packed(packed_scene)

func _on_preload_failed(path: String, error_code: int) -> void:
	label.text = "読み込みに失敗しました (code=%d)" % error_code

メリットと応用

この ScenePreloader をコンポーネントとして導入するメリットはかなり多いです。

  • シーン構造がスッキリ
    「ロード専用の巨大マネージャシーン」を作らずに済みます。
    タイトル画面、ステージハブ、ロード画面など、「先読みしたい場所」にだけ ScenePreloader をポン付けできます。
  • 責務が明確
    「シーンをいつ・どこで先読みするか」という責務を、各シーンごとに局所化できます。
    変更したいときも、そのシーンだけ見れば OK です。
  • 再利用性が高い
    どのゲームでも「重いシーンを裏で読みたい」ニーズはほぼ共通なので、
    res://components/scene_preloader.gd を別プロジェクトに持っていくだけで、すぐ再利用できます。
  • 継承ツリーを増やさない
    「ロード機能付きタイトル画面」とか「ロード機能付きステージハブ」みたいな、
    意味不明な継承階層を作らずに済みます。
    ただの ControlNode2D に、ScenePreloader をアタッチするだけで OK です。

さらに、応用としては:

  • ステージごとに「次に来る可能性が高いステージ」を複数パターン先読みする
  • UI の重いアニメーションシーンを先読みしておく
  • オンラインゲームで「次のマップを予測して先読み」する

など、ゲームの体感クオリティを上げるアイデアにそのまま使えます。

改造案:複数シーンをキューに入れて順番に先読みする

「次のステージ候補が複数あるから、順番に先読みしておきたい」というケース向けに、
簡易的な「プリロードキュー」を追加する改造案です。

ScenePreloader に、例えばこんなメソッドを追加すると:


var _queue: Array[String] = []

func enqueue_scene(path: String) -> void:
	"""
	複数シーンを順番に先読みしたい場合の簡易キュー。
	現在何も読み込んでいなければ即座に先読み開始し、
	読み込み中であればキューに積んでおく。
	"""
	_queue.append(path)
	# 何も読み込み中でなければ、すぐに次を開始
	if not _is_preloading and not is_loaded():
		_start_next_from_queue()

func _start_next_from_queue() -> void:
	if _queue.is_empty():
		return
	scene_path = _queue.pop_front()
	preload_scene()

func _on_preload_completed(path: String, packed_scene: PackedScene) -> void:
	# もともとの処理を維持しつつ、次のキューを開始
	emit_signal("preload_completed", path, packed_scene)
	_start_next_from_queue()

こんな感じで、「複数のシーンを順番に先読みするプリローダー」としても発展させられます。
もちろん、これも巨大なマネージャではなく、あくまで 1 つのコンポーネントとして各所に配備できるのがポイントですね。

継承より合成で、ロード周りもサクッとコンポーネント化していきましょう。