本节摘要:投影是三维到二维的临门一脚:透视矩阵把视锥扭成一个规则六面体,透视除法(除以 w)实现近大远小,齐次裁剪以"比较绝对值"的低成本剔除视野外几何,深度值由此诞生,谁遮谁的问题交给 Z-buffer 解决。本节把 4.2 的相机空间一路送到第 3 章光栅化的门口,是"像素诞生记"主线最关键的一棒。
阅读完本节,你应当能够:
针孔相机模型:三维点 (x, y, z) 在相机看向负 z 的约定下,投影到距离相机 d 的平面上。按相似三角形:
屏幕x = d · x / (-z) 屏幕y = d · y / (-z) 同一个 1 米高的物体: 距相机 2 米:屏幕高 = d·1/2 = 0.5d 距相机 5 米:屏幕高 = d·1/5 = 0.2d 远处的等大物体投影更小——透视的全部秘密就是除以深度
1.2 节埋的伏笔在此兑现:齐次坐标除以 w 的机制,正是为这一步准备的。透视矩阵把 -z 悄悄塞进 w 分量,随后的一次除法自然完成"除以深度",这个设计让投影完全融入矩阵乘法体系,GPU 顶点阶段无需任何特殊指令。
图形 API 约定投影后进入"齐次裁剪空间":除以 w 之后,可见范围恰好是各分量绝对值 ≤1 的立方体。把视锥(近小远大的六面棱台)扭成这个立方体的矩阵:
透视投影矩阵 P(列向量约定,f=1/tan(视场角/2),r=宽高比,n/f=近/远平面): f/r 0 0 0 0 f 0 0 0 0 (f+n)/(n-f) 2fn/(n-f) 0 0 -1 0
import numpy as np def perspective(fov_deg, aspect, n, f): # 构造透视投影矩阵 t = 1 / np.tan(np.radians(fov_deg) / 2) M = np.zeros((4,4)) M[0,0] = t / aspect M[1,1] = t M[2,2] = (f+n) / (n-f) M[2,3] = 2*f*n / (n-f) M[3,2] = -1 # 关键:把 -z 复制进 w return M P = perspective(90, 1.0, 0.1, 100) pts = [(0,0,-0.5), (0,0,-2), (0,0,-10)] # 同一视线上的三个点 for p in pts: v = P @ np.array([*p, 1.0]) ndc = v[:3] / v[3] # 透视除法 print(f"深度 z={p[2]:6}: 裁剪w={v[3]:6.2f} NDC={np.round(ndc,3)}") # 输出: # 深度 z= -0.5: 裁剪w= 0.50 NDC=[ 0. 0. -0.778] # 深度 z= -2.0: 裁剪w= 2.00 NDC=[ 0. 0. 0.111] # 深度 z= -10.0: 裁剪w= 10.00 NDC=[ 0. 0. 0.889]
裁剪发生在除法之前:直接检查裁剪坐标 |x|≤w、|y|≤w、|z|≤w,六个平面全是简单比较——3.2 节的裁剪问题被投影矩阵精心设计成了几乎白送的形式。三角形与平面相交时,交点参数直接用于裁剪顶点属性(3.2 节的告诫在此同样成立)。
深度值藏着一个坑:NDC 的 z 分量不是线性分布。上面输出可见,近处 0.5 米就已映射到 -0.78,前 5% 的深度区间挤掉了几乎一半的精度。这是刻意设计:近处物体更醒目,精度优先给近处;代价是远处共面物体深度难分,出现斑驳闪烁的 z-fighting。工程修法:近平面在安全前提下尽量推远、两共面物体手工偏移、或用浮点深度缓冲。
投影压平了世界,重叠像素必须裁决归属。三种经典思路:
| 方案 | 思路 | 代价 | 失效场景 |
|---|---|---|---|
| 画家算法 | 按深度从远到近画,近者覆盖远者 | 需整体排序 | 三角形互相穿插时无全局序 |
| Z-buffer | 每像素记深度,更近才覆盖 | 一张全屏深度图 | 共面 z-fighting |
| BSP 树 | 预计算空间剖分顺序 | 建树成本、静态场景限定 | 动态几何需重建 |
Z-buffer 胜在"逐像素、无需全局序、天然并行",成为硬件标配:
def zbuffer_raster(tris, w, h): # tris: 每项含屏幕顶点、深度、颜色(简化:每三角形恒定深度) color = np.zeros((h, w, 3), dtype=np.uint8) depth = np.full((h, w), np.inf) # 深度图初始化为无穷远 for (vs, z, c) in tris: for (x, y) in vs: # 简化:逐像素枚举 if z < depth[y, x]: # 更近者胜出 depth[y, x] = z color[y, x] = c return color, depth frame, depth = zbuffer_raster( [([(3,3)], 5.0, (255,0,0)), # 红色方块,远处 ([(3,3)], 2.0, (0,0,255)), # 蓝色方块,近处,同一像素 ([(3,3)], 8.0, (0,255,0))], 8, 6) # 绿色最远 print(frame[3,3], "胜出深度 =", depth[3,3]) # 输出: [ 0 0 255] 胜出深度 = 2.0 蓝色(最近)覆盖红色与绿色
画家算法并未退役:不透明物体先画粗略排序(前到后,配合提前深度测试省着色)、半透明物体后到前逐层混合——顺序错了半透明立刻浑浊,这是 6.3 节混合阶段的伏笔。
第 4 站到站。像素已知道自己在哪、离相机多远,但它还不知道自己该是什么颜色——第 5 章,光照登场。