Introduzione
Leggi prima l'introduzione a Three.js, perché è la base di questo documento.
Quello che voglio fare è parlare di shader. WebGL è fantastico e, come ho già detto, Three.js (e altre librerie) fanno un ottimo lavoro di astrazione delle difficoltà per te. Ma a volte vorrai ottenere un effetto specifico o approfondire un po' come sono apparsi sullo schermo questi effetti straordinari e gli shader faranno quasi sicuramente parte dell'equazione. Potresti passare dalle nozioni di base dell'ultimo tutorial a qualcosa di un po' più complesso.
Partirò dal presupposto che utilizzi Three.js, poiché ci semplifica molto il lavoro per quanto riguarda l'avvio dello shader. Inizierai con il contesto degli shader, per poi passare ai passaggi avanzati più avanti in questo documento.
1. Le nostre due tonalità
WebGL non offre l'utilizzo della pipeline fissa, che è un modo abbreviato per dire che non ti offre alcun mezzo per eseguire il rendering dei tuoi contenuti out-of-the-box. Offre invece la pipeline programmabile, che è più potente ma anche più difficile da capire e utilizzare. In breve, la pipeline programmabile significa che, in qualità di programmatore, ti assumi la responsabilità di visualizzare i vertici e così via sullo schermo. Gli shader fanno parte di questa pipeline e ne esistono due tipi:
- Vertex shader
- Shader di frammenti
Entrambe, sono sicuro che concorderai, non significano assolutamente nulla di per sé. Quello che devi sapere è che entrambi funzionano interamente sulla GPU della scheda grafica. Ciò significa che vogliamo trasferire a loro tutto ciò che è possibile, lasciando alla CPU il compito di svolgere altre attività. Una GPU moderna è fortemente 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 uno shader dei vertici viene fornito a turno ciascuno di questi vertici e può modificarli. Spetta allo shader dei vertici cosa fa effettivamente 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. Di per sé è un processo piuttosto interessante, perché stiamo parlando di ottenere una posizione 3D (un vertice con x, y, z) su uno schermo 2D o proiettata su di esso. Fortunatamente per noi, se utilizziamo qualcosa come Three.js, abbiamo un modo abbreviato per impostare gl_Position senza che le cose diventino troppo pesanti.
3. Fragment shader
Quindi abbiamo il nostro oggetto con i suoi vertici e li abbiamo proiettati sullo schermo 2D, ma cosa dire dei colori che usiamo? E per quanto riguarda la texture e l'illuminazione? È esattamente a questo che serve lo shader di frammenti. Proprio come lo shader dei vertici, anche lo shader dei frammenti ha un solo compito obbligatorio: deve impostare o eliminare la variabile gl_FragColor, un altro vettore float 4D, che è il colore finale del 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 è il dato fornito da questi tre vertici allo scopo di disegnare ogni pixel del triangolo. Per questo motivo, i frammenti ricevono valori interpolati dai vertici che li compongono. Se un vertice è colorato di rosso e il suo vicino di blu, vedremo i valori di colore interpolati dal rosso, passando per il viola, al blu.
4. Variabili shader
Puoi effettuare tre dichiarazioni di variabili: Uniforms, Attributes e Varyings. Questi codici possono essere fonte di confusione, in quanto non corrispondono ad altre lingue comuni. Ecco come puoi considerarli:
Le uniformi vengono inviate sia agli shader dei vertici sia agli shader dei frammenti 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 lo shader dei vertici. Ad esempio, ogni vertice potrebbe avere un colore distinto. Gli attributi hanno una relazione one-to-one con i vertici.
Le varianti sono variabili dichiarate nello shader dei vertici che vogliamo condividere con lo shader dei frammenti. A questo scopo, ci assicuriamo di dichiarare una variabile di tipo e nome diversi sia nello shader dei vertici sia nello shader dei frammenti. Un utilizzo classico è la normale di un vertice, poiché può essere utilizzata nei calcoli di illuminazione.
Più avanti, utilizzeremo tutti e tre i tipi per farti capire come vengono applicati.
No, dai un'occhiata a uno degli shader più semplici che puoi creare.
5. Bonjourno World
Ecco quindi l'Hello World degli shader dei vertici:
/**
* 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 lo shader di frammenti:
/**
* Set the color 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, vero?
Nello shader dei vertici, Three.js ci invia alcune uniformi. Queste due uniformi sono matrici 4D, chiamate matrice modello-visualizzazione e matrice di proiezione. Non è necessario sapere esattamente come funzionano, anche se è sempre meglio capire come funzionano le cose, se possibile. In breve, si tratta del modo in cui la posizione 3D del vertice viene proiettata nella posizione 2D finale sullo schermo.
Li ho esclusi dallo snippet di esempio perché Three.js li aggiunge alla parte superiore del codice shader, quindi non devi preoccuparti di farlo. A dire il vero, aggiunge molti più dati, come dati di illuminazione, colori dei vertici e normali dei vertici. Se lo facessi senza Three.js, dovresti creare e impostare autonomamente tutti gli uniform e gli attributi. Storia vera.
6. Utilizzo di un MeshShaderMaterial
Ok, abbiamo configurato uno shader, ma come lo utilizziamo con Three.js?
/**
* 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à gli shader allegati alla mesh a cui assegni il materiale. Non c'è niente di più semplice. Probabilmente sì, ma stiamo parlando di 3D nel browser, quindi immagino che ti aspetti un certo livello di complessità.
Possiamo aggiungere altre due proprietà al nostro MeshShaderMaterial: uniform e attributes. Possono accettare vettori, numeri interi o numeri in virgola mobile, ma come ho detto in precedenza, le uniform sono uguali per l'intero frame o per tutti i vertici, quindi tendono a essere valori singoli. Gli attributi, invece, sono variabili per vertice, quindi devono essere un array. Deve esistere una relazione uno a uno tra il numero di valori nell'array degli attributi e il numero di vertici nel mesh.
7. Passaggi successivi
Ora dedicheremo un po' di tempo all'aggiunta di un ciclo di animazione, attributi dei vertici e una variabile uniforme. Aggiungeremo anche una variabile in modo che lo shader dei vertici possa inviare alcuni dati allo shader dei frammenti. Il risultato finale è che la nostra sfera rosa sembrerà illuminata dall'alto e di lato e pulserà. È un po' strano, ma spero che ti aiuti a comprendere bene i tre tipi di variabili, nonché il modo in cui sono correlate tra loro e alla geometria sottostante.
8. Luce finta
Ora aggiorna la colorazione in modo che non sia un oggetto colorato piatto. Potremmo dare un'occhiata a come Three.js gestisce l'illuminazione, ma come sicuramente saprai è più complesso del necessario, quindi lo simuleremo. Ti consigliamo di dare un'occhiata agli shader fantastici che fanno parte di Three.js e a quelli del progetto WebGL di Chris Milk e Google, Rome. Torniamo ai nostri shader. Aggiorneremo lo shader dei vertici per fornire a ogni vertice la normale allo shader dei frammenti. Lo facciamo con una variazione di:
// 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 nello shader di framenti configureremo lo stesso nome di variabile e poi utilizzeremo il prodotto scalare della normale del vertice con un vettore che rappresenta una luce che brilla dall'alto e da 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 color
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);
}
Il prodotto scalare funziona perché, dati due vettori, restituisce un numero che indica il grado di "somiglianza" tra i due vettori. Con i vettori normalizzati, se puntano esattamente nella stessa direzione, ottieni un valore di 1. Se puntano in direzioni opposte, ottieni -1. Quello che facciamo è prendere quel numero e applicarlo alla nostra illuminazione. Pertanto, un vertice in alto a destra avrà un valore vicino o uguale a 1 (completamente illuminato), mentre un vertice sul lato avrà un valore vicino a 0 e un vertice sul retro avrà un valore pari a -1. Impostiamo il valore su 0 per qualsiasi valore negativo, ma quando inserisci i numeri ottieni l'illuminazione di base che vediamo.
Passaggi successivi Sarebbe bello provare a modificare la posizione di alcuni vertici.
9. Attributi
Ora vorrei che aggiungessimo un numero casuale a ogni vertice utilizzando un attributo. Utilizzeremo questo numero per spostare il vertice lungo la sua normale. Il risultato finale sarà una specie di strana palla chiodata che cambierà ogni volta che aggiorni la pagina. Non sarà ancora animato (lo sarà in seguito), ma alcuni aggiornamenti della pagina ti mostreranno che è randomizzato.
Inizia aggiungendo l'attributo allo shader dei vertici:
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);
}
Come ti sembra?
Non cambia molto. Questo perché l'attributo non è stato configurato in MeshShaderMaterial, quindi lo shader utilizza un valore pari a zero. Al momento è una sorta di segnaposto. Tra un attimo aggiungeremo l'attributo a MeshShaderMaterial in JavaScript e Three.js li collegherà automaticamente.
Inoltre, è importante notare che ho dovuto assegnare la posizione aggiornata a una variabile vec3 nuova perché l'attributo originale, come tutti gli attributi, è di sola lettura.
10. Aggiornamento di MeshShaderMaterial
Passa direttamente all'aggiornamento di MeshShaderMaterial con l'attributo necessario per alimentare lo spostamento. Un promemoria: gli attributi sono valori per vertice, quindi abbiamo bisogno di un valore per vertice nella nostra sfera. 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 deformata, ma la cosa interessante è che tutto lo spostamento avviene sulla GPU.
11. Crea animazione
Dovremmo animarlo. Come facciamo? Ci sono due cose da fare:
- Una variabile uniforme per animare lo spostamento da applicare in ogni frame. Possiamo usare il seno o il coseno, dato che vanno da -1 a 1.
- Un ciclo di animazione in JS
Aggiungeremo l'uniform sia a MeshShaderMaterial sia allo shader dei vertici. Per prima cosa, lo shader dei vertici:
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);
}
Successivamente, aggiorniamo 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 completi. Ma al momento sembra che abbiamo fatto un passo indietro. Ciò è dovuto in gran parte al fatto che il valore dell'ampiezza è pari a 0 e, poiché lo moltiplichiamo per lo spostamento, non vediamo alcun cambiamento. Inoltre, non abbiamo configurato il ciclo di animazione, quindi non vediamo mai lo 0 cambiare.
Nel nostro JavaScript ora dobbiamo racchiudere la chiamata di rendering in una funzione e poi utilizzare requestAnimationFrame per chiamarla. In questo campo 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);
Conclusione
È tutto. Ora puoi vedere che si anima in modo strano (e leggermente psichedelico) e pulsante.
Ci sarebbe molto altro da dire sugli shader, e spero che questa introduzione ti sia stata utile. Ora dovresti essere in grado di comprendere gli shader quando li vedi e avere la sicurezza di creare shader straordinari.