当記事ではrequestAnimationFrameにフォーカスし、ブラウザゲームを開発する際に必要なフレームスキップ処理について紹介する。
requestAnimationFrameとsetInterval, setTimeoutは何が違うのか?
requestAnimationFrame
とsetInterval
setTimeout
には明確な違いがある。requestAnimationFrame
はブラウザの状態により実行間隔を自動で調整してくれるのに対し、setInterval
setTimeout
は時間どおりに実行するため、処理がすべて終わる前に次の処理が実行されてしまう。そのため、ブラウザゲーム開発ではrequestAnimationFrameを使うことが多い。
ただ、わざとブラウザに負荷をかけたりバックグラウンドで実行させたりすると、描画を遅らせることができてしまう。このような処理落ちはゲームバランスを壊す要因になりうるため、フレームスキップ処理の実装が必要になってくる。
ということで、今回は処理落ちしたときにフレームスキップする方法を紹介する。
フレームスキップする場合としない場合
まずは以下のgifアニメーションを見てほしい。
上段が「フレームスキップしない」、下段が「フレームスキップする」
見ていただいてわかるように、「フレームスキップしない」は負荷がかかったときにオブジェクトが停止し、次のフレームでは同じ場所から再開している。
対して「フレームスキップする」は停止しても、次のフレームでは時間差を補完して描画しているので、不正行為ができなくなる。
フレームスキップを実装する
const fps = 60;
let now = 0;
function render(timestamp) {
// 前フレームと今フレームの時間差
const delta = Math.floor(timestamp - now);
// 時間差が16.66ms(1フレーム/fps)以上の場合はアップデート処理を実行する
while (delta >= 0) {
// TODO: アップデート処理(座標や当たり判定など)
// update()
delta -= 1000.0 / fps;
}
// TODO: 描画処理
// draw()
now = timestamp;
window.requestAnimationFrame(render);
}
window.requestAnimationFrame(render);
requestAnimationFrame
のコールバックは、引数として現在のタイムスタンプ(performance.now()
)が渡ってくるので、前フレームと今フレームの時間差を計算する。60FSPであればフレーム間は約16.66msとなる。
もしフレームの時間差が16.66ms以上だった場合は、負荷が高くなり60FPSを割っていることになる。そのため、時間差を埋めるためにwhileループでアップデート処理を行う。
アップデート処理が終わってから描画処理をすることで、フレームスキップができる。
具体的なサンプルコード
<div>
<label><input type="checkbox" id="skip" checked>フレームスキップする</label>
<button type="button" id="start">start</button>
<button type="button" id="stop">stop</button>
</div>
<canvas id="game" width="640" height="100"></canvas>
const $ = selector => document.querySelector(selector);
// Debug用
const sleep = msec => new Promise((resolve, reject) => setTimeout(resolve, msec));
class Item {
constructor({ x, y }) {
this.x = x;
this.y = y;
this.w = 40;
this.h = 40;
this.way = 1;
}
draw(game) {
game.ctx.save();
game.ctx.fillStyle = 'red';
game.ctx.fillRect(this.x, this.y, this.w, this.h);
game.ctx.restore();
}
update(game) {
this.x += 10 * this.way;
if (game.w <= this.x + this.w) {
this.way = -1;
} else if (this.x < 0) {
this.way = 1;
}
}
}
class Game {
constructor(el) {
this.canvas = document.querySelector(el);
this.ctx = this.canvas.getContext('2d');
this.w = this.canvas.width;
this.h = this.canvas.height;
this.items = [];
this.isRunning = false;
this.currentFrame = 0;
this.fps = 60.0;
this.now = 0;
}
push(item) {
this.items.push(item);
}
start() {
if (!this.isRunning) {
this.isRunning = true;
this.reqId = window.requestAnimationFrame(this.render.bind(this));
}
}
stop() {
this.isRunning = false;
window.cancelAnimationFrame(this.reqId);
}
async render(timestamp) {
let delta = Math.floor(timestamp - this.now);
if ($('#skip').checked) {
// 処理落ちした分だけupdateする(描画処理は行わない)
while (delta >= 0) {
for (let item of this.items) {
item.update(this);
}
delta -= 1000/ this.fps;
this.currentFrame++;
}
} else {
// フレームスキップしない
for (let item of this.items) {
item.update(this);
}
this.currentFrame++;
}
this.ctx.clearRect(0, 0, this.w, this.h);
for (let item of this.items) {
item.draw(this);
}
// DEBUG:60フレームにつき1回250ms(約16フレーム)処理落ちさせる
if (this.currentFrame % this.fps === 0) {
await sleep(250);
}
this.now = timestamp;
this.reqId = window.requestAnimationFrame(this.render.bind(this));
}
}
const game = new Game('#game');
const item = new Item({ x: 320, y: 30 });
game.push(item);
game.start();
$('#start').addEventListener('click', () => {
game.start();
});
$('#stop').addEventListener('click', () => {
game.stop();
});
以上
written by @bc_rikko
0 件のコメント :
コメントを投稿