Introduzione
In precedenza ho fornito un'introduzione a Three.js. Se non lo hai ancora fatto, ti consiglio di leggere l'articolo, in quanto costituisce la base su cui si baserà questo articolo.
Voglio parlare degli shader. WebGL è fantastico e, come ho già detto, Three.js (e altre librerie) fanno un ottimo lavoro di astrazione delle difficoltà. Tuttavia, a volte vorrai ottenere un effetto specifico o approfondire un po' il modo in cui queste fantastiche funzionalità vengono visualizzate sullo schermo e gli shader faranno quasi sicuramente parte di questa equazione. Inoltre, se sei come me, potresti voler passare dalle nozioni di base dell'ultimo tutorial a qualcosa di un po' più complicato. Lavorerò basandoci sul fatto che utilizzi Three.js, che svolge per noi una parte del lavoro d'asino. Ti anticipo anche che all'inizio spiegherò il contesto degli shader e che nella seconda parte di questo tutorial entreremo in un ambito leggermente più avanzato. Il motivo è che gli shader sono insoliti a prima vista e richiedono un po' di spiegazione.
1. I nostri due shader
WebGL non offre l'utilizzo della pipeline fissa, il che significa che non offre alcun mezzo per eseguire il rendering dei contenuti out-of-the-box. Tuttavia, offre la pipeline programmabile, che è più potente, ma anche più difficile da comprendere e utilizzare. In breve, Pipeline programmabile significa, in qualità di programmatore, che ti assumi la responsabilità di visualizzare sullo schermo i vertici e così via. Gli Shader fanno parte di questa pipeline e ne esistono due tipi:
- Vertex shader
- Ombreggiatori di frammenti
Entrambi, sono sicuro che sarai d'accordo, non significano assolutamente nulla da soli. Ciò che devi sapere è che entrambi funzionano interamente sulla GPU della tua scheda grafica. Ciò significa che vogliamo scaricare su di loro tutto il possibile, lasciando alla CPU il compito di svolgere altri lavori. Una GPU moderna è altamente ottimizzata per le funzioni richieste dagli shader, quindi è fantastico poterla utilizzare.
2. Vertex shader
Prendi una forma primitiva standard, ad esempio una sfera. È composto da vertici, giusto? A un vertex shader viene assegnato ogni singolo vertice in modo da poterli manipolare. Spetta allo shader vertex decidere cosa fare con ciascuno, ma ha una responsabilità: a un certo punto deve impostare qualcosa chiamato gl_Position, un vettore float 4D, che è la posizione finale del vertice sullo schermo. Questo è un processo piuttosto interessante di per sé, perché in realtà stiamo parlando dell'ottenimento di una posizione 3D (un vertice con x,y,z) su uno schermo 2D, o proiezione, su uno schermo 2D. Fortunatamente, se utilizziamo qualcosa come Three.js, avremo un modo abbreviato per impostare gl_Position senza appesantire troppo il codice.
3. Shader di frammenti
Abbiamo l'oggetto con i suoi vertici e li abbiamo proiettato sullo schermo 2D, ma cosa succede con i colori che usiamo? E le texture e l'illuminazione? Questo è esattamente lo scopo dello Shader dei frammenti. Molto simile allo shader vertex, anche lo shader fragment ha un solo compito obbligatorio: deve impostare o ignorare la variabile gl_FragColor, un altro vettore float 4D, che è il colore finale del nostro frammento. Ma che cos'è un frammento? Pensa a tre vertici che formano un triangolo. Ogni pixel all'interno del triangolo deve essere disegnato. Un frammento è costituito dai dati forniti da questi tre vertici allo scopo di disegnare ogni pixel del triangolo. Per questo motivo, i frammenti ricevono valori interpolati dai vertici che ne fanno parte. Se un vertice è di colore rosso e il suo vicino è blu, i valori del colore si interpolano dal rosso, al viola, al blu.
4. Variabili Shader
Quando parliamo di variabili, puoi fare tre dichiarazioni: Uniformi, Attributi e Variabili. Quando ho sentito parlare per la prima volta di questi tre, ero molto confuso perché non corrispondevano a nulla con cui avevo mai lavorato. Ecco come puoi utilizzarli:
Le uniformi vengono inviate sia ai shader vertex sia agli shader fragment e contengono valori che rimangono invariati nell'intero frame sottoposto a rendering. Un buon esempio potrebbe essere la posizione di una luce.
Gli attributi sono valori applicati ai singoli vertici. Gli attributi sono disponibili solo per vertex shaker. Ad esempio, ogni vertice potrebbe avere un colore distinto. Gli attributi hanno una relazione one-to-one con i vertici.
Le variabili Varying sono variabili dichiarate in vertex shaker che vogliamo condividere con lo shaker dei frammenti. A questo scopo, ci assicuriamo di dichiarare una variabile variabile dello stesso tipo e nome sia in vertex shaker che in quello per frammenti. Un uso classico è la normale di un vertice, poiché può essere utilizzata nei calcoli di illuminazione.
In seguito utilizzeremo tutti e tre i tipi per farti capire come vengono applicati nella realtà.
Ora che abbiamo parlato di shader vertex e shader fragment e dei tipi di variabili con cui si occupano, è il momento di esaminare gli shader più semplici che possiamo creare.
5. Bonjourno World
Qui c'è l'Hello World di Vertex Shader:
/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(position,1.0);
}
Ed ecco lo stesso per il frammento shader:
/**
* Set the colour to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}
Non troppo complicato, giusto?
In Vertex Shader ci hanno inviato un paio di uniformi da Three.js. Queste due uniformi sono matrici 4D, chiamate matrice modello-vista e matrice di proiezione. Non è necessario conoscere esattamente come funzionano, anche se è sempre meglio capire come funzionano le cose, se possibile. In breve, indicano in che modo la posizione 3D del vertice viene effettivamente proiettata sulla posizione 2D finale sullo schermo.
In realtà li ho omessi dallo snippet qui sopra perché Three.js li aggiunge all'inizio del codice dello shader, quindi non devi preoccuparti di farlo. A dire il vero, aggiunge molto di più, ad esempio dati sulle luci, colori dei vertici e normali dei vertici. Se stessi facendo questo senza Three.js, avresti dovuto creare e impostare personalmente tutte quelle uniformi e gli attributi. È una storia vera.
6. Utilizzo di un materiale MeshShader
Bene, abbiamo configurato uno shader, ma come lo utilizziamo con Three.js? In realtà è molto semplice. È più o meno così:
/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader: $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});
A questo punto, Three.js compilerà ed eseguirà i tuoi shaker allegati alla mesh a cui invii il materiale. Non c'è molto più facile di così. Probabilmente sì, ma stiamo parlando dell'esecuzione 3D nel tuo browser, quindi immagino che ti aspetti una certa complessità.
Possiamo aggiungere altre due proprietà a MeshShaderMaterial: uniformi e attributi. Possono accettare vettori, interi o valori float, ma come accennato prima, le uniformi sono uguali per l'intero frame, ovvero per tutti i vertici, quindi tendono a essere singoli valori. Gli attributi, invece, sono variabili per vertice, pertanto devono essere un array. Deve esistere una relazione uno a uno tra il numero di valori nell'array di attributi e il numero di vertici nel mesh.
7. Passaggi successivi
Ora ci occuperemo di aggiungere un ciclo di animazione, attributi dei vertici e una uniforme. Aggiungeremo anche una variabile variabile in modo che vertex shaker possa inviare alcuni dati. Il risultato finale è che la nostra sfera, che era rosa, sembrerà illuminata dall'alto e di lato e inizierà a pulsare. È un po' complicato, ma spero che ti aiuti a comprendere bene i tre tipi di variabili, nonché il loro rapporto tra loro e con la geometria di base.
8. Una luce falsa
Aggiorniamo la colorazione in modo che non sia un oggetto a colori piatti. Potremmo dare un'occhiata a come Three.js gestisce l'illuminazione, ma sicuramente avrete notato che la sua elaborazione è più complessa di quanto ci serva in questo momento, quindi facciamo finta di farlo. Ti consiglio di dare un'occhiata ai fantastici shader che fanno parte di Three.js, nonché a quelli del recente e straordinario progetto WebGL di Chris Milk e Google, Rome. Torniamo ai nostri shader. Aggiorneremo il nostro Vertex Shader per fornire ogni vertice normale allo Shader di frammenti. Lo facciamo con:
// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;
void main() {
// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(position,1.0);
}
e nel Shader frammento imposteremo lo stesso nome di variabile e poi utilizzeremo il prodotto scalare della normale del vertice con un vettore che rappresenta una luce che illumina dall'alto e a destra della sfera. Il risultato netto è un effetto simile a una luce direzionale in un pacchetto 3D.
// same name and type as VS
varying vec3 vNormal;
void main() {
// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
// ensure it's normalized
light = normalize(light);
// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));
// feed into our frag colour
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);
}
Il prodotto scalare funziona perché, dati due vettori, genera un numero che indica quanto sono "simili" i due vettori. Con i vettori normalizzati, se puntano esattamente nella stessa direzione, ottieni un valore pari a 1. Se puntano in direzioni opposte, ottieni un valore -1. Quello che facciamo è prendere questo numero e applicarlo all'illuminazione. Pertanto, un vertice in alto a destra avrà un valore vicino o uguale a 1, ovvero completamente illuminato, mentre un vertice sul lato avrà un valore vicino a 0 e sul retro sarà -1. Fissiamo il valore su 0 per qualsiasi cosa negativa, ma quando colleghi i numeri ottieni l'illuminazione di base che stiamo vedendo.
Passaggi successivi Sarebbe bello provare a mettere in discussione alcune posizioni dei vertici.
9. Attributi
Ora vorrei che assegnassimo un numero casuale a ogni vertice tramite un attributo. Utilizzeremo questo numero per spingere il vertice verso l'esterno lungo la sua normale. Il risultato finale sarà una strana sfera a punta che cambia ogni volta che aggiorni la pagina. Per il momento non sarà ancora animato (lo sarà dopo), ma dopo alcuni aggiornamenti della pagina vedrai che è casuale.
Iniziamo aggiungendo l'attributo allo streamr Vertex:
attribute float displacement;
varying vec3 vNormal;
void main() {
vNormal = normal;
// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position +
normal *
vec3(displacement);
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(newPosition,1.0);
}
Che aspetto ha?
Non molto diverso, in realtà! Questo accade perché l'attributo non è stato configurato in MeshShaderMaterial, quindi lo shader utilizza un valore zero. È un po' come un segnaposto al momento. A breve aggiungeremo l'attributo al MeshShaderMaterial in JavaScript e Three.js li collegherà automaticamente.
Da notare anche il fatto che ho dovuto assegnare la posizione aggiornata a una nuova variabile vec3 perché l'attributo originale, come tutti gli attributi, è di sola lettura.
10. Aggiornamento di MeshShaderMaterial
Passiamo subito all'aggiornamento di MeshShaderMaterial con l'attributo necessario per il nostro spostamento. Promemoria: gli attributi sono valori per vertice, quindi nella nostra sfera è necessario un valore per vertice. Esempio:
var attributes = {
displacement: {
type: 'f', // a float
value: [] // an empty array
}
};
// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes: attributes,
vertexShader: $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});
// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}
Ora vediamo una sfera distorta, ma la cosa fantastica è che tutto lo spostamento avviene sulla GPU.
11. Animare quel coglione
Dovremmo assolutamente animarlo. Come facciamo? Dobbiamo fare due cose:
- Un'uniforme per animare l'entità dello spostamento da applicare in ogni fotogramma. Possiamo utilizzare il seno o il coseno perché vanno da -1 a 1
- Un loop di animazione in JS
Aggiungeremo l'uniforme sia al MeshShaderMaterial sia al Vertex Shader. Innanzitutto lo shader vertex:
uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;
void main() {
vNormal = normal;
// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position +
normal *
vec3(displacement *
amplitude);
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(newPosition,1.0);
}
Aggiorniamo quindi MeshShaderMaterial:
// add a uniform for the amplitude
var uniforms = {
amplitude: {
type: 'f', // a float
value: 0
}
};
// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms: uniforms,
attributes: attributes,
vertexShader: $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});
Per il momento i nostri shader sono stati completati. Ma a destra sembreremmo aver fatto un passo indietro. Questo accade in gran parte perché il valore dell'ampiezza è pari a 0 e, poiché lo moltiplichiamo per lo spostamento, non vediamo alcuna variazione. Inoltre, non abbiamo configurato il loop di animazione, quindi non vediamo mai il valore 0 cambiare in altro modo.
Nel nostro codice JavaScript ora dobbiamo racchiudere la chiamata di rendering in una funzione e poi utilizzare requestAnimationFrame per chiamarla. Qui dobbiamo anche aggiornare il valore dell'uniforme.
var frame = 0;
function update() {
// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;
renderer.render(scene, camera);
// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);
12. Conclusione
È tutto. Ora puoi vedere che si anima in modo strano (e leggermente psichedelico).
C'è molto altro da dire sugli shader, ma spero che questa introduzione ti sia stata utile. Ora dovresti essere in grado di capire gli streamr quando li vedi e avere la certezza di poter creare dei tuoi Shaker straordinari.