UE5.8 Shader Permutation 与 PSO 缓存

📑 目录

UE5.8 Shader Permutation 与 PSO 缓存

一、上线当晚的卡顿电话

游戏上线第三天,凌晨两点,老周的电话响了。运营同学语速很快:玩家进地下溶洞关卡时帧率直接掉到个位数,屏幕卡住两秒,然后恢复正常。复现路径很稳定:第一次进洞必卡,第二次再进就没事。

老周打开 Sentry 和内部埋点,发现卡顿点集中在 FShaderCompilingManagerFShaderPipelineCache 两个模块。一个负责运行时 Shader 编译,一个负责 PSO 缓存查找。两者同时飙高,说明玩家设备在进洞那一刻才第一次见到这套材质和渲染状态组合,本地既没有编译好的 Shader,也没有命中 PSO 缓存。

这个项目用的是 UE5.8。我们在 PC 和主机上开发了大半年,Studio 的机器配置高,DDC 里早就塞满了缓存,开发阶段根本感受不到问题。玩家端一清缓存,所有 Permutation 和 PSO 缺失立刻暴露出来。

这篇笔记把当时排查和优化的过程整理下来,尽量说人话,少套概念。那天晚上我学到的最大一课是:高配置开发机会骗人,玩家机器才是真正考场之一。

二、Shader 编译流程:从 HLSL 到机器码

UE5.8 的 Shader 编译链路比很多人想象的长。材质编辑器里的节点只是第一层,真正干活的在后台。

下面这张图是整体架构,能看清各个环节谁在做什么事:

flowchart TD
    subgraph ue_engine ["UE5.8 引擎"]
        material_editor["Material Editor"]
        hlsl_gen["HLSL Generator"]
        shader_compile_manager["Shader Compile Manager"]
        ddc["Derived Data Cache"]
        rhi["RHI 抽象层"]
    end
    subgraph platform ["平台层"]
        dxc["DXC"]
        fxc["FXC"]
        spirv["SPIRV-Tools"]
        metal["Metal Compiler"]
    end
    material_editor --> hlsl_gen
    hlsl_gen --> shader_compile_manager
    shader_compile_manager --> dxc
    shader_compile_manager --> fxc
    shader_compile_manager --> spirv
    shader_compile_manager --> metal
    dxc --> ddc
    fxc --> ddc
    spirv --> ddc
    metal --> ddc
    ddc --> rhi

再来看一下单条 Shader 的编译流水线:

flowchart TD
    A["材质图表 Material Graph"] --> B{静态开关与 Feature 选择}
    B --> C["生成 HLSL 模板"]
    C --> D[ShaderCompileWorker]
    D --> E["DXC / FXC / SPIRV 编译器"]
    E --> F["平台相关字节码"]
    F --> G[DerivedDataCache]
    G --> H["运行时加载到 RHI"]

材质图表里的每一个连线、每一个参数,最终会被翻译成一段带宏的 HLSL 模板。模板里塞满了 #ifSWITCH_* 宏,用来控制不同功能开不开。编译器根据当前材质实例、平台、质量等级把这些宏展开,生成具体的 Shader 源码,再交给平台编译器生成字节码。

这个过程中最容易被忽视的是 ShaderCompileWorker。它不是 UE 主线程的一部分,而是一个独立进程,专门负责把 HLSL 编译成目标平台的 Shader 字节码。主线程把编译任务塞进一个队列,Worker 消费队列,编完再回调。开发机上 Worker 数量默认跟 CPU 核心数挂钩,所以编译快;玩家设备上如果核心少、磁盘慢,队列一积压,卡顿就来了。

老周在日志里看到这样一条:

LogShaderCompilers: Display: Shaders left to compile 847

八百多个 Shader 在玩家进洞那一刻排队编译,能不卡吗。

三、Shader Permutation 与材质变体

Permutation 是 UE Shader 体系里最烦人的概念之一。说白了,就是同一个材质因为开关组合不同而生成的很多个 Shader 版本。

举个例子。你做一个岩石材质,支持以下功能:

  • 法线贴图开关
  • 视差贴图开关
  • 潮湿效果开关
  • 细节法线开关
  • 三层材质混合开关

每个开关要么开要么关。如果这五个开关全部独立,理论上的组合数是 25=322^5 = 32。这还只是一个材质。项目里几百个材质,叠加不同平台、不同质量等级、不同渲染路径,Permutation 数量会指数级膨胀。

用公式描述,如果材质有 kk 组静态开关,第 ii 组有 sis_i 个开关,那么变体数量为:

Nvariant=i=1k2si=2i=1ksiN_{\text{variant}} = \prod_{i=1}^{k} 2^{s_i} = 2^{\sum_{i=1}^{k} s_i}

实际项目里不会所有组合都用上,但这个公式说明了一个道理:每加一个静态开关,潜在变体数就翻一倍。如果项目管线审核不严,美术同学随手勾选几个开关,Permutation 就会悄悄失控。

UE5.8 提供了几个控制 Permutation 的方向:

  • StaticSwitchParameter:材质实例里可以覆盖,但变体在打包时就确定了。
  • Shader Permutation Reduction:在项目设置里关闭不需要的 Renderer Features。
  • Material Quality Level:低画质直接关闭高级特性,减少变体。
  • Shared: Wrapped 等共享代码路径:让多个材质共用同一个底层 Shader。

我们当时做了一次材质审计,把没用到的高级功能关掉,比如关掉了一半材质的次表面散射和顶点动画。变体数量从一千三百万降到四百万,包体里的 Shader 库小了 28%。

四、PSO 是什么,为什么它比 Shader 还严格

Shader 只是 PSO 的一部分。PSO 全称 Pipeline State Object,是 DX12 和 Vulkan 这类现代 API 提出的概念。它把渲染管线的一堆状态打包成一个对象:顶点布局、Shader、光栅化状态、混合状态、深度模板状态、RT 格式、采样状态等等。

为什么需要 PSO?因为 GPU 喜欢提前知道全部状态。传统 API 可以运行时随便改一个状态,现代 API 要求你把常用组合预先创建好,运行时直接绑定。好处是驱动可以做更多优化,坏处是创建 PSO 很慢,而且如果运行时碰到一个没创建过的 PSO,就会触发编译或查找,导致卡顿。

PSO 的依赖关系可以用下面这张图表示:

graph LR
    VS["Vertex Shader"] --> PSO["Pipeline State Object"]
    PS["Pixel Shader"] --> PSO
    GS["Geometry Shader"] --> PSO
    IL["Vertex Input Layout"] --> PSO
    RS["Rasterizer State"] --> PSO
    BS["Blend State"] --> PSO
    DS["Depth Stencil State"] --> PSO
    RT["Render Target Format"] --> PSO

在 UE5.8 里,PSO 缓存文件主要有两类:

  • .upipelinecache:运行时收集的 PSO 使用记录,每台机器独立生成。
  • .stablepc.csv:稳定的 PSO 列表,打包时预置到游戏里。

运行时如果 PSO 命中缓存,直接绑定;没命中,UE 会尝试异步创建。如果异步队列满了,主线程只能硬等,这就是卡顿的来源。

从数字上看,一次 PSO 创建的耗时通常在 1ms1\text{ms}20ms20\text{ms} 之间,复杂状态甚至更高。如果一帧里连续出现 nn 次未命中,CPU 等待时间近似为:

Tcpu=i=1ntiT_{cpu} = \sum_{i=1}^{n} t_i

其中 tit_i 是第 ii 个 PSO 的创建耗时。这个等待会直接把帧时间顶穿 33ms,玩家看到的就是顿卡。

五、PSO 预采集:把卡顿从玩家端挪到打包阶段

解决 PSO 缺失最直接的办法是预采集。思路很简单:在开发或测试阶段把游戏跑一遍,把所有可能用到的 PSO 都记录下来,打包时一起发布。玩家运行时只查缓存,不再临时创建。

sequenceDiagram
    participant Dev as "开发/测试机"
    participant Game as "游戏进程"
    participant Cache as .upipelinecache
    participant Tool as ShaderPipelineCacheTools
    participant Pak as "Pak/包体"
    participant Player as "玩家设备"

    Dev->>Game: 运行全关卡/自动化测试
    Game->>Cache: 记录使用过的 PSO
    Dev->>Tool: 合并 .upipelinecache
    Tool->>Tool: 去重并生成 .stablepc.csv
    Tool->>Pak: 把稳定缓存打进包
    Player->>Pak: 启动时加载 .stablepc.csv
    Player->>Player: 运行时命中 PSO 缓存

UE5.8 的 PSO 预采集一般分四步:

  1. 打包一个带 PSO 收集功能的测试包,命令行加上 -logPSO
  2. 用自动化测试或人工跑测覆盖所有关卡、所有画质、所有载具和技能。
  3. 把收集到的 .upipelinecacheShaderPipelineCacheTools 合并、排序、去重,生成 .stablepc.csv
  4. .stablepc.csv 放到 Content/Collections/ 或项目配置指定的目录,正式打包时它会自动合并进 Shader Pipeline Cache。

ShaderPipelineCacheTools 在 Engine/Binaries/Win64 下,命令大概长这样:

ShaderPipelineCacheTools.exe merge *.upipelinecache Merged.stablepc.csv

我们当时犯的错是只跑了主关卡,没跑溶洞。结果正式包里缺少那套潮湿岩石 + 点光源 + 体积雾的 PSO,玩家第一次进洞就触发创建。

六、减少运行时 Shader 编译的实际手段

PSO 预采集解决的是状态组合缺失问题,但它依赖 Shader 已经存在。如果 Shader 本身没在 DDC 或包里,运行时还是要编译。下面几个手段是我们验证过有效的。

6.1 提前编译常用 Shader

项目设置里打开 r.ShaderPrecompile 相关选项,启动时把高频 Shader 预编译好。代价是启动时间增加,但换来的是进关卡不卡。这笔账对开放世界项目很划算。

6.2 合理设置 ShaderCompileWorker 优先级

默认 Worker 优先级不高,如果玩家后台还有其他程序,Shader 编译会被抢。可以在 DefaultEngine.ini 里调整:

[/Script/Engine.RendererSettings]
r.ShaderCompiler.JobPriority=High

6.3 限制材质特性爆炸

前面说过,每加一个静态开关,Permutation 就可能翻倍。项目里最好有一份材质规范,明确哪些功能允许用,哪些不允许。我们后来规定:基础环境材质最多三个静态开关,角色材质最多五个,超过需要 TA 审批。

6.4 使用 Local Derived Data Cache

开发机上把 DDC 共享给团队,避免每个人重复编译。发布包里的 Shader 库要完整,不要把希望寄托在玩家端的首次运行编译上。

6.5 异步加载与提前触发

UE5.8 的 UStreamableManagerAsyncLoading 可以在进关卡前把资源加载完。配合 r.ShaderPredraw 提前把 Shader 跑一遍,让玩家进洞时 everything is ready。

6.6 加载界面是个合法的编译时机

有个技巧很多人都忽略了:加载界面本来就是玩家愿意等待的时间。与其把 Shader 编译藏到游戏里偷偷卡一下,不如在加载界面光明正大地跑。我们在加载溶洞关卡时,会主动触发一次 Shader 预热:

r.ShaderPipelineCache.WarmAllPSOs 1

这条命令会让引擎在后台把当前关卡可能用到的 PSO 都创建一遍。玩家在加载条走完之前,所有 Shader 和 PSO 已经就绪。代价是加载时间增加两秒左右,但换来的是进关卡后完全不卡。这笔账很容易算清楚。

6.7 团队共享 DDC

开发阶段还有一个大头是 DDC 缺失。新人入职第一次打开项目,动辄编译几千个 Shader,半天过去了。我们在内网搭了一个 Shared DDC 服务器,每个人在 DefaultEngine.ini 里指向同一个路径:

[DerivedDataBackendGraph]
Shared=(Type=FileSystem, ReadOnly=false, Clean=false, Flush=false, PurgeTransient=true, DeleteUnusedAtStartup=true, UnusedFileAge=34, FoldersToClean=-1, Path=\\fileserver\\DDC, EditorOverrideSetting=SharedDerivedDataCache)

这样只要有人编过一次,其他人直接拿缓存。团队整体编译时间从几个小时降到十几分钟,大家下班也更早了。

七、Shader 压缩与包体

Shader 库占包体空间很大,尤其是 Permutation 爆炸之后。UE5.8 默认用 LZ4 压缩 Shader 库,可以改成 Oodle 进一步压小。项目设置在 DefaultEngine.ini 里:

[/Script/Engine.RendererSettings]
r.ShaderCompressionFormat=Oodle
r.ShaderLibrary.Enable=1
r.ShaderLibrary.SeparateCompositeShaderLibrary=1

我们当时的 Shader 库从 420MB 压到 210MB,加载时解压 CPU 开销增加了不到 1ms,对内存占用也有明显改善。对包体敏感的项目来说,这笔交易很值。

另外,UE5.8 支持 Shader Library Chunking,把 Shader 按关卡或材质分组,按需加载。这样玩家进溶洞时只加载溶洞相关的 Shader,而不是一次性把整个 Shader 库读进内存。

八、平台差异:DX12、Vulkan、Metal

不同图形 API 对 PSO 和 Shader 缓存的处理不一样,调优策略也得跟着变。

8.1 DX12

DX12 的 PSO 创建是同步且重量级的。UE5.8 在 DX12 上的 PSO 缓存文件叫 .stablepc.csv,打包时预置。DX12 的 PSO 包含完整状态,只要有一个状态不同,就是新的 PSO。这也意味着 DX12 对 PSO 预采集的覆盖率要求最高,漏一个状态就是一次卡顿。

8.2 Vulkan

Vulkan 的 Pipeline Cache 可以序列化到磁盘,驱动层的缓存文件通常叫 vk_pipeline_cache.bin。UE5.8 会把 Vulkan 的 PSO 缓存合并进 Shader Pipeline Cache。Vulkan 对 Shader 变体更友好,因为 SPIR-V 是中间码,驱动还可以做后续优化。但 Vulkan 的 PSO 哈希算法跟 DX12 不一样,不能复用同一份 .stablepc.csv

8.3 Metal

Metal 用 Binary Archive 缓存 PSO。iOS 项目要特别注意包体,因为 Metal 的预编译二进制会比较大。UE5.8 在 iOS 上默认开启 r.Mobile.AllowPipelineStateSorting,建议配合 PSO 预采集一起用。

跨平台打包时,我们做了一个 Jenkins 任务,每个平台单独跑一遍全关卡 PSO 采集,再分别生成 .stablepc.csv 或对应平台的缓存文件。不要用一个平台的 PSO 列表直接给另一个平台用,那样基本等于没做。

具体操作上,PC 端我们跑完采集后直接拿 Merged.stablepc.csv;PS5 和 Xbox 有各自的专用缓存文件路径,需要在打包参数里指定;iOS 则要关心 Metal Binary Archive 的版本号,系统升级后缓存可能失效,得在版本发布前重新采集一遍。

九、调试与优化

出问题的时候,日志和命令行是最直接的工具。

9.1 常用命令

# 查看 Shader 编译统计
stat shaders

# 查看 PSO 缓存命中率
r.ShaderPipelineCache.LogPSO=1

# 强制开启 PSO 预采集
r.ShaderPipelineCache.Enabled=1
r.ShaderPipelineCache.SaveAfterPSOsLogged=1000

# 关闭异步 Shader 编译,方便定位问题
r.Shaders.Optimize=0

9.2 RenderDoc 抓帧

RenderDoc 可以看到每一 Draw Call 用的 PSO 和 Shader。如果某一帧突然多了一个 PSO 创建事件,重点检查那个 Draw Call 的材质和渲染状态。

9.3 分析 .stablepc.csv

.stablepc.csv 本质上是文本文件,每一行代表一个 PSO。可以用脚本统计哪个材质产生的 PSO 最多,哪个关卡引入的新 PSO 最多。我们当时写了一个 Python 脚本,按材质名聚合,发现 60% 的 PSO 来自一个过度使用静态开关的角色头发材质。

9.4 监控玩家端数据

上线后我们在客户端埋点,记录首次遇到未缓存 PSO 的次数、Shader 编译队列峰值、进关卡卡顿时长。数据回来后再有针对性地补 PSO 采集用例。

十、把卡顿量化:几个有用的指标

优化最怕拍脑袋。老周那段时间天天盯着三个数字:Shader 编译队列峰值 QmaxQ_{max}、PSO 缓存命中率 PhitP_{hit}、进关卡卡顿时长 TstallT_{stall}

PSO 缓存命中率的定义很直观:

Phit=NcachedNtotalP_{hit} = \frac{N_{cached}}{N_{total}}

其中 NcachedN_{cached} 是命中缓存的 PSO 数量,NtotalN_{total} 是这一帧请求绑定的 PSO 总数。我们内部定的红线是 Phit0.95P_{hit} \ge 0.95;低于这个值,就必须补采集用例。

Shader 编译队列峰值更容易看,日志里直接有 Shaders left to compile。我们上线前这个值在 800 以上,优化后降到 30 以内。另一个指标是首次进关卡出现未缓存 PSO 的玩家比例,这个要靠客户端埋点,不能靠猜。

埋点方案并不复杂。我们在 FShaderPipelineCache 的命中和未命中路径加了事件,记录 PSO Hash、当前关卡名、材质名和耗时。数据回收到 ClickHouse 后,用 Grafana 做了一个看板。每天早上第一件事,就是看看昨晚有没有新 PSO 漏网。

10.1 一个快速定位未缓存 PSO 的技巧

打开 r.ShaderPipelineCache.LogPSO=1 后,日志里会输出每次未命中缓存的 PSO Hash。把 Hash 拿到 .stablepc.csv 里搜,如果搜不到,说明打包时漏采了。如果搜得到但还是未命中,多半是当前运行时状态和打包时略有不同,比如动态模板值、自定义裁剪平面、或者是某个后处理材质换了一套 RT 格式。

我们当时遇到过一个案例:某件装备的溶解特效在编辑器里正常,到玩家机器上就触发新 PSO。查到最后发现是特效用了 Stencil 做遮罩,但 PSO 采集时那件装备刚好没触发溶解,Stencil 状态没录进去。把对应动画状态补进采集流程后,问题消失。

下面这张表是我们优化前后的对比:

指标优化前优化后
首次进洞卡顿率17%0.4%
Shader 编译队列峰值84723
PSO 缓存命中率83%99.2%
平均卡顿时长2100ms80ms
Shader 库大小420MB210MB

数字不会说谎。只要把这些指标监控起来,Shader 和 PSO 的问题就从玄学变成了可量化、可追踪的工程问题。

十一、复盘与建议

回到开头那个溶洞卡顿。最终我们是这样解决的:

  1. 把溶洞关卡和所有分支路径加入自动化 PSO 采集。
  2. 审计材质,关闭不必要的静态开关,Permutation 从一千三百万降到四百万。
  3. 把 Shader 库压缩改成 Oodle,并启用 Chunking。
  4. 启动阶段预编译高频 Shader,用 r.ShaderPredraw 提前触发关键 PSO。
  5. 客户端加埋点,监控后续版本是否引入新的未缓存 PSO。

上线一周后,玩家端首次进洞卡顿率从 17% 降到 0.4%。这个数字告诉我们,Shader 和 PSO 的问题不是不能解决,只是很容易在开发阶段被高配置机器掩盖。

如果你也在做 UE5.8 项目,我建议从立项早期就做三件事:建立材质开关规范、把全关卡 PSO 采集写进 CI、在低端机上定期清缓存跑测。否则上线后再补,成本会高很多。

老周后来把这次复盘写进了组内 Wiki,标题叫《别让 Shader 在玩家机器上现场加班》。我觉得这标题挺贴切的。

Shader 和 PSO 这类问题永远不会彻底消失,因为项目在不断加新功能、新材质、新平台。我们能做的,是把编译和状态创建尽量提前,把监控和采集流程自动化,让玩家只看到流畅的画面,而不是背后那台机器在疯狂加班。