Créez un shader d'eau de dessin animé pour le Web. 2e partie

Dans la première partie, nous avons étudié la mise en place de l'environnement et de la surface de l'eau. Dans cette partie, nous allons donner de la flottabilité aux objets, ajouter des lignes d'eau à la surface et créer des lignes de mousse avec un tampon de profondeur autour des limites des objets se croisant avec la surface.

Pour rendre la scène un peu meilleure, j'y ai apporté des modifications mineures. Vous pouvez personnaliser votre scène comme vous le souhaitez, mais j'ai fait ce qui suit:

  • Ajout de modèles d'un phare et d'une pieuvre.
  • Ajout d'un modèle au sol avec la couleur #FFA457 .
  • Ajout d'une couleur de ciel #6CC8FF .
  • Ajout de la couleur de rétroéclairage #FFC480 à la scène (ces paramètres peuvent être trouvés dans les paramètres de la scène).

Ma scène originale ressemble maintenant à ceci.


Flottabilité


La façon la plus simple de créer une flottabilité est d'utiliser un script pour pousser des objets vers le haut et vers le bas. Créez un nouveau script Buoyancy.js et définissez ce qui suit dans son initialisation:

 Buoyancy.prototype.initialize = function() { this.initialPosition = this.entity.getPosition().clone(); this.initialRotation = this.entity.getEulerAngles().clone(); //     ,  //        //     this.time = Math.random() * 2 * Math.PI; }; 

Maintenant, dans la mise à jour, nous exécutons l'incrément de temps et tournons l'objet:

 Buoyancy.prototype.update = function(dt) { this.time += 0.1; //      var pos = this.entity.getPosition().clone(); pos.y = this.initialPosition.y + Math.cos(this.time) * 0.07; this.entity.setPosition(pos.x,pos.y,pos.z); //    var rot = this.entity.getEulerAngles().clone(); rot.x = this.initialRotation.x + Math.cos(this.time * 0.25) * 1; rot.z = this.initialRotation.z + Math.sin(this.time * 0.5) * 2; this.entity.setLocalEulerAngles(rot.x,rot.y,rot.z); }; 

Appliquez ce script au bateau et voyez comment il saute de haut en bas sur l'eau! Vous pouvez appliquer ce script à plusieurs objets (y compris la caméra - essayez-le)!

Texture de surface


Alors que nous pouvons voir les vagues, nous regardons les bords de la surface de l'eau. L'ajout de texture rendra le mouvement de surface plus visible. De plus, c'est un moyen peu coûteux de simuler les réflexions et les caustiques.

Vous pouvez essayer de trouver des textures caustiques ou en créer une vous-même. J'ai dessiné une texture dans Gimp que vous pouvez utiliser librement. Toute texture convient, à condition qu'elle puisse être carrelée sans joints perceptibles.

Après avoir choisi la texture que vous aimez, faites-la glisser vers la fenêtre Actifs de votre projet. Nous devons référencer cette texture à partir du script Water.js, alors créons-en un attribut:

 Water.attributes.add('surfaceTexture', { type: 'asset', assetType: 'texture', title: 'Surface Texture' }); 

Et puis affectez-le dans l'éditeur:


Maintenant, nous devons le passer au shader. Allez dans Water.js et définissez la fonction CreateWaterMaterial nouveau paramètre:

 material.setParameter('uSurfaceTexture',this.surfaceTexture.resource); 

Revenez maintenant sur Water.frag et déclarez un nouvel uniforme:

 uniform sampler2D uSurfaceTexture; 

Nous avons presque fini. Pour rendre une texture sur un plan, nous devons savoir où se trouve chaque pixel dans le maillage. Autrement dit, nous devons transférer les données du vertex shader vers le fragment one.

Variables variables


Des variables variables vous permettent de transférer des données du vertex shader vers le fragment one. Il s'agit du troisième type de variables spéciales pouvant être utilisées dans le shader (les deux premières sont uniformes et attributaires ). Une variable est définie pour chaque sommet et chaque pixel peut y accéder. Puisqu'il y a beaucoup plus de pixels que de sommets, la valeur est interpolée entre les sommets (d'où le nom «variant» - il s'écarte des valeurs qui lui sont transmises).

Pour le tester en fonctionnement, déclarez une nouvelle variable dans Water.vert comme variant:

 varying vec2 ScreenPosition; 

Et puis affectez-lui la valeur gl_Position après le calcul:

 ScreenPosition = gl_Position.xyz; 

Revenons maintenant à Water.frag et déclarez la même variable. Nous ne pouvons pas obtenir la sortie des données de débogage à partir du shader, mais nous pouvons utiliser la couleur pour le débogage visuel. Voici comment procéder:

 uniform sampler2D uSurfaceTexture; varying vec3 ScreenPosition; void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5); //    varying- color = vec4(vec3(ScreenPosition.x),1.0); gl_FragColor = color; } 

L'avion doit maintenant être noir et blanc et la ligne de séparation des couleurs ira là où ScreenPosition.x = 0. Les valeurs de couleur ne changent que de 0 à 1, mais les valeurs de ScreenPosition peuvent être en dehors de cet intervalle. Ils sont automatiquement limités, par conséquent, lorsque vous voyez du noir, il peut être 0 ou un nombre négatif.

Ce que nous venons de faire, c'est de passer la position d'écran de chaque sommet à chaque pixel. Vous pouvez voir que la ligne séparant les côtés noir et blanc passera toujours au centre de l'écran, quelle que soit la surface réelle du monde.

Tâche 1: créez une nouvelle variable variable pour transférer la position dans le monde au lieu de la position de l'écran. Visualisez-le de la même manière. Si la couleur ne change pas avec le mouvement de la caméra, alors tout est fait correctement.

Utilisation des UV


UV est les coordonnées 2D de chaque sommet du maillage, normalisées de 0 à 1. Elles sont nécessaires pour un échantillonnage correct de la texture sur le plan, et nous les avons déjà configurées dans la partie précédente.

Nous allons déclarer un nouvel attribut dans Water.vert (ce nom est tiré de la définition d'un shader dans Water.js):

 attribute vec2 aUv0; 

Et maintenant, il nous suffit de le passer au fragment shader, il suffit donc de créer des variations et de lui attribuer la valeur d'attribut:

 //  Water.vert //        varying vec2 vUv0; // .. //        //  varying,        vUv0 = aUv0; 

Nous allons maintenant déclarer la même variable variable dans le fragment shader. Pour nous assurer que tout fonctionne, nous pouvons visualiser le débogage comme avant, puis Water.frag ressemblera à ceci:

 uniform sampler2D uSurfaceTexture; varying vec2 vUv0; void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5); //  UV color = vec4(vec3(vUv0.x),1.0); gl_FragColor = color; } 

Vous devriez voir un gradient confirmant que nous avons une valeur de 0 à une extrémité et de 1 à l'autre. Maintenant, pour échantillonner la texture pour de vrai, tout ce que nous devons faire est de:

 color = texture2D(uSurfaceTexture,vUv0); 

Après cela, nous verrons la texture à la surface:


Style de texture


Au lieu de simplement définir la texture comme nouvelle couleur, combinons-la avec le bleu existant:

 uniform sampler2D uSurfaceTexture; varying vec2 vUv0; void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5); vec4 WaterLines = texture2D(uSurfaceTexture,vUv0); color.rgba += WaterLines.r; gl_FragColor = color; } 

Cela fonctionne car la couleur de la texture est noire (0) partout sauf les lignes de flottaison. En l'ajoutant, nous ne changeons pas la couleur bleue initiale, à l'exception des endroits avec des lignes où elle devient plus claire.

Cependant, ce n'est pas le seul moyen de combiner les couleurs.

Tâche 2: pouvez-vous combiner les couleurs pour obtenir l'effet le plus faible illustré ci-dessous?


Texture en mouvement


Comme dernier effet, nous voulons que les lignes se déplacent le long de la surface et cela ne semble pas si statique. Pour ce faire, nous profiterons du fait que toute valeur en dehors de l'intervalle de 0 à 1 passée à la fonction texture2D sera transférée (par exemple, 1,5 et 2,5 deviennent égaux à 0,5). Par conséquent, nous pouvons augmenter notre position par la variable de temps uniforme que nous avons déjà définie pour augmenter ou diminuer la densité des lignes à la surface, ce qui donnera au fragment final un shader de cette forme:

 uniform sampler2D uSurfaceTexture; uniform float uTime; varying vec2 vUv0; void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5); vec2 pos = vUv0; //      1 //     pos *= 2.0; //   ,      pos.y += uTime * 0.02; vec4 WaterLines = texture2D(uSurfaceTexture,pos); color.rgba += WaterLines.r; gl_FragColor = color; } 

Lignes de mousse et tampon de profondeur


Le rendu des lignes de mousse autour des objets dans l'eau permet de voir plus facilement à quel point les objets sont immergés et où ils traversent la surface. De plus, de cette façon, notre eau devient beaucoup plus crédible. Pour réaliser les lignes de mousse, nous devons en quelque sorte savoir où se trouvent les limites de chaque objet et le faire efficacement.

Trick


Nous devons apprendre à déterminer si un pixel à la surface de l'eau est proche de l'objet. Si c'est le cas, nous pouvons le peindre dans la couleur de la mousse. Il n'existe aucun moyen simple de résoudre ce problème (pour autant que je sache). Par conséquent, pour le résoudre, j'utilise une technique utile pour résoudre des problèmes: je vais prendre un exemple dont nous connaissons la réponse et voir si nous pouvons la généraliser.

Jetez un œil à l'image ci-dessous.


Quels pixels doivent faire partie de la mousse? Nous savons que cela devrait ressembler à ceci:


Examinons donc deux pixels spécifiques. Ci-dessous, je les ai marqués avec des astérisques. Le noir sera sur la mousse et le rouge non. Comment les distinguons-nous dans un shader?


On sait que même si ces deux pixels de l'espace écran sont proches l'un de l'autre (les deux s'affichent au sommet du phare), en fait ils sont très éloignés dans l'espace du monde. Nous pouvons le vérifier en regardant la même scène sous un angle différent.


Notez que l'étoile rouge n'est pas située sur le phare, comme il nous a semblé, mais la noire est en fait là. On peut distinguer l'utilisation de la distance à la caméra, qui est généralement appelée "profondeur". La profondeur 1 signifie que le point est très proche de la caméra, la profondeur 0 signifie qu'il est très loin. Mais ce n'est pas seulement une question de distances absolues dans le monde, de profondeur ou de caméra. La profondeur par rapport au pixel derrière elle est importante.

Regardez à nouveau la première vue. Disons que la coque du phare a une valeur de profondeur de 0,5. La profondeur de l'étoile noire sera très proche de 0,5. Autrement dit, elle et le pixel en dessous ont des valeurs de profondeur très proches. En revanche, l'astérisque rouge aura une profondeur beaucoup plus grande, car il est plus proche de la caméra, disons 0,7. Et bien que le pixel derrière lui soit toujours sur le phare, il a une valeur de profondeur de 0,5, c'est-à-dire qu'il y a plus de différence.

Voilà l'astuce. Lorsque la profondeur d'un pixel à la surface de l'eau est suffisamment proche de la profondeur du pixel sur lequel il est dessiné, nous sommes assez proches de la bordure d'un objet et pouvons rendre le pixel comme de la mousse.

Autrement dit, nous avons besoin de plus d'informations que nous en avons dans n'importe quel pixel. Nous devons en quelque sorte trouver la profondeur du pixel sur lequel il doit être tracé. Et ici, le buffer de profondeur nous est utile.

Tampon de profondeur


Vous pouvez considérer un tampon d'image ou un tampon d'image comme un rendu ou une texture cible hors écran. Lorsque nous devons lire des données, nous devons effectuer un rendu hors écran. Cette technique est utilisée dans l'effet de fumée .

Le tampon de profondeur est un rendu cible spécial qui contient des informations sur les valeurs de profondeur de chaque pixel. N'oubliez pas que la valeur dans gl_Position , calculée dans le vertex shader, était la valeur de l'espace d'écran, mais elle a également une troisième coordonnée - la valeur Z. Cette valeur Z est utilisée pour calculer la profondeur, qui est écrite dans le tampon de profondeur.

Le tampon de profondeur est destiné au rendu correct de la scène sans avoir besoin de trier les objets de l'arrière vers l'avant. Chaque pixel à dessiner vérifie d'abord le tampon de profondeur. Si sa valeur de profondeur est supérieure à la valeur du tampon, elle est dessinée et sa propre valeur écrase la valeur du tampon. Sinon, il est jeté (car cela signifie qu'il y a un autre objet devant lui).

En fait, vous pouvez désactiver l'écriture dans le tampon de profondeur pour voir à quoi tout ressemblerait sans lui. Essayons de le faire dans Water.js:

 material.depthTest = false; 

Vous remarquerez que l'eau sera désormais toujours tirée par le haut, même si elle se trouve derrière des objets opaques.

Visualisation du tampon de profondeur


Ajoutons un moyen de rendre le tampon de profondeur à des fins de débogage. Créez un nouveau script DepthVisualize.js . Fixez-le à la caméra.

Pour accéder au tampon de profondeur dans PlayCanvas, écrivez simplement ce qui suit:

 this.entity.camera.camera.requestDepthMap(); 

Nous injectons donc automatiquement la variable uniforme dans tous nos shaders, que nous pouvons utiliser en la déclarant comme suit:

 uniform sampler2D uDepthMap; 

Vous trouverez ci-dessous un exemple de script demandant une carte de profondeur et la rendant au-dessus d'une scène. Il a configuré un redémarrage à chaud.

 var DepthVisualize = pc.createScript('depthVisualize'); //  initialize,       DepthVisualize.prototype.initialize = function() { this.entity.camera.camera.requestDepthMap(); this.antiCacheCount = 0; //    ,         this.SetupDepthViz(); }; DepthVisualize.prototype.SetupDepthViz = function(){ var device = this.app.graphicsDevice; var chunks = pc.shaderChunks; this.fs = ''; this.fs += 'varying vec2 vUv0;'; this.fs += 'uniform sampler2D uDepthMap;'; this.fs += ''; this.fs += 'float unpackFloat(vec4 rgbaDepth) {'; this.fs += ' const vec4 bitShift = vec4(1.0 / (256.0 * 256.0 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / 256.0, 1.0);'; this.fs += ' float depth = dot(rgbaDepth, bitShift);'; this.fs += ' return depth;'; this.fs += '}'; this.fs += ''; this.fs += 'void main(void) {'; this.fs += ' float depth = unpackFloat(texture2D(uDepthMap, vUv0)) * 30.0; '; this.fs += ' gl_FragColor = vec4(vec3(depth),1.0);'; this.fs += '}'; this.shader = chunks.createShaderFromCode(device, chunks.fullscreenQuadVS, this.fs, "renderDepth" + this.antiCacheCount); this.antiCacheCount ++; //     ,        this.command = new pc.Command(pc.LAYER_FX, pc.BLEND_NONE, function () { pc.drawQuadWithShader(device, null, this.shader); }.bind(this)); this.command.isDepthViz = true; //    ,      this.app.scene.drawCalls.push(this.command); }; //  update,     DepthVisualize.prototype.update = function(dt) { }; //  swap,      //      DepthVisualize.prototype.swap = function(old) { this.antiCacheCount = old.antiCacheCount; //      for(var i=0;i<this.app.scene.drawCalls.length;i++){ if(this.app.scene.drawCalls[i].isDepthViz){ this.app.scene.drawCalls.splice(i,1); break; } } //    this.SetupDepthViz(); }; //      ,  : // http://developer.playcanvas.com/en/user-manual/scripting/ 

Essayez de copier le code et de commenter / décommenter la ligne this.app.scene.drawCalls.push(this.command); pour activer / désactiver le rendu en profondeur. Cela devrait ressembler à l'image ci-dessous.


Tâche 3: la surface de l'eau n'est pas attirée dans le tampon de profondeur. Le moteur PlayCanvas le fait exprès. Pouvez-vous comprendre pourquoi? Quelle est la particularité du matériau de l'eau? En d'autres termes, compte tenu de nos règles de vérification des profondeurs, que se passerait-il si des pixels d'eau étaient écrits dans le tampon de profondeur?

Astuce: vous pouvez modifier une ligne dans Water.js, ce qui vous permet d'écrire de l'eau dans le tampon de profondeur.

Il convient également de noter que dans la fonction d'initialisation, je multiplie la valeur de profondeur par 30. Cela est nécessaire pour le voir clairement, car sinon la plage de valeurs serait trop petite pour afficher les tons de couleur.

Mise en œuvre astuce


Il existe plusieurs fonctions auxiliaires dans le moteur PlayCanvas pour travailler avec des valeurs de profondeur, mais au moment de la rédaction, elles n'étaient pas publiées en production, nous devrons donc les configurer nous-mêmes.

Nous définissons les variables uniformes suivantes dans Water.frag :

 //   uniform-    PlayCanvas uniform sampler2D uDepthMap; uniform vec4 uScreenSize; uniform mat4 matrix_view; //      uniform vec4 camera_params; 

Nous définissons ces fonctions auxiliaires sur la fonction principale:

 #ifdef GL2 float linearizeDepth(float z) { z = z * 2.0 - 1.0; return 1.0 / (camera_params.z * z + camera_params.w); } #else #ifndef UNPACKFLOAT #define UNPACKFLOAT float unpackFloat(vec4 rgbaDepth) { const vec4 bitShift = vec4(1.0 / (256.0 * 256.0 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / 256.0, 1.0); return dot(rgbaDepth, bitShift); } #endif #endif float getLinearScreenDepth(vec2 uv) { #ifdef GL2 return linearizeDepth(texture2D(uDepthMap, uv).r) * camera_params.y; #else return unpackFloat(texture2D(uDepthMap, uv)) * camera_params.y; #endif } float getLinearDepth(vec3 pos) { return -(matrix_view * vec4(pos, 1.0)).z; } float getLinearScreenDepth() { vec2 uv = gl_FragCoord.xy * uScreenSize.zw; return getLinearScreenDepth(uv); } 

Nous passerons les informations de shader sur la caméra dans Water.js . Collez ceci à l'endroit où vous passez les autres variables uniformes comme uTime:

 if(!this.camera){ this.camera = this.app.root.findByName("Camera").camera; } var camera = this.camera; var n = camera.nearClip; var f = camera.farClip; var camera_params = [ 1/f, f, (1-f / n) / 2, (1 + f / n) / 2 ]; material.setParameter('camera_params', camera_params); 

Enfin, nous avons besoin d'une position dans le monde de chaque pixel pour notre fragment shader. Nous devons l'obtenir du vertex shader. Par conséquent, nous définirons une variable variable dans Water.frag :

 varying vec3 WorldPosition; 

Définissez la même variable variable dans Water.vert . Ensuite, nous lui attribuons une position déformée par rapport au vertex shader afin que le code complet ressemble à ceci:

 attribute vec3 aPosition; attribute vec2 aUv0; varying vec2 vUv0; varying vec3 WorldPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; uniform float uTime; void main(void) { vUv0 = aUv0; vec3 pos = aPosition; pos.y += cos(pos.z*5.0+uTime) * 0.1 * sin(pos.x * 5.0 + uTime); gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0); WorldPosition = pos; } 

Nous réalisons l'astuce pour de vrai


Nous sommes maintenant enfin prêts à mettre en œuvre la technique décrite au début de cette section. Nous voulons comparer la profondeur du pixel dans lequel nous nous trouvons avec la profondeur du pixel en dessous. Le pixel dans lequel nous nous trouvons provient d'une position dans le monde et le pixel en dessous est obtenu à partir de la position de l'écran. Par conséquent, nous prenons ces deux profondeurs:

 float worldDepth = getLinearDepth(WorldPosition); float screenDepth = getLinearScreenDepth(); 

Tâche 4: l'une de ces valeurs ne sera jamais plus grande que l'autre (en supposant depthTest = true). Pouvez-vous déterminer lequel?

Nous savons que la mousse sera là où la distance entre les deux valeurs est petite. Par conséquent, rendons cette différence pour chaque pixel. Collez-le à la fin du shader (et désactivez le script de visualisation de la profondeur de la section précédente):

 color = vec4(vec3(screenDepth - worldDepth),1.0); gl_FragColor = color; 

Et cela devrait ressembler à ceci:


Autrement dit, nous choisissons correctement les limites de tout objet immergé dans l'eau en temps réel! Bien sûr, vous pouvez augmenter la différence pour rendre la mousse plus épaisse ou moins courante.

Nous avons maintenant de nombreuses options pour combiner cette sortie avec la surface de l'eau pour créer de belles lignes de mousse. Vous pouvez les laisser avec un dégradé, utiliser pour échantillonner à partir d'une texture différente ou leur attribuer une couleur spécifique si la différence est inférieure ou égale à une certaine valeur limite.

Ce que j'ai le plus aimé, c'est l'attribution d'une couleur similaire aux lignes d'eau statique, donc ma fonction principale terminée ressemble à ceci:

 void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5); vec2 pos = vUv0 * 2.0; pos.y += uTime * 0.02; vec4 WaterLines = texture2D(uSurfaceTexture,pos); color.rgba += WaterLines.r * 0.1; float worldDepth = getLinearDepth(WorldPosition); float screenDepth = getLinearScreenDepth(); float foamLine = clamp((screenDepth - worldDepth),0.0,1.0) ; if(foamLine < 0.7){ color.rgba += 0.2; } gl_FragColor = color; } 

Pour résumer


Nous avons créé la flottabilité d'objets immergés dans l'eau, appliqué une texture mobile à la surface pour simuler les caustiques et appris à utiliser le tampon de profondeur pour créer des bandes de mousse dynamiques.

Dans la troisième et dernière partie, nous allons ajouter les effets du post-traitement et apprendre à les utiliser pour créer l'effet de distorsion sous-marine.

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/fr416983/


All Articles