로딩 씬 UI 만들기
메인 씬 진입 시, 게임의 준비 과정동안 보여질 로딩 UI를 작업했다.
따로 로딩 씬을 만들지는 않고, 한 씬에서 로딩 중에는 로딩 UI가 보여지게 할 생각이다.
이유는, 현재 로딩의 대부분은 Chunk의 생성 부분인데, 이 작업은 MainScene에서 이뤄지기 떄문에 씬이 열려있어야 하기 떄문이다.
현재 게임의 준비 과정은 다음과 같다.
이 중 1, 2, 3번은 비동기 방식으로 callback을 호출할 수 있는 구조로 작성해뒀기 때문에, callback을 이용해서 로딩 씬 UI에 현재 상황을 반영하게 할 계획이다.
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이 돌아가는 연출을 작성해줬다.
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의 애니메이션 연출도 따라서 멈추게 된다.
이에 대한 해결 방법으로 내가 생각했던 것들은 다음과 같다.
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;
}
일정 시간마다 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의 사용을 최적화해준 패키지라고 한다.
나중에 한 번 찾아서 사용법이나 작동 방식을 읽어봐야겠다.