TT Lab
시작하기
배우기 러닝패스 코스

3D 수학과 소프트웨어 래스터라이저

정육면체를 화면에 세운다

TT Lab 에서 이어서 보기

목표

카메라 행렬과 투영 행렬을 직접 만들고, 원근 나눗셈과 뷰포트 변환을 거쳐 정육면체를 화면에 세웁니다. 이 실습이 끝나면 "정점 셰이더가 하는 일" 을 한 줄로 설명할 수 있게 됩니다.

왜 중요한가

3차원 점 하나가 픽셀 좌표가 되기까지 네 개의 좌표계를 지납니다. 모델, 월드, 뷰, 클립입니다. 각 단계가 행렬 하나라서 미리 곱해 MVP 로 합칠 수 있고, 그래서 GPU 는 정점마다 곱셈 한 번만 합니다.

여기서 가장 중요한 것은 원근감이 행렬이 아니라 나눗셈에서 나온다는 사실입니다. 행렬 곱은 선형 연산이라 나눗셈을 표현할 수 없습니다. 그래서 투영 행렬은 마지막 행을 (0,0,-1,0) 으로 두어 결과의 w 에 카메라로부터의 거리를 실어 두고, 그다음에 x·y·z 를 그 w 로 나눕니다. 이 우회로를 이해하면 깊이 버퍼의 정밀도가 왜 가까운 곳에 몰리는지, near 평면을 왜 함부로 줄이면 안 되는지도 같이 풀립니다.

단계

  1. /root/pipeline 에 도구 상자 gfxlib.py 와 mat3d.py 를 놓습니다.
  2. /root/pipeline/view.py 에 look_at 을 만듭니다.
  3. /root/pipeline/proj.py 에 perspective 를 만듭니다.
  4. /root/pipeline/screen.py 에 to_screen 을 만들고 /root/pipeline/out/04-divide.txt 를 적습니다.
  5. /root/pipeline/out/cube.png 에 정육면체 와이어프레임을 그립니다.
  6. orthographic 을 더해 /root/pipeline/out/ortho.png 를 만듭니다.
  7. 시야각 셋으로 그려 /root/pipeline/out/07-fov.txt 를 적습니다.

참고

그리기 도구 상자를 놓는다

/root/pipeline/gfxlib.py 를 예시 그대로 저장하고, /root/pipeline/check.py 로 시험 무늬를 그려 /root/pipeline/out/00-check.png 를 만드십시오. 무늬는 64x64 검은 배경에 (0,0)에서 (63,63)까지 흰색(255,255,255) 대각선을 긋고, 그 뒤에 (0,32)에서 (63,32)까지 빨강(255,0,0) 가로선을 겹쳐 그은 것입니다. 이 실습은 행렬 도구 /root/pipeline/mat3d.py 도 함께 씁니다.

이 실습부터는 PNG 인코더를 다시 만들지 않습니다. 첫 실습에서 손으로 만든 것과 같은 코드를 도구로 내려 줍니다 — 여기서 배울 것은 파일 형식이 아니기 때문입니다.

실습 파드에는 볼륨이 없어서 앞 실습에서 만든 파일이 남아 있지 않습니다. 그래서 실습마다 도구 상자를 다시 놓는 것으로 시작합니다.

Canvas(w, h, bg) 를 만들고 line(x0, y0, x1, y1, rgb) 로 두 선을 그은 뒤 write_png(path) 로 저장하십시오. 가로선을 나중에 그어야 교차점 (32,32) 가 빨강이 됩니다.

mat3d.py 는 앞 실습에서 직접 만든 벡터·행렬 연산과 같은 것입니다. 여기서는 재료로 받아 쓰고, 배울 것은 카메라와 투영입니다.

카메라를 원점으로 끌어온다

/root/pipeline/view.py 에 look_at(eye, target, up) 를 만들어 4x4 뷰 행렬을 돌려주게 하십시오. 세 축은 f = normalize(target-eye), s = normalize(cross(f, up)), u = cross(s, f) 이고, 세 번째 행에는 -f 를 넣습니다.

행렬의 각 행이 카메라의 축이고, 마지막 열은 그 축 방향으로 카메라 위치를 되돌리는 값입니다. 즉 row0 = (s.x, s.y, s.z, -dot(s, eye)) 입니다.

세 번째 행만 부호가 반대인 이유는 오른손 좌표계에서 카메라가 -z 방향을 보기로 약속했기 때문입니다. 그래서 그 행은 (-f.x, -f.y, -f.z, dot(f, eye)) 가 됩니다.

확인은 간단합니다. eye=(0,0,5), target=(0,0,0), up=(0,1,0) 일 때 원점은 (0, 0, -5) 로 가야 합니다. 카메라 앞 5만큼 떨어진 자리라는 뜻입니다.

벡터 연산은 mat3d 의 normalize, cross, dot, vsub 를 쓰십시오.

절두체를 정육면체로 누른다

/root/pipeline/proj.py 에 perspective(fov_deg, aspect, near, far) 를 만드십시오. t = 1/tan(fov/2) 로 두고 m[0][0]=t/aspect, m[1][1]=t, m[2][2]=-(far+near)/(far-near), m[2][3]=-2*far*near/(far-near), m[3][2]=-1, 나머지는 0 입니다.

마지막 행이 (0, 0, -1, 0) 인 것이 이 행렬의 전부라고 해도 됩니다. 이 행 때문에 결과의 w 성분이 카메라로부터의 거리(-z)가 되고, 다음 단계의 나눗셈이 원근감을 만듭니다.

m[3][3] 은 0 입니다. 1 로 두면 w 가 거리와 무관해져 원근이 사라집니다.

t = 1/tan(radians(fov)/2) 입니다. fov 를 라디안으로 바꾸는 것을 빠뜨리면 시야각이 엉뚱해지는데 오류는 나지 않습니다.

w 로 나누고 화면 좌표로

/root/pipeline/screen.py 에 to_screen(clip, w, h) 를 만드십시오. 클립 좌표 (x,y,z,w) 를 받아 ndc = (x/w, y/w) 로 나눈 뒤 sx = (ndc_x*0.5+0.5)*w_px, sy = (1-(ndc_y*0.5+0.5))*h_px 를 실수 두 개로 돌려줍니다. 그리고 perspective(60,1,0.1,100) 으로 뷰 좌표의 점 A(1,0,-2) 와 B(1,0,-8) 를 256x256 화면에 옮긴 값을 /root/pipeline/out/04-divide.txt 에 A=sx,sy B=sx,sy 두 줄로 적으십시오(소수점 아래 여섯 자리).

두 점은 x 가 똑같이 1 인데 깊이만 다릅니다. 나눗셈을 제대로 했다면 먼 쪽인 B 가 화면 중앙(128)에 더 가깝게 나와야 합니다. 그게 원근감입니다.

sy 에서 1 에서 빼는 것이 y 뒤집기입니다. 이미지의 y 는 아래로 자라기 때문입니다.

mat3d.apply(P, (1,0,-2)) 는 w=1 로 두고 곱해 네 성분을 돌려줍니다. 그 결과를 to_screen 에 넣으십시오.

정육면체의 열두 모서리

/root/pipeline/cube.py 로 /root/pipeline/out/cube.png(256x256, 검은 배경, 흰색 선)를 만드십시오. 정육면체는 여덟 꼭짓점 (±1,±1,±1) 과 그것을 잇는 열두 모서리이고, 카메라는 look_at((2.5,2.5,2.5),(0,0,0),(0,1,0)), 투영은 perspective(60, 1, 0.1, 100) 입니다.

모서리는 두 꼭짓점의 좌표가 한 축만 다른 쌍입니다. 여덟 개 중 그런 쌍을 고르면 정확히 열두 개가 나옵니다.

MVP 는 mat3d.mul(P, V) 입니다. 모델 행렬이 단위 행렬이라 생략한 것인데, 곱하는 순서는 투영이 왼쪽입니다.

각 꼭짓점을 한 번씩만 변환해 화면 좌표 목록을 만들어 두고, 모서리는 그 목록에서 꺼내 cv.line 으로 이으십시오. 꼭짓점마다 다시 계산하면 같은 일을 두 번 합니다.

직교 투영과 견주어 본다

/root/pipeline/proj.py 에 orthographic(l, r, b, t, n, f) 를 더하고, 같은 카메라로 /root/pipeline/out/ortho.png 를 만드십시오. 경계는 l=-2.5, r=2.5, b=-2.5, t=2.5, n=0.1, f=100 입니다.

직교 투영 행렬의 마지막 행은 (0, 0, 0, 1) 입니다. w 가 항상 1 이라 나눗셈이 항등이 되고, 그래서 거리와 무관하게 크기가 같습니다.

대각선 성분은 2/(r-l), 2/(t-b), -2/(f-n) 이고 마지막 열은 -(r+l)/(r-l), -(t+b)/(t-b), -(f+n)/(f-n) 입니다. 범위를 -1에서 1로 옮기는 평행이동과 크기 조절을 합친 것입니다.

두 그림을 나란히 보면 차이가 보입니다. 원근에서는 가까운 면이 크고 먼 면이 작지만, 직교에서는 마주 보는 두 면의 크기가 같아 육각형처럼 보입니다.

시야각을 바꾸면 무엇이 달라지나

시야각 60도·75도·90도로 같은 정육면체를 그려 /root/pipeline/out/fov60.png, fov75.png, fov90.png 를 만들고, 각 그림에서 정육면체가 차지하는 가로 폭(여덟 꼭짓점 화면 x 좌표의 최댓값 빼기 최솟값)을 /root/pipeline/out/07-fov.txt 에 fov60=, fov75=, fov90= 세 줄로 적으십시오(소수점 아래 세 자리).

카메라 위치는 그대로 두고 시야각만 바꿉니다. 시야각이 넓어지면 더 많은 것이 화면에 들어오므로 같은 물체는 오히려 작아집니다. 세 값이 줄어드는 순서로 나와야 합니다.

폭은 그림에서 세지 말고 변환한 꼭짓점 좌표에서 바로 구하십시오. 여덟 개의 sx 중 최댓값에서 최솟값을 빼면 됩니다.

앞 단계의 코드를 함수로 묶어 시야각만 인자로 받게 하면 세 번 반복할 일이 없습니다.