GESCHRIEBEN VON:
Spieleprogrammierung in java
Vorwort
“Warum Spieleprogrammierung?” Diese Frage ist einfach zu beantworten, wenn man selbst gerne
spielt und vielleicht auch schon ab und zu davon geträumt hat ein “eigenes” Spiel zu entwickeln: es
macht einfach großen Spaß! Aber wenn man sich etwas intensiver mit dem Thema auseinandersetzt,
dann wird man recht bald feststellen, daß sich zum Spaß auch schnell die Arbeit gesellt, denn Spiele
sind, auch wenn sie auf den ersten Blick oft einfach erscheinen, alles andere als einfach!
Genaugenommen sind Spiele kleine Simulationen einer realen Welt und verhalten sich als solche
nach einem klar definierten und abgegrenzten Logik- und Regelwerk.
Die Spieleprogrammierung ist daher fachlich betrachtet nicht uninteressant! Sie vereint in sich nicht
nur verschiedene Disziplinen wie Audio, Grafik, Animation und Logik sondern stellt den
Programmierer auch im Bereich Performance und Timing vor einige Herausforderungen. Soll ein
Spiel dann auch noch netzwerkfähig und über das Internet in Echtzeit spielbar sein, muß man sich
mit dem Thema Synchronisation außeinandersetzen und dabei natürlich auf die Unzuverlässigkeit
des Internets Rücksicht nehmen. Dieses ist nämlich, aufgrund ständiger unvorhersehbarer
Verzögerungen im Datentransfer, für eine flüssige Synchronisation nicht gerade optimal!
2. Grundlagen der Spieleprogrammierung in Java
“Kann man überhaupt Spiele in Java programmieren?” Das ist die wichtigste Frage, die man klären
muß, bevor man mit der Entwicklung eines Spiels in Java beginnt! Die Antwort dazu ist
“grundsätzlich ja, aber natürlich nicht alles”.
Java ist mittlerweile technisch weit fortgeschritten und obwohl es in manchen Bereichen noch etwas
hinter der Performance von C/C++ oder anderen Sprachen zurückliegt, wird es doch langsam mehr
und mehr eine ernste Alternative für die Spieleentwicklung. Durch zahlreiche interne und externe
Bibliotheken und APIs kann man auch unter Java mittlerweile auf Hardwarebeschleunigung durch
OpenGL oder DirectX zurückgreifen und so performance-lastige Funktionen auf die
darunterliegende Hardware auslagern. Es gibt außerdem Bibliotheken, die ein direktes Ansteuern
der Eingabegeräte über DirectInput unterstützen und so ein Event-System-unabhängiges Abfragen
von Eingabegeräten (wie z.B. Joysticks oder Gamepads) ermöglichen.
Es gibt zahlreiche Studien, die bewiesen haben, daß Java in der Ausführung von Programmcode im
Vergleich zu C/C++ langsamer ist. Bei manchen Funktion benötigt Java etwa 130-150% der Zeit
um die gleichen Rechenoperationen durchzuführen, während es bei anderen sogar schneller ist und
nur etwa 90% der Rechenzeit benötigt. Im Durchschnitt kann man dann mit etwa 20-30% mehr
Rechenzeit im Vergleich zu C/C++ rechnen. Diese Zahlen sind jedoch stark abhängig von der
verwendeten JVM Version und wurden mit jeder neuen Version von Java immer etwas besser.
Wenn man diese Entwicklung sieht und auch noch im Hinterkopf behält, daß bisher nur etwa 50%
der Optimierungsmöglichkeiten in den JVMs genutzt wurden, kann man davon ausgehen, daß Java
auch in Zukunft noch stark an Performance gewinnen wird.
Aber auch wenn Java um vielleicht 30% langsamer als C++ ist, reicht die Leistung immer noch aus,
um interessante Spiele zu erstellen, die auch aktuellen Erwartungen an die Qualität von Grafik und
Sound gerecht werden können. Natürlich können keine Highend-Spiele erstellt werden, die durch
hardwarenahe Programmierung und hardwarespezifische Optimierung jedes bischen Performance
nutzen. Aber dafür ist Java auch nicht gedacht! Der Vorteil bei der Verwendung von Java ist ein
hohes Abstraktionsniveau durch Objektorientierung und die Verwendung funktionsreicher und
“intelligenter” APIs, um so eine erhöhte Produktivität und Wiederverwendbarkeit zu erziehlen.2.1. Java 2D
Java bietet mit Java2D ein mächtiges und flexibles Framework für die Darstellung von geräte- und
auflösungsunabhängiger 2D Grafik. Es ist eine Erweiterung des Abstract Windowing Toolkits
(AWT) und stellt eine Sammlung von Klassen, für die Arbeit mit geometrischen Formen, Text und
Rasterbildern, zur Verfügung. Als Bestandteil der Java Foundation Classes (JFC) ist Java2D
automatisch in jeder aktuellen JVM vorhanden.
Zusammen mit Java Swing bietet Java2D alle Grundlagen für die Darstellung und Verarbeitung von
Grafik. Eine Einführung zu Java Swing und Java2D befindet sich im Anhang.
Als generelle, vielseitige und mächtige API zur Darstellung von Grafik und GUI sind Java Swing
und Java2D natürlich nicht auf Spiele optimiert und deshalb weniger performant als andere
spezialisierte Bibliotheken. Seit der Java Version 1.4 sind jedoch zahlreiche Grafikfunktionen von
Java Swing und Java2D hardwarebeschleunigt und somit gut für die Spieleprogrammierung
einsetzbar. Insbesondere das Zeichnen von Lienen und das Füllen von Rechtecken, sowie das
Zeichnen / Kopieren von Bildern sind optimiert. 1-Bit Transparenz von Bildern (also komplett
durchsichtige Bereiche) ist ebenfalls hardwarebeschleunigt, während teilweise Transparenz rein
softwarebasiert berechnet wird.
In Java Version 1.5 soll die Hardwarebeschleunigung noch weiter ausgebaut werden. Da es aber zu
Beginn unserer Arbeit noch keine (stabile) Version von Java 1.5 gab, können wir darüber auch noch
keine Angaben machen. Für unsere Arbeit haben wir Java 1.4.2_04 verwendet und alle Aussagen
sind daher auf dieser Version von Java basiert.
a) Aktives Rendern
Will man eine Anwendung schreiben, die den Bildschirminhalt häufig verändert und neuzeichnet,
dann wird man früher oder später zwangsläufig an die Grenzen des Event-Handling-Systems stoßen.
In AWT / Swing wird nämlich ein Neuzeichnen von Fenstern und Komponenten über die Methode
den Main-Thread des GUI-Toolkits weiter. Dieser ist jedoch zuständig für das Zeichnen (Rendern)
aller GUI Komponenten und somit ziemlich beschäftigt. Wird nun ein repaint() in häufigen
Intervallen (z.B. 25 mal pro Sekunde) durchgeführt, dann führt das zu einer erhöhten Belastung des
Event-Systems und des Main-Threads. Dies erzeugt nicht nur einen erhöhten Verwaltungsaufwand
für Events und so eine verzögerte Verarbeitung von Benutzereingaben wie z.B. Tastatur- und
Mouse-Events, sondern auch eine insgesamt langsamere Reaktion der gesamten GUI.
Um dies zu vermeiden, sollte das Neuzeichnen in einem eigenständigen Thread unter Umgehung
des Swing Repaint-Modells erfolgen. Hierfür wird die entsprechende Methode paintComponent
(Graphics g) überschrieben und eine zusätzliche Methode renderScreen() erstellt, die dann in
regelmäßigen Abständen vom Render-Thread aufgerufen wird. Diese Methode ist nun für das
Rendern der Komponente zuständig und verwendet hierfür das Graphics-Objekt der Komponente.Der entsprechende Quellcode dazu könnte in ungefähr so aussehen:
import java.awt.*;
import javax.swing.JComponent;
public class MyComponent extends JComponent
{
private Image myImage;
// more code to write here .
public void paintComponent(Graphics g)
{
// do nothing here .
}
public void renderScreen()
{
// get Graphics object from component
Graphics g = getGraphics();
// perform rendering
g.drawImage(myImage,0,0,this);
g.drawLine(0,0,10,20);
// .
}
}
public class Renderer extends Thread
{
private MyComponent myComponent = new MyComponent();
// more code to write here .
public void run()
{
while(true)
{
// render component
myComponent.renderScreen();
// rest a bit and give time to other Threads
try
{
Thread.sleep(20L);
}
catch (InterruptedException ex) {}
}
}
}
Double Buffering
Führt man diesen Code aus, dann wird man feststellen, daß das Rendern zwar funktioniert, aber
nicht ohne ein gelegentliches Flackern des gerenderten Bildes. Das Flackern tritt nämlich dann auf,
Bereich auf die Bildröhre zu projezieren. Diesen Effekt kann man jedoch umgehen, indem man das
zu zeichnende Bild zuerst in einem nicht-sichtbaren Speicherbereich erstellt und dann diesen
Bereich anschließend in den sichtbaren Bereich kopiert. Das Zeichnen selbst nimmt nämlich
deutlich mehr Zeit in Anspruch als das Kopieren das Speicherbereiches!Diese Methode des Renderns nennt man Double Buffering. In Java verwenden wir für das Double
Buffering ein eigens erstelltes BufferedImage, daß die Größe der zu rendernden Oberfläche hat und
im Folgenden als BackBuffer bezeichnet wird. Der BackBuffer wird über die Methode createImage
(int width, int height) der Komponente erstellt und verhält sich ansonsten wie ein normales Image-
Objekt. Das Kopieren des BackBuffers in einen sichtbaren Bildschirmbereich erfolgt daher über die
drawImage( .) Methode des zugehörigen Graphics-Objektes.
Rendert man nun die Komponente über Double Buffering, dann erfolgt das Rendern in 3 Schritten:
1. Erzeuge einen BackBuffer, falls dieser noch nicht vorhanden ist
2. Verwende das Graphics-Objekt des BackBuffers für alle Renderoperationen
3. Zeichne den BackBuffer auf den Bildschirm über das Graphics-Objekt der Komponente
Der entsprechende Quellcode dazu könnte in ungefähr so aussehen:
import java.awt.*;
import javax.swing.JComponent;
{
private Image backBuffer;
private Image myImage;
// more code to write here .
private void createBackBuffer()
{
backBuffer = createImage(getWidth(),getHeight());
}
public void paintComponent(Graphics g)
{
updateScreen();
}
public void renderScreen()
{
// if backBuffer doesn't exist, create one
if (backBuffer == null) createBackBuffer();
// get Graphics object from backBuffer
Graphics g = backBuffer.getGraphics();
// render screen on backBuffer
g.drawImage(myImage,0,0,this);
g.drawLine(0,0,10,20);
// .
}
public void updateScreen()
{
Graphics g = getGraphics();
if (g != null) // component already visible?
{
// is there a backBuffer to draw?
if (backBuffer != null) g.drawImage(backBuffer, 0, 0, null);
else
{
// if not, create one and render on it
createBackBuffer();
renderScreen();
}
}
}
}public class Renderer extends Thread
{
private MyComponent myComponent = new MyComponent();
// more code to write here .
public void run()
{
while(true)
{
myComponent.renderScreen(); // render component
myComponent.updateScreen(); // draw backBuffer to screen
try
{
Thread.sleep(20L);
}
catch (InterruptedException ex) {}
}
}
}
Führt man diesen Code jetzt aus, dann stellt man fest, daß das Flackern nun nicht mehr auftritt.
Interessant ist an dieser Stelle vielleicht noch, daß Java Swing Komponenten schon standardmäßig
einen internes Double Buffering verwenden, um so ein Flackern zu vermeiden. Dieses sollte man,
wenn man Komponenten nach dem oben beschriebenen Verfahren selbst rendert, über die Funktion
setDoubleBuffered(false) deaktiveren, um so einen Performanceverlust durch ein zusätzliches
(unnötiges) Puffern durch die Swing Komponente zu vermeiden!
b) Bilder und Sprites
Programmiert man in Java, dann macht man sich eigentlich zuerst einmal keine Gedanken darüber
wo und wie ein Objekt gespeichert wird, denn diese Arbeit nimmt uns ja glücklicherweise die JVM
ab! Und da Image-Objekte ebenfalls Objekte sind, trifft das auch für sie zu. Aber trotzdem ist
Image-Objekt nicht gleich Image-Objekt und Java hat auch nicht zufälligerweise verschiedene
Bildtypen.
BufferedImage
nämlich komplett von Java verwaltet und ist somit perfekt für den Einstieg in die Programmierung
mit Bildern geeignet.
Was macht aber Java im Hintergrund? Und wie sieht so eine “Verwaltung” eigentlich intern aus?
Nun genaugenommen werden Image-Objekte in der Regel zuerst einmal im Hauptspeicher angelegt
und dann dort durch verschiedene Grafik-Funktionen (wie z.B. Draw, Transform, Filter, etc.)
bearbeitet. Zum Anzeigen des Bildes wird anschließend der Speicherbereich, der die eigentliche
Bild-Information (also die einzelnen Bildpunkte / Pixel) enthält, aus dem Hauptspeicher in den
Videospeicherbereich (VRAM) kopiert und von dort aus dann über die Grafikkarte auf den
Bildschirm projeziert. Das Anlegen der Kopie im Hauptspeicher, sowie das Kopieren der Pixel
(Blitting) aus dem Hauptspeicher in das VRAM führt Java bei einem BufferedImage automatisch
durch. Um jedoch den Kopiervorgang zwischen Hauptspeicher und VRAM zu beschleunigen, legt
Java bei einem BufferedImage, eine zusätzliche Kopie des Bildes in einem hardwarebeschleunigtenBereich an und synchronisiert diese dann bei Bedarf automatisch mit der Kopie aus dem
BufferedImage um ein Bild mit relativ statischem Inhalt handelt.
(Quelle: VolatileImage.pdf)
Sprites
Sprites sind Bilder mit statischem Inhalt, die Charaktere und Objekte eines Spiels darstellen. Da ihr
Inhalt sich nach dem Laden nicht mehr verändert, sollte für sie ein BufferedImage verwendet
werden. Das BufferedImage erstellt nämlich beim ersten Rendern automatisch eine Kopie im
VRAM und verwendet diese anschließend für weitere Render-Durchläufe. Da der Inhalt der Bilder
statisch ist, ist eine permanente Synchronisation zwischen Hauptspeicher und VRAM nicht
notwendig, wodurch man hier eine performante und automatisch-verwaltete Version des Bildes
bekommt.
(Quelle: VolatileImage.pdf)VolatileImage
Wird der Inhalt eines Bildes jedoch häufig verändert, dann muß eine evtl. vorhandene Kopie im
hardwarebeschleunigten Speicherbereich ebenfalls häufig aktualisiert werden. Wie man sich sicher
vorstellen kann, geht in dieser Situation sehr viel Zeit für das Kopieren zwischen Hauptsspeicher
und hardwarebeschleunigtem Speicher verloren und Java kann hier intern auch nicht viel
Verwaltung und Synchronisation verzichtet und das Bild direkt im hardwarebeschleunigten Bereich,
also dem VRAM bei Windows Betriebssystemen, anlegt. Dadurch entfällt das Kopieren aus dem
Hauptspeicher und das Verändern der Pixel wird durch hardwarebeschleunigte Grafik-Funktionen
direkt im VRAM durchgeführt. So wird nicht nur die CPU entlastet, sondern Grafik-Operationen
können auch parallel, durch die Verwendung der Grafik-Hardware, durchgeführt werden. Dieser
neue, beschleunigte Bildtyp heißt VolatileImage.
(Quelle: VolatileImage.pdf)
Wie das Wort Volatile (auf Deutsch “flüchtig”) aber schon andeutet, ist das VRAM ein “flüchtiger”
Speicherbereich, in dem Daten jederzeit überschrieben werden können. Deshalb müssen bei der
Verwendung eines VolatileImages auch spezielle Maßnahmen getroffen werden, um den korrekten
Inhalt des Bildes sicherzustellen bzw. bei Bedarf zu erneuern. Diesen Zusatzaufwand muß man hier
leider betreiben, bekommt aber im Gegenzug einen extrem performanten Bild-Typ. Da ein
Überschreiben der Bilddaten im VRAM seltener auftritt, als man es zunächst annehmen würde und
als BackBuffer für das DoubleBuffering.
Folgende Situationen führen zu einem Überschreiben von Daten im VRAM:
• Ausführen einer anderen Anwendung im Fullscreen-Modus
• Starten eines Bildschirmschoners
• Unterbrechen eines Tasks über den Taskmanager (unter Windows)
• Verändern der Bildschirmauflösung
VolatileImage als BackBuffer
Wird ein VolatileImage als BackBuffer verwendet, dann muß man vor jedem Zugriff auf den
BackBuffer überprüfen, ob dieser noch gültig ist und bei Bedarf einen neuen BackBuffer erzeugen.
Außerdem muß man nach dem Rendern überprüfen, ob der Inhalt des VolatileImages in der
Zwischenzeit vielleicht überschrieben wurde und ggf. nochmal neu rendern. Ist dieser Fall jedoch
nicht eingetreten, dann kann man den BackBuffer jetzt auf dem Bildschirm anzeigen.Der veränderte Quellcode könnte in ungefähr so aussehen:
import java.awt.*;
import java.awt.image.*;
import javax.swing.JComponent;
public class MyComponent extends JComponent
{
private VolatileImage backBuffer;
private Image myImage;
// more code to write here .
{
// get the actual GraphicsConfiguration and create a compatible
// VolatileImage as BackBuffer
GraphicsConfiguration gc = getGraphicsConfiguration();
backBuffer = gc.createCompatibleVolatileImage(getWidth(),getHeight());
}
public void renderScreen()
{
// if backBuffer doesn't exist, create one
if (backBuffer == null) createBackBuffer();
do
{
// validate the backBuffer
int valCode = backBuffer.validate(getGraphicsConfiguration());
if (valCode == VolatileImage.IMAGE_RESTORED)
{
System.out.println("backBuffer - IMAGE_RESTORED");
// This case is just here for illustration
// purposes. Since we are
// recreating the contents of the back buffer
// every time through this loop, we actually
// do not need to do anything here to recreate
// the contents. If our VImage was an image that
// we were going to be copying _from_, then we
// would need to restore the contents at this point
}
else if (valCode == VolatileImage.IMAGE_INCOMPATIBLE)
{
// backBuffer Image is incompatible with actual screen
// settings, so we have to create a new compatible one
createBackBuffer();
}
// get Graphics object from backbuffer
Graphics g = backBuffer.getGraphics();
// render on backbuffer
g.drawImage(myImage,0,0,this);
g.drawLine(0,0,10,20);
// .
// rendering is done; now check if contents got lost
// and loop if necessary
} while (backBuffer.contentsLost());
}
// . more code to write here
}Einsatz und Grenzen der VolatileImage API
Die VolatileImage API ist momentan noch stark in Entwicklung und wird mit kommenden Java
Versionen noch weiter verbessert werden. Derzeit ist nur das Zeichnen von Lienen und das
Kopieren und Füllen von rechteckigen Bereichen hardwarebeschleunigt, sowie einige komplexere
Funktionen, die als Kombination dieser Basisfunktionen auftreten. Beim Rendern von Text können
die einzelnen Buchstaben nach dem Rendern im VRAM zwischengespeichert und von dort bei
weiteren Render-Durchläufen wieder kopiert werden, wodurch man sich ein aufwendiges Neu-
Rendern spart. Komplexere Funktionen jedoch wie z.B. diagonale Linien, Curved Surfaces, Anti-
Aliasing und Komposition sind nicht hardwarebeschleunigt und werden derzeit über reines