Es gab immer ein gewisses Mysterium um Rauch. Es ist ästhetisch ansprechend anzusehen und schwer zu modellieren. Wie viele physikalische Phänomene ist es ein chaotisches System, das die Vorhersage sehr schwierig macht. Der Zustand der Simulation hängt stark von den Wechselwirkungen zwischen den einzelnen Partikeln ab.
Genau das macht es zu einem großen Problem, mit der GPU fertig zu werden: Sie kann auf das Verhalten eines einzelnen Partikels heruntergebrochen werden, das sich millionenfach gleichzeitig an verschiedenen Orten wiederholt.
In diesem Tutorial werde ich Sie von Grund auf mit einem Rauch-Shader begleiten und Ihnen einige nützliche Shader-Techniken zeigen, damit Sie Ihr Arsenal erweitern und Ihre eigenen Effekte entwickeln können!
Dies ist das Endergebnis, auf das wir hinarbeiten werden:
Klicken Sie hier, um mehr Rauch zu erzeugen. Sie können dies auf dem CodePen bearbeiten und bearbeiten.Wir werden den Algorithmus implementieren, der in Jon Stams Arbeit über Echtzeitfluiddynamik in Spielen vorgestellt wird. Sie lernen auch, wie man geht zu einer Textur rendern, auch bekannt als using Rahmenpuffer, Dies ist eine sehr nützliche Technik bei der Shader-Programmierung, um viele Effekte zu erzielen.
Die Beispiele und spezifischen Implementierungsdetails in diesem Lernprogramm verwenden JavaScript und ThreeJS. Sie sollten jedoch auf jeder Plattform mitverfolgen können, die Shader unterstützt. (Wenn Sie nicht mit den Grundlagen der Shader-Programmierung vertraut sind, stellen Sie sicher, dass Sie mindestens die ersten beiden Tutorials dieser Serie durchlaufen.)
Alle Codebeispiele werden auf CodePen gehostet. Sie können sie jedoch auch im GitHub-Repository finden, das diesem Artikel zugeordnet ist (möglicherweise lesbarer)..
Der Algorithmus in Jos Stams Papier bevorzugt Geschwindigkeit und visuelle Qualität gegenüber körperlicher Genauigkeit. Genau das wollen wir in einer Spielumgebung.
Dieses Papier kann viel komplizierter aussehen, als es wirklich ist, insbesondere wenn Sie sich mit Differentialgleichungen nicht auskennen. Der gesamte Kern dieser Technik ist jedoch in dieser Abbildung zusammengefasst:
Dies ist alles, was wir implementieren müssen, um einen realistisch aussehenden Raucheffekt zu erzielen: Der Wert in jeder Zelle wird bei jeder Iteration an alle benachbarten Zellen abgegeben. Wenn nicht sofort klar ist, wie das funktioniert, oder wenn Sie nur sehen möchten, wie das aussehen würde, können Sie an dieser interaktiven Demo herumbasteln:
Sehen Sie sich die interaktive Demo auf CodePen an.Durch Klicken auf eine Zelle wird der Wert auf festgelegt 100
. Sie können sehen, wie jede Zelle im Laufe der Zeit ihren Wert an ihre Nachbarn verliert. Es ist am einfachsten, wenn Sie auf klicken Nächster um die einzelnen Frames zu sehen. Schalten Sie die Anzeigemodus um zu sehen, wie es aussehen würde, wenn wir einen Farbwert diesen Zahlen entsprechen würden.
Die obige Demo wird auf der CPU ausgeführt, wobei jede Zelle von einer Schleife durchlaufen wird. So sieht diese Schleife aus:
// W = Anzahl der Spalten im Raster // H = Anzahl der Zeilen im Raster // f = Streuungs- / Diffusionsfaktor // Wir kopieren das Gitter zuerst nach newGrid, um zu vermeiden, dass das Gitter so bearbeitet wird, wie wir es lesen (var r = 1; rDieser Ausschnitt ist wirklich der Kern des Algorithmus. Jede Zelle gewinnt ein wenig von ihren vier benachbarten Zellen, abzüglich ihres eigenen Werts, wo
f
ist ein Faktor, der kleiner als 1 ist. Wir multiplizieren den aktuellen Zellenwert mit 4, um sicherzustellen, dass er vom höheren Wert zum niedrigeren Wert diffundiert.Um diesen Punkt zu verdeutlichen, betrachten Sie dieses Szenario:
Nehmen Sie die Zelle in der Mitte (auf Position
[1,1]
im Gitter) und wenden die obige Diffusionsgleichung an. Angenommenf
ist0,1
:0,1 * (100 + 100 + 100 + 100-4 * 100) = 0,1 * (400-400) = 0Es findet keine Diffusion statt, da alle Zellen gleiche Werte haben!
Wenn wir überlegendie Zelle oben links Stattdessen (nehmen Sie an, dass alle Zellen außerhalb des abgebildeten Gitters sind
0
):0,1 * (100 + 100 + 0 + 0-4 * 0) = 0,1 * (200) = 20Also bekommen wir ein Netz erhöhen, ansteigen von 20! Betrachten wir einen letzten Fall. Nach einem Zeitschritt (Anwendung dieser Formel auf alle Zellen) sieht unser Raster folgendermaßen aus:
Schauen wir uns das diffuse an Zelle in der Mitte nochmal:
0,1 * (70 + 70 + 70 + 70-4 * 100) = 0,1 * (280 - 400) = -12Wir bekommen ein Netz verringernvon 12! Es fließt also immer von den höheren Werten zu den niedrigeren.
Wenn wir wollten, dass dies realistischer aussieht, könnten wir die Größe der Zellen verringern (was Sie in der Demo tun können), aber irgendwann werden die Dinge sehr langsam werden, da wir nacheinander laufen müssen durch jede Zelle. Unser Ziel ist es, dies in einen Shader schreiben zu können, in dem wir die Leistung der GPU verwenden können, um alle Zellen (als Pixel) gleichzeitig parallel zu verarbeiten.
Zusammenfassend lässt sich sagen, dass unsere allgemeine Technik darin besteht, dass jedes Pixel einen Teil seines Farbwerts, jeden Frame, an seine benachbarten Pixel weitergibt. Klingt ziemlich einfach, nicht wahr? Lassen Sie uns das umsetzen und sehen, was wir bekommen!
Implementierung
Wir beginnen mit einem einfachen Shader, der sich über den gesamten Bildschirm zieht. Um sicherzustellen, dass es funktioniert, stellen Sie den Bildschirm auf ein durchgehendes Schwarz (oder eine beliebige Farbe). Das Setup, das ich verwende, sieht in Javascript aus.
Sie können dies auf dem CodePen bearbeiten und bearbeiten. Klicken Sie oben auf die Schaltflächen, um HTML, CSS und JS anzuzeigen.Unser Shader ist einfach:
einheitliche vec2 res; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = vec4 (0,0,0,0,0,0,1,0);
res
undPixel
gibt uns die Koordinate des aktuellen Pixels. Wir übergeben die Abmessungen des Bildschirmsres
als einheitliche Variable. (Wir benutzen sie jetzt nicht, aber wir werden es bald tun.)Schritt 1: Werte über Pixel verschieben
Folgendes möchten wir noch einmal implementieren:
Unsere allgemeine Technik besteht darin, dass jedes Pixel bei jedem Frame einen Teil seines Farbwerts an seine benachbarten Pixel weitergibt.In seiner jetzigen Form heißt dies unmöglichmit einem Shader zu tun. Kannst du sehen warum? Denken Sie daran, dass ein Shader nur einen Farbwert für das aktuelle Pixel zurückgeben kann, das er verarbeitet. Wir müssen diesen Wert also so anpassen, dass er nur das aktuelle Pixel beeinflusst. Wir können sagen:
Jeder Pixel sollte gewinnen einige Farbe von seinen Nachbarn, während sie einige von ihren eigenen verlieren.Das können wir jetzt umsetzen. Wenn Sie dies tatsächlich versuchen, könnten Sie jedoch auf ein grundlegendes Problem stoßen.
Betrachten Sie einen viel einfacheren Fall. Angenommen, Sie möchten einfach einen Shader erstellen, der ein Bild im Laufe der Zeit langsam rot färbt. Sie könnten einen Shader wie folgt schreiben:
einheitliche vec2 res; einheitliche sampler2D-Textur; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (tex, pixel); // Dies ist die Farbe des aktuellen Pixels. gl_FragColor.r + = 0.01; // Inkrementieren der roten KomponenteEs ist zu erwarten, dass bei jedem Bild die rote Komponente jedes Pixels um 1 ansteigt
0,01
. Stattdessen erhalten Sie nur ein statisches Bild, bei dem alle Pixel nur ein kleines bisschen röter sind, als sie begonnen haben. Die rote Komponente jedes Pixels wird nur einmal erhöht, obwohl der Shader jeden Frame ausführt.Kannst du sehen warum?
Das Problem
Das Problem ist, dass jede Operation, die wir in unserem Shader ausführen, an den Bildschirm gesendet wird und dann für immer verloren geht. Unser Prozess sieht jetzt so aus:
Wir geben unsere einheitlichen Variablen und die Textur an den Shader weiter, machen die Pixel etwas röter, zeichnen diese auf den Bildschirm und beginnen dann wieder von vorne. Alles, was wir im Shader zeichnen, wird beim nächsten Zeichnen gelöscht.
Was wir wollen, ist ungefähr so:
Anstatt direkt auf den Bildschirm zu zeichnen, können wir stattdessen zu etwas Textur zeichnen und dann zeichnen Das Textur auf den Bildschirm. Sie erhalten dasselbe Bild auf dem Bildschirm wie sonst, nur dass Sie Ihre Ausgabe jetzt als Eingabe zurückgeben können. Sie können also Shader haben, die etwas aufbauen oder verbreiten, anstatt jedes Mal gelöscht zu werden. Das nenne ich "Frame Buffer Trick".
Der Frame-Puffer-Trick
Die allgemeine Technik ist auf jeder Plattform gleich. Auf der Suche nach "In Textur rendern" In welcher Sprache und in welchen Tools Sie auch arbeiten, sollten Sie die erforderlichen Details zur Implementierung angeben. Sie können auch nachschlagen, wie Sie es verwenden Rahmenpufferobjekte, Dies ist nur ein weiterer Name für die Möglichkeit, in einem Puffer zu rendern, anstatt auf dem Bildschirm dargestellt zu werden.
In ThreeJS entspricht dies dem WebGLRenderTarget. Dies ist, was wir als unsere Mitteltextur zum Rendern verwenden. Es gibt noch einen kleinen Vorbehalt: Sie können nicht gleichzeitig von derselben Textur lesen und rendern. Der einfachste Weg, dies zu umgehen, besteht darin, einfach zwei Texturen zu verwenden.
Sei A und B zwei Texturen, die du erstellt hast. Ihre Methode wäre dann:
- Passiere A durch deinen Shader und rendere auf B.
- Übertragen Sie B auf den Bildschirm.
- Passiere B durch den Shader und rendere auf A.
- Rendern Sie A auf Ihren Bildschirm.
- Wiederholen Sie 1.
Oder eine prägnantere Art, dies zu kodieren, wäre:
- Passiere A durch deinen Shader und rendere auf B.
- Übertragen Sie B auf den Bildschirm.
- Tauschen Sie A und B (so dass die Variable A jetzt die Textur enthält, die in B war und umgekehrt).
- Wiederholen Sie 1.
Das ist alles was es braucht. Hier ist eine Implementierung davon in ThreeJS:
Sie können dies auf dem CodePen bearbeiten und bearbeiten. Der neue Shader-Code befindet sich im HTML Tab.Dies ist immer noch ein schwarzer Bildschirm, mit dem wir angefangen haben. Unser Shader ist auch nicht zu verschieden:
einheitliche vec2 res; // Die Breite und Höhe unserer Bildschirmuniform sampler2D bufferTexture; // Unsere Eingabestruktur void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (bufferTexture, pixel);Außer jetzt, wenn Sie diese Zeile hinzufügen (probieren Sie es aus!):
gl_FragColor.r + = 0,01;Sie werden sehen, wie der Bildschirm langsam rot wird, anstatt nur um zu vergrößern
0,01
Einmal. Dies ist ein ziemlich bedeutender Schritt, also sollten Sie sich einen Moment Zeit nehmen, um zu vergleichen und es mit den ursprünglichen Einstellungen zu vergleichen.Herausforderung: Was passiert, wenn Sie setzen
gl_FragColor.r + = Pixel.x;
Wenn Sie ein Frame-Puffer-Beispiel verwenden, verglichen mit dem Setup-Beispiel? Nehmen Sie sich einen Moment Zeit, um darüber nachzudenken, warum die Ergebnisse unterschiedlich sind und warum sie Sinn machen.Schritt 2: Eine Rauchquelle erhalten
Bevor wir etwas bewegen können, müssen wir überhaupt erst Rauch erzeugen. Am einfachsten ist es, einen beliebigen Bereich in Ihrem Shader manuell auf weiß zu setzen.
// Ermittle den Abstand dieses Pixels von der Mitte des Bildschirms 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);Wenn wir testen möchten, ob unser Bildspeicher korrekt funktioniert, können wir es versuchen hinzufügen den Farbwert, anstatt ihn nur einzustellen. Sie sollten den Kreis langsam weißer und weißer sehen.
// Ermittle den Abstand dieses Pixels von der Mitte des Bildschirms 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;Eine andere Möglichkeit besteht darin, diesen festen Punkt durch die Position der Maus zu ersetzen. Sie können einen dritten Wert übergeben, um anzugeben, ob die Maus gedrückt wird oder nicht. Auf diese Weise können Sie klicken, um Rauch zu erzeugen. Hier ist eine Implementierung dafür.
Klicken Sie, um "Rauch" hinzuzufügen. Sie können dies auf dem CodePen bearbeiten und bearbeiten.So sieht unser Shader jetzt aus:
// die Breite und Höhe unserer Bildschirmuniform vec2 res; // Unsere Eingabe-Textur einheitlich sampler2D bufferTexture; // Die x, y sind die Position. Das z ist die einheitliche Leistung / Dichte vec3 smokeSource; void main () vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D (bufferTexture, pixel); // Ermittle den Abstand des aktuellen Pixels vom Float der Rauchquelle dist = distance (smokeSource.xy, gl_FragCoord.xy); // Rauch erzeugen, wenn die Maus gedrückt wird, wenn (smokeSource.z> 0.0 && dist < 15.0) gl_FragColor.rgb += smokeSource.z;Herausforderung: Denken Sie daran, dass Verzweigungen (Bedingungen) für Shader normalerweise teuer sind. Können Sie das ohne eine if-Anweisung umschreiben? (Die Lösung befindet sich im CodePen.)
Wenn dies nicht sinnvoll ist, wird im vorherigen Beleuchtungs-Lernprogramm die Verwendung der Maus in einem Shader näher erläutert.
Schritt 3: Diffuse den Rauch
Jetzt ist dies der einfachste Teil - und der lohnendste! Wir haben jetzt alle Teile, wir müssen nur noch den Shader sagen: jedes Pixel sollte gewinneneinige Farbe von seinen Nachbarn, während sie einige von ihren eigenen verlieren.
Das sieht ungefähr so aus:
// Smoke Diffus Float xPixel = 1.0 / res.x; // Die Größe eines einzelnen Pixel-Floats 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 Gleichung gl_FragColor.rgb + = 14.0 * 0.016 * (leftColor.rgb + rightColor.rgb + downColor.rgb + upColor.rgb - 4.0 * gl_FragColor.rgb);Wir haben unsere
Klicken Sie, um Rauch hinzuzufügen. Sie können dies auf dem CodePen bearbeiten und bearbeiten.f
Faktor wie zuvor. In diesem Fall haben wir den Zeitschritt (0,016
ist 1/60, da wir mit 60 fps laufen) und ich versuchte immer wieder Zahlen, bis ich angekommen bin14
, was gut aussieht. Hier ist das Ergebnis:Oh, es steckt fest!
Dies ist die gleiche diffuse Gleichung, die wir in der CPU-Demo verwendet haben, und dennoch bleibt unsere Simulation stecken! Was gibt?
Es stellt sich heraus, dass Texturen (wie alle Zahlen auf einem Computer) eine begrenzte Genauigkeit haben. Irgendwann wird der Faktor, den wir subtrahieren, so klein, dass er auf 0 abgerundet wird, sodass die Simulationsziele hängen bleiben. Um dies zu beheben, müssen wir sicherstellen, dass der Mindestwert nicht unterschritten wird:
Floatfaktor = 14,0 * 0,016 * (leftColor.r + rightColor.r + downColor.r + upColor.r - 4,0 * gl_FragColor.r); // Wir müssen die geringe Genauigkeit von Texeln berücksichtigen. Float minimum = 0.003; if (Faktor> = -minimum && Faktor < 0.0) factor = -minimum; gl_FragColor.rgb += factor;Ich benutze die
r
Komponente statt derrgb
Um den Faktor herauszufinden, weil es einfacher ist, mit einzelnen Zahlen zu arbeiten, und weil alle Komponenten sowieso gleich sind (da unser Rauch weiß ist).Durch Versuch und Irrtum fand ich
Klicken Sie, um Rauch hinzuzufügen. Sie können dies auf dem CodePen bearbeiten und bearbeiten.0,003
eine gute Schwelle sein, wo es nicht stecken bleibt. Ich mache mir nur Sorgen um den Faktor, wenn er negativ ist, um sicherzustellen, dass er immer abnehmen kann. Sobald wir diesen Fix angewendet haben, erhalten Sie Folgendes:Schritt 4: Rauch nach oben streuen
Das sieht allerdings nicht sehr nach Rauch aus. Wenn wir wollen, dass er nach oben fließt und nicht in alle Richtungen, müssen wir einige Gewichte hinzufügen. Wenn die unteren Pixel immer einen größeren Einfluss haben als die anderen Richtungen, scheinen sich unsere Pixel nach oben zu bewegen.
Indem wir mit den Koeffizienten herumspielen, können wir zu etwas kommen, das mit dieser Gleichung ziemlich anständig aussieht:
// Floatfaktor der diffusen Gleichung = 8,0 * 0,016 * (leftColor.r + rightColor.r + downColor.r * 3,0 + upColor.r - 6,0 * gl_FragColor.r);Und so sieht das aus:
Klicken Sie, um Rauch hinzuzufügen. Sie können dies auf dem CodePen bearbeiten und bearbeiten.Ein Hinweis zur diffusen Gleichung
Im Grunde habe ich mit den Koeffizienten herumgespielt, damit es nach oben gut aussieht. Sie können es genauso gut in jede andere Richtung fließen lassen.
Es ist wichtig zu wissen, dass es sehr einfach ist, diese Simulation "in die Luft zu sprengen". (Ändern Sie das
6,0
dort zu5,0
und sehen was passiert). Dies liegt offensichtlich daran, dass die Zellen mehr gewinnen als verlieren.Diese Gleichung ist eigentlich das, was das von mir zitierte Papier als "schlecht diffuses" Modell bezeichnet. Sie stellen eine alternative Gleichung vor, die stabiler ist, aber für uns nicht sehr praktisch ist, vor allem, weil sie in das Raster schreiben muss, aus dem sie lesen. Mit anderen Worten, wir müssten in der Lage sein, dieselbe Textur gleichzeitig zu lesen und zu schreiben.
Was wir haben, reicht für unsere Zwecke aus, aber Sie können sich die Erklärung in der Zeitung ansehen, wenn Sie neugierig sind. Die in der interaktiven CPU-Demo implementierte Alternativgleichung finden Sie auch in der Funktion
diffuse_advanced ()
.Eine schnelle Lösung
Wenn Sie mit Ihrem Rauch herumspielen, werden Sie vielleicht bemerken, dass er unten am Bildschirm hängen bleibt, wenn Sie dort etwas generieren. Dies liegt daran, dass die Pixel in dieser unteren Zeile versuchen, die Werte aus den darunter liegenden Pixeln zu erhalten die, die nicht existieren.
Um dies zu beheben, stellen Sie einfach sicher, dass die Pixel in der unteren Zeile gefunden werden
0
Unter ihnen:// Die untere Begrenzung behandeln // Dies muss vor der diffusen Funktion ausgeführt werden, wenn (pixel.y <= yPixel) downColor.rgb = vec3(0.0);In der CPU-Demo beschäftigte ich mich damit, die Zellen in der Grenze nicht diffus zu machen. Alternativ können Sie einfach jede Zelle außerhalb des zulässigen Bereichs manuell auf einen Wert von festlegen
0
. (Das Raster in der CPU-Demo erstreckt sich in jeder Richtung um eine Zeile und Spalte von Zellen, sodass Sie die Grenzen nie wirklich sehen können.)Ein Geschwindigkeitsraster
Herzliche Glückwünsche! Sie haben jetzt einen funktionierenden Rauchshader! Das letzte, was ich kurz besprechen wollte, ist das Geschwindigkeitsfeld, das in der Zeitung erwähnt wird.
Ihr Rauch muss nicht gleichmäßig nach oben oder in eine bestimmte Richtung diffundieren. es könnte einem allgemeinen Muster wie dem abgebildeten folgen. Sie können dies tun, indem Sie eine andere Textur einreichen, in der die Farbwerte die Richtung angeben, in die der Rauch an dieser Stelle einströmen soll, genau wie bei einer normalen Karte, um eine Richtung bei jedem Pixel in unserem Lernprogramm anzugeben.
Tatsächlich muss Ihre Velocity-Textur auch nicht statisch sein! Sie können den Frame-Puffer-Trick verwenden, um auch die Geschwindigkeiten in Echtzeit zu ändern. Ich werde das in diesem Tutorial nicht behandeln, aber es gibt viel Potenzial, um es zu erkunden.
Fazit
Wenn Sie etwas aus diesem Tutorial entfernen möchten, ist es eine sehr nützliche Technik, wenn Sie die Möglichkeit haben, eine Textur anstelle eines Bildschirms zu rendern.
Wofür eignen sich Frame-Puffer??
Eine häufige Verwendung hierfür ist Nachbearbeitungin Spielen. Wenn Sie einen Farbfilter anwenden möchten, anstatt ihn auf jedes einzelne Objekt anzuwenden, können Sie alle Ihre Objekte mit einer Textur in der Größe des Bildschirms rendern. Wenden Sie dann den Shader auf diese endgültige Textur an und zeichnen Sie sie auf den Bildschirm.
Ein anderes Beispiel ist das Implementieren von erforderlichen Shadern mehrere Durchgänge wie Unschärfe.Normalerweise lassen Sie Ihr Bild durch den Shader laufen, verwischen Sie in x-Richtung und führen Sie es erneut durch, um in y-Richtung zu verschwimmen.
Ein letztes Beispiel ist verzögertes Rendern, Wie im vorherigen Beleuchtungs-Tutorial erläutert, können Sie auf einfache Weise viele Lichtquellen zu Ihrer Szene hinzufügen. Das Coole daran ist, dass die Berechnung der Beleuchtung nicht mehr von der Anzahl der Lichtquellen abhängt, die Sie haben.
Scheuen Sie sich nicht vor technischen Dokumenten
Das von mir zitierte Papier enthält definitiv mehr Details und es wird davon ausgegangen, dass Sie mit der linearen Algebra vertraut sind, aber lassen Sie sich nicht davon abhalten, es zu zerlegen und es zu implementieren. Das Wesentliche war am Ende ziemlich einfach zu implementieren (nach einigem Anpassen der Koeffizienten)..
Hoffentlich haben Sie hier ein wenig mehr über Shader gelernt. Wenn Sie Fragen, Vorschläge oder Verbesserungen haben, teilen Sie diese bitte weiter unten mit!