본문 바로가기
Unity • C#

[Unity] 마인크래프트 Voxel System 구현과 Jobs + Burst 최적화

by SAENS 2024. 1. 5.

이 글에서는 마인크래프트를 구현하는 데 사용되는 방법인 Voxel System과, 이를 Job System + Burst Compiler로 최적화하여 속도를 2배 가까이 빠르게 만든 과정을 설명하려 합니다.


A. 서론

서론이 길지만 딱히 쓸 데 있는 내용은 아니니, 넘어가셔도 좋습니다.

최근 만들고 있는 모바일 게임에서 3D 맵 에디터를 만들기 위해 여러가지 방안을 생각해 봤습니다. 처음엔 무식하게 GameObject를 하나씩 Instantiate/Destroy하는 방식으로 구현했는데, 사실 맵이 클 필요가 없어서 성능이 그리 크게 저하되지는 않았습니다. 그런데 모바일이니 성능은 최대한 올리는게 좋고, 기왕 하는거 좀 더 큰 맵을 만들 수 있도록 했으면 좋을 것 같고, 마인크래프트와 같이 블록을 일일이 쌓기만 하는 것 보다는 사각형, 타원 그리기 기능 같은 그림판 유사한 기능도 넣으면 편하지 않을까 하는 욕심이 들었습니다.

그래서 ECS로 구현해보기로 마음 먹었습니다. 블로그에 ECS 관련 글이 몇 개 있는 이유입니다. 막상 하면 그리 어려운 작업은 아닌데, 아예 처음 접해보는 개념이라 처음에는 너무 복잡하고 어려워서 애를 먹었더랍니다. 삽질 끝에 구현을 하고 생각해보니 확실히 블록이 많을 때 렌더링 성능은 전보다 조금 나아졌지만, 그리 극단적인 향상도 아니었고, 메모리 사용량은 아예 줄지 않았다는 것을 깨달았습니다. 모바일에서 돌아가야 하니 메모리는 최대한 아끼고 성능은 최대한 올리는 게 좋죠.

그래서 Voxel 방식을 사용하기로 결정했습니다. 사실 전부터 생각은 했었지만 복잡해 보여서 미뤄뒀는데, ECS보다 덜 복잡하다는 걸 나중에서야 깨달았답니다. Voxel 방식이 아주 빠르다는 것은 마인크래프트를 해봐서 잘 알고 있었고, ECS가 생각보다 별로였어서, 이전 삽질의 흔적을 바로 밀어버리고 눈물을 머금은 채 구현을 시작했습니다.

결론적으로 성능이 비교도 할 수 없이 좋아졌습니다. 따로 최적화 하지 않고 되는 대로 구현했는데도 훨씬 많은 수의 블록을 담을 수 있게 되었죠. 게다가 이전에 ECS나 GameObject를 이용했을 때는 블록이 두 개 이상 맞닿아 있는 경계선에 다른 오브젝트가 충돌할 때 OnCollisionEnter 메서드가 두 개 이상 발생해 이상한 일이 일어나버려서 따로 처리를 해줘야 했는데(이 처리 때문에 LateFixedUpdate 글을 쓰게 됐습니다), Voxel 방식을 이용하면 모든 블록이 하나의 오브젝트로 렌더링되기 때문에 그러한 문제가 전혀 발생하지 않게 됐습니다.

이 Voxel System은 삼차원 공간의 모든 좌표를 순회하고, 각 좌표마다 여섯 방향을 더 검사합니다. 공간의 스케일이 (X, Y, Z) 일 때 최소 X * Y * Y * 6번의 반복이 발생합니다. ECS를 공부하면서 Job System도 함께 공부했는데, 덕분에 이걸 Job System + Burst Compiler 조합으로 최적화 할 수 있겠다는 생각이 들었고, 실행에 옮겼습니다.

네 가지 경우를 테스트를 해 보았는데, 결과는 대강 다음과 같았습니다.

Jobs Burst NativeArray 실행시간(비율) GC 주기(비율)
x x x 1
x o x 1 1
x o o 1.2 3
o x o 1.1 3
o o o 0.5 3

일반 배열과 함께 BurstCompiler만 사용했을 때에는 그냥 구현한 것과 차이가 없었고, NativeArray와 함께 Job System이나 Burst Compiler 중 하나만 사용했을 때엔 그냥 구현한 것보다 오히려 더 느렸으며, 모든 기능을 함께 사용했을 때만 성능이 두 배 가까이 뛴 것을 확인할 수 있었습니다. 이유는 잘 모르겠습니다. 그래서 이 글에서는 생으로 구현한 것과 Job System + Burst Compiler를 모두 사용하는 경우만 다룰 예정입니다.


B. 기본 개념


1. Voxel 이란?

Volume + Pixel의 합성어입니다. 부피를 가진 픽셀이라는 말이죠. 최소 단위인 이 voxel을 게임 내 삼차원 공간에서 1x1x1 크기의 정육면체이며 모든 블록의 위치의 모든 성분들을 음이 아닌 정수로 정의하면 편하게 처리할 수 있습니다.


2. 알고리즘

기본적으로 그래프 탐색 알고리즘의 접근 방식을 사용합니다. voxel[,,]이라는 3차원 배열이 있을 때, voxel[x,y,z] = 0인 경우 (x,y,z)에는 아무 블록도 없고, voxel[x,y,z] = 2인 경우 2번째 타입의 블록이 (x,y,z)에 할당되어있다는 뜻입니다. 이 블록의 타입, voxel[x,y,z]의 값을 voxelIndex라 할 것입니다.

흔히 그래프 탐색 알고리즘 문제를 풀 때 크기가 6인 dx, dy, dz 라는 int 배열들을 사용하는데, 이 대신 크기가 6인 Vector3(또는 int3)배열 하나(이하 deltaPosition)를 사용할 것입니다. 하나의 점(voxel)에서 6방향을 탐색하고 필요한 경우에 Mesh에 면을 그리는 방식입니다.


3. Mesh

Mesh에 네모를 하나 그린다는 것은, 4개의 정점을 추가하고, 2개의 삼각형(= 6개의 vertice인덱스)을 추가하는 일입니다. 적절한 텍스처를 입히기 위해 uv도 추가할 것입니다. 이 과정을 AddFace라고 할 것입니다. 메서드 이름이에요.

(a) vertices와 triangles

Mesh에 하나의 네모를 그린다는게 정확히 어떤 의미인지는 예시를 봐야 와닿습니다. 2차원에서 간단한 예를 들어 설명하자면, (0, 0), (0, 1), (1, 1), (1, 0) 이라는 네 개의 정점을 추가하면, 그 순서대로 Mesh의 vertices 배열에 들어갑니다. 대충 왼쪽 아래부터 (0, 0), 시계방향으로 돈다고 생각하면 오른쪽 그림과 같습니다.

그리고 Mesh의 triangles 배열은 단순한 1차원 int 배열인데요, 각 원소는 vertices에서의 인덱스를 나타냅니다. 즉, triangles 배열에 {0, 1, 2} 세 원소를 추가하면 vertice의 [0], [1], [2], 즉 (0, 0), (0, 1), (1, 1) 을 통해 삼각형을 만들었다는 얘기이며, {0, 2, 3} 세 원소를 또 추가하면 (0, 0), (1, 1), (1, 0) 을 통해 삼각형을 만들었다는 얘기입니다. 이렇게 두 삼각형을 만들면 비로소 하나의 사각형 면이 완성되는 것이죠.

이 때 그려져야 하는 면의 방향과 추가되는 vertice 인덱스의 순서가 중요한데, 오른쪽 그림에서처럼 그려지는 방향을 기준으로 시계 방향으로 돌아야 합니다. 왼손을 따봉 하시면 엄지손가락이 삼각형이 그려져야 할 방향, 나머지 손가락이 vertice들이 추가되어야 할 방향이라고 생각하시면 됩니다.

네모 하나를 그리면 총 4개의 vertices, 6개의 triangles가 나옵니다. (uvs는 vertices와 일대일 대응해야 하므로 이것 역시 4개가 됩니다.) 그래서 n개의 면을 만들게 되면 4n개의 vertices, 6n개의 triangles가 나오고, triangles 배열의 크기는 항상 vertices 배열 크기의 1.5배입니다. 이 사실은 나중에 최적화할 때 중요합니다.

 

 (b) vertices와 uvs

하나의 네모를 추가하고 나면 vertices의 크기가 4가 되고, 이 각각의 vertice들에 uv position을 할당시켜줄 수 있는데, 이는 텍스처의 어떤 위치와 대응되는지입니다. 오브젝트 전체가 오른쪽과 같은 Atlas 형태의 텍스처를 쓸 텐데, 사용할 텍스처들이 하나의 텍스처에 모여 있는 것을 말합니다. 4개의 각 vertice에 대해서 왼쪽 아래면 왼쪽 아래, 오른쪽 위면 오른쪽 위, 그 위치를 잘 맞춰서 대응시켜줘야 합니다. 아래 그림처럼 uv 좌표는 항상 텍스처의 왼쪽 위가 (0, 0)인데, 한 면이 U[0]를 그리도록 하고 싶다면 위 (a)에서 추가된 네모가 왼쪽 아래에서부터 시계방향으로 돌기 때문에, 이에 맞춰서 U[0] 부분의 왼쪽 아래부터 시계방향으로 돌아 (0, 1/4), (0, 0), (1/6, 0), (1/6, 1/4) 순서대로 uv를 추가해줍니다.


4. Unmanaged Type

여기서는 최적화 여부에 상관 없이 Vector3, Vector3Int 등의 타입 대신 Unity.Mathematics에서 정의된 float3, int3 등을 사용했습니다. 그 이유는 여러가지가 있는데, 최적화 관점에서는 Job System을 사용할 때 Unmanaged Type, 즉 클래스(Managed Type)가 아닌 구조체를 사용해야 하기 때문이고, 최적화 문제가 아니더라도 다음 코드 예시에서처럼 x, y, z, w에 접근할 때 배열처럼 접근할 수 있어서 for-loop에서 사용할 수 있고, ~.xy, ~.xz, ~.wx, ~.zwx 와 같이 자유자재로 순서나 개수가 다른 형태로 변환하는게 굉장히 쉽다거나(Shader 코드에서 사용되는 그것과 똑같다고 보시면 됩니다.) 등의 장점이 많기 때문입니다.

int3 myInt4 = new int4(20, 1, 5, 8);
int sum = 0;
for(int i=0 ; i<4 ; ++i) sum += myInt4[i];

Debug.Log(sum) // 34 출력

int2 myInt2 = myInt4.xw;

Debug.Log(myInt2) // (20, 8) 출력

또한 Unmanaged type은 말 그대로 관리되지 않습니다. 즉 GC(Garbage Collector)에게 일거리를 주지 않는다는 얘기입니다. 하지만 아예 관리되지 않는 건 아니고, int3, float2 등의 value type은 .NET 내부에서 관리해주기 때문에 해제 시점을 신경쓸 필요도 없습니다. (최적화 파트에서 사용할 NativeArray<T>와 같은 reference type은 직접 해제해줘야 합니다. 나중에 다루겠습니다.)

게다가 타이핑할 때 Shift 키를 누르지 않아도 되며 길이도 짧고 자판 배치도 편합니다.

성능상으로도, 사용성 측면에서도 꽤나 이점이 많기 때문에 저는 평소에도 Jobs, Burst, ECS에서와 같이 꼭 구조체가 필요한 경우가 아니더라도 종종 이 타입들을 이용하곤 합니다.


C. 최적화하지 않은 구현


1. 코드

아래 자세한 설명이 있지만, 사실 코드를 보는 게 가장 이해하기 쉽습니다. 코드를 보다가 이게 왜 이런 식으로 짜여졌는지 도저히 이해가 되지 않을 때 아래 2.설명을 보시면 될 것 같습니다.

using System.Collections.Generic;
using Unity.Mathematics;
using UnityEngine;

public class VoxelMeshGenerator : MonoBehaviour
{
    public MeshFilter meshFilter;
    public MeshCollider meshCollider;

#region VARIABLES
    byte[,,] voxels;
    public int3 dimensions = new int3(10, 10, 10);
    public int textureCount = 4;
#endregion

#region CONSTANTS
    int3[] deltaPositions =
    {
        new int3(0, 1, 0),      // up
        new int3(0, -1, 0),     // down
        new int3(0, 0, 1),      // front
        new int3(0, 0, -1),     // back
        new int3(1, 0, 0),      // right
        new int3(-1, 0, 0),     // left
    };

    // 정육면체의 8개 꼭짓점의 상대좌표
    float3[] verticePositions =
    {
        new float3(-0.5f, 0.5f, -0.5f), new float3(-0.5f, 0.5f, 0.5f),      // 0, 1
        new float3(0.5f, 0.5f, 0.5f), new float3(0.5f, 0.5f, -0.5f),        // 2, 3
        new float3(-0.5f, -0.5f, -0.5f), new float3(-0.5f, -0.5f, 0.5f),    // 4, 5
        new float3(0.5f, -0.5f, 0.5f), new float3(0.5f, -0.5f, -0.5f),      // 6, 7
    };

    // 네모 하나가 그 방향에 따라 가지는 vertice들의, verticePositions에서의 인덱스
    int4[] faceVertices =
    {
        new int4(1, 2, 3, 0),   // up
        new int4(6, 5, 4, 7),   // down
        new int4(2, 1, 5, 6),   // front
        new int4(0, 3, 7, 4),   // back
        new int4(3, 2, 6, 7),   // right
        new int4(1, 0, 4, 5)    // left
    };
    
    // 삼
    int[] triangleVertices = { 0, 1, 2, 0, 2, 3 };

    int2[] dUV =
    {
        new int2(0, 1), // LT
        new int2(1, 1), // RT
        new int2(1, 0), // RB
        new int2(0, 0)  // LB
    };
#endregion

#region OUTPUTS
    List<float3> vertices = new List<float3>();
    List<int> triangles = new List<int>();
    List<float2> uvs = new List<float2>();
#endregion

#region TEST

    void MakeRandomVoxelsForTest()
    {
        voxels = new byte[dimensions.x, dimensions.y, dimensions.z];

        for (int x = 0; x < dimensions.x; x++)
        for (int y = 0; y < dimensions.y; y++)
        for (int z = 0; z < dimensions.z; z++)
        {
            voxels[x, y, z] = (byte)UnityEngine.Random.Range(0, textureCount);
        }
    }

    void Update()
    {
        MakeRandomVoxelsForTest();
        Generate();
    }
#endregion

    void Awake()
    {
        Mesh newMesh = new Mesh();
        newMesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
        meshFilter.sharedMesh = newMesh;
        meshCollider.sharedMesh = newMesh;
    }

    void Generate()
    {
        int3 dim = dimensions;

        for(int x=0 ; x<dim.x ; ++x)
        for(int y=0 ; y<dim.y ; ++y)
        for(int z=0 ; z<dim.z ; ++z)
        {
            if (voxels[x, y, z] == 0) continue;
        
            for (int dir = 0; dir < 6; dir++)
            {
                int3 p = new int3(x, y, z);
                int3 np = p + deltaPositions[dir];
                
                if(np.x < 0 || np.x >= dim.x
                || np.y < 0 || np.y >= dim.y
                || np.z < 0 || np.z >= dim.z
                || voxels[np.x, np.y, np.z] == 0)
                {
                    AddFace(p, dir);
                    continue;
                }
            }
        }

        meshFilter.sharedMesh.Clear();

        meshFilter.sharedMesh.SetVertices(vertices.ConvertAll(v => (Vector3)v));
        meshFilter.sharedMesh.SetTriangles(triangles, 0);
        meshFilter.sharedMesh.SetUVs(0, uvs.ConvertAll(v => new Vector2(v.x, v.y)));
        
        meshFilter.sharedMesh.RecalculateBounds();
        meshFilter.sharedMesh.RecalculateNormals();
        meshFilter.sharedMesh.RecalculateTangents();
        
        vertices.Clear();
        triangles.Clear();
        uvs.Clear();
    }

    void AddFace(int3 p, int dir)
    {
        int vc = vertices.Count;
        byte vi = voxels[p.x, p.y, p.z];

        // Vertices
        for(int i=0 ; i<4 ; ++i)
        {
            float3 dp = verticePositions[faceVertices[dir][i]];
            vertices.Add(p + dp);
        }

        // Triangles
        for(int i=0 ; i<6 ; ++i)
        {
            triangles.Add(vc + triangleVertices[i]);
        }

        // UVs
        for(int i=0 ; i<4 ; ++i)
        {
            float2 d = dUV[i];
            uvs.Add(new float2(
                (d.x + dir) / 6,
                (vi - 1 + d.y) / textureCount
            ));
        }
    }
}

2. 설명

(a) VARIABLES

  • voxels: 어느 위치에 현재 어느 블록이 있는지를 나타내는 3차원 배열
  • dimensions: 맵의 크기
  • textureCount: 텍스처 종류의 개수. 텍스처 종류의 개수는 블록 종류의 개수와 같습니다.


(b) CONSTANTS

- deltaPositions
여섯 방향을 탐색하기 위해서 존재하는 배열입니다. up-down-front-back-right-left 순서를 지켜주세요.

new int3(0, 1, 0),      // up
new int3(0, -1, 0),     // down
new int3(0, 0, 1),      // front
new int3(0, 0, -1),     // back
new int3(1, 0, 0),      // right
new int3(-1, 0, 0)      // left

- verticePositions
임의의 voxel의 중심을 (0, 0, 0)이라고 설정했을 때 정육면체의 8개 꼭짓점의 상대좌표입니다. voxel 하나의 크기를 1 x 1 x 1 으로 정의했기 때문에 모든 꼭짓점 성분의 절댓값이 0.5입니다.

new float3(-0.5f, 0.5f, -0.5f),     // 0
new float3(-0.5f, 0.5f, 0.5f),      // 1
new float3(0.5f, 0.5f, 0.5f),       // 2
new float3(0.5f, 0.5f, -0.5f),      // 3
new float3(-0.5f, -0.5f, -0.5f),    // 4
new float3(-0.5f, -0.5f, 0.5f),     // 5
new float3(0.5f, -0.5f, 0.5f),      // 6
new float3(0.5f, -0.5f, -0.5f),     // 7

(x, y, z)에 있는 voxel 정육면체의 8개 꼭짓점은 변수 i(0 ≤ i < 8)에 대하여 각각 (x, y, z) + verticePositions[i] 입니다.

deltaPositions가 수직으로 6개 방향을 나타낸다면, verticePositions는 대각선으로 8개 방향을 나타내는 셈입니다. 아래 faceVertices와 함께 사용됩니다.

- faceVertices
네모 하나가 그 방향에 따라 가지는 vertice들의, verticePositions에서의 인덱스입니다.

new int4(1, 2, 3, 0),   // up
new int4(6, 5, 4, 7),   // down
new int4(2, 1, 5, 6),   // front
new int4(0, 3, 7, 4),   // back
new int4(3, 2, 6, 7),   // right
new int4(1, 0, 4, 5)    // left

정육면체의 각 면을 정면으로 바라볼 때, 아래와 같은 그림들이 나오는데요. 여기서 정면이라 함은 y가 큰 것이 위로 가도록, 만약 법선이 y축에 평행하다면(: 윗면, 아랫면) z가 큰 것이 위로 가게끔 두고 보면 됩니다. 위에 triangles에서와 같이, 왼손법칙(?)이 적용되도록 해야 합니다. up-down-front-back-right-left 순서로 면을 바라봤을 때 아래 그림과 같습니다. 왼쪽 위 꼭짓점부터 시계방향으로 돌아 그 vertice의 인덱스가 나옵니다.

만약 어떤 면을 그릴 때 그 방향이 뒤(back)쪽이라면, 정육면체에 위치하는 8개 꼭짓점 중에서 [0], [3], [7], [4] 순서로 사용하게 될 것입니다. 이러한 값을 i(0 ≤ i < 6)에 대해 일반화하기 위해 사용되는 배열입니다.

- triangleVertices
하나의 네모가 가지는 삼각형은 두 개라는 것을 위에서 설명 드렸죠. 이 배열은 이들의 상대적인 인덱스를 나타내는 값입니다.

0, 1, 2, 0, 2, 3

현재 n개의 네모가 그려져있다고 할 때(총 4n개의 vertices) 그 다음 네모를 그리려면 4개의 vertice들이 추가될 것이고, 그 추가된 것들 중에서 첫번째, 두번째, 세번째, 즉 [4n+0], [4n+1], [4n+2]로 삼각형 하나, [4n+0], [4n+2], [4n+3]으로 삼각형 하나를 만듭니다. 그래서 이 배열 {0, 1, 2, 0, 2, 3}은 네모 하나를 그리는 과정에서 vertice가 추가된 이후 바로 triangles를 추가할 텐데, 이 때 추가할 triangle 값을 i(0 ≤ i < 6)에 대해 일반화하기 위한 배열입니다.

- dUV
이 배열은 UV좌표상에서 텍스처 하나의 꼭짓점들의 상대좌표, 즉 verticePositions와 비슷한 역할을 합니다.

new int2(0, 1),// LT
new int2(1, 1),// RT
new int2(1, 0),// RB
new int2(0, 0)// LB

그림은 위에서도 나온 Atlas 텍스처입니다. 각 칸은 정사각형이라서 x축 방향은 위, 아래, 앞, 뒤, 오른쪽, 왼쪽 총 여섯 가지 있어서 1/6 단위로 변할 것이고, y축 방향으로는 블록의 종류만큼 있어서 1/textureCount 단위로 변할 것입니다. front 방향 네모에 [1]F를 그려주고 싶다고 합시다.

이 네모는 2, 1, 6, 5 순서로 vertices가 추가될 것이고, 그 순서를 맞춰 uv를 할당해줍니다. 여기서는 왼쪽 위부터 시계방향으로 추가해야 하므로 (2/6, 1/4), (3/6, 1/4), (3/6, 2/4), (2/6, 2/4)를 추가합니다. 이러한 값을 i(0 ≤ i < 4)에 대해 일반화하기 위해 사용되는 배열입니다.

UV 좌표상에서 왼쪽 위는 상대적으로 (0, 1), 오른쪽 위는 (1, 1), 오른쪽 아래는 (1, 0), 왼쪽 아래는 (0, 0)으로 설정하고 이들을 방향값 dir(0 ≤ dir < 6)와 텍스처의 개수(textureCount)로 적절히 나누어서 for문 내에서 uv 좌표를 일반화하는 데 사용합니다.

그래서 vi = voxel[x,y,z]에 대해 추가할 uv의 x좌표는 (dUV[i].x + {방향값 dir}) / 6y좌표는 **(dUV[i].y + {vi - 1}) / textureCount)**입니다. vi에서 1을 빼주는 이유는 voxel[x,y,z]가 0이면 블록이 없는 것이고 1부터 블록으로 인식하기 때문에, 이를 0부터 시작하는 uv y좌표에 대응시켜주기 위함입니다.

(c) TEST

Voxel에 더미 데이터를 랜덤으로 생성해서 집어넣고, 성능에 얼마나 영향을 미치는지 확인하기 위해 매 프레임마다 Generation을 호출합니다. 이 부분은 최적화 파트에서도 똑같이 사용합니다.

(d) Awake

- new Mesh()와 메모리 누수
로컬 변수 newMesh를 선언했는데요, Awake에서 new Mesh() 인스턴스를 생성해서 할당해주고, meshFilter, meshCollider의 sharedMesh를 이것으로 설정합니다.

제가 참고한 영상에서는 Generate할 때마다 new Mesh() { ~~ } 로 새로운 Mesh 객체를 생성하고 meshFilter.sharedMesh에 할당해주는데, 이렇게 하면 Generate 메서드가 맨 처음 단 한번만 호출되지 않는 이상, 반드시 메모리 누수가 발생합니다. 성능 프로파일링을 하지 않았다면 저도 모르고 지나갔을 겁니다.

문제가 왜 발생하는지 알아보려 Mesh 클래스 내부를 확인해보니 Job System 활용을 위한 구조체를 발견할 수 있었습니다. 즉, Unmanaged Type이면서 Reference Type인 구조체(높은 확률로 NativeArray<T>)를 사용했을 것이고, 원칙적으로 Mesh를 에셋 시스템에서 불러와 사용하기 때문에 Mesh 자체가 Load/Unload 되는 시점에만 이러한 인스턴스들을 Dispose할 것입니다. 그래서 Mesh라는 클래스 객체 자체는 GC에 의해 수집되었을지언정 그 내부에서 사용했던 unmanaged 인스턴스들이 Dispose되지 못해 메모리에 남아있고, 내부적으로 그에 대한 error/warning 은 무시한 것으로 보입니다.

- Mesh의 IndexFormat
기본적으로 하나의 Mesh에 총 65,536개까지의 vertice가 들어갈 수 있는데, 코드에서 보시다시피 IndexFormat을 32비트로 설정하면 그 제곱만큼, 즉 4,294,967,296개까지 넣어줄 수 있습니다. 물론 이걸 다 채울 수는 없을 것 같지만, 6만개는 너무 적어서 설정해줍니다. 이는 플랫폼에 따라 지원하지 않을 수도 있습니다.

 

(e) Generation

  • dimension 내에 있는 모든 x, y, z좌표(음이 아닌 정수)를 for 문으로 돌립니다.
  • 각 칸에 대해 블록이 있는지 검사하고, 없으면 넘어갑니다.
  • 블록이 있으면 6방향에 대해 for문을 돌립니다. 그 방향에 블록이 없거나, dimension 범위를 벗어나는 방향이라면 그쪽에 면을 그리고, 그게 아니라면 넘어갑니다.
  • 면을 그릴 때는 vertice 4개 추가 → triangle 6개 추가(정확히 말하면 삼각형 두개 추가) → uv 4개 추가하는 순으로 했습니다. 이 코드에서 삼각형은 반드시 정점이 추가된 이후에 추가되어야 합니다. (최적화 부분에서는 순서를 지킬 필요 없습니다.)
  • AddFace 메서드는 위 VARIABLES 의 개념들을 통해 이해하실 수 있을 겁니다.
  • 로컬변수 newMesh를 Clear해준 뒤, Set~, Recalculate~ 메서드들을 호출해줍니다.
    • 이 때 meshFilter 및 meshCollider에는 Awake에서 이미 sharedMesh를 newMesh로 참조해주었기 때문에 다시 newMesh로 할당해줄 필요는 없-다고 생각했는데요, 확실히 meshFilter는 Update에서 처리되는지 다시 할당할 필요는 없습니다. 그와 다르게 meshCollider는 sharedMesh 프로퍼티의 setter가 호출되어야만 충돌체를 다시 계산하는 것 같습니다. 그래서 meshCollider.sharedMesh = newMesh를 다시 호출해줍니다.
  • 마지막으로, 사용했던 OUTPUTS의 List들을 Clear해줍니다.

3. 적용

(1) Components 아래와 같이 Mesh Filter, Mesh Renderer, Voxel Mesh Generation 컴포넌트가 모두 있어야 하고, 필요에 따라 Mesh Collider도 추가합니다. 여기 Mesh 부분에는 아무거나 넣어도 됩니다. 어차피 코드의 Awake에서 new Mesh로 대체되니까요.

 

(2) Material 할당 Material 파일을 하나 새로 만들어주시고 메인 텍스처는 위에서 계속 언급된, Atlas 형태의 텍스처를 사용합니다. 텍스처를 입힌 Material을 Mesh Renderer > Materials에 할당해줍니다.


D. 최적화


1. Job 사용법

에 대해서는 다른 글에 간단히 정리했습니다. 여기서는 IJob 인터페이스만 사용할 예정입니다. 해당 글에서도 설명되어있지만, IJobParallelFor 인터페이스를 사용하려면 어떤 인덱스 i에 대해 작업을 수행중일 때에는 그 i 외의 인덱스로는 접근할 수 없기 때문입니다. 위에서 보셨다시피 면을 하나 만들 때 4개의 정점, 6개의 삼각형, 4개의 uv를 추가하므로 여러 인덱스에 접근해야 하죠.


2. 필요한 작업

(a) 모든 컬렉션 타입을 NativeArray<T>로 변환.
위에서 언급했듯이 Managed Type은 사용할 수 없기 때문에 List나 일반 배열은 사용할 수 없습니다. 그래서 배열들은 Unity.Collections namespace에 있는 NativeCollection<T>으로 전부 바꿔줘야 합니다. 옛날엔 NativeList<T>가 있었는데, 2022.3.10f1 기준으로는 없어서, Add 메서드를 사용할 수 없고 배열의 크기를 미리 계산하고 인덱스를 캐싱하면서 접근해야 합니다.

int[] arr = new int[100] { ... };

// 기존 List Add 방식
List<int> list = new List<int>();
for(int i=0 ; i<arr.Length ; ++i)
{
	list.Add(arr[i]);
	list.Add(arr[i] + 1);
	list.Add(arr[i] + 2);
	list.Add(arr[i] + 3);
}

// 인덱스 캐싱 방식
NativeArray<int> nList = new NativeArray<int>(arr.Length, Allocator.Persistent);
int indexCache = 0;
for(int i=0 ; i<arr.Length ; ++i)
{
	nList[indexCache] = arr[i];
	nList[indexCache + 1] = arr[i] + 1;
	nList[indexCache + 2] = arr[i] + 2;
	nList[indexCache + 3] = arr[i] + 3;

	indexCache += 4;
}

 

(b) 렌더링할 면의 개수 계산 (Precalculate)
방금 말씀드렸다시피 배열의 크기를 미리 계산해야 합니다. 면이 하나 그려질 때마다 4개, 6개, 4개의 정점, 삼각형, uv가 추가된다고 했죠. 면의 개수를 n이라 하면 vertices, triangles, uvs의 크기는 각각 4n, 6n, 4n인 것인데, 이 크기를 구하려면 이 면의 개수 n을 먼저 구해야겠죠.

사실 Generate 메서드와 다를 것이 없습니다. 그저 AddFace(~)메서드 대신 n++; 만 추가해주면 됩니다. 이를 Generate가 실행되기 전, NativeArray<T>들을 정해진 크기로 먼저 초기화해주기 위해 Precalculate를 호출해줍니다.

int Precalculate()
{
		int n = 0;
		int3 dim = dimensions; // 길어서 줄이려고 만든 변수
		
		for(int x=0 ; x<dim.x ; ++x)
		for(int y=0 ; y<dim.y ; ++y)
		for(int z=0 ; z<dim.z ; ++z)
		{
		    int pos1D = x * dim.y * dim.z + y * dim.z + z;
		    if (voxels[pos1D] == 0) continue;
		
		    for (int dir = 0; dir < 6; dir++)
		    {
		        int3 np = new int3(x, y, z) + deltaPositions[dir];
		
		        if(np.x < 0 || np.x >= dim.x
		        || np.y < 0 || np.y >= dim.y
		        || np.z < 0 || np.z >= dim.z
		        || voxels[x, y, z] == 0)
		        {
		            n++;
		            continue;
		        }
		    }
		}

		return n;
}

 

(c) 1차원으로 변환
모든 List나 배열들을 NativeArray로 바꾼다면, 3차원 배열인 voxels는 어떻게 해야 할까요? 주먹구구식으로 생각하면 NativeArray<NativeArray<NativeArray<T>>> 와 같이 삼중으로 배열을 만들 수 있을 것 같습니다. 다행히도(?) 이렇게 하면 에러가 발생합니다.

하이퍼토마토 글에도 언급되었지만, 크기가 고정된 모든 n차원 배열은 반드시 1차원 배열으로 변환이 가능합니다.

3차원 배열인 voxels의 x, y, z축 크기가 각각 X, Y, Z 라고 하면 1차원 배열로 변환했을 때 그 크기는 X * Y * Z 이며, 임의의 점 (x, y, z)는 1차원 배열에서의 인덱스(position1D)로 변환하면 다음과 같습니다.

int position1D = (x * Y * Z) + (y * Z) + z;

position1D를 다시 3차원 배열에서의 인덱스, 편하게 int3 형태로 변환하면 다음과 같습니다.

int3 position3D = new int3
(
	position1D / (Y * Z),
	position1D / Z % Y,
	position1D % Z
);

 

(d) Job 내부 필드 재사용을 위한 구조체
Precalculate, Generate가 모두 최소 X * Y * Z 번의 반복을 실행하기 때문에 Job으로 만들어줘야 합니다. 그런데 구조가 거의 똑같다고 봐도 무방하지만, 안타깝게도 IJob은 클래스가 아닌 구조체이기 때문에, 게다가 delegate 형태의 작업도 할 수 없기 때문에 아예 따로 따로 만들어줘야 합니다. Job을 하나 만들 때에는 내부에서 필요한 모든 필드를 외부에서 초기화해줘야 하는데, 위 CONSTANTS, VARIABLES, OUTPUTS에 있는 11개의 필드들을 Job 하나마다 하나하나 집어넣기는 작업이 좀 너무한 감이 있습니다. 그래서 다른건 몰라도 이 필드들만이라도 좀 편하게 다루기 위해 구조체를 새로 만들어줍시다. 이 친구들은 아예 새로운 스크립트 파일을 생성해서 넣어주고, VoxelStructures.cs로 이름 지어줍시다.

[BurstCompile]
public struct VoxelVariables
{
    public int3 dimensions;
    public NativeArray<byte> voxels;
    public int textureCount;

    [BurstCompile]
    public void Dispose()
    {
        voxels.Dispose();
    }
}

[BurstCompile]
public struct VoxelConstants
{
    public NativeArray<int3> deltaPositions;
    public NativeArray<float3> verticePositions;
    public NativeArray<int> triangleVertices;
    public NativeArray<int4> faceVertices;
    public NativeArray<float2> dUV;

    public void Dispose()
    {
        verticePositions.Dispose();
        triangleVertices.Dispose();
        dUV.Dispose();
        deltaPositions.Dispose();
        faceVertices.Dispose();
    }
}

[BurstCompile]
public struct VoxelOutputs
{
    public NativeArray<float3> vertices;
    public NativeArray<int> triangles;
    public NativeArray<float2> uvs;

    [BurstCompile]
    public void Dispose()
    {
        vertices.Dispose();
        triangles.Dispose();
        uvs.Dispose();
    }
}

각 구조체에 있는 reference type인 NativeArray<T>는 반드시 직접 Dispose해줘야 하기 때문에, 외부에서 쉽게 Dispose할 수 있도록 메서드를 만들어줬습니다.

(e) Precalculate, Generate를 Job으로 변환
이 부분은 아래 코드에서 확인하세요!


3. 코드

최적화 부분에서는 코드가 총 세 개 있습니다. 위에서 만든 VoxelStructures.cs와, 변환된 Job을 선언하고 구현한 VoxelJobs.cs, Job의 인스턴스를 생성하고 스레드에 할당해줄 Mono 클래스 VoxelMeshGeneratorOpt.cs 입니다. (맨 위 최적화하지 않은 코드와 구분하기 위해 Opt라는 단어를 추가했습니다.)

(1) VoxelJobs.cs

using Unity.Mathematics;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;

[BurstCompile]
public struct MeshGenerationJob : IJob
{
    [ReadOnly] public VoxelVariables variables;
    [ReadOnly] public VoxelConstants constants;
    public VoxelOutputs outputs;

    public int vCount;

    [BurstCompile]
    public void Execute()
    {
        int3 dim = variables.dimensions;
        int size1D = dim.x * dim.y * dim.z;

        for(int i=0 ; i<size1D ; ++i)
        {
            int z = i % dim.z;
            int y = i / dim.z % dim.y;
            int x = i / (dim.y * dim.z);

            if (variables.voxels[i] == 0) continue;
        
            for (int dir = 0; dir < 6; dir++)
            {
                int3 p = new int3(x, y, z);
                int3 np = p + constants.deltaPositions[dir];
                int ni = np.x * dim.y * dim.z + np.y * dim.z + np.z;
                
                if(np.x < 0 || np.x >= dim.x
                || np.y < 0 || np.y >= dim.y
                || np.z < 0 || np.z >= dim.z
                || variables.voxels[ni] == 0)
                {
                    AddFace(dir, p, i);
                    continue;
                }
            }
        }
    }

    [BurstCompile]
    public void AddFace(int dir, float3 pos3D, int pos1D)
    {
        byte voxelIndex = variables.voxels[pos1D];

        if(voxelIndex == 0) return;

        // Add Vertices
        for(int i=0 ; i<4 ; ++i)
        {
            outputs.vertices[vCount + i] = pos3D + constants.verticePositions[constants.faceVertices[dir][i]];
        }
        
        // Add Triangles
        for(int i=0 ; i<6 ; ++i)
        {
            int tCount = vCount * 3 / 2;
            outputs.triangles[tCount + i] = vCount + constants.triangleVertices[i];
        }

        // Add UVs
        for(int i=0 ; i<4 ; ++i)
        {
            float2 d = constants.dUV[i];
            int tc = variables.textureCount;

            outputs.uvs[vCount + i] = new float2
            (
                (d.x + dir) / 6,
                (voxelIndex - 1 + d.y) / tc
            );
        }

        vCount += 4;
    }

    [BurstCompile]
    public void Dispose()
    {
        outputs.Dispose();
    }
}

[BurstCompile]
public struct PrecalculationJob : IJob
{
    [ReadOnly] public VoxelVariables variables;
    [ReadOnly] public VoxelConstants constants;
    public NativeArray<int> result;

    [BurstCompile]
    public void Execute()
    {
        int3 dim = variables.dimensions;

        for(int x=0 ; x<dim.x ; ++x)
        for(int y=0 ; y<dim.y ; ++y)
        for(int z=0 ; z<dim.z ; ++z)
        {
            int pos1D = x * dim.y * dim.z + y * dim.z + z;
            if (variables.voxels[pos1D] == 0) continue;
        
            for (int dir = 0; dir < 6; dir++)
            {
                int3 np = new int3(x, y, z) + constants.deltaPositions[dir];

                int ni = np.x * dim.y * dim.z + np.y * dim.z + np.z;
                
                if(np.x < 0 || np.x >= dim.x
                || np.y < 0 || np.y >= dim.y
                || np.z < 0 || np.z >= dim.z
                || variables.voxels[ni] == 0)
                {
                    result[0]++;
                    continue;
                }
            }
        }
    }

    [BurstCompile]
    public void Dispose()
    {
        result.Dispose();
    }
}

Precalculate에서는 result라는, 크기가 1인 NativeArray<int>를 사용해주었는데, 저게 만약 그냥 int라면, Job 내부에서 아무리 지지고 볶아도 외부에서 접근하면 기본값인 0으로 보입니다. 그래서 지겹도록 언급되었던, reference type인 NativeArray<int>는 외부에서 접근할 수 있기 때문에 사용한 것입니다.

(2) VoxelMeshGeneratorOpt.cs
최적화 하지 않은 구현에서의 코드와 다를 게 없습니다. Precalculate가 추가되고 Job에 맞게 일부 변형된 것 뿐입니다.  제대로 된 성능 비교를 위해 TEST 부분은 Job으로 구현하지 않았습니다.

using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;

public class VoxelMeshGenerationOptimized : MonoBehaviour
{
    public MeshFilter meshFilter;
    public MeshCollider meshCollider;

#region VARIABLES
    NativeArray<byte> voxels;
    public int3 dimensions = new int3(10, 10, 10);
    public int textureCount = 4;
#endregion

    VoxelVariables variables;
    VoxelConstants constants;

    void Awake()
    {
        Mesh newMesh = new Mesh();
        newMesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
        meshFilter.sharedMesh = newMesh;
        meshCollider.sharedMesh = newMesh;
    }

    void OnEnable()
    {
        Init();
    }
    void OnDisable()
    {
        Dispose();
    }

    void Init()
    {
        constants = new VoxelConstants()
        {
            deltaPositions = new NativeArray<int3>
            (
                new int3[]
                {
                    new int3(0, 1, 0),      // up
                    new int3(0, -1, 0),     // down
                    new int3(0, 0, 1),      // front
                    new int3(0, 0, -1),     // back
                    new int3(1, 0, 0),      // right
                    new int3(-1, 0, 0)      // left
                }
                , Allocator.Persistent
            ),
            verticePositions = new NativeArray<float3>
            (
                new float3[]
                {
                    new float3(-0.5f, 0.5f, -0.5f),     // 0
                    new float3(-0.5f, 0.5f, 0.5f),      // 1
                    new float3(0.5f, 0.5f, 0.5f),       // 2
                    new float3(0.5f, 0.5f, -0.5f),      // 3
                    new float3(-0.5f, -0.5f, -0.5f),    // 4
                    new float3(-0.5f, -0.5f, 0.5f),     // 5
                    new float3(0.5f, -0.5f, 0.5f),      // 6
                    new float3(0.5f, -0.5f, -0.5f),     // 7
                }
                , Allocator.Persistent
            ),
            triangleVertices = new NativeArray<int>
            (
                new int[] { 0, 1, 2, 0, 2, 3 }
                , Allocator.Persistent
            ),
            faceVertices = new NativeArray<int4>
            (
                new int4[]
                {
                    new int4(1, 2, 3, 0),   // up
                    new int4(6, 5, 4, 7),   // down
                    new int4(2, 1, 5, 6),   // front
                    new int4(0, 3, 7, 4),   // back
                    new int4(3, 2, 6, 7),   // right
                    new int4(1, 0, 4, 5)    // left
                }
                , Allocator.Persistent
            ),
            dUV = new NativeArray<float2>
            (
                new float2[]
                {
                    new float2(0, 1), // LT
                    new float2(1, 1), // RT
                    new float2(1, 0), // RB
                    new float2(0, 0)  // LB
                }
                , Allocator.Persistent
            ),
        };
    }

    void Dispose()
    {
        voxels.Dispose();
        variables.Dispose();
        constants.Dispose();
    }

#region TEST
    void MakeRandomVoxelsForTest()
    {
        int size1D = dimensions.x * dimensions.y * dimensions.z;
        if(voxels.IsCreated) voxels.Dispose();
        voxels = new NativeArray<byte>(size1D, Allocator.Persistent);
        for (int i = 0; i < size1D; i++)
        {
            voxels[i] = (byte)UnityEngine.Random.Range(0, textureCount);
        }
    }
    void Update()
    {
        MakeRandomVoxelsForTest();
        Generate();
    }
#endregion

    int Precalculate()
    {
        PrecalculationJob job = new PrecalculationJob()
        {
            variables = variables,
            constants = constants,
            result = new NativeArray<int>(1, Allocator.TempJob),
        };

        JobHandle handle = job.Schedule();
        handle.Complete();

        int res = job.result[0];

        job.Dispose();

        return res;
    }

    void Generate()
    {
        variables = new VoxelVariables()
        {
            voxels = new NativeArray<byte>(voxels, Allocator.TempJob),
            dimensions = dimensions,
            textureCount = textureCount,
        };

        int renderFaceCount = Precalculate();

        var outputs = new VoxelOutputs()
        {
            vertices = new NativeArray<float3>(renderFaceCount * 4, Allocator.TempJob),
            triangles = new NativeArray<int>(renderFaceCount * 6, Allocator.TempJob),
            uvs = new NativeArray<float2>(renderFaceCount * 4, Allocator.TempJob),
        };

        MeshGenerationJob job = new MeshGenerationJob()
        {
            variables = variables,
            constants = constants,
            outputs = outputs,
            vCount = 0,
        };

        JobHandle handle = job.Schedule();
        handle.Complete();

        meshFilter.sharedMesh.Clear();

        meshFilter.sharedMesh.SetVertices(outputs.vertices);
        meshFilter.sharedMesh.SetTriangles(outputs.triangles.ToArray(), 0);
        meshFilter.sharedMesh.SetUVs(0, outputs.uvs);
        
        meshFilter.sharedMesh.RecalculateBounds();
        meshFilter.sharedMesh.RecalculateNormals();
        meshFilter.sharedMesh.RecalculateTangents();
        
        job.Dispose();
    }
}

4. Precalculate 최적화

 

이 글의 코드에서는 다루지 않았습니다만, 저는 맵에디터 기능이라서, Precalculate를 OnEnable에서만 실행해주고, 블록이 생기고 없어질 때에 그 지점만 확인해서 renderFaceCount를 수정하도록 하여 Generate할 때마다 X * Y * Z * 6번의 반복 대신 6번의 반복만 하도록 줄일 수 있었습니다. 임의의 좌표에 블록을 하나 추가하면 일단 무조건 6개의 면이 추가되었다고 처리한 뒤, 6방향을 검사하여 {인접한 블록의 수 * 2} 만큼 다시 면을 빼주면 됩니다. 이 작업도 job으로 처리할 수 있겠죠. (아래 성능 테스트에는 반영되지 않았습니다.)


E. 성능 비교

40 x 40 x 40 기준으로 테스트한 결과, 최적화 없이 구현된 코드로는 약 43ms, 최적화한 경우엔 약 22ms의 속도가 나왔습니다. 2배 가까이 빨라진 셈이죠. 거기에 Memory쪽 그래프를 보시면 GC 호출도 최적화한 경우에 훨씬 적게 일어납니다.

오른쪽이 최적화된 모습

 

.여담

이 글에서 다룬 최적화는, 어떻게 보면 Job System의 진짜 기능(?)을 사용하지 않았다고 볼 수 있습니다. Generation 작업을 싸그리 하나의 Job으로 만들었기 때문에 멀티스레딩이 아닌 것이죠. 그래서 그냥 기계 친화적인 코드로 변환만 했다고 봐도 무방합니다.

그래서 Chunk 기능을 도입하면 여러 개의 GenerationJob을 JobHandle.CombineDependencies 메서드를 통해 병렬실행하면 멀티스레딩의 덕을 크게 볼 수 있습니다. 저는 이정도만 해도 충분히 만족스러워서 구현하지는 않았습니다. 귀찮기도 하구요 ㅎㅎ

댓글