Nel mio Beginner's Guide to Shaders mi sono concentrato esclusivamente sugli shader di frammenti, il che è sufficiente per qualsiasi effetto 2D e ogni esempio di ShaderToy. Ma c'è un'intera categoria di tecniche che richiedono vertex shader. Questo tutorial ti guiderà attraverso la creazione di toon stilizzati e l'introduzione di vertex shader. Introdurrò anche il buffer di profondità e come usarlo per ottenere maggiori informazioni sulla scena e creare linee di schiuma.
Ecco come dovrebbe essere l'effetto finale. Puoi provare una demo live qui (con il mouse sinistro in orbita, con il tasto destro del mouse per scorrere, la rotella per lo zoom).
In particolare, questo effetto è composto da:
Quello che mi piace di questo effetto è che tocca molti concetti diversi nella computer grafica, quindi ci permetterà di attingere idee dai tutorial passati, oltre a sviluppare tecniche che possiamo usare per una varietà di effetti futuri.
Userò PlayCanvas solo perché ha un IDE gratuito basato sul Web, ma tutto dovrebbe essere applicabile a qualsiasi ambiente che esegue WebGL. Alla fine puoi trovare una versione di Three.js del codice sorgente. Partirò dal presupposto che tu stia usando gli shader dei frammenti e navigando nell'interfaccia di PlayCanvas. Qui puoi rispolverare gli shader e sfogliare un'introduzione su PlayCanvas qui.
L'obiettivo di questa sezione è quello di impostare il nostro progetto PlayCanvas e posizionare alcuni oggetti ambientali per testare l'acqua contro.
Se non hai già un account con PlayCanvas, registrane uno e creane uno nuovo progetto vuoto. Per impostazione predefinita, dovresti avere un paio di oggetti, una fotocamera e una luce nella scena.
Il progetto Poly di Google è una risorsa davvero eccezionale per i modelli 3D per il Web. Ecco il modello di barca che ho usato. Una volta scaricato e decompresso, dovresti trovare a .obj
e a .png
file.
.png
file.Ora puoi trascinare il Tugboat.json nella tua scena ed elimina gli oggetti Box e Plane. Puoi scalare la barca se sembra troppo piccola (ho impostato la mia a 50).
Puoi aggiungere altri modelli alla tua scena allo stesso modo.
Per impostare una telecamera orbita, copieremo uno script da questo esempio di PlayCanvas. Vai a quel link e clicca su editore per inserire il progetto.
mouse-input.js
e orbita-camera.js
da quel progetto tutorial nei file con lo stesso nome nel tuo progetto.Suggerimento: puoi creare cartelle nella finestra dell'asset per mantenere le cose organizzate. Ho inserito questi due script di fotocamera in Script / Fotocamera /, il mio modello in Modelli / e il mio materiale in Materiali /.
Ora, quando avvii il gioco (pulsante di riproduzione in alto a destra nella vista scena), dovresti essere in grado di vedere la tua barca e orbitare attorno ad esso con il mouse.
L'obiettivo di questa sezione è generare una mesh suddivisa da utilizzare come superficie dell'acqua.
Per generare la superficie dell'acqua, adatteremo del codice da questo tutorial di generazione del terreno. Crea un nuovo file di script chiamato Water.js
. Modifica questo script e crea una nuova funzione chiamata GeneratePlaneMesh
che assomiglia a questo:
Water.prototype.GeneratePlaneMesh = function (options) // 1 - Imposta le opzioni predefinite se non ne viene fornita nessuna se (opzioni === indefinito) options = suddivisioni: 100, width: 10, height: 10; // 2 - Genera punti, uv e indici var posizioni = []; var uvs = []; indici var = []; var row, col; var normali; per (riga = 0; riga <= options.subdivisions; row++) for (col = 0; col <= options.subdivisions; col++) var position = new pc.Vec3((col * options.width) / options.subdivisions - (options.width / 2.0), 0, ((options.subdivisions - row) * options.height) / options.subdivisions - (options.height / 2.0)); positions.push(position.x, position.y, position.z); uvs.push(col / options.subdivisions, 1.0 - row / options.subdivisions); for (row = 0; row < options.subdivisions; row++) for (col = 0; col < options.subdivisions; col++) indices.push(col + row * (options.subdivisions + 1)); indices.push(col + 1 + row * (options.subdivisions + 1)); indices.push(col + 1 + (row + 1) * (options.subdivisions + 1)); indices.push(col + row * (options.subdivisions + 1)); indices.push(col + 1 + (row + 1) * (options.subdivisions + 1)); indices.push(col + (row + 1) * (options.subdivisions + 1)); // Compute the normals normals = pc.calculateNormals(positions, indices); // Make the actual model var node = new pc.GraphNode(); var material = new pc.StandardMaterial(); // Create the mesh var mesh = pc.createMesh(this.app.graphicsDevice, positions, normals: normals, uvs: uvs, indices: indices ); var meshInstance = new pc.MeshInstance(node, mesh, material); // Add it to this entity var model = new pc.Model(); model.graph = node; model.meshInstances.push(meshInstance); this.entity.addComponent('model'); this.entity.model.model = model; this.entity.model.castShadows = false; // We don't want the water surface itself to cast a shadow ;
Ora puoi chiamare questo nel inizializzare
funzione:
Water.prototype.initialize = function () this.GeneratePlaneMesh (suddivisioni: 100, larghezza: 10, altezza: 10); ;
Dovresti vedere solo un piano quando avvii il gioco ora. Ma questo non è solo un piano. È una maglia composta da mille vertici. Come sfida, prova a verificarlo (è una buona scusa per leggere il codice che hai appena copiato).
Sfida n. 1: Disporre la coordinata Y di ogni vertice di una quantità casuale per far sì che l'aereo assomigli all'immagine sottostante.
L'obiettivo di questa sezione è di dare alla superficie dell'acqua un materiale personalizzato e creare onde animate.
Per ottenere gli effetti desiderati, abbiamo bisogno di creare un materiale personalizzato. La maggior parte dei motori 3D avrà degli shader predefiniti per il rendering degli oggetti e un modo per sovrascriverli. Ecco un buon riferimento per farlo in PlayCanvas.
Creiamo una nuova funzione chiamata CreateWaterMaterial
che definisce un nuovo materiale con uno shader personalizzato e lo restituisce:
Water.prototype.CreateWaterMaterial = function () // Crea un nuovo materiale vuoto var material = new pc.Material (); // Un nome semplifica l'identificazione quando si esegue il debug di material.name = "DynamicWater_Material"; // Crea la definizione dello shader // imposta dinamicamente la precisione in base al dispositivo. var gd = this.app.graphicsDevice; var fragmentShader = "precision" + gd.precision + "float; \ n"; fragmentShader = fragmentShader + this.fs.resource; var vertexShader = this.vs.resource; // Una definizione di shader utilizzata per creare un nuovo shader. var shaderDefinition = attributes: aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0,, vshader: vertexShader, fshader: fragmentShader; // Crea lo shader dalla definizione this.shader = new pc.Shader (gd, shaderDefinition); // Applica lo shader a questo materiale material.setShader (this.shader); restituire materiale; ;
Questa funzione acquisisce il codice shader dei vertici e dei frammenti dagli attributi dello script. Quindi definiamo quelli nella parte superiore del file (dopo il pc.createScript
linea):
Water.attributes.add ('vs', type: 'asset', assetType: 'shader', titolo: 'Vertex Shader'); Water.attributes.add ('fs', type: 'asset', assetType: 'shader', titolo: 'Fragment Shader');
Ora possiamo creare questi file shader e collegarli al nostro script. Torna all'editor e crea due nuovi file shader: Water.frag e Water.vert. Collega questi shader allo script come mostrato di seguito.
Se i nuovi attributi non vengono visualizzati nell'editor, fare clic su analizzare pulsante per aggiornare lo script.
Ora metti questo shader di base in Water.frag:
void main (void) vec4 color = vec4 (0.0,0.0,1.0,0.5); gl_FragColor = color;
E questo dentro Water.vert:
attributo vec3 aPosition; uniforme mat4_model mat4; uniforme mat4_viewProjection mat4; void main (void) gl_Position = matrix_viewProjection * matrix_model * vec4 (aPosition, 1.0);
Finalmente, torna a Water.js e fagli usare il nostro nuovo materiale personalizzato al posto del materiale standard. Quindi, invece di:
var material = new pc.StandardMaterial ();
Fare:
var material = this.CreateWaterMaterial ();
Ora, se avvii il gioco, l'aereo dovrebbe essere blu.
Finora, abbiamo appena creato alcuni dummy shader sul nostro nuovo materiale. Prima di scrivere gli effetti reali, un'ultima cosa che voglio impostare è il ricaricamento automatico del codice.
Scomporre il scambiare
funzione in qualsiasi file di script (come Water.js) abilita il caricamento a caldo. Vedremo come utilizzarlo in seguito per mantenere lo stato anche mentre aggiorniamo il codice in tempo reale. Ma per ora vogliamo solo riapplicare gli shader una volta rilevato un cambiamento. Gli shader vengono compilati prima di essere eseguiti in WebGL, quindi sarà necessario ricreare il materiale personalizzato per attivarlo.
Verificheremo se il contenuto del nostro codice shader è stato aggiornato e, in tal caso, ricreare il materiale. Innanzitutto, salva gli shader correnti nel inizializzare:
// inizializza il codice chiamato una volta per entità Water.prototype.initialize = function () this.GeneratePlaneMesh (); // Salva gli shader correnti this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; ;
E nel aggiornare, controlla se ci sono state modifiche:
// codice di aggiornamento chiamato ogni frame Water.prototype.update = function (dt) if (this.savedFS! = this.fs.resource || this.savedVS! = this.vs.resource) // Ricreare il materiale in modo che gli shader possano essere ricompilati var newMaterial = this.CreateWaterMaterial (); // Applicalo al modello var model = this.entity.model.model; model.meshInstances [0] .material = newMaterial; // Salva i nuovi shader this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; ;
Ora, per confermare ciò, avvia il gioco e cambia il colore dell'aereo Water.frag ad un blu più raffinato. Una volta salvato, il file dovrebbe essere aggiornato senza dover aggiornare o riavviare! Questo era il colore che ho scelto:
vec4 color = vec4 (0.0.0.7,1.0,0.5);
Per creare le onde, dobbiamo spostare ogni vertice nella nostra mesh ogni fotogramma. Sembra come se fosse molto inefficiente, ma ogni vertice di ogni modello viene già trasformato su ogni fotogramma che rendiamo. Questo è ciò che fa il vertex shader.
Se si pensa a un framment shader come una funzione che gira su ogni pixel, prende una posizione e restituisce un colore, quindi un vertex shader è una funzione che gira su ogni vertice, prende una posizione e restituisce una posizione.
Il vertex shader predefinito prenderà il posizione del mondo di un determinato modello e restituire il posizione dello schermo. La nostra scena 3D è definita in termini di x, yez, ma il monitor è un piano bidimensionale piatto, quindi proiettiamo il nostro mondo 3D sul nostro schermo 2D. Questa proiezione è ciò che la visualizzazione, la proiezione e le matrici dei modelli si prendono cura di e non rientra nell'ambito di questo tutorial, ma se vuoi imparare esattamente cosa succede in questo passaggio, ecco una guida molto bella.
Quindi questa linea:
gl_Position = matrix_viewProjection * matrix_model * vec4 (aPosition, 1.0);
Prende una posizione
come la posizione del mondo 3D di un particolare vertice e lo trasforma in GL_POSITION
, che è la posizione finale dello schermo 2D. Il prefisso 'a' su aPosition significa che questo valore è un attributo. Ricorda che a uniformevariable è un valore che possiamo definire sulla CPU per passare a uno shader che mantiene lo stesso valore su tutti i pixel / vertici. Il valore di un attributo, d'altra parte, deriva da un schieramento definito sulla CPU. Il vertex shader viene chiamato una volta per ogni valore in quella matrice di attributi.
Puoi vedere che questi attributi sono impostati nella definizione shader che abbiamo configurato in Water.js:
var shaderDefinition = attributes: aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0,, vshader: vertexShader, fshader: fragmentShader;
PlayCanvas si occupa di impostare e passare una serie di posizioni dei vertici per una posizione
quando passiamo questo enum, ma in generale è possibile passare qualsiasi matrice di dati al vertex shader.
Diciamo che vuoi schiacciare l'aereo moltiplicando tutto X
valori della metà. Dovresti cambiare una posizione
o GL_POSITION
?
Proviamo una posizione
primo. Non possiamo modificare direttamente un attributo, ma possiamo fare una copia:
attributo vec3 aPosition; uniforme mat4_model mat4; uniforme mat4_viewProjection mat4; void main (void) vec3 pos = aPosition; pos.x * = 0,5; gl_Position = matrix_viewProjection * matrix_model * vec4 (pos, 1.0);
L'aereo dovrebbe ora sembrare più rettangolare. Niente di strano lì. Ora cosa succede se invece proviamo a modificare GL_POSITION
?
attributo vec3 aPosition; uniforme mat4_model mat4; uniforme mat4_viewProjection mat4; void main (void) vec3 pos = aPosition; //pos.x * = 0.5; gl_Position = matrix_viewProjection * matrix_model * vec4 (pos, 1.0); gl_Position.x * = 0,5;
Potrebbe sembrare lo stesso fino a quando non inizi a ruotare la fotocamera. Stiamo modificando le coordinate dello spazio dello schermo, il che significa che avrà un aspetto diverso a seconda di come lo stai guardando.
Ecco come puoi spostare i vertici, ed è importante fare questa distinzione tra se ti trovi nello spazio del mondo o dello schermo.
Sfida n. 2: puoi spostare l'intera superficie del piano di alcune unità (lungo l'asse Y) nel vertex shader senza distorcerne la forma?
Sfida n. 3: Ho detto che gl_Position è 2D, ma gl_Position.z esiste. Puoi eseguire alcuni test per determinare se questo valore influisce su qualcosa, e in tal caso, a cosa serve?
Un'ultima cosa di cui abbiamo bisogno prima di poter creare le onde in movimento è una variabile uniforme da usare come tempo. Dichiara un'uniforme nel tuo vertex shader:
uniforme float uTime;
Quindi, per passare questo al nostro shader, torna indietro Water.js e definire una variabile temporale nell'inizializzazione:
Water.prototype.initialize = function () this.time = 0; ///// Prima definisci l'ora qui this.GeneratePlaneMesh (); // Salva gli shader correnti this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; ;
Ora, per passare questo al nostro shader, usiamo material.setParameter
. Per prima cosa impostiamo un valore iniziale alla fine del CreateWaterMaterial
funzione:
// Crea lo shader dalla definizione this.shader = new pc.Shader (gd, shaderDefinition); ////////////// La nuova parte material.setParameter ('uTime', this.time); questo materiale = materiale; // Salva un riferimento a questo materiale //////////////// // Applica lo shader a questo materiale material.setShader (this.shader); restituire materiale;
Ora nel aggiornare
funzione possiamo incrementare il tempo e accedere al materiale usando il riferimento che abbiamo creato per esso:
this.time + = 0,1; this.material.setParameter ( 'utimo', this.time);
Come passaggio finale, nella funzione di scambio, copia il vecchio valore del tempo, in modo che anche se cambi il codice, continuerà ad aumentare senza reimpostare a 0.
Water.prototype.swap = function (old) this.time = old.time; ;
Ora tutto è pronto. Avvia il gioco per assicurarti che non ci siano errori. Ora muoviamo il nostro aereo in base a una funzione del tempo Water.vert
:
pos.y + = cos (uTime)
E il tuo aereo dovrebbe muoversi su e giù ora! Poiché ora abbiamo una funzione di scambio, puoi anche aggiornare Water.js senza dover riavviare. Prova a incrementare il tempo più velocemente o più lentamente per confermare che funzioni.
Sfida n. 4: puoi spostare i vertici in modo che assomiglino all'onda sottostante?
Come suggerimento, ho parlato in profondità di diversi modi per creare onde qui. Era in 2D, ma la stessa matematica si applica qui. Se preferisci dare un'occhiata alla soluzione, ecco il succo.
L'obiettivo di questa sezione è di rendere la superficie dell'acqua traslucida.
Potresti aver notato che il colore che stiamo restituendo in Water.frag ha un valore alfa di 0,5, ma la superficie è ancora completamente opaca. La trasparenza in molti modi è ancora un problema aperto nella computer grafica. Un modo economico per realizzarlo è utilizzare la fusione.
Normalmente, quando un pixel sta per essere disegnato, controlla il valore nel file buffer di profondità contro il proprio valore di profondità (la sua posizione lungo l'asse Z) per determinare se sovrascrivere il pixel corrente sullo schermo o scartare se stesso. Questo è ciò che ti permette di renderizzare una scena correttamente senza dover riordinare gli oggetti in primo piano.
Con la fusione, invece di scartare o sovrascrivere semplicemente, possiamo combinare il colore del pixel che è già disegnato (la destinazione) con il pixel che sta per essere disegnato (la fonte). Qui puoi vedere tutte le funzioni di fusione disponibili in WebGL.
Per fare in modo che l'alfa funzioni come ci aspettiamo, vogliamo che il colore combinato del risultato sia la sorgente moltiplicata per l'alfa più la destinazione moltiplicata per uno meno l'alfa. In altre parole, se l'alfa è 0.4, il colore finale dovrebbe essere:
finalColor = source * 0.4 + destination * 0.6;
In PlayCanvas, l'opzione pc.BLEND_NORMAL fa esattamente questo.
Per abilitare questo, basta impostare la proprietà sul materiale all'interno CreateWaterMaterial
:
material.blendType = pc.BLEND_NORMAL;
Se lanci il gioco ora, l'acqua sarà traslucida! Questo non è perfetto, però. Un problema sorge se la superficie traslucida si sovrappone a se stessa, come mostrato di seguito.
Possiamo risolvere questo problema usando da alfa a copertura, che è una tecnica multi-campionamento per ottenere trasparenzainvece di miscelare:
//material.blendType = pc.BLEND_NORMAL; material.alphaToCoverage = true;
Ma questo è disponibile solo in WebGL 2. Per il resto di questo tutorial, userò il blending per mantenerlo semplice.
Finora abbiamo impostato il nostro ambiente e creato la nostra superficie d'acqua traslucida con onde animate dal nostro vertex shader. La seconda parte riguarderà l'applicazione della galleggiabilità sugli oggetti, l'aggiunta di linee d'acqua alla superficie e la creazione delle linee di schiuma attorno ai bordi degli oggetti che intersecano la superficie.
La parte finale riguarderà l'applicazione dell'effetto distorsivo post-processo subacqueo e alcune idee su dove andare dopo.
Qui puoi trovare il progetto finito PlayCanvas ospitato. Una porta Three.js è anche disponibile in questo repository.