8 - 摄像机

本节将会讨论如何在OpenGL中配置一个摄像机,并且将会讨论FPS风格的摄像机,能够在3D场景中自由移动。也会讨论键盘和鼠标输入,最终完成一个自定义的摄像机类。

OpenGL本身没有摄像机(Camera)的概念,但我们可以通过把场景中的所有物体往相反方向移动的方式来模拟出摄像机,产生一种我们在移动的感觉,而不是场景在移动。

摄像机

摄像机/观察空间的示意图如下:

定义摄像机

要定义一个摄像机,我们需要它 在世界空间中的位置、观察方向、指向它右侧和上方的向量

摄像机位置

摄影机的位置可由世界空间中指向它的向量指定:

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

正Z轴是由屏幕指向你的,因此摄像机向后走就得沿着正Z方向走

摄像机方向

接下来指定摄像机的观察方向,让摄像机指向场景原点(0, 0, 0),将原点向量和位置向量相减即可得到摄像机的观察方向向量。

需要注意的是,这里需要的是方向向量的反方向,因此得交换相减顺序,代码如下:

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

右轴

接下来还需要一个指向摄像机右边(正x轴)的向量,它代表摄像机空间x轴的正方向。

计算右向量需要点小技巧,先随便找一个上向量,然后把它和刚刚得到的方向向量进行叉乘即可:

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

上轴

接下来需要一个指向摄像机上方(正y轴)的向量,可以通过右向量和方向向量叉乘得到:

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

LookAt矩阵

有了观察空间(摄像机的三个轴),再来一个平移向量就能拼成一个变换矩阵,可以将世界空间的任何向量变换到这个空间上。这正是Look At矩阵干的事情。

利用glm::lookAt来创建它:

glm::mat4 view = glm::mat4(1.0f);
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));

该函数需要一个摄像机位置,一个观察目标位置和随便一个上向量。

接下来尝试将摄影机在场景中旋转,只需动态变换x和z坐标就行了:

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)); 

效果是这样的:

方向移动

接下来尝试让摄像机随着按键操作进行移动。

首先必须设置一个摄像机系统:

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);

然后LookAt函数就成了:

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

然后在processInput()里添加移动的相关操作:

// WASD控制摄像机移动
static 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;

这里对 右向量 进行了标准化,以便匀速移动。

移动速度

这样就能进行移动了。但需要注意的是,随着CPU的能力不同,调用processInput()函数的频率也不同,这导致移速在不同机器上可能是不同的。

为了确保大家的移动速度都一样,图形程序/游戏通常会跟踪一个 时间差(DeltaTime)变量,它储存了渲染上一帧所用的时间。将所有速度都去乘这个deltaTime值(如果上一帧渲染时间很长,那就需要更大的速度来平衡渲染花费了的时间),让速度在不同机器上变得相同。

声明两个全局变量:

// DeltaTime变量
float deltaTime = 0.0f; // 当前帧与上一帧的时间差
float lastFrame = 0.0f; // 上一帧的时间

然后在每一帧中计算出新的deltaTime以备后用:

// 在渲染循环里
float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;

最后,就能用deltaTime去计算速度了:

float cameraSpeed = 2.5f * deltaTime;

效果如下:

视角移动

现在我们尝试通过鼠标实现摄像机视角的转动。为了能够改变视角,需要根据鼠标输入来改变cameraFront向量,所需知识如下:

欧拉角

欧拉角(Euler Angle)是可以表示3D空间中任意旋转的3个值:俯仰角(Pitch)、偏航角(Yaw)和滚转角(roll)

俯仰角是描述我们如何往上/下看的角;偏航角描述我们如何往左/右看;滚转角描述我们如何翻滚摄像机。对于摄像机来说,用前两个角就可以了。

给定一个俯仰角和偏航角,可以把它们转化为一个代表新方向的3D向量:

  • 偏航角:因为偏航角是左右看的,所以我们在xOy平面上看z轴。

    如图,知道偏航角后,就能计算z和x分量了。:

    glm::vec3 direction;
    direction.x = cos(glm::radians(yaw));
    direction.y = sin(glm::radians(yaw));
  • 俯仰角:因为俯仰角是上下看的,所以我们在xOz平面上看向y轴。

    如图,知道俯仰角后,就能知道俯仰程度(往上/下看了多少):

    direction.y = sin(glm::radians(pitch));

    除此之外,俯仰角还影响了x和z分量,因此也得计算:

    direction.x = cos(glm::radians(pitch));
    direction.z = cos(glm::radians(pitch));

综合起来的结果如下(画一下3D坐标系就能推导出来):

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

PS&TODO: 使用欧拉角的摄像机系统并不完美,可能会出现 万向节死锁问题,最好的摄像机系统是使用 四元数(Quaternions)的,计划未来实现。

鼠标输入

偏航角和俯仰角是通过鼠标/手柄获得的,水平的移动影响偏航角,竖直的移动影响俯仰角。只需储存上一帧鼠标的位置,在当前帧中计算当前鼠标的位置和上一帧的相差多少,相差越大,俯仰角/偏航角就改变越大。

通过glfwSetInputMode()函数,隐藏并捕捉光标:

// glfw: 捕捉并隐藏鼠标
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);

接下来,和键盘输入类似,我们也要处理一下鼠标输入:

// 声明回调函数
void mouse_callback(GLFWwindow* window, double xpos, double ypos);

// 注册回调函数
glfwSetCursorPosCallback(window, mouse_callback);

然后定义两个全局变量,存储上一帧的鼠标位置:

// 初始化上一帧鼠标位置为屏幕中心
float lastX = 400, lastY = 300;

在处理鼠标输入时,有以下几步:

  1. 计算鼠标距上一帧的偏移量:

    float xOffset = xpos - lastX;
    float yOffset = lastY - ypos; // 注意y坐标是从底部往顶部依次增大的
    lastX = xpos;
    lastY = ypos;
    
    float sensitivity = 0.05f; // 鼠标灵敏度
    xOffset *= sensitivity;
    yOffset *= sensitivity;
  2. 把偏移量添加到摄像机的俯仰角和偏航角中:

    yaw += xOffset;
    pitch += yOffset;
  3. 对俯仰角进行最大和最小值的限制。对于俯仰角,不能看向高于89°的地方(超出视角会逆转),同样不低于-89°。

    if(pitch > 89.0f)
      pitch =  89.0f;
    if(pitch < -89.0f)
      pitch = -89.0f;
  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);

运行代码后,发现在窗口第一次获取焦点的时候摄像机会突然跳一下。这是因为鼠标刚移动进窗口,xposypos离屏幕中心很远,偏移量很大,会跳。因此得改一下:

if(firstMouse) // 这个bool变量初始时是设定为true的
{
    lastX = xpos;
    lastY = ypos;
    firstMouse = false;
}

缩放

在可以按方向与视角移动摄像机后,我们尝试实现一个 缩放(Zoom) 接口,利用鼠标滚轮来放大/缩小视野。Fov定义了我们可以看到场景的范围。当Fov变小时,看到场景的范围就会缩小,产生放大(Zoom In)的感觉,反之亦然。

类似的,我们也要声明和定义一个处理鼠标滚轮输入的回调函数:

void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
  // 限制fov范围在1.0f ~ 45.0f之间
  if(fov >= 1.0f && fov <= 45.0f)
    fov -= yoffset;
  if(fov <= 1.0f)
    fov = 1.0f;
  if(fov >= 45.0f)
    fov = 45.0f;
}

还记得透视矩阵中第一个参数是fov吗,将第一项的固定值修改一下,然后将其移入渲染循环中(因为每帧都要更新一下)。最终效果如下:

摄像机类

接下来将上边有关摄像机的内容都封装好,封装成camera.hpp。详见这里

参考资料

  • 摄像机 - LearnOpenGL CN (learnopengl-cn.github.io)