【Godot】Godotのプロファイラー入門:重いフレームから、原因の関数名を1行取り出す

作成: 2026-09-23最終更新: 2026-10-10

ゲームが重くなったとき、MonitorsとProfilerで原因の関数名を取り出し、直したあとに測り直す方法を解説します。

ゲームが重くなったとき、どこを直せばよいかは、FPS の数字だけでは分かりません。Godot のデバッガには、1フレームの時間をどの関数が使ったかを、関数の名前で出す道具が最初から入っています。

推測で直すと、外れることが多いです。敵の数を減らしたり print で時間を出したりしているうちに、コードのあちこちが少しずつ崩れていきます。

この記事では、Monitors と Profiler で重い関数の名前を取り出し、直して確かめるまでを解説します。

人形が、グラフの山と関数の一覧を並べたパネルの前に立ち、濃紺に塗られた1行を指さしている

この記事でわかること

  • FPS だけでは、何が重いのか分からない理由
  • Monitors・Profiler・Visual Profiler を開く順番
  • Profiler の一覧で、呼んだ先まで含めた時間・その関数だけの時間・呼ばれた回数を読み分けること
  • 実践:300体の群れから、重い関数を1行取り出して直す

動作確認環境: Godot 4.7

Sponsored

1フレームに使える時間

60FPS を目指すなら、次の画面を約16.7ミリ秒(1000 ÷ 60)ごとに用意する必要があります。その間に、物理の計算(_physics_process() と当たり判定)、ゲームの処理(_process())、描画の準備が進みます。

約16.7ミリ秒の線に物理・ゲームの処理・描画の準備が収まれば間に合い、ゲームの処理が膨らんではみ出すとFPSが下がる

どれかに時間がかかりすぎると、次の画面が間に合わず、FPS が下がります。FPS は、約1秒ごとに何枚描けたかを数えた結果です。下がったことは分かっても、どこが膨らんだのか、その中のどの関数なのかまでは教えてくれません。そこを見るのが、デバッガにある計測用のタブです。

3つのタブの使い分け

エディタ下の Debugger パネルのうち、この記事では3つのタブを使い分けます。

  • Monitors: FPS・処理時間・ノードの数などを、約1秒ごとに記録する。ゲームを実行していれば、何も押さなくても記録される
  • Profiler: 1フレームごとに、どの関数に何ミリ秒かかったかを記録する。Start を押してから記録が始まる
  • Visual Profiler: 描画の工程ごとに、CPU と GPU(描画を受け持つ部品)の時間を記録する。これも Start を押してから記録が始まり、自分で書いた関数の名前は出ない
Monitorsから3つに分かれる。Processが上がればProfiler、関数が小さければVisual Profiler、Nodesが増え続けるなら消し忘れ

開く順番は、粗いものから細かいものへ、です。まず Monitors で、FPS が下がったときに一緒に動く数字を探します。Process が上がっていれば、Profiler で関数名を探します。

Process は高いのに自分の関数がどれも小さいなら、描画を疑って Visual Profiler を開きます。

Monitors:約1秒ごとの数字

Monitors タブの左の一覧で項目にチェックを入れると、右にその項目のグラフが出ます。最初に入れておくのは次の4つです。

項目何の数字か
Time の FPS約1秒のあいだに描けたフレームの数
Time の Process約1秒の区間でいちばん長かった、物理以外の処理時間。描画の準備も含む
Time の Physics Process約1秒の区間でいちばん長かった、物理1回ぶんの処理時間
Object の Nodesいまツリーにあるノードの数

FPS と Process は、数え方が違います。0.1秒止まる引っかかりがあっても、その1秒に描けた枚数が数枚減るだけなので、FPS では目立ちません。Process は区間の最大なので、止まった区間だけ跳ね上がります。物理の中で止まっているなら、Physics Process のほうに山が出ます。ときどきカクつくときは、FPS ではなく、この2つの山を探します。

FPSの線はほぼ平らで浅くくぼむだけだが、Processの線は0.1秒止まった区間だけ高い山になる

Nodes は、重さの種類を見分けるのに使います。敵や弾を出すたびに増えて、消えても戻らないなら、重い関数ではなく消し忘れたノードを疑います。画面の外へ飛んでいった弾を queue_free() していないと、ツリーに残ったまま数が増えていきます。

Nodesのグラフ。出して消す作りは上下しても元の高さに戻り、消し忘れがあると右へ行くほど増え続ける
Sponsored

Profiler:関数ごとの時間

Profiler は、記録してから選ぶ、の順で使います。

  1. エディタからゲームを実行し、Profiler タブの Start を押す
  2. 重い場面を数秒再現する
  3. Stop を押す
  4. 右のグラフで、重いフレームを1本クリックする

記録していなかった時間は、あとから見られません。カクついてからタブを開いても、その山は残っていません。記録は古いものから上書きされていくので、重い場面を再現したら、すぐに Stop を押します。Stop を押した直後に出ている Script Functions の数字も、1フレームぶんではなく記録全体の合計です。グラフの途中の1本をクリックしてから読みます。

左の一覧の Script Functions に、そのフレームで動いた自分の関数が並びます。列は Time(かかった時間)と Calls(そのフレームで呼ばれた回数)です。

最初につまずくのは、並び順です。一覧は Inclusive の大きい順に並びます。Inclusive は、その関数の中から呼んだ関数の時間まで含めた合計です。_process() から重い関数を呼んでいると、呼び出し元の _process() がいちばん上に来ます。

上部の Time: を Self に切り替えると、その関数自身が使った時間だけになります。並び順はそのままで、数字だけが変わります。順番ではなく Time の数字を見比べて、いちばん大きい行を探します。それが、実際に時間を使っている関数です。

同じ2行の一覧。Inclusiveでは_processが30.0ms、Selfでは1.5msに減り、_push_from_crowdの28.5msが残る

他のエンジンでいうと: デバッガの Profiler タブは、Unity の Profiler、Unreal Engine の Unreal Insights や stat コマンドにあたります。

一覧の名前は、スクリプトに class_name(スクリプトに付けるクラスの名前)があると、クラス名つきで出ます。class_name Enemy なら Enemy._process です。付けていないと、どのスクリプトの _process も同じ名前で並び、ファイル名は行にマウスを載せたときにだけ出ます。

組み込みの関数は、一覧に行として出ません。get_nodes_in_group() や distance_to() の時間は、既定の設定では、呼んだ自分の関数の Self に入ります。一覧で名前が取れるのは自分で書いた関数までで、その中の何が重いかはコードを読んで確かめます。

Calls(呼ばれた回数)の読み方

1フレームぶんの Time は、1回あたりの平均時間に Calls を掛けた値です。上部の Measure: を既定の Frame Time (ms) から Average Time (ms) に切り替えると、1回あたりの時間が直接出ます。同じ5ミリ秒でも、Calls を見ると、次に確かめることが変わります。

Calls 300は敵の数と同じなので毎フレーム要る仕事かを疑い、Calls 1は1回の中身を見る

Calls が敵や弾の数と同じなら、1体ぶんの仕事を毎フレーム全員でやっています。まず、その仕事が毎フレーム要るのかを確かめます。間引ける仕事なら間引き、一度調べれば済む結果なら覚えておいて使い回します。毎回どうしても要るなら、1回を軽くする方向で調べます。Calls が1なのに Time だけ大きいなら、その1回の中身が重いです。

実践:300体の群れから、重い関数を1行取り出す

サバイバー系の敵の群れ、弾幕シューティングの弾、見下ろしARPGの雑魚——どれも、同じスクリプトを付けたノードが数百あります。1体ぶんの小さな無駄が、数百倍になる場面です。ここでは、敵300体がプレイヤーへ寄ってくる場面を作ります。重くなった原因の関数名を Profiler で取り出し、直したあとその行が沈むのを確かめます。

画面の中央で人形に敵が密集し、一覧ではEnemy._push_from_crowdの行がCalls 300で青く塗られている

① 置くもの

新しい2Dのプロジェクトで始めます。画面の大きさは既定の 1152×648 のままです。

  • main.tscn: ルートは Node2D(名前 Main、メインシーンに設定)。子に Player と Enemies を置く
  • Player: Sprite2D(icon.svg、位置 (576, 324)、Z Index 1)。Player を選んで Groups ドックの + を押し、player という名前のグループを作る。作ると Player にチェックが入る(敵が追う相手を探すのに使う)
  • Enemies: Node2D。敵の入れ物
  • enemy.tscn: ルートは Sprite2D(名前 Enemy、icon.svg、Scale (0.25, 0.25))。enemy.gd を付ける

Player の Z Index を1にしておくと、集まってきた敵に埋もれずに見えます。

比べるあいだは、V-Sync を切ります。V-Sync は、画面の書き換えのタイミングにフレームを合わせる設定です(FPS管理)。Project Settings の Display → Window → V-Sync → V-Sync Mode を Disabled にして、ゲームを実行し直します。V-Sync が入っていると FPS が画面の更新回数で頭打ちになり、画面の書き換えを待つ時間も Process に混ざるので、直したあとの伸びが見えません。直す前と後は同じ設定で測り、終わったら元に戻します。

② 組み立て

Main は、_ready() で敵を300体、画面のあちこちに出します。配置に使う乱数は種(seed)を固定しておき、直す前と後で同じ配置から測れるようにします。

# main.gd(Main に付ける)
extends Node2D

const ENEMY_SCENE := preload("res://enemy.tscn")

@export var enemy_count := 300

func _ready() -> void:
	var rng := RandomNumberGenerator.new()
	rng.seed = 12345   # 直す前と後で、同じ配置から測る
	for i in enemy_count:
		var enemy: Enemy = ENEMY_SCENE.instantiate()
		enemy.position = Vector2(rng.randf_range(0.0, 1152.0), rng.randf_range(0.0, 648.0))
		$Enemies.add_child(enemy)

敵は、プレイヤーへ向かいながら、近すぎる仲間から離れます。仲間を探す _push_from_crowd() を、毎フレーム全員が呼んでいます。

# enemy.gd(Enemy に付ける)
class_name Enemy
extends Sprite2D

const SPEED := 60.0
const PERSONAL_SPACE := 32.0   # これより近い仲間からは離れる

@onready var player: Node2D = get_tree().get_first_node_in_group("player")

func _ready() -> void:
	add_to_group("enemies")

func _process(delta: float) -> void:
	var to_player := global_position.direction_to(player.global_position)
	var away := _push_from_crowd()                  # 毎フレーム、全員を調べる
	global_position += (to_player + away).normalized() * SPEED * delta

# 近すぎる仲間から離れる向きを返す
func _push_from_crowd() -> Vector2:
	var away := Vector2.ZERO
	for other: Enemy in get_tree().get_nodes_in_group("enemies"):
		if other != self and global_position.distance_to(other.global_position) < PERSONAL_SPACE:
			away += other.global_position.direction_to(global_position)
	return away

300体がそれぞれ300体ずつ調べるので、ループは1フレームで9万回まわります。class_name Enemy を付けてあるので、一覧には Enemy._process の形で出ます。

③ 動かして確かめる

直す前はEnemy._push_from_crowdの時間の棒が長くCalls 300、直したあとは棒が短くなりCallsも減る

F5 で実行します。見るのは3つです。

まず Monitors で FPS・Process・Nodes にチェックを入れます。敵は中央へ集まり、プレイヤーの位置に重なる大きな群れになります。Process は高いまま張り付き、FPS は低いままです。はっきり重くならないときは、Main を選んでインスペクタの Enemy Count を600にします(ループの回数は4倍)。Nodes は敵を出し終えたあと横ばいで、増え続けてはいません。

次に、敵が中央に集まりきるまで5秒ほど待ってから、Profiler の Start を押し、3秒ほど待って Stop を押します。グラフの途中の1本をクリックすると、一覧の上から Enemy._process、Enemy._push_from_crowd の順に並びます。起動したばかりのフレームを避ければ、Calls はどちらも300です。Time: を Self に切り替えると、_process の数字は小さくなり、_push_from_crowd の数字は大きいまま残ります。時間を使っているのは _push_from_crowd で、Calls が敵の数と同じなので、毎フレーム全員が調べる必要があるのかを疑います。

仕上げに、_push_from_crowd() を0.2秒に1回だけ呼ぶ形に直します。_ready() と _push_from_crowd() は残し、const PERSONAL_SPACE の行の下に変数を2つ足して、_process() だけを次のものに置き換えます。

var crowd_push := Vector2.ZERO                 # 最後に調べた「離れる向き」
var push_timer := randf_range(0.0, 0.2)        # 全員が同じフレームで調べないよう、ずらす

func _process(delta: float) -> void:
	push_timer -= delta
	if push_timer <= 0.0:
		push_timer = 0.2
		crowd_push = _push_from_crowd()        # 0.2秒に1回だけ調べる
	var to_player := global_position.direction_to(player.global_position)
	global_position += (to_player + crowd_push).normalized() * SPEED * delta

同じ敵の数で、同じように5秒待ってから測り直します。Enemy._process の Calls は300のままです。Enemy._push_from_crowd の Calls は、1秒に1500回(300体 ÷ 0.2秒)をフレームで割った数になります。60FPS なら1フレームに平均25回ほどで、この関数の合計時間も同じくらいの割合で小さくなります。このフレームの重さの大半がこの関数だったなら、Monitors の FPS も上がり、Process も下がります。

最後に、間隔の 0.2 を2か所とも 1.0 にして測ります。Calls はさらに5分の1ほどに減ります。そのかわり、敵どうしが重なってからほどけるまでに、はっきり時間がかかるようになります。軽さと反応の速さを、同じ数字1つで引き換えにしているのが見えます。

ポイントは2つです。

  • 名前を1つに絞るまでが計測: Process が高いと分かっただけでは、どこを直すか決まりません。Enemy._push_from_crowd まで絞れて、はじめて手が動きます
  • 比べるのは同じ条件の数字: 敵の数・V-Sync・配置の乱数をそろえます。グラフの縦軸は表示中のデータに合わせて変わるので、線の高さではなく同じ行の数字で比べます。調べる回数を減らしたぶん、仲間から離れる反応は遅れるので、重なり方や動きが許せる範囲かも見ます

関数名から次に調べる場所

Profiler が教えてくれるのは、直し方ではなく、次に調べる場所です。関数名と Calls から候補を決め、次の表を目安に読み進めます。

一覧や Monitors での見え方疑うもの続き
自分の関数の Calls が敵の数と同じで、中で全員との距離を調べている毎フレームの総当たりArea2D入門(近くの相手だけを当たり判定で拾う)
自分の関数の Calls が敵の数と同じで、中で毎回ノードを探している毎フレームの get_node() やグループの取り直しノード参照入門
弾やエフェクトを出す関数が重く、Nodes が上下するノードの生成と破棄オブジェクトプール入門
場面が切り替わる1フレームだけ、Calls が1の関数が重い読み込み・生成・初期化動的ロード入門
Physics Process が高く、Profiler の Physics 2D の欄が大きい当たり判定の組み合わせの数Collision Layer と Mask 入門
Process は高いのに、自分の関数はどれも小さい描画Visual Profiler で工程ごとの時間を見る(macOS の Compatibility レンダラーでは使えない)
Sponsored

うまくいかないときは

症状原因直し方
グラフをクリックしたら、ゲームが止まった記録中にクリックすると、ゲームに一時停止の要求が出るDebugger の Stack Trace タブにある Continue ボタンで再開する。読むときは先に Stop を押す
Stop した直後、Calls が何万もある停止のときに足される最後の1本は、記録全体の合計グラフの途中の1本をクリックして、1フレームぶんを見る
エディタで測った数字が、リリース形式で書き出したゲームより大きいエディタからの実行には、デバッグ機能と計測の負荷が加わるエディタでは直す候補と前後の差を見る。最終的な動きは、Export With Debug(書き出すときに開発用の機能を含めるかどうかのチェック)を外して書き出したゲームで確かめる
Nodes は横ばいなのに、遊ぶほどメモリの使用量が増えていくremove_child() でツリーから外しただけのノードは解放されず、Nodes には数えられないまま Orphan Nodes(ツリーの外に残ったノード)に溜まるMonitors の Object → Orphan Nodes にもチェックを入れる。要らないノードは外すのではなく queue_free() する

おまけ:先に知っておくと良いこと

  • ゲーム画面に数字を出す: Performance.get_monitor(Performance.TIME_FPS) のように呼ぶと、Monitors と同じ数字を画面に出せます。TIME_PROCESS は秒なので、ミリ秒にするなら1000倍します。どちらも約1秒ごとにしか変わりません
  • 1回だけの重さを分ける: 最初の一度だけ重いなら、読み込み・初期化・描画の準備が候補です。同じ操作をもう一度して、毎回起きるかを確かめます
Sponsored

まとめ

  • FPS は結果: どこが膨らんだかは Monitors の Process と Physics Process で見る。どちらも区間の最大なので、一瞬の停止も拾う
  • 開く順は Monitors → Profiler: 自分の関数が小さいのに Process が高ければ Visual Profiler。Nodes が増え続けるなら消し忘れ
  • 一覧は Inclusive の順: Self に切り替えて、順番ではなく数字の大きい行を探す
  • Calls で重さの種類を分ける: 敵の数と同じなら、毎フレーム要る仕事かを疑う。1なら1回の中身を見る
  • 直したら同じ条件で測り直す: 敵の数・V-Sync・配置をそろえて、同じ行が沈むのを見る

まずは、いちばん数の多いスクリプトの _process() から、Calls を見てみてください。あなたのゲームでいちばん数の多いスクリプトは、どれでしょうか。

次に読む学習パスの次の記事
最適化

GodotのFPSを安定させるフレームレート管理と最適化

この記事を読む
Godot このセクションのノート104