PAYATHON 2026

C++ 中如何直接更新 quaternion,实现不保存 yaw 和 pitch 的旋转?

数据阿明

可以。getRotation() 返回的是 glm::quat &,可以用当前 quaternion 保存旋转状态:每次把输入转换为增量 quaternion,再与当前值相乘。

还需要处理原代码中的 pitchLimit,因为增量旋转本身不会限制俯仰角。要保留原有的相机行为,需要根据当前姿态计算允许的旋转量,或者继续由相机控制器维护 pitch。

原代码为什么依赖 yaw 和 pitch

原代码保存累计角度,每次都用它们重新构造姿态:

rotation = glm::normalize(yawQuat * pitchQuat);

直接更新 quaternion 时,输入对应的是本次旋转的角度增量。累计状态已经包含在当前 rotation 中,不必再单独保存 yaw 和 pitch 的累计值。

也不能只把原代码里的 this->yaw、this->pitch 换成输入增量,然后继续覆盖 rotation,那样会丢失此前的姿态。

用增量 quaternion 更新 Transform

下面假设:

  • rotation 将局部坐标中的向量旋转到父坐标系;没有父节点时,父坐标系就是世界坐标系。
  • 上方向为 +Y,局部右方向为 +X。
  • sensitivity 的单位是弧度/输入单位。
  • 当前 rotation 已初始化为有效的单位 quaternion。

可以把通用逻辑写成一个接收 Transform & 的函数,供 Camera 和其他组件调用:

#include <glm/glm.hpp>
#include <glm/gtc/quaternion.hpp>

void rotateTransform(
    Transform &transform,
    const int deltaX,
    const int deltaY,
    const float sensitivity)
{
    const float yawStep = -static_cast<float>(deltaX) * sensitivity;
    const float pitchStep = -static_cast<float>(deltaY) * sensitivity;

    const glm::quat yawDelta = glm::angleAxis(
        yawStep, glm::vec3(0.0f, 1.0f, 0.0f));

    const glm::quat pitchDelta = glm::angleAxis(
        pitchStep, glm::vec3(1.0f, 0.0f, 0.0f));

    glm::quat &rotation = transform.getRotation();
    rotation = glm::normalize(yawDelta * rotation * pitchDelta);
}

在 Camera 中调用:

void Camera::rotate(
    const int deltaX,
    const int deltaY,
    const float sensitivity)
{
    rotateTransform(*this->getTransform(), deltaX, deltaY, sensitivity);
}

这里的 yawStep 和 pitchStep 是函数内的临时增量,没有保存累计 yaw、pitch。这个版本也没有执行原代码的 pitch 限制。

乘法顺序决定旋转轴所属的坐标系

按照上述局部坐标系到父坐标系的旋转约定:

rotation = delta * rotation;

表示沿父坐标系中定义的轴施加增量旋转。

rotation = rotation * delta;

表示沿对象局部坐标系中定义的轴施加增量旋转。

因此,示例中的更新式:

rotation = glm::normalize(yawDelta * rotation * pitchDelta);

让水平输入绕父坐标系的 +Y 轴旋转,让垂直输入绕相机自身的 +X 轴旋转。左右乘不能随意交换,因为 quaternion 乘法通常不满足交换律。

如果初始姿态可写成 Y(yaw) * X(pitch),且没有其他旋转干预,那么忽略 pitch 限制时,这种更新能保持原代码的组合形式:

Y(Δyaw) * [Y(yaw) * X(pitch)] * X(Δpitch)
= Y(yaw + Δyaw) * X(pitch + Δpitch)

如果其他组件已经给对象添加了 roll,这段代码会在现有姿态上继续旋转,不会自动消除 roll。

不保存 pitch 时如何限制俯仰角

如果只是让物体自由旋转,可以不设 pitch 限制。对于不能翻转的相机,可以从当前 quaternion 计算临时俯仰角,再限制本次增量。

下面的写法适用于这些条件:局部前方向为 -Z,上方向为父坐标系的 +Y,姿态没有 roll,并且 pitch 限制严格小于 π/2。它可以替换前面函数中计算 pitchDelta 和赋值 rotation 的部分:

#include <cmath>

// Preconditions: no roll, and 0 < pitchLimit < pi / 2.
glm::quat &rotation = transform.getRotation();
const glm::quat current = glm::normalize(rotation);

const glm::vec3 forward = current * glm::vec3(0.0f, 0.0f, -1.0f);
const float currentPitch = std::asin(
    glm::clamp(forward.y, -1.0f, 1.0f));

const float targetPitch = glm::clamp(
    currentPitch + pitchStep, -pitchLimit, pitchLimit);

const float allowedPitchStep = targetPitch - currentPitch;

const glm::quat pitchDelta = glm::angleAxis(
    allowedPitchStep, glm::vec3(1.0f, 0.0f, 0.0f));

rotation = glm::normalize(yawDelta * current * pitchDelta);

使用这个版本时,需要把 pitchLimit 传入函数,并与旋转角度一样使用弧度。对 forward.y 的 clamp 是为了避免浮点误差让 asin 的输入超出 [-1, 1]。

这里计算的是前向量相对水平面的仰角,不能用来还原任意姿态下无限累计的 pitch。如果对象存在 roll、需要越过竖直方向,或者父坐标系的上方向不符合上述假设,就需要重新定义约束方式。

对于常规相机控制,继续在控制器中保存 yaw、pitch 也很合理。Transform 仍然只保存 quaternion,控制器负责输入和角度限制,通用旋转接口可以照常供其他组件使用。

接入现有 Transform 时需要确认什么

Transform 的声明没有展示构造函数和矩阵计算实现,接入时需要核对以下细节:

  • 初始化:确保 rotation 初始值有效。单位 quaternion 可以明确写成 glm::quat(1.0f, 0.0f, 0.0f, 0.0f),不要对全零 quaternion 做归一化。
  • 角度单位:glm::angleAxis 接收弧度。如果输入灵敏度使用度,需要先通过 glm::radians 转换。
  • 轴方向:示例采用 +Y、+X 和 -Z。如果引擎中的 UP、RIGHT 或前方向不同,需要相应调整;传入 glm::angleAxis 的轴应为单位向量。
  • 父子关系:如果 rotation 保存局部旋转,左乘时的轴属于父坐标系。父节点有旋转时,它未必等于世界坐标系的上方向。要绕世界轴旋转,应先把该轴转换到父坐标系。
  • 变换缓存:如果引擎缓存 local/world matrix,直接通过引用赋值还需要触发对应的更新机制。仅凭当前声明无法确定是否已有这一步。

备注:内容仅供参考。