記得幾年前,個人一個同事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、前端可視化方面有深刻研究。對程序員思惟能力訓練和培訓、程序員職業規劃有濃厚興趣。