三维空间刚体运动4-2:四元数线性插值方法:Slerp(详解证明)


序:本篇系列文章参照高翔老师《视觉SLAM十四讲从理论到实践》,讲解三维空间刚体运动,为读者打下坚实的数学基础。博文将原第三讲分为五部分来讲解,其中四元数部分较多较复杂,又分为四部分。如果读者急于实践,可直接阅读第五部分的机器人运动轨迹,此部分详细讲解了安装准备工作。此系列总体目录如下:

  1. 旋转矩阵和变换矩阵
  2. 旋转向量表示旋转
  3. 欧拉角表示旋转
  4. 四元数包括以下部分:
    4-1. 四元数表示变换
    4-2. 四元数线性插值方法:LinEuler/LinMat/Lerp/Nlerp/Slerp
    4-3. 四元数多点插值方法:Squad
    4-4. 四元数多点连续解析解插值方法:Spicv
    4-5. 四元数多点离散数值解插值方法:Sping
  5. 实践:SLAM中显示机器人运动轨迹及相机位姿

摘要:四元数插值四个方法Slerp、Squad、Spicv和Sping既复杂又很重要,为了详细阐述,故每个方法独立成一篇博文讲解。没有插值的四元数是没有灵魂的,插值的重要性不言而喻。Slerp是经典的两点间一阶连续可导插值方法,Squad方法在Slerp的基础上实现多点间的一阶连续可导,Spicv是多点间连续解析解插值方法,而Sping则是多点间离散数值解插值方法,更适合复杂曲线,此博文也是国内首篇介绍Spicv和Sping的中文资料。

1. 幂函数形式Slerp插值方法的引出

有了上一篇的前置知识,我们就能开始讨论四元数的插值 (Interpolation) 了。与其它的旋转表示形式不同,四元数的一些性质让它的插值变得非常简单。另外,插值方法也可以用于解决欧拉角的万象锁问题,其原因可移步《欧拉角表示旋转》一文。

这里为了后续概念的引出,先简要讲解幂函数形式的Slerp插值方法,并由此延展出线性插值方法,Slerp的详细内容会在后边第五节讲解。
假设有两个旋转变换 q 0 = [ c o s ( θ 0 ) , s i n ( θ 0 ) u 0 ] q_{0}=[cos(θ_{0}), sin(θ_{0})u_{0}] q0=[cos(θ0),sin(θ0)u0] q 1 = [ c o s ( θ 1 ) , s i n ( θ 1 ) u 1 ] q_{1}=[cos(θ_{1}), sin(θ_{1})u_{1}] q1=[cos(θ1),sin(θ1)u1],我们希望找出一些中间变换 q t q_{t} qt,让初始变换 q 0 q_{0} q0能够平滑地过渡到最终变换 q 1 q_{1} q1 t t t的取值为 t ∈ [ 0 , 1 ] t ∈ [0, 1] t[0,1]。当 t = 0 t=0 t=0 q t q_{t} qt等同于初始变换 q 0 q_{0} q0,而 t = 1 t=1 t=1 q t q_{t} qt等同于最终变换 q 1 q_{1} q1
由于插值的对象是两个变换,不妨假设3D空间中任意一个向量 v v v,那么 q 0 q_{0} q0会将 v v v变换到 v 0 = q 0 v q 0 ∗ v_{0}=q_{0}vq^{*}_{0} v0=q0vq0,而 q 1 q_{1} q1会将 v v v变换到 v 1 = q 1 v q 1 ∗ v_{1}=q_{1}vq^{*}_{1} v1=q1vq1。我们需要找出中间向量 v t = q t v q t ∗ v_{t}=q_{t}vq^{*}_{t} vt=qtvqt所对应的变换 q t q_{t} qt,使 v v v旋转到 v 0 v_{0} v0 v 1 v_{1} v1中间的某个位置 v t v_{t} vt,如图:
在这里插入图片描述可以看到,这个旋转的变化量对应的仍是一个旋转。它将由 q 0 q_{0} q0变换到 v 0 v_{0} v0的向量进一步旋转到 v t v_{t} vt。这个旋转有某一固定的旋转轴 u t u_{t} ut,我们只需要缩放变换所对应的角度 ϕ \phi ϕ就能达到插值的目的。那么如何获得这个旋转的变化量呢?考虑变换 Δ q \Delta q Δq能将已经旋转到 v 0 v_{0} v0的向量𝑣直接变换到 v 1 v_{1} v1,这其实就是一个旋转的复合,即先进行 q 0 q_{0} q0变换,再进行 Δ q \Delta q Δq变换,它们复合的结果需要等于 q 1 q_{1} q1变换,也就是说: Δ q q 0 = q 1 \Delta qq_{0}=q_{1} Δqq0=q1那么有: Δ q = q 1 q 0 − 1 (1.1) \Delta q=q_{1}q^{-1}_{0}\tag{1.1} Δq=q1q01(1.1)因为所有的旋转 q q q都是单位四元数,故 q − 1 = q ∗ q^{-1} =q^{*} q1=q,如果能对 Δ q \Delta q Δq t t t次方, ( Δ q ) t (\Delta q)^{t} (Δq)t就能缩放这个旋转所对应的角度了。所以得出插值的公式: q t = S l e r p ( q 0 , q 1 ; t ) = ( q 1 q 0 ∗ ) t q 0 (1.2) q_{t}=Slerp(q_{0},q_{1};t)=(q_{1}q^{*}_{0})^{t}q_{0}\tag{1.2} qt=Slerp(q0,q1;t)=(q1q0)tq0(1.2)可以发现,当 t = 0 t=0 t=0时, q t = ( q 1 q 0 ∗ ) 0 q 0 = q 0 q_{t}=(q_{1}q^{*}_{0})^{0}q_{0}=q_{0} qt=(q1q0)0q0=q0;当 t = 1 t=1 t=1时, q t = ( q 1 q 0 ∗ ) 1 q 0 = q 1 q_{t}=(q_{1}q^{*}_{0})^{1}q_{0}=q_{1} qt=(q1q0)1q0=q1;而当 t t t为中间值,比如 t = 0.4 t=0.4 t=0.4时, q t = ( q 1 q 0 ∗ ) 0.4 q 0 q_{t}=(q_{1}q^{*}_{0})^{0.4}q_{0} qt=(q1q0)0.4q0,它会先进行 q 0 q_{0} q0变换将 v v v变换到 v 0 v_{0} v0,在此基础上再向 v 1 v_{1} v1旋转两者夹角的40%。
这个插值方法叫做幂函数形式的 S l e r p Slerp Slerp插值,虽然这个公式对四元数插值的分析很有帮助,也有一些很棒的性质,但它的计算不仅涉及到多个四元数的乘法,而且还包含幂运算,在实际应用中的效率很低,我们希望找出一个更高效的插值方法,它就是后面的三角函数形式。两种方式各有利弊,可在使用中根据实际情况权衡利弊来做选择。
在具体讲解 S l e r p Slerp Slerp之前,我们还需要研究一下3D空间的旋转向量与4D空间四元数之间的关系。

2. 4D空间四元数夹角 VS 3D空间向量旋转变化量

上一篇已经讨论过3D空间向量旋转变化量到4D空间的四元数之间夹角的关系,这一篇为加深对四元数的理解,逆向讨论,推算出4D空间四元数之间夹角到3D空间向量旋转变化量之间的关系。

2.1 4D空间四元数夹角

为了探讨4D空间四元数夹角,先来实际计算一下 Δ q \Delta q Δq。由于我们在单位四元数上讨论,所以只和角度有关,故只计算其包含角度信息的实部就可以了,如下: Δ q = q 1 q 0 ∗ = [ cos ⁡ θ 1 , sin ⁡ θ 1 u 1 ] [ cos ⁡ θ 0 , − sin ⁡ θ 0 u 0 ] = [ cos ⁡ θ 0 cos ⁡ θ 1 − ( sin ⁡ θ 1 u 1 ) ⋅ ( − sin ⁡ θ 0 u 0 ) , ⋯   ] = [ cos ⁡ θ 0 cos ⁡ θ 1 + ( sin ⁡ θ 1 u 1 ) ⋅ ( sin ⁡ θ 0 u 0 ) , ⋯   ] (2.1) \begin{aligned} \Delta q&=q_{1}q^{*}_{0}\\&=[\cos\theta_{1},\sin\theta_{1}\mathbf{u}_{1}][\cos\theta_{0},-\sin\theta_{0}\mathbf{u}_{0}]\\&=[\cos\theta_{0}\cos\theta_{1}-(\sin\theta_{1}\mathbf{u}_{1})\cdot(-\sin\theta_{0}\mathbf{u}_{0}),\cdots ]\\&=[\cos\theta_{0}\cos\theta_{1}+(\sin\theta_{1}\mathbf{u}_{1})\cdot(\sin\theta_{0}\mathbf{u}_{0}),\cdots ]\end{aligned}\tag{2.1} Δq=q1q0=[cosθ1,sinθ1u1][cosθ0,sinθ0u0]=[cosθ0cosθ1(sinθ1u1)(sinθ0u0),]=[cosθ0cosθ1+(sinθ1u1)(sinθ0u0),](2.1)如果将 q 0 q_{0} q0 q 1 q_{1} q1看作是两个四维向量,碰巧 Δ q \Delta q Δq的实部就是 q 0 q_{0} q0 q 1 q_{1} q1的点乘: q 0 ⋅ q 1 = cos ⁡ θ 0 cos ⁡ θ 1 + ( sin ⁡ θ 0 u 0 ) ⋅ ( sin ⁡ θ 1 u 1 ) (2.2) q_{0}\cdot q_{1}=\cos\theta_{0}\cos\theta_{1}+(\sin\theta_{0}\mathbf{u}_{0})\cdot(\sin\theta_{1}\mathbf{u}_{1})\tag{2.2} q0q1=cosθ0cosθ1+(sinθ0u0)(sinθ1u1)(2.2)对于四元数4D空间夹角:因为 q 0 q_{0} q0 q 1 q_{1} q1都是单位四元数,所以 q 0 ⋅ q 1 q_{0}\cdot q_{1} q0q1正好是这两个四元数在4D空间中夹角的余弦值,记这个夹角为 θ \theta θ,那么 q 0 ⋅ q 1 = ∥ q 0 ∥ ∥ q 1 ∥ cos ⁡ θ = cos ⁡ θ q_{0}\cdot q_{1}=\left \| q_{0} \right \|\left \| q_{1} \right \|\cos\theta=\cos\theta q0q1=q0q1cosθ=cosθ
由于 Δ q \Delta q Δq在3D空间中表示的也是一个旋转,假设它代表的旋转角度是 2 ϕ 2\phi 2ϕ,则 Δ q \Delta q Δq的实数部为 cos ⁡ 1 2 ( 2 ϕ ) \cos\frac{1}{2}(2\phi) cos21(2ϕ)(这部分的证明可参考《三维空间刚体运动4-1:四元数表示变换》中第三节:用四元数表示旋转),那么就有: cos ⁡ 1 2 ( 2 ϕ ) = cos ⁡ θ (2.3) \cos\frac{1}{2}(2\phi)=\cos\theta\tag{2.3} cos21(2ϕ)=cosθ(2.3)因为 ϕ \phi ϕ θ \theta θ都是夹角, ϕ , θ ∈ [ 0 , π ] \phi,\theta \in [0,\pi] ϕ,θ[0,π],所以这个方程有唯一的解: ϕ = θ \phi=\theta ϕ=θ。也就是说, q 0 q_{0} q0 q 1 q_{1} q1在4D四元数空间中的夹角 θ \theta θ,正好是它们代表的3D旋转变化量 Δ q \Delta q Δq角度的一半,即: θ = 1 2 ( 2 ϕ ) \theta=\frac{1}{2}(2\phi) θ=21(2ϕ)。所以,我们可以直接用在4D四元数空间中插值四元数的方法对3D空间中的旋转进行插值。

2.2 3D空间向量旋转变化量

那么4D空间四元数的夹角如何对应到3D空间的向量旋转变化量呢?为了更直观地理解这层关系,请看下面这两幅图。虽然四元数是处于四维空间之内的,但是因为只有两个四元数,我们可以将它们投影到一个二维的圆上来,也就是左图,右图则是 3D 空间中发生的旋转改变:在这里插入图片描述
可以看到,当4D四元数空间中的 q 1 q_{1} q1 q 0 q_{0} q0之间的夹角为 θ \theta θ时,对应3D空间中的旋转变化量正好为 2 θ 2\theta 2θ。对于能投影到圆上的4D空间单位四元数 q t q_{t} qt,使它与 q 0 q_{0} q0的夹角为 t θ t\theta tθ,与 q 1 q_{1} q1的夹角为 ( 1 − t ) θ (1-t)\theta (1t)θ,那么就能确定在3D空间中,它相对于 q 0 q_{0} q0的旋转变化量为 2 t θ 2t\theta 2tθ,相对于 q 1 q_{1} q1的旋转变化量为 2 ( 1 − θ ) 2(1-\theta) 2(1θ)
总结:现在,两个单位四元数 q 0 q_{0} q0 q 1 q_{1} q1的插值就被我们简化为了一个圆上(对应为4D空间中超球面的一部分)两个向量 v 0 v_{0} v0 v 1 v_{1} v1的插值,故我们能直接套用向量的插值公式对两个四元数进行插值。接下来,在讨论对两个向量进行插值之前,为方便可视化演示,先介绍下可视化插值曲线。

3. 可视化插值曲线

在讲解具体插值方法之前,先了解下可视化插值曲线。我们通常先从理论上比较各种插值方法,同时也会比较它们的实际应用效果,以实际应用效果考量理论的可行性。
本节包含所使用可视化方法的简短描述和各类可视化参数的含义,即对可视化图的解释。

3.1 直接可视化图像

最明显的可视化方法就是将插值旋转量直接应用于对象,它可简便的通过定义一个使用三维可视化工具的对象来实现,并且使用插值旋转量对这个对象进行旋转。这里我们使用大圆体代表三维的单位圆,图中的点代表四元数插值,第一个关键帧使用较大圆点,其他关键帧使用较小的圆点,插值点使用更小的圆点,如下图:在这里插入图片描述
上图取自 S l e r p Slerp Slerp的可视化图。对于关键帧的取值,我们选择旋转角和旋转轴时,确保旋转落在相同三维超平面上,具体取值如下:

旋转角 θ ∈ [ − π , π ] \theta\in[-\pi,\pi] θ[π,π]旋转轴 v ∈ R 3 v\in \mathbb{R}^{3} vR3
1(1,3,0)
1.9(-1,0,0)
0(-2,1,0)
-2(3,4,0)
-1(-1,4,0)
1(1,3,0)

需要注意的是,表中的旋转和球面上的点并不完全对应,这是因为表中包含的是一般旋转量,而可视化展示的是相关的四元数。由于我们仅关心插值曲线的几何形状,所以球面上的关键帧的位置不是绝对相关的,是近似相关。
我们用这种可视化方法产生可演示对象旋转的图像序列,这种方法能给我们对插值曲线动作的直观观感,但不能展示出插值曲线平滑性和它的角速度的变化量,因此我们需要其它可视化方法来提供这些信息。

3.2 角速度近似值的可视化

我们想要可视化插值曲线的角速度,这样就可以看到插值曲线是否有恒定的角速度。为了产生角速度的图像,必须定义角速度近似值的函数,那么剩下的工作就是根据插值参数画出这个函数了。计算角速度近似值有多种基于物理或数学定义的方法,我们使用基于数学的四元数范数定义,这时可以计算两个四元数 q 1 , q 2 q_{1},q_{2} q1,q2的距离: d ( q 1 , q 2 ) = ∥ q 1 − q 2 ∥ (3.1) d(q_{1},q_{2})=\left \| q_{1}-q_{2}\right \|\tag{3.1} d(q1,q2)=q1q2(3.1)然后定义第 i i i个四元数 q i q_{i} qi的角速度为终点平均值的近似值: V ( q i ) = d ( q i , q i − 1 ) + d ( q i , q i + 1 ) 2 = ∥ q i − q i − 1 ∥ + ∥ q i − q i + 1 ∥ 2 (3.2) V(q_{i})=\frac{d(q_{i},q_{i-1})+d(q_{i},q_{i+1})}{2}=\frac{\left \| q_{i}-q_{i-1}\right \|+\left \| q_{i}-q_{i+1}\right \|}{2}\tag{3.2} V(qi)=2d(qi,qi1)+d(qi,qi+1)=2qiqi1+qiqi+1(3.2)根据四元数参数画出 V V V的值,就产生了每帧角速度近似值的可视化图像:在这里插入图片描述
上图为 S l e r p Slerp Slerp插值的角速度图像,横轴代表帧数及四元数 q i q_{i} qi对应的关键帧,纵轴为角速度值。由于第一帧和最后一帧的计算缺少参数,故忽略,因此最左侧点是角速度的第一个插值帧,同时剩余的关键帧使用星号标注。

3.3 插值曲线平滑性可视化

我们同样希望可视化插值曲线的平滑性,但由于四元数空间是四维的,故无法直接对其进行可视化。需要强调的是,我们将只在单位四元数上进行插值( L i n E u l e r LinEuler LinEuler除外),并且插值的四元数也是单位四元数,这意味着我们只需要三维数据即可进行可视化,因为单位四元数都落在单位圆表面。
在实践中,很难在二维空间的媒介中(纸面或显示器)有效的可视化3D空间,所以我们通过把四元数在第三维相同的超平面上进行插值的方法来移除另外一维,而这可以通过固定所有关键帧的四元数某一坐标轴的方法来实现(本文固定 z z z轴),此时插值曲线应保持在能展现二维空间的平面上(前面所述如果不明白,可忽略,着重理解下面所述)。另外,为了与四维的单位圆保持联系,我们选择在三维单位圆的表面上展示插值曲线。在 S p r i n g Spring Spring插值方法中,我们将讨论落在四元数单位圆表面的理想插值曲线或称最优插值曲线。这种可视化方法的选择,将有助于确保我们可视化的观察插值曲线是否停留在单位圆的表面上,比如后面要讲到的 L i n E u l e r LinEuler LinEuler插值方法。
最后需要重点说明的是,插值曲线的平滑性没有单独作图,但可通过观察四元数插值图像的角度是否平顺来判断是否平滑。
总之,我们将使用球面和二维图形的方法演示插值方法。下面我们先介绍平面线性插值方法。

4. 平面线性插值方法:LinEuler,LinMat,Lerp和Nlerp

前一节讨论了插值图像和角速度变化两种旋转形态,本节将总结常见的四种平面线性插值方法: L i n E u l e r , L i n M a t , L e r p LinEuler,LinMat,Lerp LinEuler,LinMat,Lerp N l e r p Nlerp Nlerp,并调查这些插值方法在这两种形态中的表现。从简单直观的方法开始,逐渐到有扎实理论基础的高级插值方法,同时探讨最优差值算法的标准,最终的目的是定义并应用这种插值方法。
不管是哪种插值方法,我们都希望将中间向量 v t v_{t} vt写为初始向量 v 0 v_{0} v0和最终向量 v 1 v_{1} v1的线性组合,也就是说: v t = α v 0 + β v 1 (4.1) v_{t}=\alpha v_{0}+\beta v_{1}\tag{4.1} vt=αv0+βv1(4.1)其中,系数 α \alpha α β \beta β都是 t t t的函数,不同的插值方法只是拥有不同的系数而已。

4.1 LinEuler

最容易想到的是两个欧拉角元组之间的简单线性插值方法 L i n E u l e r LinEuler LinEuler(Linear Euler),代表欧拉角的 v 0 = ( x 0 , y 0 , z 0 ) v_{0}=(x_{0},y_{0},z_{0}) v0=(x0,y0,z0) v 1 = ( x 1 , y 1 , z 1 ) v_{1}=(x_{1},y_{1},z_{1}) v1=(x1,y1,z1)之间的插值算法上表示如下: L i n E u l e r ( v 0 , v 1 , t ) = ( 1 − t ) v 0 + t v 1 , t ∈ [ 0 , 1 ] (4.2) LinEuler(v_{0},v_{1},t)=(1-t)v_{0}+tv_{1},t\in[0,1]\tag{4.2} LinEuler(v0,v1,t)=(1t)v0+tv1,t[0,1](4.2)其插值曲线和角速度图如下所示:在这里插入图片描述
从图中可看出,插值曲线缺失,这是因为插值点是基于欧拉角的,而展示的是欧拉角对应的四元数。从第三节可知,演示图像圆体上显示的是 z z z轴为0时的单位四元数,而关键帧附近的四元数复合这一规则但是其他的点并不符合,因此插值点虽然是单位四元数但是 z z z轴却不为0,因此插值曲线从圆体表面消失了。从这点讲 L i n E u l e r LinEuler LinEuler既不正确当然也不是最优解。
从角速度图像来看,与插值相关的角速度直观显示逐渐变慢,因此再一次说明: L i n E u l e r LinEuler LinEuler既不正确也不是最优解。

4.2 LinMat

另一种容易想到的方法就是旋转矩阵上的线性插值 L i n M a t LinMat LinMat,这意味着每一矩阵元素的线性插值之间都独立无关。 L i n M a t LinMat LinMat算法定义如下:旋转矩阵 M 0 , M 1 ∈ R 4 × R 4 M_{0},M_{1}\in \mathbb{R}^{4} \times \mathbb{R}^{4} M0,M1R4×R4之间的插值曲线被定义为: L i n M a t ( M 0 , M 1 , t ) = ( 1 − t ) M 0 + t M 1 , t ∈ [ 0 , 1 ] (4.3) LinMat(M_{0},M_{1},t)=(1-t)M_{0}+tM_{1},t\in[0,1]\tag{4.3} LinMat(M0,M1,t)=(1t)M0+tM1,t[0,1](4.3)其插值曲线和角速度图如下所示:在这里插入图片描述
L i n E u e r LinEuer LinEuer类似,由于正交矩阵之间的线性插值将不再是正交矩阵,线性矩阵插值的曲线也不落在单位圆上。另外,插值矩阵总体上是包含变形、缩放、投影和其它转换元素的齐次变换矩阵,因此插值点会出现各种随机错误,比如,整个对象经过变换后可能会塌陷成单一点。
这里的可视化方法不能展示其他形式的变换,因此不能演示不是纯旋转矩阵的插值矩阵,当把矩阵转为四元数时只考察它的旋转部分。在上图中,我们将插值曲线投影到单位四元数圆上,这里只展示了插值曲线点的纯旋转部分,最后,经过一系列的妥协整改后,我们得到了一个还不错的插值曲线。
正如上面所述,有好的演示并不代表 L i n M a t LinMat LinMat是可用的,因为图像只展示了整个矩阵插值曲线的一部分而已。这里讨论这个方法主要是为了整个章节叙述的完整性。

4.3 Lerp

最后来看一下两个向量间插值最简单的一种形式:线性插值(Linear Interpolation),也叫做”Lerp”,然后由向量插值引出四元数插值。Lerp会沿着一条直线进行插值,如果将 v 0 v_{0} v0 v 1 v_{1} v1看做是三角形的两个边,那么 v t v_{t} vt会指向三角形的第三条边,如图:在这里插入图片描述
从图中可以看到,我们能将 v t v_{t} vt写为两个向量的和(用红色标出)。其中一个向量正是 v 0 v_{0} v0,而另一个向量则是 ( v 1 − v 0 ) (v_{1}-v_{0}) (v1v0)乘上一个系数,我们直接将 t t t作为这个系数,所以: v t = L e r p ( v 0 , v 1 , t ) = v 0 + t ( v 1 − v 0 ) = ( 1 − t ) v 0 + t v 1 (4.4) v_{t}=Lerp(v_{0},v_{1},t)=v_{0}+t(v_{1}-v_{0})=(1-t)v_{0}+tv_{1}\tag{4.4} vt=Lerp(v0,v1,t)=v0+t(v1v0)=(1t)v0+tv1(4.4) t = 0 t=0 t=0时, v t = v 0 v_{t}=v_{0} vt=v0;当 t = 1 t=1 t=1时, v t = v 1 v_{t}=v_{1} vt=v1,这正是我们想要的结果。将上面的结果应用到四元数上,就能得到: q t = L e r p ( q 0 , q 1 , t ) = ( 1 − t ) q 0 + t q 1 (4.5) q_{t}=Lerp(q_{0},q_{1},t)=(1-t)q_{0}+tq_{1}\tag{4.5} qt=Lerp(q0,q1,t)=(1t)q0+tq1(4.5)当然,因为我们是沿着一条直线(也就是圆上的一个弦)进行插值的,这样插值出来的四元数并不是单位四元数,如图:在这里插入图片描述
由于插值曲线并不在单位圆上,所以这里并没有给出相应的图形(它的效果图放在 N l e r p Nlerp Nlerp中)。而将插值曲线投影到单位圆上就是 N l e r p Nlerp Nlerp要解决的问题。

4.4 Nlerp

虽然这样插值出来的 q t q_{t} qt并不是单位四元数,但只要将 q t q_{t} qt除以它的模长 ∥ q t ∥ \left \| q_{t} \right \| qt就能够转化成一个单位四元数了,如图:在这里插入图片描述
我们将这种先对向量进行插值,再进行正规化 (Normalization) 的插值方法称为正规化线性插值(Normalized Linear Interpolation),即“ N l e r p Nlerp Nlerp”。与 L e r p Lerp Lerp不同, N l e r p Nlerp Nlerp的两个输入向量必须是单位向量,否则插值出来的结果不会经过初始和最终向量。下面分别是向量和四元数的 N l e r p Nlerp Nlerp公式 v t = N l e r p ( v 0 , v 1 , t ) = ( 1 − t ) v 0 + t v 1 ∥ ( 1 − t ) v 0 + t v 1 ∥ (4.6) \mathbf{v}_{t}=Nlerp(\mathbf{v}_{0},\mathbf{v}_{1},t)=\frac{(1-t)\mathbf{v}_{0}+t\mathbf{v}_{1}}{\left \| (1-t)\mathbf{v}_{0}+t\mathbf{v}_{1} \right \|}\tag{4.6} vt=Nlerp(v0,v1,t)=(1t)v0+tv1(1t)v0+tv1(4.6) q t = N l e r p ( q 0 , q 1 , t ) = ( 1 − t ) q 0 + t q 1 ∥ ( 1 − t ) q 0 + t q 1 ∥ (4.7) q_{t}=Nlerp(q_{0},q_{1},t)=\frac{(1-t)q_{0}+tq_{1}}{\left \| (1-t)q_{0}+tq_{1} \right \|}\tag{4.7} qt=Nlerp(q0,q1,t)=(1t)q0+tq1(1t)q0+tq1(4.7) N l e r p Nlerp Nlerp的插值曲线和角速度图如下所示:在这里插入图片描述
N l e r p Nlerp Nlerp插值仍然存在有一定的问题,当需要插值的弧比较大时, v t v_{t} vt的角速度会有显著的变化。更具体一些,如下图:在这里插入图片描述
这五个 t t t值将整个弧和弦分割成了四个部分,虽然弦上的四段是等长的,但是四个弧是完全不相等的, t = 0 t = 0 t=0 t = 0.25 t = 0.25 t=0.25之间的弧(红色)明显比 t = 0.25 t = 0.25 t=0.25 t = 0.50 t = 0.50 t=0.50的弧(蓝色)要短了不少。这也就是说,在同等时间内, v t v_{t} vt扫过的角度是不同的。 v t v_{t} vt扫过的速度(或者说角速度)首先会不断地增加,到 t = 0.50 t = 0.50 t=0.50之后会开始减速,所以 N l e r p Nlerp Nlerp插值不能保证均匀的角速度,这就引出了 S l e r p Slerp Slerp插值。
总结 L i n E u l e r LinEuler LinEuler L i n M a t LinMat LinMat的缺陷很明显,它们产生的线性插值并不能生成插值曲线,因此不能用欧拉角或旋转矩阵来定义令人满意的插值曲线,下边也不再考虑它们。 L e r p Lerp Lerp虽然能用于插值曲线,但是插值点不是单位四元数且角速度不固定。 N l e r p Nlerp Nlerp虽然能产生单位四元数的插值曲线,但是并没有解决角速度问题。

5. 球面线性插值方法:Slerp

为了解决这个问题,我们可以转而对角度进行线性插值。也就是说,如果 v 0 v_{0} v0 v 1 v_{1} v1之间的夹角为 θ \theta θ,那么 v 0 v_{0} v0 v t v_{t} vt之间的夹角: θ t = t θ \theta _{t}=t\theta θt=tθ。因为对角度的线性插值是让向量直接在球面的弧上旋转,所以又称球面线性插值(Spherical Linear Interpolation),即“ S l e r p Slerp Slerp”。类比于 L e r p Lerp Lerp是平面上的线性插值, S l e r p Slerp Slerp是球面上的线性插值。我们第一节讨论的四元数插值公式正是对四元数在四维超球面上的旋转,它是 S l e r p Slerp Slerp的幂函数形式。本节将详细介绍 S l e r p Slerp Slerp的三角函数形式和幂函数形式。

5.1 Slerp的三角函数形式

5.1.1 公式推导

首先看一下Slerp的三角函数形式,推导如下:我们希望将 v t v_{t} vt写为 v 0 v_{0} v0 v 1 v_{1} v1的线性组合: v t = α v 0 + β v 1 (5.1) v_{t}=\alpha v_{0}+\beta v_{1}\tag{5.1} vt=αv0+βv1(5.1)注意这里的 v 0 v_{0} v0 v 1 v_{1} v1仍是单位向量。为了求出这其中的 𝛼 和 𝛽,我们需要借助图像来找出一些关系:在这里插入图片描述
因为图中涉及到很多的角度关系,我们可以先对 公式(5.1)的两边同时点乘 v 0 v_{0} v0 v 0 ⋅ v t = v 0 ⋅ ( α v 0 + β v 1 ) = α ( v 0 ⋅ v 0 ) + β ( v 0 ⋅ v 1 ) v_{0}\cdot v_{t}=v_{0}\cdot(\alpha v_{0}+\beta v_{1})=\alpha (v_{0}\cdot v_{0})+\beta (v_{0}\cdot v_{1}) v0vt=v0(αv0+βv1)=α(v0v0)+β(v0v1)我们知道, v 0 v_{0} v0 v t v_{t} vt之间的夹角是 t θ t\theta tθ v 0 v_{0} v0与它自身之间的夹角为 0, v 0 v_{0} v0 v 1 v_{1} v1之间的夹角是 θ,而且所有的向量都是单位向量,所以根据点乘公式: cos ⁡ ( t θ ) = α + β cos ⁡ θ (5.2) \cos(t\theta)=\alpha+\beta\cos\theta\tag{5.2} cos(tθ)=α+βcosθ(5.2)同理,我们将 (5.1) 的两边同时点乘 v 1 v_{1} v1,构造第二个方程: v 1 ⋅ v t = v 1 ⋅ ( α v 0 + β v 1 ) = α ( v 1 ⋅ v 0 ) + β ( v 1 ⋅ v 1 ) v_{1}\cdot v_{t}=v_{1}\cdot(\alpha v_{0}+\beta v_{1})=\alpha (v_{1}\cdot v_{0})+\beta (v_{1}\cdot v_{1}) v1vt=v1(αv0+βv1)=α(v1v0)+β(v1v1) cos ⁡ ( ( 1 − t ) θ ) = α cos ⁡ θ + β (5.3) \cos((1-t)\theta)=\alpha\cos\theta+\beta\tag{5.3} cos((1t)θ)=αcosθ+β(5.3)现在,我们就有了两个方程(5.2)和(5.3)以及两个未知数 α \alpha α β \beta β,我们只需要解这两个方程,求出 α \alpha α β \beta β就能获得 Slerp 的公式了。求解过程如下:

  1. 由(5.2) 我们能得到: α = cos ⁡ ( t θ ) − β cos ⁡ θ (5.4) \alpha=\cos(t\theta)-\beta\cos\theta\tag{5.4} α=cos(tθ)βcosθ(5.4)
  2. 将(5.4)代入(5.3),利用一些三角恒等式解出 β \beta β cos ⁡ ( ( 1 − t ) θ ) = ( cos ⁡ ( t θ ) − β cos ⁡ θ ) cos ⁡ θ + β \cos((1-t)\theta)=(\cos(t\theta)-\beta\cos\theta)\cos\theta+\beta cos((1t)θ)=(cos(tθ)βcosθ)cosθ+β ⇒ β = cos ⁡ ( ( 1 − t ) θ ) − cos ⁡ ( t θ ) cos ⁡ θ ( 1 − cos ⁡ 2 θ ) = cos ⁡ θ cos ⁡ ( t θ ) + sin ⁡ θ sin ⁡ ( t θ ) − cos ⁡ ( t θ ) cos ⁡ θ sin ⁡ 2 θ = sin ⁡ ( t θ ) sin ⁡ θ \Rightarrow \begin{aligned}\beta&=\frac{\cos((1-t)\theta)-\cos(t\theta)\cos\theta}{(1-\cos^{2}\theta)}\\&=\frac{\cos\theta\cos(t\theta)+\sin\theta\sin(t\theta)-\cos(t\theta)\cos\theta}{\sin^{2}\theta}\\&=\frac{\sin(t\theta)}{\sin\theta}\end{aligned} β=(1cos2θ)cos((1t)θ)cos(tθ)cosθ=sin2θcosθcos(tθ)+sinθsin(tθ)cos(tθ)cosθ=sinθsin(tθ)
  3. β \beta β代入公式(5.4)就能解出 α \alpha α α = cos ⁡ ( t θ ) − ( sin ⁡ ( t θ ) sin ⁡ θ ) cos ⁡ θ = cos ⁡ ( t θ ) sin ⁡ θ − sin ⁡ ( t θ ) cos ⁡ θ sin ⁡ θ = sin ⁡ ( ( 1 − t ) θ ) sin ⁡ θ \begin{aligned}\alpha &= \cos(t\theta)-\left ( \frac{\sin(t\theta)}{\sin\theta}\right )\cos\theta\\&=\frac{\cos(t\theta)\sin\theta-\sin(t\theta)\cos\theta}{\sin\theta}\\&=\frac{\sin((1-t)\theta)}{\sin\theta}\end{aligned} α=cos(tθ)(sinθsin(tθ))cosθ=sinθcos(tθ)sinθsin(tθ)cosθ=sinθsin((1t)θ)
  4. α \alpha α β \beta β代回(5.1),就可以得到向量的 S l e r p Slerp Slerp公式: v t = S l e r p ( v 0 , v 1 , t ) = sin ⁡ ( ( 1 − t ) θ ) sin ⁡ θ v 0 + sin ⁡ ( t θ ) sin ⁡ θ v 1 (5.5) \mathbf{v}_{t}=Slerp(\mathbf{v}_{0},\mathbf{v}_{1},t)=\frac{\sin((1-t)\theta)}{\sin\theta}\mathbf{v}_{0}+\frac{\sin(t\theta)}{\sin\theta}\mathbf{v}_{1}\tag{5.5} vt=Slerp(v0,v1,t)=sinθsin((1t)θ)v0+sinθsin(tθ)v1(5.5)类似的,可以得到四元数的 S l e r p Slerp Slerp公式: q t = S l e r p ( q 0 , q 1 , t ) = sin ⁡ ( ( 1 − t ) θ ) sin ⁡ θ q 0 + sin ⁡ ( t θ ) sin ⁡ θ q 1 (5.6) q_{t}=Slerp(q_{0},q_{1},t)=\frac{\sin((1-t)\theta)}{\sin\theta}q_{0}+\frac{\sin(t\theta)}{\sin\theta}q_{1}\tag{5.6} qt=Slerp(q0,q1,t)=sinθsin((1t)θ)q0+sinθsin(tθ)q1(5.6)其中 q 0 q_{0} q0 q 1 q_{1} q1之间的夹角 θ \theta θ可以直接使用它们点乘的结果来计算,即: θ = cos ⁡ − 1 ( q 0 ⋅ q 1 ) \theta=\cos^{-1}(q_{0}\cdot q_{1}) θ=cos1(q0q1)

这里需要说明两点:
(1)这里导出的公式会比之前利用幂运算的公式要高效很多,但是它仍然涉及到三个三角函数以及一个反三角函数的运算,所以还是会比 N l e r p Nlerp Nlerp要慢一点。如果要插值的角度比较小的话, N l e r p Nlerp Nlerp其实相对于 S l e r p Slerp Slerp的误差并没有那么大。为了提高效率,我们经常会使用 N l e r p 来 代 替 S l e r p Nlerp 来代替 Slerp NlerpSlerp,也可以用一些数值分析的方法来近似并优化四元数的 S l e r p Slerp Slerp,你可以在一些图形引擎的源代码中找到某些例子。
(2)除了效率问题之外,我们在实现 S l e r p Slerp Slerp时要注意,如果单位四元数之间的夹角 θ \theta θ非常小,那么 sin ⁡ θ \sin\theta sinθ可能会由于浮点数的误差被近似为 0.0 0.0 0.0,从而导致除以零的错误。所以在实施 S l e r p Slerp Slerp之前,需要检查两个四元数的夹角是否过小(或者完全相同)。一旦发现这种问题,我们就必须改用 N l e r p Nlerp Nlerp对两个四元数进行插值,这时候 N l e r p Nlerp Nlerp的误差非常小所以基本不会与真正的 S l e r p Slerp Slerp有什么区别。

5.2 Slerp的幂函数形式

虽然Slerp幂函数的运算效率比不上它的三角函数形式,但它有很多非常优良的性质,因此在高阶应用中更广泛,下面就来看一下Slerp的幂函数形式。

5.2.1 四个幂函数恒等式

先看一条定理:
四元数定理3:假设 p ∈ H , q ∈ H ∘ p\in \mathbb{H},q\in \overset{\circ}{\mathbb{H}} pH,qH,如果 r ∈ R ∖ { 0 } r\in \mathbb{R} \setminus \left \{ 0 \right \} rR{0},那么 ( r q ) p ( r q ) − 1 = q p q − 1 (rq)p(rq)^{-1}=qpq^{-1} (rq)p(rq)1=qpq1
由定理可知,一条经过原点的直线上的四元数代表同一旋转,不会因标量而改变,而由于单位四元数有一系列优良的性质,所以这里只使用单位四元数表示旋转。
Slerp不仅仅只是简单线性插值,而是在四元数单位圆上,在关键帧之间划定相应的弧线,所以被称为大弧线插值(great arc interpolation)或球面线性插值(Squerical Linear interpolation),即 S l e r p Slerp Slerp。Slerp解决了Nlerp的角速度问题,作为对比如下图所示(左侧为Nlerp,右侧为Slerp):在这里插入图片描述
Slerp的幂函数形式在算法上定义如下:给定 q 0 , q 1 ∈ H 1 , h ∈ [ 0 , 1 ] q_{0},q_{1}\in \mathbb{H}_{1},h\in [0,1] q0,q1H1,h[0,1],下面表示球面线性插值的四个方程是等价的: S l e r p ( p , q , h ) = p ( p ∗ q ) h (5.7) Slerp(p,q,h)=p(p^{*}q)^{h}\tag{5.7} Slerp(p,q,h)=p(pq)h(5.7) S l e r p ( p , q , h ) = ( p q ∗ ) 1 − h q (5.8) Slerp(p,q,h)=(pq^{*})^{1-h}q\tag{5.8} Slerp(p,q,h)=(pq)1hq(5.8) S l e r p ( p , q , h ) = ( q p ∗ ) h p (5.9) Slerp(p,q,h)=(qp^{*})^{h}p\tag{5.9} Slerp(p,q,h)=(qp)hp(5.9) S l e r p ( p , q , h ) = q ( q ∗ p ) 1 − h (5.10) Slerp(p,q,h)=q(q^{*}p)^{1-h}\tag{5.10} Slerp(p,q,h)=q(qp)1h(5.10)另外,我们注意到对偶同步引出的以下公式也是正确的: S l e r p ( p , q , h ) = S l e r p ( q , p , 1 − h ) Slerp(p,q,h)=Slerp(q,p,1-h) Slerp(p,q,h)=Slerp(q,p,1h)下面给出Slerp四个表达式相等的证明。首先证明公式 ( 5.7 ) = ( 5.9 ) (5.7)=(5.9) (5.7)=(5.9),根据上一篇《四元数表示变换》四元数定理2有: p ( p ∗ q ) h = p ( p ∗ q ) h ( p ∗ q ) = ( p ( p ∗ q ) h p ∗ ) q = ( p p ∗ q p ∗ ) h q = ( p q ∗ ) h p \begin{aligned} p(p^{*}q)^{h} &= p(p^{*}q)^{h}(p^{*}q) \\&= (p(p^{*}q)^{h}p^{*})q \\&= (pp^{*}qp^{*})^{h}q \\&= (pq^{*})^{h}p \end{aligned} p(pq)h=p(pq)h(pq)=(p(pq)hp)q=(ppqp)hq=(pq)hp然后证明 ( 5.10 ) = ( 5.8 ) (5.10)=(5.8) (5.10)=(5.8) q ( q ∗ p ) 1 − h = q ( q ∗ p ) 1 − h ( q ∗ q ) = ( q ( q ∗ p ) 1 − h q ∗ ) q = ( q ( q ∗ p ) q ∗ ) 1 − h q = ( p q ∗ ) 1 − h q \begin{aligned} q(q^{*}p)^{1-h} &= q(q^{*}p)^{1-h}(q^{*}q) \\&= (q(q^{*}p)^{1-h}q^{*})q \\&= (q(q^{*}p)q^{*})^{1-h}q \\&= (pq^{*})^{1-h}q \end{aligned} q(qp)1h=q(qp)1h(qq)=(q(qp)1hq)q=(q(qp)q)1hq=(pq)1hq最后证明 ( 5.8 ) = ( 5.7 ) (5.8)=(5.7) (5.8)=(5.7) ( p q ∗ ) 1 − h q = ( p q ∗ ) ( p q ∗ ) − h q = ( p q ∗ ) ( ( p q ∗ ) − 1 ) h q = p q ∗ ( ( p q ∗ ) ∗ ) h q = p q ∗ ( q p ∗ ) h q = p ( q ∗ ( q p ∗ ) q ) h = p ( p ∗ q ) h \begin{aligned} (pq^{*})^{1-h}q &= (pq^{*})(pq^{*})^{-h}q \\&= (pq^{*})((pq^{*})^{-1})^{h}q \\&= pq^{*}((pq^{*})^{*})^{h}q \\&= pq^{*}(qp^{*})^{h}q \\&= p(q^{*}(qp^{*})q)^{h} \\&= p(p^{*}q)^{h} \end{aligned} (pq)1hq=(pq)(pq)hq=(pq)((pq)1)hq=pq((pq))hq=pq(qp)hq=p(q(qp)q)h=p(pq)h至此证明了Slerp的四个表达式的恒等性,为便于统一后文中我们只使用 S l e r p ( p , q , h ) = p ( p ∗ q ) h Slerp(p,q,h)=p(p^{*}q)^{h} Slerp(p,q,h)=p(pq)h

5.2.2 恒定角速度的证明

Slerp实际上在四维四元数球体上演示的大弧线(great arc,或称完美弧或等角速度弧线,或许老外认为圆周上的弧线相较椭圆线来说更加完美,所以这样命名,它应与椭圆弧区分开,下文统称大弧线)特性并不明显,通常在以往文献中它通过单位四元数的李群结构引出,在这里我们将通过基础微分几何的形式给出完整证明。
通常有多种不同方法可以证明四元数球体上的弧线为大弧线:一种方法是基于slerp的曲率,容易看出整个插值曲线的曲率都一致,在单位圆上所有大弧线上的曲率相等;本文使用另一种方法,其关键点是:如果观察到插值曲线的二次导数向量与插值曲线的位置向量平行,并且方向相反,那么插值曲线就是大弧线,即: d 2 d h 2 S l e r p ( p , q , h ) = c   S l e r p ( p , q , h ) , c ≤ 0 \frac{d^{2}}{dh^{2}}Slerp(p,q,h)=c\space Slerp(p,q,h), c\leq 0 dh2d2Slerp(p,q,h)=c Slerp(p,q,h),c0从物理层面解释,它与作用于一个物体上的力(常量)产生了恒定角速度的平面圆周远动的情况相类似,插值曲线的一次导数是插值曲线在单位圆对应点的切平面切率,而其二次导数是与切平面切率正交的向量,它正好与曲线的位置向量平行且方向相反。

在具体证明之前,需要一条引理:
四元数引理4:设 p = [ s , v ] , q 1 = [ s 1 , ( x 1 , y 1 , z 1 ) ] = [ s 1 , v 1 ] , q 2 = [ s 2 , ( x 2 , y 2 , z 2 ) ] = [ s 2 , v 2 ] ∈ H p=[s,v],q_{1}=[s_{1},(x_{1},y_{1},z_{1})]=[s_{1},\mathbf{v_{1}}],q_{2}=[s_{2},(x_{2},y_{2},z_{2})]=[s_{2},\mathbf{v_{2}}] \in \mathbb{H} p=[s,v],q1=[s1,(x1,y1,z1)]=[s1,v1],q2=[s2,(x2,y2,z2)]=[s2,v2]H,那么 ( p q 1 ) ⋅ ( p q 2 ) = ∥ p ∥ ( q 1 ⋅ q 2 ) (pq_{1})\cdot(pq_{2})=\left \| p \right \|(q_{1}\cdot q_{2}) (pq1)(pq2)=p(q1q2)
引理的证明较为繁琐,限于篇幅和精力,这里省略,感兴趣的读者可以查看附录文献2中P44的证明。

下面就可以引入恒定角速度定理:
四元数定理4:曲线 S l e r p ( p , q , h ) : H 1 × H 1 × [ 0 , 1 ] ∈ H 1 Slerp(p,q,h):\mathbb{H}_{1}\times\mathbb{H}_{1}\times[0,1]\in\mathbb{H}_{1} Slerp(p,q,h):H1×H1×[0,1]H1是单位四元数球面上的介于 p p p q q q之间的一段大弧线,其对应的 S l e r p Slerp Slerp的位置向量函数有恒定角速度。
为了证明以上定理,我们必须首先证明 S l e r p ( p , q , h ) Slerp(p,q,h) Slerp(p,q,h)满足以下四个条件:
S l e r p ( p , q , 0 ) = p (5.11) Slerp(p,q,0)=p\tag{5.11} Slerp(p,q,0)=p(5.11) S l e r p ( p , q , 1 ) = q (5.12) Slerp(p,q,1)=q\tag{5.12} Slerp(p,q,1)=q(5.12) ∥ S l e r p ( p , q , h ) ∥ = 1 , h ∈ [ 0..1 ] (5.13) \left \|Slerp(p,q,h) \right \| =1, h\in[0..1]\tag{5.13} Slerp(p,q,h)=1,h[0..1](5.13) d 2 d h 2 S l e r p ( p , q , h ) = c   S l e r p ( p , q , h ) , c ≤ 0 ∈ R (5.14) \frac{d^{2}}{dh^{2}}Slerp(p,q,h)=c\space Slerp(p,q,h),c\leq0\in\mathbb{R}\tag{5.14} dh2d2Slerp(p,q,h)=c Slerp(p,q,h),c0R(5.14)条件 ( 5.11 ) (5.11) (5.11) ( 5.12 ) (5.12) (5.12)可以使用指数和对数的定义直接给出: S l e r p ( p , q , 0 ) = p ( p ∗ q ) 0 = p exp ⁡ ( [ 0 , 0 ] ) = p [ 1 , 0 ] = p Slerp(p,q,0)=p(p^{*}q)^{0}=p\exp([0,0])=p[1,0]=p Slerp(p,q,0)=p(pq)0=pexp([0,0])=p[1,0]=p S l e r p ( p , q , 1 ) = p ( p ∗ q ) 1 = p exp ⁡ ( log ⁡ ( p ∗ q ) ) = p p ∗ q = p p − 1 q = q \begin{aligned}Slerp(p,q,1)&=p(p^{*}q)^{1}=p\exp(\log(p^{*}q)) \\&= pp^{*}q=pp^{-1}q=q\end{aligned} Slerp(p,q,1)=p(pq)1=pexp(log(pq))=ppq=pp1q=q对于条件 ( 5.13 ) (5.13) (5.13),由于指数对应于单位四元数(见《三维空间刚体运动4-1》中节5-3的单位四元数的指数形式),并且积的范数等于范数的积: ∥ S l e r p ( p , q , h ) ∥ = ∥ p ∥ ∥ ( p ∗ q ) h ∥ = 1   ∥ exp ⁡ ( h log ⁡ ( p ∗ q ) ) ∥ = 1 \left \| Slerp(p,q,h) \right \|=\left \| p \right \| \left \| (p^{*}q)^{h} \right \|=1\space\left \| \exp(h\log(p^{*}q)) \right \|=1 Slerp(p,q,h)=p(pq)h=1 exp(hlog(pq))=1对于条件 ( 5.14 ) (5.14) (5.14),我们需要推导出 S l e r p Slerp Slerp的二次导数,根据《三维空间刚体运动4-1》中节2.2中的当四元数的指数为函数时的微分法则可以得到: d d h S l e r p ( p , q , h ) = d d h p ( p ∗ q ) h = p ( p ∗ q ) h log ⁡ ( p ∗ q ) = S l e r p ( p , q , h ) log ⁡ ( p ∗ q ) (5.15) \begin{aligned}\frac{d}{dh}Slerp(p,q,h) &= \frac{d}{dh}p(p^{*}q)^{h} \\&= p(p^{*}q)^{h}\log(p^{*}q) \\&= Slerp(p,q,h)\log(p^{*}q)\end{aligned}\tag{5.15} dhdSlerp(p,q,h)=dhdp(pq)h=p(pq)hlog(pq)=Slerp(p,q,h)log(pq)(5.15) d 2 d h 2 S l e r p ( p , q , h ) = p ( p ∗ q ) h log ⁡ ( p ∗ q ) 2 = S l e r p ( p , q , h ) log ⁡ ( p ∗ q ) 2 (5.16) \begin{aligned}\frac{d^{2}}{dh^{2}}Slerp(p,q,h) &=p(p^{*}q)^{h}\log(p^{*}q)^{2} \\&= Slerp(p,q,h)\log(p^{*}q)^{2}\end{aligned}\tag{5.16} dh2d2Slerp(p,q,h)=p(pq)hlog(pq)2=Slerp(p,q,h)log(pq)2(5.16)如果 log ⁡ ( p ∗ q ) 2 \log(p^{*}q)^{2} log(pq)2为非负实数,那么条件 ( 5.14 ) (5.14) (5.14)就成立。由于 p ∗ , q ∈ H 1 p^{*},q\in \mathbb{H}_{1} p,qH1,所以 p ∗ q ∈ H 1 p^{*}q\in \mathbb{H}_{1} pqH1,根据上一节指数形式中的定理,存在 θ ∈ R \theta \in \mathbb{R} θR v ∈ R 3 , ∣ v ∣ = 1 \mathbf{v}\in \mathbb{R}^{3},\left | \mathbf{v} \right |=1 vR3,v=1,使得 p ∗ q = [ cos ⁡ θ , sin ⁡ θ v ] p^{*}q=[\cos\theta,\sin\theta\mathbf{v}] pq=[cosθ,sinθv],那么: log ⁡ ( p ∗ q ) 2 = [ 0 , θ v ] 2 = [ − θ 2 v ⋅ v , θ 2 v × v ] = [ − θ 2 , 0 ] \log(p^{*}q)^{2}=[0,\theta\mathbf{v}]^{2}=[-\theta^{2}\mathbf{v}\cdot\mathbf{v},\theta^{2}\mathbf{v}\times\mathbf{v}]=[-\theta^{2},0] log(pq)2=[0,θv]2=[θ2vv,θ2v×v]=[θ2,0]因此存在 c = − θ 2 ≤ 0 c=-\theta^{2}\leq0 c=θ20,使得 d 2 d h 2 S l e r p ( p , q , h ) = c   S l e r p ( p , q , h ) \frac{d^{2}}{dh^{2}}Slerp(p,q,h)=c\space Slerp(p,q,h) dh2d2Slerp(p,q,h)=c Slerp(p,q,h)

5.2.3 最短弧线

虽然 S l e r p ( p , q , h ) , h ∈ [ 0 , 1 ] Slerp(p,q,h),h\in [0,1] Slerp(p,q,h),h[0,1]表示横跨 p p p q q q之间的大弧线,但是由于单位圆上正向与反向的旋转相同导致有两种可能的曲线,而以下定理则表明 S l e r p Slerp Slerp产生的是期望的较短弧线。
四元数定理5:设 p , q ∈ H 1 p,q\in \mathbb{H}_{1} p,qH1,那么 S l e r p ( p , q , h ) , h ∈ [ 0 , 1 ] Slerp(p,q,h),h\in [0,1] Slerp(p,q,h),h[0,1]在单位四元数圆上的 p p p q q q之间延展的大弧线是最短的。
证明:假设 q 1 2 = S l e r p ( p , q , 1 2 ) q_{\frac{1}{2}}=Slerp(p,q,\frac{1}{2}) q21=Slerp(p,q,21)并且使 α \alpha α表示 p p p q 1 2 q_{\frac{1}{2}} q21之间的角,那么当且仅当 α ∈ [ − π 2 , π 2 ] \alpha\in[-\frac{\pi}{2},\frac{\pi}{2}] α[2π,2π]时, S l e r p Slerp Slerp将产生最短的大弧线,这等价于 cos ⁡ α ∈ [ 0 , 1 ] \cos \alpha\in[0,1] cosα[0,1],因此我们只需检查 cos ⁡ α \cos\alpha cosα的符号就可以了。
使 p , q ∈ H 1 p,q\in \mathbb{H}_{1} p,qH1 p = [ s , v ] p=[s,v] p=[s,v],那么: cos ⁡ α = p ⋅ q 1 2 = p ⋅ S l e r p ( p , q , 1 2 ) = p ⋅ ( p ( p ∗ q ) 1 2 ) \begin{aligned}\cos\alpha &= p\cdot q_{\frac{1}{2}} \\&=p\cdot Slerp(p,q,\frac{1}{2}) \\&= p \cdot (p(p^{*}q)^{\frac{1}{2}})\end{aligned} cosα=pq21=pSlerp(p,q,21)=p(p(pq)21)由于 p ∗ , q ∈ H 1 p^{*},q\in\mathbb{H}_{1} p,qH1并且 p ∗ q ∈ H 1 p^{*}q\in\mathbb{H}_{1} pqH1,因此根据上一篇的四元数定理1,存在 w ∈ R 3 , ∣ w ∣ = 1 \mathbf{w}\in\mathbb{R}^{3},\left |\mathbf{w}\right | = 1 wR3,w=1 ψ ∈ [ − π , π ] \psi\in[-\pi,\pi] ψ[π,π],使得 p ∗ q = [ cos ⁡ ψ , sin ⁡ ψ   w ] p^{*}q=[\cos\psi,\sin\psi\space\mathbf{w}] pq=[cosψ,sinψ w],结合四元数引理4有: cos ⁡ α = p ⋅ ( p [ cos ⁡ ψ , sin ⁡ ψ   w ] 1 2 ) = p ⋅ ( p exp ⁡ ( 1 2 log ⁡ [ cos ⁡ ψ , sin ⁡ ψ   w ] ) = p ⋅ ( p exp ⁡ [ 0 , ψ 2   w ] ) = p ⋅ ( p [ cos ⁡ ψ 2 , sin ⁡ ψ 2   w ] ) = ( p [ 1 , 0 ] ) ⋅ ( p [ cos ⁡ ψ 2 , sin ⁡ ψ 2   w ] ) = ∥ p ∥ ( [ 1 , 0 ] ⋅ [ cos ⁡ ψ 2 , sin ⁡ ψ 2   w ] ) = ∥ p ∥ cos ⁡ ψ 2 = cos ⁡ ψ 2 \begin{aligned}\cos\alpha &= p\cdot(p[\cos\psi,\sin\psi\space\mathbf{w}]^{\frac{1}{2}}) \\&= p\cdot(p\exp(\frac{1}{2}\log[\cos\psi,\sin\psi\space\mathbf{w}]) \\&= p\cdot(p\exp[0,\frac{\psi}{2}\space\mathbf{w}]) \\&= p\cdot(p[\cos\frac{\psi}{2},\sin\frac{\psi}{2}\space\mathbf{w}]) \\&= (p[1,0])\cdot(p[\cos\frac{\psi}{2},\sin\frac{\psi}{2}\space\mathbf{w}]) \\&= \left \| p \right \|([1,0]\cdot[\cos\frac{\psi}{2},\sin\frac{\psi}{2}\space\mathbf{w}]) \\&= \left \| p \right \| \cos\frac{\psi}{2} \\&= \cos\frac{\psi}{2}\end{aligned} cosα=p(p[cosψ,sinψ w]21)=p(pexp(21log[cosψ,sinψ w])=p(pexp[0,2ψ w])=p(p[cos2ψ,sin2ψ w])=(p[1,0])(p[cos2ψ,sin2ψ w])=p([1,0][cos2ψ,sin2ψ w])=pcos2ψ=cos2ψ ψ ∈ [ − π , π ] \psi\in[-\pi,\pi] ψ[π,π]可知 cos ⁡ ψ 2 ≥ 0 \cos\frac{\psi}{2}\geq 0 cos2ψ0,因此 cos ⁡ α ≥ 0 \cos\alpha\geq 0 cosα0,所以 S l e r p Slerp Slerp p p p q q q之间的跨越的弧线最短。

5.3 两种方法对比总结

节5.2中,我们证明了幂函数形式的四个公式的恒等性,并且证明了它具有恒定角速度,并能产生所期望的大弧线,但是传统的文献上都避免使用Slerp的幂函数表达式(节5.1中讨论过,可能是出于计算效率原因),然而我们在使用这种表达形式的过程中并没有碰到任何问题。三角函数的形式相对简洁,计算也不算复杂,读者可根据实际情况选择Slerp的三角函数形式或幂函数形式。
除此之外, 还有一种常见的错误形式,这里做一下简要说明,以免新人犯错。类似平面线性插值表达式 p ( 1 − h ) + q h p(1-h)+qh p(1h)+qh,根据公式5.1有: S l e r p ( p , q , h ) = p ( p ∗ q ) h = p ( p − 1 q ) h = p p − h q h = p 1 − h q h \begin{aligned}Slerp(p,q,h) &= p(p^{*}q)^{h}=p(p^{-1}q)^{h} \\&= pp^{-h}q^{h}=p^{1-h}q^{h}\end{aligned} Slerp(p,q,h)=p(pq)h=p(p1q)h=pphqh=p1hqh看似正确,但是忽略了一点,因为对于 q , p ∈ H q,p\in \mathbb{H} q,pH,等式 ( q p ) h = q h p h (qp)^{h}=q^{h}p^{h} (qp)h=qhph并不总是成立,四元数并不满足交换律。
最后,看一下 S l e r p Slerp Slerp的插值曲线和角速度图:
在这里插入图片描述
图像显示,Slerp的插值曲线构建了四元数单位圆上的大弧线,在微分几何层面,大弧线是最短曲线,由于四元数双倍覆盖的原因,计算时还需要判断四元数方向,另外,Slerp有恒定的角速度。总之,Slerp是两个旋转之间的的最优插值曲线。

5.4 双倍覆盖带来的问题

两种方法都会带来双倍覆盖的问题。在5.2.3中证明的最短弧线是4D空间四元数 p 0 , p 1 p_{0},p_{1} p0,p1之间的插值曲线,而对应到3D空间中向量 v 0 , v 1 v_{0},v_{1} v0,v1旋转变化量会加倍,所以应该考虑当 p 0 , p 1 p_{0},p_{1} p0,p1之间的角度 θ > π 2 \theta > \frac{\pi}{2} θ>2π时,向量 v 0 , v 1 v_{0},v_{1} v0,v1旋转变化量 ϕ > π \phi > \pi ϕ>π,显然向量 v 0 , v 1 v_{0},v_{1} v0,v1之间的弧线不是最短的,反向取反时才是。如果你还记得,两个不同的单位四元数 q q q − q -q q对应的其实是同一个旋转,就不难理解这点。这个特性显然会对我们的插值造成一些影响,虽然 q q q − q -q q对向量变换的最终效果是完全相同的,但是它们作为向量相差了 𝜋 弧度:在这里插入图片描述
可以看到,虽然我们能够将 q 0 q_{0} q0向左插值至 q 1 q_{1} q1(蓝色的弧),但这会将3D空间中的向量旋转接近 360 ° 360\degree 360°,而实际上这两个旋转相差并没有那么多,它并不是3D空间中的弧面最短路径(Geodesic,即一般所称的大地线)。而如果我们将向 q 0 q_{0} q0右插值至等价的 − q 1 -q_{1} q1(红色的弧),它的旋转变化量就会比插值到 q 1 q_{1} q1要小很多,所以 q 0 q_{0} q0插值到 − q 1 -q_{1} q1才是插值的最短路径。
这也就告诉我们,在对两个单位四元数进行插值之前,我们需要先检测 q 0 q_{0} q0 q 1 q_{1} q1之间是否是钝角,这里有两种方法:
(1)检测它们点积的结果 q 0 ⋅ q 1 q_{0}\cdot q_{1} q0q1是否为负数.如果 q 0 ⋅ q 1 < 0 q_{0}\cdot q_{1}< 0 q0q1<0,那么我们就反转其中的一个四元数,比如说将 q 1 q_{1} q1改为 − q 1 -q_{1} q1,并使用 q 0 q_{0} q0 − q 1 -q_{1} q1之间新的夹角来进行插值,这样才能保证插值的路径是最短的。
(2)比较 q 0 q_{0} q0 q 1 q_{1} q1以及 q 0 q_{0} q0 − q 1 -q_{1} q1之间的距离: ∥ q 0 − q 1 ∥ \left \| q_{0}-q_{1} \right \| q0q1 ∥ q 0 + q 1 ∥ \left \| q_{0}+q_{1} \right \| q0+q1,当 ∥ q 0 − q 1 ∥ < ∥ q 0 + q 1 ∥ \left \| q_{0}-q_{1} \right \| < \left \| q_{0}+q_{1} \right \| q0q1<q0+q1时,取 q 1 q_{1} q1,反之取 − q 1 -q_{1} q1

6. 实践

四元数运算、Slerp插值及Squard插值的代码如下:
math_quaternion.h

#ifndef _MATH_ROBOT_H_
#define _MATH_ROBOT_H_
 
typedef struct Quat{
	float s;         //scalar
	float v[3];		 //vector
}QUAT;
 
QUAT Slerp_Inter(QUAT *Qs, QUAT *Qe, float lambda);
QUAT Squad(QUAT *Qi, QUAT *Si, QUAT *Si_1, QUAT *Qi_1, double t);
void GetCtlPoint(QUAT Qn[], int n, QUAT Sn[4]);

math_quaternion.c


//四元数相加或相减
QUAT Quat_Add(QUAT *q1, QUAT *q2)
{
	QUAT Q;
	Q.s    = q1->s+q2->s;
	Q.v[0] = q1->v[0]+q2->v[0];
	Q.v[1] = q1->v[1]+q2->v[1];
	Q.v[2] = q1->v[2]+q2->v[2];
 
	return Q;
}
 

//四元数标量乘法
QUAT Quat_Smupltipy(QUAT *Q, double scalar)
{
	QUAT q;
	q.s = Q->s*scalar;
	q.v[0] = Q->v[0]*scalar;
	q.v[1] = Q->v[1]*scalar;
	q.v[2] = Q->v[2]*scalar;
	return q;
}
 
//四元数内积或点积
double Quat_Dot(QUAT *Q1, QUAT *Q2)
{
	return (Q1->s*Q2->s + Q1->v[0]*Q2->v[0] + Q1->v[1]*Q2->v[1] + Q1->v[2]*Q2->v[2]);
}

//四元数外积或叉积
QUAT Quat_Product(QUAT *q1, QUAT *q2)
{
	QUAT Q;
	Q.s    = q1->s*q2->s    - q1->v[0]*q2->v[0] - q1->v[1]*q2->v[1] - q1->v[2]*q2->v[2] ;
	Q.v[0] = q1->v[0]*q2->s + q1->s*q2->v[0]    - q1->v[2]*q2->v[1] + q1->v[1]*q2->v[2] ;
	Q.v[1] = q1->v[1]*q2->s + q1->v[2]*q2->v[0] + q1->s*q2->v[1]    - q1->v[0]*q2->v[2] ;
	Q.v[2] = q1->v[2]*q2->s - q1->v[1]*q2->v[0] + q1->v[0]*q2->v[1] + q1->s*q2->v[2] ;
 
	return Q;
}

//四元数共轭
QUAT Quat_Conj(QUAT *Q)
{
	QUAT q;
	q.s = Q->s;
	q.v[0] = -Q->v[0];
	q.v[1] = -Q->v[1];
	q.v[2] = -Q->v[2];
	return q;
}
 
//四元数取反
QUAT Quat_Reverse(QUAT *Q)
{
	QUAT q;
	q.s = -Q->s;
	q.v[0] = -Q->v[0];
	q.v[1] = -Q->v[1];
	q.v[2] = -Q->v[2];
	return q;
}
 
//四元数模长
double Quat_Norm(QUAT *q)
{
	return (sqrt(q->s*q->s+
		         q->v[0]*q->v[0]+
		         q->v[1]*q->v[1]+
		         q->v[2]*q->v[2]));
}

//四元数对数运算
QUAT Quat_Log(QUAT *q)
{
    //四元数求对数
    // log(q)=[0, θv]
    double sina = sqrt(q->v[0]*q->v[0]+q->v[1]*q->v[1]+q->v[2]*q->v[2]);
    double cosa = q->s;
    double theta = atan2(sina,cosa);
    QUAT Q;
    //当sina很小时,不能作分子,用theta代替sin(theta)
    if(cosa > 0.9995){
        Q = *q;
    }
    else{ 
        Q = Quat_Smupltipy(q, theta/sina);
    }
    Q.s=0;
    return Q;
}
 
//四元数指数运算
QUAT Quat_Exp(QUAT *q)
{
	//exp(q)=[cosθ,sinθv]
	//求四元数的指数
	double theta = sqrt(q->v[0]*q->v[0]+q->v[1]*q->v[1]+q->v[2]*q->v[2]);
	double cosa = COS(theta);
    QUAT Q;
	//当sina很小时,不能作分子,用theta代替sin(theta)
	if(cosa > 0.9995){
		Q = *q;
	}
	else{
		Q = Quat_Smupltipy(q, sin(theta)/theta);
	}
	Q.s = cosa;
 
	return Q;
}
 
//计算控制点,形参n是控制点数,跟姿态个数相同
void GetCtlPoint(QUAT Qn[], int n, QUAT Sn[])
{
    Sn[0] = Qn[0];        //第一个控制点和最后一个控制点无法由公式获取
    Sn[n-1] = Qn[n-1];    //因此设置为与第一个插值点和最后一个插值点相同,对整个曲线影响不大
	QUAT qi,qi_conj,qi_m1,qi_m1_conj,qi_a1;
	QUAT m0,m1,m0_log,m1_log,m_log_sum,k,k_exp;
	u8 i = 0;
    for(i = 1; i < n-1; i++){
        qi = Qn[i];
        qi_m1 = Qn[i-1];
        qi_a1 = Qn[i+1];
        if(Quat_Dot(&qi, &qi_m1)<0)  qi_m1 = Quat_Reverse(&qi_m1);
        if(Quat_Dot(&qi, &qi_a1)<0)  qi_a1 = Quat_Reverse(&qi_a1);
        qi_conj = Quat_Conj(&qi);
        qi_m1_conj=Quat_Conj(&qi_m1);
        m0 = Quat_Product(&qi_m1_conj, &qi);
        m1 = Quat_Product(&qi_conj, &qi_a1);
 
		//k = (log(m0)-log(m1))/4;
		m0_log = Quat_Log(&m0);
		m1_log = Quat_Log(&m1);
		m_log_sum = Quat_Add(&m0_log,-&m1_log);
		k = Quat_Smupltipy(&m_log_sum, 1/4);
		k_exp = Quat_Exp(&k);
        Sn[i] = Quat_Product(&qi,&k_exp);
	}
}
 
//四元数球面线性插值
QUAT Slerp_Inter(QUAT *Qs, QUAT *Qe, float lambda)
{
		float cosa = Qs->s*Qe->s + Qs->v[0]*Qe->v[0] + Qs->v[1]*Qe->v[1] + Qs->v[2]*Qe->v[2];
		float k0, k1;
		QUAT Qt;
		// If the dot product is negative, the quaternions have opposite handed-ness and slerp won't take
    // the shorter path. Fix by reversing one quaternion.
		//q与-q实际上对应的是同一个旋转(double cover),为了得到最短路径,
		//插补之前应该判断两个四元数的角度,钝角则反转其中一个四元数
		if(cosa < 0){
			cosa = -cosa;
			Qe->s = -Qe->s;
			Qe->v[0] = -Qe->v[0];
			Qe->v[1] = -Qe->v[1];
			Qe->v[2] = -Qe->v[2];
		}
 
		// If the inputs are too close for comfort, linearly interpolate 
		//这里使用的是Lerp,使用Nlerp可能误差更小
		if(cosa > 0.9995f){
			k0 = 1.0f - lambda;
			k1 = lambda;
		}
		else{
			float sina = sqrt(1.0f - cosa*cosa);
			float a = atan2(sina, cosa);
			k0 = sin((1.0f - lambda)*a) / sina;
			k1 = sin(lambda*a) / sina;
		}
 
		Qt.s 	  = Qs->s*k0 + Qe->s*k1;
		Qt.v[0] = Qs->v[0]*k0 + Qe->v[0]*k1;
		Qt.v[1] = Qs->v[1]*k0 + Qe->v[1]*k1;
		Qt.v[2] = Qs->v[2]*k0 + Qe->v[2]*k1;
		return Qt;
}
 
//squad姿态插值
QUAT Squad(QUAT *Qi, QUAT *Si, QUAT *Si_1, QUAT *Qi_1, double t)
{
	QUAT k1 = Slerp_Inter(Qi,Qi_1,t);
	QUAT k2 = Slerp_Inter(Si,Si_1,t);
	return Slerp_Inter(&k1,&k2, 2*t*(1-t));
}

//主程序
#define QUAD_SIZE 5    //插值顶点个数
#define INSERT_POINTS_NUM 10    //每段Slerp插值点个数
string squad_file = "../squad_points.txt";    //存储插值顶点坐标的四元数数值,目前为5个
QUAT Qn[QUAD_SIZE], Sn[QUAD_SIZE], InsertPoints[(QUAD_SIZE-1)*(INSERT_POINTS_NUM-1) + QUAD_SIZE ];    //数组Qn,Sn,InsertPoints分别存储插值顶点、控制点和插值点坐标(个数为每段的插值点加插值顶点个数)

int main(int argc, char** argv){
    ifstream fin(squad_file );
    if (!fin) {
        cout << "cannot find squad_file at " << squad_file << endl;
        return 1;
    }
    
    qi=0;
    while (!fin.eof()) {
        double qx, qy, qz, qw;
        fin >> qx >> qy >> qz >> qw;
        Quaterniond qn(qw, qx, qy, qz));
        Qn[qi++]=qn;
    }
    cout << "read total"<<sizeof(Qn)<<" quaternions entries"<<endl;

	//计算控制点
	GetCtlPoint(Qn, QUAD_SIZE, Sn);
	//计算插值点
	ii=0;
	for (i=0;i<QUAD_SIZE-1;i++){
        InsertPoints[ii++]=Qn[i];
        for (t=0;t<1;t=t+1/INSERT_POINTS_NUM){
            InsertPoints[ii++] = Squad(Qn[i],Sn[i],S[i+1],Qn[i+1],t)
        }
        InsertPoints[ii]=Qn[QUAD_SIZE-1];
    }
}

squad_points.txt内容内容较简单,不再上传资源,如下所示:

0.011409802 0.010697415 0.002189494 0.999875307
0.030095484 0.210740373 0.005826227 0.977061331
0.239664942 0.330350608 0.268484533 0.872551024
0.100135505 0.065253392 0.038791075 0.992073655
-0.091520697 -0.364526123 -0.311982661 0.872588933

本文基于《视觉SLAM十四讲:从理论到实践》和《Quaternions, Interpolation and Animation》编写,但相对于原文会适当精简,同时为便于全面理解,会收集其他网络好文,根据作者理解,加入一些注解和扩展知识点,如果您觉得还不错,请一键四连(点赞关注收藏评论),让更多的人看到。

参考文献:

  1. 《视觉SLAM十四讲:从理论到实践》,高翔、张涛等著,中国工信出版社
  2. Quaternions, Interpolation and Animation
  3. 四元数与三维旋转
  4. 单位四元数多姿态插值(squad)
  5. 四元数解算姿态角解析
  • 44
    点赞
  • 127
    收藏
    觉得还不错? 一键收藏
  • 20
    评论
在MATLAB中,可以使用旋转矩阵来实现三维空间刚体运动。旋转矩阵是一种正交矩阵,它可以保持长度、角度、面积等特征不变的仿射变换,即内积和度量不变。旋转矩阵的逆等于它的转置,同时行列式的值为正负1。 在MATLAB中,可以使用makehgtform函数来创建旋转矩阵。例如,如果给定一个单位向量normal和旋转角度theta,可以使用下面的代码创建旋转矩阵Matrix_Rot: theta=acos(costheta); Matrix_Rot=makehgtform('axisrotate',normal,theta); 其中,normal是旋转轴的单位向量,theta是旋转角度。这样,Matrix_Rot就是表示刚体运动的旋转矩阵。 更多关于旋转矩阵的信息,可以参考维基百科的页面和博客文章。关于MATLAB中的刚体运动和旋转矩阵的应用,还可以参考博客文章。 总结起来,MATLAB中的三维空间刚体运动可以通过旋转矩阵来实现,旋转矩阵是一种正交矩阵,它可以保持长度、角度、面积等特征不变的仿射变换。在MATLAB中,可以使用makehgtform函数来创建旋转矩阵。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [3D视觉(三)刚体运动及matlab实现](https://blog.csdn.net/piaoxuezhong/article/details/78524498)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT0_1"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 20
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值