Unity 렌더 파이프라인 (2) - 드로우콜과 배칭 - soo:bak
작성일 :
GPU에 명령을 보내는 비용
Part 1에서 렌더 파이프라인이 씬의 오브젝트를 GPU에 제출하는 구조를 살펴보았습니다. 카메라가 씬을 관찰하고, 보이는 오브젝트를 정렬하고, GPU에 렌더링 명령을 보내는 일련의 과정이었습니다.
그런데 모바일 기기에서는 씬에 오브젝트가 수백 개만 되어도 프레임 레이트가 급격히 떨어지는 경우가 있습니다. GPU가 느린 것이 아니라, CPU가 GPU에 렌더링 명령을 준비하고 전달하는 과정에서 병목이 발생하기 때문입니다.
이 과정에서 CPU가 GPU에 보내는 렌더링 명령 하나를 드로우콜(Draw Call)이라 합니다.
오브젝트 하나를 그리려면 최소 하나의 드로우콜이 필요하고, 씬의 오브젝트가 많아지면 드로우콜 수도 함께 늘어납니다.
드로우콜이 늘어날수록 CPU가 매 프레임 처리해야 할 작업량이 증가하여, 결국 프레임을 제시간에 완성하지 못하게 됩니다.
이 비용을 줄이기 위해 Unity는 여러 배칭(Batching) 기법을 제공합니다.
이 글에서는 드로우콜의 비용이 정확히 어디서 발생하는지 분석한 뒤, Static Batching, Dynamic Batching, SRP Batcher, GPU Instancing 네 가지 기법의 원리와 트레이드오프를 살펴봅니다.
드로우콜(Draw Call)의 정의
앞에서 설명했던 것처럼, 드로우콜은 CPU가 GPU에게 보내는 렌더링 명령입니다.
하나의 드로우콜에는 “이 메쉬의 삼각형들을, 이 머티리얼이 지정하는 셰이더와 프로퍼티로 렌더링하라”는 의미가 담겨 있습니다.
GPU는 스스로 그릴 대상을 결정하지 못하므로, 오브젝트마다 별도의 드로우콜이 필요합니다.
씬에 나무 10그루, 건물 5채, 캐릭터 3명이 있다면 18번의 드로우콜이 발생합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
씬의 오브젝트와 드로우콜
══════════════════════════════════════════════════════════
CPU GPU
─── ───
나무 A ── 드로우콜 1 ──► 렌더링
나무 B ── 드로우콜 2 ──► 렌더링
건물 A ── 드로우콜 3 ──► 렌더링
⋮ ⋮ ⋮
캐릭터 C ── 드로우콜 18 ──► 렌더링
──────────────────────────────────────────────────────────
오브젝트 18개 → 드로우콜 18회
══════════════════════════════════════════════════════════
드로우콜 자체의 비용은 크지 않습니다. GPU에 “그려라”라는 명령을 전달하는 것 자체는 가벼운 함수 호출이기 때문입니다.
실제 비용은 드로우콜이 실행되기 직전, GPU 상태를 변경하는 과정에서 발생합니다.
상태 변경 비용
GPU는 상태 머신(State Machine)처럼 동작합니다. 상태 머신은 현재 설정된 상태에 따라 동작이 결정되는 구조입니다.
GPU가 삼각형을 그리기 전에는 어떤 셰이더를 사용할 것인지, 어떤 텍스처를 바인딩할 것인지, 블렌딩 모드는 무엇인지 같은 상태를 먼저 설정해야 합니다.
CPU가 이 상태를 GPU에 전달하고, GPU가 내부적으로 상태를 변경하는 과정에서 비용이 발생합니다.
1
2
3
4
5
6
7
8
9
10
하나의 오브젝트를 그리기 위한 CPU → GPU 명령 흐름
CPU가 보내는 명령 GPU에서 일어나는 일
────────────────── ──────────────────────
(1) SetShader 셰이더 프로그램 전환 ┐
(2) BindTexture 텍스처 슬롯 설정 │ 상태 변경
(3) SetBlendState 블렌딩 상태 전환 │
(4) SetUniform 상수 버퍼 갱신 ┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
(5) DrawCall 삼각형 렌더링 시작 ← 실제 드로우콜
(1)~(4)가 상태 변경이고, (5)가 실제 드로우콜입니다.
상태 변경 중 가장 비용이 큰 것은 셰이더 교체입니다.
현재 셰이더 프로그램을 해제하고 새 셰이더를 로드하려면, GPU 파이프라인에 남아 있는 이전 작업을 모두 비워야(flush) 합니다.
이전 셰이더로 처리 중이던 정점과 프래그먼트가 파이프라인 각 단계에 남아 있고, 새 셰이더는 처리 로직이 다르기 때문입니다.
이 flush 동안 GPU는 새 작업을 시작할 수 없어 파이프라인에 유휴 시간이 생깁니다.
텍스처 바인딩이나 블렌딩 모드 변경은 셰이더 교체보다 가볍지만, 횟수가 누적되면 무시할 수 없는 비용이 됩니다.
같은 머티리얼을 사용하는 오브젝트를 연속으로 그리면 상태 변경이 필요 없습니다. 머티리얼이 같으면 셰이더, 텍스처, 블렌딩 모드가 모두 같으므로, GPU가 기존 상태를 유지한 채 다음 메쉬를 그릴 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
상태 변경(SetPass)과 드로우콜의 관계
[비효율적] 머티리얼이 계속 바뀜
SetPass → 머티리얼 A 상태 설정
드로우콜 1: 나무
SetPass → 머티리얼 B 상태 설정
드로우콜 2: 건물
SetPass → 머티리얼 A 상태 설정 (A로 되돌아감)
드로우콜 3: 나무
SetPass → 머티리얼 C 상태 설정
드로우콜 4: 캐릭터
→ SetPass 4회, 드로우콜 4회
[효율적] 같은 머티리얼끼리 정렬
SetPass → 머티리얼 A 상태 설정
드로우콜 1: 나무
드로우콜 2: 나무 ← 상태 유지 (SetPass 불필요)
SetPass → 머티리얼 B 상태 설정
드로우콜 3: 건물
SetPass → 머티리얼 C 상태 설정
드로우콜 4: 캐릭터
→ SetPass 3회, 드로우콜 4회
Part 1에서 렌더 파이프라인이 오브젝트를 정렬한다고 했는데, 그 정렬 기준이 머티리얼입니다.
같은 머티리얼끼리 연속 배치하여 상태 변경 횟수를 줄이기 위한 것입니다.
SetPass Call
앞 절에서 확인한 것처럼, 드로우콜의 실제 비용은 상태 변경에서 발생합니다.
Unity는 이 상태 변경이 실제로 몇 번 일어났는지를 SetPass Call이라는 지표로 추적합니다.
여기서 “Pass”는 셰이더의 렌더링 패스를 뜻합니다. 패스는 정점 처리부터 프래그먼트 처리까지를 한 번 수행하는 단위입니다.
새로운 패스가 설정될 때마다 GPU의 셰이더 프로그램이 교체됩니다.
이 교체가 일어날 때마다 SetPass Call이 1 증가합니다.
드로우콜이 100개라도 SetPass Call이 5개라면, GPU 상태 변경은 5번밖에 일어나지 않은 것입니다.
나머지 95개의 드로우콜은 이전 드로우콜과 동일한 상태에서 메쉬만 교체하여 그린 셈입니다.
반대로 드로우콜이 50개인데 SetPass Call도 50개라면, 매 드로우콜마다 상태가 바뀐 것이므로 상태 변경 비용이 최대치에 해당합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
드로우콜 200개에서 SetPass Call의 차이
| = 상태 변경 지점, ── = 같은 상태 내 드로우콜
씬 A: SetPass Call 10
|────────────────────|───────────|──────|───| ...
Pass 1 Pass 2 Pass 3
→ 상태 변경 10회, 상태 유지 190회
씬 B: SetPass Call 180
|─|─|─|─|─|─|─|─|─|─|─|─|─|─|─|─|─|─|─|─| ...
거의 매 드로우콜마다 상태 변경
→ 상태 변경 180회, 상태 유지 20회
따라서 최적화할 때는 드로우콜 수보다 SetPass Call 수를 먼저 확인해야 합니다. Unity Profiler의 Rendering 섹션에서 확인할 수 있습니다.
SetPass Call을 줄이려면 머티리얼 종류를 줄이거나, 같은 머티리얼끼리 정렬하거나, 셰이더 variant(Shader Variant)를 통일해야 합니다.
셰이더 variant는 하나의 셰이더에서 기능 옵션의 조합에 따라 만들어지는 서로 다른 컴파일 버전입니다.
예를 들어 URP Lit 셰이더는 안개(Fog), 그림자 수신(Receive Shadows) 같은 기능을 켜거나 끌 수 있는데, 안개를 켠 Lit과 끈 Lit은 서로 다른 variant입니다.
같은 셰이더라도 variant가 다르면 GPU 입장에서는 별개의 프로그램이므로 상태 변경이 발생합니다.
이제부터 살펴볼 배칭 기법들은 모두 드로우콜 수 또는 SetPass Call 수를 줄이는 것을 목표로 합니다.
Static Batching
Static Batching은 움직이지 않는(Static으로 표시된) 오브젝트들의 메쉬를 빌드 시점에 하나의 큰 메쉬로 합치는 기법입니다.
씬에 같은 머티리얼을 사용하는 나무 50그루가 있다고 가정합니다. Static Batching 없이는 50번의 드로우콜이 필요합니다. 하지만 Static Batching을 적용하면, 빌드 시점에 50그루의 나무 메쉬가 하나로 합쳐지고, 런타임에는 1번의 드로우콜로 모두 그릴 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Static Batching 적용 전
나무 1 ──► 드로우콜 1 ─┐
나무 2 ──► 드로우콜 2 │
나무 3 ──► 드로우콜 3 │ 50번의 드로우콜
... │ (같은 머티리얼이지만
나무 50 ─► 드로우콜 50 ─┘ 개별 메쉬이므로 개별 제출)
Static Batching 적용 후 (빌드 시점)
나무 1 ─┐
나무 2 │ 하나의 큰 메쉬로 합침
나무 3 ├──► 합친 메쉬 ──► 드로우콜 1
... │ (1번의 드로우콜로 50그루 모두 렌더링)
나무 50 ┘
작동 조건
Static Batching이 적용되려면 두 가지 조건을 만족해야 합니다.
첫째, 오브젝트의 Inspector에서 Static 체크박스가 켜져 있어야 합니다. 정확히는 Static 체크박스의 하위 옵션 중 Batching Static 플래그가 필요합니다. 이 플래그는 “이 오브젝트는 런타임에 이동하지 않는다”는 약속이므로, 엔진이 빌드 시점에 위치를 확정하고 메쉬를 합칠 수 있습니다.
둘째, 같은 머티리얼을 사용해야 합니다. 앞 절에서 설명한 것처럼 머티리얼이 다르면 GPU 상태가 달라지므로, 하나의 드로우콜로 합칠 수 없습니다. 나무 50그루가 모두 동일한 머티리얼을 사용해야 하나의 배치로 묶입니다.
장점과 단점
장점은 CPU가 GPU에 보내는 드로우콜 수가 줄어들어 CPU 측 렌더링 준비 비용이 감소한다는 점입니다.
메쉬를 합치는 작업이 빌드 시점에 끝나므로, 런타임에 추가 CPU 비용이 발생하지 않습니다.
단점은 메모리 증가입니다.
나무 50그루가 모두 같은 프리팹에서 만들어졌다면, 원래는 메쉬 데이터 하나만 메모리에 올리고 50번 재사용합니다. 각 인스턴스에는 위치, 회전, 스케일 정보(변환 행렬)만 저장하면 됩니다.
하지만 Static Batching을 적용하면 각 나무의 정점이 월드 좌표로 변환되어 하나의 큰 메쉬에 복사됩니다. 합쳐진 메쉬가 원본과 별도로 메모리에 상주하므로, 정점 데이터가 50배로 늘어납니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
메모리 사용 비교 (나무 1,000 정점 × 50그루)
[Static Batching 없이]
원본 메쉬: 1,000 정점 (1개를 50번 재사용)
인스턴스: 변환 행렬 50개
─────────────────────────────────
정점 합계: 1,000
[Static Batching 적용]
합친 메쉬: 50 × 1,000 = 50,000 정점
원본 메쉬: 1,000 정점 (MeshFilter 참조로 메모리에 남음)
─────────────────────────────────
정점 합계: 51,000
모바일 기기의 메모리는 제한적입니다.
일반적인 모바일 기기의 총 RAM은 3~8 GB 수준이고, 이 중 앱이 사용할 수 있는 양은 더 적습니다.
Static Batching으로 드로우콜을 줄이면서 메모리를 과도하게 사용하면, 오히려 메모리 부족으로 OS가 앱을 강제 종료하거나 다른 리소스의 로딩이 지연됩니다.
정점 수가 많은 오브젝트(5,000 정점 이상의 고폴리곤 건물 등)를 수십 개 이상 Static Batching하는 경우에는 메모리 비용 대비 효과를 프로파일링으로 확인해야 합니다.
다만, 메모리가 늘어나더라도 불필요한 렌더링까지 증가하는 것은 아닙니다.
Frustum Culling은 카메라 시야 밖의 오브젝트를 렌더링 대상에서 제외하는 기법인데, Unity의 Static Batching은 메쉬를 합친 뒤에도 개별 메쉬 단위의 Frustum Culling을 지원합니다.
나무 50그루 중 카메라에 보이는 것이 5그루뿐이라면, Unity는 인덱스 버퍼(정점을 어떤 순서로 연결하여 삼각형을 구성할지 지정하는 데이터)에서 보이는 메쉬의 인덱스만 포함하여 GPU에 제출합니다.
Mesh.CombineMeshes()로 메쉬를 수동 결합하는 경우에는 동작이 다릅니다.
수동 결합에서는 결합된 메쉬가 단일 오브젝트가 되어 전체가 하나의 AABB(Axis-Aligned Bounding Box, 오브젝트를 감싸는 축 정렬 직육면체)로 컬링되므로, 일부만 보이더라도 전체가 GPU에 제출됩니다.
Static Batching은 앞서 설명한 것처럼 인덱스 버퍼를 조작하여 이 문제를 피하지만, 합쳐진 정점 데이터 자체가 메모리에 상주하는 비용은 여전히 남아 있습니다.
Dynamic Batching
Static Batching은 빌드 시점에 메쉬를 합치므로, 런타임에 움직이는 오브젝트에는 적용할 수 없습니다.
Dynamic Batching은 이 제한을 해결하기 위해, 런타임에 매 프레임 CPU가 작은 메쉬들을 합치는 방식입니다.
1
2
3
4
5
6
7
8
9
Dynamic Batching — 매 프레임 CPU가 수행하는 작업
(1) 같은 머티리얼을 사용하는 작은 메쉬들을 찾음
(2) 각 메쉬의 정점을 월드 좌표로 변환
(3) 변환된 정점들을 하나의 버퍼에 합침
(4) 합친 메쉬를 하나의 드로우콜로 GPU에 제출
비교: Static Batching → 빌드 시점에 1회
Dynamic Batching → 매 프레임 반복
매 프레임 CPU가 정점을 변환하고 합쳐야 하므로 CPU 비용이 발생합니다. 각 오브젝트의 정점을 로컬 좌표에서 월드 좌표로 변환하는 행렬 곱셈을, 정점마다 수행해야 하기 때문입니다.
이 CPU 비용이 드로우콜 절약으로 얻는 이점보다 커지는 경우가 많아, Dynamic Batching에는 엄격한 제한이 걸려 있습니다.
제한 사항
Dynamic Batching은 메쉬의 정점 수에 제한이 있습니다.
정점 하나에는 위치, 법선, UV 좌표 등 여러 데이터 요소가 저장되는데, 이를 정점 속성(Vertex Attribute)이라 합니다.
CPU가 매 프레임 변환해야 하는 것은 이 정점 속성 전체이므로, Unity는 두 가지 제한을 동시에 적용합니다.
정점 수는 최대 300개, 정점 속성 총합(정점 수 × 정점당 속성 수)은 최대 900개이며, 둘 중 먼저 도달하는 쪽이 실제 제한이 됩니다.
1
2
3
4
5
6
7
Dynamic Batching 정점 제한 (정점 속성 총합 900 이하)
속성 수 사용 속성 예시 최대 정점
────── ───────────────────────────────────── ────────
3개 위치 + 법선 + UV 300
4개 위치 + 법선 + UV + 탄젠트 225
5개 위치 + 법선 + UV0 + UV1 + 탄젠트 180
정점 수 외에 추가 조건도 있습니다. 같은 머티리얼을 사용해야 하고, 스케일 유형도 일치해야 합니다.
오브젝트의 x, y, z 축 비율이 모두 동일한 상태를 균일 스케일(Uniform Scale), 축마다 비율이 다른 상태를 비균일 스케일(Non-uniform Scale)이라 합니다.
CPU가 정점을 월드 좌표로 변환할 때 두 유형은 법선 벡터 계산 방식이 달라지므로, 균일 스케일 오브젝트끼리, 비균일 스케일 오브젝트끼리만 배칭이 가능합니다.
음수 스케일(미러링)을 사용하는 오브젝트는 아예 배칭에서 제외됩니다.
라이트맵(Lightmap, 미리 계산된 조명 정보를 저장한 텍스처)을 사용하는 경우에는 같은 라이트맵 인덱스와 같은 오프셋/스케일을 가져야 합니다.
멀티패스 셰이더를 사용하는 오브젝트도 배칭 대상이 되지 않습니다.
URP에서의 비활성화
이러한 제한 때문에 Dynamic Batching이 실제로 적용되는 상황은 한정적입니다.
300 정점 이하의 메쉬는 UI 요소나 단순한 파티클 정도에 국한되고, 적용되더라도 CPU에서 매 프레임 정점을 변환하는 비용이 드로우콜 절약 효과를 상쇄하는 경우가 빈번합니다.
이런 이유로 URP에서는 Dynamic Batching이 기본적으로 비활성화되어 있습니다.
URP는 대신 SRP Batcher라는 더 효율적인 방식을 제공합니다.
URP Asset의 Inspector에서 Dynamic Batching을 강제로 활성화할 수는 있지만, 대부분의 경우 SRP Batcher가 더 나은 성능을 보입니다.
SRP Batcher
Static Batching과 Dynamic Batching은 모두 “메쉬를 합쳐서 드로우콜 수를 줄이는” 접근입니다.
SRP Batcher는 발상이 다릅니다. 메쉬를 합치지 않고, GPU 상태 변경 자체를 최소화하여 CPU 측 비용을 줄이는 방식입니다. URP와 HDRP의 핵심 최적화 기법에 해당합니다.
기존 방식의 문제
SRP Batcher가 없는 기존 렌더링 방식에서는, 드로우콜마다 머티리얼의 속성 값(색상, 텍스처 오프셋, 셰이더 파라미터 등)을 CPU 메모리에서 GPU 메모리로 업로드합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
기존 방식: 매 드로우콜마다 CPU → GPU 전송
CPU가 보내는 데이터 GPU에서 일어나는 일
───────────────────── ─────────────────────
드로우콜 1 (셰이더 A)
셰이더 설정 ──► 셰이더 A 로드
머티리얼 속성 ──► 상수 버퍼 갱신
오브젝트 변환 행렬 ──► 변환 행렬 갱신 → 렌더링
드로우콜 2 (셰이더 A, 같은 머티리얼)
셰이더 설정 ──► 셰이더 A 유지
머티리얼 속성 ──► 상수 버퍼 갱신 ← 값이 같아도 다시 전송
오브젝트 변환 행렬 ──► 변환 행렬 갱신 → 렌더링
드로우콜 3 (셰이더 B)
셰이더 설정 ──► 셰이더 B 로드
머티리얼 속성 ──► 상수 버퍼 갱신
오브젝트 변환 행렬 ──► 변환 행렬 갱신 → 렌더링
드로우콜 1과 드로우콜 2가 같은 셰이더(셰이더 A)를 사용하더라도, 머티리얼이 다르면(예: 색상 값이 다름) 매번 머티리얼 속성을 CPU에서 GPU로 업로드해야 합니다.
CPU가 데이터를 준비하고 GPU 메모리에 복사하는 과정이 드로우콜마다 반복되므로, CPU 시간이 소모됩니다.
SRP Batcher의 동작
기존 방식에서 이런 일이 발생하는 이유는 GPU의 상수 버퍼가 하나의 공유 슬롯이기 때문입니다. 머티리얼 A로 그리면 상수 버퍼에 A의 값이 들어가고, 머티리얼 B로 그리면 B의 값으로 덮어씁니다. 다시 머티리얼 A로 그리려면, A의 값이 이미 덮어써졌으므로 다시 업로드해야 합니다.
SRP Batcher는 이 구조를 바꿉니다. 각 머티리얼마다 별도의 상수 버퍼(Constant Buffer, CBUFFER) 영역을 GPU 메모리에 확보하고, 속성 값을 지속적으로 저장합니다. 상수 버퍼는 셰이더 실행 중 변하지 않는 데이터를 저장하는 GPU 전용 메모리 영역입니다.
머티리얼 A의 값은 CBUFFER의 오프셋 0에, 머티리얼 B의 값은 오프셋 1에 동시에 상주하므로, 서로 덮어쓰이지 않습니다. CPU는 드로우콜마다 데이터를 다시 복사하는 대신, “CBUFFER의 N번 위치를 사용하라”는 오프셋만 전달하면 됩니다.
1
2
3
4
5
6
7
8
9
10
11
SRP Batcher — GPU 메모리에 상주하는 CBUFFER
머티리얼 CBUFFER 오브젝트 CBUFFER
오프셋 데이터 오프셋 데이터
────── ────────────────── ────── ──────────────
0 머티리얼 A 색상, UV 등 0 오브젝트 1 변환 행렬
1 머티리얼 B 색상, UV 등 1 오브젝트 2 변환 행렬
2 머티리얼 C 색상, UV 등 2 오브젝트 3 변환 행렬
각 머티리얼/오브젝트가 고유한 오프셋에 상주 → 서로 덮어쓰이지 않음
CPU는 드로우콜마다 "오프셋 N을 사용하라"고 지정할 뿐, 데이터를 다시 복사하지 않음
기존 방식에서는 드로우콜마다 머티리얼 속성을 CPU에서 GPU로 복사해야 했지만, SRP Batcher에서는 GPU에 이미 상주하는 데이터의 오프셋만 지정합니다. 데이터 복사가 사라지므로, 드로우콜당 CPU 비용이 오프셋 지정 수준으로 줄어듭니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
기존 방식 vs SRP Batcher (머티리얼 A, B를 사용하는 드로우콜 3회)
기존 방식 CPU 동작
───────────────────────────────────────────── ─────────────
드로우콜 1 머티리얼 A 속성 → 상수 버퍼에 복사 데이터 복사
드로우콜 2 머티리얼 B 속성 → 상수 버퍼에 복사 데이터 복사
드로우콜 3 머티리얼 A 속성 → 상수 버퍼에 복사 데이터 복사 (A를 다시)
합계: 복사 3회
SRP Batcher CPU 동작
───────────────────────────────────────────── ─────────────
초기화 A → 오프셋 0, B → 오프셋 1에 저장 데이터 복사
드로우콜 1 "오프셋 0 사용" 지정 오프셋 지정
드로우콜 2 "오프셋 1 사용" 지정 오프셋 지정
드로우콜 3 "오프셋 0 사용" 지정 오프셋 지정
합계: 복사 0회 (초기화 이후)
SRP Batcher의 배칭 단위
SRP Batcher의 배칭 단위는 같은 셰이더 variant입니다. 셰이더 variant가 같은 드로우콜은 머티리얼이 달라도 하나의 배치로 묶입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
SRP Batcher의 배칭 단위: 셰이더 variant
SRP 배치 1 — 셰이더 variant A GPU 상태
───────────────────────────────────────── ──────────────────
드로우콜 1 머티리얼 A (빨간색 나무) SetPass (셰이더 A)
드로우콜 2 머티리얼 B (파란색 나무) 오프셋만 변경
드로우콜 3 머티리얼 C (초록색 나무) 오프셋만 변경
SRP 배치 2 — 셰이더 variant B GPU 상태
───────────────────────────────────────── ──────────────────
드로우콜 4 머티리얼 D (투명 유리) SetPass (셰이더 B)
드로우콜 5 머티리얼 E (투명 물) 오프셋만 변경
드로우콜 5회, SetPass Call 2회
Static Batching과 Dynamic Batching은 같은 머티리얼이어야 배칭할 수 있었습니다. SRP Batcher는 셰이더 variant만 일치하면 되므로, 머티리얼 종류가 다양한 씬에서도 효과적으로 배칭됩니다.
장점
같은 셰이더 variant 안에서는 GPU 상태 변경이 일어나지 않으므로 SetPass Call이 감소합니다.
메쉬를 합치지 않기 때문에 Static Batching과 달리 메모리 증가가 없습니다.
Dynamic Batching처럼 매 프레임 정점을 변환하는 작업도 필요 없어 런타임 CPU 비용이 추가되지 않습니다.
또한, 오브젝트의 변환 행렬만 CBUFFER에서 갱신하면 되므로 Static이 아닌 움직이는 오브젝트에도 동작합니다.
작동 조건
SRP Batcher가 동작하려면 두 가지 조건이 필요합니다.
첫째, SRP 호환 셰이더를 사용해야 합니다. URP/HDRP에 포함된 Lit, Unlit 등의 기본 셰이더는 이미 호환됩니다.
커스텀 셰이더를 작성하는 경우에는 앞에서 설명한 두 CBUFFER를 셰이더 코드에 올바르게 선언해야 합니다.
UnityPerMaterial은 머티리얼의 시각적 속성을 담습니다. 기본 색상, 텍스처의 타일링과 오프셋, 메탈릭 값, 매끄러움 등 셰이더의 Properties 블록에 선언한 속성들이 여기에 해당합니다.
UnityPerDraw는 오브젝트마다 달라지는 데이터를 담습니다. 월드 변환 행렬, 라이트맵 UV의 오프셋과 스케일, 라이트프로브 계수 등이 포함됩니다. 개발자가 이 변수들을 셰이더 코드에 올바르게 선언하면, 각 변수의 값은 Unity 엔진이 런타임에 자동으로 채워 넣습니다.
이 두 CBUFFER 선언이 없으면 SRP Batcher가 해당 셰이더를 배칭에서 제외합니다.
Inspector에서 셰이더를 선택하면 “SRP Batcher: compatible” 또는 “not compatible” 표시로 호환 여부를 확인할 수 있습니다.
둘째, URP Asset의 Inspector에서 SRP Batcher가 활성화되어 있어야 합니다. URP에서는 기본적으로 활성화 상태입니다.
GPU Instancing
지금까지 살펴본 배칭 기법들은 메쉬를 합치거나(Static/Dynamic) 상태 변경을 줄이는(SRP Batcher) 방식이었습니다.
GPU Instancing은 또 다른 접근으로, 같은 메쉬를 여러 번 그릴 때 하나의 드로우콜로 여러 인스턴스를 한꺼번에 그리는 기법입니다. 나무, 풀, 돌, 총알 등 동일한 메쉬가 수백 ~ 수천 개 반복되는 상황에 적합합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GPU Instancing (나무 메쉬 A × 100개)
Instancing 없이
──────────────────────────────────────────────
나무 A 위치(0,0,0) 드로우콜 1
나무 A 위치(5,0,0) 드로우콜 2
나무 A 위치(10,0,0) 드로우콜 3
... ...
나무 A 위치(500,0,0) 드로우콜 100
합계: 100회
GPU Instancing
──────────────────────────────────────────────
메쉬 데이터 나무 A (100개가 공유)
인스턴스 버퍼 #1 변환 행렬, 색상
#2 변환 행렬, 색상
#3 변환 행렬, 색상
...
#100 변환 행렬, 색상
합계: 1회
GPU Instancing의 핵심은 GPU가 같은 메쉬 데이터를 한 번만 읽고 인스턴스별 데이터만 다르게 적용하면서 반복 렌더링하는 것입니다.
각 인스턴스는 위치, 회전, 스케일이 다를 수 있으며, 셰이더에서 UNITY_INSTANCING_BUFFER를 선언하면 색상이나 밝기 같은 추가 속성도 인스턴스별로 다르게 지정할 수 있습니다.
동작 방식
GPU Instancing에서 CPU는 메쉬 데이터와 인스턴스 버퍼, 두 가지를 GPU에 보냅니다.
1
2
3
4
5
6
7
8
9
GPU Instancing — CPU → GPU 데이터 전달
데이터 내용 전달 횟수
──────────────── ───────────────────────── ──────────
메쉬 데이터 정점, 인덱스, UV 한 번
인스턴스 버퍼 변환 행렬, 색상 × N개 한 번
GPU는 메쉬 데이터를 한 번 읽고,
인스턴스 버퍼를 순회하면서 같은 메쉬를 N번 렌더링
앞에서 Static Batching은 나무 50그루의 메쉬를 하나로 합쳐 정점이 51,000개로 늘어났습니다.
GPU Instancing은 원본 메쉬(1,000 정점)를 하나만 GPU에 유지하고, 인스턴스 버퍼에는 변환 행렬과 색상만 추가하므로 정점 데이터가 복제되지 않습니다.
적합한 상황
GPU Instancing은 동일한 메쉬가 수백~수천 개 반복되는 상황에서 효과가 큽니다. 숲의 나무, 풀, 돌처럼 같은 에셋이 대량으로 배치되는 환경이 대표적입니다. 전략 게임에서 화면에 동시에 표시되는 수백 개의 유닛이나, 총알, 아이템 등 반복 오브젝트에도 적합합니다.
반대로, 씬에 같은 메쉬가 2~3개뿐인 상황에서는 GPU Instancing의 효과가 미미합니다. 인스턴스 버퍼를 구성하고 전달하는 오버헤드가 드로우콜 절약 효과를 상쇄할 수 있기 때문입니다.
SRP Batcher와의 관계
GPU Instancing과 SRP Batcher는 동시에 사용할 수 없습니다. SRP Batcher가 활성화되어 있으면 GPU Instancing은 자동으로 비활성화됩니다.
그 이유는 두 기법이 CBUFFER를 사용하는 방식에 있습니다.
앞서 살펴본 것처럼, SRP Batcher는 UnityPerMaterial CBUFFER에 머티리얼 속성을 지속 저장하고 드로우콜마다 오프셋만 바꿔서 참조합니다. 이 방식은 CBUFFER의 내용이 변하지 않는다는 전제 위에서 동작합니다.
GPU Instancing은 같은 CBUFFER 슬롯에 인스턴스별 데이터(변환 행렬, 인스턴스 프로퍼티 등)를 담아야 하므로, 매 인스턴스마다 CBUFFER의 내용이 바뀌어야 합니다.
“내용이 변하지 않는다”는 전제와 “매번 내용을 바꿔야 한다”는 요구가 서로 모순되므로, 두 기법은 동시에 동작할 수 없습니다.
1
2
3
4
5
6
7
8
9
SRP Batcher vs GPU Instancing: 선택
기법 적합한 상황 비고
──────────────── ───────────────────────────────── ─────────────────────────
SRP Batcher 다양한 머티리얼, 다양한 메쉬 URP 기본 활성화
GPU Instancing 같은 메쉬가 수백 개 이상 반복 SRP Batcher 비활성화 필요
기본 전략: SRP Batcher
예외: 동일 메쉬 대량 반복(숲, 풀밭) → GPU Instancing
실제 프로젝트에서는 SRP Batcher를 전체 씬의 기본 전략으로 유지합니다. 숲이나 풀밭처럼 동일 메쉬가 대량 반복되는 경우에만, 해당 머티리얼의 GPU Instancing을 선택적으로 활성화합니다. Unity의 머티리얼 Inspector에서 “Enable GPU Instancing” 체크박스를 켜면 해당 머티리얼만 SRP Batcher 대신 GPU Instancing으로 동작합니다.
배칭 기법 비교
네 가지 배칭 기법의 특성을 한눈에 비교하면 다음과 같습니다.
1
2
3
4
5
6
7
8
9
항목 Static Batching Dynamic Batching SRP Batcher GPU Instancing
────────────────── ───────────────── ───────────────── ───────────────────── ─────────────────────
배칭 방식 빌드 시 메쉬 합침 매 프레임 합침 합치지 않음 합치지 않음
머티리얼 조건 같은 머티리얼 같은 머티리얼 같은 셰이더 variant 같은 머티리얼
메쉬 제한 없음 최대 300 정점 없음 같은 메쉬
움직이는 오브젝트 불가 가능 가능 가능
메모리 증가 있음 (메쉬 복제) 없음 없음 없음
런타임 CPU 비용 없음 (빌드 시점) 있음 (매 프레임) 없음 없음
URP 기본 상태 활성화 비활성화 활성화 비활성화 (SRP와 충돌)
URP 환경에서는 SRP Batcher를 기본 전략으로 유지합니다. 머티리얼이나 메쉬의 종류에 관계없이 셰이더 variant만 일치하면 배칭되므로, 대부분의 씬에서 별도 설정 없이 SetPass Call이 줄어듭니다.
여기에 Static Batching을 추가로 적용합니다. 움직이지 않는 배경 오브젝트 중 같은 머티리얼을 공유하는 것들이 대상이며, 메쉬 복제로 인한 메모리 증가를 감수할 수 있을 때 사용합니다.
동일 메쉬가 수백 개 이상 반복되는 숲이나 풀밭 같은 상황에서는 GPU Instancing을 선택적으로 활성화합니다.
Dynamic Batching은 SRP Batcher가 더 나은 대안이므로 URP에서는 기본 비활성화 상태를 유지합니다.
모바일에서의 드로우콜 예산
앞에서 네 가지 배칭 기법의 특성을 비교했습니다. 이 기법들이 가장 중요해지는 환경이 모바일입니다.
모바일 기기의 CPU는 데스크톱에 비해 클럭 속도가 낮습니다(1~3 GHz). 드로우콜의 비용은 CPU 측에서 발생하므로, 같은 수의 드로우콜이라도 모바일에서는 프레임 시간을 더 많이 소모합니다.
모바일에서는 프레임당 허용할 드로우콜 상한을 미리 정합니다. 이를 드로우콜 예산이라 하며, 이 예산 안에서 씬을 설계합니다.
1
2
3
4
5
6
7
8
플랫폼별 드로우콜 예산 (대략적 기준)
플랫폼 드로우콜 예산 SetPass Call 예산
──────────────── ─────────────────── ───────────────────
데스크톱 수천 이상 가능 수백 이상 가능
모바일 (고급) 200 ~ 300 50 ~ 100
모바일 (중급) 100 ~ 200 30 ~ 50
모바일 (저급) 50 ~ 100 20 ~ 30
이 수치는 대략적인 가이드라인입니다. 실제 예산은 기기의 SoC(System on Chip, CPU와 GPU가 하나의 칩에 통합된 모바일 프로세서) 세대와 성능, 셰이더 복잡도, 메쉬 크기, 화면 해상도 등에 따라 달라집니다. 모바일에서는 일반적으로 100~200개의 드로우콜을 넘기지 않도록 설계하는 것이 안전합니다.
드로우콜 수보다 SetPass Call 수가 성능에 더 직접적인 영향을 줍니다.
SRP Batcher를 활성화하고, 셰이더 variant의 종류를 최소화하고, 머티리얼의 수를 줄이는 것이 모바일 최적화의 기본 방향입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
모바일 드로우콜 최적화의 우선순위
(1) SRP Batcher 활성화
→ SetPass Call 감소, 가장 기본적이고 효과적
(2) 머티리얼/셰이더 종류 최소화
→ 텍스처 아틀라스로 머티리얼 통합
→ 불필요한 셰이더 키워드 제거
(3) Static Batching 적용
→ 움직이지 않는 배경 오브젝트에 적용
→ 메모리 여유가 있을 때
(4) GPU Instancing 선택적 사용
→ 동일 메쉬가 수백 개 반복되는 경우에만
(5) 오브젝트 수 자체를 줄임
→ LOD(Level of Detail, 카메라와의 거리에 따라 메쉬의 복잡도를 자동 조절하는 기법)로 먼 오브젝트 단순화
→ 작은 오브젝트를 하나의 메쉬로 미리 합침 (Mesh Combine)
(1)부터 순서대로 적용하면 효율적입니다. SRP Batcher 활성화만으로도 대부분의 씬에서 SetPass Call이 크게 줄어듭니다. Unity 공식 벤치마크에서는 SRP Batcher 활성화 후 CPU 렌더링 시간이 최대 4배까지 단축된 사례가 있습니다. 그 이후에 머티리얼 통합, Static Batching, GPU Instancing 순으로 추가 최적화를 적용합니다.
Unity Profiler에서 확인하기
배칭 기법을 적용한 뒤에는 실제로 드로우콜과 SetPass Call이 줄었는지 측정해야 합니다. 이때 사용하는 도구가 Unity Profiler의 Rendering 모듈과 Frame Debugger입니다.
1
2
3
4
5
6
7
8
9
10
Unity Profiler → Rendering 모듈에서 확인할 수 있는 항목
항목 값 의미
────────────────── ───────── ──────────────────────────
SetPass Calls 35 GPU 상태 변경 횟수
Draw Calls 210 CPU → GPU 렌더링 명령 횟수
Total Batches 180 배칭 적용 후 실제 배치 수
Triangles 125,000 렌더링된 삼각형 수
Vertices 95,000 렌더링된 정점 수
Saved by batching 30 배칭으로 절약된 드로우콜 수
SetPass Calls는 GPU 상태 변경 횟수입니다. 이 값이 높으면 머티리얼이나 셰이더의 종류가 너무 다양하다는 신호입니다.
Draw Calls는 CPU가 GPU에 보낸 렌더링 명령의 총 횟수이고, Total Batches는 배칭이 적용된 후의 실제 배치 수입니다. 배칭이 효과적으로 동작하면 원래의 Draw Calls 수에 비해 Total Batches가 줄어듭니다.
Saved by batching은 배칭 덕분에 절약된 드로우콜 수입니다. 위 예시에서 Draw Calls가 210이고 Total Batches가 180이므로, 배칭으로 30개의 드로우콜이 절약된 것입니다.
Frame Debugger(Window → Analysis → Frame Debugger)는 프레임의 각 드로우콜을 하나씩 재생하는 도구입니다. 어떤 오브젝트가 어떤 머티리얼로 그려지는지, SRP Batcher가 적용되었는지, 배칭이 왜 깨졌는지를 확인할 수 있습니다. 예를 들어, 같은 셰이더를 사용하는 두 오브젝트가 SRP 배치로 묶이지 않았다면 “shader keyword 불일치” 같은 이유를 표시합니다. 배칭이 기대대로 작동하지 않을 때 원인을 파악하는 데 유용합니다.
드로우콜 흐름 요약
지금까지 다룬 개념들이 실제 렌더링에서 어떤 순서로 동작하는지 정리합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
프레임의 렌더링 과정
CPU ───────────────────────────────────────────────────────────
1. 씬의 오브젝트 수집
2. 가시성 판단 (카메라에 보이는 오브젝트 선별)
3. 정렬 (머티리얼/셰이더 기준)
4. 배칭 적용
SRP Batcher 같은 셰이더 variant → SetPass 최소화
Static Batching 정적 오브젝트 메쉬 합침
GPU Instancing 같은 메쉬 인스턴스 묶음
5. 드로우콜 생성 및 GPU에 제출
│
▼
GPU ───────────────────────────────────────────────────────────
1. 상태 변경 (SetPass)
2. 정점 처리
3. 래스터화
4. 프래그먼트 처리
5. 프레임 버퍼에 기록
다이어그램에서 “배칭 적용” 단계가 CPU 쪽에 위치하는 이유는, 배칭이 GPU가 아닌 CPU에서 드로우콜을 묶거나 상태 변경을 줄이는 작업이기 때문입니다. 배칭을 거친 후 GPU에 제출되는 드로우콜과 SetPass Call이 줄어들고, 그만큼 GPU 쪽의 상태 변경 부담도 함께 줄어듭니다.
마무리
- 드로우콜은 CPU가 GPU에 보내는 렌더링 명령이며, 실제 비용은 드로우콜 직전의 GPU 상태 변경(셰이더 교체, 텍스처 바인딩 등)에서 발생합니다.
- SetPass Call은 GPU 상태 변경 횟수를 나타내는 지표로, 드로우콜 수보다 성능에 더 직접적인 영향을 줍니다.
- SRP Batcher는 머티리얼 속성을 GPU의 CBUFFER에 지속 저장하여 SetPass Call을 줄이며, URP의 핵심 최적화 기법입니다.
- Static Batching은 빌드 시점에 메쉬를 합쳐 드로우콜을 줄이지만, 정점 데이터 복제로 메모리가 증가합니다.
- GPU Instancing은 동일 메쉬가 수백 개 이상 반복되는 상황에서 효과적이지만, SRP Batcher와 동시 사용이 불가합니다.
- 모바일에서는 100~200개의 드로우콜, 50개 이하의 SetPass Call을 목표로 설계합니다.
각 기법은 서로 다른 트레이드오프를 가지고 있으며, 하나의 기법만으로 모든 상황에 대응할 수는 없습니다. 실무에서는 SRP Batcher를 기본으로 유지하면서, 씬의 특성에 따라 Static Batching이나 GPU Instancing을 선택적으로 적용합니다.
드로우콜과 SetPass Call을 줄이는 것만큼, 화면에 보이지 않는 오브젝트를 GPU에 아예 제출하지 않는 것도 중요합니다. 이 글에서는 GPU에 “어떻게” 명령을 보내는지의 비용을 다뤘습니다. 다음은 “무엇을” 보내는지를 줄이는 문제입니다.
Part 3에서는 GPU에 제출할 오브젝트 수 자체를 줄이는 컬링(Culling)과, 거리에 따라 메쉬 복잡도를 낮추는 LOD 시스템을 다룹니다.
관련 글
시리즈
- Unity 렌더 파이프라인 (1) - Built-in과 URP의 구조
- Unity 렌더 파이프라인 (2) - 드로우콜과 배칭 (현재 글)
- Unity 렌더 파이프라인 (3) - 컬링과 오클루전