[CS300 #283] 3D 변환 행렬 — 4x4 하나로 옮기고 돌리고 원근을 건다
컴퓨터공학 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)이 다르므로, 데이터를 주고받을 때 어떤 관례인지 문서로 명시해야 한다.
확인 문제
- 3x3 행렬로 이동을 표현할 수 없는 이유는?
- 방향 벡터를 동차 좌표로 나타낼 때 w 를 0 으로 두는 이유는?
- 열벡터 관례에서
M = T * R * S는 어떤 순서로 적용되는가? - 투영 행렬만으로 원근 효과가 완성되지 않는 이유는?
- 비균일 크기 변환이 있을 때 법선에 모델 행렬을 그대로 곱하면 안 되는 이유는?
풀이
- 선형 변환은 원점을 원점으로 보내야 하는데 이동은 원점을 옮긴다. 이동은 아핀 변환이다.
- 4번째 열의 이동 성분이 w 와 곱해지므로 w=0 이면 이동이 무시된다. 방향은 위치가 없으므로 이동의 영향을 받으면 안 된다.
- S(크기) → R(회전) → T(이동) 순서다.
- 원근은 깊이로 나누는 비선형 연산인데 행렬 곱은 선형이다. 행렬은 깊이를 w 에 담아 두고, 나눗셈은 그 뒤 원근 나눗셈 단계에서 한다.
- 늘어난 방향으로 법선도 같이 늘어나 표면과의 수직 관계가 깨진다. 역행렬의 전치를 써야 수직이 유지된다.
더 읽을거리 (References)
- MDN, Matrix math for the web
- W3C, CSS Transforms Module Level 1 — transform 목록을 행렬로 합성하는 규칙
- Khronos, The OpenGL Graphics System: A Specification (Version 4.6, Core Profile) — 좌표 변환과 원근 나눗셈 정의
- S. Marschner, P. Shirley, Fundamentals of Computer Graphics, 5th ed., CRC Press, 2021.