Créer de l'eau pour le Web deuxième partie

Bienvenue dans cette série en trois parties sur la création d’une eau stylisée dans PlayCanvas à l’aide de shaders de vertex. Dans la partie 1, nous avons couvert la configuration de notre environnement et de la surface de l'eau. Cette partie couvrira l'application de la flottabilité aux objets, l'ajout de lignes d'eau à la surface et la création de lignes de mousse avec le tampon de profondeur autour des bords des objets coupant la surface.. 

J'ai apporté quelques petites modifications à ma scène pour la rendre un peu plus jolie. Vous pouvez personnaliser votre scène comme bon vous semble, mais ce que j'ai fait est la suivante:

  • Ajout des modèles phare et pieuvre.
  • Ajout d'un plan de sol avec la couleur # FFA457
  • Ajout d'une couleur claire pour la caméra de # 6CC8FF.
  • Ajout d'une couleur ambiante à la scène de # FFC480 (vous pouvez le trouver dans les paramètres de la scène).

Ci-dessous, voici à quoi ressemble mon point de départ.

Flottabilité 

Le moyen le plus simple de créer de la flottabilité consiste simplement à créer un script qui déplacera les objets de haut en bas. Créez un nouveau script appelé Flottabilité.js et définissez son initialize sur:

Buoyancy.prototype.initialize = function () this.initialPosition = this.entity.getPosition (). Clone (); this.initialRotation = this.entity.getEulerAngles (). clone (); // L'heure initiale est définie sur une valeur aléatoire. Ainsi, si // ce script est associé à plusieurs objets, ils ne seront pas // tous déplacés de la même manière this.time = Math.random () * 2 * Math.PI; ;

Maintenant, dans la mise à jour, nous incrémentons le temps et faisons pivoter l'objet:

Flottabilité.prototype.update = fonction (dt) this.time + = 0.1; // Déplace l'objet de haut en bas 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); // fait légèrement pivoter l'objet 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 sur votre bateau et regardez-le tanguer dans l'eau! Vous pouvez appliquer ce script à plusieurs objets (y compris la caméra, essayez-le)!

Texturer la surface

À l'heure actuelle, la seule façon de voir les vagues consiste à regarder les bords de la surface de l'eau. L'ajout d'une texture permet de rendre le mouvement sur la surface plus visible et constitue un moyen peu coûteux de simuler des réflexions et des caustiques..

Vous pouvez essayer de trouver une texture caustique ou créer la vôtre. En voici un que j'ai dessiné dans Gimp et que vous pouvez utiliser librement. Toute texture fonctionnera aussi longtemps qu'elle pourra être carrelée de manière transparente.

Une fois que vous avez trouvé une texture que vous aimez, faites-la glisser dans la fenêtre d'actif de votre projet. Nous devons référencer cette texture dans notre script Water.js, créez-lui donc un attribut:

Water.attributes.add ('surfaceTexture', type: 'actif', assetType: 'texture', titre: 'Surface Surface');

Et puis assignez-le dans l'éditeur:

Nous devons maintenant le transmettre à notre shader. Aller à Water.js et définir un nouveau paramètre dans le CreateWaterMaterial une fonction:

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

Maintenant aller dans Water.frag et déclarer notre nouvel uniforme:

uniforme sampler2D uSurfaceTexture;

Nous y sommes presque. Pour rendre la texture sur le plan, nous devons savoir où se trouve chaque pixel le long du maillage. Ce qui signifie que nous devons transmettre des données du vertex shader au fragment shader.

Variables variables

UNE variantvariable vous permet de transmettre des données du vertex shader au fragment shader. C'est le troisième type de variable spéciale que vous pouvez avoir dans un shader (les deux autres étant uniformeet attribut). Il est défini pour chaque sommet et est accessible par chaque pixel. Puisqu'il y a beaucoup plus de pixels que de sommets, la valeur est interpolée entre les sommets (c'est de là que vient le nom "variable", cela varie des valeurs que vous lui avez attribuées)..

Pour essayer ceci, déclarez une nouvelle variable dans Eau.vert comme variant:

variant vec2 ScreenPosition;

Et puis le mettre à gl_Position après avoir été calculé:

ScreenPosition = gl_Position.xyz;

Revenons maintenant à Water.frag et déclarer la même variable. Il n'y a aucun moyen d'obtenir une sortie de débogage à partir d'un shader, mais nous pouvons utiliser la couleur pour déboguer visuellement. Voici une façon de faire ceci:

uniforme sampler2D uSurfaceTexture; variant vec3 ScreenPosition; vide principal (vide) vec4 color = vec4 (0,0,0,7,1,0,0,5); // Test de notre nouvelle variable variable color = vec4 (vec3 (ScreenPosition.x), 1.0); gl_FragColor = couleur; 

L'avion doit maintenant apparaître en noir et blanc, la ligne qui les sépare étant l'endroit où ScreenPosition.x est 0. Les valeurs de couleur ne vont que de 0 à 1, mais les valeurs dans ScreenPosition peut être en dehors de cette plage. Ils sont automatiquement bloqués, donc si vous voyez du noir, cela pourrait être 0, ou négatif.

Ce que nous venons de faire, c’est de faire 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 se trouvera toujours au centre de l'écran, quelle que soit la surface réelle dans le monde..

Défi n ° 1: Créer une nouvelle variable variable pour passer la position mondiale à la place de la position d'écran. Visualisez-le de la même manière que ci-dessus. Si la couleur ne change pas avec l'appareil photo, vous avez correctement procédé..

Utiliser les UV 

Les UV sont les coordonnées 2D de chaque sommet du maillage, normalisées de 0 à 1. C’est exactement ce dont nous avons besoin pour bien échantillonner la texture dans le plan. C’est déjà à partir de la partie précédente..

Déclarer un nouvel attribut dans Eau.vert (ce nom vient de la définition du shader dans Water.js):

attribut vec2 aUv0;

Et tout ce que nous avons à faire est de le transmettre au fragment shader. Créez simplement une variante et réglez-la sur l'attribut:

// In Water.vert // Nous déclarons ceci avec nos autres variables en haut, variant variant vec2 vUv0; //… // Dans la fonction principale, nous stockons la valeur de l'attribut // dans la variable afin que le shader fragment puisse y accéder vUv0 = aUv0; 

Nous déclarons maintenant la même variation dans le fragment shader. Pour vérifier que cela fonctionne, nous pouvons le visualiser comme avant, de sorte que Water.frag ressemble maintenant à:

uniforme sampler2D uSurfaceTexture; variant vec2 vUv0; vide principal (vide) vec4 color = vec4 (0,0,0,7,1,0,0,5); // Vérification de la couleur d'UV = vec4 (vec3 (vUv0.x), 1.0); gl_FragColor = couleur; 

Et vous devriez voir un gradient, confirmant que nous avons une valeur de 0 à une extrémité et de 1 à l'autre. Maintenant, pour goûter notre texture, tout ce que nous avons à faire est:

color = texture2D (uSurfaceTexture, vUv0);

Et vous devriez voir la texture sur la surface:

Styliser la texture

Au lieu de simplement définir la texture comme notre nouvelle couleur, combinons-la avec le bleu que nous avions:

uniforme sampler2D uSurfaceTexture; variant vec2 vUv0; vide principal (vide) vec4 color = vec4 (0,0,0,7,1,0,0,5); vec4 WaterLines = texture2D (uSurfaceTexture, vUv0); color.rgba + = WaterLines.r; gl_FragColor = couleur; 

Cela fonctionne car la couleur de la texture est noire (0) partout sauf pour les lignes d'eau. En l'ajoutant, nous ne changeons pas la couleur bleue d'origine, sauf dans les endroits où il y a des lignes, où elle devient plus claire.. 

Ce n'est pas le seul moyen de combiner les couleurs, bien que.

Défi n ° 2: Pouvez-vous combiner les couleurs de manière à obtenir l’effet plus subtil illustré ci-dessous?

Déplacer la texture

Pour finir, nous souhaitons que les lignes se déplacent le long de la surface pour ne pas sembler aussi statiques. Pour ce faire, nous utilisons le fait que toute valeur donnée à la texture2D fonctionner en dehors de la plage 0 à 1 sera bouclé (de telle sorte que 1,5 et 2,5 deviennent tous deux 0,5). Ainsi, nous pouvons incrémenter notre position de la variable uniforme que nous avons déjà définie et multiplier celle-ci pour augmenter ou réduire la densité des lignes de notre surface, ce qui donne à notre frag shader final l'aspect suivant:

uniforme sampler2D uSurfaceTexture; uniforme float uTime; variant vec2 vUv0; vide principal (vide) vec4 color = vec4 (0,0,0,7,1,0,0,5); vec2 pos = vUv0; // En multipliant par un nombre supérieur à 1, la texture // se répète plus souvent pos * = 2.0; // Déplacer toute la texture pour qu'elle se déplace le long de la surface pos.y + = uTime * 0.02; vec4 WaterLines = texture2D (uSurfaceTexture, pos); color.rgba + = WaterLines.r; gl_FragColor = couleur; 

Lignes de mousse et le tampon de profondeur

Le rendu des lignes de mousse autour des objets dans l'eau permet de voir plus facilement comment les objets sont immergés et où ils coupent la surface. Cela rend également notre eau beaucoup plus crédible. Pour ce faire, nous devons en quelque sorte déterminer où se trouvent les bords de chaque objet et le faire efficacement..

L'astuce

Ce que nous voulons, c'est pouvoir dire, à partir d'un pixel à la surface de l'eau, s'il est proche d'un objet. Si c'est le cas, nous pouvons le colorer en mousse. Il n'y a pas de moyen simple de faire cela (à ma connaissance). Donc, pour comprendre cela, nous allons utiliser une technique utile de résolution de problème: trouver un exemple auquel nous connaissons la réponse et voir si nous pouvons le généraliser.. 

Considérez la vue ci-dessous.

Quels pixels devraient faire partie de la mousse? Nous savons que cela devrait ressembler à quelque chose comme ça:

Alors pensons à deux pixels spécifiques. J'ai marqué deux avec des étoiles ci-dessous. Le noir est dans la mousse. Le rouge n'est pas. Comment pouvons-nous les distinguer dans un shader?

Ce que nous savons, c'est que même si ces deux pixels sont proches l'un de l'autre dans l'espace d'écran (les deux sont représentés directement au-dessus du corps du phare), ils sont en réalité éloignés l'un de l'autre dans l'espace mondial. Nous pouvons le vérifier en regardant la même scène sous un angle différent, comme indiqué ci-dessous..

Notez que l'étoile rouge n'est pas sur le corps du phare tel qu'il est apparu, mais l'étoile noire l'est en réalité. Nous pouvons les distinguer en utilisant la distance à la caméra, communément appelée «profondeur», où une profondeur de 1 signifie qu'elle est très proche de la caméra et une profondeur de 0 signifie que c'est très loin. Mais ce n’est pas seulement une question de distance absolue du monde, ou de profondeur, à la caméra. C'est la profondeur par rapport au pixel derrière.

Retournez à la première vue. Supposons que le corps du phare a une profondeur de 0,5. La profondeur de l'étoile noire serait très proche de 0,5. Ainsi, il possède, avec le pixel situé derrière, des valeurs de profondeur similaires. L'étoile rouge, par contre, aurait une profondeur beaucoup plus grande, car elle serait plus proche de la caméra, disons 0,7. Et pourtant, le pixel derrière, toujours sur le phare, a une profondeur de 0,5, il y a donc une plus grande différence.

C'est le truc. Lorsque la profondeur du pixel à la surface de l'eau est suffisamment proche de la profondeur du pixel sur lequel elle est dessinée, nous sommes assez près du bord de quelque chose, et on peut le rendre sous forme de mousse. 

Nous avons donc besoin de plus d'informations que celles disponibles dans un pixel donné. Nous avons en quelque sorte besoin de connaître la profondeur du pixel sur lequel il va être dessiné. C'est là que le tampon de profondeur entre en jeu.

Le tampon de profondeur

Vous pouvez considérer un tampon ou un framebuffer comme une cible de rendu hors écran ou une texture. Vous voudriez rendre le rendu hors écran lorsque vous essayez de lire des données, technique utilisée par cet effet de fumée.

Le tampon de profondeur est une cible de rendu spéciale qui contient des informations sur les valeurs de profondeur à chaque pixel. Rappelez-vous que la valeur de gl_Position calculé dans le vertex shader était une valeur d'espace d'écran, mais il avait aussi une troisième coordonnée, une valeur Z. Cette valeur Z est utilisée pour calculer la profondeur qui est écrite dans le tampon de profondeur.. 

Le but de la mémoire tampon de profondeur est de dessiner correctement notre scène, sans qu'il soit nécessaire de trier les objets de haut en bas. Chaque pixel sur le point d'être dessiné en premier consulte le tampon de profondeur. Si sa valeur de profondeur est supérieure à la valeur dans la mémoire tampon, elle est dessinée et sa propre valeur remplace celle de la mémoire tampon. Sinon, il est jeté (parce que cela signifie qu'un autre objet est devant lui).

Vous pouvez réellement désactiver l'écriture dans le tampon de profondeur pour voir à quoi les choses ressembleraient. Vous pouvez essayer ceci dans Water.js:

material.depthTest = false;

Vous verrez comment l'eau sera toujours rendue au sommet, même si elle se trouve derrière des objets opaques..

Visualiser le tampon de profondeur

Ajoutons un moyen de visualiser le tampon de profondeur à des fins de débogage. Créez un nouveau script appelé DepthVisualize.js. Attachez ceci à votre appareil photo. 

Tout ce que nous avons à faire pour accéder au tampon de profondeur dans PlayCanvas est de dire:

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

Ceci injectera automatiquement un uniforme dans tous nos shaders que nous pourrons utiliser en le déclarant comme:

uniforme sampler2D uDepthMap;

Vous trouverez ci-dessous un exemple de script qui demande la carte de profondeur et la restitue par-dessus notre scène. Il est configuré pour le rechargement à chaud. 

var DepthVisualize = pc.createScript ('depthVisualize'); // initialise le code appelé une fois par entité DepthVisualize.prototype.initialize = function () this.entity.camera.camera.requestDepthMap (); this.antiCacheCount = 0; // Pour empêcher le moteur de mettre en cache notre shader afin que nous puissions le mettre à jour en direct this.SetupDepthViz (); ; DepthVisualize.prototype.SetupDepthViz = function () var device = this.app.graphicsDevice; var chunks = pc.shaderChunks; this.fs = "; this.fs + = 'variant 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 + = 'profondeur de flottement = 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 (profondeur), 1.0); '; this.fs + =' '; this.shader = chunks.createShaderFromCode (device, chunks.fullscreenQuadVS, this.fs, "renderDepth" + this.antiCacheCount); this.antiCacheCount ++; // Nous créons manuellement un appel à dessiner pour rendre la carte de profondeur au dessus de tout this.command = new pc.Command (pc.LAYER_FX, pc.BLEND_NONE, function () pc.drawQuadWithShader (device, null, this.shader); .bind (this)); this.command.isDepthViz = true; // Marquez-le simplement afin que nous puissions le supprimer ultérieurement this.app.scene.drawCalls.push (this.command); ; // code de mise à jour appelé chaque image DepthVisualize.prototype.update = fonction (dt) ; // méthode de swap appelée pour le rechargement à chaud // hérite de votre état de script ici DepthVisualize.prototype.swap = function (old) this .antiCacheCount = old.antiCacheCount; // Supprime l'appel de tirage de profondeur pour (var i = 0; i

Essayez de copier cela dans, et commentez / décommentez la ligne this.app.scene.drawCalls.push (this.command); pour basculer le rendu de la profondeur. Cela devrait ressembler à l'image ci-dessous.

Défi n ° 3: La surface de l'eau n'est pas attirée dans la zone tampon de profondeur. Le moteur PlayCanvas le fait intentionnellement. Peux-tu comprendre pourquoi? Quelle est la particularité du matériau de l'eau? En d'autres termes, d'après nos règles de vérification de la profondeur, que se passerait-il si les pixels de l'eau écrivaient dans la mémoire tampon de profondeur??

Astuce: il y a une ligne que vous pouvez modifier dans Water.js qui entraînera l'écriture de l'eau dans le tampon de profondeur.

Une autre chose à noter est que je multiplie la valeur de la profondeur par 30 dans le shader intégré dans la fonction initialize. Ceci est juste pour pouvoir le voir clairement, parce que sinon la plage de valeurs est trop petite pour être vue comme des nuances de couleur.

Mettre en œuvre le tour

Le moteur PlayCanvas comprend un ensemble de fonctions d’aide permettant de travailler avec les valeurs de profondeur, mais au moment de leur rédaction, elles ne sont pas mises en production. Nous allons donc les configurer nous-mêmes..

Définir les uniformes suivants à Water.frag:

// Ces uniformes sont tous injectés automatiquement par PlayCanvas uniform sampler2D uDepthMap; uniforme vec4 uScreenSize; uniforme mat4 matrix_view; // Nous devons configurer celui-ci nous-mêmes uniforme vec4 camera_params;

Définissez ces fonctions d'assistance au-dessus de la fonction principale:

#ifdef GL2 float linearizeDepth (float z) z = z * 2,0 - 1,0; retourne 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 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / (256.0 *, 1.0); point de retour (rgbaDepth, bitShift);  #endif #endif float getLinearScreenDepth (vec2 uv) #ifdef GL2 renvoie 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; retourne getLinearScreenDepth (uv); 

Transmettez des informations sur la caméra au shader de Water.js. Mettez ceci à l'endroit où vous passez d'autres uniformes comme uTime:

if (! this.camera) this.camera = this.app.root.findByName ("Caméra"). 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 de la position mondiale pour chaque pixel dans notre shader de frag. Nous devons obtenir cela du vertex shader. Alors définissez une variation dans Water.frag:

variant vec3 WorldPosition;

Définir la même en variant dans Eau.vert. Puis définissez-le sur la position déformée dans le vertex shader, afin que le code complet ressemble à ceci:

attribut vec3 aPosition; attribut vec2 aUv0; variant vec2 vUv0; variant vec3 WorldPosition; uniforme mat4 matrix_model; uniforme mat4 matrix_viewProjection; uniforme float uTime; void main (void) vUv0 = aUv0; vec3 pos = aPosition; pos.y + = cos (pos.z * 5,0 + heure) * 0,1 * sin (pos.x * 5,0 + heure); gl_Position = matrix_viewProjection * matrix_model * vec4 (pos, 1.0); WorldPosition = pos;  

Réaliser le truc

Nous sommes enfin prêts à mettre en œuvre la technique décrite au début de cette section. Nous voulons comparer la profondeur du pixel où nous sommes à la profondeur du pixel derrière elle. Le pixel auquel nous nous trouvons provient de la position mondiale et le pixel derrière de la position de l'écran. Alors prenez ces deux profondeurs:

float worldDepth = getLinearDepth (WorldPosition); float screenDepth = getLinearScreenDepth ();
Défi n ° 4: l'une de ces valeurs ne sera jamais supérieure à l'autre (en supposant que depthTest = true). Pouvez-vous en déduire?

Nous savons que la mousse se situera là où la distance entre ces deux valeurs est faible. Alors rendons cette différence à chaque pixel. Placez ceci au bas de votre shader (et assurez-vous que le script de visualisation de profondeur de la section précédente est désactivé):

color = vec4 (vec3 (screenDepth - worldDepth), 1,0); gl_FragColor = couleur;

Ce qui devrait ressembler à ceci:

Qui choisit correctement les bords de tout objet immergé dans l'eau en temps réel! Vous pouvez bien sûr réduire cette différence pour rendre la mousse plus épaisse / plus fine.

Il existe maintenant de nombreuses façons de combiner cette sortie avec la couleur de la surface de l’eau pour obtenir de jolies lignes de mousse. Vous pouvez le conserver sous forme de dégradé, l'utiliser pour échantillonner à partir d'une autre texture ou le définir sur une couleur spécifique si la différence est inférieure ou égale à un seuil..

Mon look préféré est de lui donner une couleur similaire à celle des lignes d'eau statiques. Ma dernière fonction principale ressemble à ceci:

vide principal (vide) 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); si (ligne mousse) < 0.7) color.rgba += 0.2;  gl_FragColor = color; 

Résumé

Nous avons créé une flottabilité sur des objets flottant dans l'eau, nous avons donné à notre surface une texture mobile pour simuler des caustiques, et nous avons vu comment nous pourrions utiliser le tampon de profondeur pour créer des lignes de mousse dynamiques..

Pour finir, la prochaine et dernière partie présentera les effets post-traitement et leur utilisation pour créer l’effet de distorsion sous-marine..

Code source

Vous pouvez trouver le projet PlayCanvas hébergé terminé ici. Un port Three.js est également disponible dans ce référentiel.