在Unity引擎中创建草着色器


本教程将向您展示如何编写几何着色器,以从传入网格的顶部生成草叶,并使用细分来控制草的密度。

本文介绍了在Unity中编写草着色器的分步过程。 着色器接收传入的网格,然后使用几何着色器从网格的每个顶点生成草叶。 为了有趣和现实起见,草叶的大小旋转度随机的 ,并且它们也会受到风的影响。 为了控制草的密度,我们使用细分来分离传入的网格。 草将能够投射接收阴影。

完成的项目将发布在文章的末尾。 生成的着色器文件包含大量注释,这些注释使理解变得更容易。

要求条件


要完成本教程,您将需要有关Unity引擎的实用知识以及对着色器语法和功能的初步了解。

下载项目草案(.zip)

开始工作


下载项目草稿,然后在Unity编辑器中将其打开。 打开Main场景,然后在代码编辑器中打开Grass着色器。

该文件包含一个产生白色的着色器,以及我们将在本教程中使用的一些功能。 您会注意到,这些功能以及顶点着色器都包含在CGINCLUDE 外部CGINCLUDE 。 放置在此块中的代码将自动包含在着色器的所有遍次中。 稍后将派上用场,因为我们的着色器将经过数次传递。

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

1.几何着色器


几何着色器是渲染管线的可选部分。 它们顶点着色器(如果使用镶嵌细分,则为镶嵌细分着色器)之后,在为片段着色器处理顶点之前执行。


Direct3D图形管道11.请注意,在此图中,片段着色器称为pixel shader

几何着色器在输入处接收单个图元 ,并且可以生成零个,一个或多个图元。 我们将从编写一个几何着色器开始,该着色器在输入处接收一个顶点 (或 ),并馈入一个代表草叶的三角形

 // Add inside the CGINCLUDE block. struct geometryOutput { 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) 

但是,由于我们的传入网格(在本例中为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); 


这给出了非常奇怪的结果。 当您移动相机时,很明显三角形在屏幕空间中呈现。 这是合乎逻辑的:由于几何着色器是在处理顶点之前立即执行的,因此它使顶点着色器无需承担顶点着色器在截断空间中显示的责任。 我们将更改代码以反映这一点。

 // Update the return call in the vertex shader. //return UnityObjectToClipPos(vertex); 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)); 


现在我们的三角形在世界上正确渲染了。 但是,似乎只创建了一个。 实际上,为网格的每个顶点绘制了一个三角形,但是分配给三角形顶点的位置是恒定的 -它们对于每个传入的顶点都不会改变。 因此,所有三角形都位于另一个三角形的上方。

我们将通过使输出顶点位置相对于输入点偏移来解决此问题。

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


为什么有些顶点不创建三角形?

尽管我们确定传入的图元将是三角形 ,但仅从三角形的一个点传输草叶,而丢弃其他两个点。 当然,我们可以从所有三个传入点转移草叶,但是这将导致以下事实:相邻的三角形过度地在彼此之上创建草叶。

或者,您可以通过将具有拓扑类型的网格作为几何着色器的传入网格来解决此问题。

现在可以正确绘制三角形,并且三角形的底部位于发出三角形的峰值处。 在继续之前,请使GrassPlane对象在场景中处于非活动状态 ,并使GrassBall对象处于活动状态 。 我们希望草能够在不同类型的表面上正确生成,因此在不同形状的网格上对其进行测试非常重要。


而所有三角形均沿一个方向发射,而不是从球体表面向外发射。 为了解决这个问题,我们将在切线空间中创建草叶。

2.切线空间


理想情况下,我们希望通过设置不同的宽度,高度,曲率和旋转来创建草叶,而不考虑从中发出草叶的表面的角度。 简单地说,我们在发出该顶点的顶点局部的空间中定义一片草叶,然后对其进行变换,使其位于网格的局部 。 该空间称为切线空间


在切线空间中,相对于曲面的法线和位置(在我们的情况下为顶点)定义X轴, Y轴和Z轴。

像任何其他空间一样,我们可以使用三个向量来定义顶点的切线空间: rightforwardup 。 使用这些向量,我们可以创建一个矩阵,用于将草叶从切线变为局部空间。

您可以通过添加新的输入顶点数据来向上向右访问向量。

 // 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编辑器导出网格时,它通常已经在网格数据中存储了双法线(也称为到两个点的切线 )。 无需导入这些双法线,Unity只需获取每个双法线的方向并将其分配给切线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原理 ,或者不要重复自己

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

图片

这更像我们所需要的,但并不完全正确。 这里的问题是,最初我们将Y轴的方向指定为“上”(上)。 但是,在切线空间中,向上方向通常沿Z轴定位。 现在,我们将进行这些更改。

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


3.草的外观


为了使三角形看起来更像草叶,您需要添加颜色和变化。 我们首先添加一个从草叶顶部向下的渐变

3.1颜色渐变


我们的目标是允许艺术家设置两种颜色-顶部和底部,并在这两种颜色之间进行插值,以使他倾斜到草叶的底部。 这些颜色已在着色器文件中定义为_TopColor_BottomColor 。 为了正确采样,您需要将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坐标,三角形的基点的两个顶点分别位于左下和右下,顶端位于顶部的中心。


草叶片的三个顶点的UV坐标。 尽管我们使用简单的渐变绘制草叶,但相似的纹理排列方式允许您覆盖纹理。

现在,我们可以使用UV对片段着色器中的顶部和底部颜色进行采样,然后使用lerp对其进行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); 


3.2随机叶片方向


为了产生变化并赋予草更自然的外观,我们将使每片草在随机方向上看起来。 为此,我们需要创建一个旋转矩阵,使草叶绕其轴旋转任意量。

着色器文件中有两个函数可以帮助我们做到这一点: rand从3D输入生成一个随机数,而AngleAxis3x3接收角度(以弧度为单位 )并返回一个矩阵,该矩阵绕着指定的轴旋转该值。 后一个函数的工作原理与C# Quaternion.AngleAxis函数完全相同(只有AngleAxis3x3返回一个矩阵,而不是一个四元数)。

rand函数返回的数字范围为0 ... 1; 我们将其乘以2 Pi即可获得整个角度值范围。

 // Add below the line declaring the tangentToLocal matrix. float3x3 facingRotationMatrix = AngleAxis3x3(rand(pos) * UNITY_TWO_PI, float3(0, 0, 1)); 

我们使用传入的pos位置作为随机旋转的种子。 因此,每片草叶都会有自己的旋转,在每帧中都是恒定的。

通过将其乘以创建的tangentToLocal矩阵,可以将旋转应用于草叶。 注意矩阵乘法是不可 交换的 ; 操作数的顺序很重要

 // Add below the line declaring facingRotationMatrix. float3x3 transformationMatrix = mul(tangentToLocal, facingRotationMatrix); … // Replace the multiplication matrix operand with our new transformationMatrix. triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0.5, 0, 0)), float2(0, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(-0.5, 0, 0)), float2(1, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0, 0, 1)), float2(0.5, 1))); 


3.3随机向前弯曲


如果所有草叶都完全对齐,它们将看起来一样。 这可能适合修剪整齐的草,例如在修剪过的草坪上,但在自然界中,草不会像这样生长。 我们将创建一个新的矩阵来沿X轴旋转草,并创建一个属性来控制该旋转。

 // Add as a new property. _BendRotationRandom("Bend Rotation Random", Range(0, 1)) = 0.2 … // Add to the CGINCLUDE block. float _BendRotationRandom; … // Add to the geometry shader, below the line declaring facingRotationMatrix. float3x3 bendRotationMatrix = AngleAxis3x3(rand(pos.zzx) * _BendRotationRandom * UNITY_PI * 0.5, float3(-1, 0, 0)); 

再次,我们将草叶的位置用作随机种子,这一次是通过扫掠它来创建唯一的种子。 我们还将UNITY_PI乘以0.5 ; 这将使我们的随机间隔为0 ... 90度。

我们再次通过旋转应用此矩阵,以正确的顺序乘以所有内容。

 // Modify the existing line. float3x3 transformationMatrix = mul(mul(tangentToLocal, facingRotationMatrix), bendRotationMatrix); 


3.4宽度和高度


而草叶的大小被限制为1个单位的宽度和1个单位的高度。 我们将添加属性以控制大小,并添加属性以添加随机变化。

 // Add as new properties. _BladeWidth("Blade Width", Float) = 0.05 _BladeWidthRandom("Blade Width Random", Float) = 0.02 _BladeHeight("Blade Height", Float) = 0.5 _BladeHeightRandom("Blade Height Random", Float) = 0.3 … // Add to the CGINCLUDE block. float _BladeHeight; float _BladeHeightRandom; float _BladeWidth; float _BladeWidthRandom; … // Add to the geometry shader, above the triStream.Append calls. float height = (rand(pos.zyx) * 2 - 1) * _BladeHeightRandom + _BladeHeight; float width = (rand(pos.xzy) * 2 - 1) * _BladeWidthRandom + _BladeWidth; … // Modify the existing positions with our new height and width. triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(width, 0, 0)), float2(0, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(-width, 0, 0)), float2(1, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0, 0, height)), float2(0.5, 1))); 


三角形现在更像草叶,但也太少了。 传入的网格中根本没有足够的峰值以产生密集密集的场的印象。

一种解决方案是使用C#或在3D编辑器中创建一个新的,更密集的网格。 这将起作用,但不允许我们动态控制草的密度。 相反,我们将使用tessellation拆分传入的网

4.镶嵌


细分是渲染管线的可选阶段,在顶点着色器之后和几何着色器(如果有)之前执行。 它的任务是将一个传入表面细分为许多图元。 细分可通过两个可编程步骤来实现: 外壳着色器。

对于表面着色器,Unity具有内置的镶嵌实现 。 但是,由于我们不使用表面着色器,因此必须实现自己的外壳着色器和域着色器。 在本文中,我将不详细讨论镶嵌的实现,而仅使用现有的CustomTessellation.cginc文件。 该文件改编自Catlike Coding文章 ,该文章是有关Unity中细分实现的极好信息来源。

如果在场景中包含TessellationExample对象,我们将看到它已经具有实现镶嵌细分的材质。 更改“ 镶嵌细分统一”属性可演示细分效果。


我们在草着色器中实现了细分,以控制平面的密度,从而控制生成的草叶片的数量。 首先,您需要添加CustomTessellation.cginc文件。 我们将通过其到着色器的相对路径来引用它。

 // Add inside the CGINCLUDE block, below the other #include statements. #include "Shaders/CustomTessellation.cginc" 

如果打开CustomTessellation.cginc ,您会注意到已经在其中定义了vertexInputvertexOutput以及顶点着色器。 无需在我们的草着色器中重新定义它们; 它们可以被删除。

 /*struct vertexInput { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; }; struct vertexOutput { float4 vertex : SV_POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; }; vertexOutput vert(vertexInput v) { vertexOutput o; o.vertex = v.vertex; o.normal = v.normal; o.tangent = v.tangent; return o; }*/ 

请注意, CustomTessellation.cginc顶点着色器只是将输入直接传递给细分阶段; 在域着色器内部调用的vertexOutput函数承担创建vertexOutput结构的任务。

现在,我们可以将外壳着色器添加到草着色器中。 我们还将添加一个新的_TessellationUniform属性以控制单位大小-与此属性相对应的变量已经在CustomTessellation.cginc声明。

 // Add as a new property. _TessellationUniform("Tessellation Uniform", Range(1, 64)) = 1 … // Add below the other #pragma statements in the SubShader Pass. #pragma hull hull #pragma domain domain 

现在,更改Tessellation Uniform属性可以控制草的密度。 我发现以5的值可获得良好的结果。


5.风


我们通过采样变形纹理来实现风。 该纹理看起来就像法线贴图 ,仅其中只有两个而不是三个通道。 我们将使用这两个通道作为沿XY的风向。


在采样风纹理之前,我们需要创建一个UV坐标。 代替使用分配给网格的纹理坐标,我们应用输入点的位置。 因此,如果世界上有多个草网,就会产生一种幻想,即它们都属于同一风力系统。 我们还使用_Time shader内置变量在草表面上滚动风纹理。

 // Add as new properties. _WindDistortionMap("Wind Distortion Map", 2D) = "white" {} _WindFrequency("Wind Frequency", Vector) = (0.05, 0.05, 0, 0) … // Add to the CGINCLUDE block. sampler2D _WindDistortionMap; float4 _WindDistortionMap_ST; float2 _WindFrequency; … // Add to the geometry shader, just above the line declaring the transformationMatrix. float2 uv = pos.xz * _WindDistortionMap_ST.xy + _WindDistortionMap_ST.zw + _WindFrequency * _Time.y; 

我们将_WindDistortionMap的比例尺和偏移量应用于该位置,然后将其进一步移至_Time.y ,并缩放至_WindFrequency 。 现在,我们将使用这些UV来采样纹理并创建一个属性来控制风的强度。

 // Add as a new property. _WindStrength("Wind Strength", Float) = 1 … // Add to the CGINCLUDE block. float _WindStrength; … // Add below the line declaring float2 uv. float2 windSample = (tex2Dlod(_WindDistortionMap, float4(uv, 0, 0)).xy * 2 - 1) * _WindStrength; 

请注意,我们将采样值从纹理从0 ... 1间隔缩放到-1 ... 1间隔。 接下来,我们可以创建表示风向的归一化矢量。

 // Add below the line declaring float2 windSample. float3 wind = normalize(float3(windSample.x, windSample.y, 0)); 

现在,我们可以创建一个矩阵来围绕此向量旋转,并将其乘以我们的transformationMatrix

 // Add below the line declaring float3 wind. float3x3 windRotation = AngleAxis3x3(UNITY_PI * windSample, wind); … // Modify the existing line. float3x3 transformationMatrix = mul(mul(mul(tangentToLocal, windRotation), facingRotationMatrix), bendRotationMatrix); 

最后,我们在Unity编辑器中将“ Wind纹理(位于项目的根)传输到草材质的“ 风扭曲贴图”字段。 我们还将纹理的“ 平铺”参数设置为0.01, 0.01


如果草没有在“ 场景”窗口中设置动画,则单击“ 切换天空盒”,“雾”和其他各种效果按钮以启用动画材质。

从远处看,草看上去是正确的,但是如果我们仔细观察草叶片,就会注意到整个草叶片都在转动,这就是为什么基部不再附着在地面上的原因。


草叶的根部不再附着在地面上,而是与地面相交(以红色显示),并垂悬在地面的上方(以绿线表示)。

我们将通过定义第二个变换矩阵来解决此问题,该变换矩阵仅适用于基础的两个顶点。在该矩阵将不被包括基质windRotationbendRotationMatrix,由于其中底座附接到草表面。

 // Add below the line declaring float3x3 transformationMatrix. float3x3 transformationMatrixFacing = mul(tangentToLocal, facingRotationMatrix); … // Modify the existing lines outputting the base vertex positions. triStream.Append(VertexOutput(pos + mul(transformationMatrixFacing, float3(width, 0, 0)), float2(0, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrixFacing, float3(-width, 0, 0)), float2(1, 0))); 

6.叶片的曲率


现在,单个草叶由一个三角形定义。在远距离,这不是问题,但是在草叶附近,它们看起来非常僵硬和几何形状,而不是有机的和生动的。我们将通过从几个三角形构建草叶并将它们沿曲线弯曲来解决此问题

每片草被分成几段。每个片段将具有矩形形状,并且由两个三角形组成,除了上面的片段以外-它是一个三角形,表示草叶的尖端。

到目前为止,我们仅绘制了三个顶点,创建了一个三角形。那么,如果有更多的顶点,几何着色器如何知道需要连接哪些顶点并形成三角形?答案在于数据结构三角带。前三个顶点连接并形成三角形,每个新顶点与前两个顶点形成三角形。


细分的草叶,以三角形带表示,一次创建一个顶点。在前三个顶点之后,每个新顶点与前两个顶点形成一个新三角形。

这不仅在内存使用方面更加高效,而且还使您可以轻松,快速地在代码中创建三角形序列。如果我们要创建几个三角形的条纹,可以为TriangleStream函数调用RestartStrip

在我们开始从几何着色器绘制更多顶点之前,我们需要增加它maxvertexcount。我们将使用该设计#define允许着色器作者控制线段的数量,并从中计算显示的顶点数量。

 // Add to the CGINCLUDE block. #define BLADE_SEGMENTS 3 … // Modify the existing line defining the maxvertexcount. [maxvertexcount(BLADE_SEGMENTS * 2 + 1)] 

最初,我们将线段数设置为3,然后根据线段数进行更新maxvertexcount以计算顶点数。

要创建分段的草叶,我们使用一个循环for。循环的每次迭代都将添加两个顶点leftright。完成尖端后,我们将最后一个顶点添加到草叶的尖端。

在执行此操作之前,将代码的草叶顶点的部分计算位置移入函数中将很有用,因为我们将在循环内外多次使用此代码。将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坐标。我们将更新现有代码以使该功能正常工作。

 // Update the existing code outputting the vertices. triStream.Append(GenerateGrassVertex(pos, width, 0, float2(0, 0), transformationMatrixFacing)); triStream.Append(GenerateGrassVertex(pos, -width, 0, float2(1, 0), transformationMatrixFacing)); triStream.Append(GenerateGrassVertex(pos, 0, height, float2(0.5, 1), transformationMatrix)); 

该函数开始正确运行,并且我们准备将顶点生成代码移入循环for在此行下添加float width以下内容:

 for (int i = 0; i < BLADE_SEGMENTS; i++) { float t = i / (float)BLADE_SEGMENTS; } 

我们宣布一个循环,该循环将针对每个草叶片段运行一次。在循环内部,添加一个变量t此变量将存储一个介于0 ... 1之间的值,指示我们沿着草叶移动了多远。我们使用此值在循环的每次迭代中计算段的宽度和高度。

 // Add below the line declaring float t. float segmentHeight = height * t; float segmentWidth = width * (1 - t); 

当向上移动一片草时,高度增加而宽度减小。现在我们可以将调用添加到循环中,GenerateGrassVertex以将顶点添加到三角形流中。我们还将GenerateGrassVertex在循环外部添加一个调用,以创建草尖。

 // Add below the line declaring float segmentWidth. float3x3 transformMatrix = i == 0 ? transformationMatrixFacing : transformationMatrix; triStream.Append(GenerateGrassVertex(pos, segmentWidth, segmentHeight, float2(0, t), transformMatrix)); triStream.Append(GenerateGrassVertex(pos, -segmentWidth, segmentHeight, float2(1, t), transformMatrix)); … // Add just below the loop to insert the vertex at the tip of the blade. triStream.Append(GenerateGrassVertex(pos, 0, height, float2(0.5, 1), transformationMatrix)); … // Remove the existing calls to triStream.Append. //triStream.Append(GenerateGrassVertex(pos, width, 0, float2(0, 0), transformationMatrixFacing)); //triStream.Append(GenerateGrassVertex(pos, -width, 0, float2(1, 0), transformationMatrixFacing)); //triStream.Append(GenerateGrassVertex(pos, 0, height, float2(0.5, 1), transformationMatrix)); 

看一下带有声明的行float3x3 transformMatrix-在这里,我们选择两个转换矩阵之一:我们取transformationMatrixFacing基的顶点和transformationMatrix所有其他的顶点


如今,草叶分为许多部分,但草叶表面仍然平坦-尚未涉及新的三角形。我们将添加草曲率的叶片,转移的顶点的位置在Y首先,我们需要修改函数GenerateGrassVertex,使其在Y中有一个偏移量,我们将其称为forward

 // Update the function signature of GenerateGrassVertex. geometryOutput GenerateGrassVertex(float3 vertexPosition, float width, float height, float forward, float2 uv, float3x3 transformMatrix) … // Modify the Y coordinate assignment of tangentPoint. float3 tangentPoint = float3(width, forward, height); 

为了计算每个顶点的位移,我们将一个pow值代入函数中t提高t功率后,其对前移的影响将是非线性的,并将草叶变成曲线。

 // Add as new properties. _BladeForward("Blade Forward Amount", Float) = 0.38 _BladeCurve("Blade Curvature Amount", Range(1, 4)) = 2 … // Add to the CGINCLUDE block. float _BladeForward; float _BladeCurve; … // Add inside the geometry shader, below the line declaring float width. float forward = rand(pos.yyz) * _BladeForward; … // Add inside the loop, below the line declaring segmentWidth. float segmentForward = pow(t, _BladeCurve) * forward; … // Modify the GenerateGrassVertex calls inside the loop. triStream.Append(GenerateGrassVertex(pos, segmentWidth, segmentHeight, segmentForward, float2(0, t), transformMatrix)); triStream.Append(GenerateGrassVertex(pos, -segmentWidth, segmentHeight, segmentForward, float2(1, t), transformMatrix)); … // Modify the GenerateGrassVertex calls outside the loop. triStream.Append(GenerateGrassVertex(pos, 0, height, forward, float2(0.5, 1), transformationMatrix)); 

这是一段相当大的代码,但是所有工作都与草叶的宽度和高度类似。在较低值_BladeForward,并且_BladeCurve我们得到一个有序,整齐干净的草坪,和更大的价值将给予相反的效果。


7.灯光和阴影


作为完成着色器的最后一步,我们将添加投射接收阴影的功能。我们还将从主要的定向光源中添加简单的照明。

7.1投射阴影


要在Unity中投射阴影,您需要向着色器添加第二遍。场景中创建阴影的光源将使用此通道,以将草的深度渲染到其阴影贴图中。这意味着必须在阴影通道中启动几何着色器,以便草叶可以投射阴影。

由于几何着色器是写在块内部的CGINCLUDE,因此我们可以在文件的任何传递中使用它。创建第二遍,该遍将使用与第一遍相同的着色器,但片段着色器除外-我们将定义一个新遍,并在其中编写一个用于处理输出的宏。

 // Add below the existing Pass. Pass { Tags { "LightMode" = "ShadowCaster" } CGPROGRAM #pragma vertex vert #pragma geometry geo #pragma fragment frag #pragma hull hull #pragma domain domain #pragma target 4.6 #pragma multi_compile_shadowcaster float4 frag(geometryOutput i) : SV_Target { SHADOW_CASTER_FRAGMENT(i) } ENDCG } 

除了创建新的片段着色器之外,这段代码还有一些重要的区别。标签LightMode有值ShadowCaster,而不是一个ForwardBase-它说的统一,这段话应该用来渲染阴影贴图的对象。这里还有一个预处理器指令multi_compile_shadowcaster它可以确保着色器编译投射阴影所需的所有必要选项。

使游戏对象在场景中Fence 处于活动状态因此我们得到了一个表面,草叶可以在其上投射阴影。


7.2获取阴影


Unity从创建阴影的光源的角度渲染阴影贴图后,会启动一个通道,将阴影“收集”到屏幕空间的纹理中要对该纹理进行采样,我们将需要计算屏幕空间中顶点的位置,并将其转移到片段着色器。

 // Add to the geometryOutput struct. unityShadowCoord4 _ShadowCoord : TEXCOORD1; … // Add to the VertexOutput function, just above the return call. o._ShadowCoord = ComputeScreenPos(o.pos); 

在段落的片段着色器中,ForwardBase我们可以使用宏来获取一个值,该值float指示曲面是否在阴影中。此值的范围是0 ... 1,其中0是完全阴影,1是完全照明。

为什么屏幕空间的UV坐标称为_ShadowCoord?这不符合以前的命名约定。
Unity ( ). SHADOW_ATTENUATION . Autolight.cginc , , .

 #define SHADOW_ATTENUATION(a) unitySampleShadow(a._ShadowCoord) 

- , .

 // Add to the ForwardBase pass's fragment shader, replacing the existing return call. return SHADOW_ATTENUATION(i); //return lerp(_BottomColor, _TopColor, i.uv.y); 

最后,我们需要将着色器正确配置为接收阴影。为此,我们将在ForwardBase预处理过程中添加预处理器指令以便编译所有必需的着色器选项。

 // Add to the ForwardBase pass's preprocessor directives, below #pragma target 4.6. #pragma multi_compile_fwdbase 


将相机拉近后,我们可以注意到草叶表面上的伪影。它们是由单个草叶在自己身上投射阴影的事实引起的。我们可以通过线性移动或将截断空间中的顶点位置稍微远离屏幕来解决此问题我们将为此使用Unity宏,并将其包含在设计中,#if以便仅在阴影路径中执行操作。

 // Add at the end of the VertexOutput function, just above the return call. #if UNITY_PASS_SHADOWCASTER // Applying the bias prevents artifacts from appearing on the surface. o.pos = UnityApplyLinearShadowBias(o.pos); #endif 


应用线性阴影偏移后,条纹形式的阴影伪影将从三角形的表面消失。

为什么在阴影的草叶边缘会出现伪影?

(multisample anti-aliasing MSAA ) Unity , . , .

— , , Unity . ( ); Unity .

7.3照明


我们将使用非常简单且常见的漫射照明计算算法来实现照明。


...,其中N是表面的法线,L是主要方向照明的标准化方向,I是计算出的照明。在本教程中,我们将不会实现间接照明。

目前,法线尚未分配给草叶的顶点。与顶点位置一样,我们首先计算切线空间中的法线,然后将其转换为局部。

当“ 叶片曲率量”1时,切线空间中的所有草叶都指向一个方向:与Y轴正对作为解决方案的第一步,我们假设没有曲率,计算法线。

 // Add to the GenerateGrassVertex function, belowing the line declaring tangentPoint. float3 tangentNormal = float3(0, -1, 0); float3 localNormal = mul(transformMatrix, tangentNormal); 

tangentNormal定义为与Y轴正好相对,由用于将切点转换为局部空间的同一矩阵进行转换。现在我们可以将其传递给函数VertexOutput,然后传递给结构geometryOutput

 // Modify the return call in GenerateGrassVertex. return VertexOutput(localPosition, uv, localNormal); … // Add to the geometryOutput struct. float3 normal : NORMAL; … // Modify the existing function signature. geometryOutput VertexOutput(float3 pos, float2 uv, float3 normal) … // Add to the VertexOutput function to pass the normal through to the fragment shader. o.normal = UnityObjectToWorldNormal(normal); 

注意,在得出结论之前,我们将法线转换为世界空间Unity将着色器在世界空间中的主要定向光源的方向传达给着色器,因此这种转换是必需的。

现在,我们可以可视化着色器片段中的法线,ForwardBase以检查工作的结果。

 // Add to the ForwardBase fragment shader. float3 normal = facing > 0 ? i.normal : -i.normal; return float4(normal * 0.5 + 0.5, 1); // Remove the existing return call. //return SHADOW_ATTENUATION(i); 

由于在我们的着色器中Cull分配了一个Off,因此将渲染草叶的两侧。为了使法线指向正确的方向,我们使用VFACE添加到片段着色器的辅助参数如果我们显示曲面的正面,则

该参数fixed facing将返回一个数;如果相反,则该参数将返回一个负数如果需要,我们在上面的代码中使用它来翻转法线。


当“ 叶片曲率量”大于1时,每个顶点的切线Z位置将偏移forward传递给函数的量GenerateGrassVertex我们将使用此值按比例缩放法线Z

 // Modify the existing line in GenerateGrassVertex. float3 tangentNormal = normalize(float3(0, -1, forward)); 

最后,将代码添加到片段着色器以组合阴影,定向照明和环境照明。我建议在我的卡通着色器教程中研究有关在着色器中实现自定义照明的更多详细信息

 // Add to the ForwardBase fragment shader, below the line declaring float3 normal. float shadow = SHADOW_ATTENUATION(i); float NdotL = saturate(saturate(dot(normal, _WorldSpaceLightPos0)) + _TranslucentGain) * shadow; float3 ambient = ShadeSH9(float4(normal, 1)); float4 lightIntensity = NdotL * _LightColor0 + float4(ambient, 1); float4 col = lerp(_BottomColor, _TopColor * lightIntensity, i.uv.y); return col; // Remove the existing return call. //return float4(normal * 0.5 + 0.5, 1); 


结论


在本教程中,草覆盖了10x10单位的小区域。为了使着色器在保持高性能的同时覆盖较大的开放空间,必须进行优化。您可以基于距离应用镶嵌,以使更少的草叶远离相机渲染。另外,在长距离上,可以使用具有叠加纹理的单个四边形来绘制多组草叶,而不是单独的草叶。


Unity引擎的标准资产包中包含的草纹理在一个四边形上绘制了许多草叶,这减少了场景中三角形的数量。

尽管从本质上讲我们不能将几何着色器与表面着色器一起使用,以改善或扩展照明和阴影的功能,但是如果您需要使用标准Unity照明模型,则可以研究此GitHub存储库,该存储库通过延迟渲染和手动填充G缓冲区展示了该问题的解决方案。

GitHub存储库中的着色器源代码

补充:合作


没有互操作性,图形效果对玩家来说似乎是静止的或毫无生气的。本教程已经很长了,因此我没有添加有关世界对象与草的交互作用的部分。

交互式药草的幼稚实现将包含两个组件:游戏世界中的一些组件,可以将数据传输到着色器以告诉它正在与草的哪一部分进行交互,以及在着色器中进行编码以解释此数据。此处

显示了如何用水实现的示例它可以适应与草一起工作;无需在角色所在的位置绘制涟漪图,而是可以将草叶调低以模拟台阶的效果。

Source: https://habr.com/ru/post/zh-CN458060/


All Articles