Écrire des shaders GPU en TypeScript avec TypeGPU
Créez des shaders GPU en TypeScript avec TypeGPU. Configurez des buffers typés, exécutez un shader de calcul et détectez les incompatibilités à la compilation.
TypeGPU est une bibliothèque TypeScript qui encapsule WebGPU dans des primitives typées. Le compilateur TypeScript vérifie ainsi vos schémas de buffers, vos layouts de bind groups et vos fonctions GPU, au lieu de laisser ces erreurs se produire à l’exécution sur le GPU.
Si vous avez déjà suivi un tutoriel WebGPU, vous connaissez le scénario. Côté JavaScript, la vérification de types passe sans problème. Puis le champ d’une struct dans la chaîne WGSL se retrouve décalé d’un float, et vous récupérez un buffer rempli de données incohérentes, sans la moindre erreur. Ce guide suppose que vous savez déjà ce qu’est WebGPU. Si ce n’est pas le cas, commencez par ajouter des effets WebGPU à une interface utilisateur et par les différences entre WebGPU et WebGL. Ici, vous allez configurer TypeGPU, créer des buffers typés et exécuter un compute shader de bout en bout. Vous verrez aussi l’erreur de type que WebGPU brut est incapable de vous signaler.
Points clés à retenir
- Pour écrire un shader TypeGPU sous forme de fonction TypeScript, vous la marquez avec
'use gpu'et ajoutez le plugin de buildunplugin-typegpu. Ce plugin reste par ailleurs une partie optionnelle de la bibliothèque. - Chaque buffer TypeGPU est créé à partir d’un schéma tel que
d.arrayOf(d.u32, 16). Le schéma détermine la taille du buffer en octets, la conversion des valeurs JavaScript en binaire et les valeurs acceptées par.write(). - Dans un tableau WGSL, chaque élément
vec3foccupe 16 octets. TypeGPU ajoute automatiquement le padding pour les tableaux et objets simples, mais copie unFloat32Arrayoctet par octet. Dans ce cas, vous devez ajouter le padding vous-même. - Si vous passez à
root.createBindGroupun buffer dont le schéma ou l’usage ne correspond pas au layout, TypeScript le rejette dès la compilation. - TypeGPU n’a pas encore atteint la version 1.0 (branche 0.12.x), et certaines versions mineures ont supprimé des API dépréciées.
Pourquoi WGSL et la frontière JS-GPU causent la plupart des bugs WebGPU
La plupart des bugs WebGPU ne viennent pas de la logique des shaders. Ils surviennent à la frontière entre les deux mondes. Les shaders WebGPU sont écrits en WGSL, un langage distinct que votre outillage TypeScript traite comme une simple chaîne opaque. Plusieurs éléments doivent être synchronisés à la main : la disposition en octets de vos buffers, les indices de binding numériques et la correspondance entre chaque bind group et ce que déclare le shader. Une seule erreur suffit, et vous obtenez généralement des données silencieusement erronées plutôt qu’une erreur de compilation.
TypeGPU place les deux côtés en TypeScript. Un seul schéma décrit les données côté CPU et côté GPU, et les fonctions de shader s’écrivent elles aussi en TypeScript.
Configurer TypeGPU avec Vite
Installez le runtime, puis le plugin de build et les définitions de types WebGPU en tant que dépendances de développement :
npm install typegpu
npm install --save-dev unplugin-typegpu @webgpu/types
Déclarez les types WebGPU dans tsconfig.json. Si TypeScript ne reconnaît pas les types WebGPU comme GPUDevice, la section de dépannage du guide de démarrage vous renvoie vers ce paramètre.
{ "compilerOptions": { "types": ["@webgpu/types"] } }
unplugin-typegpu est optionnel pour TypeGPU dans son ensemble. Il devient nécessaire dès que vous voulez écrire vos shaders sous forme de fonctions TypeScript 'use gpu'. Comme il repose sur unplugin, il est disponible pour Vite, mais aussi pour esbuild, Farm, Rolldown, Rollup, Rspack et webpack. Un plugin Babel distinct est fourni pour React Native.
// vite.config.ts
import { defineConfig } from 'vite';
import typegpu from 'unplugin-typegpu/vite';
export default defineConfig({
plugins: [typegpu()],
});
À l’exécution, await tgpu.init() vous fournit un objet root. Tous les buffers, bind groups et pipelines présentés ci-dessous sont créés à partir de cet objet.
Buffers typés et schémas
Un buffer TypeGPU provient toujours d’un schéma construit avec les constructeurs d.*. Comme l’explique la référence sur les buffers TypeGPU, TypeGPU s’appuie sur le schéma pour trois choses. Il calcule la taille nécessaire du buffer. Il convertit les valeurs JavaScript en octets, et inversement. Il détermine aussi les types acceptés par les méthodes .write() et .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') définit le flag d’usage WebGPU et l’inscrit également dans le type du buffer. Lorsqu’un buffer ne peut pas être mappé, .read() copie son contenu via un staging buffer temporaire. Vous n’avez donc jamais à écrire mapAsync vous-même.
Les règles d’alignement de WGSL attribuent 16 octets à chaque élément d’un tableau de vec3f : 12 octets de données et 4 octets de padding. Si vous passez des tableaux ou des objets simples à .write(), TypeGPU insère ce padding pour vous. En revanche, si vous passez un Float32Array, .write() copie ses octets tels quels. Le tableau doit donc déjà contenir le padding :
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
Un compute shader de bout en bout
Le programme TypeGPU complet ci-dessous double un tableau de huit entiers sur le GPU, puis relit le résultat. Une fonction 'use gpu' passée à root.createGuardedComputePipeline reçoit un indice de thread. L’appel à dispatchThreads(n) exécute n threads logiques, et l’indice prend chaque valeur de 0 à n − 1 exactement une fois.
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]
Le layout déclare ce qu’attend le shader, et le bind group fournit le buffer réel. Vous n’avez jamais à calculer un nombre de workgroups ni à écrire de staging buffer. Si vous n’avez pas besoin d’interchanger les buffers, root.createMutable(schema) offre une alternative plus concise, avec une ressource fixe. Le shader y accède via .$, et vous relisez les résultats avec .read().
Layouts de bind groups et sûreté de typage à la compilation
C’est sur la vérification des bind groups à la compilation que TypeGPU se distingue le plus de WebGPU brut. Un layout de bind group décrit les ressources qu’attend un shader, tandis qu’un bind group fournit les buffers concrets. Avec TypeGPU, un buffer dont le schéma ou les flags d’usage sont incorrects échoue à la vérification de types dès la création du bind group. Ce comportement est documenté dans le guide des bind groups.
Les entrées de stockage sont en lecture seule par défaut, d’où la présence de access: 'mutable' dans le layout ci-dessus. Comme le shader fait référence à layout.$ et non à un buffer précis, un même pipeline peut s’exécuter sur des données différentes :
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
});
Les bind groups sont immuables. Créez-en un par combinaison de ressources, et mettez-les en cache si vous en avez un ensemble restreint et fixe. Avec WebGPU brut, le compilateur ignore tout de ce que déclare le WGSL. Lier un buffer de floats là où le shader attend des u32 compile alors sans erreur et produit silencieusement des valeurs fausses.
Le même shader en WGSL et en TypeGPU
Un shader TypeGPU et son équivalent WGSL écrit à la main ont à peu près la même taille. La différence se situe dans le code hôte qui les entoure. Voici la version WGSL écrite à la main du shader de doublement :
@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;
}
Et voici la version TypeGPU de l’exemple précédent :
const double = root.createGuardedComputePipeline((x) => {
'use gpu';
if (x >= N) {
return;
}
layout.$.values[x] = layout.$.values[x] + layout.$.values[x];
});
Pour exécuter la version WGSL avec WebGPU brut, vous devez écrire à la main tout le code hôte suivant :
device.createShaderModuleavec la chaîne source WGSLdevice.createComputePipeline- des entrées de bind group avec des indices
bindingnumériques qui doivent correspondre à@binding(0) - un calcul du nombre de workgroups pour
dispatchWorkgroups - un staging buffer
MAP_READ, une commande de copie etmapAsyncpour relire les résultats
Avec TypeGPU, l’objet layout remplace les indices numériques, et .read() gère le staging.
Limites, solutions de repli et compatibilité navigateur
TypeGPU n’a pas encore atteint la version 1.0. La branche 0.12 est sortie le 12 août 2026, et certaines versions mineures ont supprimé des API dépréciées. Consultez donc le guide de migration 0.12 et les guides ultérieurs avant toute mise à jour. La bibliothèque est sous licence MIT et développée par Software Mansion.
Revenez à WebGPU brut si nécessaire. Vous pouvez encapsuler un GPUBuffer existant avec root.createBuffer(schema, existingBuffer) pour bénéficier de lectures et d’écritures typées. Le cycle de vie de ce buffer vous incombe alors, et vous devez vous assurer que son contenu correspond au schéma. Pour pouvoir appeler .write(), il doit aussi disposer de 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);
Protégez vos indices. WebGPU empêche les accès hors limites d’atteindre la mémoire située en dehors du binding, mais ne les rend pas corrects pour autant. Vérifiez l’indice dès que la taille du dispatch peut dépasser celle du tableau.
Évitez les lectures entre threads. Les threads GPU s’exécutent sans ordre garanti. Si un thread lit un élément qu’un autre thread écrit au cours du même dispatch, le résultat est non déterministe :
// 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 limite également le nombre de workgroups que vous pouvez dispatcher sur un même axe. Pour les grilles de grande taille, répartissez le travail sur x et y.
Compatibilité navigateur. TypeGPU fonctionne partout où WebGPU est disponible. Consultez la couverture actuelle de WebGPU sur caniuse pour votre public cible.
Sachez quand ne pas l’utiliser. La documentation de TypeGPU elle-même recommande un framework de rendu pour afficher un modèle 3D interactif sur une page. Pour ce type de besoin, créer des animations 3D avec Three.js est plus adapté.
Conclusion
TypeGPU corrige la partie de WebGPU où les bugs sont les plus difficiles à traquer : la frontière entre les données JavaScript et la mémoire du shader. Il décrit les deux côtés avec un seul schéma TypeScript et vérifie le typage de chaque binding. Commencez par ajouter unplugin-typegpu à un projet Vite et exécutez l’exemple de doublement. Essayez ensuite de passer un buffer incompatible à createBindGroup pour voir le compilateur détecter l’erreur.
FAQ
Puis-je utiliser mes shaders WGSL existants avec TypeGPU ?
Oui. La CLI tgpu-gen lit les fichiers de shaders WGSL existants et génère des fichiers TypeScript contenant les définitions TypeGPU correspondantes. Elle prend en charge les motifs glob ainsi qu'un mode watch qui régénère les définitions lorsque les shaders sont modifiés. Les shells de fonctions TypeGPU acceptent par ailleurs leur implémentation sous forme de chaîne WGSL ou de fonction JavaScript. Vous pouvez donc migrer une base de code fonction par fonction.
Une fonction 'use gpu' peut-elle aussi s'exécuter sur le CPU ?
Oui. Une fonction marquée avec 'use gpu' reste une fonction TypeScript classique. Vous pouvez donc l'appeler directement en JavaScript et obtenir un résultat. Vous pouvez aussi la passer à tgpu.resolve pour générer du WGSL, ou l'exécuter sur le GPU via un pipeline. Cela vous permet de tester unitairement la logique auxiliaire de vos shaders avec votre test runner JavaScript habituel, avant de l'exécuter sur le GPU.
Comment récupérer le WGSL généré par TypeGPU ?
Appelez tgpu.resolve avec les ressources TypeGPU dont vous avez besoin, comme des fonctions 'use gpu' et des schémas. La fonction renvoie une chaîne WGSL prête à l'emploi qui inclut toutes leurs dépendances transitives. C'est utile lorsqu'un autre moteur WebGPU ou un système de matériaux attend du WGSL brut. Vous n'avez pas besoin d'appeler resolve vous-même lorsque vous utilisez les pipelines TypeGPU, car ils résolvent et compilent le shader en interne.
TypeGPU fonctionne-t-il avec React Native ?
Oui. TypeGPU fonctionne dans React Native grâce au package react-native-webgpu. Pour écrire des shaders 'use gpu', ajoutez unplugin-typegpu/babel à la liste des plugins dans babel.config.js, puis videz le cache Metro si nécessaire. React Native WebGPU ne fonctionne pas dans Expo Go : un projet Expo nécessite donc expo prebuild. Sur le simulateur iOS, vous devez également désactiver Metal API Validation dans Xcode.
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