GLSL Partikel 2
Bitte haben Sie etwas Geduld und nehmen Sie keine Änderungen vor, bis der Artikel hochgeladen wurde. |
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.
Inhaltsverzeichnis
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.
- GL_ARB_texture_float
- GL_EXT_texture_integer
- GL_EXT_texture_buffer_object
- GL_EXT_gpu_shader4
- GL_NV_transform_feedback
- GL_EXT_geometry_shader4
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
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. Clipping und alles was danach in der Pipeline kommt wird nicht benötigt. Also insbesondere der Rasterizer und der Fragmentshader. Darum schalten wird diesen unnötigen Kram mit glEnable(GL_RASTERIZER_DISCARD_NV) einfach ab. Man sollte natürlich nicht vergessen das ganze später wieder einzuschalten.
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 genau 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
Nachdem wir nun den Update-Schritt erledigt haben, stellt sich die Frage wie den nun eigentlich neue Partikel ins System kommen sollen. Dank Transform-Feedback geht dies überraschend einfach. Wir brauchen keine Fragmentierung oder ähnliches beachten, sondern einfach nur die Daten der neuen Partikel ans Ende der jeweiligen Buffer schreiben.
Wir natürlich glBufferSubData benutzen, aber da nicht genau wissen wie viele Partikel gerade aktiv sind, wissen wir nicht in welchen Bereich geschrieben werden muss. Natürlich könnte man jetzt einen Query benutzen um die Anzahl der via Transform-Feedback geschriebenen Partikel zu ermitteln, aber da wir sowieso schon alles eingerichtet haben können wir die neuen Partikel auch einfach rendern.
Während Transform-Feedback aktiv ist, ist es nicht erlaubt den Shader zu wechseln. Aus diesem Grund verwenden wir einfach weiter unseren Update-Shader, wobei wir die Uniform timeElapsed auf 0 setzen. So werden zwar einige unnötige Berechnungen durchgeführt, aber normalerweise emittiert man sowieso nur einige 100 Partikel pro Frame. Wie die Partikel dann letztlich genau gerendert werden ist daher nur von geringer Bedeutung. Ich verwende hier zunächst mehrere std::vector um die nach und nach von der Physik und Spiellogik erzeugten Partikel zu sammeln. Einmal pro Frame werden diese dann in Buffer-Objekte geschrieben.
Sollte die maximale Größe der Partikelbuffer überschritten werden, werden die folgenden Partikel von Transform-Feedback stillschweigend unterdrückt. Um im nächsten Frame zu wissen wie viele Partikel aktiv sind, verwenden wir einen Query. Hier nun der Code zum aktualisieren der alten und emittieren der neuen Partikel.
void CParticleSystem::update(double dTimeElapsed) { // flip buffers int input = m_flip; int output = m_flip = (1 - m_flip); // setup shader glUseProgram(m_prgMove); glUniform1f(m_locMoveTimeElapsed, (float)dTimeElapsed); // setup transform feedback glBindBufferBaseNV(GL_TRANSFORM_FEEDBACK_BUFFER_NV, 0, m_vboPosition[output]); glBindBufferBaseNV(GL_TRANSFORM_FEEDBACK_BUFFER_NV, 1, m_vboVelocity[output]); glBindBufferBaseNV(GL_TRANSFORM_FEEDBACK_BUFFER_NV, 2, m_vboRandom[output]); GLint locations[3] = { m_locMovePosition, m_locMoveVelocity, m_locMoveSeed }; glTransformFeedbackVaryingsNV(m_prgMove, 3, locations, GL_SEPARATE_ATTRIBS_NV); glEnable(GL_RASTERIZER_DISCARD_NV); glBeginQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN_NV, m_qryWritten); glBeginTransformFeedbackNV(GL_POINTS); // draw active particles glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_BUFFER_EXT, m_tboRandom[input]); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_BUFFER_EXT, m_tboVelocity[input]); glBindBuffer(GL_ARRAY_BUFFER, m_vboPosition[input]); glVertexPointer(4, GL_FLOAT, 0, 0); glEnableClientState(GL_VERTEX_ARRAY); glDrawArrays(GL_POINTS, 0, m_active); // draw newly emitted particles if (m_emitted > 0) { glUniform1f(m_locMoveTimeElapsed, 0.0f); glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_BUFFER_EXT, m_tboRandomEmit); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_BUFFER_EXT, m_tboVelocityEmit); glBindBuffer(GL_ARRAY_BUFFER, m_vboPositionEmit); glVertexPointer(4, GL_FLOAT, 0, 0); glDrawArrays(GL_POINTS, 0, m_emitted); m_emitted = 0; } // clean up transform feedback, retrieve count of processed particles glEndTransformFeedbackNV(); glEndQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN_NV); glGetQueryObjectuiv(m_qryWritten, GL_QUERY_RESULT, &m_active); // clean up glDisableClientState(GL_VERTEX_ARRAY); glDisable(GL_RASTERIZER_DISCARD_NV); glBindBuffer(GL_ARRAY_BUFFER, 0); glUseProgram(0); }
Rendern
Ein Partikelsystem ist natürlich sinnfrei, wenn die Partikel nicht auch irgendwie dargestellt werden. Das ganze ist nicht weiter kompliziert. Wir benötigen weder die Velocity, die Lebenzeit noch den aktuellen Zufallsseed. Daher können wir einfach den Position-Buffer als Vertexbuffer rendern. Die Methode render() und die verwendeten Shader sollten selbst erklärend sein.
void CParticleSystem::render(CVector3f vEye) { int input = m_flip; // setup shader CVector2i viewport = CViewerWidget::instance()->getViewport(); glUseProgram(m_prgRender); glUniform3f(m_locRenderEye, vEye.x, vEye.y, vEye.z); glUniform1f(m_locRenderViewport, viewport.y); // setup vbo, tbo's, etc. glEnable(GL_VERTEX_PROGRAM_POINT_SIZE); glEnable(GL_POINT_SPRITE); glTexEnvf(GL_POINT_SPRITE, GL_COORD_REPLACE, GL_TRUE); // generate TexCoords automatically glDisable(GL_ALPHA_TEST); glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glDepthMask(GL_FALSE); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, m_texParticleAlpha); glBindBuffer(GL_ARRAY_BUFFER, m_vboPosition[input]); glVertexPointer(4, GL_FLOAT, 0, 0); glEnableClientState(GL_VERTEX_ARRAY); // draw all active particles glDrawArrays(GL_POINTS, 0, m_active); // clean up glDisableClientState(GL_VERTEX_ARRAY); glDepthMask(GL_TRUE); glDisable(GL_BLEND); glDisable(GL_POINT_SPRITE); glDisable(GL_VERTEX_PROGRAM_POINT_SIZE); glBindBuffer(GL_ARRAY_BUFFER, 0); glUseProgram(0); }
Vertexshader:
uniform vec3 eye; // camera position uniform float viewportHeight; varying float alphaScale; void main() { vec4 position = vec4(gl_Vertex.xyz, 1.0); float size = gl_Vertex.w; // transform position gl_Position = gl_ModelViewProjectionMatrix * position; // calculate pointsize gl_PointSize = (viewportHeight * size) / distance(position.xyz, eye); // rendering particles with size 0.0 may result in undefined behaivor gl_PointSize = clamp(gl_PointSize, 0.01, 64.0); // derive an alpha value from particle size alphaScale = clamp(0.05 / size, 0.001, 0.2); }
Fragmentshader:
uniform sampler2D texAlpha; varying float alphaScale; const vec3 color = vec3(0.384, 0.343, 0.286); void main() { float alpha = texture2D(texAlpha, gl_TexCoord[0].xy).a * alphaScale; if (alpha < 0.0039) { discard; } // discard fragment when alpha smaller than 1/255 gl_FragColor = vec4(color, alpha); }
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.