Créez un shader d'eau de dessin animé pour le Web. Partie 1

Dans mon tutoriel «Création de shaders», j'ai principalement examiné les shaders de fragments, qui sont suffisants pour implémenter des effets 2D et des exemples sur ShaderToy . Mais il existe toute une catégorie de techniques qui nécessitent l'utilisation de vertex shaders. Dans ce didacticiel, je vais parler de la création d'un shader d'eau de dessin animé stylisé et vous présenter les vertex shaders. Je parlerai également du tampon de profondeur et de la façon de l'utiliser pour obtenir plus d'informations sur la scène et créer des lignes d'écume de mer.

Voici à quoi ressemblera l'effet fini. Une démo interactive peut être vue ici .


Cet effet se compose des éléments suivants:

  1. Un maillage d'eau translucide avec des polygones subdivisés et des sommets décalés pour créer des vagues.
  2. Lignes d'eau statiques Ă  la surface.
  3. Flottabilité simulée du bateau.
  4. Lignes dynamiques de mousse autour des limites des objets dans l'eau.
  5. Post-traitement pour créer une distorsion de tout sous l'eau.

Dans cet effet, j'aime le fait qu'il touche à de nombreux concepts différents de l'infographie, il nous permettra donc d'utiliser les idées des didacticiels précédents, ainsi que de développer des techniques qui peuvent être appliquées dans de nouveaux effets.

Dans ce tutoriel, j'utiliserai PlayCanvas , simplement parce que c'est un IDE Web gratuit et pratique, mais tout peut être appliqué à n'importe quel autre environnement WebGL sans aucun problème. À la fin de l'article, la version du code source de Three.js sera présentée. Nous supposerons que vous connaissez déjà bien les shaders de fragments et l'interface PlayCanvas. Vous pouvez actualiser vos connaissances sur les shaders ici et vous familiariser avec PlayCanvas ici .

Réglage de l'environnement


Le but de cette section est de configurer notre projet PlayCanvas et d'y insérer plusieurs objets environnementaux que l'eau influencera.

Si vous n'avez pas de compte PlayCanvas, enregistrez-le et créez un nouveau projet vierge . Par défaut, vous devriez avoir quelques objets dans la scène, une caméra et une source de lumière.


Insérer des modèles


Le projet Google Poly est une excellente ressource pour trouver des modèles 3D pour le Web. J'ai pris le modèle de bateau à partir de là. Après avoir téléchargé et déballé l'archive, vous y trouverez des fichiers .obj et .png .

  1. Faites glisser les deux fichiers dans la fenĂŞtre Actifs du projet PlayCanvas.
  2. Sélectionnez le matériau généré automatiquement et sélectionnez le fichier .png comme carte diffuse.


Vous pouvez maintenant faire glisser Tugboat.json dans la scène et supprimer les objets Box et Plane. Si le bateau semble trop petit, vous pouvez augmenter son échelle (j'ai réglé la valeur à 50).


De même, vous pouvez ajouter d'autres modèles à la scène.

Caméra en orbite


Pour configurer la caméra volant en orbite, nous allons copier le script de cet exemple PlayCanvas . Suivez le lien et cliquez sur Editeur pour ouvrir le projet.

  1. Copiez le contenu de mouse-input.js et orbit-camera.js de ce projet de didacticiel dans des fichiers portant les mĂŞmes noms que ceux de votre projet.
  2. Ajoutez un composant Script à la caméra.
  3. Attachez deux scripts à la caméra.

Astuce: pour organiser le projet, vous pouvez créer des dossiers dans la fenêtre Actifs. J'ai mis ces deux scripts de caméra dans le dossier Scripts / Camera /, mon modèle dans Models / et le matériel dans le dossier Materials /.

Maintenant, lorsque vous démarrez le jeu (le bouton de lancement dans la partie supérieure droite de la fenêtre de la scène), vous devriez voir un bateau que vous pouvez inspecter avec une caméra en le déplaçant en orbite avec la souris.

Division du polygone de surface de l'eau


Le but de cette section est de créer un maillage subdivisé qui sera utilisé comme surface de l'eau.

Pour créer une surface d'eau, nous adaptons une partie du code du tutoriel de génération de relief . Créez un nouveau Water.js script Water.js . Ouvrez ce script pour le modifier et créez une nouvelle fonction GeneratePlaneMesh qui ressemblera à ceci:

 Water.prototype.GeneratePlaneMesh = function(options){ // 1 -    ,     if(options === undefined) options = {subdivisions:100, width:10, height:10}; // 2 -  , UV   var positions = []; var uvs = []; var indices = []; var row, col; var normals; for (row = 0; row <= options.subdivisions; row++) { for (col = 0; col <= options.subdivisions; col++) { var position = new pc.Vec3((col * options.width) / options.subdivisions - (options.width / 2.0), 0, ((options.subdivisions - row) * options.height) / options.subdivisions - (options.height / 2.0)); positions.push(position.x, position.y, position.z); uvs.push(col / options.subdivisions, 1.0 - row / options.subdivisions); } } for (row = 0; row < options.subdivisions; row++) { for (col = 0; col < options.subdivisions; col++) { indices.push(col + row * (options.subdivisions + 1)); indices.push(col + 1 + row * (options.subdivisions + 1)); indices.push(col + 1 + (row + 1) * (options.subdivisions + 1)); indices.push(col + row * (options.subdivisions + 1)); indices.push(col + 1 + (row + 1) * (options.subdivisions + 1)); indices.push(col + (row + 1) * (options.subdivisions + 1)); } } //   normals = pc.calculateNormals(positions, indices); //    var node = new pc.GraphNode(); var material = new pc.StandardMaterial(); //   var mesh = pc.createMesh(this.app.graphicsDevice, positions, { normals: normals, uvs: uvs, indices: indices }); var meshInstance = new pc.MeshInstance(node, mesh, material); //      var model = new pc.Model(); model.graph = node; model.meshInstances.push(meshInstance); this.entity.addComponent('model'); this.entity.model.model = model; this.entity.model.castShadows = false; //   ,       }; 

Maintenant, nous pouvons l'appeler dans la fonction initialize :

 Water.prototype.initialize = function() { this.GeneratePlaneMesh({subdivisions:100, width:10, height:10}); }; 

Maintenant, lorsque vous démarrez le jeu, vous ne devriez voir qu'une surface plane. Mais ce n'est pas seulement une surface plane, c'est un maillage composé de milliers de pics. Comme exercice, essayez de le vérifier vous-même (c'est une bonne raison d'étudier le code que vous venez de copier).

Problème 1: décaler la coordonnée Y de chaque sommet d'une valeur aléatoire de sorte que le plan ressemble à la figure ci-dessous.


Les vagues


Le but de cette section est de désigner la surface de l'eau de votre propre matériel et de créer des vagues animées.

Pour obtenir les effets dont nous avons besoin, vous devez configurer votre propre matériel. La plupart des moteurs 3D ont un ensemble de shaders prédéfinis pour le rendu des objets et un moyen de les redéfinir. Voici un bon lien sur la façon de procéder dans PlayCanvas.

Attachement Shader


Créons une nouvelle fonction CreateWaterMaterial qui CreateWaterMaterial nouveau matériau avec un shader modifié et le renvoie:

 Water.prototype.CreateWaterMaterial = function(){ //     var material = new pc.Material(); //    ,       material.name = "DynamicWater_Material"; //    //        . var gd = this.app.graphicsDevice; var fragmentShader = "precision " + gd.precision + " float;\n"; fragmentShader = fragmentShader + this.fs.resource; var vertexShader = this.vs.resource; //       . var shaderDefinition = { attributes: { aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0, }, vshader: vertexShader, fshader: fragmentShader }; //     this.shader = new pc.Shader(gd, shaderDefinition); //      material.setShader(this.shader); return material; }; 

Cette fonction prend le vertex et le fragment shader code des attributs de script. Définissons-les donc en haut du fichier (après la ligne pc.createScript ):

 Water.attributes.add('vs', { type: 'asset', assetType: 'shader', title: 'Vertex Shader' }); Water.attributes.add('fs', { type: 'asset', assetType: 'shader', title: 'Fragment Shader' }); 

Nous pouvons maintenant créer ces fichiers shader et les attacher à notre script. Retournez dans l'éditeur et créez deux fichiers shader: Water.frag et Water.vert . Attachez ces shaders au script comme indiqué dans la figure ci-dessous.


Si les nouveaux attributs ne sont pas affichés dans l'éditeur, cliquez sur le bouton Analyser pour mettre à jour le script.

Collez maintenant ce shader de base dans Water.frag :

 void main(void) { vec4 color = vec4(0.0,0.0,1.0,0.5); gl_FragColor = color; } 

Et celui-ci est dans Water.vert :

 attribute vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main(void) { gl_Position = matrix_viewProjection * matrix_model * vec4(aPosition, 1.0); } 

Enfin, revenez à Water.js pour utiliser notre nouveau matériel au lieu du matériel standard. Autrement dit, au lieu de:

 var material = new pc.StandardMaterial(); 

insérer:

 var material = this.CreateWaterMaterial(); 

Maintenant, après avoir commencé le jeu, l'avion doit être bleu.


Redémarrage à chaud


Pour l'instant, nous venons de mettre en place des flans de shader pour notre nouveau matériau. Avant de commencer à écrire des effets réels, je souhaite configurer le rechargement automatique du code.

Après avoir décommenté la fonction d' swap dans n'importe quel fichier de script (par exemple, dans Water.js), nous activerons le rechargement à chaud. Plus tard, nous verrons comment l'utiliser pour maintenir l'état même lors de la mise à jour du code en temps réel. Mais pour l'instant, nous voulons simplement réappliquer les shaders après avoir apporté les modifications. Avant de s'exécuter dans WebGL, les shaders sont compilés, donc pour ce faire, nous devons recréer notre matériel.

Nous vérifierons si le contenu de notre code shader a changé, et si c'est le cas, recréer le matériel. Tout d'abord, enregistrez les shaders actuels dans initialize :

 //  initialize,       Water.prototype.initialize = function() { this.GeneratePlaneMesh(); //    this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; }; 

Et dans la mise à jour, nous vérifions si des changements sont survenus:

 //  update,     Water.prototype.update = function(dt) { if(this.savedFS != this.fs.resource || this.savedVS != this.vs.resource){ //   ,      var newMaterial = this.CreateWaterMaterial(); //     var model = this.entity.model.model; model.meshInstances[0].material = newMaterial; //    this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; } }; 

Maintenant, pour vous assurer que cela fonctionne, démarrez le jeu et changez la couleur de l'avion dans Water.frag en un bleu plus agréable. Après avoir enregistré le fichier, il doit être mis à jour même sans redémarrage et redémarrez! Voici la couleur que j'ai choisie:

 vec4 color = vec4(0.0,0.7,1.0,0.5); 

Vertex Shaders


Pour créer des vagues, nous devons déplacer chaque sommet de notre maillage dans chaque image. Il semble que ce sera très inefficace, mais chaque sommet de chaque modèle est déjà transformé dans chaque image rendue. C'est ce que fait le vertex shader.

Si nous percevons un fragment shader comme une fonction qui est exécutée pour chaque pixel, obtient sa position et renvoie la couleur, alors un vertex shader est une fonction qui s'exécute pour chaque sommet, obtient sa position et renvoie sa position .

Un vertex shader obtient par défaut une position dans le monde du modèle et renvoie sa position à l'écran . Notre scène 3D est définie en coordonnées x, y et z, mais le moniteur est un plan plat à deux dimensions, nous projetons donc un monde 3D sur un écran 2D. Les matrices du type, de la projection et du modèle sont impliquées dans une telle projection, nous ne la considérerons donc pas dans ce tutoriel. Mais si vous voulez comprendre ce qui se passe exactement à chaque étape, voici un très bon guide .

Autrement dit, cette ligne:

 gl_Position = matrix_viewProjection * matrix_model * vec4(aPosition, 1.0); 

reçoit aPosition comme position dans le monde 3D d'un sommet particulier et le convertit en gl_Position , c'est-à-dire en position finale sur l'écran 2D. Le préfixe «a» dans aPosition indique que cette valeur est un attribut . N'oubliez pas que l' uniforme variable est une valeur que nous pouvons définir dans le CPU et la transmettre au shader. Il conserve la même valeur pour tous les pixels / sommets. D'un autre côté, la valeur d'attribut est obtenue à partir du tableau CPU spécifié. Un vertex shader est appelé pour chaque valeur de ce tableau d'attributs.

Vous pouvez voir que ces attributs sont configurés dans la définition de shader que nous avons définie dans Water.js:

 var shaderDefinition = { attributes: { aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0, }, vshader: vertexShader, fshader: fragmentShader }; 

PlayCanvas prend soin de configurer et de transmettre un tableau de positions de vertex pour une position lors du passage de cette énumération, mais dans le cas général, nous pouvons transmettre n'importe quel tableau de données au vertex shader.

Mouvement du sommet


Supposons que nous voulons compresser tout le plan en multipliant toutes les valeurs x par 0,5. Faut-il changer aPosition ou gl_Position ?

Essayons d'abord aPosition . Nous ne pouvons pas changer l'attribut directement, mais nous pouvons créer une copie:

 attribute vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main(void) { vec3 pos = aPosition; pos.x *= 0.5; gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0); } 

L'avion devrait maintenant ressembler davantage à un rectangle. Et cela n'a rien d'étrange. Mais que se passe-t-il si nous essayons de changer gl_Position ?

 attribute vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main(void) { vec3 pos = aPosition; //pos.x *= 0.5; gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0); gl_Position.x *= 0.5; } 

Jusqu'à ce que vous commenciez à déplacer la caméra, elle peut avoir la même apparence. Nous changeons les coordonnées de l'espace d'écran, c'est-à-dire que l'image dépendra de la façon dont nous la regardons .

Nous pouvons donc déplacer les sommets, et en même temps, il est important de faire la distinction entre le travail dans les espaces univers et écran.

Tâche 2: pouvez-vous déplacer la surface entière du plan de plusieurs unités vers le haut (le long de l'axe Y) dans le vertex shader sans déformer sa forme?

Tâche 3: J'ai dit que gl_Position est bidimensionnelle, mais gl_Position.z existe également. Pouvez-vous vérifier si cette valeur affecte quelque chose, et si oui, à quoi sert-elle?

Ajouter du temps


La dernière chose dont nous avons besoin avant de commencer à créer des ondes en mouvement est une variable uniforme qui peut être utilisée comme temps. Déclarez l'uniforme dans le vertex shader:

 uniform float uTime; 

Maintenant, pour le passer au shader, revenons à Water.js et définissons la variable de temps dans initialize:

 Water.prototype.initialize = function() { this.time = 0; /////     this.GeneratePlaneMesh(); //    this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; }; 

Maintenant, pour transférer la variable dans le shader, nous utilisons material.setParameter . Tout d'abord, nous définissons la valeur initiale à la fin de la fonction CreateWaterMaterial :

 //     this.shader = new pc.Shader(gd, shaderDefinition); //////////////   material.setParameter('uTime',this.time); this.material = material; //      //////////////// //      material.setShader(this.shader); return material; 

Maintenant, dans la fonction de update , nous pouvons effectuer un incrément de temps et accéder au matériel en utilisant le lien créé pour cela:

 this.time += 0.1; this.material.setParameter('uTime',this.time); 

Enfin, dans la fonction swap, nous copions l'ancienne valeur de temps de sorte que même après avoir changé le code, elle continue d'augmenter sans réinitialiser à 0.

 Water.prototype.swap = function(old) { this.time = old.time; }; 

Maintenant, tout est prêt. Exécutez le jeu pour vous assurer qu'il n'y a pas d'erreur. Maintenant, déplaçons notre avion en utilisant la fonction de temps dans Water.vert :

 pos.y += cos(uTime) 

Et notre avion devrait commencer à monter et descendre! Puisque nous avons maintenant une fonction d'échange, nous pouvons également mettre à jour Water.js sans avoir à redémarrer. Pour vous assurer que cela fonctionne, essayez de modifier l'incrément de temps.


Tâche 4: pouvez-vous déplacer les sommets pour qu'ils ressemblent aux vagues de la figure ci-dessous?


Permettez-moi de vous dire que j'ai examiné en détail le sujet des différentes façons de créer des vagues ici . L'article est lié à la 2D, mais les calculs mathématiques sont applicables à notre cas. Si vous voulez juste voir la solution, voici l'essentiel .

Translucidité


Le but de cette section est de créer une surface d'eau translucide.

Vous pouvez remarquer que la couleur renvoyée à Water.frag a une valeur de canal alpha de 0,5, mais la surface reste toujours opaque. Dans de nombreux cas, la transparence devient toujours un problème non résolu en infographie. Un moyen peu coûteux de le résoudre est d'utiliser le mélange.

Habituellement, avant de dessiner un pixel, il vérifie la valeur dans le tampon de profondeur et la compare à sa propre valeur de profondeur (sa position le long de l'axe Z) pour déterminer s'il faut redessiner ou non le pixel d'écran actuel. C'est ce qui vous permet de rendre la scène correctement sans avoir à trier les objets de l'arrière vers l'avant.

Lors du mixage, au lieu de simplement rejeter le pixel ou l'écraser, nous pouvons combiner la couleur du pixel déjà rendu (cible) avec le pixel que nous allons dessiner (la source). Une liste de toutes les fonctions de mixage disponibles dans WebGL peut être trouvée ici .

Pour que le canal alpha fonctionne conformément à nos attentes, nous voulons que la couleur combinée du résultat soit une source multipliée par un canal alpha plus un pixel de destination multiplié par un moins alpha. En d'autres termes, si alpha = 0,4, alors la couleur finale doit avoir une valeur:

 finalColor = source * 0.4 + destination * 0.6; 

Dans PlayCanvas, c'est l'opération que pc.BLEND_NORMAL effectue .

Pour l'activer, définissez simplement la propriété du matériau dans CreateWaterMaterial :

 material.blendType = pc.BLEND_NORMAL; 

Si vous commencez maintenant le jeu, l'eau deviendra translucide! Cependant, il est encore imparfait. Le problème se pose lorsque la surface translucide se superpose à elle-même, comme illustré ci-dessous.


Nous pouvons l'éliminer en utilisant l' alpha à la couverture , une technique de multi-échantillonnage pour la transparence, au lieu de mélanger:

 //material.blendType = pc.BLEND_NORMAL; material.alphaToCoverage = true; 

Mais il n'est disponible que dans WebGL 2. Dans la suite du tutoriel, par souci de simplicité, je vais utiliser le mixage.

Pour résumer


Nous avons mis en place l'environnement et créé une surface translucide de l'eau avec des ondes animées du vertex shader. Dans la deuxième partie du didacticiel, nous examinerons la flottabilité des objets, ajouterons des lignes à la surface de l'eau et créerons des lignes de mousse le long des limites des objets se croisant avec la surface.

Dans la troisième (dernière) partie, nous examinerons l'application de l'effet de post-traitement des distorsions sous-marines et examinerons des idées d'amélioration.

Code source


Le projet PlayCanvas terminé peut être trouvé ici . Notre référentiel possède également un port de projet sous Three.js .

Source: https://habr.com/ru/post/fr416953/


All Articles