LabHub
学习 学习路径 课程

3D 数学与软件光栅化器

把正方体立到屏幕上

在 LabHub 中继续学习

目标

亲自构建相机矩阵和投影矩阵,经过透视除法与视口变换,将立方体呈现在屏幕上。完成后,你将能用一句话说明“顶点着色器做什么”。

为什么重要

一个三维点成为像素坐标前,会经过模型、世界、视图、裁剪四个坐标系。每一步都是一个矩阵,因此可预先相乘成 MVP,GPU 对每个顶点只需执行一次乘法。

这里最重要的是:透视感并非来自矩阵,而是来自除法。矩阵乘法是线性运算,无法表达除法。因此投影矩阵将最后一行设为 (0,0,-1,0),让结果的 w 携带到相机的距离,再用 w 除 x、y、z。理解这一过程,也就能理解深度缓冲精度为何集中在近处,以及 near 平面为何不能随意减小。

步骤

  1. /root/pipeline 中放置工具箱 gfxlib.pymat3d.py
  2. /root/pipeline/view.py 中实现 look_at
  3. /root/pipeline/proj.py 中实现 perspective
  4. /root/pipeline/screen.py 中实现 to_screen,并写入 /root/pipeline/out/04-divide.txt
  5. 将立方体线框绘制到 /root/pipeline/out/cube.png
  6. 添加 orthographic,生成 /root/pipeline/out/ortho.png
  7. 使用三个视野角绘制,并写入 /root/pipeline/out/07-fov.txt

参考

放置绘图工具箱

按示例原样保存 /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 个单位。

向量运算使用 mat3dnormalizecrossdotvsub

将视锥压成立方体

/root/pipeline/proj.py 中实现 perspective(fov_deg, aspect, near, far)。令 t = 1/tan(fov/2),设置 m[0][0]=t/aspectm[1][1]=tm[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_pxsy = (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,syB=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.pngfov75.pngfov90.png;并把每张图中立方体占据的水平宽度(八个顶点屏幕 x 坐标最大值减最小值)写入 /root/pipeline/out/07-fov.txt,使用 fov60=fov75=fov90= 三行(小数点后三位)。

保持相机位置不变,只改变视野角。视野越宽,画面能容纳的内容越多,因此同一物体反而越小。三个数值应依次减小。

不要从图像中计数,直接由变换后的顶点坐标计算宽度:八个 sx 的最大值减最小值。

把上一步代码封装为仅接收视野角参数的函数,就无需重复三次。