在Unity中创建程序网格

Procedural Grid(程序网格)

原文 https://catlikecoding.com/unity/tutorials/procedural-grid/

1
2
3
4
5
1.创建点网格
2.使用协程分析它们的位置
3.使用三角形定义一个曲面
4.自动生成法线
5.添加纹理坐标和切线

在本教程中,我们将创建一个简单的顶点和三角形网格

alt 图1
复杂的外观之下是简单的几何形状

渲染物体

如果你想在 Unity 中可视化某些东西,你可以使用网格。 它可能是从另一个程序导出的 3D 模型。 它可能是程序生成的网格。 它可以是精灵、UI 元素或粒子系统,Unity 也为此使用网格。 甚至屏幕效果也使用网格渲染。

那么什么是网格? 从概念上讲,网格是图形硬件用来绘制复杂内容的构造。 它至少包含一组定义 3D 空间中的点的顶点,以及一组连接这些点的三角形(最基本的 2D 形状)。 三角形构成了网格所代表的任何表面。

由于三角形是平的并且有直边,它们可以用来完美地可视化平直的东西,比如立方体的面。 弯曲或圆形表面只能通过使用许多小三角形来近似。 如果三角形看起来足够小——不大于一个像素——那么你就不会注意到近似值。 通常这对于实时性能来说是不可行的,因此表面总是会出现某种程度的锯齿状。

alt 图2-1
alt 图2-2
Unity 的默认胶囊体、立方体和球体,着色模式与线框模式

1
2
如何显示线框?
您可以在其工具栏的左侧选择场景视图的显示模式。 前三个选项是着色、线框和着色线框。

如果你想让一个游戏对象显示一个 3D 模型,它需要有两个组件。 第一种是 Mesh Filter,该组件包含对您希望显示的网格的引用。 第二个是Mesh Render,您可以使用它来配置网格的渲染方式。 应该使用哪种材质,是否应该投射或接收阴影,等等。

alt 图2-3
Unity 的默认立方体游戏对象

1
2
为什么会有一系列的材料?
一个网格渲染器可以有多种材质。 这主要用于渲染具有多个独立三角形集的网格,称为子网格。 这些主要用于导入的 3D 模型,在本教程中不予介绍。

您可以通过调整其材质来完全改变网格的外观。 Unity 的默认材质只是纯白色。 您可以通过 Assets / Create / Material 创建一个新的材质资源并将其拖到您的游戏对象上来替换它。 新材质默认使用 Unity 的标准着色器,它为您提供一组控件来调整表面的视觉行为方式。

向网格添加大量细节的快速方法是提供反照率贴图。 这是表示材料基本颜色的纹理。 当然,我们需要知道如何将此纹理投影到网格的三角形上。 这是通过将 2D 纹理坐标添加到顶点来完成的。 纹理空间的两个维度被称为 U 和 V,这就是为什么它们被称为 UV 坐标。 这些坐标通常位于 (0, 0) 和 (1, 1) 之间,覆盖整个纹理。 根据纹理设置,超出该范围的坐标会被固定或导致平铺。

alt 图2-4
alt 图2-5
alt 图2-6
应用于 Unity 网格的 UV 测试纹理

创建顶点网格

那么如何制作自己的网格呢? 让我们通过生成一个简单的矩形网格来找出答案。 网格将由单位长度的方形瓷砖(四边形)组成。 创建一个新的 C# 脚本并将其转换为具有水平和垂直大小的网格组件。

1
2
3
4
5
6
7
using UnityEngine;
using System.Collections;

public class Grid : MonoBehaviour
{
public int xSize, ySize;
}

当我们将此组件添加到游戏对象时,我们还需要给它一个Mesh FilterMesh Render。 我们可以为我们的类添加一个属性,让 Unity 自动为我们添加它们。

1
2
3
4
5
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class Grid : MonoBehaviour
{
public int xSize, ySize;
}

现在您可以创建一个新的空游戏对象,将网格组件添加到其中,它还将具有其他两个组件。 设置渲染器的材质并保留过滤器的网格未定义。 我将网格的大小设置为 10 x 5。

alt 图3-1
Grid物件

一旦对象Awake,我们就会生成实际的网格,这发生在我们进入播放模式时。

1
2
3
4
private void Awake()
{
Generate();
}

让我们首先关注顶点位置,然后将三角形留到后面。 我们需要保存一个 3D 向量数组来存储这些点。 顶点的数量取决于网格的大小。 我们需要在每个四边形的拐角处有一个顶点,但相邻的四边形可以共享同一个顶点。 因此,我们需要比每个维度中的图块多一个顶点。

(#x+1)(#y+1)

alt 图3-2
4 x 2 网格的顶点和四边形索引

1
2
3
4
5
private Vector3[] vertices;
private void Generate()
{
vertices = new Vector3[(xSize + 1) * (ySize + 1)];
}

让我们可视化这些顶点,以便我们可以检查我们是否正确定位它们。 我们可以通过添加一个 OnDrawGizmos 方法并在场景视图中为每个顶点绘制一个小的黑色球体来做到这一点。

1
2
3
4
5
6
7
private void OnDrawGizmos()
{
Gizmos.color = Color.black;
for (int i = 0; i < vertices.Length; i++) {
Gizmos.DrawSphere(vertices[i], 0.1f);
}
}
1
2
3
什么是 gizmos?
Gizmos 是可以在编辑器中使用的视觉提示。 默认情况下,它们在场景视图中可见,而在游戏视图中不可见,但您可以通过它们的工具栏进行调整。 Gizmos 实用程序类允许您绘制图标、线条和其他一些东西。
Gizmos 可以在 OnDrawGizmos 方法中绘制,该方法由 Unity 编辑器自动调用。 另一种方法是 OnDrawGizmosSelected,它只对选定对象调用。

当我们不处于播放模式时,这将产生错误,因为当 Unity 处于编辑模式时,当我们没有任何顶点时,也会调用 OnDrawGizmos 方法。 为防止出现此错误,请检查数组是否存在,如果不存在则跳出方法。

1
2
3
4
5
6
private void OnDrawGizmos () {
if (vertices == null) {
return;
}

}

alt 图3-3
Gizmos

在播放模式下,我们只能在原点看到一个球体。 这是因为我们还没有定位顶点,所以它们都在那个位置重叠。 我们必须使用双循环遍历所有位置。

1
2
3
4
5
6
7
8
9
private void Generate()
{
vertices = new Vector3[(xSize + 1) * (ySize + 1)];
for (int i = 0, y = 0; y <= ySize; y++) {
for (int x = 0; x <= xSize; x++, i++) {
vertices[i] = new Vector3(x, y);
}
}
}
1
2
为什么Gizmos不随物体移动?
Gizmo 直接在世界空间中绘制,而不是在对象的本地空间中。 如果您希望它们尊重您的对象变换,则必须通过使用 transform.TransformPoint(vertices[i]) 而不仅仅是 vertices[i] 来显式应用它。

我们现在看到了顶点,但是它们的放置顺序是不可见的。 我们可以使用颜色来显示这一点,但我们也可以通过使用协程来减慢这个过程。 这就是我在脚本中使用 System.Collections 的原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void Awake()
{
StartCoroutine(Generate());
}

private Vector3[] vertices;
private IEnumerator Generate()
{
WaitForSeconds wait = new WaitForSeconds(0.05f);
vertices = new Vector3[(xSize + 1) * (ySize + 1)];
for (int i = 0, y = 0; y <= ySize; y++) {
for (int x = 0; x <= xSize; x++, i++) {
vertices[i] = new Vector3(x, y);
yield return wait;
}
}
}

创建网格

现在我们知道顶点的位置正确,我们可以处理实际的网格。 除了在我们自己的组件中保存对它的引用之外,我们还必须将它分配给 Mesh Filter 。 然后,一旦我们处理了顶点,我们就可以将它们提供给我们的网格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private Mesh mesh;
private IEnumerator Generate()
{
WaitForSeconds wait = new WaitForSeconds(0.05f);

GetComponent<MeshFilter>().mesh = mesh = new Mesh();
mesh.name = "Procedural Grid";

vertices = new Vector3[(xSize + 1) * (ySize + 1)];
for (int i = 0, y = 0; y <= ySize; y++) {
for (int x = 0; x <= xSize; x++, i++) {
vertices[i] = new Vector3(x, y);
yield return wait;
}
}

mesh.vertices = vertices;
}
1
2
我们的组件是否需要抓住网格?
我们只需要在 Generate 方法中引用网格。 由于 Mesh Filter 也有它的参考,它无论如何都会留下来。 我将其设为全局变量,因为本教程之外的下一个合乎逻辑的步骤是为网格设置动画,我鼓励您尝试。

alt 图4-1
在Play模式下

我们现在有一个网格处于播放模式,但它还没有出现,因为我们没有给它任何三角形。 三角形是通过顶点索引数组定义的。 由于每个三角形都有三个点,因此三个连续的索引描述了一个三角形。 让我们从一个三角形开始。

1
2
3
4
5
6
7
8
9
10
private IEnumerator Generate () 
{


int[] triangles = new int[3];
triangles[0] = 0;
triangles[1] = 1;
triangles[2] = 2;
mesh.triangles = triangles;
}

我们现在有一个三角形,但是我们使用的三个点都在一条直线上。 这会产生一个不可见的退化三角形。 前两个顶点很好,但是我们应该跳到下一行的第一个顶点。

1
2
3
triangles[0] = 0;
triangles[1] = 1;
triangles[2] = xSize + 1;

这确实给了我们一个三角形,但它只能从一个方向看到。 在这种情况下,它仅在从 Z 轴的相反方向看时可见。 因此,您可能需要旋转视图才能看到它。

三角形从哪一侧可见取决于其顶点索引的方向。 默认情况下,如果它们以顺时针方向排列,则三角形被认为是向前且可见的。 逆时针三角形被丢弃,因此我们不需要花时间渲染对象的内部,这些对象通常无论如何都不会被看到。

alt 图4-2
三角形的两条边

所以当我们向下看 Z 轴时,要让三角形出现,我们必须改变它的顶点遍历的顺序。 我们可以通过交换最后两个索引来做到这一点。

1
2
3
triangles[0] = 0;
triangles[1] = xSize + 1;
triangles[2] = 1;

alt 图4-3
第一个三角形

我们现在有一个三角形覆盖了我们网格的第一个图块的一半。 为了覆盖整个瓷砖,我们只需要第二个三角形。

1
2
3
4
5
6
7
int[] triangles = new int[6];
triangles[0] = 0;
triangles[1] = xSize + 1;
triangles[2] = 1;
triangles[3] = 1;
triangles[4] = xSize + 1;
triangles[5] = xSize + 2;

alt 图4-4
由两个三角形组成的四边形

由于这些三角形共享两个顶点,我们可以将其减少到四行代码,明确地只提到每个顶点索引一次。

1
2
3
4
triangles[0] = 0;
triangles[3] = triangles[2] = 1;
triangles[4] = triangles[1] = xSize + 1;
triangles[5] = xSize + 2;

alt 图4-5
第一个四边形

我们可以通过把它变成一个循环来创建整个第一行图块。 当我们迭代顶点和三角形索引时,我们必须跟踪两者。 让我们也将 yield 语句移动到这个循环中,这样我们就不再需要等待顶点出现了。

1
2
3
4
5
6
7
8
int[] triangles = new int[xSize * 6];
for (int ti = 0, vi = 0, x = 0; x < xSize; x++, ti += 6, vi++) {
triangles[ti] = vi;
triangles[ti + 3] = triangles[ti + 2] = vi + 1;
triangles[ti + 4] = triangles[ti + 1] = vi + xSize + 1;
triangles[ti + 5] = vi + xSize + 2;
yield return wait;
}

顶点 Gizmo 现在立即出现,并且在短暂的等待后,所有三角形都立即出现。 要看到瓦片一张一张地出现,我们必须在每次迭代时更新网格,而不是仅在循环之后更新。

1
2
mesh.triangles = triangles;
yield return wait;

现在通过将单循环变成双循环来填充整个网格。 请注意,移动到下一行需要将顶点索引增加一个,因为每行的顶点数比图块多一个。

1
2
3
4
5
6
int[] triangles = new int[xSize * ySize * 6];
for (int ti = 0, vi = 0, y = 0; y < ySize; y++, vi++) {
for (int x = 0; x < xSize; x++, ti += 6, vi++) {

}
}

alt 图4-5

如您所见,整个网格现在都充满了三角形,一次一行。 一旦您对此感到满意,您可以删除所有协程代码,以便立即创建网格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private void Awake () {
Generate();
}

private void Generate () {
GetComponent<MeshFilter>().mesh = mesh = new Mesh();
mesh.name = "Procedural Grid";

vertices = new Vector3[(xSize + 1) * (ySize + 1)];
for (int i = 0, y = 0; y <= ySize; y++) {
for (int x = 0; x <= xSize; x++, i++) {
vertices[i] = new Vector3(x, y);
}
}
mesh.vertices = vertices;

int[] triangles = new int[xSize * ySize * 6];
for (int ti = 0, vi = 0, y = 0; y < ySize; y++, vi++) {
for (int x = 0; x < xSize; x++, ti += 6, vi++) {
triangles[ti] = vi;
triangles[ti + 3] = triangles[ti + 2] = vi + 1;
triangles[ti + 4] = triangles[ti + 1] = vi + xSize + 1;
triangles[ti + 5] = vi + xSize + 2;
}
}
mesh.triangles = triangles;
}
1
2
为什么不使用单个四边形?
当我们创建一个平面矩形表面时,我们只需要两个三角形就足够了。 这是绝对正确的。 更复杂的结构的关键在于它允许更多的控制和表达。 实验!

生成附加顶点数据

我们的网格目前以一种特殊的方式点亮。 那是因为我们还没有给网格提供任何法线。 默认的法线方向是 (0, 0, 1),这与我们需要的完全相反。

1
2
3
4
5
6
法线是如何工作的?
法线是垂直于表面的向量。 我们总是使用单位长度的法线,它们指向表面的外部,而不是内部。

法线可用于确定光线照射到表面的角度(如果有的话)。 具体如何使用取决于着色器。

由于三角形总是平坦的,因此不需要提供有关法线的单独信息。 但是,这样做我们可以作弊。 实际上顶点没有法线,三角形有。 通过将自定义法线附加到顶点并在它们之间跨三角形进行插值,我们可以假装我们有一个平滑弯曲的表面,而不是一堆平坦的三角形。 这种错觉是令人信服的,只要你不注意网格的尖锐轮廓。

法线是每个顶点定义的,所以我们必须填充另一个向量数组。 或者,我们可以要求网格根据其三角形来计算法线本身。 这次让我们偷懒吧。

1
2
3
4
5
private void Generate () {

mesh.triangles = triangles;
mesh.RecalculateNormals();
}
1
2
如何重新计算法线?
Mesh.RecalculateNormals 方法计算每个顶点的法线,方法是确定哪些三角形与该顶点连接,确定这些平面三角形的法线,对它们进行平均,并对结果进行归一化。

alt 图5-1
alt 图5-2
没有法线和有法线的区别

接下来是UV坐标。 您可能已经注意到网格当前具有统一的颜色,即使它使用具有反照率纹理的材质。 这是有道理的,因为如果我们自己不提供 UV 坐标,那么它们都为零。

要使纹理适合我们的整个网格,只需将顶点的位置除以网格尺寸即可。

1
2
3
4
5
6
7
8
9
10
vertices = new Vector3[(xSize + 1) * (ySize + 1)];
Vector2[] uv = new Vector2[vertices.Length];
for (int i = 0, y = 0; y <= ySize; y++) {
for (int x = 0; x <= xSize; x++, i++) {
vertices[i] = new Vector3(x, y);
uv[i] = new Vector2(x / xSize, y / ySize);
}
}
mesh.vertices = vertices;
mesh.uv = uv;

alt 图5-3
alt 图5-4
不正确的 UV 坐标、夹紧与包裹纹理

纹理现在出现了,但它没有覆盖整个网格。 它的确切外观取决于纹理的环绕模式是否设置为钳制或重复。 发生这种情况是因为我们目前正在将整数除以整数,这会产生另一个整数。 为了在整个网格中获得零和一之间的正确坐标,我们必须确保我们使用的是浮点数。

1
uv[i] = new Vector2((float)x / xSize, (float)y / ySize);

纹理现在投影到整个网格上。 当我将网格的大小设置为 10 x 5 时,纹理将显示为水平拉伸。 这可以通过调整材质的纹理平铺设置来解决。 通过将其设置为 (2, 1),U 坐标将加倍。 如果纹理设置为重复,那么我们将看到它的两个方形图块。

alt 图5-5
alt 图5-6
alt 图5-7
正确的 UV 坐标,平铺 1,1 与 2,1

向表面添加更明显细节的另一种方法是使用法线贴图。 这些贴图包含编码为颜色的法线向量。 将它们应用于表面将产生比单独使用顶点法线创建的更详细的灯光效果。

alt 图5-8
alt 图5-9
凹凸不平的表面,采用金属制成,具有戏剧性的效果

将此材料应用于我们的网格会产生凹凸,但它们是不正确的。 我们需要将切线向量添加到我们的网格中以正确定位它们。

1
2
3
4
5
6
切线如何工作?
法线贴图在切线空间中定义。 这是一个围绕物体表面流动的 3D 空间。 这种方法允许我们在不同的位置和方向应用相同的法线贴图。

表面法线在这个空间中代表向上,但是哪种方式是正确的呢? 这是由切线定义的。 理想情况下,这两个向量之间的夹角为 90°。 它们的叉积产生定义 3D 空间所需的第三方向。 实际上,该角度通常不是 90°,但结果仍然足够好。

所以切线是 3D 向量,但 Unity 实际上使用的是 4D 向量。 它的第四个分量始终为 -1 或 1,用于控制第三个切线空间维度的方向——向前或向后。 这有助于法线贴图的镜像,这通常用于具有双边对称性的事物的 3D 模型,例如人。 Unity 的着色器执行此计算的方式要求我们使用 -1。

由于我们有一个平面,所有切线都指向同一个方向,即向右。

alt 图5-10
假装凹凸不平的平坦表面

现在您知道如何创建简单的网格并使用材质使其看起来更复杂。 网格需要顶点位置和三角形,通常也是 UV 坐标 - 最多四组 - 通常也需要切线。 您也可以添加顶点颜色,尽管 Unity 的标准着色器不使用这些颜色。 您可以创建自己的着色器来使用这些颜色,但这是另一个教程的内容。

top