原文链接:UE4 Shader 编译以及变种实现
正文
一、动机
这篇文章主要是我对 UE4 中 Shader 编译过程以及变种的理解,了解这一块挺有必要的,毕竟动辄几千上万个 Shader 的编译在 UE 里简直是家常便饭,了解它底层的实现机制后内心踏实一点,要去修改的话大方向也不错
这部分工作是我之前就做好的,文章里涉及内部修改的地方都被我阉割掉了(阉割版,之后还有内容我再加。。 工作繁忙外加懒)
所以这篇文章主要用于知识普及,分享给广大被 UE4 中的 Shader 编译折磨的码农们,凑活着看把,看完其实应该都了解了,有收获的记得点赞哟。
二、 UE4 中 Shader 的组织和获取
在讲具体的 Shader 编译过程时,先讲 UE4 的渲染过程,渲染过程中是怎么拿 Shader 的,最后再讲这些 Shader 是怎么生成的
虚幻引擎中讲到线程主要有三个:
- 游戏线程,
- 渲染线程,
- RHI线程。
其中我们平时关心的比较多的就是 游戏线程 和 渲染线程 了,至于 RHI 线程偏向于底层硬件接口,是甚少关心的,一般情况下也很少有需要改动到 RHI 线程的东西
2.1 渲染线程
虚幻引擎在 FEngineLoop::PreInit 中对 渲染线程 进行初始化
具体的位置是在 StartRenderingThread 函数里面,此时虚幻引擎主窗口是尚未被绘制出来的,渲染线程 的启动位于 StartRenderingThread 函数里面,这个函数大概做了以下几件事
1:通过 FRunnableThread::Create 函数创建 渲染线程
2:等待 渲染线程 准备好从自己的 TaskGraph 取出任务并执行
3:注册 渲染线程
4:创建 渲染线程 心跳更新线程
2.2 渲染线程的运行
在 UE4 的体系中
渲染线程 的主要执行内容在全局函数RenderingThreadMain(RenderingThread.cpp)中
从本质上来讲它更像是一个员工,等着老板给他派任务,老板塞给他的任务都会放在 TaskMap 中,他则负责不断的提取这些任务去执行
老板可以通过 ENQUEUE_RENDER_COMMAND 系列宏,给员工派发任务(添加到 TaskMap 中),下图说明了这个过程
具体代码调用实例如下,这个宏是在 游戏线程 中调用的,有时候 游戏线程 中有一些资源发生了变动,或者添加了一些新的资源,抑或是因为一些逻辑而要去改到 渲染线程 的一些操作,都需要有一种方法去通知到 渲染线程,就像是两艘并行飞驰的船,各自走自己的路,另一艘船上发生了什么是完全不知道的,而 UE4 就通过设置一系列宏为两艘船之间的通信提供了方法
- em。。。 马赛克忽略吧
员工执行任务时也不是直接向 GPU 发送指令,而是将渲染命令添加到 RHICommandList,也就是 RHI 命令列表中,由 RHI 线程 不断取出指令,向 GPU 发送,并阻塞等待结果
此时 RHI 线程 虽然阻塞,但是 渲染线程 依然正常工作,可以继续处理向 RHI 命令列表 填充指令
2.3 渲染过程中 Shader 的来源及选择
明白了上述那些概念我们知道,屏幕结果就像是我们最终要做出来的产品,老板就像是产品经理,告诉员工这个产品要怎么做,并交给员工对应的资源,员工根据这些资源,和老板的命令去完成最终的产品(绘制到屏幕上)
首先讲这些资源在 UE4 中对应的是什么,以及员工在完成不同的工作阶段(绘制 Pass)时是如何从这么多资源中拿到自己想要的资源的,再去将这些资源的生成
资源的组织:ShaderMap
那么屏幕上的画面究竟是如何呈现的呢?员工是怎么样去用这些资源的呢,换句话说就是老板给员工的资源,员工是怎么处理成最终能用的资源的?并且这些资源是怎么组织的?
这里就涉及到一个名词:ShaderMap
用过虚幻4的渲染的都知道,虚幻引擎中的着色器数量是非常庞大的,如果改动一个材质,经常就需要编几千个甚至上万个 Shader,其实也就是说单个材质会编译出多个 Shader,这一点是非常重要的
用一个简单点的概念来理解 ShaderMap,可以把它理解成一个三维矩阵,长度为每个材质类型,宽度为每个渲染阶段,高度为每个顶点工厂类型,矩阵的每一个方格都对应了一组着色器组合(顶点着色器,像素着色器),材质也 不一定 参与全部阶段,所以这个三维矩阵中是存在有很多空缺的
顶点工厂在 UE4 中的含义是负责抽象顶点数据以供后面的着色器获取,从而让着色器能够忽略由于顶点类型造成的差异,比如说 普通的静态网格物体和使用 GPU 进行蒙皮的物体,二者的顶点数据不同,但是通过顶点工厂进行抽象后,提供统一的数据获取接口,供后面的着色器使用
资源的选择:怎么从 ShaderMap 中拿到想要的 Shader
现在是第二个问题,如何根据当前阶段,当前的材质类型,当前顶点工厂类型,从这个三维矩阵中获得需要的着色器组合呢
以一个 StaticMesh 物体的渲染为例(动态物体不同),对着色器数据选择的过程如下:
1: 渲染线程 把这个物体添加进场景 AddToScene
2: 更新场景的静态物体绘制列表 AddStaticMeshes
3: 调用 CacheMeshDrawCommands,开始生成当前物体的绘制命令 MeshDrawCommands 并缓存住
4: 遍历所有的 Rendering Pass 类型,获取当前场景的 CachedDrawLists 生成 Drawlistcontext
5: 调用不同 Pass(以 BasePass 为例)的 AddMeshBatch 函数,并将 Drawlistcontext 作为参数传入(方便之后把生成的绘制命令缓存住)
6: 通过一系列参数判断该 Mesh 应不应该在当前 Pass(BasePass为例)生成绘制命令,如果验证通过,那么调用当前 Pass的 Process 函数
其实也没啥,但是还是加马赛克
7: 获取该 Mesh 在当前 Pass 绘制需要的 Shaders,绘制状态,光栅化状态,并最终生成该 Mesh 的绘制命令
所以到这一步就讲清楚了渲染时怎么去拿 Shader 的流程,需要去看不同 Pass 的 GetShaders 函数,结合之前对 ShaderMap 的分析来看它的传入参数,MaterialResource 对应它使用的材质资源,VertexFactory 的 type 对应所用到的顶点工厂类型,最后还有用到的顶点和像素着色器
最终得到顶点着色器和像素着色器的调用如下(此时材质类型和渲染 Pass 已经确定)
材质的 GetShader 函数首先以当前顶点工厂类型的 id 为索引,通过 GetMeshShaderMap 函数从 OrderedMeshShaderMaps 成员变量中查询到对应顶点工厂类型的 MeshShaderMap,随后调用当前 MeshShaderMap 的 GetShader 函数,以当前着色器类型为参数查询,查询到实际对应的着色器
总结如下:实质上获取一组着色器组合需要的三个变量:渲染Pass,顶点工厂类型,材质类型,这也就不难理解 UE4 中对资源的组织形式了
三、 UE4 中 Shader 的生成
MaterialShader 的编译
在第二部分的内容中已经说清楚了 UE4 中 Shader 的组织形式以及具体是怎么去获取,那么接下来的问题就是如何去生成这些 Shader,及材质如何编译,产生 ShaderMap 并缓存起来
在之前一篇对材质分析的文章中已经说到当 HLSL 代码生成后(这篇文章在我的笔记里,之后考虑放出来),就需要进入到真正的着色器编译阶段
材质节点图生成的 HLSL 代码只是一批函数,并不具备完整的着色器信息,这些代码会镶嵌到真正的着色器编译环境中(FShaderCompilerEnvironment),重新编译成最终的 ShaderMap 中每一个着色器,主要流程如下
1: 保存材质并编译当前材质,触发 Shader 编译,调用FMaterial::BeginCompileShaderMap()
2: 新建一个 ShaderMap 实例,调用 HLSLTranslator 把材质节点翻译成 HLSL 代码
3: 初始化着色器编译环境,FShaderCompilerEnvironment 通过MaterialTraslator::GetMaterialEnvironment 初始化实例,主要就是去设置宏
3.1: 根据当前 Material 的各种属性,初始化各种着色器宏定义,从而控制编译过程中的各种宏开关是否启动
3.2: 根据 FHLSLMaterialTranslator 在解析过程中得出当前的参数集合,添加参数定义到环境中
4: 开始实际的编译工作
4.1: 调用 NewShaderMap 的 Compile 函数:
- a) 调用
FMaterial::SetupMaterialEnvironment函数,设置当前的编译环境,这里面也会去设置各种宏定义
- b) 获取所有顶点工厂类型,对于每一种顶点工厂类型,查看该类型对应的
ShaderMap是不是已经被使用,如果被使用就去BeginCompile
- c)
BeginCompile函数中会去遍历所有的ShaderType,中间会调到实例类的ModifyCompilationEnvironment, 最终调用全局函数GlobalBeginCompileShader,这个全局函数会去填充FShaderCompileJob,包括设置 shader 格式、usf 路径、注入宏等等
- d) 真正执行编译任务的是把所有
FShaderCompileJob交给FShaderCompilingManager,并且让其马上执行编译并返回
如何实现 Shader 变种?
FMeshMaterialShaderType 继承自 FShaderType,他存有模板类的两个静态函数指针:ModifyCompilationEnvironment 和 ShouldCompilePermutation ,因此每次遍历我们都可以访问到这两个函数
上文中的 c 阶段会先调用 ShouldCompilePermutation 询问 TMobileBasePassPS 是否为当前 Template、VertexFactory、 Material 组合编译 Shader
如果需要编译,则调用 ModifyCompilationEnvironment 注入该当前模板确定的宏,以此实现 Shader 的变种。
GlobalShader 的编译
在使用编辑器的时候,经常会有需要改动到 Shader 文件,并且需要在编辑器中查看效果的需求,与材质编辑器中的材质 Shader 不一样,材质编辑器提供了编译按钮,对材质的改动都可以保存并编译出 Shader 保存到 ShaderMap 中,所以如果改动了目录下的 Shader 文件怎么告诉引擎去帮我们编译修改后的 Shader ?
虚幻针对这个功能已经提供了相应的指令
recompileshaders changedrecompileshaders globalrecompileshaders material <MaterialName>recompileshaders allrecompileshaders <path>
如果不知道这些指令,一个比较死的办法自然是重启编辑器,让它重编改动过的 Shader,当然也可以不重启编辑器来重编这些改动过的 shader,比如使用 recompileshaders changed,这里首先讲通过指令重编的方法,它的具体流程是怎样?
一:动态重编 Shader 不需要重开编辑器
1: 修改 Shader 文件,保存,在控制台输入 recompileshaders changed
2: 调用 RecompileShaders,根据指令的内容进入不同的分支,先去匹配具体的命令内容
3: 寻找过期的 Shader 文件(改动过的 Shader)
4: 如果当前对 Shader 文件(.usf)没有任何改动,直接返回 No Shader changes found,如果有改动,调用BeginRecompileGlobalShaders
- a) 调用
FlushRenderingCommands,等待渲染线程执行完所有挂起的渲染命令 - b) 根据当前平台得到
GlobalShaderMap,GetGlobalShaderMap(ShaderPlatform),这里也可以看出来不同的ShaderType是存在不同的ShaderMap中的
- c) 从
ShaderMap中移除过期的CurrentGlobalShaderType和ShaderPipline(顶点还是像素着色器等等..)的 Shader - d) 调用
VerifyGlobalShaders重编ShaderMap中的 Shader
5: 完成 GlobalShader 的重编,调用FinishRecompileGlobalShaders() ,该函数会阻塞直到所有的 Global Shaders 被编译和处理完毕
二:重开编辑器
1: 在引擎的 preinit 函数中调用 CompileGlobalShaderMap
2: 新建一个 GlobalShaderMap 实例
3: 查看 Shader 缓存 DDC 中的内容与设定的 KeyString 是否一致,如果不一致说明缓存中对应部分的内容已经失效了,UE 就会去重编这部分内容(对应最开始说到的重编 Shader 问题),并且去重新生成这部分的 DDC
4: 从 DDC 中反序列化出来 GlobalShaderMap 实例的内容
5: 接下来就是一些 Shader 资源的初始化操作...
四、UE4 中材质 Cook 保存的是什么
所谓的 Cook 是指 把平台无关的编辑向数据转化为特定平台运行时所需的数据,对于材质来说就是把上述的 usf 文件和材质连线编译成安卓运行时需要的 GLSL 源码。
1: Cook Commandlet 会首先调用一个 Package 里面所有的 UObject 的BeginCacheForCookedPlatformData(const ITargetPlatform *TargetPlatform) 方法,该方法由各个 UObject 派生类各自实现,目的是生成特定所需数据并缓存下来,对于材质来讲就是 UMaterial 的 BeginCacheForCookedPlatformData
- a) 开始为目标平台缓存着色器,并将正在编译的材质资源存储到
CachedMaterialResourcesForCooking中
- b) 为当前
ShaderFormat/FeatureLevel、QualityLevel生成一个FMaterialResource数组,并调用CacheShadersForResources填充其内容
2: 之后 Cook Commandlet 会保存该 Package,也就是是去执行到 UMaterial 里面的 Serialize 方法
实际上前面部分提到的 usf 文件和材质连线都通过 CacheShadersForResources 被转化成了一个个 FMaterialResource ,所以 FMaterialResource 到底是什么东西?
在 UMaterial 能找到如下成员
结合之前的分析,不难得出 UMaterial 持有 QualityLevelNum * FeatureLevelNum 个FMaterialResource,可以通过 QualityLevel 和 FeatureLevel 索引到 FMaterialResource
FMaterialResource 里有一个关键的成员 FMaterialShaderMap,FMaterialShaderMap 可以通过 FVertexFactoryType::GetId() 来索引到 FMeshMaterialShaderMap;而FMeshMaterialShaderMap 可以通过 FShaderType 来索引 FShader
因此 FMaterialResource 里面存放的实际上是 FShader 的集合,而 FShader 里面存放的就是最终使用的 Shader 代码了