1. Introduction
Dernière mise à jour:13 avril 2023
Qu'est-ce que le GPU Web ?
WebGPU est une nouvelle API moderne permettant d'accéder aux fonctionnalités de votre GPU dans les applications Web.
API moderne
Avant WebGPU, il existait une solution WebGL, qui offrait un sous-ensemble de fonctionnalités. Elle a permis à une nouvelle classe de contenu Web enrichi que les développeurs ont développée d'incroyables. Elle était toutefois basée sur l'API OpenGL ES 2.0, publiée en 2007, qui était 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 elles ont également évolué avec Direct3D 12, Metal et Vulkan.
Le GPU Web apporte les avancées de ces API modernes à la plate-forme Web. Il met l'accent sur l'activation des fonctionnalités de GPU sur plusieurs plates-formes tout en présentant une API qui s'intègre naturellement au Web et est moins détaillée que certaines des API natives sur lesquelles il repose.
Affichage
Les GPU sont souvent associés à un rendu graphique rapide et détaillé, et WebGPU ne fait pas exception. Il offre les fonctionnalités requises pour un grand nombre des techniques de rendu les plus courantes sur les GPU pour ordinateur et pour mobile, et offre la possibilité d'ajouter de nouvelles fonctionnalités à mesure que les capacités matérielles évoluent.
Calcul
En plus de l'affichage, 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. Ces nuanceurs de calcul peuvent être utilisés indépendamment, 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 capacité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 :
- Utilisez les fonctionnalités de rendu WebGPU pour dessiner des graphismes 2D simples.
- Utilisez les capacités de calcul de WebGPU pour effectuer la simulation.
Dans la série "Game of Life", on appelle un "automate mobile". Un réseau de cellules change d'état au fil du temps en fonction d'un ensemble de règles. Dans le jeu 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.
Points abordés
- Comment configurer le GPU Web et un canevas.
- Dessiner une géométrie simple en 2D
- Utiliser les nuanceurs de sommet et de fragment pour modifier le dessin
- Utiliser des nuanceurs de calcul pour effectuer une simulation simple
Cet atelier de programmation présente les concepts fondamentaux concernant WebGPU. Il ne s'agit pas d'un examen complet de l'API et il ne couvre pas (ou n'exige pas) de sujets fréquemment associés tels que les mathématiques 3D.
Ce dont vous aurez besoin
- Une version récente de Chrome (113 ou version ultérieure) sur ChromeOS, macOS ou Windows WebGPU est une API multiplate-forme et multinavigateur, mais n'est pas disponible partout.
- Connaissances des langages HTML, JavaScript et des Outils pour les développeurs Chrome
Vous n'êtes pas obligé de connaître d'autres API graphiques, telles que WebGL, Metal, Vulkan ou Direct3D. Toutefois, si vous les connaissez, vous constaterez probablement de nombreuses similitudes avec le GPU Web pour vous aider à démarrer 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. Cependant, certains exemples pouvant servir de points de contrôle sont disponibles à l'adresse https://glitch.com/edit/#!/your-first-webgpu-app. Vous pouvez les consulter et les consulter au fur et à mesure si vous rencontrez des difficultés.
Utilisez la Play Console.
WebGPU est une API assez complexe reposant sur un grand nombre de règles qui garantissent une utilisation appropriée. Pire encore, en raison du fonctionnement de l'API, elle ne peut pas générer d'exceptions JavaScript courantes pour de nombreuses erreurs. Il est donc plus difficile de déterminer précisément l'origine du problème.
Vous renverrez des problèmes lorsque vous développerez des GPU Web, surtout pour les débutants, et ce n'est pas un problème ! Les développeurs à l'origine de l'API sont conscients des difficultés liées au développement de GPU. Ils ont travaillé sans relâche pour s'assurer que chaque fois que le code WebGPU entraîne une erreur, vous recevez des messages très détaillés et utiles dans la Play Console. Ils vous aident à identifier et à résoudre le problème.
Il est toujours utile de garder la console ouverte lorsque vous travaillez sur n'importe quelle application Web, mais c'est particulièrement le cas ici.
3. Initialiser le GPU Web
Commencez par une <canvas>
Le GPU Web peut être utilisé sans afficher aucun élément à l'écran si vous souhaitez l'utiliser pour effectuer des calculs. Toutefois, si vous souhaitez effectuer un rendu, comme nous le ferons 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 interrogeons 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 vous intéresser aux bits de GPU Web ! Tout d'abord, n'oubliez pas que la propagation des API telles que WebGPU dans l'ensemble de l'écosystème Web peut prendre un certain temps. Par conséquent, nous vous recommandons de commencer par vérifier si le navigateur de l'utilisateur peut utiliser le GPU Web.
- Pour vérifier si l'objet
navigator.gpu
, qui sert de point d'entrée pour 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 si le GPU Web n'est pas disponible en faisant revenir la page à un mode qui n'utilise pas WebGPU. (Il pourrait peut-être utiliser WebGL à la place.) 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 le GPU Web est compatible avec le navigateur, la première étape de l'initialisation du WebGPU pour votre application consiste à demander un élément GPUAdapter
. Vous pouvez considérer un adaptateur comme une représentation WebGPU d'un matériel GPU spécifique sur votre appareil.
- Pour obtenir un adaptateur, utilisez la méthode
navigator.gpu.requestAdapter()
. Il renvoie une promesse. Il est donc plus pratique de l'appeler avecawait
.
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
. Vous voulez donc 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 à l'utilisation de WebGPU.
La plupart du temps, vous pouvez laisser le navigateur choisir un adaptateur par défaut, comme vous le faites ici, mais pour des besoins plus avancés, des arguments peuvent être transmis à requestAdapter()
, qui spécifient si vous voulez utiliser du matériel basse consommation ou des performances élevées sur des appareils avec 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. L'appareil est l'interface principale qui permet d'interagir le plus avec le GPU.
- Obtenez l'appareil en appelant
adapter.requestDevice()
, qui renvoie également une promesse.
index.html
const device = await adapter.requestDevice();
Comme pour requestAdapter()
, il est possible de transmettre des options ici pour des utilisations plus avancées, telles que l'activation de fonctionnalités matérielles spécifiques ou la demande de limites plus élevées. Mais pour les besoins de cet exemple, 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 qu'il s'affiche sur la page: configurez le canevas à utiliser avec l'appareil que vous venez de créer.
- Pour ce faire, commencez par demander un
GPUCanvasContext
à partir du canevas en appelantcanvas.getContext("webgpu")
. (Il s'agit du même appel que celui que vous utiliseriez pour initialiser les contextes Canvas 2D ou WebGL, à l'aide des types de contexte2d
etwebgl
, respectivement). Lecontext
renvoyé doit ensuite être associé à l'appareil à l'aide de la méthodeconfigure()
, comme suit:
index.html
const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
Vous pouvez transmettre quelques options, mais les plus importantes sont les device
avec lesquelles 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 disposées en mémoire. Le fonctionnement de la mémoire de texture dépasse le cadre de cet atelier de programmation. Il est important de savoir que le contexte du canevas fournit des textures à votre code pour dessiner, et que le format que vous utilisez peut avoir un impact sur l'efficacité avec laquelle le canevas affiche ces images. Les différents types d'appareils fonctionnent mieux lorsque différents formats de texture sont utilisés. Si vous n'utilisez pas le format préféré de l'appareil, des copies de mémoire supplémentaires peuvent se produire 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 toile. 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 disposez d'un appareil sur lequel le canevas est configuré, vous pouvez commencer à l'utiliser pour en modifier le contenu. Pour commencer, effacez-la avec une couleur unie.
Pour ce faire, ou presque avec WebGPU, vous devez lui fournir des commandes spécifiques.
- Pour ce faire, demandez à l'appareil de créer une
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 ce cas, effacement du canevas). L'étape suivante consiste à utiliser encoder
pour lancer un Render Pass.
Les passes de rendu se produisent lorsque toutes les opérations de dessin dans WebGPU se produisent. Chaque élément commence par un appel beginRenderPass()
, qui définit les textures qui reçoivent le résultat de toute commande de dessin effectuée. Des utilisations plus avancées peuvent fournir plusieurs textures, appelées pièces jointes, à diverses fins (par exemple, pour stocker la profondeur de la géométrie rendue ou fournir un anticrénelage). Toutefois, pour cette application, vous n'en avez besoin que d'un seul.
- Obtenez la texture à partir du contexte de canevas que vous avez créé précédemment en appelant
context.getCurrentTexture()
, qui renvoie une texture dont la largeur et la hauteur en pixels correspondent aux attributswidth
etheight
du canevas, et auxformat
spécifiés lors de l'appel decontext.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'une colorAttachment
. Les passes de rendu nécessitent de fournir un GPUTextureView
au lieu d'un GPUTexture
, qui indique les parties de la texture à afficher. Cela n'a d'importance que pour des cas d'utilisation plus avancés. Dans ce cas, vous appelez createView()
sans aucun argument sur la texture, ce qui indique que vous souhaitez que la carte de rendu utilise toute la texture.
Vous devez également spécifier l'action que le cycle de rendu doit effectuer avec la texture au début et à la fin:
- La valeur
"clear"
deloadOp
indique que vous souhaitez effacer la texture au début de la passe de rendu. - La valeur
"store"
pourstoreOp
indique qu'une fois le rendu du rendu terminé, vous souhaitez que les résultats de tout dessin effectué pendant le rendu soient enregistrés dans la texture.
Une fois que le rendu de la carte a commencé, vous ne faites rien. Au moins pour l'instant. Lancer le rendu de la rendu avec loadOp: "clear"
suffit à effacer la vue de la texture et le canevas.
- Mettez fin à 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 simplement ces appels n'entraîne pas l'exécution de ces opérations. Il enregistre simplement les commandes que le GPU utilisera plus tard.
- Pour créer un
GPUCommandBuffer
, appelezfinish()
sur l'encodeur de commandes. Le tampon de commande est un handle opaque pour les commandes enregistrées.
index.html
const commandBuffer = encoder.finish();
- Envoyez le tampon de commande au GPU à l'aide de l'
queue
deGPUDevice
. La file d'attente exécute toutes les commandes GPU, ce qui garantit que leur exécution est correctement ordonnée et correctement synchronisée. La méthodesubmit()
de la file d'attente utilise 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 que ces deux étapes soient réduites en une seule, comme le montrent les exemples de pages 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 l'afficher 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 à nouveau context.getCurrentTexture()
afin d'obtenir une nouvelle texture pour une passe de rendu.
- Actualisez la page. Notez que le canevas est noir. Félicitations ! Cela signifie que vous avez créé votre première application WebGPU.
Choisissez une couleur !
Pour tout dire, les carrés noirs sont assez ennuyeux. Avant de passer à la section suivante, prenez quelques instants pour le personnaliser un peu.
- Dans l'appel
device.beginRenderPass()
, ajoutez une ligne avec unclearValue
aucolorAttachment
, 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",
}],
});
clearValue
indique à la carte de rendu la couleur à utiliser lors de l'opération clear
au début de la carte. Le dictionnaire transmis contient quatre valeurs: r
pour le rouge, g
pour le vert, b
pour le bleu et a
pour la 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 }
est rouge vif.{ r: 1, g: 0, b: 1, a: 1 }
est violet vif.{ r: 0, g: 0.3, b: 0, a: 1 }
est vert foncé.{ r: 0.5, g: 0.5, b: 0.5, a: 1 }
est gris moyen.{ r: 0, g: 0, b: 0, a: 0 }
est le noir transparent 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.
- Une fois que vous avez choisi la couleur, actualisez la page. La couleur choisie doit s'afficher sur la toile.
4. Dessiner une géométrie
À la fin de cette section, votre application dessinera une géométrie simple sur le canevas: un carré coloré. Attention : l'apparence peut sembler fastidieuse pour un résultat aussi simple, car WebGPU est conçu pour afficher de façon très efficace beaucoup de données géométriques. L'un des effets secondaires de cette efficacité est qu'il peut sembler anormalement difficile d'effectuer des tâches relativement simples. Cependant, c'est ce que vous attendez si vous vous tournez vers une API comme WebGPU. Vous voulez en faire plus.
Comprendre le fonctionnement des GPU
Avant toute autre modification de code, il est utile de faire une présentation très rapide, simplifiée et de haut niveau de la manière dont les GPU créent les formes que vous voyez à l'écran. (N'hésitez pas à passer à la section "Définir des sommets" si vous connaissez déjà les principes de base du rendu GPU.)
Contrairement à une API telle que 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 le GPU Web), à 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 les triangles, car ils présentent de nombreuses propriétés mathématiques intéressantes qui les rendent faciles à traiter de manière efficace et prévisible. Presque tout ce que vous dessinez avec le GPU doit être divisé en triangles avant que celui-ci ne puisse le dessiner, et ces triangles doivent être définis par leurs points d'angle.
Ces points, ou sommets, sont donnés en fonction des valeurs X, Y et (pour le contenu 3D) Z qui définissent un point sur un système de coordonnées cartésien défini par WebGPU ou des API similaires. Il est plus facile d'envisager la structure du système de coordonnées en lien avec le canevas de votre page. Quelles que soient la largeur ou la hauteur de votre canevas, le bord gauche est toujours à -1 sur l'axe X et le bord droit à toujours +1 sur l'axe X. De même, le bord inférieur est toujours -1 sur l'axe Y et le bord supérieur est +1 sur l'axe Y. Cela signifie que (0, 0) correspond toujours au centre du canevas, (-1, -1) correspond toujours à l'angle inférieur gauche et (1, 1) au coin supérieur droit. C'est ce qu'on appelle l'espace clip.
Au départ, les sommets sont rarement définis dans ce système de coordonnées. Les GPU reposent donc sur de petits programmes appelés nuanceurs de sommet pour effectuer les calculs nécessaires à la transformation des sommets en un espace d'extrait, ainsi que tout autre calcul nécessaire pour dessiner les sommets. Par exemple, le nuanceur peut appliquer une animation ou calculer la direction du sommet à une source lumineuse. Ces nuanceurs sont écrits par vous, le développeur WebGPU, et ils offrent un contrôle incroyable sur le fonctionnement du GPU.
À partir de là, le GPU utilise tous les triangles constitués par 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 retour vert ou aussi complexe que le calcul de l'angle de la surface par rapport à la lumière du soleil qui rebondit sur d'autres surfaces à proximité, filtré par du brouillard et modifié par la surface métallique. Vous êtes sous votre contrôle, ce qui peut être à la fois autoritaire et stimulant.
Les résultats de ces couleurs de pixel sont ensuite accumulés dans une texture, qui peut ensuite être affichée à l'écran.
Définir les sommets
Comme indiqué précédemment, la simulation du jeu 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, un carré tracé au centre du canevas, tiré de ses bords d'un côté et d'une autre, présente des coordonnées d'angle comme suit:
Pour transmettre ces coordonnées au GPU, vous devez placer les valeurs dans un TypedArray. Si vous ne le connaissez pas encore, les TypedArrays sont un groupe d'objets JavaScript qui vous permet d'allouer des blocs de mémoire contigus et d'interpréter chaque élément de la série comme un type de données spécifique. Par exemple, dans un élément Uint8Array
, chaque élément du tableau est un octet unique et non signé. TypedArrays est idéal pour envoyer des données avec des API sensibles à la mise en page en mémoire, comme WebAssembly, WebAudio et (bien évidemment) WebGPU.
Pour l'exemple de carré, car les valeurs sont fractionnaires, un Float32Array
est approprié.
- Créez un tableau contenant toutes les positions des sommets du diagramme en plaçant la déclaration de tableau suivante dans votre code. Placez-le 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. Ils sont fournis à titre indicatif et sont plus lisibles. Elle vous permet de voir que chaque paire de valeurs constitue les coordonnées X et Y d'un sommet.
Il y a cependant un problème. Les GPU fonctionnent en termes de triangles. Cela signifie que vous devez fournir les sommets par groupes de trois. Vous avez un groupe de quatre. La solution consiste à répéter deux sommets pour créer deux triangles partageant une arête au milieu du carré.
Pour former le carré à partir du diagramme, vous devez lister 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 également scinder le carré avec les deux autres angles, cela ne change rien.
- Mettez à jour votre tableau
vertices
précédent pour qu'il se présente comme suit:
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 exactement identiques et le GPU les affiche sans blancs. Il s'affiche sous la forme d'un carré unique et uni.
Créer un tampon de sommet
Le GPU ne peut pas dessiner des sommets avec des données provenant d'un tableau JavaScript. Les GPU ont souvent 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 lorsqu'il dessine doivent être placées dans cette mémoire.
Pour de nombreuses valeurs, 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.
- Pour créer un tampon qui contiendra vos sommets, ajoutez l'appel suivant à
device.createBuffer()
après la définition de votre tableauvertices
.
index.html
const vertexBuffer = device.createBuffer({
label: "Cell vertices",
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
La première chose à noter est que vous attribuez un libellé au tampon. Chaque objet WebGPU que vous créez peut se voir attribuer un libellé facultatif, ce qui est votre souhait ! Vous pouvez utiliser n'importe quelle chaîne tant qu'elle vous aide à identifier l'objet. Si vous rencontrez des problèmes, ces étiquettes sont utilisées dans les messages d'erreur générés par le GPU pour vous aider à comprendre le problème.
Spécifiez ensuite une taille de la mémoire tampon pour les octets. Vous avez besoin d'un tampon de 48 octets, que vous déterminez en multipliant la taille d'une valeur flottante 32 bits ( 4 octets) par le nombre de valeurs flottantes dans votre tableau vertices
(12). Heureusement, TypedArrays calcule déjà leur byteLength, ce qui vous permet de l'utiliser lors de la création du tampon.
Enfin, vous devez spécifier l'utilisation du tampon. Il s'agit d'un ou de plusieurs indicateurs GPUBufferUsage
, plusieurs qui sont combinés avec l'opérateur |
( bitwise OR). Dans ce cas, vous devez spécifier que vous souhaitez utiliser le tampon pour les données de sommet (GPUBufferUsage.VERTEX
) et pour pouvoir y copier des données (GPUBufferUsage.COPY_DST
).
L'objet de tampon qui vous est renvoyé est opaque : vous ne pouvez pas facilement inspecter les données qu'il contient. De plus, la plupart de ses attributs sont immuables. Vous ne pouvez pas redimensionner une GPUBuffer
après sa création ni modifier les indicateurs d'utilisation. Vous pouvez modifier le contenu de sa mémoire.
Lors de la création initiale du tampon, la mémoire qu'elle contient est initialisée sur zéro. Il existe plusieurs façons de modifier son contenu, mais le plus simple consiste à appeler device.queue.writeBuffer()
avec un TypedArray que vous souhaitez copier.
- 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 page 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 si vous comptez dessiner. Vous devez être en mesure d'en dire plus sur la structure des données du sommet.
- Définissez la structure des données de sommet avec un dictionnaire
GPUVertexBufferLayout
:
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.
La première chose à faire est arrayStride
. Il s'agit du nombre d'octets que le GPU doit avancer dans le tampon lorsqu'il recherche le prochain sommet. Chaque sommet de votre carré est composé de deux nombres à virgule flottante 32 bits. Comme indiqué précédemment, un float 32 bits correspond à 4 octets, donc deux floats correspondent à 8 octets.
Ensuite, la propriété attributes
, qui est un tableau. Les attributs sont les informations individuelles encodées dans chaque sommet. Vos sommets ne contiennent qu'un seul attribut (la position des sommets), mais les cas d'utilisation plus avancés comportent souvent des sommets avec plusieurs attributs tels que la couleur d'un sommet ou la direction vers laquelle pointe la surface géométrique. Toutefois, nous n'allons pas nous y attarder dans cet atelier de programmation.
Dans votre attribut unique, vous définissez d'abord le format
des données. Elle provient d'une liste de types GPUVertexFormat
qui décrivent chaque type de données de sommet que le GPU peut comprendre. Vos sommets comportent deux floats 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 le schéma ?
offset
décrit ensuite le nombre d'octets dans le sommet à partir duquel cet attribut particulier. Vous ne devez vous préoccuper de cela que si votre tampon contient plusieurs attributs, que nous n'aborderons pas dans cet atelier de programmation.
Enfin, vous avez le shaderLocation
. Il s'agit d'un nombre arbitraire compris entre 0 et 15, qui doit être unique pour chaque attribut défini. Il associe cet attribut à une entrée spécifique du nuanceur de sommets, que vous découvrirez dans la section suivante.
Notez que, même si vous définissez ces valeurs maintenant, vous ne les transmettrez pas encore à l'API WebGPU. Cela est à venir, mais il est plus facile d'envisager ces valeurs au moment où vous définissez vos sommets. Vous les configurez donc maintenant pour les utiliser plus tard.
Commencer par les nuanceurs
Vous disposez maintenant des données que vous souhaitez afficher, mais vous devez encore indiquer précisément au GPU comment les traiter. Cela concerne en grande partie les nuanceurs.
Les nuanceurs sont de petits programmes que vous écrivez et qui s'exécutent sur votre GPU. Chaque nuanceur opère sur une étape différente des données: traitement Vertex, traitement Fragment ou calcul général. Comme elles sont exécutées sur le GPU, elles sont structurées de manière plus rigide que votre code JavaScript moyen. Mais cette structure leur permet de s'exécuter en parallèle très rapidement et, surtout, en parallèle.
Les nuanceurs dans WebGPU sont écrits dans un langage d'ombre appelé WGSL (WebGPU Shading Language). WGSL est, d'un point de vue syntaxique, un peu comme Rust, avec des fonctionnalités destinées à faciliter et accélérer les types de GPU courants (comme les mathématiques vectorielles et les matrices). L'enseignement de l'ensemble du langage d'ombrage va bien au-delà de cet atelier de programmation. Nous espérons que vous reviendrez sur certains points fondamentaux en parcourant quelques exemples simples.
Les nuanceurs eux-mêmes sont transmis à WebGPU en tant que chaînes.
- Créez un emplacement pour saisir votre code de nuanceur en copiant le code suivant dans votre code sous
vertexBufferLayout
:
index.html
const cellShaderModule = device.createShaderModule({
label: "Cell shader",
code: `
// Your shader code will go here
`
});
Pour créer les nuanceurs que vous appelez device.createShaderModule()
, vous devez fournir une chaîne facultative label
et WGSL code
en tant que chaîne. Notez que vous utilisez des accents graves ici pour autoriser les chaînes multilignes. Une fois que vous avez ajouté un code WGSL valide, la fonction renvoie un objet GPUShaderModule
avec les résultats compilés.
Définir le nuanceur de sommets
Commençons par le nuanceur de sommets, car c'est également 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 de votre vertexBuffer
. Comme votre vertexBuffer
comporte six positions (sommets), la fonction que vous définissez est appelée six fois. Chaque fois qu'elle est appelée, une position différente de 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 qu'ils ne seront pas forcément appelés dans un ordre séquentiel. À la place, les GPU excellent dans l'exécution de nuanceurs comme celui-ci 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 incroyablement rapides, mais cela s'accompagne de limites. Pour garantir un parallélisme extrême, les nuanceurs de sommets ne peuvent pas communiquer entre eux. Chaque appel du nuanceur ne peut voir que les données d'un seul sommet à la fois et ne peut générer des valeurs que pour un seul sommet.
Dans WGSL, un nuanceur de sommets peut être nommé comme vous le souhaitez, mais il doit contenir l'attribut @vertex
pour indiquer l'étape du nuanceur qu'il représente. WGSL indique les fonctions avec le mot clé fn
, utilise des parenthèses pour déclarer des arguments et des accolades pour définir la portée.
- Créez une fonction
@vertex
vide, comme ceci:
index.html (code createShaderModule)
@vertex
fn vertexMain() {
}
Ce n'est toutefois 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. Il est toujours donné sous forme de vecteur à quatre dimensions. Les vecteurs sont si courants d'utilisation 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
).
- 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 sûr, si la fonction a un type renvoyé, vous devez renvoyer une valeur dans le corps de la fonction. Vous pouvez construire un nouveau vec4f
à renvoyer à l'aide de 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 l'emplacement du sommet dans l'espace des extraits.
- 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, car le GPU reconnaît que les triangles qu'il produit ne forment qu'un seul point, puis le supprime.
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 et un type @location()
correspondant à ceux que vous avez décrits dans vertexBufferLayout
. Vous avez spécifié un shaderLocation
de 0
. Dans votre code WGSL, marquez l'argument avec @location(0)
. Vous avez également défini le format sur float32x2
, qui est un vecteur en 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 naturel.
- 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 le modifier légèrement. Vous devez récupérer les deux composants de l'argument de position et les placer dans les deux premiers composants du vecteur de retour, en laissant respectivement les deux derniers composants 0
et 1
.
- Renvoie la bonne position en indiquant explicitement les composants 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 mappages sont si courants dans les nuanceurs, vous pouvez également transmettre le vecteur de position en tant que premier argument d'un raccourci pratique. Cela signifie la même chose.
- Réécrivez l'instruction
return
avec 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 telle quelle, mais cela suffit pour se lancer.
Définir le nuanceur de fragments
Passons maintenant au nuanceur de fragments. Les nuanceurs de fragments fonctionnent de la même manière que les nuanceurs de sommet, 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 sommet. Le GPU extrait la sortie des nuanceurs de sommets et la triangule, créant des triangles à partir d'ensembles de trois points. Il rastérise chacun de ces triangles en déterminant les pixels des pièces jointes de couleur de sortie incluses dans ce 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 sommet 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 une vec4f
. Toutefois, dans ce cas, le vecteur représente une couleur, et non une position. Vous devez attribuer 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'une seule pièce jointe, la position est 0.
- Créez une fonction
@fragment
vide, comme ceci:
index.html (code createShaderModule)
@fragment
fn fragmentMain() -> @location(0) vec4f {
}
Les quatre composants du vecteur renvoyé sont les valeurs de couleur rouge, vert, bleu et alpha. Ils sont interprétés exactement comme le clearValue
que vous avez défini dans beginRenderPass
. vec4f(1, 0, 0, 1)
est donc rouge vif, ce qui semble être une couleur correcte pour votre carré. Vous êtes toutefois libre de le définir sur la couleur de votre choix.
- 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 c'est un nuanceur de fragments complet ! Ce n'est pas terriblement intéressant. Il définit simplement chaque pixel de chaque triangle sur rouge, mais c'est suffisant pour l'instant.
Pour résumer, après avoir ajouté le code du nuanceur expliqué ci-dessus, votre appel createShaderModule
se présente maintenant comme suit:
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. À la place, vous devez l'utiliser dans un GPURenderPipeline
, créé en appelant device.createRenderPipeline(). Le pipeline de rendu contrôle la façon de dessiner la géométrie, y compris les éléments utilisés pour utiliser les nuanceurs, l'interprétation des données dans les tampons de sommet et le type de géométrie à afficher (lignes, points, triangles, etc.).
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'un layout
qui décrit les types d'entrées (autres que les tampons de sommet) dont le pipeline a besoin, mais vous n'en avez pas vraiment. Heureusement, vous pouvez transmettre "auto"
pour le moment. Le pipeline crée sa propre mise en page à partir des nuanceurs.
Vous devez ensuite fournir des informations sur l'étape vertex
. Le module
est le GPU GPUShaderModule qui contient votre nuanceur de sommets, et entryPoint
donne le nom de la fonction dans le code du nuanceur appelé pour chaque appel du sommet. Vous pouvez avoir plusieurs fonctions @vertex
et @fragment
dans un seul module de nuanceur. Les tampons sont un tableau d'objets GPUVertexBufferLayout
décrivant la façon dont vos données sont empaquetées dans les tampons de sommet avec lesquels vous utilisez ce pipeline. Heureusement, vous l'avez déjà défini dans votre vertexBufferLayout
! C'est ici que vous le transmettez.
Enfin, vous avez des détails sur l'étape fragment
. Cela inclut également un module de nuanceur et un entryPoint, comme l'étape du sommet. La dernière étape consiste à définir le targets
avec lequel ce pipeline est utilisé. Il s'agit d'un tableau de dictionnaires contenant des détails (par exemple, la texture format
) des pièces jointes de couleur générées par le pipeline. Ces détails doivent correspondre aux textures fournies dans le colorAttachments
de toutes les passes de rendu avec lesquelles ce pipeline est utilisé. Votre pass de rendu utilise des textures du contexte du canevas et utilise la valeur que vous avez enregistrée dans canvasFormat
pour son format. Vous devez donc transmettre le même format ici.
Vous n'êtes pas loin de toutes les options que vous pouvez spécifier lors de la création d'un pipeline de rendu, mais cela suffit pour cet atelier de programmation.
Dessiner un carré
Vous disposez maintenant de tout ce dont vous avez besoin pour dessiner votre carré.
- Pour dessiner le carré, revenez aux paires d'appels
encoder.beginRenderPass()
etpass.end()
, puis ajoutez les commandes suivantes:
index.html
// After encoder.beginRenderPass()
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices
// before pass.end()
Toutes les informations nécessaires pour dessiner votre carré sont fournies au WebGPU. Tout d'abord, vous utiliserez setPipeline()
pour indiquer le pipeline à utiliser pour dessiner. Cela inclut les nuanceurs utilisés, la mise en page des données de sommet et d'autres données d'état pertinentes.
Appelez ensuite setVertexBuffer()
avec le tampon contenant les sommets de votre carré. Vous l'appelez avec 0
, car ce tampon correspond au 0e élément de la définition vertex.buffers
du pipeline actuel.
Enfin, effectuez l'appel draw()
, qui semble étrangement simple après la configuration précédente. La seule chose que vous devez transmettre est le nombre de sommets qu'il doit afficher, qu'il extrait des tampons de sommet actuellement définis et qu'il interprète avec le pipeline actuellement défini. Vous pourriez simplement le coder en dur dans 6
, mais en le calculant à partir du tableau des sommets (12 floats / 2 coordonnées par sommet = 6 sommets), cela signifie que si vous décidez de remplacer le carré par un cercle, il y aura moins de mises à jour manuelles.
- Actualisez votre écran et, enfin, affichez les résultats de tous vos efforts: un grand carré de couleur.
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. Toutes les actions sont effectuées par étapes. Il est ainsi plus facile de vérifier votre progression au fil du temps.
Dans cette section, vous allez apprendre à:
- Transmettre des variables (appelées "uniformes") au nuanceur à partir de JavaScript.
- Utiliser des 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 effectuer le rendu d'une grille, vous devez connaître une information essentielle. Dans combien de cellules contient-elle une largeur et une hauteur ? En tant que développeur, c'est vous qui décidez de simplifier les choses en traitant la grille comme un carré (largeur et hauteur identiques) et en utilisant une puissance de deux. (ce qui facilitera les choses plus tard). Vous souhaitez agrandir la grille, mais pour le reste de cette section, définissez la taille de votre grille sur 4 x 4. Vous pourrez ainsi mieux comprendre les calculs mathématiques de cette section. Passez à la vitesse supérieure
- 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 le rendu de votre carré afin de l'afficher GRID_SIZE
fois GRID_SIZE
sur le canevas. Le carré doit donc être bien plus petit et très grand.
Vous pouvez approcher cela de cette approche en augmentant considérablement la taille de votre tampon de sommet et en définissant GRID_SIZE
fois GRID_SIZE
de carrés à la taille et à la position appropriées. Ce n'est pas un problème ! Quelques boucles et quelques calculs mathématiques. Cependant, il ne s'agit pas d'utiliser au mieux le GPU et d'utiliser plus de mémoire que nécessaire pour obtenir l'effet. Cette section traite d'une approche plus adaptée aux GPU.
Créer une mémoire tampon uniforme
Tout d'abord, vous devez communiquer la taille de grille que vous avez choisie au nuanceur, car il l'utilise pour modifier l'affichage des éléments. Vous pourriez simplement coder la taille en dur dans le nuanceur, mais cela signifie que chaque fois que vous voulez modifier la taille de la grille, vous devez recréer le nuanceur et afficher le pipeline, ce qui est coûteux. Il est préférable de fournir la taille de la grille au nuanceur en tant qu'uniformes.
Vous avez appris précédemment qu'une valeur différente de la mémoire tampon des sommets est transmise à chaque appel d'un nuanceur de sommets. Un uniforme est une valeur de tampon qui est identique pour chaque appel. Ils sont utiles pour communiquer des valeurs communes à un élément géométrique (comme sa position), à un frame d'animation complet (comme l'heure actuelle) ou même à toute la durée de vie de l'application (comme une préférence utilisateur).
- Créez un tampon uniforme 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);
Cela devrait vous sembler bien familier, car il s'agit presque exactement du même code que celui que vous avez utilisé pour créer le tampon des sommets plus tôt. En effet, les uniformes sont communiqués à l'API WebGPU via les mêmes objets GPUBuffer que les sommets, la principale différence étant que usage
inclut cette fois GPUBufferUsage.UNIFORM
au lieu de GPUBufferUsage.VERTEX
.
Accédez à des uniformes dans un nuanceur
- Définissez un 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
Cet élément définit un uniforme de votre nuanceur appelé grid
, qui est un vecteur flottant en 2D correspondant au tableau que vous venez de copier dans le tampon uniforme. Il spécifie également que l'uniforme est lié à @group(0)
et @binding(0)
. Vous découvrirez la signification de ces valeurs dans un instant.
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 du sommet par le vecteur de la grille. Étant donné que pos
est un vecteur 2D et que grid
est un vecteur 2D, WGSL effectue une division au niveau du composant. En d'autres termes, le résultat est identique à vec2f(pos.x / grid.x, pos.y / grid.y)
.
Ces types d'opérations vectorielles sont très courants dans les nuanceurs GPU, car de nombreuses techniques de rendu et de calcul les utilisent.
Dans votre cas, cela signifie que (si vous avez utilisé une taille de grille de 4), le carré que vous affichez correspond au quart de sa taille d'origine. Cette approche est idéale si vous souhaitez en placer quatre sur une ligne ou une colonne.
Créer un groupe de liaison
Le fait de déclarer l'uniforme dans le nuanceur ne le connecte pas au tampon que vous avez créé. Pour ce faire, vous devez créer et définir un groupe de liens.
Un groupe de liaison est un ensemble de ressources que vous souhaitez rendre accessibles à votre nuanceur en même temps. Il peut inclure plusieurs types de tampons, comme votre tampon uniforme, ainsi que d'autres ressources telles que des textures et des échantillons, que nous n'avons pas abordées ici, mais qui font partie des techniques courantes de rendu GPU.
- Créez un groupe de liaison avec votre tampon uniforme en ajoutant le code suivant après la création du tampon uniforme 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 fichier label
standard, vous avez également besoin d'un élément 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 page du groupe de liaisons, car vous l'avez créé avec layout: "auto"
. Ainsi, le pipeline crée automatiquement des mises en page de groupe de liaisons à partir des liaisons que vous avez déclarées dans le code du nuanceur. Dans ce cas, vous la demandez à getBindGroupLayout(0)
, où 0
correspond au @group(0)
que vous avez saisi dans le nuanceur.
Après avoir spécifié la mise en page, vous fournissez un tableau entries
. Chaque entrée est un dictionnaire contenant au moins 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 souhaitez exposer à la variable au niveau de l'index de liaison spécifié. Dans ce cas, votre tampon uniforme.
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 liaison après sa création, mais vous pouvez modifier le contenu de ces ressources. Par exemple, si vous modifiez le tampon uniforme pour qu'il contienne une nouvelle taille de grille, cela est reflété par les futurs appels de dessin utilisant ce groupe de liaisons.
Lier le groupe de liaisons
Maintenant que le groupe de liaisons est créé, vous devez encore demander à WebGPU de l'utiliser pour le dessin. Heureusement, c'est assez simple.
- Revenez au rendu du rendu et ajoutez cette nouvelle 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);
Le 0
transmis en tant que premier argument correspond à l'@group(0)
dans le code du nuanceur. Vous indiquez que chaque @binding
faisant partie de @group(0)
utilise les ressources de ce groupe de liaisons.
Le nuanceur est désormais exposé à votre nuanceur.
- Actualisez la page. Vous devriez obtenir ce qui suit:
Parfait ! La taille de votre carré est alors égale à un quart de la taille précédente. Ce n'est pas grand chose, mais cela montre que votre uniforme est appliqué et que le nuanceur peut désormais accéder à la taille de votre grille.
Manipulation de la géométrie dans le nuanceur
Maintenant que vous pouvez référencer la taille de la grille dans le nuanceur, vous pouvez commencer à manipuler la géométrie du rendu pour l'adapter au modèle de grille souhaité. Pour ce faire, réfléchissez exactement à l'objectif que vous souhaitez atteindre.
D'un point de vue conceptuel, vous devez diviser votre canevas en cellules individuelles. Pour conserver la convention que l'axe X augmente à mesure que vous vous déplacez vers la droite et que l'axe Y augmente à mesure que vous vous déplacez, supposons que la première cellule se trouve en bas à gauche du canevas. Vous obtenez une mise en page semblable à celle-ci, avec la géométrie carrée actuelle au milieu:
Le défi consiste à trouver une méthode dans le nuanceur qui vous permet de positionner la géométrie carrée dans l'une de ces cellules en fonction des coordonnées de la cellule.
Tout d'abord, vous pouvez voir que le carré n'est pas aligné avec 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 résoudre ce problème, vous pouvez mettre à jour le tampon des sommets du carré. En déplaçant les sommets de sorte que, par exemple, (0,1, 0,1) au lieu de (-0,8, -0,8), l'angle inférieur droit soit aligné sur les limites de la cellule. 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.
- Modifiez le module du nuanceur de sommets avec le code suivant:
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 gauche d'une unité (qui correspond bien à la moitié de l'espace des extraits) avant de le diviser par la taille de la grille. Résultat : un carré aligné à l'aide d'une grille se trouve juste à côté de l'origine.
Ensuite, comme le système de coordonnées de votre canevas est placé (0, 0) au centre et (-1, -1) en bas à gauche, et que vous souhaitez que (0, 0) soit en bas à gauche, vous devez traduire la position de votre géométrie par (-1, -1) après la division de celle-ci par la taille de la grille pour la déplacer dans cet angle.
- Traduisez la position de votre géométrie comme suit:
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);
}
Et maintenant, votre carré est bien positionné dans la cellule (0, 0) !
Comment faire si vous voulez le placer dans une autre cellule ? Pour l'obtenir, déclarez un vecteur cell
dans votre nuanceur et renseignez-le avec une valeur statique telle que let cell = vec2f(1, 1)
.
Si vous l'ajoutez à gridPos
, il annule la - 1
dans l'algorithme. Vous ne voulez donc pas l'appliquer. Vous devez déplacer le carré d'une unité de grille (un quart de la toile) pour chaque cellule. On dirait que vous devez effectuer une autre division par grid
.
- Modifiez le positionnement de votre grille comme suit:
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 maintenant la page, le message suivant s'affichera:
Hmmm... Pas tout à fait.
En effet, étant donné que les coordonnées du canevas vont de -1 à +1, il s'agit en fait de deux unités réparties sur. Cela signifie que si vous voulez déplacer un sommet d'un quart de la toile, vous devez le déplacer de 0,5 unité. C'est une erreur facile à effectuer lorsque vous raisonnez à l'aide des coordonnées GPU. Heureusement, la solution est aussi simple.
- Multipliez votre décalage par deux, 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.
Voici à quoi ressemble la capture d'écran:
De plus, vous pouvez maintenant définir cell
sur n'importe quelle valeur dans les limites de la grille, puis l'actualiser pour afficher le carré à l'emplacement souhaité.
Dessiner des instances
Maintenant que vous pouvez placer le carré là où vous le souhaitez avec des calculs mathématiques, l'étape suivante consiste à afficher un carré dans chaque cellule de la grille.
Pour ce faire, vous pouvez par exemple écrire les coordonnées des cellules dans un tampon uniforme, puis appeler la fonction draw pour chaque carré de la grille et la mettre à jour à chaque fois. Ce serait toutefois très lent, car le GPU doit attendre que la nouvelle coordonnée soit écrite à chaque fois par JavaScript. Pour optimiser les performances du GPU, il est essentiel de réduire au maximum le temps passé sur les autres parties du système.
À la place, vous pouvez utiliser une technique appelée exploration. L'instanciation permet de demander au GPU de dessiner plusieurs copies de la même géométrie avec un seul appel à draw
, ce qui est beaucoup plus rapide que d'appeler draw
une fois pour chaque copie. Chaque copie de la géométrie est appelée instance.
- Pour indiquer au GPU que le nombre d'instances de votre carré doit 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 souhaitez lui dessiner les six sommets (vertices.length / 2
) de votre carré 16 fois (GRID_SIZE * GRID_SIZE
). Toutefois, si vous actualisez la page, les éléments suivants s'affichent toujours:
Pourquoi ? C'est parce que vous dessinez les 16 carrés au même endroit. Vous devez disposer d'une logique supplémentaire dans le nuanceur qui repositionne la géométrie par instance.
Dans le nuanceur, en plus des attributs de sommet tels que pos
provenant de votre tampon de sommet, vous pouvez accéder aux valeurs intégrées de WGSL. Ces valeurs sont calculées par WebGPU. instance_index
est l'une de ces valeurs. 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 chaque sommet traité dans 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 sommet. Six autres fois avec une instance_index
de 1
, puis six autres avec instance_index
de 2
, et ainsi de suite.
Pour voir comment cela fonctionne, vous devez ajouter le instance_index
intégré à vos entrées de nuanceur. Procédez de la même manière que pour la position, mais au lieu de le taguer avec un attribut @location
, utilisez @builtin(instance_index)
, puis attribuez-lui le nom de votre choix. Vous pouvez l'appeler instance
pour qu'il corresponde à l'exemple de code. Utilisez-le ensuite dans la logique du nuanceur.
- Utilisez
instance
à la place des coordonnées de la cellule:
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 maintenant, vous verrez que vous avez plusieurs carrés ! mais vous ne pouvez pas les voir toutes.
Cela s'explique par le fait que les coordonnées de cellule que vous générez sont (0, 0), (1, 1), (2, 2), etc. jusqu'à (15, 15), mais seules les quatre premières de ces valeurs sont adaptées. Pour obtenir la grille souhaitée, vous devez transformer instance_index
de sorte que chaque index soit mappé sur une cellule unique dans votre grille, comme suit:
Pour ce faire, le calcul est relativement simple. Pour la valeur X de chaque cellule, vous devez utiliser le modulo de instance_index
et la largeur de la grille, que vous pouvez effectuer dans WGSL avec l'opérateur %
. Pour la valeur Y de chaque cellule, vous souhaitez que instance_index
soit divisé par la largeur de la grille, en supprimant les fractions restantes. Vous pouvez le faire avec la fonction floor()
de WGSL.
- Modifiez les calculs comme suit:
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);
}
Après avoir mis à jour le code, vous disposez enfin de la grille de carrés tant attendue !
- Maintenant que tout fonctionne, revenez en arrière et augmentez la taille de la grille.
index.html
const GRID_SIZE = 32;
Et voilà ! Vous pouvez grossir cette grille vraiment, et votre GPU la gère parfaitement. Vous ne verrez plus les carrés individuels bien avant de rencontrer des goulots d'étranglement.
6. Encore plus: plus coloré !
À ce stade, vous pouvez facilement passer à la section suivante, car vous avez jeté les bases du reste de l'atelier de programmation. Toutefois, si la grille de carrés partageant la même couleur est facilement gérable, n'est-ce pas ? Heureusement, un peu plus de luminosité vous aide à rendre les choses plus claires avec un peu de code mathématiques et de nuanceurs !
Utiliser des structures dans les nuanceurs
Jusqu'à présent, vous avez transmis une donnée du nuanceur de sommets: la position transformée. Toutefois, vous pouvez renvoyer beaucoup plus de données depuis le nuanceur de sommets, puis les utiliser dans le nuanceur de fragments.
Le seul moyen de transmettre des données hors du nuanceur de sommets est de les renvoyer. Un nuanceur de sommets est toujours requis pour renvoyer une position. Par conséquent, si vous souhaitez renvoyer avec d'autres données, vous devez les placer dans une structure. Les structures dans WGSL sont des types d'objets nommés qui contiennent une ou plusieurs propriétés nommées. Les propriétés peuvent également être balisées avec des attributs tels que @builtin
et @location
. Vous les déclarez en dehors des fonctions, puis vous pouvez leur transmettre des instances dans et 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 faire référence à 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 ce cas, cela ne fait pas trop de différence et le rendu du nuanceur est un peu plus long. Cependant, à mesure que vos nuanceurs se complexifient, les structs 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 ne prenez aucune entrée et vous transmettez une couleur unie (rouge) comme sortie. Si le nuanceur en savait plus sur la géométrie qu'il colore, vous pouvez utiliser ces données supplémentaires pour la rendre un peu plus intéressante. Par exemple, que se passe-t-il si vous souhaitez modifier la couleur de chaque carré en fonction de ses coordonnées de cellule ? L'étape @vertex
sait quelle cellule est en cours de rendu. Il vous suffit de la transmettre à 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 un @location
de notre choix. Étant donné que vous souhaitez transmettre les coordonnées de la cellule, ajoutez-la à la structure VertexOutput
précédente, puis définissez-la dans la fonction @vertex
avant de revenir.
- Modifiez la valeur renvoyée pour votre nuanceur de sommets, comme suit:
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;
}
- Dans la fonction
@fragment
, recevez la valeur en ajoutant un argument avec le même@location
. Les noms ne doivent pas nécessairement être identiques, mais ils sont plus faciles à retrouver.
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);
}
- Vous pouvez également 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);
}
- Une autre solution**, étant donné que ces deux fonctions sont définies dans le même module de nuanceur dans votre code, consiste à réutiliser la structure de sortie de l'étape
@vertex
. Cela facilite la transmission de 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);
}
Quel que soit le modèle choisi, vous avez accès au numéro de cellule dans la fonction @fragment
et pouvez l'utiliser pour influencer la couleur. Avec l'un des codes ci-dessus, le résultat se présente comme suit:
Il y a certainement d'autres coloris, mais l'apparence n'est pas aussi belle. Vous vous demandez peut-être pourquoi seules les lignes de gauche et de bas sont différentes. En effet, les valeurs de couleur que vous renvoyez à partir de la fonction @fragment
s'attendent à ce que chaque canal soit compris entre 0 et 1, et toutes les valeurs en dehors de cette plage lui seront limitées. Les valeurs des cellules, en revanche, 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 sur le canal rouge ou vert, et toutes les cellules suivantes sont limitées à 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, idéalement de zéro à une fin sur chaque axe, ce qui signifie une division par grid
.
- Modifiez le nuanceur de fragments comme suit:
index.html (appel createShaderModule)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell/grid, 0, 1);
}
Si vous actualisez la page, le nouveau code vous fournit un dégradé de couleurs bien plus clair sur l'ensemble de la grille.
Bien que cela soit certainement une amélioration, un coin sombre est malheureusement visible en bas à gauche, où la grille devient noire. Lorsque vous commencez à reproduire la partie "Jeu de la vie", une partie difficile de la grille masque le contenu. J'adorerais l'éclaircir.
Heureusement, vous disposez d'un canal de couleur inutilisé (bleu) que vous pouvez utiliser. Idéalement, le bleu doit être le plus lumineux possible lorsque les autres couleurs sont les plus sombres. Puis le fondu s'assombrit à mesure que les autres couleurs se développent. Pour ce faire, le moyen le plus simple est de faire en sorte que le canal commence à 1 et soustraite l'une des valeurs de cellule. Il peut s'agir de c.x
ou c.y
. Essayez les deux, puis choisissez celui que vous préférez !
- Ajoutez des couleurs plus vives au nuanceur de fragments, comme suit:
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 beau !
Cette étape n'est pas essentielle. Toutefois, comme il semble plus agréable, il est inclus dans le fichier source du point de contrôle correspondant, et les autres captures d'écran de cet atelier de programmation reflètent cette grille plus colorée.
7. Gérer l'état des cellules
Vous devez ensuite contrôler quelles cellules sur la grille s'affichent, en fonction d'un état stocké sur le GPU. C'est important pour la simulation finale.
Vous avez juste besoin d'un signal Marche/Arrêt pour chaque cellule. Par conséquent, toutes les options vous permettant de stocker un large tableau de presque tous les types de valeurs fonctionnent. Vous pensez peut-être qu'il s'agit d'un autre cas d'utilisation pour les tampons uniformes. Bien que vous puissiez faire cela, c'est plus difficile, car les tampons uniformes sont limités en taille, n'acceptent pas les tableaux de taille dynamique (vous devez spécifier la taille du tableau dans le nuanceur) et ne peuvent pas être écrits par des nuanceurs de calcul. Ce dernier élément est le plus problématique, car vous souhaitez effectuer la simulation du Jeu de la vie sur le GPU dans un nuanceur de calcul.
Heureusement, il existe une autre option de tampon qui permet d'éviter toutes ces limites.
Créer un tampon de stockage
Les tampons de stockage sont des tampons à usage général qui peuvent être lus et écrits dans les nuanceurs de calcul, ainsi que dans les nuanceurs de sommets. Ils peuvent être très volumineux et n'ont pas besoin d'une taille spécifique dans un nuanceur, ce qui les rend beaucoup plus semblables à la mémoire générale. Elle sert à stocker l'état de la cellule.
- Afin de créer un tampon de stockage pour l'état de votre cellule, vous disposez au départ d'un extrait de code de création de tampon qui vous semble 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,
});
Comme pour le sommet et les tampons uniformes, appelez device.createBuffer()
avec la taille appropriée, puis veillez à spécifier une utilisation de GPUBufferUsage.STORAGE
cette fois.
Vous pouvez remplir le tampon comme précédemment en remplissant le TypedArray de la même taille, puis en appelant device.queue.writeBuffer()
. Pour observer l'effet de la mémoire tampon sur la grille, commencez par lui attribuer une valeur prévisible.
- Activez la troisième cellule 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, mettez à jour votre nuanceur pour qu'il examine le contenu du tampon de stockage avant d'afficher la grille. Cette méthode est très semblable à celle utilisée précédemment.
- Mettez à jour 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 l'uniforme de la grille. Vous souhaitez conserver le même @group
que l'uniforme grid
, mais le nombre de @binding
doit être différent. Le type var
est storage
, afin de refléter le différents type de tampon, et non un seul vecteur, le type que vous fournissez pour cellState
est un tableau de valeurs u32
, afin de correspondre au Uint32Array
en JavaScript.
Ensuite, dans le corps de la fonction @vertex
, interrogez l'état de la cellule. Étant donné que l'état est stocké dans un tableau plat dans le tampon de stockage, vous pouvez utiliser instance_index
pour 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 à partir du tableau sont de 1 ou de 0, vous pouvez mettre à l'échelle la géométrie en fonction de l'état actif. Sa mise à l'échelle par 1 laisse la géométrie seule. En l'agrandissant, elle se réduit à un point unique que le GPU supprime.
- Mettez à jour le code du nuanceur pour ajuster la position en fonction de l'état actif de la cellule. La valeur de l'état doit être convertie en
f32
afin de répondre aux exigences de sécurité du type de 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 voir l'état de la cellule, ajoutez le tampon de stockage à un groupe de liaison. Comme elle fait partie du même @group
que le tampon uniforme, ajoutez-la également au même groupe de liaison dans le code JavaScript.
- Ajoutez le tampon de stockage comme suit:
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 le binding
de la nouvelle entrée correspond au @binding()
de la valeur correspondante dans le nuanceur.
Une fois ces éléments en place, vous devriez pouvoir actualiser la page et voir le schéma s'afficher dans la grille.
Utiliser le schéma de tampon de ping-pong
La plupart des simulations comme celle que vous créez utilisent généralement au moins deux copies de leur état. À chaque étape de la simulation, ils lisent une copie de l'état et écrivent dans l'autre. Ensuite, retournez à l'étape suivante et lisez le code de l'état dans lequel elle a été écrite précédemment. Ce modèle est communément appelé modèle de ping-pong, car la version la plus récente de l'état bascule d'une étape à l'autre entre chaque copie d'état.
Pourquoi est-ce nécessaire ? Prenons un exemple simplifié: imaginez que vous écrivez une simulation très simple dans laquelle vous déplacez chaque bloc actif vers la droite d'une cellule à chaque étape. Pour faciliter la compréhension, 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 vous voyez ! Il est actif ! Déplacez-la vers la droite. Le fait que vous modifiiez les données en même temps que vous les observez corrompez les résultats.
En utilisant le modèle de ping-pong, vous vous assurez toujours d'effectuer l'étape suivante de la simulation en utilisant uniquement les résultats de la dernière étape.
// 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);
- Utilisez ce modèle dans votre propre code en mettant à jour l'allocation de votre tampon de stockage afin de créer deux tampons identiques:
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,
})
];
- Pour visualiser la différence entre les deux tampons, fournissez-leur 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);
- Pour afficher les différents tampons de stockage dans votre rendu, mettez à jour vos groupes de liaison 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
Jusqu'à présent, vous n'avez effectué qu'un seul dessin par actualisation de page, mais vous souhaitez maintenant afficher les données mises à jour au fil du temps. Pour cela, vous avez besoin d'une simple boucle de rendu.
Une boucle de rendu est une boucle sans fin qui dessine votre contenu sur la toile à un certain intervalle. De nombreux jeux et autres contenus qui doivent être animés fonctionnent avec la fonction requestAnimationFrame()
pour programmer des rappels au même rythme que l'écran (60 fois par seconde).
Cette application peut également l'utiliser, mais dans ce cas, vous devrez probablement effectuer des mises à jour plus longues pour suivre plus facilement les opérations de simulation. Vous pouvez gérer la boucle vous-même afin de contrôler la fréquence de mise à jour de votre simulation.
- Tout d'abord, choisissez un taux que notre simulation doit mettre à jour à 200 ms, mais vous pouvez accélérer ou accélérer votre choix si vous le souhaitez, puis effectuer le suivi du nombre d'étapes de simulation 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
- Déplacez ensuite tout le code que vous utilisez actuellement pour le rendu dans une nouvelle fonction. Planifiez la répétition de cette fonction à l'intervalle souhaité avec
setInterval()
. Assurez-vous également que la fonction met à jour le nombre de pas et utilisez-la pour choisir le groupe de liaisons à lier.
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 les deux tampons d'état que vous avez créés.
Le processus de rendu est terminé ! Vous êtes prêt à afficher la sortie de la simulation du Jeu de la vie que vous allez créer à l'étape suivante, au cours de laquelle vous commencerez à utiliser des nuanceurs de calcul.
Il est évident que les capacités d'affichage de WebGPU ne sont pas les seules à étudier ici, mais le reste n'est pas abordé dans cet atelier de programmation. Nous espérons qu'il vous donnera suffisamment d'aperçu sur le fonctionnement du rendu WebGPU, ce qui nous permettra d'explorer des techniques plus avancées, comme le rendu 3D.
8. Exécuter la simulation
Passons maintenant à la dernière grande partie du puzzle: la simulation du Jeu de la vie dans un nuanceur de calcul.
Enfin, utilisez les nuanceurs de calcul
Dans cet atelier de programmation, vous avez découvert les nuanceurs de calcul, mais de quoi s'agit-il exactement ?
Les nuanceurs de calcul sont semblables aux nuanceurs de sommet et de fragment, car ils sont conçus pour s'exécuter avec un parallélisme extrême sur le GPU, mais contrairement aux deux autres nuanceurs, ils ne possèdent pas d'ensemble spécifique d'entrées et de sorties. Vous lisez et écrivez des données exclusivement à partir des sources de votre choix, telles que des tampons de stockage. Cela signifie qu'au lieu de s'exécuter une fois pour chaque sommet, instance ou pixel, vous devez lui indiquer le nombre d'appels de la fonction de nuanceur souhaitée. Ensuite, lorsque vous exécutez le nuanceur, il vous est demandé d'identifier l'appel en cours de traitement, et vous pouvez décider des données auxquelles vous allez accéder et des opérations que vous allez effectuer à partir de celui-ci.
Les nuanceurs Compute doivent être créés dans un module de nuanceur, comme les nuanceurs de sommet et de fragment. Ajoutez-les à votre code pour commencer. Comme vous pouvez l'imaginer, 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
.
- 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() {
}`
});
Étant donné que les GPU sont fréquemment utilisés pour les graphismes en 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 coordonner des tâches conformes à une grille 2D ou 3D, ce qui est idéal pour votre cas d'utilisation. Vous souhaitez appeler ce nuanceur GRID_SIZE
fois GRID_SIZE
fois, 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 sont de taille X, Y et Z, et ceux-ci peuvent avoir une taille de 1, mais augmenter les performances offre souvent de meilleures performances. Pour votre nuanceur, choisissez une taille de groupe de travail arbitraire de 8 fois 8. Il s'avère utile pour effectuer un suivi dans votre code JavaScript.
- Définissez une constante pour la taille de votre 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, en utilisant les littéraux de modèle JavaScript pour pouvoir facilement utiliser la constante que vous venez de définir.
- Ajoutez la taille du groupe de travail à la fonction du nuanceur, comme ceci:
index.html (appel 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 du nuanceur, vous pouvez accepter diverses valeurs @builtin
dans votre fonction de nuanceur de calcul pour indiquer l'appel sur lequel vous vous trouvez et décider du travail à effectuer.
- Ajoutez une valeur
@builtin
, comme ceci:
index.html (appel createShaderModule)
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Vous transmettez global_invocation_id
intégré, un vecteur tridimensionnel d'entiers non signés qui vous indique votre position dans la grille d'appels du nuanceur. Vous exécuterez ce nuanceur une fois pour chaque cellule de votre grille. Vous obtenez des nombres tels que (0, 0, 0)
, (1, 0, 0)
ou (1, 1, 0)
, jusqu'à (31, 31, 0)
, ce qui signifie que vous pouvez les traiter comme l'index de cellule sur lequel vous allez travailler.
Les nuanceurs de calcul peuvent également utiliser des uniformes, comme vous le faites avec les nuanceurs de sommet et de fragment.
- Utilisez un uniforme avec votre nuanceur de calcul pour déterminer la taille de la grille:
index.html (appel 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 la cellule en tant que tampon de stockage. Mais dans le cas présent, vous avez besoin de deux. Comme les nuanceurs de calcul ne disposent pas d'une sortie requise, comme une position de sommet ou une couleur de fragment, l'écriture de valeurs dans un tampon ou une texture de stockage est le seul moyen d'obtenir des résultats à partir d'un nuanceur de calcul. Utilisez la méthode ping-pong que vous avez étudiée précédemment. Vous disposez d'un tampon de stockage qui alimente l'état actuel de la grille et d'un tampon dans lequel vous écrivez le nouvel état.
- Exposez l'état des entrées et sorties des cellules sous forme de tampons de stockage, comme ceci:
index.html (appel 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 rend 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. (il n'existe pas de mode de stockage en écriture seule dans le GPU Web).
Ensuite, vous devez pouvoir mapper votre index de cellule dans le tableau de stockage linéaire. C'est à l'opposé de ce que vous avez fait dans le nuanceur de sommets, où vous avez pris le 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))
.)
- Écrivez une fonction pour pouvoir aller dans l'autre sens. Elle 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 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 s'éteint, et inversement. Ce n'est pas encore le jeu de la vie, mais cela suffit pour montrer que le nuanceur de calcul fonctionne.
- Ajoutez l'algorithme simple comme suit:
index.html (appel 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. Avant de voir les résultats, vous devez apporter quelques modifications supplémentaires.
Lier les mises en page de groupe et de pipeline
Comme vous pouvez le remarquer avec le nuanceur ci-dessus, il utilise en grande partie les mêmes entrées (uniformes et tampons de stockage) que votre pipeline de rendu. Vous pensez peut-être simplement utiliser les mêmes groupes de liaisons et en faire autant ? La bonne nouvelle, c'est que vous pouvez ! Pour cela, un peu plus de configuration manuelle est nécessaire.
Chaque fois que vous créez un groupe de liaison, vous devez fournir un élément GPUBindGroupLayout
. Vous avez précédemment obtenu cette mise en page en appelant getBindGroupLayout()
sur le pipeline de rendu, qui l'a à son tour créée automatiquement, car vous avez fourni layout: "auto"
lors de sa création. Cette approche fonctionne bien lorsque vous n'utilisez qu'un seul pipeline, mais si vous disposez de plusieurs pipelines qui souhaitent partager des ressources, vous devez créer la mise en page explicitement, puis la fournir au groupe de liaisons et aux pipelines.
Pour comprendre pourquoi, prenons en compte le fait que, dans vos pipelines de rendu, vous utilisez un seul tampon uniforme et un seul tampon de stockage, mais dans le nuanceur de calcul que vous venez d'écrire, vous avez besoin d'un deuxième tampon de stockage. Étant donné que les deux nuanceurs utilisent les mêmes valeurs @binding
pour le tampon uniforme 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 souhaitez créer une mise en page qui décrit toutes les ressources présentes dans le groupe de liaisons, et pas seulement celles utilisées par un pipeline spécifique.
- Pour créer cette mise en page, 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 de la création du groupe de liaisons lui-même, dans la mesure où vous décrivez une liste de entries
. La différence est que vous décrivez le type de ressource que l'entrée doit être et la manière dont elle est utilisée plutôt que de fournir la ressource elle-même.
Dans chaque entrée, vous attribuez le numéro binding
à la ressource qui, comme vous l'avez appris lors de la création du groupe de liaisons, correspond à la valeur @binding
des nuanceurs. Vous fournissez également les indicateurs visibility
, qui sont des indicateurs GPUShaderStage
qui indiquent les étapes du nuanceur pouvant utiliser la ressource. Vous souhaitez que le tampon de stockage uniforme et le premier tampon soient accessibles au sommet et les nuanceurs de calcul, mais le deuxième ne doit être accessible que dans les nuanceurs de calcul. Vous pouvez également rendre les ressources accessibles aux nuanceurs de fragment avec ces indicateurs, mais vous n'avez pas besoin de le faire ici.
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 pour chacune d'elles. Il existe d'autres options, comme texture
ou sampler
, mais vous n'en avez pas besoin ici.
Dans le dictionnaire de tampons, définissez des options telles que le type de tampon type
. La valeur par défaut étant "uniform"
, vous pouvez laisser le dictionnaire vide pour la liaison 0. Notez toutefois que vous devez au moins définir 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 lien 2 possède un type "storage"
, car vous l'utilisez avec l'accès read_write
.
Une fois le bindGroupLayout
créé, vous pouvez le transmettre au moment de créer vos groupes de liaison 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 liaison afin qu'elle corresponde à la mise en page que vous venez de définir.
- Mettez à jour la création du groupe de liaisons, comme suit:
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é mis à jour pour utiliser cette mise en page de groupe de liaison explicite, vous devez mettre à jour le pipeline de rendu afin qu'il utilise le même attribut.
- Créez un
GPUPipelineLayout
.
index.html
const pipelineLayout = device.createPipelineLayout({
label: "Cell Pipeline Layout",
bindGroupLayouts: [ bindGroupLayout ],
});
Une mise en page de pipeline est une liste de mises en page de groupe de liaisons (dans le cas présent, une qui utilise) un ou plusieurs pipelines. L'ordre des mises en page des groupes de liaisons dans le tableau doit correspondre aux attributs @group
des nuanceurs. Cela signifie que bindGroupLayout
est associé à @group(0)
.
- Une fois que vous avez la mise en page 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 sommet et de fragment, vous avez besoin d'un pipeline de calcul pour utiliser votre nuanceur de calcul. Heureusement, les pipelines de calcul sont bien moins complexes que les pipelines de rendu, car ils n'ont pas d'état à définir, mais uniquement le nuanceur et la mise en page.
- 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 le nouveau pipelineLayout
au lieu de "auto"
, comme dans le pipeline de rendu mis à jour. Ainsi, votre pipeline de rendu et votre pipeline de calcul peuvent utiliser les mêmes groupes de liaison.
Cartes de calcul
Cela vous amène à utiliser l'utilisation du pipeline de calcul. Étant donné que vous effectuez le rendu dans une carte de rendu, vous pouvez probablement deviner que vous devez effectuer des tâches de calcul dans une carte de calcul. Les opérations de calcul et de rendu peuvent se produire dans le même encodeur de commande. Vous devez donc mélanger légèrement votre fonction updateGrid
.
- Déplacez la création de l'encodeur en haut de la fonction, puis commencez la création d'une carte de calcul avec elle (avant
step++
).
index.html
// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();
const computePass = computeEncoder.beginComputePass();
// Compute work will go here...
computePass.end();
// Existing lines
step++; // Increment the step count
// Start a render pass...
Tout comme les 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 carte de calcul avant celle-ci, car elle permet d'utiliser immédiatement les derniers résultats de la carte. C'est également la raison pour laquelle vous incrémentez le nombre d'step
entre les cartes, de sorte que le tampon de sortie du pipeline de calcul devienne le tampon d'entrée du pipeline de rendu.
- Ensuite, définissez le pipeline et le groupe de liaison dans la carte de calcul en utilisant le même schéma pour basculer entre les groupes de liaison que pour la carte de rendu.
index.html
const computePass = computeEncoder.beginComputePass();
// New lines
computePass.setPipeline(simulationPipeline),
computePass.setBindGroup(0, bindGroups[step % 2]);
computePass.end();
- Enfin, au lieu de dessiner comme dans un pass 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 = computeEncoder.beginComputePass();
computePass.setPipeline(simulationPipeline),
computePass.setBindGroup(0, bindGroups[step % 2]);
// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);
computePass.end();
Il est très important de noter que le nombre que vous transmettez dans dispatchWorkgroups()
n'est pas le nombre d'appels. Il s'agit plutôt du nombre de groupes de tâches à 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 soit 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 constater que la grille s'inverse à chaque mise à jour.
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 tendances habituelles ne font pas de "Jeu de la vie" très intéressant.) Vous pouvez randomiser les valeurs comme vous le souhaitez, mais il existe un moyen simple de commencer et d'obtenir des résultats raisonnables.
- Pour démarrer chaque cellule dans un état aléatoire, mettez à jour 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 nécessaires pour en arriver là, le code du nuanceur peut être extrêmement simple.
Tout d'abord, vous devez connaître le nombre de ses voisins actifs pour chaque cellule. Vous n'avez pas à vous soucier de celles qui sont actives, mais uniquement du nombre.
- Pour faciliter la récupération des données de cellules voisines, ajoutez une fonction
cellActive
qui renvoie la valeurcellStateIn
de la coordonnée donnée.
index.html (appel createShaderModule)
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
La fonction cellActive
renvoie un élément si la cellule est active. Par conséquent, la valeur renvoyée lors de l'appel de cellActive
pour les huit cellules environnantes vous indique le nombre de cellules voisines actives.
- Voici le nombre de voisins actifs:
index.html (appel 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 problème mineur: que se passe-t-il lorsque la cellule que vous consultez n'est pas à la périphérie du tableau ? Selon votre logique cellIndex()
, il déborde de la ligne suivante ou précédente, ou sort du bord du tampon.
Pour ce qui est un jeu, un moyen courant et facile de résoudre ce problème est de faire en sorte que les cellules situées sur le bord de la grille traitent les cellules situées à l'opposé de la grille comme leurs voisines, créant ainsi un effet de retour à la ligne.
- Prise en charge du contournement de la grille avec une modification mineure de la fonction
cellIndex()
.
index.html (appel createShaderModule)
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
En encapsulant la cellule X et Y à l'aide de l'opérateur %
lorsqu'elle dépasse la taille de la grille, vous êtes sûr de ne jamais accéder en dehors des limites de la mémoire tampon de stockage. Ainsi, vous pouvez être sûr que le nombre de activeNeighbors
est prévisible.
Ensuite, appliquez l'une des quatre règles suivantes:
- Toute cellule ayant moins de deux voisins devient inactive.
- Toute cellule active avec deux ou trois voisins reste active.
- Toute cellule inactive ayant exactement trois voisins devient active.
- Toute cellule ayant plus de trois voisins devient inactive.
Vous pouvez le faire avec une série d'instructions if, mais WGSL est également compatible avec les instructions switch, qui conviennent à cette logique.
- Implémentez la logique du jeu de la vie, comme ceci:
index.html (appel 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 suit:
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 votre page et observez le développement de votre nouvel appareil mobile créé !
9. Félicitations !
Vous avez créé une version classique de la simulation Jeu de la vie de Conway, qui s'exécute entièrement sur votre GPU à l'aide de l'API WebGPU.
Et ensuite ?
- Consultez les exemples de GPU Web.
Complément d'informations
- WebGPU : tous les cœurs, aucun canevas
- GPU Web brut
- Principes de base du GPU
- Bonnes pratiques concernant les GPU Web