本教程将向您展示如何编写几何着色器,以从传入网格的顶部生成草叶,并使用细分来控制草的密度。
本文介绍了在Unity中编写草着色器的分步过程。 着色器接收传入的网格,然后使用
几何着色器从网格的每个顶点生成草叶。 为了有趣和现实起见,草叶的
大小和
旋转度是
随机的 ,并且它们也会受到
风的影响。 为了控制草的密度,我们使用
细分来分离传入的网格。 草将能够
投射并
接收阴影。
完成的项目将发布在文章的末尾。 生成的着色器文件包含大量注释,这些注释使理解变得更容易。
要求条件
要完成本教程,您将需要有关Unity引擎的实用知识以及对着色器语法和功能的初步了解。
下载项目草案(.zip) 。
开始工作
下载项目草稿,然后在Unity编辑器中将其打开。 打开
Main
场景,然后在代码编辑器中打开
Grass
着色器。
该文件包含一个产生白色的着色器,以及我们将在本教程中使用的一些功能。 您会注意到,这些功能以及顶点着色器都包含在
CGINCLUDE
外部的
CGINCLUDE
块
中 。 放置在此块中的代码将
自动包含在着色器的
所有遍次中。 稍后将派上用场,因为我们的着色器将经过数次传递。
我们将从编写一个
几何着色器开始,该
着色器从网格表面上的每个顶点生成三角形。
1.几何着色器
几何着色器是渲染管线的可选部分。 它们
在顶点着色器(如果使用镶嵌细分,则为镶嵌细分着色器)之后,在为片段着色器处理顶点之前执行。
Direct3D图形管道11.请注意,在此图中,片段着色器称为pixel shader 。几何着色器在输入处接收单个
图元 ,并且可以生成零个,一个或多个图元。 我们将从编写一个几何着色器开始,该着色器在输入处接收一个
顶点 (或
点 ),并馈入
一个代表草叶的
三角形 。
上面的代码声明了一个带有两个参数的几何着色器,称为
geo
。 第一个
triangle float4 IN[3]
报告将采用一个三角形(由三个点组成)作为输入。 第二个对象,例如
TriangleStream
,设置了一个着色器以输出三角形的流,以便每个顶点都使用
geometryOutput
结构来传输其数据。
上面我们说过,着色器将接收一个顶点并输出一草。 为什么然后我们得到一个三角形?将一个
作为输入将降低成本。 这可以如下进行。
void geo(point vertexOutput IN[1], inout TriangleStream<geometryOutput> triStream)
但是,由于我们的传入网格(在本例中为
GrassPlane10x10
文件夹中的
GrassPlane10x10
)具有
三角形拓扑 ,因此这将导致传入网格拓扑与所需的输入图元之间不匹配。 尽管DirectX HLSL中
允许这样做,但是
OpenGL中不允许这样做,因此将显示错误。
此外,我们在函数声明上方的方括号中添加了最后一个参数:
[maxvertexcount(3)]
。 他告诉GPU,我们将输出(但不
要求这样做)
不超过 3个顶点。 我们还通过在
Pass
声明
SubShader
使用几何着色器。
我们的几何着色器尚未执行任何操作。 要绘制三角形,请在几何着色器中添加以下代码。
geometryOutput o; o.pos = float4(0.5, 0, 0, 1); triStream.Append(o); o.pos = float4(-0.5, 0, 0, 1); triStream.Append(o); o.pos = float4(0, 1, 0, 1); triStream.Append(o);
这给出了非常奇怪的结果。 当您移动相机时,很明显三角形在
屏幕空间中呈现。 这是合乎逻辑的:由于几何着色器是在处理顶点之前立即执行的,因此它使顶点着色器无需承担顶点着色器在
截断空间中显示的责任。 我们将更改代码以反映这一点。
现在我们的三角形在世界上正确渲染了。 但是,似乎只创建了一个。 实际上,为网格的每个顶点
绘制了一个三角形,但是分配给三角形顶点的位置是
恒定的 -它们对于每个传入的顶点都不会改变。 因此,所有三角形都位于另一个三角形的上方。
我们将通过使输出顶点位置相对于输入点
偏移来解决此问题。
为什么有些顶点不创建三角形?尽管我们确定传入的图元将是
三角形 ,但仅从三角形的
一个点传输草叶,而丢弃其他两个点。 当然,我们可以从所有三个传入点转移草叶,但是这将导致以下事实:相邻的三角形过度地在彼此之上创建草叶。
或者,您可以通过将具有拓扑
点类型的网格作为几何着色器的传入网格来解决此问题。
现在可以正确绘制三角形,并且三角形的底部位于发出三角形的峰值处。 在继续之前,请使
GrassPlane
对象在场景中
处于非活动状态 ,并使
GrassBall
对象
处于活动状态 。 我们希望草能够在不同类型的表面上正确生成,因此在不同形状的网格上对其进行测试非常重要。
而所有三角形均沿一个方向发射,而不是从球体表面向外发射。 为了解决这个问题,我们将在
切线空间中创建草叶。
2.切线空间
理想情况下,我们希望通过设置不同的宽度,高度,曲率和旋转来创建草叶,而不考虑从中发出草叶的表面的角度。 简单地说,我们在发出
该顶点的顶点局部的空间中定义一片草叶,然后对其进行变换,使其位于
网格的局部 。 该空间称为
切线空间 。
在切线空间中,相对于曲面的法线和位置(在我们的情况下为顶点)定义X轴, Y轴和Z轴。像任何其他空间一样,我们可以使用三个向量来定义顶点的切线空间:
right ,
forward和
up 。 使用这些向量,我们可以创建一个矩阵,用于将草叶从切线变为局部空间。
您可以通过添加新的输入顶点数据来
向上和
向右访问向量。
第三向量可以通过取两个
向量之间的
向量乘积来计算。 向量乘积返回一个
垂直于两个传入向量的向量。
为什么矢量乘积的结果乘以切线w的坐标?从3D编辑器导出网格时,它通常已经在网格数据中存储了双法线(也称为
到两个点的切线 )。 无需导入这些双法线,Unity只需获取每个双法线的方向并将其分配给切线
w的坐标即可。 这样可以节省内存,同时提供重新创建正确的Binormal的功能。 有关此主题的详细讨论,请参见
此处 。
有了所有三个向量,我们可以为切线和局部空间之间的转换创建矩阵。 在将草叶的每个顶点传递给
UnityObjectToClipPos
之前,我们将其乘以该矩阵,该矩阵期望局部空间中有一个顶点。
在使用矩阵之前,我们将顶点输出代码传递给函数,以免一次又一次地写相同的代码行。 这就是所谓
的DRY原理 ,或者
不要重复自己 。
最后,我们将输出顶点与
tangentToLocal
矩阵相乘,使它们与输入点的法线正确对齐。
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0.5, 0, 0)))); triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(-0.5, 0, 0)))); triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 1, 0))));
这更像我们所需要的,但并不完全正确。 这里的问题是,最初我们将
Y轴的方向指定为“上”(上)。 但是,在切线空间中,向上方向通常沿
Z轴定位。 现在,我们将进行这些更改。
3.草的外观
为了使三角形看起来更像草叶,您需要添加颜色和变化。 我们首先添加一个从草叶顶部向下的
渐变 。
3.1颜色渐变
我们的目标是允许艺术家设置两种颜色-顶部和底部,并在这两种颜色之间进行插值,以使他倾斜到草叶的底部。 这些颜色已在着色器文件中定义为
_TopColor
和
_BottomColor
。 为了正确采样,您需要将
UV坐标传递到片段着色器。
我们为三角形的草叶创建了UV坐标,三角形的基点的两个顶点分别位于左下和右下,顶端位于顶部的中心。
草叶片的三个顶点的UV坐标。 尽管我们使用简单的渐变绘制草叶,但相似的纹理排列方式允许您覆盖纹理。现在,我们可以使用UV对片段着色器中的顶部和底部颜色进行采样,然后使用
lerp
对其进行
lerp
。 我们还将需要修改片段着色器的参数,使
geometryOutput
作为输入,而不仅仅是
float4
的位置。
3.2随机叶片方向
为了产生变化并赋予草更自然的外观,我们将使每片草在随机方向上看起来。 为此,我们需要创建一个旋转矩阵,使草叶绕其
上轴旋转任意量。
着色器文件中有两个函数可以帮助我们做到这一点:
rand
从3D输入生成一个随机数,而
AngleAxis3x3
接收角度(以
弧度为单位 )并返回一个矩阵,该矩阵绕着指定的轴旋转该值。 后一个函数的工作原理与C#
Quaternion.AngleAxis函数完全相同(只有
AngleAxis3x3
返回一个矩阵,而不是一个四元数)。
rand
函数返回的数字范围为0 ... 1; 我们将其乘以
2 Pi即可获得整个角度值范围。
我们使用传入的
pos
位置作为随机旋转的种子。 因此,每片草叶都会有自己的旋转,在每帧中都是恒定的。
通过将其乘以创建的
tangentToLocal
矩阵,可以将旋转应用于草叶。 注意矩阵乘法是
不可 交换的 ; 操作数的顺序很
重要 。
3.3随机向前弯曲
如果所有草叶都完全对齐,它们将看起来一样。 这可能适合修剪整齐的草,例如在修剪过的草坪上,但在自然界中,草不会像这样生长。 我们将创建一个新的矩阵来沿
X轴旋转草,并创建一个属性来控制该旋转。
再次,我们将草叶的位置用作随机种子,这一次是通过
扫掠它来创建唯一的种子。 我们还将
UNITY_PI
乘以
0.5 ; 这将使我们的随机间隔为0 ... 90度。
我们再次通过旋转应用此矩阵,以正确的顺序乘以所有内容。
3.4宽度和高度
而草叶的大小被限制为1个单位的宽度和1个单位的高度。 我们将添加属性以控制大小,并添加属性以添加随机变化。
三角形现在更像草叶,但也太少了。 传入的网格中根本没有足够的峰值以产生密集密集的场的印象。
一种解决方案是使用C#或在3D编辑器中创建一个新的,更密集的网格。 这将起作用,但不允许我们动态控制草的密度。 相反,我们将使用
tessellation拆分传入的网
格 。
4.镶嵌
细分是渲染管线的可选阶段,在顶点着色器之后和几何着色器(如果有)之前执行。 它的任务是将一个传入表面细分为许多图元。 细分可通过两个可编程步骤来实现:
外壳和
域着色器。
对于表面着色器,Unity具有
内置的镶嵌实现 。 但是,由于我们
不使用表面着色器,因此必须实现自己的外壳着色器和域着色器。 在本文中,我将不详细讨论镶嵌的实现,而仅使用现有的
CustomTessellation.cginc
文件。 该文件改编自
Catlike Coding文章 ,该
文章是有关Unity中细分实现的极好信息来源。
如果在场景中包含
TessellationExample
对象,我们将看到它已经具有实现镶嵌细分的材质。 更改“
镶嵌细分统一”属性可演示细分效果。
我们在草着色器中实现了细分,以控制平面的密度,从而控制生成的草叶片的数量。 首先,您需要添加
CustomTessellation.cginc
文件。 我们将通过其到着色器的
相对路径来引用它。
如果打开
CustomTessellation.cginc
,您会注意到已经在其中定义了
vertexInput
和
vertexOutput
以及顶点着色器。 无需在我们的草着色器中重新定义它们; 它们可以被删除。
请注意,
CustomTessellation.cginc
顶点着色器只是将输入直接传递给细分阶段; 在域着色器内部调用的
vertexOutput
函数承担创建
vertexOutput
结构的任务。
现在,我们可以将
外壳和
域着色器添加到草着色器中。 我们还将添加一个新的
_TessellationUniform
属性以控制单位大小-与此属性相对应的变量已经在
CustomTessellation.cginc
声明。
现在,更改
Tessellation Uniform属性可以控制草的密度。 我发现以
5的值可获得良好的结果。
5.风
我们通过采样
变形纹理来实现风。 该纹理看起来就像
法线贴图 ,仅其中只有两个而不是三个通道。 我们将使用这两个通道作为沿
X和
Y的风向。
在采样风纹理之前,我们需要创建一个UV坐标。 代替使用分配给网格的纹理坐标,我们应用输入点的位置。 因此,如果世界上有多个草网,就会产生一种幻想,即它们都属于同一风力系统。 我们还使用
_Time
shader内置变量在草表面上滚动风纹理。
我们将
_WindDistortionMap
的比例尺和偏移量应用于该位置,然后将其进一步移至
_Time.y
,并缩放至
_WindFrequency
。 现在,我们将使用这些UV来采样纹理并创建一个属性来控制风的强度。
请注意,我们将采样值从纹理从0 ... 1间隔缩放到-1 ... 1间隔。 接下来,我们可以创建表示风向的归一化矢量。
现在,我们可以创建一个矩阵来围绕此向量旋转,并将其乘以我们的
transformationMatrix
。
最后,我们在Unity编辑器中将“
Wind
纹理(位于项目的根)传输到草材质的“
风扭曲贴图”字段。 我们还将纹理的“
平铺”参数设置为
0.01, 0.01
。
如果草没有在“
场景”窗口中设置动画,则单击“
切换天空盒”,“雾”和其他各种效果按钮以启用动画材质。
从远处看,草看上去是正确的,但是如果我们仔细观察草叶片,就会注意到整个草叶片都在转动,这就是为什么基部不再附着在地面上的原因。草叶的根部不再附着在地面上,而是与地面相交(以红色显示),并垂悬在地面的上方(以绿线表示)。我们将通过定义第二个变换矩阵来解决此问题,该变换矩阵仅适用于基础的两个顶点。在该矩阵将不被包括基质windRotation
和bendRotationMatrix
,由于其中底座附接到草表面。
6.叶片的曲率
现在,单个草叶由一个三角形定义。在远距离,这不是问题,但是在草叶附近,它们看起来非常僵硬和几何形状,而不是有机的和生动的。我们将通过从几个三角形构建草叶并将它们沿曲线弯曲来解决此问题。每片草被分成几段。每个片段将具有矩形形状,并且由两个三角形组成,除了上面的片段以外-它是一个三角形,表示草叶的尖端。到目前为止,我们仅绘制了三个顶点,创建了一个三角形。那么,如果有更多的顶点,几何着色器如何知道需要连接哪些顶点并形成三角形?答案在于数据结构三角带。前三个顶点连接并形成三角形,每个新顶点与前两个顶点形成三角形。细分的草叶,以三角形带表示,一次创建一个顶点。在前三个顶点之后,每个新顶点与前两个顶点形成一个新三角形。这不仅在内存使用方面更加高效,而且还使您可以轻松,快速地在代码中创建三角形序列。如果我们要创建几个三角形的条纹,可以为TriangleStream
函数调用RestartStrip。在我们开始从几何着色器绘制更多顶点之前,我们需要增加它maxvertexcount
。我们将使用该设计#define
允许着色器作者控制线段的数量,并从中计算显示的顶点数量。
最初,我们将线段数设置为3,然后根据线段数进行更新maxvertexcount
以计算顶点数。要创建分段的草叶,我们使用一个循环for
。循环的每次迭代都将添加两个顶点:left和right。完成尖端后,我们将最后一个顶点添加到草叶的尖端。在执行此操作之前,将代码的草叶顶点的部分计算位置移入函数中将很有用,因为我们将在循环内外多次使用此代码。将CGINCLUDE
以下内容添加到块中: geometryOutput GenerateGrassVertex(float3 vertexPosition, float width, float height, float2 uv, float3x3 transformMatrix) { float3 tangentPoint = float3(width, 0, height); float3 localPosition = vertexPosition + mul(transformMatrix, tangentPoint); return VertexOutput(localPosition, uv); }
该函数执行相同的任务,因为它传递了我们先前传递的参数VertexOutput
以生成草叶的顶点。获得位置,高度和宽度后,它将使用传输的矩阵正确地变换顶点并为其分配UV坐标。我们将更新现有代码以使该功能正常工作。
该函数开始正确运行,并且我们准备将顶点生成代码移入循环for
。在此行下添加float width
以下内容: for (int i = 0; i < BLADE_SEGMENTS; i++) { float t = i / (float)BLADE_SEGMENTS; }
我们宣布一个循环,该循环将针对每个草叶片段运行一次。在循环内部,添加一个变量t
。此变量将存储一个介于0 ... 1之间的值,指示我们沿着草叶移动了多远。我们使用此值在循环的每次迭代中计算段的宽度和高度。
当向上移动一片草时,高度增加而宽度减小。现在我们可以将调用添加到循环中,GenerateGrassVertex
以将顶点添加到三角形流中。我们还将GenerateGrassVertex
在循环外部添加一个调用,以创建草尖。
看一下带有声明的行float3x3 transformMatrix
-在这里,我们选择两个转换矩阵之一:我们取transformationMatrixFacing
基的顶点和transformationMatrix
所有其他的顶点。如今,草叶分为许多部分,但草叶表面仍然平坦-尚未涉及新的三角形。我们将添加草曲率的叶片,转移的顶点的位置在Y。首先,我们需要修改函数GenerateGrassVertex
,使其在Y中有一个偏移量,我们将其称为forward
。
为了计算每个顶点的位移,我们将一个pow
值代入函数中t
。提高t
功率后,其对前移的影响将是非线性的,并将草叶变成曲线。
这是一段相当大的代码,但是所有工作都与草叶的宽度和高度类似。在较低值_BladeForward
,并且_BladeCurve
我们得到一个有序,整齐干净的草坪,和更大的价值将给予相反的效果。7.灯光和阴影
作为完成着色器的最后一步,我们将添加投射和接收阴影的功能。我们还将从主要的定向光源中添加简单的照明。7.1投射阴影
要在Unity中投射阴影,您需要向着色器添加第二遍。场景中创建阴影的光源将使用此通道,以将草的深度渲染到其阴影贴图中。这意味着必须在阴影通道中启动几何着色器,以便草叶可以投射阴影。由于几何着色器是写在块内部的CGINCLUDE
,因此我们可以在文件的任何传递中使用它。创建第二遍,该遍将使用与第一遍相同的着色器,但片段着色器除外-我们将定义一个新遍,并在其中编写一个用于处理输出的宏。
除了创建新的片段着色器之外,这段代码还有一些重要的区别。标签LightMode
有值ShadowCaster
,而不是一个ForwardBase
-它说的统一,这段话应该用来渲染阴影贴图的对象。这里还有一个预处理器指令multi_compile_shadowcaster
。它可以确保着色器编译投射阴影所需的所有必要选项。使游戏对象在场景中Fence
处于活动状态;因此我们得到了一个表面,草叶可以在其上投射阴影。7.2获取阴影
Unity从创建阴影的光源的角度渲染阴影贴图后,会启动一个通道,将阴影“收集”到屏幕空间的纹理中。要对该纹理进行采样,我们将需要计算屏幕空间中顶点的位置,并将其转移到片段着色器。
在段落的片段着色器中,ForwardBase
我们可以使用宏来获取一个值,该值float
指示曲面是否在阴影中。此值的范围是0 ... 1,其中0是完全阴影,1是完全照明。为什么屏幕空间的UV坐标称为_ShadowCoord?这不符合以前的命名约定。Unity ( ).
SHADOW_ATTENUATION
.
Autolight.cginc
, , .
#define SHADOW_ATTENUATION(a) unitySampleShadow(a._ShadowCoord)
- , .
最后,我们需要将着色器正确配置为接收阴影。为此,我们将在ForwardBase
预处理过程中添加预处理器指令,以便编译所有必需的着色器选项。
将相机拉近后,我们可以注意到草叶表面上的伪影。它们是由单个草叶在自己身上投射阴影的事实引起的。我们可以通过线性移动或将截断空间中的顶点位置稍微远离屏幕来解决此问题。我们将为此使用Unity宏,并将其包含在设计中,#if
以便仅在阴影路径中执行操作。
应用线性阴影偏移后,条纹形式的阴影伪影将从三角形的表面消失。为什么在阴影的草叶边缘会出现伪影?(multisample anti-aliasing
MSAA ) Unity
, . , .
— , ,
Unity . ( );
Unity .
7.3照明
我们将使用非常简单且常见的漫射照明计算算法来实现照明。...,其中N是表面的法线,L是主要方向照明的标准化方向,I是计算出的照明。在本教程中,我们将不会实现间接照明。目前,法线尚未分配给草叶的顶点。与顶点位置一样,我们首先计算切线空间中的法线,然后将其转换为局部。当“ 叶片曲率量”为1时,切线空间中的所有草叶都指向一个方向:与Y轴正对。作为解决方案的第一步,我们假设没有曲率,计算法线。
tangentNormal
定义为与Y轴正好相对,由用于将切点转换为局部空间的同一矩阵进行转换。现在我们可以将其传递给函数VertexOutput
,然后传递给结构geometryOutput
。
注意,在得出结论之前,我们将法线转换为世界空间;Unity将着色器在世界空间中的主要定向光源的方向传达给着色器,因此这种转换是必需的。现在,我们可以可视化着色器片段中的法线,ForwardBase
以检查工作的结果。
由于在我们的着色器中Cull
分配了一个值Off
,因此将渲染草叶的两侧。为了使法线指向正确的方向,我们使用VFACE
添加到片段着色器的辅助参数。如果我们显示曲面的正面,则该参数fixed facing
将返回一个正数;如果相反,则该参数将返回一个负数。如果需要,我们在上面的代码中使用它来翻转法线。当“ 叶片曲率量”大于1时,每个顶点的切线Z位置将偏移forward
传递给函数的量GenerateGrassVertex
。我们将使用此值按比例缩放法线的Z轴。
最后,将代码添加到片段着色器以组合阴影,定向照明和环境照明。我建议在我的卡通着色器教程中研究有关在着色器中实现自定义照明的更多详细信息。
结论
在本教程中,草覆盖了10x10单位的小区域。为了使着色器在保持高性能的同时覆盖较大的开放空间,必须进行优化。您可以基于距离应用镶嵌,以使更少的草叶远离相机渲染。另外,在长距离上,可以使用具有叠加纹理的单个四边形来绘制多组草叶,而不是单独的草叶。Unity引擎的标准资产包中包含的草纹理。在一个四边形上绘制了许多草叶,这减少了场景中三角形的数量。尽管从本质上讲我们不能将几何着色器与表面着色器一起使用,以改善或扩展照明和阴影的功能,但是如果您需要使用标准Unity照明模型,则可以研究此GitHub存储库,该存储库通过延迟渲染和手动填充G缓冲区展示了该问题的解决方案。GitHub存储库中的着色器源代码补充:合作
没有互操作性,图形效果对玩家来说似乎是静止的或毫无生气的。本教程已经很长了,因此我没有添加有关世界对象与草的交互作用的部分。交互式药草的幼稚实现将包含两个组件:游戏世界中的一些组件,可以将数据传输到着色器以告诉它正在与草的哪一部分进行交互,以及在着色器中进行编码以解释此数据。此处显示了如何用水实现的示例。它可以适应与草一起工作;无需在角色所在的位置绘制涟漪图,而是可以将草叶调低以模拟台阶的效果。