LabHub
学习 学习路径 课程

3D 数学与软件光栅化器

把旋转的物体输出成八张

在 LabHub 中继续学习

目标

为长方体打光,使各个面呈现不同亮度;改变角度输出八帧,并在浏览器中播放。完成本实验后,课程 1 的所有部分将连成整体——向量、矩阵、相机、投影、光栅化、深度和光照。

为什么重要

只用一种颜色绘制的立方体看起来只是六边形轮廓,而不像立体物体,因为人判断形状的线索来自亮度差异。亮度与表面法线和光照方向夹角的余弦成正比,将两个向量归一化后做点积便能直接得到该余弦。计算如此简单,结果却立刻可见。

这里有两个常见错误:一是遗漏 max(0, ...),让背光面的亮度变成负数;二是用模型矩阵旋转法线时按 w=1 相乘,把平移量也加进去。第二个问题在物体靠近原点时不易暴露,往往会长期残留。

步骤

  1. /root/shading 中放置工具箱 gfxlib.pymat3d.py
  2. 使用 /root/shading/mesh.py 创建长方体和面法线,并写入 out/02-normals.txt
  3. /root/shading/light.py 中实现 Lambert 漫反射。
  4. /root/shading/out/frame00.png 绘制受光的长方体。
  5. 每隔 45 度输出八帧(frame00.png ~ frame07.png)。
  6. 使用 /root/shading/out/index.html 在浏览器中播放。
  7. 将八帧拼接成 /root/shading/out/spin.png,并生成 out/07-sheet.txt

参考

放置绘图工具箱

按示例原样保存 /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_ymulapplynormalizecrossdot。本实验将它们作为材料,学习重点是光照。

长方体网格与面法线

/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,nzf12=

[(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.pngframe07.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.pngframe07.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=8cell=128x128sheet=512x256 三行。缩放采用每个横纵 2 像素块只取左上角像素的最近邻方式,第 k 帧放在 행 = k // 4열 = k % 4 的位置。

可使用 gfxlib.read_png(path) 读取已生成的 PNG,再通过 Canvasget(x, y) 取得像素。

最近邻缩放就是 cell.get(i, j) = frame.get(2*i, 2*j)。不要计算平均值——评分器会逐值核对。

放置位置为 x = 열*128 + iy = 행*128 + j

实际工作中也会使用这种对照图。与逐帧打开相比,它更容易观察动画的整体变化。