简介:这是一份面向Unity开发者和3D图形学习者的四元数专题讲解PDF,用几何与线性代数的视角替代抽象代数,系统梳理四元数从定义到三维旋转应用的完整链路。资源共1个PDF文件,压缩包仅439KB,适合快速通读与反复查阅。文档先从复数乘法与2D旋转的关系切入,再自然过渡到四元数的定义、共轭与旋转公式v''=q*v'*conj(q),并针对Unity引擎给出Quaternion.Euler、RotateTowards、Lerp等常用API的用法说明,同时对比欧拉角解释万向节死锁问题的成因。内容将复数、矩阵与3D旋转打通来讲,附录还提供了MATLAB/Octave演示代码的GitHub链接,便于读者动手验证。目前已有3375人学习下载,适合希望深入理解旋转本质并提升Unity中平滑旋转与插值应用能力的中高级开发者。
1. 四元数详细讲解:它到底在解决什么问题,凭什么替代欧拉角
做三维旋转计算的人,迟早会撞上一个叫「万向锁」的玄学问题:明明欧拉角三个数就能描述姿态,结果俯仰角一接近 90 度,航向角就开始乱跳,姿态解算整个翻车。做过机器人、无人机、三维渲染的从业者应该都见过这种现象。四元数之所以被频繁提起,是因为它用四个数避免了这个奇点问题,同时避免了矩阵运算带来的大量冗余计算和漂移累积。这篇笔记不是去抄数学课本,而是把四元数从定义到代码落地、再到实际项目里常见的坑,按一线使用顺序捋一遍。无论你是做姿态传感器融合、游戏骨骼动画,还是三维引擎的相机控制,这套内容都够你直接用来排查问题和写代码。
我没有某个神秘项目源码可以拿来分析,但四元数的工程化路径是非常成熟的:理解它的旋转本质,学会轴角转换,然后在你的代码里用一个稳定的工具类,最后避开单位化、符号顺序这些血泪坑。文章里的代码都是常见做法,你可以在自己的项目里直接验证。
2. 四元数的数学内核:从虚数扩展到旋转映射,别怕复数
2.1 四元数不是四维向量,它是带约束的旋转算子
很多人拿到四元数接到的第一个知识点就是:它是一个四元组 (w, x, y, z)。然后就用它当普通向量去算点积、叉积,结果自然对不上号。四元数的本质是复数系的扩展——在一维复数 i² = -1 的基础上,引入了三个虚部,满足 i² = j² = k² = ijk = -1。这个定义让四元数具备了描述三维旋转的代数性质。
这里的关键是它的几何含义:一个单位四元数对应的就是一个三维旋转,旋转轴是虚部向量 (x, y, z),旋转角度则编码在 w 和虚部的长度里。设旋转角为 θ,旋转轴为单位向量 n,则四元数可以表示为:
q = cos(θ/2) + (nx·i + ny·j + nz·k)·sin(θ/2)
注意 θ/2 这个半角,这是几乎所有人第一次算错的地方。它意味着绕轴旋转 θ,在四元数里需要用 θ/2 去构建。旋转矩阵可以完全没有欧拉角的奇点问题,因为它根本不用去单独表达「绕 X、绕 Y、绕 Z」三个分量的累积关系,而是一次性表达「绕某个任意轴转了多大角度」。
工程上建议把四元数当作一个「旋转算子」来看待,而不是一个普通元组。它的约束是单位长度:w² + x² + y² + z² = 1。如果这个条件被破坏,旋转矩阵的缩放、归一化、插值都会出问题。这是后面避坑章节第一个要展开的细节。
2.2 核心公式对照:乘法、共轭、归一化,这三个你绕不过去
把四元数用在代码里,最开始只需要记三个操作:乘法、共轭、归一化。四元数乘法不满足交换律,这是和普通向量最大的差别。q1 * q2 表示先做 q2 的旋转,再做 q1 的旋转。这个次序在姿态更新的时候特别关键——你把传感器数据喂进去的顺序反了,结果就完全不对。
乘法公式如下(设 q1 = w1 + x1 i + y1 j + z1 k,q2 同理):
w = w1 w2 - x1 x2 - y1 y2 - z1 z2 x = w1 x2 + x1 w2 + y1 z2 - z1 y2 y = w1 y2 + y1 w2 + z1 x2 - x1 z2 z = w1 z2 + z1 w2 + x1 y2 - y1 x2共轭 q* 是把虚部取反,对于单位四元数来说,共轭等于逆。也就是说,如果你想撤销一次旋转,乘上它的共轭就行,这一点比矩阵求逆快得多:矩阵逆计算是 O(n³) 级别,四元数共轭只是三个数取符号。归一化则是把四元数除以它的模长,约束回单位球面。设初始传来的四元数可能带噪声,归一化能防止旋转中引入缩放畸变。
2.3 为什么欧拉角的奇点问题在四元数这里不存在
欧拉角的万向锁本质是:三个旋转轴在特定姿态下发生了退化,两个轴对齐了,于是丢失了一个自由度。数学上这和欧拉角的参数化有关——它在特定角度下导数矩阵不满秩。四元数把旋转表达为四维单位球面上的点,任何姿态都对应球面上的一个点,不存在「角度趋近某个值就失去自由度」的可能。
一句话概括:三维旋转群 SO(3) 在拓扑上不是一个三维球体,无法用三个坐标参数全局描述,而四元数所在的 S³ 球面恰好可以做到无奇点的覆盖。这个性质决定了它在姿态解算、动画插值、物理仿真这些场景里是首选表示。如果你在做一个欧拉角方案,当俯仰角长期在 ±90° 附近工作,换成四元数不是优化,是必要替换。
3. 把四元数用起来:轴角转换、旋转向量与可视化验证
3.1 从轴角到四元数:构造和解析的标准流程
拿到一个旋转轴和旋转角,怎么构建四元数?反过来,给你一个四元数,怎么读出来轴和角?这两个手法是传感器标定、机器人运动学里最常写的。从轴角到四元数,常见做法是先做半角变换,再把轴分量乘上 sin(θ/2)。注意轴 n 必须是单位向量,否则结果不是单位四元数,后续旋转矩阵会出缩放。
从四元数到轴角的解析逻辑是:w = cos(θ/2),所以 θ = 2·arccos(w),轴的三个分量由单位化后的虚部给出,即对 (x, y, z) 归一化得到旋转轴。需要小心的是 θ 接近 0 的时候,轴方向不稳定,这是数值上要注意的边界。
下面给出一个轴角转四元数的示例实现:
import numpy as np def axis_angle_to_quat(axis, angle_rad): axis = np.array(axis, dtype=np.float64) axis = axis / np.linalg.norm(axis) # 轴必须是单位向量 half = angle_rad / 2.0 w = np.cos(half) xyz = axis * np.sin(half) return np.array([w, xyz[0], xyz[1], xyz[2]]) def quat_to_axis_angle(q): q = np.array(q, dtype=np.float64) q = q / np.linalg.norm(q) # 防御性归一化 w = np.clip(q[0], -1.0, 1.0) # clip 防止 arccos 越界 angle = 2.0 * np.arccos(w) if np.sin(angle / 2.0) < 1e-8: return np.array([1.0, 0.0, 0.0]), 0.0 axis = q[1:4] / np.sin(angle / 2.0) return axis, angle3.2 用四元数旋转一个三维向量:不被绕晕的最小实现
旋转向量的标准公式是 v' = q * v * q⁻¹,也就是先把三维向量 v 补成 w=0 的纯四元数,然后左乘 q,右乘 q 的共轭(单位四元数时就是共轭)。先做广播,再去乘虚部。这个操作的执行顺序不能反。先乘后乘都错,因为左乘和右乘不等价。直接套公式的代码如下:
def rotate_vector(q, v): q = q / np.linalg.norm(q) v = np.array(v, dtype=np.float64) qv = np.array([0.0, v[0], v[1], v[2]]) q_conj = np.array([q[0], -q[1], -q[2], -q[3]]) # q * qv t = quat_mult(q, qv) # t * q_conj res = quat_mult(t, q_conj) return res[1:4] def quat_mult(a, b): w1, x1, y1, z1 = a w2, x2, y2, z2 = b return np.array([ w1*w2 - x1*x2 - y1*y2 - z1*z2, w1*x2 + x1*w2 + y1*z2 - z1*y2, w1*y2 + y1*w2 + z1*x2 - x1*z2, w1*z2 + z1*w2 + x1*y2 - y1*x2 ])函数quat_mult里的乘法顺序对应实际旋转的先后顺序。这里 q * v * q⁻¹ 是先左乘 q 再右乘 q 的共轭,正是绕轴旋转的标准顺序。如果你需要做连续旋转,比如先绕 Z 轴转 30 度再绕 X 轴转 45 度,旋转施加顺序是从右往左,也就是使用 q_total = q_x * q_z,这里是代码里容易搞反的次序问题。
3.3 用四元数生成旋转矩阵:驱动三维引擎的桥梁
OpenGL、Unity、Three.js 等渲染引擎内部通常接受旋转矩阵,不直接吃四元数(虽然部分引擎封装了转换接口),所以你要能手动把四元数转成 3x3 旋转矩阵。单位四元数 q = (w, x, y, z) 对应的旋转矩阵为:
R = [[1-2(y²+z²), 2(xy-wz), 2(xz+wy)], [2(xy+wz), 1-2(x²+z²), 2(yz-wx)], [2(xz-wy), 2(yz+wx), 1-2(x²+y²)]]
这个矩阵的每一行表示旋转后的基向量在原坐标系里的投影。如果你发现渲染出来的物体既放大又拉伸了,检查一下这张矩阵是否由单位四元数生成。这里给出转换实现:
def quat_to_rotation_matrix(q): q = q / np.linalg.norm(q) w, x, y, z = q return np.array([ [1 - 2*(y*y + z*z), 2*(x*y - w*z), 2*(x*z + w*y)], [2*(x*y + w*z), 1 - 2*(x*x + z*z), 2*(y*z - w*x)], [2*(x*z - w*y), 2*(y*z + w*x), 1 - 2*(x*x + y*y)] ])注意矩阵的转置方向。这个公式给出的是列向量约定下的标准矩阵;在行向量约定的引擎里(比如部分 Web 渲染管线),需要转置后再喂给着色器。这个坑能让你调一整天渲染效果,下面避坑章节会专门展开。
3.4 可视化验证:把四元数转成三个正交向量画出来
写完了转换函数,建议不要直接上真机,先做个可视化验证。我的常见做法是:给定一个初始姿态四元数,转成旋转矩阵,然后把矩阵的三个列向量当作红绿蓝三条线画出来,拖拽调整角度,观察物体转动是否符合直觉。这一步能过滤掉一大半符号方向错误。
验证脚本大致逻辑是:随机生成旋转轴和角度,计算旋转后的向量,再和欧拉角计算的结果做差值对比,最大误差控制在 1e-6 内才算通过。如果你的误差是 2 倍左右,多半是半角没处理;如果是模长对不上,多半是没归一化。跑通这个验证脚本再去接入工程,能省下大量排查时间。
4. 在代码里落地四元数:最小可用的旋转工具箱
4.1 姿态更新里的四元数微分方程:为什么传感器方案绕不开它
四元数在惯性导航领域有一个绕不开的公式:四元数微分方程。它描述了角速度到四元数变化率的映射,姿态解算从它出发。公式可以写成 q_dot = 0.5 * q * ω,其中 ω 是纯四元数形式的角速度 (0, ωx, ωy, ωz)。实现时通常采用一阶龙格库塔更新,也就是用当前姿态加上导数与采样时间间隔的乘积,来做姿态递推。
这个方程的推导可以从四元数和角速度的几何关系理解,但对工程使用来说,记住更新形式就够了。假设你的 IMU 输出角速度,采样周期 dt,姿态更新就写成 q_new = q + 0.5 * q * ω * dt,之后做归一化。下面是可用的姿态更新函数:
def quat_update(q, gyro, dt): # gyro: (wx, wy, wz) 单位弧度/秒 qw, qx, qy, qz = q wv = np.array([0.0, gyro[0], gyro[1], gyro[2]]) dq = 0.5 * quat_mult(q, wv) * dt q_new = q + dq q_new = q_new / np.linalg.norm(q_new) return q_newdq = 0.5 * quat_mult(q, wv) * dt这一步对应微分方程的离散化。写成 q + dq 而不是严格意义上的矩阵指数形式,是在 dt 足够小(小于 0.01s)情况下的合理近似。如果传感器频率不够高,比如只有 20Hz 采样,一阶近似会产生明显漂移,这时要考虑二阶龙格库塔或更高的数值积分方式。实际项目里最好把瞬时角速度校准好零偏,否则纯积分漂移会迅速积累成角度偏转。
4.2 构建一个可复用的四元数工具类:方法划分与边界处理
工程上建议把常用操作封装成一个四元数类,避免散落成零散函数。类里至少应该包含:构造(从轴角、从旋转矩阵、从欧拉角)、旋转向量、乘、共轭、归一化、打印。核心操作归一化,要在每次运算后都做一次,因为多次乘法会累积浮点误差,让模长漂移。
下面是一个简化的 Python 工具类,直接可拷走:
class Quaternion: def __init__(self, w=1.0, x=0.0, y=0.0, z=0.0): self.q = np.array([w, x, y, z], dtype=np.float64) self.normalize_inplace() @staticmethod def from_axis_angle(axis, angle): axis = axis / np.linalg.norm(axis) half = angle / 2.0 return Quaternion(np.cos(half), *(axis * np.sin(half))) def normalize_inplace(self): norm = np.linalg.norm(self.q) if norm < 1e-12: self.q = np.array([1.0, 0.0, 0.0, 0.0]) else: self.q /= norm def multiply(self, other): res = quat_mult(self.q, other.q) q = Quaternion(*res) return q def rotate(self, v): return rotate_vector(self.q, v) def to_matrix(self): return quat_to_rotation_matrix(self.q)类里暴露出的关键边界是:构造后立即归一化;旋转方法内部的单位化;乘法生成后自动归一化。这套设计能保证你在绝大多数使用场景都不会踩单位化缺失的坑。要注意的是封装不要做重,重到影响性能,嵌入式 C 代码里这类操作通常以内联函数实现。
4.3 坐标系约定:同一套四元数在不同硬件里含义完全不同
传感器和渲染引擎的坐标系方向五花八门:有的 Z 轴向上,有的 Y 轴向上;有的使用右手系,有的使用左手系;重力方向在不同设备上还区分正负。四元数表述的是「绕某个轴的方向和角度」,这个轴是定义在哪个坐标系下的?这一点没搞清楚,代码越写越错。
我的一般做法是:先明确传感器的坐标轴朝向,再定义目标坐标系的轴,然后补一层转换。比如某 IMU 使用右手系 Z 向上,而渲染引擎使用右手系 Y 向上,那就要把一个 90 度的姿态补偿矩阵预先乘进去。不做这一步,数据几乎必然偏 90 度或翻转。调试的时候先让小角度转动一格,观察输出变化方向,方向不对就反转对应轴的分量。
4.4 从欧拉角转四元数:三个备忘和一句忠告
从欧拉角转四元数有现成公式,但不同引擎的旋转顺序不同,转换结果差异明显。未约定顺序之前,任何欧拉角转四元数的代码都不能直接信任。常见顺序有 ZYX、XYZ、YXZ,Unity 使用 ZXY(相对自身轴),ROS 使用 ZYX 的等效表示。换顺序就会产生不同的目标姿态。
代码里常见实现是先分别构造三个轴的单轴四元数,再按顺序乘起来。假设采用 ZYX 顺序:
def euler_to_quat_zxy(roll, pitch, yaw): cr, cp, cy = np.cos(roll/2), np.cos(pitch/2), np.cos(yaw/2) sr, sp, sy = np.sin(roll/2), np.sin(pitch/2), np.sin(yaw/2) qw = cr*cp*cy + sr*sp*sy qx = sr*cp*cy - cr*sp*sy qy = cr*sp*cy + sr*cp*sy qz = cr*cp*sy - sr*sp*cy return Quaternion(qw, qx, qy, qz)这段公式对应固定轴 XYZ / 本体轴 ZXY 的等效关系,如果项目里用的顺序不同,公式四个分量的加减号需要相应调整。忠告是:不要把欧拉角存在代码里当主状态变量,只在调试和展示时用。你可能会因为图省事一直用欧拉角计算,但奇点、符号分岔、角度回绕这些代价会在某个阶段全部回来找上你。
5. 四元数避坑指南:顺序、符号、单位四元数与数值爆炸的排查记录
5.1 坑一:旋转顺序反了,姿态完全对不上
现象:同样的角速度输入,用四元数更新姿态,得到的姿态和真实方向有一定偏差,旋转方向明显与预期相反。原因可能有两层:一是四元数乘法的左右顺序写反,二是 IMU 的角速度定义方向与实际应用相反。判断方法很简单,把设备绕单个轴正方向转动一下,看输出姿态角是否跟着正方向增长。
解决:先固定乘法惯例。代码里我统一采用「新姿态 = 增量四元数 × 旧姿态」,而增量四元数由角速度构建。增量的虚部方向由右手定则确定,如果你的设备输出角速度是左手系,取反虚部即可。先在单个轴上验证方向,再组合多轴,不要直接做多轴同时验证,否则分不清是哪条轴的问题。
5.2 坑二:四元数没归一化,物体越变越大
现象:旋转多次之后,模型的尺寸缓慢变大或缩小,或者姿态更新越来越不稳定,几分钟后彻底飞掉。原因就是浮点乘法的四舍五入令模长逐渐偏离 1。轴承旋转计算里「单位四元数」是硬约束,模长大于 1 会放大,小于 1 会缩小,典型的累积漂移。
解决:每次做完乘法、微分更新、插值之后,立刻做归一化。不要等到最后一步再统一归一化,中间过程的偏差已经污染了旋转向量。代码里可以在工具类的multiply返回值之前强制归一再return。养成这个习惯后,这类问题基本绝迹。
5.3 坑三:符号不一致,两个四元数表示同一个旋转
现象:同一姿态,有人传来 q,有人传来 -q,程序里对比半天数值完全相反,但实际旋转效果一样。原因:单位四元数在球面上具有二重覆盖性,q 和 -q 是同一个旋转。如果你用简单的坐标差来判断两个姿态是否接近,会把本应相同的姿态误判为差很远。
解决:在比较姿态差异时,先检查点积 q1 · q2 是正还是负。点积为负,就对 q2 取负再参与差值计算。姿态插值也同样要处理最短路径:先做点积判断,如果小于 0,则把末端四元数翻向 -q2,这样插值才走的是近路而不是绕远路。
5.4 坑四:把四元数当普通向量喂给渲染管线
现象:渲染出来的模型错位、拉伸、反向,矩阵更新一帧坏一帧。原因多半是旋转矩阵的行列约定与渲染引擎不一致。GL 系通常是列主序,DirectX 是行主序;左乘和右乘向量直接影响最终显示。
解决:在喂给引擎前,打印一行旋转矩阵作用于向量后的结果,跟手算的预期对比。比如让一个向量按 (1, 0, 0) 输入,经 q 旋转 90 度应得 (0, 1, 0),如果变成 (0, -1, 0),就是符号方向问题;如果变成行的形式,就转置矩阵。始终用向量做一次冒烟测试,而不要直接依赖引擎渲染效果去猜。
5.5 坑五:imu 的陀螺仪零偏没校准,四元数也救不了漂移
现象:静态放置设备,姿态仍然持续缓慢偏移,几分钟内漂一两度甚至更多。原因不是四元数方法问题,而是陀螺仪零偏存在。零偏被积分后产生线性增长的角度误差,四元数只是表示形式,无法消除传感器原生误差。
解决:上电后先静置采集 1 到 2 秒的陀螺仪均值,作为零偏减掉。如果你对精度要求更高,引入互补滤波或卡尔曼滤波,用加速度计修正横滚和俯仰方向的长期漂移。四元数在这里的作用是提供一个无奇点的姿态状态量,滤波器的预测和更新阶段都在四元数域完成,而不是说换四元数就能绕开元器件误差。
6. 进阶:四元数微分方程、姿态插值与你真正需要的验证方法
这个东西真正进阶的方向不是背更多公式,而是会用四元数去解决实际工程问题。我最常用到的是两个点:插值短路径(slerp)和姿态平滑。简单线性插值(lerp)在角位移较大的情况下会产生加速减速感,slerp 则保证角速度恒定。做相机动画、骨骼关键帧、机械臂路径规划时,这个差别是用户体感级别的。
slerp 的公式实现为:设 t 为 0 到 1 的插值参数,q(t) = (sin((1-t)Ω)/sinΩ) · q1 + (sin(tΩ)/sinΩ) · q2。用之前先把 q2 的符号调整为与 q1 的点积为正。注意 Ω 接近 0 的时候要退化为普通 lerp,避免除零。注意 sinΩ 很小的阈值设在 1e-6 左右即可。如果你发现插值路径绕了大圈,方向越来越诡异,九成是符号没处理。
姿态平滑的习惯我保持了很久:每当接入一个新的传感器或渲染引擎,先把原点、统一坐标系、轴方向、旋转顺序这四个问题写在注释里,再开始写换算代码。四元数的难点不在它本身,而在它和外界交互时层层的坐标系与约定差异。做验证专门写一个随机姿态的往返测试,从随机四元数转矩阵再转回四元数,误差在 1e-5 以内才算通过。新代码推到主干前先跑一遍这个回归测试,能为整个项目省下大量的联调时间。
最后说一个我自己的教训:有一次为了图省事,直接在项目里混用了两套旋转库,一套接口返回行向量矩阵,另一套是列主序,结果编译不报错、渲染不闪烁,但模型姿态就是隐隐约约差一点。排查了很久才发现。后来我把所有四元数操作收敛到同一个工具类里,并在类注释里写明坐标系约定,这类问题就再也没出现过。希望帮到你。
本文还有配套的精品资源,点击获取