记得几年前,个人一个同事J须要作一个动画功能,大概的需求是
实现球面上一个点到另一个点的动画。当时他遇到了难度,在研究了一个上午无果的状况下,咨询了我。我就告诉他说,你先尝试一个简化的版本,就是实现圆环上一个点到另一个点的动画。以下图所示,要实现点A插值渐变到B的动画过程。javascript
同事J的解决方案是,先计算出来A点和圆心O的连线和水平方向(与X轴平行)的夹角1,再计算出B点和圆心O的连线和水平水平方向的夹角2。 计算出夹角之后,开始实现动画效果,因为已经有了两个角度,因此只须要实现一个角度不断插值变化的效果便可,以下图所示:前端
可是这儿存在一个问题,好比下图中。java
从A点和B点的位置变化从图中能够看出,A点在第二象限,角度范围是π/2~π,而A点在第三象限,角度范围在 -π~-π/2(Math.atan2的计算结果)。此时从A点的角度动画到B点的角度,动画效果是从A点沿着顺时针方向绕一大圈动画到B,而不是直接从A点逆时针动画到B点。
而实际上咱们想要的结果是从A点逆时针到B点(运动的角度最小)。若是此时须要得到正确的结果,就须要作各类角度的转换适配。node
首先假设OA的坐标点为(x1,y1),注意此处是A点相对于与圆心O点的坐标,这样方便计算。而后计算出角度,咱们知道能够经过Math.atan2(y,x)来计算角度。 那么计算出来的角度的范围以下,以坐标系4个象限为分类标准:程序员
第四象限的角度范围是: -PI/2 ~-PI
以下图所示:数据库
从上面图中能够看出,象限之间的角度变换不是线性的,好比从第二象限到第三象限,角度出现了跳跃式的变换。假设A点在第二象限,B点在第三象限,以下图所示:ruby
如今假设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点移到第一象限,有下面两种状况:
正是因为有了这个角度的问题,致使这个动画实现的难度变大。同事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的顺序有关:
有了相关的向量知识,如今给出问题的解决方案,代码以下:
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到向量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、前端可视化方面有深刻研究。对程序员思惟能力训练和培训、程序员职业规划有浓厚兴趣。