GPU 인스턴싱 Foliage: 수천 개 풀·자갈·소품 오브젝트의 GPU 렌더링 최적화

GPU 인스턴싱 Foliage: 수천 개 풀·자갈·소품 오브젝트의 GPU 렌더링 최적화

수천 개의 풀, 자갈, 소품 등 필드 오브젝트를 GPU 인스턴싱과 컴퓨트 셰이더 컬링을 활용해 단일 드로우 콜 수준으로 최적화하는 기법을 소개합니다.

CPU 병목과 수천 개 필드 오브젝트 렌더링의 한계

오픈월드 게임이나 3D 환경을 구축할 때 필드를 채우는 풀(Grass), 자갈(Pebble), 소품(Prop) 같은 오브젝트는 수천 개에서 수만 개에 달합니다. 이러한 오브젝트를 개별 메쉬 리소스 및 트랜스폼으로 배치하면 렌더 파이프라인에서 심각한 성능 저하가 발생합니다.

주된 원인은 CPU에서 GPU로 렌더링 명령을 보내는 드로우 콜(Draw Call)과 Render State 변경(SetPass Call) 비용입니다. 개별 오브젝트마다 트랜스폼 행렬을 계산하고 매개변수를 교체한 뒤 드로우 콜을 호출하면 CPU 병목이 지배적으로 변합니다.

Static Batching이나 Dynamic Batching 같은 기존 방식은 메모리 사용량이 급증하거나 정적 오브젝트로 고정되어 애니메이션을 적용하기 어렵다는 단점이 있습니다. 이러한 한계를 극복하기 위해 동일한 메쉬 리소스를 공유하는 수천 개의 오브젝트를 단 한 번의 드로우 콜로 처리하는 GPU 인스턴싱(GPU Instancing)이 필수적으로 사용됩니다.

CPU와 GPU 간의 드로우 콜 병목 현상 및 오버헤드 구조

GPU 인스턴싱의 기본 원리

GPU 인스턴싱은 동일한 메쉬와 재질을 공유하는 개체들에 대해 각 인스턴스의 트랜스폼(위치, 회전, 크기) 및 개별 속성 데이터를 GPU 버퍼에 일괄 전달한 뒤 단일 렌더링 명령으로 묘사하는 기술입니다.

버텍스 셰이더(Vertex Shader)는 입력으로 들어오는 SV_InstanceID를 통해 현재 처리 중인 인스턴스의 인덱스를 파악하고 해당 인스턴스의 변환 행렬을 가져와 정점 위치를 계산합니다.

Pworld=Minstance×PlocalP_{world} = M_{instance} \times P_{local}

여기서 PlocalP_{local}은 메쉬의 기본 정점 좌표이며 MinstanceM_{instance}는 해당 인스턴스의 4x4 월드 변환 행렬입니다. 카메라 공간 및 정점 변환 식은 다음과 같이 표현할 수 있습니다.

Pclip=Mviewproj×Minstance×PlocalP_{clip} = M_{viewproj} \times M_{instance} \times P_{local}

이 과정을 시각화하면 CPU가 인스턴스 배열을 넘기고 GPU에서 동시 처리하는 구조를 한눈에 이해할 수 있습니다.

flowchart TD
    A[CPU: 인스턴스 트랜스폼 배열 생성] --> B[GPU StructuredBuffer 업로드]
    B --> C[DrawIndexedInstanced 호출]
    C --> D[GPU: SV_InstanceID 기반 버텍스 변환]
    D --> E[단일 드로우 콜 렌더링 완료]

HLSL 커스텀 셰이더 구현

GPU 인스턴싱을 적용하려면 버텍스 셰이더에서 인스턴스별 데이터를 수신할 수 있도록 HLSL 버퍼 구조를 정의해야 합니다. 수천 개의 인스턴스 행렬을 전달할 때는 StructuredBuffer를 사용하는 방식이 유연하며 메모리 관리 측면에서도 유리합니다.

다음은 인스턴스별 월드 행렬과 색상 편차 데이터를 읽어 정점 위치를 변환하는 HLSL 버텍스 셰이더 예제입니다.

struct InstanceData
{
    float4x4 worldMatrix;
    float4 colorVariation;
};

StructuredBuffer<InstanceData> _InstanceBuffer;

struct Attributes
{
    float4 positionOS : POSITION;
    float3 normalOS : NORMAL;
    uint instanceID : SV_InstanceID;
};

struct Varyings
{
    float4 positionCS : SV_POSITION;
    float3 normalWS : NORMAL;
    float4 color : COLOR;
};

Varyings Vert(Attributes input)
{
    Varyings output;
    
    // SV_InstanceID를 사용해 해당 인스턴스 데이터 참조
    InstanceData inst = _InstanceBuffer[input.instanceID];
    
    // 월드 공간 변환
    float4 positionWS = mul(inst.worldMatrix, input.positionOS);
    
    // 클립 공간 변환 및 정점 노멀 계산
    output.positionCS = mul(UNITY_MATRIX_VP, positionWS);
    output.normalWS = mul((float3x3)inst.worldMatrix, input.normalOS);
    output.color = inst.colorVariation;
    
    return output;
}

이와 같이 셰이더 내에서 버퍼를 직접 참조하면 렌더링 시 매번 레지스터를 재설정하지 않고도 인스턴스 고유의 바람 애니메이션 파라미터나 색상 값을 적용할 수 있습니다.

컴퓨트 셰이더 기반 컬링과 간접 렌더링

모든 인스턴스를 단순 인스턴싱으로 그리게 되면 시야 밖에 있거나 카메라에서 멀리 떨어진 오브젝트까지 모조리 렌더링되어 래스터라이저 오버헤드와 픽셀 셰이더 병목이 발생합니다. CPU에서 인스턴스를 하나씩 검사하여 컬링하는 것은 다시 CPU 병목을 유발하므로 GPU 컴퓨트 셰이더(Compute Shader)를 활용해 절두체 컬링(Frustum Culling)을 수행하는 것이 효과적입니다.

각 인스턴스의 바운딩 스피어 중심점 좌표 CworldC_{world}와 반지름 RR에 대해 시야 절두체 평면과의 거리를 계산하여 가시성을 판정합니다.

Di=NiCworld+diD_i = N_i \cdot C_{world} + d_i

모든 평면에 대해 다음 조건을 만족하면 가시적인 인스턴스로 판단하여 AppendStructuredBuffer에 인덱스를 저장합니다.

DiRD_i \ge -R
flowchart LR
    A[전체 인스턴스 버퍼] --> B[Compute Shader: Frustum Culling]
    B -- 가시적 인스턴스 --> C[AppendStructuredBuffer]
    B -- 비가시적 인스턴스 --> D[버림]
    C --> E[DrawMeshInstancedIndirect]

컴퓨트 셰이더 기반 절두체 컬링 및 간접 렌더링 파이프라인 구조

컬링 결과로 남은 인스턴스 개수를 GPU의 Indirect Arguments 버퍼에 기록하고 DrawMeshInstancedIndirect 명령을 호출하면 CPU 개입 없이 완전한 GPU 기반 렌더링 파이프라인을 구축할 수 있습니다.

실전 최적화 고려사항

  • 메모리 정렬과 패딩: HLSL StructuredBuffer 사용 시 16바이트 정렬 규칙을 준수해야 합니다. float4x4(64바이트)와 float4(16바이트)를 차례대로 구성하여 메모리 접근 효율을 극대화합니다.
  • LOD 및 거리 기반 디스어피어: 카메라와의 거리가 일정 이상 멀어지면 컴퓨트 셰이더 단계에서 인스턴스를 선택적으로 배제하거나 LOD 단계별 버퍼로 분기하여 삼각형 개수를 대폭 줄입니다.
  • 그림자 패스 최적화: 그림자 맵 생성 시에도 동일한 간접 렌더링 버퍼를 활용하되 광원 공간 절두체 컬링을 별도로 수행하면 그림자 렌더링 오버헤드를 줄일 수 있습니다.

GPU 인스턴싱과 컴퓨트 셰이더 컬링을 결합하면 수만 개의 풀, 자갈, 소품 오브젝트가 배치된 환경에서도 드로우 콜을 1~2회 수준으로 유지하면서 안정적인 프레임을 확보할 수 있습니다.

#Unity#Unreal Engine#C++#Shader#GPU Instancing#Optimization

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs