Der Artikel beschreibt eine erweiterbare Engine für Punkt-Partikel vollständig basierend auf Meta-Programmierung und C++ Templates.

Bemerkung vorweg: Dieser Artikel entstand 2010 zum Spiel "Nightwatch". Ich bin mir mittlerweile über reale Flaschenhälse von Partikelsystemen in richtigen Spielen bewußt. Der Code ist dennoch eine nette Gedankenübung zur C++ Templateprogrammierung.

Template Particle Engine

Particle Engines waren 2010 komplett neu für mich. Ein naiver Ansatz war sehr häufig in Tutorials zu finden: eine abstrakte Basisklasse stellt rein-virtuelle Funktionen für das aktualisieren der Particles und das kopieren in den FrameBuffer bereit.

Jedoch ein Artikel über eine templatebasierte Engine von Kent Lai beeindruckte mich mehr. Er versprach bessere Performance durch Vermeidung des virtuellen Funktionsaufrufs, aber ohne an Flexibilität einzubüßen.

Ich hatte 2006 schon einmal viel mit Templates und Metaprogrammierung zu tun und wollte meine Kenntnis hier wieder auffrischen. Ich stellte mir also selbst die Aufgabe:

Das Particle Engine Framework soll generisch und erweiterbar sein, darf aber nicht (viel) langsamer werden, als eine handgeschriebene Engine.

ParticleEngine

Das Ziel ist, eine spezifische Particle Engine über eine Aufzählung von Policy-Klassen Templates zu "konfigurieren". Beispiel: Der Feuereffekt im Titelbildschirm.
	typedef Particle::Engine<FireParticle, 8192,
		Particle::LinearMovement,
		Particle::PhoenixLifeDecrease,

		Particle::AlphaCircle,
		Particle::Glowing,
		Particle::SetColor
	> Fire;
	Fire fire;
FireParticle ist die Struktur eines Partikels. 8192 ist die maximale Anzahl von Partikeln und die restlichen Parameter sind die Aktionen, die während der Aktualisierung und des Renderns ausgeführt werden sollen.

In diesem Fall bewegen sich alle Partikel linear mit einer konstanten Geschwindigkeit (LinearMovement) und werden sofort neu am Emmiter erzeugt wenn sie sterben (PhoenixLifeDecrease). Als Partikel-Shape nehme ich einen Kreis mit abfallendem Alpha-Wert (AlphaCircle). Die Partikel sollen "glühen" (blending über Ziel-Alphawert; Glowing) und alle Partikel haben eine konstante Farbe (SetColor).

Konfiguration der Parameter

Konfiguration der Engine-Parameter die nicht über Template-Parameter geschehen kann, geschieht über Membervariablen. Welche Variablen zur Verfügung stehen, hängt davon ab, welche Aktionen im typedef deklariert wurden. Im Beispiel "Fire" stellt "Particle::SetColor" eine Variable "particleColor" zur Verfügung, die eine einheitliche Farbe für alle Partikel setzt. Die Initialisierung geschieht typischerweise im Konstruktor der Klasse, die den Feuereffekt benutzt (hier als pimpl-Idiom):
	TitleImpl()
	{
		fire.particleColor = D3DCOLOR_COLORVALUE(0.1f,0.06f,0.03f,1.0f);
	}

Das Particle - struct

Der erste Parameter an die Engine ist ein struct für ein Particle. FireParticle sieht so aus:
struct FireParticle
{
	struct Vertex
	{
		float x,y,z;
		Vertex()
			: x(randf()*gGame->width()-70)
			, y(500)
			, z(0)
		{
		}
		static const int FVF = D3DFVF_XYZ;
	};

	float maxLife;
	float life;
	Vector velocity;

	FireParticle()
		: maxLife(randf()*5.0f)
		, life(maxLife)
		, velocity(randf()*70, randf()*randf()*60-70)
	{
	}
};
Die Anforderungen an das Particle-struct sind:
  • Es muss ein inner-struct "Vertex" beinhalten, welches wiederum eine Konstante FVF definiert. Vertex wird beim rendern mit memcpy in den VertexBuffer kopiert. (Also keine virtuellen Funktionen!) Die Struktur von Vertex muss der FVF - Konstante entsprechen.
  • Welche Membervariablen das Particle benötigt hängt wiederum von den benutzten Aktionen ab. Z.B. "LinearMovement" benötigt ein "velocity", "PhoenixLifeDecrease" benötigt ein "life".
  • Alle Member-Variablen werden durch den Default-Konstruktor initialisiert. Diese Einschränkung hat sich als nicht haltbar herausgestellt und wird wohl bald entfernt werden.
  • Particle und Vertex haben copy-Semantik.

Der Sourcecode der Engine

Leider unterstützt mein Compiler noch keine Varadic Templates. Ich weiche auf eine lange Liste von Template Argumenten mit default-Parametern aus:
	// No-Op type. You may safely ignore this.
	template<class P> struct EmptyTemplate {};

	// Main Engine Template.
	template<class P, size_t MAX_SIZE,
		template<class> class U1,
		template<class> class U2 = EmptyTemplate,
		template<class> class U3 = EmptyTemplate,
		template<class> class U4 = EmptyTemplate,
		template<class> class U5 = EmptyTemplate,
		template<class> class U6 = EmptyTemplate,
		template<class> class U7 = EmptyTemplate,
		template<class> class U8 = EmptyTemplate,
		template<class> class U9 = EmptyTemplate,
		template<class> class U10 = EmptyTemplate,
		template<class> class U11 = EmptyTemplate,
		template<class> class U12 = EmptyTemplate,
		template<class> class U13 = EmptyTemplate,
		template<class> class U14 = EmptyTemplate,
		template<class> class U15 = EmptyTemplate>
	class Engine : public Sequence<P, U1, U2, U3, U4, U5, U6,
				U7, U8, U9, U10, U11, U12, U13, U14, U15>
	{
	public:
		size_t size;
		typedef P Particle;
		typedef typename Particle::Vertex Vertex;
		typedef Sequence<P, U1, U2, U3, U4, U5, U6,
				U7, U8, U9, U10, U11, U12, U13, U14, U15> BaseSequence;
		typedef Engine<Particle, MAX_SIZE, U1, U2, U3, U4, U5, U6,
				U7, U8, U9, U10, U11, U12, U13, U14, U15> EngineType;

		Particle particle[MAX_SIZE];
		Vertex vertex[MAX_SIZE];
		IDirect3DVertexBuffer9* vb;

		Engine()
			: size(MAX_SIZE)
		{
			deviceReset();
			lostConnection = gGame->deviceLost.connect(
				std::bind(&EngineType::deviceLost, this));
			resetConnection = gGame->deviceReset.connect(
				std::bind(&EngineType::deviceReset, this));
		}
		~Engine()
		{
			deviceLost();
			lostConnection.disconnect();
			resetConnection.disconnect();
		}

		void update(float elapsed)
		{
			Sequence::emitParticles(elapsed, particle, vertex, size, MAX_SIZE);
			for (size_t i = 0; i < size;)
			{
				if (Sequence::updateParticle(elapsed, particle[i], vertex[i]))
				{
					particle[i] = particle[--size];
					vertex[i] = vertex[size];
				}
				else
					++i;
			}
		}

		void paint()
		{
			if (!size)
				return;
			Sequence::prePaint();

			gDevice->SetRenderState(D3DRS_POINTSPRITEENABLE, true);
			gDevice->SetFVF(Vertex::FVF);
			gDevice->SetStreamSource(0, vb, 0, sizeof(Vertex));
			Vertex* vertexData;
			VERIFY(vb->Lock(0, sizeof(Vertex)*size,
				(void**)&vertexData, D3DLOCK_DISCARD));
			memcpy(vertexData, vertex, sizeof(Vertex)*size);
			VERIFY(vb->Unlock());
			VERIFY(gDevice->DrawPrimitive(D3DPT_POINTLIST, 0, size));

			Sequence::postPaint();
		}

		void deviceLost()
		{
			SAFE_RELEASE(vb);
		}

		void deviceReset()
		{
			VERIFY(gDevice->CreateVertexBuffer(
				sizeof(vertex),
				D3DUSAGE_DYNAMIC | D3DUSAGE_POINTS | D3DUSAGE_WRITEONLY,
				Vertex::FVF,
				D3DPOOL_DEFAULT,
				&vb,
				0));
		}

	private:
		boost::signals::connection resetConnection, lostConnection;
	};

Sequence

Sequence ist die Klasse, die das "nacheinander aufrufen" der Aktionen übernimmt.
	// Combines update actions to a sequence using this template
	// and call the appropiate member functions if present.
	template<class P, template<class> class U1,
		template<class> class U2, template<class> class U3,
		template<class> class U4, template<class> class U5,
		template<class> class U6, template<class> class U7,
		template<class> class U8, template<class> class U9,
		template<class> class U10, template<class> class U11,
		template<class> class U12, template<class> class U13,
		template<class> class U14, template<class> class U15>
	class Sequence : public U1<P>, public Sequence<P, U2, U3, U4,
			U5, U6, U7, U8, U9, U10, U11, U12, U13, U14, U15, EmptyTemplate>
	{
		typedef Sequence<P, U2, U3, U4, U5, U6, U7, U8, U9, U10,
				U11, U12, U13, U14, U15, EmptyTemplate> Tail;
	public:
		void emitParticles(float elapsed, P* p, typename P::Vertex* v,
				size_t& size, size_t MAX_SIZE)
		{
			Call<Traits<U1<P>,P>::hasEmitParticles, U1<P>, P>::
				emitParticles(*this, elapsed, p, v, size, MAX_SIZE);
			Call<Traits<Tail,P>::hasEmitParticles, Tail, P>::
				emitParticles(*this, elapsed, p, v, size, MAX_SIZE);
		}

		// returns whether the particle should be killed.
		bool updateParticle(float elapsed, P& p, typename P::Vertex& v)
		{
			return Call<Traits<U1<P>,P>::hasBoolUpdateParticle, U1<P>, P>::
					boolUpdateParticle(*this, elapsed, p, v)
				|| (Call<Traits<U1<P>,P>::hasUpdateParticle, U1<P>, P>::
					updateParticle(*this, elapsed, p, v), false)
				|| Call<Traits<Tail,P>::hasBoolUpdateParticle, Tail, P>::
					boolUpdateParticle(*this, elapsed, p, v)
				|| (Call<Traits<Tail,P>::hasUpdateParticle, Tail, P>::
					updateParticle(*this, elapsed, p, v), false);
		}

		void prePaint()
		{
			Call<Traits<U1<P>,P>::hasPrePaint, U1<P>, P>::prePaint(*this);
			Call<Traits<Tail,P>::hasPrePaint, Tail, P>::prePaint(*this);
		}

		void postPaint()
		{
			Call<Traits<U1<P>,P>::hasPostPaint, U1<P>, P>::postPaint(*this);
			Call<Traits<Tail,P>::hasPostPaint, Tail, P>::postPaint(*this);
		}
	};
	template<class P> struct Sequence<P, EmptyTemplate, EmptyTemplate,
			EmptyTemplate, EmptyTemplate, EmptyTemplate, EmptyTemplate,
			EmptyTemplate, EmptyTemplate, EmptyTemplate, EmptyTemplate,
			EmptyTemplate, EmptyTemplate, EmptyTemplate, EmptyTemplate,
			EmptyTemplate> {};
Wie man sieht, erbt Sequence von der ersten Aktion und sich selbst, nur mit einem Parameter weniger. Das "Ende" der Liste wird durch Template-Spezialisierung markiert.

Ansonsten besteht Sequence aus 4 Funktionen, die selber an 5 verschiedene Funktionen weitergeleitet werden.

Bedingter Funktionsaufruf

Die Weiterleitung selber wird dabei durch einen bedingten Funktionsaufruf realisiert. Das template "Call" testet über SFINAE ob die Zielfunktion deklariert wurde und ruft diese nur auf, wenn sie existiert.

Meine Implementation von SFINAE sieht wie folgt aus:

	// The following stuff is used for SFINAE - detection of the member functions.
	template<class T, void (T::*)()>
		struct EmptyFunction {};
	template<class T, class P, void (T::*)(float, P&, typename P::Vertex&)>
		struct EmptyUpdateFunction {};
	template<class T, class P, bool (T::*)(float, P&, typename P::Vertex&)>
		struct EmptyBoolUpdateFunction {};
	template<class T, class P,
			void (T::*)(float, P*, typename P::Vertex*, size_t&, size_t)>
		struct EmptyEmitFunction {};

	template<class T, class P> float checkForFunction(...);
	template<class T, class P> char checkForFunction(
		EmptyFunction<T, &T::prePaint>*);
	template<class T, class P> char checkForFunction(
		EmptyFunction<T, &T::postPaint>*, int);
	template<class T, class P> char checkForFunction(
		EmptyUpdateFunction<T, P, &T::updateParticle>*, int, int);
	template<class T, class P> char checkForFunction(
		EmptyBoolUpdateFunction<T, P, &T::updateParticle>*, int, int, int);
	template<class T, class P> char checkForFunction(
		EmptyEmitFunction<T, P, &T::emitParticles>*, int, int, int, int);

	template<class T, class P> struct Traits {
		const static bool hasPrePaint =
			sizeof(checkForFunction<T,P>(0)) == 1;
		const static bool hasPostPaint =
			sizeof(checkForFunction<T,P>(0,0)) == 1;
		const static bool hasUpdateParticle =
			sizeof(checkForFunction<T,P>(0,0,0)) == 1;
		const static bool hasBoolUpdateParticle =
			sizeof(checkForFunction<T,P>(0,0,0,0)) == 1;
		const static bool hasEmitParticles =
			sizeof(checkForFunction<T,P>(0,0,0,0,0)) == 1;
	};

	template<bool, class T, class P> struct Call 
	{
		static void prePaint(T& t) {t.prePaint();}
		static void postPaint(T& t) {t.postPaint();}
		static void updateParticle(
				T& t, float elapsed, P& p, typename P::Vertex& v)
		{t.updateParticle(elapsed, p, v);}
		static bool boolUpdateParticle(
				T& t, float elapsed, P& p, typename P::Vertex& v)
		{return t.updateParticle(elapsed, p, v);}
		static void emitParticles(
				T& t, float elapsed, P* p, typename P::Vertex* v,
				size_t& size, size_t MAX_SIZE)
		{t.emitParticles(elapsed, p, v, size, MAX_SIZE);}
	};
	template<class T, class P> struct Call<false,T, P>
	{
		static void prePaint(T&) {}
		static void postPaint(T&) {}
		static void updateParticle(T&, float, P&, typename P::Vertex&) {}
		static bool boolUpdateParticle(
				T&, float, P&, typename P::Vertex&) {return false;}
		static void emitParticles(
				T&, float, P*, typename P::Vertex*, size_t&, size_t) {}
	};

Aktionen

Schließlich fehlt noch die Implementation der einzelnen Aktionen. Wie in Sequence zu sehen war, sind dies Templates, die einen Parameter (das Particle-struct) übergeben bekommen.

Ein Hauptaugenmerk ist, dass diese Klassen so einfach wie möglich zu benutzen und zu verstehen sind. Das sind die Klassen, die ein Nutzer des Frameworks hauptsächlich kennen und verstehen muss. Außerdem ist dies der Punkt, wo die Engine über Benutzerdefinierte Routinen erweitert wird.

Dem Benutzer von Aktionen stehen fünf Funktionen zur Verfügung, die er implementieren kann - aber nicht muss. Stellt er eine Implementation wird diese zu bestimmten Zeitpunkten während der Abarbeitung der Engine aufgerufen.

Damit stellt das Interface eine typische Beschreibung einer abstrakten Basisklasse mit nur rein-virtuellen Funktionen zur Verfügung. Mit einer Ausnahme: Die Funktion "updateParticle" kann entweder ein "bool" zurück liefern oder ein "void". Diese Überladung ist mit abstrakten Basisklassen nicht möglich.

// Pseudocode. Kein Bestandteil von Nightwatch (nicht mal legales C++)
template<class P> // Partikel-Struktur
struct MoeglicheBasisKlasseVonAktionen
{
	// wird am Anfang des Renderns (vor locken des VertexBuffer) aufgerufen
	void prePaint();
	// wird nach Ende des Renderns aufgerufen (clean-up)
	void postPaint();
	// wird zu jedem Tick aufgerufen um das Partikel zu aktualisieren
	void updateParticle(float elapsed, P& p, typename P::Vertex& v);
	// wie oben, nur wenn true geliefert wird, wird das Partikel gelöscht
	bool updateParticle(float elapsed, P& p, typename P::Vertex& v);
	// wird aufgerufen um Partikel zu erzeugen.
	void emitParticles(float elapsed, P* p, typename P::Vertex* v,
			size_t& size, size_t MAX_SIZE);
};
Der Code dient lediglich zur Demonstration der aktuellen Möglichkeiten einzelner Aktionen. Hier ein Beispiel einiger Aktionen:
	/*
	Linear movement with a particle-specific velocity.
	The particle struct must have a member called "velocity".

	Requires: Vector velocity
	*/
	template<class P>
	struct LinearMovement
	{
		void updateParticle(float elapsed, P& p, typename P::Vertex& v)
		{
			v.x += p.velocity.x()*elapsed;
			v.y += p.velocity.y()*elapsed;
		}
	};

	/*
	Acceleration towards a fixed constant vector.

	Requires: Vector velocity
	*/
	template<class P>
	struct Gravity
	{
		Vector gravity;
		Gravity() : gravity(0,80) {}
		void updateParticle(float elapsed, P& p, typename P::Vertex& v)
		{
			p.velocity = p.velocity + elapsed * gravity;
		}
	};

	/*
	Decrease the lifetime and kills the particle, if it reaches zero life.

	Requires: float life;
	*/
	template<class P>
	struct LifeDecrease
	{
		bool updateParticle(float elapsed, P& p, typename P::Vertex& v)
		{
			p.life -= elapsed;
			return p.life < 0;
		}
	};
Also ich bin zufrieden mit der Einfachheit, in der Aktionen definiert werden können.

Abschließend noch ein Beispiel aus "CursorSparkle" für den animierten Cursor. Die Aktion bewirkt, dass Partikel nur bewegt werden, wenn sie sich nicht unter dem Cursor befinden.

template<class P>
struct IfNotUnderMouse : Particle::LinearMovement<P>
{
	void updateParticle(float elapsed, P& p, typename P::Vertex& v)
	{
		if ((Gui::state.mouse - Vector(v.x-25, v.y-25)).length2() > 10)
			Particle::LinearMovement<P>::updateParticle(elapsed, p, v);
	}
};
Zwei Bemerkungen noch am Ende:

Boost und __if_exists

Erstens habe ich erst später von der Bibliothek "boost::enable_if" und der Microsoft-Erweiterung "__if_exists" erfahren. Beides würde die Konstruktion der Engine sehr vereinfachen. "boost::concepts" würde zudem die Fehlermeldungen verbessern. Ich überlege, meine Implementation entsprechend anzupassen. Die hier vorgestellte Lösung basiert noch auf reinem Standard-C++ und benötigt keine weiteren Bibliotheken.

Overdesign

Und zweitens benutzt das Design sehr komplexe Techniken der Templateprogrammierung. Die Fehlermeldungen sind extrem schwer zu interpretieren und der Code für Ungeübte in der Meta-Programmierung sehr schwer zu warten. Ich würde Code dieser Komplexität nur einsetzen, wenn in meinem Projektteam noch mindestens ein weiterer ist, der das komplette Framework vollständig versteht. ;-)

Einige Ansatzpunkte zur strukturellen Vereinfachung des Frameworks ohne Performance einzubüßen:

  1. updateParticle liefert immer ein bool. Nachteil: Die meisten Aktionen müssen nun eben ein "return false;" am Ende einfügen.
  2. Eine Basisklasse für alle Aktionen, die alle möglichen Funktionen leer und nicht-virtuell implementiert wird bereitstellt. Diese Klasse kann als Basisklasse für Aktionen dienen. Das Template "Call" und aller SFINAE-Code wird entfernt. Nachteil: Anwender des Framework müssen nicht-virtuelle Funktionen überschreiben, was gegen gängige Programmierempfehlungen verstößt. Alternative müssen alle Aktionen alle Funktionen (notfalls leer) implementieren.

Resumé

Mittlerweile weiß ich: Mein Framework ist keine Alternative zu bestehenden Particle Engines. Gerade für Partikelsysteme bietet sich ein Data-Driven-Design weit aus besser an. Es ist um mehrere Größenordnungen wichtiger, Daten richtig zu sortieren, effizient zu cullen alle cache-misses zu vermeiden und wenn möglich den Datenverkehr zwischen CPU und GPU zu schonen.

Und ein Template-basiertes System ist durch Grafiker praktisch unbenutzbar (oder erschwert zumindest grafische Editoren enorm) und verlängert Kompilierungszeiten (was ohnehin schon ein Problem für größere Spiele ist).

Sein Ziel hat es aber erreicht: Ich fühle mich wieder fit in Sachen Template- und Meta-Programmierung. ;-)