Creazione di Toon Water per il Web parte 3

Bentornati a questa serie in tre parti sulla creazione di acqua stilizzata su PlayCanvas utilizzando vertex shader. Nella parte 2 abbiamo coperto le linee di galleggiamento e schiuma. In questa parte finale, applicheremo la distorsione subacquea come effetto post-processo.

Effetti di rifrazione e post-processo

Il nostro obiettivo è comunicare visivamente la rifrazione della luce attraverso l'acqua. Abbiamo già spiegato come creare questo tipo di distorsione in uno shader di frammenti in un tutorial precedente per una scena 2D. L'unica differenza qui è che avremo bisogno di capire quale area dello schermo è sott'acqua e applicare solo la distorsione lì. 

Post produzione

In generale, un effetto post-processo è qualsiasi cosa applicata all'intera scena dopo il rendering, come una tinta colorata o un vecchio effetto schermo CRT. Invece di rendere la tua scena direttamente sullo schermo, devi prima renderla a un buffer o texture, e quindi renderla sullo schermo, passando attraverso uno shader personalizzato.

In PlayCanvas, puoi impostare un effetto post-processo creando un nuovo script. Chiamalo Refraction.js, e copia questo modello per iniziare con:

// --------------- DEFINIZIONE POST EFFETTO ------------------------ // pc.extend ( pc, function () // Constructor - Crea un'istanza del nostro effetto post var RefractionPostEffect = function (graphicsDevice, vs, fs, buffer) var fragmentShader = "precision" + graphicsDevice.precision + "float; \ n"; fragmentShader = fragmentShader + fs; // questa è la definizione dello shader per il nostro effetto this.shader = new pc.Shader (graphicsDevice, attributes: aPosition: pc.SEMANTIC_POSITION, vshader: vs, fshader: fs); this.buffer = buffer;; // Il nostro effetto deve derivare da pc.PostEffect RefractionPostEffect = pc.inherits (RefractionPostEffect, pc.PostEffect); RefractionPostEffect.prototype = pc.extend (RefractionPostEffect.prototype, // Ogni effetto post deve implementare il rendering metodo che // imposta qualsiasi parametro richiesto dallo shader e // rende anche l'effetto sullo schermo render: function (inputTarget, outputTarget, rect) var device = this.device; var scope = device.scope; // set esimo e input renderizza target allo shader. Questa è l'immagine resa dalla nostra fotocamera scope.resolve ("uColorBuffer"). SetValue (inputTarget.colorBuffer); // Disegna un quad a tutto schermo sul target di output. In questo caso il target di output è lo schermo. // Disegnando un quad a tutto schermo si eseguirà lo shader che abbiamo definito sopra pc.drawFullscreenQuad (device, outputTarget, this.vertexBuffer, this.shader, rect); ); return RefractionPostEffect: RefractionPostEffect;  ()); // --------------- SCRIPT DEFINITION ------------------------ // var Refraction = pc. createScript ( 'rifrazione'); Refraction.attributes.add ('vs', type: 'asset', assetType: 'shader', titolo: 'Vertex Shader'); Refraction.attributes.add ('fs', type: 'asset', assetType: 'shader', titolo: 'Fragment Shader'); // inizializza il codice chiamato una volta per entità Refraction.prototype.initialize = function () var effect = new pc.RefractionPostEffect (this.app.graphicsDevice, this.vs.resource, this.fs.resource); // aggiungi l'effetto alla coda postEffects della telecamera var queue = this.entity.camera.postEffects; queue.addEffect (effetto); this.effect = effect; // Salva gli shader correnti per hot ricaricare 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 = function (old) this.entity.camera.postEffects.removeEffect (old.effect); this.initialize (); ;

Questo è proprio come uno script normale, ma definiamo a RefractionPostEffect classe che può essere applicata alla telecamera. Ciò richiede un vertice e uno shader di frammenti da renderizzare. Gli attributi sono già impostati, quindi creiamo Refraction.frag con questo contenuto:

highp float di precisione; uniforme sampler2D uColorBuffer; variando vec2 vUv0; void main () vec4 color = texture2D (uColorBuffer, vUv0); gl_FragColor = color;  

E Refraction.vert con un vertex shader di base:

attributo vec2 aPosition; variando vec2 vUv0; void main (void) gl_Position = vec4 (aPosition, 0.0, 1.0); vUv0 = (aPosition.xy + 1.0) * 0.5;  

Ora allega il Refraction.js script alla telecamera e assegna gli shader agli attributi appropriati. Quando avvii il gioco, dovresti vedere la scena esattamente com'era prima. Questo è un effetto post vuoto che restituisce semplicemente la scena. Per verificare che funzioni, prova a dare alla scena una tonalità rossa.

In Refraction.frag, invece di semplicemente restituire il colore, prova a impostare il componente rosso su 1.0, che dovrebbe apparire come l'immagine qui sotto.

Distortion Shader

Abbiamo bisogno di aggiungere un'uniforme temporale per la distorsione animata, quindi vai avanti e creane uno in Refraction.js, all'interno di questo costruttore per l'effetto post:

var RefractionPostEffect = function (graphicsDevice, vs, fs) var fragmentShader = "precision" + graphicsDevice.precision + "float; \ n"; fragmentShader = fragmentShader + fs; // questa è la definizione dello shader per il nostro effetto this.shader = new pc.Shader (graphicsDevice, attributes: aPosition: pc.SEMANTIC_POSITION, vshader: vs, fshader: fs); // >>>>>>>>>>>>> Inizializza l'ora qui this.time = 0; ;

Ora, all'interno di questa funzione di rendering, passiamo al nostro shader e lo incrementiamo:

RefractionPostEffect.prototype = pc.extend (RefractionPostEffect.prototype, // Ogni effetto post deve implementare il metodo render che // imposta qualsiasi parametro richiesto dallo shader e // inoltre rende l'effetto sullo schermo render: function (inputTarget, outputTarget, rect) var device = this.device; var scope = device.scope; // Imposta l'input render target sullo shader. Questa è l'immagine resa dalla nostra telecamera scope.resolve ("uColorBuffer"). setValue (inputTarget) .colorBuffer); /// >>>>>>>>>>>>>>>>>> Passa il tempo uniforme qui scope.resolve ("uTime"). setValue (this.time); this.time + = 0.1; // Disegna un quad a tutto schermo sul target di output.In questo caso il target di output è lo schermo // Disegnando un quad a tutto schermo si eseguirà lo shader che abbiamo definito sopra pc.drawFullscreenQuad (device, outputTarget, this. vertexBuffer, this.shader, rect););

Ora possiamo usare lo stesso codice shader del tutorial di distorsione dell'acqua, rendendo il nostro shader di frammenti completo simile a questo:

highp float di precisione; uniforme sampler2D uColorBuffer; uniforme float uTime; variando 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 = color;  

Se tutto ha funzionato, ora dovrebbe apparire tutto come se fosse sott'acqua, come di seguito.

Sfida n. 1: la distorsione si applica solo alla metà inferiore dello schermo.

Maschere della telecamera

Ci siamo quasi. Tutto quello che dobbiamo fare ora è applicare questo effetto di distorsione solo nella parte subacquea dello schermo. Il modo più semplice che ho imparato a fare questo è di ri-renderizzare la scena con la superficie dell'acqua resa come un solido bianco, come mostrato di seguito.

Questo sarebbe reso a una trama che avrebbe funzionato come una maschera. Passeremo quindi questa texture al nostro shader di rifrazione, che distorce solo un pixel nell'immagine finale se il pixel corrispondente nella maschera è bianco.

Aggiungiamo un attributo booleano sulla superficie dell'acqua per sapere se viene usato come maschera. Aggiungi questo a Water.js:

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

Possiamo quindi passarlo allo shader con material.setParameter ( 'isMask', this.isMask); come di solito. Quindi dichiaralo in Water.frag e imposta il colore su bianco se è vero.

// Dichiara la nuova uniforme in alto uniforme bool isMask; // Alla fine della funzione principale, sovrascrivere il colore da bianco // se la maschera è vera se (isMask) color = vec4 (1.0); 

Confermare che questo funziona alternando "Is Mask?" proprietà nell'editor e rilancio del gioco. Dovrebbe sembrare bianco, come nell'immagine precedente.

Ora, per ri-renderizzare la scena, abbiamo bisogno di una seconda fotocamera. Crea una nuova videocamera nell'editor e chiamala CameraMask. Duplica anche l'entità Water nell'editor e chiamala WaterMask. Assicurati che "Is Mask?" è falso per l'entità Acqua ma vero per WaterMask.

Per dire alla nuova macchina da renderizzare su una trama invece che sullo schermo, crea un nuovo script chiamato CameraMask.js e collegalo alla nuova fotocamera. Creiamo un RenderTarget per catturare l'output di questa fotocamera in questo modo:

// inizializza il codice chiamato una volta per entità CameraMask.prototype.initialize = function () // Crea una destinazione di rendering 512x512x24-bit con un buffer di profondità var colorBuffer = new pc.Texture (this.app.graphicsDevice, width: 512, altezza: 512, formato: 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; ;

Ora, se lo avvii, vedrai che questa fotocamera non sta più mostrando sullo schermo. Possiamo afferrare l'output del suo target di rendering in Refraction.js come questo:

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); // ... // Il resto di questa funzione è uguale a prima;

Si noti che passo questa trama maschera come argomento al costruttore di effetti post. Abbiamo bisogno di creare un riferimento ad esso nel nostro costruttore, quindi sembra:

//// Aggiunto un argomento extra sulla riga sottostante var RefractionPostEffect = function (graphicsDevice, vs, fs, buffer) var fragmentShader = "precision" + graphicsDevice.precision + "float; \ n"; fragmentShader = fragmentShader + fs; // questa è la definizione dello shader per il nostro effetto this.shader = new pc.Shader (graphicsDevice, attributes: aPosition: pc.SEMANTIC_POSITION, vshader: vs, fshader: fs); this.time = 0; //// <<<<<<<<<<<<< Saving the buffer here this.buffer = buffer; ;

Infine, nella funzione di rendering, passa il buffer al nostro shader con:

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

Ora per verificare che tutto funzioni, lo lascerò come una sfida.

Sfida n. 2: rendere uMaskBuffer sullo schermo per confermare che è l'uscita della seconda fotocamera.

Una cosa da tenere presente è che la destinazione del rendering è impostata nell'inizializzazione di CameraMask.js e che deve essere pronta prima che venga chiamato Refraction.js. Se gli script vengono eseguiti al contrario, si verificherà un errore. Per assicurarti che corrano nell'ordine corretto, trascina il CameraMask in cima all'elenco delle entità nell'editor, come mostrato di seguito.

La seconda fotocamera dovrebbe sempre guardare la stessa vista dell'originale, quindi facciamo in modo che segua sempre la sua posizione e rotazione nell'aggiornamento di CameraMask.js:

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

E definire CameraToFollow nella fase di inizializzazione:

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

Maschere di abbattimento

Entrambe le telecamere stanno attualmente rendendo la stessa cosa. Vogliamo che la maschera sia in grado di rendere tutto tranne l'acqua reale, e vogliamo che la macchina fotografica realizzi tutto tranne la maschera d'acqua.

Per fare ciò, possiamo usare la maschera bit di culling della telecamera. Funziona in modo simile alle maschere di collisione se hai mai usato quelle. Un oggetto verrà abbattuto (non renderizzato) se il risultato di un bit per bit E tra la sua maschera e la maschera della fotocamera è 1.

Diciamo che l'acqua avrà il bit 2 impostato, e WaterMask avrà il bit 3. Quindi la fotocamera reale deve avere tutti i bit impostati tranne 3 e la fotocamera deve avere tutti i bit impostati ad eccezione di 2. Un modo semplice per dire "tutti i bit tranne N" deve fare:

~ (1 << N) >>> 0

Puoi leggere ulteriori informazioni sugli operatori bit a bit qui.

Per impostare le maschere di culling della telecamera, possiamo metterlo dentro CameraMask.js's inizializza in basso:

 // Imposta tutti i bit ad eccezione di 2 this.entity.camera.camera.cullingMask & = ~ (1 << 2) >>> 0; // Imposta tutti i bit ad eccezione di 3 this.CameraToFollow.camera.camera.cullingMask & = ~ (1 << 3) >>> 0; // Se si desidera stampare questa maschera bit, provare: // console.log ((this.CameraToFollow.camera.camera.cullingMask >>> 0) .toString (2));

Ora, in Water.js, imposta la maschera della mesh d'acqua sul bit 2 e la versione mask di essa sul bit 3:

// Metti questo nella parte inferiore dell'inizializzazione di Water.js // Imposta le maschere di culling var bit = this.isMask? 3: 2; meshInstance.mask = 0; meshInstance.mask | = (1 << bit);

Ora, una vista avrà l'acqua normale e l'altra avrà l'acqua bianca solida. La metà sinistra dell'immagine qui sotto è la vista dalla fotocamera originale, e la metà destra è dalla fotocamera della maschera.

Applicazione della maschera

Un ultimo passo ora! Sappiamo che le aree sottomarine sono contrassegnate da pixel bianchi. Abbiamo solo bisogno di controllare se non siamo a un pixel bianco, e in tal caso, disattivare la distorsione in Refraction.frag:

// Controlla la posizione originale e la nuova posizione distorta vec4 maskColor = texture2D (uMaskBuffer, pos); vec4 maskColor2 = texture2D (uMaskBuffer, vUv0); // Non siamo ad un pixel bianco? if (maskColor! = vec4 (1.0) || maskColor2! = vec4 (1.0)) // Restituiscilo nella posizione originale pos = vUv0; 

E così dovrebbe andare!

Una cosa da notare è che poiché la trama per la maschera viene inizializzata all'avvio, se ridimensioni la finestra in fase di runtime, non corrisponderà più alle dimensioni dello schermo.

Anti aliasing

Come passaggio di pulizia opzionale, potresti aver notato che i bordi della scena ora appaiono un po 'taglienti. Questo perché quando abbiamo applicato il nostro effetto post, abbiamo perso l'anti-aliasing. 

Possiamo applicare un anti-alias aggiuntivo sopra i nostri effetti come un altro effetto post. Fortunatamente ce n'è uno disponibile nel negozio PlayCanvas che possiamo usare. Vai alla pagina degli asset dello script, fai clic sul grande pulsante di download verde e scegli il tuo progetto dall'elenco che appare. Lo script verrà visualizzato nella radice della finestra delle risorse come postEffect-fxaa.js. Allega questo all'entità Fotocamera e la tua scena dovrebbe apparire un po 'più bella! 

Pensieri finali

Se sei arrivato così lontano, dai una pacca sulla spalla! Abbiamo coperto molte tecniche in questa serie. Ora dovresti essere a tuo agio con vertex shader, renderizzare trame, applicare effetti di post-elaborazione, selezionare selettivamente gli oggetti, utilizzare il depth buffer e lavorare con il blending e la trasparenza. Anche se stavamo implementando questo in PlayCanvas, questi sono tutti concetti grafici generali che troverai in qualche modo su qualunque piattaforma tu finisca in.

Tutte queste tecniche sono applicabili anche a una varietà di altri effetti. Un'applicazione particolarmente interessante che ho trovato dei vertex shader è in questo talk sull'arte di Abzu, in cui spiegano come hanno usato i vertex shader per animare in modo efficiente decine di migliaia di pesci sullo schermo.

Ora dovresti avere anche un buon effetto dell'acqua che puoi applicare ai tuoi giochi! Potresti facilmente personalizzarlo ora che hai messo insieme tutti i dettagli da solo. C'è ancora molto di più che puoi fare con l'acqua (non ho nemmeno menzionato alcun tipo di riflessione). Di seguito sono un paio di idee.

Onde basate sul rumore

Invece di animare semplicemente le onde con una combinazione di seno e coseno, è possibile campionare una trama di rumore per rendere le onde un po 'più naturali e imprevedibili.

Dynamic Foam Trails

Invece di linee d'acqua completamente statiche sulla superficie, puoi disegnare su quella texture quando gli oggetti si muovono, per creare una scia di schiuma dinamica. Ci sono molti modi per farlo, quindi questo potrebbe essere il suo progetto.

Codice sorgente

Qui puoi trovare il progetto finito PlayCanvas ospitato. Una porta Three.js è anche disponibile in questo repository.