티스토리 뷰
🚀 v-table 없는 RHI 설계: Custom vs UE5
게임 엔진 아키텍처에서 RHI(Render Hardware Interface) 커맨드 시스템은
메인 스레드와 렌더 스레드 사이의 가교 역할을 합니다.
수만 개의 명령이 오가는 구간에서 CPU 오버헤드(특히 가상 함수 호출 비용)를
줄이는 것이 성능 최적화의 핵심입니다.
오늘은 직접 구현한 v-table 프리(Static Dispatch) 방식과 UE5의 하이브리드 방식을
소스 코드로 비교해 보겠습니다.
1. UE5의 방식: "Virtual 인터페이스 + 강력한 확장성"
먼저 언리얼 엔진 5의 소스 코드를 보겠습니다.
UE5는 성능을 챙기면서도 거대 엔진에 필요한 디버깅 기능을 통합한 설계를 가집니다.
// [UE5] 최상위 베이스: 가상 함수를 사용하여 인터페이스를 통일함
struct FRHICommandBase
{
FRHICommandBase* Next = nullptr;
virtual void ExecuteAndDestruct(FRHICommandListBase& CmdList) = 0;
};
// [UE5] 중간 템플릿: CRTP를 활용해 실행과 소멸을 자동화
template<typename TCmd, typename NameType = FUnnamedRhiCommand>
struct FRHICommand : public FRHICommandBase
{
void ExecuteAndDestruct(FRHICommandListBase& CmdList) override final
{
// 트레이싱 및 디버깅용 매크로 (UE5의 강점)
TRACE_CPUPROFILER_EVENT_SCOPE_ON_CHANNEL_STR(NameType::TStr(), RHICommandsChannel);
TCmd* ThisCmd = static_cast<TCmd*>(this);
ThisCmd->Execute(CmdList); // 실제 로직 실행
ThisCmd->~TCmd(); // 객체 소멸 (메모리 해제와 분리됨)
}
};
// [UE5] 실제 커맨드 정의 (매크로 사용)
FRHICOMMAND_MACRO(FRHICommandSetViewport)
{
float MinX, MinY, MinZ, MaxX, MaxY, MaxZ;
// ... 생성자 생략 ...
void Execute(FRHICommandListBase& CmdList);
};
- 특징
virtual키워드가 존재하며, 객체마다 v-table 포인터(vptr)를 가집니다. - 장점
Next포인터를 통한 링크드 리스트 순회가 매우 직관적이며, 매크로를 통해 콜스택 추적 및
프로파일링이 매우 용이합니다.
2. Custom RHI 방식: "Zero v-table"
제가 구현한 방식은 v-table 자체를 제거하여 CPU의 간접 참조 단계를 최소화한 방식입니다.
// [Custom] 가상 함수가 없는 베이스 구조체
struct RHICommandBase
{
using ExecuteFunc = void(*)(RHICommandBase*);
ExecuteFunc func; // 8바이트 함수 포인터가 vptr 역할을 대신함
RHICommandBase(const ExecuteFunc func) : func(func) {};
void Excute() { func(this); } // 직접 함수 주소로 점프
};
// [Custom] 템플릿 브릿지: 정적 함수를 부모의 함수 포인터에 등록
template<typename T>
struct RHICommand : public RHICommandBase
{
RHICommand() : RHICommandBase(&ExcuteAndDestruct) {};
static void ExcuteAndDestruct(RHICommandBase* commandBase)
{
T* realCommand = static_cast<T*>(commandBase);
realCommand->Execute(); // 멤버 함수 호출
realCommand->~T(); // 소멸자 호출
}
};
// [Custom] 실제 커맨드: virtual 없이 상속만으로 완성
struct RHICommandSetViewport : public RHICommand<RHICommandSetViewport>
{
void Execute() {
std::cout << typeid(*this).name() << std::endl;
}
};
- 특징
virtual키워드가 전혀 없습니다.
부모가 가진 일반 함수 포인터가 실행할 함수의 주소를 직접 들고 있습니다. - 장점
v-table 조회가 생략되어 1단계 점프(Direct Jump)로 실행됩니다.
객체 크기에서 8바이트(vptr)를 아낄 수 있고, CPU 캐시 효율이 극대화됩니다.
3. 두 방식의 일대일 비교
| 비교 항목 | UE5 RHI Command | Custom RHI Command |
|---|---|---|
| 다형성 구현 | 동적 다형성 (virtual) |
정적 다형성 (CRTP + FuncPtr) |
| 메모리 레이아웃 | vptr(8B) + Next(8B) = 16B | funcPtr(8B) = 8B |
| 실행 메커니즘 | vptr → vtable → jump (2단계) | funcPtr → jump (1단계) |
| 소멸 처리 | 가상 함수 내에서 수동 호출 | 정적 함수 내에서 수동 호출 |
| 디버깅 편의성 | 매우 뛰어남 (이름, 콜스택) | 직접 구현 필요 (가벼움) |
4. 결론: 왜 v-table을 버리는가?
UE5는 수많은 개발자가 협업하는 거대 엔진이기에 가상 함수 1번의 비용을 지불하더라도
디버깅과 관리의 편의성을 선택한 것으로 보입니다.
대신 Linear Allocator를 써서 연속된 메모리에 명령을 배치해 성능 저하를 막았습니다.
반면, 제가 구현한 방식은 "불필요한 v-table을 제거하겠다"는 최적화 철학을 가집니다.
v-table을 제거함으로써 CPU 파이프라인의 분기 예측을 돕고, 메모리 대역폭을 낭비하지 않습니다.
'개인 프로젝트 > Renderer' 카테고리의 다른 글
| [ Renderer ] UE5 - RHI 계층 구조 (0) | 2026.01.27 |
|---|---|
| [ Renderer ] World & Renderer Worker (0) | 2025.12.30 |
| [ Renderer ] wtr::Engine 구조 설계 (0) | 2025.12.26 |
- Total
- Today
- Yesterday
- GC
- void* pointer
- 포인터
- 메모리
- 모던 C++ 챌린지
- 보이드 포인터
- cmakelists
- RHI
- GameEngine
- 개발 일지
- std::is_base_of
- SFINAE #Template #C++
- void pointer
- MemoryProject
- 그래픽스
- logproject
- 페이징기법
- 세그먼테이션기법
- 외부단편화
- C++
- Reflection
- cmake
- pointer
- 수학
- FloatingPoint
- 증명
- 내부단편화
- 뮤택스
- 로드리게스 회전 행렬
- RHICommand
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |