8 minute read

렌더링 최적화 이야기를 하면 거의 반드시 “배칭”이 나옵니다. 그런데 정작 “배칭이 뭐냐”고 물으면 드로우콜을 줄이는 것이라는 대답이 돌아오는 경우가 많습니다. 반은 맞고 반은 틀립니다. 요즘 API에서는 드로우콜 자체보다 드로우콜에 딸려오는 상태 변경이 훨씬 비싸고, 배칭 기법들은 대부분 드로우콜 개수가 아니라 그 상태 변경을 겨냥합니다.

이 글에서는 배칭이 정확히 무엇을 아끼는 기술인지, 어떤 종류가 있는지, 각각이 어떤 원리로 동작하는지를 정리합니다. 특정 엔진 사용법보다 왜 그렇게 동작하는가에 무게를 둡니다.

1. 배칭이 아끼는 비용의 정체

한 개의 오브젝트를 그리라고 엔진이 시킬 때 실제로 일어나는 일은 대략 이렇습니다.

파이프라인(셰이더) 바인딩
  → 디스크립터/상수 버퍼 바인딩 (머티리얼 파라미터, 오브젝트 트랜스폼)
  → 텍스처 바인딩
  → 정점/인덱스 버퍼 바인딩
  → 드로우 명령 기록

여기서 마지막 줄, 그러니까 우리가 “드로우콜”이라고 부르는 명령 하나를 커맨드 버퍼에 적는 비용은 사실 가장 쌉니다. 진짜 비용은 그 위의 네 줄, 즉 상태(state)를 바꾸는 데 있습니다.

  • CPU 측 — 상태 조합이 바뀔 때마다 드라이버는 유효성 검증을 하고, 필요하면 하드웨어 명령을 다시 컴파일하듯 뱉어냅니다. DX11/GL 시절 “드로우콜이 비싸다”는 말은 정확히는 드로우콜 하나당 드라이버가 하는 이 뒤처리가 비싸다는 뜻이었습니다.
  • GPU 측 — 파이프라인 상태가 바뀌면 인플라이트 작업이 끊깁니다. 삼각형 20개짜리 드로우 1000개는 삼각형 20000개짜리 드로우 1개보다 훨씬 느린데, 웨이브/워프를 꽉 채우지 못한 채로 매번 셋업 비용을 다시 내기 때문입니다. 타일 기반 모바일 GPU에서는 이 손해가 더 큽니다.

그래서 배칭의 정의는 이렇게 잡는 편이 정확합니다.

배칭이란 “같은 상태로 그릴 수 있는 것들”을 모아 상태 변경 횟수와 드로우 명령 수를 줄이는 모든 기법이다.

핵심어는 같은 상태입니다. 배칭의 종류란 결국 “어떤 수단으로 서로 다른 오브젝트들을 같은 상태로 만들 것인가” 의 차이입니다.

DX12/Vulkan으로 넘어오면서 드라이버 검증 비용은 크게 줄었습니다(PSO를 미리 만들어 두니까요). 그 결과 드로우콜 개수 자체의 중요도는 내려갔고, 대신 “오브젝트마다 상수 버퍼를 채우고 바인딩하는 CPU 작업”과 “GPU 점유율”이 남은 병목이 되었습니다. 뒤에 나오는 SRP Batcher나 GPU 드리븐 렌더링은 정확히 이 남은 병목을 겨냥한 기법들입니다.

2. 배칭이 성립하는 조건

무엇을 하나로 묶으려면 다음이 전부 같아야 합니다.

조건 깨지는 대표 사례
셰이더/파이프라인(변형 포함) 셰이더 키워드 하나 차이, 다른 셰이더 변형
머티리얼 파라미터 컬러만 다른 머티리얼 인스턴스
텍스처 바인딩 같은 셰이더인데 텍스처가 다름
정점 레이아웃 UV2가 있는 메시와 없는 메시
렌더 스테이트 블렌드/뎁스/스텐실/컬링 모드 차이
그리는 순서의 자유 알파 블렌딩(정렬 필수), UI의 겹침 순서

마지막 줄이 자주 간과됩니다. 불투명 물체는 순서를 마음대로 바꿔도 되지만(뎁스 테스트가 정리해 줍니다) 반투명은 뒤에서 앞으로 순서를 지켜야 합니다. 그래서 배칭의 여지는 불투명 패스에서 훨씬 넓고, 반투명·UI는 “정렬 순서를 유지하면서 묶을 수 있는 구간만” 묶게 됩니다.

3. 배칭의 종류

크게 네 갈래로 나뉩니다. 정점을 합치거나(merge), 드로우를 늘리거나(instance), 상태 설정 비용을 없애거나(persistent state), 아예 GPU에게 드로우 목록을 만들게 하거나(GPU-driven).

3-1. 정적 배칭 (Static Batching)

움직이지 않는 오브젝트들의 정점을 미리 월드 공간으로 변환해서 하나의 큰 버텍스/인덱스 버퍼에 구워두는 방식입니다. 유니티의 Static Batching, UE의 머지 액터(Merge Actors)가 여기에 해당합니다.

원리는 단순합니다. 오브젝트마다 다른 것이 트랜스폼뿐이라면, 트랜스폼을 정점에 미리 적용해 버리면 차이가 사라집니다.

[Mesh A] world = M_a * v_a   ─┐
[Mesh B] world = M_b * v_b   ─┼→ 하나의 버퍼에 월드 좌표로 저장
[Mesh C] world = M_c * v_c   ─┘   드로우 시 모델 행렬은 항등행렬

버퍼를 다시 바인딩할 필요가 없고, 컬링을 통과한 것들끼리는 인덱스 범위를 이어 붙여 한 번에 그릴 수 있습니다.

  • 장점 — 런타임 비용이 사실상 0. 상태 설정과 버퍼 바인딩이 통째로 사라집니다.
  • 단점 — 메모리. 같은 메시를 100번 배치했다면 정점이 100벌 복사됩니다. 그리고 컬링 성능이 나빠집니다. 한 덩어리로 묶인 이상 그 안의 일부만 화면 밖이라고 골라내기가 어려워집니다.
  • 쓸 곳 — 공간적으로 가까운, 정점 수가 적은, 절대 안 움직이는 소품들. 맵 반대편 오브젝트끼리 묶으면 컬링만 망가지고 이득이 없습니다.

3-2. 동적 배칭 (Dynamic Batching)

움직이는 오브젝트를 매 프레임 CPU가 정점을 변환해서 임시 버퍼에 합쳐 그립니다.

이건 거래입니다. 드로우콜 비용을 아끼는 대신 CPU의 정점 변환 비용을 냅니다. 그래서 정점 수 제한이 붙습니다. 유니티 기준으로 정점 300개, 정점 속성 900개 이하만 대상이 되는데, 이 선을 넘으면 “정점 변환하느라 쓴 CPU 시간 > 아낀 드로우콜 시간”이 되기 때문입니다.

현대 엔진에서는 거의 죽은 기법입니다. DX12/Vulkan에서 드로우콜이 싸진 반면 CPU 정점 변환 비용은 그대로라 손익분기가 무너졌고, 같은 문제를 인스턴싱이 훨씬 잘 풀기 때문입니다. 유니티도 SRP에서는 기본적으로 꺼두기를 권합니다.

3-3. GPU 인스턴싱 (GPU Instancing)

같은 메시를 여러 번 그릴 때 쓰는 방법입니다. 정점 데이터는 한 벌만 두고, 드로우 명령에 “이걸 N번 그려라”라고 개수만 넘깁니다.

// 정점 버퍼 1개, 드로우 1개, 인스턴스 1000개
vkCmdDrawIndexed(cmd, indexCount, /*instanceCount=*/1000, 0, 0, 0);
// D3D12: DrawIndexedInstanced(indexCount, 1000, 0, 0, 0);

셰이더는 자기가 몇 번째 인스턴스인지를 시스템 값으로 받아, 인스턴스별 데이터를 버퍼에서 뽑아 씁니다.

layout(std430, binding = 0) readonly buffer Instances 
{
    mat4 model[];
} instances;

void main()
{
    mat4 model = instances.model[gl_InstanceIndex];   // HLSL: SV_InstanceID
    gl_Position = viewProj * model * vec4(inPos, 1.0);
}

여기서 중요한 발상 전환이 하나 있습니다.

오브젝트마다 다른 값을 “바인딩으로 전달”하는 대신 “인덱스로 조회”하게 만들면, 바인딩이 같아지고 곧 배칭이 성립한다.

트랜스폼뿐 아니라 컬러, 텍스처 타일링, 랜덤 시드 같은 것도 전부 인스턴스 버퍼에 넣으면 됩니다. 유니티에서 머티리얼 프로퍼티가 다르면 배치가 깨지지만 MaterialPropertyBlock + 인스턴싱을 쓰면 유지되는 이유가 이것입니다.

  • 장점 — 메모리 효율(정점 1벌), 대량 반복에 최적. 풀·나무·군중·탄막.
  • 단점 — 메시가 같아야 합니다. 그리고 인스턴스 단위 컬링이 기본으로 되지 않아서, 보통 CPU나 컴퓨트 셰이더로 가시 인스턴스만 골라 버퍼를 다시 채우는 작업을 함께 합니다.

정적 배칭과의 차이를 한 줄로 정리하면 이렇습니다. 정적 배칭은 서로 다른 메시를 하나로 합치는 것(merge), 인스턴싱은 같은 메시를 여러 번 그리는 것(instance). 문제의 성격이 다릅니다.

3-4. 상태 설정을 상주시키기 (SRP Batcher 계열)

이 갈래는 드로우콜을 전혀 줄이지 않습니다. 드로우콜은 1000개 그대로인데도 빨라집니다.

관찰은 이렇습니다. 오브젝트마다 반복되는 CPU 작업의 대부분은 “머티리얼 상수 버퍼를 채워서 올리는 일”입니다. 그런데 머티리얼 파라미터는 대부분의 프레임에서 바뀌지 않습니다.

그래서 유니티 SRP Batcher는 머티리얼별 CBUFFER를 GPU 메모리에 상주시켜 두고, 프레임마다 다시 올리지 않습니다. 오브젝트별 데이터(트랜스폼 등)는 거대한 CBUFFER 하나에 몰아넣고 오프셋만 바꿔 가리킵니다. 드로우 사이에 남는 CPU 작업은 사실상 오프셋 갱신뿐입니다.

성립 조건이 앞의 기법들과 다르다는 점이 재미있습니다. 머티리얼이 달라도 됩니다. 대신 셰이더 변형(variant)이 같아야 합니다. 셰이더가 같으면 파이프라인 레이아웃이 같고, 그러면 CBUFFER 슬롯 구조가 같아서 포인터만 갈아끼울 수 있기 때문입니다.

같은 발상이 UE에도 있습니다. UE4.22 이후의 Mesh Draw Command 캐싱은 정적 메시의 드로우 명령을 미리 만들어 캐시해 두고 매 프레임 재사용하며, 상태가 동일한 MDC들은 자동으로 인스턴싱으로 합쳐집니다(auto-instancing). “매 프레임 다시 계산하지 말고 캐시해 두자”라는 점에서 뿌리가 같습니다.

3-5. 인다이렉트 드로우와 GPU 드리븐 렌더링

지금까지는 CPU가 “무엇을 몇 개 그릴지” 정했습니다. 인다이렉트 드로우는 그 인자들을 GPU 버퍼에 두고 CPU는 “이 버퍼를 읽어서 그려라”라고만 시킵니다.

struct VkDrawIndexedIndirectCommand {
    uint32_t indexCount, instanceCount, firstIndex;
    int32_t  vertexOffset;
    uint32_t firstInstance;
};

// 드로우 인자를 GPU 버퍼에서 읽고, 드로우 개수까지 GPU가 정한다
vkCmdDrawIndexedIndirectCount(cmd, drawBuffer, 0, countBuffer, 0, maxDraws, stride);

instanceCount를 컴퓨트 셰이더가 0으로 쓰면 그 드로우는 사라집니다. 즉 컬링을 GPU가 합니다. CPU는 매 프레임 무엇이 보이는지 알 필요조차 없습니다.

여기에 바인드리스(VK_EXT_descriptor_indexing)를 얹으면 마지막 제약인 텍스처까지 풀립니다. 텍스처를 슬롯에 바인딩하는 대신 거대한 배열로 두고 인덱스로 찝는 방식입니다.

// 드로우/인스턴스마다 다른 머티리얼과 텍스처를 인덱스로 조회
uint matIdx = drawData[gl_DrawID].materialIndex;
vec4 albedo = texture(textures[nonuniformEXT(materials[matIdx].albedoTex)], uv);

여기까지 오면 서로 다른 메시, 서로 다른 머티리얼, 서로 다른 텍스처를 드로우 한 번(정확히는 멀티드로우 한 번)으로 그릴 수 있습니다. UE5의 Nanite는 이 방향의 극단으로, 메시를 클러스터로 쪼개 GPU가 컬링·LOD 선택·래스터화까지 주도합니다. 이쯤 되면 “배칭”이라기보다 씬 전체가 하나의 배치입니다.

  • 장점 — CPU가 드로우 목록 관리에서 완전히 해방. 오브젝트 수에 CPU 비용이 거의 비례하지 않습니다.
  • 단점 — 구현 복잡도가 한 단계 높고, 디버깅이 어렵습니다(RenderDoc에서 드로우 하나만 보이니까요). 그리고 GPU 컬링 결과를 CPU가 모르기 때문에 오클루전 컬링에 보통 이전 프레임 뎁스를 쓰는 등의 지연 기법이 필요합니다.

3-6. 배칭 조건을 인위적으로 맞추기

기법이라기보다 에셋 측 대응이지만 실무 효과는 제일 큽니다. 앞의 표에 있던 “같아야 하는 것들”을 억지로 같게 만드는 작업입니다.

  • 텍스처 아틀라스 / 텍스처 배열 — 여러 텍스처를 한 장으로 합치거나 배열 레이어로 묶어 텍스처 바인딩 차이를 제거합니다. 2D·UI 배칭의 근간입니다.
  • 머티리얼 통합 — 컬러만 다른 머티리얼 10종을 하나로 합치고, 차이는 인스턴스 데이터로 넘깁니다.
  • 셰이더 변형 정리 — 키워드로 갈라진 변형을 줄이면 SRP Batcher 배치가 길게 이어집니다.
  • 정점 포맷 통일 — 어떤 메시에만 UV2가 있으면 그 메시는 영원히 따로 그려집니다.

종류 정리

기법 무엇을 줄이나 성립 조건 대가
정적 배칭 버퍼 바인딩 + 드로우 안 움직임, 같은 머티리얼 메모리, 컬링 입도
동적 배칭 드로우 저정점, 같은 머티리얼 CPU 정점 변환
GPU 인스턴싱 드로우 같은 메시 + 같은 머티리얼 인스턴스 컬링 직접 구현
SRP Batcher / MDC 캐싱 드로우당 CPU 셋업 같은 셰이더 변형 셰이더 작성 규약
인다이렉트 + 바인드리스 CPU 개입 전부 확장 지원, 데이터 재설계 구현·디버깅 복잡도
아틀라스/머티리얼 통합 위 기법들의 성립 조건 — 에셋 파이프라인 작업

4. 관통하는 원리 하나

종류가 여섯 가지지만 전부 같은 한 가지 변환을 하고 있습니다.

“차이를 상태 변경으로 표현하던 것”을 “차이를 데이터 인덱싱으로 표현하는 것”으로 바꾼다.

  • 트랜스폼이 다르다 → 상수 버퍼를 매번 바꾼다 → 인스턴스 버퍼에서 gl_InstanceIndex로 조회한다
  • 머티리얼이 다르다 → 디스크립터를 매번 바꾼다 → 머티리얼 배열에서 materialIndex로 조회한다
  • 텍스처가 다르다 → 텍스처를 매번 바인딩한다 → 바인드리스 배열에서 인덱스로 조회한다
  • 메시가 다르다 → 버퍼를 매번 바인딩한다 → 통합 버퍼에 오프셋으로 접근한다

상태 변경은 파이프라인을 끊지만, 데이터 인덱싱은 그냥 메모리 읽기 한 번입니다. GPU가 압도적으로 잘하는 일이죠. 배칭 기법의 역사는 “바인딩을 인덱싱으로 강등시켜 온 역사” 라고 봐도 무리가 없습니다.

그리고 그 대가는 대체로 일정합니다.

  • 메모리 — 데이터를 미리 합치거나 복사해 둡니다.
  • 입도(granularity) — 크게 묶을수록 컬링과 LOD를 세밀하게 못 합니다.
  • 유연성 — 런타임에 머티리얼을 바꾸거나 오브젝트를 추가하기가 까다로워집니다.

배칭 전략을 고른다는 건 결국 이 세 가지를 얼마나 내줄 것인가를 고르는 일입니다.

5. 실무에서 배치가 깨지는 흔한 지점

계측 없이 짐작하면 대부분 틀립니다. 그래도 자주 나오는 범인은 정해져 있습니다.

  • 머티리얼 인스턴스 남발 — 컬러 하나 바꾸려고 만든 인스턴스가 배치를 쪼갭니다. MaterialPropertyBlock + 인스턴싱으로 돌리세요.
  • 음의 스케일 — 스케일에 음수가 들어가면 삼각형 와인딩이 뒤집혀 컬링 상태가 달라지고, 배치가 갈립니다.
  • 라이트맵/라이트 프로브 차이 — 라이트맵 인덱스나 UV 스케일이 다르면 다른 배치가 됩니다.
  • UI의 겹침 순서 — 아틀라스 A와 B의 요소가 화면에서 번갈아 겹치면 A→B→A→B로 배치가 강제로 쪼개집니다. UI 최적화는 “겹치지 않게 레이아웃하기”가 절반입니다.
  • 반투명 정렬 — 정렬이 강제하는 순서 때문에 묶을 수 있는 구간이 짧아집니다.
  • 그림자 패스 — 메인 패스는 잘 묶였는데 그림자 패스에서 안 묶이는 경우가 흔합니다. 패스마다 상태 조합이 다릅니다.

확인은 도구로 합니다. 유니티는 Frame Debugger가 배치가 깨진 이유를 문자열로 알려주고, UE는 stat rhi·stat scenerendering과 r.MeshDrawCommands.* 계열 CVar로 봅니다. 저수준에서는 RenderDoc / PIX로 드로우 목록과 그 사이의 상태 변경을 직접 세면 됩니다.

마치며

배칭을 “드로우콜 줄이기”로만 이해하면 SRP Batcher가 왜 드로우콜을 그대로 두고도 빨라지는지, 왜 요즘 엔진들이 인스턴싱을 넘어 바인드리스와 인다이렉트로 가는지 설명이 안 됩니다.

정확한 그림은 이쪽입니다.

배칭은 상태 변경을 줄이는 기술이고, 상태 변경을 줄이는 보편적인 수단은 “차이를 바인딩이 아니라 인덱스로 표현하는 것”이다.