前面几篇的剖析工具(stat、Insights、控制台变量)在 Shipping 包里一个都不剩,这不是阉割,是设计。这篇把"构建配置 → 编译宏 → 运行时行为"这条链讲透:你的开发包和玩家手里的发行包,跑的根本不是同一份代码。
四种构建配置
UE 的 TargetConfiguration 分四档:DebugGame(只优化引擎、游戏代码 -O0)、Development、Test、Shipping。Test 是 Shipping 的调试版——优化等级与 Shipping 相同,但保留了控制台、stat 命令和一部分调试设施,专门给压测和 QA 用。
同一份代码在四种配置下行为不同,靠的都是编译期宏。核心几个(定义都在 Runtime/Core/Public/Misc/Build.h 和 AssertionMacros.h,UE5.3):
UE_BUILD_DEBUG/DEVELOPMENT/TEST/SHIPPING:互斥的总开关,代码里大量#if以它为准;WITH_EDITOR:非编辑器目标一律为 0,整个 editor 模块直接不编译;DO_CHECK:为 1 时check/checkf展开成真正的断言,为 0 时退化成无副作用的空宏。定义为UE_BUILD_DEBUG || UE_BUILD_DEVELOPMENT || UE_BUILD_TEST || USE_CHECKS_IN_SHIPPING——注意 Shipping 里除非显式开USE_CHECKS_IN_SHIPPING,所有 check 全部消失;DO_GUARD_SLOW:checkSlow用的,比 DO_CHECK 更严格,只在 Debug;STATS:为 1 时STAT()宏展开成统计采集,stat全家桶可用;Shipping 里 STAT 全部空转。
check/ensure 在 Shipping 里变成什么
翻 AssertionMacros.h(UE5.3),check 的实现走 UE_ASSERT_CHECK_IMPL,整体包在 #if DO_CHECK 里;ensure 同样受 #if DO_CHECK 控制(注意它不是"更轻的 check",语义是"报一次错但继续跑",裁剪开关和 check 是同一个)。Shipping 下这几个宏展开后只剩表达式本身(ensure(expr) 退化为 !!(expr),只保留求值的副作用),断言逻辑、格式化、崩溃上报全部不存在。
这意味着两件事:
- 永远不要指望 check 在 Shipping 里抓错误。发布包里的空指针解引用直接崩,没有 callstack 提示。要保险就显式 if + UE_LOG,或者接受崩溃交给平台的 crash report;
USE_CHECKS_IN_SHIPPING=1(BuildConfiguration.xml 里开)能把断言装回 Shipping,用来定位"只在发行包复现"的崩溃很值,代价是每帧多一笔
$$T_{check} = N_{check/frame} \times c_{check}$$
$c_{check}$ 单个看着微小,但热路径上每帧几十万次预判的分支累积起来能到毫秒级。我们项目开过一次,CPU 帧时间涨了 6%,抓完崩溃源就关掉了。
stat 命令为什么消失
stat unit 的数据源是 STAT() 宏插桩。宏在 Stats/Stats.h 里定义,包在 #if STATS 中;而 STATS 在 Build.h 里定义为 Debug/Development/Test 三档为 1,Shipping 为 0。所以 Shipping 包里 stat 命令不存在不是控制台被藏了,是采集代码根本没编译进去。这也解释了为什么 stat 数字本身有开销(每帧这么多计数器和 TLS 往返),Test 包压测时要把这个因素算进去。
其余编译期差异
- 游戏代码优化等级:Development 默认游戏模块不优化(引擎模块带优化),Shipping/Test 全部 -O2/-O3。自己模块想强制优化,
Build.cs里OptimizeCode = CodeOptimization.Always; - RHI 校验层:
r.RHIValidation在非 Shipping 默认开,每次命令列表调用都过一遍校验,Draw 多时是个不小的固定开销。Shipping 整体关闭; - C++ 异常:多数平台 Shipping 默认禁用异常(
bDisableExceptions),错误路径走FPlatformMisc的封装,少了一套栈展开代码的体积和运行时成本; - 控制台:Shipping 里控制台整体编译掉(
ALLOW_CONSOLE宏),不是隐藏入口。CVar 仍然生效——从ConsoleVariables.ini读——但不能运行时敲; - 日志:UE_LOG 在 Shipping 保留,但高频日志的成本别无视,verbose 级别的还是会被求值参数。
开发包和发行包差多少
经验区间:CPU bound 场景下 Development → Shipping 帧时间差 10%–30%,来源按贡献排大致是:未优化的游戏代码 > check/ensure 与校验层 > STAT 采集 > 其余杂项。GPU 耗时几乎不变——这些开关全在 CPU 侧。所以"我开发包跑 60 帧,应该没问题"不构成任何结论;反过来, Shipping 包掉了帧,你也没有 stat 可用,排查得靠提前在 Test 包上复现,或者挂 Insights(trace 走独立通道,Shipping 可开 trace.enable)。
压测的正确姿势
| 用途 | 配置 |
|---|---|
| 日常开发迭代 | Development |
| 性能压测 / 发版前验证 | Test(同 Shipping 优化 + 有控制台和 stat) |
| 玩家手上的包 | Shipping |
PGO/LTO 这类链接期优化(bUsePGOProfile、ThinLTO)只在 Shipping/Test 有意义,且会拉长打包时间,按版本节奏开。
一句话总结
构建配置本质是"运行时行为 = 源码 × 编译宏"的乘法:开发包的慢有相当一部分是调试设施的开销,发行包的"黑盒"是裁剪的必然结果。把 Development 当性能结论、把 Shipping 当调试环境,是打包阶段最容易犯的两种错——各自的正确替代分别是 Test 包和提前规划好的 trace。