Zack.Zhang Game Developer

虚幻4自定义Shader教程(翻译)

2021-11-21
zack.zhang

原文:《Unreal Engine 4 Custom Shaders Tutorial》

材质编辑器是一个很棒的工具,因为它基于节点的系统,美术人员也可以创建着色器。然而,它也是有局限性的。例如,你不能创建如循环和switch语句之类的东西。

幸运的是,你可以通过编写自己的代码来绕过这些限制。为此,可以创建一个自定义节点,该节点将允许你编写HLSL代码。

在本教程中,你将学习如何:

  • 创建自定义节点,并设置其输入。

  • 将材质节点转换为HLSL。

  • 使用外部的文本编辑器来编辑Shader文件。

  • 创建HLSL函数

为了演示这些,将使用HLSL对场景图像进行去饱和,输出不同的场景纹理,并创建高斯模糊效果。

注意:本教程假设你已经知道使用虚幻引擎的基础知识。如果你是虚幻引擎的新手,你应该首先回顾一下我们前面的10部分虚幻引擎初学者教程系列。

本教程还假定你熟悉类C语言,如C++或C语言。如果你知道一种语法类似的语言,比如Java,那么你应该也能够理解。

注意:本教程是虚幻引擎着色器教程系列的四个部分中的一部分:
第一部分:卡通渲染(Cel-Shading)
第二部分:卡通描边(Toon Outlines)
第三部分:使用HLSL定制着色器(你在这儿)
第四部分:油画滤镜

开始

下载工程(提取码:wb82),解压缩,进入CustomShadersStarter文件夹并打开工程CustomShaders.uproject。你将看到如下场景:

unreal-engine-shaders-00

首先,会使用HLSL对场景图像进行去饱和度。为此,需要在后期处理材质中创建并使用自定义节点。

创建自定义节点

进入Materials文件夹并打开PP_Desaturate。这是你将去编辑,以创建去饱和度效果的材质。

unreal-engine-shaders-01

首先,创建一个Custom节点。与其他节点一样,它可以有多个输入,但只有一个输出。

unreal-engine-shaders-02

接下来,确保已选择“Custom”节点,然后转到“细节”面板。你将看到以下内容:

unreal-engine-shaders-03

以下是每个属性的功能:

  • 代码:这是写HLSL代码的地方。

  • 输出类型:输出范围从单个值(CMOT浮点1)到四通道矢量(CMOT浮点4)。

  • 描述:显示在节点本身上的文本。这是命名“Custom”节点的好方法。将其设置为“Desaturate”

  • 输入:这是你可以添加和命名输入引脚的地方。你可以通过使用它们的名字,在代码里引用输入参数。将输入0的名称设置为“SceneTexture”

unreal-engine-shaders-04

要降低图像饱和度,把“代码”框中的文本替换为以下内容:

return dot(SceneTexture, float3(0.3,0.59,0.11));

注意:dot()是一个内部函数。这些是HLSL中内置的功能。如果你需要atan()或lerp()之类的函数,请先检查一下是否有该函数。

最后,像下面一样连接所有节点:

unreal-engine-shaders-05

总结:

  1. SceneTexture:后期处理输入0会输出当前像素的颜色值。

  2. Desaturate节点会获得颜色并将其去饱和度。然后会将结果输出到自发光颜色

点击应用,然后关闭PP_Desaturate。现在场景图像就被去掉饱和度了。

unreal-engine-shaders-06

你可能想知道去饱和度的代码时从哪里来的。当你使用材质节点时,它会转换为HLSL。如果查看生成的代码,可以找到相应的代码并复制粘贴它。这就是去饱和度代码怎么转换为HLSL的。

在下一节中,你将学习到如何将材质节点转换为HLSL。

将材质节点转换为HLSL

对于本教程,你将把SceneTexture节点转换为HLSL。这在以后创建高斯模糊的时候会非常有用。

首先,进入Maps文件夹,并打开GaussianBlur关卡。然后,回到Materials文件夹,打开PP_GaussianBlur

unreal-engine-shaders-07

虚幻会为所有对最终输出节点有影响的节点生成HLSL。在这个例子中,虚幻会为SceneTexture节点生成HLSL。

要查看整个材质的HLSL代码,请选择“窗口/着色器代码/HLSL代码”。选择这个会打开一个包含生成代码的单独窗口。

unreal-engine-shaders-08

注意:如果HLSL代码窗口为空,则需要在工具栏中启用实时预览unreal-engine-shaders-09

由于生成的代码有小几千行,因此很难查找。为了简化搜索,可以点击复制按钮,并将文本粘贴到文本编辑器中(我使用Notepad++)。然后关闭HLSL代码窗口。

现在,你需要找到SceneTexture代码的位置。最简单的方法是找到CalcPixelMaterialInputs()的定义。此函数用于引擎计算所有材质的输出。如果查看函数的底部,你将看到每个输出的最终值:

PixelMaterialInputs.EmissiveColor = Local1;
PixelMaterialInputs.Opacity = 1.00000000;
PixelMaterialInputs.OpacityMask = 1.00000000;
PixelMaterialInputs.BaseColor = MaterialFloat3(0.00000000,0.00000000,0.00000000);
PixelMaterialInputs.Metallic = 0.00000000;
PixelMaterialInputs.Specular = 0.50000000;
PixelMaterialInputs.Roughness = 0.50000000;
PixelMaterialInputs.Anisotropy = 0.00000000;
PixelMaterialInputs.Tangent = MaterialFloat3(1.00000000,0.00000000,0.00000000);
PixelMaterialInputs.Subsurface = 0;
PixelMaterialInputs.AmbientOcclusion = 1.00000000;
PixelMaterialInputs.Refraction = 0;
PixelMaterialInputs.PixelDepthOffset = 0.00000000;
PixelMaterialInputs.ShadingModel = 0;

由于这是一个后期处理材质,你只需要关心自发光颜色。如你所见,他的值是Loacal1的值。变量LocalX是函数用于存储中间值的局部变量。如果查看这些给输出赋值的代码的正上方,你会看到引擎如何计算每个局部变量的。

MaterialFloat4 Local0 = SceneTextureLookup(GetDefaultSceneTextureUV(Parameters, 14), 14, false);
MaterialFloat3 Local1 = lerp((float4(View.OneOverPreExposure.xxx, 1) * Local0).rgba.rgb,Material.VectorExpressions[1].rgb,MaterialFloat(Material.ScalarExpressions[0].x));

最后一个局部变量(本例中是Lacal1)通常是一个“虚拟”计算,因此你可以忽略它。这意味着SceneTextureLookup()是SceneTexture节点的函数。

现在已经有了正确的函数,让我们测试一下。

使用SceneTextureLookup函数

首先,参数的作用是什么?这是SceneTextureLookup()的声明:

float4 SceneTextureLookup(float2 UV, int SceneTextureIndex, bool bFiltered)

以下是每个参数的作用:

  • UV:要采样的UV位置。例如,(0.5, 0.5)的UV会采样中间的像素。

  • SceneTextureIndex:这用来指定采样哪个场景纹理。可以从下面的表里查找到每个场景纹理以及它的索引值。例如,要对后期处理输入0进行采样,可以使用14作为索引。

  • Filtered:场景纹理是否应使用双线性过滤。通常设置为false。

unreal-engine-shaders-10

你可以输出“场景法线”来测试SceneTextureLookup()函数。转到“材质编辑器”(material editor)并创建名为Gaussian BlurCustom节点。然后在代码字段中输入以下内容:

return SceneTextureLookup(GetDefaultSceneTextureUV(Parameters, 8), 8, false);

这会输出当前像素的“场景法线”。GetDefaultSceneTextureUV()会获取当前像素的UV。

注意:在4.19之前,您可以通过使用TextureCoordinate节点作为输入来获得UV。在4.19中,正确的方法是使用GetDefaultSceneTextureUV()并提供想要的索引。

接下来,断开SceneTexture节点的连接。然后,连接Gaussian Blur节点到自发光颜色上去,并且点击应用

unreal-engine-shaders-11

此时,你会看到以下错误:

[SM5] /Engine/Generated/Material.ush(2023,8-76):  error X3004: undeclared identifier 'SceneTextureLookup'

这说明材质中不存在SceneTextureLookup()。那么,为什么它在使用“SceneTexture”节点时有效,而在“Custom”节点中无效呢?使用SceneTexture时,编译器将包含SceneTextureLookup()的定义。由于未使用该函数节点,因此无法使用该函数。

幸运的是,解决这个问题很容易。将SceneTexture节点设置为与正在采样的纹理相同的纹理。在本例中,将其设置为场景法线

unreal-engine-shaders-12

注意:在截至写该文章时,引擎存在着一个bug,如果场景纹理不相同,编辑器将会崩溃。但是只要它运行了,你可以自由地修改“Custom”节点中的场景纹理。

现在,编译器会包含SceneTextureLookup()的定义了。

点击应用,然后回到主编辑器界面。你现在会看到每一个像素的场景法线了。

unreal-engine-shaders-13

目前来看,“Custom”节点中的代码还没有太大问题,因为使用的都是小片段。但是,如果代码开始变长,就很难维护了。

要改进工作流,虚幻允许你包含外部Shader文件。有了它,你就可以在自己的文本编辑器中编写代码了,然后切换回虚幻引擎进行编译。

使用外部Shader文件

首先,需要创建一个“Shaders”文件夹。在“Custom”节点中使用#include指令时,虚幻会去这个文件夹里查找。

打开工程文件夹,并且创建一个新的叫做“Shaders”的文件夹。现在,工程文件夹应该看起来是这样的:

unreal-engine-shaders-14

接下来,进入Shaders文件夹,并且创建一个新的文件,命名为Gaussian.usf。这是你的Shader文件:

unreal-engine-shaders-15

注意:Shader文件的后缀名必须是“.usf”或者“.ush”

在文本编辑器中打开Gaussian.usf,插入下面的代码。确保更改后保存过该文件了。

return SceneTextureLookup(GetDefaultSceneTextureUV(Parameters, 2), 2, false);

这与之前的代码相同,但将改为输出漫反射颜色

要让虚幻检测到新的文件夹和Shader,需要重启编辑器。重启后,确保处于GaussianBlur关卡中。然后重新打开PP_GaussianBlur,像下面一样替换Gaussian Blur中的代码:

#include "/Project/Gaussian.usf"
return 1;

现在,当你编译时,编译器将用Gaussian.usf的内容替换第一行。请注意,你不需要用项目名称替换“Project”(C++项目)(如果是蓝图项目,需要复制绝对路径而不是写“Project”,或者之间放在Engine/Shaders下)。

点击应用,然后回到主编辑器界面。你会看到漫反射颜色而不再是场景发现了。

unreal-engine-shaders-16

现在,所有事情都设置好了,要做Shader开发也很容易了。现在是时候创建高斯模糊了。

注意:因为这不是高斯模糊的教程,所以我不会花太多时间解释它。如果你想了解更多,请查看高斯平滑《Gaussian Smoothing》《Calculating Gaussian Kernels》

创建高斯模糊

就像在卡通描边教程中一样,此效果使用卷积。最终输出是卷积核中所有像素的平均值。

在典型的方形模糊中,每个像素具有相同的权重。这会导致在更宽的模糊处出现重影。高斯模糊通过在像素离中心越远时减小像素的权重来避免这种情况。这使得中心像素更加重要。

unreal-engine-shaders-01

由于需要的采样数太多,使用材质节点连接出卷积并不理想。例如,在5×5的卷积核中,你需要采样25次。将尺寸加倍到10×10的卷积核,将增加到100次采样。此时,节点图看起来就像是一碗意大利面。

这就是“Custom”节点的由来。使用它,你可以编写一个小的for循环,采样卷积核中的每个像素。第一步是设置一个参数来控制采样半径。

创建Radius(半径)参数

首先,返回到材质编辑器并创建一个名为“Radius”(半径)的新ScalarParameter。将其默认值设置为1

unreal-engine-shaders-17

半径决定了图像的模糊程度。

接下来,为Gaussian Blur创建一个新输入,并将其命名为Radius。然后,创建一个Round节点并按如下方式连接所有内容:

unreal-engine-shaders-18

Round节点,即四舍五入,是为了确保卷积核维度始终是整数。

现在是开始编码的时候了!由于每个像素需要计算两次高斯分布(垂直和水平偏移),因此最好将其转换为函数。

使用“Custom”节点时,不能以标准方式创建函数。这是因为编译器会复制粘贴你的代码到函数中。由于无法在函数内部再去定义函数,因此将收到一个错误。

幸运的是,你可以利用编译器这种复制粘贴行为来创建全局函数。

创建全局函数

如上所述,编译器会将自定义节点中的文本复制粘贴到函数中。因此,如果您有以下情况:

return 1;

编译器会把它粘贴到CustomExpressionX函数中,甚至没有缩进!

MaterialFloat3 CustomExpression0(FMaterialPixelParameters Parameters)
{
return 1;
}

看看如果改用此代码会发生什么:

    return 1;
}

float MyGlobalVariable;

int MyGlobalFunction(int x)
{
    return x;

生成的HLSL现在变为:

MaterialFloat3 CustomExpression0(FMaterialPixelParameters Parameters)
{
    return 1;
}

float MyGlobalVariable;

int MyGlobalFunction(int x)
{
    return x;
}

如您所见,MyGlobalVariable和MyGlobalFunction()并不会包含在函数中。这使它们的作用域变成了全局,意味着你可以在任何地方使用它们。

注意:输入代码中缺少最后一个大括号。这一点很重要,因为编译器会在末尾插入大括号。如果您在大括号中离开,你将得到两个大括号并收到一个错误。

现在让我们使用这个行为来创建高斯函数。

创建高斯函数

简化的一维高斯函数为:

unreal-engine-shaders-19

这会产生一条钟形曲线,该曲线接受的输入范围约为-1到1。然后它会输出一个从0到1的值。

unreal-engine-shaders-20

在本教程中,你将把高斯函数放入一个单独的“Custom”节点中。创建新的Custom节点并将其命名为Global

然后,将代码字段中的文本替换为以下内容:

    return 1;
}

float Calculate1DGaussian(float x)
{
    return exp(-0.5 * pow(3.141 * (x), 2));

Calculate1DGaussian()是简化的一维高斯编码表。

要使此功能可用,需要在材质图中的某个位置使用Global节点。实现这一点最简单的方法是简单地将Global节点与图中的第一个节点相乘。这样可以确保在其他“Custom”节点中使用全局函数之前,先定义它们。

首先,将Global节点的输出类型设置为CMOT浮点4。需要这样做,是因为你将用它与SceneTexture节点相乘,SceneTexture节点是一个float4

unreal-engine-shaders-21

下一步,创建一个Multiply节点,并按如下方式连接所有内容:

unreal-engine-shaders-22

点击应用来编译。现在,所有后续的“Custom”节点都可以使用在Global中定义的函数。

下一步是创建for循环,对卷积核中的每个像素进行采样。

采样多个像素

打开Gaussian.usf并用以下代码替换代码:

static const int SceneTextureId = 14;
float2 TexelSize = View.ViewSizeAndInvSize.zw;
float2 UV = GetDefaultSceneTextureUV(Parameters, SceneTextureId);
float3 PixelSum = float3(0, 0, 0);
float WeightSum = 0;

以下是每个变量的用途:

  • SceneTextureId:保存了要采样的场景纹理的索引。这样就不必将索引硬编码到函数调用中。在本例中,索引用于后期处理输入0

  • TexelSize:保存了texel的大小。用于将偏移转换为UV空间。

  • UV:当前像素的UV。

  • PixelSum:用作卷积核中每个像素颜色的和。

  • WeightSum:用作卷积核中每个像素权重的和。

接下来,需要创建两个for循环。一个用于垂直偏移,一个用于水平偏移。添加以下代码到变量列表下面:

for (int x = -Radius; x <= Radius; x++)
{
    for (int y = -Radius; y <= Radius; y++)
    {

    }
}

从概念上讲,这将创建一个以当前像素为中心的网格,尺寸为2×r+1。例如,如果半径为2,则尺寸将为(2×2+1)乘以(2×2+1),也就是5×5

接下来,需要累加像素颜色和权重。为此,请在for循环内部添加以下内容:

float2 Offset = UV + float2(x, y) * TexelSize;
float3 PixelColor = SceneTextureLookup(Offset, SceneTextureId, 0).rgb;
float Weight = Calculate1DGaussian(x / Radius) * Calculate1DGaussian(y / Radius);
PixelSum += PixelColor * Weight;
WeightSum += Weight;

以下是每一行的功能:

  1. 计算采样像素的相对偏移,并将其转换为UV空间。

  2. 使用偏移对“SceneTexture”进行采样(在本例中为后期处理输入0)。

  3. 计算采样像素的权重。要计算二维高斯分布,只需将两个一维高斯分布相乘即可。需要除以Radius的原因是,简化的高斯函数的期望值为-1到1。此除法会将x和y归一化到此范围内。

  4. 将加权颜色累加到PixelSum

  5. 将权重累加到WeightSum

最后,你需要计算结果,即加权平均值。为此,请在文件末尾(for循环外部)添加以下内容:

return PixelSum / WeightSum;

这就是高斯模糊!关闭Gaussian.usf,然后返回材质编辑器。点击应用,然后关闭PP_GaussianBlur。使用PPI_Blur来测试不同模糊半径的差异。

unreal-engine-shaders-02

注意:有时应用按钮将被禁用。只需进行虚拟更改(例如移动节点),它就会重新激活。

局限性

尽管“Custom”节点非常强大,但它也有其缺点。在本节中,我将介绍使用它时的一些限制和注意事项。

渲染访问能力

“Custom”节点无法访问渲染管道的许多部分。包括灯光信息和运动矢量等内容。请注意,这与使用前向渲染时略有不同。

引擎版本兼容性

在一个版本的虚幻引擎中编写的HLSL代码不能保证在另一个版本中能正常工作。如本教程中所述,在4.19之前,你可以使用TextureCoordinate获取场景纹理UV。在4.19中,你需要使用GetDefaultSceneTextureUV()。

优化

以下是Epic关于优化的摘录:

自定义节点会阻止常量叠算(Constant folding)并可能产生较内置节点更多的指令调用。常量叠算是UE4中降低着色器指令调用的一种优化方式。 例如:一个表达式链Time >Sin >Mul by parameter > Add,将会被UE4塌陷成一个指令 这是可能的,因为所有的表达式输入(Time, parameter)对于本次draw call来说是一个常量,它们并不随像素而改变。然而UE4无法在自定义节点中折叠它们,这样就无法达到和内置节点同样的效率。 所以最佳实践就是,只有当你的已有的节点实在无法满足你的需要时再使用自定义节点。

接下来干什么?

如果你想从“Custom”节点获得更多信息,我建议您查看Ryan Bruck的博客。他在文章中详细介绍了如何使用“Custom”节点创建raymarchers和其他效果。


Similar Posts

下一篇 GPU架构和渲染

评论