Gli shader: il giardino proibito
Oggi cominciamo subito con un grosso spoiler. Avete presente quei giochi in cui ci sono praterie sconfinate mosse dal vento (tipo Ghost of Tsushima)? Oppure giochi in cui navighiamo su un mare coperto di onde (come Sea of Thieves)? Beh, sappiate che in entrambi i casi non c’è nulla che si muove. Davvero, è tutto fermo! Quello che vediamo è il risultato di programmi eseguiti direttamente dalla nostra scheda grafica, che ci fanno visualizzare un cubo come fosse una zolla di fili d’erba o delle foglie (avete presente Minecraft?).
Questi programmi prendono il nome di shader e rappresentano una parte molto importante dello sviluppo di un videogioco. Questo perché da loro dipende gran parte dell’impatto visivo ed emotivo che riusciamo a trasmettere al giocatore.
Ho intitolato questa uscita della newsletter Il giardino proibito perché gli shader, dal punto di vista del programmatore, sono un po’ un mondo a parte. Dobbiamo ragionare in maniera diversa, ci sono linguaggi dedicati e non abbiamo un vero e proprio ambiente di sviluppo. O meglio, qualcosa c’è... ma personalmente ho qualche riserva a chiamarlo così.
Oggi, quindi, faremo due passi dentro questo giardino proibito; vedremo dei bellissimi fiori, ma scopriremo anche che per coglierli servono dei guanti molto robusti.
Come programmare uno shader
Durante la mia carriera ho imparato un certo numero di linguaggi di programmazione. Non tantissimi, perché non è il mio mestiere e non mi sono laureato a Pisa (decidete liberamente voi se questo sia un pregio o un difetto), ma penso di aver accumulato un po’ di esperienza.
Mi piace però definire l’esperienza con gli shader esoterica.
Innanzitutto, esistono due modi per costruire uno shader.
Il primo modo consiste nell'utilizzare ambienti grafici, come il Material Editor di Unreal o lo Shader Graph di Unity.
Il secondo modo è scrivere direttamente il codice in un linguaggio accettato dal game engine che stiamo usando. Sia Unreal che Unity, ad esempio, accettano HLSL (High-Level Shader Language). Che, rimanga tra noi, di alto livello, secondo me, non è che abbia un granché.
Da educatore quale sono, ovviamente, suggerisco di scrivere direttamente il codice che, sebbene sia decisamente più complesso, aiuta tantissimo a capire i meccanismi interni della GPU. Inoltre, è possibile raggiungere un livello di controllo che un editor grafico difficilmente permette.
Qualsiasi strada decidiamo di prendere, la filosofia è la stessa. Uno shader è un software che elabora le informazioni di un oggetto da visualizzare in due passaggi: prima i vertici (gli spigoli della mesh) dell’oggetto e poi il colore dei singoli pixel (chiamati anche frammenti).
Quindi, per poter scrivere uno shader, ci sono due consigli molto importanti che mi sento di darvi subito.
Primo: MAI pensare in termini di “questo vertice” o “questo pixel”
Una GPU, diversamente da una CPU, è un sistema di calcolo SIMD (Single Instruction Multiple Data). Al suo interno, ogni singola operazione viene applicata contemporaneamente a tutti i dati.
Possiamo scrivere, ad esempio, le istruzioni per amplificare la componente di colore blu di un pixel:
color.b = color.b * 2.0;
color.b = clamp(color.b, 0.0, 1.0);
La prima riga prende la componente blu (.b) del colore attuale del pixel e la moltiplica per due. La seconda riga, con la funzione clamp, si assicura che il risultato sia compreso tra 0 (blu assente) e 1 (blu al massimo).
Ora, la domanda è: “Per quale pixel?” La risposta è: “Per tutti!”
Se vogliamo colorare di blu solo una parte dell’oggetto, allora dobbiamo collegare il calcolo a una caratteristica geometrica della posizione del pixel. Ad esempio, potremmo volere amplificare il blu solo nei pixel che si trovano sotto l’orizzonte, ovvero con una coordinata y minore di zero.
color.b = color.b * (1.0 - clamp(sign(coordinates.y), -1.0, 0.0)));
color.b = clamp(color.b, 0.0, 1.0);
La funzione sign restituisce -1, 0 o 1 in base al fatto che il frammento sia sotto o sopra l’orizzonte. La funzione clamp della prima riga limita i valori a -1 e 0. Di conseguenza, la componente blu verrà moltiplicata per 1 se il valore della coordinata y è maggiore di zero.
color.b = color.b * (1.0 - 0.0)
e per 2 se la coordinata y è sotto l’orizzonte (fate caso ai segni)
color.b = color.b * (1.0 - (-1.0))
A cosa può servire tutto questo?
Ad esempio, per creare una boa o un galleggiante che ondeggia e, sotto il livello dell’acqua, ha un colore diverso, più blu. Il game engine sposta su e giù il galleggiante, mentre la scheda grafica si occupa di farcelo vedere del colore giusto.
Questo semplice esempio, così come ve l’ho proposto, ci porta però al mio secondo suggerimento.
Secondo: MAI usare condizioni o cicli
Le strutture di controllo all’interno di HLSL esistono, ma usarle equivale a fare una specie di dispetto alla GPU. Infatti, il cosiddetto branching è, per la struttura interna della GPU, molto dispendioso. È molto meglio, soprattutto dal punto di vista delle prestazioni, eseguire tutti i calcoli e poi eliminare (moltiplicando per zero) le componenti che non ci interessano.
Prendiamo, ad esempio, la formula che vi ho proposto prima per discriminare sopra e sotto l’orizzonte. Per noi è più comodo pensare in termini di “if (y <= 0) …”. Per una GPU, invece, è molto più veloce eseguire il calcolo e, alla fine, moltiplicare per 1.
Non fatevi ingannare dalle biforcazioni che vedete nei tool grafici. Quelle sono duplicazioni di informazioni (la stessa variabile usata in più formule) e costanti che partecipano ai vari calcoli.
Gli shader non sono difficili
Qualcuno di voi, forse, si starà preoccupando. È comprensibile. Distinguiamo, però, i due aspetti. Da una parte c’è la fase di apprendimento, e quella non vi nascondo che presenta una certa difficoltà. Dall’altra, abbiamo la produzione degli effetti visivi che, una volta che abbiamo imparato, può dare grandi risultati con del codice relativamente semplice.
Ad esempio, uno dei primi shader che ho scritto per Unity era un esperimento in cui volevo rappresentare un oggetto a cubetti (con dei voxel). Dal punto di vista pratico, basta prendere i vertici (nella prima fase dello shader) e farli visualizzare spostandoli verso la coordinata discreta più vicina.
Il codice, nella sua forma più semplice, è questo:
void vert (inout appdata_full v) {
float3 snapped = floor(v.vertex.xyz / _VoxelSize)
* _VoxelSize + (_VoxelSize / 2);
v.vertex.xyz = (1 - step(_VoxelSize, 0)) * snapped
+ step(_VoxelSize, 0) * v.vertex.xyz;
}
Se _VoxelSize è la dimensione di un voxel (un parametro all’interno del materiale), la prima riga calcola, per tutte e tre le coordinate, il multiplo di _VoxelSize più vicino, tenendo conto del centro del voxel. La seconda riga è una specie di if: Se _VoxelSize è zero, mostrerà il vertice nella posizione originale; altrimenti lo visualizzerà nella posizione discretizzata.
Il risultato delle due linee di codice è il seguente:
Impegnandosi un po’ di più, è possibile ottenere deformazioni più complesse con una gestione dinamica dei vertici. Ad esempio, è possibile dare l’impressione che un oggetto si stia sciogliendo.
In questo shader faccio visualizzare i vertici in base a un parametro del materiale che indica il livello di scioglimento. Li visualizzo spostati verso il basso e verso il centro o l’esterno, a seconda della loro posizione relativa. Per ottenere l’animazione, è sufficiente un componente esterno che varia il parametro di scioglimento alla velocità opportuna.
Come si usano gli shader
Una volta scritto o generato (con un tool grafico) il codice HLSL, utilizzare uno shader è piuttosto semplice.
All’interno della definizione di ogni materiale sono presenti una serie di parametri e un selettore per lo shader. Se il nostro shader è stato compilato senza errori, lo troveremo tra quelli selezionabili. Il resto delle informazioni sul materiale diventerà i parametri che il nostro codice utilizzerà durante l’esecuzione. Questo, però, implica che il pannello di configurazione di un materiale cambierà, a volte anche in modo significativo, in base allo shader selezionato.
Vi va di provare?
Dopo questi primi passi nel giardino proibito (di cui, non vi nascondo, qui vi ho fatto vedere giusto l’ingresso) qualcuno di voi potrebbe decidere di volere entrare e anche rimanerci.
C’è un sacco di documentazione disponibile online. Io, però, suggerisco sempre di non buttarsi subito a testa bassa sui tutorial dei singoli game engine ma di chiarirsi prima le idee con qualcosa di più generico.
Trovo, per iniziale, molto utili i libri di Francisco Tufro; di cui il primo 2D Shader Development: Foundations è disponibile anche attraverso il sito web dell’autore. Una volta capiti i meccanismi, si può andare a fare un po’ di pratica con software di test come ShaderToy o Shadered. Nel momento in cui vi sentite pronti, allora potete andare a seguire dei tutorial più avanzati come quelli di ShaderLab o Cat Like Coding.
La strada, come vi dicevo, è un po’ in salita, ma può dare grandi soddisfazioni.
Per questa settimana direi che possiamo terminare qui. Avete un sacco di puntatori per esplorare il giardino proibito degli shader. Nel frattempo, se vi piace leggere di questi argomenti e non siete ancora iscritti, questo è il momento buono per farlo usando il pulsante qui sotto.
Potrete ricevere questa newsletter comodamente nella vostra casella di posta.
Happy hacking; e ci sentiamo tra sette giorni.