第18章 · 打磨与优化:从能跑到跑得快

📑 目录

导语:验收前一周,「回响峡谷」在 RTX 3080 / 4K 下从 60fps 掉到 45。我认定是第 17 章刚上线的 PCG 森林把 Draw Call 打爆了,砍了三天树,Draw Call 从 3800 压到 2100,帧率从 45 涨到 45.3。第四天早上打开 Unreal Insights,时间轴上最刺眼的罪魁祸首是一条 220ms 着色器编译尖峰——每次开门必现,渲染反而安安静静。三天的树白砍了,PCG 规则还得回滚。这种学费每个性能工程师都交过,区别只在于交一次还是按年续费。

本章地图

mindmap
  root((第18章 打磨与优化))
    第一戒律
      先测量 别猜
      三个动手前必答的问题
      Amdahl 劝退线
    Insights 三视图
      CPU 时间轴
      GPU 各 Pass
      内存曲线
      健康帧对照病态帧
    瓶颈画像四连
      Draw Call 爆炸
      着色器编译卡顿
      物理过度模拟
      动画线程拥堵
    预算制度
      16.6ms 工资表
      内存分饼
      收益乘工作量取舍
    平台分档
      主机 高端PC 入门PC
      自动降级防抖动
    达标实战
      22ms 到 15.9ms 战报

18.1 直觉在性能问题上的命中率,不如抛硬币

先把那次翻车复盘完。当时我看到的现象是"45fps",脑子里立刻跳出的解释是"树太多"。这个因果链看起来天衣无缝:第 17 章的 PCG 管线刚铺完峡谷南侧的森林,同一段时间帧率掉了,第一嫌疑人舍它其谁?

然而凶手另有其人。45fps 是平均帧率,而平均帧率会说谎。22ms 的稳态帧是 45fps,18ms 稳态加每十几秒一条 220ms 尖峰也是 45fps。这两种病吃完全不同的药:前者要砍渲染,后者只需要把着色器编译从玩家必经之路上挪走。我在第一种诊断下干了三天,病却在第二种里。

先明确一个世界观再谈规则:帧率是产品指标。技术指标只是它的影子——玩家说不出什么叫 Named Event,但手感的黏滞、开门的停顿、战斗高潮的那一下幻灯片,他全都记得住。性能工作修复的,是玩家对游戏的信任。数字只是便于追踪的替身。

这件事的教训值三条规则。

一、平均帧率只能帮你挂个号。确诊得看帧时间曲线和直方图。 先分清稳态超标还是尖峰超标,再谈修什么。

二、你写的代码很少是你以为的那种慢法。 算法课教的"贵"是循环和递归,游戏帧时间的"贵"是缓存未命中、线程互相等待、驱动状态切换、每帧几百次小内存分配。盯着代码行数猜瓶颈,命中率不如抛硬币。

三、没抓数据就改代码,等于没看日志就回滚数据库。抓一次 30 秒的 Insights 只要一杯咖啡的时间,比三天返工便宜三个数量级。

动手之前,先用 stat unit 回答三个问题:瓶颈在 CPU 还是 GPU(Game 高是逻辑侧,GPU 贴着 Frame 是渲染侧)?稳态还是尖峰?超标部分占帧时间的比例是多少?第三个问题决定了值不值得修。而判断"值不值得"有一把数学尺子——平均帧率最多就是个分诊台,诊断书要靠曲线和直方图来写。

Amdahl 定律:优化收益的天花板

设被优化部分占帧时间的比例为 pp,你把它加速了 ss 倍,整体加速比 SS 为:

S=1(1p)+psS = \frac{1}{(1-p) + \dfrac{p}{s}}

哪怕你把这部分优化到零耗时(ss \to \infty),整体收益也收敛于 Smax=11pS_{max} = \dfrac{1}{1-p}

代入数字感受一下。背包 UI 重绘占帧时间 5%(p=0.05p = 0.05),收益天花板是 Smax=1/0.951.053S_{max} = 1/0.95 \approx 1.053,也就是 5.3%。22ms 的帧,把它删干净了也只能到 20.9ms,离 16.6ms 的目标还差 4.3ms。同一段时间拿去砍占帧时间 40% 的渲染,哪怕只砍一半,省下 4.4ms,直接达标。同样是三天工作量,收益差出八倍。

所以预算纪律里有一条劝退线:占比不足 10%、又不是尖峰元凶的系统,默认不碰。Amdahl 定律不反对优化,它反对的是不看占比的优化。

再补一刀机会成本:工程师一周的时间同样是团队预算里的一格工资,花在 5% 收益的系统上,等于主动放弃了另一个 40% 收益系统的修复窗口。优化排期和资源排期,从来都是同一张表。

整个工作流是一张循环图。一次性冲刺?醒醒,那连热身都算不上:

flowchart TD
    A["测量:stat unit + Insights 抓取"] --> B["定位:哪条线程、哪个系统、稳态还是尖峰"]
    B --> C["假设:写下一个可证伪的假设"]
    C --> D["最小修复:一次只改一件事"]
    D --> E["复测:同路线同场景,三次取中位数"]
    E --> F{达标?}
    F -- 否 --> A
    F -- 是 --> G["写入回归:CI 性能门禁 + 更新预算表"]
    D -.->|改了还慢,假设错了| B

注意两个细节。一次只改一件事,改三件就说不清哪件起作用;"复测三次取中位数"是因为机器状态会飘,单次数据没有法律效力。

测量环境同样有纪律:固定硬件、固定跑图路线、固定画质档,三者缺一个,两次数据就没有可比性。关掉 Steam 和 Discord 的覆盖层,它们会往帧里注入自己的开销;先热机两分钟再测稳态,冷启动数据里全是编译与加载的噪声;笔记本务必插电源,电池模式的功耗墙会让 CPU 降频,测出来的瓶颈是假瓶颈。

18.2 Unreal Insights:CPU、GPU、内存各看哪里

stat unit 是体温计,Unreal Insights 是核磁共振。前者花两秒告诉你烧不烧,后者花五分钟告诉你病灶在哪。抓取流程:游戏内控制台执行 trace.start cpu,gpu,frame,bookmark,玩 30 秒有代表性的场景,trace.stop 得到一个 .utrace 文件,用引擎目录下的 UnrealInsights.exe 打开。内存追踪开销大,单独跑一轮 trace=memory,别和帧时间测量混在同一轮互相污染。

还有两个老手习惯:尽量在打包版上抓,编辑器本身要吃掉 2~3ms,会扭曲判断;命令行加 -statnamedevents,Named Events 才够细。

整条数据链路是这样走的:

sequenceDiagram
    participant P as 游戏进程(打包版)
    participant T as Trace 服务
    participant D as 磁盘 .utrace
    participant I as UnrealInsights
    P->>T: trace.start 后持续推送事件流
    T->>D: 异步落盘为 .utrace 会话文件
    P->>T: trace.stop 收尾并刷新缓冲
    I->>D: 打开会话文件并建索引
    Note over P,T: 事件流低开销异步传输
    Note over P,T: 抓取本身对帧时间影响约 2%

抓取开销在 2% 量级,不至于把病灶压没。这一点比不少第三方采样器强,那些工具自己常常就是最大的噪声源。

30 秒速查面板

抓 Insights 之前,这组控制台命令能完成 80% 的分诊,建议背下来:

stat fps              ; 帧率与帧时间
stat unit             ; Game / Draw / GPU / Frame 四行,分诊第一步
stat unitgraph        ; 帧时间历史曲线,看稳态还是尖峰
stat scenerendering   ; draw call、三角形、可见实例数
profilegpu            ; GPU 各 Pass 一次性快照
stat anim             ; 动画更新耗时
stat physics          ; Chaos 解算耗时
stat memory           ; 内存总量与流送池
stat streaming        ; 纹理流送状态
obj list class=Texture2D   ; 谁吃了纹理内存
r.ScreenPercentage 75 ; 快速验证分辨率缩放收益
t.MaxFPS 60           ; 锁帧做对照实验
trace.start cpu,gpu,frame,bookmark / trace.stop

CPU 时间轴:三条线程的接力赛

Timing 面板的主体是 Game、Render、RHI 三条线程泳道,外加每帧的标记线。健康帧像一场交接顺畅的接力:Game 在 6.8ms 交棒,Render 在 12.5ms 交棒,GPU 在 15.9ms 冲线,谁都不贴 16.6ms 的红线。

gantt
    title 一帧的 Insights CPU 时间轴(健康帧,16.6ms 预算内)
    dateFormat HH:mm:ss.SSS
    axisFormat %S.%L
    section Game 线程
    逻辑与物理 Tick        :00:00:00.000, 00:00:04.000
    动画更新               :00:00:04.000, 00:00:05.500
    提交渲染命令           :00:00:05.500, 00:00:06.800
    section Render 线程
    场景遍历与 Draw 提交   :00:00:06.800, 00:00:12.500
    section RHI 线程
    命令转译并提交 GPU     :00:00:12.500, 00:00:14.200
    section GPU
    本帧渲染执行           :00:00:08.000, 00:00:15.900

病态帧有三种面相,对应三种病:

  • 一根独苗:某个 Named Event 长条独占,比如 FAnimInstance 更新一根 5ms 的条——单系统失控,去 18.3 查对应画像。
  • 密集锯齿:几十根碎条积少成多,通常是每帧小分配加 GC,或者大量 Actor 各自 Tick。
  • 线程空白:Game 早早干完,Render 干等,或者 GPU 空转等 CPU 喂——等锁、等流送、等同步,瓶颈在"等"而不在"算"。
指标健康帧病态帧
Game 线程6~8ms,各系统分段均匀单条超过 4ms,或整体贴满 16.6ms
Render / RHI与 Game 交错接力,无大片空白长空白(等 Game 喂数据或等 GPU 帧同步)
帧标记线间距均匀,约 16.6ms 一格间距抖动,偶发孤立长帧
Named Events最长条 < 1.5ms一根条吃掉整个系统的预算

下钻有两个手法能省一半时间。框选一段帧范围,Insights 会给出范围内每个 Named Event 的 Count、Total、Median、Max——对账预算用 Median,抓尖峰用 Max,别拿 Max 去填预算表,那是拿极端值定常态工资。另一个是 bookmark 通道:在代码里埋自定义书签(战斗开始、进入教堂、开门),时间轴上会出现对应的标记线,爆发帧与普通帧分开统计,不用肉眼大海捞针。

GPU 视图:按 Pass 对账

GPU 时间轴把一帧拆成 ShadowDepths、BasePass、Lumen、Translucency、PostProcessing 这些 Pass,每个 Pass 一根条。「回响峡谷」4K/60fps 目标的 GPU 预算是 9ms,健康分布大致是:BasePass 3.0ms、Lumen 2.5ms、ShadowDepths 1.2ms、半透明 0.6ms、后处理 1.4ms,加起来留一点零头。

Pass健康帧病态帧的典型长相
ShadowDepths1.2ms5.8ms:远距植被全部投阴影,VSM 页表爆炸
BasePass3.0ms6ms+:材质指令数失控或三角形密度过高
Lumen2.5ms6.8ms:4K 下开硬件光追,追踪成本随分辨率平方涨
Translucency0.6ms3.9ms:雨夜特效 Overdraw 爆炸(见第 13 章)
PostProcessing1.4ms3ms+:胶片颗粒、晕影全开还叠了自定义 Pass

一个读法提醒:GPU 时间轴和 CPU 提交之间存在错位,GPU 忙不等于 CPU 可以加码。看到 GPU 贴满,处方是减 GPU 的活——把逻辑挪到异步线程无非是换了一条路继续堵。

GPU 超标先做 30 秒的分辨率缩放实验:r.ScreenPercentage 50 把内部分辨率砍半,帧时间如果几乎同比大降,病灶在像素侧——填充率、Lumen、半透明、后处理;如果几乎不动,病灶在几何与提交侧——三角形密度、Draw 数量、场景遍历。一条命令把 GPU 瓶颈分成两大门派,再决定翻哪本处方。

内存视图:曲线的形状比数值重要

Memory Insights 的主视图是内存随时间的曲线,配合 LLM(Low Level Memory)标签可以看到每一块内存归哪个系统。健康曲线的形状:阶梯式上升后进入平台期,GC 造成的锯齿规律出现,流送进出平衡——进峡谷升 200MB,出峡谷降回 190MB 左右。

曲线形状健康 / 病态含义
阶梯上升后平稳健康流送正常加载并驻留
单调爬升不回头病态泄漏或流送只进不出,迟早撞墙
锯齿又密又深病态每帧大量临时分配,GC 频繁,周期卡顿
直角尖峰病态大块资源走了同步加载,伴随帧尖峰

内存问题和帧时间问题会互相伪装。显存接近打满时,驱动会把资源往系统内存倒,GPU 时间莫名其妙翻倍。帧时间查不出原因的时候,先看内存曲线是不是已经贴着上限。

自带探针:让系统学会自我指认

引擎自带的 Named Events 覆盖不到你的游戏逻辑。在可疑系统里埋探针,Insights 时间轴上就会出现属于你的一根条,排查时不用猜:

DECLARE_STATS_GROUP(TEXT("ECInventory"), STATGROUP_ECInventory, STATCAT_Advanced);

void UECInventoryComponent::RebuildItemGrid()
{
    // 进 Insights 时间轴(Named Event),也进 stat 组
    TRACE_CPUPROFILER_EVENT_SCOPE(EC_RebuildItemGrid);
    SCOPE_CYCLE_COUNTER(STAT_EC_RebuildGrid);

    for (FItemSlot& Slot : Slots)
    {
        TRACE_CPUPROFILER_EVENT_SCOPE(EC_SortSlot);
        Slot.Resort();
    }
}

两个宏的分工:TRACE_CPUPROFILER_EVENT_SCOPE 进 Trace/Insights 通道,SCOPE_CYCLE_COUNTER 进传统 stat 统计。5.8 中对应官方文档的 Trace 与 CpuProfiler 词条,宏的具体变体以文档为准。埋点本身有微秒级开销,发布版本会被自动剔除,可以放心留在代码里。

18.3 四种常见瓶颈的画像:症状、工具、处方

分诊有了,接下来是专科门诊。下面四张画像覆盖了「回响峡谷」开发期 80% 的性能工单,每张按症状、工具、处方开药。这张决策树是入口:

flowchart TD
    S["stat unit 四行"] --> Q1{Frame 贴着谁?}
    Q1 -->|贴 GPU| G["profilegpu 看各 Pass"]
    Q1 -->|贴 Game| C["Insights CPU 时间轴下钻"]
    Q1 -->|贴 Draw| D["stat scenerendering 看 draw 数"]
    G --> G1{哪个 Pass 超支?}
    G1 -->|ShadowDepths| P1["阴影:砍远距投送,调 VSM"]
    G1 -->|Lumen| P2["Lumen:软追踪或降质量"]
    G1 -->|Translucency| P3["Overdraw:压特效层级"]
    G1 -->|BasePass| P4["材质指令数或三角形密度"]
    C --> C1{最长的 Named Event}
    C1 -->|动画| P5["URO 降频 + BudgetAllocator"]
    C1 -->|物理| P6["休眠 + 降迭代 + 池化"]
    C1 -->|孤立尖峰| P7["PSO 编译:预热清单"]
    D --> P8["合批、实例化、HLOD、剔除"]

画像一:Draw Call 爆炸

症状stat unit 里 Draw 线程 12ms 以上,GPU 却有富余——典型的"CPU 喂不饱 GPU"。stat scenerendering 显示 Mesh draw commands 三千起步。

工具stat scenerendering 看数量级;FreezeRendering 冻结视锥后转相机,排查"看不见却在画"的漏网之鱼;Insights 里 Render 线程的遍历段长度。

处方:同类小物件合并成 ISM/HISM 实例化组件,一片废墟从 400 次 draw 变 1 次;静态网格能开 Nanite 的开 Nanite,让簇管线接管(边界见第 7 章);远景走 HLOD;给小物件设合理的剔除距离。主流场景把 draw commands 压到 2000 以内,数量级因项目而异,但"Render 线程 < 4ms"这条线是硬的。实测对照:「回响峡谷」教堂废墟 412 个碎石块 Actor 改成 HISM,同机位 draw commands 从 2600 掉到 1180,Render 线程从 6.1ms 降到 2.9ms——这是本章所有修复里性价比最高的一次,工作量只有半天。

画像二:着色器编译卡顿

症状:首次进入新区域、某个特效第一次出现、第一次开门——一条 100~300ms 的孤立尖峰,第二次路过不再现。打包版比编辑器明显,因为编辑器后台编译一直在偷偷帮你热身。

工具:Insights 帧时间轴上的孤立尖峰形态是招牌;输出日志里 LogShaderCompilers 的编译记录对得上时间点;PSO 相关统计确认 miss 数。

处方:PSO 预缓存——打包时收集管线状态缓存,加载界面或流送间隙里预热,把编译从"玩家看到的那一刻"挪到"玩家没看到的时候"。更根本的一手是收敛材质变体:开关排列组合爆炸才是 PSO 数量失控的根源——10 个二态静态开关全排列就是 1024 个变体,每多加一个开关变体数翻倍,材质评审时先问一句"这个开关真的需要运行时切换吗"。异步编译相关 CVar 保持默认开启。5.8 中具体机制以官方文档 Pipeline State Cache 词条为准。

画像三:物理过度模拟

症状stat physics 里 Chaos 解算 3ms 以上;场面越乱帧率越低;布料、碎块、载具一多,Game 线程跟着抖。

工具stat physicsp.Chaos.DebugDraw 系列可视化当前活跃的刚体;Insights 物理通道的 Named Events。

处方:离屏和远距刚体强制休眠,睡着的物体不解算;布料只在镜头视锥内模拟;破坏碎块池化并设上限,超出就回收最老的;解算迭代次数按平台降档;能烘焙成动画的物理演出就别实时解。原则一句话:玩家看不到的物理,一帧都不该算。「回响峡谷」教堂穹顶崩塌的演出原先实时解算 60 块碎块,爆发帧物理段 4.7ms;改成关键帧烘焙的破坏动画后降到 0.2ms,观感差别要逐帧截图才找得到。实时物理的尊严,不该由帧率买单。

画像四:动画线程拥堵

症状stat anim 居高不下;12 个 NPC 在场时 Game 线程动画段 5ms 以上,而主角自己只占 0.5ms——群众演员比主角还贵。

工具stat anim;Insights 里 FAnimInstance 相关长条;Animation Budget Allocator 插件的统计面板。

处方:URO(Update Rate Optimization)按距离降频,30 米外的 NPC 跑 15Hz 人眼分辨不出;AnimBudgetAllocator 把动画更新按帧均摊,削平集体同步更新造成的浪涌;动画多线程评估保持开启;远处的群众演员从 Motion Matching 退回简单状态机,MM 的搜索成本只值得花在镜头焦点上(成本账见第 12 章)。降频的感知边界要自己标定:15Hz 在 30 米外无感,挪到 5 米内就露馅——脚步滑移和节奏迟滞会被核心玩家一眼看穿。URO 的距离分档要配合动画 LOD 一起调,贴脸的主角和重要 NPC 永远跑满帧率。

四张画像的速查表:

画像一句话症状第一工具第一处方
Draw Call 爆炸Draw 线程高、GPU 闲stat scenerendering实例化合批 + Nanite + HLOD
着色器编译卡顿首次出现的孤立尖峰Insights 尖峰形态PSO 预热 + 收敛变体
物理过度模拟场面一乱就掉帧stat physics休眠 + 池化 + 降迭代
动画线程拥堵NPC 越多 Game 越慢stat animURO 降频 + 预算均摊

18.4 预算制度:给每个系统发"工资"

帧时间的目标是"不许超"。"尽量快"——这句话骗过多少团队。前者的系统各自为战,每个人都说自己"已经很快了",加起来照样超标;后者把 16.6ms 当成月度工资总额,每个系统月初领固定额度,花超了要写检讨。没有预算表的优化,等于没有秤的减肥。

「回响峡谷」的 16.6ms 工资表

验收目标 4K/60fps(RTX 3080 档硬件),总额 16.6ms,分配如下:

系统预算 (ms)健康实测 (ms)明细与说明
GPU 渲染总包9.08.7BasePass 3.0 / Lumen 2.5 / VSM 阴影 1.5 / 半透明 0.6 / 后处理 1.4
Game 逻辑(含物理)4.03.6物理同步占其中 0.8
动画1.51.4主角 0.5,NPC 按预算器均摊
音频0.40.3MetaSounds 图与衰减计算
流送摊销0.50.4World Partition 格子进出(见第 16 章)
尖峰缓冲余量1.2战斗爆发帧专用,平时冻结
合计16.614.4 + 余量60fps 达标线

用公式说,预算制度就是一条不等式:

Tframe=iBi+Bmargin16.6msT_{frame} = \sum_{i} B_{i} + B_{margin} \le 16.6\,\text{ms}

每个 BiB_i 是系统领到的工资,BmarginB_{margin} 是任何人都不许动的战备金。优化工作的全部内容,就是让每个系统的实测值待在它自己的 BiB_i 里面——超了,要么减系统的活,要么开预算评审会重新分账,没有第三条路。

顺带回答一个评审会上必问的问题:为什么咬死 60fps,把 40 判了死刑?这背后是产品决策,技术偏好没有发言权。「回响峡谷」是第三人称动作游戏,60fps 下输入到画面的延迟比 30fps 低约 17ms,翻滚与格挡的手感差异,核心玩家一摸便知。换成回合制策略游戏,30fps 换更重的画质完全成立——预算表永远从产品定义倒推,不从硬件能力正推。

预算制度的三条执行规则:

  1. 预算是合同,建议值没有法律效力。 CI 每晚自动跑固定路线抓取数据,任何系统连续三次超支 5%,性能门禁直接打回提交。
  2. 超支先问"谁最近涨薪了"。 对照上周曲线找增量,新增内容默认要吃掉余量,吃超了就说明预算表该重审,重新分账是唯一的出路。
  3. 余量神圣不可挪用。 1.2ms 的缓冲是给"12 个 NPC 加特效全开"这种爆发帧准备的。平时把它分掉,爆发帧必然破 16.6ms,而玩家记住的永远是卡的那一下。

预算表本身是逆向工程出来的:先测一批健康系统的实测分布,取 P95 加 15% 作为初版额度,跑两个月再按实测收紧。新系统立项要先申请预算再动工——动画组想给 NPC 加全身 IK,先回答"预算从谁的额度里出",答不上来就回去改方案。这条流程听着官僚,实际省的是上线前那种全员救火。

内存也要发工资

帧时间有工资表,内存也得有。「回响峡谷」的入门档目标硬件可用内存约 8GB,按 LLM 标签切饼:

「回响峡谷」内存预算分饼(8.0 GB 可用档)8.0 GB内存工资总额纹理流送池 2.0 GB(25%)——永远是大头,先查它网格 / 几何 / 渲染资源 1.6 GB(20%)引擎与系统开销 1.6 GB(20%)物理 + 动画数据 0.6 GB(7.5%)音频 0.5 GB(6.25%)未分配余量 1.7 GB(21%)——给流送尖峰留的缓冲区监控手段:stat LLM 按标签对账,任何一格连续三次超标触发预算评审

饼图里最需要解释的是那块"未分配余量"。它本质上只有一个身份:流送系统的安全气囊。玩家冲刺穿过三个格子时,加载峰值会瞬时超出稳态占用 1GB 以上。把这块分光,等于把安全气囊拆了换真皮座椅。

对账的日常动作是 stat LLM(需要细目时换 stat LLMFULL 看全标签):纹理池超了,先查纹理的 LOD Bias 与虚拟纹理流送池上限;几何超了,查 Nanite 簇缓存和关卡实例是否重复驻留。每周例会把六个标签的实测值贴进预算表,曲线连续三周上涨的标签,自动成为下一次预算评审的对象。

超支了砍谁:收益乘工作量矩阵

预算评审会上的每个候选项,都丢进这张矩阵里过一遍。横轴是工作量,纵轴是收益:

quadrantChart
    title "优化项取舍矩阵"
    x-axis "工作量小" --> "工作量大"
    y-axis "收益低" --> "收益高"
    quadrant-1 "立项攻坚"
    quadrant-2 "立即动手"
    quadrant-3 "顺手做"
    quadrant-4 "放弃(Amdahl 劝退区)"
    "Lumen 软追踪切换": [0.3, 0.9]
    "NPC 动画 URO": [0.4, 0.75]
    "植被远距阴影关闭": [0.2, 0.6]
    "PSO 预热清单": [0.5, 0.8]
    "重写背包 UI": [0.8, 0.15]
    "自研内存分配器": [0.9, 0.5]

左上象限的活不要犹豫,右下象限的活不要接。真正难的是右上:收益高但工作量大,比如重写流送策略——这种要单独立项排期,别指望顺手做完。

18.5 平台分档:一份代码跑三档硬件

「回响峡谷」要同时伺候三档硬件,它们的预算表根本不是一个量级:

档位代表硬件目标关键配置
主机档PS5 / XSX动态 1440p→4K / 60fps动态分辨率兜底,Lumen 软追踪,VSM 中档
高端 PCRTX 4070 及以上原生 4K / 60fpsEpic 全高,Lumen 硬件光追
入门 PCGTX 1660 / RX 65001080p / 60fps(允许 30 档)屏幕比例 66%,阴影与特效全 Low,GI 走烘焙 + SSGI

入门档有一笔诚实的成本要交代:关掉 Lumen 后没有免费的动态 GI 替补,要么回退到烘焙光照加 SSGI,要么接受观感降级。「回响峡谷」选了前者,代价是光照要构建两套数据,关卡团队的烘焙时间翻倍。预算不够的时候,选项从来都不是"全都要"。

机制上,分档靠三样东西协作:Scalability 组(sg.ViewDistanceQualitysg.ShadowQualitysg.GlobalIlluminationQuality 等 0~3 档)管通用画质;GameUserSettings 负责持久化玩家的手动选择;DeviceProfile 给主机这种固定硬件写死配置。首次启动跑一次硬件基准测试定档,之后交还给玩家。

主机档还有一层外部约束:平台认证(索尼 TRC、微软 XR 系列规范)把帧率稳定性列为硬性条款,长时间跌破目标帧率的版本会被打回——帧率不合格直接导致上架被拒,这已经超出了体验讨论的范畴。主机配置用 DeviceProfile 按平台固化在配置文件的对应分段里,一份资产出三份画质。另外动态分辨率别只给主机开:入门 PC 档同样值得打开,玩家对分辨率轻微浮动的容忍度远高于对卡顿的容忍度——糊一点是风格,卡一下是事故。

自动降级:降得果断,升得克制

定档只是入场券。同一张显卡,同一场景,战斗爆发时照样可能破帧。运行时需要一个自动降级器,盯着滚动平均帧时间做决策:

stateDiagram-v2
    [*] --> 基准测试: 首次启动
    基准测试 --> 定档: 硬件评分映射档位
    定档 --> 稳态运行: 应用 Scalability 档
    稳态运行 --> 降档: 连续 240 帧超标
    降档 --> 稳态运行: 降一档并清零计数
    稳态运行 --> 升档试探: 连续 1800 帧富余超 25%
    升档试探 --> 稳态运行: 升一档观察
    升档试探 --> 降档: 试探失败立即回退
    note right of 升档试探
        升档阈值远高于降档阈值
        且只在非战斗场景试探,防止来回抖动
    end note

核心原则是降档与升档不对称:240 帧(约 4 秒)超标就降,1800 帧(30 秒)富余才升,而且升档只在安全场景试探。玩家可以接受画质慢慢变好,不能接受打 boss 时画质反复横跳。实现骨架:

void AECPerfDirector::ApplyTier(EPerfTier Tier)
{
    UGameUserSettings* S = UGameUserSettings::Get();
    switch (Tier)
    {
    case EPerfTier::Console:  // 主机:动态分辨率兜底 + 软追踪
        S->SetOverallScalabilityLevel(2);
        SetCVar(TEXT("r.DynamicRes.OperationMode"), 1);
        SetCVar(TEXT("r.Lumen.HardwareRayTracing"), 0);
        break;
    case EPerfTier::HighPC:   // 高端 PC:原生 4K + 硬件光追
        S->SetOverallScalabilityLevel(3);
        SetCVar(TEXT("r.Lumen.HardwareRayTracing"), 1);
        break;
    case EPerfTier::EntryPC:  // 入门 PC:缩放渲染 + 全 Low
        S->SetOverallScalabilityLevel(1);
        SetCVar(TEXT("r.ScreenPercentage"), 66);
        SetCVar(TEXT("r.Shadow.Virtual.Enable"), 0);
        break;
    }
    S->ApplySettings(false);
}

SetCVar 是三行的小封装:IConsoleManager::Get().FindConsoleVariable(...)->Set(...)。帧时间监控用 FApp::GetDeltaTime() 做环形缓冲滚动平均,配合上面状态机的两套阈值。玩家手动改过画质后,自动降级器只降不升——用户的选择永远优先于算法。

分档做完不算完,每档都要走一遍验收:固定路线跑图取帧时间中位数,同机位截图进画质评审。降级的目标是在预算内保住美术总监划的三条底线:光影氛围、角色特写、特效可读性。底线之外,都可以谈。入门档最容易被当成"能跑就行"的垃圾桶——阴影全关、材质糊成一片的版本确实能跑,但那已经越过了降级的底线,进入了放弃治疗的领域。

18.6 战报:「回响峡谷」从 22ms 到 15.9ms

卷二收尾,把前五节的流程完整跑一遍。以下记录取自验收冲刺的真实工作日志,数字做了归整,过程原样保留。

D0 09:40 · 超标确认。 验收环境:RTX 3080 / 4K / Epic 预设,固定跑图路线(营地→峡谷→教堂)。实测 22.0ms,约 45fps,目标 16.6ms。stat unit:Game 16.2 / Draw 11.4 / GPU 21.4。GPU 是主犯,Game 也超标,两边都有活要干。

D0 11:00 · 数据捕获。 打包版抓三轮 Insights(帧时间两轮、内存一轮)。时间轴供词三条:GPU 侧 Lumen 6.8ms——硬件光追在 4K 下的追踪成本失控;ShadowDepths 3.2ms——PCG 森林的远距植被全部在投阴影;Game 侧动画段 5.1ms——教堂场景 12 个 NPC 全员跑 Motion Matching。另有进门瞬间 220ms 的 PSO 编译尖峰,老熟人。

D0 18:00 · 基线归档。 未做任何修改的原始数据入库:.utrace 文件、stat 截图、机器配置、驱动版本。后续所有对比都以这份基线为锚——没有基线的"快了 6ms"证明不了任何事,也许只是机器那天心情好。

D0 14:00 · 假设与方案评审。 四条修复全部来自左上象限:Lumen 切软追踪并降质量档;远距植被关闭阴影投送并下调 VSM 预算;NPC 动画上 URO 加预算器;PSO 预热清单补收集。背包 UI 重绘 0.4ms 一项,按 Amdahl 劝退线标记"不修",写入预算表备注。

D1 全天 · 最小修复。 一次改一件,改完当场复测:软追踪切换后 Lumen 从 6.8ms 降到 4.2ms,画面差异需要放大截图才看得出;植被阴影处理后 ShadowDepths 降到 1.8ms,30 米外没人看得出树影少了;NPC 动画 URO 15Hz 加 BudgetAllocator 均摊,Game 线程动画段从 5.1ms 压到 1.6ms;PSO 预热后进门尖峰从 220ms 压到 18ms,埋在流送间隙里,玩家无感。

D2 10:00 · 验证。 同一路线三轮抓取取中位数:帧时间 15.9ms,稳定 60fps。GPU 实测 8.7ms,贴住 9.0ms 预算;1.2ms 余量完整未动。修复前后对账:

项目修复前修复后手段
帧时间(中位数)22.0ms / 45fps15.9ms / 60fps
Lumen6.8ms4.2ms硬件光追 → 软追踪降档
ShadowDepths3.2ms1.8ms远距植被关投送 + VSM 降预算
Game 动画段5.1ms1.6msURO 15Hz + BudgetAllocator
进门尖峰220ms18msPSO 预热清单
背包 UI0.4ms0.4ms(不修)Amdahl 劝退

D2 16:00 · 写回归。 跑图路线接入 CI 性能门禁,预算表归档,超支 5% 自动打回。那次达标产出了一件比 60fps 更值钱的东西——从此以后每次超标都有一张表可以对照。达标是事件的终点。制度,则从这里出发。

最后留一段复盘,三条带走。一,四条假设命中三条,VSM 降预算那条收益只有预期的一半——假设可证伪的价值就在于,证伪了也不亏,数据照样进预算表。二,所有修复都回到验收原场景验证,测试关卡里跑出的漂亮数字不算数,玩家不在测试关卡里玩。三,门禁比自律可靠:没有 CI 打回机制,预算表活不过下一个版本节点。

战报收尾还有一条诚实备注:内存饼图显示纹理流送池实测 2.3GB,超出 2.0GB 预算 15%,已标记为下个 sprint 的头号评审项。帧时间达标不等于所有预算都健康——性能工作没有毕业典礼,只有下一轮测量。

小结

先测量,别猜。平均帧率会说谎,稳态和尖峰是两种病,抓 30 秒 Insights 比猜三天便宜三个数量级。Amdahl 定律给出收益天花板 1/(1p)1/(1-p):占比 5% 的系统优化到零也只有 5.3% 收益,占比不足 10% 又不是尖峰元凶的系统默认不碰。Insights 三视图各管一件事:CPU 时间轴看线程接力与长条,GPU 视图按 Pass 对账,内存视图看曲线形状。健康帧与病态帧的差异是结构性的,不是数值大小。预算制度把"尽量快"变成"不许超":每个系统领工资,超支写检讨,余量是战备金。自动降级要降得果断、升得克制,两套不对称阈值防抖动;玩家的手动设置永远优先。

上手任务

  1. 抓一次自己的现场。 在你的竖切工程里 trace.start cpu,gpu,frame,bookmark 抓 30 秒跑图,写出 CPU 时间轴、GPU 各 Pass、内存曲线各一个观察结论。验收标准:报告里每条结论都带具体毫秒数和 Named Event 或 Pass 名,不许出现"感觉挺流畅"。
  2. 立一张工资表。 为你的项目写一份 16.6ms(或 33.3ms)预算表,逐项填实测值。验收标准:每项有数字来源,超支项有明确处理结论(修复 / 重审 / 劝退),余量不低于 1ms。
  3. 实现自动降级器。 滚动平均帧时间连续 240 帧超 17.5ms 降一档 Scalability。验收标准:用 t.MaxFPS 20 模拟低端机,10 秒内自动降档;解除锁帧后 30 秒内不来回抖动。

下一章

帧率达标了,但玩家对游戏的第一触感来自界面,画质反而排在后面——而背包 UI 欠下的那 0.4ms,卷三开篇就要还。第 19 章进入 UMG 与 CommonUI:MMO 量级的界面复杂度,从数据绑定和输入路由的地基打起。

延伸阅读

  • 官方文档:Unreal Engine → Unreal Insights(Trace 通道、Timing 与 Memory 面板词条)
  • 官方文档:Unreal Engine → Rendering → Pipeline State Cache(PSO 预热与收集流程)
  • 官方文档:Unreal Engine → Scalability 与 DeviceProfiles(分档机制的完整配置参考)
  • GDC 历年关于帧预算与性能文化的分享(搜索关键词:frame budget、performance culture、optimization workflow)
  • 引擎源码:Engine/Source/Programs/UnrealInsights(Insights 前端)与 Engine/Source/Runtime/TraceLog(Trace 协议实现);分档逻辑见 GameUserSettings 与 Scalability 相关实现