Hvordan skrive en røykskygge

Det har alltid vært en viss luft av mystikk rundt røyk. Det er estetisk behagelig å se og unnvikende til modell. Som mange fysiske fenomener er det et kaotisk system, noe som gjør det svært vanskelig å forutsi. Tilstanden for simuleringen avhenger sterkt av samspillet mellom dets individuelle partikler. 

Dette er akkurat det som gjør det så bra et problem å takle med GPU: den kan brytes ned til oppførselen til en enkeltpartikkel, gjentatt samtidig millioner av ganger på forskjellige steder. 

I denne veiledningen vil jeg gå deg gjennom å skrive en røykskygge fra grunnen, og lære deg noen nyttige shader teknikker slik at du kan utvide ditt arsenal og utvikle dine egne effekter!

Hva du vil lære

Dette er sluttresultatet vi skal arbeide for:

Klikk for å generere mer røyk. Du kan gaffel og redigere dette på CodePen.

Vi implementerer algoritmen som presenteres i Jon Stams papir om real-time væskedynamikk i spill. Du lærer også hvordan du skal gjengi til en tekstur, også kjent som bruk ramme buffere, som er en veldig nyttig teknikk i shader programmering for å oppnå mange effekter. 

Før du kommer i gang

Eksemplene og spesifikke implementeringsdetaljer i denne opplæringen bruker JavaScript og ThreeJS, men du bør kunne følge med på en hvilken som helst plattform som støtter shaders. (Hvis du ikke er kjent med grunnleggende om shader programmering, må du passe på minst de to første opplæringsprogrammene i denne serien.)

Alle kodeeksemplene er hostet på CodePen, men du kan også finne dem i GitHub-depotet som er tilknyttet denne artikkelen (som kan være mer lesbar). 

Teori og bakgrunn

Algoritmen i Jos Stams papir favoriserer hastighet og visuell kvalitet over fysisk nøyaktighet, noe som er akkurat det vi vil ha i en spillinnstilling. 

Dette papiret kan se mye mer komplisert enn det egentlig er, spesielt hvis du ikke er kjent med differensialligninger. Imidlertid er hele denne teknikkens oppsummering oppsummert i denne figuren:

Dette er alt vi trenger for å implementere for å få en realistisk utseende-røykeffekt: verdien i hver celle avtar til alle nabo-cellene på hver iterasjon. Hvis det ikke umiddelbart er klart hvordan dette virker, eller hvis du bare vil se hvordan dette ser ut, kan du tinker med denne interaktive demoen:

Se den interaktive demoen på CodePen.

Ved å klikke på en celle settes verdien til 100. Du kan se hvordan hver celle langsomt mister sin verdi til naboene over tid. Det kan være lettest å se ved å klikke neste for å se de enkelte rammene. Bytt Visningsmodus for å se hvordan det ville se ut hvis vi lagde en fargevarianse tilsvarer disse tallene.

Ovennevnte demo er alle kjørt på CPUen med en loop som går gjennom hver celle. Slik ser det slik ut:

// W = antall kolonner i rutenettet // H = antall rader i ruten // f = spredningen / diffusoren // Vi kopierer rutenettet til newGrid først for å unngå redigering av rutenettet som vi leser fra den for (var r = 1; r

Denne brikken er virkelig kjernen i algoritmen. Hver celle får litt av sine fire nærliggende celler, minus sin egen verdi, hvor f er en faktor som er mindre enn 1. Vi multipliserer nåværende celleverdi med 4 for å sikre at den diffunderer fra høyere verdi til lavere verdi.

For å klargjøre dette punktet, vurder dette scenariet: 

Ta celle i midten (på posisjon [1,1] i rutenettet) og bruk diffusjonsligningen ovenfor. La oss anta f er 0.1:

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

Ingen diffusjon skjer fordi alle cellene har likeverdige verdier! 

Hvis vi vurderercellen øverst til venstre i stedet (antar at cellene utenfor bildet på bildet er alle 0):

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

Så vi får et netto øke av 20! La oss vurdere et endelig tilfelle. Etter en tidstest (gjelder denne formel for alle celler), ser vårt rutenett ut slik:

La oss se på den diffuse på celle i midten en gang til:

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

Vi får et netto avtaav 12! Så det flyter alltid fra de høyere verdiene til de nedre.

Nå, hvis vi ønsket at dette skulle se mer realistisk ut, kunne vi redusere størrelsen på cellene (som du kan gjøre i demoen), men på et tidspunkt vil ting bli veldig sakte, da vi er tvunget til å løpe i rekkefølge gjennom hver celle. Målet vårt er å kunne skrive dette i en skygge, hvor vi kan bruke GPU's kraft til å behandle alle cellene (som piksler) samtidig parallelt.

Så for å oppsummere, er vår generelle teknikk at hver piksel gir bort noen av fargeværdiene, hver ramme, til nabobillingene. Høres ganske enkelt, ikke sant? La oss implementere det og se hva vi får! 

Gjennomføring

Vi starter med en grunnleggende shader som trekker over hele skjermen. For å sikre at den fungerer, kan du prøve å sette skjermen til en solid svart (eller en hvilken som helst vilkårlig farge). Slik ser du oppsettet jeg bruker i Javascript.

Du kan gaffel og redigere dette på CodePen. Klikk på knappene øverst for å se HTML, CSS og JS.

Vår skygge er rett og slett:

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

res og pixel er det for å gi oss koordinaten til dagens piksel. Vi overfører skjermens dimensjoner i res som en uniform variabel. (Vi bruker dem ikke akkurat nå, men vi kommer snart.)

Trinn 1: Flytte verdier over piksler

Her er det vi vil implementere igjen:

Vår generelle teknikk er å ha hver piksel gi bort noen av fargeværdiene hver ramme til sine nabobilder.

Angitt i sin nåværende form, er dette umuligå gjøre med en skygge. Kan du se hvorfor? Husk at alt en skygge kan gjøre, er å returnere en fargeverdi for den nåværende pikselen den behandler, så vi må omstille dette på en måte som bare påvirker gjeldende piksel. Vi kan si:

Hver piksel skal gevinst litt farge fra naboene, mens du mister noe av seg selv.

Nå er dette noe vi kan implementere. Hvis du egentlig prøver å gjøre dette, kan du kanskje komme inn i et grunnleggende problem ...  

Tenk på et mye enklere tilfelle. La oss si at du bare vil lage en skygge som slår et bilde rødt sakte over tid. Du kan skrive en skygge slik:

uniform vec2 res; uniform sampler2D tekstur; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (tex, pixel); // Dette er fargen på den nåværende piksel gl_FragColor.r + = 0.01; // Øk den røde komponenten

Og forventer at hver ramme vil den røde delen av hver piksel øke med 0.01. I stedet vil alt du får er et statisk bilde der alle pikslene er bare en liten bit redder enn de startet. Den røde delen av hver piksel vil kun øke en gang, til tross for at skyggeren går i hver ramme.

Kan du se hvorfor?

Problemet

Problemet er at enhver operasjon vi gjør i vår skyggelegging, sendes til skjermen og tapt for alltid. Vår prosess ser nå slik ut:

Vi overfører ensartede variabler og tekstur til skyggeren, det gjør pikslene litt røde, trekker det til skjermen, og starter deretter over fra grunnen igjen. Alt vi trekker i skyggen blir ryddet av neste gang vi tegner. 

Det vi ønsker er noe slikt:


I stedet for å tegne direkte på skjermen, kan vi tegne til litt tekstur i stedet, og deretter tegne at tekstur på skjermen. Du får samme bilde på skjermen som du ellers ville ha, bortsett fra nå kan du sende utgangen tilbake som input. Så du kan ha shaders som bygger opp eller forplanter noe, i stedet for å bli ryddet hver gang. Det er det jeg kaller "frame buffer trick". 

Frame Buffer Trick

Den generelle teknikken er den samme på hvilken som helst plattform. Leter etter "gjengi til tekstur" Uansett hvilket språk eller verktøy du bruker, skal du oppgi de nødvendige implementeringsdetaljer. Du kan også slå opp hvordan du bruker ramme bufferobjekter, som bare er et annet navn for å kunne gjengi noen buffer i stedet for å gjengis til skjermen. 

I ThreeJS er tilsvarende dette WebGLRenderTarget. Dette er hva vi skal bruke som vår mellomliggende tekstur som skal gjengis til. Det er en liten advarsel igjen: Du kan ikke lese fra og gjengi til samme tekstur samtidig. Den enkleste måten å komme seg rundt er å bare bruke to teksturer. 

La A og B være to teksturer du har opprettet. Metoden din ville da være:

  1. Pass A gjennom din skygge, gjengi på B.
  2. Render B til skjermen.
  3. Pass B gjennom skygge, gjenta på A.
  4. Gi A til skjermen.
  5. Gjenta 1.

Eller en mer konsis måte å kode dette ville være:

  1. Pass A gjennom din skygge, gjengi på B.
  2. Render B til skjermen.
  3. Bytt A og B (slik at variabelen A nå har tekstur som var i B og omvendt).
  4. Gjenta 1.

Det er alt som trengs. Her er en implementering av det i ThreeJS:

Du kan gaffel og redigere dette på CodePen. Den nye skyggekoden er i HTML tab.

Dette er fortsatt en svart skjerm, som er det vi startet med. Vår skygge er ikke så annerledes enten:

uniform vec2 res; // Bredden og høyden på skjermen uniformer sampler2D bufferTexture; // Våre innspill tekstur tomrom main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (bufferTexture, pixel); 

Bortsett fra nå hvis du legger til denne linjen (prøv det!):

gl_FragColor.r + = 0,01;

Du ser at skjermen sakte blir rød, i motsetning til bare å øke med 0.01 en gang. Dette er et ganske betydelig skritt, så du bør ta et øyeblikk å spille rundt og sammenligne det med hvordan vårt opprinnelige oppsett fungerte. 

Utfordring: Hva skjer hvis du setter gl_FragColor.r + = pixel.x; når du bruker et rammebufferteksempel, sammenlignet med når du bruker oppsetteksemplet? Ta deg tid til å tenke på hvorfor resultatene er forskjellige og hvorfor de gir mening.

Trinn 2: Skaffe en røykkilde

Før vi kan gjøre noe, trenger vi en måte å skape røyk på. Den enkleste måten er å manuelt sette et vilkårlig område til hvitt i skyggeren. 

 // Få avstanden til denne piksel fra midten av skjermen 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); 

Hvis vi vil teste om rammebufferen fungerer som den skal, kan vi prøve å legge til fargen verdien i stedet for bare å sette den inn. Du bør se sirkelen sakte bli hvitere og hvitere.

// Få avstanden til denne piksel fra midten av skjermen 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; 

En annen måte er å erstatte det faste punktet med musens posisjon. Du kan passere en tredje verdi for om musen trykkes eller ikke, slik at du kan klikke for å lage røyk. Her er en implementering for det.

Klikk for å legge til "røyk". Du kan gaffel og redigere dette på CodePen.

Slik ser vår skygge ut nå:

// Bredden og høyden på skjermen vår uniform vec2 res; // Vår inngangsstruktur ensartet sampler2D bufferTexture; // Den x, y er posiiton. Z er strøm / tetthet uniform vec3 smokeSource; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (bufferTexture, pixel); // Få avstanden til gjeldende piksel fra røykkilden float dist = avstand (smokeSource.xy, gl_FragCoord.xy); // Generer røyk når musen er trykket hvis (smokeSource.z> 0.0 && dist < 15.0) gl_FragColor.rgb += smokeSource.z;  

Utfordring: Husk at forgrening (conditionals) er vanligvis dyrt i shaders. Kan du omskrive dette uten å bruke en if-setning? (Løsningen er i CodePen.)

Hvis dette ikke gir mening, er det en mer detaljert forklaring på hvordan du bruker musen i en skygge i den forrige lysveiledningen.

Trinn 3: Sprøyt røyk

Nå er dette den enkle delen - og det mest givende! Vi har alle brikkene nå, vi trenger bare å endelig fortelle skyggeren: hver piksel skal gevinstlitt farge fra naboene, mens du mister noe av seg selv.

Som ser noe ut som dette:

 // Smoke diffus float xPixel = 1.0 / res.x; // Størrelsen på en enkelt piksel 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 ligning gl_FragColor.rgb + = 14.0 * 0.016 * (leftColor.rgb + rightColor.rgb + downColor.rgb + upColor.rgb - 4.0 * gl_FragColor.rgb);

Vi har vår f faktor som før. I dette tilfellet har vi timestep (0,016 er 1/60, fordi vi kjører på 60 fps) og jeg fortsatte å prøve tall før jeg kom til 14, som ser ut til å se bra ut. Her er resultatet:

Klikk for å legge til røyk. Du kan gaffel og redigere dette på CodePen.

Uh, det er fast!

Dette er den samme diffuse ligningen vi brukte i CPU-demoen, og likevel blir simuleringen vår fast! Hva gir? 

Det viser seg at teksturer (som alle tall på en datamaskin) har begrenset presisjon. På et tidspunkt blir faktoren vi trekker av, så liten at den blir avrundet til 0, slik at simuleringene sitter fast. For å fikse dette må vi kontrollere at det ikke faller under noen minimumsverdi:

float factor = 14.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r + upColor.r - 4.0 * gl_FragColor.r); // Vi må ta hensyn til den lave presisjonen av Texels float minimum = 0.003; hvis (faktor> = -minimum && faktor < 0.0) factor = -minimum; gl_FragColor.rgb += factor;

Jeg bruker r komponent i stedet for rgb for å få faktoren, fordi det er lettere å arbeide med enkle tall, og fordi alle komponentene er det samme nummer uansett (siden vår røyk er hvit). 

Ved prøve og feil fant jeg 0,003 å være en god terskel der den ikke sitter fast. Jeg bekymrer meg bare om faktoren når den er negativ, for å sikre at den alltid kan senke. Når vi har brukt denne løsningen, er det hva vi får:

Klikk for å legge til røyk. Du kan gaffel og redigere dette på CodePen.

Trinn 4: Diffuser røyken oppover

Dette ser ikke så mye ut som røyk, skjønt. Hvis vi vil at den skal flyte oppover i stedet for i alle retninger, må vi legge til noen vekter. Hvis bunnpikslene alltid har større innflytelse enn de andre retningene, så ser våre piksler opp. 

Ved å leke med koeffisientene, kan vi komme til noe som ser ganske anstendig ut med denne ligningen:

// Diffus likning float factor = 8.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r * 3.0 + upColor.r - 6.0 * gl_FragColor.r);

Og her ser det ut som:

Klikk for å legge til røyk. Du kan gaffel og redigere dette på CodePen.

En notat på den diffuse ligningen

Jeg falt i utgangspunktet med koeffisientene der for å få det til å se bra utover. Du kan like godt få det til å strømme i en annen retning. 

Det er viktig å merke seg at det er veldig enkelt å gjøre denne simuleringen "blåse opp". (Prøv å endre 6.0 der inne til 5.0 og se hva som skjer). Dette er åpenbart fordi cellene får mer enn de mister. 

Denne ligningen er faktisk hva papiret jeg citerte refererer til som "dårlig diffus" modell. De presenterer en alternativ likning som er mer stabil, men er ikke veldig praktisk for oss, hovedsakelig fordi den må skrive til rutenettet den leser fra. Med andre ord, vi trenger å kunne lese og skrive til samme tekstur på samme tid. 

Det vi har er tilstrekkelig til vårt formål, men du kan se på forklaringen i papiret hvis du er nysgjerrig. Du finner også den alternative ligningen implementert i den interaktive CPU-demoen i funksjonen diffuse_advanced ().

En rask løsning

En ting du kanskje merker, hvis du leker med røyken din, er det fast i bunnen av skjermen hvis du genererer noen der.Dette er fordi pikslene på den nederste raden prøver å få verdiene fra pikslene nedenfor dem, som ikke eksisterer.

For å fikse dette, sørger vi bare for at pikslene i den nederste raden finner 0 under dem:

// Håndter bunngrensen // Dette må løpe før den diffuse funksjonen hvis (pixel.y <= yPixel) downColor.rgb = vec3(0.0); 

I CPU-demoen behandlet jeg det ved å ikke gjøre cellene i grensen diffus. Du kan alternativt bare sette inn en utenomgående celle manuelt manuelt 0. (Gitteret i CPU-demoen strekker seg med en rad og kolonne av celler i hver retning, slik at du aldri ser grensene)

Et hastighetsnett

Gratulerer! Du har nå en arbeidsrøykskader! Det siste jeg ønsket å kortlegge, er hastighetsfeltet som papiret nevner.

Din røyk trenger ikke å jevnlig diffusere oppover eller i en bestemt retning; det kan følge et generelt mønster som det bildet avbildet. Du kan gjøre dette ved å sende inn en annen tekstur der fargevurderingene representerer hvilken retning røyken skal strømme inn på den plasseringen, på samme måte som vi brukte et normalt kart for å angi en retning ved hver piksel i vår belysningsveiledning.

Faktisk, din hastighetstekstur trenger heller ikke å være statisk heller! Du kan bruke rammebuffertricket til å også ha hastighetene i realtid. Jeg vil ikke dekke det i denne opplæringen, men det er mye potensial å utforske.

Konklusjon

Hvis det er noe å ta bort fra denne opplæringen, er det at det å kunne gjengi tekstur i stedet for bare på skjermen er en veldig nyttig teknikk.

Hva er rammebuffere bra for?

En vanlig bruk for dette er post-prosesseringi spill. Hvis du vil bruke et slags fargfilter, i stedet for å bruke det på hvert enkelt objekt, kan du gjøre alle objektene dine til en tekstur størrelsen på skjermen, og deretter bruke skyggeren til den endelige tekstur og tegne den på skjermen. 

Et annet eksempel er når man implementerer shaders som krever flere pass, for eksempel uskarphet.Du vil vanligvis kjøre bildet ditt gjennom skyggeren, uskarpe på x-retningen, deretter kjøre den gjennom igjen for å uskarpe på y-retningen. 

Et siste eksempel er utsatt gjengivelse, som omtalt i den forrige belysningsveiledningen, som er en enkel måte å effektivt legge til mange lyskilder til din scene. Den kule tingen om dette er at beregningen av belysningen ikke lenger er avhengig av mengden lyskilder du har.

Ikke vær redd for tekniske artikler

Det er definitivt mer detaljert dekket i papiret jeg sa, og det antas at du har noen kjennskap til lineær algebra, men ikke la det avskrekke deg fra å dissekere det og prøve å implementere det. Gjerningen av det endte ganske enkelt med å implementere (etter noen tinkering med koeffisientene). 

Forhåpentligvis har du lært litt mer om shaders her, og hvis du har noen spørsmål, forslag eller forbedringer, vennligst del dem nedenfor!