LabHub
배우기 러닝패스 코스

3D Math and a Software Rasterizer

Stand a Cube Up on the Screen

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

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

왜 중요한가

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

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

단계

  1. /root/pipeline 에 도구 상자 gfxlib.pymat3d.py 를 놓습니다.
  2. /root/pipeline/view.pylook_at 을 만듭니다.
  3. /root/pipeline/proj.pyperspective 를 만듭니다.
  4. /root/pipeline/screen.pyto_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.pylook_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만큼 떨어진 자리라는 뜻입니다.

벡터 연산은 mat3dnormalize, cross, dot, vsub 를 쓰십시오.

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

/root/pipeline/proj.pyperspective(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.pyto_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.txtA=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.pyorthographic(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.txtfov60=, fov75=, fov90= 세 줄로 적으십시오(소수점 아래 세 자리).

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

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

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