1. 从一次掉帧说起为什么需要理解 GPU 指令执行去年帮一个朋友看他的 UE 项目场景不算复杂一个室内展厅几盏动态光几块半透明玻璃结果在 4060 Laptop 上跑 1080p 只能到 40 帧出头。他第一反应是显卡不行但我让他把渲染线程和 GPU 耗时打开一看Base Pass 的 Shader 耗时占了将近 60%。问题不在显卡在于他写的那几个材质里塞了太多看起来没什么的节点——一堆 lerp、pow、sin还有几个没必要的纹理采样。这些节点在材质编辑器里看着人畜无害编译成 HLSL 之后就是成百上千条 ALU 指令GPU 每个像素都要老老实实跑一遍。这就是我想聊 UE Shader 优化的起点你得先知道 GPU 是怎么执行你写的那些指令的才知道该砍哪里。很多人优化 Shader 的套路是凭感觉删节点删完发现帧数没变或者画面崩了。原因很简单他们不知道瓶颈在 ALU、在寄存器、还是在纹理采样单元也不知道 GPU 的 SIMD 结构对分支有多不友好。这篇内容适合谁看如果你在做 UE 项目写过材质、改过 Shader、被性能问题折磨过或者你是技术美术、图形程序想从会写进阶到写得快那这篇就是给你准备的。我会从 GPU 执行指令的基本单位讲起一路讲到 UE 里怎么定位 Shader 瓶颈、怎么改、改完怎么验证。全程用大白话加实际案例不堆公式但该有的原理一个不落。先给个结论性的认知Shader 优化的本质是让 GPU 用最少的指令周期、最少的寄存器、最少的带宽把同样的画面画出来。这三个最少对应三个不同的瓶颈方向后面会逐个拆。2. GPU 到底怎么执行一条 Shader 指令2.1 从 SIMD 和 Wave 说起为什么分支是性能杀手CPU 执行指令是一条一条来一个核心同一时刻处理一条指令流。GPU 完全不是这个逻辑。GPU 的核心执行单位叫WaveAMD 叫 WavefrontNVIDIA 叫 Warp一个 Wave 通常是 32 或 64 个线程在 UE 里你经常看到 32 或 64 这个数字。这 32 个线程共享同一个指令指针也就是说它们必须同时执行同一条指令只是各自操作自己的数据。这就是 SIMD单指令多数据的本质。好处是吞吐量爆炸坏处是——一旦遇到分支Wave 里的线程如果走了不同的路GPU 只能把两条路都跑一遍先跑走 if 的那批走 else 的线程被 mask 掉空转再跑走 else 的那批。这叫分支发散Divergence。举个 UE 里常见的例子。你在材质里写了个if (LightVector.z 0)看起来只是判断一下光照方向。但如果这个判断依赖逐像素的数据同一个 Wave 里 32 个像素可能一半满足一半不满足那这个分支的代价就是两条路都执行。如果 if 里还有纹理采样那更惨采样单元被浪费一半。实操心得在 UE 材质里能用lerp、step、saturate这类无分支运算替代if的尽量替代。lerp(a, b, t)在硬件上往往就是一条 FMA融合乘加指令而if可能带来两倍的执行路径。当然如果分支条件是 uniform整个 Wave 都一样比如基于材质参数的开关那分支几乎没代价因为不会发散。2.2 ALU、寄存器、纹理单元三个不同的瓶颈GPU 里干活的主要有三类单元ALU算术逻辑单元负责加减乘除、点乘、三角函数、幂运算等。你材质里每一个数学节点最终都变成 ALU 指令。寄存器RegisterGPU 上最快的存储但数量极其有限。每个线程能用的寄存器数量直接决定了能同时跑多少个 WaveOccupancy。纹理采样单元TMU/Texture Unit负责纹理采样、过滤。采样有延迟而且带宽有限。这三者对应三种瓶颈瓶颈类型典型症状常见原因ALU 瓶颈Shader 指令数极高GPU 计算单元满载复杂数学运算、大量 lerp/pow/sin、循环寄存器瓶颈Occupancy 低延迟隐藏不住变量太多、复杂表达式、大数组带宽/采样瓶颈纹理单元满载采样延迟高采样次数多、纹理分辨率高、Mip 未正确使用关键认知这三者往往互相制约。你为了减少 ALU 指令把计算拆成多步可能增加寄存器压力你为了减少采样次数用数学近似可能增加 ALU 负担。优化就是在这三者之间找平衡。2.3 寄存器压力为什么变量多会让 Shader 变慢寄存器是 GPU 上最稀缺的资源之一。每个 SM流多处理器有一堆寄存器分给同时驻留的所有线程。假设一个 SM 有 65536 个寄存器你的 Shader 每个线程用 64 个寄存器那这个 SM 最多同时跑 1024 个线程如果每个线程用 128 个寄存器就只能跑 512 个线程。线程数少了会怎样延迟隐藏能力下降。GPU 靠大量线程切换来掩盖内存访问、纹理采样的延迟。线程少了一旦某个 Wave 在等纹理没有足够的其他 Wave 顶上GPU 就空转帧率就掉。那什么会让寄存器用量飙升几个典型同时存活的中间变量太多。比如你一口气算了 10 个中间值然后才用它们这 10 个都得占寄存器。复杂的长表达式。编译器为了优化可能会展开成很多临时变量。循环里的累加器、索引变量。大数组在 Shader 里数组基本都放寄存器或 local memory很贵。踩过的坑有次我写了个自定义节点里面用了个float3x3矩阵做变换还顺手存了几个中间向量。编译出来一看寄存器用量从 40 飙到 90Occupancy 直接腰斩。后来把矩阵拆成三次 dot 运算中间变量用完即弃寄存器降回 50 出头帧数回来了。能用完就扔的变量别让它一直活着。3. UE 里怎么定位 Shader 瓶颈3.1 用 Shader Complexity 视图快速扫雷UE 编辑器里有个神器叫Shader Complexity视图在视图模式里选 Optimization Viewmodes - Shader Complexity。它用颜色表示每个像素的 Shader 开销绿色是便宜红色是贵白热化就是贵到离谱。这个视图的好处是直观一眼就能看出场景里哪块区域 Shader 最重。比如你发现某面墙是红的点进去看它的材质大概率是节点太多或者采样太多。但要注意Shader Complexity 是个粗略估计它主要看指令数不直接反映寄存器压力和带宽。所以它是扫雷工具不是诊断工具。真正定位问题还得看更细的数据。3.2 用 ProfileGPU 和 RenderDoc 看真实数据UE 自带的ProfileGPU命令控制台输入ProfileGPU或按 CtrlShift,能给你每个 Pass 的 GPU 耗时。重点看 Base Pass、Lighting、Translucency 这几个跟 Shader 强相关的。更细的要看RenderDoc或者PIXNVIDIA 平台。以 RenderDoc 为例抓一帧之后找到你关心的 Draw Call点进去看它的 Shader能看到指令数Instruction Count寄存器用量Register CountOccupancy理论占用率每个阶段的耗时这几个数字是优化的体检报告。指令数高就是 ALU 瓶颈寄存器高就是占用率问题采样次数多就是带宽问题。3.3 读懂编译后的 Shader 汇编UE 材质编译后可以看 HLSL 和汇编。在材质编辑器里Window - Shader Code - HLSL Code 能看到生成的 HLSL。想看汇编得用平台工具比如 D3D 的可以用fxc或 RenderDoc 反汇编。看汇编不用全看懂重点看几类指令mul、mad、fma乘法和融合乘加最便宜。div、rcp、sqrt、pow、sin、cos贵能省则省。tex、texld纹理采样有延迟。if、loop分支和循环看是否发散。一个经验值一个像素 Shader 的 ALU 指令数控制在 100 以内算健康超过 200 就要警惕超过 500 基本就是性能黑洞。当然这跟目标平台有关移动端要更严格。4. 实战把一个重材质从 40 帧救回 70 帧4.1 问题现场一个半透明玻璃材质回到开头那个案例。朋友的展厅里有一面玻璃隔断用的是半透明材质开了折射、反射、还有一层菲涅尔边缘光。材质节点大概长这样一个 Cubemap 采样做反射一个 SceneColor 采样做折射菲涅尔用pow(1 - dot(N, V), 5)算边缘光用sin做波动还有几个 lerp 混合在 RenderDoc 里看这个材质的像素 Shader指令数 380 多寄存器 96Occupancy 只有 40% 左右。半透明 Pass 本来就贵要读 SceneColor有混合再加上这么重的 Shader直接成了瓶颈。4.2 逐项拆解哪些能砍哪些不能砍我按性价比排了个序第一刀菲涅尔的 pow 换成近似。pow(x, 5)在硬件上通常展开成多次乘法或者用 exp/log 近似不便宜。菲涅尔这种视觉上不需要精确的直接用x*x*x*x*x或者更省的x*x再 lerp 一下肉眼看不出差别。这一刀省了大概 20 条指令。第二刀sin 波动换成预计算的纹理或者直接去掉。边缘光的 sin 波动是逐像素算的但视觉上它只是个缓慢变化的装饰。改成用一张小的噪声纹理采样或者干脆用顶点色/UV 驱动把逐像素的 sin 干掉。省了 15 条指令加一次三角函数。第三刀合并采样。反射和折射用了两张不同的纹理但其实可以打包到一张图的不同通道一次采样拿两个数据。省了一次采样。第四刀中间变量用完即弃。原来代码里把 N、V、L 都存着后面反复用。改成需要时重算重算比存着便宜因为寄存器压力下来了。寄存器从 96 降到 64。第五刀把不随像素变化的计算挪到顶点着色器或材质参数。有些常量计算其实每个像素都一样挪到 VS 或者干脆在 CPU 算好传进来。4.3 改完的效果和验证改完之后指令数从 380 降到 210 左右寄存器 64Occupancy 提到 60% 多。帧数从 40 出头回到 70 左右。画面呢我让朋友对比了改前改后的截图他自己看了半天说好像没啥区别。这就是 Shader 优化的常态大部分视觉上差不多的效果背后是巨大的性能差异。玩家不会盯着你的菲涅尔看它是不是精确的 5 次方但他们会因为掉帧骂娘。验证环节很重要。改完一定要用 ProfileGPU 对比改前改后的 GPU 耗时。用 RenderDoc 确认指令数和寄存器确实降了。截图对比画面确认没有明显视觉退化。在不同距离、不同角度、不同光照下都看一眼避免某些视角下穿帮。注意事项半透明材质的优化要特别小心因为它涉及混合和深度。你砍掉的某些计算可能在某些视角下才显现问题。改完一定要多角度验证别只看正面。5. 那些年踩过的 Shader 优化坑5.1 常见问题速查表问题现象可能原因排查方向解决思路改完 Shader 帧数没变瓶颈不在 Shader看 ProfileGPU 各 Pass 占比先确认是不是 GPU 瓶颈可能是 CPU 或带宽帧数变了但画面崩了砍过头了对比截图看哪个效果没了逐项回退找到临界点移动端特别卡但 PC 没事移动端 ALU/带宽更弱看移动端 GPU 耗时移动端要更激进地砍指令和采样Occupancy 上不去寄存器或共享内存占用高看寄存器用量减少同时存活的变量拆复杂表达式某些视角突然掉帧分支发散或采样缓存失效多视角 Profile消除逐像素分支优化纹理访问模式5.2 几个反直觉的经验经验一不是所有 pow 都贵。pow(x, 2)编译器会优化成x*x很便宜。但pow(x, 2.5)或者变量指数就贵了。所以看到 pow 先看指数是不是常数、是不是整数。经验二纹理采样不一定比计算贵。在 ALU 已经满载的情况下用一次纹理采样换掉一堆计算往往是划算的。反过来如果带宽已经吃紧那就别再加采样了。要看瓶颈在哪。经验三顶点着色器里的计算比像素着色器便宜得多。一个模型可能只有几千个顶点但有几百万个像素。能在 VS 算的别放到 PS。比如一些跟视角无关的常量、光照方向变换都可以挪到 VS。经验四材质实例化能省编译时间但不一定省运行时间。材质实例只是改参数Shader 本身还是那套指令。如果参数变化导致走了不同的分支那运行时该贵还是贵。经验五别迷信节点少就快。有时候一个复杂节点内部做了优化比你手动拼一堆简单节点还快。UE 的很多内置节点比如 Fresnel、Lighting都是优化过的能用内置就用内置。5.3 一个容易被忽略的点Shader 变体UE 的材质会生成大量 Shader 变体不同光照、不同平台、不同质量等级。变体多了会导致编译时间长打包慢。运行时切换变体可能卡顿。内存占用高。优化变体的思路是砍掉用不到的变体。比如你的项目不用静态光照就把静态光照相关的变体关掉。在项目设置里可以配置。这个不直接提升单帧性能但能改善整体体验和包体大小。6. 从指令到画面一套可复用的优化流程6.1 先测量再动手优化最忌讳的就是我觉得这里慢。一定要先测。流程是用 ProfileGPU 看整体找到最贵的 Pass。用 Shader Complexity 看场景找到最红的区域。用 RenderDoc 抓帧看具体材质的指令数、寄存器、采样次数。确定瓶颈类型ALU / 寄存器 / 带宽。针对性优化。6.2 优化的优先级排序按性价比从高到低砍掉不必要的效果如果某个效果玩家根本注意不到直接删。这是最省事的。降低计算精度能用 half 就不用 float移动端尤其重要。用近似替代精确菲涅尔、衰减、噪声很多都可以近似。合并采样打包纹理通道减少采样次数。消除分支用 lerp/step 替代 if。减少寄存器压力变量用完即弃拆复杂表达式。挪计算到 VS 或 CPU能提前算的提前算。6.3 一个具体的检查清单每次写完或改完一个材质我会过一遍这个清单有没有逐像素的 if 或循环有没有非常数的 pow、sin、cos、sqrt纹理采样次数是多少能不能合并中间变量是不是都活着能不能提前释放有没有能在 VS 算的东西放在了 PS精度是不是都用 float能不能降 half有没有用内置节点替代手写节点改完有没有多角度验证画面这个清单不复杂但能挡住 80% 的低级性能问题。7. 关于 GPU 执行模型还有几个值得知道的细节7.1 延迟隐藏GPU 为什么需要那么多线程GPU 的纹理采样、内存访问延迟很高几百个周期。如果只有一个线程在跑它等一次采样就浪费几百个周期。GPU 的解法是同时驻留大量线程一个线程等采样的时候切换到另一个线程继续算。这就是延迟隐藏。所以 Occupancy同时驻留的线程数很关键。Occupancy 低了延迟隐藏不住GPU 就空转。而 Occupancy 直接受寄存器用量影响。这就是为什么减少寄存器往往比减少指令更能提升性能——它让更多线程能同时跑。7.2 指令级并行为什么指令顺序也重要GPU 的 ALU 有多个执行端口可以同时执行不同类型的指令。如果你的指令序列里全是同类型的比如全是乘法端口就堵了如果交错着来乘法、加法、采样交替端口利用率就高。编译器一般会帮你重排指令但有时候它排得不好。手动调整指令顺序让不同类型的指令交错能提升吞吐。这个比较进阶一般不用手动做但知道有这回事看汇编的时候能理解为什么某些写法快。7.3 纹理采样的那些门道纹理采样不只是读个颜色那么简单。它涉及过滤双线性、三线性、各向异性越复杂越贵。MipMip 用对了能大幅减少带宽和缓存失效。UE 默认会算 Mip但如果你手动算 UV 或者用了不规则的采样Mip 可能失效。缓存相邻像素采样相邻纹素缓存命中率高。如果 UV 跳变很大比如三平面映射的接缝缓存命中率低性能就差。优化采样的思路减少采样次数、用更简单的过滤、保证 UV 连续、正确使用 Mip。8. 写在最后一些个人体会Shader 优化这件事说到底是个理解硬件 理解需求的活。你得知道 GPU 喜欢什么无分支、少寄存器、连续访问不喜欢什么发散、高寄存器压力、随机访问同时你得知道玩家在意什么整体观感、帧率稳定不在意什么菲涅尔是不是精确的 5 次方。我个人的习惯是每写一个稍微复杂点的材质都会顺手用 RenderDoc 看一眼指令数和寄存器。这个习惯帮我避开了很多上线才发现卡的坑。刚开始看汇编会觉得头大看多了就有感觉了哪些指令贵、哪些便宜心里大概有数。还有一点别把优化当成一次性的事。项目在变场景在变硬件在变今天优化的结论明天可能就不适用了。保持测量、保持怀疑、保持验证比记住任何一条具体技巧都重要。最后分享一个小技巧如果你不确定某个改动有没有用就做个 A/B 对比。改之前截个图、记个帧数改之后再截再记。数据不会骗人感觉会。我见过太多人感觉优化了结果一测帧数没变白忙活。