想必不少做过UE4渲染优化的朋友,都有过这种体验:开放世界项目里,玩家跑着跑着拐进一片新区域,画面瞬间像被什么东西拽住一样顿一下,帧时间曲线里冒出一根尖刺,然后马上又恢复正常。大多数人第一反应是“Shader编译卡了”,但我实际排查过的项目里,真正拖垮帧率的元凶,往往不是Shader编译本身,而是PSO没有在正确的时间点创建好。这个现象背后,就是PSO与Shader编译之间那层经常被忽略的关系。
这篇是“UE4 PSO介绍”系列的第六篇,前面几篇更多在讲PSO本身的形态、状态组合、跨平台差异。这一篇我想换个视角,把Shader从源码到最终GPU可执行状态之间的完整链条拆开,看看哪些环节是Cook阶段就能压住的,哪些环节只能靠运行时创建,以及为什么做PSO预热时,很多人把力气用错了地方。
如果你已经能看懂DrawCall、渲染线程、RHI这些词,并且正在为运行时卡顿发愁,这篇应该能帮你把排查方向理清楚。刚接触UE4渲染的读者也可以读,我会尽量把逻辑链条讲得完整一些,毕竟这个知识点一旦通了,很多渲染问题的排查思路都会跟着通。
1. 为什么卡顿的锅,常被Shader编译背走
1.1 一个容易混淆的“双编译”现场
先说一个我见过很多次的场景:项目里某个关卡加载完成后,玩家一动,画面就卡。开发团队查了半天,发现日志里有一堆Shader编译相关的输出,于是断定是“Shader编译太慢”。但如果你把时间戳仔细对一下,会发现真正的耗时点根本不是Shader源码编译,而是后续的PSO创建。
UE4里,Shader编译和PSO创建是两个不同阶段的事情,只是它们经常被连着触发,导致很多人混为一谈。
Shader编译,指的是把材质编辑器里写出来的HLSL,经过UE自己的ShaderCompiler、再经过平台相关的编译器,最终变成目标平台能执行的字节码或机器码。这个过程很重,动辄几十毫秒甚至上百毫秒。在PC上,这部分大多发生在Cook阶段,或者加载ShaderMap的时候;在移动平台上,更多变体会在打包时提前烘焙好。也就是说,游戏真正跑起来之前,Shader编译已经完成了相当大一部分。
PSO创建,则是另一回事。它不是在编译Shader源码,而是在向图形API申请一个“状态全部绑好的管线对象”。DX12、Vulkan、Metal这些API把PSO当成一等公民,创建时驱动可能会做链接、优化、生成硬件状态表等操作。真正在运行时造成卡顿的,经常是这个“创建动作”本身。
1.2 把两段时间轴摆在一起看
我们可以把两个过程放在时间轴上对比:
- Cook阶段:Shader源码编译为主,同时可能生成PSO缓存文件。
- 加载阶段:加载ShaderMap,把编译好的字节码放进内存。
- 首次渲染某材质时:取字节码,组合渲染状态,创建PSO。
- 后续再次使用同一材质:直接命中已创建的PSO,几乎不耗时。
所以,运行时出现的那次峰值,往往出现在“首次渲染某个新状态”的节点上,而不是“首次解析Shader源码”的节点上。绝大多数情况下,Shader字节码早就在内存里躺着了,真正在现场做决策、向驱动申请管线对象的动作,才是卡顿的来源。
这个区别搞清楚之后,再看PSO缓存系统,就会明白它到底在预什么:它不负责编译Shader,它负责的是把“创建PSO”这件事提前做完。
2. Shader和PSO是一份合同的正面与背面
2.1 着色器只是“算法”,PSO才是“可执行的章程”
把Shader单独拎出来看,它只是流水线上某个阶段的一段程序。顶点着色器知道怎么把顶点坐标从模型空间变换到裁剪空间,像素着色器知道怎么根据输入的UV、法线、光照参数计算最终颜色。但一段程序本身,并不能决定它以什么状态运行。
举个例子,同一个像素着色器,既可以用在开启深度写入的实体物体上,也可以用在关闭深度写入的半透明物体上,还可以用在开启混合的粒子特效上。Shader代码里没有这些信息,它只负责在输入数据到来时执行计算逻辑。那“以什么方式跑这段代码”由谁决定?就是PSO。
PSO里除了绑定着色器之外,还包含一堆固定功能状态:深度测试怎么开、模板测试怎么设、混合模式怎么配、光栅化要不要开背面剔除、顶点输入布局是什么。你可以把Shader理解成“做什么”,PSO理解成“以什么章程做”。渲染API拿到PSO之后,才知道该怎么配置硬件寄存器,该怎么把Shader和各个固定功能单元组合起来。
2.2 固定功能状态为何会成为变体组合爆炸的推手
既然说到了固定功能状态,就得多说一句。很多人把PSO缓存做不好,归罪于Shader变体太多。但实际上,Shader变体只是其中一半,另一半压力来自固定功能状态的组合。
同一个材质,在UE4里可能因为渲染路径不同,产生Forward、Deferred、Mobile等多个变体;同一个Shader变体,又可能因为深度描画、模板写入、混合方式、顶点工厂等状态不同,被组合出多个不同的PSO。最简单的办法是“所有状态都列出来做笛卡尔积”,但那样PSO数量会失控。
UE4内部会用一套初始化数据来生成PSO,可以理解成一个Key。Key里的每一项变化,都可能导致一个全新的PSO对象。这个Key不只包含Shader字节码的哈希,还包含渲染状态、顶点声明、渲染目标格式等等。这也是为什么两个材质如果Shader相同但渲染目标格式不同,运行时依然可能创建两个PSO对象。
2.3 不同图形API下,“创建PSO”的代价完全不同
这个差异值得单独拿出来讲,因为它解释了很多跨平台项目的诡异表现。
在DX11这类老API里,没有显式PSO概念。驱动会在内部自己打一套组合拳,把Shader和状态在第一次使用某个组合时编译链接,并且通常会在驱动层面做缓存。开发者感知不到PSO创建这个动作,但第一次切换状态时依然可能卡,只是卡在驱动内部,很难排查。
在DX12、Vulkan、Metal这类新API里,PSO变成了显式对象。你必须在提交DrawCall之前创建好对应状态的PSO。好处是控制力强了很多,坏处是创建动作变成了一笔“一次性付清”的开销。如果某个PSO在运行时才被首次创建,渲染线程可能会被卡住,有时一卡就是几十毫秒,甚至上百毫秒。
移动平台更敏感。很多移动GPU的驱动,在创建PSO时要做额外后端编译和寄存器分配,耗时比PC更不可控。这也是为什么PSO缓存方案在移动平台上价值最高。PC上可能只是偶尔掉一帧,移动平台直接肉眼可见地卡死。
3. 运行时创建PSO的那条“卡顿链条”到底怎么走的
3.1 从DrawCall到PSO不命中需要经过几次查找
为了讲清楚运行时到底发生了什么,我们顺着一次普通DrawCall的流程走一遍:
首先,场景里有一个可见的Mesh,引擎把它加入到渲染队列。渲染线程拿到这个Mesh后,会根据它的材质、渲染特征、顶点工厂、当前渲染Pass等信息,去查找对应的FMaterialShaderMap,确定需要绑定哪些Shader。
这步查找本身很快,因为ShaderMap在加载时就已经建立好了。关键问题在下一步:引擎拿到Shader字节码和一堆状态信息之后,会组装一个FGraphicsPipelineStateInitializer,然后去PSO缓存里查有没有已创建好的对应PSO。
这个查找,在UE4里走的是哈希索引,正常情况下非常快。但如果没有命中,引擎就只能现场调用RHI创建新的PSO。渲染线程在这里会被阻塞住,因为后续所有依赖这个PSO的DrawCall都没法继续。这也是渲染线程时间线上那个尖峰的直接来源。
3.2 渲染线程到底在等谁
顺着上面往下想,渲染线程阻塞时,它到底在等什么?答案并不统一。
如果目标是DX12或者Vulkan,PSO创建的大部分工作发生在驱动层。驱动拿到你提交的状态和着色器字节码后,可能需要进行内部编译、链接、优化,甚至针对特定GPU微架构做指令调度。这一步,引擎完全控制不了时间,只能等驱动返回结果。
有一种情况是驱动内部有缓存,比如你之前已经在另一个进程或者另外一套PSO里创建过一模一样的组合,驱动能直接命中,那创建很快。但没有缓存时,现场编译的代价就完全暴露出来。
还有个更隐蔽的点:现在的GPU驱动很多时候是异步编译的。你调用创建PSO的接口,驱动先返回给你一个对象,但真正的后端编译延迟到第一次DrawCall提交时才做。这种情况下,渲染线程可能不卡,但GPU端会卡。帧时间曲线里一样会出现尖峰,只是尖峰的位置不在渲染线程,而在GPU时间线上。排查时如果只盯渲染线程,很容易漏掉这个问题。
3.3 一个没预热的尖峰是怎么体现在帧时间里的
我用项目里实际看到的帧时间曲线来描述一下。正常画面稳定时,渲染线程大概在8到10毫秒一帧,GPU在6到8毫秒一帧。某个瞬间,玩家进入一片新区,画面里出现了以前没见过的材质组合,帧时间曲线直接飙到80毫秒甚至更高。
这80毫秒不是一次性把Shader源码从头编译出来的时间,而是创建一个新PSO、并且把它提交到GPU后等待后端编译的时间。这个PSO对应的Shader字节码可能早就在内存里了,甚至可能已经在Cook阶段生成好了。但引擎在运行时没有提前创建对应的PSO对象,直到第一次绘制命中时才去申请,所以尖峰就出现了。
要验证这一点,可以用帧调试工具把渲染线程、RHI线程、GPU三条时间线叠在一起看。如果渲染线程阻塞的起始点正好在某个PSO创建调用附近,而且GPU时间线在之后明显出现长尾,那么这个尖峰基本可以锁定在PSO运行时创建上。
4. 预缓存缓存了什么:PipelineFileCache机制逐层拆
4.1 Cook阶段:Shader库与PFC文件的分工
UE4的PSO缓存系统,核心产物是Pipeline File Cache,也就是常说的PFC文件。理解它之前,先分清它和Shader库的区别。
Cook阶段,Shader编译系统会把所有材质变体编译成目标平台字节码,存成Shader库。这部分解决的是“有没有字节码”的问题。PFC文件不一样,它记录的是“哪些PSO组合真实存在并被使用过”。可以说,Shader库是原材料库,PFC是已经验证过的配方清单。
当你开启PSO缓存相关设置后,Cook时引擎会尝试收集关卡中用到的材质和渲染状态,生成一份PSO的预设列表。UE4本身有一套静态收集逻辑,能抓出关卡里引用到的材质对应的大量PSO组合。但注意,它并不能覆盖全部运行时动态变化。比如某种运行时生成的材质、某些动态拼接的顶点工厂、或者是根据人物皮肤颜色中途换贴图的材质,都可能漏掉。
所以PFC文件会出现“命中”和“漏配”两种情况。命中很好理解:Cook阶段已经把某个PSO列进了清单,启动时按清单预创建,运行时直接命中。漏配则是Cook时没抓到,运行时才首次创建,自然就会产生开销。
4.2 启动阶段:按批次创建PSO的时间预算
PFC文件在启动时加载,但UE4不会一口气把所有PSO全创建出来,那样反而会把加载时间拖爆。它会按批次创建,每帧或者每段时间窗口只创建一定数量的PSO,把总耗时摊到启动加载阶段。这里有几个参数很关键:
r.ShaderPipelineCache.Enabled:总开关,默认通常是开启的。r.ShaderPipelineCache.BatchSize:每批创建的PSO数量。r.ShaderPipelineCache.PreCompileBatchTime:每批预创建允许占用的最大帧时间预算。r.ShaderPipelineCache.SaveAfterPSOsCreated:运行时新创建的PSO在创建完成后保存回缓存文件。
这几个参数决定了“预创建”和“正常渲染”之间如何竞争GPU和CPU资源。BatchSize设置太大,可能造成加载时明显卡顿;设置太小,预创建速度太慢,游戏开始后玩家可能已经跑到新区,PSO还没预热完。
比较稳妥的做法是,在加载界面阶段提高批处理节奏,用满帧时间预算,让PSO尽早创建完;在游戏运行阶段降低批处理节奏,避免和正常渲染抢夺资源。
4.3 命中与失配:为什么“失配”不等于“失败”
还有一个容易误解的点:PFC文件里记录的Key,是运行时PSO查找用的完整状态Key。只要Key完全一致,就算缓存文件是很多个版本之前生成的,依然能命中。反过来,只要任何一项状态变化,比如混合模式从透明改成了遮罩,或者深度写入开关变了,Key就不一样了,就会失配。
失配不一定是坏事。PSO缓存的核心目标是“让可预见的组合都被提前创建”,而不是“所有组合都被提前创建”。很多失配属于第一次看到的新组合,只要它不产生明显卡顿,动态创建一次也完全可以接受。
但有一种失配需要警惕:同一个视觉效果的材质,因为状态Key序列化顺序或者版本更新发生变化,导致之前缓存的所有PSO全部失配。这种情况会造成缓存大面积失效,启动时预创建了一堆旧PSO,真正渲染时又现场创建一堆新PSO,等于缓存白做了。碰到这种情况,排查思路通常是检查Shader编译环境变量、PSO初始化数据生成逻辑是否在某次改动里被调整过。
5. PSOPrecache和PipelineFileCache并不是同一件事
5.1 旧体系:跑过才知道要什么
PipelineFileCache这套体系有个天然短板:它必须先“跑过”一轮,才知道哪些PSO组合真实存在。Cook阶段的静态收集能抓住关卡里静态引用的材质,但对运行时动态出现的变体没办法。所以很多项目上线后,第一轮游玩时依然会有卡顿,等跑完一轮、缓存文件攒齐了,下次启动才会流畅。
这个短板在很多大世界项目里会被放大。因为玩家探索路径不完全线性,一个玩家可能花几个小时才跑完某片区域,另一个玩家可能上来就直奔某个角落。两人生成的PFC文件可能差异巨大,谁都没法提前覆盖对方需要的全部PSO。
5.2 新体系:在DrawCall之前提前把PSO摆上桌
UE4后续加入的PSO预创建机制,也就是r.PSOPrecache.Enabled控制的这套逻辑,出发点就是解决“没跑过就不认识”的问题。它的思路不再依赖历史缓存文件,而是在场景收集和资源加载阶段,就根据可见Mesh、材质、绘制状态,提前把PSO创建好,等真正渲染时直接命中。
换句话说,旧体系是“跑一次、记住、下次用”,新体系是“还没画,就先把可能用的东西准备好”。两者侧重点不一样,效果也不一样。
新体系的优势在于首次游玩就能获得不错的表现,因为它不需要历史数据。代价是它需要精确的场景与状态收集逻辑,收集得不够准,预创建可能漏;收集得过头,可能创建一堆永远不会用到的PSO,白白浪费内存和加载时间。实际项目中,我见过不少团队把这两个开关同时打开,因为它们覆盖的短板正好互补。
5.3 实战中可以同时开启吗
可以,但要注意节奏。PipelineFileCache负责启动阶段批量预创建,PSOPrecache负责运行前的实时预创建。两者如果都开着,可能在加载阶段造成重复的PSO创建。
从实际表现看,重复创建本身不致命,因为第二次创建会命中缓存,耗时很小。真正需要注意的是内存峰值。一张比较复杂的关卡地图,PSO对象数量可能达到几千甚至上万,每个PSO在驱动侧都有一份资源占用。如果两套机制同时把所有PSO都提前创建,内存压力会明显上升。
我的建议是,PC端内存宽裕,两者都开问题不大;移动端要谨慎评估,可以先用PipelineFileCache保证历史命中,再根据线上数据决定要不要全面开启PSOPrecache。
6. 一个真实排查案例:拐弯进峡谷后帧率崩掉
6.1 首先排除的不是Shader编译,而是PSO创建
说一个我经手过的项目案例。那是一款开放世界动作游戏,某次测试中,玩家从平原地区骑马跑进一段峡谷,画面瞬间卡住,帧率从60掉到十几,持续了两三秒又恢复。测试组报上来的描述是“新场景加载卡顿”,初步怀疑是材质流送问题。
拿到数据后,我先把帧时间曲线打开,发现渲染线程峰值非常明显,将近90毫秒。接着我看了加载线程和IO线程,都没有异常。这基本可以排除资源流送的问题,因为如果是流送瓶颈,等待时间会更多体现在IO或者游戏线程上。
之后我打开r.ShaderPipelineCache.LogPSO,让引擎把运行时PSO创建和命中的日志全部记录下来。跑完这段路径后,我把日志翻出来,对比PFC缓存文件里已有的PSO Key,很快就发现了一个匹配不上的组合。
6.2 日志与缓存比对定位到“漏网”变体
那个失配的PSO来自一套峡谷里的岩石材质,材质本身并不复杂,但它的渲染路径和普通岩石不太一样:它用了Masked混合,并且开启了两套UV动画,运行时还根据距离切换了不同的着色模型。
问题出在Cook阶段的静态收集没能把“Masked + 双UV动画 + 距离切换”这个组合完整记录下来。PFC文件里可能只有其中一部分组合,运行时真正使用的那个组合不在缓存里,所以等到玩家走进峡谷、首次绘制这套材质时,现场创建PSO。
这里有一个很典型的教训:带有复杂材质函数或者Blueprint驱动参数的材质,很容易逃过静态收集。不是引擎不知道这个材质存在,而是“材质存在”和“这个PSO组合存在”之间还隔着一层运行时状态推导。
6.3 修复和验证:从卡顿85ms到基本无感知
修复方案没有走弯路:我在项目里单独加了一段启动期预热逻辑,把峡谷区域内特定材质会用到的那组PSO组合,在进入关卡时提前通过异步任务创建。当然,我这里说的逻辑并不是随便把材质丢进一个列表然后全量预创建,而是根据实际收集到的PSO Key做定向预创建。
改完之后,跑同一段测试路径,帧时间曲线里那个85毫秒的尖峰消失了,取而代之的是加载阶段多出的几十毫秒预热开销。由于预热发生在地图加载转场期间,玩家无感知。后来又遇到类似问题,我一律都会先看LogPSO输出,再看PFC文件命中情况,最后才去怀疑Shader本身。这已经成了我的固定排查顺序。
7. 关于预热策略我踩过几次坑之后留下的习惯
7.1 为不同平台设定不同的批处理节奏
PSO预热没有一个万能参数组合。PC平台因为CPU和GPU资源相对宽裕,r.ShaderPipelineCache.BatchSize可以调得偏大,r.ShaderPipelineCache.PreCompileBatchTime也可以放宽,尽量在加载阶段就把PSO创建完。
移动平台则要谨慎得多。移动端的CPU性能和内存带宽有限,如果沿用PC的批处理节奏,加载时可能直接卡死。我的习惯是,移动端每批PSO数量控制在PC的一半以下,并且把每帧预创建的时间预算压到最低。宁愿启动后还有一小部分PSO在后台慢慢补,也不要让玩家在加载界面里感觉进度条卡住。
还有一点需要注意:预创建PSO时会消耗内存,移动端如果PSO数量太大,容易出现低内存警告。遇到这种情况,可以先跑一遍内置PSO统计工具,看看哪些PSO组合使用率极低,再考虑是否需要对低频组合关闭预创建。
7.2 热更新、Pak新增内容后的PSO处理
项目如果支持热更新,PSO缓存文件的处理就得额外小心。玩家已经生成了一份PFC文件,这时候更新包加了新材质或者新渲染状态,旧缓存文件里没有对应记录,启动时缓存命中率会下降。如果不做任何处理,新版内容首次进入时很可能会重现卡顿。
我的处理习惯是:热更新版本发布时,会同步生成一份附加的PSO缓存分片,并在启动逻辑里把新旧两份缓存合并。合并时注意保留旧缓存,因为玩家之前跑过的内容不应该被丢掉。同时,对新版本新增的内容单独做一次预热,避免把新PSO的创建压力放到运行时。
7.3 最后再分享一个小技巧
如果你不确定某个PSO到底是不是运行时现场创建的,有个最直接的验证办法:把r.ShaderPipelineCache.LogPSO打开,跑完目标场景后,看日志里统计的“首次创建PSO”数量。如果数量不小,说明预热覆盖还有缺口。接下来要做的事情不是盲目扩大预创建范围,而是根据日志里的PSO Key反推到底是哪类材质、哪种渲染状态组合被漏掉了。
我踩过最深的坑,就是一开始以为“多预创建一定没错”,结果一大堆不常用的PSO占着内存,该卡的地方照样卡。后来才明白,PSO预热的核心不是“把所有东西都准备好”,而是“把即将用到的东西刚好准备好”。这个“刚好”需要靠日志、缓存比对和项目实际游玩数据来不断校准。
如果你也在和UE4的运行时卡顿较劲,希望这篇能把你的排查方向带向正确的地方。PSO和Shader编译的关系,说到底就是把“能预先做的事”和“必须在现场做的事”分开,然后用缓存和预创建机制填平中间的时间差。