探索 OpenGL 音视频渲染技术(9):相机

这个系列文章我们来介绍一位海外工程师如何探索 OpenGL 音视频渲染技术,对于想要开始学习音视频技术的朋友,这些文章是份不错的入门资料,这是第 9 篇:OpenGL 相机。

—— 来自公众号“关键帧Keyframe”的分享

上一章中,我们讨论了视图矩阵以及如何使用视图矩阵在场景中移动(我们在其中稍微向后移动)。OpenGL 本身并不熟悉相机的概念,但我们可以通过将场景中的所有对象向相反方向移动来模拟相机的移动,从而给人一种我们正在移动的错觉。

在本章中,我们将讨论如何在 OpenGL 中设置相机。我们将讨论一种飞行风格的相机,它允许你在 3D 场景中自由移动。我们还将讨论键盘和鼠标输入,并以一个自定义的相机类结束。

1、相机/视图空间

当我们谈论相机/视图空间时,我们谈论的是从相机的视角看到的所有顶点坐标,即场景的原点:视图矩阵将世界坐标变换为相对于相机位置和方向的视图坐标。为了定义一个相机,我们需要它在世界空间中的位置、它所指向的方向、一个指向右侧的向量和一个从相机向上指的向量。细心的读者可能会注意到,我们实际上将创建一个以相机位置为原点的三个相互垂直的单位轴的坐标系。

1.1、相机位置

获取相机位置很简单。相机位置是世界空间中的一个向量,指向相机的位置。我们将相机位置设置为上一章中设置的相同位置:

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

别忘了,正 z 轴穿过你的屏幕朝向你,因此如果我们想让相机向后移动,我们沿着正 z 轴移动。

1.2、相机方向

接下来需要的向量是相机的方向,即它指向的方向。目前我们让相机指向场景的原点:(0,0,0)。记住,如果我们从两个向量中减去另一个向量,我们得到一个表示这两个向量差的向量?从相机位置向量中减去场景原点向量,我们得到我们想要的方向向量。由于按照惯例(在 OpenGL 中)相机指向负 z 轴,我们想要反转方向向量。如果我们交换减法的顺序,我们现在得到一个指向相机正 z 轴的向量:

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

“方向”向量的名字不是最佳选择,因为它实际上指向目标的相反方向。

1.3、右轴

我们需要的下一个向量是一个“右”向量,它代表相机空间的正 x 轴。为了得到“右”向量,我们使用一个小技巧,首先指定一个在世界空间中向上指的“上”向量。然后我们对步骤 2 中的向上向量和方向向量进行叉积。由于叉积的结果是一个与两个向量都垂直的向量,我们将得到一个指向正 x 轴方向的向量(如果我们交换叉积的顺序,我们将得到一个指向负 x 轴的向量):

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

1.4、上轴

现在我们已经有了 x 轴向量和 z 轴向量,获取指向相机正 y 轴的向量就相对容易了:我们对右向量和方向向量进行叉积:

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

通过叉积和一些技巧,我们成功创建了形成视图/相机空间的所有向量。对于更喜欢数学的读者来说,这个过程在高等代数中被称为格拉姆-施密特过程。使用这些相机向量,我们现在可以创建一个 LookAt 矩阵,它对创建相机非常有用。

2、Look At

矩阵的一个很好的特性是,如果你用三个相互垂直(或非线性)的轴定义一个坐标系,你可以创建一个包含这三个轴和一个平移向量的矩阵,通过将任何向量与该矩阵相乘,可以将其变换到该坐标系。这正是 LookAt 矩阵的作用,现在我们有了三个相互垂直的轴和一个定义相机空间的位置向量,我们可以创建自己的 LookAt 矩阵:

LookAt=[RxRyRz0UxUyUz0DxDyDz00001]∗[100−Px010−Py001−Pz0001]

其中 R 是右向量,U 是上向量,D 是方向向量,P 是相机的位置向量。请注意,旋转(左矩阵)和平移(右矩阵)部分是反转的(分别转置和取反),因为我们希望将世界向与相机移动方向相反的方向旋转和平移。将此 LookAt 矩阵用作我们的视图矩阵,有效地将所有世界坐标变换为我们刚刚定义的视图空间。LookAt 矩阵的作用正如其名:它创建一个视图矩阵,使其看向给定的目标。

幸运的是,GLM 已经为我们完成了所有这些工作。我们只需要指定相机位置、目标位置和一个代表世界空间中向上向量的向量。GLM 然后创建 LookAt 矩阵,我们可以将其用作视图矩阵:

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函数需要位置、目标和向上向量。此示例创建了一个与我们在上一章中创建的相同的视图矩阵。

在深入用户输入之前,让我们先玩点有趣的。我们通过围绕场景旋转相机来开始。我们将场景的目标保持在 (0,0,0)。我们使用一些三角函数在每帧中创建一个代表圆上一点的 x和 z坐标,我们将这些坐标用于相机位置。通过随时间重新计算 x和 y坐标,我们在圆上的所有点中移动,因此相机围绕场景旋转。我们通过 GLFW 的 glfwGetTime函数在每帧中创建一个新的视图矩阵:

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

如果你运行此代码,你应该看到类似以下内容:

探索 OpenGL 音视频渲染技术(9):相机

凭借这段代码,相机现在随时间围绕场景旋转。请随意使用半径和位置/方向参数进行实验,以了解此 LookAt 矩阵的工作原理。如果你遇到问题,请查看源代码。

3、自由移动

围绕场景旋转相机很有趣,但亲自控制所有移动会更有趣!首先,我们需要设置一个相机系统,因此在程序的顶部定义一些相机变量是有用的:

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

首先我们将相机位置设置为之前定义的 cameraPos。方向是当前位置加上我们刚刚定义的方向向量。这确保了无论我们如何移动,相机都保持朝向目标方向。让我们通过在按下某些键时更新 cameraPos向量来玩这些变量。

我们已经定义了一个 processInput函数来管理 GLFW 的键盘输入,所以让我们添加一些额外的键命令:

void processInput(GLFWwindow *window)
{
    ...
    const 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键之一时,相机位置就会相应更新。如果我们想向前或向后移动,我们添加或减去一个按某些速度值缩放的方向向量。如果我们想侧向移动,我们进行叉积以创建一个“右”向量,并相应地沿右向量移动。这创造了使用相机时熟悉的平移效果。

请注意,我们对结果的“右”向量进行了归一化。如果我们不归一化此向量,结果叉积可能会根据 cameraFront变量返回不同大小的向量。如果我们不归一化该向量,我们的移动速度将根据相机的方向变快或变慢,而不是保持一致的移动速度。

现在,你应该已经可以让相机移动了,尽管速度是特定于系统的,因此你可能需要调整 cameraSpeed

4、移动速度

目前,我们在周围移动时使用了一个恒定值作为移动速度。理论上这看起来不错,但在实践中,不同人的机器有不同的处理能力,结果是有些人每秒渲染的帧数比其他人多。每当一个用户渲染的帧数比另一个用户多时,他也会更频繁地调用 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;
    [...]
}

由于我们使用了 deltaTime,相机现在以每秒 2.5个单位的恒定速度移动。结合上一节,我们现在应该有一个更平滑、更一致的相机系统,用于在场景中移动:

探索 OpenGL 音视频渲染技术(9):相机

现在我们的相机在任何系统上的移动和查看速度都是一致的。再次,如果你遇到问题,请查看源代码。我们将在任何与移动相关的部分中频繁看到 deltaTime值。

5、环顾四周

仅使用键盘键移动并不有趣。特别是因为我们不能转身,这使得移动相当受限。这就是鼠标派上用场的地方!

为了在场景中环顾四周,我们必须根据鼠标的输入更改 cameraFront向量。然而,根据鼠标旋转来更改方向向量有点复杂,需要一些三角函数。如果你不理解三角函数,不要担心,你可以直接跳到代码部分并将它们粘贴到你的代码中;如果你想了解更多,可以随时回来。

6、欧拉角

欧拉角是三个值,可以表示 3D 中的任何旋转,由莱昂哈德·欧拉在 1700 年代的某个时候定义。有三个欧拉角:_俯仰角_、_偏航角_ 和 _横滚角_。下图以视觉方式给出了它们的含义:

探索 OpenGL 音视频渲染技术(9):相机

俯仰角是描述我们上下看的角度,如第一张图所示。第二张图显示了偏航值,它代表我们左右看的程度。横滚角代表我们“滚动”的程度,主要用于太空飞行相机。每个欧拉角由一个值表示,通过组合这三个值,我们可以计算 3D 中的任何旋转向量。

对于我们的相机系统,我们只关心偏航和俯仰值,所以我们不会在这里讨论横滚角。给定一个俯仰和偏航值,我们可以将它们转换为一个 3D 向量,表示新的方向向量。将偏航和俯仰值转换为方向向量的过程需要一些三角函数。我们从一个基本案例开始:

让我们复习一下,并查看一般直角三角形的情况(其中一个边为 90 度角):

探索 OpenGL 音视频渲染技术(9):相机

如果我们定义斜边的长度为 1,根据三角函数(SOH CAH TOA),我们知道邻边的长度为 cosx/h = cosx/1 = cosx,对边的长度为 siny/h = siny/1 = siny。这为我们提供了在直角三角形中 x和 y边的长度的通用公式,具体取决于给定的角度。让我们使用这个来计算方向向量的分量。

让我们想象这个相同的三角形,但现在从顶部视角看,邻边和对边分别平行于场景的 x 和 z 轴(就像沿着 y 轴向下看一样)。

探索 OpenGL 音视频渲染技术(9):相机

如果我们可视化偏航角是从 x 轴开始的逆时针角度,我们可以看到 x 边的长度与 cos(yaw)相关。同样,z 边的长度与 sin(yaw)相关。

如果我们取这个知识和一个给定的偏航值,我们可以用它来创建一个相机方向向量:

glm::vec3 direction;
direction.x = cos(glm::radians(yaw)); // 注意我们首先将角度转换为弧度
direction.z = sin(glm::radians(yaw));

这解决了如何从偏航值获得一个 3D 方向向量的问题,但还需要包括俯仰值。让我们现在查看 y 轴侧,就好像我们坐在 xz 平面上:

同样,从这个三角形我们可以看到方向的 y 分量等于 sin(pitch),所以我们填入:

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

然而,从俯仰三角形我们还可以看到 xz 边受到 cos(pitch)的影响,所以我们需要确保这也是方向向量的一部分。加入这个后,我们得到从偏航和俯仰欧拉角转换而来的最终方向向量:

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));
cameraFront = glm::normalize(direction);

这为我们提供了一个公式,将偏航和俯仰值转换为一个 3D 方向向量,用于环顾四周。

我们已经将场景世界设置为朝向负 z 轴的方向。然而,如果我们查看偏航的 x 和 z 三角形,我们会看到 θ为 0时,相机的 direction向量指向正 x 轴。为了确保相机默认指向负 z 轴,我们可以给偏航一个默认值,即顺时针旋转 90 度。正度数是逆时针旋转,所以我们设置默认的偏航值为:

yaw = -90.0f;

你可能现在就在想:我们如何设置和修改这些偏航和俯仰值?

7、鼠标输入

偏航和俯仰值是从鼠标(或控制器/操纵杆)移动中获得的,水平鼠标移动影响偏航,垂直鼠标移动影响俯仰。这个想法是存储上一帧的鼠标位置,并计算当前帧中鼠标移动的偏移量。水平或垂直的偏移量越大,我们更新俯仰或偏航值的幅度就越大,因此相机也应该移动得越多。

首先,我们将告诉 GLFW 隐藏光标并捕获它。捕获光标意味着,一旦应用程序获得焦点,鼠标光标将保持在窗口中心(除非应用程序失去焦点或退出)。我们可以通过一个简单的配置调用来实现这一点:

在此调用之后,无论我们如何移动鼠标,它都不会可见,并且不会离开窗口。这对于 FPS 相机系统来说是完美的。

为了计算俯仰和偏航值,我们需要告诉 GLFW 监听鼠标移动事件。我们通过创建一个具有以下原型的回调函数来实现这一点:

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

这里 xpos和 ypos表示当前的鼠标位置。一旦我们使用 GLFW 注册回调函数,每次鼠标移动时都会调用 mouse_callback函数:

glfwSetCursorPosCallback(window, mouse_callback);

在处理飞行风格相机的鼠标输入时,在能够完全计算相机的方向向量之前,我们需要采取几个步骤:

  1. 计算自上一帧以来鼠标的偏移量。
  2. 将偏移值添加到相机的偏航和俯仰值中。
  3. 对最小/最大俯仰值添加一些约束。
  4. 计算方向向量。

第一步是计算自上一帧以来鼠标的偏移量。我们首先需要在应用程序中存储上一帧的鼠标位置,我们将其初始化为屏幕中心(屏幕大小为 800600):

float lastX = 400, lastY = 300;

然后在鼠标的回调函数中,我们计算上一帧和当前帧之间的偏移移动:

float xoffset = xpos - lastX;
float yoffset = lastY - ypos; // 反转,因为 y 坐标从底部到顶部
lastX = xpos;
lastY = ypos;

const float sensitivity = 0.1f;
xoffset *= sensitivity;
yoffset *= sensitivity;

请注意,我们将偏移值乘以一个灵敏度值。如果我们省略这个乘法,鼠标移动会太强烈;请根据你的喜好调整灵敏度值。

接下来,我们将偏移值添加到全局声明的俯仰和偏航值中:

yaw   += xoffset;
pitch += yoffset;

在第三步中,我们希望对相机添加一些约束,以便用户无法进行奇怪的相机移动(这也会导致 LookAt 翻转,一旦方向向量与世界向上向量平行)。俯仰需要受到约束,以便用户无法向上看超过 89度(在 90度时我们得到 LookAt 翻转),也无法向下看超过 -89度。这确保用户可以向上看天空或向下看脚下,但不会更远。约束通过在违反约束时将欧拉值替换为其约束值来实现:

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

请注意,我们对偏航值没有设置约束,因为我们不希望限制用户的水平旋转。然而,如果你愿意,添加偏航值的约束也同样简单。

第四步和最后一步是使用上一节中的公式计算实际的方向向量:

glm::vec3 direction;
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));
cameraFront = glm::normalize(direction);

这个计算出的方向向量现在包含了从鼠标移动计算出的所有旋转。由于 cameraFront向量已经包含在 GLM 的 LookAt 函数中,我们就可以开始使用了。

如果你现在运行代码,你会注意到每当窗口首次获得鼠标的焦点时,相机会突然大幅跳动。这是因为一旦你的光标进入窗口,鼠标回调函数就会被调用,其 xpos和 ypos位置等于你的鼠标进入屏幕的位置。这通常是一个显著偏离屏幕中心的位置,导致大的偏移量,从而导致大幅移动。我们可以通过定义一个全局 bool变量来解决这个问题,以检查这是否是我们第一次接收鼠标输入。如果是第一次,我们更新初始鼠标位置为新的 xpos和 ypos值。结果的鼠标移动将使用新进入的鼠标位置坐标来计算偏移量:

if (firstMouse) // 初始设置为 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.1f;
    xoffset *= sensitivity;
    yoffset *= sensitivity;

    yaw   += xoffset;
    pitch += yoffset;

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

    glm::vec3 direction;
    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));
    cameraFront = glm::normalize(direction);
}

就这样!试试看,你现在可以自由地在 3D 场景中移动了!

8、缩放

作为相机系统的额外功能,我们还将实现一个缩放界面。在上一章中,我们提到视野或 fov在很大程度上决定了我们可以看到多少场景。当视野变小时,场景的投影空间也会变小。这个较小的空间被投影到相同的 NDC 上,给人一种放大效果。为了放大,我们将使用鼠标的滚轮。与鼠标移动和键盘输入类似,我们有一个用于鼠标滚动的回调函数:

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

滚动时,yoffset值告诉我们垂直滚动的量。当调用 scroll_callback函数时,我们更改全局声明的 fov变量的内容。由于 45.0是默认的 fov值,我们希望将缩放级别限制在 1.0到 45.0之间。

现在我们必须每帧将透视投影矩阵上传到 GPU,但这次使用 fov变量作为视野:

projection = glm::perspective(glm::radians(fov), 800.0f / 600.0f, 0.1f, 100.0f);

最后,别忘了注册滚动回调函数:

glfwSetScrollCallback(window, scroll_callback);

就这样,我们实现了一个简单的相机系统,允许在 3D 环境中自由移动。

请随意进行一些实验,如果你遇到问题,可以将你的代码与源代码进行比较。

9、相机类

在接下来的章节中,我们将始终使用相机来轻松查看场景,并从各个角度查看结果。然而,由于相机代码在每个章节中可能占用大量空间,我们将稍微抽象其细节,并创建自己的相机对象,它为我们完成大部分工作,并添加一些不错的额外功能。与着色器章节不同,我们不会引导你创建相机类,但如果你想知道内部工作原理,可以提供完全注释的源代码。

与 Shader对象一样,我们将相机类完全定义在一个单独的头文件中。你可以在这里找到相机类;在本章之后,你应该能够理解代码。至少建议你查看一次该类,作为一个如何创建自己的相机系统的例子。

我们介绍的相机系统是一种飞行风格的相机,适用于大多数目的,并且与欧拉角配合良好,但当你创建不同的相机系统(如 FPS 相机或飞行模拟相机)时要小心。每个相机系统都有其自己的技巧和怪癖,因此请务必阅读相关资料。例如,这种飞行相机不允许俯仰值大于或等于 90度,而当我们将横滚值考虑在内时,静态向上向量 (0,1,0)也不起作用。

使用新相机对象的源代码的更新版本可以在这里找到。

10、练习

  • 看看你能否将相机类转换为一个真正的 FPS 相机,使你无法飞行;你只能在 xz平面上左右看:解决方案。
  • 尝试创建自己的 LookAt 函数,其中你手动创建一个视图矩阵,如本章开头所述。用你自己的实现替换 GLM 的 LookAt 函数,看看它是否仍然表现相同:解决方案。

音视频方向学习、求职,欢迎加入我们的星球

丰富的音视频知识、面试题、技术方案干货分享,还可以进行面试辅导

探索 OpenGL 音视频渲染技术(9):相机

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。

(0)

相关推荐

发表回复

登录后才能评论