Edited at

Canvas と requestAnimationFrame でアニメーション

requestAnimationFrame を使って Canvas の上で絵を動かします。


ゴール

ロード中っぽいぐるぐるするやつをつくります。

See the Pen Loading Circle by hoo (@hoo-chan) on CodePen.


requestAnimationFrame

requestAnimationFrame は1つの関数(以下、callback)を引数にとる関数です。実行するとブラウザが次に画面を更新する前に callback が呼ばれます。さらに次の画面更新でもやりたい処理がある場合は callback の中で requestAnimationFrame を呼びます。

requestAnimationFrame が画面の更新と同期しているおかげで、必要最低限の処理で滑らかなアニメーションが作れます。例えば理想的な setTimeout がこれより短い間隔で情報を更新できたとしても、見えるのは更新時点の情報なので直前の1回以外は無駄な処理です。


準備

Canvas を document.body に追加して resize のイベントハンドラで画面いっぱいの表示が維持されるようにします。Retina 向けに devicePixelRatio 倍しておきます。

// document.bodyにCanvasを追加する

const canvas = document.createElement('canvas');
document.body.appendChild(canvas);

// Canvasを画面いっぱいに表示する
function onResize() {
canvas.width = innerWidth * devicePixelRatio;
canvas.height = innerHeight * devicePixelRatio;
}
window.addEventListener('resize', onResize);
onResize();

// 後述
requestAnimationFrame(function (t0) {
const ctx = canvas.getContext('2d');
render(t0);
function render(t1) {
requestAnimationFrame(render);
ctx.fillStyle = '#55c500';
ctx.fillRect(0, 0, canvas.width, canvas.height);
draw(ctx, t1 - t0);
}
});

最後の部分ですが、メインの requestAnimationFrame のループを別の requestAnimationFrame の callback の中にいれます。ここでアニメーションが始まった時刻 t0 を定義すると、中の callback のタイムスタンプ t1 との差 t1 - t0 がアニメーション開始からの時間になります。初回は t1 として t0 を渡せば、0 から始まります。これは「N 秒後に終わらせる」ような場合に便利です。

const duration = 3000;

function render(t1) {
const 進捗 = ((t1 - t0) % duration) / duration;
if (進捗 < 1) {
requestAnimationFrame(render);
} else {
// おわり
}
}

今回は無限ループするアニメーションなので、開始位置が決まるくらいしかうまみはありません。

callback の中にある draw は画面の更新に合わせて呼ばれます。ここまでで requestAnimationFrame の話は終わりです。あとは draw の中で Canvas に絵を描く話になります。


時間を表示してみる

とりあえず動いているのを確認するために現在時刻とフレームレートを表示してみます。タイムスタンプ t はミリ秒なので直前の t との差で 1000 を割ればフレームレートになります。

function draw(ctx, t) {

const canvas = ctx.canvas;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = devicePixelRatio * 20 + 'px Courier,monospace';
ctx.fillStyle = '#ffffff';
ctx.fillText(
new Date().toISOString(),
canvas.width / 2,
canvas.height / 2
);
ctx.fillText(
(1000 / (t - draw.lastT)).toFixed(3) + 'FPS',
canvas.width / 2,
canvas.height / 2 + devicePixelRatio * 20
);
draw.lastT = t;
}

See the Pen Loading Circle 1 by hoo (@hoo-chan) on CodePen.


円を描く

arc() を画面の中心に持っていってもいいですが translate で (0, 0) を画面の中心に持っていくと楽です。draw の最初で save() 最後で restore() して draw 内での ctx への変更を draw の外に出さないようにします。

function draw(ctx) {

ctx.save();
const canvas = ctx.canvas;
// 時刻を書く部分は省略しています
const radius = 0.4 * Math.min(canvas.width, canvas.height);
ctx.beginPath();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.arc(0, 0, radius, 0, 2 * Math.PI);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = devicePixelRatio;
ctx.stroke();
ctx.restore();
}

See the Pen Loading Circle 3 by hoo (@hoo-chan) on CodePen.


動く円を描く

t から 2 秒周期で変化する量(phase)をつくり arc() の最後の引数にかけてみます。線を太くしました。

function draw(ctx, t) {

ctx.save();
const canvas = ctx.canvas;
// 時刻を書く部分は省略しています
const phase = (t % 2000) / 2000;
const radius = 0.4 * Math.min(canvas.width, canvas.height);
ctx.beginPath();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.arc(0, 0, radius, 0, 2 * Math.PI * phase);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = radius / 6;
ctx.lineCap = 'round';
ctx.stroke();
ctx.restore();
}

See the Pen Loading Circle 3 by hoo (@hoo-chan) on CodePen.


追いかける

phase を phase1 として、これが 0.5 になったら追いかけてくる量(phase2)を作りました。右側になっていた端点が上になるように rotate を追加しました。

function draw(ctx, t) {

ctx.save();
const canvas = ctx.canvas;
// 時刻を書く部分は省略しています
const phase1 = (t % 2000) / 2000;
const phase2 = 2 * Math.max(phase1 - 0.5, 0);
const radius = 0.4 * Math.min(canvas.width, canvas.height);
const PI2 = Math.PI * 2;
ctx.beginPath();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(-0.5 * Math.PI); // 反時計回りに1/4回転
ctx.arc(0, 0, radius, PI2 * phase2, PI2 * phase1);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = radius / 6;
ctx.lineCap = 'round';
ctx.stroke();
ctx.restore();
}

See the Pen Loading Circle 4 by hoo (@hoo-chan) on CodePen.


cubicBezierを使う

速さが一定だとなめらか感に欠けるのでゆっくり始まってゆっくり終わるようにしたいです。CSS の cubic-bezier っぽく使える generateCSSCubicBezierを使います。

const easeInOut = generateCSSCubicBezier(0.42, 0, 0.58, 1);

function draw(ctx, t) {
ctx.save();
const canvas = ctx.canvas;
// 時刻を書く部分は省略しています
const phase1 = (t % 2000) / 2000;
const phase2 = 2 * Math.max(phase1 - 0.5, 0);
const x1 = easeInOut(phase1);
const x2 = easeInOut(phase2);
const radius = 0.4 * Math.min(canvas.width, canvas.height);
const PI2 = Math.PI * 2;
ctx.beginPath();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(Math.PI / -2);
ctx.arc(0, 0, radius, PI2 * x2, PI2 * x1);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = radius / 6;
ctx.lineCap = 'round';
ctx.stroke();
ctx.restore();
}

See the Pen Loading Circle 5 by hoo (@hoo-chan) on CodePen.


全体を回転させる

全体が回転するように rotate の引数を変えました。

function draw(ctx, t) {

ctx.save();
const canvas = ctx.canvas;
// 時刻を書く部分は省略しています
const phase1 = (t % 2000) / 2000;
const phase2 = 2 * Math.max(phase1 - 0.5, 0);
const phase3 = (t % 5000) / 5000; // 5 秒周期
// 0.42, 0.00, 0.58, 1.00 は CSS の ease-in-out と同じ
const x1 = cubicBezier(0.42, 0.00, 0.58, 1.00, phase1);
const x2 = cubicBezier(0.42, 0.00, 0.58, 1.00, phase2);
const radius = 0.4 * Math.min(canvas.width, canvas.height);
const PI2 = Math.PI * 2;
ctx.beginPath();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(2 * Math.PI * phase3); // 5 秒で 1 周する
ctx.arc(0, 0, radius, PI2 * x2, PI2 * x1);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = radius / 6;
ctx.lineCap = 'round';
ctx.stroke();
ctx.restore();
}

わかりにくかったので下の例は補助線が入っています。

See the Pen Loading Circle 6 by hoo (@hoo-chan) on CodePen.