최근 로딩 화면을 수정하면서 처음에는 단순히 UI를 다시 디자인하고 진행률을 자연스럽게 만드는 작업이라고 생각했다.
그런데 실제 코드를 따라가다 보니 생각보다 훨씬 많은 시스템이 연결되어 있었다.
로딩 화면을 정상적으로 종료하려면 Delegate를 알아야 했고, SeamlessTravel 도중 화면이 보이지 않는 문제를 조사하면서 UMG와 Slate의 차이를 보게 됐다. MetaHuman Pawn의 선로드 시점을 앞당기는 과정에서는 FStreamableHandle의 수명과 GameInstanceSubsystem의 역할도 이해해야 했다.
특히 이번 작업에서는 처음 보는 Unreal API가 많았다.
FSimpleDelegate, BindWeakLambda(), ExecuteIfBound(), MoveTemp(), TakeWidget(), FTSTicker 같은 코드가 처음에는 각각 따로 보였는데, 로딩 화면의 전체 흐름을 따라가면서 왜 필요한지 조금씩 연결할 수 있었다.
일반적인 함수 호출은 호출한 순간 바로 실행된다.
HideLoadingScreen();
이 코드를 만나면 그 자리에서 HideLoadingScreen()이 실행된다. 하지만 모든 작업이 이렇게 즉시 실행될 수 있는 것은 아니다. 이번 로딩 화면에서는 LoadingScreenSubsystem이 로딩 종료를 시작하더라도 바로 Widget을 제거하면 안 됐다.
화면의 진행률이 실제로 100%에 도달하고 잠시 표시된 다음에 제거되어야 했다.
즉 Subsystem은 이런 요청을 해야 했다.
지금 Hide해라
가 아니라
100% 표시가 끝났을 때
Hide를 실행해라
가 필요했다.
이처럼 실행할 함수를 지금 호출하지 않고 저장하거나 다른 객체에게 넘겨 나중에 실행할 수 있게 하는 방법이 Delegate다.
이번 구조에서는 Subsystem이 Widget에게 종료 후 실행할 행동을 Delegate로 전달한다.
FSimpleDelegate OnFinished;
OnFinished.BindWeakLambda(this, [this]()
{
HideLoadingScreen();
});
ActiveLoadingWidget->BeginCompletionExit(OnFinished);
이 시점에서 HideLoadingScreen()이 실행되는 것은 아니다. OnFinished 안에 실행할 행동을 저장하고, 그것을 Widget에게 전달한 상태다.
흐름으로 보면 다음과 같다.
LoadingScreenSubsystem
↓
"완료되면 이 함수를 실행해"
↓
LoadingScreenWidget
↓
100%까지 진행
↓
Hold
↓
Delegate 실행
↓
HideLoadingScreen()
FSimpleDelegateFSimpleDelegate OnFinished;
이번 코드에서 사용한 Delegate 타입은 FSimpleDelegate였다.
FSimpleDelegate는 매개변수도 없고 반환값도 없는 함수 하나를 연결할 수 있는 Delegate다.
이번 완료 콜백에서는 Widget이 Subsystem에 값을 전달할 필요가 없다.
필요한 정보는 끝났다는 이벤트다.
그 뒤에는 미리 등록해 둔 HideLoadingScreen()을 실행하면 된다.
따라서 복잡한 매개변수를 갖는 Delegate를 만들 필요 없이 FSimpleDelegate면 충분했다.
BindWeakLambda()Delegate를 만들었다고 해서 아직 실행할 함수가 들어 있는 것은 아니다.
무엇을 실행할 것인지 Delegate에 연결해야 한다.
이번에는 다음과 같이 작성되어 있었다.
OnFinished.BindWeakLambda(this, [this]()
{
HideLoadingScreen();
});
여기에는 두 개념이 같이 들어 있다.
먼저 Lambda는 이름이 없는 작은 함수를 그 자리에서 만드는 C++ 문법이다.
[this]()
{
HideLoadingScreen();
}
이 Lambda는 현재 객체인 this를 사용해서 HideLoadingScreen()을 호출한다.
그런데 Delegate의 특징은 지금이 아니라 나중에 실행될 수도 있다는 것이다.
Delegate를 등록할 때는 객체가 살아 있었더라도 실제 실행 시점에는 해당 객체가 파괴되었을 수도 있다.
BindWeakLambda()는 UObject를 Context로 함께 넘겨서, 해당 객체가 더 이상 유효하지 않은 경우 Lambda를 실행하지 않도록 하는 방식이다.
개념적으로 보면 다음과 같다.
Delegate 실행 시점
↓
Context UObject가 아직 유효한가?
↓
YES → Lambda 실행
NO → 실행하지 않음
이번 작업을 통해 비동기 처리나 콜백에서는 단순히 "무슨 함수를 실행할까?"뿐 아니라 "그 함수를 실행할 때 대상 객체가 아직 살아 있는가?"도 중요하다
ExecuteIfBound()Delegate는 연결했다고 자동으로 실행되지 않는다.
실제로 실행하려면 Execute() 또는 ExecuteIfBound() 같은 함수를 호출해야 한다.
이번 코드에서는 다음과 같이 사용했다.
Local.ExecuteIfBound();
여기서 Bound는 Delegate에 실행할 함수가 연결되어 있다는 의미다.
따라서 ExecuteIfBound()는 개념적으로 다음과 같다.
if (Delegate에 실행할 함수가 연결되어 있다면)
{
실행한다;
}
Delegate가 비어 있을 수도 있는 상황에서는 ExecuteIfBound()를 사용하면 먼저 Binding 여부를 확인하고 안전하게 실행할 수 있다.
이번 작업 전에는 ExecuteIfBound()라는 API 자체가 생소했지만, Delegate의 기본 흐름은 다음처럼 생각하면 이해하기 쉬웠다.
Delegate 생성
↓
Bind
↓
함수 저장
↓
필요한 시점까지 기다림
↓
ExecuteIfBound
↓
저장된 함수 실행
이번에 가장 생소했던 개념 중 하나가 Reentrancy, 재진입이었다.
재진입은 어떤 함수가 아직 실행 중인데, 그 함수가 호출한 다른 코드 때문에 다시 자기 객체의 상태나 관련 실행 경로로 들어오는 상황을 말한다.
처음에는 다음 코드가 굉장히 이상해 보였다.
FSimpleDelegate Local = MoveTemp(CompletionExitDelegate);
CompletionExitDelegate.Unbind();
Local.ExecuteIfBound();
왜 그냥 이렇게 하지 않는지 의문이었다.
CompletionExitDelegate.ExecuteIfBound();
하지만 이번 Delegate가 실행하는 함수를 따라가 보면 이유가 보인다.
TickCompletionExit()
↓
Delegate 실행
↓
HideLoadingScreen()
↓
Loading Session 정리
↓
Widget / Delegate / 각종 상태 정리
즉 Delegate를 실행한 뒤에는 현재 Widget이나 Subsystem의 상태가 그대로 유지된다는 보장이 없다.
외부 코드를 호출한 순간 그 코드가 다시 현재 객체에 영향을 줄 수 있다.
그래서 외부 콜백을 실행하기 전에 현재 객체의 상태부터 정리하는 것이 중요하다.
현재 완료 처리 코드는 다음 순서를 사용한다.
bCompletionExitPending = false;
FSimpleDelegate Local = MoveTemp(CompletionExitDelegate);
CompletionExitDelegate.Unbind();
Local.ExecuteIfBound();
먼저 완료 대기 상태를 끝낸다.
bCompletionExitPending = false;
이제 다시 동일한 경로가 들어오더라도 현재 객체는
"아직 완료 처리 중"
이라고 잘못 판단하지 않는다.
그다음 실행할 Delegate를 지역 변수로 옮긴다.
FSimpleDelegate Local = MoveTemp(CompletionExitDelegate);
그리고 멤버 Delegate를 명시적으로 비운다.
CompletionExitDelegate.Unbind();
마지막으로 지역 변수에 보관해 둔 Delegate를 실행한다.
Local.ExecuteIfBound();
정리하면 순서가 다음과 같다.
1. 현재 상태를 완료 상태로 변경
2. 실행할 Callback을 Local에 확보
3. 멤버 Delegate 정리
4. 외부 Callback 실행
여기서 중요한 것은 4번이 마지막이라는 것이다.
MoveTemp()는 무엇인가MoveTemp()도 이번에 처음 제대로 보게 됐다.
FSimpleDelegate Local = MoveTemp(CompletionExitDelegate);
MoveTemp()는 Unreal에서 C++의 Move Semantics를 사용하기 위한 도우미 함수다.
단순히 값을 복사해서
CompletionExitDelegate
Local
두 곳에 같은 Delegate를 하나씩 만드는 것과는 목적이 다르다.
기존 객체가 가지고 있던 내부 상태를 새 객체가 이동해서 사용할 수 있도록 한다.
조금 더 정확하게 말하면 MoveTemp() 자체가 데이터를 이동시키는 함수라기보다, 해당 값을 rvalue로 취급하게 해서 Move Constructor가 사용될 수 있도록 만든다.
이번 코드에서는 실행할 Delegate 상태를 Local로 옮긴 뒤 외부 Callback을 호출한다.
멤버에 실행할 Delegate를 둔 채 외부 코드 실행
X
실행할 Delegate를 Local로 분리
→ 멤버 상태 정리
→ Local 실행
O
이 패턴 덕분에 Callback이 실행되는 동안 다시 같은 종료 흐름으로 들어와도 기존 멤버 Delegate를 또 실행하는 상황을 막을 수 있다.
이번에 기억해 둘 규칙은 다음 하나면 될 것 같다.
외부 Callback을 호출하기 전에 내 객체의 상태부터 일관된 상태로 만들어 둔다.
MoveTemp(), Unbind(), ExecuteIfBound()를 각각 외우기보다 왜 이 순서로 등장하는지를 이해하는 것이 더 중요했다.
Unreal에서 UI를 만들 때 가장 자주 접하는 것은 UMG다.
예를 들어 다음과 같은 클래스들이 익숙하다.
UUserWidget
UImage
UTextBlock
UProgressBar
Blueprint에서 만드는 WBP_LoadingScreen 역시 UMG Widget이다.
그런데 Unreal의 UI 아래에는 Slate라는 더 낮은 레벨의 UI Framework가 있다.
개념적으로는 다음처럼 볼 수 있다.
UMG
UUserWidget
WBP_LoadingScreen
↓
Slate
SWidget
↓
Viewport
↓
최종 화면
UMG는 UObject와 Blueprint 기반으로 UI를 편하게 제작할 수 있도록 만들어진 상위 계층이고, 실제 UI 표현에는 Slate Widget이 사용된다.
이번 로딩 문제를 조사하기 전에는 UMG Widget을 화면 UI 그 자체처럼 생각했는데, 실제로는 그 아래 렌더링 계층이 하나 더 있었다.
TakeWidget()현재 로딩 화면은 UMG Widget에서 Slate Widget을 얻어 Viewport에 직접 추가하는 방식을 사용한다.
그때 등장한 것이 TakeWidget()이다.
LoadingScreenSlateWidget = ActiveLoadingWidget->TakeWidget();
TakeWidget()을 통해 UUserWidget이 내부적으로 사용하는 Slate 표현을 얻을 수 있다.
그 Slate Widget을 GameViewportClient에 직접 붙인다.
ViewportClient->AddViewportWidgetContent(
LoadingScreenSlateWidget.ToSharedRef(),
10000
);
따라서 현재 구조는 개념적으로 다음과 같다.
WBP_LoadingScreen
↓
UUserWidget
↓
TakeWidget()
↓
SWidget
↓
GameViewportClient
↓
Viewport
그리고 제거할 때도 RemoveFromParent()가 아니라 Viewport에서 Slate Widget을 직접 제거한다.
ViewportClient->RemoveViewportWidgetContent(
LoadingScreenSlateWidget.ToSharedRef()
);
Travel 과정에서 World에 종속된 Widget Tree와 로딩 Overlay의 수명을 분리하기 위한 구조다.
SeamlessTravel 중 로딩 화면이 사라졌을 때 처음에는 Widget이 파괴됐다고 생각했다.
그래서 실제 상태를 계속 측정했다.
그런데 조사 결과는 예상과 달랐다.
Widget 존재
Added = 1
Visibility 정상
Opacity = 1
Geometry 정상
같은 Widget Instance 유지
그런데도 화면에서는 Custom Loading UI가 보이지 않았다.
이 경험을 통해 다음 두 가지는 별개의 문제라는 것을 알게 됐다.
Widget 객체의 Lifetime
≠
최종 화면의 Rendering 결과
객체가 정상적으로 메모리에 존재하고 Viewport에 등록되어 있다고 해서 반드시 최종 화면에 보인다고 단정할 수 없다.
그 위에 다른 Render Layer나 Engine Overlay가 그려질 수도 있기 때문이다.
따라서 UI가 안 보일 때 단순히 Visibility만 확인해서는 부족하다.
Widget이 존재하는가?
↓
Viewport에 등록되어 있는가?
↓
Geometry / Opacity / Visibility는 정상인가?
↓
그 위를 다른 Rendering Layer가 덮고 있지는 않은가?
처럼 계층을 나눠서 확인해야 한다.
bDisableWorldRendering현재 로딩 화면 코드에서는 다음 값을 사용한다.
ViewportClient->bDisableWorldRendering = true;
이 이름 그대로 Game World의 Rendering을 끄는 옵션이다.
중요한 것은 UI Rendering까지 끄는 것이 아니라는 점이다.
따라서 다음 상태가 가능하다.
World Rendering
OFF
Loading UI Rendering
ON
로딩 중 아직 완성되지 않은 World가 화면에 노출되는 것을 막으면서 별도의 Loading Overlay는 계속 표시할 수 있다.
그리고 로딩 화면을 실제로 제거할 때 다시 원래 상태로 돌린다.
ViewportClient->bDisableWorldRendering = false;
처음에는 로딩 UI와 World Rendering을 하나의 화면 문제로 생각했는데, 실제로는 서로 다른 렌더링 책임이었다.
StreamingPauseRendering은 다른 계층의 문제였다이번 SeamlessTravel 문제에서 더 중요한 것은 StreamingPauseRendering이었다.
처음에는 Custom Widget이 사라지는 이유를 Widget 자체에서 찾았다.
하지만 Widget은 계속 정상 상태였다.
조사를 진행하면서 Unreal Engine의 기본 StreamingPauseRendering 처리에서 별도의 화면이 올라올 수 있다는 것을 확인했다.
프로젝트의 Custom Loading UI와는 별개의 Engine Layer였다.
즉 구조가 대략 다음처럼 된 것이다.
Custom Loading UI
↓
정상적으로 존재
하지만
Engine StreamingPauseRendering 화면
↓
그 위에서 표시
그래서 Custom Widget의 Visibility나 Opacity를 아무리 확인해도 원인을 찾을 수 없었다.
실제 조사에서는 StreamingPauseRendering Delegate를 해제한 뒤 증상이 사라졌다.
여기서 인과관계를 정확히 구분할 필요가 있었다.
이번 작업에는 동시에 두 가지 변경이 들어갔다.
1. GameViewportClient + Slate 직접 부착
+ bDisableWorldRendering
2. StreamingPauseRendering Delegate 해제
1번은 Travel 중 로딩 Overlay를 더 안정적인 계층에 두고 World 노출을 막는 역할이었다.
하지만 Custom Loading UI를 직접 덮고 있던 원인은 2번, 즉 Engine의 StreamingPauseRendering 화면이었다.
따라서
"Slate로 바꿔서 문제가 해결됐다"
라고 단정하면 정확하지 않다.
이번 문제를 통해 같은 증상을 보고도 객체 Lifetime 문제, UI 상태 문제, Render Layer 문제를 구분해야 한다는 것을 배웠다.
MetaHuman Pawn 관련 Hitch를 조사하면서 동기 로딩과 비동기 로딩의 차이를 다시 보게 됐다.
동기 로딩은 필요한 Asset이 없으면 해당 호출 위치에서 로딩이 끝날 때까지 기다린다.
LoadSynchronous();
반면 비동기 로딩은 먼저 Load 요청을 걸어 두고 다른 작업을 계속 진행할 수 있다.
RequestAsyncLoad(...)
개념적으로는 다음과 같다.
Synchronous Load
요청
↓
기다림
↓
완료
↓
다음 코드
Asynchronous Load
요청
↓
게임 진행
↓
백그라운드에서 Load 진행
↓
완료 Callback
이번 문제에서는 Pawn이 실제 필요해지는 ClassSelect나 Spawn 순간에 동기 Load 비용을 지불하지 않도록, 그보다 훨씬 앞에서 RequestAsyncLoad()를 시작했다.
FStreamableHandleRequestAsyncLoad()를 사용하면서 등장한 것이 FStreamableHandle이다.
Handle은 쉽게 말하면 현재 진행 중인 비동기 Load 요청을 가리키는 객체라고 볼 수 있다.
프로젝트에서는 다음과 같은 형태로 저장한다.
TSharedPtr<FStreamableHandle> PawnPreloadHandle;
그리고 비동기 요청을 시작한다.
PawnPreloadHandle =
UAssetManager::GetStreamableManager().RequestAsyncLoad(...);
이 Handle을 통해 해당 Load 요청이 살아 있는지 확인하고, 완료나 해제 같은 요청 수명을 관리할 수 있다.
여기서 중요한 것은 Load 자체만이 아니라 그 Load 요청을 누가 소유할 것인가였다.
처음 Preload 구조는 ClassSelect 화면이나 PlayerController 쪽 Lifetime에 가까이 묶여 있었다.
그런데 Pawn Preload가 필요한 기간은 그보다 길었다.
Preload 시작
↓
SeamlessTravel
↓
ClassSelect
↓
Pawn Spawn
반면 특정 Widget의 Lifetime은 다음처럼 짧을 수 있다.
Widget 활성화
↓
Preload 시작
↓
Widget Deactivate
↓
Widget Lifetime 종료
즉 필요한 Lifetime과 소유자의 Lifetime이 맞지 않았다.
Preload가 필요한 기간
>
Widget이 살아 있는 기간
이 상태에서는 UI의 Lifecycle이 바뀌는 것이 Asset Load 요청에도 영향을 줄 수 있다.
여기서 처음으로
변수를 어디에 둘 것인가보다,
이 상태가 얼마 동안 살아 있어야 하는가를 먼저 봐야 한다
는 생각이 중요하다는 것을 알게 됐다.
GameInstanceSubsystem과 SeamlessTravelGameInstanceSubsystem인가현재 Pawn Preload는 UDominationPreloadSubsystem이 담당한다.
UDominationPreloadSubsystem
: public UGameInstanceSubsystem
왜 PlayerController나 Widget이 아니라 GameInstance 수준의 Subsystem을 사용했는지 처음에는 감이 잘 오지 않았다.
필요한 조건을 정리하면 이유가 명확했다.
Lobby에서 이미 존재해야 함
+
SeamlessTravel 중에도 유지되어야 함
+
FStreamableHandle을 계속 보관해야 함
+
특정 UI나 PlayerController의 Lifetime에 의존하면 안 됨
이 조건을 만족시키려면 World보다 긴 Lifetime이 필요했다.
GameInstance는 하나의 게임 실행 동안 여러 World를 거치더라도 계속 유지된다.
개념적으로 보면 다음과 같다.
GameInstance
────────────────────────────────────
Lobby World
───────────
Transition
Domination World
────────────────
Lobby World
───────────
World는 교체되지만 GameInstance는 그보다 바깥 Lifetime을 가진다.
따라서 UGameInstanceSubsystem이 Preload Handle을 소유하면 Travel 동안에도 Load 요청을 유지할 수 있다.
이번 사례를 일반화하면 다음 기준으로 생각할 수 있다.
어떤 상태가
Widget보다 오래 필요하다
면 Widget에 두는 것이 이상할 수 있다.
PlayerController가 교체될 수 있는 구간까지 넘어야 한다면 PlayerController 역시 적절하지 않을 수 있다.
World 자체가 바뀌는 구간을 넘어야 한다면 World에 종속된 객체보다 더 긴 Lifetime을 찾아야 한다.
즉 설계할 때 다음 질문을 먼저 던지는 것이 좋다.
이 상태는 언제 만들어지는가?
↓
언제까지 필요할까?
↓
그 기간 동안 Owner가 살아 있는가?
이번에는 그 답이 GameInstanceSubsystem이었다.
FTSTickerUnreal에서 일정 시간마다 코드를 실행할 때 흔히 World의 TimerManager를 사용한다.
GetWorld()->GetTimerManager()
그런데 이름에서 알 수 있듯 이 Timer는 특정 World에 속한다.
이번 로딩 진행률은 SeamlessTravel 구간 전체에서 계속 움직여야 했다.
Lobby World
↓
Travel
↓
Transition
↓
Domination World
Travel 도중에는 World가 교체된다.
따라서 World에 묶인 Timer를 로딩 전체 구간의 소유자로 쓰기에는 Lifetime 경계가 맞지 않는다.
FTSTicker그래서 Estimated Travel Progress에는 FTSTicker를 사용했다.
EstimatedTravelTickerHandle =
FTSTicker::GetCoreTicker().AddTicker(
FTickerDelegate::CreateUObject(
this,
&ThisClass::TickEstimatedTravelProgress
)
);
FTSTicker는 특정 World의 TimerManager에 직접 묶이지 않은 Core Ticker다.
따라서 World 교체 구간을 넘어 계속 동작해야 하는 로직에 사용할 수 있다.
현재 구조는 다음과 같다.
BeginTravelProgress()
↓
FTSTicker 등록
↓
EstimatedTravelProgress 증가
↓
World 교체
↓
계속 진행
↓
완료 / 세션 종료
↓
StopEstimatedTravelTicker()
FTSTicker라고 해서 Hitch를 피하는 것은 아니다처음에는 Core Ticker라면 Game Thread가 멈춰도 계속 움직이는 것처럼 느낄 수 있다.
하지만 그렇지 않다.
현재 FTSTicker 역시 Game Thread에서 실행된다.
따라서 Game Thread가 큰 Asset Load 등의 이유로 Hitch되면 Ticker도 같이 멈춘다.
Game Thread 실행 중
→ FTSTicker 진행
Game Thread Hitch
→ FTSTicker도 정지
Game Thread 재개
→ 다시 진행
즉 FTSTicker를 선택한 이유는
Hitch 중에도 Progress를 계속 움직이기 위해서
가 아니라
World가 교체되는 Travel 구간에서도
Ticker의 Lifetime을 유지하기 위해서
였다.
비슷해 보이지만 완전히 다른 이유다.
각 개념만 따로 보면 Delegate, Slate, GameInstanceSubsystem, FTSTicker가 서로 다른 이야기처럼 보인다.
하지만 현재 로딩 흐름에서는 모두 하나의 Lifecycle 안에서 연결된다.
먼저 Lobby에서 Domination으로 이동하기 시작한다.
OnSeamlessTravelStart
↓
UDominationPreloadSubsystem
↓
RequestAsyncLoad()
↓
FStreamableHandle 보관
UDominationPreloadSubsystem은 GameInstanceSubsystem이기 때문에 World 교체 중에도 Load 요청을 계속 관리한다.
동시에 LoadingScreenSubsystem에서는 Travel 진행률을 시작한다.
BeginTravelProgress()
↓
20%
↓
FTSTicker
↓
20 ~ 85% Estimated Progress
여기서 20~85%는 실제 Asset Load 비율이 아니라 UX를 위한 추정 진행률이다.
실제 완료를 의미하는 구간은 별도로 잠겨 있다.
90% Preload 완료
95% 최종 World 도착
99% Match 준비 완료
100% FinishLoadingScreen
특히 90 → 95 → 99는 순서를 강제한다.
예를 들어 World Loaded 이벤트가 먼저 들어왔다고 해서 바로 95%를 보여주지 않는다.
WorldLoaded = true
하지만
PreloadComplete = false
→ 상태만 기억
→ 화면은 아직 90% 이상으로 가지 않음
Preload가 완료되면 앞 단계가 열리고 이후 단계도 조건에 따라 반영된다.
게임 로직에서 로딩 종료 요청이 왔다고 바로 100%가 되는 것은 아니다.
FinishLoadingScreen() 입구에는 CanFinishLoading()이라는 Gate가 있다.
if (!CanFinishLoading())
{
DeferFinishUntilPreloadEnd();
return;
}
Gate는 쉽게 말하면 다음 단계로 넘어가기 전에 조건을 확인하는 관문이다.
이번 Gate가 묻는 것은
"Preload가 성공적으로 끝났나?"
만은 아니다.
더 정확히는
"아직 더 기다려야 할 Preload가 남아 있는가?"
다.
그래서 정상 완료뿐 아니라 Timeout이나 중도 해제처럼 더 이상 기다리지 않기로 한 경우에도 Gate는 열릴 수 있다.
Gate가 열리면 그때 비로소
ApplyProgress(1.0f);
으로 실제 완료 Target을 적용한다.
ApplyProgress(1.0f)은 시스템의 Progress를 100%로 만든다.
하지만 화면의 Display Progress가 즉시 100%가 된다는 뜻은 아니다.
Widget에서는 Target을 향해 Display를 보간한다.
그리고 완료 대기 중 일정 시간 안에 100%에 도달하지 못하면 SnapToTarget()으로 Target에 붙일 수 있다.
중요한 점은 시간이 지났다고 Widget을 Hide하지는 않는다는 것이다.
CompletionSnapSeconds 도달
↓
아직 100%가 아니면
SnapToTarget()
↓
Display 100% 적용
↓
IsVisuallyComplete 확인
↓
CompletionHoldSeconds 동안 표시
↓
Delegate 실행
즉 IsVisuallyComplete()가 실제 제거 전에 반드시 통과해야 하는 관문이다.
100%를 화면에 그리지 않고 바로 제거하는 경로를 만들지 않는 것이 현재 완료 흐름의 계약이다.
Widget이 100% 표시와 Hold를 마치면 저장해 둔 Delegate를 실행한다.
FSimpleDelegate Local = MoveTemp(CompletionExitDelegate);
CompletionExitDelegate.Unbind();
Local.ExecuteIfBound();
Delegate에는 처음 Subsystem에서 등록했던 코드가 들어 있다.
OnFinished.BindWeakLambda(this, [this]()
{
HideLoadingScreen();
});
따라서 전체 흐름은 다시 Subsystem으로 돌아온다.
Subsystem
Finish 요청
↓
Widget
시각적인 완료 처리
↓
Delegate
↓
Subsystem
HideLoadingScreen()
Widget이 스스로 Viewport에서 자신을 제거하지 않는다는 것도 중요하다.
Widget은
"화면 연출이 끝났다"
만 판단하고,
실제 Viewport 부착과 세션 상태를 소유하는 Subsystem이 제거를 담당한다.
이번 구현 중 하나 더 놓쳤던 부분이 있었다.
처음에는 FinishLoadingScreen()에서 Widget이 없으면 그냥 return하도록 만들었다.
if (!ActiveLoadingWidget)
{
return;
}
하지만 HideLoadingScreen()은 단순히 화면만 지우는 함수가 아니었다.
그 안에는
GameState Binding 해제
FTSTicker 정리
진행률 상태 Reset
각종 Session Flag 초기화
같은 세션 종료 작업도 들어 있었다.
따라서 Widget이 없다고 그냥 반환하면 화면은 없더라도 로딩 세션은 살아 있을 수 있었다.
특히 FTSTicker가 남으면 다음 World까지 Estimated Progress가 계속 살아 있는 문제가 생길 수 있다.
그래서 현재는 다음처럼 처리한다.
if (!ActiveLoadingWidget)
{
HideLoadingScreen();
return;
}
여기서 다시 한 번 UI의 존재 여부와 로딩 시스템의 Lifecycle은 같은 것이 아니다라는 점을 확인할 수 있었다.