언리얼에서 패키징은 다양한 의미를 갖는다.
등의 의미를 갖는다.
이번에 알아본 것은 1번, 언리얼 오브젝트를 감싸는 오브젝트로써의 패키지이다.
사실 지금까지 써온 모든 언리얼 오브젝트들은 Transient라는 패키지에 속해있다.
패키지에는 애셋이라는 서브 오브젝트가 존재하고 그 하위에 또 다양한 서브 오브젝트들이 있다.
언리얼 에디터에 노출되는 친구들은 이 애셋이라는 친구들이다.
일반적으로 한 패키지에는 한 애셋이 존재한다고 한다.
void UMyGameInstance::SaveStudentPackage() const
{
/*패키지를 저장하기 전에 일단 로드를 해주는 것이 안전한 방법이라고 한다.
이미 존재할 수도 있는 데이터를 메모리로 가져와 현재 상태를 반영한 작업을 할 수 있다.
로드된 패키지는 다른 객체와의 참조 관계를 포함하는데,
이 때 새 객체를 생성하면 참조가 손상될 수도 있다.
*/
UPackage* StudentPackage = ::LoadPackage(nullptr, *PackageName, LOAD_None);
if (StudentPackage)
{
StudentPackage->FullyLoad();
}
//패키지 생성
StudentPackage = CreatePackage(*PackageName);
EObjectFlags ObjectFlag = RF_Public | RF_Standalone;
//서브 오브젝트(애셋) 생성
UStudent* TopStudent = NewObject<UStudent>(StudentPackage, UStudent::StaticClass(), *AssetName, ObjectFlag);
TopStudent->SetName(TEXT("너굴"));
TopStudent->SetOrder(42);
//애셋의 서브 오브젝트를 생성
const int32 NumofSubs = 10;
for (int32 ix = 1; ix <= NumofSubs; ++ix)
{
FString SubObjectName = FString::Printf(TEXT("Strudent%d"), ix);
UStudent* SubStudent = NewObject<UStudent>(TopStudent, UStudent::StaticClass(), *SubObjectName, ObjectFlag);
SubStudent->SetName(FString::Printf(TEXT("학생%d"), ix));
SubStudent->SetOrder(ix);
}
//패키지 파일 이름과 확장자 및 플래그를 설정
const FString PackageFileName = FPackageName::LongPackageNameToFilename(PackageName, FPackageName::GetAssetPackageExtension());
FSavePackageArgs SaveArgs;
SaveArgs.TopLevelFlags = ObjectFlag;
if (UPackage::SavePackage(StudentPackage, nullptr, *PackageFileName, SaveArgs))
{
UE_LOG(LogTemp, Display, TEXT("패키지가 성공적으로 저장되었습니다."));
}
}
오. 애셋이 생성되었다.
이제 이 애셋을 한번 로드 해보자
void UMyGameInstance::LoadStudentPackage() const
{
//패키지 로드
UPackage* StudentPackage = ::LoadPackage(nullptr, *PackageName, LOAD_None);
if (nullptr == StudentPackage)
{
UE_LOG(LogTemp, Display, TEXT("패키지를 찾을 수 없습니다."));
return;
}
StudentPackage->FullyLoad();
//패키지에서 애셋을 찾아 TopStudent에 저장 후 출력 해보기
UStudent* TopStudent = FindObject<UStudent>(StudentPackage, *AssetName);
PrintStudentInfo(TopStudent, TEXT("FindObject"));
}
잘 되는 것을 보니 기분이 좋다.
에디터에서 애셋 간의 연결 작업을 할 일이 많은데 이럴 때마다 직접 패키지를 불러서 할당하기에는 부하가 크다.
그래서 무조건 로딩을 한다기 보다는 일단 해당 애셋이나 패키지의 경로를 갖고 있다고 생각하면 편하다. (오브젝트 경로)
오브젝트 경로는 프로젝트 내에서 유일한 값이기에 키 값으로 쓰일 수 있게 된다.
->오브젝트 경로로 연결을 가능하게 한다.
오브젝트 경로: {패키지 이름}.{애셋이름}
로딩할 경우에는 크게 세가지가 있다.
공식 문서에서 자세히 알아볼 수 있다.
문서에서는 크게 4가지로 나누고 있다.
가장 기본적인 경우이며 UPROPERTY 매크로를 통해 노출되는 프로퍼티이다.
로드 해야할 애셋이 명확한 경우에 그 프로퍼티를 오브젝트 생성자에서 설정해주는 경우이다.
ConstructorHelpers
같은 특수 클래스가 사용된다.
해당 애셋을 찾을 수 없는 경우에는 nullptr
이 반환되고 이를 참조하려 한다면 크래시가 뜨기 때문에 조심해야한다.
애셋 로드 시점을 쉽게 제어할 수 있는 방법이다.
TSoftObjectPtr
를 사용하고 LoadObject<>()
이나 StaticLoadObject()
,FStreamingManager
를 사용해서 오브젝트를 로드할 수 있다.
LoadObject
같은 경우에는 애셋을 동기식으로 로드한다. 그러므로 호출 시점에 따라 프레임이 출렁일 수 있으니 사용 시 조심해야한다.
FStreamingManager
는 비동기 로딩을 지원한다.
이미 생성, 로드 된 UObject를 사용하는 경우에 FindObject<>()
아직 로드되지 않은 오브젝트를 로드하려면 LoadObject<>()
를 사용한다.
패키지로써 저장하고 로드하는 것은 해보았으니 이번에는 LoadObject
로 오브젝트를 불러와보자.
void UMyGameInstance::LoadStudentObject() const
{
//{패키지 이름}.{애셋 이름}으로 오브젝트 경로를 설정한다. 위에서 사용했던 내용이 있기 때문에 그대로 썼다.
const FString TopSoftObjectPath = FString::Printf(TEXT("%s.%s"), *PackageName, *AssetName);
//오브젝트 경로를 통해서 오브젝트를 로딩한다. 패키지 로딩이 아니기 때문에 첫 인자는 nullptr로 설정해준다!
UStudent* TopStudent = LoadObject<UStudent>(nullptr, *TopSoftObjectPath);
PrintStudentInfo(TopStudent, TEXT("LoadObject Asset"));
}
그 다음 이 함수를 실행시켜 보면?
성공!
이번엔 생성자에서 한번 로딩을 해보자.
그렇다면 이 애셋은 게임이 시작하기 전에 이미 메모리에 올라와있다는 것을 뜻할 것이다.
생성자에서 애셋을 로딩할 때는 LoadObject
를 사용하는 것이 아니라.
ConstructorHelpers
라는 클래스를 사용하게 된다.
UMyGameInstance::UMyGameInstance()
{
const FString TopSoftObjectPath = FString::Printf(TEXT("%s.%s"), *PackageName, *AssetName);
static ConstructorHelpers::FObjectFinder<UStudent> UASSET_TopStudent(*TopSoftObjectPath);
if (UASSET_TopStudent.Succeeded())
{
PrintStudentInfo(UASSET_TopStudent.Object, TEXT("Constructor"));
}
}
한번 실행해보면,
Constructor 태그가 붙은 로그가 제일 먼저 찍혀있는 것을 볼 수 있다.
당연하다. 생성자에 있는 함수였으니까
근데 왜 두번 실행이 되는가?가 문제인데
이는 에디터가 로딩될 때 생성자들을 모두 호출하면서 찍힌 것이고
두번째는 에디터 내에 게임이 실행될 때 또 생성자에 관련된 함수들이 자동으로 호출되므로 두 번 찍히게 된다.
생성자 코드에서 애셋을 로딩할 경우 해당 애셋이 반드시 존재해야하며
존재하지 않는다면 무시무시한 일이..
이번에는 비동기로딩이다. FStreamableManager
라는 클래스와 FStreamableHandle
을 사용하게 된다.
//헤더
#include "Engine/StreamableManager.h"
FStreamableManager StreamableManager;
TSharedPtr<FStreamableHandle> Handle;
//구현부
void UMyGameInstance::LoadStudnetAsync()
{
const FString TopSoftObjectPath = FString::Printf(TEXT("%s.%s"), *PackageName, *AssetName);
//핸들을 통해 비동기 로딩을 리퀘스트하고 람다함수를 넣어줄 수 있다.
Handle = StreamableManager.RequestAsyncLoad(TopSoftObjectPath,
[&]()
{
if (Handle.IsValid() && Handle->HasLoadCompleted())
{
UStudent* TopStudent = Cast<UStudent>(Handle->GetLoadedAsset());
if (TopStudent)
{
PrintStudentInfo(TopStudent, TEXT("AsyncLoad"));
Handle->ReleaseHandle();
Handle.Reset();
}
}
});
}
이를 실행해보면?
야호~
이렇게 애셋을 저장하고 로딩하는 다양한 방법에 대해 알아보았다.
사실 프로젝트를 진행하며 이미 계속 쓰고 있는 방법이지만 ConstructorHelpers
와 LoadObject
가 뭐가 다른건지는 정확히 알고 쓰지는 않아서 이제 보니 큰일이 날 뻔 했다.
사실 데모 패키징할 때 게임에서 fatal error의 등장에 정신이 나가버릴 뻔 하기도 했지만..
애셋 저장과 로딩에 대해 몰랐다면 이제라도 제대로 알고 넘어가는 것이 좋지 않을까!