技术博客

SCons 构建系统:自定义编译选项与模块开关

第 10 / 149 篇 · 2026-03-22 · godot · 构建系统SCons编译配置

第一次编译 Godot 的人都会被 SCons 劝退一次:scons platform=linuxbsd target=editor -j8 这条命令背后没有 CMakeLists.txt,没有 Makefile,只有一个 3000 行的 SConstruct 和散落各目录的 SCsub。但用熟了会发现这套系统对"裁剪引擎"这件事的支持比 CMake 好——scons disable_3d=yes module_text_server_fb_enabled=no 就能编出一个砍掉 3D 和高级字体渲染的 2D 专用引擎,体积从 100MB 降到 30MB。这篇讲 SCons 在 Godot 里的具体组织方式,以及怎么用它做定制构建。

为什么是 SCons:Python 脚本的灵活性

Godot 选 SCons 的核心原因是构建逻辑需要编程。CMake 的 DSL 适合"声明依赖关系",但 Godot 的构建要干很多"声明"之外的事:

  • 动态生成代码:methods.py 里的 generate_disabled_classes() 根据 disable_3d=yes 生成 disabled_classes.gen.h,这个生成逻辑是几百行 Python;
  • 平台特定的工具链探测:macOS 构建要自动找 Xcode 路径、检查 SDK 版本、决定是否用 MoltenVK——这些在 CMake 里要写一堆 execute_process,在 SCons 里就是 Python 的 subprocess;
  • 模块系统的条件编译:每个 modules/xxx/ 目录自带一个 SCsub,里面用 Python 逻辑决定"这个模块要不要编译进来"——modules/jolt/SCsub 里会检查 module_jolt_enabled 和平台能力。

SCons 的缺点是增量构建慢(全量 Python 解释器启动 + 依赖图构建要几秒),以及 IDE 集成不如 CMake(CLion/VSCode 对 CMake 的支持是一等公民)。Godot 的妥协是提供了 compiledb 工具(scons compiledb=yes 生成 compile_commands.json),让 clangd 能接管代码导航。

构建脚本的分层:SConstruct → SCsub

Godot 的构建不是单文件,而是分层的:

SConstruct              # 顶层:解析命令行选项、初始化环境、调用各层 SCsub
├── core/SCsub          # core 层:编译 core/**/*.cpp 成 libcore.a
├── servers/SCsub       # servers 层:依赖 core,编译成 libservers.a
├── scene/SCsub         # scene 层:依赖 core+servers,编译成 libscene.a
├── editor/SCsub        # editor 层:只在 target=editor 时编译
├── platform/linuxbsd/SCsub  # 平台层:链接所有库 + 平台特有源文件成可执行文件
└── modules/xxx/SCsub   # 每个模块一个,被主 SConstruct 动态发现

SConstruct 的核心逻辑(简化):

# SConstruct
env = Environment(tools=custom_tools)  # 创建构建环境
opts = Variables(customs_files)         # 命令行选项解析
opts.Add('platform', 'Target platform', '')
opts.Add('target', 'editor|template_debug|template_release', 'editor')
opts.Add('disable_3d', 'Disable 3D nodes and servers', False)
# ... 200 多个选项
opts.Update(env)

# 递归处理子目录
SConscript('core/SCsub', exports='env')
SConscript('servers/SCsub', exports='env')
if env['target'] == 'editor':
    SConscript('editor/SCsub', exports='env')

每个 SCsub 拿到 env 对象(包含编译器路径、flags、选项值),往里面加自己的源文件和编译选项。exports='env' 是 SCons 的机制,让子脚本共享同一个构建环境。

库的构建顺序有讲究。 core 最先编译(无依赖),servers 其次(依赖 core),scene 再其次(依赖前两者),platform 最后链接。这个顺序是单向依赖的构建系统体现——scene 的 SCsub 里 env.Prepend(LIBS=['servers', 'core']),反向不行。

模块开关:编译期裁剪的实现

每个模块目录下的 SCsub 决定自己要不要参与编译:

# modules/jolt/SCsub
Import('env')
if not env['module_jolt_enabled']:
    Return()  # 模块被禁用,不编译任何文件
env.add_source_files(env.modules_sources, '*.cpp')
env.Append(CPPDEFINES=['GDJOLT_ENABLED'])  # 定义宏,供代码里 #ifdef 用

配合 SConstruct 里的选项定义:

opts.Add(BoolVariable('module_jolt_enabled', 'Enable Jolt physics module', True))

命令行 scons module_jolt_enabled=no 就能关掉这个模块。但光不编译还不够——代码里可能有 #include "modules/jolt/jolt_physics_server.h",所以模块系统还有第二层:头文件的条件包含。

modules/register_module_types.gen.cpp 是构建时生成的,内容类似:

// 自动生成,不要手改
void register_module_types() {
#ifdef MODULE_JOLT_ENABLED
    jolt_register_types();
#endif
#ifdef MODULE_TEXT_SERVER_FB_ENABLED
    text_server_fb_register_types();
#endif
    // ...
}

这个生成逻辑在 methods.py 的 generate_module_registration_header() 里——扫描所有启用的模块,生成对应的 #ifdef 块。禁用模块时,不仅不编译它的 .cpp,连注册调用都被预处理掉,彻底从二进制里消失。

平台特定的构建逻辑

platform/macos/SCsub 里的 macOS 特定逻辑(简化):

Import('env')
if env['arch'] == 'arm64':
    env.Append(CPPDEFINES=['__ARM_NEON__'])  # Apple Silicon 的 NEON 支持
env.Append(LIBS=['Cocoa', 'Metal', 'QuartzCore'])  # 系统框架
if env['vulkan']:
    env.Append(LIBS=['MoltenVK'])  # Vulkan 通过 MoltenVK
env.Append(LINKFLAGS=['-rpath', '@executable_path/../Frameworks'])  # .app bundle 的库路径

平台层的职责是把前面编译出的静态库(libcore.a、libservers.a、libscene.a、libeditor.a)加上平台特有的源文件(os_macos.mm、godot_main_macos.mm)链接成最终的可执行文件或 .app bundle。交叉编译(Linux 上编 Windows 版用 MinGW)也是在这里切换工具链。

常用定制命令实战

几个实际用过的定制构建:

最小 2D 引擎(30MB vs 默认 100MB):

scons platform=linuxbsd target=template_release \
    disable_3d=yes \
    module_text_server_fb_enabled=no module_text_server_adv_enabled=no \
    module_mbedtls_enabled=no \
    lto=full

disable_3d=yes 会触发 methods.py 生成 disabled_classes.gen.h,把所有 Node3D 派生类从 ClassDB 注册表里剔除——编辑器里看不到 3D 节点,运行时 ClassDB::instantiate("Node3D") 返回 nullptr。

调试构建带 AddressSanitizer:

scons platform=linuxbsd target=editor \
    dev_mode=yes \
    use_asan=yes \
    debug_symbols=yes

dev_mode=yes 会加 -Werror 和更多警告,use_asan=yes 链 ASan 运行时。编译出的二进制启动慢 2 倍,但内存错误第一时间暴露。

无编辑器、无 GDScript 的纯运行时(服务器用):

scons platform=linuxbsd target=template_release \
    tools=no \
    module_gdscript_enabled=no \
    optimize=size

tools=no 裁掉整个 editor/ 目录,module_gdscript_enabled=no 让引擎只支持 C# 或 GDExtension。配合 optimize=size(-Os)能编出 15MB 的无头服务器。

构建流程的依赖图

SCons 构建流程:选项解析 → 分层编译 → 链接 SConstruct 解析 200+ 选项 methods.py 生成代码/检测工具链 core/SCsub libcore.a servers/SCsub libservers.a scene/SCsub libscene.a editor/SCsub 仅 target=editor platform/xxx/SCsub 链接所有库 + 平台源文件 bin/godot.xxx 最终可执行文件 裁剪点 disable_3d=yes module_xxx_enabled=no tools=no(裁 editor/) lto=full(链接时优化)

调试构建问题的入口

编译错误排查的常用命令:

  • scons --debug=explain:显示为什么某个文件要重新编译(依赖变了还是命令变了);
  • scons -c 或 rm -rf .sconsign.dblite:清缓存,解决"改了选项但没生效";
  • verbose=yes:显示完整编译命令,能看到实际的编译器 flags;
  • compiledb=yes:生成 compile_commands.json,之后能用 clangd 或 clang-tidy 分析单个文件。

最常见的坑是选项改了但没重新生成代码——比如改了 disable_3d,但 disabled_classes.gen.h 没重新生成。这是因为生成逻辑只在 SConstruct 第一次运行时执行,增量构建不触发。解决方法是 touch SConstruct 强制全量重新配置。

本文代码引自 Godot 4.2.2-stable 的 SConstruct、methods.py、core/SCsub、modules/jolt/SCsub,均有删减。