次期 WebGPU への移行は、単にグラフィックス API を切り替えるだけではありません。これは Web グラフィックスの未来に向けた一歩でもあります。ただし、準備と理解があれば、この移行はより良いものになります。この記事を読めばその準備が整います。
皆さん、こんにちは。私の名前はドミトリー・イヴァシュチェンコです。MY.GAMES のソフトウェア エンジニアです。この記事では、WebGL と今後の WebGPU の違いについて説明し、プロジェクトの移行を準備する方法について説明します。
WebGL と WebGPU のタイムライン
WebGPU の現状と今後の展望
高レベルの概念的な違い
初期化
• WebGL: コンテキスト モデル
• WebGPU: デバイスモデル
プログラムとパイプライン
• WebGL: プログラム
• WebGPU: パイプライン
制服
• WebGL 1 のユニフォーム
• WebGL 2 のユニフォーム
• WebGPU のユニフォーム
シェーダ
• シェーダ言語: GLSL と WGSL
• データ型の比較
• 構造物
• 関数の宣言
• 組み込み関数
• シェーダ変換
規約の違い
テクスチャ
• ビューポートスペース
• クリップスペース
WebGPU のヒントとコツ
• 使用するパイプラインの数を最小限に抑えます。
• 事前にパイプラインを作成する
• レンダーバンドルを使用する
まとめ
WebGL は、他の多くの Web テクノロジーと同様、そのルーツはかなり過去にまで遡ります。 WebGPU への移行の背後にあるダイナミクスと動機を理解するには、まず WebGL 開発の歴史を簡単に見てみると役立ちます。
近年、開発者にさらなる制御と柔軟性を提供する新しいグラフィックス API への関心が高まっています。
現在、WebGPU は、バージョン 113 以降、Google Chrome ブラウザーや Microsoft Edge ブラウザーを介して Windows、Mac、ChromeOS などの複数のプラットフォームで利用できます。近い将来、Linux および Android もサポートされる予定です。
WebGPU をすでにサポートしている (または実験的なサポートを提供している) エンジンの一部を以下に示します。
これを考慮すると、WebGPU への移行、または少なくともそのような移行に向けたプロジェクトの準備は、近い将来にタイムリーなステップであると思われます。
ズームアウトして、初期化から始めて、WebGL と WebGPU の間の高レベルの概念的な違いをいくつか見てみましょう。
グラフィックス API の使用を開始するとき、最初のステップの 1 つは、対話用にメイン オブジェクトを初期化することです。このプロセスは WebGL と WebGPU で異なり、両方のシステムにいくつかの特徴があります。
WebGL では、このオブジェクトは「コンテキスト」として知られており、本質的に HTML5 キャンバス要素上に描画するためのインターフェイスを表します。このコンテキストを取得するのは非常に簡単です。
const gl = canvas.getContext('webgl');
WebGL のコンテキストは、実際には特定のキャンバスに関連付けられています。つまり、複数のキャンバスでレンダリングする必要がある場合は、複数のコンテキストが必要になります。
WebGPU では、「デバイス」と呼ばれる新しい概念が導入されています。このデバイスは、対話する GPU 抽象化を表します。初期化プロセスは WebGL よりも少し複雑ですが、柔軟性が高くなります。
const adapter = await navigator.gpu.requestAdapter(); const device = await adapter.requestDevice(); const context = canvas.getContext('webgpu'); context.configure({ device, format: 'bgra8unorm', });
このモデルの利点の 1 つは、1 つのデバイスで複数のキャンバスにレンダリングできること、またはまったくレンダリングできないことです。これにより、柔軟性がさらに高まります。たとえば、1 つのデバイスが複数のウィンドウまたはコンテキストでのレンダリングを制御する場合があります。
WebGL と WebGPU は、グラフィックス パイプラインを管理および編成するための異なるアプローチを表します。
WebGL では、主にシェーダ プログラムに焦点が当てられます。このプログラムは頂点シェーダーとフラグメント シェーダーを組み合わせて、頂点の変換方法と各ピクセルの色付け方法を定義します。
const program = gl.createProgram(); gl.attachShader(program, vertShader); gl.attachShader(program, fragShader); gl.bindAttribLocation(program, 'position', 0); gl.linkProgram(program);
WebGL でプログラムを作成する手順:
このプロセスにより、柔軟なグラフィックス制御が可能になりますが、特に大規模で複雑なプロジェクトの場合、複雑でエラーが発生しやすくなる場合もあります。
WebGPU では、個別のプログラムではなく「パイプライン」の概念が導入されています。このパイプラインはシェーダーだけでなく、WebGL では状態として確立される他の情報も結合します。したがって、WebGPU でのパイプラインの作成はより複雑に見えます。
const pipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module: shaderModule, entryPoint: 'vertexMain', buffers: [{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }], }, fragment: { module: shaderModule, entryPoint: 'fragmentMain', targets: [{ format, }], }, });
WebGPU でパイプラインを作成する手順:
WebGL はレンダリングの各側面を分離しますが、WebGPU はより多くの側面を 1 つのオブジェクトにカプセル化して、システムをよりモジュール化して柔軟にしようとします。 WebGL のようにシェーダーとレンダリング状態を個別に管理するのではなく、WebGPU はすべてを 1 つのパイプライン オブジェクトに結合します。これにより、プロセスがより予測可能になり、エラーが発生しにくくなります。
均一変数は、すべてのシェーダ インスタンスで使用できる定数データを提供します。
基本的な WebGL では、API 呼び出しを通じてuniform
変数を直接設定できます。
GLSL :
uniform vec3 u_LightPos; uniform vec3 u_LightDir; uniform vec3 u_LightColor;
JavaScript :
const location = gl.getUniformLocation(p, "u_LightPos"); gl.uniform3fv(location, [100, 300, 500]);
この方法は単純ですが、 uniform
変数ごとに複数の API 呼び出しが必要です。
WebGL 2 の登場により、 uniform
変数をバッファーにグループ化できるようになりました。個別のユニフォーム シェーダを使用することもできますが、より良いオプションは、ユニフォーム バッファを使用して、異なるユニフォームをより大きな構造にグループ化することです。次に、WebGL 1 で頂点バッファをロードする方法と同様に、この均一なデータをすべて GPU に一度に送信します。これには、API 呼び出しが減り、最新の GPU の動作に近づくなど、パフォーマンス上の利点がいくつかあります。
GLSL :
layout(std140) uniform ub_Params { vec4 u_LightPos; vec4 u_LightDir; vec4 u_LightColor; };
JavaScript :
gl.bindBufferBase(gl.UNIFORM_BUFFER, 1, gl.createBuffer());
WebGL 2 で大きな均一バッファのサブセットをバインドするには、 bindBufferRange
として知られる特別な API 呼び出しを使用できます。 WebGPU には、 setBindGroup
API を呼び出すときにオフセットのリストを渡すことができる、動的ユニフォーム バッファ オフセットと呼ばれる同様のものがあります。
WebGPU はさらに優れた方法を提供します。このコンテキストでは、個々のuniform
変数はサポートされなくなり、作業はuniform
バッファーを通じてのみ行われます。
WGSL :
[[block]] struct Params { u_LightPos : vec4<f32>; u_LightColor : vec4<f32>; u_LightDirection : vec4<f32>; }; [[group(0), binding(0)]] var<uniform> ub_Params : Params;
JavaScript :
const buffer = device.createBuffer({ usage: GPUBufferUsage.UNIFORM, size: 8 });
最新の GPU は、多数の小さなブロックではなく、1 つの大きなブロックにデータをロードすることを好みます。毎回小さなバッファーを再作成して再バインドする代わりに、1 つの大きなバッファーを作成し、そのバッファーの異なる部分をさまざまな描画呼び出しに使用することを検討してください。このアプローチにより、パフォーマンスが大幅に向上します。
WebGL はより命令的であり、呼び出しごとにグローバル状態をリセットし、可能な限りシンプルになるように努めています。一方、WebGPU は、よりオブジェクト指向を目指しており、効率化につながるリソースの再利用に重点を置いています。
WebGL から WebGPU への移行は、方法の違いにより難しいように思えるかもしれません。ただし、中間ステップとして WebGL 2 への移行から始めると、作業が簡素化されます。
WebGL から WebGPU に移行するには、API だけでなくシェーダーも変更する必要があります。 WGSL 仕様は、最新の GPU の効率とパフォーマンスを維持しながら、この移行をスムーズかつ直感的に行うように設計されています。
WGSL は、WebGPU とネイティブ グラフィックス API の間のブリッジとなるように設計されています。 GLSL と比較すると、WGSL は少し冗長に見えますが、構造はよく知られています。
テクスチャのシェーダーの例を次に示します。
GLSL :
sampler2D myTexture; varying vec2 vTexCoord; void main() { return texture(myTexture, vTexCoord); }
WGSL :
[[group(0), binding(0)]] var mySampler: sampler; [[group(0), binding(1)]] var myTexture: texture_2d<f32>; [[stage(fragment)]] fn main([[location(0)]] vTexCoord: vec2<f32>) -> [[location(0)]] vec4<f32> { return textureSample(myTexture, mySampler, vTexCoord); }
以下の表は、GLSL と WGSL の基本データ型と行列データ型の比較を示しています。
GLSL から WGSL への移行は、コードの読みやすさを向上させ、エラーの可能性を減らすことができる、より厳密な型指定とデータ サイズの明示的な定義が求められていることを示しています。
構造体を宣言するための構文も変更されました。
GLSL:
struct Light { vec3 position; vec4 color; float attenuation; vec3 direction; float innerAngle; float angle; float range; };
WGSL:
struct Light { position: vec3<f32>, color: vec4<f32>, attenuation: f32, direction: vec3<f32>, innerAngle: f32, angle: f32, range: f32, };
WGSL 構造内でフィールドを宣言するための明示的な構文を導入することで、より明確になりたいという要望が強調され、シェーダー内のデータ構造の理解を簡素化します。
GLSL :
float saturate(float x) { return clamp(x, 0.0, 1.0); }
WGSL :
fn saturate(x: f32) -> f32 { return clamp(x, 0.0, 1.0); }
WGSL での関数の構文の変更は、宣言と戻り値へのアプローチの統一を反映し、コードの一貫性と予測可能性を高めます。
WGSL では、多くの組み込み GLSL 関数の名前が変更または置換されました。例えば:
WGSL の組み込み関数の名前を変更すると、名前が簡素化されるだけでなく、より直感的になり、他のグラフィックス API に慣れている開発者にとって移行プロセスが容易になります。
プロジェクトを WebGL から WebGPU に変換することを計画している人にとって、**[Naga](https://github.com/gfx-rs/naga) など、GLSL を WGSL に自動的に変換するツールがあることを知っておくことが重要です。 /)**、GLSL を WGSL に変換するための Rust ライブラリです。 WebAssembly を使用すれば、ブラウザ内で直接動作することもできます。
Naga がサポートするエンドポイントは次のとおりです。
移行後、画像が反転するという驚きに遭遇するかもしれません。 OpenGL から Direct3D (またはその逆) にアプリケーションを移植したことのある人は、すでにこの古典的な問題に直面しています。
OpenGL および WebGL のコンテキストでは、テクスチャは通常、開始ピクセルが左下隅に対応するような方法でロードされます。ただし、実際には、多くの開発者は画像を左上隅からロードするため、画像反転エラーが発生します。それにもかかわらず、この誤差は他の要因によって補正でき、最終的には問題を平準化することができます。
OpenGL とは異なり、Direct3D や Metal などのシステムは伝統的にテクスチャの開始点として左上隅を使用します。このアプローチが多くの開発者にとって最も直感的であると思われることを考慮して、WebGPU の作成者はこの手法に従うことにしました。
WebGL コードがフレーム バッファーからピクセルを選択する場合は、WebGPU が異なる座標系を使用するという事実に備えてください。座標を修正するには、単純な「y = 1.0 - y」演算を適用する必要がある場合があります。
開発者がオブジェクトが予想より早く切り取られたり消えたりする問題に直面した場合、多くの場合、これは深度ドメインの違いに関連しています。 WebGL と WebGPU には、クリップ スペースの深度範囲を定義する方法に違いがあります。 WebGL は -1 から 1 の範囲を使用しますが、WebGPU は Direct3D、Metal、Vulkan などの他のグラフィックス API と同様に 0 から 1 の範囲を使用します。この決定は、他のグラフィックス API を使用するときに 0 から 1 の範囲を使用することの利点がいくつか確認されたため、行われました。
モデルの位置をクリップ空間に変換する主な役割は、射影行列にあります。コードを適応させる最も簡単な方法は、射影行列の出力結果が 0 ~ 1 の範囲になるようにすることです。 gl-matrix などのライブラリを使用している場合は、簡単な解決策があります。つまり、 perspective
関数を使用する代わりに、 perspectiveZO
;同様の関数は他の行列演算にも使用できます。
if (webGPU) { // Creates a matrix for a symetric perspective-view frustum // using left-handed coordinates mat4.perspectiveZO(out, Math.PI / 4, ...); } else { // Creates a matrix for a symetric perspective-view frustum // based on the default handedness and default near // and far clip planes definition. mat4.perspective(out, Math.PI / 4, …); }
ただし、既存の射影行列があり、そのソースを変更できない場合があります。この場合、それを 0 から 1 の範囲に変換するには、投影行列に深さの範囲を修正する別の行列を事前に乗算します。
ここで、WebGPU を使用するためのヒントとコツについて説明します。
使用するパイプラインが増えるほど、状態の切り替えが多くなり、パフォーマンスが低下します。資産の出所によっては、これは簡単ではないかもしれません。
パイプラインを作成してすぐに使用することは機能する可能性がありますが、これはお勧めできません。代わりに、すぐに戻り、別のスレッドで動作を開始する関数を作成します。パイプラインを使用する場合、実行キューは保留中のパイプライン作成が完了するまで待機する必要があります。これにより、パフォーマンスに重大な問題が発生する可能性があります。これを回避するには、パイプラインを作成してから最初に使用するまでに必ず時間を空けてください。
あるいは、さらに良いのは、 create*PipelineAsync
バリアントを使用することです。 Promise は、パイプラインが使用できる状態になると、停止することなく解決されます。
device.createComputePipelineAsync({ compute: { module: shaderModule, entryPoint: 'computeMain' } }).then((pipeline) => { const commandEncoder = device.createCommandEncoder(); const passEncoder = commandEncoder.beginComputePass(); passEncoder.setPipeline(pipeline); passEncoder.setBindGroup(0, bindGroup); passEncoder.dispatchWorkgroups(128); passEncoder.end(); device.queue.submit([commandEncoder.finish()]); });
レンダー バンドルは、事前に記録された部分的な再利用可能なレンダー パスです。これらには、ほとんどのレンダリング コマンド (ビューポートの設定などを除く) を含めることができ、後で実際のレンダー パスの一部として「再生」できます。
const renderPass = encoder.beginRenderPass(descriptor); renderPass.setPipeline(renderPipeline); renderPass.draw(3); renderPass.executeBundles([renderBundle]); renderPass.setPipeline(renderPipeline); renderPass.draw(3); renderPass.end();
レンダー バンドルは、通常のレンダー パス コマンドと一緒に実行できます。レンダー パスの状態は、バンドルの実行の前後にデフォルトにリセットされます。これは主に、描画の JavaScript オーバーヘッドを軽減するために行われます。 GPU のパフォーマンスは、アプローチに関係なく変わりません。
WebGPU への移行は、単にグラフィックス API を切り替えるだけではありません。これは、さまざまなグラフィックス API の成功した機能と実践を組み合わせた、Web グラフィックスの未来に向けた一歩でもあります。この移行には技術的および哲学的な変更を完全に理解する必要がありますが、その利点は大きいです。