学习笔记
参考各路知乎大佬文章
首先是对变体的基本认知
概括就是变体是指根据引擎中上层编写(UnityShaderLab/UE连连看)中的各种defines情况,根据不同平台编译成的底层shader,OpenGL-glsl/DX(9-11)-dxbc DX12-dxil/Vulkan-spirv,是打到游戏包里的
在引擎开发编辑模式下,Unity/UE用户层写的是HLSL,根据引擎选择的目标平台,编译底层shader的流程也有区别。项目打包出来到目标平台上,是不会用开发时的HLSL再在目标平台上实时编译成底层shader的,是在游戏打包时,将目标平台的所有shader变体(glsl/dxbc/spirv)生成好打包进去
并且由于压缩,变体数对于游戏包体的影响可能不是很大
DX9-DX11 dxbc是字节码,GPU上跑的机器码还需要进一步转换成二进制码
DX12(2014年推出) dxil,GPU仍需要转成二进制码,但是多了很重要的PSO Cache流程
OpenGL glsl不需要离线编译,直接交给GPU驱动编译成机器码。OpenGL 4.1以上可以通过glGetProgramBinary回读这个二进制码,省去之后的编译工作
OpenGL ES 3.0(2012年推出)以上也可以通过glGetProgramBinary回读厂商的机器码
Metal(2014年推出) AIR字节码,交给GPU驱动编译成机器码
最后GPU上跑的不是glsl/dxbc/spirv GPU上跑的是机器码
现在的各种游戏,第一次进入游戏时总会有一个编译着色器的流程,这一步是在做什么?Unity和UE项目做的事是不太一样的,后面讲到PSO时再提及
首先看看手机OpenGL ES流程
以OpenGL ES3.0为例,不同的硬件厂商的GPU其机器码标准是不一样的,所以第一次进游戏时,需要把hlsl编译成当前手机硬件的GPU机器码,并且可以通过glGetProgramBinary回读厂商的机器码将编译好的机器码存在本地磁盘,下次进游戏时直接从磁盘读取编译好的产物
那么有没有办法不在游戏第一次打开时,不想等这么久的着色器编译,可以快速开始游戏呢?有的,随着厂商的发展,有抽象出一种中间格式的语言,这种语言机器友好,编译非常快速,并且具备跨机器运行的能力
及以下三家及对应的中间语言,如果以后的游戏是用DX12/Vulkan/Metal开发的,游戏打包时就可以将shader编译成对应的中间语言,但是这样做同样有问题,那就是这种中间格式的大小比shader源码大很多
然后看看PC DX(9-11)/12的流程
DX(9-11)的dxbc传递给显卡生成机器码
DX12 dxil取代了dxbc
由于dxbc和dxil是互不相通的,所以游戏为了支持不支持DX12的老电脑,只能将shader源码打到游戏包中,实际根据用户电脑是否支持DX12,再编译成dxbc/dxil,最后再是dxil生成PSO Cache,所以黑猴耗时长的部分在源码到dxbc/dxil这一步
PSO Cache(Pipeline State Object Cache渲染管线状态对象缓存)
Metal和Vulkan中有和PSO对应的概念,但并不叫PSO,只是经常用PSO替代称呼,OpenGL/ES没有PSO概念
要了解PSO是什么,先回忆一下GPU流水线。绘制一个物体的整个流程(pipeline),除开shader,其中的还有很多状态设置,比如是否进行透明度混合,混合方式是什么等等。
PSO Cache做的事就是把整个pipeline生成的机器码存下来
这里的PSO包括了shader和应用层设置渲染状态的代码
PSO和硬件是强绑定的,不同显卡/显卡驱动生成的PSO缓存也是不能通用的
OK有了上述认知,我们现在知道了现代API(DX12/Metal/Vulkan)提供了在应用层cache PSO的功能,针对不同的平台,引擎应用层会做相应的处理,那么接下来就可以看一下应用层的游戏引擎对应PSO Cache的相关流程了。
https://zhuanlan.zhihu.com/p/572503905
Unity
先看Unity,Unity6(2024.10.17发布)之前的版本是没有PSO Cache的功能的
老版本Unity Unity - Manual: Shader loading
是把加载的场景或资源所有的材质变体都加载到CPU中的,并且有一个可自定义大小的CPU空间存所有的变体,首次加载时,创建PSO的流程还是要走,可能会出现卡顿。创建过一次之后,会缓存该变体。当没有任何物体引用到某变体时从CPU和GPU中清掉。
为了提高效率,方案是变体WarmUp和变体收集文件ShaderVariantCollection的组合拳。
可以看出老版本的Unity,是无法省掉创建PSO的开销的,所以项目的重点会在于减少项目变体,剔除掉不用的变体,以及尽可能跑全变体收集文件上。
Unity6+ 对应UE的Bundle PSO Cache 当前只支持(DX12/Metal/Vulkan)
Unity - Scripting API: GraphicsStateCollection
新增PSO工作流,主要的功能在GraphicsStateCollection对象
流程还是跑游戏,根据目标平台缓存本地PSO Cache文件,因为开发期中材质变体可能会经常变动,所以跑游戏更新cache的思路是和原来的变体收集文件是一样的
cache的结果同样可以查看包含的变体,以及修改每个变体关联的渲染状态
PSO Cache也需要WarmUp,有同步和异步俩种方法执行
UE
UE中的PSO类型,这里主要关心的是Graphics PSO
UE4
Bundle PSO Cache
首先shader会在打包时编译成字节码,这些字节码有三种保存形式
1、在项目设置中,ShareMaterialShaderCode开关勾选才能走PSO Cache流程,如果没有勾选,字节码会打包附带于每个材质变体自身上,这样影响包体大小,虽然热更只需考虑增量,但这个方案大体量一些的项目基本都不会用
2、勾选,存成ushaderbytecode,UE维护一个ShaderCodeLibrary归档这些字节码,除Metal语言外的所有语言都使用该ShaderCodeLibrary
3、勾选,存成Native(metallib/metalmap),Metal原生ShaderCodeLibrary
后续没走PSO Cache的流程,创建PSO时就读取对应的字节码,然后二次编译PSO
PSO Cache文件有俩种文件类型:
.upipelinecache类型文件,这种是运行游戏时记录的 其中不会直接保存shader代码(无论是源码或者编译好的机器码),也不保存shader路径,保存的是shader路径的SHA hash作为索引
.spc(Stable PSO cache)类型文件 稳定的缓存信息
存储预计多个版本中不会改变的信息,如材质名称,顶点工厂名称,着色器类型等的描述称为stable key,UE5 UE4.27用.shk/UE4老版本用.scl.csv文件表示
Bundle PSO Cache的流程
https://dev.epicgames.com/documentation/en-us/unreal-engine/optimizing-rendering-with-pso-caches-in-unreal-engine?application_version=5.4
https://zhuanlan.zhihu.com/p/681319390
总体流程就是
1、打包时Cook一遍工程,扫使用到的所有材质变体,将编译成的平台无关的字节码存到shaderCodeLibrary,生成.shk文件
2、手机上跑游戏收集PSO,存到.upipelinecache文件中
增量收集
3、根据.shk文件和.upipelinecache文件,用ShaderPipelineCacheTools命令行生成.spc文件,然后将该.spc文件放到项目中再打包,spc文件会转换成upipelinecache文件打进包中,UE会整理成对应平台的PSOList
4、再次启动游戏,自动加载upipelinecache,编译shader时使用PSO Caching,收集的是对应GPU上编译成的机器码
5、重复流程
6、项目材质有重大改变时,可能需要重新记录Cache信息,因为老的没用到的PSO如果更新后根本没用到就纯浪费了
以上是项目打包相关的相关流程,接下来看一下手机跑游戏时PSO编译的流程
首先是三个关键流程
UE Graphics PSO缓存的信息包括
其中BoundShaderStateInput(BSS)包括
根据平台的不同,上诉信息可能只有部分作为PSO提交,其余走FallBack设置
之前提到OpenGL本身没有PSO机制,但是UE这套PSO Cache的流程,也将OpenGL的渲染状态抽象为PSO,起作用是PSO中的一部分信息BoundShaderState
UE虽然也提供了后台异步编译的功能,但是手游基本都会关闭此功能,而是在第一次加载游戏时全部一次性编译完
Usage机制
默认引擎会加载PSOList中的所有PSO,UsageMask可以添加筛选机制
LRU机制
生成的PSO可以缓存在内存中,OpenGL和Vulkan提供了LRU机制,可以限制加载到内存中的PSO数量,Metal没有该机制
UE5+
多了一套PSO Precache流程 UE5.3首次出现,5.4默认开启
https://dev.epicgames.com/documentation/en-us/unreal-engine/pso-precaching-for-unreal-engine?application_version=5.4
https://zhuanlan.zhihu.com/p/679832250
这是一套相对自动收集PSO Cache的方案,在Loading后就开始走收集流程,并在后台线程上异步编译
目前仅适用于D3D12 手游项目制作和Precache这套暂时无缘
如何控制项目材质变体的数量
UE变体数太多会导致什么问题
如果变体数很多,影响游戏包体大小,首次运行游戏时编译PSOCache耗时会比较长,全量编译PSO低端机可能会OOM,并且垃圾一点的手机编的也慢,加上发热等,影响玩家第一次的游玩体验。Metal编译生成的MemoryCache也会很大,而且随着游戏版本持续运营,又一直在出新效果玩法,对后续的膨胀问题就很难把控。还有图形驱动的升级会清掉PSO缓存,IOS升系统等导致得重新编译一次,又影响体验。
是时候回忆一下UE的材质系统了
VertexFactory
材质面板中勾选Usage后,UE会编译相应VertexFactory的shader变体
https://zhuanlan.zhihu.com/p/707759496
FShader持有ShaderCode在FShaderMapResource中的索引
FShaderType
FShaderType是FShader的元类,负责桥接FShader与对应的usf文件,FShader对应的FShaderType用using指定
当使用IMPLEMENT_MATERIAL_SHADER_TYPE时,就会为FShader构造一个相应的FShaderType,将FShader、Shader入口函数名,ShaderFrequency桥接起来,同时将FShaderType注册到一个全局列表中。编译Shader时会使用到这个全局列表
FMaterial/FMaterialResource
FMaterialShaderMap
FMaterialShaderMap中存储着材质在特定QualityLevel + ShaderPlatform下编译出的所有shader数据
其父类FShaderMapBase中的几个重要数据
FShaderMapResourceCode
FShaderMapResourceCode中存储的是编译后的shader代码,通过FShader存储的ShaderIndex索引
FShaderMapResource
FShaderMapResource负责创建和存储多个RHI端的shader,其子类有FShaderMapResource_SharedCode和FShaderMapResource_InlineCode,对应不同获取ShaderCode的方式,SharedCode就是前文所说,如果项目设置勾选了ShareMaterialShaderCode,保存在.uasset中的代码会统一放在.ushaderbytecode文件中,运行时创建一个FShaderCodeLibrary管理
FShaderMapContent
FMaterialShaderMap持有一个FShaderMapContent的引用,FShaderMapContent存有特定VertexFactoryType和ShaderType设置下对应的FShader实例
整体的流程可以分为俩个大的步骤,编译流程和绘制流程
首先看编译流程
https://zhuanlan.zhihu.com/p/85340922
https://zhuanlan.zhihu.com/p/707759496
材质编辑器中连的蓝图节点可以理解为只是HLSL生成过程中的一种输入,具体Pass用到什么shader,还得根据shader主干文件(如移动端BasePass的MobileBasePassVertexShader.usf MobileBasePassPixelShader.usf),VertexFactory,Common文件等生成最终的HLSL,然后再根据对应图形API将HLSL编译成对应shaderCode
其中FHLSLMaterialTranslator MaterialTemplate.usf模版的填充,自定义材质节点的一些使用之前也提过这里就不再提了
编译流程,我们需要关心的大的步骤就是
UMaterial->FMaterial/FMaterialResource->FMaterialShaderMap
编译好的ShaderCode是保存在FShaderMapResource中的
不同VertexFactoryType ShaderType对应ShaderMap的生成逻辑在
FMaterialShaderMap::Compile()
FMaterial::GetDependentShaderAndVFTypes()中
https://zhuanlan.zhihu.com/p/467788335
然后是绘制流程
谈及变体主要涉及的是MeshMaterialShader(MaterialShader的子类),那么就需要回忆下Mesh Draw Pipeline的流程
https://dev.epicgames.com/documentation/en-us/unreal-engine/mesh-drawing-pipeline?application_version=4.27
MeshBatch的Cache和Dynamic生成流程,后续的MeshPassProcessor和MeshDrawCommand生成流程之前讲过,这里就不再重述了。
mesh如何知道自己对应的vertexFactory就在生成MeshBatch流程中完成
FMeshBatchElement包含的是一个基本的绘制需要的信息
MeshDrawCommand包含了一次drawCall所需的全部信息,渲染信息的收集绑定是在MeshPassProcessor中完成的
渲染所需相关的数据由MeshPassProcessor收集
渲染时shader的获取,关注
XXMeshProcessor::Process中的GetXXPassShaders如
其中根据RenderPass创建特定FShader对应的FShaderType实例,最后用TryGetShaders方法获取FShader实例
FMaterial::TryGetShaders中,先获取FMaterial中的FShaderMapContent,然后用FShaderMapContent::GetShader通过ShaderType template实例字符串索引对应的FShader实例
而FShader持有ShaderCode在FShaderMapResource中的索引
后续提交给RHI Thread找对应的硬件编译过的机器码或者PSO Cache绘制即可
要更细的话,其实还有一个游戏加载时的流程
https://zhuanlan.zhihu.com/p/681306302
OK 在有了以上内容的认知之后,我们就可以来看一下UE项目中有哪些地方可以优化变体和PSO Cache了
可以从正反俩角度出发分析
首先正向分析,项目中那些地方会影响产生的变体
https://zhuanlan.zhihu.com/p/681316533
1、静态材质开关
包含连连看中的staticSwitchParameter和.usf中项目自己加的#ifdef
设A为主材质(无论有多少个静态开关),BC为A的材质实例,如果BC的开关override情况是相同的,那么BC会有俩个shaderMap,对应的俩个shaderCode内容是一样的,经过ShaderCodeLibrary相同结果剔除机制,进包后是一个shaderCode。
这时D也是A的材质实例,E是C的材质实例,DE的开关override情况相同且与BC不同,那么DE也是俩个shaderMap,俩相同内容的shaderCode,进包后也是一个shaderCode。如果FE开关override没改动,那么FEC是同一套shaderMap。
2、材质Usage 注意这里的Usage和PSO Cache那个UsageMask不是一个概念
如前文所说,材质Usage的设置主要影响VertexFactory组合
项目中的主材质,尤其是通用主材质,AutoUsage开关都应该关闭,然后根据美术实际的使用情况,酌情考虑开关勾选以及是否需要拆分主材质
3、PSO UsageMask
做更细致的UsageMask拆分
然后是反向的分析
项目打包流程的.shk .spc文件都是很好的参考用于分析项目实际用到的变体情况,当然由于这俩是二进制文件,所以还得转成可阅读的文本文件
正向分析看不到实际用到的ShaderType情况和项目中图程侧的一些管线上的自定义修改。从.shk .spc反向分析shaderType,VFType,QulityLevel等条目还是很有必要的