언리얼 엔진 C++ 개발 중 동일한 UStaticMesh를 사용하는 컴포넌트(예: 분수대, 총알 등)를 월드에 수십, 수백 개 배치할 때 발생하는 시스템 부하의 원인에 대해 찾아보고 정리해보았다.
흔히 직관적으로 “화면에 그려지는 개수가 많아지니 데이터가 복사되어 메모리 부하가 클 것”이라고 오해하지만, 실제 게임 엔진의 병목 지점은 완전히 다른 곳에 있다.
1. 메모리 관점: 복사가 아닌 ‘참조’ (Flyweight Pattern)
월드에 동일한 액터를 100개 배치한다고 해서 형태를 구성하는 메시 데이터(버텍스, 인덱스, UV 등)가 메모리에 100번 복사되지 않는다. 현대 게임 엔진은 플라이웨이트 패턴을 기반으로 리소스를 관리한다.
- 시스템 RAM (CPU 영역): 엔진 초기화 시 원본
UStaticMesh데이터는 단 한 번만 로드된다. 각 컴포넌트(UStaticMeshComponent)는 이 원본 데이터의 메모리 주소를 가리키는 포인터만을 소유한다. - VRAM (GPU 영역): 렌더링을 위해 그래픽 카드로 데이터가 넘어갈 때도 마찬가지다. 메시의 버텍스 정보는 GPU 메모리의 버텍스 버퍼에 딱 한 번만 올라간다.
결과적으로 인스턴스가 아무리 늘어나도 동일한 데이터를 재참조할 뿐이므로, 데이터 ‘복사’로 인한 메모리 오버헤드는 0에 가깝다.
2. 렌더링 관점: 진짜 부하의 원인은 ‘드로우 콜(Draw Call)’
메모리 복사 부하가 없다면 왜 객체를 많이 그릴수록 프레임이 떨어질까? 중요한건 데이터의 크기가 아니라 명령의 횟수다.
CPU가 GPU에게 “이 메시를 그려라”라고 명령을 내리는 과정을 드로우 콜(Draw Call) 이라고 한다.
CPU -> GPU
- CPU가 렌더링할 객체의 월드 트랜스폼(위치, 회전, 스케일)을 계산한다.
- GPU에게 렌더링 상태 변경(머티리얼, 쉐이더 설정 등)을 지시한다.
- GPU에게 그리기(Draw) 명령을 보낸다.
이 과정에서 CPU와 GPU 사이의 통신 비용 Context Switching이 발생한다. 화면에 그릴 객체가 수백 개로 늘어나면 이 통신 횟수 자체가 기하급수적으로 증가한다. 결국 CPU는 명령을 보내느라 과부하가 걸리고, GPU는 이전 명령을 처리하고 다음 명령을 기다리며 노는 병목이 발생한다. 부하는 그리는 행위(연산) 그 자체와 명령 하달 과정에서 발생한다.
3. 최적화 기법: 하드웨어 인스턴싱 (Hardware Instancing)
엔진은 드로우 콜 병목을 해결하기 위해 하드웨어 인스턴싱이라는 최적화 기법을 사용한다.
- 기존 방식: 100개의 분수대를 그리기 위해 CPU가 GPU에게 100번의 드로우 콜을 보냄.
- 인스턴싱 방식: 버텍스 정보는 1번만 참조하되, 100개의 분수대
위치 정보(Transform Matrix)를 배열로 묶어 GPU에 전달. CPU는 “이 1개의 버텍스 데이터를 사용해서, 여기 전달한 100개의 위치에 한 번에 그려라”라는단일 명령(Draw Call 1회)을 내림.
이를 통해 CPU의 오버헤드를 극적으로 줄이고, GPU의 연산 유닛(ALU)을 효율적으로 점유하여 렌더링을 수행할 수 있다.
4. C++ 생성자 코드와의 연관성
// 컴포넌트 생성 및 에셋 참조
Body = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Body"));
static ConstructorHelpers::FObjectFinder<UStaticMesh> BodyMeshRef(TEXT("경로"));
if (BodyMeshRef.Succeeded())
{
Body->SetStaticMesh(BodyMeshRef.Object);
}
위의 C++ 코드는 단순히 컴포넌트를 만들고 에셋을 끼워 넣는 과정처럼 보이지만, 엔진 구조적으로는 CDO(Class Default Object) 를 구성하는 단계다.
런타임에 동적으로 에셋을 복사해오는 것이 아니라, 엔진 초기화 단계에서 ConstructorHelpers를 통해 에셋을 단 한 번 메모리에 적재해 CDO를 완성한다. 이후 게임 내에서 스폰되는 모든 해당 클래스의 인스턴스들은 이 CDO에 적재된 에셋의 주소값만을 안전하게 참조(Reference)하도록 설계되어 있다.
5. 심화: 하이폴리곤 메시 자체의 크기는 부하를 주지 않는가?
앞서 설명한 내용이 CPU와 GPU 간의 통신 병목(Draw Call)에 대한 것이라면, 메시 자체가 가진 버텍스 수(5만~10만 개 이상의 하이폴리곤)는 GPU 내부의 연산 병목을 유발한다. 메모리(VRAM)에 한 번만 올라가서 공유되더라도, 매 프레임 이를 처리하는 연산량은 정직하게 버텍스 수에 비례하기 때문이다.
5-1. 버텍스 쉐이더(Vertex Shader) 연산
GPU는 렌더링할 때 메시에 포함된 모든 버텍스의 화면상 위치를 계산(Transform)해야 한다. 버텍스가 10만 개라면, 화면에 1번 그릴 때마다 GPU 연산 유닛(ALU)이 10만 번의 버텍스 쉐이더 프로그램을 실행해야 한다. 여러 개를 배치하면 이 연산량은 배로 늘어난다.
5-2. USkeletalMesh의 스키닝(Skinning) 비용
정적인 UStaticMesh와 달리, 애니메이션이 들어간 하이폴리곤 캐릭터의 USkeletalMesh는 높은 부하를 유발한다. 매 프레임 캐릭터가 움직일 때마다 10만 개의 버텍스는 자신과 연결된 뼈대(Bone)의 가중치(Weight)를 바탕으로 위치를 재계산해야 한다. 이 행렬 곱셈 연산이 GPU에 큰 부하를 가한다.
5-3. 쿼드 오버드로우(Quad Overdraw)와 마이크로 트라이앵글(Micro-triangle)
하이폴리곤 메시는 화면에 작게 표시될 때 1픽셀보다 Micro-triangle들을 무수히 만들어낸다. GPU는 픽셀을 칠할 때 2x2(Quad) 단위로 병렬 처리하는데, 삼각형이 너무 조밀하면 실제 그릴 필요가 없는 픽셀까지 연산에 포함되어 렌더링 효율이 급락한다.
5-4. 언리얼 엔진의 해결책
이러한 GPU 연산 부하를 막기 위해 엔진은 다음과 같은 기술을 사용한다.
StaticMesh: 언리얼 엔진 5의 나나이트(Nanite) 기술을 통해 화면에 보이는 픽셀 수준으로 폴리곤을 실시간 병합/분할하여 버텍스 연산 부하를 최적화한다.
SkeletalMesh (캐릭터): 뼈대 애니메이션 연산 때문에 나나이트 적용이 까다롭다. 따라서 거리에 따라 폴리곤 수를 강제로 줄여 연산량을 통제하는 LOD(Level of Detail)가 필요하다.
6. 정리
- 메시는 복사되지 않고 한 번만 로드되어
참조된다. (메모리 최적화) - 다수의
StaticMesh배치시에 발생하는 부하 —>X 데이터 복사로 인한 메모리 부하? X, 반복 연산과드로우 콜 오버헤드 - 하이폴리곤
USkeletalMesh에 발생하는 부하 —> GPU 내부의 버텍스 쉐이더 및 스키닝 연산 병목과 나나이트 적용의 어려움
6. Reference
- GPU Performance for Game Artists (FragmentBuffer)
- 렌더링 파이프라인(Draw Call, Vertex/Pixel Shader)과 메모리 대역폭이 실제 게임 에셋(Mesh, Texture)에 어떤 방식으로 병목을 일으키는지 상세히 풀어낸 아티클입니다.

