此系列记录我跟随 LearnOpenGL 学习的历程。

# 入门

# 安装 OpenGL

如果是 Windows 端,我使用的 msvc+xmake+vscode 开发环境。msvc 安装直接从微软官方下载 Visual Studio,xmake 用于项目构建和 c++ 库管理,vscode 轻巧方便。
xmake 的安装,这里推荐从 Scoop 安装 xmake。

l
# 可选:首次运行远程脚本时需要
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
# 安装 Scoop 的命令
Invoke-RestMethod get.scoop.sh | Invoke-Expression
# 从 Scoop 安装 xmake
scoop install xmake
# xmake 生成项目
xmake create opengl
cd opengl

关于 xmake 在 vscode 环境的配置,xmake 无法用 vscode C/C++ 扩展默认的 InteltiSense 进行语法补全和语法检查,所以我们需要用 Clangd 代替,在 vscode 安装 Clangd 扩展,如果原先没有安装 Clangd 会自动安装,也可以用 scoop 安装 Clangd。
但是仅仅有 Clangd 是不够的,我们需要在 xmake.lua 中进行如下配置修改,修改后编译成功通过一次后则可以检测到新的库。

a
xmake.lua
...
+ add_rules("plugin.compile_commands.autoupdate", {outputdir = ".vscode"})
...

而 xmake 自身的调试无法在 vscode 上进行方便的断点检查,所以我们利用 vscode C/C++ 扩展所带的调试功能,在.vscode 文件夹下新建 launch.json,配置如下:

n
{
    // 使用 IntelliSense 了解相关属性。 
    // 悬停以查看现有属性的描述。
    // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "(gdb) 启动",
            "type": "cppvsdbg",
            "request": "launch",
            "program": "${workspaceFolder}/build/windows/x64/debug/a.exe",
            "args": [],
            "stopAtEntry": false,
            "cwd": "${fileDirname}",
            "environment": [],
            "externalConsole": false,
        }
    ]
}

我们进行 OpenGL 开发所需相关的库有 glad、glfw(后续还有 assimp、glm 等),在这里我们额外用 imgui 作为界面库,在 xmake.lua 中进行修改

a
xmake.lua
...
+ add_requires("glad","glfw")
+ add_requires("imgui",{configs = {glfw = true, opengl3 = true}})
target("main")
    set_kind("binary")
    add_files("src/main.cpp")
+   add_packages("glfw", "glad", "imgui")
...

关于 imgui 的配置,我们在 main.cpp 的添加如下头文件:

p
main.cpp
...
+ #include "imgui.h"
+ #include "imgui_impl_glfw.h"
+ #include "imgui_impl_opengl3.h"
+ #include "imgui_impl_opengl3_loader.h"
...

之后利用 xmake 进行 build,xmake 会自动下载库进行配置。

# 纹理(引用我们自己的头文件)

在纹理一节中,我们需要添加新的 stb_image.h 单头文件图像加载库,我们可以在项目下新建 include 文件夹,将 stb_image.h 头文件放入 include 文件夹中,将.cpp 文件放入 source 文件夹中(如果有的话)。之后在 xmake.lua 下将 include 文件夹中的头文件都加入链接:

a
xmake.lua
...
target("main")
    set_kind("binary")
+   add_includedirs("include")
    add_files("src/main.cpp")
    add_packages("glfw", "glm", "imgui", "assimp", "glad")
...

由于 OpenGL 要求 y 轴坐标在图片底部,但是图片的 y 轴坐标通常在顶部,所以我们可以借助 stb_image.h 在图像加载时帮助我们翻转 y 轴,只需要在加载任何图像前加入以下语句即可:

p
stbi_set_flip_vertically_on_load(true);

值得注意的是,后续用 assimp 导入的模型纹理,大部分模型纹理 (比如米哈游官方提供的各类模型) 进行了处理,本身图像 y 轴便是反的,此时不用再翻转 y 轴。

# 变换

这一章主要涉及的是矩阵相关知识,比较重点的是关于旋转部分,欧拉角与四元数
q=(cos(θ2),sin(θ2)vx,sin(θ2)vy,sin(θ2)vz)q=(cos(\frac{\theta }{2} ),sin(\frac{\theta }{2} )*vx,sin(\frac{\theta }{2} )*vy,sin(\frac{\theta }{2} )*vz)

欧拉角:
1、欧拉角的旋转顺序非常重要,不同的旋转顺序得到的最终旋转结果不一致。
2、使用欧拉角描述三维旋转时,当一个旋转轴与另一个旋转轴重合时,系统失去一个自由度,导致无法独立控制所有旋转方向,即万向节死锁现象。
3、物体角度状态与欧拉角坐标并非一一对应关系,某些位置状态并不唯一确定一组欧拉角坐标。
四元数:
1、包含了四个实参数以及三个虚部(一个实部三个虚部)。
2、不会产生万向节死锁现象。
3、可以插值,不同的四元数对应的旋转是唯一的,多次旋转可以进行计算上的优化。

同时引用了 glm 库,该库同样只需要通过 xmake 引入即可:

a
xmake.lua
...
+ add_requires("glm")
target("main")
    set_kind("binary")
    add_includedirs("include")
    add_files("src/main.cpp")
-   add_packages("glfw", "glad", "imgui")
+   add_packages("glfw", "glad", "imgui","glm")
...

# 坐标系统

首先要记住,OpenGL 默认是 x 轴朝右,y 轴朝上,z 轴朝内 (朝向你自己)。

  • 局部坐标,是对象相对于局部原点的坐标,也是物体起始的坐标。比如,最原始的处于远点的一个立方体。

  • 世界空间,世界空间坐标是处于一个更大的空间范围的。这些坐标相对于世界的全局原点,它们会和其它物体一起相对于世界的原点进行摆放。比如,我们将处于原点的立方体摆放到我们需要摆放到的位置。

  • 观察空间,每个坐标都是从摄像机或者说观察者的角度进行观察的。比如,我们将立方体相对于原点的坐标转换为相对于摄像机的坐标,如果我们要将摄像机向后移动,那么我们将整个场景向前移动,两者是一样的。

  • 裁剪空间,坐标到达观察空间之后,我们需要将其投影到裁剪坐标。裁剪坐标会被处理至 - 1.0 到 1.0 的范围内,并判断哪些顶点将会出现在屏幕上。最常用的投影有正视投影、透视投影。这里要注意,经过透视投影后是非线性的,后续对法向量的处理需要注意。

  • 屏幕空间,我们将使用一个叫做视口变换 (Viewport Transform) 的过程。视口变换将位于 - 1.0 到 1.0 范围的坐标变换到由 glViewport 函数所定义的坐标范围内。最后变换出来的坐标将会送到光栅器,将其转化为片段。

各个坐标的变换关系是这样的:
局部空间 --model--> 世界空间 --view--> 观察空间 --projection--> 裁剪空间 -- 视口变换 --> 屏幕空间
在这里的 modelviewprojection 为变换矩阵,比如对坐标 x 进行变换时候,最后变换的结果是 projection*view*model*x,矩阵乘法的顺序不能错,其中 projection*view*model 得到的结果称为 MVP 矩阵。

# 摄像机

一个仿照 Unity 摄像机移动系统(WASD 控制前后左右,Q 上升,E 下降)

p
main.cpp
...
void processInput(GLFWwindow *window)
{
    if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
        glfwSetWindowShouldClose(window, true);
    if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
        myCamera.ProcessKeyboard(FORWARD, deltaTime * MoveSpeed);
    if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
        myCamera.ProcessKeyboard(BACKWARD, deltaTime * MoveSpeed);
    if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
        myCamera.ProcessKeyboard(LEFT, deltaTime * MoveSpeed);
    if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
        myCamera.ProcessKeyboard(RIGHT, deltaTime * MoveSpeed);
    if (glfwGetKey(window, GLFW_KEY_Q) == GLFW_PRESS)
        myCamera.ProcessKeyboard(UP, deltaTime * MoveSpeed);
    if (glfwGetKey(window, GLFW_KEY_E) == GLFW_PRESS)
        myCamera.ProcessKeyboard(DOWN, deltaTime * MoveSpeed);
    if (glfwGetKey(window, GLFW_KEY_F) == GLFW_PRESS)
        cameraMouseControl = !cameraMouseControl; // 切换摄像机控制模式
    if (glfwGetKey(window, GLFW_KEY_R) == GLFW_PRESS)
        myCamera.Reset(); // 重置摄像机位置和方向
}
...
camera.h
...
void ProcessKeyboard(Camera_Movement direction, float deltaTime)
    {
        float velocity = MovementSpeed * deltaTime;
        if (direction == FORWARD)
            Position += glm::vec3(Front.x, 0.0f, Front.z) * velocity;
        if (direction == BACKWARD)
            Position -= glm::vec3(Front.x, 0.0f, Front.z) * velocity;
        if (direction == LEFT)
            Position -= glm::vec3(Right.x, 0.0f, Right.z) * velocity;
        if (direction == RIGHT)
            Position += glm::vec3(Right.x, 0.0f, Right.z) * velocity;
        if (direction == UP)
            Position += glm::vec3(0.0f, 1.0f, 0.0f) * velocity;
        if (direction == DOWN)
            Position -= glm::vec3(0.0f, 1.0f, 0.0f) * velocity;
    }
...

如果你引用了 imgui 作为界面库,为了使鼠标作用不冲突,可以进行如下修改:

p
main.cpp
...
void mouse_callback(GLFWwindow *window, double xpos, double ypos)
{
    // 获取 ImGui 的 IO 状态
    ImGuiIO &io = ImGui::GetIO();
    // 如果 ImGui 正在使用鼠标,或左键未按下时,不处理视角移动
    if (io.WantCaptureMouse || glfwGetMouseButton(window, GLFW_MOUSE_BUTTON_LEFT) != GLFW_PRESS || !cameraMouseControl)
    {
        firstMouse = true; // 重置初始位置标记
        return;
    }
    if (firstMouse) // 这个 bool 变量初始时是设定为 true 的
    {
        lastX = xpos;
        lastY = ypos;
        firstMouse = false;
    }
    float xoffset = (xpos - lastX);
    float yoffset = lastY - ypos; // 注意这里是相反的,因为 y 坐标是从底部往顶部依次增大的
    if (cameraMouseControl)
    {
        xoffset *= cameraSpeed;
        yoffset *= cameraSpeed;
    }
    lastX = xpos;
    lastY = ypos;
    myCamera.ProcessMouseMovement(xoffset, yoffset);
}
...