UPL6-7——手动合并网格

前置知识:UED——Unity编辑器拓展

本章代码关键字

1
2
3
4
5
6
7
8
9
10
11
12
13
CombineInstance                    // 网格实例化参数
CombineInstance.mesh // 网格属性
CombineInstance.subMeshIndex // 使用的子网格索引
CombineInstance.transform // 将网格的顶点变换到某个空间坐标系下的4*4空间变换矩阵
transform.worldToLocalMatrix // 获取可以将点从世界空间转换到本地空间的矩阵
transform.localToWorldMatrix // 获取可以将点从本地空间转换到世界空间的矩阵
mesh.vertexCount // 获取网格的顶点数量
mesh.indexFormat // 网格的索引格式,可以是uint16或uint32,默认是uint16
IndexFormat.UInt16 // uint16的索引格式,它使得网格最多可支持65535个顶点
IndexFormat.UInt32 // uint32的所有格式,它使得网格最多可支持42亿个顶点
mesh.CombineMeshes() // 合并网格,需要传入需要被合并网格的CombineInstance数据,是否合并为单个子网格,是否应用CombineInstance的空间变换矩阵,是否包含光照贴图数据
mesh.RecalculateBounds() // 重新计算网格的包围盒信息
mesh.RecalculateNormals() // 重新计算网格的法线信息,顶点发生合并变化并且需要用法线贴图时需要重新算

为什么要手动合并网格

静态批处理虽然可以帮助我们自动合并静态物体,
但是它会保留原始网格,会增加内存开销,并且动态创建的对象,也不会自动进行静态批处理
而我们自己手动来合并网格,不仅可以减少 DrawCall,还可以解决这些问题:

  1. 我们可以自己合并网格后,销毁原网格,节省内存
  2. 动态创建的对象,可以通过我们自己来控制网格合并
  3. 可以整体变换(移动、旋转、缩放)合并后的网格对象
  4. 我们甚至可以再拓展一下,自己合并材质和网格,让不同材质之间的物体也能一起处理

总的来说,手动合并网格比起静态批处理更加的灵活可控

合并网格的必备知识

  1. 网格合并方法

    Mesh​ 中的 CombineMeshes 方法

    1
    void CombineMeshes(CombineInstance[] combine, bool mergeSubMeshes = true, bool useMatrices = true, bool hasLightmapData = false)
    • 参数1:combine 表示合并实例数组

      • CombineInstance 实例化参数

        • 属性 mesh​:Mesh 要合并的网格

        • 属性 subMeshIndex​:int 使用的子网格索引

        • 属性 transform​:Matrix4x4 空间变换矩阵

          一般用传入:父对象.worldToLocalMatrix * 子对象.localToWorldMatrix 用于将子网格顶点从子对象本地空间转换到父对象的本地空间

          其中:
          父对象.worldToLocalMatrix​ 表示将点从世界空间转换到父对象本地空间
          子对象.localToWorldMatrix 表示将点从子对象本地空间转换到世界空间

    • 参数2:mergeSubMeshes​ 表示是否合并为单个子网格,true​ 合并为单个子网格(需要相同材质)、false 保留多个子网格(可不同材质)

    • 参数3:useMatrices​ 表示是否应用变换矩阵,true 将保持空间位置

    • 参数4:hasLightmapData 表示是否包含光照贴图数据

  2. 合并网格流程

    1. 获取想要合并的网格的网格数据

    2. 根据网格数据实例化对应个数的 CombineInstance 对象

    3. 创建一个新的网格对象,并设置顶点索引格式(用于将子网格顶点从子对象本地空间转换到父对象的本地空间)

    4. 调用 CombineMeshes​ 方法,传入 CombineInstance 数组和相关参数进行网格合并

    5. 得到对应网格后重新计算法线和包围盒等数据

      • 包围盒:一定要重新算
      • 法线、切线:顶点发生合并变化并且需要用法线贴图时需要重新算
    6. 得到合并后的网格即可根据需求使用它

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
using UnityEngine;

public class Lesson46 : MonoBehaviour
{
private void Start()
{
CombineMesh();
}

private void CombineMesh()
{
// 获取想要合并的Mesh
MeshFilter[] meshFilters = GetComponentsInChildren<MeshFilter>();

// 实例化对应的CombineInstance结构体对象数组
CombineInstance[] combines = new CombineInstance[meshFilters.Length];
for (int i = 0; i < meshFilters.Length; i++)
{
// 得到想要的合并的网格信息
combines[i].mesh = meshFilters[i].sharedMesh;
// 将子网格的顶点位置从当前本地空间转换为父对象的本地空间
combines[i].transform = this.transform.worldToLocalMatrix * meshFilters[i].transform.localToWorldMatrix;
Destroy(meshFilters[i].gameObject);
}

// 创建新网格并合并网格
Mesh mesh = new();
// indexFormat属性默认是Uint16,代表最多支持65535个顶点,如果超过65535个顶点,则此参数一定要设置为Int32
int totalVertices = 0;
foreach (var item in combines)
{
totalVertices += item.mesh.vertexCount;
}
if (totalVertices > 65535)
{
mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
}
mesh.CombineMeshes(combines, true, true, false);
// 重新计算网格的包围盒和法线
mesh.RecalculateBounds();
mesh.RecalculateNormals();
// 渲染合并后的网格
MeshFilter filter = this.gameObject.AddComponent<MeshFilter>();
filter.mesh = mesh;
MeshRenderer renderer = this.gameObject.AddComponent<MeshRenderer>();
renderer.material = meshFilters[0].GetComponent<MeshRenderer>().sharedMaterial;
}
}

合并前:

image

合并后:

image

可以看到,网格被合并了,子物体也被销毁,同时 Batch 数量也有效的得到了降低,而且合并的网格对象可以整体的移动,旋转,缩放

存储合并的网络

上文是在运行时合并网格,有时候在实际开发中,我们有时可能需要在开发时就合并网格,并将其存储下来
这样可以减小在运行时合并的开销,因此我们可以基于上文的代码进行修改,将合并后的网格直接作为资源文件存储下来,然后自行决定如何使用

主要流程:要将合并网格功能作为编辑器功能

  1. 添加功能入口
  2. 获取选中对象
  3. 合并选中对象的子对象中的网格
  4. 存储网格
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
using System.IO;
using UnityEditor;
using UnityEngine;

public class CombineMeshEditor : MonoBehaviour
{
[MenuItem("Tools/Mesh/合并所选对象子物体网格")]
public static void CombineMesh()
{
// 获取当前在窗口选择的GameObject
GameObject obj = Selection.activeGameObject;
if (obj == null)
{
Debug.LogError("你没有选择任何GameObject");
return;
}

// 获取想要合并的Mesh
MeshFilter[] meshFilters = obj.GetComponentsInChildren<MeshFilter>();
if (meshFilters.Length == 0)
{
Debug.LogError("你选择的GameObject下的子对象没有MeshFilter");
return;
}

// 实例化对应的CombineInstance结构体对象数组
CombineInstance[] combines = new CombineInstance[meshFilters.Length];
for (int i = 0; i < meshFilters.Length; i++)
{
// 得到想要的合并的网格信息
combines[i].mesh = meshFilters[i].sharedMesh;
// 将子网格的顶点位置从当前本地空间转换为父对象的本地空间
combines[i].transform = obj.transform.worldToLocalMatrix * meshFilters[i].transform.localToWorldMatrix;
}

// 创建新网格并合并网格
Mesh mesh = new();
// indexFormat属性默认是Uint16,代表最多支持65535个顶点,如果超过65535个顶点,则此参数一定要设置为Int32
int totalVertices = 0;
foreach (var item in combines)
{
totalVertices += item.mesh.vertexCount;
}
if (totalVertices > 65535)
{
mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
}
mesh.CombineMeshes(combines, true, true, false);
// 重新计算网格的包围盒和法线
mesh.RecalculateBounds();
mesh.RecalculateNormals();

// 存储合并完成的网格,路径可以通过配置文件存储
if (!Directory.Exists(Application.dataPath + "/Mesh"))
{
Directory.CreateDirectory(Application.dataPath + "/Mesh");
}
AssetDatabase.CreateAsset(mesh, "Assets/Mesh/CombineMesh.asset");
AssetDatabase.Refresh();
}
}

假设要合并如下的对象的网格:

image

选择对象,在工具栏点击对应选项

image

接下来即可得到合并的网格对象:

image

创建一个空物体,添加 MeshFilter​ ,使用合并的网格,再添加 MeshRenderer,关联一个材质,即可应用合并出来的网格

image

更多优化思考

  1. 碰撞器问题

    对于子物体的碰撞体,如果想要保留原碰撞体,我们可以不直接移除子物体,而是只移除子物体渲染相关的控件(例如 MeshFilter​ 和 MeshRenderer

  2. 预设体问题

    上文的编辑器环境存储合并网格,仅仅只包含了网格数据,而其他数据,例如碰撞体,依附脚本等信息等并没有保存,
    因此实际上可以在编辑器内创建包含合并网格信息的预制体,仅将子对象的渲染控件移除