マガジン

時間が経つほど遅くなる:増え続けるものを探す

遊びをつくる

執筆にClaudeを使用しています。

紙の飛行機が、海辺の小さな街と山の上を飛んでいく絵。
記事のテーマをもとに制作したAI生成イメージ。実際のゲーム画面ではありません。

遊び始めの1分は滑らかなのに、10分たつとカクつき始め、ページを開き直すと元に戻る。そういう症状のゲームを作ってしまった場面を考えてみます。ロジックを見直しても、処理の重い箇所は見つかりません。それでも遅くなるなら、多くの場合は何かが増え続けています。 増え続けるものの正体は、勘で探すより数えるほうが早く見つかります。この記事では、配列の長さ、イベントリスナーの登録数、タイマーの生存数、DOMノード数の4つを一定間隔で記録し、時間とともに伸びるものを特定する設計を示します。結果は書きません。計測は読者が自分のゲームで行い、読み方の判断基準だけを用意します。

この記事の目次
  1. 遅くなる理由は「増えたもの」が毎フレームの仕事を増やすから
  2. 計測スクリプトを仕込む
  3. 数字の読み方と、犯人の見分け方
  4. 公式作品の設計から読み取れること
  5. 次の行動
  6. 参考資料

遅くなる理由は「増えたもの」が毎フレームの仕事を増やすから

ゲームは一定間隔で更新と描画を繰り返します。その途中で扱う対象が増え続けると、1回あたりの仕事量が時間に比例して増えます。起動直後は軽くても、対象が数千、数万になれば16ms前後の枠に収まらなくなります。

増えやすいものは、おおよそ次の4種類です。

  • 配列やMapなどの集まり:弾、パーティクル、軌跡の点、ログ、履歴。画面外に出たり役目を終えたりしても、取り除かない限り残ります。
  • イベントリスナー:リスタートや画面遷移のたびに addEventListener を呼ぶと、同じ処理が何重にも登録されます。1回のキー入力で処理が複数回走るので、症状が入力時の重さとして出ることもあります。
  • タイマーとアニメーションループ:setInterval を止めずに再開始する、requestAnimationFrame のループを二重に開始する、といった形です。ループが2本になれば、1フレームの更新も2回走ります。
  • DOMノード:スコア表示やログ行を足し続けると、ノードが増えてスタイル計算や再描画が重くなります。

ここで大事なのは、同じ「遅い」でも原因ごとに数字の出方が違うことです。数えるものを4つに分けておけば、どの数字が伸びているかで容疑者を絞れます。

計測スクリプトを仕込む

準備するもの

  • 自作ゲームのHTMLファイルを、単体でブラウザで開ける状態にしたもの
  • ブラウザの開発者ツール(コンソールを開けるもの)
  • 10分ほど放置または操作を続けられる時間

アソバースに投稿した作品はsandbox付きiframeの中で、メインサイトとは別のオリジンから配信されて動きます。そのため、サイト上で動かしながら外から中身を調べるのは手間がかかります。計測は、手元で単体のHTMLとして開いて行うのが確実です。

計測コード

次のスクリプトを、ゲーム本体のスクリプトより前に <script> として入れます。リスナーの追加と削除、setInterval の開始と停止、ゲーム側の requestAnimationFrame の呼び出し回数を数え、5秒ごとに平均フレーム間隔、最大フレーム間隔、DOMノード数と合わせて出力します。

js
(() => {
  const c = { addL: 0, remL: 0, setI: 0, clrI: 0, raf: 0 };
  const ael = EventTarget.prototype.addEventListener;
  const rel = EventTarget.prototype.removeEventListener;
  EventTarget.prototype.addEventListener = function (...a) { c.addL++; return ael.apply(this, a); };
  EventTarget.prototype.removeEventListener = function (...a) { c.remL++; return rel.apply(this, a); };
  const si = setInterval, ci = clearInterval, raf = requestAnimationFrame;
  window.setInterval = (...a) => { c.setI++; return si(...a); };
  window.clearInterval = (id) => { c.clrI++; return ci(id); };
  window.requestAnimationFrame = (f) => { c.raf++; return raf(f); };
  let last = performance.now(), sum = 0, n = 0, max = 0;
  const tick = (t) => { const d = t - last; last = t; sum += d; n++; max = Math.max(max, d); raf(tick); };
  raf(tick);
  si(() => {
    const avg = n ? +(sum / n).toFixed(1) : 0;
    const extra = window.dbg ? window.dbg() : {};
    console.log(JSON.stringify({ s: Math.round(performance.now() / 1000), avgMs: avg, maxMs: Math.round(max), dom: document.getElementsByTagName('*').length, ...c, ...extra }));
    c.raf = 0; sum = 0; n = 0; max = 0;
  }, 5000);
})();

配列の長さは、ゲーム側で window.dbg = () => ({ bullets: bullets.length, trail: trail.length }) のように、疑わしい配列を返す関数を1つ定義しておくと、同じログ行に並んで出ます。名前はゲームの変数に合わせて変えてください。

このスクリプトには限界があります。ゲームが起動時に requestAnimationFrame を別の変数へ退避している場合、その呼び出しは数えられません。同じリスナーを同じ対象に二重登録しても、ブラウザは重複を無視するため、addL の増加が実際の登録数と一致しないこともあります。あくまで「増え方の傾向」を見る道具として使います。

記録の取り方

  1. ゲームを開き、同じ操作を10分間続けます。放置で再現する症状なら放置でかまいません。
  2. 5秒ごとに出る行を、10分ぶん(約120行)記録します。
  3. 同じ手順をあと2回、ページを開き直して行います。合計3回です。
  4. 余裕があれば、開発者ツールの「パフォーマンスモニター」(Chromeの場合)でJSヒープ、DOMノード、JSイベントリスナーのグラフも並べて見ます。

3回繰り返すのは、1回だけでは偶然の揺れと区別できないためです。条件を変えるときは、一度に1つだけにします。

数字の読み方と、犯人の見分け方

増え方で容疑者を絞る

  • dbg の配列長が単調に増える、疑うもの:取り除かれない配列、次に確かめること:追加している箇所と、削除する条件を探します
  • addL が remL より速く増える、疑うもの:登録の繰り返し、次に確かめること:リスタートや画面切り替えの処理で、毎回登録していないかを見ます
  • setI が clrI より速く増える、疑うもの:止め忘れの setInterval、次に確かめること:開始と停止が対になっているかを見ます
  • raf が5秒ごとに増える、または平均値の何倍にもなる、疑うもの:ループの多重起動、次に確かめること:ループを開始する関数が複数回呼ばれていないかを見ます
  • dom が増える、疑うもの:追加し続けるDOM、次に確かめること:ログや履歴の表示行数に上限があるかを見ます
  • どれも増えず avgMs だけ伸びる、疑うもの:1回の描画が重くなる要因、次に確かめること:キャンバスのパス蓄積など、描画側を疑います

raf の値は画面のリフレッシュレートに左右されます。60Hzの画面か144Hzの画面かで基準が変わるため、絶対値ではなく「最初の5秒と比べて何倍か」で見てください。

時間との関係を見る

avgMs と疑わしい数字を、時間軸で並べて比べます。次のような見方をします。

  • 配列長が一定の上限で頭打ちになり、avgMs も安定しているなら、その配列は犯人ではありません。
  • 配列長と avgMs が同じペースで伸びるなら、強い容疑者です。配列に上限を付けるか、役目を終えた要素を取り除く処理を入れて、もう一度同じ計測をします。
  • 数字を直しても avgMs が変わらないなら、犯人はほかにいます。直した効果は、必ず同じ手順で再計測して確かめてください。

メモリの見方にも注意が必要です。JSヒープは、ガベージコレクション(使われなくなったメモリの自動回収)が走るたびにのこぎり状に上下します。上下そのものは正常で、見るべきは「谷の底」が時間とともに切り上がっているかどうかです。

よくある誤り

  • 描画の重さを疑って先に最適化を始めてしまうことです。数える前に直すと、効果の有無を判定できません。
  • 開発者ツールを開いたまま計測し、ツール自体の負荷を混ぜてしまうことです。比較は常に同じ状態で行います。
  • 別のタブに切り替えると、ブラウザがアニメーションの頻度を落とします。計測中はゲームのタブを前面に保ちます。
  • リスナーの数だけを見て安心してしまうことです。リスナーが一定でも、その中身が配列に積み続けている場合があります。

直し方の判断基準

原因が見つかったら、次の方針で直します。

  • 配列には「寿命」か「上限」のどちらかを必ず付けます。役目を終えたら取り除くか、古いものから捨てて一定の長さに保ちます。
  • リスナーは、ゲーム開始時に1回だけ登録する形にします。画面ごとに付け外しが必要なら、AbortController の signal を addEventListener のオプションに渡し、abort() で一括して外せます。
  • タイマーとループは、開始する関数の中で「すでに動いていないか」を確認します。IDを変数に持ち、再開始の前に止めるのが基本です。
  • DOMに足す行には最大数を決め、超えた分は古い行から消します。

公式作品の設計から読み取れること

アソバースの公式作品について確認できている範囲で、「増え続けにくい形」になっている点を整理します。内部の実装までは確認済みではないため、ここで読み取れるのは外から見える設計の枠組みだけです。

  • 雨音のピアノの録音は、30秒・128音までです。記録できる量に上限が決まっているので、録音データが際限なく伸びる使い方にはなりません。
  • 余白の庭に置けるものは最大80個です。保存される内容も、この上限の範囲に収まる性質のものだと読み取れます。
  • 紙ひこうき郵便は36〜44秒、時計じかけの台所は1皿28秒と、1回の遊びが短く区切られています。区切りごとに状態を初期化しやすい構造ですが、実際に何をリセットしているかは作品を開いて確かめる部分です。
  • 灯りのチェスは取った駒と全棋譜を表示します。棋譜は手数ぶん増えるデータですが、対局が終われば増加も止まります。長さに自然な終点がある例です。
  • 保存があるのは余白の庭、WAVE / WAVE、かたちのあいだの3作品で、ほかの10作品の記録や録音は画面を開いている間だけです。後者は開き直せば状態が空に戻るため、長く開き続けたときの蓄積だけが問題になります。

こうして並べると、増えるものには「上限」「終点」「リセットの機会」のどれかを用意する、という設計の型が見えてきます。自作ゲームを点検するときも、増えるデータごとに、この3つのどれがあるかを書き出してみてください。1つも当てはまらないものが、計測の最初の候補です。

アソバースのクラウドセーブも、同じ観点で見られます。作品ごとにJSONで128KiBまでという上限があります。プレイログを全部セーブに積み続ける作りにすると、増え続けるデータがセーブの上限にぶつかる可能性があります。セーブに入れるのは再開に必要な状態だけにして、履歴は直近の数件に絞る設計が安全です。

次の行動

自作ゲームに計測スクリプトを入れ、10分間のログを3回取ってみてください。最初に見るのは、avgMs と一緒に伸びる数字が何かです。見つかったら、上限か寿命を1つだけ足して、同じ手順で再計測します。

参考資料

  • Chrome DevTools ドキュメント(パフォーマンスモニター、メモリパネル)
  • MDN Web Docs(EventTarget.addEventListener、AbortController、Window.requestAnimationFrame)