导语:上周评审,美术在「回响峡谷」的废墟小镇里摆了 120 盏点光,理由是"每扇窗户都要有生活的痕迹"。帧率从 60 掉到 19。TA 敲开控制台看了一眼,说灯不用删,把其中 90 盏的阴影投影关掉就行。改完回到 54 fps。他凭什么只看一眼就知道砍哪里?因为他知道一帧的每一毫秒花在哪,知道哪项开销是乘法、哪项是加法。这一章不用写一行 shader,但跟完一个像素从 Actor 到屏幕的一生,面对掉帧你也能只看一眼就敢下判断。
本章地图
mindmap root((渲染管线
一天游)) 一帧的一生 Game 线程 渲染线程 RHI 线程 GPU GPU 补课 显存带宽为什么贵 合批与仓库取货 延迟渲染 100 盏灯的账 GBuffer 调色盘 RDG 与三大阶段 几何 光照 后处理 帧剖析 stat unit 四行 ProfileGPU 命令速查表 帧预算 16.6ms 分配 回响峡谷目标
6.1 一个像素的一生要过四道手
跟踪对象:废墟小镇里一堵风化的混凝土墙,Mesh 是 SM_Wall_04,材质 M_Concrete_Wet。它身上的一个片元,最终会出现在屏幕坐标 (963, 412) 上。从游戏逻辑到那一格像素点亮,它要过四道手:Game 线程、渲染线程(Render Thread,老资历口中的 Draw 线程)、RHI 线程、GPU。
第一道手:Game 线程。 这堵墙是静态的,Tick 里它什么都不干。但它身上的 UPrimitiveComponent 每一帧都要向渲染世界报到一次:Transform 变了吗?材质被脚本换过吗?变了,Game 线程就创建或更新一份"渲染态快照"——场景代理(FPrimitiveSceneProxy)。
为什么要快照这么绕?因为 Game 线程随时在改 UObject——蓝图在改、动画在改、物理在改——渲染线程没法边读边改。SceneProxy 就是只读副本:Game 线程写,渲染线程读,两边互不锁死。这套"双份账本"的思路,和第 21 章复制系统里"服务器有真相、客户端有影子"是同一个哲学。
快照还有第二个好处,很少有人说透:数据布局。UObject 散在堆上,指针跳来跳去,CPU 缓存命中率难看;SceneProxy 和 Draw Command 是紧凑排列的数组,渲染线程顺序扫过去,缓存一行顶一行用。下一节要讲的"喂不饱"问题在 CPU 侧同样存在,解法是同一个:让数据排好队,别让工人满仓库找货。
第二道手:渲染线程。 帧开始,它拿到 FScene(整个渲染世界的镜像),先做可见性剔除:视锥、遮挡、距离三关。小镇里 4000 个 Primitive,剔完剩 900 个。幸存的 SceneProxy 针对每个渲染 Pass 生成一批绘制命令(FMeshDrawCommand)——顶点缓冲、索引缓冲、shader、纹理绑定,全部打包好。打包完按 shader 和材质排序,让 GPU 少换状态。
三关剔除里,遮挡剔除最值得一说。视锥和距离剔除是几何判断,便宜;遮挡剔除要回答"墙后面那排房子看得见吗",UE 的做法是拿上一帧的深度做硬件遮挡查询:把物体的包围盒对着上一帧画面测一遍"露出来没有"。用旧帧的结果测新帧,意味着快速甩镜头时个别物体会晚一帧冒出来——拿绝对正确换速度的经典交易。小镇里 4000 个 Primitive 剔到 900,大头就是遮挡剔除的功劳。
第三道手:RHI 线程。 渲染线程的命令是平台无关的,RHI(Render Hardware Interface)线程负责翻译成 D3D12 / Vulkan / Metal 的真实 API 调用,灌进 GPU 命令队列。像同声传译:渲染线程说"画这个",RHI 用显卡厂商听得懂的方言复述一遍。
第四道手:GPU。 命令到手,顶点着色、光栅化、片元着色写进 GBuffer,光照 Pass 读 GBuffer 算光,后处理链收尾,Present 一声令下,(963, 412) 点亮。从 Tick 到亮灯,四棒接力,排队延迟最多一到两帧。
sequenceDiagram
participant G as Game 线程
participant R as 渲染线程
participant H as RHI 线程
participant V as GPU
Note over G: 帧 N 开始
G->>G: Tick:逻辑 / 物理 / 动画
G->>R: 更新脏组件的 SceneProxy
Note over G: 帧 N 提交完,转去跑帧 N+1
R->>R: 视锥 / 遮挡 / 距离剔除
R->>R: 生成 MeshDrawCommand 并按状态排序
R->>H: 提交平台无关命令列表
H->>V: 翻译成 D3D12 / Vulkan 调用
V->>V: 几何 → 光照 → 后处理
V-->>G: Present(画面落地,约落后一帧)注意这张图的关键点:三条线程加 GPU 是流水,不是串行。帧 N 的 Game 线程跑逻辑时,帧 N-1 的渲染线程还在画。所以帧时间从来不由四段相加决定——最慢那一棒说了算。这是后面所有帧剖析的世界观,先刻进脑子。
流水线并行还有个直接后果:屏幕上这帧画面,用的逻辑状态是一帧前的。相机已经转了,画面还没转——VR 里这种"晚一帧"能让人当场晕菜,所以相机和头部追踪这类数据有专门的晚闩(Late Latch)通道,渲染线程提交前再更新一次,绕过排队。代价是 Game 线程的状态和 GPU 正在画的暂时对不上。引擎把裂缝藏得很好,但调试运动相关的 bug 时,得记得这条裂缝存在。
数据在这一路的三次变身,用类图看更清楚:
classDiagram
class UPrimitiveComponent {
+Transform
+Materials
+MarkRenderStateDirty()
}
class FScene {
+Primitives
+Lights
}
class FPrimitiveSceneProxy {
+DrawStaticElements()
+GetDynamicMeshElements()
}
class FMeshDrawCommand {
+VertexStreams
+IndexBuffer
+ShaderBindings
}
UPrimitiveComponent ..> FPrimitiveSceneProxy : Game 线程创建/更新
FScene "1" o-- "*" FPrimitiveSceneProxy : 渲染世界持有
FPrimitiveSceneProxy ..> FMeshDrawCommand : 渲染线程逐 Pass 生成把整段接力压缩成伪代码,看清"并行"到底并行在哪:
# 伪代码:UE 一帧内的三线程协作(简化版)
def frame_N():
parallel: # Game 线程(帧 N 的逻辑)
world.tick(dt) # 逻辑/物理/动画/蓝图
for comp in dirty_components: # 只处理本帧变脏的组件
scene.update_proxy(comp) # 写渲染态快照 SceneProxy
submit_to_render_thread(scene)
parallel: # 渲染线程(还在画帧 N-1 的场景)
visible = cull(prev_scene, camera) # 视锥 + 遮挡 + 距离剔除
for render_pass in [shadow, base, lighting, post]:
cmds = build_draw_commands(visible, render_pass)
cmds.sort_by_shader() # 排序:减少 GPU 状态切换
rhi_queue.submit(cmds) # RHI 线程接力翻译成 API 调用Game 线程给渲染线程派活的通道,是引擎里的 ENQUEUE_RENDER_COMMAND 宏:把一小段 lambda 挂进渲染线程的命令队列,渲染线程按序取出执行。在 Game 线程代码里直接动渲染资源,轻则竞态重则崩溃,一切跨线程交付都必须走这条队列——和 SceneProxy 快照是同一套纪律的两端。
两段 parallel 同时跑,这就是 UE 渲染的基本节奏。Game 线程是编剧,渲染线程是场务,RHI 是翻译,GPU 是演员。编剧拖稿、场务摆烂、演员NG,任何一方超时,整组等。
6.2 补课·GPU 扫盲:渲染的核心矛盾是"喂不饱"
补课:本节补 GPU 硬件常识,图形学出身的读者直接跳到 6.3。
CPU 和 GPU 的分工,用仓库类比最省事。GPU 流处理器是市中心的分拣中心,算力惊人:RTX 4090 大约 80 TFLOPS。显存(VRAM)是郊区大仓库,GDDR6X 给到大约 1 TB/s 带宽。两者之间只有一条高速公路。
问题出在比例上。80 TFLOPS 对 1 TB/s,意味着每从仓库取 1 字节数据,分拣中心最好对它做 80 次运算,否则工人就在流水线边上等货。这个比值叫算术强度(Arithmetic Intensity)。过去十几年,算力增速远快于带宽增速——分拣中心越建越快,高速公路扩不动。所以现代渲染的核心矛盾,"算不动"是错觉——"喂不饱"才是真相。
这一个矛盾,解释了你天天见到的一堆设计:
- 纹理为什么要有 Mipmap:远处物体用小图,同一批像素少跑几趟高速。
- 纹理为什么要压缩(BC7 / ASTC):同样一趟车,多装四倍的货。
- Lumen 为什么要 Surface Cache:光照算在简化的卡片面上,比每帧对全场景重算省下几个数量级的取货量(第 7 章细讲)。
高速公路上还有服务区:GPU 片上的多级缓存。缓存命中取货只要几个时钟周期,比跑一趟显存快几十倍。所以"相邻像素读相邻纹素"是黄金访问模式,Mipmap 和块压缩都在伺候它;跳着读、随机读,等于每取一件货都跑一趟郊区。写 shader 的人天天跟这条规律搏斗,写上层逻辑的人知道它存在就够了。
分拣中心内部还分工序,像素填充(Fill Rate)是最容易堵车的一道:每往帧缓冲写一个像素,都要走"读旧值—算新值—写回"的完整过磅流程。半透明一层叠一层,同一个像素被反复过磅,这叫过绘制(Overdraw)。粒子特效糊满屏时帧率跳水,凶手通常不是粒子数量。填充率才是真凶——6.3 账单里的系数 就是这么来的,第 13 章讲特效时还会跟它打一次交道。
合批(Batching)是同一逻辑的另一面。Draw Call 的本质是"去仓库取一批货的手续"。手续费的大头不在路上跑(GPU),而在办手续(CPU 提交参数、驱动验证状态)。取 1 件货办一次手续,和取 1000 件货办一次手续,手续费交得一样多。1000 个小物件分 1000 次画,比合成一次画慢得多——慢在 CPU 办手续办到手软,GPU 在旁边干等。
UE 的对策有三层:渲染线程把 Draw Command 按 shader、材质、顶点缓冲排序,让相同状态的命令挨着走;实例化静态网格(Instanced Static Mesh, ISM / HISM)把同模型的成百上千个实例合成一次 Draw;Nanite 干脆重写了整个几何提交路径,那是第 7 章的故事。
# 伪代码:渲染线程的合批思路——"一次手续取整批货"
def build_batches(draw_commands):
# 按"办手续的成本项"排序:shader > 材质 > 顶点缓冲
draw_commands.sort(key=lambda c: (c.shader, c.material, c.vb))
batches = []
for cmd in draw_commands:
if batches and same_state(batches[-1], cmd):
# 同 shader 同材质:实例数据追加,一次 Draw 画 N 个
batches[-1].instances.append(cmd.instance_data)
else:
batches.append(new_batch(cmd))
return batches # 1000 个物件可能只剩几十个 Batch经验数字:1080p 下,UE 项目 Draw Call 在 2000~3000 以内通常健康;破 5000,渲染线程大概率先顶不住。别当定律看——这是排查的起点。到了第 18 章,会拿 Insights 把这笔账算到每个 Pass 头上。
最后纠正一个常见误会:合批省的是"手续",不是"运费"。合成一次 Draw 之后,该画的三角形一个不少,GPU 的活儿没变轻——变轻的是 CPU 和驱动的提交负担。所以 Draw Call 优化救的是 Draw 栏,GPU 栏岿然不动;四行里谁是瓶颈,决定这招灵不灵。这正是 6.5 罗盘的用处:先看账单栏,再选工具。
顺带分清两个常被混用的词:带宽是高速公路的宽度,容量是仓库的面积。显存容量爆掉的症状是贴图被强制降清晰度、流送抖动甚至崩溃;带宽不够的症状是帧时间随分辨率和过绘制线性恶化。前者在 stat unit 四行里看不出来,要查 stat rhi 的显存项;后者直接写在 GPU 栏上。两种病开两种药方,别吃错。
6.3 延迟渲染赢在算术上:给 100 盏动态光算笔账
回到评审现场那 120 盏灯。要理解 TA 的判断,先得知道前向渲染(Forward Shading)和延迟渲染(Deferred Shading)各自怎么收钱。
前向的逻辑很直觉:画每个片元时,把所有可能影响它的灯都遍历一遍,逐盏算光照,算完合成颜色。灯少的时候挺好,灯一多就露馅——每个片元都为每盏灯付了钱,哪怕那盏灯根本照不到它。
延迟渲染分两阶段收钱。阶段一叫 Base Pass:片元不算光照,只把材质参数——BaseColor、Normal、Roughness、Metallic、深度——写进一组全屏纹理,这组纹理统称 GBuffer(几何缓冲)。阶段二叫 Lighting Pass:逐盏灯,只对它实际覆盖的屏幕区域,从 GBuffer 里捞出材质参数算一次光照。
拿数字说话。1080p 约 200 万像素;小镇夜景平均过绘制(Overdraw,同一屏幕像素被几个片元轮流覆盖)按 2 算;100 盏动态点光。
前向的账:200 万像素 × 2 过绘制 × 100 盏灯 = 4 亿次光照计算。其中绝大多数是白付的——一盏只照亮半扇窗的灯,也被迫对每个片元表态"我照不到你"。
延迟的账:Base Pass 写入 200 万 × 2 = 400 万次材质落盘;Lighting Pass 里,假设每盏灯平均覆盖 5% 的屏幕,100 盏 × 10 万像素 = 1000 万次光照计算。合计约 1400 万次,对前向的 4 亿次。差将近 30 倍,而且灯越多差得越狠。
先停一下,看前向那个 100 是从哪来的。引擎又不傻,每个物体其实只遍历真正覆盖它的灯——但"每个物体带上自己的灯列表"这件事本身要 CPU 逐物体算,灯一多,光是维护清单就够喝一壶,shader 还得按灯数编译出不同变体。延迟路径把这一切推平:所有物体走同一个 BasePass,灯的事以后再说。
写成式子,区别一目了然:
是像素数, 是过绘制率, 是灯数, 是第 盏灯的屏幕覆盖率,几个 是单次操作成本。前向式子里 坐在乘号上,灯数直接放大总成本;延迟式子里 折进了求和号,每盏灯只按自己真实覆盖的像素付钱。灯数从乘数变成加数——这就是延迟渲染的全部胜利。
代价也摆上台面,延迟不是白赢的:
- GBuffer 是带宽黑洞。每帧全屏写一遍,光照阶段再全屏读回来,恰好命中上一节说的最贵操作。1080p 下一套 GBuffer 几十 MB,4K 直接翻四倍。
- MSAA 基本告别。GBuffer 多样本化会让带宽再炸一次,所以 UE 桌面端默认走 TAA / TSR 这类时序抗锯齿。
- 透明物体进不了 GBuffer——一个像素存不下两层材质参数,透明仍然走前向路径。
- 灯少、几何简单、带宽紧张的平台,前向反而划算。UE 的移动端渲染路径就是前向,这是在匹配硬件。
现在能听懂 TA 那句话了。灯的数量早已不是乘法项,但阴影投影还是:每盏投影灯都要额外渲染一遍 Shadow Map,灯数重新坐回乘号上。砍掉 90 盏灯的投影,砍的是乘法项;保留光照本身,付的只是加法项。不懂这两式的人删灯,懂的人删投影。
调色盘上有几个设计细节值得停一眼。法线只占 RGB 三个 10bit 通道,精度刚好够光照用,多一比特都是浪费带宽;Roughness、Metallic、Specular 挤在一张图的 RGB 里,alpha 塞 ShadingModelID——一个整数标记,告诉光照 Pass 这个像素该走默认光照、次表面还是布料模型的 shader 分支。BaseColor 的 alpha 也没闲着,存环境光遮蔽(AO)与间接光强度。整套 GBuffer 没有一个比特在度假,全是被带宽逼出来的抠门。
GBuffer 到底存了什么?把它摊成调色盘看——每个像素的材质参数都挤在这几个槽位里,等光照阶段来取:
各 Pass 的执行顺序与数据流向,一张图收拢:
flowchart TD
A[PrePass
只写深度] --> B[BasePass
材质参数写进 GBuffer]
B --> C[阴影
VSM 逐灯渲染 ShadowMap]
C --> D[Lumen
间接光与反射]
D --> E[Lighting Pass
逐灯读 GBuffer 累积直接光]
E --> F[半透明
单独走前向路径]
F --> G[后处理链
TSR / Bloom / 色调映射]
G --> H[Present 上屏]
B -. 写 .-> GB[(GBuffer
Depth / A / B / C)]
E -. 读 .-> GB
D -. 读 .-> GB6.4 RDG 不管画画,只管排产
上一张图里七八个 Pass 只是主干,UE 实际一帧有几十个 Pass:深度预通道、阴影、Lumen 场景更新、反射、AO、半透明、后处理长链……谁来决定它们的顺序?谁来保证"读 GBuffer 的 Pass 一定排在写 GBuffer 之后"?谁把中间纹理的显存算清楚、能省就省?
答案是渲染依赖图(Render Dependency Graph, RDG)。每个 Pass 不自己插队,而是向 RDG 声明:我读哪些纹理,写哪些纹理。RDG 拿着所有声明干三件事:按依赖关系排出合法执行序;算出每张中间纹理的生死区间;把生死区间不重叠的纹理塞进同一块显存(内存别名,Aliasing)。
这个思路工程上到处有亲戚。像 K8s 调度 Pod:你只声明资源需求,编排器管摆放。也像数据库的执行计划:你写 SQL,优化器决定join顺序。RDG 出现之前,UE 渲染器手写 Pass 顺序、手算显存,加一个新 Pass 像往满员的地铁里再塞一个人;RDG 之后,Pass 变成声明式任务,渲染器从"脚本"变成了"调度系统"。
一个 RDG Pass 的声明长这样:
// RDG Pass 声明骨架(简化示意,完整签名以官方文档 RenderGraph 词条为准)
struct FMyPassParams
{
FRDGTextureRef Input; // 声明:我要读它
FRDGTextureRef Output; // 声明:我要写它
};
void AddMyPass(FRDGBuilder& Graph, FRDGTextureRef In, FRDGTextureRef Out)
{
FMyPassParams* Params = Graph.AllocParameters<FMyPassParams>();
Params->Input = In; // RDG 由此推导:本 Pass 依赖写 In 的人
Params->Output = Out; // 以及:读 Out 的人必须排在我后面
Graph.AddPass(RDG_EVENT_NAME("MyPass"), Params, ERDGPassFlags::Raster,
[Params](FRHICommandList& RHICmdList)
{
// 真正干活的 lambda:RHI 线程执行时回调
// 绑定输入输出,画一个全屏三角形
});
}骨架就三行实质内容:申请参数块、填读写声明、提交 lambda。依赖、排序、显存复用全是 RDG 自动推的。想翻真实案例,源码 Engine/Source/Runtime/Renderer/Private 下随便挑一个 Pass 文件,全是这个模子。
一个顺手的联动:下一节要用的 ProfileGPU,拆出来的那棵 Pass 时间树,节点名就是 RDG_EVENT_NAME 里写的字符串。写 Pass 时名字起得认真一点,半年后查性能的人会念你的好——"MyPass" 和 "LumenScene.Update" 在账单上的可读性,差着一个时代。
声明式带来一个直接好处:剪裁免费。某个 Pass 的输入没人用,RDG 直接把它从图里剪掉,不执行也不占显存。画质档位降级(第 18 章的平台分档)很大程度就是往图里少塞几个 Pass。
最后把几十 Pass 归堆,记住三大阶段就够用了。以「回响峡谷」的目标机(RTX 3080 / 1080p)给个数量级感受:
- 几何阶段(PrePass + BasePass):2~4 ms。把可见几何画成 GBuffer,顶点压力和 GBuffer 带宽在这结账。
- 光照阶段(阴影 + Lumen + Lighting):4~7 ms。全帧最贵的一段,Lumen 通常占大头。
- 后处理阶段(TSR + Bloom + 色调映射):1.5~3 ms。TSR 是其中最大的单项,换取的是边缘干净和运动稳定。
后处理为什么雷打不动放最后?因为它处理的是"已经是图像的图像":TSR 要把当前帧和历史帧对齐,Bloom 要在亮度里找高光扩散,色调映射把 HDR 数值压进显示器的 LDR 区间。它们不碰几何也不碰灯,只对最终像素动刀——顺序提前任何一步,后面的 Pass 都得返工。阶段顺序和编译器的前中后端一个道理:前一阶段的产出是后一阶段的输入,谁也跳不过谁。
三段加起来 8~14 ms,刚好卡在 60 fps 的 16.6 ms 预算线上下。哪个阶段超了,钱花在哪个 Pass,就要靠下一节的工具去查。
6.5 stat unit 四行数字,先分清账单栏再查账
性能工作的第一条戒律是"先测量,别猜"——这条第 18 章还会展开成完整工作流,本章先把最顺手的那几件工具发到手。
游戏里按 ~ 键打开控制台,输入 stat unit,屏幕右上角出现四行:
- Frame:一帧总耗时,玩家真正感受到的数字。
- Game:Game 线程耗时,逻辑、物理、动画、蓝图都在这栏。
- Draw:渲染线程耗时,剔除、生成 Draw Command、提交 RHI 在这栏。
- GPU:显卡执行耗时,三大阶段的 Pass 全在这栏。
还记得 6.1 的流水线吗?三线程加 GPU 并行推进,帧时间由最慢的一棒决定,写成式子:
是线程间的同步开销,正常情况下不到 1 ms。这个式子是帧剖析的罗盘:四行数字里找出最大的那栏,瓶颈就在那栏,其他两栏怎么优化都白搭。
照着罗盘走,四种典型读数对应四条排查路线:
flowchart TD
A[stat unit 看四行] --> B{哪一栏最高?}
B -->|Game 高| C[CPU 逻辑瓶颈
查蓝图 Tick / 物理 / 动画]
B -->|Draw 高| D[渲染线程瓶颈
查 Draw Call 数量与剔除开销]
B -->|GPU 高| E[GPU 瓶颈
开 ProfileGPU 拆 Pass]
B -->|三栏都低 Frame 高| F[同步等待
查锁竞争 / 资源同步加载]
E --> G{r.ScreenPercentage 降到 50
GPU 时间降了吗?}
G -->|明显降| H[像素侧瓶颈
着色 / 带宽 / 过绘制]
G -->|几乎不动| I[几何侧瓶颈
顶点 / 曲面细分 / 剔除]Draw 高的那条路线再补一刀:stat scenerendering 能看到 Draw Call 总数和各阶段耗时,回到 6.2 的经验数字对一下,就知道是不是合批出了问题。GPU 高且定位到像素侧之后,ProfileGPU 会把 GPU 时间按 Pass 拆成一棵树——BasePass 多少、Lumen 多少、TSR 多少,精确到 0.01 ms。ProfileGPU 的输出是一棵时间树:根节点是一帧总耗时,往下逐层展开 BasePass、ShadowDepths、Lights、Translucency、PostProcessing,每层标出毫秒数与占比。读法只有一条:沿着最大的分支一直往下钻,钻到单个大头 Pass 为止。第一次看这棵树通常会被吓一跳——最贵的往往不是事先怀疑的那个。这棵树怎么和 Insights 配合读,第 18 章再展开。
常用命令收进一张表,贴在工位上比收藏夹管用:
| 命令 | 作用 | 什么时候用 |
|---|---|---|
stat fps | 只显示帧率 | 最快速度确认"卡不卡" |
stat unit | Frame / Game / Draw / GPU 四行 | 定位瓶颈在哪条线程 |
stat unitgraph | 四行加历史曲线 | 抓间歇性尖峰 |
stat scenerendering | 渲染线程细分与 Draw Call 统计 | Draw 高时继续拆 |
stat gpu | GPU 分组耗时(简化版) | 不离开游戏粗看 GPU 分布 |
ProfileGPU | GPU 逐 Pass 耗时树 | 精确定位最贵 Pass |
r.ScreenPercentage 50 | 临时降内部渲染分辨率 | 验证是否像素侧瓶颈 |
stat rhi | RHI 层统计(显存、Draw 提交) | 怀疑带宽或提交开销时 |
FreezeRendering | 冻结当前剔除结果 | 排查剔除错误、穿帮 |
三条使用纪律。其一,测试要开独立进程(Standalone)或打包版,编辑器视口自带一堆额外开销,读数虚高。其二,每个数字都要记录场景和硬件——"GPU 11 ms"必须带上"小镇夜景 / RTX 3080 / 1080p",否则两周后自己都不认得。其三,一次只改一个变量,改完重测,连着改三个设置等于没测。
还有一种读数骗局要防:GPU 只有 11 ms,帧率却死死卡在 60——查垂直同步(VSync)和帧率上限(t.MaxFPS)。它们把 Present 钉在显示器刷新节奏上,四行数字全都健康,可一旦小幅超过 16.6 ms 就直接掉到 30。测性能先 r.VSync 0 解锁,拿到裸数据再谈优化,否则测的是显示器的脾气,不是游戏的。
6.6 「回响峡谷」的 16.6ms:预算是制度,不是愿望
60 fps 意味着一帧 16.6 ms 的总盘子。「回响峡谷」竖切的目标机定在 RTX 3060 / 1080p,目标 60 fps 稳定,画质档对标第 7 章要开的 Nanite + Lumen 组合。16.6 ms 怎么切,提前切好,别等超了再抢。
gantt
title 「回响峡谷」一帧 16.6ms 目标分配(1080p / 60fps,数值单位 ms)
dateFormat x
axisFormat %L ms
section CPU(与 GPU 并行)
Game 线程逻辑 :a1, 0, 4000
渲染线程提交 :a2, 0, 5000
section GPU 时间线
几何 BasePass :b1, 0, 4000
阴影与 Lumen 光照 :b2, after b1, 6000
后处理 TSR/合成 :b3, after b2, 3000
安全余量 :b4, after b3, 3000图上两条线并行:CPU 侧 Game 用 4 ms、渲染线程用 5 ms,都在 GPU 一帧的时间里完成;GPU 侧几何 4、光照 6、后处理 3,剩 3 ms 余量。最慢的一棒是 GPU 的 13 ms,离 16.6 还有富余——这 3 ms 的真相是:留给第 13 章特效爆发和第 9 章竖切里还没做的音频、UI 的。
这张表同时是画质档位的地基。低中高不是三个玄学开关——它是这套预算的三个版本:低档砍光照栏和几何栏——Lumen 降到软件追踪、阴影距离缩短;中档维持现状;高档动用余量。第 18 章的自动降级系统,干的就是帧时间连续超标时自动换到下一档预算表。预算和档位是一体两面,先有预算,才有档可降。
落成分配表,每个子系统领工资:
| 子系统 | 预算 | 超标的典型原因 | 先砍什么 |
|---|---|---|---|
| Game 线程逻辑 | ≤ 4 ms | 蓝图 Tick 泛滥、物理检测过密 | Tick 频率、事件驱动替代轮询 |
| 渲染线程提交 | ≤ 5 ms | Draw Call 破 3000、剔除失效 | ISM 合批、距离剔除收紧 |
| GPU 几何 | ≤ 4 ms | 高模没 LOD、植被过密 | Nanite 或 LOD 链(第 7 章) |
| GPU 光照 | ≤ 6 ms | 投影灯太多、Lumen 质量开满 | 砍阴影投影、Lumen 降档 |
| GPU 后处理 | ≤ 3 ms | TSR 质量档过高、Bloom 滥用 | TSR 降档、砍屏幕效果 |
| 安全余量 | ≈ 3 ms | —— | 任何人不得挪用,除非评审通过 |
预算制度的两条规矩。第一,预算是评审的通行证:某个系统想加功能,先说自己要花哪一栏的钱、从哪挤。美术想加 40 盏投影灯?那 GPU 光照从 6 ms 涨到 7.2,超出部分谁出?要么砍别的灯,要么申请动用余量,摆到台面上谈。第二,余量只降不补:每章新系统进场都从余量里扣,扣到 1 ms 以下就该开性能专项了,别等帧率崩了再开会。
目标改成 4K 的话,切法要重算:像素数变四倍,GPU 侧与像素强相关的栏——光照、后处理——大致跟着涨;几何栏涨得慢,顶点工作量不随分辨率变;CPU 两栏几乎不动。所以 4K 预算的本质是把能省的钱全补给像素侧,补不动就降内部渲染分辨率、让 TSR 向上重建。这也正是 TSR 的存在意义:让"渲染多少像素"和"显示多少像素"脱钩。
预算落地靠两条记录:每次评审留一张 stat unit 截图进文档,配一句"当前最大栏是 X";每次超标留一条原因和砍法。半年后回看,这份流水账比任何性能报告都诚实——它记着团队在哪些栏上交过学费。
这套"先分蛋糕再做菜"的顺序,到第 18 章会升级成完整的预算制度,配合 Insights 自动对账。但习惯从这一章就要养成:看到任何画面需求,先问一句——这钱花在哪一栏?
小结
一帧是三条线程加一台 GPU 的四棒接力,帧时间由最慢的一棒决定,优化其余三棒全是白费力气。现代渲染的核心矛盾是"喂不饱":算力涨得比带宽快,所以 Mipmap、纹理压缩、合批,本质都是少跑高速公路。延迟渲染的胜利是算术胜利——灯数从乘号挪进加号;代价是 GBuffer 带宽、透明回前向、告别 MSAA。RDG 把渲染器从手写脚本变成声明式调度:Pass 声明读写,依赖、排序、显存复用全自动,剪裁免费。stat unit 四行是罗盘,ProfileGPU 是账本,先分清瓶颈在哪一栏,再决定查什么——测量永远先于猜测。
上手任务
- 读自己的四行:打开「回响峡谷」灰盒(或任意模板场景),Standalone 模式下跑
stat unit并截图。验收:能指着截图说出当前最大栏是哪栏、对应哪类瓶颈。 - 点灯实验:在场景里摆 50 盏不投影的点光,前后各跑一次
ProfileGPU。验收:找到光照相关条目,写出开灯前后的毫秒数差值;再把其中 25 盏打开投影,重复一次,对比两笔差价并解释为什么第二笔贵得多。 - 分辨率探针:GPU 栏最高时执行
r.ScreenPercentage 50,观察 GPU 时间变化。验收:写出一句结论——"从 X ms 降到 Y ms,瓶颈在像素侧 / 不在像素侧",并说出这个结论排除了哪类问题。 - 定预算:给你的场景仿照 6.6 画一张分配表。验收:五栏数字加起来 ≤ 16.6 ms,且每栏都能写出"超了先砍什么"。
下一章
GBuffer 里那套材质参数是怎么从三角形里长出来的?Nanite 把"多少三角形"这个问题变成"多少像素",Lumen 把"多少盏灯"变成"多少探针"——第 7 章拆开 5.x 的新渲染三件套,「回响峡谷」的光影目标也定在那里。
延伸阅读
- 官方文档:Unreal Engine → Rendering → Graphics Programming → Deferred Shading(延迟渲染总览)
- 官方文档:Unreal Engine → Rendering → Graphics Programming → Render Dependency Graph(RDG 词条与 API 约定)
- 源码:Engine/Source/Runtime/RenderCore/Public/RenderGraphBuilder.h(RDG 真实接口形态)
- 源码:Engine/Source/Runtime/Renderer/Private/DeferredShadingRenderer.cpp(一帧的 Pass 装配现场)
- Adrian Courrèges 的 DOOM (2016) 帧剖析图形研究(Graphics Study 系列):逐 Pass 拆解一帧的教科书式示范