ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

透视投影矩阵推导:从视锥体到裁剪空间的完整数学原理

透视投影矩阵推导:从视锥体到裁剪空间的完整数学原理 1. 从3D到2D的那一步凭什么非它不可在图形学、游戏引擎、三维可视化和仿真渲染这些方向里有个东西几乎每天都会踩到——透视投影矩阵。只要是做实时渲染的朋友不管是写OpenGL、DirectX还是用Unity、UE、Godot这类引擎哪怕你自己没手写过引擎底层也一直在用它。它就是决定“三维世界里的点最后到底显示在屏幕哪个像素”的那道数学关卡。透视投影矩阵解决的是个很朴素的问题人眼看到的画面是近大远小的平行铁轨会在远处交汇成一个点两栋一样高的楼房离你近的那栋看起来更高。但屏幕是一块平面的矩形区域要让一个三维场景在二维屏幕上呈现出这种“近大远小”的视觉感受就必须对三维坐标做一套特殊变换。这套变换的数学载体就是透视投影矩阵。很多朋友第一次接触它是在图形学课程或者引擎源码里看到那个16个浮点数的数组一看就头疼直接选择背下来用。但背下来的代价就是一旦画面出现变形、拉伸、深度精度问题、近裁剪面异常你根本不知道该调哪一项。这篇文章我想带你把透视投影矩阵从头推一遍搞清楚每个元素的来历再讲清楚实际项目里怎么选参数、怎么排查那些经典问题。无论你是刚入门图形学的学生还是在引擎里要定制相机行为的开发者这东西只要推明白了你后面调渲染bug的效率会明显提升至少不会再“看见矩阵就绕道”。2. 场景为什么要“透视”而不是“正交”2.1 近大远小本质是除法不是缩放先建立一个直觉正交投影和透视投影到底差在哪正交投影就像工程图纸的立面图——不管物体离你多远它在屏幕上的大小完全不变。一个离相机1米和离相机100米的正方体投影到屏幕上是同样大小。这种投影方式适合CAD、轴测图、部分UI排版场景因为它能保持物体的真实尺寸比例不会产生视觉畸变。透视投影则模仿人眼和相机镜头的成像规律。它的核心规则是物体在屏幕上的尺寸和它到相机的距离成反比。一个物体距离相机2米时它的成像高度是距离4米时的两倍。这里有个关键点正交投影可以用“缩放”来实现透视投影本质上却不能靠简单的缩放它靠的是“除法”。说得具体一点相机坐标系下的一个点 \((x, y, z)\)按透视规律它投影到屏幕上的坐标近似是 \((x/z, y/z)\)——把x和y都除以z距离越远z越大x和y缩得越小这就是近大远小的数学根源。你可能会想既然是除以z那我们直接让着色器把顶点坐标除以z不就行了问题在于GPU的渲染管线是分阶段的它得在“裁剪”阶段就知道哪些顶点在视锥体外然后才做透视除法。可除法之后顶点的深度信息就没法线性保留了。所以真正工程中的做法是先用一个矩阵把相机空间的点变换到裁剪空间这个矩阵把“除以z”这件事藏在w分量里等到GPU固定管线阶段再统一除以w。这就是透视投影矩阵存在的意义。2.2 视锥体相机怎么看世界在推导矩阵之前必须先建立一个概念相机能看到的空间范围是什么形状正交投影的视景体是长方体六面都是平面。透视投影的视景体是一个平截头棱锥体frustum形状像金字塔被削掉尖端后的那截。它的近裁面是一个小矩形远裁面是一个大矩形四个侧面从近裁面出发向外扩张。这个形状对应了现实相机成像的视野范围相机前方的空间从近处到远处越远范围越宽。描述这个视锥体工程上最常见的方式是用四个参数垂直视野角FOVField of View、宽高比Aspect、近裁剪面距离near、远裁剪面距离far。为什么用垂直FOV而不是水平FOV因为在桌面平台和游戏行业里垂直FOV是约定俗成的参考基准水平FOV可以随宽高比变化算出来比如 \(\text{fov}_x 2 \times \arctan(\tan(\text{fov}_y/2) \times \text{aspect})\)。用垂直FOV的好处是你旋转屏幕、改变窗口宽度时只要高度不变画面中物体的视觉大小就不会变很多玩家调“视野范围”时也默认改垂直角。近裁剪面和远裁剪面同样重要。近裁剪面是相机能看到的最小距离小于这个距离的几何体会被完全裁掉。远裁剪面是可见距离上限超出区间的内容一律不渲染。这两个值设立的核心目的有两个一是控制渲染范围、减少无意义的绘制二是给深度缓冲一个有限的数值区间让Z值有地方可存。2.3 裁剪坐标和w分量的秘密有人可能会问投影变换是为了把视锥体变成一个标准立方体这样GPU才好做裁剪——那为什么不直接用相似三角形算完就完事了原因在于GPU里三个硬性阶段顶点着色器输出裁剪坐标硬件按裁剪坐标在视锥体内做裁剪然后才执行透视除法。所以在顶点着色器里我们必须给每个顶点塞一个四维向量 \((x, y, z, w)\)。当这个向量经过透视除法四维同时除以w之后x、y、z就被归一化到NDC归一化设备坐标区间。OpenGL的NDC区间是 \([-1, 1]^3\)DirectX和Vulkan的NDC区间是x、y在 \([-1,1]\)z在 \([0,1]\)。既然NDC要求x、y、z都在固定区间而现场来做“除以z”会破坏线性深度那唯一的方案就是把“除以z”存进w分量。透视投影矩阵的第四行干的恰恰就是这件事——它把相机空间的 \(-z\) 赋给w。这样经过透视除法的时候x、y、z的线性变换结果都自动除上了深度而深度本身又因为矩阵第三行的精心设计保持在可用区间内。这就是整个推导最核心的思维把非线性除法推迟到裁剪之后用矩阵 w分量扮演中间人。3. 一步一步推导透视投影矩阵3.1 先定记号约定一套相机坐标系推导前我们先统一记号。假设相机位于原点视线朝向-z方向这个习惯在OpenGL和多数图形学教材里是一致的。相机坐标系下一个点的坐标为 \((x_e, y_e, z_e)\)其中 \(z_e\) 是负数因为可见的物体在相机前方即z轴负方向那一侧。近裁剪面距离记为 \(n\)远裁剪面距离记为 \(f\)它们都是正数。投影矩阵的任务是把相机空间里视锥体内的任意点 \((x_e, y_e, z_e)\)映射到裁剪空间得到一个四维向量 \((x_c, y_c, z_c, w_c)\)。这个四维向量要满足当 \((x_e, y_e, z_e)\) 位于视锥体内时透视除法后 \((x_c/w_c, y_c/w_c, z_c/w_c)\) 落在NDC区间内。视锥体边界上的点的投影坐标正好落在NDC边界比如 \(\pm1\) 或0这样硬件才知道哪里裁剪。3.2 x和y的映射从相似三角形到矩阵系数先看x和y。在相机空间里观察一个位于深度 \(-z_e\) 处的点它的透视投影在近裁面上的坐标大约是多少这个直接由相似三角形得出。设想从相机原点发出一条光线经过相同方向上的近裁面点投影关系满足\[ \frac{x_{\text{ndc}}}{x_e} \frac{n}{-z_e},\quad \frac{y_{\text{ndc}}}{y_e} \frac{n}{-z_e} \]这个比例式说的是在近裁面上成像点的坐标等于原始坐标乘上 \(n/(-z_e)\)。但我们还需要把近裁面上这个尺寸映射到NDC区间。近裁面的宽高是由垂直FOV和宽高比决定的。设垂直半FOV为 \(\theta \text{fov}/2\)那么近裁面的高度就是 \(2 n \tan\theta\)宽度就是 \(2 n \tan\theta \cdot \text{aspect}\)。要把近裁面上的坐标映射到范围 \([-1, 1]\)就需要除以半宽、半高。于是\[ x_{\text{ndc}} \frac{x_e \cdot n / (-z_e)}{n \tan\theta \cdot \text{aspect}} \frac{x_e}{(-z_e) \cdot \tan\theta \cdot \text{aspect}} \]\[ y_{\text{ndc}} \frac{y_e \cdot n / (-z_e)}{n \tan\theta} \frac{y_e}{(-z_e) \cdot \tan\theta} \]注意这两个式子同时除以了 \(-z_e\)。这意味着矩阵设计时x和y分量应该直接写入“系数乘 \(x_e\)”然后把除以 \(-z_e\) 的操作留给透视除法来完成。所以矩阵第一行和第二行的系数就确定了\[ m_{11} \frac{1}{\tan\theta \cdot \text{aspect}},\quad m_{22} \frac{1}{\tan\theta} \]其余参与x、y的列都填0。也就是说第一行和第二行在矩阵里分别是 \((m_{11}, 0, 0, 0)\) 和 \((0, m_{22}, 0, 0)\)。3.3 z的映射把深度也拉进NDCx和y的推导本身并不复杂真正的重头戏是z怎么处理。我们不能直接保留 \(z_e\)也不能简单做线性映射因为NDC的z需要和x、y一样做完透视除法之后落在一个固定区间。透视除法意味着 \(z_{\text{ndc}} z_c / w_c\)而 \(w_c\) 又要设计成 \(-z_e\)。所以我们需要寻找一个矩阵第三行的形式\[ z_c A z_e B \]其中A和B是待定常数。经过透视除法后\[ z_{\text{ndc}} \frac{A z_e B}{-z_e} \]我们要求当 \(z_e -n\)点在近裁面上 \(z_{\text{ndc}} -1\)OpenGL的NDC近端当 \(z_e -f\)点在远裁面上 \(z_{\text{ndc}} 1\)OpenGL的NDC远端。代入这两个边界条件\[ \frac{A(-n) B}{n} -1 \]\[ \frac{A(-f) B}{f} 1 \]第一个式子移项\[ -A \frac{B}{n} -1 \quad\Rightarrow\quad \frac{B}{n} A - 1 \]第二个式子移项\[ -A \frac{B}{f} 1 \quad\Rightarrow\quad \frac{B}{f} A 1 \]两个式子相减消去A\[ \frac{B}{n} - \frac{B}{f} -2 \]\[ B\left(\frac{1}{n} - \frac{1}{f}\right) -2 \]\[ B \frac{-2nf}{f - n} \]然后回代求A\[ \frac{B}{n} A - 1 \quad\Rightarrow\quad A 1 \frac{B}{n} 1 - \frac{2f}{f-n} \frac{fn}{f-n} \]于是得到一个非常经典的结果\[ z_c \frac{fn}{f-n} z_e \frac{-2nf}{f-n} \]也就是说矩阵第三行是\[ m_{31} 0,\quad m_{32} 0,\quad m_{33} \frac{fn}{f-n},\quad m_{34} \frac{-2nf}{f-n} \]3.4 最终矩阵OpenGL视角下的完整形式现在把所有系数拼起来。矩阵第四行要负责把 \(w_c\) 设为 \(-z_e\)所以第四行是 \((0, 0, -1, 0)\)。整合后的透视投影矩阵\[ M_{\text{persp}} \begin{bmatrix} \frac{1}{\tan\theta \cdot \text{aspect}} 0 0 0 \ 0 \frac{1}{\tan\theta} 0 0 \ 0 0 \frac{fn}{f-n} \frac{-2nf}{f-n} \ 0 0 -1 0 \end{bmatrix} \]留在裁剪空间的点 \((x_c, y_c, z_c, w_c)\) 满足\[ x_c \frac{x_e}{\tan\theta \cdot \text{aspect}},\quad y_c \frac{y_e}{\tan\theta},\quad z_c \frac{fn}{f-n} z_e - \frac{2nf}{f-n},\quad w_c -z_e \]做完透视除法后\[ x_{\text{ndc}} \frac{x_e}{-z_e \cdot \tan\theta \cdot \text{aspect}},\quad y_{\text{ndc}} \frac{y_e}{-z_e \cdot \tan\theta},\quad z_{\text{ndc}} \frac{fn}{f-n} \frac{2nf}{(f-n)z_e} \]这就是OpenGL常用的透视投影矩阵的完整推导过程。很多引擎里你看到的Perspective函数比如glm::perspective、Unity的Matrix4x4.Perspective返回的数据排布略有差异行主序/列主序但数学本质和这个完全一致。如果要对齐DirectX或Vulkan只需要改动两点NDC的z范围从 \([-1,1]\) 改成 \([0,1]\)近裁剪面的深度边界从-1改成0另外Vulkan的NDC y轴向下还需要把第二行的 \(m_{22}\) 取负。其他x、y系数和w设计完全不变。4. 真实项目里参数怎么定才不翻车4.1 FOV、宽高比、near和far的推荐配置推导完矩阵重新看这四个参数就特别有感觉了。FOV人眼舒适区大概在垂直视角60度到75度之间游戏里FPS类常见的默认值是75度到90度。这个数值越大视野越宽场景里的物体越小边缘畸变越明显越小视野越窄越像长焦镜头“拉近”的效果。想模拟沉浸视角建议从60到75起步搞竞技射击、竞速游戏90度也比较常见。但超过110度之后边缘拉伸感太强多数玩家会觉得不舒服。Aspect屏幕宽高比。这个值一般直接由渲染视口的宽高计算即aspect viewportWidth / viewportHeight。需要特别注意的是如果你的渲染目标不是全屏比如是编辑器里一个小窗口、分屏画面、UI相机那宽高比也必须跟着改。比如画面切成左右分屏每个视图的宽高比就变成了原来的约一半透视投影矩阵里的aspect也要对应更新否则物体看起来会被水平拉伸或压扁。near近裁剪面。很多人习惯把它设得很小比如0.01或0.001觉得越小的near越能看清近处的东西。但这在深度缓冲里是个隐藏陷阱。深度精度分布是非线性的near越小近处的深度值变化越剧烈远处的深度值被压在一小块区间里远处物体很容易出现z-fighting深度冲突。实践上如果场景里没有特别近的物体建议把near设到0.1甚至0.5。我在项目里见过把near设成0.0001然后抱怨远处山体闪成一片的这种案例几乎每周都有。far远裁剪面。这个值决定了你能看多远。原则上它是够用就好不是越大越好。far和near的比值 \(f/n\) 直接影响深度精度比值越大深度分布越向近处倾斜远处的深度分辨率越差。所以如果你场景里最远的物体在2000米完全没必要把far设成100000。提示near和far的比值是深度精度的“隐形天花板”。想提升远处深度精度优先调整near向上提而不是把far往下压。4.2 正交投影和透视投影怎么选正交投影矩阵的推导比透视简单得多它本质上就是平移加缩放把视景体的六个面映射到NDC立方体。它的矩阵是线性变换没有w分量的玄机w保持为1。所以你写glm::ortho得到的就是一个纯粹的平移缩放矩阵。实际项目里怎么选做2D UI、地图、编辑器场景、物理调试绘制、等距视角游戏用正交投影准没错它能保证比例一致、不产生透视变形。做3D场景、第一人称/第三人称视角、摄影机跟随、虚拟现实就必须用透视投影才能有真实感。有些引擎里的“透视相机”和“正交相机”是可以切换的比如Unity的Camera组件带Projection选项引擎会根据你选的类型动态生成对应矩阵——原理上就是咱们上面推的这两套东西。4.3 深度缓冲精度问题为什么远处的东西总在闪透视投影矩阵推导完一个重要的副作用就是深度分布不均。对同一个矩阵 \(z_{\text{ndc}}\) 和 \(z_e\) 之间是反比例关系不是线性关系。这意味着近处物体的深度值占用了NDC里很大的范围远处物体的深度值被压缩在NDC接近1的一小段区间里。举个具体数字例子。设near设为0.1far设为100。那么距离相机1米的物体它的NDC z大约在什么位置代入公式 \(z_{\text{ndc}} (fn)/(f-n) 2nf/((f-n) z_e)\)当 \(z_e -1\) 时算出来大约是0.98左右几乎压到了远端的边界。而距离0.1米的近裁面物体z是-1OpenGL NDC近端距离100米的物体z是1。五米、十米、二十米的物体它们的z值全部挤在0.99到1.0这段极窄的范围内。深度缓冲是固定精度16位、24位、32位浮点这极窄的区间里能区分的深度层次就非常少距离一拉开两个深度值落到同一个量化层级远处物体就开始闪烁、交叠。解决这个问题的思路有三个方向调整near和far的比值让场景几何体的距离范围落在相对不那么极端的区间。使用反向ZReversed-Z技术把近裁剪面的深度映射到NDC的1远裁剪面映射到0。这样配合浮点深度缓冲近处和远处的精度分配更均匀因为浮点数本身在接近0的时候精度更高跟反向Z正好互补。这个方案在现代图形APIDirectX 11、Vulkan里非常流行几乎是标配。启用对数深度缓冲在顶点着色器里直接把深度值转到对数空间进一步摊平深度分布。代价是可能需要关闭硬件深度压缩优化并且要处理各种底层差异。5. 常见问题与排查技巧实录症状原因排查与处理场景被拉伸成“扁的”或“宽的”投影矩阵里的aspect和实际渲染视口宽高比不一致检查视口尺寸动态更新aspect注意DPI和窗口缩放物体离相机太远就消失超出far裁剪面按场景距离合理调大far或者调整相机位置/场景缩放物体离相机很近就被裁掉小于near裁剪面或者相机与物体之间有几何体穿模确认near值不要过小但也不能把场景中需要的近处内容裁掉远处物体闪烁、交错像“摩尔纹”深度精度不足far/near比值过大先提near值再压far值考虑反向Z必要时启用对数深度用矩阵变换后的顶点全挤在屏幕中心w分量没有被正确设置顶点做完透视除法后坐标异常检查矩阵第四行是否为(0,0,-1,0)确认顶点w没有在外部被覆盖物体边缘出现非预期裁剪裁剪空间的x/y超出NDC范围检查FOV和aspect确认视锥体四个侧面计算无误在Vulkan下画面上下颠倒Vulkan的NDC y轴方向与OpenGL相反把矩阵第二行的m22取反或者在viewport设置里做翻转排查投影矩阵相关问题时我有个比较实用的检查方法先拿一个轴对齐的边界盒顶点输入到你的矩阵里手算一遍裁剪空间坐标再除w看NDC值是否符合预期。尤其验证近裁面四角和远裁面四角这两个临界位置如果它们输出都落在NDC边界上说明矩阵公式本身没问题如果早期画面就崩了那多半是宽高比、FOV口径垂直还是水平或者行列主序传参出了差异。6. 再聊几句我踩过的坑透视投影矩阵推导本身不算难难的是你在实际项目里永远会撞上各种“隐性约定”。我最开始用OpenGL写渲染器的时候被FOV口径坑过一次当时手写的代码里用了水平FOV引擎那边却传的是垂直FOV结果画面纵向视野特别窄场景像被望远镜拉近了一样。后来我统一封装成一个ProjectionParams结构体明确标注是垂直半角还是全角再也没犯这种错。还有一个容易被忽略的点CUDA或Shadow Map的矩阵与主相机矩阵要保持一致性。有时候阴影贴图用正交投影、主相机用透视投影两者的aspect和near/far不必一致但要保证视锥体边界大致吻合否则就会出现“阴影边缘漏光”——清一色是两套矩阵的裁剪体对不上导致的。最后分享一个调试技巧如果投影后画面整个偏了优先怀疑不是矩阵错而是顶点输入顺序或NDC坐标变换搞反了。我在Vulkan项目里就遇到过类似情况——画面上下颠倒、左右反转第一反应以为矩阵算错了后来发现是viewport的Y轴和窗口坐标体系不一致导致的。矩阵推导咱们已经吃透了遇到问题时就该用它的原理一步步拆定位反而更快。希望这篇推导笔记能帮你把这块硬骨头啃下来之后再看引擎里的投影参数会顺手很多。
返回列表