入门-07.变换
变换(Transformations)
我们可以尝试着在每一帧改变物体的顶点并且重设缓冲区从而使他们移动,但这太繁琐了,而且会消耗很多的处理时间。然而,我们现在有一个更好的解决方案,使用(多个)矩阵(Matrix)对象可以更好的变换(Transform)一个物体。
向量(Vector)
向量可以在任意维度(Dimension)上,但是我们通常只使用2至4维。如果一个向量有2个维度,它表示一个平面的方向(想象一下2D的图像),当它有3个维度的时候它可以表达一个3D世界的方向。
由于向量表示的是方向,起始于何处并不会改变它的值。下图我们可以看到向量v和w是相等的,尽管他们的起始点不同:
我们通常设定这个方向的原点为(0,0,0),然后指向对应坐标的点,使其变为位置向量(Position Vector)来表示(你也可以把起点设置为其他的点,然后说:这个向量从这个点起始指向另一个点)。位置向量(3, 5)的在图像中起点是(0, 0),指向(3, 5)。我们可以使用向量在2D或3D空间中表示方向与位置.
向量与标量运算(Scalar Vector Operations)
标量(Scalar)只是一个数字(或者说是仅有一个分量的矢量)。当把一个向量加/减/乘/除一个标量,我们可以简单的把向量的每个分量分别进行该运算。
对于加法来说会像这样:
向量取反(Vector Negation)
对一个向量取反会将其方向逆转。我们在一个向量的每个分量前加负号就可以实现取反了(或者说用-1数乘该向量):
Paste_Image.png向量加减
向量的加法可以被定义为是分量的(Component-wise)相加,即将一个向量中的每一个分量加上另一个向量的对应分量:
Paste_Image.png就像普通数字的加减一样,向量的减法等于加上第二个向量的相反数。
长度(Length)
我们使用勾股定理(Pythagoras Theorem)来获取向量的长度/大小。
有一个特殊类型向量叫做单位向量(Unit Vector)。
我们可以用任意向量的每个分量除以向量的长度得到它的单位向量:
Paste_Image.png我们把这种方法叫做一个向量的标准化(Normalizing)。
向量相乘(Vector-vector Multiplication)
当需要乘法时我们可以从中选择:一个是点乘(Dot Product),另一个是叉乘(Cross Product)。
- 点乘(Dot Product)
点乘在计算光照的时候会很有用。
- 叉乘(Cross Product)
叉乘只在3D空间有定义,它需要两个不平行向量作为输入,生成正交于两个输入向量的第三个向量。如果输入的两个向量也是正交的,那么叉乘的结果将会返回3个互相正交的向量。
Paste_Image.png下面你会看到两个正交向量A和B叉乘结果:
Paste_Image.png注:用考研时老汤(汤家凤)教的那种方法可以方便的写出这个公式。
矩阵(Matrix)
下面是一个2×3矩阵的例子:
Paste_Image.png矩阵可以通过(i, j)进行索引,i是行,j是列,这就是上面的矩阵叫做2×3矩阵的原因(3列2行,也叫做矩阵的维度(Dimension))。这与你在索引2D图像时的(x, y)相反,获取4的索引是(2, 1)(第二行,第一列)(译注:如果是图像索引应该是(1, 2),先算列,再算行)。
矩阵的加减
矩阵与标量的加减如下所示:
Paste_Image.png矩阵与矩阵之间的加减就是两个矩阵对应元素的加减运算
Paste_Image.png矩阵的数乘(Matrix-scalar Products)
和矩阵与标量的加减一样,矩阵与标量之间的乘法也是矩阵的每一个元素分别乘以该标量。
Paste_Image.png矩阵相乘(Matrix-matrix Multiplication)
矩阵乘法基本上意味着遵照规定好的法则进行相乘。当然,相乘还有一些限制:
- 只有当左侧矩阵的列数与右侧矩阵的行数相等,两个矩阵才能相乘。
- 矩阵相乘不遵守交换律(Commutative),。
矩阵与向量相乘
为什么我们关心矩阵是否能够乘以一个向量?有很多有意思的2D/3D变换本质上都是矩阵,而矩阵与我们的向量相乘会变换我们的向量。
单位矩阵(Identity Matrix)
单位矩阵是一个除了对角线以外都是0的N × N矩阵。
Paste_Image.png缩放(Scaling)
当我们对一个向量进行缩放的时候就是对向量的长度进行缩放,而它的方向保持不变。如果我们进行2或3维操作,那么我们可以分别定义一个有2或3个缩放变量的向量,每个变量缩放一个轴(x、y或z)。
我们可以尝试去缩放向量
v=(3,2)。我们可以把向量沿着x轴缩放0.5,使它的宽度缩小为原来的二分之一;我们可以沿着y轴把向量的高度缩放为原来的两倍。我们看看把向量缩放(0.5, 2)所获得的s是什么样的:
记住,OpenGL通常是在3D空间操作的,对于2D的情况我们可以把z轴缩放1这样z轴的值就不变了。我们刚刚的缩放操作是不均匀(Non-uniform)缩放,因为每个轴的缩放因子(Scaling Factor)都不一样。如果每个轴的缩放都一样那么就叫均匀缩放(Uniform Scale)。
我们下面设置一个变换矩阵来为我们提供缩放功能。如果我们把缩放变量表示为(S1,S2,S3)我们可以为任意向量(x, y, z)定义一个缩放矩阵:
Paste_Image.png注意,第四个缩放的向量仍然是1,因为不会缩放3D空间中的w分量。w分量另有其他用途,在后面我们会看到。
平移(Translation)
平移(Translation)是在原来向量的基础上加上另一个的向量从而获得一个在不同位置的新向量的过程,这样就基于平移向量移动(Move)了向量。
和缩放矩阵一样,在4×4矩阵上有几个特别的位置用来执行特定的操作,对于平移来说它们是第四列最上面的3个值。如果我们把缩放向量表示为(Tx, Ty, Tz)我们就能把平移矩阵定义为:
Paste_Image.png这样是能工作的,因为所有的平移值都要乘以向量的w列,所以平移值会加到向量的原始坐标上(想想矩阵乘法法则)。而如果你用3x3矩阵我们的平移值就没地方放也没地方乘了,所以是不行的。
齐次坐标(Homogeneous coordinates)
向量的w分量也叫齐次坐标。想要从齐次坐标得到3D坐标,我们可以把x、y和z坐标除以w坐标。我们通常不会注意这个问题,因为w分量通常是1.0。使用齐次坐标有几点好处:它允许我们在3D向量上进行平移(如果没有w分量我们是不能平移向量的),下一章我们会用w值创建3D图像。
如果一个向量的齐次坐标是0,这个坐标就是方向向量(Direction Vector),因为w坐标是0,这个向量就不能平移(译注:这也就是我们说的不能平移一个方向)。
有了平移矩阵我们就可以在3个方向(x、y、z)上移动物体,它是我们的变换工具箱中非常有用的一个变换矩阵。
旋转(Rotation)
如果你想知道旋转矩阵是如何构造出来的,我推荐你去看可汗学院线性代数视频。
首先我们来定义一个向量的旋转到底是什么。2D或3D空间中点的旋转用角(Angle)来表示。角可以是角度制或弧度制的,周角是360度或2 PI弧度。我个人更喜欢用角度,因为它们看起来更直观。
大多数旋转函数需要用弧度制的角,但是角度制的角也可以很容易地转化为弧度制:
- 弧度转角度:角度 = 弧度 * (180.0f / PI)
- 角度转弧度:弧度 = 角度 * (PI / 180.0f)
PI约等于3.14159265359。
转半圈会向右旋转360/2 = 180度,向右旋转1/5圈表示向右旋转360/5 = 72度。这表明2D空间的向量v是k由向右旋转72度得到的:
Paste_Image.png在3D空间中旋转需要一个角和一个旋转轴(Rotation Axis)。物体会沿着给定的旋转轴旋转特定角度。
使用三角学就能把一个向量变换为一个经过旋转特定角度的新向量。这通常是使用一系列正弦和余弦各种巧妙的组合得到的(一般简称sin和cos)。当然,讨论如何生成变换矩阵超出了这个教程的范围。
旋转矩阵在3D空间中每个单位轴都有不同定义,这个角度表示为theta:
沿x轴旋转:
Paste_Image.png沿y轴旋转:
Paste_Image.png沿z轴旋转:
Paste_Image.png利用旋转矩阵我们可以把我们的位置向量(Position Vectors)沿一个或多个轴进行旋转。也可以把多个矩阵结合起来,比如先沿着X轴旋转再沿着Y轴旋转。
但是这会很快导致一个问题——万向节死锁(Gimbal Lock,可以看看这个视频(优酷)来了解)。
我们不会讨论它的细节,但是一个更好的解决方案是沿着任意轴比如(0.662, 0.2, 0.7222)(注意,这是个单位向量)旋转,而不是使用一系列旋转矩阵的组合。这样一个(超级麻烦)的矩阵是存在的,下面(Rx,Ry,Rz)代表任意旋转轴:
在数学上讨论如何生成这样的矩阵仍然超出了本节内容。但是记住,即使这样一个矩阵也不能完全解决万向节死锁问题(尽管会极大地避免)。避免万向节死锁的真正解决方案是使用四元数(Quaternion),它不仅安全,而且计算更加友好。有关四元数会在后面的教程中讨论。
矩阵的组合
我们有一个顶点(x, y, z),我们希望将其缩放2倍,然后用位移(1, 2, 3)来平移它。我们需要一个平移和缩放矩阵来完成这些变换。结果的变换矩阵看起来像这样:
Paste_Image.png- 注意,当矩阵相乘时我们先写平移再写缩放变换的。矩阵乘法是不可交换的,这意味着它们的顺序很重要。当矩阵相乘时,在最右边的矩阵是第一个乘以向量的,所以你应该从右向左读这个乘法。
- 我们建议您在组合矩阵时,先进行缩放操作,然后是旋转,最后才是平移,否则它们会(消极地)互相影响。比如,如果你先平移然后缩放,平移的向量也会同样被缩放(译注:比如向某方向移动2米,2米也许会被缩放成1米)!
将我们的矢量左乘最终的变换矩阵会得到以下结果:
Paste_Image.png不错!向量先缩放2倍,然后平移了(1, 2, 3)个单位。
实践
OpenGL没有任何自带的矩阵和向量形式,所以我们必须自己定义数学类和方法。在这个教程中我们更愿意抽象所有的数学细节,使用已经做好了的数学库。幸运的是有个使用简单的专门为OpenGL量身定做的数学库,那就是GLM。
GLM
GLM是OpenGL Mathematics的缩写,它是一个只有头文件的库,也就是说我们只需包含合适的头文件就行了;不用链接和编译。GLM可以从他们的网站上下载。(或者从项目托管网站,sourceforge下载)把头文件的根目录复制到你的includes文件夹,然后你就可以使用这个库了。
我们需要的GLM的大多数功能都可以从下面这3个头文件中找到:
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
我们来看看是否可以利用我们刚学的变换知识把一个向量(1, 0, 0)平移(1, 1, 0)个单位(注意,我们把它定义为一个glm::vec4类型的值,其中齐次坐标我们设定为1.0):
glm::vec4 vec(1.0f, 0.0f, 0.0f, 1.0f);
glm::mat4 trans;
trans = glm::translate(trans, glm::vec3(1.0f, 1.0f, 0.0f));
vec = trans * vec;
std::cout << vec.x << vec.y << vec.z << std::endl;
我们先用GLM内建的向量类定义一个叫做vec的向量。接下来我们定义一个mat4类型的trans,默认是4×4单位矩阵。接下来我们创建一个变换矩阵,我们是把单位矩阵和一个平移向量传递给glm::translate函数来完成这个工作的(然后用给定的矩阵乘以平移矩阵就能获得最后需要的矩阵)。
这个代码片段将会输出210,所以这个平移矩阵是正确的。
我们来做些更有意思的事情,让我们来旋转和缩放之前教程中的那个箱子。首先我们把箱子逆时针旋转90度。然后缩放0.5倍,使它变成原来的二分之一。我们先来创建变换矩阵:
glm::mat4 trans;
trans = glm::rotate(trans, 90.0f, glm::vec3(0.0, 0.0, 1.0));
trans = glm::scale(trans, glm::vec3(0.5, 0.5, 0.5));
首先,我们把箱子在每个轴缩放到0.5倍,然后沿Z轴旋转90度。注意有纹理的那面矩形是在XY平面上的,我们需要把它绕着z轴旋转。因为我们把这个矩阵传递给了GLM的每个函数,GLM会自动将矩阵相乘,返回的结果是一个包括了多个变换的变换矩阵。
有些GLM版本接收的是弧度而不是角度,这种情况下你可以用glm::radians(90.0f)将角度转换为弧度。(我使用的版本glm-0.9.6.3需要用这个函数来转换才可以。)
下一个大问题是:如何把矩阵传递给着色器?我们在前面简单提到过GLSL里的mat4类型。所以我们改写顶点着色器来接收一个mat4的uniform变量,然后再用矩阵uniform乘以位置向量:
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 color;
layout (location = 2) in vec2 texCoord;
out vec3 ourColor;
out vec2 TexCoord;
uniform mat4 transform;
void main()
{
gl_Position = transform * vec4(position, 1.0f);
ourColor = color;
TexCoord = vec2(texCoord.x, 1.0 - texCoord.y);
}
在把位置向量传给gl_Position之前,我们添加一个uniform,并且用变换矩阵乘以它。我们的箱子现在应该是原来的二分之一大小并旋转了90度(向左倾斜)。当然,我们仍需要把变换矩阵传递给着色器:
GLuint transformLoc = glGetUniformLocation(ourShader.Program, "transform");
glUniformMatrix4fv(transformLoc, 1, GL_FALSE, glm::value_ptr(trans));
我们首先请求uniform变量的地址,然后用有Matrix4fv后缀的glUniform函数把矩阵数据发送给着色器。-
- 第一个参数你现在应该很熟悉了,它是uniform的地址(Location)。
- 第二个参数告诉OpenGL我们将要发送多少个矩阵,目前是1。
- 第三个参数询问我们我们是否希望对我们的矩阵进行置换(Transpose),也就是说交换我们矩阵的行和列。OpenGL开发者通常使用一种内部矩阵布局叫做以列为主顺序的(Column-major Ordering)布局。GLM已经是用以列为主顺序定义了它的矩阵,所以并不需要置换矩阵,我们填GL_FALSE。
- 最后一个参数是实际的矩阵数据,但是GLM并不是把它们的矩阵储存为OpenGL所希望的那种,因此我们要先用GLM的自带的函数value_ptr来变换这些数据。
我们创建了一个变换矩阵,在顶点着色器中声明了一个uniform,并把矩阵发送给了着色器,着色器会变换我们的顶点坐标。最后的结果应该看起来像这样:
Image 037.png我们现在做些更有意思的,看看我们是否可以让箱子随着时间旋转,我们还会重新把箱子放在窗口的右下角。要让箱子随着时间推移旋转,我们必须在游戏循环中更新变换矩阵,因为它需要在每一次渲染迭代中被更新。我们使用GLFW的时间函数来获取不同时间的角度:
glm::mat4 trans;
trans = glm::translate(trans, glm::vec3(0.5f, -0.5f, 0.0f));
trans = glm::rotate(trans,(GLfloat)glfwGetTime() * 50.0f, glm::vec3(0.0f, 0.0f, 1.0f));
要记住的是前面的例子中我们可以在任何地方声明变换矩阵,但是现在我们必须在每一次迭代中创建它,从而保证我们能够更新旋转矩阵。这也就意味着我们不得不在每次迭代中中重新创建变换矩阵。通常在渲染场景的时候,我们也会有多个在每次渲染迭代中都用新的值重新创建的变换矩阵
在这里我们先把箱子围绕原点(0, 0, 0)旋转,之后,我们把旋转过后的箱子平移到屏幕的右下角。记住,实际的变换顺序应该从下向上阅读:尽管在代码中我们先平移再旋转,实际的变换却是先应用旋转然后平移的。明白所有这些变换的组合,并且知道它们是如何应用到物体上的并不简单。只有尝试和实验这些变换你才能快速地掌握它们。
如果你做对了,你将看到下面的结果:
如果你没有得到正确的结果,或者你有哪儿不清楚的地方。可以看工程文件。
下个教程中,我们会讨论怎样使用矩阵为顶点定义不同的坐标空间。这将是我们进入实时3D图像的第一步!
练习
- 使用应用在箱子上的最后的变换,尝试将其改变成先旋转,后平移。看看发生了什么,试着想想为什么会发生这样的事情:
glm::mat4 trans;
trans = glm::rotate (trans, glm::radians ((GLfloat)glfwGetTime() * 50.0f), glm::vec3 (0.0, 0.0, 1.0));
trans = glm::translate (trans, glm::vec3 (0.5, -0.5, 0.0));
我的理解:(没有解释到本质上)代码中,我们先旋转,后平移,然而,实际的变换却是先平移后旋转,所以,会先将箱子向右下角平移,再绕着z轴旋转。不同于,修改前的箱子先绕着z轴旋转,再向右下角平移。
答案:
/* Why does our container now spin around our screen?:
== ===================================================
Remember that matrix multiplication is applied in reverse. This time a translation is thus
applied first to the container positioning it in the bottom-right corner of the screen.
After the translation the rotation is applied to the translated container.
A rotation transformation is also known as a change-of-basis transformation
for when we dig a bit deeper into linear algebra. Since we're changing the
basis of the container, the next resulting translations will translate the container
based on the new basis vectors. Once the vector is slightly rotated, the vertical
translations would also be slightly translated for example.
If we would first apply rotations then they'd resolve around the rotation origin (0,0,0), but
since the container is first translated, its rotation origin is no longer (0,0,0) making it
looks as if its circling around the origin of the scene.
If you had trouble visualizing this or figuring it out, don't worry. If you
experiment with transformations you'll soon get the grasp of it; all it takes
is practice and experience.
*/
- 尝试着再次调用glDrawElements画出第二个箱子,但是只能使用变换将其摆放在不同的位置。保证这个箱子被摆放在窗口的左上角,并且会不断的缩放(而不是旋转)。使用sin函数在这里会很有用;注意使用sin函数取到负值时会导致物体被翻转: