Divide by depth for instant 3D
깊이로 나누면 즉시 3D가 됩니다
3D 카메라의 원리를 x와 y를 깊이 z로 나누는 원근 투영에서 출발해 설명합니다. 단순한 나눗셈이 perspective projection matrix와 전체 그래픽 파이프라인의 한 단계라는 점을 수식과 인터랙티브 예제로 보여줍니다.
- 주제
AI 요약
게임 개발을 처음 시작하면 카메라가 이미 구현된 고수준 프레임워크를 사용하게 됩니다. 하지만 그래픽스의 낮은 수준으로 내려가면 카메라는 몇 가지 단순한 좌표 변환의 조합으로 이해할 수 있습니다. 이 글은 복잡한 카메라 구현을 곧바로 다루지 않고, 3D 점을 화면에 배치하는 가장 단순한 규칙에서 출발합니다.
깊이로 나누는 원근 투영
위쪽을 y축, 카메라가 바라보는 방향을 z축이라고 하면 3D 좌표 (x, y, z)를 다음처럼 2D 좌표로 보낼 수 있습니다.
x' = x / z y' = y / z
x와 y가 같아도 z가 커지면 투영된 좌표가 원점에 가까워집니다. 예를 들어 (2, 1, 2)는 (1, 0.5)가 되고, (2, 1, 4)는 (0.5, 0.25), (2, 1, 8)은 (0.25, 0.125)가 됩니다. 같은 방향에 놓인 점이 멀어질수록 소실점 (0, 0)에 가까워지는 이유가 이 나눗셈 하나에 들어 있습니다.
이 규칙을 이용하면 카메라의 위쪽 축을 중심으로 공을 회전시키면서 z축 방향으로 이동시키고, 깊이에 따라 공의 화면 위치와 크기를 함께 바꿀 수 있습니다. 글에서는 이 방식으로 만든 인터랙티브 예제를 제공하고, 같은 원리로 더 복잡한 형태도 그릴 수 있다고 설명합니다. 다만 카메라의 방향과 위치, 시야각 같은 요소가 빠진 단순한 모델이라 실제 3D 작업 전체를 대신하지는 않습니다.
perspective projection matrix
실제 삼각형 기반 그래픽스에서는 perspective projection matrix를 사용합니다. 그래픽스와 computer vision은 좌표축과 행렬 배치 규약이 서로 다를 수 있지만, 글은 field of view, aspect ratio, near clipping plane, far clipping plane을 매개변수로 삼는 형태를 소개합니다.
P = [ f/a 0 0 0 ] [ 0 f 0 0 ] [ 0 0 A B ] [ 0 0 1 0 ]
여기서 f는 focal scale이며 vertical field of view θ에서 다음처럼 구합니다.
f = 1 / tan(θ/2)
a = width / height
A와 B는 near plane n과 far plane F를 깊이 값으로 변환합니다.
A = (F + n) / (F - n) B = -2Fn / (F - n)
near와 far 값은 깊이 범위를 정하는 동시에 어떤 물체가 카메라의 시야 안에 들어오는지 판단하는 기준이 됩니다. 화면 밖의 geometry를 미리 제거하는 culling 같은 최적화에도 이 범위를 활용합니다.
world space에서 화면까지
먼저 점을 장면 전체가 공유하는 world space에서 카메라가 원점에 있는 view space로 변환합니다. 이후 view-space 좌표에 projection matrix를 곱해 clip-space 좌표를 만듭니다. 점 (x, y, z, 1)을 행렬에 곱하면 결과는 다음과 같습니다.
(fx/a, fy, Az + B, z)
마지막 성분 w가 z가 되므로, clip-space의 첫 세 성분을 w로 나누면 다음과 같은 normalized device coordinates, 즉 NDC가 나옵니다.
x_ndc = (f/a)x / z y_ndc = fy / z
f와 a를 모두 1로 두면 x_ndc = x/z, y_ndc = y/z가 됩니다. 처음 소개한 깊이 나누기 규칙은 별도의 요령이 아니라 perspective projection matrix가 만드는 결과의 특수한 형태입니다.
전체 흐름은 world → view → clip → /w → NDC → pixels → rasterization으로 정리됩니다. NDC는 장면 속 실제 거리 좌표가 아니라 카메라가 보는 정규화된 범위 안에서 점이 어디에 놓이는지를 나타냅니다. 이후 화면 해상도에 맞춰 pixel 좌표로 바꾸고, 삼각형을 rasterization합니다. 카메라의 위치와 방향을 반영하려면 view 변환이 필요하고, 시야각과 화면 비율을 반영하려면 projection matrix의 f와 a가 필요합니다. 반대로 카메라 기준 좌표만 이미 계산된 단순한 상황이라면 깊이로 나누는 한 단계만 직접 구현해도 됩니다.
동차 좌표와 4번째 값
토론에서는 점을 (x, y, z, 1)로 쓰는 이유도 설명합니다. 3D에서 평행이동은 원점을 그대로 두지 않으므로 3x3 행렬만으로 표현하는 선형 변환이 아닙니다. 3D 공간을 w=1인 4차원 공간의 일부로 올리면 평행이동을 포함한 affine transformation을 4x4 행렬 곱으로 처리할 수 있습니다. 따라서 마지막의 1은 perspective divide에 필요한 w를 계산하고, 회전·확대·평행이동을 같은 행렬 연산 체계에 넣는 역할을 합니다.
Hacker News 반응
- @gabrieloc — 안녕하세요. 한때 저도 어려워했던 3D 카메라의 작동 방식을 쉽게 설명하려고 인터랙티브 예제와 함께 메모를 작성했습니다. 누군가에게 도움이 되면 좋겠고, 그렇지 않더라도 슬라이더를 움직여 볼 만합니다.
- @dyarosla — 시각화와 슬라이더가 좋습니다. view frustum의 양쪽 가장자리에서 공을 제대로 clipping하는 것도 고려해 보면 좋겠습니다. near와 far도 마찬가지입니다. 수학적으로 반드시 그래야 한다는 뜻은 아니지만, 3D 그래픽스 애플리케이션에서는 보통 그렇게 기대합니다.
- @anitil — 설명이 정말 좋습니다. 제목을 보자마자 tsoding의 영상을 떠올렸는데, 글에서도 언급해서 반가웠습니다.
- @aappleby — “view frustum은 꼭짓점이 잘려 나간 90도 피라미드이고, z=1에서 끝이 잘리며 끝부분은 무한대까지 이어진다”고 시작하면, 행렬과 perspective divide를 이용해 이것을 NDC에 매핑하는 과정을 직접 만들 수 있습니다. 짧은 Graphics 101 수업에서 가르칠 때 이 방법을 썼습니다. 보통처럼 “이 perspective matrix를 계산하세요. 숫자의 출처는 묻지 마세요”라고 하는 것보다 수학이 직관적으로 나옵니다.
- @psvv — 혼자 공부할 때는 near plane을 사용하지 않고 카메라 뒤의 물체도 카메라 앞에 나타나는 모습을 보는 것조차 도움이 됐습니다. 깊이로 나누기 같은 기초 몇 가지를 알면 많은 3D 그래픽스를 꽤 쉽게 유도할 수 있습니다. 좌표계의 기저로부터 transformation matrix를 만드는 방법도 그런 기초 중 하나입니다. perspective matrix를 만드는 법을 찾아볼 필요가 줄어듭니다. 스스로 발견한 내용은 읽기만 한 내용보다 기억에 잘 남습니다. perspective-correct texture에서 어려워지지만, 혼자 해결하지 못하더라도 직접 문제를 마주치는 과정 자체가 학습의 일부라고 생각합니다.
- @Lerc — 저는 창문을 이용해 설명하는 편입니다. 학생들은 눈에서 창틀로 이어지는 피라미드와 그 선이 계속 이어지는 모습을 쉽게 상상합니다. 창문 밖 물체의 모서리에서 눈으로 줄을 뻗는다고 말한 뒤, 그 줄이 창문을 통과하면 어디에 닿는지 묻습니다. 3D 그래픽스는 그 줄이 창문 어디를 통과하는지 찾아서, 창문에 그림을 붙였을 때 창문 밖의 물체와 똑같이 보이게 만드는 일입니다.
- @TN1ck — 슬라이더가 훌륭합니다. 모바일에서 UX가 좋고 세부 사항을 신경 쓴 점이 마음에 듭니다.
- @skzv — 좋습니다. 이 수학을 한 단계 더 확장해 depth map으로 2D 사진을 3D 장면으로 바꿔 본 적이 있습니다.
- @tmoertel — 3D 좌표를 열벡터 (x, y, z, 1)로 쓸 때 마지막 1이 왜 필요한지 설명하면서, 평행이동은 선형 변환이 아니라 affine transformation이라는 점을 언급하면 좋겠습니다. 원점이 자기 자신으로 매핑되지 않기 때문입니다. 3D 공간에서는 3x3 행렬로 평행이동을 표현할 수 없습니다. 대신 3D 공간을 4번째 좌표가 w=1로 고정된 4차원 공간에 넣습니다. 그러면 원래 3D 공간의 평행이동이 4차원 공간의 선형 변환이 되고, 4x4 행렬 곱으로 표현됩니다. 마지막의 1 덕분에 평행이동을 포함한 일반적인 3D 연산을 선형대수와 행렬 곱으로 처리합니다.
- @globalnode — 좋은 직관입니다. 2D를 3D 안에 넣는 더 단순한 예를 생각해 봤습니다. 3D 공간에 넣은 2D 평면이 3D 원점을 지나면 3D에서는 선형 변환이고 2D에서는 affine transform이 되는 걸까요?
- @sheept — affine transformation matrix는 본질적으로 shear입니다. 2D의 경우 z=1인 평면을 3D 공간에서 shear하면 그 평면을 평행이동하는 효과가 납니다.
- @tmoertel — 그렇지 않습니다. x-y 평면을 z=0에 고정하면 3D 공간의 원점을 지나지만, 그렇게 하면 평행이동에 필요한 값을 만들 수 없습니다. x-y 평면을 z=1에 넣으면 (x, y, 1)을 (x+s, y, 1)로 옮기는 변환을 3D의 선형 변환으로 표현할 수 있습니다. 변환식은 f(x, y, z) = (x + s*z, y, z)입니다. 원점 (0,0,0)은 그대로 두면서, 평면 안의 (0,0,1)은 (s,0,1)로 옮깁니다. 행렬로 쓰면 [[1 0 s] [0 1 0] [0 0 1]]입니다. 평면을 z=r에 넣는 경우에도 r이 0이 아니면 같은 방식을 쓸 수 있지만, 행렬의 이동값을 s/r로 조정해야 합니다.
- @JKCalhoun — 이 글과 같은 수학을 사용하는 구식 3D 엔진을 작성했습니다. HTML5 Canvas에서 flat-shaded polygon을 렌더링합니다. 커서 키로 sand crawler를 움직이고 대괄호 키로 FOV를 바꿀 수 있습니다. 깊이 정렬에서 이상한 현상이 조금 남아 있지만, 코드가 아니라 모델 자체의 문제라고 생각합니다.
- @throwaway219450 — 올해 초 Tsoding이 만든 재미있는 데모가 있습니다. 좋은 부분은 7분쯤부터 시작하지만, 전체 발표도 훌륭하고 모든 것이 맞물리는 과정이 거의 마법처럼 보입니다.
- @rhyperior — 한때 이 내용은 소프트웨어 업계의 상식처럼 보였습니다. Carmack, Abrash, Hecker를 비롯한 사람들이 게임 개발과 발견 과정을 공개적으로 진행했기 때문입니다. 누군가 처음부터 다시 유도해야 했다는 점이 조금 재미있습니다.
- @socalgal2 — 문제는 간단하고 좋은 설명을 찾기 어렵다는 데 있습니다. 검색하면 나쁜 설명이나, 일부 사람에게만 맞는 설명, 사전 지식을 너무 많이 가정하는 설명, 수학을 지나치게 앞세운 설명을 만나기 쉽습니다. 요즘은 LLM에 자신의 수준에 맞는 설명을 요청하고 이해하지 못한 부분을 다시 물어볼 수 있지만, 아직 많은 사람이 그렇게 사용하지는 않습니다.
- @nik282000 — 이런 방식이 바로 3D를 구현하는 저렴한 방법인 줄 알았습니다.
원문: gabrieloc.com / 번역·요약: Trawling