Portfolio von
Immanuel Scholz |
|
Inhaltsverzeichnis
Template Particle EngineParticleEngine Konfiguration der Parameter Das Particle - struct Der Sourcecode der Engine Sequence Bedingter Funktionsaufruf Aktionen Boost und __if_exists Overdesign Resumé Template Particle EngineParticle 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.ParticleEngineDas 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 ParameterKonfiguration 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 - structDer 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:
Der Sourcecode der EngineLeider 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; }; SequenceSequence 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 FunktionsaufrufDie 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) {} }; AktionenSchließ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_existsErstens 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.OverdesignUnd 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:
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. ;-) |
|