내일배움캠프 Unity 61일차 TIL - 팀 9와 4분의 3 - 개발일지

Wooooo·2024년 1월 22일
0

내일배움캠프Unity

목록 보기
63/94

[오늘의 키워드]

로딩 씬 UI 만들기


[Loading Scene UI]

메인 씬 진입 시, 게임의 준비 과정동안 보여질 로딩 UI를 작업했다.
따로 로딩 씬을 만들지는 않고, 한 씬에서 로딩 중에는 로딩 UI가 보여지게 할 생각이다.
이유는, 현재 로딩의 대부분은 Chunk의 생성 부분인데, 이 작업은 MainScene에서 이뤄지기 떄문에 씬이 열려있어야 하기 떄문이다.

현재 게임의 준비 과정은 다음과 같다.

  1. 필요한 Resource 로딩
  2. Map Data 읽기
  3. 불러온 Map Data로 Chunk 생성
  4. 게임 초기화 (객체 생성, UI생성)

이 중 1, 2, 3번은 비동기 방식으로 callback을 호출할 수 있는 구조로 작성해뒀기 때문에, callback을 이용해서 로딩 씬 UI에 현재 상황을 반영하게 할 계획이다.


[LoadingSceneUI 클래스 작성]

public class UILoadingScene : UIScene
{
	...

    private void Update()
    {
        time += Time.deltaTime;
        if (time > 1f) time -= 1f;
        _loadingCircleImage.color = gradient.Evaluate(time);
        _loadingCircleImage.fillAmount = _curve1.Evaluate(time);
        _loadingCircleTransform.Rotate(0, 0, -820f * _curve2.Evaluate(time) * Time.deltaTime);
    }

    public override void Initialize()
    {
        base.Initialize();

        BindImage(typeof(Images));
        BindText(typeof(Texts));

        _loadingCircleImage = GetImage((int)Images.LoadingCircle);
        _loadingCircleTransform = _loadingCircleImage.transform;
        _loadingArgumentText = GetText((int)Texts.LoadingArgument);
    }

    public void ReceiveCallbacks(string argument)
    {
        _loadingArgumentText.text = argument;
    }
}

ReceiveCallbacks 메서드로, 현재 진행상황을 UI에 업데이트할 수 있도록 해줬다.
Update 메서드 내부에는 Loading Circle이 돌아가는 연출을 작성해줬다.


[Scene 초기화 순서 변경]

    private IEnumerator Start()
    {
        UILoadingScene loadingUI = null;
        // 0. 로딩씬 UI open
        Managers.Resource.LoadAsync<UnityEngine.Object>("UILoadingScene.prefab", (obj) =>
        {
            Managers.UI.ShowSceneUI<UILoadingScene>();
            Managers.UI.TryGetSceneUI(out loadingUI);
        });

        yield return new WaitWhile(() => loadingUI == null);

        // 1. 리소스 로드
        ResourceLoad((key, count, total) =>
        {
            loadingUI.ReceiveCallbacks($"Resource Loading ... ({count} / {total})");
            if (count == total)
            {
                // 2. 맵 생성
                Managers.Game.GenerateWorldAsync((progress, argument) =>
                {
                    // 맵 생성 진행 중 콜백
                    loadingUI.ReceiveCallbacks($"{argument} ({progress * 100:00}%)");
                },
                () =>
                {
                    // 맵 생성 완료 시 콜백
                    // 3. 객체 생성, 초기화
                    loadingUI.ReceiveCallbacks($"Game Initialize ...");
                    SpawnPlayer();
                    UIInitialize();
                    Managers.Game.World.InitializeWorldNavMeshBuilder(callback: op => 
                    {
                        // 4. NavMesh 생성
                        var mon = Managers.Resource.GetCache<GameObject>("Skeleton.prefab");
                        Instantiate(mon);
                        SpawnObject();
                        Managers.Game.Init();
                    });
                });
            }
        });
    }

유니티의 라이프사이클 메서드인 Start 메서드는 코루틴으로도 바꿀 수 있다.

씬에 진입하면, 먼저 로딩 씬 UI를 띄우도록 한 다음, WaitWhile문을 이용해서 로딩 씬이 열릴 때까지 대기해줬다. 왜냐하면 로딩 씬을 로드하는 것도 비동기라서,,

이후에는 각 로딩의 콜백에서 진행상황을 안내하기 위한 텍스트를 로딩 UI에 넘겨줬다.


[트러블 슈팅]

청크를 생성하는 작업은 되게 큰 작업이라 한 번에 진행하면 메인 쓰레드가 전력으로 십수초간 열일을 해서 그 동안은 게임이 멈추게 된다.

이 병목현상으로 인해 로딩UI의 애니메이션 연출도 따라서 멈추게 된다.

이에 대한 해결 방법으로 내가 생각했던 것들은 다음과 같다.

  1. 큰 작업을 일정한 작은 단위로 나누어 반복한다.
    이 경우엔 청크 생성 코루틴에서 어떤 시점에 yield return null과 함께 진행상황 콜백을 호출하도록 구현해봤다.
  2. 작업을 병렬로 처리한다.
    이 방법은 유니티의 Job System을 이용하면 될 것 같다.
  3. 로딩 UI에 애니메이션을 적용하지 않는다.
    가장 간단한 방법이다. 만약 이 방법을 쓴다면 로딩 로고가 돌아가는 연출 대신 프로그레스 바를 채택할 것 같다.

일정 시간마다 yield return

    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;
    }

일정 시간마다 yield return과 함께 진행상황 콜백을 호출하는 구조를 작성했다.
0.035초 쯤으로 하니까 22~28프레임 쯤 나와서 좀 자연스럽게 보이는 것 같다.

대신, 이 방법의 문제는 프레임이 늘어난 만큼 메인쓰레드가 놀게되고 결국 로딩 시간이 길어진다.

가장 좋은 방법은 Job System을 이용해서 병렬로 처리하는 방법일 것 같은데, 추후 공부해서 적용할 기회가 생긴다면 적용할 수 있도록 해야겠다.


[구현 모습]


[콜백 지옥]

콜백 지옥이란, 콜백을 여러 차례 타고 들어가며 호출하게 되는 구조에서 코드가 마치 피라미드처럼 보여지는 현상을 말한다.

예시

step1(function (value1) {
    step2(function (value2) {
        step3(function (value3) {
            step4(function (value4) {
                step5(function (value5) {
                    step6(function (value6) {
                        // Do something with value6
                    });
                });
            });
        });
    });
});

이렇게 콜백 안에 콜백, 또 그 안에 콜백...
결국 들여쓰기가 계속 돼서 피라미드 형태가 된다.

내 코드

    private IEnumerator Start()
    {
        UILoadingScene loadingUI = null;
        // 0. 로딩씬 UI open
        Managers.Resource.LoadAsync<UnityEngine.Object>("UILoadingScene.prefab", (obj) =>
        {
            Managers.UI.ShowSceneUI<UILoadingScene>();
            Managers.UI.TryGetSceneUI(out loadingUI);
        });

        yield return new WaitWhile(() => loadingUI == null);

        // 1. 리소스 로드
        ResourceLoad((key, count, total) =>
        {
            loadingUI.ReceiveCallbacks($"Resource Loading ... ({count} / {total})");
            if (count == total)
            {
                // 2. 맵 생성
                Managers.Game.GenerateWorldAsync((progress, argument) =>
                {
                    // 맵 생성 진행 중 콜백
                    loadingUI.ReceiveCallbacks($"{argument} ({progress * 100:00}%)");
                },
                () =>
                {
                    // 맵 생성 완료 시 콜백
                    // 3. 객체 생성, 초기화
                    loadingUI.ReceiveCallbacks($"Game Initialize ...");
                    SpawnPlayer();
                    UIInitialize();
                    Managers.Game.World.InitializeWorldNavMeshBuilder(callback: op => 
                    {
                        // 4. NavMesh 생성
                        var mon = Managers.Resource.GetCache<GameObject>("Skeleton.prefab");
                        Instantiate(mon);
                        SpawnObject();
                        Managers.Game.Init();
                    });
                });
            }
        });
    }

현재 내 씬을 로딩하는 코드에서도 같은 현상이 발생 중이다.

벌써부터 가독성이 구려져서 주석 없이는 알아보기 힘들다 ...

코루틴은 return이 IEnumerator로 강제돼서 콜백 사용이 반 필수적인데, 이는 곧 콜백 지옥을 유발하게 될 가능성이 크다. UniTask라는 외부 패키지가 있던데, Unity에서 Task의 사용을 최적화해준 패키지라고 한다.

나중에 한 번 찾아서 사용법이나 작동 방식을 읽어봐야겠다.

profile
game developer

0개의 댓글