main/main.cpp 是 Godot 里单文件行数的亚军(四千多行,冠军是 editor/editor_node.cpp)。但翻完会发现启动逻辑其实就三个函数:Main::setup() → Main::setup2() → Main::start(),外加各平台 main() 里的薄薄一层入口。这篇以 4.2.2 的 platform/linuxbsd/godot_linuxbsd.cpp 为起点,把这条链按"谁依赖谁"理清楚——看懂启动顺序的本质是看依赖拓扑,而不是背流程。
平台 main():只做三件平台相关的事
每个桌面平台的入口都长得几乎一样(platform/linuxbsd/godot_linuxbsd.cpp):
int main(int argc, char *argv[]) {
OS_LinuxBSD os;
setlocale(LC_CTYPE, ""); // 平台特定的 locale 修正
Error err = Main::setup(argv[0], argc - 1, &argv[1]);
if (err != OK) { return 255; }
if (Main::start() == OK) {
os.run(); // 进入主循环
} else {
os.get_main_loop()->finalize();
}
Main::cleanup(); // 上面都结束了才会走到这
return os.get_exit_code();
}
平台层负责的只有:构造 OS_LinuxBSD(里面完成 OS::set_singleton)、把命令行参数转交 Main、最后调用 os.run() 驱动主循环。所有跨平台逻辑都下沉到 Main 类,这是"启动流程只有一份"的原因——macOS 的 godot_main_macos.mm 和这段几乎逐行相同,差异仅在 Objective-C 的 autorelease pool。
os.run() 的实现也值得看一眼(core/os/os.cpp):
void OS::run() {
if (!main_loop) return;
main_loop->initialize();
while (!main_loop->iteration()) {} // 返回 true 表示请求退出
main_loop->finalize();
}
主循环就是这么朴素的一个 while。每帧的全部工作都压在 MainLoop::iteration() 里, SceneTree 的那套 _process 派发是它内部的子步骤。
setup():在最穷的环境里活下来
Main::setup() 干的是"连内存分配器都还没准备好"阶段的活。它的顺序约束很严格,因为后面每一步都依赖前面:
- 注册核心类型:
register_core_types(),把 Object/RefCounted/Resource 这些塞进 ClassDB。注意此时还没有渲染、没有物理,所以连String::num()这种基础函数能用了,但Node还不可知; - 初始化全局单例:
Globals(后来的 ProjectSettings)、Engine、MessageQueue。这些全局对象的构造函数顺序是个经典坑——Engine要先于几乎所有 server 存在,因为很多类的构造函数里会查Engine::get_singleton(); - 解析命令行:
--editor、--headless、--path都在这里消化。--headless的作用不是"不画画面",而是把 DisplayServer 换成 dummy 实现,后续渲染初始化会走完全不同的分支; - 决定运行模式:editor / project manager / game,由命令行参数和当前目录有没有
project.godot共同决定。
这个函数里最容易踩的坑是日志系统的初始化时机。OS 单例在平台 main 里构造,setup() 一进来就能用 print_line(),但文件日志要等到 ProjectSettings 读出 logging/file_logging/enable_file_logging 才开。所以启动早期(比如 ClassDB 注册阶段)的崩溃信息只能看 stdout,想调试"双击图标闪退"必须重定向 stdout 到文件。
setup2():把 server 层立起来
Main::setup2() 是资源消耗的大头,顺序同样严格:
- 初始化 DisplayServer:先开窗口/显示服务,因为后面的渲染器需要一个 surface;
- 初始化 RenderingServer:创建渲染设备(Vulkan/D3D12/Metal/GLES3),这一步是启动里最慢的部分之一,涉及 GPU 驱动握手;
- 初始化其余 server:PhysicsServer2D/3D、NavigationServer、TextServer、AudioServer。注意 AudioServer 是最后初始化的,因为音频线程启动后马上要吃数据,依赖混音图建好;
- 注册场景类型:
register_scene_types(),Node/SceneTree/Control/Node3D 全部进 ClassDB。这步必须在 server 之后——MeshInstance3D的_bind_methods里引用了 RenderingServer 的枚举; - 注册模块与脚本:
register_module_types()、ScriptServer::init_languages(),GDScript 在这里成为可被 ClassDB 实例化的语言; - 加载 GDExtension:扫描
*.gdextension文件,加载动态库。放在模块之后是因为扩展可能注册新的节点类型,要赶在场景实例化之前。
server 初始化的失败处理值得注意:RenderingServer 初始化失败时 setup2() 会尝试降级(比如 Vulkan 失败退到 Compatibility),而不是直接退出。这套降级链由 rendering/driver 项目设置控制,是"游戏在没独显的办公机上也能跑"的来源。
start():决定第一个 MainLoop 是谁
Main::start() 解决最后一个问题:游戏起来之后谁在跑。它做三件事:
- 解析 main scene:从
project.godot读application/run/main_scene,或者用--path命令行指定的场景; - 构造 SceneTree:
memnew(SceneTree),把它OS::set_main_loop()成当前 MainLoop; - 加载 autoload 和 main scene:autoload 是挂在 SceneTree 根节点下的常驻节点,main scene 是最后一个 add_child。
编辑器模式下这里分叉:不走 main scene,而是实例化 EditorNode 作为根。--editor 和正常游戏共享同一个二进制、同一套 setup,差异只在 start() 里实例化谁——这是"编辑器就是引擎跑起来的一种模式"的实现证据。
完整启动序列
清理顺序:cleanup() 是 setup 的逆序
Main::cleanup() 基本是启动序列的镜像:先释放 main loop(场景树析构,节点们收到 NOTIFICATION_EXIT_TREE 和 PREDELETE),再逐个关 server(音频先停,渲染后停),最后 unregister_core_types()。这个对称性不是巧合——server 之间互相持有 RID 引用,析构顺序错了会拿到一堆 "ObjectDB instances leaked" 的警告。这也是为什么排查退出崩溃时,先看的永远是 cleanup 阶段栈,而不是业务代码。
有一个细节值得记住:cleanup() 里会调 ResourceCache::clear() 和 ClassDB::cleanup(),这两步之后任何静态对象再析构就会踩空指针。所以 Godot 里严禁在静态对象的析构函数里碰引擎 API——静态析构发生在 main() 返回之后,此时 ClassDB 已经拆了。C++ 的全局 Ref<Texture> 成员是这种崩溃的经典来源,改成函数内局部 static 或显式管理可以避开。
调试启动问题的三板斧
- 加
--verbose:启动各阶段的耗时和决策会打到 stdout,渲染器降级、模块加载失败都会留痕; - 断点下在
Main::start()而不是main():前面几千行是确定性的初始化,业务差异从这里才开始; - 退出崩溃先看
ObjectDB::cleanup()的泄漏报告:--verbose模式下会列出每个泄漏对象的类名和 ID,能直接定位是哪个自定义类忘了memdelete。
main.cpp 剩下的三千多行是参数解析表、帮助文本、各种工具子命令(--doctool、--export 等)的实现——它们不影响主路径,按需查阅即可。启动流程本身,就是这 200 行核心代码。
补充:工具子命令的分支位置
--export-release、--doctool、--headless --script 这些"跑完就退出"的模式,分叉点都在 setup2() 末尾。setup2() 完成后会检查一组全局标志(editor、project_manager、cmdline_tool 等),如果是工具模式,直接执行对应任务然后 ::exit(),根本走不到 Main::start()。这就是为什么 CI 里跑 --headless --export-release 不需要显示环境——DisplayServer 在 setup2() 里被换成了 dummy 实现,而导出逻辑只碰资源管线不碰渲染。
还有一个容易漏看的细节:Main::start() 返回的 Error 决定 os.run() 是否执行。如果 main scene 加载失败,start() 返回 ERR_CANT_OPEN,os.run() 被跳过,直接走 finalize() + cleanup()——所以"场景路径写错"的报错出现在启动日志的最末尾,而不是中间,排查时要从后往前翻。
主循环的退出条件与帧步进
os.run() 里的 while (!main_loop->iteration()) 只有两个退出路径:SceneTree::quit()(脚本里 get_tree().quit())把 MainLoop 的退出标志置位,iteration() 返回 true;或者 OS 层收到窗口关闭事件,转发给 MainLoop::notification(NOTIFICATION_WM_CLOSE_REQUEST),默认实现里同样调 quit()。没有第三条路——这意味着"杀进程之外的所有正常退出"都会走完整的 finalize() + Main::cleanup(),析构顺序的可靠性是有保证的。
每帧的耗时控制不在 os.run() 而在 main_timer_sync.cpp 的 MainTimerSync:它根据 physics_ticks_per_second(默认 60)和实际帧时间算出这一帧要跑几个物理步。渲染帧 144Hz 配物理 60Hz 时,大部分渲染帧跑 0 个物理步、每两三帧补 1 个——这个"补步"逻辑就是 MainTimerSync::advance() 里的累加器模式,和经典游戏循环文章《Fix Your Timestep》里的 accumulator 是同一个算法。这也是为什么物理帧间隔恒定而渲染帧间隔浮动:物理时间由累加器严格对齐 1/60s 的整数倍,渲染时间跟着 vsync 走。
本文代码引自 Godot 4.2.2-stable 的 main/main.cpp、main/main_timer_sync.cpp、platform/linuxbsd/godot_linuxbsd.cpp、core/os/os.cpp,均有删减。