如何正确计算两个四元数的中间插值四元数?
如何正确计算两个四元数的中间插值四元数?
看起来你在四元数插值上绕了不少弯路,我来给你把思路理清楚,一步步说清楚正确的做法~
首先得明确:我们用单位四元数表示旋转,所以所有插值操作都要围绕“保持旋转的平滑性、路径合理性”来做,先拆解你之前的尝试,再给你正确的实现:
1. 你一开始的线性插值(LERP)问题出在哪?
你直接对四元数的四个分量做线性插值:
q3 = { q1[0] + (q2[0] - q1[0])*ratio, q1[1] + (q2[1] - q1[1])*ratio, q1[2] + (q2[2] - q1[2])*ratio, q1[3] + (q2[3] - q1[3])*ratio, };
这其实是四元数的LERP(线性插值),但有两个致命问题:
- 插值后的四元数不是单位四元数,必须手动归一化才能用来表示旋转
- 插值路径是3D空间中的直线,对应的旋转速度是不均匀的(一开始快,后来慢,或者反过来),视觉上会有卡顿感
只有当两个四元数表示的旋转夹角非常小的时候,LERP归一化后的效果才接近SLERP,否则不推荐直接用。
2. 你的SLERP代码接近正确,但缺了关键的一步!
你找的SLERP代码逻辑是对的,但没有处理“负四元数”的情况——因为四元数q和-q表示的是同一个旋转,但如果直接用它们做SLERP,可能会走最长的那个旋转弧(绕半圈以上),而不是最短的路径。
修正后的SLERP代码应该加上“点积为负时取反其中一个四元数”的处理:
#include <cmath> #include <array> using vec4 = std::array<float, 4>; vec4 quaternionSlerp(vec4 q1, vec4 q2, float t) { float cosHalfTheta = q1[3]*q2[3] + q1[0]*q2[0] + q1[1]*q2[1] + q1[2]*q2[2]; // 处理q和-q的情况:如果点积为负,取反q2,保证插值走最短弧 if (cosHalfTheta < 0.0F) { q2 = {-q2[0], -q2[1], -q2[2], -q2[3]}; cosHalfTheta = -cosHalfTheta; } // 如果两个四元数几乎相同,直接返回q1 if (std::fabs(cosHalfTheta) >= 1.0F) { return q1; } float halfTheta = std::acos(cosHalfTheta); float sinHalfTheta = std::sqrt(1.0F - cosHalfTheta*cosHalfTheta); // 如果夹角接近180度,用LERP近似(避免除以0) if (std::fabs(sinHalfTheta) < 1e-4F) { return { q1[0]*(1-t) + q2[0]*t, q1[1]*(1-t) + q2[1]*t, q1[2]*(1-t) + q2[2]*t, q1[3]*(1-t) + q2[3]*t }; } float ratioA = std::sin((1.0F - t) * halfTheta) / sinHalfTheta; float ratioB = std::sin(t * halfTheta) / sinHalfTheta; return { q1[0]*ratioA + q2[0]*ratioB, q1[1]*ratioA + q2[1]*ratioB, q1[2]*ratioA + q2[2]*ratioB, q1[3]*ratioA + q2[3]*ratioB }; }
这个修正后的SLERP就是正确的球面插值实现,它会沿着旋转的最短球面弧插值,保证旋转速度均匀,是动画中最常用的四元数插值方法。
3. 你尝试的“四元数乘法插值”思路是对的,但实现错了
你想通过q3 = q1 * (q1逆 * q2)^ratio来计算插值,这个数学逻辑是完全正确的:
- 先计算从q1到q2的旋转增量:
delta = q1^{-1} * q2(q1的逆就是它的共轭,因为q1是单位四元数) - 然后把这个增量旋转的角度乘以ratio,得到
delta^ratio - 最后用q1乘以这个缩放后的增量,得到插值结果
但你的实现里有两个错误:计算delta的顺序搞反了,且没有正确对增量四元数做“角度缩放”。给你这个思路的正确实现:
// 计算单位四元数的共轭(逆,因为单位四元数的逆等于共轭) vec4 quaternionConjugate(vec4 q) { return {-q[0], -q[1], -q[2], q[3]}; } // 四元数乘法 vec4 multiplyQuaternions(vec4 q1, vec4 q2) { float w1 = q1[3], x1 = q1[0], y1 = q1[1], z1 = q1[2]; float w2 = q2[3], x2 = q2[0], y2 = q2[1], z2 = q2[2]; return { w1*x2 + x1*w2 + y1*z2 - z1*y2, // X w1*y2 - x1*z2 + y1*w2 + z1*x2, // Y w1*z2 + x1*y2 - y1*x2 + z1*w2, // Z w1*w2 - x1*x2 - y1*y2 - z1*z2 // W }; } // 将四元数转成轴角形式(轴是x,y,z,角度是theta) void quaternionToAxisAngle(vec4 q, float& x, float& y, float& z, float& theta) { theta = 2 * std::acos(q[3]); float s = std::sqrt(1.0F - q[3]*q[3]); if (s < 1e-4F) { // 角度接近0,轴任意 x = 1; y = 0; z = 0; } else { x = q[0]/s; y = q[1]/s; z = q[2]/s; } } // 将轴角转成四元数 vec4 axisAngleToQuaternion(float x, float y, float z, float theta) { float halfTheta = theta * 0.5F; float s = std::sin(halfTheta); return {x*s, y*s, z*s, std::cos(halfTheta)}; } // 用增量旋转的方式计算插值 vec4 quaternionDeltaInterp(vec4 q1, vec4 q2, float ratio) { // 计算delta = q1^{-1} * q2 (从q1到q2的旋转增量) vec4 q1Conj = quaternionConjugate(q1); vec4 delta = multiplyQuaternions(q1Conj, q2); // 将delta转成轴角,缩放角度 float ax, ay, az, theta; quaternionToAxisAngle(delta, ax, ay, az, theta); vec4 scaledDelta = axisAngleToQuaternion(ax, ay, az, theta * ratio); // 插值结果 = q1 * scaledDelta (先做q1旋转,再做缩放后的delta旋转) return multiplyQuaternions(q1, scaledDelta); }
这个方法和SLERP的数学结果是完全一致的,只是实现思路不同,本质都是基于球面的最短路径插值。
4. 该选哪种插值方法?
- SLERP:优先选它!旋转速度完全均匀,视觉效果最平滑,适合动画、相机控制等需要流畅过渡的场景,唯一的缺点是计算量比LERP大一点(有三角函数)。
- NLERP(归一化LERP):如果对性能要求极高,且两个旋转的夹角不大,可以用LERP之后归一化,计算快,效果接近SLERP:
vec4 quaternionNlerp(vec4 q1, vec4 q2, float t) { // 先处理负四元数 if (q1[3]*q2[3] + q1[0]*q2[0] + q1[1]*q2[1] + q1[2]*q2[2] < 0) { q2 = {-q2[0], -q2[1], -q2[2], -q2[3]}; } // 线性插值 vec4 res = { q1[0]*(1-t) + q2[0]*t, q1[1]*(1-t) + q2[1]*t, q1[2]*(1-t) + q2[2]*t, q1[3]*(1-t) + q2[3]*t }; // 归一化 float norm = std::sqrt(res[0]*res[0] + res[1]*res[1] + res[2]*res[2] + res[3]*res[3]); return {res[0]/norm, res[1]/norm, res[2]/norm, res[3]/norm}; } - 直接LERP:绝对不要直接用,除非你接受旋转速度不均匀的问题,而且必须归一化。
最后提醒一下:所有插值操作的前提是q1和q2都是单位四元数,如果你的四元数是从其他地方来的,先做归一化再插值,否则结果会出错~




