컴퓨터 렌더링 최적화에 관한 정보를 찾으면, '드로우 콜'이라는 용어를 시도 때도 없이 만나게 될 것이다.

드로우 콜이라는 것은 CPU 병목의 주 원인으로 꼽히며, 게임 최적화에서 빼놓을 수 없는 존재이다.

 

드로우 콜에 대해 알아보자.

 

드로우 콜

먼저, 드로우 콜이라는 것은 간단하게 말하자면, CPU가 GPU에게 렌더링을 명령하는 것이다.

 

DirectX, Open GL, Vulkan 같은 그래픽스 API를 사용해서 게임을 만들게 되면, 렌더링을 할 때 그래픽카드의 도움을 받아서 연산을 진행하게 된다. DirectX의 경우 그래픽카드의 연산을 활용하기 위해, DeviceContext에 쉐이더도 세팅하고 렌더타겟도 세팅하고 뷰포트토 세팅하고 많은 세팅을 하게 된다. 그렇게 그래픽카드에 여러 명령을 보내서 렌더링을 실행하게 된다. 

 

드로우 콜의 과정을 알아보자.

 

먼저, 우리는 API에서 제공해주는 기능을 토대로 버퍼와 쉐이더를 생성하게 된다. 그 과정에서, GPU의 메모리에 해당 정보가 저장되게 된다. 버텍스 버퍼, 인덱스 버퍼, 픽셀 쉐이더, 뎁스 스텐실 등의 모든 정보가 GPU메모리 상에 저장되는 것이다.

 

GPU는 이 정보들과 RenderState를 기준으로 렌더링을 진행하게 된다. 

 

RenderState란?

: 어떤 대상을 어떻게 그릴지에 대한 정보를 저장하고 있는 테이블.

 

예를 들어, A라는 캐릭터를 툰 쉐이더를 이용해 그리고자 한다고 해보자. 그렇다면, A의 버텍스 버퍼, 인덱스 버퍼, 사용할 텍스쳐, 사용할 버텍스 쉐이더, 픽셀 쉐이더, 알파블렌딩, 뎁스 스텐실 등등 수많은 정보가 필요하다.

 

이미 모든 정보는 GPU의 메모리에 저장되어 있지만, 지금 어떤 것을 이용해 그릴 지는 알 수가 없다.

그 것에 대한 정보를 저장하고 있는 것이 RenderState이다.

 

위의 그림처럼, 현재 렌더링을 실행할 때 어떤 것을 사용하여 렌더링을 할 지에 대한 정보를 담고 있는 것이 Render State이며, Render State의 각 값은 GPU 메모리의 주소값으로 보유하고 있게 된다.

 

그렇다면, Render State에서 현재 어떤 것을 가지고 렌더링을 실행할 것인가는 어떻게 정해지는 것일까?
CPU에서 명령을 하게 된다.

 

위처럼, 렌더링를 하기 전에 우리는 버텍스 버퍼를 세팅하고, 버텍스 쉐이더를 세팅하고, 픽셀 쉐이더를 세팅하는 등 현재 렌더링에서 사용되어야 할 머티리얼을 세팅해준다. 

 

이러한 함수들이 그래픽카드의 Render State를 바꾸라는 명령인 것이다.

이렇게 Render State를 의도한 대로 모두 변경하고 나면, Direct X에선 Draw함수를 실행하게 된다. (Draw, DrawAuto, DrawIndexed..)

 

해당 함수는 렌더링 세팅이 완료 되었으니, 이제 그림을 그려라! 라는 명령인 것이다.

이 명령을 DP Call 이라고 한다. (Draw Primitive Call)

 

DP Call 명령은 즉각적으로 GPU에서 처리하는 것은 아니고, CPU의 Command Buffer에 명령어를 저장해놓으면 GPU에선 명령 처리가 가능할 때, 가장 앞의 명령어를 꺼내서 실행하는 방식이다.

(GPU가 기존 작업을 끝낼 때까지 CPU가 대기할 필요도 없고, GPU도 명령받은 작업들을 효율적으로 처리할 수 있다.)

 

이처럼, Render State 변경 명렁을 내리고 DP Call을 하는 것을 통틀어 Draw Call이라고 부르는 것이다.

 

드로우 콜은 기본적으로 CPU에서 GPU로 보내는 명령이기 때문에, 명령을 GPU가 알아듣도록 변환할 필요가 있다. 이 과정에서 아주 많은 오버헤드가 발생한다고 한다.

 

드로우 콜의 오버헤드를 줄이는 법

많은 사람들이 착각하는 것중 하나가 한 메쉬의 버텍스 개수가 적어지면 드로우 콜의 오버헤드가 완화될 것이라고 기대하는 것이다. 앞에서 말했듯이 버텍스 버퍼는 이미 GPU 메모리에 저장되어 있고, 포인터를 이용해서 Render State를 변경하게 된다.

 

즉, CPU에서 GPU로 데이터를 보내는 과정은 버텍스의 개수와는 큰 관련이 없고, GPU에서 렌더링 연산을 수행하는 것과 연관이 있는 것이다. 버텍스의 개수는 CPU의 연산 부담을 늘리는 것이 아니라 GPU의 연산 부담을 늘리는 것이다

 

.드로우 콜의 오버헤드를 줄이기 위해선, 텍스쳐 퀄리티를 낮춘다거나 버텍스의 개수를 낮추는 등의 작업이 필요한 것이 아니라 드로우 콜 자체의 횟수를 줄이는 것이 중요하다.

 

드로우 콜은 무언가를 그려야 할 때마다 호출하게 된다. 예를 들어, 메쉬 3개로 이루어진 캐릭터는 3번의 드로우콜을 호출해야 하며, 반대로 메쉬가 1개인 캐릭터라도 외곽선 등의 추가적인 효과를 적용하기 위해 2번 이상 드로우 콜을 호출할 수도 있다.

 

즉, 메쉬의 개수 혹은 머티리얼의 개수에 따라 드로우 콜의 호출 빈도가 결정되는 것이다.

그렇다면, 무작정 매쉬의 수를 줄이거나 머티리얼의 수를 줄여버리면 될까?

물론 아니다. 그래픽 퀄리티를 조금이라도 올리기 위해 하드웨어 성능을 극한까지 사용하는 사람들도 있는 와중에, 매쉬와 머티리얼을 줄여서 퀄리티를 포기한다는 것은 올바른 해결법이 아니다.

 

그렇다면, 여러 개의 메쉬를 한 번의 드로우 콜로 해결할 수 있다면?
여러 번 그려야 하는 것을 한 번의 드로우 콜로 해결하고자 하는 것이 배칭이다.

 

배칭

하나의 캐릭터 모델을 눈 메쉬, 머리 메쉬, 상체 메쉬, 하의 메쉬로 쪼개놓았다고 해보자.

 

그렇다면, 4개의 메쉬는 다른 머티리얼을 쓸까? 물론 그런 상황도 있겠지만, 일반적으로는 모두 같은 머티리얼을 사용해서 렌더링하게 된다. 차이가 있다면, 텍스쳐는 모두 다른 텍스쳐를 사용할 것이다.

 

즉, 거의 모든 렌더 스테이트가 동일한데 텍스쳐 하나 때문에 4번의 드로우 콜을 보내야 하는 것이다.

 

하지만, 아틀라스 텍스쳐를 사용한다면?

 

아틀라스 텍스쳐란, 하나의 이미지 파일에 위처럼 여러 오브젝트에 사용될 텍스쳐를 모아놓은 것이다.

여기서 UV값을 이용하여 필요한 만큼 잘라서 텍스쳐를 메쉬에 입히는 것이다.

 

이러한 이미지 파일로 텍스쳐를 생성한 뒤, 부위별로 UV만 매칭해준다면 서로 다른 메쉬이지만 같은 텍스쳐를 사용하는 셈이 되는 것이다. 즉, 눈, 머리, 상의, 하체에 대해 렌더 스테이트가 완전히 동일해지는 것이다.

 

이러한 경우엔 4개의 메쉬를 하나로 합쳐버리더라도 품질의 손상 없이 렌더링이 가능해진다.

(메쉬를 나눠놓는 이유가 대부분 다른 텍스쳐를 입히기 위함이다.)

 

아틀라스 이미지를 활용하면, 여러 메쉬를 하나로 합치는 것이 가능하고 드로우 콜을 1번으로 압축하는게 가능하게 된다는 것이다!

 

즉 배칭이란, 같은 머티리얼을 사용하는 여러 메쉬를 하나로 합쳐 한 번의 드로우 콜로 렌더링하는 방식이라는 것이다.

 

배칭은 스태틱 배칭, 다이나믹 배칭 두 가지 종류가 있다.

 

스태틱 배칭은 로딩 타임에 미리 매쉬들을 배칭하는 것이다. 주로 움직이지 않는 오브젝트(맵, 장식물등)에 대해 사용한다.

여러 메쉬 중 같은 머티리얼을 사용하는 메쉬를 하나로 묶어, 한 번의 드로우콜로 렌더링을 해결하게 해준다.

 

다이나믹 배칭은 런타임에 배칭을 진행하는 것이다. 주로 움직이는 대상에 대해 사용한다.

실시간으로 오브젝트를 하나로 합치는 연산을 진행하며, 드로우 콜을 줄인다고 한다.

 

하지만, 이러한 배칭에도 문제가 있다.

 

스태틱 배칭의 문제점

 

1. 메모리 사용량이 증가한다.

: 덩어리로 합친 메쉬를 추가적으로 VRAM (GPU 메모리)에 올려놓아야 하기 때문에 메모리 사용량이 증가한다.

 

2. 컬링을 제대로 적용할 수 없어진다.

: 메쉬가 나누어져 있을 땐, 화면 밖으로 벗어난 메쉬만 렌더링 하지 않는 것이 가능하지만 하나로 합쳐버리는 순간  화면을 벗어나는 부분만 렌더링 하지 않는 것이 불가능해진다.

 

다이나믹 배칭의 문제점

 

1. 연산량이 너무 많다.

: 실시간으로 연산하기엔 연산량이 너무 많아, 버텍스의 수가 많으면 오히려 드로우 콜을 줄임으로써 얻는 이득보다 배칭으로 얻는 손해가 더 커질 수 있다고 한다.

 

배칭이라는 기술이 여러모로 효율적으로 보이는 것 같으면서도, 상황에 따라 비효율적일 수 있기 때문에 신중하게 사용하는 것이 좋을 것 같다.

CPU에서 GPU로 데이터를 보낼 땐, 그냥 데이터 덩어리로 데이터를 보낸다. C++ 코드에서 자료형이 어떻고, 변수 이름이 어떻고 이런건 아무 신경도 안쓰고, 그 안에 들어있는 데이터만 보내버리는 것이다.

 

쉐이더에서 상수버퍼의 자료형을 새로 선언해주는 이유도 이 때문이다. 들어온 데이터 중 몇 바이트를 어떤 자료형으로 사용할 것이고, 어떤 변수명을 사용할 것인지 등에 대한 정보는 CPU에서 보내주지 않기 때문에 쉐이더에서 직접 명시해야 하는 것이다.

 

이 과정에서 문제가 한 가지 생긴다. 상수버퍼는 반드시 데이터를 16바이트 단위로 정렬한다고 한다. 16바이트 경계를 넘어서면, 서로 침범할 수 없다고 한다. 데이터 덩어리가 들어오면, 우리가 C++ 코드에서 몇 바이트 단위로 데이터를 사용을 했건간에 무조건 16바이트 단위로 잘라버린다.

 

예를 들어, float3타입의 데이터 2개를 상수버퍼로 GPU에 보냈다고 해보자.

데이터를 보낼 땐 아래와 같이 보낼 것이다.

 

이렇게 데이터를 묶어서, 덩어리로 GPU에 보낼 것이다.

 

그리고 우리는 float3 자료형 2개를 보냈기 때문에, 쉐이더 코드에서도 float3 자료형으로 두 데이터를 받을 것이다.

A = {1.0f, 0.0f, 1.0f}, B = {0.7f, 0.4, 0.5f} 라고 한다면, 쉐이더에서도  A = {1.0f, 0.0f, 1.0f}, B = {0.7f, 0.4, 0.5f} 를 사용하길 원할 것이다.

 

하지만 실제로는 A = {1.0f, 0.0f, 1.0f}, B = {0.4f, 0.5f, 0.0f}가 되어버린다.

왜일까?

 

두 데이터를 보낼 때, 우리는 데이터가 연결된 상태의 24바이트로 데이터를 보냈다.

이를 GPU에서 저장할 땐, 16바이트 단위로 저장한다고 하였다. 이 때문에 데이터는 아래와 같이 저장된다.

B가 잘려서, 일부분이 A와 같은 영역에 저장되는 것이다.

이로 인해, B는 앞의 4바이트에 해당되는 0.7f데이터를 잃게되고 y와 z값이 x와 y값으로 변하게 된다.

 

A를 float3형으로 사용하게 되면 {1.0f, 0.0f, 1.0f}가 되지만, float4형으로 A를 받아보면 A는 {1.0f, 0.0f, 1.0f, 0.7f}로 B의 x값이 A의 z값에 저장되어 있음을 알 수 있다.

 

그렇기 때문에, 우리는 데이터를 의도한 대로 온전히 보내기 위해선 반드시 16바이트로 맞춰서 보내야 한다.

 

만약, float3 float3 float float 이렇게 4개의 데이터를 보낸다면, (float3 float) (float3 float)처럼 순서를 바꿔서 16바이트에 데이터가 포함되는 형태로 바꿔서 데이터를 보내야 한다는 것이다.

 

만약 따로 보낼 float형의 변수가 없다면, float4형의 변수를 선언한 뒤, xyz에 값을 저장하고 w를 비워서 데이터를 보내거나

의미없는 데이터를 float형으로 추가해서 함께 보낸다음 그냥 안쓰면 된다.

 

보통 16바이트를 채우기 위해 사용한 의미없는 데이터를 패딩이라고 한다.

 

(다른 사람들 쉐이더 코드 보면 float3 Position, float Padding 이런 코드가 자주 보인다. 이 때 padding은 16바이트를 맞추기 위해 사용한 의미없는 데이터라고 보면 된다.)

 

요약 : 상수버퍼에 데이터를 담아 GPU로 보낼 땐 반드시 16바이트 단위로 보내자.

 

우리가 코드를 작성하다 보면, 분기를 나눠야하는 경우가 상당히 많다.

분기를 나누는 이유가 여러가지가 있겠지만, 근본적으로는 상황에 맞게 필요한 연산만 실행하기 위해서일 것이다.

이는 최적화를 위해서 일 수도 있고, 올바른 결과물을 위해서일 수도 있을 것이다.

 

하지만, 만약 모든 분기가 다 실행된다면?

 

예를 들어 아래 코드를 보자.

if(현재 픽셀이 피부라면)
{
    Color = SkinShading();
}
else if(현재 픽셀이 옷이라면)
{
    Color = ClothShading();
}
else
{
    Color = HairShading();
}

 

뭐 이런 예시가 있다고 가정해보자. (일반적으로는 이런 분기는 나뉘지 않을 것 같긴 하다...  옷이랑 피부, 머리카락은 아예 다른 쉐이더를 쓰지 않을까?)

 

이 때, 우리가 기대하는 것은, 픽셀의 타입에 맞게 하나의 함수만 실행되는 것이다.

현재 픽셀이 피부라면, SkinShading만 실행하고, 옷이라면 ClothShading만 실행하고 그것도 아니라면 hairShading을 실행하는 것이다.

 

하지만 그래픽카드의 연산은 우리 의도대로 실행되지 않는다고 한다.

 

그래픽카드는 다중 병렬 연산을 통해 아주 고속으로 수많은 픽셀에 대한 연산을 처리해준다.

일반적으로는 32개나 64개의 레지스터를 묶어서 실행한다고 한다.

 

이 때, 병렬 연산을 극대화 하기 위해서는 모든 레지스터가 동일한 명령어를 처리하도록 하는 것이 가장 좋다고 한다.

즉, 분기 때문에 각 레지스터들이 다른 연산을 실행하게 되면 병렬 연산의 성능이 저하될 수 있다는 것이다.

 

이로 인해 레지스터간 처리해야 하는 명령어를 통일하기 위해 GPU에선 모든 분기를 다 실행한 뒤에 조건에 맞는 결과값만 반환하는 방식으로 실행한다고 한다.

 

하지만, 모든 분기를 다 실행하게 되면 이는 성능상에 엄청난 영향을 끼칠 수 있다. 특히나 if~else문 내부에 있는 연산이 무거울수록 그 정도는 더욱 커질것이다.

 

만약, 모든 픽셀이 동일한 분기로 진입하는 것이 확정된 상황이라면 모든 분기에 대한 연산을 다 실행하지 않고 특성 분기에 대해서만 실행하도록 최적화가 되어있다고는 한다.

 

하지만, 일반적인 상황에선 그렇지 않기 때문에 분기를 나누는 것이 아닌가?!

 

그러므로 우리는 쉐이더 내에서 if else 문을 사용할 때엔 주의를 하여서 사용해야 한다.

분기 안에 특히나 무거운 연산들이 포함되어 있을 때는 더욱 그렇다.

+ Recent posts