1. 从一次掉帧事故说起:为什么要懂 GPU 执行指令
去年调一个开放世界场景,PC 端跑得挺稳,一到移动端就掉到 22 帧。用 Profiler 抓下来一看,Base Pass 的 ALU 占用高得离谱,Shader 指令数比预期多了将近三倍。当时第一反应是模型面数或者 Draw Call 的问题,排查了一圈才发现,罪魁祸首是几个看起来"人畜无害"的材质节点——一个Frac接Sin再接Pow,在 PC 上被编译器优化掉了,在移动端却老老实实全量执行。
这件事让我彻底意识到一个问题:做 UE 渲染优化,如果不懂 GPU 到底怎么执行你写的 Shader,那你所有的优化都是盲猜。你看到的节点图只是"意图",真正跑在显卡上的是编译器翻译出来的指令流,中间隔着寄存器分配、指令调度、SIMD 打包好几层。今天这篇就聊聊 UE Shader 优化这件事,从 GPU 执行指令的底层逻辑讲起,把 ALU、寄存器、指令周期这些概念串起来,最后落到实际项目里能直接用的优化手段。
这篇文章适合谁看?如果你是中高级 TA、渲染向的程序,或者做移动端优化的客户端开发,那基本每一段都能对上你的日常。如果你刚接触 UE 材质系统,也没关系,我会用生活化的类比把底层概念讲清楚,你至少能明白"为什么我改一个节点,帧率会变"。
核心关键词先摆出来:UE、Shader、GPU、ALU、寄存器。这五个词基本构成了整条优化链路的主干,后面所有内容都围绕它们展开。
2. GPU 执行指令的底层逻辑:先搞懂硬件在干什么
2.1 从 CPU 到 GPU:两种完全不同的执行哲学
要理解 Shader 优化,得先接受一个反直觉的事实:GPU 不是"更快的 CPU",它是为吞吐量而生的另一套架构。
CPU 的核心思路是"把单条指令做到极致快"。它有复杂的乱序执行、分支预测、超大缓存,为的是让一个线程里的指令尽可能流水线化。你写个if-else,CPU 会预测你走哪条分支,提前把指令预取进来,猜错了就清空流水线重来,代价是几十个周期。
GPU 的思路完全相反:它不关心单条指令多快,它关心同一时刻能并行处理多少条指令。一个现代 GPU 有几千个 ALU 核心,它们被组织成一个个 SIMD 单元(在 NVIDIA 架构里叫 Warp,32 个线程一组;AMD 叫 Wavefront,通常 64 个)。同一组里的所有线程必须执行同一条指令,只是操作的数据不同。
这就引出了 Shader 优化里最核心的一条铁律:分支是 GPU 的天敌。如果一组 32 个线程里,有 16 个走if分支、16 个走else分支,GPU 不会并行执行两条路径,而是先让走if的那 16 个执行、另外 16 个空转,再反过来执行一遍。等于两条分支的代价全付了,还浪费了一半算力。这就是所谓的Warp Divergence(线程束发散)。
提示:在 UE 里写材质时,能用
Lerp、Step、Saturate这类无分支运算替代if的地方,尽量替代。移动端尤其敏感。
2.2 ALU 到底在算什么:指令周期与吞吐量
ALU(Arithmetic Logic Unit,算术逻辑单元)是 GPU 里真正干活的单元,加减乘除、点乘、比较、位运算都归它管。但不同运算的"成本"是不一样的,这个成本通常用指令周期或者吞吐量来衡量。
在大多数现代 GPU 架构里,一个 ALU 单元每个周期能完成的操作大致分几档:
| 运算类型 | 典型吞吐量 | 说明 |
|---|---|---|
| FMA(乘加融合) | 1/周期 | 最高效,a*b+c一条指令搞定 |
| 加法/减法 | 1/周期 | 基础运算 |
| 乘法 | 1/周期 | 基础运算 |
| 除法 | 1/4~1/8 周期 | 需要多步迭代,成本高 |
| 三角函数(Sin/Cos) | 1/4~1/16 周期 | 查表或多项式逼近 |
| Pow/Exp/Log | 1/4~1/8 周期 | 依赖指数对数单元 |
| 开方 Sqrt | 1/4 周期 | 迭代逼近 |
这张表是理解 Shader 优化的关键。一个Pow的代价可能等于 4 到 8 个FMA。你在材质里随手连一个Power节点,可能就吃掉了整个像素着色器预算的一大块。
我见过太多项目,材质里到处是Sin、Cos、Pow、Normalize,单个看没问题,几百个材质叠加起来,ALU 直接爆掉。移动端 GPU 的 ALU 数量本来就少,这种写法基本等于自杀。
2.3 寄存器:Shader 里最稀缺的资源
寄存器是 GPU 上速度最快的存储,比显存快几个数量级,但数量极其有限。一个 Shader 程序能用的寄存器数量是有硬上限的,比如某些移动端架构每个线程只能用 64 个或 128 个寄存器。
寄存器的作用是暂存中间计算结果。你写的每一行 Shader 代码,编译器都要决定:这个中间值放寄存器里,还是算完就扔?如果寄存器不够用,编译器就会把一些值"溢出"到更慢的存储(比如 Local Memory),性能直接断崖式下跌。
这里有个反直觉的点:寄存器用得越多,不一定越好,但用得越少,往往意味着更多的重复计算。编译器在两者之间做权衡。你写 Shader 时如果中间变量特别多、依赖链特别长,寄存器压力就会很大,编译器可能被迫 spill(溢出),这时候性能反而更差。
注意:在 UE 里查看 Shader 的寄存器使用情况,可以在
ConsoleVariables.ini里开启r.ShaderDevelopmentMode=1,配合r.DumpShaderDebugInfo=1,编译后会输出每个 Shader 的指令数和寄存器占用。这是排查问题的第一步。
2.4 指令流水线:为什么"顺序"很重要
GPU 的 ALU 是流水线化的,一条指令从取指、译码、执行到写回,分好几个阶段。理想情况下,每个周期都能发射一条新指令,流水线满载。但如果指令之间有依赖——比如第二条指令要用第一条的结果——那第二条就得等第一条算完,流水线就出现气泡(Bubble)。
这就是指令级并行(ILP)的意义。编译器会尝试重排指令,把没有依赖的运算穿插在一起,填满流水线。但如果你写的 Shader 依赖链特别长,比如A = f(B); C = g(A); D = h(C);一路串下去,编译器再聪明也没法并行,只能干等。
实操里的经验是:尽量让独立的计算并行展开。比如你要算三个不相关的光照项,别写成串行依赖,让它们各自独立计算最后再合并。这样 GPU 的多个 ALU 通道能同时开工。
3. UE Shader 编译链路:你的节点是怎么变成指令的
3.1 从材质图到 HLSL:中间发生了什么
在 UE 里,你在材质编辑器里连的每一个节点,最终都会被翻译成 HLSL 代码,再编译成 GPU 指令。这个链路大致是:
- 材质图解析:UE 把节点图转成内部的表达式树
- HLSL 生成:表达式树翻译成 HLSL 源码,这一步会做常量折叠、死代码消除等基础优化
- Shader 编译:HLSL 经过 DXC/HLSLcc 编译成目标平台的字节码(DXIL、SPIR-V、Metal 等)
- 驱动编译:显卡驱动再把字节码编译成真正的机器指令
关键点在于:第 2 步和第 3 步的优化能力是有限的。UE 的 HLSL 生成器不会做激进的代数化简,它基本是"忠实翻译"你的节点图。所以你在材质里连了Multiply再Divide,它不会自动帮你约掉,除非编译器在后续阶段发现并优化。
我实测过一个案例:材质里有个A * B / B的写法,本意是归一化,但 B 可能为 0 所以加了保护。结果 UE 生成的 HLSL 里这个乘除原封不动保留,编译器也没优化掉(因为有除零风险)。改成A直接输出后,指令数少了 6 条,帧率在移动端涨了 3 帧。
3.2 指令数怎么看:Profiler 里的关键指标
UE 的 Shader Profiler 会给出每个 Shader 的指令数统计,主要看这几个:
- Instruction Count:总指令数,最直观的指标
- ALU Instruction Count:算术指令数,优化重点
- Texture Instruction Count:纹理采样指令数
- Register Count:寄存器占用
在移动端,一个像素着色器的 ALU 指令数控制在50 条以内是比较安全的,超过 100 条就要警惕了。PC 端宽松一些,但也不建议超过 200 条。
提示:
r.Mobile.ShaderComplexity这个命令可以在移动端预览时直接显示 Shader 复杂度热力图,红色区域就是 ALU 重灾区,非常直观。
3.3 变体爆炸:Shader 优化的隐形杀手
UE 的材质系统有个特性叫Static Switch和Static Component Mask,它们会在编译期生成不同的 Shader 变体。一个材质如果有 3 个 Static Switch,理论上就有 2³ = 8 个变体。项目里几百个材质,变体数量轻松上万。
变体本身不直接影响运行时性能,但会影响:
- 包体大小:每个变体都要存一份编译后的字节码
- 编译时间:变体越多,打包越慢
- PSO 卡顿:运行时切换变体可能触发 PSO 编译,造成卡顿
优化变体的核心思路是:能用动态分支的地方,评估是否真的需要 Static Switch。如果某个开关在运行时几乎不变,用 Static Switch 合理;如果经常变,用动态分支或者参数插值可能更划算。
4. 实操:从指令层面优化一个真实 Shader
4.1 案例背景:一个"看起来很正常"的材质
拿一个常见的角色皮肤材质举例。原始节点图大致是这样:
- 基础色贴图采样
- 法线贴图采样
- 粗糙度贴图采样
- 一个 Fresnel 边缘光
- 一个简单的三光源 Blinn-Phong 高光
- 一个基于距离的雾效混合
看起来没什么问题,但 Profiler 显示 ALU 指令数 187,移动端跑起来明显吃力。
4.2 逐项拆解:哪些指令可以砍
第一刀:Fresnel 边缘光。原始写法用了Pow(1 - Dot(N, V), Power)。Pow在移动端成本很高,而且1 - Dot之后还要Saturate。改成Saturate(1 - Dot(N, V))然后直接乘系数,省掉Pow,视觉差异在大多数场景下几乎看不出来。这一刀砍掉约 12 条指令。
第二刀:Blinn-Phong 高光。原始写法对三个光源各算一次Normalize(HalfVector)再Pow。Normalize涉及开方和除法,成本极高。优化方案是:把 HalfVector 的归一化提到循环外,或者用近似公式替代精确归一化。三个光源合并计算,省掉约 30 条指令。
第三刀:雾效混合。原始写法用了Exp2做指数雾。Exp2成本中等,但可以预计算到顶点着色器里,用插值传给像素着色器。这一刀把像素着色器的负担转移到顶点着色器,省掉约 8 条指令。
第四刀:贴图采样合并。粗糙度、金属度、AO 三张图可以打包到一张图的 RGB 通道里,减少采样次数。纹理采样虽然不占 ALU,但占带宽和采样单元,移动端同样敏感。
优化后,ALU 指令数从 187 降到 94,移动端帧率从 22 涨到 41。
4.3 关键代码对比
优化前的 HLSL 大致是这样:
// 优化前 float3 N = normalize(Normal); float3 V = normalize(ViewDir); float fresnel = pow(1.0 - saturate(dot(N, V)), 5.0); float3 H1 = normalize(L1 + V); float3 H2 = normalize(L2 + V); float3 H3 = normalize(L3 + V); float spec1 = pow(saturate(dot(N, H1)), 32.0); float spec2 = pow(saturate(dot(N, H2)), 32.0); float spec3 = pow(saturate(dot(N, H3)), 32.0); float fog = exp2(-FogDensity * FogDistance);优化后:
// 优化后 float3 N = normalize(Normal); float3 V = ViewDir; // 已在顶点着色器归一化 float fresnel = saturate(1.0 - dot(N, V)); float3 H = L1 + L2 + L3 + V * 3.0; // 合并近似 float spec = saturate(dot(N, normalize(H))); spec = spec * spec; // 替代 pow(x, 2) spec = spec * spec; // 替代 pow(x, 4) spec = spec * spec; // 替代 pow(x, 8) // 雾效在顶点着色器计算,这里直接插值 float fog = FogFactor;注意pow(x, 2)用x * x替代,pow(x, 4)用两次平方,pow(x, 8)用三次平方。这是最经典的指令优化技巧,因为乘法是 1 周期,Pow是 4 到 8 周期。
4.4 寄存器压力的处理
优化过程中还遇到一个坑:中间变量太多,寄存器溢出。解决办法是缩短变量生命周期。比如H1、H2、H3算完就扔,不要保留到后面。UE 的 HLSL 生成器有时候会保留一些不必要的中间变量,手动在 Custom Node 里重写关键段落能有效降低寄存器压力。
实操心得:在 Custom Node 里写 HLSL 时,尽量用
float而不是float3存标量,用half精度存颜色和光照系数。移动端对half的支持很好,能省一半寄存器。
5. 常见问题与排查技巧实录
5.1 指令数正常但帧率还是低,怎么办
这是最常见的困惑。指令数只是 ALU 层面的指标,帧率还受这些因素影响:
| 问题类型 | 排查方向 | 典型表现 |
|---|---|---|
| 带宽瓶颈 | 纹理采样次数、RenderTarget 读写 | 降低分辨率后帧率明显提升 |
| 寄存器溢出 | Register Count 过高 | 指令数不多但执行慢 |
| 分支发散 | 材质里有动态 if | 场景复杂度变化时帧率波动大 |
| PSO 卡顿 | 变体切换频繁 | 特定操作时突然卡一下 |
| Overdraw | 半透明物体叠加 | 半透明区域帧率骤降 |
排查顺序建议:先看 Overdraw,再看带宽,最后看 ALU。因为前两者往往影响更大,而且更容易优化。
5.2 移动端和 PC 端的优化策略差异
移动端 GPU(Mali、Adreno、PowerVR)和 PC 端(NVIDIA、AMD)的架构差异很大:
- 移动端是 TBDR(Tile-Based Deferred Rendering):先分块渲染,再统一写回。这意味着 Overdraw 的代价比 PC 端低,但带宽更敏感。
- 移动端 ALU 数量少:同样指令数,移动端更吃力。
- 移动端对
half精度支持好:PC 端用half可能被提升到float,移动端真能省。 - 移动端分支惩罚更重:Warp 更小,发散代价更高。
所以移动端优化的优先级是:降精度 > 减指令 > 减采样 > 减分支。PC 端则更看重带宽和 PSO。
5.3 一个容易被忽略的坑:材质函数的重复计算
UE 的材质函数(Material Function)如果被多个地方调用,每次调用都会展开一份代码。我见过一个项目,一个计算复杂光照的函数被调用了 5 次,指令数直接翻了 5 倍。
解决办法是:把重复计算的结果缓存到 Custom Node 或者用 Material Parameter Collection 传递。如果函数结果只依赖全局参数,完全可以预计算一次,其他地方直接引用。
5.4 常见问题速查表
| 现象 | 可能原因 | 快速验证 | 解决方向 |
|---|---|---|---|
| 移动端掉帧严重 | ALU 指令过多 | 看 Shader Complexity | 砍 Pow/Sin/除法 |
| 帧率波动大 | 分支发散 | 看 Warp Divergence | 改 Lerp/Step |
| 特定场景卡顿 | PSO 编译 | 看 PSO 缓存命中 | 预编译变体 |
| 半透明区域慢 | Overdraw | 看 Overdraw 视图 | 减少半透明层数 |
| 显存占用高 | 纹理未压缩 | 看纹理格式 | 用 BC/ASTC 压缩 |
| 编译时间过长 | 变体爆炸 | 看变体数量 | 精简 Static Switch |
6. 一些踩坑之后的个人体会
做 UE Shader 优化这几年,最大的体会是:优化不是"把指令数砍到最低",而是"在视觉可接受的范围内找到性价比最高的方案"。我见过有人为了省几条指令,把画面搞得面目全非,这就本末倒置了。
另一个体会是:先测量,再优化。不要凭感觉猜哪里慢,Profiler 数据说话。很多时候你以为的瓶颈和实际的瓶颈完全不是一回事。我踩过最深的坑就是花了两天优化一个材质的 ALU,结果发现真正的瓶颈是半透明排序导致的 Overdraw。
最后分享一个小技巧:建立自己的 Shader 指令预算表。针对项目里不同类型的材质(角色、场景、特效、UI),分别定一个 ALU 指令数上限,超过就预警。这样在美术同学连节点的时候就能及时发现问题,而不是等到打包才发现帧率崩了。这个习惯帮我省了无数次返工。
后续如果要做更深入的优化,可以往这几个方向扩展:一是研究 UE 的 Shader 变体管理机制,把变体数量压下来;二是针对目标平台做指令级的微调,比如用mad替代mul+add;三是结合 GPU 抓帧工具(RenderDoc、Snapdragon Profiler)看真实的指令流,找到编译器没优化掉的地方手动处理。这些内容展开又是一大篇,有机会再聊。