Post

自定义渲染管线:Draw Calls、Shaders、 Batches (翻译二)

自定义渲染管线:Draw Calls、Shaders、 Batches (翻译二)
  • 编写一个 HLSL shader
  • 支持 SRP batcher、GPU instancing 以及 dynamic batching
  • 为每个对象配置材质属性,并随机绘制多个对象
  • 创建透明(transparent)和镂空(cutout)材质

Shaders

为了绘制物体,CPU 必须告诉 GPU 绘制什么以及如何绘制。绘制的内容通常是一个 mesh。如何绘制则由 shader 定义,它是一组供 GPU 执行的指令。除了 mesh 之外,shader 还需要额外的信息来完成工作,包括物体的变换矩阵(transformation matrices)和材质属性。

Unity 的 LW/Universal 和 HD RP 允许你使用 Shader Graph package 来设计 shader,它会为你生成 shader 代码。但我们的自定义 RP 不支持该功能,因此我们必须亲手编写 shader 代码。这让我们能够完全控制并理解 shader 的工作原理。

Unlit Shader

我们的第一个 shader 将简单地以纯色绘制 mesh,不包含任何光照。可以通过 Assets / Create / Shader 菜单中的选项之一创建 shader asset。 Unlit Shader 是最合适的选择,但我们将从头开始,删除创建的 shader 文件中所有的默认代码。将该 asset 命名为 Unlit ,并将其存放在 Custom RP 下新建的 Shaders 文件夹中。

Unlit shader asset.
Unlit shader asset.

Shader 的定义类似于一个类,但只需使用 Shader 关键字,后跟一个字符串,该字符串用于在材质的 Shader 下拉菜单中为其创建一个条目。让我们使用 Custom RP/Unlit 。紧随其后的是一个代码块,其中包含更多带有关键字的前缀块。有一个 Properties 块用于定义材质属性,接着是一个 SubShader 块,其中必须包含一个 Pass 块,用于定义一种渲染方式。请创建该结构,内部块暂时保持为空。

1
2
3
4
5
6
7
8
9
Shader "Custom RP/Unlit" {

    Properties {}

    SubShader 
    {
        Pass {}
    }
}

这定义了一个最小化的 Shader,它可以编译并允许我们创建一个使用它的材质。

自定义 Unlit 材质
自定义 Unlit 材质

默认的 Shader 实现会将网格渲染为纯白色。材质显示了渲染队列(render queue)的默认属性,它是从 Shader 中自动获取的,并被设置为 2000,这是不透明几何体的默认值。它还有一个启用双面全局光照(double-sided global illumination)的开关,但这对我们来说并不重要。

HLSL Programs

用于编写 shader 代码的语言是高级着色语言(High-Level Shading Language),简称 HLSL。我们需要将其放置在 Pass 代码块中,位于 HLSLPROGRAM 和 ENDHLSL 关键字之间。这样做是因为在 Pass 代码块中也可以放置其他非 HLSL 代码。

1
2
3
4
Pass {
    HLSLPROGRAM
    ENDHLSL
}

为了绘制 mesh,GPU 必须对其所有三角形进行光栅化,将其转换为像素数据。它通过将顶点坐标从 3D 空间变换到 2D 可视化空间,然后填充被所得三角形覆盖的所有像素来实现这一点。这两个步骤由两个独立的 shader 程序控制,我们必须同时定义它们。第一个被称为顶点内核/程序/着色器(vertex kernel/program/shader),第二个被称为片元内核/程序/着色器(fragment kernel/program/shader)。一个片元(fragment)对应一个显示像素或纹理纹素(texel),尽管它可能不代表最终结果,因为稍后当有东西绘制在它上面时,它可能会被覆盖。

1
2
3
4
HLSLPROGRAM
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment
ENDHLSL

shader 编译器现在会提示找不到声明的 shader kernels。我们需要编写同名的 HLSL 函数来定义它们的实现。我们可以直接在 pragma 指令下方编写,但我们将把所有 HLSL 代码放在一个单独的文件中。具体来说,我们将在同一个 asset 文件夹中使用一个 UnlitPass.hlsl 文件。我们可以通过添加一个带有文件相对路径的 #include 指令,来指示 shader 编译器插入该文件的内容。

1
2
3
4
HLSLPROGRAM
...
#include "UnlitPass.hlsl"
ENDHLSL

Unity 没有创建 HLSL 文件的便捷菜单选项,因此你必须执行类似以下的操作:复制 shader 文件,将其重命名为 UnlitPass ,在外部将其文件扩展名更改为 hlsl 并清空其内容。

UnlitPass HLSL asset file.
UnlitPass HLSL asset file.

Include Guard

HLSL 文件用于对代码进行分组,就像 C# 类一样,尽管 HLSL 并没有类的概念。除了代码块的局部作用域外,只有一个全局作用域。因此,所有内容在任何地方都是可以访问的。包含(include)一个文件也不等同于使用命名空间。它会在 include 指令所在的位置插入文件的全部内容,所以如果你多次包含同一个文件,就会得到重复的代码,这极有可能导致编译错误。为了防止这种情况,我们将为 UnlitPass.hlsl 添加一个 include guard

可以使用 #define 指令来定义任何标识符,通常使用大写字母。我们将使用它在文件顶部定义 CUSTOM_UNLIT_PASS_INCLUDED

1
2
3
#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED
#endif

如果宏已经被定义,那么 #ifndef 之后的所有代码都将被跳过,从而不会被编译。我们必须在文件末尾添加 #endif 指令来结束其作用域。

Shader Functions

我们在包含保护(include guard)的作用域内定义 shader 函数。它们的写法就像没有访问修饰符的 C# 方法。先从什么都不做的简单 void 函数开始。

1
2
3
4
5
6
7
8
#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED

void UnlitPassVertex () {}

void UnlitPassFragment () {}

#endif

为了生成有效的输出,我们必须让 fragment 函数返回一个颜色。该颜色由一个包含红、绿、蓝和 alpha 分量的四分量 $float4$ 向量定义。我们可以通过 $float4(0.0, 0.0, 0.0, 0.0)$ 来定义纯黑色,但也可以只写一个零,因为单个数值会自动扩展为完整的向量。由于我们正在创建一个不透明的 shader,因此 alpha 值并不重要,写零即可。

此时着色器编译器会报错,因为我们的函数缺少语义(semantics)。我们必须指明返回值的含义,因为我们可能会产生许多具有不同含义的数据。在这种情况下,我们提供渲染目标的默认系统值,方法是在 UnlitPassFragment 的参数列表后写一个冒号,后跟 SV_TARGET。

1
2
3
float4 UnlitPassFragment () : SV_TARGET {
	return 0.0;
}

UnlitPassVertex 负责变换顶点位置,因此应该返回一个位置。这同样是一个 $float4$ 向量,因为它必须被定义为齐次裁剪空间位置,我们稍后会详细介绍。我们再次从零向量开始,在这种情况下,我们必须指明其含义为 SV_POSITION

1
2
3
float4 UnlitPassVertex () : SV_POSITION {
	return 0.0;
}

Space Transformation

当所有顶点都被设置为$0$时,mesh 会塌陷为一个点,并且不会渲染任何内容。vertex function 的主要任务是将原始顶点位置转换到正确的空间。调用该函数时,如果我们提出请求,它将获得可用的顶点数据。我们通过向 UnlitPassVertex 添加参数来实现这一点。我们需要在对象空间(object space)中定义的顶点位置,因此我们将其命名为 positionOS,采用与 Unity 新 RP 相同的命名约定。该位置的类型是 $float3$,因为它是一个 3D 点。让我们最初先返回它,并通过 $float4(positionOS, 1.0)$ 添加 1 作为所需的第四个分量。

1
2
3
float4 UnlitPassVertex (float3 positionOS) : SV_POSITION {
	return float4(positionOS, 1.0);
}

我们还需要为输入添加语义,因为顶点数据可以包含的不仅仅是位置。在这种情况下,我们需要在参数名称后直接加上一个冒号来添加 POSITION

1
2
3
float4 UnlitPassVertex (float3 positionOS : POSITION) : SV_POSITION {
	return float4(positionOS, 1.0);
}
对象空间位置
对象空间位置

网格再次显示出来,但这是错误的,因为我们输出的位置是在错误的空间中。空间转换需要矩阵,这些矩阵在绘制时会被发送到 GPU。我们必须将这些矩阵添加到我们的着色器中,但由于它们总是相同的,我们将 Unity 提供的标准输入放在一个单独的 HLSL 文件中,既是为了保持代码结构化,也是为了能够将代码包含在其他着色器中。添加一个 UnityInput.hlsl 文件,并将其放在直接位于 Custom RP 下的 ShaderLibrary 文件夹中,以镜像 Unity 资源包的文件夹结构。

ShaderLibrary 文件夹与 UnityInput 文件
ShaderLibrary 文件夹与 UnityInput 文件

文件开头先编写一个 include guard CUSTOM_UNITY_INPUT_INCLUDED ,然后在全局作用域内定义一个名为 unity_ObjectToWorld 的 $float4x4$ 矩阵。在 C# 类中这相当于定义一个字段,但在着色器中它被称为 uniform 值。它由 GPU 在每次 draw 时设置一次,并在该次 draw 期间的所有 vertex 和 fragment 函数调用中保持不变(即 uniform,统一的)。

1
2
3
4
5
6
#ifndef CUSTOM_UNITY_INPUT_INCLUDED
#define CUSTOM_UNITY_INPUT_INCLUDED

float4x4 unity_ObjectToWorld;

#endif

我们可以使用矩阵将坐标从对象空间转换到世界空间。由于这是常见功能,让我们为此创建一个函数,并将其放在另一个文件中,这次是放在同一个 ShaderLibrary 文件夹的 Common.hlsl 文件里。我们在其中包含 UnityInput ,然后声明一个 TransformObjectToWorld 函数,将 float3 作为输入和输出。

1
2
3
4
5
6
7
8
9
10
#ifndef CUSTOM_COMMON_INCLUDED
#define CUSTOM_COMMON_INCLUDED

#include "UnityInput.hlsl"

float3 TransformObjectToWorld (float3 positionOS) {
	return 0.0;
}
	
#endif

空间转换是通过调用带有矩阵和向量的 mul 函数来完成的。在这种情况下,我们确实需要一个 4D 向量,但由于其第四个分量始终为 1,我们可以通过使用 $float4(positionOS, 1.0)$ 自行添加。结果同样是一个第四分量始终为 1 的 4D 向量。我们可以通过访问向量的 xyz 属性从中提取前三个分量,这被称为 swizzle 操作。

1
2
3
float3 TransformObjectToWorld (float3 positionOS) {
	return mul(unity_ObjectToWorld, float4(positionOS, 1.0)).xyz;
}

我们现在可以在 UnlitPassVertex 中转换到世界空间。首先在函数正上方包含 Common.hlsl 。由于它存在于不同的文件夹中,我们可以通过相对路径 ../ShaderLibrary/Common.hlsl 访问它。然后使用 TransformObjectToWorld 计算 positionWS 变量,并返回它以替代对象空间位置。

1
2
3
4
5
6
#include "../ShaderLibrary/Common.hlsl"

float4 UnlitPassVertex (float3 positionOS : POSITION) : SV_POSITION {
	float3 positionWS = TransformObjectToWorld(positionOS.xyz);
	return float4(positionWS, 1.0);
}

结果仍然错误,因为我们需要一个在齐次裁剪空间中的位置。这个空间定义了一个立方体,其中包含相机视野内的一切,在透视相机的情况下,它会变形为梯形。从世界空间转换到这个空间可以通过乘以视图投影矩阵来完成,该矩阵考虑了相机的位置、方向、投影、视场和近远裁剪平面。unity_ObjectToWorld矩阵已经提供,所以将其添加到UnityInput.hlsl中。

1
2
3
float4x4 unity_ObjectToWorld;

float4x4 unity_MatrixVP;

向 Common.hlsl 添加一个 TransformWorldToHClip ,其工作原理与 TransformObjectToWorld 相同,不同之处在于其输入位于世界空间(world space),使用另一个矩阵,并生成一个 float4 。

1
2
3
4
5
6
7
float3 TransformObjectToWorld (float3 positionOS) {
	return mul(unity_ObjectToWorld, float4(positionOS, 1.0)).xyz;
}

float4 TransformWorldToHClip (float3 positionWS) {
	return mul(unity_MatrixVP, float4(positionWS, 1.0));
}

让 UnlitPassVertex 使用该函数返回正确空间中的位置。

1
2
3
4
float4 UnlitPassVertex (float3 positionOS : POSITION) : SV_POSITION {
	float3 positionWS = TransformObjectToWorld(positionOS.xyz);
	return TransformWorldToHClip(positionWS);
}
修正黑色球体
修正黑色球体

我们刚刚定义的这两个函数非常常用,因此它们也被包含在 Core RP Pipeline package 中。核心库定义了更多有用且必不可少的内容,所以让我们安装该 package,删除我们自己的定义,并改为包含相关文件,在本例中是 Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl

1
2
3
4
5
6
7
8
9
//float3 TransformObjectToWorld (float3 positionOS) {
//	return mul(unity_ObjectToWorld, float4(positionOS, 1.0)).xyz;
//}

//float4 TransformWorldToHClip (float3 positionWS) {
//	return mul(unity_MatrixVP, float4(positionWS, 1.0));
//}

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"

这会导致编译失败,因为 SpaceTransforms.hlsl 中的代码并不假定 unity_ObjectToWorld 存在。相反,它期望相关的矩阵通过宏定义为 UNITY_MATRIX_M,所以让我们在包含文件之前,在单独的一行编写 #define UNITY_MATRIX_M unity_ObjectToWorld 来实现这一点。之后,所有出现的 UNITY_MATRIX_M 都会被替换为 unity_ObjectToWorld。这样做是有原因的,我们稍后会发现。

1
2
3
#define UNITY_MATRIX_M unity_ObjectToWorld

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"

对于逆矩阵 unity_WorldToObject 也是如此,它应该通过 UNITY_MATRIX_I_M 定义;unity_MatrixV 矩阵通过 UNITY_MATRIX_V 定义;而 unity_MatrixVP 通过 UNITY_MATRIX_VP 定义。最后,还有通过 UNITY_MATRIX_P 定义的投影矩阵,它以 glstate_matrix_projection 的形式提供。我们不需要这些额外的矩阵,但如果不包含它们,代码将无法编译。

1
2
3
4
5
#define UNITY_MATRIX_M unity_ObjectToWorld
#define UNITY_MATRIX_I_M unity_WorldToObject
#define UNITY_MATRIX_V unity_MatrixV
#define UNITY_MATRIX_VP unity_MatrixVP
#define UNITY_MATRIX_P glstate_matrix_projection

Unity 2022 需要额外的三个矩阵。

1
2
3
4
5
6
7
8
#define UNITY_MATRIX_M unity_ObjectToWorld
#define UNITY_MATRIX_I_M unity_WorldToObject
#define UNITY_MATRIX_V unity_MatrixV
#define UNITY_MATRIX_VP unity_MatrixVP
#define UNITY_MATRIX_P glstate_matrix_projection
#define UNITY_MATRIX_I_V unity_MatrixInvV
#define UNITY_PREV_MATRIX_M unity_prev_MatrixM
#define UNITY_PREV_MATRIX_I_M unity_prev_MatrixIM

也将额外的矩阵添加到 UnityInput 。

1
2
3
4
5
6
7
8
9
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;

float4x4 unity_MatrixVP;
float4x4 unity_MatrixV;
float4x4 unity_MatrixInvV;
float4x4 unity_prev_MatrixM;
float4x4 unity_prev_MatrixIM;
float4x4 glstate_matrix_projection;

最后缺失的是一个非矩阵的项。它是 unity_WorldTransformParams,其中包含了一些我们在这里同样不需要的变换信息。它是一个定义为 real4 的向量,这本身不是一个有效的类型,而是根据目标平台指向 float4 或 half4 的别名。

1
2
3
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
real4 unity_WorldTransformParams;

该别名和许多其他基础宏是根据图形 API 定义的,我们可以通过包含 Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl 来获取所有这些内容。在包含 UnityInput.hlsl 之前,在我们的 Common.hlsl 文件中执行此操作。如果你对这些文件的内容感到好奇,可以在导入的 package 中检查它们。

1
2
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "UnityInput.hlsl"

颜色

可以通过调整 UnlitPassFragment 来更改渲染对象的颜色。例如,我们可以通过返回 $float4(1.0, 1.0, 0.0, 1.0)$ 而不是零来使其变为黄色。

1
2
3
float4 UnlitPassFragment () : SV_TARGET {
	return float4(1.0, 1.0, 0.0, 1.0);
}
黄色球体
黄色球体

为了能够按材质配置颜色,我们必须将其定义为统一值。在 include 指令下方,UnlitPassVertex 函数之前执行此操作。我们需要一个 float4,并将其命名为 _BaseColor。前导下划线是表示材质属性的标准方式。在 UnlitPassFragment 中返回此值,而不是硬编码的颜色。

1
2
3
4
5
6
7
8
9
10
11
12
#include "../ShaderLibrary/Common.hlsl"

float4 _BaseColor;

float4 UnlitPassVertex (float3 positionOS : POSITION) : SV_POSITION {
	float3 positionWS = TransformObjectToWorld(positionOS);
	return TransformWorldToHClip(positionWS);
}

float4 UnlitPassFragment () : SV_TARGET {
	return _BaseColor;
}

我们又回到了黑色,因为默认值为零。要将其链接到材质,我们必须将 _BaseColor 添加到 Unlit shader中的 Properties 块。

1
2
3
Properties {
    _BaseColor
}

属性名称后必须跟着一个用于检查器的字符串和一个 Color 类型标识符,就像为方法提供参数一样。

1
_BaseColor("Color", Color)

最后,我们必须提供一个默认值,在本例中是为其分配一个包含四个数字的列表。我们使用白色。

1
_BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
红色无光材质
红色无光材质

现在可以使用我们的着色器创建多个材质,每个材质都有不同的颜色。

批处理

每次绘制调用都需要 CPU 和 GPU 之间的通信。如果大量数据必须发送到 GPU,那么它可能会因为等待而浪费时间。当 CPU 忙于发送数据时,它无法执行其他操作。这两个问题都可能降低帧率。目前我们的方法很简单:每个对象都有自己的绘制调用。这是最糟糕的做法,尽管我们最终发送的数据量很少,所以目前还可以。

举个例子,我创建了一个包含76个球体的场景,每个球体使用四种材质之一:红色、绿色、黄色和蓝色。它需要78次绘制调用来渲染,其中76次用于球体,一次用于天空盒,一次用于清除渲染目标。

76个球体,78次绘制调用
76个球体,78次绘制调用

如果你打开 Stats 窗口的 Game 面板,你就可以看到渲染帧所需的概览。这里有趣的事实是,它显示了 77 个批次——忽略清除——其中零个通过批处理节省。

游戏窗口统计。
游戏窗口统计。

SRP Batcher

批处理是合并绘制调用的过程,减少 CPU 和 GPU 之间通信所花费的时间。最简单的方法是启用 SRP batcher。然而,这只适用于兼容的着色器,而我们的 Unlit 着色器不兼容。你可以在 Inspector 中选择它来验证。有一行 SRP Batcher 指示不兼容,下面给出了一个原因。

不兼容。
不兼容。

SRP 批处理并没有减少绘制调用的数量,而是使其更精简。它在 GPU 上缓存材质属性,这样就不必在每次绘制调用时都发送它们。这减少了必须通信的数据量以及 CPU 在每次绘制调用中必须完成的工作。但这仅在着色器遵循严格的统一数据结构时才有效。

所有材质属性都必须在具体的内存缓冲区中定义,而不是在全局级别。这是通过将_BaseColor声明包装在名为UnityPerMaterialcbuffer块中来完成的。这类似于结构体声明,但必须以分号结尾。它通过将_BaseColor放入特定的常量内存缓冲区来隔离它,尽管它仍然可以在全局级别访问。

1
2
3
cbuffer UnityPerMaterial {
	float _BaseColor;
};

常量缓冲区并非在所有平台(如 OpenGL ES 2.0)上都受支持,因此我们不直接使用cbuffer,而是可以使用我们从 Core RP Library 中包含的CBUFFER_STARTCBUFFER_END宏。第一个宏将缓冲区名称作为参数,就像它是一个函数一样。在这种情况下,我们得到的结果与之前完全相同,只是cbuffer代码不会存在于不支持它的平台上。

1
2
3
CBUFFER_START(UnityPerMaterial)
	float4 _BaseColor;
CBUFFER_END

我们还必须对 unity_ObjectToWorld、unity_WorldToObject 和 unity_WorldTransformParams 执行此操作,只是它们必须分组到 UnityPerDraw 缓冲区中。

1
2
3
4
5
CBUFFER_START(UnityPerDraw)
	float4x4 unity_ObjectToWorld;
	float4x4 unity_WorldToObject;
	real4 unity_WorldTransformParams;
CBUFFER_END

在这种情况下,如果使用其中一个值,则需要定义特定的值组。对于转换组,我们还需要包含 $float4 unity_LODFade$,即使我们不使用它。确切的顺序无关紧要,但 Unity 将其直接放在 unity_WorldToObject 之后,所以我们也这样做。

1
2
3
4
5
6
CBUFFER_START(UnityPerDraw)
	float4x4 unity_ObjectToWorld;
	float4x4 unity_WorldToObject;
	float4 unity_LODFade;
	real4 unity_WorldTransformParams;
CBUFFER_END
与 SRP batcher 兼容。
与 SRP batcher 兼容。

在我们的着色器兼容后,下一步是启用 SRP batcher,这通过将 GraphicsSettings.useScriptableRenderPipelineBatching 设置为 true 来完成。我们只需执行一次此操作,因此让我们在创建RP实例时执行此操作,方法是向 CustomRenderPipeline 添加一个构造函数。

1
2
3
public CustomRenderPipeline () {
    GraphicsSettings.useScriptableRenderPipelineBatching = true;
}
负批次已保存。
负批次已保存。

Stats 面板显示保存了 76 个批次,尽管它显示的是负数。帧调试器现在在 RenderLoopNewBatcher.Draw 下显示一个 SRP Batch 条目,但请记住,它不是一个单独的绘制调用,而是一系列经过优化的绘制调用。

一个 SRP 批次。
一个 SRP 批次。

多种颜色

尽管我们使用了四种材质,但我们只获得了一个批次。这是因为所有数据都缓存在 GPU 上,每个绘制调用只需包含一个指向正确内存位置的偏移量。唯一的限制是每种材质的内存布局必须相同,这在本例中是成立的,因为我们对所有材质都使用了相同的着色器,每个着色器只包含一个颜色属性。Unity 不会比较材质的精确内存布局,它只会批处理使用完全相同着色器变体的绘制调用。

如果我们想要几种不同的颜色,这种方法很有效,但如果想让每个球体都有自己的颜色,我们就不得不创建更多的材质。如果能按对象设置颜色会更方便。这在默认情况下是不可能的,但我们可以通过创建自定义组件类型来支持它。将其命名为 PerObjectMaterialProperties。由于它是一个示例,我将其放在 Examples 文件夹下的 Custom RP 中。

其理念是,一个游戏对象可以附加一个 PerObjectMaterialProperties 组件,该组件有一个 Base Color 配置选项,用于设置其 _BaseColor 材质属性。它需要知道着色器属性的标识符,我们可以通过 Shader.PropertyToID 检索并存储在一个静态变量中,就像我们在 CameraRenderer 中为着色器通道标识符所做的那样,尽管在这种情况下它是一个整数。

1
2
3
4
5
6
7
8
9
10
using UnityEngine;

[DisallowMultipleComponent]
public class PerObjectMaterialProperties : MonoBehaviour {
	
	static int baseColorId = Shader.PropertyToID("_BaseColor");
	
	[SerializeField]
	Color baseColor = Color.white;
}
PerObjectMaterialProperties 组件。
PerObjectMaterialProperties 组件。

通过 MaterialPropertyBlock 对象设置每个对象的材质属性。我们只需要一个所有 PerObjectMaterialProperties 实例都可以重用的对象,因此为其声明一个静态字段。

1
static MaterialPropertyBlock block;

创建一个 MaterialPropertyBlock,然后使用属性标识符和颜色在其上调用 SetColor,然后通过 SetPropertyBlock 将该块应用于游戏对象的 Renderer 组件,该方法会复制其设置。在 OnValidate 中执行此操作,以便结果立即显示在编辑器中。

1
2
3
4
5
6
7
void OnValidate () {
    if (block == null) {
        block = new MaterialPropertyBlock();
    }
    block.SetColor(baseColorId, baseColor);
    GetComponent<Renderer>().SetPropertyBlock(block);
}

我将该组件添加到了 24 个任意球体上,并给它们赋予了不同的颜色。

多种颜色。
多种颜色。

不幸的是,SRP batcher 无法处理每个对象的材质属性。因此,这 24 个球体各自回退到一个常规的绘制调用,并且由于排序,可能会将其他球体也分成多个批次。

24个非批处理绘制调用。
24个非批处理绘制调用。

此外,OnValidate 不会在构建中被调用。为了让单独的颜色出现在那里,我们还必须在 Awake 中应用它们,我们可以通过简单地在那里调用 OnValidate 来实现。

1
2
3
	void Awake () {
		OnValidate();
	}

GPU Instancing

还有另一种合并绘制调用的方法,它适用于每个对象的材质属性。这被称为 GPU 实例化,其工作原理是为多个具有相同网格的对象一次性发出一个绘制调用。CPU 收集所有每个对象的变换和材质属性,并将它们放入数组中,然后发送到 GPU。GPU 随后遍历所有条目,并按照提供的顺序渲染它们。

由于 GPU 实例需要通过数组提供数据,我们当前的着色器尚不支持它。要实现此功能,第一步是在着色器的 Pass 块中,在vertex和fragment前添加 #pragma multi_compile_instancing 指令。

1
2
3
#pragma multi_compile_instancing
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment

这将使 Unity 生成我们着色器的两个变体:支持 GPU instance和不支持GPU 实例。材质检查器中也出现了一个切换选项,允许我们为每个材质选择要使用的版本。

已启用 GPU 实例化的材质。
已启用 GPU 实例化的材质。

支持 GPU 实例化需要改变方法,为此我们必须从核心着色器库中包含 UnityInstancing.hlsl 文件。这必须在定义 UNITY_MATRIX_M 和其他宏之后、包含 SpaceTransforms.hlsl 之前完成。

1
2
3
4
#define UNITY_MATRIX_P glstate_matrix_projection

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"

UnityInstancing.hlsl 所做的是重新定义这些宏,以访问实例化数据数组。但要使其工作,它需要知道当前正在渲染的对象的索引。该索引通过顶点数据提供,因此我们必须使其可用。 UnityInstancing.hlsl 定义了宏来简化此操作,但它们假设我们的顶点函数有一个结构体参数。

可以声明一个 struct(就像 cbuffer 一样),并将其用作函数的输入参数。我们还可以在结构体内部定义语义。这种方法的优点是比长参数列表更具可读性。因此,将 UnlitPassVertex 的 positionOS 参数封装在一个 Attributes 结构体中,表示顶点输入数据。

1
2
3
4
5
6
7
8
struct Attributes {
	float3 positionOS : POSITION;
};

float4 UnlitPassVertex (Attributes input) : SV_POSITION {
	float3 positionWS = TransformObjectToWorld(input.positionOS);
	return TransformWorldToHClip(positionWS);
}

当使用 GPU 实例化时,对象索引也可以作为顶点属性使用。我们只需在Attributes中放入 UNITY_VERTEX_INPUT_INSTANCE_ID 即可在适当的时候添加它。

1
2
3
4
struct Attributes {
	float3 positionOS : POSITION;
	UNITY_VERTEX_INPUT_INSTANCE_ID
};

接下来,在 UnlitPassVertex 的开头添加 ` UNITY_SETUP_INSTANCE_ID(input); `。这会从输入中提取索引并将其存储在一个全局静态变量中,其他实例化宏都依赖于此变量。

1
2
3
4
5
float4 UnlitPassVertex (Attributes input) : SV_POSITION {
	UNITY_SETUP_INSTANCE_ID(input);
	float3 positionWS = TransformObjectToWorld(input.positionOS);
	return TransformWorldToHClip(positionWS);
}

这足以让 GPU 实例化工作,尽管 SRP batcher 优先,所以我们现在没有得到不同的结果。但是我们还不支持每个实例的材质数据。要添加这个,我们需要在需要时用数组引用替换 _BaseColor。这是通过将 CBUFFER_START 替换为 UNITY_INSTANCING_BUFFER_START,将 CBUFFER_END 替换为 UNITY_INSTANCING_BUFFER_END 来完成的,这现在也需要一个参数。这不必与开头相同,但没有令人信服的理由让它们不同。

1
2
3
4
5
6
7
//CBUFFER_START(UnityPerMaterial)
//	float4 _BaseColor;
//CBUFFER_END

UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
	float4 _BaseColor;
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

然后将 _BaseColor 的定义替换为 UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)

1
2
3
4
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
	//	float4 _BaseColor;
	UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

当使用实例化时,我们现在还必须在 UnlitPassFragment 中提供实例索引。为了简化此操作,我们将使用一个结构体让 UnlitPassVertex 同时输出位置和索引,并使用 UNITY_TRANSFER_INSTANCE_ID(input, output); 在索引存在时复制它。我们将此结构体命名为 Varyings,就像 Unity 所做的那样,因为它包含的数据在同一三角形的不同片段之间可能会有所不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Varyings {
	float4 positionCS : SV_POSITION;
	UNITY_VERTEX_INPUT_INSTANCE_ID
};

Varyings UnlitPassVertex (Attributes input) {
	Varyings output;
	UNITY_SETUP_INSTANCE_ID(input);
	UNITY_TRANSFER_INSTANCE_ID(input, output);
	float3 positionWS = TransformObjectToWorld(input.positionOS);
	output.positionCS = TransformWorldToHClip(positionWS);
	return output;
}

将此结构体作为参数添加到 UnlitPassFragment。然后像之前一样使用 UNITY_SETUP_INSTANCE_ID 来使索引可用。现在必须通过 UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor) 访问材质属性。

1
2
3
4
float4 UnlitPassFragment (Varyings input) : SV_TARGET {
	UNITY_SETUP_INSTANCE_ID(input);
	return UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
}
实例化绘制调用
实例化绘制调用

Unity 现在能够将 24 个球体与每个对象的颜色结合起来,从而减少了绘制调用的数量。我最终得到了四个实例化绘制调用,因为这些球体之间仍然使用了四种材质。GPU 实例化仅适用于共享相同材质的对象。由于它们覆盖了材质颜色,因此它们都可以使用相同的材质,这使得它们可以在一个批次中绘制。

一个实例化材质
一个实例化材质

批处理大小存在限制

具体取决于目标平台以及每个实例需要提供的数据量。如果超出此限制,则会生成多个批处理。此外,如果使用了多种材质,排序仍然会拆分批处理。

绘制大量实例网格

当数百个对象可以组合在一个绘制调用中时,GPU 实例化就成为一个显著的优势。但是手动编辑场景中的这么多对象是不切实际的。所以让我们随机生成一堆。创建一个 MeshBall 示例组件,它将在唤醒时生成大量对象。让它缓存 _BaseColor 着色器属性,并添加网格和材质的配置选项,这些材质必须支持实例化。

1
2
3
4
5
6
7
8
9
10
11
12
using UnityEngine;

public class MeshBall : MonoBehaviour {

	static int baseColorId = Shader.PropertyToID("_BaseColor");

	[SerializeField]
	Mesh mesh = default;

	[SerializeField]
	Material material = default;
}

创建一个带有此组件的游戏对象。我给它指定了默认的球体网格来绘制。

用于球体的网格球体组件。
用于球体的网格球体组件。

我们可以生成许多新的游戏对象,但我们不必这样做。相反,我们将填充一个变换矩阵和颜色数组,并告诉 GPU 渲染一个带有这些数据的网格。这就是 GPU 实例化最有用之处。我们可以一次提供多达 1023 个实例,因此让我们添加长度为该值的数组字段,以及一个我们需要传递颜色数据的 MaterialPropertyBlock。在这种情况下,颜色数组的元素类型必须是 Vector4。

1
2
3
4
	Matrix4x4[] matrices = new Matrix4x4[1023];
	Vector4[] baseColors = new Vector4[1023];

	MaterialPropertyBlock block;

创建一个 Awake 方法,用半径为 10 的球体内的随机位置和随机 RGB 颜色数据填充数组。

1
2
3
4
5
6
7
8
9
	void Awake () {
		for (int i = 0; i < matrices.Length; i++) {
			matrices[i] = Matrix4x4.TRS(
				Random.insideUnitSphere * 10f, Quaternion.identity, Vector3.one
			);
			baseColors[i] =
				new Vector4(Random.value, Random.value, Random.value, 1f);
		}
	}

Update 中,如果块尚不存在,我们会创建一个新块,并对其调用 SetVectorArray 来配置颜色。之后,调用 Graphics.DrawMeshInstanced,并将网格、子网格索引零、材质、矩阵数组、元素数量和属性块作为参数。我们在此处设置块,以便网格球在热重载后仍然存在。

1
2
3
4
5
6
7
	void Update () {
		if (block == null) {
			block = new MaterialPropertyBlock();
			block.SetVectorArray(baseColorId, baseColors);
		}
		Graphics.DrawMeshInstanced(mesh, 0, material, matrices, 1023, block);
	}
1023 个球体,3 次绘制调用。
1023 个球体,3 次绘制调用。

Play Game,现在会生成一个密集的球体。渲染所需的绘制调用次数取决于平台,因为每个绘制调用的最大缓冲区大小不同。在我的情况下,渲染需要三次绘制调用。

单个网格的绘制顺序与我们提供数据的顺序相同。除此之外,没有任何排序或剔除,尽管整个批次一旦超出视锥体就会消失。

动态批处理

还有第三种减少绘制调用的方法,称为动态批处理。这是一种旧技术,它将共享相同材质的多个小网格组合成一个更大的网格进行绘制。但是当使用MaterialPropertyBlock时,此方法也无法有效和批。

较大的网格是按需生成的,因此只适用于小型网格。球体太大,但它适用于立方体。要查看其效果,请禁用 GPU 实例化,并在 CameraRenderer.DrawVisibleGeometry 中将 enableDynamicBatching 设置为 true。

1
2
3
4
5
6
var drawingSettings = new DrawingSettings(
    unlitShaderTagId, sortingSettings
) {
    enableDynamicBatching = true,
    enableInstancing = false
};

同时禁用 SRP 批处理器,因为它具有优先合批权。

1
GraphicsSettings.useScriptableRenderPipelineBatching = false;
改为绘制立方体。
改为绘制立方体。

通常情况下,GPU 实例化比动态批处理效果更好。这种方法也有一些注意事项,例如当涉及不同比例时,较大网格的法线向量不保证是normalize化单位长度。此外,由于现在是一个网格而不是多个网格,绘制顺序也会发生变化。

还有静态批处理,它的工作方式类似,但会提前对标记为批处理静态的对象进行处理。除了需要更多内存和存储空间外,它没有其他注意事项。RP 不用关注这一点,所以我们不必担心。

配置批处理

哪种方法最好可能会有所不同,因此我们将其配置化。首先,添加布尔参数来控制是否使用动态批处理和 GUI 实例化来 DrawVisibleGeometry,而不是硬编码。

1
2
3
4
5
6
7
8
9
10
11
12
	void DrawVisibleGeometry (bool useDynamicBatching, bool useGPUInstancing) {
		var sortingSettings = new SortingSettings(camera) {
			criteria = SortingCriteria.CommonOpaque
		};
		var drawingSettings = new DrawingSettings(
			unlitShaderTagId, sortingSettings
		) {
			enableDynamicBatching = useDynamicBatching,
			enableInstancing = useGPUInstancing
		};
		...
	}

Render 现在必须提供此配置,而此配置又依赖于 RP 提供。

1
2
3
4
5
6
7
8
	public void Render (
		ScriptableRenderContext context, Camera camera,
		bool useDynamicBatching, bool useGPUInstancing
	) {
		...
		DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
		...
	}

CustomRenderPipeline 将通过在其构造函数方法中设置的字段来跟踪选项,并在 Render 中传递它们。同时,为构造函数添加一个布尔参数用于 SRP batcher,而不是始终启用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
	bool useDynamicBatching, useGPUInstancing;

	public CustomRenderPipeline (
		bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher
	) {
		this.useDynamicBatching = useDynamicBatching;
		this.useGPUInstancing = useGPUInstancing;
		GraphicsSettings.useScriptableRenderPipelineBatching = useSRPBatcher;
	}

	...
	
	protected override void Render (
		ScriptableRenderContext context, List<Camera> cameras
	) {
		for (int i = 0; i &;t cameras.Count; i++) {
			renderer.Render(
				context, cameras[i], useDynamicBatching, useGPUInstancing
			);
		}
	}

最后,将所有三个选项作为配置字段添加到 CustomRenderPipelineAsset,并在 CreatePipeline 中将它们传递给构造函数调用。

1
2
3
4
5
6
7
8
	[SerializeField]
	bool useDynamicBatching = true, useGPUInstancing = true, useSRPBatcher = true;

	protected override RenderPipeline CreatePipeline () {
		return new CustomRenderPipeline(
			useDynamicBatching, useGPUInstancing, useSRPBatcher
		);
	}
RP 配置。
RP 配置。

现在可以更改我们的 RP 使用的方法。切换选项会立即生效,因为 Unity 编辑器在检测到资产更改时会创建一个新的 RP 实例。

透明度

我们的着色器可用于创建无光不透明材质。可以更改颜色的 alpha 分量,这通常表示透明度,但目前没有效果。我们还可以将渲染队列设置为 Transparent ,但这只改变对象何时以及以何种顺序绘制,而不是如何绘制。

降低了 alpha 并使用了透明渲染队列。
降低了 alpha 并使用了透明渲染队列。

我们不需要编写单独的着色器来支持透明材质。稍作修改,我们的 Unlit 着色器就可以支持不透明和透明渲染。

混合模式

不透明和透明渲染的主要区别在于我们是否替换之前绘制的内容,或者将之前的结果组合起来以产生透明效果。我们可以通过设置源和目标混合模式来控制这一点。在这里,源指的是现在绘制的内容,而目标指的是之前绘制的内容以及结果将最终出现在哪里。为此添加两个着色器属性:_SrcBlend 和 _DstBlend。它们是混合模式的枚举,但我们能使用的最佳类型是 Float,默认情况下,源设置为 1,目标设置为 0。

1
2
3
4
5
	Properties {
		_BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
		_SrcBlend ("Src Blend", Float) = 1
		_DstBlend ("Dst Blend", Float) = 0
	}

为了便于编辑,我们可以将 Enum 属性添加到属性中,并以完全限定的 UnityEngine.Rendering.BlendMode 枚举类型作为参数。

1
2
		[Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("Src Blend", Float) = 1
		[Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Dst Blend", Float) = 0
不透明混合模式。
不透明混合模式。

默认值代表我们已经使用的不透明混合配置。源设置为1,表示它被完全添加,而目标设置为零,表示它被忽略。

标准透明度的源混合模式是 SrcAlpha,这意味着渲染颜色的 RGB 分量会乘以其 alpha 分量。因此,alpha 越低,它就越弱。然后将目标混合模式设置为相反的模式:OneMinusSrcAlpha,以达到总权重为 1。

透明混合模式。
透明混合模式。

混合模式可以在 Pass 块中使用 Blend 语句后跟两个模式来定义。我们想使用着色器属性,可以通过将它们放在方括号中来访问。这是可编程着色器时代之前的旧语法。

1
2
3
4
5
6
7
    Pass {
        Blend [_SrcBlend] [_DstBlend]

        HLSLPROGRAM
        ...
        ENDHLSL
    }
半透明黄色球体。
半透明黄色球体。

不写入深度

透明渲染通常不写入深度缓冲区,因为它从中得不到好处,甚至可能产生不希望的结果。我们可以通过 ZWrite 语句控制是否写入深度。同样,我们可以使用着色器属性,这次使用 _ZWrite。

1
2
    Blend [_SrcBlend] [_DstBlend]
    ZWrite [_ZWrite]

使用自定义的 Enum(Off, 0, On, 1) 属性定义着色器属性,以创建一个默认开启的、值为 0 和 1 的开关。

1
2
3
    [Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("Src Blend", Float) = 1
    [Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Dst Blend", Float) = 0
    [Enum(Off, 0, On, 1)] _ZWrite ("Z Write", Float) = 1
深度写入已关闭。
深度写入已关闭。

纹理

之前我们使用 alpha 贴图创建了不均匀的半透明材质。我们通过向着色器添加 _BaseMap 纹理属性来支持这一点。在这种情况下,类型是 2D,我们将使用 Unity 的标准白色纹理作为默认值,用 ` white ` 字符串表示。此外,我们必须以一个空的 code 块结束纹理属性。它很久以前用于控制纹理设置,但今天仍应包含在内,以防止在某些情况下出现奇怪的错误。

1
2
    _BaseMap("Texture", 2D) = "white" {}
    _BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
带纹理的材质。
带纹理的材质。

纹理必须上传到 GPU 内存,Unity 会为我们完成此操作。着色器需要一个相关纹理的句柄,我们可以像定义 uniform 值一样定义它,只不过我们使用 TEXTURE2D 宏并将名称作为参数。我们还需要为纹理定义一个采样器状态,它控制纹理应如何采样,同时考虑其环绕和过滤模式。这是通过 SAMPLER 宏完成的,类似于 TEXTURE2D,但名称前缀为 sampler。这与 Unity 自动提供的采样器状态的名称匹配。

纹理和采样器状态是着色器资源。它们不能按实例提供,必须在全局范围声明。在 UnlitPass.hlsl 中的着色器属性之前完成此操作。

1
2
3
4
5
6
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);

UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
	UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

除此之外,Unity 还通过一个 float4 提供纹理的平铺和偏移,该 float4 的名称与纹理属性相同,但附加了 _ST,代表缩放和变换或类似含义。此属性应是 UnityPerMaterial 缓冲区的一部分,因此可以按实例设置。

1
2
3
4
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
	UNITY_DEFINE_INSTANCED_PROP(float4, _BaseMap_ST)
	UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

为了能采样纹理,我们需要纹理坐标,它是顶点属性的一部分。具体来说,我们需要第一对坐标,因为可能有更多对。这通过向 Attributes 添加一个具有 TEXCOORD0 含义的 float2 字段来完成。由于它是用于我们的基础贴图,并且纹理空间维度普遍命名为 U 和 V,我们将其命名为 baseUV

1
2
3
4
5
struct Attributes {
	float3 positionOS : POSITION;
	float2 baseUV : TEXCOORD0;
	UNITY_VERTEX_INPUT_INSTANCE_ID
};

我们需要将坐标传递给片段函数,因为纹理是在那里采样的。因此,也要将 $float2 baseUV$ 添加到 Varyings 中。这次我们不需要添加特殊含义,它只是我们传递的数据,不需要 GPU 的特殊关注。但是,我们仍然必须赋予它一些含义。我们可以应用任何未使用的标识符,我们简单地使用 VAR_BASE_UV

1
2
3
4
5
struct Varyings {
	float4 positionCS : SV_POSITION;
	float2 baseUV : VAR_BASE_UV;
	UNITY_VERTEX_INPUT_INSTANCE_ID
};

当我们在 UnlitPassVertex 中复制坐标时,我们也可以应用存储在 _BaseMap_ST 中的缩放和偏移。这样,我们就可以按顶点而不是按片段进行操作。缩放存储在 XY 中,偏移存储在 ZW 中,我们可以通过 swizzle 属性访问它们。

1
2
3
4
5
6
7
Varyings UnlitPassVertex (Attributes input) {
	...

	float4 baseST = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseMap_ST);
	output.baseUV = input.baseUV * baseST.xy + baseST.zw;
	return output;
}

UV 坐标现在可用于 UnlitPassFragment,并在三角形上进行插值。在这里通过使用 SAMPLE_TEXTURE2D 宏并以纹理、采样器状态和坐标作为参数来采样纹理。最终颜色是纹理和统一颜色通过乘法组合而成的。将两个相同大小的向量相乘会导致所有匹配分量相乘,因此在这种情况下是红色乘以红色,绿色乘以绿色,依此类推。

1
2
3
4
5
6
float4 UnlitPassFragment (Varyings input) : SV_TARGET {
	UNITY_SETUP_INSTANCE_ID(input);
	float4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.baseUV);
	float4 baseColor = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
	return baseMap * baseColor;
}
带纹理的黄色球体。
带纹理的黄色球体。

因为我们纹理的 RGB 数据是统一的白色,所以颜色不受影响。但 alpha 通道是变化的,因此透明度不再统一。

Alpha 裁剪

另一种看穿表面的方法是在其中打孔。着色器也可以做到这一点,通过丢弃一些它们通常会渲染的片段。这会产生硬边,而不是我们目前看到的平滑过渡。这种技术被称为 alpha 裁剪。通常的做法是定义一个截止阈值。alpha 值低于此阈值的片段将被丢弃,而所有其他片段则被保留。

添加一个 _Cutoff 属性,默认设置为 0.5。由于 alpha 始终介于零和 1 之间,我们可以使用 Range(0.0, 1.0) 作为其类型。

1
2
    _BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
    _Cutoff ("Alpha Cutoff", Range(0.0, 1.0)) = 0.5

也将其添加到 UnlitPass.hlsl 中的材质属性。

1
2
	UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
	UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)

我们可以在 UnlitPassFragment 中调用 clip 函数来丢弃片段。如果传入的值为零或更小,它将中止并丢弃该片段。因此,将最终的 alpha 值(可通过 aw 属性访问)减去截止阈值后传递给它。

1
2
3
4
5
	float4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.baseUV);
	float4 baseColor = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
	float4 base = baseMap * baseColor;
	clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
	return base;
Alpha 截止值设为 0.2。
Alpha 截止值设为 0.2。
Alpha 截止值设为 0.2。

一种材质通常使用透明度混合或 Alpha 裁剪,而不是同时使用两者。典型的裁剪材质除了被丢弃的片段外是完全不透明的,并且会写入深度缓冲区。它使用AlphaTest渲染队列,这意味着它会在所有完全不透明对象之后渲染。这样做是因为丢弃片段会使一些 GPU 优化变得不可能,因为三角形不能再被假定完全覆盖它们后面的内容。通过首先绘制完全不透明的对象,它们最终可能会覆盖 Alpha 裁剪对象的一部分,这样就不需要处理其隐藏的片段。

Alpha 裁剪材质。
Alpha 裁剪材质。
Alpha 裁剪材质。

但要使此优化生效,我们必须确保仅在需要时才使用 clip。我们将通过添加一个功能切换着色器属性来实现这一点。它是一个默认为零的 Float 属性,带有一个 Toggle 属性,用于控制着色器关键字,我们将使用 _CLIPPING。属性本身的名称无关紧要,因此只需使用 _Clipping

1
2
		_Cutoff ("Alpha Cutoff", Range(0.0, 1.0)) = 0.5
		[Toggle(_CLIPPING)] _Clipping ("Alpha Clipping", Float) = 0  
Alpha 裁剪已关闭,按理说。
Alpha 裁剪已关闭,按理说。

着色器功能

启用此开关会将 _CLIPPING 关键字添加到材质的活动关键字列表中,禁用则会将其移除。但这本身并不会产生任何效果。我们必须告诉 Unity 根据关键字是否已定义来编译我们着色器的不同版本。我们通过在其 Pass 中的指令中添加 #pragma shader_feature _CLIPPING 来实现这一点。

1
2
    #pragma shader_feature _CLIPPING
    #pragma multi_compile_instancing

现在,Unity 将会编译我们的着色器代码,无论是否定义了 _CLIPPING。它将生成一个或两个变体,具体取决于我们如何配置材质。因此,我们可以根据定义使代码具有条件性,就像包含守卫一样,但在这种情况下,我们只希望在定义了 _CLIPPING 时才包含裁剪行。我们可以使用 #ifdef _CLIPPING 来实现,但我更喜欢 #if defined(_CLIPPING)

1
2
3
	#if defined(_CLIPPING)
		clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
	#endif

Cutoff Object1

由于Cutoff值是 UnityPerMaterial 缓冲区的一部分,因此可以按实例配置。所以让我们将该功能添加到 PerObjectMaterialProperties 中。它的工作方式与颜色相同,只是我们需要在属性块上调用 SetFloat 而不是 SetColor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
	static int baseColorId = Shader.PropertyToID("_BaseColor");
	static int cutoffId = Shader.PropertyToID("_Cutoff");

	static MaterialPropertyBlock block;

	[SerializeField]
	Color baseColor = Color.white;

	[SerializeField, Range(0f, 1f)]
	float cutoff = 0.5f;

	...

	void OnValidate () {
		...
		block.SetColor(baseColorId, baseColor);
		block.SetFloat(cutoffId, cutoff);
		GetComponent<Renderer>().SetPropertyBlock(block);
	}
每个实例化对象的 Alpha 截止值。
每个实例化对象的 Alpha 截止值。
每个实例化对象的 Alpha 截止值。

Alpha 裁剪球体球

MeshBall 也是如此。现在我们可以使用剪裁材质,但所有实例最终都会有完全相同的孔洞。

近距离观察 Alpha 剪裁的网格球。
近距离观察 Alpha 剪裁的网格球。

让我们通过给每个实例一个随机旋转,加上 0.5-1.5 范围内的随机统一缩放来增加一些多样性。但是,我们不会为每个实例设置截止值,而是将其颜色的 alpha 通道在 0.5-1 范围内变化。这给了我们不太精确的控制,但这只是一个随机示例。

1
2
3
4
5
6
7
8
9
10
11
matrices[i] = Matrix4x4.TRS(
    Random.insideUnitSphere * 10f,
    Quaternion.Euler(
        Random.value * 360f, Random.value * 360f, Random.value * 360f
    ),
    Vector3.one * Random.Range(0.5f, 1.5f)
);
baseColors[i] = new Vector4(
        Random.value, Random.value, Random.value,
        Random.Range(0.5f, 1f)
    );
更多样化的网格球。
更多样化的网格球。

请注意,Unity 仍然会向 GPU 发送一个cutoff数组,每个实例一个,即使它们都相同。该值是材质的副本,因此通过改变它,可以一次性改变所有球体的孔洞,即使它们仍然不同。


下一个篇是方向光

This post is licensed under CC BY 4.0 by the author.