目录

透视投影(Perspective Projection)变换推导过程

透视投影(Perspective Projection)变换推导过程

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210424144836.png

透视投影是3D固定流水线的重要组成部分,是将相机空间中的点从视锥体(frustum)变换到规则观察体(Canonical View Volume)中,待裁剪完毕后进行透视除法的行为。在算法中它是通过透视矩阵乘法透视除法(perspective division)(又叫齐次除法 homogeneous division)两步完成的。

 

透视投影变换是令很多刚刚进入3D图形领域的开发人员感到迷惑乃至神秘的一个图形技术。其中的理解困难在于步骤繁琐,对一些基础知识过分依赖,一旦对它们中的任何地方感到陌生,立刻导致理解停止不前。

没错,主流的3D APIs如OpenGL、D3D的确把具体的透视投影细节封装起来,比如

gluPerspective(…)就可以根据输入生成一个透视投影矩阵。而且在大多数情况下不需要了解具体的内幕算法也可以完成任务。但是你不觉得,如果想要成为一个职业的图形程序员或游戏开发者,就应该真正降伏透视投影这个家伙么?我们先从必需的基础知识着手,一步一步深入下去(这些知识在很多地方可以单独找到,但我从来没有在同一个地方全部找到,但是你现在找到了)。

我们首先介绍两个必须掌握的知识。有了它们,我们才不至于在理解透视投影变换的过程中迷失方向(这里会使用到向量几何、矩阵的部分知识,如果你对此不是很熟悉,可以参考《向量几何在游戏编程中的使用》系列文章)

1.齐次坐标


透视投影变换是在齐次坐标下进行的,而齐次坐标本身就是一个令人迷惑的概念,这里我们先把它理解清楚。

简短的解释:齐次坐标就是将一个原本是n维的向量用一个n+1维向量来表示。

“齐次坐标表示是计算机图形学的重要手段之一,它既能够用来明确区分向量和点,同时也更易用于进行仿射(线性)几何变换。”—— F.S. Hill, JR

参考:齐次坐标的意义

2.线性插值


这是在图形学中普遍使用的基本技巧,我们在很多地方都会用到,比如2D位图的放大、缩小,Tweening变换,以及我们即将看到的透视投影变换等等。基本思想是:给一个x属于[a, b],找到y属于[c, d],使得x与a的距离比上ab长度所得到的比例,等于y与c的距离比上cd长度所得到的比例,用数学表达式描述很容易理解:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421231520.png

这样,从a到b的每一个点都与c到d上的唯一一个点对应。有一个x,就可以求得一个y。

此外,如果x不在[a, b]内,比如x < a或者x > b,则得到的y也是符合y < c或者y > d,比例仍然不变,插值同样适用。

投影变换(推导方案一)


转自:http://blog.csdn.net/popy007/article/details/1797121

好,有了上面两个理论知识,我们开始分析这次的主角——透视投影变换。这里我们选择OpenGL的透视投影变换进行分析,其他的APIs会存在一些差异,但主体思想是相似的,可以类似地推导。经过相机矩阵的变换,顶点被变换到了相机空间。这个时候的多边形也许会被视锥体裁剪,但在这个不规则的体中进行裁剪并非那么容易的事情,所以经过图形学前辈们的精心分析,裁剪被安排到规则观察体(Canonical View Volume, CVV)中进行,CVV是一个正方体,x, y, z的范围都是[-1,1],多边形裁剪就是用这个规则体完成的。所以,事实上是透视投影变换由两步组成:

 

1)  用透视变换矩阵把顶点从视锥体中变换到裁剪空间的CVV中。

2)  CVV裁剪完成后进行透视除法(一会进行解释)。

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421231724.png

我们一步一步来,我们先从一个方向考察投影关系。

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421231739.png

上图是右手坐标系中顶点在相机空间中的情形。设P(x,z)是经过相机变换之后的点,视锥体由eye——眼睛位置,np——近裁剪平面,fp——远裁剪平面组成。N是眼睛到近裁剪平面的距离,F是眼睛到远裁剪平面的距离。投影面可以选择任何平行于近裁剪平面的平面,这里我们选择近裁剪平面作为投影平面。设P’(x’,z’)是投影之后的点,则有z’ = -N。通过相似三角形性质,我们有关系:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421231755.png

同理,有

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421231811.png

这样,我们便得到了P投影后的点P’

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421231826.png

从上面可以看出,投影的结果z’始终等于-N,在投影面上。实际上,z’对于投影后的P’已经没有意义了,这个信息点已经没用了。但对于3D图形管线来说,为了便于进行后面的片元操作,例如z缓冲消隐算法,有必要把投影之前的z保存下来,方便后面使用。因此,我们利用这个没用的信息点存储z,处理成:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421231834.png

这个形式最大化地使用了3个信息点,达到了最原始的投影变换的目的,但是它太直白了,有一点蛮干的意味,我感觉我们最终的结果不应该是它,你说呢?我们开始结合CVV进行思考,把它写得在数学上更优雅一致,更易于程序处理。假入能够把上面写成这个形式:

 https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421231853.png

那么我们就可以非常方便的用矩阵以及齐次坐标理论来表达投影变换:

 
https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421231902.png

其中

 
https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421231918.png

 

哈,看到了齐次坐标的使用,这对于你来说已经不陌生了吧?这个新的形式不仅达到了上面原始投影变换的目的,而且使用了齐次坐标理论,使得处理更加规范化。注意在把

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421231959.png
变成 https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232008.png 的一步我们是使用齐次坐标变普通坐标的规则完成的。这一步在透视投影过程中称为透视除法(Perspective Division),这是透视投影变换的第2步,经过这一步,就丢弃了原始的z值(得到了CVV中对应的z值,后面解释),顶点才算完成了投影。而在这两步之间的就是CVV裁剪过程,所以裁剪空间使用的是齐次坐标
https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232043.png
,主要原因在于透视除法会损失一些必要的信息(如原始z,第4个-z保留的)从而使裁剪变得更加难以处理,这里我们不讨论CVV裁剪的细节,只关注透视投影变换的两步。

矩阵

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232055.png

就是我们投影矩阵的第一个版本。你一定会问为什么要把z写成

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232116.png

 

有三个原因:

  1. 后面投影之后的光栅化阶段,要通过x’和y’对z进行线性插值,以求出三角形内部片元的z,进行z缓冲深度测试。在数学上,投影后的x’和y’,与z不是线性关系,与1/z才是线性关系。而正 https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232253.png 是1/z的线性关系,即-a+b/z。用这个1/z的线性组合值和x’、y’进行插值才是正确的。
  2. P’的3个代数分量统一地除以分母-z,易于使用齐次坐标变为普通坐标来完成,使得处理更加一致、高效。
  3. 后面的CVV是一个x,y,z的范围都为[-1,1]的规则体,便于进行多边形裁剪。而我们可以适当的选择系数a和b,使得 https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232329.png 这个式子在z = -N的时候值为-1,而在z = -F的时候值为1,从而在z方向上构建CVV。

上面是原始作者列举的原因,但我认为主要是一个原因由于在光栅化的过程中,要进行 Z 坐标的倒数的插值,参考透视校正插值)。

接下来我们就求出a和b:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232345.png

  这样我们就得到了透视投影矩阵的第一个版本:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232402.png

使用这个版本的透视投影矩阵可以从z方向上构建CVV,但是x和y方向仍然没有限制在[-1,1]中,我们的透视投影矩阵的下一个版本就要解决这个问题。

为了能在x和y方向把顶点从Frustum情形变成CVV情形,我们开始对x和y进行处理。先来观察我们目前得到的最终变换结果:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232434.png

我们知道-Nx / z的有效范围是投影平面的左边界值(记为left)和右边界值(记为right),即[left, right],-Ny / z则为[bottom, top]。而现在我们想把-Nx / z属于[left, right]映射到x属于[-1, 1]中,-Ny / z属于[bottom, top]映射到y属于[-1, 1]中。你想到了什么?哈,就是我们简单的线性插值,你都已经掌握了!我们解决掉它:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232451.png

则我们得到了最终的投影点:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232614.png

下面要做的就是从这个新形式出发反推出下一个版本的透视投影矩阵。注意到
https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232635.png

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232644.png
经过透视除法的形式,而P’只变化了x和y分量的形式,az+b和-z是不变的,则我们做透视除法的逆处理——给P’每个分量乘上-z,得到

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232712.png

而这个结果又是这么来的:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232720.png

则我们最终得到:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232726.png

M就是最终的透视变换矩阵。相机空间中的顶点,如果在视锥体中,则变换后就在CVV中。如果在视锥体外,变换后就在CVV外。而CVV本身的规则性对于多边形的裁剪很有利。OpenGL在构建透视投影矩阵的时候就使用了M的形式。注意到M的最后一行不是(0 0 0 1)而是(0 0 -1 0),因此可以看出透视变换不是一种仿射变换,它是非线性的。另外一点你可能已经想到,对于投影面来说,它的宽和高大多数情况下不同,即宽高比不为1,比如640/480。而CVV的宽高是相同的,即宽高比永远是1。这就造成了多边形的失真现象,比如一个投影面上的正方形在CVV的面上可能变成了一个长方形。解决这个问题的方法就是在对多变形进行透视变换、裁剪、透视除法之后,在归一化的设备坐标(Normalized Device Coordinates)上进行的视口(viewport)变换中进行校正,它会把归一化的顶点之间按照和投影面上相同的比例变换到视口中,从而解除透视投影变换带来的失真现象。进行校正前提就是要使投影平面的宽高比和视口的宽高比相同。

 

便利的投影矩阵生成函数

 

3D APIs都提供了诸如gluPerspective(fov, aspect, near, far)或者D3DXMatrixPerspectiveFovLH(pOut, fovY, Aspect, zn, zf)这样的函数为用户提供快捷的透视矩阵生成方法。我们还是用OpenGL的相应方法来分析它是如何运作的。

 

gluPerspective(fov, aspect, near, far)

 

fov即视野,是视锥体在xz平面或者yz平面的开角角度,具体哪个平面都可以。OpenGL和D3D都使用yz平面。

 

aspect即投影平面的宽高比。

 

near是近裁剪平面的距离

 

far是远裁剪平面的距离。

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421232759.png

上图中左边是在xz平面计算视锥体,右边是在yz平面计算视锥体。可以看到左边的第3步top = right / aspect使用了除法(图形程序员讨厌的东西),而右边第3步right = top x aspect使用了乘法,这也许就是为什么图形APIs采用yz平面的原因吧!

 

到目前为止已经完成了对透视投影变换的阐述,我想如果你一直跟着我的思路下来,应该能够对透视投影变换有一个细节层次上的认识。当然,很有可能你已经是一个透视投影变换专家,如果是这样的话,一定给我写信,指出我认识上的不足,我会非常感激。Bye!

投影变换(推导方案二)


个人更倾向这种推导

转自:https://www.scratchapixel.com/lessons/3d-basic-rendering/perspective-and-orthographic-projection-matrix/opengl-perspective-projection-matrix

我们从相机的原点到要投影的点 P 画一条线,该线与图像平面的交点表示投影点 Ps 的位置。在OpenGL中,图像平面位于附近的剪切平面上(而不是与相机原点精确地相差一个单位),这里是近剪切面。

( 假设相机空间下任意一点 P ,首先投影到近裁剪面所在平面的点为 Ps,然后将 Ps 变换到NDC立方体空间)
https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422230135.png

图1:我们使用相似三角形的属性来找到 Ps 的位置。

相似的三角形ΔABC 和 ΔDEF是相似的。因此我们可以这样写:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422230519.png

如果我们用n 替代AB,近裁剪平面,DE用 Pz替代 (P的z坐标)和EF用 Py替代(P的y坐标),我们可以将该等式重写为(等式1):

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422230841.png

根据类似的原理得到方程式(等式2)

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422231103.png

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422231211.png

图2:相机的视锥范围或观看量由相机的视场,近和远裁剪平面以及图像纵横比定义。在OpenGL中,点投影在视锥的正面(靠近剪切平面)上。

计算 x和y的范围([-1,1])


现在我们有两个值 Psx 和 Psÿ我们仍然需要解释它们与OpenGL透视矩阵的关系。投影矩阵的目标是将投影到图像平面上的值重新映射到一个单位立方体(一个最小和最大范围分别为(-1,-1,-1)和(1,1,1)的立方体)。但是,一旦将点P投影到图像平面上,如果其x和y坐标包含在x的[left,rigtht]和y [bottom,top]的范围内,则Ps是可见的。如图2所示。这些坐标定义了可见点(包含在视锥体中并投影在图像平面上的所有点)在图像平面上的界限或边界。如果我们假设PsX 是可见的,那么我们可以这样写:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422231355.png 然后对该不等式进行变换, https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422231700.png 再进行变换 https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422232350.png

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422232410.png

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422232427.png

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422232503.png

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422232533.png

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422232555.png

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422232623.png 上面不等式都乘以Pz后得到新的不等式,我们用矩阵的形式对不等式编码。将不等式的第一项系数和第二项系数替换矩阵的第一行第一列和第三列的系数,得到的结果:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422232907.png

请记住,OpenGL矩阵使用colum-major排序,因此我们将不得不使用列向量在矩阵的右边以及点坐标处写入乘法符号:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422233100.png

根据矩阵计算出Psx结果: https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422233428.png

然后除以 -Pz

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422233740.png

根据上面的推导,我们可以推导出Psy 的不等式:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422233859.png

然后将系数带入矩阵 得出结果:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422233957.png

根据矩阵运算得出:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422234048.png

然后除以 -Pz后: https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422234104.png

计算 z的范围([-1,1])


我们剩下要做的就是找到一种将投影点的z坐标重新映射到[-1,1]范围的方法。我们知道P的x和y坐标对投影点z坐标的计算没有帮助。因此,将要与P x和y坐标相乘的矩阵第三行的第一和第二系数必须为零(绿色)。我们在矩阵中剩下两个未知的系数A和B(红色)

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422234321.png

如果我们写方程式来计算 Ps然后除以 -Pž后 使用这个矩阵,我们得到(记住 Psž 也除以 Psw 当点从齐次坐标转换为笛卡尔坐标时,同时 Pw = 1):

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422234418.png

我们需要找到A和B的值。希望我们知道 Pž 躺在附近的裁剪平面上, Psž 需要重新映射为-1以及当 Pž 躺在远的剪裁平面上, Psž 需要重新映射为1。因此,我们需要替换 Psž 经过 ñ 和 F 在方程中得到两个新方程(请注意,投影在像面上的所有点的z坐标为负,但 ñ 和 F 是正数,因此我们将使用 − n 和 - f 取反):

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422234754.png

让我们求解方程式(1)中的B:

  B = − n + A n 。

并用该等式代入方程式(2)中的B:

  -fA − n + A n = f。

然后求解A和B:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422235039.png

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422235150.png

我们可以替换在矩阵中为A和B找到的解决方案,最后得到:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210422235233.png

这就是OpenGL透视投影矩阵

z-fighting

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210423000431.png

图3:投影点的z坐标的重新映射是非线性的。此图显示了结果Psž 对于Near = 1和far = 5。

z坐标的重映射(我们选择将z重映射到[-1,1]范围。从技术上讲,您可以将其重新映射为所需的任何内容,但[-1,1]也是常见的选择。)具有以比距离更远的点更高的数值精度来表示距离摄像机更近的点的属性。当缺乏数值精度导致某些相邻样本在投影到屏幕后,当它们的z坐标在世界上时,它们具有相同的深度值时,此属性可能会成为问题。空间实际上是不同的,这个问题称为 Z战 (z-fighting) 。这个问题不能真正解决(我们始终局限于可以存储在单精度浮点数中的精度,尽管如果将近剪切面和远剪切面分别设置得尽可能接近,则可以将问题最小化。场景中最接近和最远的物体,这就是为什么总是建议调整剪切平面的原因)。 最后翻译可能不准确,这里给出原文: (we are always limited to the precision that can be stored in a single-precision floating-point number though the problem can be minimised if the near and flar clipping planes are fit respectively as closely as possible to the nearest and furthest object visible in the scene. This is the reason why adjusting the clipping planes is always recommended.)

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210423001302.png

简单概括透视投影变换

  • 先变换x,y
    1. 将透视视椎体P投影到近裁剪面上P1
    2. 将在近裁剪面上P1变换到NDC正方形[-1,1]范围内
  • 再变换z
    1. 使用 (A*z+B)/(-Pz) 的代数形式根据取值范围[-1,1]求解A和B

注意求解A和B时:
方程式:
(A*(-n)+B)/(-(-n))=-1
(A*(-f)+B)/(-(-f))=1
为啥-n带入后是-1,因为是从相机空间(右手坐标系)变换到ndc空间(左手坐标系),因为坐标系变换所以不等式代入-n后对应ndc空间是-1,不等式代入-f后对应ndc空间是1
https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210424144836.png
https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421231724.png

Unity中透视投影变换


根据上面推导的结果按照右边yz平面计算视锥体,得出最终Unity中用的透视矩阵:

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421235050.png

https://cdn.jsdelivr.net/gh/codingriver/cdn/texs/透视投影变换推导/20210421235111.png

按照OpenGL计算的,不讨论DirectX

OpenGL Projection Matrix