GPU Instancing으로 풀·자갈 대량 오브젝트를 렌더링하는 방법

Unity URP에서 GPU Instancing으로 풀과 자갈 같은 반복 오브젝트의 드로우 콜을 줄이는 원리와 셰이더 설정, 배치 기준, 한계 및 디버깅 방법을 정리합니다.

✓ 복사 완료!
챕터렌더링 파이프라인 & 배칭
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
GPU Instancing으로 풀·자갈 대량 오브젝트를 렌더링하는 방법

핵심 요약

GPU Instancing은 동일한 메시와 머티리얼을 공유하는 오브젝트를 한 번의 드로우 호출로 묶어 풀, 자갈, 낙엽처럼 반복되는 환경 소품의 CPU 제출 비용을 낮추는 기술이다. Unity URP에서는 인스턴싱을 지원하는 셰이더와 동일한 머티리얼을 사용하고 인스턴스별 차이는 Transform 또는 인스턴싱 프로퍼티로 전달하면 된다. 단, 메시나 머티리얼이 달라지거나 그림자·라이트·렌더 상태가 달라지면 배치가 분리된다.

풀과 자갈 렌더링에서 GPU Instancing이 필요한 이유

환경 아트에서 작은 오브젝트를 개별 GameObject로 배치하면 폴리곤 수보다 먼저 CPU 측 병목이 생기기 쉽다. 렌더러마다 컬링 결과를 처리하고 드로우 콜을 제출해야 하기 때문이다. 특히 카메라 가까이에 풀 수천 포기와 자갈 수천 개가 동시에 들어오는 장면에서 이 비용이 누적된다.

GPU Instancing은 동일한 지오메트리를 여러 번 그리는 작업에 적합하다. GPU는 인스턴스마다 다른 월드 변환 행렬을 적용해 같은 메시를 반복 렌더링한다.

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

여기서 M_instance는 각 풀 포기나 자갈 하나의 위치, 회전, 스케일을 담은 인스턴스 변환 행렬이다.

방식CPU 드로우 제출 비용인스턴스별 변형적합한 대상
개별 MeshRenderer높음자유로움고유한 영웅 오브젝트
GPU Instancing낮음Transform, 색, 바람 강도 등풀, 자갈, 꽃, 낙엽
Static Batching낮음런타임 이동 불가완전히 고정된 배경 메시
단일 병합 메시매우 낮음개별 컬링·편집이 어려움멀리 있는 지형 장식

GPU Instancing으로 동일한 풀 메시를 여러 변환 행렬로 렌더링하는 개념

Unity URP에서 GPU Instancing은 어떻게 구현할까?

다음 예시는 Unity 2022.3 LTS와 URP 기준이다. Shader Graph를 사용한다면 Graph Inspector의 Support GPU Instancing을 켜는 것부터 확인한다. HLSL 셰이더를 직접 작성한다면 인스턴싱 매크로를 명시해야 한다.

1. 인스턴싱 가능한 머티리얼과 메시를 준비한다

  1. 풀 또는 자갈 메시 하나를 만든다. 풀은 알파 클리핑을 쓰는 저폴리 카드나 작은 클러스터 메시가 보통 효율적이다.
  2. 모든 인스턴스가 같은 머티리얼을 참조하게 한다. 복제된 머티리얼은 다른 배치로 취급될 수 있다.
  3. 머티리얼 Inspector에서 Enable GPU Instancing을 켠다. URP Lit 또는 Simple Lit 머티리얼에서도 사용할 수 있다.
  4. 풀과 자갈처럼 메시가 다른 대상은 별도 인스턴싱 배치가 생기는 것이 정상이다.

머티리얼 속성을 개별 renderer.material로 바꾸면 Unity가 머티리얼 인스턴스를 만들 수 있다. 색이나 바람 세기 같은 차이는 MaterialPropertyBlock 또는 Graphics.DrawMeshInstanced의 인스턴스 데이터로 전달한다.

2. 풀 셰이더에 인스턴싱 프로퍼티를 선언한다

아래는 인스턴스별 색조와 바람 위상 값을 사용할 때 필요한 최소 HLSL 구조다. URP 셰이더의 패스 안에서 사용한다.

#pragma multi_compile_instancing

UNITY_INSTANCING_BUFFER_START(GrassProps)
    UNITY_DEFINE_INSTANCED_PROP(float4, _TintColor)
    UNITY_DEFINE_INSTANCED_PROP(float, _WindPhase)
UNITY_INSTANCING_BUFFER_END(GrassProps)

struct Attributes
{
    float4 positionOS : POSITION;
    float2 uv : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct Varyings
{
    float4 positionCS : SV_POSITION;
    float4 color : COLOR;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

Varyings vert(Attributes input)
{
    Varyings output;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output);

    float phase = UNITY_ACCESS_INSTANCED_PROP(GrassProps, _WindPhase);
    float4 tint = UNITY_ACCESS_INSTANCED_PROP(GrassProps, _TintColor);
    float sway = sin(_Time.y * 2.0 + phase) * input.positionOS.y * 0.08;

    input.positionOS.x += sway;
    output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
    output.color = tint;
    return output;
}

_WindPhase를 인스턴스마다 다르게 주면 모든 풀이 같은 타이밍으로 흔들리는 현상을 줄일 수 있다. 정점 높이에 비례해 흔들림을 곱하면 뿌리는 고정되고 잎 끝이 더 크게 움직인다.

3. Graphics.DrawMeshInstanced로 배치 단위 렌더링한다

Graphics.DrawMeshInstanced는 호출당 최대 1023개 행렬을 전달한다. 따라서 수만 개를 그릴 때는 1023개 이하의 청크로 나누고 카메라 주변 청크만 호출해야 한다.

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

public sealed class InstancedGrassRenderer : MonoBehaviour
{
    [SerializeField] Mesh grassMesh;
    [SerializeField] Material grassMaterial;
    [SerializeField] List<Matrix4x4> matrices = new();

    const int BatchSize = 1023;
    readonly Matrix4x4[] batchMatrices = new Matrix4x4[BatchSize];

    void Update()
    {
        for (int start = 0; start < matrices.Count; start += BatchSize)
        {
            int count = Mathf.Min(BatchSize, matrices.Count - start);
            matrices.CopyTo(start, batchMatrices, 0, count);

            Graphics.DrawMeshInstanced(
                grassMesh,
                0,
                grassMaterial,
                batchMatrices,
                count,
                null,
                ShadowCastingMode.On,
                true,
                gameObject.layer
            );
        }
    }
}

이 예제는 원리를 보여 주는 최소 구현이다. 실제 프로젝트에서는 월드 전체의 행렬을 매 프레임 순회하지 말고 월드를 일정 크기의 셀로 나눈 뒤 프러스텀과 거리 기준을 통과한 셀만 그리는 방식이 안전하다.

배치가 나뉘는 이유는 무엇인가?

GPU Instancing은 ‘비슷해 보이는 오브젝트’를 묶는 기능이 아니라 렌더 상태가 사실상 같은 항목을 묶는 기능이다. 다음 중 하나가 다르면 별도 드로우가 될 수 있다.

배치 분리 원인예시대응 방법
메시가 다름풀 A와 풀 B종류별 배치를 유지하거나 멀리서는 하나의 메시로 통합
머티리얼이 다름색만 다른 복제 머티리얼MaterialPropertyBlock 또는 인스턴싱 프로퍼티 사용
셰이더 키워드가 다름알파 클립, 노멀맵 옵션 차이필요한 변형만 유지
렌더 큐·블렌드 상태가 다름투명 풀과 불투명 자갈렌더 특성별로 분리
라이트맵 데이터가 다름베이크된 소품라이트맵 전략을 별도로 검토

특히 투명 렌더링은 깊이 정렬 때문에 완전한 배치 효율을 기대하기 어렵다. 풀은 가능한 경우 Alpha Clipping 기반의 컷아웃 셰이더를 우선 검토하고 부드러운 가장자리가 꼭 필요할 때만 투명 블렌딩을 사용한다.

풀과 자갈을 효율적으로 배치하는 기준

풀: 밀도보다 실루엣과 컬링 범위를 먼저 설계한다

풀은 화면을 많이 차지하지만 개별 형태를 자세히 보기는 어렵다. 따라서 가까운 영역에는 카드 또는 작은 클러스터를 배치하고 중거리부터는 더 적은 정점의 메시와 낮은 밀도를 쓴다. 원거리에서는 잔디 텍스처, 지형 디테일, 임포스터로 전환하는 편이 낫다.

권장하는 시작점은 다음과 같다.

  • 10~20m: 클러스터 메시, 바람 정점 변형, 그림자 사용 여부를 측정해 결정
  • 20~50m: 더 단순한 메시와 낮은 밀도, 짧은 그림자 거리
  • 그 이상: 개별 메시 제거 또는 지형 기반 표현으로 전환

풀 청크의 AABB가 지나치게 크면 카메라에 조금만 걸려도 청크 전체가 렌더링된다. 반대로 너무 작으면 컬링과 드로우 호출이 늘어난다. 월드 크기, 카메라 속도, 풀 밀도를 기준으로 셀 크기를 측정하면서 정한다.

자갈: 메시 변형과 텍스처 아틀라스로 반복을 숨긴다

자갈은 같은 메시를 그대로 반복하면 패턴이 금방 드러난다. 인스턴스마다 Y축 회전, 0.8~1.2 범위의 균일 스케일, 약한 색조 변화를 적용한다. 메시 종류가 2~4개라면 종류별 인스턴싱 배치로 나누는 편이 보통 충분하다.

서로 다른 텍스처를 쓰고 싶다면 텍스처 아틀라스 또는 Texture2DArray를 고려한다. Texture2DArray는 셰이더에서 레이어 인덱스를 인스턴스 데이터로 넘겨 샘플링할 수 있어 여러 자갈 표면을 하나의 머티리얼 흐름 안에 유지하는 데 유리하다.

성능은 어떤 순서로 검증해야 할까?

  1. 드로우 콜과 CPU 시간을 먼저 측정한다. Unity Profiler에서 Render Thread, Main Thread, Batches와 SetPass Calls를 확인한다. CPU가 이미 여유 있고 GPU가 병목이라면 인스턴싱만으로 프레임이 크게 오르지 않을 수 있다.
  2. Frame Debugger로 실제 배치를 확인한다. 기대한 풀 또는 자갈이 인스턴싱 호출로 묶였는지 어떤 머티리얼·키워드 차이로 분리됐는지 확인한다.
  3. GPU 비용을 따로 측정한다. RenderDoc, Unity GPU Profiler 또는 플랫폼 도구로 오버드로우, 알파 클리핑, 그림자 패스, 정점 수를 본다. 인스턴싱은 드로우 콜을 줄여도 화면에 그리는 픽셀 수를 줄이지 않는다.
  4. 거리별 품질을 조정한다. LOD, 그림자 거리, 밀도, 셀 크기를 한 항목씩 바꾸고 동일한 카메라 위치에서 비교한다.

자주 묻는 질문 (FAQ)

GPU Instancing을 켜면 모든 드로우 콜이 하나가 되나?

아니다. 동일한 메시, 머티리얼, 셰이더 변형, 렌더 상태를 공유하는 인스턴스만 묶인다. 풀 종류와 자갈 종류가 다르면 각각 별도 배치가 되는 것이 정상이다.

MaterialPropertyBlock을 쓰면 인스턴싱이 깨지나?

인스턴싱을 지원하는 셰이더의 인스턴스 프로퍼티로 값을 전달한다면 깨지지 않는다. 반대로 머티리얼 자체를 복제하거나 셰이더가 해당 프로퍼티의 인스턴싱을 선언하지 않았다면 기대한 배치가 나오지 않을 수 있다.

풀에 그림자를 켜도 되는가?

가까운 거리의 풀에는 효과적일 수 있지만 수천 개의 알파 클리핑 메시가 그림자 맵을 채우면 비용이 커진다. 그림자 거리와 캐스팅 거리를 제한하고 중·원거리 풀은 그림자를 끄는 방식부터 측정하는 것이 좋다.

정리

GPU Instancing의 핵심은 동일한 풀·자갈 메시를 하나의 머티리얼 흐름으로 유지하고 인스턴스별 차이를 Transform과 셰이더 프로퍼티로 넘기는 것이다. 다만 이것은 CPU 드로우 제출 최적화다. 최종 성능은 인스턴싱과 함께 청크 컬링, LOD, 알파 클리핑, 그림자 거리, 오버드로우 제어를 묶어 측정할 때 가장 안정적으로 개선된다.