初めての WebGPU アプリ

1. はじめに

WebGPU ロゴは、図案化された「W」を形成する複数の青い三角形で構成されます

最終更新日:2023 年 4 月 13 日

WebGPU とは

WebGPU は、ウェブアプリの GPU の機能にアクセスするための新しい最新 API です。

最新の API

WebGPU の前には WebWebGL があり、WebGPU の機能のサブセットを提供していました。新しいクラスのリッチなウェブ コンテンツによって、デベロッパーの皆様は素晴らしいコンテンツを生み出してきました。これは、2007 年にリリースされた OpenGL ES 2.0 API がベースであり、さらに古い OpenGL API に基づいています。この間に GPU は大幅に進化し、Direct3D 12MetalVulkan とともに、それらのインターフェースに使用されるネイティブ API も進化しました。

WebGPU は、こうした最新の API の進化をウェブ プラットフォームに提供します。これは、ウェブをまたいで自然に操作できる API であり、さらに、その上で構築されているネイティブ API よりも冗長な形で、クロス プラットフォーム方式で GPU 機能を有効にすることに重点を置いています。

レンダリング

GPU は多くの場合、高速で詳細なグラフィックに関連しており、WebGPU も例外ではありません。デスクトップ GPU とモバイル GPU の両方で現在最も一般的なレンダリング技術の多くをサポートするために必要な機能を備えており、ハードウェア機能の進化に伴い、今後新しい機能が追加される可能性があります。

コンピューティング

WebGPU はレンダリングに加えて、汎用性の高い並列ワークロードを実行する際の GPU の可能性を引き出します。これらの Compute シェーダーは、レンダリング コンポーネントなしで単独で使用するか、レンダリング パイプラインの緊密に統合された部分として使用できます。

今日の Codelab では、簡単な入門プロジェクトを作成するために、WebGPU のレンダリング機能とコンピューティング機能の両方を活用する方法を学びます。

作業内容

この Codelab では、WebGPU を使用して Conway's Game of Life を作成します。作成するアプリの機能は次のとおりです。

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

この Codelab の最終プロダクトのスクリーンショット

ゲーム・オブ・ライフはセルオートマトンと呼ばれるもので、ルールに基づいてセルのグリッドが時間の経過とともに変化していきます。ゲーム・オブ・ライフでは、隣接するセルの数に応じてセルがアクティブまたは非アクティブになり、視聴するうちにさまざまなパターンが変化します。

学習内容

  • WebGPU を設定し、キャンバスを構成する方法
  • シンプルな 2D ジオメトリを描画する方法。
  • 描画対象を変更するために頂点シェーダーとフラグメント シェーダーを使用する方法。
  • 簡単なシミュレーションを実行するためのコンピューティング シェーダーの使用方法。

この Codelab では、WebGPU の背後にある基本的なコンセプトについて説明します。本 API を包括的にレビューするためのものではありません。また、3D 行列数学などのよく使われるトピックを網羅するものでもありません。

必要なもの

  • ChromeOS、macOS、Windows で動作する Chrome の最新バージョン(113 以降)。WebGPU はクロスブラウザ、クロス プラットフォーム API ですが、まだあらゆる場所で利用されていません。
  • HTML、JavaScript、Chrome DevTools に関する知識。

WebGL、Metal、Vulkan、Direct3D などの他の Graphics API に習熟していることは必須ではありません経験があれば、WebGPU との多くの類似点に気づき、すぐに利用を開始できるでしょう。

2. 設定する

コードを取得する

この Codelab には依存関係がなく、WebGPU アプリの作成に必要なすべてのステップが網羅されているため、コードの作成は必要ありません。チェックポイントとして使用できる実例のいくつかを https://glitch.com/edit/#!/your-first-webgpu-app でご覧いただけます。行き詰まった場合もぜひご確認ください。

デベロッパー コンソールの使用

WebGPU はかなり複雑な API であり、適切な使用法を規定するルールが多数あります。さらに悪いことに、この API の仕組みにより、多くのエラーで JavaScript の一般的な例外を発生させることができず、問題の原因を正確に特定することは困難です。

特に初心者の場合、WebGPU を使用して開発する際には問題が発生します。API の背後にある開発者は、GPU 開発作業の課題を認識しており、WebGPU コードでエラーが発生すると、デベロッパー コンソールに、問題の特定と修正に役立つ非常に詳細なメッセージを確実に返すことができるよう尽力しています。

任意のウェブ アプリケーションで作業中にコンソールを開いたままにすることは常に有用ですが、ここでは特に当てはまります。

3. WebGPU を初期化する

<canvas> で開始

WebGPU を使用すれば計算のみを行い、画面に何も表示せずに WebGPU を使用できます。この Codelab で行うように、何かをレンダリングする場合は、キャンバスが必要です。まずはこちらでご確認ください。

1 つの<canvas>要素と、キャンバス要素に対するクエリを行う <script> タグを含む新しい HTML ドキュメントを作成します。(または、グリッチの 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 をサポートしているにもかかわらず、GPU ハードウェアに WebGPU の使用に必要な機能がすべて含まれていない場合に発生することがあります。

ほとんどの場合、ここでデフォルト アダプタをブラウザで選択しても問題ありませんが、より高度な機能が必要な場合は、複数の GPU を持つデバイスで低消費電力のハードウェアと高性能のハードウェアのどちらを使用するかを指定する requestAdapter()渡すことができる引数があります(ノートパソコンなど)。

アダプターを用意したら、GPU の使用を開始する前に、GPUDevice をリクエストします。デバイスは、GPU とのインタラクションがほとんど発生するメイン インターフェースです。

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

index.html

const device = await adapter.requestDevice();

requestAdapter() と同様に、特定のハードウェア機能を有効にする、上限を引き上げるなど、より高度な用途の場合は渡せるオプションがありますが、これらの用途についてはデフォルトをそのまま使用してもかまいません。

キャンバスを設定する

デバイスが作成できたので、次はそのページでページに何かを表示する場合は、作成したデバイスで使用するキャンバスを設定します。

  • そのためには、まず canvas.getContext("webgpu") を呼び出して、キャンバスから GPUCanvasContext をリクエストします。(これは、それぞれ 2dwebgl のコンテキスト タイプを使用して、Canvas 2D または 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",
  }]
});

テクスチャは colorAttachmentview プロパティとして指定します。レンダリング パスでは、テクスチャのどの部分をレンダリングするかを示す GPUTexture ではなく GPUTextureView を指定する必要があります。これは非常に高度なユースケースに限って重要です。ここでは、テクスチャの引数を指定せずに createView() を呼び出して、レンダリングパスでテクスチャ全体を使用することを示します。

また、レンダリング開始時と終了時にテクスチャをどのように処理するかも指定する必要があります。

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

レンダリング パスが開始されると何もしません。当面は少なくともloadOp: "clear" でレンダリング パスを開始するだけで、テクスチャ ビューとキャンバスをクリアできます。

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

index.html

pass.end();

これらの呼び出しを実行しても、GPU が実際に何かを起こすことはありません。後で実行するコマンドを GPU に記録するだけです。

  1. GPUCommandBuffer を作成するには、コマンド エンコーダで finish() を呼び出します。コマンド バッファは、記録されたコマンドの不透明なハンドルです。

index.html

const commandBuffer = encoder.finish();
  1. GPUDevicequeue を使用して、コマンド バッファを 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(赤ba(透明))の 4 つの値が含まれています。g各値の範囲は 01 で、それらを組み合わせてカラーチャネルの値を記述します。例:

  • { 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. 色を選択したら、ページを再読み込みします。選択した色がキャンバスに表示されます。

デフォルトのクリアカラーを変更する方法を示すためにキャンバスがダークブルーにクリアされています。

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

このセクションの終わりまでに、アプリはキャンバスにシンプルなジオメトリ(色付きの正方形)を描画します。単純な出力では多くの作業が必要と思われるかもしれませんが、WebGPU は多数のジオメトリを効率的にレンダリングするように設計されているためです。この効率性の副作用は、比較的単純な作業が困難に感じられるかもしれないという点にありますが、WebGPU のような API に代えて、より複雑な処理を行いたい場合にも期待されます。

GPU の描画方法を理解する

コードを変更する前に、GPU が画面上に表示されるシェイプを作成する方法を概説し、短時間で簡単に確認することをおすすめします。(GPU レンダリングの仕組みに関する基本的な知識をお持ちの方は、このセクションをスキップしてご参照ください。)

多数のシェイプやオプションが利用できる Canvas 2D のような API とは異なり、GPU では実際に、いくつかの異なるシェイプ(GPU と呼ばれるプリミティブ)、つまりポイント、ライン、トライアングルしか処理されません。この Codelab では三角形のみを使用します。

GPU の三角形には、数多くの優れた数学的性質があり、予測可能かつ効率的な方法で簡単に処理できるため、GPU では三角形とほぼ同じように動作します。GPU で描画するほとんどすべてのものを、GPU で描画する前に三角形に分割する必要があります。三角形の角は角で定義する必要があります。

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

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

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

そこから、GPU はこれらの変換された頂点で構成されるすべての三角形を取得し、それらを描画するために必要な画面上のピクセルを決定します。次に、「フラグメント シェーダー」という別の小さなプログラムを実行して、各ピクセルの色を計算します。計算は、緑色の戻りのような単純なものでも、周囲の他の表面からの太陽光の反射に対する表面の角度の計算のようなものも、霧によってフィルタされ、表面の金属性による修正と同じくらい複雑で、大きな力と圧倒的なものになる場合があります。

これらのピクセルカラーの結果はテクスチャに蓄積され、画面に表示されます。

頂点を定義する

前述のように、「ゲーム・オブ・ライフ」シミュレーションはセルのグリッドとして表示されます。アプリは、アクティブなセルと無効なセルを区別してグリッドを可視化する方法を必要としています。この Codelab で使用するアプローチは、アクティブなセルに色付きの四角を描画し、アクティブでないセルを空のままにすることです。

つまり、正方形の四隅のうちの 1 隅に 1 つずつ、合計 4 つの地点を GPU に提供する必要があります。たとえば、キャンバスの中央に描画される正方形は端から少し引き込まれ、次のような角の座標になります。

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

これらの座標を GPU にフィードするには、その値を TypedArray に配置する必要があります。まだよく知らない場合、TypedArray は JavaScript オブジェクトのグループであり、連続したメモリブロックを割り当て、系列の各要素を特定のデータ型として解釈できます。たとえば Uint8Array では、配列内の各要素は単一の符号なしバイトです。TypedArrays は、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,
]);

この間隔とコメントは値に影響せず、便宜性が高く、読みやすくするためのものです。これにより、値の各ペアが 1 つの頂点の X 座標と Y 座標を構成していることがわかります。

問題があります。GPU は三角形で機能することを覚えていますか?つまり、頂点を 3 つのグループに分けて指定する必要があります。4 つのグループがあります。この問題を解決するには、2 つの頂点を繰り返して、2 つの三角形を作成します。この三角形の形状は、正方形の真ん中にあるエッジを持ちます。

正方形の 4 つの頂点を使用して 2 つの三角形を形成する図。

図から正方形を形成するには、(-0.8、-0.8)および(0.8、0.8)頂点を 2 回、青い三角形と赤色の三角形を 1 つずつリストする必要があります。(他の 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 はギャップなしでレンダリングしています。これは、単一で塗りつぶしの正方形としてレンダリングされます。

頂点バッファを作成する

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,
});

まず注意すべきは、バッファにラベルを与えていることです。作成する各 WebGPU オブジェクトにはオプション ラベルを設定できますが、そうすることをおすすめします。ラベルは、オブジェクトが何であるかを識別するのに役立つ限り、任意の文字列です。問題が発生した場合、これらのラベルは WebGPU が生成するエラー メッセージで使用され、問題を理解するのに役立ちます。

次に、バッファのサイズをバイト単位で指定します。48 バイトのバッファが必要です。これは、32 ビット浮動小数点数(4 バイト)のサイズに vertices 配列(12)の浮動小数点数を掛けて決定します。幸い、TypedArrays ではすでに byteLength が計算されるため、バッファの作成時に使用できます。

最後に、バッファの使用状況を指定する必要があります。GPUBufferUsage フラグのうち 1 つ以上を指定します。複数のフラグは |ビット演算 OR)演算子で結合されます。この場合、頂点データ(GPUBufferUsage.VERTEX)にバッファを使用し、そこにデータもコピーできることを指定します(GPUBufferUsage.COPY_DST)。

返されるバッファ オブジェクトは不透明であり、保持されているデータを(簡単に)検査することはできません。また、属性のほとんどは不変です。GPUBuffer の作成後にそのサイズを変更したり、使用フラグを変更したりすることはできません。その内容を変更できるのはメモリの内容です。

バッファが最初に作成されたとき、そこに含まれるメモリはゼロに初期化されます。コンテンツを変更する方法はいくつかありますが、コピーする TypedArray で device.queue.writeBuffer() を呼び出すのが最も簡単な方法です。

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

index.html

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

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

頂点データを格納したバッファがありますが、GPU に関する限り、必要なのはバイトの blob です。これから絵を描く場合は、もう少し詳しく入力する必要があります。頂点データの構造について、より詳細に WebGPU に説明できる必要があります。

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 の範囲外です。

単一の属性で、まずデータの format を定義します。これは、GPU が認識できる各タイプの頂点データを記述する GPUVertexFormat タイプのリストに由来します。頂点には、それぞれ 2 つの 32 ビット浮動小数点数があるため、float32x2 の形式を使用します。頂点データがそれぞれ 4 つの 16 ビット符号なし整数で構成されている場合は、代わりに uint16x4 を使用します。パターンがわかり、

次に、offset は、この特定の属性が開始する頂点のバイト数を記述します。これを考慮する必要があるのは、バッファに複数の属性がある場合だけです。この属性は、この Codelab では表示されません。

最後は shaderLocation です。これは 0 ~ 15 の任意の数値で、定義したすべての属性に対して一意である必要があります。頂点シェーダー内の特定の入力にこの属性をリンクします。この点については、次のセクションで説明します。

これらの値をここで定義しましたが、まだ WebGPU API に渡していません。後で詳しく説明しますが、これらの値は頂点を定義する時点で最も簡単に考えられるため、後で使用するために設定しておきます。

シェーダーで開始する

これでレンダリングしたデータを取得できましたが、GPU で処理方法を正確に指示する必要があります。その多くはシェーダーで発生します。

シェーダーとは、GPU 上で記述され、実行できる小さなプログラムのことです。各シェーダーは、データのさまざまなステージ(Vertex 処理、Fragment 処理、一般的な Compute)に従って動作します。これらは GPU 上にあるため、平均的な JavaScript よりも厳格に構造化されています。しかし、この構造により、非常に高速かつ重要な並列実行が可能になります。

WebGPU のシェーダーは、WGSL(WebGPU Shading Language)というシェーディング言語で記述されます。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 のような独自の型を持つ、言語で最高水準のプリミティブとして扱われます。2D ベクトル(vec2f)と 3D ベクトル(vec3f)にも同じようなタイプがあります。

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

index.html(createShaderModule コード)

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

}

もちろん、関数が戻り値の型を持っている場合は、実際に関数本体で値を返す必要があります。構文 vec4f(x, y, z, w) を使用して、返す vec4f を新たに作成できます。xyz の値はすべて浮動小数点数であり、戻り値の中で、クリップ空間内の頂点の位置を示します。

  1. 静的な値 (0, 0, 0, 1) を返すと、厳密に言えば有効な頂点シェーダーがあります。ただし、GPU が生成する三角形は 1 つのポイントであると認識し、破棄するため、何も表示しません。

index.html(createShaderModule コード)

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

代わりに、作成したバッファのデータを使用します。これを行うには、関数の引数を @location() 属性で宣言し、vertexBufferLayout で説明したタイプと一致するようにします。0shaderLocation を指定したため、WGSL コードで引数を @location(0) としてマークします。また、形式は 2D ベクトルである float32x2 として定義しているため、WGSL の引数は vec2f です。任意の名前を付けることができますが、頂点の位置を表すため、pos などの名前は自然です。

  1. シェーダー関数を次のコードに変更します。

index.html(createShaderModule コード)

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

次に、その位置を返す必要があります。位置は 2D ベクトルで、戻り値の型は 4D ベクトルなので、少し変更する必要があります。位置引数から 2 つのコンポーネントを取得し、戻り値のベクトルの最初の 2 つのコンポーネントにそれぞれ配置して、最後の 2 つのコンポーネントをそれぞれ 01 のままにします。

  1. 使用する位置コンポーネントを明示的に指定して、正しい位置を返します。

index.html(createShaderModule コード)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos.x, pos.y, 0, 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 つしかないため、ロケーションは 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 関数を含めることができます)。バッファは、このパイプラインを使用する頂点バッファにデータをパッキングする方法を記述する GPUVertexBufferLayout オブジェクトの配列です。幸いなことに、これはすでに vertexBufferLayout で定義されています。ここでこれを渡します。

最後に、fragment ステージについて詳しく説明します。頂点ステージなどのシェーダーの moduleentryPoint も含まれています。最後のビットでは、このパイプラインを使用する targets を定義します。これは、パイプラインが出力するカラー アタッチメントの詳細(テクスチャ format など)を指定する辞書の配列です。これらの詳細は、このパイプラインで使用されるレンダリング パスの colorAttachments で指定されたテクスチャと一致している必要があります。レンダリング パスでは、キャンバス コンテキストのテクスチャを使用し、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. 画面を更新すると、すべての作業の結果(色付きの大きな四角)が表示されます。

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

5. グリッドを描画する

まず、自分にお祝いをしてください。ほとんどの GPU API では、ジオメトリの最初の部分を画面に表示させるのが最も難しいステップの一つです。ここで行う作業はすべて小さなステップで行えるため、作業の進捗を簡単に確認できます。

このセクションの内容:

  • JavaScript から変数(ユニフォームと呼ばれます)をシェーダーに渡す方法。
  • ユニフォームを使用してレンダリング動作を変更する方法。
  • インスタンス化を使用して同じジオメトリの異なる複数のバリアントを描画する方法

グリッドを定義する

グリッドをレンダリングするには、それに関するごく基本的な情報が必要です。セルには幅と高さの両方を含むセルがいくつありますか。デベロッパーは自由に決められますが、グリッドを整理するために、グリッドを同じ幅と高さで扱い、2 のべき数のサイズにします。(これにより、後で数学が簡単になります)。もっと大きくしたいのですが、このセクションではグリッドサイズを 4x4 に設定します。これは、このセクションで使用する数学がわかりやすくなるためです。後でスケールアップする

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

index.html

const GRID_SIZE = 4;

次に、正方形のレンダリング方法を更新して、正方形の GRID_SIZE×GRID_SIZE がキャンバスに収まるようにします。つまり、正方形はずっと小さくして、いくつも作るべきです。

このアプローチでは、頂点バッファを大幅に大きくして、そのGRID_SIZE倍の正方形を、適切なサイズと位置でGRID_SIZE定義することが可能です。そのためのコードは、実際には悪くはありません。for for ループとちょっとした数学があります。しかし、この場合は 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 に通知されるからです。ただし主な違いは、この場合は usageGPUBufferUsage.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 というシェーダー内のユニフォームを定義します。これは、ユニフォーム バッファにコピーした配列に一致する 2D の浮動小数点ベクトルです。また、ユニフォームが @group(0)@binding(0) にバインドされていることも指定します。各値の意味については後で説明します。

次に、シェーダー コードの他の場所では、グリッド ベクトルを必要に応じて使用できます。このコードでは、頂点位置をグリッド ベクトルで割っています。pos は 2 次元ベクトルであり、grid は 2 次元ベクトルであるため、WGSL は成分単位で除算を行います。つまり、結果は vec2f(pos.x / grid.x, pos.y / grid.y) の場合と同じです。

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

この場合、グリッドサイズ 4 を使用すると、レンダリングされる正方形は元のサイズの 4 分の 1 になります。4 行を 1 行または 2 列に収めたい場合にぴったりです。

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

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

バインド グループは、同時にシェーダーにアクセス可能にするリソースの集合です。これには、ユニフォーム バッファなど複数の種類のバッファと、ここで取り上げていないが 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" でパイプラインを作成したため、バインディング グループのレイアウトを要求できます。これにより、パイプラインは、シェーダー コード自体で宣言したバインディングからバインディング グループ レイアウトを自動的に作成します。この場合、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);

最初の引数として渡された 0 は、シェーダー コードの @group(0) に対応します。@group(0) の一部である各 @binding がこのバインド グループ内のリソースを使用するということです。

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

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

暗い青の背景の中央に小さな赤い正方形があります。

今回、正方形が以前の 4 分の 1 になりました。それほどではありませんが、これはユニフォームが実際に適用され、シェーダーがグリッドのサイズにアクセスできるようになったことを示しています。

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

シェーダーでグリッドサイズを参照できるようになったので、次は、希望するグリッド パターンに合うようにレンダリングするジオメトリを操作してみましょう。そのためには、何を達成したいかを正確に検討する必要があります。

概念的にキャンバスを個別のセルに分割する必要があります。1 つ目のセルはキャンバスの左下、右に移動すると X 軸が右に移動すると X 軸が上がるという規則を維持します。現在のレイアウトが中央にある、次のようなレイアウトが表示されます。

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

ここでの課題は、シェーダー内でメソッドを指定し、セル座標を与えられたこれらのセルのいずれかに正方形のジオメトリを配置できるようにすることです。

まず、正方形がキャンバスの中心を囲むように定義されているため、どのセルも適切に配置されていません。正方形が内部にうまく入るように、セルを 0.5 セル分移動させます。

これを解決する方法の 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 つのグリッドにつき 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);
}

今すぐ更新すると、次のように表示されます。

概念的には、セル(0, 0)、セル(0, 1)、セル(1, 0)、セル(1, 1)の中央に赤い正方形の付いた 4x4 のグリッドに分割されたキャンバスです。

少し時間を置いて期待どおりではない。

これは、キャンバスの座標が -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 つの正方形をレンダリングします。

アプローチのひとつとして、セル座標をユニフォーム バッファに書き込み、グリッド内の正方形ごとに 1 回描画して、毎回ユニフォームを更新するという方法があります。しかし、GPU は毎回 JavaScript によって新しい座標が書き込まれるまで待つ必要があるため、処理が非常に遅くなります。GPU のパフォーマンスを改善するための重要なポイントの 1 つは、システムの他の部分で待機する時間を最小限にすることです。

代わりに、インスタンス化という手法を使用できます。インスタンシングは、GPU に draw を 1 回呼び出すだけで同じジオメトリの複数のコピーを描画するよう指示し、コピーごとに 1 回 draw を呼び出すよりもはるかに高速です。ジオメトリの各コピーはインスタンスと呼ばれます。

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

index.html

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

これにより、正方形 16(GRID_SIZE * GRID_SIZE)の 6 つの頂点を描画するようシステムに伝えられます。vertices.length / 2ただし、ページを更新しても引き続き次のように表示されます。

前の図と同じ画像で、何も変更されていないことを示します。

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

シェーダーでは、頂点バッファに由来する pos などの頂点属性に加えて、WGSL の組み込み値と呼ばれるものにもアクセスできます。これらは WebGPU によって計算される値で、instance_index があります。instance_index は、0 から number of instances - 1 への 32 ビット符号なし数値であり、シェーダー ロジックの一部として使用できます。この値は、同じインスタンスに含まれる処理済みの頂点ごとに同じです。つまり、Vertex シェーダーは、頂点バッファ内の位置ごとに 1 回、0instance_index で 6 回呼び出されます。次に、instance_index1 としてさらに 6 回、次に 2instance_index として、さらに 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 グリッドに分割したもの。各セルは線形インスタンス インデックスにも対応しています。

その数式はかなり単純です。各セルの 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 の残りの部分の基礎を固めたので、この時点で次のセクションに簡単に移ることができます。しかし、同じ色を共有する正方形のグリッドは相性が良いとはいえ、それほど画期的ではありませんか?幸いなことに、もう少し数学とシェーダーのコードがあれば、物事を明るくすることができます。

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

これまでは、頂点シェーダーから 1 つのデータ、つまり変換された位置を渡していました。ただし、実際には、頂点シェーダーからより多くのデータを返し、それをフラグメント シェーダーで使用できます。

頂点シェーダーからデータを渡す唯一の方法は、データを返すことです。位置を返すには、常に頂点シェーダーが必要です。そのため、一緒に他のデータも返す場合は、構造体に格納する必要があります。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 ステージの出力構造体を再利用します。これにより、名前と場所が一致しているので、値が渡しやすくなります。

index.html(createShaderModule 呼び出し)

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

選択したパターンに関係なく、@fragment 関数のセル番号にアクセスして、色に影響を与えるために使用できます。上記のコードを使用すると、出力は次のようになります。

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

色は多様ですが、見栄えがよくありません。左右の行のみが異なるのはなぜかと思うかもしれません。これは、@fragment 関数から返された色の値が 0 ~ 1 の範囲内にあり、その範囲外の値がすべてクランプされるためです。一方、セルの値の範囲は 0 ~ 32 で、最初の行と列は、赤色または緑色のカラー チャンネルのいずれかで完全な 1 の値にすぐにヒットし、それ以降のすべてのセルは同じ値にクランプされることがわかります。

色間の遷移をスムーズにするには、各カラー チャネルの小数値を返す必要があります。最初はゼロから始まり、各軸に沿った 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 でコンピューティング シェーダー内の Game of Life シミュレーションを行いたいので、最も問題です。

幸いにも、このような制限をすべて回避する別のバッファ オプションがあります。

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

ストレージ バッファは、コンピューティング シェーダーで読み書きでき、頂点シェーダーで読み書きできる汎用バッファです。非常に大きくなる可能性があり、シェーダーで特定の宣言されたサイズが不要になるため、一般的なメモリと非常によく似ています。これがセルの状態を保存するために使用するものです。

  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 を維持したい一方で、@binding の数値は異なっている必要があります。var 型は異なるバッファ型を反映するために storage であり、1 つの Vector ではなく cellState として指定する型は u32 の配列であり、JavaScript の Uint32Array と一致します。

次に、@vertex 関数の本体で、セルの状態をクエリします。状態はストレージ バッファのフラット配列に格納されるため、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() と一致することを確認してください。

配置すると、更新して、グリッドにパターンが表示されます。

暗い背景に、左下から右上に伸びるカラフルな四角形のストライプ模様。

ping-pong バッファ パターンを使用する

構築するようなほとんどのシミュレーションでは、通常、少なくとも 2 つの状態を使用します。シミュレーションの各ステップで、状態の 1 つのコピーから読み取り、もう 1 つ状態に書き込みます。次に、次のステップでこれをめくって、前の書き込み状態から読み取ります。これは一般に「ピンポン」パターンと呼ばれます。というのも、最新バージョンの状態が各ステップ間で行き来するためです。

それはなぜ必要でしょうか。簡単な例を見てみましょう。アクティブなブロックをステップごとに 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 ステップで配列の終わりまで移動します。なぜでしょうか。状態を維持したまま更新するには、アクティブなセルを右に移動して次のセルを確認します。有効になりましたもう一度右に動かしてください。確認と同時にデータを変更すると、結果が破損します。

ピンポン パターンを使用すると、前のステップの結果のみを使用して、常にシミュレーションの次のステップを実行できます。

// 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 回)と同じ速さでコールバックのスケジュールを設定します。

このアプリでもそのように使用できますが、その場合は、シミュレーションの処理を簡単に実行できるように、更新を長いステップで行うことをおすすめします。代わりにループを管理し、シミュレーションを更新する頻度を制御できるようにします。

  1. まず、シミュレーションで更新するレートを 200 ms で選択します。ただし、必要に応じて低速にすることも、高速化することもできます。その後、完了したステップの数を追跡します。

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. シミュレーションを実行する

では、パズルの最後の主要な部分では、コンピューティング シェーダーで Game of Life シミュレーションを実行します。

最後に、シェーダーを使用します。

この Codelab ではコンピューティング シェーダーについて抽象的に学習しましたが、具体的には何でしょうか。

コンピューティング シェーダーは、Vertex シェーダーおよびフラグメント シェーダーと似たような点で GPU 上で極端に並列に動作するように設計されていますが、他の 2 つのシェーダー ステージとは異なり、特定の入力と出力のセットはありません。ストレージ バッファなど、選択したソースからのみデータの読み取りと書き込みを行います。つまり、頂点、インスタンス、ピクセルごとに 1 回ずつ実行するのではなく、必要なシェーダー関数の呼び出し回数を指示する必要があります。その後、シェーダーを実行するときに、どの呼び出しが処理中かが伝えられます。その後、どのデータにアクセスするか、そこからどのオペレーションを実行するかを決定できます。

Compute シェーダーは、頂点シェーダーやフラグメント シェーダーと同様にシェーダー モジュールで作成する必要があるので、まずそれをコードに追加します。ご想像のとおり、実装した他のシェーダーの構造を考慮すると、コンピューティング シェーダーのメイン関数を @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 軸に沿って特定の回数だけシェーダーを呼び出せるよう構成されています。これにより、2D または 3D グリッドに準拠する作業を簡単にディスパッチでき、ユースケースに優れています。シミュレーションのセルごとに 1 回、このシェーダーを GRID_SIZEGRID_SIZE 回呼び出す必要があります。

GPU ハードウェア アーキテクチャの性質上、このグリッドはワークグループに分割されます。ワークグループには X、Y、Z サイズがあり、それぞれ 1 つのサイズにできますが、多くの場合、ワークグループを大きくするとパフォーマンス上のメリットがあります。シェーダーには、8 x 8 のやや任意のワークグループ サイズを選択します。これは、JavaScript コードをトラッキングするのに便利です。

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

index.html

const WORKGROUP_SIZE = 8;

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

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

index.html(Compute createShaderModule 呼び出し)

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

}

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

他のシェーダー ステージと同様に、Compute Engine 関数への入力として受け入れられるさまざまな @builtin 値により、どの呼び出しが行われているかを特定し、必要な処理を決定できます。

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

index.html(Compute createShaderModule 呼び出し)

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

}

global_invocation_id ビルトインを渡します。これは、シェーダー呼び出しのグリッドのどこにあるかを示す符号なし整数の 3 次元ベクトルです。このシェーダーをグリッド内のセルごとに 1 回実行します。(0, 0, 0)(1, 0, 0)(1, 1, 0) などの数値を (31, 31, 0) まで取得します。つまり、これをセルインデックスとして扱うことができます。

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

  1. 計算シェーダーでユニフォームを使用すると、次のようにグリッドサイズを確認できます。

index.html(Compute 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 つが必要です。コンピューティング シェーダーには、頂点の位置やフラグメントの色などの必要な出力がないため、ストレージ バッファまたはテクスチャに値を書き込むことが、コンピューティング シェーダーから結果を得る唯一の方法です。先ほど学習した ping-pong の方法を使用します。つまり、グリッドの現在の状態をフィードするストレージ バッファと、グリッドの新しい状態を書き込むストレージ バッファがあります。

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

index.html(Compute 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 には書き込み専用ストレージ モードはありません)。

次に、セル インデックスを線形ストレージ配列にマッピングする方法が必要です。これは基本的に、頂点シェーダーで行ったのとは逆で、線形 instance_index を取得して、2D グリッドセルにマッピングしました。(そのアルゴリズムは vec2f(i % grid.x, floor(i / grid.x)) でした)。

  1. 逆方向に進む関数を記述します。セルの Y 値にグリッドの幅を乗算し、その X 値を加算します。

index.html(Compute 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(Compute 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 つのストレージ バッファを使用しますが、ここで作成したコンピューティング シェーダーには 2 つ目のストレージ バッファが必要です。2 つのシェーダーはユニフォームと最初のストレージ バッファに同じ @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 値と一致します。また、リソースを使用できるシェーダー ステージを示す GPUShaderStage フラグである visibility も指定します。ユニフォームと最初のストレージ バッファはどちらも頂点シェーダーとコンピューティング シェーダーでアクセスできるようにする必要がありますが、2 つ目のストレージ バッファはコンピューティング シェーダーでのみアクセスできるようにする必要があります。これらのフラグを使用してフラグメント シェーダーからリソースにアクセスできるようにすることもできますが、そうする必要はありません。

最後に、使用するリソースのタイプを指定します。これは、公開対象に応じて、異なる辞書キーです。この例では、3 つのリソースはすべてバッファです。そのため、buffer キーを使用して、それぞれのオプションを定義します。ほかにも、texturesampler などのオプションがありますが、ここでは必要ありません。

バッファ ディクショナリでは、使用される 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 = computeEncoder.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 = computeEncoder.beginComputePass();

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

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

index.html

const computePass = computeEncoder.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 で定義されたワークグループの数となります。

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

ページを再度表示すると、更新のたびにグリッドが反転していることがわかります。

暗い背景に、左下から右上に伸びるカラフルな四角形のストライプ模様。 背景が濃い青色で、左下から右上にカラフルな正方形が 2 つ並んだ斜めの縞模様。前の画像の反転

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

最終的なアルゴリズムを実装するように Compute シェーダーを更新する前に、ストレージ バッファ コンテンツを初期化しているコードに戻り、ページ読み込みごとにランダムなバッファを生成するようにコードを更新します。(通常のゲームパターンは、ゲーム オブ ライフの面白い出発点にはなりません)。値は任意にランダム化できますが、開始して簡単に妥当な結果を得る方法があります。

  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);

これで、Game of Life シミュレーションのロジックを実装できるようになりました。ここまでの作業をすべて終えると、シェーダー コードは簡単だったはずです。

まず、任意のセルについて、どの近隣セルがアクティブであるかを把握する必要があります。どちらがアクティブなかは重要ではなく、カウントのみが重要になります。

  1. 隣接するセルデータを簡単に取得するには、指定された座標の cellStateIn 値を返す cellActive 関数を追加します。

index.html(Compute createShaderModule 呼び出し)

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

cellActive 関数は、セルがアクティブな場合は 1 を返すため、周辺の 8 つのセルすべてに対して cellActive を呼び出すことで戻り値がアクティブになり、隣接するセルの数がアクティブになります。

  1. 次のようにアクティブな近隣の数を探します。

index.html(Compute 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(Compute 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 つある非アクティブ セルがすべてアクティブになります。
  • 3 つ以上のネイバーを含むセルはすべて無効になります。

一連の if ステートメントを使用してこの処理を行うことができますが、WGSL は Switch ステートメントをサポートしているため、このロジックに適しています。

  1. 次のように Game of Life ロジックを実装します。

index.html(Compute 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;
  }
}

参考までに、最終的な Compute シェーダー モジュール呼び出しは次のようになります。

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;
        }
      }
    }
  `
});

手順は以上です。これで完了です。ページを更新して、新しく作成したモバイルオートマトンの成長をご確認ください。

濃い青色の背景にレンダリングされたカラフルなセルが表示された、Game of Life シミュレーションの状態の例のスクリーンショット。

9. 完了

WebGPU API を使用して、GPU 上で完全に動作する従来の Conway の Game of Life シミュレーション バージョンを作成しました。

次のステップ

参考資料

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