第 36 章 四元数与旋转:为什么欧拉角会”万向锁”,主程怎么躲

你做到第 35 章结尾那张”主程能力全景图”时,我跟你摊了牌:模块一”数学与空间”里有个明晃晃的硬缺口——四元数 0 命中、万向锁 0、欧拉角仅 1 处。你当时心里可能”咯噔”一下:我天天拖 Unity 的 Rotation 滑块,转个炮管、转个摄像机,不都好好的吗?这玩意儿能是缺口?

我先不急着讲四元数。你先回答我一个问题:你有没有遇到过”角色抬着头往上看、然后再往左右转,结果整个人突然抖一下、转不过去、或者姿态瞬间乱掉”的现象? 如果没遇到,是你运气好或者项目简单;等你写飞行模拟、写自由视角摄像机、写第一人称角色”低头走路再转身”的时候,这道坎迟早绊你。

这一章,我就把”旋转”这件你以为小事、其实坑极深的东西,从生活现象一路刨到引擎里的 Quaternion,让你以后转任何东西都转得明白、转得稳。咱们还是走老路子:现象 → 现实问题 → 工程师方案 → 落进引擎成了哪些参数 / API。

36.1 旋转这件小事有多坑:拧瓶盖与飞机姿态

先别想游戏。你拧一瓶矿泉水,瓶盖绕瓶口那根竖直轴转——这叫一个旋转。再想你开车打方向盘,轮子绕一个水平轴左右偏——这又是一个旋转。你想想,空间里要描述”一个东西转了”,最少需要几个独立的”转”来描述全方位的朝向?

答案是三个。你盯着一个能自由翻滚的飞机模型(航模那种):

  • 俯仰(Pitch):机头上下抬,绕”左右方向的轴”转。像你点头。
  • 偏航(Yaw):机头左右摆,绕”竖直方向的轴”转。像你摇头。
  • 滚转(Roll):机身翻滚,绕”机头到机尾那根前后轴”转。像你歪着脑袋。

这三个动作,合起来才能把飞机摆成任意姿态。它们有个共同的名字叫欧拉角(Euler Angles)——用三个角度(通常记作 X、Y、Z 三个绕轴旋转的角度)来标记一个物体的朝向。这个名字来自数学家欧拉,你不用管他,记住”用三个角度描述朝向”就够了。

稍微对比一下你已有的知识:第 2 章讲”坐标”描述”在哪”,现在讲”欧拉角”描述”朝哪”。位置是平移(加法),朝向是旋转(乘法,第 2 章 2.5.5 埋过伏笔)。欧拉角就是旋转在”人能看懂”层面的一种记账方式:告诉你”先绕 X 转多少、再绕 Y 转多少、再绕 Z 转多少”。

生活里你其实天天用欧拉角的思路。你给朋友指路:“先向右转 90 度,再抬头 30 度看那架无人机”——这就是在用一个”绕竖直轴偏航 + 绕水平轴俯仰”的欧拉角描述。它好懂、好调、好显示(面板上一行 X/Y/Z 数字,谁都看得懂),所以游戏引擎最初也爱用。

但问题就藏在这”绕 X、再绕 Y、再绕 Z”的顺序里。 你细品:它是有顺序的,先转哪个、后转哪个,结果不一样。而且——只要你愿意先停下来想三秒——当其中两个轴转到重合的时候,会发生什么?这就引出了本章的主角麻烦:万向锁。

36.2 万向锁(Gimbal Lock):地球仪极点类比

我先讲现象,再讲为什么它是坑。

万向锁,英文 Gimbal Lock,字面意思是”万向节锁死”。你需要先知道”万向节(Gimbal)“是什么:想象三个套在一起的金属环,外环套中环、中环套内环,每个环都能绕自己的轴转动,最里面的环上固定一个陀螺仪。这是飞机、老式导航仪里真实存在的机械结构,用来测量姿态。

现在关键来了:当外环和中环转到同一个平面的时候,这两个环的转轴就重合了。 本来三个环提供三个独立的转动自由度,这下”废掉一个”,只剩两个能独立转。系统丢了第三个自由度——你想让物体绕那个已经重合的轴转,它根本转不动,因为两个环现在干的活一模一样。这就是”锁”。

用地球仪类比最直观,你一定见过地球仪:它有个底座,地球仪能绕竖直轴(地轴)左右转(这就是”经度”的变化,相当于偏航 Yaw);同时,地球仪也能绕水平轴前后倾(这就是”纬度”的变化,相当于俯仰 Pitch)。好,现在你把地球仪往前后倾 90 度,让北极点正对着你。这时候你再想让地球仪”绕地轴转”——发现没?因为整个仪面已经”立”起来了,原本的竖直地轴和那个前后倾的水平轴,现在都变成了”你眼前的水平方向”,两轴重合,你只能在一个平面上打转,再也调不出”既绕地轴又倾”的组合朝向了。那一个本该独立的自由度,消失了。

我插一句和你的 Go 后端经验对照:这就像你设计了一套”按字段顺序依次变换的配置”,a THEN b THEN c,结果当 b 的值跑到某个临界点时,b 和 c 的作用向量完全相同,第三个字段名存实亡。系统”看起来还有三个旋钮,实际只有两个能调”——这就是万向锁的本质:自由度丢失。

落到游戏里,万向锁的坑有三个表现,你至少会撞上其中之一:

  1. 转不动:物体在某个姿态附近,你想让它绕某个方向平滑转,它卡住不动,因为那个方向的旋转轴和已有轴重合了。
  2. 突变 / 抽搐:你用三个角度做插值动画(让角度从 A 平滑变到 B),当经过锁点附近,解算会”短路”,物体瞬间跳变、或者疯狂抖动。
  3. 不可预测:同一个目标朝向,用欧拉角可能有多种角度组合表达(奇异点附近),插值走哪条路完全看你”先转哪根轴”,结果忽左忽右。

这就是为什么第 35 章把”万向锁 0 命中”列为硬缺口——你只要做需要任意自由朝向的东西(飞行物、太空游戏、自由摄像机、第一人称角色转头),就绕不开它。老老实实堆欧拉角,迟早在某天凌晨被”角色抬到顶再看左右突然抽一下”的 bug 折磨。

那工程师怎么解这个”锁”?往下看。

36.3 四元数是什么(直觉层):从复数到 3D 旋转

先稳住,别被”四元数”三个字吓到。我用最省的数学、最狠的类比,把它讲到你”能用、能懂为什么”。

你学过复数吧?复数长这样:a + bi,其中 i 是虚数单位(i² = -1)。一个非常美妙的事实是:复数乘法,天然表示平面(2D)里的旋转。 你把复数看成”复平面上的一个点”,两个复数相乘,等价于”把其中一个绕原点旋转某个角度”。所以 2D 旋转用复数就够漂亮了。

问题来了:我们活在 3D 世界,要转的是三维里的朝向。数学家哈密顿(Hamilton)想:能不能发明一种”3D 版的复数”,让它的乘法也能表示 3D 旋转? 他折腾了很久,发现 2D 用 1 个虚数单位 i 就够了,但 3D 旋转需要三个虚数单位还不够,得用三个互不相干的虚部,再加上一个实部,总共四个分量:

q = w + x·i + y·j + z·k

这就是四元数(Quaternion)。w 是实部,x、y、z 是三个虚部(它们满足一组你自己不用记的乘法规则,只要知道它们对应空间三个轴)。四个数字一组,描述一个旋转。

最关键、你一定要装进脑子的一句话:一个”单位四元数”(长度为 1 的四元数),等价于”绕某根轴转某个角度”。 具体说,那个 (x, y, z) 部分指向”旋转轴的方向”,w 和这个轴一起编码了”转了多少度”。换句话说——四元数不是一个”三个角度的顺序账本”,而是一个”绕某轴转某角”的单一指令。 这正是它躲开万向锁的命门,我在 36.4 细讲。

你不用去抠 i、j、k 的代数规则,那是数学家的事。你只要建立这个直觉:复数 = 2D 旋转;四元数 = 3D 旋转。它们都是”用一种数的乘法来编码旋转”的思路升级版。 Unity 里的 Quaternion 类型,底下存的就是这四个浮点数 (x, y, z, w)。

类比帮你记:欧拉角像”先迈左脚、再迈右脚、再抬左手”这种分步动作指令,顺序敏感、还会踩到自己的脚(万向锁);四元数像”直接朝着东南方、走 30 度弧线”这种单一指令,没有分步、没有顺序问题、永远不踩自己脚。

36.4 四元数怎么避开万向锁:连回第 27 章”旋转用乘法”

现在回答最要害的问题:为什么四元数不存在万向锁?

回头看 36.2 的地球仪。万向锁的根因是:欧拉角用”先绕 X、再绕 Y、再绕 Z”的分步顺序,当某两步的轴重合时,丢了一个自由度。 它的”旋转”是一串有顺序的轴旋转拼起来的,拼的过程中轴会动、会重合。

四元数不一样。它不记录”先转哪根轴、后转哪根轴”,它只记录”最终绕哪根固定轴、转了多少度”——这个轴是空间里一根固定的方向,不随旋转步骤变化、不会和别的轴”重合导致消失”。两个旋转要组合?直接把两个四元数乘起来(注意:旋转必须用乘法,第 2 章 2.5.5 和第 27 章都强调过”旋转是乘法不是加法”),得到的新四元数依然是一个”绕某轴转某角”的干净旋转。没有”分步”、没有”轴重合”的环节,自由度一个都不少。

这里连回第 27 章的核心结论。第 27 章你亲手走完”局部 → 世界 → 相机 → 屏幕”的六道关,其中 Model 矩阵(4×4)负责把旋转、缩放、平移一起搬。当时我反复说”旋转是乘法、连乘成矩阵链”。四元数正是这条”旋转用乘法”哲学在旋转表示层面的升级:两个朝向叠加 = 两个四元数相乘,乘出来的结果还是合法旋转,绝不会像欧拉角那样在中间步骤丢自由度。你学第 27 章时建立的”旋转不能加、只能乘”的直觉,在这里直接复用——四元数就是为”旋转乘法”量身定做的数。

一句话点透:欧拉角是”分步转,步骤里会翻车”;四元数是”直接给最终转向,一步到位、轴固定、永不重合”。 这就是主程躲万向锁的正解:能用四元数表示的旋转,就别用欧拉角拼。

(补充一个你可能被面试官追问的点:四元数数学上其实在”对极点”附近也有理论上的奇异性,但它的奇点不会像欧拉角那样在常见姿态(如抬头 90 度)就爆发,而且工程上几乎碰不到。所以业界共识是”四元数是无万向锁问题的实用方案”,你面试就这么说,稳。)

36.4.5 四元数的两个冷知识(面试常问,主程该有数)

讲完”为什么四元数无万向锁”,我顺手补两个你面试大概率被追问、但教程里少有人讲透的点。懂了它们,你谈四元数才像个真主程,而不是背定义。

第一,四元数必须”归一化(normalize)”。 你说一个单位四元数(长度为 1)才表示一个纯旋转。可浮点计算跑久了、或者你把两个四元数乱乘一气,它的长度可能漂移成 0.999 或 1.003。长度不是 1 的四元数,乘进去就不再是一个”干净旋转”,它会顺带把物体缩放(拉长/压扁)——你绝不希望转向的同时模型悄悄变大。所以工程惯例是:每次四元数参与运算、尤其是插值前后,调用一次 Quaternion.Normalize()(或确保 Slerp 的输入本身是单位四元数)。这就像你写后端做浮点累加要防精度漂移一样,是基本功。

第二,四元数有”双倍覆盖(double cover)“特性:q 和 -q 表示同一个旋转。 听起来反直觉:四元数有四个分量,取全体负号,居然转出来朝向一模一样?是的。几何上可以理解为”绕轴转 θ”和”绕反方向轴转 -θ”是同一个最终姿态。这带来一个工程细节:你比较两个朝向”是否相等”时,不能简单比四个分量相等,要允许 q 与 -q 两种情况;做 slerp 时如果起止两个四元数”符号相反”(点积是负),直接插值会绕远路(走大弧),所以 Slerp 内部会先把其中一个取反再插,保证走最短弧。Unity 的 Slerp 已经帮你处理了这点,但你手写四元数插值逻辑时,这个坑会咬人。

连回第 27 章再一次:第 27 章的 Model 矩阵是把”旋转+缩放+平移”打包的 4×4。四元数和它什么关系?答案是——四元数可以转换成一个 3×3 旋转矩阵(Unity 里 Matrix4x4.Rotate(quaternion) 就是干这事的)。也就是说,四元数是”旋转的紧凑、无锁表示”,矩阵是”旋转(连同缩放平移)能直接喂给渲染管线的表示”。你第 27 章学的那套”变换链用矩阵连乘”,底层每个旋转,引擎往往先用四元数算好、再转成矩阵拼进 Model——两者不是替代,是”好用的内部表示”与”好用的流水线表示”的搭配。你懂了这个分工,面试聊”四元数 vs 矩阵”就不会二选一,而是说清各自用途。

第三,关于性能。 你做联机或大量 AI 转向时可能担心:四元数和矩阵哪个快?结论:四元数乘法(16 次乘加左右)比 3×3 矩阵乘法(27 次)便宜,插值 slerp 也比矩阵插值稳;但真正上 GPU 渲染时,最终还是要转成矩阵。所以”运算阶段用四元数,渲染阶段转矩阵”是业界标准流水。记住”各司其职”四字即可,不必死记数字。

36.5 slerp 球面线性插值:让旋转平滑过渡

你调动画、调摄像机、调炮塔转向,经常要做一件事:让一个物体的朝向,从 A 平滑地变到 B。 这叫”旋转插值”。

如果用欧拉角,你会想:“简单,把 X/Y/Z 三个角度分别用 lerp(线性插值)从 A 滑到 B 不就行了?“——不行,而且歪得离谱。 为什么?因为角度插值是在”三个独立数字”上做直线,但旋转的本质是”在球面(所有朝向构成一个球面)上走一条弧线”。你在角度数字上直线走,物体在真实空间里走的却是一条别扭的、速度不均匀、甚至会”绕远路”的曲线。这就回到开头说的”抽搐 / 突变”。

四元数给了我们正确工具:slerp(Spherical Linear Interpolation,球面线性插值)。顾名思议,它在”球面”上做”线性”插值——也就是让物体沿着连接 A、B 两个朝向的那段最短弧线,匀速、顺滑地转过去。像你用手把方向盘匀速从左边打到右边,而不是数字上跳。

slerp 的数学你不用会,但你得懂它的输入输出:给它两个四元数(起始朝向、目标朝向)和一个 t(0 到 1 的进度),它返回一个中间朝向四元数。当 t=0 是 A,t=1 是 B,t=0.5 是正中间那一下。

留个钩子连第 37 章:slerp 是”旋转版”的插值,而第 37 章你会学的 Vector3.Lerp 是”位置 / 向量版”的插值。两者套路一模一样——都是”在正确的几何空间里走直线”。记住这条,下一章秒懂。

36.6 Unity 实操:Transform.rotation 与那些 API

理论和引擎对上号才算真会。打开 Unity,任何一个物体的 Transform 组件里那个 Rotation 字段,你在 Inspector 里看到的是 欧拉角(X/Y/Z 三个数,好读),但在代码里 transform.rotation 拿到的,其实是个 Quaternion(四元数)。引擎在面板显示时帮你转成了欧拉角方便你看,背地里所有旋转计算都用四元数。

你最常用的几个四元数 API(都是 Quaternion 这个 struct 上的静态方法或构造):

  • Quaternion.Euler(float x, float y, float z):把”欧拉角”转成”四元数”。这是你从人话(三个角度)进到引擎内部语言(四元数)的入口。注意:它内部已经帮你避开了万向锁,因为你一旦传进去变成四元数,后续旋转就用乘法,不再有分步轴重合的问题。
  • Quaternion.AngleAxis(float angle, Vector3 axis):用”绕某根轴 axis 转 angle 度”直接造一个四元数。这是最贴近四元数本质的构造法——你明确告诉它轴在哪、转多少。
  • Quaternion.LookRotation(Vector3 forward, Vector3 up = Vector3.up):给它一个”朝向哪”的方向向量,它返回一个让物体 z 轴对准那个方向的四元数。做”角色看向目标""炮塔瞄准敌人”神器。
  • Quaternion.Slerp(Quaternion a, Quaternion b, float t):36.5 讲的球面插值,从 a 平滑转到 b。

关键告诫(主程级):别用 transform.Rotate(x, y, z) 叠欧拉角去做长时间、任意朝向的动画。 Rotate 默认是在当前欧拉角上”再加上”一段欧拉旋转,它背后仍走”欧拉角累加”的路子,长时间、跨奇异点就会抽搐。正确姿势是:用上面的 API 算出”目标四元数”,再用 Slerp 每帧把当前朝向往目标 slerp 一点点。

给你一段”炮塔平滑转向目标”的小 C# 片段,这正是上面那些 API 的组合拳:

using UnityEngine;
 
public class TurretAim : MonoBehaviour
{
    public Transform target;   // 要瞄准的目标
    public float turnSpeed = 3f; // 转向速度,越大转得越快
 
    void Update()
    {
        if (target == null) return;
 
        // 1. 算出"炮塔应该朝向哪":从炮塔指向目标的方向
        Vector3 dir = target.position - transform.position;
 
        // 2. 用 LookRotation 把"方向"变成"目标四元数"
        //    (up 用默认世界 up,保证炮塔不会翻滚)
        Quaternion targetRot = Quaternion.LookRotation(dir);
 
        // 3. 当前朝向,朝目标朝向,用 slerp 平滑插值
        //    Time.deltaTime * turnSpeed 控制"每秒转多少比例",帧率无关
        transform.rotation = Quaternion.Slerp(
            transform.rotation,
            targetRot,
            Time.deltaTime * turnSpeed
        );
    }
}

你品这段代码的味道:全程没有碰欧拉角的分步累加,只在”当前四元数”和”目标四元数”之间 slerp。目标炮塔会像一个真炮塔那样,匀速、顺滑、绝不抽搐地转向敌人。这就是主程处理旋转的标准写法。

再对比你 35 章学的缺口:如果这章没补,你多半会写成”每帧给 Rotation.y 加一点”的欧拉叠加法,目标在正上方(抬头 90 度,奇异点)时,炮塔会突然乱转——经典万向锁现场。现在你有了 slerp + LookRotation,这个坑直接绕过。

36.7 排查:转身抽搐 / 突变,八成是欧拉插值或顺序问题

你上线或联调时,若遇到以下症状,按这个清单查:

  1. 角色/摄像机在”抬头或低头到接近竖直”时突然抖、转不动、或朝向乱跳 → 典型万向锁。根因:你用了欧拉角插值(Mathf.Lerp 三个角度)或 Rotate 叠加。对策:改成四元数 Slerp。
  2. 两个旋转之间过渡”绕了远路”、速度忽快忽慢 → 还是在角度数字上做直线,没用 slerp。对策:换 Quaternion.Slerp。
  3. 模型导入后朝向怪异、绕轴相反 → 连第 2 章 2.9 的”手性/坐标轴约定”坑:Blender(右手、z-up)到 Unity(左手、y-up)坐标轴没对齐。对策:导入设置里勾”Convert Left-handed”或旋转根节点,而不是在代码里硬拧欧拉角掩盖。
  4. 动画里骨骼旋转抽搐 → 多半是动画关键帧之间用了欧拉插值而非四元数插值(Unity 的 Animation/Animator 默认关键帧之间用四元数 slerp,但你如果自己手写插值逻辑就可能踩)。对策:动画插值交给引擎,或自己用 Quaternion.Slerp/SlerpUnclamped。

一句排查口诀送你:“旋转出妖,先看是不是还在用欧拉角做插值或叠加;是,就换四元数 slerp。” 这能解决你九成的旋转毛病。

36.8 小结与连回主线

咱们把这一章扣回溯源学习法的三句话:

  • 现象 / 现实问题:用欧拉角(三个角度分步转)描述 3D 朝向,简单好懂,但当两旋转轴重合时丢失一个自由度,物体转不动、突变、抽搐——这就是万向锁,做自由朝向时必踩。
  • 工程师的方案:用四元数(w + xi + yj + zk)表示旋转,它本质是”绕某固定轴转某角”的单一指令,旋转用乘法组合、不会丢自由度,从而无万向锁;用 slerp 在球面上做平滑插值。
  • 落进引擎的参数 / API:Transform.rotation(四元数)、Quaternion.Euler / AngleAxis / LookRotation / Slerp,以及”别用 Rotate 叠加欧拉角做长动画”这条实践铁律。

连回前面的章节:第 2 章讲旋转是乘法不是加法、第 27 章讲旋转用矩阵乘法搬坐标——本章的四元数正是这套”旋转用乘法”哲学在旋转表示层面的补完;而第 35 章点名的”万向锁 0 命中 / 四元数 0”硬缺口,到这里被正式填平。下一章(第 37 章)我们顺着”向量”往下走,把点积、叉积、插值变成你游戏逻辑里的真刀真枪。

【思考题】

  1. 用你自己的话解释”万向锁”:为什么欧拉角在”抬头接近 90 度”附近会让摄像机或角色”转不动 / 抽搐”?试着用 36.2 的地球仪类比复述一遍。
  2. 为什么说”四元数不存在(工程意义上的)万向锁”?关键不在于”四元数数学更神奇”,而在于它和欧拉角在”描述旋转的方式”上有什么根本不同?连回第 27 章”旋转用乘法”这句话。
  3. 为什么用 Mathf.Lerp 对三个欧拉角分别插值,得到的旋转会”歪”、会”绕远路”?而 Quaternion.Slerp 为什么不会?用”在正确的几何空间里走直线”这句话解释。
  4. 给你一段炮塔代码(见 36.6),如果把它改成”每帧 transform.Rotate(0, speed*Time.deltaTime, 0) 去追目标”,为什么在目标跑到炮塔正上方时会出怪现象?这怪现象叫什么?

【参考答案】

  1. 欧拉角是”先绕 X、再绕 Y、再绕 Z”三个有顺序的轴旋转拼出来的。当俯仰(比如抬头)转到接近 90 度时,原本的”偏航轴(竖直)“和”滚转轴(前后)“会转到同一平面、两轴重合,系统丢失一个独立自由度——想绕那个重合轴转时转不动,插值经过此处就抖动/突变。地球仪类比:把地球仪前后倾 90 度让北极朝你,原本的”绕地轴转”和”前后倾”两轴都变成你眼前水平方向、重合了,只剩一个平面能转,调不出原朝向组合。这就是自由度丢失的万向锁。
  2. 根本不同在于”描述方式”:欧拉角记录的是”分步、有顺序的旋转序列”,序列中间轴会动、会重合、会丢自由度;四元数记录的是”最终绕哪根固定轴、转了多少度”这一个单一指令,轴是空间里固定的方向,不随步骤变化、不会和别的轴重合消失,旋转组合用乘法(连第 27 章”旋转用乘法”),结果仍是干净旋转,自由度一个不少。所以四元数不是”魔法消去”了奇点,而是”根本不走会重合的分步路径”,从而工程上无万向锁。
  3. 欧拉角插值是在”三个角度数字”这条直线上滑,但真实旋转发生在”所有朝向构成的球面”上;数字上直线走,物体在真实空间里走的是别扭、不均速、绕远路的曲线,所以歪。Quaternion.Slerp 在球面上做线性插值,走的是连接两朝向的最短弧线,匀速顺滑,因此不会歪。两句话统一为:插值必须”在正确的几何空间里走直线”——角度空间是错的,球面(四元数)空间才对。
  4. 改成 Rotate(0, speed*dt, 0) 是在”当前欧拉角上每帧累加一个偏航角”去追目标,本质仍是欧拉角分步叠加。当目标跑到炮塔正上方(抬头接近 90 度)时,偏航轴和滚转轴重合,进入万向锁奇异点,炮塔会突然乱转 / 抽搐 / 转不动。这个怪现象就叫万向锁(Gimbal Lock)。正确写法是用 36.6 的 LookRotation 算出目标四元数,再用 Slerp 平滑追——全程四元数,绕开奇异点。