Comment écrire un shake

Il y a toujours eu un certain mystère autour de la fumée. C'est esthétiquement agréable à regarder et insaisissable à modéliser. Comme beaucoup de phénomènes physiques, c'est un système chaotique, ce qui le rend très difficile à prévoir. L'état de la simulation dépend fortement des interactions entre ses particules individuelles. 

C’est précisément ce qui en fait un problème de taille avec le GPU: il peut être décomposé en comportement d’une seule particule, répété simultanément des millions de fois dans différents endroits.. 

Dans ce tutoriel, je vais vous expliquer comment écrire un nuage de fumée à partir de zéro et vous enseigner quelques techniques utiles pour vous permettre d'étendre votre arsenal et de développer vos propres effets.!

Ce que vous apprendrez

C’est le résultat final sur lequel nous allons travailler:

Cliquez pour générer plus de fumée. Vous pouvez créer et éditer ceci sur CodePen.

Nous allons implémenter l'algorithme présenté dans l'article de Jon Stam sur la dynamique des fluides en temps réel dans les jeux. Vous apprendrez également à rendre à une texture, également connu comme utilisant tampons d'image, qui est une technique très utile dans la programmation de shader pour obtenir de nombreux effets. 

Avant de commencer

Les exemples et les détails de mise en œuvre spécifiques de ce tutoriel utilisent JavaScript et ThreeJS, mais vous devriez pouvoir suivre sur n'importe quelle plate-forme prenant en charge les shaders. (Si vous ne connaissez pas les bases de la programmation par shader, assurez-vous de lire au moins les deux premiers tutoriels de cette série.)

Tous les exemples de code sont hébergés sur CodePen, mais vous pouvez également les trouver dans le référentiel GitHub associé à cet article (qui pourrait être plus lisible).. 

Théorie et contexte

L'algorithme dans le papier de Jos Stam privilégie la vitesse et la qualité visuelle à la précision physique, ce qui est exactement ce que nous souhaitons dans un jeu.. 

Ce document peut paraître beaucoup plus compliqué qu'il ne l'est réellement, surtout si vous ne maîtrisez pas bien les équations différentielles. Cependant, l’essentiel de cette technique est résumé dans cette figure:

C'est tout ce que nous devons mettre en œuvre pour obtenir un effet de fumée d'apparence réaliste: la valeur de chaque cellule se dissipe à toutes les cellules voisines à chaque itération. Si vous ne savez pas tout de suite comment cela fonctionne, ou si vous voulez juste savoir à quoi cela ressemblerait, vous pouvez bricoler avec cette démo interactive:

Voir la démo interactive sur CodePen.

En cliquant sur une cellule, sa valeur est 100. Vous pouvez voir comment chaque cellule perd progressivement sa valeur au profit de ses voisins. Il serait peut-être plus facile de voir en cliquant sur Suivant pour voir les images individuelles. Changer le Mode d'affichage pour voir à quoi il ressemblerait si nous faisions une valeur de couleur correspondant à ces nombres.

La démo ci-dessus est exécutée sur le processeur avec une boucle parcourant chaque cellule. Voici à quoi ressemble cette boucle:

// W = nombre de colonnes dans la grille // H = nombre de lignes dans la grille // f = facteur de propagation / diffuse // Nous copions d'abord la grille dans newGrid pour éviter de modifier la grille telle que lue (var r = 1; r

Cet extrait est vraiment le cœur de l'algorithme. Chaque cellule gagne un peu de ses quatre cellules voisines, moins sa propre valeur, où F est un facteur inférieur à 1. Nous multiplions la valeur actuelle de la cellule par 4 pour nous assurer qu'elle diffuse de la valeur la plus élevée à la valeur la plus basse.

Pour clarifier ce point, considérons ce scénario: 

Prendre le cellule au milieu (à la position [1,1] dans la grille) et appliquez l’équation de diffusion ci-dessus. Assumons F est 0,1:

0,1 * (100 + 100 + 100 + 100-4 * 100) = 0,1 * (400-400) = 0

Aucune diffusion ne se produit car toutes les cellules ont des valeurs égales! 

Si on considèrela cellule en haut à gauche à la place (supposons que les cellules en dehors de la grille illustrée sont toutes 0):

0,1 * (100 + 100 + 0 + 0-4 * 0) = 0,1 * (200) = 20

Donc nous obtenons un filet augmenter de 20! Considérons un dernier cas. Après un pas de temps (en appliquant cette formule à toutes les cellules), notre grille ressemblera à ceci:

Regardons le diffuse sur le cellule au milieu encore:

0,1 * (70 + 70 + 70 + 70-4 * 100) = 0,1 * (280 - 400) = -12

Nous obtenons un filet diminutionde 12! Donc, il découle toujours des valeurs les plus élevées aux valeurs les plus basses.

Maintenant, si nous voulions que cela paraisse plus réaliste, nous pourrions réduire la taille des cellules (ce que vous pouvez faire dans la démo), mais à un moment donné, les choses vont devenir très lentes, car nous sommes obligés de fonctionner en séquence. à travers chaque cellule. Notre objectif est de pouvoir écrire cela dans un shader, où nous pouvons utiliser la puissance du GPU pour traiter toutes les cellules (en pixels) simultanément en parallèle..

Donc, pour résumer, notre technique générale consiste à faire en sorte que chaque pixel donne une partie de sa valeur de couleur, chaque image aux pixels voisins. Cela semble assez simple, n'est-ce pas? Mettons cela en œuvre et voyons ce que nous obtenons! 

la mise en oeuvre

Nous allons commencer avec un shader de base qui dessine sur tout l'écran. Pour vous assurer que cela fonctionne, essayez de régler l’écran sur un noir uni (ou n’importe quelle couleur arbitraire). Voici à quoi ressemble la configuration que j'utilise en Javascript.

Vous pouvez créer et éditer ceci sur CodePen. Cliquez sur les boutons en haut pour voir les fichiers HTML, CSS et JS..

Notre shader est simplement:

uniforme vec2 res; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = vec4 (0.0.0.0.0.0.1.0.0); 

res et pixel sont là pour nous donner la coordonnée du pixel actuel. Nous passons les dimensions de l'écran res comme variable uniforme. (Nous ne les utilisons pas pour l'instant, mais nous le ferons bientôt.)

Étape 1: Déplacement de valeurs entre pixels

Voici ce que nous voulons implémenter à nouveau:

Notre technique générale consiste à faire en sorte que chaque pixel attribue une partie de sa valeur de couleur à chaque image aux pixels voisins..

Exprimé dans sa forme actuelle, c’est impossiblefaire avec un shader. Pouvez-vous voir pourquoi? Rappelez-vous que tout ce qu'un shader peut faire est de renvoyer une valeur de couleur pour le pixel en cours de traitement. Nous devons donc reformuler cette valeur de manière à ne concerner que le pixel en cours. Nous pouvons dire:

Chaque pixel devrait Gain de la couleur de ses voisins, tout en perdant une partie de sa propre.

Maintenant, c'est quelque chose que nous pouvons mettre en œuvre. Si vous essayez réellement de le faire, cependant, vous pourriez rencontrer un problème fondamental…  

Considérons un cas beaucoup plus simple. Supposons que vous souhaitiez créer un shader qui donne une image rouge lentement au fil du temps. Vous pourriez écrire un shader comme ceci:

uniforme vec2 res; texture uniforme sampler2D; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (tex, pixel); // il s'agit de la couleur du pixel actuel gl_FragColor.r + = 0.01; // incrémente la composante rouge

Et attendez-vous à ce que chaque image, la composante rouge de chaque pixel augmente de 0,01. Au lieu de cela, tout ce que vous obtiendrez est une image statique dans laquelle tous les pixels sont juste un tout petit peu plus rouges qu'ils ne l'avaient commencé. La composante rouge de chaque pixel n'augmentera qu'une seule fois, malgré le fait que le shader exécute chaque image..

Pouvez-vous voir pourquoi?

Le problème

Le problème est que toute opération effectuée dans notre shader est envoyée à l'écran puis perdue à jamais. Notre processus ressemble maintenant à ceci:

Nous transmettons nos variables uniformes et notre texture au shader, les pixels sont légèrement plus rouges, puis dessinés à l'écran, puis tout recommence à zéro.. Tout ce que nous dessinons dans le shader est effacé la prochaine fois que nous dessinons. 

Ce que nous voulons, c'est quelque chose comme ça:


Au lieu de dessiner directement à l'écran, nous pouvons dessiner une texture, puis dessiner cette texture sur l'écran. Vous obtenez la même image à l’écran que vous auriez sinon, sauf que vous pouvez maintenant renvoyer votre sortie en entrée. Ainsi, vous pouvez avoir des shaders qui construisent ou propagent quelque chose, plutôt que d'être effacés à chaque fois. C’est ce que j’appelle le "tour de tampon de trame". 

Le tour de tampon d'image

La technique générale est la même sur toutes les plateformes. À la recherche de "rendre à la texture" quel que soit le langage ou les outils que vous utilisez, apportez les détails nécessaires à la mise en œuvre. Vous pouvez également rechercher comment utiliser objets tampon de trame, qui est juste un autre nom pour être capable de rendre à un tampon au lieu de rendre à l'écran. 

Dans ThreeJS, l'équivalent de ceci est le WebGLRenderTarget. C’est ce que nous utiliserons comme texture intermédiaire pour le rendu. Il reste une petite mise en garde: vous ne pouvez pas lire et rendre à la même texture simultanément. Le moyen le plus simple de contourner ce problème consiste à utiliser simplement deux textures. 

Soit A et B deux textures que tu as créées. Votre méthode serait alors:

  1. Passez A dans votre shader, rendez-le sur B.
  2. Rendu B à l'écran.
  3. Passez B dans le shader, rendez-le sur A.
  4. Rendez A sur votre écran.
  5. Répétez 1.

Ou, une manière plus concise de coder ceci serait:

  1. Passez A dans votre shader, rendez-le sur B.
  2. Rendu B à l'écran.
  3. Échange A et B (si la variable A contient maintenant la texture qui était dans B et vice versa).
  4. Répétez 1.

C'est tout ce qu'il faut. Voici une implémentation de cela dans ThreeJS:

Vous pouvez créer et éditer ceci sur CodePen. Le nouveau code de shader est dans le HTML languette.

C’est toujours un écran noir, c’est ce avec quoi nous avons commencé. Notre shader n'est pas trop différent non plus:

uniforme vec2 res; // La largeur et la hauteur de notre écran uniforme sampler2D bufferTexture; // notre texture d'entrée void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (bufferTexture, pixel); 

Sauf que maintenant si vous ajoutez cette ligne (essayez-la!):

gl_FragColor.r + = 0.01;

Vous verrez l’écran virer lentement au rouge au lieu de simplement augmenter de 0,01 une fois que. C'est une étape assez importante, vous devriez donc prendre un moment pour vous amuser et le comparer à la manière dont notre configuration initiale a fonctionné. 

Défi: Qu'est-ce qui se passe si vous mettez gl_FragColor.r + = pixel.x; lors de l'utilisation d'un exemple de tampon d'image, par rapport à l'utilisation de l'exemple d'installation? Prenez un moment pour réfléchir à la raison pour laquelle les résultats sont différents et pourquoi ils ont un sens..

Étape 2: Obtenir une source de fumée

Avant de pouvoir faire bouger quoi que ce soit, nous avons besoin d'un moyen de créer de la fumée en premier lieu. Le moyen le plus simple est de définir manuellement certaines zones arbitraires en blanc dans votre shader.. 

 // Obtenir la distance de ce pixel à partir du centre de l'écran float dist = distance (gl_FragCoord.xy, res.xy / 2.0); si (dist < 15.0) //Create a circle with a radius of 15 pixels gl_FragColor.rgb = vec3(1.0); 

Si nous voulons tester si notre frame buffer fonctionne correctement, nous pouvons essayer de ajouter à la valeur de couleur au lieu de simplement la définir. Vous devriez voir le cercle devenir de plus en plus blanc.

// Obtenir la distance de ce pixel à partir du centre de l'écran float dist = distance (gl_FragCoord.xy, res.xy / 2.0); si (dist < 15.0) //Create a circle with a radius of 15 pixels gl_FragColor.rgb += 0.01; 

Une autre méthode consiste à remplacer ce point fixe par la position de la souris. Vous pouvez passer une troisième valeur pour indiquer si la souris est enfoncée ou non, ainsi vous pourrez cliquer pour créer de la fumée. Voici une implémentation pour ça.

Cliquez pour ajouter "fumer". Vous pouvez créer et éditer ceci sur CodePen.

Voici à quoi ressemble notre shader maintenant:

// La largeur et la hauteur de notre écran uniforme vec2 res; // Notre texture d'entrée uniforme sampler2D bufferTexture; // Le x, y est la position. Le z est l’uniforme puissance / densité vec3 smokeSource; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (bufferTexture, pixel); // Obtenir la distance du pixel actuel de la source de fumée float dist = distance (smokeSource.xy, gl_FragCoord.xy); // Génère de la fumée lorsque la souris est enfoncée si (smokeSource.z> 0.0 && dist < 15.0) gl_FragColor.rgb += smokeSource.z;  

Défi: N'oubliez pas que les branches (conditionnelles) sont généralement coûteuses dans les shaders. Pouvez-vous réécrire ceci sans utiliser une instruction if? (La solution est dans le CodePen.)

Si cela n’a aucun sens, il existe une explication plus détaillée de l’utilisation de la souris dans un shader dans le précédent tutoriel sur l’éclairage..

Étape 3: Diffuser la fumée

Maintenant, c’est la partie la plus facile et la plus enrichissante! Nous avons toutes les pièces maintenant, nous avons juste besoin de dire enfin au shader: chaque pixel devrait Gainde la couleur de ses voisins, tout en perdant une partie de sa propre.

Ce qui ressemble à ceci:

 // Flotteur diffuse de fumée xPixel = 1.0 / res.x; // La taille d'un seul pixel float yPixel = 1.0 / res.y; vec4 rightColor = texture2D (bufferTexture, vec2 (pixel.x + xPixel, pixel.y)); vec4 leftColor = texture2D (bufferTexture, vec2 (pixel.x-xPixel, pixel.y)); vec4 upColor = texture2D (bufferTexture, vec2 (pixel.x, pixel.y + yPixel)); vec4 downColor = texture2D (bufferTexture, vec2 (pixel.x, pixel.y-yPixel)); // Équation diffuse gl_FragColor.rgb + = 14.0 * 0.016 * (leftColor.rgb + rightColor.rgb + downColor.rgb + upColor.rgb - 4.0 * gl_FragColor.rgb);

Nous avons notre F facteur comme avant. Dans ce cas, nous avons le timestep (0,016 est 1/60, parce que nous courons à 60 fps) et j'ai continué à essayer des chiffres jusqu'à ce que je suis arrivé à 14, qui semble bien paraître. Voici le résultat:

Cliquez pour ajouter de la fumée. Vous pouvez créer et éditer ceci sur CodePen.

Uh Oh, c'est coincé!

C'est la même équation diffuse que nous avons utilisée dans la démo de la CPU, et pourtant notre simulation est bloquée! Ce qui donne? 

Il s'avère que les textures (comme tous les nombres sur un ordinateur) ont une précision limitée. À un moment donné, le facteur que nous soustrayons devient si petit qu'il est arrondi à 0, de sorte que les simulations sont bloquées. Pour résoudre ce problème, nous devons vérifier qu'elle ne tombe pas en dessous d'une valeur minimale:

facteur de flottement = 14.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r + upColor.r - 4.0 * gl_FragColor.r); // Nous devons tenir compte de la faible précision de texels float minimum = 0.003; si (facteur> = -minimum && facteur < 0.0) factor = -minimum; gl_FragColor.rgb += factor;

J'utilise le r composant au lieu de rgb pour obtenir le facteur, car il est plus facile de travailler avec des nombres simples, et parce que tous les composants sont le même nombre (car notre fumée est blanche). 

Par essais et erreurs, j'ai trouvé 0,003 être un bon seuil pour ne pas rester bloqué. Je m'inquiète seulement du facteur lorsqu'il est négatif, afin de m'assurer qu'il peut toujours diminuer. Une fois que nous appliquons ce correctif, voici ce que nous obtenons:

Cliquez pour ajouter de la fumée. Vous pouvez créer et éditer ceci sur CodePen.

Étape 4: Diffuser la fumée vers le haut

Cela ne ressemble pas beaucoup à de la fumée, cependant. Si nous voulons que le flux monte au lieu de dans toutes les directions, nous devons ajouter des poids. Si les pixels du bas ont toujours une influence plus grande que les autres directions, nos pixels sembleront se déplacer vers le haut.. 

En jouant avec les coefficients, nous pouvons arriver à quelque chose qui semble assez décent avec cette équation:

// Facteur de flottement de l'équation diffuse = 8.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r * 3.0 + upColor.r - 6.0 * gl_FragColor.r);

Et voici à quoi ça ressemble:

Cliquez pour ajouter de la fumée. Vous pouvez créer et éditer ceci sur CodePen.

Une note sur l'équation diffuse

En gros, j'ai manipulé les coefficients pour que le résultat soit positif. Vous pouvez tout aussi bien le faire couler dans une autre direction. 

Il est important de noter qu'il est très facile de faire exploser cette simulation. (Essayez de changer le 6.0 là pour 5.0 et voir ce qui se passe). C'est évidemment parce que les cellules gagnent plus qu'elles ne perdent. 

Cette équation est en fait ce que le document cité précédemment désigne comme le modèle "mauvais diffus". Ils présentent une équation alternative plus stable, mais qui n’est pas très pratique pour nous, principalement parce qu’elle doit écrire sur la grille à partir de laquelle elle lit. En d'autres termes, il faudrait pouvoir lire et écrire simultanément sur la même texture. 

Ce que nous avons est suffisant pour nos besoins, mais vous pouvez jeter un coup d'œil à l'explication fournie dans le document si vous êtes curieux. Vous trouverez également l'équation alternative implémentée dans la démonstration interactive de la CPU dans la fonction diffuse_advanced ().

Une solution rapide

Une chose que vous remarquerez peut-être, si vous jouez avec votre fumée, est qu’elle reste bloquée au bas de l’écran si vous en générez quelques-unes. C’est parce que les pixels de cette rangée du bas tentent d’obtenir les valeurs des pixels situés en dessous eux, qui n'existent pas.

Pour résoudre ce problème, nous nous assurons simplement que les pixels de la rangée du bas trouvent 0 sous eux:

// Gère la limite inférieure // Ceci doit être exécuté avant la fonction diffuse if (pixel.y <= yPixel) downColor.rgb = vec3(0.0); 

Dans la démonstration du processeur, j’ai traité ce problème simplement en ne rendant pas les cellules de la frontière diffuses. Vous pouvez également simplement définir manuellement une cellule hors limites de manière à ce qu'elle ait la valeur 0. (La grille de la démonstration de la CPU s'étend sur une ligne et une colonne de cellules dans chaque direction, de sorte que vous ne voyez jamais les limites)

Une grille de vélocité

Toutes nos félicitations! Vous avez maintenant un shader de fumée fonctionnel! La dernière chose que je voulais aborder brièvement est le champ de vitesse mentionné dans le document..

Votre fumée ne doit pas diffuser uniformément vers le haut ou dans une direction spécifique; il pourrait suivre un modèle général comme celui illustré. Vous pouvez le faire en envoyant une autre texture où les valeurs de couleur représentent la direction dans laquelle la fumée doit s’écouler à cet endroit, de la même manière que nous avons utilisé une carte normale pour spécifier une direction à chaque pixel de notre tutoriel sur l’éclairage..

En fait, votre texture de vélocité ne doit pas non plus être statique! Vous pouvez utiliser cette astuce pour que les vitesses changent également en temps réel. Je ne couvrirai pas cela dans ce tutoriel, mais il y a beaucoup de potentiel à explorer.

Conclusion

S'il y a quelque chose à retenir de ce tutoriel, c'est qu'il est très utile de pouvoir restituer une texture plutôt que simplement à l'écran..

A quoi servent les tampons d'image?

Une utilisation commune de ceci est post-traitementdans les jeux. Si vous souhaitez appliquer une sorte de filtre de couleur, au lieu de l'appliquer à chaque objet, vous pouvez rendre tous les objets en texture de la taille de l'écran, puis appliquer votre shader à cette texture finale et la dessiner à l'écran.. 

Un autre exemple concerne la mise en œuvre de shaders nécessitant passes multiples, telles que flou.En règle générale, vous passez votre image dans le shader, appliquez un flou dans la direction x, puis relancez-la pour la rendre floue dans la direction y.. 

Un dernier exemple est rendu différé, comme indiqué dans le précédent tutoriel sur l'éclairage, qui permet d'ajouter efficacement de nombreuses sources de lumière à votre scène. La bonne chose à propos de cela est que le calcul de l'éclairage ne dépend plus de la quantité de sources de lumière que vous avez.

N'ayez pas peur des documents techniques

Le document que j'ai cité contient plus de détails, ce qui suppose que vous maîtrisiez bien l'algèbre linéaire, mais que cela ne vous dissuade pas de le disséquer et d'essayer de le mettre en œuvre. L’essentiel en a fini par être assez simple à mettre en œuvre (après quelques retouches avec les coefficients). 

J'espère que vous en apprendrez un peu plus sur les shaders ici, et si vous avez des questions, des suggestions ou des améliorations, veuillez les partager ci-dessous.!