OpenGL学习笔记(五)变换
参考资料:OpenGL中文翻译
变换
尽管我们现在已经知道了如何创建一个物体、着色、加入纹理,给它们一些细节的表现,但因为它们都还是静态的物体,仍是不够有趣。我们可以尝试着在每一帧改变物体的顶点并且重配置缓冲区从而使它们移动,但这太繁琐了,而且会消耗很多的处理时间。我们现在有一个更好的解决方案,使用 (多个)矩阵(Matrix)对象可以更好的变换(Transform)一个物体。
为了深入了解变换,我们首先要在讨论矩阵之前进一步了解一下向量。这一节的目标是让你拥有将来需要的最基础的数学背景知识。如果你发现这节十分困难,尽量尝试去理解它们,当你以后需要它们的时候回过头来复习这些概念。
向量
向量有一个方向(Direction)和大小(Magnitude)。
下面你会看到3个向量,每个向量在2D图像中都用一个箭头(x, y)表示。
数学家喜欢在字母上面加一横表示向量。
向量的运算
向量与标量运算
标量(Scalar)只是一个数字(或者说是仅有一个分量的向量)。当把一个向量加/减/乘/除一个标量,我们可以简单的把向量的每个分量分别进行该运算。
向量取反
对一个向量取反(Negate)会将其方向逆转。一个指向东北的向量取反后就指向西南方向了。我们在一个向量的每个分量前加负号就可以实现取反了(或者说用-1数乘该向量):
向量加减
两个相同维度的向量进行加减即为它们对应的分量进行加减:
求向量长度
由勾股定理可得:
向量的单位化
通常单位向量会变得很有用,特别是在我们只关心方向不关心长度的时候(如果改变向量的长度,它的方向并不会改变)。
向量相乘
点乘(Dot Product)
点乘的计算:
点乘是通过将对应分量逐个相乘,然后再把所得积相加来计算的。
点乘结果与长度及夹角的等量关系:
如果 \(\vec{v}\) 和 \(\vec{k}\) 都是单位向量,它们的长度会等于1。这样公式会有效简化成:
因此我们可以利用点乘来计算两个向量的夹角的余弦值:
通过余弦值计算\(\theta\)的值,我们可以使用反余弦函数\(cos^{−1}\) ,可得结果是143.1度。现在我们很快就计算出了这两个向量的夹角。
叉乘
叉乘只在3D空间中有定义,它需要两个不平行向量作为输入,生成一个正交于两个输入向量的第三个向量。如果输入的两个向量也是正交的,那么叉乘之后将会产生3个互相正交的向量。接下来的教程中这会非常有用。下面的图片展示了3D空间中叉乘的样子:
不同于其他运算,如果你没有钻研过线性代数,可能会觉得叉乘很反直觉,所以只记住公式就没问题啦(记不住也没问题)。下面你会看到两个正交向量A和B叉积:
矩阵
矩阵的加减
矩阵与矩阵之间的加减就是两个矩阵对应元素的加减运算,例如:
矩阵的数乘
计算方法:和矩阵与标量的加减一样,矩阵与标量之间的乘法也是矩阵的每一个元素分别乘以该标量。
矩阵相乘
- 只有当左侧矩阵的列数与右侧矩阵的行数相等,两个矩阵才能相乘。
- 矩阵相乘不遵守交换律(Commutative)。
与单位矩阵相乘
在OpenGL中,由于某些原因我们通常使用4×4的变换矩阵,而其中最重要的原因就是大部分的向量都是4分量的。我们能想到的最简单的变换矩阵就是单位矩阵(Identity Matrix)。单位矩阵是一个除了对角线以外都是0的N×N矩阵。在下式中可以看到,这种变换矩阵使一个向量完全不变:
作用:单位矩阵通常是生成其他变换矩阵的起点,用于生成其他矩阵。
缩放
对一个向量进行缩放(Scaling)就是对向量的长度进行缩放,而保持它的方向不变。
我们先来尝试缩放向量\(\vec{v}=(3,2)\)。我们可以把向量沿着x轴缩放0.5,使它的宽度缩小为原来的二分之一;我们将沿着y轴把向量的高度缩放为原来的两倍。我们看看把向量缩放(0.5, 2)倍所获得的\(\vec{s}\)是什么样的:
记住,OpenGL 通常是在3D空间 进行操作的,对于2D的情况我们可以把 z轴缩放1倍,这样z轴的值就不变了。我们刚刚的缩放操作是不均匀(Non-uniform)缩放,因为每个轴的 缩放因子(Scaling Factor)都不一样。如果每个轴的缩放因子都一样那么就叫 均匀缩放(Uniform Scale)。
我们下面会构造一个变换矩阵来为我们提供缩放功能。我们从单位矩阵了解到,每个对角线元素会分别与向量的对应元素相乘。如果我们把1变为3会怎样?这样子的话,我们就把向量的每个元素乘以3了,这事实上就把向量缩放3倍。如果我们把缩放变量表示为(S1,S2,S3)
我们可以为任意向量(x,y,z)
定义一个缩放矩阵:
注意,第四个缩放向量仍然是1,因为在3D空间中缩放w分量是无意义的。w分量另有其他用途,在后面我们会看到。
位移
位移(Translation)是在原始向量的基础上加上另一个向量从而获得一个在不同位置的新向量的过程,从而在位移向量基础上移动了原始向量。
和缩放矩阵一样,在4×4矩阵上有几个特别的位置用来执行特定的操作,对于位移来说它们是第四列最上面的3个值。如果我们把位移向量表示为\((T_x,T_y,T_z)\),我们就能把位移矩阵定义为:
这样是能工作的,因为所有的位移值都要乘以向量的w行,所以位移值会加到向量的原始值上(想想矩阵乘法法则)。而如果你用3x3矩阵我们的位移值就没地方放也没地方乘了,所以是不行的。
所以构造矩阵的时候,第4列前三行填的数是希望分类加上的数,而第M[i][i]
即为希望i
分量乘以的系数。结果为M[i][i]*v[i]+M[i][4]
。
旋转
角度值和弧度制角的转换:
- 弧度转角度:
角度 = 弧度 * (180.0f / PI)
- 角度转弧度:
弧度 = 角度 * (PI / 180.0f)
在3D空间中旋转需要定义一个角和一个旋转轴(Rotation Axis)。物体会沿着给定的旋转轴旋转特定角度。当2D向量在3D空间中旋转时,我们把旋转轴设为z轴。
使用三角学,给定一个角度,可以把一个向量变换为一个经过旋转的新向量。这通常是使用一系列正弦和余弦函数(一般简称sin和cos)各种巧妙的组合得到的。
旋转矩阵在3D空间中每个单位轴都有不同定义,旋转角度用θ表示:
- 沿x轴旋转:
- 沿y轴旋转:
- 沿z轴旋转:
利用旋转矩阵我们可以把任意位置向量沿一个单位旋转轴进行旋转。也可以将多个矩阵复合,比如先沿着x轴旋转再沿着y轴旋转
。但是这会很快导致一个问题——万向节死锁(Gimbal Lock)。但是对于3D空间中的旋转,一个更好的模型是沿着任意的一个轴,比如单位向量\((0.662, 0.2, 0.7222)\)旋转,而不是对一系列旋转矩阵进行复合。这样的一个(超级麻烦的)矩阵是存在的,见下面这个公式,其中\((R_x,R_y,R_z)\)代表任意旋转轴:
在数学上讨论如何生成这样的矩阵仍然超出了本节内容。但是记住,即使这样一个矩阵也不能完全解决万向节死锁问题(尽管会极大地避免)。避免万向节死锁的真正解决方案是使用四元数(Quaternion),它不仅更安全,而且计算会更有效率。四元数可能会在后面的教程中讨论。
矩阵的组合
使用矩阵进行变换的真正力量在于,根据矩阵之间的乘法,我们可以把多个变换组合到一个矩阵中。假设我们有一个顶点\((x, y, z)\),我们希望将其 (1)缩放2倍,然后 (2)位移(1, 2, 3)个单位。我们需要一个位移和缩放矩阵来完成这些变换。结果的变换矩阵看起来像这样:
注意,当矩阵相乘时我们先写位移再写缩放变换的。矩阵乘法是不遵守交换律的,这意味着它们的顺序很重要。当矩阵相乘时,在最右边的矩阵是第一个与向量相乘的,所以你应该 从右向左读
这个乘法。建议您在组合矩阵时,(1)先进行缩放操作,(2)然后是旋转,(3)最后才是位移,否则它们会(消极地)互相影响。比如,如果你先位移再缩放,位移的向量也会同样被缩放(译注:比如向某方向移动2米,2米也许会被缩放成1米)!
即需要从右往左:缩放,旋转,位移。
从左往右:位移,旋转,缩放
用最终的变换矩阵左乘我们的向量会得到以下结果:
向量先缩放2倍,然后位移了(1, 2, 3)个单位。
实践
第三方库
OpenGL没有自带任何的矩阵和向量知识,所以我们必须定义自己的数学类和函数。在教程中我们更希望抽象所有的数学细节,使用已经做好了的数学库。幸运的是,有个易于使用,专门为OpenGL量身定做的数学库,那就是GLM。
GLM是OpenGL Mathematics的缩写,它是一个只有头文件的库,也就是说我们只需包含对应的头文件就行了,不用链接和编译。GLM可以在它们的网站上下载。把头文件的根目录复制到你的includes文件夹,然后你就可以使用这个库了。
GLM库从0.9.9版本起,默认会将矩阵类型初始化为一个零矩阵(所有元素均为0),而不是单位矩阵(对角元素为1,其它元素为0)。如果你使用的是0.9.9或0.9.9以上的版本,你需要将所有的矩阵初始化改为
glm::mat4 mat = glm::mat4(1.0f)
。如果你想与本教程的代码保持一致,请使用低于0.9.9版本的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 = glm::mat4(1.0f)
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 单位矩阵 。
- 创建一个变换矩阵,我们是把 (1)单位矩阵 和一个 (2)位移向量 传递给
glm::translate
函数来完成这个工作的(即用给定的矩阵乘以位移矩阵)。 - 把向量乘以位移矩阵并且输出最后的结果。
如果你仍记得位移矩阵是如何工作的话,得到的向量应该是\((1 + 1, 0 + 1, 0 + 0)\),也就是\((2, 1, 0)\)。这个代码片段将会输出210
,所以这个位移矩阵是正确的。
运行结果:
tips: glm为我们准备了方便我们对变量进行打印的函数
std::string to_string(matType x)
,在glm/ext.hpp
这个头文件中,这样我们就可以比较方便地进行调试了。
对之前的箱子进行操作
让我们来旋转和缩放之前教程中的那个箱子。首先我们把箱子逆时针旋转90度。然后缩放0.5倍,使它变成原来的一半大。我们先来创建变换矩阵:
glm::mat4 trans;
// 即 trans = trans * newMat;
trans = glm::rotate(trans, glm::radians(90.0f), glm::vec3(0.0, 0.0, 1.0));
trans = glm::scale(trans, glm::vec3(0.5, 0.5, 0.5));
// 即最终为:trans = trans * rotateMat * scaleMat;
首先,(1)我们把箱子在每个轴都缩放到0.5倍,(2)然后沿z轴旋转90度。GLM希望它的角度是弧度制的(Radian),所以我们使用glm::radians
将角度转化为弧度。注意有纹理的那面矩形是在XY平面上的,所以我们需要把它绕着z轴旋转。因为我们把这个矩阵传递给了GLM的每个函数,GLM会自动将矩阵相乘,返回的结果是一个包括了多个变换的变换矩阵。
下一个大问题是:如何把矩阵传递给着色器? 我们在前面简单提到过GLSL
里也有一个mat4
类型。所以我们将修改顶点着色器让其接收一个mat4
的uniform变量
,然后再用矩阵uniform
乘以位置向量:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;
out vec2 TexCoord;
uniform mat4 transform; <- 接收的变换矩阵
void main()
{
gl_Position = transform * vec4(aPos, 1.0f); <- 使用矩阵对顶点向量进行转换
TexCoord = vec2(aTexCoord.x, 1.0 - aTexCoord.y);
}
GLSL
也有mat2
和mat3
类型从而允许了像向量一样的混合运算。前面提到的所有数学运算(像是标量-矩阵相乘,矩阵-向量相乘和矩阵-矩阵相乘)在矩阵类型里都可以使用。当出现特殊的矩阵运算的时候我们会特别说明。
在把位置向量传给gl_Position之前,我们先添加一个uniform,并且将其与变换矩阵相乘。我们的箱子现在应该是原来的二分之一大小并(向左)旋转了90度。当然,我们仍需要把变换矩阵传递给着色器:
unsigned int transformLoc = glGetUniformLocation(ourShader.ID, "transform");
glUniformMatrix4fv(transformLoc, 1, GL_FALSE, glm::value_ptr(trans));
步骤:
- 查询
uniform
变量的地址 - 用有
Matrix4fv
后缀的glUniform
函数把矩阵数据发送给着色器。
glUniformMatrix4fv
函数参数:
- uniform的位置值。
- 发送矩阵的个数,这里是1。
- 是否希望对输入的矩阵进行置换(Transpose),也就是说交换我们矩阵的行和列。OpenGL开发者通常使用一种内部矩阵布局,叫做列主序(Column-major Ordering)布局。GLM的默认布局就是列主序,所以并不需要置换矩阵,我们填GL_FALSE。
- 真正的矩阵数据,但是
GLM
并不是把它们的矩阵储存为OpenGL所希望接受的那种,因此我们要先用GLM的自带的函数value_ptr来变换这些数据。
运行结果:
- 矩阵转换前
- 矩阵转换后
我们的箱子向左侧旋转,并是原来的一半大小,所以变换成功了。
然后我们希望箱子随着时间旋转,我们还会重新把箱子放在窗口的右下角。要让箱子随着时间推移旋转,我们必须在游戏循环中更新变换矩阵,使它在每一次渲染迭代中都要更新。我们使用GLFW的时间函数来获取不同时间的角度:
在这里我们先把箱子围绕原点\((0, 0, 0)\)旋转,之后,我们把旋转过后的箱子位移到屏幕的右下角。记住,实际的变换顺序应该与阅读顺序相反:尽管在代码中我们先位移再旋转,实际的变换却是先应用旋转再是位移的。
至此,第五节变换的学习结束。