[Unreal Engine] Reflection System - Registration 3

Imeamangryang·2025년 6월 27일

Unreal Reflection System

목록 보기
9/10
post-thumbnail

출처 : staticJPL - Unreal-Engine-Core-Documentation


Post Initialization

CloseDisregardForGC

InitCoreUObject에서 ProcessNewlyLoadedObjects 호출 이후에는 다음 코드가 실행됩니다.

//CoreUObjectUtilities.cpp
FGCObject::StaticInit();  
if (GUObjectArray.IsOpenForDisregardForGC())  
{  
    GUObjectArray.CloseDisregardForGC();  
}

CloseDisregardForGC가 호출되면 UClass*의 생성이 완료되고, 가비지 컬렉터(GC)가 활성화될 수 있습니다. 이 시점에서 CDO(클래스 기본 오브젝트)가 구성되고, 엔진의 핵심 컴포넌트에 대한 패키지 오브젝트가 준비됩니다.

Dazhao의 설명에 따르면,

"이러한 필수 오브젝트들은 게임이 실행될 때만 파괴되므로, GC에 의해 관리되지 않습니다. 따라서 초기에는 GC가 꺼진 상태(OpenForDisregardForGc = True)로 시작하고, 타입 시스템이 구축된 후에는 NewObject를 통해 오브젝트가 생성될 수 있으므로 GC를 활성화할 수 있습니다."

즉, 이 시점까지 다양한 타입의 오브젝트가 메모리에 생성되고 타입 정보가 수집됩니다. 이후 단계에서는 이러한 공통 타입에 대해 최종적인 생성(Z_Construct)을 마치고, 오브젝트 간의 바인딩 및 링크 작업이 이어집니다.


Binding & Linking

최종 단계에서는 타입 오브젝트의 생성이 끝난 후 정렬 및 추가적인 후처리(post-initialization)가 이루어집니다. 이후 섹션에서는 바인딩(Binding)과 링크(Linking) 과정에 대해 다룹니다.

Bind

바인딩의 목적은 함수 포인터를 올바른 주소에 연결하는 것입니다.

Bind 연산은 FField의 가상 함수로 정의되어 있습니다. 따라서 모든 필드는 이 바인딩 연산을 오버로드할 수 있지만, 실제로 이 함수를 명시적으로 오버라이드하는 대표적인 타입은 UClassUFunction입니다.

void UFunction::Bind()
{
    UClass* OwnerClass = GetOwnerClass();

    // If this isn't a native function, or this function belongs to a native interface class (which has no C++ version),
    // use ProcessInternal (call into script VM only) as the function pointer for this function
    if (!HasAnyFunctionFlags(FUNC_Native))
    {
        // Use processing function.
        Func = &UObject::ProcessInternal;
    }
    else
    {
        // Find the function in the class's native function lookup table.
        FName Name = GetFName();
        FNativeFunctionLookup* Found = OwnerClass->NativeFunctionLookupTable.FindByPredicate([=](const FNativeFunctionLookup& NativeFunctionLookup) { return Name == NativeFunctionLookup.Name; });
        if (Found)
        {
            Func = Found->Pointer;
        }
#if USE_COMPILED_IN_NATIVES
        else if (!HasAnyFunctionFlags(FUNC_NetRequest))
        {
            UE_LOG(LogClass, Warning, TEXT("Failed to bind native function %s.%s"), *OwnerClass->GetName(), *GetName());
        }
#endif
    }
}

UFunction::Bind 코드의 동작을 살펴보면 다음과 같습니다.

  • 네이티브 함수가 없거나, 함수가 네이티브 인터페이스 클래스(즉, C++ 구현이 없는 클래스)에 속하는 경우, DECLARE_FUNCTION 매크로를 통해 ProcessInternal을 호출하도록 함수 포인터를 설정합니다. 이로써 스크립트 VM에서만 실행됩니다.
  • 그렇지 않은 경우, 클래스의 네이티브 함수 룩업 테이블에서 해당 함수 이름을 검색하여 함수 포인터를 바인딩합니다. 만약 찾지 못하면 경고 로그를 출력합니다.

즉, 올바른 함수 포인터를 찾아 바인딩하는 역할을 수행합니다.

/**
 * Find the class's native constructor.
 */
void UClass::Bind()
{
    UStruct::Bind();

    if (!ClassConstructor && IsNative())
    {
        UE_LOG(LogClass, Fatal, TEXT("Can't bind to native class %s"), *GetPathName());
    }

    UClass* SuperClass = GetSuperClass();
    if (SuperClass && (ClassConstructor == nullptr || !CppClassStaticFunctions.IsInitialized() || ClassVTableHelperCtorCaller == nullptr))
    {
        // Chase down constructor in parent class.
        SuperClass->Bind();
        if (!ClassConstructor)
        {
            ClassConstructor = SuperClass->ClassConstructor;
        }
        if (!ClassVTableHelperCtorCaller)
        {
            ClassVTableHelperCtorCaller = SuperClass->ClassVTableHelperCtorCaller;
        }
        if (!CppClassStaticFunctions.IsInitialized())
        {
            CppClassStaticFunctions = SuperClass->CppClassStaticFunctions;
        }

        // propagate flags.
        // we don't propagate the inherit flags, that is more of a header generator thing
        ClassCastFlags |= SuperClass->ClassCastFlags;
    }

    // if( !Class && SuperClass )
    //{
    //}
    if (!ClassConstructor)
    {
        UE_LOG(LogClass, Fatal, TEXT("Can't find ClassConstructor for class %s"), *GetPathName());
    }
}

UClass의 바인딩(Binding)은 블루프린트 컴파일 시점이나 패키지 내 클래스를 로드할 때 매우 중요합니다. 네이티브 클래스의 경우, 생성자 함수 포인터는 이미 GetPrivateStaticClassBody를 통해 제공됩니다. 하지만 C++ 코드가 없는(즉, 블루프린트 전용) 클래스의 경우에는 생성자 바인딩이 기본 클래스에서 이뤄져야 정상적으로 동작합니다. 소스 코드에는 본질적으로 동일한 역할을 하는 세 개의 바인딩 함수가 존재하며, 이들은 모두 GetPrivateStaticClassBody에 전달됩니다.

마지막으로, UScriptStructUClass의 생성 과정에서 마지막 단계는 StaticLink 호출입니다. 이 과정의 저수준 동작을 이해하려면 언리얼 엔진의 직렬화(Serialization) 시스템이 어떻게 동작하는지 알아야 합니다. 이 단계에서는 비어 있는 직렬화 아카이브 객체(FNullArchive)가 UStruct::Link 함수에 전달됩니다.

void UStruct::StaticLink(bool bRelinkExistingProperties)  
{  
    FNullArchive ArDummy;  
    Link(ArDummy, bRelinkExistingProperties);  
}

언리얼 리플렉션 시스템에서 link라는 용어는 일반적인 C++의 링크 단계와 유사하게 여러 의미를 가집니다.

  1. 심볼 주소 연결(Symbol Address Linking): 구조 변경이나 컴파일 이후 함수나 변수의 참조 주소를 갱신하는 과정입니다.
  2. 참조 링크(RefLink): 속성의 특성에 따라 하위 필드를 체인으로 분류하여 효율적으로 관리할 수 있도록 합니다.
  3. 직렬화 링크(Serialization Linking): 디스크에 저장된 객체와 메모리 내 객체 간의 매핑을 설정하여, 직렬화된 데이터를 올바르게 로드하고 해석할 수 있도록 합니다.
  4. 직렬화 후 링크(Post-Serialization Linking): 객체가 직렬화되어 메모리에 로드된 후, 속성 오프셋과 메모리 정렬을 처리하는 단계로, FArchive를 활용해 런타임에서 사용할 수 있도록 최종 세팅을 마칩니다.

언리얼 엔진의 리플렉션 시스템은 시간이 지나면서 UProperty 의존도를 줄였지만, 블루프린트 호환성 등 레거시 지원을 위해 여전히 중요합니다. 다만, UPropertyFProperty 모두 링크 과정은 유사하게 동작합니다. UStruct::Link를 설명하기 전에, 속성(Property) 단위의 링크 과정(LinkInternal, SetupOffset 등)부터 이해하는 것이 중요합니다.

UnrealTypes.cpp에서 LinkInternal의 기본 클래스 타입 정의는 다음과 같습니다.

//
// Link property loaded from file.
//
void FProperty::LinkInternal(FArchive& Ar)
{
    check(0);
    // Link shouldn't call super...and we should never link an abstract property, like this base class
}

check(0) 매크로는 파생 타입이 FProperty 클래스를 상속받아 LinkInternal을 올바르게 오버라이드하지 않았을 때 버그나 잠재적 크래시를 방지하기 위한 안전장치 역할을 합니다.

파생 클래스에서 LinkInternal을 사용하는 예시는 다음과 같습니다.

void FBoolProperty::LinkInternal(FArchive& Ar)
{
    check(FieldSize != 0);
    ElementSize = FieldSize;

    if (IsNativeBool())
    {
        PropertyFlags |= (CPF_IsPlainOldData | CPF_NoDestructor | CPF_ZeroConstructor);
    }
    else
    {
        PropertyFlags &= ~(CPF_IsPlainOldData | CPF_ZeroConstructor);
        PropertyFlags |= CPF_NoDestructor;
    }

    PropertyFlags |= CPF_HasGetValueTypeHash;
}

이처럼 LinkInternal 함수는 속성의 특성에 따라 프로퍼티 플래그를 설정하는 데 사용됩니다.

int32 FProperty::SetupOffset()
{
    UObject* OwnerUObject = GetOwner<UObject>();
    if (OwnerUObject && (OwnerUObject->GetClass()->ClassCastFlags & CASTCLASS_UStruct))
    {
        UStruct* OwnerStruct = (UStruct*)OwnerUObject;
        Offset_Internal = Align(OwnerStruct->GetPropertiesSize(), GetMinAlignment());
    }
    else
    {
        Offset_Internal = Align(0, GetMinAlignment());
    }

    uint32 UnsignedTotal = (uint32)Offset_Internal + (uint32)GetSize();
    if (UnsignedTotal >= (uint32)MAX_int32)
    {
        UE::CoreUObject::Private::OnInvalidPropertySize(UnsignedTotal, this);
    }
    return (int32)UnsignedTotal;
}

SetupOffset 함수는 직렬화 이후 속성의 메모리 오프셋을 정확히 산출하는 역할을 합니다.

아래는 UStruct::Link 함수의 핵심 부분만 발췌하여 주요 동작을 설명합니다.

void UStruct::Link(FArchive& Ar, bool bRelinkExistingProperties)
{
    // 모든 FProperty에 대해 LinkInternal 호출
    for (FField* Field = ChildProperties; (Field != NULL) && (Field->GetOwner<UObject>() == this); Field = Field->Next)
    {
        if (FProperty* Property = CastField<FProperty>(Field))
        {
            Property->LinkWithoutChangingOffset(Ar);
        }
    }

    // 참조, 구조체, 배열 등을 최적화된 정리용으로 링크
    FProperty** PropertyLinkPtr = &PropertyLink;
    FProperty** DestructorLinkPtr = &DestructorLink;
    FProperty** RefLinkPtr = (FProperty**)&RefLink;
    FProperty** PostConstructLinkPtr = &PostConstructLink;

    TArray<const FStructProperty*> EncounteredStructProps;
    for (TFieldIterator<FProperty> It(this); It; ++It)
    {
        FProperty* Property = *It;

        // 객체 참조를 포함하는 프로퍼티는 RefLink에 추가
        if (Property->ContainsObjectReference(EncounteredStructProps, EPropertyObjectReferenceType::Any))
        {
            *RefLinkPtr = Property;
            RefLinkPtr = &(*RefLinkPtr)->NextRef;
        }
        const UClass* OwnerClass = Property->GetOwnerClass();
        bool bOwnedByNativeClass = OwnerClass && OwnerClass->HasAnyClassFlags(CLASS_Native | CLASS_Intrinsic);

        // 소멸자가 필요한 프로퍼티는 DestructorLink에 추가
        if (!Property->HasAnyPropertyFlags(CPF_IsPlainOldData | CPF_NoDestructor) &&
            !bOwnedByNativeClass)
        {
            *DestructorLinkPtr = Property;
            DestructorLinkPtr = &(*DestructorLinkPtr)->DestructorLinkNext;
        }
        // CDO에서 값을 복사해야 하는 프로퍼티는 PostConstructLink에 추가
        if (OwnerClass && (!bOwnedByNativeClass || (Property->HasAnyPropertyFlags(CPF_Config) && !OwnerClass->HasAnyClassFlags(CLASS_PerObjectConfig))))
        {
            *PostConstructLinkPtr = Property;
            PostConstructLinkPtr = &(*PostConstructLinkPtr)->PostConstructLinkNext;
        }
        *PropertyLinkPtr = Property;
        PropertyLinkPtr = &(*PropertyLinkPtr)->PropertyLinkNext;
    }

    // FProperty가 참조하는 UObject를 수집하여 GC용 배열에 저장
    CollectPropertyReferencedObjects(MutableView(ScriptAndPropertyObjectReferences));
}

UFunction(UStruct의 서브타입)의 링크(Link) 과정은 InitializeDerivedMembers를 호출하여 파라미터 및 반환값의 정보 오프셋을 계산하는 단계로 구성됩니다. 주요 단계는 다음과 같습니다.

  1. Internal Link: UFunction 내부의 프로퍼티를 초기화하고 연결합니다. 이 단계에서 파라미터와 반환값 등 런타임에 필요한 속성들이 올바르게 설정됩니다.
  2. RefLink: 오브젝트 참조를 포함하는 프로퍼티를 처리합니다. RefLink는 가비지 컬렉션(GC) 효율성을 높이기 위해 객체 간 참조 관계를 구축하고, 객체의 수명 관리를 담당합니다.
  3. PostConstructLink: 함수의 속성이 직렬화 이후 올바르게 초기화되도록, 클래스 기본 오브젝트(CDO)에서 원본 기본값을 가져옵니다. 이는 설정 파일이나 CDO에서 값을 읽어와 적용하는 단계입니다.
  4. DestructorLink: 소멸 시 추가 정리가 필요한 프로퍼티를 식별하고 처리합니다. 이를 통해 함수 속성에 연결된 리소스가 올바르게 해제됩니다.

이러한 링크 단계들은 FProperty 속성에 대한 반복 탐색을 최소화하여 다양한 상황에서 성능을 최적화합니다. 각 단계는 UFunction 객체가 언리얼 엔진 내에서 효율적으로 실행되고 리소스가 적절히 관리될 수 있도록 핵심적인 역할을 합니다.

void UFunction::InitializeDerivedMembers()
{
    NumParms = 0;
    ParmsSize = 0;
    ReturnValueOffset = MAX_uint16;
    FProperty** ConstructLink = &FirstPropertyToInit;

    for (FProperty* Property = CastField<FProperty>(ChildProperties); Property; Property = CastField<FProperty>(Property->Next))
    {
        if (Property->PropertyFlags & CPF_Parm)
        {
            NumParms++;
            ParmsSize = IntCastChecked<uint16>(Property->GetOffset_ForUFunction() + Property->GetSize());
            if (Property->PropertyFlags & CPF_ReturnParm)
            {
                ReturnValueOffset = IntCastChecked<uint16>(Property->GetOffset_ForUFunction());
            }
        }
        else if ((FunctionFlags & FUNC_HasDefaults) == 0)
        {
            // we're done with parms and we've not been tagged as FUNC_HasDefaults, so we can abort
            // this potentially costly loop:
            break;
        }
        else if (!Property->HasAnyPropertyFlags(CPF_ZeroConstructor))
        {
            *ConstructLink = Property;
            Property->PostConstructLinkNext = nullptr;
            ConstructLink = &Property->PostConstructLinkNext;
        }
    }
}

UMetaData

UMetaData 타입은 에디터 모드에서만 사용됩니다. 하지만 언리얼 엔진의 리플렉션 시스템을 탐구하는 개발자라면, 엔진에 새로운 기능을 추가하거나 툴/시스템을 통합하고자 할 수 있습니다.

아래는 UMetaData 타입의 getter와 setter 예시입니다:

#if WITH_METADATA  
void AddMetaData(UObject* Object, const FMetaDataPairParam* MetaDataArray, int32 NumMetaData)  
{       
    if (NumMetaData)  
    {          
        UMetaData* MetaData = Object->GetOutermost()->GetMetaData();  
        for (const FMetaDataPairParam* MetaDataParam = MetaDataArray, *MetaDataParamEnd = MetaDataParam + NumMetaData; MetaDataParam != MetaDataParamEnd; ++MetaDataParam)  
        {             
            MetaData->SetValue(Object, UTF8_TO_TCHAR(MetaDataParam->NameUTF8), UTF8_TO_TCHAR(MetaDataParam->ValueUTF8));  
        }       
    }   
}  
#endif
/**
 * Gets (after possibly creating) a metadata object for this package
 *
 * @return A valid UMetaData pointer for all objects in this package
 */
UMetaData* UPackage::GetMetaData()
{
    checkf(!FPlatformProperties::RequiresCookedData(), TEXT("MetaData is only allowed in the Editor."));

#if WITH_EDITORONLY_DATA
    PRAGMA_DISABLE_DEPRECATION_WARNINGS
    UMetaData* LocalMetaData = MetaData;
    PRAGMA_ENABLE_DEPRECATION_WARNINGS

    // If there is no MetaData, try to find it.
    if (LocalMetaData == nullptr)
    {
        LocalMetaData = FindObjectFast<UMetaData>(this, FName(NAME_PackageMetaData));

        // If MetaData is null then it wasn't loaded by linker, so we have to create it.
        if (LocalMetaData == nullptr)
        {
            LocalMetaData = NewObject<UMetaData>(this, NAME_PackageMetaData, RF_Standalone | RF_LoadCompleted);
        }
        SetMetaData(LocalMetaData);
    }
    check(LocalMetaData);

    if (LocalMetaData->HasAnyFlags(RF_NeedLoad))
    {
        FLinkerLoad* MetaDataLinker = LocalMetaData->GetLinker();
        check(MetaDataLinker);
        MetaDataLinker->Preload(LocalMetaData);
    }
    return LocalMetaData;
#else
    return nullptr;
#endif
}

소스 코드를 살펴보면, UMetaData는 UPackage와 연관된 UObject이지만 FField에는 바인딩되지 않습니다.

/**  
 * An object that holds a map of key/value pairs. */  
class UMetaData : public UObject  
{  
    DECLARE_CASTED_CLASS_INTRINSIC_WITH_API(UMetaData, UObject, CLASS_MatchedSerializers, TEXT("/Script/CoreUObject"), CASTCLASS_None, COREUOBJECT_API);  
  
public:  
    /**  
     * Mapping between an object, and its key->value meta-data pairs.     */  
    TMap< FWeakObjectPtr, TMap<FName, FString> > ObjectMetaDataMap;  
  
    /**  
     * Root-level (not associated with a particular object) key->value meta-data pairs.     * Meta-data associated with the package itself should be stored here. */   
     TMap< FName, FString > RootMetaDataMap;

여기서 DECLARE_CASTED_CLASS_INTRINSIC_WITH_API 매크로에 주목해야 합니다. 이 매크로는 클래스 선언과 직렬화 함수 생성을 위해 내부적으로 DECLARE_CLASS로 확장되며, DECLARE_CLASS_INTRINSIC과 동일한 레이아웃을 따릅니다.

const FString& FField::GetMetaData(const FName& Key) const  
{  
    // If not found, return a static empty string  
    static FString EmptyString;  
  
    // Every key needs to be valid, and meta data needs to exist  
    if (Key == NAME_None || !MetaDataMap)  
    {  
        return EmptyString;  
    }  
  
    // Look for the property  
    const FString* ValuePtr = MetaDataMap->Find(Key);  
  
    // If we didn't find it, return NULL  
    if (!ValuePtr)  
    {  
        return EmptyString;  
    }  
  
    // If we found it, return the pointer to the character data  
    return *ValuePtr;  
}

ObjectMetaData는 기존 UObject를 참조하기 위해 FWeakObjectPtr를 사용하며, 이는 주로 GUObjectAllocator 배열 내의 UObject를 가리키는 데 활용됩니다.

여기서 한 가지 의문이 생깁니다. 왜 이 MetaDataMap을 UObject에 직접 추가하지 않았을까요?

Dazhao의 설명에 따르면:

이 방식은 UObject로부터의 디커플링(결합도 감소) 개념에 부합하며, 불필요한 필드 추가로 인한 메모리 낭비를 방지합니다. 만약 메타데이터를 직접 포함한다면 다음과 같은 단점이 있습니다.

  • 메타데이터 사용 여부와 관계없이 모든 UObject의 메모리 사용량이 증가합니다.
  • 메타데이터는 에디터에서만 사용되며, 쿠킹 시에는 제외되어야 하므로, UObject에 직접 포함하면 강한 결합이 발생합니다. 반면, MetaDataMap을 독립 객체로 두면 파일로 저장하거나 분리하기가 훨씬 쉽습니다. 강하게 결합될 경우 바이너리 스트림에서 분리해내기 어렵습니다.

또한 Dazhao는 UMetaData의 사용 목적에 대해 다음과 같이 언급합니다.

"만약 이를 제거한다면, 간접 접근 계층이 추가되어 캐시 미스(CacheMiss) 가능성과 효율 저하가 발생할 수 있습니다. 하지만 실제로 UMetaData는 에디터에서만 사용되므로, 에디터가 약간 느려져도 상관없습니다. 게임처럼 프레임레이트 요구사항이 있는 것도 아니고, UMetaData 접근 빈도도 높지 않습니다. 주로 UI 초기화 시 인터페이스를 변경할 때만 사용됩니다. UMetaData는 UPackage와 연관(Outer가 UPackage)되도록 재설계되었으며, UPackage는 직렬화 저장의 단위이기 때문에 UMetaData를 독립적으로 로드하거나 해제할 수 있습니다."

profile
언리얼 엔진 주니어 개발자 입니다.

0개의 댓글