1. 从一次移动端掉帧说起:为什么要从GPU执行指令的角度聊UE Shader优化
去年帮一个朋友看他们团队做的移动端项目,场景不算复杂,一个角色加几个PBR道具,中端机跑起来帧率在45到55之间反复横跳,GPU耗时曲线像心电图。他们一开始的优化思路很典型:砍面数、降分辨率、把后处理一个个关掉试。折腾了两周,帧率是上去了,但画面也快回到十年前了。后来我让他们把Shader的指令数和寄存器占用拉出来看,问题一下就清楚了——真正吃性能的不是三角形数量,而是几个材质里隐藏的ALU指令膨胀和寄存器压力导致的Occupancy下降。
这件事让我意识到,很多做UE的朋友对Shader优化的理解停留在“改参数”层面,比如把Quality调低、把某个Feature关掉,但很少有人往下再走一层,去问一句:GPU到底是怎么执行我写的这段Shader的?这个问题的答案,直接决定了你优化时该动哪里、不该动哪里。你如果不知道一个lerp在硬件上会展开成几条指令,不知道一个动态分支会让整个Wave付出什么代价,那优化就变成了碰运气。
这篇内容就是想把这条链路讲清楚。我会从GPU执行指令的基本单位讲起,然后落到UE的Shader编译产物上,最后给出几个可以直接上手操作的优化手段。适合已经写过一些HLSL、用过Material Editor、但总觉得优化使不上劲的TA和图形程序看。纯新手也能看懂,因为我会尽量用生活化的类比把硬件概念讲明白,但如果你连Shader是什么都还没概念,建议先补一下基础再回来。
提示:本文讨论的是GPU通用执行模型和UE Shader编译的通用规律,不针对某一款具体显卡型号。不同架构的细节会有差异,但大方向是一致的。
2. GPU到底怎么执行一条Shader指令
2.1 从“一个像素”到“一组线程”:SIMD与Wave的真相
很多人脑子里对GPU执行Shader的模型是这样的:屏幕上有1920×1080个像素,每个像素跑一遍Pixel Shader,大家各跑各的。这个模型在逻辑上没错,但在硬件上完全不是这么回事。
真实的GPU是SIMD(Single Instruction Multiple Data)架构。它不会一个像素一个像素地执行,而是把一批像素打包成一组,让这组像素同时执行同一条指令。这组像素在不同的API和架构里有不同的叫法,在DX12和UE的语境下通常叫Wave(AMD叫Wavefront,NVIDIA叫Warp),一个Wave通常是32或64个线程。
打个比方:SIMD就像军训时教官喊“向左转”,一个方阵的人同时转,而不是教官走到每个人面前单独说一遍。这个类比的关键在于——如果方阵里有一个人不需要转,教官也得等所有人都执行完这条指令才能喊下一条。这就是后面要讲的Divergence问题的根源。
在UE里,你写的Material Graph最终会被编译成HLSL,再编译成GPU指令。一个Pixel Shader的Wave里,32个线程可能对应屏幕上相邻的32个像素。它们共享同一份指令流,但每个线程有自己的寄存器数据(比如这个像素的UV、法线、世界坐标)。
2.2 ALU、寄存器与指令流水线:Shader执行的三块拼图
GPU核心内部跟Shader执行最相关的三个东西是:ALU、寄存器和指令调度器。
ALU(Arithmetic Logic Unit)是真正干活的单元,负责加减乘除、点乘、比较这些运算。你可以把它理解成工厂里的工人。一个GPU有多少ALU,决定了它一个时钟周期能并行处理多少运算。UE里你看到的Instruction Count,统计的就是Shader里ALU指令的数量。
寄存器是工人手边的工作台。每个线程在执行Shader时,它的中间变量、输入输出数据都放在寄存器里。寄存器有两个关键属性:数量有限和访问速度极快。如果一个Shader需要的寄存器太多,超过了硬件给每个线程分配的上限,就会发生Register Spilling——数据被临时写到更慢的显存里,性能直接崩掉。
指令调度器负责把指令喂给ALU。它会在指令之间寻找可以并行的机会,比如一条纹理采样指令发出后需要等几百个周期才有结果,调度器就会在这段时间里插入其他ALU指令,让工人别闲着。这个机制叫Latency Hiding。
这三者的关系可以用一个餐厅厨房来类比:ALU是厨师,寄存器是灶台旁边的备菜区,调度器是传菜的主管。备菜区就那么大,菜堆太多放不下就得往冷库跑(Spilling),厨师再快也白搭;主管如果不会安排,厨师做完一道菜干等下一道,出餐速度也上不去。
2.3 Occupancy:为什么寄存器用多了反而慢
Occupancy(占用率)是Shader优化里最容易被忽略、但影响巨大的指标。它指的是一个GPU核心上同时驻留的Wave数量,相对于硬件支持的最大Wave数量的比例。
为什么Occupancy重要?因为GPU靠多Wave切换来隐藏延迟。当Wave A在等纹理采样结果时,调度器可以切到Wave B继续执行ALU指令。如果Occupancy很低,比如一个核心只能驻留1个Wave,那这个Wave一等待,整个核心就闲置了。
而Occupancy的直接决定因素之一就是每个线程的寄存器用量。硬件给每个核心的寄存器文件大小是固定的,比如64K个32位寄存器。如果每个线程用64个寄存器,那最多能驻留1024个线程;如果每个线程用128个寄存器,就只能驻留512个线程。线程少了,Wave就少了,延迟隐藏能力就弱了。
这就是为什么有时候你优化了半天ALU指令数,性能反而没提升——因为瓶颈根本不在ALU吞吐,而在Occupancy太低导致延迟没藏住。UE的Shader编译结果里,你可以通过平台统计信息看到寄存器用量,这个数字比Instruction Count更值得关注。
3. UE Shader编译产物里藏着什么
3.1 从Material Graph到HLSL再到GPU指令的三级跳
你在Material Editor里连的节点,到GPU真正执行的指令,中间隔了三层:
第一层是Material Graph到HLSL。UE的材质编译器会把你的节点图翻译成HLSL代码。这一步会做常量折叠、死代码消除等基础优化,但也会因为节点连接方式产生冗余。比如你连了一个Multiply节点但乘数是1,编译器不一定能识别出来。
第二层是HLSL到中间表示。UE用的是自己的Shader编译框架,会先把HLSL转成一种中间表示,然后做跨平台的优化。这一步会做指令合并、循环展开等操作。
第三层是中间表示到目标平台的GPU指令。这一步由各平台的Shader编译器完成,比如DXC、Mesa的NIR等。这一步的优化质量直接决定了最终指令数和寄存器用量,而且不同平台差异巨大。
理解这个三级跳的意义在于:你在Material Graph层面做的优化,不一定能传导到最终指令。有时候你简化了节点图,但编译器本来就帮你优化掉了,白忙一场;有时候你加了一个看似无害的节点,却触发了编译器的某个保守策略,导致寄存器暴涨。
3.2 Instruction Count和Register Usage:两个必须盯住的数字
在UE里查看Shader统计信息,最直接的方式是在Console里输入r.ShaderStats相关的命令,或者在材质编辑器的Stats面板里看。不同UE版本命令略有差异,但核心指标就两个:
Instruction Count:Shader的ALU指令总数。这个数字反映了计算复杂度。但要注意,它不包含纹理采样指令和分支指令的独立开销,所以不能只看这一个数。
Register Usage:每个线程占用的寄存器数量。这个数字直接决定Occupancy上限。在移动端尤其关键,因为移动GPU的寄存器文件通常比桌面小。
我一般会这样用这两个数字:先看Register Usage,如果超过某个阈值(比如移动端超过48),就先想办法降寄存器;如果寄存器没问题但帧率还是上不去,再看Instruction Count,找ALU热点。
3.3 一个真实案例:从120条指令到67条的优化过程
拿一个常见的PBR材质举例。原始版本用了标准的BaseColor+Metallic+Roughness+Normal+AO五张图,加上一些细节法线混合和自发光。编译后在移动端平台上Instruction Count是120左右,Register Usage是56。
第一步,我把细节法线混合从两层降到一层。原来是用两张法线图做Blend,改成只用一张主法线加一个常量强度控制。Instruction Count降到98,Register Usage降到48。
第二步,我把AO的计算从Pixel Shader挪到了Vertex Shader。因为AO在这个场景里是静态的,逐顶点计算完全够用。这一步省掉了每个像素的一次纹理采样和一次乘法。Instruction Count降到82,Register Usage降到44。
第三步,也是最关键的一步,我把自发光从动态计算改成了预烘焙到Emissive贴图。原来自发光是用一个Fresnel加一个动态颜色算的,改成直接采样一张自发光图。Instruction Count降到67,Register Usage降到38。
最终帧率从48提升到58,而且画面几乎看不出差别。这个案例的核心逻辑是:优先砍寄存器,再砍指令数,最后才考虑降画质。因为寄存器降下来,Occupancy上去,延迟隐藏能力变强,即使指令数没降多少,实际执行效率也会提升。
4. 几个能直接抄的Shader优化手段
4.1 用数学等价变换砍掉冗余ALU指令
很多Shader里的ALU指令是可以通过数学等价变换消掉的。举几个我常用的例子:
例一:归一化向量的点乘。如果你要算dot(normalize(a), normalize(b)),而a和b的长度已知且固定,可以提前把长度乘进去,避免两次归一化。比如dot(a,b) / (length(a)*length(b)),如果length是常量,编译器能直接折叠。
例二:lerp的展开。lerp(a, b, t)在硬件上通常是a + t*(b-a),也就是一条乘加。但如果你写的是a*(1-t) + b*t,编译器可能生成两条乘法加一条加法。养成用lerp的习惯,让编译器去选最优展开方式。
例三:saturate的妙用。saturate在大多数GPU上是免费的(作为指令的修饰符),但如果你用clamp(x, 0, 1),编译器可能生成额外的指令。能用saturate的地方就别用clamp。
例四:避免pow。pow(x, 2)写成x*x,pow(x, 0.5)写成sqrt(x)。pow在硬件上通常是exp2(log2(x)*y),至少三条指令,而乘法和开方都是一条。
这些变换单个看省不了多少,但一个复杂Shader里累积起来,省个20%到30%的ALU指令是很常见的。
4.2 寄存器压力的来源与释放方法
寄存器压力主要来自三个方面:临时变量太多、变量生命周期太长、分支导致寄存器分配保守。
临时变量太多是最常见的。你在Material Graph里每连一个节点,编译器就可能分配一个临时寄存器。解决办法是尽量合并计算,比如把多个乘法合并成一个向量乘法,把多个加法合并成一个向量加法。UE的Material Editor里可以用Append和ComponentMask来手动控制向量打包。
变量生命周期太长是指一个变量从计算出来到最后一次使用之间隔了很多指令,这段时间它一直占着寄存器。解决办法是尽量在使用前才计算,用完就释放。在HLSL里可以通过调整代码顺序来实现,在Material Graph里则要注意节点的连接顺序。
分支导致寄存器分配保守是因为编译器不知道分支会走哪条路,所以两条路的寄存器需求要同时满足。解决办法是尽量用lerp和step代替if,把分支变成无分支计算。这在移动端尤其重要,因为移动GPU对分支的容忍度更低。
4.3 纹理采样与ALU的平衡术
纹理采样和ALU是Shader里的两大开销来源,但它们的特点不同:纹理采样延迟高但吞吐大,ALU延迟低但吞吐有限。优化的核心思路是让两者重叠。
具体做法是:把纹理采样尽量提前,让采样指令发出后,在等待结果的几百个周期里,调度器可以执行后面的ALU指令。如果你把采样放在Shader最后,那采样延迟就暴露出来了,ALU没活干,只能干等。
在UE里,这意味着你要注意Material Graph里纹理采样节点的位置。如果采样结果只用于最后一步输出,那前面的ALU计算其实可以和采样并行。但如果你把采样结果立刻用于一个复杂的中间计算,那这个计算就得等采样结果,延迟就暴露了。
另一个技巧是合并纹理采样。如果你需要采样同一张图的多个通道,尽量用一次采样拿到所有通道,而不是分多次采样。比如把Roughness和Metallic打包到同一张图的R和G通道,一次采样就够了。
4.4 移动端Shader优化的特殊注意事项
移动端GPU和桌面GPU有几个关键差异,直接影响Shader优化策略:
寄存器更少。移动GPU的寄存器文件通常只有桌面的一半甚至更少,所以Register Usage的阈值要卡得更严。我一般建议移动端Pixel Shader的Register Usage控制在32以内。
带宽更紧张。移动端的显存带宽是稀缺资源,纹理采样和Render Target读写的开销比桌面大得多。所以移动端优化要更注重减少纹理采样次数和RT切换。
分支代价更高。移动GPU的Wave通常更宽(比如64),分支导致Divergence的代价更大。移动端Shader里应该尽量避免动态分支,能用无分支计算就用无分支。
精度可以降。移动端很多计算可以用half精度(16位浮点),寄存器占用和ALU吞吐都能翻倍。UE里可以通过half类型和MediumP精度修饰符来控制。但要注意,坐标计算和深度计算通常需要full精度,降精度会导致画面瑕疵。
5. 常见问题与排查技巧实录
5.1 Shader优化常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 帧率低但GPU占用不高 | Occupancy太低 | 查看Register Usage | 降寄存器用量 |
| 帧率随分辨率线性下降 | Pixel Shader瓶颈 | 对比不同分辨率下的GPU耗时 | 降ALU指令或纹理采样 |
| 帧率随三角形数线性下降 | Vertex Shader或几何瓶颈 | 查看Vertex Shader指令数 | 简化顶点计算 |
| 画面出现闪烁或瑕疵 | 精度不足或分支Divergence | 检查half精度使用位置 | 关键计算改回full精度 |
| 移动端发热严重 | 带宽或ALU过载 | 查看带宽统计 | 减少纹理采样和RT读写 |
| 编辑器里流畅但打包后卡 | 平台编译差异 | 对比编辑器和打包后的Shader统计 | 针对目标平台重新优化 |
5.2 我踩过的三个坑
第一个坑:只看Instruction Count,忽略Register Usage。早期我做优化,盯着指令数砍,结果寄存器暴涨,帧率反而降了。后来才明白,寄存器是Occupancy的直接决定因素,优先级比指令数更高。
第二个坑:在Material Graph里做“看起来聪明”的优化,但编译器不领情。比如我试过手动把多个节点合并成一个Custom Node,结果编译器生成的指令反而更多了。后来学乖了,优化前后一定要看编译产物,不能凭感觉。
第三个坑:忽略平台差异。同一个Shader在桌面和移动端编译出来的指令数和寄存器用量可能差一倍。我早期用桌面端的标准去优化移动端,结果移动端还是卡。后来养成了习惯,优化移动端就在移动端平台上编译和测试,不跨平台推断。
5.3 一个快速定位Shader瓶颈的实操流程
当你怀疑某个材质是性能瓶颈时,可以按这个流程快速定位:
第一步,在Console里输入r.ShaderStats相关命令,或者用ProfileGPU抓一帧,找到GPU耗时最高的几个Pass。
第二步,在材质编辑器的Stats面板里看这个材质的Instruction Count和Register Usage。如果Register Usage超过阈值,先降寄存器。
第三步,用r.Shaders.Optimize相关命令对比优化前后的编译产物,确认你的改动确实传导到了最终指令。
第四步,如果还是找不到瓶颈,用r.Shaders.Dump把Shader的汇编导出来看。这一步比较硬核,但能让你看到编译器到底生成了什么指令,有没有意外的Spilling或分支。
第五步,针对性地改Material Graph,然后重复第二步到第四步,直到指标达标。
注意:不同UE版本的Console命令可能有差异,建议先查一下你所用版本的官方文档。另外,Shader编译优化是一个迭代过程,不要指望一次改动就到位。
6. 写在最后:Shader优化是理解硬件的过程
我做图形这些年,最大的体会是:Shader优化不是背几条规则就能做好的,它本质上是理解硬件怎么工作,然后顺着硬件的脾气去写代码。你知道GPU是SIMD的,就会主动避免Divergence;你知道寄存器决定Occupancy,就会主动控制变量数量;你知道纹理采样延迟高,就会主动把采样提前。
这些知识不是从Material Editor的文档里能学到的,得往下走一层,去看GPU执行指令的模型。我一开始也觉得这些硬件细节离日常开发很远,但踩过几次坑之后发现,恰恰是这些底层知识,决定了你优化时是有的放矢还是瞎猫碰死耗子。
最后分享一个我个人的习惯:每次做完一个材质,我都会花两分钟看一下它的Shader统计信息。如果Register Usage超过40,我就会问自己一句“这个数字能不能再降”。这个习惯帮我避免了很多后期才暴露的性能问题。你也可以试试,从下一个材质开始,把Register Usage当成一个必看的指标。