컴퓨터공학 300 주제 시리즈의 284번째 글이다. 전체 지도는 여기.

한 줄 요약

실시간 3D 렌더링은 정점 처리 → 기본 도형 조립 → 래스터화 → 프래그먼트 처리 → 출력 병합의 고정된 흐름으로 삼각형을 픽셀로 바꾼다. 프로그래머는 이 중 정점·프래그먼트 단계를 셰이더로 채운다.

왜 필요한가

게임, 지도 앱, 웹의 3D 뷰어, 브라우저의 페이지 합성까지 GPU 위에서 같은 구조로 그려진다. 파이프라인을 알면 다음이 보인다.

  • 화면이 느릴 때 병목이 정점 수인지, 픽셀 수인지, 상태 변경인지
  • 투명한 물체를 왜 뒤에서부터 정렬해 그려야 하는지
  • 셰이더 코드가 “한 번” 도는 게 아니라 정점마다, 픽셀마다 수백만 번 도는 이유
  • WebGL·Vulkan·Direct3D·Metal 의 API 가 달라도 개념이 같은 이유

핵심 개념

전체 흐름

OpenGL·Vulkan·Direct3D 의 명세는 세부가 다르지만, 래스터화 기반 그래픽스 파이프라인은 다음 단계로 요약된다.

 정점 데이터(버퍼)
      |
 [1] 입력 조립      정점 버퍼에서 위치·법선·UV 등을 읽는다
      |
 [2] 정점 셰이더    정점마다 MVP 변환 -> 클립 좌표 출력      (프로그래머가 작성)
      |
 (선택) 테셀레이션·지오메트리 셰이더
      |
 [3] 도형 조립·클리핑  정점 3개를 삼각형으로 묶고, 시야 밖은 잘라낸다
      |              원근 나눗셈 -> NDC -> 뷰포트 변환 -> 화면 좌표
      |              뒷면 제거(back-face culling)
 [4] 래스터화        삼각형이 덮는 픽셀(프래그먼트)을 찾고 속성을 보간한다
      |
 [5] 프래그먼트 셰이더 프래그먼트마다 색을 계산한다          (프로그래머가 작성)
      |
 [6] 출력 병합       깊이·스텐실 테스트, 블렌딩 -> 프레임버퍼
      |
   화면

정점 셰이더

정점 하나를 입력받아 클립 공간 위치를 출력한다. 앞 글에서 본 P * V * M * position 계산이 여기서 일어난다. 정점별 색이나 텍스처 좌표 같은 값도 다음 단계로 넘긴다. 정점끼리는 서로를 모른다. 그래서 GPU 가 수천 개를 동시에 처리할 수 있다.

// WebGL(GLSL ES) 정점 셰이더의 골격
attribute vec3 aPosition;
attribute vec2 aUV;
uniform mat4 uMVP;
varying vec2 vUV;
void main() {
  vUV = aUV;
  gl_Position = uMVP * vec4(aPosition, 1.0);
}

래스터화와 보간

래스터화기는 화면 좌표의 삼각형을 받아 그 안에 중심이 들어오는 픽셀을 찾는다. 흔한 방법은 변 함수(edge function)다. 세 변 각각에 대해 점이 어느 쪽에 있는지 부호를 계산하고, 셋 다 같은 쪽이면 내부다. 이 값들을 정규화하면 무게중심 좌표(barycentric coordinates)가 되고, 정점 속성을 섞는 가중치로 그대로 쓴다.

래스터화의 출력 단위를 프래그먼트라 한다. 프래그먼트는 “아직 픽셀이 될지 정해지지 않은 후보”다. 깊이 테스트에서 떨어지면 버려진다.

원근 투영된 삼각형에서 텍스처 좌표를 화면 공간에서 그냥 선형 보간하면 무늬가 휘어 보인다. GPU 는 속성을 w 로 나눈 값을 보간한 뒤 다시 보정하는 원근 보정 보간을 한다.

프래그먼트 셰이더

프래그먼트마다 최종 색을 계산한다. 텍스처 조회, 조명 계산, 그림자 판정이 여기서 일어난다. 1920x1080 화면 전체를 덮는 삼각형이면 이 셰이더가 약 200만 번 실행된다. 화면에 겹쳐 그려지는 횟수(overdraw)만큼 더 늘어난다. 고해상도에서 프레임이 떨어지면 대개 이 단계가 병목이다.

깊이 버퍼와 출력 병합

여러 삼각형이 같은 픽셀을 덮으면 누가 보이는가. 깊이 버퍼(z-buffer)는 픽셀마다 지금까지 그린 것 중 가장 가까운 깊이를 저장한다. 새 프래그먼트가 더 가까울 때만 색과 깊이를 덮어쓴다. 덕분에 불투명한 물체는 순서에 상관없이 그려도 결과가 맞다.

투명한 물체는 다르다. 블렌딩은 “이미 그려진 색”과 섞는 연산이라 순서에 의존한다. 그래서 엔진은 불투명 물체를 먼저 그리고, 반투명 물체는 카메라에서 먼 것부터 정렬해 그린다.

고정 단계와 프로그래머블 단계

단계 누가 정하나
입력 조립, 클리핑, 원근 나눗셈, 래스터화 하드웨어 고정 기능(설정만 가능)
정점·프래그먼트 셰이더 프로그래머 코드
깊이 테스트, 블렌딩 고정 기능(방식 선택 가능)

래스터화 대신 광선을 쏘아 교차를 찾는 레이 트레이싱은 다른 방식의 렌더링이다. 최근 GPU 는 둘을 섞어 쓰는 경우가 많지만, 기본 골격은 위 파이프라인이다.

직접 해 보기

변 함수 래스터화, 무게중심 보간, 깊이 테스트를 30줄로 구현한다. 두 삼각형이 서로를 관통하도록 깊이를 주었다.

W, H = 24, 12
color = [["." for _ in range(W)] for _ in range(H)]
depth = [[float("inf")] * W for _ in range(H)]   # 깊이 버퍼: 가까울수록 작은 값

def edge(a, b, px, py):
    # 점 p 가 선분 a->b 의 어느 쪽에 있는지 (부호 있는 넓이의 2배)
    return (b[0] - a[0]) * (py - a[1]) - (b[1] - a[1]) * (px - a[0])

def draw_triangle(v0, v1, v2, ch):
    area = edge(v0, v1, v2[0], v2[1])
    for y in range(H):
        for x in range(W):
            px, py = x + 0.5, y + 0.5                # 픽셀 중심에서 표본
            w0, w1, w2 = edge(v1, v2, px, py), edge(v2, v0, px, py), edge(v0, v1, px, py)
            if (w0 >= 0 and w1 >= 0 and w2 >= 0) or (w0 <= 0 and w1 <= 0 and w2 <= 0):
                b0, b1, b2 = w0 / area, w1 / area, w2 / area   # 무게중심 좌표
                z = b0 * v0[2] + b1 * v1[2] + b2 * v2[2]       # 깊이 보간
                if z < depth[y][x]:                            # 깊이 테스트
                    depth[y][x] = z
                    color[y][x] = ch                           # "프래그먼트 셰이더" 결과

# (x, y, z) 화면 좌표. A 는 왼쪽이 가깝고 오른쪽이 멀다. B 는 일정한 깊이 5.
draw_triangle((1, 1, 1), (20, 2, 9), (4, 11, 1), "A")
draw_triangle((8, 0, 5), (23, 6, 5), (8, 11, 5), "B")
print("\n".join("".join(r) for r in color))

실행 결과다.

........B...............
.AAAAAAAAAAB............
.AAAAAAAAAABBBAAAAA.....
..AAAAAAAAABBBBBB.......
..AAAAAAAAABBBBBBBB.....
..AAAAAAAAAABBBBBBBBBB..
...AAAAAAAAABBBBBBBBBB..
...AAAAAAABBBBBBBBB.....
...AAAAABBBBBBBB........
....AAA.BBBBB...........
....A...BB..............
........................

A 가 먼저 그려졌지만 B 와 겹치는 곳에서 결과가 갈린다. A 의 왼쪽(깊이 1 근처)은 B(깊이 5)보다 가까워 A 가 남고, 오른쪽으로 갈수록 A 가 멀어져 B 가 이긴다. 2행 오른쪽 끝에 다시 A 가 보이는 것은 그 부분에 B 가 없기 때문이다. 물체 단위 정렬로는 이런 관통을 표현할 수 없다. 픽셀 단위 깊이 테스트라서 가능하다.

GPU 는 이 이중 루프를 수천 개 코어에서 병렬로 돈다. 바운딩 박스만 검사하는 최적화와 타일 단위 처리도 한다.

현업에서는

  • 렌더링 성능을 볼 때 “드로우 콜 수”, “정점 수”, “해상도·overdraw”를 따로 측정한다. 해상도를 절반으로 낮췄을 때 프레임이 크게 오르면 프래그먼트 단계가 병목이다.
  • 브라우저도 페이지의 레이어를 GPU 로 합성한다. CSS transform 과 opacity 애니메이션이 부드러운 이유는 레이아웃을 다시 하지 않고 합성 단계에서 처리되기 때문이다.
  • 머신러닝 추론이 GPU 에서 빠른 이유도 이 구조와 같다. 서로 독립적인 수많은 작은 계산을 동시에 처리하도록 설계된 하드웨어다. 홈랩 k3s 클러스터에 GPU 노드를 붙여도 쓰임새는 대개 렌더링이 아니라 이런 병렬 연산이다.
  • 웹에서 3D 를 다룰 때는 WebGL 이나 WebGPU 를 쓴다. API 는 다르지만 정점·프래그먼트 셰이더와 깊이 버퍼라는 골격은 같다.

확인 문제

  1. 정점 셰이더와 프래그먼트 셰이더는 각각 무엇을 입력받아 무엇을 출력하는가?
  2. 불투명 물체는 그리는 순서와 상관없이 결과가 맞는데 반투명 물체는 그렇지 않은 이유는?
  3. 해상도를 4K 로 올렸더니 프레임이 크게 떨어졌다. 어느 단계를 먼저 의심해야 하는가?
  4. 무게중심 좌표는 래스터화에서 어떤 두 가지 용도로 쓰이는가?

풀이

  1. 정점 셰이더는 정점 하나의 속성을 받아 클립 좌표 위치와 보간할 값을 낸다. 프래그먼트 셰이더는 보간된 값을 받아 그 프래그먼트의 색(과 선택적으로 깊이)을 낸다.
  2. 깊이 테스트는 순서와 무관하게 가장 가까운 것을 남기지만, 블렌딩은 이미 그려진 색과 섞는 연산이라 순서가 바뀌면 결과가 달라진다.
  3. 픽셀 수에 비례하는 프래그먼트 셰이더와 메모리 대역폭(출력 병합)이다.
  4. 픽셀이 삼각형 내부인지 판정하는 데, 그리고 정점 속성(깊이·색·UV)을 보간하는 가중치로 쓰인다.

더 읽을거리 (References)