그래픽스 API
우선 드로우콜과 배칭에 대해서 살펴보기 전, 유니티가 어떻게 렌더링 과정을 거치는 지 부터,
다시 살펴보기로 하였다.
먼저 그래픽스 API는 여러 종류가 있는데. 존재하는 이유는 렌더링은 GPU에서 처리하는데
수 많은 GPU를 하나하나 맞춰서 개발하는 것은 사실상 불가능하기 때문에
다양한 GPU의 드라이버를 이용하는 라이브러리를 만들고 API로 제공해서, OS나 GPU상관 없이
렌더링을 할 수 있도록 도와준다.
다만 개발의 편의성,최적화적인 측면,하드웨어 지원 등 OS 별로 권장되는 API가 다른데
아래와 같다.
Direct X - MS
OpenGL - MS , 매킨토시 , Linux
Vulkan - 안드로이드에서 쓰는 차세대 그래픽 API
Metal - IOS에서 쓰는 차세대 그래픽 API
다만, 유니티의 경우 빌드하는 플랫폼에 따라 그것에 맞춘 그래픽 API를 사용해 렌더링 해준다.
그리고 이것을 알아야하는 이유는 최적화를 하려면 그래픽스 API가 어떻게 렌더링을 처리하는지
그 과정을 알아야하기 때문이다.
드로우콜
CPU는 현재 프레임에 어떤 것을 그려야 할지 정해서, GPU에 그리라고 명령을 하는데.
이것을 바로 드로우콜이라고 한다.
드로우콜 간단하게 이해하기.
기본적으로 오브젝트를 그릴 때 메시가 1개, 머테리얼이 1개라면 드로우콜이 한 번 일어난다.
참고자료를 확인하면 캐릭터가 하나만 배치된 상태인데, 드로우콜이 6개나 되는 상황을 확인할 수 있다.
사유는 저기 하이라키에서 볼 수 있는, 슬라임 객체가 5개나 디세이블 되어 있는데, 배치의 개수는
비활성화 된 객체 또한 포함 시킨다.
여하튼.
이는 드로우콜 배칭 이 되질 않아 발생하는 현상인데.
프로젝트 세팅에서 Dynamic Batching을 체크해주면 해결 할 수 있다.
드로우콜 깊게 파해치기
화면상에 그림을 표현하는 렌더링 파이프라인 과정을 이해하기 전에, 아까 위에서 잠깐 언급한그래픽스 API에 대한 이야기를 먼저 꺼내보고자 한다. 유니티에서 드로우콜을 생성하기 전 까지 그래픽스 API를 어떻게 초기화하고 어떤 과정들이 있는지를 분석해보자.
GPU 디바이스 생성
현재 기기의 GPU를 표현하는 오브젝트를 생성한다. 현재 기기의 하드웨어인 GPU를 추상적으로
표현한 오브젝트이다.
수 많은 GPU를 하나하나 맞춰서 개발하는 것은 사실상 불가능하기 때문에
다양한 GPU의 드라이버를 이용하는 라이브러리를 만들고 API로 제공해서, OS나 GPU상관 없이 렌더링을 할 수 있도록 도와준다.
위에서 잠깐 언급한 내용을 이해하면, 추상적으로 표현한 GPU 오브젝트가 대략적으로 어떤 것인지
감을 잡을 수 있다. 구체적으로 GPU 오브젝트가 어떤 방식으로 만들어졌는지는 나와있지 않지만
핵심적인 내용은 아래와 같다.
- 현재 기기의 GPU를 표현하는 오브젝트
- 한번만 생성, 하나만 존재
- 커맨드 큐로부터 커맨드를 받아들여 실행
커맨드 큐 생성
위에 계속 언급한 내용 처럼, CPU에서 렌더링, 상태변경 등의 명령을 GPU에 전달한다.
그런데 GPU가 바쁘게 작업하는 도중이라면, CPU는 GPU의 작업이 끝나기를 계속 기다리게 될 수 있다. 따라서 커맨드 패턴과 메세지 큐에 의한 비동기 방식을 활용한다.
간단히 CPU에서 GPU에 전달할 명령을 임시 공간에 담아 두고, GPU가 여유가 될 때 마다 명령을 하나씩 꺼내어 처리한다.
동작 방식을 좀 더 살펴보자면 CPU의 각 스레드에서 GPU에 전달할 렌더링 관련 명령을 모듈화 하여
커맨드 버퍼에 차곡차곡 쌓게 된다.
그리고 앞서 설명한 비동기 방식으로 처리 한다고 하였듯,
GPU의 공유 커맨드 큐에 전송한 뒤, GPU를 기다리지 않고 CPU는 다른 작업을 수행한다.
곧장 커맨드 큐에 전송하지 않는 이유는, 데이터 동기화 문제로 인한 성능 저하를 유발할 수 있으므로 명령들을 모아 버퍼 단위로 전송하는 방식을 사용하는 것인데.
간단하게 그림으로 예를 들어 설명해보자면, 사용할 데이터 사용할 텍스쳐 등을 버퍼로 묶지 않고
그대로 큐에 담게 되면, 어떤 일이 일어날지 상상해보면 될 것 같다.
1번 그림의 사용할 데이터를 불러오다가 갑자기 2번 그림의 사용할 데이터도 불러오게 되고
순서가 뒤죽박죽 섞이게 되는 현상도 있을 뿐더러, 그렇다고 큐에 담긴 내용들을 하나하나
구분 짓는 다면 불필요한 성능 저하 이슈가 있다는 얘기이다.
그러니 그리기 단계 까지 오기 전 까진 그저 버퍼에 담아두고, 최종적으로 커맨드 큐에
버퍼 형태로 전송한다는 이야기가 된다.
이 과정에서 버퍼에 쌓인 명령이 나중에 실행 될 때, CPU의 System RAM에서 GPU의 VRAM으로
사용할 Texture나 모델의 정점,사용할 Shader등 다양한 그래픽 정보들이 복제되서 전달 된다.
렌더링 파이프라인 상태 생성
렌더링 파이프라인 상태 오브젝트들은, 현재 렌더링 파이프라인의 상태를 표현한다.
렌더링 파이프라인 상태 오브젝트를 좀 더 간단히 묘사하자면 위 사진과 같이 표현할 수 있는데.
렌더링 파이프라인 서술자 오브젝트는 렌더링 파이프라인이 어떻게 동작할지 묘사하는 오브젝트라고 한다.
포함하는 데이터 중 정점 서술자는 정점이 어떻게 구성되는지를 표현할 수 있다.
날 것 그대로 들어오는 데이터를 정점 형태로 조립할 수 있다. ( 렌더링 파이프라인의 공정 중 정점 조립 때 사용 )
상태 오브젝트는 여러 개를 생성 할 수 있는데 이 말은,렌더링 파이프라인이라는 일련의 처리를
조금씩 다른 방식으로 구성해서, 준비해놓고 오브젝트 마다 다른 렌더링 파이프라인을 적용 할 수 있다. 간단히 서로 다른 오브젝트에게 서로 다른 쉐이더를 적용 할 수 있다.
사용할 데이터 로드
그래픽스 API를 초기화 한 이후, 렌더링 파이프라인을 실행하기전, 사용할 데이터를 미리 메모리에 로드 하는 과정이 필요하다. 그래픽 카드에 전송할 데이터를 일단 시스템 메모리에 올려놓아야 한다.
위와 같이, 정점 데이터를 보냈다고 가정 했을 때 주의 사항으로 정점은 위치가 아닌
속성 중 하나라는 점이다.
오브젝트들의 이러한 정점 데이터들은 쉐이더 안에서는 구조체 형태를 띄지만
커맨드를 통해서 RAM에서 VRAM으로 옮겨지는 과정에선 Stream 형태의 Buffer로
전달된다.
위 그림처럼 구조화 되지 않고, stream 형태로 데이터가 들어오는데, 정점 조립기에 의해
정점으로 구조화 한다.
결론
이렇게 예시로 삼각형을 하나 그리기 위한 커맨드 버퍼를 드로우콜이라 볼 수 있다.
드로우콜을 줄여야하는 이유
위에 까진, 드로우콜이 일어나기 전 과정과 드로우콜이 어떤 것인지를 표현했고, 이번엔
그래서 이 드로우콜을 왜 줄여야 하는가를 정리해보고자 한다.
Render States(렌더상태)
GPU가 렌더링을 수행하기 위해선 어떤 텍스쳐를 사용할지, 어떤 버텍스들을 사용할지, 어떤 쉐이더를 사용할지 등을 순차적으로 알려줘야 한다. 이런 정보들은 GPU의 상태 정보를 담는 테이블에 저장된다. 이 테이블을 렌더 상태라고 부르며, 각각의 요소는 GPU 메모리를 가리키는 포인터를 저장한다.
DP Call
CPU가 렌더 상태를 변경하라는 명령을 GPU에게 보내고 나면, CPU는 마지막으로 GPU에게 메시를 그리라고 명령을 보낸다. 이 때 Draw Primitive Call ( DP Call ) 이라 부른다. GPU는, DP Call을 받으면렌더 상태의 정보들을 바탕으로 오브젝트의 메시를 렌더링 한다.
Render States Changes(렌더 상태 변경)
이렇게, 한 오브젝트의 메시가 렌더링 됐다면, CPU는 또 다른 오브젝트를 렌더링 하기 위해 사용할 쉐이더, 메시, 텍스쳐등의 정보들을 변경하라는 명령을 반복할 것이다. 그렇게 되면 Render States
역시 바뀔 것이다. 그 후 DP Call을 받은 GPU가 다시 오브젝트를 렌더링 한다.
정말 간단하게 표현하자면 이렇게 Render States 테이블은 GPU Memory에서 정보 포인터를 가져오고 그려주고 다시, 가져오고를 반복한다는 의미다.
결론
그래서 왜 줄여야 하는가에 대한 답을 이제 해보려고 한다.
일단, 드로우콜에서 사용되는 명령들이 모두 GPU가 알아들을 수 있는 명령들로 변환되어야
하는데. 이것이 CPU 오버헤드를 발생시키며 따라서 드로우콜은 대게 CPU 병목의 주 원인이 된다.
해결 방법은 드로우콜 횟수 자체를 줄여야만 한다.
아까 위에서도 언급하였듯이 메시가 1개, 머테리얼이 1개라면 드로우콜이 한 번 일어나고
메시가 10개면 드로우콜도 10번, 10개의 메시로 이뤄진 오브젝트가 20개 있다면
드로우콜은 200번 발생하게 된다.
마찬가지로 메시 1개, 머터리얼이 여러 개인 경우에도 여러 번의 드로우콜이 발생한다. ( 서브 메시 생성 )
쉐이더 내에서 멀티패스로 두 번 이상 렌더링을 하는 경우도 드로우콜이 여러번 발생한다.
(ex 카툰 렌더링 쉐이더에서 추가적으로 외각선을 그려주는 경우)
배칭
이번 글을 포스팅 한 이유는 이 Batch(배치)을 설명하기 위해서였다.
정의는 동일한 메테리얼을 공유하는 복수의 드로우콜을 하나로 묶어서 드로우콜 하는 기법이며
배치의 개수는 Draw Call + SetPass calls의 값이라고 한다.
일단 SetPass에 대한 개념은 추후 설명하도록 하고, 결론적으로 유니티에서 최적화를 할려면
이 배치 개수를 줄여야 한다.
필자 또한, PC에선 MOS 프로젝트가 아무 문제 없이 작동했지만 모바일에선 프레임 드랍 현상이
일어났던 현상을 해결 할 수 있던 것도 이 배치를 줄였기 때문에 가능했다.
평균 적정 개수로는 PC : 1000개 이상 가능 ~ 3000개, 모바일의 경우 100개도 많고, 최신 디바이스는 200개 정도 가능하다고 한다.
Set Pass Call
쉐이더로 인한 렌더 상태 변경만을 의미한다.
오브젝트를 렌더링 하는 중 머터리얼이 바뀌면 쉐이더 및 파라미터들이 바뀌면서 SetPass가 증가한다. 이 땐 많은 상태 변경들이 일어나기 때문에 SetPass도 CPU 성능을 꽤 잡아먹는다.
즉 메시의 변경은 렌더 상태 변경이지만 SetPass Call에는 해당하지 않고, 메시가 달라도 머테리얼이 같다면 SetPass Call은 딱 1번만 발생한다.
Batching
여러 개의 Batch를 하나로 묶는 최적화 기법이다. 드로우콜 횟수를 줄이는 가장 효과적인 방법이며
배칭을 위해서는 오브젝트들이 동일한 머터리얼을 사용해야 하는데, 이 때 머터리얼이 동일하다는 것은 동일한 머터리얼 인스턴스가 같다는 것을 의미한다.
Static Batching
런타임에서 움직이지 않는 메시들에 대해서만 가능하다. 간단히 움직이지 않는(위치,회전,스케일등) 땅이나, 나무 등 배경이 되는 오브젝트들에 사용할 수 있다.
여러 개의 메시를 하나의 메시로 통합하는 개념이며, 메시를 통합한 만큼 하나의 배치로 그려줄 수
있다. 다만 Static Batching이 되어도 컬링 연산은 원래 메시 기준으로 이루어진다는 장점이 있다.
사용 상의 차이는 Static 활성화 여부 차이.
다만 장점만 있는 것은 아니라. 메모리를 희생해 성능을 올리는 개념이고
뒤에 설명할 다이나믹 배칭보다는 효과가 좋아 CPU의 부담을 줄일 수 있지만. 머테리얼을 메모리에 올려놓고 사용하기 때문에, 적은 메모리를 사용해야 한다 거나 과도하게 많은 오브젝트를 사용할 땐
피해야 한다.
또 통으로 제작시 화면에 일부만 보이더라도 전체 처리를 해야 하기 때문에 적절히 쪼개서 모듈화 방식으로 제작하면 성능 상 유리하다.
런타임 도중에도 생성은 가능하지만 데이터 수집 + 재생성 하기 때문에 부하가 있다.
Dynamic Batching
매 프레임마다 다이나믹 오브젝트들의 버텍스를 모아서 합쳐주는 처리를 수행한다. 그리고
이 버텍스들을 모아 다이나믹 배칭에 쓰이는 버텍스 버퍼와 인덱스 버퍼에 담는다. GPU는 이 버퍼에
있는 합쳐진 버텍스들을 가져다서 렌더링 한다. 따라서 여러 개의 오브젝트가 버퍼를 거쳐 1번의
배치로 처리 될 수 있다.
효율에 관해서는 그렇게 큰 기대를 할 수 없는데, 특정한 경우에선 일반적인 드로우콜 보다 효율이
좋지 못하고 조건도 까다롭다. 그 이유를 풀어서 설명해보자면
매 프레임 마다, 다이나믹 오브젝트들의 버텍스를 모으고 합치는 과정에서
데이터 구축과 갱신이 필요하기 때문에 매 프레임마다 오버헤드가 발생한다.
또 스키닝을 수행하는 Skinned Mesh에는 적용이 불가능하다.
→ 게임에선 애니메이션을 갖는 캐릭터가 이에 해당한다.
버텍스가 너무 많은 메시는 제외된다 (버텍스 개수가 300개 이상)
결론
결론적으로, 배칭과 관련된 내용은 정적/동적 배칭 외에도 여러가지가 있지만.
필자가 최적화 했던 부분은 지형이 되는 오브젝트의 Batch 기준으로 1,500개 정도
잡아먹었던 것을, 정적배칭을 통해 실 게임 시 batch 갯수를 50개 가까이로 줄여서 성능 향상을
일으킬 수 있었다.
글을 작성하면서, 각종 포스팅 자료를 찾아보며 왜 최적화가 되었는지 예전에 난 머릿속에선 원리를 이해하지 못하고 사용했더라면, 이제는 그 이유와 원리를 완전히 깨닫게 되었다.
또한, 참고 자료 중에서 꼭 포스팅 해보고 싶은 글이 있었는데
다음 글은 https://darkcatgame.tistory.com/139 를 참고하여 정리해서
좀 더 배칭에 관련된 내용들에 대해 학습노트를 포스팅 할 예정이다.