ブラウザのレイヤー化とGPUコンポジットの判断基準
アニメーションが60fpsで滑らかに動く理由と、will-changeを盛りすぎてかえって重くなる罠を回避できるようになる。合成レイヤーへの昇格条件・レイヤー爆発のコスト・transformが速い原理を解説します。
- 1.ブラウザは特定の要素を合成レイヤー(独立したGPUテクスチャ)へ昇格させ、再合成だけで位置や不透明度を更新する。昇格は3D transform・animation対象・will-change・video/canvas・重なり順の都合などブラウザ内部の発見的規則で決まり、開発者が直接は制御しない。
- 2.transform と opacity はレイヤーのテクスチャを変えずプロパティツリーの値を差し替えるだけで反映でき、合成はコンポジタスレッドで走るためメインスレッドのJSが詰まっても止まらない。だから「動かすなら transform、消すなら opacity」が原則になる。
- 3.レイヤーは1枚ごとにGPUメモリ(幅×高さ×4バイト)と合成コストを消費するため、will-changeの乱用はレイヤー爆発を招き逆効果。will-changeは「動く直前に付け、終わったら外す」短命指定が正しく、常時付けっぱなしは避ける。
合成レイヤーとは何か
ブラウザはページ全体を1枚の絵として毎フレーム塗り直しているわけではありません。レンダリングの最終段であるコンポジット(合成)では、ページを複数の合成レイヤー(compositing layer)に分割し、各レイヤーを独立したGPUテクスチャとして保持します。最終画面は、これらのテクスチャをGPU上で重ね合わせる(compose)だけで生成されます。段ごとの全体像は レンダリングパイプライン詳説 で扱っているので、本稿はどの要素がレイヤーに昇格するかと、そのコストと制御に焦点を絞ります。
合成レイヤーに分かれていることの効能は明快です。あるレイヤーの位置や不透明度だけが変わったなら、そのテクスチャの中身(ピクセル)は不変なので、ペイントもラスタライズもやり直さず、配置パラメータを変えて再合成するだけで済みます。これが transform / opacity のアニメーションが軽い根本理由です。
合成レイヤーは、内部に複数のDOM要素を含みうるまとまった1枚のテクスチャです。レイヤーに分かれていない要素は、背景レイヤー(ルート側)の中にまとめて描かれます。レイヤー化とは「この部分を別テクスチャに切り出し、再合成だけで動かせるようにする」操作だと捉えると、後述の昇格条件もコストも一貫して理解できます。
合成レイヤーへの昇格条件
要素がレイヤーへ昇格するかは、開発者が直接指定するものではなく、ブラウザが**発見的規則(heuristics)**で判定します。Chromium では概ね次のいずれかに該当すると専用レイヤーが割り当てられます。
・3D変換: transform に translateZ / translate3d / rotate3d など Z 軸を含む指定
・アニメーション対象: transform / opacity をコンポジタで動かしている要素
・will-change: transform / opacity を値に持つ will-change 指定
・特定要素: <video>, <canvas>, WebGL コンテキスト, 一部の <iframe>
・position: fixed / sticky(スクロール時に独立して動くため)
・重なりの都合: 既存レイヤーと z 順で重なる要素(後述のオーバーラップ)
歴史的には transform: translateZ(0) や backface-visibility: hidden を「ハック」として書き、強制的にレイヤー化する手法が広く使われました。現在はこの意図を表明する正規の手段として will-change が用意されています。
昇格した要素の上に重なる要素は、合成順序を正しく保つために巻き込まれてレイヤー化されることがあります。たとえばレイヤー化した要素の手前にテキストが重なっていると、そのテキストも別レイヤーへ追い出されます。「1つ昇格させたつもりが、重なり関係で芋づる式に増える」のがオーバーラップ問題で、後述のレイヤー爆発の主因の1つです。
transform / opacity がメインスレッド外で動く理由
transform と opacity だけが特別扱いされるのは、これらがテクスチャの中身を変えない変換だからです。両者の値はペイント段が生成する描画命令リスト(display list)には焼き込まれず、プロパティツリーのノードとして別管理されます。合成時にこのノードの値(変換行列・α値)を読んで適用するため、値を差し替えるだけで反映でき、ペイントもラスタライズも不要です。
決定的なのは実行スレッドです。合成はコンポジタスレッドで駆動でき、メインスレッド(JS実行・スタイル計算・レイアウトが走る場所)から独立しています。
| 駆動方式 | 走る段 | 実行スレッド | JSが詰まると |
|---|---|---|---|
| transform / opacity(合成) | 再合成のみ | コンポジタスレッド | 影響を受けず60fps維持 |
| left / top / width(幾何) | レイアウト→ペイント→合成 | メインスレッド | 即カクつく |
| color / background(見た目) | ペイント→合成 | メインスレッド | 更新が止まる |
/* 良い:合成のみで完結。コンポジタスレッドで駆動され、JS が重くても滑らか */
.card { will-change: transform; }
.card:hover { transform: translateX(20px) scale(1.05); opacity: 0.85; }
/* 悪い:見た目は近いが left/width はレイアウト起点。毎フレーム メインスレッドが要る */
.card-bad:hover { left: 20px; width: 105%; }
位置を left / top で動かすと毎フレーム メインスレッドでレイアウトが走り、JSが重ければ即カクつきます。同じ移動でも transform: translate() ならコンポジタスレッド側で完結します。フェードも visibility や display ではなく opacity を使えば再合成だけで済みます。どのプロパティがどの段を起こすかの仕分けは リフローとリペイントのコスト に詳述しています。
なお、合成がコンポジタスレッドで完結するのは、合成を別プロセスのGPUプロセスに依頼する構造とも対応します。レンダラとGPUの分離は ブラウザのマルチプロセス・アーキテクチャ を参照してください。
レイヤー爆発のコスト
レイヤー化は無料ではありません。各レイヤーは専用のGPUテクスチャを持ち、その容量は実ピクセル数で決まります。
1レイヤーのVRAM ≒ 幅(px) × 高さ(px) × デバイスピクセル比^2 × 4バイト
例: 300×200 の要素を DPR 2 の画面で → 600×400×4 = 約 0.96 MB
レイヤーが増えると、(1) GPUメモリ消費が積み上がり、(2) 毎フレームの合成処理そのもの(重ね合わせるレイヤー枚数)が増え、(3) レイヤー管理のためのメインスレッド側の簿記も増えます。数十枚程度なら問題になりにくいですが、リストの全項目に will-change を当てるといった操作で数百〜数千枚に膨れると、メモリ圧迫とジャンクを招きます。これが**レイヤー爆発(layer explosion)**です。
will-change: transform を全要素に「念のため」付けると、ブラウザはそれら全てを今すぐ昇格させます。動いていない要素まで個別テクスチャを確保するため、GPUメモリと合成枚数が無駄に増え、本来軽いはずの最適化が全体の重さの原因に転じます。will-change は「最適化のヒント」であって「速くする魔法」ではありません。
will-change の正しい使い方
will-change は、これから変化するプロパティを事前にブラウザへ伝え、変化が始まる前にレイヤー昇格やテクスチャ確保を済ませてもらうためのヒントです。要点は短命に使うことに尽きます。
/* 悪い:常時昇格。動いていなくてもレイヤーを確保し続ける */
.item { will-change: transform; }
// 良い:動く直前に付け、アニメーションが終わったら外す
el.addEventListener('pointerenter', () => {
el.style.willChange = 'transform'; // 直前に昇格を予約
});
el.addEventListener('transitionend', () => {
el.style.willChange = 'auto'; // 終わったら解放してレイヤーを返す
});
| 使い方 | 効果 | 副作用 |
|---|---|---|
| 動く直前に付け、直後に外す | 昇格コストを変化前に前倒し | ほぼなし(理想形) |
| 少数の常時アニメ要素に静的指定 | 毎回の昇格を省く | レイヤー数ぶんのVRAM |
| 多数要素へ常時指定 | 実質効果なし | レイヤー爆発・メモリ圧迫 |
実務指針は次の通りです。
- 対象を
transform/opacityに限る:これら以外のwill-change(例width)は合成で完結せず、ヒントの旨みが薄い。 - 指定数を絞る:同時にアニメーションする少数要素にだけ当てる。「全部に予防的に」は禁物。
- 付けたら外す:JSで動的に付与し、
transitionend/animationendでautoに戻してレイヤーを解放する。 - 実測で確かめる:DevTools の Layers パネルでレイヤー枚数とメモリを、Rendering の Layer borders で昇格範囲を可視化する。
まとめ
レンダリング問では、(1) 合成レイヤーは独立GPUテクスチャで、位置・不透明度の変化は再合成だけで反映できること、(2) 昇格は3D transform・animation対象・will-change・video/canvas・fixed/sticky・オーバーラップなどの発見的規則で決まること、(3) transform / opacity がプロパティツリー経由でコンポジタスレッドに乗るためメインスレッド外で動くこと、(4) レイヤーは1枚ごとにVRAMと合成コストを食い、will-change 乱用がレイヤー爆発を招くこと、の4点が頻出です。
ブラウザは要素を合成レイヤーへ昇格させ、独立したGPUテクスチャとして保持します。transform / opacity はプロパティツリーの値差し替えでテクスチャを変えずに反映でき、コンポジタスレッドで駆動されるため、メインスレッドのJSが詰まっても60fpsを保てます。一方でレイヤーは1枚ごとにVRAMと合成コストを消費するため、will-change を全要素へ常時付けるとレイヤー爆発で逆効果になります。will-change は「動く直前に付け、終わったら外す」短命のヒントとして使うのが正解です。さらに踏み込むなら リフローとリペイントのコスト のプロパティ仕分けと Webパフォーマンス の計測手法を合わせて、分類から実測・改善までを一本の筋道で押さえてください。
Web/フロントエンド Article
ブラウザのレイヤー化とGPUコンポジットの判断基準を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
レンダリング
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
transform と opacity はレイヤーのテクスチャを変えずプロパティツリーの値を差し替えるだけで反映でき、合成はコンポジタスレッドで走るためメインスレッドのJSが詰まっても止まらない。だから「動かすなら transform、消すなら opacity」が原則になる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「レンダリング / ブラウザ」に近いか確認する。
- 強みである「ブラウザは特定の要素を合成レイヤー(独立したGPUテクスチャ)へ昇格させ、再合成だけで位置や不透明度を更新する。昇格は3D transform・animation対象・will-change・video/canvas・重なり順の都合などブラウザ内部の発見的規則で決まり、開発者が直接は制御しない。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。