Avendo padroneggiato le basi degli shader, adottiamo un approccio pratico per sfruttare la potenza della GPU per creare un'illuminazione realistica e dinamica.
La prima parte di questa serie ha riguardato i fondamenti degli shader grafici. La seconda parte ha spiegato la procedura generale di impostazione degli shader per servire come riferimento per qualsiasi piattaforma tu scelga. Da qui in poi, affronteremo concetti generali sugli shader grafici senza assumere una piattaforma specifica. (Per motivi di comodità, tutti gli esempi di codice continueranno a utilizzare JavaScript / WebGL.)
Prima di andare avanti, assicurati di avere un modo per eseguire shader con cui ti trovi a tuo agio. (JavaScript / WebGL potrebbe essere più semplice, ma ti consiglio di provare a seguire la tua piattaforma preferita!)
Alla fine di questo tutorial, non solo sarai in grado di vantare una solida conoscenza dei sistemi di illuminazione, ma ne avrai costruito uno tu stesso da zero.
Ecco come appare il risultato finale (fai clic per attivare le luci):
Puoi fork e modificare questo su CodePen.Mentre molti motori di gioco offrono sistemi di illuminazione già pronti, capire come sono fatti e come crearne uno ti dà molta più flessibilità nel creare un look unico per il tuo gioco. Gli effetti shader non devono essere puramente estetici, ma possono aprire le porte a nuove affascinanti meccaniche di gioco!
Chroma è un grande esempio di questo; il personaggio del giocatore può correre lungo le ombre dinamiche create in tempo reale:
Salteremo molto del setup iniziale, dato che questo era il tutorial precedente. Inizieremo con un semplice frammento di shader che mostra la nostra texture:
Puoi fork e modificare questo su CodePen.Niente di stravagante sta succedendo qui. Il nostro codice JavaScript sta configurando la nostra scena e inviando la texture per il rendering, insieme alle dimensioni dello schermo, allo shader.
var uniforms = tex: tipo: 't', valore: texture, // La texture res: tipo: 'v2', valore: new THREE.Vector2 (window.innerWidth, window.innerHeight) // Keeps la risoluzione
Nel nostro codice GLSL, dichiariamo e usiamo queste uniformi:
uniforme sampler2D tex; uniforme vec2 res; void main () vec2 pixel = gl_FragCoord.xy / res.xy; vec4 color = texture2D (tex, pixel); gl_FragColor = color;
Ci assicuriamo di normalizzare le nostre coordinate pixel prima di utilizzarle per disegnare la trama.
Solo per essere sicuro di aver capito tutto quello che sta succedendo qui, ecco una sfida di riscaldamento:
Sfida: Riesci a rendere la texture mantenendo intatto il suo rapporto di aspetto? (Provaci tu stesso, passeremo attraverso la soluzione qui sotto).
Dovrebbe essere abbastanza ovvio il motivo per cui viene allungato, ma ecco alcuni suggerimenti: Guarda la linea in cui normalizziamo le nostre coordinate:
vec2 pixel = gl_FragCoord.xy / res.xy;
Stiamo dividendo a vec2
di a vec2
, che equivale a dividere ogni componente singolarmente. In altre parole, quanto sopra è equivalente a:
vec2 pixel = vec2 (0.0,0.0); pixel.x = gl_FragCoord.x / res.x; pixel.y = gl_FragCoord.y / res.y;
Stiamo dividendo i nostri x e y per numeri diversi (la larghezza e l'altezza dello schermo), quindi sarà naturalmente allungato.
Cosa succederebbe se dividessimo sia la x che la y di gl_FragCoord
solo dalla x res
? O per quanto riguarda solo la y invece?
Per ragioni di semplicità, manterremo il nostro codice di normalizzazione come-è per il resto del tutorial, ma è bene capire cosa sta succedendo qui!
Prima di poter fare qualsiasi cosa di fantasia, dobbiamo avere una fonte di luce. Una "fonte di luce" non è altro che un punto che inviamo al nostro shader. Costruiremo una nuova uniforme per questo punto:
var uniforms = // Aggiungi la nostra variabile di luce qui light: type: 'v3', valore: new THREE.Vector3 (), tex: tipo: 't', valore: texture, // La texture res: type: 'v2', valore: new THREE.Vector2 (window.innerWidth, window.innerHeight) // Mantiene la risoluzione
Abbiamo creato un vettore con tre dimensioni perché vogliamo utilizzare il X
e y
come il posizione della luce sullo schermo, e il z
come il raggio.
Impostiamo alcuni valori per la nostra sorgente luminosa in JavaScript:
uniforms.light.value.z = 0.2; // Il nostro raggio
Intendiamo utilizzare il raggio come percentuale delle dimensioni dello schermo, quindi 0.2
sarebbe il 20% del nostro schermo. (Non c'è niente di speciale in questa scelta: potremmo averlo impostato in pixel, questo numero non significa nulla finché non facciamo qualcosa con esso nel nostro codice GLSL.)
Per ottenere la posizione del mouse in JavaScript, aggiungiamo semplicemente un listener di eventi:
document.onmousemove = function (event) // Aggiorna la sorgente luminosa per seguire il nostro mouse uniforms.light.value.x = event.clientX; uniforms.light.value.y = event.clientY;
Ora scriviamo un codice shader per utilizzare questo punto luce. Inizieremo con un compito semplice: Vogliamo che ogni pixel all'interno del nostro raggio di luce sia visibile e che tutto il resto sia nero.
Tradurre questo in GLSL potrebbe assomigliare a questo:
uniforme sampler2D tex; uniforme vec2 res; uniforme luce vec3; // Ricordati di dichiarare la divisa qui! void main () vec2 pixel = gl_FragCoord.xy / res.xy; vec4 color = texture2D (tex, pixel); // Distanza del pixel corrente dalla posizione di luce float dist = distance (gl_FragCoord.xy, light.xy); if (light.z * res.x> dist) // Controlla se questo pixel è senza l'intervallo gl_FragColor = colour; else gl_FragColor = vec4 (0.0);
Tutto ciò che abbiamo fatto qui è:
Uh Oh! Qualcosa sembra fuori da come la luce sta seguendo il mouse.
Sfida: Puoi aggiustarlo? (Di nuovo, fai un salto prima di attraversarlo sotto).
Potresti ricordare dal primo tutorial di questa serie che l'asse y qui è capovolto. Potresti essere tentato di fare solo:
light.y = res.y - light.y;
Che è matematicamente valido, ma se lo facessi, il tuo shader non verrà compilato! Il problema è che le variabili uniformi non possono essere modificate.Per capire perché, ricordalo questo codice runs per ogni singolo pixel in parallelo. Immagina tutti quei core del processore che provano a cambiare una singola variabile allo stesso tempo. Non bene!
Possiamo risolvere questo problema creando una nuova variabile invece di provare a modificare la nostra uniforme. O meglio ancora, possiamo semplicemente fare questo passo prima passandolo allo shader:
Puoi fork e modificare questo su CodePen.uniforms.light.value.y = window.innerHeight - event.clientY;
Ora abbiamo definito con successo la gamma visibile della nostra scena. Sembra molto nitido, anche se ...
Invece di limitarci a tagliare il nero quando siamo fuori dal range, possiamo provare a creare una sfumatura uniforme verso i bordi. Possiamo farlo usando la distanza che stiamo già calcolando.
Invece di impostare tutti i pixel all'interno dell'intervallo visibile al colore della trama, in questo modo:
gl_FragColor = color;
Possiamo moltiplicarlo per un fattore della distanza:
gl_FragColor = color * (1.0 - dist / (light.z * res.x));Puoi fork e modificare questo su CodePen.
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. Quindi, quando osserviamo il pixel esattamente alla fonte di luce, dist
è 0
, quindi finiamo per moltiplicarci colore
di 1
, che è il colore completo.
dist
è calcolato per alcuni pixel arbitrari. dist
è diverso a seconda del pixel in cui ci troviamo, mentre light.z * res.x
è costante.Quando guardiamo un pixel sul bordo del cerchio, dist
è uguale alla lunghezza del raggio, quindi finiamo per moltiplicarci colore
di 0
, che è nero.
Finora non abbiamo fatto molto di più che creare una maschera gradiente per la nostra texture. Tutto sembra ancora piatto. Per capire come risolvere questo problema, vediamo cosa sta facendo il nostro sistema di illuminazione in questo momento, al contrario di quello che è ipotetico fare.
Nello scenario sopra, ti aspetteresti UN per essere il più illuminato, poiché la nostra sorgente luminosa è direttamente sopra la testa, con B e C essendo scuro, poiché quasi nessun raggio di luce colpisce effettivamente i lati.
Tuttavia, questo è ciò che il nostro sistema di illuminazione attuale vede:
Sono tutti trattati allo stesso modo, perché l'unico fattore che stiamo prendendo in considerazione è distanza sul piano xy.Ora, potresti pensare che tutto ciò di cui abbiamo bisogno ora è l'altezza di ciascuno di questi punti, ma non è proprio così. Per capire perché, considera questo scenario:
UN è la parte superiore del nostro blocco, e B e C sono i lati di esso. D è un'altra zona di terreno nelle vicinanze. Possiamo vederlo UN e D dovrebbe essere il più luminoso, con D essendo un po 'più scuro perché la luce lo sta raggiungendo ad angolo. B e C, d'altra parte, dovrebbe essere molto scuro, perché quasi nessuna luce li sta raggiungendo, dal momento che sono rivolti verso la fonte di luce.
Non è tanto l'altezza quanto la direzione che la superficie sta affrontandodi cui abbiamo bisogno. Questo è chiamato il superficie normale.
Ma come passiamo queste informazioni allo shader? Non è possibile inviare una gigantesca serie di migliaia di numeri per ogni singolo pixel, possiamo? In realtà, lo stiamo già facendo! Tranne che non lo chiamiamo un schieramento, noi lo chiamiamo a struttura.
Questo è esattamente ciò che è una normale mappa; è solo un'immagine in cui il r
, g
e B
i valori di ciascun pixel rappresentano una direzione anziché un colore.
Sopra è una semplice mappa normale. Se usiamo un selettore di colori, possiamo vedere che la direzione predefinita, "piatta", è rappresentata dal colore (0.5, 0.5, 1)
(il colore blu che occupa la maggior parte dell'immagine). Questa è la direzione che punta verso l'alto. I valori x, y e z sono mappati ai valori r, geb.
Il lato inclinato a destra sta puntando a destra, quindi il suo valore x è più alto; il valore x è anche il suo valore rosso, motivo per cui appare più rossastro / rosato. Lo stesso vale per tutti gli altri lati.
Sembra divertente perché non è pensato per essere reso; è fatto puramente per codificare i valori di queste normali di superficie.
Quindi carichiamo questa semplice mappa normale per testare con:
var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/normal_test.jpg" var normal = THREE.ImageUtils.loadTexture (normalURL);
E aggiungilo come una delle nostre variabili uniformi:
var uniforms = norma: tipo: 't', valore: normale, // ... il resto della nostra roba qui
Per verificare che l'abbiamo caricato correttamente, proviamo a renderlo al posto della nostra trama modificando il nostro codice GLSL (ricordate, a questo punto lo stiamo solo usando come trama di sfondo, piuttosto che una normale mappa):
Puoi fork e modificare questo su CodePen.Ora che abbiamo i nostri normali dati di superficie, è necessario implementare un modello di illuminazione. In altre parole, dobbiamo dire alla nostra superficie come tenere conto di tutti i fattori che dobbiamo calcolare la luminosità finale.
Il modello Phong è il più semplice che possiamo implementare. Ecco come funziona: data una superficie con dati normali come questo:
Semplicemente calcoliamo l'angolo tra la sorgente luminosa e la superficie normale:
Più piccolo è questo angolo, più luminoso è il pixel.
Ciò significa che i pixel direttamente al di sotto della sorgente di luce, dove la differenza di angolo è 0, saranno i più luminosi. I pixel più scuri saranno quelli che puntano nella stessa direzione del raggio di luce (che sarebbe come la parte inferiore dell'oggetto)
Ora implementiamo questo.
Dal momento che stiamo usando una semplice mappa normale per testare, impostiamo la nostra texture su un colore solido in modo che possiamo facilmente capire se funziona.
Quindi, invece di:
vec4 color = texture2D (...);
Facciamo un bianco solido (o qualsiasi colore ti piaccia davvero):
vec4 color = vec4 (1.0); // bianco solido
Questa è una scorciatoia GLSL per creare un vec4
con tutti i componenti uguali a 1.0
.
Ecco come appare il nostro algoritmo:
Abbiamo bisogno di sapere in quale direzione si trova la superficie in modo da poter calcolare quanta luce dovrebbe raggiungere questo pixel. Questa direzione è memorizzata nella nostra mappa normale, quindi ottenere il nostro vettore normale significa semplicemente ottenere il colore del pixel corrente della texture normale:
vec3 NormalVector = texture2D (norm, pixel) .xyz;
Poiché il valore alfa non rappresenta nulla nella mappa normale, abbiamo solo bisogno dei primi tre componenti.
Ora abbiamo bisogno di sapere in quale direzione il nostro luce sta indicando. Possiamo immaginare che la nostra superficie luminosa sia una torcia elettrica posizionata davanti allo schermo, nella posizione del nostro mouse, in modo da poter calcolare il vettore di direzione della luce semplicemente utilizzando la distanza tra la sorgente di luce e il pixel:
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 rispetto al vettore normale della superficie tridimensionale). Puoi giocare con questo valore. Scoprirai che più è piccolo, più nitido è il contrasto tra le aree luminose e quelle scure. Puoi pensare a questo come all'altezza che stai tenendo la torcia sopra la scena; più è lontano, più uniforme è la luce distribuita.
Ora per normalizzare:
NormalVector = normalize (NormalVector); LightVector = normalize (LightVector);
Usiamo la funzione built-in normalize per assicurarci che entrambi i nostri vettori abbiano una lunghezza di 1.0
. Dobbiamo farlo perché stiamo per calcolare l'angolo usando il prodotto punto. Se sei un po 'confuso su come funziona, potresti voler rispolverare alcuni dei tuoi algebra lineare. Per i nostri scopi, devi solo saperlo il prodotto punto restituirà il coseno dell'angolo tra due vettori di uguale lunghezza.
Andiamo avanti e farlo con la funzione di punto incorporato:
float diffuse = dot (NormalVector, LightVector);
Lo chiamo diffondere solo perché questo è ciò che viene chiamato questo termine nel modello di illuminazione Phong, a causa di come detta la quantità di luce che raggiunge la superficie della nostra scena.
Questo è tutto! Ora vai avanti e moltiplica il tuo colore con questo termine. Sono andato avanti e ho creato una variabile chiamata distanceFactor
in modo che la nostra equazione sia più leggibile:
float distanceFactor = (1.0 - dist / (light.z * res.x)); gl_FragColor = color * diffuse * distanceFactor;
E abbiamo un modello di illuminazione funzionante! (Potresti voler espandere il raggio della tua luce per vedere l'effetto in modo più chiaro.)
Puoi fork e modificare questo su CodePen.Hmm, qualcosa sembra un po 'fuori. Sembra che la nostra luce sia inclinata in qualche modo.
Rivediamo i nostri calcoli per un secondo qui. Abbiamo questo vettore leggero:
vec3 LightVector = vec3 (light.x - gl_FragCoord.x, light.y - gl_FragCoord.y, 60.0);
Che sappiamo ci darà (0, 0, 60)
quando la luce è direttamente sopra questo pixel. Dopo averlo normalizzato, lo sarà (0, 0, 1)
.
Ricorda che vogliamo un normale che punta direttamente verso la luce per avere la massima luminosità. La nostra superficie di default normale, rivolta verso l'alto, è (0.5, 0.5, 1)
.
Sfida: Riesci a vedere la soluzione ora? Puoi implementarlo?
Il problema è che non è possibile memorizzare numeri negativi come valori di colore in una trama. Non è possibile indicare un vettore che punta a sinistra come (-0,5, 0, 0)
. Quindi, le persone che creano mappe normali devono aggiungere 0.5
a tutto. (O, in termini più generali, hanno bisogno di spostare il loro sistema di coordinate). Devi essere consapevole di questo per sapere che devi sottrarre 0.5
da ciascun pixel prima di utilizzare la mappa.
Ecco come appare la demo dopo la sottrazione 0.5
dalla x e y del nostro vettore normale:
C'è un'ultima correzione che dobbiamo fare. Ricorda che il prodotto punto restituisce il coseno dell'angolo. Ciò significa che il nostro output è bloccato tra -1 e 1. Non vogliamo valori negativi nei nostri colori, e mentre WebGL sembra scartare automaticamente questi valori negativi, si potrebbe ottenere un comportamento strano altrove. Possiamo utilizzare la funzione max integrata per risolvere questo problema, ruotando questo:
float diffuse = dot (NormalVector, LightVector);
In questo:
float diffuse = max (dot (NormalVector, LightVector), 0.0);
Ora hai un modello di illuminazione funzionante!
Puoi rimettere a posto la trama delle pietre, e puoi trovare la sua mappa normale reale nel repository GitHub per questa serie (o, direttamente, qui):
Abbiamo solo bisogno di cambiare una riga JavaScript, da:
var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/normal_test.jpg"
a:
var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/blocks_normal.JPG"
E una linea GLSL, da:
vec4 color = vec4 (1.0); // bianco fisso
Non avendo più bisogno del bianco solido, tiriamo la vera trama, in questo modo:
vec4 color = texture2D (tex, pixel);
Ed ecco il risultato finale:
Puoi fork e modificare questo su CodePen.La GPU è molto efficiente in quello che fa, ma sapere cosa può rallentarlo è prezioso. Ecco alcuni suggerimenti su questo:
Una cosa sugli shader è che generalmente è preferibile evitare ramificazione quando possibile. Mentre raramente devi preoccuparti di un sacco di Se
dichiarazioni su qualsiasi codice che scrivi per la CPU, possono essere un importante collo di bottiglia per la GPU.
Per capire perché, ricordalo ancora il tuo codice GLSL runs su ogni pixel sullo schermo in parallelo. La scheda grafica può fare molte ottimizzazioni sulla base del fatto che tutti i pixel devono eseguire le stesse operazioni. Se c'è un sacco di Se
tuttavia, alcune di queste ottimizzazioni potrebbero iniziare a fallire, poiché diversi pixel eseguiranno ora un codice diverso. Se o non Se
le istruzioni effettivamente rallentano le cose sembra dipendere dall'implementazione specifica dell'hardware e della scheda grafica, ma è una buona cosa da tenere a mente quando si cerca di accelerare lo shader.
Questo è un concetto molto utile quando si tratta di illuminazione. Immagina se volessimo avere due sorgenti luminose, o tre, o una dozzina; avremmo bisogno di calcolare l'angolo tra ogni superficie normale e ogni punto di luce. Questo rallenterà rapidamente il nostro shader a passo d'uomo. Rendering differito è un modo per ottimizzarlo suddividendo il lavoro del nostro shader in più passaggi. Ecco un articolo che entra nei dettagli di ciò che significa. Citerò la parte pertinente per i nostri scopi qui:
L'illuminazione è la ragione principale per percorrere una rotta rispetto all'altra. In una pipeline standard di rendering in avanti, i calcoli di illuminazione devono essere eseguiti su ogni vertice e su ogni frammento nella scena visibile, per ogni luce nella scena.
Ad esempio, invece di inviare una serie di punti luce, potresti invece disegnarli tutti su una trama, come cerchi, con il colore di ciascun pixel che rappresenta l'intensità della luce. In questo modo, sarai in grado di calcolare l'effetto combinato di tutte le luci nella scena, e semplicemente di inviare quella texture finale (o buffer come talvolta viene chiamato) per calcolare l'illuminazione da.
Imparare a dividere il lavoro in più passaggi per lo shader è una tecnica molto utile. Gli effetti di sfocatura fanno uso di questa idea per accelerare lo shader, ad esempio, nonché effetti come uno shader fluido / fumogeno. È fuori dallo scopo di questo tutorial, ma potremmo rivisitare la tecnica in un futuro tutorial!
Ora che hai un light shader funzionante, ecco alcune cose da provare e giocare con:
z
valore) del vettore di luce per vedere il suo effettoLa trama delle pietre e la mappa normale utilizzate in questo tutorial sono tratte da OpenGameArt:
http://opengameart.org/content/50-free-textures-4-normalmaps
Esistono molti programmi che possono aiutarti a creare mappe normali. Se sei interessato a saperne di più su come creare le tue mappe normali, questo articolo può aiutarti.