《Unity3D高级编程之进阶主程》第五章,3D模型与动画(二) - 合并3D模型

Animation 和 Animator的选择。

===

首先说明Unity3D引擎已经不再对Animation动画系统进行维护。但不维护也并不是说一定不能用,很多旧的项目任然在用,只是在老版本中使用。新动画系统 Mecanim 中有了新的动画组件 Animator,为什么要用新系统 Mecanim 呢?

原因如下几个方面:

  1. Mecanim 系统使用多线程计算,比Animation的单线程性能要高出一点。

  2. Unity3D本身就自带对 Mecanim 系统的优化选项“Optimize GameObject”。开启该选项,Animator.Update和MeshSkinning.Update的CPU占用均会一定程度的降低。

  3. Animator的功能更加多,Retargeting功能让不同角色使用同一套动画资源,比如游戏中的角色的空闲动画,就可以使用同一个动画文件省去了动画资源内存的开销。Animator状态机的接入,让动画可以在不同的条件下可以自动的切换。

Animation因为Unity3D引擎不再维护了,大多数人选择Animator这是正确的做法。其实就我而言我更喜欢Animation,但现实是也不得不抛弃它去投奔新‘主子’(Animator),因为不再维护意味着从长远来看它会越来越糟糕。

Unity3D 3D模型中SubMesh的意义

在模型中可以有很多网格,一个模型可以由很多个网格构成。因此在Unity3D中一个Mesh网格的构成可以由多个子Mesh组成也就是SubMesh,即一个Mesh里可以有多个SubMesh。

引擎在渲染的时候,每个SubMesh都需要对应一个Material材质球来匹配做渲染,说白了一个SubMesh本身就是普通的模型有很多个三角形构成它也需要材质球支持以达成渲染。在美术人员制作3D模型过程中,可以将SubMesh拆分成独立的Mesh,也可以并成多个子模型即SubMesh。

这里可能大家有个疑问,为什么美术人员在制作3D模型时不把网格都编成一个而要制作成多个SubMesh?这是有原因:

一种情况是,3D模型制作人员在制作模型的时候,希望一个模型中一部分Mesh用一种材质球来表现效果,另一部分Mesh则用另一种材质球来表现效果,这时就需要将模型拆分开来。因为一个Mesh只能对应一个材质球做渲染,一个材质球只能表现一种效果,当他们需要表现两种完全不同的效果时就需要拆分。

第二种情况是,模型中的某部分的贴图,在众多模型中共同使用的频率比较高,为了不重复制作以及减少重复劳动,那么就会让原本可以整体的模型单独拆分出来一部分公共材质的部分让它们都使用同一个材质球。

第三种情况是,在制作动画时,由于动画过于复杂导致如果使用同一个模型去表现的话,骨骼数量就会成倍增加。为了能更好的表现动画,也为了能更节省骨骼的使用量,拆分出一部分模型让他们单独成为模型动画的一部分。

以上三种情况都是我们在制作模型过程中需要着重考虑的问题,通常情况下都会用拆分模型的方式来解决这些问题。

其实SubMesh有诸多好处,与没有SubMesh的Mesh相比,拥有多个SubMesh一样可以有动画,另外它还能针对不同部分的Mesh选择有个性化的材质球来表现效果,从功能上来看比单个Mesh要灵活的多。但它也有些许缺点,由于每个SubMesh都多出了材质球,导致SubMesh越多,增加的Drawcall也越多。Mesh中存在多个SubMesh,在动作和拆分材质球渲染上确实有很好的优势,但无法与其他Mesh合并,导致优化的一个重要环节被阻断。

SubMesh虽然功能很强大,但对性能的开销也需要注意,需要我们慎重使用。有时我们也可以选择用完全拆分Mesh为其他Mesh的形式来代替SubMesh,这样在合并Mesh时就有更多的选择了。下面我们就来深入浅出的聊聊合并模型的方法和途径。

动态合并3D模型。

我们制作的场景中的3D的物体很多,每个3D物体都需要有一个材质球支持,导致每个模型都会产生一个Drawcall(渲染管线的调用),众多的3D模型会产生很多Drawcall,CPU在等待渲染GPU在忙于处理Drawcall,使得帧率下降画面卡顿感强烈。

实际中的项目都会遇到这样的问题,场景中要摆放的3D物体很多,包括人物,建筑,路标,景观,树木,石头,碎块,花朵等。这些3D物体都有自己的材质球,相同模型的物体使用相同的材质球,不一样的物体使用不同的材质球,有时不一样的物体也有相同的材质球。如果不做任何优化处理就会产生很多Drawcall,导致帧率下降。于是我们就会想这么多的材质球引起这么多Drawcall,是否能合并合并成一个,这就是合并3D模型发挥作用的时候。

合并3D模型主要的目的就是为了减少Drawcall,它是通过减少材质球的提交数量来完成优化手段的,说的简单点就是把拥有相同材质球的模型合并起来成为一个模型和一个材质球,从而减少向GPU提交的Drawcall数量。

Unity3D引擎在合并模型从而优化Drawcall上有自己的功能,即 动态批处理 和 静态批处理 两种,它们的前提条件都是模型物体必须是相同材质球的模型,除了这个必要条件外还有其他条件也需要符合。下面我们就来介绍下Unity3D中动态批处理和静态批处理:

动态批处理

动态批处理即意味着随时都在做的模型合并批量处理,当我们把 Dynamic Batch 动态批处理开启时,Unity3D可以自动批处理场景中某些物体成为同一个Drawcall,如果是他们使用的是同一个材质球并且满足一些条件的话动态批处理会自动完成的,我们不需要增加额外的操作。

其中需要满足的动态批处理的条件是,

1,动态批处理的物体的顶点数目要在一定范围之内,动态批处理只能应用在少于900个顶点的Mesh中。

	如果你的Shader使用顶点坐标,法线,单独的UV,那么只能动态批处理300个顶点内的网格,

	如果你的Shader使用顶点坐标,法线,UV0,UV1和切线,则只能有180个顶点了。

2,两个物体的缩放比例一定是相同,假如两个物体不在同一个缩放单位上,它们将不会进行动态批处理(例如物体A的缩放比例是(1,1,1),物体B的缩放比例是是(1,1,2),他们的缩放比例不同则不会被合并处理,除非A的缩放比例改为(1,1,2),或者B的缩放比例改为(1,1,1))

3,使用相同的材质球的模型才会被合并,使用不同的材质球是不会被动态批处理的,即使他们模型是同一个或者看起来像是同一个。

4,多管线(Pipeline)Shader会中断动态批处理。

	很多Unity3D里的Shader支持多个灯光的前置渲染增加了多个渲染通道,这些多个通道的材质球是无法用于动态批处理渲染的。

	Legacy Deferred(灯光前置通道)传统延迟渲染路径已经被动态处理关闭,因为它必须绘制物体两次。

	所有多个pass的Shader增加了渲染管道,不会被动态批处理。

动态批处理的条件是很苛刻的,在项目中很多模型是不符合动态批处理的。另外动态批处理要消耗CPU转换所有物体的顶点到世界空间的操作,所以它唯一的优势是如果它的工作能让Drawcall变少。

最后我们需要理解一味的减少Drawcall不是万能,它的资源需求取决于很多因素,主要被图形API使用。例如一个控制台或流行的API像Apple Metal这样的,Drawcall的开销会普遍很低,因此动态批处理时常在优化方面的优势并不是很大。

静态批处理

静态批处理允许引擎在离线的情况下去做模型合并的批处理以降低Drawcall,无论模型多大只要使用同一个材质球都会被静态批处理优化。他通常比动态批处理有用(因为它不需要实时转换顶点来消耗CPU),但也消耗了更多的内存。

为了让静态批处理起作用,我们需要将物体置为静态不同的,即我们需要去确认指定的物体是否是静态的不能动,不能移动、不能旋转或者缩放。因此我们需要给这物体在面板上标记一个静态的标记以确定性的告诉Unity3D引擎,此物体是不能动不能缩放的,可以对该物体做静态批处理的预处理。

使用静态批处理需要增加额外的内存来存储合并的模型。在静态批处理下如果一些物体在静态批处理前共用一个模型,那么Unity3D会复制每个物体的模型以用来合并,在Editor里或者在实时运行状态下都会做这个操作。这可能不总是有益的,因为这样做会带来大量的内存增加,因此有时我们需要减少对物体的静态处理来减少内存的使用量,虽然这样做会牺牲了渲染性能,不过我觉得内存换CPU是值得的,但是如果100兆的内存来换1%的CPu效率任然是不划算的,所以我们还是应该谨慎。

静态批处理的具体做法是,将所有静态物体放入世界空间,并且把他们以材质球为分类标准分别合并起来,并构建一个大的顶点集合和索引缓存,所有可见的同类物体就会被同一批的Drawcall处理,这就会让一系列的Drawcall减少从而实现优化的效果。

技术上来说静态批处理并没有节省3D API Drawcall数量,但他节省了他们之间的状态改变导致的消耗。在大多数平台上,批处理被限制在6万4千个顶点和6万4千个索引(OpenGLES上为48k,macOS上为32k),所以倘若我们超过这个数量需要取消一些静态批处理对象。

现在我们知道动态批处理 和 静态批处理是什么了,我们来做个简单总结:

	1,	动态批处理条件是,使用同一材质球,顶点数量不超过900个,有法线的不超过300个顶点,有两个UV的不超过150个顶点,缩放大小要一致,Shader不能有多通道。

	2,	静态批处理条件是,必须是点上静态标记的物体,不能动,不能旋转,不能缩放,不能有动画。

动态批处理的规则是极其严格的,在具体的场景中能用到的模型是相对简单的,它对顶点限制太紧,而且缩放比例还要相同,渲染管道也只能有一个。

静态批处理的使用范围更广一些,但要求物体是静态不能移动,旋转,缩放。这个限制太固定,用到的地方只有完全不动的场景中的固定物体。

动态批处理限制太大,静态批处理又不满足我们的需求,所以有时我们也只能自己手动合并模型来替代Unity3D的批处理。也只有用自己程序合并的模型才能体现自定义动态批处理的用途。比如构建场景后的动态建筑,动态小件合并,人物模型更换装备,发型,首饰,衣裤等导致多个模型挂载的需要合并模型来优化渲染。

自己来编写合并3D模型的程序

为了编写自己的合并3D模型程序需要调用些Unity3D的API,我们来了解下Unity3D的几个类和接口:

	Mesh类有个CombineMeshes的接口提供了合并3D模型的入口。

	MeshFilter类,是承载Mesh数据的类。

	MeshRenderer类,是绘制Mesh网格的类。

在使用这几个类前我们首先需要弄明白几个概念:

1, SubMesh的意义。

	前文用专门的一节来解释它的意义。这里简单阐述下,SubMesh是Mesh里拆出来的子模型,SubMesh需要额外多个的材质球,而普通的Mesh只有一个材质球。

2, MeshFilter 和 MeshRenderer中的 mesh 和 shareMesh ,material 和 shareMaterial 的区别。

	mesh 和 material 都是实例型的变量,对 mesh 和 material 进行任何操作都会额外复制一份后再进行重新赋值,即使只是get操作也同样会发生复制效果。也就是说对 mesh 和 material 进行操作后就会变成另一个实例,虽然看上去一样,但其实已经是同的实例了。

	sharedMesh 和 sharedMaterial 与前面两个变量不同,他们是共享型的。多个3D模型可以共用同一个指定的 sharedMesh 和 sharedMaterial,当你修改sharedMesh或sharedMaterial里面的参数时,多个同是指向同一个 sharedMesh 和 sharedMaterial的模型就会同时改变效果。也就是说 sharedMesh 和 sharedMaterial 被改变后,所有使用sharedMesh 和 sharedMaterial资源的3D模型会有同一个表现效果。

3, materials 和 sharedMaterials 的区别。

	与前面 material 和 sharedMaterial 同样的区别, materials 是实例型,sharedMaterials 是共享型,只不过现在他们变成了数组形式。

	materials 只要对它进行任何操作都会复制一份一模一样的来替换,sharedMaterials 操作后所有指向这个材质球的模型都会改变效果。而 materials 和 material,与 sharedMaterials 和 sharedMaterial 的区别是,materials和sharedMaterials可以针对不同的subMesh,而material和sharedMaterial只针对主Mesh。也就是说 material 和 sharedMaterial 等于 materials[0] 和 sharedMaterials[0]。

4, Mesh,MeshFilter,MeshRenderer的关系。

	Mesh是数据资源,它可以有自己的资源文件,比如XXX.FBX。Mesh里存储了,顶点,uv,顶点颜色,三角形,切线,法线,骨骼,骨骼权重等提供渲染必要的数据。

	MeshFilter是一个承载Mesh数据的类,Mesh被实例化后存储在MeshFilter,MeshFilter有两种类型即实例型和共享型的变量,mesh和sharedMesh,对mesh的操作将生成新的mesh实例,而对sharedMesh操作将改变与其他模型共同拥有的那个指定的Mesh数据实例。

	MeshRenderer具有渲染功能,它会提取MeshFilter中的Mesh数据,结合自身的materials或者sharedMaterials进行渲染。

5, CombineInstance即合并数据实例类。

	合并时我们需要为每个需要合并的 Mesh 创建一个CombineInstance实例并往里面放入,mesh,subMesh的索引,lightmap的缩放和偏移,以及realtimeLightmap的缩放和偏移(如果有的话),和世界坐标矩阵。CombineInstance承载了所有需要合并的数据,通过将CombineInstance数组传入到合并接口,即通过Mesh.CombineMeshes接口进行合并。

理清以上概念后,我们在编写合并3D模型程序时难度会降低很多。下面来看下合并3D模型的具体步骤:

1,建立合并数据数组


CombineInstance[] combine = new CombineInstance[mMeshFilter.Count];

2,填入合并数据


for(int i = 0 ; i< mMeshFilter.Count ; i++)
{
    combine[i].mesh = mMeshFilter[i].sharedMesh;
    combine[i].transform = mMeshFilter.transform.localToWorldMatrix;
    combine[i].subMeshIndex = i; //标识Material的索引位置,可以为0,1,2等
}

3,合并所有Mesh为单独一个


new_meshFilter.sharedMesh.CombineMeshes(combine);

或者,合并后保留SubMesh


		new_meshFilter.sharedMesh.CombineMeshes(combine,false);

4,CombineMeshes接口定义为


public void CombineMeshes(CombineInstance[] combine, bool mergeSubMeshes = true, bool useMatrices = true, bool hasLightmapData = false);

完整代码为:


CombineInstance[] combine = new CombineInstance[mMeshFilter.Count];

for(int i = 0 ; i< mMeshFilter.Count ; i++)
{
    combine[i].mesh = mMeshFilter[i].sharedMesh;
    combine[i].transform = mMeshFilter.transform.localToWorldMatrix;
    combine[i].subMeshIndex = i;//标识Material的索引位置,可以为0,1,2等
}

new_meshFilter.sharedMesh.CombineMeshes(combine);

· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第五章,3D模型与动画(二) - 合并3D模型

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)