UE4材质系统源码剖析
材质(Material) 是可以应用到网格物体(Mesh)上的资源,用它可控制场景的可视外观。从较高的层面上来说,可能最简单的方法就是把材质视为应用到一个物体的"描画"。但这种说法也会产生一点点误导,因为材质实际上定义了组成该物体所用的表面类型(质感)。您可以定义它的颜色、它的光泽度及您是否能看穿该物体(半透明)等等。
在这里是默认大家对使用材质系统拥有基本的基本操作经验,所以本文并不会对一般的简单操作做任何的解释和说明,如果大家想对Unreal材质系统的基本操作感兴趣的话,参考官方文档:
https://docs.unrealengine.com/zh-CN/Engine/Rendering/Materials/index.html
对与某些高端的材质TA向的技巧和经验总结以后会在之后的文档中进行总结和归纳。
因此,本文的重点主要是来分析材质系统的运行逻辑和手段,从自动的代码生成到生成变体的过程,最终到如何和几何信息绑定提交渲染的一整套的流程。
一.简介
由于后文即将阐述的编译功能或者变体功能更加的抽象和难以理解,所以我们需要一个比较直观的切入来审视Unreal的整个材质系统。
我希望呈现一种较为直接的介绍模式,这一切就在我们打开一个Materal.asset开始。其对应于代码中的UMaterial。
1.1 PBR和它的输出
当我们打开材质编辑器,最开始我们能最直接看见的就是我们的材质的输出。如下图的左
右图是迪斯尼的PBR参考,这是unreal 的PBR材质节点。
对应代码中,是对应的各种FXXXMaterialInput。材质输出属性在UE4中的基类叫FMaterialInput,它有一些子类用于表达具体的输出属性的具体数据类型,如FColorMaterialInput,FScalarMaterialInput,FVectorMaterialInput等。
UMaterial中对应的材质输出属性和材质编辑器上的输出节点属性几乎是一一对应的(注,显示出来的名字可能会和变量量不同,因为名字因材质类型,光照模型而显示不同的名字),完整定义如下:
|
|
这里比较重要的是有一个WITH_EDITORONLY_DATA的宏,其关闭和IOS竟然相关!那按道理来说如果平台不同的话确实在最后呈现上会有明显的不同。
1.2顶层设计
至此我们先来高屋建瓴的看一下与Material相关的类,来更加清楚地了解其顶层设计。
1.2.1UMaterialInstance和UMaterialInstanceDynamic
材质实例,是我们在编辑器中经常使用的一种资源,通过将通用的材质统合起来,有效的减少材质的数量。使用上,如果材质之间就只有贴图或者参数不同的话,就可以构建一个基础的材质,然后将需要变化的材质进行参数化。并创建材质实例以应对不同的情况。
只有UMaterial材质模板带有可编辑的节点图并可拒此生成对应的Shader组合,而UMaterialInstance材质实例则只需要引用UMaterial对应的Shader.UMaterialInstance只能修改材质模板暴露出来的材质参数。
有几点是非常需要注意,其一是UMaterialInstance的继承关系,UMaterialInstance并不是继承自UMaterial而是和UMaterial一样继承自UMaterialInterface。这和我们在使用时的感觉是不太一样的,因为我们在使用时一般都是使用UMaterial来生成UMaterialInstance。
这种设计模式的目的应该是非常的清楚,因为UMaterialInstance是可以用另一个UMaterialInstance来进行创建的。具体的实现细节就需要深入到代码中来细细品来。
UMaterialInstanceDynamic实际继承的是UMaterialInstance,这是我们在程序运行时经常使用的一种。
1.2.1FMaterial
这是没有GC的UMaterial的剥离产物。
1.2.2FMaterialResource
抛开PhysicalMaterial(UMaterial中包含的成员变量),UE4中的UMaterial包含在不同的硬件条件(FeatureLevel)和不同的材质质量(MateialQualityLevel)设定下的不同材质表现,同时它可能它在引擎的其它子系统下也可能有自己的特殊表现。如LightMass下就和实时渲染的材质参数不同。
这些表现方式被封装为FMaterialResource类,FMaterialResource是UMaterial为指定FeatureLevel和MateialQualityLevel后的具体表现形式。
FMaterialResource包含用于渲染的Shaders、Shaders参数、RenderStates、LightingMode等数据。因LightingMode在UE4中不可定制只可选择内蕴的几种固定模型,故其可看作一种特殊的RenderState,在此前提下FMaterailResource简化为ShaderMap + Shaders参数 + RenderStates。其中Shader参数和RenderStates来源于对材质本身的引用数据,ShaderMap则来源于编译材质节点图所生成的Shader变种。
1.2.3FMaterialRenderProxy
FMaterialRenderProxy是FMaterial用于渲染线程的代理,它可以透过FMaterail和UMaterialInterface访问到Shader、渲染状态,光照模型等所有用户设置好的材质参数。
1.3材质节点
当我们了解了顶层的设计框架,我们接下来将深入到另一个代码生成的领域当中,来探究材质编辑器的连连看是如何工作的。
在所有的材质表达式中,我们会将所有的表达式存入Expressions。
类似于蓝图,材质编辑器中典型的节点包含节点本身,节点的输入,节点的输出三个部分。特殊情况下,一些节点没有输入,如const数据,各类parameters,内置的inputs节点等等。
UMaterialExpression是材质节点的基类,UMaterialExpression继承自UObject。其定义了通用的材质节点的属性和方法。
UMaterialExpression的主要属性 :
|
|
UMaterialExpression的主要方法
|
|
FExpressionInput是材质节点的输入,它是个结构体,持有一个UMaterialExpression指针,并指定它自己取的是该Expression的哪个输出。它基本上就是对UMaterialExpression的一个简单的封装。
FExpressionOutput是材质节点的输出,同样是个结构体,它比Input简单,结构体里只有名字和Mask。Mask用于Mask输出的结果,可能同一个结果会显示为多个输出,如TextureSampler2D有RGB,R,G,B,A四个输出,它们都是对TextureSample出来的RGBA进行不同分量Mask的结果。
1.3.1材质节点的种类
- 内置输入节点 ,显示为红色Tile的节点,输入类节点本身没有输出接口,这些节点可能来源于前一Shader阶段的输出数据(如TexCoord[0]),也可能来源于引擎内置的UniformParameter(如Time节点)。
- 内置函数和运算符节点,显示为暗绿色的节点,内置的函数节点可能来源于HLSL语言层面的原生函数(如sin,cos),也可能来源于引擎自己在usf中定义的标准函数库(如sinFast,cosFast)等。
- 常数节点,这类节点同样显示为亮绿色。如Constant、Const2Vector,Const3Vector,StaticBool等节点
- 可变参数节点(UniformParameter) ,这类节点显示为亮绿色,其来源于用户在运行时可修改的Shader参数。如ScalarParameter,TextureSampleParameter2D等。TextureSample节点虽然在编辑器语义下是常数类节点,但实际上它在编译为Shader参数时同样为UniformParameter,实际上所有Texture相关的节点在编译后都表示为UniformParameter。
- Static节点族:StaticBool为常数节点,StaticBoolParameter是允许材质instance修改的常数节点(因为修改节点后会导致shader重编),StaticSwitch类似于在usf中进行宏定义,其结果是只编译通过StaticSwitch测试的那个分支的代码,另一个分支会被直接丢弃。StaticSwitchParamter是在instance中可以修改可通过分支的StaticSwitch,同样它的修改会导致shader重编。
- FeatureLevelSwitch和QualitySwitch节点 : 这两个节点直接对应UMaterial中的材质切换条件:根据硬件条件和质量等级切换不同品质和实现的材质。
- 关于if节点,UE4中不存在流程跳转节点,有同学可能会注意到,材质编辑器中存在if节点。实际上材质编辑器上的If节点不会被编译成if指令,它会被编译成一个或两个三元运算符:A > B ? C : D 或 A==B? C : (A>B ? D : E)。
- 既然不存在流程跳转节点,所以循环节点也就无从谈起了——在材质编辑器中找不到for和while节点的原因也在于此。
- 在没有动态分支if且staticswitch需要instance使用过才会展开对应分支的前提下,要实现诸如四季切换或昼夜切换的时候,对整个美术生产流程就不太友好了。
二.材质的编译
本节将要讨论UE4从材质蓝图生成原始的HLSL代码的基本流程,这儿的材质特指的Surface Domain类材质,其对应的Shader种类为MeshShader,只因这一类材质应用最为广泛,可用于描述任何3D Primitive表面属性,一个游戏中可见的90%以上材质都属此类。
2.1材质编译的入口和时机
对于UMaterial,其生成Shader的入口函数有二,
- 一个用于资源Cooking,名为CacheResourceShadersForCooking。
- 一个用于材质编辑和引擎运行时渲染,名为CacheResourceShadersForRendering,
在不同的阶段,如果我们寻找一个shadermap在内存或者DDC中没有找到,那么就会调用这两个函数。
我们仅针对一下CacheResourceShadersForRendering来做一下比较深入的探讨。首先的问题就是其调用时机是什么。
-
在反序列化时会进行调用。
-
当我们打开材质编辑器中的实时预览按键时,其会在每一次的改动后进行调用。其始于 UMaterial::PostEditChangeProperty。
-
当我们没有勾选实时预览时,我们点击apply按键才会进行UMaterial::PostEditChangeProperty的调用。
因此,我们将调用接口和时机讨论完后,我们将进行更加细节的探讨,讨论这个函数到底干了一些什么我们关心的事情。
2.2材质编译做了什么
当我们发生了材质的更改,接下来会发生如下事件
- FlushRenderingCommands(),会被直接调用,强制之前的命令缓冲渲染完毕,这里确保线程安全。之后调用
- CancelOutstandingCompilation(),强制所有的目前的编译工作取消,这也是很自然的意见事情。
- 之后我们调用CacheResourceShadersForRendering(bRegenerateId);
我们正式进入到CacheResourceShadersForRendering里面
2.2.1RebuildShadingModelField
这里顾名思义是用来重新编译我们的ShadingMode,这里需要注意的是,处理我们默认的shading model外,还有一个MSM_FromMaterialExpression,这个是需要特殊处理的。
目前这个阶段仅仅是识别,并没有任何其他的处理。
2.2.2FlushResourceShaderMaps
在这里我们需要更加深入的讲解一下我们的FMaterialResource。
FMaterialResource是FMaterail的子类,用于UMaterial的渲染,FMaterialResource代表的是从一个截面(在某个Render API FeatureLevel、指定的平台下、指定材质品质预设下)时它对应的UMaterial的具体呈现。
虽然来说我们这里的FMaterialResource并不是函数名称关于ShaderMaps代表之以,不过我们并不打算在这里进行对变体这一特性的深入探讨,我们将在下一节,进行更加细致和缜密的探究。
回归正题,我们FlushResourceShaderMaps调用的FMaterial::ReleaseShaderMap()函数,实际是对于我上图中我们对不同的质量不同的RHIFeaturelevel的二维数组中的每个FMaterialResource的调用。
|
|
2.2.3UpdateResourceAllocations
从这里我们很明显的看到,关于对MaterialResources的空间分配和设置更新
2.2.4CacheShadersForResources
在这里,我们会所有的单个FMaterial执行Shader生成的函数为CacheShadersForResources,CacheShadersForResources先会重建材质函数依赖信息、材质收集器依赖信息,并把此材质(或材质函数)所依赖的输出和输入纹理填充到依赖列表里。
2.2.4.1搜集阶段
- RebuildMaterialFunctionInfo:这里会将所有的材质表达式,进行筛选
- RebuildMaterialParameterCollectionInfo
- AppendReferencedTextures这里会将材质中所有引用的贴图进行收集
不管如何,这个阶段也仅仅只是搜集填充MaterialFunctionInfos,MaterialParameterCollectionInfos,InOutTextures,CurrentFunction
2.2.4.2CacheShaders(ShaderPlatform, TargetPlatform);
这里我们就进入了FMaterial类中。在这里,我们的不会有static parameters和相应的ShaderPlatform,TargetPlatform宏的开关问题,因为这里都会被指定。
FMaterialShaderMapId,是唯一标识FMaterialShaderMap的ID,内置变量很多,列出比较重要的几个
- FSHAHash CookedShaderMapIdHash;
- FGuid BaseMaterialId;
- EMaterialQualityLevel::Type QualityLevel;
- ERHIFeatureLevel::Type FeatureLevel;
在这个FMaterialResource中存在两个FMaterialShaderMap指针,这两个指针一个用于游戏线程(GameThreadShaderMap),一个用于渲染线程(RenderingThreadShaderMap),实际上这两个ShaderMap指针指向的是同一个FMaterialShaderMap对象。
之后,会把所有的依赖注入给FMaterialShaderMapId,当然数据仅仅是TArray
随后判断,如果自己本身是MaterialInstance,那么我们将要把配置的StaticParmeterValue进行打开关闭。
得到一个FMaterialShaderMapId后,继续进行调用另一个FMaterial::CacheShaders来进行后续的操作
2.2.4.3CacheShaders(ShaderMapId, Platform, TargetPlatform);
这里会区分是否是第一次还是拥有基本的cache,我们现在仅讨论第一次创建的情况。我们将排除其他非常良好的情况。我们看需要进行编译的地方。BeginCompileShaderMap
2.2.4.4BeginCompileShaderMap
真正的编译环节是通过FHLSLMaterialTranslator来完成初始的翻译工作,然后,再进行编译。
2.2.4.4.1翻译阶段
直接的调用MaterialTranslator.Translate();
FHLSLMaterialTranslator类是材质由表达式编译(翻译)成HLSL的核心类,实现了几乎所有材质表达式的代码生成功能:几乎每个UMaterialExpression的子类在FHLSLMaterialTranslator类中都有一个唯一的函数与之一一对应。这样在UMaterialExpression的Compile函数被调用时步骤分为三步,先检查表达式的合法性和前置条件,再处理表达式的输入节点,最后把表达式的条件和输入节点一起送入FHLSLMaterialTranslator与之对应的函数中进行HLSL代码生成。
我们将要扩展材质编辑器的节点,就必须要处理翻译这里的流程。
这是一段非常独立且极其庞大的代码段,这里并没有对我们介绍其他部分会有影响,所以我们暂且不谈。
2.2.4.4.1编译阶段
当我们得到翻译结果后,我们将翻译后的字节码存储为FString,然后增加一些path,或者是设置环境和平台的操作后就进行编译。
在编译阶段,我们需要再次更加深刻的去理解几个概念。
2.3 变体的基本概念
2.3.1FVertexFactoryType:
材质必须支持应用于不同的网格类型,而这是通过顶点工厂来实现的。
我们来看一下我们的系统当中到底拥有多少的FVertexFactoryType
- FGPUBaseSkinVertexFactory
- FGeometryCacheVertexVertexFactory
- FLandscapeVertexFactory
- FLocalVertexFactory
- FNiagaraVertexFactoryBase
- FParticleVertexFactoryBase
- FPointCloudVertexFactory
- FVectorFieldVisualizationVertexFactory
以上只是一级继承关系,还有更多的二级继承的类别。其中的每一个都是作用于不同的地方,但其实我们一般而言会进行使用的是则是FLocalVertexFactory。这是一般的mesh的不同渲染的顶点工厂。
其实我们可以更进一步的来看一下这些顶点工厂的不同,但是我们还是将这种细致的差别比较放在shader篇更加的妙。我们在这里仅仅是想让大家知道我们的material不是仅支持FLocalVertexFactory的,其实它还要支持非常非常多的顶点工厂。
2.3.2FShaderType
shadertype看似比较少,其实不是的。
|
|
这些只是内置的shaderType,但是,你只要在程序里继承FShader的shader,都会生成自己的ShaderType。所以,可以预见的是,shaderType的数量是极其巨大的。
这里其实对unreal的shader有一个非常重要的思考,那就是对于我们日常见到的材质,其实都是生成的模板,也就是把连线连接到PBR节点的输出当中,但是具体的如何使用,是通过shader中的代码来使用的。所以如果不加限制,那么变体的数量则会非常急速的增加。
例如,我们自己实现的喷射管线的shader
其中的一个函数ShouldCompilePermutation就是判断FShaderType能给哪些生成变体的。
如果我们返回的是true,那它将对所有的场景中所有的material生成对应的变体!
2.3.3FShaderPipelineType
这里蕴含着和Mesh相关Rendering 的个数(如DepthRendering ,ShadowRendering,BasePassRendering)等等,对每一个材质,我们都会生成不同的生成阴影贴图的pipline
具体的pipeline的组合数非常的多,例如
2.4 材质的变体
FMaterialResource不是持有一个Shader,而是持有在指定截面下此材质所有的Shader变体(Shader Permutations)。
影响变体多寡主要因素包括我们已经在上述讲完了。
一个材质动辄150+的Shader Permations是很正常的事,所以在UE开发中,ShaderPermation Reduce对包体和运行效率均为不可回避的优化项。
三.材质的管线路径
当我们了解了材质系统的编译时机,存储位置,变体策略,我们将更加细致的来看一下其传入渲染线程的路径和时机。目前暂且不论