www.codeworx.org/opengl-tutorials/codeworx.org - Partikelsysteme mit OpenGL - Teil 1 - Über den Wolken

Partikelsysteme mit OpenGL - Teil 1 - Über den Wolken

Dieses Tutorial soll einen Einblick geben wie man ein Partikelsystem in OpenGL
realisiert. Dazu simulieren wir einen „Flug über den Wolken“ bei dem Schneeflocken
in Richtung Camera fliegen. Dabei geht es nicht darum eine möglichst realistische
Simulation zu programmieren sondern vielmehr darum, wie ein Partikelsystem in den
Grundzügen aufgebaut ist und wie man es einsetzt.

Was ist ein Partikelsystem?
Ein Partikelsystem ist erst einmal nur ein Konzept, eine Sammlung bzw. Liste von
Objekten, die sich gemäß bestimmten Regeln verhalten. Mit einer meist großen
Anzahl solcher Objekte versucht man nun visuelle Effekte zu erzielen. Nehmen wir
zum Beispiel einmal eine Landschaft in der es regnet. Die einzelnen Regentropfen
wären hier unsere Objekte und der Regen im Ganzen das Partikelsystem. Die
einzelnen Regentropfen verhalten sich gemäß den Gesetzen der Schwerkraft und
können zum Beispiel von Winden beeinflusst werden. Regenwolken dienen als
Austrittspunkte der Regentropfen, und würden dann als Emitter bezeichnet. Trifft ein
Regentropfen auf den Boden so „stirbt“ er und wird aus der Liste gelöscht.
Einsatzmöglichkeiten von Partikelsystemen

• Regen
• Schnee
• sonstige Wettereffekte
• Feuer
• Explosionen
• Rauch
• Feuerwerk
• usw.

Aufbau
Ein Partikelsystem besteht aus einem Emitter. Dieser Emitter erschafft, verwaltet und
entfernt Partikel. Partikel sind hierbei als kleine geometrische Objekte auszufassen
die in einer Ebene des Raumes liegen. Partikel können also Punkte, Linien oder
Dreiecke sein, je nach gewünschtem visuellem Effekt.
Im Folgenden betrachten wir Partikel immer als ein oder mehr Dreiecke, die in einer
Ebene liegen. Auf diese Dreiecke zeichnen wir eine Textur, die je nach gewünschtem
Effekt auf das Objekt gezeichnet wird, beispielsweise mit Blending oder einer
Alphamap. Zur Vereinfachung benutzen wir hier 2 Dreiecke die ein Quadrat bilden
und brauchen so nur ein Quadrat pro Partikel zu zeichnen.

Eine erste Demo
In diesem ersten Tutorial sollen Partikel sowie Emitter in Klassen gekapselt werden.
Ein erster Entwurf einer Partikelklasse könnte also folgendermaßen aussehen:
Jeder Partikel besitzt:

• eine Position,
• einen Geschwindigkeitsvektor,
• eine Farbe,
• eine bestimmte Größe,
• den Zeitpunkt seiner „Geburt“
• und seine Lebensdauer

Außerdem wäre es nicht schlecht, wenn jeder Partikel seinen Emitter kennen würde
und eine bool’sche Variable besitzt, die über seinen „Lebensstatus“ aussagt. Da
Partikel keine statischen Objekte sind, sondern ihre Position und andere
Eigenschaften mit der Zeit verändern braucht man noch eine Updatefunktion sowie
eine Funktion zum rendern des Partikels auf dem Bildschirm.
Konkret können wir also ein Partikel so umsetzen:

typedef float VECTOR[3];

class CParticle
{
   public:
   // Konstruktor und Destruktor
   CParticle(CPartEmitter* pEmitter);
   virtual ~CParticle();
   // Methoden
   void Update();                    // Aufruf einmal pro Frame
   void Render();                    // Aufruf einmal pro Frame
   void Set(…);                      // … steht für Attribute des Objekts
   CParticle* GetNext();             // Listenfunktion
   void SetNext(CParticle* pNext);   // Listenfunktion
   public:
   // Attribute
   VECTOR m_vPosition;               // Position im Raum
   VECTOR m_vVelocity;               // Geschwindigkeitsvektor
   float m_clrColor[4];              // RGB-Farbe, m_clrColor[3]
   // enthält den Alpha-Wert
   float m_fSize;
   float m_fBirthday;                // Zeitpunkt der Konstruktion
   float m_fLifetime;                // Lebensdauer in Sekunden
   bool m_bLife;                     // wird im Konstrukror auf true gesetzt.
                                     // Soll das Objekt vom Emitter gelöscht
                                     // werden: m_bLife = false
   private:
   CPartEmitter* m_pEmitter;         // Zeiger auf Emitter
   CParticle* m_pNext;               // Zeiger auf nächstes Element der Liste
   };
   
   // Benutzung des Templates CList
   class CParticleList : public CList<CParticle*> {};

Zur besseren Lesbarkeit wird VECTOR als Array vereinbart, das 3 float-Werte
aufnehmen kann. Im Konstruktor des Partikels wird der zugehörige Emitter
übergeben und in m_pEmitter gespeichert.
Die Set(…) Funktion bekommt Parameter entsprechend den Attributen des Partikel
und setzt diese.

Die GetNext() und SetNext() Funktionen werden benötigt, da CParticleList
eine Listenstruktur für die Partikel bereitstellt, die im Emitter verwendet wird.
Der Konstruktor speichert den übergebenen Emitter und setzt die Attribute auf
bestimmte Startwerte. Das Attribut m_fBirthday enthält den Entstehungszeitpunkt
des Partikels.
Die Update() Funktion:

void CParticle Update()
{
   if(m_pEmitter->m_fCurTime - m_fBirthday > m_fLifetime)
   {
      m_bLife= false ;              // Partikel wird beim nächsten
                                    // Schleifendruchlauf gelöscht
      return;                       // keine weiteren Updates mehr nötig
   }

   // neue Position anhand der Geschwindigkeit und der Zeit seit
   // dem letzen Update berechnen
   m_vPosition[0] += (m_vVelocity[0]*m_pEmitter->m_fFrameTime);
   m_vPosition[1] += (m_vVelocity[1]*m_pEmitter->m_fFrameTime);
   m_vPosition[2] += (m_vVelocity[2]*m_pEmitter->m_fFrameTime);
}

Gerendert wird ein Rechteck mit den Ausmaßen des Partikels:

void CParticle::Render()
{
   glColor4fv(m_clrColor); // Farbe des Partikel setzen
   glBegin(GL_QUADS);      // Begin der Zeichenoperation,
   //zeichne Rechtecke
   //oben rechts
   glTexCoord2i(1,1);
   glVertex3f(m_vPosition[0]+m_fSize,m_vPosition[1]+m_fSize,
   m_vPosition[2]);
   // oben links
   glTexCoord2i(0,1);
   glVertex3f(m_vPosition[0]-m_fSize,m_vPosition[1]+m_fSize,
   m_vPosition[2]);
   // unten links
   glTexCoord2i(0,0);
   glVertex3f(m_vPosition[0]-m_fSize,m_vPosition[1]-m_fSize,
   m_vPosition[2]);
   // unten rechts
   glTexCoord2i(1,0);
   glVertex3f(m_vPosition[0]+m_fSize,m_vPosition[1]-m_fSize,
   m_vPosition[2]);
   glEnd();                // Ende der Zeichenoperation
}

Damit ist die erste Version der Partikelklasse fertig. Wir haben uns auf das
Wesentliche beschränkt und werden diese Klasse nach und nach erweitern.

Der Emitter sollte nun eine Liste von Partikeln enthalten und in der Lage sein diese
zu verwalten:

class CPartEmitter
{
   public:
   CPartEmitter(int iMaxParticles);
   virtual ~CPartEmitter();          
   // Im Destruktor werden alle
   // Partikel gelöscht, die sich noch
   // in der Liste befinden
   int GetNumParticles();
   public:
   bool Add(CParticle* pParticle);
   void Update();
   // Diese beiden statischen Variablen müssen im Hauptprogramm
   // einmal in pro Frame aktualisiert werden
   static float m_fFrameTime;
   static float m_fCurTime;
   private:
   CParticleList m_ParticleList;
   int m_iNumParticles;
   int m_iMaxParticles;
};

Dem Konstruktor wird die maximal mögliche Anzahl von Partikeln übergeben, die der
Emitter aufnehmen soll.
Der Destruktor löscht alle Elemente aus m_ParticleList.
Die statischen Variablen m_fFrameTime bzw. m_fCurTime speichern den
Zeitaufwand pro Frame bzw. die aktuelle Zeit und sollten einmal pro Frame vom
Hauptprogramm aktualisiert werden.

Die Add() Funktion:

bool CPartEmitter::Add(CParticle* pParticle)
{
   // falls die maximale Anzahl von Partikeln noch nicht
   // erreicht wurde, wird der Liste eine Partikel hinzugefügt
   // und true zurückgegeben
   if(m_iNumParticles < m_iMaxParticles)
   {
      m_ParticleList.Add(pParticle);
      m_iNumParticles++;
      return true;
   }

   // ansonsten wird false zurückgegeben
   return false;
}

Die Update() Funktion:

void CPartEmitter::Update()
{
   // Schleife durch alle Partikel der Liste
   CParticle* pPar = m_ParticleList.GetHead();
   CParticle* pPrev = NULL;
   while(pPar)
   {
      pPar->Update();    // Update des Partikels
      if(!pPar->m_bLife) // falls der Partikel nicht mehr lebt
      {
         // Partikel wird aus Liste entfernt und gelöscht
         if(pPrev)
            pPrev->SetNext(pPar->GetNext());
         else
            m_ParticleList.SetHead(pPar->GetNext());
delete pPar; pPar = NULL; m_iNumParticles--; if(pPrev) pPar = pPrev->GetNext(); else pPar = m_ParticleList.GetHead();
continue; // zum nächsten Schleifendurchlauf }
else { // falls der Partikel noch lebt, wird er gerendert pPar->Render(); } pPrev = pPar; pPar = pPar->GetNext(); // Listenfortschaltung } }

Die Update-Funktion besteht also im Wesentlichen daraus, alle Partikel, die sich in
der Partikelliste des Emitters befinden zu durchlaufen, sie zu aktualisieren und sie
gegebenenfalls zu rendern, falls diese noch „leben“ andernfalls, falls die
Lebenspanne der Partikel abgelaufen ist, werden diese vom Emitter aus der Liste
gelöscht.

Nun steht also die Partikel- und Emitterklasse zur Verfügung. Das Hauptprogramm
besteht zum einem aus dem OpenGL Code zum erstellen der Anwendung (dieser
Teil wird hier nicht weiter erläutert), zum anderem aus dem Teil, der sich mit unseren
Partikeln beschäftigt. Dieser Teil ist wie folgt aufgebaut:
• Initialisierungsfunktionen zum Programmstart
• Rendern der Szene in der Hauptschleife

Ein globales CPartEmitter-Objekt g_pEmitter wird von der Funktion
InitParticles() erzeugt:

void InitParticles()
{
   // neues Emitterobjekt das max. 500 Partikel verwalten kann
   g_pEmitter = new CPartEmitter(500);
   // Timer
   g_Timer.Init();
   g_Timer.Update();
   // Der Emitter bekommt die aktuelle Zeit & die Frame-Zeit
   CPartEmitter::m_fCurTime = g_Timer.GetCurrentTime();
   CPartEmitter::m_fFrameTime = g_Timer.GetFrameTime();
   // Neue Partikel erzeugen
   BuildParticles();
}

In BuildParticles() werden pro Aufruf 200 Partikel mit unterschiedlichen
Attributen zum Emitter hinzugefügt.

void BuildParticles()
{
   for(int i = 0; i < 200; i++)
   {
      // neuer Partikel
      CParticle* p = new CParticle(g_pEmitter);
// Partikel entstehen um (-10,-10,-150) + Zufallsvektor VECTOR vPos = { -10 + ((rand() % 80)-40), -10+((rand() % 80)-40), -150+((rand() % 60)-30)}; // zufällige Geschwindigkeit VECTOR vVel= { ( rand()%10)-5*0.001 , ( rand()%10)-5*0.001 , ( rand()%60)-15 *0.001}; float clrCol[4] = { 0.7, 0.7, 0.7 ,1}; // helles Grau
// mind. Lebensdauer: 5.0 + Zufall float fLife = (rand() % 10 )*0.1 + 5.0;
// variable Groesse, mind. 0.2 float fSize = 0.2f + ( (rand() % 10) -5)*0.1; p->Set(vPos,vVel,clrCol,fSize,fLife); // Werte übergeben
// Neues Partikelobjekt dem Emitter übergeben g_pEmitter->Add(p); } }

Damit die Partikel richtig gezeichnet werden, werden folgende OpenGL-
Einstellungen beim Start der Demo vorgenommen:

   // Blending Einstellungen
   glEnable(GL_BLEND);
   glBlendFunc(GL_SRC_ALPHA,GL_ONE);
   glEnable(GL_TEXTURE_2D);
   glBindTexture(GL_TEXTURE_2D,g_Texture[0]);// Schneetextur

In der Hauptschleife darf der Timer nicht vergessen werden:

   g_Timer.Update();
   CPartEmitter::m_fCurTime = g_Timer.GetCurrentTime();
   CPartEmitter::m_fFrameTime = g_Timer.GetFrameTime();
   RenderScene(); // hier wird die Szene gezeichnet

Bleibt nur noch die Funktion RenderScene():

void RenderScene()
{
   // Hintergrund und Tiefenbuffer löschen
   glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
   glLoadIdentity(); // Einheitsmatrix laden
   glPushMatrix();   // Matrix auf Stack speichern
   // Kamerabewegungen
   if(g_bDir)
      glRotatef(g_fRot, 0.0f, 0.0f, 1.0f);
g_fRot += g_Timer.GetFrameTime()*10; glTranslatef(g_vPos[0],g_vPos[1],g_vPos[2]);
if(g_vPos[2] > 0 && !g_bDir) { g_vPos[2] -= g_Timer.GetFrameTime()*20; } else if (!g_bDir) g_bDir = true; if(g_vPos[2] < 700 && g_bDir) { g_vPos[2] += g_Timer.GetFrameTime()*10; } else if(g_vPos[2] >= 700) g_bDir = false; // Neue Partikel hinzufügen, falls mehr als 1 s seit // dem letzen Aufruf von BuildParticles verstrichen ist if(g_Timer.GetCurrentTime() - g_fLastUpdate > 1.0f) { BuildParticles(); g_fLastUpdate = g_Timer.GetCurrentTime(); }
DrawBackground(); // Hintergrund zeichnen glPopMatrix(); // Matrix vom Stack holen, da die // Partikel immer sichtbar sein sollen g_pEmitter->Update(); // Partikel zeichnen SwapBuffers(g_hDC); }

Fazit
Wie man sieht ist ein einfaches Partikelsystem schnell erstellt. Schaut man sich
allerdings die Demo etwas genauer an, wird man feststellen, dass die Partikel „wie
aus dem Nichts“ entstehen und bei Ablauf der Lebenspanne mit einem Schlag nicht
mehr gezeichnet werden. Schöner wäre es doch wenn die Partikel „langsam
sterben“, also zum Beispiel Verblassen würden (Schneeflocken schmelzen).
Auch ist die Zeichenoperation mit Quads ist nicht besonders effizient, besser wären
zum Beispiel Trianglestrips.
Diese und andere Optimierungen und Verbesserungen werden wir uns im nächsten
Tutorial vornehmen und schauen was mit Partikeln noch so alles möglich ist.
Feedback erwünscht!

Den VC++ 6.0-Arbeitsbereich dazu gibts hier.

rick@diamond-productions.de, diamond-productions.de

codeworx.org