직렬화
직렬화는 오브젝트나 연결된 오브젝트의 묶음을 바이트 스트림으로 변경화는 과정으로 말한다.
게임 서버에서 패킷을 만들어 낼 때 사용해봤다.
복잡한 데이터를 일렬로 세워서 사용하기 편하게 만드는 것이라고 생각하면 된다.
직렬화를 역으로 하는 것을 역직렬화, Deserialization이라고 한다.
- Object → Byte Stream = 직렬화 (Serialization)
- Byte Stream → Object = 역직렬화 (Deserialization)
직렬화의 장점
- 프로그램의 상태를 저장하고 복원이 가능하다 (게임 Save)
- 객체 정보를 클립보드에 복사해서 다른 프로그램으로 전송가능
- 네트워크를 통해 프로그램의 상태를 복원할 수 있음 (멀티 게임)
- 압축과 암호화를 통해서 데이터를 안전하게 보관이 가능하다
직렬화 구현 시 고려해야 할 점.
직렬화의 경우에는 직접 구현 시, 다양한 상황을 고려해야 한다.
(데이터를 저장을 하고 싶은데, 솔직히 모든 데이터를 마구잡이로 직렬화 시켜버리는 건 효율이 떨어지니까.)
- 데이터 레이아웃 : 오브젝트가 가지고 있는 데이터를 어떻게 변환할 것인가?
- 이식성 : 다른 시스템에 전송해도 이식 가능? → 운영체제 별로 엔디안 기법이 다름.
- 버전 관리 : 새로운 기능이 추가될 때 이를 어떻게 확장할 것인가? (약간 아이템 스폰할 때 아이템이 추가가 된다던가, 갑자기 아이템 별로 원래는 옵션이 크게 없었는데, 메이플 마냥 레어 이런게 붙기 시작한다면? 어지러운데..?)
- 성능 : 네트워크 비용을 줄이기 위해서 어떤 데이터 형식을 사용할 것인가?⇒ 양자화.
- 보안 : 데이터를 어떻게 안전하게 보호할 것인가?
- 에러 처리 : 전송 과정에서 문제가 발생할 경우 어떻게 인식하고 처리할 것인가?
언리얼 엔진의 직렬화 시스템
- 언리얼 엔진은 이런 상황을 모두 고려한 직렬화 시스템을 자체적으로 제공한다.
- 직렬화 시스템을 위해서 제공하는 클래스는 FArchive
- 아카이브 클래스 (FArchive)
- Shift operator를 이용해서 데이터 자체를 바이트 스트림으로 바꿀 수 있다.
- 다양한 아카이브 클래스 제공
- 메모리 아카이브 (FMemoryReader, FMemoryWriter) ⇒ 메모리에 전송하는 방법
- 파일 아카이브 (FArchiveFileReaderGeneric, FArchiveFileWriterGeneric) ⇒ 파일에다가 쓰고 읽는 방법
- 기타 언리얼 오브젝트와 관련된 아카이브 클래스 (FArchiveUObject)
- Json 직렬화 기능 : 별도의 라이브러리를 통해서 제공
일반 구조체 직렬화
FStudentData 라고 하는 UStruct가 아닌 일반 구조체가 있다고 가정한다.
FStudentData RawDataSrc(55, TEXT("김민석"));
임의의 숫자와 이름을 생성해서 구조체를 만들어주고 이를 파일에 저장할 것이다.
파일 경로는 이렇게 설정할 수 있다.
const FString SavedDir = FPaths::Combine(FPlatformMisc::ProjectDir(), TEXT("Saved"));
FPlatformMisc의 ProjectDir은 프로젝트 디렉토리 경로를 가져올 수 있고 거기서 Saved 폴더에 저장할 것이다.
파일을 읽고 쓰기 위해서는 FArchiveFileReaderGeneric, FArchiveFileWriterGeneric을 사용한다고 했는데 기본적으로 FArchive 클래스를 상속받기 때문에 다음과 같이 만들어준다.
const FString RawDataFileName(TEXT("RawData.bin"));
FString RawDataAbsolutePath = FPaths::Combine(*SavedDir, *RawDataFileName);
FArchive* RawFileWriterAr = IFileManager::Get().CreateFileWriter(*RawDataAbsolutePath);
//FileManager라는 인터페이스에서, 파일을 쓸 수 있는 아카이브를 만들어 낸다..
FArchive* RawFileReaderAr = IFileManager::Get().CreateFileReader(*RawDataAbsolutePath);
이제 Archive에 shift연산을 통해 데이터를 밀어넣으면 되는데 이를 위해 구조체에 operator를 override 해준다.
friend FArchive& operator<<(FArchive& Ar, FStudentData& InStudentData) {
Ar << InStudentData.Order;
Ar << InStudentData.Name;
}
if (nullptr != RawFileWriterAr)
{
*RawFileWriterAr << RawDataSrc;
RawFileWriterAr->Close();
delete RawFileWriterAr;
RawFileWriterAr = nullptr;
}
근데 만약에 쓰는 것을 마무리를 했다면 이제 더이상 쓸모가 없어지므로, 무조건 메모리 누수를 방지하기 위해서 삭제를 해주고, Dangling 포인터를 방지하기 위해서 객체의 포인터를 리셋을 해준다.
불러오는것도 똑같다.
FArchive* RawFileReaderAr = IFileManager::Get().CreateFileReader(*RawDataAbsolutePath);
if (nullptr != RawFileReaderAr)
{
*RawFileReaderAr << RawDataDest;
}
<< operator를 불러오기나 저장에 모두 사용한다.
언리얼 오브젝트 직렬화
언리얼 오브젝트는 UObject를 상속 받는 클래스를 의미한다. UObject에는 이미 Serialization이 virtual 함수로 구현되어 있다. 이를 override해서 구현만 해주면 된다.
void UStudent::Serialize(FArchive& Ar)
{
Super::Serialize(Ar);
// 만약 Order와 Name이 UPROPERTY라면 할 밑의 문장은 할 필요 없다 Super 클래스에서 자동으로 매치해준다.
Ar << Order;
Ar << Name;
}
언리얼 오브젝트를 만들고 데이터를 채워보자.
StudentSrc = NewObject<UStudent>();
StudentSrc->SetName(TEXT("김민석"));
StudentSrc->SetOrder(100);
이것도 일반 구조체와 동일한 플로우를 가진다.
이름 지정 -> 쓸 수 있는 아카이브 만들기 -> 쓰기 -> 닫기
읽어오기 -> 읽어온 데이터 넣어주기
메모리에 불러오는 방식을 이용하면 다음과 같다.
TArray<uint8> BufferArray;
FMemoryWriter MemoryWriterAr(BufferArray);
StudentSrc->Serialize(MemoryWriterAr);
바이트 배열을 만들고 거기에 Student의 데이터를 넣어줄 수 있다. 이때 MemoryWriterArchive를 통해 쓰일 버퍼를 지정해 주고 그걸 원하는 언리얼 오브젝트에 Serialize 해주어서 버퍼 안에 데이터를 넣는 것이다.
if (TUniquePtr<FArchive> FileWriterAr = TUniquePtr<FArchive>(IFileManager::Get().CreateFileWriter(*ObjectDataAbsolutePath)))
//생성이 완료가 되어서 지정이 된다면?
{
*FileWriterAr << BufferArray;
//바이트로 보내는 것이기 때문에, 쉽게 밀어 보낼 수 있다.
FileWriterAr->Close();
}
TArray<uint8> BufferArrayFromFile;
if (TUniquePtr<FArchive> FileReaderAr = TUniquePtr<FArchive>(IFileManager::Get().CreateFileReader(*ObjectDataAbsolutePath)))
{
*FileReaderAr << BufferArrayFromFile;
FileReaderAr->Close();
}
FMemoryReader MemoryReaderAr(BufferArrayFromFile);
UStudent* FromStudent = NewObject<UStudent>();
FromStudent->Serialize(MemoryReaderAr);
UE_LOG(LogTemp, Log, TEXT("[Read UObject] Name : %s, Order : %s"), *FromStudent->GetName(), FromStudent->GetOrder());
Json 직렬화
JavaScript Object Notation의 약자로, 웹 환경에서 서버와 클라 사이에 데이터를 주고받을 때, 사용하는 텍스트 기반의 데이터 포멧이다.
장점.
- 텍스트임에도 데이터의 크기가 가볍다
- 읽기가 편해서 데이터를 보고서 이해할 수 있음 (어디에 뭐, 어디에 뭐 이렇게 있음)
- 사실상 웹통신의 표준으로도 널리 사용된다
단점.
- 지원하는 타입이 몇개 안된다
- 텍스트 형식으로만 사용이 가능하다. 숫자 타입의 경우에는 타입이 뭔지 모름
Json을 언리얼에서 사용하고자 한다면 Json JsonUtility를 활용하면 된다.
Json 라이브러리를 사용하기 위해서는 언리얼이 제공하는 스마트 포인터에 대해서 알 필요가 있다.
- TUniquePtr : 지정한 곳에서만 메모리를 관리하는 포인터를 의미한다.
- 특정한 오브젝트에게 명확하게 포인터 해지 권한을 주고 싶은 경우
- delete 구문 없이 자동으로 소멸시키고 싶을 때 사용.
- TSharedPtr : 더 이상 사용하지 않는다면 자동으로 메모리를 해지하는 포인터를 의미한다.
- 여러 로직에서 할당된 오브젝트가 공유해서 사용되는 경우를 의미한다. (만약 외부에서 사용을 딱히 하지 않는다면, 자동으로 해제가 되게끔 동작한다)
- 다른 함수로부터 할당된 오브젝트를 Out으로 받는 경우
- Null일수도 있음.
- TSharedRef : 공유포인터는 동일한데, 유효한 객체를 항사 보장받는 레퍼런스
- 포인터와 다르게 레퍼런스로 사용한다
- Not Null을 보장을 받으며 오브젝트를 편리하게 사용하고 싶은 경우에 사용.
Json으로 읽고 쓰기
#include "JsonObjectConverter.h"
이 헤더가 반드시 필요하다.
TSharedRef<FJsonObject> JsonObjectSrc = MakeShared<FJsonObject>();
이렇게 되면 Null이 아님을 보장을 받게 된다.
FJsonObjectConverter::UStructToJsonObject(StudentSrc->GetClass(), StudentSrc, JsonObjectSrc);
//어찌되었던 UClass 자체가 UStruct를 상속을 받다보니, 이를 활용할 수 있다.
//FJsonObjectConverter::UStructToJsonObject(ClassData, Original Object, TargetJsonObject)
//의 형식으로 되어있는 듯.
FString JsonOutString;
TSharedRef<TJsonWriter<TCHAR>> JsonWriterAr = TJsonWriterFactory<TCHAR>::Create(&JsonOutString);
if (FJsonSerializer::Serialize(JsonObjectSrc, JsonWriterAr))
{
FFileHelper::SaveStringToFile(JsonOutString, *JsonDataAbsolutePath);
}
불러오기는 이렇게 한다.
TSharedRef<TJsonReader<TCHAR>> JsonReaderAr = TJsonReaderFactory<TCHAR>::Create(JsonInString);
Writer와 마찬가지로 다음과 같이 선언을 해주면 된다.
TSharedPtr<FJsonObject> JsonObjectDest;
//읽어 들여서 json자체가 있다고 한다면?
if (FJsonSerializer::Deserialize(JsonReaderAr, JsonObjectDest)) {
UStudent* JsonStudentDest = NewObject<UStudent>();
if (FJsonObjectConverter::JsonObjectToUStruct(JsonObjectDest.ToSharedRef(), JsonStudentDest->GetClass(), JsonStudentDest)) {
//이번에는 내부적으로 데이터가 들어간 것을 기본적으로 요구하고 있는
//클래스의 규격에 맞게, Destination에 보관하는 형태로 진행.
//FJsonObjectConverter::JsonObjectToUStruct(SharedReference Data from Json,
//Class Schema , Targetobject)
//데이터 처리가 성공하였다면 True를 반환하게 될 것임.
PrintStudentInfo(JsonStudentDest, TEXT("JsonData"));
}
//아까는 UStruct to Json Object였다는 것을 알아두자.
}
패키지
직렬화를 이용해서 단일 언리얼 데이터를 저장할 수 있었다. 다만 오브젝트들이 다양하게 있다면?
이때 언리얼 오브젝트 데이터를 효과적으로 관리하는 방법을 통일해야한다.
이미 이런 방법은 언리얼에서 자체적으로 제작되어있다.
패키지라는 단어는 매우 중의적이다.
언리얼 오브젝트를 감싼 포장 오브젝트를 의미하기도 하는 반면
개발된 최종 컨텐츠를 정리해서 하나의 프로그램으로 만드는 작업을 의미하기도 한다.
DLC와 같이 향후 확장 컨텐츠에 사용되는 별도의 데이터 묶음을 의미하기도 한다.
패키지와 에셋
언리얼 오브젝트 패키지는 다수의 언리얼 오브젝트를 포장하는데 사용되는 언리얼 오브젝트를 의미한다. 오브젝트를 저장하려고 만드는 오브젝트인 것이다.
기본적으로 모든 언리얼 오브젝트는 패키지에 소속이 되어있다.(Transient Package)
그리고 이러한 패키지에 저장되어 있는 오브젝트 중에서 가장 대표, 바로 하단에 위치한 것이 Asset이다.
패키지의 구조상으로는 여러 개의 에셋을 저장할수야는 있는데, 일반적으로 하나의 패키지에 하나의 에셋만 저장하고 에셋안에 여러개의 서브 오브젝트를 저장하는 식이다.
데이터 저장
패캐지를 통해 데이터를 저장해보자.
중요한 것은 SaveStudentPackage 함수이다.
UCLASS()
class TASKPROJECT_API UMyGameInstance : public UGameInstance
{
GENERATED_BODY()
public :
UMyGameInstance();
virtual void Init() override;
void SaveStudentPackage() const;
private :
TObjectPtr<class UStudent> StudentSrc;
static const FString PackageName;
static const FString AssetName;
};
cpp 파일에서 static으로 정의된 FString을 선언해주자.
const FString UMyGameInstance::PackageName = TEXT("/Game/{PackageName}");
=> 브라우저에서 직접 보기위한 방법.
패키지 이름에 대한 규칙.
언리얼 프로젝트를 키게 된다면, 고유한 경로를 가지게 되어있음.
바로 /Game이다. 이는 게임에서 사용이 되는 Asset들을 포함하고 있는 폴더를 의미한다.
/Saved의 경우 Temp라는 폴더에 매핑이 되어있다!
이번에는 직접 만들어서 브라우저에서 직접 확인을 해볼 것이기에 /Game으로 지정해보자!
-> 이 다음에 이제 내가 만들려고 하는 패키지 이름을 지정하면 된다
const FString UMyGameInstance::AssetName = TEXT("TopStudent");
이후 브라우저에서 가장 먼저 올라오게 될 에셋을 지정한다.
void UMyGameInstance::SaveStudentPackage() const
{
UPackage* StudentPackage = CreatePackage(*PackageName);
EObjectFlags ObjectFlag = RF_Public | RF_Standalone;
UStudent* TopStudent = NewObject<UStudent>(StudentPackage, UStudent::StaticClass(),*AssetName, ObjectFlag);
TopStudent->SetName(TEXT("김민석"));
TopStudent->SetOrder(100);
for (int32 ix = 1; ix <= NumOfSubs; ++ix)
{
FString SubObjectName = FString::Printf(TEXT("Student%d"), ix);
UStudent* SubStudent = NewObject<UStudent>(TopStudent, UStudent::StaticClass(), *SubObjectName, ObjectFlag);
SubStudent->SetName(FString::Printf(TEXT("제 6층의 망령 %d"), ix));
SubStudent->SetOrder(ix);
}
const FString PackageFileName = FPackageName::LongPackageNameToFilename(PackageName, FPackageName::GetAssetPackageExtension());
저 GetAssetPackageExtension = uasset의 확장자를 의미한다.
PackageName : 프로젝트의 정보를 바탕으로 해서 컨텐츠 폴더를 지정하고 (/Game),
이후에 파일이름을 지정하게 된다 (Student)
그 다음에 .uasset이라는 확장자가 붙게 되어 최종 결과가 나온다.
FSavePackageArgs SaveArgs;
이후에 이거를 지정을 해줄 것인데,
FSavePackageArgs SaveArgs;
SaveArgs.TopLevelFlags = ObjectFlag;
//저장을 하려고 할 때, 패키지 자체를 어떻게 저장할 것인지 보이는 듯하다.
if (UPackage::SavePackage(StudentPackage, nullptr, *PackageFileName, SaveArgs)) {
UE_LOG(LogTemp, Log, TEXT(패키지가 저장이 되었습니다!"));
}
}
이렇게해서 패키지를 생성할 수 있다. 다면 언리얼 5.1을 기준으로 햇을 땐 가능하지만 5.3의 경우 PackageName과 AssetName이 같아야 하고 5.4.4 버전의 경우는 SavePackage를 통해 패키지를 생성할 수 없다. 오직 Factory로만 생성이 가능하기 때문에 이러한 기능이 있구나 정도로만 넘어가면 된다.
에셋 정보의 저장과 로딩 전략
게임 제작 단계에서 에셋간의 연결 작업이 많이 일어나게 되어있다. 이 때, 연결을 지을 때마다 패키지를 부르는 것은 작업 부하가 너무 많이 걸린다.
그래서 언리얼에서는 패키지와 오브젝트를 지정한 문자열을 대체해서 사용한다.
이를 오브젝트 경로라고 하는데 프로젝트 내에서 오브젝트 경로는 유일하다.
결국 오브젝트 경로를 사용하면 다양한 방법으로 에셋을 로딩할 수 있다.
애셋의 로딩 전략.
- 프로젝트에서 애셋이 반드시 필요한 경우 : 생성자에서 로딩을 한다.
- 런타임에서 필요한 때에 바로 로딩하는 경우 : 런타임에서 정적 로딩
- 런타임에서 비동기적으로 로딩을 하는 경우 : 런타임 로직에서 관리자를 사용해 비동기 로딩.
에셋은 이러한 오브젝트 경로를 가진다.
{애셋 클래스 정보}'{패키지 이름}.{에셋이름}'
{패키지이름}.{애셋이름}
요런식으로 해서 끌고 올 수 있다. 여기서 에셋 클래스 정보는 생략이 가능하다.
강참조
1. 직접 프로퍼티 참조
언리얼 오브젝트를 만들 때 타입을 명시적으로 지정을 하는 방법.
2. 생성 시간 참조
강 참조 진행시 해당 오브젝트가 가리키고 있는 애셋을 생성자 코드로 생성하는 방법.
⇒ (근데 엔진 초기화시 생성자가 실행되는데? 그렇게 되면 시작 전에 로드가 되는 거겠군)
약참조
1. 간점 프로퍼티 참조
TSoftObjectPtr을 이용해서 LoadObject 혹시 StaticLoadObject나 FStreamingManager를 사용하여 오브젝트를 로드
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category=Building)
TSoftObjectPtr<UStaticMesh> BaseMesh;
UStaticMesh* GetLazyLoadedMesh()
{
if (BaseMesh.IsPending())
{
const FSoftObjectPath& AssetRef = BaseMesh.ToStringReference();
BaseMesh = Cast< UStaticMesh>(Streamable.SynchronousLoad(AssetRef));
}
return BaseMesh.Get();
}
FStreamingManager는 에셋의 비동기 로딩을 지원하는 관리자 객체이기에 컨텐츠 제작과 무관한 싱글톤 클래스에 생성해주면 좋다.
코드 작성
비교적 쉽게 경로를 통해 오브젝트를 가져올 수 있다.
void UMyGameInstance::LoadStudentObject() const
{
const FString TopSoftObjectPath = FString::Printf(TEXT("%s.%s"), *PackageName, *AssetName);
UStudent* TopStudent = LoadObject<UStudent>(nullptr, *TopSoftObjectPath);
}
생성자에서 로딩해보자.
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"));
}
}
비동기로 로드하는 방법은 다음과 같다.
FStreamableManager StreamableManager;
TSharedPtr<FStreamableHandle> Handle;
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();
}
}
);
'Unreal Engine 5 > 강의' 카테고리의 다른 글
[UE5] 캐릭터 움직임 - 캐릭터 컨트롤에 대해 (0) | 2024.11.29 |
---|---|
[UE5] 언리얼 게임 제작 기초 - C++ 과 캐릭터 입력 시스템 (0) | 2024.11.28 |
[UE5] 언리얼 컨테이너 라이브러리 - Struct와 TMap, 언리얼 메모리 관리 (0) | 2024.11.25 |
[UE5] 언리얼 컨테이너 라이브러리 - TArray, TSet (0) | 2024.11.22 |
[UE5] 언리얼 C++ 설계 - 인터페이스, 컴포지션, 델리게이트 (0) | 2024.11.21 |