OpenGL-ES摄像机和MVP矩阵

本文介绍了OpenGL中的MVP矩阵、摄像机等相关知识,翻译自Cameras on OpenGL ES 2.x - The ModelViewProjection Matrix

朋友们大家好!

这边文章中,我将要讨论3D世界中非常非常重要的一部分。众所周知,我们设备屏幕背后的这个世界仅仅是在努力重现人眼的美丽和复杂性。为此我们要使用摄像机(cameras),它是真实世界中人眼的模拟。我们使用数学公式来构造摄像机。

在本文中,我将讨论摄像机及其背后的公式,凸透镜和凹透镜的区别,什么是投影、矩阵、四元数,最后是著名的模型-视图-投影矩阵(Model View Projection Matrix)。你们知道的,如果有任何疑问尽管提问,我很乐意帮助。

这里是一个小列表,可以帮助你找到教程中你想要的内容。

  • 教程内容列表

{:toc}

概览

我们先来看摄像机的基础,它在真实世界中如何工作,镜头的区别,缩放的原理,平移,旋转和一些相似的概念。在巩固了这些概念之后,我们会深入OpenGL并理解那些内容如何在我们的应用程序中配合。所以最后我们会有一些代码,我会提供公式并解释其如何工作。

是否还记得在我的OpenGL系列教程中,我说了Khronos推卸了很多责任并且是致力于3D世界最重要部分的时候?(更准确地说是在第一部分,如果不记得了你可以看这里)。好的,从OpenGL ES 1.x到2.x,摄像机就是Khronos所推卸的这些责任之一。所以我们必须自己创建摄像机。想知道吗?我爱这个!通过着色器我们可以很大程度地控制我们的应用程序以达到惊人的效果,除此之外,我们还可以自由地创建优秀的3D引擎。

使用OpenGL来控制摄像机,我们只有两三种摄像机。但当我们开始自己编程实现摄像机,我们能够创建任何种类的摄像机。本文中我将会讨论最基本的摄像机:正交摄像机和透视摄像机。

OK,我们现在开始!

真实世界的摄像机

人的眼睛是凸透镜,在视网膜上将图像转化为上下颠倒的。通常摄像机的镜头是由多个凸透镜和凹透镜组成,但最终的图像使其更像是一个凸透镜,与人的眼睛相似。

最终图像取决于很多因素,不仅仅是镜头的类型,但是概括来说,以下的图片展示了图像在不同类型的透镜后呈现的样子。

A picture behind each kind of lens.

两种类型都可以产生与原始图像相同的图像。意思是,使用一个微小角度的扭曲,取决于物体与镜头的距离还有视角(angle of view)。下一个图片将展示一个摄像机最重要的属性。

Camera attributes.

图片中红色的区域对于摄像机来说是不可见的,所以这一区域内的所有片段(fragment)都会被剪切掉。“景深(Depth of Field)”是可见区域,它内部的所有片段都是可见的。通常,“景深”这一个词还可以用来描述一种特殊效果,镜头模糊效果(Lens Blur)。因为人的眼睛有焦点(focus),使得焦点以外的物体看起来很模糊,镜头模糊特效模拟这个焦点,让焦点以外的物体看起来模糊。那么我为什么不把属性“焦点”放在上边的图片里呢?因为焦点是某些摄像机仅有的特殊特性,基础的3D摄像机并没有实现焦点的行为。另一个重要的属性是“视角(Angle of View)”,代表的是摄像机可见的水平方向角度。这一角度以外的任何片段对摄像机来说都是不可见的。有些时候这个“视角”也可以用来代表垂直区域,但是通常我们更倾向于使用宽度和高度来定义最终图像的宽高比。

现代摄像机非常精确,并可以使用摄像机属性并组合不同的镜头来实现很多很棒的特效。现在我们回到我们虚拟的世界,看看我们可以怎样从数学的角度转化这些属性和行为。但是在我们开始3D摄像机之前,我们需要再了解一些3D世界中的数学。

3D世界简史

我们3D世界的祖父是欧几里得(Euclid),也就是大家知道的亚历山大的欧几里得(Euclid of Alexandria)。他生活在公元前323–283(哇,他还真有点老!)的希腊城市亚历山大。欧几里得创建了我们今天还在用的欧几里得空间(Euclidean Space)和欧几里得几何(Euclidean Geometry),我确定你之前听过这些名字。简单来讲欧几里得空间由3个平面构成,这三个平面提供了三个轴向X、Y和Z。每个平面使用的都是传统几何(traditional geometry),另一个希腊人毕达哥拉斯(Pythagoras,公元前570-公元前495)对传统几何有很大的贡献。欧几里得发展他们的概念的原因不难得出,你懂的,希腊人热爱建筑学,为了构建更完美的结构形式,他们需要在3D的假想空间中完成所有的微积分学工作,不用说他们的哲学和对科学的热情。

在我们的时光机里向前很多年,我们到达17世纪初,一个叫笛卡尔(René Descartes)的伟人创建了笛卡尔坐标系的时候。这是惊人的创举!它把欧几里得的理论和线性代数联系在了一起,并向欧几里得变换(Euclidean Transformations,即平移,缩放和旋转)引入了矩阵。欧几里得变换是用传统的毕达哥拉斯方法创建的,所以你可以想象有多大的计算量,但是多亏了笛卡尔我们能够使用矩阵来实现欧几里得变换。简便,快速并且更漂亮!3D世界的矩阵真是棒极了!

但是使用欧几里得变换的矩阵并不完美。它们出现了很多问题,最大的一个是关于旋转的,叫做万向节死锁(Gimbal Lock)。当你试图旋转一个平面并且很偶然另两个平面和它们自己接触,那么下一次旋转这两个平面之一时就会出现万向节死锁,这意味着它将被动地旋转两个被锁的轴。很多年之后,另一个伟人汉密尔顿(Sir William Rowan Hamilton)在1843年创造了一种处理欧几里得旋转并避免万向节死锁的方法,叫做四元数法(Quaternions)。四元数更快,更好并且是处理3D旋转最优雅的方式。四元数是由一个虚部(复数)和一个实部组成。正如在3D世界中我们总会用到单位矢量(矢量的大小/长度等于1)的计算,我们可以丢弃四元数的虚部只使用实数。准确地说,四元数是汉密尔顿的一篇论文,它包含了很多比3D旋转更多的东西,但对于我们和3D世界来说,最主要的应用就是处理旋转。

OK,这些东西和摄像机到底有什么关系呢?很简单,基于这一切我们开始使用4x4的矩阵来处理欧几里得变换并使用一个4元素的矢量来描述空间中的点(XYZW)。W是齐次坐标元素。这里我不会讨论它,只是告诉你齐次坐标是奥古斯特·费迪南德·莫比乌斯(August Ferdinand Möbius)于1827年创建的,用于处理笛卡尔坐标系中的无限这一概念。我们稍后会讨论莫比乌斯的贡献,但是简短地说,把无限的概念放在笛卡尔坐标系中非常复杂,为此我们可以使用一个复数虚部,但是这对于实数计算实在是不好。所以为了解决这一问题,莫比乌斯增加了一个实数变量W,把我们带回了实数的世界。无论如何,关键点在于4x4矩阵完美适用于4阶矢量,并且当我们在3D世界中使用单个矩阵来完成欧几里得变换时,我们认为使用相同的4x4矩阵来处理3D世界中的摄像机也是一个不错的想法。

Visual representation of a Matrix 4x4 and a Quaternion.

上图展示的是4x4矩阵和四元数看起来的样子。如你所见,矩阵中有3个位置的值用来平移(位置XYZ),但是其它的指令混杂在红色区域中。每个旋转(XYZ)会影响4个位置的值,而每个缩放(XYZ)会影响1个位置的值。四元数有4个实数值,其中三个(XYZ)代表着一个矢量,这个矢量会形成一个方向。第四个值代表它围绕它的枢轴的旋转。我们稍后会讨论四元数,但是它最酷的特征是我们可以从中提取出一个旋转矩阵。我们可以通过构造一个4x4矩阵并且只填充黄色的元素来实现这一过程。

现在你可能会想:“卧槽”!冷静一下,实践并不像理论那样复杂!在着手敲代码之前,我们还需要另一个概念:投影。

投影

我不打算从技术上解释它,而仅仅是向你展示!我确信你已经知道两种投影类型的不同,或许是其它的名字,但我确信你知道那是什么意思:

Differences between Orthographic and Perspective Projection.

projection_simcity_example

看到了吗?很简单,正交投影(Orthographic projection)在2D游戏中很常用,像模拟城市(Sim City)或老版本的模拟人生(The Sims)或畅销的暗黑破坏神(Diablo),暗黑破坏神III除外,它使用了透视投影(Perspective projection)。正交投影在真实世界中并不存在,人的眼睛不可能接收到这样的图像,因为我们的眼睛形成的图像总会有一个消失点(vanish point)。所以真实的摄像机总是捕获透视视图的图像。

很多人问我使用OpenGL来做2D图形,好吧,这是我的第一个提示,你将会使用正交投影来创建类似于模拟城市或模拟人生那样的游戏。星际争霸(Starcraft)那样的游戏使用透视投影来模拟正交投影。这可能吗?当然!因为一切都和镜头的行为关联,最终的图像会取决于很多因素,例如,视角很大的透视投影可以看起来更像是一个正交投影。我的意思是,那像是从空中的飞机上看地面。从那个距离之外,城市看起来像是模型而消失点也似乎不起作用。

projection_starcraft_high_example

在继续下文之前,我们需要一点题外话来深入理解一下两种投影之间的差别。你还记得笛卡尔和他的笛卡尔坐标系对吧?在线性代数中两个平行线永不相交,即时是无限长。我们如何在线性代数中处理无限这个想法呢?使用∞(无限符号)来计算吗?没用的。为了创建透视投影,我们真的需要一个消失点,有了它两条平行线就一定会在无限远处接触。因此,我们该如何解决呢?我们不能!至少不会是使用线性代数知识。我们需要一些其它的东西。

多亏莫比乌斯,我们可以处理这个小问题了。这个人创造了“齐次坐标(Homogeneous Coordinates)”。

vanish_point_example

这个方法简单到不可描述,简直难以置信(正合我意)。莫比乌斯仅仅是向任何的多维系统增加了一个最后的坐标值即坐标w。2D变成了2D+1(x,y -> x,y,w),3D变成了3D+1(x,y,z -> x,y,z,w)。在空间计算中我们仅仅是将原始值除以w,就这样。看这段伪代码:

1
2
3
4
5
6
// This is the original cartesian coordinates.
x = 3.0; y = 1.5;
// This is new homogeneous coordinate.
w = 1.0;
// Space calculations.
finalX = x/w; finalY = y/w;

在大多数情况下w会是1.0。只有在表示(无限)时它才会变,这时w将是0.0!“卧槽!!!除以0?”并不是这样。w常用来求解两方程的系统,因此如果是0,投影将会投向无限远。我知道似乎要被理论搞晕了,一个简单的实用例子是产生阴影。当我们在3D场景中有一个灯光时,一个无限远处并且不会衰减的灯光,像3D世界中的太阳光,我们可以很简单地使w等于0来构建由此灯光产生的阴影。此时阴影会被投射到墙上或地板上,和原始模型恰好相同。很明显真实世界中的灯光和阴影很傻瓜但远比此复杂,但是记住在我们虚拟的3D世界中我们只是复制真实的行为。使用更多的步骤我们就可以更真实地模拟阴影的行为,这对于专业3D软件实现的渲染来说会非常好,但是对于游戏来说并不是很好的解决方案,更真实的阴影需要CPU和GPU做大量的工作。对于游戏来说,使用莫比乌斯的方法来投射阴影很简单并且对于玩家来说也看起来很不错!

OK,这些都是关于投影的,现在我们来到OpenGL来看看我们该如何用代码实现所有的这些概念。矩阵和四元数将会是我们的盟友。

3D世界中的摄像机

我们要理解的第一件事就是3D世界中的变换是如何发生的。一旦我们定义了一个3D物体,它的结构会保持完整不变(结构指的是顶点、纹理坐标和法线)。在一帧与一帧之间变化的只是一些矩阵(通常只有一个矩阵)。那些矩阵会产生基于原始结构的临时变化。所以请记住,“原始的结构永远不会变”!

因此,当我们在屏幕上旋转一个物体时,深入地想,我们做的事情是创建了一个矩阵,矩阵中包含了发生旋转的信息。然后在我们的着色器中,当我们用矩阵乘以我们物体的顶点,在屏幕上物体就看起来像是在旋转。这对于任何3D元素都适用,如灯光和摄像机。但是摄像机对象还有一些特殊的行为,作用于它的所有矩阵都必须反转。下边的例子可以帮助理解这个问题:

Rotating the camera in CW around an object.

Rotating the object in CCW around its own Y axis.

注意上边的图中两种情况下设备屏幕上的结果图像是一样的。这种现象促使我们产生这样一个想法:摄像机的每个动作与物体空间内相比都是相反的。例如,如果摄像机向+Z移动,会产生与物体向-Z移动相同的效果。摄像机绕+Y方向转动会与物体绕它的局部-Y轴旋转产生同样效果。所以请记住,对摄像机的每个变换都会是相反的,我们很快会用到这一点。

关于摄像机的下一个概念是如何让局部空间和世界空间交互。在上边的例子中,如果我们想在局部空间中沿-Y旋转物体,会产生与以摄像机为枢沿+Y方向旋转摄像机并且在XZ平面内绕物体移动摄像机相同的结果。使用矩阵来处理这样的操作会为我们节省大量时间。从局部空间旋转变为全局空间旋转我们所需要做的全部就是改变相乘时矩阵的顺序(A x B = 局部, B x A = 全局,记住矩阵乘法是不满足交换律的)。因此我们必须用摄像机的矩阵乘以物体的矩阵,按照这种顺序。

OK,我知道谈论技术会让人迷惑,但是相信我,代码会比你想象中的要简单。我们再回顾一下这些概念然后开始进入代码。

  • 我们从不会改变物体的结构,改变的只是一些矩阵,这些矩阵会乘以原始物体的结构以得到我们想要的结果。
  • 对于摄像机,在我们构造矩阵之前,所有的变换都应当反转。
  • 摄像机将会是我们在3D世界中的眼睛,我们假设摄像机总是在局部空间,所以最终的矩阵将会是“摄像机矩阵x物体矩阵”的结果,严格按此顺序。

3D世界背后的代码

我将展示处理这些工作的所有公式,并解释它们的使用方法,但是我不会深入讲述这些公式背后的数学逻辑,也不会讲述这些共识是如何创建的,这不是我此处的目的。如果你很感兴趣想深入知道这些公式是如何创建的,我会向你推荐一本很棒的书,讲解了这些公式是怎么来的,还有一个很棒的数学网站:

我知道EuclideanSpace这个网站的布局不太好,看起来有些业余,但是相信我那里的所有公式都非常可靠,所有的公式。导航通过顶部菜单来实现,初看可能会有些疑惑,但是很有组织,很有数学的思维。

OK,我们开始矩阵。

矩阵

一些人会把矩阵当作里边有魔法的黑盒。好的,它的效果确实像是有魔法,但是却不是一个黑盒。它更像是一个组织良好的包裹,并且我们可以理解魔法使如何工作的,它的“手段”是什么,理解了它如何组织我们可以使用矩阵来做很多事。记住矩阵所做的一切也是欧几里得使用毕达哥拉斯方法和角度的概念所做的。笛卡尔只是把所有的知识放在一个叫做矩阵的包裹里。在3D世界里我们使用4x4矩阵(4行4列),这种矩阵也被称为方阵(square matrix)。最快最简单的方法在编程语言中表达矩阵是通过数组。更确切地说是一个有16个元素的线性一维数组。
使用线性一维数组我们可以用两种方法来表达一个矩阵,行优先(row-major)或列优先(column-major)。这只是一种惯例,因为实际上左乘(pre-multiply)一个行优先矩阵和右乘(post-multiply)一个列优先矩阵会得到相同的结果。然而,OpenGL选择了列优先的记法,我们也跟随这种方法。
下边是把数组下标使用列优先记法来组织:

  • 列优先记法
1
2
3
4
5
6
7
8
9
.
| 0 4 8 12 |
| |
| 1 5 9 13 |
| |
| 2 6 10 14 |
| |
| 3 7 11 15 |
.

现在我将分别展示5种矩阵:平移矩阵,缩放矩阵,旋转矩阵X,旋转矩阵Y和旋转矩阵Z。稍后我们会研究如何把它们合成一个单个的矩阵。

使用4x4矩阵完成的最简单的操作就是平移,即改变XYZ的位置。非常非常简单,你甚至不需要公式。你需要做的是这个:

  • 平移矩阵
1
2
3
4
5
6
7
8
9
.
| 1 0 0 X |
| |
| 0 1 0 Y |
| |
| 0 0 1 Z |
| |
| 0 0 0 1 |
.

第二简单的操作是缩放。正如你在3D专业软件中所看到的,你可以对于每个轴向单独地修改缩放。这一操作不需要公式。你要做的是这个:

  • 缩放矩阵
1
2
3
4
5
6
7
8
9
.
| SX 0 0 0 |
| |
| 0 SY 0 0 |
| |
| 0 0 SZ 0 |
| |
| 0 0 0 1 |
.

现在我们来点复杂的。是时候使用矩阵来实现绕着指定轴的旋转了。我们可以使用右手法则(Right Hand Rule)来思考3D世界的旋转。右手法则定义了所有三个轴的正方向,除此之外还有旋转的顺序。将你的拇指和一个轴的正方向对齐,收起其他的手指,那么你的手指指向的方向就是围绕该轴的旋转正方向:

Rotations using the Right Hand Rule.

我们可以只用一个角度值来创建一个围绕某轴的旋转矩阵。为实现这一目的,我们要用到正弦和余弦。如你所知,转角要用弧度值而不是角度值。使用Angle * PI / 180把角度值转为弧度值,使用Angle \ 180 / PI来把弧度值转为角度值。然而我的建议是,为提高性能“提前计算PI / 180180 / PI的值”。使用C宏,我喜欢像这样:

1
2
3
4
5
6
7
8
9
10
11
// Pre-calculated value of PI / 180.
#define kPI180 0.017453
// Pre-calculated value of 180 / PI.
#define k180PI 57.295780
// Converts degrees to radians.
#define degreesToRadians(x) (x * kPI180)
// Converts radians to degrees.
#define radiansToDegrees(x) (x * k180PI)

OK,有了我们的旋转弧度值,是时候使用下边的方程了:

  • Rotate X
1
2
3
4
5
6
7
8
9
.
| 1 0 0 0 |
| |
| 0 cos(θ) sin(θ) 0 |
| |
| 0 -sin(θ) cos(θ) 0 |
| |
| 0 0 0 1 |
.
  • Rotate Y
1
2
3
4
5
6
7
8
9
.
| cos(θ) 0 -sin(θ) 0 |
| |
| 0 1 0 0 |
| |
| sin(θ) 0 cos(θ) 0 |
| |
| 0 0 0 1 |
.
  • Rotate Z
1
2
3
4
5
6
7
8
9
.
| cos(θ) -sin(θ) 0 0 |
| |
| sin(θ) cos(θ) 0 0 |
| |
| 0 0 1 0 |
| |
| 0 0 0 1 |
.

或许你在其它地方见过有同样的公式但是元素的负号不同,但是请记住,通常它们是使用传统方式来教学,即使用行优先记法,因此记住我们这里用的是列优先记法,列优先记法完美适用于OpenGL的处理过程。

现在该把那些矩阵合到一起了。与字面数字一样,我们需要把它们相乘得到最终结果。但是矩阵的乘法会有些特殊的表现。你很可能还能从高中或大学的知识中记起一些。

  • 矩阵乘法不满足交换律。A x B 和 B x A是不同的。
  • 计算乘法A x B即是计算A的每行和B的每列的值相乘的结果。
  • 为计算乘法A x B,A矩阵必须有和B矩阵行数相等的列数。否则不能进行乘法计算。

然而3D世界中我们总是使用方阵,4x4或者有时候使用3x3,因此我们只能用4x4矩阵与4x4矩阵相乘。现在我们进入代码,使用16元素的数组来计算上边的方程:

  • 数组实现的矩阵公式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
typedef float mat4[16];
void matrixIdentity(mat4 m)
{
m[0] = m[5] = m[10] = m[15] = 1.0;
m[1] = m[2] = m[3] = m[4] = 0.0;
m[6] = m[7] = m[8] = m[9] = 0.0;
m[11] = m[12] = m[13] = m[14] = 0.0;
}
void matrixTranslate(float x, float y, float z, mat4 matrix)
{
matrixIdentity(matrix);
// Translate slots.
matrix[12] = x;
matrix[13] = y;
matrix[14] = z;
}
void matrixScale(float sx, float sy, float sz, mat4 matrix)
{
matrixIdentity(matrix);
// Scale slots.
matrix[0] = sx;
matrix[5] = sy;
matrix[10] = sz;
}
void matrixRotateX(float degrees, mat4 matrix)
{
float radians = degreesToRadians(degrees);
matrixIdentity(matrix);
// Rotate X formula.
matrix[5] = cosf(radians);
matrix[6] = -sinf(radians);
matrix[9] = -matrix[6];
matrix[10] = matrix[5];
}
void matrixRotateY(float degrees, mat4 matrix)
{
float radians = degreesToRadians(degrees);
matrixIdentity(matrix);
// Rotate Y formula.
matrix[0] = cosf(radians);
matrix[2] = sinf(radians);
matrix[8] = -matrix[2];
matrix[10] = matrix[0];
}
void matrixRotateZ(float degrees, mat4 matrix)
{
float radians = degreesToRadians(degrees);
matrixIdentity(matrix);
// Rotate Z formula.
matrix[0] = cosf(radians);
matrix[1] = sinf(radians);
matrix[4] = -matrix[1];
matrix[5] = matrix[0];
}

下边是两个代表4x4矩阵的16元素数组相乘的代码。

  • 矩阵乘法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void matrixMultiply(mat4 m1, mat4 m2, mat4 result)
{
// Fisrt Column
result[0] = m1[0]*m2[0] + m1[4]*m2[1] + m1[8]*m2[2] + m1[12]*m2[3];
result[1] = m1[1]*m2[0] + m1[5]*m2[1] + m1[9]*m2[2] + m1[13]*m2[3];
result[2] = m1[2]*m2[0] + m1[6]*m2[1] + m1[10]*m2[2] + m1[14]*m2[3];
result[3] = m1[3]*m2[0] + m1[7]*m2[1] + m1[11]*m2[2] + m1[15]*m2[3];
// Second Column
result[4] = m1[0]*m2[4] + m1[4]*m2[5] + m1[8]*m2[6] + m1[12]*m2[7];
result[5] = m1[1]*m2[4] + m1[5]*m2[5] + m1[9]*m2[6] + m1[13]*m2[7];
result[6] = m1[2]*m2[4] + m1[6]*m2[5] + m1[10]*m2[6] + m1[14]*m2[7];
result[7] = m1[3]*m2[4] + m1[7]*m2[5] + m1[11]*m2[6] + m1[15]*m2[7];
// Third Column
result[8] = m1[0]*m2[8] + m1[4]*m2[9] + m1[8]*m2[10] + m1[12]*m2[11];
result[9] = m1[1]*m2[8] + m1[5]*m2[9] + m1[9]*m2[10] + m1[13]*m2[11];
result[10] = m1[2]*m2[8] + m1[6]*m2[9] + m1[10]*m2[10] + m1[14]*m2[11];
result[11] = m1[3]*m2[8] + m1[7]*m2[9] + m1[11]*m2[10] + m1[15]*m2[11];
// Fourth Column
result[12] = m1[0]*m2[12] + m1[4]*m2[13] + m1[8]*m2[14] + m1[12]*m2[15];
result[13] = m1[1]*m2[12] + m1[5]*m2[13] + m1[9]*m2[14] + m1[13]*m2[15];
result[14] = m1[2]*m2[12] + m1[6]*m2[13] + m1[10]*m2[14] + m1[14]*m2[15];
result[15] = m1[3]*m2[12] + m1[7]*m2[13] + m1[11]*m2[14] + m1[15]*m2[15];
}

如你所知,标准C不能通过函数来返回数组,因此我们必须传入一个指向结果数组的指针。如果你使用的语言支持返回一个数组,如JavaScript或ActionScript,你可以选择返回数组字面量而不用操作指针。

那么有一点非常重要:“你不能直接组合矩阵,例如对一个矩阵作用旋转矩阵X再作用另外一个旋转矩阵Z。你必须将它们分别创建并将它们两个两个相乘,直到得到最终的结果!”

举个例子,为了平移、旋转和缩放一个对象,你必须分别创建每个矩阵,并进行乘法计算((Scale \* Rotation) * Translation)来得到最终的变换矩阵。

现在我们来谈谈矩阵的一些提示和技巧。

深入矩阵

是时候打开矩阵的“黑盒”来理解它内部到底发生什么了。之前我使用矩阵但并不知道发生了什么,什么是左乘或右乘一个矩阵,如果所有的矩阵都是列优先矩阵,为什么要对矩阵转置,为什么要使用逆矩阵,然而在我看了一些MIT的矩阵课程之后,我必须说一切都变了。我希望和你们分享这些知识:

  1. 左乘或右乘矩阵的意义是什么?这代表着事情发生的顺序。在乘法计算中的第二个矩阵将会首先产生作用!如果我们有乘法A x B这表示B会首先生效其次是A。因此如果计算旋转矩阵x平移矩阵,表示这个物体先平移后旋转。对缩放矩阵也是这样。
  2. The order in Matrices Multiplication indicates what happens first.
  3. 使用上述逻辑,我们可以理解为什么局部旋转(local rotations)和全局旋转(global rotations)的差别就在于将旋转矩阵左乘还是右乘另一个矩阵。如果你总是右乘一个新的旋转矩阵,这表示该物体将先进行这个新的旋转再旋转旧的角度,这就是一个局部旋转。如果你总是左乘左乘一个新的旋转矩阵,这表示该物体先旋转旧的角度再旋转新的,这是全局旋转。
  4. 任意3D物体都拥有三个局部矢量:右矢量、上矢量和前矢量。这些矢量对于对其作用的欧几里得变换(缩放、旋转和平移)非常重要。特别是做局部变换时。好消息是:是否还记得旋转公式?旋转公示所做的就是将旋转角度转化为这些矢量并将其放入矩阵。所以你可以直接从一个旋转矩阵中提取出这些矢量,更棒的是矩阵中的这些矢量己经是正交化的了。
  5. The local vectors in a matrix with column-major notation.
  6. 另一件很酷的事是关于正交矩阵(orthogonal matrices)的,注意不要与标准正交(orthonormal)搞混,后者指的是两个正交矢量(orthogonal vectors)。理论上讲,正交矩阵中的行矢量和列矢量都是单位正交矢量,简单而言,我们可以认为正交矢量是不带任何缩放的旋转矩阵!我再重复一下,这一点非常重要,正交矩阵是旋转矩阵,纯粹的旋转不带任何的缩放。通过旋转矩阵我们得到的是单位矩阵并且它们总是正交的!单位矢量和正交矢量到底是什么?很简单,单位矢量是指长度/大小为1的矢量,所以称之为“单位”矢量。正交矢量是指两个或两个以上的矢量,两两夹角为90度。再看上边的图片,注意右矢量(Right),上矢量(Up)和前矢量(Look)在3D世界中总是正交的。
  7. 依然是正交矩阵,正交矩阵的逆矩阵等于它的转置矩阵。喔!棒极了!因为计算逆矩阵我们需要至少100次乘法计算以及至少40次的加法计算,但是计算转置我们不需要任何的计算,只需要改变一些值的顺序。这对于我们的性能来说是一个极大的提升。但是为什么我们需要矩阵的逆矩阵?在着色器中计算光照!记住实际上的物体并没有改变,我们仅仅是通过将它们与矩阵相乘来改变对它们的顶点的计算。因此为了计算在全局坐标系下的光照,我们需要对旋转矩阵求逆。当然,很明显我们还需要在其它的很多地方用到逆矩阵,比如摄像机,因此可以用转置来代替逆矩阵真的很棒!不要怀疑,从技术角度来看,逆矩阵表示的就是与原始矩阵相乘(左乘或右乘皆可)可以得到单位矩阵的矩阵。简言之,逆矩阵将原始矩阵中各变换都逆转过来。

你可以从一个矩阵中提取出旋转、缩放和平移的值。但不幸的是不存在从矩阵中提取出负的缩放的精确方法。你可以从《3D Game Programming》或者EuclideanSpace的网站上找到用来提取这些值的公式。这里我不会讨论这些公式因为我有一些更好的建议:“不要从矩阵中提取值,更好的做法是存储对用户友好的值。如存储全局旋转值(XYZ),局部缩放值(XYZ)以及全局位置值(XYZ)。”

现在该认识一下汉密尔顿的贡献,即四元数了!

四元数

对我来说,四元数是最3D计算中最伟大的发明了。如果矩阵对一些人来说非常严密且是“魔法”,那么对这些人来说四元数就是“奇迹”。四元数简单得难以置信。简言之就是:“取一个矢量作为方向,并且将它围绕自己的轴旋转”。

如果你简单研究一下,你就会发现很多关于它的讨论。四元数是很有争议的!有人喜欢它,也有人讨厌它。有人说它仅仅是流行而已,也有人说它棒极了。为什么会有这些关于四元数的评价?好,这是因为使用旋转矩阵时我们发现了围绕任意轴旋转的公式,可以直接避免全局死锁(Gimbal Lock),或者换句话说,这个公式可以和四元数产生相同的效果(实际上确实是很相似)。这里我不会展示这一公式,因为我觉得这不是一个好的方案。

关于四元数和围绕任意轴的旋转矩阵之间的战争你会看到人们讨论说这个会花费27次乘法,外加一些求和、正弦、余弦矢量长度计算而四元数只需要21或24次乘法计算,等等一些其它的令人反感的讨论!无论如何,在实际的硬件环境下,你可以在你的应用程序中减少1千万次乘法计算而得到的是0.04秒(在iPhone4上1千万次乘法计算是0.12秒)!这并不算显著。为提高应用程序的性能,有一些远比乘法计算更重要的东西。实际上二者之间的这一数字每帧是小于1000的。

那么对于四元数和旋转矩阵最关键的点是什么呢?我对四元数的喜爱源自它的简单!非常有组织,非常清晰,在连续的旋转变换时会难以置信的精确。我将会演示如何使用四元数,而你们可以自己判断。

我们从简单的概念开始。四元数正如它的名字要表达的,是一个4阶的矢量(x,y,z,w)。我们常使用四元数的符号w,x,y,z并将w放在第一个,这仅仅是惯例。这并不重要,因为所有的四元数操作总是会使用字母x,y,zw。有一个需要注意,不要混淆了四元数的w和齐次坐标的w,这是两个完全不同的东西。

四元数是4维矢量,因此很多矢量操作对它都适用。但是仅仅有几个公式比较重要:乘法计算、归一化(identity)和取反(inverse)。在这三个公式之前,我向你介绍从四元数中提取出一个矩阵的公式。这是最重要的:

  • 四元数到矩阵
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
.
// This is the arithmetical formula optimized to work with unit quaternions.
// |1-2y²-2z² 2xy-2zw 2xz+2yw 0|
// | 2xy+2zw 1-2x²-2z² 2yz-2xw 0|
// | 2xz-2yw 2yz+2xw 1-2x²-2y² 0|
// | 0 0 0 1|
// And this is the code.
// First Column
matrix[0] = 1 - 2 * (q.y * q.y + q.z * q.z);
matrix[1] = 2 * (q.x * q.y + q.z * q.w);
matrix[2] = 2 * (q.x * q.z - q.y * q.w);
matrix[3] = 0;
// Second Column
matrix[4] = 2 * (q.x * q.y - q.z * q.w);
matrix[5] = 1 - 2 * (q.x * q.x + q.z * q.z);
matrix[6] = 2 * (q.z * q.y + q.x * q.w);
matrix[7] = 0;
// Third Column
matrix[8] = 2 * (q.x * q.z + q.y * q.w);
matrix[9] = 2 * (q.y * q.z - q.x * q.w);
matrix[10] = 1 - 2 * (q.x * q.x + q.y * q.y);
matrix[11] = 0;
// Fourth Column
matrix[12] = 0;
matrix[13] = 0;
matrix[14] = 0;
matrix[15] = 1;
.

就像矩阵公式一样,这一转换也会产生一个由单位矢量组成的正交矩阵。有些地方你会看到算法公式使用w²+x²-y²-z²而不是1-2y²-2z²,不必惊慌,这是因为汉密尔顿最初的四元数更复杂一些。它们有一个虚部(ijk)并且不仅仅是单位四元数。但是在3D世界中我们总是使用单位矢量,所以我们可以抛弃四元数的虚部并假设它们总是单位四元数。正是由于这一优化,我们可以使用公式1-2y²-2z²

现在我们来谈谈其它公式。首先是乘法公式:

  • 四元数乘法计算
1
2
3
4
5
6
7
.
// Assume that this multiplies q1 x q2, in this order, resulting in "newQ".
newQ.w = q1.w * q2.w - q1.x * q2.x - q1.y * q2.y - q1.z * q2.z;
newQ.x = q1.w * q2.x + q1.x * q2.w + q1.y * q2.z - q1.z * q2.y;
newQ.y = q1.w * q2.y - q1.x * q2.z + q1.y * q2.w + q1.z * q2.x;
newQ.z = q1.w * q2.z + q1.x * q2.y - q1.y * q2.x + q1.z * q2.w;
.

四元数乘法公式与旋转矩阵的相乘有相同的效果,举例说明。与矩阵相乘相同,四元数的乘法不满足交换律。因此q1 x q2不等于q2 x q1。这里我不会展示具体的算法公式,这是因为两个4维矢量的乘法(original multiplication)远比3维矢量的叉乘要复杂,并且它需要一次令人迷惑的矩阵乘法计算(实际上算数计算的结果就是上边的代码)。我们来关注重点。如果你有兴趣想了解4维矢量乘法计算更多内容可以尝试这个:http://www.mathpages.com/home/kmath069.htm

归一化。归一化四元数会产生归一化的矩阵,以下是公式:

  • 四元数归一化
1
2
3
4
5
6
.
q.x = 0;
q.y = 0;
q.z = 0;
q.w = 1;
.

好的,现在是四元数取反的公式,也就是“共轭四元数”:

  • 四元数取反
1
2
3
4
5
6
7
.
q.x *= -1;
q.y *= -1;
q.z *= -1;
// At this point is a good idea normalize the quaternion again.
.

我喜欢这个取反公式。因为它很简单!它太简单了!并且这三行代码和取矩阵的逆有着相同的效果!是的兄弟,如果你使用四元数来旋转,那么你将不必求矩阵的逆,那样需要100多次乘法和求和计算,你只需以上三行代码。正如我所说,并不是因为处理过程的简化,而是简单!四元数真是太简单了!

到现在一切都OK吗?如果有疑问记得提问。现在我们来看这两个被称为“四元数之战”的原因的公式。我们在其中加入一些旋转角度。为达到这一目的,我们有两种方法:第一种方法使用四元数的概念,提供一个表示旋转方向的矢量和围绕此方向旋转的角度,第二种方法使用欧拉角(X,Y和Z)直接提供三个角度值。后者需要更多的乘法计算,但是却对用户更友好,因为它就像是给旋转矩阵设置角度。

首先来通过一个方向和一个角度来设置四元数:

  • 四元数的转轴
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.
// The new quaternion variable.
vec4 q;
// Converts the angle in degrees to radians.
float radians = degreesToRadians(degrees);
// Finds the Sin and Cosin for the half angle.
float sin = sinf(radians * 0.5);
float cos = cosf(radians * 0.5);
// Formula to construct a new Quaternion based on direction and angle.
q.w = cos;
q.x = vec.x * sin;
q.y = vec.y * sin;
q.z = vec.z * sin;
.

为了实现连续的旋转你可以构建多个四元数。与矩阵方法相似,通过改变乘法计算的顺序(q1 x q2 或 q2 x q1)来产生局部或全局旋转。记住,与矩阵相似,当你使用乘法计算q1 x q2时这表示:“先旋转q2再q1”。

下边是将欧拉角转为四元数的公式:

  • 欧拉角到四元数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.
// The new quaternion variable.
vec4 q;
// Converts all degrees angles to radians.
float radiansY = degreesToRadians(degreesY);
float radiansZ = degreesToRadians(degreesZ);
float radiansX = degreesToRadians(degreesX);
// Finds the Sin and Cosin for each half angles.
float sY = sinf(radiansY * 0.5);
float cY = cosf(radiansY * 0.5);
float sZ = sinf(radiansZ * 0.5);
float cZ = cosf(radiansZ * 0.5);
float sX = sinf(radiansX * 0.5);
float cX = cosf(radiansX * 0.5);
// Formula to construct a new Quaternion based on Euler Angles.
q.w = cY * cZ * cX - sY * sZ * sX;
q.x = sY * sZ * cX + cY * cZ * sX;
q.y = sY * cZ * cX + cY * sZ * sX;
q.z = cY * sZ * cX - sY * cZ * sX;
.

如你所见,我组织代码使角度的顺序为Y,Z最后X。为什么这样?因为这是将要由四元数产生的旋转的顺序。使用此公式时我们可以调整顺序吗?不,不能。这一公式是为了产生(Y,Z,X)这样的旋转。顺便提一下这是我们所说的“欧拉旋转顺序(Euler Rotation Order)”。如果你想了解更多关于旋转顺序或者说它表示什么,请看这个很棒的视频http://www.youtube.com/watch?v=zc8b2Jo7mno

这就是四元数的基础。很明显我们有从四元数中取回参数的公式,取出欧拉角,取出矢量方向等。对你来说这些都很不错因为你可以查看四元数内部发生了什么。对此我的建议和对于矩阵的一样:“总是存储用户友好的变量来控制你的旋转”。

现在我们回到矩阵,来最终了解如何创建相机镜头。

3D摄像机背后的代码

终于!我们已经准备好要理解如何创建相机镜头了。现在很容易就明确我们需要做什么。我们需要创建一个矩阵,这个矩阵可以通过顶点的深度来调整顶点的位置。使用我们一开始(景深、近平面、远平面、视角等)看到的一些概念,我们可以计算出一个矩阵来完成优雅且平滑的变化以模拟真实的镜头,这个镜头已经是对人眼的一种模拟了。

如早先所述,我们可以创建两种投影:透视和正交。我不会在这里深入讲解每种投影的数学公式,如果你对投影矩阵背后的概念感兴趣可以在这里找到一个很好的讲解http://www.songho.ca/opengl/gl_projectionmatrix.html。好的,我们来看代码。首先是最基础的一种,正交投影:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
.
// These paramaters are lens properties.
// The "near" and "far" create the Depth of Field.
// The "left", "right", "bottom" and "top" represent the rectangle formed
// by the near area, this rectangle will also be the size of the visible area.
float near = 0.001, far = 100.0;
float left = 0.0, right = 320.0, bottom = 480.0, top = 0.0;
// First Column
matrix[0] = 2.0 / (right - left);
matrix[1] = 0.0;
matrix[2] = 0.0;
matrix[3] = 0.0;
// Second Column
matrix[4] = 0.0;
matrix[5] = 2.0 / (top - bottom);
matrix[6] = 0.0;
matrix[7] = 0.0;
// Third Column
matrix[8] = 0.0;
matrix[9] = 0.0;
matrix[10] = -2.0 / (far - near);
matrix[11] = 0.0;
// Fourth Column
matrix[12] = -(right + left) / (right - left);
matrix[13] = -(top + bottom) / (top - bottom);
matrix[14] = -(far + near) / (far - near);
matrix[15] = 1;
.

如你所见,正交矩阵没有“视角”,因为它不需要。你应该还记得正交投影中一切看起来都相等的,单元总是正方形的,换句话说,正交投影是一个线性投影。

上边的代码展示的就是之前我们想象的。投影矩阵会轻微影响旋转(XYZ),直接影响缩放(主对角线),而对顶点位置影响更深刻。

现在来看透视投影,一个有点复杂的情况。

  • 透视投影
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
.
// These paramaters are about lens properties.
// The "near" and "far" create the Depth of Field.
// The "angleOfView", as the name suggests, is the angle of view.
// The "aspectRatio" is the cool thing about this matrix. OpenGL doesn't
// has any information about the screen you are rendering for. So the
// results could seem stretched. But this variable puts the thing into the
// right path. The aspect ratio is your device screen (or desired area) width divided
// by its height. This will give you a number < 1.0 the the area has more vertical
// space and a number > 1.0 is the area has more horizontal space.
// Aspect Ratio of 1.0 represents a square area.
float near = 0.001, far = 100.0;
float angleOfView = 45.0;
float aspectRatio = 0.75;
// Some calculus before the formula.
float size = near * tanf(degreesToRadians(angleOfView) / 2.0);
float left = -size, right = size, bottom = -size / aspectRatio, top = size / aspectRatio;
// First Column
matrix[0] = 2 * near / (right - left);
matrix[1] = 0.0;
matrix[2] = 0.0;
matrix[3] = 0.0;
// Second Column
matrix[4] = 0.0;
matrix[5] = 2 * near / (top - bottom);
matrix[6] = 0.0;
matrix[7] = 0.0;
// Third Column
matrix[8] = (right + left) / (right - left);
matrix[9] = (top + bottom) / (top - bottom);
matrix[10] = -(far + near) / (far - near);
matrix[11] = -1;
// Fourth Column
matrix[12] = 0.0;
matrix[13] = 0.0;
matrix[14] = -(2 * far * near) / (far - near);
matrix[15] = 0.0;
.

理解:哇喔,公式只是轻微的改变,但是现在不再影响XY位置,只改变Z位置(深度)。它继续影响旋转XYZ,但对第三列有极大的干扰,那是什么呢?那正是对透视改变和调整宽高比的因子的计算。注意第三列最后一个元素是负的。这会在生成最终矩阵(相乘)时反转宽高比。

现在谈谈最终矩阵。这是很重要的一步。不像其它的矩阵乘法,这一次你不能改变顺序,否则你无法得到期望的结果。这是你需要做的:

取摄像机的View矩阵(一个包含有摄像机的旋转和平移的反转矩阵),将其右乘投影矩阵Projection:

1
PROJECTION MATRIX x VIEW MATRIX

请记住,这么做产生的效果会像是“先作用VIEW MATRIX再作用PROJECTION MATRIX”。

现在你有了我们所谓的VIEW*PROJECTION矩阵。使用这个新矩阵要把MODEL矩阵(MODEL矩阵包含了物体的所有旋转、缩放和平移)右乘VIEW*PROJECTION矩阵:VIEW PROJECTION x MODEL。再强调一遍,这表示“先作用MODEL矩阵然后作用VIEWPROJECTION矩阵。”最终你得到了所谓的`MODELVIEW*PROJECTION`矩阵!

祝贺你!

好的,我知道你现在在想什么…“卧槽!这么多就只是产生了一个简单的矩阵!”是的,我也这么想。对此有没有更简单更快速的方法?

好,我觉得这个问题的答案就是这篇文章的结论。来吧!

结论

自此以后,所有的东西都会比这复杂。矩阵和四元数仅仅是构建3D引擎或者个人框架之旅的第一步。所以如果你还没有决定的话,或许现在是时候做一个了。我觉得你会有两个选择:

  1. 你可以自己创建一个框架/引擎。
  2. 你可以拿一个现有的框架/引擎并学习如何使用。

正如所有的“选择”一样,两个都有好处和坏处。你应该考虑并决定哪个更有助于你的目标。我想到了第三个选择,在你的项目中创建一些小的模板并使用,这不是一个好主意,所以我个人放弃了这个选择的选项。3D世界中有太多东西要处理,以至于很可能在试图把所有的东西匹配到我们一个或几个模板时会变得疯狂或非常失败。所以我的最后一个建议是“做一个选择”。

无论如何,我的下一个教程会更进阶一些。现在我们来一起回顾本教程:

  • 摄像机有凸透镜或凹透镜。摄像机还有一些属性如景深、视角、近平面和远平面。
  • 在3D世界我们可以使用透视这种真正的投影,也可以使用不真实的投影正交投影。
  • 摄像机的运作与普通3D物体相反。
  • 我们从来不会改变3D物体的结构,我们只使用临时变化的结果。
  • 这些变化可以通过矩阵实现。我们有旋转、缩放和平移一个矩阵的公式。我们还可以用四元数来处理旋转。
  • 我们还用矩阵来创建透视投影或正交投影摄像机的镜头。

就这些了。
感谢阅读,下一篇教程见!

REFERENCE

http://blog.db-in.com/cameras-on-opengl-es-2-x/