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:
- Ein durchscheinendes Wassernetz mit unterteilten Polygonen und versetzten Eckpunkten, um Wellen zu erzeugen.
- Statische Wasserleitungen an der Oberfläche.
- Simulierter Auftrieb des Bootes.
- Dynamische Schaumlinien um die Grenzen von Objekten im Wasser.
- 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.
- Ziehen Sie beide Dateien in das Assets-Fenster des PlayCanvas-Projekts.
- 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.
- Kopieren Sie den Inhalt von
mouse-input.js
und orbit-camera.js
aus diesem Lernprogramm in gleichnamige Dateien aus Ihrem Projekt. - Fügen Sie der Kamera eine Skriptkomponente hinzu .
- 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){
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(){
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 :
Und im
Update prüfen wir, ob Änderungen aufgetreten sind:
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;
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;
Um die Variable auf den Shader zu übertragen, verwenden wir jetzt
material.setParameter
. Zuerst setzen wir den Anfangswert am Ende der
CreateWaterMaterial
Funktion:
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:
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 .