UE4 Shader编译以及变种实现

UE4 Shader编译以及变种实现

一、动机

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