zoukankan      html  css  js  c++  java
  • 绕圆弧动画的向量解决方式

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

     
    image.png

    同事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/4*PI,如果按照角度插值的方式进行运动。示例代码片段入下:

          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的角度加上2PI,可以解决上面的问题。
    但是这种方式不能解决所有的情况,比如把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、前端可视化方面有深入研究。对程序员思维能力训练和培训、程序员职业规划有浓厚兴趣。

     
    ITman彪叔公众号
  • 相关阅读:
    HTMLPARSER.NET 参考资料
    C#线程篇---Task(任务)和线程池不得不说的秘密(5)
    C#控制定位Word光标移动到任意行或者最后一行,取得光标位置等操作
    处理Selection对象和Range对象——Word VBA中重要的两个对象
    C# word开发
    c# 解析JSON的几种办法
    NameValueCollection详解
    解析jquery获取父窗口的元素
    Window_Open详解
    Implementing Remote Validation in MVC
  • 原文地址:https://www.cnblogs.com/flyfox1982/p/11044300.html
Copyright © 2011-2022 走看看