UE4.22+ 添加Uniform

Uniform是一种从CPU中的应用向GPU中的着色器发送数据的方式,Uniform是全局的(Global)。全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。第二,无论你把Uniform值设置成什么,Uniform会一直保存它们的数据,直到它们被重置或更新。
UE4的材质编辑器可以调用的红色节点中很多就是Uniform,如ObjectBounds,ActorPosition等等。本文记录了如何在UE4.22或更高版本添加自定义的Uniform,并且生成材质节点在任意材质中调用,该Uniform值也将显示在场景的Skylight组件上供用户修改。

创建CPU中的数据结构体

FShaderParametersMetadata

这是一个4.22的新类,4.21有一个类似的结构体FUniformBufferStruct。4.22中可以在ShaderParameterMetadata.h中找到这个类。
FShaderParametersMetadata是一个储存了多个着色器参数的集合或容器。该容器可以储存不同数据类型的参数。每个参数都是一个成员,可以是矩阵、向量、数组或者纹理。
(提示:FShaderParameter.h中还定义了另外一个着色器参数系统。有FShaderParameter, FShaderResourceParameter, FShaderUniformBufferParameter等等。依然可以在单独的着色器中使用,如在LightRendering.cpp中一样。)
如果要定义一组着色器参数,可以用宏来声明这些元数据,这是将它们放进UniformBuffer的快捷办法。

ShaderParameterMacros.h

这个头文件中包含了一些声明参数结构体的宏。比如BEGIN_GLOBAL_SHADER_PARAMETER_STRUCT()和END_GLOBAL_SHADER_PARAMETER_STRUCT()。这两个宏创建了一个类作为ParameterMetadata的壳,如:

1
2
3
4
5
6
7
8
9
// Begin Macro:
class STRUCT_NAME {
static Metadata;
CreateUniformBuffer() (when declare as GLOBAL)
...
Parameter Macro: Insert all members (Parameter)
...
// End:
some ending stuff + }

对于全局的参数结构体,cpp中需要添加另外一个宏: IMPLEMENT_GLOBAL_SHADER_PARAMETER_STRUCT(FMyParameterStruct, “MyShaderBindingName”);
这个宏将静态的元数据放入一个全局的容器,扩展后的宏类似下方:
1
2
FShaderParametersMetadata StructTypeName::Metadata(...);
=> FShaderParametersMetadata(..) { Add-Self-To-Global-Container }

现在有了元数据的壳,可以开始使用SHADER_PARAMETER(Type, Name) 添加一些参数到壳中,用类型和名称创建一个成员,然后添加该成员到元数据的成员数组中。
(提示:有两个版本的声明:GLOBAL和LOCAL,区别是GLOBAL会在类中创建一个UniformBuffer并且所有成员会放进这个缓冲中,另外GLOBAL会将元数据保存在全局的map和list中。而LOCAL方式不会创建内在的UniformBuffer,所以不用调用IMPLEMENT_宏。)

案例:Primitive Uniform, View Uniform

可以参考一下PrimitiveUniform和ViewUniform,两个最重要的UniformShaderParameters。

PrimitiveUniformShaderParameters.h

该头文件中声明了一个FPrimitiveUniformShaderParameters的结构体。之所以比较重要是因为在检索着色器代码的时候经常会看到”Primitive”或”GetPrimitiveData()“这样的变量,都是来自这个uniform结构体的。它保存了一个图元(或图元组件)的信息,包括LocalToWorld, WorldToLocal等等。
primUniform

FViewUniformShaderParameters(SceneView.h)

可以认为这是当前视图相关的场景信息,包括相机的信息,游戏时间或者其他渲染需要的信息。这也是一个适合添加自定义全局Uniform参数的地方。在这个ShaderParameters中,
viewUniform

4.22加入了StructuredBuffer类型,可以传递数组进StructuredBuffer,名称为PrimitiveSceneData的Buffer包含所有GPU实例化的图元数据。
newUniform

IMPLEMENT_Macro(SceneView.cpp)

上文提到过,实现宏创建元数据并且保存到全局的容器中。另外,这个宏也会帮助着色器编译器为GPU生成着色器代码。例如着色器编译器会生成一个”cbuffer Primitive{}”和”cbuffer View{}”,名称是传进宏里的名称。cbuffer的内容也是从FPrimitiveUniformShaderParameters或FViewUniformShaderParameters反射的。
reflection

(提示:当不支持自动实例化时,就会使用cubuffer Primitive,因此每个Drawcall现在的图元会绑定它的UniformBuffer到cbuffer。当自动实例化开启是,就不使用这个cbuffer了,而是使用ViewUniform中的StructuredArray”PrimitiveSceneData” 。)

GPU:生成着色器代码

之前讨论过,cbuffer结构中的着色器代码在任何.ush文件中都没有预先定义,它们是在编译着色器的时候生成的。下面大致看下原理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//////////////////////
// ShaderCompiler.cpp
//////////////////////
void GlobalBeginCompileShader(…)
{

// 设置编译标识,添加任务到队列,编译器稍后会调用该任务
NewJobs.Add(NewJob);
}

//////////////////////////
// ShaderCompileWorker.cpp
//////////////////////////
static void ProcessCompilationJob(…)
{
...
// 调用ShaderFormatD3D.cpp::CompileShader() (D3D)
Compiler->CompileShader(…);
}

////////////////////////
// D3DShaderCompiler.cpp
////////////////////////
void CompileShader_Windows_SM5(…)
{

CompileD3DShader(…);
}

void CompileD3DShader(…)
{

if (Input.RootParameterBindings.Num())
{
// 生成全局ShaderParameters的cbuffer代码,将字符串放在PreprocessedShaderSource中
MoveShaderParametersToRootConstantBuffer(Input, PreprocessedShaderSource);
}
// 处理字符串:修改代码便于从"cbuffer.memberData"到"cbuffer_memberData"的代码允许cbuffer访问。
RemoveUniformBuffersFromSource(Input.Environment, PreprocessedShaderSource);

}

着色器代码是在MoveShaderParametersToRootConstantBuffer()中生成的,然后RemoveUniformBuffersFromSource()会对着色器代码进行调整。[cbuffer_name].[dataMember]会转化成[cbuffer_name]_[dataMember],例如View.LocalToWorld会变成View_LocalToWorld。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ShaderCompilerCommon.cpp:
void MoveShaderParametersToRootConstantBuffer(…)
{

/* Print the string "cbuffer {…}" , but looks like this is still an intermediate code,
the string will be proccessed later again, but I don't trace too much in this part */
// 输出字符串"cbuffer{...}", 但貌似只是过渡,后面字符串还会被处理一次。
FString NewShaderCode = FString::Printf(
TEXT("cbuffer %s\r\n")
TEXT("{\r\n")
TEXT("%s")
TEXT("}\r\n\r\n%s"),
FShaderParametersMetadata::kRootUniformBufferBindingName,
*RootCBufferContent,
*PreprocessedShaderSource);
PreprocessedShaderSource = MoveTemp(NewShaderCode);
}
// 交叉编译器还不支持结构体初始化,用名称替换所有的uniform缓冲结构体成员的引用。
// 例如View.WorldToClip变成View_WorldToClip,移除结构体的依赖。
void RemoveUniformBuffersFromSource(…)
{

/* 'OpaqueBasePass.Shared.Reflection .SkyLightCubemapBrightness' ->
'OpaqueBasePass_Shared_Reflection_SkyLightCubemapBrightness ' */

}

生成的着色器代码

下方的两个文件在vs中是找不到的,因为它们是在编译时自动生成的。但是,可以修改着色器编译器设置来显示debug信息。在ConsoleVariables.ini中添加r.Shaders.Optimize=0和r.Shaders.KeepDebugInfo=1。然后就可以用RenderDoc抓帧查看资源包括着色器代码。
在View.ush和Primitive.ush中,可以看到cbuffer的内容与C++宏声明的着色器参数是一一对应的,而变量的前缀则是结构体的名称。
cbufferView cbufferPrim

更新和绑定缓冲

更新缓冲和绑定缓冲是不同的。更新需要获取参数的数据然后更新数据到UniformBuffer;绑定缓冲是告诉着色器我们将要使用哪些缓冲。例如,我们有十个UniformBuffer,它们是不同Views, Primitivies等等的缓冲,它们会在任意的drawcall前更新。但是渲染一个模型时,如果着色器只需要十个中的两个,只需要绑定其中的两个缓冲到着色器。

SceneRendering.cpp

不同的uniform参数可以有自己的更新管线,所以它们可以在不同的地方更新。这里会展示一下更新ViewUniformBufferParameter的位置。
从DeferredShadingRenderer.cpp::Render()可以追踪到InitViews(),最后到SceneRendering.cpp::SetupUniformBufferParameters(),此处设置了FViewUniformBufferParameter的数据。

1
2
3
4
5
6
7
8
9
/* SceneRendering.cpp */
void FViewInfo::SetupUniformBufferParameters(…)
{

SetupCommonViewUniformBufferParameters(…);

}/* SceneView.cpp */
void FSceneView::SetupCommonViewUniformBufferParameters()
{…}

ClobalShader.h

这是绑定缓冲的地方,同样地,不同的渲染pass可以在不同的地方绑定缓冲。发送drawcall的时候缓冲绑定几乎已经完成。因为绑定缓冲意味着链接缓冲到着色器,所以必须同时传递shaderRHI和缓冲到函数中。

1
2
3
4
5
inline void SetParameters(…, ShaderRHI, ViewUniformBuffer)
{

SetUniformBufferParameter(…, ShaderRHI, …, ViewUniformBuffer);
}

D3D11StateCachePrivate.h

SetUniformBufferParameter()最终会来到InternalSetSetConstantBuffer()函数,这个函数会调用D3D11 API [Type]SetConstantBuffers到一个顶点/外壳/域/几何/像素/计算着色器。绑定工作在此时完成。

1
2
3
4
5
6
D3D11_STATE_CACHE_INLINE void InternalSetSetConstantBuffer(…)
{

case SF_Vertex: Direct3DDeviceIMContext->VSSetConstantBuffers(…);
break;
…}

添加Uniform控制参数

添加参数

因为要向View添加一个全局uniform,所以在SceneView.h添加一个VIEW_UNIFORM_BUFFER_MEMBER。
addMember

更新参数值

可以借用SkyLightComponent和Proxy来存放自定义的uniform,在SetupUniformBufferParameters()中可以把用户编辑过的值传到ViewUniform。
SceneRendering.cpp:
attachSky

添加一个新的UPROPERTY”MyCustomClobalUniform”到SceneManagement.h
p1

添加相应的组件变量在SkyLightSceneProxy中——“MyCustomGlobalUniform”。在代理的构造函数中,复制SkyLightComponent的值。
SceneManagement.h:
p2 p3

在着色器代码中访问参数

在.ush或.usf文件或材质编辑器的Custom节点中都可以使用View.TestConstantUniform来调用该数值了,并且可以在天光组件的参数面板中修改该数值进行全局控制。
p4 p5

创建材质节点

添加节点类

在引擎的\Source\Runtime\Engine\Classes\Materials 路径下存放了材质编辑器的所有节点源文件。每个节点都有自己的UMaterialExpression类。所以同样在此路径创建一个头文件MaterialExpressionMyGlobalUniform.h。因为此案例只是一个向量数据,可以拷贝DeltaTime的节点。
确保添加完头文件后重新生成sln,这样可以自动生成.generated.h文件,否则会编译失败。
p6

然后在MaterialExpressions.cpp中实现它的构造函数
p7

节点的编译器设置

在MaterialCompiler.h中添加MyCustomUniform()方法。
p8 p9

在HLSLMaterialTranslator.h中,才是MaterialCompiler真正起作用的地方。调用AddInlinedCodeChunk(DATA_TYPE, CODE_STRING)来实现。
p10

测试

保存代码,重新成成sln工程,编译并打开引擎。
p11

总要恰饭的嘛