Vulkan模型查看器设计:相机类与三维变换
相机类
三维场景中的相机参数设置与更新统一用一个相机类管理。相机提供获取视图矩阵、获取投影矩阵用于设置模型的三维变换。
相机提供三种交互模式:1)拖拽(PAN模式);2)第一人称浏览模式(FPS);3)轨迹球模式(Orbit)。
一、接口定义 Camera.h
class Camera {
public:glm::vec3 position; // 相机位置glm::vec3 front; // 相机前向向量glm::vec3 up; // 相机上方向glm::vec3 right; // 相机右向量glm::vec3 worldUp; // 世界坐标系上方向float yaw; // 偏航角float pitch; // 俯仰角float fovy; // 垂直视场角float near; // 近面float far; // 远面float movementSpeed; // 移动速度float mouseSensitivity; // 鼠标敏感度enum CameraMovement { FORWARD, BACKWARD, LEFT, RIGHT }; // 相机移动方向enum OperationMode { ORBIT, FPS, PAN }; // 操作模式Camera(glm::vec3 position, glm::vec3 worldUp, float yaw = YAW, float pitch = PITCH);void init(); // 初始化glm::mat4 getViewMatrix() const; // 获取视图矩阵glm::mat4 getProjectionMatrix(float aspectRatio) const; // 获取投影矩阵void setOperationMode(OperationMode mode); // 设置交互模式void processKeyboard(CameraMovement direction, float deltaTime); // 处理键盘操作void processMouseMovement(float xoffset, float yoffset); // 处理了鼠标移动void processMouseScroll(float yoffset, float deltaTime); // 处理缩放void updateCameraVectors(); // 更新相机向量private:OperationMode currentMode; // 当前模式glm::vec3 target; // 观察目标float panSpeed; // 平移速度系数float zoomSpeed; // 缩放系数float orbitSpeed; // 环绕旋转速度float orbitTheta; // 环绕角度float orbitPhi; // 垂直角度float radius; // 初始相机距离glm::vec3 computeScreenCenterTarget(); // 计算平面中心在世界坐标系XZ平面投影点
};
二、WASD 模式
通过键盘 WASD 键控制相机位置前进后退。
- 输入处理
/*** 处理键盘输入事件,控制窗口和相机行为* @param window GLFW窗口句柄,用于获取键盘状态*/
void processInput(GLFWwindow* window)
{// 若ImGui需要捕获鼠标输入,则不处理键盘事件if (ImGui::GetIO().WantCaptureMouse) return; // ESC键:关闭窗口if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)glfwSetWindowShouldClose(window, true);// WASD键:控制相机移动if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)camera.processKeyboard(Camera::FORWARD, deltaTime);if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)camera.processKeyboard(Camera::BACKWARD, deltaTime);if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)camera.processKeyboard(Camera::LEFT, deltaTime);if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)camera.processKeyboard(Camera::RIGHT, deltaTime);
}
- 相机移动处理
/*** 处理键盘控制的相机移动* @param direction 移动方向(枚举值:前后左右)* @param deltaTime 帧间隔时间,用于平滑移动速度*/
void Camera::processKeyboard(CameraMovement direction, float deltaTime) {// 设置为第一人称视角模式setOperationMode(FPS);// 计算移动速度(与帧间隔时间成正比,确保帧率无关的平滑移动)float velocity = movementSpeed * deltaTime;// 根据方向更新相机位置if (direction == FORWARD) {position += front * velocity; // 向前移动} else if (direction == BACKWARD) {position -= front * velocity; // 向后移动} else if (direction == LEFT) {// 计算右向量(与front和up垂直)并向左移动position -= glm::normalize(glm::cross(front, up)) * velocity;}if (direction == RIGHT) {// 计算右向量并向右移动position += glm::normalize(glm::cross(front, up)) * velocity;}// 更新相机目标点(始终位于相机前方)target = position + front;
}
三、PAN 模式
鼠标左键按住拖拽三维场景。
- 鼠标事件回调,计算偏移量,判断事件类型
/*** GLFW鼠标回调函数,处理鼠标移动事件并控制相机行为* @param window GLFW窗口句柄* @param xposIn 鼠标当前位置的x坐标(屏幕空间)* @param yposIn 鼠标当前位置的y坐标(屏幕空间)*/
void mouseCallback(GLFWwindow* window, double xposIn, double yposIn)
{// 若ImGui需要捕获鼠标输入,则不处理(避免与UI交互冲突)if (ImGui::GetIO().WantCaptureMouse) return; // 将double类型坐标转换为floatfloat xpos = static_cast<float>(xposIn);float ypos = static_cast<float>(yposIn);// 首次鼠标移动时初始化上一帧位置if(firstMouse){lastX = xpos;lastY = ypos;firstMouse = false;}// 计算鼠标从上次事件到当前的位移量(像素)float xoffset = xpos - lastX;float yoffset = ypos - lastY;// 更新上一帧位置为当前位置,准备下一帧计算lastX = xpos;lastY = ypos;// 根据按下的鼠标按钮执行不同的相机操作模式if (leftMousePressed) {// 左键:平移模式(在屏幕平面上移动相机)camera.setOperationMode(Camera::PAN);camera.processMouseMovement(xoffset, yoffset);}...
- 处理鼠标拖拽
根据相机位置与目标位置的距离动态计算拖动速度。
void Camera::processMouseMovement(float xoffset, float yoffset) {switch (currentMode) {case PAN: {float distance = glm::length(position - target); // 计算相机与目标位置距离float dynamicPanSpeed = panSpeed * std::max(distance, 1.0f); // 根据相机距离动态调整平移速度,距离越远速度越快。xoffset *= dynamicPanSpeed;yoffset *= dynamicPanSpeed;// 鼠标向右拖拽,xOffset是正的,但这时候需要场景向右移动,所以相机应该向左移动,故取-xOffset// yoffset为什么不取反呢?因为 vulkan 的NDC坐标系Y轴正方向是朝下的,和OpenGL相反,OpenGL需要取反,Vulkan 不用glm::vec3 delta = right * (-xoffset) + up * yoffset;position += delta; // 相机位置沿计算的位移向量移动。target += delta; // 目标点同步移动,保持相机与目标的相对位置不变。break;...
- 数学原理
- 平移操作基于相机的局部坐标系;
- 右向量 (right):与屏幕水平方向平行,指向相机右侧。
- 上向量 (up):与屏幕垂直方向平行,指向上方。
- 位移向量:将鼠标偏移量转换为世界坐标系中的实际位移,通过右向量和上向量的线性组合实现。
- 动态速度的设计是为了在大场景中保持良好的操作体验:当相机远离原点时,需要更大的位移量才能产生明显的视觉变化,因此速度随距离增加。
四、FPS 模式
第一人称模式就是以相机自身位置为原点,调整偏航角(yaw)和俯仰角(pitch)。
void CameraManager::processMouseMovement(float xoffset, float yoffset) {switch(currentMode) {case FPS: {// 将鼠标偏移量乘以灵敏度系数,控制旋转速度xoffset *= mouseSensitivity;yoffset *= mouseSensitivity;// 更新相机的偏航角(Yaw)和俯仰角(Pitch)// 偏航角: 水平旋转角度(左右)// 俯仰角: 垂直旋转角度(上下)yaw += xoffset;pitch += yoffset;// 限制俯仰角范围在[-89°, 89°]之间// 避免超过90°导致视角翻转(万向节死锁)if (pitch > 89.0f)pitch = 89.0f;if (pitch < -89.0f)pitch = -89.0f;// 根据更新后的欧拉角重新计算相机的前向向量、右向量和上向量// 这些向量决定了相机的朝向和姿态updateCameraVectors();// 更新目标点位置// 目标点始终位于相机前方"front"向量方向上target = position + front;break;}...
}
...void Camera::updateCameraVectors() {// 根据欧拉角计算新的前向向量glm::vec3 newFront;newFront.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));newFront.y = sin(glm::radians(pitch));newFront.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));front = glm::normalize(newFront);// 重新计算右向量和上向量// 右向量 = 前向向量 叉乘 世界上方向right = glm::normalize(glm::cross(front, worldUp));// 上向量 = 右向量 叉乘 前向向量up = glm::normalize(glm::cross(right, front));
}
...
五、Orbit 模式
轨迹球模式,以相机在XZ平面上的投影点为球心,相机与投影点距离为球径进行绕球心旋转,相机就像球面上的点在球面上运动实时改变位置和前向向量观察目标。XZ是右手坐标系的X轴和Z轴构成,也是世界舞台的基准面。
- 计算投影点(球心坐标)
在设置交互模式时,只在从其它模式切换到轨迹球模式时计算投影点为目标点:
void Camera::setOperationMode(OperationMode mode) {if (mode == ORBIT && currentMode != ORBIT) {// 计算新的目标点,即屏幕中心投影到XZ平面的点glm::vec3 newTarget = computeScreenCenterTarget();glm::vec3 delta = position - newTarget;int newRadius = glm::length(delta);if (newRadius == 0) { // 相机前向向量位于XZ平面上currentMode = PAN;return;}radius = newRadius;target = newTarget;// 计算初始环绕角度glm::vec3 dir = glm::normalize(delta);orbitPhi = glm::degrees(asin(dir.y));orbitTheta = glm::degrees(atan2(dir.z, dir.x));}currentMode = mode;}// 计算平面中心在XY平面上的投影glm::vec3 Camera::computeScreenCenterTarget() {glm::vec3 direction = front;// 如果相机的前向向量完全水平(即 direction.y == 0),则直接返回相机在水平面(y=0)上的投影点。// 此时目标点的 x 和 z 坐标与相机位置相同,y 坐标为 0。if (direction.y == 0.0f) {return glm::vec3(position.x, 0.0f, position.z);}/**当相机前向向量不水平时,计算射线(从相机位置出发,沿前向向量方向)与水平面(y=0)的交点:t 是射线参数,表示从相机位置沿前向向量移动多远能到达水平面。通过参数方程 position + t * direction 计算交点坐标,确保 y 坐标为 0。**/float t = -position.y / direction.y;return glm::vec3(position.x + t * direction.x, 0.0f, position.z + t * direction.z);}
- 轨迹球计算
case ORBIT: {xoffset *= orbitSpeed;yoffset *= -orbitSpeed;orbitTheta += xoffset; orbitPhi -= yoffset;orbitPhi = glm::clamp(orbitPhi, -89.0f, 89.0f);position.x = target.x + radius * cos(glm::radians(orbitTheta)) * cos(glm::radians(orbitPhi));position.y = target.y + radius * sin(glm::radians(orbitPhi));position.z = target.z + radius * sin(glm::radians(orbitTheta)) * cos(glm::radians(orbitPhi));glm::vec3 newFront = glm::normalize(target - position);pitch = glm::degrees(asin(newFront.y));yaw = glm::degrees(atan2(newFront.z, newFront.x));updateCameraVectors();break;
}
这段代码处理轨迹球模式下的相机旋转逻辑:
-
输入处理:
xoffset
和yoffset
是鼠标/触摸的位移量,分别控制水平和垂直旋转。orbitSpeed
是旋转灵敏度系数,用于调整旋转速度。yoffset
取负是因为垂直方向上的鼠标移动与俯仰角(pitch)变化方向相反。
-
角度更新:
orbitTheta
(方位角):水平旋转角度,控制左右环绕。orbitPhi
(俯仰角):垂直旋转角度,控制上下环绕。glm::clamp(orbitPhi, -89.0f, 89.0f)
防止俯仰角超过±89°,避免相机翻转(万向节死锁)。
-
球面坐标计算:
- 使用球坐标系公式,根据
orbitTheta
、orbitPhi
和radius
(相机与目标点的距离)计算相机新位置:
其中x = target.x + radius * cos(θ) * cos(φ) y = target.y + radius * sin(φ) z = target.z + radius * sin(θ) * cos(φ)
θ
是方位角,φ
是俯仰角。
- 使用球坐标系公式,根据
-
相机朝向更新:
newFront = glm::normalize(target - position)
:计算从相机位置指向目标点的前向向量。- 根据前向向量重新计算
pitch
和yaw
角度(用于其他相机模式)。 updateCameraVectors()
:更新相机的其他向量(如右向量、上向量),确保视图正确渲染。
-
数学原理
轨迹球旋转基于球坐标系:- 方位角(θ):绕Y轴旋转的角度,范围通常是0°~360°。
- 俯仰角(φ):绕X轴旋转的角度,范围通常是-90°~90°(水平面以下为负,以上为正)。
- 半径(radius):相机到目标点的距离,保持不变。
通过这三个参数,可以将球坐标系转换为笛卡尔坐标系(代码中的
position.x
、position.y
、position.z
计算公式)。
三维变换
- 用
glm::translate
实现位移 - 用
glm::scale
实现缩放 - 用
glm::rotate
实现旋转- 旋转采用矩阵左乘方式实现外部旋转(全局旋转)
- 旋转ZYX表示依次绕 X->Y->Z 轴旋转,矩阵乘法是反着的,由于是左乘,最右边的矩阵先应用,顺序是 rotationZ * rotationY * roationX
void initModelTransformUI() {ImGui::Begin("模型变换");ImGui::Text("转换");glm::mat4 model = glm::mat4(1.0f);if (ImGui::DragFloat3("位移", &transform.translation[0], 0.1f)) {model = glm::translate(model, transform.translation);model = glm::rotate(model, glm::radians(transform.rotation.x), glm::vec3(1.0f, 0.0f, 0.0f));model = glm::rotate(model, glm::radians(transform.rotation.y), glm::vec3(0.0f, 1.0f, 0.0f));model = glm::rotate(model, glm::radians(transform.rotation.z), glm::vec3(0.0f, 0.0f, 1.0f));model = glm::scale(model, transform.scale);globalModelMatrix = model;}if (ImGui::DragFloat3("旋转(ZYX)", &transform.rotation[0], 1.0f)) {model = glm::translate(model, transform.translation);model = glm::rotate(model, glm::radians(transform.rotation.x), glm::vec3(1.0f, 0.0f, 0.0f));model = glm::rotate(model, glm::radians(transform.rotation.y), glm::vec3(0.0f, 1.0f, 0.0f));model = glm::rotate(model, glm::radians(transform.rotation.z), glm::vec3(0.0f, 0.0f, 1.0f));model = glm::scale(model, transform.scale);globalModelMatrix = model;}if (ImGui::DragFloat3("缩放", &transform.scale[0], 0.1f)) {model = glm::translate(model, transform.translation);model = glm::rotate(model, glm::radians(transform.rotation.x), glm::vec3(1.0f, 0.0f, 0.0f));model = glm::rotate(model, glm::radians(transform.rotation.y), glm::vec3(0.0f, 1.0f, 0.0f));model = glm::rotate(model, glm::radians(transform.rotation.z), glm::vec3(0.0f, 0.0f, 1.0f));model = glm::scale(model, transform.scale);globalModelMatrix = model;}ImGui::End();
}...
try {while (!glfwWindowShouldClose(window)) {float currentFrame = static_cast<float>(glfwGetTime());deltaTime = currentFrame - lastFrame;lastFrame = currentFrame;processInput(window);glfwPollEvents();ImGui_ImplVulkan_NewFrame();ImGui_ImplGlfw_NewFrame();ImGui::NewFrame();initCameraUI();initModelTransformUI();ImGui::Render(); // 左乘,最右边的initialModelMatrix是固定不变的,因此是全局旋转,即绕世界坐标系旋转objModel->modelMatrix = globalModelMatrix * initialModelMatrix; vkRender(&vkcontext, ImGui::GetDrawData());}vkDeviceWaitIdle(vkcontext.device);
...