https://roystan.net/articles/grass-shader/

你将学会使用几何着色器,根据输入的网格顶点生成草的叶片,并且使用曲面细分控制草的密度。

本篇教程将一步一步教你如何在 Unity 中实现一个 grass shader。这个 shader 将接收一个输入网格,根据网格的每个顶点来生成草的叶片。为了有趣和真实,草叶将有随机的尺寸和旋转,并且被风影响。

为了控制草的密度,将使用曲面细分来细分输入的网格。草能够投射和接收阴影。

先前条件

下载初始化工程

https://github.com/IronWarrior/UnityGrassGeometryShader/archive/skeleton.zip

开始

下载初始化工程,使用 Unity editor 打开。打开 Main 场景,使用编辑器打开 Grass shader。

这个文件包含一个输出白色的着色器,以及一些我们将在本教程中使用的函数。你会注意到这些函数,以及顶点着色器,都包含在 SubShader 之外的 CGINCLUDE 块中。放置在此块中的代码将自动包含在着色器的任何 passes 中;这将在以后有用,因为我们的着色器将有多个 passes。

我们将开始写一个几何着色器从我们的网格表面上的每个顶点生成三角形。

几何着色器

几何着色器是渲染管线中一个可选的部分。它们在顶点着色器之后执行(或者是曲面细分着色器——如果曲面细分被使用),在为片段着色器处理顶点之前。

1-xcdc.webp

Direct3D 11 图形管线

几何着色器接收单个图元,并且可以生成 0,1,或者多个图元。我们将开始写一个几何着色器接收一个顶点作为输入,并且输出一单个三角形表示草叶。

// Add inside the CGINCLUDE block
struct geometryOutout
{
	float4 pos : SV_POSITION;
};

[maxvertexcount(3)]
void geo(triangle float4 IN[3] : SV_POSITION, inout TriangleStream<geometryOutput> triStream)
{
}

...

// Add inside the SubShader Pass, just below the #pragma fragment frag line
#pragma geometry geo

上述声明一个几何着色器名为 geo ,附带着两个参数。第一个参数,triangle float4 IN[3] ,表明我们将接收单个三角形(由三个点组成)作为我们的输入。第二个参数,类型 TriangleStream ,表明我们的着色器将输出三角形流,每个顶点使用 geometryOutput 结构传递数据。

如上所述,我们将取一个顶点,并发出一片草叶。那我们为什么要接收一个三角形呢?

把一个点作为输入当然会少一些冗余,如下所示

void geo(point vertexOutput IN[1], inout TriangleStream<geometryOutput> triStream)

然而,因为我们输入的网格存在三角形网格拓扑,这会导致与我们请求的输入图元不匹配。这在 DirectX HLSl 被接受,但是 OpenGL 中不被接受,会导致一个错误。

[maxvertexcount(3)] 会告诉 GPU,我们将发射最多 3 个顶点(实际可以不只 3 个)。我们也需要确保我们的 SubShader 使用几何着色器,通过声明它在 Pass 里面。

我们的几何着色器至今没有做什么事情,通过将下列代码放到几何着色器来发射一个三角形。

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);

2-mler.webp

这产生了一些奇怪的结果。产生奇怪效果的原因是几何着色器在顶点着色器之后,我们必须保证几何着色器输出的顶点也在裁剪空间。

// Update the return call in the vertex shader.
return vertex;

...

// Update each assignment of o.pos in the geometry shader.
o.pos = UnityObjectToClipPos(float4(0.5, 0, 0, 1));

...

o.pos = UnityObjectToClipPos(float4(-0.5, 0, 0, 1));

...

o.pos = UnityObjectToClipPos(float4(0, 1, 0, 1));

1-gjla.webp

我们的三角形现在正确地渲染在世界里了。然而,现在只有一个被创建。在实际情况中,我们为网格中的每个顶点绘制了一个三角形,但是我们分配给三角形顶点的位置是恒定的——它们不会因为每个输入顶点而改变——将所有三角形放置在另一个顶点之上。

我们将通过更新输出顶点的位置,使其偏离输入点来纠正这个错误。

// Add to the top of the geometry shader.
float3 pos = IN[0];

...

// Update each assignment of o.pos
o.pos = UnityObjectToClipPos(pos + float3(0.5, 0, 0));

o.pos = UnityObjectToClipPos(pos + float3(-0.5, 0, 0));

o.pos = UnityObjectToClipPos(pos +float3(0, 1, 0));

2-okfg.webp

为什么一些顶点没有变成三角形?

尽管我们已经将输入图元定义为三角形,但我们只将三角形的一个点变为叶片,而丢弃了其他两个点。虽然我们可以将三个点都变成叶片,但这会导致相邻三角形冗余。

根据输入的顶点,现在三角形被正确地绘制了。在继续之前,将场景中的 FenceGrassPlane 失活,并且将 GrassBall 激活。因为我们希望我们的草能够正确地为各种表面生成,所以在不同形状的网格上测试它是很重要的。

3-hnpb.webp

现在,这些三角形都是向同一个方向发射,而不从球体表面向外发射。为了解决这个问题,我们将在切线空间中构建草叶。

切线空间

4-qvsi.webp

在切线空间中,X、Y 和 Z 轴是根据表面的法线和位置(在我们的例子中是一个顶点)来定义的。

像任何一种空间一样,我们可以用三个向量来定义顶点的切线空间:右、前、上。有了这些向量,我们可以构造一个矩阵来旋转我们的草叶从切线到局部空间。

我们可以通过添加一些新的顶点输入来访问 right 和 up 向量。

// Add to the CGINCLUDE block.
struct vertexInput
{
	float4 vertex : POSITION;
	float3 normal : NORMAL;
	float4 tangent : TANGENT;
};

struct vertexOutput
{
	float4 vertex : SV_POSITION;
	float3 normal : NORMAL;
	float4 tangent : TANGENT;
};

...

// Modify the vertex shader.
vertexOutput vert(vertexInput v)
{
	vertexOutput o;
	o.vertex = v.vertex;
	o.normal = v.normal;
	o.tangent = v.tangent;
	return o;
}

...

// Modify the input for the geometry shader. Note that the SV_POSITION semantic is removed.
void geo(triangle vertexOutput IN[3], inout TriangleStream<geometryOutput> triStream)
...

// Modify the existing line declaring pos.
float3 pos = IN[0].vertex;

第三个向量可以通过其他两个向量的叉乘来计算。叉乘返回一个垂直于它的两个输入向量的向量。

// Place in the geometry shader, below the line declaring float3 pos.
float3 vNormal = IN[0].normal;
float4 vTangent = IN[0].tangent;
float3 vBinormal = cross(vNormal, vTangent) * vTangent.w;

为什么叉乘的结果乘以切线的 w 坐标?

当一个网格从 3D 建模包导出时,它通常已经在网格数据中存储了 binormals(也叫做 bitangents)。Unity 没有导入这些 binormals,而是简单地获取每个 binormal 的方向,并将其分配给 tangent 的 w 坐标。这样做的好处是节省内存,同时仍然可以确保稍后可以重建正确的 binormal。

利用这三个向量,我们可以构造一个可以在切线空间和局部空间之间进行变换的矩阵。我们将草叶中的每个顶点乘以这个矩阵,然后将其传递给 UnityObjectToClipPos

// Add below the lines declaring the three vectors.
float3x3 tangentToLocal = float3x3(
	vTangent.x, vBinormal.x, vNormal.x,
	vTangent.y, vBinormal.y, vNormal.y,
	vTangent.z, vBinormal.z, vNormal.z
);

在使用这个矩阵之前,我们将把顶点输出代码移到一个函数中,以避免一遍又一遍地编写同一行代码。这通常被称为 DRY 原则,don't repeat yourself。

// Add to the CGINCLUDE block.
geometryOutput VertexOutput(float3 pos)
{
	geometryOutput o;
	o.pos = UnityObjectToClipPos(pos);
	return o;
}

...

// Remove the following from the geometry shader.
geometryOutput o;

o.pos = UnityObjectToClipPos(pos + float3(0.5, 0, 0));
triStream.Append(o);

o.pos = UnityObjectToClipPos(pos + float3(-0.5, 0, 0));
triStream.Append(o);

o.pos = UnityObjectToClipPos(pos + float3(0, 1, 0));
triStream.Append(o);

// ...and replace it with the code below.
triStream.Append(VertexOutput(pos + float3(0.5, 0, 0)));
triStream.Append(VertexOutput(pos + float3(-0.5, 0, 0)));
triStream.Append(VertexOutput(pos + float3(0, 1, 0)));

最后,我们将输出顶点乘以 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))));

5-yxyy.webp

这看起来更接近我们想要的,但并不完全正确。这里的问题是,我们最初将 "up" 方向定义为 Y 轴;然而,在切线空间中,惯例通常向上的方向是沿着 Z 轴。

// Modify the position of the third vertex being emitted.
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 0, 1))));

6-xytq.webp

草的外形

为了使我们的三角形看起来更像草叶,我们需要添加一些颜色和多样性。我们将开始添加一个梯度,从叶片的顶部运行到底部。

草的梯度

我们的目标是让艺术家定义两种颜色——顶部和底部——并在这两种颜色间进行插值。这些颜色早已定义在 shader 文件中作为 _TopColor_BottomColor 。为了正确采样,我们需要向 fragment shader 提供 UV 坐标。

// Add to the geometryOutput struct.
float2 uv : TEXCOORD0;

...

// Modify the VertexOutput function signature
geometryOutput VertexOutput(float3 pos, float2 uv)

...

// Add to VertexOutput, just below the line assigning o.pos
o.uv = uv;

...

// Modify the existing lines in the geometry shader.
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0.5, 0, 0)), float2(0, 0));
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(-0.5, 0, 0)), float2(1, 0));
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 0, 1)), float2(0.5, 1));

我们为叶片构建了一个三角形的 uv,两个基本顶点分别位于底部的左边和右边,顶端顶点位于中间顶部。

7-uyqg.webp

我们草叶三个顶点的 UV 坐标,虽然我们将用简单的渐变为叶片上色,但以这种方式布置坐标可以允许纹理映射。

我们现在可以使用 UV 在片段着色器中采样顶部和底部的颜色,并使用 lerp 在它们之间进行插值。我们还需要修改片段着色器的参数,以将 geometryOutput 作为输入,而不仅仅是 float4 位置。

// Modify the function signature of the fragment shader.
float4 frag(geometryOutput i, fixed facing : VFACE) : SV_Target

...

// Replace the existing return call.
return float4(1, 1, 1, 1);

return lerp(_BottomColor, _TopColor, i.uv.y);

9-jsnm.webp