Advertisement
  1. Game Development
  2. Shaders

Una guida per principianti alla programmazione degli Shaders Grafici: Parte 3

Scroll to top
Read Time: 18 min
This post is part of a series called A Beginner's Guide to Coding Graphics Shaders.
A Beginner's Guide to Coding Graphics Shaders: Part 2

() translation by (you can also view the original English article)

Dopo aver appreso le basi degli shader, sporchiamoci le mani con per sfruttare la potenza della GPU e creare una illuminazione dinamica e realistica.

La prima parte di questa serie ha riguardato i fondamenti degli shader grafici. La seconda parte ha spiegato la procedura generale di creazione degli shader come riferimento per qualsiasi piattaforma scelta. Da qui in avanti, affronteremo concetti generali sugli shader grafici senza assumere una piattaforma specifica. (Per convenienza, tutti gli esempi di codice saranno comunque in JavaScript / WebGL.)

Prima di proseguire, assicuratevi un buon metodo con cui eseguire i vostri shader come più vi fa comodo. (JavaScript / WebGL potrebbe essere il sistema più semplice ma vi incoraggio a provare altre strade per la vostra piattaforma preferita!)

Obiettivi

Entro la fine di questo tutorial, non vanterete solo una solida conoscenza dei sistemi di illuminazione, ma ne avrete costruito uno voi stessi da zero.

Ecco come si presenterà il risultato finale (fare clic per attivare le luci):

Potete fare un fork e modificare su CodePen.

Mentre molti motori di gioco offrono sistemi di illuminazione già pronti, capire come sono fatti e come crearne uno proprio ti darà molta più flessibilità nella creazione di un look unico che calza per il vostro gioco. Gli effetti shader non devono essere pura cosmetica, possono aprire le porte a interessanti nuove meccaniche di gioco!

Chroma per questo è un grande esempio; il personaggio del giocatore può correre lungo le ombre dinamiche create in tempo reale:

Per iniziare: La Nostra Scena Iniziale

Salteremo un sacco di configurazioni iniziali, dal momento che questo è ciò che ha riguardato il precedente tutorial. Inizieremo con una semplice frammento di shader per il rendering di una texture:

Potete fare un fork e modificare su CodePen.

Non stiamo facendo niente di stratosferico qui. Il nostro codice JavaScript crea la scena inviando la texture da renderizzare, insieme alle dimensioni dello schermo, allo shader.

1
var uniforms = {
2
  tex : {type:'t',value:texture},//The texture

3
  res : {type: 'v2',value:new THREE.Vector2(window.innerWidth,window.innerHeight)}//Keeps the resolution

4
}

Nel nostro codice GLSL, dichiariamo e usiamo queste variabili uniforms:

1
uniform sampler2D tex;
2
uniform vec2 res;
3
void main() {
4
    vec2 pixel = gl_FragCoord.xy / res.xy;
5
    vec4 color = texture2D(tex,pixel);
6
    gl_FragColor = color;
7
 }

Facciamo in modo di normalizzare le nostre coordinate pixel prima di usarle per disegnare la texture.

Giusto per assicurarsi di aver compreso tutto quello che sta succedendo qui, ecco una sfida di riscaldamento:

Sfida: Puoi fare il render della texture, mantenendone intatto il rapporto delle sue dimensioni? (Provateci da soli, noi lavoreremo con la soluzione qui di seguito.)

Dovrebbe essere abbastanza ovvio perché viene allungata, ma qui ci sono alcuni suggerimenti: Guardate la linea dove normalizziamo le nostre coordinate:

1
vec2 pixel = gl_FragCoord.xy / res.xy;

Siamo dividendo un vec2 da un vec2, che è la stessa che dividere ciascun componente singolarmente. In altre parole, quanto sopra è equivalente a:

1
vec2 pixel = vec2(0.0,0.0);
2
pixel.x = gl_FragCoord.x / res.x;
3
pixel.y = gl_FragCoord.y / res.y;

Stiamo dividendo la nostra x e y per numeri diversi (la larghezza e l'altezza dello schermo), così saranno allargate naturalmente.

Che cosa accadrebbe se dividessimo sia la x che  la y di gl_FragCoord solo per la x res? O che dire se solo per la y?

Per semplicità, abbiamo intenzione di mantenere il nostro codice normalizzante così come è per il resto del tutorial, ma è bene capire che cosa sta succedendo!

Passo 1: Aggiunta di una sorgente luminosa

Prima fare qualcosa di eccezionale, abbiamo bisogno di avere una fonte di luce. Una "sorgente di luce" non è altro che un punto inviato al nostro shader. Costruiremo una nuova variabile uniform per questo punto:

1
var uniforms = {
2
  //Add our light variable here

3
  light: {type:'v3', value:new THREE.Vector3()},
4
  tex : {type:'t',value:texture},//The texture

5
  res : {type: 'v2',value:new THREE.Vector2(window.innerWidth,window.innerHeight)}//Keeps the resolution

6
}

Abbiamo creato un vettore a tre dimensioni perché vogliamo utilizzare la x e la y come posizione della luce sullo schermo, e la z come raggio.

Fissiamo alcuni valori per la nostra sorgente di luce in JavaScript:

1
uniforms.light.value.z = 0.2;//Our radius

Intendiamo utilizzare il raggio come percentuale della dimensione dello schermo, in modo che 0.2 sia il 20% del nostro schermo. (Niente di speciale su questa scelta Potevamo impostare la dimensione in pixel. E' un numero che non significa nulla fintanto che non lo usiamo nel nostro codice GLSL.)

Per ottenere la posizione del mouse in JavaScript, basta aggiungere un listener di eventi:

1
document.onmousemove = function(event){
2
  //Update the light source to follow our mouse

3
	uniforms.light.value.x = event.clientX; 
4
	uniforms.light.value.y = event.clientY; 
5
}

Ora scriviamo del codice per lo shader che faccia uso di questo punto luce. Inizieremo con un compito semplice: vogliamo che ogni pixel interno all'ampiezza della luce sia visibile, tutto il resto dovrebbe essere nero.

Tradotto in GLSL potrebbe essere simile a questo:

1
uniform sampler2D tex;
2
uniform vec2 res;
3
uniform vec3 light;//Remember to declare the uniform here!

4
void main() {
5
    vec2 pixel = gl_FragCoord.xy / res.xy;
6
    vec4 color = texture2D(tex,pixel);
7
    //Distance of the current pixel from the light position

8
    float dist = distance(gl_FragCoord.xy,light.xy);
9
    
10
    if(light.z * res.x > dist){//Check if this pixel is without the range

11
      gl_FragColor = color;
12
    } else {
13
      gl_FragColor = vec4(0.0);
14
    }
15
}

Tutto quello che abbiamo fatto è stato:

  • Dichiarare la nostra variabile uniform della luce.
  • Usare la funzione interna distance per calcolare la distanza tra la posizione della luce e la posizione del pixel corrente.
  • Controllare se questa distanza (in pixel) è superiore al 20% della larghezza dello schermo; se è così, restituire il colore di quel pixel, altrimenti il nero.
Potete fare un fork e modificare su CodePen.

Uh Oh! C'è qualcosa che sembra sbagliato su come la luce segue il mouse.

Sfida: Si può rimediare? (Anche in questo caso, provate da soli, prima di vedere la soluzione qua sotto.)

Risolvere il movimento della Luce

Potremmo ricordarci dal primo tutorial di questa serie che l'asse y è capovolto. Potremmo essere tentati di fare proprio:

1
light.y = res.y - light.y;

Che matematicamente torna, ma se lo fate lo shader non compila! Il problema è che le variabili uniform non possono essere cambiate. Per capire perché, ricordate che questo codice viene eseguito per ogni singolo pixel in parallelo. Immaginate tutti i core del processore che cercano di cambiare una singola variabile, allo stesso tempo. Non è buono!

Siamo in grado di risolvere questo problema con la creazione di una nuova variabile invece di cercare di modificare la nostra uniform. O ancora meglio, possiamo semplicemente fare questo passo prima di passarlo allo shader:

Potete fare un fork e modificare su CodePen.
1
uniforms.light.value.y = window.innerHeight - event.clientY; 

Ora abbiamo definisce con successo l'ampiezza visibile della nostra scena. Sembra molto forte, anche se ....

Aggiunta di un gradiente

Invece di tagliare a nero quando siamo fuori del campo, possiamo provare a creare un gradiente lineare verso i bordi. Possiamo farlo utilizzando la distanza che abbiamo già calcolato.

Invece di impostare tutti i pixel all'interno del campo visibile al colore della texture, in questo modo:

1
gl_FragColor = color;

Possiamo moltiplicare per un fattore della distanza:

1
gl_FragColor = color * (1.0 - dist/(light.z * res.x));
Potete fare un fork e modificare su CodePen.

Questo funziona perché dist è la distanza in pixel tra il pixel corrente e la sorgente luminosa. Il termine (light.z * res.x) è la lunghezza del raggio. Così, quando stiamo guardando il pixel esattamente al centro della la fonte luminosa, dist è 0, così da moltiplicare color fino a 1, che è il colore pieno.

In questo diagramma, dist è calcolato per alcuni pixel arbitrari. dist è diversa a seconda di quale pixel troviamo, mentre light.z * res.x è costante.

Quando osserviamo un pixel sul bordo del cerchio, dist è uguale alla lunghezza del raggio, quindi si finisce per moltiplicare colore per 0, che è nero.

Fase 2: aggungere profondità

Finora non abbiamo fatto altro che creare una maschera di gradiente per la nostra texture. Tutto sembra ancora piatto. Per capire come risolvere questo problema, vediamo quello che il nostro sistema di illuminazione fa al momento invece di quello che dovrebbe fare.

Nello scenario sopra, ci aspetteremmo A più acceso, dato che la nostra fonte di luce è allo zenit, con B e C più scuri, dal momento che quasi nessun raggio di luce sta effettivamente colpendo i lati.

Tuttavia, questo è ciò che il nostro sistema di illuminazione attuallmente vede:

Sono tutti trattati allo stesso modo, perché l'unico fattore che stiamo prendendo in considerazione è la distanza sul piano xy. Ora, potremmo pensare d'aver bisogno dell'altezza di ciascuno di questi punti, ma non è del tutto giusto. Per capire perché, si consideri questo scenario:

A è la parte superiore del nostro blocco, B e C sono i suoi lati. D è un altro pezzo di terra nelle vicinanze. Possiamo notare che A e D dovrebbero essere più brillanti, con D un po 'più scuro, perché la luce sta arrivando di angolo. B e C, d'altra parte, dovrebbe essere molto più scuri, perché quasi nessuna luce li sta raggiungendo, visto che sono lontani dalla sorgente luminosa.

Non è l'altezza, ma piuttosto la direzione dove è rivolta la superficie di cui abbiamo bisogno. Questa è chiamata la normale alla superficie.

Ma come si fa a passare queste informazioni allo shader? Non possiamo trasmettere un array enorme di migliaia di numeri per ogni singolo pixel, o si può? In realtà, lo stiamo già facendo! Tranne che noi non parliamo di un array, ma parliamo di una texture.

Questo è ciò che esattamente è una mappa di normali; è solo un'immagine dove i valori r, g e b di ciascun pixel rappresentano una direzione invece di un colore.

Example normal mapExample normal mapExample normal map

Sopra è una semplice mappa di normali. Se usiamo un selettore di colori, possiamo vedere che la direzione "piatta" di default è rappresentata dal colore (0.5, 0.5, 1) (il colore blu che occupa la maggior parte delle immagini). Questa è la direzione che punta verso l'alto. I valori della x, y e z sono mappati sui valori r, g e b.

Il lato inclinato a destra è rivolto verso destra, quindi il suo valore x è superiore; il valore di x è anche il suo valore di rosso, che è il motivo per cui sembra più rossastro/rosa. Lo stesso vale per tutti gli altri lati.

E' buffo perché l'immagine non è destinata per essere mostrata; è fatta esclusivamente per codificare i valori di queste normali alle superfici.

Quindi cerchiamo di caricare questa semplice mappa di normali per testarla:

1
var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/normal_test.jpg"
2
var normal = THREE.ImageUtils.loadTexture(normalURL);

E aggiungiamola come una delle nostre variabili uniform:

1
var uniforms = {
2
  norm: {type:'t', value:normal},
3
  //.. the rest of our stuff here

4
}

Per verificare che l'abbiamo caricata correttamente, proviamo a mostrarla al posto della nostra texture modificando il nostro codice GLSL (ricordate, ora la stiamo solo usando come una texture di fondo, invece che come una mappa di normali):

Potete fare un fork e modificare su CodePen.

Fase tre: Applicare l'illuminazione ad un modello

Ora che abbiamo i dati delle nostre normali di superficie, abbiamo bisogno di implementare un modello di illuminazione. In altre parole, abbiamo bisogno di dire alla nostra superficie come tener conto di tutti i fattori che abbiamo per calcolare la luminosità finale.

Il modello di Phong è quello più semplice che possiamo implementare. Ecco come funziona: Data un superficie con i dati delle normali come questi:

Semplicemente calcoliamo l'angolo tra la sorgente luminosa e la normale alla superficie:

Più piccolo è questo angolo, più luminoso sarà il pixel.

Questo significa che i pixel direttamente sotto la sorgente luminosa, dove la differenza di angolo è 0, saranno più brillanti. I pixel più scuri saranno quelli che puntano nella stessa direzione del raggio di luce (come a dire la parte inferiore dell'oggetto)

Ora andiamo ad implementarlo.

Dal momento che stiamo usando una semplice mappa di normali proviamo impostando la nostra texture con un colore a tinta unita in modo che si possa facilmente dire se il tutto funziona.

Così, invece di:

1
vec4 color = texture2D(...);

Facciamo un bianco pieno (o di qualsiasi colore che vi piace):

1
vec4 color = vec4(1.0); //solid white

Questo è la scorciatoia GLSL per creare un vec4 con tutti i componenti uguali a 1,0.

Ecco come appare il nostro algoritmo:

  1. Prendiamo il vettore normale in questo pixel.
  2. Prendiamo il vettore della direzione della luce.
  3. Normalizziamo i nostri vettori.
  4. Calcoliamo l'angolo tra loro.
  5. Moltiplichiamo il colore finale per questo fattore.

1. Prendiamo il vettore normale a questo pixel

Abbiamo bisogno di sapere in quale direzione è rivolta la superficie in modo da poter calcolare la quantità di luce che deve raggiungere questo pixel. Questa direzione è memorizzata nella nostra mappa delle normali, quindi ottenere il nostro vettore normale non significa altro che ricavare il colore del pixel corrente nella texture delle normali:

1
vec3 NormalVector = texture2D(norm,pixel).xyz;

Dal momento che il valore alfa non rappresenta nulla nella mappa delle normali, abbiamo solo bisogno dei primi tre componenti.

2. Otteniamo il Vettore Luce di direzione

Ora abbiamo bisogno di sapere in quale direzione punta la nostra luce. Possiamo immaginare la nostra sorgente luminosa come una torcia tenuta davanti allo schermo, nella posizione del nostro puntatore, in modo da poter calcolare il vettore di direzione della luce semplicemente utilizzando la distanza tra la sorgente luminosa e il pixel:

1
vec3 LightVector = vec3(light.x - gl_FragCoord.x,light.y - gl_FragCoord.y,60.0);

Deve anche avere una coordinata z (per poter calcolare l'angolo contro il vettore normale 3-dimensionalesulla superificie). Potete giocare con questo valore. Scoprirete che è piccolo, più nitido è il contrasto tra le aree chiare e scure. Si può immaginarla come l'altezza con cui si tiene in mano la torcia sopra la scena; più lontana è, più uniformemente la luce sarà distribuita.

3. Normalizziamo i nostri vettori

Come normalizzare:

1
NormalVector = normalize(NormalVector);
2
LightVector = normalize(LightVector);

Usiamo la funzione di interna normalize per assicurarsi che entrambe i nostri vettori abbiano lunghezza di 1,0. Abbiamo bisogno di fare così perché stiamo per calcolare l'angolo con il prodotto scalare. Se siete un pò confusi sul suo funzionamento, dovreste rispolverare rispolverare un pò della vostra algebra lineare. Per i nostri scopi, è sufficiente sapere che il prodotto scalare restituisce il coseno dell'angolo tra due vettori di uguale lunghezza.

4. Calcolare l'angolo tra i nostri vettori

Andiamo avanti e facciamolo con la funzione interna dot:

1
float diffuse = dot( NormalVector, LightVector );

La chiamiamo diffuse proprio perché è il termine usato nel modello di illuminazione Phong, per come viene determinata la quantità di luce che raggiunge la superficie della nostra scena.

5. Moltiplicare il colore finale per questo fattore

Questo è tutto! Ora andiamo avanti a moltiplicare il colore con questo valore. Proseguendo ho creato una variabile chiamata distanceFactor in modo da rendere la nostra equazione più leggibile:

1
float distanceFactor = (1.0 - dist/(light.z * res.x));
2
gl_FragColor = color * diffuse * distanceFactor;

Ed abbiamo ottenuto un modello di illuminazione funzionante! (Potremmo ampliare il raggio della tua luce per vedere l'effetto più chiaro.)

Potete fare un fork e modificare su CodePen.

Hmm, qualcosa sembra un pò fuori fase. Sembra che che la nostra luce si sia inclinata in qualche modo.

Cerchiamo un secondo di rivedere qua la nostra matematica. Abbiamo questa vettore luce:

1
vec3 LightVector = vec3(light.x - gl_FragCoord.x,light.y - gl_FragCoord.y,60.0);

Che, come sappiamo ci da (0, 0, 60) quando la luce è direttamente in cima ai pixel. Dopo averlo normalizzato, sarà (0, 0, 1).

Ricordate che vogliamo un normale che punti direttamente verso la luce per avere la massima luminosità. Di default il piano è rivolto verso l'alto, (0.5, 0.5, 1).

Sfida: Vedete la soluzione ora? Potete realizzarla?

Il problema è che non è possibile memorizzare i numeri negativi come valori dei colori in una texture. Non è possibile indicare un vettore che punta a sinistra come (-0,5, 0, 0). Così, chi crea mappe di normali deve aggiungere uno 0,5 a tutto. (O, più in generale, deve spostare il suo sistema di coordinate). È necessario essere consapevoli di questo per sapere che si dovrebbe sottrarre 0,5 da ogni pixel prima di utilizzare la mappa.

Ecco come la demo appare dopo aver sottratto 0,5 dalla x e dalla y del nostro vettore normale:

Potete fare un fork e modificare su CodePen.

Dobbiamo fare un ultimo fix. Ricordando che il prodotto scalare restituisce il coseno dell'angolo. Significa che il nostri valori in uscita sono bloccati tra -1 e 1. Non vogliamo valori negativi nei nostri colori, considerando poi che WebGL sembra scartare automaticamente questi valori, potremmo ottenere un comportamento strano altrove. Possiamo usare la funzione interna max per risolvere questo problema, cambiando questo:

1
float diffuse = dot( NormalVector, LightVector );

In questo:

1
float diffuse = max(dot( NormalVector, LightVector ),0.0);

Ora abbiamo un modello di illuminazione funzionante!

È possibile rimettere la texture con le pietre e trovare la sua vera e propria mappa delle normali nel repository GitHub di questa serie (o, direttamente, qui):

Abbiamo bisogno di cambiare una sola riga di JavaScript, da:

1
var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/normal_test.jpg"

a:

1
var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/blocks_normal.JPG"

E una linea di GLSL, da:

1
vec4 color = vec4(1.0);//solid white

Non serve più il bianco pieno, mettiamo su la texture reale, in questo modo:

1
vec4 color = texture2D(tex,pixel);

E ecco è il risultato finale:

Potete fare un fork e modificare su CodePen.

Suggerimenti per l'ottimizzazione

La GPU è molto efficiente in quello che fa, ma sapere cosa può rallentarla è prezioso. Ecco alcuni suggerimenti per quanto riguarda:

Ramificazione

Una cosa sugli shader è che preferibile evitare, dove possibile, è la branching (ramificazione). Mentre raramente, in qualsiasi codice scritto per la CPU, ci si preoccupa di un gruppo di istruzioni if, queste possono essere un serio ostacolo per la GPU.

Per capire perché, ricorda ancora una volta che il codice GLSL viene eseguito in parallelo per ogni pixel sullo schermo. La scheda grafica può eseguire un sacco di ottimizzazioni in basandosi sul fatto che tutti i pixel devono eseguire le stesse operazioni. Se, però, ci sono un sacco di istruzioni if alcune di queste ottimizzazioni potrebbero iniziare a fallire, perché per diversi pixel verrà eseguito un codice diverso. Comunque sia nelle istruzioni if il rallentamento sembra dipendere dalla specifica realizzazione dell'hardware delle schede grafiche, ma è sempre una buona cosa da tenere a mente quando si cerca di accelerare i vostri shader.

Rendering Differito

Questo è un concetto molto utile quando si tratta di illuminazione. Immaginate se volevamo due fonti di luce, o tre, o una dozzina; avremmo avuto bisogno di calcolare l'angolo tra ogni superficie normale e ogni punto di luce. Questo rallenterà rapidamente il nostro shader a passo d'uomo. Il deferred rendering (n.d.a. differito) dividendo il lavoro del nostro di shader in più passaggi è un modo per ottimizzare. Ecco un articolo che entra nel dettaglio su ciò che significa. Citerò qui la parte rilevante per i nostri scopi:

L'illuminazione è la ragione principale per prendere una strada piuttosto che un'altra. In una standard forward (n.d.r. anticipata) pipeline i calcoli di illuminazione devono essere eseguiti su ogni vertice e su ogni frammento della scena visibile, per ogni luce nella scena.

Ad esempio, invece di inviare una matrice di punti luce, si potrebbe disegnarli tutti su una struttura, come dei cerchi, con il colore di ogni pixel a rappresentare l'intensità della luce. In questo modo, sarete in grado di calcolare l'effetto combinato di tutte le luci della scena e basterà inviare la texture finale (o tampone come viene talvolta chiamato) per calcolare l'illuminazione.

Imparare a dividere il lavoro in più passaggi per lo shader è una tecnica molto utile. Gli effetti di Blur (n.d.a. sfocatura) fanno uso di questa idea per accelerare lo shader, ad esempio, così come gli effetti di ombreggiatura fluido / fumo. Siamo fuori del campo di interesse del tutorial, ma potremmo rivedere la tecnica in un futuro tutorial!

Prossimi passi

Ora che abbiamo uno shader di illuminazione funzionante, qui ci sono alcune cose con cui provare a giocare:

  • Provate a modificare l'altezza (valore z) del vettore luce per vedere il suo effetto
  • Provate a modificare l'intensità della luce. (Lo potete fare moltiplicando il valore di diffussione di un fattore.)
  • Aggiungere un valore ambientale nell'equazione luce. (Ciò significa dare un valore di minimo, in modo che anche le aree scure non siano buie. Questo aiuta il realismo, perché gli oggetti nella vita reale rimangono sempre un pò illuminati anche se non vengono colpiti da una luce diretta)
  • Provare la realizzazione di alcuni di questi shader in questo tutorial WebGL. E' fatto con Babylon.js invece di Three.js, ma è possibile saltare alle parti GLSL. In particolare, il cell shading (n.d.a. ombreggiatura dei fumetti) e il Phong shading potrebbero interessarvi.
  • Prendete qualche ispirazione dalle demo su GLSL Sandbox e ShaderToy

Riferimenti

La texture delle pietre e la mappa delle normali utilizzati in questo tutorial sono tratti da OpenGameArt:

http://opengameart.org/content/50-free-textures-4-normalmaps

Ci sono un sacco di programmi che aiutano nella creazione delle mappe di normali. Se siete interessati a saperne di più su come creare le proprie mappe di normali, questo articolo può aiutarvi.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Game Development tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.