文章目录
- 摄像机/观察空间/视图变换
- LookAt矩阵
- 移动相机(处理键盘输入)
- 移动速度
- 欧拉角
- 移动视角(处理鼠标输入)
- 缩放场景(处理滚轮输入)
- Camera类
摄像机/观察空间/视图变换
在上一节变换中,我们讨论过观察空间中如何使用View矩阵移动场景。
从世界空间(World Space)到观察空间(View Space)的变换,就是视图变换(View Transformation),实际上是在对摄像机做变换,也即View / Camera Transformation
回顾GAMES101学习笔记(二):Transformation 变换 中的视图变换部分:
如何定义相机:
- 相机的位置
- 相机的朝向 LookAt
- 相机的向上方向(固定相机,避免相机绕LookAt方向旋转)
视图变换要做的就是把相机设定在标准位置:
- 相机放在原点
- 相机LookAt方向为-Z方向
- 将其他物体做同样的变换
OpenGL本身没有摄像机(Camera)的概念,我们通过把场景中的所有物体往相反方向移动的方式来模拟出摄像机,产生一种我们在移动的感觉,而不是场景在移动。
在观察空间中,是以摄像机的视角作为场景原点时设定场景中所有的顶点坐标:观察矩阵把所有的世界坐标变换为相对于摄像机位置与方向的观察坐标。要定义一个摄像机,我们需要它在世界空间中的位置、观察的方向、一个指向它右侧的向量以及一个指向它上方的向量。细心的读者可能已经注意到我们实际上创建了一个三个单位轴相互垂直的、以摄像机的位置为原点的坐标系。
1. 摄像机位置P
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);
不要忘记正z轴是从屏幕指向你的,如果我们希望摄像机向后移动,我们就沿着z轴的正方向移动。
2. 摄像机方向D
这里指的是摄像机指向哪个方向。现在我们让摄像机指向场景原点:(0, 0, 0)。还记得如果将两个矢量相减,我们就能得到这两个矢量的差吗?用场景原点向量减去摄像机位置向量的结果就是摄像机的指向向量。
方向向量(Direction Vector)并不是最好的名字,因为它实际上指向从它到目标向量的相反方向(如上图,蓝色的方向向量大概指向z轴的正方向,与摄像机实际指向的方向是正好相反的)。
由于我们知道摄像机指向z轴负方向,但我们希望方向向量(Direction Vector)指向摄像机的z轴正方向。如果我们交换相减的顺序,我们就会获得一个指向摄像机正z轴方向的向量:
glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraDirection = glm::normalize(cameraPos - cameraTarget);
3. 右轴和上轴
我们需要的另一个向量是一个右向量(Right Vector),它代表摄像机空间的x轴的正方向。
如何获取右向量:先定义一个上向量(Up Vector)。接下来把上向量和第二步得到的方向向量进行叉乘。两个向量叉乘的结果会同时垂直于两向量,因此我们会得到指向x轴正方向的那个向量(如果我们交换两个向量叉乘的顺序就会得到相反的指向x轴负方向的向量):
glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f);
glm::vec3 cameraRight = glm::normalize(glm::cross(up, cameraDirection));
现在我们已经有了x轴向量和z轴向量,获取一个指向摄像机的正y轴向量就相对简单了:我们把右向量和方向向量进行叉乘:
glm::vec3 cameraUp = glm::cross(cameraDirection, cameraRight);
LookAt矩阵
我们创建了所有构成观察/摄像机空间的向量,使用这些向量我们就可以创建一个LookAt矩阵了。
使用矩阵的好处之一是如果你使用3个相互垂直(或非线性)的轴定义了一个坐标空间,你可以用这3个轴外加一个平移向量来创建一个矩阵,并且你可以用这个矩阵乘以任何向量来将其变换到那个坐标空间。
这正是LookAt矩阵所做的,现在我们有了3个相互垂直的轴和一个定义摄像机空间的位置坐标,我们可以创建我们自己的LookAt矩阵了:
其中R是右向量,U是上向量,D是方向向量,P是摄像机位置向量。
注意,位置向量是相反的,因为我们最终希望把世界平移到与我们自身移动的相反方向。
把这个LookAt矩阵作为观察矩阵可以很高效地把所有世界坐标变换到刚刚定义的观察空间。LookAt矩阵就像它的名字表达的那样:它会创建一个看着(Look at)给定目标的观察矩阵。
使用glm
库可以很快创建一个新的View矩阵:
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));
- 第一个参数eye,位置向量,摄像机的位置
- 第二个参数center,目标向量,注视点
- 第三个参数up,上向量
接下来我们上手做一个小练习:将摄像机的注视点保持在(0, 0, 0),让摄像机在场景中旋转。
我们需要用到一点三角函数知识来在每一帧创建一个x和z坐标,它会代表圆上的一点,我们将会使用它作为摄像机的位置。通过重新计算x和y坐标,我们会遍历圆上的所有点,这样摄像机就会绕着场景旋转了。
我们预先定义这个圆的半径radius,在每次渲染循环中使用GLFW的glfwGetTime
函数重新创建观察矩阵,来扩大这个圆。 参考源码
float radius = 10.0f;
float camX = sin(glfwGetTime()) * radius;
float 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));
移动相机(处理键盘输入)
接下来,我们处理键盘输入WASD来控制相机移动。
我们要设置一个摄像机系统,先定义一些摄像机变量:
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);
通过这些变量来更新view矩阵:
view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
我们将摄像机位置设置为之前定义的cameraPos
。方向是当前的位置加上我们刚刚定义的方向向量。这样能保证无论我们怎么移动,摄像机都会注视着目标方向。
更新processInput
函数处理GLFW
的键盘输入:
void processInput(GLFWwindow *window)
{...float cameraSpeed = 0.05f; // adjust accordinglyif (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;
}
- 向前/向后:把位置向量加上或减去方向向量。
- 向左/向右:使用叉乘来创建一个右向量(Right Vector),并沿着它相应移动
注意,我们对右向量进行了标准化。如果我们没对这个向量进行标准化,最后的叉乘结果会根据cameraFront变量返回大小不同的向量。如果我们不对向量进行标准化,我们就得根据摄像机的朝向不同加速或减速移动了,但如果进行了标准化移动就是匀速的。
移动速度
目前我们的移动速度是个常量。理论上没什么问题,但是实际情况下根据处理器的能力不同,有些人可能会比其他人每秒绘制更多帧,也就是以更高的频率调用processInput
函数。结果就是,根据配置的不同,有些人可能移动很快,而有些人会移动很慢。当你发布你的程序的时候,你必须确保它在所有硬件上移动速度都一样。
图形程序和游戏通常会跟踪一个时间差(Deltatime)变量,它储存了渲染上一帧所用的时间。
我们把所有速度都去乘以deltaTime
值。如果我们的deltaTime
很大,就意味着上一帧的渲染花费了更多时间,所以这一帧的速度需要变得更高来平衡渲染所花去的时间。使用这种方法时,无论你的电脑快还是慢,摄像机的速度都会相应平衡,这样每个用户的体验就都一样了。
设置两个全局变量来计算出deltaTime
值:
float deltaTime = 0.0f; // 当前帧与上一帧的时间差
float lastFrame = 0.0f; // 上一帧的时间
在每一帧中我们计算出新的deltaTime
以备后用。
float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
现在我们有了deltaTime
,在计算速度的时候可以将其考虑进去了:
void processInput(GLFWwindow *window)
{float cameraSpeed = 2.5f * deltaTime;...
}
现在我们有了一个在任何系统上移动速度都一样的摄像机。
欧拉角
只用键盘移动我们不能转向,移动很受限制。接下来处理鼠标输入,来让我们的视角跟随鼠标转起来。为了能够改变视角,我们需要根据鼠标的输入改变cameraFront
向量。然而,根据鼠标移动改变方向向量是非常复杂,需要一些三角学知识,我们先了解一下欧拉角。
欧拉角(Euler Angle)是可以表示3D空间中任何旋转的3个值,由莱昂哈德·欧拉(Leonhard Euler)在18世纪提出。一共有3种欧拉角:俯仰角(Pitch)、偏航角(Yaw) 和 滚转角(Roll),下面的图片展示了它们的含义:
- 俯仰角(Pitch) 描述我们如何往上或往下看
- 偏航角(Yaw) 描述我们往左和往右看的程度
- 滚转角(Roll) 代表我们如何翻滚摄像机,通常在太空飞船的摄像机中使用
每个欧拉角都有一个值来表示,把三个角结合起来我们就能够计算3D空间中任何的旋转向量了。对于我们的摄像机系统来说,我们只关心俯仰角和偏航角,所以我们不会讨论滚转角。
给定一个俯仰角Pitch
和偏航角Yaw
,我们可以把它们转换为一个代表新的方向向量的3D向量。 (推理图解)
基于俯仰角和偏航角的方向向量:
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));
移动视角(处理鼠标输入)
偏航角和俯仰角是通过鼠标(或手柄)移动获得的,水平的移动影响偏航角,竖直的移动影响俯仰角。
它的原理就是,储存上一帧鼠标的位置,在当前帧中我们当前计算鼠标位置与上一帧的位置相差多少。如果水平/竖直差别越大那么俯仰角或偏航角就改变越大,也就是摄像机需要移动更多的距离。
在这个例子中,我们创建一个FPS摄像机系统,需要告诉GLFW隐藏光标,并捕捉(Capture)它。我们可以用一个简单地配置调用来完成:
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
在调用这个函数之后,无论我们怎么去移动鼠标,光标都不会显示了,它也不会离开窗口。对于FPS摄像机系统来说非常完美。
接下来,为了计算俯仰角和偏航角,我们需要让GLFW监听鼠标移动事件。(和键盘输入相似)我们会用一个回调函数来完成,函数的原型如下:
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
这里的xpos
和ypos
代表当前鼠标的位置。当我们用GLFW注册了回调函数之后,鼠标一移动mouse_callback
函数就会被调用:
glfwSetCursorPosCallback(window, mouse_callback);
在处理FPS风格摄像机的鼠标输入的时候,我们必须在最终获取方向向量之前做下面这几步:
1. 计算鼠标距上一帧的偏移量
我们必须先在程序中储存上一帧的鼠标位置,我们把它的初始值设置为屏幕的中心(屏幕的尺寸是800x600):
float lastX = 400, lastY = 300;
然后在鼠标的回调函数中我们计算当前帧和上一帧鼠标位置的偏移量:
float xoffset = xpos - lastX;
float yoffset = lastY - ypos; // 注意这里是相反的,因为y坐标是从底部往顶部依次增大的
lastX = xpos;
lastY = ypos;float sensitivity = 0.05f;
xoffset *= sensitivity;
yoffset *= sensitivity;
注意,我们把偏移量乘以了sensitivity(灵敏度)值。如果我们忽略这个值,鼠标移动就会太大了;可以自己实验一下,找到适合自己的灵敏度值。
2. 把偏移量添加到摄像机的俯仰角和偏航角中
接下来我们把偏移量加到全局变量pitch
和yaw
上:
yaw += xoffset;
pitch += yoffset;
3. 对偏航角和俯仰角进行最大和最小值的限制
还需要给摄像机添加一些限制,这样摄像机就不会发生奇怪的移动了(这样也会避免一些奇怪的问题)。对于俯仰角,要让用户不能看向高于89度的地方(在90度时视角会发生逆转,所以我们把89度作为极限),同样也不允许小于-89度。这样能够保证用户只能看到天空或脚下,但是不能超越这个限制。我们可以在值超过限制的时候将其改为极限值来实现:
if(pitch > 89.0f)pitch = 89.0f;
if(pitch < -89.0f)pitch = -89.0f;
注意我们没有给偏航角设置限制,这是因为我们不希望限制用户的水平旋转。要设置的话也很简单,同Pitch
4. 计算方向向量
通过俯仰角和偏航角来计算以得到真正的方向向量:
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);
计算出来的方向向量就会包含根据鼠标移动计算出来的所有旋转了。
如果现在运行代码,你会发现在窗口第一次获取焦点的时候摄像机会突然跳一下。这个问题产生的原因是,在你的鼠标移动进窗口的那一刻,鼠标回调函数就会被调用,这时候的xpos
和ypos
会等于鼠标刚刚进入屏幕的那个位置。这通常是一个距离屏幕中心很远的地方,因而产生一个很大的偏移量,所以就会跳了。我们可以简单的使用一个bool
变量检验我们是否是第一次获取鼠标输入,如果是,那么我们先把鼠标的初始位置更新为xpos
和ypos
值,这样就能解决这个问题;接下来的鼠标移动就会使用刚进入的鼠标位置坐标来计算偏移量了:
if(firstMouse) // 这个bool变量初始时是设定为true的
{lastX = xpos;lastY = ypos;firstMouse = false;
}
最后完整的回调函数:
void mouse_callback(GLFWwindow* window, double xpos, double ypos)
{if(firstMouse){lastX = xpos;lastY = ypos;firstMouse = false;}float xoffset = xpos - lastX;float yoffset = lastY - ypos; lastX = xpos;lastY = ypos;float 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);
}
缩放场景(处理滚轮输入)
再来实现一个缩放(Zoom)功能。在之前的文章中提到过视野(Field of View)或fov定义了我们可以看到场景中多大的范围。当视野变小时,场景投影出来的空间就会减小,产生放大(Zoom In)了的感觉。
我们会使用鼠标的滚轮来放大。与鼠标移动、键盘输入一样,我们需要一个鼠标滚轮的回调函数:
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
值代表我们竖直滚动的大小。当scroll_callback
函数被调用后,我们改变全局变量fov
变量的内容。
因为45.0f是默认的视野值,我们将会把缩放级别(Zoom Level)限制在1.0f到45.0f。
我们现在在每一帧都必须把透视投影矩阵上传到GPU,但现在使用fov变量作为它的视野:
projection = glm::perspective(glm::radians(fov), 800.0f / 600.0f, 0.1f, 100.0f);
最后不要忘记注册GLFW的鼠标滚轮的回调函数:
glfwSetScrollCallback(window, scroll_callback);
Camera类
至此,我们已经基本实现了一个使用欧拉角的FPS摄像机系统(当然,真正的FPS摄像机是不能飞行的,只能呆在xz平面上。参考)
它并不完美,根据你的视角限制或者是配置,你仍然可能引入万向节死锁问题。最好的摄像机系统是使用四元数(Quaternions)的。(这里可以查看四元数摄像机的实现)
接下来的学习中,我们将总会使用一个摄像机来浏览场景,从各个角度观察结果。我们需要将他抽象出来,创建一个Camera对象,来完成大量重复的工作。
和Shader
对象一样,我们把Camera
类写在一个单独的头文件中:
#ifndef CAMERA_H
#define CAMERA_H#include <glad/glad.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>// Defines several possible options for camera movement. Used as abstraction to stay away from window-system specific input methods
enum Camera_Movement {FORWARD,BACKWARD,LEFT,RIGHT
};// Default camera values
const float YAW = -90.0f;
const float PITCH = 0.0f;
const float SPEED = 2.5f;
const float SENSITIVITY = 0.1f;
const float ZOOM = 45.0f;// An abstract camera class that processes input and calculates the corresponding Euler Angles, Vectors and Matrices for use in OpenGL
class Camera
{
public:// camera Attributesglm::vec3 Position;glm::vec3 Front;glm::vec3 Up;glm::vec3 Right;glm::vec3 WorldUp;// euler Anglesfloat Yaw;float Pitch;// camera optionsfloat MovementSpeed;float MouseSensitivity;float Zoom;// constructor with vectorsCamera(glm::vec3 position = glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f), float yaw = YAW, float pitch = PITCH) : Front(glm::vec3(0.0f, 0.0f, -1.0f)), MovementSpeed(SPEED), MouseSensitivity(SENSITIVITY), Zoom(ZOOM){Position = position;WorldUp = up;Yaw = yaw;Pitch = pitch;updateCameraVectors();}// constructor with scalar valuesCamera(float posX, float posY, float posZ, float upX, float upY, float upZ, float yaw, float pitch) : Front(glm::vec3(0.0f, 0.0f, -1.0f)), MovementSpeed(SPEED), MouseSensitivity(SENSITIVITY), Zoom(ZOOM){Position = glm::vec3(posX, posY, posZ);WorldUp = glm::vec3(upX, upY, upZ);Yaw = yaw;Pitch = pitch;updateCameraVectors();}// returns the view matrix calculated using Euler Angles and the LookAt Matrixglm::mat4 GetViewMatrix(){return glm::lookAt(Position, Position + Front, Up);}// processes input received from any keyboard-like input system. Accepts input parameter in the form of camera defined ENUM (to abstract it from windowing systems)void ProcessKeyboard(Camera_Movement direction, float deltaTime){float velocity = MovementSpeed * deltaTime;if (direction == FORWARD)Position += Front * velocity;if (direction == BACKWARD)Position -= Front * velocity;if (direction == LEFT)Position -= Right * velocity;if (direction == RIGHT)Position += Right * velocity;}// processes input received from a mouse input system. Expects the offset value in both the x and y direction.void ProcessMouseMovement(float xoffset, float yoffset, GLboolean constrainPitch = true){xoffset *= MouseSensitivity;yoffset *= MouseSensitivity;Yaw += xoffset;Pitch += yoffset;// make sure that when pitch is out of bounds, screen doesn't get flippedif (constrainPitch){if (Pitch > 89.0f)Pitch = 89.0f;if (Pitch < -89.0f)Pitch = -89.0f;}// update Front, Right and Up Vectors using the updated Euler anglesupdateCameraVectors();}// processes input received from a mouse scroll-wheel event. Only requires input on the vertical wheel-axisvoid ProcessMouseScroll(float yoffset){Zoom -= (float)yoffset;if (Zoom < 1.0f)Zoom = 1.0f;if (Zoom > 45.0f)Zoom = 45.0f;}private:// calculates the front vector from the Camera's (updated) Euler Anglesvoid updateCameraVectors(){// calculate the new Front vectorglm::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));Front = glm::normalize(front);// also re-calculate the Right and Up vectorRight = glm::normalize(glm::cross(Front, WorldUp)); // normalize the vectors, because their length gets closer to 0 the more you look up or down which results in slower movement.Up = glm::normalize(glm::cross(Right, Front));}
};
#endif