Eine kurze Einführung in die Entwicklung mit der Murl Engine
Die Murl Engine ist ein natives, plattformunabhängiges, zeitgesteuertes Scene Graph Multimedia Framework für die Entwicklung von Spielen und Multimedia-Applikationen. Das Framework wurde für höchste Performance und Flexibilität entworfen und erlaubt die Cross-Plattform-Entwicklung von nativen Applikationen mit einer einzigen Code Basis.
Native Cross Plattform
Das Framework besteht aus einem plattformunabhängigen Kernstück ("Framework Code") und einer plattformspezifischen Anbindung ("Plattform Code") für jede Plattform. Der Framework Code wurde in C++ entwickelt, der Plattform Code plattformabhängig in C, C++, Objective C und Java.
Die Programmierung ("User Code") erfolgt in C++ entsprechend der zu Verfügung stehenden API (Programmierschnittstelle, engl. Application Programming Interface). Für jede Plattform kann aus User Code, Framework Code und Plattform Code eine native Applikation erzeugt werden.
Die Entwicklung von nativen Anwendungen in C++ erlaubt eine effiziente Programmierung auf hohem Abstraktionsniveau und optimale Performance.
Native bedeutet in diesem Zusammenhang, dass die Applikation direkt auf der Zielhardware und dem Zielbetriebssystem ausführbar ist. Es werden keine Plugins, Emulatoren, Laufzeitumgebungen oder ähnliches zwischen Hardware und der Applikation benötigt. Dadurch wird die bestmögliche Performance und Integration erreicht.
Die Applikationsentwicklung kann prinzipiell mit jeder beliebigen C++ IDE erfolgen. Unterstützung bei der Projekterstellung bieten jedoch nur Microsoft Visual Studio (Express) für Windows und Apple XCode für Mac OS X Rechner und sind daher dringend empfohlen.
Um iOS und/oder Mac OS X Applikationen erzeugen zu können, ist ein Rechner mit Mac OS X Betriebssystem und XCode erforderlich. Für Windows Applikationen wird ein Rechner mit einem Windows Betriebssystem und Visual Studio benötigt. Android Applikationen können sowohl mit Windows Rechnern als auch mit Mac OS X Rechnern entwickelt werden.
Zeitgesteuert
Die Programmierung des Frameworks erfolgt zeitgesteuert. Dabei kommen ein Render-Thread und ein Logik-Thread zum Einsatz, die immer parallel in einer Schleife abgearbeitet werden.
Der Logik-Thread ruft bei jedem Durchlauf der Schleife eine User-Code Methode (z.B. OnProcessTick()
) auf. In dieser Methode kann die Applikation upgedatet werden. Typischerweise wird abgefragt, wie viel Zeit seit dem letzten Aufruf vergangen ist (z.B. GetCurrentTickDuration()
) und welche Benutzereingaben an den Input-Geräten anliegen. Mit diesen Informationen wird dann die Anwendung "simuliert" und die Objekte entsprechend upgedatet.
Nach ein- oder mehrmaligem Abfragen dieser Informationen startet der Render-Thread, alle sichtbaren Objekte zu zeichnen, während der Logik-Thread bereits mit der Evaluierung des nachfolgenden Schrittes beginnt.
Die Schrittweite (also die Zeit zwischen zwei Durchläufen) für den Render-Thread und für den Logik-Thread kann über ein Konfigurationsobjekt im Programm eingestellt werden. Dabei ist sowohl eine fixe als auch eine variable Schrittweite möglich. Des Weiteren kann auch ein minimaler und maximaler Zeitwert festgelegt werden. Zusätzlich besteht die Möglichkeit die Anzahl der Schritte, die für jeden Frame durchgeführt werden müssen, zu definieren.
Szenengraph
Die Murl Engine verwendet einen Szenengraph für die Beschreibung der virtuellen Welt. Ein Szenengraph ist eine objektorientierte Datenstruktur und entspricht einem gerichteten azyklischen Graphen. Mit einem Szenengraph können Objekte gehalten, gruppiert und transformiert werden.
Der Szenengraph bildet eine baumähnliche Struktur aus Graphenknoten, welche aus genau einem Wurzelknoten und beliebig vielen Kinderknoten besteht. Jeder Graphenknoten beinhaltet Informationen über die virtuelle Szene, wie beispielsweise Geometrie, Farben, Lichtquellen, Audioquellen oder Transformationen.
Jeder Graphenknoten wirkt auf all seine Kinderknoten. Ein Transform-Knoten kann beispielsweise dafür benutzt werden, die Position und Rotation des darunterliegenden Szenengraphen zu ändern. Das bedeutet, dass alle Kinderknoten und Kindeskinderknoten von solch einer Änderung betroffen sind. Generische Knoten können zum Beispiel unsichtbar gemacht werden, wovon ebenso alle Kinderknoten betroffen sind.
Eine Ausnahme bilden State-Knoten wie z.B. MaterialState
, ParametersState
, CameraState
etc. State-Knoten bewirken eine Kontextänderung und wirken daher nicht nur auf die Kinderknoten, sondern auch auf alle nachfolgenden Knoten. Wird zum Beispiel mit dem Knoten MaterialState
ein Material ausgewählt, werden alle nachfolgenden Knoten mit diesem Material gezeichnet. Dies gilt solange, bis ein neues Material ausgewählt wird.
Mit dem Graphenknoten SubState
können State-Änderungen lokal begrenzt werden. Das heißt, dass State-Änderungen von Kinderknoten eines SubState
Knoten, keine Auswirkung auf den Kontext nachfolgender Knoten haben. Der SubState
Knoten setzt also nach Abarbeitung seiner Kinderknoten den Kontext wieder auf den ursprünglichen Zustand zurück.
Alle Graphenknoten haben einige grundlegende Eigenschaften gemeinsam:
- eindeutige
id
Name active
Eigenschaftvisible
Eigenschaft
Mit dem id
Namen kann ein Knoten eindeutig identifiziert werden. Mit der visible
Eigenschaft kann ein Knoten sichtbar bzw. nicht sichtbar geschaltet werden. Der Render-Thread überspringt dann diesen Knoten inkl. aller Kinderknoten (Ausgabe-Traversierung wird übersprungen). Mit der active
Eigenschaft kann ein Knoten für alle Logikoperationen aktiviert bzw. deaktiviert werden. Der Logik-Thread überspringt dann diesen Knoten inkl. aller Kinderknoten (Logik-Traversierung wird übersprungen). Um einen Knoten vollständig zu deaktivieren, muss also active
und visible
auf false gesetzt werden. Das kann einzeln erfolgen oder optional gemeinsam mit der activeAndVisible
Eigenschaft.
- Zu beachten
- Achtung, id Namen müssen aus alphanumerischen Zeichen sowie den Zeichen Punkt und Underscore zusammengesetzt werden und dürfen nicht mit einer Ziffer beginnen. Die Verwendung von anderen Zeichen (z.B. -, +, :, etc.) ist nicht erlaubt.
Bei jedem Durchlauf wird der Szenengraph ausgehend vom Wurzelknoten nach dem Verfahren der Tiefensuche (englisch depth-first search, DFS) traversiert. Alle Kinderknoten werden rekursiv in der Reihenfolge in der sie definiert wurden abgearbeitet. Für obigen Beispielgraphen ergibt sich folgende Traversierungsreihenfolge:
- Root
- View
- Camera
- FixedProgram
- Material
- MaterialState
- Transform
- CubeGeometry
- PlaneGeometry
- CameraTransform
Genaugenommen finden bei jedem Durchlauf zwei unterschiedliche Traversierungen statt - die Logik-Traversierung und die Ausgabe-Traversierung:
- Eine Logik-Traversierung findet jedes Mal statt, wenn die Methoden
OnProcessTick()
im User Code abgeschlossen sind. Für alle Knoten deren Eigenschaften im User Code geändert wurden, wird der interne Status upgedatet. Wenn für spezielle Knoten eine Physik Simulation notwendig sein sollte, wird auch diese durchgeführt. - Wenn alle Logik-Traversierungen für ein Bild (Frame) abgeschlossen sind, wird einmal eine Ausgabe-Traversierung durchgeführt. Dabei werden alle relevanten Informationen für das Rendering aufbereitet.
XML Dateien
Häufig wird der Szenengraph bzw. werden Szenenteilgraphen bei der Murl Engine in einem oder mehreren Textdokumenten in XML Notation erstellt. Diese XML Files werden dann bei Bedarf von der Applikation in den Speicher geladen und die einzelnen Knoten je nach Anforderung modifiziert. Nachfolgend wird der Aufbau eines XML Dokuments überblicksmäßig beschrieben.
<?xml version="1.0" ?> <Graph> <View id="view" /> <Camera id="camera" viewId="view" fieldOfViewX="400" nearPlane="400" farPlane="2500" clearColorBuffer="1" > <!-- comment --> <FixedProgram id="prg_white" /> <Material id="mat_white" programId="prg_white" /> <MaterialState materialId="mat_white" slot="0" /> <Transform id="transform"> <CubeGeometry id="myCube01" scaleFactor="200" posX="0" posY="0" posZ="0" /> <PlaneGeometry id="myPlane01" scaleFactorX="42" scaleFactorY="100" posX="0" posY="0" posZ="0" /> </Transform> <CameraTransform cameraId="camera" posX="0" posY="0" posZ="800" /> </Camera> </Graph>
XML Dokumente beginnen üblicherweise mit der optionalen XML-Deklaration z.B. <?xml version="1.0" ?>
Elemente innerhalb eines XML Dokuments werden durch < >
gekennzeichnet. Jedes Element hat ein Start-Tag <element>
und ein End-Tag </element>
. Kinderelemente werden innerhalb des Start- und End-Tags definiert. Für Elemente ohne Kinder kann optional das Empty-Element-Tag <element/>
verwendet werden. Die Angabe von <element></element>
ist gleichbedeutend mit der Angabe von <element/>
.
Attribute für ein Element werden innerhalb des Start-Tags oder Empty-Element-Tags als Paar mit Attribut-Name und Attribut-Wert angegeben: attributeName="attributeValue"
Kommentare innerhalb einer XML Datei beginnen mit <!--
und enden mit -->
.
- Zu beachten
- Achtung, einzelne Attribute können nicht auskommentiert werden.
Für die Erstellung eines XML Dokuments müssen ein paar grundsätzliche Regeln befolgt werden:
- In einem XML Dokument muss genau ein Wurzelelement existieren, welches alle anderen Elemente umschließt.
- Für jedes Start-Tag muss genau ein End-Tag in der gleichen Verschachtelungsebene existieren und umgekehrt (ebenentreu paarig verschachtelt).
- Alle Attribut-Namen für ein Element müssen eindeutig sein (keine zwei Attribute mit demselben Namen innerhalb eines Elements).
Namenskonventionen
KLASSEN, NAMESPACES, FUNKTIONEN, METHODEN
Klassen-, Namespace-, Methoden- und Funktionsnamen beginnen mit einem Großbuchstaben
z.B. MyApp
bzw. LoadData()
INTERFACES
Interface-Klassen beginnen mit I
z.B. IAppConfiguration
VARIABLEN
Variablen beginnen mit einem Kleinbuchstaben
z.B. rotationAngleX
MEMBER VARIABLEN
Membervariablen beginnen mit m
z.B. mVar
DATEINAMEN
Dateinamen setzen sich aus dem Namespace und dem Klassennamen zusammen, bestehen ausschließlich aus Kleinbuchstaben und verwenden "_" als Trennzeichen
z.B. murl_my_app.cpp
/ murl_my_app.h
für die Klasse MyApp
im Namespace Murl
Primitive Datentypen
Die C++ Standard-Datentypen werden auf unterschiedlichen Systemen unterschiedlich repräsentiert und haben daher unterschiedliche Wertebereiche. Daher sollten immer die im Framework definierten plattformunabhängigen Datentypen verwendet werden (Großschreibung beachten z.B. Char
statt char
). Die Datentypen sind in der Datei murl_types.h
deklariert:
Passende Containerklassen aus murl_types.h
:
Mathematische Konstanten sind in der Datei murl_math_types.h
definiert:
Mathematische Funktionen
befinden sich im Namespace Murl::Math
:
Weitere Hilfsfunktionen
sind im Namespace Murl::Util
zu finden - z.B.:
Strings
Das Framework stellt auch eine eigene String Klasse
mit zahlreichen Konvertierungs-
und nützlichen Hilfsfunktionen
zur Verfügung.
Debug Messages
Der Header murl_debug_trace.h
beinhaltet Methoden zur Ausgabe von Debug Nachrichten
.
DEBUG::TRACE, DEBUG::ERROR
Eine einfache Statusmeldung in der Konsole kann mit dem Befehl Debug::Trace
ausgegeben werden. Bei manchen Plattformen werden zusätzlich Zeitangaben ausgegeben. Dieser Befehl kann wie der Befehl printf
mit optionalen Formatparametern verwendet werden.
Debug::Trace("Hello World!"); Debug::Trace("The result is %u:", result);
Die Ausgabe von Debug::Trace
Meldungen erfolgt nur im Debug-Modus und wird bei einem Release-Build automatisch "wegoptimiert". Wenn eine Meldung auch in einem Release-Build aufscheinen soll, kann dafür Debug::Error
verwendet werden.
MURL_TRACE
Der Befehl MURL_TRACE
gibt zusätzlich zur Statusmeldung auch noch den Methodenname und die Zeilennummer der Nachricht aus. Es können auch optionale Formatparameter verwendet werden.
MURL_TRACE(0, "Debug Hello World");
Der erste Parameter (0) gibt den "Log-Level" für diese Meldung an. Debug Nachrichten deren Log-Level über den globalen Log-Level liegen werden unterdrückt. Ein gesetzter globaler Log-Level von 1 zeigt also nur Debug Nachrichten mit einem Log-Level kleiner gleich 1 an. Der globale Log-Level kann übrigens mit SetLogLevel
gesetzt und mit GetLogLevel
abgefragt werden. Die Statusmeldung sieht dann ungefähr so aus:
Die Ausgabe von MURL_TRACE
Meldungen erfolgt ebenfalls nur im Debug-Modus.
SYSTEM::CONSOLE::PRINT
Eine weiter Möglichkeit für Konsolen Statusmeldungen bietet die Funktion Murl::System::Console::Print
. Diese Meldungen erscheinen auch im Release-Build. Die Ausgabe erfolgt "exakt wie angegeben" d.h. es werden keine Extrainformationen wie Zeitstempel, Zeilenumbrüche etc. hinzugefügt.
Dabei können wiederum optionale Formatparameter verwendet werden.
System::Console::Print("Print with System Console"); System::Console::Print("Current Time %.10f", state->GetCurrentTickTime());
PRINTTREE
Mit dem Befehl PrintTree
kann der aktuelle Szenengraph als Statusmeldung in der Konsole ausgegeben werden.
Graph::IRoot* root = state->GetGraphRoot(); root->PrintTree();
Je nach gegebenen Startknoten kann der gesamte Szenengraph oder auch nur ein Teilgraph ausgegeben werden.
Graph::INode* node = mBallTransform->GetNodeInterface(); node->PrintTree();
SETUSERDEBUGMESSAGE
Wenn das Debug-Paket geladen wurde, kann mit der Methode SetUserDebugMessage
vom Interface Murl::Logic::IState
eine einfache Statusmeldung am Bildschirm angezeigt werden.
state->SetUserDebugMessage("Package Loading succeeded!");