プロジェクトはJavaScriptのみです。スコアリングは安価であり、ブラウザの抽出とレンダリングが実際の上限です。
これらを別々に測定します:
- YouTubeチャットの抽出とフィルタリング
- MutationObserverのアタッチ/再アタッチ
- 保留キューの受け入れ
- テキストラスタリゼーション
- キャンバス描画ループ
- アクティブコメント数
- ドロップされたコメント
- フレームp50/p95/p99
- ロングタスクの数
スコアリングはextension/scoring.js内でローカルかつ安価に保つべきです。
初期の実用的な目標:
- レンダラー:
maxActive=2000でコントロールを応答性のある状態に保つ - フレーム予算:50msを超えるメインスレッドタスクの繰り返しを避ける
- キュー予算:無制限の保留成長を避ける
- 抽出:YouTubeがチャットiframeまたは
#itemsを置き換えた後も流れ続ける - セキュリティ:リモートフェッチなし、HTMLインジェクションシンクなし
レンダリングプローブを実行します:
npm run test:e2eまたは手動でChromeで開きます:
bench/danmaku-bench.html
プローブはDOM + CSSアニメーション、DOM + JavaScriptトランスフォームの更新、およびキャンバスの再描画を比較します。最終的なGPUの動作については、フルスクリーン/手動のChromeテストを使用してください。
実行します:
npm run sandboxその後、開きます:
http://127.0.0.1:4173/
サンドボックスはextension/scoring.jsを直接使用します。
「滑らかに流れるのに1〜3秒ごとにガクッとする」症状の正体はフレームペーシングではなく、ガベージコレクションの嵐でした。以前はコメント1件ごとに専用の<canvas>へラスタライズしていました。canvasのバッキングストアはV8の外部メモリとして計上されるため、チャットの流量(毎秒100件以上、典型的なDPRで1件50〜300KB)ではその予算が0.5〜3秒ごとに尽き、V8がフルのマークコンパクトGCを強制していました。実GPUでの計測(SYC_HEADED=1 node bench/jank-trace.mjs、200コメント/秒): 12秒間にMajorGCが13回、JSヒープ2MBに対して停止時間10〜26ms、落ちフレーム22回。
修正は構造的なもので、コメントを共有のアトラスページへ詰め込みます。
- ページは2048×256デバイスpxのGPUバッキングcanvas(2MB)です。スロットはシェルフ詰めで、バイリニアサンプリングが隣へ滲まないよう1pxのガターを取ります。
- 文字は小さなCPUスクラッチcanvas(2の冪サイズのバケットごとに1枚、永続的に再利用)でラスタライズし、完成したスプライトをページへ転写(blit)します。大きなアクセラレートcanvasへ直接グリフの縁取り(
strokeText)を描くとAMD内蔵GPUでフレームレートが半減しましたが、画像の転写はほぼ無料です。 - 画面上の各スプライト(および準備済みで未受入のもの)は自分のページへの参照を保持します。現在のページが満杯になると、参照ゼロの最も古いページを消去して再利用します。空きページが無い間だけ新しいページを開き、上限があります。したがって定常状態では何も割り当てません。
- ラスタキャッシュは日和見的です。エントリはページの世代が一致する間だけ有効で、ページが再利用された後は参照時に破棄されます。
- ページ幅を超える巨大テキストやアトラスが完全に埋まった場合は単独ビットマップへフォールバックしますが、これは例外であり通常経路ではありません。
修正後(同一マシン・同一負荷): MajorGCは3回でいずれも2.6〜3.6ms(3MBヒープに対するV8のアイドル時メモリ削減)、12秒間の落ちフレームは単発4回、フレームループp99は10msから5msへ。bench/jank-trace.mjsが回帰プローブです。MajorGC回数をほぼゼロに保ってください。
アトラス修正後、サンドボックスでは滑らかなのに実際のYouTubeライブページではまだガタつきました。実ページで未パッケージの拡張機能ごとトレース(bench/real-page-trace.mjs、12秒・合成コメント20件/秒+実チャット、AMD Renoir iGPU、60Hz)すると、取りこぼした全フレームでレンダラーのメインスレッドは空いており(フレームループp99 3ms、21msを超えるタスクなし、MajorGC 0回)、コンポジタはBeginFrameを毎回受け取っているのに、フレームはスワップ/表示段階で保留され(Swap 24〜27ms、submit→presentation 48ms)、その結果スケジューラがBeginMainFrameを飛ばしていました。キャンバスの大きさは無関係(renderScalePct 50/75/100で同じ)、GPU使用率は15〜60%にとどまり、オーバーレイを切って動画の上で素のDOMを60fpsで動かすだけでさらに多く取りこぼします。つまり原因は描画ではなく、ページのコミットパイプラインが表示側のバックプレッシャーで詰まることです。
同じページで計測した2つの対策(12秒あたりのvsync取りこぼし数):
| 条件 | X11 (XWayland) | ネイティブWayland |
|---|---|---|
| 既定のキャンバス | 26, 38 | 10 |
desynchronized: true のキャンバス |
3, 4 | 4 |
| オーバーレイなし、全面DOMアニメーション | 60 | 7 |
- オーバーレイのキャンバスは
{ alpha: true, desynchronized: true }で作ります。ページのコミットに相乗りせず、独自のコンポジタサーフェスを持つためです。デスクトップChromeではダブルバッファのまま(シングルバッファの低遅延キャンバスはChromeOS/Androidのみ)なのでティアリングは起きません。Chrome 140以降はWaylandセッションではネイティブWaylandで動作し、こちらの列の方が素性が良いです。 - わずかに残る取りこぼしは、下記の平滑化したフレーム間隔によりジャンプとしては見えなくなります。
エンジンがコンテントスクリプトにあった間、弾幕のループはYouTubeのメインスレッドを共有していました。実ページ(Brave 154、ネイティブWayland、Renoir iGPU、ホロライブの配信、15秒)で計測すると、拡張をオフにしてもYouTube自身が655 msと526 msのメインスレッドタスクを実行しており、ページ内のオーバーレイはそのたびに止まっていました。さらにオーバーレイのrAFが、YouTubeが必要とする時だけでなく毎vsyncでページ全体のライフサイクル(スタイル、IntersectionObserverの計算、レイヤ化、ペイント)を走らせていました。
Workerならこれを避けられますが、YouTubeのCSPがblob:のWorkerをブロックし、Workerは拡張オリジンのスクリプトを読めません。そこでエンジンは、プレーヤーの上に重ねた拡張オリジンのiframe stage.htmlで動かすようにしました。拡張自身のレンダラープロセスとフレームクロックを持ちます。content.jsはページ側の設定・翻訳・トグルを担当し、コメントをpostMessageで渡し、プレーヤーのポインタ入力も中継します(ステージはpointer-events: none。カーソル下に何があるかをステージが報告するので、右クリックを同期的に握りつぶせます)。ステージは設定をストレージから直接読みます。
最終ビルド、ハードウェアGLのheadless Brave(SYC_HEADLESS=1)、20コメント/秒+ライブチャットで15秒、各3回:
| 実行 | YouTubeのメインスレッド | YouTubeのBeginMainFrame |
|---|---|---|
| 拡張オフ | 27–30 ms/s | 2–3/s |
| ページ内エンジン | 168–171 ms/s | 30–31/s |
| ステージフレーム | 37–77 ms/s | 2/s |
合計は小さくなりません。描画の仕事はステージのプロセスへ移ります(実画面で45–70 ms/s)。変わるのは、YouTubeのページが拡張なしと同程度に軽いままになり、YouTubeの長いタスクの間も弾幕が動き続けることです。headlessのキャンバスはソフトウェアラスタなので、ステージ側の数値とフレームペーシングはheadlessでは代表値になりません。それらは実画面で判断してください。
注意点:
- ステージはセッションごとの動的URLで読み込みますが、文書のオリジンは
chrome-extension://<拡張ID>のままです。照合と送信先はそちらに合わせます。 - iframeの
color-schemeが中の文書と異なると不透明な背景が描かれます。両側ともnormalにしています。
- ホットキューでの
Array.prototype.shift()は配列の内容を移動させる可能性があるため、ヘッドインデックスまたはリングバッファを好むべきです。 - テキストラスタリゼーションはスコアリングよりも高価です。
push()はキューに追加するだけです。フレームループはハードな時間予算(rasterBudgetMs)の下でreadyキューへラスタライズしてから受け入れます。レンダースレッドには意図的にタイマーを置きません: vsyncの締め切り直前にsetTimeoutスライスが入ると周期的な「ガクン」として現れます(正確に1秒のワーカー統計タイマーが約1Hzのヒッチを引き起こしました)。そのためラスタリゼーションはフレーム駆動で有界です。予算はバーストを数msに抑え、フレーム内に十分収まります。 - 高DPRはビットマップメモリと描画/クリア作業を増加させます。レンダラーアーキテクチャを追加する前に、
renderScalePctとレーン形状を適切に保ってください。 - グロー/シャドウは負荷の下で劣化するべきです。
- レーンの割り当ては現在安価ですが、レーン数が大幅に増加した場合は、レーンの空き時間に対して優先キューを使用するべきです。
- 移動量は生のrAF差分ではなく平滑化したフレーム間隔(
frameEMA、α = 0.1)で決めます。vsyncを1回取りこぼした時点で画面には同じフレームが2回表示済みなので、その直後に生の33msぶん進めると全コメントが2歩先へ飛び、この「止まってから2倍飛ぶ」が目に見えるガタつきになります。平滑化すると取りこぼしは単なる「1回止まる」だけになり、フレームレートが恒常的に低い場合も約20フレームで実時間速度に収束します。弾幕は時計に同期していないので、わずかなドリフトは見えません。LONG_GAP_MSを超えるギャップは一時停止(テレポートも消滅もしない)として扱います。通常のヒッチ(約500ms)より十分大きく保たないと不具合が再発します。 - アイドルフレーム(スプライトなしでキャンバスがクリーン)は、フルキャンバスのクリアとコンポジットを完全にスキップします。
clear()/_resize()が_dirtyを立てるため、古いフレームは一度だけ確実に消去されます。 - 実行時に解像度を「適応」させないでください。以前の適応レンダースケール(約1.5秒ごとに
dprを下げ、そのたびにラスタキャッシュを全消去してキャンバスをリサイズ)はそれ自体が周期的なガクつきの発生源だったため撤去しました。 - スプライト位置は水平方向にはスナップしません: xは正確な小数座標で描画します。xをデバイスグリッドにスナップすると等速運動が交互のピクセル刻みに量子化され(
dpr 0.75で1フレームあたり約18%の速度変動を計測)、常時ガクつきとして見えます。レーンのyは変化しないため、文字エッジの鮮明さのためにスナップしたままにします(Math.round(y * dpr) / dpr)。水平方向のサブピクセルフィルタリングは階段状のカクつきよりはるかに目立ちません。
現在のヒューリスティックスコアラーのために新しいスコアラートランスポートを追加しないでください。バッチ処理または状態を持つスコアラーが明確なChrome/V8のエンドツーエンドの勝利を最初に証明し、契約/ドキュメントが出荷前に更新される場合にのみ再考してください。