TT Lab
开始
学习 学习路径 课程

着色器与 GPU 管线

用距离函数一直做到阴影

在 TT Lab 中继续学习

目标

只用距离函数定义形状,用球体追踪找到表面,用梯度求出法线,再加入明暗和阴影。本课程的最后一张图就是在这里生成的。

为什么重要

光栅化以三角形为前提。要改变形状就必须重新生成顶点,而合并或挖去两个形状属于几何运算,相当麻烦。距离函数用公式表示形状——球只需一行,并集只需一行 min。

而且,该值是安全前进距离,仅凭这一个性质就让整个算法得以成立。如果最近的表面相距 d,那么无论朝哪个方向走,d 的范围内都不会有任何东西,因此光线整段跳过去也不会越过表面。法线用梯度求,阴影只需再发射一次光线。光栅化中需要单独的数据结构和多个阶段才能完成的事情,在这里用同一个函数就解决了。

步骤

  1. 在 /root/raymarch 中放置工具箱。
  2. /root/raymarch/sdf.py — 距离函数与组合运算。
  3. /root/raymarch/march.py — 球体追踪。
  4. /root/raymarch/out/sphere.png — 轮廓。
  5. /root/raymarch/out/shaded.png — 梯度法线与朗伯模型。
  6. /root/raymarch/out/csg.png — 切削并拼合的形状。
  7. /root/raymarch/out/shadow.png 和 out/07-shadow.txt — 阴影光线。

参考

放置绘图工具箱

按示例原样保存 /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,说明阴影光线总是落空。