영상을 불러오지 못했습니다

Unreal Engine의 C++ Cast<T> 함수는 어떻게 동작하는가?

2578 단어
13 분
Unreal Engine의 C++ Cast<T> 함수는 어떻게 동작하는가?

Unreal C++에서 UObject*를 구체적인 type으로 확인할 때는 보통 dynamic_cast가 아니라 Cast<T>를 사용한다.

UObject* Candidate = GetCandidateObject();
if (AMyActor* Actor = Cast<AMyActor>(Candidate))
{
Actor->ActivateFeature();
}

둘 다 실패 가능한 runtime cast처럼 보이지만 type information의 출처가 다르다.

  • 표준 dynamic_cast: compiler가 생성한 C++ RTTI와 polymorphic class hierarchy 사용
  • Unreal Cast<T>: UClass, UStruct, interface table 같은 Unreal reflection metadata 사용

그래서 native class와 Blueprint-generated subclass를 같은 UObject hierarchy로 판별하면서도 C++ RTTI를 요구하지 않는다. 다만 Cast는 type 관계만 검사한다. raw pointer가 pending kill인지, GC lifetime이 유효한지, gameplay state가 사용할 수 있는지는 별도 문제다.

아래 구현 설명은 Unreal Engine 5의 Casts.h를 기준으로 한다. Unreal Engine 4의 내부 dispatcher는 다르므로 사용하는 engine branch의 source와 함께 읽어야 한다.

먼저 API를 고르는 기준#

의도API실패 의미와 주의점
compile time에 보장된 derived→base upcast일반 implicit conversioncast가 필요 없음
mismatch가 정상적으로 가능한 UObject 입력Cast<T> + null 검사null 입력이나 불일치면 null
성공이 programmer invariantCastChecked<T>check 설정에 따라 type 검사가 제거될 수 있어 외부 입력 검증용이 아님
subclass를 허용하지 않고 정확한 runtime UClassExactCast<T>subclass면 null
pointer가 필요 없고 관계만 확인Object && Object->IsA<T>()raw null object에서 member를 호출하지 않음
Blueprint 구현까지 가능한 Unreal interface 호출ImplementsInterface + I...::Execute_...Blueprint-only 구현은 native interface pointer가 없음
native C++ interface pointer가 반드시 필요Cast<I...>Blueprint-only 구현에는 null일 수 있음
FField·FProperty hierarchyCastField<T>UObject Cast와 다른 hierarchy
UObject가 아닌 native C++ hierarchy표준 C++ castchecked downcast에는 RTTI/ABI 조건 필요

raw pointer가 gameplay 관점에서도 살아 있어야 한다면 먼저 IsValid(Object)를 확인한다. Cast가 non-null을 반환했다는 사실만으로 destroy 예정 object까지 사용할 수 있다는 뜻은 아니다.

UE5 Cast<T>의 큰 흐름#

Cast<T>는 source와 destination이 완전한 type인지 compile time에 확인한 뒤, null과 Unreal interface 여부, cast flag 사용 가능 여부를 차례로 나눈다.

아니요

object 없음

object 있음

아니요

아니요

아니요

아니요

성공

성공

성공

성공

실패

실패

실패

실패

Cast(Src)

Src가 null인가?

nullptr 반환

From이 IInterface인가?

_getUObject()

To가 interface인가?

GetInterfaceAddress

IsA 검사

cast flag 경로가 활성인가?

HasAnyCastFlag 검사

To가 interface인가?

IsA 검사

target pointer 반환

아니요

object 없음

object 있음

아니요

아니요

아니요

아니요

성공

성공

성공

성공

실패

실패

실패

실패

Cast(Src)

Src가 null인가?

nullptr 반환

From이 IInterface인가?

_getUObject()

To가 interface인가?

GetInterfaceAddress

IsA 검사

cast flag 경로가 활성인가?

HasAnyCastFlag 검사

To가 interface인가?

IsA 검사

target pointer 반환

Source가 Unreal interface인 경우#

FromIInterface 계열이면 먼저 _getUObject()로 owning UObject를 찾는다.

  • To도 interface면 GetInterfaceAddress로 native interface 주소를 찾는다.
  • UObject target이면 IsA<To>()로 class 관계를 검사한다.

owning object를 얻지 못하거나 관계가 맞지 않으면 null이다.

Source가 UObject 계열인 경우#

UObject 계열에서는 다음 세 경로 중 compile-time 설정과 target type에 맞는 경로를 사용한다.

  • common engine type에 대한 cast flag bit test
  • Unreal interface table에서 native interface 주소 검색
  • IsA<To>()를 통한 UClass/UStruct hierarchy 검사

public Cast<T>의 의미는 같아도 이 분기를 구성하는 helper와 macro는 engine branch에 따라 달라질 수 있다.

EClassCastFlags는 모든 class의 고유 ID가 아니다#

UClass::ClassCastFlags는 common engine type cast를 빠르게 판별하기 위한 EClassCastFlags bit mask다. AActor, USceneComponent, UBlueprint 같은 제한된 engine category에 flag가 정의되어 있고 superclass의 flag가 subclass로 상속된다.

중요한 한계가 있다.

  • 64-bit mask의 모든 bit를 임의의 game class마다 하나씩 배정하지 않는다.
  • TCastFlags_V<T>의 기본은 CASTCLASS_None이다.
  • custom game type 대부분은 target-specific bit가 없어 IsA path를 사용한다.
  • flag가 있다고 정확한 class equality를 뜻하지 않는다. 해당 category 또는 descendant 관계를 나타낸다.

application code가 이 내부 flag를 직접 선택하거나 확장하기보다 Cast, IsA, ExactCast의 공개 의미에 의존해야 한다.

IsA는 raw UObject와 FObjectPtr를 구분해서 읽어야 한다#

raw UObject 계열의 canonical path는 UObjectBaseUtility::IsA다. 개념적으로 다음 관계를 확인한다.

Object->GetClass()->IsChildOf(TargetClass)

target UClass*가 null이면 의미 있는 class 관계를 검사할 수 없다. Object가 null인 상태에서 member function을 호출하는 것도 C++ undefined behavior이므로 다음처럼 guard한다.

bool IsMyActor(const UObject* Object)
{
return Object && Object->IsA<AMyActor>();
}

ObjectPtr.cppFObjectPtr::IsA는 raw UObject의 member 구현과 구분해야 한다. FObjectPtr path는 handle resolve와 access tracking을 포함할 수 있는 별도 overload다. 결과의 class 관계는 같아도 raw pointer 비용을 분석할 때 해당 구현을 그대로 대입하면 안 된다.

IsChildOf의 두 구현#

UStruct::IsChildOf는 자신이 target UStruct와 같거나 descendant인지 판단한다. engine의 compile-time 설정에 따라 두 구현 중 하나를 사용할 수 있다.

구현query 비용추가 비용·특징
outer walkO(h)자기 자신부터 super chain을 순회
struct arrayO(1)type마다 ancestor chain을 O(h) 공간으로 저장·초기화

여기서 h는 inheritance depth다. 어떤 build가 어느 구현과 cast flag 경로를 선택하는지는 해당 engine branch의 macro 설정을 확인한다. editor 여부만으로 모든 branch의 값을 단정하지 않는다.

경로별 복잡도#

Cast 전체를 무조건 O(1) 또는 무조건 비싸다고 설명할 수 없다.

경로대략적인 query 비용비고
HasAnyCastFlagO(1)common flagged engine type
outer walk IsChildOfO(h)inheritance depth에 비례
struct-array IsChildOfO(1)type당 O(h) ancestor storage/initialization
raw ExactCastO(1)exact UClass* equality만 비교
interface address lookupclass depth와 interface 목록에 비례Blueprint/native 구현 여부 분기 포함
FObjectPtr overloadclass check 외 handle 비용 가능raw pointer path와 별도 측정

interface search나 object handle resolve까지 포함해 “모든 Unreal cast는 bit test 하나”라고 일반화하면 안 된다. 반대로 game hierarchy 깊이는 보통 제한적이므로 cast 몇 번만 보고 architecture를 바꿀 이유도 없다. hot path는 Unreal Insights나 platform profiler로 실제 빈도와 target을 측정한다.

Cast, CastChecked, ExactCast, IsA#

실패가 정상이라면 Cast#

void TryActivate(UObject* Candidate)
{
if (!IsValid(Candidate))
{
return;
}
if (AMyActor* Actor = Cast<AMyActor>(Candidate))
{
Actor->ActivateFeature();
}
}

subclass도 허용하며 mismatch는 null이다. network payload, user selection, optional component처럼 실제 type이 달라질 수 있는 입력에 적합하다.

실패가 programmer bug라면 CastChecked#

AMyActor* Actor = CastChecked<AMyActor>(KnownActorObject);

check가 활성화된 build에서는 null 또는 mismatch를 진단한다. ECastCheckedType::NullAllowed를 사용하면 null만 허용할 수 있지만 non-null mismatch는 여전히 invariant 위반이다.

check가 compile out되는 build에서는 CastChecked의 진단도 같은 형태로 남는다고 기대하면 안 된다. 그래서 untrusted runtime input의 보안·유효성 검사로 사용하지 않는다. 실패가 정상일 수 있다면 Cast와 null branch를 쓴다.

정확한 class만 허용한다면 ExactCast#

if (UMyExactAsset* Asset = ExactCast<UMyExactAsset>(Object))
{
// UMyExactAsset의 subclass는 여기 들어오지 않는다.
}

raw pointer 구현은 Object->GetClass() == UMyExactAsset::StaticClass() 형태다. O(1)이지만 “Cast보다 빠르다”는 이유만으로 선택하지 않는다. subclass를 의도적으로 거부하는 semantics가 필요할 때만 쓴다.

bool만 필요하면 IsA#

if (Object && Object->IsA<UMyAsset>())
{
// type relation만 확인
}

그 뒤 같은 pointer를 다시 cast해서 사용한다면 Cast 한 번으로 pointer와 branch를 함께 얻는 편이 낫다.

Unreal interface의 Blueprint-only 함정#

Blueprint가 C++로 선언된 Unreal interface를 구현해도 native C++ IInterface subobject가 생기는 것은 아니다. Blueprint-only 구현에는 object pointer만 있고 native interface pointer는 없을 수 있다.

이 때문에 다음 코드는 Blueprint-only implementation에서 null일 수 있다.

if (IInteractable* NativeInterface = Cast<IInteractable>(Object))
{
NativeInterface->InteractNativeOnly();
}

Blueprint 구현과 override까지 호출해야 한다면 reflection path를 사용한다.

void TryInteract(UObject* Object, AActor* Instigator)
{
if (!IsValid(Object) ||
!Object->GetClass()->ImplementsInterface(
UInteractable::StaticClass()))
{
return;
}
IInteractable::Execute_Interact(Object, Instigator);
}

Interfaces in Unreal EngineExecute_ wrapper가 C++ 구현과 Blueprint override를 모두 호출하기 위한 path라고 설명한다. native interface pointer 자체가 필요한 API에서만 Cast<IInteractable>을 사용한다.

Blueprint Cast node는 C++ template 호출과 동일하지 않다#

Blueprint의 Cast To node도 Unreal reflection 관계를 검사하지만 C++ Cast<T> template을 그대로 instantiate하는 것은 아니다.

  1. Kismet compiler가 class→class, interface→class, class→interface, interface→interface 조합에 맞는 cast statement를 만든다.
  2. class cast bytecode에는 target UClass literal이 들어간다.
  3. VM은 class target이면 IsA, interface target이면 ImplementsInterface와 script-interface path를 사용한다.

Blueprint node에는 VM dispatch 비용이 더해지고 target class reference가 asset dependency에 참여할 수 있다. 이는 class hierarchy query 자체의 비용과 별도 축이다. cast node 하나의 CPU 비용, 반복 횟수, target Blueprint의 load dependency를 섞어서 “Cast To는 항상 비싸다”라고 결론 내리지 않는다.

C++ RTTI와의 경계#

Unreal CastUObjectBase 또는 Unreal interface 계열을 대상으로 한다. plain C++ class hierarchy에는 UClass가 없으므로 사용할 수 없다.

struct INativeService
{
virtual ~INativeService() = default;
};
struct FConcreteService final : INativeService
{
};

이 hierarchy의 checked downcast는 표준 dynamic_cast와 native RTTI 영역이다. UE module에서 필요하면 Module PropertiesbUseRTTI와 platform ABI를 검토한다. RTTI를 끈 build가 dynamic_caststatic_cast로 바꾸어 주지는 않는다. 표준 cast의 자세한 전제는 C++ 캐스팅 글에서 다룬다.

FFieldFProperty도 UObject hierarchy가 아니므로 CastField<T>를 사용한다.

if (FStructProperty* StructProperty =
CastField<FStructProperty>(Property))
{
const UScriptStruct* Struct = StructProperty->Struct;
}

결론#

Cast<T>는 C++ RTTI의 값싼 복제품이 아니다. Unreal reflection이 이미 유지하는 UClass hierarchy와 interface metadata를 이용하는 UObject 전용 cast다. source와 target의 종류에 따라 cast flag, interface address, IsA 중 맞는 경로를 선택한다.

성능은 선택된 경로와 IsChildOf 구현 방식에 따라 다르다. outer walk는 inheritance depth에 비례하고, struct-array query와 cast flag, exact class 비교는 O(1)이다. interface와 object pointer는 추가 비용이 있을 수 있다. 그래서 API는 micro-optimization보다 의미로 고른다.

  • mismatch가 정상: Cast와 null 검사
  • mismatch가 invariant 위반: CastChecked
  • exact runtime class만: ExactCast
  • bool만: guarded IsA
  • Blueprint-capable interface: ImplementsInterface + Execute_
  • non-UObject: 표준 C++ type system과 RTTI 정책

Cast 성공은 object lifetime, ownership, network authority, gameplay readiness를 보장하지 않는다. type check와 object validity를 분리해서 검증해야 한다.

참고 자료#

글 공유하기

이 글이 도움이 되셨다면 다른 사람과 공유해 주세요!

Unreal Engine의 C++ Cast<T> 함수는 어떻게 동작하는가?
https://hon454.github.io/posts/how-unreal-engine-cast-works/
작성자
Jihoon Jeon
게시일
2024-01-29
라이선스
CC BY-NC-SA 4.0

댓글

Profile Image of the Author
Jihoon Jeon
안녕하세요! 서브컬처와 자전거를 사랑하는 개발자 전지훈입니다.
공지사항
제 블로그에 오신 것을 환영합니다! 예시 공지입니다.
카테고리
태그
최신 일상
사이트 통계
게시글
46
카테고리
10
태그
139
총 단어 수
103,271
운영 일수
0
최근 활동
0일 전
사이트 정보
빌드 플랫폼
GitHub Actions
블로그 버전
Firefly v6.16.3
라이선스
CC BY-NC-SA 4.0