C++를 사용한 언리얼 엔진을 학습하던 중, 오브젝트 생성 시 UObject::CreateDefaultObject, New Object<T>, UWorld::SpawnActor<T> 와같은 각기 다른 호출방식이 있었습니다. 이에 대해 어떠한 상황에서 해당 함수들을 호출하는지 학습하고 기록하고자 이번 포스팅을 하게 되었습니다. 또한, 생성자 호출 시 추가적으로 어떠한 과정이 이루어지는지에 대해 알아보던중 CDO를 알게되어 추가적으로 작성하게 되었습니다.
CDO(Class Default Object)
CDO는 엔진이 실행되며 클래스가 로드 될 때 초기화, 생성되는 인스턴스화 되지 않은 기본값을 가지고 있는 객체입니다.
C++이 제공하지 않는 언리얼만의 추가 기능들을 제공한다.
UClass 클래스에서 추가기능(메타정보)들을 보관한다.
메모리에 한번만 존재하며, 클래스가 로드될 때 자동으로 생성된다.
언리얼 오브젝트를 생성할 때 매번 초기화 하지않고 CDO를 복제해서 사용한다.
객체 생성시 초기값에 대한 일관성 유지 및 재사용으로 인한 생성 비용 감소
CDO 생성 및 기본 프로퍼티 값 확인 예시 코드
// AMyCharacter 클래스의 CDO에 접근
AMyCharacter* DefaultCharacter = AMyCharacter::StaticClass()->GetDefaultObject<AMyCharacter>();
// 기본 프로퍼티 값 확인
float DefaultHealth = DefaultCharacter->Health;
CreateDefaultSubObject
클래스의 서브 객체(Ex : Compoenet)들을 생성하고 초기화 하는데 사용한다.
클래스 생성자에서 호출된다.
BeginPlay()와 같은 생성자가 아닌 곳에서 호출 시 동적할당으로 생성하게되어 에러가 발생한다.
언리얼 엔진에서 메모리를 관리한다.
생성된 객체는 에디터 단계에서 조작할 수 있다.
// Camera Component와 SpringArm Component를 캐릭터의 컴포넌트로 추가
FPSCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
FPSSpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
NewObject
동적으로 객체를 생성할 경우 사용된다.
런타임 단계에서 객체를 생성하고 싶을시에 사용한다.
생성자 단계에서 생성하지 않는 객체들의 생성을 위해 일반적으로 사용된다.
// 런타임 환경에서 객체를 생성하고 싶을 때
// 생성자 이후 객체를 생성 시
UDynamicCreateObject* DCObject = NewObject<UDynamicCreateObject>(this, UDynamicCreateObject::StaticClass());
또한 추가 / 제거 / 삽입하는 함수들의 대부분이 TArray와 동일하기 때문에, 그 외 다른 주요 함수들에 대해 알아보겠습니다.
슬랙 (Slack)
할당된 메모리에 엘리먼트가 없는것
Reset() 또는 Empty를 사용하여 메모리 할당 해제 없이 모든 엘리먼트를 제거하여 슬랙을 만들 수 있다.
TSet을 비우고 엘리먼트 수가 같거나 적은 TSet들을 Append할 시 효율적이다.
정렬 (Sorting)
정렬 이후 배열이 수정된다면, 해당 순서를 보장할 수 없다
Key를 사용해 검색을 해서 시간복잡도가 O(1)이기 때문에 특별한 이유가 없으면 정렬을 하지 않아도 된다.
TMap
std::unordered_map과 유사한 동작을 가지고 있다.
TSet과 같이 해시 기반의 Key를 가지고 있어 검색 및 삽입속도가 빠르다.
Key와 Value 구조로 되어있으며, Key는 중복될 수 없지만, Value는 중복될 수 있다.
TMultiMap은 중복된 키를 저장할 수 있다.
TMap은 동일 Key를 사용하면 기존 것에서 대체되며, TMultiMap은 새로 저장한다.
iterator를 사용하여 모든 키-값을 조회할 수 있다.
데이터를 삭제해도 재구축이 일어나지 않는다
비어있는 데이터가 있을 수 있다.
KeyFuncs
한 유형에 operator== 와 멤버가 아닌 GetTypeHash 오버로드가 있는 한, 그 유형은 변경 없이 TMap 의 키 유형으로 사용해도 됩니다. 하지만 그 함수 오버로드 없이 유형을 키로 사용하고 싶은 경우가 있습니다. 이러한 경우, 별도의 커스텀 KeyFuncs 를 제공해 주면 됩니다. 키 유형에 대해 KeyFunc 를 만들려면, 다음과 같이 두 개의 typedef 및 세 개의 static 함수 정의가 필요합니다:
KeyInitType - 키 전달에 사용됩니다.
ElementInitType - 엘리먼트 전달에 사용됩니다.
KeyInitType GetSetKey(ElementInitType Element) - 엘리먼트의 키를 반환합니다.
bool Matches(KeyInitType A, KeyInitType B) - A 와 B 가 동일하면 true, 아니면 false 를 반환합니다.
uint32 GetKeyHash(KeyInitType Key) - 키의 해시 값을 반환합니다. 보통 외부 GetTypeHash 함수를 호출합니다.
WinAPI환경에서 2D게임 개발을 할 시에는 자료구조가 필요할 시 STL에 있는 자료구조들을 자주 사용했습니다.
언리얼 엔진을 접하면서 STL의 자료구조들과 같이 언리얼 내부에 있는 자료구조들이 있는지에 대해 알아보았는데, 언리얼 컨테이너 라이브러리(Unreal Container Library)에 포함되어있는 TArray, TMap, TSet의 자료구조 외에도 다양한 클래스 및 구조체들이 있었습니다.
효율적인 리소스 사용을 위해서 어떤것들이 더 효율적인지 비교하며 학습한 내용들을 이번 포스팅을 통해 알아보겠습니다.
언리얼 컨테이너 라이브러리(UCL)
언리얼에서 제공하는 대표 컨테이너 라이브러리
언리얼 오브젝트를 안정적으로 지원하며, 다수의 오브젝트 처리에 효율적이다.
UCL의 라이브러리는 가비지컬렉션을 통해 메모리 관리가 가능하다.
대표적인 라이브러리는 TMap, TSet, TArray가 존재한다.
접두사 T는 Template Library를 의미한다.
UCL과 C++의 STL의 차이점
C++ STL은 게임외에도 사용할 수 있도록 범용적으로 설계되어 있다.
STL에는 많은 기능들이 포함되어 있어 컴파일 시 UCL보다 많은 시간이 걸린다.
STL은 런타임 오버헤드가 최소화 되어있으며, UCL은 STL보다 런타임 환경에서 오버헤드가 더 발생할 수 있다.
UCL은 언리얼 오브젝트에 특화되어있다. 즉, 게임 개발이라는 한정된 범위가 존재한다.
UCL은 게임 개발에 적합한 API와 기능들을 가지고 있어, 게임 제작에 최적화된 구조로 설계되어있다.
UCL은 디버거 및 로거와 연동할 수 있어 문제가 발생했을 때 원인을 쉽게 파악할 수 있다.
UCL은 UPROPERTY()를 사용하여 블루프린터와 연동하여 사용할 수 있다.
즉, 언리얼 엔진 환경에서의 게임 개발시에는 UCL을 사용하는것이 더 적합하다고 할 수 있습니다.
TArray
동적 배열(Dynamic Array)클래스이다.
STL의 vector와 동작 원리가 유사하다.
기존 Array의 특징과 같이, 데이터가 순차적으로 모여있기 때문에 메모리를 효과적으로 사용할 수 있다.
임의 데이터를 접근하는데에 있어 빠른 속도를 보장한다. 시간복잡도 O(1)
탐색, 삽입, 삭제시에는 시간복잡도 O(N)이다.
중복된 값을 허용한다.
배열 만들고 채우기
// 정의
TArray<int32> IntArray;
// Init : n의 값을 n번 채운다.
IntArray.Init(10, 5);
// IntArray == {10, 10, 10, 10, 10}
// Add, Emplace : 배열의 가장 마지막 위치에 추가한다. (Dynamic Array이므로 최대 사이즈 고려 X)
IntArray.Add(10); // 값 타입의 경우 해당 자료형의 임시 값을 추가해 복사한다.)
IntArray.Emplace(10); // 임시 변수를 생성하지 않는다.
FString Arr[] = { TEXT("TEXT1"), (TEXT("TEXT2")) };
TArray<FString> StrArray;
// Append : 해당 배열의 크기에 다수의 엘리먼트를 한꺼번에 추가한다.
StrArray.Append(Arr, sizeof(Arr));
// StrArray == {TEXT1, TEXT2};
//Insert : 단일 엘리먼트 또는 엘리먼트 배열 사본을 주어진 인덱스에 추가한다.
StrArray.Insert(TEXT("TEXT3"), 1);
// StrArray == {TEXT1, TEXT3, TEXT2};
정렬 (Sort)
TArray<FString> FStrArray = {TEXT("!"), TEXT("Hello"), TEXT("Apple"), TEXT("Boat"), TEXT("World")};
FStrArray.Sort(); // 안정적이지 않다. FString의 경우 대소문자 구분없이 사전식 비교후 정렬을 실행한다.
// FStrArray == {!, Apple, Boat, Hello, World};
FStrArray.HeapSort(); // 안정적이지 않다. FString의 경우 FString의 길이별로 정렬하지만, 같은 크기의 경우 순서를 보장하지 않는다.
// FStrArray == {!, Boat, Hello, Apple, World};
FStrArray.StableSort(); // Merge Sort형태로 구현되어있다. 순서를 보장한다.(A->Z의 순서 정렬)
// FStrArray == {!, Boat, Apple, Hello, World};
쿼리
TArray<FString> StrArray = { TEXT("!"), TEXT("Hello"), TEXT("Apple"), TEXT("Boat"), TEXT("World") };
// Num : 배열의 Element가 몇개인지 확인하기
int32 Count = StrArray.Num();
// Count == 5
// GetData : 배열 내 Element에 대한 포인터를 반환받는다.
FString* StrPtr = StrArray.GetData();
// Index 별로 개별 접근이 가능하다.
// StrPtr[0] == "!"
// StrPtr[1] == "Hello"
// ...
// StrPtr[5] - undefined behavior
// Element 값 받아오기
FString Elem1 = StrArray[1];
// Elem1 == "Hello"
// IsValidIndex(n) : 특정 인덱스가 유효한지에 대한 검사
bool isValid1 = StrArray.IsValidIndex(1);
bool isValid6 = StrArray.IsValidIndex(6);
// isValid1 == true
// isValid6 == false
// Contains : 배열에 특정 엘리먼트가 들어가있는지 조회하기
bool bContain = StrArray.Contains(TEXT("Hello"));
bool bContain2 = StrArray.Contains(TEXT("NotHello"));
// bContain == true
// bContain == false
제거
TArray<int32> ValArr;
int32 Temp[] = { 10, 20, 30, 5, 10, 15, 20, 25, 30 };
ValArr.Append(Temp, UE_ARRAY_COUNT(Temp));
// Remove : 배열에서 일치하는 엘리먼트를 모두 지운다.
ValArr.Remove(20);
// ValArr == {10,30,5,10,15,25,30}
// RemoveSingle : 배열에서 처음으로 일치하는 엘리먼트를 하나 지운다.
ValArr.RemoveSingle(10);
// ValArr == {10,5,10,15,25,30}
// RemoveAt : 인덱스N의 엘리먼트를 지운다.
ValArr.RemoveAt(2);
// ValArr == {10,5,15,25,30}
// RemoveAll : 일치하는 엘리먼트를 모두 지운다.
ValArr.RemoveAll([](int32 Val) {
return Val % 3 == 0;
});
// ValArr == {10,5,25}
// Empty : 배열에 있는 모든 것을 제거한다.
ValArr.Empty();
// ValArr == []
힙(Heap)
TArray는 이진 힙 데이터 구조체를 지원하는 함수가 존재하며, 이진 트리 유형의 구조로 되어있다.
Heapify 함수룰 사용하여 기존 배열을 Heap으로 변환할 수 있다.
TArray<int32> HeapArr;
for (int32 Val = 10; Val != 0; --Val)
HeapArr.Add(Val);
// HeapArr == [10,9,8,7,6,5,4,3,2,1]
// Heapify : 이진 힙으로 변경시킨다.
HeapArr.Heapify();
// HeapArr == [1,2,4,3,6,5,8,10,7,9]