1. 从 GPU 怎么执行指令说起:先忘掉“节点越少越快”
1.1 你拖的那些节点,落地后是一行一行真实指令
搞 Shader 优化这几年,我最怕听到的一句话是:“我这里节点挺少的,怎么还是卡?”
节点多少和 GPU 实际执行的指令数,很多时候根本不是一回事。你在 Material Editor 里拖出来的几十个节点,首先会被 UE 翻译成 HLSL,再交给目标平台的 shader 编译器编译成显卡真正运行的机器指令。这个过程中,引擎和驱动会做大量优化:常量折叠、死代码消除、公共子表达式合并。有时候你画了 15 个节点,最终编译出来只有 6 条有效指令;有时候你只画了 4 个节点,但里面有几次世界坐标换算和多次归一化,最终指令反而更多。
所以真正要盯的,不是编辑器里的连线数量,而是编译产物里的像素指令数、寄存器占用、纹理采样次数。在 UE 里可以直接打开材质编辑器右上角的 Stats 面板查看,也可以切到Shader Complexity视图模式看屏幕上各区域的着色成本。我习惯在动任何优化之前,先把这几个数字记录一次,没有基线就谈不上优化。
1.2 Warp、Wavefront 和 CTA:GPU 是“班组制”不是“单兵作战”
了解了指令之后,还得知道 GPU 怎么执行这些指令。无论是 NVIDIA、AMD,还是移动端的 Mali、Adreno,图形相关的计算基本都是 SIMT(Single Instruction, Multiple Threads)模式:许多个线程共享同一条指令流,同一个时刻执行同一条指令,但每个线程处理的数据各不相同。
NVIDIA 把 32 个这样的线程叫做一个 Warp,AMD 把 64 个线程叫做一个 Wavefront。在 CUDA 编程里,又会看到 Cooperative Thread Array(CTA)这个词,它指的就是一个线程块,内部通常包含多个 Warp。你只需要记住:GPU 调度和执行的最小单位不是单个像素或单个线程,而是一整队线程。可以把它想成一个流水线班组,工头喊“开始切”,32 个人同时动手,只是每个人切自己手里的零件。
这个特性直接决定了分支、寄存器、纹理采样这些操作的真实代价。任务分派时,GPU 会尽量把相邻像素分包给同一个 Warp,让它们走同样的指令,减少“有人干活有人围观”的局面。理解了这一点,之后再看到那些“为什么有些像素特别贵”的问题,就有了解释的起点。
1.3 指令执行不是一瞬间的事:取指、发射、延迟与 Occupancy
一条指令从被 GPU 取出,到真正算完,中间要经历取指、译码、发射、执行、写回这一整套流程。加法这类 ALU 指令在硬件里的延迟通常只有几个周期,而一次纹理采样可能要几百个周期。如果每个 Warp 都在干等采样结果,计算单元就闲着,那性能就浪费了。
所以 GPU 设计了一个非常聪明的机制:靠大量并发 Warp 来掩盖延迟。一个 Warp 在等待纹理返回时,调度器立刻把另一个 Warp 拉上来算,等结果回来后切回去。这个并行度就叫 Occupancy(占用率),如果同时活跃的 Warp 太少,等待就掩盖不住,GPU 就会开始空转。
这段内容跟优化的关系极大。很多时候你砍掉几条指令,反而让某个 Shader 变得寄存器过多,占用率下降,整体性能更差。这就是为什么说,优化必须从“理解 GPU 怎么执行指令”开始,而不是从“凭感觉删节点”开始。
2. Shader 真正的三条命门:分支分歧、寄存器压力、指令类型
2.1 分支是 Warp 里的“电梯博弈”,分歧代价远比想象中大
很多开发者喜欢在材质里直接加if (Mask > 0.5) DoA else DoB。在 CPU 上这是很自然的逻辑,但在 GPU 的 SIMT 模式下,一个 Warp 里的 32 个线程必须保证步调一致。如果其中一半线程满足条件,另一半不满足,硬件不能同时执行两个方向。它只能先让满足条件的那 16 个线程执行 A 分支,同时“冻结”另外 16 个线程,然后再反过来执行 B 分支。
也就是说,动态分支一旦出现分歧,耗时约等于两条分支路径相加。判断分支成本高低,不是看这个if写得多不多,而是看同一个 Warp 里的分歧率有多高。如果判断条件是全屏统一的参数,或者所有像素状态都相同,那这个分支叫 Uniform Branch,硬件基本只走要用的那一条,成本很低。如果每个像素的 Mask 都不同,那这个分支就极其昂贵,那些“为了保护性能而加的分支”反而成了性能坑。
遇到这种情况,我通常按优先级处理:能编译期展开的判断,尽量用 Static Switch 或Switch on Material Parameter,让编译器在发布时只保留一路代码;真正需要运行时判断的,先评估分歧概率,比如一张 smoothstep 的噪声 mask 基本等于处处分歧,那宁可要lerp两套结果也不要写if。但反过来,如果分支体里有好几层法线合并或多次采样,硬算lerp反而更贵,所以没有绝对正确,只有按场景去实测。
2.2 寄存器是编译器的“临时仓库”,装不下就开始拖后腿
Shader 里的每个中间变量都会占用寄存器文件,每线程可用的寄存器数量是有限的。如果某一时刻同时活跃的变量太多,编译器装不下,就会把一部分数据“溢出”到本地内存(Local Memory)。本地内存背后是显存或内存,访问速度和寄存器天差地别,一旦溢出,性能立刻崩。
寄存器占用同时会影响 Occupancy。比如某个 SM 限制每线程最多用 64 个寄存器,如果某个 Shader 每个线程用了 60 个,那就只能同时塞一个 Warp 进来,其余 Warp 全在排队。刚才说的“用并发掩盖延迟”的优势就直接没了,ALU 饿死,纹理等待又掩盖不住,帧率掉得毫无道理。
在 MaterialStats 里要专门盯 Registers 这栏。桌面机上,像素着色器每线程用 20~30 个寄存器很常见,也基本可接受;移动端尽量控制在 16~24 个以内。控制手段不外乎三种:减少同时存活的临时变量、把中间结果尽量直接喂给下一级、把能在顶点着色器算完的计算挪走。很多节点图看着清爽,实际上每一级都保留了中间变量,编译器优化不动,寄存器就是这么膨胀上去的。
2.3 指令和指令不一样:ALU、SFU、纹理采样的价格相差极大
判断 Shader 快慢,不能只看“指令数”。GPU 里有多种执行单元,不同类型指令的吞吐差别很大。
ALU 指令(加法、乘法、逻辑运算)基本是吞吐最高的,每秒可以执行极多;特殊函数单元(SFU)处理倒数、平方根、sin/cos,吞吐明显低一截;而纹理采样表面上只占一条指令,背后却是采样器发起的一次显存读取,延迟和带宽开销都远高于 ALU。经验上可以粗略理解成:一次带 Trilinear 的纹理采样,大约相当于几十条甚至上百条 ALU 指令的耗时,具体看缓存命中和带宽。
这就解释了为什么有的材质“指令数不高,但就是慢”。如果瓶颈是纹理带宽,你再怎么压缩 ALU 都没用;反过来如果是 ALU 密集,拼命合并贴图也没意义。实践中要先区分自己是 ALU-bound、Texture-bound 还是 Bandwidth-bound。这个判断做准了,优化方向才不会跑偏。
3. 在 UE 里把优化落到实处的几个具体动作
3.1 先定位瓶颈,再谈优化:用好 GPU 分析器而不是拍脑袋
我的经验是,很多“优化圣手”看了半天材质,结果发现项目瓶颈根本不在像素着色器。所以第一步永远是先看全局,再下结论。
UE 里常用的话是这类:控制台敲ProfileGPU或输入Stat GPU,能看每个 Pass 的耗时;要深入看单个 DrawCall,用 RenderDoc 抓帧查看 shader 耗时、寄存器、占用率;要分析 CPU 侧提交和整体瓶颈,则用 Unreal Insights 录帧分析。材质编辑器里的 MaterialStats 用来确认单个材质的指令预算,非常快,但别把它当成全部真相。
台式机、主机和移动端的典型瓶颈差异很大。台式机场景很多卡在后处理、Overdraw 和高复杂度光照;移动端则更容易被像素着色器指令和带宽压垮。先确定平台,再决定把有限的优化时间投到哪里。我见过太多人在主机项目里抠几十条 ALU,最后发现一个后处理叠加就吃掉所有收益,这种白费工夫要尽量避免。
3.2 像素着色器瘦身顺序:输出、采样、公共计算、Custom Node
一旦确认瓶颈在像素着色器本身,我习惯按固定顺序做一轮清扫:
- 检查材质所有输出。Opacity、Emissive、World Position Offset、Pixel Depth Offset 这些只要有一路在被使用,编译器就会为此生成额外代码。把不需要的几路输出切断,常常能直接砍掉一截隐藏分支。
- 检查采样器。看有没有重复采样同一张贴图,或者采样结果只为了一个很小范围的细节。能合并通道就合并,能把 4 张遮罩压成两张 RGBA 就压。
- 检查公共计算。很多材质里同样的 UV 变换、同样的世界位置计算被重复拖了多次。下沉到一组中间变量里给所有采样共享,指令数立刻少一截。
- 关键节点用 Custom Node 重写。复杂的混合公式直接用 HLSL 写成一段代码,往往比节点图生成的代码紧凑得多。对编译器来说,节点图生成的变量依赖树比较复杂,手写公式能更明确地告诉它哪些中间量可以消掉。
这一轮做完,多数材质的像素指令能下降 30%~50%,而且画面上通常看不出任何差异。注意每改一步都切到实际目标平台看一眼,避免编译器版本的差异造成误判。
3.3 半精度、Quality Switch 与移动端取舍
移动端 GPU 做 float 运算通常不是全速的,改用 half(fp16)可以明显提速,因为硬件能在一个周期内把两个 half 打包处理。UE 材质节点里可以把某些输出引脚直接设为 Half 精度,适合纯颜色、简单 UV、粗糙度这类线性数据。
但 half 不是银弹。数值跨度大的地方、多次求和的累计量、跨多节点复用的变量,一旦降精度会出现色阶断层、闪烁甚至大块脏色。稳妥做法是:先用全 float 实现并把结果跑通,再逐条把确认为颜色或 alpha 的路径切成 half,然后到目标设备上做视觉对比。每次只切一条路径,出问题能立即定位。按我自己的习惯,法线方向和世界坐标计算永远是 float,只有最终颜色和粗糙度这类输出路径才敢放心用 half。
真正影响移动端整体表现的是场景级开关,比如 Feature Level Switch 和 Quality Switch。把远处物体的细节法线、各向异性、次表面散射按平台能力分档,比单个材质节省几十条指令管用得多。这需要在资源规范里从一开始就定好,上线前才开始补档位永远是手忙脚乱。
3.4 材质优化要配合渲染管线:PSO 与实例的更新开销
材质本身的指令数降下来了,如果场景里充满 PSO 切换,帧率还是上不去。PSO(Pipeline State Object)切换的本质是让 GPU 重新配置整条渲染管线,代价非常高。同屏尽量合并相似材质,减少半透明物体数量,就是为 Shader 优化创造更好的执行条件。
另外,不要在 Tick 里频繁创建或修改 Dynamic Material Instance。每次参数修改都可能导致 uniform 缓冲更新,打断 GPU 指令流。如果确实需要大量参数批量更新,优先用材质实例传参数并合并到同一个 PSO,把状态切换次数压到最低。这一点和 Shader 指令优化看起来不直接相关,但实际落地时,其影响经常比单材质优化还大。
4. 实操复盘:角色材质从 3.1ms 压到 1.2ms 的过程
4.1 接手时的材质结构和初始数据
前阵子朋友塞给我一个移动端角色材质,说“低端机跑不动”。我打开 Material Editor,看到差不多 55 个节点,里面有几张通道打包的遮罩贴图(金属、粗糙、AO、细节 mask),两张细节法线,还有一堆世界坐标换算,以及好几个smoothstep和lerp的叠叠乐。
先用 MaterialStats 切到 Android 的 Vulkan 平台看了下:像素指令约 182 条,寄存器占用 32,纹理采样 9 次。单独看这个材质,低端机上角色占用的 GPU 像素处理时间大约 3.1ms。在 30 帧预算都紧张的场景里,这已经是不可接受的开销了。
4.2 三步优化:采样合并、共享计算、分档开关
第一刀砍在纹理采样上。那几张遮罩贴图里,有两张的 RG 和 BA 内容完全可以合并,我把金属、粗糙、AO、细节 mask 重新打包成两张 512x512 的通道图。采样次数从 9 次降到 6 次,这个改动最直观,因为移动端的带宽和纹理采样开销非常贵。
第二刀是消除重复计算。一堆节点里,同样的 UV 缩放平移被拖了四五次,世界位置相关的计算也重复出现了好几轮。我把这些统一归一组成一组公共输入,所有采样和颜色混合都共享这部分结果。
第三刀是分档和精度控制。细节法线通过 Quality Switch 只在近距离启用,远处直接跳过;原来一个运行时if分支改用 Switch on Material Parameter 让编译器编译期展开;最后把纯 alpha 相关路径切成半精度。改完再看 MaterialStats,像素指令从 182 压到 87,寄存器从 32 降到 20,纹理采样降到 5 次。
4.3 实测数据与后续资产规范
拿同一台测试机、同一条跑图路径重新验证,角色材质的 GPU 像素处理时间从 3.1ms 降到 1.2ms,整体帧耗时从接近 45ms 压到 32ms。帧率从 22 提升到 31,之后又配合场景里其它物体的材质优化,稳定到 38~40 左右。
这个结果其实很有意思:指令数量少了 50% 以上,帧率却没有翻倍。因为优化后的瓶颈被转移到了 Overdraw 和带宽上,像素着色已经不是唯一卡点。这再次说明,优化时不要只盯着单个 Shader 的数字,而是要反复回到整个帧的 GPU 分析结果上。
这次项目结束后,我们给团队定了一个移动端角色资产的硬预算:像素指令不超过 120 条,寄存器不超过 24,采样次数不超过 6。新资产进版本前先看 MaterialStats,超预算就回来改,避免上线前集中优化。
5. 常见问题与排查技巧实录
5.1 快速定位对照表
| 现象 | 可能原因 | 优先排查方向 |
|---|---|---|
| GPU 耗时高但材质指令数不高 | 带宽瓶颈或采样过多 | 压缩贴图格式、降低分辨率、减少采样 |
| 像素指令高但节点图看着不多 | 某些基础节点很肥,优化不彻底 | 重写 Custom Node,检查公共计算 |
| 帧率忽高忽低不稳定 | 动态分支分歧严重或占用率过低 | 消除分支分歧,检查寄存器占用 |
| 移动端发热降频明显 | 高精度运算过多 | 切 half,砍采样,降低整体负载 |
| 某些显卡上材质编译失败 | 采样器数量超上限或寄存器溢出 | 看 MaterialStats 的 Samplers 与 Registers |
| 同材质不同物体帧率差异大 | 分支条件随对象实时变化 | 用 Quality Switch 按距离或平台分档 |
5.2 我踩过的几个坑和最终心得
第一个坑是合并贴图时为了省采样,把 AO 和金属这类对精度要求不高的数据放进低位通道,一开始看着没问题,HDR 调完光之后出现一大片脏色。后来重新按 BC7 / ASTC 格式处理,每条通道的位宽分配仔细检查才解决。
第二个坑是半精度优化带来的色阶断层。某次把皮肤材质的一条路径切成 half 后,高光区域出现一圈圈断带。排查之后确认是中间计算累计量太大,最后只把最终输出的 roughness 路径切成 half,法线相关仍保持 float,问题才消失。
第三个坑是“合并材质”不能当万能解。团队把几十个相似材质合并成一个“超级大材质”,结果同屏 Overdraw 不降反升,因为大材质留了很多分支,大量像素在做无效计算。合并材质前提是分支能被编译期消除,或者分支成本足够低。
最后分享一个我自己反复验证有效的技巧:从头到尾只信数据和画面,不信直觉。每做一步改动,先在目标平台录一段相同路径的 ProfileGPU,同时截屏对比视觉差异。时间久了,你会慢慢培养出“看节点图就能大概猜出指令流走向”的感觉,而这种感觉的来源,不是经验玄学,恰恰就是你对 GPU 执行指令这件事的理解深度。