用距离函数一直做到阴影
目标
只用距离函数定义形状,用球体追踪找到表面,用梯度求出法线,再加入明暗和阴影。本课程的最后一张图就是在这里生成的。
为什么重要
光栅化以三角形为前提。要改变形状就必须重新生成顶点,而合并或挖去两个形状属于几何运算,相当麻烦。距离函数用公式表示形状——球只需一行,并集只需一行 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 条光线。即使用 Python,一张图也能在 1 秒内完成——因为距离函数能大步跳过空旷区域。
- 常见错误 1:让阴影光线紧贴表面出发。第一步就会命中自身,所有像素都变成阴影。
- 常见错误 2:距离函数返回的值大于实际距离。光线会越过表面,形状上出现孔洞。
放置绘图工具箱
按示例原样保存 /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 编码器。我们把第一个实验中手写的同类代码作为工具提供,因为这里要学习的不是文件格式。
实验 Pod 没有卷,上一个实验创建的文件不会保留。因此每个实验都从重新放置工具箱开始。
创建 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 条光线。即使用 Python,也能在 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),在光栅化的阴影贴图中也以同样的名字出现。
因为加入了地面平面,所以屏幕下方是地面。应当能看到球在地面上投下阴影的样子。
阴影的比例大约是百分之六。如果大得多,说明抬高得不够;如果为 0,说明阴影光线总是落空。