RenderingNG アーキテクチャの概要

Chris Harrelson
Chris Harrelson

前回の投稿では、RenderingNG アーキテクチャの目標と主な特性について説明しました。この投稿では、各コンポーネントがどのように設定されているかと、レンダリング パイプラインがそれらをどのように処理するかについて説明します。

レンダリングのタスクは次のとおりです。

  1. コンテンツを画面上のピクセルにレンダリングする
  2. ある状態から別の状態へのコンテンツの視覚効果をアニメーション化する
  3. 入力に応じてスクロールします。
  4. 適切な場所に効率的に入力をルーティングして、デベロッパー スクリプトやその他のサブシステムが応答できるようにします。

レンダリングするコンテンツは、各ブラウザタブのフレームツリーとブラウザの UI です。タッチ スクリーン、マウス、キーボード、その他のハードウェア デバイスからの未加工の入力イベントのストリーム。

各フレームには次のものが含まれます。

  • DOM 状態
  • CSS
  • キャンバス
  • 画像、動画、フォント、SVG などの外部リソース

フレームは HTML ドキュメントとその URL です。ブラウザタブに読み込まれるウェブページには、トップレベルのフレーム、トップレベルのドキュメントに含まれる各 iframe の子フレーム、その再帰的な iframe の子孫が含まれます。

視覚効果とは、スクロール、変換、クリップ、フィルタ、不透明度、統合など、ビットマップに適用されるグラフィック操作です。

アーキテクチャ コンポーネント

RenderingNG では、これらのタスクが論理的に複数のステージとコード コンポーネントに分割されます。コンポーネントは、最終的にさまざまな CPU プロセス、スレッド、それらのスレッド内のサブコンポーネントに配置されます。いずれも、すべてのウェブ コンテンツの信頼性スケーラブルなパフォーマンス拡張性を実現するうえで重要な役割を果たします。

レンダリング パイプライン構造

以下のテキストで説明するレンダリング パイプラインの図。

レンダリングはパイプラインで進行し、その過程でいくつかのステージとアーティファクトが作成されます。各ステージは、レンダリング内で明確に定義されたタスクを 1 つ実行するコードを表します。アーティファクトは、ステージの入力または出力であるデータ構造です。図では、入力または出力を矢印で示しています。

このブログ投稿では、アーティファクトについてはあまり詳しく説明しません。これについては、次の投稿の RenderingNG における主なデータ構造とそのロールで説明します。

パイプラインのステージ

上の図では、ステージがどのスレッドまたはプロセスで実行されているかを示す色で示されています。

  • 緑: レンダリング プロセスのメインスレッド
  • 黄: レンダリング プロセス コンポジタ
  • オレンジ: ビジュアリゼーションのプロセス

場合によっては、状況に応じて複数の場所で実行できます。そのため、2 つの色を使用するものもあります。

ステージは次のとおりです。

  1. アニメーション化: 計算されたスタイルを変更し、宣言的なタイムラインに基づいて時間の経過とともにプロパティ ツリーを変更します。
  2. スタイル: DOM に CSS を適用し、スタイル計算を作成します。
  3. レイアウト: 画面上の DOM 要素のサイズと位置を決定し、不変フラグメント ツリーを作成します。
  4. プリペイント: プロパティ ツリーを計算し、必要に応じて既存のディスプレイ リストと GPU テクスチャ タイルinvalidateします。
  5. スクロール: プロパティ ツリーを変更して、ドキュメントとスクロール可能な DOM 要素のスクロール オフセットを更新します。
  6. Paint: DOM から GPU テクスチャ タイルをラスターする方法を記述したディスプレイ リストを計算します。
  7. Commit: プロパティ ツリーと表示リストをコンポジタ スレッドにコピーします。
  8. レイヤ化: ディスプレイ リストを合成レイヤリストに分割し、独立したラスタライズとアニメーションを実現します。
  9. ワークレットのラスター、デコード、ペイント: ディスプレイ リスト、エンコードされた画像、ペイント ワークレット コードを、それぞれ GPU テクスチャ タイルに変換します。
  10. 有効にする: 視覚効果とともに、GPU タイルを描画して画面に配置する方法を表すコンポジタ フレームを作成します。
  11. Aggregate: 表示可能なすべてのコンポジタ フレームのコンポジタ フレームを単一のグローバル コンポジタ フレームに結合します。
  12. 描画: GPU で集約されたコンポジタ フレームを実行して、画面上にピクセルを作成します。

レンダリング パイプラインのステージは、不要であればスキップできます。たとえば、視覚効果のアニメーションとスクロールでは、レイアウト、プリペイント、ペイントをスキップできます。そのため、図ではアニメーションとスクロールが黄色と緑色のドットでマークされています。視覚効果のためにレイアウト、プリペイント、ペイントをスキップできる場合は、それらをすべてコンポジタ スレッドで実行し、メインスレッドをスキップできます。

ブラウザの UI レンダリングはここでは直接示していませんが、この同じパイプラインの簡略版と考えることができます(実際、その実装はコードの大部分を共有します)。動画(直接図示されていません)は通常、フレームを GPU テクスチャ タイルにデコードしてからコンポジタ フレームと描画ステップに接続する独立したコードを使用してレンダリングします。

プロセスとスレッドの構造

CPU プロセス

複数の CPU プロセスを使用することで、サイト間やブラウザの状態からパフォーマンスとセキュリティを分離し、GPU ハードウェアから安定性とセキュリティを分離できます。

CPU プロセスのさまざまな部分の図

  • レンダリング プロセスでは、単一のサイトとタブの組み合わせに対する入力をレンダリング、アニメーション化、スクロールし、ルーティングします。 レンダリング プロセスは多数あります。
  • ブラウザ プロセスは、ブラウザ UI の入力(URL バー、タブのタイトル、アイコンなど)をレンダリング、アニメーション化、ルーティングし、残りのすべての入力を適切なレンダリング プロセスにルーティングします。ブラウザ プロセスは 1 つだけです。
  • 可視化プロセスは、複数のレンダリング プロセスとブラウザ プロセスからの合成を集約します。GPU を使用してラスターと描画を行う。Viz プロセスは 1 つだけです。

異なるサイトでは常に異なるレンダリング プロセスが発生します。(実際には、常にパソコンを使用し、モバイルでは可能な限り行います。以下では「always」と記述しますが この注意事項はどの部分にも当てはまります)

通常、同じサイトの複数のブラウザタブやウィンドウは、互いに関連している(一方が他方を開いている)場合を除き、異なるレンダリング プロセスで動作します。パソコンの Chromium でメモリ負荷が強くなると、同じサイトの複数のタブが、関連していなくても同じレンダリング プロセスに表示されることがあります。

1 つのブラウザタブ内では、異なるサイトのフレームは常にそれぞれ異なるレンダリング プロセスで動作しますが、同じサイトのフレームは常に同じレンダリング プロセスになります。レンダリングの観点から見ると、複数のレンダリング プロセスの重要な利点は、クロスサイトの iframe とタブが互いにパフォーマンスを分離できることです。さらに、オリジンはさらなる分離にオプトインできます。

すべての Chromium に 1 つの Viz プロセスがあります。 結局のところ、通常は描画先となる GPU と画面は 1 つのみです。Viz を独自のプロセスに分離することで、GPU ドライバやハードウェアのバグが発生した場合の安定性が向上します。また、Vulkan などの GPU API で重要なセキュリティ分離にも適しています。また、一般的なセキュリティにおいても重要です。

ブラウザには多くのタブとウィンドウがあり、そのすべてに描画するブラウザ UI ピクセルがあるので、なぜブラウザ プロセスが 1 つしかないのかと疑問に思われるかもしれません。これは、一度に 1 つのタブのみがフォーカスされるためです。実際、非表示のブラウザタブはほとんど無効になり、すべての GPU メモリが破棄されます。しかし、レンダリング プロセスにも(WebUI と呼ばれる)複雑なブラウザ UI レンダリング機能が実装されつつあります。これはパフォーマンスを分離する理由ではなく、Chromium の使いやすいウェブ レンダリング エンジンを活用するためのものです。

古い Android デバイスでは、WebView で使用されるとレンダリングとブラウザ プロセスが共有されます(これは Android 版 Chromium には通常は適用されず、WebView にのみ適用されます)。WebView では、ブラウザ プロセスは埋め込みアプリと共有され、WebView のレンダリング プロセスは 1 つだけです。

保護された動画コンテンツをデコードするユーティリティ プロセスが存在する場合もあります。このプロセスは上図には示されていません。

スレッド

スレッドは、低速なタスク、パイプラインの並列化、複数のバッファリングにもかかわらず、パフォーマンスの分離と応答性の達成に役立ちます。

記事で説明されているレンダリング プロセスの図。

  • メインスレッドは、スクリプト、レンダリング イベントループ、ドキュメントのライフサイクル、ヒットテスト、スクリプト イベントのディスパッチ、HTML や CSS などのデータ形式の解析を実行します。
    • メインスレッド ヘルパーは、エンコードやデコードが必要な画像のビットマップや blob の作成などのタスクを実行します。
    • ウェブワーカーはスクリプトを実行し、OffscreenCanvas のレンダリング イベントループを実行します。
  • コンポジタ スレッドは、入力イベントの処理、ウェブ コンテンツのスクロールとアニメーションの実行、ウェブ コンテンツの最適なレイヤ化の計算、画像のデコード、ワークレットのペイント、ラスタータスクの調整を行います。
    • コンポジタ スレッド ヘルパーは、Viz のラスタータスクを調整し、画像デコード タスク、ワークレットのペイント、フォールバック ラスターの実行を行います。
  • メディア、デマルチプレクサ、オーディオ出力スレッドは、動画と音声のストリームをデコード、処理、同期します。(動画はメインのレンダリング パイプラインと並行して実行されます)。

メインスレッドとコンポジタ スレッドを分離することは、アニメーションのパフォーマンス分離とメインスレッド処理からのスクロールにとって非常に重要です。

同じサイトの複数のタブやフレームが最終的に同じプロセスに入る場合でも、レンダリング プロセスごとにメインスレッドは 1 つだけです。ただし、さまざまなブラウザ API で実行される処理からパフォーマンスは分離されています。たとえば、Canvas API における画像のビットマップと blob の生成は、メインスレッドのヘルパー スレッドで実行されます。

同様に、レンダリング プロセスごとに、コンポジタ スレッドは 1 つだけです。一般的に、コンポジタ スレッドで非常に高コストなオペレーションはすべてコンポジタ ワーカー スレッドまたは Viz プロセスに委任され、この作業は入力ルーティング、スクロール、アニメーションと並行して実行できるため、通常は 1 つしか問題になりません。コンポジタ ワーカー スレッドは Viz プロセスで実行されるタスクを調整しますが、ドライバのバグなどの Chromium では制御できない理由により、あらゆる場所での GPU アクセラレーションが失敗する可能性があります。このような場合、ワーカー スレッドは CPU でフォールバック モードで処理を行います。

コンポジタ ワーカー スレッドの数は、デバイスの機能によって異なります。たとえば、デスクトップは通常、モバイル デバイスに比べて CPU コアが多く、バッテリー制約が少ないため、使用するスレッド数が多くなります。これはスケールアップとスケールダウンの例です。

興味深い点として、レンダリング プロセスのスレッド アーキテクチャでは、次の 3 種類の最適化パターンが応用されています。

  • ヘルパー スレッド: 長時間実行サブタスクを追加のスレッドに送信して、同時に発生する他のリクエストに対する親スレッドの応答性を維持します。この手法の例としては、メインスレッド ヘルパーとコンポジタ ヘルパー スレッドがあります。
  • 複数バッファリング: 新しいコンテンツのレンダリング中に以前にレンダリングされたコンテンツを表示し、レンダリングのレイテンシを隠します。コンポジタ スレッドはこの手法を使用します。
  • パイプラインの並列化: レンダリング パイプラインを複数の場所で同時に実行します。このように、スクロールとアニメーションは並行して実行できるため、メインスレッドのレンダリングの更新が行われていても、スクロールとアニメーションを高速に処理できます。

ブラウザ プロセス

レンダリングと合成のスレッドと、レンダリングと合成のスレッド ヘルパーの関係を示すブラウザのプロセス図。

  • レンダリングと合成のスレッドは、ブラウザ UI での入力に応答し、他の入力を正しいレンダリング プロセスにルーティングします。ブラウザの UI のレイアウトと描画を行います。
  • レンダリングと合成のスレッド ヘルパーは、画像デコード タスクとフォールバック ラスターまたはデコードを実行します。

ブラウザ プロセスのレンダリングと合成スレッドは、メインスレッドとコンポジタ スレッドが 1 つに統合される点を除き、レンダリング プロセスのコードと機能に似ています。 設計上、長いメインスレッド タスクからパフォーマンスを分離する必要はないため、必要なスレッドは 1 つだけです。

ビジュアリゼーション プロセス

Viz プロセスに GPU メインスレッドとディスプレイ コンポジタ スレッドが含まれることを示す図。

  • GPU メインスレッドのラスターは、リストと動画フレームを GPU テクスチャ タイルとして表示し、コンポジタ フレームを画面に描画します。
  • ディスプレイ コンポジタ スレッドは、各レンダリング プロセスとブラウザ プロセスからの合成を、画面に表示するための単一のコンポジタ フレームに集約して最適化します。

ラスターと描画は、どちらも GPU リソースに依存しており、GPU をマルチスレッドで確実に使用するのが難しいため、通常は同じスレッドで実行されます(GPU へのマルチスレッド アクセスが容易になることが、新しい Vulkan 標準を開発する理由の一つです)。Android WebView には、WebView がネイティブ アプリに埋め込まれる方法のため、描画用に OS レベルのレンダリング スレッドが別途存在します。他のプラットフォームでこのようなスレッドが今後提供される可能性があります。

ディスプレイ コンポジタは常に応答可能でなければならず、GPU メインスレッドで起こり得る速度低下の原因をブロックしない必要があるため、別のスレッドにあります。GPU メインスレッドの速度低下の原因の一つは、ベンダー固有の GPU ドライバなど、Chromium 以外のコードの呼び出しです。これは、予測が困難な方法で遅くなる可能性があります。

コンポーネント構造

各レンダリング プロセスのメインスレッドまたはコンポジタ スレッド内には、構造化された方法で相互作用する論理ソフトウェア コンポーネントがあります。

レンダリング プロセスのメインスレッド コンポーネント

Blink レンダラの図。

  • Blink レンダラ:
    • ローカル フレームツリー フラグメントは、ローカル フレームのツリーとフレーム内の DOM を表します。
    • DOM API と Canvas API コンポーネントには、これらの API がすべて実装されています。
    • ドキュメント ライフサイクル ランナーは、commit ステップまでのレンダリング パイプライン ステップを実行します。
    • 入力イベントのヒットテストとディスパッチ コンポーネントは、ヒットテストを実行してイベントのターゲットとなっている DOM 要素を特定し、入力イベントのディスパッチ アルゴリズムとデフォルトの動作を実行します。
  • レンダリング イベントループのスケジューラとランナーは、イベントループで何をいつ実行するかを決定します。デバイスのディスプレイと一致する頻度でレンダリングが行われるようにスケジュールされます。

フレームツリーの図。

ローカル フレームツリー フラグメントは、考えるには少し複雑です。フレームツリーはメインページとその子 iframe を再帰的に処理します。フレームは、レンダリング プロセスでレンダリングされる場合、ローカルであり、それ以外の場合はリモートです。

レンダリング プロセスに応じてフレームに色を付けることを想像できます。上の画像では、緑色の円はすべて 1 つのレンダリング プロセスのフレームで、オレンジ色の円は 2 番目のレンダリング プロセス、青色の円は 3 番目のレンダリング プロセスのフレームです。

ローカル フレームツリー フラグメントは、フレームツリー内の同じ色の連結コンポーネントです。画像には 4 つのローカル フレーム ツリーがあります。サイト A に 2 つ、サイト B に 1 つ、サイト C に 1 つあります。各ローカル フレームツリーは、独自の Blink レンダラ コンポーネントを取得します。ローカル フレームツリーの Blink レンダラは、他のローカル フレームツリーと同じレンダリング プロセスにある場合もあれば、そうでない場合もあります(前述のとおり、レンダリング プロセスの選択方法によって決まります)。

レンダリング プロセス コンポジタ スレッドの構造

レンダリング プロセス コンポジタ コンポーネントを示す図。

レンダリング プロセス コンポジタ コンポーネントには次のものがあります。

  • 合成レイヤリスト、ディスプレイ リスト、プロパティ ツリーを維持するデータハンドラ
  • レンダリング パイプラインのアニメーション、スクロール、合成、ラスター、デコード、有効化の各ステップを実行するライフサイクル ランナー。(アニメーションとスクロールは、メインスレッドとコンポジタの両方で行われる可能性があります)。
  • 入力とヒットのテストハンドラは、合成レイヤの解決で入力処理とヒットテストを実行し、コンポジタ スレッドでスクロール操作を実行できるかどうか、およびどのレンダリング プロセスのヒットテストをターゲットにするかを決定します。

実例

それでは、例を使ってアーキテクチャを具体的に見てみましょう。この例では、次の 3 つのタブがあります。

タブ 1: foo.com

<html>
  <iframe id=one src="foo.com/other-url"></iframe>
  <iframe  id=two src="bar.com"></iframe>
</html>

タブ 2: bar.com

<html>
 …
</html>

タブ 3: baz.com html <html> … </html>

これらのタブのプロセス、スレッド、コンポーネント構造は次のようになります。

タブの処理の図。

それでは、レンダリングに関する 4 つの主なタスクについて、それぞれを例に見ていきましょう。

  1. コンテンツを画面上のピクセルにレンダリングします。
  2. ある状態から別の状態へのコンテンツの視覚効果をアニメーション化します。
  3. 入力に応じてスクロールします。
  4. 入力を適切な場所に効率的にルーティングして、デベロッパー スクリプトやその他のサブシステムが応答できるようにします。

タブ 1 の変更した DOM をレンダリングするには:

  1. デベロッパー スクリプトが foo.com のレンダリング プロセスで DOM を変更する場合。
  2. Blink レンダラはコンポジタにレンダリングを行う必要があることを伝えます。
  3. コンポジタは Viz に対し、レンダリングを行う必要があることを伝えます。
  4. Viz がレンダリングの開始をコンポジタに通知します。
  5. コンポジタは開始信号を Blink レンダラに転送します。
  6. メインスレッド イベントのループ ランナーがドキュメントのライフサイクルを実行します。
  7. メインスレッドがコンポジタ スレッドに結果を送信します。
  8. コンポジタ イベントループ ランナーが合成ライフサイクルを実行します。
  9. ラスタータスクはすべて Viz のラスター用に送信されます(多くの場合、タスクは複数あります)。
  10. ビジュアリゼーションは、GPU 上でコンテンツをラスター化します。
  11. Viz がラスタータスクの完了を確認します。注: Chromium は多くの場合、ラスターの完了を待たずに、代わりに同期トークンと呼ばれるものを使用します。同期トークンは、ステップ 15 が実行される前にラスタータスクで解決する必要があります。
  12. コンポジタ フレームが Viz に送信されます。
  13. Viz は、foo.com レンダリング プロセス、bar.com iframe レンダリング プロセス、ブラウザ UI のコンポジタ フレームを集約します。
  14. Viz が引き分けをスケジュールします。
  15. Viz は集約されたコンポジタ フレームを画面に描画します。

タブ 2 で CSS 変換遷移をアニメーション化するには:

  1. bar.com レンダリング プロセスのコンポジタ スレッドは、既存のプロパティ ツリーを変更することで、コンポジタ イベントループでアニメーションを作動させます。その後、コンポジタのライフサイクルが再実行されます。(ラスタータスクとデコードタスクが行われる場合がありますが、ここでは示されていません)。
  2. コンポジタ フレームが Viz に送信されます。
  3. Viz は、foo.com レンダリング プロセス、bar.com レンダリング プロセス、ブラウザ UI のコンポジタ フレームを集約します。
  4. Viz が引き分けをスケジュールします。
  5. Viz は集約されたコンポジタ フレームを画面に描画します。

タブ 3 でウェブページをスクロールするには:

  1. 一連の input イベント(マウス、タップ、キーボード)がブラウザ プロセスに入ります。
  2. 各イベントは baz.com のレンダリング プロセス コンポジタ スレッドにルーティングされます。
  3. コンポジタは、メインスレッドがイベントについて知る必要があるかどうかを判断します。
  4. イベントは、必要に応じてメインスレッドに送信されます。
  5. メインスレッドが input イベント リスナー(pointerdowntouchstarpointermovetouchmovewheel)を起動して、リスナーがイベントで preventDefault を呼び出すかどうかを確認します。
  6. メインスレッドは、preventDefault がコンポジタに呼び出されたかどうかを返します。
  7. そうでない場合、入力イベントはブラウザ プロセスに返されます。
  8. ブラウザ プロセスにより、他の最近のイベントと組み合わせてスクロール操作に変換されます。
  9. スクロール操作が再び baz.com のレンダリング プロセス コンポジタ スレッドに送信されます。
  10. そこにスクロールが適用され、bar.com レンダリング プロセスのコンポジタ スレッドがコンポジタ イベントループでアニメーションを動作させます。これにより、プロパティ ツリーのスクロール オフセットが変更され、コンポジタのライフサイクルが再実行されます。また、メインスレッドに scroll イベント(ここでは図示していません)を発生させるように指示します。
  11. コンポジタ フレームが Viz に送信されます。
  12. Viz は、foo.com レンダリング プロセス、bar.com レンダリング プロセス、ブラウザ UI のコンポジタ フレームを集約します。
  13. Viz が引き分けをスケジュールします。
  14. Viz は集約されたコンポジタ フレームを画面に描画します。

タブ 1 の iframe #2 のハイパーリンクに click イベントをルーティングするには:

  1. input イベント(マウス、タップ、キーボード)がブラウザ プロセスに到着します。近似ヒットテストを実行して、bar.com iframe レンダリング プロセスがクリックを受信し、そこに送信します。
  2. bar.com のコンポジタ スレッドは、click イベントを bar.com のメインスレッドにルーティングし、レンダリング イベントループ タスクをスケジュールして処理します。
  3. bar.com のメインスレッド ヒット用の入力イベント プロセッサは、iframe 内のどの DOM 要素がクリックされたかを判断するためにテストを行い、スクリプトが監視する click イベントを発行します。preventDefaultが聞こえない場合、ハイパーリンクに移動します。
  4. ハイパーリンクのリンク先ページが読み込まれると、上記の「変更された DOM をレンダリングする」の例と同様の手順で新しい状態がレンダリングされます。(これらのその後の変更はここでは説明していません)。

おわりに

かなり詳しく説明してしまいましたね。 ご覧のとおり、Chromium でのレンダリングはかなり複雑です。 すべての要素を覚えて理解するには長い時間がかかるため、難しく思われるのも無理はありません。

最も重要なポイントは、概念的にシンプルなレンダリング パイプラインがあることです。これは、慎重なモジュール化と細部への配慮によって、多くの自己完結型コンポーネントに分割されました。これらのコンポーネントは、スケーラブルなパフォーマンス拡張性の可能性を最大化するために、並列プロセスとスレッドに分割されています。

これらの各コンポーネントは、最新のウェブアプリに必要なパフォーマンスと機能をすべて実現するうえで重要な役割を果たします。各要素の詳細と、それらが果たす重要な役割については 近日中に公開する予定です

その前に、この投稿で言及した主要なデータ構造(レンダリング パイプライン図の両側に青色で示されているもの)が RenderingNG にとってコード コンポーネントと同じくらい重要であることについても説明します。

お読みいただきありがとうございました。引き続きご期待ください。

イラスト: Una Kravets 氏