언리얼 엔진 C++ 개발 중 동일한 UStaticMesh를 사용하는 컴포넌트(예: 분수대, 총알 등)를 월드에 수십, 수백 개 배치할 때 발생하는 시스템 부하의 원인에 대해 찾아보고 정리해보았다.
흔히 직관적으로 “화면에 그려지는 개수가 많아지니 데이터가 복사되어 메모리 부하가 클 것”이라고 오해하지만, 실제 게임 엔진의 병목 지점은 완전히 다른 곳에 있다.
1. 메모리 관점: 복사가 아닌 ‘참조’ (Flyweight Pattern)
월드에 동일한 액터를 100개 배치한다고 해서 형태를 구성하는 메시 데이터(버텍스, 인덱스, UV 등)가 메모리에 100번 복사되지 않는다. 현대 게임 엔진은 플라이웨이트 패턴(Flyweight Pattern)을 기반으로 리소스를 관리한다.
- 시스템 RAM (CPU 영역): 엔진 초기화 시 원본
UStaticMesh데이터는 단 한 번만 로드된다. 각 컴포넌트(UStaticMeshComponent)는 이 원본 데이터의 메모리 주소를 가리키는 포인터만을 소유한다. - VRAM (GPU 영역): 렌더링을 위해 그래픽 카드로 데이터가 넘어갈 때도 마찬가지다. 메시의 버텍스 정보는 GPU 메모리의 버텍스 버퍼(Vertex Buffer)에 딱 한 번만 올라간다.
결과적으로 인스턴스가 아무리 늘어나도 동일한 데이터를 재참조할 뿐이므로, 데이터 ‘복사’로 인한 메모리 오버헤드는 0에 가깝다.
2. 렌더링 관점: 진짜 부하의 원인은 ‘드로우 콜(Draw Call)’
메모리 복사 부하가 없다면 왜 객체를 많이 그릴수록 프레임이 떨어질까? 부하의 핵심은 데이터의 크기가 아니라 명령의 횟수에 있다.
CPU가 GPU에게 “이 메시를 그려라”라고 명령을 내리는 과정을 드로우 콜(Draw Call) 이라고 한다.
CPU -> GPU
- CPU가 렌더링할 객체의 월드 트랜스폼(위치, 회전, 스케일)을 계산한다.
- GPU에게 렌더링 상태 변경(머티리얼, 쉐이더 설정 등)을 지시한다.
- GPU에게 그리기(Draw) 명령을 보낸다.
이 과정에서 CPU와 GPU 사이의 통신 비용(Context Switching)이 발생한다. 화면에 그릴 객체가 수백 개로 늘어나면 이 통신 횟수 자체가 기하급수적으로 증가한다. 결국 CPU는 명령을 보내느라 과부하가 걸리고, GPU는 이전 명령을 처리하고 다음 명령을 기다리며 노는(Idle) 병목 현상이 발생한다. 부하는 그리는 행위(연산) 그 자체와 명령 하달 과정에서 발생한다.
3. 최적화 기법: 하드웨어 인스턴싱 (Hardware Instancing)
엔진은 드로우 콜 병목을 해결하기 위해 하드웨어 인스턴싱이라는 최적화 기법을 사용한다.
- 기존 방식: 100개의 분수대를 그리기 위해 CPU가 GPU에게 100번의 드로우 콜을 보냄.
- 인스턴싱 방식: 버텍스 정보는 1번만 참조하되, 100개의 분수대
위치 정보(Transform Matrix)를 배열로 묶어 GPU에 전달. CPU는 “이 1개의 버텍스 데이터를 사용해서, 여기 전달한 100개의 위치에 한 번에 그려라”라는단일 명령(1 Draw Call)을 내림.
이를 통해 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 내부의 버텍스 쉐이더 및 스키닝 연산 병목과 나나이트 적용의 어려움(Quad Overdraw)

