Erstellen Sie einen Cartoon-Water-Shader für das Web. Teil 1

In meinem Tutorial „Erstellen von Shadern“ habe ich mich hauptsächlich mit Fragment-Shadern befasst, die ausreichen, um 2D-Effekte und Beispiele auf ShaderToy zu implementieren . Es gibt jedoch eine ganze Kategorie von Techniken, die die Verwendung von Vertex-Shadern erfordern. In diesem Tutorial werde ich über das Erstellen eines stilisierten Cartoon-Wasser-Shaders sprechen und Ihnen Vertex-Shader vorstellen. Ich werde auch über den Tiefenpuffer sprechen und wie man ihn verwendet, um mehr Informationen über die Szene zu erhalten und Linien aus Seeschaum zu erzeugen.

So sieht der fertige Effekt aus. Eine interaktive Demo finden Sie hier .


Dieser Effekt besteht aus folgenden Elementen:

  1. Ein durchscheinendes Wassernetz mit unterteilten Polygonen und versetzten Eckpunkten, um Wellen zu erzeugen.
  2. Statische Wasserleitungen an der Oberfläche.
  3. Simulierter Auftrieb des Bootes.
  4. Dynamische Schaumlinien um die Grenzen von Objekten im Wasser.
  5. Nachbearbeitung, um unter Wasser alles zu verzerren.

In diesem Effekt gefällt mir die Tatsache, dass es viele verschiedene Konzepte der Computergrafik berührt, sodass wir die Ideen aus den vorherigen Tutorials verwenden und Techniken entwickeln können, die in neuen Effekten angewendet werden können.

In diesem Tutorial werde ich PlayCanvas verwenden , einfach weil es eine praktische kostenlose Web-IDE ist, aber alles kann problemlos auf jede andere WebGL-Umgebung angewendet werden. Am Ende des Artikels wird die Quellcodeversion für Three.js vorgestellt. Wir gehen davon aus, dass Sie sich bereits mit Fragment-Shadern und der PlayCanvas-Oberfläche auskennen. Hier können Sie Ihr Wissen über Shader auffrischen und sich hier mit PlayCanvas vertraut machen.

Umgebungseinstellung


In diesem Abschnitt wird unser PlayCanvas-Projekt konfiguriert und mehrere Umgebungsobjekte eingefügt, die durch Wasser beeinflusst werden.

Wenn Sie kein PlayCanvas-Konto haben, registrieren Sie es und erstellen Sie ein neues leeres Projekt . Standardmäßig sollten einige Objekte in der Szene, eine Kamera und eine Lichtquelle vorhanden sein.


Modelle einfügen


Eine großartige Ressource zum Auffinden von 3D-Modellen für das Web ist das Google Poly- Projekt. Ich habe das Bootsmodell von dort genommen. Nach dem Herunterladen und Entpacken des Archivs finden Sie darin .obj und .obj Dateien.

  1. Ziehen Sie beide Dateien in das Assets-Fenster des PlayCanvas-Projekts.
  2. Wählen Sie das automatisch generierte Material aus und wählen Sie die .png Datei als diffuse Karte aus.


Jetzt können Sie Tugboat.json in die Szene ziehen und die Objekte Box und Plane löschen. Wenn das Boot zu klein aussieht, können Sie seine Skalierung erhöhen (ich habe den Wert auf 50 gesetzt).


Ebenso können Sie der Szene beliebige andere Modelle hinzufügen.

Umlaufende Kamera


Um die im Orbit fliegende Kamera zu konfigurieren, kopieren wir das Skript aus diesem PlayCanvas-Beispiel . Folgen Sie dem Link und klicken Sie auf Editor , um das Projekt zu öffnen.

  1. Kopieren Sie den Inhalt von mouse-input.js und orbit-camera.js aus diesem Lernprogramm in gleichnamige Dateien aus Ihrem Projekt.
  2. Fügen Sie der Kamera eine Skriptkomponente hinzu .
  3. Fügen Sie der Kamera zwei Skripte hinzu.

Tipp: Um das Projekt zu organisieren, können Sie Ordner im Fenster Assets erstellen. Ich habe diese beiden Kameraskripte im Ordner Scripts / Camera / abgelegt, mein Modell in Models / und das Material im Ordner Materials /.

Wenn Sie jetzt das Spiel starten (die Starttaste im oberen rechten Teil des Szenenfensters), sollten Sie ein Boot sehen, das Sie mit einer Kamera inspizieren können, indem Sie es mit der Maus in die Umlaufbahn bewegen.

Wasseroberflächen-Polygonabteilung


In diesem Abschnitt wird ein unterteiltes Netz erstellt, das als Wasseroberfläche verwendet wird.

Um eine Wasseroberfläche zu erstellen, passen wir einen Teil des Codes aus dem Tutorial zur Reliefgenerierung an . Erstellen Sie eine neue Water.js Skriptdatei. Öffnen Sie dieses Skript zum Bearbeiten und erstellen Sie eine neue GeneratePlaneMesh Funktion, die folgendermaßen aussieht:

 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; //   ,       }; 

Jetzt können wir es in der initialize aufrufen:

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

Wenn Sie das Spiel starten, sollten Sie nur eine flache Oberfläche sehen. Dies ist jedoch nicht nur eine flache Oberfläche, sondern ein Netz aus Tausenden von Gipfeln. Versuchen Sie als Übung, dies selbst zu überprüfen (dies ist ein guter Grund, den gerade kopierten Code zu studieren).

Problem 1: Verschieben Sie die Y-Koordinate jedes Scheitelpunkts um einen zufälligen Wert, sodass die Ebene wie in der folgenden Abbildung aussieht.


Die Wellen


Der Zweck dieses Abschnitts besteht darin, die Wasseroberfläche Ihres eigenen Materials zu bestimmen und animierte Wellen zu erzeugen.

Um die von uns benötigten Effekte zu erzielen, müssen Sie Ihr eigenes Material konfigurieren. Die meisten 3D-Engines verfügen über eine Reihe vordefinierter Shader zum Rendern von Objekten und zur Neudefinition. Hier ist ein guter Link dazu in PlayCanvas.

Shader-Anhang


Erstellen wir eine neue CreateWaterMaterial Funktion, CreateWaterMaterial neues Material mit einem geänderten Shader definiert und zurückgibt:

 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; }; 

Diese Funktion übernimmt den Vertex- und Fragment-Shader-Code aus den Skriptattributen. Definieren wir sie also oben in der Datei (nach der Zeile pc.createScript ):

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

Jetzt können wir diese Shader-Dateien erstellen und an unser Skript anhängen. Kehren Sie zum Editor zurück und erstellen Sie zwei Shader-Dateien: Water.frag und Water.vert . Fügen Sie diese Shader wie in der folgenden Abbildung gezeigt an das Skript an.


Wenn die neuen Attribute nicht im Editor angezeigt werden, klicken Sie auf die Schaltfläche Analysieren , um das Skript zu aktualisieren.

Fügen Sie nun diesen grundlegenden Shader in Water.frag ein :

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

Und dieser ist in 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); } 

Kehren Sie schließlich zu Water.js zurück , um unser neues Material anstelle des Standardmaterials zu verwenden. Das heißt, anstelle von:

 var material = new pc.StandardMaterial(); 

einfügen:

 var material = this.CreateWaterMaterial(); 

Jetzt, nach dem Start des Spiels, sollte das Flugzeug blau sein.


Heißer Neustart


Im Moment haben wir nur Shader-Rohlinge für unser neues Material eingerichtet. Bevor ich anfange, echte Effekte zu schreiben, möchte ich das automatische Neuladen von Code einrichten.

Nachdem Sie die swap Funktion in einer Skriptdatei (z. B. in Water.js) auskommentiert haben, aktivieren wir das Hot-Reloading. Später werden wir sehen, wie Sie diesen Status verwenden können, auch wenn Sie den Code in Echtzeit aktualisieren. Im Moment möchten wir die Shader jedoch nur erneut anwenden, nachdem wir die Änderungen vorgenommen haben. Vor dem Ausführen in WebGL werden Shader kompiliert. Dazu müssen wir unser Material neu erstellen.

Wir werden prüfen, ob sich der Inhalt unseres Shader-Codes geändert hat, und in diesem Fall das Material erneut erstellen. Speichern Sie zunächst die aktuellen Shader in initialize :

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

Und im Update prüfen wir, ob Änderungen aufgetreten sind:

 //  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; } }; 

Um sicherzustellen, dass dies funktioniert, starten Sie das Spiel und ändern Sie die Farbe des Flugzeugs in Water.frag in ein angenehmeres Blau. Nach dem Speichern der Datei sollte diese auch ohne Neustart und Neustart aktualisiert werden! Hier ist die Farbe, die ich gewählt habe:

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

Vertex Shader


Um Wellen zu erzeugen, müssen wir jeden Scheitelpunkt unseres Netzes in jedem Frame verschieben. Es scheint sehr ineffizient zu sein, aber jeder Scheitelpunkt jedes Modells ist bereits in jedem gerenderten Frame transformiert. Dies ist, was der Vertex-Shader tut.

Wenn wir einen Fragment-Shader als eine Funktion wahrnehmen, die für jedes Pixel ausgeführt wird, seine Position erhält und Farbe zurückgibt, ist ein Vertex-Shader eine Funktion, die für jeden Vertex ausgeführt wird, seine Position erhält und seine Position zurückgibt .

Ein Vertex-Shader erhält standardmäßig eine Position in der Modellwelt und gibt seine Position auf dem Bildschirm zurück . Unsere 3D-Szene wird in x-, y- und z-Koordinaten eingestellt, aber der Monitor ist eine flache zweidimensionale Ebene, sodass wir eine 3D-Welt auf einen 2D-Bildschirm projizieren. Matrizen des Typs, der Projektion und des Modells sind an einer solchen Projektion beteiligt, daher werden wir sie in diesem Tutorial nicht berücksichtigen. Wenn Sie jedoch verstehen möchten, was in jeder Phase genau passiert, finden Sie hier eine sehr gute Anleitung .

Das heißt, diese Zeile:

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

empfängt aPosition als Position in der 3D-Welt eines bestimmten Scheitelpunkts und konvertiert sie in gl_Position , gl_Position in die endgültige Position auf dem 2D-Bildschirm. Das Präfix "a" in aPosition gibt an, dass dieser Wert ein Attribut ist . Vergessen Sie nicht, dass die Variable uniform ein Wert ist, den wir in der CPU definieren und an den Shader übergeben können. Der Wert bleibt für alle Pixel / Eckpunkte gleich. Andererseits wird der Attributwert aus dem angegebenen CPU- Array erhalten . Für jeden Wert dieses Attributarrays wird ein Vertex-Shader aufgerufen.

Sie können sehen, dass diese Attribute in der Shader-Definition konfiguriert sind, die wir in Water.js festgelegt haben:

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

PlayCanvas kümmert sich um das Einrichten und Übertragen eines Arrays von Scheitelpunktpositionen für aPosition wenn diese Aufzählung übergeben wird. Im allgemeinen Fall können wir jedoch jedes Datenarray an den Scheitelpunkt-Shader übergeben.

Scheitelpunktbewegung


Angenommen, wir möchten die gesamte Ebene komprimieren, indem wir alle x Werte mit 0,5 multiplizieren. Müssen wir aPosition oder gl_Position ?

Versuchen aPosition zuerst aPosition . Wir können das Attribut nicht direkt ändern, aber wir können eine Kopie erstellen:

 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); } 

Jetzt sollte die Ebene eher wie ein Rechteck aussehen. Und daran ist nichts Seltsames. Aber was passiert, wenn wir gl_Position versuchen, gl_Position zu ändern?

 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; } 

Bis Sie die Kamera bewegen, sieht sie möglicherweise gleich aus. Wir ändern die Koordinaten des Bildschirmbereichs, dh das Bild hängt davon ab, wie wir es betrachten .

So können wir die Eckpunkte verschieben, und gleichzeitig ist es wichtig, zwischen Arbeit in der Welt und Bildschirmräumen zu unterscheiden.

Aufgabe 2: Können Sie die gesamte Oberfläche der Ebene im Vertex-Shader um mehrere Einheiten (entlang der Y-Achse) nach oben verschieben, ohne die Form zu verzerren?

Aufgabe 3: Ich sagte, dass gl_Position zweidimensional ist, aber gl_Position.z existiert auch. Können Sie überprüfen, ob sich dieser Wert auf etwas auswirkt, und wenn ja, wofür wird er verwendet?

Zeit hinzufügen


Das Letzte, was wir brauchen, um bewegte Wellen zu erzeugen, ist eine einheitliche Variable, die als Zeit verwendet werden kann. Deklarieren Sie die Uniform im Vertex-Shader:

 uniform float uTime; 

Um es nun an den Shader zu übergeben, kehren wir zu Water.js zurück und definieren die Zeitvariable in initialize:

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

Um die Variable auf den Shader zu übertragen, verwenden wir jetzt material.setParameter . Zuerst setzen wir den Anfangswert am Ende der CreateWaterMaterial Funktion:

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

Jetzt können wir in der update einen Zeitschritt ausführen und über den dafür erstellten Link auf das Material zugreifen:

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

Schließlich kopieren wir in der Swap-Funktion den alten Zeitwert so, dass er auch nach dem Ändern des Codes weiter zunimmt, ohne auf 0 zurückgesetzt zu werden.

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

Jetzt ist alles fertig. Führen Sie das Spiel aus, um sicherzustellen, dass keine Fehler vorliegen. Bewegen wir nun unser Flugzeug mit der Zeitfunktion in Water.vert :

 pos.y += cos(uTime) 

Und unser Flugzeug sollte sich auf und ab bewegen! Da wir jetzt eine Swap-Funktion haben, können wir Water.js auch aktualisieren, ohne neu starten zu müssen. Versuchen Sie, das Zeitinkrement zu ändern, um sicherzustellen, dass dies funktioniert.


Aufgabe 4: Können Sie die Eckpunkte so verschieben, dass sie wie die Wellen in der folgenden Abbildung aussehen?


Lassen Sie mich Ihnen sagen, dass ich das Thema der verschiedenen Arten der Wellenerzeugung hier im Detail untersucht habe . Der Artikel bezieht sich auf 2D, aber mathematische Berechnungen sind auf unseren Fall anwendbar. Wenn Sie nur die Lösung sehen möchten, dann ist hier das Wesentliche .

Transluzenz


Der Zweck dieses Abschnitts besteht darin, eine durchscheinende Wasseroberfläche zu schaffen.

Möglicherweise stellen Sie fest, dass die an Water.frag zurückgegebene Farbe einen Alpha-Kanalwert von 0,5 hat, die Oberfläche jedoch weiterhin undurchsichtig bleibt. In vielen Fällen wird Transparenz in der Computergrafik immer noch zu einem ungelösten Problem. Eine kostengünstige Möglichkeit, dies zu lösen, ist das Mischen.

Normalerweise überprüft es vor dem Zeichnen eines Pixels den Wert im Tiefenpuffer und vergleicht ihn mit seinem eigenen Tiefenwert (seiner Position entlang der Z-Achse), um festzustellen, ob das aktuelle Bildschirmpixel neu gezeichnet werden soll oder nicht. Auf diese Weise können Sie die Szene korrekt rendern, ohne Objekte von hinten nach vorne sortieren zu müssen.

Beim Mischen können wir, anstatt das Pixel einfach abzulehnen oder zu überschreiben, die Farbe des bereits gerenderten Pixels (Ziels) mit dem Pixel kombinieren, das wir zeichnen möchten (die Quelle). Eine Liste aller in WebGL verfügbaren Mischfunktionen finden Sie hier .

Damit der Alpha-Kanal unseren Erwartungen entspricht, möchten wir, dass die kombinierte Farbe des Ergebnisses eine Quelle multipliziert mit einem Alpha-Kanal plus einem Zielpixel multipliziert mit einem minus Alpha ist. Mit anderen Worten, wenn Alpha = 0,4 ist, sollte die endgültige Farbe einen Wert haben:

 finalColor = source * 0.4 + destination * 0.6; 

In PlayCanvas ist dies die Operation, die pc.BLEND_NORMAL ausführt .

Um es zu aktivieren, legen Sie einfach die Materialeigenschaft in CreateWaterMaterial :

 material.blendType = pc.BLEND_NORMAL; 

Wenn Sie jetzt das Spiel starten, wird das Wasser durchscheinend! Es ist jedoch immer noch unvollkommen. Das Problem tritt auf, wenn die durchscheinende Oberfläche sich selbst überlagert, wie unten gezeigt.


Wir können es beseitigen, indem wir Alpha to Coverage verwenden , eine Multisampling-Technik für Transparenz, anstatt zu mischen:

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

Es ist jedoch nur in WebGL 2 verfügbar. Im weiteren Verlauf des Tutorials werde ich der Einfachheit halber das Mischen verwenden.

Zusammenfassend


Wir haben die Umgebung eingerichtet und eine durchscheinende Wasseroberfläche mit animierten Wellen vom Vertex-Shader erstellt. Im zweiten Teil des Tutorials werden wir den Auftrieb von Objekten betrachten, Linien zur Wasseroberfläche hinzufügen und Schaumlinien entlang der Grenzen von Objekten erstellen, die sich mit der Oberfläche schneiden.

Im dritten (letzten) Teil werden wir die Anwendung des Nachbearbeitungseffekts von Unterwasserverzerrungen betrachten und Ideen für weitere Verbesserungen prüfen.

Quellcode


Das fertige PlayCanvas-Projekt finden Sie hier . Unser Repository hat auch einen Projektport unter Three.js .

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


All Articles