© DrHitch/Shutterstock.com
Erfolgreiche Spieleentwicklung

3 Einführung in die prozedurale Landschaftsgestaltung


In diesem Kapitel werden wir uns mit den Grundlagen der prozeduralen Landschaftsgestaltung auseinandersetzen und in diesem Zusammenhang die Geheimnisse des Tile-basierten Texture Mappings, der Zufallszahlen und Noise-Berechnungen (Rauschen) enthüllen.

Eines haben die letzten beiden Kapitel zweifelsohne gezeigt: man sollte sich durch die Schlichtheit und den gewöhnungsbedürftigen Anblick eines durch Minecraft inspirierten Spiels nicht täuschen lassen. Die Herausforderungen hinsichtlich der Speicherverwaltung, des Renderings sowie der zugehörigen Programmabläufe sind nicht zu unterschätzen. Im letzten Kapitel haben wir demonstriert, wie man die im Vorfeld erforderlichen Berechnungsschritte auf mehrere Worker-Threads verteilen kann. Zur Vermeidung potenzieller Speicherprobleme haben wir uns dazu entschlossen, die komplette Spielewelt in lauflängenkodierter Form im Speicher zu verwalten. Bedenken Sie, dass bereits eine verhältnismäßig kleine Minecraft-Welt mit einer quadratischen Ausdehnung von 3 000 Metern und einer Höhe von nur 256 Metern genügend Raum für 2,304 Milliarden Blöcke mit einer Kantenlänge von jeweils einem Meter (3 000*3 000*2 56 = 2 304 000 000) bietet. Würde man nun für die Beschreibung der jeweiligen Blockeigenschaften auf eine 1-Byte-Variable vom Datentyp unsigned __int8 zurückgreifen (die Position eines Blocks in der Spielewelt ergibt sich aus der Position im zugehörigen unsigned __int8-Array), entspräche dies einem Gesamtspeicherbedarf von 2,304 GB. Lediglich der darzustellende Bereich, der vom Spieler nach Belieben umgestaltet werden kann (hierbei greifen wir auf ein Ray-Casting-Verfahren zurück), wird in unkomprimierter Form im Speicher gehalten.

Im zweiten Kapitel haben Sie darüber hinaus ein einfaches Verfahren kennengelernt, mit dessen Hilfe man die Lichtausbreitung in einer blockbasierten Welt auch ohne umfangreiche mathematische Berechnungen oder komplizierte physikalische Theorien auf der CPU simulieren kann. Stattdessen bedarf es lediglich dreier einfacher Annahmen:

  • Im Outdoorbereich ist die Helligkeit maximal, sofern man auf die Darstellung etwaiger Schatten verzichtet.
  • In den Innenräumen – ob es sich dabei um Gebäude oder Höhlen handelt, spielt keine Rolle – ist die Helligkeit minimal, sofern der Lichteinfall von allen Seiten blockiert wird.
  • Sollte der Lichteinfall nicht vollständig blockiert sein, nimmt die Lichtintensität mit zunehmendem Abstand vom Eintrittspunkt (z. B. einem Fenster oder einem Höhleneingang) schrittweise ab.

Damit wir die Ergebnisse unserer Lichtausbreitungssimulation im späteren Verlauf im Rahmen des Deferred Lightings berücksichtigen können, müssen wir die auf der CPU berechneten Helligkeitswerte bei der Darstellung der Spieleweltblöcke im zugehörigen Fragment-Shader-Programm lediglich ein wenig überarbeiten (sprunghafte Helligkeitsänderungen lassen sich durch Mittelwertbildung sichtlich verringern) und in einem Render-Target zwischenspeichern.

Sämtliche Instanzen der einzelnen Blockoberflächen – für jede der sechs Flächen verwenden wir ein separates Mesh – können wir dank Geometry Instancing mit nur einem einzigen Draw Call rendern. Zumindest gilt das, sofern man alle für die Darstellung erforderlichen Daten, also die Positionen der Blöcke in der Spielewelt sowie die Indices der zu verwendenden Texturen, in einem Texture-Buffer-Objekt zwischenspeichert. Darüber hinaus verwenden wir anstelle von einfachen Texturen so genannte Texture-Array-Objekte, was die Anzahl der erforderlichen Texturwechsel auf ein Minimum reduziert.

Da uns bei einer für das Spielvergnügen optimalen Bildfrequenz von 60 Frames pro Sekunde gerade einmal 0,0166 Sekunden für die Berechnung und Darstellung eines neuen Szenenbilds zur Verfügung stehen, sind wir ferner dazu gezwungen, die einzelnen Programmabläufe auf mehrere Threads aufzuteilen. Während der Hauptprogrammthread für die Szenendarstellung, die Programmablaufsteuerung (hierzu gehört unter anderem auch die Threadsynchronisierung) sowie die Weiterverarbeitung der Maus- und Tastatureingaben verantwortlich ist, werden die Sichtbarkeitstests, die Aktualisierung der Umgebungsinformationen und die Simulation der Lichtausbreitung in drei zusätzliche Worker-Threads ausgelagert. In diesem Zusammenhang bedienen wir uns eines raffinierten Tricks, mit dessen Hilfe sich sämtliche Worker-Thread-Programmabläufe von der Framerate abkoppeln lassen. So können sich die zugehörigen Berechnungen problemlos über mehrere Frames erstrecken. Bei dem von uns als Parallel Buffering bezeichneten Verfahren gilt es zu verhindern, dass ein Worker-Thread einen im Verlauf der Szenendarstellung genutzten Datensatz zwischenzeitlich überschreiben kann. Darum nutzen wir, ähnlich wie beim Double Buffering, zwei unabhängige Daten-Arrays sowie eine zusätzliche Zeigervariable, in der wir die Adresse des jeweils beim Rendering zu berücksichtigenden Arrays speichern. Sobald die Aktualisierung des bei der Szenendarstellung nicht genutzten Daten-Arrays abgeschlossen ist, müssen wir aus dem Hauptprogramm heraus lediglich die in der Zeigervariablen gespeicherte Adresse ändern. Beim nächsten Renderdurchgang kann so auf die Daten des aktualisierten Arrays zugegriffen werden.

Tile-based Texture Mapping

Die Texturierung der Spielewelt ist immer auch ein Kampf gegen die Uniformität. Da Speicherplatz nun mal eine begrenzte Ressource ist, ist man dazu gezwungen, Texturen mehrfach zu verwenden. Im Prinzip gibt es gegen diese Vorgehensweise auch nichts einzuwenden, jedenfalls dann nicht, wenn dem Spieler das Recycling verborgen bleibt. Leider gelten in einer blockbasierten Spielewelt etwas andere Gesetze. Betrachten Sie doch einmal den in Abbildung 3.3 gezeigten Screenshot einer bereits veralteten Demoprogrammversion. Die dort gezeigten Grünflächen sind das Resultat einer großen Zahl aneinandergereihter Grasblöcke. Da wir jedoch sämtliche Blöcke mit der gleichen Textur überzogen haben, wiederholen sich die dargestellten Oberflächendetails in steter Regelmäßigkeit. Um das Problem zu kaschieren, könnte man nun einfach die verwendeten Texturen ein wenig überarbeiten und in diesem Zusammenhang die besonders hervorstechenden Oberflächendetails entfernen. Im Rahmen unserer Demoanwendung gehen wir jedoch noch einen Schritt weiter und versuchen, das Problem auf technische Weise zu bekämpfen. Zu diesem Zweck erstellen wir, wie in Abbildung 3.4 gezeigt, für jeden Blocktyp eine Materialtextur, die sich ihrerseits aus 16 unterschiedlichen Textur-Tiles (Texturkacheln) zusammensetzt. In Abhängigkeit von der jeweiligen Blockposition in der Spielewelt wird nun nach dem Zufallsprinzip ermittelt, welche Texturkachel für die Texturierung einer Blockoberfläche verwendet werden soll. Wie Sie anhand der Abbildungen 3.1 und 3.2 erkennen können, wirken die dort gezeigten Landschaften dank unseres kleinen Tricks bedeutend abwechslungsreicher, da nun für jeden Blocktyp (z. B. Gras, Sand usw.) 16 Texturen zur Auswahl stehen, die sich nach Belieben nahtlos aneinanderreihen lassen. Die einzelnen Arbeitsschritte, die beim Erstellen einer solchen Materialtextur erforderlich sind, können Sie mithilfe der Abbildung 3.5 nachvollziehen.

rudolph_welten_1.jpg

Abbildung 3.1: Blockbasierte Spielewelt (frühe Demoprogrammversion)

rudolph_welten_2.jpg

Abbildung 3.2: Blockbasierte Spielewelt (frühe Demoprogrammversion)

rudolph_welten_3.jpg

Abbildung 3.3: Blockbasierte Spielewelt (jede Materialtextur besteht aus einer einzelnen Texturkachel)

rudolph_welten_4.jpg

Abbildung 3.4: Texturierung einer blockbasierten Spielewelt

rudolph_welten_5.jpg

Abbildung 3.5: Erstellen einer Materialtextur (Workflow)

Im ersten Schritt generieren wir zunächst 16 Textur-Tiles mit einer Auflösung von 8 x 8 Pixeln. Hierbei müssen wir beachten, dass die farbliche Abfolge der Randpixel bei allen Texturkacheln identisch ist, damit sich diese beim späteren Zusammenbau der Materialtextur nahtlos aneinanderreihen lassen. Da die zu texturierenden Blockflächen eine Kantenlänge von einem Meter haben, erstreckt sich jedes Pixel über eine Fläche von 12,5 * 12,5 Quadratzentimetern (bzw. 1/8 * 1/8 Quadratmetern). Im zweiten Schritt vergrößern wir die einzelnen Textur-Tiles auf eine Auflösung von 128 x 128 Pixel und generieren mit einem Weichzeichner (Farbfilter) einen kontinuierlichen Farbverlauf, den man im fertigen Spiel bereits aus großer Distanz erkennen kann (Abb. 3.1 und 3.2). Im dritten Schritt fügen wir die zuvor überarbeiteten Texturkacheln zu einer 512 x 512 Pixel großen Materialtextur zusammen und versehen diese abschließend mit einer zusätzlichen Feinstruktur, die sich lediglich aus kurzer Distanz wahrnehmen lässt. Um sicherzustellen, dass unser Spiel auch auf Computern mit nicht ganz so leistungsstarken Grafikkarten lauffähig bleibt, erstellen wir zusätzlich einen zweiten Satz von Materialtexturen mit halbierter Auflösung.

Damit nun im finalen Spiel nicht die komplette Materialtextur auf die einzelnen Blockoberflächen gemappt wird, müssen wir zudem die Texturkoordinaten (tu , tv) der einzelnen Block-Meshes überarbeiten. Zu diesem Zweck ändern wir die jeweiligen Werte dahingehend ab, dass die Blöcke standardmäßig mit der ersten Texturkachel, die sich oben links in der Materialtextur findet, überzogen werden:

  • Vertex 1: (0.0, 0.0)
  • Vertex 2: (0.25, 0.0)
  • Vertex 3: (0.25, 0.25)
  • Vertex 4: (0.0, 0.25)
[...]
uniform samplerBuffer InstancesTBO;

const vec2 TexOffsetArray[16] = vec2[16](vec2(0.0, 0.0), vec2(0.25, 0.0),
vec2(0.5, 0.0), vec2(0.75, 0.0), ...,
vec2(0.0, 0.75), vec2(0.25, 0.75),
vec2(0.5, 0.75), vec2(0.75, 0.75));

void main()
{
vec4 BlockData = texelFetch(InstancesTBO, gl_InstanceID);

// ID-Wert der Materialtextur:
float TextureID = mod(BlockData.w, 100);

// Offset-Wertepaar für den Zugriff auf das gewünschte Texture-Tile:
vec2 TexOffset = TexOffsetArray[int(BlockData.w-TextureID)/100];

gs_TexCoord[0] = vec4(gs_MultiTexCoord0.xy + TexOffset
/*Texture Coordinates*/, TextureID, 1.0);

[...]
}

Listing 3.1: Tile-based Texture Mapping – Berechnung der Textur-ID sowie der zugehörigen Texturkoordinaten (Vertex Shader)

Die eigentlichen Texturkoordinaten werden erst zur Laufzeit innerhalb des für Geometriedarstellung verantwortlichen Vertex-Shader-Programms (Listing 3.1) mithilfe der im nachfolgenden Array gespeicherten Offset-Wertepaare berechnet:

const vec2 TexOffsetArray[16] = vec2[16](vec2(0.0, 0.0), vec2(0.25, 0.0),
vec2(0.5, 0.0), vec2(0.75, 0.0), ...,
vec2(0.0, 0.75), vec2(0.25, 0.75),
vec2(0.5, 0.75), vec2(0.75, 0.75));

Möchte man beispielsweise eine Blockoberfläche mit der Texturkachel Nr. 16 (diese findet sich unten rechts in der Materialtextur) texturieren, müssen wir hierfür lediglich das Offset-Wertepaar (0.75, 0.75) zu den im Vertex Buffer gespeicherten Texturkoordinaten hinzuaddieren:

vec2 TexOffset = TexOffsetArray[15];
gs_TexCoord[0] = vec4(gs_MultiTexCoord0.xy + TexOffset
/*Texture Coordinates*/, TextureID, 1.0);

Daraus ergibt sich:

  • Vertex 1: (0.0, 0.0) + (0.75, 0.75) = (0.75, 0.75)
  • Vertex 2: (0.25, 0.0) + (0.75, 0.75) = (1.0, 0.75)
  • Vertex 3: (0.25, 0.25) + (0.75, 0.75) = (1.0, 1.0)
  • Vertex 4: (0.0, 0.25) + (0.75, 0.75) = (0.75, 1.0)

So weit, so gut. Die Sache hat jedoch einen kleinen Haken. Bisher haben wir innerhalb des Vertex Shaders lediglich Zugriff auf die Blockpositionen sowie die Indices der zu verwendenden Texturen. Welches Offset-Wertepaar für die Berechnung der Texturkoordinaten benötigt wird, ist hingegen nirgends gespeichert. Da man den Speicherbedarf des im Rahmen des Geometry Instancings genutzten Texture-Buffer-Objekts nicht zusätzlich vergrößern sollte, nutzen wir einen kleinen Trick, mit dessen Hilfe wir den Materialtexturindex und den Offset-Array-Index zu einem einzigen Zahlenwert zus...

Neugierig geworden? Wir haben diese Angebote für dich:

Angebote für Teams

Für Firmen haben wir individuelle Teamlizenzen. Wir erstellen Ihnen gerne ein passendes Angebot.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang