Godot 的 modules/ 目录下有 40 多个可选模块:GDScript 脚本、glTF 模型导入、Jolt 物理、mbedtls 加密、text_server 字体整形……每个模块都是自包含的,可以独立开关。但模块里的类要能被 ClassDB::instantiate() 找到、编辑器要知道模块提供的导入器、运行时要能调模块的初始化函数——这些都需要一套注册机制。核心文件是构建时生成的 modules/register_module_types.gen.cpp,以及每个模块自带的 register_types.cpp。这篇讲这套机制怎么工作,以及它如何解决 C++ 静态初始化顺序这个经典坑。
模块的自描述:register_types.cpp 的三段式
每个模块目录下都有一个 register_types.cpp,结构固定(以 modules/gdscript/register_types.cpp 为例):
void initialize_gdscript_module(ModuleInitializationLevel p_level) {
if (p_level == MODULE_INITIALIZATION_LEVEL_SERVERS) {
// 第一阶段:注册到 ClassDB
GDREGISTER_CLASS(GDScript);
ScriptServer::register_language(&gdscript_language);
}
if (p_level == MODULE_INITIALIZATION_LEVEL_SCENE) {
// 第二阶段:依赖 scene 层的初始化
EditorNode::add_init_callback(&gdscript_editor_init);
}
}
void uninitialize_gdscript_module(ModuleInitializationLevel p_level) {
if (p_level == MODULE_INITIALIZATION_LEVEL_SERVERS) {
ScriptServer::unregister_language(&gdscript_language);
}
}
模块对外暴露两个函数:initialize_xxx_module() 和 uninitialize_xxx_module()。关键点在 ModuleInitializationLevel 参数——初始化分三个阶段,模块在不同阶段干不同的事:
MODULE_INITIALIZATION_LEVEL_CORE:core 层刚初始化完,可以注册不依赖任何上层的类型(如自定义 Variant 类型);MODULE_INITIALIZATION_LEVEL_SERVERS:server 层就绪,可以注册依赖 RenderingServer/PhysicsServer 的类;MODULE_INITIALIZATION_LEVEL_SCENE:scene 层就绪,可以注册 Node 派生类、编辑器插件。
这个分阶段设计是为了解决依赖顺序。GDScript 模块的 GDScript 类继承 Script(在 core 层),但 GDScriptLanguage 要注册到 ScriptServer(在 servers 层初始化时创建)。如果模块初始化不分阶段,要么"core 阶段注册时 ScriptServer 还没存在",要么"servers 阶段注册时某些 core 类型已经错过了注册窗口"。分阶段让每个模块在正确的时机做正确的事。
生成代码:register_module_types.gen.cpp
模块的 register_types.cpp 不会被直接调用——它们被构建时生成的 register_module_types.gen.cpp 汇总。这个文件由 methods.py 的 generate_module_registration_header() 生成,内容类似:
// Auto-generated, do not edit
void register_module_types() {
initialize_modules(MODULE_INITIALIZATION_LEVEL_CORE);
initialize_modules(MODULE_INITIALIZATION_LEVEL_SERVERS);
initialize_modules(MODULE_INITIALIZATION_LEVEL_SCENE);
}
static void initialize_modules(ModuleInitializationLevel p_level) {
#ifdef MODULE_GDSCRIPT_ENABLED
initialize_gdscript_module(p_level);
#endif
#ifdef MODULE_JOLT_ENABLED
initialize_jolt_module(p_level);
#endif
#ifdef MODULE_MBEDTLS_ENABLED
initialize_mbedtls_module(p_level);
#endif
// ... 40 多个模块,每个都有 #ifdef 保护
}
生成逻辑扫描所有 modules/xxx/ 目录,检查 SCsub 里模块是否启用,启用的就生成对应的 #ifdef MODULE_XXX_ENABLED 块。这个 MODULE_XXX_ENABLED 宏由模块的 SCsub 在编译时定义(上一篇讲的 env.Append(CPPDEFINES=['MODULE_XXX_ENABLED']))。
为什么是生成代码而不是配置文件? 因为模块列表是编译期确定的(scons module_xxx_enabled=no 直接不编译该模块),用运行时的配置文件加载模块列表会引入"配置和实际编译不一致"的风险。生成代码把模块列表固化在二进制里,register_module_types() 的行为在编译期就完全确定。
调用时机:Main::setup2() 的精确位置
register_module_types() 在 Main::setup2() 里被调用,位置有严格要求(第二篇讲的启动流程):
Error Main::setup2() {
// ... DisplayServer、RenderingServer 初始化
register_core_types(); // core 层类型注册
register_server_types(); // servers 层类型注册
register_module_types(); // ← 在这里,三阶段初始化
register_scene_types(); // scene 层类型注册
// ...
}
模块初始化在 servers 之后、scene 之前。这个顺序的原因:
- 模块可能注册 server 层的扩展(如 Jolt 注册为 PhysicsServer3D 的实现),必须在 scene 层之前——因为 scene 层的
PhysicalBone3D类在_bind_methods里会查 PhysicsServer3D 的枚举; - 模块可能注册 scene 层的类(如 glTF 的
GLTFDocument是 Resource 派生),必须在 scene 层类型注册之前——否则GLTFDocument里引用Animation类时 ClassDB 里还没有。
三阶段初始化让模块可以"按需迟到"——第一阶段注册不依赖任何上层的类型,第三阶段注册依赖 scene 的类型。这是用"时间分片"解决"依赖拓扑"的经典手法。
静态初始化顺序问题的规避
C++ 的静态初始化顺序是臭名昭著的未定义行为——不同编译单元的全局对象构造顺序不保证。Godot 的模块机制完全规避了静态初始化:
- 模块里没有全局对象的构造函数干注册(如
static GDScriptLanguage lang;的构造函数里调ScriptServer::register_language)——这种写法在 Godot 里是禁止的; - 所有注册都通过
initialize_xxx_module()显式调用,调用顺序由register_module_types()控制; - 模块内部的单例(如
GDScriptLanguage::get_singleton())用"首次调用时构造"的懒汉模式,而不是全局构造。
这个纪律的代价是模块作者要记住"不要在全局构造函数里注册",换来的是初始化顺序完全可预测——register_module_types() 里的调用顺序就是字母序(生成代码按目录名排序),没有任何隐藏依赖。
模块的清理:uninitialize 的对称性
uninitialize_module_types() 在 Main::cleanup() 里调用,顺序是初始化的逆序(第三阶段先清理,第一阶段最后清理)。模块的 uninitialize_xxx_module() 要做的通常是:
- 从全局注册表移除(如
ScriptServer::unregister_language); - 释放模块持有的全局资源(如 GDScript 的字节码缓存);
- 注销 ClassDB 里的类(实际是
ClassDB::cleanup()统一处理,模块不用管)。
很多模块的 uninitialize 是空的——因为退出时的清理顺序由 Main::cleanup() 全局控制,模块只需要释放"自己 new 出来的、不属于引擎管理的资源"。GDScript 模块的清理函数就是典型案例:只负责释放 GDScriptCache 里的字节码,类的注销由 ClassDB 统一做。
模块注册的三阶段时序
实战:写一个最小模块
写一个自定义模块的最小骨架(以 modules/mymodule/ 为例):
modules/mymodule/
├── SCsub
├── config.py
├── register_types.h
├── register_types.cpp
└── my_node.h / my_node.cpp
config.py 定义模块元数据:
def can_build(env, platform):
return True # 所有平台都能编译
def configure(env):
pass # 可选:检查依赖库
SCsub 编译模块源文件:
Import('env')
env.add_source_files(env.modules_sources, '*.cpp')
env.Append(CPPDEFINES=['MODULE_MYMODULE_ENABLED'])
register_types.cpp 注册类型:
#include "register_types.h"
#include "core/object/class_db.h"
#include "my_node.h"
void initialize_mymodule_module(ModuleInitializationLevel p_level) {
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) return;
GDREGISTER_CLASS(MyNode);
}
void uninitialize_mymodule_module(ModuleInitializationLevel p_level) {
// 通常为空,ClassDB 统一清理
}
之后 scons 会自动发现这个模块(扫描 modules/ 目录),生成对应的注册代码。禁用方式是 scons module_mymodule_enabled=no,或者删掉整个目录。
最常见的坑是初始化阶段选错——MyNode 继承 Node(scene 层),如果在 LEVEL_SERVERS 阶段注册,GDREGISTER_CLASS 会失败(ClassDB 里还没有 Node)。规则很简单:继承链最深层的类在哪个层,就在哪个阶段注册。
本文代码引自 Godot 4.2.2-stable 的 modules/register_module_types.gen.cpp(生成)、modules/gdscript/register_types.cpp、methods.py,均有删减。