繞圓弧動畫的向量解決方式

記得幾年前,個人一個同事J須要作一個動畫功能,大概的需求是
實現球面上一個點到另一個點的動畫。當時他遇到了難度,在研究了一個上午無果的狀況下,諮詢了我。我就告訴他說,你先嚐試一個簡化的版本,就是實現圓環上一個點到另一個點的動畫。以下圖所示,要實現點A插值漸變到B的動畫過程。
image.png前端

同事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個象限爲分類標準:程序員

  • 第一象限的角度範圍是: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、前端可視化方面有深刻研究。對程序員思惟能力訓練和培訓、程序員職業規劃有濃厚興趣。
ITman彪叔公衆號

相關文章
相關標籤/搜索