개인적으로 재미있게 개발한 기능들의 구현 과정과 기반 지식을 기록합니다.
회사에서 UI 렌더링 최적화 작업을 진행할 일이 있었다. 당시 개발중인 엔진의 문제점은 씬에 배치된 UI를 각 요소마다 한 번의 드로우콜로 그린다는 점이었다. 즉, 10개의 UI 요소가 스크린에 배치되어 있다면 10번의 쿼드 메쉬에 대한 드로우콜을 수행하는 상황이었다. 당연히 한 메쉬를 그릴 때 하나의 드로우콜을 수행하는 것 아니냐고 생각할 수 있지만, 게임에서 UI 관련 작업을 해 본적이 있다면 다음 두 가지 사실을 알 수 있을 것이다.
1. 임의의 화면에 배치되는 UI의 요소는 생각보다 많다. 기본적으로 수십개가 배치될 수 있으며 그 이상일 수 있다.
2. 그렇게 배치되는 UI요소들이 의외로 같은 텍스쳐를 사용하는 경우가 꽤 있다.
이러한 UI요소를 한 드로우콜에 하나씩 그리기 시작한다면 그 수에 의해 예상보다 큰 성능 하락을 경험할 수 있게 된다. 이를 방지하기 위해선 드로우콜의 수를 줄이는 방법을 고려해야 하는데, 그래픽스 엔지니어의 입장에서 기본적으로 드로우콜을 줄이기 위해선 인스턴싱을 걸거나, 메쉬를 묶어 그리는 방법(Batching)을 고려할 것이다(그려져야 할 요소들의 드로우콜을 최소화하는 것이 목적이므로 컬링 등은 논외로 한다).
나는 메쉬를 묶어 그리는 방법(이하 '배칭'이라 칭함)을 선택했는데, 그 이유는 우리 엔진이 선택적으로 인스턴싱을 걸 수 없다는 것이었다. 즉, 같은 텍스쳐를 쓰는 100개의 메쉬를 묶어 그릴 때 뿐만 아니라 서로 다른 텍스쳐를 쓰는 100개의 메쉬를 각각 그릴 때에도 인스턴싱 드로우콜을 수행하게 되는데, 많지 않은 수의 메쉬를 연산할 때에는 인스턴싱 드로우콜 보다 일반 드로우콜을 여러 번 호출하는 게 오히려 성능이 좋게 나오기 때문이다.
간단한 예시로(이해하기 쉽게 든 예시이므로 정확성은 다소 부족할 수 있다), 일반적인 드로우콜(DrawIndexed)을 쿼드 메쉬에 호출할 때 0.1ms가 걸리고, 인스턴싱 드로우콜(DrawIndexedInstanced)을 호출할 때 1.5ms가 걸린다고 가정해 보자.
만약 동일한 텍스쳐를 사용하는 쿼드 메쉬가 100개 있을 때 인스턴싱 드로우콜을 호출하면 인스턴스 카운트를 100으로 설정한 한 번의 드로우콜이 호출되므로 1.5ms지만 일반 드로우콜을 100번 호출하면 10ms가 걸린다. 인스턴싱이 이득을 보는 대표 사례이다. 반대로 서로 다른 텍스쳐를 사용하는 메쉬가 10개 있다면? 일반 드로우콜이나 인스턴싱 드로우콜이나 결국 10번 수행되므로 1ms 와 15ms의 결과가 나타난다. 이것이 인스턴싱이 오히려 성능 하락을 일으키는 사례이다.
배칭을 구현하는 다양한 방법이 있을 수 있으나, 일반적으로 UI는 정적 배칭(Static Batching)으로 구현될 것이다. 정적 배칭은 CPU단에서 메쉬를 묶어 거대한 하나의 메쉬를 생성한 후 이를 한 번의 드로우콜로 그려낸다. 이 과정에서 얻는 장점은 당연히 드로우콜의 감소일 것이고, 단점은 메쉬를 묶는 CPU의 비용이다.
그러나 UI의 대부분은 '정적'이므로(움직이지 않으므로) 매 프레임마다 메쉬를 묶을 필요가 없다. 한 번 메쉬를 묶어놓은 후 이를 캐싱해 두면 충분하다.
배칭을 구현할 경우(사실 UI를 묶어 그리는 경우라면 반드시) 고려해야 할 점이 있다. 바로 깊이 경쟁과 겹침 문제이다. UI들은 일반적으로 같은 z값을 가지므로 이들을 그릴 때 별도의 Depth를 부여한 후 그에 맞게 Depth test를 disable한 후 그리게 된다. 그렇지 않으면 필연적으로 깊이 경쟁(Z Fighting)이 일어나기 때문이다.
그렇다면, 아무리 동일한 텍스쳐를 사용하는 UI들이라 하더라도 서로 겹치거나 다른 텍스쳐를 사용하는 UI가 Depth 상으로 그들의 사이에 위치하게 된다면 그들을 묶기 어려워진다. 이를 위해선 겹침 요소를 함께 고려해서 배칭을 진행해야 한다.
서로 겹치는지를 어떻게 고려할 수 있을까? 나의 경우 충돌 판정에 AABB를, 그 결과를 나타내는 자료구조에 DAG를 이용했다. 엔진에 이미 렌더 바운딩 박스에 대한 기능이 구현되어 있으므로, 이를 이용해 서로 겹쳐졌는지를 판정할 수 있었으며, 그 결과를 DAG(비순환 방향그래프)로 구성한다.
다음과 같은 UI 렌더링이 수행되어야 한다고 가정해 보자. 이 때 정사각형은 UI Image, 사각형 안의 숫자는 UI Depth, 사각형의 색상이 텍스쳐의 종류이며 UI Depth가 높을수록 먼저 그려져야 한다고 가정하자.
사람은 직관적으로 빨간색(32, 3) -> 초록색(28, 1) -> 파란색(30, 2) 순으로 묶어 그리면 된다는 것을 안다. 그러나 이를 컴퓨터의 입장에서 어떻게 추상화할 수 있을까? 단순히 UI Depth만 가지고는 이를 알아낼 수 없다. 따라서 다음과 같이 DAG를 구성한다.
화살표는 자신의 PreUI(자신보다 먼저 그려져야만 하는, 겹쳐졌으면서 자신보다 UIDepth가 큰)를 가리킨다. 이 상태에서 그래프를 UIDepth가 높은 정점부터 순회하는 것을 반복하며 같은 텍스쳐를 가지며, PreUI들이 모두 이미 묶여진 상태인 정점이 가리키는 UI들을 함께 묶으면 된다.
그렇다면 만약 아래의 경우는 어떨까?
언뜻 보기엔 3을 그리고 (2, 1)을 그리면 될 것으로 보이는데, 정말 그래도 될까? 2와 1이 예시처럼 단색의 텍스쳐를 사용하지 않고 다음처럼 복잡한 텍스쳐를 사용한다면, 깊이 경쟁이 일어나지 않을까?
이미 앞서 만든 충돌그래프를 바탕으로 메쉬를 묶기 때문에, 묶인 메쉬는 하나의 드로우콜로 그려질 것이다. 한 드로우콜 안에서 수행된 픽셀 쉐이더의 연산은 프레임버퍼에 쓰여질 때 드로우콜이 호출된 정점 데이터의 인덱스 순서에 맞게 들어가기 때문에 이를 걱정할 필요가 없다. 즉, 앞에 그려져야 할 메쉬부터 차곡차곡 모아서 하나의 정점 버퍼를 생성하면 묶인 데이터 간의 깊이 경쟁은 고민할 필요가 없다(참고: https://developer.nvidia.com/content/life-triangle-nvidias-logical-pipeline , 제 브런치에 해당 아티클을 번역한 글이 있습니다).
위의 방법을 통해 성공적으로 UI 요소들의 드로우콜을 줄일 수 있었다. 물론 더 좋은 방법도 존재할 것이고, 상술한 방식의 단점도 존재할 것이지만(당장 예시로 든 상황에서도 이상적인 드로우콜 수보다는 많은 콜이 일어난다) 일단은 목표한 수준의 성능이 나왔기 때문에 안정적으로 엔진에 탑재될 수 있었다. 상술한 작업 뿐만 아니라 내가 만든 기능이 엔진에 정상적으로 탑재되어서 동작할 때의 성취감은 이루 말 할 수가 없다. 엔진 개발은 너무 즐거운 일이다!