1. 렌더링 성능, 왜 Render Thread부터 살펴봐야 하는가
언리얼 엔진 5로 프로젝트를 진행하다 보면, 씬에 배치된 오브젝트가 늘어날수록 프레임 드롭이 발생하는 상황을 자주 마주하게 됩니다. 이때 대부분의 성능 병목은 GPU 자체의 처리 능력보다 CPU가 GPU에 렌더링 명령을 전달하는 과정에서 발생합니다. 이 과정을 담당하는 것이 바로 Render Thread입니다.
언리얼 엔진은 Game Thread와 Render Thread를 분리하여 병렬로 동작시킵니다. Game Thread가 프레임 N+1의 게임 로직을 처리하는 동안, Render Thread는 프레임 N의 렌더링 명령을 준비합니다. 여기에 RHI Thread(Render Hardware Interface Thread)가 추가되어, Render Thread가 생성한 플랫폼 독립적인 그래픽 명령을 실제 그래픽 API(DX12, Vulkan 등)로 변환하는 역할을 수행합니다.
[Game Thread] ──── Frame N+1 로직 처리 ────>
[Render Thread] ──── Frame N 렌더링 명령 생성 ────>
[RHI Thread] ──── Frame N-1 GPU 명령 변환 ────>
위의 구조처럼 각 스레드가 한 프레임씩 뒤쳐져 파이프라인 형태로 동작합니다. 만약 Render Thread에서 병목이 발생하면, Game Thread가 아무리 빠르게 처리하더라도 전체 프레임 레이트가 Render Thread의 속도에 묶이게 됩니다. 따라서 렌더링 최적화의 첫 단계는 Render Thread에서 무슨 일이 벌어지는지 정확히 이해하고, 그 부담을 줄이는 것입니다.
stat unit 콘솔 명령어를 통해 각 스레드의 소요 시간을 확인할 수 있습니다. 만약 Render Thread의 수치가 Game Thread보다 높다면, 이 글에서 다루는 최적화 기법들이 직접적인 해결책이 될 수 있습니다.
2. 드로우 콜(Draw Call)의 개념과 비용
2-1. 드로우 콜이란
드로우 콜(Draw Call)은 CPU가 GPU에 "이 메시를 이 머티리얼로 그려라"라고 요청하는 명령 단위입니다. 씬에 존재하는 모든 메시는 최소 하나의 드로우 콜을 발생시키며, 하나의 메시가 여러 머티리얼 슬롯을 사용하면 슬롯 수만큼 드로우 콜이 추가됩니다.
드로우 콜 하나하나의 비용은 크지 않지만, 수천 개가 누적되면 CPU 측에서 심각한 병목이 됩니다. 이는 드로우 콜마다 셰이더 바인딩, 버텍스 버퍼 설정, 렌더 스테이트 변경 등 CPU 작업이 수반되기 때문입니다.
현재 씬의 드로우 콜 수는 다음 콘솔 명령어로 확인할 수 있습니다.
stat SceneRendering // Mesh Draw Calls 항목 확인
stat RHI // DrawPrimitive Calls 항목 확인
위 명령어 중 stat SceneRendering의 Mesh Draw Calls 항목은 순수하게 메시에서 발생하는 드로우 콜만 표시하므로, 렌더링 최적화 시 가장 직접적인 지표로 활용됩니다.
2-2. 언리얼 엔진의 Mesh Drawing Pipeline
언리얼 엔진 5의 Mesh Drawing Pipeline은 Retained Mode 기반으로 동작합니다. 매 프레임마다 드로우 명령을 새로 구축하는 대신, 씬에 존재하는 Static Mesh에 대한 드로우 명령을 미리 캐싱해두고 재사용합니다.
이 파이프라인의 핵심 최적화 기법은 드로우 콜 병합(Draw Call Merging)입니다. 동일한 메시와 셰이더 파라미터를 사용하는 오브젝트들을 정렬하여, 파라미터 전환 없이 연속으로 렌더링할 수 있도록 합니다. 전통적인 배칭(Batching)만큼 드로우 콜 자체를 줄이지는 않지만, 렌더 스테이트 전환 비용을 크게 절약합니다.
Render Thread 내부에서의 처리 흐름은 다음과 같습니다.
InitViews (가시성 계산)
└── FParallelMeshDrawCommandPass (패스별 태스크 생성)
├── 동적 명령 생성
├── 정렬(Sorting)
└── 드로우 콜 병합(Merging)
RenderBasePass (실제 드로잉)
└── FDrawVisibleMeshCommandsAnyThreadTasks (멀티코어 분산)
└── 코어 수와 드로우 수에 따라 병렬 디스패치
위의 흐름에서 볼 수 있듯이, 대부분의 작업은 Render Thread의 크리티컬 패스에서 벗어나 별도의 태스크로 처리됩니다. 디버깅이 필요할 때는 r.MeshDrawCommands.ParallelPassSetup 0 명령으로 병렬 패스 설정을 비활성화하여 Render Thread에서 직접 처리하도록 전환할 수 있습니다.
2-3. 드로우 콜 줄이기 — 실전 기법
드로우 콜을 줄이는 대표적인 방법은 크게 세 가지입니다.
첫째, 인스턴스드 스태틱 메시(Instanced Static Mesh, ISM)를 활용합니다.
동일한 메시를 여러 번 배치해야 할 때, 개별 Static Mesh Actor 대신 ISM 또는 HISM(Hierarchical Instanced Static Mesh)을 사용하면 하나의 드로우 콜로 수백, 수천 개의 인스턴스를 렌더링할 수 있습니다.
다음은 C++에서 HISM 컴포넌트를 설정하는 예시입니다.
// AMyInstancedActor.h
UPROPERTY(VisibleAnywhere)
UHierarchicalInstancedStaticMeshComponent* TreeMeshComponent;
// AMyInstancedActor.cpp
AMyInstancedActor::AMyInstancedActor()
{
TreeMeshComponent = CreateDefaultSubobject<UHierarchicalInstancedStaticMeshComponent>(TEXT("TreeMeshComp"));
RootComponent = TreeMeshComponent;
// 메시 에셋 지정
static ConstructorHelpers::FObjectFinder<UStaticMesh> MeshAsset(TEXT("/Game/Environment/SM_Tree"));
if (MeshAsset.Succeeded())
{
TreeMeshComponent->SetStaticMesh(MeshAsset.Object);
}
}
void AMyInstancedActor::BeginPlay()
{
Super::BeginPlay();
// 1000개의 나무 인스턴스를 랜덤 위치에 배치
for (int32 i = 0; i < 1000; ++i)
{
FTransform InstanceTransform;
InstanceTransform.SetLocation(FVector(FMath::RandRange(-5000.f, 5000.f),
FMath::RandRange(-5000.f, 5000.f),
0.f));
TreeMeshComponent->AddInstance(InstanceTransform); // 인스턴스 추가
}
}
위 코드처럼 UHierarchicalInstancedStaticMeshComponent를 사용하면, 1000개의 나무가 개별 드로우 콜 대신 소수의 드로우 콜로 처리됩니다. HISM은 ISM과 달리 인스턴스별로 서로 다른 LOD를 적용할 수 있으며, 내부적으로 계층적 컬링을 지원하므로 대규모 폴리지(Foliage)에 특히 적합합니다.
둘째, 액터 병합(Actor Merging)을 활용합니다.
에디터에서 여러 Static Mesh Actor를 선택한 뒤 Actor > Merge Actors 메뉴를 통해 하나의 메시로 합칠 수 있습니다. 병합 유형은 네 가지가 있습니다.
| 병합 유형 | 특징 | 드로우 콜 |
|---|---|---|
| Merge | 머티리얼별 섹션 유지, UV 보존 | 머티리얼 수만큼 |
| Simplify | 프록시 메시 생성, 지오메트리 단순화 | 1회 |
| Batch | 동일 메시를 ISM으로 변환 | 대폭 감소 |
| Approximate | Nanite 메시 지원, 복잡한 소스 처리 가능 (UE5 신기능) | 1회 |
셋째, 머티리얼 수를 줄입니다.
하나의 메시에 사용되는 머티리얼 슬롯 수가 곧 드로우 콜 수에 직결됩니다. 텍스처 아틀라스(Texture Atlas)를 활용하여 여러 텍스처를 하나의 머티리얼로 통합하거나, 머티리얼 인스턴스를 적극 활용하여 셰이더 전환 비용을 줄이는 것이 효과적입니다.
2-4. UE 5.5의 자동 배칭 기능
언리얼 엔진 5.5부터는 자동 배칭(Automatic Batching) 기능이 도입되었습니다. 동일한 Static Mesh 에셋을 사용하는 오브젝트들을 엔진이 자동으로 ISM으로 묶어 처리합니다. 이 기능 덕분에, 동일 메시 16,001개를 배치했을 때 발생하던 드로우 콜이 절반 수준인 8,002로 감소하는 효과가 확인되었습니다.
다만, 자동 배칭은 실제 ISM 컴포넌트를 직접 사용하는 것보다 약간의 CPU 및 메모리 오버헤드가 있으므로, 대량의 동일 메시를 배치할 때는 여전히 직접 ISM/HISM을 구성하는 것이 최적입니다.
3. LOD(Level of Detail) 시스템
3-1. LOD가 필요한 이유
카메라에서 멀리 떨어진 오브젝트를 가까이 있는 오브젝트와 동일한 폴리곤 수로 렌더링하는 것은 GPU 자원의 낭비입니다. 화면에서 수 픽셀밖에 차지하지 않는 오브젝트에 수만 개의 삼각형을 할당할 이유가 없습니다. LOD(Level of Detail) 시스템은 카메라와의 거리에 따라 메시의 복잡도를 단계적으로 줄여, 렌더링 부하를 크게 절감하는 메커니즘입니다.
3-2. LOD의 기본 구조
LOD는 0번부터 시작하는 인덱스 체계를 사용합니다. LOD0이 가장 높은 디테일(원본 메시)이며, 번호가 올라갈수록 폴리곤이 줄어든 단순화된 버전입니다. 일반적으로 LOD 단계는 3~4개를 설정하는 것이 권장됩니다.
LOD 전환 시점은 화면에서 차지하는 비율(Screen Size)을 기준으로 결정됩니다. Auto Compute LOD Distances 옵션을 활성화하면, 전체 LOD 수에 맞춰 화면 비율 기준을 균등 분할하여 자동으로 전환 거리를 계산합니다.
3-3. LOD 자동 생성
언리얼 엔진은 에디터에서 LOD를 자동으로 생성하는 기능을 제공합니다. Static Mesh 에디터의 Details 패널에서 Number of LODs 값을 설정하면, 엔진이 폴리곤 감소 알고리즘을 적용하여 각 단계의 LOD를 생성합니다.
블루프린트나 Python 스크립트를 통한 일괄 LOD 생성도 가능합니다.
import unreal
# LOD 자동 생성을 위한 옵션 설정
options = unreal.EditorScriptingMeshReductionOptions()
# LOD 단계별 설정: (화면 비율 임계값, 상대 삼각형 비율)
lod_settings = [
unreal.EditorScriptingMeshReductionSettings(1.0, 1.0), # LOD0: 원본
unreal.EditorScriptingMeshReductionSettings(0.75, 0.5), # LOD1: 50% 삼각형
unreal.EditorScriptingMeshReductionSettings(0.5, 0.25), # LOD2: 25% 삼각형
unreal.EditorScriptingMeshReductionSettings(0.25, 0.12), # LOD3: 12% 삼각형
]
options.set_editor_property('reduction_settings', lod_settings)
# 메시 에셋에 LOD 적용
mesh_path = "/Game/Meshes/SM_Building"
static_mesh = unreal.EditorAssetLibrary.load_asset(mesh_path)
unreal.EditorStaticMeshLibrary.set_lods(static_mesh, options) # LOD 생성 실행
unreal.EditorAssetLibrary.save_asset(mesh_path)
위 코드처럼 unreal.EditorStaticMeshLibrary.set_lods() 함수를 사용하면, 프로젝트 내 수백 개의 메시에 일괄적으로 LOD를 설정할 수 있어 대규모 프로젝트에서 매우 유용합니다.
3-4. HLOD(Hierarchical Level of Detail)
HLOD는 일반 LOD보다 한 단계 상위의 개념입니다. 개별 메시가 아닌 여러 액터를 하나의 그룹으로 묶어, 원거리에서 단일 단순화 메시로 대체합니다. 이를 통해 드로우 콜, 메모리 사용량, 지오메트리 복잡도를 동시에 줄일 수 있습니다.
World Partition 환경에서 HLOD Layer를 설정할 때 선택할 수 있는 유형은 다음과 같습니다.
| HLOD 레이어 유형 | 설명 |
|---|---|
| Instancing | 동일 메시 인스턴스를 하나의 ISM으로 묶음 |
| Merged Mesh | 여러 메시를 하나로 병합 |
| Simplified Mesh | 병합 후 폴리곤 단순화 적용 |
| Approximated Mesh | Nanite 메시를 포함한 복잡한 소스 처리 가능 |
HLOD는 특히 오픈 월드 게임에서 원거리 풍경의 렌더링 부담을 획기적으로 줄여주는 핵심 시스템입니다.
3-5. LOD 전환 시 팝핑(Popping) 방지
LOD 전환이 급격하게 이루어지면, 오브젝트의 형태가 순간적으로 바뀌는 팝핑 현상이 발생합니다. 이를 완화하는 두 가지 기법이 있습니다.
- 스크린 도어 페이딩(Screen-door Fading): 화면 공간에서 디더(Dither) 패턴을 사용하여 두 LOD를 블렌딩합니다. 구현이 간단하지만, 디더 패턴이 가까이에서 눈에 보일 수 있습니다.
- 지오모핑(Geomorphing): 별도의 UV 좌표 세트를 활용하여, LOD 간 버텍스를 부드럽게 보간합니다. 시각적 품질은 뛰어나지만 추가 데이터 준비가 필요합니다.
4. Nanite — GPU 기반의 자동 LOD 시스템
4-1. Nanite란
Nanite는 UE5에서 도입된 가상화 지오메트리(Virtualized Geometry) 시스템입니다. 전통적인 LOD가 미리 준비된 여러 단계의 메시를 전환하는 방식이라면, Nanite는 원본 고폴리곤 메시 하나만으로 GPU가 실시간으로 화면에 보이는 영역만 필요한 만큼의 디테일로 렌더링합니다.
Nanite의 핵심 동작 원리는 다음과 같습니다.
- 메시를 128개 삼각형 단위의 클러스터(Cluster)로 분할합니다.
- 클러스터 단위로 다단계 LOD 계층 구조를 자동 생성합니다.
- 렌더링 시 화면상의 크기에 따라 적절한 클러스터 LOD를 실시간으로 선택합니다.
- 소프트웨어 래스터라이저(Software Rasterizer)와 하드웨어 래스터라이저(Hardware Rasterizer)를 삼각형 크기에 따라 자동 전환합니다. 삼각형이 1픽셀보다 작으면 소프트웨어, 크면 하드웨어 래스터라이저를 사용합니다.
4-2. Nanite의 오클루전 컬링
Nanite는 독자적인 GPU 기반 2패스 오클루전 컬링을 수행합니다.
[1패스] 이전 프레임의 HZB로 인스턴스/클러스터 오클루전 테스트
└── 가시 클러스터 래스터라이즈 → 새로운 HZB 생성
[2패스] 새로운 HZB로 1패스에서 가려졌던 인스턴스 재테스트
└── 새로 보이게 된 인스턴스의 클러스터 래스터라이즈
위의 2패스 구조 덕분에, 이전 프레임에서 가려져 있다가 현재 프레임에서 새로 보이게 된 오브젝트도 놓치지 않고 정확하게 렌더링할 수 있습니다. 이 과정은 전부 GPU에서 처리되므로, CPU 측 드로우 콜 병목을 근본적으로 제거합니다.
4-3. Nanite 사용 시 머티리얼 최적화
Nanite를 활성화한 메시라도 머티리얼의 복잡도에 따라 성능 차이가 큽니다. 래스터 빈(Raster Bin) 전환 비용을 고려하여, 다음과 같은 우선순위를 지키는 것이 좋습니다.
| 우선순위 | 머티리얼 유형 | 비고 |
|---|---|---|
| 최적 | Opaque, WPO/PDO 없음 | 소프트웨어 래스터라이저 활용 가능 |
| 양호 | Opaque, 최소 WPO | 약간의 추가 비용 |
| 주의 | Masked | 가급적 사용 자제 |
| 비권장 | 복잡한 WPO/PDO 적용 | 래스터 빈 전환 비용 큼 |
가능하다면 Opacity Mask를 완전 불투명(Opaque)으로 대체하고, 비슷한 변형 머티리얼을 가진 메시끼리 씬에서 가까이 배치하여 래스터 빈 전환을 최소화하는 것이 효과적입니다.
4-4. Nanite의 한계
Nanite는 만능이 아닙니다. 다음의 경우에는 전통적인 LOD 시스템이 여전히 필요합니다.
- 스켈레탈 메시(Skeletal Mesh): Nanite는 현재 Static Mesh만 지원합니다.
- 반투명 머티리얼(Translucent Material): Nanite 파이프라인은 불투명 렌더링에 최적화되어 있습니다.
- 모바일 플랫폼: Nanite는 데스크톱/콘솔 수준의 GPU를 필요로 합니다.
5. 컬링(Culling) 시스템
5-1. 화면 밖은 그리지 않는다 — 프러스텀 컬링
프러스텀 컬링(Frustum Culling)은 카메라의 시야각 밖에 있는 오브젝트를 렌더링 대상에서 제외하는 기법입니다. 언리얼 엔진은 기본적으로 이 컬링을 수행하며, 개발자가 별도로 설정할 필요는 없습니다.
5-2. 가려진 오브젝트는 그리지 않는다 — 오클루전 컬링
오클루전 컬링(Occlusion Culling)은 다른 오브젝트에 의해 완전히 가려진 오브젝트를 렌더링 대상에서 제외하는 기법입니다. 건물 뒤에 숨겨진 오브젝트를 그리지 않음으로써, 불필요한 드로우 콜과 GPU 처리를 절약합니다.
오클루전 컬링 자체에도 연산 비용이 발생합니다. 따라서 모든 메시를 오클루더(Occluder)로 설정하는 것은 오히려 역효과입니다. 대형 건물, 넓은 벽, 지형 등 큰 차폐물만 오클루더로 지정하고, 작은 오브젝트는 오클루더 역할에서 제외하는 것이 바람직합니다.
5-3. 컬링 관련 콘솔 명령어
stat InitViews // 가시성 계산(컬링 포함)에 소요된 시간 확인
r.VisualizeOccludedPrimitives 1 // 오클루전 컬링된 프리미티브 시각화
FreezeRendering // 현재 카메라 위치에서 렌더링 고정 (컬링 결과 확인용)
위 명령어들을 활용하면, 현재 씬에서 컬링이 효과적으로 동작하고 있는지 시각적으로 확인할 수 있습니다.
6. Render Thread와 Game Thread 간의 통신 최적화
6-1. FPrimitiveSceneProxy 구조
언리얼 엔진에서 렌더링 가능한 모든 오브젝트는 Game Thread 측의 UPrimitiveComponent와 Render Thread 측의 FPrimitiveSceneProxy로 이원화되어 있습니다. 이는 두 스레드가 동시에 같은 메모리에 접근하여 발생하는 경합(Race Condition)을 방지하기 위한 설계입니다.
// Game Thread: UPrimitiveComponent를 소유하고 변경
// Render Thread: FPrimitiveSceneProxy를 소유하고 읽기
// 컴포넌트를 씬에 등록하면 SceneProxy가 생성됨
UActorComponent::RegisterComponent();
// → 내부적으로 FPrimitiveSceneProxy 인스턴스 생성 및 씬 등록
위 코드처럼 RegisterComponent()가 호출되면, 엔진은 해당 컴포넌트에 대응하는 FPrimitiveSceneProxy를 생성하여 Render Thread에 등록합니다. 이후 Game Thread에서 컴포넌트의 속성을 변경할 때는 반드시 MarkRenderStateDirty()를 호출하여 Render Thread에 변경 사항을 전파해야 합니다.
6-2. Render Command를 통한 안전한 데이터 전달
Game Thread에서 Render Thread로 데이터를 전달할 때는 ENQUEUE_RENDER_COMMAND 매크로를 사용합니다.
// Game Thread에서 Render Thread로 데이터 전달
float NewOpacity = 0.5f;
ENQUEUE_RENDER_COMMAND(UpdateMaterialOpacity)(
[NewOpacity](FRHICommandListImmediate& RHICmdList)
{
// 이 코드는 Render Thread에서 실행됩니다
// NewOpacity 값을 캡처하여 안전하게 사용
GDynamicRHI->SetGlobalUniformValue(NewOpacity);
}
);
위의 코드에서 람다 캡처를 통해 Game Thread의 데이터를 Render Thread로 안전하게 복사합니다. 포인터를 직접 전달하면 두 스레드 간 메모리 충돌이 발생할 수 있으므로, 반드시 값 복사 방식을 사용해야 합니다.
6-3. 불필요한 렌더 스테이트 업데이트 줄이기
매 프레임 MarkRenderStateDirty()를 호출하는 것은 Render Thread에 불필요한 부담을 줍니다. 실제로 값이 변경되었을 때만 호출하도록 가드를 두는 것이 좋습니다.
void UMyComponent::SetCustomColor(FLinearColor InColor)
{
if (CustomColor != InColor) // 값이 실제로 변경된 경우에만
{
CustomColor = InColor;
MarkRenderStateDirty(); // Render Thread에 변경 전파
}
}
위 코드처럼 변경 여부를 확인한 뒤에만 MarkRenderStateDirty()를 호출하면, Render Thread가 불필요하게 FPrimitiveSceneProxy를 재생성하는 비용을 절약할 수 있습니다.
7. RDG(Render Dependency Graph)와 병렬 렌더링
7-1. RDG의 역할
RDG(Render Dependency Graph), 즉 FRDGBuilder는 Render Thread 내에서 GPU 리소스의 할당·해제와 렌더링 명령의 실행을 관리하는 시스템입니다. RDG는 다음과 같은 자동 최적화를 수행합니다.
- 메모리 풀링: 임시 GPU 리소스의 할당·해제를 풀 기반으로 최적화합니다.
- 드로우 콜 재정렬: 렌더 스테이트 전환을 최소화하도록 명령 순서를 재배치합니다.
- 사용되지 않는 명령 제거(Pruning): 최종 출력에 기여하지 않는 패스를 자동으로 제거합니다.
7-2. RHI 병렬 변환
UE 5.5부터 병렬 변환(Parallel Translation) 기능이 강화되었습니다. RHI Thread가 Render Thread의 명령 리스트를 플랫폼별 명령으로 변환할 때, 여러 태스크를 병렬로 처리하여 최대 2배의 성능 향상이 보고되었습니다. DX12 및 Vulkan 환경에서 특히 효과적이며, r.RHICmdBasePassDeferredContexts 설정으로 Base Pass의 병렬 디스패치를 제어할 수 있습니다.
8. 일반 C++과의 비교
렌더링 파이프라인의 멀티스레딩 설계는 일반 C++ 멀티스레딩과 동일한 원칙을 공유합니다.
공통점: 언리얼 엔진의 Game Thread / Render Thread 분리는 일반 C++의 프로듀서-컨슈머(Producer-Consumer) 패턴과 본질적으로 동일합니다. Game Thread가 렌더링에 필요한 데이터를 생산하고, Render Thread가 그 데이터를 소비하여 GPU 명령으로 변환합니다. 스레드 간 데이터 전달 시 뮤텍스(Mutex)나 락 프리(Lock-Free) 큐를 활용하는 것도 일반 C++과 같은 접근입니다.
// 일반 C++: 프로듀서-컨슈머 패턴
std::queue<RenderCommand> CommandQueue;
std::mutex QueueMutex;
// 프로듀서 (Game Thread 역할)
void ProduceCommand(RenderCommand Cmd)
{
std::lock_guard<std::mutex> Lock(QueueMutex);
CommandQueue.push(Cmd);
}
// 컨슈머 (Render Thread 역할)
void ConsumeCommand()
{
std::lock_guard<std::mutex> Lock(QueueMutex);
if (!CommandQueue.empty())
{
RenderCommand Cmd = CommandQueue.front();
CommandQueue.pop();
Cmd.Execute();
}
}
// 언리얼 엔진: ENQUEUE_RENDER_COMMAND 매크로가 동일한 역할
ENQUEUE_RENDER_COMMAND(MyCommand)(
[CapturedData](FRHICommandListImmediate& RHICmdList)
{
// Render Thread에서 실행 (컨슈머 역할)
ProcessData(CapturedData);
}
);
차이점: 언리얼 엔진은 ENQUEUE_RENDER_COMMAND 매크로를 통해 개발자가 직접 동기화 프리미티브(뮤텍스, 세마포어 등)를 관리할 필요 없이 안전하게 스레드 간 통신을 할 수 있도록 추상화하고 있습니다. 또한, FPrimitiveSceneProxy / UPrimitiveComponent의 이원화 구조를 통해, 각 스레드가 소유한 데이터의 경계를 컴파일 타임에 명확히 구분할 수 있습니다. 일반 C++에서는 이러한 소유권 분리를 개발자가 직접 설계하고 유지해야 합니다.
9. 유니티 엔진과의 비교
9-1. 드로우 콜 최적화
유니티에서는 Static Batching, Dynamic Batching, SRP Batcher라는 세 가지 배칭 시스템으로 드로우 콜을 줄입니다. 이 중 SRP Batcher는 셰이더 바리언트가 같은 오브젝트들의 렌더 스테이트 전환 비용을 줄이는 방식으로, 언리얼 엔진의 Mesh Drawing Pipeline에서 동일 셰이더 파라미터 오브젝트를 정렬·병합하는 접근과 유사합니다.
언리얼 엔진만의 강점은 Retained Mode 기반 캐싱입니다. Static Mesh의 드로우 명령을 미리 캐싱해두고 프레임마다 재사용하므로, 유니티의 Dynamic Batching처럼 매 프레임 배칭을 재계산하는 오버헤드가 없습니다.
9-2. LOD 시스템
유니티의 LOD Group 컴포넌트는 언리얼의 LOD 시스템과 개념적으로 동일합니다. 화면 비율 기준으로 LOD를 전환하며, 에디터에서 전환 거리를 시각적으로 조절할 수 있습니다.
차이점은 HLOD입니다. 유니티에는 기본 내장된 HLOD 시스템이 없어, 대규모 오픈 월드에서 원거리 오브젝트를 그룹 단위로 단순화하려면 서드파티 솔루션이나 커스텀 구현이 필요합니다. 언리얼 엔진은 World Partition과 HLOD가 긴밀하게 통합되어, 대규모 월드의 렌더링 최적화를 엔진 차원에서 체계적으로 지원합니다.
9-3. Nanite에 대응하는 기능
유니티에는 현재 Nanite에 직접 대응하는 기능이 없습니다. 유니티에서 고폴리곤 메시를 처리하려면, 전통적인 LOD 시스템이나 SpeedTree, Simplygon 같은 외부 미들웨어에 의존해야 합니다. Nanite의 GPU 기반 자동 LOD 및 클러스터 단위 컬링은 현 시점에서 언리얼 엔진의 고유한 강점입니다.
10. 주의사항
10-1. 프로파일링 없이 최적화하지 않는다
모든 최적화의 출발점은 프로파일링입니다. stat unit, stat SceneRendering, stat RHI, stat GPU, ProfileGPU 등의 콘솔 명령어를 통해 병목이 실제로 어디에 있는지 확인한 뒤 최적화에 착수해야 합니다. Render Thread가 아닌 Game Thread나 GPU 자체에 병목이 있는 상황에서 드로우 콜만 줄이는 것은 효과가 없습니다.
10-2. 인스턴싱과 컬링의 트레이드오프
ISM/HISM을 사용하면 드로우 콜은 줄어들지만, CPU 측 프러스텀 컬링과 오클루전 컬링이 제한됩니다. 인스턴스 단위가 아닌 전체 ISM 단위로 컬링이 수행되므로, 넓은 영역에 분산된 인스턴스의 경우 오히려 화면 밖의 인스턴스까지 렌더링하게 될 수 있습니다. HISM은 계층적 컬링을 지원하여 이 문제를 어느 정도 완화하므로, 대규모 배치에는 ISM보다 HISM을 우선적으로 고려하시기 바랍니다.
10-3. 액터 병합 시 주의점
Merge Actors 기능은 드로우 콜을 줄이는 강력한 도구이지만, 병합된 메시는 개별 오클루전 컬링이 불가능해집니다. 건물 내부의 소품들을 하나로 병합하면, 건물이 화면에 보이는 한 내부 소품도 전부 렌더링됩니다. 따라서 병합은 항상 시야에 함께 보이는 오브젝트 그룹에 한정하여 적용하는 것이 바람직합니다.
10-4. Nanite와 전통 LOD의 병행
Nanite를 활성화한 메시에는 별도의 LOD 설정이 불필요합니다. 그러나 프로젝트 내에 Nanite를 사용할 수 없는 에셋(스켈레탈 메시, 반투명 메시 등)이 함께 존재한다면, 해당 에셋에는 반드시 전통적인 LOD를 설정해야 합니다. 하나의 프로젝트에서 두 시스템을 병행하는 전략이 현실적입니다.
10-5. HLOD의 디스크 및 메모리 비용
HLOD는 사전에 생성된 단순화 메시와 베이크된 텍스처를 별도의 에셋으로 저장합니다. 이는 디스크 용량, 런타임 메모리, VRAM에 추가 부담을 줍니다. 타겟 플랫폼의 메모리 예산을 고려하여 HLOD 설정을 조절해야 하며, 특히 콘솔이나 모바일 환경에서는 HLOD 단계의 텍스처 해상도와 폴리곤 수를 보수적으로 설정하는 것이 좋습니다.
10-6. MarkRenderStateDirty의 남용 금지
MarkRenderStateDirty()는 Render Thread에 FPrimitiveSceneProxy의 재생성을 요청하는 비용이 큰 연산입니다. Tick 함수 내에서 매 프레임 호출하면 Render Thread에 지속적인 부담이 가해집니다. 실제 변경이 발생한 시점에만 호출하도록 반드시 가드를 두어야 합니다.
이 글에서는 Render Thread 최적화의 핵심 영역인 드로우 콜 최적화, LOD 시스템, Nanite, 컬링, 그리고 스레드 간 통신까지 살펴보았습니다. 모든 최적화는 프로파일링 결과를 기반으로 병목 지점을 정확히 파악한 뒤 적용해야 하며, 드로우 콜 감소(ISM, 액터 병합, 머티리얼 최적화), LOD/HLOD 시스템 활용, Nanite 도입, 그리고 컬링 전략을 프로젝트의 특성에 맞게 조합하는 것이 가장 효과적인 접근입니다.
'언리얼 엔진 5' 카테고리의 다른 글
| [UE5] Enhanced Input System (0) | 2026.03.09 |
|---|---|
| [UE5] GPU 최적화 (0) | 2026.03.08 |
| [UE5] Game Thread 최적화 (0) | 2026.03.04 |
| [UE5] 프로파일링 - Unreal Insights (0) | 2026.03.03 |
| [UE5] 프로파일링 - stat 명령어로 스레드 병목 진단하기 (1) | 2026.03.02 |