오늘은 SeamlessTravel 중 로딩 화면이 사라지는 문제와 MetaHuman 로딩 Hitch를 조사했다.
처음에는 서로 다른 문제처럼 보였지만, 정리하면서 결국 세 가지 질문으로 좁혀졌다.
화면을 실제로 누가 그리고 있는가?
↓
무거운 에셋 로딩을 누가, 언제 시작해야 하는가?
↓
그 로딩의 진행률을 실제로 측정할 수 있는가?
이 과정에서 Render Layer, Preload Lifetime, 동기/비동기 로딩, Progress 표현을 같이 정리하게 됐다.
먼저 Render Layer는 화면에 그려지는 요소들이 어떤 계층에서 합성되는가를 의미한다.
처음에는 커스텀 Loading Widget이 화면에서 사라졌기 때문에 Widget이 제거되거나 숨겨졌다고 생각했다.
그런데 실제 상태를 확인해보니 Widget은 계속 Viewport에 붙어 있었고, Visibility, Opacity, Geometry도 정상이었다.
문제는 다른 곳에 있었다.
Blocking Level Streaming이 발생하면 Unreal의 StreamingPauseRendering이 MoviePlayer를 통해 엔진 자체 로딩 화면을 띄울 수 있다.
이 화면은 내가 관리하던 Widget과 다른 계층에서 그려지기 때문에, Widget이 멀쩡하게 살아 있어도 화면에서는 가려질 수 있었다.
즉,
Widget이 안 보인다
≠
Widget이 사라졌다
라는 것을 알게 됐다.
UI 상태가 정상이라면 다음에는 "이 Widget을 왜 못 그리지?" 만 확인할 것이 아니라, "지금 화면 위를 누가 그리고 있지?" 까지 확인해야 한다.
Soft Reference는 에셋을 바로 메모리에 올리지 않고 경로만 참조하는 방식이다.
TSoftClassPtr<APawn> PawnClass;
하지만 Soft Reference로 바꿨다고 해서 에셋이 자동으로 미리 로드되는 것은 아니다. 실제로 메모리에 올리는 시점은 별도의 Preload가 결정한다.
RequestAsyncLoad(PawnClass.ToSoftObjectPath(), ...);
즉 둘은 역할이 다르다.
Soft Reference
→ 어떻게 참조할 것인가
Preload
→ 언제 실제로 로드할 것인가
이번 문제에서는 ClassSelect나 PlayerController가 Pawn Preload를 시작하고 있었다.
그런데 Client에서는 ClassSelect가 나타나기 약 0.4초 전에야 Preload가 시작됐고, 실제 로딩에는 약 30초가 걸렸다.
로드 대상이 틀린 것이 아니라, 로드를 시작하는 객체의 Lifetime이 너무 짧고 시작 시점이 늦었던 것이다.
그래서 Preload의 소유자를 UGameInstanceSubsystem으로 옮겼다.
ClassSelect Widget
↓
PlayerController Component
↓
GameInstanceSubsystem
GameInstance는 Lobby부터 존재하고 SeamlessTravel 동안에도 유지되기 때문에 Travel 시작부터 요청을 걸 수 있다.
여기서 중요한 것은 다음과 같다.
데이터를 사용하는 객체와 그 데이터를 준비하는 작업의 소유자는 같을 필요가 없다.
비동기 작업은 필요하다면 더 긴 Lifetime을 가진 객체가 소유해야 한다.
Synchronous Load는 필요한 순간에 로딩이 끝날 때까지 Game Thread가 기다리는 방식이고, Asynchronous Load는 요청을 먼저 걸어 두고 작업을 나눠 진행하는 방식이다.
이번에는 MetaHuman 로딩 비용 자체가 사라진 것은 아니었다.
수정 전에는 필요한 순간까지 에셋이 준비되지 않아 동기 로드가 발생했고, 한 Frame이 길게 멈췄다.
Travel
↓
필요한 순간 Sync Load
↓
Game Thread Hitch
Preload를 Travel 시작으로 앞당긴 뒤에는 다음과 같은 구조가 됐다.
Travel + Async Preload
↓
필요한 시점 전에 Load 완료
↓
Sync Load가 이미 로드된 값을 반환
실제로 Travel 시작부터 ClassSelect까지의 전체 시간은 수정 전후가 거의 비슷했다. 대신 긴 로딩 비용이
게임이 멈춘 한 Frame ->로딩 화면 뒤의 비동기 구간 으로 이동했다.
Measured Progress는 실제 작업 상태에서 얻은 값이고, Estimated Progress는 실제 완료율을 의미하지 않는 화면 표현용 진행값이다.
처음에는 FStreamableHandle::GetProgress()가 있으니 그대로 Loading Percent에 사용하면 된다고 생각했다.
하지만 실측에서는 약 25초 동안 거의 변화가 없다가 마지막 수십 ms에 다음 값이 몰렸다.
0.333
↓
0.667
↓
Complete
실제 값이라고 해서 항상 좋은 UI 데이터는 아니었던 것이다.
그래서 현재는 개념적으로 다음과 같이 나누는 방향으로 설계했다.
| Progress | 의미 |
|---|---|
20 ~ 85% | Estimated Progress |
90% | Preload Complete |
95% | World Loaded |
99% | Match Ready |
100% | Finish |
20~85%는 UX를 위한 추정값이지만, 이후 구간은 실제 이벤트가 오기 전에는 열리지 않는다.
즉,
실제 진행률을 알 수 없는 구간을 억지로 실제 퍼센트처럼 계산하기보다,
추정 진행과 실제 완료 조건을 분리한다.