UE 项目里没有 CMakeLists,没有 Makefile,只有 .Target.cs 和 .Build.cs 两类 C# 文件。点下 Build 之后发生的一切都由 UBT(UnrealBuildTool,源码在 Engine/Source/Programs/UnrealBuildTool)推导出来。这篇文章把这条推导链走完:目标描述 → 模块图 → 编译动作图 → 命令行。理解了它,"为什么我加的库没被链接"、"为什么这个宏在 Shipping 里没生效"这类问题就都有答案了。
两层声明:Target 管"编成什么",Module 管"怎么编"
UBT 的输入是两级 C# 类,职责分得很清楚:
// MyGame.Target.cs —— 描述一个"构建目标"
public class MyGameTarget : TargetRules
{
public MyGameTarget(TargetInfo Target) : base(Target)
{
Type = TargetType.Game; // Game / Editor / Server / Client / Program
DefaultBuildSettings = BuildSettingsVersion.V5;
ExtraModuleNames.Add("MyGame"); // 这个目标要编哪些模块
}
}
// Source/MyGame/MyGame.Build.cs —— 描述一个"模块"
public class MyGame : ModuleRules
{
public MyGame(ReadOnlyTargetRules Target) : base(Target)
{
PublicDependencyModuleNames.AddRange(new string[] {
"Core", "CoreUObject", "Engine", "InputCore"
});
PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
}
}
Target 回答的是产品层面的问题:这个目标是什么类型(Game 要 cook 资产,Editor 要带编辑器模块)、用哪套默认设置、包含哪些模块。Module 回答的是工程层面的问题:我这个模块依赖谁、include 路径加什么、链什么库、定义什么宏。
关键的认知是:依赖不是传递闭包式的随意引用。PublicDependencyModuleNames 和 PrivateDependencyModuleNames 的区别不是"能不能 include",而是你的 Public 头文件里暴露了谁。如果你的模块 A 的 Public 头文件里出现了模块 B 的类型,那 B 必须在 Public 依赖里——否则下游模块 C 依赖 A 时,UBT 不会把 B 的 include 路径传给 C,C 编译 A 的头文件就会找不到 B 的类型。UBT 在 ModuleRules 的校验阶段(UnrealBuildTool/Rules/ 下)会检查这个,报错信息是 "expects private module X to be public"。
推导链第一步:从 Target 展开模块图
UBT 入口在 UnrealBuildTool/Modes/BuildMode.cs,核心流程是:
- 收集 Target:扫描
Source/下所有*.Target.cs,用 C# 反射实例化TargetRules子类(UnrealBuildTool/System/RulesCompiler.cs负责把这些 .cs 文件编译成一个临时程序集再执行——所以 .Build.cs 语法报错时你会先看到 C# 编译错误)。 - 实例化模块规则:对 Target 声明的每个模块名,找到对应的
.Build.cs,实例化ModuleRules。同一个模块被多个 Target 引用时,每个 Target 一份独立实例——这就是为什么ModuleRules的构造函数拿到的是ReadOnlyTargetRules,模块规则可以读取目标信息(比如Target.Configuration)来条件化自己的行为,但不能修改目标。 - 递归展开依赖图:从 Target 的模块出发,按
PublicDependencyModuleNames/PrivateDependencyModuleNames递归,得到完整的有向无环图。这一步在UEBuildModule里完成,产出是UEBuildTarget对象——构建的全部决策数据都在它身上。
这里有个容易踩的坑:依赖图是模块粒度的,不是文件粒度。你在 PrivateDependencyModuleNames 里加了 Slate,整个模块的所有 .cpp 都能 include Slate 的 Public 头文件,没有办法做"只有这几个文件需要 Slate"的细粒度声明。想隔离依赖,唯一的办法是拆模块。
推导链第二步:模块图变成编译动作
有了 UEBuildTarget,UBT 要为每个模块算出具体的编译参数。这一步的核心在 UnrealBuildTool/Platform/ 下的各平台 ToolChain(如 VCToolChain.cs、AppleToolChain.cs)。每个 ToolChain 负责把模块规则翻译成 CompileEnvironment 和 LinkEnvironment:
- include 路径:模块自身 Public/Classes + 所有依赖模块的 Public/Classes。Private 目录只加自己模块的。
- 预处理器定义:来自三处叠加——模块规则里写的
PublicDefinitions/PrivateDefinitions、UBT 自动注入的(如WITH_EDITOR=1、UE_BUILD_SHIPPING=1、每个模块的MODULENAME_API导出宏)、全局BuildConfiguration.xml里的设置。 - PCH 与 Unity Build:默认开启。PCH(预编译头)由
PCHUsage字段控制(ModuleRules.PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs是现在的推荐值);Unity Build 把多个 .cpp 合成一个大文件编译(bUseUnity),默认每 32 个文件合一,可以通过Engine/Programs/UnrealBuildTool/Configuration/BuildConfiguration.cs里的MaxIncludedFilesPerUnityFile或模块级的bFasterWithoutUnity调整。
Unity Build 值得展开,因为它是新手最常撞上的"灵异现象"源头。把 32 个 .cpp 合成一个编译单元意味着:文件 A 里 using namespace Foo; 会影响同单元的文件 B;A 里定义的匿名命名空间符号、宏泄漏都可能污染 B。症状是"单独编译没问题,合编就挂"或反之。排查方法是在模块的 .Build.cs 里加 bFasterWithoutUnity = true 关掉 Unity 重编——如果好了,就是 Unity 污染,去查宏泄漏和 using 声明。UE 5.x 引入了 Adaptive Unity(bAdaptiveUnityDisablesPCH 等开关):UBT 记录每个文件最近的编译时间,自动把"经常改动的文件"排除出 unity blob,只对稳定的文件做合并。这是对增量编译速度和全量编译速度的动态权衡,代价是第一次全量编译后行为才收敛。
// 模块级构建参数的实际出处(简化自 ModuleRules.cs)
public class ModuleRules
{
public PCHUsageMode PCHUsage; // PCH 策略
public bool bFasterWithoutUnity; // 关掉 unity build
public List<string> PublicIncludePaths; // 额外 include(相对引擎根)
public List<string> PublicDefinitions; // 对所有下游生效的宏
public string PrivatePCHHeaderFile; // 独占 PCH(大模块省编译时间的手段)
public List<string> PublicAdditionalLibraries; // 额外链接库
// ...
}
推导链第三步:动作图与增量判断
UBT 最后把"每个模块编哪些文件、用什么参数"展开成一个动作图(Action Graph):节点是编译/链接动作(Action),边是依赖(编译依赖 PCH 产物,链接依赖所有编译产物)。这个图的执行器在 UnrealBuildTool/Executors/ 下,本地执行时是 ParallelExecutor,接 FASTBuild/ Horde 时是远程执行器。
增量编译的判断不靠文件时间戳的朴素比较,而是 UBT 把每个动作的完整输入指纹(源文件哈希 + 编译参数 + 依赖产物哈希)序列化存进 Intermediate/Build/ 下的缓存。下次构建时重算指纹比对,一致就跳过。这解释了三个常见现象:
- 改
.Build.cs里一个宏定义,整个模块重编——因为编译参数变了,指纹全变。 - 删了
Intermediate/等于全量重编——指纹缓存没了。 - 有时"改了头文件但下游没重编"——头文件指纹没变(比如只改了注释)或者下游动作指纹的依赖列表里根本没纳入这个头文件(依赖扫描靠编译器生成的
.d//showIncludes输出,某些 include 方式会漏)。
实战:怎么看 UBT 到底生成了什么
三个我自己常用的排查手段:
- 导出 JSON 描述:
UnrealBuildTool -Mode=JsonExport -Target=MyGameEditor ...会把整个动作图(每个文件的编译命令行、include 路径、宏定义)导出到Intermediate/Build/.../*.json。想确认"这个宏到底有没有定义到这个文件上",查 JSON 比猜快得多。 - 打开详细日志:
BuildConfiguration.xml里加<PrintDebugInfo>true</PrintDebugInfo>或命令行-Verbose,UBT 会把模块图展开过程和指纹比对结果打出来。 - 看 Response File:实际传给编译器的参数在
Intermediate/Build/.../UnrealEditor/<Module>/下的.rsp文件里,这是"编译器真正看到的"最终参数,比 IDE 显示的更准确(IDE 的工程文件也是 UBT 生成的,是二手信息)。
几个结论
- 依赖写 Public 还是 Private,判断标准是"我的 Public 头文件暴没暴露它",不是"我用不用它"。写错位置的错误要等到下游模块编译时才暴露,很绕。
- 编译参数的最终权威是
.rsp文件和 JsonExport 产物,IDE 显示的参数是 UBT 生成的二手工程文件,二者不一致时信前者。 - 增量编译出问题先怀疑指纹缓存(
Intermediate/Build),再怀疑 Unity 污染(bFasterWithoutUnity对比),最后才怀疑 UBT 本身的 bug——前两个占了九成以上。 .Build.cs是代码不是配置:它能拿到Target.Platform、Target.Configuration做条件分支,也能读环境变量、查文件系统。把"按平台换库版本"这类逻辑写在这里,比在外面包脚本干净。
下一篇讲这条链路上游的另一个生成器:UHT 怎么把 UCLASS() 变成 .generated.h 里的注册代码。