Guida per principianti alla codifica degli shaders grafici

Imparare a scrivere shader grafici sta imparando a sfruttare la potenza della GPU, con le sue migliaia di core tutti in esecuzione in parallelo. È una sorta di programmazione che richiede una mentalità diversa, ma sbloccare il suo potenziale vale il guaio iniziale. 

Praticamente ogni simulazione grafica moderna che vedi è alimentata in qualche modo dal codice scritto per la GPU, dagli effetti di luce realistici nei giochi AAA all'avanguardia agli effetti di post-elaborazione 2D e alle simulazioni fluide. 

Una scena in Minecraft, prima e dopo aver applicato alcuni shader.

Lo scopo di questa guida

La programmazione degli shader a volte si presenta come un'enigmatica magia nera ed è spesso fraintesa. Ci sono molti esempi di codice là fuori che ti mostrano come creare effetti incredibili, ma offrono poche o nessuna spiegazione. Questa guida mira a colmare questa lacuna. Mi concentrerò maggiormente sulle basi della scrittura e sulla comprensione del codice dello shader, quindi puoi facilmente modificare, combinare o scrivere il tuo da zero!

Questa è una guida generale, quindi ciò che apprendi qui si applicherà a tutto ciò che può eseguire gli shader.

Quindi cos'è uno shader?

Uno shader è semplicemente un programma che viene eseguito nella pipeline grafica e comunica al computer come eseguire il rendering di ciascun pixel. Questi programmi sono chiamati shader perché sono spesso usati per controllare l'illuminazione e gli effetti di ombreggiatura, ma non c'è ragione per cui non possano gestire altri effetti speciali. 

Gli shader sono scritti in un linguaggio di ombreggiatura speciale. Non ti preoccupare, non devi uscire e imparare una lingua completamente nuova; useremo GLSL (OpenGL Shading Language) che è un linguaggio simile a C. (Esistono molte lingue di shading per piattaforme diverse, ma dal momento che sono tutte adattate per funzionare sulla GPU, sono tutte molto simili).

Nota: questo articolo riguarda esclusivamente gli shader dei frammenti. Se sei curioso di sapere cos'altro c'è fuori, puoi leggere su varie fasi nella pipeline grafica sul Wiki OpenGL.

Saltiamo dentro!

Useremo ShaderToy per questo tutorial. Ciò ti consente di iniziare a programmare gli shader direttamente nel tuo browser, senza il fastidio di impostare nulla! (Utilizza WebGL per il rendering, quindi avrai bisogno di un browser in grado di supportarlo.) La creazione di un account è facoltativa, ma utile per salvare il tuo codice.

Nota: ShaderToy è in versione beta al momento della stesura di questo articolo. Alcuni piccoli dettagli dell'interfaccia utente / sintassi potrebbero essere leggermente diversi. 

Facendo clic su New Shader, dovresti vedere qualcosa di simile a questo:

La tua interfaccia potrebbe apparire leggermente diversa se non hai effettuato l'accesso.

La piccola freccia nera nella parte inferiore è ciò che fai clic per compilare il tuo codice.

Cosa sta succedendo?

Sto per spiegare come funzionano gli shader in una frase. Siete pronti? Ecco qui!

L'unico scopo di uno shader è di restituire quattro numeri: r, g, B,e un.

Questo è tutto ciò che fa o può fare. La funzione che vedi di fronte scorre per ogni singolo pixel sullo schermo. Restituisce quei quattro valori di colore e questo diventa il colore del pixel. Questo è ciò che viene chiamato a Pixel Shader(a volte indicato come a Fragment Shader). 

Con questo in mente, proviamo a trasformare il nostro schermo in rosso. I valori rgba (rosso, verde, blu e "alpha", che definisce la trasparenza) vanno da 0 a 1, quindi tutto ciò che dobbiamo fare è tornare r, g, b, a = 1,0,0,1. ShaderToy si aspetta che il colore del pixel finale sia archiviato fragColor.

void mainImage (out vec4 fragColor, in vec2 fragCoord) fragColor = vec4 (1.0,0.0,0.0,1.0); 

Congratulazioni! Questo è il tuo primo shader di lavoro!

Sfida: Puoi cambiarlo in un solido colore grigio?

vec4 è solo un tipo di dati, quindi avremmo potuto dichiarare il nostro colore come variabile, in questo modo:

void mainImage (out vec4 fragColor, in vec2 fragCoord) vec4 solidRed = vec4 (1.0,0.0,0.0,1.0); fragColor = solidRed; 

Questo non è molto eccitante, però. Abbiamo il potere di eseguire il codice centinaia di migliaia di pixel in parallelo e li stiamo impostando tutti sullo stesso colore. 

Proviamo a rendere un gradiente sullo schermo. Bene, non possiamo fare molto senza sapere alcune cose sul pixel che stiamo influenzando, come la sua posizione sullo schermo ...

Input Shader

Il pixel shader passa alcune variabili che puoi usare. Il più utile per noi è fragCoordche contiene le coordinate xey del pixel (e z, se stai lavorando in 3D). Proviamo a trasformare tutti i pixel nella metà sinistra dello schermo nero e tutti quelli nella metà destra rossa:

void mainImage (out vec4 fragColor, in vec2 fragCoord) vec2 xy = fragCoord.xy; // Otteniamo le nostre coordinate per il pixel corrente vec4 solidRed = vec4 (0,0.0,0.0,1.0); // Questo è attualmente nero al momento se (xy.x> 300.0) // Numero arbitrario, non lo facciamo sapere quanto è grande il nostro schermo! solidRed.r = 1.0; // Imposta il suo componente rosso su 1.0 fragColor = solidRed; 

Nota: Per ogni vec4, puoi accedere ai suoi componenti tramite obj.x, obj.y, obj.z e obj.w oattraversoobj.r, obj.g, obj.b, obj.a. Sono equivalenti; è solo un modo conveniente di nominarli per rendere il tuo codice più leggibile, in modo che quando gli altri lo vedono obj.r, lo capiscono obj rappresenta un colore.

Vedi un problema con il codice sopra? Prova a cliccare sul vai a schermo intero pulsante in basso a destra della finestra di anteprima.

La proporzione dello schermo rosso varia in base alla dimensione dello schermo. Per garantire che esattamente la metà dello schermo sia rossa, dobbiamo sapere quanto è grande il nostro schermo. La dimensione dello schermo è non una variabile incorporata come la posizione dei pixel era, perché di solito dipende da te, il programmatore che ha creato l'app, per impostarlo. In questo caso, sono gli sviluppatori ShaderToy che impostano le dimensioni dello schermo.  

Se qualcosa non è una variabile incorporata, puoi inviare quelle informazioni dalla CPU (il tuo programma principale) alla GPU (il tuo shader). ShaderToy lo gestisce per noi. Puoi vedere tutte le variabili passate allo shader in Input Shader scheda. Vengono chiamate le variabili passate in questo modo dalla CPU alla GPU uniforme in GLSL. 

Analizziamo il nostro codice qui sopra per ottenere correttamente il centro dello schermo. Dovremo utilizzare l'input dello shader iResolution:

void mainImage (out vec4 fragColor, in vec2 fragCoord) vec2 xy = fragCoord.xy; // Otteniamo le nostre coordinate per il pixel corrente xy.x = xy.x / iResolution.x; // Dividiamo le coordinate in base alla dimensione dello schermo xy.y = xy.y / iResolution.y; // Ora x è 0 per il pixel più a sinistra e 1 per il pixel più a destra vec4 solidRed = vec4 (0,0.0,0.0,1.0); // Questo è in realtà in questo momento nero se (xy.x> 0.5) solidRed.r = 1.0; // Imposta il suo componente rosso su 1.0 fragColor = solidRed; 

Se provi ad allargare la finestra di anteprima questa volta, i colori dovrebbero comunque dividere perfettamente lo schermo a metà.

Da una divisione a una sfumatura

Trasformare questo in un gradiente dovrebbe essere piuttosto facile. I nostri valori cromatici vanno da 0 a 1, e le nostre coordinate ora vanno da 0 a 1 anche.

void mainImage (out vec4 fragColor, in vec2 fragCoord) vec2 xy = fragCoord.xy; // Otteniamo le nostre coordinate per il pixel corrente xy.x = xy.x / iResolution.x; // Dividiamo le coordinate in base alla dimensione dello schermo xy.y = xy.y / iResolution.y; // Ora x è 0 per il pixel più a sinistra e 1 per il pixel più a destra vec4 solidRed = vec4 (0,0.0,0.0,1.0); // Questo in realtà è nero adesso solidRed.r = xy.x; // Imposta il suo componente rosso sul valore x normalizzato fragColor = solidRed; 

E voilà! 

Sfida: Puoi trasformare questo in un gradiente verticale? Che dire della diagonale? Che dire di un gradiente con più di un colore?

Se giochi abbastanza, puoi dire che l'angolo in alto a sinistra ha le coordinate (0,1), non (0,0). Questo è importante da tenere a mente. 

Disegno di immagini

Giocare con i colori è divertente, ma se vogliamo fare qualcosa di impressionante, il nostro shader deve essere in grado di prendere input da un'immagine e modificarla. In questo modo possiamo creare uno shader che interessa l'intero schermo di gioco (come un effetto fluido sott'acqua o correzione del colore) o influenzare solo determinati oggetti in determinati modi in base agli input (come un sistema di illuminazione realistico).

Se stessimo programmando su una piattaforma normale, avremmo bisogno di inviare la nostra immagine (o texture) alla GPU come a uniforme, allo stesso modo in cui avresti inviato la risoluzione dello schermo. ShaderToy si prende cura di questo per noi. Ci sono quattro canali di input in basso:

I quattro canali di input di ShaderToy.

Clicca su iChannel0 e seleziona qualsiasi texture (immagine) che ti piace.

Una volta fatto, ora hai un'immagine che viene passata allo shader. C'è un problema, tuttavia: non c'èDrawImage () funzione. Ricorda, l'unica cosa che il pixel shader può mai fare è cambia il colore di ogni pixel.

Quindi, se possiamo solo restituire un colore, come possiamo disegnare la nostra texture sullo schermo? Abbiamo bisogno di mappare in qualche modo il pixel corrente su cui si trova il nostro shader, al pixel corrispondente sulla texture:

A seconda di dove è (0,0) sullo schermo, potrebbe essere necessario capovolgere l'asse y per mappare correttamente la trama. Al momento della stesura, ShaderToy è stato aggiornato per avere la sua origine in alto a sinistra, quindi non c'è bisogno di capovolgere nulla.

Possiamo farlo usando la funzione trama (textureData, coordinate), che prende i dati di texture e un (x, y) coordina la coppia come input e restituisce il colore della trama a quelle coordinate come a vec4.

Puoi abbinare le coordinate allo schermo nel modo che preferisci. È possibile disegnare l'intera trama su un quarto dello schermo (saltando i pixel, ridimensionandola in modo efficace verso il basso) o disegnando solo una porzione della trama. 

Per i nostri scopi, vogliamo solo vedere l'immagine, quindi abbineremo i pixel 1: 1:

void mainImage (out vec4 fragColor, in vec2 fragCoord) vec2 xy = fragCoord.xy / iResolution.xy; // Condensing this in one line vec4 texColor = texture (iChannel0, xy); // Ottieni il pixel in xy da iChannel0 fragColor = texColor; // Imposta il pixel dello schermo su quel colore

Con ciò, abbiamo la nostra prima immagine!

Ora che stai tracciando correttamente i dati da una trama, puoi manipolarli come preferisci! Puoi allungarlo, ridimensionarlo o giocare con i suoi colori.

Proviamo a modificare questo con un gradiente, simile a quello che abbiamo fatto sopra:

texColor.b = xy.x;

Congratulazioni, hai appena realizzato il tuo primo effetto di post-elaborazione!

Sfida: Puoi scrivere uno shader che trasformerà un'immagine in bianco e nero?

Nota che anche se si tratta di un'immagine statica, ciò che stai vedendo di fronte a te sta accadendo in tempo reale. Puoi vederlo da solo sostituendo l'immagine statica con un video: fai clic su iChannel0 inserisci di nuovo e seleziona uno dei video. 

Aggiunta di alcuni movimenti

Finora tutti i nostri effetti sono stati statici. Possiamo fare cose molto più interessanti facendo uso degli input che ShaderToy ci offre. iGlobalTimeè una variabile in costante aumento; possiamo usarlo come seme per realizzare effetti periodici. Proviamo a giocare un po 'con i colori:

void mainImage (out vec4 fragColor, in vec2 fragCoord) vec2 xy = fragCoord.xy / iResolution.xy; // Condensing this in one line vec4 texColor = texture (iChannel0, xy); // Ottieni il pixel a xy da iChannel0 texColor.r * = abs (sin (iGlobalTime)); texColor.g * = abs (cos (iGlobalTime)); texColor.b * = abs (sin (iGlobalTime) * cos (iGlobalTime)); fragColor = texColor; // Imposta il pixel dello schermo su quel colore

Ci sono funzioni seno e coseno integrate in GLSL, così come molte altre utili funzioni, come ottenere la lunghezza di un vettore o la distanza tra due vettori. I colori non dovrebbero essere negativi, quindi ci assicuriamo di ottenere il valore assoluto usando il addominali funzione.

Sfida: Puoi creare uno shader che cambia un'immagine avanti e indietro dal bianco e nero al colore pieno?

Una nota sul debug degli shader

Mentre potresti essere abituato a passare attraverso il tuo codice e stampare i valori di tutto per vedere cosa sta succedendo, non è davvero possibile quando scrivi shader. Potresti trovare alcuni strumenti di debug specifici per la tua piattaforma, ma in generale la soluzione migliore è impostare il valore che stai testando su qualcosa di grafico che puoi vedere.

Conclusione

Queste sono solo le basi per lavorare con gli shader, ma mettersi a proprio agio con questi fondamentali ti permetterà di fare molto di più. Sfoglia gli effetti su ShaderToy e vedi se riesci a comprenderne o replicarne alcuni!

Una cosa che non ho menzionato in questo tutorial è Vertex ShadersSono ancora scritti nella stessa lingua, eccetto che corrono su ogni vertice anziché su ciascun pixel e restituiscono una posizione e un colore. Vertex Shaders è solitamente responsabile della proiezione di una scena 3D sullo schermo (qualcosa che è incorporato nella maggior parte delle pipeline grafiche). I pixel shader sono responsabili di molti degli effetti avanzati che vediamo, ecco perché sono il nostro obiettivo.

Sfida finale: Puoi scrivere uno shader che rimuove lo schermo verde nei video su ShaderToy e aggiunge un altro video come sfondo al primo?

Questo è tutto per questa guida! Apprezzerei molto il tuo feedback e le tue domande. Se c'è qualcosa di specifico su cui vuoi saperne di più, per favore lascia un commento. Le guide future potrebbero includere argomenti come le basi dei sistemi di illuminazione, o come fare una simulazione fluida, o impostare shader per una piattaforma specifica.