Draw Calls(绘制调用)
——着色器和批处理
本节内容
- 完成一个简单的HLSL着色器
- 支持可编程渲染管线批处理(SRP Batcher)、GPU缓存系统(GPU-Instancing)、动态批处理(Dynamic Batching)
- 配置逐对象的材质属性并随机绘制多个对象
- 创建透明和可裁剪材质
这是一个关于如何创建一个Custom SRP的系列教程的第二个部分,它包含了着色器的编写以及如何高效绘制多个对象。
这个教程使用的是Unity版本是2019.2.6f1.
(ps:文章总被吞…最后偶然看到可能会被吞的一些词儿…尝试改了点但有些意思感觉不到位~)
1. 着色器(Shaders)
为了绘制一些东西,CPU必须告诉GPU要绘制什么和如何绘制。绘制的内容通常是网格(Mesh
)。如何绘制它是由shader定义的,它是一组为GPU的指令。除了mesh,shader还需要额外的信息来完成它的工作,包括对象的转换矩阵(Transformation Matrices)和材质(Material)属性。
Unity的轻量级渲染管线(LWRP
,Unity 2019.3 后变更为URP
)、通用渲染管线(URP)和高清渲染管线(HDRP)允许你使用Shader Graph包设计Shader,它会为你生成Shader代码。 但是我们的Custom RP
不支持这一点,所以我们必须自己编写Shader代码。这让我们可以完全控制和理解Shader要做的工作。
1.1 无光照着色器(Unlit Shader)
我们的第一个shader将简单地使用纯色绘制一个mesh,没有任何照明。一个shader资源可以通过Assets/Create/Shader
菜单中的选项之一来创建。Unlit Shader
是最合适的,但我们要重新开始,删除创建的Shader文件的所有默认代码。命名资源为Unlit
,并放入一个新的Custom RP
目录下的Shaders
文件夹中。
Shader代码的大部分看起来像c#代码,但它包含了不同方法的混合,包括一些古老的部分,它们在过去有意义,但现在没有了。
Shader像一个类一样被定义,但shader关键字后面只跟着一个字符串,用于在material的Shader
下拉菜单中为它创建一个入口。让我们使用Custom RP/Unlit
。字符串后面是一个代码块,它包含更多带有关键字的代码块。有一个Properties
代码块来定义材质属性,然后是一个SubShader
代码块,我们需要有一个Pass
代码块,它定义了一种渲染的方式。使用这些空代码块来创建这种结构。
Shader "Custom RP/Unlit" {
Properties {}
SubShader {
Pass {}
}
}
这定义了一个最简单的shader,它可以编译并允许我们创建一个可以使用它的material。
默认的shader实现了将mesh渲染为纯白色。材质显示了渲染队列(Render Queue
)的默认属性,它自动从shader中获取,并设置为2000
,这是不透明(Opaque)几何体的默认渲染队列参数。它也有一个双面全局照明(Double Sided Global Illumination
)的开关,但这个属性现在与我们无关。
1.2 高级着色器语言程序(HLSL Programs)
我们用来编写着色器代码的语言是高级着色器语言(High-level shader language),简称HLSL
。 我们必须把它放在Pass
块中的HLSLPROGRAM
和ENDHLSL
关键字之间。我们必须这样做,因为在Pass
块中也可以放入其他非HLSL代码。
Pass {
HLSLPROGRAM
ENDHLSL
}
CG程序呢?
.
Unity仍然支持编写CG程序而不只是HLSL程序,但我们将只使用HLSL,就像Unity最新的RP一样。
为了绘制mesh,GPU必须光栅化所有的三角形,并将其转换为像素化数据。它通过将顶点(vertex
)坐标从3D空间转换为2D可视化空间,然后填充三角形覆盖的所有像素来实现这一步。这两个步骤是由两个单独的着色程序分别控制的,这两个程序我们都必须定义。第一个被称为顶点着色器(vertex shader
)或顶点内核(vertex kernel
)或顶点程序(vertex program
),第二个被称为片元/片段着色器(fragment shader
)或片元/片段内核(fragment kernel
)或片元/片段程序(fragment program
)。一个fragment相当于一个显示像素(pixel
)或纹理纹素(texture texel
),它可能不能代表最终的结果,因为另外一些东西可能会在之后被绘制在它上面时,从而覆盖它。
我们必须用一个名称来标识两个程序,这是通过pragma
指示符实现的。这些单行语句以#pragma为开头,后面跟着vertex或fragment以及相关名称。我们将使用UnlitPassVertex
和UnlitPassFragment
为他们命名。
HLSLPROGRAM
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment
ENDHLSL
pragma 是什么意思?
.
pragma这个词来自希腊语,指的是一个行为或一些事情需要被处理。许多编程语言使用它来发出特殊的编译器指令。*
shader编译器现在会抱怨它找不到声明的shader程序。我们必须编写具有相同名称的HLSL
函数来定义它们的实现。我们可以直接在pragma
指令下面执行此操作,但我们将首先把所有HLSL代码放在一个单独的.hlsl
文件中。确切的说,我们将使用同一个资源文件夹下的UnlitPass.hlsl
文件。我们可以通过添加一个带有文件相对路径的#include
指令来指示shader编译器添加该文件的内容。
HLSLPROGRAM
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment
#include "UnlitPass.hlsl"
ENDHLSL
Unity没有一个方便的菜单选项来创建一个HLSL文件,所以你必须做一些事情,如复制shader文件,重命名为UnlitPass
,更改其文件扩展名为HLSL
,并删除其中的内容。
1.3 引用保护(Include Guard)
HLSL文件可以像c#类一样对代码进行分组,尽管HLSL没有类的概念。除了代码块的局部作用域之外,只有一个全局作用域。所以任何东西在任何地方都是可以访问的。引用一个文件也不同于使用名称空间。它在#include
指令处插入被引用文件的全部内容,因此,如果多次引用相同的文件,就会得到重复的代码,这很可能导致编译器错误。为了防止这种情况,我们将在UnlitPass.hlsl
中添加一个引用保护。
可以使用#define
指令来定义任何标识符,通常使用大写。我们将在文件的顶部使用它定义CUSTOM_UNLIT_PASS_INCLUDED
。
#define CUSTOM_UNLIT_PASS_INCLUDED
这是一个简单宏定义的例子,它只定义了一个标识符。如果它存在,则意味着我们的文件已被引用,所以我们就不想再一次引用它的内容了。换句话说,我们只希望在代码尚未定义时插入它。我们可以用#ifndef
指令检查。在宏定义之前执行此操作。
#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED
在#ifndef
之后的所有代码将被跳过,因此如果宏已经定义,则不会被编译。 我们必须通过在文件末尾添加#endif
指令来终止#ifndef
的作用域。
#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED
#endif
现在我们可以确保文件的所有相关代码永远不会被多次嵌入,即使我们不止一次地引用它。
1.4 着色器函数(Shader Functions)
我们在引用保护的作用域内定义shader函数。它们就像c#
方法一样编写,只是没有任何访问修饰符。从不做任何事情的简单void
函数开始。
#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED
void UnlitPassVertex () {}
void UnlitPassFragment () {}
#endif
这足以让我们的shader进行编译。显示出来的可能是一个默认的青色shader。
为了产生有效的输出,我们必须让我们的片元函数返回一个颜色。颜色是用包含红色、绿色、蓝色和透明度分量(rgba
)的四分量类型float4
定义的。 我们可以通过float4(0.0, 0.0, 0.0, 0.0)
将颜色定义为纯黑色,我们也可以写一个简单的0
,因为单个值会自动扩展为一个完整的指定类型。透明度并不重要,因为我们正在创建一个不透明的shader,所以设置透明度为0即可。
float4 UnlitPassFragment () {
return 0.0;
}
为什么用'0.0'代替了'0'?
.
它表明我们指的是浮点值(float)而不是(int),但这对编译器没有影响。
我们应该使用浮点数(float)还是半精度(half)类型?
.
大多数移动端gpu都支持这两种精度类型,half效率更高。 因此,如果你正在针对手机进行优化,那么尽可能使用half类型是有意义的。经验法则是只对位置和纹理坐标使用float,其余部分使用half
,前提是保证结果足够好。
.
当不针对移动平台时,精度不是问题,因为GPU总是使用浮点数,即使我们使用的half。在本系列教程中,我将始终使用float。
.
也有fixed类型,但它只被旧硬件支持,通常相当于half。
此时,shader将编译失败,因为我们的函数缺少语义。我们必须用返回的值来表明我们的想法,因为这可能会产生许多具有不同含义的数据。在本例中,我们为渲染目标提供了默认的系统值,通过在UnlitPassFragment
的参数列表后面写一个冒号,以及SV_TARGET
来表示。
float4 UnlitPassFragment () : SV_TARGET {
return 0.0;
}
UnlitPassVertex
负责转换顶点位置,因此应该返回一个位置。这也是一个float4
类型,因为它必须定义为齐次剪辑空间(homogeneous clip space
)位置,我们稍后会讲到。同样,我们从返回零向量开始,然后,我们必须指出它的语义是SV_POSITION
。
float4 UnlitPassVertex () : SV_POSITION {
return 0.0;
}
1.5 空间变换(Space Transformation)
当所有的顶点都设置为0时,mesh坍塌为一个点,然后没有任何东西会被渲染。顶点函数的主要工作是将原始顶点位置转换到正确的空间。当它被调用时,如果我们请求的话,函数会提供给我们可用的顶点数据。我们通过给UnlitPassVertex
添加参数来做到这一点。我们需要顶点位置,这是在对象空间中定义的,所以我们将其命名为positionOS
,使用与URP相同的约定。positionOS
的类型是float3
,因为它是一个3D坐标。刚开始时我们只返回它,将第四个分量设为1。
float4 UnlitPassVertex (float3 positionOS) : SV_POSITION {
return float4(positionOS, 1.0);
}
顶点位置不应该是 float4 类型吗?
.
通常3D空间中的点是由4D向量定义的,它们的第四个分量设置为1,而方向向量则将其设置为0。这使得使用相同的变换矩阵正确地变换坐标和方向成为可能。然而,这种技术只在位置和方向混合时才需要,而通常情况下不会出现这种情况。相反,旋转变换会使用不同的计算量更小的的代码。
.
位置原本是3D向量,但会自动展开为4D向量,第四个分量为1。因此,我们可以将位置定义为 float4 ,但这不是必需的。此行为也适用于其他输入数据。确切地说,缺失的 XYZ 值被设置为0,W总是被设置为1。
我们还必须向输入添加语义,因为顶点数据可以包含不止一个位置。在本例中,我们需要用POSITION
。
float4 UnlitPassVertex (float3 positionOS : POSITION) : SV_POSITION {
return float4(positionOS, 1.0);
}
mesh再次出现,但不正确,因为我们输出的位置使用了错误的空间。空间转换需要矩阵,当绘制某些东西时,这些矩阵被发送给GPU。我们必须将这些矩阵添加到我们的shader中,但因为它们总是相同的,我们将把Unity提供的标准输入放在一个单独的HLSL
文件中,既保持代码结构化,又能够在不同的shader中引用这些代码。我们模仿unity的管线文件夹结构,添加一个UnityInput.hlsl
文件,并将其放在Custom RP
下的ShaderLibrary
文件夹中。
以CUSTOM_UNITY_INPUT_INCLUDED
引用保护开始编写这个文件,然后在全局作用域中定义一个名为unity_ObjectToWorld
的float4x4
类型的矩阵。在c#类中,这将定义一个字段,但在这里它被认为是一个统一值。它由GPU每次绘制设置一次,在绘制期间所有顶点和片元函数的调用时都保持恒定不变。
#ifndef CUSTOM_UNITY_INPUT_INCLUDED
#define CUSTOM_UNITY_INPUT_INCLUDED
float4x4 unity_ObjectToWorld;
#endif
我们可以用这个矩阵把坐标从物体空间转换成世界空间。因为这是常见的功能,让我们为它创建一个函数,并将其放入另一个文件,在同样的``文件夹中新建一个Common. hlsl
文件。我们在Common.hlsl
中引用UnityInput.hlsl
,然后声明一个TransformObjectToWorld
函数,使用float3
作为输入和输出的类型。
#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)
来设置。我们可以通过访问向量的xyz
属性提取它的前三个分量(称为'swizzle'操作,个人觉得这个'swizzle operation'意思类似'语法糖')。
float3 TransformObjectToWorld (float3 positionOS) {
return mul(unity_ObjectToWorld, float4(positionOS, 1.0)).xyz;
}
我们现在可以在UnlitPassVertex
中将坐标转换到世界空间。首先函数之前引用Common.hlsl
。因为被引用的文件可能存在于不同的文件夹中,我们需要使用相对路径../ShaderLibrary/Common.hlsl
。然后使用TransformObjectToWorld
计算一个世界空间坐标positionWS
变量并返回它。
#include "../ShaderLibrary/Common.hlsl"
float4 UnlitPassVertex (float3 positionOS : POSITION) : SV_POSITION {
float3 positionWS = TransformObjectToWorld(positionOS.xyz);
return float4(positionWS, 1.0);
}
很不幸,现在的结果仍然是错误的,因为我们还需要一个齐次剪辑空间(homogeneous clip space
)的坐标。这个空间定义了一个立方体,它包含了相机视图的一切,这个立方体在透视相机中被扭曲成一个梯形。从世界空间到这个空间的转换可以通过乘以视图投影矩阵(view-projection matrix
)来完成,该矩阵负责相机的位置、方向、投影、视图和远近剪切平面,它使unity_ObjectToWorld
矩阵变得有用。所以我们将它添加到UnityInput.hlsl
中。
float4x4 unity_ObjectToWorld;
float4x4 unity_MatrixVP;
添加TransformWorldToHClip
到Common.hlsl
中,它的工作原理与TransformObjectToWorld
相同,只是它的输入是在世界空间中,并且它使用了另一个矩阵,并返回一个float4
类型的值。
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
使用这个TransformWorldToHClip
并返回在正确的空间中的坐标。
float4 UnlitPassVertex (float3 positionOS : POSITION) : SV_POSITION {
float3 positionWS = TransformObjectToWorld(positionOS.xyz);
return TransformWorldToHClip(positionWS);
}
1.6 核心库(Core Library)
我们刚刚定义的两个函数是很常用的,它们也包含在Core RP
管线包中。核心库中定义了许多很有用和很重要的东西,所以让我们安装那个包(在Window/Package Manager
中)。删除我们自己的定义,取而代之的是引用核心库的相关文件,在这里我们引用Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl
。
//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
替换。稍后我们会了解这样做的原因。
#define UNITY_MATRIX_M unity_ObjectToWorld
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"
对于逆矩阵unity_WorldToObject
也是如此,它应该通过UNITY_MATRIX_I_M
宏定义,通过UNITY_MATRIX_V
定义unity_MatrixV
矩阵,通过UNITY_MATRIX_VP
定义unity_MatrixVP
。最后,还有通过UNITY_MATRIX_P
定义的投影矩阵,可用glstate_matrix_projection
表示。我们目前不需要这些矩阵,但如果不包含它们,代码就无法编译。
#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
将额外的矩阵添加到UnityInput
中。
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
float4x4 unity_MatrixVP;
float4x4 unity_MatrixV;
float4x4 glstate_matrix_projection;
最后缺少的东西是一个矩阵以外的东西,那就是unity_WorldTransformParams
,它包含了一些我们目前不需要的变换信息。它是一个real4
类型的向量,它本身不是有效类型,而是float4
或half4
的别名,具体怎样定义取决于目标平台。
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
real4 unity_WorldTransformParams;
real4
和很多其他的基础宏都是在每个图形API中定义的,我们可以通过包含Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl
来得到这些。在包含UnityInput.hlsl
之前,在我们Common.hlsl
文件这样做。如果你对包中的内容感兴趣,可以去查看这些文件。
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "UnityInput.hlsl"
1.7 颜色(Color)
渲染对象的颜色可以通过调整UnlitPassFragment
来改变。例如,我们可以通过返回float4(1.0, 1.0, 0.0, 1.0)
而不是0来使其变为黄色。
float4 UnlitPassFragment () : SV_TARGET {
return float4(1.0, 1.0, 0.0, 1.0);
}
为了使分开配置每个材质的颜色成为可能,我们必须将其定义为一个统一的值。在#include
指令之后,以及UnlitPassVertex
函数之前执行此操作。我们需要一个float4
参数,将它命名为_BaseColor
。前面的下划线是表示它代表材质属性的一个规范化的方式。在UnlitPassFragment
中返回这个值而不是之前返回的的硬编码颜色。
#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;
}
我们现在又看到了黑色的球体,因为默认值是0。要链接到材质,我们必须在Unlit
着色器的属性块中添加_BaseColor
。
Properties {
_BaseColor
}
属性名后面必须跟着一个用于Inspector
面板中使用的的字符串和一个Color
类型的标识符,就像为方法提供参数一样。
_BaseColor("Color", Color)
最后,我们必须提供一个默认值,在本例中是通过给它分配一个包含四个浮点数的列表。让我们先用(1.0, 1.0, 1.0, 1.0)
将它设置为白色。
_BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
现在可以用我们的shader创建多个材质,每个材质都有不同的颜色。
2. 批处理(Batching)
每次draw call
都代表着CPU和GPU之间的通信。如果大量的数据需要被发送到GPU,那么可能会为等待浪费很多时间。当CPU忙于发送数据时,它不能做其他事情。双方出现问题都会降低帧率。目前我们渲染对象的方法很简单,导致每个对象都有自己的 draw call,这是最糟糕的方式。当然因为我们现在发送的数据很少,所以表现上还好。
为了做一个实例,我制作了一个有76个球体的场景,每个球体使用四种材料中的一种:红色、绿色、黄色和蓝色。它需要78个draw call来渲染,76个用于球体,一个用于天空盒,一个用于清除渲染目标。
如果你打开游戏窗口的统计面板(Stats
),那么你就可以看到帧渲染的概览。这里有趣的是,它显示了77个批处理(batches),而清除操作被忽略了,其中saved by batching
是0个。
2.1 SRP批处理(SRP Batcher)
批处理是合并draw call
的过程,减少了CPU和GPU之间的通信时间。最简单的方法是启用SRP
批处理。然而,这只适用于兼容的shader,而我们的Unlit shader
并不兼容。你可以通过选择它并在Inspector
面板中来验证这一点。有一个SRP Batcher
行表示不兼容,并在该行下给出了一个不兼容的原因。
SRP批处理不是减少draw call的数量,而是使它们更精简。它在GPU上缓存材质属性,所以它们不必在每个draw call中都被发送。这既减少了通信的数据量,也减少了CPU在每个draw call中所做的工作。但这只在shader遵循严格和统一数据结构时有效。
所有的材质属性都必须在一个具体的内存缓冲区中定义,而不是在全局作用域定义。这是通过在一个名为UnityPerMaterial
的cbuffer
块中包装_BaseColor
声明实现的。这类似于结构体声明,但必须以分号结束。它通过将_BaseColor
放在特定的常量内存缓冲区中,尽管它在访问级别上仍然是全局性的。
cbuffer UnityPerMaterial {
float _BaseColor;
};
常量缓冲区不是在所有平台上都支持的——比如OpenGL ES 2.0
——所以我们可以使用Core RP
中包含的CBUFFER_START
和CBUFFER_END
宏来代替直接使用cbuffer
。第一个宏将缓冲区名称作为参数,就像一个函数一样。在目前的情况下,我们得到了与之前完全相同的结果,只是cbuffer
代码会在不支持它的平台上不存在。
CBUFFER_START(UnityPerMaterial)
float4 _BaseColor;
CBUFFER_END
我们也必须为unity_ObjectToWorld
、unity_WorldToObject
和unity_WorldTransformParams
做同样的操作。
CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
real4 unity_WorldTransformParams;
CBUFFER_END
在这种情况下,如果使用其中某个值,则需要定义特定的值的群组。对于变换信息的组,我们还需要包含float4 unity_LODFade
,即使我们没有使用它。确切的顺序并不重要,但Unity将它直接放在unity_WorldToObject
之后,所以让我们也这样做。
CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
float4 unity_LODFade;
real4 unity_WorldTransformParams;
CBUFFER_END
随着我们的着色器兼容SRP,下一步是启用SRP批处理,这是通过将GraphicsSettings.useScriptableRenderPipelineBatching
的值
设置为true
来完成的。我们只需要将它执行一次,所以让我们在RP被创建时这样做,通过添加一个CustomRenderPipeline
的构造函数来完成。
public CustomRenderPipeline () {
GraphicsSettings.useScriptableRenderPipelineBatching = true;
}
统计面板显示有76个批次被合并,尽管它显示的是一个负数。帧调试器现在在RenderLoopNewBatcher.Draw
下显示了一个单独的SRP Batch
条目。记住这不是一个单独的 Draw Call,而是他们优化后的一个序列。
2.2 更多颜色(Many Colors)
尽管我们用了四种材质,我们却只消耗了一个批次。这是可行的,因为所有的数据都缓存在GPU上,每次绘制调用只需要一个指向到正确内存位置的索引。唯一的限制是每个材质的内存布局必须是相同的,这是因为我们为他们使用相同的只包含一个单一的颜色属性的着色器Unity不比较材料的内存布局,它只是对使用完全相同的着色器变体的draw call进行批处理。
如果我们仅仅想要得到这几种不同的颜色,批处理将如我们所愿表现的很好。但如果我们想让每个球体产生不同的颜色,那么我们就必须创建更多的材质。如果我们可以为每个对象设置颜色那就更方便了。这在默认情况下是不可能的,但我们可以通过创建自定义组件来支持它。新建一个c#脚本并命名为PerObjectMaterialProperties
。因为它是一个示例,我把它放在Custom RP
下的Examples
文件夹中。
这个想法是,一个游戏对象可以有一个PerObjectMaterialProperties
组件,它有一个BaseColor
配置选项,将用于设置它的_BaseColor
材质属性。它需要知道shader属性的标识符,我们可以通过Shader.PropertyToID获取它并存储在一个静态变量中,就像我们在CameraRenderer
中为shader pass
标识符做的那样,当然在这个例子中它是一个int
类型。
需要注意的是,我在类名上方添加了一个[DisallowMultipleComponent]属性,他可以防止一个组件被多次添加到同一个物体上。
using UnityEngine;
[DisallowMultipleComponent]
public class PerObjectMaterialProperties : MonoBehaviour {
static int baseColorId = Shader.PropertyToID("_BaseColor");
[SerializeField]
Color baseColor = Color.white;
}
设置逐对象的材质属性是通过一个MaterialPropertyBlock对象完成的。我们需要一个所有PerObjectMaterialProperties
实例都可以重用的字段,因此为它声明一个静态字段。
static MaterialPropertyBlock block;
如果block
为空,那就创建一个新的block
,然后用属性标识符和颜色调用它的SetColor
方法,然后通过SetPropertyBlock
将block
传递到游戏对象的Renderer组件。在OnValidate中做这些,结果会立即显示在编辑器中。
void OnValidate () {
if (block == null) {
block = new MaterialPropertyBlock();
}
block.SetColor(baseColorId, baseColor);
GetComponent<Renderer>().SetPropertyBlock(block);
}
OnValidate() 在什么时候被调用
.
当组件在Unity编辑器被加载或更改时,OnValidate 被调用。所以每次加载场景和编辑组件时,一些颜色会立即出现并对编辑的做出反应。
我给24个随便摆放的球体添加了这个组件,并给它们调配了不同的颜色。
不幸的是,SRP批处理不能处理逐对象的材质属性。 因此,24个球体的绘制会回到一个常规的draw call,由于排序的影响,可能还会将这些球体划分在多个批次中。
此外,OnValidate 在 build 中不会被调用,所以我们还必须在Awake中调用它。
void Awake () {
OnValidate ();
}
2.3 GPU实例化(GPU Instancing)
还有另一种方法来合并 draw call,它对逐对象的材质属性都有效。这就是众所周知的GPU Instancing
,它是通过为具有相同网格的多个对象发出一个 draw call 来实现的。CPU收集每个对象的变换信息和材质属性,并将它们放在数组中发送给GPU。然后,GPU遍历所有条目,并按照提供的顺序渲染它们。
因为 GPU Instancing 需要通过数组来提供数据,我们的shader目前不支持它。第一步是添加#pragma multi_compile_instancing
指令,位于我们的shader
的Pass
块的vertex
和fragment
指令上方。
#pragma multi_compile_instancing
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment
这将使Unity为生成我们的两个shader变体,一个支持GPU Instancing,一个不支持。材质面板中还出现了一个开关选项,允许我们选择每个材质使用哪个版本。
支持GPU Instancing需要改变一些东西,为此我们必须引用shader核心库中的UnityInstancing.hlsl
文件。我们在定义UNITY_MATRIX_M
和其他宏之后并在引用SpaceTransforms.hlsl
之前做这个。
#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 Attributes {
float3 positionOS : POSITION;
};
float4 UnlitPassVertex (Attributes input) : SV_POSITION {
float3 positionWS = TransformObjectToWorld(input.positionOS);
return TransformWorldToHClip(positionWS);
}
当使用 GPU Instancing 时,对象索引也可以作为顶点属性使用。我们可以在属性中添加UNITY_VERTEX_INPUT_INSTANCE_ID
。
struct Attributes {
float3 positionOS : POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
接下来,在UnlitPassVertex
的开始处添加UNITY_SETUP_INSTANCE_ID(input);
。这将从input
中提取索引,并将其存储在其他实例化宏依赖的全局静态变量中。
float4 UnlitPassVertex (Attributes input) : SV_POSITION {
UNITY_SETUP_INSTANCE_ID(input);
float3 positionWS = TransformObjectToWorld(input.positionOS);
return TransformWorldToHClip(positionWS);
}
这足以让GPU Instancing产生作用,但因为SRP Batcher更优先,所以我们现在没有得到一个不同的结果。但是我们还不支持每个实例的材质的数据。这是通过用UNITY_INSTANCING_BUFFER_START
替换CBUFFER_START
和用UNITY_INSTANCING_BUFFER_END
替换CBUFFER_END
来实现的。
//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)
。
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
// float4 _BaseColor;
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
当实例被使用时,我们现在还必须使实例索引在UnlitPassFragment
中可以访问。为了使这变得简单,我们将使用一个struct
来为UnlitPassVertex
输出位置和索引,使用UNITY_TRANSFER_INSTANCE_ID(input,output);
在索引存在时复制它。我们将这个结构命名为Varyings
,就像Unity所做的那样,因为它包含的数据可以在相同三角形的片元之间变化。
struct Varyings {
float4 positionCS : SV_POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
Varyings UnlitPassVertex (Attributes input) { //: SV_POSITION {
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)
来访问。
float4 UnlitPassFragment (Varyings input) : SV_TARGET {
UNITY_SETUP_INSTANCE_ID(input);
return UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
}
Unity现在能够将24个逐对象颜色的球体合并,从而减少了 draw call 的数量。因为这些球体仍然使用了4种材质,所以我们最终得到了4个draw calls。GPU Instancing只适用于使用了相同材质的对象。 他们只是重写了材质的颜色,因此如果使用相同的材质就允许它们在单个批次中被绘制。
注意,根据目标平台和每个实例需要提供的数据量,批处理大小是有限制的。如果你超过这个限制,你就会得到不止一个批次。此外,如果有多个材质在使用,排序操作仍然会拆分批次。
2.4 绘制大量实例化网格(Drawing Many Instanced Meshes)
当数百个对象可以在一个 draw call 中合并时,GPU-Instancing成为一个显著的优势。但是手工编辑场景中这么多物体很繁琐的,所以让我们通过巧妙的方法随机生成一堆。创建一个MeshBall.cs
组件,当它被激活时将生成许多对象。让它缓存_BaseColor
着色器属性,并添加支持实例化的Mesh和Material的配置选项。
using UnityEngine;
public class MeshBall : MonoBehaviour {
static int baseColorId = Shader.PropertyToID("_BaseColor");
[SerializeField]
Mesh mesh = default;
[SerializeField]
Material material = default;
}
创建一个游戏对象并添加这个组件。我给了它默认的Sphere
网格来绘制。
我们可以生成许多新的游戏对象,但我们并不准备这么做。相反,我们将填充一个矩阵和颜色的数组,并告诉GPU用这些数据来渲染一个网格。 这就是GPU-Instancing最有用的地方。我们可以一次性提供多达1023个实例,所以让我们添加具有该长度的数组的字段,以及一个我们需要传递颜色数据的MaterialPropertyBlock
。在本例中,颜色数组的元素类型必须是Vector4
。
Matrix4x4[] matrices = new Matrix4x4[1023];
Vector4[] baseColors = new Vector4[1023];
MaterialPropertyBlock block;
创建一个Awake
方法,在半径为10的球体内产生随机位置和随机RGB颜色数据,并填充数组。
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
中,如果block还不存在那我们创建一个新的block,并调用SetVectorArray
来配置颜色。 之后调用Graphics.DrawMeshInstance`,并使用网格、默认为零的子网格索引、材质、矩阵数组、元素数量和属性块作为参数。
void Update () {
if (block == null) {
block = new MaterialPropertyBlock();
block.SetVectorArray(baseColorId, baseColors);
}
Graphics.DrawMeshInstanced(mesh, 0, material, matrices, 1023, block);
}
运行游戏将会产生一个密集的球体。具体有多少次draw call取决于平台,因为每次draw call 的最大缓冲区大小是不同的。就我这里而言,需要三个draw call来渲染。
请注意,各个mesh的绘制顺序与我们提供数据的顺序相同。除此之外,没有任何与排序或剔除有关的操作,然而一旦它在相机视图锥体之外,整个批次将消失。
2.5 动态批处理(Dynamic Batching)
还有第三种减少 draw call 的方法,称为动态批处理。这是一个古老的技术,将多个共享相同材质的小网格组合成一个更大的网格。然而当使用逐对象的材质属性时,这也不起作用。
更大的网格会根据需要生成,所以它只适用于小网格。球体相对而言太大了,它更适用于立方体。想查看它的行为的话,可以在CameraRenderer.DrawVisibleGeometry
中禁用GPU-Instancing
和设置enableDynamicBatching
为true
。
var drawingSettings = new DrawingSettings(
unlitShaderTagId, sortingSettings
) {
enableDynamicBatching = true,
enableInstancing = false
};
同时禁用SRP Bather
,因为它具有更高的优先级。
GraphicsSettings.useScriptableRenderPipelineBatching = false;
一般来说,GPU-Instancing比动态批处理工作得更好。这种方法也需要注意一些东西,例如,当涉及到不同的缩放时,合并后大网格的法向量不能保证是单位长度。此外,绘制顺序也可能改变,因为它现在是一个网格,而不是多个。
还有静态批处理(Static Batching
),它的工作方式类似,但仅对标记为batching-static
的对象非运行状态下提前执行。除了需要更多的内存和存储空间。
2.6 配置批处理(Configuring Batching)
哪种批处理方式是最好的,在各种情况下都可能不同,所以让我们使它们可配置。首先,取代硬编码方式,添加布尔参数来控制动态批处理和GUI-Instancing是否启用。
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
方法现在必须提供此配置。
public void Render (
ScriptableRenderContext context, Camera camera,
bool useDynamicBatching, bool useGPUInstancing
) {
…
DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
…
}
CustomRenderPipeline
将通过字段来跟踪这些选项,在它的构造函数中设置字段,并在Render() 中传递它们。还要在构造函数中为SRP Bather添加一个布尔参数,而不是总是启用它。
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, Camera[] cameras) {
foreach (Camera camera in cameras) {
renderer.Render(
context, camera, useDynamicBatching, useGPUInstancing
);
}
}
最后,将这三个选项作为配置字段添加到CustomRenderPipelineAsset
,并将它们传递给CreatePipeline
中的构造函数。
[SerializeField]
bool useDynamicBatching = true, useGPUInstancing = true, useSRPBatcher = true;
protected override RenderPipeline CreatePipeline () {
return new CustomRenderPipeline(
useDynamicBatching, useGPUInstancing, useSRPBatcher
);
}
开关一个选项将立即生效,因为当Unity编辑器检测到资源发生变化时,它将创建一个新的RP实例。
2. 透明(Transparency)
我们的shader目前可以用来创建无光照的不透明材料。 可以尝试更改颜色的表示透明度的alpha
属性,但没有效果。我们也可以将渲染队列(Render Queue
)设置为Transparent
,但是这只更改了对象何时被绘制和处理哪个绘制序列,而不是如何绘制。
我们不需要写一个单独的shader来支持透明材质。通过一点额外的工作,我们的Unlit着色器就可以同时支持非透明和透明渲染。
3.1 混合模式(Blend Modes)
非透明渲染和透明渲染的主要区别在于,我们是替换之前绘制的内容,还是结合之前绘制的结果,从而来产生透明效果。我们可以通过设置将来源和目标混合的模式来控制这一点。在这里,来源指的是现在绘制的内容,目标指的是之前绘制的内容以及将怎样产生结果。为此我们需要添加两个着色器属性:_SrcBlend
和_DstBlend
。它们是混合模式的枚举,但我们可以使用的最佳类型是Float
,默认情况下,Source
设置为1,Destination
设置为0。
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 枚举类型作为参数。
默认值表示我们已经使用的不透明混合配置。 Src Blend
被设置为1,意味着它被完全添加,而Dst Blend
被设置为0,意味着它被忽略。
标准透明度的Src Blend
混合模式是SrcAlpha
,这意味着颜色的RGB分量乘以它的alpha分量,alpha越低颜色就越弱。然后将Dst Blend
混合模式设置为相反的值OneMinusSrcAlpha
,以达到混合的总权重为1。
混合模式可以在Pass
块中定义,我们可以通过将混合模式的属性名放在方括号中来访问它们,这是在可编程着色器出现之前的旧语法。
Pass {
Blend [_SrcBlend] [_DstBlend]
HLSLPROGRAM
…
ENDHLSL
}
3.2 不写入深度(Not Writing Depth)
透明度渲染通常不会写入深度缓冲区,因为它不会从中受益,甚至可能产生一些我们不希望的结果。我们可以通过ZWrite
语句控制是否写入深度。我们也可以使用shader属性进行控制,这次使用_ZWrite
。
Blend [_SrcBlend] [_DstBlend]
ZWrite [_ZWrite]
用一个自定义的Enum(Off, 0, On, 1)
属性来定义shader属性,以创建一个默认开启的开关。
[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
3.3 纹理(Texturing)
之前我们使用alpha贴图创建了一个不均匀的半透明材质。让我们现在通过添加_BaseMap
纹理属性到shader中来支持它。在本例中,这个纹理的类型是2D,我们将使用Unity的标准白色纹理作为默认值,用white
字符串表示。同样,我们必须用一个空代码块结束纹理属性。很久以前,它被用来控制设置纹理,直到今天也仍然应该使用,以防止产生奇怪的错误。
_BaseMap("Texture", 2D) = "white" {}
_BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
纹理必须上传到GPU内存中,这是Unity背后为我们做的。Shader需要一个与纹理有关的句柄,我们可以像定义一个值一样定义它,除了我们使用带有名称的TEXTURE2D
宏作为参数。 我们还需要为纹理定义一个采样状态,它控制它应该如何采样,并考虑到它的包装(wrap
)和滤波(filter
)模式。 这是通过SAMPLER
宏完成的,就像TEXTURE2D
,但在名称前加上了SAMPLER
。 这与Unity自己提供的采样器状态的名称相匹配。
纹理和采样状态是shader资源。不能为每个实例提供,因此必须在全局作用域中声明。在UnlitPass.hlsl
中的声明shader属性之前做这个。
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
除此之外,Unity还通过float4
类型来实现纹理的平铺(tilling
)和偏移(offset
),其名称与纹理属性相同,但添加了_ST
为后缀,它代表缩放和平移或类似的一些东西。这个属性应该是UnityPerMaterial
缓冲区的一部分,因此可以在每个实例中设置。
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
。
struct Attributes {
float3 positionOS : POSITION;
float2 baseUV : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
我们需要将坐标传递给片元函数,因为纹理就是在这里采样的。添加float2 baseUV
到Varyings
。 这一次我们不需要添加一个特殊的语义,它只是我们内部传递的数据,不需要得到GPU的特别关注。然而,我们仍然需要赋予它一些意义。我们可以使用任何未被使用的标识符,例如使用VAR_BASE_UV
。
struct Varyings {
float4 positionCS : SV_POSITION;
float2 baseUV : VAR_BASE_UV;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
当我们在UnlitPassVertex
中复制坐标时,我们也可以应用存储在_BaseMap_ST
中的缩放和偏移量。这样我们就可以逐顶点(per-vertex)来做,而不是逐片元(per-fragment)。将缩放存储在XY
中,偏移量存储在ZW
中。
Varyings UnlitPassVertex (Attributes input) {
…
float4 baseST = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseMap_ST);
output.baseUV = input.baseUV * baseST.xy + baseST.zw;
return output;
}
现在UnlitPassFragment
可以使用UV坐标,在三角形上进行插值。在这里,通过使用带有纹理、采样器状态和坐标参数的SAMPLE_TEXTURE2D
宏对纹理进行采样。最后的颜色是将纹理和颜色通过乘法运算合并在一起。两个相同大小的向量相乘会得到所有互相匹配的分量相乘,所以在这种情况下,红色乘以红色,绿色乘以绿色,等等。
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;
}
3.4 透明度裁剪(Alpha Clipping)
另一种透视表面的方法是在表面上挖洞。Shader 也可以做到这一点,通过丢弃一些片段。这会产生硬边,而不是我们目前看到的平滑过渡。这种技术被称为透明度裁剪。通常的方法是定义一个裁剪阈值。透明度低于这个阈值的片元将被丢弃,而其他的片元将被保留。
添加一个_Cutoff
属性,默认设置为0.5。 由于alpha总是位于0和1之间,我们可以使用Range(0.0, 1.0)
作为它的类型。
_BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
_Cutoff ("Alpha Cutoff", Range(0.0, 1.0)) = 0.5
同样将它添加到UnlitPass.hlsl
的材质属性中。
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)
我们可以通过调用UnlitPassFragment
中的裁剪函数来抛弃片元。如果传递给它的值为0或更小,它将中止并抛弃该片元。因此,通过使用属性a
或w
减去裁剪阈值后将得到的alpha值传递给它。
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混合或alpha裁剪,而不是同时使用两者。一个典型的裁剪类型的材质是完全不透明的,除了被舍弃的片元,并且会写入深度缓冲区。它使用
AlphaTest
渲染队列,这意味着它在所有完全不透明的对象之后进行渲染。这样做是因为抛弃片元使得一些与GPU有关的优化行为不再允许,因为三角形不再被认为完全覆盖它们后面的东西。通过首先绘制完全不透明的对象,它们可能会覆盖部分使用了透明度裁剪的对象,然后就不需要处理它们的隐藏片元。
但要使上述优化行为生效,我们必须确保裁剪行为只在需要时使用。我们将通过添加一个有着开关特性的shader属性来做到这一点。它是一个默认为零的浮点类型的属性,带有一个Toggle
属性,它可以控制一个shader关键字,我们将使用_CLIPPING
作为关键字。属性本身的名称并不重要,所以简单的使用_Clipping
就挺好。
_Cutoff ("Alpha Cutoff", Range(0.0, 1.0)) = 0.5
[Toggle(_CLIPPING)] _Clipping ("Alpha Clipping", Float) = 0
3.5 着色器特性(Shader Features)
启用这个开关将添加_CLIPPING
关键字到材质的已激活关键字列表,而禁用将删除它。但是它现在本身并没有做任何事情。我们必须告诉Unity根据关键字是否已经定义来编译一个不同版本的shader。我们通过在指令的Pass中添加#pragma shader_feature _CLIPPING
来实现这个。
#pragma shader_feature _CLIPPING
#pragma multi_compile_instancing
现在Unity将编译我们的shader代码,不管是否定义了_CLIPPING
。它将生成一个或两个变体,这取决于我们如何配置材质。因此,我们可以在代码中以定义的关键字作为条件,就像引用保护一样,但在本例中,我们只希望在定义了_CLIPPING
之后包含裁剪行为。我们可以使用#ifdef _CLIPPING
,但我更喜欢使用#if defined(_CLIPPING)
。
#if defined(_CLIPPING)
clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
#endif
3.6 逐对象裁剪(Cutoff Per Object)
因为cutoff
是UnityPerMaterial
缓冲区的一部分,它可以为每个实例配置。让我们添加这种功能到PerObjectMaterialProperties.cs
中。它的工作原理与颜色相同,除了我们需要在block
上调用SetFloat
而不是SetColor
。
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);
}
3.6 透明度裁剪的球体(Ball of Alpha-Clipped Spheres)
对于MeshBall.cs
也是如此。现在我们可以使用裁剪材质,但所有实例都有完全相同的透明孔洞。
让我们通过给每个实例赋予随机旋转的功能,以及在0.5-1.5范围内的随机的缩放来添加一些多样性。但是我们将通过在0.5-1的范围内随机它们颜色的alpha值,而不是为每个实例设置cutoff值。这给了我们不那么精确的控制,但它只是一个简单随机的例子。
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仍然会发送一个cutoff值的数组给GPU,每个实例一个值,即使它们都是相同的。这个值是材料中值的副本,所以通过改变它,可以一次性改变所有球体的透明孔洞,即使它们仍然不同。