自定义渲染管线: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 文件夹中。
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,它可以编译并允许我们创建一个使用它的材质。
默认的 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 并清空其内容。
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 资源包的文件夹结构。
文件开头先编写一个 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次用于球体,一次用于天空盒,一次用于清除渲染目标。
如果你打开 Stats 窗口的 Game 面板,你就可以看到渲染帧所需的概览。这里有趣的事实是,它显示了 77 个批次——忽略清除——其中零个通过批处理节省。
SRP Batcher
批处理是合并绘制调用的过程,减少 CPU 和 GPU 之间通信所花费的时间。最简单的方法是启用 SRP batcher。然而,这只适用于兼容的着色器,而我们的 Unlit 着色器不兼容。你可以在 Inspector 中选择它来验证。有一行 SRP Batcher 指示不兼容,下面给出了一个原因。
SRP 批处理并没有减少绘制调用的数量,而是使其更精简。它在 GPU 上缓存材质属性,这样就不必在每次绘制调用时都发送它们。这减少了必须通信的数据量以及 CPU 在每次绘制调用中必须完成的工作。但这仅在着色器遵循严格的统一数据结构时才有效。
所有材质属性都必须在具体的内存缓冲区中定义,而不是在全局级别。这是通过将_BaseColor声明包装在名为UnityPerMaterial的cbuffer块中来完成的。这类似于结构体声明,但必须以分号结尾。它通过将_BaseColor放入特定的常量内存缓冲区来隔离它,尽管它仍然可以在全局级别访问。
1
2
3
cbuffer UnityPerMaterial {
float _BaseColor;
};
常量缓冲区并非在所有平台(如 OpenGL ES 2.0)上都受支持,因此我们不直接使用cbuffer,而是可以使用我们从 Core RP Library 中包含的CBUFFER_START和CBUFFER_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,这通过将 GraphicsSettings.useScriptableRenderPipelineBatching 设置为 true 来完成。我们只需执行一次此操作,因此让我们在创建RP实例时执行此操作,方法是向 CustomRenderPipeline 添加一个构造函数。
1
2
3
public CustomRenderPipeline () {
GraphicsSettings.useScriptableRenderPipelineBatching = true;
}
Stats 面板显示保存了 76 个批次,尽管它显示的是负数。帧调试器现在在 RenderLoopNewBatcher.Draw 下显示一个 SRP Batch 条目,但请记住,它不是一个单独的绘制调用,而是一系列经过优化的绘制调用。
多种颜色
尽管我们使用了四种材质,但我们只获得了一个批次。这是因为所有数据都缓存在 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;
}
通过 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 个球体各自回退到一个常规的绘制调用,并且由于排序,可能会将其他球体也分成多个批次。
此外,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 实例化需要改变方法,为此我们必须从核心着色器库中包含 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);
}
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 使用的方法。切换选项会立即生效,因为 Unity 编辑器在检测到资产更改时会创建一个新的 RP 实例。
透明度
我们的着色器可用于创建无光不透明材质。可以更改颜色的 alpha 分量,这通常表示透明度,但目前没有效果。我们还可以将渲染队列设置为 Transparent ,但这只改变对象何时以及以何种顺序绘制,而不是如何绘制。
我们不需要编写单独的着色器来支持透明材质。稍作修改,我们的 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 值(可通过 a 或 w 属性访问)减去截止阈值后传递给它。
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 裁剪,而不是同时使用两者。典型的裁剪材质除了被丢弃的片段外是完全不透明的,并且会写入深度缓冲区。它使用AlphaTest渲染队列,这意味着它会在所有完全不透明对象之后渲染。这样做是因为丢弃片段会使一些 GPU 优化变得不可能,因为三角形不能再被假定完全覆盖它们后面的内容。通过首先绘制完全不透明的对象,它们最终可能会覆盖 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
着色器功能
启用此开关会将 _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 裁剪球体球
MeshBall 也是如此。现在我们可以使用剪裁材质,但所有实例最终都会有完全相同的孔洞。
让我们通过给每个实例一个随机旋转,加上 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数组,每个实例一个,即使它们都相同。该值是材质的副本,因此通过改变它,可以一次性改变所有球体的孔洞,即使它们仍然不同。
下一个篇是方向光。








































