JavaScript基于時間的動畫算法
來自: http://web.jobbole.com/85061/
目錄
- 前言
- 基于幀的動畫算法(Frame-based)
- 基于時間的動畫算法(Time-based)
- 改良基于時間的動畫算法
- 總結
前言
前段時間無聊或有聊地做了幾個移動端的HTML5游戲。放在不同的移動端平臺上進行測試后有了詭異的發現,有些手機的動畫會“快”一點,有些手機的動畫會“慢”一點,有些慢得還不是一兩點。
通過查找資料發現,基于幀的算法(Frame-based)來實現動畫會導致不同幀率的平臺體驗不一致,而基于時間(Time-based)的動畫算法可以很好地改良這種情況,讓不同幀率的情況下都能達到較為統一的速度上的體驗。
本文介紹的就是基于幀動畫算法和基于時間動畫算法的差異,以及對基于時間算法的改良。
基于幀的動畫算法(Frame-based)
相信做過前端的人對使用JavaScript實現動畫的原理都很熟悉。現在讓你實現一個讓一個div從左到右來回移動的JS代碼,你可能嗖嗖就寫出來了:
function moveDiv(div, fps) { var left = 0; var param = 1;
function loop () { update(); draw(); } function update() { left += param * 2; if (left > 300) { left = 300; param = -1; } else if (left 0) { left = 0; param = 1; } } function draw() { div.style.left = left + "px"; } setInterval(loop, 1000 / fps); } moveDiv(document.getElementById("div1"), 60);
function moveDiv(div, fps) { var left = 0; var param = 1; function loop () { update(); draw(); } function update() { left += param * 2; if (left > 300) { left = 300; param = -1; } else if (left 0) { left = 0; param = 1; } } function draw() { div.style.left = left + "px"; } setInterval(loop, 1000 / fps); } moveDiv(document.getElementById("div1"), 60);</div>
</div>
效果如下:
http://jsfiddle.net/livoras/4taf9hhs/embedded/result,js,html,css/
看看代碼,我們讓一個div在0 ~ 300px區間內左右來回移動。 update 計算更新描繪div的位置, draw 重新描繪頁面上的div。為了方便起見,這里直接使用 setInterval 作為定時器,實際情況下可以采用你喜歡的 setTimeout 或者 requestAnimationFrame 。這里設置每秒鐘到更新60次,60fps是人盡皆知的比較適合做動畫的幀率。
地球人都知道,JavaScript中的定時器是不準確的。由于JavaScript運行時需要耗費時間,而JavaScript又是單線程的,所以如果一個定時器如果比較耗時的話,是會阻塞下一個定時器的執行。所以即使你這里設置了 1000 / 60 每秒60幀的幀率,在不同的瀏覽器平臺的差異也會導致實際上你的沒有60fps的幀率。
所以上面代碼在一個手機上執行的時候可能有60fps的幀率,在另外一個手機上可能就只有30fps,更甚可能只有10fps。
我們模擬一下這種情況會有什么效果發生:
http://jsfiddle.net/livoras/Lcv1jm53/embedded/result,js,html,css/
這完全不對大頭!
可以看到三個方塊移動速度根本不在同一個channel上。想象一下一個超級馬里奧游戲在10fps的情況會怎么樣?按跳躍一下,你會看到馬里奧以一種太空漫游的姿態在空中拋弧線。
導致這種情況的原因很簡單,因為我們計算和繪制每個div位置的時候是在每幀更新,每幀移動2px。在60fps的情況下,我們1秒鐘會執行60幀,所以小塊每秒鐘會移動60 * 2 = 120px;如果是30fps,小塊每秒就移動30 * 2 = 60px,以此類推10fps就是每秒移動20px。
三個小塊在單位時間內移動的距離不一樣!
假如你現在要做一個超級馬里奧的游戲,怎么做到可以在不同幀率的情況下讓馬里奧看起來還是那么迅速且帥氣?
解決方案很明顯。雖然不同的瀏覽器平臺上的運行差異可能會導致幀率的不一致,但是有一樣東西是在任何平臺上都一致的,那就是時間。所以我們可以改良我們的算法,不是以幀為基準來更新方塊的位置,而是以時間為單位更新。也就是說,我們之前是 px/frame ,現在換成 px/ms 。
這就是接下來要說的基于時間(Time-based)的動畫算法。
基于時間的動畫算法(Time-based)
其實思路和實現都很簡單。我們計算每一幀離上一幀過去了多少時間,然后根據過去的時間來更新方塊的位置。
例如,上面的方塊應該每秒鐘移動120px,每毫秒移動120 / 1000 = 0.12像素(12px/ms)。如果上一幀方塊的位置在left為10px的位置,到了這一幀的時候,假設相對于上一幀來說時間過去了200ms,那在時間上來說在這一幀方塊應該移動200ms * 0.12px/ms = 240px。最終位置應該為10 + 240 = 250px。其實就是 left = left + detalTime * speed 。代碼如下:
function moveDivTimeBased(div, fps) { var left = 0; var current = +new Date; var previous = +new Date; var param = 1;
function loop() { var current = +new Date; var dt = current - previous; // 計算時間差 previous = current; update(dt); draw() } function update(dt) { left += param * (dt * 0.12); // 根據時間差更新位置 if (left > 300) { left = 300; param = -1; } else if (left 0) { left = 0; param = 1; } } function draw() { div.style.left = left + "px"; } setInterval(loop, 1000 / fps); }
function moveDivTimeBased(div, fps) { var left = 0; var current = +new Date; var previous = +new Date; var param = 1; function loop() { var current = +new Date; var dt = current - previous; // 計算時間差 previous = current; update(dt); draw() } function update(dt) { left += param * (dt * 0.12); // 根據時間差更新位置 if (left > 300) { left = 300; param = -1; } else if (left 0) { left = 0; param = 1; } } function draw() { div.style.left = left + "px"; } setInterval(loop, 1000 / fps); }</div>
</div>
看看效果如何:
http://jsfiddle.net/livoras/8da1nssL/embedded/result,js,html,css/
看起來比上面的好多了,30fps和10fps好像能勉強趕上60fps的步伐。但是時間久了會發現30fps和10fps越來越落后于60fps。(建議先刷新再看看效果會更加明顯)
這是因為 每次小方塊碰到邊緣的時候,都會損失掉一部分時間,而且幀率越低的損失越大。 看看我們上面的 update 函數:
function update(dt) { left += param * (dt * 0.12); // 根據時間差更新位置 if (left > 300) { left = 300; param = -1; } else if (left 0) { left = 0; param = 1; } }
function update(dt) { left += param * (dt * 0.12); // 根據時間差更新位置 if (left > 300) { left = 300; param = -1; } else if (left 0) { left = 0; param = 1; } }
</div>
假如我們現在方塊的位置在left為290px的位置,這一幀傳入的dt為100ms,那么我們left為290 + 100 * 0.12 = 302,但是302大于300,所以left會被設置為300。那么本來用來移動2px的時間就會白白被“拋棄”掉。dt越大,浪費得越多,所以30fps和10fps會比60fps越來越慢。
為了解決這個問題,我們對已有的算法進行改良。
改良基于時間的動畫算法
解決思路如下:不一次算整塊的時間(dt)移動的距離,而是把dt分成固定的時間片,通過多次update固定的時間片來計算dt時間后應該到什么位置。
比較抽象,我們直接看代碼:
function moveDivTimeBasedImprove(div, fps) { var left = 0; var current = +new Date; var previous = +new Date; var dt = 1000 / 60; var acc = 0; var param = 1; function loop() { var current = +new Date; var passed = current - previous; previous = current; acc += passed; // 累積過去的時間 while(acc >= dt) { // 當時間大于我們的固定的時間片的時候可以進行更新 update(dt); // 分片更新時間 acc -= dt; } draw(); } // update 和 draw 函數不變 setInterval(loop, 1000 / fps); }
function moveDivTimeBasedImprove(div, fps) { var left = 0; var current = +new Date; var previous = +new Date; var dt = 1000 / 60; var acc = 0; var param = 1; function loop() { var current = +new Date; var passed = current - previous; previous = current; acc += passed; // 累積過去的時間 while(acc >= dt) { // 當時間大于我們的固定的時間片的時候可以進行更新 update(dt); // 分片更新時間 acc -= dt; } draw(); } // update 和 draw 函數不變 setInterval(loop, 1000 / fps); }
</div>
我們先確定一個固定更新的時間片,如固定為60fps時一幀的時間:1000 / 60 = 0.167ms。然后積累過去的時間,然后根據固定時間片分片進行更新。也就說,即使這一幀和上一幀相差過去了100ms,我也會把這100ms分成很多個0.167ms來執行update函數。這樣做有兩個好處:
- 固定的時間片足夠小,更新的時候可以減少邊緣損失的時間。
- 不同幀率,不管你是60,30,還是10fps,也是根據固定時間片來執行update函數,所以即使有損失,不同幀率之間的損失是一樣的。那么我們三個方塊就可以達到同步移動的效果的了!
看上面的代碼,update和draw函數保持不變,而loop函數中,對過去的時間進行了累加,當時間超過固定的片就可以執行update。while循環可以保證更新直到把積累的時間都更新完。
對時間進行積累,然后分固定片更新。這種方式還有一個非常大的好處,如果你的幀率超過了60fps,如達到100fps或者200fps,這時候passed會小于0.167ms,時間就會被積累,積累大于0.167才會執行更新。碉堡的效果就是: 不管你的幀率是高還是低,移動速度都可以和60fps情況下的速度同步。
看看最后的效果:
http://jsfiddle.net/livoras/25nut92z/embedded/result,js,html,css/
還是蠻不錯的。
總結
基于幀的動畫算法會在幀率不同的情況下導致動畫體驗有較大的差異,所有動畫都應該基于時間進行執行。而基于時間的動畫算法要注意邊緣時間的損失,最好采取積累時間,然后分固定片更新動畫的方式。