序言
以glew、glfw库
OpenGL学习网站
glfw官网
OpenGL-API文档
glew官网
变换
使用(多个)矩阵(Matrix)
对象可以更好的变换(Transform)
一个物体。可看后面的矩阵
部分。
向量
向量
:有一个方向(Direction)
和大小(Magnitude,也叫做强度或长度)
。
向量
可以在任意维度(Dimension)
上,但是我们通常只使用2
至4
维。如果一个向量
有2
个维度,它表示一个平面的方向(想象一下2D的图像),当它有3个维度的时候它可以表达一个3D
世界的方向。
下面你会看到3个向量,每个向量在2D图像中都用一个箭头(x, y)
表示。我们在2D图片中展示这些向量,因为这样子会更直观一点。你可以把这些2D向量当做z坐标为0的3D向量。由于向量表示的是方向,起始于何处并不会改变它的值。下图我们可以看到向量$\overline$和$\overline$是相等的,尽管他们的起始点不同:
数学家喜欢在字母上面加一横表示向量,比如说$\overline$当用在公式中时它们通常是这样的:
$$
B = \begin
\textcolorx \ \textcolory \ \textcolorz
\end
$$
由于向量
是一个方向,所以有些时候会很难形象地将它们用位置(Position)
表示出来。为了让其更为直观,我们通常设定这个方向的原点为(0, 0, 0)
,然后指向一个方向,对应一个点,使其变为位置向量(Position Vector)
(你也可以把起点设置为其他的点,然后说:这个向量从这个点起始指向另一个点)。比如说位置向量
(3, 5)在图像中的起点会是(0, 0)
,并会指向(3, 5)
。我们可以使用向量在2D或3D空间中表示方向与位置。
向量与标量运算
标量(Scalar)
只是一个数字(或者说是仅有一个分量的向量)。当把一个向量加/减/乘/除
一个标量,我们可以简单的把向量的每个分量分别进行该运算。对于加法来说会像这样:
$$
\begin
\textcolor1 \ \textcolor2 \ \textcolor3
\end + x =
\begin
\textcolor1 + x \ \textcolor2 + x \ \textcolor3 + x
\end
$$
其中的+可以是+,-,×或÷,其中×是乘号。注意-和÷运算时不能颠倒(标量-/÷向量),因为颠倒的运算是没有定义的。
向量取反
对一个向量取反(Negate)
会将其方向逆转。一个指向东北的向量取反后就指向西南方向了。
我们在一个向量的每个分量前加负号就可以实现取反了(或者说用-1数乘该向量):
$$
-\overline = -
\begin
\textcolor \ \textcolor \ \textcolor
\end =
\begin
-\textcolor \ -\textcolor \ -\textcolor
\end
$$
向量加减
向量的加法可以被定义为是分量的(Component-wise)相加
,即将一个向量中的每一个分量加上另一个向量的对应分量:
$$
\overline =
\begin
\textcolor1 \ \textcolor2 \ \textcolor3
\end ,
\overline =
\begin
\textcolor4 \ \textcolor5 \ \textcolor6
\end \rightarrow \overline + \overline =
\begin
\textcolor1 + \textcolor4 \ \textcolor2 + \textcolor5 \ \textcolor3 + \textcolor6
\end =
\begin
\textcolor5 \ \textcolor7 \ \textcolor9
\end
$$
向量v = (4, 2)
和k = (1, 2)
可以直观地表示为(5, 4):
就像普通数字的加减一样,向量的减法等于加上第二个向量的相反向量:
$$
\overline =
\begin
\textcolor1 \ \textcolor2 \ \textcolor3
\end ,
\overline =
\begin
\textcolor4 \ \textcolor5 \ \textcolor6
\end \rightarrow \overline + - \overline =
\begin
\textcolor1 + (-\textcolor4) \ \textcolor2 + (-\textcolor5) \ \textcolor3 + (-\textcolor6)
\end =
\begin
-\textcolor3 \ -\textcolor3 \ -\textcolor3
\end
$$
两个向量的相减会得到这两个向量指向位置的差。这在我们想要获取两点的差会非常有用。
长度
我们使用勾股定理(Pythagoras Theorem)
来获取向量的长度(Length)
或大小(Magnitude)
。如果你把向量的x
与y
分量画出来,该向量会和x
与y
分量为边形成一个三角形:
因为两条边(x和y)是已知的,如果希望知道斜边$\textcolor{\overline}$的长度,我们可以直接通过勾股定理来计算:
$$
|| \textcolor{\overline} || = \sqrt{\textcolorx2 + \textcolory2}
$$
$|| \textcolor{\overline} ||$ 表示向量
$\textcolor{\overline}$的长度
,我们也可以加上$z^2$把这个公式拓展到三维空间。
例子中向量(4, 2)
的长度等于:
$$
|| \textcolor{\overline} || =
\sqrt{\textcolor42 + \textcolor22} =
\sqrt{\textcolor{16} + \textcolor{14}} =
\sqrt{20} = 4.47
$$
结果是4.47
。
有一个特殊类型的向量叫做单位向量(Unit Vector)
。
单位向量
有一个特别的性质——它的长度是1
。我们可以用任意向量的每个分量除以向量的长度得到它的单位向量$\hat$。
$$
\hat = \frac{\overline}{|| \overline ||}
$$
我们把这种方法叫做一个向量的标准化(Normalizing)
。单位向量头上有一个^
样子的记号。通常单位向量会变得很有用,特别是在我们只关心方向不关心长度的时候(如果改变向量的长度,它的方向并不会改变)。
向量相乘
两个向量相乘是一种很奇怪的情况。普通的乘法在向量上是没有定义的,因为它在视觉上是没有意义的。
但是在相乘的时候我们有两种特定情况可以选择:一个是点乘(Dot Product),记作$\overline\overline$,另一个是叉乘(Cross Product),记作$\overline\overline$。
点乘
两个向量的点乘等于它们的数乘结果乘以两个向量之间夹角的余弦值。
可能听起来有点费解,我们来看一下公式:
$$
\overline*\overline = || \overline || * || \overline || * \cos\theta
$$
它们之间的夹角记作$\theta$。为什么这很有用?想象如果$\overline$和$\overline$都是单位向量,它们的长度会等于1。这样公式会有效简化成:
$$
\overline*\overline = 1 * 1 * \cos\theta = \cos\theta
$$
现在点积
只定义了两个向量
的夹角
。你也许记得90度
的余弦值
是0
,0
度的余弦值
是1
。使用点乘
可以很容易测试两个向量是否正交(Orthogonal)
或平行
(正交意味着两个向量互为直角
)。
你也可以通过点乘的结果计算两个非单位向量的夹角,点乘的结果除以两个向量的长度之积,得到的结果就是夹角的余弦值,即$\cos\theta$。
通过上面点乘定义式可推出:
$$
\cos\theta = \frac{\overline*\overline}{||\overline || * || \overline ||}
$$
所以,我们该如何计算点乘
呢?点乘
是通过将对应分量
逐个相乘
,然后再把所得积
相加来计算的。两个单位向量
的(你可以验证它们的长度都为1)点乘
会像是这样:
$$
\begin
\textcolor{0.6} \ -\textcolor{0.8} \ \textcolor0
\end *
\begin
\textcolor{0} \ \textcolor{1} \ \textcolor0
\end = (\textcolor{0.6} * \textcolor0) + (-\textcolor{0.8} * \textcolor1) + (\textcolor{0} * \textcolor0) = -0.8
$$
要计算两个单位向量间的夹角,我们可以使用反余弦函数
$\cos^{-1}$,可得结果是143.1度。现在我们很快就计算出了这两个向量的夹角。点乘会在计算光照的时候非常有用(游戏中:碰撞检测、射线检测等,判断两个物体相交)。
叉乘
叉乘
只在3D空间
中有定义,它需要两个不平行向量作为输入,生成一个正交
于两个输入向量的第三个向量。如果输入的两个向量也是正交的,那么叉乘之后将会产生3个互相正交的向量。
接下来的教程中这会非常有用。下面的图片展示了3D空间中叉乘
的样子:
不同于其他运算,如果你没有钻研过线性代数,可能会觉得叉乘
很反直觉,所以只记住公式就没问题啦(记不住也没问题)。
下面你会看到两个正交向量A和B叉积:
$$
\begin
\textcolor \ \textcolor \ \textcolor
\end *
\begin
\textcolor \ \textcolor \ \textcolor
\end =
\begin
\textcolor * \textcolor - \textcolor * \textcolor \ \textcolor * \textcolor - \textcolor * \textcolor \ \textcolor * \textcolor - \textcolor * \textcolor
\end
$$
按照步骤来,即可获得叉乘
,你就能得到一个正交于两个输入向量的第三个向量。游戏中可判断对方方位。
矩阵
简单来说矩阵
就是一个矩形的数字
、符号``或
表达式`数组。
矩阵
中每一项叫做矩阵的元素(Element)
。
下面是一个2×3矩阵
的例子:
$$
\left[
\begin
4 & 5 & 6
\end1 & 2 & 3\
\right]
$$
矩阵可以通过(i, j)
进行索引,i
是行,j
是列,这就是上面的矩阵叫做2×3矩阵的原因(3列2行,也叫做矩阵的维度(Dimension)
)。这与你在索引2D图像时的(x, y)
相反,获取4的索引是(2, 1)(第二行,第一列)(如果是图像索引应该是(1, 2),先算列,再算行)。
矩阵加减
矩阵
与标量
之间的加减定义如下:
$$
\left[
\begin
3 & 4
\end1 & 2 \
\right] + \textcolor3 =
\left[
\begin
1 + \textcolor3 & 2 + \textcolor
3 + \textcolor3 \3 & 4 + \textcolor3
\end
\right] =
\left[
\begin
6 & 7
\end4 & 5 \
\right]
$$
标量值要加到矩阵的每一个元素上。
矩阵与标量的减法也相似:
$$
\left[
\begin
3 & 4
\end1 & 2 \
\right] - \textcolor3 =
\left[
\begin
1 - \textcolor3 & 2 - \textcolor
3 - \textcolor3 \3 & 4 - \textcolor3
\end
\right] =
\left[
\begin
0 & 1
\end-2 & -1 \
\right]
$$
矩阵与矩阵之间的加减就是两个矩阵对应元素的加减运算,所以总体的规则和与标量运算是差不多的,只不过在相同索引下的元素才能进行运算。这也就是说:加法和减法只对同维度的矩阵才是有定义的。一个3×2矩阵和一个2×3矩阵(或一个3×3矩阵与4×4矩阵)是不能进行加减的。
我们看看两个2×2矩阵是怎样相加的:
$$
\left[
\begin
\textcolor1 & \textcolor
\textcolor2 \3 & \textcolor4
\end
\right] +
\left[
\begin
\textcolor5 & \textcolor
\textcolor6 \7 & \textcolor8
\end
\right] =
\left[
\begin
\textcolor1 + \textcolor3 & \textcolor2 + \textcolor
\textcolor6 \3 + \textcolor7 & \textcolor4 + \textcolor8
\end
\right] =
\left[
\begin
\textcolor6 & \textcolor
\textcolor8 \{10} & \textcolor{12}
\end
\right]
$$
同样的法则也适用于减法:
$$
\left[
\begin
\textcolor4 & \textcolor
\textcolor2 \1 & \textcolor6
\end
\right] -
\left[
\begin
\textcolor2 & \textcolor
\textcolor4 \0 & \textcolor1
\end
\right] =
\left[
\begin
\textcolor4 - \textcolor2 & \textcolor2 - \textcolor
\textcolor4 \1 - \textcolor0 & \textcolor6 - \textcolor1
\end
\right] =
\left[
\begin
\textcolor2 & -\textcolor
\textcolor2 \{1} & \textcolor{5}
\end
\right]
$$
矩阵数乘
和矩阵
与标量
的加减一样,矩阵
与标量
之间的乘法也是矩阵的每一个元素分别乘以该标量。
下面的例子展示了乘法的过程:
$$
2 *
\left[
\begin
\textcolor1 & \textcolor
\textcolor2 \3 & \textcolor4
\end
\right] =
\left[
\begin
\textcolor2* \textcolor1 & \textcolor2 * \textcolor
\textcolor2 \2 * \textcolor3 & \textcolor2 * \textcolor4
\end
\right] =
\left[
\begin
\textcolor2 & \textcolor
\textcolor2 \{6} & \textcolor{8}
\end
\right]
$$
现在我们也就能明白为什么这些单独的数字要叫做标量(Scalar)
了。
简单来说,标量就是用它的值缩放(Scale)矩阵
的所有元素(注意Scalar是由Scale + -ar演变过来的)。前面那个例子中,所有的元素都被放大了2倍。
矩阵相乘
矩阵乘法
基本上意味着遵照规定好的法则进行相乘。当然,相乘还有一些限制:
- 1.只有当左侧矩阵的列数与右侧矩阵的行数相等,两个矩阵才能相乘。
- 2.矩阵相乘不遵守交换律(Commutative),也就是说$AB \neq BA$。
我们先看一个两个2×2
矩阵相乘的例子:
$$
\left[
\begin
\textcolor1 & \textcolor
\textcolor2 \3 & \textcolor4
\end
\right] *
\left[
\begin
\textcolor5 & \textcolor
\textcolor6 \7 & \textcolor8
\end
\right] =
\left[
\begin
\textcolor1* \textcolor5 + \textcolor2 * \textcolor7 &
\textcolor1* \textcolor6 + \textcolor2 * \textcolor
\textcolor8 \3 * \textcolor5 + \textcolor4 * \textcolor7 &
\textcolor3 * \textcolor6 + \textcolor4 * \textcolor8
\end
\right] =
\left[
\begin
43 & 50
\end19 & 22 \
\right]
$$
矩阵的乘法是一系列乘法和加法组合的结果,它使用到了左侧矩阵的行
和右侧矩阵的列
。我们可以看下面的图片:
我们首先把左侧矩阵的行
和右侧矩阵的列
拿出来。这些挑出来行和列将决定我们该计算结果2x2
矩阵的哪个输出值。如果取的是左矩阵
的第一行,输出值就会出现在结果矩阵的第一行。接下来再取一列,如果我们取的是右矩阵的第一列,最终值则会出现在结果矩阵的第一列。这正是红框里的情况。如果想计算结果矩阵右下角的值,我们要用第一个矩阵的第二行和第二个矩阵的第二列(注:简单来说就是结果矩阵的元素的行取决于第一个矩阵,列取决于第二个矩阵)。
计算一项的结果值的方式是先计算左侧矩阵对应行
和右侧矩阵对应列
的第一个元素之积
,然后是第二个,第三个,第四个等等,然后把所有的乘积相加,这就是结果了。现在我们就能解释为什么左侧矩阵的列数必须和右侧矩阵的行数相等了,如果不相等这一步的运算就无法完成了!
结果矩阵的维度是(n, m),n等于左侧矩阵的行数,m等于右侧矩阵的列数。
我们用一个更大的例子来结束对矩阵相乘的讨论。试着使用颜色来寻找规律。作为一个有用的练习,你可以试着自己解答一下这个乘法问题,再将你的结果和图中的这个进行对比(如果用笔计算,你很快就能掌握它们)。
可以看到,矩阵相乘非常繁琐而容易出错(这也是我们通常让计算机做这件事的原因),而且当矩阵变大以后很快就会出现问题。
矩阵与向量相乘
目前为止,通过这些教程我们已经相当了解向量了。我们用向量来表示位置
,表示颜色
,甚至是纹理坐标
。让我们更深入了解一下向量
,它其实就是一个N×1矩阵
,N表示向量分量的个数(也叫N维(N-dimensional)向量)
。如果你仔细思考一下就会明白。向量
和矩阵
一样都是一个数字序列
,但它只有1列
。那么,这个新的定义对我们有什么帮助呢?如果我们有一个M×N
矩阵,我们可以用这个矩阵乘以我们的N×1向量
,因为这个矩阵的列数
等于向量的行数
,所以它们就能相乘
。
很多有趣的2D/3D变换都可以放在一个矩阵中,用这个矩阵乘以我们的向量将变换(Transform)
这个向量。
单位矩阵
在OpenGL中,由于某些原因我们通常使用4×4
的变换矩阵
,而其中最重要的原因就是大部分的向量都是4
分量的。我们能想到的最简单的变换矩阵就是单位矩阵(Identity Matrix)
。
单位矩阵是一个除了对角线以外都是0
的N×N
矩阵。
在下式中可以看到,这种变换矩阵使一个向量完全不变:
$$
\left[
\begin
\textcolor1 & \textcolor0 & \textcolor0 & \textcolor
\textcolor0 \0 & \textcolor1 & \textcolor0 & \textcolor
\textcolor0 \0 & \textcolor0 & \textcolor1 & \textcolor
\textcolor0 \0 & \textcolor0 & \textcolor0 & \textcolor1
\end
\right] *
\left[
\begin
4
\end1 \2 \3 \
\right] =
\left[
\begin
\textcolor
\textcolor1* 1 \
\textcolor1 *2 \
\textcolor1 * 3 \1 * 4
\end
\right] =
\left[
\begin
4
\end1 \2 \3 \
\right]
$$
向量看起来完全没变。从乘法法则来看就很容易理解来:第一个结果元素是矩阵的第一行的每个元素乘以向量的每个对应元素。因为每行的元素除了第一个都是0,可得:$\textcolor11+\textcolor02+\textcolor03+\textcolor04=1$,向量的其他3个元素同理。
缩放
对一个向量进行缩放(Scaling)
就是对向量的长度进行缩放,而保持它的方向不变。由于我们进行的是2维或3维操作,我们可以分别定义一个有2或3个缩放变量的向量,每个变量缩放一个轴(x、y或z)。
我们先来尝试缩放向量$\textcolor{\overline}$=(3,2)
。我们可以把向量沿着x
轴缩放0.5
,使它的宽度缩小为原来的二分之一;我们将沿着y轴把向量的高度缩放为原来的两倍。我们看看把向量缩放(0.5, 2)
倍所获得的$\textcolor{\overline}$是什么样的:
记住,OpenGL通常是在3D空间进行操作的,对于2D的情况我们可以把z轴缩放1倍,这样z轴的值就不变了。我们刚刚的缩放操作是不均匀(Non-uniform)缩放
,因为每个轴的缩放因子(Scaling Factor)都不一样。如果每个轴的缩放因子都一样那么就均匀缩放(Uniform Scale)
。
我们下面会构造一个变换矩阵
来为我们提供缩放功能。我们从单位矩阵了解到,每个对角线元素会分别与向量的对应元素相乘。如果我们把1变为3会怎样?这样子的话,我们就把向量的每个元素乘以3了,这事实上就把向量缩放3倍。如果我们把缩放变量表示为$(\textcolor,\textcolor,\textcolor)$我们可以为任意向量$(x,y,z)$定义一个缩放矩阵:
$$
\left[
\begin
\textcolor & \textcolor0 & \textcolor0 & \textcolor
\textcolor0 \0 & \textcolor & \textcolor0 & \textcolor
\textcolor0 \0 & \textcolor0 & \textcolor & \textcolor
\textcolor0 \0 & \textcolor0 & \textcolor0 & \textcolor
\end
\right] *
\begin
\endx \ y \ z \ 1 =
\begin
\textcolor * x \ \textcolor * y \ \textcolor
\end * z \ 1
$$
注意,第四个缩放向量仍然是1,因为在3D空间中缩放w分量是无意义的。w分量另有其他用途。
位移
位移(Translation)
是在原始向量
的基础上加上另一个向量从而获得一个在不同位置的新向量的过程,从而在位移向量基础上移动了原始向量。我们已经讨论了向量加法,所以这应该不会太陌生。
和缩放矩阵一样,在4×4
矩阵上有几个特别的位置用来执行特定的操作,对于位移来说它们是第四列最上面的3个值。如果我们把位移向量表示为$(\textcolor,\textcolor,\textcolor)$,我们就能把位移矩阵定义为:
$$
\left[
\begin
\textcolor{1} & \textcolor0 & \textcolor0 & \textcolor
\textcolor \0 & \textcolor{1} & \textcolor0 & \textcolor
\textcolor \0 & \textcolor0 & \textcolor{1} & \textcolor
\textcolor \0 & \textcolor0 & \textcolor0 & \textcolor{1}
\end
\right] *
\begin
\endx \ y \ z \ 1 =
\begin
x + \textcolor \ y + \textcolor \ z + \textcolor
\end\ 1
$$
这样是能工作的,因为所有的位移值都要乘以向量的w行,所以位移值会加到向量的原始值上(想想矩阵乘法法则)。而如果你用3x3矩阵我们的位移值就没地方放也没地方乘了,所以是不行的。
齐次坐标(Homogeneous Coordinates)
向量的w分量也叫齐次坐标
。想要从齐次向量得到3D向量,我们可以把x
、y
和z
坐标分别除以w
坐标。我们通常不会注意这个问题,因为w
分量通常是1.0
。
使用齐次坐标
有几点好处:它允许我们在3D向量上进行位移(如果没有w
分量我们是不能位移向量的)。
如果一个向量的齐次坐标
是0
,这个坐标就是方向向量(Direction Vector)
,因为w
坐标是0
,这个向量就不能位移(注:这也就是我们说的不能位移一个方向)。
有了位移矩阵我们就可以在3个方向(x、y、z)
上移动物体,它是我们的变换工具箱中非常有用的一个变换矩阵。
旋转
首先我们来定义一个向量的旋转到底是什么。2D或3D空间中的旋转用角(Angle)
来表示。角可以是角度制
或弧度制
的,周角
是360角度
或2PI弧度
。
大多数旋转函数需要用弧度制的角,但幸运的是角度制的角也可以很容易地转化为弧度制的:
- 弧度转角度:
角度 = 弧度 * (180.0f / PI)
。 - 角度转弧度:
弧度 = 角度 * (PI / 180.0f)
。
PI
约等于3.14159265359。
转半圈会旋转360/2 = 180度
,向右旋转1/5圈
表示向右旋转360/5 = 72度
。
下图中展示的2D向量$\textcolor{\overline}$是由$\textcolor{\overline}$向右旋转72度
所得的:
在3D空间中旋转需要定义一个角
和一个旋转轴(Rotation Axis)
。物体会沿着给定的旋转轴
旋转特定角度
。如果你想要更形象化的感受,可以试试向下看着一个特定的旋转轴,同时将你的头部旋转一定角度。当2D向量在3D空间中旋转时,我们把旋转轴设为z轴(尝试想象这种情况)。
使用三角学,给定一个角度,可以把一个向量变换为一个经过旋转的新向量。这通常是使用一系列正弦
和余弦函数(一般简称sin和cos)
各种巧妙的组合得到的。
旋转矩阵
在3D空间中每个单位轴都有不同定义,旋转角度用$\theta$表示:
沿x轴旋转:
$$
\left[
\begin
\textcolor{1} & \textcolor0 & \textcolor0 & \textcolor
\textcolor{0} \0 & \textcolor{\cos\theta} & -\textcolor{\sin\theta} & \textcolor
\textcolor{0} \0 & \textcolor{\sin\theta} & \textcolor{\cos\theta} & \textcolor
\textcolor{0} \0 & \textcolor0 & \textcolor0 & \textcolor{1}
\end
\right] *
\begin
\endx \ y \ z \ 1 =
\begin
x \ \textcolor{\cos\theta} * y - \textcolor{\sin\theta} * z \ \textcolor{\sin\theta} * y + \textcolor
\end{\cos\theta} * z\ 1
$$
沿y轴旋转:
$$
\left[
\begin
\textcolor{\cos\theta} & \textcolor0 & \textcolor{\sin\theta} & \textcolor
\textcolor{0} \0 & \textcolor{1} & \textcolor{0} & \textcolor
-\textcolor{0} \{\sin\theta} & \textcolor{0} & \textcolor{\cos\theta} & \textcolor
\textcolor{0} \0 & \textcolor0 & \textcolor0 & \textcolor{1}
\end
\right] *
\begin
\endx \ y \ z \ 1 =
\begin
\textcolor{\cos\theta} * x + \textcolor
-\textcolor{\sin\theta} * z \y \{\sin\theta} * x + \textcolor
\end{\cos\theta} * z\ 1
$$
沿z轴旋转:
$$
\left[
\begin
\textcolor{\cos\theta} & -\textcolor{\sin\theta} & \textcolor{0} & \textcolor
\textcolor{0} \{\sin\theta} & \textcolor{\cos\theta} & \textcolor{0} & \textcolor
\textcolor{0} \{0} & \textcolor{0} & \textcolor{1} & \textcolor
\textcolor{0} \0 & \textcolor0 & \textcolor0 & \textcolor{1}
\end
\right] *
\begin
\endx \ y \ z \ 1 =
\begin
\textcolor{\cos\theta} * x - \textcolor
\textcolor{\sin\theta} * y \{\sin\theta} * x + \textcolor
\end{\cos\theta} * y \z \ 1
$$
利用旋转矩阵
我们可以把我们的位置向量沿一个单位轴进行旋转
。也可以把多个矩阵结合起来,比如先沿着x轴旋转再沿着y轴旋转。但是这会很快导致一个问题——万向节死锁(Gimbal Lock)
。我们不会讨论它的细节,但是一个更好的解决方案是沿着任意轴比如(0.662, 0.2, 0.7222)
(注意这是个单位向量
)旋转,而不是使用一系列旋转矩阵的组合。
这样一个(超级麻烦的)矩阵是存在的,见下面这个公式,$(\textcolor,\textcolor,\textcolor)$
代表任意旋转轴:
$$
\left[
\begin
\cos\theta + \textcolor{}2(1 - \cos\theta) & \textcolor \textcolor(1 - \cos\theta) - \textcolor\sin\theta & \textcolor\textcolor(1 - \cos\theta) + \textcolor
\textcolor\sin\theta & 0 \\textcolor(1 - \cos\theta) + \textcolor\sin\theta & \cos\theta + \textcolor2(1 - \cos\theta) & \textcolor\textcolor(1 - \cos\theta) - \textcolor\sin\theta & \textcolor
\textcolor{0} \\textcolor(1 - \cos\theta) - \textcolor\sin\theta & \textcolor\textcolor(1 - \cos\theta) + \textcolor\sin\theta & \cos\theta + \textcolor^2(1 - \cos\theta) & \textcolor
\textcolor{0} \0 & \textcolor0 & \textcolor0 & \textcolor{1}
\end
\right]
$$
避免万向节死锁的真正解决方案是使用四元数(Quaternion),它不仅安全,而且计算更加友好。
矩阵组合
使用矩阵进行变换的真正力量在于,根据矩阵之间的乘法,我们可以把多个变换组合到一个矩阵中。让我们看看我们是否能生成一个变换矩阵,让它组合多个变换。
假设我们有一个顶点(x, y, z)
,我们希望将其缩放2
倍,然后位移(1, 2, 3)
个单位。我们需要一个位移和缩放矩阵来完成这些变换。结果的变换矩阵看起来像这样:
注意,当矩阵相乘时我们先写位移(第一个括号内是位移)再写缩放变换(第二个括号内是缩放,第三个括号就是位移与缩放组合的)的。矩阵乘法是不遵守交换律的,这意味着它们的顺序很重要。当矩阵相乘时,在最右边的矩阵是第一个与向量相乘的,所以你应该从右向左读这个乘法。建议您在组合矩阵时,先进行缩放操作,然后是旋转,最后才是位移,否则它们会(消极地)互相影响。比如,如果你先位移再缩放,位移的向量也会同样被缩放(译注:比如向某方向移动2米,2米也许会被缩放成1米)!
用最终的变换矩阵左乘我们的向量会得到以下结果:
$$
\left[
\begin
\textcolor{2} & \textcolor{0} & \textcolor{0} & \textcolor
\textcolor{1} \{0} & \textcolor{2} & \textcolor{0} & \textcolor
\textcolor{2} \{0} & \textcolor{0} & \textcolor{2} & \textcolor
\textcolor{3} \0 & \textcolor0 & \textcolor0 & \textcolor{1}
\end
\right] *
\left[
\begin
1
\endx \y \z \
\right] =
\left[
\begin
\textcolor{2}x + \textcolor
\textcolor{1} \{2}y + \textcolor
\textcolor{1} \{2}z + \textcolor
1
\end{3} \
\right]
$$
不错!向量先缩放2倍
,然后位移了(1, 2, 3)个单位
。
实践
OpenGL没有自带任何的矩阵和向量知识,所以我们必须定义自己的数学类和函数。幸运的是,有个易于使用,专门为OpenGL量身定做的数学库,那就是GLM。
GLM:是OpenGL Mathematics
的缩写,它是一个只有头文件
的库,也就是说我们只需包含对应的头文件(文件最里面的glm文件)
就行了,不用链接和编译
。GLM可以在它们的官网上下载。
, 建议下载0.9.6前版本的。
把头文件的根目录(文件最里面的glm文件)复制到你的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
函数来完成这个工作的(然后用给定的矩阵乘以位移矩阵
就能获得最后需要的矩阵
)。
之后我们把向量乘以位移矩阵
并且输出最后的结果。如果你仍记得位移矩阵是如何工作的话,得到的向量应该是(1 + 1, 0 + 1, 0 + 0)
,也就是(2, 1, 0)
。这个代码片段将会输出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)
将角度
转换为弧度
。
下一个大问题是:如何把矩阵传递给着色器?我们在前面简单提到过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);
}
GLSL也有mat2
和mat3
类型从而允许了像向量一样的混合运算。
在把位置向量传给gl_Position
之前,我们先添加一个uniform
,并且将其与变换矩阵相乘
。我们的箱子现在应该是原来的二分之一大小并(向左)旋转了90度
。当然,我们仍需要把变换矩阵
传递给着色器
:
GLuint transformLoc = glGetUniformLocation(ourShader.Program, "transform");
glUniformMatrix4fv(transformLoc, 1, GL_FALSE, glm::value_ptr(trans));
我们首先查询uniform
变量的地址,然后用有Matrix4fv
后缀的glUniform
函数把矩阵数据发送给着色器。
glUniformMatrix4fv
:将数据传进uniform
定义的数据。
- 第一个参数:得到的索引/位置值(
glGetUniformLocation
返回)。 - 第二个参数:告诉OpenGL我们将要发送多少个矩阵,这里是
1
。 - 第三个参数:询问我们我们是否希望对我们的
矩阵进行置换(Transpose)
,也就是说交换我们矩阵的行和列。OpenGL开发者通常使用一种内部矩阵布局,叫做列主序(Column-major Ordering)布局
。GLM
的默认布局就是列主序
,所以并不需要置换矩阵
,我们填GL_FALSE
。 - 第四个参数:需要真正的矩阵数据,但是
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,(GLfloat)glfwGetTime() * 50.0f, glm::vec3(0.0f, 0.0f, 1.0f));
要记住的是前面的例子中我们可以在任何地方声明变换矩阵,但是现在我们必须在每一次迭代中创建它,从而保证我们能够不断更新旋转角度。这也就意味着我们不得不在每次游戏循环的迭代中重新创建变换矩阵。通常在渲染场景的时候,我们也会有多个需要在每次渲染迭代中都用新值重新创建的变换矩阵。
在这里我们先把箱子围绕原点(0, 0, 0)旋转,之后,我们把旋转过后的箱子位移到屏幕的右下角。记住,实际的变换顺序应该与阅读顺序相反:尽管在代码中我们先位移再旋转,实际的变换却是先应用旋转再是位移的。明白所有这些变换的组合,并且知道它们是如何应用到物体上是一件非常困难的事情。只有不断地尝试和实验这些变换你才能快速地掌握它们。
完整代码
#include <glew.h>
#include <glfw3.h>
#include <iostream>
#include <SOIL2.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
//顶点着色器
const char* vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 position;\n"
"layout (location = 1) in vec3 color;\n"
"layout (location = 2) in vec2 texCoord;\n"
"out vec3 ourColor;\n"
"out vec2 TexCoord;\n"
"uniform mat4 transform;\n"
"void main()\n"
"{\n"
" gl_Position = transform * vec4(position, 1.0f);\n"
" ourColor = color;\n"
" TexCoord = vec2(texCoord.x, 1.0 - texCoord.y);\n"
"}\n";
//片段着色器
const char* fragmentShaderSource = "#version 330 core\n"
"in vec3 ourColor;\n"
"in vec2 TexCoord;\n"
"out vec4 color;\n"
"uniform sampler2D ourTexture1;\n"
"uniform sampler2D ourTexture2;\n"
"void main()\n"
"{\n"
" color = mix(texture(ourTexture1, TexCoord), texture(ourTexture2, TexCoord), 0.2);\n"
"}\n";
int main()
{
glfwInit(); //必须要将glfw初始化
//告诉GLFW使用OpenGL版本
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); //主版本号
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); //次版本号
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); //使用的是OpenGL核心模式
glfwWindowHint(GLFW_RESIZABLE, GL_FALSE); //不允许调整窗口大小
//创建窗口
GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", nullptr, nullptr);
if (window == nullptr) {
std::cout << "Failed to create GLFW Window" << std::endl;
glfwTerminate(); //销毁窗口与数据
return -1;
}
glfwMakeContextCurrent(window); //将OpenGL指向为当前窗口
glewExperimental = GL_TRUE; //用于告知GLEW使用现化OpenGL技术
//glew初始化
if (glewInit() != GLEW_OK) {
std::cout << "Failed to initialize GLEW" << std::endl;
return -1;
}
//视口
int width = 800, height = 600;
glfwGetFramebufferSize(window, &width, &height); //设置OpenGL渲染窗口的尺寸
glViewport(0, 0, width, height); //设置窗口的维度 前两个参数控制窗口左下角的位置, 第三、四个参数控制渲染窗口的宽度和高度
//顶点着色器
GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, nullptr);
glCompileShader(vertexShader);
GLint vertexSuccess;
GLchar vertexInfoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &vertexSuccess);
if (!vertexSuccess) {
glGetShaderInfoLog(vertexShader, 512, nullptr, vertexInfoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << vertexInfoLog << std::endl;
}
//片段着色器
GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, nullptr);
glCompileShader(fragmentShader);
GLint fragmentSuccess;
GLchar fragmentInfoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &fragmentSuccess);
if (!fragmentSuccess) {
glGetShaderInfoLog(vertexShader, 512, nullptr, fragmentInfoLog);
std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << fragmentInfoLog << std::endl;
}
//着色器链接程序
GLuint shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
GLint programSuccess;
GLchar programInfoLog[512];
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &programSuccess);
if (!programSuccess) {
glGetProgramInfoLog(shaderProgram, 512, nullptr, programInfoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << programInfoLog << std::endl;
}
GLfloat vertices[] = {
//-----位置 -----颜色 ---纹理坐标
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, //右上
0.5f, -0.5f, 0.f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, //右下
-0.5f, -.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, //左下
-0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f //右上
};
//顶点索引
GLuint indices[] = {
0, 1, 3,
1, 2, 3
};
//顶点数据
GLuint VBO, EBO, VAO;
glGenBuffers(1, &VBO); //顶点缓冲对象
glGenBuffers(1, &EBO); //索引缓冲数组
glGenVertexArrays(1, &VAO); //顶点数组对象
glBindVertexArray(VAO);
//顶点数据
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
//顶点索引
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
//顶点属性
//顶点坐标
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (GLvoid*)0);
glEnableVertexAttribArray(0);
//颜色
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(GLfloat), (GLvoid*)(3 * sizeof(GLfloat)));
glEnableVertexAttribArray(1);
//纹理坐标
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(GLfloat), (GLvoid*)(6 * sizeof(GLfloat)));
glEnableVertexAttribArray(2);
//纹理1--------------
//加载纹理
int textureWidht, textureHeight;
unsigned char* image = SOIL_load_image("container.jpg", &textureWidht, &textureHeight, 0, SOIL_LOAD_RGB);
//生成纹理
GLuint texture1;
glGenTextures(1, &texture1);
glBindTexture(GL_TEXTURE_2D, texture1);
//环绕方式-WRAP,默认环绕方式-重复纹理图形GL_REPEAT
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); //对应X轴
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); //对应Y轴
//过滤方式。缩小(GL_TEXTURE_MIN_FILTER)和放大(GL_TEXTURE_MAG_FILTER)都采用线性过滤(GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); //缩小
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); //放大
//生成纹理
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, textureWidht, textureHeight, 0, GL_RGB, GL_UNSIGNED_BYTE, image);
//多级渐远纹理
glGenerateMipmap(GL_TEXTURE_2D);
//加载纹理2---------------
image = SOIL_load_image("awesomeface.png", &textureWidht, &textureHeight, 0, SOIL_LOAD_RGB);
//生成纹理
GLuint texture2;
glGenTextures(1, &texture2);
glBindTexture(GL_TEXTURE_2D, texture2);
//环绕方式-WRAP,默认环绕方式-重复纹理图形GL_REPEAT
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); //对应X轴
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); //对应Y轴
//过滤方式。缩小(GL_TEXTURE_MIN_FILTER)和放大(GL_TEXTURE_MAG_FILTER)都采用线性过滤(GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); //缩小
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); //放大
//生成纹理
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, textureWidht, textureHeight, 0, GL_RGB, GL_UNSIGNED_BYTE, image);
//多级渐远纹理
glGenerateMipmap(GL_TEXTURE_2D);
//解除绑定
SOIL_free_image_data(image); //释放图像资源
glBindTexture(GL_TEXTURE_2D, 0); //解除纹理绑定
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
while (!glfwWindowShouldClose(window)) {
//检查GLFW是否退出,即窗口是否关闭了,true代表结束了
glfwPollEvents(); //检查有没有事件发生(键盘输入、鼠标移动),如发生调用对应的回调函数 键盘事件:glfwSetKeyCallback(window, key_callback); key_callback即设定的回调函数
glClearColor(0.2f, 0.3f, 0.3f, 1.0f); //清空屏幕所用的颜色,即清除颜色缓冲之后,整个颜色缓冲都会被填充为glClearColor里所设置的颜色。
glClear(GL_COLOR_BUFFER_BIT); //清空屏幕缓冲,这里是颜色缓冲
//渲染指令
//glBindTexture(GL_TEXTURE_2D, texture);
glUseProgram(shaderProgram);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);
glUniform1i(glGetUniformLocation(shaderProgram, "ourTexture1"), 0);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);
glUniform1i(glGetUniformLocation(shaderProgram, "ourTexture2"), 1);
//平移旋转
/*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));*/
//平移随时间旋转
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));
GLuint transformLoc = glGetUniformLocation(shaderProgram, "transform");
glUniformMatrix4fv(transformLoc, 1, GL_FALSE, glm::value_ptr(trans));
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
glfwSwapBuffers(window); //交换颜色缓冲,用来绘制,输出显示在屏幕上
}
glDeleteBuffers(1, &VBO);
glDeleteBuffers(1, &EBO);
glDeleteVertexArrays(1, &VAO);
glfwTerminate();
return 0;
}