언리얼 구조체
언리얼 오브젝트는 UCLASS 매크로를 통해 리플렉션을 보장 받았는데, 구조체 라고 못 받을리 없다.
구조체는 UStrurct를 이용해 관련 프로퍼티를 체계화 및 조작할 수 있다.
공식 문서에 적혀져 있는 그대로 가져와 보자.
USTRUCT(BlueprintType)
// 블루 프린트와 호환되는 데이터 성격을 띄게 된다.
struct FMyStruct
{
GENERATED_BODY()
//우리가 흔히 아는 Generate_Body => 리플렉션을 통해서 활용할 수 있도록 뼈대를 제공.
//~ 다음 멤버 변수는 블루프린트 그래프를 통해 액세스할 수 있습니다.
// 테스트 변수에 대한 툴팁입니다.
//리플렉션을 활용해서 조회하거나, 블루프린트에서 동작하게 끔 볼 수 있음.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Test Variables")
int32 MyIntegerMemberVariable;
//C++ 멤버 변수처럼 활용을 해주면된다.
//~ 다음 멤버 변수는 블루프린트 그래프를 통해 액세스할 수 없습니다.
int32 NativeOnlyMemberVariable;
/**~
* 이 UObject 포인터는 블루프린트 그래프에 액세스할 수 없으나
* UE4의 리플렉션, 스마트 포인터, 가비지 컬렉션 시스템에
* 표시됩니다.
*/
//이런 경우에는 언리얼 오브젝트를 받을 수 있는데, 이런 경우에는 무조건 UPROPERTY를 붙여야함.
UPROPERTY()
UObject* SafeObjectPointer;
};
추가 정보.
- UStructs 는 언리얼 엔진의 스마트 포인터 및 가비지 컬렉션 시스템을 사용하여 가비지 컬렉션에 의해 UObjects 가 제거되는 것을 방지할 수 있습니다.
- (UPROPERTY를 이용을 해줘야 하는 이유임 ⇒ 동작 중에 같이 날아가버리면 석이 많이 나가)
- 구조체는 단순한 데이터 타입에 적합하므로 UObjects 와는 다릅니다.(단순한 데이터 타입에만 사용하는 것이 적합함)
- 프로젝트 내부에서 보다 복잡한 인터랙션을 하기 위해서는, 대신 UObject 또는 AActor 서브클래스를 만드는 것이 좋습니다.
- (구조체는 데이터, UObject는 뭔가의 Action 이런 것들을 하는 데 적합함.)
- (C++ 문법상에서는 솔직히 큰 차이가 없지만 언리얼은 다름. 용도 자체가 완전 다르다!)
- UStructs 는 리플리케이션용으로 간주되지 않습니다.
- UProperty 변수는 리플리케이션용으로 간주 됩니다.
- (하지만 내부에 선언하고 있는 변수는 UPROPERTY로 만들어 줄 시 Reflection을 보장 받을 수 있음. 솔직히 구조체를 관리하지 못하면 데이터 관리를 하기가 좀 빡셀 거 같긴 해요.)
- (편리한 기능은 날아가게 된다. 애초에 F로 선언이 되므로 특별하지는 않음.)
- 언리얼 엔진에는 구조체를 위한 Make 및 Break 함수 자동 생성 기능이 있습니다.
- BlueprintType 태그로 아무 UStruct 를 표시합니다.
- UStruct에 하나 이상의 BlueprintReadOnly 또는 BlueprintReadWrite 프로퍼티가 있다면 Break가 표시됩니다.
- Break가 생성한 순수 노드는 BlueprintReadOnly 또는 BlueprintReadWrite 로 태그된 각 프로퍼티에 출력 핀을 하나씩 제공합니다.
(에디터에서 사용하는 편리 기능을 언리얼 구조체 C++를 통해 제공이 가능하다.)
UStruct는 데이터 저장/ 전종세 특화된 가벼운 객체로 다른 일을 할 경우는 Class를 이용하는 것이 좋다.
GENERATED_BODY 매크로를 선언해야하는데 리플렉션, 직렬화 같은 유용한 기능을 지원한다. 이 매크로를 선언하는 순간 구조체는 UScriptStruct 클래스로 구현된다.
그렇다고 리플렉션이 전부 제공되지는 않는다. UFUNCTION은 지원하지 않는다.
언리얼 리플렉션 계층 구조
UStruct는 NewObject API를 제공하지 않는데 그 이유는 위의 사진을 보면 알 수 있다.
클래스를 구성하는 요소들에 대해서 다음과 같은 계층으로 이루어졌다.
⇒ 상위에는 UObject → UField → UStruct → UClass 순
필드에 대한 값은 UField가 관리하도록 되어있다.
⇒ 이를 이용해서 열거형, 구조체가 만들어진다. (Composition으로 활용)
UStruct를 상속받아서, UScriptStruct, UClass가 만들어지는데, UScriptStruct = GENERATED_BODY 매크로가 들어간 특별한 구조체.
UClass는 이제 UStruct의 구조를 상속받아 만들어지고 이제 UFunction을 Composition으로 넣어서 관리한다.
(지금 여기 있는 구조만 봐도 UStruct 자체가 기능을 넣지 못하는 이유를 파악할 수 있다.)
USTRUCT()
struct FStudentData {
//GENERATED_BODY를 이용해서 리플렉션을 보장.
GENERATED_BODY()
//애초에 struct의 기본적인 접근 지시자가 public이기 떄문에, 없어도 public으로 접근 가능
FStudentData() {
}
//UObject가 아니기 때문에, NewObject를 이용해서 생성하는 경우는 없음
//따라서 생성자를 오버로딩할 수 있음.
FStudentData(FString InName, int32 InOrder) : Name(InName), Order(InOrder) {}
//UPROPERTY를 넣어도 되고 안넣어도 되는데, 목적성에 맞게끔 넣기는 해야된다.
//하지만 언리얼 오브젝트 포인터를 넣는 경우에는 무조건 UPROPERTY를 넣어야 한다.
UPROPERTY()
FString Name;
UPROPERTY()
int32 Order;
};
FString MakeRandomName()
{
TCHAR FirstChar[] = TEXT("김이박최");
TCHAR MiddleChar[] = TEXT("상혜지성");
TCHAR LastChar[] = TEXT("수은원연");
TArray<TCHAR> RandArray;
RandArray.SetNum(3);
RandArray[0] = FirstChar[FMath::RandRange(0, 3)];
//언리얼에서 제공을 해주는, FMath::RandRange를 이용해서 랜덤값을 추출한다.
RandArray[1] = MiddleChar[FMath::RandRange(0, 3)];
RandArray[2] = LastChar[FMath::RandRange(0, 3)];
return RandArray.GetData();
}
다음과 같은 함수를 구성하는데, TArray<TCHAR>의 경우에는 TCHAR 배열을 포함하고 있는 컨테이너라,
포인터 값을 넘겨주면 알아서 FString을 만들어 낼 수 있게 된다.
TArray<FString> AllStudentsNames;
Algo::Transform(StudentData, AllStudentsNames, [](const FStudentData& Val) {
return Val.Name;
});
UE_LOG(LogTemp, Log, TEXT("모든 학생의 이름 수 : %d"), AllStudentsNames.Num());
TSet<FString> AllUniqueNames;
Algo::Transform(StudentData, AllUniqueNames, [](const FStudentData& Val) {
return Val.Name;
});
UE_LOG(LogTemp, Log, TEXT("중복 없는 학생의 이름 수 : %d"), AllUniqueNames.Num());
이렇게 사용이 가능하다.
TMap
TMap은 역시 STL Map과 유사하다면 유사하지만 동작방식은 unordered_map과 비슷하다.
STL map vs TMap
STL map
- STL map은 STL set과 동일하게 이진 트리로 구성이 되어있다.
- 정렬은 지원하는데 메모리 구성이 효율적이지 않음
- 데이터 삭제 시 재구축 (균형 이진 트리의 전형적 특징)
- 모든 자료를 순회하는데 적합하지는 않다. ⇒ 트리를 타고 가야 하므로..
TMap
- TSet을 그냥 구현한 것 ⇒ 키 밸류 구성의 튜플 구조임.
- 내부구조가 TSet이랑 동일함
- 해시테이블의 형태로 구축이 되어있다보니까 빠르게 검색이 가능함.
- 동적 배열의 형태
- 삭제해도 재구축 X ⇒ 애초에 해시테이블 구조라서..
- 중복은 허용하지 않지만, 중복을 허용하고 싶으면, TMultiMap을 사용한다.
동작원리는 STL unordered_map과 유사하다.
키 밸류 쌍이 필요한 자료구조에 광범위하게 사용된다.
TSet과 구조적인 부분에서 동일하지만, 내부적으로 저장되는 데이터의 경우에는 key, Value의 튜플로 저장이된다.
데이터를 TPari<Key, Valeu>로 저장하게 된다.
특징
이것도 동질성 컨테이너임, 처음에 TMap을 만들어 내었을 때, Pair의 형태를 지정하고 해당 형태를 기준으로 동일한 것만 우중충 넣기 때문에, 빠르게 탐색이 가능함.
TSet과 동일한 해시 컨테이너라서 Key 유형에는 GetTypeHash를 지원하고, 키의 동일성을 비교하기 위한 operator==을 제공해야 한다.
TSet과 유사하고, Index를 기반으로 찾는 것도 가능하고, Contains로 내부에 데이터 정보가 존재하는 지 여부를 물어서 찾는 거도 가능한데, 동작이 많아서 Find로 찾는 것도 지원을 함.
⇒ nullptr 체크.
인덱스를 찾아서 만약에 존재하면 내부에 존재하는 값을 뱉어주고, 만약에 없다면 데이터를 만들어주는 FindOrAdd가 있음.
근데 좋은 방법은 아닌 듯..? ⇒ 기능에 대해서는 한 가지만 명확하게 제공하는 것이 좋으니까.
FindKey의 경우에는 Value를 기반으로 해서, Key를 찾는 것인데, Value의 경우에는 Hash함수가 없다보니, 최악의 경우에는 전탐색을 거칠 수도 있음.
(성능이 별로…)
Key정보 Value 정보를 Array로 가져오는 방법이 있음. ⇒ 매우 유용함.
TMap<int32, FString> StudentMap;
Algo::Transform(StudentData, StudentMap, [](const FStudentData& Val) {
return TPair<int32, FString>(Val.Order, Val.Name);
});
이제 마찬가지로 Transform을 이용해서 넣어줄 것인데, StudentMap에 Lambda를 이용해서
Pair의 정보를 넣어주면 된다.
Algo::Transform(StudentData, StudentMapByUniqueName, [](const FStudentData& Val) {
return TPair<FString, int32>(Val.Name, Val.Order);
});
UE_LOG(LogTemp, Log, TEXT("이름 따른 학생 맵의 레코드 수 : %d"), StudentMapByUniqueName.Num());
const FString TargetName(TEXT("이혜은"));
TArray<int32> AllOrder;
StudentMapByName.MultiFind(TargetName, AllOrder);
//TargetName을 가진 레코드를 AllOrder에 넣어주는 함수를 실행한다.
구조체를 Map과 Set 등에서 사용하려면 커스텀을 해줘야한다. GetTypeHash가 지정되어 있지 않기 때문이다.
bool operator==(const FStudentData& InOther) const {
return Order == InOther.Order;
}
friend FORCEINLINE uint32 GetTypeHash(const FStudentData& InStudentData) {
return GetTypeHash(InStudentData.Order);
}
이제 이거를 만들어 줄 것인데, friend를 이용해서 타 클래스의 private과 protected에 접근을 하자.
그래서 Order을 기반으로 해시를 만들어 내면?
이렇게 할경우 제대로 컴파일이 진행된다.
TArray = 빈틈 없고, 높은 접근 성능 + 순회 성능 (검색 삽입 삭제가 부하가 있고 느림.)
(간편하면서도 효율적이다. 많이 사용된다)
TMap = Key와 Value의 쌍으로 있다보니까, 중복을 허용하지 않고 그대로 가져오게 된다.
중복을 허용하고 싶다면, TMultiMap,
만약에 Key Value가 아니라 그냥 중복처리를 해준 것을 사용하고 싶다면 TSet을 사용하자.
언리얼 메모리 관리
C++ 메모리 관리의 문제점
- C++은 저수준으로 메모리 주소에 직접 접근하는 포인터를 이용해서 관리함.
- 그렇다보니까 new delete를 짝을 해줘야 함. ⇒ 안해주면 Memory Leak.. 프로그램 BOOM
- 이거 못지키면 문제가 많음.
- 잘못된 포인터 사용 예시
- 메모리 누수 : new 하고 반환을 안해준 경우 (도서관에서 책 빌리고 반납 안 한 경우)
- Heap에 그대로 남아 있음.
- 허상 포인터 : 이미 해제한 것을 또 가리키고 있는 것 (책 반납했는데, 난 빌리고 있는 줄)
- 와일드 포인터 : 값이 초기화되지 않아서 엉뚱한 주소를 가리킨다.
- 잘못된 포인터는 다양한 문제를 일으키며 한번의 실수는 프로그램을 종료시킨다.안꺼지고 클라에 영향이 간다면?
- 게임 규모가 커지고 복잡해지는 만큼 프로그래머가 실수할 확률이 증가한다.
그래서.
Java랑 C#은 이런 문제를 고치기 위해서 포인터를 버리고 가비지 컬렉션을 도입.
GC(가비지 컬렉션)
- 프로그램에서 더이상 사용하지 않는다고 생각하면 알아서 메모리를 회수하는 시스템.
- (알기로는 프로그램인걸로 알고 있음 이거도)
- 동적으로 생성된 모든 오브젝트의 정보를 모아둔 저장소를 사용하여 사용되지 않는 메모리를 추적한다.
- 마크 스윕의 방식.
- 저장소에서 최초 검색을 시작하는 루트 오브젝트를 표시한다
- 루트 오브젝트가 참조하는 객체를 찾아 마크한다.
- 마크된 객체로부터 다시 참조하는 객체를 찾아 마크하고 이를 계속 반복한다
- 이제 저장소에는 마크된 객체와 마크되지 않은 객체의 두 그룹으로 나뉜다.
- 가비지 컬렉터가 저장소에서 마크되지 않은 객체들의 메모리를 회수한다. (Sweep)
언리얼의 경우 마크-스윕 방식으로 GC를 구현했다.
지정된 주기마다 몰아서 없애는 방식으로 동작하는데 이 시간을 GCCycle이라고 한다. 기본적으로는 60초로 저장되어있다. 어찌됐든 GC도 프로그램의 일종이라 동작의 부하가 조금 있다. 그래서 언리얼에서는 성능향상을 위해서 병렬처리 클러스터링 같은 기능을 탑재한다.
GUObjectArray
관리되는 모든 언리얼 오브젝트의 정보를 저장하는 전역변수이다. 언리얼 엔진이 활성화 되면 누구나 이 배열에 접근할 수 있다.
GUObjectArray는 Flag가 설정되어 있는데 두 가지가 존재한다.
- Garbage 플래그 : 다른 언리얼 오브젝트로부터의 참조가 없어 회수 예정인 오브젝트
- RootSet 플래그 : 다른 언리얼 오브젝트로부터 참조가 없어도 회수하지 않는 특별한 오브젝트. (매니저 이런거?)
GC가 이러한 플래그들을 보고 회수를 할지 말지 결정하게 된다.
가비지 컬렉터의 메모리 회수법
GC는 지정된 시간(기본 60초)에 따라서 주기적으로 메모리를 회수 하는데 Garbage 플래그로 설정된 오브젝트들을 파악하고 안전하게 메모리를 회수한다. 우리는 오브젝트를 관리할 때, Garbage 플래기를 수동으로 설정하지 않아도 시스템이 알아서 설정하게 된다.
일일히 접근해서 플래그를 변경하지 않아도 되는데 알아두어야하는 점은 한번 생성된 언리얼 오브젝트는 바로 삭제가 되지 않는다는 것이다. new delete를 하는 방식이 아니라, 레퍼런스 정보를 없앰으로 자동으로 회수하는 것이기 때문이다.
가령 내가 어떤 언리얼 오브젝트를 nullptr로 설정하면 바로 삭제되는게 아니라 GCCycle 이 됐을 때 삭제가 되기 때문에 그 사이에 잠깐의 틈이 생길 수 있다.
언리얼 GC의 장점.
아까 문제가 되었던 곳이 모두 해결 된다.
- 메모리 누수 문제
- GC를 통해서 자동으로 삭제가 되므로 해결이 된다.
- 다만 C++ 오브젝트는 직접 신경 써야 한다. (언리얼 오브젝트에 한함) (스마트 포인터 사용)
- 댕글링 포인터 문제
- 언리얼 오브젝트는 이를 탐지하기 위한 함수를 제공한다 ::IsValid() ⇒ 사용 가능?
- C++ 오브젝트는 직접 신경 써야 한다. (스마트 포인터 사용)
- 와일드 포인터 문제
- UPROPERTY를 속성하면 지 알아서 nullptr로 지정해줌.
- C++ 오브젝트의 경우에는 손수 초기화를 해줘야 함.
아무래도 언리얼 오브젝트, C++ 오브젝트에 대해서 서로 대응이 다르다보니 실수가 잦다.
회수를 하지 않기 위해서는 다음과 같이 지정해야한다.
- 언리얼 엔진을 참조를 설정한 언리얼 오브젝트
- UPROPERTY로 참조가 된 언리얼 오브젝트 (대다수가 이거를 사용한다)
- AddReferencedObject 함수를 통해 참조를 설정한 언리얼 오브젝트(UPROPRERTY를 사용하지 않는 경우라고 한다면.. 이걸 사용하는데, 잘은 사용 안함.)
- RootSet으로 지정한 언리얼 오브젝트 ⇒ 오브젝트가 중요한 Case.
UClass가 아닌 일반 클래스에서 UObject를 관리해야한다면?
UPROPERTY를 사용하지 못하는 일반 C++에서 언리얼 오브젝트를 관리를 해야 한다면,
(일반 클래스에 언리얼 오브젝트가 멤버변수로 들어간 경우)
FGCObject 클래스를 상속 받은 후, AddReferencedObjects 함수를 구현한다.
함수 구현 부에서 관리할 언리얼 오브젝트를 추가해 준다.
이러한 경우는 진짜 발생하지 않을 가능성이 높다.
언리얼 오브젝트 관리 원칙
- 생성된 언리얼 오브젝트를 유지하기 위해서 레퍼런스 참조 방법을 설계할 것.
- 언리얼 오브젝트 안에 언리얼 오브젝트라면 UPROPERTY를 사용
- 일반 C++ 오브젝트 내의 언리얼 오브젝트라면 FGCObject 상속할 것.
- (AddReferencedObjects를 선언하자)
- 생성된 언리얼 오브젝트는 강제로 지우지 말 것 ⇒ 생성하고 GC에게 맞긴다.
- 참조를 끊는다 생각하자
- GC에게 회수를 재촉할 수는 있음 (ForceGarbageCollection)
- 콘텐츠에서 Destroy는 할 수 있는데, 바로 삭제는 안되는 것임. (내부 동작 동일) (Actor 제거하고 싶을 때 이거 활용이 가능 ⇒ 플래그 설정 후 회수하게끔 동작)
'Unreal Engine 5 > 강의' 카테고리의 다른 글
[UE5] 언리얼 게임 제작 기초 - C++ 과 캐릭터 입력 시스템 (0) | 2024.11.28 |
---|---|
[UE5] 언리얼 오브젝트 관리- 직렬화, 패키지 (0) | 2024.11.27 |
[UE5] 언리얼 컨테이너 라이브러리 - TArray, TSet (0) | 2024.11.22 |
[UE5] 언리얼 C++ 설계 - 인터페이스, 컴포지션, 델리게이트 (0) | 2024.11.21 |
[UE5] 언리얼 오브젝트의 이해 - 리플렉션 시스템 (3) | 2024.11.20 |