乐趣区

绕圆弧动画的向量解决方式

记得几年前,我的一个同事 J 需要做一个动画功能,大概的需求是
实现球面上一个点到另外一个点的动画。当时他遇到了难度,在研究了一个上午无果的情况下,咨询了我。我就告诉他说,你先尝试一个简化的版本,就是实现圆环上一个点到另外一个点的动画。如下图所示,要实现点 A 插值渐变到 B 的动画过程。

同事 J 的解决方案是,先计算出来 A 点和圆心 O 的连线和水平方向(与 X 轴平行)的夹角 1,再计算出 B 点和圆心 O 的连线和水平水平方向的夹角 2。计算出夹角以后,开始实现动画效果,由于已经有了两个角度,所以只需要实现一个角度不断插值变化的效果即可,如下图所示:

但是这儿存在一个问题,比如下图中。

从 A 点和 B 点的位置变化从图中可以看出,A 点在第二象限,角度范围是 π /2~π,而 A 点在第三象限,角度范围在 -π~-π/2(Math.atan2 的计算结果)。此时从 A 点的角度动画到 B 点的角度,动画效果是从 A 点沿着顺时针方向绕一大圈动画到 B,而不是直接从 A 点逆时针动画到 B 点。
而实际上我们想要的结果是从 A 点逆时针到 B 点(运动的角度最小)。如果此时需要获得正确的结果,就需要做各种角度的转换适配。

角度的难点在哪儿

首先假设 OA 的坐标点为(x1,y1),注意此处是 A 点相对于与圆心 O 点的坐标,这样方便计算。然后计算出角度,我们知道可以通过 Math.atan2(y,x)来计算角度。那么计算出来的角度的范围如下,以坐标系 4 个象限为分类标准:

  • 第一象限的角度范围是:0 ~ PI/2
  • 第二象限的角度范围是:PI/2 ~ PI
  • 第三象限的角度范围是:-PI ~-PI/2
  • 第四象限的角度范围是: -PI/2 ~-PI

如下图所示:

从上面图中可以看出,象限之间的角度变换不是线性的,比如从第二象限到第三象限,角度出现了跳跃式的变换。假设 A 点在第二象限,B 点在第三象限,如下图所示:

现在假设 A 点的角度为 3/4 PI, B 点的角度为 – 3/4PI, 如果按照角度插值的方式进行运动。示例代码片段入下:

      var i = 0,count = 200;
      var PI = Math.PI;
      function animateAngle() {var angle = (angle1 * (count-i) + angle2 * (i)) / count;
        var x = cx + Math.cos(angle) * r,
            y = cy + Math.sin(angle) * r;
        ctx.beginPath();
        ctx.moveTo(cx,cy);
        ctx.lineTo(x,y);
        ctx.strokeStyle = 'red';
        ctx.stroke();
        i ++;
        if(i > count){i = 0;}
      }

运动的轨迹如下图红色弧线所示,

而实际,我们希望的效果是按照最短的路径进行运动,如下图蓝色弧线:

为什么运动轨迹是红色的弧线呢。因为使用了角度的插值,A 点角度是 PI3/4,B 点角度为 -PI3/4,因此插值是从一个正的角度减少到一个负的角度,这正好是红色路径。下图标记了主要节点的角度:

同样的道理,从 B 点动画到 A 点,也同样会走红色路径。

要实现 A 点和 B 点之间沿着蓝色弧线动画,需要把 B 点的角度加上 2 PI,此时 B 点的角度为 PI5/4。看来把小于 0 的角度加上 2 *PI,可以解决上面的问题。
但是这种方式不能解决所有的情况,比如把 A 点移到第一象限,有下面两种情况:

  • 情况 1:红色弧线的角度小于 PI,此时应该沿着红色弧线动画,此时
    B 点的角度不应该加上 PI*2
  • 情况 2:红色弧线的角度大于 PI,此时应该沿着蓝色弧线动画,此时
    B 点的角度应该加上 PI*2

可以看出情况比较复杂,需要考虑角度的各种情况进行转换,才能得到正确的结果,所以很多人程序员会陷入其中热找不到正解。

向量解决

正是由于有了这个角度的问题,导致这个动画实现的难度变大。同事 J 在经过各种实验后未能找到好的解决方案,问我如何解决。我看了之后,给出的解决方案是,可以考虑直接用向量的插值,而不是用角度的插值。向量的基本概念,我们在高中就学习过,此处不做详细说明。

向量解决方案一

比如上面的问题,无论是 A 点到 B 点,还是 A 点到 C 点,都可以用统一的模式解决。首先,我们可以把问题简化成一个线性运动的问题,比如从 A 点运动 C 点,由于是线性问题,这通过向量的插值(0~1)很容易计算出来,首先计算出向量 OA,然后计算出向量 OC,通过之后可以通过插值运算,计算出中间向量
OX = OA (1-x)+ OC (x)
上面的公式计算出来的 OX,其长度和 OA 和 OC 并不相等,所以点 X 并不是在圆环上运动。此时只需要通过向量的缩放操作,把 OX 的长度延长为 OA 的长度即可。

以下是代码片段:

 var v1 = new Vec3(x1-cx,y1-cy,0),
         v2 = new Vec3(x2-cx,y2-cy,0);
var i = 0,count = 200;
function animateVector(){
          var a = i / count;
          var v = new Vec2().lerpVectors(v1,v2,a);
          v.setLength(r);
          i ++;
          if(i > count){i = 0;}
          
        ctx.beginPath();
        ctx.moveTo(cx,cy);
        ctx.lineTo(v.x + cx,v.y + cy);
        ctx.strokeStyle = 'orange';
        ctx.stroke();}

其中 Vec2 是二维向量类。
当然上面的解决方案有个问题:上面的运动是基于直线均匀运动的,应此并不能保证动画的角度均匀性。当角度小的时候,这种差异并不大,所以在不严格要求角度均匀的情况下,可以不用处理。而如果角度大的时候,速度差异就会比较大。

向量解决方案二

如果一定要角度均匀,也是可以做的,可以用到向量的点乘、叉乘知识。首先我们需要学习两个知识点

向量的点乘简介

向量 A(x1,y1)和向量 B(x2,y2)的点乘结果如下:

A*B = x1*x2 + y1*y2

向量 A 点乘向量 B 的点乘结果的另外一个公式如下:

a * b = |a| * |b| * cosθ 

通过该公式可以推导出,两个向量之间的夹角的计算公式:

cosθ  = a * b /(|a| * |b|)
θ = Math.acos(a * b /( |a| * |b|));

点乘计算出来的夹角的的范围是在 0~PI 之间。

向量的叉乘

二维向量没有叉乘,叉乘是针对三维向量的。本文所述的问题,是一个二维的问题,但是为了方便使用叉乘来解决问题,把二维问题升级到三维问题,也就是,增加一个 z 坐标。
向量叉乘的结果叫做向量积,其本身也是一个向量,向量积的定义如下:
模长:(在这里 θ 表示两向量之间的夹角 (共起点的前提下)(0° ≤ θ ≤ 180°),它位于这两个矢量所定义的平面上。)
方向:向量 A 与向量 B 的向量积的方向与这两个向量所在平面垂直,且遵守右手定则。(一个简单的确定满足“右手定则”的结果向量的方向的方法是这样的:若坐标系是满足右手定则的,当右手的四指从 A 以不超过 180 度的转角转向 B 时,竖起的大拇指指向是向量 C 的方向。C = A ∧ B)

本文中,向量 A 和向量 B 都在 xy 平面,所以他们的叉乘结果 C(向量积)和 xy 平面垂直,和 z 坐标平行。其方向和 A 到 B 的顺序有关:

  • 当 A 到 B 是顺时针的时候,C 指向 z 轴的负方向。
  • 当 A 到 B 是逆时针的时候,C 指向 z 轴的正方向。

有了相关的向量知识,现在给出问题的解决方案,代码如下:

 var v1 = new Vec3(x1-cx,y1-cy,0),
           v2 = new Vec3(x2-cx,y2-cy,0);
        var crossVector = new Vec3().crossVectors(v1,v2);
var i = 0,count = 100;
function animateVector2(){
        var a = i / count;
        var vAngle = v1.angleTo(v2); 
        if(crossVector.z > 0){// 通过向量叉乘判断是逆时针还是顺时针,crossVector.z > 0 是逆时针
            angleEnd = angle1 + vAngle;
        }else{angleEnd = angle1 - vAngle;}
        var angle = (angle1 * (count-i) + angleEnd * (i)) / count;
        var x = cx + Math.cos(angle) * r,
            y = cy + Math.sin(angle) * r;
        ctx.beginPath();
        ctx.moveTo(cx,cy);
        ctx.lineTo(x,y);
        ctx.strokeStyle = 'orange';
        ctx.stroke();
        i ++;
        if(i > count){i = 0;}
      }

大致步骤如下:

  1. 通过三角函数知识,计算出 A 点的夹角 angle1。
  2. 通过向量的点乘知识,可以计算出两个向量之间的夹角 vAngle。
  3. 通过向量叉乘计算出向量 A 和向量 B 的向量积 crossVector。
  4. 通过 crossVector 的方向,来判断向量 A 到向量 B 的运动方向是顺时针还是逆时针。如果 crossVector.z > 0 说明是逆时针,反之是顺时针。
  5. 如果是顺时针,通过 angle1 – vAngle 计算出角度 angleEnd,如果是逆时针,通过 angle1 + vAngle 计算出角度 angleEnd。
  6. 通过在 angle1 和 angleEnd 之间进行角度插值来实现动画效果。

总结:上面的方法其实还是使用角度的插值来实现动画效果,所以是角度均匀的动画。但是借助了向量工具,让起始和结束角度的计算变得容易。

向量解决方案三

方案一的问题在于,向量 A 到向量 B 之间的线性插值是直线均匀的,但是不是角度均匀的。如果我们把线性插值的插值因子改成角度均匀,而仍然使用线性插值的计算方式,就可以解决方案一的问题。这要借助三角函数的知识,先看下图:

首先通过向量点乘,可以计算出角 AOB 的夹角 vAngle,假定运动的角度为 θ,此时运动点在 X 处,通过三角函数知识可以得到:

AM = MB = OA Math.sin(vAngle/2) = r Math.sin(vAngle/2) ;
其中 r 为半径
OM = OA Math.cos(vAngle/2) = r Math.cos(vAngle/2) ;
因此可以算出
XM = OM * Math.tan(vAngle/2 – θ),
最终可以计算出 AX 的长度为
AX = AM – XM = r Math.sin(vAngle/2) – r Math.cos(vAngle/2) *Math.tan(vAngle/2 – θ)

通过以上计算公式,可以计算出基于角度的线性插值的插值因子 s = AX/AB。带入插值因子,结合向量的线性插值即可实现角度均匀的动画效果, 代码如下:

function animateVector3(){
        var a = i / count;
        var vAngle = v1.angleTo(v2); // 通过向量计算夹角
        var stepAngle = a * vAngle; // 
        var halfLength = r * Math.sin(vAngle/2);
        var stepLength = halfLength - r * Math.cos(vAngle/2)* Math.tan(vAngle/2 - stepAngle);
        a = stepLength / (halfLength * 2); // 弧线到直线上的映射关系:0.5 - Math.cos(vAngle/2)* Math.tan(vAngle/2 - stepAngle) / (Math.sin(vAngle/2) * 2)
        // a = 0.5 - Math.cos(vAngle/2)* Math.tan(vAngle/2 - stepAngle) / (Math.sin(vAngle/2) * 2);
        var v = new Vec2().lerpVectors(v1,v2,a); // 向量插值
        v.setLength(r);
        i ++;
        if(i > count){i = 0;}  
        ctx.beginPath();
        ctx.moveTo(cx,cy);
        ctx.lineTo(v.x + cx,v.y + cy);
        ctx.strokeStyle = 'orange';
        ctx.stroke();}

回到角度适配方案

下面这段转换代码可以达到角度适配的效果,此处列出代码,不进行说明,有兴趣的读者,可以自己研究。可以看出,稍显复杂。

 var i = 0,count = 200;
 var PI = Math.PI;
function animateAngle2() {
          var angleStart,angleEnd;
          if(Math.sign(angle1) == Math.sign(angle2)){return animateAngle();
          }else{if(angle1 < 0 && angle1 +2*PI > angle2 + PI){return animateAngle();
              }else if(angle2 < 0 && angle2 +2*PI > angle1 + PI){return animateAngle();
              }else if(angle1 < 0){
                  angleStart = angle1 + 2 * PI;
                  angleEnd = angle2;
              }else{
                  angleStart = angle1;
                  angleEnd = angle2 + 2 * PI;
              }
          }
       
           var angle = (angleStart * (count-i) + angleEnd * (i)) / count;
           var x = cx + Math.cos(angle) * r,
                y = cy + Math.sin(angle) * r;
            ctx.beginPath();
            ctx.moveTo(cx,cy);
            ctx.lineTo(x,y);
            ctx.strokeStyle = 'red';
            ctx.stroke();
            i ++;
            if(i > count){i = 0;}
      }

球面的情况

上面解决了圆环的情况,如果是球面的情况,如果是通过角度转换的方式,则非常复杂。
而通过向量的方式:

  • 向量解决方案一和向量解决方案三,可以平滑的移植到球面运动的情况,复杂度并没有提高。
  • 向量解决方案二,需要做一些的调整,才可以方便的移植到球面的情况,这里面涉及到一些坐标系变换的知识,稍微复杂,此处不讲述。有兴趣的同学,可以留言点赞。如果有很多人希望了解,我会在写一篇文章来讲解这个问题。

当然 如果学过三维的同学一定知道四元数的相关知识, 通过四元数可以很方便的实现球面插值,这超过本文的范围,不讲述,有兴趣的同学自己了解吧。

总结

可以看出:
通过角度转换的方式来实现圆环或者球面上面的动画,要适配很多情况,比较复杂。
而通过向量来实现圆环或者球面上面的动画,会变得简单和容易理解。

这也是为什么当时同事 J 自己研究了一上午也没有做出来,实现的效果,总是一会儿行,一会儿不行。而他在理解了向量的解决方案之后,10 分钟便写出了健壮的动画效果代码。

本文整体代码

关注公众号留言获取。

欢迎关注公众号“ITman 彪叔”。彪叔,拥有 10 多年开发经验,现任公司系统架构师、技术总监、技术培训师、职业规划师。熟悉 Java、JavaScript、Python 语言,熟悉数据库。熟悉 java、nodejs 应用系统架构,大数据高并发、高可用、分布式架构。在计算机图形学、WebGL、前端可视化方面有深入研究。对程序员思维能力训练和培训、程序员职业规划有浓厚兴趣。

退出移动版