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

Im ersten Teil haben wir uns mit der Einrichtung der Umgebung und der Wasseroberfläche befasst. In diesem Teil geben wir Objekten Auftrieb, fügen Wasserlinien auf der Oberfläche hinzu und erstellen Schaumlinien mit einem Tiefenpuffer um die Grenzen von Objekten, die sich mit der Oberfläche schneiden.

Um die Szene ein wenig besser aussehen zu lassen, habe ich kleinere Änderungen daran vorgenommen. Sie können Ihre Szene nach Ihren Wünschen anpassen, aber ich habe Folgendes getan:

  • Modelle eines Leuchtturms und eines Oktopus hinzugefügt.
  • Ein Grundmodell mit der Farbe #FFA457 .
  • #6CC8FF eine Himmelsfarbe von #6CC8FF .
  • #FFC480 Hintergrundbeleuchtung Farbe zur Szene hinzugefügt (diese Parameter finden Sie in den Szeneneinstellungen).

Meine ursprüngliche Szene sieht jetzt so aus.


Auftrieb


Der einfachste Weg, Auftrieb zu erzeugen, besteht darin, Objekte mithilfe eines Skripts nach oben und unten zu verschieben. Erstellen Sie ein neues Buoyancy.js- Skript und legen Sie bei der Initialisierung Folgendes fest:

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

Jetzt im Update führen wir das Zeitinkrement aus und drehen das Objekt:

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

Wenden Sie dieses Skript auf das Boot an und sehen Sie, wie es im Wasser auf und ab springt! Sie können dieses Skript auf mehrere Objekte anwenden (einschließlich der Kamera - probieren Sie es aus)!

Oberflächentextur


Während wir die Wellen sehen können, schauen wir auf die Ränder der Wasseroberfläche. Durch Hinzufügen von Textur wird die Oberflächenbewegung deutlicher. Darüber hinaus ist es eine kostengünstige Möglichkeit, Reflexionen und Ätzungen zu simulieren.

Sie können versuchen, einige ätzende Texturen zu finden oder selbst eine zu erstellen. Ich habe eine Textur in Gimp gezeichnet, die Sie frei verwenden können. Jede Textur ist geeignet, sofern sie ohne erkennbare Fugen gekachelt werden kann.

Nachdem Sie die gewünschte Textur ausgewählt haben, ziehen Sie sie in das Assets-Fenster Ihres Projekts. Wir müssen diese Textur aus dem Water.js-Skript referenzieren, also erstellen wir ein Attribut dafür:

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

Und dann im Editor zuweisen:


Jetzt müssen wir es an den Shader weitergeben. Gehen Sie zu Water.js und setzen Sie die CreateWaterMaterial Funktion CreateWaterMaterial neuen Parameter:

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

Gehen Sie jetzt zurück zu Water.frag und erklären Sie eine neue Uniform:

 uniform sampler2D uSurfaceTexture; 

Wir sind fast fertig. Um eine Textur in einer Ebene zu rendern, müssen wir wissen, wo sich jedes Pixel im Netz befindet. Das heißt, wir müssen Daten vom Vertex-Shader zum Fragment One übertragen.

Unterschiedliche Variablen


Mit unterschiedlichen Variablen können Sie Daten vom Vertex-Shader zum Fragment-Shader übertragen. Dies ist der dritte Typ von speziellen Variablen, die im Shader verwendet werden können (die ersten beiden sind einheitlich und Attribut ). Für jeden Scheitelpunkt wird eine Variable festgelegt, auf die jedes Pixel zugreifen kann. Da es viel mehr Pixel als Scheitelpunkte gibt, wird der Wert zwischen den Scheitelpunkten interpoliert (daher der Name "variierend" - er weicht von den an ihn übergebenen Werten ab).

Um es im Betrieb zu testen, deklarieren Sie eine neue Variable in Water.vert als variierend:

 varying vec2 ScreenPosition; 

Und weisen Sie gl_Position nach der Berechnung den Wert gl_Position :

 ScreenPosition = gl_Position.xyz; 

Kehren Sie nun zu Water.frag zurück und deklarieren Sie dieselbe Variable. Wir können die Ausgabe von Debugging-Daten nicht vom Shader abrufen, aber wir können Farbe für das visuelle Debugging verwenden. So geht's:

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

Die Ebene sollte jetzt schwarzweiß aussehen, und die ScreenPosition.x verläuft dort, wo ScreenPosition.x = 0 ist. Die Farbwerte ändern sich nur von 0 auf 1, die Werte in ScreenPosition können jedoch außerhalb dieses Bereichs liegen. Sie werden automatisch begrenzt. Wenn Sie also Schwarz sehen, kann dies 0 oder eine negative Zahl sein.

Was wir gerade getan haben, ist, die Bildschirmposition jedes Scheitelpunkts an jedes Pixel zu übergeben. Sie können sehen, dass die Linie zwischen der schwarzen und der weißen Seite immer in der Mitte des Bildschirms verläuft, unabhängig davon, wo sich die Oberfläche tatsächlich auf der Welt befindet.

Aufgabe 1: Erstellen Sie eine neue variierende Variable, um die Position in der Welt anstelle der Bildschirmposition zu übertragen. Visualisieren Sie es auf die gleiche Weise. Wenn sich die Farbe mit der Bewegung der Kamera nicht ändert, ist alles korrekt.

Mit UV


UV sind die 2D-Koordinaten jedes Scheitelpunkts im Netz, normalisiert von 0 bis 1. Sie sind für die korrekte Abtastung der Textur in der Ebene erforderlich, und wir haben sie bereits im vorherigen Teil konfiguriert.

Wir deklarieren ein neues Attribut in Water.vert (dieser Name stammt aus der Definition eines Shaders in Water.js):

 attribute vec2 aUv0; 

Und jetzt müssen wir es nur noch an den Fragment-Shader übergeben, also einfach variieren und ihm den Attributwert zuweisen:

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

Jetzt deklarieren wir dieselbe variierende Variable im Fragment-Shader. Um sicherzustellen, dass alles funktioniert, können wir das Debuggen wie zuvor visualisieren, und dann sieht Water.frag folgendermaßen aus:

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

Sie sollten einen Farbverlauf sehen, der bestätigt, dass wir an einem Ende den Wert 0 und am anderen Ende den Wert 1 haben. Um die Textur wirklich zu testen, müssen wir nur noch Folgendes tun:

 color = texture2D(uSurfaceTexture,vUv0); 

Danach sehen wir die Textur auf der Oberfläche:


Textur-Styling


Anstatt nur die Textur als neue Farbe festzulegen, kombinieren wir sie mit dem vorhandenen Blau:

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

Dies funktioniert, weil die Texturfarbe überall außer Wasserlinien schwarz (0) ist. Wenn Sie es hinzufügen, ändern wir die anfängliche blaue Farbe nicht, mit Ausnahme von Stellen mit Linien, an denen es heller wird.

Dies ist jedoch nicht die einzige Möglichkeit, Farben zu kombinieren.

Aufgabe 2: Können Sie die Farben kombinieren, um den unten gezeigten schwächeren Effekt zu erzielen?


Textur bewegen


Als letzten Effekt möchten wir, dass sich die Linien entlang der Oberfläche bewegen und sie nicht so statisch aussehen. Dazu nutzen wir die Tatsache, dass jeder Wert außerhalb des Intervalls von 0 bis 1, der an die Funktion texture2D wird, übertragen wird (z. B. werden sowohl 1,5 als auch 2,5 gleich 0,5). Daher können wir unsere Position um die einheitliche Zeitvariable erhöhen, die wir bereits festgelegt haben, um die Liniendichte auf der Oberfläche zu erhöhen oder zu verringern, wodurch der endgültige Fragment-Shader diese Form erhält:

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

Schaumlinien und Tiefenpuffer


Durch das Rendern von Schaumstofflinien um Objekte im Wasser ist es viel einfacher zu erkennen, wie eingetaucht die Objekte sind und wo sie die Oberfläche überqueren. Außerdem wird unser Wasser auf diese Weise viel glaubwürdiger. Um die Schaumlinien zu erkennen, müssen wir irgendwie herausfinden, wo sich die Grenzen jedes Objekts befinden, und dies effektiv tun.

Trick


Wir müssen lernen zu bestimmen, ob sich ein Pixel auf der Wasseroberfläche in der Nähe des Objekts befindet. Wenn ja, dann können wir es in der Farbe des Schaums malen. Es gibt keine einfachen Möglichkeiten, dieses Problem zu lösen (soweit ich weiß). Um es zu lösen, verwende ich daher eine nützliche Technik zur Lösung von Problemen: Ich werde ein Beispiel nehmen, für das wir die Antwort kennen und sehen, ob wir sie verallgemeinern können.

Schauen Sie sich das Bild unten an.


Welche Pixel sollten Teil des Schaums sein? Wir wissen, dass es ungefähr so ​​aussehen sollte:


Schauen wir uns also zwei bestimmte Pixel an. Unten habe ich sie mit Sternchen markiert. Schwarz wird auf dem Schaum sein und Rot nicht. Wie unterscheiden wir sie in einem Shader?


Wir wissen, dass diese beiden Pixel im Bildschirmbereich zwar nahe beieinander liegen (beide werden auf dem Leuchtturm gerendert), aber im Weltraum der Welt tatsächlich sehr weit entfernt sind. Wir können dies überprüfen, indem wir dieselbe Szene aus einem anderen Blickwinkel betrachten.


Beachten Sie, dass sich der rote Stern nicht auf dem Leuchtturm befindet, wie es uns schien, sondern der schwarze tatsächlich. Wir können von der Entfernung zur Kamera unterscheiden, die normalerweise als "Tiefe" bezeichnet wird. Tiefe 1 bedeutet, dass der Punkt sehr nahe an der Kamera liegt, Tiefe 0 bedeutet, dass er sehr weit entfernt ist. Dabei geht es aber nicht nur um absolute Entfernungen in der Welt, Tiefe oder Kamera. Die Tiefe relativ zum Pixel dahinter ist wichtig.

Schauen Sie sich noch einmal die erste Ansicht an. Nehmen wir an, der Rumpf des Leuchtturms hat einen Tiefenwert von 0,5. Die Tiefe des schwarzen Sterns liegt sehr nahe bei 0,5. Das heißt, es und das Pixel darunter haben sehr enge Tiefenwerte. Auf der anderen Seite hat das rote Sternchen eine viel größere Tiefe, da es näher an der Kamera liegt, z. B. 0,7. Und obwohl sich das Pixel dahinter noch auf dem Leuchtturm befindet, hat es einen Tiefenwert von 0,5, das heißt, es gibt mehr Unterschiede.

Das ist der Trick. Wenn die Tiefe eines Pixels auf der Wasseroberfläche nahe genug an der Tiefe des Pixels liegt, über das es gezeichnet wird, befinden wir uns ziemlich nahe am Rand eines Objekts und können das Pixel wie Schaum rendern.

Das heißt, wir brauchen mehr Informationen als in jedem Pixel. Wir müssen irgendwie die Tiefe des Pixels herausfinden, über das es gezeichnet werden soll. Und hier ist der Tiefenpuffer für uns nützlich.

Tiefenpuffer


Sie können sich einen Frame-Puffer oder Frame-Buffer als Ziel-Rendering oder Textur außerhalb des Bildschirms vorstellen. Wenn wir Daten lesen müssen, müssen wir außerhalb des Bildschirms rendern. Diese Technik wird beim Raucheffekt eingesetzt .

Der Tiefenpuffer ist ein spezielles Ziel-Rendering, das Informationen zu den Tiefenwerten jedes Pixels enthält. Vergessen Sie nicht, dass der im Vertex-Shader berechnete Wert in gl_Position der Bildschirmraumwert war, aber auch eine dritte Koordinate hat - den Z-Wert. Dieser Z-Wert wird zur Berechnung der Tiefe verwendet, die in den Tiefenpuffer geschrieben wird.

Der Tiefenpuffer dient zum korrekten Rendern der Szene, ohne dass Objekte von hinten nach vorne sortiert werden müssen. Jedes zuerst zu zeichnende Pixel überprüft den Tiefenpuffer. Wenn sein Tiefenwert größer als der Wert im Puffer ist, wird er gezeichnet und sein eigener Wert überschreibt den Wert des Puffers. Andernfalls wird es verworfen (weil es bedeutet, dass sich ein anderes Objekt davor befindet).

Tatsächlich können Sie das Schreiben in den Tiefenpuffer deaktivieren, um zu sehen, wie alles ohne ihn aussehen würde. Versuchen wir es in Water.js:

 material.depthTest = false; 

Sie werden feststellen, dass Wasser jetzt immer von oben gezogen wird, auch wenn es sich hinter undurchsichtigen Objekten befindet.

Tiefenpuffer-Visualisierung


Fügen wir eine Möglichkeit hinzu, den Tiefenpuffer für Debugging-Zwecke zu rendern. Erstellen Sie ein neues DepthVisualize.js- Skript. Befestigen Sie es an der Kamera.

Um auf den Tiefenpuffer in PlayCanvas zuzugreifen, schreiben Sie einfach Folgendes:

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

Daher fügen wir die einheitliche Variable automatisch in alle unsere Shader ein, die wir verwenden können, indem wir sie wie folgt deklarieren:

 uniform sampler2D uDepthMap; 

Unten finden Sie ein Beispielskript, das eine Tiefenkarte anfordert und über einer Szene rendert. Er hat einen Hot-Neustart konfiguriert.

 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/ 

Versuchen Sie, den Code zu kopieren und die Zeile this.app.scene.drawCalls.push(this.command); kommentieren / zu kommentieren this.app.scene.drawCalls.push(this.command); zum Aktivieren / Deaktivieren des Tiefen-Renderings. Dies sollte wie im Bild unten aussehen.


Aufgabe 3: Die Wasseroberfläche wird nicht in den Tiefenpuffer gezogen. Die PlayCanvas-Engine tut dies absichtlich. Können Sie herausfinden warum? Was ist das Besondere am Wassermaterial? Mit anderen Worten, was würde angesichts unserer Regeln zur Überprüfung der Tiefe passieren, wenn Pixel Wasser in den Tiefenpuffer geschrieben würden?

Hinweis: Sie können eine Zeile in Water.js ändern, wodurch Sie Wasser in den Tiefenpuffer schreiben können.

Es sollte auch beachtet werden, dass ich in der Initialisierungsfunktion den Tiefenwert mit 30 multipliziere. Dies ist notwendig, um ihn klar zu sehen, da sonst der Wertebereich zu klein wäre, um Farbtöne anzuzeigen.

Trick-Implementierung


In der PlayCanvas-Engine gibt es mehrere Zusatzfunktionen für die Arbeit mit Tiefenwerten. Zum Zeitpunkt des Schreibens waren sie jedoch noch nicht in der Produktion freigegeben, sodass wir sie selbst konfigurieren müssen.

Wir definieren die folgenden einheitlichen Variablen in Water.frag :

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

Wir definieren diese Hilfsfunktionen über die Hauptfunktion:

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

Wir werden die Shader-Informationen über die Kamera in Water.js weitergeben . Fügen Sie dies dort ein, wo Sie die anderen einheitlichen Variablen wie uTime übergeben:

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

Schließlich benötigen wir für unseren Fragment-Shader eine Position in der Welt jedes Pixels. Wir müssen es vom Vertex-Shader bekommen. Daher definieren wir in Water.frag eine variierende Variable:

 varying vec3 WorldPosition; 

Definieren Sie dieselbe variierende Variable in Water.vert . Dann weisen wir ihm eine verzerrte Position vom Vertex-Shader zu, sodass der vollständige Code folgendermaßen aussieht:

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

Wir erkennen den Trick wirklich


Jetzt sind wir endlich bereit, die am Anfang dieses Abschnitts beschriebene Technik zu implementieren. Wir wollen die Tiefe des Pixels, in dem wir uns befinden, mit der Tiefe des Pixels darunter vergleichen. Das Pixel, in dem wir uns befinden, wird von einer Position in der Welt genommen, und das Pixel darunter wird von der Bildschirmposition erhalten. Deshalb nehmen wir diese beiden Tiefen:

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

Aufgabe 4: Einer dieser Werte wird niemals größer als der andere sein (vorausgesetzt, depthTest = true). Können Sie feststellen, welche?

Wir wissen, dass Schaum dort ist, wo der Abstand zwischen den beiden Werten gering ist. Lassen Sie uns diesen Unterschied daher für jedes Pixel rendern. Fügen Sie dies am Ende des Shaders ein (und deaktivieren Sie das Tiefenvisualisierungsskript aus dem vorherigen Abschnitt):

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

Und es sollte ungefähr so ​​aussehen:


Das heißt, wir wählen die Grenzen jedes in Wasser getauchten Objekts in Echtzeit richtig aus! Natürlich können Sie den Unterschied skalieren, um den Schaum dicker oder weniger häufig zu machen.

Wir haben jetzt viele Möglichkeiten, diese Ausgabe mit der Wasseroberfläche zu kombinieren, um schöne Schaumlinien zu erzeugen. Sie können sie mit einem Farbverlauf belassen, zum Abtasten aus einer anderen Textur verwenden oder ihnen eine bestimmte Farbe zuweisen, wenn der Unterschied kleiner oder gleich einem bestimmten Grenzwert ist.

Was mir am besten gefallen hat, war die Zuweisung einer Farbe, die den Linien des statischen Wassers ähnelt, sodass meine fertige Hauptfunktion folgendermaßen aussieht:

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

Zusammenfassend


Wir haben den Auftrieb von in Wasser getauchten Objekten erzeugt, eine sich bewegende Textur auf die Oberfläche aufgebracht, um Ätzmittel zu simulieren, und gelernt, wie man mit dem Tiefenpuffer dynamische Schaumstreifen erzeugt.

Im dritten und letzten Teil werden wir die Effekte der Nachbearbeitung hinzufügen und lernen, wie man sie verwendet, um den Effekt der Unterwasserverzerrung zu erzeugen.

Quellcode


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

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


All Articles