래스터화 — 3차원을 화면에 칠하는 가장 빠른 방법
서론
3D Gaussian Splatting 글에서 속도 차이를 이렇게 한 줄로 정리했습니다.
광선 마칭 대신 래스터화. 이 한 줄이 속도 차이의 대부분을 설명합니다.
이 글은 그 한 줄을 풀어 씁니다. 앞의 SfM과 MVS가 사진에서 3차원을 얻는 이야기였다면, 지금부터 세 편은 반대 방향 — 가지고 있는 3차원을 화면에 그리는 이야기입니다.
1. 렌더링 루프는 두 가지뿐이다
3차원 장면을 2차원 이미지로 만드는 일은, 결국 프리미티브와 픽셀을 짝지어 주는 일입니다. 짝짓는 순서를 뒤집으면 완전히 다른 두 알고리즘이 나옵니다.
1
2
3
4
5
래스터화 광선 추적
-------------------- --------------------------
for 프리미티브: for 픽셀:
for 덮는 픽셀: for 광선이 만나는 프리미티브:
색을 칠한다 교차를 찾는다
둘 다 같은 답을 낼 수 있습니다. 그런데 성능이 완전히 다릅니다.
래스터화가 빠른 이유는 알고리즘이 영리해서가 아니라, 하드웨어가 좋아하는 순서이기 때문입니다.
- 프리미티브 하나를 처리하는 동안 그 정점 데이터는 캐시에 그대로 있습니다. 픽셀마다 장면 전체를 다시 훑는 광선 추적은 그렇지 않습니다.
- 삼각형 하나가 덮는 픽셀들은 완전히 독립적입니다. 수천 개를 동시에 처리해도 서로 간섭하지 않습니다. GPU는 이런 계산을 하려고 만들어진 물건입니다.
- 분기가 거의 없습니다. 광선 추적은 “이 광선이 어디에 부딪히나”에 따라 이후 실행 경로가 갈라지는데, 같은 워프 안의 스레드들이 서로 다른 길로 가면 GPU는 그만큼 손해를 봅니다.
정리하면 이렇습니다. 래스터화의 비용은 프리미티브 수와 그것이 덮는 픽셀 수에 비례하고, 장면의 복잡도와 무관합니다. 이 성질이 3DGS를 실시간으로 만들어 준 것입니다.
2. 좌표 변환 — 화면에 오기까지
프리미티브를 칠하려면 먼저 그것이 화면 어디에 있는지 알아야 합니다. 정점 하나가 거치는 경로는 표준화돼 있습니다.
정점 하나가 픽셀 좌표에 도달하기까지. 앞의 세 단계는 행렬 곱이고, 마지막 하나만 나눗셈이다.
여기서 중요한 관찰이 하나 있습니다. 원근이 생기는 것은 행렬 곱이 아니라 마지막 나눗셈입니다. 행렬은 아무리 곱해도 선형 변환이라 평행선을 평행선으로 보냅니다. 멀리 있는 것이 작아지고 평행선이 한 점에 모이는 현상은 전부 $w$ 로 나누는 순간에 일어납니다.
$w$ 에 원근 정보를 담아 두었다가 마지막에 한 번 나눈다 — 이게 동차 좌표의 존재 이유입니다. 컴퓨터 비전에서 $P = K[R \mid t]$ 를 쓰는 것과 그래픽스에서 $PVM$ 을 쓰는 것은 관례가 다를 뿐 같은 구조입니다.
왜 나눗셈 전에 “클립 좌표”라는 단계가 따로 있나. 카메라 뒤에 있는 정점은 $w < 0$ 입니다. 그대로 나누면 부호가 뒤집혀 화면 반대편에 멀쩡한 모습으로 나타납니다. 그래서 나누기 전에, 아직 동차 좌표인 상태에서 $-w \le x, y, z \le w$ 조건으로 잘라 냅니다. 이게 클리핑입니다. 나눗셈이 비가역적인 연산이라 순서를 바꿀 수 없습니다.
3. 삼각형을 픽셀로 바꾸기
화면 좌표까지 왔으면 이제 세 가지 질문에 답하면 끝입니다.
① 어느 픽셀이 삼각형 안인가 ② 겹치면 누가 앞인가 ③ 그 픽셀의 색·깊이·좌표는 얼마인가. 모두 실제로 계산해 그린 것이다.
3.1 안과 밖 — 엣지 함수
변 $\overline{ab}$ 에 대해 다음 값을 정의합니다.
\[E_{ab}(p) = (b_x - a_x)(p_y - a_y) - (b_y - a_y)(p_x - a_x)\]이건 벡터 $b-a$ 와 $p-a$ 의 외적의 $z$ 성분이고, 부호는 $p$ 가 변의 어느 쪽에 있는지를 말해 줍니다. 삼각형의 세 변에 대해 부호가 전부 같으면 안쪽입니다.
\[E_{v_0v_1}(p) \ge 0 \;\wedge\; E_{v_1v_2}(p) \ge 0 \;\wedge\; E_{v_2v_0}(p) \ge 0\]이 함수가 표준이 된 이유는 1차 함수라서 증분 계산이 되기 때문입니다.1 픽셀을 오른쪽으로 하나 옮기면 $E$ 는 상수만큼 변합니다. 곱셈 없이 덧셈만으로 한 줄을 훑을 수 있고, 정수 산술로 처리할 수 있어 경계 판정이 인접 삼각형과 정확히 일치합니다(틈이 생기지 않습니다).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import numpy as np
def edge(a, b, p):
"""p가 변 ab의 어느 쪽에 있는가. 부호가 곧 답이고, 크기는 넓이의 두 배다."""
return (b[0]-a[0])*(p[..., 1]-a[1]) - (b[1]-a[1])*(p[..., 0]-a[0])
def rasterize(V, Z, attr, W, H, zbuf, out):
"""V: (3,2) 화면 좌표, Z: (3,) 뷰 공간 깊이, attr: (3,k) 정점 속성."""
lo = np.maximum(np.floor(V.min(0)).astype(int), 0) # 경계 상자만 훑는다
hi = np.minimum(np.ceil(V.max(0)).astype(int) + 1, [W, H])
ys, xs = np.mgrid[lo[1]:hi[1], lo[0]:hi[0]]
p = np.stack([xs + 0.5, ys + 0.5], -1) # 픽셀 중심
area = edge(V[0], V[1], V[2][None])[0]
if area == 0:
return
l0 = edge(V[1], V[2], p) / area # 무게중심 좌표
l1 = edge(V[2], V[0], p) / area
l2 = 1.0 - l0 - l1
inside = (l0 >= 0) & (l1 >= 0) & (l2 >= 0)
inv_z = l0/Z[0] + l1/Z[1] + l2/Z[2] # 1/z 는 화면에서 선형
z = 1.0 / np.where(inv_z == 0, 1e-9, inv_z)
win = inside & (z < zbuf[lo[1]:hi[1], lo[0]:hi[0]]) # z-버퍼 테스트
# 원근 보정 보간: 속성/z 를 보간한 뒤 1/z 로 나눈다
num = (l0[..., None]*attr[0]/Z[0] + l1[..., None]*attr[1]/Z[1]
+ l2[..., None]*attr[2]/Z[2])
val = num / inv_z[..., None]
zbuf[lo[1]:hi[1], lo[0]:hi[0]][win] = z[win]
out[lo[1]:hi[1], lo[0]:hi[0]][win] = val[win]
3.2 누가 앞인가 — z-버퍼
픽셀마다 지금까지 본 것 중 가장 가까운 깊이를 하나 저장해 두고, 새 프리미티브가 그보다 가까울 때만 덮어씁니다.
이 단순한 장치의 힘은 순서에 의존하지 않는다는 데 있습니다. 삼각형을 아무 순서로 던져 넣어도 결과가 같습니다. 정렬이 필요 없고, 메모리는 화면 크기만큼만 있으면 됩니다. 프리미티브가 백만 개든 1억 개든 z-버퍼는 그대로입니다.
단, 불투명할 때만 그렇습니다. 뒤에 있는 것이 완전히 가려진다는 전제가 있어야 “가장 가까운 것 하나”만 기억해도 되니까요. 반투명이 섞이는 순간 이 전제가 깨지고, 그때부터는 정렬이 필요해집니다. 이 이야기가 다음 글의 주제이고, 3DGS가 z-버퍼를 쓰지 않는 이유이기도 합니다.
3.3 그 픽셀의 값은 — 무게중심 좌표와 원근 보정
삼각형 안의 점은 세 꼭짓점의 가중 평균으로 쓸 수 있습니다.
\[p = \lambda_0 v_0 + \lambda_1 v_1 + \lambda_2 v_2, \qquad \lambda_0+\lambda_1+\lambda_2 = 1\]그리고 $\lambda_i$ 는 엣지 함수를 넓이로 나눈 값과 정확히 같습니다. 즉 안팎 판정을 하면서 보간 가중치가 공짜로 따라 나옵니다.
그런데 이 가중치를 색이나 텍스처 좌표에 그대로 쓰면 틀립니다. 위 그림 ③의 왼쪽이 그 결과입니다. 이유는 이렇습니다.
화면 좌표는 이미 $w$ 로 나눠진 값입니다. 화면에서 등간격으로 움직여도 3차원 공간에서는 멀수록 더 많이 움직입니다. 그러니 화면에서 선형인 것은 속성 $u$ 가 아니라 $u/w$ 입니다.
정확한 공식은 이렇습니다.2
\[u(p) = \frac{\displaystyle\sum_i \lambda_i \frac{u_i}{w_i}}{\displaystyle\sum_i \frac{\lambda_i}{w_i}}\]깊이만은 예외입니다. $1/w$ 자체가 화면에서 선형이므로, z-버퍼에는 $z$ 대신 $1/z$ 를 저장하고 그냥 선형 보간하면 됩니다. 위 코드에서 inv_z를 보간한 뒤 뒤집는 것이 그 이유입니다.
4. GPU가 실제로 하는 일 — 타일
실제 GPU 래스터라이저는 여기에 한 겹을 더 얹습니다. 화면을 작은 타일(예: 16×16 픽셀)로 나눠 두고, 프리미티브를 픽셀이 아니라 타일 단위로 먼저 배분합니다.
이유는 메모리입니다. 프레임버퍼와 z-버퍼 전체를 매번 오가면 대역폭이 감당이 안 됩니다. 타일 하나 분량은 GPU 내부의 빠른 메모리에 다 들어가므로, 그 타일에 걸친 프리미티브를 전부 처리한 다음 결과를 한 번만 밖으로 씁니다. 모바일 GPU는 거의 전부 이 방식이고, 데스크톱 GPU도 내부적으로 비슷한 일을 합니다3
3DGS의 래스터라이저가 타일 기반인 것도 정확히 같은 이유입니다. 다만 3DGS는 여기에 한 가지를 더 합니다 — 타일마다 걸친 가우시안 목록을 만들고 깊이순으로 정렬합니다. z-버퍼로는 반투명을 처리할 수 없기 때문입니다. 정렬 비용이 붙지만, 타일 하나에 걸리는 가우시안은 많아야 수백 개라 GPU 기수 정렬로 감당이 됩니다4
5. 래스터화가 못 하는 것
래스터화는 “각 프리미티브가 화면 어디에 보이는가”만 답합니다. 그래서 다른 프리미티브를 알아야 답할 수 있는 질문에는 원리적으로 약합니다.
| 현상 | 왜 어려운가 |
|---|---|
| 그림자 | 광원에서 보이는지를 알아야 한다 → 별도 패스(섀도 맵)가 필요 |
| 거울 반사 | 반사된 방향에 무엇이 있는지 알아야 한다 |
| 굴절, 전역 조명 | 빛이 여러 번 튄 경로를 따라가야 한다 |
| 반투명 겹침 | z-버퍼로 안 되고, 정렬이 필요하다 |
앞의 셋은 광선 추적이 자연스럽게 푸는 것들이고, 그래서 현대 게임 엔진은 래스터화를 기본으로 하되 필요한 효과에만 광선 추적을 섞습니다.
마지막 항목이 우리에게 중요합니다. 3DGS의 가우시안은 전부 반투명합니다. 그래서 3DGS의 래스터라이저는 z-버퍼를 버리고 정렬 + 알파 블렌딩으로 갑니다.
6. 3DGS는 이 중 무엇을 가져갔나
| 고전 래스터화 | 3DGS |
|---|---|
| 프리미티브 = 삼각형 | 프리미티브 = 화면에 투영된 2D 가우시안 |
| 안팎 판정 = 엣지 함수 (0 또는 1) | 부드러운 감쇠 $\exp(-\frac12 d^\top \Sigma’^{-1} d)$ |
| 깊이 해결 = z-버퍼 | 깊이순 정렬 + 알파 블렌딩 |
| 속성 보간 = 무게중심 좌표 | 가우시안 하나가 색·불투명도를 통째로 들고 있음 |
| 타일 단위 처리 | 동일 (16×16 타일) |
| 미분 불가능 (안팎이 계단 함수) | 미분 가능 (지수 감쇠라 매끄러움) |
마지막 줄이 핵심입니다. 고전 래스터화에서 삼각형의 경계는 계단입니다. 정점을 아주 조금 움직여도 픽셀은 덮이거나 안 덮이거나 둘 중 하나로 튑니다. 미분이 0 아니면 정의되지 않으므로, 기울기를 흘려보낼 수가 없습니다.
가우시안은 경계가 없습니다. 중심에서 멀어질수록 부드럽게 옅어질 뿐입니다. 그래서 가우시안을 조금 옮기면 픽셀 값도 조금 변하고, 그 변화율이 곧 기울기가 됩니다. 1편에서 본 역전파를 렌더러 전체에 흘릴 수 있게 되는 것입니다. 이 이야기는 8편에서 제대로 다룹니다.
7. 자주 나오는 질문들
Q1. 삼각형이 아니라 왜 하필 가우시안인가?
3DGS 글에서 짧게 짚었지만 여기서 다시 정리하면 세 가지입니다. 점은 크기가 없어 화면에 구멍이 생기고, 삼각형 메시는 최적화 중에 위상을 바꾸기가 까다롭습니다(꼭짓점을 옮기다 보면 뒤집히거나 자기를 뚫습니다). 가우시안은 경계가 부드럽고, 위상이라는 개념이 아예 없고, 3차원 가우시안을 투영해도 여전히 가우시안입니다. 마지막 성질 덕분에 화면에서의 모양을 2×2 공분산 하나로 정확히 쓸 수 있습니다.
Q2. 프리미티브가 수백만 개면 래스터화도 느려지지 않나?
느려집니다. 래스터화의 비용은 대략 프리미티브 수 + 총 덮인 픽셀 수입니다. 3DGS가 가우시안 개수를 무한정 늘리지 않고 불투명도가 낮은 것을 주기적으로 지우는 이유 중 하나가 이것입니다. 화면을 거의 다 덮는 거대한 가우시안 몇 개가 생기면 두 번째 항이 폭발합니다.
Q3. z-버퍼가 있는데 왜 여전히 정렬 이야기가 나오나?
z-버퍼는 가장 가까운 것 하나만 남기는 장치입니다. 뒤에 있는 것이 완전히 가려질 때만 옳습니다. 반투명 프리미티브는 뒤에 있는 것도 결과에 기여하므로 “누가 이겼는가”가 아니라 “어떤 순서로 섞을 것인가“가 문제가 됩니다. 그리고 섞는 연산이 순서에 의존하기 때문에 정렬을 피할 수 없습니다.
Q4. 안티에일리어싱은 어디에 들어가나?
엣지 함수는 픽셀 중심 하나만 검사하므로 경계가 계단으로 나옵니다. 표준 해법은 픽셀당 여러 지점을 검사하는 것(MSAA)이거나, 아예 크게 그린 뒤 줄이는 것(SSAA)입니다. 흥미롭게도 3DGS에는 이 문제가 훨씬 덜합니다. 가우시안의 가장자리가 원래 부드럽게 옅어지기 때문에, 계단이 생길 경계 자체가 없습니다.
마치며
- 렌더링 루프는 두 가지뿐이다. 프리미티브 바깥 루프(래스터화) 가 하드웨어 친화적이라 빠르다.
- 원근은 행렬이 아니라 $w$ 로 나누는 한 번의 나눗셈에서 생긴다. 그래서 클리핑은 나눗셈 전에 해야 한다.
- 삼각형 래스터화는 엣지 함수(안팎) + z-버퍼(가림) + 무게중심 좌표(보간) 세 가지로 끝난다.
- 화면에서 선형인 것은 속성이 아니라 속성 $/w$ 다. 이걸 빼먹으면 텍스처가 휜다.
- GPU는 타일 단위로 처리해 메모리 대역폭을 아낀다. 3DGS의 래스터라이저도 마찬가지다.
- 래스터화는 불투명을 전제로 한다. 이 전제가 깨지면 z-버퍼를 정렬과 알파 블렌딩으로 바꿔야 한다.
그 “정렬과 알파 블렌딩”이 다음 글입니다. 그리고 거기서 볼륨 렌더링 적분이 사실 같은 식이라는 것도 함께 확인합니다.
이 시리즈 — 3DGS를 읽기 위한 기초지식
- MLP와 역전파
- SGD에서 Adam까지
- SfM 파이프라인
- MVS — 밀집 복원
- 래스터화 ← 지금 읽는 글
- 알파 블렌딩과 볼륨 렌더링
- 구면 조화 함수
- 미분 가능 렌더링
- NeRF
- 3D Gaussian Splatting ← 도착지
참고문헌
Pineda, J. (1988). A parallel algorithm for polygon rasterization. SIGGRAPH ‘88, 17–20. — 엣지 함수 기반 래스터화의 원형. ↩︎
Heckbert, P., & Moreton, H. (1991). Interpolation for polygon texture mapping and shading. State of the Art in Computer Graphics. — 원근 보정 보간. ↩︎
Akenine-Möller, T., Haines, E., & Hoffman, N. (2018). Real-Time Rendering (4th ed.). CRC Press. — 2장 그래픽스 파이프라인, 23장 하드웨어. 타일 기반 렌더링 설명이 여기 있다. ↩︎
Kerbl, B., Kopanas, G., Leimkühler, T., & Drettakis, G. (2023). 3D Gaussian Splatting for Real-Time Radiance Field Rendering. ACM TOG, 42(4). — 6절이 타일 래스터라이저 구현. ↩︎