はじめに
どうも、ゲームプログラマーのChocolaMintです。
Godotという、最近「なぜか」流行っているオープンソースのゲームエンジン、ご存知でしょうか。
ここの「オープンソース」について、皆さんはどう認識しているのでしょうか。
- 無料で使える!(例外もありますが、Godotの場合は本当に無料)
- MITライセンスなので、例えメインブランチが突然有料化宣言を出しても、フォークして無料バージョンを作れる!
- ソースコードが見られる!
…で?
そうですよね。ソースコードが見られるのって、別にいいじゃない?どうせエンジン改造なんて、企業レベルの開発チームじゃないとできないことでしょう。しかもC++ってめんどくさそうですし…
別のオープンソースのゲームエンジンの場合は、たしかにそうかもしれませんね。が、Godotは実は読みやすい、改造しやすい方なんです!
今回の新シリーズ、「Godotのソースコードを理解せよ!」では、毎回Godotの一部機能を抜粋して、関連のソースコードの実装を紹介します。Godotのソースコードの理解を深めることで、Godotをもっと自由自在に使えるはずです。少しでもみんなのお役に立てれば幸いです。
では、本題に入りましょうか。
Godotのメインループ
ほとんどのゲームに不可欠な無限ループ。大体のゲームエンジンのメインループはこんな感じです:
- 1回以上の物理処理の更新(FixedUpdate、PhysicsTick、_physics_processなど)
- ゲーム内のオブジェクトを更新(Update、Tick、_processなど)
- 描画処理
もちろん、Godotにもメインループがあります。今回は以下の二つの質問を中心に解説します。
- メインループの中にどんな処理が発生するのか
- メインループはどこから実行されているのか
解説の便宜上、今回はマスターブランチ(4.3)のバージョンを解説します。別のバージョンを使っている方はバージョンの違いに気をつけてください。もっとも、メインループの仕組みはそんなに変わらないはずですが。
メインループ(Main::iteration()
)にある処理
GodotのメインループはMain::iteration()
というstatic関数で実装されています。
ざっくりまとめると、以下のような処理が発生します:(一部省略)
bool Main::iteration() {
// 1. 時間関連の変数の初期化、そしてタイマーの同期
// 2. XR(いわゆるVR/AR)処理
// 3. NavigationServer2D/3D(探索AI機能)の同期
// 4. 物理処理
// 5. MainLoop(≒シーンツリー)のprocess関数を呼び出して、シーン内のノードを更新
// 6. 描画処理
// 7. サウンド処理
// 終了したい場合はfalseをreturn
}
今回は「5. MainLoop処理」だけを紹介します。ゲームの開発者として、ノードはどうやって更新されているのか、やはり気になりますよね。
MainLoop処理では、今使っているMainLoop
クラスを取得して、MainLoop::process()
を実行します。ここのprocess_step * time_scale
で、タイムスケールを適応して、ゲーム内の時間の速さを制御しています。
if (OS::get_singleton()->get_main_loop()->process(process_step * time_scale)) {
exit = true;
}
OSクラスとは?
オペレーティングシステムの機能を管理するシングルトンクラスです。対応プラットフォームによって、異なる実装があります。例えば、WindowsのOSクラスはOS_Windows
で、WebのOSクラスはOS_Web
です。
Godotのコードベースにはたくさんのシングルトンがあって、get_singleton()
で取得できます。
ここのMainLoop
はGDScriptで実装できますが、デフォルト設定はSceneTree
を使います。SceneTree
というのは、ゲーム内の全ての「ノード」のヒエラルキー(ツリー構造)を管理するクラスです。GDScriptのNode.get_tree()
で取得するのは普通ですね。
scene/main/scene_tree.cpp
のSceneTree::process()
では、シーン内のノードを更新する処理が書かれてあります。
-
MultiplayerAPI
(マルチプレイ)のポーリングを行う -
"process_frame"
シグナルを発信する - シーン内のノードを「
process_priority
が低い方から」、そして「上から下まで」Node::notification(Node::NOTIFICATION_PROCESS)
を呼び出して(SceneTree::_process(false)
)、間接的にGDScriptのNode._process()
を呼び出すことになる - Timerノードを更新する
- Tweenノードを更新する
実はSceneTreeにはSceneTree::physics_process
という関数もあります。こっちは物理処理のループで呼び出されて、SceneTree::_process(false)
じゃなくてSceneTree::_process(true)
を呼び出します。
ノード更新の細かい順番について
まず、SceneTree::_process
で、全てのノードはprocess_thread_group
でいくつかのグループに分けられています。ほとんどのノードはメインスレッドで更新されますが、マルチスレッドの場合は別のグループになります。
デフォルト設定のPROCESS_THREAD_GROUP_INHERIT
だと親ノードのグループを継承しますが、PROCESS_THREAD_GROUP_MAIN_THREAD
だと別のグループとしてメインスレッドで更新されて、そしてPROCESS_THREAD_GROUP_SUB_THREAD
だとWorkerThreadPool
のスレッドで更新されます。process_thread_group_order
を指定することで、グループの間の順番を指定することもできます。実際、以下のコードでソートしています。
// SceneTree::_process(bool b_physics)の抜粋。ProcessGroupSortファンクターでソートする。
process_groups.sort_custom<ProcessGroupSort>();
bool SceneTree::ProcessGroupSort::operator()(const ProcessGroup *p_left, const ProcessGroup *p_right) const {
int left_order = p_left->owner ? p_left->owner->data.process_thread_group_order : 0;
int right_order = p_right->owner ? p_right->owner->data.process_thread_group_order : 0;
// process_thread_group_orderが同じの場合、サブスレッドのグループが先
if (left_order == right_order) {
int left_threaded = p_left->owner != nullptr && p_left->owner->data.process_thread_group == Node::PROCESS_THREAD_GROUP_SUB_THREAD ? 0 : 1;
int right_threaded = p_right->owner != nullptr && p_right->owner->data.process_thread_group == Node::PROCESS_THREAD_GROUP_SUB_THREAD ? 0 : 1;
return left_threaded < right_threaded;
} else {
return left_order < right_order;
}
}
ノード側は「ツリーに入る時」(NOTIFICATION_ENTER_TREE
)、Node::_add_to_process_thread_group
を通じてSceneTreeのグループに登録して、そして「ツリーから出る時」(NOTIFICATION_EXIT_TREE
)、Node::_remove_from_process_thread_group
で登録を解除します。
それから、SceneTree::_process_group
で、ComparatorWithPriority
もしくはComparatorWithPhysicsPriority
でグループ内のノードをソートし、process_priority
の小さい順で、Node::notification
を呼び出します。同じprocess_priority
の場合は深度が浅い方から呼び出します。
if (p_physics) {
if (p_group->physics_node_order_dirty) {
nodes.sort_custom<Node::ComparatorWithPhysicsPriority>();
p_group->physics_node_order_dirty = false;
}
} else {
if (p_group->node_order_dirty) {
nodes.sort_custom<Node::ComparatorWithPriority>();
p_group->node_order_dirty = false;
}
}
struct ComparatorWithPriority {
bool operator()(const Node *p_a, const Node *p_b) const {
return p_b->data.process_priority == p_a->data.process_priority
? p_b->is_greater_than(p_a) // 深度を比較
: p_b->data.process_priority > p_a->data.process_priority;
}
};
struct ComparatorWithPhysicsPriority {
bool operator()(const Node *p_a, const Node *p_b) const {
return p_b->data.physics_process_priority == p_a->data.physics_process_priority
? p_b->is_greater_than(p_a) // 深度を比較
: p_b->data.physics_process_priority > p_a->data.physics_process_priority; }
};
余談ですが、Node::is_greater_than
の実装には、実は「深度を計算するループ」が入っているんです。ほとんどの場合、ノードの深度は言うほど深くないからパフォーマンス的に別にいいと思います。(気にはなりますが)「壊れてないものを直すな」ということですね。
メインループはそもそもどこから実行されるのか
ほとんどの場合、Main::iteration()
は各プラットフォーム(Windows、Androidなど)のOS
クラスのどこかにあるwhile(true)
の無限ループから呼び出されます。
例として、platform/windows/os_windows.cpp
のOS_Windows::run()
では、以下のようなコードが書いてあります:
void OS_Windows::run() {
if (!main_loop) {
return;
}
main_loop->initialize();
while (true) {
DisplayServer::get_singleton()->process_events(); // get rid of pending events
if (Main::iteration()) {
break;
}
}
main_loop->finalize();
}
では、OS_Windows::run()
はどこから実行されるのか?
すぐ隣のplatform/windows/godot_windows.cpp
にて、Windows対応のWinMain
関数があります。
WinMain
->main
->_main
->widechar_main
の順で、コマンドライン引数をWCHAR(ワイド文字、UTF-8対応)として取得してから、OS_Windows::run()
を実行します。
もう一つ面白い例は、Web対応のOS_Web
のメインループの実装です。こっちの場合は、run()
ではなくて、OS_Web::main_loop_iterate
を使っています。この関数はplatform/web/web_main.cpp
のmain_loop_callback
から呼び出されて、そしてmain_loop_callback
はgodot_web_main
でEmscriptenのメインループとして設定されています。Emscriptenの仕組みを使って、ブラウザーのrequestAnimationFrame
コールバックに登録したから、「無限ループ」を動かしているのは実はブラウザーの方なんです。なのでwhile(true)
みたいな構文が見つからなかったです。
Emscriptenとは?
LLVMを使うプログラミング言語(C、C++、Rustなど)をWebAssemblyにコンパイルして、ブラウザーで動けるようにするツールチェーンです。
ところが、「Main::iteration()
」でソースコードを検索してみると、無限ループ以外のところからメインループを実行することは意外とあります。
例えばeditor/progress_dialog.cpp
のProgressDialog::_update_ui()
では、プログレスバーの描画の更新のため、Main::iteration()
をもう一回実行します。
void ProgressDialog::_update_ui() {
// Run main loop for two frames.
if (is_inside_tree()) {
DisplayServer::get_singleton()->process_events();
Main::iteration();
}
}
なぜプログレスバーはこんなことしないと更新できないのか
プログレスバーの使い方をざっくり言うと:
- 重い処理に入る前、表示させる
- 処理を段階に分けて、各段階に入る前プログレスバーの表示を更新させ、段階の内容と処理全般の進捗(プログレス)を更新する
つまり、「次のフレーム」はここの処理が終わるまで来ません。普通はqueue_redraw
で次のフレームでUIを更新させるが、ここではMain::iteration()
を直接に呼び出すことで、描画処理を強制的に実行して、プログレスバーの更新を行います。
こうしないと、この処理自体を複数フレームに分けて実行するしかないですね。
ProgressDialogの実際の使用例はplatform/windows/export/export_plugin.cpp
のEditorExportPlatformWindows::run
を参考してください。
まとめ
Godotのメインループのコードを読んで、普段使わなくても聞いたことがある機能の名前が出たりしまして、意外と親しく見えますね。「process_priority
」のシステムがあるから、ソーティングしないといけないとか、納得できる実装がほとんどでした。
次回のテーマはまだ決めていないですが、ソースコード解説の上、エンジン改造の話を入れてみたい気持ちはあります。では、お楽しみに!