UE5.8 GPU Profiling 与 RenderDoc / PIX:一次阴影 pass 的深夜攻坚战

📑 目录

UE5.8 GPU Profiling 与 RenderDoc / PIX:一次阴影 pass 的深夜攻坚战

周三晚上十一点,渲染组的老陈收到美术组发来的一段录屏。视频里是一片废墟战场,玩家角色站在断墙旁边,镜头一转,帧率从 60 跌到 37。美术说是粒子特效太多,TA 说是 Lumen 开销太大,策划则坚持是刷怪数量导致的。老陈没有急着下结论,他打开 RenderDoc,在 DX12 模式下抓了一帧,三分钟就找到了元凶:一个动态聚光灯的阴影 pass 占掉了 8.7 毫秒,分辨率高达 4096×4096,且没有任何 cascaded 拆分。

这篇文章就围绕那次深夜排障,讲清楚 UE5.8 里怎么用 RenderDoc 和 PIX 做 GPU Profiling。内容从 capture 流程、事件树阅读、draw call 分析、纹理与渲染目标检查、Shader 反汇编,到和 GPU Visualizer 配合定位瓶颈,再到 PC 与主机的平台差异。读完你至少能在自己的项目里独立抓一帧,并知道该往哪个 pass 开刀。

1. 为什么需要 RenderDoc 和 PIX

UE 自带的 stat gpu 和 GPU Visualizer 已经能给出很粗的时间拆分,比如 BasePass、ShadowDepths、Lighting、PostProcessing 各占多少毫秒。但它们有两个局限:一是只能看到 pass 级别,无法钻进单个 draw call;二是看不到 GPU 内部的真正状态,比如纹理采样、Shader 指令、寄存器占用、屏障开销。

RenderDoc 和 PIX 属于帧捕获与重放工具。它们把一帧里所有图形 API 调用记录下来,保存成一个可以离线打开的文件,然后让你按 draw call 逐步回放、查看每一步的输入输出。两者的定位略有不同:

  • RenderDoc:开源、跨平台,支持 D3D11/12、Vulkan、OpenGL,界面直观,适合日常抓帧和分享 capture 文件。
  • PIX:微软出品,深度集成 D3D12 和 Xbox,支持 GPU 占用率、Warp、内存分配、计时分析,适合 Windows 和 Xbox 平台的深度排查。

在 UE5.8 的 DX12 路径上,这两个工具都能直接attach 到编辑器或打包游戏。老陈那天晚上用的是 RenderDoc,因为问题在 PC 上复现稳定;到了周五把修复方案提交到 Xbox 测试版本时,他又用 PIX 做了一次验证。

flowchart TD
    game["Game Thread"]
    render["Render Thread"]
    rhi["RHI Thread"]
    driver["GPU Driver"]
    rd["RenderDoc"]
    pix["PIX"]
    gpuvis["GPU Visualizer"]
    rhi --> driver
    driver --> rd
    driver --> pix
    render --> gpuvis

2. RenderDoc 捕获一帧

在 UE5.8 里启用 RenderDoc 最简单的方法是从 RenderDoc 启动编辑器。打开 RenderCode UI,点击 File → Launch Application,填入 UE5Editor.exe 的路径和项目参数。也可以让 RenderDoc 直接 attach 到已经运行的进程,但启动式捕获更稳定,符号也更容易加载。

启动参数通常这样写:

# 从 RenderDoc 启动 UE5.8 编辑器并进入游戏视图
UE5Editor.exe "D:\Project\MyGame.uproject" -game -d3d12

# 从 RenderDoc 启动打包版本
MyGame.exe -d3d12

UE5.8 默认启用 RHI 线程,RenderDoc 捕获的是最终提交到驱动的命令。这里有一个常见坑:如果 capture 的时候勾选了 "Queue Capture of Frame",但游戏还在加载着色器或编译 PSO,第一帧可能特别重,不代表真实性能。老陈的习惯是先进到场景里稳定跑十秒,再按 capture 快捷键。

RenderDoc 的默认捕获键是 F12。按下后,capture 文件会保存在 RenderDoc 的临时目录,也可以提前设置保存路径。抓到的帧在 RenderDoc 里打开后,左侧是 Event Browser,中间是 Texture Viewer,右侧是 Pipeline State 和 Mesh Viewer。

老陈那天晚上抓了五帧,他挑了帧时间最长的一帧打开。Event Browser 里密密麻麻全是事件,但他第一步不是钻细节,而是先看 Statistics 面板。Statistics 会列出所有 draw/dispatch/copy 事件的耗时排序,Top 1 是一个叫 ShadowDepths 的 pass,耗时 8.7 毫秒。

flowchart TD
    A["启动 RenderDoc 并加载游戏"] --> B["进入目标场景稳定运行"]
    B --> C["按 F12 捕获目标帧"]
    C --> D["在 Event Browser 按耗时排序"]
    D --> E["定位到 ShadowDepths 等耗时 pass"]
    E --> F["逐层展开查看具体 draw call"]
    F --> G["检查纹理/Shader/状态"]
    G --> H["定位根因并制定优化方案"]
    H --> I["修复后重新抓帧验证"]

3. PIX 在 DX12 中的使用

PIX 的启动方式和 RenderDoc 类似,但它对 D3D12 的支持更深入。打开 PIX,新建一个 GPU Capture 项目,选择要启动的可执行文件,勾上 D3D12 选项,点击 Launch。UE5.8 在 DX12 下运行时,PIX 会自动注入捕获层。

PIX 最大的优势在于 Timing Capture 和 GPU Capture 可以结合起来看。Timing Capture 记录一段时间内的 GPU 占用率、队列等待、Present 间隔,适合看帧时间波动;GPU Capture 则是一帧的完整快照,适合分析具体 draw call。

老陈在 Xbox 版本上验证时用的就是 PIX 的 GPU Capture。他关心的是 Shadow Pass 在 Scarlett 上的表现是否和 PC 一致。结果发现 PC 上 8.7 毫秒的阴影 pass,在 Xbox 上只要 4.2 毫秒。原因有两个:一是主机的 tile-based 内存访问模式减少了带宽压力;二是 UE5.8 在 Xbox 上默认启用了 16-bit depth 的反向 Z,深度测试效率更高。

PIX 的界面布局和 RenderDoc 很像,但多了一个 Occupancy 视图,能看到每个 wave 的活跃线程比例。如果某个 compute shader 的 occupancy 很低,说明寄存器压力或线程组大小设置不合理。

sequenceDiagram
    participant U as "UE5.8 进程"
    participant R as "RenderDoc 捕获层"
    participant P as "PIX 捕获层"
    participant D as "D3D12 Driver"
    participant G as GPU
    U->>D: 提交命令列表
    D->>R: RenderDoc 拦截并记录
    D->>P: PIX 拦截并记录
    D->>G: 实际派发命令
    R-->>U: 保存 .rdc 文件
    P-->>U: 保存 .wpix 文件

4. 在 GPU Visualizer 里先看大局

在打开 RenderDoc 之前,老陈先用 UE 自带的 GPU Visualizer 做了一轮快速扫描。控制台输入 profilegpu 或者按 Ctrl+Shift+Comma,就能打开一帧的 GPU 耗时瀑布图。GPU Visualizer 的精度不如外部抓帧工具,但它直接集成在编辑器里,不需要额外配置,适合快速判断瓶颈方向。

GPU Visualizer 把一帧拆成多个大类:BasePass、ShadowDepths、PrePass、Lighting、PostProcessing、Translucency、Lumen、Virtual Shadow Maps 等。每个大类下面还有子项。老陈当时看到的是 ShadowDepths 一项特别突出,占整帧 GPU 时间的 52%。这个比例显然不正常,说明问题在阴影 pass,而不是 BasePass 或 Lumen。

有了这个大方向,他才决定用 RenderDoc 抓帧。如果 GPU Visualizer 显示 BasePass 占大头,那应该去检查材质复杂度和 overdraw;如果 Lighting 占大头,可能和 Lumen 的 radiance cache 或反射有关;如果 Translucency 占大头,往往是粒子或水体 shader 的问题。

GPU Visualizer 还有一个隐藏技巧:在 BasePass 上右键,可以按材质或 mesh 排序。这个视图能直接告诉你是哪个材质的 shader 最慢,比 RenderDoc 里一个个找 draw call 要快。

5. 分析特定 draw call

RenderDoc 里定位到 ShadowDepths pass 后,老陈把它展开,发现里面有 137 个 draw call。最慢的一个 draw call 耗时 2.3 毫秒,绘制的是一个动态聚光灯的阴影贴图。选中这个 draw call,右侧 Pipeline State 会显示它用的 PSO、Shader、顶点布局、深度模板状态、视口和裁剪矩形。

这个 draw call 的视口是 4096×4096,绘制的是一个带骨骼动画的角色。角色本身只有 1.2 万个三角形,但阴影贴图分辨率太高,且没有启用 PCF 降噪,导致每个像素都要做 16 次采样。更关键的是,这个聚光灯的阴影贴图每帧都会重新生成,因为它被标记为 CastDynamicShadowsbUseCachedShadowMap 为 false。

在 Pipeline State 里,老陈还看到 DepthStencilState 的 DepthFuncGREATER,这是 UE5.8 在反向 Z 下的默认设置。反向 Z 本身没问题,但它要求远平面足够大,否则深度精度会浪费。这个聚光灯的 MaxDrawDistance 被美术拉到 50000,导致阴影贴图覆盖了整个关卡,大量远处静态物体也被画了进去。

// 在 UObject 或蓝图中限制动态阴影距离
LightComponent->MaxDrawDistance = 5000.0f;
LightComponent->bUseCachedShadowMap = true;
LightComponent->ShadowResolutionScale = 1.0f;

上面这段改动把聚光灯的阴影距离从 50000 降到 5000,开了缓存阴影贴图,并把分辨率缩放从 4 降到 1。重新抓帧后,这个 draw call 从 2.3 毫秒降到 0.4 毫秒。

分析 draw call 时,RenderDoc 的 Texture Viewer 非常有用。选中 draw call 后,Texture Viewer 默认显示当前绑定的 depth buffer。你可以切换查看输入纹理、输出纹理,也可以用 PS 输出的方式看像素级别的颜色值。老陈用它确认阴影贴图边缘有大量的空像素,说明视锥裁剪和 shadow caster 列表还有优化空间。

6. 渲染目标与纹理检查

RenderDoc 的 Texture Viewer 支持多种查看模式。除了看颜色,还可以看深度、模板、法线、粗糙度、金属度等通道。在 UE5.8 里,GBuffer 的每个通道都可以通过 Texture Viewer 单独检查。

老陈在分析 BasePass 时,发现场景里有一块地面特别亮。切换到 WorldNormal 通道后,看到那块区域的法线方向异常,说明法线贴图或切线空间有问题。进一步检查发现美术导入的地形材质用了错误的法线贴图压缩格式,导致 GPU 在采样时做了额外的 unpack 运算。

纹理检查的另一个重点是格式和 mip。RenderDoc 会显示每个纹理的 DXGI 格式、尺寸、mip 层级、内存占用。如果一个 512×512 的 UI 贴图用了 R8G8B8A8_UNORM 没问题,但一个 4096×4096 的环境光遮蔽贴图也用这个格式,内存和带宽就会爆炸。更合理的做法是压缩成 BC7 或 BC5,具体取决于通道用途。

UE5.8 里有一个容易被忽略的参数:r.Streaming.PoolSize。这个值控制纹理流送池的大小。如果池子不够,远处纹理会频繁被换入换出,导致 GPU 等待 upload。RenderDoc 的 Resource Inspector 可以看到每个纹理是否在 capture 时被 upload 到 GPU,结合 Texture Viewer 的 mip 显示,能判断流送是否跟上了镜头移动。

7. Shader 编译结果查看

找到慢的 draw call 后,下一步通常要看它到底在 GPU 上执行了什么指令。RenderDoc 的 Pipeline State 面板里可以打开 Vertex Shader 和 Pixel Shader 的 disassembly。UE5.8 默认编译的是 DXIL,所以你会看到 D3D12 中间语言的反汇编,而不是 HLSL 源码。

老陈看那个阴影 pass 的 pixel shader,发现里面有一大段取整和分支。原因是美术在材质里用了 if 判断来做风吹草地的动画,而这个材质也被用于阴影 pass。阴影 pass 不需要颜色输出,但这些分支和顶点动画仍然会被执行。

UE5.8 的阴影 pass 可以用 castshadows 相关的材质节点做优化,比如把顶点动画在阴影 pass 中关掉。更通用的做法是在 material graph 里用 Use Vertex Animation in Shadow Pass 开关,或者直接在材质里判断 IsShadowPass

// 在 custom node 或 HLSL 中判断当前是否在阴影 pass
#if DECAL_LIGHTING_INVOCATION == 1
    // 阴影 pass 专用的简化逻辑
#endif

除了反汇编,RenderDoc 还能显示 Shader 的 Resource Bindings,包括每个 constant buffer、texture、sampler 的绑定槽位。老陈曾遇到过一起 bug:一个后处理 material 采样了错误的纹理索引,导致画面出现条纹。Resource Bindings 里一眼就能看到 sampler 绑定的是第几张纹理,比猜代码快得多。

PIX 在 Shader 分析上比 RenderDoc 更强。PIX 的 Shader Debugger 可以单步执行 DXIL,查看每个线程的寄存器值,还支持在特定像素上设置断点。这个能力在调试计算着色器时尤其有用,比如 Niagara 的 GPU 模拟或者 Lumen 的 radiance cache 更新。

8. 性能瓶颈定位

GPU 瓶颈通常可以归为几类:几何压力、像素填充率、带宽、ALU 指令、同步开销。RenderDoc 和 PIX 提供的数据足以做初步分类。

如果某个 pass 的三角形数很多但像素数不多,瓶颈在几何端。解决思路是减少 draw call、合并 mesh、启用 Nanite、使用 LOD。

如果 pass 的像素数远超屏幕分辨率,说明 overdraw 严重。解决思路是从前到后排序不透明物体、减少透明物体重叠、使用 Early-Z 和 PrePass。

如果纹理采样次数多、带宽占用高,瓶颈在内存子系统。解决思路是压缩纹理、减少 mipmap 级数、合并贴图、使用 tiled resources。

如果 Shader 的 instruction count 很高,瓶颈在 ALU。解决思路是简化材质、减少分支、把复杂运算搬到 vertex shader 或 compute shader、使用 half precision。

老陈那个阴影 pass 同时占了几项:4096×4096 的阴影贴图带来高像素填充;大量远处物体带来高几何压力;材质分支带来高 ALU。他的修复方案也对应地做了三件事:降分辨率、限制投射距离、简化阴影 pass 的材质。

graph LR
    A["高三角形数"] -->|"几何压力"| G["启用 LOD / Nanite / 合批"]
    B["高 overdraw"] -->|"像素填充"| P["排序 / Early-Z / 减少透明"]
    C["高纹理采样"] -->|"带宽瓶颈"| Bw["压缩 / 合并 / 流送"]
    D["高指令数"] -->|"ALU 瓶颈"| A2["简化 shader / 减少分支"]
    E["长同步等待"] -->|"同步开销"| S["减少 barrier / 合并 pass"]

9. 平台差异

PC 和主机在 GPU Profiling 上有明显差异。PC 的 GPU 架构多样,驱动行为也不尽相同;主机架构固定,但内存和带宽预算更紧张。

RenderDoc 在 Windows 上支持所有主流显卡,但在某些旧驱动上可能无法捕获 DX12 的 RayTracing 命令。UE5.8 默认开启 Lumen 的硬件光追路径时,如果 capture 失败,可以尝试在控制台输入 r.Lumen.HardwareRayTracing 0 切回软件光追,再抓帧。

PIX 只能在 Windows 10/11 和 Xbox 上使用。PC 版 PIX 对 NVIDIA 和 AMD 显卡的支持略有差异,NVIDIA 显卡通常能完整使用所有功能,AMD 显卡在某些旧驱动上可能缺少 Occupancy 数据。Xbox 版 PIX 功能最全,但 capture 文件很大,一次 GPU Capture 动辄几个 G。

主机平台的另一个特点是 tile-based deferred rendering。Xbox Series X|S 和 PS5 的 GPU 都有某种形式的 tile cache,这意味着带宽瓶颈的表现和 PC 不一样。一个在 PC 上看起来带宽很高的 pass,在主机上可能因为 on-chip cache 而很快。反过来,一个在 PC 上很快的 pass,可能因为 tile 大小不合适而在主机上翻车。

老陈在把修复方案提交到 Xbox 版本时,用 PIX 的 Counter 视图确认了几个关键指标:

  • Primitives Culled:被剔除的图元比例,过低说明 CPU 端剔除不够好。
  • Pixels Shaded:实际着色的像素数,和 overdraw 相关。
  • Texture Cache Hit Rate:纹理缓存命中率,低于 90% 通常意味着纹理采样模式或 mipmap 有问题。
  • Wave Occupancy:wave 占用率,低于 50% 说明 shader 寄存器压力大或分支发散严重。

UE5.8 在主机上还有一些平台专属控制台变量,比如 r.Xbox.One.EnableTiledResourcesr.PS5.AsyncCompute。这些变量会影响渲染路径,抓帧时要注意当前开启的是哪条路径。

10. 和 Insights 配合看 CPU/GPU 关联

GPU Profiling 不是孤立的。很多时候 GPU 的瓶颈根因在 CPU,比如 draw call 提交太晚导致 GPU 空等,或者资源 upload 不及时导致 GPU 停滞。反过来,CPU 也可能在等 GPU 的 Fence。

老陈在修完阴影 pass 后,帧率从 37 回到 58,但偶尔还会掉到 45。他用 Unreal Insights 抓了一段 trace,发现 Render Thread 上每隔几帧就会出现一次 WaitForFrameEvent,持续 2 到 3 毫秒。这说明 GPU 还没画完上一帧,CPU 在等 Present。

进一步用 RenderDoc 的 Statistics 面板按时间排序,他发现 PostProcessing 里的 Motion Blur 偶尔会出现一个长条。原因是 Motion Blur 依赖速度缓冲,而速度缓冲的生成和场景的动态物体数量有关。某些帧里敌人刷新较多,速度缓冲生成变慢,拖累了整个 PostProcessing pass。

这种 CPU/GPU 耦合问题,单看一个工具很难找到根因。老陈的工作流是先用 stat unit 看 Frame、Game、Draw、GPU、RHIT 哪一行最高,再用 GPU Visualizer 拆 GPU 时间,最后用 RenderDoc 或 PIX 抓具体帧,必要时配合 Insights 看 CPU 侧。

整帧的 GPU 时间可以粗略表示为各个 pass 的累加:

TGPU=iTpass(i)+Tsync+TidleT_{GPU} = \sum_{i} T_{pass}^{(i)} + T_{sync} + T_{idle}

其中 Tpass(i)T_{pass}^{(i)} 是第 ii 个渲染 pass 的耗时,TsyncT_{sync} 是管线同步和 barrier 等待,TidleT_{idle} 是 GPU 空等 CPU 提交命令的时间。优化的目标不是把每个 pass 都压到最低,而是让 TGPUT_{GPU} 低于帧预算。对于 60fps,预算就是:

Tbudget=10006016.67msT_{budget} = \frac{1000}{60} \approx 16.67 \, \text{ms}

如果 TGPUT_{GPU} 已经超过预算,就要按 pass 耗时排序,优先优化占比最高的部分。老陈的原则是:先解决 Tpass(i)T_{pass}^{(i)} 里的大头,再处理同步和空闲。

11. 实战:抓帧修复完整流程

回到老陈那次深夜排障。完整流程可以拆成以下几步:

第一步,复现问题。他让测试组在废墟战场关卡跑了五分钟,确认掉帧和镜头转向到聚光灯方向有关。控制台输入 stat gpu,看到 ShadowDepths 占比超过 50%。

第二步,抓帧分析。他用 RenderDoc 启动游戏,在掉帧最严重的瞬间按下 F12。打开 capture 后,先按 Event Browser 的 Duration 排序,确认 ShadowDepths 是最慢的 pass。

第三步,逐层下钻。展开 ShadowDepths,找到耗时 2.3 毫秒的那个 draw call。检查它的 viewport、PSO、Shader、Resource Bindings,确认是 4096×4096 的动态聚光灯阴影贴图。

第四步,交叉验证。他同时用 GPU Visualizer 按材质排序,确认这个阴影 pass 没有包含本不该投射阴影的材质。又用 Texture Viewer 看了阴影贴图,发现边缘有大量空像素。

第五步,制定修复。限制聚光灯 MaxDrawDistance、启用缓存阴影贴图、降低阴影分辨率缩放、关闭阴影 pass 中不必要的顶点动画。

第六步,重新验证。修改后重新抓帧,ShadowDepths 从 8.7 毫秒降到 2.1 毫秒,帧率回到 58。再用 PIX 在 Xbox 上验证,确认没有引入新的平台问题。

第七步,设置预算。他和渲染组约定,单个动态光源的阴影 pass 不能超过 1 毫秒,整个 ShadowDepths 不能超过 4 毫秒。后续新增光源都要先抓帧确认不超标。

flowchart LR
    A["复现掉帧"] --> B["stat gpu 定位 ShadowDepths"]
    B --> C["RenderDoc 抓帧"]
    C --> D["按 Duration 排序找到最慢 draw call"]
    D --> E["检查 viewport/PSO/Shader/纹理"]
    E --> F["限制距离/降分辨率/开缓存"]
    F --> G["重新抓帧验证"]
    G --> H["PIX 验证主机版本"]
    H --> I["设定 pass 级性能预算"]

12. 常见陷阱

RenderDoc 和 PIX 功能强大,但用不好也会踩坑。下面列几个老陈踩过或见过的问题。

第一,capture 时机不对。 游戏加载画面、着色器编译、PSO 缓存预热阶段都不具备代表性。要跑到稳定状态再抓帧,最好多抓几帧取最差的一帧。

第二,只看总时间不看占比。 一个 pass 耗时 5 毫秒听起来很高,但如果整帧只有 12 毫秒,它就不是瓶颈。要先看占比,再决定优化优先级。

第三,忽略 sync 和 idle。 GPU 时间线里的大段空白不代表没事,可能是 CPU 提交太晚或资源没准备好。要结合 Insights 或 PIX 的 Timing Capture 一起看。

第四,优化后不看其他 pass。 压低了 ShadowDepths 可能让 BasePass 变成瓶颈。每次修改后都要重新抓帧,看整体帧时间是否下降。

第五,在编辑器里捕获但按打包版本优化。 编辑器的渲染路径和打包版本不完全一致,比如编辑器的某些 debug 视图、碰撞可视化会额外产生 draw call。关键优化要在打包版本上验证。

结语

老陈那次修复提交后,美术组和策划组都没再为帧率吵过架。不是他们变客气了,而是 RenderDoc 把时间拆成了具体的 pass 和 draw call,把争论变成了数据。

GPU Profiling 的精髓不在工具本身,而在于建立一套现象到根因的排查路径。先用 stat unit 和 GPU Visualizer 看大局,再用 RenderDoc 或 PIX 抓帧下钻到具体 draw call,最后用 Texture Viewer、Pipeline State、Shader Disassembly 找到真正耗时的原因。平台差异不能忽略,PC 上验证完还要在主机上用 PIX 再过一遍。

UE5.8 的渲染管线越来越复杂,Lumen、Nanite、Virtual Shadow Maps、Temporal Super Resolution 这些新技术在带来画面提升的同时,也让 GPU 瓶颈更隐蔽。养成抓帧分析的习惯,比上线前临时救火要靠谱得多。