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()로 진행률을 추적하고, 모든 에셋 로딩 완료 후 게임플레이로 전환하는 완전한 시스템을 구축하세요.