전체 글 목록으로

Unity에서 드로우 콜을 20,001개에서 153개로 줄여도 빨라지지 않았던 이유

“드로우 콜을 줄이면 빨라진다”는 Unity 최적화에서 가장 먼저 나오는 이야기입니다. 큐브 20,000개가 물결치는 씬에서 그것을 실제로 해 봤습니다. 드로우 콜은 20,001개에서 153개까지 줄었습니다. 그런데도 프레임 타임은 8.948 ms에서 8.709 ms까지밖에 가지 않습니다. p50 기준 0.24 ms 차이입니다. 이 정도로 작으면 직접 돌려 봐도 차이를 알 수 없습니다.

같은 그림을 큐브마다 게임 오브젝트를 만들지 않고 다시 그리면 프레임 타임은 1.678 ms가 됩니다. FPS로 보면 111.8에서 596.0입니다. 이때 드로우 콜은 60개였습니다.

153개와 60개는 20,001개에서 보면 둘 다 비슷하게 작은 숫자입니다. 그런데도 한쪽은 8.709 ms 그대로였고, 다른 한쪽은 1.678 ms가 되었습니다. 이 낙차를 만든 것은 드로우 콜의 개수가 아니었습니다.

측정한 세 가지 모드

씬은 공통입니다. 원반 모양으로 늘어놓은 큐브 20,000개에 진행하는 사인파를 걸었습니다. 매 프레임 20,000개 전부의 위치를 다시 쓰고, 카메라는 그 주위를 천천히 돕니다. 다른 것은 큐브를 그리는 방식과 큐브마다 게임 오브젝트를 두는지 여부, 이 두 가지뿐입니다. 세 모드 모두 실행 파일은 같고, 시작 인자만 바꿉니다.

  • NAIVE: 큐브 한 개마다 게임 오브젝트와 MeshRenderer를 하나씩 두고, 머티리얼의 GPU 인스턴싱은 끈다
  • INSTANCED_RENDERER: 게임 오브젝트 구성은 NAIVE와 같고, 머티리얼의 GPU 인스턴싱만 켜져 있다
  • INSTANCED_DIRECT: 큐브별 게임 오브젝트를 만들지 않고, 스크립트 하나에서 Graphics.RenderMeshInstanced로 그리는 방식

측정 환경은 Unity 6000.4.11f1, Built-in Render Pipeline(포워드 렌더링), 스크립팅 백엔드는 Mono, 그래픽스 API는 D3D12입니다. GPU는 RTX 4070 Ti, CPU는 Core i5-13500, 해상도는 1920x1080, vSync는 끄고, 그림자는 모든 모드에서 비활성화했습니다. 동적 배칭과 정적 배칭은 명시적으로 껐고, 배치 수 카운터가 0인 것을 확인했습니다. 각 모드 모두 15초 워밍업 뒤 120초를 측정했습니다. 아래 값은 따로 언급이 없으면 p50입니다.

Unity에서는 Player Settings의 Frame Timing Stats를 켜지 않으면 렌더 스레드와 GPU 시간이 0인 채로 남습니다. 이번 구분은 이 체크박스 하나에 달려 있습니다.

결과

모드 FPS 프레임 타임 프레임 타임 p95 게임 스레드 렌더 스레드 GPU 시간 메모리
NAIVE 111.8 8.948 ms 10.644 ms 8.920 ms 0.480 ms 0.909 ms 169.0 MB
INSTANCED_RENDERER 114.8 8.709 ms 12.919 ms 8.680 ms 0.519 ms 0.703 ms 176.6 MB
INSTANCED_DIRECT 596.0 1.678 ms 2.774 ms 1.668 ms 0.091 ms 0.232 ms 56.6 MB

그리기 카운터의 내역은 이렇습니다.

모드 표준 드로우 콜 인스턴스화 드로우 콜 인스턴스 배치 SetPass Calls 삼각형
NAIVE 20,001 0 0 153 240,002
INSTANCED_RENDERER 1 152 149 153 240,002
INSTANCED_DIRECT 20 40 59 2 240,002

이 글에서 “드로우 콜”이라고 쓴 것은 표준과 인스턴스화의 합계입니다. NAIVE가 20,001개, INSTANCED_RENDERER가 1 + 152로 153개, INSTANCED_DIRECT가 20 + 40으로 60개입니다. 같은 153이라는 숫자가 SetPass Calls 열에도 나와 있지만, 이것은 다른 지표입니다. NAIVE의 20,001개 가운데 20,000개가 큐브 몫이고, 남은 1개는 큐브 이외의 그리기입니다.

세 모드의 화면을 함께 실어 둡니다.

물결치는 원반 모양으로 늘어선 큐브 20,000개. 왼쪽 위 오버레이에 NAIVE, FPS 125, FRAME TIME 8.01 ms, DRAW CALLS 20,001, OBJECTS 20,000이 표시되어 있다
NAIVE. 큐브 한 개마다 게임 오브젝트를 하나씩 두고, 드로우 콜은 20,001개.
같은 큐브 원반. 왼쪽 위 오버레이에 GPU INSTANCING ONLY, FPS 118, FRAME TIME 8.44 ms, DRAW CALLS 153, OBJECTS 20,000이 표시되어 있다
INSTANCED_RENDERER. GPU 인스턴싱으로 드로우 콜은 153개로 줄지만, FPS는 NAIVE와 거의 같습니다.
같은 큐브 원반. 왼쪽 위 오버레이에 RenderMeshInstanced, FPS 654, FRAME TIME 1.53 ms, DRAW CALLS 60, OBJECTS 20,000이 표시되고, 아래쪽 띠에 No GameObjects: 60 draw calls - 5x FPS라고 적혀 있다
INSTANCED_DIRECT. 아래쪽 띠는 "게임 오브젝트 없이 드로우 콜 60개, FPS는 5배"로 읽히지만, 이 모드는 게임 오브젝트를 없애는 동시에 그리기 경로도 바꾸고 있습니다. FPS 차이는 게임 오브젝트를 없앤 것만의 결과가 아닙니다.

오버레이의 숫자는 캡처한 순간의 한 프레임 값입니다. 표에 실은 p50과는 일치하지 않습니다.

INSTANCED_RENDERER에 대해서는 불리한 숫자도 함께 적어 둡니다. p50에서는 111.8에서 114.8로 올라간 FPS도, 평균으로 보면 111.2에서 109.0으로 떨어집니다. 프레임 타임 p95는 10.644 ms에서 12.919 ms로 나빠졌습니다. 메모리도 169.0 MB에서 176.6 MB로 늘었습니다. 드로우 콜을 20,001개에서 153개로 줄여서 얻은 것은 p50 기준 0.24 ms였습니다.

병목은 렌더 스레드가 아니라 게임 스레드였다

답은 NAIVE의 내역에 처음부터 나와 있습니다. 프레임 타임은 8.948 ms였습니다. 이에 대해 동시에 돌아가는 세 가지 내역은 게임 스레드가 8.920 ms, 렌더 스레드가 0.480 ms, GPU 시간이 0.909 ms입니다. 여기서 말하는 게임 스레드는 Unity의 Profiler에서 메인 스레드로 보이는 것입니다. 드로우 콜을 쌓는 일은 렌더 스레드의 몫입니다. 설령 렌더 스레드를 0 ms로 만들 수 있다 해도, 프레임 타임은 게임 스레드의 8.920 ms로 정해집니다.

이 내역을 읽는 법은 단순합니다. 프레임 타임에 가장 가까운 값이 그 프레임을 정하고 있는 자리입니다.

  • 게임 스레드가 프레임 타임과 거의 같다: CPU의 로직 쪽이 병목
  • 렌더 스레드가 프레임 타임에 육박한다: CPU의 그리기 쪽이 병목
  • GPU 시간이 프레임 타임에 육박한다: GPU가 병목

드로우 콜 감소가 가장 잘 듣는 것은 두 번째 경우입니다. 이 씬은 첫 번째였습니다. 게임 스레드의 8.920 ms와 프레임 타임의 8.948 ms가 거의 일치합니다.

INSTANCED_RENDERER는 그 해석을 검증하려고 측정한 모드입니다. 표준 드로우 콜은 20,001개에서 1개로 떨어졌고, 인스턴스화 드로우 콜 152개와 배치 149개로 바뀌었습니다. 렌더 스레드는 0.480 ms에서 0.519 ms로, 오히려 조금 늘었습니다. 게임 오브젝트를 가진 두 모드에서 렌더 스레드는 0.5 ms 안팎에서 움직이지 않았습니다. D3D12의 이 구성에서 드로우 콜 발행은 프레임 타임에 영향을 줄 만한 부하가 아니었습니다. GPU 시간 쪽은 0.909 ms에서 0.703 ms로 내려갔습니다. 효과는 있지만 GPU는 애초에 병목이 아니어서, 프레임 타임에는 나타나지 않습니다.

효과를 낸 것은 게임 오브젝트 20,000개를 만들지 않은 것

INSTANCED_DIRECT에서는 드로우 콜을 내는 방식뿐 아니라 씬의 구성 자체도 바꾸고 있습니다. 큐브마다 게임 오브젝트, Transform, MeshRenderer를 만들지 않고, 행렬 배열을 매 프레임 다시 써서 스크립트 하나로 그립니다. Graphics.RenderMeshInstanced는 한 번의 호출로 최대 1,023 인스턴스까지만 그릴 수 있습니다. 그래서 20,000개를 1,023개씩 나눠 넘깁니다. 호출은 20번이 되지만, 카운터상의 내역은 호출 횟수와 일대일로 대응하지 않습니다.

private void RenderInstanced()
{
    for (int start = 0; start < _objectCount; start += MaxInstancesPerCall)
    {
        int chunk = Mathf.Min(MaxInstancesPerCall, _objectCount - start);
        Graphics.RenderMeshInstanced(_renderParams, _cubeMesh, 0, _matrices, chunk, start);
    }
}

게임 스레드는 NAIVE에서 8.920 ms, INSTANCED_RENDERER에서도 8.680 ms였습니다. 그것이 1.668 ms가 되었습니다. 사라진 것은 게임 오브젝트 20,000개가 매 프레임 요구하던 CPU 시간입니다. 컴포넌트 업데이트, 게임 오브젝트 단위의 컬링, Transform 동기화가 그 내용입니다.

다만 이 모드는 두 가지를 동시에 바꾸고 있습니다. 게임 스레드가 8.680 ms에서 1.668 ms로 떨어진 이유를 우리는 주로 게임 오브젝트 쪽의 기여로 읽고 있습니다. 게임 오브젝트를 남긴 채 그리기 경로만 바꾼 INSTANCED_RENDERER에서 게임 스레드가 움직이지 않았기 때문입니다. 그렇다고 해서 이 세 모드만으로 두 요인을 완전히 분리할 수 있는 것은 아닙니다. SetPass Calls가 153개에서 2개로, GPU 시간이 NAIVE의 0.909 ms에서 0.232 ms로 내려간 것은 그리기 경로 쪽의 효과이고, 게임 오브젝트의 유무와는 별개의 이야기입니다.

메모리가 줄어든 것도 마찬가지로 게임 오브젝트를 없앤 결과입니다. 169.0 MB에서 56.6 MB로, 112 MB 줄었습니다. 게임 오브젝트 한 개당 약 5.6 KB이지만, 이 값은 차이를 개수로 나눈 것에 지나지 않습니다. SDK가 보내는 것은 프로세스 전체의 할당된 메모리(Profiler.GetTotalAllocatedMemoryLong)입니다. INSTANCED_DIRECT 쪽은 20,000개 분량의 Matrix4x4로 약 1.28 MB를 새로 할당합니다. 그래도 이 112 MB는 메시나 정점 같은 그리기 데이터가 줄어든 몫이 아닙니다. 삼각형의 수는 세 모드 모두 240,002개 그대로였습니다.

내 씬이 어느 경우인지 가려내기

볼 것은 프레임 타임과 세 가지 내역뿐입니다. 게임 스레드, 렌더 스레드, GPU 시간 가운데 어느 것이 프레임 타임에 가장 가까운지 확인합니다. Framedash의 Unity SDK는 이 네 가지를 perf_heartbeat로 자동 전송합니다(frame_time_ms, game_thread_ms, render_thread_ms, gpu_time_ms).

이번 측정에서 Framedash가 맡은 것은 그 수집과 비교입니다. 모드마다 BeginAutomatedSession의 build_id를 바꿔 세 번의 실행을 구분하고, CLI로 차이를 뽑았습니다.

framedash perf-diff --baseline <NAIVE의 build_id> --candidate <INSTANCED_DIRECT의 build_id> \
  --threshold 5 --fail-on-regression

돌아온 차이는 이렇습니다.

  • 프레임 타임 p50: 9.011 ms에서 1.701 ms로(-81.12%)
  • GPU 시간: 0.9257 ms에서 0.2406 ms로(-74.00%)
  • 메모리: 175.7 MB에서 59.4 MB로(-66.20%)
  • 샘플 수는 각각 133개, 판정은 “No performance regression beyond 5%”

위 표의 p50과 조금 어긋나는 것은 집계하는 샘플이 다르기 때문입니다. 서버 쪽은 워밍업 뒤의 자동 세션 120초 동안 1초마다 보낸 이벤트와, SDK가 10초마다 보내는 perf_heartbeat를 합쳐 집계합니다. 이것으로 각 모드 133 샘플입니다. 한편 표의 p50은 프레임마다 취한 값입니다. 서버 쪽 출력을 그대로 실었기 때문에 자릿수도 표와 다릅니다. CI에서 같은 명령을 실행하면, 릴리스 전에 빌드 간의 차이를 이 입도로 확인할 수 있습니다.

이번 이야기가 어디까지인지

여기까지의 값은 하나의 구성에서 측정한 한 가지 워크로드의 것입니다. D3D12, Built-in Render Pipeline, Mono, 데스크톱 GPU라는 전제가 달라지면 내역의 비율도 달라집니다. 드라이버 쪽 오버헤드가 큰 모바일용 그래픽스 API, SRP Batcher가 작동하는 URP나 HDRP, IL2CPP 빌드에서 렌더 스레드의 비율이 이번과 같으리라는 보장은 없습니다. 그림자를 켜면 패스가 늘고, 드로우 콜 한 개의 무게도 달라집니다.

측정을 만든 방식에도 유의할 점이 있습니다. 그리기 카운터 값은 Development 빌드에서, 시간 값은 Release 빌드에서 가져왔습니다. 프로파일러의 오버헤드를 시간 쪽에 섞지 않기 위해서입니다. 카운터는 프레임마다 편차가 커서 최솟값과 최댓값은 믿을 것이 못 됩니다. 카운터에 대해 p50만 실은 것은 그 때문입니다. 20,000개 전부의 위치를 매 프레임 다시 쓰는 워크로드라는 점도, 자기 씬에 대입해 보기 전에 확인해 주세요.

측정한 다음에 깎는다

드로우 콜 감소가 가장 잘 듣는 것은 렌더 스레드가 프레임 타임에 육박할 때입니다. 측정해 두면 드로우 콜을 깎는 작업이 이번처럼 0.24 ms짜리 일인지를 손대기 전에 판단할 수 있습니다. 게임 스레드가 프레임 타임과 거의 같다면, 다음으로 의심할 것은 게임 오브젝트의 개수입니다.

순서는 이렇습니다. 먼저 Frame Timing Stats를 켭니다. 다음으로 프레임 타임과 세 가지 내역을 나란히 놓고 어느 것이 가장 가까운지 봅니다. 깎는 것은 그다음입니다.