셰이더와 GPU 파이프라인 · 정점 셰이더와 프래그먼트 셰이더 · 이론
셰이더는 두 번 불린다
한 줄 요약
정점 셰이더는 정점마다 한 번 불려 클립 좌표와 보간할 값들을 내놓고, 프래그먼트 셰이더는 픽셀마다 한 번 불려 색을 내놓습니다. 그 사이에서 래스터라이저가 값을 무게중심 좌표로 섞습니다.
왜 이게 필요했나
옛 그래픽 하드웨어는 고정 기능이었습니다. 조명 모델도 텍스처 합성 방식도 하드웨어에 박혀 있어서, 개발자는 정해진 손잡이만 돌릴 수 있었습니다. 새로운 표현을 하려면 하드웨어가 바뀌기를 기다려야 했습니다.
셰이더는 그 두 지점을 프로그램 가능하게 열어 준 것입니다. 정점을 어디로 보낼지, 픽셀을 무슨 색으로 칠할지를 개발자가 쓴 코드가 정합니다. 중요한 것은 아무 데나 연 것이 아니라는 점입니다. 파이프라인의 구조 자체는 그대로 두고, 정점 단계와 프래그먼트 단계만 열었습니다. 그래야 나머지(래스터라이즈, 깊이 판정, 혼합)를 하드웨어가 계속 병렬로 처리할 수 있습니다.
어떻게 동작하나
정점 셰이더는 정점 하나를 받아 정점 하나를 내놓습니다. 입력은 두 종류입니다.
attribute 정점마다 다른 값 — 위치, 법선, UV, 정점 색uniform 드로우 콜 전체에서 같은 값 — MVP 행렬, 광원 방향, 시간출력은 반드시 클립 좌표(gl_Position)를 포함해야 하고, 그 밖에 프래그먼트 단계로 넘길 값들을 varying(요즘 이름으로는 out)으로 내보냅니다. 정점 셰이더는 정점을 만들거나 없앨 수 없습니다. 하나 들어가면 하나 나옵니다. 그래야 하드웨어가 몇 개를 처리할지 미리 알고 병렬로 나눌 수 있습니다.
래스터라이저는 프로그램할 수 없는 고정 단계입니다. 삼각형을 받아 그 안의 픽셀을 골라내고, 각 픽셀에서 세 정점의 varying 을 무게중심 좌표로 섞어 프래그먼트 셰이더에 넘겨 줍니다. 앞 코스에서 직접 만든 것이 바로 이 단계입니다.
프래그먼트 셰이더는 픽셀 하나를 받아 색 하나를 내놓습니다. 입력은 보간된 varying 과 uniform, 그리고 텍스처입니다. 여기서 할 수 있는 특별한 일이 하나 있는데 버리기(discard) 입니다. 이 프래그먼트를 아예 쓰지 않겠다고 선언하면 깊이 버퍼에도 색 버퍼에도 아무것도 남지 않습니다. 나뭇잎이나 철망처럼 구멍이 뚫린 표면을 표현할 때 씁니다.
한 가지 짚어 둘 것은 프래그먼트와 픽셀이 다르다는 점입니다. 프래그먼트는 "어떤 삼각형이 이 픽셀 자리에 만들어 낸 후보" 이고, 같은 픽셀에 프래그먼트가 여러 개 생길 수 있습니다. 그중 어느 것이 최종 픽셀이 될지는 깊이 판정과 혼합이 정합니다.
단계가 이 둘만 있는 것은 아닙니다. 정점과 프래그먼트 사이에 지오메트리 셰이더와 테셀레이션을 끼울 수 있는데, 이것들은 정점을 늘리거나 줄일 수 있습니다. 대신 개수가 실행 중에 정해지므로 하드웨어가 미리 나눠 줄 수 없어 훨씬 느립니다. 그래서 실무에서는 꼭 필요한 곳에만 씁니다. 파이프라인 바깥에 컴퓨트 셰이더도 있는데, 그것은 래스터라이즈와 무관하게 임의의 계산을 GPU 에서 돌리는 통로입니다.
한 가지 더 알아 둘 것은 유니폼과 어트리뷰트를 넘기는 비용입니다. 유니폼은 드로우 콜마다 한 번 설정하면 되지만 드로우 콜 자체가 비싸므로, 같은 셰이더로 물체 천 개를 그릴 때 천 번 유니폼을 바꾸며 천 번 그리는 것은 나쁜 설계입니다. 인스턴싱은 물체마다 다른 값을 어트리뷰트처럼 넘겨 한 번의 드로우 콜로 처리하는 방법이고, 나무나 잔디를 수만 그루 그리는 장면이 그렇게 만들어집니다.
현장에서 만나는 모습
성능을 볼 때 정점 수와 프래그먼트 수를 따로 세는 이유가 여기 있습니다. 정점 셰이더는 정점 수만큼, 프래그먼트 셰이더는 화면을 덮은 넓이만큼 불립니다. 화면을 가득 채우는 삼각형 하나는 정점 셋에 프래그먼트 수십만 개입니다. 그래서 무거운 계산은 가능하면 정점 셰이더로 옮기고 보간에 맡깁니다. 다만 보간은 선형이라, 정규화가 필요한 값(법선 같은 것)은 프래그먼트 쪽에서 다시 정규화해야 합니다.
또 하나는 버리기가 공짜가 아니라는 점입니다. discard 가 있는 셰이더는 깊이를 미리 판정하는 최적화를 쓸 수 없게 만듭니다. 색을 계산해 봐야 이 프래그먼트가 살아남는지 알 수 있기 때문입니다. 그래서 구멍 뚫린 표면은 완전히 불투명한 것보다 비쌉니다.
그리고 셰이더 사이의 계약은 이름과 타입으로 이루어집니다. 정점 셰이더가 out vec2 uv 로 내보내면 프래그먼트 셰이더는 in vec2 uv 로 받습니다. 이 이름이 어긋나면 연결이 끊어지는데, 컴파일은 통과하고 값만 0 으로 들어오는 경우가 있어 찾기 어렵습니다. 요즘 GLSL 은 위치를 숫자로 못박는 방법(layout(location = 0))을 제공해 이 문제를 줄입니다.
또 하나 알아 둘 것은 분기의 비용입니다. GPU 는 수십 개의 프래그먼트를 한 묶음으로 같은 명령을 함께 실행합니다. 묶음 안에서 if 의 결과가 갈리면 양쪽 분기를 모두 실행하고 필요 없는 결과를 버리는 식으로 처리하므로, 조건문이 있어도 빨라지지 않고 오히려 둘 다 도는 비용이 듭니다. 묶음 전체가 같은 쪽으로 가면 그때는 실제로 건너뜁니다. 그래서 셰이더의 조건문은 화면에서 넓게 뭉쳐 갈리도록 설계하는 편이 낫습니다.
다음 실습에서 할 것
정점 셰이더와 프래그먼트 셰이더를 파이썬 함수로 쓰고, 그 사이에 래스터라이저를 놓아 삼각형 하나를 그립니다. uniform 만 바꿔 같은 기하로 다른 그림이 나오는 것을 확인하고, discard 로 구멍을 뚫습니다. 마지막에는 정점 수·삼각형 수·프래그먼트 수를 세어 어느 단계가 몇 번 불렸는지를 숫자로 봅니다.