题图(取自COD冷战的着色器编译提示)
PSO(管线状态对象 Pipeline State Object)是伴随现代图形API(DirectX12、Vulkan、Metal)而出现的概念,它本质上是单次绘制时渲染管线所处的状态信息的集合(Shader、混合器状态、光栅器状态、图元拓扑信息等)。在D3D12中,PSO的描述信息由D3D12_GRAPHICS_PIPELINE_STATE_DESC结构体给出:
bash
typedef struct D3D12_GRAPHICS_PIPELINE_STATE_DESC {
ID3D12RootSignature *pRootSignature;
D3D12_SHADER_BYTECODE VS;
D3D12_SHADER_BYTECODE PS;
D3D12_SHADER_BYTECODE DS;
D3D12_SHADER_BYTECODE HS;
D3D12_SHADER_BYTECODE GS;
D3D12_STREAM_OUTPUT_DESC StreamOutput;
D3D12_BLEND_DESC BlendState;
UINT SampleMask;
D3D12_RASTERIZER_DESC RasterizerState;
D3D12_DEPTH_STENCIL_DESC DepthStencilState;
D3D12_INPUT_LAYOUT_DESC InputLayout;
D3D12_INDEX_BUFFER_STRIP_CUT_VALUE IBStripCutValue;
D3D12_PRIMITIVE_TOPOLOGY_TYPE PrimitiveTopologyType;
UINT NumRenderTargets;
DXGI_FORMAT RTVFormats[8];
DXGI_FORMAT DSVFormat;
DXGI_SAMPLE_DESC SampleDesc;
UINT NodeMask;
D3D12_CACHED_PIPELINE_STATE CachedPSO;
D3D12_PIPELINE_STATE_FLAGS Flags;
} D3D12_GRAPHICS_PIPELINE_STATE_DESC;
Vulkan和Metal中,有和PSO相对应的概念,它们并不叫PSO,但是在提到现代图形API的图形管线状态时,我们通常都用PSO来指代
旧式图形API(如OpenGL、DirectX11等)中,管线状态可通过单独的API Call来在渲染过程中进行设置(比如glSetBlendState、glCompileShader等),这带来了几个潜在问题:
- 在实际的DrawCall发出之前,用户可以随意改变管线的状态,所以图形驱动通常需要额外跟踪状态信息,并将实际的状态设置工作延迟到实际的DrawCall之前执行
- 图形驱动需要在渲染过程中实时计算硬件状态,将API编码到硬件指令
- 游玩时编译着色器会造成卡顿(虽然游戏引擎会提供一些预热功能来规避)
- 这种工作流程本质上将管线状态设置工作耦合进渲染循环中,会干扰渲染效率
新式图形API通过引入PSO的概念,将管线状态视为一个集合对象,使用PSO之后的管线有以下优点
- 管线状态不再可以在渲染过程中被松散设置
- 一旦PSO对象被创建,其立刻处于可用的状态,且管线编译期信息不再可以被更改(如着色器、混合状态、颜色掩码等)
- 着色器的编译相当于在PSO创建时就完成了
- 因为PSO创建后的编译期相关信息不可以再更改,因而大部分硬件指令已经在此时被生成,驱动程序不需要再操心地跟踪管线状态
- 渲染过程中,只需切换不同的PSO就可以改变管线状态,切换PSO的过程是相对较快的,这样可以将管线状态设置工作从渲染循环中剥离出来
概括来说,PSO设计理念之一,就是要将渲染过程中存在的实时状态计算(包括着色器编译),都显式地转移到一个统一的时间段内一气呵成地做完,将编译内容和渲染过程解耦,以为渲染过程让出更多的时间
但PSO的创建(编译)是一个非常非常耗时的操作,其时间开销已经不能和传统API的运行时着色器编译相提并论,即便是一些小Demo级的游戏里,如果尝试在游玩时创建关卡内所有物体的PSO,也可能导致长达数秒的停滞
现代游戏引擎为解决这个问题,通常会针对现代图形API平台增加PSO缓存支持,PSO缓存系统简单来说是帮助开发人员生成当前应用程序用到的所有PSO的列表,以便告诉用户机器在合适的阶段(比如加载期间,或由开发人员指定的其它时间)编译好这些PSO,这样能够避免游玩时创建所带来的停滞
这几年越来越多的PC平台游戏转向D3D12/Vulkan等新API,因此预先创建PSO成为了迫在眉睫的需要,大家可能发现这两年越来越多的游戏会显式的提示用户等待着色器编译(题图)
听起来是不是挺像Shader变体收集的,其实他们只有亿点点差别,如果你并不熟悉PSO,那你可以非常粗略的认为PSO缓存收集和编译就是新式图形API下的Shader变体收集和编译(但它们在底层的工作细节差别巨大)
本文主要内容
-
基于UE官方的游戏Demo来测试Unreal 5.4中的PSO缓存功能
-
StackOBot,比较简单,有一个3D欢迎界面和一个关卡
-
古代山谷,相对更复杂,有两个自由移动关卡,带战斗和运行时生成的特效
UE PSO缓存实践
概览
Unreal 5.4中,PSO系统有两个组成部分:一个是PSO Precache,另一个是Bundle PSO Cache
PSO Precache(PSO预缓存系统):
是一种相对自动的预编译PSO的功能,相关逻辑在Component的**PostLoad()**函数内被调用。当用户第一次加载关卡的时候,Precache系统会尝试收集关卡内对象的网格和材质信息,并在后台线程上异步编译PSO
由于编译PSO的过程发生在关卡加载阶段,因此游玩时不会因为PSO编译而导致游戏卡顿(当然,理想情况如此);且编译的内容会被缓存下来,当用户第二次进入同一个关卡时,通过复用第一次的结果,加载速度会更快
Precache系统本质上依赖于底层缓存,编译好的数据只适用于当前软硬件环境。当图形驱动版本更换、图形硬件更换后,PSO需要被重新生成(这也就是你换了显卡或者更了驱动之后,游戏会提示你重新编译着色器的原因)
Precache系统相当于是分步进行的,因为它只会在加载时生成本关卡的PSO,这代表着玩家不需要在一上来就编译整个游戏要用到的所有PSO
另外,Precache的过程在PostLoad期间进行,但运行时生成的Actor并不会调用该函数,这意味着运行时由用户生成的新Actor的PSO可能无法被记录在案(比如一个只有在玩家触发时才出现的粒子特效,且该粒子特效并不是一开始就在关卡内的),这部分PSO会在游玩过程中编译,造成卡顿
该功能在5.3中首次出现,于5.4中默认开启,目前仅支持D3D12平台,移动端滚出克
用于手动收集PSO,该系统早在UE4时就有了,开发人员需要手动跑游戏来收集PSO列表,并将收集到的文件包含到包体内发布给用户;当用户首次启动游戏时,Bundle PSO就开始编译
由于其流程依赖于手动采集,所以目前Epic也不是很青睐这种做法,官方是想在未来将PSO收集的过程完全自动化,以Precache系统完全替代Bundle PSO
但实际上目前最佳的实践仍是将这二者结合使用,因为PSO Precache总会有漏掉的地方,做不到100%的覆盖率
StackOBot项目简介
该项目刚启动时就进入一个3D场景+UI做成的欢迎界面:
因此实际上很多PSO编译的过程在刚进游戏就开始了,这样我们来不及启动Insight抓取数据
为了能更好的分析该3D欢迎界面的PSO编译情况,我又给它套娃了一层界面,让用户点两次启动才能进游戏
创建空关卡,并给一个UI,点击按钮后跳转到3D欢迎界面
1. StackOBot,关闭PSO缓存系统,清除上一次缓存
测试环境:13700F+RTX4070
第一次测试,我们使用下列命令关闭PSO预缓存系统,而且不会使用Bundle PSO
bash
[/Script/Engine.RendererSettings]
r.PSOPrecaching=0
这样生成的包体内不含任何Bundle PSO信息,且加载关卡的时候也不会提前准备PSO,所有PSO都需要在用户游玩过程中遇到的时候生成
打包游戏,并使用 -clearPSODriverCache 启动参数来运行游戏,因为第一次编译PSO后的结果会被缓存下来,但我们是为了测试PSO缓存覆盖率,所以要保证每一次启动游戏时,上一次生成的二进制数据都要被清空,否则会干扰判断
1号尖峰:
RHICreateComputePipelineState(计算PSO):4.8s
RHICreateGraphicsPipelineState(图形PSO):1.7s
2号尖峰是一组连续尖峰的集合,其中最长的一个:
RHICreateComputePipelineState(计算PSO):1.0s
RHICreateGraphicsPipelineState(图形PSO):1.9s
游玩过程的小尖峰(出现尖峰时玩家有明显卡顿感):
RHICreateComputePipelineState(计算PSO):未触发
RHICreateGraphicsPipelineState(图形PSO):39.7ms -> 68.7ms -> 92.8ms
结论:
- 运行时PSO创建开销很大,会造成明显的感知停顿
这还只是个小Demo场景,如果是更复杂的,卡顿会进一步放大导致几乎不能玩;另外,用户机器的CPU不一定比测试机好,所以他们的编译时间可能要更长
如果测试机的CPU核心数非常多,这可能会弱化PSO编译带来的停滞,此时可以使用 -corelimit=n (n是需要输入的核心数量)启动参数来运行游戏,可以更准确的复现目标机器编译PSO的耗时
2. StackOBot,关闭PSO缓存,不清除上一次缓存
这次我们不清除上一次的编译缓存,再来运行一下游戏
1号尖峰:
RHICreateComputePipelineState(计算PSO):414.7ms
RHICreateGraphicsPipelineState(图形PSO):162.7ms
2号尖峰:
RHICreateComputePipelineState(计算PSO):74.6ms
RHICreateGraphicsPipelineState(图形PSO):188.3ms
结论:
进游戏时PSO创建过程用时变短了,且游玩过程没有PSO编译尖峰,这受益于上一次运行时所保留下来的数据,此时所谓的PSO编译只是加载上一次的结果
注意,因为这一次的PSO创建依赖于上一次的缓存,假设上一次没走到某个特殊点A,那么这一次走到A点可能还是会触发编译尖峰,等到下次启动游戏时才会加载缓存
3. StackOBot,只启用PSO Precache,清除上一次缓存
首先启用PSO Precache系统
bash
[/Script/Engine.RendererSettings]
r.PSOPrecaching=1
(可选)启用PSO Precache验证层,0表示关闭;1表示轻量级跟踪,对性能影响小;2表示详细跟踪;启用验证层可以在运行游戏时直观的看到PSO预缓存的命中情况
我们会先使用轻量级跟踪,后面会描述轻量级跟踪和详细跟踪的区别
bash
[/Script/Engine.RendererSettings]
r.PSOPrecaching=1
r.PSOPrecache.Validation=1
1号尖峰:
RHICreateComputePipelineState(计算PSO):4.4s
RHICreateGraphicsPipelineState(图形PSO):3.6s
2号尖峰:
RHICreateComputePipelineState(计算PSO):1.2s
RHICreateGraphicsPipelineState(图形PSO):4.6s
对比第一次测试(第3小节)可知,本次2号尖峰(也就是正式进入游戏时)的PSO创建时长比之前高出许多,尤其是图形PSO部分
这代表关卡中要用到的大部分PSO都已经在关卡加载的时候被创建,因此游玩过程中的PSO尖峰就几乎不存在了
如果启用了PSO Precache验证层,那么跑游戏时可以通过Stat PSOPrecache命令来查看PSO缓存命中情况
图中显示有17个未追踪的PSO缓存,官方文档里给出未追踪的可能原因是
验证被禁用、全局材质、VertexFactory不受支持、MeshPassProcessor类型不受支持
这里我尝试将验证级别给到2,重新打包,其它参数不变,得到如下结果:
可以看到大部分PSO成功命中,少部分PSO在使用时还没来得及编译完毕(Too Late Count)
要注意的是该面板只统计"应该被Precache系统所捕获的PSO",如果是Precache本身无法捕获到的内容(比如运行时生成的内容),是不会出现在这里的
4. StackOBot,只启用PSO Precache,清除上一次缓存,运行时生成Actor
先前提到过,Precache系统会在组件的PostLoad函数调用期间来编译PSO,而运行时生成的Actor不调用PostLoad,这会导致Precache无法准确收集到PSO
而且要额外注意两个问题:
- 只有当【运行时生成的新Actor所携带的PSO】与【当前加载的关卡内的任一物件的PSO】都不匹配的时候,才会触发运行时编译,否则它只是复用已编译的PSO
- Precache调试层不能捕获运行时Actor的PSO Miss事件,因为这种Actor本身就不归Precache管,所以不要觉得Precache Miss Count为0就是100% PSO覆盖
这里我做了一个简单测试,新建一个带Trigger的Actor,当玩家触发时会生成一个小蓝人,这个小蓝人的PSO信息从未在当前关卡出现过
Insight数据如下:
可以看到,触发Trigger的时候出现了81.9ms的图形PSO编译;另外,触发Trigger之前有一个很矮的小尖峰,我看了一下是20ms的计算PSO编译
但是第3小节测试的时候没发现有这个问题,所以也挺迷惑的,不知道是动了什么
不过,这些数据终究还是表明Precache系统做不到100%的PSO覆盖率,实际我们还是要结合Bundle PSO来使用,在接下来的古代山谷项目里,我们会尝试使用二者结合的工作流
5. 古代山谷项目,使用PSO Precache,清除上一次缓存
Insight数据如下:
1号尖峰是一系列尖峰的集合,只记录最高的数据:
RHICreateComputePipelineState(计算PSO):7.7s
RHICreateGraphicsPipelineState(图形PSO):5.5s
运行时PSO编译:
RHICreateComputePipelineState(计算PSO):未触发
RHICreateGraphicsPipelineState(图形PSO):32.8ms -> 15.2ms -> 296.7μs
从Insight数据看,我们确实触发了相当长时间的运行时PSO编译,但通过PSO Precache验证数据的结果来看,Miss Count始终保持为0,所以再次强调不要通过这个面板验证整体PSO的覆盖率
下面我们就尝试使用Bundle PSO Cache,来将这部分游玩时PSO编译内容给优化掉
7. 古代山谷项目,PSO Precache + Bundle PSO Cache,清除驱动缓存
① 配置设置:
- 在DefaultEngine.[ini](https://zhida.zhihu.com/search?q=ini\&zhida_source=entity\&is_preview=1)或者(Platform)Engine.ini中添加(实际项目更推荐后者,因为不同平台的PSO需要不同的收集):
bash
[DevOptions.Shaders]
NeedsShaderStableKeys=true
[/Script/Engine.RendererSettings]
r.ShaderPipelineCache.Enabled=1
r.ShaderPipelineCache.ExcludePrecachePSO=1
其中,[r.ShaderPipelineCache.ExcludePrecachePSO=1](https://zhida.zhihu.com/search?q=r.ShaderPipelineCache.ExcludePrecachePSO%3D1\&zhida_source=entity\&is_preview=1) 用于确保Bundle PSO不会收集PreCache已经收集过的PSO
- 在DefaultGame.ini中添加(注意该ini文件中可能已经存在该配置,记得先查找一下看看):
bash
[/Script/UnrealEd.ProjectPackagingSettings]
bShareMaterialShaderCode=True
bSharedMaterialNativeLibraries=True
- 如果你从来没用过Bundle PSO Cache,请确保(Project)/Build/(Platform)/PipelineCaches文件夹是空的或根本不存在
② 跑游戏收集PSO:
- 打包项目
- 为打包出的程序添加-[logPSO](https://zhida.zhihu.com/search?q=logPSO\&zhida_source=entity\&is_preview=1) 启动参数,然后运行游戏
- 尽可能覆盖所有位置
③ 转换PSO缓存:
- 跑完游戏后,你会发现游戏打包的目录中的(GameName)/Saved/CollectedPSOs里已经有了一个后缀为.rec.upipelinecache的文件,将该文件复制到一个自定义路径下(比如D:\PSOCache,你可以选择自己喜欢的,无所谓)
- 回到项目路径,找到(Project)/Saved/Cooked/(Platform)/(ProjectName)/Metadata/PipelineCaches文件夹,拷贝里面所有的.shk文件,复制到D:\PSOCache中(和.rec.upipelinecache一个目录)
有些时候,因项目平台设置的关系,.shk文件可能有PCD3D_SM5和PCD3D_SM6两种,实测如果把这两种SM的文件都放到D:\PSOCache下,会导致之后使用转换PSO缓存打包时报错,建议一次只复制一个SM级别的.shk
- 在 D:\PSOCache 中新建一个txt,输入以下内容后改名为PSOCache.bat:
F:\Programme\Unreal\UE_5.4\Engine\Binaries\Win64\UnrealEditor-Cmd.exe -run=ShaderPipelineCacheTools expand D:\PSOCache\*.rec.upipelinecache D:\PSOCache\*.shk D:\PSOCache\20240819_AncientVallery_PCD3D_SM6.spc
- 这里第一行是UnrealEditor-Cmd.exe的路径,这个是跟着你UE安装目录走的
- 第二行注意要把 D:\PSOCache 改成你自己自定义的路径,如果你也是和我一样的路径,就无所谓
- 第三行是最终生成的.spc文件名,命名随意
- 运行该bat,你应该能得到一个.spc文件:
④ 使用转换后的PSO缓存再次打包:
- 将③中生成的.spc文件复制到项目路径(Project)/Build/(Platform)/PipelineCaches中,再次打包
- 注意一下打包过程中的log,看下有没有LogShaderPipelineCacheTools:开头的,有的话就正常
⑤ 运行游戏:
- 默认情况下,Bundle PSO会在游戏刚启动的时候就开始编译,你可以添加-Log启动参数,然后运行游戏看一下日志,如果有出现 LogRHI : FShaderPipelineCache::BeginNextPrecompileCacheTask(),则代表触发了编译过程
⑥ Insight数据:
1号尖峰:
RHICreateComputePipelineState(计算PSO):2.4s
RHICreateGraphicsPipelineState(图形PSO):11.1s
2号尖峰:
RHICreateComputePipelineState(计算PSO):1.8s
RHICreateGraphicsPipelineState(图形PSO):11.4s
游玩时PSO编译:未触发,但确实有尖峰,不过这就不在本文的范畴里了;由此可见我们成功通过Bundle PSO Cache弥补了Precache的不足
Unity6的PSO缓存
关于Unity6的PSO缓存,社区上有这么一个帖子:
简而言之:
- Unity6中的PSO缓存类似UE的Bundle PSO Cache,需要手动收集,并在合适的时机预热
- Unity可能会在未来版本中推出类似PSO Precache的功能(咕)
- 传统的 ShaderVariantCollection.WarmUp 并不能适用于新图形API,因为该方法不能提供PSO所需的一些特殊信息,在新API下需要使用 GraphicsStateCollection
- GraphicsStateCollection 目前在传统API下没有Fallback(这意味着在旧平台上要手动回退到_ShaderVariantCollection_.WarmUp?)
1. 新URP场景,关闭PSO缓存
我们使用新URP场景模板进行测试,该场景打包出来后是一个自动运行的Benchmark程序,用户无法以第一人称视角操作,如果你想自由移动,可以在打包的时候去掉第一个场景
该场景有三个展览室,进去会触发动画,同时也会触发PSO编译
在不使用任何预编译PSO的情况下,得到的Profiler数据如下,在进入展览室的时候,有一个142.43ms的尖峰,玩家卡顿感非常明显:
可以看到该帧的生成时间很长,若干个线程内都有PSO编译事件,我这里最猛的是2号线程,连续的几个PSO编译事件堆积起来可以有50多ms
2. 新URP场景,启用PSO缓存
① 创建脚本:
新建一个Mono脚本,编写代码,这里我写的是最基本的:
cs
public class PSOCache : MonoBehaviour
{
private GraphicsStateCollection _graphicsStateCollection;
private string _cacheFilePath;
private bool _hasValidCache = false;
void Start()
{
_cacheFilePath = Application.dataPath + "/PSOCache.graphicsstate";
_graphicsStateCollection = new GraphicsStateCollection();
_hasValidCache = _graphicsStateCollection.LoadFromFile(_cacheFilePath);
if (_hasValidCache)
{
_graphicsStateCollection.WarmUp();
}
else
{
_graphicsStateCollection.BeginTrace();
}
}
private void OnDestroy()
{
if (!_hasValidCache)
{
_graphicsStateCollection.EndTrace();
_graphicsStateCollection.SaveToFile(_cacheFilePath);
}
}
}
② 跑游戏收集PSO:
打包后,跑游戏,尽可能多的覆盖,这一步本质上和以前跑变体收集没什么区别,实际项目可以做成自动化的
结束游戏后,DataPath下会出现一个pso缓存文件:
③ 再次运行游戏查看Profiler数据:
虽然存在一些尖峰(最高的约28ms),但这些尖峰都不是PSO编译导致的,所以就不讨论了
如果你的PSO Cache命中良好,那么游玩过程中是不应该出现CreateGraphicsPipelineImpl事件的
补充说明
unity官方发的帖子,在跑GardenScene场景的时候,最多能干到276ms,Worker内的单个PSO编译事件能跑到84ms,但我本地测试没有这么恐怖,单个Worker内的单个PSO编译事件只占8ms左右(然后积少成多)
不知道是不是我的操作有问题,如果有dalao跑过相关流程请指教
Q & A
PSO的话题下常出现这样几个常见疑问:
Q:PSO能不能让厂商编译好,直接给用户,这样用户就不用等待编译了
A:固定硬件(Xbox,PS5,SteamDeck等)平台可以,PC不行,图形驱动和图形硬件都不一样,机器码都需要重编
Q:为什么以前的游戏没有着色器编译过程?
A:也有,只是没弹窗让你看到而已,现在这些游戏东西太多了,而且在新API下,如果不提前编,进游戏时实时创建PSO可比以前运行时编Shader开销大多了。所以目前的游戏都倾向于告(恐)诉(吓)用户最好编完再进,不然电脑会爆炸
附录
UE5.4 PSO预缓存源码调用链
这里简单描述一下,或许能帮助大家理一下思路
结构:[文件名] ~ [命名空间+函数名] [关键行数]
① RHI层:
Material.cpp ~ UMaterial::PrecachePSOs 2641 (注意PrecachePSOs函数在Component中也存在) ->
MaterialShared.cpp ~ FMaterial::CollectPSOs 2947 ->
PSOPrecache.cpp (Extern) ~ PrecacheMaterialPSOs ->
PSOPrecache.cpp ~ FMaterialPSORequestManager::PrecachePSOs 327 (如果先前已缓存过,则会于283行返回) ->
MaterialShared.cpp ~ FMaterialShaderMap::CollectPSOs 2744 ->
PSOPrecache.cpp (Extern) ~ PrecachePSOs 135 & 148 ->
PipelineStateCache.cpp ~ PipelineStateCache::PrecacheGraphicsPipelineState ->
PipelineStateCache.cpp ~ FPrecacheGraphicsPipelineCache::PrecacheGraphicsPipelineState 2775 ->
PipelineStateCache.cpp ~ TryAddNewState
1087行调用[CreateNewPSO](https://zhida.zhihu.com/search?q=CreateNewPSO\&zhida_source=entity\&is_preview=1)来初始化内存,但没有实际创建内容
1107行调用OnNewPipelineStateCreated来实际创建PSO ->
PipelineStateCache.cpp ~ FPrecacheGraphicsPipelineCache::OnNewPipelineStateCreated 2790 ->
PipelineStateCache.cpp ~ InternalCreateGraphicsPipelineState
如果是异步创建,则通过2524行的CompilePSO函数来创建
如果非异步创建,则直接调用2541行的RHICreateGraphicsPipelineState函数来创建 ->
② D3D12平台层:
D3D12State.cpp ~ FD3D12DynamicRHI::RHICreateGraphicsPipelineState ->
获取FD3D12PipelineStateCache的引用,并尝试在其中查找给定的PSO描述,如果找得到则直接返回
如果是新PSO则调用CreateAndAdd函数创建新PSO
D3D12RHIPrivate.h给出了D3D12平台下的具体行为
③ 传统API层(以D3D11为例):
D3D11平台无应用层PSO概念,因此没有重写RHICreateGraphicsPipelineState函数,D3D11RHIPrivate.h给出了D3D11平台下的具体行为
因为没有PSO概念,所以在Set Pipeline State的时候,D3D11走的是IRHICommandContextPSOFallback接口下的RHISetGraphicsPipelineState函数;该接口专门为不支持应用层PSO的图形API提供管线状态设置方法