このエントリでは、「ブラウザ上で Canvas アニメーションを効率的に動かすコードを書こうとした場合に、どういった API を利用すれば良いか?」をテーマに、Timer API について説明します。
このエントリに登場する Time API は、Date.getTime, Date.now, performance.now, setTimeout, setInterval, requestAnimationFrame, setImmediate です。
時刻を取得する API
Canvas アニメーションを行うには、まずは前回取得した時刻と現在の時刻との差分を取得し、それに基づいてアニメーションの変化量を求める必要があります。
以下が、JavaScript で利用可能な現在の時刻を取得するための Timer API です。下に行くほど新しく追加された API になります。
-
- 現在時刻を1970年からの日時で取得します。
var now = new Date().getTime(); // 1411972055133
- このメソッドは古くからありますが既に役目を終えています。より高速な Date.now() を使いましょう。
-
Date.now()
- 現在時刻を1970年からの日時で取得します。
var now = Date.now(); // 1411972055133
-
- ページの表示を開始してからの経過時刻を DOMHighResTimeStamp 型で取得します。
var now = performance.now(); // 238808.9190000028
- 整数部からミリセカンドの値を取得できます。小数部からはマイクロセカンドの値を取得できます。
時刻を取得する API の重みについて
現在時刻を取得するDate.now() や performance.now() はシステムコールを伴う可能性の高い、非常に重い部類のAPIです。何度も時刻を取得するようなコードはできるだけ避けてください。
function bad() {
var callbackList = [ callback1, callback2, callback3 ];
for (var i = 0, iz = callbackList; i < iz; ++i) {
callbackList[i]();
}
function callback1() { var now = Date.now(); ... }
function callback2() { var now = Date.now(); ... }
function callback2() { var now = Date.now(); ... }
}
function good() {
var callbackList = [ callback1, callback2, callback3 ];
var now = Date.now(); // ループの前に一度だけ取得する
for (var i = 0, iz = callbackList; i < iz; ++i) {
callbackList[i](now); // 現在時刻を渡す
}
function callback1(now) { ... }
function callback2(now) { ... }
function callback2(now) { ... }
}
タイミング API
滑らかな Canvas アニメーションを描画するためには、システムに一定の間隔でコールバックしてもらう必要があります。
以下は、JavaScript で利用可能な、あるタイミングで関数をコールバックする Timer API です。下に行くほど新しく追加された API になります。
-
- この関数を使うと1秒間に最大250回ほど呼びされる関数を登録できます。呼び出し間隔は 1000/250 = 4ms です。
-
setTimeout(callback:Function, delay:MillisecondInteger, [arg:Any, ...])
の形で登録し、登録から delay 時間経過後にcallback(arg, ...)
の形で一度だけコールバックされます。 - 1秒間に250回描画APIを実行しても実際のfpsは恐らく60以上にはなりません。190回は無駄になるでしょう。何よりCPUとバッテリーに優しくありません。
-
- この関数を使うと1秒間に最大62.5回ほどのアニメーションを定期的に呼び出す事が可能です。呼び出し間隔は 1000/62.5 = 16ms です。
-
setInterval(callback:Function, delay:MillisecondInteger, [arg:Any, ...])
の形で登録し、delay時間が経過するたびにcallback(arg, ...)
の形でコールバックされます。 - setInterval は最大で1秒間に 62.5回コールバックする能力があるため、何回かは無駄な描画をしてしまうことになるかもしれません。
-
window.requestAnimationFrame()
- この関数を使うと画面のリフレッシュレートに合わせて1秒間に最大60回関数を呼び出すことが可能です。呼び出し間隔は 1000/60 = 16.66ms です。
-
requestAnimationFrame(callback:Function)
の形で登録し、画面の再描画が発生する直前にcallbback(time:DOMHighResTimeStamp, ...)
の形で一度だけコールバックされます。 - time には現在時刻が DOMHighResTimeStamp 型で格納されています。
- 画面の描画負荷に合わせて無理なく描画が行えるため、CPUとバッテリーにやさしい実装ができます。
- iOS 7 では 1000/60 = 16.66ms で動作していましたが、iOS 8 からは 1000/30 = 33.33ms で動作するようになりました。詳しい理由は不明です。
- 名前を縮めて RAF と呼ばれる事もあります。
-
- この関数を使うと待機中のタスクが無くなったタイミングでコールバックする関数を登録できます。
-
setImmediate(callback:Function, [arg:Any, ...])
の形で登録し、JavaScriptを実行するスクリプトエンジンのタスク(キュー)が空になったタイミングでcallback(arg, ...)
の形でコールバックされます。 - 暇になったタイミングで何かしたい(例: バックグラウンドでダウンロードを行いたい)場合など、色々と使いようはありそうです。
- 現在のところ IE 以外では利用できませんが、setImmediate が実装されている環境では、setTimeout(callback, 0) を setImmediate(callback) で置換できます。この場合 setImmediate はより効率的に動作します。
Timer API implement status
ここまでの説明で、理想的な Canvasアニメーションを実装するには、requestAnimationFrame() と performance.now() を使うと良い、ということまでは説明したのですが、
ターゲット環境で実際に利用可能かどうかを事前に調べる必要があります。
今回は caniuse.com で実装状況を調べてみました。
API | AOSP | Chrome | Safari | IE |
---|---|---|---|---|
Date#getTime | YES | YES | YES | YES |
Date.now | YES | YES | YES | 9+ |
performance.now | 4.4+ | YES | 8+ | 10+ |
setTimeout | YES | YES | YES | YES |
setInterval | YES | YES | YES | YES |
requestAnimationFrame | 4.4+ | YES | 6.1+ | 10+ |
setImmediate | NO | NO | NO | 10+ |
- AOSP は
Android Open Source Project Stock Browser
つまり Android Browser です。 - Android Browser は 4.4 から中身が Chromium をベースとした Chrome WebView に入れ替わっており、Chrome とほぼ一緒です(一部例外はあります)。
こうしてみると、Android Browser を何とかすれば performance.now() と requestAnimationFrame() を使った今どきな実装が可能になります。
Android Browser で開いたページを Chrome for Android で開き直すには、ChromeTrigger.js を使うという手があります。
タイマーの時間解像度(分解能)について
setTimeout の分解能は HTML5 で規定されているように最小で 4ms です。
つまり setTimeout(callback, 4) と setTimeout(callback, 0) は同じ結果になります。
HTML5 が規定される以前は、IE 6,7,8 が 15ms, Firefox が 10ms, Chrome が 1ms とブラウザごとにバラバラでした。
さすがにこの状況はまずいという事で、現在は最小で 4ms と規定されています。
用途毎の分解能について
できるだけ高速にアニメーションをさせようとタイマーの分解能を上げ過ぎると、CPU がサスペンドする暇が無くなるため、より多くのバッテリーが消費されてしまいます。
常にCPU/GPUが全力を出さないように、用途に応じた分解能を設定すべきでしょう。
近年はバッテリーやCPU負荷の観点から、setTimeout や setInterval は使わずに、より無駄の少ない requestAnimationFrame の利用が推奨されています。
requestAnimationFrame を使うと、タイマーの解像度をどれぐらいにすべきか? 環境毎の違いはどうやって吸収すべきか? といった悩みからも解放されます。お勧めです。
状況に応じた分解能の変化とブーストについて
最近のモバイルブラウザは、ノンアクティブなページ(PageHide状態)になると、タイマーの分解能が 1s に落ちます。つまり、setTimeout(, 4) が setTimeout(, 1000) として機能するようになるということです。
他にも、ACアダプターを接続している時はフルパワーで、バッテリーで駆動しているとペースを変えるIE 9などのブラウザも存在します。
Flash Player も同様にタイマーの分解能を制御します。
サブシステムがバラバラのタイマーで動作する危うさについて
システムの中にサブシステムが複数ある状況で、
各サブシステムがバラバラのタイマーで動作していると、
タイミングの不整合によるズレや、フレームスキップなどが発生する事があります。
このバグは非常に厄介なことに、「後から気がついても修正できない」事が多々有ります。
例:
- サブシステム A, B, C があり、C が描画を行う
- A は setTimeout(callback,4) で動作しており、時々 B に指示を出す
- B は setInterval(callback,16) で動作しており、B からの指示を受けて情報を収集し C に描画指示を出す
- C は requestAnimationFrame で動作し、B からの指示を受けて描画を行う
このようなバラバラのタイマーで動くシステムの描画タイミングはコントロールもできません。
描画されるべきアニメーションが一瞬で終わってしまうフレームスキップや、アニメーション中のプチフリが発生し、バッテリーもCPUも浪費してしまいます。
ベースクロックという考え方
システムで共通のタイマーを使用するようにすると、このような問題に対応することができます。
https://github.com/uupaa/WMClock.js/wiki/WMClock などがこの用途に向いているといえるでしょう。
WMClock は WMBaseTime.js を使用しているため、複数の端末でタイムラインを同期させたり、ある端末で保存したイベントを他の端末でリプレイするといった操作も簡単になります。
var clock = new WMClock({ vsync: true });
clock.on(tick);
window.onload = function() { clock.start(); } // マスタークロックの供給開始
function tick(time, // @arg Number - elapsed time. クロックが供給開始されてからの経過時間
delta) { // @arg delta - delta time. 前回のtick呼び出しからの経過時間
console.log(time, delta);
}
Canvas Animation demo
Canvas API の getImageData, putImageData を使い、3万個のドット(particle)を飛ばす DEMO で、各Timer APIのスコアを測ってみました。
Score
電源を1〜2度再投入し、動作開始から10秒ほど経過させ、数値が安定した状態で fps を測定しています。
Mobile Safari | setTimeout | setInterval | requestAnimationFrame |
---|---|---|---|
iPod touch 4G (iOS 5.0.1) | 8.5 | 8.0 | -- |
iPhone 3GS (iOS 4.3.3) | 5.1 | 5.2 | -- |
iPhone 3GS (iOS 6.1) | 4.1 | 4.1 | 4.1 |
iPhone 4 (iOS 5.0.1) | 9.0 | 9.0 | -- |
iPhone 4S (iOS 5.0) | 13.5 | 14.1 | -- |
iPhone 4S (iOS 5.1.1) | 14.6 | 15.0 | -- |
iPhone 4S (iOS 7.1) | 34.0 | 34.2 | 34.6 |
iPhone 5 (iOS 6.0.1) | 16.0 | 18.3 | 18.0 |
iPhone 5 (iOS 6.1.0) | 16.0 | 18.0 | 18.0 |
iPhone 5 (iOS 7 beta) | 59.0 | 57.0 | 59.0 |
iPhone 5 (iOS 7 beta2) | 59.0 | 57.0 | 59.0 |
iPhone 5 (iOS 7 beta4) | 64.2 | 58.8 | 60.0 |
iPhone 5 (iOS 7 beta6) | 63.5 | 58.4 | 60.0 |
iPhone 5 (iOS 7 GM seed) | 66.4 | 59.1 | 60.0 |
iPhone 5 (iOS 7.0.3) | 65.2 | 59.2 | 60.0 |
iPhone 5 (iOS 7.1 beta5) | 66.3 | 59.1 | 60.4 |
iPhone 5 (iOS 8.0 beta3) | 49.6 | 59.8 | 51.6 |
iPhone 5 (iOS 8.0 beta5) | 56.9 | 59.9 | 30.0 |
iPhone 5 (iOS 8.0 GM Seed) | 55.4 | 59.9 | 30.0 |
iPhone 5 (iOS 8.0.2) | 56.2 | 59.9 | 30.0 |
iPhone 5 (iOS 8.1 beta) | 55.3 | 60.0 | 30.0 |
iPhone 5 (iOS 8.1) | 53.32 | 59.9 | 30.0 |
iPhone 5 (iOS 8.1.1) | 56.63 | 60.0 | 30.0 |
iPhone 5 (iOS 8.2 beta) | 50.66 | 60.0 | 30.0 |
iPad 2 (iOS 4.3.3) | 12.9 | 12.9 | -- |
iPad 2 (iOS 5.0) | 16.4 | 17.6 | -- |
iPad 2 (iOS 6 beta) | 12.8 | 12.4 | 12.5 |
iPad 3 (iOS 5.1) | 17.3 | 18.3 | -- |
iPad 3 (iOS 6.1.3) | 12.7 | 12.0 | 12.6 |
iPad 3 (iOS 7 beta2) | 35.5 | 35.4 | 35.4 |
iPad 3 (iOS 7 beta4) | 40.2 | 40.4 | 39.9 |
iPad mini Retina (iOS 7.1.2) | 99.8 | 40.4 | 39.9 |
Android
Android Browser | setTimeout | setInterval | requestAnimationFrame |
---|---|---|---|
HTC Desire HD (OS 2.2, 533.1) | 5.2 | 3.9 | -- |
SoftBank 005SH (OS 2.2.1, 533.1) | 5.2 | 5.2 | -- |
GALAXY Nexus (OS 4.0.1, 534.40) | 11.9 | 13.4 | -- |
NW-Z1000 (OS 2.3.4, 533.1) | 3.0 | 3.1 | -- |
NW-Z1000 (OS 4.0.4, 534.30) | 11.3 | 12.1 | -- |
Chrome for Android | setTimeout | setInterval | requestAnimationFrame |
---|---|---|---|
GALAXY Nexus (OS 4.0.1 Chrome beta) | 18.4 | 21.5 | 17.2 |
Nexus 7 (2012, OS 4.1, Chrome 18) | 38.0 | 42.0 | 18.2 |
Nexus 7 (2012, OS 4.2, Chrome 18) | 38.0 | 42.0 | 18.2 |
Nexus 7 (2012, OS 4.2.2, Chrome 28 beta) | 28.8 | 31.1 | 29.8 |
Nexus 7 (2012, OS 4.3.0, Chrome 29 beta) | 38.6 | 41.1 | 29.8 |
Nexus 7 (2012, OS 4.3.0, Chrome 31 beta) | 38.6 | 39.9 | 39.2 |
Nexus 7 (2012, OS 4.4.0, Chrome 32 beta) | 37.4 | 40.6 | 36.6 |
Nexus 7 (2012, OS 5.0.0, Chrome 39 beta) | 44.1 | 32.4 | 28.1 |
Other
Opera for Android (WebKit based) | setTimeout | setInterval | requestAnimationFrame |
---|---|---|---|
NW-Z1000 (OS 2.3.4, OPR/14) | 25.4 | 27.8 | 26.4 |
Nexus 7 (2012, OS 4.2.2, OPR/14) | 33.1 | 37.8 | 30.0 |
Nexus 7 (2012, OS 4.3.0, OPR/15) | 29.8 | 29.8 | 29.8 |
Game Console (WebKit based) | setTimeout | setInterval | requestAnimationFrame |
---|---|---|---|
Wii U Browser (534.52) | 7.5 | 8.3 | -- |
Firefox Mobile | setTimeout | setInterval | requestAnimationFrame |
---|---|---|---|
HTC Desire HD (OS 2.2, Fx 5) | 20.2 | -- | 20.5 |
GALAXY Nexus (OS 4.0.1 Fx 10) | 14.3 | 14.3 | 14.3 |
Nexus 7 (2012, OS 4.1, Fx 16) | 15.2 | 15.4 | 15.2 |
Nexus 7 (2012, OS 4.3.0, Fx 25) | 48.7 | 49.1 | 49.2 |
Desktop
in White MacBook with BootCamp + Windows 7 + 4GB MEM
Desktop Browser | setTimeout | setInterval | requestAnimationFrame |
---|---|---|---|
IE 9.0.1 (x86) | 46.0 | 45.0 | -- |
IE 9.0.1 (x64) | 17.0 | 17.0 | -- |
IE 10 pp1 | 42.5 | 45.0 | -- |
IE 10 pp2 | 40.0 | 40.0 | 44.0 |
Safari 5.1.2 | 45.0 | 52.0 | -- |
Firefox 10 | 125.0 | 62.5 | 60.0 |
Firefox 15.1 | 100.0 | 62.0 | 60.0 |
Chrome 17 | 141.0 | 60.0 | 60.0 |
Opera 11.61 | 110.0 | 62.5 | -- |
Opera 12 | 100.0 | 62.8 | -- |