聊 Shader 优化之前,我建议你先暂时把 UE 编辑器里那一堆花花绿绿的调试视图关掉。Shader Complexity、ProfileGPU、RenderDoc 这些工具确实能告诉你“哪里慢”,但真正要回答“为什么慢”,你得先搞明白 GPU 到底怎么执行你写的那些指令。这个事儿想通了,后面所有优化手段都不是死记硬背的招数,而是顺着硬件逻辑自然推出来的。
我自己做技术美术这些年,最深的感触就是:绝大多数 Shader 性能问题根本不是因为你算法不够花哨,而是你根本不了解 GPU 的工作方式。比如同一个if分支,在 CPU 上写和 GPU 上写完全是两回事;同一张纹理采样,在不同硬件上的代价能差出一个数量级;你精心写的复杂光照代码,可能因为一个 half 精度没开就白白损失一半性能。说白了,Shader 优化的底层逻辑就是“顺着 GPU 的脾气来”——GPU 喜欢什么,你就给它什么;GPU 怕什么,你就躲开什么。恰好 CPU 和 GPU 的“脾气”差别极大,搞反了就各种卡顿。
这篇文章不打算写成枯燥的硬件科普,而是想从 GPU 怎么取指、怎么执行指令、怎么访问内存这几个最底层的点出发,带你一步步倒推出 UE Shader 优化里最实用的那些思路和套路。不管你是刚入行的技术美术,还是被项目性能折腾到秃头的客户端程序,只要耐心看完,至少能少走我当年踩过的一大半坑。
1. GPU 到底是怎么执行 Shader 的
1.1 SIMT、Warp 和 CTA:先把这个搞清楚
在聊优化之前,得先说清楚 GPU 执行指令的基本单位。CPU 是“一个核跑一个线程”,每个核心独立取指、独立执行,所以你能随意写分支、写循环,逻辑怎么乱都行。GPU 不是这么玩的,它靠的是海量并行,核心设计理念是“让几千个线程一起干同一件事”。
所以 GPU 采用了 SIMT(单指令多线程)架构:一条指令同时喂给一堆线程。NVIDIA 把这堆线程叫做 Warp,通常是 32 个线程一组;AMD 那边叫 Wavefront,通常是 64 个线程一组。你可以把 Warp 想象成一个“线程宿舍”,宿舍里的 32 个人必须步调一致——教官喊一声“齐步走”,32 个人必须同时迈腿。而在 UE 的 Shader 编译器眼里,你的每个像素、每个顶点最终都会被编进某个 Warp 里,跟着其他 31 个“室友”一起执行同一条指令。
这里还有一个相关概念叫 CTA(Cooperative Thread Array),它跟 Warp 是包含关系:一个 CTA 由多个 Warp 组成,对应到 CUDA 里就是 Thread Block,在图形渲染管线里就是一个 Compute Shader 的工作组(Workgroup)。硬件调度是以 CTA 为单位把任务分发给 GPU 上的计算单元(比如 NVIDIA 的 SM、AMD 的 CU),然后 SM 内部再以一个一个 Warp 为单位发射指令。所以你看,Warp 是指令执行的调度粒度,CTA 是资源分配的调度粒度——这俩容易混,但搞清楚了,后续理解 Occupancy(占用率)和延迟隐藏就很轻松了。
为什么要强调这个?因为在 SIMT 架构下,一个 Warp 里的所有线程必须执行同一条指令。如果你的代码里出现了if (condition) { A } else { B },而且同一个 Warp 里有的线程走 A 分支、有的走 B 分支,那 GPU 没办法真的“分流”,它只能先让所有线程执行 A 分支(走 B 的线程被禁用/掩蔽掉),再让所有线程执行 B 分支(走 A 的线程被禁用)。也就是说,分支路径会被串行执行,你原本以为只跑一遍的代码,实际跑了两遍甚至更多。这就是所谓的 Divergence(发散),也是 GPU 上写分支最需要警惕的点。
1.2 一条 DrawCall 背后的执行流程
再往深一层,看看 GPU 是怎么执行你的一整段 Shader 的。以很常见的渲染管线为例,你发了一条 DrawCall,要画一个带材质的模型,背后大概经历这么几步:
第一步,CPU 侧准备阶段。CPU 把顶点数据、纹理、常量缓冲区(Constant Buffer)统统整理好,再通过驱动把命令塞进 GPU 的命令缓冲区。这一步对应的就是 UE 里的 RHI 层提交,常见开销比如 DrawCall 个数、状态切换、资源绑定、缓冲区上传,全在这儿。
第二步,GPU 取指-译码阶段。GPU 拿到命令后,会把它翻译成内部指令流。注意,GPU 不会直接执行 HLSL 或者 GLSL,而是执行由驱动编译器转换出来的中间码和机器码。在 PC 上,UE 的 Shader 默认编译成 DXBC / DXIL(DX12 下是 DXIL),然后 NVIDIA 或者 AMD 的驱动再把它 JIT 成硬件微码;在移动端,UE 的 Mobile 渲染路径则要依赖驱动把 GLSL ES 或是 SPIR-V 编译成 Mali、Adreno 或者 Apple GPU 的私有指令集。所以同样一个材质,在不同平台上执行的“指令”并不一样。
第三步,调度与发射阶段。硬件调度器把 Warp 按一定节奏发射到计算单元里。每个计算单元内部有一堆 ALU(算术逻辑单元)和 SFU(特殊功能单元,处理三角函数、倒数等复杂函数),还有一堆寄存器文件。指令被发射之后,要经历取操作数、运算、写回的过程,ALU 指令几个周期就能跑完,但一些复杂指令(比如向量除法、三角函数)可能消耗十几到几十个周期。
第四步,访存阶段。这是 GPU 最昂贵的一环。Shader 里执行一次纹理采样,GPU 要走纹理管线:命中纹理缓存(在一二级 Cache 里)的话大概几十个周期的延迟,没命中就得从显存(VRAM)甚至是系统内存里搬数据,延迟直接跳到几百个周期。好在 GPU 有大量 Warp 在同时运行,硬件调度器可以把等待显存返回的 Warp 挂起,立刻换上一批不需要访存的 Warp 继续执行。这就是所谓的延迟隐藏——GPU 用并行度来硬扛高延迟。
所以你会发现,GPU 就是这么个东西:它的强项是海量并行+延迟隐藏,弱点则是对分支发散的容忍度低、对访存带宽的要求极高。理解了这两个核心特性,Shader 优化的一大半思路基本就浮出水面了。
1.3 瓶颈三角形:ALU、带宽、延迟
我习惯把 GPU 性能约束关注点归纳成三个指标:ALU 计算量、显存/缓存带宽、延迟与线程占用。任何一个成了瓶颈,你的帧率都上不去。
- ALU 瓶颈:你的 Shader 里有太多复杂数学运算(大量 mul、pow、sin、cos、纹理采样次数多到把 ALU 吞吐打满)。表现是 GPU 利用率很高,但帧率依然低,ProfileGPU 里某个 DrawCall 的 GPU 时间明显偏高。
- 带宽瓶颈:Shader 里纹理采样过多、纹理格式过大、Mesh 顶点数据过于臃肿,导致显存带宽被打满。表现是 GPU 占有率并不高,但整体帧率被拖死,因为大家都在等数据从显存里搬回来。
- 延迟/占用瓶颈:寄存器用量过高、线程数不足,导致 SM 里的 Warp 太少、无法隐藏访存延迟。表现是 GPU 计算单元利用率很低,但帧率依然上不去——典型的“算力闲着但没人能干活”。
知道了这三类瓶颈,你就明白优化目标不只是“减少指令数”这么简单。很多时候真正卡你的是带宽问题,你却在那儿调 ALU 指令,自然怎么调都没用。这也是为什么我一直强调,优化之前先定位瓶颈类型,别拿着锤子见钉子就敲。
2. 从执行模型反推 UE Shader 优化思路
2.1 指令数不是一切,但它是体检报告
UE 材质编辑器里最直观的指标就是这个:材质编辑器右侧的 Stats 面板,会告诉你当前的Instruction Count和Texture Samples。很多人优化 Shader 就看这个数字,觉得数字越大越危险,越小越安全。其实这话对了一半,指令数的确是一份有用的“体检报告”,但它不能直接取代对瓶颈的判断。
比如说,同样的 200 条指令,如果它们是 200 条四元数乘法,那么对 ALU 的压力远大于 200 条简单的 MOV 指令;如果这 200 条指令里有 4 个tex采样指令,那么真正的瓶颈大概率在纹理带宽而不在 ALU 计算。所以指令数只能告诉你“这材质大概多复杂”,但它没法告诉你“到底为什么会卡”。
那指令数的意义在哪?在横向比较。同一个材质,你调整某个节点的算法后,如果 Instruction Count 从 250 掉到 180,说明你的修改确实减少了 GPU 工作量。但如果你看不到这个数字变化,光靠肉眼在屏幕上对比画质差异,很容易做无用功。所以我建议把 Stats 面板当成一个“优化标尺”,每次改动都看一下数值变化,再结合 GPU 实测数据综合判断,而不是盲目追求数字最小化。
还有一点值得注意:Instruction Count 分平台。UE 编辑器里默认显示的是当前 RHI 对应的指令统计,PC 上可能是 SM5 的指令,移动端可能是 ES 3.1 的指令,两者差距非常大。移动端的 ALU 运算能力和并行度远弱于桌面 GPU,同样的材质在桌面端可能只有 150 条指令,在移动端编译器里展开后能膨胀到 300 条以上。所以做移动端项目的时候,记得把编辑器里的Preview Rendering Level切到ES 3.1(或者对应平台)再来看指令数,不然你优化的目标基准都搞错了。
2.2 带宽比 FLOPs 更值钱:采样与纹理的代价
很多新手写 Shader 最容易忽略的一个事实:一次纹理采样(tex 指令)的开销,绝不是“读个像素”这么简单。纹理采样在硬件上要经历纹理解压(BC 格式、ASTC 格式解压)、双线性/三线性过滤、Mip 级选择、Cache 命中检测等一系列环节。在 PC 高端显卡上,一条tex指令的延迟远高于一条mul;而在移动端 GPU 上,纹理采样通常更贵,尤其是带宽消耗大的格式。
举个例子。一个基础 PBR 材质要有漫反射纹理、法线纹理、粗糙度/金属度纹理、AO 纹理,这就是 4 次采样。如果你再叠加一个细节法线、一个细节 AO、一个自定义遮罩纹理,采样次数直接奔着 7~8 次去了。在桌面端可能还好,但移动端很多 GPU 的纹理单元和带宽都吃紧,这 8 次采样足够把带宽打到一个很危险的程度。我在移动项目里常用的标准是:像素着色器里的采样次数尽量控制在 4 次以内,最多 6 次;超出部分都要拿出来反复审问——真的有必要吗?
那怎么降采样次数?核心思路是纹理打包。比如你原本用 3 张纹理分别存 Roughness、Metallic、AO,完全可以合到一张纹理的 RGB 通道里,一次采样全出来。UE 里也可以通过Material Attribute和自制 Shading Model 来配合这种做法。另一个思路是用LUT(查找表)替代复杂计算,比如把很贵的 BRDF 积分预先烘焙到一张 2D 纹理上,渲染时一次采样搞定。虽然这也增加了采样次数,但比起十几个周期的复杂计算,可能是因为更值的——具体哪个更优,还是要看你的瓶颈在 ALU 还是带宽。
顺带一提,移动端 Mip 缺失是带宽隐形杀手。如果某张纹理没有生成 Mip 链(或者 Mip 链不完整),GPU 在采样时无法进行有效的纹理缓存优化,镜头一动,整块纹理被反复从显存搬进缓存,带宽瞬间爆掉。UE 里默认的纹理导入设置会自动生成 Mip,但如果你用了某些第三方资源或者动态创建的纹理,别忘检查一下 Mip 设置。
2.3 条件分支为什么贵:波前发散
刚才讲 GPU 执行模型时提到过,同一个 Warp 里的线程必须执行同一条指令,遇到发散分支时只能串行执行两条路径。这意味着什么?意味着GPU 上的分支代价跟 CPU 完全不是一个量级。CPU 上有分支预测,猜对了几乎零成本;GPU 上几乎没有分支预测这个说法(有也是针对 uniform 分支),你的分支到底跑多少遍,取决于同一个 Warp 里有多少线程是走 A 的、有多少是走 B 的。
那么在 UE 材质里,什么时候分支是安全的?只有一种情况:分支条件在渲染期间是常量(uniform 变量),比如从材质参数集传来的bool参数,或者平台宏控制的编译期常量。这种情况下,同一个 Warp 里的所有线程走的是同一个分支,不产生发散,分支代价极小。
但如果你分支条件是逐像素变化的——比如if (Normal.y > 0.5),那么同一个 Warp 里基本必然出现发散,GPU 会先执行true路径、再执行false路径,再合并回来。结果就是你的 Shader 实际执行时间变成“两条路径执行时间之和”。更糟的是,如果分支体内有纹理采样,两条路径加起来可能多出一倍的带宽消耗。
UE 材质编辑器里专门有个Branch节点,但要慎用。我之前见过一个 TA 同学为了省性能,把复杂的风摆动计算包在一个Branch后面,想“只有草地才执行这堆计算”。结果因为分支条件在像素间不稳定,不但没省,反而多跑了一遍计算。这就是典型的“在 GPU 上写了想象中的分支”。
那正确的做法是什么?优先级从高到低我记得是这样:能删就删(不用的功能直接断线),能用静态分支(编译期常量、材质质量等级、平台宏)就用静态分支;能用数学算式近似(lerp 混合、smoothstep 过渡)就尽量替代条件逻辑;实在要动态分支,确保分支条件是 uniform 的,且分支体内的代码尽量短、尽量少采样纹理。
2.4 半精度:移动端的救命稻草
最后说精度。GPU 上浮点运算有三种精度:full(32 位,也就是 float)、partial(16 位,也就是 half)、minimal(更低阶的定点,通常用于特定硬件)。桌面 GPU 对 32 位浮点运算的支持很完善,所以你在 PC 上写 float 一点问题都没有。但移动端 GPU 不一样,大部分 Mali、Adreno 的 ALU 对FP16 有“打包加速”——一条指令能同时处理两份 16 位浮点数据,吞吐率直接翻倍。也就是说,同样的算法,用 half 精度写,在移动端可能比 float 快接近一倍。
UE 材质编辑器里怎么控制?材质属性里有个Use Full Precision选项,默认是关闭的,意味着 UE 会尽量使用半精度优化。但这里有个坑:一旦你的材质里出现某些特定节点(比如Sin、Cos、Sqrt的高精度版本、某些 Custom 节点强制转成 float),编译器可能会把整段 Shader 里很多计算都推到 32 位精度,导致半精度优化全部失效。所以你在移动端做材质时,别轻易勾选 Use Full Precision,这不是让你全精度反而更省,而是彻头彻尾的性能毒药。
另外,Custom 节点里手写的 HLSL 也很容易无意间破坏半精度优化。如果你在 Custom 节点里写float temp = ...,而 UE 编译器又没法把它折叠成 half,那这一段计算就变成全精度了。如果确实需要手写,尽量显式用half类型。不过也要注意,half 精度不是白给的,它精度太低时会出现明显瑕疵——比如大范围 UV 坐标时的带状伪影(Band Artifact)、高精度光照计算中的颜色断层。所以在移动端用 half 要分场景,世界坐标相关的大数计算、需要高度精密的法线处理,尽量保持 float;而对颜色、UV 偏移这类可接受的精度损失,大胆用 half。
3. 在 UE 里动手:工具、参数与实操
3.1 材质编辑器里的第一手判断
前面理论撑得差不多了,现在讲实操。UE 材质编辑器本身就是一个非常好的 Shader 性能分析工具,前提是你知道怎么用。
第一件事:打开 Stats 面板。在材质编辑器工具栏右侧(或者 Window 菜单里)能找到Stats,它会实时显示:
- Instruction Count:总的指令数估算
- Texture Samples:纹理采样次数
- Vdecl:顶点数据声明(显示你用了哪些顶点属性)
- Interpolators:从顶点着色器传到像素着色器的插值器数量
这里头Interpolators容易被忽略,但它其实很关键——插值器数量直接决定顶点数据传输量,而顶点带宽是经常被忽略的瓶颈。场景里角色、植被、武器各有几十个插值器时,光传插值器数据就能把带宽吃满。减少插值器的方法跟减少采样一样:打包。需要传到像素着色器的数据(比如世界法线、世界切线、UV 集合)如果能塞进更少的 Float4,就别开一堆插值器。
第二件事:查看生成的 Shader Code。UE 材质编辑器里有一个比较隐蔽的入口:在工具栏里找到Window → Shader Code(或者材质蓝图的预览图下方有个Code按钮),点开能看到当前材质生成的 HLSL 源码。这是我从入行至今一直依赖的功能——与其猜 GC 藏了什么,不如直接看它编译给你生成的代码。比如某个材质你只想算一次光照,结果打开源码发现 UE 把一个常数折叠、把某个函数多算了三次,你就能立刻定位问题。通常情况下,如果生成的 HLSL 里有大量你完全没想到的mul、dp4、sincos,那就说明材质图里某个节点在暗地里做高开销的事情,挨个排查删掉即可。
第三件事:材质质量等级。UE 材质节点有一个Feature Level Switch(或者MaterialQualityLevel节点),可以在 Low / Medium / High 三档之间切换不同逻辑。移动端项目里,我通常会给材质做两版逻辑:High 档用完整的 PBR 光照、法线细节采样;Low 档砍到只剩漫反射+单次采样,甚至用 Unlit 替代 Lit。这个成本很低,收益很高,是最适合规模化铺开的上层优化手段。
3.2 Shader Complexity 与 Overdraw 排查
材质编辑器里解决的问题是“这个材质本身写了多少指令”,但画面里真正跑起来什么样,还得靠视图模式说话。UE 编辑器主视口的 Buffer Visualization(Lit 模式旁边的下拉菜单)里有一个Shader Complexity视图,它能把你屏幕上所有像素实际执行的 Shader 指令量用颜色映射成一张热力图:蓝色是便宜,绿色还不错,黄色偏贵,红色就是性能炸弹。
这个视图用起来有一个技巧:别在纯色背景或者空旷场景里看,那些地方基本全是蓝色,没什么参考价值。你应该切到游戏视角、最复杂的战斗场景、角色与植被最密集的区域去看。如果某个角色材质区域亮起红色,可以先锁定是角色;然后看是身体材质贵还是头发材质贵;最后回去优化对应材质。Shader Complexity 的实际数据是每像素的指令数,并不是真实 GPU 时间,但它定位“哪个物体最贵”非常高效。
Shader Complexity 视图还配合一个经典思路:寻找 Overdraw 大户。Overdraw 指同一个像素被多次渲染,最常见的是半透明粒子、玻璃、体积雾、正确排序的多个半透明面片叠加。在 Shader Complexity 视图下,半透明区域的红色往往不是材质本身贵,而是它被画了 5 遍、10 遍。排查 Overdraw 还有一个更直观的方法:编辑器里View Mode → Lit下按Ctrl+Shift+K或者打开Buffer Visualization → Overdraw(具体按键版本不同),把 Overdraw 视图打开,画面越白的地方表示重复绘制越严重。
Overdraw 的优化手段大家都知道:半透明粒子控制数量、排序开启从前往后渲染、粒子材质尽量用 Unlit 加极简指令;半透明物件能合并的就合并;屏幕空间效果(体积光、景深)用低分辨率另一半分辨率;实在躲不开的,考虑用Half Resolution 渲染半透明通道——UE 里可以通过 Scene Capture、自定义后处理或者某些移动端 Renderer 设置来控制,但这属于比较进阶的操作,下文的常见问题部分我还会展开讲。
3.3 ProfileGPU/Insight 与 RenderDoc 的配合
材质级分析完了,还要看单帧真实 GPU 开销。UE 里最常用的还是控制台命令ProfileGPU(在编辑器或游戏里按Ctrl+Shift+,),它能列出这一帧里所有 Pass 的 GPU 时间,从 PrePass、BasePass、半透明 Pass 到后处理,逐一给你摊开看。这个数据的价值在于:它能精确告诉你瓶颈在哪条 Render Pass 上,而不只是某个材质。
典型用法是:打开 ProfileGPU,看 BasePass 是否异常偏高(说明场景里材质普遍太贵或者几何体过多);再看半透明 Pass 是否反常(说明粒子或者玻璃材质性能爆炸);然后顺着最慢的 Pass 双击进去,能看到具体的 DrawCall 列表,最贵的 DrawCall 一目了然。我通常会把这里的 GPU 耗时跟 Shader Complexity 视图交叉验证:ProfileGPU 说某个 DrawCall 耗时高,Shader Complexity 视图里也确实那片区域发红,基本就能确认是材质问题而非其他系统问题。
但 ProfileGPU 只能看到“某个 DrawCall 花了多少毫秒”,还是看不到“这个 DrawCall 到底为什么花钱”。这时候上RenderDoc。UE 的插件市场(或者引擎自带插件里)有 RenderDoc 的集成,启动后能捕获一帧,然后你可以查看每个 DrawCall 的输入输出、纹理绑定、资源状态,还能看它到底绑定了哪些 Shader。更进阶的用法是:在 RenderDoc 里打开某个 DrawCall 的 Shader 汇编(DXBC / SPIR-V / DXIL),看实际的采样指令、分支指令、ALU 指令分布。虽然会有点门槛,但当你真正学会读这些汇编的时候,你对硬件执行的理解会发生质变——你会亲眼看着一条tex指令摆在面前,意识到一次采样到底背负了多少代价。
移动端如果不想这么重,也可以用 Mali Offline Compiler(ARM 官方的二进制分析器)或者 Adreno GPU Profiler 来静态分析 Shader:把 UE 导出的 GLSL 塞进去,它能直接告诉你寄存器使用量、ALU 指令数、纹理单元占用率。这个非常适合团队里做 Shader 评审的时候用:同一个材质,桌面端跑得好好的,拿到移动端编译器里一过,你才知道它膨胀成了什么样子。
3.4 移动端优化:从 Shader Model 到 Quality Switch
移动端是 Shader 优化最重要的战场,但也是新手最容易翻车的地方。因为移动端 GPU 架构跟桌面差距太大,很多桌面上的“常识”到移动端直接失效。
一个典型的差异是Tile-Based Rendering(TBR)。桌面 GPU 是立即渲染模式(IMR),画完一整个 DrawCall 再写回显存;移动端 Mali、Adreno 这类则先把一帧分成许多个小块(Tile),每个 Tile 上执行完所有 DrawCall 后,才一次性把结果写回显存。这意味着移动端的带宽消耗模式跟桌面完全不一样:它非常在意 Render Pass 的切换、非常在意内存读写次数,因为每一次从 Tile 内存写回主显存都很贵。所以移动端优化里常出现“减少 Render Target 切换”“利用 Subpass 机制”“用 FrameBuffer Fetch 做延迟光照”这些操作,本质上都是为了减少带宽消耗。
另一个差异是精度敏感。之前说过半精度在移动端能翻倍吞吐率,但反过来也意味着,如果代码里混用了 float 和 half,编译器为了转换可能要插入很多f2h、h2f指令,反而变慢。所以你写移动端材质时,尽量保持同一链路里用同一精度,别一会儿 float 一会儿 half 随便混。
还有移动端的指令限制。老一些的 Mali GPU(比如 Mali-400 系列)有指令数上限(如 256 条),超过就直接编译失败;虽然新 GPU 没有硬上限,但指令过多、寄存器过多都会显著拉低 Occupancy,导致延迟隐藏能力下降。UE 里查看移动端指令数的办法,就是把 Preview 切到 ES 3.1,然后重新看一下 Stats 面板的指令数;如果发现数百甚至上千条,那一定得砍。
实际操作层面,我最推荐的一个工作是:给材质建立移动端降级规范。比如:
- 移动端 Low 档材质:Unlit 或极少的光照模型、0~1 次法线采样、不采样场景深度、不上向量贴花。
- 移动端 Medium 档材质:Lit,但关闭高光反射细节、法线用单张低精度贴图、AO 用顶点烘培混合替代。
- 桌面端 High 档材质:完整 PBR,保留细节法线、AO、按需增加采样层。
用Feature Level Switch把三档逻辑串起来后,美术在桌面端做完效果,切到移动端一调档位就能生效,而且完全不打断原有的质感。这套降级流程是我在任何 UMG 项目里都会优先推行的。
4. 常见 Shader 性能问题与排查实录
4.1 材质糊成一片:贴图混合与采样失控
我接手过好几个项目,特征非常一致:场景里所有地面、墙面、台阶全部用 Landscape 材质 + 多层贴图混合。做了五层混合、每层都有三到四张贴图(漫反射、法线、粗糙度、AO),LOD 没管好,结果一个地表的 Texture Samples 能飙到 20 次甚至更多。这种材质在桌面端还能兜底,到了移动端直接成了带宽黑洞,整个场景帧率被地面拖到个位数。
排查方法很直接:切换 Shader Complexity 视图,会发现地面颜色红得发紫。再配合 Stats 面板看一眼 Texture Samples,20 次采样的数字摆在面前,谁都没法说是“正常材质”。
怎么解决?核心思路是减少实际混合的贴图数 + 打包。比如把原来 5 层混合控制成 2 到 3 层,并且把法线、粗糙度、AO 在导入时就打包成一张纹理(RGB 分别存)或者用单通道纹理。也可以用 UE 的Landscape Layer Weight做更高效混合,尽量避免逐层三重采样后再做一堆lerp。如果非要保留多层效果,可以在 PC 上做高级效果、移动端用一个简化的 Landscape 材质(两层或者干脆用烘焙好的贴图),这也是之前提的材质质量等级思想的延伸。
4.2 一个分支卡死一台手机:动态分支的代价
有一次我在移动端项目里要做“根据视角距离切换细节”的效果,顺手用了一个动态Branch判断距离远近。我的原意是“远处的草不用算那么精细”,结果实测帧率反而下降了 30%。排查过程很典型:Shader Complexity 视图没看出异常,但 ProfileGPU 显示植被渲染那一项特别慢。后来打开生成代码一看,好家伙,编译器给我生成了两份完整的草摆动 + 纹理采样代码,动态分支存在,但它两个路径都被执行了——草摆动没省下来,反而多了一套判断逻辑。
这个坑踩完后我养成了一个习惯:动态分支条件必须全部来自 uniform 常量或材质参数集,凡是跟像素坐标、法线方向、UV 数值相关的分支,一律用数学混合替代。比如距离远近的过渡,用smoothstep+lerp把两套效果按权重混在一起,虽然两套代码都会执行,但至少没有额外的分支开销,而且混合的渐变更自然。不要在 GPU 上跟编译器玩“投机取巧”的把戏,GPU 老老实实跑代码,没有那么多捷径。
4.3 半透明粒子:Overdraw 的无底洞
粒子系统是 Shader 优化的重灾区,尤其是半透明粒子。一次屏幕爆炸特效,可能同时渲染几百上千个粒子,每个粒子又是一个四边形面片,同一个像素被反复写入六七次甚至更多。每个粒子材质如果还带了法线采样和半透明光照计算,那这个像素的真实成本就是“粒子数 × 材质成本”。
Overdraw 难在它不像材质指令那样能一眼看出来——你打开 Shader Complexity 视图,粒子区域会红得刺眼,但那不是某一个粒子贵,而是十几个粒子层层叠叠。排查方法用 Overdraw 视图最直观,画面上那个白色浓度爆表的位置就是问题区域。
优化手段按优先级:
- 降低粒子数量,这是最粗暴但最有效的。一个 200 粒子特效砍到 80 粒子,视觉上可能几乎没差别。
- 粒子材质降级:半透明粒子通常不需要完整 PBR,改成 Unlit + 简单混合,指令数从一两百降到几十。
- 半分辨率渲染:如果粒子确实躲不开多,把半透明通道渲染到半分辨率 Render Target 再上采样。UE 里可以使用 Post Process Volume 的 Screen Percentage,或者自定义 Scene Capture 路径。实测对移动端来说,这个操作往往能让粒子场景从 30 帧救回 60 帧。
- 剔除多余混合:粒子材质里打开
Disable Depth Test或者用Additive混合模式,有时能减少不必要的深度读写。但这需要美术反复调参,否则视觉效果会崩。
4.4 一个我踩过的坑:Mip 缺失引发的性能雪崩
最后分享一个非常隐蔽的问题。有一次在移动端测试场景时,画面里所有物体转到某个角度就突然掉帧,转回来帧率又恢复。我一开始以为是场景剔除的问题,后来发现是一张高分辨率大地贴图没有 Mip 链。GPU 在采样这张贴图时,因为 Mip 缺失,它在被缩小显示时依然在加载 LOD0 级别的高分辨率数据,导致纹理缓存命中率暴跌、显存带宽被来回冲刷,帧率瞬间雪崩。
排查方法有两个:一是用 RenderDoc 看纹理绑定时有没有 Mip 链;二是在 UE 里打开这个纹理资产,查看它的导入设置。如果导入时 Mip 生成被禁用了,赶紧改回来。更隐蔽的是,某些运行时生成的纹理(比如 Render Target 或者动态纹理)默认是没有 Mip 的,如果你把它当作漫反射贴图采样,问题就来了。解决办法:如果是动态纹理需要 Mip,在创建时指定TargetableTexture加 GenerateMips 选项;如果你只是需要它作为临时渲染结果,那就别让它在透视距离被缩小采样——尽量控制好它的使用范围。
这至今是我遇到的最典型的“带宽型故障”案例:画面上看不出任何指令有问题,材质也不复杂,纯粹是数据管理出了问题。所以 Shader 优化真不只是“少写几条指令”,它是一个覆盖材质、纹理、资源管理、渲染管线的系统工程。
做了这么多年技术美术,我越来越觉得 Shader 优化没有什么银弹。每家引擎、每台硬件都有自己的脾气,真正靠谱的路子就是把 GPU 执行指令的底层逻辑吃透,再把 UE 提供的分析工具用熟,遇到性能问题时按“先定位瓶颈、再针对性优化、最后回归验证”的流程去走。这些工具和理念拼在一起,才能在一个个项目里沉淀出属于你自己的优化方法论。下次再有人问我“怎么优化 UE Shader”,我大概还是会从 GPU 怎么执行指令开始讲——因为这条路,我自己走下来,是真的省过不少弯路。