#Chromium#WebGL#GPU#ANGLE

GPUを積んだのに、Chromiumは黙ってCPUで描いていた — 4fps→58fpsにした3点セット


無人で動く AI アバターのライブ配信システムを作っていました。人が操作しなくても、3D アバター(VRM)がしゃべり、コメントに反応し、24 時間ずっと配信し続ける。その心臓部が「ヘッドレス Chromium で 3D シーンを描画し、その映像をキャプチャして RTMP で送り出す」というパイプラインです。

ここが、想像の何倍も落とし穴だらけでした。GPU を積んだのに CPU で描かれる。CPU 描画に降格する保険が最初から効かない。GPU 描画は成立したのに絵だけが壊れる。CPU では動いていたキャプチャが GPU に載せた途端に真っ黒になる。どれも「エラーが出ない」「数字は正常」という共通点があり、だから厄介でした。

この記事は、その一連のつまずきを実際に踏んだ順にまとめたものです。要点を先に並べると次の 4 つです。

  1. CPU 描画(SwiftShader)は 3D アバターには使えない — しかも「解像度を落とす」保険が効かない
  2. GPU で実際に描かせるには 3 点セットが要る — フルビルドの Chromium・ICD 登録・起動フラグ
  3. ANGLE のバックエンド選択で、fps が同じでも絵が壊れるgl-eglvulkan は等価ではない
  4. canvas.captureStream() は GPU 環境で壊れる — CDP の screencast に逃がす

同じシーンで、CPU 描画だと約 4fps、GPU 描画を正しく成立させると 57〜58fps。10 倍以上の差です。ここに至るまでの試行錯誤を順に書きます。

まず「CPU で配信する」という計画が崩れた

最初の設計は、レンダラーを CPU だけで動かすものでした。GPU を確保しなくていいので、コストもスケールも都合がいい。設計書には保険まで書いてありました。「重かったら 540p / 24fps に降格する」と。

Chromium には SwiftShader という WebGL の CPU 実装が入っていて、GPU がなくても WebGL は動きます。実際、素の WebGL(三角形を回すだけ)なら 60fps 出ました。ここで安心してしまった。

問題は中身でした。VRM のアバターを描画すると 4fps。同じブラウザ、同じ CPU、同じ解像度で 15 倍の差です。

解像度スイープという安いテスト

重い描画に出会ったとき、最初にやるべきなのは解像度を振って fps を測ることだと思っています。5 分で終わって、得られる情報が大きい。実測はこうなりました。

解像度 fps
720p 約 4
540p 約 4
360p 約 4

**設計書の保険が最初から効かないことが、この 3 行で確定しました。**ピクセル数を 1/4 にしても fps が動かないなら、律速はピクセル処理ではありません。解像度を下げても救えない。

判定の考え方はこうです。

観測 律速 打てる手
解像度に比例して軽くなる フラグメント処理(ピクセルを塗る側) 解像度降格、シェーダ簡略化、ポストエフェクト削減
解像度を下げても変わらない ジオメトリ / CPU 側の処理 頂点数削減、ドローコール削減、ボーン・物理演算の削減、そもそも GPU を使う

今回は後者でした。VRM シーンで解像度に依存しない処理といえば、こういったものです。

  • スキニング: ボーンごとの頂点変換。頂点数とボーン数で決まり、出力解像度とは無関係
  • spring bone: 髪や衣装の揺れ物の物理演算。毎フレーム CPU で回る
  • MToon マテリアル: トゥーン表現のためにアウトライン用の描画パスが増える。ドローコールが増える方向

出力ピクセルを減らしても、これらは 1 ミリも減りません。だから 360p でも 4fps だったわけです。

「降格プラン」は、比例関係を確かめてから書く

反省点はここでした。「重かったら 540p に落とす」は、何に比例して軽くなるのかを確かめずに書いた保険でした。書いた時点では安心材料になり、実測した瞬間に無価値になりました。

保険としてもう少しマシだった選択肢もありました。

  • fps を落とす(30fps → 15fps): ジオメトリ律速でも効く。ただし配信映像としての品質低下は解像度より目立ちやすい
  • シーンを軽くする(spring bone を減らす、ボーン数を減らす、アウトラインを切る): 効くが、見た目のキャラクター性を損なう。効果量も事前には読めない
  • GPU を使う: 確実。ただしコストと在庫の問題が発生する

最終的に 3 番目を選び、描画対応 GPU(RTX 系)をクラウドで借りる構成にしました。GPU に載せると同じシーンが 57〜58fps で回ります。4fps から 57fps なので、悩む余地はありませんでした。

教訓を一行にすると、降格プランを設計に書くときは「何を減らすと、何に比例して軽くなるか」を必ず添える。これができないなら、それはプランではなく願望です。

GPU を渡しても、Chromium は勝手に CPU で描く

さて GPU を使うと決めた。ところがここからが本番でした。GPU 付きのコンテナを用意して、--gpus all を渡して、ヘッドレス Chromium を起動する。nvidia-smi も通っている。それでも WebGL は GPU で描かれません。**SwiftShader に黙って落ちます。**エラーも警告も出ないので、fps を測るまで気づけません。

結論から言うと、実際に GPU で描かせるには次の 3 つが全部必要でした。ひとつでも欠けると CPU に落ちます。

  1. フルビルドの Chromium(既定の headless shell では GPU 描画できない)
  2. EGL の ICD 登録10_nvidia.json と libglvnd)
  3. 起動フラグ--use-gl=angle --use-angle=vulkan --ignore-gpu-blocklist --no-sandbox

1. 既定の headless shell では描けない

自動化ツール経由で Chromium を起動すると、何も指定しなければ軽量な headless shell が立ち上がることがあります。CI で DOM を触るには最適ですが、GPU 描画のパスが入っていません。

フルビルドの Chromium を明示的に指定します。

const browser = await chromium.launch({
  channel: 'chromium',   // ← headless shell ではなくフルビルドを使う
  args: [ /* 後述のフラグ */ ],
});

ここに気づくまでが長かった。「Chromium は起動している」「ページは表示されている」「WebGL コンテキストも取れている」ので、全部成功しているように見えるからです。CPU で描いているだけで。

2. EGL の ICD が登録されていないと、GPU を見つけられない

コンテナに GPU が見えていることと、グラフィックスドライバのエントリポイントが登録されていることは別です。後者は ICD(Installable Client Driver)というファイルで登録されます。

  • EGL: /usr/share/glvnd/egl_vendor.d/10_nvidia.json
  • Vulkan: /usr/share/vulkan/icd.d/ 配下

ここが空だと、Chromium は「NVIDIA の EGL 実装が存在しない」と判断して SwiftShader に落ちます。実際、最初に検証したときは Vulkan の ICD ディレクトリが空でした。

あわせて libglvnd(GL のベンダー中立ディスパッチ層)も要ります。単体で入れてもいいですが、実務上は診断ツールを入れると依存で付いてきます。

RUN apt-get update && apt-get install -y \
      mesa-utils vulkan-tools \   # libglvnd が依存で入る + 検証にも使う
      xvfb \
    && rm -rf /var/lib/apt/lists/*

コンテナ側では NVIDIA_DRIVER_CAPABILITIES=all を指定します。既定値(compute,utility)だとグラフィックス関連のライブラリがコンテナにマウントされません。「GPU は見えるのに描けない」の典型的な原因がこれです。

3. 起動フラグ

--no-sandbox
--use-gl=angle
--use-angle=vulkan          # または gl-egl
--ignore-gpu-blocklist

--use-angle には gl-eglvulkan の 2 択があり、**どちらも fps は同じ(57〜58fps)でした。**ただし描画品質に差が出ることがあり、最終的に vulkan を採用しています(この理由は後述します)。

--ignore-gpu-blocklist は、Chromium が「このドライバ構成は既知の問題があるので GPU を使わない」と自己判断してしまうのを止めるためのものです。コンテナ環境ではだいたい必要になります。

成立したかを、どう確認するか

いちばんやってはいけないのが「起動したから OK」です。**このスタックは失敗を成功のように見せます。**必ず出力側から裏を取ってください。

**GL_RENDERER を読む。**ページ内で実際に使われているレンダラー名を取ります。

const gl = document.createElement('canvas').getContext('webgl');
const ext = gl.getExtension('WEBGL_debug_renderer_info');
console.log(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL));
  • CPU に落ちている: SwiftShader / Google SwiftShader などの文字列
  • 成立している: NVIDIA やアダプタ名が入った文字列

これを起動時に自動で吐かせて、ログに残すようにしました。一度成立してもイメージ更新で壊れることがあるので、毎回確認できるほうがいい。

実測 fps を見る。requestAnimationFrame の間隔を数十秒ぶん測ります。判定はきれいに割れました。

状態 VRM シーンの fps
SwiftShader(CPU) 約 4
GPU 描画成立 57〜58

10 倍以上違うので、迷う余地がありません。中間の値が出ることはありませんでした。

つまずいた順に並べると

  1. GPU 付きコンテナで起動 → 動いた(ように見えた)→ 実は SwiftShader
  2. フラグを足した → 変わらない → ICD が無かった
  3. ICD を置いた → 変わらない → headless shell だった
  4. フルビルドに変えた → 57fps

毎回「フラグが足りないのでは」と疑ってフラグばかり増やしていましたが、実際にはフラグ・ドライバ登録・ブラウザ本体の 3 レイヤーの問題で、1 レイヤーずつ潰す必要がありました。今から同じことをするなら、フラグをいじる前に GL_RENDERER を出す最小のプローブページを先に作ります。切り分けが 10 倍速くなります。

同じ fps なのに、絵だけが壊れる — ANGLE のバックエンド

GPU 描画が成立して fps も 57 出た。キャプチャも通っている。それでも配信された映像では、キャラクターの首から上だけがチラチラと明滅していました。

結論を先に書くと、--use-anglegl-egl から vulkan に変えたら消えました。それ以外は何も変えていません。

前のセクションで見たとおり、この 2 つはベンチマークが同じでした。

バックエンド VRM シーンの fps
--use-angle=gl-egl 57〜58
--use-angle=vulkan 57〜58

**性能は完全に同じ。**なので当初は「どちらでもいい、先に動いたほうを使おう」と gl-egl を選んでいました。ここが判断ミスでした。

症状: 数値に出ない壊れ方

配信映像を見ると、キャラクターの首から上のマテリアルだけが明滅します。顔と髪の部分です。フレームレートは安定しているので、コマ落ちではありません。特定のマテリアルの描画結果が、フレームごとに揺れている。

VRM のトゥーンシェーディングには MToon というマテリアルが使われていて、輪郭線(アウトライン)のために背面を膨らませて描くなど、素の PBR より込み入ったことをしています。このあたりの描画順序や深度の扱いが、ANGLE のバックエンドによって微妙に違う挙動になっていたと考えられます。

重要なのは、この不具合は我々が持っていたどの指標にも出なかったことです。

  • fps: 正常(57〜58)
  • GPU 使用率: 正常
  • コンソールエラー: なし
  • WebGL コンテキストロスト: なし
  • キャプチャした映像の尺: 実時間どおり

人間が見て初めて分かる壊れ方でした。

切り分け

やったことは単純で、他を固定してバックエンドだけを入れ替えました。

--use-gl=angle --use-angle=gl-egl   → 首から上がチラつく
--use-gl=angle --use-angle=vulkan   → チラつかない

同じイメージ、同じページ、同じ GPU、同じフラグ、違うのは --use-angle の値だけ。これで再現/消失がきれいに分かれたので、原因はここだと確定できました。実配信の映像を実際に目で見て確認しています。以降、vulkan を必須設定として構成に焼き込みました。

学び: 描画の「正しさ」は性能指標では守れない

この件で運用を変えたのは次の 2 点です。

1. 描画系の変更は必ず実映像を見る。CI で回せる指標(fps、エラー数、プロセス生存)は、描画の正しさを何も保証しません。今回のように、全指標が緑のまま絵だけが壊れます。レンダラーを触ったときにスクリーンショットか短い録画を必ず目視する運用にしました。「動いている」と「正しく描けている」は別の検査項目です。

**2. 等価に見える選択肢でも、片方を既定として固定する。**ベンチマークが同じだからといって、gl-eglvulkan は等価ではありませんでした。**性能が同じ選択肢は「どちらでもいい」ではなく「まだ差が観測できていない」**と考えたほうがいい。片方を既定として設定に明記し、変えるときは理由を書く。

なお、ANGLE のバックエンドは環境(ドライバのバージョン、GPU 世代、コンテナのライブラリ構成)にも依存するので、「vulkan が常に正しい」という話ではありません。自分の環境で目視して決める、というのが正しい結論だと思います。

GPU に載せたら、今度はキャプチャが真っ黒になった

絵は正しく描けるようになった。最後の関門がキャプチャでした。

ブラウザで描画した映像をライブ配信するとき、まず思いつくのが canvas.captureStream() です。canvas から MediaStream が取れて、そのまま録画や送信に流せる。標準 API で、実装も短い。CPU 描画で動かしていたときは、これで動いていました。GPU 描画に切り替えたら壊れました。

  • フレームが真っ黒になる
  • 描画がチラつく
  • しばらくすると止まる

同じコード、同じページ、GPU が付いただけ。逆説的ですが、環境を良くしたら壊れたわけです。

何が起きていたか

原因は、GPU 上のテクスチャをどの形式で受け渡すかのネゴシエーションが成立していなかったことでした。Chromium は GPU バッファの形式(gfx::BufferFormat)をいくつか候補として持っていて、環境に応じて使えるものを選びます。我々のコンテナ環境では、その候補が軒並み使えない状態でした。

CPU 描画のときは、そもそも GPU バッファを経由しないので、この問題が存在しませんでした。GPU に載せたことで初めて通るようになったコードパスで壊れた、という構図です。

実は、この「遅い/不安定な描画がキャプチャを壊す」兆候は CPU 描画のときにも出ていました。画面をキャプチャして録画すると、90 秒動かしても 6 秒分しか記録されないという現象です。読み出し(ReadPixels)とエンコードがメインスレッドで描画と取り合いになり、フレームがタイムラインごと脱落していました。**描画が遅いとき、被害は fps だけでなく成果物の尺にも出る。**ライブ配信ではプラットフォーム側がストリームをライブと認識してくれなくなります。

この手の不具合は、フラグをいじって直せることもありますが、我々は深追いをやめました。理由は 2 つです。

  1. コンテナ環境・ドライバ・GPU 世代の組み合わせに依存し、次のホストで再発しない保証がない
  2. 配信は落ちてはいけない。環境依存の綱渡りを本番の中心に置きたくない

移行先: CDP の Page.startScreencast

代わりに使ったのが Chrome DevTools Protocol(CDP)の Page.startScreencast です。これは DevTools がリモートデバッグで画面を見せるときに使っている仕組みで、ページのスクリーンショットを連続で送ってきます。

構成はこうなりました。

Chromium
  └ CDP: Page.startScreencast(JPEG フレームが連続で届く)
       └ Node 側で 30fps にペーシングして名前付きパイプへ書く
            └ ffmpeg:MJPEG(映像)+ fifo(音声)の 2 入力を mux
                 └ H.264 + AAC → RTMP

captureStream() が「GPU のテクスチャをそのままメディアパイプラインに流す」のに対して、screencast は「画像として出してもらう」形になります。効率は落ちますが、GPU バッファのネゴシエーションという不安定な依存が消えます。実測では、これで CPU ホスト・GPU ホストの両方で安定して実時間を維持できるようになりました。

思わぬ利点: HTML オーバーレイが乗る

移行して気づいた副次的な効果がありました。captureStream() は canvas の中身しか撮れませんが、screencast はページ全体を撮ります。

配信では、コメントへの返答をテロップとして画面に出したり、投げ銭のバナーを表示したりします。canvas キャプチャの構成だと、これらを全部 WebGL の中で描く必要がありました。文字の描画、折り返し、アニメーション、フォント。全部自前です。

screencast なら、普通の HTML と CSS で書いたものがそのまま映ります。

<!-- これがそのまま配信画面に乗る -->
<div class="telop">こんばんは!コメントありがとうございます</div>

テロップのレイアウトを CSS で調整して、ブラウザで見た目を確認して、そのまま本番。開発速度がまったく変わりました。制約を回避するために選んだ方式が、結果的に表現力を増やしたという珍しいケースです。

実装上の注意

**1. ペーシングは自分で持つ。**screencast は「送れるときに送る」ので、フレーム間隔は一定ではありません。ffmpeg に一定レートで渡すために、Node 側で 30fps に整えています。ここを素通しにすると、可変フレームレートの扱いで A/V 同期が崩れます。

2. 音声は別経路で合流させる。映像が JPEG の連続になったので、音声は別の名前付きパイプで ffmpeg に渡し、2 入力として mux しています。この構成ではどちらのタイムラインをゼロ点にするかを決める必要があります。我々は「音声の最初のチャンクが届いた時点で映像の書き込みを始める」方式にしました。

**3. 複数プロセスで動かすならパイプのパスを分ける。**1 台のホストで複数の配信を相乗りさせると、固定パスの fifo が衝突します。ポート番号などでパスを分けてください。

まとめ

無人 AI アバター配信のパイプラインを組む中で踏んだ落とし穴を、順に振り返ると次のようになります。

  • **CPU 描画(SwiftShader)は 3D アバターには使えない。**素の WebGL は 60fps でも、VRM は 4fps。しかも解像度に依存しないジオメトリ/CPU 律速なので、解像度降格という保険が効かない。降格プランは「何に比例して軽くなるか」を確かめてから書く
  • **ヘッドレス Chromium は GPU 描画に失敗すると黙って CPU にフォールバックする。**必要なのは 3 点セット — フルビルドの Chromium(channel: 'chromium')、EGL/Vulkan の ICD 登録 + libglvnd、起動フラグ。コンテナ側は NVIDIA_DRIVER_CAPABILITIES=all。成立確認は GL_RENDERER の文字列と実測 fps で。「起動した」を成功と読まない
  • **ANGLE の gl-eglvulkan は fps が同じでも描画結果が違うことがある。**症状は MToon マテリアル(顔・髪)の明滅で、どの性能指標にも出ない。描画系の変更は実映像を目視する運用をパイプラインに入れる
  • **canvas.captureStream() は GPU バッファのネゴシエーションが成立しない環境で黒フレーム・チラつき・停止を起こす。**CDP の Page.startScreencast に逃がすと環境依存が減り、おまけにページ全体(HTML オーバーレイ)が撮れる。ペーシング・音声合流・パス衝突は自前で面倒を見る

通底しているのは、GPU が絡む構成は失敗が例外ではなく性能の劣化や絵の破綻として現れるということです。動くけれど遅い、数字は緑だけど絵が壊れている、環境を良くしたら別のコードパスで壊れた。どれも「起動したから OK」では絶対に見つかりません。最初に判定用のプローブ(GL_RENDERER を出すページ、解像度スイープ、実映像の目視)を用意しておくのが、結局いちばん早い、というのが一連の教訓でした。

← 記事一覧へ