前言
前几篇比较随意,本篇知识点接续坐标系统和摄像机,按照比较规范的格式来提供FPS相机功能。
效果图
功能详解
1.项目结构
QtCameraDemo/ ├── CMakeLists.txt ├── main.cpp ├── res.qrc #资源文件 ├── mainwindow.h / mainwindow.cpp ├── GLWidget.h / GLWidget.cpp # OpenGL 渲染窗口 + 输入分发 └── Camera.h / Camera.cpp # 相机核心(重点)2.CMakeLists.txt
代码中有涉及C++ 17的特性,改下配置信息
set(CMAKE_CXX_STANDARD 17)3.MainWindow主窗口
负责显示相机、【交互提示框】及底部状态栏。
// mainwindow.cpp #include "mainwindow.h" #include "GLWidget.h" #include <QLabel> #include <QStatusBar> MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { //1.相机 m_glWidget = new GLWidget(this); setCentralWidget(m_glWidget); //2. 左上角操作说明(半透明悬浮) —— m_helpLabel = new QLabel(m_glWidget); m_helpLabel->setText(QStringLiteral( "交互说明\n" "──────────────────\n" "【FPS 模式】\n" " W/A/S/D 前后左右\n" " Q / E 下降 / 上升\n" " Shift 加速 ×3\n" " 左键拖拽 转视角\n" " 滚轮 调节速度\n" "──────────────────\n" "F 复位相机")); m_helpLabel->setStyleSheet( "background: rgba(15,18,25,170); color:#e8e8e8; padding:10px;" "border-radius:8px; font-size:12px;" "font-family: Consolas, 'Microsoft YaHei', monospace;"); m_helpLabel->adjustSize(); m_helpLabel->move(12, 12); m_helpLabel->raise(); //3.底部状态栏:实时相机状态 —— m_infoLabel = new QLabel(this); statusBar()->addWidget(m_infoLabel); connect(m_glWidget, &GLWidget::cameraInfoChanged, m_infoLabel, &QLabel::setText); resize(1280, 800); setWindowTitle(QStringLiteral("OpenGL FPS相机")); }4.3D渲染主体
增加着色器
前几篇是通过函数addShaderFromSourceCode加载代码字符串方式的GLSL,实现对着色器的编译,并把它加入程序(后续要跟随.link()一起用)
addShaderFromSourceCode(QOpenGLShader::Vertex, string)本篇则是通过函数addShaderFromSourceFie加载代码文件方式的GLSL,实现对着色器的编译,并把它加入程序(后续要跟随.link()一起用)
m_program.create(); m_program.addShaderFromSourceFile(QOpenGLShader::Vertex, ":/shaders/cube.vert");//增加顶点着色器 m_program.addShaderFromSourceFile(QOpenGLShader::Fragment, ":/shaders/cube.frag");//增加片段着色器 m_program.link();文件方式适合移植管理,但是如果项目介意qrc资源方式加载时,最好恢复成字符串的方式。
这里额外说明:addShader的意义是读取 GLSL 源码,并提交给 OpenGL 驱动编译,并非通常理解的gcc/g++编译。后续跟随的link(),目的是让显卡驱动维护好这个编译实例,方便显卡GPU来调用它,调用的契机是.bind()。
设置(初始)光源方向
m_program.bind();//激活着色器程序 //给着色器的uLightDir赋值---必须先激活着色器 m_program.setUniformValue("uLightDir", QVector3D(0.4f, 0.9f, 0.35f));//设置光源方向 m_program.release();//释放几何构建
包括生成地面网格、生成中心的RGB坐标轴、生成立方体
// ============================================================ // 几何构建:网格 / 坐标轴 / 立方体(pos+color+normal 交错存储) // ============================================================ void GLWidget::setupSceneGeometry() { // ========================================== // 顶点布局约定 (Vertex Layout) // ========================================== // 我们自定义的顶点格式包含 9 个 float,共 36 字节: // 索引 0-2: vec3 Position (空间坐标 x, y, z) // 索引 3-5: vec3 Color (顶点颜色 r, g, b) // 索引 6-8: vec3 Normal (法线向量,用于光照计算) constexpr int kStride = 9 * sizeof(float); // ========================================== // 辅助函数:创建并上传 VAO/VBO,本身不写坐标,它只负责把别人算好的坐标data送进 GPU // ========================================== auto createVaoVbo = [this](QOpenGLVertexArrayObject& vao, QOpenGLBuffer& vbo, const std::vector<float>& data) { // 1. 创建并绑定 VAO ,记录了下面所有 vertexAttribPointer 的配置状态 vao.create(); vao.bind(); // 2. 创建、绑定 VBO 并上传顶点数据到显卡显存 vbo.create(); vbo.bind(); // data.data() 是 CPU 端的数组指针,size() * sizeof(float) 是数据总大小 vbo.allocate(data.data(), static_cast<int>(data.size() * sizeof(float))); // 3. 告诉显卡如何解析这 36 字节的数据 // layout(location = 0) 对应顶点位置 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, kStride, reinterpret_cast<void*>(0)); glEnableVertexAttribArray(0); // layout(location = 1) 对应顶点颜色 glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, kStride, reinterpret_cast<void*>(3 * sizeof(float))); glEnableVertexAttribArray(1); // layout(location = 2) 对应法线 glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, kStride, reinterpret_cast<void*>(6 * sizeof(float))); glEnableVertexAttribArray(2); // 4. 解绑 VBO 和 VAO (防止后续代码误修改状态) vbo.release(); vao.release(); }; // ========================================== // 1. 生成地面网格 (Ground Grid) // ========================================== { std::vector<float> gridData; // 存储所有网格线的顶点数据 // 网格范围:-100 到 100,步长为 2 const int N = 100; const int step = 2; const int majorEvery = 10; // 每 10 格画一条更明显的"主线" // Lambda:向数组中推入一条线段(包含 2 个顶点,共 18 个 float) auto pushLine = [&gridData](const QVector3D& p1, const QVector3D& p2, const QVector3D& color) { float v[] = { p1.x(), p1.y(), p1.z(), color.x(), color.y(), color.z(), 0, 0, 1, p2.x(), p2.y(), p2.z(), color.x(), color.y(), color.z(), 0, 0, 1 }; gridData.insert(gridData.end(), v, v + 18); }; const QVector3D minorCol(0.2f, 0.2f, 0.2f); // 细线颜色 (暗灰) const QVector3D majorCol(0.5f, 0.5f, 0.5f); // 主线颜色 (亮灰) // 循环生成平行于 X 轴 和 平行于 Z 轴的线条 for (int i = -N; i <= N; i += step) { const QVector3D col = (i % majorEvery == 0) ? majorCol : minorCol; pushLine(QVector3D(-N, 0, i), QVector3D(N, 0, i), col); // 画横线 pushLine(QVector3D(i, 0, -N), QVector3D(i, 0, N), col); // 画纵线 } // 计算网格线的顶点总数(总 float 数 / 9) m_gridVertexCount = static_cast<int>(gridData.size()) / 9; createVaoVbo(m_gridVao, m_gridVbo, gridData); } // ========================================== // 2. 生成 RGB 坐标轴 (X红, Y绿, Z蓝) // ========================================== { // 手动定义 6 条线段 (每条线段 2 个顶点) // 数据格式依然是: X,Y,Z, R,G,B, Nx,Ny,Nz std::vector<float> axesData = { // X 轴 (红色) 0, 0, 0, 0.9, 0.2, 0.2, 0, 0, 1, // 起点 25, 0, 0, 0.9, 0.2, 0.2, 0, 0, 1, // 终点 // Y 轴 (绿色) 0, 0, 0, 0.2, 0.9, 0.2, 0, 0, 1, // 起点 0, 25, 0, 0.2, 0.9, 0.2, 0, 0, 1, // 终点 // Z 轴 (蓝色) 0, 0, 0, 0.2, 0.2, 0.9, 0, 0, 1, // 起点 0, 0, 25, 0.2, 0.2, 0.9, 0, 0, 1 // 终点 }; createVaoVbo(m_axesVao, m_axesVbo, axesData); } // ========================================== // 3. 生成单位立方体 (Cube) // ========================================== { std::vector<float> cubeData; // 存储立方体的 36 个顶点 (6个面 * 2个三角形) // Lambda:根据法线和中心点生成一个包含 2 个三角形的正方形面 auto addFace = [&cubeData](const QVector3D& normal, const QVector3D& up, const QVector3D& col) { // 通过叉积计算向右的方向向量 const QVector3D right = QVector3D::crossProduct(up, normal).normalized(); // 面的中心位置 (沿法线向外偏移 0.5) const QVector3D center = normal * 0.5f; // 计算面的四个角点 const QVector3D corners[4] = { center + (-right - up) * 0.5f, center + ( right - up) * 0.5f, center + ( right + up) * 0.5f, center + (-right + up) * 0.5f }; // 三角形绘制索引顺序 (逆时针 CCW) const int indices[6] = { 0, 1, 2, 0, 2, 3 }; // 将两个三角形(共 6 个顶点)的数据压入数组 for (int idx : indices) { float v[] = { corners[idx].x(), corners[idx].y(), corners[idx].z(), col.x(), col.y(), col.z(), normal.x(), normal.y(), normal.z() }; cubeData.insert(cubeData.end(), v, v + 9); } }; // 定义立方体的统一颜色 const QVector3D col(0.85f, 0.85f, 0.88f); // 依次生成立方体的 6 个面 addFace(QVector3D( 0, 0, 1), QVector3D(0, 1, 0), col); // 正面 addFace(QVector3D( 0, 0, -1), QVector3D(0, 1, 0), col); // 背面 addFace(QVector3D( 1, 0, 0), QVector3D(0, 1, 0), col); // 右面 addFace(QVector3D(-1, 0, 0), QVector3D(0, 1, 0), col); // 左面 addFace(QVector3D( 0, 1, 0), QVector3D(0, 0, 1), col); // 顶面 addFace(QVector3D( 0, -1, 0), QVector3D(0, 0, 1), col); // 底面 createVaoVbo(m_cubeVao, m_cubeVbo, cubeData); } // ========================================== // 4. 定义场景中的立方体实例 (Instancing Data) // ========================================== // 我们只生成了一个"单位立方体"的网格数据,通过 Model 矩阵把它缩放、移动到不同位置 m_cubes = { // { 位置(x,y,z), 缩放大小, 旋转轴(X,Y,Z), 转速, 初始旋转角度 } { { 0.0f, 0.60f, 0.0f}, 1.2f, {0, 1, 0}, 20.0f, 0.0f }, // 中心 { { 6.0f, 1.00f, -4.0f}, 2.0f, {0, 1, 0}, 12.0f, 30.0f }, // 右侧 { {-7.0f, 0.50f, 5.0f}, 1.0f, {1, 1, 0}, 18.0f, 60.0f }, // 左侧 { { 4.0f, 0.35f, 8.0f}, 0.7f, {0, 0, 1}, 28.0f, 0.0f }, // 远处 { {-4.0f, 3.50f, -8.0f}, 1.5f, {1, 0, 1}, 9.0f, 45.0f } // 悬浮高台 }; }渲染
包含相机变化渲染、地面表格按原有配置渲染、RGB坐标轴按原有配置渲染,5个立方体渲染等。
// ============================================================ // 渲染 // ============================================================ void GLWidget::paintGL() { // 1.清屏 glClearColor(0.10f, 0.12f, 0.16f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 帧间隔 dt(切后台回来防跳变) const qint64 now = m_timer.elapsed(); float dt = float(now - m_lastTime) / 1000.0f; m_lastTime = now; dt = qMin(dt, 0.1f); m_camera.setViewportSize(width(), height()); m_camera.update(dt); // FPS 键盘移动 //根据camera的实际变化进行渲染 m_program.bind(); m_program.setUniformValue("uView", m_camera.viewMatrix()); m_program.setUniformValue("uProjection", m_camera.projectionMatrix(float(width()) / qMax(1, height()))); m_program.setUniformValue("uViewPos", m_camera.position()); // —— 地面网格(线,不受光照)—— QMatrix4x4 identity; m_program.setUniformValue("uIsLine", true); m_program.setUniformValue("uModel", identity); //网格线比较简单,用vao记录的配置信息直接画即可 m_gridVao.bind(); glDrawArrays(GL_LINES, 0, m_gridVertexCount); m_gridVao.release(); // —— RGB坐标轴比较简单,用vao记录的配置信息直接画即可 m_axesVao.bind(); glLineWidth(2.0f); glDrawArrays(GL_LINES, 0, 6); glLineWidth(1.0f); m_axesVao.release(); // —— 5个立方体(光照)—— m_program.setUniformValue("uIsLine", false); m_cubeVao.bind(); const float t = float(m_timer.elapsed()) / 1000.0f; for (const auto& cb : m_cubes) { QMatrix4x4 m; m.translate(cb.pos); m.rotate(cb.rotAngle0 + cb.rotSpeed * t, cb.rotAxis); m.scale(cb.scale); //根据立方体的Model矩阵进行移动 m_program.setUniformValue("uModel", m); glDrawArrays(GL_TRIANGLES, 0, 36); } m_cubeVao.release(); m_program.release(); // 节流刷新状态栏(10Hz 足够)--发信号给主框架 if (now - m_lastInfoEmit > 100) { m_lastInfoEmit = now; emit cameraInfoChanged(infoString()); } }键值响应
首先需要在构造函数GLWidget中,设置键盘焦点和鼠标追踪,然后重载相关事件
setFocusPolicy(Qt::StrongFocus); // 键盘焦点 setMouseTracking(true); // 鼠标追踪protected: void mousePressEvent (QMouseEvent* e) override; void mouseMoveEvent (QMouseEvent* e) override; void mouseReleaseEvent(QMouseEvent* e) override; void wheelEvent (QWheelEvent* e) override; void keyPressEvent (QKeyEvent* e) override; void keyReleaseEvent (QKeyEvent* e) override; void focusOutEvent (QFocusEvent* e) override; // ============================================================ // 输入事件 → 相机(纯 FPS) // ============================================================ void GLWidget::mousePressEvent(QMouseEvent* e) { #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) m_camera.onMousePressed(e->position(), e->button()); #else m_camera.onMousePressed(QPointF(e->pos()), e->button()); #endif setCursor(Qt::BlankCursor); // FPS 转视角时隐藏光标,接近游戏手感 e->accept(); } void GLWidget::mouseMoveEvent(QMouseEvent* e) { #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) m_camera.onMouseMoved(e->position()); #else m_camera.onMouseMoved(QPointF(e->pos())); #endif e->accept(); } void GLWidget::mouseReleaseEvent(QMouseEvent* e) { m_camera.onMouseReleased(); setCursor(Qt::ArrowCursor); e->accept(); } void GLWidget::wheelEvent(QWheelEvent* e) { m_camera.onWheelScrolled(float(e->angleDelta().y())); e->accept(); } void GLWidget::keyPressEvent(QKeyEvent* e) { if (e->isAutoRepeat()) { e->accept(); return; } switch (e->key()) { case Qt::Key_F: // 复位到默认出生机位 m_camera.reset(); break; default: // 其余按键(WASD / QE / Shift)交给相机做移动 m_camera.onKeyPressed(e->key()); break; } e->accept(); } void GLWidget::keyReleaseEvent(QKeyEvent* e) { if (!e->isAutoRepeat()) m_camera.onKeyReleased(e->key()); e->accept(); } void GLWidget::focusOutEvent(QFocusEvent* e) { m_camera.clearKeys(); // 防止失焦后按键卡死 QOpenGLWidget::focusOutEvent(e); }5.摄像机主体
在【qt openGL入门笔记】坐标系统和摄像机-CSDN博客中已经提到过,摄像机简单的理解是改变V(View) + P(Projection) 的参数来实现的实例。原理上也简单:
- Model(模型矩阵):负责摆在世界哪里、多大、怎么转,在3D渲染主体类GLWidget中的几何构建和渲染中,都含了地面网格、RGB坐标轴、立方体具体的变动效果;
- View(视图矩阵):负责让眼睛(相机)站在哪(m_position)、朝哪看(m_yaw / m_pitch);
- Projection(投影矩阵):负责镜头的“广角/焦距”,把3D压成2D(m_fov / m_nearZ / m_farZ)。
可以理解为VP侧重于“观察者的属性”,属于VP具体的实现在Camera类中实现,以下是完整代码
Camera.h
#pragma once #include <QMatrix4x4> #include <QVector3D> #include <QPointF> #include <QSet> /** * 第一人称(FPS)相机: * - 左键拖动转视角(Yaw / Pitch) * - WASD 前后左右平移,Q/E 升降,Shift 加速 * - 滚轮调节移动速度 * - F 复位到默认出生机位 */ class Camera { public: Camera(); QMatrix4x4 viewMatrix() const; QMatrix4x4 projectionMatrix(float aspect) const; void onMousePressed(const QPointF& pos, Qt::MouseButton button); void onMouseMoved(const QPointF& pos); void onMouseReleased(); void onWheelScrolled(float angleDeltaY); void onKeyPressed(int key); void onKeyReleased(int key); void clearKeys(); void update(float dt); void setViewportSize(int w, int h) { m_viewportW = w; m_viewportH = h; } /** 配置默认出生机位:相机位于 eye,看向 center */ void setSpawnPose(const QVector3D& eye, const QVector3D& center); /** 立即摆位到默认出生机位 */ void applySpawnPose(); /** F 复位:恢复默认出生机位与速度(FPS 瞬间重置) */ void reset(); QVector3D position() const { return m_position; } float yaw() const { return m_yaw; } float pitch() const { return m_pitch; } float moveSpeed() const { return m_moveSpeed; } private: void updateCameraVectors(); QVector3D m_position{0, 0, 0}; QVector3D m_front{0, 0, -1}; QVector3D m_up{0, 1, 0}; QVector3D m_right{1, 0, 0}; QVector3D m_worldUp{0, 1, 0}; float m_yaw = -90.0f; float m_pitch = -15.0f; float m_fov = 45.0f; float m_nearZ = 0.1f; float m_farZ = 10000.0f; float m_lookSensitivity = 0.15f; float m_moveSpeed = 12.0f; QSet<int> m_keysHeld; QPointF m_lastPos; bool m_rotating = false; int m_viewportW = 1280; int m_viewportH = 800; // —— 默认出生机位(启动 & reset 的唯一数据源)—— QVector3D m_spawnEye; QVector3D m_spawnCenter; static constexpr float kDefaultMoveSpeed = 12.0f; };Camera.cpp
#include "Camera.h" #include <QtMath> // 1. 眼睛在空间中的位置 m_position(你站在哪里) // 2. 眼睛朝向哪里,用两个角度描述:m_yaw(偏航角,左右转) 和 m_pitch(俯仰角,抬头低头) // 有了位置和朝向,就能算出 viewMatrix(视图矩阵),告诉 GPU "从哪个角度看世界"。 Camera::Camera() { // —— 默认出生机位:场景后上方悬空(0,6,18),看向场景中心(0,1,0)—— setSpawnPose(QVector3D(0.0f, 6.0f, 18.0f), QVector3D(0.0f, 1.0f, 0.0f)); // 把出生机位的坐标/角度真正写入运行状态 applySpawnPose(); // 根据刚算好的 yaw/pitch,推导出 m_front/m_right/m_up 三个朝向向量 updateCameraVectors(); } // ============================================================ // 默认出生机位:设置 / 应用 // ============================================================ void Camera::setSpawnPose(const QVector3D& eye, const QVector3D& center) { m_spawnEye = eye; m_spawnCenter = center; } void Camera::applySpawnPose() { m_position = m_spawnEye; // 由出生视线方向反解 yaw/pitch(与 updateCameraVectors 互为逆运算) const QVector3D dir = (m_spawnCenter - m_position).normalized(); m_pitch = qRadiansToDegrees(qAsin(qBound(-1.0f, dir.y(), 1.0f))); m_yaw = qRadiansToDegrees(qAtan2(dir.z(), dir.x())); // 运行时状态一并还原 m_moveSpeed = kDefaultMoveSpeed; m_rotating = false; m_keysHeld.clear(); } // ============================================================ // F 复位:恢复默认出生机位(FPS 瞬间重置) // ============================================================ void Camera::reset() { applySpawnPose(); updateCameraVectors(); } // ============================================================ // 核心:由 yaw/pitch 推导朝向向量(pitch 正 = 抬头) // ============================================================ void Camera::updateCameraVectors() { // 把"度"转成"弧度",因为三角函数(sin/cos)只接受弧度 const float radYaw = qDegreesToRadians(m_yaw); const float radPitch = qDegreesToRadians(m_pitch); m_front = QVector3D( qCos(radYaw) * qCos(radPitch), qSin(radPitch), qSin(radYaw) * qCos(radPitch) ).normalized(); // 右手方向 = 正前方 与 世界上方向(Y轴) 的叉积。 m_right = QVector3D::crossProduct(m_front, m_worldUp).normalized(); // 头顶方向 = 右手方向 与 正前方 的叉积,再次利用叉积得到第三个垂直轴 m_up = QVector3D::crossProduct(m_right, m_front).normalized(); } // ============================================================ // 视图矩阵(View):把"世界"变换成"从相机角度看"的样子 // ============================================================ QMatrix4x4 Camera::viewMatrix() const { QMatrix4x4 view; view.lookAt(m_position, m_position + m_front, m_worldUp); return view; } // ============================================================ // 投影矩阵(Projection):定义相机的"镜头"(透视效果) // ============================================================ QMatrix4x4 Camera::projectionMatrix(float aspect) const { QMatrix4x4 proj; proj.perspective(m_fov, aspect, m_nearZ, m_farZ); return proj; } // ============================================================ // 鼠标交互(FPS:左键按住拖动 = 转视角) // ============================================================ void Camera::onMousePressed(const QPointF& pos, Qt::MouseButton button) { m_lastPos = pos; if (button == Qt::LeftButton) m_rotating = true; } void Camera::onMouseMoved(const QPointF& pos) { const float dx = float(pos.x() - m_lastPos.x()); const float dyUp = float(m_lastPos.y() - pos.y()); // 屏幕 y 向下为正,取反成"抬头为正" m_lastPos = pos; if (m_rotating) { m_yaw += dx * m_lookSensitivity; m_pitch = qBound(-89.0f, m_pitch + dyUp * m_lookSensitivity, 89.0f); updateCameraVectors(); } } void Camera::onMouseReleased() { m_rotating = false; } // FPS:滚轮调节移动速度(不是缩放场景) void Camera::onWheelScrolled(float angleDeltaY) { const float steps = angleDeltaY / 120.0f; m_moveSpeed *= qPow(1.15f, steps); m_moveSpeed = qBound(1.0f, m_moveSpeed, 300.0f); } // ============================================================ // 每帧更新:FPS 键盘移动 // ============================================================ void Camera::update(float dt) { QVector3D dir; if (m_keysHeld.contains(Qt::Key_W)) dir += m_front; if (m_keysHeld.contains(Qt::Key_S)) dir -= m_front; if (m_keysHeld.contains(Qt::Key_D)) dir += m_right; if (m_keysHeld.contains(Qt::Key_A)) dir -= m_right; if (m_keysHeld.contains(Qt::Key_E)) dir += m_worldUp; if (m_keysHeld.contains(Qt::Key_Q)) dir -= m_worldUp; if (dir.lengthSquared() > 1e-6f) { dir.normalize(); const float boost = m_keysHeld.contains(Qt::Key_Shift) ? 3.0f : 1.0f; m_position += dir * (m_moveSpeed * boost * dt); m_position.setY(qMax(m_position.y(), 0.5f)); // 防钻地 } } void Camera::onKeyPressed(int key) { m_keysHeld.insert(key); } void Camera::onKeyReleased(int key) { m_keysHeld.remove(key); } void Camera::clearKeys() { m_keysHeld.clear(); }