Building Unreal Engine 5 from Source
UE 源码在 Epic 的私有 GitHub 仓库。要拉取,GitHub 账号得先加入 EpicGames 组织(在 epicgames.com 关联账号后 Epic 会发邀请),然后用普通的 git clone。
常用的命令是:
git clone --depth 1 --branch release \
https://github.com/EpicGames/UnrealEngine.git
--branch release:锁定 release 分支。UE 有多个分支:
release:当前最新稳定版(Epic 推荐)master / ue5-main:开发主干,不稳定release-5.x:特定大版本的发布分支不带 --branch 默认拉 master/默认分支,可能拿到开发中的代码。
--depth 1:浅克隆,只取最近一次提交,不要历史。
UE 仓库历史从 2010 年代开始、几万个提交,完整 .git 目录十几 GB。--depth 1 把这堆历史完全跳过:
| 完整克隆 | --depth 1 | |
|---|---|---|
| 历史提交 | 全部(数万) | 仅 1 个 |
.git 大小 | 十几 GB | 几百 MB |
| 工作树(源码本体) | 相同 | 相同 |
能否 git log 历史 | 是 | 否(只 1 条) |
能否 git blame | 是 | 受限 |
如果只是编译使用,不需要 git log/blame/rebase 历史,浅克隆能省下十几 GB 下载量。代价是丢失历史,要 git fetch --unshallow 才能补回。
此外还用到 Git LFS(Large File Storage)。UE 仓库里部分二进制(一些锁定的依赖、文档资源)走 LFS 跟踪。LFS 把大文件存到独立的 LFS 服务器,Git 仓库里只留指针文本,clone 时再按需拉取大文件本体。装 Git LFS 才能让这些文件就位。
注意:UE 的绝大多数大二进制依赖其实不是用 LFS,而是用自家工具 GitDependencies(见 B06)。LFS 只是少数文件。
--depth 1 后 checkout 别的分支会受限:浅克隆没有别的分支引用,要切分支得先 git fetch --depth 1 origin <branch>。Setup.bat(B06),否则引擎编不起来。master 又想要稳定,要么重新 clone,要么 git fetch 远端再切换。git clone 只拿到源码。引擎要跑,还缺一大堆二进制依赖:预编译的第三方库、内容资产、平台 SDK。这些不放进 Git,由 Setup.bat 调用 Epic 自家的 GitDependencies 工具按需下载。
Git 设计给文本(源码)用的,对二进制大文件非常不友好:
UE 仓库几十 GB 的二进制依赖如果直接进 Git,clone 一次几十 GB 历史,任何提交都重写一遍。所以 Epic 用三种机制绕开:
仓库根有一个 .gitdependencies 文件(JSON 格式),列出所有需要下载的二进制文件路径和来源。Setup.bat 实际跑的是:
Engine/Build/BatchFiles/GitDependencies.exe # Windows
Engine/Build/BatchFiles/GitDependencies.py # 跨平台
它读 .gitdependencies,从 Epic 的 CDN / GitHub 的 raw 端点下载每个文件到对应路径。关键特性:
Setup.bat,已下载的跳过、未完成的续传,不会从头再来。.gitdependencies 在 Git 里随源码一起 checkout,不同版本的源码对应不同依赖集合,保证版本匹配。.git,不污染版本历史。对比直接用
git lfs pull:LFS 也按需,但需要文件先在 LFS 服务器注册、走 LFS 协议。GitDependencies 是 Epic 自研的”按清单下文件”,更轻、更可控。
依赖总量通常三四十个 GB,典型包括:
跳过 Setup.bat 直接编译,链接阶段会报”找不到 xxx.lib”——因为依赖没就位。
Engine/Intermediate 或缓存目录,否则已下文件可能要重来。--all / --exclude 选项:GitDependencies 支持过滤(如不下某些平台),节省空间。不需要 iOS/Android 可以排除。Setup.bat 下的二进制依赖 ≠ DDC 缓存。DDC 是引擎运行时自己生成的派生数据(见 B17),首次启动才填。GenerateProjectFiles.bat 生成 UE5.sln(Visual Studio 解决方案)。这里要纠正一个常见误解:这个 sln 几乎不包含真正的编译规则,它只是个外壳,把编译请求转交给 UnrealBuildTool。
普通 VS 工程(C# / 普通 C++)的编译规则全在 .vcxproj 文件里:每个 .cpp 文件、每个 include 路径、每个预处理器宏、每条链接选项都列出来。VS 点 Build 时直接调 MSBuild 读这些 .vcxproj 编译。
UE 不是这种模式。
UE5.sln 是个指针文件,作用是:
但 Build 按钮按下去,VS 不是按 .vcxproj 里的规则编译,而是执行 .vcxproj 里写的一行 pre-build step,把编译请求转发给 UnrealBuildTool(UBT)。UBT 接管后,VS 退居”显示编译输出的窗口”角色。
VS Build 按钮
→ 触发 .vcxproj 的自定义 build step
→ 调用 UnrealBuildTool.exe <Target> <Config> <Platform>
→ UBT 真正算依赖、调度编译(见 B08)
这就是”sln 不包含编译规则,只是外壳”的含义。
.vcxproj 不现实。UBT 从 Build.cs(见 B09)自动算出每个模块的依赖和编译选项,按需生成 .vcxproj 给 VS(只为开发浏览用),真正的编译规则在 UBT 里。.generated.cpp,这些文件不在源码树里。普通 .vcxproj 改一次重启 VS 才生效,UBT 每次编译都重算,永远准确。Build.cs / Target.cs,收集模块和目标。UE5.sln 和各项目(UE5.vcxproj、UnrealHeaderTool.vcxproj 等)的工程文件——但 vcxproj 里把 Build 动作委托给 UBT。Build.cs 里。Build.bat → UBT 等价(B14)。UnrealBuildTool(UBT)是 Epic 用 C# 写的构建调度程序。它不是编译器,而是”指挥编译器的大脑”:读构建脚本、算依赖、决定哪些文件要重编、再调底层 MSVC/clang。
1. 解析命令行:Target 名、Configuration、Platform
(如 UE5Editor Win64 Development)
2. 读 Target.cs(见 B09):确定要编哪些模块
3. 读每个模块的 Build.cs:算出模块间依赖图
4. 拓扑排序:先编依赖、再编被依赖
5. 跑 UHT 生成反射代码(见 B11)
6. 决定哪些 .cpp 需要重编(增量构建)
7. 拼 Unity Build 文件、调 MSVC 编译每个单元
8. 调链接器生成 DLL/EXE
9. 写 build.xml 增量缓存,下次跳过未改的
UBT 不每次全量编译,而是算”自上次构建以来什么变了”。判断维度:
.cpp/.h 改过,对应单元重编。#include,不靠编译器)。某个 .h 改了,所有直接间接 include 它的 cpp 都重编。这套机制比 MSBuild 的原生增量更细:MSBuild 只看 cpp 的时间戳,不知道头文件改了谁要重编,常常要么不重编(漏)要么全重编(费时)。UBT 的精确依赖图让大引擎的增量构建可行。
UBT 算出要编的文件后,给 MSVC(Windows)/clang(Mac/Linux)拼命令行:
cl.exe /c /I<include...> /D<宏...> < unity_拼出来的大.cpp > → .obj
关键参数:
/I include 路径:来自 Build.cs 声明的依赖模块的 Public/Private 路径。/D 预处理宏:UE_BUILD_DEVELOPMENT、PLATFORM_WINDOWS 等,由 Target/Configuration/Platform 注入,控制 #ifdef 分支。/Yu//Yc 预编译头:UE 用预编译头(UEngine.h 等)加速,UBT 自动管理。链接阶段调 link.exe 把 .obj 组成 DLL 或 EXE。
| 维度 | 取值 | 影响 |
|---|---|---|
| Target | UE5Editor/UE5Game/UE5Server/ShaderCompileWorker | 编哪些模块、产出什么(见 B09/B16) |
| Configuration | Debug/DebugGame/Development/Shipping/Test | 优化级别、是否带调试符号、宏 |
| Platform | Win64/Mac/Linux/IOS/Android | 编译器、SDK、平台宏 |
三者的组合决定整套宏、依赖、优化选项。Shipping 配置会去掉编辑器、断言、调试代码,体积大幅缩小、性能提升。
Intermediate/Build 才生效。Platform 参数必须和当前 OS 匹配(远程编译工具链除外)。Build.bat 把 UBT 自己编出来,才能用它编引擎。这是个 bootstrap。UE 把引擎切成几千个模块(Module),用 C# 声明式描述依赖关系。两套脚本:
Build.cs:每个模块一个,声明它依赖哪些别的模块。Target.cs:定义最终要构建出什么(编辑器、游戏、服务器),是一堆模块的组合。一个模块的 Build.cs(放在模块根目录,文件名 = 模块名 + .Build.cs)大致这样:
using UnrealBuildTool;
public class MyModule : ModuleRules
{
public MyModule(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
// 声明依赖的别的模块
PublicDependencyModuleNames.AddRange(new string[] {
"Core", "CoreUObject", "Engine", "RenderCore"
});
// 私有依赖(只在 .cpp 里用,不暴露给依赖本模块的人)
PrivateDependencyModuleNames.AddRange(new string[] {
"Slate", "SlateCore"
});
// 额外 include 路径、宏、库...
}
}
两类依赖:
.cpp 里用。不传递给上游。这个 public/private 区分让 UBT 能算出每个模块需要暴露给依赖者哪些 include 路径、哪些不用——避免无关模块互相可见导致的编译时间爆炸。
每个模块至少包含:
MyModule/
├── MyModule.Build.cs # 上面那段
├── Public/ # 对外暴露的头文件
│ └── MyModule.h
├── Private/ # 实现的 .cpp 和不公开的头
│ ├── MyModule.cpp
│ └── MyModulePCH.h # 可选预编译头
.Build.cs 里 PublicDependencyModuleNames 引用的是模块名(如 "Core"),UBT 知道 Core 的 Public/ 目录要加到本模块的 include 路径里。
Target.cs 描述”这次编译产出什么”。引擎自带的例子 UE5EditorTarget.cs:
public class UE5EditorTarget : TargetRules
{
public UE5EditorTarget(TargetInfo Target) : base(Target)
{
Type = TargetType.Editor; // 编辑器:含全部编辑器模块
DefaultBuildSettings = BuildSettingsVersion.V5;
ExtraModuleNames.AddRange(new string[] { "Engine", "Core", ... });
// UBT 根据 Type 自动加上 Editor 必需的全部模块
}
}
TargetType 决定形态:
| Type | 产出 | 包含的模块 |
|---|---|---|
Editor | UnrealEditor.exe | 引擎 + 全部编辑器 UI/工具 |
Game | MyGame.exe | 引擎 + 游戏模块,不含编辑器 |
Server | MyServer.exe | 仅服务器(无客户端渲染) |
Program | 各种工具 | 单个程序(如 ShaderCompileWorker,B16) |
游戏工程的 Target.cs 还会 ExtraModuleNames.AddRange("MyGameModule") 把自己的游戏模块加进去。
Build.cs 自动算依赖,不需要全局清单。新加模块只声明自己依赖谁,UBT 拓扑排序自动决定编译顺序。Build.cs 接入引擎,无需改引擎本体。Target 类名 = <TargetName>Target,文件 = <TargetName>.Target.cs,命令行用 <TargetName>。命名错了 UBT 找不到。同一份 UE 源码能编出完全不同形态的二进制,取决于构建配置。对比两种:Development Editor 用 DLL、Shipping 用单体。
Development Editor 配置把每个模块编成独立的 DLL:
UnrealEditor-Core.dll
UnrealEditor-Engine.dll
UnrealEditor-RenderCore.dll
UnrealEditor-MyModule.dll
UnrealEditor.exe # 主程序,加载这些 DLL
好处:
代价:启动时要加载几百个 DLL、动态解析符号,启动略慢;分发时带一堆 DLL。
Shipping 发行版走单体构建(monolithic build),所有模块静态链接进一个大 EXE:
MyGame.exe # 几百 MB,包含引擎 + 游戏全部代码
好处:
代价:改任何模块都要重链整个 EXE(链接阶段几十分钟),不适合开发期 iterate。
TargetRules 里有 LinkType 属性:
// Development Editor:模块化
LinkType = TargetLinkType.Modular;
// Shipping:单体
LinkType = TargetLinkType.Monolithic;
实际默认行为按 Configuration 自动决定:Development/Debug 默认 Modular(开发要快迭代),Shipping 默认 Monolithic(发布要小要快启动)。这就是”同一份源码能编出完全不同的形态”的含义。
更深一层:编辑器跑着的时候加载游戏模块 DLL,玩家改了 C++ 重新编译生成新版本 DLL,编辑器卸载旧 DLL、加载新 DLL,实现热重载。单体 EXE 没法热替换(整个进程的代码都在内存里、不能换),所以热重载只在 Modular 配置下可用。
这是 UE 编辑器迭代快的根本机制,也是为什么引擎本体不能编成 Monolithic 给编辑器用——编辑器场景必须 Modular。
DLLEXPORT/API 宏标记,单体下变成普通符号。代码里 MODULENAME_API 宏在 Modular 展开成 __declspec(dllexport),在 Monolithic 展开成空——同一份源码两种展开。你写的 UCLASS、UPROPERTY、UFUNCTION 这些宏,普通 C++ 编译器不认识。真正编译之前,UE 先跑 UnrealHeaderTool(UHT) 扫描这些宏,自动生成 .generated.h 和 .gen.cpp,提供反射、序列化、垃圾回收、蓝图绑定。
UE 的 C++ 编译是两段式:
1. UHT 阶段:扫描所有带 UE 宏的头文件
→ 生成 <Module>.generated.h, <Class>.generated.cpp
2. MSVC 阶段:编译"原始 cpp + 生成 cpp" → obj → DLL/EXE
普通 C++ 项目只有阶段 2。UE 多了阶段 1 的代码生成,这是反射系统的代价。
你在头文件写:
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, Category="Stats")
float Health;
UFUNCTION(BlueprintCallable)
void TakeDamage(float Amount);
};
UCLASS()、UPROPERTY()、UFUNCTION() 在标准 C++ 里展开成空(#define UPROPERTY(...) 当 UHT 没参与时是空宏),所以普通编译器看不懂也不报错——它们只是给 UHT 看的标记。GENERATED_BODY() 不是空宏,它展开成对 UHT 生成的代码的 include 和注入(如 static UClass* StaticClass() 的声明)。UHT 看到这些标记,在 <Module>.generated.h 里生成:
UClass* 静态对象,描述这个类的元数据(名字、父类、所有属性、所有函数)。UProperty* 描述(名字、类型、偏移量、EditAnywhere 标志)。UFunction* 描述(参数、返回类型、BlueprintCallable 标志、调用入口)。在 <Class>.generated.cpp 里生成这些元数据对象的实例化代码。
UPROPERTY 序列化,不用手写。UPROPERTY 的指针被 GC 追踪——只追踪 UPROPERTY 标记的指针,普通 UObject* 指针 GC 看不见,可能误删。BlueprintCallable 函数被 UHT 生成一个 thunk,蓝图调用时通过反射找到这个 C++ 函数并调用。Replicated 属性自动生成网络同步代码。UBT 调度时,在编译任何 cpp 之前先把 UHT 跑一遍(B08 流水线第 5 步)。UHT 自己也是一个 Target(UnrealHeaderTool),要先编出来。UHT 扫描依赖图、找出所有引用 UE 宏的头文件、生成 .generated.* 文件,这些生成文件和源码一起被 MSVC 编译。
增量上:UHT 维护缓存,只有改过的头文件才重新生成。
GENERATED_BODY() 必须在类体里,位置错了 UHT 报错。它注入的是 UHT 生成的内部声明。UCLASS 不行,必须 UCLASS()。空括号也是合法元数据。UPROPERTY 才被 GC 跟踪:把 UObject* 写成普通 C++ 成员变量、不标 UPROPERTY,GC 可能回收它指向的对象,留下悬空指针——典型崩溃源。.generated.h 没更新就编 cpp,会出现”找不到类型”的诡异错误,通常清 Intermediate 重编可解。<Class>.generated.cpp,但根因是你头文件里的宏用错。读懂 UHT 错误需要这种翻译能力。把前面的原理串起来,UE 的一次完整编译是这样的。其中 Unity Build 是 UE 让编译飞快的关键优化。
1. UBT 解析命令行 (Target/Config/Platform)
2. UBT 读 Target.cs + 所有 Build.cs,算模块依赖图、拓扑排序
3. UBT 跑 UHT,扫描所有 UE 宏,生成 .generated.h/.gen.cpp
4. UBT 拼 Unity Build 文件(这一步是关键优化)
5. MSVC 编译每个 Unity 文件 → .obj
6. MSVC link 把 .obj 链成 DLL (Modular) 或大 EXE (Monolithic)
7. UBT 写 build.xml 缓存,记录本次编译的依赖指纹
C++ 的编译单元(translation unit)是一个 .cpp。普通构建里每个 .cpp 单独编,每个单元都要重复解析所有 include 的头文件(Engine.h、Core.h 等)——同一个 10000 行的头文件被几千个 cpp 各解析一遍,绝大部分时间浪费在重复解析。
Unity Build 把多个 .cpp 拼成一个大 cpp再编:
// UnityBuild-Engine-0.cpp (UBT 生成)
#include "Private/Actor.cpp"
#include "Private/World.cpp"
#include "Private/Level.cpp"
// ... 几十个 cpp 拼进来
拼成的大 cpp 编译一次,所有这些 cpp 共享一次头文件解析。原本要解析 50 次的头文件,现在解析 1 次。
| 普通 | Unity Build | |
|---|---|---|
| 头文件解析次数 | 每个 cpp 一次 | 一组 cpp 一次 |
| 编译单元数 | 数千 | 数十(每组一个) |
| 总编译时间 | 慢(大头开销在头文件) | 快(解析开销摊到几十个单元) |
实测 Unity Build 能让 UE 编译快 2-3 倍。这是 UE 之所以能在合理时间编完几百万行代码的关键之一。
不是没成本:
UBT 提供 bUseUnityBuild 开关,可关掉用普通构建(默认开)。
UBT 的现代实现更精细:
/MP 并行编译多个 cpp。Unity Build 后单元少,并行度可能下降,UBT 配合把 Unity 文件再切片喂给 /MP。#pragma once vs include guard:Unity Build 把多个 cpp include 同一个头,头必须有 include guard(#pragma once 或 #ifndef),否则重定义。编完会多出两个关键目录。理解它们的区别,才知道哪些可以删、哪些是成品。
Engine/Intermediate/(项目里也有 Intermediate/)放编译中间产物:
.obj:MSVC 编译每个 Unity/cpp 单元产生的目标文件。.generated.h / .gen.cpp,由 UHT 在编译前生成、再被 MSVC 编译。这些不在源码树里。.cpp:Unity Build 拼出来的大文件、动态生成的反射胶水代码。特征:又大又可重建。可以随时删除,下次构建会重新生成——所以它不进 Git(.gitignore 排除),不参与版本控制。
这就是为什么前面强调要留足磁盘:Intermediate 单独就要占一两百 GB,加上 Binaries 和源码,整个 UE 编译环境轻松 300GB+。
Engine/Binaries/Win64/(按平台分子目录)放最终链接产物:
UnrealEditor.exe:编辑器主程序(Development Editor 配置产出)。UnrealEditor-<Module>.dll:每个模块的 DLL(Modular 配置)。UnrealHeaderTool.exe:UHT 本体。UnrealBuildTool.exe:UBT 本体(实际是 .NET 程序)。ShaderCompileWorker.exe:Shader 编译器(见 B16)。这些是要执行/分发的成品。删了 Intermediate 但保留 Binaries,编辑器还能直接跑(只是下次改代码要重生成 Intermediate 才能编译)。
UE 根目录/
├── Engine/
│ ├── Source/ # 源码(Git 管理)
│ ├── Content/ # 美术资产(GitDependencies 下载)
│ ├── Binaries/ # 成品 exe/dll(编译产出)
│ │ └── Win64/
│ │ ├── UnrealEditor.exe
│ │ └── UnrealEditor-Core.dll ...
│ └── Intermediate/ # 中间产物(可删可重建)
│ ├── Build/ # .obj、Unity 文件、build.xml
│ └── ...
├── GenerateProjectFiles.bat
├── Setup.bat
└── UE5.sln # 由 GenerateProjectFiles 生成
Intermediate/Build 立即释放几十到上百 GB,下次编译重新生成(耗时但安全)。Intermediate 强制全量重建。.gitignore 自动排除 Intermediate 和 Binaries:源码仓库只跟踪 Source 和配置脚本,不跟踪编译产物。clone 后必须 Setup + GenerateProjectFiles + Build 才有 Binaries。Build/ 子目录:Intermediate/Build 里按 <Target>/<Platform>/<Config> 分层,删的时候找准目标,别误删别的配置。编完引擎双击 UnrealEditor.exe,可能撞上报错:“找不到 ShaderCompileWorker.exe”。原因:它是独立的 Target,编 UE5Editor 时不会自动编出来,要单独编一次。
ShaderCompileWorker 是一个独立的小程序,专门负责编译 shader。它是 UE 引擎里的一个 Program 类型 Target:
| Target 名 | Type | 作用 |
|---|---|---|
UE5Editor | Editor | 编辑器本体 |
ShaderCompileWorker | Program | 编译 shader 的辅助进程 |
为什么 shader 编译要独立进程?因为:
UE 的 Target 系统里,每个 Target 是独立的产物。UE5Editor Target 只编”编辑器要用的模块”,不自动编所有 Program。ShaderCompileWorker 是单独的 Target,命令行:
# 在开发者命令行里
Engine/Build/BatchFiles/Build.bat ShaderCompileWorker Win64 Development
这条命令让 UBT 编 ShaderCompileWorker Target,产出 Engine/Binaries/Win64/ShaderCompileWorker.exe。
不编它,编辑器启动时找不到这个工具进程,无法编译 shader,弹错。
要点破一个事实:引擎不是一个 exe,是一组程序。除了 UnrealEditor 和 ShaderCompileWorker,还有:
每个都是单独 Target,按需编。Setup 之后默认编译最常用的几个,但 ShaderCompileWorker 有时要手动。
新版 UE 安装脚本(Setup.bat / Build 流程)通常会预编译 ShaderCompileWorker 和几个关键 Program,避免用户撞这个错。但如果你只手动编了 UE5Editor,就有可能漏掉。修法就是显式编一次 ShaderCompileWorker Target。
Engine/Binaries/Win64/ShaderCompileWorker.exe 存在。你写的材质、HLSL 代码,最终是 GPU 上跑的 shader。它们要针对不同平台编译成字节码(DXIL、SPIR-V、Metal IR 等)。UE 用 ShaderCompileWorker 多进程并行编译,编完缓存进 DDC(Derived Data Cache,派生数据缓存)。
材质编辑器里的节点图、.usf/.ush 里的 HLSL,都不是 GPU 直接能跑的形式。流程:
材质节点图 / HLSL 源
→ UE 生成统一的高层 HLSL
→ 交给各平台 shader 编译器(FXC/DXC for DX、glslang for Vulkan、Metal 编译器等)
→ 编译成字节码(DXBC/DXIL/SPIR-V/Metal IR)
→ 驱动加载到 GPU
shader 数量爆炸:一个材质 × 不同渲染特性(光照模式、阴影、雾、LOD)× 不同平台(DX11/DX12/Vulkan/Metal/各主机)× 不同 shader 阶段(VS/PS/GS/CS)= 几万到几十万个 shader。这就是为什么首启编辑器要花很久——CPU 满载就是成千上万个 shader 在并行编。
每个 shader 字节码是”输入派生出的数据”。UE 不每次启动都重编,而是首次编完缓存进 DDC:
下次启动时,UE 用同样的键查 DDC:
也就是说,“首次编完缓存进 DDC,之后再启动直接复用缓存就快了”。慢只有第一次,很多人误以为”还在编译就是没成功”,其实缓存填满后就快了。
DDC 是个通用机制,任何派生数据都进:
| 派生类型 | 来源 | DDC 内容 |
|---|---|---|
| Shader 字节码 | HLSL 编译 | 平台 shader 字节码 |
| Texture mipmap/压缩 | 源贴图导入 | 平台格式贴图 |
| 静态光照 | Lightmass 烘焙 | 光照贴图 |
| 距离场 | 模型几何 | SDF / mesh distance field |
| NavMesh | 关卡几何 | 导航数据 |
DDC 的理念:派生数据是源数据 + 引擎版本的纯函数,源没变就能从缓存复用,不必每次重新生成。
UE 有多个 DDC 后端,按顺序查:
Engine/DerivedDataCache),最快。团队协作时配 shared DDC,能省掉每个人各自首次编译——这是大工作室的关键工程基础设施。
-NoShaderCompileWorker 调试:禁用 worker 强制单进程编 shader,便于排查 shader 编译错误(速度慢但日志清晰)。新建项目时选 C++ 而不是蓝图,关键区别:会生成一个游戏模块,它和引擎模块一样走 UBT 编译流程。
模板(如 FirstPerson)生成的工程结构:
MyProject/
├── MyProject.uproject # 项目描述文件(JSON)
├── Source/
│ ├── MyProject/
│ │ ├── MyProject.Build.cs # 游戏模块定义(关键)
│ │ ├── MyProject.h # 模块头
│ │ ├── MyProject.cpp # 模块实现 + IMPLEMENT_MODULE
│ │ ├── MyProjectCharacter.h/cpp # 模板代码(角色、武器等)
│ │ └── ...
│ ├── MyProject.Target.cs # Game Target(编 standalone exe)
│ └── MyProjectEditor.Target.cs # Editor Target(编进编辑器)
├── Config/
├── Content/ # 蓝图、美术资产
└── Intermediate/ # 编译中间产物(自动)
三个关键脚本的角色:
| 文件 | 作用 |
|---|---|
MyProject.Build.cs | 声明游戏模块依赖哪些引擎模块(见下) |
MyProject.Target.cs | 定义 Shipping/standalone 构建产出的 Target |
MyProjectEditor.Target.cs | 定义在编辑器里跑游戏的 Target(开发用) |
using UnrealBuildTool;
public class MyProject : ModuleRules
{
public MyProject(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[] {
"Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay"
});
}
}
游戏模块依赖 Core/Engine 等基础引擎模块,自己的 C++ 代码(角色、武器)就能用 AActor、UObject 这些类型。
游戏模块和引擎模块没有本质区别——都是 Module。点项目”创建/编译”时:
MyProject.Build.cs,算依赖(包括引擎模块)。MyProject.dll(Binaries/Win64/UnrealEditor-MyProject.dll),编辑器运行时动态加载。也就是说”选 C++ 会生成一个游戏模块,靠 UBT 编译”——和编引擎用的是同一套工具链、同一套模块系统。游戏模块无缝接入引擎。
.uproject 是项目的 JSON 描述:
{
"FileVersion": 3,
"EngineVersion": "5.7.0",
"Modules": [
{ "Name": "MyProject", "Type": "Runtime", "LoadingPhase": "Default" }
],
"Plugins": [
{ "Name": "SomePlugin", "Enabled": true }
]
}
EngineVersion:绑定的引擎版本(自编译引擎会写对应版本号)。Modules:项目包含的模块(标准是一个游戏主模块,可加更多)。Plugins:启用的插件(插件也是模块)。UE 用这个文件决定用哪个引擎打开项目、加载哪些模块和插件。
.uproject 里 EngineVersion 决定用哪个引擎。装了多套引擎(启动器版 + 自编译版),双击 uproject 可能用错的引擎。右键 uproject → “Switch Unreal Engine version” 切换。MyProject.Target.cs(独立运行)和 MyProjectEditor.Target.cs(编辑器内运行),少了某个对应配置编不了。关掉再打开项目,弹”MyProject 模块缺失,或和引擎版本不一致,是否 rebuild”。这背后是 UE 模块系统的二进制兼容性(ABI)绑定机制。
游戏模块(MyProject)在 Modular 配置下编成 UnrealEditor-MyProject.dll。这个 DLL 不是孤立的——它是针对特定引擎构建编出来的,和该引擎的 DLL 共享同一套二进制约定。
“二进制约定”包括:
AActor 加了一个字段,游戏 DLL 里访问 Actor->Health 的偏移就错了。AActor 加了一个虚函数,游戏 DLL 里调 Actor->Tick() 的槽位错了,调用崩溃。任何一项不匹配,加载 DLL 时轻则符号解析失败、重则运行时崩。
UBT/编辑器在加载项目模块前会校验模块和当前引擎是否匹配。校验方式:
不一致的情况:
Binaries 或没编过,编辑器找不到 DLL。任一情况,UBT 检测到就弹”是否 rebuild”。
点 Yes:编辑器调 UBT 重新编译游戏模块(针对当前引擎),生成新的匹配 DLL,加载它打开项目。这通常几十秒到几分钟(只重编游戏模块,不重编引擎)。
点 No:项目可能打不开、或打开后崩溃(因为模块对不上引擎)。
这种情况很正常——改引擎、换版本、清缓存都会触发,是正常流程的一部分,不是 bug。
这是 UE 模块化设计的代价:热重载和模块隔离要求模块是独立 DLL,而独立 DLL 又要求严格的 ABI 绑定。两套机制互相牵制:
商业插件分发时通常只给特定引擎版本的预编 DLL(如 “5.1 兼容”),跨版本要重编——根源就是 ABI 绑定。