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