DevToolsでガタつくCSSアニメーションをデバッグする
ガタつくCSSアニメーション(ジャンク)は、フレームバジェットを超過することで発生します。60fpsでは、ブラウザが各フレームを生成するために使える時間は約16.7msです。レイアウトの再計算、ペイント、またはメインスレッドの過負荷によってフレームの処理が長引くと、フレームレートが低下し、目に見えるカクつきとして現れます。解決策は「will-changeを増やす」ことではありません。必要なのは診断です。レンダリングパイプラインのどのステージが、どのスレッドで長時間かかっているかを特定することが重要です。この記事では、現在のChrome DevToolsにおける4つのパネル(Rendering、Performance、Animations、Layers)を使った体系的なワークフローを紹介し、ジャンクの原因を追跡する方法を、再現可能な修正前後の実例とともに解説します。
対象読者はtransformとopacityが軽量であることは知っているものの、具体的な手順を持っていない方を想定しています。ノートPCでは問題なく動作したアニメーションが、ミドルレンジのAndroid端末でカクつくというのが典型的なケースです。必要なのは別のプロパティのTipsではなく、トリアージ(問題の優先順位付けと切り分け)です。
重要なポイント
- 60fpsでは、ブラウザはスタイル計算、レイアウト、ペイント、コンポジットを完了するために1フレームあたり約16.7msしかありません。このバジェットを一度でも超えると、目に見えるカクつきが発生します(Chrome DevToolsパフォーマンスドキュメント)。
- パネルの順番で診断します。Renderingで素早い目視確認を行い、Performanceで遅いフレームの根本原因を特定し、Animationsで問題のあるキーフレームを絞り込み、Layersでコンポジターのメモリコストを確認します。
- Performanceパネルのメインスレッドトラックで、黄色いJavaScriptバーの直下に紫色のRecalculate StyleまたはLayoutバーが表示されている場合は、強制同期レイアウトが発生しているサインです。赤い三角形をクリックすると、問題のあるJSの正確な行に移動できます。
- コンポジターは
transformとopacityをレイアウトやペイントなしにアニメーション化できます。一方、left/top/width/heightをアニメーション化すると、毎フレームでメインスレッドのレイアウト処理が強制されます(CSS Triggers)。 animation-timelineを使用したスクロール駆動アニメーションは、transform/opacityに対してコンポジター上で実行されるため、そのジャンクはメインスレッドの長時間タスクとしてではなく、Framesトラックに現れます。
アニメーションジャンクとは何か?
ジャンクとは、ユーザーが知覚できるほどのフレームの欠落または遅延のことです。60fpsを維持するには、ブラウザが各フレームを約16.7ms(1000ms ÷ 60)以内に処理し終える必要があります。この時間枠の中で、スタイルの再計算、レイアウト、ペイント、そのフレームのコンポジットをすべて完了しなければなりません。1フレームでも処理が長引くと、ブラウザはデッドラインを守れず、実効フレームレートが30fps以下に低下し、動きがスキップして見えます。Googleのレンダリングパフォーマンスガイドでも同じバジェットについて言及されています。滑らかな視覚的変化はフレームごとの時間枠内に収まる必要があり、アニメーションは目が連続した動きを追っているため、バジェットが超過する最も目立つ場面です。
1フレームの遅延が知覚されやすい一方で、1回のネットワークリクエストの遅延が気づかれにくい理由があります。アニメーションは脳が動きとして統合するフレームの連続だからです。1フレームでも遅れると統合が崩れ、動きの途中での停止がジャンプとして認識されます。「だいたい滑らか」では不十分な理由はここにあります。知覚品質を決めるのは平均ではなく、最悪のフレームだからです。
レンダリングパイプラインの概要
すべての視覚的な更新は、パース → スタイル → レイアウト → ペイント → コンポジットという固定のパイプラインを経由します。ブラウザはHTMLとCSSをDOMとCSSOMにパースし、どのスタイルが適用されるかを計算し(スタイル)、各ボックスのジオメトリと位置を算出し(レイアウト)、ピクセルをレイヤーにラスタライズし(ペイント)、最後にレイヤーを合成して表示画像を生成します(コンポジット)。web.devのレンダリングパイプライン解説が詳細なリファレンスとして定番ですが、ここでは概要だけで十分です。
この記事全体の基盤となる重要なポイントがあります。パイプラインの各ステージは特定のスレッドで実行され、特定のDevToolsパネルに表示されます。レイアウトとペイントはJavaScriptと同じメインスレッドで実行されます。コンポジットは別のスレッドで実行されます。コンポジットのみを行うアニメーション(既存のレイヤーを移動したり、不透明度を変更したりするもの)は、メインスレッドをほぼ完全に回避できます。一方、レイアウトをトリガーするアニメーションは、毎フレームでメインスレッドに処理を引き戻し、他のすべての処理と16.7msのバジェットを奪い合います。この違いを可視化するのが、以下のパネルです。
アニメーションジャンクを診断するDevToolsパネル
Discover how at OpenReplay.com.
体系的なジャンク診断は、4つのパネルを順番に使用します。Renderingパネルで素早い目視確認(ペイントが不必要な場所でフラッシュしていないか)を行い、Performanceパネルで記録を取って遅いフレームの根本原因を特定し、Animationsパネルでどのキーフレームやプロパティが問題を引き起こしているかを絞り込み、Layersパネルでコンポジターレイヤーのプロモーションがメモリ圧迫を引き起こしていないかを確認します。この順番で使用してください。Renderingは問題のカテゴリを数秒で絞り込み、Performanceはトレースを提供し、Animationsはプロパティを特定し、Layersは修正のコストを検証します。
Renderingパネル:素早い目視確認
Renderingパネルは最初の確認場所です。アニメーションが不必要な再ペイントを行っているかどうかを、視覚的かつ即座に確認できるからです。コマンドメニュー(Cmd/Ctrl+Shift+Pで「Show Rendering」と入力)またはMore Tools → Renderingから開きます(Chrome DevToolsレンダリングリファレンス)。注目すべきトグルは3つです。
- Frame Rendering Stats:アニメーション実行中のライブFPS表示とGPUメモリオーバーレイを表示します。アニメーション中に60を大きく下回る数値が確認できれば、ジャンクが存在することが確認されます。
- Paint flashing:ブラウザが再ペイントした領域を緑色のフラッシュでハイライトします。
transformのみをアニメーション化している要素は、移動中に緑色のフラッシュが発生しないはずです。アニメーションに追随する緑色のフラッシュが見られる場合は、ペイントがトリガーされています。 - Layer borders:コンポジターレイヤーをオレンジ色で枠表示します。ハードウェアアクセラレーションが有効になっていると期待している要素が実際に独自のレイヤーを持っているかどうかの確認と、意図しないレイヤーの発見に使用します。
手順:
- Renderingパネルを開きます。
- Paint flashingを有効にして、アニメーションをトリガーします。
- アニメーション中に要素が緑色にフラッシュする場合、アニメーションは毎フレームでペイントを行っています。レイアウトまたはペイントプロパティがアニメーション化されています。これはプロパティレベルの問題を示しており、次にPerformanceパネルを開く必要があります。
- フラッシュがないのに動きがカクつく場合、ボトルネックはペイントではなくメインスレッドのJavaScriptである可能性が高く、これもPerformanceパネルで調査すべき問題です。
Performanceパネル:遅いフレームの根本原因の特定
Performanceパネルでは、アニメーションを記録し、パイプラインのどのステージがフレームバジェットを超過したかを正確に読み取ることができます。FPSチャート、Framesトラックのフレームごとのタイミング、メインスレッドのアクティビティ、そして現在のChromeでは強制リフローなどの問題を自動的に検出するInsightsサイドバーが表示されます(Chrome DevToolsパフォーマンスリファレンス)。
記録前に、ジャンクが実際に発生するデバイスを近似するためにCPUをスロットリングしてください。Chrome DevToolsはCapture settingsでCPUスロットリングのプリセットを提供しており、パネルには「4x slowdown」プリセットが用意されています。推奨されるアプローチは、低性能なハードウェアを近似するスロットリングでテストすることです(パフォーマンスリファレンス、CPUスロットリング)。スロットリングが重要な理由があります。CSSアニメーションがローカルのプロファイリングでは問題なく動作するのに本番環境でカクつく最も一般的な原因は、デバイスの状況の違いです。複数のタブを開いた状態でChromeを実行しているミドルレンジのAndroid端末は、開発用ノートPCのCPUバジェットのほんの一部しか持っていません。スロットリングはこれを近似しますが、メモリ圧迫や並行するGPU負荷のシミュレーションなしに完全に再現することはできません。
手順:
- Performanceパネルを開き、Screenshotsにチェックを入れます。
- Capture settings(歯車アイコン)でCPUスロットリングをスロットリングプリセットに設定します。
- Recordをクリックし、数秒間アニメーションを実行してからStopをクリックします。
- まずFPS/Framesトラックを確認します。フレームの上の赤いマークは、バジェットを超過したフレームを示しています。
- 問題のあるフレームにズームインして、Mainトラックをスキャンします。
アニメーションデバッグで最も有用なヒューリスティックは次のとおりです。
メインスレッドトラックで黄色いバーの下に紫色のバーがある = 強制同期レイアウト。赤い三角形が修正箇所へのリンクです。
メインスレッドトラックで、黄色いJavaScriptバーの直下に紫色のRecalculate StyleまたはLayoutバーが表示されている場合、強制同期レイアウトが発生しています。これは、JavaScriptがDOMへの書き込みの直後にレイアウトプロパティを読み取ったため、ブラウザがスクリプトの実行中にレイアウトを強制的に解決させられた状態です。スタイルの書き込み後にoffsetWidth、offsetTopを読み取ったり、getBoundingClientRect()を呼び出したりすると、ブラウザは同期的にレイアウトをフラッシュせざるを得なくなります。Paul Irishの定番リスト「レイアウト/リフローを強制するもの」にこれらのトリガーが列挙されています。紫色のバーの赤い三角形をクリックすると、「Layout Forced」警告と問題のあるJSの正確な行へのソースファイルリンクが含まれたSummaryエントリが開きます。web.devのレイアウトスラッシングガイドでは、読み取り後書き込みパターンについて詳しく解説しています。
黄色いバーの下に紫色のバーがない場合、JavaScriptは処理を完了し、ブラウザが独自のスケジュールでレンダリングを行えた状態です。これが目指すべきトレースの姿です。
Animationsパネル:キーフレームの絞り込み
Animationsパネルでは、実行中のアニメーションを検査、スクラブ(タイムライン上での手動操作)、スローダウンさせることができ、アニメーション全体ではなく特定のキーフレームやプロパティにジャンクを特定できます。More Tools → Animationsから開きます(Chrome DevToolsアニメーションドキュメント)。Chromeはアニメーションを監視し、発火したアニメーションをリストに表示します。キャプチャしたアニメーションを検査したり、タイムラインをスクラブしたり、キーフレームを確認したりすることができます。
このパネルの診断力は、Paint flashingと組み合わせることで発揮されます。Renderingパネルでペイントフラッシュを監視しながら、アニメーションを10%の再生速度に落とすことで、どの特定のキーフレームが再ペイントをトリガーするかを最も素早く特定できます。問題のあるプロパティ値が有効になった瞬間に緑色のフラッシュが表示されます。
手順:
- Animationsパネルを開き、アニメーションをトリガーしてリストに表示させます。
- 再生速度を**10%**に設定します(コントロールはパネル上部にあります)。
- Paint flashingを有効にした状態でタイムラインをスクラブし、緑色のフラッシュを監視します。
- タイムラインの特定の時点で緑色のフラッシュが表示された場合、その時点でアクティブなキーフレームに調査を集中させます。
Firefoxやほかのブラウザにもそれぞれのアニメーションインスペクターがありますが、ここではChromeを前提として説明しています。
Layersパネル:コンポジターコストの確認
Layersパネルでは、どの要素が独自のコンポジターレイヤーにプロモートされたか、その理由、そしてメモリコストを確認できます。これにより、will-changeを無闇に追加することを防げます。More Tools → Layersから開きます(Chrome DevTools Layersドキュメント)。レイヤーを選択すると、詳細ペインにメモリ消費量とコンポジットの理由が表示されます。
プロモーションはトレードオフです。要素を独自のレイヤーに移動すると、コンポジターが隣接する要素を再ペイントせずにアニメーション化できますが、各レイヤーはテクスチャのためにGPUメモリを消費します。MDNのwill-changeドキュメントでは、このプロパティは最終手段であると明記されています。多くの要素に適用するとリソースが無駄になります。ブラウザはすでに低コストなプロパティを独自に最適化しており、過剰なプロモーションはパフォーマンスを低下させる可能性があります。Layersパネルを使ってプロモートされたレイヤーの数を確認し、各レイヤーがそのメモリコストに見合っているかどうかをチェックしてください。
実例:leftとtransformのアニメーション比較
leftをアニメーション化すると毎フレームでレイアウトがトリガーされますが、transform: translateX()をアニメーション化するとレイアウトもペイントもトリガーされません。同じ動きでも、異なるスレッドで実行されます。以下が問題のあるバージョンで、leftをアニメーション化しています。
/* 問題あり: レイアウトプロパティをアニメーション化 */
.box {
position: absolute;
left: 0;
width: 100px;
height: 100px;
background: tomato;
animation: slide 1s ease-in-out infinite alternate;
}
@keyframes slide {
to {
left: 200px;
}
}
このバージョンで各パネルが表示する内容は次のとおりです。Renderingパネルでは、アニメーション中ずっとボックスが緑色にフラッシュします。leftを変更するとレイアウトが強制され、レイアウトの後には必ずペイントが続くためです。Performanceパネルのメインスレッドトラックは毎フレームで紫色のRecalculate StyleとLayoutバーで埋まり、CPUスロットリングを有効にするとFramesトラックにバジェット超過のフレームが表示されます。left、top、width、heightはすべてレイアウトをトリガーします(プロパティごとの詳細はCSS Triggersを参照)。レイアウトはメインスレッドで実行されるため、他のすべての処理と16.7msのバジェットを奪い合います。
修正版では、同じ動きをtransformのみで表現しています。
/* 修正済み: コンポジットのみのプロパティをアニメーション化 */
.box {
position: absolute;
left: 0;
width: 100px;
height: 100px;
background: tomato;
animation: slide 1s ease-in-out infinite alternate;
}
@keyframes slide {
to {
transform: translateX(200px);
}
}
translateXはtransformを使って同じ位置変化を表現します。修正後の変化は次のとおりです。Paint flashingで動作中に緑色のフラッシュが表示されなくなり、Performanceパネルのメインスレッドトラックはフレームごとに紫色で埋まらなくなり、アニメーションはコンポジター上で実行されます。コンポジターはレイアウトやペイントをトリガーせずにtransformとopacityをアニメーション化できるため、ブラウザは毎フレームでジオメトリを再計算する代わりに、既存のレイヤーテクスチャを移動するだけで済みます。
修正リスト
修正はプロパティの置き換えです。レイアウトやペイントをトリガーするプロパティをtransformまたはopacityに置き換えます。以下の表は、各アニメーションの意図とコンポジットのみで実現できる代替手段を対応付けています。
| 意図 | 避けるべき(レイアウト/ペイントを強制) | 使用すべき(コンポジットのみ) |
|---|---|---|
| 移動 | left、top、margin | transform: translate() |
| サイズ変更 | width、height | transform: scale() |
| 回転 | レイアウトに影響するハック | transform: rotate() |
| フェード | visibilityの切り替え、背景の変更 | opacity |
アニメーション化するCSSプロパティとして最も安全で広くサポートされているのはtransform(移動、スケール、回転、スキュー)とopacityです。ブラウザはこれらをレイアウトやペイントをトリガーせずにコンポジター上で実行できるためです。filterもblur()などの関数に対してGPUアクセラレーションが効く場合がありますが、サポート状況と動作はブラウザによって異なります。コストがかからないと仮定する前に、Paint flashingを使ってRenderingパネルで確認してください。MDNのfilterドキュメントにプロパティの説明があり、CSS Triggersにはエンジンごとのレンダリングへの影響が記録されています。その他の多くのアニメーションプロパティはペイントをトリガーし、サイズや位置を変更するプロパティはメインスレッドでのレイアウト再計算をトリガーします。
JavaScriptで駆動するアニメーションでは、すべてのDOM読み取りをDOM書き込みの前にまとめてください。実例のトレースで発生した強制同期レイアウトは、書き込みの後にレイアウトプロパティを読み取ることで発生します。読み取りを先にまとめることで、ブラウザは新しいレイアウトをフラッシュする代わりに、前のフレームのレイアウトから値を返せるようになります。レイアウトスラッシングガイドでこのパターンの詳細を確認できます。
will-changeは戦略的に使用し、デフォルトとして使わないでください。アニメーション化しようとしている要素に適用し、アニメーションが終了したら削除してください。MDNによると、広く適用するとGPUメモリが無駄になります。ブラウザはすでに低コストなプロパティを独自に最適化しているためです。Layersパネルで効果を確認してください。
スクロール駆動アニメーション:異なるジャンクのシグネチャ
animation-timeline: scroll()またはanimation-timeline: view()で宣言されたスクロール駆動アニメーションでは、DevToolsで確認すべき場所が変わります。transformまたはopacityのみをアニメーション化する場合、コンポジター上で実行されるため、ジャンクはメインスレッドトラックの長時間タスクとして現れません。代わりにFramesトラックでドロップしたフレームを確認してください。MDNのanimation-timelineドキュメントとChromeのスクロール駆動アニメーションガイドに、この機能とブラウザサポートの基準が記載されています。メインスレッドトラックで何も見つからないのにFramesトラックにバジェット超過のフレームが表示される場合は、コンポジット非対応のプロパティがスクロール駆動のキーフレームに紛れ込んでいる可能性を疑ってください。
本番環境でのみアニメーションがカクつく理由
DevToolsは制御された条件下でプロファイリングを行いますが、完全に再現できない変数があります。それは実際のユーザー環境です。デバイスのCPU性能、メモリ圧迫、並行するアクティビティなどが該当します。ジャンクの報告がローカルで再現できない場合、この欠けているコンテキストが原因であることがほとんどです。セッションリプレイを使えばこれをキャプチャできるため、記録を取る前にどの条件をシミュレートすべきかを把握できます。
4つのパネルを順番に使用してください。Renderingで確認し、Performanceで根本原因を特定し、Animationsで絞り込み、Layersでコストを確認する。この手順を踏めば、次にカクつくアニメーションに直面しても、もはや推測に頼る必要はありません。
よくある質問
コードではなく、デバイスの状況の問題です。複数のタブを開いた状態のミドルレンジのAndroid端末は、ノートPCのCPUバジェットのほんの一部しか持っていません。PerformanceパネルのCapture settingsでCPUスロットリングを有効にし、セッションリプレイを使って本番環境でジャンクが発生した際の実際の状況をキャプチャしてください。
`transform`はレイアウトやペイントをトリガーせずにコンポジタースレッドで実行されます。ブラウザは毎フレームで既存のレイヤーテクスチャを移動するだけです。一方、`left`や`top`は毎フレームでメインスレッドのレイアウト再計算を強制し、その後ペイントも発生するため、JavaScriptと16.7msのバジェットを奪い合います。
確実にはできません。コンポジットのみで処理されることが保証されているのは`transform`と`opacity`だけです。`filter`は一部のエンジンで`blur()`などの関数に対してGPUアクセラレーションが効く場合がありますが、サポート状況はブラウザによって異なります。Paint flashingを使ってRenderingパネルで確認してください。緑色のフラッシュが表示される場合は、毎フレームでペイントが発生しています。
`animation-timeline: scroll()`と`view()`は、`transform`または`opacity`のみをアニメーション化する場合にコンポジター上で実行されるため、メインスレッドに長時間タスクが発生しません。ジャンクはFramesトラックに現れます。メインスレッドに何も表示されないのにFramesトラックにバジェット超過のフレームが表示される場合は、コンポジット非対応のプロパティがキーフレームに紛れ込んでいる可能性があります。
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.