记得几年前,我的一个同事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个象限为分类标准:
从上面图中可以看出,象限之间的角度变换不是线性的,比如从第二象限到第三象限,角度出现了跳跃式的变换。假设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点移到第一象限,有下面两种情况:
两种情况
正是由于有了这个角度的问题,导致这个动画实现的难度变大。同事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、前端可视化方面有深入研究。对程序员思维能力训练和培训、程序员职业规划有浓厚兴趣。
ITman彪叔公众号