Bentornati a questa serie in tre parti sulla creazione di acqua stilizzata su PlayCanvas utilizzando vertex shader. Nella prima parte abbiamo coperto il nostro ambiente e la superficie dell'acqua. Questa parte riguarderà l'applicazione della galleggiabilità agli oggetti, l'aggiunta di linee d'acqua alla superficie e la creazione delle linee di schiuma con il tampone di profondità attorno ai bordi degli oggetti che intersecano la superficie.
Ho apportato alcune piccole modifiche alla mia scena per renderla un po 'più carina. Puoi personalizzare la tua scena come preferisci, ma quello che ho fatto è stato:
# FFA457
. # 6CC8FF
.# FFC480
(puoi trovarlo nelle impostazioni della scena).Di seguito è riportato l'aspetto del mio punto di partenza.
Il modo più semplice per creare assetto è solo quello di creare uno script che spinga gli oggetti su e giù. Crea un nuovo script chiamato Buoyancy.js e impostarne l'inizializzazione su:
Buoyancy.prototype.initialize = function () this.initialPosition = this.entity.getPosition (). Clone (); this.initialRotation = this.entity.getEulerAngles (). clone (); // L'ora iniziale è impostata su un valore casuale in modo che se // questo script sia collegato a più oggetti, non tutti // si spostino allo stesso modo this.time = Math.random () * 2 * Math.PI; ;
Ora, nell'aggiornamento, incrementiamo il tempo e ruotiamo l'oggetto:
Buoyancy.prototype.update = function (dt) this.time + = 0.1; // Sposta l'oggetto in alto e in basso 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); // Ruota leggermente l'oggetto 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); ;
Applica questo script alla tua barca e osservala mentre salta su e giù nell'acqua! Puoi applicare questo script a diversi oggetti (inclusa la fotocamera-provalo)!
In questo momento, l'unico modo per vedere le onde è osservare i bordi della superficie dell'acqua. L'aggiunta di una texture aiuta a rendere più visibile il movimento sulla superficie ed è un modo economico per simulare riflessi e caustiche.
Puoi provare a trovare alcune texture caustiche o crearne di tue. Ecco uno che ho disegnato in Gimp che puoi usare liberamente. Qualsiasi texture funzionerà finché può essere piastrellata senza problemi.
Una volta che hai trovato una texture che ti piace, trascinala nella finestra delle risorse del tuo progetto. Dobbiamo fare riferimento a questa texture nel nostro script Water.js, quindi creare un attributo per esso:
Water.attributes.add ('surfaceTexture', type: 'asset', assetType: 'texture', titolo: 'Surface Texture');
E poi assegnarlo nell'editor:
Ora dobbiamo passare al nostro shader. Vai a Water.js e impostare un nuovo parametro nel CreateWaterMaterial
funzione:
material.setParameter ( 'uSurfaceTexture', this.surfaceTexture.resource);
Adesso vai Water.frag e dichiara la nostra nuova uniforme:
uniforme sampler2D uSurfaceTexture;
Ci siamo quasi. Per rendere la texture sul piano, dobbiamo sapere dove si trova ogni pixel lungo la mesh. Il che significa che dobbiamo passare alcuni dati dal vertex shader allo shader del frammento.
UN variandovariabile consente di passare i dati dal vertex shader allo shader del frammento. Questo è il terzo tipo di variabile speciale che puoi avere in uno shader (gli altri due sono uniformee attributo). È definito per ogni vertice ed è accessibile da ciascun pixel. Dato che ci sono molti più pixel dei vertici, il valore è interpolato tra i vertici (da qui il nome "variabile", che varia dai valori che gli dai).
Per provarlo, dichiara una nuova variabile in Water.vert come variabile:
variando vec2 ScreenPosition;
E poi impostarlo GL_POSITION
dopo che è stato calcolato:
ScreenPosition = gl_Position.xyz;
Ora torna a Water.frag e dichiara la stessa variabile. Non c'è modo di ottenere un output di debug all'interno di uno shader, ma possiamo usare il colore per eseguire il debug visivo. Ecco un modo per farlo:
uniforme sampler2D uSurfaceTexture; variando vec3 ScreenPosition; void main (void) vec4 color = vec4 (0.0.0.7,1.0,0.5); // Prova la nostra nuova variabile variabile color = vec4 (vec3 (ScreenPosition.x), 1.0); gl_FragColor = color;
L'aereo dovrebbe ora apparire in bianco e nero, dove la linea che li separa è dove ScreenPosition.x
è 0. I valori dei colori vanno solo da 0 a 1, ma i valori in ScreenPosition
può essere fuori da questo intervallo. Vengono automaticamente bloccati, quindi se stai vedendo il nero, potrebbe essere 0 o negativo.
Quello che abbiamo appena fatto è passato la posizione dello schermo di ogni vertice ad ogni pixel. Puoi vedere che la linea che separa i lati bianco e nero si troverà sempre al centro dello schermo, indipendentemente da dove la superficie sia effettivamente nel mondo.
Sfida n. 1: crea una nuova variabile variabile per passare la posizione del mondo anziché la posizione dello schermo. Visualizzalo nello stesso modo come abbiamo fatto sopra. Se il colore non cambia con la fotocamera, allora hai fatto correttamente.
Gli UV sono le coordinate 2D per ciascun vertice lungo la mesh, normalizzati da 0 a 1. Questo è esattamente ciò di cui abbiamo bisogno per campionare correttamente la trama sul piano, e dovrebbe essere già impostato dalla parte precedente.
Dichiara un nuovo attributo in Water.vert (questo nome deriva dalla definizione di shader in Water.js):
attributo vec2 aUv0;
E tutto ciò che dobbiamo fare è passarlo allo shader di frammenti, quindi basta creare una variante e impostarla sull'attributo:
// In Water.vert // Dichiariamo questo insieme alle altre nostre variabili nella parte superiore variando vec2 vUv0; // ... // Giù nella funzione principale, memorizziamo il valore dell'attributo // nel variare in modo che il frag shader possa accedervi vUv0 = aUv0;
Ora dichiariamo la stessa variazione nello shader del frammento. Per verificare che funzioni, possiamo visualizzarlo come prima, in modo che ora Water.frag assomigli a:
uniforme sampler2D uSurfaceTexture; variando vec2 vUv0; void main (void) vec4 color = vec4 (0.0.0.7,1.0,0.5); // Confermare UV's color = vec4 (vec3 (vUv0.x), 1.0); gl_FragColor = color;
E dovresti vedere un gradiente, confermando che abbiamo un valore di 0 a un'estremità e 1 all'altra. Ora, per campionare la nostra texture, tutto ciò che dobbiamo fare è:
color = texture2D (uSurfaceTexture, vUv0);
E dovresti vedere la texture sulla superficie:
Invece di impostare la texture come il nostro nuovo colore, combiniamola con il blu che avevamo:
uniforme sampler2D uSurfaceTexture; variando 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;
Questo funziona perché il colore della trama è nero (0) ovunque tranne che per le linee d'acqua. Aggiungendolo, non cambiamo il colore blu originale ad eccezione dei punti in cui ci sono linee, dove diventa più luminoso.
Questo non è l'unico modo per combinare i colori, però.
Sfida n. 2: puoi combinare i colori in un modo per ottenere l'effetto più sottile mostrato di seguito?
Come effetto finale, vogliamo che le linee si muovano lungo la superficie in modo che non sembri così statica. Per fare questo, usiamo il fatto che qualsiasi valore dato al Texture2D
la funzione al di fuori dell'intervallo 0 a 1 si avvolge (tale che 1.5 e 2.5 diventano entrambi 0.5). Quindi possiamo incrementare la nostra posizione per la variabile uniforme di tempo che abbiamo già impostato e moltiplicare la posizione per aumentare o diminuire la densità delle linee nella nostra superficie, rendendo il nostro shader finale simile a questo:
uniforme sampler2D uSurfaceTexture; uniforme float uTime; variando vec2 vUv0; void main (void) vec4 color = vec4 (0.0.0.7,1.0,0.5); vec2 pos = vUv0; // Moltiplicare per un numero maggiore di 1 fa sì che // texture si ripeta più spesso pos * = 2.0; // Spostamento dell'intera trama in modo che si muova lungo la superficie pos.y + = uTime * 0.02; vec4 WaterLines = texture2D (uSurfaceTexture, pos); color.rgba + = WaterLines.r; gl_FragColor = color;
Il rendering delle linee di schiuma attorno agli oggetti nell'acqua rende molto più facile vedere come gli oggetti sono immersi e dove tagliano la superficie. Rende anche la nostra acqua molto più credibile. Per fare questo, abbiamo bisogno di capire in qualche modo dove sono i bordi su ogni oggetto, e farlo in modo efficiente.
Quello che vogliamo è essere in grado di dire, dato un pixel sulla superficie dell'acqua, se è vicino a un oggetto. Se è così, possiamo colorarlo come schiuma. Non c'è un modo semplice per farlo (che io sappia). Quindi, per capirlo, useremo un'utile tecnica per la risoluzione dei problemi: vieni con un esempio a cui conosciamo la risposta e vediamo se possiamo generalizzarla.
Considera la vista qui sotto.
Quali pixel dovrebbero essere parte della schiuma? Sappiamo che dovrebbe assomigliare a questo:
Quindi pensiamo a due pixel specifici. Ho segnato due con stelle qui sotto. Quello nero è nella schiuma. Quello rosso non lo è. Come possiamo distinguerli all'interno di uno shader?
Quello che sappiamo è che anche se quei due pixel sono vicini nello spazio dello schermo (entrambi sono resi proprio sopra il corpo del faro), sono in realtà molto distanti nello spazio del mondo. Possiamo verificare questo osservando la stessa scena da una diversa angolazione, come mostrato di seguito.
Si noti che la stella rossa non è in cima al corpo del faro come appariva, ma in realtà la stella nera lo è. Possiamo distinguerli usando la distanza dalla fotocamera, comunemente indicata come "profondità", dove una profondità di 1 significa che è molto vicina alla fotocamera e una profondità di 0 significa che è molto lontana. Ma non è solo una questione di distanza assoluta del mondo, o profondità, per la fotocamera. È la profondità rispetto al pixel dietro.
Guarda indietro alla prima vista. Diciamo che il corpo del faro ha un valore di profondità di 0,5. La profondità della stella nera sarebbe molto vicina a 0,5. Quindi, e il pixel dietro ad esso hanno valori di profondità simili. La stella rossa, d'altra parte, avrebbe una profondità molto più grande, perché sarebbe più vicina alla fotocamera, diciamo 0.7. Eppure il pixel dietro di esso, sempre sul faro, ha un valore di profondità di 0,5, quindi c'è una differenza maggiore lì.
Questo è il trucco. Quando la profondità del pixel sulla superficie dell'acqua è abbastanza vicina alla profondità del pixel su cui è disegnata, siamo molto vicini al limite di qualcosa, e possiamo renderlo come schiuma.
Quindi abbiamo bisogno di più informazioni di quante siano disponibili in ogni dato pixel. In qualche modo abbiamo bisogno di conoscere la profondità del pixel che sta per essere disegnata sopra. È qui che entra in gioco il buffer di profondità.
Puoi pensare ad un buffer, o ad un framebuffer, come un semplice bersaglio di rendering fuori schermo o una trama. Dovresti eseguire il rendering fuori schermo quando stai cercando di leggere i dati, una tecnica che utilizza questo effetto fumo.
Il depth buffer è uno speciale render render che contiene informazioni sui valori di profondità di ciascun pixel. Ricorda che il valore in GL_POSITION
calcolato nel vertex shader era un valore di spazio sullo schermo, ma aveva anche una terza coordinata, un valore Z. Questo valore Z viene utilizzato per calcolare la profondità che viene scritta nel buffer di profondità.
Lo scopo del buffer di profondità è disegnare correttamente la scena, senza la necessità di riordinare gli oggetti in primo piano. Ogni pixel che sta per essere disegnato prima consulta il buffer di profondità. Se il suo valore di profondità è maggiore del valore nel buffer, viene disegnato e il suo valore sovrascrive quello nel buffer. Altrimenti, viene scartato (perché significa che un altro oggetto è di fronte ad esso).
Puoi effettivamente disattivare la scrittura nel buffer di profondità per vedere come sarebbero le cose senza di esso. Puoi provare questo in Water.js:
material.depthTest = false;
Vedrai come l'acqua sarà sempre visualizzata sopra, anche se si trova dietro oggetti opachi.
Aggiungiamo un modo per visualizzare il buffer di profondità a scopo di debug. Crea un nuovo script chiamato DepthVisualize.js. Attaccalo alla tua fotocamera.
Tutto ciò che dobbiamo fare per ottenere l'accesso al buffer di profondità in PlayCanvas è quello di dire:
this.entity.camera.camera.requestDepthMap ();
In questo modo verrà automaticamente iniettata un'uniforme in tutti i nostri ombreggiatori che possiamo usare dichiarandola come:
uniforme sampler2D uDepthMap;
Di seguito è riportato uno script di esempio che richiede la mappa di profondità e la rende visibile in cima alla scena. È impostato per il caricamento a caldo.
var DepthVisualize = pc.createScript ('depthVisualize'); // inizializza il codice chiamato una volta per entità DepthVisualize.prototype.initialize = function () this.entity.camera.camera.requestDepthMap (); this.antiCacheCount = 0; // Per impedire al motore di memorizzare nella cache il nostro shader in modo da poterlo aggiornare dal vivo this.SetupDepthViz (); ; DepthVisualize.prototype.SetupDepthViz = function () var device = this.app.graphicsDevice; var chunks = pc.shaderChunks; this.fs = "; this.fs + = 'variando vec2 vUv0;'; this.fs + = 'uniforme 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 + = 'profondità di ritorno;'; 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 (profondità), 1.0); '; this.fs + =' '; this.shader = chunks.createShaderFromCode (dispositivo, chunks.fullscreenQuadVS, this.fs, "renderDepth" + this.antiCacheCount); this.antiCacheCount ++; // Creiamo manualmente una chiamata di disegno per rendere la mappa di profondità in cima a tutto this.command = new pc.Command (pc.LAYER_FX, pc.BLEND_NONE, function () pc.drawQuadWithShader (device, null, this.shader); .bind (this)); this.command.isDepthViz = true; // Contrassegnalo per poterlo rimuovere in seguito this.app.scene.drawCalls.push (this.command); ; // codice di aggiornamento chiamato ogni frame DepthVisualize.prototype.update = function (dt) ; // metodo di swap chiamato per lo script hot-reloading // eredita lo stato del tuo script qui DepthVisualize.prototype.swap = function (old) this .antiCacheCount = old.antiCacheCount; // Rimuove la richiesta di estrazione di profondità per (var i = 0; iProva a copiare in, e commentare / decommentare la linea
this.app.scene.drawCalls.push (this.command);
per alternare il rendering di profondità. Dovrebbe assomigliare all'immagine qui sotto.Sfida n. 3: la superficie dell'acqua non viene trascinata nel buffer di profondità. Il motore PlayCanvas lo fa intenzionalmente. Puoi capire perché? Cosa c'è di speciale nel materiale dell'acqua? Per dirla in un altro modo, in base alle nostre regole di controllo approfondito, cosa succederebbe se i pixel dell'acqua scrivessero nel buffer di profondità?Suggerimento: c'è una riga che puoi modificare in Water.js che farà sì che l'acqua venga scritta nel buffer di profondità.
Un'altra cosa da notare è che moltiplico il valore di profondità di 30 nello shader incorporato nella funzione di inizializzazione. Questo è solo per poterlo vedere chiaramente, perché altrimenti l'intervallo di valori è troppo piccolo per essere visto come sfumature di colore.
Implementare il trucco
Il motore di PlayCanvas include una serie di funzioni di supporto per lavorare con i valori di profondità, ma al momento della scrittura non vengono rilasciati in produzione, quindi li installeremo da soli.
Definire le seguenti uniformi a Water.frag:
// Queste uniformi sono tutte iniettate automaticamente da PlayCanvas uniforme sampler2D uDepthMap; uniforme vec4 uScreenSize; Mat4_view mat4 uniforme; // Dobbiamo impostare questo noi stessi vec4 camera_param uniforme;Definisci queste funzioni di supporto sopra la funzione principale:
#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); punto di ritorno (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);Passa alcune informazioni sulla fotocamera allo shader in Water.js. Metti questo dove passi altre uniformi come uTime:
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);Infine, abbiamo bisogno della posizione del mondo per ogni pixel nel nostro shader di frag. Dobbiamo ottenere questo dal vertex shader. Quindi definire un variare in Water.frag:
variando vec3 WorldPosition;Definire la stessa variazione in Water.vert. Quindi impostalo sulla posizione distorta nel vertex shader, quindi il codice completo sarà simile a:
attributo vec3 aPosition; attributo vec2 aUv0; variando vec2 vUv0; variando vec3 WorldPosition; uniforme mat4_model mat4; uniforme mat4_viewProjection mat4; uniforme 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;In realtà implementando il trucco
Ora siamo finalmente pronti per implementare la tecnica descritta all'inizio di questa sezione. Vogliamo confrontare la profondità del pixel in cui ci troviamo alla profondità del pixel dietro di esso. Il pixel che proviamo proviene dalla posizione del mondo e il pixel dietro viene dalla posizione dello schermo. Quindi prendi queste due profondità:
float worldDepth = getLinearDepth (WorldPosition); float screenDepth = getLinearScreenDepth ();Sfida n. 4: uno di questi valori non sarà mai più grande dell'altro (supponendo depthTest = true). Puoi dedurre quale?Sappiamo che la schiuma sarà dove la distanza tra questi due valori è piccola. Quindi rendiamo tale differenza ad ogni pixel. Metti questo nella parte inferiore dello shader (e assicurati che lo script di visualizzazione di profondità della sezione precedente sia disattivato):
color = vec4 (vec3 (screenDepth - worldDepth), 1.0); gl_FragColor = color;Quale dovrebbe assomigliare a questo:
Che individua correttamente i bordi di qualsiasi oggetto immerso nell'acqua in tempo reale! Ovviamente è possibile ridimensionare questa differenza per renderla più spessa / più sottile.
Ora ci sono molti modi in cui puoi combinare questa uscita con il colore della superficie dell'acqua per ottenere linee di schiuma dall'aspetto gradevole. Puoi tenerlo come una sfumatura, usarlo per campionare da un'altra texture, o impostarlo su un colore specifico se la differenza è inferiore o uguale a qualche soglia.
Il mio aspetto preferito è impostarlo su un colore simile a quello delle linee d'acqua statiche, quindi la mia funzione principale finale è la seguente:
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;Sommario
Abbiamo creato galleggiabilità su oggetti galleggianti nell'acqua, abbiamo dato alla nostra superficie una texture in movimento per simulare le caustiche, e abbiamo visto come potevamo usare il depth buffer per creare linee di schiuma dinamica.
Per finire, la prossima e ultima parte introdurrà effetti post-process e come usarli per creare l'effetto di distorsione subacquea.
Codice sorgente
Qui puoi trovare il progetto finito PlayCanvas ospitato. Una porta Three.js è anche disponibile in questo repository.