通过代码生成一个三角形和一个四边形。
定义顶点位置、法线、切线和纹理坐标。
使用简单和高级的 Mesh API。
将顶点数据存储在多个流或单个流中。
这是有关程序Mesh的系列教程中的第一个教程。它出现在伪随机噪声系列之后。它引入了多种通过代码创建Mesh的方法,通过简单和高级的Mesh API。
本教程使用 Unity 2020.3.18f1 制作。
由两个三角形制成的定制四边形。
1. 构建三角形
显示某物的典型方法是使用特定材料渲染网格。 Unity 有一些简单形状的内置网格,包括立方体和球体。其他网格可以购买、下载或自己制作,然后导入到项目中。但是也可以通过代码在运行时按需创建网格,这就是本系列的内容。这种网格被称为程序网格,因为它们是使用特定算法通过代码生成的,而不是手动建模。
如基础系列中所述,从一个新项目开始。我们将使用 Mathematics 中的类型,因此通过包管理器导入它。虽然我们在本教程中还不需要它,但我也已经包含了 Burst 包。最后,我将使用 URP,因此导入 Universal RP 并为其创建资产并配置 Unity 以使用它。
1.1 简单的程序网格组件
按程序创建网格有两种不同的方法:简单方法和高级方法。每个都有自己的 API。
我们将使用这两种方法依次生成相同的网格,从简单的 Mesh API 开始。这种方法一直是 Unity 的一部分。为其创建一个组件类型,将其命名为 SimpleProceduralMesh。
using UnityEngine;
public class SimpleProceduralMesh : MonoBehaviour { }
当我们进入播放模式时,我们将使用这个自定义组件类型来生成我们的网格。要绘制网格,我们需要一个游戏对象,该对象还具有 MeshFilter 和 MeshRenderer 组件。我们可以强制将这些组件添加到我们将自己的组件添加到的同一个游戏对象中,方法是将两个组件类型作为参数提供给它 RequireComponent 属性。为了表明我们引用了类型本身,我们必须将每个类型传递给 typeof 运算符,就好像它是一个方法调用一样。
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class SimpleProceduralMesh : MonoBehaviour { }
创建一个新的空游戏对象并将我们的 SimpleProceduralMesh 附加到它。这也会自动给它一个 MeshFilter 和一个 MeshRenderer 组件。然后创建一个新的默认URP材质并将其分配给我们的游戏对象,因为默认的MeshRenderer组件没有材质集。 MeshFilter 组件还没有一个网格,但这是正确的,因为我们将在播放模式下给它一个。
简单程序网格的游戏对象。
我们在 OnEnable 方法中生成网格。这是通过创建一个新的 Mesh 对象来完成的。还可以通过设置其名称属性将其命名为 Procedural Mesh。
public class SimpleProceduralMesh : MonoBehaviour {
void OnEnable () {
var mesh = new Mesh {
name = "Procedural Mesh"
};
}
}
然后我们将它分配给我们的 MeshFilter 组件的网格属性,我们可以通过调用我们组件上的通用 GetComponent 方法来访问它,特别是对于 MeshFilter。
var mesh = new Mesh {
name = "Procedural Mesh"
};
GetComponent<MeshFilter>().mesh = mesh;
程序网格出现在播放模式中。
当我们现在进入播放模式时,对网格的引用将出现在 MeshFilter 的检查器中,即使没有绘制任何内容。我们可以通过双击它的参考来访问我们的网格的检查器,或者通过我们可以为它打开的上下文菜单的 Properties… 选项。
程序网格的检查器窗口。
这告诉我们网格的当前状态。它没有任何顶点或索引,它有一个带有零个三角形的子网格,并且其边界设置为零。所以还没有什么可画的。
1.2 添加Vertices(顶点)
网格包含三角形,它们是可以在 3D 中描述的最简单的表面。每个三角形都有三个角。这些是网格的顶点,我们将首先定义它们。
最简单的顶点只不过是 3D 空间中的一个位置,用 Vector3 值描述。我们将使用默认的零、右和上向量为单个三角形创建顶点。这定义了位于 XY 平面上的等腰直角三角形,其 90° 角位于原点,其他角在不同维度上相距一个单位。
XY 平面上的等腰直角三角形。
有多种方法可以通过简单的 Mesh API 设置顶点,但最简单的方法是创建一个包含所需顶点的 Vector3 数组,并将其分配给网格的 vertices 属性。
var mesh = new Mesh {
name = "Procedural Mesh"
};
mesh.vertices = new Vector3[] {
Vector3.zero, Vector3.right, Vector3.up
};
GetComponent<MeshFilter>().mesh = mesh;
我们是否必须在将网格分配给 MeshFilter 之前填充网格?
这不是强制性的,但它最有意义。此外,调整已经在使用的网格会在稍后更改网格时触发通知,因此 MeshRenderer 组件可以根据更改进行调整。因此,您通常会在将其分配给任何东西之前完成生成网格。
三个顶点。
现在进入播放模式,然后检查我们的网格会告诉我们它有三个顶点。每个顶点定义一个位置,它由三个 32 位浮点值组成,因此是 4 个字节的三倍,因此每个顶点 12 个字节,总共 36 个字节。
还没有三角形,但网格已经自动从我们给它的顶点导出了它的边界。
1.3 定义三角形
仅有顶点是不够的。我们还必须描述如何绘制网格的三角形,即使对于只有一个三角形的普通网格也是如此。在设置顶点后,我们将通过将一个带有三角形索引的 int 数组分配给三角形属性来完成此操作。这些索引是指顶点位置的索引。最直接的做法是按顺序列出三个索引:0、1 和 2。
mesh.vertices = new Vector3[] {
Vector3.zero, Vector3.right, Vector3.up
};
mesh.triangles = new int[] {
0, 1, 2
};
一个三角形定义了三个索引。
现在我们的网格告诉我们它有一个三角形,由三个顶点索引定义。索引总是从三角形数组中的索引零开始,因为只有一个子网格。索引总共只占用 6 个字节而不是 12 个字节,因为它们存储为 UInt16,它匹配 16 位 ushort C# 类型,它定义了一个只有两个字节而不是四个字节的无符号整数。
一个三角形也终于出现在游戏和场景窗口中,但它不是从所有视图方向都可见的。默认情况下,三角形仅在查看其正面时可见,而不是在查看其背面时。你在看哪一边是由顶点的缠绕顺序决定的。如果您跟踪三角形的边缘,按照索引指示的顺序遍历其顶点,则在视觉上最终会顺时针或逆时针移动。顺时针一侧是正面,因此这是可见的一侧。
逆时针和顺时针缠绕顺序。
这意味着我们只会在负 Z 方向看时看到三角形。我们可以通过交换第二个和第三个顶点索引的顺序来扭转这种局面。然后我们可以在正 Z 方向看时看到三角形。
mesh.triangles = new int[] {
0, 2, 1
};
沿 Z 轴观察时可见的三角形。
1.4 法向量
目前我们三角形的光照不正确。它的行为就像从相反的一侧被点亮一样。发生这种情况是因为我们还没有定义法线向量,着色器使用它们来计算光照。
法线向量是一个单位长度的向量,如果您站在一个表面上,它描述了局部向上的方向。所以这些向量直接指向远离表面的方向。因此我们三角形表面的法向量应该是 Vector3.back,在我们的网格的局部空间中直接指向负 Z 轴。但是如果没有提供法线向量,Unity 默认使用前向向量,因此我们的三角形似乎是从错误的一侧照亮的。
尽管为表面定义法向量才真正有意义,但网格定义了每个顶点的法向量。用于着色的最终表面法线是通过在三角形表面上插值顶点法线来找到的。通过使用不同的法向量,可以将表面曲率的错觉添加到平面三角形中。这使得可以使网格看起来光滑,而实际上它们是多面的。
在设置顶点位置后,我们可以通过将 Vector3 数组分配给网格的法线属性来向顶点添加法向量。 Unity 检查数组是否具有相同的长度,如果我们提供了错误数量的法向量,将会失败并抱怨。
mesh.vertices = new Vector3[] {
Vector3.zero, Vector3.right, Vector3.up
};
mesh.normals = new Vector3[] {
Vector3.back, Vector3.back, Vector3.back
};
使用正确的照明。
由于添加了法线向量,我们的顶点数据的大小翻了一番,每个顶点 24 字节,总共 72 字节。
位置和法线。
1.5 纹理
通过对网格应用纹理,可以将表面细节添加到网格中。最简单的纹理是用于为表面着色的图像。在 URP 的情况下,这称为底图。这是这样一个纹理,它可以很容易地看到纹理是如何应用于三角形的。
Base map.
下载图像,然后将其导入您的项目,方法是将其放入项目的 Assets 文件夹或通过将文件拖放到项目窗口中。然后将其分配给材质的 Base Map 属性。
带底图的材质。
最初这似乎没什么区别,因为我们还没有定义任何纹理坐标。默认情况下它们为零,这意味着纹理的左下角用于整个三角形,即白色。
由于纹理是 2D 图像并且三角形表面也是 2D,因此纹理坐标是 Vector2 值。它们指定在每个顶点处对纹理进行采样的位置,并且它们将在三角形表面上进行插值。它们是归一化坐标,因此 0-1 包含的范围涵盖每个维度的整个纹理。
在 Unity 中,原点位于纹理的左下角,因此最明显的无失真纹理映射与顶点位置匹配。我们通过为它的 uv 属性分配一个数组来将它们添加到网格中。纹理坐标通常被描述为 UV 坐标,因为它们是纹理空间中的二维坐标,命名为 U 和 V 而不是 X 和 Y。
mesh.vertices = new Vector3[] {
Vector3.zero, Vector3.right, Vector3.up
};
mesh.normals = new Vector3[] {
Vector3.back, Vector3.back, Vector3.back
};
mesh.uv = new Vector2[] {
Vector2.zero, Vector2.right, Vector2.up
};
Textured triangle.
网格检查器会将纹理坐标列为 UV0,并显示它们为顶点大小添加了 8 个字节。
带纹理坐标。
您还可以以不同的方式映射纹理,例如将 Vector2.one 用于第三个顶点。这将扭曲图像,剪切它。
替代纹理映射。
1.6 法线贴图
添加表面细节的另一种常见方法是通过法线贴图。这通常是通过法线贴图纹理完成的,这是一个包含表面法线向量的图像。这是一个这样的纹理,它描述了交替上升和下降斜面的强烈棋盘图案,加上一些微妙的不均匀变化。
法线贴图。
导入图像后,将其纹理类型设置为法线贴图,否则 Unity 将无法正确解释。
纹理类型设置为法线贴图
然后将其用作材质的法线贴图。
就像顶点法向量一样,法线贴图用于调整表面法向量,添加表面变化影响光照的错觉,即使通过三角形仍然是平坦的。
法线映射三角形。
虽然这似乎已经奏效,但结果目前是不正确的。看起来更高的应该看起来更低,反之亦然。发生这种情况是因为法线贴图的向量存在于纹理空间中,必须转换到世界空间才能影响光照。这需要一个变换矩阵,它定义了一个相对于表面的 3D 空间,称为切线空间。它由右轴、上轴和前轴组成。
向上轴应该指向远离使用顶点法向量的表面。除此之外,我们还需要一个右轴和一个前轴。右轴应该指向我们认为正确的任何方向,在我们的例子中只是 Vector3.right。它也称为切线轴或向量,因为它必须始终与曲面曲率相切。我们通过将向量分配给网格的切线属性来定义每个顶点的这些。着色器可以通过计算正交于法线和切线的向量来构建第三个轴本身。然而,它可以通过两种不同的方式,产生一个指向前方或后方的向量。这就是切向量必须是 Vector4 值的原因:它们的第四个分量应该是 1 或 -1,以控制第三个轴的方向。
默认的切线向量指向右侧,并且它们的第四个分量设置为 1。由于 Unity 的着色器构建切线空间的方式这是不正确的,我们必须使用 -1 来代替。
mesh.normals = new Vector3[] {
Vector3.back, Vector3.back, Vector3.back
};
mesh.tangents = new Vector4[] {
new Vector4(1f, 0f, 0f, -1f),
new Vector4(1f, 0f, 0f, -1f),
new Vector4(1f, 0f, 0f, -1f)
};
正确和不正确的法线贴图。
为什么第三轴的方向是可配置的?
这有助于轻松镜像法线贴图,这通常用于具有双边对称性的事物的 3D 模型,例如人。
由于切向量有四个分量,我们的顶点大小增加了 16 个字节,最终大小为 48 个字节。我们的三个顶点总共有 144 个字节。
2 构建四边形
网格可以包含比单个三角形更多的内容。为了演示这一点,我们将通过向其添加第二个三角形将网格变成四边形。
2.1 第二个三角形
我们可以通过取两个直角等腰三角形并将它们放在一起并使它们的斜边接触来创建一个四边形。我们保留现有的三角形并添加第二个三角形,其右角在 X 和 Y 维度上距离原点一个单位。我们将使用顶点顺序 right, up, one。但是为了明确我们有两个不同的三角形,我们最初将通过将其坐标从 1 增加到 1.1 来稍微偏移并放大新三角形。将所需的位置添加到顶点数组。
mesh.vertices = new Vector3[] {
Vector3.zero, Vector3.right, Vector3.up,
new Vector3(1.1f, 0f), new Vector3(0f, 1.1f), new Vector3(1.1f, 1.1f)
};
还增加法线和切线数组,使它们具有相同的大小,只需用相同的值填充它们。
mesh.normals = new Vector3[] {
Vector3.back, Vector3.back, Vector3.back,
Vector3.back, Vector3.back, Vector3.back,
};
mesh.tangents = new Vector4[] {
new Vector4(1f, 0f, 0f, -1f),
new Vector4(1f, 0f, 0f, -1f),
new Vector4(1f, 0f, 0f, -1f),
new Vector4(1f, 0f, 0f, -1f),
new Vector4(1f, 0f, 0f, -1f),
new Vector4(1f, 0f, 0f, -1f)
};
为了保持我们的纹理不失真和匹配,我们必须为新顶点使用适当的纹理坐标。
mesh.uv = new Vector2[] {
Vector2.zero, Vector2.right, Vector2.up,
Vector2.right, Vector2.up, Vector2.one
};
最后,将其索引添加到三角形数组中。由于我们定义新顶点的方式,我们可以按顺序列出它们。
mesh.triangles = new int[] {
0, 2, 1, 3, 4, 5
};
2.2 重用顶点
我们不需要为每个三角形定义单独的顶点,多个三角形可以使用相同的顶点。当三角形分开时我们不能这样做,但为了完成四边形,我们将它们推到一起,这意味着我们可以将第一个三角形的右顶点和上顶点重用于第二个三角形。因此,我们可以将顶点数组减少到四个位置:XY 平面上的零、右、上和一。
mesh.vertices = new Vector3[] {
Vector3.zero, Vector3.right, Vector3.up, new Vector3(1f, 1f)
//new Vector3(1.1f, 0f), new Vector3(0f, 1.1f), new Vector3(1.1f, 1.1f)
};
同样,从其他阵列中消除冗余数据。
mesh.normals = new Vector3[] {
Vector3.back, Vector3.back, Vector3.back, Vector3.back
//Vector3.back, Vector3.back, Vector3.back,
};
mesh.tangents = new Vector4[] {
new Vector4(1f, 0f, 0f, -1f),
new Vector4(1f, 0f, 0f, -1f),
new Vector4(1f, 0f, 0f, -1f),
//new Vector4(1f, 0f, 0f, -1f),
//new Vector4(1f, 0f, 0f, -1f),
new Vector4(1f, 0f, 0f, -1f)
};
mesh.uv = new Vector2[] {
Vector2.zero, Vector2.right, Vector2.up, Vector2.one
//Vector2.right, Vector2.up, Vector2.one
};
第二个三角形的索引列表现在变为 1、2、3。
mesh.triangles = new int[] {
0, 2, 1, 1, 2, 3
};
我们可以通过检查器验证网格有四个顶点和两个三角形。
3 高级网格 API
Unity 2019 引入了替代的高级网格 API,它允许更高效地生成网格,从而可以跳过中间步骤和自动验证。 Unity 2020 对此 API 进行了扩展,使其能够很好地处理Job和 Burst。我们将使用最后一种方法,尽管我们不会在本教程中使用单独的Job。
3.1 多流方法
当我们通过简单的 API 将数据分配给网格时,Unity 必须在某个时候将所有内容复制并转换为网格的本机内存。高级 API 允许我们直接在网格的本机内存格式中工作,跳过转换。这意味着我们必须了解网格的数据是如何布局的。
网格的内存被分成多个区域。我们需要知道的两个区域是顶点区域和索引区域。顶点区域由一个或多个数据流组成,这些数据流是相同格式的顶点数据的连续块。 Unity 支持每个网格最多四个不同的顶点数据流。
由于我们有顶点位置、法线、切线和纹理坐标,我们可以将每一个都存储在一个单独的流中。我们称之为多流方法。
多流方法:首先是位置,然后是法线,然后是切线,然后是纹理坐标。
创建一个新的 AdvancedMultiStreamProceduralMesh 组件类型,就像之前的 SimpleProceduralMesh 一样,最初只创建一个空网格并将其分配给 MeshFilter。
using UnityEngine;
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class AdvancedMultiStreamProceduralMesh : MonoBehaviour {
void OnEnable () {
var mesh = new Mesh {
name = "Procedural Mesh"
};
GetComponent<MeshFilter>().mesh = mesh;
}
}
然后用新的高级组件替换我们游戏对象的简单组件,或者调整一个副本并禁用简单版本,以便稍后进行比较。
3.2 网格数据
要写入原生网格数据,我们必须首先分配它。我们通过调用静态 Mesh.AllocateWritableMeshData 方法来做到这一点。为了便于批量生成网格,此方法返回一个 Mesh.MeshDataArray 结构,其作用类似于本地网格数据数组,可用于写入。我们必须告诉它我们想要生成多少个网格,这只是一个。在创建 Mesh 对象之前执行此操作并通过变量跟踪数组。
Mesh.MeshDataArray meshDataArray = Mesh.AllocateWritableMeshData(1);
var mesh = new Mesh {
name = "Procedural Mesh"
};
现在将数据留空,我们通过调用 Mesh.ApplyAndDisposeWritableMeshData 完成,并将数组和它应用于的网格作为参数。我们可以直接将数组应用于网格,因为它只有一个元素。之后我们就不能再访问网格数据,除非我们通过 Mesh.AcquireReadOnlyMeshData 再次检索它。
var mesh = new Mesh {
name = "Procedural Mesh"
};
Mesh.ApplyAndDisposeWritableMeshData(meshDataArray, mesh);
GetComponent<MeshFilter>().mesh = mesh;
如果我们现在进入播放模式,网格的检查器将显示它是完全空的。
空网格
为了填充网格数据,我们必须从数组中检索单个元素,并通过变量对其进行跟踪。它的类型是 Mesh.MeshData。
Mesh.MeshDataArray meshDataArray = Mesh.AllocateWritableMeshData(1);
Mesh.MeshData meshData = meshDataArray[0];
3.3 Vertex Attributes
此时网格数据的格式还没有定义,需要我们自己定义。为此,我们需要使用来自 Unity.Collections 和 UnityEngine.Rendering 命名空间的类型。
using Unity.Collections;
using UnityEngine;
using UnityEngine.Rendering;
我们网格的每个顶点都有四个属性:位置、法线、切线和一组纹理坐标。我们将通过分配一个带有 VertexAttributeDescriptor 元素的临时本机数组来描述这些。我会将计数值存储在变量中,以明确数字代表什么。
int vertexAttributeCount = 4;
Mesh.MeshDataArray meshDataArray = Mesh.AllocateWritableMeshData(1);
Mesh.MeshData meshData = meshDataArray[0];
var vertexAttributes = new NativeArray<VertexAttributeDescriptor>(
vertexAttributeCount, Allocator.Temp
);
然后通过对网格数据调用 SetVertexBufferParams 来分配网格的顶点流,并将顶点计数和属性定义作为参数。之后我们不再需要属性定义,所以我们处理它。
int vertexAttributeCount = 4;
int vertexCount = 4;
Mesh.MeshDataArray meshDataArray = Mesh.AllocateWritableMeshData(1);
Mesh.MeshData meshData = meshDataArray[0];
var vertexAttributes = new NativeArray<VertexAttributeDescriptor>(
vertexAttributeCount, Allocator.Temp
);
meshData.SetVertexBufferParams(vertexCount, vertexAttributes);
vertexAttributes.Dispose();
我们是否需要为属性使用native array?
不,但另一种方法是使用常规数组,这需要托管内存分配。调用带有变量编号或参数的 SetVertexBufferParams——就像所有其他带有显式变量参数列表的方法一样——也会分配一个托管数组来存储参数。
在设置顶点缓冲区参数之前,我们必须描述四个属性,将每个顶点属性设置为一个新的 VertexAttributeDescriptor 结构值。我们从位置开始。 VertexAttributeDescriptor 的构造函数有四个可选参数,用于描述属性类型、格式、维度和包含它的流的索引。默认值对于我们的位置是正确的,但我们必须至少提供一个参数,否则我们最终会使用没有参数的构造函数,这将是无效的。因此,让我们明确地将维度参数设置为 3,这表明它由三个分量值组成。
var vertexAttributes = new NativeArray<VertexAttributeDescriptor>(
vertexAttributeCount, Allocator.Temp, NativeArrayOptions.UninitializedMemory
);
vertexAttributes[0] = new VertexAttributeDescriptor(dimension: 3);
meshData.SetVertexBufferParams(vertexCount, vertexAttributes);
我们通过设置法线、切线和纹理坐标的属性来遵循这一点。每个参数的第一个参数应该是 VertexAttribute.Normal、VertexAttribute.Tangent 和 VertexAttribute.TexCoord0。还要适当地设置它们的维度并给它们连续的流索引。
vertexAttributes[0] = new VertexAttributeDescriptor(dimension: 3);
vertexAttributes[1] = new VertexAttributeDescriptor(
VertexAttribute.Normal, dimension: 3, stream: 1
);
vertexAttributes[2] = new VertexAttributeDescriptor(
VertexAttribute.Tangent, dimension: 4, stream: 2
);
vertexAttributes[3] = new VertexAttributeDescriptor(
VertexAttribute.TexCoord0, dimension: 2, stream: 3
);
网格检查器现在将显示与我们的简单网格示例相同的顶点数据布局和大小。它没有透露这些数据是如何分成流的。
我们可以通过跳过其内存初始化步骤来进一步优化我们对原生数组的使用。默认情况下,Unity 用零填充分配的内存块,以防止出现奇怪的值。我们可以通过将 NativeArrayOptions.UninitializedMemory 作为第三个参数传递给 NativeArray 构造函数来跳过这一步。
var vertexAttributes = new NativeArray<VertexAttributeDescriptor>(
vertexAttributeCount, Allocator.Temp, NativeArrayOptions.UninitializedMemory
);
这意味着数组的内容是任意的并且可以是无效的,但是我们将其全部覆盖,因此无关紧要。
3.4 设置顶点
虽然我们不会在本教程中使用Job,但此时我们将切换到使用Mathematics。
using Unity.Mathematics;
using static Unity.Mathematics.math;
调用 SetVertexBufferParams 后,我们可以通过调用 GetVertexData 检索顶点流的本机数组。它返回的本地数组实际上是一个指向网格数据相关部分的指针。所以它就像一个代理,没有单独的数组。这将允许Job直接写入网格数据,跳过从原生数组到网格数据的中间复制步骤。
GetVertexData 是一个通用方法,默认情况下返回第一个流的本机数组,其中包含位置。所以数组的元素类型是float3。使用它来设置位置,这次使用 float3 而不是 Vector3。
meshData.SetVertexBufferParams(vertexCount, vertexAttributes);
vertexAttributes.Dispose();
NativeArray<float3> positions = meshData.GetVertexData<float3>();
positions[0] = 0f;
positions[1] = right();
positions[2] = up();
positions[3] = float3(1f, 1f, 0f);
对其余顶点数据执行相同操作,将适当的流索引作为参数传递给 GetVertexData。
NativeArray<float3> positions = meshData.GetVertexData<float3>();
positions[0] = 0f;
positions[1] = right();
positions[2] = up();
positions[3] = float3(1f, 1f, 0f);
NativeArray<float3> normals = meshData.GetVertexData<float3>(1);
normals[0] = normals[1] = normals[2] = normals[3] = back();
NativeArray<float4> tangents = meshData.GetVertexData<float4>(2);
tangents[0] = tangents[1] = tangents[2] = tangents[3] = float4(1f, 0f, 0f, -1f);
NativeArray<float2> texCoords = meshData.GetVertexData<float2>(3);
texCoords[0] = 0f;
texCoords[1] = float2(1f, 0f);
texCoords[2] = float2(0f, 1f);
texCoords[3] = 1f;
3.5 设置三角形
我们还必须为三角形索引保留空间,这是通过调用 SetIndexBufferParams 来完成的。它的第一个参数是三角形索引计数。它还有第二个参数,用于描述索引格式。让我们最初使用 IndexFormat.UInt32,它匹配 uint 类型。我们在设置顶点数据后执行此操作。
int vertexAttributeCount = 4;
int vertexCount = 4;
int triangleIndexCount = 6;
…
meshData.SetIndexBufferParams(triangleIndexCount, IndexFormat.UInt32);
var mesh = new Mesh {
name = "Procedural Mesh"
};
可以通过通用 GetIndexData 方法检索三角形索引的本机数组。用它来设置六个索引。
meshData.SetIndexBufferParams(triangleIndexCount, IndexFormat.UInt32);
NativeArray<uint> triangleIndices = meshData.GetIndexData<uint>();
triangleIndices[0] = 0;
triangleIndices[1] = 2;
triangleIndices[2] = 1;
triangleIndices[3] = 1;
triangleIndices[4] = 2;
triangleIndices[5] = 3;
我们的网格现在有索引,但它们需要的空间是我们简单网格所需的两倍。那是因为我们使用的是 32 位无符号整数类型。
这种格式允许访问大量顶点,但 Unity 默认使用较小的 16 位类型,这将索引缓冲区的大小减半。这将可访问顶点的数量限制为 65.535。由于我们只有六个顶点,我们可以使用与 ushort 类型匹配的 IndexFormat.UInt16 就足够了。
meshData.SetIndexBufferParams(triangleIndexCount, IndexFormat.UInt16);
NativeArray<ushort> triangleIndices = meshData.GetIndexData<ushort>();
3.6 设置子网格
最后一步是定义网格的子网格。我们在设置索引后通过将 subMeshCount 属性设置为 1 来执行此操作。
meshData.subMeshCount = 1;
var mesh = new Mesh {
name = "Procedural Mesh"
};
我们还必须指定子网格应该使用索引缓冲区的哪一部分。这是通过使用子网格索引和 SubMeshDescriptor 值调用 SetSubMesh 来完成的。 SubMeshDescriptor 构造函数有两个参数,用于索引开始和索引计数。在我们的例子中,它应该涵盖所有索引。
meshData.subMeshCount = 1;
meshData.SetSubMesh(0, new SubMeshDescriptor(0, triangleIndexCount));
现在我们终于再次看到了我们的四边形。
3.7 网格和子网格边界
当我们以这种方式创建网格时,Unity 不会自动计算其边界。但是,Unity 会计算子网格的边界,这在某些情况下是需要的。这需要检查子网格的所有顶点。我们可以通过设置传递给 SetSubMesh 的子网格描述符的 bounds 属性来避免自己提供正确边界的所有工作。我们还应该设置它的 vertexCount 属性。
var bounds = new Bounds(new Vector3(0.5f, 0.5f), new Vector3(1f, 1f));
meshData.subMeshCount = 1;
meshData.SetSubMesh(0, new SubMeshDescriptor(0, triangleIndexCount) {
bounds = bounds,
vertexCount = vertexCount
});
我们必须通过将 MeshUpdateFlags.DontRecalculateBounds 作为第三个参数传递给 SetSubMesh 来明确指示 Unity 不要自己计算这些值。
meshData.SetSubMesh(0, new SubMeshDescriptor(0, triangleIndexCount) {
bounds = bounds,
vertexCount = vertexCount
}, MeshUpdateFlags.DontRecalculateBounds);
我们可以对整个网格使用相同的边界,也可以通过分配给它的 bounds 属性。
var mesh = new Mesh {
bounds = bounds,
name = "Procedural Mesh"
};
3.8 减少顶点尺寸
理想情况下,顶点数据保持尽可能小,既可以减少内存压力,也可以改善 GPU 缓存。顶点属性使用的默认格式是 VertexAttributeFormat.Float32,它匹配浮点类型。因为我们的网格非常简单,所以我们不需要这么高的精度。通过将 VertexAttributeFormat.Float16 作为新的第二个参数传递,让我们将切线和纹理坐标格式减少到一半精度。其他两个参数不再需要命名。
vertexAttributes[2] = new VertexAttributeDescriptor(
VertexAttribute.Tangent, VertexAttributeFormat.Float16, 4, 2
);
vertexAttributes[3] = new VertexAttributeDescriptor(
VertexAttribute.TexCoord0, VertexAttributeFormat.Float16, 2, 3
);
我们还必须调整设置这些值的代码,使其使用 half 类型。这不是native C# 类型,这意味着不支持此类型的数学运算。相反,我们必须通过 half 方法将最终值从 float 转换为 half。
half h0 = half(0f), h1 = half(1f);
NativeArray<half4> tangents = meshData.GetVertexData<half4>(2);
tangents[0] = tangents[1] = tangents[2] = tangents[3] =
half4(h1, h0, h0, half(-1f));
NativeArray<half2> texCoords = meshData.GetVertexData<half2>(3);
texCoords[0] = h0;
texCoords[1] = half2(h1, h0);
texCoords[2] = half2(h0, h1);
texCoords[3] = h1;
将切线和纹理坐标减少到 16 位值会使我们的顶点大小减少 12 个字节,总共 36 个,减少了 25%。
有多种数据格式可用,但有大小限制:每个属性的总大小必须是四个字节的倍数。如果我们将 position 或 normal 切换为 16 位值,那么它们的总大小将是两个字节的三倍,因此每个字节是六个字节,而不是四的倍数。所以我们不能简单地将 VertexAttributeFormat.Float16 用于位置和法线属性,除非我们将它们的维度增加到 4。这会引入一个无用的组件,但会将它们的大小从 12 字节减少到 8 个字节。但是,我们不会这样做,因为这些属性通常需要 32 位精度。此外,Unity 编辑器在检查您是否在场景窗口中拖动选择网格时需要 3D 位置矢量。如果不是这种情况,则此操作将产生一连串错误和不正确的选择。
还有其他数据格式,但它们不像从浮点数转换为半数那样容易支持,因此我不在本教程中包含它们。
3.9 单流方法
不需要将每个属性放在单个流中,否则最多只能支持四个顶点属性。另一个极端是将所有属性放在一个流中。在这种情况下,属性按顶点分组,因此数据是混合的。
尽管我们可以按任何顺序定义属性,但 Unity 需要每个流的固定属性顺序:位置、法线、切线、颜色、从 0 到 7 的纹理坐标集、混合权重和混合索引。
我们将通过引入 AdvancedSingleStreamProceduralMesh 组件类型来演示单流方法,该组件类型最初是 AdvancedMultiStreamProceduralMesh 的副本,只是名称发生了变化。像我们之前所做的那样调整场景,以便我们可以看到这种新方法的结果。
public class AdvancedSingleStreamProceduralMesh : MonoBehaviour {
…
}
为了存储顶点数据,我们必须为其定义一个结构类型,我们将其命名为 Vertex。在 AdvancedSingleStreamProceduralMesh 中执行此操作,因为我们不会在其他任何地方使用它。为它提供与我们的多流方法匹配的所需数据的字段,具有正确的类型和正确的顺序。
public class AdvancedSingleStreamProceduralMesh : MonoBehaviour {
struct Vertex {
public float3 position, normal;
public half4 tangent;
public half2 texCoord0;
}
…
}
由于此数据无需修改即可直接复制到网格和 GPU 内存,因此必须完全按照我们的描述使用此数据结构。默认情况下不能保证这一点,因为 C# 编译器可能会重新排列事物以优化我们的代码。我们可以通过使用 LayoutKind.Sequential 参数将 StructLayout 属性附加到它来强制执行确切的顺序。两者都来自 System.Runtime.InteropServices 命名空间。
using System.Runtime.InteropServices;
…
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class AdvancedSingleStreamProceduralMesh : MonoBehaviour {
[StructLayout(LayoutKind.Sequential)]
struct Vertex { … }
…
}
要将所有属性放在第一个流中,我们可以简单地从所有 VertexAttributeDescriptor 构造函数调用中删除流参数。
vertexAttributes[0] = new VertexAttributeDescriptor(dimension: 3);
vertexAttributes[1] = new VertexAttributeDescriptor(
VertexAttribute.Normal, dimension: 3 //, stream: 1
);
vertexAttributes[2] = new VertexAttributeDescriptor(
VertexAttribute.Tangent, VertexAttributeFormat.Float16, 4 //, 2
);
vertexAttributes[3] = new VertexAttributeDescriptor(
VertexAttribute.TexCoord0, VertexAttributeFormat.Float16, 2 //, 3
);
接下来,删除设置单独流的代码。取而代之的是检索单个 Vertex 流的本机数组。
vertexAttributes.Dispose();
//NativeArray<float3> positions = meshData.GetVertexData<float3>();
//…
//NativeArray<float3> normals = meshData.GetVertexData<float3>(1);
//…
//NativeArray<half4> tangents = meshData.GetVertexData<half4>(2);
//…
//NativeArray<half2> texCoords = meshData.GetVertexData<half2>(3);
//…
NativeArray<Vertex> vertices = meshData.GetVertexData<Vertex>();
meshData.SetIndexBufferParams(triangleIndexCount, IndexFormat.UInt16);
NativeArray<ushort> triangleIndices = meshData.GetIndexData<ushort>();
最后,再次设置相同的顶点,但现在将完整的顶点分配给单个数组。因为它们都有相同的法线和切线,我们可以设置一次,只改变每个顶点的位置和纹理坐标。
vertexAttributes.Dispose();
NativeArray<Vertex> vertices = meshData.GetVertexData<Vertex>();
half h0 = half(0f), h1 = half(1f);
var vertex = new Vertex {
normal = back(),
tangent = half4(h1, h0, h0, half(-1f))
};
vertex.position = 0f;
vertex.texCoord0 = h0;
vertices[0] = vertex;
vertex.position = right();
vertex.texCoord0 = half2(h1, h0);
vertices[1] = vertex;
vertex.position = up();
vertex.texCoord0 = half2(h0, h1);
vertices[2] = vertex;
vertex.position = float3(1f, 1f, 0f);
vertex.texCoord0 = h1;
vertices[3] = vertex;
meshData.SetIndexBufferParams(triangleIndexCount, IndexFormat.UInt16);
结果看起来与多流方法完全相同——甚至网格检查器也没有显示出不同——但数据布局不同。