OpenGL 向量与矩阵的应用

1,317 阅读13分钟

向量

向量的定义

在 3D 笛卡尔坐标系, ⼀个顶点就是XYZ 坐标空间上的⼀个位置。⽽在空间中给定的
⼀个位置,恰恰是由⼀个单独的 (x,y,z) 定义的. ⽽这这样的 (x,y,z) 就是向量。如图所示:


向量和标量的区别:

  • 标量只有大小
  • 向量不仅有大小、还有方向

单位向量

单位向量是长度为1的向量。

向量⻓度(向量的模)计算公式:


单位化向量

如果向量不是单位向量,而我们把它缩放到1,那么这个过程就叫做标准化,将一个向量进行标准化就是把它的模缩为1,也叫单位化向量。 


OpenGL如何定义向量 

math3d库,有2个数据类型,能够表示一个三维和四维向量。

  • M3DVector3f可以表示一个三维向量(x,y,z)。
  • M3DVector4f则可以表示一个四维向向量(x,y,z,w)。

在典型情况下,w坐标设为1.0。x,y,z值通过除以w,来进行缩放。而除以1.0则本质上不改变x,y,z值。示例代码如下:

//三维向量/四维向量的声明
typedef float M3DVector3f[3]; typedef float M3DVector4f[4];
//声明一个三维向量 M3DVector3f:类型 vVector:变量名 
M3DVector3f vVector;
//声明一个四维向量并初始化一个四维向量 
M3DVector4f vVertex = {0,0,1,1};
//声明一个三分量顶点数组,例如⽣成⼀个三角形 
M3DVector3f vVerts[] = {
-0.5f,0.0f,0.0f, 0.5f,0.0f,0.0f, 0.0f,0.5f,0.0f
};

向量点乘(dot product)

两个(三分量)单位向量之间的点乘运算将得到一个标量(只有一个值),它表示两个向量之间的夹角。要进行这种运算,这两个向量必须为单位长度,而返回的结果将在-1.0到+1.0范围之内。这个数字实际上就是这两个向量之间夹角的余弦值。如图所示:

OpenGL中针对相应点乘,提供了两个API:

m3dDotProduct3:获得2个向量量之间的点乘结果,即余弦值 = cosαm3dGetAngleBetweenVector3:获取2个向量之间夹⻆的角度,即α = arccos(余弦值)

向量叉乘(cross product)

两个向量之间叉乘所得的结果是另外一个向量,这个新向量与原来两个向量定义的平面垂直,也叫平面的法线。要进行叉乘,这两个向量都不必为单位向量。如图所示为两个向量V1和V2,以及它们的叉乘结果V3。


前提: 2个普通向量
动作: 向量与向量叉乘
结果: 向量(垂直于原来2个向量定义的平⾯的向量)
和点乘不同,在进行叉乘时向量的顺序是非常重要的。如上图所示,V3是V2和V1进行叉乘得到的结果。如果调换V1和V2的顺序,那么结果得到V3的将会指向与原来相反的方向。

OpenGL中针对向量叉乘也提供了对应的API

m3dCrossProduct3对两个向量进行叉乘 并返回运算得到的结果向量。 

矩阵

一个矩阵不必是正方形的,但是矩阵中每一行(或每一列)的元素数都必须和其他行(或列)
的元素数相同。如下图示例都表示一个矩阵。


矩阵分类


要点总结:
  • 行优先矩阵:一行一行读取
  • 列优先矩阵:一列一列读取
  • 两者的关系为:行优先矩阵经过转置 即可的到列优先矩阵

单元矩阵(Matrix)

要点总结:

  • 对角线上元素都是1,其余元素都是0的矩阵就是单元矩阵
  • 向量 X 单元矩阵 = 向量 X 1,不会发生任何变化
  • 向量与单元矩阵相乘的必要条件:向量的列数等于单元矩阵的行数

初始化一个单元矩阵的三种方式:

  • 单元矩阵初始化⽅方式①
GLFloat m[] = {
  1,0,0,0, //X Column
  0,1,0,0, //Y Column
  0,0,1,0, //Z Column
  0,0,0,1 // Translation
}
  • 单元矩阵初始化⽅方式②
M3DMatrix44f m = {
  1,0,0,0, //X Column
  0,1,0,0, //Y Column
  0,0,1,0, //Z Column
  0,0,0,1 // Translation
}  
  • 单元矩阵初始化⽅方式③
void m3dLoadIdentity44f(M3DMatrix44f m);

OpenGL中的矩阵

许多矩阵库都定义了一个二维矩阵作为C语言中的二维数组

typedef float M3DMatrix33f[9]; typedef float M3DMatrix44f[16];

OpenGL的约定⾥,更多倾向使⽤⼀维数组; 这样做的原因是: OpenGL 使⽤的是 Column-Major(以列为主)矩阵排序的约定;

OpenGL中的矩阵都是4*4的矩阵,如图:


  • 列向量进行了特殊的标注,表示这是以列为主的矩阵,主要体现为矩阵的最后一行都是0,只有最后一个元素为1

理解OpenGL中的矩阵相乘

线性代数⻆度

在线性代数数学的维度,为了便于书写. 所以坐标计算. 都是从左往右顺序,进⾏计算. 如下列公式:
  • 变换后顶点向量 = V_local * M_model * M_view * M_pro
  • 变换后顶点向量 = 顶点 ✖ 模型矩阵 ✖ 观察矩阵 ✖ 投影矩阵

上述mvp矩阵计算:顶点是行向量,要满足矩阵相乘的条件(叉乘),mvp矩阵必须放在右边,属于右乘。如图:


OpenGL角度

OpenGL中的矩阵规定是以列为主,所以顶点以列向量的方式表示
  • 变换后顶点向量 = M_pro * M_model * M_view  * V_local
  • 变换后顶点向量 = 投影矩阵 ✖ 模型矩阵 ✖ 观察矩阵 ✖ 顶点
从OpenGL角度理解mvp矩阵的计算,由于顶点是列向量,如果项进行矩阵规则,就需要满足矩阵相乘的条件,需要将mvp矩阵的顺序颠倒为pvm,且放在列向量的左边,属于左乘。如图:

OpenGL中实现矩阵左乘:

inline void MultMatrix(const M3DMatrix44f mMatrix) {
    M3DMatrix44f mTemp;
    m3dCopyMatrix44(mTemp, pStack[stackPointer]);
    m3dMatrixMultiply44(pStack[stackPointer], mTemp, mMatrix);
}
分析得知矩阵相乘主要有三步:
  1. 从栈顶获取栈顶矩阵 复制到mTemp
  2. 将栈顶矩阵mTemp 左乘mMatrix
  3. 将结果放回栈顶空间⾥

回顾之前Demo里的矩阵变化代码:


可以得知在实际的代码中,mvp矩阵的计算顺序是pvm,最后再将mvp矩阵与顶点矩阵相乘,得到物体变换后的顶点和位置

  • ChangeSize函数中,得到投影矩阵,将投影矩阵压入投影矩阵堆栈栈顶,并与模型视图矩阵栈顶相乘,将结果覆盖栈顶,即 投影矩阵 * 单元矩阵 = 投影矩阵
  • RenderScene函数中,将栈顶矩阵copy一份,然后将观察者矩阵与模型视图矩阵堆栈栈顶相乘,其结果覆盖栈顶矩阵,即投影矩阵 * 视图矩阵 = 视图投影矩阵
  • 得到模型矩阵,将模型矩阵与栈顶矩阵相乘,其结果覆盖栈顶矩阵,即 栈顶 = 模型视图投影矩阵

OpenGL中的矩阵变换

基础变换

OpenGL中基础变化术语概述:

视觉坐标

视觉坐标是相对于观察者的视角而言的,无论可能进行何种变换,我们都可以将它们视为“绝对
的”屏幕坐标。这样,视觉坐标就表示一个虚拟的固定坐标系,它通常作为参考坐标系使用。本章讨论的所有变换都是根据它们相对于视觉坐标系的效果来描述的。


如上图所示从两个不同视点显示了视觉坐标系。在左边的图(a图)中,视觉坐标系是以场景的观察者的角度(也就是垂直于显示器的方向)。在右边的图(b图)中,视觉坐标系稍稍进行了旋转,这样就可以更好地观察z 轴的位置关系了。从观察者的角度来看,x 轴和y 轴的正方向分别指向右方和上方。z 轴的正方向从原点指向使用者,而z 轴的负方向则从观察者指向屏幕内部。

视图变换 

  • 视图变换是应用到场景中的第一种变换。它用来确定场景中的有利位置。在默认情况下,透视投影中的观察点位于原点(0,0,0),并沿着z 轴的负方向进行观察(向显示器内部“看去”)。观察点相对于视觉坐标系进行移动,来提供特定的有利位置。当观察点位于原点(0,0,0)时,就像在透视投影中一样,绘制在z 坐标为正的位置的对象则位于观察者背后。
  • 从大局上考虑,在应用任何其他模型变换之前,必须先应用视图变换。这样做是因为,对于视觉坐标系而言,视图变换移动了当前的工作坐标系。所有后续变换随后都会基于新调整的坐标系进行。然后,在实际开始考虑如何进行这些变换时,就会更容易地看到这些变换是如何实现的了。 (引用《OpenGL 超级宝典 第5版》96页)

模型变换

模型变换用于操纵模型和其中的特定对象。这些变换将对象移动到需要的位置,然后再对它们进行旋转和缩放。 

常见的模型变换:

  • 平移:将矩阵m 在x方向平移x0个单位,y方向平移y0个单位,z方向平移z0个单位后结果存放在m矩阵中。
void m3dTranslationMatrix44(M3DMatrix44f m, floata x0, float y0, float z0);

复制代码
  • 旋转:围绕某一轴旋转一定弧度
m3dRotationMatrix44(m3dDegToRad(45.0), floata x, float y, float z);
复制代码
  • 缩放 :将矩阵m 在x方向缩放xScale倍,y方向缩放yScale倍,z方向缩放zScale倍后结果存放在m矩阵中。
void m3dScaleMatrix44(M3DMatrix44f m, floata xScale, float yScale, float zScale);
复制代码
  • 综合变化: product = a ✖ b
void m3dMatrixMultiply44(M3DMatrix44f product, const M3DMatrix44f a, const M3DMatrix44f 

下图中:a-平移  b-旋转  c-缩放


注意点:
  • 当缩放的x/y/z参数传值-1时,可以实现物体围绕某一个轴的翻转

有一个小陷阱需要注意,就是运算的顺序是有影响的。例如,用一个旋转矩阵乘以一个平移矩阵,与用一个平移矩阵乘以一个旋转矩阵是不同的。

用一个正方形逐渐变换的过程举例说明

如下图(a)正方形先围绕z 轴进行旋转,然后再沿着变换后得到的新x 轴进行平移。

在图(b)中,同样的正方形首先沿着x 轴进行平移,然后再围绕z 轴进行旋转。


可以得知两者最终外观上有所不同是因为每次变换都与最后的变换结果相关,变换的顺序是不能交换的,交换后的矩阵相乘结果是不一致的。

模型视图的二元性

实际上,视图和模型变换按照它们内部效果和对场景的最终外观来说是一样的。


如上图将对象向后移动和将参考坐标系向前移动在视觉上没有区别。

投影变换

投影变换将在模型视图变换之后应用到顶点上。这种投影实际上定义了视景体并创建了裁剪平面。

投影方式主要有两种

  • 在正投影(或者说平行投影)中,所有多边形都是精确地按照指定的相对大小来在屏幕上绘制的,屏幕上物体与实物的比例是 = 1:1的。
  • 透视投影的特点就是透视缩短(foreshortening),这种特性使得远处的物体看起来比近处同样大小的物体更小一些(远小近大)。
  • 透视投影的优势在于,我们不必弄清楚线在哪里相交或远处的物体到底有多少。我们需要做的仅仅是指定适用模型视图变换的场景,然后应用透视投影矩阵。

一个简单的例子:


矩阵堆栈

因为矩阵乘法是3D图形中如此重要的组成部分,所以几乎所有程序员的工具箱中都包含了一系列函数类,用来创建和操作矩阵乘法。

矩阵堆栈的使用:

我们会使用一个矩阵堆栈来帮助完成这些工作,而GLTools库则会在math3d矩阵函数顶部建立实用类。这个类称为GLMatrixStack。这个类的构造函数允许指定堆栈的最大深度,默认的堆栈深度为64。这个矩阵堆栈在初始化时已经在堆栈中包含了单位矩阵。
//类型
GLMatrixStack::GLMatrixStack(int iStackDepth = 64); 
//在堆栈顶部载⼊⼀个单元矩阵
void GLMatrixStack::LoadIdentity(void); 
//在堆栈顶部载⼊任何矩阵
//参数:4*4矩阵
void GLMatrixStack::LoadMatrix(const M3DMatrix44f m); 
//矩阵乘以矩阵堆栈顶部矩阵,相乘结果存储到堆栈的顶部
void GLMatrixStack::MultMatrix(const M3DMatrix44f); 
//获取矩阵堆栈顶部的值 GetMatrix 函数
//为了适应GLShaderMananger的使⽤,或者获取顶部矩阵的副本
const M3DMatrix44f & GLMatrixStack::GetMatrix(void); 
void GLMatrixStack::GetMatrix(M3DMatrix44f mMatrix);//将当前矩阵压⼊堆栈(栈顶矩阵copy ⼀份到栈顶) 
void GLMatrixStack::PushMatrix(void); 
//将M3DMatrix44f 矩阵对象压⼊当前矩阵堆栈
void PushMatrix(const M3DMatrix44f mMatrix); 
//将GLFame 对象压⼊矩阵对象
void PushMatrix(GLFame &frame); 
//出栈(出栈指的是移除顶部的矩阵对象)
void GLMatrixStack::PopMatrix(void);
压栈与出栈一个矩阵的真正价值在于通过压栈操作存储一个状态,然后通过出栈操作恢复这个状态。通过GLMatrixStack类,我们可以使用PushMatrix函数将矩阵压入堆栈来存储当前矩阵值。 

矩阵堆栈中关于入栈、相乘、出栈的流程

  1. 原始矩阵堆栈中,拷贝一份栈顶矩阵,压入栈顶
  2. 当有变换操作时,变换操作的矩阵与矩阵堆栈栈顶矩阵相乘,将其结果覆盖栈顶矩阵
  3. 如果还有其他矩阵入栈,则继续相乘
  4. 当没有矩阵需要push,即图形绘制完成后,需要pop栈顶矩阵,还原原始状态

流程如图所示:


注意:push和pop操作是一一对应的,使用几次push,就需要使用几次pop还原原始状态

仿射变换

//Rotate 函数angle参数是传递的度数,⽽不是弧度void MatrixStack::Rotate(GLfloat angle,GLfloat x,GLfloat y,GLfloat z);
void MatrixStack::Translate(GLfloat x,GLfloat y,GLfloat z);
void MatrixStack::Scale(GLfloat x,GLfloat y,GLfloat z);

GLFrame

GLFrame和矩阵对应 GLFrame定义:

GLFrame
{
    protected:
        M3DVector3f vOrigin;//x 
        M3DVector3f vForward; //z
        M3DVector3f vUp; //y
}

GLFrame操作也与矩阵相似:在矩阵堆栈中存储的是GLFrame对应的矩阵

//将堆栈的顶部压⼊任何矩阵
void GLMatrixStack::LoadMatrix(GLFrame &frame);
//矩阵乘以矩阵堆栈顶部的矩阵。相乘结果存储在堆栈的顶部 void GLMatrixStack::MultMatrix(GLFrame &frame);
//将当前的矩阵压栈
void GLMatrixStack::PushMatrix(GLFrame &frame);

照相机管理

GetCameraMatrix这个函数⽤来检索条件适合的观察者矩阵,存入第一个参数中

void GetCameraMatrix(M3DMatrix44f m,bool bRotationOnly = flase);