webgl-components について
シェーダー駆動の WebGL 表現を、本番用 UI コンポーネントとして実装するための技術ガイダンスです。共有レンダリング、性能、光学効果、画像処理、フォールバックを扱います。
主な機能
- 01複数インスタンスで WebGL コンテキストを共有
- 02単一のフレームループで描画を調整
- 03シェーダーとテクスチャのコストを管理
- 04屈折で光学効果を表現
- 05コンテキスト喪失とフォールバックに対応
- 06画像テクスチャをキャッシュしてデコード
活用シーン
- 01リスト内のアニメーションアバター描画
- 02UI へのアンビエントシェーダー表現の追加
- 03反復ウィジェットの GPU 処理を削減
- 04WebGL が使えない環境でのフォールバック提供
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ flornkm/ skills --skill webgl-componentsインストール後、Claude Code で/webgl-componentsを入力して読み込まれたか確認します。
同梱ファイルscripts/gpu-probe.html
詳細
WebGL コンポーネント
シェーダー駆動のビジュアル(アイデンティティアバター、アンビエントオーブ、アニメーションテクスチャ)を製品 UI に組み込むためのレッスンです。起こりがちな問題は予測可能で、そのほとんどはウィジェットを、リスト内で 50 回レンダリングされるコンポーネントではなく、デモのように扱うことに起因します。
アーキテクチャ:1 つのコンテキスト、多数のインスタンス
コンポーネントのインスタンスごとに WebGL コンテキストを作成してはいけません。 ブラウザーは 1 つのドキュメントで同時に使用できるコンテキスト数をおよそ 8~16 個に制限しており、上限に達すると最も古いものを通知なく破棄します。アバターのリストはすぐに上限に達します。
- モジュールレベルで 1 つの WebGL コンテキストを用意してオフスクリーンで描画し、各コンポーネントインスタンスには軽量な 2D キャンバスを割り当てます。各フレームでは、共有 GL キャンバスに描画してから、
drawImageで領域をインスタンスの 2D キャンバスに転送します。 - GL の描画バッファーはブラウザーが合成するまでしか有効ではないため、描画と転送は同じタスク内で行います。await をまたいではいけません。
- GL の原点は左下、2D キャンバスの原点は左上です。そのため、
0ではなくcanvas.height - sizeを始点として転送します。 - 共有キャンバスは最大のインスタンスに合わせて拡大し、セッション中は縮小しません。
- 同一の描画をまとめます。 すべてのビジュアル入力(ソース、色合い、サイズ、ポインターの状態)が一致するインスタンスは同じピクセルを描画するため、1 回描画して、その結果をすべてのキャンバスに転送します。同じスタイルの項目が並ぶリストなら、行ごとに 1 回ではなく、フレームごとに 1 回の描画で済みます。
フレームループの管理
- すべてのインスタンスで共有する
requestAnimationFrameループを 1 つだけ用意し、モジュールレベルで管理します。インスタンスごとにループを作ってはいけません。 IntersectionObserverですべてのインスタンスの表示状態を監視し、画面外のインスタンスは描画しないようにします。document.visibilityState !== "visible"の間はループを完全に一時停止し、表示中のインスタンスがなくなったらスケジュールを停止します。- フレームレートに上限を設けます。 ゆっくりしたアンビエントな動きは 30fps を超えても利点がなく、上限のないループはウィジェットが画面に表示されている間ずっと CPU コアを占有します。rAF が 120 回呼び出される 120Hz ディスプレイでは、その負荷が 2 倍になります。
- 一度限りの高コストな処理は分散させます。ソーステクスチャの生成は、残りを後のフレームに再スケジュールして、1 フレームあたりおよそ 1 つに抑えます。一度に 12 種類のバリエーションを追加するページで、すべてを単一フレーム内に生成すると、初回描画が滞るためです。
コストを左右するのはフラグメントシェーダーの処理と面積
- ピクセル単位のコストはインスタンス数ではなくウィジェットの面積に比例します。 フラグメントシェーダーはホットループとして予算を見積もります。ピクセルごと、フレームごとにサンプリングするブラーカーネル(13、25、121 タップ)は、この種のウィジェットが破綻する原因です。ピクセルあたりテクスチャ参照を 1 回に抑え、ノイズ評価を数回追加する程度を目指します。
- レイヤー化されたパターンや高コストなパターンの生成は、事前計算済みのソーステクスチャに移します。ノイズオクターブ、筋、粒子をあらかじめ焼き込んだものをオフスクリーンで一度だけレンダリングし、フレームごとの処理では歪ませた位置からサンプリングするだけにします。動きはパターンの再計算ではなく、サンプリングする位置によって生み出します。
- ノイズで UV を揺らすと、GPU の微分値に基づくミップ選択が過剰にぼかします。歪んだ微分値が実際のサンプリング密度より大きく見えるためです。
texture2D(..., bias)に負のミップバイアスを適用し、ディテールを戻します。これは「なぜ急にぼやけて色あせて見えるのか」という症状として現れ、テクスチャの問題と誤診されやすいものです。 - 何もぼかさなくなった時点で、ミップマップは必須です。1024px のソースを 16~48px のウィジェットに縮小すると、ひどいエイリアシングが発生します。WebGL1 の
generateMipmapには 2 の累乗のテクスチャが必要なため、生成するソースは 2 の累乗に保ち、任意サイズの画像はアップロード前に2 の累乗のキャンバスへリサンプリングします。
描いて済ませず、実際の光学現象を使いましょう
ガラス、クローム、虹色の輝き、コースティクスは、こうしたウィジェットが存在する一般的な理由です。それらを描いて表現しようと考えるのは自然なことです。ハイライトにはガウス関数を使い、虹には紫から赤へのランプを手作業で作り、エッジには smoothstep のリングを使う、といった具合です。しかし、描いて表現した光学効果は、角度や背景が変わるたびに新たな調整項目が必要になり、何かが動いた途端に説得力を失います。光学現象から導き出す方法なら、ピクセルあたりのコストはほぼ同じでありながら、それだけで成立します。
- 法線ひとつで、すべてを表現できます。 円盤では、
N = normalize(vec3(p, sqrt(1 - r*r)))です。これから、フレネル項0.04 + 0.96 * pow(1 - dot(N, V), 5.0)と反射ベクトルをあと2行で求められます。また、フレネル項で重み付けしたenv(reflect(-V, N))だけで、すでに鏡面反射のエッジを表現できます。かすめる角度では重みが1になり、リムには環境しか映りません。これに別途リム項を足す必要があるなら、反射が本来の役割を果たしていない証拠です。 - 環境はキューブマップではなく、関数でも表現できます。 方向を色に変換する関数は、柔らかなキーライトのローブ、垂直方向のグラデーション、床からの反射を組み合わせれば、
powsを数個使うだけで実現できます。テクスチャのアップロードもGPUメモリも不要で、ウィジェットの曲面全体にわたって、見た目の正しい反射を得られます。 - 分散は描くのではなく、生成しましょう。 内部の像を複数の波長でサンプリングし、それぞれに異なる屈折率を使って、目が割り当てる色で重み付けします。内部の白色光源は正しい順序のスペクトルとなって現れ、ガラスが厚いリムに向かって広がりも自動的に大きくなります。手作業で作ったランプでは、こうはなりません。単純なRGBの3回サンプリングでは両端に色の縁ができるだけです。完全なスペクトルには中間の波長も必要なので、8~16回のサンプリングを見込んでください。
- 実際の光学系について、バグのように見えてもそうではない点が2つあります。
- 球体では、その半径に沿って分散します。 水平な特徴は長さ方向に分かれ、頑固に白いままです。その特徴に垂直な方向に広がりを得るには、屈折法線に傾きを加えるくさびが必要です。これは小細工ではなく、プリズムです。
- そのくさびをそのまま適用すると、平均偏角の分だけ像もずれて、特徴がウィジェットの端から外れてしまいます。 画像の位置は通常の表面法線で決め、中間波長に対するくさびの着地点を計算し、波長ごとにはその差分だけを加えます。位置と分離には別々の制御が必要で、通常は深度も別にします。色収差の広がりに適した光線深度を使って画像の位置も決めると、像が弧を描いてしまうためです。
- 物理的であることは、誇張しないという意味ではありません。実際のガラスの分散は、ウィジェットのサイズでは見えないほど小さいため、現実から大きく離してもよい値は屈折率の広がりです。これ以外のすべての値はそこから決まるため、名前付き定数として保持し、その旨を明記してください。
- 内部の像が鮮明になるほど、必要な波長数も増えます。 波長ごとに内部の像全体を別々に描画するため、ぼやけた特徴なら粗いサンプリングが目立ちませんが、くっきりした特徴では縞が見えるようになります。14回のサンプリングで鮮明なリボンを3本描くと、スペクトルではなく42本の線を描くことになります。内部の像を鮮明にすることとサンプリング回数を増やすことは、同じ変更です。前者だけを行うと、シェーダーのバグのように見えます。
- 波長ごとに変化しないものは、すべてループの外に出しましょう。 分散では、サンプルが一方の軸に沿ってずれるため、もう一方の軸から導かれるもの、つまり波の中心線、幅のテーパー、長さ方向の減衰は、どのサンプルでも同じであり、一度だけ計算できます。これにより、28回のサンプリングのコストは14回の場合とほぼ同じになり、「高コストなので回数を減らす」ではなく「適切にサンプリングする」ことが可能になります。
- ループ外への移動後にコストを確認しましょう。波長ごとのサンプリングは、
refract1回と少数のexps だけです。ループ全体のコストは13タップのブラーと同程度であり、ブラーと違って面積以外の要素には左右されません。それでもコストを削る必要がある場合は、解像度を落とす前にサンプリング回数を減らしてください。ただし、そのたびに縞模様が出ないか再確認してください。
透明性:シェーダーが届く範囲と届かない範囲
- 任意のページコンテンツの上に重ねるウィジェットでは、完成した不透明画像ではなく、追加するものと遮るものを出力する必要があります — プリマルチプライドな
vec4(emitted, coverage)を使います。中央を透明にしてページを透過させ、反射する縁だけを適切に不透明にできます。 - 環境光の定数には、背景が暗黙に想定されています。 白いページ上のウィジェットに暗いスタジオを映り込ませると、視線が浅い角度になる縁ではほかのものが何も見えないため、硬い黒い輪郭ができます。
prefers-color-schemeに応じて環境光のレベルを設定し、uniform として渡してください。 - WebGL は、自身のキャンバスの背後にあるピクセルを読み取れません。 シェーダーだけで、ライブなページコンテンツを真に屈折させることはできません。正直な選択肢は、
backdrop-filter(blur()はどの環境でもサポートされています。SVG のfeDisplacementMapなら適切に歪ませられますが、移植性はありません)を適用した DOM レイヤーをキャンバスの下に置くか、背景を自分でシーンに描画することです。どちらを実装したかを明記してください。ページを屈折させると説明されたウィジェットが、実際にはプロシージャルな環境を反射しているだけなら、模様のあるものの上に移動した瞬間に見破られます。
解像度:シェーダーの負荷を基準にする
ほかのプロジェクトのデバイスピクセル比の上限をそのまま使わないでください。適切な上限は、フラグメントシェーダーの負荷によって決まります。
- 負荷の高いシェーダー(マルチタップブラーや、ピクセルごとに多数のオクターブを使うもの)では、フィルコストが比率の二乗に比例するため、約 1.5 という積極的な上限が必要です。
- 負荷の低いシェーダー(テクスチャ参照が 1 回のもの)では、ディスプレイ本来の密度で描画してください。それより低く制限すると、ウィジェットの縁が目に見えてぼやけるだけのアップスケーリングになり、ほとんど何も得られません。
よりシャープな描画を実現するには、まずシェーダーを最適化してください。上限はシェーダーを完成させてから決め、先に決めないでください。
テクスチャキャッシュと GPU メモリ
- 安定したソースキーでテクスチャをキャッシュし、複数のインスタンスで 1 回のアップロードを共有できるようにします。
- キャッシュのサイズに上限を設けてください。 各ソーステクスチャは、ミップチェーンを含めておよそ 100 万ピクセル以上になるため、バリアント数が無制限だと GPU メモリが際限なく増加します。
- 単に挿入順でなく、実際の状況に応じて削除してください。マウント済みインスタンスで現在使用中のキーは除外し、上限以下になるまで残りを
gl.deleteTextureで削除します。画面上に表示中のテクスチャを削除すると、すぐに再生成が必要になります。
画像ソース
createImageBitmapを使ってメインスレッド以外でデコードし、使えない場合はImageにフォールバックします。また、早期リターン経路でデコード済みピクセルメモリがリークしないよう、finallyでビットマップをclose()します。- テクスチャが読み込まれるまでは、単色の色合いなど、もっともらしいプレースホルダーを表示します。読み込みが完了したら、リスナーを再描画します。
- カバー表示に合わせて画像をクロップする場合、輝度のサンプリングやパレット抽出など、そこから導き出すすべての測定では、表示するものと同じクロップ領域を測定する必要があります。そうしないと、保証が成り立つのは誰にも見えないピクセルだけになってしまいます。
堅牢性
- コンテキストの喪失に対処してください。
webglcontextlostを監視し、preventDefault()を呼び出して描画ループを停止し、マウント済みのすべてのインスタンスを DOM フォールバックに切り替えます。GPU のリセットやタブへの負荷によるコンテキスト喪失は日常的に起こることであり、まれな事象ではありません。 failIfMajorPerformanceCaveat: trueを指定して、ソフトウェアラスタライズを拒否してください。GPU がブロックリストに登録されている環境では、負荷の高いソース処理に数秒かかってタブがフリーズするため、そこでのウィジェットにはフラットなフォールバックのほうが確実に適しています。- シェーダー自体で出力範囲を保証してください。生成される輝度を中間的な範囲にクランプし、輝度を保つブレンドで色合いを適用します。これにより、色相と彩度は色合いから、輝度はソースから取得できるため、どの入力でもウィジェットが真っ黒になったり、白飛びしたりすることはありません。選別済みの入力が今後も選別されているとは限らないため、それを当てにしないでください。
フォールバックはエッジケースではなく、本番環境で必要な機能です
failIfMajorPerformanceCaveat: true は適切な選択ですが、その意味を受け入れてください。このウィジェットは、ハードウェアアクセラレーションが無効になっているすべてのマシンで描画されません。これは珍しい状態ではなく、Chrome の設定を切り替えるだけで発生します。さらに、ブロックリストに登録された GPU、リモートデスクトップセッション、コンテキストの喪失でも描画されません。そうしたユーザーにはフォールバックだけが表示されますが、いずれも開発に使うマシンではありません。だからこそフォールバックは壊れたままリリースされます。修正できる人が誰も実際に描画を確認しないからです。
- WebGL を使わないフォールバックを必ず同梱し、要素のアイデンティティを保ってください。同じ色、同じシード由来の見た目を、通常の DOM と CSS で描画します。ウィジェットは装飾であり、フォールバックが契約です。
fallbackを最小限の API 仕様に含めてください。sourceの隣にプロパティとして用意し、ドキュメントにあるすべてのサンプルに含めてください。特に最初のコピー&ペースト用スニペットには必ず含めます。人間もエージェントも、このスニペットを流用するからです。「フォールバックのデモ」という専用の例だけで渡すギャラリーでは、他のすべての利用者に省略してよいと教えてしまいます。- フォールバックをソースの種類に合わせてください。生成されたソースなら、同じ色合いの平らな円盤にします(色はアイデンティティを示すシグナルなので、同じ種類の要素として認識できます)。画像ソースなら、シェーダーのカバーフィット時のクロップに合わせ、同じ画像に
object-coverを指定します。より大きな画像を切り取って表示する場合は、フレームに対して絶対配置し、クリップによってクロップされるようにします。 - フォールバックを切り抜くフレームを、その包含ブロックにしてください。
overflow: hiddenは、切り抜く要素自体が配置要素でなければ、絶対配置された子孫を切り抜きません。大きすぎる画像を静的なラッパーに対して配置するフォールバックは、クリップを無視し、周囲の UI に画像を元の大きさで描画してしまいます。オーバーフローを隠すフレームにposition: relativeを設定し、設定されていない場合はレビューをブロックしてください。この不具合は、開発マシンで該当ブランチが一度も描画されないため、気づかれないままリリースされます。 - 正常な GPU でも分岐を実行できるようにしてください。
forceFallbackプロパティを用意し、デザインシステムまたはドキュメントページで、対応する中で最も厳しいフォールバック形状(ウィジェットより桁違いに大きく、中心からずらして配置した画像)に使用します。そうすれば、クリッピングの不具合があれば顧客からの報告を待つことなく、ドキュメントページ上に写真が大きくはみ出し、誰でも気づけます。フォールバックへの切り替えでキャンバスがアンマウントされる場合は、登録用のエフェクトがそのフラグに依存するようにし、古いインスタンスをリークさせずに解放してください。 - ドキュメントのデモが、謳っている内容を実際に検証しているか確認してください。たとえば、MDX の
imgオーバーライドなど、ドキュメントのパイプラインで組み込み要素を横取りすると、フォールバックが依存するclassNameやstyleが気づかれないまま取り除かれることがあります。その結果、デモはそれらしく表示されても、何もテストできていない状態になります。
React と SSR の統合
- React Server Components では、コンポーネントのファイルに
"use client"が必要です。 これは React 全般の要件ではなく、RSC の境界を示すマーカーです。そのため、使用している環境を確認してください。Next.js App Router や、その他の RSC 構成(React Router の RSC モード、Waku、Parcel および Vite の RSC プラグイン)では適用されます。一方、通常の SPA、Next Pages Router、Astro や Remix のアイランドでは効果のないディレクティブであり、モジュールレベルのディレクティブに関するバンドラー警告が出るだけです。適用される環境では、これを省略しても開発時には動作することが多い一方、開発時と本番時で RSC の動作が異なるため、本番のサーバーレンダリングが原因の分かりにくいダイジェストエラーで失敗することがあります。開発サーバーではなく、実際の本番ビルドで確認してください。 - RSC かどうかにかかわらず、すべてのサーバーレンダリングではサーバー上でモジュールがインポートされます。そのため、モジュールのスコープではブラウザー API(
window、document、matchMedia)を使用せず、マウント後に呼び出す関数内でのみ使用してください。これは Next と同じくらい、Astro、Remix、Gatsby、Vite の SSR ビルドでも問題になります。 - エフェクトの依存関係に使うプリミティブ値には、オブジェクトのプロパティを展開してください。
source={{...}}のようなインラインオブジェクトは、エフェクトがオブジェクトの同一性に依存している場合、レンダーのたびにインスタンスを再登録します。 - 登録と解除は、クリーンアップ関数を返す1つのエフェクトで行います。ポインター位置や表示状態などの命令的な制御には state ではなく ref を使ってください。ポインターの移動で React を再レンダーしてはなりません。
アクセシビリティとレイアウト
prefers-reduced-motionが有効な場合は、静止画を1フレームだけ描画し、ループを開始しません。 動きを減らすことは見た目を簡素化することではないため、ビジュアルの忠実度は維持してください。メディアクエリーの変更を監視し、表示をリアルタイムで再描画します。- 装飾的なインスタンスには
aria-hiddenを指定し、意味のあるインスタンスにはラベル付きでrole="img"を指定します。デフォルトを非表示とするプロパティにしてください。 - 内部で z-index を使ってレイヤーを重ねるコンポーネントは、
isolation: isolateで囲んでください。そうしないと、内部のスタッキングがページに漏れ、ウィジェットが固定ヘッダーやナビゲーションより前面に表示されます。 - キャンバスや背後の画像など、視覚表現のみを担うレイヤーは操作できないようにしてください。ポインターイベント、ドラッグ、テキスト選択を無効にします。
識別性と決定性
- エンティティの ID の安定したハッシュを使って、切り抜き範囲、パレット、ノイズのオフセットなどを厳選した小さなテーブルから選び、エンティティごとのバリエーションを決定します。同じエンティティは、セッションや表示領域をまたいでも、WebGL からフォールバックに切り替わっても、同じ見た目でなければなりません。
- Knuth の乗算ハッシュのような強力な整数ハッシュを使って、パラメーター空間全体にバリエーションを分散させてください。連番のオフセットは使わないでください。隣り合う ID が明確に異なる見た目になるようにします。
- 描画処理では
Math.random()を決して使わないでください。ウィジェットをスクリーンセーバーではなく識別マークにするのは、決定性です。
目視確認なしで検証する
- シェーダーのロジックは単純な数式なので、NumPy または PIL に移植し、統計値をアサートします。バリアントごとに平均輝度、パーセンタイルの広がり、局所勾配の詳細メトリクスを確認します。これにより、ブラウザーを開かなくても、「すべてのバリアントがほぼ黒で描画される」「コントラストが失われる」といった問題を検出できます。
- ドキュメントから推測せず、測定してマシンがどの分岐を通るかを確かめます。
scripts/gpu-probe.htmlで、厳格なコンテキストの結果、WebGL が存在するかどうか、現在のコンテキスト上限、ブリットの向きを直接確認できます。結果をコンポーネントに適用できるのはオプションが一致する場合に限られるため、まずファイルの先頭にあるCONTEXT_OPTIONSを実際のコンポーネントのオプションに合わせて編集してください。サーバーで配信し、JSON を確認します。
python3 -m http.server 8000 --directory scripts
- GPU なしの経路を実際にテストします。前提で済ませないでください。GPU のない Chrome でプローブを開き、次に独自のアプリを開きます。一時的な
--user-data-dirを使うと、通常のプロファイルに影響しません。
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --disable-gpu --user-data-dir=/tmp/nogpu-profile http://localhost:8000/gpu-probe.html
プローブに FALLBACK と表示されることを確認し、アプリ内のすべてのウィジェットがフォールバックを表示し、正しくクリッピングされ、フレームの外には何も描画されないことを確認します。GPU なしの状態には違いがあることに注意してください。--disable-gpu は WebGL を完全に無効にします。一方、GUI の切り替え(設定 → システム →「ハードウェア アクセラレーションが使用可能な場合は使用する」をオフにしてから再起動)では、通常、ソフトウェアラスタライザーが残りますが、厳格なコンテキストはどのみちそれを拒否します。どちらもフォールバック分岐に進みます。また、影響を受けるユーザーが実際に利用している状態は、GUI の切り替えによるものです。
- ブラウザーでのチェック項目:50 個のインスタンスが 60fps でスクロールする、タブを非表示にすると CPU 使用率がほぼゼロになる、モーションを軽減すると静止フレームが表示される、コンテキストを強制的に失うとフォールバックに切り替わる、
--disable-gpuではフォールバックだけが表示されてフレームの外には何も描画されない、本番ビルドでページが配信される。