共计 6422 个字符,预计需要花费 17 分钟才能阅读完成。
一、动机
这篇文章次要是我对 UE4 中 Shader 编译过程以及变种的了解,理解这一块还是挺有必要的,毕竟动辄几千上万个 Shader 的编译在 UE 里几乎是粗茶淡饭。理解它底层的实现机制后心田虚浮一点,如果要去批改,大方向也不会错。
这部分工作是我之前就做好的,文章里波及外部批改的中央都被我阉割掉了。所以这篇文章次要用于常识遍及,分享给宽广被 UE4 中的 Shader 编译折磨的码农们,凑活着看,看完其实应该就理解了。
二、UE4 中 Shader 的组织和获取
在讲具体的 Shader 编译过程时,先讲 UE4 的渲染过程,渲染过程中是怎么拿 Shader 的,最初再讲这些 Shader 是怎么生成的。
空幻引擎中讲到线程次要有三个:游戏线程、渲染线程和 RHI 线程。
其中咱们平时关怀的比拟多的就是游戏线程和渲染线程了,至于 RHI 线程偏差于底层硬件接口,是甚少关怀的,个别状况下也很少有须要改变到 RHI 线程的货色。
1. 渲染线程
空幻引擎在 FEngineLoop::PreInit 中对渲染线程进行初始化。
具体的地位是在 StartRenderingThread 函数外面,此时空幻引擎主窗口是尚未被绘制进去的,渲染线程的启动位于 StartRenderingThread 函数外面,这个函数大略做了以下几件事:
1)通过 FRunnableThread::Create 函数创立渲染线程
2)期待渲染线程筹备好从本人的 TaskGraph 取出工作并执行
3)注册渲染线程
4)创立渲染线程心跳更新线程
2. 渲染线程的运行
在 UE4 的体系中,渲染线程的次要执行内容在全局函数 RenderingThreadMain(RenderingThread.cpp)中。
从实质上来讲他更像是一个员工,等着老板给他派工作,老板塞给他的工作都会放在 TaskMap 中,他则负责一直地提取这些工作去执行。
老板能够通过 ENQUEUE_RENDER_COMMAND 系列宏,给员工派发工作(增加到 TaskMap 中),下图阐明了这个过程:
具体代码调用实例如下,这个宏是在游戏线程中调用的,有时候游戏线程中有一些资源产生了变动,或者增加了一些新的资源,抑或是因为一些逻辑而要去改到渲染线程的一些操作,都须要有一种办法去告诉到渲染线程,就像是两艘并行飞驰的船,各自走本人的路,另一艘船上产生了什么是齐全不晓得的,而 UE4 就通过设置一系列宏为两艘船之间的通信提供了办法。
员工执行工作时也不是间接向 GPU 发送指令,而是将渲染命令增加到 RHICommandList,也就是 RHI 命令列表中,由 RHI 线程一直取出指令,向 GPU 发送,并阻塞期待后果。
此时 RHI 线程尽管阻塞,然而渲染线程仍然失常工作,能够持续解决向 RHI 命令列表填充指令。
3. 渲染过程中 Shader 的起源及抉择
明确了上述那些概念咱们晓得,屏幕后果就像是咱们最终要做进去的产品,老板就像是产品经理,通知员工这个产品要怎么做,并交给员工对应的资源,员工依据这些资源,和老板的命令去实现最终的产品(绘制到屏幕上)。
首先讲这些资源在 UE4 中对应的是什么,以及员工在实现不同的工作阶段(绘制 Pass)时是如何从这么多资源中拿到本人想要的资源的,再去讲这些资源的生成。
3.1 资源的组织:ShaderMap
那么屏幕上的画面到底是如何出现的呢?员工是怎么样去用这些资源的呢,换句话说就是老板给员工的资源,员工是怎么解决成最终能用的资源的?这些资源是怎么组织的?这里就波及到一个名词:ShaderMap。
用过空幻 4 的渲染的都晓得,空幻引擎中的着色器数量是十分宏大的,如果改变一个材质,常常就须要编几千个甚至上万个 Shader,其实也就是说单个材质会编译出多个 Shader,这一点是十分重要的。
用一个简略点的概念来了解 ShaderMap,能够把它了解成一个三维矩阵,长度为每个材质类型,宽度为每个渲染阶段,高度为每个顶点工厂类型,矩阵的每一个方格都对应了一组着色器组合(顶点着色器,像素着色器),材质也不肯定参加全副阶段,所以这个三维矩阵中是存在有很多空缺的。
顶点工厂在 UE4 中的含意是负责形象顶点数据以供前面的着色器获取,从而让着色器可能疏忽因为顶点类型造成的差别,比如说一般的动态网格物体和应用 GPU 进行蒙皮的物体,二者的顶点数据不同,然而通过顶点工厂进行形象后,提供对立的数据获取接口,供前面的着色器应用。
3.2 资源的抉择:怎么从 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 的生成
1. 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,并且让其马上执行编译并返回。
2. 如何实现 Shader 变种?
FMeshMaterialShaderType 继承自 FShaderType,他存有模板类的两个动态函数指针:ModifyCompilationEnvironment 和 ShouldCompilePermutation,因而每次遍历咱们都能够拜访到这两个函数。
上文中的 C 阶段会先调用 ShouldCompilePermutation 询问 TMobileBasePassPS 是否为以后 Template、VertexFactory、Material 组合编译 Shader。
如果须要编译,则调用 ModifyCompilationEnvironment 注入该以后模板确定的宏,以此实现 Shader 的变种。
3. GlobalShader 的编译
在应用编辑器的时候,常常会有须要改变到 Shader 文件,并且须要在编辑器中查看成果的需要,与材质编辑器中的材质 Shader 不一样,材质编辑器提供了编译按钮,对材质的改变都能够保留并编译出 Shader 保留到 ShaderMap 中,所以如果改变了目录下的 Shader 文件怎么通知引擎去帮咱们编译批改后的 Shader。
空幻针对这个性能曾经提供了相应的指令:
recompileshaders changed,recompileshaders global,recompileshaders material,recompileshaders all,recompileshaders
如果不晓得这些指令,一个比拟死的方法天然是重启编辑器,让它重编改变过的 Shader,当然也能够不重启编辑器来重编这些改变过的 Shader,比方应用 Recompileshaders changed,这里首先讲通过指令重编的办法,它的具体流程是怎么?
3.1 动静重编 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 被编译和处理完毕。
3.2 重开编辑器
1)在引擎的 preinit 函数中调用 CompileGlobalShaderMap。
2)新建一个 GlobalShaderMap 实例。
3)查看 Shader 缓存 DDC 中的内容与设定的 KeyString 是否统一,如果不统一阐明缓存中对应局部的内容曾经生效了,UE 就会去重编这部分内容(对应最开始说到的重编 Shader 问题),并且去从新生成这部分的 DDC。
4)从 DDC 中反序列化进去 GlobalShaderMap 实例的内容。
5)接下来就是一些 Shader 资源的初始化操作。
3.3 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 代码了。
文末,再次感激 YiQiu 的分享,如果您有任何独到的见解或者发现也欢送分割咱们,一起探讨。(QQ 群:793972859)
作者主页:https://www.zhihu.com/people/xie-jie-62-1,作者也是 U Sparkle 流动参与者,UWA 欢送更多开发敌人退出 U Sparkle 开发者打算,这个舞台有你更精彩!