Hoe een rookshader te schrijven

Er is altijd een zekere lucht van mysterie rond rook geweest. Het is esthetisch aangenaam om te bekijken en ongrijpbaar om te modelleren. Zoals veel fysieke verschijnselen, is het een chaotisch systeem, waardoor het erg moeilijk te voorspellen is. De toestand van de simulatie hangt sterk af van de interacties tussen de afzonderlijke deeltjes. 

Dit is precies wat het zo'n groot probleem maakt om met de GPU aan te pakken: het kan worden opgedeeld in het gedrag van een enkel deeltje, miljoenen keren herhaald op verschillende locaties. 

In deze tutorial zal ik je helpen met het schrijven van een rookshader vanaf nul, en leer je een aantal handige shader-technieken zodat je je arsenaal kunt uitbreiden en je eigen effecten kunt ontwikkelen!

Wat je leert

Dit is het eindresultaat waar we naartoe zullen werken:

Klik om meer rook te genereren. U kunt dit vorkelen en bewerken op CodePen.

We zullen het algoritme implementeren dat wordt gepresenteerd in Jon Stam's paper over Real-Time Fluid Dynamics in Games. Je leert ook hoe renderen naar een textuur, ook bekend als gebruiken framebuffers, wat een zeer nuttige techniek is in shader-programmering voor het bereiken van vele effecten. 

Voordat u aan de slag gaat

De voorbeelden en specifieke implementatiedetails in deze tutorial gebruiken JavaScript en ThreeJS, maar u zou moeten kunnen meegaan op elk platform dat shaders ondersteunt. (Als u niet bekend bent met de basisprincipes van shader-programmering, moet u de eerste twee zelfstudies in deze serie doorlopen.)

Alle codevoorbeelden worden gehost op CodePen, maar je kunt ze ook vinden in de GitHub-repository die bij dit artikel hoort (wat misschien leesbaarder is). 

Theorie en achtergrond

Het algoritme in het papier van Jos Stam geeft de voorkeur aan snelheid en visuele kwaliteit ten opzichte van fysieke nauwkeurigheid, en dat is precies wat we willen in een game-omgeving. 

Dit papier kan veel gecompliceerder lijken dan het in werkelijkheid is, vooral als je niet goed thuis bent in differentiaalvergelijkingen. De hele kern van deze techniek is echter samengevat in deze figuur:

Dit is alles wat we moeten implementeren om een ​​realistisch uitziend rookeffect te krijgen: de waarde in elke cel verdwijnt naar alle naburige cellen op elke iteratie. Als het niet meteen duidelijk is hoe dit werkt, of als u alleen wilt zien hoe dit eruit zou zien, kunt u sleutelen aan deze interactieve demo:

Bekijk de interactieve demo op CodePen.

Klikken op een cel stelt de waarde ervan in 100. Je kunt zien hoe elke cel langzaam zijn waarde verliest aan zijn buren in de loop van de tijd. Het is misschien het gemakkelijkst om te zien door op te klikken volgende om de individuele frames te zien. Schakel over Weergavemodus om te zien hoe het eruit zou zien als we een kleurwaarde overeenkwamen met deze cijfers.

De bovenstaande demo wordt allemaal uitgevoerd op de CPU met een lus die door elke cel gaat. Dit is hoe die loop eruit ziet:

// W = aantal kolommen in raster // H = aantal rijen in raster // f = de spread / diffuse factor // We kopiëren eerst het raster naar newGrid om te voorkomen dat het raster wordt bewerkt zoals we er voor lezen (var r = 1; r

Dit fragment is echt de kern van het algoritme. Elke cel krijgt een klein beetje van de vier aangrenzende cellen, minus zijn eigen waarde, waar f is een factor die kleiner is dan 1. We vermenigvuldigen de huidige celwaarde met 4 om ervoor te zorgen dat deze van de hogere waarde naar de lagere waarde diffundeert.

Om dit punt te verduidelijken, overweeg dit scenario: 

Neem de cel in het midden (op positie [1,1] in het raster) en gebruik de bovenstaande diffusievergelijking. Laten we aannemen f is 0.1:

0.1 * (100 + 100 + 100 + 100-4 * 100) = 0.1 * (400-400) = 0

Geen diffusie gebeurt omdat alle cellen gelijke waarden hebben! 

Als we overwegende cel links bovenaan in plaats daarvan (neem aan dat de cellen buiten het afgebeelde raster allemaal zijn 0):

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

Dus we krijgen een net toenemen van 20! Laten we een laatste geval overwegen. Na een tijdspanne (door deze formule toe te passen op alle cellen), ziet ons raster er als volgt uit:

Laten we naar het diffuse op de cel in het midden nog een keer:

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

We krijgen een net verminderenvan 12! Dus het stroomt altijd van de hogere naar de lagere waarden.

Als we wilden dat dit er realistisch uitzag, konden we de cellen verkleinen (wat je in de demo kunt doen), maar op een gegeven moment gaat het echt traag worden, omdat we gedwongen worden om sequentieel te rennen door elke cel. Ons doel is om dit in een arcering te kunnen schrijven, waarbij we de kracht van de GPU kunnen gebruiken om alle cellen tegelijk (als pixels) tegelijkertijd te verwerken.

Kort samengevat is onze algemene techniek dat elke pixel een deel van de kleurwaarde, elk frame, weggeeft aan de aangrenzende pixels. Klinkt vrij eenvoudig, nietwaar? Laten we dat implementeren en zien wat we krijgen! 

Implementatie

We beginnen met een standaard shader die over het hele scherm tekent. Probeer het scherm in te stellen op een effen zwart (of een willekeurige kleur) om te controleren of het werkt. Dit is hoe de setup die ik gebruik eruitziet in Javascript.

U kunt dit vorkelen en bewerken op CodePen. Klik op de knoppen bovenaan om de HTML, CSS en JS te zien.

Onze shader is gewoon:

uniforme vec2 res; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = vec4 (0.0,0.0,0.0.1.0); 

res en pixel zijn er om ons de coördinaat van de huidige pixel te geven. We passeren de dimensies van het scherm in res als een uniforme variabele. (We gebruiken ze nu niet, maar dat zullen we binnenkort doen.)

Stap 1: waarden verplaatsen over pixels

Dit is wat we opnieuw willen implementeren:

Onze algemene techniek is om elke pixel een deel van de kleurwaarde elk frame weg te geven naar de aangrenzende pixels.

In de huidige vorm gesteld, is dit zo onmogelijkte maken met een arcering. Zie je waarom? Vergeet niet dat het enige wat een arcering kan doen, is om een ​​kleurwaarde terug te geven voor de huidige pixel die het verwerkt, dus we moeten dit op een manier aanpassen die alleen de huidige pixel beïnvloedt. We kunnen zeggen:

Elke pixel zou moeten krijgen wat kleur van zijn buren, terwijl een aantal eigen verliezen.

Dit is iets dat we kunnen implementeren. Als je dit echter echt probeert, kom je misschien een fundamenteel probleem tegen ...  

Overweeg een veel eenvoudiger geval. Stel dat u alleen een arcering wilt maken die een afbeelding langzaam rood laat kleuren. U zou een dergelijke arcering kunnen schrijven:

uniforme vec2 res; uniforme sampler2D textuur; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (tex, pixel); // Dit is de kleur van de huidige pixel gl_FragColor.r + = 0.01; // Verhoog de rode component

En verwacht dat, elk frame, de rode component van elke pixel zou toenemen met 0.01. In plaats daarvan krijg je alleen een statische afbeelding waarin alle pixels een klein beetje roder zijn dan ze begonnen. De rode component van elke pixel zal slechts één keer worden verhoogd, ondanks het feit dat de arcering elk frame uitvoert.

Kun je zien waarom?

Het probleem

Het probleem is dat elke bewerking die we in onze arcering uitvoeren naar het scherm wordt verzonden en vervolgens voor altijd verloren gaat. Ons proces ziet er nu zo uit:

We geven onze uniforme variabelen en textuur door aan de arcering, het maakt de pixels iets roder, trekt dat naar het scherm en begint opnieuw opnieuw. Alles wat we binnen de arcering tekenen, wordt gewist door de volgende keer dat we tekenen. 

Wat we willen is zoiets als dit:


In plaats van rechtstreeks naar het scherm te tekenen, kunnen we in plaats daarvan naar een bepaalde textuur tekenen en vervolgens tekenen dat textuur op het scherm. U krijgt hetzelfde beeld op het scherm als anders, maar nu kunt u uw uitvoer als invoer doorgeven. Dus je kunt shaders hebben die iets opbouwen of propageren, in plaats van dat je elke keer wordt gewist. Dat is wat ik de "frame buffer trick" noem. 

De framebuffertrick

De algemene techniek is hetzelfde op elk platform. Op zoek naar "render naar textuur" in welke taal of hulpmiddelen u ook werkt, moet u de nodige implementatiegegevens geven. Je kunt ook opzoeken hoe te gebruiken framebufferobjecten, dat is gewoon een andere naam om naar een bepaalde buffer te kunnen renderen in plaats van naar het scherm te renderen. 

In ThreeJS is het WebGLRenderTarget het equivalent hiervan. Dit is wat we zullen gebruiken als onze intermediaire textuur om aan te geven. Er is nog een kleine waarschuwing over: je kunt niet tegelijkertijd lezen en dezelfde structuur weergeven. De eenvoudigste manier om erachter te komen is om gewoon twee texturen te gebruiken. 

Laat A en B twee texturen zijn die je hebt gemaakt. Uw methode zou dan zijn:

  1. Leid A door je arcering, render op B.
  2. Render B naar het scherm.
  3. Leid B door shader, render op A.
  4. Render A naar je scherm.
  5. Herhaal 1.

Of een meer beknopte manier om dit te coderen zou zijn:

  1. Leid A door je arcering, render op B.
  2. Render B naar het scherm.
  3. Swap A en B (dus de variabele A bevat nu de textuur die in B was en omgekeerd).
  4. Herhaal 1.

Dat is alles wat nodig is. Hier is een implementatie van die in ThreeJS:

U kunt dit vorkelen en bewerken op CodePen. De nieuwe arceringscode staat in de HTML tab.

Dit is nog steeds een zwart scherm, waarmee we zijn begonnen. Onze arcering is ook niet te verschillend:

uniforme vec2 res; // De breedte en hoogte van onze schermuniform sampler2D bufferTexture; // Onze invoerstructuur void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (bufferTexture, pixel); 

Behalve nu als u deze regel toevoegt (probeer het!):

gl_FragColor.r + = 0,01;

Je zult zien dat het scherm langzaam rood wordt, in plaats van alleen maar groter wordt 0.01 een keer. Dit is een vrij belangrijke stap, dus je moet een moment nemen om te spelen en het vergelijken met hoe onze eerste setup werkte. 

Uitdaging: Wat gebeurt er als je zet gl_FragColor.r + = pixel.x; bij gebruik van een frame-buffervoorbeeld, vergeleken met het gebruik van het setup-voorbeeld? Neem even de tijd om na te denken over waarom de resultaten anders zijn en waarom ze logisch zijn.

Stap 2: een rookbron vinden

Voordat we iets kunnen laten bewegen, hebben we een manier nodig om in de eerste plaats rook te creëren. De eenvoudigste manier is om handmatig een willekeurig gebied in te stellen op wit in uw arcering. 

 // Haal de afstand van deze pixel tot het midden van het scherm: 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); 

Als we willen testen of onze framebuffer correct werkt, kunnen we proberen toevoegen aan de kleurwaarde in plaats van deze alleen in te stellen. Je zou moeten zien dat de cirkel langzaam witter en witter wordt.

// Haal de afstand van deze pixel tot het midden van het scherm: 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; 

Een andere manier is om dat vaste punt te vervangen door de positie van de muis. U kunt een derde waarde doorgeven voor het feit of de muis is ingedrukt of niet, zodat u op deze manier kunt klikken om rook te maken. Hier is een implementatie voor.

Klik om "rook" toe te voegen. U kunt dit vorkelen en bewerken op CodePen.

Dit is hoe onze shader er nu uitziet:

// De breedte en hoogte van ons scherm uniforme vec2 res; // Onze ingangstextuur uniforme sampler2D bufferTexture; // De x, y zijn de posiiton. De z is de macht / dichtheid uniform vec3 smokeSource; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (bufferTexture, pixel); // Verkrijg de afstand van de huidige pixel van de rookbron drijvende dist = afstand (smokeSource.xy, gl_FragCoord.xy); // Rook genereren wanneer de muis wordt ingedrukt als (smokeSource.z> 0.0 && dist < 15.0) gl_FragColor.rgb += smokeSource.z;  

Uitdaging: Onthoud dat vertakkingen (conditionals) meestal duur zijn in shaders. Kun je dit herschrijven zonder een if-statement te gebruiken? (De oplossing staat in de CodePen.)

Als dit niet logisch is, is er een meer gedetailleerde uitleg over het gebruik van de muis in een arcering in de vorige belichtingshandleiding.

Stap 3: Verspreid de rook

Dit is nu het makkelijke deel en het meest lonende! We hebben nu alle stukjes, we moeten alleen maar de shader vertellen: elke pixel zou moeten krijgenwat kleur van zijn buren, terwijl een aantal eigen verliezen.

Wat er ongeveer zo uitziet:

 // Smoke diffuse float xPixel = 1.0 / res.x; // De grootte van één enkele 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)); // Diffuse vergelijking gl_FragColor.rgb + = 14.0 * 0.016 * (leftColor.rgb + rightColor.rgb + downColor.rgb + upColor.rgb - 4.0 * gl_FragColor.rgb);

We hebben onze f factor zoals eerder. In dit geval hebben we de tijdspanne (0,016 is 1/60, omdat we met 60 fps lopen) en ik bleef nummers proberen tot ik aankwam 14, dat lijkt er goed uit te zien. Dit is het resultaat:

Klik om rook toe te voegen. U kunt dit vorkelen en bewerken op CodePen.

Uh Oh, het zit vast!

Dit is dezelfde diffuse vergelijking die we in de CPU-demo hebben gebruikt, en toch loopt onze simulatie vast! Wat geeft? 

Het blijkt dat texturen (zoals alle getallen op een computer) een beperkte nauwkeurigheid hebben. Op een gegeven moment wordt de factor die we aftrekken zo klein dat deze wordt afgerond naar 0, dus de simulatie blijft hangen. Om dit te verhelpen, moeten we controleren of het niet onder een bepaalde minimumwaarde komt:

zweeffactor = 14,0 * 0,016 * (leftColor.r + rightColor.r + downColor.r + upColor.r - 4,0 * gl_FragColor.r); // We moeten rekening houden met de lage precisie van texels float minimum = 0,003; if (factor> = -minimum && factor < 0.0) factor = -minimum; gl_FragColor.rgb += factor;

Ik gebruik de r component in plaats van de rgb om de factor te krijgen, omdat het gemakkelijker is om met enkele nummers te werken, en omdat alle componenten toch hetzelfde aantal zijn (omdat onze rook wit is). 

Met vallen en opstaan, vond ik 0,003 een goede drempel zijn waar het niet vastloopt. Ik maak me alleen zorgen over de factor wanneer deze negatief is, om te zorgen dat deze altijd kan verminderen. Zodra we deze correctie toepassen, krijgen we dit:

Klik om rook toe te voegen. U kunt dit vorkelen en bewerken op CodePen.

Stap 4: laat de rook naar boven diffunderen

Dit lijkt echter niet veel op rook. Als we willen dat het omhoog stroomt in plaats van in elke richting, moeten we wat gewichten toevoegen. Als de onderste pixels altijd een grotere invloed hebben dan de andere richtingen, dan lijken onze pixels omhoog te gaan. 

Door met de coëfficiënten te spelen, kunnen we met deze vergelijking tot iets komen dat er behoorlijk goed uitziet:

// Diffuse vergelijking zweeffactor = 8,0 * 0,016 * (leftColor.r + rightColor.r + downColor.r * 3.0 + upColor.r - 6.0 * gl_FragColor.r);

En hier is hoe dat eruit ziet:

Klik om rook toe te voegen. U kunt dit vorkelen en bewerken op CodePen.

Een opmerking over de diffuse vergelijking

Ik speelde eigenlijk rond met de coëfficiënten daar om het er goed uit te laten stromen. Je kunt het net zo goed in een andere richting laten stromen. 

Het is belangrijk op te merken dat het heel eenvoudig is om deze simulatie "op te blazen". (Probeer het te veranderen 6.0 daar naar toe 5.0 en kijk wat er gebeurt). Dit komt natuurlijk omdat de cellen meer verdienen dan dat ze verliezen. 

Deze vergelijking is eigenlijk wat het door mij geciteerde blad het "slechte diffuse" model noemt. Ze presenteren een alternatieve vergelijking die stabieler is, maar niet erg handig voor ons, vooral omdat het moet schrijven naar het rooster waarvan het leest. Met andere woorden, we zouden tegelijkertijd dezelfde structuur moeten kunnen lezen en schrijven. 

Wat we hebben is voldoende voor onze doeleinden, maar als je nieuwsgierig bent, kun je de uitleg in de krant bekijken. U zult ook de alternatieve vergelijking vinden die is geïmplementeerd in de interactieve CPU-demo in de functie diffuse_advanced ().

Een snelle oplossing

Een ding dat je misschien opvalt, als je speelt met je rook, is dat het vast komt te zitten aan de onderkant van het scherm als je daar wat genereert. Dat komt omdat de pixels in die onderste rij proberen de waarden van de onderstaande pixels te krijgen ze, die niet bestaan.

Om dit op te lossen, zorgen we er eenvoudig voor dat de pixels in de onderste rij vinden 0 onder hen:

// Behandel de ondergrens // Dit moet vóór de diffuse functie lopen als (pixel.y. <= yPixel) downColor.rgb = vec3(0.0); 

In de CPU-demo heb ik dat afgehandeld door simpelweg de cellen in de grens niet diffuus te maken. U kunt ook gewoon handmatig elke out-of-bounds-cel instellen om een ​​waarde van te hebben 0. (Het raster in de CPU-demo wordt met één rij en kolom met cellen in beide richtingen verlengd, dus u ziet nooit de grenzen)

A Velocity Grid

Gefeliciteerd! Je hebt nu een werkende rookshader! Het laatste dat ik kort wilde bespreken, is het snelheidsveld dat in de krant wordt genoemd.

Uw rook hoeft niet uniform naar boven of in een specifieke richting te diffunderen; het zou een algemeen patroon kunnen volgen zoals het afgebeelde patroon. U kunt dit doen door een andere textuur in te sturen waarbij de kleurwaarden de richting aangeven waarin de rook op die locatie zou moeten binnenstromen, op dezelfde manier waarop we een normale kaart gebruikten om een ​​richting in elke pixel in onze verlichtingstool te specificeren.

In feite hoeft je velocity-structuur ook niet statisch te zijn! Je zou de framebuffertruc ook kunnen gebruiken om de snelheden in realtime te veranderen. Ik zal dat in deze tutorial niet behandelen, maar er is veel potentieel om te verkennen.

Conclusie

Als er iets is dat u uit deze zelfstudie kunt verwijderen, is het een zeer nuttige techniek om een ​​textuur te renderen in plaats van alleen naar het scherm..

Wat zijn Frame Buffers goed voor?

Een veelgebruikt gebruik hiervan is nabewerkingin games. Als u een soort kleurenfilter wilt toepassen in plaats van het toe te passen op elk afzonderlijk object, kunt u al uw objecten renderen naar een structuur die overeenkomt met het schermformaat, vervolgens uw arcering toepassen op die uiteindelijke structuur en deze naar het scherm tekenen. 

Een ander voorbeeld is het implementeren van shaders die vereisen meerdere passen, zoals onscherpte.Normaal gesproken voer je je afbeelding door de arcering, vervaag je in de x-richting en voer je het vervolgens opnieuw uit om te vervagen in de y-richting. 

Een laatste voorbeeld is uitgestelde weergave, zoals besproken in de vorige belichtingshandleiding, een eenvoudige manier om efficiënt veel lichtbronnen toe te voegen aan je scène. Het leuke hiervan is dat het berekenen van de verlichting niet langer afhankelijk is van de hoeveelheid lichtbronnen die je hebt.

Wees niet bang voor technische documenten

Er is zeker meer detail in de paper die ik heb aangehaald, en het gaat ervan uit dat je vertrouwd bent met lineaire algebra, maar laat dat je niet weerhouden om het te ontleden en proberen het te implementeren. De kern ervan was vrij eenvoudig te implementeren (na wat sleutelen aan de coëfficiënten). 

Hopelijk heb je hier wat meer over shaders geleerd en als je vragen, suggesties of verbeteringen hebt, deel deze dan hieronder!