在本节中,我们将通过键盘来控制我们的视角,通过鼠标的操作来移动、旋转以及缩放我们的模型。
视角控制
我们通过方向键/WASD 来移动我们的视角,通过 Ctrl + 方向键/WASD 来旋转视角。
Camera 类
首先创建一个 Camera 类,它接受 3 个参数(相机位置,视觉中心,上向量),用于确定视图矩阵。
cpp
class Camera {
public:
Camera(glm::vec3 position, glm::vec3 center, glm::vec3 up);
~Camera();
glm::vec3 getViewCenter() const { return m_center; }
glm::vec3 getPosition() const { return m_position; }
glm::mat4 getViewMat() const { return m_view; }
// 移动相机位置和视觉中心,模拟人物移动
void move(float dx, float dy, float dz);
// 旋转相机视角,模拟抬头低头,左转右转
void rotate(float yaw, float pitch);
private:
// 相机位置、视觉中心、上向量
glm::vec3 m_position;
glm::vec3 m_center;
glm::vec3 m_up;
glm::mat4 m_view;
};
注册键盘事件回调
在我们的事件循环里调用 processInput(window, &camera);处理键盘输入事件,同时把 Camera 的指针传给 glfw。
cpp
void processInput(GLFWwindow *window, Camera* camera)
{
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
// 检查是否按下了Ctrl键
bool ctrlPressed = (glfwGetKey(window, GLFW_KEY_LEFT_CONTROL) == GLFW_PRESS ||
glfwGetKey(window, GLFW_KEY_RIGHT_CONTROL) == GLFW_PRESS);
// 移动速度和旋转速度
const float moveSpeed = 0.02f;
const float rotateSpeed = 0.002f;
if (ctrlPressed) {
// Ctrl + 方向键/WASD: 旋转相机视角
if (glfwGetKey(window, GLFW_KEY_LEFT) == GLFW_PRESS || glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS) {
camera->rotate(rotateSpeed, 0.0f);
}
if (glfwGetKey(window, GLFW_KEY_RIGHT) == GLFW_PRESS || glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS) {
camera->rotate(-rotateSpeed, 0.0f);
}
if (glfwGetKey(window, GLFW_KEY_UP) == GLFW_PRESS || glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS) {
camera->rotate(0.0f, rotateSpeed);
}
if (glfwGetKey(window, GLFW_KEY_DOWN) == GLFW_PRESS || glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS) {
camera->rotate(0.0f, -rotateSpeed);
}
} else {
// 方向键/WASD: 移动相机位置和视觉中心
if (glfwGetKey(window, GLFW_KEY_LEFT) == GLFW_PRESS || glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS) {
camera->move(-moveSpeed, 0.0f, 0.0f);
}
if (glfwGetKey(window, GLFW_KEY_RIGHT) == GLFW_PRESS || glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS) {
camera->move(moveSpeed, 0.0f, 0.0f);
}
if (glfwGetKey(window, GLFW_KEY_UP) == GLFW_PRESS || glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS) {
camera->move(0.0f, 0.0f, moveSpeed);
}
if (glfwGetKey(window, GLFW_KEY_DOWN) == GLFW_PRESS || glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS) {
camera->move(0.0f, 0.0f, -moveSpeed);
}
}
}
在键盘按键事件回调函数中,根据按下的键的不同调用 camera 对应的函数,move 用于控制视角平移,rotate 用于控制视角的旋转,moveSpeed 和 rotateSpeed 用于控制移动和旋转的速度。
平移视角
cpp
void Camera::move(float dx, float dy, float dz)
{
// 计算相机的前方向向量(从相机位置指向视觉中心)
glm::vec3 forward = glm::normalize(m_center - m_position);
// 计算右向量(前向量 × 上向量)
glm::vec3 right = glm::normalize(glm::cross(forward, m_up));
// dx 沿右向量移动,dz 沿前向量移动,dy 沿世界 Y 轴移动
glm::vec3 movement = right * dx + forward * dz + glm::vec3(0.0f, dy, 0.0f);
// 同时更新相机位置和视觉中心
m_position += movement;
m_center += movement;
m_view = glm::lookAt(m_position, m_center, m_up);
}
当我们按下 w 时,人物往前移动,对应 z 轴变小(s 同理),所以需要沿着 z 轴移动相机位置,而视觉中心移不移动其实没太大影响,模型在视野中的大小跟模型位置和相机位置的距离有关,跟视觉中心无关,视觉中心影响的其实是看的角度,这里视觉中心做同样的位移主要是左右移动时,看的方向不变。按下 a / d 时,我们需要沿着 x 轴移动相机位置和视觉中心,由于我们只有前后(z 轴)左右(x 轴)移动,所以 y 轴没有变换,如果有跳跃动作,那么就需要加上 y 轴的变换了。
旋转视角
cpp
void Camera::rotate(float yaw, float pitch)
{
// 计算相机的前方向向量(从相机位置指向视觉中心)
glm::vec3 forward = glm::normalize(m_center - m_position);
glm::vec3 right = glm::normalize(glm::cross(forward, m_up));
glm::vec3 up = glm::normalize(glm::cross(right, forward));
// 绕右向量旋转(垂直旋转pitch)- 抬头/低头
glm::mat4 pitchRotation = glm::rotate(glm::mat4(1.0f), pitch, right);
forward = glm::vec3(pitchRotation * glm::vec4(forward, 0.0f));
up = glm::vec3(pitchRotation * glm::vec4(up, 0.0f));
// 绕世界上向量旋转(水平旋转yaw)- 左转/右转
glm::mat4 yawRotation = glm::rotate(glm::mat4(1.0f), yaw, glm::vec3(0.0f, 1.0f, 0.0f));
forward = glm::vec3(yawRotation * glm::vec4(forward, 0.0f));
right = glm::vec3(yawRotation * glm::vec4(right, 0.0f));
up = glm::vec3(yawRotation * glm::vec4(up, 0.0f));
// 更新视觉中心(保持相机位置不变)
float distance = glm::length(m_center - m_position);
m_center = m_position + glm::normalize(forward) * distance;
// 更新上向量
m_up = glm::normalize(up);
// 重新计算视图矩阵
m_view = glm::lookAt(m_position, m_center, m_up);
}
想象一下,我们看向某个点,眼睛就是相机位置,看向的点就是视觉中心,鼻尖指向眉心的方向就是上向量,眼睛到右耳的方向就是右向量。当我们抬头/低头时,其实就是沿着眼睛到右耳这条轴旋转,左右摇头时就是沿着鼻尖指向眉心这条轴旋转。还有歪头,我们代码中没有,但是也是同理的,沿着眼睛到看的点这条轴旋转。
模型控制
我们将通过按住鼠标左键,移动鼠标来移动模型,按住鼠标右键,移动鼠标,旋转模型。
注册鼠标事件回调
首先我们创建一个 Transform 用来处理鼠标事件,并计算变换矩阵。
cpp
float m_scale = 1.0f;
// 鼠标事件
bool m_leftMousePressed = false;
bool m_rightMousePressed = false;
double m_lastMouseX;
double m_lastMouseY;
m_scale 用于记录缩放比例, 其余几个成员变量用于记录鼠标状态。
cpp
void handleMousePress(GLFWwindow* window, int button, int action);
void handleMouseMove(GLFWwindow* window, double xpos, double ypos);
void handleScroll(double yoffset);
这三个函数用于处理鼠标点击,移动,滚轮滚动事件。
cpp
Transform transform;
glfwSetWindowUserPointer(window, &transform);
// 设置回调
glfwSetMouseButtonCallback(window, mouseButtonCallback);
glfwSetCursorPosCallback(window, cursorPosCallback);
glfwSetScrollCallback(window, scrollCallback);
我们创建一个 Transform 的实例,把它的地址设置到 glfw 里,设置鼠标点击,移动,滚轮滚动回调。
cpp
void mouseButtonCallback(GLFWwindow* window, int button, int action, int mods)
{
Transform* transform = static_cast<Transform*>(glfwGetWindowUserPointer(window));
if (transform) {
transform->handleMousePress(window, button, action);
}
}
void cursorPosCallback(GLFWwindow* window, double xpos, double ypos)
{
Transform* transform = static_cast<Transform*>(glfwGetWindowUserPointer(window));
if (transform) {
transform->handleMouseMove(window, xpos, ypos);
}
}
void scrollCallback(GLFWwindow* window, double xoffset, double yoffset)
{
Transform* transform = static_cast<Transform*>(glfwGetWindowUserPointer(window));
if (transform) {
transform->handleScroll(yoffset);
}
}
然后在回调函数里调用对应的函数去处理这些事件。
平移
平移很简单,我们只需要在鼠标左键按下时记录鼠标的位置,然后在鼠标移动时,记录当前的鼠标位置,计算这两个屏幕位置对应的世界坐标的差值,最后根据这个差值生成平移矩阵即可。
cpp
glm::vec3 currentWorldPos = getMouseWorldPosition(xpos, ypos, width, height);
glm::vec3 lastWorldPos = getMouseWorldPosition(m_lastMouseX, m_lastMouseY, width, height);
glm::vec3 worldDelta = currentWorldPos - lastWorldPos;
glm::mat4 translationMatrix = glm::translate(glm::mat4(1.0f), worldDelta);
m_transformMat = translationMatrix * m_transformMat;
世界坐标的计算可以参考这篇文章 OpenGL 屏幕坐标转换为世界坐标
旋转
我们可以想象,在我们的屏幕上有一个球体,鼠标点击,选中这个球体表面上的某个点,移动,相当于旋转这个球,使得这个点移动到鼠标移动的位置。
首先我们需要计算鼠标点击的位置对应这个球体表面的坐标
cpp
glm::vec3 Transform::screenToSphere(double screenX, double screenY, int windowWidth, int windowHeight) {
// 归一化鼠标坐标到[-1, 1](原点在屏幕中心)
glm::vec3 spherePos;
spherePos.x = (2.0f * screenX / windowWidth - 1.0f);
spherePos.y = (1.0f - 2.0f * screenY / windowHeight);
spherePos.z = 0.0f;
// 计算z值(保证点在球体表面)
float lengthSq = spherePos.x * spherePos.x + spherePos.y * spherePos.y;
if (lengthSq <= 1.0f) {
spherePos.z = std::sqrt(1.0f - lengthSq);
} else {
spherePos = glm::normalize(spherePos);
spherePos.z = 0.0f;
}
return spherePos;
}
计算鼠标点击时对应球体表面的坐标,以及鼠标移动时对应的坐标,把这两个坐标分别和球心连接起来,它们的夹角就是旋转的角度,垂直于它们的直线就是旋转轴。然后根据旋转轴和旋转角,我们就能生成一个旋转矩阵。
cpp
// 将鼠标坐标映射到球体表面
glm::vec3 currentSpherePos = screenToSphere(xpos, ypos , width, height);
glm::vec3 lastSpherePos = screenToSphere(m_lastMouseX, m_lastMouseY, width, height);
// 计算旋转轴(垂直于两个向量的叉积)
glm::vec3 rotationAxis = glm::cross(lastSpherePos, currentSpherePos);
// 计算旋转角度(基于两个向量的点积)
float dotProduct = glm::dot(glm::normalize(lastSpherePos), glm::normalize(currentSpherePos));
float rotationAngle = std::acos(glm::clamp(dotProduct, -1.0f, 1.0f));
glm::mat4 rotationMatrix = glm::rotate(glm::mat4(1.0f), rotationAngle, rotationAxis);
m_transformMat = rotationMatrix * m_transformMat;
缩放
缩放需要注意的点是,为了防止缩放时物体位置发生改变,我们需要将物体移动到视觉中心,进行缩放,再移回原来的位置。
cpp
float scaleFactor = 1.0f + static_cast<float>(yoffset) * 0.1f;
m_scale *= scaleFactor;
m_scale = glm::clamp(m_scale, 0.01f, 100.0f);
// 围绕视图中心缩放
glm::mat4 translateToOrigin = glm::translate(glm::mat4(1.0f), -m_viewCenter);
glm::mat4 scaleMatrix = glm::scale(glm::mat4(1.0f), glm::vec3(scaleFactor));
glm::mat4 translateBack = glm::translate(glm::mat4(1.0f), m_viewCenter);
m_transformMat = translateBack * scaleMatrix * translateToOrigin * m_transformMat;