OpenGL入门第三课--矩阵变换与坐标系统

4,740 阅读17分钟

       在 OpenGL中,物体在被渲染到屏幕之前需要经过一系列的坐标变换,听起来有点吓人;不过呢如果有一定的线性代数的基础利用矩阵变换,其实也就没那么难了。即使没学过线性代数,只需要了解一些基本的矩阵运算也基本可以满足大家学习 OpenGL的要求了。下面我们就来简单学习一下有关矩阵的知识。

    矩阵是一种非常有用的数学工具,虽然有点难度,但是一旦你理解了它们后,它们会变得非常有用。为了深入了解矩阵变换,我们首先要在讨论矩阵之前了解一下向量。

向量

       向量最基本的定义就是一个方向。或者更正式的说,向量有一个方向(Direction)和大小(Magnitude,也叫做强度或长度)。你可以把向量想像成一个藏宝图上的指示:“向左走10步,向北走3步,然后向右走5步”;“左”就是方向,“10步”就是向量的长度。由于向量表示的是方向,起始于何处并不会改变它的值。如下图向量v和向量w就是相等的. 

                           

        向量可以在任意维度上,一般用到的都是2维和3维,一个三维向量在公式中通常是这样表示的。入下图:

  


                                         

      由于向量是一个方向,所以有些时候会很难形象地将它们用位置(Position)表示出来。为了让其更为直观,我们通常设定这个方向的原点为(0, 0, 0),然后指向一个方向,对应一个点,使其变为位置向量(Position Vector)(你也可以把起点设置为其他的点,然后说:这个向量从这个点起始指向另一个点)。比如说位置向量(3, 5)在图像中的起点会是(0, 0),并会指向(3, 5)。

向量与标量的运算

       标量(Scalar)只是一个数字(或者说是仅有一个分量的向量)。当把一个向量加/减/乘/除一个标量,我们可以简单的把向量的每个分量分别进行该运算。对于加法来说会像这样:

                                              

      其中的+可以是+,-,·或÷,其中·是乘号。注意-和÷运算时不能颠倒(标量-/÷向量),因为颠倒的运算是没有定义的。

向量取反

     对一个向量取反(Negate)会将其方向逆转。一个指向东北的向量取反后就指向西南方向了。我们在一个向量的每个分量前加负号就可以实现取反了(或者说用-1数乘该向量):

                                       

向量相减

     向量的加法可以被定义为是分量的相加,即将一个向量中的每一个分量加上另一个向量的对应分量;就像普通数字的加减一样,向量的减法等于加上第二个向量的相反向量:

    

向量的长度

     我们使用勾股定理来获取向量的长度:

                                                         

      我们也可以加上Z的平方 把这个公式扩展到三维空间。另外有一个特殊类型的向量叫做单位向量(Unit Vector)。单位向量有一个特别的性质——它的长度是1。我们可以用任意向量的每个分量除以向量的长度得到它的单位向量。

向量相乘

     向量相乘分为点乘和叉乘:

点乘:两个向量的点乘等于它们的数乘结果乘以两个向量之间夹角的余弦值。可能听起来有点费解,我们来看一下公式。

                                   

      它们之间的夹角记作θ,这有什么用,想象一下假如是两个单位向量点乘会是什么结果?现在点积只定义了两个向量的夹角。是否还记得90度的余弦值是0,0度的余弦值是1。这样使用点乘可以很容易测试两个向量是否正交(Orthogonal)或平行(正交意味着两个向量互为直角)。

    也可以通过点乘的结果计算两个非单位向量的夹角,点乘的结果除以两个向量的长度之积,得到的结果就是夹角的余弦值,即cosθ。所以,我们该如何计算点乘呢?点乘是通过将对应分量逐个相乘,然后再把所得积相加来计算的。

叉乘:叉乘只在3D空间中有定义,它需要两个不平行向量作为输入,生成一个正交于两个输入向量的第三个向量.如果输入的两个向量也是正交的,那么叉乘之后将会产生3个互相正交的向量。下面的图片展示了3D空间中叉乘的样子:

                                

      不同于其他运算,如果你没有钻研过线性代数,可能会觉得叉乘很反直觉,所以只记住公式就没问题(记不住也没问题)。下面你会看到两个正交向量A和B叉积:

                

矩阵

       矩阵就是一个矩形的数学表达式阵列,矩阵中每一项叫做矩阵的元素(Element)。下面是一个2×3矩阵的例子:

                                             

     矩阵可以通过(i, j)进行索引,i是行,j是列,这就是上面的矩阵叫做2×3矩阵的原因。

矩阵的加减法

     矩阵与标量之间的加减定义如下:


矩阵与标量的减法也相似:


    矩阵与矩阵之间的加减就是两个矩阵对应元素的加减运算,所以总体的规则和与标量运算是差不多的,只不过在相同索引下的元素才能进行运算。这也就是说加法和减法只对同维度的矩阵才是有定义的。一个3×2矩阵和一个2×3矩阵(或一个3×3矩阵与4×4矩阵)是不能进行加减的。我们看看两个2×2矩阵是怎样相加的:


同样的法则也适用于减法:


矩阵的数乘

     和矩阵与标量的加减一样,矩阵与标量之间的乘法也是矩阵的每一个元素分别乘以该标量。下面的例子展示了乘法的过程:

 

矩阵相乘

      矩阵之间的乘法不见得有多复杂,但的确很难让人适应。矩阵乘法基本上意味着遵照规定好的法则进行相乘。当然,相乘还有一些限制:

  • 只有当左侧矩阵的列数与右侧矩阵的行数相等,两个矩阵才能相乘;
  • 矩阵相乘不遵守交换律(Commutative),也就是说A⋅B≠B⋅A;

我们先看一个两个2×2矩阵相乘的例子:


    可以看到,矩阵相乘非常繁琐而容易出错,不够不要紧,在我们OpenGL中是不会让大家自己去计算这些的,都有相应API来完成,这里只是希望大家了解一下矩阵相乘的原理。

矩阵与向量相乘

    现在我们已经相当了解向量了。在OpenGL中可以用向量来表示位置,表示颜色,甚至是纹理坐标。与矩阵类比一下,它其实就是一个N×1矩阵,N表示向量分量的个数(也叫N维(N-dimensional)向量)。仔细想一下,其实向量和矩阵一样都是一个数字序列,但它只有1列。那么,这个新的定义对我们有什么帮助呢?如果我们有一个M×N矩阵,我们可以用这个矩阵乘以我们的N×1向量,因为这个矩阵的列数等于向量的行数,所以它们就能相乘。

但是为什么我们会关心矩阵能否乘以一个向量?好吧,正巧,很多有趣的2D/3D变换都可以放在一个矩阵中,用这个矩阵乘以我们的向量将变换(Transform)这个向量。如果你仍然有些困惑,我们来看一些例子,你很快就能明白了。

单位矩阵

      在OpenGL中,由于某些原因我们通常使用4×4的变换矩阵,而其中最重要的原因就是大部分的向量都是4分量的。我们能想到的最简单的变换矩阵就是单位矩阵(Identity Matrix)。单位矩阵是一个除了对左角线是1以外其他都是0的N×N矩阵。在下式中可以看到,这种变换矩阵使一个向量完全不变:

   

     你可能会奇怪一个没变换的变换矩阵有什么用?单位矩阵通常是生成其他变换矩阵的起点,如果我们深挖线性代数,这还是一个对证明定理、解线性方程非常有用的矩阵。

缩放

    下面会构造一个变换矩阵来为我们提供缩放功能。我们从单位矩阵了解到,每个对角线元素会分别与向量的对应元素相乘。如果我们把1变为3会怎样?这样子的话,我们就把向量的每个元素乘以3了,这事实上就把向量缩放3倍。如果我们把缩放变量表示为(S1,S2,S3)我们可以为任意向量(x,y,z)定义一个缩放矩阵:

          

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

位移

    位移(Translation)是在原始向量的基础上加上另一个向量从而获得一个在不同位置的新向量的过程,从而在位移向量基础上移动了原始向量。我们已经讨论了向量加法,所以这应该不会太陌生。和缩放矩阵一样,在4×4矩阵上有几个特别的位置用来执行特定的操作,对于位移来说它们是第四列最上面的3个值。如果我们把位移向量表示为(Tx,Ty,Tz),我们就能把位移矩阵定义为:

        

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

旋转

    在3D空间中旋转需要定义一个角和一个旋转轴(Rotation Axis)。物体会沿着给定的旋转轴旋转特定角度。当2D向量在3D空间中旋转时,我们把旋转轴设为z轴。

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

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

沿x轴旋转:


沿y轴旋转:


沿z轴旋转:


    利用旋转矩阵我们可以把任意位置向量沿一个单位旋转轴进行旋转。也可以将多个矩阵复合,比如先沿着x轴旋转再沿着y轴旋转。

    是不是感觉旋转看起来好复杂,这些旋转矩阵都是怎么计算出来的,其实这些都不需要太过在意,只有理解旋转也是通过一个特定的矩阵相乘完成的就可以了。

矩阵的组合

    使用矩阵进行变换的真正力量在于,根据矩阵之间的乘法,我们可以把多个变换组合到一个矩阵中。让我们看看我们是否能生成一个变换矩阵,让它组合多个变换。假设我们有一个顶点(x, y, z),我们希望将其缩放2倍,然后位移(1, 2, 3)个单位。我们需要一个位移和缩放矩阵来完成这些变换。结果的变换矩阵看起来像这样:


    注意,当矩阵相乘时我们先写位移再写缩放变换的。矩阵乘法是不遵守交换律的,这意味着它们的顺序很重要。当矩阵相乘时,在最右边的矩阵是第一个与向量相乘的,所以你应该从右向左读这个乘法。建议您在组合矩阵时,先进行缩放操作,然后是旋转,最后才是位移,否则它们会(消极地)互相影响。比如,如果你先位移再缩放,位移的向量也会同样被缩放(译注:比如向某方向移动2米,2米也许会被缩放成1米)!

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

           

不错!向量先缩放2倍,然后位移了(1, 2, 3)个单位。

OpenGL中的坐标系

       OpenGL中顶点着色后,我们的可见顶点都为标准化设备坐标(Normailzed Device Coordinate,NDC)。也就是每个顶点的x,y,z都应该在-1到1直接,否则对我们都是不可见的。

      一个顶点在被转化为片段之前需要依次经历一下几个重要的坐标系:

  1. 局部空间(Local Space 或者称为 物体空间 Object Space)
  2. 世界空间(World Space)
  3. 观察空间 (View Space 或者称为 视觉空间 Eye Space)
  4. 裁剪空间(Clip Space)
  5. 屏幕空间 (Screen Space)

   从一个坐标系变到另外一个坐标系需要利用变换矩阵,最重要的几个分别是模型(Model)、观察(View)、投影(Projection)三个矩阵.物体顶点的起始坐标再局部空间(Local Space),这里称它为局部坐标(Local Coordinate),它在之后会变成世界坐标(world Coordinate),观测坐标(View Coordinate),裁剪坐标(Clip Coordinate),并最后以屏幕坐标(Screen Corrdinate)的形式结束.

       想要把3D图形最终渲染到2D设备屏幕上,除了使用模型变换和视变换将物体坐标转换到照相机坐标系外,还需要进行投影变换将坐标变为裁剪坐标系,然后经过透视除法变换到规范化设备坐标系(NDC),最后进行视口变换渲染到2D屏幕上,如下图:


     在上面的图中,OpenGL只定义了裁剪坐标系、规范化设备坐标系和屏幕坐标系,而局部坐标系(物体坐标系)、世界坐标系和照相机坐标系都是为了方便用户设计而自定义的坐标系。也就是说,模型变换、视变换、投影变换,这些变换可以根据开发者的需求自行定义,这些内容在顶点着色器中完成。另外的两个透视除法和视口变换,这两个步骤是OpenGL自动执行的,在顶点着色器处理后的阶段完成。

     上面的每一个变换都创建了一个变换矩阵,模型矩阵、观察矩阵和投影矩阵。讲这些矩阵组合起来,一个顶点坐标将会根据以下过程被变换到裁剪坐标:


     注意矩阵运算的顺序是相反的(记住我们需要从右往左阅读矩阵的乘法)。最后的顶点应该被赋值到顶点着色器中的gl_Position,OpenGL将会自动进行透视除法和裁剪。

    说了那么多理论,好像都不知道怎么用,下面我们来写一个简单的案例,看看如何利用矩阵变换讲3d图像到渲染到2d屏幕上。

案例

    我们首先来看一下案例效果再来说说如何实现:

                                                            

    这这个案例中我们先通过先通过平移矩阵与旋转矩阵叉乘得到模型视图矩阵,然后通过投影矩阵叉乘模型视图矩阵得到模型视图投影矩阵也就是我们常说的mvp,然后通过平面着色器画出图形。具体代码如下:

main函数一些初始化操作和回调函数的注册:

int main(int argc, char* argv[]){   
 gltSetWorkingDirectory(argv[0]);   
 glutInit(&argc, argv);   
 glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA | GLUT_DEPTH | GLUT_SINGLE);   
 glutInitWindowSize(800, 600);   
 glutCreateWindow("矩阵变换Demo");   
 glutReshapeFunc(ChangeSize);   
 glutDisplayFunc(RenderScene);   
 GLenum error = glewInit();    
 if(GLEW_OK != error) {        
   fprintf(stderr,"GLEW Error: %s\n",glewGetErrorString(error));      
   return 1;  
  }   
 setupRC();   
 glutMainLoop();   
 return 0;
}

这里有三个方法比较重要 第一个 setupRC,主要完成一些绘图前的准备工作:

void setupRC () {
    glClearColor(0.8, 0.8, 0.8, 1.0f); //设置清屏颜色  
    shaderManager.InitializeStockShaders();//初始化固定管线
    glEnable(GL_DEPTH_TEST);//开启深度测试   
    gltMakeSphere(torusBatch, 0.4, 10, 20);//形成一个球    
    glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);//设置多边形填充模式
}

    changeSize这个函数在初次窗口显示 或者其他任何时候窗口改变的时候将会被调用。主要完成视口和投影矩阵的设置,具体如下:

void ChangeSize(int w, int h){
    if(h == 0) h = 1;
    glViewport(0, 0, w, h);   
     viewFrustum.SetPerspective(35, float(w)/float(h), 1, 1000); 
 }

RenderScene函数就是具体完成绘制的函数,具体代码如下:

void RenderScene(void){    
    //清除屏幕、深度缓存区
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    //1.建立基于时间变化的动画
    static CStopWatch rotTimer;
    //当前时间 * 60s
    float yRot = rotTimer.GetElapsedSeconds() * 60.0f;
    //2.矩阵变量
    /*
     mTranslate: 平移
     mRotate: 旋转
     mModelview: 模型视图
     mModelViewProjection: 模型视图投影MVP
     */
    M3DMatrix44f mTranslate, mRotate, mModelview, mModelViewProjection;
    //创建一个4*4矩阵变量,将花托沿着Z轴负方向移动2.5个单位长度
    m3dTranslationMatrix44(mTranslate, 0.0f, 0.0f, -2.5f);
    //创建一个4*4矩阵变量,将花托在Y轴上渲染yRot度,yRot根据经过时间设置动画帧率
     m3dRotationMatrix44(mRotate, m3dDegToRad(yRot), 0.0f, 1.0f, 0.0f);
    //为mModerView 通过矩阵旋转矩阵、移动矩阵相乘,将结果添加到mModerView上
    m3dMatrixMultiply44(mModelview, mTranslate, mRotate);
    // 将投影矩阵乘以模型视图矩阵,将变化结果通过矩阵乘法应用到mModelViewProjection矩阵上
    //注意顺序: 投影 * 模型 != 模型 * 投影
     m3dMatrixMultiply44(mModelViewProjection, viewFrustum.GetProjectionMatrix(),mModelview);
    //绘图颜色
    GLfloat vBlack[] = { 0.0f, 0.0f, 0.0f, 1.0f };
    //通过平面着色器提交矩阵,和颜色。
    shaderManager.UseStockShader(GLT_SHADER_FLAT, mModelViewProjection, vBlack);
    //开始绘图
    torusBatch.Draw();
    // 交换缓冲区,并立即刷新
    glutSwapBuffers();
    glutPostRedisplay();
} 

   这里我们先创建了平移和旋转矩阵,通过叉乘 :平移矩阵 X 旋转矩阵 = 模型视图矩阵。从右往左度实际是旋转后平移,为什么要先旋转后平移在上文矩阵的组合中已经说过了。

然后通过投影矩阵叉乘模型视图矩阵得到模型视图投影矩阵(mvp),这样就通过固定管线的平面着色器需要的参数就有了,然后调用相关OpenGL API 就能顺利完成绘制。