这是有关创建自定义脚本渲染管道的系列教程的第二部分。 本次会讲着色器的编写以及如何高效的绘制多个对象。
Shader
为了绘制几何体,CPU需要告诉GPU绘制什么东西,以及如何绘制。所绘制的东西通常都是网格(mesh)。如何绘制可以通过Shader来控制。Shader就是一些GPU指令的集合。除了要绘制的网格,Shader还需要其他的信息,其中包括变换矩阵,材质信息。
Unity的LW(或称Universal)RP和HDRP 允许你用ShaderGraph包来设计Shader,Shader Graph可以帮你生成Shader代码。但是我们的自定义的RP是不支持这个的,所以我们只能自己写Shader。这也可以让我们对Shader具体做了什么有更好的理解。
Unlit Shader
我们的第一个shader就是简单的完全用一个颜色去画一个网格,不包括任何光照效果。一个shader资产可以通过Assets / Create / Shader 来创建。Unlit Shader 是最合适的,但是我们还是会删掉里面所有预制的代码。将新的Shader文件命名为Unlit并放到Shaders文件夹下,Shaders文件夹在Runtime同层级的文件夹中,如图。
原图
Shader的代码大部分看起来很像C#代码,但是它包含多种不同的样式,其中包括一些已经过时了的。
Shader 的定义类似一个类的定义,但是是通过用Shader
关键字后跟随一个字符串来声明的。该字符串表示该Shader在选择Shader的下拉菜单中的位置。我们这里用"Custom RP/Unlit"。然后会跟随一对大括号,大括号里面还会有很多前面带着关键字的大括号。Properties
块定义了材质的属性,然后是SubShader
块,该块的内部需要有一个Pass
块,这个pass块就描述了渲染物体的方法。
Shader "Custom RP/Unlit" {Properties {}SubShader {Pass {}}
}
这样我们定义了一个可以用来创建材质球的,能通过编译的最简单的shader
原图
默认材质会将材质渲染成纯白色。材质会有一个默认的参数来控制RenderQueue,默认是2000,也就是不透明物体的渲染队列。还有一个勾选框来启用双面的全局照明,不过我们不关心这个。
本节做了:
- 通过菜单创建shader文件,命名为Unlit,放在“Custom RP/Shaders”文件夹下
- shader命名为 “Custom RP/Unlit”
- 包括两个块:
Properties
,SubShader
SubShader
块下包括一个Pass
块
HLSL代码
我们写Shader所用的语言是 High-Level Shading Language,简写为HLSL。我们需要将这部分代码放在Pass块中,并且被HLSLPROGRAM
与ENDHLSL
关键字包住。我们之所以这样做是因为还可以将其他类型的shader语言放在Pass块中。
Pass {HLSLPROGRAMENDHLSL}
为了绘制网格,GPU必须把连续的三角形(网格由很多三角形构成)变成一个个像素点(称为光栅化)。为了完成这件事,首先需要把每个顶点的坐标从3D空间变换到2D空间,然后对被三角形覆盖住的像素进行填充。这两步被不同的,我们可以编写的shader程序控制。控制第一步的shader程序被称为vertex shader,控制第二步的称为fragment shader。每个片元(fragment)对应与屏幕上的一个像素,或者纹理中的一个像素(即纹素 texel),fragment并不是最终的像素,因为它可能被其他的绘制在它上面的其他东西(比如其他fragment)盖住。
我们需要为这两个程序起个名字,通过编译指令。这是一行由#pragma
开头,跟着vertex
或fragment
,再跟着着色器的名字,我们这里叫做UnlitPassVertex
和UnlitPassFragment
HLSLPROGRAM#pragma vertex UnlitPassVertex#pragma fragment UnlitPassFragmentENDHLSL
shader 的编译器现在会报错找不到声明的shader代码(UnlitPassVertex和UnilitPassFragment)我们需要实现两个同名的HLSL函数,来定义这两个着色器。我们可以直接把代码写在这里,但是我们并不这样做。我们将这些HLSL代码放到另一个文件中。我们把这个文件命名为“UnlitPass.hlsl”在同一个文件夹下。我们可以用#include
将这个文件通过相对路径添加进来。
HLSLPROGRAM#pragma vertex UnlitPassVertex#pragma fragment UnlitPassFragment#include "UnlitPass.hlsl"ENDHLSL
Unity并没有创建HLSL文件的菜单选项,所以我们自己来创建一个空文件。
原图
本节做了:
- 用
HLSLPROGRAM
和ENDHLSL
包住Pass中的所有代码 - 指定顶点着色器,片元着色器,通过
#pragma vertex UnlitPassVertex
,和#pragma fragment UnlitPassFragment
- 在同文件夹中创建一个“UnlitPass.hlsl”文件
- 在Pass中包含这个文件
包含防护(Include Guard)
HLSL对文件的分组类似于C#的类,虽然HLSL并没有类的概念。除了代码块的局部作用域,HLSL只有一个全局的作用域。所以所有的东西在任何地方都是可以访问到的。将文件包含(include)进来与引入(using)一个命名空间也是不同的。这会将文件中所有的代码拷贝到包含指令的位置上。所以如果包含了同一个文件很多次,就会由重复的代码,并很有可能编译出错。为了防止这个问题,我们为UnilitPass.hlsl添加包含防护(Include Guard)。
我们可以用#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
我们现在就可以保证文件中的代码不会被插入多次,即使我们对其进行了多次包含操作。
本节做了:
- 在“UnlitPass.hlsl”文件中进行宏操作:
- 如果没定义
CUSTOM_UNLIT_PASS_INCLUDED
,就定义这个标志 - 在文件结尾结束判断
- 所有的代码写在这个判断范围之内。保证代码只被包含一次
Shader函数
我们在保护的范围内定义自己的shader函数。他们的语法格式就类似于C#的方法。
#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDEDvoid UnlitPassVertex () {
}void UnlitPassFragment () {
}#endif
现在我们的shader就已经可以编译通过了。结果是一个青色的材质
原图
通过修改片元函数的返回值,我们可以改变这个颜色。颜色被定义为有4个分量的float4
类型的向量,这4个分量分别代表了红绿蓝透明度。我们可以只返回一个0,会自动扩展到整个向量。透明度在这里不起作用,因为我们现在创建的是不透明shader
float4 UnlitPassFragment () {
return 0.0;
}
我们应当用
float
还是half
类型
大多数的GPU支持这两种类型。而half会更高效。所以如果你在为移动设备做优化,尽可能的用half是没问题的。一个经验法则是float只用在位置和纹理坐标上,其他的地方都用half。
当不是面向移动平台的时候,经度并不是问题,因为GPU总会用float无论你写了什么。我在本套教程中会用float。
还有一种fixed类型,不过这种类型只被旧的硬件支持。通常情况下等价于half
现在我们的shader还是会编译失败,因为这个函数没有语义。我们必须指明返回值到底代表了什么,因为有些情况可能会产生很多数据,并且有不同的作用。在这里我们用系统定义的渲染目标 ,通过在声明的后面加上冒号,再加上“SV_TARGET”。
float4 UnlitPassFragment () : SV_TARGET {
return 0.0;
}
“UnlitPassVertex”负责处理顶点的坐标,所以我们会返回一个位置,也是float4类型的,因为这个坐标必须是其次裁剪坐标系下的,我们会在后面说明。目前我们先用零向量来代表,并且把语义声明为“SV_POSITION”
float4 UnlitPassVertex () : SV_POSITION {
return 0.0;
}
本节做了:
- 在UnilitPass.hlsl文件中定义两个函数 UnlitPassFragment和UnlitPassVertex
- 注意定义返回的语义。
空间坐标变换
当所有顶点的坐标都设为0后,网格会塌陷成一个点,所以没有任何东西被渲染出来。vertex函数的主要职责就是把顶点的位置变换到正确的坐标系下。
在这个函数被调用的时候,会传入我们需要的参数。我们通过给函数添加参照来做到这一点。我们需要顶点的局部坐标系中的位置,可以命名为“positionOS”,在Unity的新渲染管线下,也用这个名字。类型是float3,因为是一个3维空间的点。我们目前先直接返回这个数,并把第四个分量设置为1。
float4 UnlitPassVertex (float3 positionOS) : SV_POSITION {
return float4(positionOS, 1.0);
}
对于这个输入也需要添加语义,因为顶点数据不只有位置。在这里需要在参数的后面加上冒号再加上“POSITION”
float4 UnlitPassVertex (float4 positionOS : POSITION) : SV_POSITION {
return float4(positionOS, 1.0);
}
原图
网格再次显示出来了,但是由于我们输出的坐标并没有被转换到正确的坐标系下,所以结果是不对的。坐标转换需要用到矩阵,这些矩阵会在GPU渲染东西的时候被传入。我们需要添加这个矩阵到我们的shader中,但是由于这些矩阵总是相同的,所以我们将把Unity提供的标准输入放到另一个HLSL文件中,这样既可以保持代码的整洁,也可以让其他代码复用。新建一个“UnityInput.hlsl”文件,并放到“ShaderLibrary”文件夹中,这个文件夹在“Custom RP”中,这与Unity的RP有相同的文件结构。
原图
我们用“CUSTOM_UNITY_INPUT_INCLUDED”作为包含保护的标识符。然后我们在全局作用域中定义一个float4x4类型的矩阵,叫做“unity_ObjectToWorld”。在c#类中,这类似于定义了一个字段,但是在这里一般被称为是通用变量(uniform value)。它由GPU每次绘制设置一次,在每次绘制的期间,这个变量的值对所有顶点和片元函数的调用保持不变(统一)。
#ifndef CUSTOM_UNITY_INPUT_INCLUDED
#define CUSTOM_UNITY_INPUT_INCLUDEDfloat4x4 unity_ObjectToWorld;#endif
你可以用矩阵进行坐标空间到世界空间的转换。由于这是个通用的功能,所以我们为他创建一个函数,并放到另一个文件中,叫做“Common.hlsl”同样在“ShaderLibrary”文件夹下。我们在这个文件夹中包含“UnityInput.hlsl”,然后声明一个“TransformObjectToWorld”函数,并用float3作为返回值,和参数。
#ifndef CUSTOM_COMMON_INCLUDED
#define CUSTOM_COMMON_INCLUDED#include "UnityInput.hlsl"float3 TransformObjectToWorld (float3 positionOS) {
return 0.0;
}#endif
坐标系的转换通过调用mul
函数将矩阵与向量相乘做到。在这里,我们需要一个4维的向量,但是第四维的值永远是1,我们可以通过float4(positionOS, 1.0)
得到。返回值依然是4维的,我们可以通过.xyz
提取出前三维。
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);
}
结果仍然是错误的,因为我们需要齐次剪辑空间中的位置。 该空间定义了一个立方体,其中包含摄像机所看到的所有内容,如果是透视摄像机,则它会变形为梯形。 从世界空间到该空间的变换可以通过与视图投影矩阵相乘来完成,该视图投影矩阵考虑了相机的位置,方向,投影,视野和远近裁剪平面。 将其添加到UnityInput.hlsl。
float4x4 unity_ObjectToWorld;float4x4 unity_MatrixVP;
在“Common.hlsl”中添加“TransformWorldToHClip”函数,这与“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 中调用这个函数,得到正确的坐标
float4 UnlitPassVertex (float3 positionOS : POSITION) : SV_POSITION {
float3 positionWS = TransformObjectToWorld(positionOS.xyz);return TransformWorldToHClip(positionWS);
}
原图
本节做了:
- 创建一个“UnityInput.hlsl”文件在“Custom RP/ShaderLibrary”文件夹下
- 用
CUSTOM_UNITY_INPUT_INCLUDED
做包含保护 - 声明变量:
float4x4 unity_ObjectToWorld;
- 声明变量:
float4x4 unity_MatrixVP;
- 创建一个“Common.hlsl”文件,在相同的文件夹下。
- 用
CUSTOM_COMMON_INCLUDED
做包含保护 - 包含“UnityInput.hlsl”
- 创建函数
TransformObjectToWorld
入参和返回值都是float3
类型 - 返回
mul(unity_ObjectToWorld, float4(positionOS, 1.0)).xyz
- 创建函数
TransformWorldToHClip
入参是float3
,返回flaot4
- 返回
mul(unity_MatrixVP, float4(positionWS, 1.0))
- 在 “UnlitPass.hlsl”中修改顶点着色器:
- 增加
float3
类型的入参,语义定义为POSITION
- 依次调用上面创建的两个函数,然后返回结果。
核心库
刚刚我们写的那两个函数由于太通用了,所以在“Core RP Pipeline”中也有他们的定义。核心库有很多有用的基础的东西,所以我们安装这个包,然后删掉我们自己的定义,并包含相关的文件,在这个路径中“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.hls”并没有假设已经有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_MatrixV
通过UNITY_MATRIX_V
代表;unity_MatrixVP
通过UNITY_MATRIX_VP
代表;glstate_matrix_projection
通过UNITY_MATRIX_P
代表。我们不需要这些矩阵,但是如果不定义这些,就无法编译通过。
#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
类型的变量。real4
只是个别名,具体可能是float4
或half4
,这取决于目标平台。
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
real4 unity_WorldTransformParams;
这个别名和许多其他的基础的宏都是按照图形API定义的,我们可以通过包含“ Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl”得到。所以在我们的“Common.hlsl”中,在包含“UnityInput.hlsl”之前我们可以包含这个文件。如果您对这些文件的内容感兴趣,可以看看包中的这些文件。
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "UnityInput.hlsl"
本节做了:
- 安装“Core RP Pipeline”包
- 在“Common.hlsl”文件中删掉自己写的函数,按顺序做如下操作
- 包含 “Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl”
- 包含 “UnityInput.hlsl”
- 对用宏对
UNITY_MATRIX_M
,UNITY_MATRIX_I_M
,UNITY_MATRIX_V
,UNITY_MATRIX_VP
,UNITY_MATRIX_P
进行定义,上面有代码 - 包含 “Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl”
- 在 “UnityInput.hlsl”中新增声明,上面同样有代码
- 新增声明
real4 unity_WorldTransformParams;
颜色
物体的颜色可以通过UnlitPassFragment来改变。例如,我们可以通过返回float4(1.0, 1.0, 0.0, 1.0)
来让物体变成黄色。
float4 UnlitPassFragment () : SV_TARGET {
return float4(1.0, 1.0, 0.0, 1.0);
}
为了能够配置每种材料的颜色,我们必须将其定义为统一的值。 在UnlitPassVertex函数之前,在include指令下执行此操作。 我们需要一个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。为了让材质与我们添加的“_BaseColor”联系起来,我们需要在“Unlit”shader文件中的 Properties
块中添加“_BaseColor”属性。
Properties {
_BaseColor}
属性后面会跟着一个小括号,其中第一个字符串是在Inspector面板中显示的名字,第二个代表了这个值的类型
_BaseColor("Color", Color)
最后我们提供一个默认值,用一组数字组成
_BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
原图
本节做了
- 在“UnlitPass.hlsl”中声明变量
float4 _BaseColor;
- 在shader文件“Unlit”中的
Properties
中做如下声明:_BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
分批
每个DrawCall需要在CPU和GPU之间传递数据。如果有很多数据需要传到GPU,则GPU有可能会浪费时间在等待上。而且当CPU在发送数据的时候,是没办法做其他事情的。这两个问题都会降低帧率。现在我们用了很直接的办法,每个物体都有自己的DrawCall。这样很浪费资源,只是因为我们现在的数据量不大,所以看不到影响。
我做了一个场景,里面有76个球,用来四个不同颜色的材质:红色,绿色,黄色,蓝色。这需要78次drawcall来渲染,76个是球的,1个是天空盒,1个是清理渲染目标
原图
如果在Game面板下打开Stats面板,就可以看到当前帧的数据概览。这里显示有77个batch(忽略了清理渲染目标),以及没有被batch存储的数据(Saved by batching)
Batching是Drawcall的合并,可减少CPU和GPU之间进行通信的时间。 最简单的方法是启用SRP批处理程序。 但是,这仅适用于兼容的着色器,而对我们的Unlit着色器无效。 您可以选择它,在Inspector中看到这个信息。 有一个SRP Batcher行指示不兼容,并给出了原因。
原图
SRP batch不会减少draw call的数量,而是使其更精简。 它在GPU上缓存了材质属性,因此不必在每次绘制调用时都将其发送出去。 这样既减少了必须传送的数据量,又减少了每个draw call,CPU必须完成的工作。 但这仅在着色器遵守用于统一的严格的数据结构时才有效。
所有材料属性都必须在具体的存储缓冲区内定义,而不是在全局级别上定义。 这是通过将_BaseColor声明包装在带有UnityPerMaterial名称的cbuffer块中来完成的。 这就像一个结构体的声明,但必须以分号终止。 它会将将_BaseColor放入特定的常量内存缓冲区,不过仍可在全局级别访问。
cbuffer UnityPerMaterial {
float _BaseColor;
};
并非所有平台(例如OpenGL ES 2.0)都支持常量缓冲区,因此,我们可以使用核心RP库中包含的CBUFFER_START和CBUFFER_END宏,而不是直接使用cbuffer。 第一个将缓冲区名称作为参数,就好像它是一个函数一样。 在这种情况下,我们得到的结果与之前完全相同,只是不支持cbuffer的平台不存在cbuffer代码。
CBUFFER_START(UnityPerMaterial)float4 _BaseColor;
CBUFFER_END
我们还必须对unity_ObjectToWorld,unity_WorldToObject和unity_WorldTransformParams执行此操作,只不过他们要分组在UnityPerDraw缓冲区中。
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批处理程序,这是通过将GraphicsSettings.useScriptableRenderPipelineBatching
设置为true来完成的。 我们只需要执行一次,因此在创建管道实例时来执行此操作。
public CustomRenderPipeline () {
GraphicsSettings.useScriptableRenderPipelineBatching = true;}
原图
“统计”面板显示保存了76个批次,虽然显示为负数。 frame debugger现在在RenderLoopNewBatcher.Draw下显示一个SRP Batch条目,但是请记住,它不是单个draw call,而是对它们的序列的优化。
原图
本节做了:
CBUFFER_START
与CBUFFER_END
封装 shader中的全局变量- 将 材质属性
_BaseColor
封装在UnityPerMaterial
中 - 将 下面四个属性封装在
UnityPerDraw
中 注意,多了一条LOD相关的- float4x4 unity_ObjectToWorld;
- float4x4 unity_WorldToObject;
- float4 unity_LODFade;
- real4 unity_WorldTransformParams;
- 在构造CustomRP的时候, 启用SPR的Batch:
GraphicsSettings.useScriptableRenderPipelineBatching = true;
更多的颜色
即使我们使用四种材料,也只用一个批。 之所以可行,是因为它们的所有数据都缓存在GPU上,并且每个绘制调用仅需包含一个指向正确内存位置的偏移量。 唯一的限制是每种材料的内存布局必须相同,这是因为我们对所有材料都使用相同的着色器,每个着色器仅包含一个颜色属性。 Unity不会比较材质的确切内存布局,它只是会对使用相同的着色器的材质采用批处理。
如果我们需要几种不同的颜色,则可以很好地工作,但是如果我们要为每个球体赋予自己的颜色,那么我们就必须创建更多的材料。 如果我们可以对每个对象都更改颜色(只使用一个材质),则会更加方便。 默认情况下这是不可能的,但是我们可以通过创建自定义组件类型来支持它。 将其命名为PerObjectMaterialProperties。 作为一个示例,我将其放在“ Custom RP”下的“ Examples”文件夹中。
这个想法是,一个游戏对象可以附加一个PerObjectMaterialProperties组件,该组件具有“基础颜色”配置选项,该选项将用于为其设置_BaseColor材质属性。 它需要知道shader属性的标识符,可以通过Shader.PropertyToID检索该标识符并将其存储在静态变量中,就像在CameraRenderer中为shader pass标识符所做的那样,尽管在这种情况下,它是int类型的。
using UnityEngine;[DisallowMultipleComponent]
public class PerObjectMaterialProperties : MonoBehaviour {
static int baseColorId = Shader.PropertyToID("_BaseColor");[SerializeField]Color baseColor = Color.white;
}
原图
设置每个对象的材质属性是通过MaterialPropertyBlock对象完成的。 我们只需要一个实例,因为所有PerObjectMaterialProperties实例都可以重用该实例,因此可以为其声明一个静态字段。
static MaterialPropertyBlock block;
如果该字段还没有被赋值,请创建一个新实例,然后在其上调用SetColor,传入shader属性的id和颜色,然后通过SetPropertyBlock将块应用于游戏对象的Renderer组件,该组件复制其设置。 在OnValidate中执行此操作,以便结果立即显示在编辑器中。
void OnValidate () {
if (block == null) {
block = new MaterialPropertyBlock();}block.SetColor(baseColorId, baseColor);GetComponent<Renderer>().SetPropertyBlock(block);}
我将组件添加到24个任意球体中,并为其赋予了不同的颜色。
原图
不幸的是,SRP的batch无法处理这种情况。 因此,这24个球体每个都属于一次常规的draw call,由于排序,也可能将其他球体分成多个批次。
原图
另外,OnValidate不会在构建中被调用。 为了使各个颜色在那里出现,我们还必须在Awake中应用它们,我们可以通过简单地在此处调用OnValidate来实现。
void Awake () {
OnValidate();}
本节做了:
- 声明一个组件, 叫做
PerObjectMaterialProperties
Shader.PropertyToID("_BaseColor")
得到shader中属性的id, 存储在静态字段中.- 有一个负责控制颜色的字段.
- 有一个静态的,
MaterialPropertyBlock
类型的字段. 用来设置材质的属性 - 在OnValidate中调用
MaterialPropertyBlock
字段的SetColor
, 传入shader的属性id和值 - 调用
Renderer
组件的SetPropertyBlock
, 将MaterialPropertyBlock
字段传入. - 在OnAwake中调用OnValidate. 这是因为OnValidate在发布之后是无效的.
GPU实例
还有一种合并绘制调用的方法,该方法就可以处理上面的情况。 这就是所谓的GPU实例化,其工作原理是一次对具有相同网格物体的多个对象发出一次绘图调用。 CPU收集所有每个对象的变换和材质属性,并将它们放入数组中,然后发送给GPU。 然后,GPU遍历所有条目,并按提供顺序对其进行渲染。
因为GPU实例需要通过数组提供数据,而我们的着色器当前不支持。 进行此工作的第一步是在着色器的Pass块的顶点和片元设置的上方添加#pragma multi_compile_instancing
指令。
#pragma multi_compile_instancing#pragma vertex UnlitPassVertex#pragma fragment UnlitPassFragment
这个会让Unity生成该Shader的两个变体, 一个带有GPU实例,一个没有. 在材质的属性面板上会多出一个勾选框, 来代表应用哪个版本的Shader
为了支持GPU实例化,代码会有一些变换,为此,我们必须包括来自核心着色器库的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定义了宏来简化此过程,但是它们假定我们的顶点着色器函数是以一个结构体作为参数的。
可以声明一个结构体(语法的结构类似cbuffer)并将其用作函数的输入参数。 我们还可以在结构体内部定义语义。 这种方法的优点是,它比长参数列表更清晰易读。 因此,将UnlitPassVertex的positionOS参数包装在新的Attributes结构中,以表示顶点输入数据。
struct Attributes {
float3 positionOS : POSITION;
};float4 UnlitPassVertex (Attributes input) : SV_POSITION {
float3 positionWS = TransformObjectToWorld(input.positionOS);return TransformWorldToHClip(positionWS);
}
使用GPU实例化时,对象索引也可用作顶点属性。 我们可以在适当的时候通过简单地将UNITY_VERTEX_INPUT_INSTANCE_ID
放在属性中来添加它。
struct Attributes {
float3 positionOS : POSITION;UNITY_VERTEX_INPUT_INSTANCE_ID
};
接下来,添加UNITY_SETUP_INSTANCE_ID(input)
; 在UnlitPassVertex的开头。 这将从输入中提取索引,并将其存储在全局静态变量中, 其他GPU实例宏会依赖这些变量。
float4 UnlitPassVertex (Attributes input) : SV_POSITION {
UNITY_SETUP_INSTANCE_ID(input);float3 positionWS = TransformObjectToWorld(input.positionOS);return TransformWorldToHClip(positionWS);
}
这足以使GPU实例化工作,但是由于SRP的批处理有更高的优先级,因此我们现在没有得到不同的结果。 但是我们还不能得到每个实例的材质数据。 为此我们要用数组引用替换_BaseColor。 这是通过用UNITY_INSTANCING_BUFFER_START
替换CBUFFER_START
以及用UNITY_INSTANCING_BUFFER_END
替换CBUFFER_END
来完成的。这个宏也需要一个参数, 这个参数不必与CBUFFER_START
的一样,但是也没必要使它们有所不同。
//CBUFFER_START(UnityPerMaterial)
// float4 _BaseColor;
//CBUFFER_ENDUNITY_INSTANCING_BUFFER_START(UnityPerMaterial)float4 _BaseColor;
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
然后用UNITY_DEFINE_INSTANCED_PROP(float4,_BaseColor)
替换_BaseColor
的定义。
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)// float4 _BaseColor;UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
使用实例化时,我们现在还必须在UnlitPassFragment中提供实例索引。 为了简单起见,我们将使用一个结构,通过UNITY_TRANSFER_INSTANCE_ID(input,output)
来获取到UnlitPassVertex输出位置和索引。 复制索引(如果存在)。 我们像Unity一样用Varying
命名此结构,因为它包含的数据在同一三角形的片段之间可能会有所不同。
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)访问material属性。
float4 UnlitPassFragment (Varyings input) : SV_TARGET {
UNITY_SETUP_INSTANCE_ID(input);return UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
}
原图
现在,Unity可以将24个球体与每个对象的颜色组合在一起,从而减少了draw call的次数。 我最后进行了四个实例化的绘制调用,因为这些球体仍使用其中的四种材料。 GPU实例化仅适用于所有共享相同材质的对象。 当我们只更改材料颜色时,它们可以使用相同的材质,然后就可以在一个batch中绘制。
原图
请注意,基于目标平台以及每个实例必须提供多少数据,批处理大小是有限制的。 如果超过此限制,那么最终将导致一批以上。 此外,如果使用多种材质,排序仍可以拆分批次。(没看懂)
本节做了:
- 在shader代码声明顶点片元函数之前加一句:
#pragma multi_compile_instancing
- 将材质上多出来的 “启用GPU实例” 打勾
- 在定义了 核心包SpaceTransforms中所需变量名 之后, 但是在包含这个文件之前, 包含
Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl
- 将 UnlitPassVertex 函数的入参改为结构体, 叫做
Attributes
. 并在结构体中加入宏UNITY_VERTEX_INPUT_INSTANCE_ID
- 在顶点着色器中用
UNITY_SETUP_INSTANCE_ID(input);
来初始化其他的宏, 其中input是vertex的入参(即带有UNITY_VERTEX_INPUT_INSTANCE_ID
的结构体). - 将顶点着色器函数的返回值和片元着色器函数的入参都变为结构体, 叫做
Varyings
, 同样在结构体中加入UNITY_VERTEX_INPUT_INSTANCE_ID
. - 在顶点着色器中用
UNITY_TRANSFER_INSTANCE_ID(input, output);
将顶点数据从输入的Attributes结构体传送到输出的Varyings结构体中 - 将 shader中材质属性的变量用
UNITY_INSTANCING_BUFFER_START
与UNITY_INSTANCING_BUFFER_END
封起来(而不是原来的cbuffer), 传入的名字不变(可以是任意的) - 用
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
来声明材质属性 - 在片元着色器中, 同样用
UNITY_SETUP_INSTANCE_ID(input)
初始化GPU实例化相关的宏 - 在调用材质属性的时候, 需要用如下形式
UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor)
. 第一个参数就是块的名字
更多参考:
官方GPU实例化的介绍
多个实例网格的绘制
手动编辑场景中的许多对象是不切实际的。 因此,让我们随机生成一些物体。 创建一个MeshBall示例组件,该组件在 Awake 中会生成许多对象。 让它缓存着色器的_BaseColor属性,并添加两个用来配置的成员: 网格和支持实例化的材质。
using UnityEngine;public class MeshBall : MonoBehaviour {
static int baseColorId = Shader.PropertyToID("_BaseColor");[SerializeField]Mesh mesh = default;[SerializeField]Material material = default;
}
创建一个游戏对象, 添加这个组件。我这里用默认的求球模型。
原图
我们可以生成许多新的游戏对象,但不必这样做。 相反,我们将填充变换矩阵和颜色的数组,并告诉GPU用它们渲染网格。 这是GPU实例化最有用的地方。 我们最多可以一次提供1023个实例,因此让我们添加一个长为1023的数组字段,以及需要传递颜色数据的MaterialPropertyBlock。 在这种情况下,颜色数组的元素类型必须为Vector4。
Matrix4x4[] matrices = new Matrix4x4[1023];Vector4[] baseColors = new Vector4[1023];MaterialPropertyBlock block;
创建一个Awake方法,该方法会随机位置和颜色, 去填充数组.
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。 我们在此处设置块,以便球网格能够承受热重载We set up the block here so the mesh ball survives hot reloads.(没看懂)。
void Update () {
if (block == null) {
block = new MaterialPropertyBlock();block.SetVectorArray(baseColorId, baseColors);}Graphics.DrawMeshInstanced(mesh, 0, material, matrices, 1023, block);}
现在进入游戏模式将产生很多球。 由于每个Draw Call的最大缓冲区大小不同,因此需要多少次绘图调用取决于平台。 在作者的情况下,需要进行三个绘制调用才能进行渲染。
请注意,各个网格的绘制顺序与我们提供数据的顺序相同。 除此之外,没有任何排序或剔除的方法,尽管一旦整个批处理在视锥范围内消失,整个批处理都将消失。
本节做了:
- 创建1023个随机的变换矩阵(物体空间到世界空间), 通过
Matrix4x4.TRS
, 并存储起来 - 创建1023个随机颜色, 这里要用
Vector4
类型的数组, 因为后面要传给shader - 创建
MaterialPropertyBlock
类型的对象block
- 取得shader属性"_BaseColor"的ID.
- 每帧调用
block.SetVectorArray
, 通过ID来设置一个属性在不同物体上的值. 这里设置"_BaseColor", 值就是前面的颜色数组. Graphics.DrawMeshInstanced(网格, sub_mesh, 材质, 变换矩阵数组, 数量, 材质属性block)
来绘制. 其中网格,材质是共享的. 变换矩阵和材质属性是每个对象独有的. 因此变换矩阵是数组, 而材质属性block本身具有存储多组数据的功能.
动态分批
还有一种降低 Draw call 的方法. 这是一种很老的技术, 它会将一些应用相同材质的小网格合并到一个大网格中. 这种技术也没有办法为每个小网格设置不同的属性.
大网格要根据需要生成, 这只适用于一些小网格. 对于球来说就已经过大了. 但是对于cube来说是可以的. 为了看到效果, 我们关掉GPU Instancing. 然后设置enableDynamicBatching
为 true
.
var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings) {
enableDynamicBatching = true,enableInstancing = false};
禁用SPR的批处理.
GraphicsSettings.useScriptableRenderPipelineBatching = false;
原图
总的来说,GPU实例化比动态批处理工作得更好。该方法也有一些注意事项,例如,如果缩放不同,那么大网格的法向量不能保证是单位长度。同时,绘制顺序也会改变,因为它现在是一个单一的网格而不是多个。
还有静态批处理,它的工作方式与此类似,但它会提前对标记为批处理静态的对象进行处理。除了需要更多的内存和存储,它不会产生其他的错误( it has no caveats 不确定是不是这个意思) 。RP不知道这一点,所以我们不用担心。
配置批处理
上面那种技术更好, 对于不同情况是不同的. 所以我们让其可以进行配置. 首先, 我们用函数的参数来控制, 而不是硬编码:
void DrawVisibleGeometry (bool useDynamicBatching, bool useGPUInstancing) {
var sortingSettings = new SortingSettings(camera) {
criteria = SortingCriteria.CommonOpaque};var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings) {
enableuseDynamicBatching = useDynamicBatching,enableInstancing = useGPUInstancing};…}
Render
函数也需要这些参数:
public void Render (ScriptableRenderContext context, Camera camera,bool useDynamicBatching, bool useGPUInstancing) {
…DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);…}
在 RenderPipeline 中增加相应选项
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);}}
在 RenderPipelineAsset 中增加相应选项
[SerializeField]bool useDynamicBatching = true, useGPUInstancing = true, useSRPBatcher = true;protected override RenderPipeline CreatePipeline () {
return new CustomRenderPipeline(useDynamicBatching, useGPUInstancing, useSRPBatcher);}
透明物体
我们的着色器无法用于透明的材质。当我们改变颜色的 alpha 通道时,通常代表着透明度, 但是现在没有效果。我们可以设置渲染队列到 Transparent, 但是这只是改变了物体的绘制顺序,而不是如何绘制。
我们并不需要再另外写一个shader去支持透明材质。只要再多做一些事情, 我们的Unlit材质就可以同时支持透明材质和不透明材质。
混合模式
渲染透明物体与渲染不透明物体之间的主要区别就是,我们是选择把原来绘制的东西全都用新的颜色代替,还是把新颜色与原来的颜色混合一下产生透视的效果。我们可以通过设置 源与目标的混合模式( source and destination blend modes)来控制这件事。source 代表现在将要画的颜色,destination 代表前面已经画上的颜色,以及最终要展示的颜色。我们用两个 Shader 属性来控制:_SrcBlend
, _DstBlend
:
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)] _SrcBlend ("Src Blend", Float) = 1[Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Dst Blend", Float) = 0
默认值表示我们已经使用的不透明混合配置。源被设置为1,这意味着它将被完全添加,而目标被设置为0,这意味着它将被忽略。标准透明度的源混合模式是SrcAlpha,alpha越低,它就越弱。然后将目标混合模式设置为反向:OneMinusSrcAlpha,以使总权重为1。
混合模式可以在Pass块中定义,后面跟着Blend
语句。我们想要使用着色器属性,我们可以通过把它们放在方括号中来访问它们。这是在可编程着色器之前的旧语法。
Pass {
Blend [_SrcBlend] [_DstBlend]HLSLPROGRAM…ENDHLSL}
不进行深度写入
透明渲染通常不会写入深度缓冲区,因为它无法从中受益,甚至可能会产生不良结果。 我们可以通过ZWrite语句控制是否写入深度。 同样,我们可以使用着色器属性,这次使用_ZWrite。
Blend [_SrcBlend] [_DstBlend]ZWrite [_ZWrite]
使用自定义的Enum(Off,0,On,1)属性定义着色器属性,以创建默认值为on和off的on-off开关,值分别为0和1。
[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的标准白色纹理作为默认设置,并以白色字符串表示。 另外,我们必须以空代码块结束texture属性。 它很早以前就用于控制纹理设置,但今天仍应包括在内,以防止某些情况下的怪异错误。
_BaseMap("Texture", 2D) = "white" {
}_BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
纹理必须上传到GPU内存,Unity会为我们做。 着色器需要一个相关纹理的句柄,我们可以定义一个 uniform 值, 不过我们使用TEXTURE2D
宏。 我们还需要为纹理定义一个采样器状态,以控制其采样方式,并考虑其环绕模式和过滤模式。 这是通过SAMPLER
宏完成的, 并且以"sampler"作为前缀. 这与Unity自动提供的采样器状态的名称相同。
纹理和采样器状态是着色器资源。 不能按实例提供,必须在全局范围内声明。 在UnlitPass.hlsl中的着色器属性之前执行此操作。
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
除此之外,Unity还可以通过float4来提供纹理的平铺和偏移,该float4与texture属性同名,但附加了_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)
要采样纹理,我们需要纹理坐标,它是顶点属性的一部分。 具体来说,我们需要第一对坐标,因为可能还要更多。 这是通过将具有TEXCOORD0语义的float2字段添加到Attributes 结构体来完成的。我们将其命名为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中的比例尺和偏移量。 这样,我们就按顶点而不是按片段进行处理。 比例尺存储在XY中,偏移量存储在ZW中,我们可以通过swizzle属性访问它们。
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宏进行采样。 最终的颜色是纹理颜色与基础颜色相乘。 将两个相同大小的向量相乘会导致所有匹配分量相乘,因此在这种情况下,红色乘以红色,绿色乘以绿色,依此类推。
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;
}
Alpha 裁剪
透视表面的另一种方法是在表面上挖洞。 着色器也可以通过丢弃通常会渲染的某些片段来做到这一点。 这样会产生硬边,而不是我们当前看到的平滑过渡。 这种技术称为alpha裁剪。 完成此操作的通常方法是定义一个截止阈值。 alpha值低于此阈值的片段将被丢弃,而所有其他片段将保留。
_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中的clip函数来丢弃片段。 如果我们传递的值为零或更小,它将中止并丢弃该片段。 因此,将最终的alpha值(可通过a或w属性访问)减去截止阈值传递给它。
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剪切对象,然后无需处理其隐藏片段。
但是,要使此优化工作有效,我们必须确保仅在需要时才使用剪辑。 我们将通过添加功能切换着色器属性来做到这一点。 这是一个Float属性,默认情况下设置为零,具有一个控制着色器关键字的Toggle属性,我们将使用_CLIPPING作为参数。 属性本身的名称无关紧要,因此只需使用_Clipping。
_Cutoff ("Alpha Cutoff", Range(0.0, 1.0)) = 0.5[Toggle(_CLIPPING)] _Clipping ("Alpha Clipping", Float) = 0
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-R3g4NT0s-1601711835860)(https://catlikecoding.com/unity/tutorials/custom-srp/draw-calls/transparency/alpha-clipping-off.png#pic_center)]
Shader Features
启用切换功能会将_CLIPPING关键字添加到材质的活动关键字列表中,而禁用则将其删除。 但这并不能单独做任何事情。 我们必须告诉Unity根据关键字是否已定义来编译着色器的不同版本。 为此,我们将#pragma shader_feature _CLIPPING
添加到其Pass中的指令中。
#pragma shader_feature _CLIPPING#pragma multi_compile_instancing
现在,无论是否定义了_CLIPPING,Unity都将编译我们的着色器代码。 它将生成一个或两个变体,具体取决于我们如何配置材料。 我们可以为此使用#ifdef _CLIPPING
,但我更喜欢#if defined(_CLIPPING)
。
#if defined(_CLIPPING)clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));#endif
对于每个物体分别进行 Cutoff
由于截止值是UnityPerMaterial缓冲区的一部分,因此可以按实例进行配置。 因此,让我们将该功能添加到PerObjectMaterialProperties中。 除了需要在属性块上调用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);}
Ball of Alpha-Clipped Spheres
MeshBall也是如此。 现在,我们可以使用 裁剪材质,但是所有实例最终都具有完全相同的洞。
让我们通过给每个实例一个随机的旋转,加上一个在0.5-1.5范围内的随机均匀比例,来增加一些变化。 但是,与其设置每个实例的截止值,不如将它们的颜色的Alpha通道更改为0.5–1范围。 这给我们带来了不太精确的控制,但是无论如何这是一个随机的例子。
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));
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VSUOPQXq-1601713266925)(https://catlikecoding.com/unity/tutorials/custom-srp/draw-calls/transparency/more-varied-ball.png#pic_center)]
请注意,Unity仍然最终会向GPU发送一个截止值数组,每个实例一个,即使它们都是相同的。 该值是材料的副本,因此,通过更改它可以一次更改所有球体的孔,即使它们仍然不同。