언리얼에서 에셋을 다룰 때 알아야 하는 중요한 개념인 강 참조와 약 참조에 대해 알아보자.
참조는 크게 2가지 방식으로 강 참조 (Hard Reference)와 약 참조(Soft Reference)가 있다.
- 강 참조 : A오브젝트가 B오브젝트를 직접적으로 참조하여 A로드 시 B가 로드하는 방식
- 약 참조 : 경로(Path) 같은 문자열 형태의 간접 메커니즘을 통해 A오브젝트가 B오브젝트를 간접적으로 참조하여 로드하는 방식
강 참조 (Hard Reference)
강 참조는 객체를 직접적으로 참조하여 가지고 있는다.
강 참조를 통해 객체를 선언하면 해당 객체는 강 참조가 존재하는 동안 메모리에서 제거되지 않는다.
언리얼에서 강 참조를 사용하는 가장 흔한 상황은 언리얼 오브젝트를 선언하는 상황이다.
TObjectPtr을 통한 클래스 멤버 변수는 강 참조를 통한 변수 선언이다.
이는 액터가 로딩될 때 따라서 자동으로 메모리를 할당한다.
UPROPERTY(EditAnywhere)
TObjectPtr<USkeletalMesh> Character;
TSubclassOf을 통해 클래스 변수를 만들고 에디터에서 드롭다운을 통해 생성하려는 클래스를 선택한다.
이 또한, 강 참조로 생성하는 것이다.
UPROPERTY(EditAnywhere)
TSubclassOf<AActor> Weapon;
또, 런 타임에 에셋 경로를 참조하여 로딩하는 방법도 있다.
ATeste::Test()
{
static ConstructorHelpers::FClassFinder<APawn> ThirdPersonClassRef(TEXT("/Game/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.BP_ThirdPersonCharacter_C"));
if (ThirdPersonClassRef.Class)
{
DefaultPawnClass = ThirdPersonClassRef.Class;
}
}
장점과 단점
강 참조의 가장 큰 장점으로는 다른 객체에 쉽게 접근할 수 있다는 점이다.
직접 캐스팅하거나 드롭다운 내에서 객체를 바로 선택하는 것은 정말 쉽고 편하게 사용할 수 있다.
다음으로, 필요한 순간에 에셋을 바로 로드할 수 있다는 점이다.
직접적으로 참조하여 가지고 있기에 에셋이 로딩되었는지 로딩 상태에 대해 걱정할 필요가 없다.
강 참조의 가장 큰 단점으로는 불필요한 메모리 낭비가 심하다는 점이다.
게임을 플레이하는 데 당장 사용되는 에셋 같은 경우에는 강 참조를 유지하는 것이 실제로 괜찮다.
플레이어 캐릭터 같은 경우에는 언제든 항상 사용하여 메모리에 로드되기 때문에 강 참조를 통해 핵심 에셋들을 로딩하는 것은 별 문제가 되지 않는다.
하지만 예를 들어, 메인 메뉴에서 보스 몬스터에게 직접 캐스팅한다고 가정해 보자.
보스 몬스터에 필요한 메모리는 약 1GB 정도 된다고 치자.
그러면, 메인 메뉴에서는 보스 몬스터가 존재해야 할 이유가 없기 때문에 존재하지 않음에도 1GB의 메모리가 낭비될 것이다.
다른 예로, 던전에서 아이템에게 직접 캐스팅한다고 가정해 보자.
아이템은 던전을 클리어하고 보상 상자를 열어야 비로소 스테이지에 스폰이 된다.
그러면, 던전에서는 보상 상자를 열어 아이템이 스폰되기 전까지 모든 아이템의 메모리가 낭비되는 것이다.
이러한 문제가 쌓이면 시작 로딩 시간을 매우 길어지게 할 수 있으며 메모리에 부하를 많이 준다.
이러한 문제는 항상 인지하고 피하려고 노력해야 하는 부분이다.
강 참조의 문제 상황
언리얼에서 강 참조의 문제가 발생하는 가장 흔한 상황은 블루프린트에서의 캐스팅이다.
(기본 액터 블루프린트를 생성하고, 플레이어 캐릭터를 가져와 3인칭 샘플 캐릭터로 캐스팅함)
생성한 테스트 액터를 우클릭하여 Size Map을 통해 해당 에셋이 차지하는 메모리 크기를 보자.
현재 이 액터는 100MB 이상의 메모리를 차지하는 것을 확인할 수 있다.
차지하고 있는 메모리는 거의 전부 캐스팅한 3인칭 캐릭터에서 나온 것이다.
이는 이 테스트 액터가 3인칭 캐릭터의 강 참조를 유지하고 있음을 의미한다.
테스트 액터가 로드되면, 이 3인칭 캐릭터가 당장 필요하지 않더라도 같이 로드된다.
이런 일이 일어나는 이유는 블루프린트에 있다.
A 블루프린트가 B 블루프린트에 대해 접근하기 위해서, 또는 그 안에 변수가 있는 경우 이 캐스팅하는 블루프린트를 먼저 로드해야 하기 때문이다.
이러한 문제를 피하기 위해 블루프린트에서는 캐스팅을 절대 사용하지 말고 항상 인터페이스를 통한 접근만 사용해야 하나?라고 생각할 수 있다.
하지만, 사실 실제 캐스팅 자체의 비용은 전혀 비싸지 않기 때문에 그렇지 않다.
이러한 문제는 비용이 비싼 블루프린트를 대상으로만 캐스팅하기 때문에 발생한다.
캐스팅할 때마다 항상 Native C++ Class 또는 저렴한 상위 블루프린트를 대상으로 캐스팅한다면, 이 문제는 완전히 피해서 개발할 수 있다.
캐스팅 외에도 강 참조의 문제를 일으킬 수 있는 상황은 여러 가지가 있다.
SpawnActor 함수 내에서 클래스 참조를 선택하거나, 인터페이스 함수를 통해 BP 객체 참조를 직접 전달하는 방법이다.
캐스팅을 피하기 위한 방법으로 이런 작업을 수행할 수 있지만, 이도 강 참조의 문제를 야기할 수 있다.
위의 두 가지 방법 외에도 강 참조로 이어질 수 있는 또 다른 상황은 상속이다.
- 만약, 테스트 액터가 메쉬와 텍스쳐를 포함하고 있다고 하자.
- 이를 상속받은 자식은 새로운 메쉬와 새로운 텍스쳐를 포함하고 있다.
- 이로 인해 자식을 로드할 때 부모로부터 모든 자산이 필요하지 않더라도 로드하게 된다.
따라서, 이 부모가 보유한 자산의 수에 따라 추가적인 메모리 낭비가 발생할 수 있다.
정리하면, 강 참조의 문제를 피하는 방법은 다음과 같다.
- 가능한 경우, 캐스팅은 Native C++ Class 또는 저렴한 상위 Class로 한다.
- 인터페이스를 통한 객체 참조 직접 전달은 피한다.
- 가능한 경우, 로직은 최대한 상위 클래스에서만 유지한다.
약 참조 (Soft Reference)
강 참조를 피해 변수를 생성하는 방법은 약 참조가 있다.
약 참조는 강 참조와 달리 객체를 직접적으로 참조하여 가지고 있지 않고, 자신을 가리키는 경로만 가지고 있다.
또한, 기본적으로 선언한다고 바로 로드가 되지 않고, 사용해야 할 때마다 수동으로 로드해야 한다.
기본적인 약 참조 변수 선언 방법으로 TSoftObjectPtr이 있다.
// .h
UStaticMesh* GetLoadMesh();
UPROPERTY(EditDefaultsOnly)
TSoftObjectPtr<UStaticMesh> Mesh;
메쉬가 존재하고, 메쉬가 로드되지 않은 상태이면 수동으로 로드하여 사용한다.
// .cpp
UStaticMesh* ATest::GetLoadMesh()
{
if (Mesh && Mesh.IsPending())
{
Mesh.LoadSynchronous();
return Mesh.Get();
}
}
장점과 단점
약 참조의 장단점을 알아보자.
장점이자 약 참조를 사용하는 가장 큰 이유로, 에셋이 필요할 때만 로드할 수 있다는 점이다.
당장 사용하고 있지 않은 에셋들을 미리 로드하지 않음으로써 로딩 시간 감소와 메모리의 낭비가 줄어들게 된다.
단점으로는 매번 에셋을 수동으로 로드해야 하므로 코드가 복잡해진다.
블루프린트에는 약 참조와 관련된 많은 노드가 있고, C++에도 관련된 많은 함수들이 있기에 이를 각각 맞춰 사용하기가 번거롭기도 하다.
그리고, 매번 에셋을 로드하는 시기도 통제하기 어렵다는 점이다.
약 참조를 통해 에셋을 로드하는 것은 곧바로 로드되는 것이 아니다.
객체에 따라 로딩 시간이 걸리기 때문에 해당 프레임에 객체가 필요하여 로드하면, 몇 프레임이 지나야 로드된다.
캐릭터 클래스, 캐릭터 메쉬, 캐릭터가 사용하는 머티리얼 등 같은 경우는 게임에서 항상 로드되어있어야 하기에 약 참조를 사용하여 얻는 이점이 없어 적합하지 않다.
로딩 확인 및 로딩 (동기 / 비동기)
앞서 설명했듯이, 약 참조로 선언한 객체는 수동으로 로드를 해줘야 한다.
하지만 그전에, 객체가 로드되었는지 확인을 먼저 해야 한다.
객체의 로딩 확인 방법은 세 가지가 있다.
1. IsPending(): 현재 객체가 존재하지 않지만(nullptr), 향후에 존재할 경우 true를 반환
2. IsValid(): 현재 객체가 존재하여 Get()으로 가져올 수 있는 경우 true를 반환
3. IsNull(): 현재 객체가 존재하지 않을 경우(nullptr) true를 반환
객체의 로드 상태를 확인하여 로드되지 않았으면, 객체를 로드해야 한다.
약 참조된 객체를 메모리에 로딩하는 방법은 두 가지가 있다.
- 동기화 로딩 (Synchronous Loading)
- 비동기화 로딩 (Asynchronous Loading)
두 가지 방법 모두 장단점이 있다.
동기화 로딩 장점
- 비동기 로딩이 가능하다.
로딩 함수에서 로딩 객체를 바로 검색할 수 있다.
이 방법을 사용하여 적은 양의 데이터를 로드하는 경우 거의 즉시 로딩된다.
동기화 로딩 단점
- 로딩 프로세스가 게임 스레드를 차단한다.
즉, 약 참조된 객체가 로딩될 때까지 게임이 일지 정지된다는 의미이다.
동기 로딩을 사용하여 많은 양의 데이터를 로드하려 하면 게임이 꽤 오랫동안 멈출 수 있다.
비동기화 로딩 장점
- 로딩 프로세스가 백그라운드에서 이뤄지기 때문에 게임이 일시 정지되지 않는다.
따라서, 많은 양의 데이터를 로딩하기에 적합하다.
비동기화 로딩 단점
- 로딩된 에셋이 갑자기 생긴다.
게임을 플레이하며 에셋이 로딩되는 것이기 때문에, 예를 들어, 플레이어 앞의 전체 레벨을 비동기식으로 로딩하면 화면에 없다가 로딩이 완료되면 갑자기 생기게 된다.
따라서, 천천히 열리는 문을 배치하거나, 컷씬을 배치하여 로딩을 숨길 수 있다.
강 참조와의 메모리 차이
블루프린트에서 약 참조를 통해 3인칭 캐릭터를 불러오고, 강 참조와의 메모리 사용을 비교해 보자.
3인칭 캐릭터의 약 참조 변수를 생성한다.
BeginPlay에서 Async Load Asset 노드를 통해 시작 시 3인칭 캐릭터를 불러와 로드한다.
Size Map을 통해 약 참조를 사용한 액터와 강 참조를 사용한 액터의 메모리 비용 차이를 보자.
메모리 비용 차이가 확연하게 나는 것을 확인할 수 있다.
약 참조를 사용한 액터는 BeginPlay가 호출되어 3인칭 캐릭터를 사용하게 되면 로드되어 강 참조와 같은 메모리를 사용하게 될 것이다.
마치며
게임의 주요 에셋에 대해서는 강 참조를 사용하고, 외의 에셋에 대해서는 약 참조를 사용한다.
중요한 점 중 하나는 강 참조를 사용하는 것이 항상 나쁜 것은 아니라는 점이다.
참조를 사용하며, 참조하고 있는 위치와 이 참조를 왜 사용하고 있는지를 알고 있어야 한다.
'📘Unreal Engine > 📝Unreal Engine' 카테고리의 다른 글
[Unreal Engine] 행동 트리(Behavior Tree) 구조 (0) | 2024.08.19 |
---|---|
[Unreal Engine] 충돌 판정 / 트레이싱 (Tracing) (0) | 2024.08.09 |
[Unreal Engine] 캐릭터 컨트롤 옵션 (0) | 2024.07.25 |
[Unreal Engine] 입력 시스템 / 향상된 입력 시스템 (Enhanced Input System) (0) | 2024.07.24 |
[Unreal Engine] 폰(Pawn)과 캐릭터(Character) (0) | 2024.07.24 |