UE5.8 渲染性能优化
一个周五下午的卡顿现场
周五下午四点半,策划小林冲进美术组工位,手里端着一杯咖啡,脸上没有周末将至的喜悦。刚合进主线的「废弃工厂」关卡在 PS5 上跑出了 35 帧,GPU 时间飙到 28 毫秒,离目标 60 帧还差得远。我们几个人围到显示器前,按下调试键,视口瞬间被大片红色和刺眼的白色填满:Shader Complexity 视图里整面墙都是深红,Overdraw 视图里粒子区域白得发亮,stat unit 的 GPU 栏比 Game 栏高出一倍还多。
那场面像极了急诊室的心电图,所有指标都在报警。没人急着改材质,也没人直接砍特效。我们先做了一件最基础的事:打开视图模式,把问题分类。这一篇文章,就是把那天晚上积累的经验整理成一份 UE5.8 渲染性能优化 checklist,方便以后新关卡合进来时不再踩同样的坑。
渲染瓶颈在哪里
在动手优化之前,得先知道时间花在哪。CPU 在准备渲染命令,GPU 在执行这些命令,任何一边卡住都会表现为掉帧。我们当天的场景里,GPU 是瓶颈,所以优化方向集中在减少 GPU 工作量上。一个粗略但够用的性能模型可以写成:
T_{\\text{frame}} = T_{\\text{CPU}} + T_{\\text{GPU}} \\approx T_{\\text{submit}} + \\sum_{i=1}^{n} D_i \\cdot C_i + \\sum_{j=1}^{m} S_j \\cdot P_j其中 是第 个 Draw Call 的固定开销, 是它对应的命令缓冲和状态切换成本; 是第 个 Shader 每像素执行的指令数, 则是该 Shader 实际覆盖的像素数。想让 T_{\\text{frame}} 低于 16.67 毫秒以维持 60 帧,就得同时压降 、、、 这四项。下面分八个方向展开。
1. Draw Call 优化
为什么 Draw Call 会高
每一个 Draw Call 都意味着 CPU 要告诉 GPU 一次「现在画这个东西」。如果场景里有几千个小石块、几百盏独立的灯、大量单独放置的植被,Draw Call 就会膨胀。CPU 提交命令的时间变长,GPU 也会频繁在状态间切换,管线利用率下降。
UE5.8 里的常用手段
静态不透明几何体尽量交给 Nanite,它会自动把高模拆成微三角形簇并按需要调度,Draw Call 基本不再按原始模型数量线性增长。对于无法使用 Nanite 的对象,比如骨骼网格体、半透明物体、植被,可以用 Instanced Static Mesh(ISM)或 Hierarchical Instanced Static Mesh(HISM)把同一份 mesh 合并成一次绘制调用。相同材质的静态网格还可以用 Actor Merge 工具合并成一个模型,减少状态切换。
下面这张架构图展示了 CPU 提交到 GPU 执行的主要路径,以及我们希望在 CPU 侧把多个小 Draw Call 合并成批量提交的思路。
flowchart TD
scene --> cull
cull --> batch
batch --> rhi
rhi --> setup
setup --> raster
raster --> shader遮挡剔除也能间接降低 Draw Call。UE5.8 默认启用 Hardware Occlusion Queries,配合 Hierarchical Z-Buffer(HZB)把被遮挡的物体提前剔除。如果远处建筑被近处墙体挡住,就不必再提交这些命令。控制台命令 r.HZBOcclusion 可以确认是否开启,多数情况下保持默认即可。
# 查看当前 Draw Call 与批次情况
stat scenerendering
stat rhi
# 确认 HZB 遮挡剔除状态
r.HZBOcclusion 1合并 Draw Call 时要注意材质种类。合并后的 mesh 如果材质不同,GPU 仍需要分批次绘制,合并收益会被抵消。比较好的做法是按材质把物体分组,再决定是否合并。
2. Shader Complexity 优化
红色区域从哪来
小林指着 Shader Complexity 视图里一片深红说:「这面墙不是只有一张砖纹贴图吗,怎么会红成这样?」我们切到材质编辑器才发现,美术为了做湿润效果,在基础色上叠了四层噪声,又在像素深度偏移里加了世界坐标运算,最后还在半透明层加了折射。Shader Complexity 视图用颜色表示每像素指令数,绿色通常代表 50 条指令以内,黄色 100 左右,红色往往超过 300,白色甚至接近上千。
降复杂度的具体做法
最直接的办法是砍掉不需要的效果。那面墙并不需要同时做湿润、折射和动态深度偏移,保留法线加粗糙度变化就够了。另一个常见陷阱是 Metallic 和 Specular 同时精细计算,如果物体表面本来就是粗糙水泥,直接把 Roughness 拉到 1,勾选 Full Rough,可以省掉一整套镜面反射计算。
自定义节点(Custom Node)和 Material Layers 也容易失控。Custom Node 里写十几行 HLSL 看起来很酷,但编译后可能生成大量指令。建议把能在顶点着色器里算的东西挪到顶点阶段,比如基于高度的颜色变化、简单的风动偏移。顶点数通常远小于像素数,能省很多。
下面这张流程图展示了我们在遇到红色区域时的排查顺序。
flowchart TD
A["打开 Shader Complexity 视图"] --> B{出现红色区域}
B -->|"是"| C["检查材质节点复杂度"]
C --> D{是否使用了 Custom 节点}
D -->|"是"| E["把计算移到顶点或后处理"]
D -->|"否"| F["检查贴图叠加层数"]
F --> G["合并或删除冗余贴图"]
E --> H["重新烘焙并对比帧时间"]
G --> H
B -->|"否"| I["检查 Light Complexity 与 Shadow"]还有一个容易忽略的点:Masked 混合模式比 Opaque 慢,Translucent 又比 Masked 慢。能做成不透明就别做 Masked,能做 Masked 就别做 Translucent。粒子特效里常出现大量半透明片,后文会单独讲 Overdraw 的控制。
3. Overdraw 控制
白色区域就是烧钱
Overdraw 视图用亮度表示一个像素被重绘了多少次。屏幕上一片白光意味着同一个像素被十几个半透明层反复着色。当天工厂关卡里冒火花的粒子特效、地面上的油污贴花、角色身后的沙尘,三层半透明叠在一起,几乎把屏幕中央刷白了。每多一层,GPU 就要再跑一次像素着色器,帧时间自然爆炸。
降低层数的方法
粒子发射器里的 Max Drawn 和 Screen Size 是两个最直接的参数。把远离相机或尺寸很小的粒子提前裁掉,能显著减少填充。对于烟雾、尘土这类大面积半透明效果,可以尝试 Temporal Anti-Aliasing 下的 Dithered Opacity,把渐变透明转换成抖动裁切,视觉上仍柔和,但 GPU 只需要跑一次不透明路径。
贴花也要控制密度。UE5.8 支持 Deferred Decal,但一张贴花就是一次全屏或半屏的四边形绘制。地面上的污渍、弹孔、油渍如果无限叠加,Overdraw 会高得离谱。建议把相近的贴花合并到一张 Atlas,并用 Fade Start/End Distance 控制显示距离。
不透明物体可以借助 Early-Z 减少 Overdraw。先画一遍不透明几何体,只写深度不写颜色,后续像素着色器只在可见片段上执行。开启 r.EarlyZPass 后,GPU 的 Hidden Surface Removal 效率会提高,但多一次 Prepass 本身也有开销,需要实测。
graph TD
A["不透明几何体"] --> B["Early-Z 深度预写入"]
B --> C["半透明粒子"]
C --> D["Deferred Decal"]
D --> E["UI 与后处理"]
E --> F["最终像素 Overdraw"]
style F fill:#f66,stroke:#333上图中,不透明层通过 Early-Z 挡住了大量后续像素,如果跳过这一步,半透明层会在不可见区域白白执行。
4. LOD 与 HLOD
远小近大的屏幕占比
角色离相机越远,占的像素越少,没必要用八千面的高模。LOD(Level of Detail)根据 Screen Size 自动切换低模,可以在不损失近景质量的前提下,把远景面数压到原来的几分之一。UE5.8 的 Skeletal Mesh 和 Static Mesh 都支持 LOD,Foliage 还有专门的 LOD 曲线。
HLOD(Hierarchical Level of Detail)再往前走了一步。它把相距不远的多个 Actor 合并成一个代理模型,远景直接绘制一个合并后的低模,而不是分别绘制每个物体。对于工厂关卡里成片的管道、箱子、货架,HLOD 把几百个 Draw Call 压缩成几个,效果立竿见影。
下面这张时序图展示了相机移动时 LOD 与 HLOD 的协作过程。
sequenceDiagram
participant Cam as Camera
participant LOD as "LOD Selector"
participant HLOD as "HLOD Cluster"
participant RHI as RHI
Cam->>LOD: 计算 ScreenSize
LOD->>LOD: 选择对应 LOD 索引
alt ScreenSize 低于 HLOD 阈值
LOD->>HLOD: 请求聚合 Mesh
HLOD->>RHI: 提交合并 Draw Call
else
LOD->>RHI: 提交原始 Draw Call
end配置要点
LOD 切换阈值不要设得太激进,否则玩家会明显看到模型跳变。可以在 Project Settings 里调整 Default LOD Screen Size 和 LOD Distance Check Time,让切换更平滑。HLOD 的生成需要烘焙时间,大型关卡建议在 CI 里跑 HLOD 构建,避免本地机器卡住。
Nanite 与 HLOD 的关系也常被误解。Nanite 负责静态不透明几何体的高模虚拟化,HLOD 负责中远景的聚合与剔除,两者互补。对于骨骼网格、植被、半透明物体,仍然需要传统的 LOD 流程。
5. Light Complexity
动态光是奢侈品
Light Complexity 视图里,颜色越红表示一个像素受影响的动态光源越多。移动光源(Movable Lights)会在每个像素上重新计算光照和阴影,成本随光源数量线性上升。一个房间里放八盏可移动点光源,地板中心那片区域可能就要跑八次光照计算,还不包括阴影。
我们的做法是把大部分光源改成 Static 或 Stationary。Static Light 完全烘焙到光照贴图,运行时几乎没有开销。Stationary Light 可以参与烘焙,同时保留部分动态属性,比如改变亮度和颜色,但阴影仍然是静态烘焙的。只有真正需要动态变化的光源才保留 Movable。
UE5.8 引入的 Lumen 和 MegaLights 改变了部分规则。Lumen 让动态全局光照更便宜,MegaLights 让区域光源带阴影的成本下降很多。但它们不是免费的,光源总数仍然要控制。一个经验值是:场景中同时影响一个像素的 Movable Light 不要超过 2 到 3 盏。
光照复杂度可以用下面这个简化公式理解:
C_{\\text{pixel}} = k \\cdot I_{\\text{shader}} \\cdot L_{\\text{lights}}其中 C_{\\text{pixel}} 是单个像素的光照开销,I_{\\text{shader}} 是材质本身的指令数,L_{\\text{lights}} 是影响该像素的有效光源数。 是渲染路径相关的常数。这个式子说明,降低 Shader Complexity 和减少光源数是相辅相成的。
6. Shadow 优化
阴影是帧时间的黑洞
关掉阴影,帧数往往能直接涨 20%。当然不能简单关掉,得有选择地控制。动态阴影主要分两类:Cascade Shadow Maps(CSM)用于方向光,Shadow Maps 用于点光源和聚光灯。每盏投射动态阴影的光都会渲染一次或多次阴影贴图,分辨率越高,开销越大。
实用参数
方向光阴影可以调 Dynamic Shadow Distance、Num Cascades、Cascade Distribution Exponent。把 CSMDistance 从 20000 降到 8000,远处的山改用 Distance Field Shadow 或干脆不投阴影,能省出大量 GPU 时间。r.Shadow.Virtual 开启 Virtual Shadow Maps 后,贴图分辨率按需求分配,缓存稳定阴影,移动缓慢的物体阴影复用率很高。
点光源和聚光灯尽量少开 Cast Shadows。局部灯光可以用 Contact Shadows 代替传统 Shadow Map,适合屏幕空间内的短阴影,成本低很多。角色和重要道具保留动态阴影,背景杂物一律关闭。
sequenceDiagram
participant Sun as "Directional Light"
participant CSM as "CSM Generator"
participant VSM as "Virtual Shadow Map"
participant Scene as "Scene Depth"
Sun->>CSM: 按级联渲染深度
CSM->>Scene: 采样方向光阴影
Sun->>VSM: 缓存虚拟贴图页
VSM->>Scene: 按需解析阴影Distance Field Shadows 对远景静态物体很有用。它预计算网格的距离场,阴影走样少,开销稳定。 skeletal mesh 和形变物体不支持,需要与 CSM 搭配使用。
7. Texture Streaming
贴图池满了会怎样
Texture Streaming Pool 是 GPU 显存里专门留给流送贴图的区域。Pool Size 配小了,远处贴图会模糊,贴图加载时还会卡顿;配大了,可能挤占其他资源。UE5.8 默认根据平台自动设置,但大型项目通常需要手动调整。
控制台命令 stat streaming 可以看到当前 Pool 使用情况。如果 Pool Memory Used 长期接近 Pool Size,就应该扩容或减贴图。PC 上我们常设到 2000 MB 以上,主机则按平台文档配置。
[/Script/Engine.RendererSettings]
r.Streaming.PoolSize=2048
r.Streaming.LimitPoolSizeToVRAM=1
r.Streaming.MipBias=0Mip Bias 是另一个杠杆。把 r.Streaming.MipBias 从 0 调到 1,系统会倾向加载低一级 Mip,显存占用和带宽都下降,画面会稍微变糊。UI、角色、近景重要物件可以单独设置 NeverStream 或调低 Streaming Distance Multiplier。
贴图分辨率也要按用途给。一个远处只看得到几十像素的小箱子贴 4K 贴图,既浪费显存也浪费带宽。建议制定一套贴图规格:角色 2K,场景主要墙面 1K 到 2K,远景道具 512 或更小,贴花 1K。
总显存占用可以粗略估算为:
M_{\\text{texture}} = \\sum_{i=1}^{k} w_i \\cdot h_i \\cdot \\text{bpp}_i、 是第 张贴图的宽高,\\text{bpp}_i 是每像素字节数。减掉一半分辨率,面积就降到四分之一,这个收益非常直观。
8. 视图模式与调试
先把问题量化
前面讲的每一项优化,都需要有数据支撑。UE5.8 提供了一系列视图模式和控制台命令,能让我们把「卡」翻译成具体的数字。
stat fps/stat unit:看帧率和 CPU/GPU 占比。stat gpu:列出 GPU 各阶段耗时,定位瓶颈在 Base Pass、Lighting、Shadow、Post Processing 还是 Translucency。stat scenerendering:看 Draw Call、Primitive 数量、遮挡剔除效果。profilegpu:抓取一帧的 GPU 剖面,配合 RenderDoc 或 PIX 查看详细调用。
视图模式方面:
- Shader Complexity:识别材质指令爆炸区域。
- Light Complexity:看一个像素受多少光源影响。
- Overdraw:看半透明填充层数。
- Quad Overdraw:按 2x2 像素四边形统计 Overdraw,更接近 GPU 实际开销。
- LOD Coloration:用颜色标识当前使用的 LOD 级别。
我们的调试流程一般是:先用 stat unit 确认是 CPU 还是 GPU 瓶颈;再用 stat gpu 看哪个阶段最大;然后切到对应视图模式,找到具体资源;最后修改材质、灯光、模型或 LOD 设置,重新抓取对比。
# 常用调试命令组合
stat fps
stat unit
stat gpu
stat scenerendering
stat streaming
profilegpuRenderDoc 和 PIX 能抓到 API 级别的调用。对于 PC 项目,RenderDoc 直接集成在编辑器里;主机平台则用 PIX。剖面时重点看顶点着色器、像素着色器、带宽占用和 Render Target 切换,不要凭感觉改。
从 35 帧到 60 帧那晚我们改了什么
回到周五晚上。我们把问题拆成上面八类后,实际改动并不大:
- 把工厂墙面材质里的四层噪声砍到一层, wetness 效果改用 Roughness 贴图模拟,Shader Complexity 从深红变成黄绿。
- 粒子特效启用 Dithered Opacity,并把远处粒子的 Screen Size 下限从 0.1 调到 0.3,Overdraw 视图里的白光区域小了一半。
- 背景杂物合并成 HLOD,远景 Draw Call 从 1200 降到 80。
- 房间里的六盏点光源改成 Stationary,只保留一盏 Movable 用于玩家手电筒,Light Complexity 视图从红变蓝。
- 方向光 CSM 距离从 15000 压到 7000,远处静态物体改用 Distance Field Shadow。
- Texture Streaming Pool 从 1024 提到 2048,并把十几张 4K 远景贴图压到 1K。
改完后 PS5 上帧时间从 28 毫秒降到 15 毫秒,稳定 60 帧。美术组和策划周末可以加餐了。
几个容易漏掉的细节
有些优化点不在主流程里,但排查时经常让人绕远路。Nanite 网格如果开启了 Fallback Mesh,低端平台会退回到传统 LOD,退回来的模型面数如果没调好,Draw Call 和面数会一起反弹。建议为关键 Nanite 资源单独设置 Fallback 的 Screen Size 阈值,避免过早切换。
Light Function 是另一类隐藏开销。很多项目拿它做投影图案或假体积光,但它本质上是按光源范围跑一次额外着色。如果一盏灯带了 Light Function 又投了动态阴影,成本会叠加。能用贴花或后处理雾替代的方案,就别挂在灯光上。
Shadow Cast 选项也要逐个检查。导入美术资源时,默认常常开启 Cast Dynamic Shadow,椅子、杯子、纸箱这些细小物体全部投阴影,毫无意义。写一个批量检查脚本,把包围盒小于一定尺寸的静态物体阴影关掉,能清掉一大批无用阴影贴图绘制。
贴图流送里还有一个 NeverStream 开关。角色、武器、UI 这类玩家时刻盯着的内容可以开,避免战斗中模糊。但场景远景开了 NeverStream,就会一直占用高分辨率 Mip,把 Pool 吃光。建议按资产重要性做白名单,而不是顺手全部勾选。
小结
渲染优化没有一招鲜,也没有固定的参数模板。核心思路是把帧时间拆成 Draw Call、Shader Complexity、Overdraw、LOD、光照、阴影、贴图流送这几个维度,再用 UE5.8 提供的视图模式和剖面工具定位具体资源。每一次改动都要用数据验证,避免拍脑袋。
如果只能记住三件事:
- 先用
stat unit和stat gpu确定瓶颈在 CPU 还是 GPU,以及具体阶段。 - 红色 Shader Complexity 和白色 Overdraw 是最直接的信号,优先处理。
- 合并、裁剪、降级、烘焙,永远是降低成本的四板斧。
下次再遇到红色视口,不必慌张,按这个流程走一遍,多数问题都能收敛。