From Distance Functions to Shadows
한국어 원문으로 표시합니다.
목표
거리 함수만으로 형태를 정의하고, 구 추적으로 표면을 찾고, 기울기로 법선을 구해 음영과 그림자까지 넣습니다. 이 패스의 마지막 그림이 여기서 나옵니다.
왜 중요한가
래스터라이즈는 삼각형을 전제로 합니다. 형태를 바꾸려면 정점을 다시 만들어야 하고, 두 형태를 합치거나 빼는 일은 기하 연산이라 까다롭습니다. 거리 함수는 형태를 수식으로 표현합니다 — 구는 한 줄, 합집합은 min 한 줄입니다.
그리고 그 값이 안전한 전진 거리라는 성질 하나가 알고리즘 전체를 성립시킵니다. 가장 가까운 표면이 d 만큼 떨어져 있으면 어느 방향으로 가도 d 만큼은 아무것도 없으므로, 광선을 그만큼 통째로 건너뛰어도 표면을 지나칠 수 없습니다. 법선은 기울기로 구하고 그림자는 광선을 한 번 더 쏘면 됩니다. 래스터라이즈에서 별도의 자료 구조와 여러 단계가 필요하던 일들이 여기서는 같은 함수 하나로 풀립니다.
단계
/root/raymarch에 도구 상자를 놓습니다./root/raymarch/sdf.py— 거리 함수와 합치기 연산./root/raymarch/march.py— 구 추적./root/raymarch/out/sphere.png— 실루엣./root/raymarch/out/shaded.png— 기울기 법선과 램버트./root/raymarch/out/csg.png— 깎고 붙인 형태./root/raymarch/out/shadow.png와out/07-shadow.txt— 그림자 광선.
참고
- 그림은
nohup python3 -m http.server 8080 -d /root/raymarch/out &로 띄우고 웹 미리보기 에서http://localhost:8080/을 엽니다. - 160x160 이면 광선 25,600 개입니다. 파이썬으로도 한 장에 1초 안쪽입니다 — 거리 함수가 빈 공간을 크게 건너뛰기 때문입니다.
- 흔한 실수 하나: 그림자 광선을 표면 바로 위에서 출발시키는 것입니다. 첫 걸음에 자기 자신을 맞아 모든 픽셀이 그늘이 됩니다.
- 흔한 실수 둘: 거리 함수가 실제 거리보다 큰 값을 돌려주는 것입니다. 광선이 표면을 건너뛰어 구멍이 뚫립니다.
그리기 도구 상자를 놓는다
/root/raymarch/gfxlib.py 를 예시 그대로 저장하고, /root/raymarch/check.py 로 시험 무늬를 그려 /root/raymarch/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) 가 빨강이 됩니다.
이 실습에서는 픽셀마다 광선을 쏜 결과를 그림으로 내보내는 데 씁니다.
거리 함수와 합치기
/root/raymarch/sdf.py 에 sphere(p, r), box(p, b), plane_y(p, h), op_union(a,b), op_intersect(a,b), op_subtract(a,b), smooth_union(a,b,k) 를 만드십시오. 상자는 q = abs(p) - b 로 두고 length(max(q,0)) + min(max(qx,qy,qz), 0) 이며, 차집합은 max(a, -b), 부드러운 합집합은 h = clamp(0.5 + 0.5*(b-a)/k, 0, 1) 에 대해 b*(1-h) + a*h - k*h*(1-h) 입니다.
부호 있는 거리 함수는 안쪽이면 음수, 바깥이면 양수, 표면 위면 0 입니다. 구는 length(p) - r 한 줄로 그 성질을 만족합니다.
상자의 식이 두 항인 이유는 바깥과 안쪽을 나눠 다루기 때문입니다. 바깥에서는 각 축의 초과분을 벡터로 보아 길이를 재고, 안쪽에서는 가장 가까운 면까지의 거리(음수)를 씁니다.
차집합의 -b 가 뜻하는 것은 b 의 안팎을 뒤집는 것입니다. 뒤집은 b 와 a 의 교집합이 곧 "a 에서 b 를 파낸 것" 입니다.
smooth_union 의 마지막 항 -k*h*(1-h) 가 이어지는 부분을 부풀려 매끄럽게 만듭니다. 이 항이 없으면 그냥 선형 보간이라 이음매가 각지게 남습니다.
구 추적
/root/raymarch/march.py 에 march(ro, rd, scene, max_steps=64, max_dist=20.0, eps=0.001) 를 만드십시오. t=0 에서 시작해 d = scene(ro + rd*t) 를 구하고 d < eps 이면 (True, t, 걸음수) 를, t > max_dist 가 되면 (False, t, max_steps) 를 돌려줍니다. 아니면 t += d 로 전진합니다.
거리 함수가 알려 주는 값은 어느 방향으로 가도 안전한 거리입니다. 그래서 그만큼 통째로 건너뛰어도 표면을 지나칠 수 없습니다. 이것이 이 알고리즘이 성립하는 이유 전부입니다.
빈 공간에서는 크게 건너뛰고 표면 가까이서는 조금씩 다가갑니다. 그래서 표면에 스치듯 지나가는 광선이 가장 비쌉니다 — 걸음이 잘게 쪼개집니다.
(0,0,-5) 에서 (0,0,1) 방향으로 원점의 반지름 1 짜리 구를 향해 쏘면 t 가 4 근처에서 멈춥니다. eps 만큼의 오차가 있어 정확히 4 는 아닙니다.
걸음 수도 함께 돌려주십시오. 다음 단계에서 비용을 눈으로 보는 데 쓸 수 있습니다.
실루엣부터
/root/raymarch/render.py 로 160x160 그림 /root/raymarch/out/sphere.png 를 만드십시오. 카메라는 (0,0,-4) 에 있고 픽셀 (x,y) 의 광선 방향은 normalize((px, py, 1.5)), px = (x+0.5)/160*2 - 1, py = -((y+0.5)/160*2 - 1) 입니다. 장면은 원점의 반지름 1 짜리 구뿐이고, 맞으면 흰색(255,255,255) 아니면 검정입니다.
py 에 마이너스가 붙는 것은 이미지의 y 가 아래로 자라기 때문입니다. 이 패스에서 계속 나온 그 문제입니다.
1.5 는 초점 거리에 해당합니다. 크면 시야가 좁아지고(망원) 작으면 넓어집니다(광각). 앞 코스의 시야각과 같은 것을 다른 방식으로 적은 것입니다.
160x160 이면 광선 25,600 개입니다. 파이썬으로도 1초 안쪽에 끝납니다 — 거리 함수가 빈 공간을 크게 건너뛰기 때문입니다.
원의 지름이 화면의 절반쯤 되어야 합니다. 훨씬 작거나 크면 카메라 거리나 초점 거리를 확인하십시오.
기울기로 법선을 구한다
/root/raymarch/shade.py 로 같은 구에 음영을 넣어 /root/raymarch/out/shaded.png 를 만드십시오. 법선은 e=0.001 로 각 축의 거리 차이를 구해 정규화한 것이고, 빛은 normalize((-0.6, 0.7, -0.5)), ambient 0.15, 기본색 (230,180,120), 밝기는 ambient + (1-ambient)*max(0, dot(n, l)) 입니다.
거리 함수의 기울기가 곧 법선입니다. 거리가 가장 가파르게 커지는 방향이 표면의 바깥 방향이기 때문입니다. 법선을 따로 저장할 필요가 없다는 것이 이 방식의 장점 중 하나입니다.
각 축마다 scene(p + e) - scene(p - e) 를 구하면 됩니다. 중앙 차분이라 한 축에 두 번, 모두 여섯 번 거리 함수를 부릅니다.
e 를 너무 작게 잡으면 부동소수점 오차가 커지고, 너무 크게 잡으면 법선이 뭉개집니다. 0.001 이 이 크기의 장면에 맞습니다.
맞은 지점은 ro + rd*t 입니다. 빛이 왼쪽 위에서 오므로 구의 왼쪽 위가 밝아야 합니다.
형태를 깎고 붙인다
/root/raymarch/csg.py 로 다음 장면을 /root/raymarch/out/csg.png 에 그리십시오. base = op_intersect(box(p,(0.85,0.85,0.85)), sphere(p,1.1)) 로 모서리를 둥글린 정육면체를 만들고, base = op_subtract(base, sphere(p-(0,0,-1.0), 0.6)) 으로 앞면을 파낸 뒤, smooth_union(base, sphere(p-(1.0,0.9,0.0), 0.45), 0.35) 로 작은 구를 부드럽게 이어 붙입니다. 음영은 5단계와 같습니다.
세 연산이 각각 무엇을 하는지 보십시오. 교집합은 두 형태가 겹치는 부분만 남겨 모서리를 둥글리고, 차집합은 구멍을 파고, 부드러운 합집합은 이음매를 녹입니다.
형태를 바꾸는 것이 곧 수식을 바꾸는 것입니다. 정점 버퍼도 인덱스 버퍼도 다시 만들 필요가 없습니다. 삼각형으로 같은 일을 하려면 기하 연산 라이브러리가 필요합니다.
좌표를 옮긴 구는 sphere(p - 중심, 반지름) 으로 씁니다. 점을 옮겨서 재는 것이 형태를 옮기는 것과 같습니다.
5단계에서 만든 render(scene, path) 를 장면 함수만 바꿔 그대로 쓰십시오.
그림자 광선
장면을 op_union(sphere(p,1.0), plane_y(p,-1.0)) 으로 두고, 맞은 지점에서 법선 방향으로 0.02 띄운 자리에서 빛 방향으로 max_dist=10.0 으로 다시 마칭해 무언가에 맞으면 ambient 만 적용하십시오. 결과를 /root/raymarch/out/shadow.png 에 쓰고 /root/raymarch/out/07-shadow.txt 에 hits=, shadowed=, fraction= 세 줄을 적으십시오. fraction 은 shadowed / hits 입니다.
그림자는 광선 하나면 됩니다. 맞은 지점에서 광원 쪽으로 다시 마칭해 무언가에 맞으면 그늘입니다. 래스터라이즈에서 그림자 맵을 굽고 좌표를 변환하며 겪는 온갖 문제가 여기서는 없습니다.
법선 방향으로 조금 띄우는 것이 중요합니다. 표면 바로 위에서 출발하면 첫 걸음에 자기 자신을 맞아 모든 픽셀이 그늘이 됩니다. 이 문제를 그림자 여드름(shadow acne)이라고 하고, 래스터라이즈의 그림자 맵에서도 같은 이름으로 나타납니다.
바닥 평면을 넣었으므로 화면 아래쪽이 바닥입니다. 공이 바닥에 그림자를 드리우는 모습이 나와야 합니다.
그늘 비율은 6퍼센트 안팎입니다. 훨씬 크면 띄우기가 모자란 것이고, 0 이면 그림자 광선이 늘 빗나가는 것입니다.