什么是程序几何体? 程序几何体就是用代码建模的几何体。通常情况下,制作3D mesh(3D网格模型)是手动操作美术软件如Maya、3DS Max或者Blender等完成的,而本文要介绍的做法却是使用程序指令构建mesh。
这可以在运行时间(mesh直到终端用户运行程序时才完成)、编辑时间(当应用正在开发时,使用脚本或工具)或在3D美术包(使用脚本语言如MEL或MaxScript)里完成。
showcase_quarters
程序生成mesh的优点在于:
多样性:可以用随机变量生成mesh,也就是说,你可以避免重复制作几何体。
可扩展性:mesh的细节程度可以由终端用户的机器性能或偏好来决定。
可控制性:不了解3D建模软件的游戏/关卡设计师可以理好地控制关卡的外观。
速度:一个对象可以简单迅速地生成多个变体。
谁说的? 以下是本人的背景:我是一名3D美工转游戏程序员再转独立开发者,我认为思考如何用脚本制作出一样东西是非常有趣的。当然,这种乐趣不是谁都能体会,不过没关系。
以下是我自己的游戏项目中的案例:
project_strangers_call
(《Stranger’s Call》的由程序生成的关卡:左半边是最高细节设置,右半边是最低细节设置,因为这些是根据随机生成的布局产生的,所以不可能出现相同的关卡)。
project_ludus_silva
(《Ludus silva》中的植物——这些是玩家在游戏中制作/编辑的)。
曾经有人看到这些图像后问我怎么做出来的。好吧,是用一些基本的形状再添加一些细节做成的。我做过的大部分程序生成的东西都是由两种基本形状构成的:平面和圆柱体。
接下来我们就来学习如何制作吧。
预备知识 本文中出现的所有案例都是使用C#和Unity制作的。所有重要的概念都可以转化为你自己习惯的语言/引擎。
你必须掌握C#的基础,如果还懂一些3D几何体的知识就更好了。
不确定自己的知识储备是否足够的人可以做下面的测试:
1、什么是class、function、array,以及loop?
2、如果我用C#语言写出来,你会不会认得出?
3、你了解3D向量是什么吗?(Unity中的Vector3结构)
4、你知道如何获得从一个点到另一点的方向吗?
怎么样?全部会吗?那就太好了。
不太会?那你可能得去学习一下《官方Unity脚本教程》和“基础C#教程”。
什么是mesh? 大部分学过3D美术或至少了解过3D美术的人都可以跳过这部分。对于那些完全不懂3D美术的人,可以学习一下这些简要的介绍。
我们后面要构建的是一个polygon mesh(多边形网格模型)。可以把它当成3D空间中的一系列顶点(vertices)构成的一系列三角形,每个三角形三个顶点之间形成平面。三角形可以也可以不共享顶点。
demo_sphere_cube_wireframe
(两个3D mesh。左边是灯光渲染后的模型,右边是三角形线条结构。)
三角形和多边形 在我们继续学习以前,首先要理清一些常用的术语。你可能听说过“poly-count”或“high-poly”/“low-poly”之类的术语。这其中的“多poly”通常是指三角形,但最好还能了解一下谁使用这些术语。大多数3D建模软件允许美工用任意边的多边形制作模型。这种软件生成的poly-count通常计算的是那些图形。但当需要渲染时,那些形状通常得分成三角形,因为那样形像软件才能理解。Unity的Mesh class也只能理解三角形。所以我们也将使用三角形建模。
除了三角形和3D位置,我们还要给mesh添加其他数据,如法线(normal)。所谓的“法线”就是与顶点垂直的向外方向。在光照mesh时要用到法线。
demo_normals
另一个要添加的另一个东西是UV座标(或简称为UV)。当给网格模型添加材质时就会用到UV。UV座标是2D空间的位置。在那个座标上的材质的像素会被贴到mesh的对应位置上。UV通常是打开的,因为这样可以把它们理解为从mesh上剥离下来的表面,然后摊平放在材质空间中。
demo_uvs
以上就是mesh的基本知识。下面我们来做几个mesh。
内容 本教程有些长度,所以我分成了如下几个部分:
1-1、平面:从平面到盒子
1-2、平面的进一步运用:制作两个物品—-房子和围栏
2-1、圆柱体:基本物品—-蘑菇和花
2-2:平面
Unity材料:Unity程序包中包含本教程使用到的脚本和场景。
资源文件夹:就是资源文件。
好吧,我们从quad(四方形)开始吧。screen_plane
最简单的形状。这是一个基本平面,有4个顶点,和两个三角形。
我们从我们必需的组件开始制作。这个mesh有顶点、三角形、法线和UV座标。绝对必要的部分只有顶点和三角形。如果你的模型不需要在场景中光照,那么就不需要法线。如果你的模型不需要贴材质,那么就不需要UV。
Vector3[] vertices = new Vector3[4];
Vector3[] normals = new Vector3[4];
Vector2[] uv = new Vector2[4];
现在我们给顶点赋一些值。以上代码有两个之前定义好的变量:m_Width和m_Length。你应该知道表示的是quad的宽度和长度吧。
这个mesh是在XZ面创建的,所以法线的方向与Y轴一致(适合做地面)。你也可以按自己的习惯改用XY面,用Z轴做法线(适合做看板)。
位置值从0.0f开始到长度/宽度,也就是用[0.0, 0.0] 作为mesh的一个顶点位置。mesh的源点就是它的轴点,所以这个mesh就会以那个顶点为旋转点。如果你愿意,还可以通过偏移宽度和长度来使那个值减半,这样轴点位于中央。
vertices[0] = new Vector3(0.0f, 0.0f, 0.0f);
uv[0] = new Vector2(0.0f, 0.0f);
normals[0] = Vector3.up;
vertices[1] = new Vector3(0.0f, 0.0f, m_Length);
uv[1] = new Vector2(0.0f, 1.0f);
normals[1] = Vector3.up;
vertices[2] = new Vector3(m_Width, 0.0f, m_Length);
uv[2] = new Vector2(1.0f, 1.0f);
normals[2] = Vector3.up;
vertices[3] = new Vector3(m_Width, 0.0f, 0.0f);
uv[3] = new Vector2(1.0f, 0.0f);
normals[3] = Vector3.up;
diagram_plane_stage1
现在我们来做三角形。三角形是由3个整数确定的,各个整数就是角的顶点的index。各个三角形的顶点的顺序通常由下往上数的,可以是顺时的也可以是逆时的,这取决于我们从哪个方向看三角形。通常,当mesh渲染时,逆时针的面会被挡掉。我们希望保证顺时针的面与法线的主向一致(即向上)。
int[] indices = new int[6]; //2 triangles, 3 indices each
indices[0] = 0;
indices[1] = 1;
indices[2] = 2;
indices[3] = 0;
indices[4] = 2;
indices[5] = 3;
diagram_plane_stage2
现在我们整理一下。在Unity中,这只是给我们刚才做的所有array赋给一个Unity Mesh class的实例。我们在最后调用RecalculateBounds()来重新计算mesh的大小(渲染需要)。
Mesh mesh = new Mesh();
mesh.vertices = vertices;
mesh.normals = normals;
mesh.uv = uv;
mesh.triangles = indices;
mesh.RecalculateBounds();
现在我们做好一个mesh了。在Unity场景中,我们把这个脚本添加到包含mesh过滤器和渲染器组件的GameObject中。以下代码寻找mesh过滤器并把刚做好的模型赋给它。这个模型现在作为一个物品的一个部分存在于场景中。
MeshFilter filter = GetComponent();
if (filter != null)
{
filter.sharedMesh = mesh;
}
You have now made your first procedural mesh.
这样你就做好了你的第一个程序生成mesh。
做计划 好吧,我们现在假设要做一个程序生成的物品。最无聊的部分就是一次又一次地写相同的mesh初始代码,但还不至于像因为出现错误而被迫重写那么让人郁闷。
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
public class MeshBuilder
{
private List m_Vertices = new List();
public List Vertices { get { return m_Vertices; } }
private List m_Normals = new List();
public List Normals { get { return m_Normals; } }
private List m_UVs = new List();
public List UVs { get { return m_UVs; } }
private List m_Indices = new List();
public void AddTriangle(int index0, int index1, int index2)
{
m_Indices.Add(index0);
m_Indices.Add(index1);
m_Indices.Add(index2);
}
public Mesh CreateMesh()
{
Mesh mesh = new Mesh();
mesh.vertices = m_Vertices.ToArray();
mesh.triangles = m_Indices.ToArray();
//Normals are optional. Only use them if we have the correct amount:
if (m_Normals.Count == m_Vertices.Count)
mesh.normals = m_Normals.ToArray();
//UVs are optional. Only use them if we have the correct amount:
if (m_UVs.Count == m_Vertices.Count)
mesh.uv = m_UVs.ToArray();
mesh.RecalculateBounds();
return mesh;
}
}
你需要的所有数据都在一个class中,它很容易在两个函数之间通过。另外,因为我们使用的是列表,而不是array,所以不会误算顶点或三角形的数量。这样还更容易组合mesh:只要用相同的MeshBuilder生成就行了。
注:这是我在自己的项目中使用的class的简化版。本版不能做的事,包括切线和顶点颜色,或为大mesh保留的空间,或者变更这个Mesh class中已存在的实例。
现在,使用这个class,我们的quad生成代码如下:
MeshBuilder meshBuilder = new MeshBuilder();
//Set up the vertices and triangles:
meshBuilder.Vertices.Add(new Vector3(0.0f, 0.0f, 0.0f));
meshBuilder.UVs.Add(new Vector2(0.0f, 0.0f));
meshBuilder.Normals.Add(Vector3.up);
meshBuilder.Vertices.Add(new Vector3(0.0f, 0.0f, m_Length));
meshBuilder.UVs.Add(new Vector2(0.0f, 1.0f));
meshBuilder.Normals.Add(Vector3.up);
meshBuilder.Vertices.Add(new Vector3(m_Width, 0.0f, m_Length));
meshBuilder.UVs.Add(new Vector2(1.0f, 1.0f));
meshBuilder.Normals.Add(Vector3.up);
meshBuilder.Vertices.Add(new Vector3(m_Width, 0.0f, 0.0f));
meshBuilder.UVs.Add(new Vector2(1.0f, 0.0f));
meshBuilder.Normals.Add(Vector3.up);
meshBuilder.AddTriangle(0, 1, 2);
meshBuilder.AddTriangle(0, 2, 3);
//Create the mesh:
MeshFilter filter = GetComponent();
if (filter != null)
{
filter.sharedMesh = meshBuilder.CreateMesh();
}
开始做几何体screen_ground
你的关卡地形,其实是一个平坦的平面。
注:这里的“不平坦”是由于给各个顶点赋了随机高度。这么做是因为可以让代码漂亮简单,不是因为可以让地形好看。如果你是很认真地要做一个地形mesh,你最好使用heightmap或perlin noise等算法。
这个地形可以当作是一系列排列在网格中的quad。这正是我们要做的第一步。首先,我们定度一个生成我们刚才做的quad的函数。只有这个函数会把参数作为位置offset(平移)值,这是添加到顶点位置的。
void BuildQuad(MeshBuilder meshBuilder, Vector3 offset)
{
meshBuilder.Vertices.Add(new Vector3(0.0f, 0.0f, 0.0f) + offset);
meshBuilder.UVs.Add(new Vector2(0.0f, 0.0f));
meshBuilder.Normals.Add(Vector3.up);
meshBuilder.Vertices.Add(new Vector3(0.0f, 0.0f, m_Length) + offset);
meshBuilder.UVs.Add(new Vector2(0.0f, 1.0f));
meshBuilder.Normals.Add(Vector3.up);
meshBuilder.Vertices.Add(new Vector3(m_Width, 0.0f, m_Length) + offset);
meshBuilder.UVs.Add(new Vector2(1.0f, 1.0f));
meshBuilder.Normals.Add(Vector3.up);
meshBuilder.Vertices.Add(new Vector3(m_Width, 0.0f, 0.0f) + offset);
meshBuilder.UVs.Add(new Vector2(1.0f, 0.0f));
meshBuilder.Normals.Add(Vector3.up);
int baseIndex = meshBuilder.Vertices.Count – 4;
meshBuilder.AddTriangle(baseIndex, baseIndex + 1, baseIndex + 2);
meshBuilder.AddTriangle(baseIndex, baseIndex + 2, baseIndex + 3);
}
注意,三角形的顶点指数。这次,我们添加未知数量的quad到MeshBuilder中。不是从index0开始,我们必须从刚添加的顶点开始。
现在调用我们刚写的函数。相当简单,只是一对调用BuildQuad()的循环,每次循环都增加offset的X和Y值:
MeshBuilder meshBuilder = new MeshBuilder();
for (int i = 0; i < m_SegmentCount; i++)
{
float z = m_Length * i;
for (int j = 0; j < m_SegmentCount; j++)
{
float x = m_Width * j;
Vector3 offset = new Vector3(x, Random.Range(0.0f, m_Height), z);
BuildQuad(meshBuilder, offset);
}
}
现在运行代码,你会看到如下图所示的模型:
screen_ground_stage1
现在看它的基本布局,但mesh效果似乎不太好。我们把这些分散的平面组合成一个吧。为此,我们要先使邻近的quad共享顶点,而不是给每一个quad做4个顶点。事实上,我们只需给各个面做一个顶点。然后这个quad就可以使用这个点做出之前的行和列。新函数如下:
void BuildQuadForGrid(MeshBuilder meshBuilder, Vector3 position, Vector2 uv,
bool buildTriangles, int vertsPerRow)
{
meshBuilder.Vertices.Add(position);
meshBuilder.UVs.Add(uv);
if (buildTriangles)
{
int baseIndex = meshBuilder.Vertices.Count – 1;
int index0 = baseIndex;
int index1 = baseIndex – 1;
int index2 = baseIndex – vertsPerRow;
int index3 = baseIndex – vertsPerRow – 1;
meshBuilder.AddTriangle(index0, index2, index1);
meshBuilder.AddTriangle(index2, index3, index1);
}
}
你会发现这个和之前的版本有许多不同之处。我们使用的位置offset作为顶点位置,因为这是可以增加的。另外,UV座标从外部代码通过,以避免和模型中的所有顶点相同。你会发现没有确定法线——我们之后再做这个。
最有趣的是三角形。它们并不是每次做的。这是因为各个quad 使用之前的行和列的顶点。如果这是任何行或列的第一个顶点,那么就没有之前的顶点可以做quad 了。
index也不同。我们从最后一个顶点index出发,并反向。前面的index是来自前一列的顶点。前一行的index必须减去这一行的index值。
以我的经验,计算三角形index是程序模型生成中最麻烦的部分,因为每次mesh算法改变,就要返回修改index。
我们再看一下调用这个函数的代码:
for (int i = 0; i <= m_SegmentCount; i++)
{
float z = m_Length * i;
float v = (1.0f / m_SegmentCount) * i;
for (int j = 0; j <= m_SegmentCount; j++)
{
float x = m_Width * j;
float u = (1.0f / m_SegmentCount) * j;
Vector3 offset = new Vector3(x, Random.Range(0.0f, m_Height), z);
Vector2 uv = new Vector2(u, v);
bool buildTriangles = i > 0 && j > 0;
BuildQuadForGrid(meshBuilder, offset, uv, buildTriangles, m_StepCount + 1);
}
}
注意,我们是在根据位置offset计算UV。另外还要注意i和j要大于0。这就是我们停止每行或列的第一个顶点再做三角形的办法。
还有一个小小的不同。这个i和j循环的结束条件是“ <=”而不是“<”。因为第一个顶点不生成三角形,我们的地面平面在各个方向上现在还是比较小。作为弥补,我们让循环再进行一次。
最后,还记得那些我们没有计算的法线吗?在模型中,各个顶点的法线取决于与周围顶点有关的顶点位置。因为我们在各个顶点位置都有一些随机性,所以直到所有顶点都生成后才能计算法线。
事实上,我们可以作弊。Unity提供了一种计算法线的函数。
Mesh mesh = meshBuilder.CreateMesh();
mesh.RecalculateNormals();
注意那个Mesh.RecalculateNormals()并不总是最好的解决方案,可能产生奇怪的结果,特别是如果mesh有缝合处的话。这个我们之后再说。但对于我们现在这个平面,它是够用了。
盒子screen_cube
盒子并不比quad来得复杂。它其实只是6个quad。
做一个盒子,我们要使用BuildQuad函数,以决定quad的面向。
the box
void BuildQuad(MeshBuilder meshBuilder, Vector3 offset,
Vector3 widthDir, Vector3 lengthDir)
{
Vector3 normal = Vector3.Cross(lengthDir, widthDir).normalized;
meshBuilder.Vertices.Add(offset);
meshBuilder.UVs.Add(new Vector2(0.0f, 0.0f));
meshBuilder.Normals.Add(normal);
meshBuilder.Vertices.Add(offset + lengthDir);
meshBuilder.UVs.Add(new Vector2(0.0f, 1.0f));
meshBuilder.Normals.Add(normal);
meshBuilder.Vertices.Add(offset + lengthDir + widthDir);
meshBuilder.UVs.Add(new Vector2(1.0f, 1.0f));
meshBuilder.Normals.Add(normal);
meshBuilder.Vertices.Add(offset + widthDir);
meshBuilder.UVs.Add(new Vector2(1.0f, 0.0f));
meshBuilder.Normals.Add(normal);
int baseIndex = meshBuilder.Vertices.Count – 4;
meshBuilder.AddTriangle(baseIndex, baseIndex + 1, baseIndex + 2);
meshBuilder.AddTriangle(baseIndex, baseIndex + 2, baseIndex + 3);
}
这看起来非常像我们之前使用的BuildQuad(),除了这里我们使用方向向量并添加到offse中,而不是直接把值插到X和Y位置。
这里,法线也可以轻易地计算了。只是两个方向的向量积。
注:或者,你可以写一个直接取四个角的位置的函数BuildQuad(),然后使用那些值。对于非常复杂的mesh,那可能是必须的,但对于我们的这个mesh,方向代码更简单清楚。
现在调用新函数:
MeshBuilder meshBuilder = new MeshBuilder();
Vector3 upDir = Vector3.up * m_Height;
Vector3 rightDir = Vector3.right * m_Width;
Vector3 forwardDir = Vector3.forward * m_Length;
Vector3 nearCorner = Vector3.zero;
Vector3 farCorner = upDir + rightDir + forwardDir;
BuildQuad(meshBuilder, nearCorner, forwardDir, rightDir);
BuildQuad(meshBuilder, nearCorner, rightDir, upDir);
BuildQuad(meshBuilder, nearCorner, upDir, forwardDir);
BuildQuad(meshBuilder, farCorner, -rightDir, -forwardDir);
BuildQuad(meshBuilder, farCorner, -upDir, -rightDir);
BuildQuad(meshBuilder, farCorner, -forwardDir, -upDir);
Mesh mesh = meshBuilder.CreateMesh();
diagram_plane_directional
这里,所有平面都来源于两个盒子中相对的角。注意近处的角是源头,意味着mesh将以此为轴点。把farCorner值除以2,nearCorner取其结果的负值。
Vector3 farCorner = (upDir + rightDir + forwardDir) / 2;
Vector3 nearCorner = -farCorner;
用心的读者会发现,第个quad都有4个顶点,所以整个盒子共有24个顶点。当然,如果只有8个,一角一个,使所有三角形共用顶点,那效率就更高了。是不是跟地面mesh一样?
答案是否。这24个顶点我们都需要。这是因为即使顶点位置与各个角一样,法线(和UV)也是不一样的。如果法线共享,那么光照效果就会非常差。
通过分离各个quad的顶点,我们制作了一个沿着边的接合,使法线分到各个面。
注:也就是说,如果出于某些原因,你的mesh不使用法线或UV,那就重写代码共享顶点也是可以的——任何形状都行。如果你要渲染上百个物品,或者制作高模,这么做节约性能的效果是非常明显的。
好好做盒子吧。下一部分,我们将看看如何更好地利用基本形状,以做出更有意思的mesh。