Advertisement
  1. Game Development
  2. Shaders

Toon-Wasser für das Web erstellen: Teil 2

Scroll to top
Read Time: 17 min

() translation by (you can also view the original English article)

Willkommen zurück zu dieser dreiteiligen Serie zum Erstellen von stilisiertem Toon-Wasser in PlayCanvas mithilfe von Vertex-Shadern. In Teil 1 haben wir uns mit der Einrichtung unserer Umwelt und der Wasseroberfläche befasst. In diesem Teil wird das Aufbringen von Auftrieb auf Objekte, das Hinzufügen von Wasserlinien zur Oberfläche und das Erstellen der Schaumlinien mit dem Tiefenpuffer um die Kanten von Objekten, die die Oberfläche schneiden, behandelt.

Ich habe einige kleine Änderungen an meiner Szene vorgenommen, damit sie ein bisschen schöner aussieht. Sie können Ihre Szene nach Belieben anpassen, aber ich habe Folgendes getan:

  • Die Modelle Leuchtturm und Tintenfisch wurden hinzugefügt.
  • Eine Grundebene mit der Farbe #FFA457 wurde hinzugefügt.
  • Es wurde eine klare Farbe für die Kamera von #6CC8FF hinzugefügt.
  • Der Szene von #FFC480 wurde eine Umgebungsfarbe hinzugefügt (diese finden Sie in den Szeneneinstellungen).

Unten sehen Sie, wie mein Ausgangspunkt jetzt aussieht.

The scene now includes an octopus and a ligthouseThe scene now includes an octopus and a ligthouseThe scene now includes an octopus and a ligthouse

Auftrieb

Der einfachste Weg, Auftrieb zu erzeugen, besteht darin, ein Skript zu erstellen, das Objekte nach oben und unten schiebt. Erstellen Sie ein neues Skript mit dem Namen Buoyancy.js und setzen Sie die Initialisierung auf:

1
Buoyancy.prototype.initialize = function() {
2
    this.initialPosition = this.entity.getPosition().clone();
3
    this.initialRotation = this.entity.getEulerAngles().clone();
4
    // The initial time is set to a random value so that if

5
    // this script is attached to multiple objects they won't 

6
    // all move the same way

7
    this.time = Math.random() * 2 * Math.PI;
8
};

Jetzt erhöhen wir im Update die Zeit und drehen das Objekt:

1
Buoyancy.prototype.update = function(dt) {
2
    this.time += 0.1;
3
    
4
    // Move the object up and down 

5
    var pos = this.entity.getPosition().clone();
6
    pos.y = this.initialPosition.y + Math.cos(this.time) * 0.07;
7
    this.entity.setPosition(pos.x,pos.y,pos.z);
8
    
9
    // Rotate the object slightly 

10
    var rot = this.entity.getEulerAngles().clone();
11
    rot.x = this.initialRotation.x + Math.cos(this.time * 0.25) * 1;
12
    rot.z = this.initialRotation.z + Math.sin(this.time * 0.5) * 2;
13
    this.entity.setLocalEulerAngles(rot.x,rot.y,rot.z);
14
};

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

Texturierung der Oberfläche

Im Moment können Sie die Wellen nur sehen, indem Sie auf die Ränder der Wasseroberfläche schauen. Das Hinzufügen einer Textur hilft dabei, Bewegungen auf der Oberfläche besser sichtbar zu machen, und ist eine kostengünstige Möglichkeit, Reflexionen und Ätzungen zu simulieren.

Sie können versuchen, eine ätzende Textur zu finden oder Ihre eigene zu erstellen. Hier ist eine, die ich in Gimp gezeichnet habe und die Sie frei verwenden können. Jede Textur funktioniert so lange, wie sie nahtlos gekachelt werden kann.

Wenn Sie eine gewünschte Textur gefunden haben, ziehen Sie sie in das Asset-Fenster Ihres Projekts. Wir müssen diese Textur in unserem Water.js-Skript referenzieren, also erstellen Sie ein Attribut dafür:

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

Und dann im Editor zuweisen:

The water texture is added to the water scriptThe water texture is added to the water scriptThe water texture is added to the water script

Jetzt müssen wir es an unseren Shader weitergeben. Gehen Sie zu Water.js und legen Sie einen neuen Parameter in der Funktion CreateWaterMaterial fest:

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

Gehen Sie jetzt zu Water.frag und erklären Sie unsere neue Uniform:

1
uniform sampler2D uSurfaceTexture;

Wir sind fast da. Um die Textur auf der Ebene zu rendern, müssen wir wissen, wo sich jedes Pixel entlang des Netzes befindet. Das heißt, wir müssen einige Daten vom Vertex-Shader an den Fragment-Shader übergeben.

Unterschiedliche Variablen

Mit einer variierenden Variablen können Sie Daten vom Vertex-Shader an den Fragment-Shader übergeben. Dies ist der dritte Typ einer speziellen Variablen, die Sie in einem Shader haben können (die anderen beiden sind einheitlich und Attribut). Sie ist für jeden Scheitelpunkt definiert und für jedes Pixel zugänglich. Da es viel mehr Pixel als Scheitelpunkte gibt, wird der Wert zwischen Scheitelpunkten interpoliert (daher kommt der Name "varying" - er weicht von den von Ihnen angegebenen Werten ab).

Um dies auszuprobieren, deklarieren Sie eine neue Variable in Water.vert als variierend:

1
varying vec2 ScreenPosition;

Und setzen Sie es dann auf gl_Position, nachdem es berechnet wurde:

1
ScreenPosition = gl_Position.xyz;

Gehen Sie nun zurück zu Water.frag und deklarieren Sie dieselbe Variable. Es gibt keine Möglichkeit, eine Debug-Ausgabe aus einem Shader heraus zu erhalten, aber wir können Farbe verwenden, um visuell zu debuggen. Hier ist eine Möglichkeit, dies zu tun:

1
uniform sampler2D uSurfaceTexture;
2
varying vec3 ScreenPosition;
3
4
void main(void)
5
{
6
    vec4 color = vec4(0.0,0.7,1.0,0.5);
7
    
8
    // Testing out our new varying variable

9
    color = vec4(vec3(ScreenPosition.x),1.0);
10
    
11
    gl_FragColor = color;
12
}

Die Ebene sollte jetzt schwarz und weiß aussehen, wobei die Trennlinie ScreenPosition.x 0 ist. Die Farbwerte reichen nur von 0 bis 1, aber die Werte in ScreenPosition können außerhalb dieses Bereichs liegen. Sie werden automatisch geklemmt. Wenn Sie also Schwarz sehen, kann dies 0 oder negativ 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 liegt, unabhängig davon, wo sich die Oberfläche tatsächlich auf der Welt befindet.

Herausforderung #1: Erstellen Sie eine neue variierende Variable, um die Weltposition anstelle der Bildschirmposition zu übergeben. Visualisieren Sie es auf die gleiche Weise wie oben. Wenn sich die Farbe mit der Kamera nicht ändert, haben Sie dies richtig gemacht.

UVs verwenden

Die UVs sind die 2D-Koordinaten für jeden Scheitelpunkt entlang des Netzes, normalisiert von 0 bis 1. Dies ist genau das, was wir benötigen, um die Textur korrekt auf der Ebene abzutasten, und sie sollte bereits im vorherigen Teil eingerichtet sein.

Deklarieren Sie ein neues Attribut in Water.vert (dieser Name stammt aus der Shader-Definition in Water.js):

1
attribute vec2 aUv0;

Und alles, was wir tun müssen, ist es an den Fragment-Shader zu übergeben. Erstellen Sie also einfach eine Variation und setzen Sie sie auf das Attribut:

1
// In Water.vert

2
// We declare this along with our other variables at the top

3
varying vec2 vUv0;
4
5
// ..

6
// Down in the main function, we store the value of the attribute 

7
// in the varying so that the frag shader can access it 

8
vUv0 = aUv0;

Jetzt erklären wir das gleiche Variieren im Fragment-Shader. Um zu überprüfen, ob es funktioniert, können wir es wie zuvor visualisieren, sodass Water.frag jetzt wie folgt aussieht:

1
uniform sampler2D uSurfaceTexture;
2
varying vec2 vUv0;
3
4
void main(void)
5
{
6
    vec4 color = vec4(0.0,0.7,1.0,0.5);
7
    
8
    // Confirming UV's 

9
    color = vec4(vec3(vUv0.x),1.0);
10
    
11
    gl_FragColor = color;
12
}

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

1
color = texture2D(uSurfaceTexture,vUv0);

Und Sie sollten die Textur auf der Oberfläche sehen:

Caustics texture is applied to the water surfaceCaustics texture is applied to the water surfaceCaustics texture is applied to the water surface

Stilisierung der Textur

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

1
uniform sampler2D uSurfaceTexture;
2
varying vec2 vUv0;
3
4
void main(void)
5
{
6
    vec4 color = vec4(0.0,0.7,1.0,0.5);
7
    
8
    vec4 WaterLines = texture2D(uSurfaceTexture,vUv0);
9
    color.rgba += WaterLines.r;
10
    
11
    gl_FragColor = color;
12
}

Dies funktioniert, weil die Farbe der Textur mit Ausnahme der Wasserlinien überall schwarz (0) ist. Durch Hinzufügen wird die ursprüngliche blaue Farbe nur an den Stellen geändert, an denen Linien vorhanden sind und an denen sie heller wird.

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

Herausforderung #2: Können Sie die Farben so kombinieren, dass der unten gezeigte subtilere Effekt erzielt wird?
Water lines applied to the surface with a more subtle colorWater lines applied to the surface with a more subtle colorWater lines applied to the surface with a more subtle color

Verschieben der Textur

Als letzten Effekt möchten wir, dass sich die Linien entlang der Oberfläche bewegen, damit sie nicht so statisch aussehen. Zu diesem Zweck verwenden wir die Tatsache, dass jeder Wert, der der texture2D-Funktion außerhalb des Bereichs von 0 bis 1 zugewiesen wird, umbrochen wird (sodass 1,5 und 2,5 beide zu 0,5 werden). So können wir unsere Position um die bereits festgelegte zeitgleiche Variable erhöhen und die Position multiplizieren, um die Dichte der Linien in unserer Oberfläche entweder zu erhöhen oder zu verringern, sodass unser endgültiger Frag-Shader folgendermaßen aussieht:

1
uniform sampler2D uSurfaceTexture;
2
uniform float uTime;
3
varying vec2 vUv0;
4
5
void main(void)
6
{
7
    vec4 color = vec4(0.0,0.7,1.0,0.5);
8
    
9
    vec2 pos = vUv0;
10
    // Multiplying by a number greater than 1 causes the 

11
    // texture to repeat more often 

12
    pos *= 2.0;
13
    // Displacing the whole texture so it moves along the surface

14
    pos.y += uTime * 0.02;
15
    
16
    vec4 WaterLines = texture2D(uSurfaceTexture,pos);
17
    color.rgba += WaterLines.r;
18
    
19
    gl_FragColor = color;
20
}

Schaumlinien und der Tiefenpuffer

Durch das Rendern von Schaumstofflinien um Objekte im Wasser ist es viel einfacher zu erkennen, wie Objekte eingetaucht sind und wo sie die Oberfläche schneiden. Es lässt unser Wasser auch viel glaubwürdiger aussehen. Dazu müssen wir irgendwie herausfinden, wo sich die Kanten auf jedem Objekt befinden, und dies effizient tun.

Der Trick

Was wir wollen, ist, anhand eines Pixels auf der Wasseroberfläche erkennen zu können, ob es sich in der Nähe eines Objekts befindet. Wenn ja, können wir es als Schaum färben. Es gibt keinen einfachen Weg, dies zu tun (von dem ich weiß). Um dies herauszufinden, verwenden wir eine hilfreiche Technik zur Problemlösung: Überlegen Sie sich ein Beispiel, auf das wir die Antwort kennen, und prüfen Sie, ob wir es verallgemeinern können.

Betrachten Sie die Ansicht unten.

Lighthouse in waterLighthouse in waterLighthouse in water

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

Lighthouse in water with foam Lighthouse in water with foam Lighthouse in water with foam

Denken wir also an zwei bestimmte Pixel. Ich habe zwei unten mit Sternen markiert. Der Schwarze ist im Schaum. Der rote ist nicht. Wie können wir sie in einem Shader unterscheiden?

Lighthouse in water with two marked pixelsLighthouse in water with two marked pixelsLighthouse in water with two marked pixels

Was wir wissen ist, dass diese beiden Pixel, obwohl sie im Bildschirmbereich nahe beieinander liegen (beide werden direkt über dem Leuchtturmkörper gerendert), im Weltraum tatsächlich weit voneinander entfernt sind. Wir können dies überprüfen, indem wir dieselbe Szene aus einem anderen Blickwinkel betrachten, wie unten gezeigt.

Viewing the lighthouse from aboveViewing the lighthouse from aboveViewing the lighthouse from above

Beachten Sie, dass der rote Stern nicht so auf dem Leuchtturmkörper liegt, wie er erschien, sondern der schwarze Stern. Wir können sie anhand des Abstands zur Kamera unterscheiden, der üblicherweise als "Tiefe" bezeichnet wird. Eine Tiefe von 1 bedeutet, dass sie sehr nahe an der Kamera liegt, und eine Tiefe von 0 bedeutet, dass sie sehr weit entfernt ist. Es geht aber nicht nur um die absolute Entfernung oder Tiefe der Welt zur Kamera. Es ist die Tiefe im Vergleich zum Pixel dahinter.

Schauen Sie zurück auf die erste Ansicht. Angenommen, der Leuchtturmkörper hat einen Tiefenwert von 0,5. Die Tiefe des schwarzen Sterns würde sehr nahe bei 0,5 liegen. Es und das Pixel dahinter haben also ähnliche Tiefenwerte. Der rote Stern hingegen hätte eine viel größere Tiefe, da er näher an der Kamera wäre, beispielsweise 0,7. Und doch hat das Pixel dahinter, das sich noch auf dem Leuchtturm befindet, einen Tiefenwert von 0,5, sodass es dort einen größeren Unterschied gibt.

Das ist der Trick. Wenn die Tiefe des Pixels auf der Wasseroberfläche nahe genug an der Tiefe des Pixels liegt, auf dem es gezeichnet ist, sind wir ziemlich nahe am Rand von etwas und können es als Schaum rendern.

Wir brauchen also mehr Informationen, als in einem bestimmten Pixel verfügbar sind. Wir müssen irgendwie die Tiefe des Pixels kennen, auf das gezeichnet werden soll. Hier kommt der Tiefenpuffer ins Spiel.

Der Tiefenpuffer

Sie können sich einen Puffer oder einen Framebuffer als ein Rendering-Ziel außerhalb des Bildschirms oder eine Textur vorstellen. Sie möchten außerhalb des Bildschirms rendern, wenn Sie versuchen, Daten zurückzulesen, eine Technik, die dieser Raucheffekt verwendet.

Der Tiefenpuffer ist ein spezielles Renderziel, das Informationen zu den Tiefenwerten an jedem Pixel enthält. Denken Sie daran, dass der im Vertex-Shader berechnete Wert in gl_Position ein Wert für den Bildschirmbereich war, aber auch eine dritte Koordinate, einen Z-Wert. Dieser Z-Wert wird verwendet, um die Tiefe zu berechnen, die in den Tiefenpuffer geschrieben wird.

Der Tiefenpuffer dient dazu, unsere Szene korrekt zu zeichnen, ohne dass Objekte von hinten nach vorne sortiert werden müssen. Jedes Pixel, das zuerst gezeichnet werden soll, konsultiert den Tiefenpuffer. Wenn sein Tiefenwert größer als der Wert im Puffer ist, wird er gezeichnet und sein eigener Wert überschreibt den Wert im Puffer. Andernfalls wird es verworfen (weil es bedeutet, dass sich ein anderes Objekt davor befindet).

Sie können das Schreiben in den Tiefenpuffer deaktivieren, um zu sehen, wie die Dinge ohne ihn aussehen würden. Sie können dies in Water.js versuchen:

1
material.depthTest = false;

Sie werden sehen, wie das Wasser immer oben wiedergegeben wird, auch wenn es sich hinter undurchsichtigen Objekten befindet.

Visualisierung des Tiefenpuffers

Fügen wir eine Möglichkeit hinzu, den Tiefenpuffer für Debugging-Zwecke zu visualisieren. Erstellen Sie ein neues Skript mit dem Namen DepthVisualize.js. Befestigen Sie dies an Ihrer Kamera.

Alles, was wir tun müssen, um Zugriff auf den Tiefenpuffer in PlayCanvas zu erhalten, ist zu sagen:

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

Dadurch wird automatisch eine Uniform in alle unsere Shader eingefügt, die wir verwenden können, indem wir Folgendes deklarieren:

1
uniform sampler2D uDepthMap;

Unten finden Sie ein Beispielskript, das die Tiefenkarte anfordert und über unserer Szene rendert. Es ist für das Hot-Reloading eingerichtet.

1
var DepthVisualize = pc.createScript('depthVisualize');
2
3
// initialize code called once per entity

4
DepthVisualize.prototype.initialize = function() {
5
    this.entity.camera.camera.requestDepthMap();
6
    this.antiCacheCount = 0; // To prevent the engine from caching our shader so we can live-update it 

7
    
8
    this.SetupDepthViz();
9
};
10
11
DepthVisualize.prototype.SetupDepthViz = function(){
12
    var device = this.app.graphicsDevice;
13
    var chunks = pc.shaderChunks;
14
    
15
    this.fs = '';
16
    this.fs += 'varying vec2 vUv0;';
17
    this.fs += 'uniform sampler2D uDepthMap;';
18
    this.fs += '';
19
    this.fs += 'float unpackFloat(vec4 rgbaDepth) {';
20
    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);';
21
    this.fs += '    float depth = dot(rgbaDepth, bitShift);';
22
    this.fs += '    return depth;';
23
    this.fs += '}';
24
    this.fs += '';
25
    this.fs += 'void main(void) {';
26
    this.fs += '    float depth = unpackFloat(texture2D(uDepthMap, vUv0)) * 30.0; ';
27
    this.fs += '    gl_FragColor = vec4(vec3(depth),1.0);';
28
    this.fs += '}';
29
        
30
    this.shader = chunks.createShaderFromCode(device, chunks.fullscreenQuadVS, this.fs, "renderDepth" + this.antiCacheCount);
31
    this.antiCacheCount ++;
32
    
33
    // We manually create a draw call to render the depth map on top of everything 

34
    this.command = new pc.Command(pc.LAYER_FX, pc.BLEND_NONE, function () {
35
       pc.drawQuadWithShader(device, null, this.shader);
36
    }.bind(this));
37
    this.command.isDepthViz = true; // Just mark it so we can remove it later

38
39
    this.app.scene.drawCalls.push(this.command);
40
};
41
42
// update code called every frame

43
DepthVisualize.prototype.update = function(dt) {
44
    
45
};
46
47
// swap method called for script hot-reloading

48
// inherit your script state here

49
DepthVisualize.prototype.swap = function(old) { 
50
    this.antiCacheCount = old.antiCacheCount;
51
    
52
    // Remove the depth viz draw call 

53
    for(var i=0;i<this.app.scene.drawCalls.length;i++){
54
        if(this.app.scene.drawCalls[i].isDepthViz){
55
            this.app.scene.drawCalls.splice(i,1);
56
            break;
57
        }
58
    } 
59
    // Recreate it 

60
    this.SetupDepthViz();
61
};
62
63
// to learn more about script anatomy, please read:

64
// https://developer.playcanvas.com/en/user-manual/scripting/

Versuchen Sie, dies in zu kopieren, und kommentieren / kommentieren Sie die Zeile this.app.scene.drawCalls.push(this.command); um das Tiefen-Rendering umzuschalten. Es sollte ungefähr so aussehen wie das Bild unten.

Boat and lighthouse scene rendered as a depth mapBoat and lighthouse scene rendered as a depth mapBoat and lighthouse scene rendered as a depth map
Herausforderung #3: Die Wasseroberfläche wird nicht in den Tiefenpuffer gezogen. Die PlayCanvas-Engine macht dies absichtlich. Können Sie herausfinden warum? Was ist das Besondere am Wassermaterial? Anders ausgedrückt, was würde passieren, wenn die Wasserpixel auf der Grundlage unserer Tiefenprüfungsregeln in den Tiefenpuffer schreiben würden?

Hinweis: In Water.js können Sie eine Zeile ändern, die dazu führt, dass das Wasser in den Tiefenpuffer geschrieben wird.

Eine andere bemerkenswerte Sache ist, dass ich den Tiefenwert im eingebetteten Shader in der Initialisierungsfunktion mit 30 multipliziere. Dies dient nur dazu, es klar zu sehen, da sonst der Wertebereich zu klein ist, um als Farbtöne angezeigt zu werden.

Den Trick umsetzen

Die PlayCanvas-Engine enthält eine Reihe von Hilfsfunktionen für die Arbeit mit Tiefenwerten. Zum Zeitpunkt des Schreibens sind sie jedoch noch nicht für die Produktion freigegeben. Daher werden wir diese nur selbst einrichten.

Definieren Sie die folgenden Uniformen für Water.frag:

1
// These uniforms are all injected automatically by PlayCanvas

2
uniform sampler2D uDepthMap;
3
uniform vec4 uScreenSize;
4
uniform mat4 matrix_view;
5
// We have to set this one up ourselves

6
uniform vec4 camera_params;

Definieren Sie diese Hilfsfunktionen über der Hauptfunktion:

1
#ifdef GL2

2
    float linearizeDepth(float z) {
3
        z = z * 2.0 - 1.0;
4
        return 1.0 / (camera_params.z * z + camera_params.w);
5
    }
6
#else

7
    #ifndef UNPACKFLOAT

8
    #define UNPACKFLOAT

9
    float unpackFloat(vec4 rgbaDepth) {
10
        const vec4 bitShift = vec4(1.0 / (256.0 * 256.0 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / 256.0, 1.0);
11
        return dot(rgbaDepth, bitShift);
12
    }
13
    #endif

14
#endif

15
16
float getLinearScreenDepth(vec2 uv) {
17
    #ifdef GL2

18
        return linearizeDepth(texture2D(uDepthMap, uv).r) * camera_params.y;
19
    #else

20
        return unpackFloat(texture2D(uDepthMap, uv)) * camera_params.y;
21
    #endif

22
}
23
24
float getLinearDepth(vec3 pos) {
25
    return -(matrix_view * vec4(pos, 1.0)).z;
26
}
27
28
float getLinearScreenDepth() {
29
    vec2 uv = gl_FragCoord.xy * uScreenSize.zw;
30
    return getLinearScreenDepth(uv);
31
}

Übergeben Sie einige Informationen über die Kamera an den Shader in Water.js. Platzieren Sie dies dort, wo Sie an anderen Uniformen wie uTime vorbeikommen:

1
if(!this.camera){
2
    this.camera = this.app.root.findByName("Camera").camera;
3
}
4
var camera = this.camera; 
5
var n = camera.nearClip;
6
var f = camera.farClip;
7
var camera_params = [
8
    1/f,
9
    f,
10
    (1-f / n) / 2,
11
    (1 + f / n) / 2
12
];
13
        
14
material.setParameter('camera_params', camera_params);

Schließlich benötigen wir die Weltposition für jedes Pixel in unserem Frag-Shader. Wir müssen dies vom Vertex-Shader erhalten. Definieren Sie also eine Variation in Water.frag:

1
varying vec3 WorldPosition;

Definieren Sie die gleiche Variation in Water.vert. Stellen Sie es dann auf die verzerrte Position im Vertex-Shader ein, damit der vollständige Code wie folgt aussieht:

1
attribute vec3 aPosition;
2
attribute vec2 aUv0;
3
4
varying vec2 vUv0;
5
varying vec3 WorldPosition;
6
7
uniform mat4 matrix_model;
8
uniform mat4 matrix_viewProjection;
9
10
uniform float uTime;
11
12
void main(void)
13
{
14
    vUv0 = aUv0;   
15
    vec3 pos = aPosition;
16
17
    pos.y +=  cos(pos.z*5.0+uTime) * 0.1 * sin(pos.x * 5.0 + uTime);
18
    
19
    gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0);    
20
    
21
    WorldPosition = pos;
22
}

Den Trick tatsächlich umsetzen

Jetzt sind wir endlich bereit, die am Anfang dieses Abschnitts beschriebene Technik zu implementieren. Wir möchten die Tiefe des Pixels, in dem wir uns befinden, mit der Tiefe des Pixels dahinter vergleichen. Das Pixel, auf dem wir uns befinden, stammt von der Weltposition, und das Pixel dahinter stammt von der Bildschirmposition. Ergreifen Sie also diese beiden Tiefen:

1
float worldDepth = getLinearDepth(WorldPosition);
2
float screenDepth = getLinearScreenDepth();
Herausforderung #4: Einer dieser Werte wird niemals größer als der andere sein (vorausgesetzt, depthTest = true). Können Sie ableiten, welche?

Wir wissen, dass der Schaum dort sein wird, wo der Abstand zwischen diesen beiden Werten gering ist. Lassen Sie uns diesen Unterschied bei jedem Pixel rendern. Fügen Sie dies am unteren Rand Ihres Shaders ein (und stellen Sie sicher, dass das Tiefenvisualisierungsskript aus dem vorherigen Abschnitt deaktiviert ist):

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

Welches sollte ungefähr so aussehen:

A rendering of the depth difference at each pixel A rendering of the depth difference at each pixel A rendering of the depth difference at each pixel

Damit werden die Kanten von Objekten, die in Wasser getaucht sind, in Echtzeit korrekt erkannt! Sie können diesen Unterschied natürlich skalieren, damit der Schaum dicker / dünner aussieht.

Es gibt jetzt viele Möglichkeiten, wie Sie diese Ausgabe mit der Wasseroberflächenfarbe kombinieren können, um gut aussehende Schaumlinien zu erhalten. Sie können es als Verlauf beibehalten, zum Abtasten aus einer anderen Textur verwenden oder auf eine bestimmte Farbe einstellen, wenn der Unterschied kleiner oder gleich einem bestimmten Schwellenwert ist.

Mein Lieblingslook ist es, eine Farbe zu wählen, die der der statischen Wasserlinien ähnelt. Meine letzte Hauptfunktion sieht also folgendermaßen aus:

1
void main(void)
2
{
3
    vec4 color = vec4(0.0,0.7,1.0,0.5);
4
    
5
    vec2 pos = vUv0 * 2.0;
6
    pos.y += uTime * 0.02;
7
    
8
    vec4 WaterLines = texture2D(uSurfaceTexture,pos);
9
    color.rgba += WaterLines.r * 0.1;
10
    
11
    float worldDepth = getLinearDepth(WorldPosition);
12
    float screenDepth = getLinearScreenDepth();
13
    float foamLine = clamp((screenDepth - worldDepth),0.0,1.0) ;
14
    
15
    if(foamLine < 0.7){
16
        color.rgba += 0.2;
17
    }
18
    
19
    gl_FragColor = color;
20
}

Abschluss

Wir haben Auftrieb für im Wasser schwimmende Objekte erzeugt, unserer Oberfläche eine sich bewegende Textur gegeben, um Ätzmittel zu simulieren, und wir haben gesehen, wie wir den Tiefenpuffer verwenden können, um dynamische Schaumlinien zu erzeugen.

Um dies abzuschließen, werden im nächsten und letzten Teil Nachbearbeitungseffekte vorgestellt und wie man sie verwendet, um den Unterwasserverzerrungseffekt zu erzeugen.

Quellcode

Das fertige gehostete PlayCanvas-Projekt finden Sie hier. In diesem Repository ist auch ein Three.js-Port verfügbar.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Game Development tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.