본문 바로가기
[Unity]

[Unity] 동적으로 메쉬 병합하기(CombineMeshes)

by 김기승 2024. 3. 4.

같은 형태의 3D 모델을 사용하더라도,

메쉬(Mesh)와  머티리얼(Material)의 개수가 적을수록

드로우콜(Draw Call)이 감소한다.

 

그렇지만, 어쩔 수 없이 많은 메쉬와 머티리얼을

사용하는 경우도 있다.

 

예를 들어, 블럭을 조립하는 게임을 만든다고 가정해보자.

조립하기 전에는 여러 모델이 분리되어 있기에,

모델의 개수만큼 드로우콜이 늘어날 수 밖에 없다.

 

그러나, 조립하고 난 뒤에는 하나의 모델로도 충분하다.


유니티에서는 Mesh 클래스에 있는 CombineMeshes 함수로

여러 메쉬를 하나의 메쉬로 병합할 수 있다.

따라서, 같은 머티리얼을 가진 메쉬를 병합한다면,

원래 상태를 그대로 유지하면서 최적화할 수 있다.

 

그림으로 알아보자.

위 그림는 메쉬 4개, 머티리얼 1개를 사용하여,

Batches = 4, SetPass calls = 1 이다.

같은 머티리얼을 가진 메쉬를 병합하면,

Batches = 1, SetPass calls = 1로 줄어든다.

 

※ SetPass calls는 쉐이더로 인한 렌더링 패스를 의미한다.

동일한 머티리얼을 사용한다면 횟수를 줄일 수 있다.

위 그림는 메쉬 4개, 머티리얼 2개를 사용하여,

Batches = 4, SetPass calls = 2 이다.

같은 머티리얼을 가진 메쉬를 병합하면,

Batches = 2, SetPass calls = 2로 줄어든다.

 

이론상 메쉬 개수를 머티리얼 개수만큼 줄일 수 있다.


스크립트

using UnityEngine;
using UnityEngine.Rendering;
using System;
using System.Collections.Generic;

public class MeshCombiner
{
    //__________________________________________________________________________ Main
    public static GameObject CombineMeshes(GameObject target) // 메쉬들을 하나로 병합하는 함수
    {
        // 현재 오브젝트 하위에 포함된 모든 MeshRenderer와 MeshFilter를 저장한다.
        List<MeshRenderer> rendererList = new List<MeshRenderer>();
        List<MeshFilter> filterList = new List<MeshFilter>();

        Transform[] childs = GetAllChild(target.transform);
        for (int i = 0, l = childs.Length; i < l; ++i)
        {
            MeshRenderer renderer = childs[i].GetComponent<MeshRenderer>();
            MeshFilter mesh = childs[i].GetComponent<MeshFilter>();

            if (renderer && renderer.sharedMaterials.Length > 0 && mesh && mesh.sharedMesh)
            {
                rendererList.Add(renderer);
                filterList.Add(mesh);
            }
        }

        // 머티리얼로 메쉬 데이터(Mesh와 로컬 Matrix)들을 저장하는 Dictionary를 구성한다.
        // MeshFilter의 매쉬를 GetSubMeshes 함수를 통해 서브 메쉬(머티리얼을 바탕으로 구분된 메쉬)로 나누어 저장한다.
        // 따라서, 같은 머티리얼이 여러 오브젝트에 나뉘어 사용되어도 병합할 수 있다.
        Dictionary<Material, List<Tuple<Mesh, Matrix4x4>>> mat2Mesh = new Dictionary<Material, List<Tuple<Mesh, Matrix4x4>>>();
        for (int i = 0, l = filterList.Count; i < l; ++i)
        {
            Mesh[] meshes = GetSubMeshes(filterList[i].sharedMesh);
            Material[] materials = rendererList[i].sharedMaterials;
            for (int j = 0, l2 = materials.Length; j < l2; ++j)
            {
                if (!materials[j]) continue;

                if (!mat2Mesh.ContainsKey(materials[j]))
                    mat2Mesh.Add(materials[j], new List<Tuple<Mesh, Matrix4x4>>());

                mat2Mesh[materials[j]].Add(Tuple.Create(meshes[j], filterList[i].transform.localToWorldMatrix));
            }
        }

        // 메쉬들을 병합하고 새 오브젝트를 구성한다.
        // CombineInstance에 메쉬와 트랜스폼을 저장하고 CombineMeshes 함수에 넘겨주면 하나의 메쉬로 병합할 수 있다.
        // 버텍스 개수가 65535개를 벗어나면 포맷을 변경해주어야 오류를 방지할 수 있다.
        GameObject combinedTarget = new GameObject(target.name + " (Combined)");
        foreach (Material mat in mat2Mesh.Keys)
        {
            int vertexCount = 0;
            List<Tuple<Mesh, Matrix4x4>> meshDatas = mat2Mesh[mat];
            CombineInstance[] combines = new CombineInstance[meshDatas.Count];
            for (int i = 0, l = meshDatas.Count; i < l; ++i)
            {
                Mesh mesh = meshDatas[i].Item1;
                combines[i].mesh = mesh;
                combines[i].transform = meshDatas[i].Item2;
                vertexCount += mesh.vertexCount;
            }

            GameObject child = new GameObject(mat.name);
            child.transform.SetParent(combinedTarget.transform, false);
            MeshRenderer renderer = child.AddComponent<MeshRenderer>();
            MeshFilter filter = child.AddComponent<MeshFilter>();

            filter.mesh = new Mesh();
            filter.mesh.indexFormat = vertexCount > ushort.MaxValue ? IndexFormat.UInt32 : IndexFormat.UInt16;
            filter.mesh.CombineMeshes(combines);
            renderer.material = mat;
        }

        return combinedTarget;
    }

    //__________________________________________________________________________ Utility
    public static Transform[] GetAllChild(Transform target) // 모든 자식을 반환하는 함수
    {
        // 자식 오브젝트를 재귀 탐색한다.
        List<Transform> childs = new List<Transform>();

        childs.Add(target);
        for (int i = 0, l = target.childCount; i < l; ++i)
            childs.AddRange(GetAllChild(target.GetChild(i)));

        return childs.ToArray();
    }
    public static Mesh[] GetSubMeshes(Mesh mesh) // 서브 메쉬들을 반환하는 함수
    {
        if (mesh == null) return null;

        // 서브 메쉬의 개수만큼 반환할 메쉬 배열을 생성한다.
        // 현재 메쉬의 정점, UV, 노멀 정보를 저장해둔다.
        int subMeshCount = mesh.subMeshCount;
        Mesh[] subMeshes = new Mesh[subMeshCount];

        Vector3[] vertices = mesh.vertices;
        Vector2[] uvs = mesh.uv;
        Vector3[] normals = mesh.normals;

        for (int i = 0, l = subMeshCount; i < l; ++i)
        {
            // 새로운 메쉬를 만들기 위한 리스트들을 생성해둔다.
            List<Vector3> newVertices = new List<Vector3>();
            List<Vector2> newUVs = new List<Vector2>();
            List<Vector3> newNormals = new List<Vector3>();
            List<int> newTriangles = new List<int>();

            // 현재 서브 메쉬의 삼각형 정보를 담는다.
            int[] triangles = mesh.GetTriangles(i);
            for (int j = 0, l2 = triangles.Length; j < l2; j += 3)
            {
                // 삼각형 세 개의 인덱스로 리스트들을 구성한다.
                int idx = triangles[j];
                int idx2 = triangles[j + 1];
                int idx3 = triangles[j + 2];

                newVertices.Add(vertices[idx]);
                newVertices.Add(vertices[idx2]);
                newVertices.Add(vertices[idx3]);

                newUVs.Add(uvs[idx]);
                newUVs.Add(uvs[idx2]);
                newUVs.Add(uvs[idx3]);

                newNormals.Add(normals[idx]);
                newNormals.Add(normals[idx2]);
                newNormals.Add(normals[idx3]);

                newTriangles.Add(newTriangles.Count);
                newTriangles.Add(newTriangles.Count);
                newTriangles.Add(newTriangles.Count);
            }

            // 서브 메쉬를 생성하고 리스트 정보들를 반영한다.
            subMeshes[i] = new Mesh();
            subMeshes[i].indexFormat = newVertices.Count > ushort.MaxValue ? IndexFormat.UInt32 : IndexFormat.UInt16;

            subMeshes[i].vertices = newVertices.ToArray();
            subMeshes[i].uv = newUVs.ToArray();
            subMeshes[i].normals = newNormals.ToArray();
            subMeshes[i].triangles = newTriangles.ToArray();
        }

        return subMeshes;
    }
}

핵심은 CombineMeshes 함수이다.

자신을 포함한 모든 자식을 탐색하여,

같은 머티리얼을 가진 서브 메쉬들을 하나로 병합해주는 역할을 한다.

따라서, 동일한 머티리얼이 여러 메쉬에 사용되어도 문제없다.

 

CombineInstance는 메쉬와 트랜스폼 정보를 담는 구조체이다.

이 구조체 배열을 구성하여, Mesh 클래스의 인스턴스 함수인 CombineMeshes 함수에 전달하면,

하나의 메쉬로 병합된다.

 

GetSubMeshes 함수는 하나의 메쉬를 분리하여 서브 메쉬들을 반환해준다.

 Mesh 클래스의 GetTriangles 함수는 해당 서브 메쉬의 삼각형 인덱스들을 반환해주므로,

정점의 UV와 노멀 정보를 새롭게 구성할 수 있다.


사용 방법

먼저, 스크립트를 간단히 작성해보겠다.

using UnityEngine;

public class TestCombine : MonoBehaviour
{
    public GameObject target;

    private void Start()
    {
        GameObject combinedTarget = MeshCombiner.CombineMeshes(target); // 타겟 오브젝트 병합
        target.SetActive(false); // 기존 오브젝트 비활성화
    }
}

 CombineMeshes 함수는 GameObject를 매개변수로 받아

GameObject를 반환하므로, 캐싱하여 사용할 수도 있다.

그리고, 모델 설정에서 Read/Write를 활성화해야 한다.


결과

기본으로 Batches = 2, SetPass calls = 2를 차지하므로(Skybox 등) 배제하고 생각하자.

병합 전(Batches = 4, SetPass calls = 1)
병합 후(Batches = 1, SetPass calls = 1)
병합 후 Hierarchy

조금 더 복잡한 모델을 보도록 하자.

병합 전(Batches = 7, SetPass calls = 3)

 

병합 후(Batches = 2, SetPass calls = 2)
병합 후 Hierarchy


복잡한 모델일 수록 병합하는 데에 시간이 걸리므로 버벅일 수도 있으니,

가벼운 모델에만 사용하는 것을 권장한다.

 

이 코드를 런타임이 아닌 에디터 상에서도 실행이 가능하다.

다만, 이 경우에는 메쉬가 씬 파일(.unity)에 직접 저장이 되어 용량이 커지므로 참고하도록 하자.

댓글