개발 이야기 안하는 개발자

언리얼5_1_3 : 언리얼 엔진의 자료구조와 메모리 관리 본문

Unreal/이득우의 언리얼 프로그래밍

언리얼5_1_3 : 언리얼 엔진의 자료구조와 메모리 관리

07e 2024. 2. 16. 22:05
반응형

Unreal Container Library (UCL)

언리얼 엔진이 자체 제작해 제종하는 자료구조 라이브러

C++ STL은 표준이라 범용성이 넓고 호환성이 높다. 하지만 기능이 많아 컴파일 시간이 오래걸린다.

UCL은 언리얼 전용이라 가볍고 게임 제작에 최적화 되어있다.

 

 

 

TArray

https://docs.unrealengine.com/5.1/ko/array-containers-in-unreal-engine/

vector와 비슷하며, 오브젝트를 순서대로 담아 효율적으로 관리하는 용도로 사용.

데이터가 순차적으로 모여있기 때문에 메모리를 효과적으로 사용할 수 있고 캐시 효율이 높다.

데이터의 접근이 빠르고, 고속으로 요소를 순회하는 것이 가능하다.

 

하지만, 중간요소에 추가나 삭제를 하게 되는 작업은 무겁다.

데이터가 많아질수록 무거우며, 많은 수의 데이터 검색 작업이 자주 일어난다면 TSet을 사용하는 것이 낫다.

 

슬랙 - 매번 데이터의 크기를 키우는건 낭비이기 때문에 한번에 여유분을 챙겨오는 기술이다.

	const int32 ArrayNum = 10;
	TArray<int32> Int32Array;

	for (int32 ix = 1; ix <= ArrayNum; ++ix)	//1,2,3,4,5,6,7,8,9,10
	{
		Int32Array.Add(ix);
	}

	Int32Array.RemoveAll(		//1, 3, 5, 7, 9
		[](int32 val)
		{
			return val % 2 == 0;
		}
	);

	Int32Array += {2, 4, 6, 8, 10};	//1,3,5,7,9,2,4,6,8,10

	TArray<int32> Int32ArrayCompare;
	int32 CArray[] = { 1,3,5,7,9,2,4,6,8,10 };
	Int32ArrayCompare.AddUninitialized(ArrayNum); //초기화
	FMemory::Memcpy(Int32ArrayCompare.GetData(), CArray, sizeof(int32) * ArrayNum); //복사

	ensure(Int32Array == Int32ArrayCompare); // 참
    
	int32 SumByAlgo = Algo::Accumulate(Int32Array, 0);	//원소 모두 더하는 알고리즘
	ensure(SumByAlgo == 55); // 참

 

 

 

TSet

https://docs.unrealengine.com/5.1/ko/set-containers-in-unreal-engine/

중복되지 않는 요소로 구성된 집합을 만드는 용도로 사용되며 set과 비슷하지만 구성이 다르다.

해시테이블 형태로 키 데이터가 구축되어 빠른 검색이 가능함. 

동적배열의 형태로 데이터가 모여있어서 비어있는 데이터가 있을 수 있음.

빠르게 순회가 가능함.

STL의 Set과 활용방법이 서로 다르다. unordered_set과 유사하게 동작하지만 동일하진 않음.

	TSet<int32> Int32Set;
	for (int32 ix = 1; ix <= ArrayNum; ++ix)
	{
		Int32Set.Add(ix);
	}

	Int32Set.Remove(2); 
	Int32Set.Remove(4);
	Int32Set.Remove(6);
	Int32Set.Remove(8);
	Int32Set.Remove(10); //1,..,3,..,5,..,7,..,9,..
	Int32Set.Add(2); // 1,..,3,..,5,..,7,..,9,2
	Int32Set.Add(4);
	Int32Set.Add(6);
	Int32Set.Add(8);
	Int32Set.Add(10); //1,10,3,8,5,6,7,4,9,2

TSet에서 Add는 마지막에서 부터 빠져있는 곳에 채워지는 방식으로 진행된다.

 

 

 

언리얼 구조체 (UScriptStruct)

데이터 저장/전송에 특화된 가벼운 객체

GENERATED_BODY()를 선언해야 함.

리플렉션, 직렬화 같은 유용한 기능 지원

UPROPERTY선언은 가능. UFUNCTION은 안됌.

언리얼의 구조체는 F로 시작함.

NewObject는 사용할 수 없음.

대부분 힙 메모리 할당(포인터 연산)없이 스택내 데이터 연산을 함.

 

멤버변수로 컨테이너에서 선언을 할때 언리얼 클래스는 반드시 UPROPERTY를 붙여 관리를 해야하는 반면 언리얼 구조체는 하지 않아도 상관없다.

	TArray<FStudentData> StudentData; //FStudentData는 구조체

	UPROPERTY()
	TArray<TObjectPtr<class UStudent>> Students; //UStudent는 클래스

 

 

구조체 예시

USTRUCT()
struct FStudentData
{
	GENERATED_BODY()
	
	FStudentData()
	{
		Name = TEXT("홍길동");
		Order = -1;
	}

	FStudentData(FString InName, int32 InOrder) : Name(InName), Order(InOrder) {}

	UPROPERTY()
	FString Name;
	UPROPERTY()
	int32 Order;
};
TArray<FStudentData> StudentData;
	const int32 StudentNum = 300;
	for (int32 ix = 1; ix <= StudentNum; ix++)
	{
		StudentData.Emplace(FStudentData(MakeRandomeName(), ix));
	}

	TArray<FString> AllStudentNames;
	Algo::Transform(StudentData, AllStudentNames,
		[](const FStudentData& Val)
		{
			return Val.Name;
		}
	);
	UE_LOG(LogTemp, Log, TEXT("모든 학생 이름의 수 : %d"), AllStudentNames.Num());

	TSet<FString> AllUniqueNames;
	Algo::Transform(StudentData, AllUniqueNames,
		[](const FStudentData& Val)
		{
			return Val.Name;
		}
	);
	UE_LOG(LogTemp, Log, TEXT("중복없는 학생 이름의 수 : %d"), AllUniqueNames.Num());
    
    
    출력(랜덤이라 결과가 매번 바뀜) : 
LogTemp: 모든 학생 이름의 수 : 300
LogTemp: 중복없는 학생 이름의 수 : 64

 

TArray로 정의한 StudentData에 Add가 아닌 Emplace를 사용했다.

Emplace는 데이터를 추가할때 클래스를 새로운 인스턴스로 가져오기 때문에 사용했다.

Algo::Transform 은 데이터를 A를 B로 조건에 맞춰 이동할때 사용 되었다.

 

TSet은 중복을 허용하지 않기때문에 겹쳤던 내용을 제외하고 64개의 데이터가 들어가게 되었다.

 

구조체를 TSet처럼 HashData를 사용해서 넣어야 할 경우 아래와 같은 메소드가 추가로 작성되어야 한다.

	bool operator==(const FStudentData& InOther) const
	{
		return Order == InOther.Order;
	}

	friend FORCEINLINE uint32 GetTypeHash(const FStudentData& InStudentData)
	{
		return GetTypeHash(InStudentData.Order);
	}

 

 

 

 

 

TMap

https://docs.unrealengine.com/5.1/ko/map-containers-in-unreal-engine/

키-벨류 조합의 레코드를 관리하는 용도로 사용되며 map과 비슷하지만 구성이 다르다.

STL map과 set은 이진트리로 구성되어 있음.

TMap은 키-벨류 구성의 튜플 데이터의 TSet 구조로 구현되어 있다.

비어있는 데이터가 있을 수 있따.

중복을 허용하지 않고, 중복을 허용하려면 TMulitMap을 사용하면 된다.

동작 원리는 STL의 unordered_map과 유사하다.

 

	TMap<int32, FString> StudentMap;
	Algo::Transform(StudentData, StudentMap,
		[](const FStudentData& Val)
		{
			return TPair<int32, FString>(Val.Order, Val.Name);
		}
	);
	UE_LOG(LogTemp, Log, TEXT("순번에 따른 학생 맵의 레코드 수 : %d"), StudentMap.Num());

	TMap<FString, int32> StudentMapByUniqueName;
	Algo::Transform(StudentData, StudentMapByUniqueName,
		[](const FStudentData& Val)
		{
			return TPair<FString, int32>(Val.Name, Val.Order);
		}
	);
	UE_LOG(LogTemp, Log, TEXT("이름에 따른 학생 맵의 레코드 수 : %d"), StudentMapByUniqueName.Num());

	TMultiMap<FString, int32> StudentMapByName;
	Algo::Transform(StudentData, StudentMapByName,
		[](const FStudentData& Val)
		{
			return TPair<FString, int32>(Val.Name, Val.Order);
		}
	);
	UE_LOG(LogTemp, Log, TEXT("이름에 따른 학생 멀티맵의 레코드 수 : %d"), StudentMapByName.Num());

	const FString TargetName(TEXT("이혜은"));
	TArray<int32> AllOrders;
	StudentMapByName.MultiFind(TargetName, AllOrders);

	UE_LOG(LogTemp, Log, TEXT("이름이 %s인 학생의 수 : %d"), *TargetName, AllOrders.Num());

    출력:
LogTemp: 순번에 따른 학생 맵의 레코드 수 : 300
LogTemp: 이름에 따른 학생 맵의 레코드 수 : 64
LogTemp: 이름에 따른 학생 멀티맵의 레코드 수 : 300
LogTemp: 이름이 이혜은인 학생의 수 : 6

 

 

 

 

 

 

언리얼 메모리 관리

C++은 저수준으로 메모리 주소에 직접 접근하는 포인터를 사용해서 오브젝트를 관리한다.

그래서 할당과 해지를 꼭 진행해야 한다.

실수로 못했을 경우 메모리 누수(Leak) / 허상 (Dangling) 포인터 / 와일드(Wild) 포인터 와 같은 문제를 야기한다.

 

가비지 컬렉션 시스템

사용하지 않는 오브젝트를 자동으로 감지해서 메모리를 회수하는 시스템이다.

마크-스윕방식으로 가비지 컬렌션 진행한다.

최초 루트 오브젝트를 표기하고, 참조하는 객체를 찾아 마크한다. 마크된 객체로부터 다시 참조하는 객체를 찾아 마크하고, 이를 반복해서 사용하지 않는 녀석들을(마크되지 않는 녀석들) 회수하게 된다(Sweep).

언리얼에선 해당 GC를 3.0초 (디폴트은 60.0) 마다 진행하게 된다.

 

언리얼은 GUObjectArray라는 전역 변수에 관리하는 모든 언리얼 오브젝트의 정보를 담는다.

여기엔 각 플래그가 설정되어 있다.

- Garbage 플래그 : 다른 언리얼 오브젝트로부터 참조가 없어서 회수 예정이다.

- RootSet 플래그 : 참조가 없어도 회수되지 않는 특별한 오브젝트. (컨텐츠 만들땐 권장되지 않음)

 

메모리 누수 문제

- 언리얼 오브젝트 : GC로 자동 해결

- C++ : 신경써야 함. (스마트 포인터 활용)

 

댕글링 포인터 문제

- 언리얼 오브젝트 : 탐지를 위한 함수 제공 : IsValid()

- C++ : 신경써야 함. (스마트 포인터 활용)

 

와일드 포인터 문제

- 언리얼 오브젝트 : UPROPERTY 속성을 지정하면 자동으로 nullptr로 초기화 해줌.

- C++ : 직접 nullptr로 초기화 해야함. (스마트 포인터 활용)

 

C++오브젝트를 GC에 넣기 위해선 FGCObject를 상속받아 AddReferencedObjects를 정의해 주어야 함.

#pragma once

#include "CoreMinimal.h"

/**
 * 
 */
class UNREALMEMORY_API FStudentManager : public FGCObject
{
public:
	FStudentManager(class UStudent* InStudent) : SafeStudent(InStudent) {}

	virtual void AddReferencedObjects(FReferenceCollector& Collector) override;

	virtual FString GetReferencerName() const override;

	const class UStudent* GetStudent() const { return SafeStudent; }

private :
	class UStudent* SafeStudent = nullptr;
};
#include "StudentManager.h"
#include "Student.h"

void FStudentManager::AddReferencedObjects(FReferenceCollector& Collector)
{
	if (SafeStudent->IsValidLowLevel())
	{
		Collector.AddReferencedObject(SafeStudent);
	}
}

FString FStudentManager::GetReferencerName() const
{
	return TEXT("FStudentManager");
}

 

 

언리얼 오브젝트 GC 예시 스크립트

	TObjectPtr<class UStudent> NonPropStudent;

	UPROPERTY()
	TObjectPtr<class UStudent> PropStudent;

	TArray<TObjectPtr<class UStudent>> NonPropStudents;

	UPROPERTY()
	TArray<TObjectPtr<class UStudent>> PropStudents;

	class FStudentManager* StudentManager = nullptr;
void CheckUObjectIsValid(const UObject* InObject, const FString& InTag)
{
	if (InObject->IsValidLowLevel())
	{
		UE_LOG(LogTemp, Log, TEXT("[%s] 유효한 언리얼 오브젝트"), *InTag);
	}
	else
	{
		UE_LOG(LogTemp, Log, TEXT("[%s] 유효하지 않은 언리얼 오브젝트"), *InTag);
	}
}
void CheckUObjectIsNull(const UObject* InObject, const FString& InTag)
{
	if (nullptr == InObject)
	{
		UE_LOG(LogTemp, Log, TEXT("[%s] 널 포인터 언리얼 오브젝트"), *InTag);
	}
	else
	{
		UE_LOG(LogTemp, Log, TEXT("[%s] 널 포인터가 아닌 언리얼 오브젝트"), *InTag);
	}
}

void UMyGameInstance::Init()
{
	Super::Init();

	NonPropStudent = NewObject<UStudent>();
	PropStudent = NewObject<UStudent>();

	NonPropStudents.Add(NewObject<UStudent>());
	PropStudents.Add(NewObject<UStudent>());

	StudentManager = new FStudentManager(NewObject<UStudent>());
}

void UMyGameInstance::Shutdown()
{
	Super::Shutdown();

	const UStudent* UStudentInManager = StudentManager->GetStudent();

	delete StudentManager;
	StudentManager = nullptr;

	CheckUObjectIsNull(UStudentInManager, TEXT("UStudentInManager"));
	CheckUObjectIsValid(UStudentInManager, TEXT("UStudentInManager"));

	CheckUObjectIsNull(NonPropStudent, TEXT("NonPropStudent"));
	CheckUObjectIsValid(NonPropStudent, TEXT("NonPropStudent"));

	CheckUObjectIsNull(PropStudent, TEXT("PropStudent"));
	CheckUObjectIsValid(PropStudent, TEXT("PropStudent"));

	CheckUObjectIsNull(NonPropStudents[0], TEXT("NonPropStudents"));
	CheckUObjectIsValid(NonPropStudents[0], TEXT("NonPropStudents"));

	CheckUObjectIsNull(PropStudents[0], TEXT("PropStudents"));
	CheckUObjectIsValid(PropStudents[0], TEXT("PropStudents"));
}
    
    출력 :
LogTemp: [UStudentInManager] 널 포인터가 아닌 언리얼 오브젝트
LogTemp: [UStudentInManager] 유효한 언리얼 오브젝트
LogTemp: [NonPropStudent] 널 포인터가 아닌 언리얼 오브젝트
LogTemp: [NonPropStudent] 유효하지 않은 언리얼 오브젝트
LogTemp: [PropStudent] 널 포인터가 아닌 언리얼 오브젝트
LogTemp: [PropStudent] 유효한 언리얼 오브젝트
LogTemp: [NonPropStudents] 널 포인터가 아닌 언리얼 오브젝트
LogTemp: [NonPropStudents] 유효하지 않은 언리얼 오브젝트
LogTemp: [PropStudents] 널 포인터가 아닌 언리얼 오브젝트
LogTemp: [PropStudents] 유효한 언리얼 오브젝트

위 스크립트는 Shutdown이라는 언리얼 내장 메소드를 활용한 것이다.

실행이 종료될때 어떻게 호출되는지 확인해보는 내용이다.

 

언리얼 GC가 삭제를 진행했기 때문에 유효하지 않다고 뜨는 것을 볼 수 있다.

해당 삭제한 것들은 어떠한 것도 연결되어 있지 않기 때문에 삭제를 진행한 것이고,

UPROPERTY가 되어 있는 것들은 삭제되지 않은 것을 볼 수 있다.

 

반응형