測ったら軽かったのに、重い — 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)約34MB36〜44ms
クジラのボス(5760×960)約22MB40ms
敵(3種)各14.7MB3種で23ms
背景6〜12MB0.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倍)を省いても見た目は変わりません。

背景の描き方の比較。前は毎フレーム空・遠景2枚・色を重ねる画像の4枚を重ねて塗り、塗る画素はキャンバスの約5倍だった。後は面の最初に横長の画像へ1度だけ描き込み、毎フレームはキャンバスと同じ大きさを1枚切り出す

ここにも小さな罠がありました。切り出す位置を整数に丸めないと、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で、ここには別の費用が残っています。次に重くなったときも、同じように層を切るところから始めます。

振り返ると、効いたのは次の四つでした。

最後に、時間の進め方についても一つ。本作の更新は1/60秒刻みで、1回の描画までに最大5回まで追いつきます。一定の刻みで更新する作りは、更新の間隔のばらつきを抑える仕組みで、処理を軽くする仕組みではありません。更新が重ければ、追いつくための更新が増えてさらに遅れます。この交換条件はGlenn FiedlerのFix Your Timestep!に詳しく書かれています。

計測の操作はChromeの性能分析の解説、静的な層を分けて描く考え方はMDNのCanvas最適化ガイドが参考になります。フレームを進める検査のほうの話は自動プレイの記事に書きました。