UE5 Cast<ClassType>(...) 은 빠를까?

에크까망·2024년 9월 20일

들어가기 전에

  • 단순화를 위해 문체를 단정적으로 작성하였으나, 이는 어디까지나 개인적인 의견임을 양해 부탁드립니다.
  • 본문과 관련하여 오류 지적이나 의견이 있으시다면 댓글로 남겨주시면 감사하겠습니다.
  • 글 작성 시점은 2024/09 입니다.
  • 글 작성 기준은 UE5.4.4 입니다.

들어가며

  • 약간의 C++ 언어에 대한 사전 지식이 있으신 것으로 가정하고 작성되었습니다.
  • UObject-UClass 구조에 대해서는 이후 포스팅에서 다시 다루도록 하겠습니다.
  • UnrealEngine Configuration (Editor 여부 포함)에 대해서도 이후 별도로 다루겠습니다.

Class Type Casting

일반적으로 Class Type Casting은 Downcasting이 안전한지 확인한 뒤 타입을 변환하는 작업을 의미합니다.

아래 코드에서 GetController()AController* 를 반환하며, 해당 클래스 포인터(인스턴스)가 APlayerController 를 구현하고 있는지(동일 클래스이거나 자식 클래스인지)를 확인한 후, 조건에 맞을 경우 적절한 포인터를 반환합니다.

APlayerController* PlayerController = Cast<APlayerController>(GetController())

Class Upcasting은 Compile-Time에 바로 확인되지만, Downcasting은 그렇지 않습니다. 따라서 해당 Object(Class Instance)에 대한 타입 정보를 제공받거나, 별도의 구현이 필요합니다.

C++ 이외의 언어에서의 RTTI

C++을 제외하고 현재 널리 사용되는 대부분의 언어는 Memory Managed 언어이며, Framework 차원에서 Garbage Collection과 Reflection을 지원합니다. 또한 이러한 언어들은 기본적으로 Run-Time Type Information(이하 RTTI)을 제공합니다.

따라서 대부분의 언어에서는 RTTI 사용 여부가 선택사항이 아니며 기본적으로 제공됩니다. 별도로 고려하거나 구현할 필요가 없습니다.

C++에서의 RTTI

반면 C++에서 RTTI는 선택 사항입니다. 컴파일 타임에 사용 여부를 지정할 수 있습니다. 기능 자체는 유용하지만, RTTI는 추가 비용이 발생하기 때문에 선택적으로 제공됩니다.

Visual Studio 2022 C++에서 RTTI 옵션은 아래와 같습니다.

옵션을 활성화하면 다음과 같은 기능을 사용할 수 있습니다.

dynamic_cast

class Base {
    virtual void foo() {}  // RTTI 사용을 위해서는 반드시 가상 함수가 필요합니다.
};

class Derived : public Base {
};

Base* basePtr = new Derived();
Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);

if (derivedPtr) {
    // 캐스팅 성공
} else {
    // 캐스팅 실패
}

typeid

Base* basePtr = new Derived();
std::cout << typeid(*basePtr).name() << std::endl; // Derived 클래스 이름 출력

비용 문제

C++에서 RTTI를 사용하면 virtual-function-table 기반의 추가 정보가 필요하며, 이는 오버헤드로 작용합니다. 또한 dynamic_caststatic_cast 에 비해 상대적으로 느립니다. 이러한 이유로 C++에서는 RTTI 사용 여부를 신중하게 선택하게 됩니다.

UE5에서의 RTTI

언리얼 엔진에서는 기본적으로 C++ RTTI가 비활성화되어 있으며, 특정 Module에서 필요할 경우 module.build.cs 에서 bUseRTTI=true; 로 활성화할 수 있습니다.

이제 Cast<APlayerController>(GetController()) 가 어떻게 구현되어 있는지 살펴보겠습니다.

Cast.h

UE_5.4/Engine/Source/Runtime/CoreUObject/Public/Templates/Casts.h

template <typename Type>
struct TCastFlags
{
    static const EClassCastFlags Value = CASTCLASS_None;
};

// Dynamically cast an object type-safely.
template <typename To, typename From>
FORCEINLINE To* Cast(From* Src)
{
    static_assert(sizeof(From) > 0 && sizeof(To) > 0, "Attempting to cast between incomplete types");

    if (Src) // 'Mark A'
    {
        if constexpr (TIsIInterface<From>::Value) // Mark B
        {
            if (UObject* Obj = Src->_getUObject())
            {
                if constexpr (TIsIInterface<To>::Value)
                {
                    return (To*)Obj->GetInterfaceAddress(To::UClassType::StaticClass());
                }
                else
                {
                    if constexpr (std::is_same_v<To, UObject>)
                    {
                        return Obj;
                    }
                    else
                    {
                        if (Obj->IsA<To>())
                        {
                            return (To*)Obj;
                        }
                    }
                }
            }
        }
        else if constexpr (UE_USE_CAST_FLAGS && TCastFlags<To>::Value != CASTCLASS_None) // Mark C
        {
            if constexpr (std::is_base_of_v<To, From>)
            {
                return (To*)Src;
            }
            else
            {
#if UE_ENABLE_UNRELATED_CAST_WARNINGS
                UE_STATIC_ASSERT_WARN((std::is_base_of_v<From, To>), "Attempting to use Cast<> on types that are not related");
#endif
                if (((const UObject*)Src)->GetClass()->HasAnyCastFlag(TCastFlags<To>::Value))
                {
                    return (To*)Src;
                }
            }
        }
        else
        {
            static_assert(std::is_base_of_v<UObject, From>, "Attempting to use Cast<> on a type that is not a UObject or an Interface");

            if constexpr (TIsIInterface<To>::Value)
            {
                return (To*)((UObject*)Src)->GetInterfaceAddress(To::UClassType::StaticClass());
            }
            else if constexpr (std::is_base_of_v<To, From>)
            {
                return Src;
            }
            else
            {
#if UE_ENABLE_UNRELATED_CAST_WARNINGS
                UE_STATIC_ASSERT_WARN((std::is_base_of_v<From, To>), "Attempting to use Cast<> on types that are not related");
#endif
                if (((const UObject*)Src)->IsA<To>()) // Mark D
                {
                    return (To*)Src;
                }
            }
        }
    }

    return nullptr;
}

위 코드는 Cast(Src)가 실제로 동작하는 방식입니다. 이를 이해하기 위해서는 C++ template, UObject-UClass 관계, 상속 구조에서의 UClass Instance 관계, 그리고 Unreal Engine의 Interface 처리 방식에 대한 이해가 필요합니다.

여기서는 Interface 관련 내용은 간략히 넘기고, 다음 사항을 중심으로 이해하시면 충분합니다.

  • class UMyObject : public UObject 를 선언하면 Module 로딩 시 UClass Instance가 생성됩니다.
  • UMyObjectAUMyObjectB 는 각각 별도의 UClass Instance를 가지며, 타입은 동일하게 UClass 입니다.
  • std::is_base_of_v<To, From> 는 To가 From의 부모 클래스인지 확인하는 C++17 표준 기능입니다.
  • constexpr 는 Compile-Time에 코드 생성을 제어할 때 사용됩니다.
  • Unreal Engine은 Interface를 제외한 다중 상속을 허용하지 않습니다.

주요 분기

Mark A

if (Src)

캐스팅 대상 포인터가 nullptr인지 확인합니다. nullptr인 경우 그대로 nullptr을 반환합니다.

Mark B

if constexpr (TIsIInterface<From>::Value)

대상이 Interface인지 확인한 후, Interface일 경우 별도의 방식으로 처리합니다.

Mark C

else if constexpr (UE_USE_CAST_FLAGS && TCastFlags<To>::Value != CASTCLASS_None)

Editor 환경에서 활성화되며, CastFlags를 활용한 빠른 판별을 수행합니다.

Mark D

if (((const UObject*)Src)->IsA<To>())

핵심 로직으로, UObject의 UClass 정보를 기반으로 상속 관계를 확인합니다.

Editor 환경에서는 부모 클래스를 따라 올라가며 비교하고,
Runtime 환경에서는 미리 구성된 BaseChain을 통해 빠르게 판별합니다.

마무리

UE5에서 Cast<ClassType>(...) 은 매우 빠르게 동작하도록 설계되어 있습니다.

profile
Game Client Programmer

0개의 댓글