Cum se scrie un shader de fum

Întotdeauna a fost un miros de fum în jurul fumului. Este plăcut din punct de vedere estetic să vă uitați și să vă simțiți ușor modelul. Ca multe fenomene fizice, este un sistem haotic, ceea ce face foarte dificilă prezicerea. Starea simulării depinde în mare măsură de interacțiunile dintre particulele sale individuale. 

Aceasta este exact ceea ce face ca o astfel de problemă mare să fie abordată cu GPU: poate fi împărțită la comportamentul unei singure particule, repetată simultan de milioane de ori în diferite locații. 

În acest tutorial, vă voi trece prin scrierea unui shader de fum de la zero și vă voi învăța câteva tehnici de shader utile, astfel încât să puteți extinde arsenalul și să vă dezvoltați propriile efecte!

Ce veți învăța

Acesta este rezultatul final pe care îl vom strădui:

Faceți clic pentru a genera mai mult fum. Puteți rula și edita acest lucru pe CodePen.

Vom implementa algoritmul prezentat în lucrarea lui Jon Stam despre Dynamics Fluid în timp real în jocuri. Veți învăța, de asemenea, cum să a da o textura, cunoscută și ca utilizarea cadru de tampon, care este o tehnică foarte utilă în programarea shader pentru a obține multe efecte. 

Înainte de a începe

Exemplele și detaliile specifice de implementare din acest tutorial utilizează JavaScript și ThreeJS, dar ar trebui să puteți urmări de-a lungul oricărei platforme care acceptă shadere. (Dacă nu sunteți familiarizați cu elementele de bază ale programării shader, asigurați-vă că treceți cel puțin primele două tutoriale din această serie.)

Toate exemplele de cod sunt găzduite pe CodePen, dar le puteți găsi și în repozitoriul GitHub asociat cu acest articol (care ar putea fi mai ușor de citit). 

Teoria și fundalul

Algoritmul din hârtia lui Jos Stam favorizează viteza și calitatea vizuală peste acuratețea fizică, ceea ce este exact ceea ce ne dorim într-un set de joc. 

Această lucrare poate arăta mult mai complicată decât este într-adevăr, mai ales dacă nu sunteți bine familiarizat în ecuațiile diferențiale. Cu toate acestea, întreaga descriere a acestei tehnici este rezumată în această figură:

Acesta este tot ceea ce trebuie să implementăm pentru a obține un efect de fum realist: valoarea din fiecare celulă se disipează la toate celulele vecine pe fiecare iterație. Dacă nu este clar clar cum funcționează acest lucru sau dacă doriți doar să vedeți cum ar arăta acest lucru, puteți să faceți acest demo interactiv:

Vizualizați demo-ul interactiv pe CodePen.

Dacă faceți clic pe orice celulă, se stabilește valoarea 100. Puteți vedea cum fiecare celulă își pierde încet valoarea pentru vecinii săi în timp. Ar putea fi mai ușor de văzut făcând clic Următor → pentru a vedea cadrele individuale. Schimbați Modul de afișare pentru a vedea cum ar arăta dacă am făcut o valoare de culoare corespunzătoare acestor numere.

Demo-ul de mai sus este executat pe CPU cu o buclă care trece prin fiecare celulă. Iată ce arată buclă:

// W = numărul de coloane în grilă // H = numărul de rânduri în grilă // f = factorul răspândit / difuz / // Am copiat grila în nouGrid mai întâi pentru a evita editarea grilei așa cum am citit de la ea pentru (var r = 1; r

Acest fragment este într-adevăr nucleul algoritmului. Fiecare celulă câștigă un pic din cele patru celule învecinate, minus valoarea proprie, unde f este un factor care este mai mic de 1. Înmulțim valoarea curentă a celulei cu 4 pentru a ne asigura că difuzează de la valoarea mai mare la cea mai mică valoare.

Pentru a clarifica acest punct, luați în considerare acest scenariu: 

Luați celula în mijloc (la poziția [1,1] în rețea) și aplicați ecuația de difuzie de mai sus. Sa presupunem f este 0.1:

0,1 * (100 + 100 + 100 + 100-4 * 100) = 0,1 * (400-400) = 0

Nu are loc difuzie, deoarece toate celulele au valori egale! 

Dacă luăm în considerarecelula din partea stângă sus în loc (presupuneți că celulele din afara rețelei imaginate sunt toate 0):

0,1 * (100 + 100 + 0 + 0-4 * 0) = 0,1 * (200) = 20

Așa că avem o plasă crește din 20! Să luăm în considerare un caz final. Dupa un test (aplicand aceasta formula tuturor celulelor), grila noastra va arata astfel:

Să ne uităm la difuzul pe celula în mijloc din nou:

0,1 * (70 + 70 + 70 + 70-4 * 100) = 0,1 * (280-400) = -12

Avem o plasă scădeadin 12! Deci, întotdeauna curge de la valorile mai mari la cele inferioare.

Acum, dacă vrem ca acest lucru să pară mai realist, am putea scădea dimensiunea celulelor (pe care o puteți face în demo), dar la un moment dat, lucrurile vor deveni foarte lente, deoarece suntem forțați să executăm secvențial prin fiecare celulă. Scopul nostru este să putem scrie acest lucru într-un shader, unde putem folosi puterea unității de procesare grafică pentru a procesa simultan toate celulele (în pixeli) în paralel.

Așadar, pentru a rezuma, tehnica noastră generală este ca fiecare pixel să dea o parte din valoarea de culoare, fiecare cadru, pixelilor săi vecini. Sună destul de simplu, nu-i așa? Să implementăm acest lucru și să vedem ce obținem! 

Punerea în aplicare

Vom începe cu un shader de bază care trage pe tot ecranul. Pentru a vă asigura că funcționează, încercați să setați ecranul la un negru solid (sau la orice culoare arbitrară). Iată cum arată configurația pe care o folosesc în Javascript.

Puteți rula și edita acest lucru pe CodePen. Faceți clic pe butoanele din partea de sus pentru a vedea codurile HTML, CSS și JS.

Shaderul nostru este pur și simplu:

uniform vec2 res; void principal () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = vec4 (0,0,0,0,0,0,1,0); 

res și pixel sunt acolo pentru a ne da coordonatele pixelului curent. Transmitem dimensiunile ecranului în res ca variabilă uniformă. (Nu le folosim chiar acum, dar în curând.)

Pasul 1: Mutarea valorilor peste pixeli

Iată ce vrem să implementăm din nou:

Tehnica noastră generală este ca fiecare pixel să dea o parte din valoarea de culoare a fiecărui cadru pixelilor săi vecini.

Declarată în forma sa actuală, aceasta este imposibilde a face cu un shader. Puteți înțelege de ce? Amintiți-vă că tot ce poate face un shader este returnarea unei valori de culoare pentru pixelul curent pe care îl procesează - așa că trebuie să repetăm ​​acest lucru într-un mod care afectează numai pixelul curent. Putem spune:

Fiecare pixel ar trebui să fie câştig o anumită culoare de la vecinii săi, în timp ce își pierde unele din propriile sale.

Acum este ceva ce putem implementa. Dacă într-adevăr încercați să faceți acest lucru, cu toate acestea, s-ar putea întâlni într-o problemă fundamentală ...  

Luați în considerare un caz mult mai simplu. Să presupunem că doriți doar să faceți un shader care transformă lent o imagine roșu în timp. S-ar putea să scrieți un shader astfel:

uniform vec2 res; textura uniformă a eșantionului2D; void principal () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (tex, pixel); // Aceasta este culoarea pixelului curent gl_FragColor.r + = 0.01; // Creșterea componentei roșii

Și se așteaptă ca, în fiecare cadru, componenta roșie a fiecărui pixel să crească cu 0,01. În schimb, tot ce veți obține este o imagine statică în care toți pixelii sunt doar puțin mai roșii decât au început. Componenta roșie a fiecărui pixel va crește doar o singură dată, în ciuda faptului că shader-ul rulează fiecare cadru.

Puteți vedea de ce?

Problema

Problema este că orice operație pe care o facem în shaderul nostru este trimisă pe ecran și apoi pierdută pentru totdeauna. Procesul nostru acum arată astfel:

Transmitem variabilele și textura uniformă la shader, face pixelii ușor mai roșii, atrage atenția pe ecran și apoi începe din nou de la zero. Orice lucru pe care îl atragem în shader este eliminat de data viitoare când tragem. 

Ceea ce vrem este ceva de genul:


În loc să tragem direct pe ecran, putem să atragem niște texturi în loc și apoi să tragem acea textură pe ecran. Obțineți aceeași imagine pe ecran ca și cum ați fi avut altfel, cu excepția cazului în care acum puteți trece înapoi la ieșire ca intrare. Deci, puteți avea un shaders care să construiască sau propaga ceva, mai degrabă decât să fie clarificat de fiecare dată. Asta este ceea ce eu numesc "truc tampon cadru". 

Trick-ul Buffer-cadru

Tehnica generală este aceeași pe orice platformă. Căutare de "render la textura" în orice limbă sau în instrumentele pe care le folosiți ar trebui să aducă detaliile de implementare necesare. De asemenea, puteți căuta cum să utilizați obiecte tampon cadru, care este doar un alt nume pentru a fi capabil să render la unele tampon în loc de redare la ecran. 

În trei JS, echivalentul este WebGLRenderTarget. Aceasta este ceea ce vom folosi ca textura noastră intermediară pentru a face. Există o mică limită: nu puteți citi și nu puteți face aceeași textura simultan. Cel mai simplu mod de a obține acest lucru este să folosiți pur și simplu două texturi. 

Fie A și B două texturi pe care le-ați creat. Metoda dvs. ar fi atunci:

  1. Treceți-vă prin umbra dvs., faceți pe B.
  2. Render B pe ecran.
  3. Treceți B prin shader, faceți pe A.
  4. Render A pe ecran.
  5. Repetați 1.

Sau, un mod mai concis pentru a codifica acest lucru ar fi:

  1. Treceți-vă prin umbra dvs., faceți pe B.
  2. Render B pe ecran.
  3. Schimbați A și B (deci variabila A deține textura care era în B și invers).
  4. Repetați 1.

Asta e tot ce este nevoie. Iată o implementare a celor de la ThreeJS:

Puteți rula și edita acest lucru pe CodePen. Noul cod shader se află în HTML fila.

Acesta este încă un ecran negru, de la care am pornit. Shaderul nostru nu este prea diferit:

uniform vec2 res; // lățimea și înălțimea șablonului de eșantionare uniformă a ecranului2D; // Textura de intrare void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = textură2D (bufferTexture, pixel); 

Cu excepția acum dacă adăugați această linie (încercați!):

gl_FragColor.r + = 0,01;

Veți vedea ecranul încet roșu, spre deosebire de creșterea doar cu 0,01 o singura data. Acesta este un pas destul de important, așa că ar trebui să faceți o clipă pentru a vă juca și ao compara cu modul în care a funcționat configurația noastră inițială. 

Provocare: Ce se întâmplă dacă puneți gl_FragColor.r + = pixel.x; atunci când utilizați un exemplu de tampon cadru, în comparație cu atunci când utilizați exemplu de configurare? Ia-ți un moment să te gândești de ce rezultatele sunt diferite și de ce au sens.

Pasul 2: Obținerea unei surse de fum

Înainte de a face ceva să ne mișcăm, avem nevoie de o modalitate de a crea fum în primul rând. Cea mai ușoară modalitate este să setați manual o zonă arbitrară la alb în shader. 

 // Descărcați distanța acestui pixel din centrul ecranului float dist = distance (gl_FragCoord.xy, res.xy / 2.0); în cazul în care (dist < 15.0) //Create a circle with a radius of 15 pixels gl_FragColor.rgb = vec3(1.0); 

Dacă vrem să verificăm dacă tamponul nostru de cadre funcționează corect, putem încerca adauga la valoarea culorii, nu doar stabilirea acesteia. Ar trebui să vedeți cercul să devină din ce în ce mai alb și mai alb.

// Descărcați distanța acestui pixel din centrul ecranului float dist = distance (gl_FragCoord.xy, res.xy / 2.0); în cazul în care (dist < 15.0) //Create a circle with a radius of 15 pixels gl_FragColor.rgb += 0.01; 

O altă modalitate este de a înlocui acel punct fix cu poziția mouse-ului. Puteți trece oa treia valoare dacă mouse-ul este apăsat sau nu, astfel încât să puteți face clic pentru a crea fum. Iată o implementare pentru asta.

Faceți clic pentru a adăuga "fum". Puteți rula și edita acest lucru pe CodePen.

Iată ce arată shaderul nostru acum:

// lățimea și înălțimea uniformei de ecran vec2 res; // Produsul nostru textura de intrare sampler2D bufferTexture; // x, y sunt poziția. Z este puterea / densitatea uniformă vec3 smokeSource; void principal () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = textură2D (bufferTexture, pixel); // Descărcați distanța pixelului curent de la sursa de fum float dist = distance (smokeSource.xy, gl_FragCoord.xy); // Generați fum când mouse-ul este apăsat dacă (smokeSource.z> 0.0 && dist < 15.0) gl_FragColor.rgb += smokeSource.z;  

Provocare: Amintiți-vă că ramificațiile (condiționalitățile) sunt de obicei costisitoare în shadere. Puteți rescrie acest lucru fără a utiliza o instrucțiune if? (Soluția este în CodePen.)

Dacă acest lucru nu are sens, există o explicație mai detaliată a folosirii mouse-ului într-un shader în tutorialul de iluminare precedent.

Pasul 3: Difuzați fumul

Acum aceasta este partea cea mai ușoară și cea mai plină de satisfacții! Avem toate piesele acum, trebuie doar să le spunem în cele din urmă shader: fiecare pixel ar trebui câştigo anumită culoare de la vecinii săi, în timp ce își pierde unele din propriile sale.

Care arată cam așa:

 // Flux de difuzie a fumului xPixel = 1.0 / res.x; // Dimensiunea unui singur pixel float yPixel = 1.0 / res.y; vec4 rightColor = textură2D (bufferTexture, vec2 (pixel.x + xPixel, pixel.y)); vec4 leftColor = textură2D (bufferTexture, vec2 (pixel.x-xPixel, pixel.y)); vec4 upColor = textură2D (bufferTexture, vec2 (pixel.x, pixel.y + yPixel)); vec4 downColor = textură2D (bufferTexture, vec2 (pixel.x, pixel.y-yPixel)); // Ecuația difuză gl_FragColor.rgb + = 14.0 * 0.016 * (leftColor.rgb + rightColor.rgb + downColor.rgb + upColor.rgb - 4.0 * gl_FragColor.rgb);

Avem pe noi f factor ca înainte. În acest caz, avem timestep (0.016 este de 1/60, pentru că rulam la 60 fps) și am încercat numerele până când am ajuns 14, care pare să arate bine. Iată rezultatul:

Faceți clic pentru a adăuga fum. Puteți rula și edita acest lucru pe CodePen.

Oh, e Stuck!

Aceasta este aceeași ecuație difuză pe care am folosit-o în demo-ul procesorului și totuși simularea noastră se blochează! Ce dă? 

Se pare că texturile (ca toate numerele de pe un computer) au o precizie limitată. La un moment dat, factorul pe care îl scăpăm devine atât de mic, încât se rotunjește la 0, deci simulativele se blochează. Pentru a rezolva acest lucru, trebuie să verificăm faptul că nu scade sub o valoare minimă:

factor float = 14.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r + upColor.r - 4.0 * gl_FragColor.r); // Trebuie să ținem cont de precizia scăzută a texturilor float minimum = 0.003; dacă (factorul = = -minimum && factor < 0.0) factor = -minimum; gl_FragColor.rgb += factor;

Eu folosesc r în locul componentei rgb pentru a obține factorul, deoarece este mai ușor să lucrezi cu numere unice și pentru că toate componentele sunt oricum identice (deoarece fumul nostru este alb). 

Prin încercare și eroare, am aflat 0.003 a fi un prag bun unde nu se blochează. Mă îngrijorez numai de factorul negativ, pentru a vă asigura că acesta poate scădea mereu. După ce aplicăm această remediere, iată ce obținem:

Faceți clic pentru a adăuga fum. Puteți rula și edita acest lucru pe CodePen.

Pasul 4: Difuzați fumul în sus

Nu prea arata ca fumul. Dacă vrem să curgă în sus, în loc de toate direcțiile, trebuie să adăugăm unele greutăți. Dacă pixelii de jos au întotdeauna o influență mai mare decât celelalte direcții, atunci pixelii noștri par să se miște în sus. 

Dacă jucăm cu coeficienții, putem ajunge la ceva care pare destul de decent cu această ecuație:

// Ecuația difuză factor float = 8.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r * 3.0 + upColor.r - 6.0 * gl_FragColor.r);

Iată cum arată:

Faceți clic pentru a adăuga fum. Puteți rula și edita acest lucru pe CodePen.

Notă privind ecuația difuză

M-am plimbat în jurul valorii de coeficienții de acolo pentru a face să arate bine care curge în sus. Puteți face tot atât de bine să curgă în orice altă direcție. 

Este important să rețineți că este foarte ușor să faceți ca această simulare să "explodeze". (Încercați să schimbați 6 acolo să 5 și vezi ce se întâmplă). Acest lucru este evident deoarece celulele câștigă mai mult decât pierd. 

Această ecuație este de fapt ceea ce hârtia pe care am citat-o ​​se referă la modelul "difuz" rău. Ele prezintă o ecuație alternativă care este mai stabilă, dar nu este foarte convenabilă pentru noi, mai ales pentru că are nevoie să scrie în grila de la care se citește. Cu alte cuvinte, trebuie să citim și să scriem la aceeași textura în același timp. 

Ceea ce avem este suficient pentru scopurile noastre, dar poți să te uiți la explicația din ziar dacă ești curios. Veți găsi, de asemenea, ecuația alternativă implementată în demo-ul CPU interactiv în funcție diffuse_advanced ().

O remediere rapidă

Un lucru pe care îl puteți observa dacă jucați cu fumul dvs. este faptul că se blochează în partea de jos a ecranului dacă generați unele acolo. Acest lucru se datorează faptului că pixelii de pe rândul de jos încearcă să obțină valorile de la pixelii de mai jos ele, care nu există.

Pentru a rezolva acest lucru, pur și simplu ne asigurăm că pixelii din rândul de jos găsesc 0 sub ele:

// Manipulați limita inferioară // Aceasta trebuie executată înainte de funcția difuză dacă (pixel.y <= yPixel) downColor.rgb = vec3(0.0); 

În demo-ul CPU, m-am ocupat de asta, pur și simplu nu făcând celulele limitate difuzate. În mod alternativ, ați putea să setați manual orice celulă care nu are limite pentru a avea o valoare 0. (Rețeaua din demo-ul CPU se extinde cu un rând și o coloană de celule în fiecare direcție, astfel încât să nu vedeți niciodată limitele)

O grilă de viteză

Felicitări! Acum aveți un shader de fum! Ultimul lucru pe care am vrut să-l discut pe scurt este domeniul de viteză pe care îl menționează lucrarea.

Fumul dvs. nu trebuie să difuzeze uniform în sus sau într-o anumită direcție; ar putea urma un model general ca cel prezentat. Puteți face acest lucru prin trimiterea unei alte texturi în care valorile culorilor reprezintă direcția în care ar trebui să curgă fumul în acea locație, în același mod în care am folosit o hartă normală pentru a specifica o direcție la fiecare pixel din tutorialul nostru de iluminare.

De fapt, textura dvs. de viteză nu trebuie să fie statică! Ați putea folosi truc-tampon cadru pentru a avea, de asemenea, modificări ale vitezelor în timp real. Nu voi acoperi asta în acest tutorial, dar există multe posibilități de explorare.

Concluzie

Dacă este ceva de luat de la acest tutorial, este faptul că a fi capabil de a face într-o textura în loc de doar la ecran este o tehnică foarte utilă.

Ce sunt tampoanele cadrelor bune pentru?

O utilizare obișnuită pentru acest lucru este post procesareîn jocuri. Dacă doriți să aplicați un fel de filtru de culoare, în loc să îl aplicați fiecărui obiect, puteți să redați toate obiectele la o textură de dimensiunea ecranului, apoi să aplicați shader-ul la textura finală și să-l desenați pe ecran. 

Un alt exemplu este implementarea shaderelor care necesită mai multe treceri, cum ar fi estomparea.De obicei, ați rula imaginea prin shader, estompați în direcția x, apoi rulați-o din nou pentru a blur în direcția y. 

Un exemplu final este amânarea redării, așa cum sa discutat în tutorialul de iluminare precedent, care este o modalitate ușoară de a adăuga în mod eficient surse de lumină în mod eficient la scena dvs. Lucrul minunat este că calculul iluminării nu mai depinde de cantitatea de surse de lumină pe care o aveți.

Nu vă temeți de documentele tehnice

Există cu siguranță mai multe detalii acoperite în lucrarea pe care am citat-o ​​și presupune că aveți o anumită familiaritate cu algebra liniară, dar nu lăsați-o să vă descurajeze să o disecați și să încercați să o implementați. Succesul a fost destul de simplu de pus în aplicare (după unele tinkering cu coeficienții). 

Sperăm că ați învățat mai multe despre shadere aici și dacă aveți întrebări, sugestii sau îmbunătățiri, vă rugăm să le partajați mai jos!