TypeGPUでGPUシェーダーをTypeScriptで記述する
TypeGPUでTypeScriptのGPUシェーダーを作成。型付きバッファを設定して計算シェーダーを実行し、バインドの不整合をコンパイル時に検出します。
TypeGPUは、WebGPUを型付きプリミティブでラップするTypeScriptライブラリです。バッファスキーマ、バインドグループレイアウト、GPU関数のチェックをTypeScriptコンパイラが行うため、これらのミスがGPU上の実行時エラーになることを防げます。
WebGPUのチュートリアルを試したことがあれば、よくある状況をご存じでしょう。JavaScript側の型チェックは問題なく通るのに、WGSL文字列内の構造体フィールドがfloat 1つ分ずれていて、エラーも出ずに意味のないデータが入ったバッファだけが返ってきます。本ガイドは、WebGPUの基本をすでに理解している読者を想定しています。まだの方は、UIにWebGPUエフェクトを追加する方法とWebGPUとWebGLの違いから始めてください。本記事では、TypeGPUのセットアップ、型付きバッファの作成、コンピュートシェーダーを最初から最後まで実行する手順を解説し、素のWebGPUでは得られない型エラーを実際に確認します。
重要なポイント
- TypeGPUシェーダーをTypeScript関数として記述するには、関数に
'use gpu'を付け、ビルドプラグインunplugin-typegpuを追加します。このプラグインは、それ以外の用途ではライブラリのオプション扱いです。 - TypeGPUのバッファはすべて、
d.arrayOf(d.u32, 16)のようなスキーマから作成されます。スキーマによって、バッファのバイトサイズ、JavaScriptの値をバイナリに変換する方法、.write()が受け付ける値が決まります。 - WGSLの配列では、
vec3fの各要素が16バイトを占有します。TypeGPUは通常の配列やオブジェクトには自動でパディングを挿入しますが、Float32Arrayはバイト単位でそのままコピーするため、パディングを自分で追加する必要があります。 - スキーマやusageがレイアウトと一致しないバッファを
root.createBindGroupに渡すと、TypeScriptがコンパイル時にエラーとして検出します。 - TypeGPUは1.0未満(0.12.x系)であり、マイナーリリースで非推奨APIが削除されたことがあります。
WebGPUのバグの多くがWGSLとJS-GPU間の境界で発生する理由
WebGPUのバグの多くは、シェーダーのロジックではなく境界部分で発生します。WebGPUのシェーダーはWGSLで記述されますが、これは別言語であり、TypeScriptのツールからは中身の見えない単なる文字列として扱われます。バッファのバイトレイアウト、数値によるバインディングインデックス、各バインドグループとシェーダー側の宣言との対応関係は、すべて手作業で整合性を保つ必要があります。どれか1つでも間違えると、多くの場合コンパイラエラーにはならず、誤ったデータが黙って生成されます。
TypeGPUは、この両側をTypeScriptにまとめます。1つのスキーマがCPU側とGPU側のデータを記述し、シェーダー関数もTypeScriptで記述します。
ViteでTypeGPUをセットアップする
ランタイムをインストールし、続いてビルドプラグインとWebGPUの型定義をdev dependenciesとしてインストールします。
npm install typegpu
npm install --save-dev unplugin-typegpu @webgpu/types
tsconfig.jsonにWebGPUの型を登録します。GPUDeviceなどのWebGPU型をTypeScriptが認識しない場合、Getting Startedガイドのトラブルシューティングセクションでもこの設定が案内されています。
{ "compilerOptions": { "types": ["@webgpu/types"] } }
unplugin-typegpuは、TypeGPU全体としてはオプションです。シェーダーを'use gpu'付きのTypeScript関数として記述する場合に必要になります。unpluginをベースにしているため、Vite以外にもesbuild、Farm、Rolldown、Rollup、Rspack、webpack向けのバージョンが提供されており、React Native向けには別途Babelプラグインがあります。
// vite.config.ts
import { defineConfig } from 'vite';
import typegpu from 'unplugin-typegpu/vite';
export default defineConfig({
plugins: [typegpu()],
});
実行時には、await tgpu.init()でrootオブジェクトを取得します。以降のバッファ、バインドグループ、パイプラインはすべてこのオブジェクトから作成します。
型付きバッファとスキーマ
TypeGPUのバッファは、必ずd.*コンストラクタで構築したスキーマから作成されます。TypeGPUのバッファリファレンスで説明されているように、TypeGPUはスキーマを使って、バッファに必要なサイズ、JavaScriptの値とバイト列の相互変換方法、.write()メソッドと.read()メソッドが扱う型を決定します。
import { tgpu, d } from 'typegpu';
const root = await tgpu.init();
const Point = d.struct({
position: d.vec3f,
weight: d.f32,
});
const points = root
.createBuffer(d.arrayOf(Point, 2), [
{ position: d.vec3f(0, 0, 0), weight: 1 },
{ position: d.vec3f(1, 2, 3), weight: 0.5 },
])
.$usage('storage');
points.write([
{ position: d.vec3f(4, 5, 6), weight: 2 },
{ position: d.vec3f(7, 8, 9), weight: 3 },
]);
console.log(await points.read());
.$usage('storage')はWebGPUのusageフラグを設定すると同時に、そのフラグをバッファの型にも記録します。バッファがマップ不可能な場合、.read()は一時的なステージングバッファを介して内容をコピーするため、mapAsyncを自分で書く必要はありません。
WGSLのアライメント規則により、vec3fの配列要素は16バイト(データ12バイト+パディング4バイト)を占有します。.write()に通常の配列やオブジェクトを渡した場合は、TypeGPUがこのパディングを自動で挿入します。一方、Float32Arrayを渡した場合、.write()はそのバイト列をそのままコピーするため、配列にあらかじめパディングを含めておく必要があります。
const dirs = root.createBuffer(d.arrayOf(d.vec3f, 2)).$usage('storage');
dirs.write([[1, 0, 0], [0, 1, 0]]); // padded for you
dirs.write(new Float32Array([1, 0, 0, 0, 0, 1, 0, 0])); // every 4th float is padding
コンピュートシェーダーを最初から最後まで実行する
以下は、8個の整数からなる配列をGPU上で2倍にし、結果を読み戻す完全なTypeGPUプログラムです。root.createGuardedComputePipelineに渡した'use gpu'関数は、スレッドインデックスを受け取ります。dispatchThreads(n)を呼び出すとn個の論理スレッドが実行され、インデックスは0からn − 1までの各値をちょうど1回ずつ取ります。
import { tgpu, d } from 'typegpu';
const root = await tgpu.init();
const N = 8;
const values = root
.createBuffer(d.arrayOf(d.u32, N), [1, 2, 3, 4, 5, 6, 7, 8])
.$usage('storage');
const layout = tgpu.bindGroupLayout({
values: { storage: d.arrayOf(d.u32, N), access: 'mutable' },
});
const bindGroup = root.createBindGroup(layout, { values });
const double = root.createGuardedComputePipeline((x) => {
'use gpu';
if (x >= N) {
return;
}
layout.$.values[x] = layout.$.values[x] + layout.$.values[x];
});
double.with(bindGroup).dispatchThreads(N);
console.log(await values.read()); // [2, 4, 6, 8, 10, 12, 14, 16]
レイアウトはシェーダーが期待するものを宣言し、バインドグループは実際のバッファを提供します。ワークグループ数を計算する必要も、ステージングバッファを書く必要もありません。バッファを差し替える必要がなければ、より簡潔な固定リソースの代替手段としてroot.createMutable(schema)を使えます。シェーダーからは.$を通じて読み取り、結果は.read()で読み戻します。
バインドグループレイアウトとコンパイル時の型安全性
バインドグループのコンパイル時チェックは、TypeGPUが素のWebGPUと最も大きく異なる点です。バインドグループレイアウトはシェーダーが期待するリソースを記述し、バインドグループは具体的なバッファを提供します。TypeGPUでは、スキーマやusageフラグが誤っているバッファは、バインドグループ作成時に型チェックで弾かれます。この挙動はバインドグループガイドに記載されています。
ストレージエントリはデフォルトでreadonlyです。上記のレイアウトでaccess: 'mutable'を指定しているのはそのためです。シェーダーは特定のバッファではなくlayout.$を参照しているので、1つのパイプラインを異なるデータに対して実行できます。
const more = root
.createBuffer(d.arrayOf(d.u32, N), [10, 20, 30, 40, 50, 60, 70, 80])
.$usage('storage');
const bindGroupB = root.createBindGroup(layout, { values: more });
double.with(bindGroupB).dispatchThreads(N); // same shader, new data
const floats = root.createBuffer(d.arrayOf(d.f32, N)).$usage('storage');
root.createBindGroup(layout, {
values: floats, // type error: d.f32 elements don't match the d.u32 layout entry
});
バインドグループはイミュータブルです。リソースの組み合わせごとに1つ作成し、組み合わせが少数かつ固定であればキャッシュしておきましょう。素のWebGPUでは、WGSLが何を宣言しているかをコンパイラは把握できないため、シェーダーがu32を期待している箇所にfloatバッファをバインドしてもコンパイルは問題なく通り、誤った数値が黙って生成されます。
同じシェーダーのWGSL版とTypeGPU版の比較
TypeGPUのシェーダーと、手書きのWGSLによる同等のシェーダーは、サイズにほとんど差がありません。違いはその周辺のホストコードにあります。2倍にするシェーダーを手書きのWGSLで記述すると次のようになります。
@group(0) @binding(0) var<storage, read_write> values: array<u32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
if (id.x >= arrayLength(&values)) {
return;
}
values[id.x] = values[id.x] * 2u;
}
そして、先ほどの例のTypeGPU版は次のとおりです。
const double = root.createGuardedComputePipeline((x) => {
'use gpu';
if (x >= N) {
return;
}
layout.$.values[x] = layout.$.values[x] + layout.$.values[x];
});
WGSL版を素のWebGPUで実行するには、以下のホストコードをすべて手書きする必要があります。
- WGSLソース文字列を渡す
device.createShaderModule device.createComputePipeline@binding(0)と一致させる必要がある、数値のbindingインデックスを持つバインドグループエントリdispatchWorkgroupsのためのワークグループ数の計算- 結果を読み戻すための
MAP_READステージングバッファ、コピーコマンド、mapAsync
TypeGPUでは、レイアウトオブジェクトが数値インデックスに取って代わり、.read()がステージングを処理します。
制限事項、エスケープハッチ、ブラウザサポート
TypeGPUは1.0未満です。0.12リリースラインは2026年8月12日にリリースされましたが、マイナーバージョンで非推奨APIが削除されたことがあるため、アップグレード前に0.12マイグレーションガイドおよびそれ以降のガイドを確認してください。このライブラリはMITライセンスで、Software Mansionによって開発されています。
必要に応じて素のWebGPUに切り替える。 既存のGPUBufferをroot.createBuffer(schema, existingBuffer)でラップすると、型付きの読み書きが可能になります。その場合、バッファのライフサイクルは自分で管理し、内容がスキーマと一致していることを保証する必要があります。また、.write()を呼び出すにはCOPY_DSTも必要です。
const raw = root.device.createBuffer({
size: 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const counter = root.createBuffer(d.u32, raw);
counter.write(42);
インデックスをガードする。 WebGPUは範囲外アクセスがバインディング外のメモリに到達することは防ぎますが、その結果が正しくなるわけではありません。ディスパッチサイズが配列より大きくなる可能性がある場合は、必ずインデックスをガードしてください。
スレッド間の読み取りを避ける。 GPUスレッドの実行順序は保証されていません。同じディスパッチ内で、あるスレッドが書き込む要素を別のスレッドが読み取ると、結果は非決定的になります。
// Nondeterministic: thread x reads index x + 1 while thread x + 1 may be writing it
const smear = root.createGuardedComputePipeline((x) => {
'use gpu';
if (x + 1 >= N) {
return;
}
layout.$.values[x] = layout.$.values[x] + layout.$.values[x + 1];
});
また、WebGPUでは1つの軸あたりにディスパッチできるワークグループ数に上限があります。大きなグリッドを扱う場合は、処理をxとyに分割してください。
ブラウザサポート。 TypeGPUはWebGPUが動作する環境であればどこでも動作します。ターゲットとするユーザー層については、caniuseで現在のWebGPU対応状況を確認してください。
使うべきでない場面を把握する。 TypeGPUの公式ドキュメントでは、インタラクティブな3Dモデルをページに配置する用途にはレンダリングフレームワークを推奨しています。そのような用途には、Three.jsによる3Dアニメーションの構築のほうが適しています。
まとめ
TypeGPUは、WebGPUの中でも最もバグの追跡が難しい部分、つまりJavaScriptのデータとシェーダーメモリの境界を、両側を1つのTypeScriptスキーマで記述し、すべてのバインディングを型チェックすることで解決します。まずはViteプロジェクトにunplugin-typegpuを追加し、2倍にする例を実行してみてください。次に、不一致のバッファをcreateBindGroupに渡し、コンパイラがそれを検出する様子を確認してみましょう。
よくある質問
既存のWGSLシェーダーをTypeGPUで使えますか?
はい。tgpu-gen CLIは既存のWGSLシェーダーファイルを読み込み、対応するTypeGPU定義を含むTypeScriptファイルを生成します。globパターンに対応しており、シェーダーの変更時に定義を再生成するwatchモードも備えています。また、TypeGPUの関数シェルは実装としてWGSL文字列とJavaScript関数のどちらも受け付けるため、コードベースを関数単位で段階的に移行できます。
'use gpu'関数はCPU上でも実行できますか?
はい。'use gpu'が付いた関数も通常のTypeScript関数であるため、JavaScriptから直接呼び出して結果を得ることができます。また、tgpu.resolveに渡してWGSLを生成したり、パイプライン経由でGPU上で実行したりすることも可能です。これにより、シェーダーのヘルパーロジックを、GPU上で実行する前に普段使っているJavaScriptのテストランナーでユニットテストできます。
TypeGPUから生成されたWGSLを取り出すにはどうすればよいですか?
'use gpu'関数やスキーマなど、必要なTypeGPUリソースを指定してtgpu.resolveを呼び出します。推移的な依存関係をすべて含んだ、そのまま使えるWGSL文字列が返されます。これは、別のWebGPUエンジンやマテリアルシステムが生のWGSLを必要とする場合に便利です。TypeGPUのパイプラインを使用する場合は、内部でシェーダーの解決とコンパイルが行われるため、resolveを自分で呼び出す必要はありません。
TypeGPUはReact Nativeで動作しますか?
はい。TypeGPUはreact-native-webgpuパッケージを通じてReact Native上で動作します。'use gpu'シェーダーを記述するには、babel.config.jsのpluginsリストにunplugin-typegpu/babelを追加し、必要に応じてMetroのキャッシュをクリアしてください。React Native WebGPUはExpo Goでは動作しないため、Expoプロジェクトではexpo prebuildが必要です。また、iOSシミュレーターではXcodeでMetal API Validationを無効にする必要があります。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k