OpenGL学习脚印: 视变换(view transformation)

写在前面
OpenGL中的坐标处理过程包括模型变换、视变换、投影变换、视口变换等内容,这个主题的内容有些多,所以分节学习,主题将分为5节内容来学习。上一节模型变换,本节学习模型变换的下一阶段——视变换。到目前位置,主要在2D下编写程序,学习了视变换后,咱们能够看到3D应用的效果了。本节示例程序都可在个人github下载css

经过本节能够了解到html

  • 视变换的概念
  • 索引绘制立方体
  • LookAt矩阵的推导(对数学不感兴趣,能够跳过)
  • 相机位置随时间改变的应用程序

坐标处理的全局过程(了解,另文详述)

OpenGL中的坐标处理包括模型变换、视变换、投影变换、视口变换等内容,具体过程以下图1所示:git

坐标处理过程

每个过程处理都有其缘由,这些内容计划将会在不一样节里分别介绍,最后再总体把握一遍。
今天咱们学习第二个阶段——视变换。github

并不存在真正的相机

OpenGL成像采用的是虚拟相机模型。在场景中你经过模型变换,将物体放在场景中不一样位置后,最终哪些部分须要成像,显示在屏幕上,主要由视变换和后面要介绍的投影变换、视口变换等决定。web

其中视变换阶段,经过假想的相机来处理矩阵计算可以方便处理。对于OpenGL来讲并不存在真正的相机,所谓的相机坐标空间(camera space 或者eye space)只是为了方便处理,而引入的坐标空间。数组

在现实生活中,咱们经过移动相机来拍照,而在OpenGL中咱们经过以相反方式调整物体,让物体以适当方式呈现出来。例如,初始时,相机镜头指向-z轴,要观察-z轴上的一个立方体的右侧面,那么有两种方式:ide

  1. 相机绕着+y轴,旋转+90度,此时相机镜头朝向立方体的右侧面,实现目的。注意这时立方体并无转动。svg

  2. 相机不动,让立方体绕着+y轴,旋转-90度,此时也能实现一样的目的。注意这时相机没有转动。完成这一旋转的矩阵记做 Ry(π2) 函数

在OpenGL中,采用方式2来完成物体成像的调整。例以下面的图表示了假想的相机:学习

相机


进一步说明

进一步说明这里相对的概念,对这个概念不感兴趣的能够跳过。默认时相机位于(0,0,0),指向-z轴,至关于调用了:

glm::lookAt(glm::vec(0.0f,0.0f,0.0f),
        glm::vec3(0.0f, 0.0f, -1.0f),
        glm::vec3(0.0f, 1.0f, 0.0f)),

获得是单位矩阵,这是相机的默认状况。

上述第一种方式,相机绕着+y轴旋转90度,相机指向-x轴,则等价于调用变为:

glm::mat4 view =glm::lookAt(glm::vec(0.0f,0.0f,0.0f),
        glm::vec3(-1.0f, 0.0f, 0.0f),
        glm::vec3(0.0f, 1.0f, 0.0f)),

获得的视变换矩阵为:

view=0010010010000001

上述第二种方式,经过立方体绕着+y轴旋转-90度,则获得的矩阵M,至关于:

glm::mat4 model = glm::rotate(glm::mat4(1.0), glm::radians(-90.0f), glm::vec3(0.0, 1.0, 0.0));

这里获得的矩阵M和上面的矩阵view是相同的,能够自行验证下。
也就是说,经过旋转相机+y轴90度,和旋转立方体+y轴-90度,最终计算获得的矩阵相同。调整相机来获得观察效果,能够经过相应的方式来调整物体达到相同的效果。在OpenGL中并不存在真正的相机,这只是一个虚构的概念。


视变换矩阵的推导(了解,对数学不感兴趣可跳过)

相机坐标系由相机位置eye和UVN基向量(或者说由forward, side ,up)构成,以下图所示:

相机坐标系
各个参数的含义以下:

  • 相机位置 也称为观察参考点 (View Reference Point) 在世界坐标系下指定相机的位置eye。
  • 相机镜头方向,由相机位置和相机指向的目标(target)位置计算出, forwrad=(targeteye)
  • 相机顶部正朝向: View Up Vector 肯定在相机哪一个方向是向上的,通常取(0, 1, 0)。这个参数稍后详细解释。

上面的图简化为:
相机参数

在使用过程当中,咱们是要指定的参数即为相机位置(eye),相机指向的目标位置(target)和viewUp vector三个参数。
Step1 : 首选计算相机镜头方向 forwrad=(targeteye) ,
进行标准化 forward=forwardforwrad
Step2: 根据view-up vector和forward肯定相机的side向量:
viewUp=viewUpviewUp
side=cross(forward,viewUp)

Step3 : 根据forward和side计算up向量:
up=cross(side,forward)
这样eye位置,以及forward、side、up三个基向量构成一个新的坐标系,注意这个坐标系是一个左手坐标系,所以在实际使用中,须要对forward进行一个翻转,利用-forward、side、up和eye来构成一个右手坐标系。

咱们的目标是计算世界坐标系中的物体在相机坐标系下的坐标,也就是从相机的角度来解释物体的坐标。从一个坐标系的坐标变换到另外一个坐标系,这就是不一样坐标系间坐标转换的过程。

计算方法1——直接计算变换矩阵

坐标和变换一节,了解到,要实现不一样坐标系之间的坐标转换,须要求取一个变换矩阵。而这个矩阵就是一个坐标系A中的原点和基在另外一个坐标系B下的表示。
咱们将相机坐标系的原点和基,使用世界坐标系表示为(s表明side基向量,u表明up基向量,f表明forward基向量):

[Camera]world=s[0]s[1]s[1]0u[0]u[1]u[2]0f[0]f[1]f[2]0eyexeyeyeyez1

如今要求取的是坐标从世界坐标系变换到相机坐标系,则计算点p在相机坐标系下表示为:
[p]camera=[World]camera[p]world=[Camera]1world[p]world=view[p]world
即求得视变换矩阵为
view=[Camera]1world=s[0]u[0]f[0]0s[1]u[1]f[1]0s[2]u[2]f[2]0dot(s,eye)dot(u,eye)dot(f,eye)1

上面计算逆矩阵的过程当中使用到了分块矩阵求逆矩阵的定理:

设方阵A、D可逆,那么分块矩阵 (A0BD) 可逆,且其逆矩阵为 T1=(A10A1BD1D1)

这种方式对应的计算代码以下:

    // 手动构造LookAt矩阵 方式1
glm::mat4 computeLookAtMatrix1(glm::vec3 eye, glm::vec3 target, glm::vec3 viewUp)
{
    glm::vec3 f = glm::normalize(target - eye); // forward vector
    glm::vec3 s = glm::normalize(glm::cross(f, viewUp)); // side vector
    glm::vec3 u = glm::normalize(glm::cross(s, f)); // up vector
    glm::mat4 lookAtMat(
        glm::vec4(s.x, u.x, -f.x, 0.0), // 第一列
        glm::vec4(s.y, u.y, -f.y, 0.0), // 第二列
        glm::vec4(s.z, u.z, -f.z, 0.0), // 第三列
        glm::vec4(-glm::dot(s, eye),
        -glm::dot(u, eye), glm::dot(f, eye), 1.0)  // 第四列
        );
    return lookAtMat;
}

这种方式求取过程当中涉及到了分块矩阵的逆矩阵计算,若是不习惯,能够看下面的方式2,这是比较经常使用的方式。

计算方法2——利用旋转和平移矩阵求逆矩阵

求取坐标转换矩阵的过程,也能够从另一个角度出发,即将世界坐标系旋转和平移至于相机坐标系重合,这样这个旋转 R 和平移 T 矩阵的组合矩阵 M=TR ,就是将相机坐标系中坐标变换到世界坐标系中坐标的变换矩阵,那么所求的视变换矩阵(世界坐标系中坐标转换到相机坐标系中坐标的矩阵) view=M1 .
其中R就是上面求得的side、up、forward基向量构成的矩阵,以下:

R=s[0]s[1]s[2]0u[0]u[1]u[2]0f[0]f[1]f[2]00001

T=000000000000eyexeyeyeyez1

那么所求的矩阵view计算过程以下:
view=(TR)1=R1T1=RTT1
在计算过程当中,使用到了旋转矩阵的性质,即旋转矩阵是正交矩阵,它的逆矩阵等于矩阵的转置。
所以所求的:
RT=s[0]u[0]f[0]0s[1]u[1]f[1]0s[2]u[2]f[2]00001

T1=000000000eyexeyeyeyez

一样计算获得视变换矩阵为:

viewRTT1  s[0]u[0]f[0]0s[1]u[1]f[1]0s[2]u[2]f[2]0dot(s,eye)dot(u,eye)dot(f,eye)1

这种方式对应的计算代码以下:

// 手动构造LookAt矩阵 方式2
glm::mat4 computeLookAtMatrix2(glm::vec3 eye, glm::vec3 target, glm::vec3 viewUp)
{
    glm::vec3 f = glm::normalize(target - eye); // forward vector
    glm::vec3 s = glm::normalize(glm::cross(f, viewUp)); // side vector
    glm::vec3 u = glm::normalize(glm::cross(s, f)); // up vector
    glm::mat4 rotate(
        glm::vec4(s.x, u.x, -f.x, 0.0), // 第一列
        glm::vec4(s.y, u.y, -f.y, 0.0), // 第二列
        glm::vec4(s.z, u.z, -f.z, 0.0), // 第三列
        glm::vec4(0.0, 0.0, 0.0, 1.0)  // 第四列
        );
    glm::mat4  translate;
    translate = glm::translate(translate, -eye);
    return rotate * translate;
}

OpenGL中视变换的实现

在OpenGL中,咱们能够经过函数glm::lookAt来实现相机指定,这个函数计算的就是上面求出的视变换矩阵。之前glu版本实现为gluLookAt,这两个函数完成的功能是同样的,参数定义以下:

API lookAt ( GLdouble eyeX, GLdouble eyeY, GLdouble eyeZ, GLdouble centerX, GLdouble centerY, GLdouble centerZ, GLdouble upX, GLdouble upY, GLdouble upZ)
其中eye指定相机位置,center指定相机指向目标位置,up指定viewUp向量。

利用GLM数学库通常实现为:

glm::mat4 view = glm::lookAt(eyePos,
    glm::vec3(0.0f, 0.0f, 0.0f), 
    glm::vec3(0.0f, 1.0f, 0.0f));

下面利用这个函数进行一些实验,以帮助理解。在设置相机参数以前,咱们学习下绘制立方体,为实验增长素材。

绘制立方体

前面索引绘制矩形一节使用了索引了矩形,若是利用索引绘制立方体,表面上看确实能够节省顶点数据,可是存在的问题是,不能为不一样面上的共同顶点指定不一样的纹理坐标,这在某些状况下会出现问题的。例以下面使用索引绘制的立方体:
索引绘制
因为在正面和侧面的顶点制定了相同的纹理坐标,插值后纹理一致,并无出现可爱的猫咪图案。为此,咱们须要为共用顶点指定不一样的顶点属性,那么解决办法之一是,继续使用顶点数组绘制方式,定义立方体的数据以下:

// 指定顶点属性数据 顶点位置 颜色 纹理
GLfloat vertices[] = {
    -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,   // A
    0.5f, -0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f,    // B
    0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f,     // C
    0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f,     // C
    -0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f,    // D
    -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,   // A

    -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f,  // E
    -0.5f, 0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 0.0, 1.0f,    // H
    0.5f, 0.5f, -0.5f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,    // G
        0.5f, 0.5f, -0.5f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,    // G
    0.5f, -0.5f, -0.5f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f,   // F
    -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f,  // E

    -0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f,    // D
    -0.5f, 0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0, 1.0f,    // H
    -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f,  // E
    -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f,  // E
    -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,   // A
    -0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f,    // D

    0.5f, -0.5f, -0.5f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f,   // F
    0.5f, 0.5f, -0.5f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,    // G
    0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f,     // C
    0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f,    // C
    0.5f, -0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f,    // B
    0.5f, -0.5f, -0.5f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f,   // F

    0.5f, 0.5f, -0.5f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,    // G
    -0.5f, 0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 0.0, 1.0f,    // H
    -0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f,    // D
    -0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f,    // D
    0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, // C
    0.5f, 0.5f, -0.5f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,    // G

    -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,   // A
    -0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f,  // E
    0.5f, -0.5f, -0.5f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f,   // F
    0.5f, -0.5f, -0.5f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f,   // F
    0.5f, -0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f,    // B
    -0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,   // A
    };

着色器使用上一节绘制矩形的着色器程序。绘制立方体后,经过设定相机位置随着时间发生改变来观察这个立方体。指定相机位置为在xoz平面圆周运动的点轨迹,代码为:

GLfloat radius = 3.0f;
GLfloat xPos = radius * cos(glfwGetTime());
GLfloat zPos = radius * sin(glfwGetTime());
glm::vec3 eyePos(xPos, 0.0f, zPos);
glm::mat4 view = glm::lookAt(eyePos,
glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));

同时在代码中指定投影方式为透视投影,代码为:

// 投影矩阵
glm::mat4 projection = glm::perspective(glm::radians(45.0f),
        (GLfloat)(WINDOW_WIDTH) / WINDOW_HEIGHT, 1.0f, 100.0f);

投影方式和投影矩阵的计算将在后面小结介绍,这里只须要知道使用方法便可。

实现绘制立方体,效果以下图所示:

须要开启深度测试

从上面的图中咱们看到了奇怪的现象,立方体后面的部分绘制在了前面的部分上,这种现象是因为深度测试(Depth Test)未开启影响的。深度测试根据物体在场景中到观察者的距离,根据设定的glDepthFunc函数断定是否经过深度测试,默认为GL_LESS,即深度小者经过测试绘制在最终的屏幕上。关于深度测试这个主题,后面会继续学习,这里再也不展开。
OpenGL中开启深度测试方法:

glEnable(GL_DEPTH_TEST);

同时在主循环中,清除深度缓冲区和颜色缓冲区:

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

开启深度测试后,旋转相机来观察立方体,效果以下:
旋转的相机

viewUp向量

上面提到了在指定相机时须要指定相机的viewUP向量,这个向量指定了相机中哪一个方向是向上的。对于相机而言,指定了相机位置eye和相机指向位置target后肯定了相机的指向,位置不变,指向不变时,仍是能够经过改变这个viewUp而影响成像的。这个相似于你眼睛的位置不变,看着的方向不变,可是你能够扭动脖子来肯定哪一个方向是向上,这个viewUp比如头顶给定的方向。相机位置固定在(0,0,3.0),指向原点,依次取viewUp为 (0,1,0),(1,0,0),(0,1,0) ,绘制立方体后的效果以下图所示:

viewUp

图中viewUp为(0,1,0)时猫的尾巴朝上,为(1,0,0)时至关于把脖子右旋转90度,看到猫的尾巴是在左边的;为(0,-1,0)至关于倒立过来看,猫的尾巴是向下的。若是想了解更多关于viewUp的解释,能够参考What exactly is the UP vector in OpenGL’s LookAt function.

更多立方体

上一节介绍了模型变换,咱们能够利用模型变换,在场景中绘制多个立方体,同时相机的位置能够采用圆的参数方程或者球面参数方程设定。绘制多个立方体的方法:

// 指定立方体位移
 glm::vec3 cubePostitions[] = {
        glm::vec3(0.0f, 0.0f, 1.2f),
        glm::vec3(0.0f, 0.0f, 0.0f),
        glm::vec3(1.2f, 1.2f, 0.0f),
        glm::vec3(-1.2f, 1.2f, 0.0f),
        glm::vec3(-1.2f, -1.5f, 0.0f),
        glm::vec3(1.2f, -1.5f, 0.0f),
        glm::vec3(0.0f, 0.0f, -1.2f),
    };
  // 在主循环中绘制立方体
for (int i = 0; i < sizeof(cubePostitions) / sizeof(cubePostitions[0]); ++i)
{
    model = glm::mat4();
    model = glm::translate(model, cubePostitions[i]);
            glUniformMatrix4fv(glGetUniformLocation(shader.programId, "model"),
        1, GL_FALSE, glm::value_ptr(model));
    glDrawArrays(GL_TRIANGLES, 0, 36);
}

对相机位置随着时间进行改变,能够采用圆的参数方程或者球面参数方程设定。这里只是做为一个示例来设定,能够根据你的具体需求设定对应角度值。示例代码以下:

// xoz平面内圆形坐标
glm::vec3 getEyePosCircle()
{
    GLfloat radius = 6.0f;
    GLfloat xPos = radius * cos(glfwGetTime());
    GLfloat zPos = radius * sin(glfwGetTime());
    return glm::vec3(xPos, 0.0f, zPos);
}
// 球形坐标 这里计算theta phi角度仅作示例演示
// 能够根据须要设定
glm::vec3 getEyePosSphere()
{
    GLfloat radius = 6.0f;
    GLfloat theta = glfwGetTime(), phi = glfwGetTime() / 2.0f;
    GLfloat xPos = radius * sin(theta) * cos(phi);
    GLfloat yPos = radius * sin(theta) * sin(phi);
    GLfloat zPos = radius * cos(theta);
    return glm::vec3(xPos, yPos, zPos);
}

例如利用球面坐标方程设定的相机位置,效果以下图所示:

球形相机坐标

最后的说明

通过视变换后,世界坐标系中坐标转换到了相机坐标系下。须要注意的相机在OpenGL中是个假想的概念,本质是经过矩阵来完成计算的。本节设定相机位置为圆周或者球面运动轨迹,并不能让用户来交互地观察场景中物体,下一节将设计一个第一人称FPS相机,让用户经过键盘和鼠标控制相机,更好地观察场景。