C'è sempre stata una certa aria di mistero intorno al fumo. È esteticamente gradevole da guardare ed elusivo da modellare. Come molti fenomeni fisici, è un sistema caotico, che rende molto difficile prevedere. Lo stato della simulazione dipende fortemente dalle interazioni tra le sue singole particelle.
Questo è esattamente ciò che rende un problema così grande da affrontare con la GPU: può essere scomposto per il comportamento di una singola particella, ripetuta simultaneamente milioni di volte in luoghi diversi.
In questo tutorial, ti guiderò attraverso la scrittura di un smoke shader da zero e ti insegnerò alcune utili tecniche di shader in modo da poter espandere il tuo arsenale e sviluppare i tuoi effetti personali!
Questo è il risultato finale su cui lavoreremo:
Clicca per generare più fumo. Puoi fork e modificare questo su CodePen.Implementeremo l'algoritmo presentato nel documento di Jon Stam su Fluid Dynamics in tempo reale nei giochi. Imparerai anche come rendere a una trama, anche conosciuto come usando buffer di frame, che è una tecnica molto utile nella programmazione degli shader per ottenere molti effetti.
Gli esempi e i dettagli specifici di implementazione in questo tutorial utilizzano JavaScript e ThreeJS, ma dovresti essere in grado di seguire tutte le piattaforme che supportano gli shader. (Se non hai familiarità con le basi della programmazione dello shader, assicurati di seguire almeno i primi due tutorial di questa serie.)
Tutti gli esempi di codice sono ospitati su CodePen, ma puoi anche trovarli nel repository GitHub associato a questo articolo (che potrebbe essere più leggibile).
L'algoritmo nel documento di Jos Stam favorisce la velocità e la qualità visiva rispetto all'accuratezza fisica, che è esattamente ciò che vogliamo in un'impostazione di gioco.
Questo documento può sembrare molto più complicato di quanto non sia in realtà, soprattutto se non si è esperti in equazioni differenziali. Tuttavia, l'essenza di questa tecnica è riassunta in questa figura:
Questo è tutto ciò che dobbiamo implementare per ottenere un effetto fumo dall'aspetto realistico: il valore in ogni cella si dissipa in tutte le celle adiacenti su ogni iterazione. Se non è immediatamente chiaro come funziona, o se vuoi solo vedere come sarebbe, puoi armeggiare con questa demo interattiva:
Guarda la demo interattiva su CodePen.Facendo clic su qualsiasi cella si imposta il valore su 100
. Puoi vedere come ogni cella perde lentamente il suo valore per i suoi vicini nel tempo. Potrebbe essere più facile da vedere facendo clic Il prossimo per vedere i singoli fotogrammi. Cambia il Modalità display per vedere come apparirebbe se avessimo un valore di colore corrispondente a questi numeri.
La demo sopra è tutta eseguita sulla CPU con un ciclo che attraversa ogni cella. Ecco come appare questo loop:
// W = numero di colonne nella griglia // H = numero di righe nella griglia // f = il fattore di diffusione / diffuso // Copiamo prima la griglia su newGrid per evitare di modificare la griglia mentre la leggiamo (var r = 1; rQuesto snippet è davvero il nucleo dell'algoritmo. Ogni cellula guadagna un po 'delle sue quattro celle vicine, meno il suo valore, dove
f
è un fattore inferiore a 1. Moltiplichiamo il valore corrente della cella per 4 per assicurarci che si diffonda dal valore più alto al valore più basso.Per chiarire questo punto, considera questo scenario:
Prendi il cella nel mezzo (in posizione
[1,1]
nella griglia) e applicare l'equazione di diffusione sopra. Assumiamof
è0.1
:0,1 * (100 + 100 + 100 + 100-4 * 100) = 0,1 * (400-400) = 0Nessuna diffusione accade perché tutte le celle hanno valori uguali!
Se consideriamola cella in alto a sinistra invece (supponiamo che le celle al di fuori della griglia nella foto siano tutte
0
):0,1 * (100 + 100 + 0 + 0-4 * 0) = 0,1 * (200) = 20Quindi otteniamo una rete aumentare di 20! Consideriamo un caso finale. Dopo un timestep (applicando questa formula a tutte le celle), la nostra griglia sarà simile a questa:
Diamo un'occhiata al diffuso sul cella nel mezzo ancora:
0,1 * (70 + 70 + 70 + 70-4 * 100) = 0,1 * (280 - 400) = -12Otteniamo una rete diminuiredi 12! Quindi scorre sempre dai valori più alti a quelli inferiori.
Ora, se volessimo che questo sembrasse più realistico, potremmo ridurre la dimensione delle celle (cosa che puoi fare nella demo), ma a un certo punto le cose si faranno molto lente, dato che siamo costretti a correre sequenzialmente attraverso ogni cellula. Il nostro obiettivo è essere in grado di scrivere questo in uno shader, dove possiamo usare la potenza della GPU per elaborare tutte le celle (come pixel) simultaneamente in parallelo.
Quindi, per riassumere, la nostra tecnica generale è che ogni pixel dia via parte del suo valore di colore, ogni fotogramma, ai suoi pixel vicini. Sembra piuttosto semplice, vero? Implementiamo ciò e vediamo cosa otteniamo!
Implementazione
Iniziamo con uno shader di base che disegna l'intero schermo. Per assicurarti che funzioni, prova a impostare lo schermo su un nero fisso (o su qualsiasi colore arbitrario). Ecco come la configurazione che sto utilizzando appare in Javascript.
Puoi fork e modificare questo su CodePen. Fai clic sui pulsanti in alto per visualizzare HTML, CSS e JS.Il nostro shader è semplicemente:
uniforme vec2 res; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = vec4 (0.0,0.0,0.0,1.0);
res
epixel
sono lì per darci le coordinate del pixel corrente. Stiamo passando le dimensioni dello schermo inres
come variabile uniforme. (Non li stiamo usando in questo momento, ma lo faremo presto).Passaggio 1: spostamento dei valori tra pixel
Ecco cosa vogliamo implementare nuovamente:
La nostra tecnica generale prevede che ogni pixel dia via parte del suo valore di colore ad ogni fotogramma rispetto ai pixel adiacenti.Detto nella sua forma attuale, questo è impossibilefare con uno shader. Riesci a vedere perché? Ricorda che tutto ciò che uno shader può fare è restituire un valore di colore per il pixel corrente che sta elaborando, quindi dobbiamo rideterminarlo in un modo che influisce solo sul pixel corrente. Possiamo dire:
Ogni pixel dovrebbe guadagno un po 'di colore dai suoi vicini, mentre ne perde un po'.Ora questo è qualcosa che possiamo implementare. Se in realtà provi a farlo, tuttavia, potresti incontrare un problema fondamentale ...
Considera un caso molto più semplice. Diciamo che vuoi solo creare uno shader che trasformi un'immagine lentamente nel tempo. Potresti scrivere uno shader come questo:
uniforme vec2 res; texture sampler2D uniforme; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (tex, pixel); // Questo è il colore del pixel corrente gl_FragColor.r + = 0.01; // Incrementa il componente rossoE aspettati che, ad ogni fotogramma, il componente rosso di ciascun pixel aumenti di
0.01
. Invece, tutto quello che otterrete è un'immagine statica in cui tutti i pixel sono solo un po 'più rossi di quelli che hanno iniziato. Il componente rosso di ogni pixel aumenterà sempre e solo una volta, nonostante il fatto che lo shader esegua ogni frame.Puoi vedere perché??
Il problema
Il problema è che qualsiasi operazione che facciamo nel nostro shader viene inviata allo schermo e quindi persa per sempre. Il nostro processo in questo momento assomiglia a questo:
Passiamo le nostre variabili uniformi e la texture allo shader, rende i pixel leggermente più rossi, lo disegna sullo schermo e ricomincia da capo. Tutto ciò che disegniamo nello shader viene cancellato dalla prossima volta che disegniamo.
Quello che vogliamo è qualcosa del genere:
Invece di disegnare direttamente sullo schermo, possiamo disegnare un po 'di texture e poi disegnare quello texture sullo schermo. Avrai la stessa immagine sullo schermo come avresti altrimenti, tranne che ora puoi restituire l'output come input. Quindi puoi creare shader che generano o propagano qualcosa, piuttosto che cancellarli ogni volta. Questo è ciò che chiamo "trucco del frame buffer".
Il trucco del buffer del frame
La tecnica generale è la stessa su qualsiasi piattaforma. Alla ricerca di "renderizza texture" in qualsiasi lingua o strumenti che stai usando dovrebbe far apparire i necessari dettagli di implementazione. Puoi anche cercare come usare oggetti del frame buffer, che è solo un altro nome per essere in grado di eseguire il rendering su un buffer anziché sul rendering sullo schermo.
In ThreeJS, l'equivalente di questo è WebGLRenderTarget. Questo è ciò che useremo come struttura intermedia per il rendering. C'è un piccolo avvertimento lasciato: non puoi leggere e rendere contemporaneamente la stessa texture. Il modo più semplice per aggirare è semplicemente utilizzare due texture.
Lascia che A e B siano due trame che hai creato. Il tuo metodo sarebbe quindi:
- Passa A attraverso il tuo shader, renderizza su B.
- Rendi B sullo schermo.
- Passa B attraverso lo shader, renderizza su A.
- Render A sul tuo schermo.
- Ripeti 1.
Oppure, un modo più conciso per codificare questo sarebbe:
- Passa A attraverso il tuo shader, renderizza su B.
- Rendi B sullo schermo.
- Scambia A e B (quindi la variabile A ora mantiene la trama che era in B e viceversa).
- Ripeti 1.
Questo è tutto ciò che serve. Ecco una implementazione di questo in ThreeJS:
Puoi fork e modificare questo su CodePen. Il nuovo codice shader è nel HTML linguetta.Questo è ancora uno schermo nero, che è quello che abbiamo iniziato. Anche il nostro shader non è molto diverso:
uniforme vec2 res; // La larghezza e l'altezza del nostro schermo uniforme sampler2D bufferTexture; // Our input texture void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (bufferTexture, pixel);Tranne ora se aggiungi questa linea (provalo!):
gl_FragColor.r + = 0,01;Vedrai lo schermo diventare rosso lentamente, invece di aumentare di poco
0.01
una volta. Questo è un passo abbastanza significativo, quindi dovresti prendere un momento per giocare e confrontarlo con il nostro setup iniziale.Sfida: Cosa succede se metti
gl_FragColor.r + = pixel.x;
quando si utilizza un esempio di frame buffer, rispetto a quando si usa l'esempio di setup? Prenditi un momento per pensare perché i risultati sono diversi e perché hanno senso.Passaggio 2: Ottenere una fonte di fumo
Prima di poter muovere qualcosa, abbiamo bisogno di un modo per creare fumo in primo luogo. Il modo più semplice è impostare manualmente un'area arbitraria in bianco nello shader.
// Ottieni la distanza di questo pixel dal centro dello schermo float dist = distance (gl_FragCoord.xy, res.xy / 2.0); if (dist < 15.0) //Create a circle with a radius of 15 pixels gl_FragColor.rgb = vec3(1.0);Se vogliamo verificare se il nostro frame buffer funziona correttamente, possiamo provarci aggiungere a il valore del colore invece di impostarlo. Dovresti vedere il cerchio lentamente diventare più bianco e più bianco.
// Ottieni la distanza di questo pixel dal centro dello schermo float dist = distance (gl_FragCoord.xy, res.xy / 2.0); if (dist < 15.0) //Create a circle with a radius of 15 pixels gl_FragColor.rgb += 0.01;Un altro modo è quello di sostituire quel punto fisso con la posizione del mouse. È possibile passare un terzo valore per il fatto che il mouse sia premuto o meno, in questo modo è possibile fare clic per creare fumo. Ecco una implementazione per questo.
Clicca per aggiungere "fumo". Puoi fork e modificare questo su CodePen.Ecco come appare il nostro shader ora:
// La larghezza e l'altezza del nostro schermo vec2 res uniforme; // Il nostro input texture sample sampler2D bufferTexture; // La x, y sono il posiiton. La z è l'uniforme potenza / densità vec3 smokeSource; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (bufferTexture, pixel); // Ottieni la distanza del pixel corrente dal galleggiante float dist = distance (smokeSource.xy, gl_FragCoord.xy); // Genera fumo quando si preme il mouse se (smokeSource.z> 0.0 && dist < 15.0) gl_FragColor.rgb += smokeSource.z;Sfida: Ricorda che le ramificazioni (condizionali) sono solitamente costose negli shader. Riesci a riscriverlo senza usare un'istruzione if? (La soluzione è nel CodePen.)
Se questo non ha senso, c'è una spiegazione più dettagliata dell'utilizzo del mouse in uno shader nel precedente tutorial sull'illuminazione.
Step 3: Diffuse the Smoke
Ora questa è la parte facile e la più gratificante! Ora abbiamo tutti i pezzi, dobbiamo solo dire allo shader: ogni pixel dovrebbe guadagnoun po 'di colore dai suoi vicini, mentre ne perde un po'.
Che assomiglia a questo:
// Smoke diffuse float xPixel = 1.0 / res.x; // La dimensione di un singolo pixel float yPixel = 1.0 / res.y; vec4 rightColor = texture2D (bufferTexture, vec2 (pixel.x + xPixel, pixel.y)); vec4 leftColor = texture2D (bufferTexture, vec2 (pixel.x-xPixel, pixel.y)); vec4 upColor = texture2D (bufferTexture, vec2 (pixel.x, pixel.y + yPixel)); vec4 downColor = texture2D (bufferTexture, vec2 (pixel.x, pixel.y-yPixel)); // Equazione diffusa gl_FragColor.rgb + = 14.0 * 0.016 * (leftColor.rgb + rightColor.rgb + downColor.rgb + upColor.rgb - 4.0 * gl_FragColor.rgb);Abbiamo il nostro
Clicca per aggiungere fumo. Puoi fork e modificare questo su CodePen.f
fattore come prima. In questo caso abbiamo il timestep (0,016
è 1/60, perché stiamo lavorando a 60 fps) e ho continuato a provare i numeri fino al mio arrivo14
, che sembra avere un bell'aspetto Ecco il risultato:Uh Oh, è bloccato!
Questa è la stessa equazione diffusa che abbiamo usato nella demo della CPU, eppure la nostra simulazione si blocca! Cosa dà?
Si scopre che le trame (come tutti i numeri su un computer) hanno una precisione limitata. Ad un certo punto, il fattore che stiamo sottraendo diventa così piccolo da essere arrotondato a 0, quindi i simulation si sono bloccati. Per risolvere questo problema, dobbiamo verificare che non scenda al di sotto di un valore minimo:
float factor = 14.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r + upColor.r - 4.0 * gl_FragColor.r); // Dobbiamo tenere conto della bassa precisione del minimo float di texels = 0,003; if (factor> = -minimum && factor < 0.0) factor = -minimum; gl_FragColor.rgb += factor;Sto usando il
r
componente invece delrgb
per ottenere il fattore, perché è più facile lavorare con numeri singoli, e perché tutti i componenti sono uguali in ogni caso (dal momento che il nostro fumo è bianco).Per prova ed errore, ho trovato
Clicca per aggiungere fumo. Puoi fork e modificare questo su CodePen.0.003
essere una buona soglia in cui non si blocca. Mi preoccupo solo del fattore quando è negativo, per garantire che possa sempre diminuire. Una volta applicata questa correzione, ecco cosa otteniamo:Step 4: Diffuse il fumo verso l'alto
Questo non sembra molto fumo, però. Se vogliamo che fluisca verso l'alto anziché in tutte le direzioni, dobbiamo aggiungere dei pesi. Se i pixel in basso hanno sempre un'influenza maggiore rispetto alle altre direzioni, allora i nostri pixel sembreranno muoversi verso l'alto.
Giocando con i coefficienti, possiamo arrivare a qualcosa che sembra abbastanza decente con questa equazione:
// Fattore float diffuso dell'equazione = 8.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r * 3.0 + upColor.r - 6.0 * gl_FragColor.r);Ed ecco come appare:
Clicca per aggiungere fumo. Puoi fork e modificare questo su CodePen.Una nota sull'equazione diffusa
Fondamentalmente ho giocato con i coefficienti per far sembrare che stia andando bene verso l'alto. Puoi anche farlo fluire in qualsiasi altra direzione.
È importante notare che è molto facile rendere questa simulazione "esplosiva". (Prova a cambiare il
6.0
lì dentro5.0
e vedi cosa succede). Questo è ovviamente perché le cellule stanno guadagnando più di quanto stanno perdendo.Questa equazione è in realtà ciò che il documento che ho citato si riferisce al modello "cattivo diffuso". Presentano un'equazione alternativa che è più stabile, ma non è molto conveniente per noi, principalmente perché ha bisogno di scrivere sulla griglia da cui sta leggendo. In altre parole, dovremmo essere in grado di leggere e scrivere sulla stessa texture allo stesso tempo.
Quello che abbiamo è sufficiente per i nostri scopi, ma se sei curioso puoi dare un'occhiata alla spiegazione sul giornale. Troverai anche l'equazione alternativa implementata nella demo della CPU interattiva nella funzione
diffuse_advanced ()
.Una correzione rapida
Una cosa che potresti notare, se giochi con il tuo fumo, è che rimane bloccata nella parte inferiore dello schermo se ne generi qualcuno. Questo perché i pixel di quella riga in basso stanno cercando di ottenere i valori dai pixel sotto loro, che non esistono.
Per risolvere questo problema, ci assicuriamo semplicemente che i pixel nella riga in basso trovino
0
sotto di loro:// Gestire il limite inferiore // Questo deve essere eseguito prima della funzione diffusa if (pixel.y <= yPixel) downColor.rgb = vec3(0.0);Nella demo della CPU, ho affrontato questo semplicemente non facendo diffondere le celle nel confine. In alternativa, puoi semplicemente impostare manualmente una cella fuori limite per avere un valore di
0
. (La griglia nella demo della CPU si estende di una riga e una colonna di celle in ogni direzione, quindi non si vedono mai i limiti)Una griglia di velocità
Congratulazioni! Ora hai un fumogeno funzionante! L'ultima cosa che volevo discutere brevemente è il campo di velocità che il documento menziona.
Il tuo fumo non deve diffondersi uniformemente verso l'alto o in una direzione specifica; potrebbe seguire un modello generale come quello nella foto. Puoi farlo inviando un'altra texture dove i valori del colore rappresentano la direzione in cui il fumo dovrebbe fluire in quella posizione, nello stesso modo in cui abbiamo usato una mappa normale per specificare una direzione per ogni pixel nel nostro tutorial sull'illuminazione.
In effetti, anche la tua texture di velocità non deve essere statica! Potresti usare il trucco del frame buffer per far cambiare le velocità in tempo reale. Non lo coprirò in questo tutorial, ma c'è un grande potenziale da esplorare.
Conclusione
Se c'è qualcosa da togliere da questo tutorial, è che essere in grado di renderizzare una texture invece che solo sullo schermo è una tecnica molto utile.
Cosa sono buoni per i frame buffer?
Un uso comune per questo è post produzionenei giochi. Se vuoi applicare una sorta di filtro colorato, invece di applicarlo a ogni singolo oggetto, puoi renderizzare tutti gli oggetti su una trama delle dimensioni dello schermo, quindi applicare lo shader alla texture finale e disegnarlo sullo schermo.
Un altro esempio è quando si implementano shader che richiedono più passaggi, come sfocatura.Di solito esegui la tua immagine attraverso lo shader, sfoca la direzione x, quindi eseguila nuovamente per sfocare la direzione y.
Un ultimo esempio è rendering differito, come discusso nel precedente tutorial sull'illuminazione, che è un modo semplice per aggiungere in modo efficiente molte fonti di luce alla scena. La cosa interessante di questo è che il calcolo dell'illuminazione non dipende più dalla quantità di sorgenti luminose che hai.
Non abbiate paura delle carte tecniche
C'è sicuramente più dettaglio nel documento che ho citato, e presume che tu abbia una certa familiarità con l'algebra lineare, ma non lasciare che ti scoraggi dal sezionarlo e provare ad implementarlo. L'essenza di esso è risultata piuttosto semplice da implementare (dopo alcuni aggiustamenti con i coefficienti).
Spero che tu abbia imparato qualcosa in più sugli shader qui, e se hai domande, suggerimenti o miglioramenti, ti preghiamo di condividerli qui sotto!