Erstellen von Toon-Wasser für das Web Teil 3

Willkommen bei dieser dreiteiligen Serie zum Erstellen von stilisiertem Toon-Wasser in PlayCanvas mithilfe von Vertex-Shadern. In Teil 2 haben wir Auftriebs- und Schaumstofflinien behandelt. In diesem letzten Teil werden wir die Unterwasserverzerrung als Nachbearbeitungseffekt anwenden.

Refraktion & Nachprozesseffekte

Unser Ziel ist die visuelle Kommunikation der Lichtbrechung durch Wasser. Wie Sie diese Art von Verzerrung in einem Fragment-Shader in einem vorherigen Lernprogramm für eine 2D-Szene erstellen, wurde bereits erläutert. Der einzige Unterschied besteht darin, dass wir herausfinden müssen, welcher Bereich des Bildschirms sich unter Wasser befindet, und nur die Verzerrung dort anwenden. 

Nachbearbeitung

Im Allgemeinen wird ein Post-Process-Effekt auf die gesamte Szene angewendet, nachdem sie gerendert wurde, z. B. ein Farbton oder ein alter CRT-Bildschirmeffekt. Anstatt Ihre Szene direkt auf dem Bildschirm zu rendern, rendern Sie sie zuerst in einem Puffer oder einer Textur und rendern sie dann auf dem Bildschirm, indem Sie einen benutzerdefinierten Shader durchlaufen.

In PlayCanvas können Sie einen Nachverarbeitungseffekt einrichten, indem Sie ein neues Skript erstellen. Nennen Refraction.js, und kopieren Sie diese Vorlage, um mit zu beginnen:

// --------------- POST EFFECT DEFINITION ------------------------ // pc.extend ( pc, function () // Konstruktor - Erzeugt eine Instanz unserer Post-Effekt-Funktion var RefractionPostEffect = (graphicsDevice, vs, fs, buffer) var fragmentShader = "precision" + graphicsDevice.precision + "float; \ n"; fragmentShader = fragmentShader + fs; // Dies ist die Shader-Definition für unseren Effekt this.shader = new pc.Shader (graphicsDevice, Attribute: aPosition: pc.SEMANTIC_POSITION, vshader: vs, fshader: fs); this.buffer = buffer;; // Unser Effekt muss von pc.PostEffect abgeleitet sein RefractionPostEffect = pc.inherits (RefractionPostEffect, pc.PostEffect); RefractionPostEffect.prototype = pc.extend (RefractionPostEffect.prototype, // Jeder Effekt muss das Rendern implementieren.) // Methode, die // alle Parameter setzt, die der Shader benötigt, und // auch die Auswirkung auf die Bildschirmdarstellung rendert: function (inputTarget, outputTarget, rect) var device = this.device; var scope = device.scope; // Set th Das Eingabe-Renderziel für den Shader. Dies ist das Bild, das von unserer Kamera angezeigt wird. Scope.resolve ("uColorBuffer"). SetValue (inputTarget.colorBuffer); // Zeichne ein Quad-Vollbild auf dem Ausgabeziel. In diesem Fall ist das Ausgabeziel der Bildschirm. // Beim Zeichnen eines Quad-Vollbilds wird der oben definierte Shader ausgeführt. Pc.drawFullscreenQuad (device, outputTarget, this.vertexBuffer, this.shader, rect); ); return RefractionPostEffect: RefractionPostEffect;  ()); // --------------- SCRIPT DEFINITION ------------------------ // var Refraction = pc. createScript ('refraction'); Refraction.attributes.add ('vs', Typ: 'Asset', AssetType: 'Shader', Titel: 'Vertex Shader'); Refraction.attributes.add ('fs', Typ: 'Asset', AssetType: 'Shader', Titel: 'Fragment Shader'); // einmal pro Entity aufgerufenen Code initialisieren Refraction.prototype.initialize = function () var effect = new pc.RefractionPostEffect (this.app.graphicsDevice, this.vs.resource, this.fs.resource); // füge den Effekt der postEffects-Warteschlange der Kamera hinzu var queue = this.entity.camera.postEffects; queue.addEffect (Effekt); this.effect = Effekt; // Speichere die aktuellen Shader für das heiße reload this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; ; Refraction.prototype.update = function () if (this.savedFS! = This.fs.resource || this.savedVS! = This.vs.resource) this.swap (this); ; Refraction.prototype.swap = Funktion (alt) this.entity.camera.postEffects.removeEffect (alt.effekt); this.initialize (); ;

Dies ist wie ein normales Skript, aber wir definieren a RefractionPostEffect Klasse, die auf die Kamera angewendet werden kann. Dies erfordert einen Scheitelpunkt und einen Fragment-Shader zum Rendern. Die Attribute sind bereits eingerichtet, also erstellen wir sie Refraction.frag mit diesem Inhalt:

Präzision Highp Float; einheitlicher sampler2D uColorBuffer; variierendes vec2 vUv0; void main () vec4 color = texture2D (uColorBuffer, vUv0); gl_FragColor = Farbe;  

Und Refraction.vert mit einem einfachen Vertex-Shader:

Attribut vec2 aPosition; variierendes vec2 vUv0; void main (void) gl_Position = vec4 (aPosition, 0.0, 1.0); vUv0 = (aPosition.xy + 1,0) * 0,5;  

Nun befestigen Sie die Refraction.js Skript an die Kamera, und weisen Sie die Shader den entsprechenden Attributen zu. Wenn Sie das Spiel starten, sollten Sie die Szene genau so sehen, wie sie zuvor war. Dies ist ein leerer Post-Effekt, der die Szene einfach neu rendert. Um zu überprüfen, ob dies funktioniert, versuchen Sie, die Szene rot zu färben.

Setzen Sie in Refraction.frag nicht einfach die Farbe zurück, sondern setzen Sie die rote Komponente auf 1.0. Dies sollte wie in der Abbildung unten aussehen.

Distortion Shader

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

var RefractionPostEffect = Funktion (graphicsDevice, vs, fs) var fragmentShader = "precision" + graphicsDevice.precision + "float; \ n"; fragmentShader = fragmentShader + fs; // Dies ist die Shader-Definition für unseren Effekt this.shader = new pc.Shader (graphicsDevice, Attribute: aPosition: pc.SEMANTIC_POSITION, vshader: vs, fshader: fs); // >>>>>>>>>>>>> Initialisieren Sie die Zeit hier this.time = 0; ;

Nun übergeben wir es innerhalb dieser Renderfunktion an unseren Shader und erhöhen ihn:

RefractionPostEffect.prototype = pc.extend (RefractionPostEffect.prototype, // Jeder Post-Effekt muss die Render-Methode implementieren, die // die vom Shader erforderlichen Parameter festlegt und // auch die Wirkung auf die Bildschirmdarstellung rendert: function (inputTarget, outputTarget, rect) var device = this.device; var scope = device.scope; // Setzt das Eingabe-Renderziel auf den Shader. Dies ist das Bild, das von unserer Kamera scope.resolve ("uColorBuffer") gerendert wird. setValue (inputTarget) .colorBuffer); /// >>>>>>>>>>>>>>>>>> Pass die Zeit einheitlich hier scope.resolve ( "utime") setValue (this.time). this.time + = 0,1; // Zeichne ein Quad-Vollbild auf dem Ausgabeziel. In diesem Fall ist das Ausgabeziel der Bildschirm. // Beim Zeichnen eines Quad-Vollbilds wird der Shader ausgeführt, den wir oben definiert haben: pc.drawFullscreenQuad (device, outputTarget, this). vertexBuffer, this.shader, rect););

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

Präzision Highp Float; einheitlicher sampler2D uColorBuffer; Uniform Float uTime; variierendes vec2 vUv0; void main () vec2 pos = vUv0; Float X = Pos.x * 15. + uTime * 0,5; Float Y = Pos.y * 15. + uTime * 0,5; pos.y + = cos (X + Y) * 0,01 * cos (Y); pos.x + = sin (X-Y) * 0,01 * sin (Y); vec4 color = texture2D (uColorBuffer, pos); gl_FragColor = Farbe;  

Wenn alles geklappt hat, sollte jetzt alles wie unter Wasser aussehen.

Herausforderung Nr. 1: Lassen Sie die Verzerrung nur auf die untere Hälfte des Bildschirms zutreffen.

Kameramasken

Wir sind fast da. Jetzt müssen wir diesen Verzerrungseffekt nur auf den Unterwasserteil des Bildschirms anwenden. Der einfachste Weg, den ich dazu gefunden habe, ist, die Szene erneut zu rendern, wobei die Wasseroberfläche als durchgehend weiß dargestellt wird, wie unten gezeigt.

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

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

Water.attributes.add ('isMask', type: 'boolean', Titel: "Is Mask?");

Wir können es dann mit dem Shader übergeben material.setParameter ('isMask', this.isMask); wie gewöhnlich. Dann deklarieren Sie es in Water.frag und setzen Sie die Farbe auf Weiß, falls dies wahr ist.

// Die neue Uniform oben deklarieren bool isMask; // Überschreibe am Ende der Hauptfunktion die Farbe mit Weiß // Wenn die Maske wahr ist if (isMask) color = vec4 (1.0); 

Bestätigen Sie, dass dies funktioniert, indem Sie "Is Mask?" Eigenschaft im Editor und Relaunch des Spiels. Es sollte wie im vorherigen Bild weiß aussehen.

Um die Szene erneut zu rendern, benötigen wir eine zweite Kamera. Erstellen Sie eine neue Kamera im Editor und rufen Sie sie auf CameraMask. Duplizieren Sie die Entität "Wasser" ebenfalls im Editor und rufen Sie sie auf Wassermaske. Stellen Sie sicher, dass die Maske "Ist Maske?" ist für die Entität Water falsch, für die WaterMask jedoch wahr.

Um der neuen Kamera zu sagen, dass sie anstelle des Bildschirms in eine Textur gerendert werden soll, erstellen Sie ein neues Skript namens CameraMask.js und befestigen Sie es an der neuen Kamera. Wir erstellen ein RenderTarget, um die Ausgabe dieser Kamera wie folgt aufzunehmen:

// einmal pro Entity aufgerufenen Code initialisieren CameraMask.prototype.initialize = function () // Erstellen Sie ein 512x512x24-Bit-Renderziel mit einem Tiefenpuffer. var colorBuffer = new pc.Texture (this.app.graphicsDevice, width: 512, Höhe: 512, Format: pc.PIXELFORMAT_R8_G8_B8, autoMipmap: true); colorBuffer.minFilter = pc.FILTER_LINEAR; colorBuffer.magFilter = pc.FILTER_LINEAR; var renderTarget = new pc.RenderTarget (this.app.graphicsDevice, colorBuffer, depth: true); this.entity.camera.renderTarget = renderTarget; ;

Wenn Sie jetzt starten, werden Sie sehen, dass diese Kamera nicht mehr auf dem Bildschirm gerendert wird. Wir können die Ausgabe seines Renderziels in packen Refraction.js so was:

Refraction.prototype.initialize = function () var cameraMask = this.app.root.findByName ('CameraMask'); var maskBuffer = cameraMask.camera.renderTarget.colorBuffer; var effect = new pc.RefractionPostEffect (this.app.graphicsDevice, this.vs.resource, this.fs.resource, maskBuffer); //… // Der Rest dieser Funktion ist derselbe wie zuvor;

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

//// Fügte ein zusätzliches Argument in der Zeile unterhalb von var hinzu RefractionPostEffect = function (graphicsDevice, vs, fs, buffer) var fragmentShader = "precision" + graphicsDevice.precision + "float; \ n"; fragmentShader = fragmentShader + fs; // Dies ist die Shader-Definition für unseren Effekt this.shader = new pc.Shader (graphicsDevice, Attribute: aPosition: pc.SEMANTIC_POSITION, vshader: vs, fshader: fs); this.time = 0; //// <<<<<<<<<<<<< Saving the buffer here this.buffer = buffer; ;

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

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

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

Herausforderung 2: Übertragen Sie den uMaskBuffer auf den Bildschirm, um zu bestätigen, dass es sich um die Ausgabe der zweiten Kamera handelt.

Dabei ist zu beachten, dass das Renderziel in der Initialisierung von CameraMask.js eingerichtet ist und bis zum Aufruf von Refraction.js fertig sein muss. Wenn die Skripts umgekehrt laufen, wird eine Fehlermeldung angezeigt. Um sicherzustellen, dass sie in der richtigen Reihenfolge ausgeführt werden, ziehen Sie die CameraMask an den Anfang der Entitätsliste im Editor (siehe unten).

Die zweite Kamera sollte immer dieselbe Ansicht wie die Originalkamera betrachten, also lassen Sie sie im Update von CameraMask.js immer ihrer Position und Drehung folgen:

CameraMask.prototype.update = Funktion (dt) var pos = this.CameraToFollow.getPosition (); var rot = this.CameraToFollow.getRotation (); this.entity.setPosition (pos.x, pos.y, pos.z); this.entity.setRotation (rot); ;

Und definieren CameraToFollow beim initialisieren:

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

Culling Masken

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

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

Nehmen wir an, für das Wasser wird Bit 2 gesetzt und für WaterMask Bit 3. Dann müssen für die echte Kamera alle Bits außer für 3 eingestellt sein, und für die Maskenkamera müssen alle Bits außer für 2 gesetzt sein. Eine einfache Möglichkeit zu sagen "alle Bits außer N" ist zu tun:

~ (1 << N) >>> 0

Weitere Informationen zu bitweisen Operatoren finden Sie hier.

Um die Kameraausschnittmasken einzurichten, können wir diese hineinlegen CameraMask.js 's am unteren Rand initialisieren:

 // Setze alle Bits außer 2 this.entity.camera.camera.cullingMask & = ~ (1 << 2) >>> 0; // Setze alle Bits außer 3 this.CameraToFollow.camera.camera.cullingMask & = ~ (1 << 3) >>> 0; // Wenn Sie diese Bitmaske ausdrucken möchten, versuchen Sie Folgendes: // 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 davon auf Bit 3:

// Setze das unten in die Initialisierung von Water.js // // Setze die Abschreckmasken var bit = this.isMask? 3: 2; meshInstance.mask = 0; meshInstance.mask | = (1 << bit);

Jetzt hat eine Ansicht das normale Wasser und die andere das feste Wildwasser. Die linke Hälfte des Bildes unten ist die Ansicht von der Originalkamera und die rechte Hälfte von der Maskenkamera.

Anwenden der Maske

Ein letzter Schritt jetzt! Wir wissen, dass die Bereiche unter Wasser mit weißen Pixeln markiert sind. Wir müssen nur prüfen, ob wir uns nicht auf einem weißen Pixel befinden, und wenn ja, schalten Sie die Verzerrung aus Refraction.frag:

// Originalposition sowie neue verzerrte Position prüfen vec4 maskColor = texture2D (uMaskBuffer, pos); vec4 maskColor2 = texture2D (uMaskBuffer, vUv0); // Wir sind nicht an einem weißen Pixel? if (maskColor! = vec4 (1.0) || maskColor2! = vec4 (1.0)) // Bringe die ursprüngliche Position wieder her pos = vUv0; 

Und das sollte es tun!

Beachten Sie Folgendes: Da die Textur für die Maske beim Start initialisiert wird, stimmt die Größe des Fensters zur Laufzeit nicht mehr mit der Bildschirmgröße überein.

Kantenglättung

Als optionaler Aufräumschritt haben Sie möglicherweise bemerkt, dass die Kanten in der Szene jetzt etwas scharf aussehen. Dies liegt daran, dass wir mit dem Post-Effekt das Anti-Aliasing verloren haben. 

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

Abschließende Gedanken

Wenn Sie es soweit geschafft haben, klopfen Sie auf die Rückseite! In dieser Serie haben wir viele Techniken behandelt. Sie sollten sich nun mit Vertex-Shadern auskennen, Texturen rendern, Nachbearbeitungseffekte anwenden, Objekte selektiv aussortieren, den Tiefenpuffer verwenden und mit Überblendungen und Transparenz arbeiten. Auch wenn wir dies in PlayCanvas implementiert haben, sind dies alles allgemeine grafische Konzepte, 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, wo sie erklären, wie sie Vertex-Shader verwendeten, 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 leicht anpassen, indem Sie jedes Detail selbst zusammengestellt haben. Es gibt noch viel mehr, was man mit Wasser machen kann (ich habe überhaupt keine Reflexion erwähnt). Nachfolgend einige Ideen.

Geräuschbasierte Wellen

Anstatt die Wellen einfach mit einer Kombination aus Sinus und Cosinus zu animieren, können Sie eine Geräuschstruktur abtasten, um die Wellen ein natürlicheres und unvorhersehbareres Aussehen zu verleihen.

Dynamic Foam Trails

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

Quellcode

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