Dieses Tutorial behandelt die folgenden Themen:
- Die Initialisierung der Grafik und des Fensters
- Die Hauptschleife (Game Loop) des Spiels
- Das Timing und die Messung der FPS (Frames-per-Second)
Dieses Tutorials wird zeigen wie die grundlegende Struktur eines Spiels aussieht. Es wird erläutert warum Spiele so aufgebaut werden, welche Vor- und Nachteile dies hat und auch welche Konsequenzen sich für die Entwicklung des Spiels hierdurch ergeben.
Am Ende des Tutorials steht ein Programm, das den Inhalt seines Fensters in einer Game Loop immer wieder neu zeichnet und dabei einen fest vorgegebenen maximalen FPS-Wert einhält. Das Programm wird beobachten ob es gerade aktiv ist und nur in diesem Fall Rechenzeit verbrauchen. Das Programm bietet dann die Grundlage für die folgenden Tutorials, welche die in diesem geschaffene Infrastruktur nutzen um weiterführende Funktionen zu implementieren.
Das in Java geschrieben Programm benutzt für das Rendering OpenGL das über die native Schnittstelle JOGL angebunden ist.
Was sind die Vorraussetzungen?
Damit ihr die Inhalte des Tutorials nachvollziehen könnt sind mindestens die folgenden Kenntnisse notwendig:
- Mittlere Kenntnisse in Java (Klassen, Methoden, Programmflusssteuerung, Vererbung)
- Grundkenntnisse mit Eclipse im Zusammenhang mit der Entwicklung von Java-Programmen
Da alle Tutorials herunterladbare Quelltexte für die Übungen und Erläuterungen enthalten, empfehle ich euch vorab die dafür notwendige Entwicklungsumgebung zu installieren damit ihr problemlos den Erklärungen folgen und direkt eigene Experimente machen könnt.
Das Fenster erstellen und für OpenGL vorbereiten
Das Fenster in dem die Grafik gerendert werden wird als Unterklasse eines JFrames erstellt. In diesem Konstruktur stellen wir auch die Parameter des Fensters so ein wie wir sie brauchen: Die Größe, den Titel und auch das Standardverhalten beim Schliessen des Fensters.
In einem richtigen Spiel würde das Fenster in den meisten Fällen als Vollbild und ohne Dekorationselemente erzeugt werden. Für Testzwecke ist es aber erstmal praktischer wenn wir ein normales Fenster verwenden:
public class MainWindow extends JFrame {
public MainWindow() {
super("Game Tutorial");
// Set the frame's properties
setSize(400,400);
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
}
}
Dieses Fenster instanzieren wir direkt aus der Main Klasse heraus:
public class Main {
public static void main(String[] args) {
MainWindow mainWin = new MainWindow();
mainWin.setVisible(true);
}
}
GLCanvas erstellen und die MainWindow-Klasse als Listener eintragen
Mit diesen beiden Klassen haben wir unser Fenster erzeugt und können nun das OpenGL-Rendering integrieren. Für diesen Zweck bietet JOGL uns die Komponente GLCanvas an – dies ist eine Oberflächenkomponente wie alle anderen AWT-Komponenten auch. Das bedeutet, dass man sie in eine beliebige AWT-GUI integrieren kann. Für eine Swing-GUI stellt JOGL die Klasse GLJPanel bereit. Diese bietet die identische Funktionalität wie GLCanvas, ist aber kompatibler zur Swing-Architektur – leider aber unter Umständen deutlich langsamer als die AWT-Variante. Die Ursache liegt hier darin, dass GLJPanel das Rendering offscreen betreibt und anschließend das Bild in die Komponente hineinkopiert – falls kein Hardware beschleunigter Pixel Buffer verfügbar ist wird die Performance dadurch deutlich leiden. Da wir keine Swing-Oberfläche brauchen, verwenden wir GLCanvas.
Die GLCanvas-Klasse bietet die Möglichkeit sich als GLEventListener bei ihr zu registrieren. Dadurch erhalten wir die Möglichkeit über alle wichtigen Ereignisse der Komponente informiert zu werden. Dazu gehört auch die Information dass die Inhalte neu gezeichnet werden müssen:
public class MainWindow extends JFrame implements GLEventListener {
/** The canvas we draw onto. */
private GLCanvas canvas;
/**
* Default Constructor.
*/
public MainWindow() {
super("Game Tutorial");
// Set the frame's properties
setSize(400,400);
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
// Create the OpenGL draw canvas and tell it that we like to receive events
canvas = new GLCanvas(new GLCapabilities());
canvas.addGLEventListener(this);
// Add the canvas to the frame
setLayout(new BorderLayout());
add(canvas, BorderLayout.CENTER);
}
// ========================================================================
// GLEventListener implementation
// ========================================================================
@Override
public void display(GLAutoDrawable drawable) {
// This is called when the windows needs to be redrawn
// Get the GL object - this is a wrapper for the current OpenGL context and provides
// us with the OpenGL methods.
GL gl = drawable.getGL();
// Clear the screen
gl.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT);
}
@Override
public void displayChanged(GLAutoDrawable drawable, boolean modeChanged, boolean deviceChanged) {
// Usually not called - only in very special cases. For example if you drag the window from
// one monitor to another one and both have different color depth settings...
}
@Override
public void init(GLAutoDrawable drawable) {
// This is called as soon as we have a valid OpenGL context
GL gl = drawable.getGL();
// Set the background color
gl.glClearColor(0, 0.5f, 1, 1);
}
@Override
public void reshape(GLAutoDrawable drawable, int x, int y, int width, int height) {
// This gets called when the window is resized for example
}
}
GLEventListener-Methoden
Wie im obigen Beispiel beschrieben gibt es vier Methoden die das Interface GLEventListener benötigt: display, displayChanged, init und reshape. Diese Methoden werden von JOGL aufgerufen wenn entsprechende Aktionen von unserer Anwendung notwendig werden.
void init(GLAutoDrawable drawable)
Diese Methode wird aufgerufen sobald ein gültiger OpenGL Kontext vorliegt. Dadurch erhält unsere Anwendung die Möglichkeit das OpenGL Rendering zu initialisieren und den OpenGL Zustand so einzustellen wie wir ihn haben möchten. Hier kann man zum Beispiel Texturen oder Shader laden und in OpenGL bekannt machen.
void display(GLAutoDrawable drawable)
Diese Methode wird aufgerufen wenn Teile der GLCanvas Komponente neu gezeichnet werden müssen – hierbei ist es allerdings unerheblich ob alles oder nur ein Teil verdeckt war. Es wird stets alles gezeichnet. Wenn die OpenGL-Anzeige Double-Buffered ist, führt JOGL nach dem Aufruf der display()-Methode selbständig einen Aufruf von swapBuffers() durch.
void reshape(GLAutoDrawable drawable, int x, int y, int width, int height)
Diese Methode wird aufgerufen wenn sich die Position und/oder Größe des OpenGL Fensters ändert. Der Aufruf von glViewport() um die Darstellung der OpenGL Grafik anzupassen wird bereits von JOGL vorgenommen so dass in der Regel hier keine Aktionen mehr folgen müssen.
void displayChanged(GLAutoDrawable drawable, boolean modeChanged, boolean deviceChanged)
Dies ist eine sehr spezielle Methode die in den allermeisten Fällen gar nicht aufgerufen wird. Sie ist für den Fall da, dass sich die grundlegenden Geräteeigenschaften geändert haben. Ein Beispiel das die Dokumentation nennt ist das Verschieben eines OpenGL-Fensters von einem Bildschirm auf einen anderen wobei der andere eine unterschiedliche Farbtiefe aufweist.
Die Game Loop
So wie das Programm derzeit aufgebaut ist wird die display()-Methode nur aufgerufen wenn ein Bereich des Fensters überdeckt wurde und neu gezeichnet werden muss. Dieses Verhalten ist für Spiele natürlich nicht gewünscht – wir benötigen eine immer wiederkehrende Neuzeichung der Fensterinhalte. Um dies zu erreichen implementieren wir eine Game Loop in der neuen Klasse Game.
Diese Game Loop soll in einer Schleife die bis zum Ende des Programms durchgehend läuft den Fensterinhalt immer neu zeichnen. Auf diese Weise können später im Spiel Daten verändert werden und es ist sichergestellt, dass die Änderung direkt sichtbar wird. Das ist vor allem in Action-Spielen wichtig, denn hier muss der Spieler direkt die Auswirkungen sehen wenn er seine Spielfigur bewegt.
package de.threedcoding.tutorials;
import java.util.logging.Logger;
/**
* This is the main game class.
*
* @author Stefan Gaffga
*/
public class Game {
/** The Logger object. */
private Logger log = Logger.getLogger(Game.class.getName());
/** The main window that shows the game contents. */
private MainWindow mainWindow;
/**
* Default Constructor.
*
* @param mainWin the main window that will be refreshed by the game loop
*/
public Game(MainWindow mainWin) {
this.mainWindow = mainWin;
}
/**
* The main game loop - runs until the window is destroyed
*/
public void run() {
log.finer("Game Loop started.");
// Yet a very... minimalistic game loop
while ( mainWindow.isVisible() ) {
mainWindow.draw();
}
log.finer("Game Loop ended.");
}
}
Die Game Loop läuft so lange wie es ein Fenster gibt das etwas darstellen kann – das ist auch ganz praktisch, denn so stellen wir sicher dass solange etwas darstellbar ist, wir auch etwas zeichnen – und wenn es nur ein schwarzer Bildschirm ist.
Für die Game Loop muss die MainWindow-Klasse noch um die Methode draw() erweitert werden. Diese Methode soll einfach den gesamten Fensterinhalt neu zeichnen:
/**
* Redraws the windows contents.
*/
public void draw() {
canvas.display();
}
In der Main-Klasse müssen wir nun natürlich die Game-Klasse auch aufrufen und die Game Loop starten lassen:
package de.threedcoding.tutorials;
import java.io.IOException;
import java.io.InputStream;
import java.util.logging.LogManager;
public class Main {
/**
* The main method.
*
* @param args the arguments
*
* @throws IOException if the log config could not be read
* @throws SecurityException if we were not allowed to set the log config
*/
public static void main(String[] args) throws SecurityException, IOException {
// Init logging
InputStream isLogConfig = Main.class.getClassLoader().getResourceAsStream("log.properties");
if ( isLogConfig!=null ) {
LogManager.getLogManager().readConfiguration(isLogConfig);
} else {
System.err.println("Warning: log.properties not found in classpath - logging is therefore disabled");
}
// Init main window
MainWindow mainWin = new MainWindow();
mainWin.setVisible(true);
// Init and start game loop
Game game = new Game(mainWin);
game.run();
}
}
Java Logging
Gleichzeitig führen wir nun auch ein Logging ein – dies ist wichtig um Fehler im Spiel zu finden. Man wird zwar hauptsächlich mit dem Debugger Fehler finden – doch was ist wenn ein Fehler nur bei einem Endkungen auftritt? Dann schaltet man das Logging ein und lässt sich das Logfile schicken um den Fehler eingrenzen zu können. Für den jetzigen Augenblick ist das Logging eine gute Kontrolle um den korrekten Ablauf des Programms sicherzustellen und um schnell zu überprüfen, ob auch all die Stellen durchlaufen werden von denen wir das erwarten.
Ich verwende hier das Java Logging das mit Java 1.4 Teil des SDK geworden ist. Damit die Ausgaben funktionieren, muss mit dem LogManager zu Beginn des Programms die Log-Konfiguration geladen werden. Das geschieht in der main() Methode.
Die log.properties enthält die Konfiguration des Logging-Systems und hat folgenden Inhalt:
de.threedcoding.tutorials.level = FINEST de.threedcoding.tutorials.handlers = java.util.logging.ConsoleHandler java.util.logging.ConsoleHandler.level = FINEST java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
Damit diese Datei zur Laufzeit im Klassenpfad liegt (denn von dort wird sie von der main-Methode gelesen), habe ich unterhalb des Projekts einen neuen Source Folder “cfg” angelegt und dort diese Datei erstellt. Source Folder haben die nette Eigenschaft, dass sie alles was sie beinhalten automatisch in das bin-Verzeichnis kopieren. Da dort auch die kompilierten Klassen liegen, liegen die Dateien der Source Folder ebenfalls automatisch im Klassenpfad.
Die Log-Konfiguration wird in der main()-Methode mit folgendem Code eingelesen:
// Init logging
InputStream isLogConfig = Main.class.getClassLoader().getResourceAsStream("log.properties");
if ( isLogConfig!=null ) {
LogManager.getLogManager().readConfiguration(isLogConfig);
} else {
System.err.println("Warning: log.properties not found in classpath - logging is therefore disabled");
}
Nachdem dieser Block ausgeführt worden ist, kann man Logger-Objekte erzeugen. Es ist wichtig, dass die Logger Objekte nachher erzeugt werden – denn nur dann ist sichergestellt, dass die Logger auch die korrekte Konfiguration haben.
Einen Logger erzeugt man so:
Logger log = Logger.getLogger("de.threedcoding.tutorials.MainWindow");
Dies erzeugt einen Logger mit dem Namen “de.threedcoding.tutorials.MainWindow”. Der Name ist hierarchisch über die Package-Namen-Struktur von Java aufgeteilt. Das hat den Vorteil, dass alle Klassen unterhalb von “de.threedcoding.tutorials” automatisch den Level FINEST zugewiesen bekommen – so wie es in der log.properties oben festgelegt wurde.
Möchte man allen Klassen den Level “OFF” zuweisen und nur die Ausgaben der Klasse MainWindow sehen so kann man folgendes in der log.properties eintragen:
de.level = OFF de.threedcoding.tutorials.MainWindow.level = FINEST
Durch die hierarchische Namensvergabe beim Logging erhält man also eine sehr flexible Möglichkeit die Ausgaben zu konfigurieren.
Ausgaben können mit verschiedenem Level gemacht werden:
log.finest("Sehr detaillierte Debug-Ausgabe");
log.finer("detaillierte Debug-Ausgabe");
log.fine("generelle Debug-Ausgabe");
log.info("eine Info-Nachricht");
log.warning("eine Warnung");
log.severe("ein Fehler");
// Man kann auch Exceptions loggen - das geht so:
log.log(Level.SEVERE, "text", exception);
Rücksicht nehmen auf andere Prozesse
Die Game Loop ist noch lange nicht fertig – derzeit zeichnet sie einfach so schnell wie möglich den Bildschirminhalt neu. Das bedeutet, dass sie immer sehr viel CPU Last erzeugen wird – egal ob das Fenster gerade sichtbar, im Hintergrund oder gar minimiert ist. Um dies zu vermeiden implementieren wir als erstes in der MainWindow Klasse das Interface WindowListener und lassen uns dadurch mitteilen wann das Fenster aktiv ist und wann nicht:
public class MainWindow extends JFrame implements GLEventListener, WindowListener {
/** The Logger object. */
private Logger log = Logger.getLogger(MainWindow.class.getName());
/** The canvas on which we render OpenGL. */
private GLCanvas canvas;
/** Flag whether the window is visible and active and therefore ready for rendering. */
private boolean ready = false;
/** Flag whether the windows was closed. */
private boolean destroyed = false;
/**
* Default Constructor.
*/
public MainWindow() {
super("Game Tutorial");
// Set the frame's properties
setSize(400,400);
addWindowListener(this);
// Create the OpenGL draw canvas and tell it that we like to receive events
canvas = new GLCanvas(new GLCapabilities());
canvas.addGLEventListener(this);
// Add the canvas to the frame
setLayout(new BorderLayout());
add(canvas, BorderLayout.CENTER);
}
public boolean isReady() {
return ready;
}
public boolean isDestroyed() {
return destroyed;
}
public void draw() {
canvas.display();
}
// ========================================================================
// WindowListener implementation
// ========================================================================
@Override
public void windowActivated(WindowEvent arg0) {
log.finer("Window activated");
ready = true;
}
@Override
public void windowClosed(WindowEvent arg0) {
log.finer("Window closed");
ready = false;
destroyed = true;
}
@Override
public void windowClosing(WindowEvent arg0) {
log.finer("Window closing");
ready = false;
setVisible(false);
dispose();
}
@Override
public void windowDeactivated(WindowEvent arg0) {
log.finer("Window deactivated");
ready = false;
}
@Override
public void windowDeiconified(WindowEvent arg0) {
log.finer("Window restored");
ready = true;
}
@Override
public void windowIconified(WindowEvent arg0) {
log.finer("Window iconified");
ready = false;
}
@Override
public void windowOpened(WindowEvent arg0) {
log.finer("Window opened");
}
// ========================================================================
// GLEventListener implementation
// ========================================================================
/**
* {@inheritDoc}
*/
@Override
public void display(GLAutoDrawable drawable) {
GL gl = drawable.getGL();
gl.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT);
}
/**
* {@inheritDoc}
*/
@Override
public void displayChanged(GLAutoDrawable drawable, boolean modeChanged, boolean deviceChanged) {
}
/**
* {@inheritDoc}
*/
@Override
public void init(GLAutoDrawable drawable) {
GL gl = drawable.getGL();
gl.glClearColor(0.2f, 0.6f, 1.0f, 0.0f);
}
/**
* {@inheritDoc}
*/
@Override
public void reshape(GLAutoDrawable drawable, int x, int y, int width, int height) {
}
}
Der nächste Schritt ist die Anpassung der Game Loop. Diese soll nur dann schnellstmöglich laufen wenn das Fenster aktiv und sichtbar ist. Dazu ändern wir die Game Loop in der Datei Game.java wie folgt:
package de.threedcoding.tutorials;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* This is the main game class. It contains the game loop.
*
* @author Stefan Gaffga
*/
public class Game {
/** The Logger object. */
private Logger log = Logger.getLogger(Game.class.getName());
/** The main window that shows the game contents. */
private MainWindow mainWindow;
/**
* Default Constructor.
*
* @param mainWin the main window that will be refreshed by the game loop
*/
public Game(MainWindow mainWin) {
this.mainWindow = mainWin;
}
/**
* The main game loop - runs as long as we have a window
*/
public void run() {
log.finer("Game starts.");
// As long as we have a window
while ( !mainWindow.isDestroyed() ) {
// Run the game loop as long as the window is visible and focused
if ( mainWindow.isReady() ) {
log.finer("Game Loop started.");
while ( mainWindow.isReady() ) {
mainWindow.draw();
}
log.finer("Game Loop stopped.");
}
// If the window is not ready anymore, pause the game loop
if ( !mainWindow.isDestroyed() ) {
// Wait for the window getting destroyed completely or restored
while ( !mainWindow.isReady() && !mainWindow.isDestroyed() ) {
try {
// Sleep a while so we don't flood the CPU
Thread.sleep(500);
} catch (InterruptedException e) {
log.log(Level.INFO, "Someone interrupted our Thread.sleep", e);
}
}
}
}
log.finer("Game ends.");
}
}
Damit verhalten wir uns schon sehr fair dem Betriebssystem und anderen Anwendungen gegenüber – wir verbrauchen sehr wenig CPU Zeit wenn das Fenster nicht aktiv ist. Falls es aktiv ist, läuft die Loop mit maximalem Tempo.
Messen der FPS (Frames-per-Second)
Der FPS-Wert wird oft als eine Art Performancemessung gesehen wenn es darum geht ein Spiel auf verschiedenen Rechnern zu vergleichen. Das ist auch in der Regel berechtigt, denn der Wert besagt ja wieviele Bilder der Rechner pro Sekunde darstellen kann und je mehr Leistung der Rechner in Verbindung mit der Grafikkarte an den Tag legt, umso höher wird der FPS Wert ausfallen.
Da sich der Bildschirminhalt in der Regel oft ändert, ist auch der Aufwand diesen zu zeichnen alles andere als konstant. Und damit ändern sich die Zeiten die das Rendering benötigt. Daher ist der FPS-Wert immer nur ein ungefährer Wert.
Um die FPS zu berechnen kann man drei unterschiedliche Wege gehen:
Die Anzahl Frames über eine Sekunde zählen
Diese Vorgehensweise liefert die exakte Anzahl an Frames pro Sekunde – allerdings wird dieser Wert nur ein mal pro Sekunde aktualisiert und ist daher ungeeignet um irgendeiner Kontrollfunktion im Spiel nachzukommen. Er hat den Nachteil dass er sich so selten aktualisiert und dem Beobachter dadurch nicht direkt ein Gefühl dafür gibt wie aufwändig eine Szene wirklich ist – man muss eine ganze Sekunde die Ansicht beibehalten um einen akkuraten Wert zu erhalten.
Die Anzahl Frames wird über einen Sekundenbruchteil gezählt und dann hochgerechnet
Dieses Verfahren liefert recht genaue Werte die auch oft aktualisiert werden. Dies ist die wohl am häufigsten verwendete Methode um die FPS zu errechnen. Diese Methode werden wir ebenfalls verwenden.
Jedes Frame wird gemessen und dann hochgerechnet
Dies ist die Extremvariante des vorhergehenden Verfahrens. Der Vorteil ist, dass die Werte äußerst aktuell sind. Die Nachteile sind, dass der Wert sich mit jedem Frame ändert und dass dieser Schätzwert immer nur an einem Frame hängt – wenn etwa 500 Frames pro Sekunde gerendert werden, dann schließt man von einem Frame auf die 499 anderen. Dass dies alles andere als genau ist kann man sich sicher vorstellen.
Das Timing: Der Zeitfaktor
Neben den Frames-per-Second ist es noch sehr wichtig einen weiteren Wert zu errechen: Den Zeitfaktor! Dieser Wert ist essentiell für den Ablauf des Spiels, denn er sorgt dafür, dass das Spiel stets mit der gleichen Geschwindigkeit abläuft. Mit diesem Faktor werden im Spiel alle Bewegungen und Animationen skaliert. Ein Rechner A der doppelt so schnell ist wie ein Rechner B wird einen halbierten Zeitfaktor haben um die schnellere Darstellung auszugleichen.
In der Klasse FPSMeter wird sowohl wie oben besprochen der FPS-Wert ermittelt, als auch dieser Zeitfaktor. Der Zeitfaktor wird dabei im Verhältnis zu einem Soll-FPS Wert berechnet. Das hat den Grund, dass man so die Geschwindigkeiten besser abschätzen kann: Möchte man programmieren, dass eine Figur einen Meter in einer Sekunde zurücklegen soll, und legt insgesamt den Soll-FPS Wert auf 30 fest, so kann man in jedem Frame diese Figur die Strecke “zeitFaktor * 1 / 30″ weiterbewegen. Die Geschwindigkeiten im Spiel sind so nachvollzieh- und vorhersagbar.
package de.threedcoding.tutorials;
/**
* Class that measures FPS and the times each frame take. This class is used to enable games to
* run a constant speed even if the rendering times of frames change.
*
* @author Stefan Gaffga
*/
public class FPSMeter {
/** The FPS value that the game speed was designed for. The speedFactor will be 1.0 for this FPS value. */
private double designFPS;
/** interval in milliseconds when to update FPS value */
private double fpsCheckInterval;
/** The time when the frame processing started. */
private long timeFrameStart;
/** This is the time in nanoseconds that a frame needs to take to not exceed the maxFPS setting. */
double minFrameTime;
/** This is the factor that needs to be applied to every movement and animation in the game to keep it running
* at a constant speed. */
double timeScale = 0;
/** The current FPS-value. Updated every fpsCheckInterval milliseconds. */
double fps = 0;
/** Until the next fpsCheckInterval, this is counting the FPSs. */
int fpsCount = 0;
/** The time the last FPS update took place. */
long lastFpsCheck;
/**
* Default Constructor.
*/
public FPSMeter() {
this(200.0, 250.0, 30.0);
}
/**
* Constructor.
*
* @param maxFPS the maximum allowed FPS - the frameEnd() method delays its return until the FPS is at max this value
* @param fpsCheckInterval the interval in which the FPS value is updated
* @param designFPS this is the FPS value the game's speeds are designed for. The speed factor will be 1.0 if the current FPS
* is exactly this value
*/
public FPSMeter(double maxFPS, double fpsCheckInterval, double designFPS) {
this.fpsCheckInterval = fpsCheckInterval;
this.designFPS = designFPS;
minFrameTime = 1e9 / maxFPS;
lastFpsCheck = System.currentTimeMillis();
}
/**
* Called at the beginning of a frame.
*/
public void frameStart() {
timeFrameStart = System.nanoTime();
}
/**
* Called at the very end of a frame - updates all the values and delays the processing if necessary.
*/
public void frameEnd() {
// Wait until the frame lasts a minimum amount of time - this
// way we get a maximum FPS limit
while ( System.nanoTime() - timeFrameStart < minFrameTime ) {
// Delegate to other process and threads while we wait Thread.yield();
}
// Time in nanoseconds this frame took to process long
diff = System.nanoTime() - timeFrameStart;
// Count this frame fpsCount++;
// Update the FPS value if its time to
if ( System.currentTimeMillis() - lastFpsCheck > fpsCheckInterval) {
fps = fpsCount / ((System.currentTimeMillis() - lastFpsCheck)/1000.0);
fpsCount=0;
lastFpsCheck = System.currentTimeMillis();
}
// Calculate the time scale factor
double frameBasedFPS = 1e9 / diff;
timeScale = designFPS / frameBasedFPS;
}
/**
* Returns the speed factor.
*
* @return the speed factor
*/
public double getSpeedFactor() {
return timeScale;
}
/**
* Returns the FPS value.
*
* Don't use this value to control the game speed. This is far to inaccurate for this. Use getSpeedFactor() instead.
*
* @param the current FPS value
*/
public double getFPS() {
return fps;
}
}
Begrenzen der maximalen FPS
Java bietet seit der Version 1.5 die Methode System.nanoTime() die (zumindest unter Windows Vista) mehr als genau genug ist um jeden Frame einzeln zu messen und den Geschwindigkeitsfaktor für das folgende Frame entsprechend anzupassen. Da die API-Dokumentation aber keine konkrete Aussage darüber macht wie genau der Timer ist, müssen wir hier vorsorgen, dass auf anderen Plattformen hier keine Probleme entstehen können.
Sollte der Timer nämlich viel ungenauer sein, dann könnte es sein, dass die Zeitdifferenz für einen Frame 0 beträgt – und dann würden wir eine Division durch Null riskieren. Um dies zu vermeiden beschränken wir die maximale Anzahl der Frames pro Sekunde auf einen Wert für den wir annehmen, dass hier auf allen Plattformen Timer mit ausreichender Genauigkeit verfügbar sind.
Hierzu wird die Anzahl an Nanosekunden ermittelt die ein Frame minimal benötigen muss damit am Ende ein FPS von maximal des Höchstwertes herauskommen kann. Falls das Frame weniger als diese Zeit bisher verbraucht hat, wartet der FPSMeter diese Zeit ab und verzögert damit das Ende des Frames.
Die Spielelogik arbeiten lassen
Bisher haben wir uns nur um das Rendering gekümmert – es gibt aber auch noch den eigentlichen Berechnungsprozess im Spiel: Die Bewegungen aller Figuren im Spiel, das Abspielen von Sounds, Animationen und Abfragen von Kollisionen sind nur einige der Aufgaben die noch erledigt werden müssen. Diese werden genau wie das Zeichnen in der Game Loop integriert – und zwar direkt vor dem Aufruf des Zeichnens. Dort rufen wir die step()-Methode auf die das Spiel genau einen Schritt weiter berechnen soll.
Und für genau diese step-Methode benötigen wir auch nun den Zeitfaktor – denn alle Aktualisierungen von Spielelementen finden hier statt. Durch die Übergabe des Faktors können alle Geschwindigkeiten mit ihm skaliert und damit auf die Rechengeschwindigkeit angepasst werden:
while ( mainWindow.isReady() ) {
fpsMeter.frameStart();
mainWindow.step(fpsMeter.getSpeedFactor());
mainWindow.draw();
fpsMeter.frameEnd();
}
Es ist wichtig, dass alle Aktionen die in der step()-Methode stattfinden immer auf ein einzelnes Frame ausgelegt sind. Würde ein Spieler einen Schuss abgeben und dieser würde mittels einer einfachen for()-Schleife bewegt, so würde alles andere im Spiel unterdessen stehenbleiben – abgesehen davon, dass auch die draw() Methode nicht aufgerufen würde und man nicht sähe wie der Schuss sich bewegt.
Um bei dem Beispiel mit dem Schuss zu bleiben: Es ist also notwendig sich die aktuelle Position zu merken und bei jedem mal wenn step() aufgerufen wird den Schuss um ein Stück (skaliert mit dem Zeitfaktor) vorwärts zu bewegen.
Dass sowohl die draw() als auch die step()-Methode in der MainWindow-Klasse liegen ist nur im Augenblick noch so – das wird sich bald ändern. Doch für den Anfang wollte ich noch nicht zu komplex werden.
Die Beispiel-step()-Methode in der MainWindow-Klasse wird nun implementiert und erhöht einen Winkel in Abhängigkeit von dem übergebenen Zeitfaktor:
public void step(double factor) {
angle += factor * 0.5;
}
Die beiden Methoden init() und display() aus dem GLEventListener Interface werden nun auch mit mehr Leben gefüllt:
public void display(GLAutoDrawable drawable) {
GL gl = drawable.getGL();
// Clear screen
gl.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT);
// Reset transformatin
gl.glLoadIdentity();
// Rotate to the angle around the axis (0.3 / 1 / 0.2)
gl.glRotated(angle, 0.3, 1, 0.2);
// Draw a Teapot with the size 6
glut.glutSolidTeapot(6.0);
}
public void init(GLAutoDrawable drawable) {
GL gl = drawable.getGL();
// This is the background color
gl.glClearColor(0.2f, 0.6f, 1.0f, 0.0f);
// Enable lighting in general and the first light
gl.glEnable(GL.GL_LIGHTING);
gl.glEnable(GL.GL_LIGHT0);
// Enable depth testing
gl.glEnable(GL.GL_DEPTH_TEST);
// Install a projection
gl.glMatrixMode(GL.GL_PROJECTION);
gl.glLoadIdentity();
gl.glFrustum(-0.1, 0.1, -0.1, 0.1, 0.1, 100.0);
gl.glTranslated(0.0, 0.0, -20.0);
gl.glMatrixMode(GL.GL_MODELVIEW);
gl.glLoadIdentity();
}
Download und Zusammenfassung
Das Beispiel wie wir es nun bis zu diesem Zeitpunkt vorliegen haben, enthält fast alle wichtigen Elemente eines Spiels: In einer Schleife wird pausenlos das Fenster neu gezeichnet und der Zustand aktualisiert (in unserem Beispiel: Der Drehwinkel erhöht), die Verwaltung der Anzeige ist implementiert und das Programm pausiert wenn das Fenster nicht aktiv ist.
Die wichtigste Komponente eines Spiels fehlt allerdings noch: Die Eingabe! Derzeit ist das Programm eher eine Grafikdemo als ein Spiel. Das Behandeln von Eingaben bespreche ich im nächsten Tutorial.
Hier könnt ihr den Quelltext des Beispielprogramms herunterladen
Tutorial Game Loop: Example Source (67)
und damit experimentieren. Die Dateien müssen alle in das Projektverzeichnis von Eclipse entpackt werden. Den cfg-Config Ordner legt ihr am besten vorher schon an und kopiert anschließend die Dateien aus dem ZIP-Archiv einfach hinein.
Fazit
Spiele (vor allem Action Spiele) enthalten meistens eine Hauptschleife die Game Loop genannt wird. Sie ruft in schneller Folge die step() und die draw() Methoden auf. Die step() Methode führt einen Berechnungsschritt für das Spiel durch, während die draw()-Methode den aktuellen Spielzustand auf den Bildschirm zeichnet. Der step() Methode wird ein Geschwindigkeitsfaktor übergeben, so dass auf schnelleren Rechnern das Spiel in der gleichen Geschwindigkeit abläuft.

