이번 글은 Lyra프로젝트의 ExperienceManagerComponent::StartExperienceLoad함수 분석글입니다.
Experience Load과정을 전부 다 정리하기에는 너무빡세서 제가 햇갈렸던 부분과 몰랐던 부분 위주의 개념이 들어간 함수 하나만 잡아서 분석 한 글입니다.
AssetStreaming, FStreamableManager, FStreamableHandle의 간단한 개념과 비동기 로드시 어떤 흐름으로 Delegate들을 호출하는지 간단한 흐름만 분석하였습니다.
실제 GetPrimaryAssetID내부의 CDO와 연관성이라던지 좀더 자세한 부분들은 Lyra프로젝트를 통해 알아봐주시면 감사하겠습니다 ㅎㅎ (추후 정리 할 수 있으면 정리 할거같네요)
호출과정
일단 Lyra의 ExperienceManagerComponent::StartExperienceLoad함수가 호출되기 전까지의 과정을 짧게 요약하면
- LyraGameMode::InitGame (Level이 로드되면 가장 먼저 호출되는 함수입니다) => GetWorld()->GetTimerManager().SetTimerForNextTick(this, &ThisClass::HandleMatchAssignmentIfNotExpectingOne);
- ALyraGameMode::OnMatchAssignmentGiven() => ExperienceComponent->ServerSetCurrentExperience(ExperienceId);
- ULyraExperienceManagerComponent::ServerSetCurrentExperience() => StartExperienceLoad();
- ULyraExperienceManagerComponent::StartExperienceLoad()
이렇게 4단계에 걸처 함수들이 호출되어 오늘 분석할 함수까지 도달하게 됩니다.
좀더 자세한 호출 과정과 흐름을 파악하고 싶다면 아래 블로그를 참고해주세요
https://zhuanlan.zhihu.com/p/602889979
함수 호출 과정이 복잡한 이유
왜 이렇게 복잡하게 해두었나?
기존에는 게임에 모드를 변경하기 위해서 AGameModeBase를 많이 활용하였습니다.
예를들어 FPS <-> AOS 형식으로 게임 플레이하는 방식이 변경되게 하는 컨텐츠가 들어간다거나 하는경우를 구현할 때..
GameMode를 직접 수정하여 컨텐츠 작업을 해야하는데 GameMode를 변경하는것은 무겁고 까다로운 작업이고 + 엔진 구조 수정이 필요하기에 이러한 부담을 줄이고자 Experience라는 개념을 도입하여 기존에 GameMode가 하던 작업을 Experience로 대체하여 GameMode를 가볍게 만들고 언리얼 엔진을 수정하지 않고도 예로 들어준 작업을 편리하게 수행하기 위해서 위처럼 복잡한 함수 호출 과정을 거쳐 Experience를 로드합니다.
StartExperienceLoad 코드
void ULyraExperienceManagerComponent::StartExperienceLoad()
{
check(CurrentExperience != nullptr);
check(LoadState == ELyraExperienceLoadState::Unloaded);
UE_LOG(LogLyraExperience, Log, TEXT("EXPERIENCE: StartExperienceLoad(CurrentExperience = %s, %s)"),
*CurrentExperience->GetPrimaryAssetId().ToString(),
*GetClientServerContextString(this));
LoadState = ELyraExperienceLoadState::Loading;
ULyraAssetManager& AssetManager = ULyraAssetManager::Get();
TSet<FPrimaryAssetId> BundleAssetList;
TSet<FSoftObjectPath> RawAssetList;
BundleAssetList.Add(CurrentExperience->GetPrimaryAssetId());
for (const TObjectPtr<ULyraExperienceActionSet>& ActionSet : CurrentExperience->ActionSets)
{
if (ActionSet != nullptr)
{
BundleAssetList.Add(ActionSet->GetPrimaryAssetId());
}
}
// Load assets associated with the experience
TArray<FName> BundlesToLoad;
BundlesToLoad.Add(FLyraBundles::Equipped);
//@TODO: Centralize this client/server stuff into the LyraAssetManager
const ENetMode OwnerNetMode = GetOwner()->GetNetMode();
const bool bLoadClient = GIsEditor || (OwnerNetMode != NM_DedicatedServer);
const bool bLoadServer = GIsEditor || (OwnerNetMode != NM_Client);
if (bLoadClient)
{
BundlesToLoad.Add(UGameFeaturesSubsystemSettings::LoadStateClient);
}
if (bLoadServer)
{
BundlesToLoad.Add(UGameFeaturesSubsystemSettings::LoadStateServer);
}
const TSharedPtr<FStreamableHandle> BundleLoadHandle = AssetManager.ChangeBundleStateForPrimaryAssets(BundleAssetList.Array(), BundlesToLoad, {}, false, FStreamableDelegate(), FStreamableManager::AsyncLoadHighPriority);
const TSharedPtr<FStreamableHandle> RawLoadHandle = AssetManager.LoadAssetList(RawAssetList.Array(), FStreamableDelegate(), FStreamableManager::AsyncLoadHighPriority, TEXT("StartExperienceLoad()"));
// If both async loads are running, combine them
TSharedPtr<FStreamableHandle> Handle = nullptr;
if (BundleLoadHandle.IsValid() && RawLoadHandle.IsValid())
{
Handle = AssetManager.GetStreamableManager().CreateCombinedHandle({ BundleLoadHandle, RawLoadHandle });
}
else
{
Handle = BundleLoadHandle.IsValid() ? BundleLoadHandle : RawLoadHandle;
}
FStreamableDelegate OnAssetsLoadedDelegate = FStreamableDelegate::CreateUObject(this, &ThisClass::OnExperienceLoadComplete);
if (!Handle.IsValid() || Handle->HasLoadCompleted())
{
// Assets were already loaded, call the delegate now
FStreamableHandle::ExecuteDelegate(OnAssetsLoadedDelegate);
}
else
{
Handle->BindCompleteDelegate(OnAssetsLoadedDelegate);
Handle->BindCancelDelegate(FStreamableDelegate::CreateLambda([OnAssetsLoadedDelegate]()
{
OnAssetsLoadedDelegate.ExecuteIfBound();
}));
}
// This set of assets gets preloaded, but we don't block the start of the experience based on it
TSet<FPrimaryAssetId> PreloadAssetList;
//@TODO: Determine assets to preload (but not blocking-ly)
if (PreloadAssetList.Num() > 0)
{
AssetManager.ChangeBundleStateForPrimaryAssets(PreloadAssetList.Array(), BundlesToLoad, {});
}
}
함수분석
LoadState를 먼저 확인후에 FLyraExperienceLoadState::Loading으로 변경해줍니다.
이후 AssetManager 싱글톤 객체를 가져오고 CurrentExperience->GetPrimaryAssetId를 가져와 BundleAssetList에 추가해줍니다.
check(CurrentExperience != nullptr);
check(LoadState == ELyraExperienceLoadState::Unloaded);
UE_LOG(LogLyraExperience, Log, TEXT("EXPERIENCE: StartExperienceLoad(CurrentExperience = %s, %s)"),
*CurrentExperience->GetPrimaryAssetId().ToString(),
*GetClientServerContextString(this));
LoadState = ELyraExperienceLoadState::Loading;
ULyraAssetManager& AssetManager = ULyraAssetManager::Get();
TSet<FPrimaryAssetId> BundleAssetList;
TSet<FSoftObjectPath> RawAssetList;
BundleAssetList.Add(CurrentExperience->GetPrimaryAssetId());
for (const TObjectPtr<ULyraExperienceActionSet>& ActionSet : CurrentExperience->ActionSets)
{
if (ActionSet != nullptr)
{
BundleAssetList.Add(ActionSet->GetPrimaryAssetId());
}
}
이후 AssetManager의 ChangeBundleStateForPrimaryAssets를 호출하는데 이때 위에서 추가한 BundleAssetList를 넣어줍니다. 이 BundleAssetList를 순회하며 비동기 로드를 하여 Handle을 반환합니다.
FStreamableHandle은 동기, 비동기 로드에 대한 핸들이며 핸들이 활성화 되어 있다면 Asset들은 메모리에 유지됩니다.
const TSharedPtr<FStreamableHandle> BundleLoadHandle = AssetManager.ChangeBundleStateForPrimaryAssets(BundleAssetList.Array(), BundlesToLoad, {}, false, FStreamableDelegate(), FStreamableManager::AsyncLoadHighPriority);
// 중략..
// If both async loads are running, combine them
TSharedPtr<FStreamableHandle> Handle = nullptr;
if (BundleLoadHandle.IsValid() && RawLoadHandle.IsValid())
{
Handle = AssetManager.GetStreamableManager().CreateCombinedHandle({ BundleLoadHandle, RawLoadHandle });
}
else
{
Handle = BundleLoadHandle.IsValid() ? BundleLoadHandle : RawLoadHandle;
}
FStreamableDelegate OnAssetsLoadedDelegate = FStreamableDelegate::CreateUObject(this, &ThisClass::OnExperienceLoadComplete);
if (!Handle.IsValid() || Handle->HasLoadCompleted())
{
// Assets were already loaded, call the delegate now
FStreamableHandle::ExecuteDelegate(OnAssetsLoadedDelegate);
}
else
{
Handle->BindCompleteDelegate(OnAssetsLoadedDelegate);
Handle->BindCancelDelegate(FStreamableDelegate::CreateLambda([OnAssetsLoadedDelegate]()
{
OnAssetsLoadedDelegate.ExecuteIfBound();
}));
}
// This set of assets gets preloaded, but we don't block the start of the experience based on it
TSet<FPrimaryAssetId> PreloadAssetList;
//@TODO: Determine assets to preload (but not blocking-ly)
if (PreloadAssetList.Num() > 0)
{
AssetManager.ChangeBundleStateForPrimaryAssets(PreloadAssetList.Array(), BundlesToLoad, {});
}
아래처럼 FStreamableDelegate에 호출할 함수를 바인딩 해줍니다.
위의 코드에서 Handle의 HasLoadCompleted가 true라면 아래 OnAssetLoadedDelegate를 Execute합니다.
FStreamableDelegate OnAssetsLoadedDelegate = FStreamableDelegate::CreateUObject(this, &ThisClass::OnExperienceLoadComplete);
CurrentExperience의 CDO를 가져와 CDO의 GetPrimaryAssetId를 가져와 BundleAssetList에 추가하였는데
이는 Experience가 맞는지 확실하게 판단하기 위해서 가져 온 것이며 GetPrimaryAssetID또한 CDO일 경우 부모를 타고타고 올라가는 로직이 while문으로 구현되어 있습니다. CDO가 아니라면 아래처럼 UClass를 바로 반환해버리기 떄문에 Experience가 맞는지 아닌지 확실히 판단 하기 위하여 CDO의 GetPrimaryAssetID를 호출한 것을 확인할 수 있습니다.
// Data assets use Class and ShortName by default, there's no inheritance so class works fine
return FPrimaryAssetId(GetClass()->GetFName(), GetFName());
비동기 로드를 사용하는 이유는 동기로드를 사용할 경우 게임을 작은 오브젝트의 경우 크게 문제가 되지 않지만 큰 오브젝트나 많은 오브젝트들을 동기로드 하는 경우 메인 쓰레드를 너무 오래 붙잡아 게임이 렉이 걸린듯한 현상이 발생할 수 있기때문에 비동기 로드를 사용하여 줍니다.
또한 FStreamableHandle은 FStreamableDelegateDelayerHelper로 구현되어 있으며 조건이 충족된다면 즉시 호출하는 것이 아니라 조건을 충족하고 다음 World Tick에 실행됩니다.
즉, 조건에 충족한 것들을 한번에 모아서 처리하기때문에 성능상의 이점을 얻을 수 있습니다.
'UE5' 카테고리의 다른 글
[CPP] static_assert (1) | 2024.02.07 |
---|---|
[UE] Unreal Smart Pointer (0) | 2024.01.23 |
[UE] C1189 에러 해결방법 (0) | 2024.01.22 |
[UE] IsA (0) | 2024.01.20 |
[UE] UObject::CreateUObject Delegate 바인딩 언제, 어디서, 왜 쓰는지 (0) | 2024.01.05 |
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!