3D Math and a Software Rasterizer
Build Vector and Matrix Operations Yourself
한국어 원문으로 표시합니다.
목표
3차원 벡터 연산과 4x4 행렬 곱을 직접 만들고, 그것으로 도형을 옮기고 돌리고 줄여 봅니다. 이 실습이 끝나면 변환 행렬을 읽고 쓸 수 있고, 곱하는 순서가 왜 중요한지 손으로 확인한 상태가 됩니다.
왜 중요한가
3차원 계산인데 행렬이 4x4 인 이유는 이동이 곱셈이 아니기 때문입니다. 좌표를 하나 늘려 (x, y, z, 1) 로 적으면 마지막 열이 그 1 과 곱해져 결과에 더해지고, 그렇게 평행이동이 곱셈 안으로 들어옵니다. 이것을 동차 좌표라고 하고, w 가 1 이면 점, 0 이면 방향이라는 구분이 여기서 나옵니다.
그리고 행렬로 표현해 두면 여러 변환을 미리 곱해 하나로 만들 수 있습니다. GPU 가 정점마다 MVP 행렬 하나만 곱하는 이유입니다. 다만 행렬 곱은 교환법칙이 성립하지 않아서, 순서를 바꾸면 물체가 제자리에서 도는 대신 궤도를 돕니다. 이 차이를 숫자로 한 번 보고 나면 잊히지 않습니다.
단계
/root/linalg/gfxlib.py도구 상자를 놓습니다./root/linalg/vec.py에 벡터 연산 일곱 개를 만듭니다./root/linalg/mat.py에 4x4 행렬 곱을 만듭니다./root/linalg/xform.py에 이동·크기·회전 행렬을 만듭니다.- 곱하는 순서를 바꾼 결과를
/root/linalg/out/05-order.txt에 적습니다. - 변환한 사각형 셋을
/root/linalg/out/square.png에 그립니다. - 외적으로 법선과 넓이를 구해
/root/linalg/out/07-normal.txt에 적습니다.
참고
- 모든 파일은
/root/linalg안에 두십시오. 채점기가 그 폴더를 import 경로에 넣고 함수를 직접 불러 봅니다. - 그림을 보려면
nohup python3 -m http.server 8080 -d /root/linalg/out &로 띄운 뒤 웹 미리보기 에서http://localhost:8080/square.png를 여십시오. - 흔한 실수 하나:
mul의 인덱스를 헷갈려 전치된 행렬을 만드는 것입니다. 단위 행렬로 시험하면 안 걸리니 이동 행렬로 시험하십시오. - 흔한 실수 둘:
rotate_y의 부호를 다른 축과 같게 맞추는 것입니다. 축의 순환 순서 때문에 y축만 반대입니다.
그리기 도구 상자를 놓는다
/root/linalg/gfxlib.py 를 예시 그대로 저장하고, /root/linalg/check.py 로 시험 무늬를 그려 /root/linalg/out/00-check.png 를 만드십시오. 무늬는 64x64 검은 배경에 (0,0)에서 (63,63)까지 흰색(255,255,255) 대각선을 긋고, 그 뒤에 (0,32)에서 (63,32)까지 빨강(255,0,0) 가로선을 겹쳐 그은 것입니다.
이 실습부터는 PNG 인코더를 다시 만들지 않습니다. 첫 실습에서 손으로 만든 것과 같은 코드를 도구로 내려 줍니다 — 여기서 배울 것은 파일 형식이 아니기 때문입니다.
실습 파드에는 볼륨이 없어서 앞 실습에서 만든 파일이 남아 있지 않습니다. 그래서 실습마다 도구 상자를 다시 놓는 것으로 시작합니다.
Canvas(w, h, bg) 를 만들고 line(x0, y0, x1, y1, rgb) 로 두 선을 그은 뒤 write_png(path) 로 저장하십시오. 가로선을 나중에 그어야 교차점 (32,32) 가 빨강이 됩니다.
3차원 벡터 연산
/root/linalg/vec.py 에 add(a,b), sub(a,b), scale(a,s), dot(a,b), cross(a,b), length(a), normalize(a) 일곱 함수를 만드십시오. 벡터는 길이 3짜리 튜플이고, dot 과 length 만 실수를 돌려줍니다.
외적의 정의를 부호까지 정확히 적어야 합니다.
cross(a, b) = (a1*b2 - a2*b1, a2*b0 - a0*b2, a0*b1 - a1*b0)
cross((1,0,0), (0,1,0)) 이 (0,0,1) 이 나오면 부호가 맞는 것입니다. 반대로 나오면 오른손 좌표계가 뒤집힌 것이고, 나중에 조명이 물체 뒤에서 비치는 것처럼 보이게 됩니다.
normalize 는 길이가 0 인 벡터를 받을 수 있습니다. 그때 0으로 나누지 않도록 원래 벡터를 그대로 돌려주십시오.
4x4 행렬 곱
/root/linalg/mat.py 에 identity(), mul(a,b), apply(m,v) 세 함수를 만드십시오. 행렬은 행 우선(길이 4짜리 리스트 4개를 담은 리스트)이고, apply 는 점 (x,y,z) 를 열 벡터 (x,y,z,1) 로 보아 곱한 뒤 (x,y,z,w) 네 성분을 돌려줍니다.
곱셈의 정의는 out[r][c] = sum(a[r][k] * b[k][c] for k in range(4)) 입니다. 인덱스 순서를 한 번 헷갈리면 전치된 행렬이 나오는데, 단위 행렬로 시험하면 안 걸립니다 — 단위 행렬은 전치해도 자기 자신이기 때문입니다.
그래서 시험은 비대칭 행렬로 하십시오. 이동 행렬처럼 마지막 열에만 값이 있는 것이 좋습니다.
apply 는 m[r][0]*x + m[r][1]*y + m[r][2]*z + m[r][3]*1 을 r=0..3 에 대해 계산합니다.
이동·크기·회전 행렬
/root/linalg/xform.py 에 translate(tx,ty,tz), scale(sx,sy,sz), rotate_x(deg), rotate_y(deg), rotate_z(deg) 다섯 함수를 만드십시오. 각도는 도(degree) 로 받고, 오른손 좌표계 기준입니다.
rotate_z 의 왼쪽 위 2x2 는 [[cos, -sin], [sin, cos]] 입니다. 이렇게 두면 rotate_z(90) 이 x축 (1,0,0) 을 y축 (0,1,0) 으로 보냅니다.
같은 규칙으로 rotate_x 는 y 를 z 로, rotate_y 는 z 를 x 로 보냅니다. rotate_y 만 부호가 반대로 들어갑니다 — m[0][2] = sin, m[2][0] = -sin 입니다. 축의 순환 순서가 x→y→z→x 이기 때문인데, 여기서 부호를 통일해 버리면 y축 회전만 반대로 돌게 됩니다.
math.radians 로 도를 라디안으로 바꾸십시오.
곱하는 순서가 결과를 바꾼다
T = translate(2,0,0), R = rotate_z(90) 을 두고 점 (1,0,0) 에 mul(T,R) 과 mul(R,T) 를 각각 적용한 결과를 /root/linalg/out/05-order.txt 에 TR=x,y,z 와 RT=x,y,z 두 줄로 적으십시오. 소수점 아래 여섯 자리까지 적습니다.
열 벡터를 오른쪽에 곱하는 관례에서는 오른쪽 행렬이 먼저 적용됩니다. mul(T, R) 은 회전을 먼저 하고 이동을 나중에 한다는 뜻입니다.
손으로 따라가 보십시오. R 이 (1,0,0) 을 어디로 보내는지 먼저 구하고, 거기에 T 를 더합니다. 반대 순서는 T 를 먼저 적용한 뒤 R 을 겁니다.
한쪽은 물체가 제자리에서 돈 뒤 옮겨진 것이고, 다른 쪽은 원점에서 멀어진 뒤 원점 기준으로 궤도를 돈 것입니다. 결과가 크게 다릅니다.
변환한 사각형을 그린다
/root/linalg/plot.py 로 256x256 PNG 를 /root/linalg/out/square.png 에 쓰십시오. 모델 좌표의 사각형은 (-1,-1,0), (1,-1,0), (1,1,0), (-1,1,0) 이고, 화면 좌표는 sx = 128 + 60*x, sy = 128 - 60*y 로 옮깁니다. 검은 배경에 사각형 셋을 그립니다 — 변환 없는 것은 흰색(255,255,255), rotate_z(30) 을 건 것은 빨강(255,60,60), mul(translate(1.2,-0.8,0), scale(0.5,0.5,1)) 을 건 것은 초록(60,255,60) 입니다.
각 사각형은 네 꼭짓점을 순서대로 이은 뒤 마지막에서 첫 점으로 돌아오는 네 개의 선분입니다. gfxlib.Canvas 의 line(x0, y0, x1, y1, rgb) 를 쓰십시오.
화면 좌표로 옮길 때 y 에 뺄셈이 들어가는 것에 주의하십시오. 수학의 y 는 위로, 이미지의 y 는 아래로 자랍니다. 이걸 빠뜨리면 초록 사각형이 위쪽에 그려집니다.
초록 사각형의 변환은 축소를 먼저 하고 이동을 나중에 합니다 — mul(translate, scale) 의 오른쪽이 먼저입니다.
외적으로 법선과 넓이를 구한다
/root/linalg/normal.py 로 두 삼각형의 단위 법선과 넓이를 구해 /root/linalg/out/07-normal.txt 에 t1_normal=, t1_area=, t2_normal=, t2_area= 네 줄로 적으십시오. 삼각형 1은 A(0,0,0) B(2,0,0) C(0,3,0), 삼각형 2는 A(1,1,1) B(2,1,1) C(1,1,3) 이고, 법선은 cross(B-A, C-A) 를 정규화한 것입니다.
외적의 길이가 두 변이 만드는 평행사변형의 넓이라서, 삼각형 넓이는 그 절반입니다. 즉 area = length(cross(B-A, C-A)) / 2 입니다.
법선의 부호는 꼭짓점을 도는 순서가 정합니다. cross(B-A, C-A) 와 cross(C-A, B-A) 는 정반대 방향입니다. 이 순서가 뒤에서 앞면과 뒷면을 가르는 기준이 되므로, 시키는 대로 맞춰 두십시오.
값은 %.6f 로 적고 벡터는 쉼표로 잇습니다. 예: t1_normal=0.000000,0.000000,1.000000