
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是SIMDSingle Instruction Multiple Data架构。它不会一个像素一个像素地执行而是把一批像素打包成一组让这组像素同时执行同一条指令。这组像素在不同的API和架构里有不同的叫法在DX12和UE的语境下通常叫WaveAMD叫WavefrontNVIDIA叫Warp一个Wave通常是32或64个线程。打个比方SIMD就像军训时教官喊“向左转”一个方阵的人同时转而不是教官走到每个人面前单独说一遍。这个类比的关键在于——如果方阵里有一个人不需要转教官也得等所有人都执行完这条指令才能喊下一条。这就是后面要讲的Divergence问题的根源。在UE里你写的Material Graph最终会被编译成HLSL再编译成GPU指令。一个Pixel Shader的Wave里32个线程可能对应屏幕上相邻的32个像素。它们共享同一份指令流但每个线程有自己的寄存器数据比如这个像素的UV、法线、世界坐标。2.2 ALU、寄存器与指令流水线Shader执行的三块拼图GPU核心内部跟Shader执行最相关的三个东西是ALU、寄存器和指令调度器。ALUArithmetic 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 CountShader的ALU指令总数。这个数字反映了计算复杂度。但要注意它不包含纹理采样指令和分支指令的独立开销所以不能只看这一个数。Register Usage每个线程占用的寄存器数量。这个数字直接决定Occupancy上限。在移动端尤其关键因为移动GPU的寄存器文件通常比桌面小。我一般会这样用这两个数字先看Register Usage如果超过某个阈值比如移动端超过48就先想办法降寄存器如果寄存器没问题但帧率还是上不去再看Instruction Count找ALU热点。3.3 一个真实案例从120条指令到67条的优化过程拿一个常见的PBR材质举例。原始版本用了标准的BaseColorMetallicRoughnessNormalAO五张图加上一些细节法线混合和自发光。编译后在移动端平台上Instruction Count是120左右Register Usage是56。第一步我把细节法线混合从两层降到一层。原来是用两张法线图做Blend改成只用一张主法线加一个常量强度控制。Instruction Count降到98Register Usage降到48。第二步我把AO的计算从Pixel Shader挪到了Vertex Shader。因为AO在这个场景里是静态的逐顶点计算完全够用。这一步省掉了每个像素的一次纹理采样和一次乘法。Instruction Count降到82Register Usage降到44。第三步也是最关键的一步我把自发光从动态计算改成了预烘焙到Emissive贴图。原来自发光是用一个Fresnel加一个动态颜色算的改成直接采样一张自发光图。Instruction Count降到67Register 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*xpow(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当成一个必看的指标。