GLSL Partikel 2

Aus DGL Wiki
Wechseln zu: Navigation, Suche
Hinweis: Dieser Artikel wird gerade Offline bearbeitet!

Bitte haben Sie etwas Geduld und nehmen Sie keine Änderungen vor, bis der Artikel hochgeladen wurde.

(weitere Artikel)
WIP Offline.jpg

Der klassische Ansatz eines GPU Partikelsystems verwendet eine oder mehrere Texturen um die Partikel zu speichern. Zur Aktualisierung wird ein Quad in Größe dieser Textur gerendert. Der Fragmentshader liest die alten Daten aus einem Backbuffer und schreibt die aktualisierten Partikel in den Framebuffer. Dieses Verfahren ist an sich sehr effizient, hat aber einige Nachteile:

  • Es ist sehr kompliziert effizient neue Partikel von der CPU zu emittieren. Die CPU muss immer wissen wo sich aktive Partikel in der Textur befinden. Sind die Lebensdauern der Partikel unterschiedlich (oder gar zufällig) wird dies zu einem echten Problem. In der Regel erfordert dies zumindest die Berechnung der Lebendauer zusätzlich auch auf der CPU durchzuführen, was natürlich nicht der Sinn eines GPU Partikelsystems ist.
  • Da die Partikelanordnung wie gerade beschrieben fragmentiert, muss beim rendern der Partikel immer die vollständige Textur verarbeitet werden, auch wenn eigentlich nur wenige Partikel tatsächlich aktiv sind. Außerdem ist zum rendern ein großes VBO mit Dummy-Partikeln erforderlich, die eigentlich keine Daten enthalten.
  • Sollen die Partikel zum Beispiel nach der Entfernung zur Kamera sortiert werden, ist dies sehr aufwendig. Auf der GPU verwendet man üblicherweise Odd-Even-Mergesort oder Bitonic-Sort. Beide Algorithmen vergleichen jeweils zwei Elemente und führen dann ggf. einen Tausch (Swap) dieser Elemente aus. Im Fragmentshader lässt sich jedoch die Position eines Fragments nicht mehr nachträglich ändern. Eine Implementierung im Fragmentshader erfordert also, dass jeder Vergleich zweimal durchgeführt wird. Das bedeutet auch, dass jeder Texturzugriff zweimal durchgeführt werden muss. Zudem erfordern beide Algorithmen sehr viele Passes. Für eine Million Partikel (1024²) sind so etwa 210 Passes erforderlich. Dies ist auch bei einer Verteilung über mehrere Frames nicht mehr sinnvoll in Echtzeit durchführbar.

Durch die Benutzung der Features aktueller Grafikhardware wie dem Geometryshader, Transform-Feedback, Instancing sowie diverser weiterer Features des Shader Model 4.0 lässt sich hier viel optimieren. Dieser Artikel setzt die Kenntnis dieser Funktionen voraus. Desweiteren sollte man natürlich wissen wie ein (state-preserving) Partikelsystem funktioniert, also zum Beispiel etwas mit den Begriffen Emitter und Forcefield anfangen können.


Grundlagen

Ich verwende hier neben OpenGL 2.1 auch zahlreiche Extensions. Wenn man das Grundprinzip verstanden hat sollte es kein Problem sein diese Extensions durch eventuell verfügbare aktuellere Extensions zu ersetzen.

Zum speichern der Partikeldaten verwende ich mehrere Texturbuffer-Objects (TBO).

  • ein 4x32bit-float Buffer zum speichern der Position und Größe des Partikels (GL_RGBA32F_ARB)
  • ein weiterer 4x32bit-float Buffer zum speichern der Velocity und Lebenszeit des Partikels (GL_RGBA32F_ARB)
  • ein 32bit-unsigned Buffer zum speichern des aktuellen Zufallsseed (GL_LUMINANCE32UI_EXT)

Alle Partikel werden durch Emitter erzeugt. Da uns später immer die genaue Anzahl der aktiven Partikel bekannt sein wird, müssen die Buffer nicht initialisiert werden.

Update-Schritt

Das wichtigste an einem Partikelsystem ist immer der Update-Schritt. Daher fangen wir auch damit an. Auch wenn wir erst später erfahren wie neu erzeugte Partikel eigentlich in den Buffer kommen, nehmen wir zunächst einmal an die Buffer wären mit aktiven Partikeln gefüllt.

Eine Grafikkarte kann nicht aus einem Buffer lesen und gleichzeitig auch hinein schreiben. Wir benötigen also jeden Buffer doppelt: Einen Inputbuffer aus dem wir als VBO bzw. TBO lesen, während wir in den Outputbuffer via Transform-Feedback schreiben. Nach jedem Updateschritt werden die Rollen der Buffer getauscht.

Zunächst interpretieren wir den Position-Inputbuffer als Vertexbuffer und rendern sämtliche Partikel. Der Vertexshader ist trivial, denn die eigentliche Arbeit verrichtet der Geometryshader. Der Fragmentshader wird nicht benötigt und daher mittels glEnable(GL_RASTERIZER_DISCARD_NV) abgeschaltet.

Vertexshader:

void main() {
	gl_Position = gl_Vertex;
}

Geometryshader:

#extension GL_EXT_gpu_shader4 : enable
#extension GL_EXT_geometry_shader4: enable

uniform samplerBuffer tboVelocity;
uniform usamplerBuffer tboRandom;
uniform float timeElapsed;

varying out vec4 velocity_out;
varying out unsigned int seed;

const vec3 wind = vec3(1.0, 1.0, 2.0);

vec3 constField() {
	return timeElapsed * wind;
}

vec3 dampField(vec3 velocity, float strength) {
	return -velocity*strength*timeElapsed;
}

vec3 noiseField(float strength) {
	vec3 result;
	seed = (seed * 1103515245u + 12345u); result.x = float(seed);
	seed = (seed * 1103515245u + 12345u); result.y = float(seed);
	seed = (seed * 1103515245u + 12345u); result.z = float(seed);
	return timeElapsed * strength * ((result / 4294967296.0) - vec3(0.5,0.5,0.5));
}

void main() {
	// get all particle data
	vec3 position = gl_PositionIn[0].xyz;
	float size = gl_PositionIn[0].w;

	vec4 tmp = texelFetchBuffer(tboVelocity, gl_PrimitiveIDIn);
	vec3 velocity = tmp.xyz;
	float lifetime = tmp.w;

	// update lifetime and discard dead particles
	lifetime -= timeElapsed;
	if (lifetime <= 0.0) { return; }

	// update position and size
	position += velocity * timeElapsed;
	size += 0.3 * size * timeElapsed;
	
	// read current random seed
	seed = texelFetchBuffer(tboRandom, gl_PrimitiveIDIn).x;

	// update velocity by applying force fields
	velocity += dampField(velocity, 0.5);
	velocity += constField();
	velocity += noiseField(1.0);
	
	// write vertex
	gl_Position = vec4(position, size);
	velocity_out = vec4(velocity, lifetime);
	EmitVertex();
}

Da wir GL_POINTS rendern ist ein Primitiv einen Vertex groß. Aus diesem Grund eignet sich die eingebaute Variable gl_PrimitiveIDIn perfekt als Arrayindex. Via TexelFetch können wir also Velocity, Lebenszeit und aktuellen Seed auslesen.

Ist die Lebenszeit eines Partikels abgelaufen, brechen wir die Verarbeitung dieses Partikels ab. Die Funktion EmitVertex() wird also nicht aufgerufen und der Partikel somit unterdrückt. Der entscheidende Vorteil von Transform-Feedback an dieser Stelle ist nun, das beim schreiben in den Outputbuffer keine Lücke entsteht. Es tritt also keine Fragmentierung auf. Am Ende des Updatevorgangs befinden sich alle Partikel aufgeräumt am Anfang des Buffers. Mit einem Query lässt sich auch die Anzahl der in den Outputbuffer geschriebenen Partikel auslesen, beim nächsten Frame wissen wir also exakt wie viele Partikel wir rendern müssen.

Bezüglich des verwendeten Zufallsgenerators in der Funktion noiseField(float) sollte man bei Bedarf den Artikel GLSL Noise konsultieren. Der Rest des Update-Shaders sollte eigentlich klar sein.

Via Transform-Feedback werden die Inhalte gl_Position und den beiden varyings velocity_out und seed in ihre jeweiligen Outputbuffer geschrieben.

Emitter

Erweiterungen

Nun wollen wir das bestehende Partikelsystem erweitern. Es sollte klar sein das die hier beschriebenen Dinge sehr viel Rechenleistung benötigen. Diese Aktionen müssen also möglicherweise über mehrere Frames verteilt werden.

Partikel/Partikel-Interaktion

Sortieren nach Z

Transparente Schatten (mit Self-Shadows)