1 Spieleweltgestaltung in drei Dimensionen
Obwohl unsere zuletzt erzeugten Landschaften bereits recht ansprechend und abwechslungsreich wirkten, kamen bei ihrer Berechnung bislang lediglich zweidimensionale Noise-Funktionen zum Einsatz. Heute werden wir uns damit befassen, wie sich die zugrunde liegenden Konzepte um eine zusätzliche Dimension erweitern lassen – denn erst mithilfe von 3D-Noise-Funktionen und 3-D-Random-Walk-Simulationen wird es möglich, dreidimensionale Geländeformationen, Höhlensysteme oder unterirdische Rohstofflagerstätten zu generieren.
Continuous Development ist das Stichwort – dieses Kapitel steht voll und ganz im Zeichen der Weiterentwicklung der von uns bislang eingesetzten prozeduralen Gestaltungstechniken und Beleuchtungsmodelle. Im ersten shortcut „Erfolgreiche Spieleentwicklung. Minecraft-Welten erschaffen“ sind wir darauf zu sprechen gekommen, wie sich unterschiedliche, vom Breitengrad abhängige Landschaftstypen und Vegetationsformen auf mathematischem Wege erzeugen lassen. Wir haben ein einfaches CPU-basiertes Verfahren besprochen, mit dessen Hilfe sich die Ausbreitung des indirekten Tageslichts halbwegs realistisch simulieren lässt. Auch haben wir uns mit den wichtigsten Einzelheiten einer von der Jahreszeit abhängigen Landschafts- und Vegetationsdarstellung auseinandergesetzt.
So weit, so gut. Bisher haben wir uns damit in unserem Spieleprototyp jedoch ausschließlich auf die Oberflächengestaltung und -darstellung beschränkt. Der Aufbau einer blockbasierten, von Minecraft inspirierten Welt lässt sich allerdings nicht mit den Spielewelten anderer Titel vergleichen, in denen eine Landschaft im Prinzip nichts weiter als eine in der xz-Ebene definierte Fläche ist, deren Höhenwerte man vorzugsweise unter Zuhilfenahme einer Height-Map von Ort zu Ort variiert. Sieht man einmal von der Tatsache ab, dass wir anstelle von Height-Maps auf 2D-Noise-Funktionen (2-D-Rauschen) zurückgegriffen haben, um das Höhenprofil unserer prozeduralen Landschaften zu verändern oder um die Spielewelt in unterschiedliche Regionen aufzuteilen, dann sind wir bisher auf eine ganz ähnliche Weise vorgegangen. Auch das von uns verwendete Beleuchtungsmodell ist noch extrem verbesserungswürdig, da wir bislang lediglich die Ausbreitung des indirekten Tageslichts simuliert haben. Eine zweite natürliche Lichtquelle, die uns im weiteren Verlauf noch häufiger begegnen wird, haben wir indes vollkommen außer Acht gelassen: Lava.
Aber keine Sorge, weder was die Beleuchtungsberechnungen, noch was die prozedurale Generierung der Spielewelt betrifft, müssen wir wieder von vorne beginnen. Betrachten Sie einfach die bislang erzeugten Landschaften als eine Art Zwischenergebnis, das es in weiteren Schritten mehr oder weniger umfangreich zu überarbeiten gilt. So lassen sich beispielsweise Höhlensysteme realisieren, in denen man das Felsgestein durch unterschiedlich große Hohlräume (Caves) und Verbindungsgänge (Cave Passageways) ersetzt. Für die Ausformung der Hohlräume bietet sich der Einsatz von dreidimensionalen Noise-Funktionen an. Die Verbindungsgänge können wir hingegen auf ähnliche Weise wie die Rohstofflagerstätten (hierauf deutet nicht zuletzt die alternative Bezeichnung Erzader hin) mithilfe von einfachen Random-Walk-Simulationen verwirklichen. Was die Erschaffung von zusätzlichen dreidimensionalen Geländeformationen betrifft, setzen wir selbstredend wiederum auf 3D-Noise-Berechnungen, auch wenn wir in diesem Zusammenhang zwei unterschiedliche Strategien verfolgen. Einerseits werden wir in ausgewählten Regionen die zuvor generierte 2D-Noise-Landschaft durch neue 3D-Noise-basierte Strukturen ersetzen.
Unabhängig davon orientieren wir uns bei der Modellierung von einer Reihe weiterer Terraindetails zudem an der Arbeitsweise eines Bildhauers. Hierfür überziehen wir unser 2D-Noise-Terrain mit einer zusätzlichen Schicht von Blöcken, aus der wir dann im zweiten Schritt die gewünschten Geländestrukturen ausformen (ausmeißeln) können. Und selbstverständlich werden wir in diesem Zusammenhang auch auf die Probleme und mögliche Lösungen eingehen, die mit dem Einsatz von 3D-Noise-Funktionen bei der Oberflächengestaltung zwangsläufig einhergehen: schwebende Blöcke und Inseln, die allenfalls in den aus dem Science-Fiction-Film Avatar bekannten Dschungellandschaften Pandoras ihre Daseinsberechtigung haben.
3D-Noise-Funktionen
1-D-, 2-D-, 3-D- oder gar 4-D-Rauschen – auf den ersten Blick klingt das alles überaus mathematisch und anfangs sogar ein wenig verwirrend, obwohl wir bereits einige Erfahrungen auf diesem Gebiet gesammelt haben. Die Einsatzmöglichkeiten von 2D-Noise-Funktionen im Rahmen der prozeduralen Landschaftsgestaltung beherrschen wir mittlerweile im Schlaf und wissen, wie sich in Gestalt von Value, Gradient oder Cell Noise drei ganz unterschiedliche Arten von Rauschwerten berechnen lassen. Worin unterscheiden sich jetzt aber zweidimensionale von dreidimensionalen oder gar vierdimensionalen Rauschwerten? Bevor wir an dieser Stelle allzu sehr ins Detail gehen, sollten wir zunächst einmal festhalten, dass es sich in allen Fällen um positions- bzw. zeitabhängige Zufallszahlen handelt. Eine 1D-Noise-Funktion, der wir die Systemzeit oder die momentane Spieldauer als Parameter übergeben, wäre beispielsweise nichts anderes als ein zeitabhängiger Zufallszahlengenerator. Mit den uns bereits vertrauten 2D-Noise-Funktionen können wir hingegen für jeden Punkt einer zunächst flachen Landschaft (in unserem Demoprogramm liegt diese in der xz-Ebene) einen Höhenwert (Rauschwert) ermitteln. Übergibt man einer 3D-Noise-Funktion als Parameter eine dreidimensionale Positionsangabe, so lässt sich der zugehörige Rauschwert als Dichte interpretieren: Ist die Dichte in einer blockbasierten Spielewelt an einem Ort zu klein, wird ein eventuell vorhandener Block entfernt; ist die Dichte hingegen groß genug, wird stattdessen ein neuer Block hinzugefügt. Würde man jedoch stattdessen eine zweidimensionale Positionsangabe mit einer zusätzlichen Zeitangabe kombinieren, könnte man mithilfe einer 3D-Noise-Funktion beispielsweise eine zweidimensionale Wasserfläche animieren. Dreidimensionale Noise-basierte Strukturen wie Wolken oder Rausch lassen sich im Unterschied dazu mit einer 4D-Noise-Funktion zum Leben erwecken. Die Berechnung der zugehörigen Rauschwerte erfordert in diesem Fall – Sie ahnen es bereits – sowohl eine dreidimensionale Orts- wie auch eine zusätzliche Zeitangabe.
Die Berechnung der denkbar einfachsten Form des (dreidimensionalen) Rauschens – so genanntes „weißes Rauschen“ ohne eine besondere Struktur – lässt sich anhand der Listings 1.1 und 1.2 am Beispiel der Funktionen RandomValue0To1() sowie GetNewDirection() nachvollziehen. Nun ja, genau genommen ist das Wort „Berechnung“ ein wenig zu hoch gegriffen, da uns die besagten Funktionen in Abhängigkeit von der Blockposition lediglich eine bereits im Vorfeld berechnete Zufallszahl bzw. einen 3-D-Gradientenvektor (Richtungsvektor) zurückliefern. Wie Sie sehen können, verzichten wir aus Performancegründen darauf, die für die Noise-Berechnungen erforderlichen Zufallswerte zur Laufzeit zu erzeugen. Zufallszahlen und Gradientenvektoren (Listing 1.3) berechnen wir stattdessen bereits im Verlauf der Initialisierungsphase und speichern sie dann in zweidimensionalen (2D-Noise-) bzw. dreidimensionalen (3D-Noise-)Arrays ab. Um Zugriff auf die in den Arrays gespeicherten Daten zu erhalten, müssen wir zunächst die als Parameter übergebenen Positionswerte (ix, iy, iz) mithilfe einfacher Modulo-Operationen (hierbei wird der Rest einer Ganzzahldivision ermittelt, beispielsweise -31 % 11 = -9) in die korrespondierenden Array-Indizes (id_x, id_y, id_z) umrechnen.
inline float RandomValue0To1(long ix, long iy, long iz)
{
long id_x = ix % HALF_SURFACE_VALUES_PER_DIR_PLUS1 +
HALF_SURFACE_VALUES_PER_DIR_MINUS1;
long id_y = iy % HALF_SURFACE_VALUES_PER_DIR_PLUS1 +
HALF_SURFACE_VALUES_PER_DIR_MINUS1;
long id_z = iz % HALF_SURFACE_VALUES_PER_DIR_PLUS1 +
HALF_SURFACE_VALUES_PER_DIR_MINUS1;
return SurfaceDataPattern3D[id_x][id_y][id_z];
}
Listing 1.1: Weißes 3-D-Rauschen
inline void GetNewDirection(long ix, long iy, long iz, float* pDirX, float* pDirY, float* pDirZ)
{
long id_x = ix % HALF_SURFACE_VALUES_PER_DIR_PLUS1 +
HALF_SURFACE_VALUES_PER_DIR_MINUS1;
long id_y = iy % HALF_SURFACE_VALUES_PER_DIR_PLUS1 +
HALF_SURFACE_VALUES_PER_DIR_MINUS1;
long id_z = iz % HALF_SURFACE_VALUES_PER_DIR_PLUS1 +
HALF_SURFACE_VALUES_PER_DIR_MINUS1;
*pDirX = SurfaceDataPattern3D_gx[id_x][id_y][id_z];
*pDirY = SurfaceDataPattern3D_gy[id_x][id_y][id_z];
*pDirZ = SurfaceDataPattern3D_gz[id_x][id_y][id_z];
}
Listing 1.2: Richtungsbestimmung bei einer Random-Walk-Simulation
#define SURFACE_VALUES_PER_DIR 21
#define HALF_SURFACE_VALUES_PER_DIR_PLUS1 11 // (21 + 1) / 2
#define HALF_SURFACE_VALUES_PER_DIR_MINUS1 10 // (21 - 1) / 2
// for 3D Gradient Noise Calculations:
float SurfaceDataPattern3D_gx[SURFACE_VALUES_PER_DIR]
[SURFACE_VALUES_PER_DIR]
[SURFACE_VALUES_PER_DIR];
float SurfaceDataPattern3D_gy[SURFACE_VALUES_PER_DIR]
[SURFACE_VALUES_PER_DIR]
...