资讯动态

四元数与三维旋转:从欧拉角死锁到单位四元数工程实践

发布时间:2026/10/29 22:09:30 来源:尧图企业网站定制
简介quaternion.pdf 是一份围绕四元数与三维旋转关系的专题讲义面向计算机图形学、机器人学、计算机视觉方向的学习者以及需要理解姿态表示与旋转矩阵相互转换的开发者。正文从复数与二维旋转的类比切入逐步引出四元数的定义、虚单位运算规则、模长与共轭性质并推导四元数与旋转矩阵之间的对应公式侧重用基础几何与线性代数做直观解释而非纯代数抽象。压缩包内仅含 1 个 PDF 文件大小约 478KB体积轻量便于本地留存与逐页批注阅读文中配有图示说明并提及配套的 MATLAB/Octave 演示代码与动画资源方便读者动手验证旋转过程。目前已有 182 人学习适合作为入门四元数、补齐姿态表示知识短板的参考材料。1. 四元数到底是个什么东西让三维旋转不打结的四个数如果你玩过三维建模软件大概遇到过一个很玄学的瞬间明明只想把模型转到某个朝向拖到某个角度附近它却突然像被卡住怎么拖都不对劲只能退回去换个顺序重新转。这个卡住的瞬间背后是一整套关于「怎么描述旋转」的数学四元数就是其中最常被提起的方案。一句话说四元数是一个实部加三个虚部组成的数写成q a bi cj dki、j、k 满足 i² j² k² ijk -1把模长限制为 1它就能干净地表示三维空间里的旋转。只要涉及「物体朝哪、怎么转过去」——动画骨骼、机器人关节、飞行器姿态、相机控制——底层的姿态表示多半绕不开它。2. 为什么三维旋转要靠四个数轴角表示里的多余自由度2.1 三个角度听上去够用直到它卡住描述三维旋转最符合直觉的方式是欧拉角绕 x 轴转一个角再绕 y 轴转一个角再绕 z 轴转一个角。三句话就能说清一个朝向人读得懂、写下来也短很多建模软件的操作面板上摆的就是三个数字。问题出在中间那个角上。当第二个角转到 ±90 度时第一个旋转轴和第三个旋转轴会重合原本三个独立的转动方向塌缩成两个你明明还剩一个角可以调但它在几何上已经和另一个角干着同一件事。操作者感受到的就是「怎么转都不对劲」数学上把这个位置叫万向节死锁。它不是数值误差也不是软件写得烂而是三个角度这套描述方式天生的死穴。还有一层麻烦欧拉角和坐标轴的选定强绑定。同一个朝向换一套轴顺序、换一套参考系三个数字全变。做动画时把两个不同工具导出的欧拉角直接拼接结果模型扭成麻花这种翻车现场并不少见。所以三维旋转需要一种不依赖轴顺序、没有卡死位置的描述方式。轴角表示就是这样一种替代方案。2.2 换一种描述一根轴加一个角轴角表示的想法很朴素任何一次三维旋转都可以理解为「绕着某根过原点的轴转某个角度」。轴用一个三维向量u (x, y, z)表示角用θ表示四个变量齐了。这里有个容易被忽略的细节四个变量但旋转本身只有三个自由度。多出来的那个去哪了答案在轴的长度上。我们关心的是轴指的方向不是它有多长——绕(0, 0, 1)转和绕(0, 0, 3)转是同一个旋转。一根轴的方向只需要两个量就能确定就像地球上任意一个方位只需要经度和纬度。所以只要规定轴是单位向量多出来的自由度就被消掉了四个变量重新对齐三个自由度。规定|u| 1不只是为了好看。后面的推导里会反复出现u · u 1很多项会因此直接消掉公式能短一大截。实际写代码时用户输入的轴几乎不可能是单位向量所以在做任何计算之前必须先把轴归一化import math def normalize(v): # 求向量长度注意长度为 0 时不能做除法 n math.sqrt(sum(c * c for c in v)) if n 0: raise ValueError(旋转轴不能是零向量) # 每个分量除以长度得到方向不变、长度为 1 的向量 return tuple(c / n for c in v)这个函数很短但它是所有轴角与四元数代码的第一道关卡。参数说明只有一条传入的v是任意长度的三维向量返回的是同方向的单位向量如果传进来的是零向量直接报错因为零向量没有方向无法定义「绕它转」这件事。2.3 把向量分成两半旋转就变成一道平面几何题有了单位轴 u接下来处理待旋转的向量 v。直接算 v 转到哪很难但把它拆开就简单了v 可以分解成平行于 u 的分量v∥和垂直于 u 的分量v⊥两者相加还是原来的 v。平行的这一半最好办。它本身就躺在旋转轴上绕着轴转一圈它还是它自己位置完全不动。所以v∥旋转之后等于v∥一个符号都不用改。垂直的这一半才是真正要干的活。v⊥垂直于轴旋转时它始终待在一个与轴垂直的平面里绕圈走长度不变轨迹是个圆。这就把一个三维问题降到了二维在平面里一个向量转了 θ 度。但平面里只有一个向量不够用得再找一个方向当参照系——拿 u 和v⊥做叉乘得到w u × v⊥。这个 w 同时垂直于 u 和v⊥长度又恰好等于v⊥所以它正好也落在那个圆上天然可以当第二个坐标轴。于是v⊥旋转后的结果可以直接写成两个方向上的投影相加v⊥ cos(θ) · v⊥ sin(θ) · (u × v⊥)把平行和垂直两半拼回去再代入v∥ (u · v)u和v⊥ v - (u · v)u化简之后得到一个相当对称的式子v cos(θ)·v (1 - cos(θ))·(u · v)·u sin(θ)·(u × v)这就是轴角旋转的一般公式。看着有点长但三项各有分工第一项是原向量按角度缩放第二项补上平行分量没被转的部分第三项负责垂直分量在平面内的“横向移动”。三项凑齐任意向量绕任意单位轴的旋转就都能算出来。换成代码它几乎是一行行照抄公式def rotate_axis_angle(v, axis, theta): # v: 待旋转向量axis: 旋转轴任意长度theta: 弧度制角度 u normalize(axis) # 先归一化否则公式不成立 cos_t, sin_t math.cos(theta), math.sin(theta) dot sum(u[i] * v[i] for i in range(3)) # u · v标量 cross (u[1]*v[2] - u[2]*v[1], # u × v u[2]*v[0] - u[0]*v[2], u[0]*v[1] - u[1]*v[0]) # 三项分别对应缩放项、平行补正项、垂直横移项 return tuple( cos_t * v[i] (1 - cos_t) * dot * u[i] sin_t * cross[i] for i in range(3) ) print(rotate_axis_angle((1, 0, 0), (0, 0, 1), math.pi / 2)) # 约 (0, 1, 0)最后那行验证值得多看两眼把 x 轴方向的向量绕 z 轴逆时针转 90 度应该落到 y 轴方向上。这是右手坐标系的约定——右手拇指指向轴的正方向四指弯曲的方向就是旋转正方向。如果跑出来是(0, -1, 0)八成是把坐标系约定或者叉乘顺序搞反了这在图形学里是最常见的一类踩坑。参数上有两个地方需要注意。theta用的是弧度不是角度直接传 90 会得到完全错误的结果axis必须先归一化如果跳过这一步公式里(1 - cos θ)(u·v)u这一项会因为|u| ≠ 1而被放大转出来的向量长度都会变。2.4 四个数字的真正好处没有卡死的位置轴角表示解决了欧拉角的核心痛点。角度从 0 连续变到 2π整个过程没有任何一个位置会让自由度塌缩也不依赖先绕哪个轴的顺序——轴和角是两个独立的对象说清就是一次旋转。但它也有自己的短板两次旋转叠在一起没法直接算。给你两个轴角对问「先转 A 再转 B 等效于绕哪根轴转多少度」用轴角形式硬推会非常痛苦。实际工程里的做法是把它转成矩阵或者四元数乘完再转回来。这也解释了四元数为什么会出现——它要继承轴角的无奇异特性同时又要像复数那样可以直接相乘来复合旋转。3. 复数是怎么转起来的四元数的预告片3.1 复数乘法藏在了一个 2×2 矩阵里在跳进四元数之前先看一个更小、更好懂的例子。复数z a bi其中 i² -1。两个复数相乘按分配律展开再合并同类项结果是(a bi)(c di) (ac - bd) (bc ad)i把这个结果的实部和虚部拆成两行写会发现它其实是一个矩阵乘一个向量的形状[ ac - bd ] [ a -b ] [ c ] [ bc ad ] [ b a ] [ d ]右边那个 2×2 矩阵就是复数a bi的矩阵形态。这一步很关键复数乘法不再只是一个代数运算它等价于用这个矩阵去做一次线性变换。复数和矩阵在这里是同一件事的两种写法。顺带还能看出虚数单位 i 对应的矩阵是[[0, -1], [1, 0]]把它自己乘自己得到的是负的单位矩阵也就是 -1。四个数字组成的 i² -1在矩阵形态下同样成立。3.2 拆开这个矩阵里面是缩放套着旋转接下来对这个矩阵做一次「解剖」。把矩阵的每个元素都除以复数的模长|z| √(a² b²)再把这个模长提到矩阵外面[ a -b ] [ a/|z| -b/|z| ] [ b a ] |z| ·[ b/|z| a/|z| ]此时矩阵里的四个元素正好可以对应到三角函数a/|z| cos θb/|z| sin θ其中 θ 就是复数在复平面上与实轴正方向的夹角。于是整个矩阵变成了[ cos θ -sin θ ] [ sin θ cos θ ] 乘以标量 |z|右边这个 2×2 矩阵是标准的二维旋转矩阵。所以结论很清楚一个复数乘以另一个复数做的事情就是「把对方旋转 θ 度再把长度缩放 |z| 倍」。当复数的模长为 1 时缩放因子变成 1剩下的就纯粹是旋转。这就是四元数故事的原型。复数用一个虚部描述了二维平面上的旋转那么增加虚部的个数是不是就能描述三维空间里的旋转答案是能但要多加两个虚部而且代价是乘法不再满足交换律。3.3 极坐标形式把旋转写成一个指数旋转角度和模长这两件事可以用更紧凑的方式写出来。按照欧拉公式cos θ i sin θ等于e^(iθ)。于是任意复数都能写成z r · e^(iθ)其中 r 是模长θ 是角度。这个形式的好处是旋转这件事被压缩进了一个指数里乘法的意义变得一目了然两个复数相乘模长相乘、角度相加。想让一个二维向量转 θ 度把它当成复数乘上e^(iθ)就行def rotate_2d(v, theta): # 把 (x, y) 视为复数 x yi乘上单位复数 e^(iθ) c, s math.cos(theta), math.sin(theta) x, y v # 展开 (c si)(x yi)实部与虚部分别对应新坐标 return (c * x - s * y, s * x c * y) print(rotate_2d((1, 0), math.pi / 2)) # 约 (0, 1)这段代码和上一章的轴角旋转放在一起看会很有意思一个是三维里绕轴转一个是二维里绕原点转实现上却都是三角函数加乘减。二维之所以简单是因为平面里只有「一个旋转方向」不需要指定轴。参数说明同样简单v是二维向量theta是弧度制角度。函数内部没有任何归一化步骤因为cos θ和sin θ天然满足平方和为 1单位复数是自动保证的——这一点在四元数那里就不成立了得手动归一化。3.4 两次旋转叠在一起角度相加顺序无所谓二维旋转还有一个非常好的性质。假设先转 θ 度再转 φ 度两次旋转对应的复数分别是z₁和z₂。复合的结果就是两个复数相乘z_net z₂ · z₁ (cos φ i sin φ)(cos θ i sin θ) cos(φ θ) i sin(φ θ)用三角恒等式展开后结果仍然是「一个角度」的形式角度就是两个角之和。这说明两件事二维旋转复合之后还是旋转复合的顺序不影响结果先转 θ 再转 φ和先转 φ 再转 θ落到同一个位置。第二条性质来自复数乘法满足交换律。z₁z₂和z₂z₁结果完全一样所以在二维平面上「先转哪个」这个问题根本不存在。到了三维这件事就不成立了。绕着不同的轴旋转先后顺序换一下最终的朝向会完全不同——拿起手边的杯子先绕竖直轴转 90 度再绕水平轴转 90 度和反过来的顺序杯子最终朝向截然不同。这就是四元数乘法不满足交换律的几何根源也是它和复数最大的分野。4. 四元数怎么表示旋转三个虚部和那个半角4.1 从一到三多出来的两个虚部改变了什么四元数的写法和复数长得像只是虚部从一个变成了三个q a bi cj dk, a, b, c, d ∈ ℝ约束条件是i² j² k² ijk -1。注意最后那一项它不是三个独立等式而是把 i、j、k 绑在了一起。由这一条能推出整个乘法表ij k、jk i、ki j反过来ji -k、kj -i、ik -j。也就是说任意两个不同虚部单位相乘交换顺序会多出一个负号。×1ijk11ijkii-1k-jjj-k-1ikkj-i-1这张表就是四元数的全部性格来源。它带来的直接后果是一般情况下q₁q₂ ≠ q₂q₁。乘法不再交换左乘和右乘是两件不同的事。这一点在写代码时格外要命顺序写反了不会报错只会得到一个看起来很正常、但完全不对的朝向。4.2 标量加向量把四个数读成「一个数配一根轴」四个分量直接摆着读不出几何意义所以四元数常被写成「标量 三维向量」的有序对q [s, v], s ∈ ℝ, v (x, y, z)这样写之后四元数乘法可以用比较紧凑的形式表达也更容易看出它跟轴角的关系s 管角度v 管轴的方向。模长的定义照抄复数的思路把四个分量的平方加起来开根号|q| √(a² b² c² d²) √(s² |v|²)这里要提醒一句四元数的模长没有直观的几何解释它度量的是一个四维向量的长度。不必强行在三维里找对应物把它当成一个定义就行。真正有用的是模长为 1 的那一类也就是单位四元数。4.3 单位四元数为什么角度要写一半把一个单位四元数写成轴角的形式会得到一个看起来有点奇怪的式子q [cos(θ/2), sin(θ/2)·u]θ 是旋转角u 是单位旋转轴。角度上多出来的那个 1/2是四元数最让人困惑的地方之一。原因在于四元数作用在向量上的方式不是普通乘法。要让一个三维向量 v 绕轴转 θ 度得用「夹心」形式v q · [0, v] · q⁻¹。q 从左边乘一次q 的逆从右边乘一次两次各贡献一半的角度合起来才是 θ。所以参数里只写 θ/2。这个「夹心」还带来一个反直觉的后果q 和 -q 表示的是同一个旋转。因为 (-q) 乘 (-q)⁻¹ 等于 q 乘 q⁻¹负号被抵消掉了。换句话说四元数到旋转的映射是「双倍覆盖」的转一圈360 度q 会走到 -q而不是回到 q得再转一圈累计 720 度才回到原来的 q。这一点在实际工程中会造成实打实的麻烦。两段动画之间做插值如果一段的四元数是 q另一段存成了 -q直接插值会走一条绕远路的长弧模型在半空中翻个跟头。所以几乎所有做四元数插值的库都会在插值前先检查两个四元数的点积如果是负的就把其中一个取反。这是血泪经验不是数学洁癖。4.4 用四元数转一个向量把公式翻成代码把上面这些拼起来四元数旋转一个向量的完整流程是由轴和角构造 q把向量塞进实部为 0 的四元数做夹心乘法取结果的虚部。核心是四元数乘法本身def quat_mul(q1, q2): # 四元数按 (w, x, y, z) 存储w 是实部 w1, x1, y1, z1 q1 w2, x2, y2, z2 q2 # 直接由乘法表展开注意 i、j、k 交叉项的正负号 return ( w1*w2 - x1*x2 - y1*y2 - z1*z2, w1*x2 x1*w2 y1*z2 - z1*y2, w1*y2 - x1*z2 y1*w2 z1*x2, w1*z2 x1*y2 - y1*x2 z1*w2, ) def quat_from_axis_angle(axis, theta): # 单位四元数实部 cos(θ/2)虚部 sin(θ/2) 乘单位轴 u normalize(axis) half theta / 2 c, s math.cos(half), math.sin(half) return (c, s * u[0], s * u[1], s * u[2]) def quat_rotate(v, q): # 夹心乘法q * [0, v] * q⁻¹ # 单位四元数的逆等于共轭即虚部取反 q_inv (q[0], -q[1], -q[2], -q[3]) pure (0.0, v[0], v[1], v[2]) result quat_mul(quat_mul(q, pure), q_inv) return result[1:] # 取虚部就是旋转后的向量三个函数的分工很清楚quat_mul处理乘法quat_from_axis_angle把轴角翻译成四元数quat_rotate完成整个夹心流程。参数上要注意quat_rotate里的q必须是单位四元数否则q⁻¹不等于共轭得老老实实除以模长平方。这是另一个常见翻车点从外部读入的四元数如果带累积误差导致模长偏离 1旋转结果就会带上一点莫名其妙的缩放而缩放又会被误认为是模型本身的问题。这里额外说明一个细节quat_rotate里做了两次四元数乘法看起来比轴角公式的开销大。但实际工程中两个旋转的复合只需要一次四元数乘法也就是四个数的乘加代价远小于把两个旋转矩阵相乘的 27 次乘法。这正是四元数在实时图形学里受欢迎的经济学原因。5. 常见误区四元数、欧拉角、旋转矩阵别混着说5.1 误区一以为四元数就是「四个角度」最容易混的一点是把四元数的四个分量读成「四个角度」或者「三个轴加一个角」。如果真是这样它和轴角表示就没区别了。为什么容易混轴角表示确实是三个轴分量加一个角而四元数在单位化之后恰好也能反推出一个轴和一个角两者形式对得上于是很容易顺手画等号。怎么区分四元数的四个分量是四个实数通过cos(θ/2)和sin(θ/2)·u的对应关系才能换算成轴角。它们的量纲根本不同——轴的分量是无量纲的方向分量角是弧度而四元数的四个分量都是无量纲的数值加起来的平方和必须等于 1。更直接的区分方法是看复合轴角没法直接相乘四元数可以。5.2 误区二以为四元数乘法满足交换律学复数时被「乘法可交换」惯坏了看到四元数长得像复数就默认q₁q₂ q₂q₁。为什么容易混二维旋转确实可以先转谁都行复数乘法也确实可交换这个直觉在平面里是成立的搬到三维就失效了。怎么区分记住一句口诀——「三维旋转天然不交换」。物理上先绕竖直轴转 90 度再绕水平轴转 90 度和反过来结果就是不一样。四元数只是忠实地把这个几何事实编码进了乘法规则里。写代码时q2 * q1表示「先施加 q1再施加 q2」顺序从右往左读这个约定必须统一否则多人协作时一定会出问题。5.3 误区三把任意四元数都当成旋转单位四元数表示旋转但四元数本身不限于单位长度。长度为 2 的四元数、长度为 0 的四元数都是合法的四元数但它们不是旋转。为什么容易混四元数的定义里没有任何「必须归一化」的约束a bi cj dk中四个实数随便取都能构成一个四元数。只有额外规定模长为 1才进入「旋转」这个子集。怎么区分看模长。|q| 1才是旋转|q| ≠ 1的普通四元数更像是「带缩放因子的旋转」作用在向量上会把长度一起改掉。所以在任何接收外部四元数的接口里归一化都应该是第一步而不是可选的优化。5.4 误区四以为四元数比欧拉角「更高级」四元数没有死锁、复合方便于是很多人得出结论说欧拉角是落后的东西应该全面淘汰。为什么容易混性能和维护性上四元数确实占优这个优势被反复强调容易盖过另一面。怎么区分两者的面向对象不同。欧拉角是给人看的美术在面板上调三个数字就能摆姿势直觉性无可替代四元数是给机器算的四个分量对人不友好你没法看着(0.707, 0, 0.707, 0)说出物体朝哪。合理的工程做法是界面层用欧拉角供人操作内部计算层用四元数两者之间做转换而不是二选一。5.5 误区五以为四元数和旋转矩阵可以无损随意互转从四元数算旋转矩阵是确定性的四个数进去九个数出来没有歧义。反过来就没那么干净。为什么容易混既然两个东西表示同一个旋转那互相转换应该是对称的这个直觉看起来无懈可击。怎么区分矩阵转四元数时会出现符号二义性——q和-q对应同一个矩阵反解时无法区分只能按 convention 选一个通常是让实部非负。另外矩阵里如果混入了数值误差反解出来的四元数模长可能不为 1还需要再归一化一次。所以这条转换链路上不能假设「无损往返」每次都要做校正。6. 什么时候该用四元数什么时候别用把三种姿态表示摆在一起边界就清楚了表示方式参数个数有无死锁复合方便度人类可读性欧拉角3有差高旋转矩阵9无中矩阵乘低轴角4轴 角无差中四元数4无高一次乘法低从表里能读出一条很实用的判断线需要连续复合、需要避免死锁、需要频繁插值的场景四元数是最优解。典型例子是动画骨骼系统——上百个关节串成一条链每个关节的朝向都要和父关节叠加如果用欧拉角链条一长误差和死锁都会找上门用四元数一次乘法叠加完还能顺手归一化压住漂移。反过来不必用四元数的场景也很明确。如果只是「让相机绕物体转一圈」这种单一轴的固定旋转一个角度变量就够了引入四元数纯属给代码加戏。如果整个系统只需要把模型摆成一个静态朝向且朝向由美术在编辑器里拖出来那就存欧拉角到渲染前再转一次省心省力。还有一个常被忽略的边界涉及大量旋转平均或者旋转优化的场合四元数的四个分量必须满足模长为 1 的约束直接做最小二乘会对不上。这时候要么转到李代数那套语言里要么接受近似的代价。这不是四元数的缺陷而是「四个数表达三个自由度」必然带来的冗余任何冗余表示都会在优化问题里冒出类似的麻烦。我自己在这上面栽过一次。做一个相机环绕控制为了图省事把鼠标的横向和纵向位移分别构造两个四元数然后直接相乘结果相机在接近正上方时开始出现诡异的抖动。查了很久才想明白横纵两个旋转需要复合的次序和参考系必须一致我先乘谁后乘谁当时是随手写的恰好在一个角度区间里对了换到另一个区间就翻了。从那以后凡是涉及多个旋转复合的地方我都强制在注释里写清「先施加哪个、后施加哪个」并且用一个特例值先手动验证一遍再继续往上写。绕了一圈回到那个最朴素的判断如果你需要的是「算得准、叠得稳、不卡死」四元数值得花时间搞懂如果你需要的是「人能看懂、能直接调」别为难自己欧拉角挺好。真正把这两件事理清楚的人比会背公式的人少得多。如果你想把这条推导链从复数一路走到四元数中间不跳步、每步都有几何解释那份完整的资料里有更细的展开和配套的演示代码值得存下来慢慢翻。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑