Selbstaktualisierende Texturen
Wenn es möglich ist, Simulationen oder Rendering-Aufgaben zu parallelisieren, ist es normalerweise am besten, sie in der GPU auszuführen. In diesem Artikel werde ich eine Technik erläutern, die diese Tatsache nutzt, um beeindruckende visuelle Tricks mit geringem Leistungsaufwand zu erstellen. Alle Effekte, die ich demonstrieren werde, werden mit Texturen implementiert, die sich bei Aktualisierung "
selbst rendern ". Die Textur wird aktualisiert, wenn ein neuer Frame gerendert wird, und der nächste Texturzustand ist vollständig vom vorherigen Zustand abhängig. Auf diesen Texturen können Sie zeichnen, was zu bestimmten Änderungen führt, und die Textur selbst kann direkt oder indirekt zum Rendern interessanter Animationen verwendet werden. Ich nenne sie
Faltungsstrukturen .
Abbildung 1: Doppelte FaltungspufferungBevor wir fortfahren, müssen wir ein Problem lösen: Die Textur kann nicht gleichzeitig gelesen und geschrieben werden, Grafik-APIs wie OpenGL und DirectX erlauben dies nicht. Da der nächste Status der Textur vom vorherigen abhängt, müssen wir diese Einschränkung irgendwie umgehen. Ich muss von einer anderen Textur lesen, nicht von der, in der ich schreibe.
Die Lösung ist die
doppelte Pufferung . Abbildung 1 zeigt, wie es funktioniert: Statt einer Textur gibt es zwei, aber eine wird beschrieben und eine wird von der anderen gelesen. Die Textur, in die geschrieben wird, wird als
Back-Buffer bezeichnet , und die gerenderte Textur wird als
Front-Buffer bezeichnet . Da der Faltungstest "in sich selbst geschrieben" ist, schreibt der Sekundärpuffer in jedem Frame in den Primärpuffer, und dann wird der Primärpuffer gerendert oder zum Rendern verwendet. Im nächsten Frame ändern sich die Rollen und der vorherige Primärpuffer wird als Quelle für den nächsten Primärpuffer verwendet.
Durch Rendern des vorherigen Zustands in eine neue Faltungsstruktur mit dem Fragment-Shader (oder
Pixel-Shader ) werden interessante Effekte und Animationen erzielt. Der Shader bestimmt, wie sich der Status ändert. Der Quellcode für alle Beispiele aus dem Artikel (sowie für andere) befindet sich im
Repository auf GitHub .
Einfache Anwendungsbeispiele
Um diese Technik zu demonstrieren, habe ich eine bekannte Simulation gewählt, bei der der Status beim Aktualisieren vollständig vom vorherigen Status abhängt: dem
Conway-Spiel „Life“ . Diese Simulation wird in einem Gitter von Quadraten durchgeführt, von denen jede Zelle lebt oder tot ist. Die Regeln für den folgenden Zellstatus sind einfach:
- Wenn eine lebende Zelle weniger als zwei Nachbarn hat, wird sie aber tot.
- Wenn eine lebende Zelle zwei oder drei lebende Nachbarn hat, bleibt sie am Leben.
- Wenn eine lebende Zelle mehr als drei lebende Nachbarn hat, ist sie tot.
- Wenn eine tote Zelle drei lebende Nachbarn hat, wird sie lebendig.
Um dieses Spiel als Faltungstextur zu implementieren, interpretiere ich die Textur als das Raster des Spiels und der Shader rendert basierend auf den obigen Regeln. Ein transparentes Pixel ist eine tote Zelle, und ein weißes undurchsichtiges Pixel ist eine lebende Zelle. Eine interaktive Implementierung wird unten gezeigt. Für den Zugriff auf die GPU verwende ich
myr.js , wofür
WebGL 2 erforderlich ist. Die meisten modernen Browser (wie Chrome und Firefox) können damit arbeiten, aber wenn die Demo nicht funktioniert, unterstützt sie der Browser höchstwahrscheinlich nicht. Verwenden Sie die Maus (oder den Touchscreen) [im Originalartikel], um lebende Zellen auf die Textur zu zeichnen.
Der Fragment-Shader-Code (in GLSL, da ich WebGL zum Rendern verwende) ist unten dargestellt. Zuerst implementiere ich die
get
Funktion, mit der ich ein Pixel von einem bestimmten Versatz von dem aktuellen lesen kann. Die
pixelSize
Variable ist ein vordefinierter 2D-Vektor, der den UV-Versatz jedes Pixels enthält, und die
get
Funktion liest damit die benachbarte Zelle. Die Hauptfunktion bestimmt dann die neue Farbe der Zelle basierend auf dem aktuellen Status (
live
) und der Anzahl der lebenden Nachbarn.
uniform sampler2D source; uniform lowp vec2 pixelSize; in mediump vec2 uv; layout (location = 0) out lowp vec4 color; int get(int dx, int dy) { return int(texture(source, uv + pixelSize * vec2(dx, dy)).r); } void main() { int live = get(0, 0); int neighbors = get(-1, -1) + get(0, -1) + get(1, -1) + get(-1, 0) + get(1, 0) + get(-1, 1) + get(0, 1) + get(1, 1); if (live == 1 && neighbors < 2) color = vec4(0); else if (live == 1 && (neighbors == 2 || neighbors == 3)) color = vec4(1); else if (live == 1 && neighbors == 3) color = vec4(0); else if (live == 0 && neighbors == 3) color = vec4(1); else color = vec4(0); }
Eine andere einfache Faltungsstruktur ist ein
Spiel mit fallendem Sand , bei dem der Benutzer bunten Sand auf die Szene werfen kann, die herunterfällt und Berge bildet. Obwohl die Implementierung etwas komplizierter ist, sind die Regeln einfacher:
- Befindet sich kein Sand unter einem Sandkorn, fällt dieser ein Pixel nach unten.
- Befindet sich Sand unter einem Sandkorn, der jedoch um 45 Grad nach links oder rechts abrutschen kann, so wird dies der Fall sein.
Das Management in diesem Beispiel ist dasselbe wie im Spiel "Leben". Da Sand nach solchen Regeln mit einer Geschwindigkeit von nur einem Pixel pro Frame abfallen kann, um den Prozess geringfügig zu beschleunigen, wird die Textur pro Frame dreimal aktualisiert. Der Quellcode der Anwendung ist
hier .
Ein Schritt vorwärts
Abbildung 2: Pixelwellen.Die obigen Beispiele verwenden direkt eine Faltungstextur; Der Inhalt wird unverändert auf dem Bildschirm angezeigt. Wenn Sie Bilder nur als Pixel interpretieren, sind die Einsatzgrenzen dieser Technik sehr begrenzt, können aber dank moderner Ausstattung erweitert werden. Anstatt Pixel als Farben zu zählen, werde ich sie etwas anders interpretieren, was verwendet werden kann, um Animationen einer weiteren Textur oder eines 3D-Modells zu erstellen.
Zuerst werde ich die Faltungsstruktur als Höhenkarte interpretieren. Die Textur simuliert
Wellen und
Vibrationen auf der Wasserebene und die Ergebnisse werden zum Rendern von Reflexionen und schattierten Wellen verwendet. Wir müssen die Textur nicht mehr als Bild lesen, sodass wir ihre Pixel zum Speichern von Informationen verwenden können. Bei einem Water Shader speichere ich die Wellenhöhe im roten Kanal und den Wellenimpuls im grünen Kanal, wie in Abbildung 2 dargestellt. Der blaue und der Alpha-Kanal werden noch nicht verwendet. Wellen entstehen durch das Zeichnen roter Flecken auf einer Faltungsstruktur.
Ich werde die Methode zur Aktualisierung der Höhenkarte, die ich von der Website von
Hugo Elias ausgeliehen habe , die aus dem Internet verschwunden zu sein scheint, nicht berücksichtigen. Er lernte diesen Algorithmus auch von einem unbekannten Autor und implementierte ihn in C zur Ausführung in der CPU. Der Quellcode für die unten stehende Anwendung ist
hier .
Hier habe ich eine Höhenkarte nur zum Versetzen der Textur und zum Hinzufügen von Schattierungen verwendet, aber in der dritten Dimension können viel interessantere Anwendungen implementiert werden. Wenn eine Faltungstextur von einem Vertex-Shader interpretiert wird, kann eine flach unterteilte Ebene verzerrt werden, um dreidimensionale Wellen zu erzeugen. Sie können die resultierende Form mit der üblichen Schattierung und Beleuchtung versehen.
Es ist anzumerken, dass die Pixel in der Faltungstextur des oben gezeigten Beispiels manchmal sehr kleine Werte speichern, die aufgrund von Rundungsfehlern nicht verschwinden sollten. Daher sollten die Farbkanäle dieser Textur eine höhere Auflösung haben und nicht die Standard-8-Bits. In diesem Beispiel habe ich die Größe jedes Farbkanals auf 16 Bit erhöht, was ziemlich genaue Ergebnisse ergab. Wenn Sie keine Pixel speichern, müssen Sie häufig die Genauigkeit der Textur erhöhen. Glücklicherweise unterstützen moderne Grafik-APIs diese Funktion.
Wir nutzen alle Kanäle
Abbildung 3: Pixelgras.Im Wasserbeispiel werden nur die roten und grünen Kanäle verwendet, aber im nächsten Beispiel werden alle vier angewendet. Es wird ein Feld mit Gras (oder Bäumen) simuliert, das mit dem Cursor verschoben werden kann. Abbildung 3 zeigt, welche Daten in einem Pixel gespeichert sind. Der Versatz wird in den roten und grünen Kanälen und die Geschwindigkeit in den blauen und Alpha-Kanälen gespeichert. Diese Geschwindigkeit wird aktualisiert, um sich mit einer allmählich verblassenden Wellenbewegung in die Ruheposition zu verschieben.
Im Beispiel mit Wasser ist das Erstellen von Wellen ganz einfach: Auf der Textur können Punkte gezeichnet werden, und Alpha-Blending sorgt für glatte Formen. Sie können problemlos mehrere überlappende Punkte erstellen. In diesem Beispiel ist alles schwieriger, da der Alpha-Kanal bereits verwendet wird. Wir können keinen Punkt mit einem Alpha-Wert von 1 in der Mitte und 0 vom Rand aus zeichnen, da dies dem Gras einen unnötigen Impuls gibt (da der vertikale Impuls im Alpha-Kanal gespeichert ist). In diesem Fall wurde ein separater Shader geschrieben, um den Effekt auf die Faltungstextur zu zeichnen. Dieser Shader stellt sicher, dass beim Alpha-Blending keine unerwarteten Effekte auftreten.
Den Quellcode der Anwendung finden Sie
hier .
Gras wird in 2D erstellt, aber der Effekt funktioniert in 3D-Umgebungen. Anstelle der Pixelverschiebung werden die Scheitelpunkte verschoben, was ebenfalls schneller ist. Mit Hilfe von Gipfeln kann auch ein anderer Effekt erzielt werden: Unterschiedliche Stärke der Äste - das Gras biegt sich mühelos mit dem geringsten Wind, und starke Bäume schwanken nur während Stürmen.
Obwohl es viele Algorithmen und Shader gibt, um die Auswirkungen von Wind und Verschiebung der Vegetation zu erzeugen, hat dieser Ansatz einen schwerwiegenden Vorteil: Das Zeichnen von Effekten auf eine Faltungstextur ist ein sehr kostengünstiger Prozess. Wenn der Effekt in einem Spiel angewendet wird, kann die Bewegung der Vegetation durch Hunderte verschiedener Einflüsse bestimmt werden. Nicht nur die Hauptfigur, sondern auch alle Gegenstände, Tiere und Bewegungen können die Welt auf Kosten unbedeutender Kosten beeinflussen.
Andere Anwendungsfälle und Mängel
Sie können mit vielen anderen Technologieanwendungen aufwarten, zum Beispiel:
- Mit einer Faltungsstruktur können Sie die Windgeschwindigkeit simulieren. Auf die Textur können Sie Hindernisse zeichnen, die die Luft um sie herum bewegen. Partikel (Regen, Schnee und Blätter) können diese Textur verwenden, um um Hindernisse zu fliegen.
- Sie können die Ausbreitung von Rauch oder Feuer simulieren.
- Die Textur kann die Dicke einer Schnee- oder Sandschicht kodieren. Spuren und andere Wechselwirkungen mit der Ebene können Dellen und Drucke auf der Ebene erzeugen.
Bei dieser Methode gibt es Schwierigkeiten und Einschränkungen:
- Es ist schwierig, Animationen an sich ändernde Bildraten anzupassen. Beispielsweise fallen in einer Anwendung mit fallendem Sand Sandkörner mit einer konstanten Geschwindigkeit ab - ein Pixel pro Aktualisierung. Eine mögliche Lösung könnte darin bestehen, Faltungsstrukturen mit einer konstanten Frequenz zu aktualisieren, ähnlich wie die meisten physischen Engines funktionieren. Die Physik-Engine läuft mit einer konstanten Frequenz und die Ergebnisse werden interpoliert.
- Das Übertragen von Daten auf die GPU ist ein schneller und einfacher Vorgang, das Zurückholen von Daten ist jedoch nicht so einfach. Dies bedeutet, dass die meisten Effekte, die mit dieser Technik erzeugt werden, unidirektional sind. Sie werden auf die GPU übertragen und die GPU erledigt ihre Arbeit ohne weitere Eingriffe und Rückmeldungen. Wenn ich die Wellenlänge aus dem Wasser beispielsweise in physikalische Berechnungen einbetten wollte (zum Beispiel, damit die Schiffe mit den Wellen mitschwingen), benötigte ich Werte aus der Faltungstextur. Das Abrufen von Texturdaten von einer GPU ist ein schrecklich langsamer Prozess, der nicht in Echtzeit ausgeführt werden muss. Die Lösung für dieses Problem kann die Implementierung von zwei Simulationen sein: eine mit einer hohen Auflösung für Wassergrafiken als Faltungstextur, die andere mit einer niedrigen Auflösung in der CPU für Wasserphysik. Wenn die Algorithmen gleich sind, können die Abweichungen durchaus akzeptabel sein.
Die Demos in diesem Artikel können weiter optimiert werden. Im Gras-Beispiel können Sie eine Textur mit viel geringerer Auflösung ohne erkennbare Mängel verwenden. Dies wird in großen Szenen sehr hilfreich sein. Eine weitere Optimierung: Sie können beispielsweise in jedem vierten Frame oder in einem Viertel pro Frame eine niedrigere Aktualisierungsrate verwenden (da diese Technik keine Probleme mit segmentierten Aktualisierungen verursacht). Um eine gleichmäßige Bildrate aufrechtzuerhalten, können der vorherige und der aktuelle Zustand der Faltungstextur interpoliert werden.
Da Faltungsstrukturen eine interne Doppelpufferung verwenden, können Sie beide Strukturen gleichzeitig zum Rendern verwenden. Der primäre Puffer ist der aktuelle Status und der sekundäre der vorherige. Dies kann nützlich sein, um die Textur über die Zeit zu interpolieren oder um Ableitungen für Texturwerte zu berechnen.
Fazit
GPUs, insbesondere in 2D-Programmen, sind häufig inaktiv. Obwohl es den Anschein hat, dass es nur zum Rendern komplexer 3D-Szenen verwendet werden kann, zeigt die in diesem Artikel beschriebene Technik mindestens eine andere Möglichkeit, die Leistung der GPU zu nutzen. Mithilfe der Funktionen, für die die GPU entwickelt wurde, können Sie interessante Effekte und Animationen implementieren, die für die CPU normalerweise zu kostspielig sind.