初めての WebGPU アプリ

1. はじめに

複数の青色の三角形で「W」の文字をスタイリッシュに表現した WebGPU のロゴ

最終更新日: 2023 年 8 月 28 日

WebGPU とは

WebGPU とは、ウェブアプリで GPU の機能を利用するための最新の API です。

最新の API

WebGPU の登場以前は、WebGPU の一部の機能を提供する WebGL という API が存在していました。WebGL によって、それまでは作成できなかったリッチなウェブ コンテンツを作成できるようになり、この API を通して優れたアプリが開発されてきました。しかし WebGL は 2007 年リリースの OpenGL ES 2.0 API をベースとしており、OpenGL ES 2.0 自体もさらに古い OpenGL API に基づくものでした。この間に GPU は大幅に進化しており、Direct3D 12、Metal、Vulkan といった、GPU とのインターフェースとなるネイティブ API も大きく進化しています。

WebGPU により、このような最新 API の先進的な機能をウェブ プラットフォームで利用できるようになります。WebGPU は、GPU の機能をプラットフォームに依存しない形で利用できるようにするとともに、ウェブ開発にとって自然で、基盤となるネイティブ API よりもシンプルに利用できる API を目指して開発されました。

レンダリング

多くの場合、GPU は滑らかで美しいグラフィックを高速でレンダリングするために使用されますが、WebGPU も例外ではありません。デスクトップおよびモバイルの GPU で今日一般的に利用されているレンダリング手法の多くをサポートするのに必要な機能を備えているだけでなく、ハードウェア機能の進化に伴い、新たな機能を取り入れることができる設計となっています。

コンピューティング

WebGPU で実現できるのはレンダリングだけではありません。GPU を利用して、高度に並列化した汎用ワークロードを実行することもできます。こうしたコンピューティング シェーダーは、レンダリング コンポーネントなしで単独で使用することも、レンダリング パイプラインに緊密に統合された形で使用することもできます。

この Codelab では、WebGPU のレンダリングとコンピューティングの両方の機能を使用して、簡単な入門プロジェクトを作成する方法について学習します。

作成するアプリの概要

この Codelab では、WebGPU を使用して、コンウェイのライフゲームを作成します。作成するアプリの機能は次のとおりです。

  • WebGPU のレンダリング機能を使用して、シンプルな 2D グラフィックを描画する。
  • WebGPU のコンピューティング機能を使用して、シミュレーションを実行する。

この Codelab の最終的な成果物のスクリーンショット

ライフゲームは、時間の経過とともに一連のルールに基づいてセルのグリッドが状態を変化させるいわゆる「セル オートマトン」と呼ばれるものです。ライフゲームでは、隣接するアクティブなセルの数に応じてセルがアクティブまたは非アクティブとなり、変化し続ける興味深いパターンが現れます。

学習内容

  • WebGPU のセットアップおよびキャンバスの構成方法
  • シンプルな 2D ジオメトリの描画方法
  • 頂点シェーダーおよびフラグメント シェーダーを使用して、描画内容を変更する方法
  • コンピューティング シェーダーを使用して、シンプルなシミュレーションを実行する方法

この Codelab では、WebGPU の背後にある基本的なコンセプトに重点を置いて説明します。API について包括的に取り上げるものではなく、また、3 次元行列の計算など、関連してよく取り上げられるトピックについても説明していません(この Codelab ではそれらのトピックについての理解は不要です)。

必要なもの

  • ChromeOS、macOS、Windows のいずれかで動作する Chrome の最新バージョン(113 以降)。WebGPU はクロスブラウザ、クロス プラットフォームの API ですが、現状は利用できるブラウザに制限があります。
  • HTML、JavaScript、Chrome DevTools の知識。

WebGL、Metal、Vulkan、Direct3D などの他のグラフィック API についての知識は必須ではありませんが、WebGPU との多くの類似点があるため、これらの API を使用した経験があればスムーズに学習を進めることができます。

2. セットアップする

コードを取得する

この Codelab には依存関係がなく、WebGPU アプリの作成に必要なすべてのステップが記載されているため、あらかじめコードを用意する必要はありません。ただし、もし行き詰まったと感じたときは、https://glitch.com/edit/#!/your-first-webgpu-app に実際に動作するサンプルが用意されていますので、各段階について正しく理解できているか確認するチェックポイントとして参照できます。

デベロッパー コンソールを使用する

WebGPU は非常に複雑な API であり、適切な使用を促すための多くのルールが定められています。さらに厄介なことに、API の仕組みにより多くのエラーで一般的な JavaScript の例外が発生しないため、問題の発生原因を正確に特定することが困難です。

WebGPU を使用して開発を進めると、特に初心者の場合は問題が生じるものですが、その点について気にする必要はありません。API のデベロッパーは、GPU 開発に伴って発生するこのような課題を熟知しているため、WebGPU のコードでエラーが発生した場合にデベロッパー コンソールに非常に詳細かつ有用なメッセージが表示されるようになっています。このメッセージを参照すれば、問題を特定して解決へと導くことができます。

ウェブ アプリケーションの開発作業を行う際は、コンソールを開いた状態にしておくと便利ですが、特に WebGPU を使用した開発では、そのようにすることをおすすめします。

3. WebGPU を初期化する

最初に学ぶべきもの: <canvas>

計算のみを行う場合は、画面に何も表示せずに WebGPU を使用できます。ただし、この Codelab のように何かをレンダリングする場合は、キャンバスが必要です。そこで、まずはキャンバスについて学習しましょう。

<canvas> 要素 1 つと、キャンバス要素に対するクエリを行う <script> タグを含む新しい HTML ドキュメントを作成します(または、Glitch の 00-starter-page.html を使用します)。

  • 以下のコードを含む index.html ファイルを作成します。

index.html

<!doctype html>

<html>
  <head>
    <meta charset="utf-8">
    <title>WebGPU Life</title>
  </head>
  <body>
    <canvas width="512" height="512"></canvas>
    <script type="module">
      const canvas = document.querySelector("canvas");

      // Your WebGPU code will begin here!
    </script>
  </body>
</html>

アダプターとデバイスをリクエストする

ではいよいよ WebGPU の説明に入ります。まず、WebGPU などの API は、ウェブ エコシステム全体で利用できるようになるまでに多少時間がかかることに注意が必要です。そこで念のため、最初にユーザーのブラウザで WebGPU を使用できるかを確認します。

  1. 以下のコードを追加して、WebGPU のエントリ ポイントとして機能する navigator.gpu オブジェクトが存在しているかどうかを確認します。

index.html

if (!navigator.gpu) {
  throw new Error("WebGPU not supported on this browser.");
}

できれば、WebGPU を利用できない場合のために、そのことをユーザーに知らせる WebGPU を使用しないモードの代替ページを用意するとよいでしょう(代替ページでは代わりに WebGL などを使用できます)。ただし、この Codelab では単純にエラーをスローして、コードの実行を停止します。

ブラウザで WebGPU がサポートされていることを確認できたら、アプリの WebGPU を初期化する最初のステップとして、GPUAdapter をリクエストします。アダプターとは、WebGPU 内でデバイスの特定の GPU ハードウェアを表現したものと考えることができます。

  1. アダプターを取得するには、navigator.gpu.requestAdapter() メソッドを使用します。このメソッドは Promise を返すため、await を使用して呼び出すと便利です。

index.html

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
  throw new Error("No appropriate GPUAdapter found.");
}

適切なアダプターが見つからない場合は、adapter 値として null が返されることがあるため、その場合に備えた処理を記述します。たとえば、ユーザーのブラウザが WebGPU をサポートしていても、WebGPU を使用するのに必要なすべての機能を GPU ハードウェアが備えていない場合は、この値が返されます。

ほとんどの場合、この処理のようにブラウザにデフォルトのアダプターを選択させるだけで問題ありませんが、より高度な処理が必要な場合は、requestAdapter() に引数を渡すことで、マルチ GPU 搭載デバイス上で省電力のハードウェアを使用するか、または高パフォーマンスのハードウェアを使用するかを指定できます(マルチ GPU は一部のノートパソコンなどが対応しています)。

アダプターを取得したら、GPU を使用する準備段階の最後として、GPUDevice をリクエストします。デバイスは、GPU とのほとんどのやり取りを行うための主なインターフェースとなります。

  1. adapter.requestDevice() を呼び出してデバイスを取得します。このメソッドも Promise を返します。

index.html

const device = await adapter.requestDevice();

requestAdapter() と同じく、特定のハードウェア機能を有効にしたり、より高い制限値を要求したりする高度なユースケースの場合に渡すことができるオプションも用意されていますが、ここではデフォルトのままでかまいません。

キャンバスを構成する

デバイスを取得できたら、ページに何かを表示する前にもうひとつやるべきことがあります。それは、作成したデバイスで使用するキャンバスの構成です。

  • そのためには、まず canvas.getContext("webgpu") を呼び出して、キャンバスから GPUCanvasContext をリクエストします(これは、それぞれ 2d および webgl のコンテキスト タイプを使用して Canvas2D または WebGL のコンテキストを初期化する場合と同じ呼び出しです)。返される context は、次のコードに示すように、configure() メソッドを使用してデバイスに関連付ける必要があります。

index.html

const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
  device: device,
  format: canvasFormat,
});

ここではいくつかのオプションを渡すことができますが、最も重要なオプションはコンテキストを使用する device と、コンテキストで使用する必要があるテクスチャ形式を表す format です。

テクスチャとは、WebGPU が画像データを保存するために使用するオブジェクトです。各テクスチャには一定の形式があり、この形式により、GPU のメモリ内でのデータの展開方法が規定されます。テクスチャ メモリの仕組みの詳細は、この Codelab の範囲外です。ここで重要な点は、キャンバスのコンテキストによってコードで描画を行うためのテクスチャが提供されること、そして使用する形式によってキャンバスでの画像表示の効率性に影響する可能性があることを理解しておくことです。最大のパフォーマンスを発揮できるテクスチャ形式は、デバイスによって異なります。そのためデバイスに適していない形式を使用すると、ページに画像を表示する際にバックグラウンドで余分なメモリのコピーが発生することがあります。

幸い WebGPU には、キャンバスで使用すべき形式を教えてくれる機能が備わっているため、この点についてあまり心配する必要はありません。ほとんどの場合、上記のように navigator.gpu.getPreferredCanvasFormat() が返す値を渡せば問題ありません。

キャンバスをクリアする

デバイスを取得し、そのデバイスにキャンバスを構成したら、そのデバイスを使用してキャンバスのコンテンツを変更できます。まず、キャンバスを単色で塗りつぶしてクリアしてみましょう。

そのためには(他の WebGPU を使用した操作と同様に)、GPU に処理内容を指示するコマンドを送信する必要があります。

  1. これを行うには、GPU コマンドを記録するインターフェースとなる GPUCommandEncoder をデバイスで作成します。

index.html

const encoder = device.createCommandEncoder();

GPU に送信するコマンドはレンダリング(この場合はキャンバスのクリア)に関連したものなので、次のステップでは encoder を使用して、レンダリング パスを開始します。

WebGPU におけるすべての描画操作は、レンダリング パスを通して実行されます。各レンダリング パスは、beginRenderPass() の呼び出しで始まります。このメソッドでは、実行されたすべての描画コマンドの出力を受け取るテクスチャを定義します。より高度なユースケースに対応するため、アタッチメントと呼ばれる複数のテクスチャを使用できる仕組みも用意されており、レンダリングされるジオメトリの奥行きを保存したり、アンチエイリアスを提供したりできます。このアプリではテクスチャは 1 つだけでかまいません。

  1. context.getCurrentTexture() を呼び出して、先ほど作成したキャンバスのコンテキストからテクスチャを取得します。このメソッドは、キャンバスの width および height 属性に一致するピクセル幅と高さ、そして context.configure() を呼び出したときに指定した format を持つテクスチャを返します。

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
     view: context.getCurrentTexture().createView(),
     loadOp: "clear",
     storeOp: "store",
  }]
});

テクスチャは、colorAttachment の view プロパティとして指定します。レンダリング パスでは、GPUTexture ではなく GPUTextureView を渡して、テクスチャのどの部分にレンダリングするか指定する必要があります。このようにテクスチャの一部を使用する機能は、より高度なユースケースでのみ必要となります。今回はテクスチャに対して引数を指定せずに createView() を呼び出し、レンダリング パスでテクスチャ全体を使用します。

また、開始時および終了時にレンダリング パスでテクスチャに対して行う処理も指定する必要があります。

  • loadOp の値が "clear" の場合は、レンダリング パス開始時にテクスチャをクリアすることを示します。
  • storeOp の値が "store" の場合は、レンダリング パスが終了したら、レンダリング パスで行われたすべての描画処理の結果をテクスチャに保存することを示します。

レンダリング パスの開始後は、少なくとも今回は何もする必要はありません。loadOp: "clear" を指定してレンダリング パスを開始するだけで、テクスチャのビューとキャンバスがクリアされます。

  1. beginRenderPass() の直後に以下の呼び出しを追加して、レンダリング パスを終了します。

index.html

pass.end();

これらの呼び出しを行っただけでは、GPU では何の処理も実行されないことに注意してください。GPU が後で実行するコマンドが記録されるだけです。

  1. コマンド エンコーダに対して finish() を呼び出して、GPUCommandBuffer を作成します。コマンド バッファは、記録されたコマンドをラップして詳細を隠すためのハンドルです。

index.html

const commandBuffer = encoder.finish();
  1. GPUDevice の queue を使用して、GPU にコマンド バッファを送信します。キューにより、すべての GPU コマンドが順番どおり、かつ適切に同期をとりながら実行されます。キューの submit() メソッドはコマンド バッファの配列を受け取りますが、ここでは 1 つのコマンド バッファのみを渡します。

index.html

device.queue.submit([commandBuffer]);

コマンド バッファを送信すると、そのコマンド バッファは再利用できなくなるため、保持しておく必要はありません。さらにコマンドを送信する場合は、別のコマンド バッファを作成する必要があります。そのため、この Codelab のサンプルページのように、これら 2 つのステップを 1 つにまとめて実行するのが一般的です。

index.html

// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);

コマンドを GPU に送信したら、JavaScript からブラウザに制御を戻します。この時点で、ブラウザはコンテキストの現在のテクスチャが変更されていることを認識し、キャンバスを更新してそのテクスチャを画像として表示します。その後にさらにキャンバスのコンテンツを更新する場合は、再度 context.getCurrentTexture() を呼び出してレンダリング パスのためのテクスチャを新たに取得し、新しいコマンド バッファを記録して送信する必要があります。

  1. ページを再読み込みします。キャンバスが黒で塗りつぶされます。これで完了です。初めての WebGPU アプリを正しく作成することができました。

WebGPU を使用してキャンバスのコンテンツをクリアできたことを示す黒色のキャンバス。

色を選択する

黒の正方形を表示するだけでは、あまり面白くないでしょう。そこで次のセクションに進む前に、少しカスタマイズしてみましょう。

  1. device.beginRenderPass() の呼び出しで、以下のように clearValue を指定した行を colorAttachment に新たに追加します。

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
    view: context.getCurrentTexture().createView(),
    loadOp: "clear",
    clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
    storeOp: "store",
  }],
});

clearValue では、レンダリング パスに対して、パスの冒頭で clear 操作を行う際に使用する色を指定できます。ここで渡すディクショナリは、赤色の r、緑色の g、青色の b、アルファ(透明度)の a の 4 つの値で構成されています。それぞれの値は 0 から 1 の値を取ることができ、各カラーチャネルの値を示しています。次に例を示します。

  • { r: 1, g: 0, b: 0, a: 1 } は明るい赤色です。
  • { r: 1, g: 0, b: 1, a: 1 } は明るい紫色です。
  • { r: 0, g: 0.3, b: 0, a: 1 } は暗い緑色です。
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 } は中間の明るさの灰色です。
  • { r: 0, g: 0, b: 0, a: 0 } はデフォルトの透明な黒色です。

この Codelab のコード例およびスクリーンショットでは暗い青色ですが、自由に色を変えてみましょう。

  1. 色を選択したら、ページを再読み込みします。選択した色がキャンバスに表示されます。

clear 操作で使用するデフォルト色の変更方法を示すために暗い青色にクリアされたキャンバス。

4. ジオメトリを描画する

このセクションでは、アプリのキャンバスに色付きの正方形というシンプルなジオメトリを描画します。単に正方形を描画するだけにしては多くの作業が必要なように思われるかもしれませんが、これは、さまざまなジオメトリを非常に効率的にレンダリングできるように WebGPU が設計されているためです。効率性追求の副次的な作用として、比較的シンプルな処理を行うだけでも非常に複雑なコードの記述が必要となりますが、これは WebGPU のような API を利用するときには避けられない問題です。これは、本来はより複雑な処理を効率的に行えるよう考えられた API だからです。

GPU における描画の仕組みを理解する

コードの変更に取り掛かる前に、GPU が画面上の形状をどのように作成するかについて、簡単に説明します(GPU におけるレンダリングの基礎的な仕組みについて理解できている場合は、「頂点を定義する」のセクションまでスキップしてもかまいません)。

Canvas2D のように、あらかじめさまざまな形状やオプションが用意されている API とは異なり、GPU では点、線分、三角形という少数の種類の形状(WebGPU ではプリミティブと呼ばれます)のみを扱います。この Codelab では、三角形のみを使用します。

GPU では、ほぼ三角形のみを使用してレンダリングが行われますが、これは、一定のパターンに従って効率的に処理を行うために必要となる、多くの優れた数学的特性を三角形が持ち合わせているからです。GPU で何かを描画する場合、ほとんどのケースでまず複数の三角形に分割し、それらの三角形を頂点によって定義する必要があります。

これらの点(頂点)は X、Y、および Z(3D コンテンツの場合)の値で指定します。頂点は、WebGPU またはその他類似の API により定義されたデカルト座標系上の点として定義されます。座標系の構造は、ページ上のキャンバスに関連付けて考えるとわかりやすくなります。キャンバスの幅や高さにかかわらず、左端が常に X 軸の -1 となり、右端が常に X 軸の +1 となります。同様に、下端が常に Y 軸の -1 となり、上端が常に Y 軸の +1 となります。つまり、(0, 0) は常にキャンバスの中心に、(-1, -1) は常に左下隅に、(1, 1) は常に右上隅になります。これをクリップ空間と呼びます。

正規化されたデバイス座標空間を可視化したシンプルなグラフ。

頂点は、最初からこの座標系で定義されていることはほとんどないため、GPU では頂点シェーダーと呼ばれる小さなプログラムを利用して、頂点をクリップ空間に変換するために必要な数学的操作や、頂点の描画に必要なその他の計算を実行します。たとえば、シェーダーによってなんらかのアニメーション効果を適用したり、頂点から光源への方向を計算したりできます。このようなシェーダーは、WebGPU のデベロッパーが作成します。シェーダーを使用することで、GPU の動作を詳細に制御できます。

GPU は、シェーダーを適用した後、変換された頂点で構成された三角形を画面に描画するために必要なピクセルを決定します。その後、もうひとつの小さなプログラムであるフラグメント シェーダー(これもデベロッパーが作成します)が実行され、各ピクセルの色が計算されます。実行される計算は、「緑色を返す」のようなシンプルなものから、近隣の他のサーフェスから反射する日光とサーフェスの相対的な角度を計算し、霧を通したフィルタをかけて、サーフェスの金属的な光沢の度合いに応じて変更を行うといった複雑なものまでさまざまです。自由に計算内容を記述できるため、強力ですが難しさを感じさせる原因ともなっています。

その後、これらのピクセルの色の結果がテクスチャに蓄積され、画面上に表示されます。

頂点を定義する

すでに説明したように、ライフゲーム シミュレーションはセルのグリッドとして表示されます。そのためアプリでは、アクティブなセルと非アクティブなセルを区別しながら、グリッドを可視化する方法が必要となります。この Codelab では、アクティブなセルには色付きの正方形を描画し、非アクティブなセルは空のままにする方法を採用します。

つまり GPU に対して、正方形の四隅にあたる各点を指定する必要があります。たとえば、キャンバスの真ん中に、端から少し離した状態で正方形を描画した場合の角の座標は以下のようになります。

正方形の角の座標を示す、正規化されたデバイス座標のグラフ

これらの座標を GPU に指定するためには、値を TypedArray に格納する必要があります。TypedArray は JavaScript オブジェクトのグループで、これを使用すると連続するメモリのブロックを割り当てて、系列内の各要素を特定のデータ型として解釈できます。たとえば Uint8Array では、配列内の各要素が単一の符号なしバイトとなります。TypedArray は、WebAssembly、WebAudio、そしてもちろん WebGPU など、厳密なメモリ レイアウトを必要とする API との間でデータをやり取りする場合に便利です。

ここで扱う正方形の例では、値は小数値となるため、Float32Array が適しています。

  1. コードに以下の配列宣言を挿入して、図のすべての頂点の位置を保持する配列を作成します。これは、コードの最上部付近、context.configure() 呼び出しのすぐ下に挿入するとよいでしょう。

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8,
   0.8, -0.8,
   0.8,  0.8,
  -0.8,  0.8,
]);

スペースやコメントは、理解しやすく、見やすくするためのもので、値には影響しません。このようにスペースやコメントを挿入することで、各頂点の値のペアが X 座標と Y 座標で構成されていることがわかりやすくなります。

ただし、問題があります。先ほど GPU は三角形を基準として描画すると説明しました。そのため、頂点の座標は 3 つ一組で指定する必要があります。ただし、ここでは 4 つ一組となっています。この問題を解決するため、正方形を 2 つに分割して、対角線を辺として共有する 2 つの三角形を作成します。つまり、頂点のうち 2 つはこれらの三角形で重複することになります。

正方形の 4 つの頂点から 2 つの三角形を作成する方法を示す図。

図に示した正方形を作るには、頂点 (-0.8, -0.8) と (0.8, 0.8) を青い三角形で 1 回、赤い三角形で 1 回の合わせて 2 回指定する必要があります(他の 2 つの頂点で正方形を分割することもできます。どちらでもかまいません)。

  1. 先ほどの vertices 配列を以下のように更新します。

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8, // Triangle 1 (Blue)
   0.8, -0.8,
   0.8,  0.8,

  -0.8, -0.8, // Triangle 2 (Red)
   0.8,  0.8,
  -0.8,  0.8,
]);

図では、わかりやすくするために 2 つの三角形を隔てる線を示していますが、頂点の座標はまったく同じなので、GPU では隙間なくレンダリングされます。つまり、1 つの単色の正方形としてレンダリングされます。

頂点バッファを作成する

GPU は、JavaScript 配列のデータから頂点を描画することはできません。多くの場合、GPU にはレンダリングに高度に最適化された専用のメモリが用意されているため、描画時に GPU が使用するデータはそのメモリに配置する必要があります。

頂点データを含む多くの値では、GPU 側のメモリは GPUBuffer オブジェクトを介して管理されます。バッファは、GPU が容易にアクセスできるメモリのブロックで、特定の目的に応じたフラグが設定されています。バッファは GPU からアクセスできる TypedArray のようなものと考えるとよいでしょう。

  1. 頂点を保持するためのバッファを作成するには、vertices 配列の定義の後に、以下の device.createBuffer() の呼び出しを追加します。

index.html

const vertexBuffer = device.createBuffer({
  label: "Cell vertices",
  size: vertices.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

まず注目すべき点は、バッファにラベルを指定できることです(label)。作成するすべての WebGPU オブジェクトには、オプションでラベルを指定することができるので、ここでもその機能を利用します。ラベルには、オブジェクトを識別するための任意の文字列を指定できます。問題が発生した場合は、WebGPU が生成するエラー メッセージでこれらのラベルが使用されるので、問題の発生原因を突き止めることができます。

次に、バッファのサイズをバイト単位で指定します(size)。座標を表す数字は 32 ビットの浮動小数点値(4 バイト)で、vertices 配列には浮動小数点値が 12 個含まれているため、これらを乗算するとバッファには合計 48 バイトが必要になります。TypedArray では byteLength プロパティで配列のサイズを取得できるので、バッファ作成時にはこのプロパティを使用できます。

最後に、バッファの使用方法を指定します(usage)。使用方法は、1 つ以上の GPUBufferUsage フラグを組み合わせて指定します。複数のフラグを指定する場合は、|(ビット演算 OR)演算子を使って組み合わせます。ここでは、バッファを頂点データ(GPUBufferUsage.VERTEX)として使用するとともに、データのコピー先(GPUBufferUsage.COPY_DST)としても使用するというフラグを指定します。

返されるバッファ オブジェクトでは詳細が隠されているため、保持されているデータを確認するのは容易ではありません。さらに、ほとんどの属性は変更できないため、作成後に GPUBuffer のサイズを変更したり、使用方法のフラグを変更したりすることはできません。変更できるのは、メモリの内容のみです。

最初にバッファを作成した時点で、メモリはすべてゼロで初期化されます。内容を変更する方法はいくつかありますが、コピー元の TypedArray を指定して device.queue.writeBuffer() を呼び出す方法が最も簡単です。

  1. 頂点データをバッファのメモリにコピーするには、以下のコードを追加します。

index.html

device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);

頂点のレイアウトを定義する

頂点データを含むバッファを作成しましたが、GPU にとってこれはバイトの塊にすぎません。このデータを使用して描画を実行するには、もう少し情報を提供する必要があります。WebGPU に対して、頂点データの構造に関する詳細を指定する必要があります。

  • GPUVertexBufferLayout ディクショナリを使用して、頂点データの構造を定義します。

index.html

const vertexBufferLayout = {
  arrayStride: 8,
  attributes: [{
    format: "float32x2",
    offset: 0,
    shaderLocation: 0, // Position, see vertex shader
  }],
};

一見するとわかりにくそうに思われるかもしれませんが、一つひとつ切り分けると簡単です。

まず指定するのは arrayStride です。これは、GPU が次の頂点のデータを取得する際にバッファ内でスキップする必要があるバイト数を示します。正方形の各頂点は 2 つの 32 ビット浮動小数点値で構成されています。先ほども説明したように、32 ビット浮動小数点値は 4 バイトなので、2 つで 8 バイトとなります。

次は、attributes プロパティですが、これは配列です。このプロパティは、各頂点にエンコードされる個々の情報を表しています。この例では、頂点には 1 つの属性(頂点位置)しか含まれていませんが、より高度なユースケースでは、頂点の色やジオメトリのサーフェスが向いている方向など、頂点に複数の属性が含まれることも珍しくありません。ただし、これについてはこの Codelab の範囲外です。

ここでは 1 つだけ属性を定義しますが、まずデータの format を定義します。GPU が理解できる頂点データの各型を表す GPUVertexFormat リストからいずれかを指定します。ここでは、各頂点データに 32 ビットの浮動小数点値が 2 つ含まれているので、float32x2 の形式を使用します。たとえば各頂点データに 16 ビットの符号なし整数が 4 つ含まれている場合は、uint16x4 を使用します。どのように型を指定すればよいかご理解いただけたでしょうか。

次に offset で、この特定の属性の開始バイト位置を指定します。これは、バッファに複数の属性が含まれている場合にのみ関連しますので、この Codelab では使用しません。

最後は shaderLocation です。これは、0 から 15 の間の任意の数字で、定義する各属性で一意であることが必要です。これにより、この属性が頂点シェーダーの特定の入力にリンクされます。これについては次のセクションで学習します。

これらの値はここで定義しておきますが、実際にはまだ WebGPU API には渡していないことに注意してください。API に渡す処理についてはこれから説明しますが、頂点を定義する時点でこれらの値について検討したほうがわかりやすいため、後で使用できるようにここで設定しておきます。

シェーダーに取り掛かる

レンダリングするデータは準備できましたが、データをどのように処理するかを具体的に GPU に伝える必要があります。その大部分の処理を担うのがシェーダーです。

シェーダーとは、デベロッパーが作成し、GPU 上で実行される小さなプログラムです。各シェーダーは、頂点の処理、フラグメントの処理、汎用のコンピューティングなど、データの各ステージに対する処理を実行します。GPU 上で実行されるものなので、平均的な JavaScript よりも厳格に構造化されています。ただしこの構造のおかげで、非常に高速に、そしてこれが重要なポイントですが並列に実行することが可能となります。

WebGPU のシェーダーは、WebGPU シェーディング言語(WGSL)と呼ばれるシェーディング言語で記述します。WGSL の構文は Rust と少し似ていますが、GPU で一般的に行う作業(ベクトルや行列の計算など)を簡単かつ高速に実行できる機能が備わっています。シェーディング言語のすべてをこの Codelab で説明することはできませんが、いくつか簡単な例を紹介しますので、基本的なコンセプトを理解していただければと思います。

シェーダー自体は、文字列として WebGPU に渡されます。

  • コードの vertexBufferLayout の下に次のコードをコピーして、シェーダーのコードを入力する場所を設けます。

index.html

const cellShaderModule = device.createShaderModule({
  label: "Cell shader",
  code: `
    // Your shader code will go here
  `
});

シェーダーを作成するには、device.createShaderModule() を呼び出して、オプションの label と WGSL の code を文字列として指定します(ここでは、複数行の文字列を指定するためにバッククォートを使用しています)。この関数に有効な WGSL のコードを追加すると、コンパイル済みの結果を含む GPUShaderModule オブジェクトが返されます。

頂点シェーダーを定義する

GPU の処理は頂点から始まるので、最初に頂点シェーダーについて学習しましょう。

頂点シェーダーは関数として定義され、GPU では vertexBuffer 内の頂点ごとに 1 回この関数が呼び出されます。vertexBuffer には 6 つの位置(頂点)が含まれているので、定義する関数は 6 回呼び出されることになります。頂点シェーダー関数が呼び出されるたびに、vertexBuffer から異なる位置が引数として関数に渡され、クリップ空間内の対応する位置が返されます。

ただし、これらは順番に呼び出されるわけではない点に注意してください。GPU はこのようなシェーダーを並列的に実行することに長けているので、性能によっては数百から数千もの頂点を同時に処理できます。GPU の驚異的な速度はこのような並列性によるところが大きいのですが、それに伴う制約もあります。並列性を極限まで高めるため、頂点シェーダーどうしでのやり取りはできません。各シェーダーの呼び出しでは、一度に 1 つの頂点のデータのみを参照でき、1 つの頂点の値のみを出力できます。

WGSL では、頂点シェーダー関数に任意の名前を付けることができますが、どのステージのシェーダーを表しているかを示すため、先頭に @vertex 属性を指定する必要があります。WGSL では fn キーワードによって関数が示され、かっこの中に引数を宣言し、中かっこでスコープを定義します。

  1. 以下のように、空の @vertex 関数を作成します。

index.html(createShaderModule のコード)

@vertex
fn vertexMain() {

}

ただし、頂点シェーダーでは、少なくともクリップ空間で処理される頂点の最終的な位置を返す必要があるため、これは関数として有効ではありません。位置は、常に 4 次元ベクトルとして指定します。ベクトルはシェーダーで非常によく使われるため、シェーダー言語ではファースト クラスのプリミティブとして扱われており、4 次元ベクトル用の vec4f などの独自の型が用意されています。また、2 次元ベクトル(vec2f)や 3 次元ベクトル(vec3f)の型も用意されています。

  1. 返される値が必須の位置であることを示すには、@builtin(position) 属性でマークします。-> 記号は、関数の戻り値であることを示すために使用されます。

index.html(createShaderModule のコード)

@vertex
fn vertexMain() -> @builtin(position) vec4f {

}

関数に戻り値の型がある場合は、当然ですが、関数本体で実際に値を返さなければなりません。構文 vec4f(x, y, z, w) を使用して、戻り値として返す新しい vec4f を作成できます。x、y、z の値はすべて、戻り値においてクリップ空間内での頂点の位置を示す浮動小数点値です。

  1. 静的な値 (0, 0, 0, 1) を返せば、一応は有効な頂点シェーダーが出来上がります。ただし、これでは GPU は生成される三角形を単なる点と認識し、破棄してしまうので、実際には何も描画されません。

index.html(createShaderModule のコード)

@vertex
fn vertexMain() -> @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}

そこで静的な値を返すのではなく、作成したバッファのデータを利用してみましょう。そのためには、関数で @location() 属性を使用して引数を宣言し、vertexBufferLayout で記述したものに一致する型を指定します。shaderLocation に 0 を指定したので、WGSL のコードでは引数を @location(0) とマークします。また、頂点データの形式を float32x2 と定義したので、WGSL の引数では 2 次元ベクトル vec2f を指定します。引数には任意の名前を付けることができますが、頂点の位置を表すものなので pos などにするとわかりやすいでしょう。

  1. シェーダー関数を以下のコードのように変更します。

index.html(createShaderModule のコード)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1);
}

次に位置を返す必要があります。位置は 2 次元ベクトルで、戻り値の型は 4 次元ベクトルなので、少し変形する必要があります。位置の引数の 2 つの要素を戻り値のベクトルの最初の 2 つの要素として使用し、最後の 2 つの要素はそれぞれ 0 および 1 とします。

  1. 使用する位置の要素を明示的に指定して、正しい位置を返します。

index.html(createShaderModule のコード)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos.x, pos.y, 0, 1);
}

ただし、このようなマッピングはシェーダーでは非常に一般的なものであるため、簡略化して単に位置のベクトルを 1 つ目の引数として渡すことができます。これでも同じ処理が実行されます。

  1. return ステートメントを以下のコードに書き直します。

index.html(createShaderModule のコード)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos, 0, 1);
}

これで最初の頂点シェーダーの完成です。非常にシンプルで、実質的に位置を変更することなく返すだけですが、シェーダーについて理解するためには十分です。

フラグメント シェーダーを定義する

次はフラグメント シェーダーです。フラグメント シェーダーも頂点シェーダーと同じように動作しますが、各頂点に対して呼び出されるのではなく、描画される各ピクセルに対して呼び出されます。

フラグメント シェーダーは、常に頂点シェーダーの後に呼び出されます。GPU は、頂点シェーダーの出力から三角形を作成します。つまり、3 つの点をもとに三角形を作ります。その後、出力されるカラー アタッチメントのどのピクセルがその三角形に含まれるかを計算し、それらの各三角形をラスタライズして、各ピクセルにつき 1 回ずつフラグメント シェーダーを呼び出します。フラグメント シェーダーは色を返します。色は通常、頂点シェーダーやテクスチャなどのアセットから送られる値に基づいて計算されます。この色が GPU によってカラー アタッチメントに書き込まれます。

頂点シェーダーと同様に、フラグメント シェーダーは高度に並列化して実行されます。入出力に関しては頂点シェーダーよりも若干柔軟性がありますが、各三角形の各ピクセルに対して 1 つの色を返すものと考えることができます。

WGSL のフラグメント シェーダー関数は @fragment 属性で示され、この関数も vec4f を返します。ただし、このベクトルは位置ではなく色を表します。返された色が beginRenderPass 呼び出しのどの colorAttachment に書き込まれるかを示すため、戻り値には @location 属性を指定する必要があります。ここではアタッチメントは 1 つだけなので、location の値は 0 です。

  1. 以下のように、空の @fragment 関数を作成します。

index.html(createShaderModule のコード)

@fragment
fn fragmentMain() -> @location(0) vec4f {

}

返されるベクトルの 4 つの要素は、それぞれ赤、緑、青、アルファの色の値ですが、先ほど beginRenderPass で設定した clearValue と同様に解釈されます。そのため、vec4f(1, 0, 0, 1) は明るい赤色となります。ここで描画する正方形にはちょうどよい色でしょう。ただしこれは、好きな色に設定できます。

  1. 返される色のベクトルを以下のように設定します。

index.html(createShaderModule のコード)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}

これでフラグメント シェーダーの完成です。あまり興味深い処理は行っていませんが、これですべての三角形のすべてのピクセルを赤色に設定できました。今回はこれで十分です。

まとめになりますが、ここまで詳しく説明してきたシェーダーのコードを追加すると、createShaderModule の呼び出しは以下のようになります。

index.html

const cellShaderModule = device.createShaderModule({
  label: 'Cell shader',
  code: `
    @vertex
    fn vertexMain(@location(0) pos: vec2f) ->
      @builtin(position) vec4f {
      return vec4f(pos, 0, 1);
    }

    @fragment
    fn fragmentMain() -> @location(0) vec4f {
      return vec4f(1, 0, 0, 1);
    }
  `
});

レンダリング パイプラインを作成する

シェーダー モジュールは、単独でレンダリングに使用することはできません。device.createRenderPipeline() を呼び出して作成する GPURenderPipeline の一部として使用することで、初めてレンダリングを行うことができます。レンダリング パイプラインでは、使用するシェーダー、頂点バッファ内のデータの解釈方法、レンダリングするジオメトリの種類(線分、点、三角形)など、ジオメトリをどのように描画するかを制御します。

レンダリング パイプラインは API 全体の中でも最も複雑なオブジェクトですが、渡すことのできる値はほとんどが省略可能で、必ず指定しなければならない値はわずかです。

  • 以下のように、レンダリング パイプラインを作成します。

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: "auto",
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

すべてのパイプラインには、(頂点バッファ以外に)どのような種類の入力がパイプラインで必要かを示す layout が必要ですが、ここでは特に必要なものはありません。今回は "auto" を渡すことができます。これによりパイプラインでは、シェーダーから自動的にレイアウトが作成されます。

次に、vertex ステージの詳細を指定する必要があります。module は頂点シェーダーを含む GPUShaderModule で、entryPoint はすべての頂点に対して呼び出される頂点シェーダーのコード内の関数名です(1 つのシェーダー モジュール内に複数の @vertex および @fragment 関数を記述できます)。buffers は、このパイプラインで使用する頂点バッファに格納されているデータについて記述する GPUVertexBufferLayout オブジェクトの配列です。幸いバッファのレイアウトは、vertexBufferLayout ですでに定義しています。ここでは、そのレイアウトを指定します。

最後に、fragment ステージの詳細を指定します。ここでも、頂点のステージと同じくシェーダーの module と entryPoint を指定します。最後の部分では、このパイプラインで使用する targets を定義します。これは、パイプラインで出力するカラー アタッチメントの詳細(テクスチャの format など)を指定するディクショナリの配列です。これらの詳細は、このパイプラインで使用するレンダリング パスの colorAttachments で指定するテクスチャと一致している必要があります。この Codelab のレンダリング パスでは、キャンバスのコンテキストのテクスチャが使用され、形式として canvasFormat に保存した値が使用されるため、ここにも同じ形式を渡します。

レンダリング パイプラインの作成時にはこの他にもさまざまなオプションを指定できますが、この Codelab ではここまでで十分です。

正方形を描画する

これで、正方形を描画するために必要なすべての要素が揃いました。

  1. 正方形を描画するには、encoder.beginRenderPass() と pass.end() の呼び出しまで戻り、その間に以下の新しいコマンドを追加します。

index.html

// After encoder.beginRenderPass()

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices

// before pass.end()

これにより、WebGPU が正方形を描画するために必要なすべての情報が提供されます。まず setPipeline() を使用して、描画に使用するパイプラインを指定します。これには、使用するシェーダー、頂点データのレイアウト、その他関連する状態データが含まれます。

次に、正方形の頂点を含むバッファを指定して、setVertexBuffer() を呼び出します。ここでは、このバッファは現在のパイプラインの vertex.buffers 定義の 0 番目の要素に相当するため、0 を指定して呼び出します。

最後に draw() を呼び出します。これまでさまざまな設定を行ってきたので、驚くほど簡単に思えるかもしれません。渡すのは、レンダリングの必要がある頂点の数だけです。頂点は、現在設定されている頂点バッファから取得され、現在設定されているパイプラインに従って解釈されます。6 にハードコードしてもかまいませんが、頂点の配列を使用して、浮動小数点値の数 12 を頂点あたりの座標の数 2 で除算して 6 個の頂点と計算することで、たとえば正方形ではなく円を描画することにした場合などに手作業で更新する箇所を減らすことができます。

  1. 画面を更新すれば、ここまでの多くの作業が実を結び、大きな色付きの正方形が 1 つ描画されます。

WebGPU でレンダリングされた 1 つの赤い正方形

5. グリッドを描画する

ここまで学習を進められたことは大きな進歩です。ほとんどの GPU API では、往往にして、初めてのジオメトリを画面上にレンダリングすることが最も難しいステップだからです。ここからは、進捗を確かめながら少しずつ着実に進めましょう。

このセクションでは、次の内容について学習します。

  • JavaScript からシェーダーにユニフォームと呼ばれる変数を渡す方法
  • ユニフォームを使用してレンダリングの動作を変更する方法
  • インスタンス化を使用して、同じジオメトリの数多くのバリエーションを描画する方法

グリッドを定義する

グリッドをレンダリングするためには、グリッドに関する非常に基本的な情報を把握しておく必要があります。それは、縦方向と横方向にそれぞれいくつのセルが存在するかという情報です。セルの数はデベロッパーが自由に決めることができますが、簡単にするためにグリッドを正方形とし(縦方向と横方向のセルの数が同じ)、2 のべき乗のサイズにします(これにより後で計算が楽になります)。最終的にはさらに大きなサイズにするのですが、このセクションでは、計算をわかりやすくするため、まずはグリッドのサイズを 4x4 に設定します。後で、サイズを大きくしてみましょう。

  • JavaScript コードの先頭に定数を追加して、グリッドのサイズを定義します。

index.html

const GRID_SIZE = 4;

次に、キャンバスに GRID_SIZE x GRID_SIZE 個の正方形をレンダリングできるように、正方形のレンダリング方法を更新します。つまり、はるかに小さなサイズの正方形を数多くレンダリングする必要があります。

そのための 1 つの方法として、頂点バッファを大幅に大きくして、そこに適切なサイズと位置の GRID_SIZE x GRID_SIZE 個の正方形を定義する方法が挙げられます。このコードはそれほど難しくありません。いくつかのループと、計算を少し追加するだけです。ただし、これでは GPU を効果的に利用できず、目的とする効果を実現するために必要以上に多くのメモリを使用してしまいます。このセクションでは、GPU をより有効に活用できる方法について説明します。

ユニフォーム バッファを作成する

シェーダーは、グリッドのサイズに応じて表示内容を変更するため、まずは選択したグリッドのサイズをシェーダーに伝える必要があります。シェーダーにサイズをハードコードすることもできますが、この場合、グリッドのサイズを変更するたびにシェーダーおよびレンダリング パイプラインの再作成が必要となり、コストがかかります。ハードコードよりもスマートな方法として、グリッドのサイズをユニフォームとしてシェーダーに提供する方法が挙げられます。

先ほど、頂点シェーダーが呼び出されるたびに、頂点バッファから異なる値が渡されることを説明しました。ユニフォームを使用すると、すべての呼び出しでユニフォーム バッファから同じ値を渡すことができます。ユニフォームは、ジオメトリで共通する値(位置など)、アニメーションのフレーム全体で共通する値(現在の時刻など)、あるいはアプリの存続期間全体で共通する値(ユーザーの設定など)といった、共通する値を伝えるのに便利です。

  • 以下のコードを追加して、ユニフォーム バッファを作成します。

index.html

// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
  label: "Grid Uniforms",
  size: uniformArray.byteLength,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);

先ほど頂点バッファを作成したときのコードと非常によく似ているので、見覚えがあるでしょう。これは、ユニフォームは頂点と同じ GPUBuffer オブジェクトを介して WebGPU API に渡されるためです。usage の値が今回は GPUBufferUsage.VERTEX ではなく GPUBufferUsage.UNIFORM となっている点が主な違いです。

シェーダーでユニフォームにアクセスする

  • 以下のコードを追加して、ユニフォームを定義します。

index.html(createShaderModule の呼び出し)

// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos / grid, 0, 1);
}

// ...fragmentMain is unchanged 

これにより、シェーダーに grid という名前のユニフォームが定義されます。これは、浮動小数点値の 2 次元ベクトルで、ユニフォーム バッファにコピーした配列に対応します。また、ユニフォームが @group(0) および @binding(0) でバインドされることが指定されています。これらの値の意味については後ほど説明します。

ユニフォーム grid を定義したので、シェーダー コード内のどこでも、自由にベクトル grid を使用できます。このコードでは、頂点の位置をベクトル grid で除算しています。pos は 2 次元ベクトルで、grid も 2 次元ベクトルなので、WGSL では要素ごとに除算が行われます。つまり、結果は vec2f(pos.x / grid.x, pos.y / grid.y) と同じになります。

多くのレンダリングおよびコンピューティング手法で使用されるため、GPU シェーダーではこのようなベクトルの操作方法が非常に一般的になっています。

この場合、(グリッドサイズとして 4 を使用したとすると)、レンダリングされる正方形のサイズは元のサイズの 4 分の 1 となります。縦横 4 つずつ正方形をレンダリングしたいので、これで理想的なサイズが得られます。

バインド グループを作成する

シェーダーでユニフォームを宣言しましたが、それだけでは作成したバッファとは接続されません。接続するためには、バインド グループを作成して、設定する必要があります。

バインド グループとは、シェーダーにも同時にアクセスできるようにするリソースのコレクションです。ユニフォーム バッファなど、いくつかの種類のバッファのほか、ここでは説明しませんが、WebGPU のレンダリング手法においては一般的なテクスチャやサンプラーなどのその他のリソースを含めることができます。

  • ユニフォーム バッファとレンダリング パイプラインを作成する部分の後に以下のコードを追加して、ユニフォーム バッファを含むバインド グループを作成します。

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  }],
});

先ほどから使用している label に加え、このバインド グループに含まれるリソースのタイプを示す layout が必要です。これについては、後のステップで詳しく説明しますが、ここでは layout: "auto" を指定してパイプラインを作成しているため、パイプラインからバインド グループのレイアウトを取得できます。auto を指定すると、シェーダーのコード自体で宣言したバインディングから、バインド グループのレイアウトがパイプラインによって自動的に作成されます。ここでは、パイプラインに対して getBindGroupLayout(0) と命令していますが、0 はシェーダーで入力した @group(0) に対応しています。

レイアウトを指定したら、entries の配列を指定します。各エントリは、少なくとも以下の値を持つディクショナリとなっています。

  • binding: シェーダーで入力した @binding() の値に対応します。この例では 0 です。
  • resource: 指定したバインディング インデックスの変数に公開する実際のリソースです。この例では、ユニフォーム バッファです。

この関数は、詳細が隠された変更不可のハンドルである GPUBindGroup を返します。バインド グループがポイントするリソースを作成後に変更することはできませんが、これらのリソースの内容は変更できます。たとえば、ユニフォーム バッファを変更して新しいグリッドサイズを格納すると、変更後は、このバインド グループを使用する描画呼び出しで、その変更内容が反映されます。

バインド グループをバインドする

バインド グループを作成したら、描画時にそのバインド グループを使用するよう WebGPU に伝える必要があります。この操作は非常に簡単です。

  1. レンダリング パスに戻って、draw() メソッドの前に以下の新しい行を追加します。

index.html

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);

pass.setBindGroup(0, bindGroup); // New line!

pass.draw(vertices.length / 2);

1 つ目の引数として渡される 0 は、シェーダーのコードの @group(0) に対応しています。ここでは、@group(0) に属する各 @binding で、このバインド グループのリソースを使用すると指定しています。

これで、ユニフォーム バッファがシェーダーに公開されました。

  1. ページを更新すると、以下のように表示されます。

暗い青色の背景の中央に描画された小さな赤い正方形。

今回は、正方形のサイズが以前の 4 分の 1 になりました。劇的な変化ではありませんが、ユニフォームが実際に適用され、シェーダーからグリッドのサイズにアクセスできることがわかりました。

シェーダーでジオメトリを操作する

シェーダーからグリッドのサイズを参照できるようになったので、目的とするグリッドのパターンに合わせて、レンダリングするジオメトリを操作できます。そのためには、具体的に何を行いたいかを明確にします。

まず、キャンバスを概念的に個々のセルに分割する必要があります。右に行くほど X 軸の座標の値が大きくなり、上に行くほど Y 軸の座標の値が大きくなるという規則を維持するため、キャンバスの左下隅のセルを 1 つ目のセルとします。すると以下のようなレイアウトとなります。現在の正方形のジオメトリは中央に表示されています。

概念的なグリッドを示すため、正規化されたデバイス座標空間を分割して各セルを可視化し、中央には現在レンダリングされている正方形のジオメトリを表示

ここでは、セルの座標が与えられたときに、シェーダーでそのセルに正方形のジオメトリを配置できる方法を見つける必要があります。

まず一目見てわかるのは、正方形がキャンバスの中央に配置されるよう定義されていないため、どのセルにもぴったりと収まる配置になっていないことです。そこで正方形をセルのサイズの半分だけ移動して、セルにぴったり収まるようにする必要があります。

その方法のひとつとして、正方形の頂点バッファを更新する方法が挙げられます。たとえば、左下隅が (-0.8, -0.8) ではなく (0.1, 0.1) となるように頂点を移動すると、この正方形をセルの境界内にぴったり収めることができます。ただし、シェーダーで頂点を自由に処理できるようになったので、シェーダーのコードを使用して適切な場所に移動すると簡単です。

  1. 頂点シェーダー モジュールを以下のコードに変更します。

index.html(createShaderModule の呼び出し)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Add 1 to the position before dividing by the grid size.
  let gridPos = (pos + 1) / grid;

  return vec4f(gridPos, 0, 1);
}

これにより、すべての頂点が(クリップ空間のサイズの半分にあたる)1 ずつ上と右に移動されてから、グリッドサイズで除算されます。その結果、原点から少し離れた場所に、グリッドにぴったり収まるように正方形がレンダリングされました。

キャンバスを概念的に 4x4 のグリッドに分割して可視化した様子。セル (2, 2) に赤い正方形が表示されている

次に、キャンバスの座標系では中央が (0, 0) で、左下が (-1, -1) となっていますが、左下を (0, 0) としたいので、グリッドサイズで除算した後、ジオメトリの位置を (-1, -1) だけ平行移動し、左下に配置する必要があります。

  1. ジオメトリの位置を以下のように平行移動します。

index.html(createShaderModule の呼び出し)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Subtract 1 after dividing by the grid size.
  let gridPos = (pos + 1) / grid - 1;

  return vec4f(gridPos, 0, 1); 
}

これで、正方形がセル (0, 0) にぴったり収まるように配置されました。

キャンバスを概念的に 4x4 のグリッドに分割して可視化した様子。セル (0, 0) に赤い正方形が表示されている

では正方形を別のセルに配置したい場合はどうすればよいでしょうか。たとえば、シェーダーで cell ベクトルを宣言し、そこに静的な値 let cell = vec2f(1, 1) を代入するとします。

これを gridPos に加算しても、アルゴリズムの - 1 が元に戻るだけで、期待する効果は得られません。この場合、各セルに対して、グリッドの 1 単位(キャンバスの 4 分の 1)だけ正方形を動かす必要があります。そこで、もう一度 grid で除算する必要があります。

  1. グリッドの位置決めを以下のように変更します。

index.html(createShaderModule の呼び出し)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1); // Cell(1,1) in the image above
  let cellOffset = cell / grid; // Compute the offset to cell
  let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!

  return vec4f(gridPos, 0, 1);
}

更新すると、以下のように表示されます。

キャンバスを概念的に 4x4 のグリッドに分割して可視化した様子。セル (0, 0)、セル (0, 1)、セル (1, 0)、セル (1, 1) の中央に赤い正方形が表示されている

これは思ったようにレンダリングされませんでした。

その原因は、キャンバスの座標が -1 から 1 の 2 単位にわたっていることにあります。つまり、キャンバスの 4 分の 1 だけ頂点を移動する場合、0.5 単位の移動が必要になります。GPU の座標について考えるときによくあるミスですので注意しましょう。修正は簡単です。

  1. 以下のように、オフセットを 2 倍にします。

index.html(createShaderModule の呼び出し)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

これで思ったとおりにレンダリングされました。

キャンバスを概念的に 4x4 のグリッドに分割して可視化した様子。セル (1, 1) に赤い正方形が表示されている

スクリーンショットは以下のようになります。

暗い青色の背景の上に赤い正方形が描画されたスクリーンショット。前の図と同じ位置に、グリッドのオーバーレイなしで描画されている赤い正方形。

さらに、cell をグリッド境界内の任意の値に設定して更新すると、その場所に正方形がレンダリングされます。

インスタンスを描画する

少し計算するだけで目的の位置に正方形を配置できるようになりました。次に、グリッドの各セルに正方形を 1 つずつレンダリングしてみましょう。

ユニフォーム バッファにセルの座標を書き込み、グリッド内の各正方形に対して draw を一度ずつ呼び出して、毎回ユニフォームを更新するのも 1 つの方法です。しかし、この方法では、JavaScript によって新しい座標が書き込まれるのを GPU が毎回待機する必要があるため、非常に時間がかかります。GPU に高いパフォーマンスを発揮させる鍵のひとつは、システムの他の部分の処理を待つ時間を最小限に抑えることです。

それ以外の方法として、インスタンス化と呼ばれる手法を使用することもできます。インスタンス化を使用すると、draw を 1 回呼び出すだけで、同じジオメトリの複数のコピーを描画するように GPU に対して指示できるので、すべてのコピーに対して毎回 draw を呼び出すよりもはるかに高速です。ジオメトリの各コピーをインスタンスと呼びます。

  1. GPU に対して、グリッドを埋めるのに十分な数の正方形のインスタンスを描画するよう指示するには、既存の描画呼び出しに引数を 1 つ追加します。

index.html

pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

これにより、システムに対して、正方形の 6 個(vertices.length / 2)の頂点を 16 回(GRID_SIZE * GRID_SIZE)描画するよう指示できます。ただし、ページを更新しても、以前と同じく以下のように表示されます。

これまでと変わらない、前の図と同じ画像。

その理由は、16 個すべての正方形が同じ場所に描画されてしまうためです。そこで、インスタンスごとにジオメトリの位置を再配置するロジックをシェーダーに追加する必要があります。

シェーダーでは、頂点バッファから取得される pos のような頂点属性に加えて、WGSL の組み込み値と呼ばれるものにアクセスできます。これらは WebGPU によって自動的に計算される値であり、そのひとつが instance_index です。instance_index は 0 から number of instances - 1 の範囲の符号なし 32 ビット整数で、シェーダーのロジックで使用できます。この値は、同じインスタンスとして処理されるすべての頂点で同じとなります。つまり、頂点バッファの各位置について、instance_index の値が 0 に設定された状態で頂点シェーダーが 6 回呼び出されます。次に instance_index の値が 1 に設定された状態で 6 回、さらに instance_index が 2 に設定された状態で 6 回というように呼び出されます。

実際の動作を確認するには、instance_index ビルトインをシェーダーの入力に追加する必要があります。位置と同じように追加しますが、@location 属性の代わりに @builtin(instance_index) を使用し、この引数に任意の名前を指定します(コード例と同じく instance という名前を付けてもかまいません)。これをシェーダー ロジックの一部として使用します。

  1. セル座標の代わりに instance を使用します。

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {
  
  let i = f32(instance); // Save the instance_index as a float
  let cell = vec2f(i, i);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

更新すると、複数の正方形が描画されました。しかし、16 個すべては描画されていません。

暗い青色の背景の上に左下隅から右上隅まで対角線上に並んだ、4 つの赤い正方形。

これは、生成したセルの座標が (0, 0)、(1, 1)、(2, 2)...(15, 15) であり、最初の 4 つしかキャンバス上に収まらなかったためです。希望どおりにグリッドを描画するには、各インデックスがグリッド内のそれぞれのセルに対応するよう、以下のように instance_index を変換する必要があります。

キャンバスを概念的に 4x4 のグリッドに分割して可視化した様子。各セルが 1 次元のインスタンス インデックスに対応している。

この計算は比較的簡単です。各セルの X 軸の値は、instance_index とグリッドの幅の剰余とします。剰余は WGSL の % 演算子で計算できます。各セルの Y 軸の値は、instance_index をグリッドの幅で除算し、小数点以下を切り捨てた値とします。これは WGSL の floor() 関数で計算できます。

  1. 以下のように計算を変更します。

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {

  let i = f32(instance);
  // Compute the cell coordinate from the instance_index
  let cell = vec2f(i % grid.x, floor(i / grid.x));

  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

コードを更新すると、ついに望みどおりに正方形のグリッドが描画されました。

暗い青色の背景の上に表示された 4 行 4 列の赤い正方形。

  1. うまく動作させることができたので、グリッドのサイズを大きくしてみましょう。

index.html

const GRID_SIZE = 32;

暗い青色の背景の上に表示された 32 行 32 列の赤い正方形。

成功です。グリッドのサイズをかなり大きくしても、平均的な GPU で問題なくレンダリングできます。一つひとつの正方形を目に見えないくらい小さくしても、GPU は高いパフォーマンスで滞りなくレンダリングを行えます。

6. 追加の実習: 色を付ける

この先の Codelab を進めるために必要な基礎は、ここまでの学習で固めることができました。そのため、このセクションは省略して、次のセクションまでスキップしてもかまいません。ただし、同じ色の正方形のグリッドを描画するだけでは、機能は満たしているものの、あまり面白くないと感じている方もいることでしょう。そこで、もっと見栄えの良い明るい色に変えたいと思います。これは、シェーダーのコードで簡単な計算を行うだけで実現できます。

シェーダーで構造体を使用する

これまでは、頂点シェーダーから変換済みの位置のデータのみが返されていました。ただし実際には、頂点シェーダーからはさまざまなデータを返すことができ、それをフラグメント シェーダーで使用できます。

頂点シェーダーからデータを受け取るためには、戻り値として返す必要があります。頂点シェーダーでは、必ず位置を返す必要があるため、位置以外のデータも返す場合は、構造体に格納する必要があります。WGSL における構造体は、名前付きオブジェクト型で、1 つ以上の名前付きプロパティが含まれます。プロパティは、@builtin や @location などの属性でマークアップすることもできます。任意の関数の外で構造体を宣言し、必要に応じて関数との間でそれらの構造体のインスタンスを入出力できます。たとえば、現在の頂点シェーダーは以下のようになっています。

index.html(createShaderModule の呼び出し)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> 
  @builtin(position) vec4f {

  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;
  
  return  vec4f(gridPos, 0, 1);
}
  • 関数の入力と出力に構造体を使用して、これと同じことを表現してみましょう。

index.html(createShaderModule の呼び出し)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
};

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
  
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  return output;
}

入力の位置とインスタンス インデックスを参照するためには、先頭に input を付ける必要があります。また、戻り値として返す構造体は、変数として宣言してから、個別のプロパティを設定する必要があります。これだけでは実現できる機能に変わりはなく、むしろシェーダー関数が長くなるだけですが、シェーダーが複雑になった場合は、構造体を使用することでデータをわかりやすく整理できます。

頂点とフラグメントの関数の間でデータを受け渡す

ここまで、@fragment 関数は非常にシンプルなものでした。

index.html(createShaderModule の呼び出し)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1);
}

入力はなく、戻り値として返す出力は単色(赤)です。しかし、色付けするジオメトリに関する詳しい情報をシェーダーで利用できれば、その追加のデータを使用してもっと面白い処理ができそうです。たとえば、それぞれの正方形の色をセルの座標に基づいて変えることができます。@vertex ステージではどのセルをレンダリングするかがわかっているので、後はそのデータを @fragment ステージに渡すだけです。

頂点とフラグメントのステージ間でデータを受け渡すには、任意の @location を使用して、頂点シェーダーの出力構造体にそのデータを含める必要があります。ここではセルの座標を渡したいので、それを先ほどの VertexOutput 構造体に追加し、@vertex 関数で設定して、戻り値として返します。

  1. 頂点シェーダーの戻り値を以下のように変更します。

index.html(createShaderModule の呼び出し)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
  @location(0) cell: vec2f, // New line!
};

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
  
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell; // New line!
  return output;
}
  1. @fragment 関数で、同じ @location を使用して引数を追加し、値を受け取ります(名前が一致している必要はありませんが、同じ名前にしておくとロジックを追跡しやすくなります)。

index.html(createShaderModule の呼び出し)

@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
  // Remember, fragment return values are (Red, Green, Blue, Alpha)
  // and since cell is a 2D vector, this is equivalent to:
  // (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
  return vec4f(cell, 0, 1);
}
  1. 代わりに構造体を使用することもできます。

index.html(createShaderModule の呼び出し)

struct FragInput {
  @location(0) cell: vec2f,
};

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. さらに、このコードでは、これらの関数はいずれも同じシェーダー モジュールで定義されているので、@vertex ステージの出力構造体をそのまま再利用することもできます。このようにすると、名前と location の値が自然に一致するので、値を渡しやすくなります。

index.html(createShaderModule の呼び出し)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}

どのパターンを使用した場合でも、@fragment 関数でセル番号にアクセスして、番号に基づいて色を設定できます。上記のいずれのコードを使用しても、出力は以下のようになります。

左端の列が緑色、下端の行が赤色、その他すべての正方形は黄色の、正方形のグリッド。

色は付きましたが、見た目がよくありません。なぜ左端の列と下端の行のみ色が違うのでしょう。それは、@fragment 関数から返される色の値は、それぞれのチャネルが 0~1 の範囲であることが必要で、この範囲外の値は範囲内に収まるように切り詰められるためです。しかしセルの値は、それぞれの軸で 0~32 の範囲です。そのため、最初の行および列で赤または緑のカラーチャネルの上限である 1 に達してしまい、それ以降のすべてのセルは同じ値に切り詰められてしまうのです。

もっと滑らかに色が移り変わるようにするためには、各カラーチャネルに対して小数値を返す必要があります。各軸が 0 から始まって 1 で終わるようにできるとよいでしょう。そのためには、再度 grid で除算する必要があります。

  1. フラグメント シェーダーを以下のように変更します。

index.html(createShaderModule の呼び出し)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell/grid, 0, 1);
}

ページを更新すると、新しいコードではグリッド全体にわたりもっときれいなグラデーション カラーが描画されます。

黒から、赤、緑、黄へと四隅にわたって色が移り変わっている正方形のグリッド。

確かに滑らかにはなりましたが、左下隅は暗くてあまりきれいではありません。グリッドが黒くなってしまっています。ライフゲームのシミュレーションを始めた場合、グリッドに見づらい部分があるとゲームの進行がわかりづらくなります。もう少し明るくしてみましょう。

幸い、青色のチャネルはまったく使用していませんので、このチャネルを使用します。他の色が最も暗くなる場所で青色を最も明るくし、他の色の強度が高くなるにつれて青色が暗くなるようにできると理想的です。青色のチャネルを、1 からセルの他のいずれかのカラーチャネルの値を減算した値とするのが最も簡単です。c.x または c.y のいずれを減算してもかまいません。両方試して、好みの方を選びましょう。

  1. フラグメント シェーダーに、以下のようにより明るい色を追加します。

createShaderModule の呼び出し

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  let c = input.cell / grid;
  return vec4f(c, 1-c.x, 1);
}

見栄えが非常に良くなりました。

四隅が赤、緑、青、黄と移り変わる正方形のグリッド。

これは絶対に必要なステップというわけではありません。しかし、見栄えを良くするため、対応するチェックポイントのソースファイルにはこのコードが含まれており、この Codelab の以降のスクリーンショットにも、このさらにカラフルになったグリッドが反映されています。

7. セルの状態を管理する

次に、GPU に保存されたなんらかの状態に基づいて、グリッド上のどのセルをレンダリングするかを制御する必要があります。これは、最終的なシミュレーションにとって重要なステップです。

各セルに対するオン / オフのシグナルがあればよいので、任意の型の大きな配列を保存できれば、どのような方法でもかまいません。ユニフォーム バッファを利用できると考える方もいるでしょう。ユニフォーム バッファを使うことは可能ですが、ユニフォーム バッファはサイズに制限があり、動的サイズの配列をサポートしておらず(シェーダーで配列サイズを指定する必要があります)、コンピューティング シェーダーでは書き込むことができないため、あまり良い方法とはいえません。そしてライフゲームのシミュレーションは、GPU のコンピューティング シェーダーで行うため、最後の点が最も問題となります。

幸いなことに、これらすべての制限を回避できる、別のバッファが用意されています。

ストレージ バッファを作成する

ストレージ バッファは、コンピューティング シェーダーで読み書きでき、頂点シェーダーで読み取ることのできる、汎用バッファです。非常に大きなサイズにすることができるほか、シェーダーで特定のサイズを宣言する必要がないため、一般的なメモリのように利用できます。このストレージ バッファを使用してセルの状態を保存します。

  1. セルの状態を保存するストレージ バッファを作成するため、すでにおなじみだと思いますが、以下に示すバッファ作成のコード スニペットを使用します。

index.html

// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);

// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
  label: "Cell State",
  size: cellStateArray.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});

頂点バッファやユニフォーム バッファと同様に、適切なサイズを指定して device.createBuffer() を呼び出しますが、今回は使用方法として GPUBufferUsage.STORAGE を指定します。

これまでと同様、同じサイズの TypedArray に値を代入して、device.queue.writeBuffer() を呼び出すことで、バッファにデータを入力します。このバッファのグリッドに対する効果を確認したいので、まずはわかりやすい値を入力します。

  1. 以下のコードを使用して、3 つごとにセルを有効にします。

index.html

// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);

シェーダーでストレージ バッファを読み取る

次に、シェーダーを更新して、ストレージ バッファの内容を読み取ってから、グリッドにレンダリングを行います。この処理は、以前にユニフォームを追加したときと非常に似ています。

  1. シェーダーを以下のコードのように更新します。

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!

まず、grid ユニフォームのすぐ下にバインディング ポイントを追加します。@group は grid ユニフォームと同じにしますが、@binding 番号は異なるものを指定します。異なる種類のバッファであることを反映させるため、var の型は storage とします。また、cellState の型として、JavaScript の Uint32Array と同じになるように、単一のベクトルではなく u32 値の配列を指定します。

次に、@vertex 関数の本体で、セルの状態を問い合わせます。状態は、ストレージ バッファに 1 次元配列として保存されているので、instance_index を使用して現在のセルの値を検索できます。

状態が非アクティブだった場合にセルを非表示にするには、どうすればよいでしょうか。配列から取得するアクティブ状態と非アクティブ状態はそれぞれ 1 または 0 なので、アクティブ状態によってジオメトリをスケーリングするとよさそうです。1 倍にスケーリングするとジオメトリはそのままとなりますが、0 倍にスケーリングするとジオメトリは点となり、GPU によって破棄されます。

  1. シェーダーのコードを更新して、セルのアクティブ状態によって位置をスケーリングします。WGSL の型安全性要件を満たすため、状態の値は f32 にキャストする必要があります。

index.html

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> VertexOutput {
  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let state = f32(cellState[instance]); // New line!

  let cellOffset = cell / grid * 2;
  // New: Scale the position by the cell's active state.
  let gridPos = (pos*state+1) / grid - 1 + cellOffset;

  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell;
  return output;
}

バインド グループにストレージ バッファを追加する

セルの状態を反映させるためには、ストレージ バッファをバインド グループに追加する必要があります。このバッファはユニフォーム バッファと同じ @group に属しているため、JavaScript のコードで同じバインド グループに追加します。

  • 以下のように、ストレージ バッファを追加します。

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  },
  // New entry!
  {
    binding: 1,
    resource: { buffer: cellStateStorage }
  }],
});

新しいエントリの binding の値が、シェーダーの対応する値の @binding() と一致するようにします。

このコードを追加して更新すると、グリッドにパターンが表示されます。

暗い青色の背景の上に左下隅から右上隅まで対角線上に並んだ、カラフルな正方形のストライプ。

バッファでピンポン パターンを使用する

ここで作成するようなシミュレーションのほとんどでは、少なくとも 2 つの状態のコピーを使用します。シミュレーションの各ステップでは、まず一方の状態のコピーから読み込み、他方のコピーに書き出します。次のステップでは、逆に書き込んだ方のコピーから状態を読み取ります。このような方式は一般的にピンポン パターンと呼ばれます。各ステップで最新バージョンの状態が 2 つのコピーの間で行ったり来たりするからです。

なぜこのようなパターンが必要なのでしょうか。シンプルな例で考えてみましょう。各ステップで、アクティブなブロックをセル 1 つ分だけ右に移動させる非常にシンプルなシミュレーションを記述するとします。わかりやすいように、以下のように JavaScript でデータとシミュレーションを定義します。

// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];

function simulate() {
  for (let i = 0; i < state.length-1; ++i) {
    if (state[i] == 1) {
      state[i] = 0;
      state[i+1] = 1;
    }
  }
}

simulate(); // Run the simulation for one step.

ただし、このコードを実行すると、1 回のステップでアクティブなセルが配列の末尾まで一気に移動してしまいます。その理由は、1 回目のループでアクティブなセルが 1 つ右に移動しますが、その場所で状態を更新し続けるため、その移動した結果を元に次のセルについて判断すると、次のセルはアクティブということになり、さらにその右のセルにアクティブなセルが移動してしまうからです。このように、データの読み取りと変更を同時に行ってしまうと、正しい結果が得られません。

ピンポン パターンを使用すれば、シミュレーションの前回のステップの結果だけを使用して、次のステップを実行できます。

// Example simulation. Don't copy into the project!
const stateA = [1, 0, 0, 0, 0, 0, 0, 0];
const stateB = [0, 0, 0, 0, 0, 0, 0, 0];

function simulate(in, out) {
  out[0] = 0;
  for (let i = 1; i < in.length; ++i) {
     out[i] = in[i-1];
  }
}

// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA); 
  1. 2 つの同じバッファが作成されるようにコードのストレージ バッファの割り当て部分を更新して、このパターンを使用します。

index.html

// Create two storage buffers to hold the cell state.
const cellStateStorage = [
  device.createBuffer({
    label: "Cell State A",
    size: cellStateArray.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  }),
  device.createBuffer({
    label: "Cell State B",
     size: cellStateArray.byteLength,
     usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  })
];
  1. 2 つのバッファの違いを視覚的にわかりやすくするため、異なるデータを入力します。

index.html

// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
  cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
  1. レンダリング時に異なるストレージ バッファの状態を表示するため、以下のように 2 つの異なるバリエーションが含まれるようバインド グループを更新します。

index.html

const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
   device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }],
  })
];

レンダリング ループを設定する

ここまでは、ページを更新したときに 1 回だけ描画を行っていましたが、時間とともに次々と更新されたデータが表示されるようにします。そのためには、シンプルなレンダリング ループが必要です。

レンダリング ループとは、無限に繰り返すループで、一定間隔でキャンバスにコンテンツが描画されます。スムーズなアニメーションを必要とする多くのゲームやその他のコンテンツでは、requestAnimationFrame() 関数を使用して、画面のリフレッシュ レート(60 fps)と同じレートでコールバックをスケジュールします。

このアプリでは、そのレートを使用することもできますが、ここではシミュレーションの動作を確認しやすいように、更新間隔を長くしてみましょう。画面のリフレッシュ レートを使わずに自身でループを管理し、シミュレーションが更新されるレートを制御します。

  1. まず、シミュレーションを更新するレートを選びます。200 ミリ秒がおすすめですが、それよりも長くても短くてもかまいません。次に、シミュレーションで実行されたステップ数を記録するための変数を用意します。

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. 現在レンダリングに使用しているすべてのコードを新しい関数に移動します。setInterval() を使用して、この関数が目的の間隔で繰り返し実行されるようにスケジュールします。また、この関数ではステップ カウントも更新されるようにし、このカウントによって 2 つのバインド グループのどちらをバインドするかを選択します。

index.html

// Move all of our rendering code into a function
function updateGrid() {
  step++; // Increment the step count
  
  // Start a render pass 
  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      loadOp: "clear",
      clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
      storeOp: "store",
    }]
  });

  // Draw the grid.
  pass.setPipeline(cellPipeline);
  pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
  pass.setVertexBuffer(0, vertexBuffer);
  pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

  // End the render pass and submit the command buffer
  pass.end();
  device.queue.submit([encoder.finish()]);
}

// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);

アプリを実行すると、キャンバスが一定間隔で更新され、作成した 2 つの状態バッファが交互に表示されます。

暗い青色の背景の上に左下隅から右上隅まで対角線上に並んだ、カラフルな正方形のストライプ。 暗い青色の背景の上に垂直に並んだ、カラフルな正方形のストライプ。

これで、レンダリング処理についてはほぼ完成しました。次のステップではいよいよコンピューティング シェーダーを使用してライフゲーム シミュレーションを構築しますが、その出力を表示する準備が整ったことになります。

ここでは WebGPU が持つさまざまなレンダリング機能の一部を紹介したにすぎませんが、この Codelab ではここまでとしておきます。ただし、WebGPU におけるレンダリングの仕組みの基礎的な部分については十分理解できたので、3D レンダリングなどの高度な手法についても理解しやすくなっているはずです。

8. シミュレーションを実行する

それでは最後の重要なステップである、コンピューティング シェーダーを使用したライフゲーム シミュレーションの実行に移りましょう。

ついにコンピューティング シェーダーを使用する

コンピューティング シェーダーについては、この Codelab を通して抽象的にではありますが学んできました。では、コンピューティング シェーダーとは具体的にどのようなものなのでしょうか。

コンピューティング シェーダーは、GPU で高度に並列化して実行できる設計である点は、頂点シェーダーやフラグメント シェーダーと似ています。しかしこれら 2 つのシェーダー ステージとは異なり、入力と出力のセットが特定のものに定められていません。このシェーダーは、ストレージ バッファなどの任意のソースとの間でデータを読み書きする処理を担います。そのため、各頂点、インスタンス、またはピクセルごとに 1 回実行される仕組みにはなっておらず、シェーダー関数を何回呼び出すのかを自身で指定する必要があります。シェーダーが実行されると、どの呼び出しが処理されているかを識別する値が渡されるので、どのデータにアクセスして、どのような操作を行うかを決定できます。

コンピューティング シェーダーは、頂点シェーダーやフラグメント シェーダーと同様、シェーダー モジュール内に作成する必要があるので、まずはコンピューティング シェーダーをコードに追加します。これまで他のシェーダーを実装してきた皆さんであれば、もうコンピューティング シェーダーをどのような形式で指定する必要があるかおわかりだと思いますが、メイン関数を @compute 属性でマークする必要があります。

  1. 以下のコードを使用して、コンピューティング シェーダーを作成します。

index.html

// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
  label: "Game of Life simulation shader",
  code: `
    @compute
    fn computeMain() {

    }`
});

GPU は 3D グラフィックに使用されることが多いため、X、Y、Z 軸に沿って特定の回数だけコンピューティング シェーダーを呼び出すようにリクエストできる構造となっています。そのため、2 次元または 3 次元グリッドに沿った形で簡単に処理をディスパッチできるようになっており、今回のユースケースに非常に適しています。シミュレーションの各セルに対して一度、つまり合計で GRID_SIZE x GRID_SIZE 回このシェーダーを呼び出す必要があります。

GPU ハードウェア アーキテクチャの性質上、このグリッドはいくつかのワークグループに分割して処理されます。ワークグループには X、Y、Z のサイズがあり、それぞれを 1 にすることもできますが、多くの場合、ワークグループを大きくすることで、パフォーマンス上のメリットを得ることができます。このシェーダーでは、やや恣意的に思えるかもしれませんがワークグループを 8 x 8 のサイズにします。このようにすると、JavaScript のコードで処理を追跡しやすくなります。

  1. 以下のように、ワークグループ サイズの定数を定義します。

index.html

const WORKGROUP_SIZE = 8;

ワークグループのサイズは、シェーダー関数自体にも追加する必要があり、そのためには JavaScript のテンプレート リテラルを使用します。こうすることで、先ほど定義した定数を簡単にそのまま使用できます。

  1. 以下のように、ワークグループのサイズをシェーダー関数に追加します。

index.html(コンピューティングの createShaderModule の呼び出し)

@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {

}

これにより、シェーダーに対して、この関数の処理を(8 x 8 x 1)のグループで行うように指示できます(指定しなかった軸はデフォルトで 1 になりますが、少なくとも X 軸は指定する必要があります)。

他のシェーダー ステージと同様に、コンピューティング シェーダー関数の入力として受け取ることのできるさまざまな @builtin 値が用意されており、これを使用することで、どの呼び出しであるか、そしてどのような処理を実行する必要があるかを判断できます。

  1. 以下のように、@builtin 値を追加します。

index.html(コンピューティングの createShaderModule の呼び出し)

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

シェーダー呼び出しのグリッドの位置を伝える符号なし整数の 3 次元ベクトルである global_invocation_id ビルトインを渡します。このシェーダーは、グリッド内の各セルに対して一度実行します。(0, 0, 0)、(1, 0, 0)、(1, 1, 0) という数値を (31, 31, 0) まで取得できるので、処理を行うセルのインデックスとして扱うことができます。

コンピューティング シェーダーでは、頂点シェーダーおよびフラグメント シェーダーと同様にユニフォームを使用できます。

  1. 以下のように、コンピューティング シェーダーでユニフォームを使用して、グリッドサイズを確認します。

index.html(コンピューティングの createShaderModule の呼び出し)

@group(0) @binding(0) var<uniform> grid: vec2f; // New line

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

頂点シェーダーと同様に、ストレージ バッファとしてセル状態を公開します。ただし、この場合は 2 つのストレージ バッファが必要です。コンピューティング シェーダーには、頂点の位置やフラグメントの色などの必須の出力がないため、コンピューティング シェーダーから結果を取得するためには、ストレージ バッファやテクスチャに値を書き込む必要があります。先ほど学んだピンポン パターンを使用して、グリッドの現在の状態を入力するためのストレージ バッファを 1 つと、グリッドの新しい状態を書き出すストレージ バッファを 1 つ用意します。

  1. 以下のように、セルの入力状態と出力状態をストレージ バッファとして公開します。

index.html(コンピューティングの createShaderModule の呼び出し)

@group(0) @binding(0) var<uniform> grid: vec2f;
    
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

1 つ目のストレージ バッファは var<storage> を使用して宣言されており、読み取り専用となっていますが、2 つ目のストレージ バッファは var<storage, read_write> を使用して宣言されています。これにより、バッファへの読み書きが可能となるので、このバッファをコンピューティング シェーダーの出力として使用できます(WebGPU には、書き込み専用のストレージ モードはありません)。

次に、セルのインデックスをストレージ バッファの 1 次元の配列にマッピングする方法が必要となります。これは、1 次元の instance_index を 2 次元のグリッドセルにマッピングした頂点シェーダーの処理と逆の処理となります(なお、そのときのアルゴリズムは vec2f(i % grid.x, floor(i / grid.x)) でした)。

  1. 逆の処理を行う関数を記述します。セルの Y 軸の値をグリッドの幅で乗算し、セルの X 軸の値を加算します。

index.html(コンピューティングの createShaderModule の呼び出し)

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

// New function   
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  
}

そして最後に、動作することを確認するために、セルが現在アクティブなら非アクティブに、非アクティブならアクティブにする非常にシンプルなアルゴリズムを実装します。まだライフゲームにはなっていませんが、まずはコンピューティング シェーダーが動作していることを確認してみましょう。

  1. 以下のようなシンプルなアルゴリズムを追加します。

index.html(コンピューティングの createShaderModule の呼び出し)

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
   
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines. Flip the cell state every step.
  if (cellStateIn[cellIndex(cell.xy)] == 1) {
    cellStateOut[cellIndex(cell.xy)] = 0;
  } else {
    cellStateOut[cellIndex(cell.xy)] = 1;
  }
}

コンピューティング シェーダーの操作は以上です。ただし結果を確認する前に、さらにいくつかの変更を加える必要があります。

バインド グループとパイプライン レイアウトを使用する

上記のシェーダーでは、レンダリング パイプラインとほぼ同じ入力(ユニフォーム バッファとストレージ バッファ)を使用していることにお気づきでしょうか。ですから、単純に同じバインド グループを使って処理を行うことができないかと考える方もいるでしょう。実は、同じバインド グループを使用することは可能です。ただし、そのためには少し手作業での準備が必要となります。

バインド グループを作成する際は、必ず GPUBindGroupLayout を指定する必要があります。先ほどは、レンダリング パイプラインに対して getBindGroupLayout() を呼び出してこのレイアウトを取得しました。このレイアウトは、パイプライン作成時に layout: "auto" を指定したため、自動的に作成されたものです。パイプラインを 1 つ使うだけならこの方法が便利ですが、リソースを共有する複数のパイプラインを使用する場合は、明示的にレイアウトを作成し、バインド グループとパイプラインの両方に提供する必要があります。

このようにする理由は、次のように考えるとわかりやすいでしょう。レンダリング パイプラインでは、ユニフォーム バッファを 1 つとストレージ バッファを 1 つ使用しますが、先ほど作成したコンピューティング シェーダーではそれに加えて 2 つ目のストレージ バッファが必要です。レンダリングとコンピューティングの 2 つのシェーダーはユニフォーム バッファと 1 つ目のストレージ バッファで同じ @binding 値を使用するため、これらをパイプライン間で共有できます。レンダリング パイプラインでは、使用しない 2 つ目のストレージ バッファは単純に無視されます。そこで、特定のパイプラインで使用するリソースだけでなく、バインド グループに存在するすべてのリソースを記述するレイアウトを作成する必要があります。

  1. このレイアウトを作成するには、device.createBindGroupLayout() を呼び出します。

index.html

// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
  label: "Cell Bind Group Layout",
  entries: [{
    binding: 0,
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: {} // Grid uniform buffer
  }, {
    binding: 1,
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: { type: "read-only-storage"} // Cell state input buffer
  }, {
    binding: 2,
    visibility: GPUShaderStage.COMPUTE,
    buffer: { type: "storage"} // Cell state output buffer
  }]
});

これは、entries のリストを指定するという点で、バインド グループ自体を作成するときと同様の構造となっています。ただし、リソース自体を指定するのではなく、エントリがどのような種類のリソースで、どのように使用されるかを記述する点が異なります。

各エントリでは、リソースの binding 番号を指定します。この番号は、バインド グループ作成時に説明したように、シェーダーの @binding の値と一致します。また、visibility も指定します。これは、どのシェーダー ステージでこのリソースを使用できるかを示す GPUShaderStage フラグです。ユニフォーム バッファと 1 つ目のストレージ バッファは、頂点シェーダーとコンピューティング シェーダーでアクセスする必要がありますが、2 つ目のストレージ バッファはコンピューティング シェーダーでのみアクセスする必要があります。

最後に、どのような種類のリソースを使用するか指定します。公開する対象によって、ディクショナリのキーが変わります。ここでは、リソースは 3 つともバッファなので、buffer キーを使用してそれぞれのオプションを定義します。他にも texture や sampler などのオプションがありますが、ここでは必要ありません。

ディクショナリの buffer キーでは、どのような type のバッファを使用するかなどのオプションを設定します。デフォルトは "uniform" なので、バインディング 0 についてはディクショナリを空のままにしておくことができます(ただし、エントリがバッファであると識別できるように、少なくとも buffer: {} は設定する必要があります)。バインディング 1 は、シェーダーで read_write アクセスを行わないため、型として "read-only-storage" を指定します。バインディング 2 は、read_write アクセスを行うため "storage" 型を指定します。

bindGroupLayout が作成されたら、パイプラインからバインド グループに問い合わせるのではなく、バインド グループの作成時にこれを渡すことができます。これを行うには、定義したレイアウトと一致するように、各バインド グループに新しいストレージ バッファのエントリを追加する必要があります。

  1. バインド グループ作成のコードを以下のように更新します。

index.html

// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: bindGroupLayout, // Updated Line
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[1] }
    }],
  }),
  device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: bindGroupLayout, // Updated Line

    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
];

明示的に定義したバインド グループ レイアウトを使用するようにバインド グループを更新できました。次に、このレイアウトを使用するようにレンダリング パイプラインを更新します。

  1. GPUPipelineLayout を作成します。

index.html

const pipelineLayout = device.createPipelineLayout({
  label: "Cell Pipeline Layout",
  bindGroupLayouts: [ bindGroupLayout ],
});

パイプライン レイアウトは、1 つ以上のパイプラインで使用するバインド グループ レイアウトのリストです(この場合、レイアウトは 1 つだけです)。配列内のバインド グループ レイアウトの順序は、シェーダーの @group 属性と一致している必要があります(つまり、bindGroupLayout は @group(0) に関連付けられます)。

  1. パイプライン レイアウトを作成したら、"auto" の代わりにそのレイアウトを使用するようにレンダリング パイプラインを更新します。

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: pipelineLayout, // Updated!
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

コンピューティング パイプラインを作成する

頂点シェーダーとフラグメント シェーダーを使用するためにレンダリング パイプラインが必要であるのと同様に、コンピューティング シェーダーを使用するためにはコンピューティング パイプラインが必要です。幸い、コンピューティング パイプラインには設定すべき状態がなく、シェーダーとレイアウトの設定のみですむため、レンダリング パイプラインに比べてはるかにシンプルです。

  • 以下のコードを使用して、コンピューティング パイプラインを作成します。

index.html

// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
  label: "Simulation pipeline",
  layout: pipelineLayout,
  compute: {
    module: simulationShaderModule,
    entryPoint: "computeMain",
  }
});

先ほどレンダリング パイプラインを更新したときと同様に、"auto" ではなく新しい pipelineLayout を渡しています。これにより、レンダリング パイプラインとコンピューティング パイプラインの両方で同じバインド グループを使用できます。

コンピューティング パス

では、実際にコンピューティング パイプラインを使用してみましょう。レンダリングはレンダリング パスで行いました。同様に、コンピューティング処理はコンピューティング パスで行うと想像できるでしょう。コンピューティングとレンダリングの処理は、いずれも同じコマンド エンコーダで行うことができるので、updateGrid 関数の中身を少し書き換えます。

  1. エンコーダ作成のコードを関数の一番上に移動し、そのエンコーダを使用してコンピューティング パスを開始します(step++ の前に配置します)。

index.html

// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

// Existing lines
step++; // Increment the step count
  
// Start a render pass...

コンピューティング パイプラインと同様に、コンピューティング パスではアタッチメントについて考える必要がないため、レンダリング パスよりもはるかに簡単に開始できます。

コンピューティング パスで生成された最新の結果をレンダリング パスですぐに使用できるよう、コンピューティング パスを実行した後で、レンダリング パスを実行します。step カウントをこれらのパスの間でインクリメントするのもそのためです。こうすることで、コンピューティング パイプラインの出力バッファを、レンダリング パイプラインの入力バッファにすることができます。

  1. 次に、コンピューティング パスにパイプラインとバインド グループを設定します。レンダリング パスと同様のパターンを使用して、バインド グループを切り替えます。

index.html

const computePass = encoder.beginComputePass();

// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);

computePass.end();
  1. 最後に、レンダリング パスでは描画を行いましたが、コンピューティング パスではコンピューティング シェーダーに対して処理をディスパッチし、各軸に対して実行するワークグループの数を指定します。

index.html

const computePass = encoder.beginComputePass();

computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);

// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);

computePass.end();

ここで重要な点は、dispatchWorkgroups() に渡す数字は呼び出し回数ではなく、シェーダーの @workgroup_size で定義したワークグループを実行する個数であることです。

グリッド全体をカバーするためにシェーダーを 32x32 回実行したい場合、ワークグループのサイズが 8x8 であれば、4x4 個のワークグループをディスパッチする必要があります(4 * 8 = 32)。グリッドのサイズをワークグループのサイズで除算して、その値を dispatchWorkgroups() に渡しているのはそのためです。

再度ページを更新すると、一定間隔でグリッドの表示が反転する様子を確認できます。

暗い青色の背景の上に左下隅から右上隅まで対角線上に並んだ、カラフルな正方形のストライプ。 暗い青色の背景の上に左下隅から右上隅まで対角線上に並んだ、2 マス幅のカラフルな正方形のストライプ。前の画像の反転。

ライフゲームのアルゴリズムを実装する

コンピューティング シェーダーを更新して最終的なアルゴリズムを実装する前に、コードの中でストレージ バッファの中身を初期化している部分に戻り、ページが読み込まれるたびにランダムなバッファが生成されるように更新します(毎回同じパターンで始めても、ライフゲームでは興味深い結果を生成できません)。どのような方法でランダムな値を生成してもかまいませんが、簡単に実装できて十分満足できる結果を得られる方法があります。

  1. ランダムな状態で各セルを開始するために、以下のように cellStateArray の初期化コードを更新します。

index.html

// Set each cell to a random state, then copy the JavaScript array 
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
  cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

これで、いよいよライフゲーム シミュレーションのロジックを実装できるようになりました。ここまでさまざまな作業が必要でしたが、シェーダーのコードは驚くほど簡単です。

まず、特定のセルについて、アクティブな隣接セルの数を把握する必要があります。どのセルがアクティブかは関係ありません。重要なのは数だけです。

  1. 隣接セルのデータを簡単に取得できるように、特定の座標の cellStateIn の値を返す cellActive 関数を追加します。

index.html(コンピューティングの createShaderModule の呼び出し)

fn cellActive(x: u32, y: u32) -> u32 {
  return cellStateIn[cellIndex(vec2(x, y))];
}

cellActive 関数は、セルがアクティブであれば 1 を返すので、8 つすべての隣接セルに対して cellActive を呼び出し、戻り値を合計すれば、アクティブな隣接セルの数がわかります。

  1. 以下のように、アクティブな隣接セルの数を取得します。

index.html(コンピューティングの createShaderModule の呼び出し)

fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines:
  // Determine how many active neighbors this cell has.
  let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                        cellActive(cell.x+1, cell.y) +
                        cellActive(cell.x+1, cell.y-1) +
                        cellActive(cell.x, cell.y-1) +
                        cellActive(cell.x-1, cell.y-1) +
                        cellActive(cell.x-1, cell.y) +
                        cellActive(cell.x-1, cell.y+1) +
                        cellActive(cell.x, cell.y+1);

ただし、この方法には少し問題があります。チェック対象のセルがボードの端の外側にある場合はどうなるでしょうか。現在の cellIndex() のロジックでは、次の行か前の行にオーバーフローするか、あるいはバッファの境界を越えてはみ出してしまいます。

ライフゲームでは、グリッドの端にあるセルにおいてグリッドの逆側の端にあるセルを隣接セルとして扱う、一種のラップアラウンドによってこの問題を解決するのが一般的かつ簡単です。

  1. cellIndex() 関数を少し変更して、グリッドのラップアラウンドをサポートします。

index.html(コンピューティングの createShaderModule の呼び出し)

fn cellIndex(cell: vec2u) -> u32 {
  return (cell.y % u32(grid.y)) * u32(grid.x) +
         (cell.x % u32(grid.x));
}

% 演算子を使用して、セルの X と Y がグリッドサイズを超えたときにラップアラウンドすると、ストレージ バッファの境界を越えたアクセスが発生しなくなります。これで、activeNeighbors のカウントに予測不能な数が設定されることを防止できます。

次に、以下の 4 つのルールのいずれかを適用します。

  • 隣接セルが 2 つ未満のセルは、非アクティブとする。
  • 隣接セルが 2 つまたは 3 つで、自身がアクティブなセルは、アクティブなままとする。
  • 隣接セルが 3 つで、自身が非アクティブなセルは、アクティブとする。
  • 隣接セルが 4 つ以上のセルは、非アクティブとする。

一連の if ステートメントを使用してこの処理を記述することもできますが、WGSL では switch ステートメントもサポートされており、このロジックにはこちらの方が適しています。

  1. 以下のように、ライフゲームのロジックを実装します。

index.html(コンピューティングの createShaderModule の呼び出し)

let i = cellIndex(cell.xy);

// Conway's game of life rules:
switch activeNeighbors {
  case 2: { // Active cells with 2 neighbors stay active.
    cellStateOut[i] = cellStateIn[i];
  }
  case 3: { // Cells with 3 neighbors become or stay active.
    cellStateOut[i] = 1;
  }
  default: { // Cells with < 2 or > 3 neighbors become inactive.
    cellStateOut[i] = 0;
  }
}

参考までに、最終的なコンピューティング シェーダー モジュールの呼び出しは以下のようになります。

index.html

const simulationShaderModule = device.createShaderModule({
  label: "Life simulation shader",
  code: `
    @group(0) @binding(0) var<uniform> grid: vec2f;

    @group(0) @binding(1) var<storage> cellStateIn: array<u32>;
    @group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

    fn cellIndex(cell: vec2u) -> u32 {
      return (cell.y % u32(grid.y)) * u32(grid.x) +
              (cell.x % u32(grid.x));
    }

    fn cellActive(x: u32, y: u32) -> u32 {
      return cellStateIn[cellIndex(vec2(x, y))];
    }

    @compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
    fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
      // Determine how many active neighbors this cell has.
      let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                            cellActive(cell.x+1, cell.y) +
                            cellActive(cell.x+1, cell.y-1) +
                            cellActive(cell.x, cell.y-1) +
                            cellActive(cell.x-1, cell.y-1) +
                            cellActive(cell.x-1, cell.y) +
                            cellActive(cell.x-1, cell.y+1) +
                            cellActive(cell.x, cell.y+1);

      let i = cellIndex(cell.xy);

      // Conway's game of life rules:
      switch activeNeighbors {
        case 2: {
          cellStateOut[i] = cellStateIn[i];
        }
        case 3: {
          cellStateOut[i] = 1;
        }
        default: {
          cellStateOut[i] = 0;
        }
      }
    }
  `
});

手順は以上です。これで完了となります。ページを更新して、新しく作成したセル オートマトンが変化していく様子を観察しましょう。

ライフゲーム シミュレーションの状態の例を示すスクリーンショット。暗い青色の背景の上にカラフルなセルがレンダリングされている。

9. お疲れさまでした

WebGPU API を使用して、完全に GPU 上で動作する有名なコンウェイのライフゲーム シミュレーションを作成できました。

次のステップ

参考資料

リファレンス ドキュメント