UE5.8 性能预算与自动化测试:一次合进主线的帧率滑坡
1. 周五晚上那封邮件
周五晚上七点,主策划老周在群里发了一段视频。画面是我们正在开发的开放世界射击项目,角色骑马穿过一片峡谷,镜头拉远到营地时帧率从 60 直接掉到 41,卡顿持续了整整八秒。视频标题只有一句话:这个版本还能上测试服吗?
那段代码是周四凌晨合进主线的。改动本身很小,只是把营地周围三十几盏灯从静态改成了可移动光源,方便美术调氛围。策划没多想就点了合并,QA 也没跑性能用例,结果周五下午打包出来的版本在 PS5 上直接翻车。
我们当晚开会复盘。美术说这是为了氛围,策划说需求排期紧,程序组的老张直接拍了桌子:问题不是这三十盏灯,是我们根本没有性能预算。如果早有一份写在 CI 里的预算表,这次改动连合并机会都不会有。
那之后两个月,我们把性能预算和自动化测试整条链路搭了起来。这篇文章就是把那段经历整理成可执行的方案。
2. 性能预算不是数字,是合同
很多团队把性能预算理解成一串目标数字:60 帧、4 GB 内存、100 MB/s 带宽。这些数字没错,但少了约束就毫无意义。性能预算更像是一份合同,规定了每个子系统在每一帧里能花多少时间、多少内存、多少 draw call。任何改动如果超了合同,要么改回去,要么从别的地方省出来。
UE5.8 项目里我们把预算分成四个账户:CPU、GPU、内存、带宽。每个账户再按子系统拆成小账户。比如 CPU 账户下面有 Game Thread、Render Thread、RHI Thread、Physics、Animation、AI;GPU 账户下面有 Base Pass、Lighting、Shadow、Post Process、Transparent;内存账户下面有 Texture、Mesh、Audio、Render Target、UObject;带宽账户下面有 Replication、Streaming、Texture Upload、Audio Streaming。
一份简化后的 60 帧主机预算可以写成:
这里 是 CPU 三线程中最慢的那个, 是 GPU 完成一帧绘制的时间。两者不是简单相加取最大,因为帧管线里有并行和串行重叠。实际评估时我们用的是 stat unit 里的 Frame、Game、Draw、GPU 四列,取 Frame 作为最终账单。
flowchart TD
subgraph budget ["性能预算总账"]
frame["Frame 16.67ms"]
cpu["CPU 账户"]
gpu["GPU 账户"]
mem["内存 账户"]
bw["带宽 账户"]
end
subgraph cpu_sub ["CPU 子账户"]
cgame["Game Thread"]
crender["Render Thread"]
crhi["RHI Thread"]
cphys["Physics"]
end
subgraph gpu_sub ["GPU 子账户"]
gbase["Base Pass"]
glight["Lighting"]
gshadow["Shadow"]
gpost["Post Process"]
end
frame --> cpu
frame --> gpu
frame --> mem
frame --> bw
cpu --> cgame
cpu --> crender
gpu --> gbase
gpu --> glight这张图不是装饰。我们把它打印出来贴在 CI 监控大屏旁边,每次构建失败就高亮超预算的那个账户。视觉效果很简单,但能让策划和美术第一时间理解:不是程序在卡流程,是某个数字真的破了红线。
3. CPU / GPU / 内存 / 带宽预算分配
预算分配没有标准答案,要看项目类型、目标平台、画面风格。我们项目是开放世界 PvE,目标 PS5 性能模式 60 帧,画质模式 30 帧。下面这张表是我们调了三个版本后定下来的主机性能预算,单位是毫秒和兆字节。
| 账户 | 子项 | 60 帧预算 | 30 帧预算 | 说明 |
|---|---|---|---|---|
| Frame | 总帧时间 | 16.67 ms | 33.33 ms | 含垂直同步容差 |
| CPU | Game Thread | 8.0 ms | 18.0 ms | gameplay、AI、动画逻辑 |
| CPU | Render Thread | 6.0 ms | 10.0 ms | 剔除、命令构建 |
| CPU | RHI Thread | 5.0 ms | 8.0 ms | 提交命令缓冲 |
| GPU | Base Pass | 5.0 ms | 12.0 ms | 不透明几何体 |
| GPU | Lighting + Shadow | 4.5 ms | 10.0 ms | Lumen + VSM |
| GPU | Post Process | 2.0 ms | 4.0 ms | TSR、Bloom、Tonemap |
| 内存 | Texture Streaming Pool | 1400 MB | 2200 MB | r.Streaming.PoolSize |
| 内存 | Render Target | 350 MB | 550 MB | 含 RT 和深度缓冲 |
| 内存 | UObject + GC | 600 MB | 900 MB | 反射对象与垃圾回收 |
| 带宽 | Replication | 80 KB/s | 120 KB/s | 服务器下行 |
| 带宽 | Texture Streaming | 40 MB/s | 60 MB/s | 磁盘到显存 |
表里的数字不是拍脑袋。Game Thread 的 8 毫秒来自我们对关键帧的 Unreal Insights 采样:正常战斗时 gameplay 逻辑平均 5.2 毫秒,留出 2.8 毫秒给突发峰值。Texture Streaming Pool 的 1400 MB 来自主机厂商给的建议值和我们实际测试的 OOM 边界之间的折中。
GPU 预算分配我们用的是一个更细的模型:
其中每一项都可以通过 stat gpu 拿到。每次美术提交新关卡,CI 都会跑一遍固定相机路径,把这张表的每一项填出来,和预算表做对比。某个子项超了 10% 就会标黄,超了 20% 直接标红失败。
内存预算最容易被低估。UE5.8 的 Nanite、Lumen、Virtual Shadow Maps 都很吃显存。我们定了一个原则:任何纹理导入时必须带 Max Texture Size 和 LOD Bias 策略,任何 mesh 超过两万面必须打 LOD。这些规则写进了项目设置和资产检查脚本,不合规的资产在提交时就会被拦住。
带宽预算主要针对联机部分。Replication 的 80 KB/s 是策划和程序一起定的:每帧同步的玩家状态、动画状态、子弹轨迹、技能事件加在一起不能超这个数。我们用 dumpticks 和 Network Profiler 抓基线,再把这些数据写进自动化测试的断言里。
4. 自动化性能测试:从人工抽查到流水线
在那次灯光回归之前,我们的性能测试主要靠人工。QA 拿到包后跑几个关卡,用 stat 命令看一眼,没问题就过。这种方式有几个致命缺陷:覆盖不全、无法对比、反馈太慢、依赖个人经验。
自动化性能测试的核心思路是:把人工跑的那一套脚本化、可重复、可对比。每次提交或每晚定时触发,机器自动启动游戏、加载指定关卡、按固定路径运行、采集性能数据、和基线对比、生成报告。
flowchart TD
A["代码或资产提交"] --> B{触发条件}
B -->|"每日构建"| C["CI 触发性能测试"]
B -->|"关键路径变更"| C
C --> D["启动 UE 自动化测试"]
D --> E["加载基准关卡"]
E --> F["运行固定相机路径"]
F --> G["采集 stat unit / stat gpu / stat memory"]
G --> H["采集 Unreal Insights trace"]
H --> I["解析数据并写入数据库"]
I --> J{是否超预算}
J -->|"否"| K["生成通过报告"]
J -->|"是"| L["生成失败报告并告警"]
L --> M["阻塞合并或通知负责人"]这个流水线里最麻烦的不是脚本本身,而是稳定性。游戏启动、加载关卡、运行相机路径、采集数据,每一步都可能因为窗口焦点、分辨率、GPU 驱动、后台进程而抖动。我们花了两周时间做环境隔离:关掉 Windows 更新、禁用输入法弹窗、固定显示器刷新率、用 -windowed -ResX=1920 -ResY=1080 锁分辨率、用 -nosplash 跳过启动画面。
UE5.8 自带一套自动化测试框架,核心命令是 UE5Editor-Cmd.exe 的 -RunAutomationTest。但性能测试通常不需要那么重的框架,我们直接用蓝图或 Python 脚本驱动游戏进程。下面这条命令是我们 nightly build 里真正在跑的:
UE5Editor-Cmd.exe "D:\Project\Game.uproject" \
-game -windowed -ResX=1920 -ResY=1080 -nosplash \
-trace=cpu,frame,gpu,memory,counters \
-benchmark="Benchmark_Canyon_Camp" \
-benchmarkduration=120 \
-output="D:\Builds\PerfResults\$(BuildId)"参数 -benchmark 指向一个蓝图关卡,里面放了一条 Spline 作为相机路径,一盏定向光模拟真实运行时的太阳角度,几处特效触发点。benchmarkduration=120 表示跑 120 秒,覆盖进场、战斗、离场三个阶段。-trace 开的 channel 足够把 CPU、GPU、内存和计数器全抓下来。
5. CI 集成:让预算成为门禁
自动化测试如果只跑不拦,等于没跑。我们的目标是把性能预算变成 CI 门禁:任何导致性能回归的提交,必须先在构建阶段失败。
我们在 Jenkins 里建了一条 Pipeline,每天晚上十点拉取主线,Cook 一个 Development 包和一个 Test 包,分别跑 Editor 内基准和打包后基准。Development 包跑得快,用于快速反馈;Test 包接近发行版本,用于最终判定。
sequenceDiagram
participant Dev as "开发者提交"
participant Git as "Git 仓库"
participant CI as "Jenkins Pipeline"
participant UE as "UE5.8 构建机"
participant DB as "性能数据库"
participant Alert as "告警通道"
Dev->>Git: push 到 feature 分支
Git->>CI: 触发 PR 构建
CI->>UE: 拉取代码 + Cook Development
UE-->>CI: 返回构建结果
CI->>UE: 运行基准性能测试
UE->>UE: 采集 stat / Insights
UE->>DB: 写入本次性能数据
DB->>DB: 与基线对比
DB-->>CI: 返回是否超预算
alt 超预算
CI->>Alert: 发送 Slack / 邮件
Alert->>Dev: @相关开发者
CI-->>Git: 标记构建失败
else 通过
CI-->>Git: 标记构建成功
endCI 门禁有两个层次。第一层是硬门禁:任何关键指标超过预算红线,构建直接失败。第二层是软门禁:连续三个版本同一指标缓慢上涨,即使没超红线也发出预警。这种慢性衰退最难防,因为单次改动看起来都很小,积累两个月就会发现帧率掉了七八帧。
我们在 Jenkinsfile 里定义了预算阈值,用 JSON 文件管理,方便策划和 TA 一起评审:
{
"budget": {
"frame_time_ms": 16.67,
"game_thread_ms": 8.0,
"render_thread_ms": 6.0,
"rhi_thread_ms": 5.0,
"gpu_time_ms": 16.0,
"texture_pool_mb": 1400,
"replication_kbps": 80
},
"tolerance": {
"yellow": 1.10,
"red": 1.20
}
}每次测试跑完,Python 脚本会读取这个 JSON,把实际值和预算值做除法。结果落在 1.0 到 1.1 之间是绿色,1.1 到 1.2 是黄色,超过 1.2 是红色。红色直接失败,黄色在报告里高亮但不阻塞。
6. 性能回归检测:怎么知道这次提交有没有坏事
回归检测的关键是基线。没有稳定的基线,所有对比都是噪音。我们的基线不是单一数字,而是每个指标在最近十个成功构建上的滑动平均值,再剔除异常值。
假设某次测试的帧时间为 ,最近十个成功构建的帧时间均值为 ,标准差为 。我们定义回归阈值为:
超过这个值就认为是显著回归。为什么是 ?因为自动化测试的环境抖动大致符合正态分布, 能覆盖 99.7% 的正常波动,误报率足够低。如果数据不服从正态分布,可以用分位数方法,比如第 95 百分位。
实际运行中我们发现,GPU 时间比 CPU 时间稳定得多,因为 GPU 工作负载相对确定;CPU 时间受后台进程和 GC 影响,抖动较大。所以 CPU 相关指标我们用了更宽的 ,GPU 用 。
回归检测的另一个维度是帧时间分布。平均帧时间可能没涨,但 99 百分位帧时间可能涨了。玩家感受到的卡顿往往来自那 1% 的尖峰。所以我们除了平均值,还监控 P95、P99、Worst Frame。
graph LR
A["单次构建性能数据"] --> B["计算均值 / P95 / P99 / 最差帧"]
B --> C["与滑动基线对比"]
C --> D{差异是否超阈值}
D -->|"是"| E["标记为回归"]
E --> F["定位变更文件"]
F --> G["通知最近提交者"]
D -->|"否"| H["更新基线窗口"]
H --> I["进入下一次构建"]定位变更文件是回归检测里最有价值的环节。Jenkins 每次构建都知道这次包含了哪些 Git 提交。如果某次构建失败,我们可以把范围缩小到这几个提交里。配合性能数据的时间线,还能进一步定位到具体关卡、具体资产、具体 Blueprint 节点。那次灯光回归如果当时有这套系统,最多两小时就能定位到三十盏可移动光源,而不是让整个组周五晚上加班。
7. 基准测试与对比:让优化有据可依
基准测试(Benchmark)不是跑一遍就完事。好的基准测试必须具备三个属性:可重复、可代表、可对比。
可重复是指同样的输入必须产生同样的输出。我们在 UE 里用 Sequence Recorder 把相机路径、角色输入、技能释放时机全部录制下来,每次测试用同一套录制文件回放。这样不同构建之间的时间线才对齐。
可代表是指基准场景必须覆盖项目的性能热点。我们做了四张基准地图:峡谷营地、主城广场、森林遭遇战、地下副本。每张地图对应不同的瓶颈:营地考察 GPU 和阴影,主城考察 CPU 和 draw call,森林考察植被和透明度,副本考察特效和联机同步。
可对比是指结果必须以统一格式存储。我们把每次测试输出成一个 CSV,列包括构建号、时间戳、关卡名、平均帧时间、P95、P99、Game Thread、Render Thread、RHI Thread、GPU Time、Texture Pool、Replication Bandwidth。所有 CSV 汇总到 InfluxDB,Grafana 出趋势图。
import pandas as pd
from pathlib import Path
def compare_builds(new_csv: Path, baseline_csv: Path, budget_json: Path):
new = pd.read_csv(new_csv)
base = pd.read_csv(baseline_csv)
metrics = ['frame_time_ms', 'game_thread_ms', 'gpu_time_ms',
'texture_pool_mb', 'replication_kbps']
for m in metrics:
delta = new[m].mean() - base[m].mean()
pct = delta / base[m].mean() * 100
print(f"{m}: {new[m].mean():.2f} vs {base[m].mean():.2f} ({pct:+.1f}%)")这段脚本只有十几行,但每天晚上都在跑。它把本次构建和上周基线做对比,输出每个指标的涨跌百分比。数据落到 Grafana 上,每个人都能看见自己的提交让帧时间涨了还是跌了。
8. Unreal Insights 自动化采集
stat 命令适合快速看数,但定位根因必须靠 trace。Unreal Insights 的 .utrace 文件在自动化场景下也能采集,只是需要一些额外配置。
UE5.8 支持通过命令行直接启动 trace,也支持在运行时通过 HTTP 控制。我们选择在 CI 里用命令行方式,最简单稳定:
# Development 包直接本地落盘
MyGame.exe -trace=cpu,frame,gpu,memory,counters,log,file
# 指定 trace 文件前缀和输出目录
MyGame.exe -trace=cpu,frame,gpu,memory,file \
-tracefile="D:\Builds\Traces\$(BuildId).utrace"自动化采集会遇到一个问题:trace 文件可能很大。一次 120 秒的测试,开全 channel 能生成 2 GB 以上的 .utrace。如果每次构建都存,磁盘很快就满。我们的做法是只保留失败构建的完整 trace,成功构建只保存聚合后的 CSV 指标。
另一个问题是 Insights 前端本身不能自动化分析。我们用了 Epic 开源的 UnrealInsights 命令行导出功能,把 trace 里的 timing 数据导成 CSV:
UnrealInsights.exe "D:\Builds\Traces\20260715.utrace" \
-export="D:\Builds\Traces\20260715_timing.csv" \
-exporttype=timing导出后的 CSV 按 Scope 聚合,包含每个函数的调用次数、总耗时、平均耗时、最大耗时。Python 脚本再按自定义规则扫描,比如找出平均耗时超过 0.5 毫秒的 Blueprint 节点、调用次数超过 100 的 Tick 函数、GC 暂停超过 20 毫秒的帧。
我们还做了一个小工具,把 trace 里的 GPU 时间按 RenderPass 拆分,输出成和预算表对应的格式。这样每次构建失败,邮件里直接给出超预算的 RenderPass 名字,省去了人工打开 Insights 的时间。
9. 报告与告警:让数据自己说话
自动化测试产生的数据量很大,如果全部堆到群里,很快就被无视。报告必须分层:摘要给领导看,详情给程序看,原始数据给需要深挖的人看。
我们的报告分三级:
- 一级是构建结果卡片。Slack 机器人推送一个方块,显示构建号、关卡、结果颜色、最关键的三个指标变化。绿色表示全部通过,黄色表示有指标接近预算,红色表示有指标超预算或被判定回归。
- 二级是邮件报告。每晚构建结束后发给相关组和主策划,包含四张基准地图的帧时间趋势图、CPU/GPU/内存/带宽四张账户表、本次失败项的详细说明。
- 三级是 Grafana 仪表盘。任何人可以点进去看历史曲线、对比任意两次构建、下钻到单个指标。
告警规则我们写得很克制。只有以下情况才发即时告警:任一指标超红色阈值、任一指标连续三次黄色、99 百分位帧时间比基线涨 20% 以上、OOM 或崩溃。其他情况只进日报,不打扰。
报告里最有用的是「变更关联」。每次告警会附带最近三次 Git 提交列表,以及这些提交改到的关卡、蓝图、材质、C++ 模块。这个信息大大缩短了定位时间。那次灯光回归如果当时有这条,邮件里会直接列出光源相关改动,策划看一眼就知道该回滚。
10. 真正能用起来的三个教训
这套系统搭完之后,我们总结了三条教训。
第一,预算要先定,后执行。很多团队是等项目后期才想起来做预算,那时已经积重难返。我们的预算表从立项三个月后就定了一版,虽然后面一直在调,但大框架没变。越早定,越早养成遵守预算的习惯。
第二,自动化测试必须稳定。一个不稳定的测试流水线会消耗大量信任。我们早期有次测试因为 Windows 自动更新弹窗导致失败,整整一周大家看到红色构建就以为是环境问题,真正的问题反而被忽略了。后来我们把构建机做了完整的环境固化。
第三,数据要共享。性能不是程序组一家的责任。把帧时间、内存、带宽数据公开给策划、美术、QA,大家才能在讨论需求时就做出合理判断。我们每周五下午开一个十五分钟的性能站会,只看 Grafana 上的曲线,谁的数据涨了谁负责解释。
那次周五晚上的邮件之后,我们的营地场景帧时间从 41 帧回到了 59 帧。三十盏可移动光源被拆成八盏关键光和二十二盏 baked 静态光,美术想要的氛围保住了,性能预算也守住了。更重要的是,从那以后类似的改动再也没能偷偷合进主线。