投影与深度精度
投影与深度精度
本篇位置:模块一 · 第 4 篇 | 前置:齐次坐标与 MVP 变换链 | 预计阅读:25 分钟
学习目标
- 区分正交投影(orthographic projection)与透视投影(perspective projection)的矩阵形式,说清楚各自的适用场景。
- 解释深度值为什么是非线性的、精度堆在近处对远处物体意味着什么。
- 会从深度缓冲(depth buffer)里存的值反推出眼空间 Z(线性化公式),这是深度重投影必备的一步。
- 知道 OpenGL 与 Vulkan 深度范围约定的差异,以及 reverse-Z 大致在解决什么问题。
正交投影:一个盒子搬进另一个盒子
上一篇结尾手算了一个点 $(1,1,-5)$ 经过透视矩阵之后,NDC 的 $z$ 分量落在 $0.8182$——这个数字从哪来、和真实距离 $5$ 是什么关系,正是这一篇要拆开讲的问题。但在动透视之前,先看一种更简单的投影:正交投影,因为它的矩阵形式能直接照出透视投影"多做了什么"。
正交投影要解决的问题很朴素:把观察空间里一个轴对齐的长方体(视箱,view box)$x\in[l,r]$、$y\in[b,t]$、$z\in[-f,-n]$,线性地搬进 NDC 的标准立方体 $[-1,1]^3$。既然是搬箱子,每个轴独立处理:先把区间宽度缩放成 $2$(NDC 的边长),再把中心平移到原点。以 OpenGL 约定写出矩阵:
$$ M_{\text{ortho}}=\begin{bmatrix} \frac{2}{r-l}&0&0&-\frac{r+l}{r-l}\ 0&\frac{2}{t-b}&0&-\frac{t+b}{t-b}\ 0&0&-\frac{2}{f-n}&-\frac{f+n}{f-n}\ 0&0&0&1 \end{bmatrix} $$
对照上一篇讲的"矩阵最后一列管平移、对角线管缩放",这个矩阵完全符合直觉:对角线三项 $\frac{2}{r-l}$、$\frac{2}{t-b}$、$-\frac{2}{f-n}$(带负号是因为 $z$ 轴朝屏幕内是负方向)把每个轴的区间宽度压缩成 $2$;最后一列的三项把区间中心搬到原点。最关键的一行是第四行——恒为 $[0,0,0,1]$,意味着不管输入点是什么,齐次坐标的 $w$ 分量原样是 $1$,透视除法(除以 $w$)变成了除以 $1$ 的空操作。这正是正交投影和透视投影的分水岭:正交投影里没有任何环节把"离相机的距离"编码进 $w$,所以物体不会因为远近产生大小变化,平行线投影完还是平行线,没有灭点(vanishing point)。
这个"不透视"的性质决定了它的两个经典用途。一是 UI 与屏幕空间元素:血条、小地图、调试网格这类东西不应该因为在场景里挪了个位置就变大变小,用正交投影渲染能保证像素级的尺寸稳定。二是阴影贴图(shadow map)里的方向光(directional light,比如太阳):太阳光可以近似成一束平行光线,从光源视角看过去的"视锥体"根本不是锥形而是一个长方体——这恰好和正交投影的视箱形状完全对应,所以方向光的阴影贴图几乎总是用正交投影生成,而不是透视投影。
透视投影矩阵拆解
透视投影矩阵要做的事情比正交投影多一步:把一个真正的视锥体(frustum,近端小、远端大的四棱锥台)压成立方体,同时还要把距离信息编码进 $w$,为后续的除法铺路。仓库里的参考实现在 labs/common/transforms.py 的 perspective(fovy_deg, aspect, near, far),逐行拆开看:
t = 1.0 / np.tan(np.radians(fovy_deg) / 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.0t = 1/tan(fovy/2) 这一行在做的事情,本质上和相机镜头的焦距(focal length)是同一个概念:fovy(垂直视场角,field of view)越大,意味着镜头越"广角",$\tan(fovy/2)$ 越大,$t$ 就越小;fovy 越小则相反,$t$ 越大,等效于长焦镜头。它的作用是把一个用角度描述的视场,转换成一个可以直接乘在坐标上的线性缩放系数——在观察空间里 $z=-1$ 处,视场边缘的点恰好被这个系数映射到 $y=\pm1$。M[1,1]=t 直接把这个缩放系数用在 $y$ 上;M[0,0]=t/aspect 在 $x$ 方向多除了一次宽高比(aspect ratio),这一步纯粹是为了适配非正方形的视口——如果视口更宽,$x$ 方向需要覆盖更大的水平视场,除以 aspect(宽/高)就把这个差异找补回来,保证画面不会因为屏幕不是正方形而被拉伸变形。
第三行 M[2,2]、M[2,3] 长得比正交投影复杂得多,原因是正交投影里 $z$ 的映射是线性的(视箱厚度直接缩放平移),而透视投影必须让 $z$ 的映射变成非线性——具体为什么非线性、非线性到什么程度,是下一节的主题,这里先记住这一行的输出叫 $z_c$(裁剪空间 $z$,还没做除法)。真正决定"透视"这个效果的其实是最后一行 M[3,2]=-1.0:它不碰 $x_c,y_c,z_c$ 中的任何一个,只做一件事——把观察空间的 $z$ 坐标(对着屏幕内是负值)取反之后,原样塞进 $w_c$。取 $-1$ 而不是 $+1$,是因为 OpenGL 约定相机看向 $-z$ 方向,视锥体内的点 $z_{\text{view}}$ 全是负数,乘以 $-1$ 之后 $w_c=-z_{\text{view}}$ 才是一个正的距离值。这一行看似不起眼,却是整套透视投影里唯一负责"把距离信息记下来"的地方——上一篇讲过,正是这个 $w_c$,在裁剪之后的除法里把"近大远小"变成了数学上的必然结果。
深度的非线性
有了 $w_c$ 编码距离这个铺垫,现在可以正式推一遍深度缓冲里实际存的值——NDC 深度 $z_{ndc}$——和眼空间距离之间的关系。约定 $z_{eye}$ 是点到相机的正距离,按 OpenGL 相机看向 $-z$ 的约定,这个点在观察空间里的真实坐标是 $z_{\text{view}}=-z_{eye}$。代入上一节矩阵的第三行和第四行:
$$ z_c=M_{22},z_{\text{view}}+M_{23}=-\frac{f+n}{f-n}\cdot(-z_{eye})-\frac{2fn}{f-n}=\frac{(f+n)z_{eye}-2fn}{f-n} $$
$$ w_c=M_{32},z_{\text{view}}=-1\cdot(-z_{eye})=z_{eye} $$
透视除法就是拿 $z_c$ 除以 $w_c$,两步代入之后约掉一个 $(f-n)$:
$$ z_{ndc}=\frac{z_c}{w_c}=\frac{(f+n)z_{eye}-2fn}{(f-n),z_{eye}} $$
这就是深度缓冲实际编码的函数。它对 $z_{eye}$ 不是线性的——分母里的 $z_{eye}$ 才是问题所在:把 $z_{eye}$ 拆成 $\frac{f+n}{f-n}-\frac{2fn}{(f-n)z_{eye}}$ 会更直观,当 $z_{eye}=f$ 时代入公式恰好得到 $z_{ndc}=1$(far 平面对应值);若继续让 $z_{eye}\to\infty$(已越过 far 平面,实际渲染中不会发生),曲线才逼近水平渐近线 $\frac{f+n}{f-n}$(在 $n\ll f$ 的常见配置下略大于 1)。这个"趋近"的速度是 $1/z_{eye}$ 量级的,越靠近相机变化越剧烈,越远离相机曲线越平缓——直白地说,深度缓冲的编码精度绝大部分都花在了离相机很近的一小段距离上,远处一大片空间被挤压进了 $z_{ndc}$ 里非常狭窄的一段数值区间。
图:两条曲线分别是 near=0.1 和 near=1.0(far=100 不变)时 $z_{ndc}$ 随眼空间距离的变化,圆点标出各自 $z_{ndc}$ 达到 $0.9$(吃掉 95% 编码区间)的位置。near=0.1 时不到 2 个单位就已经到达这一点,near=1.0 时要推迟到 17 个单位左右——near 只是从 1.0 缩小十倍到 0.1,远处(比如 90 个单位附近)同样的真实距离变化在 NDC 里能分到的编码步长就粗糙了约 10 倍。near 值不要随手设得很小,这是每个图形工程师迟早都要挨的一次打:为了让近处物体不被裁剪就把 near 从 1.0 改成 0.1 看起来毫无代价,实际上是拿远处几乎全部的深度精度去换的。
深度线性化与还原
深度缓冲里存的是 $z_{ndc}$,但很多场景需要反过来:拿到一个深度值,算出它对应的真实眼空间距离——延迟渲染(deferred shading)里从深度图重建观察空间坐标、后处理阶段做雾效或景深、以及插帧算法里做深度引导的重投影(reprojection),全都要先做这一步。把上一节的公式倒过来解出 $z_{eye}$:
$$ z_{ndc}=\frac{(f+n)z_{eye}-2fn}{(f-n),z_{eye}} \ \Longrightarrow
z_{ndc}(f-n)z_{eye}=(f+n)z_{eye}-2fn \ \Longrightarrow
z_{eye}\big[(f+n)-z_{ndc}(f-n)\big]=2fn $$
$$ z_{eye}=\frac{2fn}{(f+n)-z_{ndc}(f-n)} $$
这个式子和上一节的正向公式互为反函数——把一个 $z_{eye}$ 代进正向公式算出 $z_{ndc}$,再把这个 $z_{ndc}$ 代进这里,应该原样拿回同一个 $z_{eye}$。这条线性化公式(叫"线性化"是因为它把非线性压缩过的深度值展开回了线性的真实距离)里唯一的输入是深度缓冲值本身,加上 near、far 这两个渲染时就已知的常量,不需要额外的顶点数据——这正是它能在后处理阶段(此时早已没有原始几何信息)被大量使用的原因。
工程现实
上面推导用的 $z_{ndc}\in[-1,1]$ 是 OpenGL 的约定,但并不是所有图形 API 都这么选。Vulkan(以及 Direct3D)把深度范围定义在 $[0,1]$,对应的投影矩阵第三行系数会相应改写(把映射目标从 $[-1,1]$ 换成 $[0,1]$),但深度值随 $z_{eye}$ 非线性变化、精度堆在近处这个结论本身不会因为换了 API 而改变——$[0,1]$ 只是把同一条曲线重新缩放搬移到了另一个数值区间,曲线的形状(也就是"哪里精度多、哪里精度少")是投影矩阵那个非线性映射决定的,不是深度范围决定的。
真正能缓解精度问题的是 reverse-Z:把深度范围反过来映射,让 near 对应 $1.0$、far 对应 $0.0$,并且必须搭配浮点深度缓冲(floating-point depth buffer)才有意义。原因要从浮点数的存储方式说起:32 位浮点数的可表示值并不是均匀分布的,越靠近 $0$,相邻两个可表示值之间的间隔越小(精度越高),越靠近 $1$,间隔越大(精度越低)。正常(非 reverse)的映射把 near 放在 $0$ 附近、far 放在 $1$ 附近——问题是 near 附近本来就已经靠上一节推出的非线性获得了最多的编码精度,再叠加浮点数在 $0$ 附近的高精度纯属浪费;而 far 附近本来就被非线性投影挤压得所剩无几,偏偏又撞上了浮点数在 $1$ 附近最粗糙的区间,两个"精度差"的因素叠在了一起,远处物体的深度值几乎无法区分。reverse-Z 把两端调换过来,让 far 落在浮点数精度最高的 $0$ 附近,正好补上非线性投影在远处丢掉的精度;near 端换到浮点数精度最粗的 $1$ 附近也无妨,因为那里精度本来就绰绰有余。这是概念级的解释,具体怎么在渲染管线里配置反转的深度测试和浮点格式,留到 M3b 实操模块。
Z-fighting(深度冲突,两个几乎共面的表面在屏幕上随机交替显示、画面闪烁)的成因,用这一篇的公式看会非常直接:两个表面的真实距离差 $\Delta z_{eye}$ 哪怕固定不变,经过非线性映射之后对应的 $\Delta z_{ndc}$ 会随 $z_{eye}$ 增大而急剧缩小——对上一节的正向公式求导就能看到,$dz_{ndc}/dz_{eye}$ 里带着一个 $1/z_{eye}^2$ 的因子。当这两个表面离相机足够远时,$\Delta z_{ndc}$ 可能已经小于深度缓冲能表示的最小量化步长,于是两个本该有先后顺序的表面被舍入成了同一个存储深度值,深度测试(depth test)的胜负就变得跟像素、跟浮点误差走向有关,逐帧抖动,看起来就是那种熟悉的花纹状闪烁。
🎯 岗位关联
插帧和超分算法几乎都要消费深度缓冲:深度引导的运动矢量估计、遮挡区域判断(哪些像素在上一帧根本不可见)、以及基于深度的 warp 重投影,全都建立在"深度值能准确反映真实距离"这个假设上。如果直接把深度缓冲的值当线性距离用——比如拿两帧深度值相减去估计物体的运动幅度——在近处误差很小、看起来能用,一旦画面里有远处物体就会得到完全错误的结果,这是图形管线新手最常踩的一个坑,本篇推出的两条公式就是用来避开它的。reverse-Z 则是移动端游戏排查"远处场景闪烁、地形穿模"这类 bug 时的标准排查方向之一:看到远处出现规律性的花纹状闪烁,先问一句深度缓冲是不是浮点格式、有没有开 reverse-Z,往往比调试渲染逻辑本身更快找到根因。
小结
公式速查
| 名称 | 公式 |
|---|---|
| 正交投影矩阵对角线 | $\mathrm{diag}!\left(\frac{2}{r-l},\frac{2}{t-b},-\frac{2}{f-n},1\right)$ |
| 透视投影 $z$ 行 / $w$ 行 | $M_{22}=-\frac{f+n}{f-n},\ M_{23}=-\frac{2fn}{f-n},\ M_{32}=-1$ |
| NDC 深度(正向) | $z_{ndc}=\dfrac{(f+n)z_{eye}-2fn}{(f-n)z_{eye}}$ |
| 深度线性化(还原) | $z_{eye}=\dfrac{2fn}{(f+n)-z_{ndc}(f-n)}$ |
GL / Vulkan 深度约定差异
| 维度 | OpenGL | Vulkan / Direct3D |
|---|---|---|
| NDC 深度范围 | $[-1,1]$ | $[0,1]$ |
| 精度分布规律 | 近处密、远处疏(非线性映射决定,与范围无关) | 同左 |
| 常见优化 | reverse-Z + 浮点深度缓冲,需应用层显式配置 | 同左,配置方式随 API 略有不同 |
延伸阅读
- songho 的 Projection Matrix(正交与透视矩阵每一项的推导):https://www.songho.ca/opengl/gl_projectionmatrix.html
- NVIDIA,Depth Precision Visualized(深度精度分布的可视化讲解):https://developer.nvidia.com/content/depth-precision-visualized
- nlguillemot,Reversed-Z in OpenGL(reverse-Z 结合浮点深度缓冲的工程实现):https://nlguillemot.wordpress.com/2016/12/07/reversed-z-in-opengl/
