OpenGL入门1.5:矩阵与变换

每一个小步骤的源码都放在了Github

的内容为插入注释,可以先跳过

前言

在阅读本篇博客之前,你必须对向量和矩阵有基本的认识,并且能熟练进行向量和矩阵的运算

我们已经知道了如何创建一个物体、着色、加入纹理,但它们都还是静态的物体
我们可以尝试着在每一帧改变物体的顶点并且重配置缓冲区从而使它们移动
但是这样的操作太过复杂,而且消耗性能也很大

我们现在有一个更好的解决方案,使用多个矩阵(Matrix)对象变换(Transform)一个物体

如果你具备了我说到的向量和矩阵的数学基础,接下来的操作都很简单

使用矩阵变换向量

缩放(Scale)

对向量的长度进行缩放,而保持它的方向不变

由于我们进行的是2维或3维操作,我们可以分别定义一个有2或3个缩放变量的向量,每个变量缩放一个轴(x、y或z)

记住,OpenGL通常是在3D空间进行操作的,对于2D的情况我们可以把z轴缩放1倍,只操作x和y轴,这样z轴的值就不变了,每个轴的缩放因子(Scaling Factor)都不一样,就是不均匀(Non-uniform)缩放,都一样那么就叫均匀缩放(Uniform Scale)

我们下面会构造一个变换矩阵来为我们提供缩放功能:

从单位矩阵了解到,每个对角线元素会分别与向量的对应元素相乘,如果我们把1变为3会怎样?这样子的话,我们就把向量的每个元素乘以3了,这事实上就把向量缩放3倍

如果我们把缩放变量表示为(S1,S2,S3)我们可以为任意向量(x,y,z)定义一个缩放矩阵:

\[\begin{vmatrix} S_{1}& 0 & 0 & 0\\ 0 & S_{2} & 0 & 0\\ 0 & 0 & S_{3} & 0\\ 0 & 0 & 0 & 1\end{vmatrix}\cdot \begin{pmatrix}x\\ y\\ z\\ 1\end{pmatrix}=\begin{pmatrix}S_{1}\cdot x\\ S_{2}\cdot y\\ S_{3}\cdot z\\ 1\end{pmatrix} \]

第四个缩放向量w仍然是1,因为在3D空间中缩放w分量是无意义的(w分量另有其他用途)

位移(Translation)

在原始向量的基础上加上另一个向量从而获得一个在不同位置的新向量的过程,从而在位移向量基础上移动了原始向量,你肯定学过了向量加法,所以应该不会太陌生

和缩放矩阵一样,在4×4矩阵上有几个特别的位置用来执行特定的操作,对于位移来说它们是第四列最上面的3个值。如果我们把位移向量表示为(Tx,Ty,Tz),我们就能把位移矩阵定义为:

\[\begin{vmatrix} 1 & 0 & 0 & T_{x}\\ 0 & 1 & 0 & T_{y}\\ 0 & 0 & 1 & T_{z}\\ 0 & 0 & 0 & 1\end{vmatrix}\cdot \begin{pmatrix}x\\ y\\ z\\ 1\end{pmatrix}=\begin{pmatrix}x+T_{x}\\ y+T_{y}\\ z+T_{z}\\ 1\end{pmatrix} \]

有了位移矩阵我们就可以在3个方向(x、y、z)上移动物体,它是我们的变换工具箱中非常有用的一个变换矩阵

旋转(Rotate)

首先我们来定义一个向量的旋转到底是什么。2D或3D空间中的旋转用角(Angle)来表示,角可以是角度制或弧度制的,周角是360角度或2pi弧度

大多数旋转函数需要用弧度制的角,但幸运的是角度制的角也可以很容易地转化为弧度制的:

  • 弧度转角度:角度 = 弧度 * (180.0f / PI)
  • 角度转弧度:弧度 = 角度 * (PI / 180.0f)

PI约等于3.14159265359

转半圈会旋转360/2 = 180度,向右旋转1/5圈表示向右旋转360/5 = 72度。下图中展示的2D向量 是由 向右旋转72度所得的:

在3D空间中旋转需要定义一个角一个旋转轴(Rotation Axis)

使用三角学,给定一个角度,可以把一个向量变换为一个经过旋转的新向量,这通常是使用一系列正弦和余弦函数(一般简称sin和cos)各种巧妙的组合得到的

旋转矩阵在3D空间中每个单位轴都有不同定义,旋转角度用θ表示:

沿x轴旋转:

\[\begin{vmatrix} 1 & 0 & 0 & 0\\ 0 & cos\theta & -sin\theta & 0\\ 0 & sin\theta & cos\theta & 0\\ 0 & 0 & 0 & 1\end{vmatrix}\cdot \begin{pmatrix}x\\ y\\ z\\ 1\end{pmatrix}=\begin{pmatrix}x\\ cos\theta\cdot y-sin\theta\cdot z\\ sin\theta\cdot y+cos\theta\cdot z\\ 1\end{pmatrix} \]

沿y轴旋转:

\[\begin{vmatrix}cos\theta & 0 & sin\theta & 0\\ 0 & 1 & 0 & 0\\ -sin\theta & 0 & cos\theta & 0\\ 0 & 0 & 0 & 1\end{vmatrix}\cdot \begin{pmatrix}x\\ y\\ z\\ 1\end{pmatrix}=\begin{pmatrix}cos\theta\cdot x+sin\theta\cdot z\\ y\\ -sin\theta\cdot x+cos\theta\cdot z\\ 1\end{pmatrix} \]

沿z轴旋转:

\[\begin{vmatrix}cos\theta & -sin\theta & 0 & 0\\ sin\theta & cos\theta & 0 & 0\\ 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1\end{vmatrix}\cdot \begin{pmatrix}x\\ y\\ z\\ 1\end{pmatrix}=\begin{pmatrix}cos\theta\cdot x-sin\theta\cdot y\\ sin\theta\cdot x+cos\theta\cdot y\\ z\\ 1\end{pmatrix} \]

利用旋转矩阵我们可以把任意位置向量沿一个单位旋转轴进行旋转,也可以将多个矩阵复合,比如先沿着x轴旋转再沿着y轴旋转。但是这会很快导致一个问题——万向节死锁(Gimbal Lock)

在这里我们不会讨论它的细节,但是对于3D空间中的旋转,一个更好的模型是沿着任意的一个轴,比如单位向量\((0.662, 0.2, 0.7222)\)旋转,而不是对一系列旋转矩阵进行复合。这样的一个(超级麻烦的)矩阵是存在的,见下面这个公式,其中(Rx,Ry,Rz)代表任意旋转轴:

但是,即使这样一个矩阵也不能完全解决万向节死锁问题(虽然能尽量避免),避免万向节死锁的终极解决方案是使用四元数(Quaternion),它不仅更安全,而且计算会更有效率,这里暂不介绍四元数

矩阵的组合

使用矩阵进行变换的真正力量在于,根据矩阵之间的乘法,我们可以把多个变换组合到一个矩阵中

让我们看看我们是否能生成一个变换矩阵,让它组合多个变换:假设我们有一个顶点(x, y, z),我们希望将其缩放2倍,然后位移(1, 2, 3)个单位,我们需要一个位移和缩放矩阵来完成这些变换:

\[Trans.Scale = \begin{vmatrix}1 & 0 & 0 & 1\\ 0 & 1 & 0 & 2\\ 0 & 0 & 1 & 3\\ 0 & 0 & 0 & 1\end{vmatrix}\cdot \begin{vmatrix}2 & 0 & 0 & 0\\ 0 & 2 & 0 & 0\\ 0 & 0 & 2 & 0\\ 0 & 0 & 0 & 1\end{vmatrix}=\begin{vmatrix}2 & 0 & 0 & 1\\ 0 & 2 & 0 & 2\\ 0 & 0 & 2 & 3\\ 0 & 0 & 0 & 1\end{vmatrix} \]

注意,当矩阵相乘时我们先写位移再写缩放变换的,矩阵乘法不遵守交换律,这意味着它们的顺序很重要。当矩阵相乘时,在最右边的矩阵是第一个与向量相乘的,所以你应该从右向左读这个乘法,建议在组合矩阵时,先进行缩放操作,然后是旋转,最后才是位移,否则会互相影响

用最终的变换矩阵左乘我们的向量会得到以下结果:

\[\begin{vmatrix}2 & 0 & 0 & 1\\ 0 & 2 & 0 & 2\\ 0 & 0 & 2 & 3\\ 0 & 0 & 0 & 1\end{vmatrix}\cdot\begin{pmatrix}x\\ y\\ z\\ 1\end{pmatrix}=\begin{pmatrix}2x+1\\ 2y+2\\ 2z+3\\ 1\end{pmatrix} \]

向量先缩放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,或者改用上述代码初始化所有的矩阵。

我们需要的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); // 向量(1, 0, 0)
// 如果使用的是0.9.9及以上版本,下面这行代码就需要改为:
// glm::mat4 trans = glm::mat4(1.0f)
glm::mat4 trans; // 单位矩阵
// 传递单位矩阵和一个位移向量
trans = glm::translate(trans, glm::vec3(1.0f, 1.0f, 0.0f)); 
vec = trans * vec; // trans为位移矩阵
std::cout << vec.x << vec.y << vec.z << std::endl; // 210

我们先用GLM内建的向量类定义一个叫做vec的向量,接下来定义一个mat4类型的trans(4×4单位矩阵),下一步是创建一个变换矩阵,我们是把单位矩阵和一个位移向量传递给glm::translate函数来完成这个工作的(然后用给定的矩阵乘以位移矩阵就能获得最后需要的矩阵)
之后我们把向量乘以位移矩阵并且输出最后的结果,得到的向量应该是(1 + 1, 0 + 1, 0 + 0),也就是(2, 1, 0)
这个代码片段将会输出210,所以这个位移矩阵是正确的

我们来做些更有意思的事情,让我们来旋转和缩放之前教程中的那个箱子:首先我们把箱子逆时针旋转90度,然后缩放0.5倍,使它变成原来的一半大

我们先来创建变换矩阵:

glm::mat4 trans;
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)); 

首先,我们把箱子在每个轴都缩放到0.5倍,然后沿z轴旋转90度,GLM希望它的角度是弧度制的(Radian),所以我们使用glm::radians将角度转化为弧度,注意有纹理的那面矩形是在XY平面上的,所以我们需要把它绕着z轴旋转,因为我们把这个矩阵传递给了GLM的每个函数,GLM会自动将矩阵相乘,返回的结果是一个包括了多个变换的变换矩阵

下一个大问题是:如何把矩阵传递给着色器?我们在前面简单提到过GLSL里也有一个mat4类型,所以我们将修改顶点着色器让其接收一个mat4的uniform变量,然后再用矩阵uniform乘以位置向量:

// vertex shader
#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也有mat2mat3类型从而允许了像向量一样的混合运算,前面提到的所有数学运算(像是标量-矩阵相乘,矩阵-向量相乘和矩阵-矩阵相乘)在矩阵类型里都可以使用(出现特殊的矩阵运算的时候我们会特别说明)

在把位置向量传给gl_Position之前,我们先添加一个uniform,并且将其与变换矩阵相乘,我们的箱子现在应该是原来的二分之一大小并(向左)旋转了90度,当然,我们仍需要把变换矩阵传递给着色器:

unsigned int transformLoc = glGetUniformLocation(ourShader.ID, "transform");
glUniformMatrix4fv(transformLoc, 1, GL_FALSE, glm::value_ptr(trans));

我们首先查询uniform变量的地址,然后用有Matrix4fv后缀的glUniform函数把矩阵数据发送给着色器

  1. 第一个参数你现在应该很熟悉了,它是uniform的位置值
  2. 第二个参数告诉OpenGL我们将要发送多少个矩阵,这里是1
  3. 第三个参数询问我们我们是否希望对我们的矩阵进行置换(Transpose),也就是说交换我们矩阵的行和列,OpenGL开发者通常使用一种内部矩阵布局,叫做列主序(Column-major Ordering)布局,GLM的默认布局就是列主序,所以并不需要置换矩阵,我们填GL_FALSE
  4. 最后一个参数是真正的矩阵数据,但是GLM并不是把它们的矩阵储存为OpenGL所希望接受的那种,因此我们要先用GLM的自带的函数value_ptr来变换这些数据。

我们创建了一个变换矩阵,在顶点着色器中声明了一个uniform,并把矩阵发送给了着色器,着色器会变换我们的顶点坐标。最后的结果应该看起来像这样:

我们的箱子向左侧旋转,并是原来的一半大小,所以变换成功了

我们现在想让箱子随着时间旋转,并且把箱子放在窗口的右下角

我们必须在游戏循环中更新变换矩阵,因为它在每一次渲染迭代中都要更新,我们使用GLFW的时间函数来获取不同时间的角度:

glm::mat4 trans;
trans = glm::translate(trans, glm::vec3(0.5f, -0.5f, 0.0f));
trans = glm::rotate(trans, (float)glfwGetTime(), glm::vec3(0.0f, 0.0f, 1.0f));

要记住的是前面的例子中我们可以在任何地方声明变换矩阵,但是现在我们必须在每一次迭代中创建它,从而保证我们能够不断更新旋转角度,这意味着我们不得不在每次游戏循环的迭代中重新创建变换矩阵,通常在渲染场景的时候,我们也会有多个需要在每次渲染迭代中都用新值重新创建的变换矩阵

在这里我们先把箱子围绕原点(0, 0, 0)旋转,之后,我们把旋转过后的箱子位移到屏幕的右下角,记住,实际的变换顺序应该与阅读顺序相反:在代码中我们先位移再旋转实际的变换却是先应用旋转再是位移的

如果你做对了,你将看到下面的结果:

vs7blfFLVk

awesome

现在你可以明白为什么矩阵在图形领域是一个如此重要的工具了,我们可以定义无限数量的变换,而把它们组合为仅仅一个矩阵,如果愿意的话我们还可以重复使用它

在着色器中使用矩阵可以省去重新定义顶点数据的功夫,它也能够节省处理时间,因为我们没有一直重新发送我们的数据(这是个非常慢的过程)

posted @ 2019-07-30 21:16  KelvinVS  阅读(3567)  评论(0编辑  收藏  举报