내일배움캠프 Unity 62일차 TIL - 팀 9와 4분의 3 - 월드 생성 최적화

Wooooo·2024년 1월 23일
0

내일배움캠프Unity

목록 보기
64/94

[오늘의 키워드]

월드 생성 로직에 불필요한 연산이 많아서, 불필요한 연산을 줄였다.


[트러블 슈팅]

Chunk

기존 로직의 문제점

월드를 생성하면, 월드는 청크들을 미리 생성한 후 비활성화 해둔다.

기존 청크의 생성 로직에는 큰 문제점이 있었다.

  1. 현재 청크의 범위 내에 들어오는 모든 좌표에 블럭이 있는지 검사한다.
    청크의 범위 안에는 대략 8120개의 블럭이 들어갈 수 있다.
  2. 검사 시, World의 모든 블럭이 들어있는 Dictionary를 이용한다.
    이 Dictionary에는 약 60만개의 블럭 데이터가 들어있다.

1번과 2번의 환상의 궁합으로 엄청난 오버헤드를 일으키고 있었다.

아무리 검색 속도가 O(1)인 딕셔너리라도 데이터가 60만개 들어있는 딕셔너리를 대략 8천번씩 TryGetValue를 호출하니깐 너무너무 느렸다.

해결 방안

청크는 자신이 생성해야할 블럭들을 미리 알고 있어야 한다.
우선 기존의 3중 for문을 먼저 지우고, 자신이 생성해야할 블럭 데이터들을 저장할 리스트를 만들어줬다.

그리고, 생성자가 호출되면 바로 블럭 데이터들을 읽어서 메시를 생성하는 구조에서
생성자가 호출되면, 필드의 초기화만 이뤄지도록 한 다음 블럭 데이터를 읽어서 Mesh를 만드는 작업은 다른 시점에 호출해줄 수 있게 메서드로 분리해줬다.

기존 코드

public class Chunk
{
    public Chunk(ChunkCoord coord, World world)
    {
		...
        
        CreateMeshData();
        CreateMesh();
    }

    private void CreateMeshData()
    {
        var map = _world.VoxelMap;
        for (int y = -_data.ChunkSizeY / 2; y < _data.ChunkSizeY / 2; y++)
        {
            for (int x = -_data.ChunkSizeX / 2; x < _data.ChunkSizeX / 2; x++)
            {
                for (int z = -_data.ChunkSizeZ / 2; z < _data.ChunkSizeZ / 2; z++)
                {
                    int posX = x + _coord.x * _data.ChunkSizeX;
                    int posZ = z + _coord.z * _data.ChunkSizeZ;
                    Vector3Int pos = new(posX, y, posZ);
                    if (map.TryGetValue(pos, out var value))
                        value.type.AddVoxelDataToChunk(this, pos, value.forward);
                }
            }
        }
    }
}

변경 코드

public class Chunk
{

    private List<WorldMapData> _localMap = new();

    public Chunk(ChunkCoord coord, World world)
    {
		...
    }
	
    public void AddVoxel(WorldMapData data)
    {
        _localMap.Add(data);
    }

    public void GenerateChunk()
    {
        CreateMeshData();
        CreateMesh();
    }

    private void CreateMeshData()
    {
        foreach(var e in _localMap)
            e.type.AddVoxelDataToChunk(this, e.position, e.forward);
    }
}

World

기존 로직의 문제점

기존 코드에서 World는 맵 데이터에 들어 있는 모든 블럭들 중에서 X, Z좌표의 최대/최소값으로 큰 사각형을 만든 다음, BFS를 이용해서 사각형 내부의 모든 청크를 생성하도록 했다.
이 방법의 문제점은, 실제로는 블럭이 하나도 들어있지 않은 외곽쪽의 청크도 생성된다는 것이다.

해결 방안

월드는 자신이 생성해야할 청크들을 미리 알고 있어야한다.

가장 먼저, 월드 좌표를 청크 좌표로 변경해주는 메서드를 만들었다.
이 메서드로 알아낸 청크 좌표를 이용해서 MapData.json을 읽어오는 동안 데이터에 들어있는 블럭들의 좌표로 생성해야할 청크의 좌표도 미리 알아낼 수 있게 됐다.

결과적으로 다음과 같은 로직을 갖게 됐다.

  1. ReadMapData 단계에서 데이터를 읽는 동안 ChunkMap 딕셔너리에 생성해야 할 청크들을 초기화
    읽어온 블럭의 좌표를 청크 좌표로 변환하여 해당 청크의 localMap에 삽입
  2. GenerateChunk 단계에서 ChunkMap을 순회하며 청크에 Generate 명령

기존 코드

public class World : MonoBehaviour
{
    public IEnumerator ReadMapDataFile(TextAsset json)
    {
        foreach (var data in originData)
        {
            var type = WorldData.GetType(data.Value.type)[data.Value.typeIndex];
            _voxelMap.TryAdd(data.Key, (type, data.Value.forward));
        }
        yield return null;
	}

    private IEnumerator GenerateChunk(Action<float, string> progressCallback, float uiUpdateInterval)
    {
        float t = 0f;

        // BFS로 생성
        Queue<ChunkCoord> queue = new();
        queue.Enqueue(new ChunkCoord(0, 0));

        Dictionary<ChunkCoord, bool> visited = new();
        int minX = _voxelMap.Keys.Min(pos => pos.x) / VoxelData.ChunkSizeX - 1;
        int maxX = _voxelMap.Keys.Max(pos => pos.x) / VoxelData.ChunkSizeX + 1;
        int minZ = _voxelMap.Keys.Min(pos => pos.z) / VoxelData.ChunkSizeZ - 1;
        int maxZ = _voxelMap.Keys.Max(pos => pos.z) / VoxelData.ChunkSizeZ + 1;
        for (int x = minX; x <= maxX; x++)
            for (int z = minZ; z <= maxZ; z++)
                visited.TryAdd(new(x, z), false);
        visited[new(0, 0)] = true;
        int totalChunkCount = visited.Count;
        int createdChunkCount = 0;

        ChunkCoord[] dxdy = { ChunkCoord.Up, ChunkCoord.Down, ChunkCoord.Left, ChunkCoord.Right };

        while (queue.Count > 0)
        {
            createdChunkCount++;
            var current = queue.Dequeue();
            var chunk = CreateChunk(current);
            chunk.IsActive = false;

            for (int i = 0; i < dxdy.Length; i++)
            {
                ChunkCoord next = current + dxdy[i];
                if (visited.ContainsKey(next))
                {
                    if (!visited[next])
                    {
                        visited[next] = true;
                        queue.Enqueue(next);
                    }
                }
            }

            // 일정 시간마다 UI 업데이트
            if (Time.realtimeSinceStartup - t > uiUpdateInterval)
            {
                t = Time.realtimeSinceStartup;
                progressCallback?.Invoke((float)createdChunkCount / totalChunkCount, "Generate Chunks ...");
                yield return null;
            }
        }

        yield return null;
    }
}

변경 코드

public class World : MonoBehaviour
{
    public ChunkCoord ConvertChunkCoord(Vector3 pos)
    {
        ChunkCoord res = pos;
        res.x /= VoxelData.ChunkSizeX;
        res.z /= VoxelData.ChunkSizeZ;
        return res;
    }

    public ChunkCoord ConvertChunkCoord(Vector3Int pos)
    {
        ChunkCoord res = pos;
        res.x /= VoxelData.ChunkSizeX;
        res.z /= VoxelData.ChunkSizeZ;
        return res;
    }

    private void InitializeChunk(ChunkCoord pos, WorldMapData blockData)
    {
        if (!_chunkMap.ContainsKey(pos))
            _chunkMap.Add(pos, new(pos, this));
        _chunkMap[pos].AddVoxel(blockData);
    }

    public IEnumerator ReadMapDataFile(TextAsset json, Action<float, string> progressCallback, float uiUpdateInterval)
    {
        float t = 0;
        int currentCount = 0;
        var originData = json.text.DictionaryFromJson<Vector3Int, MapData>();
        _voxelMap = new(originData.Count, new Vector3IntEqualityComparer());

        foreach (var data in originData)
        {
            // originData를 World Map Data로 변경
            var worldMapData = new WorldMapData()
            {
                type = WorldData.GetType(data.Value.type)[data.Value.typeIndex],
                position = data.Key,
                forward = data.Value.forward,
            };
            _voxelMap.TryAdd(data.Key, worldMapData);

            // 현재 블럭이 속한 청크 좌표에 청크 생성
            // 생성한 청크에 블럭 정보 추가
            var coord = ConvertChunkCoord(data.Key);
            InitializeChunk(coord, worldMapData);

            currentCount++;

            // 일정 시간마다 UI 업데이트
            if (Time.realtimeSinceStartup - t > uiUpdateInterval)
            {
                t = Time.realtimeSinceStartup;
                progressCallback?.Invoke((float)currentCount / originData.Count, "Read MapData ...");
                yield return null;
            }
        }
        yield return null;    
	}

    private IEnumerator GenerateChunk(Action<float, string> progressCallback, float uiUpdateInterval)
    {
        float t = 0f;
        int totalChunkCount = _chunkMap.Count;
        int createdChunkCount = 0;

        foreach (var e in _chunkMap.Values)
        {
            e.GenerateChunk();
            e.IsActive = false;
            createdChunkCount++;

            // 일정 시간마다 UI 업데이트
            if (Time.realtimeSinceStartup - t > uiUpdateInterval)
            {
                t = Time.realtimeSinceStartup;
                progressCallback?.Invoke((float)createdChunkCount / totalChunkCount, "Generate Chunks ...");
                yield return null;
            }
        }    
    }
}

비교

기존 로딩 시간

게임매니저가 생성되고, 모든 로딩이 끝난 후 첫 Awake의 호출까지 걸린 시간은 대략 23초

개선 로딩 시간

개선 후에는 8초 정도로 줄었다 !

profile
game developer

0개의 댓글