前言
前几篇比较随意,本篇知识点接续**坐标系统和摄像机**,按照比较规范的格式来提供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(); }