Det har alltid varit en viss luft i mysterium kring rök. Det är estetiskt glädjande att titta och missbruka modellen. Liksom många fysiska fenomen är det ett kaotiskt system, vilket gör det väldigt svårt att förutsäga. Simuleringstillståndet beror starkt på samspelet mellan dess individuella partiklar.
Det är just det som gör det så bra att ta itu med GPU: det kan brytas ner till beteendet hos en enda partikel, upprepad samtidigt miljontals gånger på olika platser.
I den här handledningen går jag igenom dig genom att skriva en rökskärm från början och lära dig några användbara skuggtekniker så att du kan expandera din arsenal och utveckla dina egna effekter!
Detta är slutresultatet vi ska arbeta för:
Klicka för att generera mer rök. Du kan gaffla och redigera detta på CodePen.Vi kommer att implementera algoritmen som presenteras i Jon Stams papper om realtidsvätskedynamik i spel. Du lär dig också hur du ska göra till en konsistens, även känd som användning rambuffertar, vilket är en mycket användbar teknik i skuggningsprogrammering för att uppnå många effekter.
Exemplen och detaljerade detaljer i denna handledning använder JavaScript och ThreeJS, men du borde kunna följa med på vilken plattform som helst som stöder shaders. (Om du inte är bekant med grunderna för shader programmering, se till att du går igenom minst de första två handledningarna i den här serien.)
Alla kodexemplen finns på CodePen, men du kan också hitta dem i GitHub-arkivet som är associerat med den här artikeln (som kan vara mer läsbar).
Algoritmen i Jos Stams papper gynnar hastighet och visuell kvalitet över fysisk noggrannhet, vilket är exakt vad vi vill ha i en spelinställning.
Detta papper kan se mycket mer komplicerat ut än det egentligen är, speciellt om du inte är väl känd i differentialekvationer. Emellertid sammanfattas hela kärnan av denna teknik i denna figur:
Det här är allt vi behöver för att få en realistisk utseende rök effekt: värdet i varje cell sprider sig till alla dess närliggande celler vid varje iteration. Om det inte är omedelbart klart hur det här fungerar, eller om du bara vill se hur det här ser ut, kan du tinker med den här interaktiva demo:
Visa den interaktiva demo på CodePen.Genom att klicka på någon cell sätts dess värde till 100
. Du kan se hur varje cell långsamt förlorar sitt värde till sina grannar över tiden. Det kan vara lättast att se genom att klicka på Nästa för att se de enskilda ramarna. Byt ut Visningsläge för att se hur det skulle se ut om vi gjorde ett färgvärde motsvarar dessa siffror.
Ovanstående demo körs alla på CPU: n med en slinga som går igenom varje cell. Så här ser den slingan ut:
// W = antal kolumner i rutnätet // H = antal rader i rutnätet // f = spridningen / diffusivfaktorn // Vi kopierar gallret till newGrid först för att undvika att redigera rutnätet när vi läser av det för (var r = 1; rDetta fragment är verkligen kärnan i algoritmen. Varje cell får lite av sina fyra närliggande celler, minus eget värde, var
f
är en faktor som är mindre än 1. Vi multiplicerar det aktuella cellvärdet med 4 för att se till att det diffunderar från det högre värdet till det lägre värdet.För att klargöra denna punkt, överväga detta scenario:
Ta cell i mitten (vid position
[1,1]
i gallret) och applicera diffusionsekvationen ovan. Låt oss antaf
är0,1
:0,1 * (100 + 100 + 100 + 100-4 * 100) = 0,1 * (400-400) = 0Ingen diffusion händer eftersom alla celler har samma värden!
Om vi övervägercellen längst upp till vänster istället (anta att cellerna utanför det bildade gallret är alla
0
):0,1 * (100 + 100 + 0 + 0-4 * 0) = 0,1 * (200) = 20Så vi får ett nät öka av 20! Låt oss överväga ett slutligt fall. Efter en timestep (tillämpar denna formel på alla celler) ser vårt nät ut så här:
Låt oss titta på diffusa på cell i mitten igen:
0,1 * (70 + 70 + 70 + 70-4 * 100) = 0,1 * (280-400) = -12Vi får ett nät minskaav 12! Så det strömmar alltid från de högre värdena till de lägre.
Om vi nu vill se att det här ser mer realistiskt ut, kan vi minska storleken på cellerna (som du kan göra i demoen), men vid något tillfälle kommer sakerna att bli riktigt långsamma, eftersom vi tvingas sekventiellt springa genom varje cell. Vårt mål är att kunna skriva detta i en skugga där vi kan använda GPU: s förmåga att bearbeta alla celler (som pixlar) samtidigt parallellt.
Så, för att sammanfatta, är vår allmänna teknik att ha varje pixel ge bort något av dess färgvärde, varje ram, till dess närliggande pixlar. Låter ganska enkelt, eller hur? Låt oss genomföra det och se vad vi får!
Genomförande
Vi börjar med en grundläggande shader som drar över hela skärmen. För att försäkra dig om att det fungerar, försök att ställa in skärmen till en solid svart (eller någon godtycklig färg). Så här är inställningen jag använder i Javascript.
Du kan gaffla och redigera detta på CodePen. Klicka på knapparna längst upp för att se HTML, CSS och JS.Vår skuggning är helt enkelt:
enhetlig vec2 res; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = vec4 (0,0,0,0,0,0,1,0);
res
ochpixel
finns för att ge oss koordinaten för den aktuella pixeln. Vi passerar skärmens dimensioner ires
som en likformig variabel. (Vi använder dem inte just nu, men vi kommer snart.)Steg 1: Flytta värden över pixlar
Här är vad vi vill genomföra igen:
Vår allmänna teknik är att varje pixel ger bort något av dess färgvärde varje ram till dess närliggande pixlar.Angiven i sin nuvarande form är detta omöjligatt göra med en skuggning. Kan du se varför? Kom ihåg att allt en skuggare kan göra är att returnera ett färgvärde för den aktuella pixeln som den behandlar, så vi måste ändra det på ett sätt som bara påverkar den aktuella pixeln. Vi kan säga:
Varje pixel ska få någon färg från sina grannar, samtidigt som man förlorar en del av sig själv.Nu är det något vi kan genomföra. Om du faktiskt försöker göra det, kan du kanske bli ett grundläggande problem ...
Tänk på ett mycket enklare fall. Låt oss säga att du bara vill göra en skuggning som förvandlar en bild långsamt över tiden. Du kan skriva en skuggare så här:
enhetlig vec2 res; enhetlig sampler2D konsistens; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (tex, pixel); // Detta är färgen på den aktuella pixeln gl_FragColor.r + = 0.01; // Öka den röda komponentenOch förvänta sig att varje ram, den röda komponenten i varje pixel skulle öka med
0,01
. I stället är allt du får en statisk bild där alla pixlar är bara en liten bit räddning än de började. Den röda komponenten i varje pixel kommer bara att öka en gång, trots att skuggaren kör varje ram.Kan du se varför?
Problemet
Problemet är att någon operation vi gör i vår shader skickas till skärmen och sedan förloras för alltid. Vår process ser just nu ut så här:
Vi överför våra likformiga variabler och textur till skuggaren, det gör pixlarna lite rädda, drar det till skärmen och börjar sedan från början igen. Något som vi ritar inom shader blir rensat av nästa gång vi ritar.
Vad vi vill är något som detta:
I stället för att dra direkt till skärmen kan vi rita till en viss textur istället och dra sedan den där konsistens på skärmen. Du får samma bild på skärmen som du annars skulle ha, förutom nu kan du skicka din produktion tillbaka som input. Så du kan ha shaders som bygger upp eller sprider något, snarare än att bli rensade varje gång. Det är vad jag kallar "rambuffertrick".
Frame Buffer Trick
Den allmänna tekniken är densamma på vilken plattform som helst. Söker efter "göra till textur" Oavsett språk eller verktyg du använder ska ta fram nödvändiga implementeringsdetaljer. Du kan också leta upp hur du använder rambuffertobjekt, vilket bara är ett annat namn för att kunna göra till en viss buffert istället för att återge till skärmen.
I ThreeJS är motsvarigheten till detta WebGLRenderTarget. Detta är vad vi ska använda som vår mellanliggande konsistens att göra till. Det finns en liten tillflykt kvar: du kan inte läsa av och göra samma textur samtidigt. Det enklaste sättet att komma runt är att helt enkelt använda två texturer.
Låt A och B vara två texturer som du har skapat. Din metod skulle då vara:
- Passera A genom din skuggning, gör på B.
- Rendera B till skärmen.
- Passera B genom skuggning, gör på A.
- Render A till din skärm.
- Upprepa 1.
Eller ett mer kortfattat sätt att koda detta skulle vara:
- Passera A genom din skuggning, gör på B.
- Rendera B till skärmen.
- Byt A och B (så variabeln A håller nu strukturen som var i B och vice versa).
- Upprepa 1.
Det är allt som krävs. Här är ett genomförande av det i ThreeJS:
Du kan gaffla och redigera detta på CodePen. Den nya shader-koden finns i html flik.Det här är fortfarande en svart skärm, vilket är vad vi började med. Vår skuggning är inte så annorlunda heller:
enhetlig vec2 res; // Bredden och höjden på vår skärmuniform sampler2D bufferTexture; // Vår inmatningsteknik tomt huvud () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (bufferTexture, pixel);Bortsett från nu om du lägger till den här raden (prova det!):
gl_FragColor.r + = 0,01;Du ser att skärmen sakta blir röd, i motsats till att bara öka med
0,01
en gång. Det här är ett ganska viktigt steg, så du bör ta en stund att leka och jämföra det med hur vår första inställning fungerade.Utmaning: Vad händer om du lägger
gl_FragColor.r + = pixel.x;
när du använder ett rambuffertexempel jämfört med när du använder installationsexemplet? Ta en stund att tänka på varför resultaten är olika och varför de är vettiga.Steg 2: Få en rökkälla
Innan vi kan göra något rör sig, behöver vi ett sätt att skapa rök i första hand. Det enklaste sättet är att manuellt ställa in en godtycklig area till vit i din skuggare.
// Hämta avståndet från denna pixel från mitten av skärmen 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);Om vi vill testa om vår rambuffert fungerar korrekt kan vi försöka Lägg till färgvärdet istället för att bara ställa in det. Du borde se cirkeln långsamt bli vitare och vitare.
// Hämta avståndet från denna pixel från mitten av skärmen 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;Ett annat sätt är att ersätta den fasta punkten med musens position. Du kan skicka ett tredje värde för huruvida musen trycks eller inte, så kan du klicka för att skapa rök. Här är ett genomförande för det.
Klicka för att lägga till "rök". Du kan gaffla och redigera detta på CodePen.Så här ser vår shader ut nu:
// Bredden och höjden på vår skärm enhetlig vec2 res; // Vår inmatningsteknik enhetlig sampler2D bufferTexture; // Den x, y är posiiton. Z är effekt / densitet enhetlig vec3 smokeSource; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (bufferTexture, pixel); // Hämta avståndet för den aktuella pixeln från rökkällan float dist = distance (smokeSource.xy, gl_FragCoord.xy); // Generera rök när musen trycks om (smokeSource.z> 0.0 && dist < 15.0) gl_FragColor.rgb += smokeSource.z;Utmaning: Kom ihåg att förgreningar (conditionals) är vanligtvis dyrbara i shaders. Kan du skriva om detta utan att använda ett if-uttalande? (Lösningen finns i CodePen.)
Om det inte är meningsfullt, finns det en mer detaljerad förklaring om hur du använder musen i en skuggning i den tidigare belysningsövningen.
Steg 3: Diffuse rök
Nu är det den enkla delen - och det mest givande! Vi har alla bitar nu, vi behöver bara slutligen berätta för shader: varje pixel ska fånågon färg från sina grannar, samtidigt som man förlorar en del av sig själv.
Som ser något ut så här:
// Rök diffus float xPixel = 1.0 / res.x; // Storleken på en enda 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)); // Diffus ekvation gl_FragColor.rgb + = 14.0 * 0.016 * (leftColor.rgb + rightColor.rgb + downColor.rgb + upColor.rgb - 4,0 * gl_FragColor.rgb);Vi har vårt
Klicka för att lägga till rök. Du kan gaffla och redigera detta på CodePen.f
faktor som tidigare. I det här fallet har vi tidpunkten (0,016
är 1/60, eftersom vi kör på 60 bilder / sek) och jag fortsatte att försöka få siffror tills jag kom fram till14
, vilket verkar se bra ut. Här är resultatet:Uh, det är fast!
Detta är samma diffusa ekvation som vi använde i CPU-demo, och ändå sitter vår simulering fast! Vad ger?
Det visar sig att texturer (som alla siffror på en dator) har en begränsad precision. Vid en viss tid blir den faktor som vi subtraherar så liten att den avrundas till 0, så simuleringsglasen sitter fast. För att åtgärda detta måste vi kontrollera att det inte faller under något minimivärde:
float factor = 14.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r + upColor.r - 4,0 * gl_FragColor.r); // Vi måste ta hänsyn till den låga precisionen av texels float minimum = 0.003; om (faktor> = -minimum && faktor < 0.0) factor = -minimum; gl_FragColor.rgb += factor;Jag använder
r
komponent istället förrgb
för att få faktorn, eftersom det är lättare att arbeta med enstaka siffror, och eftersom alla komponenter är samma nummer ändå (eftersom vår rök är vit).Genom försök och fel fann jag
Klicka för att lägga till rök. Du kan gaffla och redigera detta på CodePen.0,003
att vara en bra tröskel där den inte fastnar. Jag oroar mig bara om faktorn när den är negativ, för att säkerställa att den alltid kan minska. När vi har tillämpat den här åtgärden, här är vad vi får:Steg 4: Sprid röken uppåt
Det ser inte så mycket ut som rök. Om vi vill flyta uppåt istället för i alla riktningar måste vi lägga till några vikter. Om de nedre pixlarna alltid har större inflytande än de andra riktningarna, så verkar våra pixlar röra sig uppåt.
Genom att leka med koefficienterna kan vi komma fram till något som ser ganska anständigt ut med denna ekvation:
// Diffus ekvation float factor = 8.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r * 3.0 + upColor.r - 6.0 * gl_FragColor.r);Och här ser det ut som:
Klicka för att lägga till rök. Du kan gaffla och redigera detta på CodePen.En anteckning på den diffusa ekvationen
Jag föll i princip med koefficienterna där för att få det att se bra ut och flyta uppåt. Du kan lika bra få det att flöda i någon annan riktning.
Det är viktigt att notera att det är väldigt lätt att göra denna simulering "spränga". (Försök byta
6,0
där inne5,0
och se vad som händer). Detta är uppenbarligen för att cellerna vinner mer än de förlorar.Denna ekvation är faktiskt vad det här dokumentet hänvisar till som "dålig diffus" modell. De presenterar en alternativ ekvation som är stabilare, men är inte särskilt bekväm för oss, främst för att den måste skriva till det nät som den läser från. Med andra ord måste vi kunna läsa och skriva till samma struktur samtidigt.
Vad vi har är tillräckligt för våra syften, men du kan titta på förklaringen i papperet om du är nyfiken. Du hittar också den alternativa ekvationen som implementeras i den interaktiva CPU-demo i funktionen
diffuse_advanced ()
.En snabb fix
En sak som du kanske märker om du leker med din rök är att den fastnar längst ned på skärmen om du genererar några där. Det beror på att pixlarna på den nedre raden försöker få värdena från pixlarna nedan dem som inte existerar.
För att åtgärda detta ska vi helt enkelt se till att pixlarna i den nedre raden hittar
0
under dem:// Hantera den nedre gränsen // Detta måste springa före diffus funktion om (pixel.y <= yPixel) downColor.rgb = vec3(0.0);I CPU-demo behandlade jag det genom att helt enkelt inte göra cellerna i gränsen diffusa. Du kan alternativt bara manuellt ställa in vilken cell som helst utanför gränsen för att få ett värde av
0
. (Nätet i CPU-demo sträcker sig med en rad och kolumn av celler i varje riktning, så att du aldrig ser gränserna)Ett hastighetsnät
grattis! Du har nu en arbetsrökare! Det sista jag ville kortfattat diskutera är det hastighetsfält som papperet nämner.
Din rök behöver inte jämnt diffunderas uppåt eller i någon specifik riktning. Det kan följa ett allmänt mönster som den som visas. Du kan göra detta genom att skicka in en annan textur där färgvärdena representerar den riktning som röken ska flöda in på den platsen, på samma sätt som vi använde en vanlig karta för att ange en riktning vid varje bildpunkt i vår belysningsövning.
Faktum är att din hastighetstekst behöver inte vara statisk heller! Du kan använda rambufferttricket för att också få hastigheterna att förändras i realtid. Jag kommer inte att täcka det i denna handledning, men det finns mycket potential att utforska.
Slutsats
Om det finns något att ta bort från den här handledningen är det att det är en väldigt användbar teknik att kunna göra till en textur istället för bara till skärmen..
Vad är rambuffertarna bra för?
En vanlig användning för detta är efterbehandlingi spel. Om du vill tillämpa ett slags färgfilter istället för att applicera det på varje enskilt objekt kan du göra alla dina objekt till en textur på skärmens storlek och applicera sedan din skuggning på den slutliga texturen och dra den till skärmen.
Ett annat exempel är när man implementerar shaders som kräver flera pass, såsom oskärpa.Du brukar köra din bild genom skuggaren, suddas på x-riktningen och kör sedan igenom igen för att suddas på y-riktningen.
Ett sista exempel är uppskjuten återgivning, som diskuterats i den tidigare belysningsövningen, vilket är ett enkelt sätt att effektivt lägga till många ljuskällor till din scen. Det häftiga med det här är att beräkningen av belysningen inte längre beror på hur många ljuskällor du har.
Var inte rädd för tekniska dokument
Det finns definitivt mer detaljerade uppgifter i det dokument jag citerade, och det förutsätter att du har en förtrogenhet med linjär algebra, men låt inte det avskräcka dig från att dissekera det och försöka implementera det. Kärnan av det slutade ganska enkelt att genomföra (efter lite tinkering med koefficienterna).
Förhoppningsvis har du lärt dig lite mer om shaders här, och om du har några frågor, förslag eller förbättringar, snälla dela dem nedan!