LabHub
배우기 러닝패스 코스

3D 수학과 소프트웨어 래스터라이저 · 모델·뷰·투영과 원근 나눗셈 · 이론

점 하나가 화면에 닿기까지

LabHub 에서 이어서 보기

한 줄 요약

정점은 모델 좌표에서 시작해 월드, 뷰, 클립 좌표를 거쳐 w 로 나뉘고 나서야 화면 좌표가 됩니다. 원근감은 이 나눗셈 한 번에서 나옵니다.

왜 이게 필요했나

물체를 만든 사람은 원점 근처에 놓고 모델링합니다. 장면을 꾸미는 사람은 그 물체를 세계 어딘가에 놓습니다. 카메라를 든 사람은 자기 위치와 방향을 기준으로 세상을 봅니다. 화면에 그리는 쪽은 픽셀 좌표만 압니다. 이 네 사람의 기준이 다 다르기 때문에 좌표계를 갈아타는 단계가 필요합니다.

여기서 각 단계를 행렬 하나로 표현할 수 있다는 것이 결정적입니다. 모델 행렬, 뷰 행렬, 투영 행렬을 미리 곱해 MVP 하나로 만들어 두면 정점마다 4x4 곱셈 한 번이면 끝납니다. 정점 셰이더가 하는 일이 사실상 이 한 줄입니다.

어떻게 동작하나

뷰 행렬은 카메라를 원점으로, 카메라가 보는 방향을 -z 축으로 옮기는 변환입니다. 카메라를 움직이는 것이 아니라 세상을 반대로 움직인다고 보면 됩니다. 만드는 방법은 카메라의 세 축을 구해 그것으로 행 벡터를 세우는 것입니다.

f = normalize(target - eye)      # 보는 방향s = normalize(cross(f, up))      # 오른쪽u = cross(s, f)                  # 진짜 위쪽 (up 은 대충 준 값이라 다시 구한다)view = |  s.x   s.y   s.z   -dot(s, eye) |       |  u.x   u.y   u.z   -dot(u, eye) |       | -f.x  -f.y  -f.z    dot(f, eye) |       |   0     0     0          1      |

세 번째 행에 마이너스가 붙는 이유는 오른손 좌표계에서 카메라가 -z 를 본다고 약속했기 때문입니다. 이 약속을 깨면 물체가 카메라 뒤에 놓여 아무것도 안 보입니다.

투영 행렬은 절두체(frustum) 안의 공간을 정육면체로 눌러 넣습니다. 원근 투영 행렬의 마지막 두 행이 이 일의 핵심입니다.

t = 1 / tan(fov/2)proj = | t/aspect  0        0                 0             |       | 0         t        0                 0             |       | 0         0   -(f+n)/(f-n)   -2*f*n/(f-n)          |       | 0         0       -1                 0             |

마지막 행이 (0, 0, -1, 0) 인 것을 보십시오. 이 행 때문에 결과의 w 성분이 -z, 즉 카메라로부터의 거리가 됩니다. 그다음 x, y, z 를 이 w 로 나누면 멀리 있는 점일수록 화면 중앙으로 당겨집니다. 이것이 원근 나눗셈(perspective divide) 이고, 원근감은 전부 여기서 나옵니다. 행렬 곱만으로는 원근이 생기지 않습니다 — 행렬은 나눗셈을 표현할 수 없기 때문에, w 에 거리를 실어 두고 나중에 나누는 우회로를 쓰는 것입니다.

나눗셈 뒤의 좌표를 정규화 장치 좌표(NDC)라고 하고 범위는 -1에서 1입니다. 마지막으로 여기에 뷰포트 변환을 걸어 픽셀 좌표로 옮깁니다. y 를 뒤집는 것도 이때입니다.

sx = (ndc_x * 0.5 + 0.5) * 화면폭sy = (1 - (ndc_y * 0.5 + 0.5)) * 화면높이

직교 투영은 w 를 건드리지 않습니다. 마지막 행이 (0, 0, 0, 1) 이라 나눗셈이 항등이 되고, 그래서 거리와 무관하게 크기가 같습니다. 도면과 2D UI 가 이걸 씁니다.

나눗셈 전에 잘라 내야 하는 이유도 여기서 나옵니다. w 가 0 이면 나눗셈이 터지고, w 가 음수면 카메라 뒤에 있는 점입니다. 뒤에 있는 점을 그냥 나누면 부호가 뒤집혀 화면 반대쪽에 멀쩡한 좌표로 나타납니다. 카메라 앞뒤에 걸친 삼각형이 화면을 가로질러 길게 늘어나는 버그가 이것입니다. 그래서 파이프라인은 나눗셈 전에 클립 좌표 상태에서 절두체 밖을 잘라 냅니다. 클립 좌표라는 이름이 여기서 왔습니다.

뷰포트 변환에는 깊이도 함께 들어갑니다. NDC 의 z 를 깊이 버퍼가 쓰는 범위로 옮기는 단계인데, OpenGL 은 기본이 -1..1 이고 Direct3D 와 Vulkan 은 0..1 입니다. 이 차이 때문에 투영 행렬의 세 번째 행이 API 마다 다르고, 남의 코드를 그대로 옮기면 깊이 판정이 뒤집히거나 절반이 잘려 나갑니다.

현장에서 만나는 모습

near 평면을 너무 작게 잡으면(0.001 같은 값) 깊이 버퍼의 정밀도가 가까운 곳에 몰려서 먼 물체끼리 z-파이팅이 납니다. 깊이 값이 1/z 에 비례해 분포하기 때문인데, 이 사실은 위 행렬의 세 번째 행에서 그대로 읽힙니다. 실무의 해법은 far 를 줄이는 것이 아니라 near 를 키우는 것입니다.

또 하나는 종횡비입니다. 창 크기가 바뀌었는데 투영 행렬의 aspect 를 갱신하지 않으면 물체가 늘어납니다. 창 크기 변경 콜백에서 투영 행렬을 다시 만들어야 하는 이유입니다.

다음 실습에서 할 것

뷰 행렬과 원근·직교 투영 행렬을 직접 만들고, 원근 나눗셈과 뷰포트 변환을 거쳐 정육면체의 열두 모서리를 화면에 그립니다. 같은 정육면체를 원근과 직교로 각각 그려 비교하고, 시야각을 바꿔 가며 물체가 화면에서 얼마나 넓게 잡히는지를 숫자와 그림으로 함께 확인합니다.