【Modern OpenGL】摄像机系统 Camera

说明:跟着learnopengl的内容学习,不是纯翻译,只是自己整理记录。
强烈推荐原文,无论是内容还是排版。 原文链接
本文地址:http://blog.csdn.net/aganlengzi/article/details/50448469

摄像机 Camera

在前面的教程中,我们讨论了视口矩阵和我们可以怎样利用视口矩阵让绘制的场景移动(我们在上次教程中成功将那个二维平面稍稍向后移动了一点)。OpenGL本身对摄像机这个概念并不熟悉,但是我们可以通过移动场景中所有的对象(就好像反方向移动一个摄像机一样)来模拟一个。

在本次教程中,我们将会讨论我们怎样在OpenGL中建立摄像机。我们将会创建一个帧率摄像机,允许你在三维场景中自由移动。在这个教程中,我们还会讨论一些关于键盘和鼠标输入的只是。最终会形成一个定制的摄像机类。

摄像机/视口坐标系

三维场景中的一个摄像机主要由它在世界坐标系中的位置、它的朝向一个指向右侧和一个指向上方的向量来做决定。细心的你可能已经发现了:我们实际上在利用这些量创建一个三个坐标轴相互垂直以所在位置为原点的坐标系。

1.摄像机的位置

得到一个摄像机的位置是十分简单的。摄像机的位置从根本上来说就是一个指向摄像机位置的向量。我们通过如下代码将摄像机的位置,也就是我们想要创建的坐标系的原点设置在和我们上次教程相同的位置。

glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);  

不要忘记,z轴的正方向是垂直于屏幕并且指向我们的。所以,当我们想把摄像机向后移动,实际上就是把摄像机向z轴的正方向移动,也就是增大摄像机z轴的坐标值。

2.摄像机的方向

确定了摄像机的位置(即坐标系的原点),接下来需要确定的是摄像机的朝向。目前,我们先默认摄像机指向我们世界坐标系的原点,也就是(0,0,0)。实际上我们是知道摄像机应该指向z轴的负方向的,那么它的相反方向实际上就是其位置坐标和世界坐标系原点的插值(两个向量之差决定了其方向),我们定义这个向量值为方向向量。如下面的代码所示:

glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraDirection = glm::normalize(cameraPos - cameraTarget);

实际上方向向量这个说法是不太合适的,因为这个方向向量命名是摄像机正面朝向的相反方向!

3.向右的坐标轴

按照一开始的说法,接下来需要的向量是向右的方向向量。它实际上代表的是我们想要创建的摄像机坐标系的x轴正方向。怎样获得向右指向的方向向量呢?还记的前面教程中关于向量的叉乘的内容吗?两个向量的叉乘得到的是垂直于这两个向量决定的平面的向量(满足右手坐标系)。所以我们利用2中得到的z轴正向向量和世界坐标系中的向上的方向向量做叉乘,便得到了指向右侧的方向向量,它就是我们想创建的摄像机坐标系的x轴正向。如下面代码所示:

glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f); 
glm::vec3 cameraRight = glm::normalize(glm::cross(up, cameraDirection));

4.向上的坐标轴

通过以上三步,我们已经得到了建立一个坐标系所需要的原点坐标、x轴,z轴向量。现在就是缺少y轴方向的向量了,我们通过将x轴和z轴的方向向量做叉乘的方式得到向上的坐标轴方向(也就是我们的y轴方向向量)。如下面的代码所示:

glm::vec3 cameraUp = glm::cross(cameraDirection, cameraRight);

利用上面得到的摄像机坐标系,我们现在可以创建我们的LookAt矩阵了,这个矩阵对于创建一个摄像机是十分有用和必要的。

Look At

使用矩阵的一大好处是:当你利用三个相互垂直的坐标轴建立了一个坐标系,你可以通过这三个坐标轴加上一个转换向量创建一个矩阵,这个矩阵可以将任何向量转换到你所定义的那个坐标系空间中。而具体转换的方式就是左乘这个矩阵。这也正是我们的Look At矩阵将要做的事情。我们现在就用上面得到的三个相互垂直的坐标轴和一个位置向量来定义一个摄像机空间(摄像机坐标系)。并通过定义一个矩阵(Look At矩阵)来将世界坐标系中的坐标值转换到这个空间中。如下所示:

【Modern OpenGL】摄像机系统 Camera_第1张图片

其中,R表示右向向量,U表示向上的向量,D代表方向向量,P是摄像机的位置向量。需要注意的是,这里的位置向量是上面讲的伪“方向向量”的反方向,也就是摄像机朝向的方向向量。为什么要这么做?因为我们向左移动摄像机那么世界坐标系中的对象看上去实际上是向右移动的。使用这个Look At矩阵作为我们的视口矩阵能够高效地将世界坐标系中的坐标转换到我们刚刚定义的视口坐标系中。这样,Look At矩阵也就名符其实了:它创建了一个朝向给定目标的视口矩阵。

幸运的是,GLM早就为我们封装了以上的过程。我们只需要指定一个摄像机的位置,一个目标位置和一个代表世界坐标系中向上方向的向量作为参数就可以创建这个Look At矩阵了。我们使用这个创建好的Look At矩阵作为我们的视口矩阵:

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

函数glm::LookAt的三个参数分别就是上面提到的三个参数,得到的view和我们上面通过手工一步步创建得到的矩阵是一模一样的。

在深入研究用户输入之前,让我们首先利用摄像机的旋转来产生一些酷炫的效果。我们设置场景的目标向量是(0,0,0)。

我们将我们的摄像机放在一个半径为10.0f的圆周上(这个圆周在世界坐标系的z轴和x轴决定的平面上),并且随着时间的推移,摄像机的位置不断改变(通过三角函数实现,主要是要保证摄像机总是在圆周上)。这样在每次改变后得到的矩阵作为我们的视口矩阵。然后来看效果。

GLfloat radius = 10.0f;
GLfloat camX = sin(glfwGetTime()) * radius;
GLfloat camZ = cos(glfwGetTime()) * radius;
glm::mat4 view;
view = glm::lookAt(glm::vec3(camX, 0.0, camZ), glm::vec3(0.0, 0.0, 0.0), glm::vec3(0.0, 1.0, 0.0));  

得到的效果应该是这个样子的(显示不了动画,应该看上起是围绕中心旋转的样子):

【Modern OpenGL】摄像机系统 Camera_第2张图片

可以通过设置不同圆的半径值来得到不同的效果。

漫游 Walk around

转动摄像机来得到变化角度的场景是有趣的,但是我们还可以让它更加有趣!那就是我们自己来移动摄像机(想什么时候移动就什么时候移动,想移动到哪儿就移动到哪儿)。不过我们还是像上面一样,应该建立摄像机系统。为了灵活性,先来定义一些必要的变量吧:

glm::vec3 cameraPos   = glm::vec3(0.0f, 0.0f,  3.0f);
glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, -1.0f);
glm::vec3 cameraUp    = glm::vec3(0.0f, 1.0f,  0.0f);

现在我们上面说到的Look At矩阵编程了这个样子:

view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);

还是和上面一样。我们首先向glm::lookAt函数传递进事先定义的摄像机位置向量,方向是当前的位置与事先定义的方向向量的和,这保证了无论我们怎样移动,这个摄像机都会指向目标。下面让我们通过将摄像机的位置变量cameraPos和我们的键盘输入相关联,以达到按键改变相机位置的目的。

在很早的教程中我们就已经知道了怎样使用键盘响应函数。不过之前只是实现了我们的程序应该如何相应”ESC”键,现在让我们为这个键盘响应函数添加更多的键值处理:

void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode)
{
    ...
    GLfloat cameraSpeed = 0.05f;
    if(key == GLFW_KEY_W)
        cameraPos += cameraSpeed * cameraFront;
    if(key == GLFW_KEY_S)
        cameraPos -= cameraSpeed * cameraFront;
    if(key == GLFW_KEY_A)
        cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
    if(key == GLFW_KEY_D)
        cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;  
}

每当我们按下WASD键的时候,cameraPos就会被相应改变。当我们想要前后移动的时候,我们将摄像机的位置在指向目标的连线上进行加减。当我们想要左右移动的时候,我们通过叉乘的方式获得要移动方向的方向向量,然后也是做相应的加减操作。

需要注意的是,我们在代码中对叉乘的结果进行了归一化处理,如果不这样做的话,因为我们叉乘得到的向量的模可能不一致,可能造成按一下键移动的距离不一样的效果,这不是我们想要的。

如果我们完成上面讲的相关代码,我们应该得到的效果应该是下面这个样子(额,没法呈现出来。下图是我按了几下w键的效果):

【Modern OpenGL】摄像机系统 Camera_第3张图片

就是键盘上的WASD分别代表向前向左向后向右,如果按下这些键的话,可以看到可见场景内的所有对象向前向左向后向右。

在初步试验了摄像机系统你可能已经注意到了:我们不能同时在两个方向上移动(也就是在对角线上移动)摄像机!而且,当我们按下某个方向上的按键的时候,画面会“迟疑”一下,然后才会按照应该移动的方向进行移动,好奇怪有没有。原因是:大多数的事件输入系统每次只能处理一个按键,并且它们的函数也只能在我们激活一个按键的时候才会被调用。我们的系统虽然也是这样,但是我们可以通过一些小技巧来克服这个缺点。

这个技巧的原理是:我们在按键响应函数中只对按键的按下和释放做处理。在game loop中我们检查哪一个按键被按下/释放了,并且完成相应的动作。所以,我们需要存储哪一个按键被按下或者释放的状态信息并且在游戏循环中对这些状态进行反应。首先,让我们来创建一个bool类型的数组来保存所有可能按键的状态信息:

bool keys[1024];  

然后,我们需要在按键响应函数key_callback中根据按键的情况来动态改变这些值:

if(action == GLFW_PRESS)
  keys[key] = true;
else if(action == GLFW_RELEASE)
  keys[key] = false;  

还有就是要创建响应函数,当我们检查到键的状态改变的时候做出相应的动作:

void do_movement()
{
  // Camera controls
  GLfloat cameraSpeed = 0.01f;
  if(keys[GLFW_KEY_W])
    cameraPos += cameraSpeed * cameraFront;
  if(keys[GLFW_KEY_S])
    cameraPos -= cameraSpeed * cameraFront;
  if(keys[GLFW_KEY_A])
    cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
  if(keys[GLFW_KEY_D])
    cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
}

最后,我们在game loop中调用我们上面实现的两个函数来完成之前只用一个函数完成的功能:

while(!glfwWindowShouldClose(window))
{
  // Check and call events
  glfwPollEvents();
  do_movement();  

  // Render stuff
  ...
}

现在,试试吧,我们应该能够同时在两个方向上移动了,并且也应该没有”迟疑“了吧。

移动速度 Movement speed

目前看上去我们设置了一个恒定的值来作为我们移动的速度,在理论上来看,这是正确的,但是在实际上,人们的处理器是不相同的,这就造成了了,有些人能够在一秒内绘制很多帧而有些人却只能绘制比较少的帧,每秒内绘制的帧的数量叫做帧率。当帧率比较大的时候,那么对do_movement的调用次数也就会变多,最终造成的是性能较好的机器能够产生的动作要多于或者快于性能较差的机器。这显然不是我们希望的,我们希望我们的程序能够在任何配置的机器上都能够对相同的动作产生相同的响应。

我们通过下面的方法来保证我们的程序在不同性能机器上都能产生相同的体验效果:
主要的原理是对每帧中产生的动作的速度和帧渲染时间进行同步关联。那么移动速度是以帧处理时间为参照的,多以并不会受到机器性能的影响了。

我们记录下面这两个量(作为程序的全局变量):

GLfloat deltaTime = 0.0f;   // 上一帧开始和当前帧开始的时间间隔
GLfloat lastFrame = 0.0f;   // 上一帧开始时刻

在每帧内,我们利用上面这两个量来计算新的deltaTime,供当前帧使用。

GLfloat currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;  

现在我们可以利用deltatime来计算渲染速度了:

void Do_Movement()
{
  GLfloat cameraSpeed = 5.0f * deltaTime;
  ...
}

当我们上面关于移动速度的代码加上后,得到的应该就是能够明显感觉比较平滑的效果了。我修改的代码main.cpp。

环视 Look around

只用键盘进行四个方向上的移动并不是那么有趣,尤其是我们不能够让我们环顾(类似于我们是地球环绕太阳一周那样)场景而只能够在某个方向上平移!是时候加入鼠标来达到我们想要的这个效果了!

为了能够环顾我们的场景,我们需要根据鼠标的输入来改变摄像机的cameraFront向量。但是这并不是十分简单的,因为要达到较好的效果,其中需要三角函数相关的计算。

欧拉角度 Euler angles

欧拉角的基本思想是将角位移分解为绕三个互相垂直轴的三个旋转组成的序列。这听起来复杂,其实它是非常直观的。之所以有“角位移”的说法正是因为欧拉角能用来描述任意旋转,但最有意义的是使用笛卡尔坐标系并按照一定顺序所组成的旋转序列。最常用的约定,即所谓“heading-pitch-bank”约定。在这个系统中,一个方位被定义为一个heading角,一个pitch角,和一个bank角。它的基本思想就是让物体开始于“标准”方位——就是物体坐标轴和惯性坐标轴对齐。在标准方位上,让物体作heading,pitch,bank旋转,最后物体到达我们想要描述的方位。

欧拉角由三个可以表示三维中任意旋转角度的部分组成,是由Leonhard Euler在1700s左右定义的。这三个欧拉角度分别是pitch,yaw和roll。下图分别直观展示了这三个量:

【Modern OpenGL】摄像机系统 Camera_第4张图片

第一个图中显示的是pitch,它代表了我们向上或者向下看的角度。第二个图显示的是yaw值,它代表了我们向左或者向右看的大小。第三个量是roll代表了我们的转动量,通常在航天飞机摄像机中使用。每一个欧拉角都是一个数量值,它们三个进行组合就能够表示三维空间中的任何一个转动角度。

在我们的摄像机系统中,我们只关心yaw和pitch值,所以我们不会讨论roll值的改变。基于任何给定的pitch和yaw值,我们可以将它们转换到三维空间中得到一个方向向量。这个转换过程需要相应的三角函数的的知识:

【Modern OpenGL】摄像机系统 Camera_第5张图片

如果我们定义直角三角形的斜边为1,三角函数变得简单,cos = x/h=cos x/1=cos x;sin y/h=sin y/1=sin y。利用这两个公式,我们可以根据已知的角度值得到x和y方向的长度。我们下面就是要用这种方法来计算我们方向向量的分量。

【Modern OpenGL】摄像机系统 Camera_第6张图片

我们的目标是把欧拉角转换成我们的三维坐标。实际上看懂了下面这张图就可以了:

【Modern OpenGL】摄像机系统 Camera_第7张图片

上图中斜边是1,pitch和yaw已经给出,那么pitch和yaw以及斜边决定的点的坐标就是如图所示的样子,这样,我们也就得到了我们的方向向量。它的计算方式可以通过glm的函数来完成:

direction.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw));
direction.y = sin(glm::radians(pitch));
direction.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));

以上就是我们将给定的欧拉角转换到三维坐标的方式。但是我们要怎样获得欧拉角的值呢?

鼠标输入 Mouse input

Pitch和yaw值的获取就是通过鼠标啊~鼠标的水平移动改变yaw值,垂直移动改变pitch值,就是这么简单。我们的想法是保存上一帧鼠标的位置并且在当前帧中,我们计算鼠标值的该表。根据其在水平和竖直方向上改变大小来更新我们的pitch值和yaw值。然后也就决定了我们的摄像机的移动。

那么试一下吧~
首先我们需要设置GLFW设置我们的程序能够在鼠标进入我们的程序窗口的时候捕获它,并且使它隐藏起来。我们可以通过以下简单的方式来设置:

glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);  

在这个函数调用之后,我们在程序窗口中移动鼠标的时候,它不可见并且不会超出我们的程序窗口。

为了按照上述方式计算鼠标的pitch和yaw值,我们需要设置GLFW使其监听鼠标移动事件,这和前面我们已经使用的键盘监听函数的实现实际上是差不多的。我们只要先实现一个函数,然后把这个函数通过注册函数注册到程序中就可以了。

void mouse_callback(GLFWwindow* window, double xpos, double ypos);

其中 xpos和ypos表示鼠标的当前位置。一旦我们注册了这个函数,这个函数就会在鼠标移动的时候被调用。

glfwSetCursorPosCallback(window, mouse_callback);  

上面的过程完成了鼠标数据的输入。接下来还有一些步骤要做:

  1. 计算鼠标从上一帧到这一帧的位移
  2. 根据位移更新摄像机的yaw和pitch值
  3. 限定yaw和pitch的最大最小值
  4. 计算方向向量

为了完成步骤1,我们设置了两个坐标值用于表示坐标在程序窗口的位置,实际上是一个二维坐标,初始化为窗口中心的位置。

GLfloat lastX = 400, lastY = 300;

然后,在鼠标的回调函数中我们来计算鼠标两帧之间的位移:

GLfloat xoffset = xpos - lastX;
GLfloat yoffset = lastY - ypos; // Reversed since y-coordinates range from bottom to top
lastX = xpos;
lastY = ypos;

GLfloat sensitivity = 0.05f;
xoffset *= sensitivity;
yoffset *= sensitivity;

需要注意的是我们对位移量做了一个灵敏度加权的操作,如果我们不使用这个权值的话,鼠标的移动可能会太大,这不是我们想要的效果。我们可以根据程序的实际运行结果对这个灵敏度权值进行调整。

接下来,我们将偏移量更新到欧拉角pitch和yaw上:

yaw   += xoffset;
pitch += yoffset;  

下面,我们想要对这个摄像机系统加上必要的限制,以防止用户得到奇怪的坐标值和奇怪的效果。下面设置的限定是:向上看不能看到大于89度或者小于-89度的情况(就是不能把脖子仰断或者头向下看到屁股):

if(pitch > 89.0f)
  pitch =  89.0f;
if(pitch < -89.0f)
  pitch = -89.0f;

我们并没有对yaw值进行限制,如果加上,也是十分方便的。原理同上。

最后一步是根据yaw和pitch计算作用后的方向向量:

glm::vec3 front;
front.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw));
front.y = sin(glm::radians(pitch));
front.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
cameraFront = glm::normalize(front);

最后得到的结果存放在cameraFront变量中,这是一个向量,我们在前面已经在函数中进行调用传参。

如果直接运行上面的代码,我们得到的结果是:在每次鼠标最开始进入到我们程序的窗口的时候,我们窗口中的场景会有一个比较大的跳跃。产生这种现象的原因是我们将鼠标的位置初始化为我们窗口的中心点了(还记得前面的初始化吧),但是我们鼠标进入到窗口的时候一般不会恰好是窗口的中心店,这样就会造成在第一次计算偏移量的时候的一大步跳跃。改进的方法就是将鼠标最开始进入到我们程序窗口的坐标作为鼠标的初始坐标:

if(firstMouse) // this bool variable is initially set to true
{
  lastX = xpos;
  lastY = ypos;
  firstMouse = false;
}

最终的鼠标响应函数为:

void mouse_callback(GLFWwindow* window, double xpos, double ypos)
{
    if(firstMouse)
    {
        lastX = xpos;
        lastY = ypos;
        firstMouse = false;
    }

    GLfloat xoffset = xpos - lastX;
    GLfloat yoffset = lastY - ypos; 
    lastX = xpos;
    lastY = ypos;

    GLfloat sensitivity = 0.05;
    xoffset *= sensitivity;
    yoffset *= sensitivity;

    yaw   += xoffset;
    pitch += yoffset;

    if(pitch > 89.0f)
        pitch = 89.0f;
    if(pitch < -89.0f)
        pitch = -89.0f;

    glm::vec3 front;
    front.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
    front.y = sin(glm::radians(pitch));
    front.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));
    cameraFront = glm::normalize(front);
}  

Ok!这样就好了!用我们的鼠标就可以让我们的场景不断地旋转了!代码.

缩放 Zoom

我们还想要为我们的摄像机系统再加入一点缩放功能的接口。前面的教程中我们讲到可以利用fov来定义我们可见程序窗口的尺寸。当视口变小,那么投影出来的对象也就变小了,看上去就是缩小的效果。我们想要利用鼠标转动滚轮来达到缩放的功能。像上面的键盘函数和鼠标移动函数,我们首先定义一个鼠标滚轮滚动的响应函数:

void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
  if(fov >= 1.0f && fov <= 45.0f)
    fov -= yoffset;
  if(fov <= 1.0f)
    fov = 1.0f;
  if(fov >= 45.0f)
    fov = 45.0f;
}

当滚轮滚动的时候,yoffset代表了我们垂直滚动量。我们借用这个值来修改我们全局设定的fov值,因为45.0ffov的默认值。我们设定缩放范围是1.0f到45.0f。

我们将fov作为参数传递进我们的透视投影矩阵生成函数中:

projection = glm::perspective(fov, (GLfloat)WIDTH/(GLfloat)HEIGHT, 0.1f, 100.0f); 

最后别忘了将鼠标滚轮函数注册上。

glfwSetScrollCallback(window, scroll_callback); 

这样应该就能够实现鼠标滚轮滚动来缩放场景的效果了。

原作者指出利用欧拉角实现摄像机系统还是比较low的,他还有更好的方法,好吧,我原来用的方法还没有欧拉角好。只能先膜拜了!好消息是后面他会讲到更好的方法。

摄像机类 Camera class

在后面的教程中,我们会经常使用改变摄像机的参数来达到我们想要的效果。但是正像这次教程讲的这整个过程,摄像机系统的建立还是比较复杂的。所以原作者封装了一个摄像机类以方便后面的使用。这是代码。

像前面的shader类一样,这个摄像机类也是只有一个头文件,我们想要使用的时候,直接包含这个头文件就好了,其中的代码相信完整看过上面步骤的人都能够很轻松看懂。

利用这个摄像机类(头文件)完成的上面相同效果的整个代码在这儿。

你可能感兴趣的:(OpenGL)