ピクセルを物理限界で回せ:ブラウザ・コンポジット(合成)の極限アーキテクチャ
フロントエンドの世界において、「リフロー(Reflow / Layout)とリペイント(Repaint / Paint)を避けよ」という格言は、もはや耳にタコができるほど語り尽くされてきた。
しかし、我々が立ち向かう現代のWebアプリケーションは、数十万のDOMノード、絶え間なく動く高解像度のアニメーション、そしてモバイルから超高解像度ディスプレイ(Retina/8K)までカバーしなければならない苛烈な戦場だ。単に「`transform` を使えば速くなる」といった表面的な知識だけで、この戦場を生き抜くことはできない。
その先に待っているのは、メモリ(VRAM)の枯渇によるサイレントクラッシュ、スクロールが1フレームだけ引っかかる微細なガタつき(Jank)、そしてメインスレッドとCompositorスレッドの非同期な競合による描画のズレといった、泥臭く、かつ致命的なバグの数々である。
本稿では、ブラウザレンダリングの最終工程にして、GPUというハードウェアを直接ドライブする心臓部である「コンポジット(合成)」のアーキテクチャを解剖する。公式マニュアルのコピーではない。ピクセルが画面に物理的に描画されるその瞬間までに、ブラウザの内部で何が起きているのか、その深淵を覗きにいこう。
—
1. 救世主としての「コンポジタースレッド」
ブラウザのレンダリングを理解する上で、最も重要なパラダイムシフトは「メインスレッド(Main Thread)」と「コンポジタースレッド(Compositor Thread)」の完全な分離である。
JavaScriptの実行、DOMの構築、CSSの解析、レイアウト計算(リフロー)、そして実際のペイント(描画コマンドの生成)は、すべてシングルスレッドであるメインスレッドで行われる。ここは常に過密状態であり、JSの重い処理が走れば簡単にブロッキングが発生する。
もし、画面のスクロールやアニメーションの毎フレーム(1秒間に60回〜120回)をメインスレッドで処理していたら、Webはまともに動かない。そこで登場するのが、Chromium(Blink)で言えば `Chrome Compositor (cc)` と呼ばれる、別スレッドで動作するコンポジタースレッドだ。
[メインスレッド]
DOM / CSSJS ─> Layout ─> Paint (描画コマンドの生成)
│
(コミット / コピー)
▼
[コンポジタースレッド]
Tiling (タイル分割) ─> Raster (ビットマップ化) ─> Draw (GPUによる合成・画面出力)
コンポジットの役割は、画面をいくつかの「レイヤー(層)」に分割し、それぞれのレイヤーをあらかじめ画像(ビットマップ)として描画しておき、最終的な画面表示の際にGPUのメモリ(VRAM)上で重ね合わせて表示位置を調整するだけにすることだ。
この「重ね合わせるだけ」の処理こそが、GPUの得意とするテクスチャマッピング(テクスチャの描画)であり、CPUを介さずにミリ秒未満で実行できる。これが、`transform` や `opacity` を使ったアニメーションが、メインスレッドが100%ビジーで固まっていても滑らかに動き続ける理由である。
—
2. レイヤー昇格(Promote)のメカニズムと裏のコスト
では、すべての要素を個別のレイヤーにすれば、リペイントが一切発生しない最強のサイトができるのではないか?
答えは「ノー」だ。レイヤーの作成には、極めて重いコストが伴う。
2.1 RenderObject から GraphicsLayer へ
ブラウザ内部では、DOM tree から `RenderObject` tree が作られ、それが重なり順(Stacking Context)に基づいて `RenderLayer` tree へと整理される。そして特定の条件を満たした要素だけが、独自の描画メモリ領域を持つ `GraphicsLayer`(合成レイヤー)へと昇格(Promote)する。
昇格の主なトリガー(Composite Trigger)は以下の通りだ。
- `transform` や `opacity` を用いた3D変形(例: `translate3d`, `translateZ`)
- `
- `will-change: transform, opacity` などの明示的な指定
- CSSによるハードウェア加速されたアニメーション実行中
2.2 ラスタライズ(Rasterization)の真実
レイヤーに昇格した要素は、直ちに画面に描画されるわけではない。
ブラウザは、レイヤー内の描画命令(「ここに半径10pxの円を描く」「ここにフォントAを配置する」といったSkiaのPaintOpコマンド群)を、実際のピクセルデータ(ビットマップ)に変換する必要がある。この変換プロセスをラスタライズと呼ぶ。
現代のブラウザは、GPUを直接使ってこのラスタライズを行う(GPU Rasterization)。しかし、巨大なレイヤーを丸ごとラスタライズするのはメモリの無駄だ。そのため、コンポジタースレッドはレイヤーを「タイル(Tiles)」(通常256×256ピクセルなど)と呼ばれる単位に分割し、ビューポート(表示領域)に近いタイルから優先的にラスタライズして、GPUのテクスチャメモリ(VRAM)に転送する。
ここでメモリ管理の泥臭い現実が牙をむく。
VRAMは有限だ。特にモバイル端末では、システムの共有メモリの一部がVRAMとして割り当てられるため、大量の、あるいは巨大なGraphicsLayerを作ると、システム全体のメモリを圧迫する。メモリが不足すると、ブラウザはレイヤーのキャッシュを破棄せざるを得ず、スクロールするたびに激しいラスタライズ(再ラスタライズ)が走り、画面が白く点滅したり、動作が著しく重くなったりする。最悪の場合、OSによってブラウザのタブプロセス自体が強制終了(OOM: Out Of Memory)させられる。
—
3. 暗黙の昇格(Implicit Promotion)という静かなる爆弾
開発者が意図的に `will-change` を指定していなくても、ブラウザが勝手にレイヤーを増殖させてしまう現象がある。これが「暗黙の昇格(Implicit Promotion)」だ。
3.1 メカニズム
ある要素 `A` が、3Dトランスフォームなどによって `GraphicsLayer`(合成レイヤー)に昇格したとする。
このとき、HTMLのマークアップ順や `z-index` の関係上、要素 `A` よりも上に重なって描画されなければならない通常の要素 `B` が存在した場合、何が起きるか。
[画面の手前]
▲ 要素 B (通常のz-index上位要素) ───> 【強制的にGraphicsLayerに昇格!】 (暗黙の昇格)
│
│ 要素 A (will-change等で昇格した要素)
[画面の奥]
もし要素 `B` を通常のレイヤー(背景レイヤー)に残したままにすると、ブラウザは「要素 `A`(合成レイヤー)」を描画した後に、「要素 `B`(通常のペイント)」を重ねなければならない。しかし、合成レイヤーはGPU側で最終的に重ね合わされるため、CPU側のペイント順序だけで正しい重なりを再現できなくなる。
この描画順序(Stacking Order)の崩壊を防ぐため、ブラウザは「合成レイヤーの上に重なるすべての要素」を強制的に独自の合成レイヤーへと昇格させる。これが暗黙の昇格だ。
3.2 レイヤー爆発(Layer Explosion)のデバッグ
これがリスト表示のアイテムや、複雑なDOM構造の中で発生すると、1つの要素をアニメーションさせただけで、その上流にある数百個の要素がイナゴの大量発生のごとく一斉に `GraphicsLayer` 化する「レイヤー爆発」を引き起こす。
これを防ぐためには、Chrome DevToolsの強力な武器である 「Layers」パネル(非表示の場合は設定から有効化する)を常に監視する必要がある。
- どの要素がなぜ昇格しているのか(原因は「Why Created」セクションに `Composite reason: Might overlap with a composited layer` などと明示される)。
- レイヤーに無駄な余白が含まれておらず、適切なサイズになっているか。
—
4. 非同期の競合:Jankと「Non-fast Scrollable Region」の正体
ユーザーがモバイル画面をスワイプしたとき、コンポジタースレッドはメインスレッドを介さずに、GPU上でレイヤーの位置を移動させて滑らかなスクロールを実現する。
しかし、この美しき非同期構造を破壊するコードが存在する。それが、アクティブなイベントリスナーである。
4.1 Non-fast Scrollable Region
JavaScriptで以下のようなコードを書いたとしよう。
// 画面全体のスクロール/タッチイベントを監視する
window.addEventListener(‘touchstart’, (event) => {
// 何らかのカスタム処理
if (shouldPreventScroll) {
event.preventDefault(); // スクロールをキャンセルする可能性がある
}
});
この瞬間、ブラウザのコンポジタースレッドは、このイベントリスナーがバインドされた領域を Non-fast Scrollable Region(非高速スクロール領域) としてマークする。
なぜか。コンポジタースレッドは「ユーザーがスクロールしようとしたとき、JS側で `preventDefault()` が呼ばれてスクロールがキャンセルされるかどうか」を事前に予知できないからだ。
結果として、ユーザーが画面に触れるたびに、コンポジタースレッドはメインスレッドに対して「ねえ、このタッチイベント、キャンセルする?」と問い合わせ、メインスレッドからの応答(JSの実行完了)を待つ。
もしメインスレッドで別の重い処理が走っていたら、その間スクロールは完全にフリーズする。これが、タッチやホイール操作時に発生する「ガタつき(Jank)」の正体だ。
4.2 解決策:Passive Event Listeners
この動作を回避するためには、ブラウザに対して「このイベントハンドラ内では絶対に `preventDefault()` を呼ばないから、私の返事を待たずに即座にスクロールしてくれ」と宣言しなければならない。それが `passive: true` オプションだ。
// passive: true を指定することで、コンポジタースレッドはJSの処理を待たずに即座にスクロールを描画できる
window.addEventListener(‘touchstart’, (event) => {
// ここで event.preventDefault() を呼ぶとブラウザに無視され、コンソールに警告が出る
}, { passive: true });
現在のモダンブラウザでは、ドキュメントルート(`window`, `document`, `body`)に対する `touchstart` や `touchmove` はデフォルトで `passive: true` に設定されるようになっているが、特定のコンポーネント内や古いブラウザ環境、あるいは `wheel` イベントなどでは依然としてこの挙動がJankの主犯となる。常に意識して明示的に制御するべきだ。
—
5. 実践:VRAMとレンダリング負荷を極限まで最適化する
ここからは、実際のアプリケーション開発において、コンポジットの仕組みを完全に掌握し、メモリ効率と描画パフォーマンスを極限まで高めるための具体的な実装パターンを示す。
5.1 `will-change` の動的ライフサイクル管理
`will-change` は「この要素は将来変更されるから、あらかじめ合成レイヤーに昇格させてラスタライズしておいてくれ」とブラウザに伝える強力なヒントだ。しかし、CSSでこれを恒常的に指定しておくことは、VRAMを常に占有し続けることを意味する。
正解は、「必要な瞬間の直前に付与し、処理が終わったら即座に剥がす」という動的なライフサイクル管理である。
以下のコードは、ユーザーがマウスホバーした、あるいはスクロールで画面に入りそうになった瞬間にのみ `will-change` を適用し、アニメーション(トランジション)終了時に自動的にそれを破棄する、メモリ効率を最大化したクラスの実装例だ。
/
- 合成レイヤー(GraphicsLayer)への昇格を動的に制御するマネージャー
/
class DynamicCompositingManager {
/
- @param {HTMLElement} element – 対象のDOM要素
/
constructor(element) {
this.element = element;
this.isComposited = false;
// バインド
this.prepare = this.prepare.bind(this);
this.cleanup = this.cleanup.bind(this);
this.init();
}
init() {
// ユーザーが要素にインタラクションしそうな気配(ホバー、フォーカス、タッチ開始)を検知
this.element.addEventListener(‘mouseenter’, this.prepare, { passive: true });
this.element.addEventListener(‘focusin’, this.prepare, { passive: true });
this.element.addEventListener(‘touchstart’, this.prepare, { passive: true });
// トランジションやアニメーションが終了したら、レイヤーを解放する
this.element.addEventListener(‘transitionend’, this.cleanup, { passive: true });
this.element.addEventListener(‘animationend’, this.cleanup, { passive: true });
}
/
- アニメーション開始前に合成レイヤーへ昇格(ラスタライズを事前に済ませる)
/
prepare() {
if (this.isComposited) return;
// ブラウザに合成レイヤーの準備を促す
// これにより、実際の変形(transform)が始まる前に別スレッドでラスタライズが完了する
this.element.style.willChange = ‘transform, opacity’;
this.isComposited = true;
console.log(‘[Compositor] Layer promoted. VRAM allocated.’);
}
/
- アニメーション終了後に合成レイヤーを解除(VRAMを解放する)
/
cleanup() {
if (!this.isComposited) return;
// will-change を解除することで、通常のペイントレイヤー(背景)に戻し、メモリを解放する
this.element.style.willChange = ‘auto’;
this.isComposited = false;
console.log(‘[Compositor] Layer demoted. VRAM released.’);
}
/
- イベントリスナーのクリーンアップ(メモリリーク防止)
/
destroy() {
this.element.removeEventListener(‘mouseenter’, this.prepare);
this.element.removeEventListener(‘focusin’, this.prepare);
this.element.removeEventListener(‘touchstart’, this.prepare);
this.element.removeEventListener(‘transitionend’, this.cleanup);
this.element.removeEventListener(‘animationend’, this.cleanup);
}
}
// 実際の適用例
const heavyCard = document.querySelector(‘.heavy-card’);
if (heavyCard) {
new DynamicCompositingManager(heavyCard);
}
5.2 CSSにおける重なり順(z-index)の厳密な設計
暗黙の昇格(Implicit Promotion)によるレイヤー爆発を防ぐため、CSSの構造設計には明確なルールを課す必要がある。
もっとも単純で強力なルールは、「合成レイヤー(`will-change` や `transform` アニメーションを適用する要素)は、兄弟要素の中で常に最も手前(`z-index` が最大)に配置する」ことだ。
/ 悪い例:暗黙の昇格を引き起こす /
.container {
position: relative;
}
.animated-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
will-change: transform; / 合成レイヤー化 /
/ z-index の指定がないため、後続の .content がこの上に重なる /
}
.content {
position: relative;
/ .animated-background の上に重なるため、ブラウザは .content も強制的に合成レイヤーに昇格させる /
}
/ 良い例:合成レイヤーを明示的に背後に配置し、重なり順を固定する、
あるいは合成レイヤーを最前面に持ってくる /
.container-fixed {
position: relative;
/ コンテナ自体に Stacking Context を作り、影響範囲をこの中に閉じ込める /
contain: layout style;
}
.animated-background-fixed {
position: absolute;
z-index: 1; / 明示的に低いz-indexを指定 /
will-change: transform;
}
.content-fixed {
position: relative;
z-index: 2; / 昇格した要素より上に重ねる必要があるなら、この要素自体も昇格させるか、重なりを回避する設計にする /
/
最適解:
もし .content-fixed がアニメーションしない静的なテキストなら、
背景のアニメーション領域とレイアウト的に重ならないように設計するか、
あるいは .content-fixed 自体を z-index: 1 にして、
アニメーションする要素(.animated-background-fixed)を
視覚的に干渉しない別レイヤーとして z-index: 2(最前面)に配置する。
/
}
さらに、モダンCSSの `contain: layout style;`(または `contain: paint;`)を使用することで、レイアウトやペイント、そしてコンポジットの計算境界を明示的に区切ることができる。これにより、万が一レイヤー昇格が発生しても、その影響がドキュメント全体に伝播するのを防ぐことができる。
—
6. ブラウザという「物理ハードウェア」をリスペクトする
Webフロントエンドは、抽象化されたサンドボックスの中で動くコードだ。しかし、そのコードが最終的に駆動するのは、シリコンでできた半導体であり、物理的なディスプレイの走査線である。
コンポジットの仕組みを理解するということは、ブラウザという「ソフトウェア」の向こう側にある、GPUメモリの帯域幅、バス転送速度、そしてピクセル充填率(Fill Rate)という物理的な限界を意識することに他ならない。
- リフローを避けるのは、メインスレッドのCPU時間を奪わないため。
- リペイントを避けるのは、ラスタライズというCPU/GPUの計算コストを抑えるため。
- コンポジットを最適化するのは、GPUのVRAMを枯渇させず、グラフィックパイプラインの同期を美しく保つため。
優れたアーキテクトは、美しいJavaScriptを書き、堅牢なCSSを設計するだけでなく、それらがブラウザエンジンのパイプラインを通り、ハードウェアに届くまでの物理的なロードマップを描くことができる。
次にCSSに `transform` を書き、あるいはイベントリスナーを登録するとき、背後で静かに、しかし超高速でピクセルを操っている「コンポジタースレッド」の鼓動に、少しだけ耳を澄ませてみてほしい。

コメント