OMSI Plugin Framework II

Aus OMSIWiki
Version vom 19. November 2012, 08:57 Uhr von Holmexx (Diskussion | Beiträge) (Schriftgöße und -farben angepasst (muss fortgesetzt werden))
Wechseln zu:Navigation, Suche

Die graue Theorie

Keine Angst, Du musst Dich jetzt nicht durch seitenlange, staubtrockene Texte wühlen, aber um ein bisschen Theorie kommen wir nicht drumherum. Dieses Kapitel soll auch keine umfassende Einführung in die Programmiersprache C/C++ und in die Spieleprogrammierung sein, sondern eher Anfängern und Unerfahrenen ein paar Tipps geben, damit sie wenigstens die größten Fettnäpfchen auslassen können.

Die Regel Nummer 1

Das Plugin, dass Du entwickeln willst, ist ja Bestandteil eines Spieles (nämlich des OMSI) und damit bist Du nun quasi Spieleprogrammierer. Ein Spieleprogrammierer muss sich aber einigen Regeln unterwerfen. Die Regel Nummer 1 heißt: Performance, Performance und noch mal Performance. Ein ungeschickt programmiertes Plugin kann die Framerate des OMSI (die ja schon recht knapp ist) in absolut frostige Tiefen drücken. Das musst Du Dir während der ganzen Programmierung ständig vor Augen halten. Wenn Du z.B. für ein Problem mehrere Lösungswege gefunden hast, solltest Du Dir wirklich die Mühe machen, sie alle nacheinander auszuprobieren. Das kostet viel Zeit, ist aber letztendlich die einzige Möglichkeit, die performanteste Lösung zu finden.

Wie Du in den weiteren Abschnitten noch feststellen wirst, ist es aber abseits der Regel Nummer 1 kaum möglich, eine starre Richtlinie aufzustellen. Du wirst immer einen Kompromiss eingehen müssen. Welches Ergebnis das Beste ist, kannst Du nur durch umfangreiche Tests herausfinden. Und diese Tests solltest Du z.B. von einem Freund durchführen lassen, der von Programmierung gar keine Ahnung hat. Das hat im Wesentlichen zwei Gründe: zum Einen sind Programmierer kurioserweise bei ihren Tests kaum in der Lage, ihre eigenen Fehler zu finden (kein Scherz) und zum Anderen wirst Du verwundert sein auf welche Ideen Benutzer kommen, an die Du bei der Programmierung nicht mal im Traum gedacht hast und die - selbstverständlich - zu Fehlern mit Programmabsturz führen.

Arbeitsspeicher

Eine andere Regel ist, den zur Verfügung stehenden Arbeitspeicher klug einzusetzen. Heutzutage, wo die meisten Computer in aller Regel über mehrere Gigabyte Speicher verfügen, ist das zwar nicht mehr ganz so eng wie früher, wo wirklich noch um jedes einzelne Byte gekämpft werden musste. Aus den Augen verlieren darf man das Thema trotzdem nicht. Das Schlimmste was passieren kann, ist, das einem Prozess der Arbeitsspeicher ausgeht. Dann muss Windows nämlich Speicher frei machen, indem es Bereiche des Arbeitsspeichers in die sogenannte Auslagerungsdatei auslagert. Und das ist die Performancebremse überhaupt. Wenn das, vielleicht auch noch mehrfach, passiert, wird das Spiel zu einer reinen Diashow verkommen. Performance und Arbeitsspeicher sind zwei Dinge, die eng miteinander verzahnt sind. Meistens ist es performanter, temporäre (zeitweilige, nur zu diesem Zweck angelegte) Variablen anzulegen und mit diesen eine Aufgabe zu lösen. Wenn aber das Anlegen der temporären Variablen dazu führt, das ersteinmal neuer Arbeitspeicher bei Windows angefordert werden muss, hat sich der ganze Performancevorteil in Nichts aufgelöst. Das gilt insbesondere für das Anlegen von (besonders) großen Datenstrukturen im sogenannten Stack. Der Stack ist in seiner Größe nämlich begrenzt. Er kann zwar vergrößert werden, aber das ist performancetechnisch so teuer, das es ein einem Spiel praktisch nicht vorkommen sollte. Falls so etwas in Deinem Code passiert, ist schlicht und ergreifend das Codedesign falsch und muss unbedingt überarbeitet werden.

Datenstrukturen, die Du mehrfach benötigst, legst Du natürlich in der Initialisierungphase an (also bei Aufruf der Funktion Start durch den OMSI) und nicht kurz vor jeder Verwendung. Das würde zwar (vielleicht) den Arbeitsspeicherverbrauch des Programmes reduzieren, kostet aber wertvolle CPU-Zeit (siehe Regel Nummer 1).

Fehlerbehandlung

Ein weiteres Thema ist die Fehlerbehandlung. Gerade bei der Fehlerbehandlung ist es wichtig, einen gesunden Kompromiss zwischen Absturzsicherheit des Programmes und Performance zu finden. Selbstverständlich möchte jeder, das es nicht zu Programmabstürzen kommt - schon gar nicht in einem Spiel. Überlegungen dazu sind deshalb so wichtig, weil ein gravierender Fehler in Deinem Plugin den gesamten OMSI ins Byte-Nirvana reißen wird.

Eine Technik zur Fehlerbehandlung die C++ bietet, ist die Möglichkeit, Codeabschnitte in try/except-Klammern einzufassen. Ganz allgemein sieht das so aus:

__try
{
   // hier kommt der Code, der zu einem Fehler führt, z.B.:
   float x = 5;
   float y = 0;
   float z = x / y;  // Super-Idee, diese Division durch 0 ;-)
   // Code, der jetzt noch hier kommt, wird nie mehr ausgeführt, da die 
   // vorherige Zeile zu einer Division-durch-0-Exception führt
}
__except ( EXCEPTION_HANDLER )
{
   // mache irgendetwas, um den Fehler wieder gerade zu biegen
}
// hier gehts ganz normal weiter, als ob nichts passiert wäre

Das hemmungslose Benutzen von try/except und Performance sind allerdings zwei Dinge, die schlecht zusammen passen. Try/except kostet nämlich CPU-Zeit und sollte nur an klug überlegten Stellen eingesetzt werden. Wenn Du in Deinem Code eine Funktion aufrufst die Du nicht selbst geschrieben hast und wo die Dokumentation schon sagt, das im Fehlerfall die oder die Exception auftreten kann, musst Du natürlich try/except verwenden. In selbst geschriebenem Code solltest Du Exceptions vermeiden. Besser ist es, wenn die Funktion im Fehlerfall einen Fehlercode zurückliefert. Dazu zwei Beispiele:

int NichtSoGuteFunktion(int irgend_ein_wichtiger_wert)
{
   if (irgend_ein_wichtiger_wert != wert_den_ich_hier_erwarte)
   {
       throw new exception_xy ( ... );  // sicher, aber teuer
   }

   // weitere Verarbeitung
   // ...

   return ergebnis;
}

Ein besserer Ansatz könnte so aussehen:

enum MeineFehlerwerte
{
   Bescheuerter_Fehler      = -1,
   Zu_wenig_Kaffee_Fehler   = -2,
   Zu_viel_Pizza_Fehler     = -3,
   // usw.
};

int BessereFunktion(int irgend_ein_wichtiger_wert, int* zeiger_auf_variable_die_ergebnis_speichert)
{
   if (irgend_ein_wichtiger_wert != wert_den_ich_hier_erwarte)
   {
       return Bescheuerter_Fehler;  // genau so sicher, aber VIEL billiger
   }

   // weitere Verarbeitung
   // ...

   *zeiger_auf_variable_die_ergebnis_speichert = ergebnis;
   return ERROR_SUCCESS; // ERROR_SUCCESS ist vordefiniert und hat den Wert 0
}

Eine Andere, ganz große Kasperfalle ist die Verwendung von nicht initialisierten Variablen, allen voran, die Zeigervariablen. Diese Fehler sind später außerdem relativ schwierig zu entdecken. Im Gegensatz zu z.B. Visual Basic werden bei C/C++ Variablen bei ihrer Deklaration nicht mit einem Wert vorbelegt. Deshalb ist es ganz wichtig, alle Variablen vor ihrer ersten Verwendung mit einem bestimmten Wert zu initialisieren.

 int variable;           // Variable hat einen zufälligen Inhalt, ist aber meistens weniger gefährlich
 char* zeiger_variable;  // Variable hat einen zufälligen Inhalt, Verwendung führt totsicher zum Programmabsturz

 // irgendwo später im Code:
 if (zeiger_variable != NULL) { ... }  // Absturz ist sicher, da ohne Initialisierung zeiger_variable niemals NULL ist !!!

 // deshalb immer so:
 int variable = 0;
 char* zeiger_variable = NULL;

 // irgendwo später im Code:
 if (zeiger_variable != NULL) { ... }  // Alles o.k., da durch Initialisierung zeiger_variable NULL sein kann

Das teuflische an der Sache ist, das die in den Kommentaren als totsicher und niemals deklarierten Dinge gar nicht so totsicher sind. Natürlich kann es sein, das der Speicher, der der Variablen durch den Compiler zugewiesen wird, rein zufällig tatsächlich NULL ist. Und laut Murphy's Law ist das bei den ersten drei Programmtests auch so. Beim vierten Test aber plötzlich nicht mehr und das Programm stürzt ab. Und dann geht die verzweifelte Fehlersuche los ...

Objektorientierte Programmierung vs. prozedurale Programmierung

So manch Hardcore-Spieleprogrammierer wird die Nase rümpfen und denken, objektorientierte Programmierung (wird im OMSI Plugin Framework verwendet), das geht gar nicht, das macht man doch nicht. Ich muss leider zugeben, so ganz unrecht haben sie nicht. Objektorientierte Programmierung erzeugt nämlich einen gewissen Overhead, der CPU-Zeit kostet. Und das ist ja bekanntlich die teuerste Resource, die uns zur Verfügung steht. Aber wie Du eingangs ja schon festgstellt hast, must Du auch hier einen Kompromiss eingehen. Auf der einen Seite steht der Overhead durch die Verwendung von Klassen, auf der anderen Seite steht ein einfacherer, übersichtlicherer Code, der besonders Programmieranfängern den Einstieg erleichtern wird. Wenn Du schon über ausreichend Programmiererfahrung verfügst, kannst Du ja mal versuchen, das Framework in eine rein prozedurale Variante zu überführen. Du wirst feststellen, dass das gar nicht so einfach ist und möglicherweise Anfänger ziemlich überfordert.

Der Overhead bei der Verwendung von Klassen entsteht dadurch, dass jedem Methodenaufruf ein unsichtbarer Parameter (der sogenannte this-Zeiger) mitgegeben wird. In Pseudo-Assembler sieht das etwa so aus:

push  parameter_1
push  parameter_2
; usw.
mov   ecx, this  ; <-- der zusätzliche Assembler-Befehl
call  method_xy
; ...

Das Gleiche gilt für die Verwendung der Felder einer Klasse. Auch der Zugriff erfolgt über den this-Zeiger. Ein etwas größerer Overhead entsteht noch, wenn die Klasse virtuelle Methoden verwendet. Dann muss nämlich noch eine Tabelle für die virtuellen Methoden angelegt werden und der Aufruf der Methoden erfolgt dann indirekt über diese Tabelle. Alles zusammen genommen denke ich aber, das der Overhead für unsere modernen Prozessoren gering ist und die Vorteile der objektorientierten Programmierung überwiegen.


[zum Kapitel 1] [zum Inhaltsverzeichnis] [zum Kapitel 3]