http://blog.csdn.net/Hoyi_Liu/archive/2009/06/22/4288443.aspx
哈哈,今天终于搞明白透视投影变换的原理了,都迷惑了好几年了,一直找不到比较明了的书和文章,大多文章都写得不全不细致。只到读到这篇文章才正真搞明白,作者写的太好了,一步步的推导让人看的十分明了,在此谢过作者!下面贴出来做个笔记,以备将来翻看。
-Twinsen 编写
- 本人水平有限,疏忽错误在所难免,还请各位数学高手、编程高手不吝赐教
-email: popyy@netease.com
透视投影是 3D 固定流水线的重要组成部分,是将相机空间中的点从视锥体 (frustum) 变换到规则观察体 (Canonical View Volume) 中,待裁剪完毕后进行透视除法的行为。在算法中它是通过透视矩阵乘法和透视除法两步完成的。
透视投影变换是令很多刚刚进入 3D 图形领域的开发人员感到迷惑乃至神秘的一个图形技术。其中的理解困难在于步骤繁琐,对一些基础知识过分依赖,一旦对它们中的任何地方感到陌生,立刻导致理解停止不前。
没错,主流的 3D APIs 如 OpenGL 、 D3D 的确把具体的透视投影细节封装起来,比如
gluPerspective(…) 就 可以根据输入生成一个透视投影矩阵。而且在大多数情况下不需要了解具体的内幕算法也可以完成任务。但是你不觉得,如果想要成为一个职业的图形程序员或游戏 开发者,就应该真正降伏透视投影这个家伙么?我们先从必需的基础知识着手,一步一步深入下去(这些知识在很多地方可以单独找到,但我从来没有在同一个地方 全部找到,但是你现在找到了 J )。
我们首先介绍两个必须掌握的知识。有了它们,我们才不至于在理解透视投影变换的过程中迷失方向(这里会使用到向量几何、矩阵的部分知识,如果你对此不是很熟悉,可以参考 《向量几何在游戏编程中的使用》 系列文章)。
齐次坐标表示
透视投影变换是在齐次坐标下进行的,而齐次坐标本身就是一个令人迷惑的概念,这里我们先把它理解清楚。
根据 《向量几何在游戏编程中的使用 6 》 中关于基的概念。对于一个向量 v 以及基 oabc ,
可以找到一组坐标 (v1,v2,v3) ,使得
v = v1 a + v2 b + v3 c ( 1 )
而对于一个点 p ,则可以找到一组坐标( p1,p2,p3 ),使得
p – o = p1 a + p2 b + p3 c ( 2 )
从上面对向量 和点 的表达,我们可以看出为了在坐标系中表示一个点 (如 p ),我们把点的位置看作是对这个基的原点 o 所进行的一个位移,即一个向量—— p – o (有的书中把这样的向量叫做位置向量 ——起始于坐标原点的特殊向量),我们在表达这个向量的同时用等价的方式表达出了点 p :
p = o + p1 a + p2 b + p3 c (3)
(1)(3) 是坐标系下表达一个向量 和点 的不同表达方式。这里可以看出,虽然都是用代数分量的形式表达向量和点,但表达一个点比一个向量需要额外的信息。如果我写出一个代数分量表达 (1, 4, 7) ,谁知道它是个向量还是个点!
我们现在把( 1 )( 3 )写成矩阵的形式:
这里 (a,b,c,o) 是坐标基矩阵,右边的列向量分别是向量 v 和点 p 在基下的坐标。这样,向量和点在同一个基下就有了不同的表达: 3D 向量 的第 4 个代数分量是 0 ,而 3D 点 的第 4 个代数分量是 1 。像这种这种用 4 个代数分量表示 3D 几何概念的方式是一种齐次坐标表示。
“齐次坐标表示是计算机图形学的重要手段之一,它既能够用来明确区分向量和点,同时也更易用于进行仿射(线性)几何变换。”—— F.S. Hill, JR
这样,上面的 (1, 4, 7) 如果写成( 1,4,7,0 ),它就是个向量;如果是 (1,4,7,1) ,它就是个点。
下面是如何在普通坐标 (Ordinary Coordinate) 和齐次坐标 (Homogeneous Coordinate) 之间进行转换:
从普通坐标转换成齐次坐标时,
如果 (x,y,z) 是个点,则变为 (x,y,z,1);
如果 (x,y,z) 是个向量,则变为 (x,y,z,0)
从齐次坐标转换成普通坐标时,
如果是 (x,y,z,1) ,则知道它是个点,变成 (x,y,z);
如果是 (x,y,z,0) ,则知道它是个向量,仍然变成 (x,y,z)
以上是通过齐次坐标来区分向量和点的方式。从中可以思考得知,对于平移 T 、旋转 R 、缩放 S 这 3 个最常见的仿射变换,平移变换只对于点才有意义,因为普通向量没有位置概念,只有大小和方向,这可以通过下面的式子清楚地看出:
而旋转和缩放对于向量和点都有意义,你可以用类似上面齐次表示来检测。从中可以看出,齐次坐标用于仿射变换非常方便。
此外,对于一个普通坐标的点 P=(Px, Py, Pz) ,有对应的一族齐次坐标 (wPx, wPy, wPz, w) ,其中 w 不等于零。比如, P(1, 4, 7) 的齐次坐标有 (1, 4, 7, 1) 、( 2, 8, 14, 2 )、( -0.1, -0.4, -0.7, -0.1 )等等。因此,如果把一个点从普通坐标变成齐次坐标,给 x,y,z 乘上同一个非零数 w ,然后增加第 4 个分量 w ;如果把一个齐次坐标转换成普通坐标,把前三个坐标同时除以第 4 个坐标,然后去掉第 4 个分量。
由于齐次坐标使用了 4 个分量来表达 3D 概念,使得平移变换可以使用矩阵进行,从而如 F.S. Hill, JR 所说,仿射(线性)变换的进行更加方便。由于图形硬件已经普遍地支持齐次坐标与矩阵乘法,因此更加促进了齐次坐标使用,使得它似乎成为图形学中的一个标准。
简单的线性插值
这是在图形学中普遍使用的基本技巧,我们在很多地方都会用到,比如 2D 位图的放大、缩小, Tweening 变换,以及我们即将看到的透视投影变换等等。基本思想是:给一个 x 属于 [a, b] ,找到 y 属于 [c, d] ,使得 x 与 a 的距离比上 ab 长度所得到的比例,等于 y 与 c 的距离比上 cd 长度所得到的比例,用数学表达式描述很容易理解:
这样,从 a 到 b 的每一个点都与 c 到 d 上的唯一一个点对应。有一个 x ,就可以求得一个 y 。
此外,如果 x 不在 [a, b] 内,比如 x < a 或者 x > b ,则得到的 y 也是符合 y < c 或者 y > d ,比例仍然不变,插值同样适用。
透视投影变换
好,有了上面两个理论知识,我们开始分析这次的主角——透视投影变换。这里我们选择 OpenGL 的透视投影变换进行分析,其他的 APIs 会存在一些差异,但主体思想是相似的,可以类似地推导。经过相机矩阵的变换,顶点被变换到了相机空间。这个时候的多边形也许会被视锥体裁剪,但在这个不规则的体中进行裁剪并非那么容易的事情,所以经过图形学前辈们的精心分析,裁剪被安排到规则观察体 (Canonical View Volume, CVV) 中进行, CVV 是一个正方体, x, y, z 的范围都是 [-1 , 1] ,多边形裁剪就是用这个规则体完成的。所以,事实上是透视投影变换由两步组成:
1) 用透视变换矩阵把顶点从视锥体中变换到裁剪空间的 CVV 中。
2) CVV 裁剪完成后进行透视除法 (一会进行解释)。
我们一步一步来,我们先从一个方向考察投影关系。
上图是右手坐标系中顶点在相机空间中的情形。设 P(x,z) 是经过相机变换之后的点,视锥体由 eye ——眼睛位置, np ——近裁剪平面, fp ——远裁剪平面组成。 N 是眼睛到近裁剪平面的距离, F 是眼睛到远裁剪平面的距离。投影面可以选择任何平行于近裁剪平面的平面,这里我们选择近裁剪平面作为投影平面。设 P’(x’,z’) 是投影之后的点,则有 z’ = -N 。通过相似三角形性质,我们有关系:
同理,有
这样,我们便得到了 P 投影后的点 P’
从上面可以看出,投影的结果 z’ 始终等于 -N ,在投影面上。实际上, z’ 对于投影后的 P’ 已经没有意义了,这个信息点已经没用了。但对于 3D 图形管线来说,为了便于进行后面的片元操作,例如 z 缓冲消隐算法,有必要把投影之前的 z 保存下来,方便后面使用。因此,我们利用这个没用的信息点存储 z ,处理成:
这个形式最大化地使用了 3 个信息点,达到了最原始的投影变换的目的,但是它太直白了,有一点蛮干的意味,我感觉我们最终的结果不应该是它,你说呢?我们开始结合 CVV 进行思考,把它写得在数学上更优雅一致,更易于程序处理。假入能够把上面写成这个形式:
那么我们就可以非常方便的用矩阵以及齐次坐标理论来表达投影变换:
其中
哈,看到了齐次坐标的使用,这对于你来说已经不陌生了吧?这个新的形式不仅达到了上面原始投影变换的目的,而且使用了齐次坐标理论,使得处理更加规范化。注意在把 变成 的一步我们是使用齐次坐标变普通坐标的规则完成的。这一步在透视投影过程中称为透视除法( Perspective Division ) ,这是透视投影变换的第 2 步,经过这一步,就丢弃了原始的 z 值(得到了 CVV 中对应的 z 值,后面解释),顶点才算完成了投影。而在这两步之间的就是 CVV 裁剪过程,所以裁剪空间使用的是齐次坐标 ,主要原因在于透视除法会损失一些必要的信息(如原始 z ,第 4 个 -z 保留的)从而使裁剪变得更加难以处理,这里我们不讨论 CVV 裁剪的细节,只关注透视投影变换的两步。
矩阵
就是我们投影矩阵的第一个版本。你一定会问为什么要把 z 写成
有两个原因:
1) P’ 的 3 个代数分量统一地除以分母 -z ,易于使用齐次坐标变为普通坐标来完成,使得处理更加一致、高效。
2) 后面的 CVV 是一个 x,y,z 的范围都为 [-1 , 1] 的规则体,便于进行多边形裁剪。而我们可以适当的选择系数 a 和 b ,使得 这个式子在 z = -N 的时候值为 -1 ,而在 z = -F 的时候值为 1 ,从而在 z 方向上构建 CVV 。
接下来我们就求出 a 和 b :
这样我们就得到了透视投影矩阵的第一个版本:
使用这个版本的透视投影矩阵可以从 z 方向上构建 CVV ,但是 x 和 y 方向仍然没有限制在 [-1,1] 中,我们的透视投影矩阵的下一个版本就要解决这个问题。
为了能在 x 和 y 方向把顶点从 Frustum 情形变成 CVV 情形,我们开始对 x 和 y 进行处理。先来观察我们目前得到的最终变换结果:
我们知道 -Nx / z 的有效范围是投影平面的左边界值(记为 left )和右边界值(记为 right ),即 [left, right] , -Ny / z 则为 [bottom, top] 。而现在我们想把 -Nx / z 属于 [left, right] 映射到 x 属于 [-1, 1] 中, -Ny / z 属于 [bottom, top] 映射到 y 属于 [-1, 1] 中。你想到了什么?哈,就是我们简单的线性插值,你都已经掌握了!我们解决掉它:
则我们得到了最终的投影点:
下面要做的就是从这个新形式出发反推出下一个版本的透视投影矩阵。注意到 是 经过透视除法的形式,而 P’ 只变化了 x 和 y 分量的形式, az+b 和 -z 是不变的,则我们做透视除法的逆处理——给 P’ 每个分量乘上 -z ,得到
而这个结果又是这么来的:
则我们最终得到:
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 是远裁剪平面的距离。
上图中左边是在 xz 平面计算视锥体,右边是在 yz 平面计算视锥体。可以看到左边的第 3 步 top = right / aspect 使用了除法(图形程序员讨厌的东西),而右边第 3 步 right = top x aspect 使用了乘法,这也许就是为什么图形 APIs 采用 yz 平面的原因吧!
到目前为止已经完成了对透视投影变换的阐述,我想如果你一直跟着我的思路下来,应该能够对透视投影变换有一个细节层次上的认识。当然,很有可能你已经是一个透视投影变换专家,如果是这样的话,一定给我写信,指出我认识上的不足,我会非常感激 J 。 Bye!
深入探索透视投影变换(续)收藏
- 潘宏
- 2009.4.14
- 本人水平有限,疏忽错误在所难免,还请各位数学高手、编程高手不吝赐教
-email: popyy@netease.com
在上一篇文章中我们讨论了透视投影变换的原理,分析了 OpenGL 所使用的透视投影矩阵的生成方法。正如我们所说,不同的图形 API 因为左右手坐标系、行向量列向量矩阵以及变换范围等等的不同导致了矩阵的差异,可以有几十个不同的透视投影矩阵,但它们的原理大同小异。这次我们准备讨论一下 Direct3D (以下简称 D3D )以及 J2ME 平台上的 JSR184 ( M3G )(以下简称 M3G )的透视投影矩阵,主要出于以下几个目的:
(1) 我们在写图形引擎的时候需要采用不同的图形 API 实现,当前主要是 OpenGL 和 D3D 。虽然二者的推导极为相似,但 D3D 的自身特点导致了一些地方仍然需要澄清。
(2) DirectX SDK 的手册中有关于透视投影矩阵的一些说明,但并不详细,甚至有一些错误,从而使初学者理解起来变得困难,而这正是本文写作的目的。
(3) M3G 是 J2ME 平台上的 3D 开发包,采用了 OpenGL 作为底层标准进行封装。它的透视投影矩阵使用 OpenGL 的环境但又进行了简化,值得一提。
本文努力让读者清楚地了解 D3D 与 M3G 透视投影矩阵的原理,从而能够知道它与 OpenGL 的一些差别,为构建跨 API 的图形引擎打好基础。需要指出的一点是为了完全理解本文的内容,请读者先理解上一篇文章《深入探索透视投影变换》的内容 ,因为 OpenGL 和它们的透视投影矩阵的原理非常相似,因此这里不会像上一篇文章从基础知识讲起,而是对比它们的差异来推导变换矩阵。我们开始!
OpenGL 与 D3D 的基本差异
前面提到,不同 API 的基本差异导致了最终变换矩阵的不同,而导致 OpenGL 和 D3D 的透视投影矩阵不同的原因有以下几个:
(1) OpenGL 默认使用右手坐标系,而 D3D 默认使用左手坐标系。
(2) OpenGL 使用列向量矩阵乘法而 D3D 使用行向量矩阵乘法。
(3) OpenGL 的 CVV 的 Z 范围是 [-1, 1] , D3D 的 CVV 的 Z 范围是 [0, 1] 。
以上这些差异导致了最终 OpenGL 和 D3D 的透视投影矩阵的不同。
D3D 的透视投影矩阵推导
我们先来看最最基本的透视关系图(上一篇文章开始的时候使用的图):
这里我们考察的是 xz 平面上的关系, yz 平面上的关系同理。这里 o 是相机位置。 np 是近裁剪平面,也是投影平面, N 是它到相机的距离。 fp 是远裁剪平面, F 是它到相机的位置。 p 是需要投影的点, p’ 是投影之后的点。根据相似三角形定 理,我们有
则有
注意到 OpenGL 使用右手坐标系,因此应该使用 -N (请参考上一篇文章的这一步),而 D3D 使用左手坐标系,因此使用 N ,这是二 者的不同点之一。这样,我们得到投影之后的点
第三个信息点是变换之后的 z 在投影平面上的位置,也就是 N ,它已经没用了,我们把 p’ 写成
从而用第三个没用信息点它来存储 z (如果读者对这一点不太了解,请参考上一篇文章)。接下来我们求出 a 和 b ,从而在 z 方向上构建 CVV 。请注意这里是 OpenGL 和 D3D 的另一个不同点, OpenGL 的 CVV 的 z 范围是 [-1, 1] ,而 D3D 的 CVV 的 z 范围是 [0, 1] 。也就是说, D3D 中在近裁剪平面上的点投影之后的点会处于 CVV 的 z=0 平面上,而在远裁剪平面上的点投影之后的点会在 CVV 的 z=1 平面上。这样我们的计算方程就是
从而我们得到了透视投影矩阵的第一个版本
即
这个时候第三个分量变换到 CVV 情形了, CVV 的 z 范围是 [0,1] 。接下来根据上一篇文章所讲到的,我们要把前两个分量变成 CVV 情形, CVV 的 x 和 y 范围是 [-1, 1] ,如下图所示:
使用线性插值,我们有:
这里 left 和 right 是投影平面的左右范围, top 和 bottom 是投影平面的上下范围。 x cvv 和 y cvv 是我们需要算出的在 CVV 情形中的 x 和 y ,也就是我们要计算出的结果。但在算出它们之前,我们先把上面的式子写成:
这里有一个需要注意的地方,如果投影平面在 x 方向上居中,则
那么第一个式子就可以销掉等号两边的 1/2 ,写成
同理,如果投影平面在 y 方向上居中,则第二个式子可以写成
则我们现在分两种情况讨论:
(1) 投影平面的中心和 x-y 平面的中心重合(在 x 和 y 方向上都居中)
(2) 一般情况
我们分别讨论:
( 1 )特殊情况方程
这组是特殊情况,方程比较简单,但也是使用频率最高的方式(这是 D3DXMatrixPerspectiveLH 、 D3DXMatrixPerspectiveRH 、 D3DXMatrixPerspectiveFovLH 、 D3DXMatrixPerspectiveFovRH 四个方法所使用的情况)。我们导出它:
则我们反推出透视投影矩阵:
其中
而 r-l 和 t-b 可以分别看作是投影平面的宽 w 和高 h 。最后那个矩阵就是 D3D 的透视投影矩阵之一。另外呢,如果我们不知道 right 、 left 、 top 以及 bottom 这几个参量,也可以根据视野( FOV – Field Of View )参量来求得。下面是两个平面的视野关系图:
其中,两个 fov 分别是在 x-z 以及 y-z 平面上的视野。如果只给了一个视野,也可以通过投影平面的宽高比计算出来:
用一个视野算出 w 或者 h ,然后用宽高比算出 h 或者 w 。
( 2 )一般情况的方程
这组方程比较繁琐,但更具一般性(和 OpenGL 一般矩阵的推导一致,这也是 D3DXMatrixPerspectiveOffCenterLH 和 D3DXMatrixPerspectiveOffCenterRH 两个方法所使用的情况)。我们导出它:
我们继续反推出透视投影矩阵:
其中
最后那个矩阵就是 D3D 的一般透视投影矩阵。
好了,目前为止,我们已经导出了 D3D 的两个透视投影矩阵。下面我把上一篇导出的 OpenGL 的透视投影矩阵写出来,大家可以拿它和刚刚导出的 D3D 的一般性透视投影矩阵做一个对比。
如果仔细观察,可以发现二者在元素的布局上是一个转置的关系,这个就是由它们使用的左右手坐标系以及使用的行列矩阵的差异造成的。而另外在一些元素的细节上也存在着差异,这是由于 D3D 的 CVV 的 z 范围不同造成的。可见在原理相同的情况下,细微的环境差异可以造成非常大的变化,而这就是透视投影矩阵存在诸多不同版本的原因。一般情况的透视投影矩阵也可以使用视野方式来定义,方法和特殊情况相同。
M3G 的透视投影矩阵
M3G 是对 OpenGL 进行的一个封装,它的透视投影变换矩阵被放到了类 Camera 里面。因为它封装了 OpenGL ,因此环境和 OpenGL 相同:右手坐标系、列向量乘法、 CVV 范围 [-1 , 1] 。它唯一和 OpenGL 有些差异的地方就在于它只使用投影平面的中心和 x-y 平面的中心重合(在 x 和 y 方向上都居中)的情况(就是我们上面 D3D 的第一种特殊情况)。我们用 OpenGL 透视投影矩阵最终版本来说明(再次提醒,如果读者对此感到迷惑,请参考第一篇文章):
上面是 OpenGL 透视投影矩阵的最终版本,也是一般性版本,我们要把它变成特殊性,版本,非常简单,和上面 D3D 的特殊情况一样,我们从对 x 和 y 进行插值的那一步来看:
和 D3D 的第一种情况一样,销掉两边的 1/2 ,得到:
则我们反推出透视投影矩阵:
最右边那个矩阵就是 M3G 的透视投影矩阵。仍然可以通过视野参数来设置透视投影矩阵,这里请读者自行推导,方法与上面 D3D 的完全相同。
结束语
我们已经完成了对 D3D 和 M3G 透视投影矩阵的说明。如果读者理解了上面的内容,可能会觉得有些厌恶——为什么没有一个统一的标准,在同一个原理下为什么要弄出这么多种差别?原因有很多,历史遗留问题、 API 厂商之间的问题等等。但对于我们来说,抓住了原理以及方法,不论如何变化都应该不会迷失。下次见!