10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Godotのソースコードを理解せよ!メインループ編

Posted at

はじめに

どうも、ゲームプログラマーのChocolaMintです。

Godotという、最近「なぜか」流行っているオープンソースのゲームエンジン、ご存知でしょうか。

ここの「オープンソース」について、皆さんはどう認識しているのでしょうか。

  • 無料で使える!(例外もありますが、Godotの場合は本当に無料)
  • MITライセンスなので、例えメインブランチが突然有料化宣言を出しても、フォークして無料バージョンを作れる!
  • ソースコードが見られる!

…で?

そうですよね。ソースコードが見られるのって、別にいいじゃない?どうせエンジン改造なんて、企業レベルの開発チームじゃないとできないことでしょう。しかもC++ってめんどくさそうですし…

別のオープンソースのゲームエンジンの場合は、たしかにそうかもしれませんね。が、Godotは実は読みやすい、改造しやすい方なんです!

今回の新シリーズ、「Godotのソースコードを理解せよ!」では、毎回Godotの一部機能を抜粋して、関連のソースコードの実装を紹介します。Godotのソースコードの理解を深めることで、Godotをもっと自由自在に使えるはずです。少しでもみんなのお役に立てれば幸いです。

では、本題に入りましょうか。

Godotのメインループ

ほとんどのゲームに不可欠な無限ループ。大体のゲームエンジンのメインループはこんな感じです:

  1. 1回以上の物理処理の更新(FixedUpdate、PhysicsTick、_physics_processなど)
  2. ゲーム内のオブジェクトを更新(Update、Tick、_processなど)
  3. 描画処理

もちろん、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.cppSceneTree::process()では、シーン内のノードを更新する処理が書かれてあります。

  1. MultiplayerAPI(マルチプレイ)のポーリングを行う
  2. "process_frame"シグナルを発信する
  3. シーン内のノードを「process_priorityが低い方から」、そして「上から下まで」Node::notification(Node::NOTIFICATION_PROCESS)を呼び出して(SceneTree::_process(false) )、間接的にGDScriptのNode._process()を呼び出すことになる
  4. Timerノードを更新する
  5. 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_TREENode::_add_to_process_thread_groupを通じてSceneTreeのグループに登録して、そして「ツリーから出る時」(NOTIFICATION_EXIT_TREENode::_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.cppOS_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.cppmain_loop_callbackから呼び出されて、そしてmain_loop_callbackgodot_web_mainでEmscriptenのメインループとして設定されています。Emscriptenの仕組みを使って、ブラウザーのrequestAnimationFrameコールバックに登録したから、「無限ループ」を動かしているのは実はブラウザーの方なんです。なのでwhile(true)みたいな構文が見つからなかったです。

Emscriptenとは?

LLVMを使うプログラミング言語(C、C++、Rustなど)をWebAssemblyにコンパイルして、ブラウザーで動けるようにするツールチェーンです。

ところが、「Main::iteration()」でソースコードを検索してみると、無限ループ以外のところからメインループを実行することは意外とあります。

例えばeditor/progress_dialog.cppProgressDialog::_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.cppEditorExportPlatformWindows::runを参考してください。

まとめ

Godotのメインループのコードを読んで、普段使わなくても聞いたことがある機能の名前が出たりしまして、意外と親しく見えますね。「process_priority」のシステムがあるから、ソーティングしないといけないとか、納得できる実装がほとんどでした。

次回のテーマはまだ決めていないですが、ソースコード解説の上、エンジン改造の話を入れてみたい気持ちはあります。では、お楽しみに!

10
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?