资讯专栏INFORMATION COLUMN

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

ybak / 1796人阅读

摘要:方向向量与向量的向量积的方向与这两个向量所在平面垂直,且遵守右手定则。向量解决方案三方案一的问题在于,向量到向量之间的线性插值是直线均匀的,但是不是角度均匀的。

记得几年前,我的一个同事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;
        }
      }

大致步骤如下:

通过三角函数知识,计算出A点的夹角angle1。

通过向量的点乘知识,可以计算出两个向量之间的夹角vAngle。

通过向量叉乘计算出向量A和向量B的向量积crossVector。

通过crossVector的方向,来判断向量A到向量B的运动方向是顺时针还是逆时针。如果crossVector.z > 0说明是逆时针,反之是顺时针。

如果是顺时针,通过 angle1 - vAngle计算出角度angleEnd,如果是逆时针,通过 angle1 + vAngle计算出角度angleEnd。

通过在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、前端可视化方面有深入研究。对程序员思维能力训练和培训、程序员职业规划有浓厚兴趣。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/104801.html

相关文章

  • 使用canvas绘制圆弧动画

    摘要:即,把放大为倍时,显示效果会被拉伸当不设置样式宽高时,浏览器中大小由画布大小决定在实际开发中,碰到一个例外,是在使用时,绘制的标签如果只设置画布大小时,在移动端的浏览器上显示异常,正常。回到圆弧动画,当前动画有两段,以顺时针方向这段为例。 效果预览 showImg(https://segmentfault.com/img/bVbm7UY?w=502&h=304); canvas 绘制基...

    Kyxy 评论0 收藏0
  • canvas 高仿 Apple Watch 表盘

    摘要:绘制表盘指针对指针的绘制,首先以原点为中心绘制一个圆,对延伸出来的指针思考了两种绘制方法第一种以轴左半边为例,点为起始点,以为控制点,为终点绘制三次贝塞尔曲线第二种以轴右半边为例,直接从点绘制直线到。 不知道大家童年时候有没有在手上画手表的经历,恰好最近在看 canvas ,于是就诞生了这个高仿表盘。 showImg(https://segmentfault.com/img/bV7y...

    Fourierr 评论0 收藏0
  • 无线页面动画优化实例

    摘要:无线页面本就分秒必争,更不用说当我们在无线页面中使用动画的时候。页面中元素的布局是相对的,因此一个元素的布局发生变化,会联动地引发其他元素的布局发生变化。它通知浏览器在页面重绘前执行你的回调函数。 无线页面本就分秒必争,更不用说当我们在无线页面中使用动画的时候。不管是css动画还是canvas动画,我们都需要时刻小心着,并且有必要掌握页面性能的基本分析方法。 既然我们的目标是优化,那么...

    ivydom 评论0 收藏0
  • circle_clock 简单canvas实现圆弧时钟

    摘要:渣渣成品图最近对于圆形有种特别的感情呢因为写了个就像到了用来做时钟大概会比较有趣吧所以就着手写了个这样的一个东西大概代码上错漏还是蛮多的接下来分享下关于如何开发一个圆形时钟条吧使用这次就没有采用的方法来实现圆环了因为我想要做多层嵌套的圆环觉 渣渣成品图:http://codepen.io/thewindswor... 最近对于圆形有种特别的感情呢...因为写了个cricle_proce...

    boredream 评论0 收藏0
  • circle_clock 简单canvas实现圆弧时钟

    摘要:渣渣成品图最近对于圆形有种特别的感情呢因为写了个就像到了用来做时钟大概会比较有趣吧所以就着手写了个这样的一个东西大概代码上错漏还是蛮多的接下来分享下关于如何开发一个圆形时钟条吧使用这次就没有采用的方法来实现圆环了因为我想要做多层嵌套的圆环觉 渣渣成品图:http://codepen.io/thewindswor... 最近对于圆形有种特别的感情呢...因为写了个cricle_proce...

    宠来也 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<