PART 8 - 강의 6/6

비동기 에셋 로딩

TSoftObjectPtr과 StreamableManager로 끊김 없는 에셋 로딩 구현

01

Hard vs Soft Reference

에셋 참조 방식의 차이

Hard Reference (직접 참조)

  • 객체 로드 시 즉시 로딩
  • 메모리에 항상 상주
  • 게임 시작 시 로딩 시간 증가
  • 메모리 사용량 증가
C++
// Hard Reference UPROPERTY() TObjectPtr<UTexture2D> HardTexture; UPROPERTY() TSubclassOf<AActor> HardActorClass;

Soft Reference (간접 참조)

  • 필요할 때만 로딩
  • 경로만 저장, 메모리 효율적
  • 비동기 로딩 가능
  • 오픈월드에 적합
C++
// Soft Reference UPROPERTY() TSoftObjectPtr<UTexture2D> SoftTexture; UPROPERTY() TSoftClassPtr<AActor> SoftActorClass;
언제 Soft Reference를 사용할까?
  • 대규모 오픈월드의 스트리밍 에셋
  • 조건부로 로드되는 콘텐츠 (DLC, 옵션)
  • 메모리 제약이 있는 플랫폼
  • 초기 로딩 시간 단축이 필요할 때
02

TSoftObjectPtr 사용법

Soft Reference 기본

C++
UCLASS() class AWeaponBase : public AActor { GENERATED_BODY() public: // Soft Reference - 경로만 저장 UPROPERTY(EditDefaultsOnly, Category = "Assets") TSoftObjectPtr<UStaticMesh> WeaponMesh; UPROPERTY(EditDefaultsOnly, Category = "Assets") TSoftObjectPtr<UTexture2D> WeaponIcon; UPROPERTY(EditDefaultsOnly, Category = "Assets") TSoftClassPtr<AActor> ProjectileClass; // 동기 로딩 (블로킹 - 주의!) void LoadMeshSync() { if (!WeaponMesh.IsNull()) { // LoadSynchronous: 로드될 때까지 대기 UStaticMesh* LoadedMesh = WeaponMesh.LoadSynchronous(); if (LoadedMesh) { MeshComponent->SetStaticMesh(LoadedMesh); } } } // 이미 로드되었는지 확인 bool IsMeshLoaded() const { return WeaponMesh.IsValid(); // 이미 메모리에 있으면 true } // 로드된 에셋 가져오기 (로드 안 되어 있으면 nullptr) UStaticMesh* GetMeshIfLoaded() { return WeaponMesh.Get(); // 로드 시도 안 함 } // 에셋 경로 가져오기 FSoftObjectPath GetMeshPath() const { return WeaponMesh.ToSoftObjectPath(); } };
LoadSynchronous 주의

LoadSynchronous()는 Game Thread를 블로킹합니다. 대용량 에셋을 로드하면 프레임 드랍이 발생할 수 있으니, 가능하면 비동기 로딩을 사용하세요.

03

FStreamableManager

비동기 에셋 로딩 시스템

비동기 로딩 흐름

로딩 요청
백그라운드 로딩
콜백 호출
에셋 사용

Game Thread 블로킹 없이 로딩

C++
#include "Engine/StreamableManager.h" #include "Engine/AssetManager.h" void AMyCharacter::LoadWeaponAsync() { // AssetManager에서 StreamableManager 가져오기 FStreamableManager& StreamableManager = UAssetManager::GetStreamableManager(); // Soft Reference 경로 FSoftObjectPath AssetPath = WeaponMeshSoft.ToSoftObjectPath(); // 비동기 로딩 요청 StreamableManager.RequestAsyncLoad( AssetPath, FStreamableDelegate::CreateUObject( this, &AMyCharacter::OnWeaponMeshLoaded ) ); } // 로딩 완료 콜백 void AMyCharacter::OnWeaponMeshLoaded() { // 로드된 에셋 가져오기 UStaticMesh* LoadedMesh = WeaponMeshSoft.Get(); if (LoadedMesh) { WeaponMeshComponent->SetStaticMesh(LoadedMesh); UE_LOG(LogGame, Log, TEXT("Weapon mesh loaded!")); } }

여러 에셋 동시 로딩

C++
void AMyCharacter::LoadMultipleAssetsAsync() { FStreamableManager& StreamableManager = UAssetManager::GetStreamableManager(); // 로드할 에셋 경로 목록 TArray<FSoftObjectPath> AssetsToLoad; AssetsToLoad.Add(WeaponMeshSoft.ToSoftObjectPath()); AssetsToLoad.Add(WeaponIconSoft.ToSoftObjectPath()); AssetsToLoad.Add(WeaponSoundSoft.ToSoftObjectPath()); // 모든 에셋 비동기 로딩 StreamableHandle = StreamableManager.RequestAsyncLoad( AssetsToLoad, FStreamableDelegate::CreateUObject( this, &AMyCharacter::OnAllAssetsLoaded ) ); } void AMyCharacter::OnAllAssetsLoaded() { // 모든 에셋이 로드됨 UStaticMesh* Mesh = WeaponMeshSoft.Get(); UTexture2D* Icon = WeaponIconSoft.Get(); USoundCue* Sound = WeaponSoundSoft.Get(); ApplyWeaponAssets(Mesh, Icon, Sound); }
04

TSharedPtr

로딩 상태 관리

C++
UCLASS() class UAssetLoader : public UObject { GENERATED_BODY() public: // 로딩 핸들 (로딩 상태 추적 및 취소용) TSharedPtr<FStreamableHandle> LoadingHandle; void StartLoading(TSoftObjectPtr<UObject> AssetToLoad) { // 이전 로딩 취소 if (LoadingHandle.IsValid()) { LoadingHandle->CancelHandle(); LoadingHandle.Reset(); } FStreamableManager& Streamable = UAssetManager::GetStreamableManager(); LoadingHandle = Streamable.RequestAsyncLoad( AssetToLoad.ToSoftObjectPath(), FStreamableDelegate::CreateUObject( this, &UAssetLoader::OnLoadComplete ) ); } // 로딩 진행률 확인 float GetLoadingProgress() const { if (LoadingHandle.IsValid()) { return LoadingHandle->GetProgress(); } return 0.0f; } // 로딩 완료 여부 bool IsLoadingComplete() const { return LoadingHandle.IsValid() && LoadingHandle->HasLoadCompleted(); } // 로딩 취소 void CancelLoading() { if (LoadingHandle.IsValid()) { LoadingHandle->CancelHandle(); LoadingHandle.Reset(); } } private: void OnLoadComplete() { if (LoadingHandle.IsValid() && LoadingHandle->HasLoadCompleted()) { // 로드된 에셋들 가져오기 TArray<UObject*> LoadedAssets; LoadingHandle->GetLoadedAssets(LoadedAssets); for (UObject* Asset : LoadedAssets) { ProcessLoadedAsset(Asset); } } } };
05

Primary Asset과 Asset Manager

대규모 에셋 관리

C++
// Primary Asset ID를 사용한 로딩 void UInventorySubsystem::LoadItemAsync(FPrimaryAssetId ItemId) { UAssetManager& AssetManager = UAssetManager::Get(); // 로드할 번들 (메인 에셋 + 관련 에셋) TArray<FName> BundlesToLoad; BundlesToLoad.Add(FName("UI")); // UI 관련 에셋 BundlesToLoad.Add(FName("Gameplay")); // 게임플레이 에셋 // 비동기 로딩 요청 LoadingHandle = AssetManager.LoadPrimaryAsset( ItemId, BundlesToLoad, FStreamableDelegate::CreateUObject( this, &UInventorySubsystem::OnItemLoaded, ItemId ) ); } void UInventorySubsystem::OnItemLoaded(FPrimaryAssetId ItemId) { UAssetManager& AssetManager = UAssetManager::Get(); // 로드된 Primary Asset 가져오기 UItemDataAsset* ItemData = AssetManager.GetPrimaryAssetObject<UItemDataAsset>(ItemId); if (ItemData) { AddItemToInventory(ItemData); } } // 카테고리별 전체 로딩 void UInventorySubsystem::LoadAllWeaponsAsync() { UAssetManager& AssetManager = UAssetManager::Get(); // "Weapon" 타입의 모든 Primary Asset ID 가져오기 TArray<FPrimaryAssetId> WeaponIds; AssetManager.GetPrimaryAssetIdList( FPrimaryAssetType("Weapon"), WeaponIds ); // 모든 무기 에셋 로딩 LoadingHandle = AssetManager.LoadPrimaryAssets( WeaponIds, TArray<FName>(), // 모든 번들 FStreamableDelegate::CreateUObject( this, &UInventorySubsystem::OnAllWeaponsLoaded ) ); }
06

실전 예제: 오픈월드 스트리밍

거리 기반 에셋 로딩

C++
UCLASS() class AStreamableActor : public AActor { GENERATED_BODY() public: // Soft Reference로 에셋 지정 UPROPERTY(EditAnywhere, Category = "Streaming") TSoftObjectPtr<UStaticMesh> HighDetailMesh; UPROPERTY(EditAnywhere, Category = "Streaming") TSoftObjectPtr<UStaticMesh> LowDetailMesh; UPROPERTY(EditAnywhere, Category = "Streaming") float HighDetailDistance = 5000.0f; protected: TSharedPtr<FStreamableHandle> CurrentLoadHandle; bool bHighDetailLoaded = false; virtual void BeginPlay() override { Super::BeginPlay(); // 저해상도 메시 먼저 동기 로드 (가벼움) if (UStaticMesh* LowMesh = LowDetailMesh.LoadSynchronous()) { MeshComponent->SetStaticMesh(LowMesh); } } virtual void Tick(float DeltaTime) override { Super::Tick(DeltaTime); UpdateDetailLevel(); } void UpdateDetailLevel() { APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(this, 0); if (!PlayerPawn) return; float Distance = FVector::Dist( GetActorLocation(), PlayerPawn->GetActorLocation() ); if (Distance < HighDetailDistance && !bHighDetailLoaded) { // 가까워지면 고해상도 로딩 LoadHighDetailMesh(); } else if (Distance > HighDetailDistance * 1.2f && bHighDetailLoaded) { // 멀어지면 저해상도로 복귀 UnloadHighDetailMesh(); } } void LoadHighDetailMesh() { if (HighDetailMesh.IsNull()) return; FStreamableManager& Streamable = UAssetManager::GetStreamableManager(); CurrentLoadHandle = Streamable.RequestAsyncLoad( HighDetailMesh.ToSoftObjectPath(), FStreamableDelegate::CreateLambda([this]() { if (UStaticMesh* HighMesh = HighDetailMesh.Get()) { MeshComponent->SetStaticMesh(HighMesh); bHighDetailLoaded = true; } }) ); } void UnloadHighDetailMesh() { // 저해상도 메시로 교체 if (UStaticMesh* LowMesh = LowDetailMesh.Get()) { MeshComponent->SetStaticMesh(LowMesh); } bHighDetailLoaded = false; // 고해상도 에셋 언로드 요청 (GC가 처리) if (CurrentLoadHandle.IsValid()) { CurrentLoadHandle->ReleaseHandle(); CurrentLoadHandle.Reset(); } } };
07

Lambda 콜백 패턴

간결한 비동기 처리

C++
// Lambda를 사용한 간결한 비동기 로딩 void AMyActor::LoadAndSpawnEnemy(TSoftClassPtr<AEnemy> EnemyClass) { FStreamableManager& Streamable = UAssetManager::GetStreamableManager(); // 클래스 비동기 로딩 Streamable.RequestAsyncLoad( EnemyClass.ToSoftObjectPath(), FStreamableDelegate::CreateLambda([this, EnemyClass]() { // 로드된 클래스로 스폰 if (UClass* LoadedClass = EnemyClass.Get()) { FActorSpawnParameters SpawnParams; GetWorld()->SpawnActor<AEnemy>( LoadedClass, SpawnLocation, FRotator::ZeroRotator, SpawnParams ); } }) ); } // WeakLambda로 안전한 콜백 (Actor가 파괴되어도 안전) void AMyActor::SafeAsyncLoad() { FStreamableManager& Streamable = UAssetManager::GetStreamableManager(); Streamable.RequestAsyncLoad( AssetPath, FStreamableDelegate::CreateWeakLambda(this, [this]() { // this가 유효할 때만 실행됨 // Actor가 파괴되면 콜백이 호출되지 않음 ApplyLoadedAsset(); }) ); }
CreateWeakLambda 권장

비동기 콜백에서는 항상 CreateWeakLambda를 사용하세요. 에셋 로딩 중 Actor가 파괴될 수 있으며, 일반 람다는 댕글링 포인터를 참조할 위험이 있습니다.

SUMMARY

핵심 요약

핵심 포인트
  • TSoftObjectPtr - 에셋 경로만 저장, 필요시 로딩
  • TSoftClassPtr - 클래스 참조용 Soft Reference
  • FStreamableManager - 비동기 로딩 관리자
  • FStreamableHandle - 로딩 상태 추적 및 취소
  • LoadSynchronous - 동기 로딩 (블로킹, 주의 필요)
  • CreateWeakLambda - 안전한 비동기 콜백
오픈월드 에셋 관리 전략
  • 필수 에셋만 Hard Reference로 유지
  • 스트리밍 에셋은 Soft Reference + 비동기 로딩
  • 거리 기반 LOD와 연계하여 메모리 관리
  • Asset Manager의 Primary Asset으로 체계적 관리
PRACTICE

도전 과제

배운 내용을 직접 실습해보세요

실습 1: FStreamableManager 비동기 로딩

FStreamableManager::RequestAsyncLoad()를 사용하여 RPG 장비 메시와 텍스처를 비동기로 로딩하세요. 로딩 완료 콜백에서 컴포넌트에 에셋을 설정하세요.

실습 2: 번들 로딩으로 그룹 에셋 로드

FStreamableManager::RequestAsyncLoad()에 다수의 FSoftObjectPath를 전달하여 한 번에 여러 에셋을 로드하세요. RPG 던전 진입 시 필요한 모든 에셋(몬스터 메시, 환경, UI)을 번들로 로딩하세요.

심화 과제: 커스텀 로딩 화면 시스템

비동기 에셋 로딩 중 진행률을 표시하는 로딩 화면을 구현하세요. GetLoadingProgress()로 진행률을 추적하고, 모든 에셋 로딩 완료 후 게임플레이로 전환하는 완전한 시스템을 구축하세요.