12k
All articles

É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.

OpenReplay Team
OpenReplay Team
Écrire des shaders GPU en TypeScript avec TypeGPU

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 build unplugin-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 vec3f occupe 16 octets. TypeGPU ajoute automatiquement le padding pour les tableaux et objets simples, mais copie un Float32Array octet par octet. Dans ce cas, vous devez ajouter le padding vous-même.
  • Si vous passez à root.createBindGroup un 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.createShaderModule avec la chaîne source WGSL
  • device.createComputePipeline
  • des entrées de bind group avec des indices binding numériques qui doivent correspondre à @binding(0)
  • un calcul du nombre de workgroups pour dispatchWorkgroups
  • un staging buffer MAP_READ, une commande de copie et mapAsync pour 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.

DevTools for the frontend

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

We use cookies to improve your experience. By using our site, you accept cookies.