JS前端可視化canvas動畫原理及其推導實現(xiàn)
前言
到目前為止我們的 fabric.js 雛形已經(jīng)有了,麻雀雖小五臟俱全,我們不僅能夠在畫布上自由的添加物體,同時還實現(xiàn)了點選和框選,并且能夠?qū)λ鼈冏鲆恍┳儞Q,不過只有變換這個操作還不夠靈活,要是能夠讓物體動起來就好了,于是就引入了這個章節(jié)的主題:動畫,以及動畫最核心的一個問題,如何保證在不同的電腦上達到同樣的動畫效果?然后說干就干,立馬開擼??。
雖然我寫的是系列文章,但每個章節(jié)單獨食用是木問題的,所以,請放心大膽的看??。
動畫的本質(zhì)
先來看看在 canvas 庫中調(diào)用動畫的一般方式吧,比如我們要讓一個矩形動起來,大體是下面這樣的用法:
rect.animate( { top: 50, left: 400, angle: 45 }, // 要動畫的屬性 { duration: 1000, onChange: canvas.renderAll.bind(canvas) } // 動畫執(zhí)行時間和手動渲染 );
代碼淺顯易懂,然后我們來想想動畫的本質(zhì)是什么,為什么我們能夠看到動畫效果呢?這個大家應該都有所了解,不就是畫布重新繪制了嗎,只要重繪的足夠多足夠快,根據(jù)人的視覺殘留效應,就形成了動畫。
沒錯,大體就是這個原因,但我們可以更具體一點,想想畫布為什么要重新繪制呢?不就是因為畫布中某個物體的某個值改變了,所以我們才要更新一下畫面,以此來表示它動了。這個物體狀態(tài)值的改變才是動畫的根本原因??。
比如一個物體要花 1s 的時間從 left=100 的地方移動到 left=200 的地方,只要我不斷修改 left 值,然后不斷 renderAll 就能看到物體從左往右移動了。這很好理解,但是有個新問題出現(xiàn)了,它應該怎樣移動呢?勻速、加速還是減速?又或者是其他方式呢?其實都可以,具體要看你希望這個 left 怎么變,以怎樣的規(guī)律變化。
動畫的實現(xiàn)
既然動畫的本質(zhì)就是值的改變,那這個值的改變和哪些因素有關(guān)呢?根據(jù)剛才的例子我們可以知道大概有以下四個因素:
- 初始值:
startValue
- 結(jié)束值:
endValue
- 值的變化時間:
duration
- 怎么變(勻速、緩動還是彈動):
easing
(一個熟悉的單詞出現(xiàn)了)
顯然動畫也是一個通用的東西,所以我們把它寫在 Util 工具類里,代碼不多,直接食用就行????:
interface IAnimationOption { /** 初始值 */ startValue?: number; /** 最終值 */ endValue?: number; /** 執(zhí)行時間 */ duration?: number; /** 緩動函數(shù) */ easing?: Function; /** 動畫一開始的回調(diào) */ onStart?: Function; /** 屬性值改變都會進行的回調(diào) */ onChange?: Function; /** 屬性值變化完成進行的回調(diào) */ onComplete?: Function; } class Util { static animate(options: IAnimationOption) { window.requestAnimationFrame((timestamp: number) => { // requestAnimationFrame 會有個默認參數(shù) timestamp,單位毫秒,表示開始去執(zhí)行回調(diào)函數(shù)的時刻 // 初始化一些變量 let start = timestamp || +new Date(), // 開始時間 duration = options.duration || 500, // 動畫時間 finish = start + duration, // 結(jié)束時間 time, // 當前時間 onChange = options.onChange || (() => {}), // 值改變進行的回調(diào) easing = options.easing || ((t, b, c, d) => -c * Math.cos((t / d) * (Math.PI / 2)) + c + b), // 緩動函數(shù),不用管名字,簡單理解為一個普通函數(shù)即可,它會返回一個數(shù)值 startValue = options.startValue || 0, // 初始值 endValue = options.endValue || 100, // 結(jié)束值 byValue = options.byValue || endValue - startValue; // 值的變化范圍 function tick(ticktime: number) { // tick 的主要任務就是根據(jù)當前時間更新值 time = ticktime || +new Date(); let currentTime = time > finish ? duration : time - start; // 當前已經(jīng)執(zhí)行了多久時間(介于0~duration) onChange(easing(currentTime, startValue, byValue, duration)); // 根據(jù)當前時間和 easing 函數(shù)算出當前的動畫值是多少,easing 理解成一個普通函數(shù)就行,它會返回一個值,就像這樣:curVal = f(x) = easing(currentTime) if (time > finish) { // 動畫結(jié)束 options.onComplete && options.onComplete(); // 動畫完成的回調(diào) return; } window.requestAnimationFrame(tick); // 循環(huán)調(diào)用 tick,不斷更新值,從而形成了動畫 } options.onStart && options.onStart(); // 動畫開始前的回調(diào) tick(start); // 開始動畫 }); } }
相信上面的注釋應該解釋的清清楚楚、明明白白。不過還是要著重講下其中的兩個點:
- 一個是為什么使用 requestAnimationFrame 這個 api 來完成動畫,這應該也是個老生常談的問題了,因為 setInterval 和 setTimeout 不準,很容易出問題,比如執(zhí)行時機不準確、切換頁面回來會堆積執(zhí)行、不流暢等,并且它們也不是專門為動畫而生(當然如果你不習慣用 requestAnimationFrame 也可以直接把它換成 setTimeout,方便自己理解);
- 而 requestAnimationFrame 是按幀率刷新的,跟著幀率走的期間我們就可以不用做很多無用功,能夠更好的知道繪制下一幀的最佳時機,也比較流暢。它們的一個最主要的區(qū)別就是:
- setInterval 和 setTimeout 是主動告訴瀏覽器什么時候去繪制;
- 而 requestAnimationFrame 則是瀏覽器在它覺得可以繪制下一幀的時候通知我們(你品,你細品,就有那味了)。
當然我們肯定不能直接傻傻的像下面這樣調(diào)用????:
// 假設要從左到右運動 let left = 100; function tick() { left++; // 更新值 window.requestAnimationFrame(tick); } tick();
因為每個屏幕刷新頻率不一樣,如果像上面這樣寫,在有的電腦上就會快一些,有的電腦上就會慢一些,不僅如此在頁面切換到后臺的時候幀率也會降低,就會導致各種問題,這顯然不是我們期望的。
所以要怎么做呢?
我們應該是以時間為維度來播放動畫,因為時間對我們來說流逝的速度是一樣的,所以在動畫一開始的時候需要記錄下開始時間 start
,之后動畫播放到哪里都會以這個開始時間為基準,回頭看看剛才代碼中計算當前動畫執(zhí)行了多長時間的方式:
let currentTime = time > finish ? duration : time - start;
就是以 start
為基準的,這點很重要。
第二點是關(guān)于 easing 函數(shù),雖然好像接觸過,但還是會有很多同學對此感到疑惑,所以接下來我會專門講下這方面的內(nèi)容,比如:這個函數(shù)是干嘛的、是怎么推導的、最終又是得到什么結(jié)果、和我們平時說的緩動函數(shù)是一個東西嗎等等之類的。
動畫的推導
在講解 onChange(easing(currentTime, startValue, byValue, duration)) 這個東西之前,我們先來看看如何讓每個物體都具有動畫的方法,就是在物體基類中擴展就行了,瞟一眼就行????:
class FabricObject { // 物體基類 _animate(property, to, options: IAnimationOption = {}) { // 某個屬性要變化到哪里 options = Util.clone(options); let currentValue = this.get(property); // 獲取初始值 if (!options.from) options.from = currentValue; // 一般不傳初始值的話就默認取當前屬性值 Util.animate({ startValue: options.from, endValue: to, easing: options.easing, // 決定了值如何變化,常用的就緩動和彈動 duration: options.duration, onChange: (value) => { // value 是 easing 函數(shù)的返回值,本質(zhì)就是值的計算,value = easing() this.set(property, value); // 重新設置屬性值 options.onChange && options.onChange(); // 值改變之后,調(diào)用 onChange 回調(diào)就會重新渲染畫布,數(shù)據(jù)和視圖分開的優(yōu)點又體現(xiàn)了出來 }, onComplete: () => { this.setCoords(); // 更新物體自身的一些坐標值等 options.onComplete && options.onComplete(); // 動畫結(jié)束的回調(diào) }, }); } }
然后再強調(diào)一下,動畫的核心就是值的變化,Util.animate
中的 easing
函數(shù)其實就是計算動畫播放到 (0, duration)
中間某一時刻的值是多少,僅此而已。再來簡單說下 easing 函數(shù)吧,一般可以叫它緩動函數(shù)。
它是首先是一個函數(shù),并且會返回一個數(shù)值,類似于 y = f(x)
,在我們的例子中就是 value = easing(time, beginValue, changeValue, duration)。這個函數(shù)有四個參數(shù)(當前時間、初始值、變化量 = 結(jié)束值-初始值、動畫時間),返回的是當前時間點所對應的值 value,顯然后面三個參數(shù)是已知的,也是固定的,唯一會變化的就是當前時間,它的取值范圍就是從 0 到 duration。
執(zhí)行動畫的時候其實就是改變這個當前時間,根據(jù)當前時間我們代入 easing 函數(shù)就能夠得到對應的 value 值。
可能有同學還是不懂這個緩動函數(shù),其實是因為被上面的公式唬住了,公式都是推導之后的簡便寫法,直接去看式子是很難理解的,單憑公式在腦海中想象出動畫效果也不太現(xiàn)實,所以這里給大家簡單推導一下這種式子怎么來的,以最簡單的勻速運動為例子,看看下面這張圖????:
上面這個過程很顯然,也不用怎么推導,下面我們來看另一個更加通用的例子,首先隨便拿一個函數(shù) y = x * x
(其他的也行),順便簡單畫下函數(shù)圖像????:
綠色代表起點,也就是動畫起始值,紅色代表終點,也就是動畫結(jié)束值。x 軸就是動畫時間,y 軸就是當前的動畫值,為了方便和統(tǒng)一,我們需要把時間換算成 [0, 1]
的范圍,0 就是起點,1 就是終點,y 軸代表的值也是一樣的道理。
然后我們的起點和終點就是(0,0)和(1,1)點
(注意:雖然xy的范圍都是0到1,看起來是個正方形,但它們的單位或者說表達的意思是不一樣的,不要混淆了),起點和終點是固定不變的,中間的曲線可以隨便怎么畫,那怎么將它寫成一個緩動函數(shù)呢?
我們先看看 x 軸代表什么,x 是一個取值范圍從0到1的變量,看看我們的緩動函數(shù)有啥變量呢,就一個 currentTime,但是 currentTime 的取值范圍是從 [0, duration]
,所以我們需要把它映射成[0, 1]
,其實也就是把 currentTime / duration 就行,然后用 currentTime / duration 代替 x;
那 y 呢,y 根據(jù) x 算出來的值,代表的是當前這個時間點所對應的值,也就是我們緩動函數(shù)的 value 值,它的取值范圍在 [startValue, startValue + byValue]
之間,所以我們也需要將其變成[0, 1]
,所以 value 的值變成了這樣(value - startValue) / byValue
,那么現(xiàn)在 y 值也有了,我們就可以將它們直接代入 y = x * x
這個初始公式,就像這樣:
① y = x * x
???? 代入 x、y
② (value - startValue) / byValue = (currentTime / duration) * (currentTime / duration)
???? 整理一下
③ value = (currentTime / duration) * (currentTime / duration) * byValue + startValue
???? 簡化一下(簡化英文單詞而已??)
④ value = (t, b, c, d) => ((t/d) * (t/d) * c + b)
這個效果其實就是 easeInQuad
先慢后快的緩入效果,其他函數(shù)也是一樣的推導方式,只要你能寫出來。不過即便知道了怎么推導,你也很難有個直觀的效果,其實常見和常用的就那么幾個,網(wǎng)上也有大把封裝好的和演示的,有個印象就行(比如可以搜一下 Tween.js)。
當然你也可以看函數(shù)圖像簡單猜一下效果,具體就是看每一點的斜率,斜率越趨近于水平就越慢,斜率越趨近于豎直就越快;如果你的函數(shù)曲線中有 y 值超出了 1,就說明中間點在某一時刻會超過終點,如果有 y 值小于 0,就說明有中間點有某一時刻會小于起始點,大概是這么個意思??。
緩動函數(shù)有個很大的特點,就是起點和終點位置是確定的,中間位置你可以隨便算,可快可慢,可以超出終點,也可以小于起點,具體什么效果,你可以隨便寫個方程運行試試,然后再根據(jù)效果調(diào)試。相信你肯定見過下面這種類型的圖:
現(xiàn)在再看看,不知道會不會感到稍微親切一點點嘞???
小結(jié)
本章我們主要講解了 canvas 中動畫的實現(xiàn),其中最重要的一點就是如何在不同幀率達到同樣的動畫效果,那就是要以時間為維度來進行度量,用 canvas 做的游戲也是一樣,時間每向前 tick 一次(滴答的意思,挺形象的叫法,古老時鐘的那種感覺),畫布就會向前推進一次(重新繪制)。
然后再補充兩個小點:
- 通常情況下動畫的發(fā)生總是伴隨著畫布的重新繪制,但是默認情況下 fabric.js 并不會自動幫我們重新繪制,需要我們手動調(diào)用(可以看看開篇代碼中 onChange 的回調(diào)是咋寫的),這是因為如果畫布中有很多物體在運動,默認自動重新繪制的話會降低性能。
- 動畫不僅僅可以作用于位置,還可以作用于各種屬性,比如透明度、顏色等,其實只要是個數(shù)值就能夠進行動畫。并且歸功于我們之前將數(shù)據(jù)和視圖分離的架構(gòu),這個章節(jié)所做的一切也僅僅是改變數(shù)據(jù)而已,并不涉及畫布繪制的內(nèi)容。
然后這里是簡版 fabric.js 的代碼
以上就是JS前端可視化canvas動畫原理及其推導實現(xiàn)的詳細內(nèi)容,更多關(guān)于JS前端可視化canvas動畫的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Three.js?Interpolant實現(xiàn)動畫插值
這篇文章主要為大家介紹了Three.js?Interpolant實現(xiàn)動畫插值示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-02-02