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 boolsche 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