本文主要解决一个问题:
如何创建一个FPS摄像机?
1.引言
在前一章中,我们讨论了观察矩阵以及如何使用变换矩阵移动场景(虽然仅仅是往后移了一点点)。本章中,我们要创建一个类似FPS的摄像机,它可以移动,可以转头,可以变焦(狙击枪里开放大镜效果)。
在这章中,你会看到
- 观察空间变换的内部原理
- 键盘操纵摄像机前后左右移动的方法
- 鼠标操纵摄像机上下左右转动的方法
- 实现变焦的方式
- 将摄像机功能封装成类(该死,好久没这么有创造性的封装一个类了,码农当太久脑子都秀逗了。)
2.观察(摄像机)空间
就像前一章说的那样,观察空间其实是以摄像机为原点,以摄像机观察的方向为-z轴方向的坐标系统。而观察矩阵的作用,就是将场景中的物体从世界坐标转换到观察坐标。要定义一个摄像机系统,我们需要它在世界空间中的位置,它的朝向,以及一个向上方向的向量。
2.1 相机位置
相机位置就是一个简单的向量,表示其在世界空间中的位置。我们把它设置成和前一章一样的位置。
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 4.0f);
别忘了OpenGL是右手坐标系,摄像机是往-z轴方向看的
2.2 光线方向
作为朝向的反方向,我称它为光线方向(物体反射光摄入观察者眼睛的方向)。计算的方式很简单,将相机位置向量和观察目标点向量做减法就可以了。我们使用世界坐标原点(默认点)作为我们的观察目标点。
glm::vec3 cameraTarget glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraDirection = glm::normalize(cameraPos - cameraTarget);
2.3 Right轴
我们下一个需要的向量是Right向量,它表示坐标系统中的x轴正方向。要计算这个Right向量,我们要用到之前学的一点小技巧:向量叉乘。Right向量必须要垂直于光线方向,因此,它必须要和光线方向与世界坐标系统的y轴组成的平面垂直。这就帮了我们的大忙,根据叉乘规则,我们只需要将y轴的单位向量与光线方向向量做叉乘就可以了。
glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f);
glm::vec3 cameraRight = glm::normalize(glm::cross(up, cameraDirection));
2.4 Up轴
现在,我们有了x轴和z轴,y轴已经呼之欲出了。没错,只需要用z轴向量叉乘x轴向量就可以了!
glm::vec3 cameraUp = glm::cross(cameraDirection, cameraRight);
叉乘真是个好东西!
好,坐标系统的三个轴都有了,马上开始生成观察矩阵。
3.观察矩阵
用矩阵的最大好处就是当你有了坐标空间的3个轴之后,再加上一个位置向量就可以创造一个变换矩阵。用这个矩阵乘上任何向量都可以将这个向量转换到观察坐标系中。我们集齐了这些条件,可以召唤神龙了:
R表示Right向量,U表示Up向量,D表示光线方向,P表示位置向量。注意,位置向量取的是它的反方向,因为物体需要朝着摄像机相反的方向移动才行。
总结一下我们需要用到的数据:摄像机的位置,摄像机的观察目标(可以生成光线方向),还有世界空间的Up向量。使用这些数据,通过计算,我们就可以生成任意的观察矩阵。非常幸运的是,glm已经帮我们封装好了一个函数,调用它,我们可以直接获取到观察矩阵(而且不用担心出错!)。
glm::mat4 view;
view = glm::lookAt(glm::vec3(0.0f, 0.0f, 4.0f),
glm::vec3(0.0f, 0.0f, 0.0f),
glm::vec3(0.0f, 1.0f, 0.0f));
验证一下函数的效果。我们把摄像机的位置放在半径为10的圆上,让它的观察点始终在世界空间原点上,并且,摄像机会不断地在圆上移动。
参考源代码:
https://gitee.com/pengrui2009/open-gl-study/blob/master/chapter9/square_9_1.cc
float radius = 10.0f;
float camX = sin(glfwGetTime()) * radius;
float camZ = cos(glfwGetTime()) * radius;
glm::mat4 view;
view = glm::lookAt(glm::vec3(camX, 0.0f, camZ), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
是不是很赞?
参考源代码:
https://gitee.com/pengrui2009/open-gl-study/blob/master/chapter9/square_9_2.cc
4. 移动相机
让相机在场景中转圈是挺有趣的,不过更有趣的还是我们自己来控制相机的移动。第一步,我们要来创建一个相机系统,这需要我们在程序开始的时候定义一些关于相机的变量。
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 4.0f);
glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, -1.0f);
glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);
观察矩阵就会变成这个样子:
view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
我们希望摄像机的朝向不变而不是观察目标不变,所以观察点就变成cameraPos+cameraFront。现在,我们就要用键盘操作移动!
在我们之前定义的processInput函数的最后添加一些代码:
float cameraSpeed = 0.05f; //移动速度
if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
cameraPos += cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
cameraPos -= cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp) * cameraSpeed);
if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp) * cameraSpeed);
这样,我们可以使用WASD键来控制前后左右的移动了。
等等,是不是还露了点什么?对了,时间!这段代码纯粹是基于按键和代码运行速度来控制的,如果机子不好,代码运行慢点移动的速度也会变慢,这就不太科学了。因此,我们引入时间来计算移动的距离。
先定义两个全局的变量,用来保存上一帧绘制的时间以及两帧之间的间隔时间。
float deltaTime = 0.0f; //两帧之间的间隔时间
float lastFrame = 0.0f; //上一帧绘制的时间
然后,每一帧都更新这两个数值:
float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
最后,在processInput中使用这个数值
float cameraSpeed = 2.5f * deltaTime; //移动速度
编译运行。
在左右方向上移动地非常快,笔者也试过调小2.5f这个数值,但是经过尝试,即便是将2.5调成0.01在左右方向上移动地还是很快,而前后方向上就太慢了。
参考源代码:
https://gitee.com/pengrui2009/open-gl-study/blob/master/chapter9/square_9_3.cc
5.环顾四周
只用WASD控制移动还不算一个完整的FPS摄像机,我们还要能转头才行!
要实现转头的功能呢,我们就要对cameraFront向量进行改变了。不过对方向向量的改变比较复杂,还涉及要一些三角学的知识。如果你不了解三角学,跳过下面这一段也无妨,直接到代码的地方,等你想了解原理的时候再回来。
欧拉角
欧拉角是绕着三条轴旋转的一个值(欧拉这个名字应该很熟悉吧)。一共有3中欧拉角,分别是:pitch、yaw和roll。(避免歧义,直接用英文。)
pitch表示我们平时抬头低头的动作,yaw表示左看右看,roll表示,嗯,二哈打滚就是这种效果,咱不适合。每个欧拉角组合起来之后,我们可以表示任何旋转。
作为一个FPS摄像机,我们只需要pitch和yaw两种旋转就行了。通过三角计算,将方向向量设置成新值。
上图就是pitch旋转的计算方法。我们的初始方向为(0, 0, -1)。当我们想要转动pitch角度时,z坐标就等于-cos(pitch),y坐标就等于sin(pitch),因为我们假定了斜边长度为1,只考虑其方向。
类似的,计算yaw的方法也是如此,z坐标等于-cos(yaw),x坐标等于-sin(yaw)。
将两个旋转整合起来:
x = -sin(yaw)*cos(pitch)
y = sin(pitch)
z = -cos(pitch) * cos(yaw)
6.鼠标输入
pitch和yaw的值是通过鼠标的移动得到的,水平方向上的移动代表了yaw的值,垂直方向上的移动代表了pitch的值。我们需要保存上一次鼠标的位置,这样可以通过计算和这次鼠标位置的差值算出转动的角度。不过首先,我们我们需要把鼠标的光标隐藏起来,并且捕获鼠标消息。
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
glfwSetCursorPosCallback(window, mouse_callback);
mouse_callback是响应鼠标消息的回调函数,原型如下:
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
window表示捕获的窗口,xpos表示x坐标,ypos表示y坐标。
为了计算一个方向向量,我们需要做这么几件事:
- 计算鼠标相对于上一次的位置偏移。
- 将偏移值累加到摄像机的yaw和pitch值中去。
- 添加一些旋转的限制
- 计算方向向量
先看代码
if (firstMouse) { //设置初始位置,防止突然跳到某个方向上
lastX = xPos;
lastY = yPos;
firstMouse = false;
}
float xoffset = lastX - xPos; //别忘了,在窗口中,左边的坐标小于右边的坐标,而我们需要一个正的角度
float yoffset = lastY - yPos; //同样,在窗口中,下面的坐标大于上面的坐标,而我们往上抬头的时候需要一个正的角度
lastX = xPos;
lastY = yPos;
float sensitivity = 0.05f; //旋转精度
xoffset *= sensitivity;
yoffset *= sensitivity;
yaw += xoffset;
pitch += yoffset;
if (pitch > 89.0f) //往上看不能超过90度
pitch = 89.0f;
if (pitch < -89.0f) //往下看也不能超过90度
pitch = -89.0f;
glm::vec3 front;
front.x = -sin(glm::radians(yaw)) * cos(glm::radians(pitch));
front.y = sin(glm::radians(pitch));
front.z = -cos(glm::radians(pitch)) * cos(glm::radians(yaw));
cameraFront = glm::normalize(front);
为了防止突然跳到某个方向,我们在鼠标刚开始的时候对它的位置进行设置。
接下来,计算与上次位置的偏移量,然后乘上旋转精度得到旋转的角度值。
然后,将旋转角度累加到pitch和yaw值中去,并且,设置pitch的最大和最小值。
最后,根据我们上面推倒的公式,计算方向向量,并将其规范化。
将这段代码写入到mouse_callback函数中,编译运行!
参考源代码:
https://gitee.com/pengrui2009/open-gl-study/blob/master/chapter9/square_9_4.cc
7.变焦
变焦功能,就是狙击枪的放大镜头。通过改变视野值来达到效果,将fov值变小,我们就能看到远方更精细的画面,将fov值变大,我们就可以看到更广的画面,当然也失去了精度优势。
那么我们如何获得fov的改变值呢?答案是通过鼠标滚轮消息来模拟!
//鼠标滚轮消息回调
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset) {
if (fov >= 1.0 && fov <= 45.0)
fov -= yoffset;
if (fov <= 1.0)
fov = 1.0;
if (fov >= 45.0)
fov = 45.0;
}
当滚轮往前的时候,yoffset为正,使得fov值变小,物体变大变精细。相反,当滚轮往后的时候,yoffset为负,使fov值变大,物体变小视野变广。
当然,必不可少的一项在之前注册这个滚轮回调函数。
glfwSetScrollCallback(window, scroll_callback);
于是,我们的投影矩阵就变成了:
projection = glm::perspective(glm::radians((float)fov), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
非常简单!编译运行,你就能通过滚轮来变焦了。
参考源代码:
https://gitee.com/pengrui2009/open-gl-study/blob/master/chapter9/square_9_6.cc
8.封装类
之后的例子中,我们会经常用到这个摄像机来观察显示效果,所以,将它封装成类是聪明的做法。限于篇幅,就不再列出详细的代码了, 不过后面会给出源码,有兴趣的童鞋可以自己看内部的实现。
检查一遍类是否可用是一个非常好的习惯,摄像机类的源码在这里,主文件的源码在这里。
我们现在封装的这个类可以满足大部分需求,但它并不是没有缺陷的。一个重要的问题就是万向节死锁。要解决这个问题,我们之后可以使用四元数的方法,现在先卖个关子。
参考源代码:
https://gitee.com/pengrui2009/open-gl-study/blob/master/chapter9/square_9_7.cc
9.总结
本章我们学了观察矩阵的内部原理,也通过一些三角学知识实现了一个简单的FPS摄像机,成果斐然!下一篇文章会对到目前为止所学到的内容进行总结梳理,毕竟知识不在多而在融会贯通。
本章节源代码:
https://gitee.com/pengrui2009/open-gl-study/tree/master/chapter9
作者:闪电的蓝熊猫
链接:https://www.jianshu.com/p/bc09f44e0856
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。