Advertisement
  1. Game Development
  2. Shaders

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

Scroll to top
Read Time: 13 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 2 haben wir Auftriebs- und Schaumlinien behandelt. In diesem letzten Teil werden wir die Unterwasserverzerrung als Nachbearbeitungseffekt anwenden.

Brechungs- und Nachbearbeitungseffekte

Unser Ziel ist es, die Lichtbrechung durch Wasser visuell zu kommunizieren. Wie Sie diese Art von Verzerrung in einem Fragment-Shader in einem früheren Tutorial für eine 2D-Szene erstellen, haben wir bereits behandelt. Der einzige Unterschied besteht darin, dass wir herausfinden müssen, welcher Bereich des Bildschirms sich unter Wasser befindet, und nur dort die Verzerrung anwenden müssen.

Nachbearbeitung

Im Allgemeinen ist ein Nachbearbeitungseffekt alles, was nach dem Rendern auf die gesamte Szene angewendet wird, z. B. ein Farbton oder ein alter CRT-Bildschirmeffekt. Anstatt Ihre Szene direkt auf dem Bildschirm zu rendern, rendern Sie sie zuerst in einen Puffer oder eine Textur und rendern diese dann über einen benutzerdefinierten Shader auf den Bildschirm.

In PlayCanvas können Sie einen Nachbearbeitungseffekt einrichten, indem Sie ein neues Skript erstellen. Nennen Sie es Refraction.js und kopieren Sie diese Vorlage zunächst:

1
//--------------- POST EFFECT DEFINITION------------------------//

2
pc.extend(pc, function () {
3
    // Constructor - Creates an instance of our post effect

4
    var RefractionPostEffect = function (graphicsDevice, vs, fs, buffer) {
5
        var fragmentShader = "precision " + graphicsDevice.precision + " float;\n";
6
        fragmentShader = fragmentShader + fs;
7
        
8
        // this is the shader definition for our effect

9
        this.shader = new pc.Shader(graphicsDevice, {
10
            attributes: {
11
                aPosition: pc.SEMANTIC_POSITION
12
            },
13
            vshader: vs,
14
            fshader: fs
15
        });
16
        
17
        this.buffer =  buffer; 
18
    };
19
20
    // Our effect must derive from pc.PostEffect

21
    RefractionPostEffect = pc.inherits(RefractionPostEffect, pc.PostEffect);
22
23
    RefractionPostEffect.prototype = pc.extend(RefractionPostEffect.prototype, {
24
        // Every post effect must implement the render method which

25
        // sets any parameters that the shader might require and

26
        // also renders the effect on the screen

27
        render: function (inputTarget, outputTarget, rect) {
28
            var device = this.device;
29
            var scope = device.scope;
30
31
            // Set the input render target to the shader. This is the image rendered from our camera

32
            scope.resolve("uColorBuffer").setValue(inputTarget.colorBuffer);            
33
34
            // Draw a full screen quad on the output target. In this case the output target is the screen.

35
            // Drawing a full screen quad will run the shader that we defined above

36
            pc.drawFullscreenQuad(device, outputTarget, this.vertexBuffer, this.shader, rect);
37
        }
38
    });
39
40
    return {
41
        RefractionPostEffect: RefractionPostEffect
42
    };
43
}());
44
45
//--------------- SCRIPT DEFINITION------------------------//

46
var Refraction = pc.createScript('refraction');
47
48
Refraction.attributes.add('vs', {
49
    type: 'asset',
50
    assetType: 'shader',
51
    title: 'Vertex Shader'
52
});
53
54
Refraction.attributes.add('fs', {
55
    type: 'asset',
56
    assetType: 'shader',
57
    title: 'Fragment Shader'
58
});
59
60
// initialize code called once per entity

61
Refraction.prototype.initialize = function() {
62
    var effect = new pc.RefractionPostEffect(this.app.graphicsDevice, this.vs.resource, this.fs.resource);
63
64
    // add the effect to the camera's postEffects queue

65
    var queue = this.entity.camera.postEffects;
66
    queue.addEffect(effect);
67
    
68
    this.effect = effect;
69
    
70
    // Save the current shaders for hot reload 

71
    this.savedVS = this.vs.resource;
72
    this.savedFS = this.fs.resource;
73
};
74
75
Refraction.prototype.update = function(){
76
     if(this.savedFS != this.fs.resource || this.savedVS != this.vs.resource){
77
         this.swap(this);
78
     }
79
};
80
81
Refraction.prototype.swap = function(old){
82
    this.entity.camera.postEffects.removeEffect(old.effect);
83
    this.initialize(); 
84
};

Dies ist wie bei einem normalen Skript, wir definieren jedoch eine RefractionPostEffect-Klasse, die auf die Kamera angewendet werden kann. Dies erfordert einen Scheitelpunkt und einen Fragment-Shader zum Rendern. Die Attribute sind bereits eingerichtet. Erstellen wir also Refraction.frag mit folgendem Inhalt:

1
precision highp float;
2
3
uniform sampler2D uColorBuffer;
4
varying vec2 vUv0;
5
6
void main() {
7
    vec4 color = texture2D(uColorBuffer, vUv0);
8
    
9
    gl_FragColor = color;
10
}

Und Refraction.vert mit einem einfachen Vertex-Shader:

1
attribute vec2 aPosition;
2
varying vec2 vUv0;
3
4
void main(void)
5
{
6
    gl_Position = vec4(aPosition, 0.0, 1.0);
7
    vUv0 = (aPosition.xy + 1.0) * 0.5;
8
}

Hängen Sie nun das Skript Refraction.js an die Kamera an und weisen Sie die Shader den entsprechenden Attributen zu. Wenn Sie das Spiel starten, sollten Sie die Szene genau so sehen, wie sie vorher war. Das ist ein leerer Post-Effekt, der die Szene einfach neu rendert. Um zu überprüfen, ob dies funktioniert, geben Sie der Szene einen roten Farbton.

Versuchen Sie in Refraction.frag, anstatt einfach die Farbe zurückzugeben, die rote Komponente auf 1,0 zu setzen, was wie im Bild unten aussehen sollte.

Scene rendered with a red tint Scene rendered with a red tint Scene rendered with a red tint

Verzerrung Shader

Wir müssen eine Zeituniform für die animierte Verzerrung hinzufügen. Erstellen Sie also eine in Refraction.js in diesem Konstruktor für den Post-Effekt:

1
var RefractionPostEffect = function (graphicsDevice, vs, fs) {
2
    var fragmentShader = "precision " + graphicsDevice.precision + " float;\n";
3
    fragmentShader = fragmentShader + fs;
4
    
5
    // this is the shader definition for our effect

6
    this.shader = new pc.Shader(graphicsDevice, {
7
        attributes: {
8
            aPosition: pc.SEMANTIC_POSITION
9
        },
10
        vshader: vs,
11
        fshader: fs
12
    });
13
    
14
    // >>>>>>>>>>>>> Initialize the time here 

15
    this.time = 0;
16
    
17
    };

Innerhalb dieser Renderfunktion übergeben wir sie nun an unseren Shader und erhöhen sie:

1
RefractionPostEffect.prototype = pc.extend(RefractionPostEffect.prototype, {
2
    // Every post effect must implement the render method which

3
    // sets any parameters that the shader might require and

4
    // also renders the effect on the screen

5
    render: function (inputTarget, outputTarget, rect) {
6
        var device = this.device;
7
        var scope = device.scope;
8
9
        // Set the input render target to the shader. This is the image rendered from our camera

10
        scope.resolve("uColorBuffer").setValue(inputTarget.colorBuffer);   
11
        /// >>>>>>>>>>>>>>>>>> Pass the time uniform here 

12
        scope.resolve("uTime").setValue(this.time);
13
        this.time += 0.1;
14
15
        // Draw a full screen quad on the output target. In this case the output target is the screen.

16
        // Drawing a full screen quad will run the shader that we defined above

17
        pc.drawFullscreenQuad(device, outputTarget, this.vertexBuffer, this.shader, rect);
18
    }
19
});

Jetzt können wir denselben Shader-Code aus dem Tutorial zur Wasserverzerrung verwenden, sodass unser vollständiger Fragment-Shader folgendermaßen aussieht:

1
precision highp float;
2
3
uniform sampler2D uColorBuffer;
4
uniform float uTime;
5
6
varying vec2 vUv0;
7
8
void main() {
9
    vec2 pos = vUv0;
10
    
11
    float X = pos.x*15.+uTime*0.5;
12
    float Y = pos.y*15.+uTime*0.5;
13
    pos.y += cos(X+Y)*0.01*cos(Y);
14
    pos.x += sin(X-Y)*0.01*sin(Y);
15
    
16
    vec4 color = texture2D(uColorBuffer, pos);
17
    
18
    gl_FragColor = color;
19
}

Wenn alles geklappt hat, sollte jetzt alles so aussehen, als wäre es unter Wasser, wie unten.

Underwater distortion applied to the whole scene Underwater distortion applied to the whole scene Underwater distortion applied to the whole scene
Herausforderung Nr. 1: Stellen Sie sicher, dass die Verzerrung nur auf die untere Hälfte des Bildschirms angewendet wird.

Kameramasken

Wir sind fast da. Jetzt müssen wir diesen Verzerrungseffekt nur noch auf den Unterwasserteil des Bildschirms anwenden. Der einfachste Weg, dies zu tun, besteht darin, die Szene neu zu rendern, wobei die Wasseroberfläche wie unten gezeigt als festes Weiß dargestellt wird.

Water surface rendered as a solid white to act as a maskWater surface rendered as a solid white to act as a maskWater surface rendered as a solid white to act as a mask

Dies würde zu einer Textur gerendert, die als Maske fungieren würde. Wir würden diese Textur dann an unseren Refraktions-Shader übergeben, der ein Pixel im endgültigen Bild nur dann verzerrt, wenn das entsprechende Pixel in der Maske weiß ist.

Fügen wir der Wasseroberfläche ein boolesches Attribut hinzu, um festzustellen, ob es als Maske verwendet wird. Fügen Sie dies zu Water.js hinzu:

1
Water.attributes.add('isMask', {type:'boolean',title:"Is Mask?"});

Wir können es dann mit material.setParameter('isMask',this.isMask) an den Shader übergeben. wie gewöhnlich. Deklarieren Sie es dann in Water.frag und setzen Sie die Farbe auf Weiß, wenn es wahr ist.

1
// Declare the new uniform at the top

2
uniform bool isMask;
3
4
// At the end of the main function, override the color to be white 

5
// if the mask is true 

6
if(isMask){
7
   color = vec4(1.0); 
8
}

Bestätigen Sie, dass dies funktioniert, indem Sie die Option "Ist Maske?" Eigenschaft im Editor und Neustart des Spiels. Es sollte weiß aussehen, wie im vorherigen Bild.

Um die Szene neu zu rendern, benötigen wir eine zweite Kamera. Erstellen Sie im Editor eine neue Kamera und nennen Sie sie CameraMask. Duplizieren Sie die Entität Water auch im Editor und nennen Sie sie WaterMask. Stellen Sie sicher, dass "Ist Maske?" ist falsch für die Entität Water, aber wahr für die WaterMask.

Um die neue Kamera anzuweisen, anstelle des Bildschirms eine Textur zu rendern, erstellen Sie ein neues Skript mit dem Namen CameraMask.js und hängen Sie es an die neue Kamera an. Wir erstellen ein RenderTarget, um die Ausgabe dieser Kamera wie folgt zu erfassen:

1
// initialize code called once per entity

2
CameraMask.prototype.initialize = function() {
3
    // Create a 512x512x24-bit render target with a depth buffer

4
    var colorBuffer = new pc.Texture(this.app.graphicsDevice, {
5
        width: 512,
6
        height: 512,
7
        format: pc.PIXELFORMAT_R8_G8_B8,
8
        autoMipmap: true
9
    });
10
    colorBuffer.minFilter = pc.FILTER_LINEAR;
11
    colorBuffer.magFilter = pc.FILTER_LINEAR;
12
    var renderTarget = new pc.RenderTarget(this.app.graphicsDevice, colorBuffer, {
13
        depth: true
14
    });
15
16
    this.entity.camera.renderTarget = renderTarget;
17
};

Wenn Sie jetzt starten, sehen Sie, dass diese Kamera nicht mehr auf dem Bildschirm gerendert wird. Wir können die Ausgabe des Renderziels in Refraction.js wie folgt abrufen:

1
Refraction.prototype.initialize = function() {
2
    var cameraMask = this.app.root.findByName('CameraMask');
3
    var maskBuffer = cameraMask.camera.renderTarget.colorBuffer;
4
    
5
    var effect = new pc.RefractionPostEffect(this.app.graphicsDevice, this.vs.resource, this.fs.resource, maskBuffer);
6
    
7
    // ...

8
    // The rest of this function is the same as before

9
    
10
};

Beachten Sie, dass ich diese Maskentextur als Argument an den Post-Effekt-Konstruktor übergebe. Wir müssen in unserem Konstruktor einen Verweis darauf erstellen, damit es so aussieht:

1
//// Added an extra argument on the line below

2
var RefractionPostEffect = function (graphicsDevice, vs, fs, buffer) {
3
        var fragmentShader = "precision " + graphicsDevice.precision + " float;\n";
4
        fragmentShader = fragmentShader + fs;
5
        
6
        // this is the shader definition for our effect

7
        this.shader = new pc.Shader(graphicsDevice, {
8
            attributes: {
9
                aPosition: pc.SEMANTIC_POSITION
10
            },
11
            vshader: vs,
12
            fshader: fs
13
        });
14
        
15
        this.time = 0;
16
        //// <<<<<<<<<<<<< Saving the buffer here 

17
        this.buffer = buffer; 
18
    };

Übergeben Sie schließlich in der Renderfunktion den Puffer an unseren Shader mit:

1
scope.resolve("uMaskBuffer").setValue(this.buffer); 

Um zu überprüfen, ob dies alles funktioniert, lasse ich das als Herausforderung.

Herausforderung Nr. 2: Rendern Sie den uMaskBuffer auf dem Bildschirm, um zu betätigen, dass es sich um die Ausgabe der zweiten Kamera handelt.

Beachten Sie, dass das Renderziel bei der Initialisierung von CameraMask.js eingerichtet wird und zum Zeitpunkt des Aufrufs von Refraction.js bereit sein muss. Wenn die Skripte umgekehrt ausgeführt werden, wird eine Fehlermeldung angezeigt. Um sicherzustellen, dass sie in der richtigen Reihenfolge ausgeführt werden, ziehen Sie die CameraMask wie unten gezeigt an den Anfang der Entitätsliste im Editor.

PlayCanvas editor with CameraMask at top of entity listPlayCanvas editor with CameraMask at top of entity listPlayCanvas editor with CameraMask at top of entity list

Die zweite Kamera sollte immer dieselbe Ansicht wie die ursprüngliche haben. Lassen Sie sie daher beim Update von CameraMask.js immer ihrer Position und Drehung folgen:

1
CameraMask.prototype.update = function(dt) {
2
    var pos = this.CameraToFollow.getPosition();
3
    var rot = this.CameraToFollow.getRotation();
4
    this.entity.setPosition(pos.x,pos.y,pos.z);
5
    this.entity.setRotation(rot);
6
};

Und definieren Sie CameraToFollow in der Initialisierung:

1
this.CameraToFollow = this.app.root.findByName('Camera');

Keulungsmasken

Beide Kameras rendern derzeit dasselbe. Wir möchten, dass die Maskenkamera alles außer dem echten Wasser rendert, und wir möchten, dass die echte Kamera alles außer dem Maskenwasser rendert.

Dazu können wir die Culling-Bit-Maske der Kamera verwenden. Dies funktioniert ähnlich wie bei Kollisionsmasken, wenn Sie diese jemals verwendet haben. Ein Objekt wird ausgesondert (nicht gerendert), wenn das Ergebnis eines bitweisen AND zwischen seiner Maske und der Kameramaske 1 ist.

Für das Wasser wird Bit 2 und für die Wassermaske Bit 3 gesetzt. Dann müssen für die reale Kamera alle Bits außer 3 gesetzt sein, und für die Maskenkamera müssen alle Bits außer 2 gesetzt sein. Eine einfache Möglichkeit, dies zu sagen "all bits except N" ist zu tun:

1
~(1 << N) >>> 0

Weitere Informationen zu bitweisen Operatoren finden Sie hier.

Um die Culling-Masken für die Kamera einzurichten, können Sie diese unten in die Initialisierung von CameraMask.js einfügen:

1
    // Set all bits except for 2 

2
    this.entity.camera.camera.cullingMask &= ~(1 << 2) >>> 0;
3
    // Set all bits except for 3

4
    this.CameraToFollow.camera.camera.cullingMask &= ~(1 << 3) >>> 0;
5
    // If you want to print out this bit mask, try:

6
    // console.log((this.CameraToFollow.camera.camera.cullingMask >>> 0).toString(2));

Setzen Sie nun in Water.js die Maske des Wassernetzes auf Bit 2 und die Maskenversion auf Bit 3:

1
// Put this at the bottom of the initialize of Water.js

2
3
// Set the culling masks 

4
var bit = this.isMask ? 3 : 2; 
5
meshInstance.mask = 0; 
6
meshInstance.mask |= (1 << bit);

Jetzt hat eine Ansicht das normale Wasser und die andere das feste weiße Wasser. Die linke Bildhälfte unten zeigt die Ansicht der Originalkamera und die rechte Hälfte die Maskenkamera.

Split view of mask camera and original cameraSplit view of mask camera and original cameraSplit view of mask camera and original camera

Anwenden der Maske

Ein letzter Schritt jetzt! Wir wissen, dass die Bereiche unter Wasser mit weißen Pixeln markiert sind. Wir müssen nur überprüfen, ob wir kein weißes Pixel haben, und wenn ja, die Verzerrung in Refraction.frag deaktivieren:

1
// Check original position as well as new distorted position

2
vec4 maskColor = texture2D(uMaskBuffer, pos);
3
vec4 maskColor2 = texture2D(uMaskBuffer, vUv0);
4
// We're not at a white pixel?

5
if(maskColor != vec4(1.0) || maskColor2 != vec4(1.0)){
6
    // Return it back to the original position

7
    pos = vUv0;
8
}

Und das sollte es tun!

Beachten Sie, dass die Textur für die Maske beim Start initialisiert wird. Wenn Sie die Größe des Fensters zur Laufzeit ändern, entspricht sie nicht mehr der Größe des Bildschirms.

Kantenglättung

Als optionalen Bereinigungsschritt haben Sie möglicherweise bemerkt, dass die Kanten in der Szene jetzt etwas scharf aussehen. Dies liegt daran, dass wir beim Anwenden unseres Post-Effekts das Anti-Aliasing verloren haben.

Wir können zusätzlich zu unserem Effekt einen zusätzlichen Anti-Alias als weiteren Post-Effekt anwenden. Glücklicherweise gibt es eine im PlayCanvas-Store, die wir einfach verwenden können. Gehen Sie zur Seite mit den Skript-Assets, klicken Sie auf die große grüne Schaltfläche zum Herunterladen und wählen Sie Ihr Projekt aus der angezeigten Liste aus. Das Skript wird im Stammverzeichnis Ihres Asset-Fensters als posteffect-fxaa.js angezeigt. Fügen Sie dies einfach der Kamera-Entität hinzu, und Ihre Szene sollte ein wenig schöner aussehen!

Abschließende Gedanken

Wenn Sie es bis hierher geschafft haben, klopfen Sie sich auf den Rücken! Wir haben in dieser Serie viele Techniken behandelt. Sie sollten jetzt mit Vertex-Shadern vertraut sein, Texturen rendern, Nachbearbeitungseffekte anwenden, Objekte selektiv aussortieren, den Tiefenpuffer verwenden und mit Überblendung und Transparenz arbeiten. Obwohl wir dies in PlayCanvas implementiert haben, sind dies alles allgemeine Grafikkonzepte, die Sie in irgendeiner Form auf jeder Plattform finden, auf der Sie landen.

Alle diese Techniken sind auch auf eine Vielzahl anderer Effekte anwendbar. Eine besonders interessante Anwendung, die ich für Vertex-Shader gefunden habe, ist in diesem Vortrag über die Kunst von Abzu, in dem erklärt wird, wie sie Vertex-Shader verwendet haben, um Zehntausende von Fischen auf dem Bildschirm effizient zu animieren.

Sie sollten jetzt auch einen schönen Wassereffekt haben, den Sie auf Ihre Spiele anwenden können! Sie können es jetzt ganz einfach anpassen, da Sie jedes Detail selbst zusammengestellt haben. Mit Wasser kann man noch viel mehr anfangen (ich habe überhaupt keine Reflexion erwähnt). Im Folgenden finden Sie einige Ideen.

Rauschbasierte Wellen

Anstatt die Wellen einfach mit einer Kombination aus Sinus und Cosinus zu animieren, können Sie eine Rauschstruktur abtasten, um die Wellen etwas natürlicher und unvorhersehbarer erscheinen zu lassen.

Dynamische Schaumwege

Anstelle von vollständig statischen Wasserlinien auf der Oberfläche können Sie auf diese Textur zeichnen, wenn sich Objekte bewegen, um eine dynamische Schaumspur zu erstellen. Es gibt viele Möglichkeiten, dies zu tun, daher könnte dies ein eigenes Projekt sein.

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.