Votre première application WebGPU

1. Introduction

Le logo WebGPU est constitué de plusieurs triangles bleus formant un "W" stylisé

Dernière mise à jour : 28/08/2023

Qu'est-ce que WebGPU ?

WebGPU est une nouvelle API moderne qui permet d'accéder aux capacités de votre GPU dans les applications Web.

API moderne

Avant WebGPU, nous pouvions utiliser WebGL, qui proposait un sous-ensemble des fonctionnalités de WebGPU. Cette API a permis de créer une nouvelle catégorie de contenus Web enrichis, et les développeurs ont réalisé des choses étonnantes grâce à elle. Elle était toutefois basée sur l'API OpenGL ES 2.0, publiée en 2007, elle-même basée sur l'API OpenGL, encore plus ancienne. Les GPU ont considérablement évolué pendant cette période, et les API natives utilisées pour interagir avec eux ont également bien progressé avec l'arrivée de Direct3D 12, Metal et Vulkan.

WebGPU intègre les avancées de ces API modernes dans la plate-forme Web. Il se concentre sur l'accessibilité des fonctionnalités de GPU sur plusieurs plates-formes tout en présentant une API qui s'intègre naturellement au Web, avec une syntaxe allégée par rapport à certaines API natives sur lesquelles il s'appuie.

Rendu

Les GPU sont souvent associés à un rendu graphique rapide et détaillé, et WebGPU ne fait pas exception. Il est doté des fonctionnalités nécessaires pour prendre en charge la plupart des techniques courantes de rendu sur GPU pour ordinateur et mobile, et offre la possibilité d'ajouter de nouvelles fonctionnalités à mesure que les capacités matérielles évoluent.

Calcul

Outre le rendu, WebGPU vous permet d'exploiter tout le potentiel de votre GPU pour exécuter des charges de travail hautement parallèles à usage général. Ses nuanceurs de calcul peuvent être utilisés de manière autonome, sans composant de rendu, ou en tant que partie étroitement intégrée de votre pipeline de rendu.

Dans cet atelier de programmation, vous allez apprendre à exploiter les fonctionnalités de rendu et de calcul de WebGPU pour créer un projet d'introduction simple.

Objectifs de l'atelier

Dans cet atelier de programmation, vous allez créer le Jeu de la vie de Conway à l'aide de WebGPU. Cette application pourra :

  • utiliser les fonctionnalités de rendu de WebGPU pour dessiner des graphismes 2D simples ;
  • utiliser les fonctionnalités de calcul de WebGPU pour effectuer la simulation.

Capture d'écran du produit final de cet atelier de programmation

Le Jeu de la vie est ce que l'on appelle un automate cellulaire, dans lequel une grille de cellules change d'état au fil du temps en fonction d'un ensemble de règles. Dans le Jeu de la vie, les cellules deviennent actives ou inactives en fonction du nombre de cellules voisines actives, ce qui génère des tendances intéressantes qui fluctuent au fil de l'observation.

Points abordés

  • Configurer WebGPU et un canevas
  • Dessiner une forme géométrique simple en 2D
  • Utiliser les nuanceurs de sommets et de fragments pour modifier le dessin
  • Utiliser les nuanceurs de calcul pour effectuer une simulation simple

Cet atelier de programmation présente les concepts fondamentaux qui sous-tendent WebGPU. Il ne s'agit pas d'un examen complet de l'API. Les sujets souvent associés comme les mathématiques 3D (calcul matriciel) ne sont pas traités, et il n'est pas nécessaire de posséder des connaissances en la matière.

Ce dont vous aurez besoin

  • Une version récente de Chrome (113 ou ultérieure) sous ChromeOS, macOS ou Windows. WebGPU est une API multiplate-forme et multinavigateur, mais n'est pas disponible partout.
  • Connaissance des langages HTML et JavaScript, ainsi que des outils pour les développeurs Chrome

Il n'est pas nécessaire de connaître d'autres API graphiques telles que WebGL, Metal, Vulkan ou Direct3D. Toutefois, si vous en avez quelques notions, vous remarquerez probablement de nombreuses similitudes avec WebGPU, et cela peut vous aider dans votre apprentissage.

2. Configuration

Obtenir le code

Cet atelier de programmation ne comporte aucune dépendance et vous présente toutes les étapes nécessaires à la création de l'application WebGPU. Vous n'avez donc pas besoin de code pour commencer. Certains exemples pouvant servir de points de contrôle sont toutefois disponibles à l'adresse https://glitch.com/edit/#!/your-first-webgpu-app. Vous pouvez les consulter et vous y reporter en cas de difficultés.

Utiliser la Play Console

WebGPU est une API assez complexe reposant sur un grand nombre de règles qui garantissent une utilisation appropriée. De plus, en raison de son mode de fonctionnement, l'API n'est pas en mesure de générer des exceptions JavaScript classiques pour de nombreuses erreurs. Il est donc plus difficile de déterminer précisément l'origine d'un problème.

WebGPU va à coup sûr vous poser quelques difficultés dans votre travail de développement, surtout si vous débutez, et c'est normal ! Les développeurs à l'origine de l'API sont conscients des difficultés liées au développement GPU. Ils ont travaillé sans relâche pour qu'à chaque erreur renvoyée par le code WebGPU, vous receviez des messages très détaillés et utiles dans la Play Console pour vous aider à identifier et à résoudre le problème.

Il est toujours utile de garder la console ouverte, quelle que soit l'application Web sur laquelle vous travaillez, et c'est particulièrement le cas ici !

3. Initialisation de WebGPU

Commencer par un <canvas>

Si vous souhaitez utiliser WebGPU pour effectuer des calculs, vous n'avez pas besoin d'afficher quoi que ce soit à l'écran. En revanche, pour réaliser un rendu comme nous allons le faire dans l'atelier de programmation, vous aurez besoin d'un canevas. C'est donc un bon point de départ.

Créez un document HTML contenant un seul élément <canvas>, ainsi qu'une balise <script> dans laquelle nous allons interroger l'élément canevas. Vous pouvez également utiliser 00-starter-page.html depuis Glitch.

  • Créez un fichier index.html avec le code suivant :

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>

Demander un adaptateur et un appareil

Vous pouvez maintenant entrer dans le vif du sujet ! Tout d'abord, n'oubliez pas que la propagation d'API comme WebGPU dans l'ensemble de l'écosystème Web peut prendre un certain temps. Par conséquent, nous vous conseillons de commencer par vérifier si WebGPU est compatible avec le navigateur de l'utilisateur.

  1. Pour vérifier si l'objet navigator.gpu qui sert de point d'entrée à WebGPU existe, ajoutez le code suivant :

index.html

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

Idéalement, vous devez informer l'utilisateur lorsque WebGPU n'est pas accessible, en rétablissant la page sur un mode qui ne l'utilise pas (WebGL pourrait peut-être convenir). Toutefois, pour les besoins de cet atelier de programmation, il vous suffit de générer une erreur pour arrêter l'exécution du code.

Une fois que vous savez que WebGPU est compatible avec le navigateur, la première étape d'initialisation de WebGPU pour votre application consiste à demander un GPUAdapter. Vous pouvez considérer un adaptateur comme la représentation par WebGPU d'un matériel GPU spécifique sur votre appareil.

  1. Pour obtenir un adaptateur, suivez la méthode navigator.gpu.requestAdapter(). Comme celle-ci renvoie une promesse, il est plus pratique de l'appeler avec await.

index.html

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

Si aucun adaptateur approprié n'est trouvé, la valeur adapter renvoyée peut être null, et vous devez pouvoir gérer cette possibilité. Cela peut arriver si le navigateur de l'utilisateur est compatible avec WebGPU, mais que son matériel GPU ne dispose pas de toutes les fonctionnalités nécessaires à son utilisation.

La plupart du temps, vous pouvez laisser le navigateur choisir un adaptateur par défaut comme vous le faites ici. Cependant, pour des besoins plus avancés, il est possible de transmettre des arguments à requestAdapter() pour spécifier si vous voulez utiliser du matériel basse consommation ou hautes performances sur des appareils dotés de plusieurs GPU (comme certains ordinateurs portables).

Une fois que vous disposez d'un adaptateur, la dernière étape avant de commencer à utiliser le GPU consiste à demander un appareil GPU (GPUDevice). Celui-ci est l'interface principale par laquelle passent la plupart des interactions avec le GPU.

  1. Pour obtenir l'appareil, appelez la méthode adapter.requestDevice(), laquelle renvoie également une promesse.

index.html

const device = await adapter.requestDevice();

Comme avec la méthode requestAdapter(), il est ici possible de transmettre des options pour des utilisations plus avancées, par exemple pour activer des fonctionnalités matérielles spécifiques ou demander des limites plus élevées. Mais pour les besoins de cet atelier, les valeurs par défaut fonctionnent parfaitement.

Configurer le canevas

Maintenant que vous disposez d'un appareil, il vous reste une dernière chose à faire si vous voulez afficher des éléments sur la page : configurer le canevas à utiliser avec l'appareil que vous venez de créer.

  • Pour ce faire, commencez par demander un GPUCanvasContext depuis le canevas en appelant canvas.getContext("webgpu"). Il s'agit du même appel que celui que vous utiliseriez pour initialiser les contextes Canvas 2D ou WebGL, en spécifiant respectivement les types de contexte 2d et webgl. Le context renvoyé doit ensuite être associé à l'appareil à l'aide de la méthode configure(), comme ceci :

index.html

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

Vous pouvez transmettre quelques options ici, mais les plus importantes sont les device avec lesquels vous allez utiliser le contexte et le format, qui est le format de texture que le contexte doit utiliser.

Les textures sont les objets utilisés par WebGPU pour stocker des données d'image. Chaque texture possède un format qui permet au GPU de savoir comment ces données sont organisées dans la mémoire. Le fonctionnement de la mémoire des données de texture dépasse le cadre de cet atelier de programmation. Retenez simplement que le contexte du canevas fournit des textures à votre code pour dessiner, et que le format que vous utilisez a une incidence sur l'efficacité de l'affichage des images sur le canevas. Chaque type d'appareils fonctionne mieux lorsqu'un certain format de texture est appliqué. Si vous n'utilisez pas le format le plus adapté à un appareil donné, des copies de mémoire supplémentaires peuvent être créées en arrière-plan avant que l'image ne s'affiche sur la page.

Heureusement, vous n'avez pas à vous en soucier, car WebGPU vous indique le format à utiliser pour votre canevas. Dans la plupart des cas, vous devez transmettre la valeur renvoyée en appelant navigator.gpu.getPreferredCanvasFormat(), comme indiqué ci-dessus.

Effacer le canevas

Maintenant que vous avez configuré le canevas sur l'appareil dont vous disposez, vous pouvez commencer à utiliser l'appareil pour modifier le contenu du canevas. Commencez par appliquer une couleur unie pour effacer le canevas.

Pour cela, comme pratiquement pour tout le reste dans WebGPU, vous devez fournir certaines commandes pour indiquer au GPU ce qu'il doit faire.

  1. Vous allez donc demander à l'appareil de créer un GPUCommandEncoder, qui fournit une interface permettant d'enregistrer les commandes GPU.

index.html

const encoder = device.createCommandEncoder();

Les commandes que vous souhaitez envoyer au GPU sont liées au rendu (dans le cas présent, "effacer le canevas"). L'étape suivante consiste à utiliser l'encoder pour lancer une passe de rendu.

Les passes de rendu correspondent au moment où s'exécutent toutes les opérations de dessin dans WebGPU. Chacune d'entre elles commence par un appel beginRenderPass(), lequel définit les textures qui reçoivent la sortie des commandes de dessin exécutées. Pour des utilisations plus avancées, plusieurs textures, appelées rattachements, sont fournies à diverses fins (par exemple, pour stocker la profondeur de la géométrie rendue ou appliquer un anticrénelage). Pour cette application, vous n'en avez besoin que d'une.

  1. Appelez context.getCurrentTexture() pour obtenir la texture à partir du contexte de canevas que vous avez créé précédemment. La commande renvoie une texture dont la largeur et la hauteur en pixels correspondent aux attributs width et height du canevas, au format spécifié lors de l'appel de context.configure().

index.html

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

La texture est donnée en tant que propriété view d'un colorAttachment. Les passes de rendu nécessitent de fournir une GPUTextureView au lieu d'une GPUTexture. Celle-ci indique les parties de la texture à afficher. Cela n'a d'importance que pour des cas d'utilisation plus avancés. Ici, vous appelez createView() sans aucun argument sur la texture, ce qui indique que vous souhaitez que la passe de rendu utilise toute la texture.

Vous devez également spécifier l'action que la passe de rendu doit effectuer avec la texture au début et à la fin :

  • Une valeur loadOp définie sur "clear" indique que vous souhaitez effacer la texture au début de la passe de rendu.
  • Une valeur storeOp définie sur "store" indique qu'une fois la passe de rendu terminée, vous souhaitez que les résultats du dessin effectué pendant l'opération soient enregistrés dans la texture.

Une fois que la passe de rendu a commencé, vous ne faites... rien. Du moins pour le moment. Le lancement de la passe de rendu avec loadOp: "clear" suffit pour effacer la vue de la texture et le canevas.

  1. Terminez la passe de rendu en ajoutant l'appel suivant juste après beginRenderPass() :

index.html

pass.end();

Il est important de noter que le fait d’effectuer ces appels n’entraîne aucune action du GPU. Ils permettent simplement d'enregistrer les commandes que le GPU exécutera plus tard.

  1. Pour créer un GPUCommandBuffer, appelez finish() sur l'encodeur de commande. Le tampon de commande est un handle opaque pour les commandes enregistrées.

index.html

const commandBuffer = encoder.finish();
  1. Envoyez le tampon de commande au GPU à l'aide de la file d'attente queue du GPUDevice. La file d'attente exécute toutes les commandes GPU, ce qui assure une exécution correctement ordonnancée et synchronisée. La méthode submit() de la file d'attente peut prendre en compte un tableau de tampons de commande, mais dans le cas présent, vous n'en avez qu'un.

index.html

device.queue.submit([commandBuffer]);

Une fois que vous avez envoyé un tampon de commande, vous ne pouvez pas le réutiliser. Il n'est donc pas nécessaire de le conserver. Si vous souhaitez envoyer d'autres commandes, vous devez créer un autre tampon de commande. C'est pourquoi il est assez courant de voir ces deux étapes regroupées en une seule, comme dans les pages d'exemples de cet atelier de programmation :

index.html

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

Après avoir envoyé les commandes au GPU, laissez JavaScript rendre le contrôle au navigateur. À ce stade, le navigateur détecte que vous avez modifié la texture actuelle du contexte et met à jour le canevas pour afficher cette texture sous forme d'image. Si vous souhaitez modifier à nouveau le contenu du canevas, vous devez enregistrer et envoyer un nouveau tampon de commande, en appelant une nouvelle fois context.getCurrentTexture() afin d'obtenir une nouvelle texture pour une passe de rendu.

  1. Actualisez la page. Notez que le canevas apparaît en noir. Félicitations ! Cela signifie que vous avez créé votre première application WebGPU.

Canevas noir indiquant que WebGPU a été utilisé correctement pour effacer le contenu du canevas.

Choisir une couleur

Il faut reconnaître que des carrés noirs sont plutôt ennuyeux. Alors prenez un moment pour personnaliser votre canevas avant de passer à la section suivante.

  1. Dans l'appel device.beginRenderPass(), ajoutez une ligne clearValue au rattachement colorAttachment, comme ceci :

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

L'option clearValue indique à la passe de rendu la couleur à utiliser lors de l'opération clear au début de son exécution. Le dictionnaire transmis contient quatre valeurs : r pour le rouge, g pour le vert, b pour le bleu et a pour l'alpha (transparence). Chaque valeur peut varier de 0 à 1. Ensemble, elles décrivent la valeur du canal de couleur. Exemple :

  • { r: 1, g: 0, b: 0, a: 1 } correspond à rouge vif.
  • { r: 1, g: 0, b: 1, a: 1 } correspond à violet vif.
  • { r: 0, g: 0.3, b: 0, a: 1 } correspond à vert foncé.
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 } correspond à gris moyen.
  • { r: 0, g: 0, b: 0, a: 0 } correspond à noir transparent (valeur par défaut).

L'exemple de code et les captures d'écran de cet atelier de programmation utilisent un bleu foncé, mais vous pouvez choisir la couleur de votre choix.

  1. Une fois que vous avez choisi la couleur, actualisez la page. La couleur choisie doit s'afficher sur le canevas.

Canevas effacé (couleur de remplacement : bleu foncé) pour montrer comment changer la couleur d&#39;effacement par défaut

4. Dessiner une forme géométrique

À la fin de cette section, votre application dessinera une forme géométrique simple sur le canevas : un carré coloré. Si vous considérez que c'est beaucoup de travail pour un résultat aussi simple, sachez que WebGPU est conçu pour afficher de façon très efficace une quantité considérable de données de géométrie. Et cette efficacité a son revers : réaliser des tâches relativement simples peut paraître excessivement complexe. Mais c'est ce qui vous attend en choisissant une API comme WebGPU, l'objectif étant évidemment d'en faire bien plus.

Comprendre le fonctionnement des GPU

Avant toute autre modification de code, il est utile de présenter rapidement les grandes lignes de la manière dont les GPU créent les formes que vous voyez à l'écran. (N'hésitez pas à passer directement à la section "Définir des sommets" si vous connaissez déjà les principes de base du rendu GPU.)

Contrairement à une API comme Canvas 2D qui propose de nombreuses formes et options, votre GPU ne gère que quelques types de formes différentes (ou primitives, telles que désignées par WebGPU), à savoir des points, des lignes et des triangles. Pour les besoins de cet atelier de programmation, vous n'utiliserez que des triangles.

Les GPU fonctionnent presque exclusivement avec des triangles, car ceux-ci présentent de nombreuses propriétés mathématiques intéressantes qui les rendent faciles à traiter de façon efficace et prévisible. Presque tout ce que vous souhaitez afficher avec un GPU doit être divisé en triangles avant que celui-ci ne puisse dessiner, et ces triangles doivent être définis par leurs points d'angles.

Ces points, ou sommets, sont représentés par des valeurs X, Y et Z (pour le contenu 3D). Celles-ci définissent un point sur un système de coordonnées cartésien défini par WebGPU or toute API semblable. Il est plus facile de considérer la structure du système de coordonnées en fonction de sa relation avec le canevas sur votre page. Quelles que soient la largeur et la hauteur du canevas, le bord gauche est toujours à -1 sur l'axe X et le bord droit à +1 sur l'axe X. De même, le bord inférieur est toujours à -1 sur l'axe Y et le bord supérieur à +1 sur l'axe Y. Cela signifie que (0, 0) correspond toujours au centre du canevas, (-1, -1) à l'angle inférieur gauche et (1, 1) à l'angle supérieur droit. C'est ce qu'on appelle l'espace des extraits.

Simple schéma permettant de visualiser l&#39;espace des coordonnées normalisées de l&#39;appareil

Au départ, les sommets sont rarement définis dans ce système de coordonnées. Les GPU s'appuient donc sur de petits programmes appelés nuanceurs de sommets pour effectuer les calculs nécessaires à la transformation des sommets en un espace des extraits, ainsi que tout autre calcul permettant de dessiner les sommets. Par exemple, le nuanceur peut appliquer une animation ou calculer la direction entre le sommet et une source lumineuse. Ces nuanceurs sont écrits par vous, le développeur WebGPU, et ils offrent des possibilités de contrôle incroyables sur le fonctionnement du GPU.

À partir de là, le GPU utilise tous les triangles constitués de ces sommets transformés et détermine les pixels nécessaires à leur affichage. Il exécute ensuite un autre petit programme que vous écrivez, appelé nuanceur de fragments, qui calcule la couleur de chaque pixel. Ce calcul peut être aussi simple que renvoyer la couleur verte, ou plus complexe comme le calcul de l'angle de la surface par rapport à la lumière du soleil qui rebondit sur d'autres surfaces à proximité, et est filtrée par du brouillard et modifiée par la surface métallique. Le tout est entièrement sous votre contrôle, ce qui peut être à la fois stimulant et écrasant.

Les résultats de ces couleurs de pixel sont ensuite rassemblés dans une texture qu'il est possible d'afficher à l'écran.

Définir les sommets

Comme indiqué précédemment, la simulation du Jeu de la vie est représentée sous la forme d'une grille de cellules. Votre application doit pouvoir visualiser la grille, en distinguant les cellules actives des cellules inactives. Dans cet atelier de programmation, vous allez dessiner des carrés de couleur dans les cellules actives et laisser les cellules inactives vides.

Cela signifie que vous devez fournir au GPU quatre points différents, un pour chacun des quatre angles du carré. Par exemple, les coordonnées des angles d'un carré tracé au centre du canevas, tiré des bords vers l'intérieur, se présentent comme ceci :

Schéma des coordonnées normalisées de l&#39;appareil montrant les coordonnées des angles d&#39;un carré

Pour transmettre ces coordonnées au GPU, vous devez placer les valeurs dans un TypedArray. Si vous ne le savez pas, les TypedArray sont un groupe d'objets JavaScript qui vous permettent d'allouer des blocs de mémoire contigus et d'interpréter chaque élément de la série comme étant d'un type de données spécifique. Par exemple, dans un Uint8Array, chaque élément du tableau est un octet unique et non signé. Les TypedArray sont parfaits pour envoyer des données dans les deux sens à l'aide d'API sensibles à l'organisation de la mémoire, comme WebAssembly, WebAudio et, bien sûr, WebGPU.

Pour l'exemple de carré, comme les valeurs sont fractionnaires, un Float32Array est approprié.

  1. Créez un tableau contenant toutes les positions des sommets du schéma en plaçant la déclaration de tableau suivante dans votre code. Insérez-la près du haut, juste sous l'appel 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,
]);

Notez que l'espacement et le commentaire n'ont aucune incidence sur les valeurs ; ces éléments sont ajoutés pour plus de commodité, afin de rendre le tout plus lisible. Ils vous permettent de voir que chaque paire de valeurs représente les coordonnées X et Y d'un sommet.

Il y a cependant un problème. Rappelez-vous que les GPU fonctionnent en termes de triangles. Cela signifie que vous devez fournir les sommets par groupes de trois. Or, vous avez un groupe de quatre. La solution consiste à répéter deux des sommets pour former deux triangles partageant un côté au centre du carré.

Schéma montrant comment les quatre sommets du carré sont utilisés pour former deux triangles.

Pour former le carré présenté sur le schéma, vous devez référencer les sommets (-0.8, -0.8) et (0.8, 0.8) deux fois, une fois pour le triangle bleu et une fois pour le rouge. Vous pouvez aussi scinder le carré avec les deux autres angles, cela revient au même.

  1. Actualisez votre tableau vertices précédent ; vous devriez obtenir ceci :

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

Bien que le schéma montre une séparation entre les deux triangles pour plus de clarté, les positions des sommets sont identiques et le GPU les restitue sans espaces. La forme affichée est un carré plein et unique.

Créer un tampon de sommets

Le GPU ne peut pas tracer des sommets à partir de données issues d'un tableau JavaScript. Les GPU disposent souvent de leur propre mémoire, qui est hautement optimisée pour le rendu. Par conséquent, toutes les données que vous souhaitez que le GPU utilise pour dessiner doivent être placées dans cette mémoire.

Lorsque les valeurs sont nombreuses, y compris les données de sommet, la mémoire côté GPU est gérée via des objets GPUBuffer. Un tampon est un bloc de mémoire facilement accessible par le GPU et signalé à certaines fins. Il s'agit en quelque sorte d'un TypedArray visible par le GPU.

  1. Pour créer un tampon qui contiendra vos sommets, ajoutez l'appel suivant à device.createBuffer() après la définition de votre tableau vertices.

index.html

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

Notez tout d'abord que vous attribuez un libellé au tampon. L'attribution d'un libellé à chaque objet WebGPU que vous créez est facultative, mais nous vous conseillons de le faire. Vous pouvez définir la chaîne de votre choix, du moment que cela vous aide à identifier l'objet. En cas de problème, ces libellés figurent dans les messages d'erreur générés par le GPU pour en faciliter la compréhension.

Attribuez ensuite une taille (en octets) au tampon. Vous avez besoin d'un tampon de 48 octets. Vous déterminez cette valeur en multipliant la taille d'une valeur flottante de 32 bits (4 octets) par le nombre de valeurs flottantes dans votre tableau vertices (12). Heureusement, lorsque vous créez le tampon, les TypedArrays calculent automatiquement leur longueur en octets.

Enfin, vous devez spécifier l'utilisation du tampon, via un ou plusieurs indicateurs GPUBufferUsage. Vous pouvez en combiner plusieurs avec l'opérateur | (OR au niveau du bit). Dans ce cas, vous devez spécifier que le tampon sera utilisé pour les données de sommet (GPUBufferUsage.VERTEX) et que vous souhaitez également pouvoir y copier des données (GPUBufferUsage.COPY_DST).

L'objet de tampon qui vous est renvoyé est opaque : l'inspection des données qu'il contient n'est pas aisée. De plus, la plupart de ses attributs sont immuables : vous ne pouvez pas redimensionner un GPUBuffer après l'avoir créé, ni modifier les indicateurs d'utilisation. Vous pouvez cependant modifier les contenus de sa mémoire.

Lors de la création initiale du tampon, la mémoire qu'il contient est initialisée à zéro. Il existe plusieurs façons de modifier son contenu, mais le plus simple consiste à appeler device.queue.writeBuffer() en spécifiant un TypedArray que vous souhaitez copier dans le tampon.

  1. Pour copier les données de sommet dans la mémoire du tampon, ajoutez le code suivant :

index.html

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

Définir la mise en forme des sommets

Vous disposez à présent d'un tampon contenant des données de sommet, mais du point de vue du GPU, il s'agit simplement d'un blob d'octets. Vous devez fournir un peu plus d'informations à WebGPU si vous comptez dessiner, en particulier concernant la structure des données de sommet.

index.html

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

Cela peut sembler un peu compliqué à première vue, mais il est relativement facile de ventiler les données.

Le premier élément à spécifier est la valeur arrayStride. Il s'agit du nombre d'octets que le GPU doit parcourir dans le tampon lorsqu'il recherche le sommet suivant. Chaque sommet de votre carré est composé de deux valeurs flottantes (floats) 32 bits. Comme indiqué précédemment, un float 32 bits correspond à 4 octets, donc deux floats correspondent à 8 octets.

Vient ensuite la propriété attributes, qui est un tableau. Les attributs sont des éléments d'information encodés dans chaque sommet. Vos sommets ne contiennent qu'un seul attribut (leur position), mais dans les cas d'utilisation plus avancés, ils comportent souvent plusieurs attributs tels que la couleur d'un sommet ou la direction vers laquelle pointe la surface géométrique. Nous ne nous attarderons pas sur ce point dans cet atelier de programmation.

Dans votre attribut unique, commencez par définir le format des données. Celui-ci provient d'une liste de GPUVertexFormat qui décrivent chaque type de données de sommet compréhensible par le GPU. Vos sommets comportent deux floats de 32 bits chacun. Vous devez donc utiliser le format float32x2. Si vos données de sommet sont plutôt composées de quatre entiers non signés de 16 bits chacun, utilisez plutôt uint16x4. Voyez-vous l'idée ?

Ensuite, offset décrit le nombre d'octets dans le sommet qui précèdent le début de l'attribut concerné. Vous n'avez à vous en préoccuper que si votre tampon contient plusieurs attributs, ce qui n'entre pas dans le cadre de cet atelier de programmation.

Enfin, nous avons shaderLocation. Il s'agit d'un nombre arbitraire compris entre 0 et 15, qui doit être unique pour chaque attribut défini. Cette valeur relie l'attribut à une entrée spécifique du nuanceur de sommets. Nous verrons cela dans la section suivante.

Notez que, même si vous définissez ces valeurs maintenant, vous ne les transmettez pas encore à l'API WebGPU. Cela viendra par la suite, mais il est plus facile de déterminer ces valeurs au moment où vous définissez vos sommets. Vous le faites donc maintenant pour les utiliser plus tard.

Configurer les nuanceurs

Vous disposez maintenant des données que vous souhaitez représenter, mais vous devez encore indiquer précisément au GPU comment les traiter. Les nuanceurs jouent ici un rôle prépondérant.

Les nuanceurs sont de petits programmes que vous écrivez et qui s'exécutent sur votre GPU. Chaque nuanceur intervient sur les données à une étape différente : traitement des sommets, traitement des fragments ou calcul général. Étant situés sur le GPU, ils sont structurés de manière plus rigide que votre JavaScript habituel. Mais cette structure leur permet de s'exécuter très rapidement et, surtout, en parallèle.

Les nuanceurs dans WebGPU sont écrits dans un langage spécifique appelé WGSL (WebGPU Shading Language). D'un point de vue syntaxique, WGSL ressemble à Rust, mais avec des fonctionnalités destinées à faciliter et accélérer les types de travaux GPU les plus courants (comme les calculs vectoriels et matriciels). Enseigner l'ensemble du langage de shading dépasse largement le cadre de cet atelier de programmation. Les quelques exemples simples que nous présentons devraient vous permettre d'en acquérir quelques notions.

Les nuanceurs eux-mêmes sont transmis à WebGPU en tant que chaînes.

  • Créez un emplacement pour saisir le code de votre nuanceur en copiant les lignes suivantes en dessous de vertexBufferLayout dans votre code :

index.html

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

Pour créer les nuanceurs, vous appelez la fonction device.createShaderModule(), à laquelle vous fournissez éventuellement un label et un code WGSL en tant que chaîne. Notez que vous utilisez ici des accents graves pour autoriser les chaînes multilignes. Une fois que vous avez ajouté un code WGSL valide, la fonction renvoie un objet GPUShaderModule contenant les résultats compilés.

Définir le nuanceur de sommets

Nous allons commencer par le nuanceur de sommets, car c'est aussi par là que le GPU démarre.

Un nuanceur de sommets est défini comme une fonction, et le GPU appelle cette fonction une fois pour chaque sommet présent dans votre vertexBuffer. Comme votre vertexBuffer comporte six positions (sommets), la fonction que vous définissez est appelée six fois. À chaque appel, une position différente du vertexBuffer est transmise à la fonction en tant qu'argument. La fonction du nuanceur de sommets doit alors renvoyer une position correspondante dans l'espace des extraits.

Notez également que les sommets ne sont pas forcément appelés par ordre séquentiel. Les GPU excellent avant tout dans l'exécution en parallèle de nuanceurs comme ceux-ci, et peuvent traiter des centaines (voire des milliers) de sommets en même temps. C'est l'une des principales raisons pour lesquelles les GPU sont si rapides, mais il existe cependant quelques limites. Afin de garantir une parallélisation optimale, les nuanceurs de sommets ne peuvent pas communiquer entre eux. Chaque appel de nuanceur ne peut voir les données que d'un seul sommet à la fois et ne peut générer des valeurs que pour un seul sommet.

Dans WGSL, une fonction de nuanceur de sommets peut être nommée comme vous le souhaitez, à condition qu'elle soit précédée de l'attribut @vertex pour indiquer l'étape du nuanceur qu'elle représente. WGSL désigne les fonctions par le mot clé fn, utilise des parenthèses pour déclarer des arguments et des accolades pour définir le champ d'application.

  1. Créez une fonction @vertex vide, comme ceci :

index.html (code createShaderModule)

@vertex
fn vertexMain() {

}

Notez cependant que ce code n'est pas valide, car un nuanceur de sommets doit renvoyer au moins la position finale du sommet en cours de traitement dans l'espace des extraits. La valeur est toujours exprimée sous forme de vecteur à quatre dimensions. Les vecteurs sont si couramment utilisés dans les nuanceurs qu'ils sont traités comme des primitives de première classe dans le langage, avec leurs propres types comme vec4f pour un vecteur à quatre dimensions. Il existe des types similaires pour les vecteurs 2D (vec2f) et 3D (vec3f).

  1. Pour indiquer que la valeur renvoyée correspond à la position requise, marquez-la avec l'attribut @builtin(position). Le symbole -> permet d'indiquer qu'il s'agit du résultat renvoyé par la fonction.

index.html (code createShaderModule)

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

}

Bien entendu, si un type renvoyé apparaît dans la fonction, vous devez renvoyer une valeur dans le corps de la fonction. Vous pouvez construire un nouveau type vec4f à renvoyer en utilisant la syntaxe vec4f(x, y, z, w). Les valeurs x, y et z sont toutes des nombres à virgule flottante qui, dans la valeur renvoyée, indiquent la position du sommet dans l'espace des extraits.

  1. Renvoyez une valeur statique de (0, 0, 0, 1), et, techniquement, vous disposez d'un nuanceur de sommets valide, bien qu'il n'affiche jamais rien, puisque le GPU reconnaît que les triangles qu'il produit ne forment qu'un seul point, qu'il supprime ensuite.

index.html (code createShaderModule)

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

À la place, vous devez utiliser les données du tampon que vous avez créé en déclarant un argument pour votre fonction avec un attribut @location() et un type qui correspondent à ceux que vous avez décrits dans la mise en forme vertexBufferLayout. Comme vous avez spécifié un emplacement shaderLocation de 0, marquez l'argument avec @location(0) dans votre code WGSL. Vous avez également défini le format comme float32x2, qui représente un vecteur 2D. Dans WGSL, votre argument est donc vec2f. Vous pouvez lui attribuer le nom de votre choix, mais comme il s'agit de vos positions de sommet, un nom tel que pos semble tout indiqué.

  1. Remplacez votre fonction de nuanceur par le code suivant :

index.html (code createShaderModule)

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

Vous devez maintenant renvoyer cette position. Étant donné que la position est un vecteur bidimensionnel et que le type renvoyé est un vecteur 4D, vous devez la modifier légèrement. Vous devez récupérer les deux composantes de l'argument de position et les placer dans les deux premières composantes du vecteur renvoyé, en laissant les deux dernières composantes respectivement sur 0 et 1.

  1. Renvoyez la position correcte en indiquant explicitement les composantes de position à utiliser :

index.html (code createShaderModule)

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

Toutefois, comme ces types de mise en correspondance sont très courants dans les nuanceurs, vous pouvez aussi transmettre le vecteur de position en tant que premier argument dans une forme abrégée pratique. Cela revient au même.

  1. Remplacez l'instruction return par le code suivant :

index.html (code createShaderModule)

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

Et voilà votre nuanceur de sommets initial ! C'est très simple : il suffit de transmettre la position inchangée pour commencer.

Définir le nuanceur de fragments

Passons maintenant au nuanceur de fragments. Ces nuanceurs fonctionnent quasiment de la même manière que les nuanceurs de sommets, mais au lieu d'être appelés pour chaque sommet, ils le sont pour chaque pixel dessiné.

Les nuanceurs de fragments sont toujours appelés après les nuanceurs de sommets. Le GPU extrait la sortie des nuanceurs de sommets et effectue une triangulation, créant ainsi des triangles à partir d'ensembles de trois points. Il effectue ensuite une rastérisation sur chacun de ces triangles en déterminant les pixels des rattachements de couleur de sortie inclus dans le triangle, puis appelle le nuanceur de fragments une fois pour chacun de ces pixels. Le nuanceur de fragments renvoie une couleur, généralement calculée à partir des valeurs qui lui ont été envoyées par le nuanceur de sommets et d'éléments tels que les textures, que le GPU écrit dans le rattachement de couleur.

Tout comme les nuanceurs de sommets, les nuanceurs de fragments sont exécutés de manière massivement parallèle. Ils sont un peu plus flexibles que les nuanceurs de sommets en termes d'entrées et de sorties, mais vous pouvez les utiliser pour renvoyer une seule couleur pour chaque pixel de chaque triangle.

Une fonction de nuanceur de fragments WGSL est indiquée par l'attribut @fragment et renvoie également un type vec4f. Toutefois, dans ce cas, le vecteur représente une couleur, et non une position. Vous devez associer un attribut @location à la valeur renvoyée pour indiquer le colorAttachment de l'appel beginRenderPass dans lequel la couleur renvoyée est écrite. Comme vous n'avez qu'un seul rattachement, la position est 0.

  1. Créez une fonction @fragment vide, comme ceci :

index.html (code createShaderModule)

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

}

Les quatre composantes du vecteur renvoyé sont les valeurs de couleur rouge, vert, bleu et alpha. Elles sont interprétées exactement comme l'élément clearValue que vous avez précédemment défini dans beginRenderPass. vec4f(1, 0, 0, 1) est donc rouge vif, ce qui semble bien convenir à votre carré. Vous êtes toutefois libre de définir cet élément sur la couleur de votre choix.

  1. Définissez le vecteur de couleur renvoyé, comme ceci :

index.html (code createShaderModule)

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

Et voilà un nuanceur de fragments complet ! Il n'est pas fantastique, mais suffisant pour l'instant, puisqu'il permet de définir simplement chaque pixel de chaque triangle sur la couleur rouge.

Pour résumer, une fois que vous avez ajouté le code du nuanceur comme expliqué ci-dessus, votre appel createShaderModule se présente comme ceci :

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

Créer un pipeline de rendu

Un module de nuanceur ne peut pas être utilisé seul pour le rendu. Vous devez l'utiliser comme composant d'un GPURenderPipeline que vous créez en appelant la fonction device.createRenderPipeline(). Le pipeline de rendu contrôle la façon dont la géométrie est dessinée, y compris les éléments tels que les nuanceurs employés, la manière d'interpréter les données dans les tampons de sommets, le type de forme géométrique à restituer (lignes, points, triangles…), et bien d'autres aspects.

Le pipeline de rendu est l'objet le plus complexe de toute l'API, mais ne vous inquiétez pas. La plupart des valeurs que vous pouvez lui transmettre sont facultatives. Il vous suffit d'en indiquer quelques-unes pour commencer.

  • Créez un pipeline de rendu, comme ceci :

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

Chaque pipeline a besoin d'une mise en forme (layout) qui décrit les types d'entrées nécessaires (autres que les tampons de sommets), mais vous n'en avez pas. Heureusement, vous pouvez transmettre la valeur "auto" pour le moment. En ce cas, le pipeline crée sa propre mise en forme à partir des nuanceurs.

Vous devez ensuite fournir des informations sur l'étape vertex. Le module est le GPUShaderModule qui contient votre nuanceur de sommets, et l'entryPoint indique le nom de la fonction dans le code du nuanceur appelé pour chaque appel de sommet. Vous pouvez avoir plusieurs fonctions @vertex et @fragment dans un seul module de nuanceur. Les tampons correspondent à un tableau d'objets GPUVertexBufferLayout qui décrivent comment vos données sont empaquetées dans les tampons de sommets avec lesquels vous utilisez ce pipeline. Vous avez déjà défini cet élément dans votre vertexBufferLayout. C'est ici que vous le transmettez.

Enfin, vous devez indiquer des informations sur l'étape fragment. Cela inclut un module de nuanceurs et un entryPoint, comme à l'étape Vertex. La dernière étape consiste à définir les cibles (targets) avec lesquelles ce pipeline est utilisé. Il s'agit d'un tableau de dictionnaires contenant les détails des rattachements de couleur de sortie du pipeline, tels que le format de texture. Ces détails doivent correspondre aux textures indiquées dans les colorAttachments des passes de rendu avec lesquelles ce pipeline est utilisé. Vos passes de rendu utilisent les textures fournies par le contexte du canevas, ainsi que la valeur de format que vous avez enregistrée dans canvasFormat. Vous devez donc transmettre le même format ici.

Vous êtes encore loin d'avoir spécifié toutes les options possibles pour la création d'un pipeline de rendu, mais nous en resterons là dans cet atelier de programmation.

Dessiner le carré

Vous disposez maintenant de tout ce qu'il vous faut pour dessiner votre carré.

  1. Pour tracer le carré, revenez à la paire d'appels encoder.beginRenderPass()/pass.end(), puis ajoutez ces commandes entre les deux appels :

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

WebGPU dispose maintenant de toutes les informations nécessaires pour tracer votre carré. Vous allez d'abord appeler la fonction setPipeline() pour indiquer quel pipeline utiliser pour le dessin. Le pipeline spécifie les nuanceurs, la mise en forme des données de sommets et d'autres données d'état nécessaires.

Ensuite, vous appelez setVertexBuffer() avec le tampon contenant les sommets de votre carré. Vous l'appelez avec 0, car ce tampon correspond à l'élément 0 dans la définition vertex.buffers du pipeline actuel.

Enfin, effectuez l'appel draw(), qui va vous paraître étonnamment simple après tout le paramétrage qui précède. Le seul élément que vous devez transmettre est le nombre de sommets que la fonction doit afficher (elle extrait cette valeur des tampons de sommets actuellement définis et l'interprète avec le pipeline actuellement défini). Vous pourriez simplement coder en dur la valeur 6, mais en calculant à partir du tableau des sommets (12 floats / 2 coordonnées par sommet = 6 sommets), si vous décidez de remplacer le carré par un cercle, vous aurez moins de modifications manuelles à effectuer.

  1. Actualisez votre écran et, enfin, affichez les résultats de tous vos efforts : un grand carré de couleur.

Un unique carré rouge rendu avec WebGPU

5. Dessiner une grille

Tout d'abord, félicitations ! L'affichage des premiers éléments géométriques à l'écran est souvent l'une des étapes les plus difficiles avec la plupart des API GPU. Ici, vous procédez par petites étapes, de façon à vérifier facilement votre progression au fur et à mesure.

Dans cette section, vous allez apprendre à :

  • transmettre des variables (appelées "variables uniformes") au nuanceur à partir de JavaScript ;
  • utiliser des variables uniformes pour modifier le comportement de rendu ;
  • utiliser l'instanciation pour dessiner plusieurs variantes d'une même géométrie.

Définir la grille

Pour afficher une grille, vous devez connaître une information essentielle. Combien de cellules contient-elle, en largeur et en hauteur ? En tant que développeur, c'est vous qui décidez, mais pour simplifier les choses, traitons la grille comme un carré (largeur et hauteur identiques) et utilisons une taille correspondant à une puissance de deux. Les calculs ultérieurs seront ainsi plus simples. Vous pouvez évidemment agrandir la grille, mais pour le reste de cette section, définissons la taille sur 4 x 4, les calculs mathématiques effectués ici seront plus faciles à comprendre. Vous redimensionnerez votre grille après.

  • Définissez la taille de la grille en ajoutant une constante en haut de votre code JavaScript.

index.html

const GRID_SIZE = 4;

Vous devez ensuite modifier la manière d'afficher votre carré de façon à obtenir GRID_SIZE x GRID_SIZE carrés sur le canevas. Le carré de base doit donc être beaucoup plus petit, et vous devez en avoir un grand nombre.

Vous pourriez y parvenir en agrandissant considérablement votre tampon de sommets et en définissant GRID_SIZE x GRID_SIZE carrés à la taille et la position appropriées à l'intérieur du tampon. En fait, le code ne serait pas trop compliqué. Juste quelques boucles et un peu de calcul. Mais pour obtenir ce résultat, il existe une meilleure façon d'utiliser un GPU, sans consommer plus de mémoire que nécessaire. C'est ce que nous allons voir dans cette section.

Créer un tampon de variables uniformes

Vous devez d'abord communiquer au nuanceur la taille de grille que vous avez choisie, car il en a besoin pour modifier l'affichage des éléments. Vous pourriez vous contenter de coder la taille en dur dans le nuanceur, mais en ce cas, vous devriez recréer le nuanceur et effectuer un rendu de pipeline pour chaque modification de la taille de la grille, ce qui peut être coûteux. C'est pourquoi il est préférable de communiquer la taille de la grille au nuanceur via des variables uniformes.

Vous avez appris précédemment qu'une valeur différente du tampon de sommets est transmise à chaque appel d'un nuanceur de sommets. Les variables uniformes sont des valeurs de tampon qui restent identiques à chaque appel. Elles permettent de communiquer des valeurs communes pour un élément géométrique (comme la position), une image complète d'animation (comme l'heure actuelle), voire pour la durée de vie de l'application (comme une préférence utilisateur).

  • Créez un tampon de variables uniformes en ajoutant le code suivant :

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

Ces lignes de code devraient vous rappeler quelque chose. Ce sont presque exactement les mêmes que celles utilisées précédemment pour créer le tampon de sommets. En effet, les variables uniformes sont transmises à l'API WebGPU via les mêmes objets GPUBuffer que les sommets, la principale différence étant que l'usage inclut cette fois GPUBufferUsage.UNIFORM au lieu de GPUBufferUsage.VERTEX.

Accéder aux variables uniformes dans un nuanceur

  • Définissez une variable uniforme en ajoutant le code suivant :

index.html (appel 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 

Ce code définit dans votre nuanceur une variable uniforme appelée grid, qui est un vecteur de float 2D correspondant au tableau que vous venez de copier dans le tampon de variables uniformes. Il indique également que la variable uniforme est liée à @group(0) et @binding(0). Vous découvrirez la signification de ces valeurs dans un moment.

Vous pouvez ensuite utiliser le vecteur de grille comme vous le souhaitez ailleurs dans le code du nuanceur. Dans ce code, vous divisez la position des sommets par le vecteur de grille. Comme pos est un vecteur 2D et que grid l'est aussi, WGSL effectue une division au niveau de la composante. En d'autres termes, le résultat est identique à vec2f(pos.x / grid.x, pos.y / grid.y).

Les opérations vectorielles de ce genre sont très courantes dans les nuanceurs GPU, car de nombreuses techniques de rendu et de calcul les utilisent.

Dans votre cas, cela signifie (si vous avez utilisé une taille de grille de 4) que le carré que vous affichez correspond au quart de sa taille d'origine. C'est parfait si vous souhaitez en placer quatre sur une ligne ou une colonne.

Créer un groupe de liaisons

Le fait de déclarer la variable uniforme dans le nuanceur ne l'associe pas au tampon que vous avez créé. Pour ce faire, vous devez créer et définir un groupe de liaisons.

Un groupe de liaisons est un ensemble de ressources que vous souhaitez rendre accessibles à votre nuanceur en même temps. Il peut inclure plusieurs types de tampons, tels que votre tampon de variables uniformes, ainsi que d'autres ressources comme des textures et des échantillonneurs, que nous n'aborderons pas ici, mais qui sont fréquemment employés dans les techniques de rendu GPU.

  • Créez un groupe de liaisons avec votre tampon de variables uniformes en ajoutant le code suivant après la création du tampon de variables uniformes et du pipeline de rendu :

index.html

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

En plus de votre label standard, il vous faut un layout qui décrit les types de ressources que ce groupe de liaisons contient. Nous verrons cela plus en détail lors d'une prochaine étape. Pour le moment, vous pouvez demander à votre pipeline la mise en forme du groupe de liaisons, car vous avez créé le pipeline en mode layout: "auto". Le pipeline crée donc automatiquement des mises en forme de groupes de liaisons à partir des liaisons que vous avez déclarées dans le code du nuanceur. Dans ce cas, vous lui demandez d'exécuter getBindGroupLayout(0), où 0 correspond au @group(0) que vous avez saisi dans le nuanceur.

Après avoir spécifié la mise en forme, vous fournissez un tableau des entries. Chaque entrée est un dictionnaire qui contient a minima les valeurs suivantes :

  • binding, qui correspond à la valeur @binding() que vous avez saisie dans le nuanceur. Dans ce cas, 0.
  • resource, qui est la ressource réelle que vous voulez exposer à la variable au niveau de l'index de liaison spécifié. Ici, il s'agit de votre tampon de variables uniformes.

La fonction renvoie un GPUBindGroup, qui est un handle opaque et immuable. Vous ne pouvez pas modifier les ressources vers lesquelles pointe un groupe de liaisons après sa création, mais vous pouvez modifier le contenu de ces ressources. Par exemple, si vous modifiez le tampon de variables uniformes pour qu'il contienne une nouvelle taille de grille, cela se reflète dans les futurs appels de dessin utilisant ce groupe de liaisons.

Lier le groupe de liaisons

Maintenant que le groupe de liaisons a été créé, vous devez encore demander à WebGPU de l'utiliser pour le dessin. Heureusement, c'est assez simple.

  1. Revenez à la passe de rendu et ajoutez cette ligne avant la méthode draw() :

index.html

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

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

pass.draw(vertices.length / 2);

La valeur 0 transmise comme premier argument correspond au @group(0) dans le code du nuanceur. Vous déclarez que chaque @binding qui fait partie de @group(0) utilise les ressources référencées dans ce groupe de liaisons.

Le tampon de variables uniformes est maintenant exposé à votre nuanceur.

  1. Actualisez la page ; vous devriez obtenir ceci :

Un petit carré rouge au centre sur un fond bleu foncé

Parfait ! Votre carré fait maintenant le quart de sa taille d'origine. Ce n'est pas grand-chose, mais cela montre que votre variable uniforme a bien été appliquée et que le nuanceur peut maintenant accéder à la taille de votre grille.

Manipuler une forme géométrique dans le nuanceur

Maintenant que vous pouvez référencer la taille de la grille dans le nuanceur, vous pouvez commencer à manipuler la figure géométrique que vous affichez pour l'adapter au modèle de grille souhaité. Pour ce faire, réfléchissez bien à ce que vous voulez obtenir.

D'un point de vue conceptuel, vous devez diviser votre canevas en cellules individuelles. Afin de conserver la convention selon laquelle les valeurs de l'axe X augmentent à mesure que vous vous déplacez vers la droite et celles de l'axe Y à mesure que vous montez, considérons que la première cellule se trouve en bas à gauche du canevas. Vous obtenez une mise en forme semblable à celle-ci, avec la forme géométrique carrée actuelle au milieu :

Illustration de la grille conceptuelle : l&#39;espace de coordonnées normalisé de l&#39;appareil sera divisé lors de la visualisation de chaque cellule avec la forme géométrique carrée actuellement affichée au centre.

La difficulté consiste à trouver dans le nuanceur une méthode qui vous permette de positionner la forme géométrique carrée dans l'une des cellules en fonction des coordonnées de la cellule.

Tout d'abord, vous pouvez voir que le carré n'est pas aligné sur les cellules, car il a été défini pour entourer le centre du canevas. Vous devez déplacer le carré d'une demi-cellule pour qu'il s'aligne correctement à l'intérieur.

Pour y parvenir, vous pouvez modifier le tampon des sommets du carré. En décalant les sommets de sorte que l'angle inférieur droit soit, par exemple, à (0,1, 0,1) au lieu de (-0,8, -0,8), vous déplaceriez ce carré pour qu'il s'aligne plus précisément sur les limites des cellules. Cependant, comme vous contrôlez entièrement le traitement des sommets dans votre nuanceur, il est tout aussi facile de les mettre en place à l'aide du code du nuanceur.

  1. Modifiez le module du nuanceur de sommets à l'aide de ce code :

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

Chaque sommet est déplacé vers le haut et vers la droite d'une unité (qui correspond bien à la moitié de l'espace des extraits) avant d'être divisé par la taille de la grille. Résultat : un carré aligné sur une grille juste à côté de l'origine.

Représentation visuelle du canevas divisé conceptuellement en une grille de 4x4 présentant un carré rouge dans la cellule (2, 2)

Ensuite, comme le système de coordonnées de votre canevas place la cellule (0, 0) au centre et la cellule (-1, -1) en bas à gauche, et que vous souhaitez que la cellule (0, 0) se situe en bas à gauche, vous devez traduire la position de votre figure géométrique par (-1, -1) après la division de celle-ci par la taille de la grille pour pouvoir la déplacer dans cet angle.

  1. Traduisez la position de votre forme géométrique comme ceci :

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

Votre carré est maintenant bien positionné dans la cellule (0, 0).

Représentation visuelle du canevas conceptuellement divisé en une grille de 4 x 4 présentant un carré rouge dans la cellule (0, 0)

Et si vous souhaitez le placer dans une autre cellule ? Pour cela, déclarez un vecteur cell dans votre nuanceur et renseignez-le avec une valeur statique comme let cell = vec2f(1, 1).

Si vous l'ajoutez à gridPos, cela annule la valeur - 1 dans l'algorithme, et ce n'est pas ce que vous voulez. Vous devez en fait déplacer le carré d'une seule unité de grille (un quart du canevas) pour chaque cellule. Par conséquent, vous devez de nouveau diviser par la valeur grid.

  1. Modifiez le positionnement de la grille comme ceci :

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

Si vous actualisez la page, vous obtenez ceci :

Représentation visuelle du canevas conceptuellement divisé en une grille de 4x4 présentant un carré rouge centré entre les cellules (0, 0), (0, 1), (1, 0) et (1, 1)

Hum… Pas tout à fait ce que vous vouliez.

Explication : comme les coordonnées du canevas vont de -1 à +1, cela représente en fait 2 unités. Autrement dit, pour déplacer un sommet d'un quart de canevas, vous devez le déplacer de 0,5 unité. C’est une erreur courante lorsque l’on raisonne avec les coordonnées GPU. Heureusement, la solution est très simple.

  1. Multipliez le décalage par 2, comme ceci :

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

Vous obtenez ainsi exactement ce que vous voulez.

Représentation visuelle du canevas conceptuellement divisé en une grille de 4 x 4 présentant un carré rouge dans la cellule (1, 1)

Voici à quoi ressemble la capture d'écran :

Capture d&#39;écran d&#39;un carré rouge sur un fond bleu foncé. Le carré rouge est tracé dans la même position que celle décrite dans le schéma précédent, sans la superposition de la grille

Vous pouvez maintenant définir cell sur n'importe quelle valeur dans les limites de la grille, puis actualiser pour afficher le carré à l'emplacement souhaité.

Dessiner des instances

Maintenant que vous savez placer le carré là où vous voulez avec quelques calculs, l'étape suivante consiste à afficher un carré dans chaque cellule de la grille.

Pour ce faire, vous pouvez par exemple écrire les coordonnées de cellule dans un tampon de variables uniformes, puis appeler la fonction draw une fois pour chaque carré de la grille, en mettant la variable uniforme à jour à chaque fois. Ce serait toutefois très lent, car le GPU devrait attendre que la nouvelle coordonnée soit écrite à chaque fois par JavaScript. Un moyen d'obtenir de bonnes performances du GPU est de minimiser le temps qu’il passe à attendre les résultats d’autres parties du système.

La technique appelée "instanciation" est plus appropriée. L'instanciation est une manière d'indiquer au GPU qu'il doit dessiner plusieurs copies d'une même figure géométrique à l'aide d'un seul appel à draw, ce qui est beaucoup plus rapide que d'appeler draw une fois pour chaque copie. Chaque copie de la figure géométrique est appelée instance.

  1. Pour indiquer au GPU le nombre d'instances de votre carré nécessaires pour remplir la grille, ajoutez un argument à votre appel de dessin existant :

index.html

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

Vous indiquez ainsi au système que vous voulez qu'il dessine les six sommets (vertices.length / 2) de votre carré 16 fois (GRID_SIZE * GRID_SIZE). Mais si vous actualisez la page, vous obtenez toujours ceci :

Une image identique au schéma précédent, pour indiquer que rien n&#39;a changé

Pourquoi ? Parce que vous dessinez les 16 carrés tous au même endroit. Il vous faut une logique supplémentaire dans le nuanceur qui repositionne la figure géométrique instance par instance.

Dans le nuanceur, en plus des attributs de sommet comme pos fournis par votre tampon de sommets, vous pouvez aussi accéder à ce que l'on appelle les valeurs intégrées de WGSL. Ces valeurs sont calculées par WebGPU, et instance_index est l'une d'entre elles. La valeur instance_index est un nombre 32 bits non signé de 0 à number of instances - 1 que vous pouvez utiliser dans votre logique de nuanceur. Sa valeur est identique pour tous les sommets individuels traités faisant partie de la même instance. Cela signifie que votre nuanceur de sommets est appelé six fois avec un instance_index de 0 (une fois pour chaque position dans votre tampon de sommets), six autres fois avec un instance_index de 1, puis six fois de plus avec un instance_index de 2, et ainsi de suite.

Pour observer l'opération, vous devez ajouter l'instance_index intégré aux entrées de votre nuanceur. Procédez de la même manière que pour la position, mais au lieu de marquer l'argument avec un attribut @location, utilisez @builtin(instance_index), puis donnez à cet argument le nom de votre choix. Vous pouvez l'appeler instance pour vous aligner sur l'exemple de code. Utilisez-le ensuite dans la logique du nuanceur.

  1. Remplacez les coordonnées de cellule par 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);
}

Si vous actualisez la page, vous voyez qu'effectivement plusieurs carrés s'affichent. Mais vous ne pouvez pas voir les 16.

Quatre carrés rouges disposés en diagonale de l&#39;angle inférieur gauche à l&#39;angle supérieur droit sur un fond bleu foncé.

En effet, les coordonnées des cellules que vous générez sont (0, 0), (1, 1), (2, 2), etc., jusqu'à (15, 15), mais seules les quatre premières tiennent sur le canevas. Pour obtenir la grille souhaitée, vous devez transformer instance_index de sorte que chaque index corresponde à une seule cellule de la grille, comme ceci :

Représentation visuelle du canevas conceptuellement divisé en une grille de 4 x 4 dont chaque cellule correspond à un index d&#39;instance linéaire

Pour obtenir ce résultat, le calcul est relativement simple. Pour la valeur X de chaque cellule, vous devez exécuter dans WGSL une opération modulo sur l'instance_index et la largeur de grille, à l'aide de l'opérateur %. Et pour la valeur Y de chaque cellule, vous devez diviser l'instance_index par la largeur de grille, en supprimant les valeurs fractionnaires restantes. Utilisez pour cela la fonction WGSL floor().

  1. Modifiez les calculs de la façon suivante :

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

Une fois le code modifié, voici enfin la grille de carrés tant attendue !

Quatre lignes de quatre colonnes de carrés rouges sur fond bleu foncé

  1. Maintenant que tout fonctionne, revenez en arrière et augmentez la taille de la grille.

index.html

const GRID_SIZE = 32;

32 lignes de 32 colonnes de carrés rouges sur fond bleu foncé.

Et voilà ! Vous pouvez générer maintenant une grille vraiment très grande qu'un GPU standard est capable de gérer. Vous ne distinguerez plus les carrés individuels bien avant que des goulots d'étranglement n'affectent les performances du GPU.

6. Bonus : encore plus de couleur !

À ce stade, vous pouvez simplement passer à la section suivante, car vous avez jeté les bases pour le reste de l'atelier de programmation. Votre grille de carrés est fonctionnelle, mais ceux-ci sont tous de la même couleur, ce qui n'est pas très attrayant. Les nuanceurs vont vous permettre d'arranger cela avec un peu de maths et de code.

Utiliser des structures dans les nuanceurs

Jusqu'à présent, vous avez transmis une donnée depuis le nuanceur de sommets : la position transformée. Vous pouvez toutefois en renvoyer beaucoup plus depuis ce nuanceur, et les utiliser dans le nuanceur de fragments.

La seule façon de transmettre des données vers l'extérieur du nuanceur de sommets est de les renvoyer. Un nuanceur de sommets est toujours nécessaire pour renvoyer une position. Si vous souhaitez renvoyer d'autres données en même temps, vous devez les placer dans une structure. Dans WGSL, les structures sont des types d'objets nommés qui contiennent une ou plusieurs propriétés nommées. Les propriétés peuvent être marquées à l'aide d'attributs comme @builtin et @location. Vous les déclarez en dehors des fonctions, puis vous pouvez en transmettre des instances dans les fonctions ou hors des fonctions, selon vos besoins. Prenons l'exemple de votre nuanceur de sommets actuel :

index.html (appel 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);
}
  • Exprimez le même élément à l'aide de structures pour l'entrée et la sortie de la fonction :

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

Notez que cela nécessite de référencer la position d'entrée et l'index de l'instance avec input. La structure que vous renvoyez en premier doit être déclarée en tant que variable et ses propriétés individuelles doivent être définies. Dans le cas présent, cela ne fait pas trop de différence, cela rend même le travail du nuanceur un peu plus long. Cependant, à mesure que vos nuanceurs se complexifient, les structures peuvent être un excellent moyen d'organiser vos données.

Transmettre des données entre les fonctions de sommet et de fragment

Pour rappel, votre fonction @fragment est aussi simple que possible :

index.html (appel createShaderModule)

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

Vous n'utilisez aucune entrée et vous transmettez une couleur unie (rouge) comme sortie. Si le nuanceur en savait plus sur la figure géométrique qu'il colore, vous pourriez utiliser ces données supplémentaires pour la rendre un peu plus intéressante. Par exemple, comment faire si vous souhaitez modifier la couleur de chaque carré en fonction de ses coordonnées de cellule ? L'étape @vertex sait sur quelle cellule le rendu est en cours d'exécution ; il vous suffit de transmettre l'information à l'étape @fragment.

Pour transmettre des données entre les étapes de sommet et de fragment, vous devez les inclure dans une structure de sortie avec une valeur @location de votre choix. Comme vous voulez transmettre les coordonnées de cellule, ajoutez-les à la structure VertexOutput précédente, puis définissez-les dans la fonction @vertex avant de renvoyer.

  1. Modifiez la valeur renvoyée depuis votre nuanceur de sommets, comme ceci :

index.html (appel 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. Dans la fonction @fragment, ajoutez un argument avec la même valeur @location pour la réception de la valeur. Les noms ne doivent pas nécessairement être identiques, mais cela permet de s'y retrouver plus facilement.

index.html (appel 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. Vous pouvez aussi utiliser une structure à la place :

index.html (appel createShaderModule)

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

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. Étant donné que ces deux fonctions sont définies dans le même module de nuanceur dans votre code, une autre solution consiste à réutiliser la structure de sortie de l'étape @vertex. Cela facilite la transmission des valeurs, car les noms et les emplacements sont naturellement cohérents.

index.html (appel createShaderModule)

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

Quelle que soit la méthode choisie, vous pouvez accéder au numéro de cellule dans la fonction @fragment et l'utiliser pour agir sur la couleur. Avec l'un ou l'autre des codes ci-dessus, le résultat se présente comme ceci :

Grille de carrés dans laquelle la colonne la plus à gauche est verte, la ligne du bas est rouge et tous les autres carrés sont jaunes

Votre grille est maintenant plus colorée, mais le résultat n'est pas très heureux. Vous vous demandez peut-être pourquoi seules les lignes de gauche et du bas sont différentes. En fait, les valeurs de couleur que vous renvoyez à partir de la fonction @fragment s'attendent à ce que chaque canal présente une valeur entre 0 et 1, et que toutes les valeurs en dehors s'alignent sur cette plage. D'autre part, les valeurs de cellule vont de 0 à 32 sur chaque axe. Comme vous pouvez le voir ici, la première ligne et la première colonne ont immédiatement atteint cette valeur complète de 1 sur le canal rouge ou vert, et toutes les cellules suivantes s'alignent sur la même valeur.

Si vous souhaitez une transition plus fluide entre les couleurs, vous devez renvoyer une valeur fractionnaire pour chaque canal de couleur, qui idéalement commence à zéro et se termine à un sur chaque axe, ce qui implique une autre division par grid.

  1. Modifiez le nuanceur de fragments comme ceci :

index.html (appel createShaderModule)

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

Si vous actualisez la page, vous constatez que le nouveau code produit effectivement un dégradé de couleurs bien plus attrayant sur l'ensemble de la grille.

Grille de carrés passant du noir au rouge, au vert et au jaune dans différents angles.

C'est en effet nettement mieux, mais il reste une zone sombre en bas à gauche, où la grille devient noire. Lorsque l'on lance la simulation du Jeu de la vie, une partie de la grille s'efface et empêche de voir ce qui se passe. Ce serait bien d'y voir plus clair.

Vous disposez pour cela d'un canal de couleur encore inutilisé, le bleu. L'effet que vous souhaitez obtenir est un bleu le plus lumineux possible qui contraste avec les couleurs plus sombres, puis qui se fond à mesure que celles-ci gagnent en intensité. La manière la plus simple est de faire débuter le canal à 1, puis de soustraire l'une des valeurs de cellule, soit c.x soit c.y. Essayez les deux, puis choisissez le rendu que vous préférez.

  1. Ajoutez des couleurs plus vives au nuanceur de fragments, comme ceci :

appel createShaderModule

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

Le résultat est plutôt joli !

Grille de carrés passant du rouge au vert, du bleu au jaune dans différents angles

Cette étape n'est pas essentielle, mais comme cette grille est plus attrayante, elle est enregistrée dans le fichier source du point de contrôle correspondant. Elle est réutilisée dans les captures d'écran suivantes de notre atelier de programmation.

7. Gérer l'état des cellules

Vous devez ensuite contrôler quelles cellules s'affichent sur la grille, en fonction de certains états stockés dans le GPU. Ce point est important pour la simulation finale.

Vous avez simplement besoin d'un signal "on/off" pour chaque cellule ; toute option vous permettant de stocker un grand tableau de quasiment tous les types de valeurs fera l'affaire. Vous pensez peut-être qu'il s'agit d'un autre cas d'utilisation des tampons de variables uniformes. Cela pourrait fonctionner, mais c'est plus difficile, car les tampons de variables uniformes sont de taille limitée, ils ne sont pas compatibles avec les tableaux de taille dynamique (vous devez spécifier la taille du tableau dans le nuanceur), et les nuanceurs de calcul ne peuvent pas y écrire de données. Ce dernier point est le plus problématique, étant donné que vous voulez lancer la simulation du Jeu de la vie sur le GPU dans un nuanceur de calcul.

Heureusement, il existe une autre option de tampon qui contourne toutes ces limites.

Créer un tampon de stockage

Les tampons de stockage sont des tampons à usage général dans lesquels les nuanceurs de calcul peuvent lire ou écrire, et que les nuanceurs de sommets peuvent lire. Ils peuvent être très volumineux et il n'est pas nécessaire de déclarer une taille spécifique dans un nuanceur. Ils s'apparentent donc davantage à une mémoire générale. Vous allez les utiliser pour stocker les états des cellules.

  1. Pour créer un tampon de stockage pour vos états de cellules, utilisez cet extrait de code, qui doit commencer à vous paraître familier :

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

De la même façon qu'avec les tampons de sommets et de variables uniformes, appelez device.createBuffer() avec la taille appropriée, puis veillez cette fois à spécifier une utilisation de type GPUBufferUsage.STORAGE.

Remplissez le tampon de la même manière qu'auparavant, en alimentant un TypedArray de même taille avec des valeurs, puis appelez device.queue.writeBuffer(). Pour observer l'effet du tampon sur la grille, commencez par lui attribuer une valeur prévisible.

  1. Activez la dernière cellule de chaque groupe de trois avec le code suivant :

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

Lire le tampon de stockage dans le nuanceur

Ensuite, modifiez votre nuanceur pour qu'il examine le contenu du tampon de stockage avant que vous affichiez la grille. L'opération est très semblable à la façon dont vous avez ajouté des variables uniformes précédemment.

  1. Modifiez votre nuanceur avec le code suivant :

index.html

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

Tout d'abord, vous ajoutez le point de liaison, qui se situe juste en dessous de la variable uniforme de grille. Vous devez conserver le même @group que pour la variable uniforme grid, mais le nombre @binding doit être différent. Le type var est passé à storage pour refléter le changement de type de tampon, et pour cellState, au lieu d'un seul vecteur, vous spécifiez que le type est un tableau de valeurs u32 pour assurer la correspondance avec Uint32Array dans JavaScript.

Ensuite, dans le corps de la fonction @vertex, interrogez l'état de la cellule. Comme celui-ci est stocké dans un tableau plat dans le tampon de stockage, vous pouvez utiliser l'instance_index afin de rechercher la valeur de la cellule actuelle.

Comment désactiver une cellule si l'état indique qu'elle est inactive ? Étant donné que les états actifs et inactifs que vous obtenez du tableau sont 1 ou 0, vous pouvez redimensionner la géométrie en fonction de l'état actif. Une mise à l'échelle à 1 ne change rien à la géométrie, et une mise à l'échelle à 0 la réduit à un seul point, lequel sera ignoré par le GPU.

  1. Mettez à jour le code du nuanceur pour ajuster la position en fonction de l'état actif de la cellule. La valeur d'état doit être convertie en f32 afin de répondre aux exigences de sécurité du typage WGSL :

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

Ajouter le tampon de stockage au groupe de liaisons

Pour pouvoir constater l'effet de l'état de la cellule, ajoutez le tampon de stockage à un groupe de liaisons. Comme ce tampon fait partie du même @group que le tampon de variables uniformes, ajoutez-le aussi au même groupe de liaisons dans le code JavaScript.

  • Ajoutez le tampon de stockage, comme ceci :

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

Assurez-vous que la valeur binding de la nouvelle entrée correspond au @binding() de la valeur correspondante dans le nuanceur.

Une fois que cela est en place et que vous avez actualisé, le modèle doit apparaître sur la grille.

Bandes de carrés colorés en diagonale du bas à gauche vers le haut à droite sur un fond bleu foncé.

Utiliser le schéma de tampon ping-pong

La plupart des simulations semblables à celle que vous créez utilisent au moins deux copies de leur état. À chaque étape de la simulation, la lecture s'exécute sur une copie de l'état, et l'écriture sur l'autre. À l'étape suivante, le mouvement s'inverse, la lecture s'exécute sur l'état qui vient d'être écrit. Ce procédé est communément appelé schéma ping-pong, car la version la plus récente de l'état passe de l'une à l'autre des copies à chaque étape.

Pourquoi est-ce nécessaire ? Prenons un exemple simplifié : imaginez que vous écrivez une simulation très basique, dans laquelle vous déplacez chaque bloc actif d'une cellule vers la droite à chaque étape. Pour que cela soit facilement compréhensible, vous définissez vos données et votre simulation en 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.

Toutefois, si vous exécutez ce code, la cellule active se déplace jusqu'à la fin du tableau en une seule étape. Pourquoi ? Comme vous continuez à mettre à jour l'état, vous déplacez la cellule active vers la droite, puis vous regardez la cellule suivante, et… Elle est active ! Alors vous continuez vers la droite. Le fait de modifier les données en même temps que vous les observez corrompt les résultats.

En utilisant le modèle ping-pong, vous êtes certain de toujours effectuer l'étape suivante de la simulation uniquement à partir des résultats de l'étape précédente.

// 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. Mettez à jour l'allocation de votre tampon de stockage afin de créer deux tampons identiques en appliquant ce modèle dans votre code :

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. Pour visualiser la différence entre les deux tampons, alimentez-les avec des données différentes :

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. Pour afficher les différents tampons de stockage dans votre rendu, mettez à jour vos groupes de liaisons pour qu'ils présentent deux variantes différentes :

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

Configurer une boucle de rendu

Vous n'avez effectué jusqu'à présent qu'un seul dessin à chaque actualisation de page, mais vous souhaitez maintenant afficher les données mises à jour au fur et à mesure. Vous avez besoin pour cela d'une simple boucle de rendu.

Une boucle de rendu est une boucle sans fin qui dessine votre contenu sur le canevas à un certain intervalle. De nombreux jeux et autres contenus qui doivent être animés de manière fluide utilisent la fonction requestAnimationFrame() pour planifier des rappels ajustés sur le taux de rafraîchissement de l'écran (60 fois par seconde).

Cette application peut également utiliser cette fonction, mais dans ce cas, vous devrez probablement actualiser les données selon des intervalles plus longs afin de suivre plus facilement les opérations de la simulation. Vous pouvez gérer la boucle vous-même afin de contrôler la fréquence d'actualisation de votre simulation.

  1. Choisissez d'abord une fréquence d'actualisation pour la simulation (200 ms par exemple, mais celle-ci peut être plus lente ou plus rapide si vous le souhaitez), puis suivez le nombre d'étapes de simulation qui ont été effectuées.

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. Déplacez ensuite tout le code que vous utilisez actuellement pour le rendu dans une nouvelle fonction. Programmez la répétition de cette fonction à l'intervalle souhaité avec setInterval(). Assurez-vous également que la fonction met à jour le nombre d'étapes, et utilisez cette valeur pour choisir le groupe de liaisons à associer.

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

À présent, lorsque vous exécutez l'application, vous constatez que le canevas s'inverse et affiche successivement les deux tampons d'état que vous avez créés.

Bandes de carrés colorés en diagonale du bas à gauche vers le haut à droite sur un fond bleu foncé. Bandes verticales de carrés colorés sur fond bleu foncé.

Le travail de rendu est quasiment terminé ! Vous êtes prêt à afficher la sortie de la simulation du Jeu de la vie que vous allez créer à l'étape suivante, où vous utiliserez des nuanceurs de calcul.

Bien évidemment, les capacités de rendu de WebGPU offrent bien plus de possibilités que la petite partie qui vous a été présentée ici, mais nous n'irons pas plus loin dans cet atelier de programmation. Nous espérons que cette introduction aux fonctionnalités de rendu de WebGPU vous aidera dans l'exploration de techniques plus avancées comme le rendu 3D.

8. Exécuter la simulation

Passons maintenant à la dernière pièce majeure du puzzle : la simulation du Jeu de la vie dans un nuanceur de calcul.

Utiliser les nuanceurs de calcul

Cet atelier vous a apporté une idée générale des nuanceurs de calcul, mais de quoi s'agit-il concrètement ?

Les nuanceurs de calcul sont semblables aux nuanceurs de sommets et de fragments en ce sens qu'ils sont conçus pour s'exécuter avec un parallélisme extrême sur le GPU. Cependant, contrairement aux deux autres types, ils ne comportent pas d'ensemble spécifique d'entrées et de sorties. La lecture et l'écriture des données s'effectue exclusivement à partir des sources de votre choix, telles que des tampons de stockage. Cela signifie qu'au lieu de les exécuter une fois pour chaque sommet, instance ou pixel, vous devez leur indiquer le nombre d'appels de la fonction de nuanceur que vous souhaitez. Ensuite, lorsque vous exécutez le nuanceur, l'appel en cours de traitement vous est indiqué, et vous pouvez décider à quelles données vous allez accéder et quelles opérations vous allez effectuer à partir de là.

Les nuanceurs de calcul doivent être créés dans un module de nuanceurs, de la même façon que les nuanceurs de sommets et de fragments. Ajoutez ce paramètre à votre code pour commencer. Comme vous pouvez le deviner compte tenu de la structure des autres nuanceurs que vous avez implémentés, la fonction principale de votre nuanceur de calcul doit être marquée avec l'attribut @compute.

  1. Créez un nuanceur de calcul avec le code suivant :

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() {

    }`
});

Les GPU étant fréquemment utilisés pour les graphismes 3D, les nuanceurs de calcul sont structurés de sorte que vous puissiez demander qu'ils soient appelés un certain nombre de fois sur les axes X, Y et Z. Vous pouvez ainsi facilement coordonner les tâches selon que vous utilisez une grille 2D ou 3D, ce qui est idéal pour votre cas d'utilisation. Vous allez appeler ce nuanceur GRID_SIZE x GRID_SIZE fois, soit une fois pour chaque cellule de la simulation.

En raison de la nature de l'architecture matérielle du GPU, cette grille est divisée en groupes de travail. Les groupes de travail ont des tailles X, Y et Z, et bien que chacune de ces tailles puisse être définie sur 1, agrandir légèrement vos groupes de travail présente souvent des avantages en termes de performances. Pour votre nuanceur, choisissez une taille de groupe de travail arbitraire de 8 fois 8, afin de faciliter le suivi dans votre code JavaScript.

  1. Définissez une constante pour le groupe de travail, comme ceci :

index.html

const WORKGROUP_SIZE = 8;

Vous devez également ajouter la taille du groupe de travail à la fonction du nuanceur elle-même à l'aide de littéraux de modèle JavaScript pour pouvoir facilement utiliser la constante que vous venez de définir.

  1. Ajoutez la taille du groupe de travail à la fonction du nuanceur, comme ceci :

index.html (appel Compute createShaderModule)

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

}

Cela indique au nuanceur que le travail effectué avec cette fonction est réalisé en groupes de (8 x 8 x 1). Par défaut, la valeur d'un axe est égale à 1, mais vous devez au moins spécifier l'axe X.

Comme pour les autres étapes de nuanceur, vous pouvez accepter diverses valeurs d'entrée @builtin dans votre fonction de nuanceur de calcul pour indiquer sur quel appel vous vous trouvez et décider du travail à effectuer.

  1. Ajoutez une valeur @builtin, comme ceci :

index.html (appel Compute createShaderModule)

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

}

Vous transmettez la valeur builtin global_invocation_id, un vecteur tridimensionnel d'entiers non signés qui vous indique votre position dans la grille d'appels du nuanceur. Vous exécutez ce nuanceur une fois pour chaque cellule de votre grille. Vous obtenez des nombres comme (0, 0, 0), (1, 0, 0), (1, 1, 0)... et ainsi de suite jusqu'à (31, 31, 0), ce qui signifie que vous pouvez les traiter comme l'index de cellule sur lequel vous travaillez.

Vous pouvez également utiliser des variables uniformes avec les nuanceurs de calcul, de la même façon qu'avec les nuanceurs de sommets et de fragments.

  1. Utilisez une variable uniforme avec votre nuanceur de calcul pour déterminer la taille de la grille :

index.html (appel 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) {

}

Comme dans le nuanceur de sommets, vous exposez également l'état de cellule en tant que tampon de stockage. Mais dans le cas présent, il vous en faut deux. Étant donné que les nuanceurs de calcul ne disposent pas d'une sortie appropriée, comme une position de sommet ou une couleur de fragment, l'écriture de valeurs dans un tampon de stockage ou une texture est le seul moyen d'obtenir des résultats à partir d'un nuanceur de calcul. Appliquez la méthode ping-pong que vous avez étudiée précédemment. Vous avez donc un tampon de stockage qui alimente l'état actuel de la grille et un autre dans lequel vous écrivez le nouvel état.

  1. Exposez l'état des entrées et sorties des cellules sous forme de tampons de stockage, comme ceci :

index.html (appel 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) {

}

Notez que le premier tampon de stockage est déclaré avec var<storage>, ce qui le définit en lecture seule, tandis que le deuxième est déclaré avec var<storage, read_write>. Cela vous permet à la fois de lire et d'écrire dans le tampon, en l'utilisant comme sortie pour votre nuanceur de calcul. Sachez qu'il n'existe pas de mode de stockage en écriture seule dans WebGPU.

Ensuite, vous devez trouver un moyen de mapper votre index de cellule dans le tableau de stockage linéaire. C'est le contraire de ce que vous avez fait dans le nuanceur de sommets, où vous avez pris l'instance_index linéaire et l'avez mappé à une cellule de grille 2D. (Pour rappel, votre algorithme était vec2f(i % grid.x, floor(i / grid.x)).)

  1. Écrivez une fonction pour aller dans l'autre sens. Cette fonction prend la valeur Y de la cellule, la multiplie par la largeur de la grille, puis ajoute la valeur X de la cellule.

index.html (appel 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) {
  
}

Enfin, pour vérifier que tout fonctionne, implémentez un algorithme très simple : si une cellule est actuellement activée, elle doit se désactiver, et inversement. Ce n'est pas encore le Jeu de la vie, mais cela suffit pour montrer que le nuanceur de calcul fonctionne.

  1. Ajoutez ce simple algorithme, comme ceci :

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

C'est tout pour votre nuanceur de calcul, du moins pour le moment ! Mais avant de voir les résultats, vous devez apporter quelques modifications.

Utiliser un groupe de liaisons et des mises en forme de pipeline

Comme vous pouvez le remarquer, le nuanceur ci-dessus utilise en grande partie les mêmes entrées (variables uniformes et tampons de stockage) que votre pipeline de rendu. Vous devez penser que vous pouvez simplement utiliser les mêmes groupes de liaisons pour parvenir à vos fins, n'est-ce pas ? Eh bien, oui ! Pour cela, il faut juste un peu plus de paramétrage manuel.

Chaque fois que vous créez un groupe de liaisons, vous devez fournir un élément GPUBindGroupLayout. Vous avez précédemment obtenu cette mise en forme en appelant getBindGroupLayout() sur le pipeline de rendu, lequel l'a alors créée automatiquement, car vous avez indiqué layout: "auto" lors de la création du pipeline. Cette approche fonctionne bien pour un seul pipeline, mais si vous en avez plusieurs qui doivent partager des ressources, vous devez créer explicitement la mise en forme, puis la fournir au groupe de liaisons et aux pipelines.

En effet, même si vous utilisez un seul tampon de variables uniformes et un seul tampon de stockage pour vos pipelines de rendu, le nuanceur de calcul que vous venez d'écrire nécessite un deuxième tampon de stockage. Comme les deux nuanceurs utilisent les mêmes valeurs @binding pour le tampon de variables uniformes et le premier tampon de stockage, vous pouvez les partager entre les pipelines. Le pipeline de rendu ignore le deuxième tampon de stockage, qu'il n'utilise pas. Vous devez créer une mise en forme qui décrit toutes les ressources présentes dans le groupe de liaisons, et pas seulement celles utilisées par un pipeline spécifique.

  1. Pour créer cette mise en forme, appelez 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
  }]
});

La structure est semblable à celle utilisée pour la création du groupe de liaisons lui-même, dans la mesure où vous décrivez une liste d'entries. La différence est que vous décrivez le type de ressource associé à l'entrée et la manière dont celle-ci est utilisée au lieu de fournir la ressource elle-même.

Dans chaque entrée, vous attribuez le numéro de binding à la ressource qui, comme vous l'avez appris lors de la création du groupe de liaisons, correspond à la valeur @binding dans les nuanceurs. Vous indiquez également une valeur visibility, qui correspond aux indicateurs GPUShaderStage qui spécifient quelles étapes du nuanceur peuvent utiliser la ressource. Vous souhaitez que le tampon de variables uniformes et le premier tampon de stockage soient accessibles dans les nuanceurs de sommets et de calcul, mais que le deuxième tampon de stockage ne le soit que dans les nuanceurs de calcul.

Enfin, vous devez indiquer le type de ressource utilisé. Il s'agit d'une clé de dictionnaire différente selon ce que vous devez exposer. Ici, les trois ressources sont des tampons. Vous devez donc utiliser la clé buffer pour définir les options de chacune. Il existe d'autres options, telles que texture ou sampler, mais vous n'en avez pas besoin ici.

Dans le dictionnaire de tampons, vous définissez des options telles que le type de tampon utilisé. "uniform" est le type par défaut. Vous pouvez laisser le dictionnaire vide pour la liaison 0. Notez toutefois que vous devez définir au moins buffer: {} pour que l'entrée soit identifiée comme un tampon. Le type "read-only-storage" est attribué à la liaison 1, car vous ne l'utilisez pas avec l'accès read_write dans le nuanceur, et le type "storage" à la liaison 2, car vous l'utilisez avec l'accès read_write.

Une fois la mise en forme bindGroupLayout créée, vous pouvez la transmettre au moment de créer vos groupes de liaisons au lieu de l'interroger à partir du pipeline. Cela signifie que vous devez ajouter une nouvelle entrée de tampon de stockage à chaque groupe de liaisons pour assurer la correspondance avec la mise en forme que vous venez de définir.

  1. Mettez à jour la création de groupe de liaisons comme ceci :

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

Maintenant que le groupe de liaisons a été modifié pour utiliser cette mise en forme de groupe de liaisons explicite, vous devez modifier le pipeline de rendu afin qu'il utilise le même attribut.

  1. Créer une mise en forme GPUPipelineLayout

index.html

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

Une mise en forme de pipeline est une liste de mises en forme de groupes de liaisons (dans le cas présent, vous en avez une) utilisées par un ou plusieurs pipelines. L'ordre des mises en forme des groupes de liaisons dans le tableau doit correspondre aux attributs @group dans les nuanceurs. Autrement dit, bindGroupLayout est associé à @group(0).

  1. Une fois que vous disposez de la mise en forme du pipeline, mettez à jour le pipeline de rendu pour qu'il l'utilise à la place de "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
    }]
  }
});

Créer le pipeline de calcul

Tout comme vous avez besoin d'un pipeline de rendu pour utiliser vos nuanceurs de sommets et de fragments, il vous faut un pipeline de calcul pour utiliser votre nuanceur de calcul. Heureusement, les pipelines de calcul sont beaucoup moins complexes que les pipelines de rendu, car ils n'ont pas à définir d'état, mais uniquement le nuanceur et la mise en forme.

  • Créez un pipeline de calcul avec le code suivant :

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

Notez que vous transmettez la nouvelle mise en forme pipelineLayout au lieu de "auto", comme dans le pipeline de rendu modifié. Ainsi, votre pipeline de rendu et votre pipeline de calcul peuvent utiliser les mêmes groupes de liaisons.

Passes de calcul

Tout cela vous amène enfin à l'utilisation effective du pipeline de calcul. Étant donné que vous effectuez le rendu dans une passe de rendu, vous devinez sans doute que vous effectuez les tâches de calcul dans une passe de calcul. Les opérations de calcul et de rendu pouvant s'exécuter dans le même encodeur de commande, vous devez légèrement modifier votre fonction updateGrid.

  1. Déplacez la création de l'encodeur en haut de la fonction, puis lancez une passe de calcul à partir de là (avant step++).

index.html

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

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

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

À l'instar des pipelines de calcul, les passes de calcul sont beaucoup plus simples à lancer que leurs homologues de rendu, car vous n'avez pas à vous soucier des rattachements.

Vous devez effectuer la passe de calcul avant la passe de rendu pour que celle-ci puisse utiliser immédiatement les derniers résultats de la passe de calcul. C'est aussi la raison pour laquelle vous augmentez par incréments le nombre de step entre les passes, de sorte que le tampon de sortie du pipeline de calcul devienne le tampon d'entrée du pipeline de rendu.

  1. Ensuite, définissez le pipeline et le groupe de liaisons dans la passe de calcul en utilisant le même schéma de bascule entre les groupes de liaisons que pour la passe de rendu.

index.html

const computePass = encoder.beginComputePass();

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

computePass.end();
  1. Enfin, au lieu de dessiner comme dans une passe de rendu, vous envoyez la tâche au nuanceur de calcul, en lui indiquant le nombre de groupes de travail à exécuter sur chaque axe.

index.html

const computePass = encoder.beginComputePass();

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

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

computePass.end();

Un point très important à noter ici est que le nombre que vous transmettez dans dispatchWorkgroups() n'est pas le nombre d'appels. Il s'agit en fait du nombre de groupes de travail à exécuter, tel que défini par @workgroup_size dans votre nuanceur.

Si vous souhaitez que le nuanceur s'exécute 32 x 32 fois pour couvrir l'intégralité de votre grille, et que la taille de votre groupe de travail est de 8 x 8, vous devez envoyer 4 x 4 groupes de travail (4 * 8 = 32). C'est pourquoi vous divisez la taille de la grille par la taille du groupe de travail et transmettez cette valeur dans dispatchWorkgroups().

Vous pouvez maintenant actualiser à nouveau la page ; vous devriez voir que la grille s'inverse à chaque mise à jour.

Bandes de carrés colorés en diagonale du bas à gauche vers le haut à droite sur un fond bleu foncé. Bandes de carrés colorés en diagonale formées de deux carrés en largeur allant de l&#39;angle inférieur gauche à l&#39;angle supérieur droit sur fond bleu foncé. Inversion de l&#39;image précédente.

Implémenter l'algorithme pour le Jeu de la vie

Avant de mettre à jour le nuanceur de calcul pour implémenter l'algorithme final, vous devez revenir au code qui initialise le contenu du tampon de stockage et le mettre à jour pour générer un tampon aléatoire à chaque chargement de page. (Les schémas habituels présentent peu d'intérêt comme points de départ du Jeu de la vie.) Vous pouvez randomiser les valeurs comme vous le souhaitez, mais il existe un moyen simple de commencer en obtenant des résultats corrects.

  1. Pour démarrer chaque cellule dans un état aléatoire, modifiez l'initialisation de cellStateArray avec le code suivant :

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

Vous pouvez maintenant implémenter la logique de la simulation du Jeu de la vie. Après tous les efforts accomplis pour en arriver là, le code du nuanceur va vous paraître extrêmement simple.

Tout d’abord, pour une cellule donnée, vous devez savoir combien de ses voisines sont actives. Ne cherchez pas à savoir lesquelles sont actives, mais uniquement combien.

  1. Pour faciliter la récupération des données des cellules voisines, ajoutez une fonction cellActive qui renvoie la valeur cellStateIn de la coordonnée concernée.

index.html (appel Compute createShaderModule)

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

La fonction cellActive renvoie "1" si la cellule est active. Par conséquent, la valeur renvoyée lors de l'appel de cellActive pour les huit cellules avoisinantes vous indique le nombre de cellules voisines actives.

  1. Trouvez le nombre de voisines actives comme ceci :

index.html (appel 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);

Cela pose toutefois un petit problème : que se passe-t-il lorsque la cellule que vous vérifiez n'est pas en bordure du tableau ? Selon votre logique cellIndex() actuelle, soit elle déborde sur la ligne suivante ou précédente, soit elle dépasse la limite du tampon.

Pour le Jeu de la vie, un moyen courant et simple de résoudre ce problème est de faire en sorte que les cellules situées sur le bord de la grille traitent celles situées à l'opposé comme leurs voisines, créant ainsi un effet enveloppant.

  1. Activez l'enveloppement de la grille en modifiant légèrement la fonction cellIndex().

index.html (appel Compute createShaderModule)

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

En encapsulant les cellules X et Y à l'aide de l'opérateur % lorsqu'elles dépassent la taille de la grille, vous êtes certain de ne jamais accéder en dehors des limites du tampon de stockage. Vous pouvez ainsi être sûr que le nombre activeNeighbors est prévisible.

Ensuite, appliquez l'une des quatre règles suivantes :

  • Toute cellule ayant moins de deux voisines devient inactive.
  • Toute cellule active ayant deux ou trois voisines reste active.
  • Toute cellule inactive ayant exactement trois voisines devient active.
  • Toute cellule ayant plus de trois voisines devient inactive.

Vous pouvez le faire avec une série d'instructions "if", mais WGSL accepte également les instructions "switch", qui conviennent à cette logique.

  1. Implémentez le Jeu de la vie, comme ceci :

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

Pour référence, l'appel final du nuanceur de calcul se présente maintenant comme ceci :

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

Et voilà ! Vous avez terminé ! Actualisez la page et observez votre nouvel automate cellulaire se développer !

Capture d&#39;écran d&#39;un exemple d&#39;état de la simulation du Jeu de la vie présentant des cellules de couleur affichées sur un fond bleu foncé.

9. Félicitations !

Vous avez créé une version de la simulation classique du Jeu de la vie de Conway, qui s'exécute entièrement sur votre GPU à l'aide de l'API WebGPU.

Et ensuite ?

Complément d'informations

Documents de référence