让平面看起来凹凸不平
目标
把法线存入纹理,并用它让平坦的表面看起来凹凸不平。完成本实验后,你将能够解释法线贴图为什么是淡紫蓝色,以及为什么需要切线空间。
为什么重要
人们辨认凹凸的线索,大多是亮度的变化。亮度由法线与光线方向的点积决定,所以不改变几何,只逐像素替换法线,也能得到同样的效果。一个只有两个三角形的平面,就能呈现出砖块的凹凸。
这里重要的是存放哪个坐标系的法线。如果存的是世界空间法线,物体一旋转就全部出错。因此要存到以表面为基准的坐标系(切线空间)中,在这个坐标系里,“平坦”始终是 (0, 0, 1)。法线贴图整体看起来是淡紫色,就是这个值所对应的颜色。
步骤
- 在
/root/normalmap中放置工具箱。 /root/normalmap/encode.py— 法线与颜色之间的转换。/root/normalmap/out/normalmap.png— 半球网格法线贴图。/root/normalmap/tbn.py— 切线空间的三个轴。/root/normalmap/out/flat.png— 用常量法线照亮的平面。/root/normalmap/out/bumped.png— 用法线贴图照亮的同一个平面。/root/normalmap/out/l0.png到l5.png以及out/07-sweep.txt— 移动光源。
参考
- 运行
nohup python3 -m http.server 8080 -d /root/normalmap/out &后,在网页预览中打开http://localhost:8080/。六张图轮流显示时,可以看到凹凸随光线而反应。 - 本实验的平面位于 XY 平面,并使用标准 UV,所以切线空间与世界空间相同。因此 TBN 变换成为恒等变换,计算变得简单。如果是其他姿态的物体,则必须经过这一步。
- 常见错误 1:在
decode中漏掉归一化。被截断为 8 位后长度不再是 1,亮度会出现细微偏差。 - 常见错误 2:把颜色截断为整数时超过 255。明亮的地方会翻转成奇怪的颜色。
放置绘图工具箱
按示例原样保存 /root/normalmap/gfxlib.py,并用 /root/normalmap/check.py 绘制测试图案,生成 /root/normalmap/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/normalmap/encode.py 中编写 encode(n) 和 decode(rgb)。encode 对三个分量应用 (q*0.5 + 0.5) * 255,返回得到的三个实数;decode 应用 c/255*2 - 1 之后返回经过归一化的单位向量。
法线的每个分量在 -1 到 1 之间,而纹理通道在 0 到 255 之间。要做的只是把范围换算过去。
平坦部分的法线 (0, 0, 1) 会变成 (127.5, 127.5, 255)。法线贴图整体看起来是淡紫蓝色,原因就在这里——这个颜色就表示“这里是平坦的”。
decode 中要做归一化,是因为被截断为 8 位后,长度不再恰好是 1。不做归一化,点积就不再等于余弦,亮度会出现细微偏差。
遇到长度为 0 的输入(不是 (128,128,128) 附近,而是恰好还原为原点的值),要避免除以 0。
制作法线贴图
用 /root/normalmap/genmap.py 把 128x128 的法线贴图写入 /root/normalmap/out/normalmap.png。按格子大小 32 划分,求出 cx = (x%32)/32*2 - 1、cy = (y%32)/32*2 - 1、r2 = cx*cx + cy*cy,当 r2 < 1 时法线取 (cx, cy, sqrt(1-r2)),否则取 (0, 0, 1),经 encode 后保存。
(cx, cy, sqrt(1-r2)) 既是单位半球上的点,也是该点的法线。因为在半径为 1 的球上,位置向量与法线相同。
每个格子是 32 像素,所以 128x128 的图中可容纳 4x4 = 十六个圆顶。格子的边缘(r2 >= 1)是平坦的底面。
把颜色截断为整数时,不要超出 0 到 255 的范围。encode 的结果本来就在这个范围内,但四舍五入时可能溢出。
生成的图像应当是淡紫色背景上带有圆形图案的样子。这个淡紫色就是 (128,128,255),也就是平坦。
切线空间的三个轴
在 /root/normalmap/tbn.py 中编写 build_tbn(p0, p1, p2, uv0, uv1, uv2),返回 (T, B, N) 三个单位向量。令 e1 = p1-p0、e2 = p2-p0、d1 = uv1-uv0、d2 = uv2-uv0、r = 1/(d1x*d2y - d2x*d1y),则 T = (e1*d2y - e2*d1y)*r、B = (e2*d1x - e1*d2x)*r、N = normalize(cross(e1, e2)),T 和 B 也要归一化。
这个公式是把“UV 变化这么多时,位置变化这么多”这两个方程联立求解得到的。T 是表面上 u 增大的方向,B 是 v 增大的方向。
分母可能为 0。当三角形的 UV 缩成一个点或位于同一条直线上时就会如此,这样的三角形没有定义切线空间。为 0 时,随便给定一个值后继续即可,例如 T=(1,0,0)、B=(0,1,0)。
给 XY 平面上的三角形贴上标准 UV,会得到 T=(1,0,0)、B=(0,1,0)、N=(0,0,1)。也就是说,这是切线空间恰好等于世界空间的特殊情形。
翻转 v 方向时,B 也会翻转。法线贴图绿色通道的约定出现分歧,就是在这里。
用常量法线照亮
用 /root/normalmap/light.py 照亮 128x128 的平面,生成 /root/normalmap/out/flat.png。所有像素的法线都是 (0,0,1),光线为 normalize((0.0, 0.3, 0.8)),ambient 为 0.15,基础颜色为 (220,200,180),亮度为 ambient + (1-ambient)*max(0, dot(n, l))。
由于所有像素的法线相同,整幅图像是同一种颜色。这是正常的。
这一步的目的,是建立与下一步比较的基准。要看几何和光照都相同、只替换法线时会有什么变化,就必须有一个基准。
把颜色截断为整数时,不要超过 255。
这个平面位于 XY 平面,UV 也是标准的,所以切线空间就是世界空间。因此在下一步中,TBN 变换成为恒等变换,计算变得简单。
用法线贴图照亮
使用同一个平面、同一束光,只是法线取自第 3 步的 normal_at(x, y),生成 /root/normalmap/out/bumped.png。
几何一点也没有变,仍然是一个平面,只是逐像素替换了法线。
但结果看起来像是嵌了一片半球网格。这是因为人们辨认凹凸的线索就是亮度的变化。
平均亮度与平坦的一侧不应相差太大,因为变亮的地方和变暗的地方会相互抵消。相反,亮度的离散程度(方差)会大得多。
这个平面位于 XY 平面并使用标准 UV,所以切线空间与世界空间相同。因此省略 TBN 变换,结果也一样——如果是其他姿态的物体,则必须经过这一步。
移动光源
把光线的 x 分量从 -0.9 到 0.9 分六档(-0.9 + 1.8*k/5)依次改变,生成用法线贴图照亮的图像 /root/normalmap/out/l0.png 到 l5.png,并对每张图中从坐标 (32,32) 开始的 32x32 方格,把左半部分平均亮度减去右半部分平均亮度的值,以 a0= 到 a5= 的形式写入 /root/normalmap/out/07-sweep.txt。再创建包含这六个名称的 /root/normalmap/out/index.html。
光线的 y、z 分量保持 0.3 和 0.8 不变,只改变 x。
左半部分更亮时 a 为正,右半部分更亮时为负。光线从左向右移动,所以六个值应当持续减小。这就是凹凸随光线而反应的证据。
平均值要把通道也加在一起再相除——每个像素有三个值,所以除以 16*32*3 即可。
把六张图连起来看,每个圆顶只有迎光的一侧变亮,仿佛活动起来一样。这就是法线贴图的作用。