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

한 줄 요약

3D 그래픽스는 점의 위치를 4x4 행렬로 바꾼다. 동차 좌표를 쓰면 이동·회전·크기 변환과 원근 투영까지 모두 행렬 곱 하나로 표현되고, 곱하는 순서가 결과를 바꾼다.

왜 필요한가

3D 장면의 물체는 각자의 좌표계에서 모델링된다. 의자 모델의 원점은 의자 밑바닥 중심이고, 방 안에 놓으려면 옮기고 돌려야 한다. 그다음 카메라 기준으로 다시 바꾸고, 마지막으로 2D 화면에 투영한다. 이 모든 단계가 행렬이다.

행렬을 모르면 다음 상황에서 막힌다.

  • 회전시켰더니 물체가 제자리에서 돌지 않고 원점을 중심으로 크게 돈다.
  • 셰이더 코드의 projection * view * model * position 이 왜 그 순서인지 모른다.
  • CSS transform: translate(...) rotate(...) 의 순서를 바꾸면 결과가 왜 달라지는지 모른다.

핵심 개념

선형 변환과 3x3 행렬

원점을 고정한 채 공간을 늘리거나 돌리는 변환은 3x3 행렬로 표현된다. z 축 기준 각도 θ 회전은 다음과 같다.

| cosθ  -sinθ  0 |   | x |
| sinθ   cosθ  0 | * | y |
|  0      0    1 |   | z |

크기 변환은 대각 성분에 배율을 둔다. 하지만 이동(translation)은 3x3 행렬로 표현할 수 없다. 이동은 원점을 다른 곳으로 보내는데, 선형 변환은 원점을 원점으로 보내기 때문이다.

동차 좌표(homogeneous coordinates)

해결책은 좌표를 하나 늘리는 것이다. 3D 점 (x, y, z) 를 (x, y, z, 1) 로 쓴다. 그러면 이동도 행렬 곱이 된다.

| 1 0 0 tx |   | x |   | x + tx |
| 0 1 0 ty | * | y | = | y + ty |
| 0 0 1 tz |   | z |   | z + tz |
| 0 0 0 1  |   | 1 |   |   1    |

네 번째 성분 w 의 의미는 이렇다.

  • w = 1: 위치(점). 이동의 영향을 받는다.
  • w = 0: 방향(벡터). 이동의 영향을 받지 않는다. 법선이나 빛의 방향이 여기에 해당한다.
  • 일반적인 w: (x/w, y/w, z/w) 와 같은 점을 뜻한다. 이 나눗셈이 원근 투영의 핵심이다.

합성과 순서

여러 변환을 차례로 적용하려면 행렬을 곱한다. 열벡터 관례(행렬 * 벡터)에서는 오른쪽 행렬이 먼저 적용된다.

M = T * R * S      ->   점 p 에 S(크기) -> R(회전) -> T(이동) 순서로 적용

행렬 곱은 교환법칙이 성립하지 않는다. “돌리고 옮기기”와 “옮기고 돌리기”는 다른 결과다. 물체를 제자리에서 돌리고 싶으면 먼저 회전하고 나중에 이동해야 한다. 이동 후 회전하면 세계 원점을 중심으로 공전한다.

OpenGL·WebGL 문서와 GLSL 은 대체로 열벡터 관례를 쓰고, 행렬을 열 우선(column-major)으로 메모리에 담는다. DirectX 계열 예제는 행벡터 관례를 쓰는 경우가 많다. 두 관례는 전치 관계이므로 곱셈 순서도 반대로 적는다. 라이브러리를 섞어 쓸 때 가장 흔한 버그다.

MVP: 모델, 뷰, 투영

정점 하나가 화면에 가기까지 거치는 표준 변환 체인이다.

객체 좌표 --Model--> 세계 좌표 --View--> 카메라 좌표 --Projection--> 클립 좌표
          --(x/w, y/w, z/w)--> 정규화 장치 좌표(NDC) --Viewport--> 화면 픽셀
행렬 하는 일
Model 물체를 세계 안의 위치·방향·크기로 놓는다
View 카메라가 원점에서 정해진 축을 바라보도록 세계 전체를 반대로 옮긴다
Projection 시야 절두체를 정규 상자로 찌그러뜨리고, 깊이를 w 에 넣는다

셰이더에서 gl_Position = P * V * M * pos; 라고 쓰는 이유가 이것이다. 오른쪽부터 Model, View, Projection 순서로 적용된다.

원근 투영과 w 나눗셈

멀리 있는 물체가 작게 보이는 원근은 “z 로 나누기”다. 하지만 행렬 곱은 나눗셈을 못 한다. 그래서 투영 행렬은 깊이 정보를 w 성분에 복사해 두고, 행렬 곱 뒤에 GPU 가 고정 기능으로 x/w, y/w, z/w 를 계산한다. 이를 원근 나눗셈(perspective divide)이라 한다.

법선 벡터는 다르게 변환한다

크기 변환이 균일하지 않으면(x 만 2배 등) 표면의 법선에 모델 행렬을 그대로 곱하면 법선이 더 이상 표면에 수직이 아니다. 법선에는 모델 행렬 왼쪽 위 3x3 의 역행렬의 전치(inverse transpose)를 곱한다. 회전과 균일 크기 변환만 있다면 원래 행렬과 방향이 같다.

직접 해 보기

순서 의존성과 원근 나눗셈을 숫자로 확인한다. 표준 라이브러리만 쓴다.

import math

def matmul(A, B):
    return [[sum(A[i][k] * B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]

def apply(M, p):
    v = [p[0], p[1], p[2], 1.0]                   # 동차 좌표 w = 1
    r = [sum(M[i][k] * v[k] for k in range(4)) for i in range(4)]
    return tuple(round(x, 3) for x in r)

def translate(tx, ty, tz):
    return [[1, 0, 0, tx], [0, 1, 0, ty], [0, 0, 1, tz], [0, 0, 0, 1]]

def rotate_z(deg):
    c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
    return [[c, -s, 0, 0], [s, c, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]

p = (1, 0, 0)
T, R = translate(5, 0, 0), rotate_z(90)
print("R 후 T (M = T*R):", apply(matmul(T, R), p))
print("T 후 R (M = R*T):", apply(matmul(R, T), p))

def perspective(d):
    # 단순화한 투영: w 에 -z/d 를 넣는다
    return [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, -1 / d, 0]]

for z in (-2, -4, -8):
    x, y, _, w = apply(perspective(1), (1, 1, z))
    print(f"z={z:3d}: clip=({x}, {y}, w={w}) -> 화면=({x / w:.3f}, {y / w:.3f})")

실행 결과다.

R 후 T (M = T*R): (5.0, 1.0, 0.0, 1.0)
T 후 R (M = R*T): (0.0, 6.0, 0.0, 1.0)
z= -2: clip=(1.0, 1.0, w=2.0) -> 화면=(0.500, 0.500)
z= -4: clip=(1.0, 1.0, w=4.0) -> 화면=(0.250, 0.250)
z= -8: clip=(1.0, 1.0, w=8.0) -> 화면=(0.125, 0.125)

점 (1,0,0) 을 “돌린 뒤 옮기면” (5,1,0) 이다. 제자리에서 90도 돌아 (0,1,0) 이 되고 x 로 5 이동했다. “옮긴 뒤 돌리면” (0,6,0) 이다. (6,0,0) 이 원점을 중심으로 돌아갔다. 같은 두 행렬이지만 순서가 다르면 결과가 다르다.

투영 쪽은 거리가 두 배가 될 때 화면 좌표가 절반이 된다. 행렬은 w 만 준비했고, 크기를 줄인 것은 w 나눗셈이다.

현업에서는

  • 웹 프런트엔드의 CSS transform 도 같은 수학이다. CSS Transforms 명세는 transform 목록을 왼쪽부터 곱해 하나의 행렬로 만든다고 정의한다. 그래서 translate(100px) rotate(45deg) 와 rotate(45deg) translate(100px) 는 다른 위치에 그려진다. 브라우저 개발자 도구에서 계산된 값을 보면 matrix(...) 로 합쳐져 있다.
  • 게임 엔진의 씬 그래프는 부모-자식 관계로 행렬을 곱해 내려간다. 자식의 세계 행렬은 부모 세계 행렬 * 자식 로컬 행렬 이다. 캐릭터 손에 든 칼이 팔을 따라 움직이는 원리다.
  • 로봇·AR·지도 소프트웨어도 좌표계 변환이 핵심이다. 좌표계마다 축 방향(오른손·왼손)과 위쪽 축(y-up, z-up)이 다르므로, 데이터를 주고받을 때 어떤 관례인지 문서로 명시해야 한다.

확인 문제

  1. 3x3 행렬로 이동을 표현할 수 없는 이유는?
  2. 방향 벡터를 동차 좌표로 나타낼 때 w 를 0 으로 두는 이유는?
  3. 열벡터 관례에서 M = T * R * S 는 어떤 순서로 적용되는가?
  4. 투영 행렬만으로 원근 효과가 완성되지 않는 이유는?
  5. 비균일 크기 변환이 있을 때 법선에 모델 행렬을 그대로 곱하면 안 되는 이유는?

풀이

  1. 선형 변환은 원점을 원점으로 보내야 하는데 이동은 원점을 옮긴다. 이동은 아핀 변환이다.
  2. 4번째 열의 이동 성분이 w 와 곱해지므로 w=0 이면 이동이 무시된다. 방향은 위치가 없으므로 이동의 영향을 받으면 안 된다.
  3. S(크기) → R(회전) → T(이동) 순서다.
  4. 원근은 깊이로 나누는 비선형 연산인데 행렬 곱은 선형이다. 행렬은 깊이를 w 에 담아 두고, 나눗셈은 그 뒤 원근 나눗셈 단계에서 한다.
  5. 늘어난 방향으로 법선도 같이 늘어나 표면과의 수직 관계가 깨진다. 역행렬의 전치를 써야 수직이 유지된다.

더 읽을거리 (References)