OpenGL教程系列(第十七课:旋转)

Number of views 93

本教程略微超出了OpenGL的范围,但解决了一个非常常见的问题:如何表示旋转?

教程3《矩阵》中,我们学习了矩阵可以围绕特定轴旋转点。虽然矩阵是变换顶点的便捷方式,但处理矩阵本身却很困难:例如从最终矩阵中提取旋转轴就相当棘手。

我们将介绍两种最常见的旋转表示方式:欧拉角和四元数。最重要的是,我们将解释为什么你应该优先使用四元数。

image1739457814094.png

前言:旋转 vs 方向

在阅读旋转相关文章时,可能会因术语产生困惑。本教程中:

  • 方向是状态:"物体的方向是..."
  • 旋转是操作:"对物体施加此旋转"

即,当你施加旋转时,就改变了方向。两者可用相同方法表示,因此容易混淆。现在让我们开始...

欧拉角

欧拉角是最直观的方向表示方式。通过存储绕X/Y/Z轴的三个旋转角度实现。用vec3即可存储:

vec3 EulerAngles(X轴弧度, Y轴弧度, Z轴弧度);

这3个旋转角度内部会被依次应用,通常按照这个顺序:首先是Y轴,然后是Z轴,最后是X轴(也可以是其它顺序)。使用不同的顺序会产生不同的结果。

欧拉角的一个简单用途是设置角色的方向。通常游戏角色不会在X轴和Z轴上旋转,而只会在垂直轴上旋转。因此,编写、理解和维护一个变量 float direction 比维护三个不同的方向会更容易。

另一个使用欧拉角的好例子是第一人称射击游戏中的摄像机:你有一个用于水平方向的角度(Y轴),以及一个用于俯仰方向的角度(X轴)。可以参考common/controls.cpp中的示例。

然而,当事情变得更加复杂时,欧拉角将变得难以处理。例如:

  • 在两个方向之间平滑插值非常困难。直接对X、Y和Z角度进行插值会显得不自然。
  • 应用多个旋转既复杂又不精确:必须计算最终的旋转矩阵,并从该矩阵中猜测欧拉角。
  • 一个广为人知的问题,万向锁(Gimbal Lock),有时会阻碍旋转,以及其他会导致模型翻转的奇点问题。
  • 不同的角度会产生相同的旋转(例如 -180° 和 180°)。
  • 它很混乱——如上所述,通常正确的顺序是YZX,但如果还使用了具有不同旋转顺序的库,可能会徒增麻烦。
  • 某些操作较为复杂:例如,围绕特定轴旋转N度的操作。

四元数是一种表示旋转的工具,它可以解决这些问题

四元数

四元数通过[x y z w]四个分量表示旋转:

x = 旋转轴.x * sin(旋转角/2)
y = 旋转轴.y * sin(旋转角/2)
z = 旋转轴.z * sin(旋转角/2)
w = cos(旋转角/2)

旋转轴(RotationAxis) 顾名思义,就是你想要进行旋转的轴

旋转角(RotationAngle) 则是围绕该轴旋转的角度(弧度)

image1739458385567.png

因此,四元数本质上存储了一个旋转轴和一个旋转角度,并且以一种便于组合旋转的方式进行存储。这意味着当我们需要将多个旋转操作连接在一起时,使用四元数可以更方便地实现这一目标,而不需要像使用旋转矩阵那样进行复杂的矩阵乘法运算。此外,四元数还能有效避免万向锁问题,提供平滑插值等功能,在3D图形学、游戏开发等领域具有重要的应用价值。

读取四元数

四元数这种方式肯定没有欧拉角直观,但它仍然是可读的:xyz 分量大致对应旋转轴,而 w 的反余弦值 (acos(w))则是旋转角度(需除以 2)。例如,想象你在调试器中看到以下数值:[0.7 0 0 0.7]x=0.7,它比 y 和 z 大,因此你知道这主要是绕 X 轴的旋转;并且 2*acos(0.7) = 1.59 弧度,所以这是一个 90° 的旋转角。

同样地,[0 0 0 1] (w=1)意味着角度 = 2*acos(1) = 0,因此这是一个单位四元数,表示没有任何旋转

特点:

  • 存储旋转轴和角度,便于旋转组合
  • 直观性较差但可解读(xyz约等于旋转轴acos(w)*2=旋转角

基本操作

了解四元数背后的数学原理很少有实际用途:这种表示方法非常不直观,通常你只会依赖那些为你完成数学计算的实用函数。如果你对此感兴趣,可以查看“实用工具与链接”页面中的数学书籍。

C++创建示例

#include <glm/gtc/quaternion.hpp>
quat q; // 单位四元数
quat q(w,x,y,z); // 直接构造
quat q = quat(vec3(90,45,0)); // 欧拉角转四元数
quat q = angleAxis(弧度转角度(旋转角), 旋转轴); // 轴角转四元数

GLSL应用

你不需要直接在GLSL中使用四元数。可以将你的四元数转换为旋转矩阵,并将其用于模型矩阵中。这样,你的顶点会像平常一样通过MVP(模型-视图-投影)矩阵进行旋转

  • 转换为旋转矩阵使用
  • GPU骨骼动画可用vec4存储并自行计算

矩阵转换

mat4 RotationMatrix = toMat4(quaternion);
mat4 ModelMatrix = 平移矩阵 * RotationMatrix * 缩放矩阵;

选择建议

在欧拉角和四元数之间做出选择还是比较头疼的事情。欧拉角对于艺术家来说更为直观,因此如果你正在编写某个3D编辑器,那么应该使用欧拉角。但是,四元数对程序员来说更为方便,并且速度也更快,因此你应该在3D引擎的核心部分使用四元数。

普遍的共识正是如此:在内部使用四元数,并且在任何时候涉及到用户界面时通过暴露欧拉角的方式

  • 3D编辑器:使用欧拉角(艺术家友好)
  • 3D引擎核心:使用四元数(计算高效)
  • 最佳实践:内部使用四元数,UI暴露欧拉角

实用技巧

在使用向量时,点积给出的是这两个向量之间角度的余弦值。如果这个值是1,那么这两个向量处于相同的方向

对于四元数而言也是一样的

四元数相似性检测

float相似度 = dot(q1,q2);
if(abs(相似度-1.0)<0.001) // 视为相同

你也可以通过对此点积取反余弦函数 acos() 来获得四元数𝑞1和𝑞2之间的角度

如何对一个点应用旋转?
你可以这样做:

rotated_point = orientation_quaternion * point;

...但是如果你想要计算你的模型矩阵,你应该将其转换为矩阵形式代替。

注意,旋转中心总是围绕原点。如果你想绕另一个点旋转:

rotated_point = origin + (orientation_quaternion * (point - origin));

如何在两个四元数之间插值?
这被称为SLERP:球面线性插值(Spherical Linear intERPolation)。使用GLM库,你可以通过mix函数实现:

glm::quat interpolatedquat = quaternion::mix(quat1, quat2, 0.5f); // 或者其他因子

如何累积两次旋转?
很简单!只需将两个四元数相乘即可。顺序与矩阵相同,即逆序:

quat combined_rotation = second_rotation * first_rotation;

如何找到两个向量之间的旋转轴与旋转角度?

基本思想很简单

  • 向量之间的角度很容易找到:点积给出其余弦值。
  • 所需的轴也很容易找到:它是两个向量的叉积。
  • 下面的算法正好实现了这一点,并处理了一些特殊情况:

下面的算法正好实现了这一点,并处理了一些特殊情况:

quat RotationBetweenVectors(vec3 start, vec3 dest){
    start = normalize(start);
    dest = normalize(dest);float cosTheta = dot(start, dest);
    vec3 rotationAxis;if (cosTheta < -1 + 0.001f){
    // 当向量方向相反时的特殊情况:
    // 没有“理想”的旋转轴
    // 所以猜测一个;只要它与start垂直,任何都可以
    rotationAxis = cross(vec3(0.0f, 0.0f, 1.0f), start);
    if (gtx::norm::length2(rotationAxis) < 0.01 ) // 不幸的是,它们平行,再试一次!
        rotationAxis = cross(vec3(1.0f, 0.0f, 0.0f), start);
        rotationAxis = normalize(rotationAxis);
        return gtx::quaternion::angleAxis(glm::radians(180.0f), rotationAxis);
        rotationAxis = normalize(rotationAxis);
        return gtx::quaternion::angleAxis(glm::radians(180.0f), rotationAxis);
     }
     rotationAxis = cross(start, dest);float s = sqrt((1+cosTheta)*2);
     float invs = 1 / s;
     return quat(
        s * 0.5f,
        rotationAxis.x * invs,
        rotationAxis.y * invs,
        rotationAxis.z * invs
     );
}

(你可以源代码中的common/quaternion_utils.cpp中找到这个函数)

我需要实现gluLookAt的类似功能。即如何让一个物体朝向某个点?
下面使用下RotationBetweenVectors这个函数。

// 找到物体前部(我们假设朝向+Z,
// 但这取决于你的模型)和期望方向之间的旋转
quat rot1 = RotationBetweenVectors(vec3(0.0f, 0.0f, 1.0f), direction);

现在,你可能还想让你的物体保持直立:

// 重新计算期望的up方向,使其与方向垂直
// 如果你真的想强制设定期望的up方向,可以跳过这部分
vec3 right = cross(direction, desiredUp);
desiredUp = cross(right, direction);// 因为第一次旋转,up方向可能完全混乱了。
// 找到旋转对象的“up”方向和期望的up方向之间的旋转
vec3 newUp = rot1 * vec3(0.0f, 1.0f, 0.0f);
quat rot2 = RotationBetweenVectors(newUp, desiredUp);

现在,将它们组合起来:

quat targetOrientation = rot2 * rot1; // 记住,顺序是反的。

注意,“direction”是一个方向,不是目标位置!但你可以简单地计算位置:targetPos - currentPos

一旦你有了targetOrientation目标轴,你可以在起始方向和目标方向之间插值了。

(你可以在common/quaternion_utils.cpp中找到这个函数)

如何使用LookAt,但限制旋转在一个特定的速度内?
基本思想是执行 SLERP(= 使用glm::mix),但调整插值的系数,使得角度不大于期望值:

float mixFactor = maxAllowedAngle / angleBetweenQuaternions;
quat result = glm::gtc::quaternion::mix(q1, q2, mixFactor);

这里是一个更完整的实现,处理了许多特殊情况。请注意,并没有直接使用mix()来优化。

quat RotateTowards(quat q1, quat q2, float maxAngle){
    if( maxAngle < 0.001f ){
    // 不允许旋转。防止后面除以0。
    return q1;
    }

    float cosTheta = dot(q1, q2);

    // q1和q2已经相等。
    // 强制设置为q2以确保正确性
    if(cosTheta > 0.9999f){
    return q2;
    }

    // 避免绕球体的长路径
    if (cosTheta < 0){
    q1 = q1 * -1.0f;
    cosTheta *= -1.0f;
    }

    float angle = acos(cosTheta);

    // 如果只有2°差异,而我们允许5°,
    // 那么我们已经到达目的地。
    if (angle < maxAngle){
    return q2;
    }

    float fT = maxAngle / angle;
    angle = maxAngle;

    quat res = (sin((1.0f - fT) * angle) * q1 + sin(fT * angle) * q2) / sin(angle);
    res = normalize(res);
    return res;
}

你可以这样使用:

CurrentOrientation = RotateTowards(CurrentOrientation, TargetOrientation, 3.14f * deltaTime );

可以在源代码中的common/quaternion_utils.cpp文件找到该函数。

0 Answers