UE5.8 顶点着色与像素着色优化
一、场景:那个让 PS5 降到 28 帧的材质
美术阿杰在新关卡里做了一面会动的藤蔓墙。基础色、法线、粗糙度、AO 自不必说,他还塞了世界坐标偏移让叶子随风摆动,用顶点颜色混合了三层苔藓,像素层面叠了 Fresnel、边缘光、雨水湿润、动态污渍,最后为了制造阴晴变化,把 Time 节点接进若干扰动纹理做 UV 滚动。在 PC 上看着挺漂亮,合进 PS5 一跑,帧率直接掉到 28。
打开 Shader Complexity 视图,那片墙红得像警报灯。Stat GPU 里 Base Pass 占了 11 毫秒,光这面墙就吞掉了一帧的三分之二预算。问题 obvious,但砍哪个节点才能让画面还能看、性能又能回来?这篇文章记录的就是那次排查中学到的顶点着色与像素着色优化方法。
GPU 渲染管线的核心阶段,是把三维场景变成屏幕像素。顶点着色器负责把模型顶点从本地空间变换到裁剪空间,同时可以做一些逐顶点的计算;像素着色器(有些平台叫片段着色器)则对每个光栅化后的片段计算颜色、深度和其他输出。一个简单但够用的成本模型是:
其中 是可见顶点数, 是顶点着色器每条指令的平均开销; 是实际填充的像素数, 是像素着色器指令数; 是纹理采样次数, 是每次采样的平均耗时。优化的目标不是单纯砍特效,而是让 、、 三项都处在预算之内。
二、顶点着色阶段:别把所有事都堆给像素
顶点着色器在光栅化之前运行。它拿到顶点位置、法线、切线、UV、顶点颜色等属性,输出裁剪空间位置和一组插值到像素阶段的变量。由于顶点数量通常远小于覆盖的像素数量,把能在顶点阶段完成的计算挪过去,常常能省出大量开销。
阿杰那面藤蔓墙的问题是,风动偏移本可以只在顶点阶段做一次,可他却在像素阶段用 World Position 算了扰动,再反向影响颜色。像素阶段覆盖的像素可能是顶点数量的几十倍甚至上百倍,同一套计算在像素里跑一遍,成本自然爆炸。
flowchart TD
cull --> draw
draw --> ia
ia --> vs
vs --> rs
rs --> ps
ps --> omUE5.8 的材质编辑器里,World Position Offset 属于顶点阶段输出,但 Base Color、Normal、Roughness、Emissive 这些默认在像素阶段计算。如果要做随风摆动,优先用 World Position Offset 或 Vertex Animation,而不是在像素里根据世界坐标再反推偏移。
顶点着色器还要注意一个隐藏成本:插值器数量。顶点着色器输出到像素阶段的数据会经过光栅化插值,法线、切线、UV0、UV1、顶点色、自定义数据,每多一组就要占用插值器带宽。插值器太多时,GPU 会把它拆成多个批次处理,反而降低效率。阿杰的藤蔓墙同时输出了三套 UV、两套世界坐标、一套顶点色、一套自定义风动参数,插值器已经逼近上限。
一个实用经验是:如果某个值在三角形内部是常量或线性变化,尽量只传必要的分量。比如风速方向可以只传一个标量强度,在像素阶段再组合方向向量,而不是把完整三维向量插值过去。
三、像素着色阶段:每一分钱都要花在刀刃上
像素着色器是真正烧钱的地方。屏幕分辨率越高、材质越复杂,这里的开销就越大。以 4K 分辨率为例,一帧要处理约 830 万个像素,如果每个像素跑 500 条指令、采样 8 张贴图,那计算量和带宽都非常可观。
像素着色器的指令数直接决定 Shader Complexity 视图里的颜色。绿色通常代表 50 条指令以内,黄色在 100 条左右,红色超过 300,白色往往逼近 1000 甚至更高。阿杰的藤蔓墙材质在 Shader Complexity 视图里红得刺眼,说明像素指令已经失控。
flowchart TD
A["打开 Shader Complexity 视图"] --> B{目标区域颜色}
B -->|"绿色"| C["当前可接受"]
B -->|"黄色"| D["检查是否有冗余节点"]
B -->|"红色"| E["立即拆解材质"]
B -->|"白色"| F["暂停合并先做简化"]
E --> G["把可顶点化计算移到 VS"]
D --> H["删除未生效的 Lerp/Fresnel"]
G --> I["重新烘焙并对比 stat gpu"]
H --> I
F --> J["按层拆分材质版本"]
J --> I拆解材质时,先问自己几个问题:这个效果玩家真的会注意到吗?它是否只在特写镜头下才有意义?能不能用贴图替代实时计算? Fresnel、边缘高光、动态污渍这些效果叠加在一起,远看往往只剩一片杂色,但 GPU 成本是实打实的。
四、避免过度复杂材质:少即是多
复杂材质的常见陷阱是把所有可能的效果塞进一个主材质。美术为了图方便,会在一个材质里做潮湿、锈迹、积雪、发光、透明、折射、次表面散射,然后通过参数实例控制开关。这种万能材质看起来很美好,实际上所有分支在底层都可能被编译进去,像素着色器指令数并不会因为你把某个参数设成零就减少。
UE5.8 的材质系统支持 Static Switch Parameter,它可以在编译期根据开关值生成不同的着色器变体。如果某个特性只在特定实例上启用,应该用 Static Switch,而不是在运行时用一个 Lerp 或 If 节点把结果屏蔽掉。
// 不推荐:运行时分支屏蔽,两条路径都会被计算
float3 WetColor = lerp(DryColor, WetLayer, bIsWet ? 1 : 0);
// 推荐:Static Switch 在编译期只保留一条路径
#if USE_WET_LAYER
return WetColor;
#else
return DryColor;
#endif上面的代码只是示意。在材质编辑器里,右键搜索 Static Switch Parameter 并命名,比如 UseWetLayer,在实例里勾选或取消。关闭后生成的着色器变体就不会包含潮湿分支的指令,像素开销真正降下来。
阿杰那面墙最后砍掉了动态污渍和边缘光两层,把 Fresnel 从两个合并成一个,并用 Static Switch 区分干燥版和湿润版。同屏只渲染干燥版时,像素指令从 600 多降到 140,Shader Complexity 颜色从红变黄。
五、Cheap Contrast 等优化节点:花小钱办大事
材质编辑器里有一些专门用来替代昂贵运算的优化节点。最常见的是 Cheap Contrast。美术经常需要把一张灰度图压暗或提亮以增强细节,直觉做法是用 Power 节点做曲线调整,或者叠加多个 Multiply/Add。Power 在像素着色器里是非线性运算,比线性运算贵得多。
Cheap Contrast 的数学本质是一个线性对比度拉伸:
其中 是输入灰度, 是对比度系数。这个公式只需要一次减法、一次乘法、一次加法,比 Power 节点少很多指令。在 UE5.8 的材质图里,Cheap Contrast 节点已经被封装好,直接替换掉 Power 做简单对比度增强即可。
类似的还有 Cheap Depth Fade,比起在像素阶段用 Scene Depth 和 Pixel Depth 做复杂插值,它的实现更轻量,适合大面积半透明物体的软边处理。如果只需要简单的线性衰减,就不要用完整的 Depth Fade 节点。
graph LR
A["灰度输入"] --> B{Cheap Contrast}
B -->|"低指令"| C["线性拉伸"]
A --> D["Power 节点"]
D -->|"高指令"| E["非线性曲线"]
C --> F["像素开销低"]
E --> G["像素开销高"]
style B fill:#9f6,stroke:#333
style D fill:#f99,stroke:#333还有一个容易被忽视的细节:One Minus 和 Saturate 这类节点在现代 GPU 上几乎免费,因为它们常被编译器合并进其他指令。但 Sine、Cosine、Power、Log、Exp 这些 transcendental 函数通常需要专用硬件单元,代价明显高于加减乘除。阿杰材质里用 Time 驱动 Sine 做 UV 摆动,本意是让叶子轻微抖动,结果每个像素都在算三角函数。后来改成顶点阶段用 Sine 计算偏移,再插值到像素,开销立刻下降。
六、World Position Offset 开销:免费的假象
World Position Offset(WPO)是顶点着色器阶段的输出,很多教程说它比像素阶段便宜,这没错,但便宜不等于免费。WPO 改变了顶点在世界空间的位置,这意味着光照、阴影、碰撞、剔除都需要重新评估。滥用 WPO 会导致三类问题。
第一,CPU 端的边界框不再准确。GPU 把顶点移出原始包围盒后,CPU 还以为物体在原处,遮挡剔除和距离裁剪会出错。远处的藤蔓墙可能因为包围盒没更新而始终提交渲染,平白浪费 Draw Call。
第二,阴影需要重新渲染。WPO 如果参与 cast shadow,阴影贴图必须按偏移后的顶点绘制。阿杰的风动藤蔓在方向光 CSM 下每帧都要重绘阴影,静态物体原本可以复用的阴影缓存完全失效。
第三,Nanite 不支持 WPO。启用 WPO 的静态网格会退出 Nanite 路径,退回到传统 LOD 渲染,面数和 Draw Call 都会反弹。如果一面墙主要靠 Nanite 来压 Draw Call,一旦开启 WPO,这个收益就消失了。
sequenceDiagram
participant CPU as "CPU 剔除"
participant VS as "Vertex Shader"
participant SM as "Shadow Map"
participant Nanite as "Nanite 路径"
CPU->>CPU: 按原始包围盒做视锥剔除
VS->>VS: WPO 移动顶点
VS->>SM: 阴影贴图按新顶点绘制
Nanite->>Nanite: 含 WPO 时退出 Nanite
SM->>CPU: 包围盒不准导致误剔除或漏剔除解决思路分两步。先把 WPO 限定在真正需要形变的局部区域。如果只有顶部的叶子需要摆动,可以在建模阶段把这部分顶点单独成一个 mesh,底部树干保持静态,避免整面墙都受 WPO 影响。第二步是在材质里用 Vertex Interpolator 把 WPO 计算结果缓存,减少重复运算。对于循环摆动,还可以把动画做成顶点动画纹理或 Skeletal Mesh,让 GPU 只查表而不实时计算。
七、Shader Complexity 视图:找到红色的源头
Shader Complexity 是 UE5.8 里最直观的材质诊断工具。视口用热图显示每个像素执行的着色器指令数,颜色从绿到白代表复杂度递增。排查时先找到红色和白色区域,再对应到具体材质。
实际使用中有一个误区:Shader Complexity 只反映指令数,不反映纹理采样次数和带宽。一个材质可能指令不多,但采样了七八张贴图,在显存带宽紧张的平台上照样卡。所以看完 Shader Complexity 还要配合 stat gpu 和 profilegpu 一起分析。
# 打开 Shader Complexity 视图
F5 键或在视图模式中选择 Shader Complexity
# 查看 GPU 各阶段耗时
stat gpu
# 抓取 GPU 剖面
profilegpuShader Complexity 视图还有一个技巧:把视口拉近到目标物体,观察颜色是否随距离变化。如果近距离白色、远距离绿色,说明问题主要在像素填充率,需要降像素指令或分辨率。如果无论远近都是红色,说明顶点阶段或材质本身已经过重。
阿杰排查时发现,藤蔓墙近看全红,但退到十米外变成绿色。这说明像素着色器指令太多,而不是顶点数太多。于是优化重点放在像素阶段,而不是砍模型面数。
八、纹理采样优化:带宽也是瓶颈
现代 GPU 的算力增长很快,但显存带宽增长相对慢。纹理采样是带宽大户,优化材质时必须考虑这一点。阿杰的藤蔓材质用了基础色、法线、粗糙度、AO、苔藓、污渍、水渍、噪声扰动一共八张贴图,每张贴图在像素阶段至少采样一次,带宽压力可想而知。
第一个优化手段是合并通道。Roughness、Metallic、Ambient Occlusion 这类标量数据可以打包到一张贴图的不同通道里。UE5.8 默认的 ORM 贴图就是这个思路:R 通道放 Ambient Occlusion,G 通道放 Roughness,B 通道放 Metallic。原本需要三次采样的数据变成一次采样。
第二个手段是按距离控制 Mip。远处物体只需要低 Mip,材质里不要强制采样最高精度。Texture Sample 节点默认会按屏幕像素占比自动选 Mip,但如果手动计算 UV 并用 MipLevel 输入覆盖,容易选到不合适的级别。除非有特殊需求,否则让硬件自动决定。
第三个手段是减少重复采样。同一张贴图如果需要不同 UV 采样多次,考虑能不能用一次采样加局部偏移来近似。比如水渍效果可以用世界坐标投影后只做一次贴图采样,而不是分别采样三次不同频率的噪声再混合。
纹理采样次数对像素开销的影响可以近似写成:
是采样总成本, 是第 张贴图每 Mip 的字节数, 是实际选中的 Mip 级别, 是该材质覆盖的像素数。减少 、提高 都能直接降低 。
九、分支与静态分支:别让 If 毁掉并行
GPU 喜欢并行执行相同指令。像素着色器里一个 If 节点,如果导致同一个 warp 内的线程走不同分支,就要把两条路径都算一遍,最后按条件屏蔽结果。这种动态分支会让指令数实际翻倍。
静态分支不同。Static Switch Parameter 在编译期确定取值,只生成一种变体。运行时同一个材质实例要么走 A 路径,要么走 B 路径,不会同一批次内混合。如果某个特性是全局性的,比如平台差异、LOD 差异,用 Static Switch 非常合适。
对于无法在编译期确定的条件,比如根据像素深度做不同处理,要尽量保证分支结果是均匀的。也就是说,相邻像素最好走同一条路径。如果屏幕上半部分走 A、下半部分走 B,GPU 的并行效率尚可接受;如果 A 和 B 在屏幕上随机交错,性能损失最大。
// 动态分支示例:运行时根据深度判断
float Depth = SceneDepth - PixelDepth;
if (Depth < 100.0f)
{
return NearColor;
}
else
{
return FarColor;
}
// 静态分支示例:编译期决定,只保留一条路径
#if USE_NEAR_COLOR
return NearColor;
#else
return FarColor;
#endif材质编辑器里的 If 节点会生成动态分支。如果分支条件来自常量或 Uniform 参数,编译器可能自动优化;如果条件来自贴图或逐像素变量,就很难优化。阿杰把根据顶点颜色做的三层苔藓混合改成了顶点阶段预计算一个混合权重,像素阶段只用一个 Lerp 插值,避免了一个逐像素的 If 判断。
十、材质实例合并:减少变体数量
项目做大了以后,材质实例会爆炸。每个实例即使有微小差异,也可能生成独立的着色器变体。变体太多会拖慢加载、增加内存占用、Cook 时间也会变长。UE5.8 提供了几种合并和精简材质实例的思路。
第一类是按功能把母材质拆成几个主模板。比如一个专门用于不透明建筑墙面,一个用于植被,一个用于角色皮肤,一个用于半透明水体。每个母材质只包含该类别必需的功能,而不是一个万能母材质包揽所有。
第二类是共享参数集。把全局参数比如天气湿度、风向、时间放到 Material Parameter Collection(MPC)里,所有相关材质实例统一读取。这样就不用在每个实例里单独设值,也能避免因为参数不同而生成额外变体。
第三类是合并不常变动的静态参数。如果十个实例都启用同样的 Static Switch 组合,可以考虑把它们合并成一个基础实例,差异部分用动态 Scalar 或 Vector 参数表达。动态参数不会导致变体增加。
flowchart TD
opaque --> wall
foliage --> leaf
skin --> hand
mpc --> wall
mpc --> leaf
mpc --> hand合并材质实例时也要注意过度合并的反面风险。如果为了合并把两个差异很大的材质硬塞进同一个母材质,Static Switch 和贴图引用数量都会增加,单个材质的复杂度反而上升。合并的前提是共享足够多的公共代码和贴图资源。
十一、回到藤蔓墙:那晚我们到底改了什么
排查到后半夜,我们把阿杰的藤蔓墙拆成了四个版本:远距离用静态网格加基础母材质,中距离启用 WPO 风动但关闭动态污渍,近距离特写才启用完整版。Static Switch 区分干燥和湿润,全局天气参数通过 MPC 驱动,不再每个实例单独连 Time。
具体改动清单不长,但效果很明显:
- 把动态污渍和边缘光从主材质里移除,改到后期处理或贴花里按需叠加。
- Fresnel 从两个合并为一个,并用 Cheap Contrast 替代 Power 做对比度增强。
- Time 驱动的 Sine 摆动从像素阶段移到顶点阶段,由 WPO 输出。
- 基础色、法线、Roughness、AO 保留,苔藓用单独通道合并到 ORM 贴图里,水渍和噪声合并成一张通道图。
- 开启动态分支的材质节点改成 Static Switch,按实例分组。
- 藤蔓顶部单独分出一个 mesh 做 WPO,底部和墙面保持静态,继续走 Nanite。
改完后 PS5 上那片墙的 Base Pass 耗时从 7.2 毫秒降到 1.4 毫秒,整关帧率回到 58 到 60 帧。美术重新加了少量贴花后,稳定在 55 帧以上,达到了可发布标准。
十二、日常检查流程
优化顶点着色和像素着色没有固定公式,但有一个可复用的检查流程。拿到性能问题后,先开 stat unit 确认是 CPU 还是 GPU 瓶颈;如果是 GPU,再用 stat gpu 看 Base Pass、Lighting、Shadow 哪一项最高。
Base Pass 高的时候,优先切 Shader Complexity 视图,找到红色区域对应材质。对可疑材质做以下检查:
- 能否把像素计算移到顶点阶段?
- 是否有未生效的冗余节点?
- 是否可以用 Cheap Contrast 或通道合并替代昂贵运算?
- WPO 是否必须?是否影响 Nanite 和阴影?
- 纹理采样次数能否减少?
- 动态分支能否改成 Static Switch?
每一项改动后都要重新抓取 stat gpu 和 Shader Complexity 对比,避免改了一处、另一处又恶化。数字不会骗人,性能优化到最后就是和数据较劲。
十三、小结
顶点着色和像素着色优化,本质上是把计算放到合适的地方、用合适的精度、在合适的时机执行。顶点数远少于像素数,能顶点化的不要像素化;像素阶段要少指令、少采样、少分支;WPO 不是免费午餐,开启前要算清楚阴影、剔除和 Nanite 的账;Static Switch 是控制变体复杂度的利器;Cheap Contrast、通道合并、Mip 控制这些小技巧积少成多。
阿杰那面藤蔓墙给我们的教训是:一个材质可以看起来很酷,但如果它让 GPU 在每帧都加班,那就不是好材质。优化的目标从来不是消灭效果,而是让效果和成本匹配。玩家在 4K 屏幕上未必看得出那些像素级别的细节,但他们一定能感觉到 28 帧和 60 帧的区别。