作为一名OpenGL初学者,当我第一次看到三维场景中能够自由移动的摄像机时,内心充满了好奇。今天,我将带大家深入理解OpenGL中的摄像机系统,并通过一个完整的代码示例,手把手教你实现3D场景漫游。

一、为什么需要摄像机?

在现实生活中,我们通过眼睛观察世界。在3D图形学中,摄像机就扮演着“眼睛”的角色。它决定了:

  • 我们从什么位置观察场景(位置)

  • 我们看向什么方向(朝向)

  • 哪个方向是“上”(上向量)

没有摄像机,我们只能看到固定的画面,无法从不同角度欣赏3D场景。

二、核心概念:三大向量

摄像机系统的核心由三个向量构成:

2.1摄像机位置(Camera Position)

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

这是摄像机在世界坐标系中的位置。上面的代码将摄像机放在(0,0,3),即原点后方3个单位处。

2.2 摄像机朝向(Camera Direction/Front)

glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, -1.0f);

这表示摄像机看向的方向。这里指向Z轴负方向,意味着摄像机正对着原点。

重要概念:实际计算中,我们通常使用“方向向量”而不是“目标点”。方向向量 = 目标点 - 位置。

2.3上向量(Up Vector)

glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);

这定义了哪边是“向上”。通常是(0,1,0),即Y轴正方向。

三、LookAt矩阵:摄像机的灵魂

glm::lookAt函数是摄像机系统的核心,它根据上述三个要素生成一个视图矩阵:

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

参数解析

  • 第一个参数:摄像机位置

  • 第二个参数:摄像机看向的点(位置+朝向)

  • 第三个参数:上向量

这个矩阵会将世界坐标转换到摄像机视角,实现“从摄像机角度看世界”的效果。

四、动态摄像机:自由移动

4.1 移动控制(WASD)

float cameraSpeed = static_cast<float>(2.5 * deltaTime);
if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
    cameraPos += cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
    cameraPos -= cameraSpeed * cameraFront;

关键技巧

  • 向前移动:位置 + 朝向 × 速度

  • 向后移动:位置 - 朝向 × 速度

  • 左右移动:位置 ± (朝向 × 上向量) × 速度

注意这里的叉积glm::cross(cameraFront, cameraUp) 计算出向右向量。

4.2 帧速率独立移动

float deltaTime = currentFrame - lastFrame;
float cameraSpeed = 2.5f * deltaTime;

这是非常重要的技巧!deltaTime表示上一帧到当前帧的时间差。乘以这个值可以确保:

  • 无论电脑快慢,移动速度恒定

  • 快电脑每帧时间短,移动量小

  • 慢电脑每帧时间长,移动量大

五、鼠标控制:旋转视角

5.1 欧拉角

我们使用欧拉角(Yaw和Pitch)来表示旋转:

  • Yaw(偏航角):左右旋转,绕Y轴

  • Pitch(俯仰角):上下旋转,绕X轴

float yaw = -90.0f;   // 初始指向-Z方向
float pitch = 0.0f;   // 初始水平看向前方

5.2 鼠标输入处理

void mouse_callback(GLFWwindow* window, double xposIn, double yposIn)
{
    float xoffset = xpos - lastX;   // 计算X轴偏移
    float yoffset = lastY - ypos;   // 反转Y轴(屏幕坐标Y向下)
    
    xoffset *= sensitivity;  // 应用灵敏度
    yoffset *= sensitivity;
    
    yaw += xoffset;      // 更新偏航角
    pitch += yoffset;    // 更新俯仰角
    
    // 限制俯仰角,防止万向锁和视野翻转
    if(pitch > 89.0f) pitch = 89.0f;
    if(pitch < -89.0f) pitch = -89.0f;
}

为什么限制pitch为±89度?

  • 超过90度会导致摄像机“翻转”,体验不佳

  • 避免万向锁问题(虽然现代四元数可以解决,但欧拉角简单够用)

5.3 从欧拉角计算方向向量

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

数学原理

  • X分量:cos(yaw) × cos(pitch)

  • Y分量:sin(pitch)

  • Z分量:sin(yaw) × cos(pitch)

这本质上是单位球面上的坐标转换。

六、视野控制:滚轮缩放

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

FOV(Field of View,视野)影响透视投影:

  • 大FOV:视野广,有“鱼眼”效果

  • 小FOV:视野窄,类似长焦镜头

  • 限制在1-45度避免极端情况

七、投影矩阵:透视效果

glm::mat4 projection = glm::perspective(
    glm::radians(fov),                    // 视野角度
    (float)SCR_WIDTH / SCR_HEIGHT,        // 宽高比
    0.1f,                                 // 近平面
    100.0f                                // 远平面
);

透视投影实现“近大远小”效果:

  • 近平面:距离摄像机0.1单位内的物体被裁剪

  • 远平面:距离摄像机100单位外的物体被裁剪

八、完整工作流程

while (!glfwWindowShouldClose(window))
{
    // 1. 更新时间差
    float currentFrame = glfwGetTime();
    deltaTime = currentFrame - lastFrame;
    lastFrame = currentFrame;
    
    // 2. 处理输入(移动和旋转)
    processInput(window);
    
    // 3. 清除缓冲区
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    
    // 4. 计算投影矩阵(基于FOV和宽高比)
    glm::mat4 projection = glm::perspective(...);
    
    // 5. 计算视图矩阵(基于摄像机位置和朝向)
    glm::mat4 view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
    
    // 6. 对每个物体计算模型矩阵并渲染
    for (unsigned int i = 0; i < 10; i++)
    {
        glm::mat4 model = glm::mat4(1.0f);
        model = glm::translate(model, cubePositions[i]);
        model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
        
        // 传递三个矩阵给着色器
        ourShader.setMat4("model", model);
        ourShader.setMat4("view", view);
        ourShader.setMat4("projection", projection);
        
        glDrawArrays(GL_TRIANGLES, 0, 36);
    }
}

九、常见问题与解决方案

9.1 摄像机抖动

原因deltaTime计算不准确或速度系数过大
解决:确保速度系数适中(如2.5),检查时间计算逻辑

9.2 旋转异常

原因:俯仰角未限制或鼠标坐标未反转
解决:限制pitch在±89度,正确处理Y轴反转

9.3 物体被裁剪

原因:物体超出近/远平面范围
解决:调整透视投影的近/远平面参数,或将摄像机移近物体

十、进阶学习方向

掌握了基础摄像机后,你可以探索:

  1. 四元数旋转:避免万向锁,实现平滑插值

  2. 第一人称与第三人称切换:改变摄像机相对位置

  3. 摄像机路径动画:沿样条曲线移动

  4. 后期处理效果:景深、运动模糊

总结

OpenGL的摄像机系统本质上是通过矩阵变换实现的“虚拟眼睛”。理解三个核心向量(位置、朝向、上向量)和LookAt矩阵是关键。通过WASD控制移动、鼠标控制旋转、滚轮控制视野,就能实现完整的3D场景漫游体验。

记住最重要的公式:

最终顶点位置 = 投影矩阵 × 视图矩阵 × 模型矩阵 × 原始顶点位置

这个矩阵乘法链条,就是3D图形学的核心精髓。希望这篇文章能帮助你迈出OpenGL学习的重要一步!

完整代码

// 包含OpenGL加载库,用于在运行时加载OpenGL函数指针
#include <glad.h>
// 包含GLFW窗口库,用于创建窗口和处理输入
#include <glfw3.h>
// 包含stb_image图像加载库,用于加载纹理图片
#include <stb_image.h>

// GLM数学库核心组件,提供向量和矩阵类型
#include <glm.hpp>
// 矩阵变换相关函数(平移、旋转、缩放、透视等)
#include <gtc/matrix_transform.hpp>
// 将glm矩阵转换为普通数组的工具
#include <gtc/type_ptr.hpp>

// 自定义着色器类的头文件,封装了着色器编译和使用的功能
#include <shader.h>

// 标准输入输出流,用于控制台输出
#include <iostream>

// 函数声明:窗口大小改变时的回调函数
void framebuffer_size_callback(GLFWwindow* window, int width, int height);
// 函数声明:鼠标移动时的回调函数,用于控制摄像机视角
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
// 函数声明:鼠标滚轮滚动时的回调函数,用于控制视野缩放
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset);
// 函数声明:处理键盘输入的函数
void processInput(GLFWwindow* window);

// 设置:窗口宽度(像素)
const unsigned int SCR_WIDTH = 800;
// 设置:窗口高度(像素)
const unsigned int SCR_HEIGHT = 600;

// 摄像机位置(世界坐标系中的坐标)
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);

// 是否是第一次移动鼠标(用于初始化上次鼠标位置)
bool firstMouse = true;
// 偏航角(左右旋转角度),-90度表示初始指向Z轴负方向
float yaw = -90.0f;
// 俯仰角(上下旋转角度),0度表示水平向前
float pitch = 0.0f;
// 上一帧鼠标的X坐标
float lastX = 800.0f / 2.0;
// 上一帧鼠标的Y坐标
float lastY = 600.0 / 2.0;
// 视野角度(Field of View),45度为正常视角
float fov = 45.0f;

// 时间相关:当前帧与上一帧的时间差
float deltaTime = 0.0f;
// 上一帧的时间戳
float lastFrame = 0.0f;

int main()
{
    // ========== GLFW初始化与配置 ==========
    // 初始化GLFW库
    glfwInit();
    // 设置OpenGL主版本号为3
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    // 设置OpenGL次版本号为3
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    // 使用核心模式(不兼容旧版本特性)
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

#ifdef __APPLE__
    // macOS系统需要额外设置向前兼容
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif

    // ========== 创建GLFW窗口 ==========
    // 创建窗口对象:宽度、高度、标题、全屏选项、共享资源
    GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "LearnOpenGL", NULL, NULL);
    if (window == NULL)
    {
        std::cout << "Failed to create GLFW window" << std::endl;
        glfwTerminate();  // 终止GLFW,释放资源
        return -1;
    }
    // 将创建的窗口设置为当前线程的OpenGL上下文
    glfwMakeContextCurrent(window);
    // 注册窗口大小改变的回调函数
    glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
    // 注册鼠标移动的回调函数
    glfwSetCursorPosCallback(window, mouse_callback);
    // 注册鼠标滚轮回调函数
    glfwSetScrollCallback(window, scroll_callback);

    // 告诉GLFW捕获鼠标光标(隐藏光标并锁定在窗口内)
    glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);

    // ========== 初始化GLAD ==========
    // GLAD用于加载OpenGL函数指针,必须在使用任何OpenGL函数之前调用
    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
    {
        std::cout << "Failed to initialize GLAD" << std::endl;
        return -1;
    }

    // ========== 配置OpenGL全局状态 ==========
    // 启用深度测试:让远处的物体被近处的遮挡
    glEnable(GL_DEPTH_TEST);

    // ========== 编译着色器程序 ==========
    // 加载并编译顶点着色器和片段着色器
    Shader ourShader("7.3.camera.vs", "7.3.camera.fs");

    // ========== 设置顶点数据 ==========
    // 顶点数组:每个顶点包含3个位置坐标(x,y,z)和2个纹理坐标(u,v)
    // 总共36个顶点(6个面 × 6个顶点/面 = 36)
    float vertices[] = {
        // 前面(Z = -0.5)的6个顶点(2个三角形)
        -0.5f, -0.5f, -0.5f,  0.0f, 0.0f,  // 左下角顶点
         0.5f, -0.5f, -0.5f,  1.0f, 0.0f,  // 右下角顶点
         0.5f,  0.5f, -0.5f,  1.0f, 1.0f,  // 右上角顶点
         0.5f,  0.5f, -0.5f,  1.0f, 1.0f,  // 重复,用于第二个三角形
        -0.5f,  0.5f, -0.5f,  0.0f, 1.0f,  // 左上角顶点
        -0.5f, -0.5f, -0.5f,  0.0f, 0.0f,  // 左下角顶点(重复)

        // 后面(Z = 0.5)的6个顶点
        -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
         0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
         0.5f,  0.5f,  0.5f,  1.0f, 1.0f,
         0.5f,  0.5f,  0.5f,  1.0f, 1.0f,
        -0.5f,  0.5f,  0.5f,  0.0f, 1.0f,
        -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,

        // 左面(X = -0.5)的6个顶点
        -0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
        -0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
        -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
        -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
        -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
        -0.5f,  0.5f,  0.5f,  1.0f, 0.0f,

        // 右面(X = 0.5)的6个顶点
         0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
         0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
         0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
         0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
         0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
         0.5f,  0.5f,  0.5f,  1.0f, 0.0f,

        // 下面(Y = -0.5)的6个顶点
        -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
         0.5f, -0.5f, -0.5f,  1.0f, 1.0f,
         0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
         0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
        -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
        -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,

        // 上面(Y = 0.5)的6个顶点
        -0.5f,  0.5f, -0.5f,  0.0f, 1.0f,
         0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
         0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
         0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
        -0.5f,  0.5f,  0.5f,  0.0f, 0.0f,
        -0.5f,  0.5f, -0.5f,  0.0f, 1.0f
    };
    
    // 10个立方体的世界坐标位置
    glm::vec3 cubePositions[] = {
        glm::vec3(0.0f,  0.0f,  0.0f),      // 第1个:原点
        glm::vec3(2.0f,  5.0f, -15.0f),     // 第2个:右上方远处
        glm::vec3(-1.5f, -2.2f, -2.5f),     // 第3个:左下方稍远
        glm::vec3(-3.8f, -2.0f, -12.3f),    // 第4个:左方远处
        glm::vec3(2.4f, -0.4f, -3.5f),      // 第5个:右方稍远
        glm::vec3(-1.7f,  3.0f, -7.5f),     // 第6个:左上方较远
        glm::vec3(1.3f, -2.0f, -2.5f),      // 第7个:右下方稍远
        glm::vec3(1.5f,  2.0f, -2.5f),      // 第8个:右上方稍远
        glm::vec3(1.5f,  0.2f, -1.5f),      // 第9个:右方较近
        glm::vec3(-1.3f,  1.0f, -1.5f)      // 第10个:左方较近
    };
    
    // ========== 创建顶点缓冲对象和顶点数组对象 ==========
    unsigned int VBO, VAO;
    // 生成1个顶点数组对象(VAO)
    glGenVertexArrays(1, &VAO);
    // 生成1个顶点缓冲对象(VBO)
    glGenBuffers(1, &VBO);

    // 绑定VAO,后续的顶点属性配置会记录到这个VAO中
    glBindVertexArray(VAO);

    // 绑定VBO为GL_ARRAY_BUFFER类型的目标
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    // 将顶点数据复制到VBO中
    // GL_STATIC_DRAW表示数据不会改变且频繁用于绘制
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    // ========== 配置顶点属性指针 ==========
    // 位置属性(location = 0):3个float,步长5个float,偏移0
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
    // 启用位置属性
    glEnableVertexAttribArray(0);
    
    // 纹理坐标属性(location = 1):2个float,步长5个float,偏移3个float
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
    // 启用纹理坐标属性
    glEnableVertexAttribArray(1);

    // ========== 加载和创建纹理 ==========
    unsigned int texture1, texture2;
    
    // ----- 纹理1 -----
    glGenTextures(1, &texture1);           // 生成纹理对象
    glBindTexture(GL_TEXTURE_2D, texture1); // 绑定纹理,后续操作作用于该纹理
    
    // 设置纹理环绕方式(超出纹理坐标范围时的处理)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);  // S轴(U)重复
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);  // T轴(V)重复
    // 设置纹理过滤方式(放大缩小时的采样方式)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); // 缩小:线性过滤
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // 放大:线性过滤
    
    int width, height, nrChannels;         // 图片宽度、高度、通道数
    stbi_set_flip_vertically_on_load(true); // 翻转Y轴,使纹理坐标原点在左下角
    // 加载图片数据
    unsigned char* data = stbi_load("雷欧.png", &width, &height, &nrChannels, 0);
    if (data)
    {
        // 生成二维纹理图像
        // 参数:目标、Mipmap等级、内部格式、宽、高、边框、输入格式、数据类型、图像数据
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
        // 生成Mipmap(多级渐远纹理,提高渲染性能)
        glGenerateMipmap(GL_TEXTURE_2D);
    }
    else
    {
        std::cout << "Failed to load texture" << std::endl;
    }
    stbi_image_free(data);  // 释放图像内存
    
    // ----- 纹理2 -----
    glGenTextures(1, &texture2);
    glBindTexture(GL_TEXTURE_2D, texture2);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    
    data = stbi_load("awesomeface.png", &width, &height, &nrChannels, 0);
    if (data)
    {
        // 注意:awesomeface.png有透明通道,所以使用GL_RGBA格式
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
        glGenerateMipmap(GL_TEXTURE_2D);
    }
    else
    {
        std::cout << "Failed to load texture" << std::endl;
    }
    stbi_image_free(data);

    // ========== 设置着色器uniform变量 ==========
    ourShader.use();                       // 激活着色器程序
    ourShader.setInt("texture1", 0);       // 设置纹理单元0对应texture1
    ourShader.setInt("texture2", 1);       // 设置纹理单元1对应texture2

    // ========== 主渲染循环 ==========
    while (!glfwWindowShouldClose(window))  // 循环直到窗口被关闭
    {
        // ----- 计算帧时间差 -----
        float currentFrame = static_cast<float>(glfwGetTime());  // 获取当前时间(秒)
        deltaTime = currentFrame - lastFrame;  // 计算两帧之间的时间差
        lastFrame = currentFrame;              // 更新上一帧时间

        // ----- 处理输入 -----
        processInput(window);                  // 响应键盘输入(WASD移动、ESC退出)

        // ----- 渲染 -----
        glClearColor(0.2f, 0.3f, 0.3f, 1.0f); // 设置清空颜色(深蓝绿色)
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 清空颜色缓冲和深度缓冲

        // ----- 绑定纹理到纹理单元 -----
        glActiveTexture(GL_TEXTURE0);          // 激活纹理单元0
        glBindTexture(GL_TEXTURE_2D, texture1); // 绑定纹理1到当前激活单元
        glActiveTexture(GL_TEXTURE1);          // 激活纹理单元1
        glBindTexture(GL_TEXTURE_2D, texture2); // 绑定纹理2到当前激活单元

        // ----- 激活着色器 -----
        ourShader.use();

        // ----- 计算并传递投影矩阵 -----
        // 透视投影:视野角度、宽高比、近平面、远平面
        glm::mat4 projection = glm::perspective(glm::radians(fov), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
        ourShader.setMat4("projection", projection);  // 传递给着色器

        // ----- 计算并传递视图矩阵(摄像机视角)-----
        // lookAt矩阵:摄像机位置、观察点位置、上向量
        glm::mat4 view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
        ourShader.setMat4("view", view);

        // ----- 渲染所有立方体 -----
        glBindVertexArray(VAO);  // 绑定VAO
        for (unsigned int i = 0; i < 10; i++)  // 循环10次,绘制10个立方体
        {
            // 计算模型矩阵
            glm::mat4 model = glm::mat4(1.0f);  // 初始化为单位矩阵
            model = glm::translate(model, cubePositions[i]);  // 平移到指定位置
            float angle = 20.0f * i;  // 每个立方体旋转角度不同
            // 绕轴(1.0, 0.3, 0.5)旋转
            model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
            ourShader.setMat4("model", model);  // 传递模型矩阵给着色器

            glDrawArrays(GL_TRIANGLES, 0, 36);  // 绘制36个顶点(一个立方体)
        }

        // ----- 交换缓冲区和处理事件 -----
        glfwSwapBuffers(window);  // 交换前后缓冲区(双缓冲技术)
        glfwPollEvents();         // 处理所有待处理的事件(键盘、鼠标等)
    }

    // ========== 清理资源 ==========
    glDeleteVertexArrays(1, &VAO);  // 删除VAO
    glDeleteBuffers(1, &VBO);       // 删除VBO
    glfwTerminate();                // 终止GLFW,释放所有资源
    return 0;
}

// 处理键盘输入的函数
void processInput(GLFWwindow* window)
{
    // 按ESC键退出程序
    if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
        glfwSetWindowShouldClose(window, true);

    // 根据帧时间计算移动速度(保证不同帧率下移动速度一致)
    float cameraSpeed = static_cast<float>(2.5 * deltaTime);
    
    // W键:向前移动(沿摄像机朝向方向)
    if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
        cameraPos += cameraSpeed * cameraFront;
    // S键:向后移动
    if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
        cameraPos -= cameraSpeed * cameraFront;
    // A键:向左移动(向右向量反方向)
    // 向右向量 = 摄像机朝向 × 上向量
    if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
        cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
    // D键:向右移动
    if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
        cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
}

// 窗口大小改变时的回调函数
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
    // 设置OpenGL视口大小(渲染区域)
    glViewport(0, 0, width, height);
}

// 鼠标移动时的回调函数(控制摄像机视角)
void mouse_callback(GLFWwindow* window, double xposIn, double yposIn)
{
    float xpos = static_cast<float>(xposIn);  // 当前鼠标X坐标
    float ypos = static_cast<float>(yposIn);  // 当前鼠标Y坐标

    // 如果是第一次移动,初始化上次位置,避免画面跳跃
    if (firstMouse)
    {
        lastX = xpos;
        lastY = ypos;
        firstMouse = false;
    }

    // 计算鼠标移动偏移量
    float xoffset = xpos - lastX;      // X轴偏移(左右)
    float yoffset = lastY - ypos;      // Y轴偏移(上下),注意反转方向
    lastX = xpos;                       // 更新上次X坐标
    lastY = ypos;                       // 更新上次Y坐标

    // 鼠标灵敏度(控制旋转速度)
    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 front;
    front.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));  // X分量
    front.y = sin(glm::radians(pitch));                            // Y分量
    front.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));  // Z分量
    cameraFront = glm::normalize(front);  // 归一化后赋值给摄像机朝向
}

// 鼠标滚轮滚动时的回调函数(控制视野缩放)
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
    // 滚轮上下滚动改变视野角度
    fov -= (float)yoffset;  // 向上滚动减小FOV(拉近视角),向下滚动增大FOV(拉远视角)
    
    // 限制视野范围:1度到45度
    if (fov < 1.0f)
        fov = 1.0f;
    if (fov > 45.0f)
        fov = 45.0f;
}

核心知识点总结

  1. OpenGL初始化流程:GLFW初始化 → 创建窗口 → GLAD初始化

  2. 顶点数据处理:VAO存储顶点属性配置,VBO存储实际顶点数据

  3. 纹理加载:stb_image加载图片 → 生成OpenGL纹理 → 设置过滤和环绕方式

  4. 摄像机系统:位置(Pos)、朝向(Front)、上向量(Up) → LookAt矩阵

  5. 投影矩阵:透视投影实现近大远小效果

  6. MVP矩阵:模型(Model)、视图(View)、投影(Projection)三个变换矩阵

  7. 输入处理:键盘控制移动(帧速率独立)、鼠标控制视角(欧拉角)、滚轮控制视野

  8. 渲染循环:处理输入 → 更新状态 → 清空缓冲 → 绑定资源 → 绘制 → 交换缓冲

希望这份详细的注释能帮助你理解OpenGL的基础知识和摄像机系统的实现原理!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐