본문으로 건너뛰기
HY Devlog
뒤로 가기

그래픽스 파이프라인 정리: 정점이 픽셀이 되기까지

화면에 삼각형 하나를 그린다는 건 생각보다 여러 단계를 거친다. 정점 좌표를 넘기면 GPU가 알아서 그려주는 것처럼 보이지만, 그 사이에는 정해진 순서의 공정이 있고 각 단계가 무엇을 책임지는지 알아야 성능 문제가 어디서 나는지도 짚을 수 있다.

한국어로 이 주제를 찾으면 대체로 다섯 단계쯤으로 요약한 글이 나온다. 틀린 건 아니지만 결정적인 단계 몇 개가 빠져 있는 경우가 많다. 정점 셰이더의 출력이 곧바로 화면 좌표인 것처럼 설명되거나, 깊이 테스트와 블렌딩을 담당하는 마지막 단계가 통째로 없거나 한다.

그래서 전체를 순서대로 정리했다. 기준은 Direct3D·Vulkan·OpenGL 공식 문서다.

목차

목차

왜 “파이프라인”인가

3D 컴퓨터 그래픽스에서 그래픽스 파이프라인은 3차원 기하를 2차원 래스터 이미지로 바꾸는 단계적 절차다. 렌더링 파이프라인이라고도 한다.

“파이프라인”이라는 이름은 공장의 조립 라인에서 왔다. 한 정점이 정점 셰이더를 통과해 다음 단계로 넘어가면, 정점 셰이더는 놀지 않고 바로 다음 정점을 받는다. 단계를 나눠두면 서로 다른 데이터가 서로 다른 단계에서 동시에 처리된다. GPU가 수천 개의 코어로 한 번에 밀어붙일 수 있는 이유가 여기에 있다.

각 단계는 두 종류로 나뉜다.

전체 단계

Direct3D의 이름을 기준으로 하고, OpenGL·Vulkan 쪽 대응 이름을 같이 적었다.

#단계종류OpenGL·Vulkan
1Input Assembler (IA)고정Vertex Specification
2Vertex Shader (VS)프로그래머블Vertex Shader
3Hull Shader (HS)프로그래머블Tessellation Control Shader
4Tessellator고정Tessellation Primitive Generator
5Domain Shader (DS)프로그래머블Tessellation Evaluation Shader
6Geometry Shader (GS)프로그래머블Geometry Shader
7Stream Output (SO)고정Transform Feedback
8정점 후처리고정Vertex Post-Processing
9Rasterizer (RS)고정Rasterization
10Pixel Shader (PS)프로그래머블Fragment Shader
11Output Merger (OM)고정Per-Sample Operations

흐름으로 보면 이렇다. 번호는 위 표와 같고, ← 생략 가능이 붙은 것이 건너뛸 수 있는 단계다.

버퍼


[1] Input Assembler ──── 프리미티브 조립


[2] Vertex Shader ────── 정점당 1회, 클립 공간으로 변환

 ├─▶ [3-5] 테셀레이션 (Hull → Tessellator → Domain)   ← 생략 가능

 ├─▶ [6] Geometry Shader ── 프리미티브 생성·수정      ← 생략 가능

 ├─▶ [7] Stream Output ──── 결과를 메모리로 회수      ← 생략 가능


[8] 정점 후처리 ───────── 클리핑 → 퍼스펙티브 분할 → 뷰포트 변환


[9] Rasterizer ───────── 프래그먼트 생성, 속성 보간


[10] Pixel / Fragment Shader ── 프래그먼트 색 결정


[11] Output Merger ───── 깊이·스텐실 테스트, 블렌딩


렌더 타깃

각 단계

1. Input Assembler — 고정

버텍스 버퍼와 인덱스 버퍼에서 데이터를 읽어 프리미티브(점·선·삼각형)로 조립해 다음 단계로 넘긴다.

인덱스 버퍼가 여기서 일한다. 정육면체의 한 꼭짓점은 세 면이 공유하는데, 정점 데이터를 세 벌 두는 대신 정점은 한 벌만 두고 인덱스로 참조하면 메모리도 줄고 정점 셰이더 호출도 줄어든다.

2. Vertex Shader — 프로그래머블

정점 하나당 정확히 한 번 실행된다. 변환, 스키닝, 모핑, 정점 단위 조명 같은 정점 단위 연산을 여기서 한다.

가장 흔한 일은 좌표 변환이다. 보통 세 개의 행렬을 차례로 곱한다.

행렬은 공간이 아니라 변환이다. “모델 행렬 = 모델이 놓인 공간”처럼 설명한 자료를 종종 보는데, 정확히는 한 공간에서 다른 공간으로 옮기는 사상이다. 이 구분을 흐리면 다음 단계가 왜 필요한지 설명되지 않는다.

그리고 중요한 지점 — 정점 셰이더의 출력은 화면 좌표가 아니다. 투영 행렬을 곱한 결과는 w 성분이 살아 있는 동차 좌표계의 클립 공간이다. 화면 좌표가 되려면 8번 단계를 더 거쳐야 한다.

3~5. 테셀레이션 — Hull → Tessellator → Domain

한국어 요약 글에서 가장 자주 통째로 빠지는 구간이다. 세 단계가 한 묶음으로 동작하며, 낮은 디테일의 표면을 GPU에서 잘게 쪼개 높은 디테일의 프리미티브로 바꾼다.

지형이나 캐릭터를 카메라 거리에 따라 동적으로 세분할 때 쓴다. 저해상도 메시만 보내고 디테일은 GPU에서 만들어내므로 대역폭이 절약된다.

6. Geometry Shader — 프로그래머블

프리미티브를 입력으로 받아 정점을 새로 만들어낼 수 있는 셰이더다. 빌보드, 외곽선, 디버그용 노멀 표시 같은 데 쓴다.

다만 실무에서는 조심스럽게 쓰는 편이다. 출력 정점 수가 가변이라 병렬화가 까다롭고, 뒤에 나올 메시 셰이더가 나온 배경 중 하나가 바로 지오메트리 셰이더의 처리 방식이 비효율적이라는 점이었다.

7. Stream Output — 고정

지오메트리 셰이더(또는 지오메트리 셰이더가 없으면 정점 셰이더)의 결과를 화면에 그리는 대신 메모리 버퍼로 되돌려 받는다.

파티클 시뮬레이션처럼 이번 프레임의 GPU 계산 결과를 다음 프레임의 입력으로 다시 쓰고 싶을 때 쓴다. OpenGL에서는 Transform Feedback이라고 부른다.

8. 정점 후처리 — 고정

원문 자료에서 통째로 빠져 있던 단계이자, “정점 셰이더가 화면 좌표를 만든다”는 오해가 생기는 지점이다. 마지막 정점 처리 셰이더가 끝난 뒤 순서대로 이런 일이 일어난다.

  1. 클리핑 — 뷰 볼륨 밖으로 나간 프리미티브를 잘라낸다. 화면 밖 삼각형이 래스터화 비용을 먹지 않는 이유다.
  2. 퍼스펙티브 분할 — 클립 좌표의 x, y, zw로 나눠 **정규화 장치 좌표(NDC)**로 만든다. 멀리 있는 것이 작아 보이는 원근이 실제로 여기서 만들어진다. 투영 행렬은 나눌 준비를 해둔 것이고, 나누는 건 이 단계다.
  3. 뷰포트 변환 — NDC를 뷰포트 설정(위치·너비·높이·깊이 범위)에 따라 실제 프레임버퍼 좌표로 옮긴다.

이 세 가지가 끝나야 비로소 “화면 어디”가 정해진다.

9. Rasterizer — 고정

벡터 정보인 프리미티브를 픽셀로 구성된 래스터 이미지로 변환한다. 삼각형이 덮는 위치마다 프래그먼트를 만들어낸다.

여기서 하나 더 — 래스터라이저는 위치만 정하는 게 아니라 정점 속성을 보간한다. 세 꼭짓점의 UV, 노멀, 색을 삼각형 내부 각 지점에 맞게 섞어서 넘긴다. 프래그먼트 셰이더가 받는 값은 전부 이 보간의 결과다.

“래스터화 단계에서는 색이 표현되지 않는다”는 설명을 종종 보는데, 최종 색을 결정하지 않는 건 맞지만 색을 포함한 속성의 보간은 바로 여기서 일어난다.

10. Pixel Shader / Fragment Shader — 프로그래머블

프래그먼트마다 실행되어 색을 결정한다. 보간된 UV로 텍스처를 샘플링하고, 보간된 노멀로 조명을 계산하고, 노멀 맵을 적용하는 일이 전부 여기서 일어난다.

Direct3D는 픽셀 셰이더, OpenGL과 Vulkan은 프래그먼트 셰이더라고 부른다. 같은 단계이지만 프래그먼트와 픽셀은 같은 말이 아니다.

프래그먼트는 “이 픽셀 자리를 차지하겠다고 나선 후보”다. 벽 뒤에 캐릭터가 있으면 한 픽셀 자리에 프래그먼트가 둘 생기고, MSAA를 켜면 한 픽셀에 여러 샘플이 생긴다. 여러 프래그먼트 중 무엇이 최종 픽셀이 되는지는 다음 단계가 정한다.

11. Output Merger — 고정

파이프라인의 마지막이고, 원문 자료에 아예 없던 단계다. 공식 문서의 표현을 그대로 옮기면 이렇다.

파이프라인 상태, 픽셀 셰이더가 생성한 픽셀 데이터, 렌더 타깃의 내용, 깊이·스텐실 버퍼의 내용을 조합해 최종 렌더링 픽셀 색을 만들어낸다. 어떤 픽셀이 보이는지를 깊이·스텐실 테스트로 결정하고 최종 픽셀 색을 블렌딩하는 마지막 단계다.

즉 다음 두 가지가 여기서 결정된다.

반투명 오브젝트를 뒤에서 앞으로 정렬해 그려야 하는 이유가 이 단계에 있다. 블렌딩은 “이미 렌더 타깃에 있는 색”과 섞는 연산이라 그리는 순서에 결과가 달라진다. 불투명 오브젝트가 순서에 둔감한 것은 깊이 테스트가 순서와 무관하게 가장 가까운 것만 남기기 때문이다.

2020년 이후 달라진 것

이 주제의 한국어 자료는 대체로 2020년 전후에 쓰였다. 그 사이에 파이프라인 자체에 큰 변화가 두 가지 있었다.

메시 셰이더 — 앞단을 통째로 갈아엎는 경로

IA → VS → HS → Tessellator → DS → GS로 이어지던 기하 처리 앞단을 두 단계로 대체하는 새 경로다. Direct3D 스펙의 표현이다.

VS, HS, DS, GS 셰이더 단계가 Amplification Shader와 Mesh Shader로 대체된다. 대략적으로 메시 셰이더가 VS+GS 또는 DS+GS를, 증폭 셰이더가 VS+HS를 대신한다.

핵심 개념은 **메시릿(meshlet)**이다. 기하를 정점 수와 삼각형 수에 상한이 있는 작은 덩어리로 미리 쪼개두는 것인데, 그러면 덩어리 단위로 컬링하고 LOD를 적용할 수 있다. 고정 기능 IA를 거치지 않고 컴퓨트 셰이더처럼 유연하게 기하를 다룬다고 보면 된다.

대신 공짜는 아니다. 기하 툴체인을 메시릿 단위로 재구성해야 한다.

Vulkan은 VK_EXT_mesh_shader로 2022년 9월에 들어왔고, Direct3D 12와의 기능 호환성을 맞추는 데 초점을 뒀다.

Vulkan 파이프라인 — 이제 안 만들어도 된다

2020년 자료에는 이런 설명이 흔하다. “Vulkan은 그래픽스 파이프라인을 변경할 수 없으므로 셰이더를 바꾸거나 파이프라인을 새로 만들어야 한다.”

그때는 맞았다. Vulkan은 셰이더 조합마다 파이프라인 객체를 미리 컴파일해 두는 구조였고, 조합이 늘어날수록 순열이 폭발하는 문제가 있었다.

지금은 선택지가 생겼다. 2023년 3월 31일에 나온 VK_EXT_shader_objectVkShaderEXT라는 단계별로 따로 컴파일되는 셰이더 객체를 도입했다. 모든 상태가 동적이 되고, 셰이더를 임의 조합으로 붙일 수 있다.

다만 파이프라인이 없어진 건 아니다. 공식 블로그의 표현대로 “애플리케이션은 파이프라인만 쓰거나, 셰이더 객체만 쓰거나, 둘을 임의로 섞어 쓸 수 있다.” 즉 대체가 아니라 선택지다.

Unity에서는 어디에 해당하나

ShaderLab에서 HLSL 함수를 어느 단계로 컴파일할지 지정하는 #pragma가 그대로 파이프라인 단계에 대응한다.

#pragma vertex vert       // 정점 셰이더
#pragma fragment frag     // 프래그먼트 셰이더
#pragma geometry geom     // 지오메트리 셰이더
#pragma hull hull         // Hull 셰이더
#pragma domain domain     // Domain 셰이더

일상적으로 쓰는 건 위의 두 줄이다. 나머지 셋은 필요할 때만 붙인다.

파이프라인 단계를 알아두면 성능 문제의 위치를 좁힐 수 있다.

정리

파이프라인을 외울 필요는 없지만, 어느 단계가 무엇을 책임지는지는 알아두면 계속 쓸모가 있다.

그리고 오래된 자료를 볼 때는 빠진 단계가 없는지를 먼저 확인하는 게 좋다. 정점 후처리와 출력 병합이 빠진 설명은 왜 원근이 생기는지, 왜 반투명을 정렬해야 하는지를 설명할 수 없다.


참고

이 글의 출발점이 된 자료는 3D 그래픽스 - 그래픽스 파이프라인이란? (Graphics Pipeline)이다. 단계 구성을 참고했고, 설명과 검증은 위 공식 문서를 기준으로 다시 작성했다.


이 글 공유하기:

이전 글
adb 정리: 안드로이드 디버그 브리지의 구조와 명령어
다음 글
Ext4와 NTFS: 리눅스와 윈도우가 디스크를 다루는 방식