本文へ

開発記録

背景の流線がコマ落ちする理由:固定 FPS から rAF ごとの描画へ

Canvas の流線を描く実際の render loop とブラウザでの観察をもとに、固定 30 FPS が描画の機会を捨ててしまう理由と、アニメーションの速度を保ちながら requestAnimationFrame ごとに更新する方法を説明します。

林予晴
公開
  • Canvas
  • requestAnimationFrame
  • アニメーション
  • パフォーマンス

以下の実装と計測は、2026年10月2日に記録された段階のものです。「現在のループ」はその時点の実装を指します。callback 60回、draw 60回、約60.1 FPS は元の観察結果であり、翻訳時に新しく計測した値ではありません。

背景アニメーションは、ページの後ろにある装飾に見えます。しかし、画面全体に固定すると、ブラウザの描画サイクルに合わせて動き続けます。今回、流線がコマ落ちした原因は、Canvas がアニメーションに向いていないことではありません。render loop が、ブラウザから渡されたフレームを先に捨てていたことでした。

以前の実装では、requestAnimationFrame の callback 内で約33ミリ秒の間隔を確認していました。これはアニメーションを最大約30 FPS に制限する処理であり、ブラウザがもっと多くのフレームを提供できても、毎回は描画しません。修正後はこの固定しきい値を外し、通常モードでは rAF ごとに更新と描画を行い、経過時間で移動量を制御します。判断の中心は、実際の画面更新に合わせてアニメーションを動かしたいなら、callback 内で一定割合のフレームを強制的に捨てないことです。

requestAnimationFrame の callback と描画の機会を、安易に捨てない

ブラウザは、次に画面を更新するのに適したタイミングで requestAnimationFrame を呼び出します。callback が常に毎秒60回になる保証も、すべての画面が同じ頻度で更新される保証もありません。処理負荷、タブの状態、デバイスによって、ブラウザの呼び出し間隔は変わります。

以前の処理は、概ね次の流れでした。元の図はそのまま残しています。内容は、rAF callback を受け取り、前回の描画から約33ms 未満なら描かずに次の callback を待ち、それ以上なら Canvas を消去して流線を描き、粒子を更新する、というものです。

收到 rAF callback
  ├─ 距離上次繪製不到約 33ms → 不畫,等下一次 callback
  └─ 否則才清空 Canvas、畫流線、更新粒子

この判定で描画の上限を約30 FPS に抑えることはできますが、callback のタイミングで利用できた画面更新の機会も捨ててしまいます。ディスプレイとブラウザがより高い頻度で更新していると、表示のリズムに合わせて自然に動くのではなく、一コマずつ飛ぶように感じられる場合があります。

問題は、「30」という数字が常に間違っていることではありません。必ずしも一定ではない閲覧環境に、固定の出力上限を埋め込んだことです。本当にフレーム制限が必要なら、消費電力、負荷、見た目について明確な目標を定め、計測に基づいて判断する必要があります。callback が多いというだけで、その一部を捨てても安全だと考えるべきではありません。

修正後の render loop が行うこと

この記録の通常アニメーションモードでは、render が呼ばれるたびに次の処理を行います。

  1. callback と draw のカウントを増やします。
  2. ページが一時停止状態でなければ、前回の描画から現在までの elapsed を計算します。
  3. 透明な Canvas を消去します。
  4. HUD、流線、データパケットの粒子を描画します。
  5. アニメーション時間と FPS のカウントを加算します。
  6. 次の requestAnimationFrame を要求します。

コードには、「前回の描画から33ミリ秒未満ならそのまま return する」という固定 FPS のしきい値がなくなりました。そのため、通常モードでは一つの callback が一つの draw に対応します。ページが非表示のときや、ユーザーが reduced-motion を設定しているときは、別の状態処理を使います。あらゆる状態で無条件に描画するわけではありません。

data-callbacks と data-draws は、確認時に実際のカウントを読み取れるようにするためのものです。パフォーマンスの順位を示すダッシュボードではありません。分かるのは「今回のページ実行で callback と draw が何回行われたか」であり、すべてのデバイスの平均 FPS や消費電力ではありません。

速度はフレーム数ではなく、時間に合わせる

固定 30 FPS のしきい値を外した後、粒子を毎回同じ固定値だけ進めると、アニメーションの速度が callback の数に依存します。callback が多ければ速くなり、少なければ遅くなります。

この実装では、流線のアニメーション時間を次のように更新します。

const elapsedSeconds = Math.min(now - previousRenderTime, 50) / 1000
previousRenderTime = now
time += elapsedSeconds

粒子の進行も経過時間に合わせて調整し、一回に適用する倍率には上限を設けています。

const frameScale = Math.min(elapsedSeconds * 30, 2)
p.progress += p.speed * frameScale

これですべてのデバイスのアニメーションが完全に同じになるわけではありません。「毎フレーム一定量だけ進む」ことによるリフレッシュレートの違いを避けると同時に、タブの復帰やメインスレッドの短い停止の後で、粒子が一度に大きく飛ぶことを防ぎます。elapsedSeconds の上限があることからも、これは見た目のための折衷であり、物理シミュレーションではありません。

33ミリ秒の判定だけを削除し、時間差を処理しなければ、問題を別の問題に置き換えることになります。画面更新の回数は増えても、アニメーションの速度がデバイスによって変わる可能性があるためです。

実際に計測したこと

この段階のスクリプトは、通常アニメーションモードで callback と draw のカウントを背景コンテナの dataset に書き込みます。元の計測では、ローカルブラウザでページを表示し、reduced-motion を有効にしない状態で一秒間観察しました。callback は60回増加し、draw も60回増加し、ページが報告した FPS は約60.1でした。以下は元の出力ラベルを維持したものです。

callbacks 增量:60
draws 增量:60
頁面回報 FPS:約 60.1

この結果が裏付けるのは、限定された判断です。その実行では、通常モードが固定 30 FPS のしきい値で callback の半分を捨てることはなく、callback と draw が同じ数だけ増えました。

次のような、より広い結論は裏付けられません。

  • すべての画面で60 FPS が得られます。
  • すべてのスマートフォンで16本の流線と粒子の軌跡を滑らかに描画できます。
  • Canvas の CPU、GPU、バッテリーの負担はすでに十分に小さいです。
  • 本番環境へのデプロイ後も、ブラウザの動作はローカル環境と完全に同じです。

アニメーションの体感は、ウィンドウサイズ、デバイスのリフレッシュレート、ページ内のほかの処理、ユーザー設定にも左右されます。計測結果には環境と観察範囲を残し、一回のブラウザの読み取り値を、一般的なパフォーマンス保証として扱わないことが必要です。

rAF ごとの描画に戻しても、すべての状態で動かし続けるわけではない

今回の修正が扱うのは、通常の表示状態における描画頻度だけです。いつでも計算を続けるべきだ、という意味ではありません。背景スクリプトには、ほかにも次の境界を残しています。

  • document.hidden のときは次の rAF をキャンセルし、ページに戻ったときに再びスケジュールします。
  • prefers-reduced-motion: reduce のときは動的なループを停止し、静的な流線だけを再描画します。
  • resize のときは Canvas と流線のサイズを調整し、必要に応じて reduced-motion の状態を再描画します。
  • pagehide のときはリスナー、アニメーション、まだキャンセルされていない frame を後処理します。
  • BFCache からの復帰時は背景の制御を初期化し直し、戻ったページがまったく動かなくなったり、リスナーを二重に登録したりすることを防ぎます。

これらはライフサイクルとアクセシビリティの境界であり、「通常モードで rAF ごとに描画するか」とは別の問題です。コマ落ちを直すために、非表示タブ、reduced-motion、ページを離れるときの後処理まで取り除くべきではありません。

更新頻度を下げるなら、先に理由を明確にする

低い頻度での更新にも、合理的な用途はあります。あるデバイスで背景描画がメインコンテンツに影響していることを計測で確認できたなら、線や粒子の数を減らす、軌跡を短くする、特定の状態ではエフェクトを無効にする、といった対応を検討できます。明確なデバイス条件やページの状態に応じた方針を設けることもできます。

ただし「固定 30 FPS」は選択肢の一つにすぎず、アニメーションの更新機会を直接変えます。採用前には、少なくとも次のことを知る必要があります。

  • 解決したいのは CPU、GPU、バッテリー、メインコンテンツの遅延のどれですか。
  • 影響を受けるデバイスや状態は何ですか。
  • 頻度を下げることで見た目は本当に改善しますか。それとも、引っかかりを低頻度の動きに置き換えるだけですか。
  • reduced-motion、hidden tab、ページを離れるときの後処理は引き続き機能しますか。

これらの情報がなければ、まずブラウザが提供するフレームに render loop を合わせ、その後で実際の負担を計測するほうが、慎重そうに見える上限を先に固定するよりも、問題の所在を把握しやすくなります。

この修正で示せること、示せないこと

コードの差分とローカルでの計測から、次の判断ができます。

  • 以前のバージョンには、rAF callback 内に約33ミリ秒の固定描画間隔がありました。
  • 修正後の通常モードでは、その固定しきい値を外し、render ごとに draw を実行します。
  • アニメーション時間と粒子の進行には elapsed time を使い、固定のフレームごとの増分だけには頼りません。
  • 今回の表示ページの観察では、callback と draw がそれぞれ約60回増え、カウントが一致しました。

これだけで、デバイスをまたぐパフォーマンス、消費電力、長時間の安定性、すべてのブラウザについての結論を出すことはできません。背景アニメーションを残す価値があるかどうかは、メインコンテンツの読みやすさ、ユーザーの reduced-motion 設定、実際の見た目の確認を合わせて判断する必要があります。

アニメーションを修正するときは、描画ループが実際に何を捨てているかを先に確認し、その後で頻度、処理量、ライフサイクルのどれを変えるか決めます。固定の数字は理解しやすく見えても、問題の本当の境界を表しているとは限りません。

記録の範囲

この記事は、背景 Canvas のスクリプト、固定間隔を使っていた過去の実装、この記録時点の callback/draw のカウント、一回のローカルブラウザでの観察をもとにまとめています。計測が表すのは、そのときの表示ページの実行だけです。すべてのデバイスのパフォーマンスを保証するものではなく、hidden tab、reduced-motion、長時間の負荷についての網羅的なテストも含んでいません。