05. 投影与深度
05. 投影与深度
本篇位置:模块一 · 第 5 篇 | 前置:3D 变换与 MVP 链 | 预计阅读:45 分钟
你已经在用它了(但只用了一半)
libvulkan 的 swapchain 实现里有这么一行(frameworks/native/vulkan/libvulkan/swapchain.cpp):
capabilities->supportedTransforms = kSupportedTransforms;
capabilities->currentTransform =
TranslateNativeToVulkanTransform(transform_hint);它把窗口系统的旋转提示翻译成 Vulkan 的说法交给应用。而那个翻译函数的注释,把两套约定的差别说得很清楚:
VkSurfaceTransformFlagBitsKHR TranslateNativeToVulkanTransform(int native) {
// Native and Vulkan transforms are isomorphic, but are represented
// differently. Vulkan transforms are built up of an optional horizontal
// mirror, followed by a clockwise 0/90/180/270-degree rotation. Native
// transforms are built up from a horizontal flip, vertical flip, and
// 90-degree rotation, all optional but always in that order.
switch (native) {
// ...
case NATIVE_WINDOW_TRANSFORM_FLIP_V:
return VK_SURFACE_TRANSFORM_HORIZONTAL_MIRROR_ROTATE_180_BIT_KHR;
// ...
}
}三个问题:
- 应用拿到
currentTransform之后该拿它怎么办? 为什么最佳实践是"把它乘进投影矩阵",而不是画完之后再转一次画面? - Vulkan 的裁剪空间 向下、深度范围是 ,OpenGL 是 向上、。 这两个盒子是干什么用的?为什么各家不一样?
- 为什么远处的物体会 z-fighting? 深度缓冲明明有 24 位精度。
顺便看一眼那个 FLIP_V 的映射:Vulkan 的枚举里根本没有"垂直镜像",所以它被翻译成"水平镜像 + 转 180°"。这正是第 2 篇那条恒等式 移项之后的样子——同样八种变换,两套不同的拆法,怎么拆是约定问题,能不能对上是数学问题。
学完你会什么
- 说清"投影"到底在做什么,以及正交投影的矩阵怎么一步步凑出来。
- 说清透视投影为什么必须借助齐次坐标的 分量,"近大远小"是在哪一步发生的。
- 说清 NDC 是什么,以及 OpenGL 和 Vulkan 的两个标准盒子差在哪。
- 说清深度值为什么不是线性的,并算出你的近平面设置浪费了多少精度。
- 说清 z-fighting 的成因,以及为什么"把 near 调小一点"是个坏主意。
- 说清 pre-rotation 为什么要乘进投影矩阵。
1. 投影要解决的问题:把一个盒子装进标准盒子
到第 4 篇为止,场景已经被搬到了相机空间:相机在原点,看向 方向。
现在要决定两件事:看得见多大范围,以及怎么把这个范围里的东西压成一张二维图像。
看得见的那块空间叫视景体(view volume)。它有两种形状:
图:上排是两种视景体的立体形状。正交投影的是个长方体——远近的取景范围一样宽,适合 CAD 图纸、2D 游戏、UI;透视投影的是个截头金字塔(叫视锥,frustum)——越远的地方取景范围越宽。下排是同样这四张等大的卡片投影之后、屏幕上真正的样子:正交画出来四张一模一样,远近完全分不出来;透视画出来越远越小,还朝画面中心收拢。
上排四张卡片是等大的,这不是画错了:上排画的是投影之前的相机空间,卡片本来就等大。"近大远小"是投影这一步才发生的事,所以光看视景体的形状看不到它——必须看下排。
视锥还顺带管着另一件事:

图:出自 LearnOpenGL CN《坐标系统》(NEAR / FAR PLANE = 近 / 远平面,FOV = 视野角,GETS DISCARDED = 会被丢弃)。落在近平面之前或远平面之后的东西——图里那个蓝色立方体——直接被裁掉,压根不进入后面的管线。"看得见多大范围"和"什么会被裁掉"是同一句话的两种说法。
两种投影都要把自己的视景体,装进同一个标准的立方体。这个标准立方体就是 NDC(Normalized Device Coordinates,归一化设备坐标)。
为什么要统一成一个标准盒子?因为下游的裁剪、视口变换、光栅化都希望面对固定的数值范围——"在不在屏幕里"退化成"每个坐标是不是落在 之间"这样一次廉价的比较。
2. 正交投影:一次平移加一次缩放
正交投影最简单,它就是把一个长方体搬进标准立方体,两步:
- 先把长方体的中心挪到原点(平移);
- 再把它缩放成 的大小(缩放)。
假设视景体在 方向是 (left/right), 方向 (bottom/top), 方向 (near/far)。
中心点在 ,所以平移矩阵是把它减掉。各方向的长度是 、、,要变成 2,所以缩放系数是 等等。两个矩阵乘起来:
看着吓人,其实只有两件事:对角线上是"缩放到 2 个单位",第四列是"先把中心挪到原点"。用第 3 篇的分区读法一眼就能拆开。
验算一下:取 ,第一行算出 ✓ 右边界正好落在 NDC 的 上。
注意第四行还是 ——正交投影不产生透视,输出的 恒为 1,不需要透视除法。
没有透视是缺点,也是优点。在真实的建模软件里,两种投影是这样的:

图:出自 LearnOpenGL CN《坐标系统》(PERSPECTIVE = 透视,ORTHOGRAPHIC = 正交)。同一个模型:左边透视,远端明显收窄,看着"真实";右边正交,本来平行的边在画面上还是平行的。建模时正交更好用,因为屏幕上量到的尺寸就是真实尺寸,不被距离干扰——这也是 CAD 图纸和 2D 游戏用正交的原因。
3. 透视投影:先把视锥挤成盒子
透视的效果,看一眼这个就懂了:

图:出自 LearnOpenGL CN《坐标系统》。铁轨的两条边在真实世界里是严格平行的,画面上却在远处交于一点。
透视投影的目标就是把这件事算出来。具体地说:一个物体在画面上的大小,应该正比于 。
问题来了:矩阵乘法只会做加权求和,它做不了除法。
第 3 篇已经给过答案:把要除的东西放进 分量,让透视除法去做这次除法。
所以透视投影矩阵干的事情是:把顶点的 (也就是它离相机的距离)塞进输出的 里。第四行不再是 ,而是 :
后面管线做透视除法时, 和 都会被 除——离得越远( 越大),除完越小。近大远小就是在这一步发生的。
图:还是那四张等大的卡片。① 它们在视锥里,一张比一张远。② 挤压这一步把视锥的远端按 距离 收窄,掰成一个方盒子(灰虚线是原来的视锥)——远处的卡片跟着被压小,"近大远小"就发生在这里。③ 归一化到 NDC——注意四张卡片在深度方向上全挤向了远端(深度 0.55 / 0.78 / 0.90 / 0.98),这是第 5 节的主题。
理解透视投影的最好方式:它 = 一次"把视锥挤成长方体"的挤压 + 一次正交投影。挤压负责近大远小,正交负责装进标准盒子。
一个常见的写法是用视野角 和宽高比 来表达:
要记的只有最后一行那个 ——它是整个透视效果的来源。前两行是"视野多大",第三行是"深度怎么映射",都是可以现推的细节。
4. NDC:两个不一样的标准盒子
图:两个盒子的近平面上画的是同一个 F。左边 OpenGL 的 向上,F 是正的;右边 Vulkan 的 向下,同一份数据画出来 F 就倒了。另外注意两个盒子的长短:GL 的深度跨 ,VK 只跨 ,所以 VK 的盒子在深度方向上只有 GL 的一半。
| OpenGL | Vulkan | |
|---|---|---|
| 范围 | ||
| 方向 | 向上 | 向下 |
| 深度范围 |
这就是问题 2 的答案的一半:盒子是同一个用途,只是各家挑了不同的约定。
两处差异各有后果:
轴翻转。 同一份场景数据、同一个投影矩阵,从 GL 管线搬到 VK 管线上,画面会上下颠倒。常见的解法有三种:在投影矩阵的第二行乘个 、给 viewport 设负的 height、或者在着色器里翻 。三种都行,但必须只做一次——做两次就转回去了,而且这类错误常常只在某个特定分辨率或某个后处理阶段才暴露出来。
顺带一提: 翻转会连带改变三角形在屏幕上的绕序,所以背面剔除的正面定义可能也要跟着改。这就是第 1 篇那道自测题说的"不说清坐标系的绕序约定没有信息量"。
深度范围。 OpenGL 的 是历史包袱:深度缓冲实际存的是 ,所以 GL 还要再做一次 的映射。这次多余的映射会额外损失精度,而 Vulkan 的 直接对上存储格式,没有这一步。
5. 深度为什么不是线性的
这是本篇最要紧的一节。
透视除法把 也除以了 。结果是:写进深度缓冲的值,和真实距离之间是一个倒数关系,不是线性关系。
以 Vulkan 的 约定为例,距离 处写进深度缓冲的值是:
验算两个端点: 时得 0, 时得 ✓
关键在中间那个 ——它是个倒数。
图:三条曲线对应三种近平面设置。共同点是:曲线在近处陡峭、在远处几乎躺平。躺平意味着很大一段距离变化,只对应很小一点深度值变化——分辨能力就在那里丢掉了。
一个让人不舒服的数字
取近平面 、远平面 (很典型的设置)。问:深度值用掉一半(也就是 达到 0.5)时,物体离相机多远?
约 0.2。 也就是说:
整整一半的深度精度,花在了 0.1 到 0.2 这 10 厘米上。剩下的 99.8 米,共用另一半。
图:远平面固定为 100。近平面每缩小 10 倍,"一半精度"覆盖的距离也跟着缩小约 10 倍。
这就是那条经典建议的由来:
想改善深度精度,把 near 调大,而不是把 far 调小。
因为精度分布几乎完全由 决定。把 far 从 100 改到 50 收效甚微;把 near 从 0.01 改到 0.1,等于把一半的精度预算从"前 2 厘米"挪到了"前 20 厘米"。
反过来说,"把 near 设得小一点,免得近处的东西被裁掉"是个代价很高的做法——它会把绝大部分深度精度浪费在你几乎不会有物体的贴脸距离上。
6. z-fighting
现在可以解释问题 3 了。
深度缓冲是有限位数的整数(常见 16、24 位)。 算出来之后要被量化成整数存起来。远处曲线躺平的那一段,相邻的距离算出来的 差别极小,量化之后可能落到同一个整数上。
一旦两个面的深度值相等,谁在前谁在后就由浮点误差和绘制顺序决定——摄像机稍微一动,两个面就交替获胜,画面上出现闪烁的斑块。这就是 z-fighting。
图:、、16 位深度缓冲。两个相距 1 厘米的面:在 1 米处,深度整数值是 59041 和 59105,相差 64,分得清清楚楚;在 60 米处,两个值都是 65491——完全一样,于是开始打架。
排查 z-fighting 的顺序:
- 先看 near 是不是设得太小(最常见,也最容易改);
- 再看两个面是不是真的挨太近(共面的墙和贴花,用 depth bias 或 stencil 解决);
- 深度格式是不是用了 16 位(换 24 位或浮点深度);
- 都不行再考虑 reverse-Z——把近平面映射到 1、远平面映射到 0,让浮点数精度最密集的区间(0 附近)对上深度曲线最平坦的区间(远处)。这一招几乎免费地换回好几位有效精度,现代引擎基本都用。
7. pre-rotation:为什么要乘进投影矩阵
回到开头那个 currentTransform。
手机竖着拿,但显示屏的物理扫描方向可能是横的。这个差异总得有人来抹平,可能的做法有三种:
| 做法 | 代价 |
|---|---|
| 显示控制器在扫描时旋转 | 有些硬件不支持,或者只支持部分角度 |
| 合成器多加一趟旋转 pass | 多读写一整帧的显存带宽,移动端最不能接受的开销 |
| 应用自己把旋转烘进渲染 | 零额外开销 |
第三种就是 pre-rotation。而"烘进渲染"最省事的落点,正是投影矩阵——因为它本来就要被每个顶点乘一次。
具体做法:拿到 currentTransform 之后,构造一个对应角度的旋转矩阵 ,把投影矩阵换成 :
为什么这是免费的? 因为 是在 CPU 上每帧算一次的 4×4 矩阵乘法,而顶点着色器里那行 gl_Position = uMVP * aPosition 一个字都不用改。旋转这件事被完全吸收进了一个本来就存在的矩阵里。
这就是问题 1 的答案,也是第 2 篇那条结论在真实场景里最值钱的一次应用:变换可以预先乘起来合并,所以多做一次变换,运行时开销可以是零。
别忘了配套的两件事:交换链的
preTransform要填上你已经处理过的那个变换(告诉系统"我搞定了,别再转一次"),以及 是 90°/270° 时画面的宽高互换了,viewport 和 scissor 要跟着调。这两处漏掉任何一个,画面要么被转两次,要么被拉伸。
回到源码
问题 1:currentTransform 拿到之后怎么用?
构造对应的旋转矩阵,左乘进投影矩阵(),并把交换链的 preTransform 设成同一个值。这样旋转在 CPU 端一次矩阵乘法里就完成了,GPU 侧零额外开销——而让合成器多做一趟旋转 pass,代价是多读写一整帧的显存带宽。
问题 2:那两个标准盒子是干什么的?
NDC 是所有视景体的统一落点,让下游的裁剪判断退化成"坐标在不在 里"。GL 和 VK 的差异纯属约定:VK 的 向下(和屏幕坐标一致)、深度 (和存储格式一致,少一次映射)。移植时 翻转必须做且只能做一次,还要注意它会连带改变屏幕空间的绕序。
问题 3:24 位深度为什么还会 z-fighting?
因为那 24 位不是均匀分给距离的。透视除法让深度和距离成倒数关系,精度绝大部分堆在近平面附近。、 时,一半的深度值花在了前 10 厘米上,剩下 99.8 米共用另一半——远处的分辨能力就是这么被挤没的。首选对策是调大 near,其次是 reverse-Z。
以后再看到 currentTransform 这类"某一层报上来的变换提示",你应该问的是:这个变换最终由谁执行、能不能被合并进某个已经存在的矩阵。 合成器里的变换合并(ui::Transform 的连乘)和渲染器里的 MVP 合并,是同一个思路在两个尺度上的应用。
自测
1. 正交投影矩阵的第四行是 (0,0,0,1),透视投影是 (0,0,−1,0)。这个差别导致了什么?
正交投影输出的 恒为 1,透视除法除以 1 等于什么都没做——没有近大远小。
透视投影把顶点的 (离相机的距离)塞进了 ,之后 、 都会被这个距离除,离得越远显示得越小。
第 3 篇讲过的"第三行/第四行管透视",在这里落到了实处。
2. 把 OpenGL 的场景移植到 Vulkan,画面上下颠倒。有哪几种改法?为什么不能同时用两种?
常见三种:投影矩阵第二行乘 ;viewport 的 height 设成负数;着色器里翻 。
只能选一种。 翻两次等于没翻,画面又转回去了——而且这类 bug 常常只在特定分辨率或某个后处理 pass 才显形,因为不同 pass 可能各自"顺手"翻了一次。
另外别忘了 翻转会改变屏幕空间的三角形绕序,背面剔除的正面定义可能要跟着调。
3. near=0.1、far=100,深度值用掉一半时物体在多远?如果把 near 改成 1.0 呢?
:约 0.1998,也就是一半的精度花在了前 10 厘米。
:约 1.9802,一半的精度覆盖到了约 2 米。
代入 解出 即可。
结论:near 调大 10 倍,有效精度范围也大约扩大 10 倍。
4. 同事说"远处 z-fighting,我把 far 从 1000 调到 500 试试"。有用吗?
基本没用。
深度精度的分布几乎完全由 near 决定, 只影响那个 的整体系数。 从 1000 减到 500,系数从 变成 ,两者都约等于 1,曲线形状几乎没动。
正确的方向是调大 near,或者启用 reverse-Z。
5. 为什么 pre-rotation 说是"零开销"?旋转矩阵不也要参与运算吗?
因为它被吸收进了一个本来就存在的矩阵。
是 CPU 上每帧一次的 4×4 矩阵乘法(几十次浮点运算),而顶点着色器里 uMVP * aPosition 那行代码完全不变——GPU 侧一次额外的乘法都没有。
对比另一条路:让合成器多做一趟旋转 pass,代价是多读写一整帧的显存。在移动端,带宽比算力金贵得多。
小结
| 概念 | 一句话 | 要点 |
|---|---|---|
| 投影的任务 | 把视景体装进标准立方体 NDC | 让裁剪退化成范围比较 |
| 正交投影 | 平移到原点 + 缩放到 2 个单位 | 第四行仍是 ,无透视 |
| 透视投影 | 把 塞进 ,交给透视除法 | 第四行的 是全部秘密 |
| 理解方式 | 透视 = 挤压视锥 + 正交 | 挤压负责近大远小 |
| NDC 差异 | GL: 上、;VK: 下、 | 翻转只能做一次,且会改绕序 |
| 深度非线性 | 中间那个倒数是根源 | |
| 精度分布 | 时,一半精度花在前 10 厘米 | 想要精度就调大 near |
| z-fighting | 远处两个深度量化到同一个整数 | 先查 near,再考虑 reverse-Z |
| pre-rotation | 把屏幕旋转左乘进投影矩阵 | 吸收进已有矩阵 = GPU 侧零开销 |
延伸阅读
- GAMES101 Lecture 4:Transformation Cont.(投影矩阵推导):https://games-cn.org/intro-graphics/
- LearnOpenGL 中文版《坐标系统》:https://learnopengl-cn.github.io/01 Getting started/08 Coordinate Systems/ (本篇的铁轨图、视锥裁剪图、Blender 对比图都引自这里,出处见
assets/external/SOURCES.md) - NVIDIA《Depth Precision Visualized》(reverse-Z 的经典说明):https://developer.nvidia.com/content/depth-precision-visualized
- Android 开发者文档《Vulkan pre-rotation》:https://developer.android.com/games/optimize/vulkan-prerotation
- AOSP 源码:
frameworks/native/vulkan/libvulkan/swapchain.cpp