1882 words
9 minutes
다수의 StaticMesh 컴포넌트 배치시의 부하 원인 (Draw Call)

언리얼 엔진 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

  1. CPU가 렌더링할 객체의 월드 트랜스폼(위치, 회전, 스케일)을 계산한다.
  2. GPU에게 렌더링 상태 변경(머티리얼, 쉐이더 설정 등)을 지시한다.
  3. 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. 정리#

  1. 메시는 복사되지 않고 한 번만 로드되어 참조된다. (메모리 최적화)
  2. 다수의 StaticMesh배치시에 발생하는 부하 —> X 데이터 복사로 인한 메모리 부하? X , 반복 연산드로우 콜 오버헤드
  3. 하이폴리곤 USkeletalMesh에 발생하는 부하 —> GPU 내부의 버텍스 쉐이더스키닝 연산 병목나나이트 적용의 어려움(Quad Overdraw)