Quando in un videogioco si verifica un esplosione, un evento violento o un impatto, un modo graficamente interessante per dare feedback al giocatore e per rendere meglio a schermo ciò che è avvenuto è utilizzando un effetto onda d’urto.
In questo breve tutorial vedremo come scrivere un fragment shader in GLSL per creare un effetto simile.
Prerequisiti
Un fragment shader è un programma che la GPU esegue per determinare il colore di ogni pixel sullo schermo. Il nome “fragment shader” è dovuto al fatto che questo particolare programma lavora su una tipologia di dati nota come “fragment”. Si tratta, in parole semplici, di una collezione di informazioni necessarie a generare un singolo pixel.
Di default, il fragment shader prende in input un vettore in 4 dimensioni chiamato gl_FragCoord
. Le coordinate x e y di questo vettore corrispondono alla posizione sulla finestra del fragment corrente.
Per questo tutorial utilizzerò VsCode come ambiente di sviluppo, con l’estensione GLSLCanvas per visualizzare l’output dello shader durante la sua realizzazione.
Parte 1 – Setup
Cominciamo scrivendo il codice GLSL necessario per iniziare.
- Definiamo la precisione delle variabili float:
mediump
va benissimo per quello che dobbiamo fare - Dichiariamo il
sampler2D
, contenente i dati che compongono la texture che utilizzeremo per questo tutorial. - Dichiariamo la risoluzione della finestra su cui lavoreremo
- Cominciamo a scrivere la funzione
main()
precision mediump float;
uniform sampler2D u_tex0;
uniform vec2 u_resolution;
void main() {
}
Code language: JavaScript (javascript)
La funzione main()
sarà eseguita per ogni fragment. Inseriremo al suo interno tutto il codice necessario per ottenere l’effetto desiderato.
Parte 2 – Disegnamo un cerchio
Ora siamo pronti per scrivere il nostro fragment shader. L’effetto che vogliamo ottenere è quello di un anello che si estende dal centro della finestra fino alle estremità. L’anello in sé non sarà visibile, ma sarà utilizzato per applicare una distorsione alla nostra texture. Invece di partire subito con la realizzazione dell’anello, però, cominciamo con una forma geometrica più semplice: un cerchio.
Un cerchio è una figura geometrica tale per cui tutti i punti al suo interno hanno distanza dal centro inferiore al raggio del cerchio stesso. Teniamo a mente questa definizione durante la scrittura del programma. Tutto il codice che segue è da considerarsi all’interno della funzione main()
Le coordinate
Cominciamo col definire le coordinate che utilizzeremo nei nostri calcoli. Come abbiamo accennato prima, gl_FragCoord
contiene le coordinate x e y del fragment corrente in riferimento alla finestra.
Con una finestra di dimensioni 500x350px, gl_FragCoord.xy
assumerà valori compresi tra (0,0) e (500,350). Una pratica abituale, quando si realizzano fragment shader, è quella di normalizzare queste coordinate nell’intervallo 0-1.
Per fare ciò ci basta dividere le coordinate per u_resolution.xy
, la risoluzione della finestra.
Questo, però, comporta uno svantaggio importante da considerare: un’unità sull’asse x non corrisponde a un’unità sull’asse y. Se disegnassi il punto (0.2 , 0.2), vedrei che questo, nella finestra di dimensioni 500x350px, è più spostato verso destra che verso l’alto.
Oltre a mantenere una copia delle coordinate normalizzate, quindi, è utile anche descrivere le coordinate considerando questo “problema”. La soluzione è molto semplice. Invece di dividere gl_FragCoord.xy
per u_resolution.xy
(che significa dividere la componente x per la larghezza della finestra, e quella y per l’altezza della finestra), ci basta dividere le componenti per una sola delle due dimensioni (u_resolution.x
, per esempio).
Abbiamo quindi:
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec2 pos = gl_FragCoord.xy / u_resolution.x;
Il cerchio
Definiamo ora 3 variabili: un vec2
per il centro del cerchio, un float
per il raggio e un float
per la distanza tra il centro e la coordinata corrente. Il centro è espresso nel secondo sistema di coordinate che abbiamo descritto.
vec2 center = vec2(0.5 , u_resolution.y / u_resolution.x * 0.5);
float dist = distance(center , pos);
float radius = 0.3;
E disegnamo il cerchio valutando due casi:
- Se la distanza è minore o uguale al raggio, allora il punto corrente è all’interno del cerchio e lo coloriamo di bianco.
- In caso contrario il punto è fuori dal cerchio e lo coloriamo di nero.
void main()
{
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec2 pos = gl_FragCoord.xy / u_resolution.x;
vec2 center = vec2(0.5 , u_resolution.y / u_resolution.x * 0.5);
float dist = distance(center , pos);
float radius = 0.3;
vec4 color;
if(dist < radius) {
color = vec4(1.0 , 1.0 , 1.0 , 1.0);
} else {
color = vec4(0.0 , 0.0 , 0.0 , 1.0);
}
gl_FragColor = color;
}
Code language: HTML, XML (xml)
La logica che abbiamo implementato tramite if / else può essere semplificata utilizzando la funzione float step(float edge , float x)
, che dà in output 1 se edge < x, e 0 in caso contrario.
float t = step(dist , radius);
color = vec4(vec3(t) , 1.0);
Notiamo un dettaglio importante: i bordi del cerchio sono seghettati. In effetti, i colori che il cerchio può assumere sono solo bianco e nero. Serve un modo per rendere il bordo più “sfumato”.
La soluzione è usare la funzione float smoothstep(float edge0, float edge1, float x), riassumibile, matematicamente, in questo modo:
- Se x <= edge0, la funzione restituisce 0
- Se x è compreso tra edge0 e edge1, la funzione restituisce un valore tra 0 e 1 che varia in base a quanto dista x da edge0 e edge1 tramite interpolazione di Hermite.
- Se x >= edge1, la funzione restituisce 1
Sostituiamo quindi la funzione step
con questa funzione, utilizzando un parametro blur_factor
per personalizzare l’ampiezza della zona sfumata.
float blur_factor = 0.01;
float t = 1.0 - smoothstep(radius - blur_factor, radius, dist);
La sottrazione a 1.0 del valore in output dalla smoothstep
serve semplicemente a invertire il risultato: in questo modo un valore t > 0 corrisponderà a una posizione all’interno del cerchio.
Parte 3 – creiamo l’anello
Per creare l’anello abbiamo due soluzioni: possiamo creare un secondo cerchio più piccolo e sovrapporlo al primo, oppure, per mantenere il codice pulito e salvare un po’ di performance, possiamo cambiare i parametri che passiamo alla funzione smoothstep
.
Siccome dobbiamo creare un anello, introduciamo una variabile disk_thickness
che ne specifichi la larghezza. Dopodiché sostituiamo i parametri della smoothstep
in questo modo:
float t = 1.0 - smoothstep(disk_thickness - blur_factor, disk_thickness, abs(radius - dist));
abs(radius - dist)
indica la distanza tra le coordinate del punto corrente e la circonferenza. Quando questa distanza è inferiore allo spessore dell’anello, il punto in questione è da considerarsi all’interno dell’anello.
Parte 4 – aggiungiamo la distorsione
Per creare un effetto distorsione dobbiamo innanzitutto avere una texture di riferimento su cui effettuare la distorsione, in modo da poter valutare graficamente quello che stiamo facendo.
Utilizziamo quindi la funzione vec4 texture2D(sampler2D sampler, vec2 coord)
per determinare il colore di ogni pixel in base alle coordinate di riferimento. Assegnando l’output di questa funzione a gl_FragColor
verrà mostrata la nostra immagine.
gl_FragColor = texture2D(u_tex0 , uv);
Per ottenere un effetto di distorsione bisogna essenzialmente prelevare il colore del pixel di riferimento a partire da una coordinata diversa da quella corrente. Utilizzeremo il nostro anello per determinare quando applicare la distorsione e a quale intensità.
Cominciamo definendo un float distortion_factor
e assegnandogli un valore a piacere. Utilizzeremo questa variabile per modulare l’intensità globale della distorsione.
Siccome la distorsione deve essere radiale (dato che stiamo lavorando con un anello), dobbiamo calcolare per le coordinate correnti il vettore da sommare a uv
per ottenere il colore del pixel di riferimento. Questo vettore sarà uscente dal centro dell’anello, e avrà magnitudine pari a t
, il valore che indica se ci si trova o meno all’interno dell’anello. In questo modo per coordinate fuori dall’anello, e quindi con t == 0
, la distorsione non verrà applicata.
il vettore uscente dal centro dell’anello è pos - center
, ma la sua magnitudine varia in base alle coordinate correnti. Normalizzando il vettore si risolve il problema e ci si assicura di poter gestire la magnitudine del vettore in maniera indipendente.
Il codice relativo, quindi, sarà il seguente:
vec2 distortion_vector = normalize(pos - center) * distortion_factor * t;
vec2 distorted_uv = uv + distortion_vector;
A questo punto basta usare distorted_uv come input a texture2D per ottenere la distorsione.
gl_FragColor = texture2D(u_tex0 , distorted_uv);
L’effetto è già ottimo così, ma possiamo valutare di aggiungere un effetto di aberrazione cromatica per un risultato ancora più interessante.
Parte 5 – aggiungiamo l’aberrazione cromatica
L’aberrazione cromatica è un effetto visivo che si traduce nella separazione dei canali RGB nell’immagine finale. Se estraiamo le componenti R, G e B da un’immagine, creiamo 3 immagini distinte usando solo una singola componente, e le sovrapponiamo in modalità additiva spostando leggermente ciascuna delle 3 immagini, otteniamo l’effetto in questione.
Per fare ciò ci basta estrarre in maniera indipendentemente i canali RGB utilizzando 3 funzioni texture2D
, a cui passeremo delle varianti di distorted_uv
ottenute sommando a ciascuna un vettore di offset per prelevare la relativa componente di colore da una posizione leggermente diversa.
Vogliamo però che l’aberrazione sia presente solo in prossimità dell’anello. Utilizzeremo ancora una volta t
come maschera per rimuovere l’effetto quando non ci si trova dentro l’anello.
vec2 r_shift = vec2(0.05 , 0.02) * t;
vec2 g_shift = vec2(0.03 , 0.04) * t;
vec2 b_shift = vec2(-0.04 , 0.02) * t;
float r = texture2D(u_tex0 , distorted_uv + r_shift).r;
float g = texture2D(u_tex0 , distorted_uv + g_shift).g;
float b = texture2D(u_tex0 , distorted_uv + b_shift).b;
gl_FragColor = vec4(r , g , b , 1.);
Risultato finale
Ecco, quindi, il risultato finale:
Il codice, completo, è questo:
precision mediump float;
uniform sampler2D u_tex0;
uniform float u_time;
uniform vec2 u_resolution;
//=====================================================
void main()
{
vec2 uv = gl_FragCoord.xy / u_resolution.xy; //x and y in range 0-1
vec2 pos = gl_FragCoord.xy / u_resolution.x; //x in range 0-1
vec2 center = vec2(0.5 , u_resolution.y / u_resolution.x * 0.5);
float dist = distance(center , pos);
//ring params
float radius = mod(u_time * 0.05 , 1.);
float blur_factor = 0.08;
float disk_thickness = 0.06;
float t = smoothstep(disk_thickness, disk_thickness - blur_factor , abs(radius - dist));
//distortion params
float distortion_factor = 0.2;
//chromatic aberration params
vec2 r_shift = vec2(0.05 , 0.02) * t;
vec2 g_shift = vec2(0.03 , 0.04) * t;
vec2 b_shift = vec2(-0.04 , 0.02) * t;
vec2 distortion_vector = normalize(pos - center) * distortion_factor * t;
vec2 distorted_uv = uv + distortion_vector;
float r = texture2D(u_tex0 , distorted_uv + r_shift).r;
float g = texture2D(u_tex0 , distorted_uv + g_shift).g;
float b = texture2D(u_tex0 , distorted_uv + b_shift).b;
gl_FragColor = vec4(r , g , b , 1.);
}
Code language: GLSL (glsl)
ho utilizzato una variabile che tiene traccia del tempo trascorso dall’avvio per far variare la lunghezza del raggio. Tramite modulo l’animazione viene messa in loop.
🙂