언리얼 엔진 렌더링 톺아보기 1 : 기본
Object, Actor, Actor Component
UObject
UObject는 모든 언리얼엔진에서 모든 object type의 기반이 되는 클래스이다. 최상위부모를 UObjectBase로 하며 UObjectBaseUtility 클래스를 상속받고 있다. UObject는 메타데이터, 리플렉션 시스템, 가비지콜렉션, 직렬화, 에디터 정보, 개체 생성및 소멸, 이벤트 콜백 등등 여러 부가기능에 필요한 기능과 정보를 제공한다.

// MyGameInstance.cpp
void UMyGameInstance::Init()
{
...
// Construct very basic object
UObject* newObject = NewObject<UObject>();
}
// One of the functions that UObject calls on construction.
UObject* StaticAllocateObject
(
....
)
{
LLM_SCOPE(ELLMTag::UObject);
....
const bool bCreatingCDO = (InFlags & RF_ClassDefaultObject) != 0;
const bool bCreatingArchetype = (InFlags & RF_ArchetypeObject) != 0;
// Checks
{ .... }
if (bCreatingCDO)
{ .... }
UObject* Obj = NULL;
if(InName == NAME_None)
{
// Create Name Somehow
}
else
{
// See if object already exists.
Obj = StaticFindObjectFastInternal( /*Class=*/ NULL, InOuter, InName, true );
....
}
FLinkerLoad* Linker = nullptr;
int32 LinkerIndex = INDEX_NONE;
bool bWasConstructedOnOldObject = false;
// True when the object to be allocated already exists and is a subobject.
bool bSubObject = false;
int32 TotalSize = InClass->GetPropertiesSize();
checkSlow(TotalSize);
int32 OldIndex = -1;
int32 OldSerialNumber = 0;
if( Obj == nullptr )
{
int32 Alignment = FMath::Max( 4, InClass->GetMinAlignment() );
Obj = (UObject *)GUObjectAllocator.AllocateUObject(TotalSize,Alignment,GIsInitialLoad);
}
else
{
// Replace an existing object without affecting the original's address or index.
check(!Obj->IsUnreachable());
check(!ObjectRestoreAfterInitProps); // otherwise recursive construction
ObjectRestoreAfterInitProps = Obj->GetRestoreForUObjectOverwrite();
// Recycling Existing Allocation..
.....
}
// If class is transient, non-archetype objects must be transient.
if ( !bCreatingCDO && InClass->HasAnyClassFlags(CLASS_Transient) && !bCreatingArchetype )
{
InFlags |= RF_Transient;
}
if (!bSubObject)
{
FMemory::Memzero((void *)Obj, TotalSize);
new ((void *)Obj) UObjectBase(const_cast<UClass*>(InClass), InFlags|RF_NeedInitialization, InternalSetFlags, InOuter, InName, OldIndex, OldSerialNumber);
}
else
{
// Propagate flags to subobjects created in the native constructor.
Obj->SetFlags(InFlags);
Obj->SetInternalFlags(InternalSetFlags);
}
// if an external package was specified, assign it to the object
if (ExternalPackage)
{
Obj->SetExternalPackage(ExternalPackage);
}
if (bWasConstructedOnOldObject)
{
....
}
if (IsInAsyncLoadingThread())
{
....
}
else
{
// Sanity checks for async flags.
....
}
// Let the caller know if a subobject has just been recycled.
if (bOutRecycledSubobject)
{
*bOutRecycledSubobject = bSubObject;
}
return Obj;
}
가장 기본적인 UObject를 하나 만들어도 언리얼 엔진 시스템과 연동된 기능이 UObject를 통해 모두 구현되고 있는것을 볼 수 있다. 개체를 재활용 하고 할당을 추적하며 Delegate의 브로드캐스트를 담당 하는등 개체에 대해서 여러 기능이 동작하고 있다.
AActor
AActor는 UObject를 상속받으며 UE 시스템의 가장 핵심이 되는 개념이다. AActor 클래스는 게임 레벨에 배치되는 모든 개체의 기반 클래스가 된다. 유니티 엔진의 GameObject 클래스와 동일하다. AActor 클래스 헤더 선언만 4천줄이 넘어간다. 네트워크를 통한 동기화(레플리케이션 시스템), 개체의 생성 및 소멸, 프레임 틱, 컴포넌트 동작, 액터 계층구조, 변환 등등의 기능을 제공한다. 액터개체는 다음 인터페이스를 통해 서로 계층구조를 가질 수 있다.

액터의 라이프사이클 : https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-engine-actor-lifecycle?application_version=5.4
// Engine\\Source\\Runtime\\Engine\\Classes\\GameFramework\\Actor.h
void AttachToActor(AActor* ParentActor, ...);
void AttachToComponent(USceneComponent* Parent, ...);
위 두 인터페이스는 사실 동일하다, 왜냐하면 AActor::AttachToActor의 구현이 RootComponent::AttachToComponent 인터페이스를 다시 호출하기 때문이다.
// Engine\\Source\\Runtime\\Engine\\Private\\Actor.cpp
void AActor::AttachToActor(AActor* ParentActor, const FAttachmentTransformRules& AttachmentRules, FName SocketName)
{
if (RootComponent && ParentActor)
{
USceneComponent* ParentDefaultAttachComponent = ParentActor->GetDefaultAttachComponent();
if (ParentDefaultAttachComponent)
{
RootComponent->AttachToComponent(ParentDefaultAttachComponent, AttachmentRules, SocketName);
}
}
}
이것은 액터 자체가 계층 구조에 대한 정보를 가지는것이 아니라 액터와 일대일 관계를 가지는 RootSceneComponent 를 통해 구현하고 있음을 볼 수 있다.
AActor를 상속받는 클래스들은 대표적으로 다음과 같은 것들이 있다.
ASkeletalMeshActor: 본이 있는 Skinned Skeletal 다이나믹 모델AStaticMeshActor: 정적인 모델ACameraActor:카메라APlayerCameraManager: 현재 월드에 있는 모든 카메라 인스턴스를 관리하는 매니저ALight: 라이트 개체.APointLight,ADirectionalLight,ASpotLight,ARectLight등등AReflectionCapture: 환경맵의 사전생성을 위한 반사 캡쳐AController: 캐릭터 컨트롤러. 하위 클래스로AAIController,APlayerController등이 있다.APawn: 동적인 캐릭터나 AI를 가진 개체를 나타낸다. 하위 클래스로ACharacter,ADefaultPawn,AWheeledVehicle등이 있다.AMaterialInstanceActor: 머터리얼 인스턴스ALightMassPortal: 전역 조명 사전생성을 위한 GI 포털AInfo: 설정 정보 클래스의 기반 클래스가 된다. 하위 클래스로AWorldSettings,AGameModeBase,AAtmosphericFog,ASkyAtmosphere,ASkyLight등이 있다.- 등등등

UActorComponent
UActorComponent는 UObject와 IInterface_AssetUserData 인터페이스를 상속받는다. 모든 컴포넌트 타입의 기반 클래스이며 AActor 인스턴스에 자식 노드로 추가될 수 있다. 액터는 그 기능과 목적이 소유하고 있는 컴포넌트에 의해 결정되는 컴포넌트 컨테이너라고도 볼 수 있다.
UActorComponent를 상속받는 클래스들은 다음과 같은 것들이 있다.
USceneComponent: SceneComponent는 transform을 가지는 ActorComponent들이다. transform은 scene에서의 위치이며, 위치, 회전, 스케일로 정의된다. SceneComponent는 서로 계층적인 구조로 구성될 수 있다. 액터의 위치, 회전, 스케일정보는 컴포넌트 계층의 최상단 루트에 있는 SceneComponent로부터 온다.UPrimitiveComponent:USceneComponent를 상속한다. Scene에서 보이는(렌더링 되는) 모든 개체들의 기반 클래스이다. 물리, 충돌, light channel같은 기능도 제공한다.UMeshComponent:UPrimtiveComponent를 상속한다. 렌더가능한 모든 삼각형 메쉬 종류들의 기반 클래스이다.UStaticMeshComponent:UMeshComponent를 상속한다. 정적 메쉬의 지오메트리 이며 보통UStaticMesh인스턴스를 만들기 위해 사용된다.USkinnedMeshComponent:UMeshComponent를 상속하며 Skinned 메쉬를 렌더링하며 메쉬 LOD, 본 등등에 대한 인터페이스를 제공한다.USkeletalMeshComponent:USkinnedMeshComponent를 상속하며 애니메이션 된USkeletalMesh인스턴스를 만드는데 사용된다.

Level에 배치되는 모든 액터는 Root Component(Scene Component)를 가지고 있다. Scene Component는 위치, 회전, 스케일을 정의하며 액터에 소속된 모든 컴포넌트에 영향을 미친다.
빈 액터도 Default Scene Root Object를 가진다. 새로운 Scene Component를 액터에 추가하면 디폴트 개체는 대체된다.

Level, World, WorldContext, Engine
ULevel, UWorld
ULevel은 Scene의 액터들을 담아둔 Unreal Engine Level단위이다. 보이는(Mesh, Light, Effect, ... ) 개체와 보이지 않는 (Volume, Blueprints, Level Configurations, Navigation Data, .. )개체를 포함한다.
UWorld는 ULevel을 가지고 있는 컨테이너 이다. 현재 실행중인 Scene을 나타낸다. ULevel이 표현되기 위해서는 UWorld에 담겨져야 한다. 각 UWorld 인스턴스는 Persistent Level을 가지고 있으며 Streaming Level을 가지고 있을 수 있다. ULevel 이외에 UWorld는 Scene, GameInstance, AISystem, FXSystem, NavigationSystem, PhysicsScene, TimerManager 등등을 가지고 있다.
UWorld에는 Game, Editor 등의 타입이 있다.
UEngine
FWorldContext는 엔진 수준의 Device Context 이다. 월드와 관계된 정보들을 UEngine이 편하게 관리하도록 구현되었다. 로직 레이어 수준에서 직접 다뤄져선 안된다. WorldType, ContextHandle, GameInstance, GameViewport 등의 정보를 저장하고 있다.
UEngine은 내부의 많은 시스템과 리소스를 조작하며 UGameEgine과 UEditorEngine으로 인스턴싱된다. 전역 싱글톤 변수이다.
// Engine\\Source\\Runtime\\Engine\\Classes\\Engine\\Engine.h
/** Global engine pointer. Can be 0 so don't use without checking. */
extern ENGINE_API class UEngine* GEngine;
프로그램 시작 시 FEngineLoop::PreInitPostStartupScreen 의 첫 부분에서 생성되고 할당된다.
// Engine\\Source\\Runtime\\Launch\\Private\\LaunchEngineLoop.cpp
int32 FEngineLoop::PreInitPostStartupScreen(const TCHAR* CmdLine)
{
(......)
if ( GEngine == nullptr )
{
#if WITH_EDITOR
if ( GIsEditor )
{
FString EditorEngineClassName;
GConfig->GetString(TEXT("/Script/Engine.Engine"), TEXT("EditorEngine"), EditorEngineClassName, GEngineIni);
UClass* EditorEngineClass = StaticLoadClass( UEditorEngine::StaticClass(), nullptr, *EditorEngineClassName);
// 엔진 인스턴스 생성
GEngine = GEditor = NewObject<UEditorEngine>(GetTransientPackage(), EditorEngineClass);
(......)
}
else
#endif
{
FString GameEngineClassName;
GConfig->GetString(TEXT("/Script/Engine.Engine"), TEXT("GameEngine"), GameEngineClassName, GEngineIni);
UClass* EngineClass = StaticLoadClass( UEngine::StaticClass(), nullptr, *GameEngineClassName);
// 엔진 인스턴스 생성
GEngine = NewObject<UEngine>(GetTransientPackage(), EngineClass);
(......)
}
}
(......)
return 0;
}
에디터 모드인지에 따라 UEditorEngine 또는 UGameEngine이 생성된다. 그리고 GEngine에 할당된다.

Memory Allocation
- 언리얼 엔진이 실행되는 플랫폼에 따른 차이를 숨기기 위해 통일된 인터페이스를 제공한다.
- 언리얼 엔진만의 정책이 적용된 할당.해제 동작이 메모리 작업의 효율성을 올린다.
- 다양한 경우에 따른 메모리 할당 정책을 지원한다.
- 스레드 세이프 메모리 작업을 지원한다.
- TLS(Thread Local Cache)를 지원한다.
- GPU 메모리를 포함하는 통합 매니징을 지원한다.
- 메모리 디버깅을 지원하고 통계를 제공한다.
Basics
FFreeMem
FMallocBinned 내부에 정의되어 있으며 메모리 정보를 담고있는 작은 블록이다.
struct FMallocBinned::FFreeMem
{
FFreeMem* Next; // 다음 메모리 블록 또는 끝을 가리키는 포인터. 메모리 풀에 의해 관리되며 순서가 보장되어있다.
uint32 NumFreeBlocks; // 할당될 수 있는 남은 블록의 개수, 최소 1개
uint32 Padding; // 구조체 16바이트 얼라인먼트를 위한 패딩
};
FPoolInfo
메모리 할당의 오버헤드를 줄이기 위해 큰 크기의 메모리를 먼저 할당하고, 해당 메모리 덩어리를 작은 청크들로 나누어 메모리 풀을 만든다.
struct FMallocBinned::FPoolInfo
{
uint16 Taken; // 할당된 메모리 블럭의 개수
uint16 TableIndex; // MemSizeToPoolTable에서 위치되어있는 인덱스
uint32 AllocSize; // 할당된 메모리 크기
FFreeMem* FirstMem; // If it is boxing mode, it points to the memory block available in the memory pool; if it is not boxing mode, it points to the memory block allocated directly by the operating system.
FPoolInfo* Next; // 다음 메모리 풀의 포인터
FPoolInfo** PrevLink; // 이전 메모리 풀의 포인터
};
메모리 풀의 메모리 블럭은 같은 크기이므로, 메모리풀의 모습은 다음과 같다.
FPoolTable
양방향 연결 리스트로 메모리 풀을 담고 있는 메모리 풀 테이블이다. 메모리 풀 테이블의 메모리 풀이 메모리 블록을 할당할 수 없으면 새로운 메모리 풀이 생성되고 추가된다.
struct FMallocBinned::FPoolTable
{
FPoolInfo* FirstPool; // 연결리스트의 Head인 처음 할당된 메모리 풀
FPoolInfo* ExhaustedPool; // 메모리를 할당할 수 없는 메모리 풀
uint32 BlockSize; // 메모리 블록 사이
};

PoolHashBucket
메모리 풀 해쉬 버킷은 메모리 주소로 해쉬된 키에 해당하는(어떤 지점을 기준으로 버킷으로 구성된) 메모리 풀을 담기 위해 쓰인다.
struct FMallocBinned::PoolHashBucket
{
UPTRINT Key; // 해쉬 키
FPoolInfo* FirstPool; // 첫 메모리 풀의 포인터
PoolHashBucket* Prev; // 이전 버킷의 포인터
PoolHashBucket* Next; // 다음 버킷의 포인터
};

- 메모리 사이즈
언리얼 엔진의 메모리 사이즈에는 많은 파라미터들이 적용된다. PoolSize, PageSize, BlockSize와 같은것들이 있으며 실제 사이즈는 Allocator, 플랫폼, Alignment, 호출자에 따라 다르다.
#if PLATFORM_IOS
#define PLAT_PAGE_SIZE_LIMIT 16384
#define PLAT_BINNED_ALLOC_POOLSIZE 16384
#define PLAT_SMALL_BLOCK_POOL_SIZE 256
#else
#define PLAT_PAGE_SIZE_LIMIT 65536
#define PLAT_BINNED_ALLOC_POOLSIZE 65536
#define PLAT_SMALL_BLOCK_POOL_SIZE 0
#endif
Memory Allocators
FMalloc은 언리얼 엔진 메모리 할당자의 핵심 클래스이다. 모든 메모리 할당과 해제 동작을 담당한다. FUseSystemMallocForNew 와 FExec를 상속받으며 메모리 할당 전략에 따라 여러 클래스로 나뉘어지는(상속 받아지는)베이스 클래스(virtual base class)이다. FMalloc의 상속관계는 다음과 같다.

위의 표는 FMalloc을 상속받는 하위 클래스를 모두 나타내지 않는다. 디버깅 또는 기타 클래스들은 포함되어있지 않다. FMalloc 상속구조에서 주요한 클래스들은 다음과 같다.
FUseSystemMallocForNewFUseSystemMallocForNew는new와delete연산자에 대한 지원을 제공한다.FMalloc이FUseSystemMallocForNew를 상속받으므로,FMalloc의 모든 하위 클래스는 C++의new,delete키워드에 대해서 메모리 작업을 지원한다.FMallocAnsiC 라이브러리의malloc과free를 직접 호출하는 기본적인 할당자이다, 캐싱이나 할당 관리 정책이 없다.FMallocBinned언리얼 엔진의 기본 메모리 할당 방법인 박싱 매니징 기법이다.FPoolTable,FPagePoolTable,PoolHashBucket을 사용하게 된다. 모든 플랫폼의 메모리 할당 기법이다.
// Engine\\Source\\Runtime\\Core\\Public\\HAL\\MallocBinned.h
class FMallocBinned : public FMalloc
{
private:
enum { POOL_COUNT = 42 };
enum { EXTENDED_PAGE_POOL_ALLOCATION_COUNT = 2 };
enum { MAX_POOLED_ALLOCATION_SIZE = 32768+1 };
(......)
FPoolTable PoolTable[POOL_COUNT]; // 모든 메모리 풀 테이블에 대해, 메모리풀의 블록 크기가 동일하다.
FPoolTable OsTable; // 시스템에 의해 할당된 메모리를 관리하는 메모리 풀 테이블이다, 사용되지 않는다.
FPoolTable PagePoolTable[EXTENDED_PAGE_POOL_ALLOCATION_COUNT]; // 메모리 페이지의 메모리 풀 테이블 (큰 메모리)
FPoolTable* MemSizeToPoolTable[MAX_POOLED_ALLOCATION_SIZE+EXTENDED_PAGE_POOL_ALLOCATION_COUNT]; // 사이즈 기준으로 인덱스 된 PoolTable과 PagePoolTable을 가리키는 포인터이다.
PoolHashBucket* HashBuckets; // 메모리 풀 해쉬 버킷
PoolHashBucket* HashBucketFreeList; // 할당가능한 메모리 풀 해쉬 버킷
uint32 PageSize; // 메모리 페이지 사이
(......)
};
메모리 할당 메커니즘을 파악하기위해, 메모리 할당자의 초기화 코드를 살펴보자'
// Engine\\Source\\Runtime\\Core\\Private\\HAL\\MallocBinned.cpp
FMallocBinned::FMallocBinned(uint32 InPageSize, uint64 AddressLimit)
{
(......)
// 메모리 크기별로 비닝이 되는 최대 테이블 개수는 8k(IOS), 32k 이다.
BinnedSizeLimit = Private::PAGE_SIZE_LIMIT/2;
(......)
// 첫번째 페이지 풀 테이블을 할당한다. 블록사이즈는 12k(IOS), 48k이다.
PagePoolTable[0].FirstPool = nullptr;
PagePoolTable[0].ExhaustedPool = nullptr;
PagePoolTable[0].BlockSize = PageSize == Private::PAGE_SIZE_LIMIT ? BinnedSizeLimit+(BinnedSizeLimit/2) : 0;
// 두번째 페이지 풀 테이블을 초기화 한다. 블록사이즈는 24k(IOS), 96k이다.
PagePoolTable[1].FirstPool = nullptr;
PagePoolTable[1].ExhaustedPool = nullptr;
PagePoolTable[1].BlockSize = PageSize == Private::PAGE_SIZE_LIMIT ? PageSize+BinnedSizeLimit : 0;
// 블록사이즈들의 어레이를 만든다. 메모리 풀 사이즈의 약수여야 하며 16비트 정렬되는 크기여야한다.
static const uint32 BlockSizes[POOL_COUNT] =
{
8, 16, 32, 48, 64, 80, 96, 112,
128, 160, 192, 224, 256, 288, 320, 384,
448, 512, 576, 640, 704, 768, 896, 1024,
1168, 1360, 1632, 2048, 2336, 2720, 3264, 4096,
4672, 5456, 6544, 8192, 9360, 10912, 13104, 16384,
21840, 32768
};
// 메모리 블럭 크기별로 메모리 풀 테이블을 만든다. 블럭 사이즈를 지정해준다.
for( uint32 i = 0; i < POOL_COUNT; i++ )
{
PoolTable[i].FirstPool = nullptr;
PoolTable[i].ExhaustedPool = nullptr;
PoolTable[i].BlockSize = BlockSizes[i];
#if STATS
PoolTable[i].MinRequest = PoolTable[i].BlockSize;
#endif
}
// MemSizeToPoolTable을 초기화 하고 사이즈별로 메모리 풀 테이블의 포인터를 지정한다.
for( uint32 i=0; i<MAX_POOLED_ALLOCATION_SIZE; i++ )
{
uint32 Index = 0;
while( PoolTable[Index].BlockSize < i )
{
++Index;
}
checkSlow(Index < POOL_COUNT);
MemSizeToPoolTable[i] = &PoolTable[Index];
}
// MemSizeToPoolTable 어레이 마지막에 메모리 페이지 풀 테이블을 추가한다
MemSizeToPoolTable[BinnedSizeLimit] = &PagePoolTable[0];
MemSizeToPoolTable[BinnedSizeLimit+1] = &PagePoolTable[1];
check(MAX_POOLED_ALLOCATION_SIZE - 1 == PoolTable[POOL_COUNT - 1].BlockSize);
}
MemSizeToPoolTable, PoolTable, PagePoolTable을 을 조금 더 잘 이해하기 위해서 다음과 같은 그림을 보자.

FMallocBinned 에서 메모리를 할당하는 코드는 다음과 같다.
// Engine\\Source\\Runtime\\Core\\Private\\HAL\\MallocBinned.cpp
void* FMallocBinned::Malloc(SIZE_T Size, uint32 Alignment)
{
(......)
// 사이즈를 메모리 Alignment에 따라 조정한다
if (Alignment == DEFAULT_ALIGNMENT)
{
// 기본 메모리 Alignment는 16바이트이다.
Alignment = Private::DEFAULT_BINNED_ALLOCATOR_ALIGNMENT;
}
Alignment = FMath::Max<uint32>(Alignment, Private::DEFAULT_BINNED_ALLOCATOR_ALIGNMENT);
SIZE_T SpareBytesCount = FMath::Min<SIZE_T>(Private::DEFAULT_BINNED_ALLOCATOR_ALIGNMENT, Size);
// 조정된 Size
Size = FMath::Max<SIZE_T>(PoolTable[0].BlockSize, Size + (Alignment - SpareBytesCount));
(......)
FFreeMem* Free = nullptr;
bool bUsePools = true; // 기본적으로 메모리 풀을 사용한다.
(......)
if (bUsePools)
{
// 할당된 크기가 BinnedSizeLimit(32k)보다 작으면 조각(Fragmented)났다는 뜻이며 MemSizeToPoolTable에서 해당하는 크기의 FPoolTable을 찾는다.
if( Size < BinnedSizeLimit)
{
// Allocate from pool.
FPoolTable* Table = MemSizeToPoolTable[Size];
#ifdef USE_FINE_GRAIN_LOCKS
FScopeLock TableLock(&Table->CriticalSection);
#endif
checkSlow(Size <= Table->BlockSize);
Private::TrackStats(Table, (uint32)Size);
FPoolInfo* Pool = Table->FirstPool;
if( !Pool )
{
Pool = Private::AllocatePoolMemory(*this, Table, Private::BINNED_ALLOC_POOL_SIZE/*PageSize*/, Size);
}
Free = Private::AllocateBlockFromPool(*this, Table, Pool, Alignment);
}
// 할당되는 크기가 BinnedSizeLimit(32k)이상이고 첫번째 메모리 페이지풀의 블럭 사이즈(48k)이하 일 때
// 또는 PageSize(64k) 이상이고 두번째 메모리 페이지 풀의 블럭 사이즈(96k)이하 일 때
// 에는 메모리 페이지 풀 테이블, PagePoolTable에 저장된다.
else if ( ((Size >= BinnedSizeLimit && Size <= PagePoolTable[0].BlockSize) ||
(Size > PageSize && Size <= PagePoolTable[1].BlockSize)))
{
// Bucket in a pool of 3*PageSize or 6*PageSize
uint32 BinType = Size < PageSize ? 0 : 1; // 첫번째 메모리 페이지 풀 테이블이냐 두번째냐
uint32 PageCount = 3*BinType + 3;
FPoolTable* Table = &PagePoolTable[BinType];
#ifdef USE_FINE_GRAIN_LOCKS
FScopeLock TableLock(&Table->CriticalSection);
#endif
checkSlow(Size <= Table->BlockSize);
Private::TrackStats(Table, (uint32)Size);
FPoolInfo* Pool = Table->FirstPool;
if( !Pool )
{
Pool = Private::AllocatePoolMemory(*this, Table, PageCount*PageSize, BinnedSizeLimit+BinType);
}
Free = Private::AllocateBlockFromPool(*this, Table, Pool, Alignment);
}
// 할당될 메모리 크기가 메모리 페이지 사이즈보다도 크면 시스템에서 바로 할당을 받아 HashBuckets테이블에 저장한다. (메모리 풀링을 하지 않는다)
else
{
// Use OS for large allocations.
UPTRINT AlignedSize = Align(Size,PageSize);
SIZE_T ActualPoolSize; //TODO: use this to reduce waste?
Free = (FFreeMem*)Private::OSAlloc(*this, AlignedSize, ActualPoolSize);
if( !Free )
{
Private::OutOfMemory(AlignedSize);
}
void* AlignedFree = Align(Free, Alignment);
// Create indirect.
FPoolInfo* Pool;
{
#ifdef USE_FINE_GRAIN_LOCKS
FScopeLock PoolInfoLock(&AccessGuard);
#endif
Pool = Private::GetPoolInfo(*this, (UPTRINT)Free);
if ((UPTRINT)Free != ((UPTRINT)AlignedFree & ~((UPTRINT)PageSize - 1)))
{
// Mark the FPoolInfo for AlignedFree to jump back to the FPoolInfo for ptr.
for (UPTRINT i = (UPTRINT)PageSize, Offset = 0; i < AlignedSize; i += PageSize, ++Offset)
{
FPoolInfo* TrailingPool = Private::GetPoolInfo(*this, ((UPTRINT)Free) + i);
check(TrailingPool);
//Set trailing pools to point back to first pool
TrailingPool->SetAllocationSizes(0, 0, Offset, BinnedOSTableIndex);
}
}
}
Free = (FFreeMem*)AlignedFree;
Pool->SetAllocationSizes(Size, AlignedSize, BinnedOSTableIndex, BinnedOSTableIndex);
(......)
}
}
return Free;
}
위 코드를 요약하자면, IOS가 아닌 플랫폼이고 기본 페이지 사이즈(64k)일 때 FMallocBinned의 할당 전략을 다음과 같이 정리된다.
- 할당될 메모리의 크기가 0초과 32k미만 이면
MemSizeToPoolTable에서 알맞은 크기의FPoolTable, 풀 테이블을 찾아 블럭에 할당한 뒤 저장한다. - 할당될 메모리의 크기가 32k이상 48k이하 이거나 64k이상 96k이하이면
PagePoolTable의FPoolTable, 메모리 페이지 풀 테이블을 사용한다. - 그보다 큰 메모리의 크기가 할당될 때는 시스템에서 직접 할당받아 해쉬 버킷에 저장한다.
- 엥? 그러면 48k ~ 64k의 메모리 크기가 할당 될 경우도 시스템에서 할당받게되는 것이지 않나?
- 맞다. 만약 해당 구간의 메모리를 64k 크기의 블럭을 가지는 메모리 페이지 풀 테이블에 할당할 경우 최대 16k의 메모리 낭비가 발생할 수 있다. 무려 3분의 1의 낭비가 발생하는것이다. 그래서 이럴 바에 그냥 시스템 할당으로 넘기는 것이다.물론 작은 블록 여러개를 합쳐 해당 크기의 메모리를 담당하는 것도 가능하다. 예를들어 2k 블럭을 가지는 메모리 풀에서 25개의 블럭으로 할당이 가능하다. 하지만 해당 메모리 풀을 관리하는 비용이 증가하게된다.
FMallocBinned, FMallocBinned2, FMallocBinned3는 처음에 큰 메모리를 미리 할당 받은 뒤 작은 블럭 단위로 메모리들을 나누어 관리한다. 메모리 할당에는 효율적이지만, IO압력이 증가하고 블럭사이즈에 알맞지 않는 할당이 발생할 때마다 메모리 낭비가 발생한다.
다음과 같은 경우에 FMallocBinned의 메모리 낭비가 눈에 띄일 수 있다.
- 새로 할당된 메모리 풀이 많이 사용되지 않을 때, 특정 프로그램의 리소스를 뺏을 수 있다.
- 메모리 정렬과 사이즈 정렬때문에 메모리 블럭을 채우지 못하는 크기의 할당들이 큰 블럭 사이즈의 메모리 풀에 할당되며 메모리 낭비를 유발한다.
- 메모리 풀 테이블, 메모리 풀, 해쉬버킷도 리소스이며 관리해야한다.
FMallocBinned2새로운 메모리 박싱 할당 방식이다. 소스코드로 비추어 보았을 때FMallocBinned2는FMallocBinned보다 더 간단하며 작은 메모리 블럭 사이즈, Alignment 크기, 스레드 캐싱을 활성화 함에 따라 할당자와 정책이 정해진다.FMallocBinned364비트 시스템에서만 사용가능한 메모리 박싱 할당 방식이다.FMallocBinned2와 비슷하며 스레드 캐싱을 지원한다.FMallocTBB인텔의 라이브러리인 Thread Building Block을 이용한scalable_allocator할당자를 사용한다. 라이브러리는 다음과 같은 인터페이스를 노출한다.
// Engine\\Source\\ThirdParty\\IntelTBB\\IntelTBB-2019u8\\include\\tbb\\scalable_allocator.h
void * __TBB_EXPORTED_FUNC scalable_malloc (size_t size);
void __TBB_EXPORTED_FUNC scalable_free (void* ptr);
void * __TBB_EXPORTED_FUNC scalable_realloc (void* ptr, size_t size);
void * __TBB_EXPORTED_FUNC scalable_calloc (size_t nobj, size_t size);
int __TBB_EXPORTED_FUNC scalable_posix_memalign (void** memptr, size_t alignment, size_t size);
void * __TBB_EXPORTED_FUNC scalable_aligned_malloc (size_t size, size_t alignment);
void * __TBB_EXPORTED_FUNC scalable_aligned_realloc (void* ptr, size_t size, size_t alignment);
void __TBB_EXPORTED_FUNC scalable_aligned_free (void* ptr);
size_t __TBB_EXPORTED_FUNC scalable_msize (void* ptr);
FMallocTBB는 위 인터페이스를 사용하여 메모리 작업을 한다.
// Engine\\Source\\Runtime\\Core\\Private\\HAL\\MallocTBB.cpp
void* FMallocTBB::TryMalloc( SIZE_T Size, uint32 Alignment )
{
(......)
void* NewPtr = nullptr;
if( Alignment != DEFAULT_ALIGNMENT )
{
Alignment = FMath::Max(Size >= 16 ? (uint32)16 : (uint32)8, Alignment);
NewPtr = scalable_aligned_malloc( Size, Alignment ); // TBB Library's allocator
}
else
{
// Fulfill the promise of DEFAULT_ALIGNMENT, which aligns 16-byte or larger structures to 16 bytes,
// while TBB aligns to 8 by default.
NewPtr = scalable_aligned_malloc( Size, Size >= 16 ? (uint32)16 : (uint32)8); // TBB Library's allocator
}
(......)
return NewPtr;
}
위의 기본적인 메모리 할당자 말고도 FMallocDebug나 FMallocStomp, FMallocJemalloc(멀티스레딩), FMallocGPU(GPU 메모리 관련) 같은 디버깅용, 특수목적용 할당자가 존재한다. 궁금하면 직접 살펴보자.
Memory operation mode
이전 섹션에서 메모리 할당이 어떻게 이루어지는지를 살펴보았으면, 여기서는 메모리를 어떻게 다루는지를 살펴보자
GMalloc:GMalloc은 글로벌 메모리 할당자로써 언리얼엔진 실행시에 생성된다.
// Engine\\Source\\Runtime\\Core\\Private\\HAL\\UnrealMemory.cpp
static int FMemory_GCreateMalloc_ThreadUnsafe()
{
(......)
GMalloc = FPlatformMemory::BaseAllocator();
(......)
}
FPlatformMemory는 운영체제마다 달라진다. 예를들어서 윈도우즈는 FWindowsPlatformMemory 이다.
// Engine\\Source\\Runtime\\Core\\Public\\Windows\\WindowsPlatformMemory.h
struct CORE_API FWindowsPlatformMemory : public FGenericPlatformMemory
{
(......)
static class FMalloc* BaseAllocator();
(......)
};
typedef FWindowsPlatformMemory FPlatformMemory;
위 코드에서 볼 수 있듯, GMalloc은 사실 FMalloc의 인스턴스이다. 다양한 운영체제에서 FMalloc의 서브클래스들을 만들기 위해, 또는 다른 메모리 할당 전략을 사용하기 위해 사용된다. 다음코드를 살펴보자 FPlatformMemory , FWindowsPlatformMemory::BaseAllocator
// Engine\\Source\\Runtime\\Core\\Private\\Windows\\WindowsPlatformMemory.cpp
FMalloc* FWindowsPlatformMemory::BaseAllocator()
{
#if ENABLE_WIN_ALLOC_TRACKING
// This allows tracking of allocations that don't happen within the engine's wrappers.
// This actually won't be compiled unless bDebugBuildsActuallyUseDebugCRT is set in the
// build configuration for UBT.
_CrtSetAllocHook(WindowsAllocHook);
#endif // ENABLE_WIN_ALLOC_TRACKING
// 전처리기 매크로 정의에 따라 다른 할당자를 사용
if (FORCE_ANSI_ALLOCATOR) //-V517
{
AllocatorToUse = EMemoryAllocatorToUse::Ansi;
}
else if ((WITH_EDITORONLY_DATA || IS_PROGRAM) && TBB_ALLOCATOR_ALLOWED) //-V517
{
AllocatorToUse = EMemoryAllocatorToUse::TBB;
}
#if PLATFORM_64BITS
else if ((WITH_EDITORONLY_DATA || IS_PROGRAM) && MIMALLOC_ALLOCATOR_ALLOWED) //-V517
{
AllocatorToUse = EMemoryAllocatorToUse::Mimalloc;
}
else if (USE_MALLOC_BINNED3)
{
AllocatorToUse = EMemoryAllocatorToUse::Binned3;
}
#endif
else if (USE_MALLOC_BINNED2)
{
AllocatorToUse = EMemoryAllocatorToUse::Binned2;
}
else
{
AllocatorToUse = EMemoryAllocatorToUse::Binned;
}
#if !UE_BUILD_SHIPPING
// If not shipping, allow overriding with command line options, this happens very early so we need to use windows functions
const TCHAR* CommandLine = ::GetCommandLineW();
// 커맨드 라인 인자에 따라 할당자를 조정
if (FCString::Stristr(CommandLine, TEXT("-ansimalloc")))
{
AllocatorToUse = EMemoryAllocatorToUse::Ansi;
}
#if TBB_ALLOCATOR_ALLOWED
else if (FCString::Stristr(CommandLine, TEXT("-tbbmalloc")))
{
AllocatorToUse = EMemoryAllocatorToUse::TBB;
}
#endif
#if MIMALLOC_ALLOCATOR_ALLOWED
else if (FCString::Stristr(CommandLine, TEXT("-mimalloc")))
{
AllocatorToUse = EMemoryAllocatorToUse::Mimalloc;
}
#endif
#if PLATFORM_64BITS
else if (FCString::Stristr(CommandLine, TEXT("-binnedmalloc3")))
{
AllocatorToUse = EMemoryAllocatorToUse::Binned3;
}
#endif
else if (FCString::Stristr(CommandLine, TEXT("-binnedmalloc2")))
{
AllocatorToUse = EMemoryAllocatorToUse::Binned2;
}
else if (FCString::Stristr(CommandLine, TEXT("-binnedmalloc")))
{
AllocatorToUse = EMemoryAllocatorToUse::Binned;
}
#if WITH_MALLOC_STOMP
else if (FCString::Stristr(CommandLine, TEXT("-stompmalloc")))
{
AllocatorToUse = EMemoryAllocatorToUse::Stomp;
}
#endif // WITH_MALLOC_STOMP
#endif // !UE_BUILD_SHIPPING
// 선택된 할당자 타입에 따라 FMalloc 서브클래스를 선
switch (AllocatorToUse)
{
case EMemoryAllocatorToUse::Ansi:
return new FMallocAnsi();
#if WITH_MALLOC_STOMP
case EMemoryAllocatorToUse::Stomp:
return new FMallocStomp();
#endif
#if TBB_ALLOCATOR_ALLOWED
case EMemoryAllocatorToUse::TBB:
return new FMallocTBB();
#endif
#if MIMALLOC_ALLOCATOR_ALLOWED && PLATFORM_SUPPORTS_MIMALLOC
case EMemoryAllocatorToUse::Mimalloc:
return new FMallocMimalloc();
#endif
case EMemoryAllocatorToUse::Binned2:
return new FMallocBinned2();
#if PLATFORM_64BITS
case EMemoryAllocatorToUse::Binned3:
return new FMallocBinned3();
#endif
default: // intentional fall-through
case EMemoryAllocatorToUse::Binned:
return new FMallocBinned((uint32)(GetConstants().BinnedPageSize&MAX_uint32), (uint64)MAX_uint32 + 1);
}
}
여기서 GMalloc이 FMalloc의 서브클래스들을 동작시키는것을 볼 수 있다. 다음 테이블은 운영체제와 할당 전략에 따른 표이다.
| OS | Supported Memory Allocation Methods | Default Memory Allocation Method |
|---|---|---|
| Windows | Ansi, Binned, Binned2, Binned3, TBB, Stomp, Mimalloc | Binned |
| Android | Binned, Binned2, Binned3 | Binned |
| Apple(IOS, Mac) | Ansi, Binned, Binned2, Binned3 | Binned |
| Unix | Ansi, Binned, Binned2, Binned3, Stomp, Jemalloc | Binned |
| HoloLens | Ansi, Binned, TBB | Binned |
FMemory:FMemory는 언리얼 엔진의 유틸리티 스태틱 클래스이다. 메모리를 조작하기위한 스태틱 메서드를 제공한다
// Engine\\Source\\Runtime\\Core\\Public\\HAL\\UnrealMemory.h
struct CORE_API FMemory
{
// C의 메모리 할당/해제 인터페이스를 직접 호출한다
static void* SystemMalloc(SIZE_T Size);
static void SystemFree(void* Ptr);
// GMalloc 개체를 통해 작업한다
static void* Malloc(SIZE_T Count, uint32 Alignment = DEFAULT_ALIGNMENT);
static void* Realloc(void* Original, SIZE_T Count, uint32 Alignment = DEFAULT_ALIGNMENT);
static void Free(void* Original);
static void* MallocZeroed(SIZE_T Count, uint32 Alignment = DEFAULT_ALIGNMENT);
// 메모리 작업용 메서드들
static void* Memmove( void* Dest, const void* Src, SIZE_T Count );
static int32 Memcmp( const void* Buf1, const void* Buf2, SIZE_T Count );
static void* Memset(void* Dest, uint8 Char, SIZE_T Count);
static void* Memzero(void* Dest, SIZE_T Count);
static void* Memcpy(void* Dest, const void* Src, SIZE_T Count);
static void* BigBlockMemcpy(void* Dest, const void* Src, SIZE_T Count);
static void* StreamingMemcpy(void* Dest, const void* Src, SIZE_T Count);
static void Memswap( void* Ptr1, void* Ptr2, SIZE_T Size );
(......)
};
GMalloc과 C스타일 시스템 메모리 할당 조작 기능을 제공한다.
new/delete연산자 :new나delete를 오버라이드하는 몇 클래스를 제외하고, 다음 선언들이 글로벌new,delete연산자를 위해 사용된다.
// Engine\\Source\\Runtime\\Core\\Public\\Modules\\Boilerplate\\ModuleBoilerplate.h
#define REPLACEMENT_OPERATOR_NEW_AND_DELETE \\
OPERATOR_NEW_MSVC_PRAGMA void* operator new ( size_t Size ) OPERATOR_NEW_THROW_SPEC { return FMemory::Malloc( Size ); } \\
OPERATOR_NEW_MSVC_PRAGMA void* operator new[]( size_t Size ) OPERATOR_NEW_THROW_SPEC { return FMemory::Malloc( Size ); } \\
OPERATOR_NEW_MSVC_PRAGMA void* operator new ( size_t Size, const std::nothrow_t& ) OPERATOR_NEW_NOTHROW_SPEC { return FMemory::Malloc( Size ); } \\
OPERATOR_NEW_MSVC_PRAGMA void* operator new[]( size_t Size, const std::nothrow_t& ) OPERATOR_NEW_NOTHROW_SPEC { return FMemory::Malloc( Size ); } \\
void operator delete ( void* Ptr ) OPERATOR_DELETE_THROW_SPEC { FMemory::Free( Ptr ); } \\
void operator delete[]( void* Ptr ) OPERATOR_DELETE_THROW_SPEC { FMemory::Free( Ptr ); } \\
void operator delete ( void* Ptr, const std::nothrow_t& ) OPERATOR_DELETE_NOTHROW_SPEC { FMemory::Free( Ptr ); } \\
void operator delete[]( void* Ptr, const std::nothrow_t& ) OPERATOR_DELETE_NOTHROW_SPEC { FMemory::Free( Ptr ); } \\
void operator delete ( void* Ptr, size_t Size ) OPERATOR_DELETE_THROW_SPEC { FMemory::Free( Ptr ); } \\
void operator delete[]( void* Ptr, size_t Size ) OPERATOR_DELETE_THROW_SPEC { FMemory::Free( Ptr ); } \\
void operator delete ( void* Ptr, size_t Size, const std::nothrow_t& ) OPERATOR_DELETE_NOTHROW_SPEC { FMemory::Free( Ptr ); } \\
void operator delete[]( void* Ptr, size_t Size, const std::nothrow_t& ) OPERATOR_DELETE_NOTHROW_SPEC { FMemory::Free( Ptr ); }
코드에서 보이듯이, 글로벌 메모리 연산자 또한 FMemory를 통해 메모리를 조작한다.
- Specific APIs : 위 세가지 메모리 작업에 더해서 언리얼엔진은 특정기능을 위 메모리를 생성하고 해제하기 위한 인터페이스를 제공한다.
struct FPooledVirtualMemoryAllocator
{
void* Allocate(SIZE_T Size);
void Free(void* Ptr, SIZE_T Size);
};
class CORE_API FAnsiAllocator
{
class CORE_API ForAnyElementType
{
void ResizeAllocation(SizeType PreviousNumElements, SizeType NumElements, SIZE_T NumBytesPerElement);
};
};
class FVirtualAllocator
{
void* AllocateVirtualPages(uint32 NumPages, size_t AlignmentForCheck);
void FreeVirtual(void* Ptr, uint32 NumPages);
};
class RENDERER_API FVirtualTextureAllocator
{
uint32 Alloc(FAllocatedVirtualTexture* VT );
void Free(FAllocatedVirtualTexture* VT );
};
template<SIZE_T RequiredAlignment> class TMemoryPool
{
void* Allocate(SIZE_T Size);
void Free(void *Ptr, SIZE_T Size);
};
호출자의 관점에서 보면, 메모리를 조작하기 위해서는 관련 메서드들이 사용되는것이 대부분이고 기본 시스템 메모리 인터페이스를 거의 호출하지는 않는다.
Garbage Collection
가비지 콜렉션은 유효하지 않은 리소스를 재활용하는데에 필요한 메커니즘이다.
- Mark-Sweep마크 앤 스윕 알고리듬은 두 단계로 나뉜다. 첫번째는 Mark 페이즈이며 루트에서 시작해 활성화된 개체를 순회하며 해당 개체가 가리키는 힙 메모리를 모두
TRUE로 마크한다.두번째 단계는 Sweep 페이즈이며 힙 리스트를 순회한다.FALSE로 마킹된 개체를 힙으로 반환하며 활성화된 개체의 태그를 리셋한다. - BiBOPBig Bag Of Pages의 약자이며 비슷한 크기의 개체들을 고정 사이즈 메모리 덩어리에 정리한다. 언리얼엔진의 메모리 블럭 전략과 유사하다.
- Conservative GCConservative GC는 포인터와 포인터가 아닌것을 구별할 수 없다. 가비지 콜렉션 수준에서는 변수가 메모리를 나타내는 포인터인지 알 수 없다. 그것을 알아내기 위해서 비용이 든다. 반대는 Exact GC이며, 포인터인것과 아닌것을 구분하기 위해 태그를 사용한다.
- Generational GCGenerational 가비지 콜렉션은 개체에 나이라는 콘셉트를 도입한다. 나이가 많아(오래되어) 가비지일 확률이 높은 개체를 우선적으로 탐색하여 효율을 높인다.
- Incremental GC가비지 콜렉션이 실행되는, 다른말로하면 런타임이 정지할 시간의 제한시간을 정해 가비지 콜렉션을 점진적으로 진행하는 방식이다.
- Reference Counting개체의 레퍼런스 카운트를 통해 GC 처리량을 늘린다.
언리얼엔진의 GC
언리얼엔진의 가비지 콜렉션 모듈의 소스코드는 다음과 같다.
// Engine\\Source\\Runtime\\CoreUObject\\Private\\UObject\\GarbageCollection.cpp
void CollectGarbage(EObjectFlags KeepFlags, bool bPerformFullPurge)
{
// 다른 스레드가 GC작업을 진행하지 않도록 락을 얻는다
AcquireGCLock();
// GC를 실행한다.
CollectGarbageInternal(KeepFlags, bPerformFullPurge);
// GC 종료 후 락을 해제한다.
ReleaseGCLock();
}
// GC 작업이 진행되는 곳이다.
// KeepFlags:UObject의 태그를 사용할지 결정한다.
// bPerformFullPurge:점진적 업데이트를 사용할지 결정한다.
void CollectGarbageInternal(EObjectFlags KeepFlags, bool bPerformFullPurge)
{
(......)
{
FGCScopeLock GCLock;
// 마지막 점진적 작업이 종료되었는지 확인하거나 풀 클린업이 필요한지 확인한다.
if (GObjIncrementalPurgeIsInProgress || GObjPurgeIsRequired)
{
IncrementalPurgeGarbage(false);
FMemory::Trim();
}
// This can happen if someone disables clusters from the console (gc.CreateGCClusters)
if (!GCreateGCClusters && GUObjectClusters.GetNumAllocatedClusters())
{
GUObjectClusters.DissolveClusters(true);
}
(......)
// Fall back to single threaded GC if processor count is 1 or parallel GC is disabled
// or detailed per class gc stats are enabled (not thread safe)
// Temporarily forcing single-threaded GC in the editor until Modify() can be safely removed from HandleObjectReference.
const bool bForceSingleThreadedGC = ShouldForceSingleThreadedGC();
// Run with GC clustering code enabled only if clustering is enabled and there's actual allocated clusters
const bool bWithClusters = !!GCreateGCClusters && GUObjectClusters.GetNumAllocatedClusters();
{
const double StartTime = FPlatformTime::Seconds();
FRealtimeGC TagUsedRealtimeGC;
// 개체에 대해 Mark 작업을 한다(ReachabilityAnalysis)
TagUsedRealtimeGC.PerformReachabilityAnalysis(KeepFlags, bForceSingleThreadedGC, bWithClusters);
UE_LOG(LogGarbage, Log, TEXT("%f ms for GC"), (FPlatformTime::Seconds() - StartTime) * 1000);
}
// Reconstruct clusters if needed
if (GUObjectClusters.ClustersNeedDissolving())
{
const double StartTime = FPlatformTime::Seconds();
GUObjectClusters.DissolveClusters();
UE_LOG(LogGarbage, Log, TEXT("%f ms for dissolving GC clusters"), (FPlatformTime::Seconds() - StartTime) * 1000);
}
// Fire post-reachability analysis hooks
FCoreUObjectDelegates::PostReachabilityAnalysis.Broadcast();
{
FGCArrayPool::Get().ClearWeakReferences(bPerformFullPurge);
// 마킹되지 않은(Unreachable) 개체를 모은다.
GatherUnreachableObjects(bForceSingleThreadedGC);
if (bPerformFullPurge || !GIncrementalBeginDestroyEnabled)
{
// 해쉬테이블에서 닿을 수 없는 개체를 지운다.
UnhashUnreachableObjects(/**bUseTimeLimit = */ false);
FScopedCBDProfile::DumpProfile();
}
}
// Set flag to indicate that we are relying on a purge to be performed.
GObjPurgeIsRequired = true;
// 모든 가비지를 삭제한다.
if (bPerformFullPurge || GIsEditor)
{
IncrementalPurgeGarbage(false);
}
// UObject 해쉬 테이블을 정리한다.
if (bPerformFullPurge)
{
ShrinkUObjectHashTables();
}
// Destroy all pending delete linkers
DeleteLoaders();
// 메모리를 정리, 해제한다.
FMemory::Trim();
}
// Route callbacks to verify GC assumptions
FCoreUObjectDelegates::GetPostGarbageCollect().Broadcast();
STAT_ADD_CUSTOMMESSAGE_NAME( STAT_NamedMarker, TEXT( "GarbageCollection - End" ) );
}
마킹 페이즈는 FRealtimeGC::PerformReachabilityAnalysis 에서 진행된다.
// Engine\\Source\\Runtime\\CoreUObject\\Private\\UObject\\GarbageCollection.cpp
class FRealtimeGC : public FGarbageCollectionTracer
{
void PerformReachabilityAnalysis(EObjectFlags KeepFlags, bool bForceSingleThreaded, bool bWithClusters)
{
(......)
/** Growing array of objects that require serialization */
FGCArrayStruct* ArrayStruct = FGCArrayPool::Get().GetArrayStructFromPool();
TArray<UObject*>& ObjectsToSerialize = ArrayStruct->ObjectsToSerialize;
// 개체의 카운트를 리셋한다.
GObjectCountDuringLastMarkPhase.Reset();
// Make sure GC referencer object is checked for references to other objects even if it resides in permanent object pool
if (FPlatformProperties::RequiresCookedData() && FGCObject::GGCObjectReferencer && GUObjectArray.IsDisregardForGC(FGCObject::GGCObjectReferencer))
{
ObjectsToSerialize.Add(FGCObject::GGCObjectReferencer);
}
{
const double StartTime = FPlatformTime::Seconds();
// 개체를 마크하기 위해 해당 개체의 함수를 마킹함수를 호출한다.
(this->*MarkObjectsFunctions[GetGCFunctionIndex(!bForceSingleThreaded, bWithClusters)])(ObjectsToSerialize, KeepFlags);
UE_LOG(LogGarbage, Verbose, TEXT("%f ms for Mark Phase (%d Objects To Serialize"), (FPlatformTime::Seconds() - StartTime) * 1000, ObjectsToSerialize.Num());
}
{
const double StartTime = FPlatformTime::Seconds();
// 개체의 Reachability Analysis를 진행한다.
PerformReachabilityAnalysisOnObjects(ArrayStruct, bForceSingleThreaded, bWithClusters);
UE_LOG(LogGarbage, Verbose, TEXT("%f ms for Reachability Analysis"), (FPlatformTime::Seconds() - StartTime) * 1000);
}
// Allowing external systems to add object roots. This can't be done through AddReferencedObjects
// because it may require tracing objects (via FGarbageCollectionTracer) multiple times
FCoreUObjectDelegates::TraceExternalRootsForReachabilityAnalysis.Broadcast(*this, KeepFlags, bForceSingleThreaded);
FGCArrayPool::Get().ReturnToPool(ArrayStruct);
#if UE_BUILD_DEBUG
FGCArrayPool::Get().CheckLeaks();
#endif
}
};
위 함수는 사실 클러스터 프로세싱을 병렬로 지원하는 두 템플릿 함수를 실행할 뿐이다.
// Engine\\Source\\Runtime\\CoreUObject\\Private\\UObject\\GarbageCollection.cpp
class FRealtimeGC : public FGarbageCollectionTracer
{
MarkObjectsFn MarkObjectsFunctions[4];
ReachabilityAnalysisFn ReachabilityAnalysisFunctions[4];
FRealtimeGC()
{
MarkObjectsFunctions[GetGCFunctionIndex(false, false)] = &FRealtimeGC::MarkObjectsAsUnreachable<false, false>;
MarkObjectsFunctions[GetGCFunctionIndex(true, false)] = &FRealtimeGC::MarkObjectsAsUnreachable<true, false>;
MarkObjectsFunctions[GetGCFunctionIndex(false, true)] = &FRealtimeGC::MarkObjectsAsUnreachable<false, true>;
MarkObjectsFunctions[GetGCFunctionIndex(true, true)] = &FRealtimeGC::MarkObjectsAsUnreachable<true, true>;
ReachabilityAnalysisFunctions[GetGCFunctionIndex(false, false)] = &FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal<false, false>;
ReachabilityAnalysisFunctions[GetGCFunctionIndex(true, false)] = &FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal<true, false>;
ReachabilityAnalysisFunctions[GetGCFunctionIndex(false, true)] = &FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal<false, true>;
ReachabilityAnalysisFunctions[GetGCFunctionIndex(true, true)] = &FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal<true, true>;
}
};
위 소스코드를 보았을 때, 언리얼 엔진의 GC는 다음 특성을 가진다.
- 메인 알고리듬은 Mark-Sweep이지만, 세단계를 거친다.
- 인덱싱으로 도달 할 수 있는 개체를 찾는다
- 클리닝할 개체를 모은다
- 2단계에서 모은 개체를 처리한다
- 게임 스레드에서
UObject들을 지운다 - 멀티스레드를 지원하는 스레드 세이프, 병렬 클러스터 프로세싱을 사용한다
- 풀 클린업이 지원되며 에디터에선 강제된다. GC가 스터터링을 유발하는 것을 방지하기 위해 점진적 작업도 지원한다
- 어떤 개체는 지워지지 않도록 지정할 수 있다.
실제로는 더욱 복잡하다.
Memory Barriers
membar, memory fences, fence instruction이라고 알려진 Memory barrier는 CPU 버퍼데이터의 out-of-order access, out-of-sync access를 다루기위해 만들어 졌다.
메모리의 비순차 접근 문제는 컴파일 타임이나 런타임에 발생할 수 있다. 컴파일 타임 비순차 접근은 컴파일러의 최적화 과정, 명령어 순서를 변경함에 따라 발생할 수 있으며 런타임 비순차 접근은 멀티프로세싱이나 멀티 스레딩으로 인해 발생할 수 있다.
컴파일 타임 메모리 배리어
다음과 같은 코드를 보자
sum = a + b + c;
print(sum);
컴파일 되었을 때, 어셈블리어로는 다음과 같은 경우 중 하나로 컴파일 될것이다.
sum = a + b;
sum = sum + c;
sum = b + c;
sum = a + sum;
sum = a + c;
sum = sum + b;
위의 겨웅에는 특별히 문제가 없지만, 아래 코드의 경우에는, 문제가 발생할 수 있다.
sum = a + b + sum;
print(sum);
///////////////////
sum = a + b;
sum = sum + sum;
sum = b + sum;
sum = a + sum;
sum = a + sum;
sum = sum + b;
따라서 컴파일타임에 이러한 문제를 막기 위해, 명령어간 메모리 배리어를 명시적으로 넣어줄 수 있다.
sum = a + b;
__COMPILE_MEMORY_BARRIER__;
sum = sum + c;
_COMPILE_MEMORY_BARRIER_ 는 컴ㅍ파일러 구현마다 달라질 수 있다.
// C11 / C++11
atomic_signal_fence(memory_order_acq_rel);
// Microsoft Visual C++
_ReadWriteBarrier();
// GCC
__sync_synchronize();
// GNU
asm volatile("" ::: "memory");
__asm__ __volatile__ ("" ::: "memory");
// Intel ICC
__memory_barrier();
여기에 더해, combined barrier 라는것이 있다. 다른 종류의 배리어들이 다른 명령어에 통합되어 들어가있는 것이다 (load, store, atomic increment, atomic compare and swap). 따라서 해당 명령어를 사용할때는 추가적인 배리어가 필요없다. 해당 명령어들은 CPU 아키텍쳐와 관련되어 있다.
런타임 메모리 배리어
멀티코어 프로세서가 등장하며 프로세서가 실제로 명령어를 실행하는 순서는 프로그래머가 작성한 순서가 아닌 데이터가 준비되는 순서로 변하였다. 비순차 멀티프로세서 아키텍쳐에서는 런타임 메모리 배리어 메커니즘 없이는 예상치 못한 실행결과가 많을 것이다. 다음과 같은 경우를 살펴보자
x, f = 0;
# processor 1
while (f == 0);
print(x);
# processor 2
x = 42;
f = 1;
실행 경우의 수 중 하나는 프로세서 1의 출력결과가 42일 것이다. 하지만 다른 경우, 프로세서 2가 비순차적으로 실행되어 두번째 줄이 먼저 실행되면 프로세서 1의 출력결과가 0일 수 있다. 비슷하게, 다른 경우에는 프로세서 1이 while 구문을 실행하기전에 값을 출력할 수도 있다. 이런 비순차 실행에서 예상치 못한 결과를 피하기 위해서는 두 프로세서 명령어간 런타임 메모리 배리어가 필요하다.
# processor 1
while (f == 0);
_RUNTIME_MEMORY_BARRIAR_; // print(x)를 실행하기 전 f를 반드시 읽어오도록 배리어
print(x);
# processor 2
x = 42;
_RUNTIME_MEMORY_BARRIAR_; // f=1을 실행하기 전 x=42가 다른 프로세서에 반영되도록 배리어
f = 1;
위의 예시가 런타임 메모리 배리어의 예시이다. 하드웨어 아키텍쳐마다 다른 구현이 존재한다.
하드웨어 수준에서는 L1, L2, L3등 여러 레벨의 캐시, 스토어 버퍼, 멀티코어와 멀티 스레딩 등 여러 요소가 있으며 메로리의 순서를 보장하기 위해서는 많은 상태와 메세지 패싱(MESI, MESI messages)들이 정의된다. 상태 수를 모두 더하면 10종류 가량되며, 하드웨어 아키텍쳐마다 다르다.
- MESI protocol

MESI 프로토콜은 invalidation-based cache coherence 프로토콜이다. write-back 캐싱을 지원하는 가장 보편적으로 사용되는 프로토콜이다.
MESI 프로토콜의 기본적인 상태는 다음과 같다 : Modified, Exclusive, Shared, Invalid
Messages : Read, Read Response, Invalidate, Invalidate Acknowledge, Read Invalidate, Writeback
위 그림은 MESI 프로토콜의 기본 상태 변화 다이어그램이다.
로드 배리어와 스토어 배리어도 등장하였다. 명령어 이전에 로드배리어를 삽입하면 캐시에 있는 데이터를 무효하 하고 메인 메모리로부터 데이터를 읽도록 한다. 명령어 뒤에 스토어 배리어를 삽입하면 캐시에 있는 최신 데이터를 메인 메모리에 바로 작성하고 다른 프로세서 스레드들이 참조할 수 있다.
로드배리어와 스토어배리어를 조합하면 다음과 같은 네 종류의 커맨드가 가능하다
- LoadLoad : 명령어 reordering으로 유발되는 out-of-order read를 배리어 앞뒤로 방지한다LoadLoad barrier가 추가되면 CPU가 LoadLoad barrier를 비순차적으로 접근해도 해당 배리어 앞뒤로 점프하지 않는다.
if (IsValid) // IsValid를 로드하고 평가한다
{
LOADLOAD_FENCE(); // LoadLoad배리어는 두 Load 사이의 reordering을 방지한다. 따라서 Value를 로드하고 읽기 전에 IsValid를 읽고 평가하도록 한다.
return Value; // Value 읽기
}
- StoreStore : 배리어 앞뒤로의 out-of-order write를 방지한다.
Value = x; // Value write
STORESTORE_FENCE(); // StoreStore배리어는 두 write 사이의 reordering을 방지한다. IsValid write작업과 다음 작업들이 실행되기 이전에 Value의 write작업이 실행되고 다른 프로세서에 보이도록 한다.
IsValid = 1; // IsValid write
- LoadStore : 배리어 앞의 로드 작업과 배리어 뒤의 스토어 작업의 reordering을 방지한다.
if (IsValid) // IsValid를 로드하고 평가
{
LOADSTORE_FENCE(); // load와 write사이의 reordering을 방지한다. IsValid가 Value 이전에 로드되고, 이후의 write작업은 플러쉬된다.
Value = x; // Value write
}
- StoreLoad : 배리어 앞의 write 작업과 배리어 뒤의 load 작업간의 reordering을 방지한다. 대부분의 CPU 아키텍쳐에서는 다른 세 배리어와 함께 보편적인 배리어이다. 이 배리어가 제일 비싸다
Value = x; // Value write
STORELOAD_FENCE(); // IsValid의 읽기작업이 실행되기 전 Value의 write 작업이 모든 프로세서에 보이도록 보장한다
if (IsValid) // IsValid를 읽고 평가
{
return 1;
}
언리얼 엔진의 메모리 배리어
언리얼 엔진의 메모리 배리어는 특정 클래스로 캡슐화 되어 있다. FGenericPlatformMisc
struct FGenericPlatformMisc
{
(......)
/**
* Enforces strict memory load/store ordering across the memory barrier call.
*/
static void MemoryBarrier();
(......)
};
// Windows
struct FWindowsPlatformMisc : public FGenericPlatformMisc
{
(......)
static void MemoryBarrier()
{
_mm_sfence();
}
(......)
};
#if WINDOWS_USE_FEATURE_PLATFORMMISC_CLASS
typedef FWindowsPlatformMisc FPlatformMisc;
#endif
// Android
struct FAndroidMisc : public FGenericPlatformMisc
{
(......)
static void MemoryBarrier()
{
__sync_synchronize();
}
(......)
};
#if !PLATFORM_LUMIN
typedef FAndroidMisc FPlatformMisc;
#endif
// Apple
struct FApplePlatformMisc : public FGenericPlatformMisc
{
(......)
static void MemoryBarrier()
{
__sync_synchronize();
}
(......)
};
// Linux
struct FLinuxPlatformMisc : public FGenericPlatformMisc
{
(......)
static void MemoryBarrier()
{
__sync_synchronize();
}
(......)
};
#if !PLATFORM_LUMIN
typedef FLinuxPlatformMisc FPlatformMisc;
#endif
x86아키텍쳐를 사용하는 윈도우즈를 제외하고는 다른 시스템은 모두 GCC의 메모리 배리어 명령어를 사용한다. 윈도우는 특이하게도 런타임 메모리 배리어이고 다른 프랫폼은 컴파일 타임 메모리 배리어로 보인다. 이것은, 특정 컴파일러의 컴파일 타임 메모리 베리어가 런타임시 하드웨어의 메모리 배리어를 동작시키기도 하기 때문이다.
언리얼 엔진의 멀티플랫폼 대응을위한 다형적 동작은 호출자로 하여금 플랫폼을 고려하지 않아도 되도록 한다. : FPlatformMisc::MemoryBarrier()
// Engine\\Source\\Runtime\\RenderCore\\Private\\RenderingThread.cpp
void RenderingThreadMain( FEvent* TaskGraphBoundSyncEvent )
{
LLM_SCOPE(ELLMTag::RenderingThreadMemory);
ENamedThreads::Type RenderThread = ENamedThreads::Type(ENamedThreads::ActualRenderingThread);
ENamedThreads::SetRenderThread(RenderThread);
ENamedThreads::SetRenderThread_Local(ENamedThreads::Type(ENamedThreads::ActualRenderingThread_Local));
FTaskGraphInterface::Get().AttachToThread(RenderThread);
// 배리어
FPlatformMisc::MemoryBarrier();
// Inform main thread that the render thread has been attached to the taskgraph and is ready to receive tasks
if( TaskGraphBoundSyncEvent != NULL )
{
TaskGraphBoundSyncEvent->Trigger();
}
// set the thread back to real time mode
FPlatformProcess::SetRealTimeMode();
#if STATS
if (FThreadStats::WillEverCollectData())
{
FThreadStats::ExplicitFlush(); // flush the stats and set update the scope so we don't flush again until a frame update, this helps prevent fragmentation
}
#endif
FCoreDelegates::PostRenderingThreadCreated.Broadcast();
check(GIsThreadedRendering);
FTaskGraphInterface::Get().ProcessThreadUntilRequestReturn(RenderThread);
// 배리어
FPlatformMisc::MemoryBarrier();
check(!GIsThreadedRendering);
FCoreDelegates::PreRenderingThreadDestroyed.Broadcast();
#if STATS
if (FThreadStats::WillEverCollectData())
{
FThreadStats::ExplicitFlush(); // Another explicit flush to clean up the ScopeCount established above for any stats lingering since the last frame
}
#endif
ENamedThreads::SetRenderThread(ENamedThreads::GameThread);
ENamedThreads::SetRenderThread_Local(ENamedThreads::GameThread_Local);
// 배리어
FPlatformMisc::MemoryBarrier();
}
런타임 메모리 배리어는 사용하지만, 컴파일 타임 배리어는 따로 추상화되어있지 않은듯 하다. 또한 그래픽스 API의 메모리 배리어도 있다.
// Direct3D / Metal
FPlatformMisc::MemoryBarrier();
// OpenGL
glMemoryBarrier(Barriers);
// Vulkan
typedef struct VkMemoryBarrier {
(......)
} VkMemoryBarrier;
typedef struct VkBufferMemoryBarrier {
(......)
} VkBufferMemoryBarrier;
typedef struct VkImageMemoryBarrier {
(......)
} VkImageMemoryBarrier;
Engine Startup Process
윈도우즈 기준으로, 시작코드는 WinMain에서 시작한다.
// Engine\\Source\\Runtime\\Launch\\Private\\Windows\\LaunchWindows.cpp
int32 WINAPI WinMain( _In_ HINSTANCE hInInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ char*, _In_ int32 nCmdShow )
{
TRACE_BOOKMARK(TEXT("WinMain.Enter"));
SetupWindowsEnvironment();
int32 ErrorLevel = 0;
hInstance = hInInstance;
const TCHAR* CmdLine = ::GetCommandLineW();
// 커맨드 라인 인자를 처리한다.
if ( ProcessCommandLine() )
{
CmdLine = *GSavedCommandLine;
}
if ( FParse::Param( CmdLine, TEXT("unattended") ) )
{
SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX);
}
(......)
// 익셉션 핸들링 설정과 에러레벨 설정에 마다 다른 실행경로가 있지만 결국은 모두 GuardedMain으로 간다
#if UE_BUILD_DEBUG
if( true && !GAlwaysReportCrash )
#else
if( bNoExceptionHandler || (FPlatformMisc::IsDebuggerPresent() && !GAlwaysReportCrash ))
#endif
{
// GuardedMain
ErrorLevel = GuardedMain( CmdLine );
}
else
{
(......)
{
GIsGuarded = 1;
// GuardedMain
ErrorLevel = GuardedMainWrapper( CmdLine );
GIsGuarded = 0;
}
(......)
}
// Exit
FEngineLoop::AppExit();
(......)
return ErrorLevel;
}
GuardedMain
// Engine\\Source\\Runtime\\Launch\\Private\\Launch.cpp
int32 GuardedMain( const TCHAR* CmdLine )
{
(......)
// EngineExit이 호출될 수 있도록 한다.
struct EngineLoopCleanupGuard
{
~EngineLoopCleanupGuard()
{
EngineExit();
}
} CleanupGuard;
(......)
// 엔진의 Pre-initialization
int32 ErrorLevel = EnginePreInit( CmdLine );
if ( ErrorLevel != 0 || IsEngineExitRequested() )
{
return ErrorLevel;
}
{
(......)
#if WITH_EDITOR
if (GIsEditor)
{
// 에디터 초기화
ErrorLevel = EditorInit(GEngineLoop);
}
else
#endif
{
// 엔진(Non-Editor)초기화
ErrorLevel = EngineInit();
}
}
(......)
while( !IsEngineExitRequested() )
{
// 프레임 업데이트
EngineTick();
}
#if WITH_EDITOR
if( GIsEditor )
{
// 에디터 Exit
EditorExit();
}
#endif
return ErrorLevel;
}
Engine Pre-Initialization(EnginePreInit) -> Engine Initialization(EngineInit) -> Engine Frame Update (EngineTick) -> Engine Exit (EngineExit)
Engine pre-initialization
기본적인 핵심 모듈을 초기화 하며 시작 화면에서 진행된다.
// Engine\\Source\\Runtime\\Launch\\Private\\Launch.cpp
int32 EnginePreInit( const TCHAR* CmdLine )
{
// GEngineLoop Preinit 호출
int32 ErrorLevel = GEngineLoop.PreInit( CmdLine );
return( ErrorLevel );
}
// Engine\\Source\\Runtime\\Launch\\Private\\LaunchEngineLoop.cpp
int32 FEngineLoop::PreInit(const TCHAR* CmdLine)
{
// 초기 실행창의 로딩바 초기화
const int32 rv1 = PreInitPreStartupScreen(CmdLine);
if (rv1 != 0)
{
PreInitContext.Cleanup();
return rv1;
}
const int32 rv2 = PreInitPostStartupScreen(CmdLine);
if (rv2 != 0)
{
PreInitContext.Cleanup();
return rv2;
}
return 0;
}
엔진의 pre-initialization 단계에서는 랜덤시드 초기화, CoreUObject 모듈 로드, FTaskGraphInterface 모듈 시작후 게임스레드 할당, 그 뒤 언리얼 엔진의 다른 핵심 코어 모듈을 로드한다 (Engine, Renderer, SlateRHIRenderer, Landscape, TextureCompressor ...).
void FEngineLoop::LoadPreInitModules()
{
#if WITH_ENGINE
FModuleManager::Get().LoadModule(TEXT("Engine"));
FModuleManager::Get().LoadModule(TEXT("Renderer"));
FModuleManager::Get().LoadModule(TEXT("AnimGraphRuntime"));
FPlatformApplicationMisc::LoadPreInitModules();
#if !UE_SERVER
if (!IsRunningDedicatedServer() )
{
if (!GUsingNullRHI)
{
// This needs to be loaded before InitializeShaderTypes is called
FModuleManager::Get().LoadModuleChecked<ISlateRHIRendererModule>("SlateRHIRenderer");
}
}
#endif
FModuleManager::Get().LoadModule(TEXT("Landscape"));
FModuleManager::Get().LoadModule(TEXT("RenderCore"));
#if WITH_EDITORONLY_DATA
FModuleManager::Get().LoadModule(TEXT("TextureCompressor"));
#endif
#endif // WITH_ENGINE
#if (WITH_EDITOR && !(UE_BUILD_SHIPPING || UE_BUILD_TEST))
FModuleManager::Get().LoadModule(TEXT("AudioEditor"));
FModuleManager::Get().LoadModule(TEXT("AnimationModifiers"));
#endif
}
그 후 로그 설정, 진행도 정보 로딩, 메모리 할당자의 TLS(Thread Localization) 설정, 글로벌 상태의 설정, 작업 디렉토리의 설정, 그리고 핵심 코어모듈의 초기화가 진행된다 (FModuleManager, IFileManager, FPlatformFileManager ...). 그리고 게임 스레드를 메인스레드로 지정하고 스레드 ID를 기록한다.
int32 FEngineLoop::PreInitPreStartupScreen(const TCHAR* CmdLine)
{
(......)
GGameThreadId = FPlatformTLS::GetCurrentThreadId();
GIsGameThreadIdInitialized = true;
FPlatformProcess::SetThreadAffinityMask(FPlatformAffinity::GetMainGameMask());
FPlatformProcess::SetupGameThread();
(......)
}
그 뒤 쉐이더의 소스 경로를 매핑하고 네트워크 토근을 처리한다. 그 뒤에 FCsvProfiler, AppLifetimeEventCapture, FTracingProfiler 등의 다른 모듈과 App을 초기화 한다. 그리고 스레드 풀과 플랫폼이 멀티스레딩을 지원하는지의 따라 특정 개수의 스레드를 생성한다.
int32 FEngineLoop::PreInitPreStartupScreen(const TCHAR* CmdLine)
{
(......)
if (FPlatformProcess::SupportsMultithreading())
{
{
TRACE_THREAD_GROUP_SCOPE("IOThreadPool");
SCOPED_BOOT_TIMING("GIOThreadPool->Create");
GIOThreadPool = FQueuedThreadPool::Allocate();
int32 NumThreadsInThreadPool = FPlatformMisc::NumberOfIOWorkerThreadsToSpawn();
if (FPlatformProperties::IsServerOnly())
{
NumThreadsInThreadPool = 2;
}
verify(GIOThreadPool->Create(NumThreadsInThreadPool, 96 * 1024, TPri_AboveNormal));
}
}
(......)
}
그리고 UGameUserSettings, Scalability, Render Thread, FConfigCaceIni, FPlatformMemory, Physics, RHI, RenderUtils, FShaderCodeLibrary, ShaderHashCahce들을 초기화 한다.
pre-initialization의 뒷부분에선 SlateRenderer, IProjectManager, IInstallBundleManager, MoviePlayer 등등을 초기화 한다
Engine Initialization
엔진 초기화는 에디터 모드와 비에디터 모드로 구분된다. 여기서는 비에디터의 경우만 분석한다.
FEngineLoop::Init에서 초기화가 진행된다.
- 설정파일에 따른 게임 엔진 인스턴스를 생성한다.
GEngine - 멀티스레딩 지원 여부에 따라
EngineService인스턴스를 만든다 GEngine->Start()- Media, AutomationWorker, AutomationController, ProfilerClient, SequenceRecorder, SequenceRecorderSections 모듈을 로드한다'
- 스레드 하트비트를 활성화 한다.
FThreadHeartBeat FExternalProfiler
Engine Frame Update
초기화가 완료되면 FEngineLoop:Tick에서 프레임이 진행된다
- 스레드를 켜고 하트비트를 후킹한다
- 프레임 단위로 렌더 모듈이 업데이트 될 수 있도록 개체를 업데이트 한다. (
FTickableObjectRenderThread인스턴스) - 프로파일러와 프레임을 맞춘다 (
FExternalProfiler) - 콘솔의 콜백 API를 실행한다
FlushRenderingCommands. 만약 별도의 렌더스레드가 활성화되지 않았다면, 렌더 명령은 게임 스레드에서 실행된다. 커맨드 큐가 드로우를 제출하도록 한다. 렌더 펜스가 마지막에 추가된다 (FRenderCommandFence)
// Engine\\Source\\Runtime\\RenderCore\\Private\\RenderingThread.cpp
void FlushRenderingCommands(bool bFlushDeferredDeletes)
{
(......)
if (!GIsThreadedRendering
&& !FTaskGraphInterface::Get().IsThreadProcessingTasks(ENamedThreads::GameThread)
&& !FTaskGraphInterface::Get().IsThreadProcessingTasks(ENamedThreads::GameThread_Local))
{
FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread);
FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread_Local);
}
ENQUEUE_RENDER_COMMAND(FlushPendingDeleteRHIResourcesCmd)(
[bFlushDeferredDeletes](FRHICommandListImmediate& RHICmdList)
{
RHICmdList.ImmediateFlush(
bFlushDeferredDeletes ?
EImmediateFlushType::FlushRHIThreadFlushResourcesFlushDeferredDeletes :
EImmediateFlushType::FlushRHIThreadFlushResources);
});
AdvanceFrameRenderPrerequisite();
FPendingCleanupObjects* PendingCleanupObjects = GetPendingCleanupObjects();
FRenderCommandFence Fence;
Fence.BeginFence();
Fence.Wait();
(......)
}
OnBeginFrame이벤트가 트리거된다- 스레드 로그를 리프레쉬한다
- 시간을
GEngine과 리프레쉬 하고 최대 프레임 레이트를 처리한다 - 모든
WroldContexts의 현재 월드를 순회하고 월드 내부 Scene의PrimitiveSceneInfo를 업데이트 한다.
for (const FWorldContext& Context : GEngine->GetWorldContexts())
{
UWorld* CurrentWorld = Context.World();
if (CurrentWorld)
{
FSceneInterface* Scene = CurrentWorld->Scene;
ENQUEUE_RENDER_COMMAND(UpdateScenePrimitives)(
[Scene](FRHICommandListImmediate& RHICmdList)
{
Scene->UpdateAllPrimitiveSceneInfos(RHICmdList);
});
}
}
- RHI 프레임 시작을 핸들링 한다
- 모든 Scene의
StartFrame을 호출한다 - 퍼포먼스 분석, 통계를 핸들링 한다
- 렌더 스레드의 프레임별 작업을 핸들링 한다
WorldToMetersScale을 핸들링 한다- 이벤트 플랫폼의 파일을 업데이트 한다
- 슬레이트 모듈의 인풋을 핸들링 한다.
UGameEngine::Tick. 이것이 메인 프레임 업데이트이다. 많은 로직이 여기서 처리된다.- 시간 여유가 있다면 로그를 리프레쉬 한다
- 닫힌 게임의 뷰포트를 클리닝 한다
- 서브시스템을 업데이트 한다
FEngineAnalytics와FStudioAnalytics모듈을 업데이트 한다- 활성화되어 있다면 ChaosModule을 업데이트 한다
- WorldTravel의 프레임 업데이트를 핸들링 한다
- 모든 월드의 프레임 업데이트를 핸들링 한다.
- Sky Light Component(
USkyLightComponent) 와 Reflector Ball Component(UReflectionCapturComponent) 를 업데이트 한다. 이것은 해당 두 컴포넌트는 특별하며 하드코딩이 필요한것을 의미한다. - 플레이어 개체를 핸들링한다 (
ULocalPlayer) - 레벨 스트리밍 로딩을 핸들링 한다
- 모든 업데이트 가능한 개체를 업데이트 한다.
FTickableGameObject가 업데이트 된다. - 게임 뷰포트를 업데이트 한다
- 창모드에서 윈도우를 핸들링 한다
- 뷰포트를 그린다
IStreamingManager와FAudioDeviceManager모듈을 업데이트 한다- 렌더링 관련 모듈을 업데이트한다.
GRenderingRealtimeClock,GRenderTargetPool,FRDGBuilder
GShaderCompilingManager의 비동기 컴파일 결과를 핸들링 한다GDistanceFieldAsyncQueue의 비동기 작업을 핸들링한다.- 슬레이트 시스템과 관련된것을 병렬로 처리한다
- ReplicatedProperties를 핸들링 한다
FTaskGraphInterface를 사용해ConcurrentTask에 저장된 작업들을 병렬로 처리한다- 렌더 큐에서 아직 처리되지 않은 렌더링 작업을 대기한다.
ENQUEUE_RENDER_COMMAND(WaitForOutstandingTasksOnly_for_DelaySceneRenderCompletion)(
[](FRHICommandList& RHICmdList)
{
QUICK_SCOPE_CYCLE_COUNTER(STAT_DelaySceneRenderCompletion_TaskWait);
FRHICommandListExecutor::GetImmediateCommandList().ImmediateFlush(EImmediateFlushType::WaitForOutstandingTasksOnly);
});
- AutomaionWorker 모듈을 업데이트한다
- RHI 모듈을 업데이트 한다
- 프레임 카운트(
GFrameCount)과 총 프레임 업데이트에 걸린시간 (TotalTickTime)을 핸들링 한다 - 다음 프레임에 클리닝 되어야할 개체를 모은다.
- End of Frame Synchronization Event(
FFRameEndSync) 개체를 핸들링 한다 Ticker와FThreadManager의TickDeferredCommands를 호출하면GEngine의 업데이트는 마무리 된다.OnEndFrame이벤트가 게임 스레드에서 트리거 된다EndFrame이벤트가 렌더링 모듈에서 트리거 된다
Engine Exit
비 에디터 모드에서 exit할때는 에러 레벨값이 반환된다. 에디터 모드의 경우에는 EditorExit 로직이 실행된다. 로그를 저장하고 개별 엔진 모듈을 해제한다.
// Engine\\Source\\Editor\\UnrealEd\\Private\\UnrealEdGlobals.cpp
void EditorExit()
{
TRACE_CPUPROFILER_EVENT_SCOPE(EditorExit);
GLevelEditorModeTools().SetDefaultMode(FBuiltinEditorModes::EM_Default);
GLevelEditorModeTools().DeactivateAllModes(); // this also activates the default mode
// Save out any config settings for the editor so they don't get lost
GEditor->SaveConfig();
GLevelEditorModeTools().SaveConfig();
// Clean up the actor folders singleton
FActorFolders::Cleanup();
// Save out default file directories
FEditorDirectories::Get().SaveLastDirectories();
// Allow the game thread to finish processing any latent tasks.
// Some editor functions may queue tasks that need to be run before the editor is finished.
FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread);
// Cleanup the misc editor
FUnrealEdMisc::Get().OnExit();
if( GLogConsole )
{
GLogConsole->Show( false );
}
delete GDebugToolExec;
GDebugToolExec = NULL;
}
Reference
https://www.cnblogs.com/timlly/p/13877623.html#141-object--actor-actorcomponent
댓글
댓글 쓰기