“坐标系下的左乘和右乘,到底是坐标系在动还是点在动”——这个问题几乎每隔几周就会出现在我的技术群里。问的人多半刚接触机械臂运动学、SLAM 或者三维渲染,拿着旋转变换的代码调了两天,最后发现某个矩阵该放左边还是右边搞反了。
前两天就有个同学给我发来一段末端位姿换算的代码,说算出来的位置偏了几十厘米。我让他把新增旋转矩阵打出来,再对比一下库文档里的接口说明,果然又是左右乘的理解出了问题。这种事我遇到太多次了,所以今天想把判断左乘右乘的那套思路完整写出来。
这篇文章主要面向正在被旋转矩阵、四元数、欧拉角绕晕的读者。我会先讲清楚“点动系动”为什么很多时候是同一件事的两种说法,再讲真正要命的多旋转顺序问题,最后给一套能直接上手的判断方法。
1. 这个问题为什么总让人晕:几种“左乘右乘”被混在了一起
1.1 场景还原:一段被问到凌晨的对话
起因是某位同学在做机械臂正运动学。末端执行器上有一个姿态旋转需要叠加到机械臂基座的变换链里。他把新姿态直接乘在了原有矩阵的左边,结果末端位置和理论值差了很远。
他问我:“老师,旋转矩阵左乘就是点动,右乘就是坐标系动,我是不是理解错了?”
这句话本身就有问题。“左乘就是点动,右乘就是系动”这个说法,在很多入门教程里流传得很广,但它把两件不同层面的事情揉在一起说,导致读者越听越乱。单独一个旋转矩阵作用于一个向量,只有R * v这种写法(在列向量约定下);能让“左乘”和“右乘”产生区别的场景,通常出现在连续旋转时“新矩阵放哪一边”。
所以我当时没有直接回答他对不对,而是让他先把场景拆开:这次新增的旋转,到底是相对基座坐标系描述的,还是相对末端当前坐标系描述的?这个问题一出来,他自己就发现代码里的语义和注释对不上。
1.2 “点动”还是“系动”在数学上为何等价
先说一个关键事实:主动旋转和被动旋转在数值上经常是同一个操作。
拿二维情况举例。假设有一个向量v = (1, 0),让它绕原点逆时针转 90°,得到v' = (0, 1)。这个变换可以用旋转矩阵R表示,v' = R * v。
现在换个说法:坐标系绕原点顺时针转 90°,向量保持不动,那么同一个向量在新坐标系中的坐标,依然可以写成(0, 1)这种形式的变换。只要把矩阵R理解为“新旧坐标系基向量之间的转换关系”,数值计算过程和刚才完全一样。
所以,“点动”和“系动”在数学形式上经常是等价描述。不同教材、不同讲师只是选择了不同的解释角度:有人习惯把R * v解释成“点在固定坐标系里被旋转”,有人习惯解释成“点不动,坐标轴换了”。两边没有谁对谁错,但如果你在两套解释之间反复横跳,就会晕。
1.3 真正让你晕的是连续旋转,不是单个旋转
单一旋转矩阵作用于向量时,其实没有太多可晕的空间:形式就是R * v,左边还是右边一目了然。真正会出错的地方,是连续两次或者多次旋转时,新来的旋转矩阵应该放在原矩阵的哪一边。
很多教程也把这个问题概括为“左乘右乘”,但它和上一节说的“点动系动”并不是一个层面。连续旋转里的“左乘右乘”,答案取决于新旋转是相对世界参考系说的,还是相对当前物体坐标系说的。这一件事才是项目里最常见的 bug 来源。
所以先别背口诀,先把问题分层:单次旋转是一种解释问题,连续旋转是另一种顺序问题,你的代码里到底属于哪一层,一定要先搞清楚。
2. 单次旋转的两种诠释:同一个R * v,两种讲法
2.1 主动旋转:固定坐标系下的点动
主动旋转是最直观的理解方式。坐标系固定不动,一个点或向量围绕某个轴旋转一个角度。例如在点云处理中,把一帧点云绕世界坐标系的 Z 轴旋转 90°,所有点的坐标都发生变化,坐标系本身没变。
这个场景里的旋转矩阵R是一个“算子”,它作用在向量v上,得到新向量v':
v' = R * v如果你看R的列向量,会发现它们恰好是原本坐标系三个基向量经过旋转之后的新位置。这一特征可以用来判断某个矩阵是不是“主动旋转”视角下的矩阵:把单位矩阵I的三个基向量分别代入R * v,看到的新方向就是旋转后的基向量方向。
很多同学会在这里产生一个疑问:既然坐标系没变,为什么有“坐标系动还是点动”这一说?答案是,同一个数学操作完全可以反过来解释。这就引到了被动旋转。
2.2 被动旋转:点不动,坐标系换了一套
被动旋转的思路是:点本身没有动,只是用来描述这个点的坐标系换了一套。假设有一个描述刚体姿态的旋转矩阵R,它的列向量是当前物体坐标系三个基轴在参考坐标系下的坐标。如果一个点在物体坐标系下的坐标是v_obj,那么它在参考坐标系下的坐标就是:
v_ref = R * v_obj这个式子和上一节v' = R * v在形式上没有任何差别,但几何意义完全不一样。前者是“同一个点在不同坐标系之间的坐标转换”,后者是“同一个坐标系下点被旋转”。
怎么快速区分?看R的列向量语义。主动旋转里,列向量是“基向量被旋转后的新坐标”;被动旋转里,列向量是“新坐标系的基向量在原坐标系中的坐标”。同一个数值矩阵,这两句话描述的是同一个几何事实,只是叙事角度不同。
2.3 我的建议:永远先标注你的数据流方向
每次写旋转矩阵相关代码前,我建议你在注释里先写清楚一个关键问题:
R 表示“把 A 坐标系下的坐标变换到 B 坐标系”,还是“把 B 坐标系下的坐标变换到 A 坐标系”?变量命名也尽量避免裸写R。我见过太多代码里全是R1、R2、R3,最后没人分得清谁是谁。更好的命名方式是这样:
R_world_to_camera R_camera_to_world R_body_to_world一旦把方向标注清楚,很多左乘右乘的困惑会自动消失。因为当你明确知道R是把“哪个系变到哪个系”之后,矩阵乘法顺序自然就由坐标变换链决定了,不需要硬背口诀。
3. 连续旋转的乘法顺序:固定轴左乘,当前轴右乘
3.1 从坐标变换链推导乘法顺序
现在进入最核心的部分。假设初始时物体坐标系B0和世界坐标系W重合,姿态矩阵为R_WB0 = I。这个矩阵的含义是:
p_w = R_WB0 * p_b0如果物体绕世界坐标系里的某个固定轴旋转,旋转矩阵为R_add,那么旋转后世界坐标变为:
p_w' = R_add * p_w = R_add * R_WB0 * p_b0所以新的姿态矩阵是:
R_WB1 = R_add * R_WB0这就是“固定轴旋转左乘”的由来。新旋转相对世界系描述,放在原姿态矩阵左边。
反过来,如果这次旋转是相对物体当前坐标系自身坐标轴进行的,例如绕当前物体坐标系的 Y 轴旋转 90°,那么在物体坐标系内部,新旧物体坐标系之间有一个局部旋转R_local:
p_b0 = R_local * p_b1再把它换算到世界系:
p_w = R_WB0 * p_b0 = R_WB0 * R_local * p_b1新的姿态矩阵变成:
R_WB1 = R_WB0 * R_local这就是“自身轴旋转右乘”的由来。
3.2 绕世界坐标轴旋转,为什么是左乘
上一节的推导已经给出了答案。用更直观的方式理解:你站在世界坐标系外面观察一个刚体,看到它绕世界坐标系的 X、Y、Z 轴转动。每次转动的旋转矩阵都是相对同一个固定的世界系描述的,所以新的旋转会作用在所有已经变换过的坐标之上。用矩阵表示,就是不断往已有矩阵的左边叠乘新的旋转。
这类似于叠加位移:先走到 A 点,再沿着世界坐标方向走一段,实际位置就是“位移向量相加”;旋转矩阵虽然不是加法,但连续左乘的逻辑一致——每一步的变换都作用于当前世界坐标结果上。
3.3 绕自身坐标轴旋转,为什么是右乘
当你坐在刚体上描述下一次转动,例如“我现在绕自己的 Y 轴转 90°”,这个描述是相对物体自身坐标系给出的。物体外部的人看这个动作,他需要在已有姿态的基础上,再做一次局部变换。局部的旋转发生在物体坐标系内部,所以新旋转在矩阵链中更靠近“刚从物体坐标系出来的那一步”,也就是放在右边。
这也是机械臂运动学里常见的现象。机械臂每个关节的旋转都是相对前一个关节坐标系定义的,所以一整条链的变换矩阵都往右边乘:
T_0_to_n = T_0_to_1 * T_1_to_2 * T_2_to_3 * ... * T_(n-1)_to_n每一个T_(i-1)_to_i都相当于在当前关节坐标系下发生的局部变换。
3.4 数值算例:两个看似相同的 90° 旋转,结果完全不同
说一百遍理论,不如实际算一组数据。采用右手系、列向量约定,旋转矩阵定义如下:
Rz(θ) = [[cosθ, -sinθ, 0], [sinθ, cosθ, 0], [0, 0, 1]] Ry(θ) = [[cosθ, 0, sinθ], [0, 1, 0], [-sinθ, 0, cosθ]]假设初始姿态为单位矩阵I。
方案一:先绕世界 Z 轴转 90°,再绕世界 Y 轴转 90°。这是固定轴旋转,新旋转左乘:
A_total = Ry(90°) * Rz(90°)代入数值:
Ry(90°) = [[0, 0, 1], [0, 1, 0], [-1, 0, 0]] Rz(90°) = [[0, -1, 0], [1, 0, 0], [0, 0, 1]] A_total = [[0, 0, 1], [1, 0, 0], [0, 1, 0]]方案二:先绕世界 Z 轴转 90°,再绕当前自身 Y 轴转 90°。这是当前轴旋转,新旋转右乘:
B_total = Rz(90°) * Ry(90°)代入数值:
B_total = [[0, -1, 0], [0, 0, 1], [-1, 0, 0]]两个矩阵完全不同。换句话说,“先转 Z 再转 Y”这句话没说完整:绕世界 Y 轴转,和绕当前自身 Y 轴转,最终姿态是两个样。这个算例值得你自己在代码里跑一遍,跑完再回头看前面的推导,会顺畅很多。
4. 工程上的快速判断法:别背公式,看参考系
4.1 三句话定位法
在实际项目里,我一般用三句话快速定位左右乘:
第一句:新增的旋转,描述在哪个坐标系下?
第二句:如果描述在世界系/固定系下,就放在已有矩阵的左边,也就是左乘。
第三句:如果描述在当前物体系/自身系下,就放在已有矩阵的右边,也就是右乘。
听起来简单,真正难的是准确判断“描述在哪个坐标系下”。很多代码的接口文档含糊,变量名又全是简写,这时需要回到原始数据流去分析。
我自己的排查步骤通常是:
- 找出这个旋转矩阵是哪个接口返回的。它返回的是世界系下的旋转,还是局部系下的旋转?
- 看这个旋转作用的对象。如果它是作用在“世界系坐标值”上的,通常是左乘;如果它是作用在“物体局部坐标链”上的,通常是右乘。
- 用一组已知数据做验证。例如让物体执行一个简单的 90° 旋转,用单位向量
(1,0,0)代入,看最终坐标是否符合预期。
4.2 从几个常见场景看左右乘
机器人正运动学是右乘的典型场景。每个关节的变换都是相对上一关节坐标系定义的,所以递推式是:
T_current = T_previous * T_joint这个右乘关系一直叠下去,最终得到的就是从基座到末端的变换矩阵。
在三维渲染里类似。物体自身有一个本地变换,如果我们想实现“物体绕自身本地轴转”,需要修改的是物体本地变换,把它右乘到祖先链上;如果要把物体放到世界系某个轴的固定旋转里,则是在外层再套一个世界矩阵,那相当于左乘。
还有无人机姿态解算里的一个经典问题:陀螺仪积分得到的姿态增量是相对机体坐标系描述的,而期望姿态往往是相对世界坐标系描述的。所以一个是右乘更新,一个是左乘更新。很多刚入门的朋友把这两者搞混,结果就是悬停姿态慢慢漂掉。
4.3 最容易翻车的地方:约定一变,左右互置
必须提醒一下,同样是“固定轴左乘、当前轴右乘”,这句话并不是普适真理,它依赖三个约定:列向量、右手系、旋转矩阵方向。
如果你把向量全部写成行向量,也就是v' = v * R,那么连续旋转的叠加方向会镜像互换。同一个“绕世界固定轴再转一次”的操作,行向量版本会变成在右边乘。
如果你用的 SDK 返回的矩阵是把“世界坐标转换到相机坐标”,而不是“相机坐标转换到世界坐标”,那么你推导出的左右乘结论也可能要取逆。
应对方法很简单:不要从网上抄口诀,拿到一个库之后,先用单位向量做一次已知结果的验证。把一个点绕某个轴转 90°,看输出坐标是否和预期一致。这样五分钟就能确认库的内部约定,比背十句口诀都管用。
5. 四元数和欧拉角场景下的同样规律
5.1 四元数连乘的顺序规则
很多项目里不用矩阵,而用四元数。四元数的旋转公式是:
v' = q * v * q^(-1)连续两次旋转时,规律和矩阵完全一致:
如果新增旋转是相对世界系描述的,新四元数放在左边:
q_total = q_new * q_old如果新增旋转是相对当前物体系描述的,新四元数放在右边:
q_total = q_old * q_new
这里最迷惑人的一点是,四元数乘法的左右并不像普通数字那样容易看出几何意义。我建议调试时把四元数转成旋转矩阵,或者直接作用到单位向量上,看最终方向是否符合预期。我自己写代码时,通常先明确写一个QuaternionMultiply函数,并在注释里标明当前是“世界系外旋”还是“本体系内旋”。
5.2 外旋与内旋:同一个旋转的两副面孔
欧拉角里也有对应的概念:外旋和内旋。
外旋指绕固定参考系的坐标轴依次旋转。这种情况下,后发生的旋转放在矩阵链的左边,所以总矩阵是:
R_total = R_last * R_middle * R_first例如先绕世界 X 轴转,再绕世界 Y 轴转,最后绕世界 Z 轴转:
R_total = Rz(γ) * Ry(β) * Rx(α)内旋指绕当前已旋转后的物体坐标系的坐标轴依次旋转。此时后发生的旋转放在右边:
R_total = R_first * R_middle * R_last工程上有一个常用的等价关系:内旋 Z-Y-X 等价于外旋 X-Y-Z,只是角度顺序要对应。这个结论在做欧拉角与旋转矩阵互转时很常用,但前提是你得先确认自己的库用的是哪种约定。
5.3 调试心得:单位向量测试法
最后分享一个我自己的调试习惯。凡是涉及旋转矩阵乘法顺序的问题,我都先写一个几十行的小脚本做“单位向量测试”。
把三个基向量(1,0,0)、(0,1,0)、(0,0,1)分别代入当前变换,观察它们旋转后的落点:
- 如果物体朝 X 方向运动的点,变换后落到了 Y 方向,说明确实发生了预期中的 90° 偏转;
- 如果落点完全不在预期位置,立刻检查左右乘顺序;
- 如果落点是反向的,则检查坐标系是左手系还是右手系。
这个方法不依赖任何高深理论,而且能快速定位问题发生在哪一层。我遇到过不少看起来像“算法写错了”的情况,最后验完发现只是某个库内部做了转置,本质还是左右乘换了个方向。
坐标系下的左乘右乘,说穿了就是两个问题:你的旋转是相对哪个参考系描述的,以及你的坐标系方向约定是什么。把这两件事写清楚,再配合单位向量测试验证,基本不会再在这个问题上熬到凌晨。