聊聊四元数与万向锁及其实际意义
烧脑警告!本篇文有着大量的逻辑思考以及三维思考,我自己写的都晕,建议养好脑子再阅读
聊聊四元数与万向锁及其实际意义
在使用 Babylon 写 ADV.JS 的人物骨骼旋转动画的时候,我发现几乎所有的骨骼旋转均使用四元数(Quaternion)实现。https://vrm.advjs.org
manager.humanoidBone[boneName].rotationQuaternion = BABYLON.Quaternion.FromEulerAngles(bone.x, bone.y, bone.z) |
而四元数本身也是游戏引擎旋转的标配,Unity
/Unreal
/Cocos
等知名引擎均使用此实现。
那么为何如此呢?
尽管已有不少的文章已经对此进行了介绍,但我仍然希望可以将其更为简化以及循序渐进一点,并附上一些自己的理解和探索此的原因,和有用的参考链接。
如有纰漏,欢迎指正。
楔子
回顾一下最初的目的,我希望为 VRM 模型编写一个在线编辑器,以便用户可以快速编辑动作、表情等信息。
用户可以自由旋转人物模型的骨骼以摆出不同的动作,并设置表情参数。
VRM 模型:一种基于 GLTF 的跨平台文件格式,可以处理类人角色(3D模型数据)。特点在于统一了人物模型的坐标系、表情、骨骼等信息。
3D 空间的旋转,一个看似简单的操作,但也蕴藏着许多前人的智慧。
对于旋转,我们直观上可以将其根据 XYZ 三个坐标轴进行旋转,并坚信它可以很轻松地表达出 3D 空间的所有旋转姿态。
但是事实似乎并非如此。
前置概念
正如直观感受来说,我们使用基于三维坐标系的方式来描述旋转。
在正式讨论之前,我们需要了解一些基础概念。
譬如欧拉角。
欧拉角
- α(进动角)是x-轴与交点线的夹角,
- β(章动角)是z-轴与Z-轴的夹角,
- γ(自旋角)是交点线与X-轴的夹角。
简单点来说,就是分别围绕 x y z 轴进行旋转的角度。可以用来描述三维空间中的物体旋转。
但是欧拉角又有静态和动态之分。
静态欧拉角,即以固定的参考系(又称实验室参考系)来进行旋转。譬如,我们旋转一个物体,不管一个物体怎么旋转,我们整体的参考系并不会发生变化。
但静态欧拉角对于描述场景中的物体旋转并不方便,譬如一个立方体使用世界坐标系(静态欧拉角)进行旋转后,我们只需要立方体根据其自己 z 轴再进行旋转,静态欧拉角便很难进行描述。
因此我们还需要可以独立描述物体旋转的欧拉角,也就是动态欧拉角。即这时物体的 xyz 轴应该是跟着物体发生变化的。
注意,动态欧拉角的旋转顺序对于物体最终选择是有影响的,所以只有固定的顺序(同时也正是这个顺序导致了后续的万向锁问题)才能确定物体最终的旋转姿态。因为当第一次旋转发生时,它的其余坐标轴位置也发生了改变。
Unity 中红色为 x 轴,绿色为 y 轴,蓝色为 z 轴
欧拉角旋转,在计算机中通常实现为 Y -> X -> Z。
关于 Unity 中的旋转顺序有人说是 YXZ,也有人说是 ZXY。实在让人烦扰。
我们不妨自己打开 Unity 来试一下。
我们先绕 Z 轴(蓝色)旋转 30度。
假设 Unity 的旋转顺序是 ZXY,那么此时我们旋转 X 轴,应该会和我们想象的一样,绕红轴进行旋转。
很明显,立方体并非绕着 X 轴旋转,它最后的姿态,应该是先进行了 X 轴旋转,再对应进行了 Z 轴旋转。
我们再做一次实验。先绕 X 轴旋转 30 度,再旋转 Z 轴。
我们可以看到这次立方体很好地绕着 Z 轴旋转。
同理再试验一下与 Y 轴的对比,我们可以得到顺序是 YXZ。
就在我准备心满意足得出结论时,我突然搜到了 Unity 的官方 API 文档描述(中文应该还没翻译,只有英文版)。
The implementation of this method applies a rotation of zAngle degrees around the z axis, xAngle degrees around the x axis, and yAngle degrees around the y axis (in that order).
https://docs.unity3d.com/ScriptReference/Transform.Rotate.html
官方说我们的顺序是 ZXY,可是这似乎与我们的实验结果相违背。
这时我发现后面还有这样一句话,相对于游戏对象的本地空间。
The rotation is relative to the GameObject’s local space (Space.Self).
我们回过头来看之前的动图,之前的解释都是建立在 XYZ 轴也相对变化之上。
此时我们假设物体自身内部的初始坐标系,XYZ 并不会随着物体转动变化,那么此时就可以解释为 ZXY 的顺序了。(Babylon 的文档 ZXY World Axes 佐证了我的想法。)
大概这也是为什么有人说顺序是 ZXY,也有人说是 YXZ 的原因了。
Unreal 则是 ZYX,https://answers.unrealengine.com/questions/506714/view.html。
Cocos 是 YZX。https://docs.cocos.com/creator/3.1/api/zh/classes/core_math.quat.html#fromeuler
Babylon 为 ZXY(以相对视角来看,则是 YXZ),与 Unity 相同。
https://doc.babylonjs.com/divingDeeper/mesh/transforms/center_origin/rotation_conventions
其他真是都各不相同。😓
yaw pitch roll
由上文,我们发现参考坐标系的选取对我们的认知也有很大影响。
坐标系则各有不同,Unreal、Unity、Babylon、DirectX 均采用左手坐标系, 但 Three.js、Cocos、OpenGL、Vulkan 则是右手坐标系。
我查阅了许多资料,发现不少都是将 yaw pitch roll 与 zyx/xyz 关联。而且可能各自对应坐标系并不相同。
实际上维基百科的定义为:
- z-y′-x″ (intrinsic rotations) or x-y-z (extrinsic rotations): the intrinsic rotations are known as: yaw, pitch and roll
坐标系通常是人为进行关联的,我们可以先从三个词的含义进行理解。
我们以人眼作为摄像机,并将其当作飞机头部。
yaw
:偏航角,即围绕竖直轴旋转的角度。
pitch
:俯仰角,即抬头低头所形成的角度。
roll
: 翻滚角,即绕我们视线这条轴旋转的角度。
此时皆为顺时针。
右手坐标系中的欧拉角逆时针旋转为正,左手系中顺时针旋转为正。
欧拉角根据旋转方式可分为 Tait-Bryan angle 和 Proper/classic Euler angle 两种。
z-y-x 属于 Tait-Bryan angle 中的一种。
这时我们以 pitch 为 x 轴建立左手坐标系,那么 roll 对应是 y 轴,yaw 对应 z 轴。恰与维基百科里说的 zyx 相对应。
万向锁
万向锁:一旦选择 ±90°作为 pitch 角,就会导致第一次旋转和第三次旋转等价,整个旋转表示系统被限制在只能绕竖直轴旋转,丢失了一个表示维度。
https://baike.baidu.com/item/%E4%B8%87%E5%90%91%E9%94%81/15817326
万向锁纯读定义来说,确实有些让人费解。
如果你喜欢从数学上理解,不妨读读这里 https://krasjet.github.io/quaternion/bonus_gimbal_lock.pdf。
而我则从我更直观的感受上进行介绍。
pitch 我们已经在上文进行了介绍,而 pitch 对应的是哪里往往与我们选取的坐标系有关。(我觉得 pitch 通常指的是旋转顺序中间的那个轴旋转,例如 XZY,指的就是绕 Z 轴,YXZ,指的就是绕 X 轴)
我们后续继续以 Unity 的左手坐标系为例(Babylon 与之相同)。pitch 为 x 轴的左手坐标系。
红:x 轴,绿:y 轴,蓝:z 轴
https://doc.babylonjs.com/toolsAndResources/utilities/World_Axes
其实从上文,我们已经发现设置 xyz 的先后顺序,并不会使物体像我们期望的那样旋转。
因为内部,物体总是以固定的 YXZ(相对视角下,以 Unity/Babylon 为例) 进行旋转的。
这也是因为动态欧拉角也必须遵循对应的顺序,才能确定唯一的旋转。也就是说物体的旋转,和我们设置 XYZ 的顺序无关,而单纯由我们使用的游戏引擎等平台决定。(这也很合理,专门再去记录用户的旋转顺序也太麻烦了也很不优雅。)
这是动态欧拉角必须按顺序旋转特性的一个体现,而万向锁正是一个极端案例。
当 pitch 旋转 90° 也就是绕 x 轴旋转 90 度,z 轴变到了原先 y 轴的位置。
在 Unity 中, 我们将 x 设置为 -90度(90度也行)
这时,无论怎么修改 Z 轴/Y 轴角度,立方体都会只能在这个平面上(也就是围绕蓝色的 Z 轴)进行旋转。万向锁就产生了。
我们分析一下,当 X = -90°,我们设置 Z 时。根据 YXZ 的顺序,立方体绕 X 轴旋转 -90°,又绕 Z 轴旋转。(好,没问题)
再看 Y,当 X = -90°,设置 Y,根据 YXZ 的顺序,立方体先绕原先的 Y 轴旋转一定度数,再绕 X 轴旋转 -90°。
比如我们假设 Y 是 30° 或 60°:
然后绕 X 轴旋转 -90°:
最终表现出的效果就如同绕 Z 轴旋转一般,我想这便是万向锁的表现与原因了。
那么我们如何解决这个问题呢?
这就涉及到了四元数的概念。
什么是四元数?
四元数(Quaternion
),首先放上维基百科与百度百科的链接。
可视化四元数 Visualizing quaternions https://eater.net/quaternions
本文中的许多关于四元数的解释参照于此。
如果你更喜欢文字版的数学公式推导证明,那你可以看一看 https://krasjet.github.io/quaternion/。
为了节约时间,我们对此做一些总结概括。
简而言之,我们可以从四元数的几何意义出发去理解。四元数就好比四维世界的数字。
复数
Tips:由于博客的渲染不支持渲染希腊字母,我用
Typora
写好了截了张图,凑合看吧
所以复数相乘,就好比做矩阵变化(旋转+拉伸)。
同理将其扩展到四元数,便好比在三维空间做变换(旋转+拉伸)。
四元数乘法:(这也是我们告诉计算机该如何去计算它)
对于矩阵乘法,四元数乘法更加简洁
四元数
比如写一个四元数:3.23 + 8.46i + 2.64j + 3.38k
Hamilton 用了一个特殊的词来称呼没有标量部分只有 i j k 分量的四元数,一个之前没有在数学和物理中出现过的词,向量(Vector)。
对于旋转来说,我们通常使用单位四元数。
单位四元数:
相当于绕 j 轴旋转了90°。
这时,只会对变换的向量进行旋转,而不会改变其本身的大小。(模长)
作用:避免了欧拉角的歧义问题。
- 优点:四元数旋转不受万向锁的影响。
- 局限性:单个四元数不能表示任何方向超过 180 度的旋转。
- 局限性:四元数的数字表示在直观上难以理解。
为什么使用四元数?
关于四元数更多详细的推论,你大可以看看本章开头引用的视频和文稿。
我想从更加直观地感受上来描述一下,我们为什么使用四元数?
回顾一下,正如上文所说,单位四元数可以起到的作用是对三维空间物体进行旋转。
而我们想要描述物体旋转,对于用户来说,欧拉角是最直观的方式。
对于游戏引擎等来说,我们通常有一个世界坐标系,同时也希望子物体也有自己的坐标系,以便我们单独旋转子物体。这时我们不得不使用动态欧拉角。
但是动态欧拉角有缺陷,因为它必须依照固定旋转顺序(如 YXZ),才能真正确定一个旋转。因此游戏引擎通常会有其自己的固定顺序,即便用户用不同的顺序去设置 XYZ,最后也根据引擎自己实现的顺序来展示物体的旋转。
这样才能保证 XYZ 值,只确定一个旋转。
因为旋转顺序固定,所以产生了万向锁。
因此我们需要一个东西它可以唯一确定并表达旋转,于是我们用到了四元数。
但对于用户来说,旋转场景中的物体要自己计算设置四元数显然是不友好的,因此 Unity 等游戏引擎的方案都是在内部存储为四元数,而为了用户方便操作理解,UI 面板上则仍然使用欧拉角表示旋转(因此我们可以在游戏引擎里复现万向锁的表现),但我们只要自己知道旋转顺序,就可以在设置的时候避开它,设置出自己想要的旋转。
此外四元数还有些额外的好处,四元数只表示各方向上 180° 之内的旋转,比如旋转 720° 其实和原先的角度是一样的,我们只需要表示 0° 就好,节约空间。计算也比矩阵等更为方便快捷。
四元数本身也可以统一不同平台下的旋转,比如此前提到 Unity 是 ZXY,Unreal 则是 ZYX,同样的 XYZ 欧拉角旋转在不同平台下表现都可能是不一样的,但是它们均采用四元数存储,便不用担忧这个问题。
对了,它还更利于做插值动画。两个单位四元数的差值就好似圆上的两个向量差值。
基于此可以更方便地实现 Lerp(Linear Interpolation 线性插值)、Nlerp(Normalized Linear Interpolation 正规化线性插值)、Slerp (Spherical Linear Interpolation 球面线性插值)。……更多自己去了解吧!
既然四元数这么多好处,那么我们又有什么理由不用它呢?
后话
网上搜了不少资料,看了半天总是一知半解,有时候看到某个不认识的概念可能又得再去先了解一下该概念的意义,而且有些帖子的具体坐标系、旋转顺序甚至是互相矛盾的,实在让人费解。因此花了两天仔细看了下靠谱的视频、维基百科(顺便吐槽下百度百科😓)和推导过程,并自己尝试写了下宏观感受上的理解,和其中需要的前置知识。希望能对后来者有所帮助。