从图形学认识Unity中的Mesh


前言

此文从图形学的角度讲解了什么是顶点,以及Unity中的Mesh是如何组织顶点并利用什么组件对它们进行绘制的。

  • 内容比较基础,如果需要深入,建议学习基本的图形学知识
  • 偏理论,如果需要更多的代码和运行结果,请在评论区告诉我!
  • 文中的示例代码以上传至github(版本2019.1.8f2) https://github.com/UnknownArkish/UnityMeshDemo

图元的定义Primitive

简单来讲,图元就是组成图像的基本单元,比如三维模型中的点、线、面等等
https://baike.baidu.com/item/图元/2303188?fr=aladdin

图形学的一个环节是建模,这里的建模和美术所说的建模类似,主要在于如何表示一个物体。

计算机中,要绘制一个物体和现实生活中一样,由点构成线,再由线构成面,最后由若干个面构成一个物体。
例如下图中,左图利用三个点绘制了一个三角形,而右图则通过两个三角形得到了一个四边形:

注:这里需要注意的是,很明显从左图中给定的三个点,有两种方式可以得到三角图元,即

  • 顺时针旋转(也称左手螺旋):p0->p1->p2
  • 逆时针旋转(右手螺旋):p0->p2->p1

它们的区别在哪里呢?类似于物理中左右手判断法则,拇指指示了三角图元的法线方向。法线其中一个作用是指明了三角图元的方向,如果从反方向看的话,这个图元是不可见的。(Unity中的Plane同理,只有一面可见)
法线另外的作用可以用来计算光照,不过那不在本文的讨论范围内,感兴趣的可以了解相关的图形学知识)

更复杂一些,即使是美术建模的模型,从图形学的角度上看,其实也就是一堆三角面片的集合:
图源水印,侵删

因此,要表示一个物体,使用三角(triangle)图元作为基本元素就足够了。
(*注:图形接口(如OpenGL和DirectX3D)可能会提供其他规格如四边形的基本图元绘制的规则,但实际上出于统一、直观的原因,一般采用Triangle而不是Quad)

从数学角度表示图元

既然上面已经对图形的基本元素图元(primitive)有了一定的认识,那么图元究竟如何在计算机中表示呢?
其实上面的图(第一张图)已经暗示了,可以使用三维坐标来表示每一个点的位置。

位置

假设我们有Vector2类,它表示一个二维坐标。(三维坐标系同理,只是多了一个维度z)
那么要绘制上图中的三角形,即问题在于表达三个点的坐标,也就可以表示为下面的代码:
(注: 数值无意义,仅表示数值1,单位需要定义)

Vector2[] vertices = new Vector2[]{ 
	new Vector2( 0, 0 ),							// p0
	new Vector2( 0, 1 ),							// p1
	new Vector2( 1, 0 ),							// p2
 };

而如果要绘制四边形,也就需要六个点的坐标,如下面所示:

Vector2[] vertices = new Vector2[]{ 
	// 三角图元 0
	new Vector2( 0, 0 ),							// p0
	new Vector2( 0, 1 ),							// p1
	new Vector2( 1, 0 ),							// p2
	// 三角图元 1
	new Vector2( 0, 1 ),							// p1
	new Vector2( 1, 1 ),							// p3
	new Vector2( 1, 0 ),							// p2
 };

事实上这些数据在OpenGL中,就是VBO(Vertex Buffer Object,顶点缓冲对象)

索引

如果单纯使用顶点来表示(绘制)图形,是非常直观的——因为我们知道每一个顶点的位置信息,并且知道每三个点构成一个三角图元。然而从上面绘制四边形的数据中可以发现,三角图元1中有两个顶点的位置信息(p1和p3)和三角图元0是一样的。这也就增加了一些开销。
假设有n个面,而且每个面之间都是两两相连的(这要求n>=3),那么也就会多增加 2 * n个顶点的开销。如果是三维的Vector3(3 * 4 byte= 12 byte),将会增加 2 * n * 12 byte = 24 * n byte的开销。而一个模型少说都有上千个面,更不用说一个顶点不止保存有位置信息了(其他还会有法线信息,贴图纹理坐标信息等)。

总而言之,这个额外的存储开销是完全可以去除的,方法就是使用索引(Indices)。
简单来说,我们仍然使用vertices存储顶点的位置信息,但是vertices只存储不重复的部分,改为使用vertices数组的indices来表示三角图元。听上去有点拗口,那么就来看代码吧:

Vector2[] vertices = new Vector2[]{ 
	new Vector2( 0, 0 ),							// p0
	new Vector2( 0, 1 ),							// p1
	new Vector2( 1, 0 ),							// p2
 };
 int[] indices = new indices[]{
 	0, 1, 2											// 表示 0->1->2 构成一个三角图元
 };

没错,indices就是这么一回事,原来是vertices数组中,每三个顶点表示一个三角图元,现在是indices数组中,每三个整型表示一个图元。这些整形不存储真正的数据,只有用到时才从vertices中取出,也就是所谓的索引了。
(你可能会觉得比起原来的,这不是额外增加了indices的开销吗,硬要回答的话,emm只能说这个是特殊情况)

为了让上面的indices不那么蠢,那么接下来看看原来的四边形,应用了indices后是怎样的:

Vector2[] vertices = new Vector2[]{ 
	new Vector2( 0, 0 ),							// p0
	new Vector2( 0, 1 ),							// p1
	new Vector2( 1, 0 ),							// p2
	new Vector2( 1, 1 ),							// p3
 };
 int[] indices = new indices[]{
 	0, 1, 2											// 表示 0->1->2 构成一个三角图元 0
 	1, 3, 2											// 表示 1->3->2 构成一个三角图元 1
 };

可以发现,这样以后,vertices就不出现重复的数据了(当然从存储开销上看,好像还是增加了,但是更长远的角度上看,当面数增加时,使用索引绝对可以减少存储开销,同时也方便管理)。

  • 其实从另外一个层面上看的话,indices使得vertices更接近于纯数据类的表示,因为indices将vertices对于「三角面片如何表示」这一个属性分离开了出去;
  • 另外,如果熟悉图形学(或者说建模)的话,顶点不止有位置信息,还有uv、normal等非常重要的顶点属性,如果不使用indices的话,那么代码将会像这样,不仅重复很多,也不好统一管理:
Vector2[] vertices = new Vector2[]{ 
	// 三角图元 0
	new Vector2( 0, 0 ),							// p0
	new Vector2( 0, 1 ),							// p1
	new Vector2( 1, 0 ),							// p2
	// 三角图元 1
	new Vector2( 0, 1 ),							// p1
	new Vector2( 1, 1 ),							// p3
	new Vector2( 1, 0 ),							// p2
 };
Vector2[] uvs= new Vector2[]{ 
	// 三角图元 0
	new Vector2( 0, 0 ),							// p0
	new Vector2( 0, 1 ),							// p1
	new Vector2( 1, 0 ),							// p2
	// 三角图元 1
	new Vector2( 0, 1 ),							// p1
	new Vector2( 1, 1 ),							// p3
	new Vector2( 1, 0 ),							// p2
 };
 // 如果后面还有normal信息,同样需要长度为 6的数组
  • 而如果使用indices,那么如下面一样(每一个vertice和uv是对应的,因此可以用 indices来同一管理它们):
Vector2[] vertices = new Vector2[]{ 
	new Vector2( 0, 0 ),							// p0
	new Vector2( 0, 1 ),							// p1
	new Vector2( 1, 0 ),							// p2
	new Vector2( 1, 1 ),							// p3
 };
Vector2[] uvs= new Vector2[]{ 
	new Vector2( 0, 0 ),							// p0
	new Vector2( 0, 1 ),							// p1
	new Vector2( 1, 0 ),							// p2
	new Vector2( 1, 1 ),							// p3
 };
 int[] indices = new indices[]{
 	0, 1, 2											// 表示 0->1->2 构成一个三角图元 0
 	1, 3, 2											// 表示 1->3->2 构成一个三角图元 1
 };

(注:关于贴图Texture以及贴图纹理坐标uv不在本文的讨论范围内,如果需要可以查阅相关资料,或者后面我也会写一篇文章进行描述)


Unity中的Mesh

上面对图元和图形进行了一些理论上的解释,相信接下来对于Unity中的Mesh类的理解也就不难了。
Unity的Mesh是一个纯数据类,只保存数据,不负责任何逻辑计算和任何渲染(但是仍然公开有一些函数,但是仍然是针对Mesh数据的,例如CalculateNormal或者Combine等)。

要创建一个Mesh,可以直接使用new的方式,创建一个空白Mesh:

Mesh mesh = new Mesh();

顶点位置vertices

Mesh中有一个公开类变量vertices,它就是上面所说的顶点的位置信息:

Vector3[] vertices = new Vector3(){
	Vector3.zero,
	new Vector3( 0, 1, 0 ),
	new Vector3( 1, 0, 0 ),
	new Vector3( 1, 1, 0 ),
};
// 将顶点位置信息赋给mesh
mesh.vertices = vertices;

索引顶点triangles

而公开类变量 triangles对应于上面所说的 indices,由此可见,mesh只支持「每三个顶点形成一个三角形面片」。
如下所示的代码,则是定义了两个 triangle,也就是上面的四边形:

int[] triangles = new int[]{
	0, 1, 2,
	1, 3, 2,
};
// 将顶点信息赋给mesh
mesh.triangles = triangles;

MeshFilter和 MeshRenderer

在这之前都毫无疑问,我们创建了一个Mesh类,里面存储了我们将要绘制的顶点数据信息。但是如果你使用过Unity的话,你会发现Unity不会直接使用Mesh作为组件,挂载至GameObject上(而且Mesh也继承自UnityEngine.Object而不是MonoBehaviour)。如下图所示:
在这里插入图片描述
取而代之的是 MeshFilter组件,而且显而易见的是,MeshFilter持有一个mesh的实例。关于为什么要使用MeshFilter保存Mesh,在后面会进行解释,现在先让我们对其渲染吧。
前面说过,Mesh是纯数据类,不涉及渲染部分,因此如果要对Mesh进行渲染,那么就需要依赖于Renderer组件了。如下面所示的代码,创建了MeshFilter以及MeshRenderer:

	// 添加 MeshFilter
	MeshFilter meshFilter = GetComponent<MeshFilter>();
	if (meshFilter == null)
	{
		meshFilter = gameObject.AddComponent<MeshFilter>();
	}
	meshFilter.mesh = mesh;
	// 添加 MeshRenderer
	MeshRenderer meshRenderer = GetComponent<MeshRenderer>();
	if( meshRenderer == null)
	{
		meshRenderer = gameObject.AddComponent<MeshRenderer>();
	}

如果把上面所说的代码,挂载到一个物体上(这里我挂在了一个空物体上),运行之后就会有以下结果:

虽然它丑不拉几,但是至少我们渲染了一个四边形出来了!
至于为什么它的颜色那么奇怪,是因为没有设置Material。Material就涉及 Shader着色器了,简单来说 Shader和 Material共同描述如何对物体进行更详细的渲染,这个渲染是GPU级别的。
(关于Shader部分,笔者也在学习阶段,或许以后也会写一篇文章)

MeshFilter的作用

接下来讲讲MeshFilter的作用。
个人猜测,MeshFilter是Unity中对Mesh的封装,相当于Mesh的容器。这样做有一个好处是,让Mesh更加纯碎的表达数据,换一句话说,MeshFilter起到了管理Mesh的作用。例如引擎可以通过共享Mesh数据类从而减少存储的开销。例如场景中如果有非常多的Cube,其实从图形本身来看,它们的组成(即Mesh)是一样的,唯一区分它们的只是Transform而已(这里涉及一些图形学的几何变换信息,感兴趣的可以自行了解)。
上述猜测基于 MeshFilter中的另外一个公共变量 sharedMesh,如果我们在场景中创建若干个 Cube以后,我们在代码中随便获取其中一个 Cube的 MeshFilter,进而获取 sharedMesh,修改它为上面的四边形,看看会发生什么事情:
在这里插入图片描述
可以发现场景中所有的Cube都变成了四边形,而我们只修改了其中一个Cube的 sharedMesh,因此验证了上面的猜测。

  • Unity会对一些基本模型采取 sharedMesh的形式,以减少数据存储开销
  • Unity对 Mesh并不是运行时加载的,上面的测试场景如果停止运行,会发现没有恢复原样,这说明Mesh信息在运行前就已经加载在内存当中

总而言之,Unity的官方文档中,对于 sharedMesh和 Mesh的使用上有以下的建议:

  • 当访问 mesh的时候,会实例化一个全新的mesh,因此不会影响到其他 GameObject的Mesh
  • 因此如果不涉及写数据,只是读数据的话,建议使用 sharedMesh代替mesh,因为 mesh会进行duplicate(拷贝)操作
  • 需要手动 destroy因访问 mesh而新实例化的 Mesh数据
    (注:这些为个人总结,建议访问官方文档以得到更准确的解释)

其他数据

以下内容属于是Mesh中一些其他的顶点属性或者信息。如果你接触过openGL、DirecX3D或者编写过Shader,那么下面的内容应该不会感到陌生。

贴图纹理坐标uv

贴图纹理坐标用以指示某个顶点应该从哪里进行纹理采样,纹理的相关知识较多,建议查阅相关资料。
Unity中的 Mesh支持8套 uv,分别是 uv和 uv1- uv7(Shader对应是 TEXCOORD0 -TEXCOORD7)。
例如下面的代码,则是设置 Mesh第一套uv坐标:

Vector2[] uvs = new Vector2[]{
	new Vector2( 0, 0 ),
	new Vector2( 0, 1 ),
	new Vector2( 1, 0 ),
	new Vector2( 1, 1 ),
};
mesh.uv = uvs;

有几个需要注意的地方:

  • uv坐标的的个数应该与vertexCount一致
  • 不能直接设置 mesh的uv,即不能直接 mesh.uv[0] = new Vector2( 0, 0 ); (原因笔者暂未明白)

法线normal

法线同样是一个非常重要的顶点属性,它不仅说明了面片的朝向,还可用于计算光照模型(这两个其实说的同一件事情)。
顶点法线的计算会对应的算法,一般采取的是——对于一个顶点,它的法线应该是周边面片法线的平均值(注意区分面片的法线和顶点的法线)。
幸运的是,Mesh提供了一个接口可以根据当前的 vertices和 triangles进行法线的计算,其API为:

public void RecalculateNormals();

只需要直接调用即可。

次法线subnormal和切线tangent

次法线和切线一般用一定义切线空间,切线空间可以实现各种高级贴图效果(例如法线贴图、视差贴图等)
同样可以通过接口计算顶点的切线:

public void RecalculateTangents();

由于切线、次法线、法线两两垂直,所以可以通过叉乘,将次法线也计算出来。


SubMesh

上面提到过,MeshRenderer负责对MeshFilter中的Mesh进行绘制,而绘制依赖于着色器,或者说着色器生成的材质(Material)。简单来说,材质具体描述了Mesh中的数据如何进行绘制。
Material只负责一个Mesh的一次绘制,但是现在有一个要求是,Mesh的不同部分,要求使用不同的材质。例如上面所绘制的四边形,两个三角面片要求绘制出不同的颜色。这个时候就需要使用SubMesh了。
从名字也能看出,SubMesh类似于Mesh,并且Mesh包含SubMesh。但是和Mesh不一样的是,SubMesh只有triangles信息,也就是说SubMesh使用自身的 triangles信息,索引找到 Mesh上的顶点信息(如vertices、 uv、normal等)后进行三角面片的绘制。它们的关系如下图所示:
在这里插入图片描述
这个设置的过程依赖于Mesh中的API是 SetTriangles,其函数原型为:

/*
 @parm triangles: 			SubMesh的triangles
 @parm submesh: 			属于Mesh中的第几个submesh
 @parm calculateBounds:	是否计算包围盒,默认为true。设置为false会使用当前存在的包围盒以减少CPU开销
 @parm baseVertex:			triangles中每一个元素的偏移值,默认为0;
*/
public void SetTriangles(int[] triangles, int submesh, bool calculateBounds = true, int baseVertex = 0);

用法如下:

	Vector3[] vertices = new Vector3[]{
	     new Vector3( 0, 0, 0 ),
	     new Vector3( 0, 1, 0 ),
	     new Vector3( 1, 0, 0 ),
	     new Vector3( 1, 1, 0 ),
	};
	int[] subTriangles_0 = new int[]{
		0, 1, 2
	};
	int[] subTriangles_1 = new int[]{
		1, 3, 2
	};
	Mesh mesh = new Mesh();
	mesh.vertices = vertices;
	// 告诉 Mesh它将会有两个SubMesh
	mesh.subMeshCount = 2;
	mesh.SetTriangles( subTriangles_0 , 0 );
	mesh.SetTriangles( subTriangles_1 , 1 );
	// 重新计算normal信息,不然后面的 material会不起作用
	mesh.RecaculateNormals();
	// MeshFilter
	MeshFilter meshFilter = GetComponent<MeshFilter>();
	if( meshFilter == null ) meshFilter = gameObject.AddComponent<MeshFilter>();
	meshFilter.mesh = mesh;
	// MeshRenderer
	MeshRenderer meshRenderer = GetComponent<MeshRenderer>();
	if( meshRenderer == null ) gameObject.AddComponent<MeshRenderer>();

将上述代码挂载到一个空节点上运行,可以得到以下结果:

嗯?!难不成笔者在骗人,这里明明只有一个三角面片。莫慌,之前不是说过,要求两个三角面片使用不同的材质吗?如果运行时,将MeshRenderer的Material的Size设为2的话,你会发现另外一个三角图元也出现了:

也就是说,有多少个SubMesh,就需要有多少个Material,并且它们的绘制关系是一一对应的。即第一个SubMesh使用第一个
Material绘制,第二个SubMesh使用第二个Material绘制,以此类推。为了验证这个,接下来创建两个默认的Material,将它们的Albedo分别设置为红色和绿色,接着运行时将这两个 Material赋给MeshRenderer,将会得到如下图所示的运行结果:
在这里插入图片描述
另外,如果双击 MeshFilter的 Mesh属性,从下面的预览窗口,也能看到这个Mesh有两个SubMesh:
在这里插入图片描述


MeshCombine

最后来说说MeshCombine,如果不说MeshCombine的话,其实SubMesh比较鸡肋,毕竟哪有程序员亲自动手负责对不同的模型 Mesh进行着色的,头发本来就不多的程序员当然将这些任务丢给美工小姐姐去做啦。
跑题了,回到MeshCombine,就是合并网格。为什么好好的模型要进行网格的合并呢?如果从应用的角度来看的话,通过网格合并可以实现游戏中的换装,你可能会说,更换的服装例如帽子好好的,为什么要合并到人物模型上?
问题在于,如果游戏中不是所有人物模型都是标准的人型呢?例如魔兽世界中,不同种族的头部模型大小是不一致的,很容易出现穿模。当然可以同一顶帽子制作适配于不同种族的模型,反正这个工作是美术做的(溜)。但是从整体出发的话,这样做无疑增加了游戏容量的大小,也会减慢游戏开发的进度,同时一定程度上影响代码的简洁性。
但是通过网格合并,将帽子的网格合并到人物模型上,并且蒙皮至人物头部的骨骼上。这样以后,由于骨骼的大小、旋转、位移信息会影响顶点,因此一旦这个帽子戴到别的任务模型身上时,由于它们头部骨骼的大小不一样,那么帽子也会自动变大。也就达到了同一个模型,适用于多个人物模型的目的。
(关于骨骼、蒙皮、骨骼蒙皮动画,可以参考下面的扩展资料)

网格合并

关于网格合并,同样Unity在 Mesh提供了相关的API:

/*
 @parm combine: 			要进行合并的CombineInstance实例
 @parm mergeSubMeshes: 		是否将SubMesh进行合并,true为将SubMesh进行合并,false表示以SubMesh的形式存在
 @parm useMatrices: 		是否应用定义在CombineInstance中的transform信息
 @hashLightmapData: 		如果为true,则应用CombineInstance中的lightmapScaleOffset对mesh中的lightmapUV进行偏移
*/
public void CombineMeshes(CombineInstance[] combine, bool mergeSubMeshes = true, bool useMatrices = true, bool hasLightmapData = false);

简单来说,CombineInstance存放了要合并的Mesh的实例,而 CombineMeshs函数就是将所有的 CombineInstance合并为一个Mesh。
注意如果 mergeSubMeshes设置为false,要合并的Mesh将会以SubMesh的形式合并至Mesh当中,而从上面对SubMesh的认识可以知道,这将会要求 Renderer有对应数量的 Material,否则会出现上面三角面片没有渲染的情况出现。

CombineInstance

CombineInstance直译过来是合并实例,它是一个结构体,本质上是 Mesh的容器,同时也简单描述了要如何对这些 Mesh进行合并。它只有以下几个成员变量:

  • lightmapScaleOffset:应用到 mesh中 lightmapUV的偏移信息.
  • realtimeLightmapScaleOffset:和 lightmapScaleOffset类似,但是这个影响的是 realtimeLightmap
  • mesh:需要被合并的 Mesh数据
  • subMeshIndex:合并后,它的 SubMesh下标
  • transform:合并前,对 mesh的几何变换矩阵

要使用它也很简单,如下面的代码所示:

        // 收集要合并的物体的所有Mesh信息
        MeshFilter[] childMeshFilters = GetComponentsInChildren<MeshFilter>();

        CombineInstance[] destCombineInstances = new CombineInstance[childMeshFilters.Length];
        for ( int i = 0; i < childMeshFilters.Length; i++)
        {
            destCombineInstances[i] = new CombineInstance();
            destCombineInstances[i].mesh = childMeshFilters[i].mesh;
            destCombineInstances[i].transform = childMeshFilters[i].transform.localToWorldMatrix;

            // 隐藏子物体,或者Destory
            childMeshFilters[i].gameObject.SetActive(false);
        }
        Mesh destMesh = new Mesh();
        // 进行合并
        destMesh.CombineMeshes(destCombineInstances, true);
        destMesh.RecalculateNormals();

        // 将合并后的mesh赋给当前的MeshFilter
        MeshFilter meshFilter = GetComponent<MeshFilter>();
        if (meshFilter == null) meshFilter = gameObject.AddComponent<MeshFilter>();
        meshFilter.mesh = destMesh;

        MeshRenderer meshRenderer = GetComponent<MeshRenderer>();
        if( meshRenderer == null)
        {
            meshRenderer = gameObject.AddComponent<MeshRenderer>();
            // 设置MeshRenderer的material
            Material material = new Material(Shader.Find("Standard"));
            meshRenderer.material = material;
        }

将上述代码挂载到一个空节点上,由于代码行为是将子物体的Mesh进行合并,因此可以在此空物体下,创建多个3D物体。如下图所示,创建了一个 Cube和一个 Sphere:
在这里插入图片描述
运行场景,可以发现子物体都被隐藏了(active为 false),但是场景上仍然有这两个物体。而我们的空物体,确实有了 Mesh信息和相关的 Filter、Renderer,说明成功合并了。如果双击Mesh,也可以进行验证:
在这里插入图片描述
当然这里的网格合并只是一个模拟的过程,真实的网格合并,往往还需要合并贴图,并且需要重新计算顶点的 uv信息。
下面的扩展资料中,也有一个关于MeshCombine的讲解,以及一个换装的实例 github工程,可以藉由此来学习!


扩展资料

发布了4 篇原创文章 · 获赞 0 · 访问量 468

猜你喜欢

转载自blog.csdn.net/Arkish/article/details/98482430