효율적인 렌더링을 위한 여러 가지 기법들에 대한 소개
들어가기에 앞서서. 렌더링(Rendering)과 쉐이딩(Shading)은 엄연히 다른 개념이다. 정확히는, Shading이 Rendering의 과정 중 하나이다(렌더링 파이프라인에 쉐이더 외에도 다양한 단계가 존재한다는 점을 생각해 보자). 그렇지만 이 글에서는 Rendering과 Shading을 같은 의미로 혼용하기로 한다.
Deferred Shading(디퍼드 쉐이딩, 지연 쉐이딩)에 대해 논하기 전에, 먼저 Shading(쉐이딩)에 대한 정의와, 쉐이딩 방식에 여러 가지가 존재할 수 있다는 것을 알아야 한다.
먼저 쉐이딩은 '음영' 이라는 이름에 맞게 조명(Light)과 머티리얼(Material)을 이용해 오브젝트의 표면이 어떻게 렌더링될지를 결정하는 것이 쉐이딩이다. 그렇기에 당연하게도 쉐이딩은 오브젝트의 표면(Surface)에 적용된 머티리얼 파라미터를 추출/준비하는 단계와 그렇게 준비한 파라미터를 모든 조명들을 이용해 둘 간의 영향을 계산해서 최종 색상을 결정하는 단계로 이루어진다. 이 글에서는 이 두 가지 단계를 간결하게 다음처럼 표현할 것이다.
1. Geometry Pass(지오메트리 패스): 머티리얼 파라미터 추출
2. Lighting Pass(라이팅 패스): 조명-머티리얼 파라미터 간 영향 계산
그럼 이번엔 쉐이딩의 여러 방식에 대해 알아보자. 쉐이딩, 즉 위의 두 단계를 처리하는 방식은 다양(Forward, Deferred, Tile-based, Forward+, ...)하지만 그 중에서도 가장 기본적이면서도 Deferred Shading과 대비되는 개념이 Forward Shading이다. Forward Shading은 씬에 존재하는 오브젝트 각각을 렌더 패스 전체에 통과시킨다. 즉, 오브젝트 하나를 렌더링하도록 요청할 때마다 그 오브젝트의 최종 쉐이딩 결과가 즉각적으로(Forward) 렌더 타겟에 반영된다. 따라서 한 씬을 렌더링하려 할 때, 그 씬에 오브젝트가 100개 있다고 가정하면 Forward Shading은 100개의 오브젝트를 하나씩 전체 렌더 패스를 통과시킨다. 즉, 오브젝트 하나의 지오메트리 패스를 완료한 다음, 바로 라이팅 패스를 수행해 얻어낸 최종 렌더링을 렌더 타겟에 기록하고, 그 다음 오브젝트에 동일한 작업을 반복하는 것이다. 이 방식은 오브젝트 하나를 그릴 때 마다 쉐이딩의 1번 과정과 2번 과정을 무조건 함께 수행한다. 즉, 두 단계가 강하게 결합되어 있다(Pre-Depth Pass 등을 이용해 두 단계를 약간(mild) 디커플링 시킬 수 있다. 이 방식은 사전에 깊이 처리를 위해 지오메트리 패스를 처리한 후 나머지 쉐이딩은 해당 패스의 결과로 얻은 깊이 맵을 바탕으로 수행하는데, 이 과정에서 '최종적으로 보여지는 오브젝트'들에 대한 나머지 쉐이딩들이 두 번째 지오메트리 패스로 지연되는 결과가 나타나기 때문이다).
반면 Deferred Shading은 그 이름(Deferred)에 맞게 오브젝트의 렌더링을 요청한다 해도 즉각적으로 모든 렌더 패스를 통과하지 않는다. 대신 렌더링을 요청한 모든 오브젝트들을 대상으로 지오메트리 패스를 수행해 그 결과를 여러 버퍼에 기록해 두고, 그 버퍼들을 이용해 라이팅 패스를 수행한다. 즉, 앞서 말한 쉐이딩의 두 단계를 분리시켜 각 단계별로 모든 오브젝트를 처리한다. 참고로 지오메트리 패스의 결과를 버퍼들에 담는다고 말했는데, 이 때 결과를 담는 버퍼들은 보통 렌더타겟(텍스처)의 형태이며, G-Buffer(Geometry Buffer) 라고 부른다.
G-Buffer에는 라이팅 단계에서 필요로 하는 어떠한 정보들이라도 담을 수 있는데, 일반적으로 다음과 같은 정보들을 담는다.
Position(Depth)
Normal
Texture Coordinate(UV)
Roughness
Occlusion
Albedo
Intensity(Light/Speculer/...)
그러나 라이팅 패스에 전달할 데이터의 종류를 원하는 대로 설정하는 것과 마음껏 버퍼에 담는 것은 별개이다. G-Buffer는 우리가 원하는 만큼 사용할 수 없을 뿐더러(지오메트리 패스에서 사용할 수 있는 렌더 타겟의 개수는 하드웨어 및 그래픽스 API 사양에 따라 다르다), 허용된 최대 개수를 사용하는 것 역시 메모리 대역폭의 낭비를 일으키기 때문에 가능한 한 적은 수의 G-Buffer을 이용하는 것이 좋다. 상기한 데이터들을 최대한 담으면서도 G-Buffer의 개수를 줄이기 위해선 G-Buffer가 담을 수 있는 각 픽셀 데이터의 컴포넌트를 최대한 쪼개서 사용하는 것이 일반적이다.
여기서 생각해 볼 수 있는 점이, G-Buffer의 사용 방식을 정하는 순간 머티리얼 파라미터가 고정되기 때문에 한 종류의 머티리얼만 사용할 수 있지 않나? 라는 점인데, 이 역시 별도의 머티리얼 ID 혹은 마스크를 G-Buffer에 기입함으로써 ID별로 G-Buffer에 저장할 값(파라미터)을 다르게 설정할 수 있고, 라이팅 패스에서 해당 값을 확인한 뒤 각기 다른 처리를 해 줄수 있다.
이렇게 두 단계가 강하게 분리됨으로써 여러 이점들을 얻을 수 있는데, 지오메트리 패스에서 필요한 모든 정보들을 G-Buffer에 담기 때문에 이후 단계에서 오브젝트들의 정보가 필요하지 않다. 또한 2x2 픽셀(픽셀 쉐이더의 최소 호출 단위)이 모두 삼각형의 안에 들어가 있지 않지만, 모든 픽셀이 쉐이딩되어야 한다고 생각해 보자. 언뜻 생각하기엔 큰 문제가 아닌 것 같지만 각 메쉬의 모든 삼각형이 1픽셀만을 차지한다고 가정할 경우, Forward Shadingd의 경우 1픽셀 쉐이딩이 일어날 때 3픽셀이 discard될 것이다. 반면, Deferred Shading의 경우 각각의 쉐이더 실행(Invocation)이 덜 비싸기 때문에 Forward Shading에 비해 추가적인 비용이 덜하다.
또, 두 단계가 완전히 분리되어 있기 때문에 대체로 쉐이더의 길이와 코드상의 복잡도가 줄어들게 된다. 전통적인 Forward Shading에서 정점, 픽셀 쉐이더 프로그램은 각각의 조명들과 머티리얼들의 모든 파라미터들을 받아서 둘 간의 영향을 계산한다. 그렇기에 이런 둘 간의 모든 조합을 고려한 길고 복잡한 쉐이더를 요구하거나, 특정 조합을 처리하기 위한 짧은 쉐이더를 많이 요구하게 된다. 보통 동적 분기를 포함하는 긴 쉐이더는 다른 일반적인 쉐이더보다 훨씬 느리게 처리된다. 따라서 많은 수의 짧은 쉐이더들이 효율적일 수 있는데, 정작 이 경우에는 그 많은 쉐이더를 생성하고 관리하는 데 상당한 작업이 요구된다. 또, Forward Shading은 결국 오브젝트 단위로 렌더 패스를 태우기 때문에 한 오브젝트의 렌더링을 완료할 때 마다(패스가 끝날 때마다) 쉐이더를 교체해야 할 가능성이 높아진다. 이와 같은 쉐이더 교체 역시 성능 하락을 유발하는 작업이다.
반면 Deferred Shading의 경우 쉐이딩 과정이 두 단계로 완전히 분리되므로 렌더 패스에서 사용되는 각각의 쉐이더가 지오메트리 패스 혹은 라이팅 패스 중 한 가지 작업만을 담당하게 되어 쉐이더의 길이가 짧아지고 코드의 복잡도가 감소하게 되는 경향을 보인다. 이처럼 쉐이더의 길이가 짧을 수록 실질적인 계산량이 줄어들기도 하거니와 컴파일러가 해당 쉐이더의 최적화에 개입할 여지가 더 많기 때문에 종합적으로 더 빠른 계산 속도를 보인다. 또한 쉐이더가 짧을 수록, 약간 다른 관점에서 보자면 최적화가 잘 될 수록 한 쉐이더에서 사용하는 레지스터의 개수가 줄어들 수 있는데, 이는 곧 해당 쉐이더가 더 적은 하드웨어 점유율을 보일 수 있다는 의미이다. 알다시피 쉐이더 프로그램은 인스턴스의 형태로 GPU안에서 완전히 병렬적으로 실행되는데, 각 쉐이더 인스턴스의 하드웨어 점유율이 낮을 수록 더 많은 인스턴스가 병렬적으로 수행될 수 있게 된다.
또, 이러한 지오메트리/라이팅의 분리는 프로그래머가 새 쉐이더를 작성/테스트해야 할 때 이를 더 빠르게 할 수 있는 구조적 토대를 마련해 준다. 새 지오메트리/라이팅 쉐이더를 적용하기 위해 프로그래머는 단지 그 단계만을 고려하는 한 개의 쉐이더를 새로 작성해 추가하거나 교체해 주면 그만이기 때문이다. 만약 Forward Shading이었다면 패스 전체를 고려해 쉐이더를 작성해야 했을 것이다.
Deferred Shading은 그림자 렌더링에서도 장점을 가진다. Forward Shading의 경우 한 패스에서 라이팅을 모두 처리하기 때문에 한 번에 모든 쉐도우 맵을 필요로 한다. 그러나 Deferred Shading은 각각의 쉐도우 맵을 한 개씩 처리하는 것이 가능하기 때문에, 메모리 점유율을 낮출 수 있다. 물론 이러한 장점은 조명을 그룹 단위로 묶어서 렌더링하는 등의 고급 기법에서는 사라지는 장점이다.
이렇게만 보면 Deferred Shading이 Forward Shading에 비해 훨씬 우수한 기법으로 보이지만, Deferred Shading 역시 단점이 존재한다. 두 가지 부분에 대해 치명적인 기술적 한계가 존재하는데, 바로 안티 에일리어싱(Anti-Aliasing)과 반투명(Transparancy) 오브젝트 렌더링이다.
먼저 반투명 오브젝트의 렌더링에 대해 얘기해 보자. 결론부터 얘기하면 Deferred Shading에서는 반투명 오브젝트를 그릴 수 없다. Deferred Shading은 지오메트리 패스를 통해 모든 오브젝트 정보를 미리 기입해서 라이팅 패스에 넘긴다고 말했는데, 이 때 오브젝트 정보를 담을 임의의 픽셀이 가질 수 있는 값은 당연하게도 오직 하나뿐이다. 따라서 이를 해결하는 방법은 모든 불투명(Opaque) 오브젝트를 Deferred Rendering으로 그린 후, 카메라 뷰에 들어온 모든 반투명 오브젝트들을 Forward Rendering으로 그리는 것이다. 하드웨어의 발전과 OIT(Order-Independent Transparency) 기법의 고도화로 인해 Deferred Shading에도 여러 투명 오브젝트 정보를 담는 방법들이 생겨났지만, 여전히 표준적으로 사용되는 방법은 Forward Rendering을 이용하는 것이다.
다음으로 안티 에일리어싱에 대해서 얘기하자면, 안티 에일리어싱 기법들의 기본은 한 픽셀에 여러 샘플을 채취한 후 그것들을 블렌딩하는 것인데(대표적으로 SSAA, MSAA 등), N개 샘플을 사용하는 안티 에일리어싱 기법을 수행할 때 Forward Shading은 N개의 색상 샘플을 필요로 하지만(x N), Deferred Shading에서는 G-Buffer의 각 컴포넌트마다 N개의 샘플을 필요로 한다(x N x Count of G-Buffers). 따라서 메모리 비용과 계산량이 증가하게 된다. 이러한 성능 한계를 극복하기 위해 FXAA나 SMAA등의 Morphological한 포스트 프로세스 기반 안티 에일리어싱을 이용하거나(TAA를 포함해서), MSAA와 같은 N-샘플 기법을 사용하더라도 외곽선 추출(Edge Detection)을 이용해 외곽선 근처 픽셀들만 적용하는 등의 방법을 사용한다.
상기한 단점들에도 불구하고, 디퍼드 쉐이딩은 이미 많은 렌더링 엔진에서 사용하고 있는 기법이다. 성능을 중요시하는 실시간 렌더링 분야에서 지오메트리 패스와 라이팅 패스를 완전히 분할한다는 것은 최적화의 측면에서 상당한 이점을 얻을 수 있기 때문이다. 물론 상대적으로 적은 메모리 자원을 가지는 모바일 렌더링 환경에서는 Deferred Rendering의 사용에 대해 많은 고민이 필요하다.