本教程略微超出了OpenGL的范围,但解决了一个非常常见的问题:如何表示旋转?
在教程3《矩阵》中,我们学习了矩阵可以围绕特定轴旋转点。虽然矩阵是变换顶点的便捷方式,但处理矩阵本身却很困难:例如从最终矩阵中提取旋转轴就相当棘手。
我们将介绍两种最常见的旋转表示方式:欧拉角和四元数。最重要的是,我们将解释为什么你应该优先使用四元数。
前言:旋转 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) 则是围绕该轴旋转的角度(弧度)
因此,四元数本质上存储了一个旋转轴和一个旋转角度,并且以一种便于组合旋转的方式进行存储。这意味着当我们需要将多个旋转操作连接在一起时,使用四元数可以更方便地实现这一目标,而不需要像使用旋转矩阵那样进行复杂的矩阵乘法运算。此外,四元数还能有效避免万向锁问题,提供平滑插值等功能,在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文件找到该函数。