从零实现轨迹球旋转:四元数原理与工程实践
发布时间:2026/10/28 5:35:21 锦皓数字建站

鼠标按住模型拖一下它就该跟着转——这是所有三维查看器的基本预期。但真动手写的时候很多人会卡在同一个地方鼠标只动了二维的一小段距离模型却要绕着一个看不见的球面转过去X轴和Y轴的旋转量怎么分配为什么我直接拿deltaX当绕Y轴角度、deltaY当绕X轴角度转着转着模型就翻车了姿态越拧越歪甚至出现万向节那种诡异的自旋轨迹球Arcball这套东西说白了就是把屏幕上的鼠标位置映射到一个虚拟球面上用两个球面点之间的旋转来驱动模型而不是简单地把像素位移塞进欧拉角。这篇就把我从零撸一个轨迹球旋转的完整过程摊开讲四元数怎么算、球面点怎么反投影、为什么必须做归一化和半球处理、拖到球外面该怎么办、以及那些文档里不会写但你一定会踩的坑。适合已经会用 OpenGL 画三角形、想给模型加交互但被旋转逻辑绕晕的开发者也适合想搞清楚为什么是四元数不是欧拉角的读者。1. 先把拖拽旋转这件事的物理直觉建立起来在写任何一行矩阵代码之前我建议先把这件事在脑子里过一遍。你面前有一个透明的球模型被固定在这个球的球心上球心正对屏幕。鼠标按下时你其实是在球面上抓住了一个点鼠标移动时你把这个点沿着球面拖到新位置模型跟着这个球一起转转轴就是旧点叉乘新点得到的那个方向转角就是两点之间的球面夹角。这就是轨迹球的全部几何本质剩下的都是工程实现。1.1 为什么不能直接把像素位移当角度新手最容易写出来的版本是这样的angleY deltaX * 0.01; angleX deltaY * 0.01;然后每帧用这两个角度拼一个旋转矩阵。小幅度拖动时看着还行一旦连续拖几圈问题全暴露。原因在于欧拉角的旋转是不可交换的——先绕X转再绕Y转和先绕Y转再绕X转结果完全不同。你每帧都在当前姿态上叠加一个固定轴的小旋转累积误差会让模型的上方向慢慢漂移最后模型侧躺着转手感彻底崩掉。更隐蔽的问题是万向节死锁。当绕X轴转到接近90度时绕Y轴和绕Z轴的旋转轴会重合你继续拖鼠标模型可能突然绕着一个完全没预期的轴疯狂自旋。这不是代码写错了是欧拉角这套表示法本身的缺陷。轨迹球要的是任意轴旋转而欧拉角只擅长绕三个固定轴分别转两者天生不匹配。1.2 四元数在这里到底扮演什么角色四元数听起来吓人但在轨迹球这个场景里你只需要理解它的一种用法用一个单位四元数表示绕某个轴转某个角度。给定旋转轴(x, y, z)必须是单位向量和转角θ四元数是q (cos(θ/2), x·sin(θ/2), y·sin(θ/2), z·sin(θ/2))注意那个θ/2这是四元数的定义方式不是笔误。它带来的好处是两个旋转的复合直接就是四元数乘法不需要关心顺序带来的轴漂移而且插值平滑、不会死锁。轨迹球每次拖拽算出一个增量旋转四元数然后乘到模型当前姿态上姿态永远是一个合法的单位四元数怎么转都不会退化。我个人的经验是姿态存储用四元数传给着色器前再转成矩阵。存储阶段保持四元数可以避免反复矩阵正交化带来的数值漂移渲染阶段用矩阵是因为 GPU 算顶点变换更直接。这个分工在后面的代码里会体现得很清楚。2. 屏幕坐标到球面坐标反投影这一步决定了手感轨迹球的核心映射函数输入是鼠标在窗口里的像素坐标输出是虚拟球面上的一个三维单位向量。这一步的精度和边界处理直接决定了拖拽手感是跟手还是发飘。2.1 把像素坐标归一化到 [-1, 1]第一步永远是归一化。假设窗口宽W、高H鼠标位置(mx, my)注意很多窗口系统原点在左上角y 向下先映射到以窗口中心为原点、范围[-1, 1]的坐标系float x (2.0f * mx - W) / W; // 水平方向右为正 float y (H - 2.0f * my) / H; // 垂直方向上为正翻转y这里有个细节值得说y 轴一定要翻转。窗口坐标 y 向下增长而我们的三维世界通常 y 向上不翻转的话上下拖动方向是反的用户第一下就会觉得这软件坏了。另外归一化用W和H分别除而不是统一除以某个值这样映射出来的区域是正方形[-1,1]²和后面的球面半径对应得上。如果你的窗口不是正方形这一步保证了拖拽在横竖方向的手感一致。2.2 球内投影与球外投影的分界归一化之后计算点到原点的距离平方r2 x*x y*y。虚拟球的半径设为 1于是分两种情况r2 1点在球面投影范围内直接令z sqrt(1 - r2)得到球面上的点(x, y, z)。这是标准的球面反投影鼠标在球正面上滑动。r2 1鼠标拖到了球外面。这时候不能简单裁剪到边界否则拖到边缘会有明显的卡顿感。常见做法是把点投影到球面的裙边上——保持x, y不变令z 0然后归一化。这样球外区域映射到球的最大圆周上拖动依然连续。Vec3 mapToSphere(float x, float y) { float r2 x*x y*y; if (r2 1.0f) { return Vec3(x, y, sqrtf(1.0f - r2)); } else { float inv 1.0f / sqrtf(r2); return Vec3(x * inv, y * inv, 0.0f); } }提示球外处理还有一种双曲面方案让z取负值形成碗状延伸手感更平滑但实现稍复杂。初期用z0的裙边方案足够等手感调优阶段再考虑升级。2.3 为什么必须归一化以及不归一化的后果上面两个分支返回的向量长度都应该是 1。球内分支因为x²y²z²1天然成立球外分支做了显式归一化。这个单位长度是后面算旋转的前提因为旋转轴要用叉乘、转角要用点乘的反余弦两者都假设输入是单位向量。我见过有人图省事跳过归一化结果拖到球外时旋转速度突然变快或变慢因为叉乘结果的模长受向量长度影响转角计算也跟着失真。这种 bug 很隐蔽因为球内区域看起来完全正常只有拖出边界才暴露。3. 两个球面点之间旋转到底怎么算出来有了按下时的球面点p0和移动后的球面点p1接下来要构造一个四元数表示把p0转到p1这个旋转。这是整个轨迹球最核心的一步也是数学味最重的一步但拆开看并不复杂。3.1 旋转轴来自叉乘转角来自点乘两个单位向量之间的旋转轴垂直于它们构成的平面方向由右手定则决定正好是叉乘axis p0 × p1。转角θ是两向量夹角由点乘给出cos θ p0 · p1。于是Vec3 axis cross(p0, p1); float cosTheta dot(p0, p1); float angle acosf(clamp(cosTheta, -1.0f, 1.0f));clamp那一下不能省。浮点误差会让点乘结果偶尔超出[-1, 1]acos收到越界值返回 NaN四元数直接污染模型瞬间消失或者变成一团乱麻。这种 NaN 传播是三维编程里最恶心的 bug 之一因为它在画面上表现为什么都没了很难定位到是哪个acos出的问题。养成习惯所有反三角函数前都 clamp。3.2 从轴角到四元数的组装拿到轴和角按 1.2 节的公式组装四元数。但有个边界情况必须处理当p0和p1几乎重合时叉乘结果接近零向量归一化会炸。这时转角也接近零直接返回单位四元数(1,0,0,0)即可表示没有旋转。Quat rotationBetween(Vec3 p0, Vec3 p1) { Vec3 axis cross(p0, p1); float len length(axis); float cosTheta clamp(dot(p0, p1), -1.0f, 1.0f); if (len 1e-6f) { return Quat(1, 0, 0, 0); // 无旋转 } axis axis / len; float angle acosf(cosTheta); float half angle * 0.5f; float s sinf(half); return Quat(cosf(half), axis.x * s, axis.y * s, axis.z * s); }注意axis归一化用的是叉乘结果的模长len不是sin(angle)。虽然数学上两者相等但数值上len更直接、更稳尤其在角度接近 0 或 π 的时候。这个细节很多教程会忽略直接写axis / sin(angle)在极端角度下精度会掉。3.3 增量旋转怎么叠加到当前姿态算出来的这个四元数是从p0到p1的增量旋转它作用在世界坐标系里。要把它叠加到模型当前姿态q_current上顺序很关键q_current delta * q_current; // 左乘在世界空间旋转 // 而不是 q_current q_current * delta; // 右乘在模型局部空间旋转左乘还是右乘决定了旋转是绕着屏幕看过去的轴转还是绕着模型自己的轴转。轨迹球要的是前者——用户拖鼠标期望的是模型相对于观察者转动所以用左乘。这个顺序搞反了表现是模型转起来轴心不对像是绕着自己的某个内部轴在拧而不是跟着鼠标走。我当初在这卡了半天后来把两种乘法都试了一遍对比手感才确认。4. 姿态的存储、累积与渲染管线对接旋转算对了接下来是工程问题姿态存在哪、怎么累积、怎么送进渲染管线。这块看似琐碎但决定了长时间交互后姿态会不会烂掉。4.1 为什么姿态要用四元数存而不是矩阵一个常见做法是直接维护一个 4x4 旋转矩阵每次增量旋转就乘上去。短期没问题但连续拖拽几百次之后浮点误差累积会让矩阵逐渐失去正交性——列向量不再互相垂直、长度不再为 1。结果就是模型被轻微拉伸或剪切看起来软掉了。四元数只要每次乘法后归一化就能长期保持合法这是它作为姿态存储格式的核心优势。void normalizeQuat(Quat q) { float n sqrtf(q.w*q.w q.x*q.x q.y*q.y q.z*q.z); if (n 1e-8f) { q.w / n; q.x / n; q.y / n; q.z / n; } else { q Quat(1, 0, 0, 0); } }每次q_current delta * q_current之后调一次归一化成本极低收益是姿态永远干净。别嫌麻烦这一步省了迟早出问题。4.2 四元数转旋转矩阵喂给着色器渲染时把四元数转成 3x3或扩成 4x4旋转矩阵作为模型矩阵的一部分传给顶点着色器。转换公式是标准的网上到处有但我要提醒的是转置问题不同资料给的矩阵可能是行主序或列主序OpenGL 的glUniformMatrix4fv默认期望列主序传错了模型会转反或者出现镜像。我的做法是写完先用一个已知角度比如绕 Z 轴 90 度验证一下确认方向对了再往下做。Mat4 quatToMat4(const Quat q) { float xx q.x*q.x, yy q.y*q.y, zz q.z*q.z; float xy q.x*q.y, xz q.x*q.z, yz q.y*q.z; float wx q.w*q.x, wy q.w*q.y, wz q.w*q.z; Mat4 m; m[0][0] 1 - 2*(yy zz); m[0][1] 2*(xy - wz); m[0][2] 2*(xz wy); m[1][0] 2*(xy wz); m[1][1] 1 - 2*(xx zz); m[1][2] 2*(yz - wx); m[2][0] 2*(xz - wy); m[2][1] 2*(yz wx); m[2][2] 1 - 2*(xx yy); return m; }4.3 拖拽状态机的完整生命周期轨迹球不是鼠标一动就算旋转它有一个明确的状态机鼠标按下时记录p0并进入拖拽态移动时用当前点算p1、求增量、更新姿态、把p0更新为p1松开时退出拖拽态。这里最容易漏的是把p0更新为p1——如果不更新每次移动都是相对最初按下点算旋转模型会越转越快、越转越乱因为增量被重复叠加了。void onMouseDown(int mx, int my) { dragging true; p0 mapToSphere(normalizeX(mx), normalizeY(my)); } void onMouseMove(int mx, int my) { if (!dragging) return; Vec3 p1 mapToSphere(normalizeX(mx), normalizeY(my)); Quat delta rotationBetween(p0, p1); q_current delta * q_current; normalizeQuat(q_current); p0 p1; // 关键滚动基准点 } void onMouseUp() { dragging false; }这个滚动基准点的模式本质上是把连续拖拽切成一系列微小增量每个增量都小到可以用小角度近似累积起来就是平滑的大旋转。理解这一点后面调手感、加惯性都好办。5. 那些文档不写、但你一定会踩的坑前面是主干这一节是我实际做下来真正花时间的地方。轨迹球的数学部分其实半小时能写完剩下的时间全耗在这些边角问题上。5.1 拖到球外的手感突变用z0裙边方案时鼠标从球内滑到球外的那一刻球面点会从(x, y, sqrt(1-r2))跳到(x/r, y/r, 0)虽然位置连续但方向变化率不连续手感上会有一个轻微的顿挫。我的处理是给球外区域加一个平滑过渡当r2略大于 1 时用一个衰减函数把z从 0 平滑过渡到负值形成碗状。具体实现可以这样if (r2 1.0f) { return Vec3(x, y, sqrtf(1.0f - r2)); } else { // 碗状延伸z 随 r 增大而变负斜率可控 float r sqrtf(r2); float z -(r - 1.0f) * 0.5f; // 0.5 是手感系数可调 Vec3 v(x, y, z); return normalize(v); }这个0.5的系数就是纯手感调参调大碗更深、拖到外面转得更重调小更接近裙边方案。没有标准答案看你的应用场景。5.2 高 DPI 屏幕下的坐标错位在 Retina 或高 DPI 屏上窗口逻辑坐标和帧缓冲物理像素是两套值。如果你用物理像素算归一化但鼠标事件给的是逻辑坐标映射出来的球面点会整体偏移表现为鼠标在模型上旋转轴却偏到一边。解决办法是统一用逻辑坐标做归一化或者把鼠标坐标乘以 DPI 缩放因子再算。这个坑在跨平台时尤其常见因为不同窗口库对坐标的定义不一致。5.3 快速拖拽时的角度跳变鼠标移动事件是离散的快速甩动时相邻两帧的球面点可能隔得很远acos算出的角度接近 π叉乘轴的方向对微小误差极其敏感旋转轴会突然翻转模型啪地跳一下。缓解办法有两个一是对鼠标事件做插值把大步长拆成小步长二是限制单帧最大转角超过阈值就分几次应用。我一般用后者简单可靠float maxStep 0.3f; // 单帧最大转角弧度 if (angle maxStep) { // 拆成多步每步 maxStep int steps (int)ceilf(angle / maxStep); for (int i 0; i steps; i) { Quat d axisAngleToQuat(axis, angle / steps); q_current d * q_current; } normalizeQuat(q_current); }5.4 触摸板与鼠标的手感差异触摸板的拖拽往往是带惯性的连续事件流而鼠标是离散的。同一套参数在鼠标上跟手在触摸板上可能显得过于灵敏或迟滞。如果目标平台两者都要支持建议把旋转灵敏度做成可配置项甚至根据输入设备类型动态调整。这个不是数学问题是产品问题但实现上要预留这个口子。6. 从能转到好转灵敏度、惯性与边界调优功能跑通只是起点真正让轨迹球好用的是后面这些调优。这部分没有标准公式全靠试但我可以给你几个起步参数和调整方向。6.1 灵敏度系数的量级参考轨迹球的灵敏度本质上是球面点移动多少模型转多少。因为球面点已经是单位向量两点夹角天然就是旋转角所以理论上不需要额外灵敏度系数——转角和鼠标位移是几何对应的。但实际用起来很多人会觉得默认手感偏钝因为窗口归一化到[-1,1]后鼠标从中心拖到边缘才对应 90 度左右的旋转。想要更灵敏可以在归一化时把坐标乘以一个大于 1 的系数相当于缩小虚拟球半径或者直接在角度上乘系数。我一般从1.0起步根据用户反馈在0.8到1.5之间调。参数典型值作用调整方向归一化缩放1.0控制虚拟球覆盖范围调大更灵敏球外碗深系数0.5球外拖拽的重量感调大更沉单帧最大转角0.3 rad防跳变调小更稳但可能迟滞惯性衰减0.9/帧松手后滑行越接近 1 滑得越久6.2 松手惯性让旋转滑起来纯轨迹球松手即停手感偏硬。加一点惯性会让交互更顺滑记录松手瞬间的角速度之后每帧按衰减系数递减地继续应用旋转直到速度低于阈值。角速度可以从最近几帧的增量旋转里估算——把增量四元数转成轴角角度除以帧间隔就是角速度。注意惯性阶段同样要做单帧最大转角限制和归一化否则快速甩动后松手模型可能转飞。6.3 边界情况清单做交互功能边界情况永远比主流程多。我列一个自己维护的检查清单每次改完都过一遍鼠标在窗口外按下再移入p0是否有效窗口 resize 过程中拖拽归一化用的W/H是否实时更新连续快速点击按下-松开-按下状态机是否正确复位拖拽中切换焦点或最小化是否残留拖拽态极小窗口宽或高接近 0归一化是否除零这些看着琐碎但每一个都真实发生过。尤其是除零窗口最小化时尺寸可能变成 0(2*mx - W)/W直接产生 inf球面点变 NaN模型消失。加个max(W, 1)就能防住。7. 收尾几个我反复验证过的实现习惯写到这里轨迹球的主干和边角都覆盖了。最后分享几个我做了多个三维交互项目后固化下来的习惯都是踩坑换来的。第一姿态永远只存四元数矩阵是临时产物。任何需要矩阵的地方现转现用不要长期持有矩阵当状态。第二所有反三角、除法、归一化前都做防御clamp、判零、判 NaN三维代码里一个 NaN 能让你查一整天。第三先用一个已知旋转验证坐标系约定绕单轴转 90 度看方向对不对确认了再叠加复杂逻辑能省掉大量到底是数学错了还是约定错了的纠结。第四把灵敏度、碗深、惯性这些做成可调参数别硬编码调手感的时候你会感谢自己。轨迹球这套东西数学上优雅工程上琐碎。真正难的不是推导四元数而是把屏幕像素、球面几何、姿态累积、渲染管线这四层对齐任何一层约定错了表现都是转得不对劲这种模糊症状。我的建议是分层验证先验证球面映射打印几个点的坐标看是否在单位球上再验证单次旋转固定两个点看结果最后才接鼠标事件。一层一层确认比一次性写完再调试快得多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。