UE4 Shader 编译以及变种实现

本文深入探讨了UE4中Shader的编译过程、组织形式及其变种实现方法。通过对渲染管线、ShaderMap概念的解析,揭示了Shader在不同阶段如何被选择和使用的秘密。此外,还详细介绍了材质Shader和Global Shader的编译流程。
摘要由CSDN通过智能技术生成

UE4 Shader 编译以及变种实现

https://zhuanlan.zhihu.com/p/154081604

一 , 动机

这篇文章主要是我对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 changed ,recompileshaders global,recompileshaders material <MaterialName>,recompileshaders all,recompileshaders <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代码了

 

 

 

 

 

 

编辑于 07-09

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值