つくったときの記録
測ったら軽かったのに、重い — Canvasゲームの負荷を実時間で切り分ける
からくり双星の処理を測ると、JavaScriptは1フレーム2〜3msしか使っていませんでした。ところが実時間で測り直すと50ms。測り方が描画の費用を隠していたのです。主な原因は、大きな画像の描画と、背景を何度も重ねて描く処理でした。定石の対策が逆効果だった話まで、計測の数字つきで書きます。
測ったら、軽かった

からくり双星の処理負荷を下げてほしい、という依頼がありました。まず測ります。
最初に使ったのは、ゲームを1フレームずつ進められる検査用のモードです。自動プレイの検査でも使っている仕組みで、1フレームぶんのJavaScriptの処理時間を測れます。結果は2〜3msでした。60Hzの画面の1フレームは約16.7msなので、余裕はたっぷりあるように見えます。
それなのに、実際に遊ぶと明らかに重いのです。
測り方が、描画を隠していた
フレームを外から1つずつ進めると、ブラウザは画面の描画をまとめて後回しにします。測れていたのはJavaScriptの時間だけで、画像を塗る費用が丸ごと抜け落ちていました。
そこで、実際の requestAnimationFrame の間隔を測り直しました。条件は開発環境のChromiumで、CPUを4倍に絞った状態です。1面のボス戦で、フレームの間隔の中央値は50ms、毎秒21.5フレームでした。
この50msという数字には意味があります。60Hzの画面では、描画の間隔は16.7msの倍数(16.7ms、33.3ms、50ms)に寄ります。中央値が50msなら、おおむね3回の表示機会につき1回のペースで描いていることを示します。
どこが重いのかは、層を一枚ずつ消して測りました。検査用の入口から背景だけ、前景だけ、画面上の情報だけを消せるようにしておき、消したときにフレームの時間がどれだけ減るかを見ます。主な原因は二つ見つかりました。
犯人1:描くたびに、画像を展開し直していた
主人公の絵は、すべての動きを3840×2200pxの1枚に並べたものでした。圧縮したファイルは小さくても、描くには展開が要ります。1画素4バイトなら、3840×2200×4=約33.8MBです。
この1枚から一コマを描く drawImage が、1回36〜44msかかっていました。一方、同じ画面の背景は0.3msです。
| 画像 | 展開後の大きさ | 1回の描画 |
|---|---|---|
| 主人公(3840×2200) | 約34MB | 36〜44ms |
| クジラのボス(5760×960) | 約22MB | 40ms |
| 敵(3種) | 各14.7MB | 3種で23ms |
| 背景 | 6〜12MB | 0.3ms |
この環境では、展開後12MB前後を境に、展開した状態を持ち続けず、描くたびに展開し直しているように振る舞いました。境の値はブラウザや端末で変わりうるので、自分の環境で測る必要があります。
定石が、逆効果だった
展開し直すのが問題なら、展開した状態を自分で持てばよいはずです。ImageBitmap や別のCanvasへ一度写して持つのは、よく知られた対策です。
試すと、複製した画像が数十〜数百個になり、60〜70フレームごとに2.3秒止まるようになりました。メモリの掃除とみられる停止です。平均の速さが上がっても、2秒以上固まるゲームは遊べません。
採ったのは、もっと素直な方法でした。画像ファイルを、行ごとに分割するのです。主人公の絵は11行に分かれ、1行は展開後3〜4.4MBになります。背景と同じく、展開した状態のまま持たれる大きさです。
// 行ごとに割った画像を、1枚の絵と同じ寸法の情報で扱う
function rowAtlas(info) {
const rows = info.files.map(f => { const im = new Image(); im.src = f; return im; });
return { rows, naturalWidth: info.w, naturalHeight: info.h };
}
// 描く側の考え方の例:何行目かで画像を選び、行の中の座標で切り出す
function drawCell(atlas, col, row, cw, ch, dx, dy, dw, dh) {
ctx.drawImage(atlas.rows[row], col * cw, 0, cw, ch, dx, dy, dw, dh);
}
画質は品質95のWebPで割り直し、不透明な画素の平均の差は1.02/255でした。見て分かる差ではありません。可逆圧縮にすると容量が2.15倍になるので、採りませんでした。
なお、全部の行を持っていれば、展開後の総量は変わりません。減ったのは「一度に展開する単位」の大きさです。
犯人2:同じ場所を5回塗っていた
層を消して測ると、背景も重いことが分かりました。毎フレーム、空を描く画像(1120×540)、ゆっくり流れる遠景の画像2枚(各1620×540、不透明度0.72)、色を重ねる画像(960×540)を重ねて塗っていたのです。
1フレームで塗る画素は2.66M、そのうち背景が2.38Mでした。キャンバスは960×540=約0.52Mです。画面全体を5回塗り直している計算になります。背景を消しただけで、毎秒22フレームが45フレームになりました。
遠景は、カメラの動きの0.22倍でゆっくり流れているだけです。それなら、カメラが動ける範囲ぶん横に長い画像を、面の最初に1度だけ描いておけば、毎フレームは960×540の切り出し1枚で済みます。空のグラデーションは横に一様なので、空を描く画像にだけ掛けていたごく小さな視差(0.02倍)を省いても見た目は変わりません。
ここにも小さな罠がありました。切り出す位置を整数に丸めないと、1フレームぶんの絵を丸ごと補間し直すことになり、描画時間が約3倍になったのです。
// 面の最初に1度だけ:カメラの可動域ぶん横に長い画像へ描き込む
const stripW = W + Math.ceil(Math.max(0, stage.width - W) * 0.22) + 8;
// 毎フレーム:キャンバスと同じ大きさを1枚切り出す。位置は整数に丸める
const sx = Math.round(Math.max(0, Math.min(strip.width - W, camera.x * 0.22)));
ctx.drawImage(strip, sx, 0, W, H, 0, 0, W, H);
結果と、ここから持ち帰れること
同じ条件(開発環境のChromium、CPU 4倍絞り、実時間)で測り直した結果です。
| 場面 | 前 | 後 |
|---|---|---|
| 1面のボス戦 | 毎秒21.5・中央値50ms | 毎秒37.6・中央値16.8ms |
| 2面のボス戦 | 毎秒17.9・中央値50ms | 毎秒33.4・中央値33.3ms |
| 3面の道中 | 毎秒21.8・中央値50ms | 毎秒40.3・中央値16.7ms |
絵は変えていません。同じ場面を描いた画面の差は、画素の平均で0.5〜0.7/255でした。2面のボス戦はまだ中央値33.3msで、ここには別の費用が残っています。次に重くなったときも、同じように層を切るところから始めます。
振り返ると、効いたのは次の四つでした。
- 測る道具が、何を後回しにしているかを疑う。フレームを外から進める計測は、描画の費用を見せない
- 「塗った画素 ÷ キャンバスの画素」を見る。今回は約5倍だった
- 大きな画像は、ファイルの容量ではなく展開後の大きさで考える
- 定石の対策も、自分の環境で測ってから採る
最後に、時間の進め方についても一つ。本作の更新は1/60秒刻みで、1回の描画までに最大5回まで追いつきます。一定の刻みで更新する作りは、更新の間隔のばらつきを抑える仕組みで、処理を軽くする仕組みではありません。更新が重ければ、追いつくための更新が増えてさらに遅れます。この交換条件はGlenn FiedlerのFix Your Timestep!に詳しく書かれています。
計測の操作はChromeの性能分析の解説、静的な層を分けて描く考え方はMDNのCanvas最適化ガイドが参考になります。フレームを進める検査のほうの話は自動プレイの記事に書きました。