性能测试工具 UE4 Shader 编译以及变种实现

侑虎科技 · 2020年07月30日 · 1605 次阅读

一、动机

这篇文章主要是我对 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 开发者计划,这个舞台有你更精彩!

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册