1. 为什么旋转偏偏要用四元数
1.1 欧拉角的万向节锁,一个绕不开的坑
在 WebGL 里让物体旋转,最先想到的方案往往是欧拉角。欧拉角把三维旋转拆成绕 X、Y、Z 三个轴的三个角度,直观、好调试,比如“先绕 X 转 30 度,再绕 Y 转 45 度”这种表述,写起代码来也简单。但很多人第一次碰到万向节锁(Gimbal Lock)时,都会觉得无比诡异:明明在绕着 X 轴转角度,转着转着,物体突然像卡住一样,后续的旋转全变成了同一个方向的翻转。
万向节锁的根源不在于角度“太大”或者“操作失误”,而是欧拉角这种用三个独立角度描述姿态的方式,本身存在自由度退化。当中间那个轴旋转到 ±90 度时,另外两个旋转轴会重合在一起,三个自由度瞬间变成两个自由度。这时候无论你调整剩下两个角度中的哪一个,效果都只是在同一个平面上旋转。就像把一个手机云台的三个旋转环转到极限位置,会发现外环和内环的运动变成了同一个动作。
欧拉角在 WebGL 里面其实还有一个更隐蔽的问题:不同规范下的旋转顺序。先绕 Z 转还是先绕 X 转,得到的结果完全不同,而且各引擎默认的旋转顺序还不一样。你的动画逻辑一旦对旋转顺序产生依赖,代码就会变得又脆又难维护。我见过不止一个项目,最后的旋转代码变成了一堆 else if 判断,全是在处理“某个轴快到 90 度时的特殊情况”,这就是欧拉角带来的典型后遗症。
1.2 旋转矩阵:直观,但代价不小
用 3x3 旋转矩阵处理旋转,是图形学教材里的标准姿势。矩阵方案最大的优点是道理直接:顶点坐标乘上一个矩阵,就是旋转后的坐标。而且在 WebGL 里,矩阵还能和模型、视图、投影矩阵直接相乘,拼出一条完整的渲染管线,非常省事。
但矩阵方案有两个实在的痛点。一个是插值困难:你很难对两个旋转矩阵里的 9 个浮点数做线性插值,因为插值之后得到的矩阵大概率不再正交,会让物体发生拉伸或者错切变形。另一个是空间浪费:一个旋转只有 3 个自由度,用 9 个数表达明显冗余。写代码时还能通过四舍五入、正交化来修复误差,但只要有一次忘记归一化,累计误差就会让模型越转越“歪”。
四元数正是冲着这两个痛点去的。一个单位四元数只有 4 个分量,表达一个旋转刚好,没有冗余空间,而旋转叠加用一次乘法就能完成。更关键的是,四元数天然支持光滑插值,这就是后面要说的 slerp。这些特点让四元数成了图形学、游戏引擎、物理仿真里表示旋转的事实标准。
1.3 先泼一盆冷水:不是所有旋转都要用四元数
四元数虽好,但也别无脑乱用。如果物体只是绕一个固定轴旋转,比如风扇叶片、传送带上的方块、围绕 Y 轴转的广告牌,那直接用欧拉角或者简单的旋转矩阵就够了,四元数在这种情况下属于杀鸡用牛刀。
我自己的判断标准很简单:如果旋转只发生在一个平面上,或者旋转角度始终很小,直接用欧拉角;一旦涉及自由视角、相机环视、角色关节动画、模型任意朝向这种“需要连续叠加旋转且要求平滑过渡”的场景,就踏踏实实上四元数。老实说,大部分 WebGL 项目里最值得上四元数的,其实不是模型旋转本身,而是相机控制。相机朝向一变,后面所有投影计算全都跟着变,这里用四元数能省掉很多烦心事。
2. 四元数的核心直觉与数学基础
2.1 从复数到超复数:旋转是怎么跑到四元数里的
很多人第一次接触四元数时,都会被那一堆代数规则吓到。其实它的出发点很好理解。复数里有一个经典性质:乘上一个单位复数 e^{iθ},相当于在二维平面上把向量逆时针旋转 θ 度。那把这个思路推广到三维,是不是也能找一个“数”,乘一下就完成绕轴旋转?
四元数就是这种推广。一个四元数有四个分量,通常写作 q = w + xi + yj + zk,其中 i、j、k 是三个虚数单位,它们之间的乘法规则是 i² = j² = k² = ijk = -1。这套规则本身很像复数,但多出来的两个虚部让它可以编码三维空间里的方向和旋转。
但注意,三维旋转不能像二维复数那样简单做一次乘法。原因是三维旋转的“转轴”本身也在三维空间里,和向量之间没有天然的交换性。正确的旋转公式是 p' = q * p * q⁻¹,也就是先把向量 p 变成一个纯四元数(实部为 0),然后左乘 q,再右乘 q 的逆。这个“前后夹击”的形式,是理解四元数旋转一切特性的钥匙。
2.2 轴角与半角:为什么公式里是 θ/2
绕着某个单位轴 u 旋转 θ 角度,可以写成一个轴角表示的元组 (u, θ)。把它转换成四元数,公式是:
q = (cos(θ/2), sin(θ/2) * u)
注意这里用的是半角,而不是 θ。不少初学者在这里犯迷糊:明明想转 θ 度,为什么存的是半角?
原因就藏在 p' = q * p * q⁻¹ 这个双面乘法里。把 q 写成轴角形式后,这个式子实际上做了两次半角旋转:第一次由 q 完成 θ/2,第二次由 q⁻¹ 再完成 θ/2,合起来才是完整的 θ。设计成半角不是故弄玄虚,而是为了让单位四元数能够正确覆盖所有三维旋转。这一步想通之后,四元数旋转公式就不再是死记硬背的黑魔法了。
这里顺带说一个容易踩的坑:如果你直接把 θ 塞进四元数而不是半角,旋转会发现转得比自己预期快一倍,而且旋转轴方向也经常是反的,排查起来特别容易绕远路。
2.3 单位四元数与共轭、归一化
四元数可以表达缩放、旋转、甚至更复杂的变换,但 WebGL 里做旋转时,必须保证它是单位四元数,也就是模长为 1。为什么要强调这点?因为旋转不应该改变物体的尺寸。一旦四元数的模长偏离 1,物体就会出现均匀缩放,在动画里表现为越转越大或越转越小。
模长要是漂移了,就做归一化:q_normalized = q / ||q||。在连续旋转的场景里,这一步几乎每帧都该做一次。原因后面会在调试部分展开:四元数的乘法在浮点运算下会产生轻微误差,日积月累就会越偏越多。
和归一化密切相关的还有共轭。四元数的共轭是把虚部变号:q* = (w, -x, -y, -z)。单位四元数的逆正好等于它的共轭,所以 q⁻¹ = q*。这意味着在 p' = q * p * q⁻¹ 里,右边那一下乘法可以写成 q*,省一次求逆计算。理解这一点,很多手写四元数库里的奇怪代码就都能看懂了。
3. 原生 WebGL 四元数旋转小实例
3.1 搭建最小渲染管线的完整步骤
标题是“小实例”,市面上很多教程会用 Three.js 直接调 API,但那样反而看不清楚四元数到底参与了什么。我这里直接用一个原生 WebGL 例子来演示,把一个立方体放在 NDC 坐标空间里,代码尽量短,但四元数相关的东西一样不藏。
第一步,创建画布并获取 WebGL 上下文:
const canvas = document.getElementById('glCanvas'); const gl = canvas.getContext('webgl'); if (!gl) { throw new Error('WebGL not supported'); }第二步,编译着色器。顶点着色器里,我们用 uniform 传入一个模型矩阵,让顶点位置和它相乘:
attribute vec3 aPosition; uniform mat4 uModelMatrix; void main() { gl_Position = uModelMatrix * vec4(aPosition, 1.0); }片元着色器随便给个颜色就行:
precision mediump float; void main() { gl_FragColor = vec4(0.6, 0.4, 0.8, 1.0); }第三步,建立顶点缓冲区。立方体有 8 个顶点、6 个面,我用三角形索引方式来绘制,避免重复定义顶点。
const positions = new Float32Array([ -1, -1, -1, 1, -1, -1, 1, 1, -1, -1, 1, -1, -1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, 1, ]); const indices = new Uint16Array([ 0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 2, 3, 7, 2, 7, 6, 1, 2, 6, 1, 6, 5, 0, 3, 7, 0, 7, 4, ]);到这里,一个最基础的可绘制管线就搭好了。这里没有模型、视图、投影矩阵的完整链条,只保留一个模型矩阵,因为四元数转换成矩阵之后,就直接填进这个 uModelMatrix 里。
3.2 JS 端四元数运算核心代码
四元数的数学运算,我需要手写一小段工具代码。不要引入任何第三方库,自己写一遍反而能加深理解。
最核心的是“轴角”和“四元数”之间的转换:
function quatFromAxisAngle(out, axis, angle) { const half = angle * 0.5; const s = Math.sin(half); out[0] = axis[0] * s; out[1] = axis[1] * s; out[2] = axis[2] * s; out[3] = Math.cos(half); return out; }然后是四元数乘法。这里要特别注意顺序,四元数乘法不满足交换律,A * B 不等于 B * A:
function quatMultiply(out, a, b) { const [ax, ay, az, aw] = a; const [bx, by, bz, bw] = b; out[0] = aw * bx + ax * bw + ay * bz - az * by; out[1] = aw * by + ay * bw + az * bx - ax * bz; out[2] = aw * bz + az * bw + ax * by - ay * bx; out[3] = aw * bw - ax * bx - ay * by - az * bz; return out; }这段代码看着繁琐,但背后的规律是:虚部取叉积加实部对应项,实部取点积消去项。用一次旋转叠加的例子来理解,就是“先按照 a 旋转,再按照 b 旋转”等价于 a * b。前面说了单位四元数求逆就是取共轭,所以还缺一个共轭函数:
function quatConjugate(out, a) { out[0] = -a[0]; out[1] = -a[1]; out[2] = -a[2]; out[3] = a[3]; return out; }3.3 从四元数到旋转矩阵,注入渲染管线
四元数本身不能直接被顶点着色器使用,WebGL 的 uniform 矩阵只认 GLSL 里的 mat4。所以在每帧渲染前,要把四元数转换成旋转矩阵。公式业界统一,是标准写法:
function quatToMatrix4(out, q) { const [x, y, z, w] = q; const x2 = x + x; const y2 = y + y; const z2 = z + z; const xx = x * x2; const xy = x * y2; const xz = x * z2; const yy = y * y2; const yz = y * z2; const zz = z * z2; const wx = w * x2; const wy = w * y2; const wz = w * z2; out[0] = 1 - (yy + zz); out[1] = xy + wz; out[2] = xz - wy; out[3] = 0; out[4] = xy - wz; out[5] = 1 - (xx + zz); out[6] = yz + wx; out[7] = 0; out[8] = xz + wy; out[9] = yz - wx; out[10] = 1 - (xx + yy); out[11] = 0; out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1; return out; }矩阵里第 12、13、14 个元素对应的是平移,这里我们先不做平移,所以保持 0,最后一个矩阵元素设为 1,保证矩阵是齐次坐标形式,能和 gl_Position 里的 vec4 正确相乘。
有了上面这些工具函数,渲染循环就可以写得很干净:
const currentQuat = [0, 0, 0, 1]; // 初始为单位四元数,实部为1 const axis = [0, 1, 0]; const anglePerFrame = 0.01; const deltaQuat = quatFromAxisAngle([0, 0, 0, 0], axis, anglePerFrame); function render() { const modelMatrix = new Float32Array(16); quatMultiply(currentQuat, currentQuat, deltaQuat); quatToMatrix4(modelMatrix, currentQuat); gl.uniformMatrix4fv(modelMatrixLocation, false, modelMatrix); gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0); requestAnimationFrame(render); } render();这里有一个隐藏的坑:每次调用 quatMultiply,传进去的第一个参数 out 和第二个参数 a 是同一个数组时,选取的实现必须保证原地操作不会把数据搞坏。上面这段写法里,我把 currentQuat 同时作为输出和第一个输入,如果你的四元数乘法不是原位置安全的,就需要额外复制一次。
3.4 加入鼠标控制,体验自由旋转
定时绕着固定轴转,只能证明四元数“转得动”。真正体现四元数价值的是自由控制,比如鼠标拖动来旋转物体。做法是在 mousedown 到 mousemove 之间,把屏幕上的位移差转换成一根旋转轴和一个旋转角度。
比如鼠标在画布上水平移动 dx 像素、垂直移动 dy 像素,可以定义旋转轴为 (dy, dx, 0) 方向,旋转角度为速度乘上某个比例系数。这里不用严格按物理意义来,视觉上顺手就行:
let isDragging = false; let lastX = 0, lastY = 0; canvas.addEventListener('mousedown', (e) => { isDragging = true; lastX = e.clientX; lastY = e.clientY; }); canvas.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - lastX; const dy = e.clientY - lastY; lastX = e.clientX; lastY = e.clientY; const speed = 0.005; const axis = [dy, dx, 0]; const len = Math.hypot(dy, dx); if (len === 0) return; axis[0] /= len; axis[1] /= len; const deltaQuat = quatFromAxisAngle([0, 0, 0, 0], axis, len * speed); quatMultiply(currentQuat, currentQuat, deltaQuat); });这套做法里,currentQuat 始终保存物体当前朝向,每一次鼠标移动产生一个微小旋转 deltaQuat,然后累积到当前朝向上。由于四元数乘法天然会把旋转合成起来,整个操作非常平滑。
有人会问,为什么不用欧拉角记录水平旋转量和竖直旋转量?因为一旦物体自身已经转过一个姿势,再用“屏幕水平=世界X轴”方式叠加欧拉角,就会出现熟悉的偏航、俯仰混乱:鼠标往左边滑,物体却往斜上方翻。四元数累积方式则从根本上回避了这个问题。
4. 调试实录:四元数旋转的常见问题
4.1 旋转方向反了,先别急着改代码
四元数旋转方向相反,是出现概率最高的问题。现象是:给定绕 Y 轴正方向旋转 90 度,结果物体往反方向转了,或者从某一侧看时转到背面去了。
这里有个关键点:旋转方向的判定依赖观察方向和坐标系习惯。比如在右手坐标系里,绕正 Y 轴正方向旋转,是从 Y 轴正方向往原点看,逆时针方向为正。但 WebGL 的右手坐标系里,如果相机从 Z 轴正方向看向原点,那么绕 Y 轴正方向转,视觉上却是顺时针。
遇到“方向反了”,我的调试顺序是固定的:第一步,确认旋转轴是不是单位向量,模长不为 1 时,旋转角度和方向都会被放大或扭曲;第二步,检查观察方向和自己定义的“正方向”是否一致;第三步,才轮代码逻辑。如果确认轴、观察方向都没问题,再把 axis 取反或者把矩阵做一次转置就行。不要一上来就到处加负号,越改越乱。
4.2 物体越转越大,问题出在模长漂移
另一个高频现象是物体转着转着,体型慢慢变大,或者越来越小。初学者第一反应是去找缩放矩阵,但真正原因大概率是四元数失去了单位化约束。
在 3.4 节的交互代码里,每次鼠标移动都会做一次 quatMultiply,浮点乘法带来的误差会累积。一旦模长变成 1.001,四元数转换成矩阵后就会带一个 1.001 倍的均匀缩放。虽然单帧差别肉眼不可见,但等模长漂到 1.1 的时候,模型的尺寸放大就很明显了。
解决办法非常朴素:每帧渲染前或者每次乘法后做一次归一化。四元数归一化公式很简单,就是整个四元数除以它的模长:
function quatNormalize(out, q) { const len = Math.hypot(q[0], q[1], q[2], q[3]); if (len === 0) { out[0] = 0; out[1] = 0; out[2] = 0; out[3] = 1; } else { out[0] = q[0] / len; out[1] = q[1] / len; out[2] = q[2] / len; out[3] = q[3] / len; } return out; }实测下来,在交互旋转这种高频累加场景里,每次鼠标停止拖动之后做一次归一化就够了;但你要是做连续自动旋转,最好每帧都执行一次,稳得很。
4.3 旋转顺序搞反:局部和世界的加减分项
用四元数做累积旋转时,乘法顺序错了,会看到物体绕的轴完全不对。比如你想让立方体绕自身的局部轴旋转,但代码写成了 worldRotation * localRotation,结果就是立方体绕世界轴转,视觉上仿佛被一根隐形的棍子撬着翻。
区分局部旋转和世界旋转,关键看四元数乘法放在等号哪一侧。设当前朝向为 q,新增的增量旋转为 deltaQ:
- 绕自身局部轴旋转:q = q * deltaQ;
- 绕世界固定轴旋转:q = deltaQ * q。
这个规律也可以用左手定则去套:四元数乘法越靠左边,就相当于旋转在更“外层”的世界空间生效;越靠右边,旋转在更“内层”的局部空间生效。在 4.2 的鼠标控制例子里,我使用的是 q = q * deltaQ,也就是让物体绕自身局部轴旋转,这样鼠标上下滑动时,物体会像握住模型本身那样翻转,非常自然。
想验证代码顺序是否正确,可以做一个简单测试:让 deltaQ 绕 X 转 90 度,再让 deltaQ 绕 Y 转 90 度,交换两者乘法顺序分别截图对比。如果结果相同,说明代码实现有问题;如果结果不同且符合预期,说明顺序逻辑是对的。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 旋转方向相反 | 轴方向定义相反、观察方向不同、矩阵转置 | 先用固定轴转 90 度验证方向,再逐项检查 |
| 物体越转越大/越小 | 四元数模长漂移 | 乘法后归一化,渲染前再归一化 |
| 绕错轴旋转 | 四元数乘法顺序错误 | 检查 q = q * deltaQ 还是 q = deltaQ * q |
| 旋转速度是预期的两倍 | 轴角转换时用了 θ 而不是 θ/2 | 检查 quatFromAxisAngle 里的 half 变量 |
| 旋转变得不均匀、抖动 | 轴向量未单位化 | 把 axis 归一化后再构造 deltaQ |
| 旋转矩阵出现透视拉伸 | 矩阵元素排布错误 | 对照公式逐项检查 out[0] 到 out[15] |
| 插值路径转远路 | 直接用线性插值处理四元数 | 改用 Slerp,至少也做归一化线性插值 |
这张表是我在实际项目里慢慢攒出来的,几乎每一条都是真实踩过坑后才写上去的。新手照着表排查,能省下不少无头苍蝇式的试错时间。
5. 连续旋转与动画插值的扩展玩法
5.1 增量旋转法 vs 绝对目标旋转法
刚才的小实例用的是增量旋转法:每帧产生一个很小的增量四元数,累积到当前朝向上。这种做法的好处是简单、省心,适合交互控制;坏处是它没有一个明确的“目标姿态”,如果你想定时让物体转到一个固定角度,还得额外记录累计角度。
另一种常见做法是绝对目标旋转法:指定一个起始四元数和终止四元数,然后在动画帧之间做插值。这里的核心工具是 slerp,球面线性插值。它的公式本质是在四元数球面上沿大圆路径走:
function quatSlerp(out, a, b, t) { let dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; if (dot < 0) { dot = -dot; b = [-b[0], -b[1], -b[2], -b[3]]; } let scale0 = 1 - t; let scale1 = t; if (1 - dot > 1e-6) { const angle = Math.acos(dot); const sinAngle = Math.sin(angle); scale0 = Math.sin((1 - t) * angle) / sinAngle; scale1 = Math.sin(t * angle) / sinAngle; } out[0] = scale0 * a[0] + scale1 * b[0]; out[1] = scale0 * a[1] + scale1 * b[1]; out[2] = scale0 * a[2] + scale1 * b[2]; out[3] = scale0 * a[3] + scale1 * b[3]; return out; }这里注意,当 a 和 b 的夹角接近 180 度时,sinAngle 接近 0,直接做除会出问题,所以上面代码里用了一个阈值判断,接近时退化成普通线性插值。还有,当 dot 为负时,两个四元数表示朝向相同但方向相反,逻辑上应该取反处理,避免插值绕一个大圈。
5.2 一个更省事的低配方案:归一化线性插值
完整 Slerp 写起来不难,但有些时候我只想要一个快速效果,不需要严格等角速度。还有一个工程上非常常见的技巧:先做普通线性插值,再归一化,也就是 nlerp。
function quatNlerp(out, a, b, t) { out[0] = a[0] + (b[0] - a[0]) * t; out[1] = a[1] + (b[1] - a[1]) * t; out[2] = a[2] + (b[2] - a[2]) * t; out[3] = a[3] + (b[3] - a[3]) * t; quatNormalize(out, out); return out; }nlerp 实现极其简单,视觉上在角度差较小的时候和 Slerp 几乎没区别,只有角度差很大时,它的角速度会略有不均,中间会稍微快一点。对博客、演示、大多数 UI 动画来说完全够用。很多游戏引擎在“非关键动画路径”上也在用 nlerp。
5.3 四元数在日常 WebGL 工作流里的位置
写到最后,想聊聊四元数在真实工作流里到底扮演什么角色。如果你用 Three.js 写项目,日常很少直接操作四元数,因为 Object3D 的 rotation 属性被封装成了很友好的欧拉角接口,内部再自动转成四元数。但这种封装会带来一种错觉:好像只要会调 API,旋转问题就解决了。
可一旦遇到相机不受控、手写骨骼动画、模型导入后朝向不对、物理引擎反馈旋转等场景,欧拉角封装就会露馅。真到了那一刻,四元数乘法顺序、单位化、Slerp 这些基本功就直接决定你能不能排查下去。
从“会用”到“理解原理”,四级台阶可以这样分:第一级,知道有四元数这个东西,能调用库函数;第二级,理解 q = (cos(θ/2), sin(θ/2) * axis) 里的半角;第三级,能自己写出乘法、矩阵转换、Slerp;第四级,能在调试时不看文档就判断是乘法顺序还是归一化问题。这个小实例做完,基本就站在第二级往第三级过渡的位置上了。
6. 从实例出发的个人体会
第一次把四元数旋转小实例在原生 WebGL 里跑通时,我印象最深的一刻不是看到立方体旋转,而是发现用鼠标拖动模型时,不管怎么转都不会卡壳。那种流畅感,和之前用欧拉角写自由视角时频繁处理万向节锁的狼狈,形成了鲜明的对比。
我自己后来做具体项目时,反而很少手写四元数数学库,基本都是用成熟的三方库。但每当调旋转相关 bug,判断“是不是乘法顺序错了”“是不是该归一化了”,靠的都是当时手动实现四元数时留下的直觉。所以我建议读到这里的读者,即使你的项目已经引了完善的引擎,也抽空自己从头写一遍四元数到矩阵的转换,认真跑一遍这个旋转实例。只有亲手把数学公式变成屏幕上转起来的物体,那些抽象的分量、共轭、半角,才会真正长在自己的脑子里。
如果还想继续往下扩展,可以试试:给这个实例加一个 Slerp 动画,让立方体从当前姿态平滑过渡到某个指定姿态;或者把四元数旋转应用到相机上,做一个能自由围绕模型旋转的视角控制;再进阶一点,可以做成鼠标交互的模型编辑器里那种旋转球控制器。四元数这个主题一旦吃透,后面所有和旋转相关的 OpenGL、WebGL、游戏引擎代码,读起来都会顺畅得多。