You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

如何正确计算两个四元数的中间插值四元数?

如何正确计算两个四元数的中间插值四元数?

看起来你在四元数插值上绕了不少弯路,我来给你把思路理清楚,一步步说清楚正确的做法~

首先得明确:我们用单位四元数表示旋转,所以所有插值操作都要围绕“保持旋转的平滑性、路径合理性”来做,先拆解你之前的尝试,再给你正确的实现:

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都是单位四元数,如果你的四元数是从其他地方来的,先做归一化再插值,否则结果会出错~

火山引擎 最新活动