回転する物体を八枚に出力する
한국어 원문으로 표시합니다.
목표
직육면체에 빛을 비춰 면마다 다른 밝기로 칠하고, 각도를 바꿔 여덟 장을 뽑아 브라우저에서 돌려 봅니다. 이 실습이 끝나면 코스 1의 모든 조각이 하나로 이어집니다 — 벡터, 행렬, 카메라, 투영, 래스터라이즈, 깊이, 조명.
왜 중요한가
한 가지 색으로 칠한 정육면체는 육각형 실루엣일 뿐 입체로 보이지 않습니다. 사람이 형태를 읽는 단서가 밝기 차이이기 때문입니다. 밝기는 표면의 법선과 빛 방향이 이루는 각도의 코사인에 비례하고, 두 벡터를 정규화한 뒤 내적하면 그 코사인이 바로 나옵니다. 계산은 이렇게 간단한데 결과는 즉시 눈에 보입니다.
여기서 자주 틀리는 곳이 둘입니다. 하나는 max(0, ...) 을 빠뜨려 빛을 등진 면의 밝기가 음수가 되는 것이고, 다른 하나는 법선을 모델 행렬로 돌릴 때 w=1 로 곱해 이동량이 더해지는 것입니다. 두 번째는 물체가 원점 근처에 있으면 드러나지 않아 오래 남습니다.
단계
/root/shading에 도구 상자gfxlib.py와mat3d.py를 놓습니다./root/shading/mesh.py로 직육면체와 면 법선을 만들고out/02-normals.txt를 적습니다./root/shading/light.py에 램버트 확산 반사를 만듭니다./root/shading/out/frame00.png에 빛을 받는 직육면체를 그립니다.- 45도씩 여덟 장(
frame00.png~frame07.png)을 뽑습니다. /root/shading/out/index.html로 브라우저에서 돌려 봅니다.- 여덟 장을 이어 붙여
/root/shading/out/spin.png와out/07-sheet.txt를 만듭니다.
참고
- 서버는
nohup python3 -m http.server 8080 -d /root/shading/out &로 띄우고 웹 미리보기 에서http://localhost:8080/을 엽니다. 여덟 장이 돌아가는 것이 이 실습의 결과물입니다. - 한 장을 그리는 데 1초 남짓 걸립니다. 여덟 장이면 10초 안쪽입니다.
- 흔한 실수 하나: 면 법선의 부호가 반대인 것입니다. 빛을 받는 면이 어둡고 등진 면이 밝으면 꼭짓점을 도는 순서를 뒤집으십시오.
- 흔한 실수 둘: 정규화를 빠뜨리는 것입니다. 법선의 길이가 1 이 아니면 내적이 코사인이 아니게 됩니다.
그리기 도구 상자를 놓는다
/root/shading/gfxlib.py 를 예시 그대로 저장하고, /root/shading/check.py 로 시험 무늬를 그려 /root/shading/out/00-check.png 를 만드십시오. 무늬는 64x64 검은 배경에 (0,0)에서 (63,63)까지 흰색(255,255,255) 대각선을 긋고, 그 뒤에 (0,32)에서 (63,32)까지 빨강(255,0,0) 가로선을 겹쳐 그은 것입니다. 이 실습은 행렬 도구 /root/shading/mat3d.py 도 함께 씁니다.
이 실습부터는 PNG 인코더를 다시 만들지 않습니다. 첫 실습에서 손으로 만든 것과 같은 코드를 도구로 내려 줍니다 — 여기서 배울 것은 파일 형식이 아니기 때문입니다.
실습 파드에는 볼륨이 없어서 앞 실습에서 만든 파일이 남아 있지 않습니다. 그래서 실습마다 도구 상자를 다시 놓는 것으로 시작합니다.
Canvas(w, h, bg) 를 만들고 line(x0, y0, x1, y1, rgb) 로 두 선을 그은 뒤 write_png(path) 로 저장하십시오. 가로선을 나중에 그어야 교차점 (32,32) 가 빨강이 됩니다.
mat3d.py 에는 rotate_y, mul, apply, normalize, cross, dot 가 들어 있습니다. 이 실습에서는 그것을 재료로 쓰고, 배울 것은 조명입니다.
직육면체 메시와 면 법선
/root/shading/mesh.py 에 반지름이 (1.0, 0.6, 0.4) 인 직육면체를 만드십시오. VERTS 는 꼭짓점 여덟 개, TRIS 는 삼각형 열두 개의 인덱스 세 쌍, FACE_COLORS 는 여섯 면의 기본색, face_normal(t) 는 cross(B-A, C-A) 를 정규화한 것입니다. 그리고 열두 법선을 /root/shading/out/02-normals.txt 에 f1=nx,ny,nz 부터 f12= 까지 적으십시오.
꼭짓점은 [(sx*1.0, sy*0.6, sz*0.4) for sx in (-1,1) for sy in (-1,1) for sz in (-1,1)] 로 만들면 여덟 개가 나옵니다.
면 하나는 사각형이라 삼각형 두 개로 쪼갭니다. 네 꼭짓점을 a, b, c, d 라 하면 (a,b,c) 와 (a,c,d) 입니다.
꼭짓점을 도는 순서가 결정적입니다. 바깥에서 볼 때 반시계 방향으로 돌아야 법선이 바깥을 가리킵니다. 확인은 dot(법선, 삼각형 무게중심) 이 양수인지 보면 됩니다 — 원점이 안쪽에 있으므로 바깥 법선이면 반드시 양수입니다.
여섯 면의 법선은 ±x, ±y, ±z 여섯 방향이고 각각 두 번씩 나와야 합니다.
램버트 확산 반사
/root/shading/light.py 에 lambert(normal, light_dir, base_rgb, ambient) 를 만드십시오. k = max(0, dot(normalize(normal), normalize(light_dir))) 로 두고 색 = base * (ambient + (1-ambient) * k) 세 채널을 실수로 돌려줍니다.
max(0, ...) 을 빠뜨리면 빛을 등진 면에서 내적이 음수가 되어 밝기가 음수로 내려갑니다. 정수로 자를 때 넘쳐서 색이 반대로 튑니다.
ambient 는 다른 표면에서 튕겨 온 빛을 뭉뚱그린 상수입니다. 이게 없으면 등진 면이 완전히 검게 되어 형태가 사라집니다. 물리적으로 정확한 값은 아니고, 전역 조명을 계산하지 않는 대가로 넣는 보정입니다.
두 벡터를 반드시 정규화하십시오. 길이가 1 이 아니면 내적이 코사인이 아니게 되어 밝기가 배로 뛰거나 죽습니다.
빛을 받는 직육면체 한 장
/root/shading/render.py 로 /root/shading/out/frame00.png(256x256, 검은 배경)를 만드십시오. 카메라는 look_at((2.2,1.8,3.0),(0,0,0),(0,1,0)), 투영은 perspective(45,1,0.1,100), 빛 방향은 normalize((0.4,0.8,1.0)), ambient 는 0.2 이고, 모델 행렬은 rotate_y(0) 입니다. 깊이 버퍼로 앞뒤를 가르고 면마다 FACE_COLORS 의 색에 램버트를 적용하십시오.
법선은 모델 행렬로 함께 돌려야 합니다. 다만 위치가 아니라 방향이므로 mat3d.apply(model, n, 0.0) 처럼 w=0 으로 곱하십시오. w=1 로 곱하면 이동량까지 더해져 조명이 틀어집니다.
깊이는 클립 좌표의 z 를 w 로 나눈 값을 쓰고, 삼각형 안에서는 무게중심 좌표로 보간합니다. 앞 실습에서 만든 것과 같은 z-버퍼입니다.
색은 정수로 바꾸기 전에 255 로 자르십시오. 램버트 계수는 1 을 넘지 않지만 반올림에서 넘칠 수 있습니다.
세 면이 서로 다른 밝기로 보이면 제대로 된 것입니다. 한 가지 밝기만 보이면 법선이 전부 같은 값이거나 정규화가 빠진 것입니다.
45도씩 여덟 장
각도를 0도부터 45도씩 늘려 /root/shading/out/frame00.png 부터 frame07.png 까지 여덟 장을 만드십시오. 파일 이름의 숫자는 두 자리(00, 01, ... 07)입니다.
앞 단계의 render(angle, path) 를 그대로 부르면 됩니다. 반복문 하나로 끝납니다.
각도를 누적해 회전 행렬을 곱해 나가지 마십시오. 부동소수점 오차가 쌓여 물체가 서서히 찌그러집니다. 각도를 상태로 두고 프레임마다 행렬을 새로 만드는 편이 안전합니다.
직육면체의 반지름이 (1.0, 0.6, 0.4) 로 서로 달라서 90도 돌 때마다 모습이 확실히 달라집니다. 정육면체였다면 90도마다 같은 그림이 나와 회전이 보이지 않았을 것입니다.
브라우저에서 돌려 본다
/root/shading/out/index.html 을 만들어 여덟 장을 순서대로 바꿔 보여 주게 하고, python3 -m http.server 8080 -d /root/shading/out 을 띄워 웹 미리보기에서 http://localhost:8080/ 을 여십시오. HTML 안에는 frame00.png 부터 frame07.png 까지 여덟 이름이 모두 들어 있어야 합니다.
가장 단순한 방법은 <img> 하나를 두고 자바스크립트의 setInterval 로 src 를 바꾸는 것입니다. 파일 이름 배열을 만들어 두고 인덱스를 돌리면 됩니다.
서버는 nohup python3 -m http.server 8080 -d /root/shading/out & 로 백그라운드에 띄우십시오. 그러지 않으면 터미널이 묶입니다.
채점은 서버가 아니라 index.html 과 여덟 장의 PNG 를 봅니다 — 떠 있다 사라지는 것으로 판정하면 같은 답이 어떤 날은 통과하고 어떤 날은 실패하기 때문입니다.
여덟 장을 한 장으로
여덟 프레임을 절반 크기(128x128)로 줄여 4열 2행으로 이어 붙인 /root/shading/out/spin.png(512x256)를 만들고, /root/shading/out/07-sheet.txt 에 cells=8, cell=128x128, sheet=512x256 세 줄을 적으십시오. 축소는 가로세로 두 픽셀 중 왼쪽 위 하나만 취하는 최근접 방식이고, 프레임 k 는 행 = k // 4, 열 = k % 4 자리에 놓입니다.
gfxlib.read_png(path) 로 만들어 둔 PNG 를 되읽을 수 있습니다. 되읽은 Canvas 의 get(x, y) 로 픽셀을 꺼내십시오.
최근접 축소는 곧 cell.get(i, j) = frame.get(2*i, 2*j) 입니다. 평균을 내지 마십시오 — 채점기가 값을 그대로 맞춰 봅니다.
놓는 자리는 x = 열*128 + i, y = 행*128 + j 입니다.
이런 대조 시트는 실무에서도 씁니다. 프레임을 한 장씩 열어 비교하는 것보다 애니메이션의 흐름이 훨씬 잘 보입니다.