把旋转的物体输出成八张
目标
为长方体打光,使各个面呈现不同亮度;改变角度输出八帧,并在浏览器中播放。完成本实验后,课程 1 的所有部分将连成整体——向量、矩阵、相机、投影、光栅化、深度和光照。
为什么重要
只用一种颜色绘制的立方体看起来只是六边形轮廓,而不像立体物体,因为人判断形状的线索来自亮度差异。亮度与表面法线和光照方向夹角的余弦成正比,将两个向量归一化后做点积便能直接得到该余弦。计算如此简单,结果却立刻可见。
这里有两个常见错误:一是遗漏 max(0, ...),让背光面的亮度变成负数;二是用模型矩阵旋转法线时按 w=1 相乘,把平移量也加进去。第二个问题在物体靠近原点时不易暴露,往往会长期残留。
步骤
- 在
/root/shading中放置工具箱gfxlib.py和mat3d.py。 - 使用
/root/shading/mesh.py创建长方体和面法线,并写入out/02-normals.txt。 - 在
/root/shading/light.py中实现 Lambert 漫反射。 - 在
/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:面法线符号相反。如果受光面很暗、背光面很亮,请反转顶点环绕顺序。
- 常见错误 2:忘记归一化。若法线长度不为 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 编码器。我们把第一个实验中手写的同类代码作为工具提供,因为这里要学习的不是文件格式。
实验 Pod 没有持久卷,上一个实验创建的文件不会保留。因此每个实验都从重新放置工具箱开始。
创建 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 六个方向,并各出现两次。
Lambert 漫反射
在 /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 应用 Lambert 光照。
法线也必须随模型矩阵一起旋转。但它是方向而非位置,因此应像 mat3d.apply(model, n, 0.0) 那样按 w=0 相乘。若按 w=1 相乘,平移量也会加入,导致光照错误。
深度使用裁剪坐标 z 除以 w 的结果,并在三角形内部以重心坐标插值。这与前一个实验实现的 z-buffer 相同。
颜色转换成整数前应截断到 255。Lambert 系数虽不超过 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>,使用 JavaScript 的 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 三行。缩放采用每个横纵 2 像素块只取左上角像素的最近邻方式,第 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。
实际工作中也会使用这种对照图。与逐帧打开相比,它更容易观察动画的整体变化。