把正方体立到屏幕上
目标
亲自构建相机矩阵和投影矩阵,经过透视除法与视口变换,将立方体呈现在屏幕上。完成后,你将能用一句话说明“顶点着色器做什么”。
为什么重要
一个三维点成为像素坐标前,会经过模型、世界、视图、裁剪四个坐标系。每一步都是一个矩阵,因此可预先相乘成 MVP,GPU 对每个顶点只需执行一次乘法。
这里最重要的是:透视感并非来自矩阵,而是来自除法。矩阵乘法是线性运算,无法表达除法。因此投影矩阵将最后一行设为 (0,0,-1,0),让结果的 w 携带到相机的距离,再用 w 除 x、y、z。理解这一过程,也就能理解深度缓冲精度为何集中在近处,以及 near 平面为何不能随意减小。
步骤
- 在
/root/pipeline中放置工具箱gfxlib.py和mat3d.py。 - 在
/root/pipeline/view.py中实现look_at。 - 在
/root/pipeline/proj.py中实现perspective。 - 在
/root/pipeline/screen.py中实现to_screen,并写入/root/pipeline/out/04-divide.txt。 - 将立方体线框绘制到
/root/pipeline/out/cube.png。 - 添加
orthographic,生成/root/pipeline/out/ortho.png。 - 使用三个视野角绘制,并写入
/root/pipeline/out/07-fov.txt。
参考
- 运行
nohup python3 -m http.server 8080 -d /root/pipeline/out &后,在网页预览中打开http://localhost:8080/。并排查看 cube.png 和 ortho.png,差异会很明显。 - 常见错误 1:将投影矩阵的
m[3][3]设为 1。这样 w 与距离无关,透视就会消失。 - 常见错误 2:遗漏
look_at第三行的负号。相机会看向反方向,画面将完全空白。
放置绘图工具箱
按示例原样保存 /root/pipeline/gfxlib.py,并用 /root/pipeline/check.py 绘制测试图案,生成 /root/pipeline/out/00-check.png。图案是在 64x64 黑色背景上从 (0,0) 到 (63,63) 绘制白色(255,255,255) 对角线,再叠加从 (0,32) 到 (63,32) 的红色(255,0,0) 水平线。本实验还需同时编写矩阵工具 /root/pipeline/mat3d.py。
从本实验开始不再重新实现 PNG 编码器。我们把第一个实验中手写的同类代码作为工具提供,因为这里要学习的不是文件格式。
实验 Pod 没有持久卷,上一个实验创建的文件不会保留。因此每个实验都从重新放置工具箱开始。
创建 Canvas(w, h, bg),用 line(x0, y0, x1, y1, rgb) 绘制两条线,再用 write_png(path) 保存。必须最后绘制水平线,交点 (32,32) 才会是红色。
mat3d.py 与前一个实验中亲自实现的向量和矩阵运算相同。这里将其作为材料,学习相机和投影。
把相机移动到原点
在 /root/pipeline/view.py 中实现 look_at(eye, target, up),返回 4x4 视图矩阵。三个轴为 f = normalize(target-eye)、s = normalize(cross(f, up))、u = cross(s, f),第三行使用 -f。
矩阵每一行是相机的一个轴,最后一列是沿该轴抵消相机位置的值,即 row0 = (s.x, s.y, s.z, -dot(s, eye))。
只有第三行符号相反,因为右手坐标系约定相机朝 -z 方向观察。因此该行为 (-f.x, -f.y, -f.z, dot(f, eye))。
验证方式很简单:当 eye=(0,0,5)、target=(0,0,0)、up=(0,1,0) 时,原点应变为 (0, 0, -5),表示它位于相机前方 5 个单位。
向量运算使用 mat3d 的 normalize、cross、dot、vsub。
将视锥压成立方体
在 /root/pipeline/proj.py 中实现 perspective(fov_deg, aspect, near, far)。令 t = 1/tan(fov/2),设置 m[0][0]=t/aspect、m[1][1]=t、m[2][2]=-(far+near)/(far-near)、m[2][3]=-2*far*near/(far-near)、m[3][2]=-1,其余为 0。
最后一行 (0, 0, -1, 0) 几乎就是该矩阵的全部关键。它使结果的 w 分量成为到相机的距离(-z),下一步除法由此产生透视。
m[3][3] 为 0。若设为 1,w 会与距离无关,透视便会消失。
公式为 t = 1/tan(radians(fov)/2)。若忘记把 fov 转成弧度,视野角会异常,却不会报错。
除以 w 并转换为屏幕坐标
在 /root/pipeline/screen.py 中实现 to_screen(clip, w, h)。接收裁剪坐标 (x,y,z,w),先计算 ndc = (x/w, y/w),再以 sx = (ndc_x*0.5+0.5)*w_px、sy = (1-(ndc_y*0.5+0.5))*h_px 返回两个浮点数。然后用 perspective(60,1,0.1,100) 将视图坐标点 A(1,0,-2) 和 B(1,0,-8) 映射到 256x256 屏幕,并在 /root/pipeline/out/04-divide.txt 中写成 A=sx,sy、B=sx,sy 两行(小数点后六位)。
两点 x 都为 1,只有深度不同。若除法正确,更远的 B 应更靠近屏幕中心(128),这就是透视。
sy 中用 1 相减是为了翻转 y,因为图像 y 轴向下增长。
mat3d.apply(P, (1,0,-2)) 以 w=1 相乘并返回四个分量,再把结果传给 to_screen。
立方体的十二条边
使用 /root/pipeline/cube.py 生成 /root/pipeline/out/cube.png(256x256、黑色背景、白线)。立方体由八个顶点 (±1,±1,±1) 及连接它们的十二条边组成;相机为 look_at((2.5,2.5,2.5),(0,0,0),(0,1,0)),投影为 perspective(60, 1, 0.1, 100)。
边是两个顶点坐标中仅一个轴不同的顶点对。从八个顶点中选择这样的组合,恰好得到十二条边。
MVP 为 mat3d.mul(P, V)。因为模型矩阵为单位矩阵而省略,乘法顺序中投影位于左侧。
每个顶点只变换一次并保存屏幕坐标列表,再从列表取端点,用 cv.line 连接。逐边重复计算顶点会做两遍相同工作。
与正交投影对照
在 /root/pipeline/proj.py 中添加 orthographic(l, r, b, t, n, f),并用同一相机生成 /root/pipeline/out/ortho.png。边界为 l=-2.5, r=2.5, b=-2.5, t=2.5, n=0.1, f=100。
正交投影矩阵最后一行为 (0, 0, 0, 1)。w 始终为 1,除法成为恒等操作,因此物体大小不随距离变化。
对角元素为 2/(r-l)、2/(t-b)、-2/(f-n),最后一列为 -(r+l)/(r-l)、-(t+b)/(t-b)、-(f+n)/(f-n)。这相当于把范围平移并缩放到 -1 至 1。
并排查看两张图即可看出区别:透视投影中近面大、远面小;正交投影中相对的两个面大小相同,看起来更像六边形。
改变视野角会发生什么
用 60 度、75 度、90 度视野角绘制同一立方体,生成 /root/pipeline/out/fov60.png、fov75.png、fov90.png;并把每张图中立方体占据的水平宽度(八个顶点屏幕 x 坐标最大值减最小值)写入 /root/pipeline/out/07-fov.txt,使用 fov60=、fov75=、fov90= 三行(小数点后三位)。
保持相机位置不变,只改变视野角。视野越宽,画面能容纳的内容越多,因此同一物体反而越小。三个数值应依次减小。
不要从图像中计数,直接由变换后的顶点坐标计算宽度:八个 sx 的最大值减最小值。
把上一步代码封装为仅接收视野角参数的函数,就无需重复三次。