メインコンテンツへ飛ぶ

テックトーク: ウインドウのサイズ変更動作の改善

· 読むのにかかる時間 1 分

私たちは、Electron での取り組みを垣間見られる新しいブログ投稿シリーズを開始します。この働きに興味を感じましたら、貢献をご検討ください!


最近、私は Electron と Chromium のウインドウのサイズ変更動作の改善に取り組みました。

バグ​

Windows では、ウインドウのサイズを変更すると以下のように古いフレームが表示されるという問題が発生していました。

ウインドウのサイズを変更するときに古いフレームが表示される問題を示すアニメーション GIF

このバグに特に興味深かった理由は何ですか?​

  1. 挑戦的だった。
  2. 大規模なコードベースの奥深くにあった。
  3. 後でわかるように、内部には 2 つの異なるバグがあった。

バグの修正​

このようなバグの場合、最初の課題は調べ始めを把握することです。

Electron は、Google Chrome のオープンソース版である Chromium をベースに構築されています。 Electron をコンパイルするとき、Electron のソースコードがサブディレクトリとして Chromium のソースツリーに追加されます。 Electron は、最新ブラウザの機能を提供するためにそののほとんどを Chromium のコードに依存しています。

Chromium には約 3600 万行のコードがあります。 Electron も大きなプロジェクトです。この問題の原因となる可能性のあるコードは大量にあります。

根本原因の絞り込み​

私は色々な実験をしてみました。

まず、Google Chrome でも以下のように問題が発生していることに気付きました。

Google Chrome もサイズ変更の問題を示しているスクリーンショット

これは、Electron ではなく Chromium で問題が発生している可能性を示唆しています。

さらに、macOS ではこの問題は示されませんでした。これは Windows 固有のソースコードによることを示唆しています。

決定的な手がかり​

私はさまざまなコマンドラインフラグと構成オプションを試しました。

私は app.disableHardwareAcceleration() で問題が解決することに気付きました。ハードウェアアクセラレーションをなくすと問題が消えたのです。

ここで背景を説明すると、Chromium は画面上にピクセルを表示するためのさまざまなグラフィックス API (OpenGL、Vulkan、Metal など) をサポートしています。 Windows では、macOS や Linux とは異なるグラフィックス API を使用しています。また Windows であっても、Chromium は複数の異なるグラフィックスバックエンドを使用できます。

Chromium が使用するグラフィックスバックエンドは、ユーザーのハードウェアによって異なります。例えば、一部のグラフィックスバックエンドではコンピュータに GPU が搭載されている必要があります。

さまざまなグラフィックスバックエンドを試したところ、以下のフラグで問題が解決したことに気付きました。

  • --use-angle=warp
  • --use-angle=vulkan
  • --use-gl=desktop
  • --use-gl=egl
  • --use-gl=osmesa
  • --use-gl=swiftshader

以下のフラグでは問題が再現されました。

  • --use-angle=d3d11 (これは現在の、Windowsのデフォルトです)
  • --use-angle=gl (WindowsではDirect3D 11へフォールバックします。「chrome://gpu/」を参照してください)

正常動作するようなフラグは、どれも Windows 上の Electron アプリでデフォルトとして使用できるほど十分なものではありません。遅すぎるか、ドライバのサポートが手厚くありませんでした。

しかし、これらの回避策が道を照らすことになりました。これらは問題が ANGLE Direct3D 11 バックエンドでのみ使用されるコードパスにあることを示していたのです。

Direct3D はハードウェアアクセラレーショングラフィックス用の Windows API です。

ANGLE は、OpenGL 呼び出しを、指定されたオペレーティングシステムのネイティブのグラフィックス API への呼び出し、ここでは Direct3D に変換するライブラリです。 ANGLE を使用すると、Chromium 開発者はすべてのプラットフォームで OpenGL 呼び出しを記述できます。 ANGLE は、使用されているグラフィックス API に応じてそれらを Direct3D、Vulkan、または Metal の API 呼び出しへ変換します。

関連する Chromium コンポーネントの特定​

Chromium は数万箇所で Direct3D を参照しています。それらすべてを検討するのは現実的ではありませんでした。

偶然にも、Chromium のソースコードで役立つ以下のようなデバッグフラグをいくつか見つけました。

  • --ui-show-paint-rects
  • --ui-show-property-changed-rects
  • --ui-show-surface-damage-rects
  • --ui-show-composited-layer-borders
  • --tint-composited-content
  • --tint-composited-content-modulate
  • (他にもたくさん)

これらは Chromium グラフィックのスタックのさまざまな部分によって再描画または更新されたブラウザウインドウの領域を強調表示してくれます。

これにより、グラフィックスのスタックのどの部分がどの出力を生成しているかを確認できるようになりました。

特に、--tint-composited-content と --tint-composited-content-modulate の組み合わせが非常に役立ちました。前者は、コンポジッタの出力に色を付けます。後者は、フレームごとの色付けを変更します。

--tint-composited-content フラグ付きの Chromium のスクリーンショット

スクリーンショットでは、シアン色のフレームが最後に描画されたフレームです。

そのフレームの右側のジャンクはシアン色になっていませんでした。これは以前のフレームから残っていたさまざまな色が付いていました。これは、ジャンクがコンポジッタから発生していないことを示しています。コンポジッタは正しい出力を送っていました。

コンポジッタは Chromium のグラフィックスのスタックの一部です。非常に簡略化していますが、このブログ記事の目的のために噛み砕くと以下のようにイメージするとよいでしょう。

  1. コンポジッタ cc が、描画命令を含む CompositorFrame を生成します。
  2. cc がその CompositorFrame をディスプレイコンポジッタ viz へ送信します。
  3. viz はフレームを描画して画面に表示します。

各 CompositorFrame に色を付けることにより、コンポジッタは正しい出力を生成したことが示されました。したがって、この問題はディスプレイコンポジターの viz にあるはずです。

関連する viz コードの特定​

そこから、私は viz ソースコード内で Direct3D が出てくる箇所を探し始めました。

注: ここからは、記事の内容が少し技術的になり、ソースコードのシンボルを参照し始めます。

ANGLE Direct3D 11 バックエンドでは、Chromium がウインドウのコンテンツを描画するために Windows の DirectComposition API を使用していることがわかりました。

Chromium の DirectComposition の OutputSurface は、Chromium の他のほとんどの出力サーフェスと異なります。これには、supports_viewporter の機能があります (ソースリンク 1、ソースリンク 2)。

出力サーフェスは描画可能なビットマップであり、多くの場合 GPU テクスチャの中身です。

supports_viewporter がない場合、ウインドウサイズが変更されるたびに、Chromium は新しいウインドウサイズに一致する出力サーフェスを新規作成することになります。そしてそのサーフェスに描画されて表示されます。

supports_viewporter は、このようなコストのかかるサーフェス割り当てを削減しようとします。 supports_viewporter では、Chromium はサイズ変更ごとに新しいサーフェスを割り当てません。代わりに、描画が必要な領域よりも大きいサーフェスを割り当てています。そして、そのサーフェスの特定の部分矩形 ("ビューポート") のみが画面上に描画され表示されます。サーフェスの他の部分は画面に表示されません。

これにより、サイズ変更のたびに新しいサーフェスを割り当てるのではなく、Chromium がサーフェスを適切な幅と高さにパディングするだけで済むため、サイズ変更処理がより効率的になると考えられるからです。このサーフェスのサイズ変更ロジックは direct_renderer.cc にあります。

つまり以下のようになっています。

サーフェス、ビューポート、クリップ矩形を示す視覚化

説明すると、

  • 青い長方形がサーフェスです。
  • 緑色の領域はビューポート、つまり表示されることになっているサーフェスの領域であり、アクティブに描画する領域です。
  • 赤い四角形はクリップ矩形、つまり実際に画面に表示されているサーフェスの部分です。

パフォーマンス最適化のため、新しいフレームを得る際にはビューポート (緑色の領域) のみが再描画されます。残りは変わらないままです。これは重要です。緑色のビューポートのみを再描画しています。ビューポートの外側の領域は更新されません。

ウインドウのサイズを変更する場合、アトミックなトランザクション (= まったく同時) でビューポート (= 画面に表示される領域) を再描画し、クリップ矩形を更新してサーフェスを新しいビューポートサイズにクリップする処理が行われるはずです。

サイズ変更後は、以下のようにならなければなりません。

更新されたビューポートとクリップ矩形の視覚化

ここで、2 つのバグのうちの 1 つ目のものに到達します。

1 つ目のバグ​

時々これらの操作が同期されなくなることがあります。例えば、ビューポートが再描画される前にクリップ矩形が更新されることがあります。すると、次のような結果が得られます。

ビューポートの前にクリップ矩形が更新された視覚化

緑色のビューポートには古いフレームがまだ表示されています。しかしクリップ矩形は大きくなり、まだ再描画していないサーフェスの領域が表示されます。

ウインドウを初めてサイズ変更すると、これらの領域は黒になります。 2 回目のサイズ変更では、これらの領域は古いピクセル値で埋まっています。そこに表示されるのは、私たちが以前その領域に描いたものです。

同様に、ウインドウを小さくしているときに、特定のエッジケースでクリップ矩形の更新前にビューポートを再描画することがありました。

クリップ矩形の更新前にビューポートが再描画された視覚化

すると、新しいフレームが小さくなり、新しいビューポートを超える領域は再描画されなかったため、クリップ矩形の一部にはまだ前のフレームが表示されます。

では、なぜこれらの操作は同期して行われないのでしょうか?

ここでは 2 つの異なる Windows API を使用しています。

理解しておくべき重要な点は、両方の関数は CPU 上で同期的に戻るということです。しかし、GPU 上で非同期的に実行されるタスクは後でスケジュールされます。 Windows とそのサービス (DWM など) は、これらのタスクがいつどのような順序で実行されるかを決定します。したがって、それらは非同期的に有効になり、必ずしも同じフレーム内で有効になるわけではありません。

残念ながら、Windows ではこれらの操作を同期する方法が提供されていません。そのため、この問題を解決するには他の方法を見つける必要がありました。

Chromium のメンテナと評価した選択肢は以下の 2 つです。

  1. サイズ変更時に、ビューポートの外側にある以前に描画された全領域を透明で塗る。これにより、それらの領域は見えなくなります。これで画面の乱れは修正されます。
  2. サイズ変更を、IDXGISwapChain1 から、IDCompositionDevice::Commit で更新を同期する DirectComposition サーフェスに切り替える。これでも画面の乱れは修正されます。

私たちは 1 つ目のオプションを選択しました。2 番目の選択肢よりもサイズ変更が高速だったからです。

私は最初の解決策を実装した パッチ を Chromium に導入しました。

メインのパッチの準備として、他に以下の 2 つのパッチも提出しました。

  1. 1 つ目のもの は、メインパッチとの組み合わせると CI が失敗する原因となっていた既存コードのバグを修正しました。これで Electron アプリと Chrome の起動も少し速くなりました。
  2. 2 つ目 は、メインのパッチのコードレビューを容易にするために分割したものです。

2 つ目のバグ​

この 1 つ目のバグに加えて、ピクセルの古さに起因する 2 つ目のバグもありました。

つまり以下のようになっています。

ユーザーがウインドウのサイズを変更すると、Chromium は新しいウインドウサイズに合わせてウインドウの中身を再描画する必要があります。これには時間がかかります。新しいフレームをすぐに準備できません。

このようになるフレームの時系列を以下に示します。

Chromium サイズ変更の時系列の 1 番目のフレーム Chromium サイズ変更の時系列の 2 番目のフレーム Chromium サイズ変更の時系列の 3 番目のフレーム Chromium サイズ変更の時系列の 4 番目のフレーム Chromium サイズ変更の時系列の 5 番目のフレーム

サイズ変更中のある特定の時点で、Windows は「そのウインドウの幅は 1,000 ピクセルです」と知らせます。しかし、ブラウザのコンポジッタが生成するフレームは遅れています。最後に描画したフレームの幅は 600 ピクセルかもしれません。

これまで、Chromium はウインドウの幅が最後に描画したフレームの幅と一致しないフレームをスキップしていました。このときはただウインドウを更新しないと決めています。

しかし、サイズ変更操作が完了するまでウインドウの内容がまったく更新されないことがよくあります。

そこで 2015 年 に誰かがこう決断しました。「どうしてこれらのフレームを表示しないのか?ウインドウのサイズと完全には一致しないかもしれないが、少なくとも何かは表示できるだろう。」

これで溝が生じるようになるのですが、当時の溝は黒でした。そのため以前の実装よりも優れていたのです。

10 年後に DirectComposition になって、この溝はしばしば古いピクセルで埋められるようになりました。

何が起こっていたのかを見てみましょう。

各フレームは複数の描画パスで構成されます。これらの描画パスは、画面上に描く必要があるさまざまなものを表します。複雑なビットマップから単色塗りの矩形まであります。

すべてのフレームにはルートの描画パスがあり、ルートの描画パスには他のすべての描画パスが含まれ、それらが結合されます。(描画パスは木構造に配置され、ルートの描画パスはその木の根になります。)

そこで、サイズ変更によってウインドウの幅が 1,000 ピクセルであることがわかったとします。したがって、出力サーフェスのビューポートも幅 1,000 ピクセルになるように調整します。しかし、今受け取ったフレームの幅は 600 ピクセルしかありません。

2015 年からの最適化 により、ルートの描画パスの幅も 1,000 ピクセルへ変更されます。しかし描画パスが実際に画面に描くものは変わりません。これにはまだ幅 600 ピクセルの画像を描画する命令のみが含まれています。

つまり以下のようになっています。

フレームがビューポートより小さいことの視覚化

黄色の領域は、フレームの描画パスが実際に何かを描画した領域です。その幅は 600 ピクセルです。

しかし、緑色のビューポートと赤色のクリップ矩形の幅は 1,000 ピクセルです。それが画面に表示される領域です。(結局のところ、ルート描画パスの幅の属性は 1,000 ピクセルの全領域の再描画を要求していました。)

しかし右側 400 ピクセルに対する描画命令がなかったため、それらの領域は更新されませんでした。

最初のサイズ変更では、そこに黒いピクセルが表示されます。(これはサーフェスの初期化色です。)

その後のサイズ変更では、その領域には以前に描画したものが表示されます。古いピクセルが表示されることになります。

この問題の修正は crrev.com/c/7156576 で行いました。

この修正によりウインドウと異なるサイズのフレームを受信したときの動作が変更されます。フレームのサイズ変更で古いピクセルを含む溝をつくる代わりに、ビューポートのサイズ変更 とクリップ矩形のサイズ変更をします。

クリップ矩形とビューポートのサイズがフレームのサイズに合わせて調整されることの視覚化

受け取ったフレームのサイズに合わせてサーフェスをクリップします。描画指示がある 600 ピクセルを超えるものは表示されません。

ほら、溝も古いピクセルももうありません!

注釈

supports_viewporter がない場合新しい出力サーフェスを割り当てることになるため、これはコストのかかる操作になります。そこで、DirectComposition では「ビューポーター」機能を使用します。なので、ビューポートのサイズを変更してもサーフェスは再割り当てされません。別の部分を見えるようにするだけです。したがって、これはコストのかからない操作です。

パッチを Electron にバックポートする​

修正が Chromium に反映されたら、それを Electron にも反映する必要がありました。

main ブランチでは、Electron は Chromium のバージョンを継続的に更新します。その結果、このパッチは Chromium ローリング PR で main にマージされました。

しかし、今 main にコミットしたものは約 3 か月後の Electron リリースに含まれるというだけです。既存のリリースおよびプレリリースの ブランチ は、古いバージョンの Chromium を実行します。

そこで、次のステップはパッチを Electron 39 と Electron 40 にバックポートすることでした。

Electron は patches/chromium ディレクトリ で Chromium のパッチのリストを保持しています。 Chromium のパッチをバックポートするときは、ここにそれを追加します。 Electron のビルド時に、これらのパッチは Chromium のソースコードへ適用されます。

(一般論として、私たちは Chromium のパッチ数を少なく抑える](../docs/latest/development/patches#patch-justification) ように努めています。すべてのパッチは、Chromium の更新でマージコンフリクトを引き起こす可能性があります。パッチによるメンテナンスの負担は現実問題です。)

Electron 39 の バックポート PR はすぐにマージされました。この修正は Electron 39.2.6 の一部となりました。 🎉

Electron 39.2.6 以降でウインドウのサイズを変更しても、古いピクセルは表示されなくなります。

(このパッチは Google Chrome Canary の一部でもあります。これらは 2026 年 2 月にリリースされる安定版 Google Chrome の一部となる予定です。)

謝辞​

この作業に資金を提供して頂いた Plasticity に深く感謝します!

ご協力いただいた Chromium チームの Michael Tang 氏と Vasiliy Telezhnikov 氏に感謝します。

おわりに​

これは私がこれまで取り組んだ中で最も難しいバグでした (18 年間のソフトウェア開発で多くの難しいバグに取り組んできたけれど)。

しかし、これまで取り組んだ中で最も楽しいプロジェクトでもありました。

面白いと思った方は、Electron への貢献 をご検討ください!新人さん大歓迎です。