© DrHitch/Shutterstock.com
Erfolgreiche Spieleentwicklung

2 Programmabläufe und Rendering-Techniken in Minecraft-Klonen


Nach der ganzen theoretischen Vorarbeit kommen wir nun auf die wichtigsten Programmabläufe in unserer Demoanwendung zu sprechen. In diesem Zusammenhang werden wir darauf eingehen, wie sich Minecraft-Welten dank moderner OpenGL-Rendering-Techniken ruckelfrei darstellen lassen und wie man die im Vorfeld erforderlichen Berechnungsschritte auf mehrere Worker-Threads verteilen kann.

Das Ganze ist mehr als die Summe seiner Teile. Für sich genommen ist ein einzelner Block wohl das primitivste aller gestalterischen Elemente. Und gerade weil das so ist, weil der Umgang mit diesem „Baumaterial“ so einfach zu erlernen ist, kann im Open-World-Spiel Minecraft praktisch jeder, der über genügend Fantasie und Zeit verfügt, zu einem Konstrukteur, Architekten, Höhlenforscher, Städte- oder Landschaftsplaner avancieren. Möchte man als Programmierer ein ähnliches Spiel entwickeln, sollte man sich nicht durch die Schlichtheit und den gewöhnungsbedürftigen Anblick der blockbasierten Welten täuschen lassen. Zur Erinnerung: Die möglichen Probleme hinsichtlich Rendering und Speicherverwaltung haben wir uns bereits im Kapitel in der letzten Ausgabe anhand der nachfolgend gezeigten Rechnung veranschaulicht:

  • Eine blockbasierte Welt mit einer Länge und Breite von jeweils 2 000 und einer Höhe von 256 Metern bietet genügend Raum für 1 024 Milliarden Blöcke mit einer Kantenlänge von jeweils einem Meter (2 000*2 000*256).
  • Greift man für die Beschreibung der jeweiligen Blockeigenschaften auf eine 1-Byte-Variable vom Datentyp unsigned __int8 zurück (die Position eines Blocks in der Spielewelt ergibt sich aus der Position im zugehörigen unsigned __int8-Array), entspricht das einem Gesamtspeicherbedarf von 1 024 GB.

Zur Lösung des Speicherproblems greifen wir in unserem Demoprogramm auf ein als Lauflängenkodierung (englische Bezeichnung: Run-Length Encoding, kurz RLE) bekanntes Komprimierungsverfahren zurück. Dank dieser Methode benötigen wir dann beispielsweise für die Beschreibung einer vollkommen flachen Spielewelt lediglich vier einfache Zahlenwerte, sofern der Untergrund aus gleichartigen Blöcken besteht:

  • Die Description-ID (mit anderen Worten: den Blocktyp) des Bodenblocks sowie die Anzahl der Bodenblöcke
  • Die Description-ID des Luftblocks sowie die Anzahl der Luftblöcke

Einzig der momentan sichtbare Ausschnitt der Spielewelt wird in unkomprimierter Form in einem Array vom Typ unsigned __int8 abgespeichert, das wir, in Abhängigkeit von der Bewegung des Spielers, kontinuierlich aktualisieren müssen. Der Zugriff auf diesen im Demoprogramm als LocalBlockArray bzw. Umgebungs-Array bezeichneten Datensatz erfolgt mithilfe der Zeigervariable BlockID_Int* pLocalBlockArray (Hinweis: typedef unsigned __int8 BlockID_Int). Die Gesamtheit aller Daten der Spielewelt speichern wir hingegen sowohl auf der Festplatte als auch im Hauptspeicher in längenkodierter Form ab. Indem wir die längenkodierten Daten zudem in einzelne Chunks (Teilbereiche der Spielewelt) unterteilen, lässt sich sowohl die Aktualisierung der Umgebungsinformationen als auch die dynamische Modifizierung der Spielewelt seitens des Spielers erheblich beschleunigen. Zum besseren Verständnis: Lediglich diejenigen Chunks, die sich innerhalb des sichtbaren Bereichs der Spielewelt befinden, müssen beim Update des Umgebungs-Arrays bzw. bei der Interaktion mit der Spielewelt berücksichtigt werden. Die übrigen Teilbereiche können hingegen vernachlässigt werden.

Es werde Licht

Zugegeben, ohne Installation einer zusätzlichen Shader-Modifikation (Beispiel: Sonic Ether's Unbelievable Shaders) oder eines High-Resolution Texture Packs sehen die Minecraft-Welten schon ein wenig altbacken aus. Dennoch ist das zum Einsatz kommende Beleuchtungsmodell weit weniger primitiv als man zunächst denken mag. Entfernt man beispielsweise, wie in Abbildung 2.1 gezeigt, einige Blöcke aus einer Wand oder Decke, dann dringt plötzlich Licht in einen vormals dunklen Raum.

rudolph_minecraft_1.jpg

Abbildung 2.1: Lichtausbreitung in der blockbasierten Spielewelt (frühe Demoprogrammversion)

Auf welche Art und Weise können wir nun diese Form der Lichtausbreitung nachbilden? Hätten wir es mit einer statischen Spielewelt zu tun, ließen sich die Lichtverhältnisse unter anderem mithilfe von im Vorfeld berechneten Light Maps simulieren. Beim Ausleuchten einer dynamisch veränderbaren Umgebung bringt uns diese Vorgehensweise hingegen nicht wirklich weiter, da sich die Lichtverhältnisse jederzeit – beispielsweise infolge der Bautätigkeit seitens des Spielers – ändern können. Auch müssen wir uns darüber Gedanken machen, wie sich die erforderlichen Beleuchtungsberechnungen in ein modernes Deferred-Rendering-Framework integrieren lassen. Auf die klassische Szenendarstellung, bei der man die Geometrieverarbeitung und Beleuchtung zeitgleich im Rahmen des Forward Renderings durchführt, werden wir in unserem Demoprogramm verzichten. Bei detailreichen und weitläufigen 3-D-Szenen würden wir hierbei lediglich eine Menge GPU-Rechenleistung unnötig verschwenden, da nicht wenige der im Verlauf der Geometriedarstellung beleuchten Oberflächen am Ende teilweise oder gar vollständig verdeckt sind. Erfolgen die Beleuchtungsberechnungen hingegen getrennt von der Geometriedarstellung im Verlauf der Post-Processing-Phase (Deferred Lighting), dann ist der erforderliche Rechenaufwand unabhängig von der Szenenkomplexität, da wir zu diesem Zeitpunkt lediglich diejenigen Pixel berücksichtigen müssen, die auch wirklich sichtbar sind. Dank der eingesparten Rechenleistung können wir nun unter anderem bei der Ausleuchtung der Spielewelt eine deutlich größere Anzahl von Lichtquellen verwenden, als das beim Forward Rendering möglich wäre. Zum Speichern der für das Deferred Lighting erforderlichen Geometrie- und Farbinformationen – auf die wir selbstverständlich auch bei der Implementierung weiterer Post-Processing-Effekte zurückgreifen können (z. B. Screen Space Ambient Occlusion, Unschärfeeffekte usw.) – nutzen wir in unserem OpenGL-Framework die nachfolgenden Render Targets (Renderziele, Texturen), die ihrerseits an ein in der Literatur als G-Buffer (Geometry Buffer) bezeichnetes Frame-Buffer-Objekt (PrimaryScreenFrameBuffer) gebunden sind:

  • Textur 1: Farbe der texturierten, unbeleuchteten, sichtbaren Szenenpixel (PrimaryScreenTexture)
  • Textur 2: Kameraraumpositionen und Tiefenwerte der sichtbaren Szenenpixel (SceneCameraSpacePosAndDepthTexture)
  • Textur 3: Kameraraumnormalen der sichtbaren Szenenpixel (CameraViewNormalTexture)
  • Textur 4: Reflexionsvermögen (Farbe und Intensität) der sichtbaren Szenenpixel (CameraViewSpecularTexture)
  • Textur 5: Zusätzliche Lichtfarbe der (z. B. mit einer Light Map texturierten und daher) selbstleuchtenden Szenenpixel (CameraViewEmissiveTexture)

Wie genau sich die jeweiligen Geometrie- und Farbinformationen bei der Darstellung der einzelnen Spieleweltblöcke in den jeweiligen Render Targets speichern lassen, können Sie anhand des in Listing 2.4 gezeigten Fragment-Shader-Programms (OpaqueBlockRenderingMediumPlusQuality.frag) nachvollziehen. An dieser Stelle richten wir unsere Aufmerksamkeit zunächst jedoch auf den in Listing 2.1 skizzierten Fragment Shader (OptimizedScreenSpaceLighting.frag), der im Rahmen des Deferred Lightings für die Beleuchtung der 3-D-Szene verantwortlich ist.

#version 330
precision highp float;

in vec4 gs_TexCoord[8];
out vec4 gs_FragColor;

uniform sampler2D ScreenTexture;
uniform sampler2D EmissiveTexture;
uniform sampler2D NormalTexture;
uniform sampler2D SpecularTexture;
uniform sampler2D CameraSpacePositionTexture;

uniform vec3 ViewDirection;

#define NumLightsMax 20

uniform vec4 LightCameraSpacePosAndRange[NumLightsMax];
uniform vec4 LightColor[NumLightsMax];
uniform vec3 NegLightDir[NumLightsMax];
uniform int NumLightsUsed;
uniform int NumDirectionalLightsUsed;
uniform vec4 AmbientLightColor;

void main()
{
if(NumLightsUsed == 0)
gs_FragColor = texture(ScreenTexture, gs_TexCoord[0].st) +
texture(EmissiveTexture, gs_TexCoord[0].st);
else // mindestens eine Lichtquelle
{
vec3 Normal = texture(NormalTexture, gs_TexCoord[0].st).rgb;

// keine Normale => keine Beleuchtung möglich
if(Normal.r < 0.01 && Normal.g < 0.01 && Normal.b < 0.01)
{
gs_FragColor = texture(ScreenTexture, gs_TexCoord[0].st) +
texture(EmissiveTexture, gs_TexCoord[0].st);
}
else
{
Normal = 2.0*Normal - vec3(1.0, 1.0, 1.0);

vec3 CameraSpacePos = texture(CameraSpacePositionTexture,
gs_TexCoord[0].st).xyz;


vec4 SpecularColor = texture(SpecularTexture, gs_TexCoord[0].st);
vec4 DiffuseLightColor = vec4(0.0, 0.0, 0.0, 1.0);
vec4 SpecularLightColor = vec4(0.0, 0.0, 0.0, 1.0);

float tempDot;
float SpecularIntensity;
float Dist;
float InvDist;
float DistanceBasedIntensity;
float DistMaxSq;
vec3 NegPointLightDir;

vec4 ScreenColor = texture(ScreenTexture, gs_TexCoord[0].st);

float BrightnessValue = ScreenColor.a;
float AmbientLightWeightFactor = BrightnessValue*BrightnessValue;
float LightDimmingValue = AmbientLightWeightFactor*AmbientLightWeightFactor;

// dunkle Bereiche schrittweise weiter abdunkeln:
LightDimmingValue *= LightInfluence;
LightDimmingValue *= LightInfluence;

// direktionale Beleuchtung:
for(int i = 0; i < NumDirectionalLightsUsed; i++)
{
tempDot = dot(Normal, NegLightDir[i]);

DiffuseLightColor += LightDimmingValue*max(tempDot, 0.0)*LightColor[i];

if(tempDot > -0.7)
{
SpecularIntensity = max(-dot(2.0*tempDot*Normal-NegLightDir[i], ViewDirection),
0.0);

// Stärke der Spiegelungen festlegen:
SpecularIntensity = pow(SpecularIntensity, 36.0*SpecularColor.w);

SpecularLightColor += LightDimmingValue*SpecularIntensity*
SpecularColor*LightColor[i];
}}

// Punktlichtbeleuchtung:
// Hinweis: Auf eine Einschränkung des Einflussbereichs einer Lichtquelle
// mittels Tilierung wird bei den nachfolgenden Berechnungen
//verzichtet:
for(int i = NumDirectionalLightsUsed; i < NumLightsUsed; i++)
{
NegPointLightDir = LightCameraSpacePosAndRange[i].xyz-CameraSpacePos;

if(abs(NegPointLightDir.x) > LightCameraSpacePosAndRange[i].w)
continue;
if(abs(NegPointLightDir.y) > LightCameraSpacePosAndRange[i].w)
continue;
if(abs(NegPointLightDir.z) > LightCameraSpacePosAndRange[i].w)
continue;

Dist = length(NegPointLightDir);

if(Dist > LightCameraSpacePosAndRange[i].w)
continue;

InvDist = 1.0/Dist;
NegPointLightDir *= InvDist;

DistMaxSq = LightCameraSpacePosAndRange[i].w*LightCameraSpacePosAndRange[i].w;
DistanceBasedIntensity = max(0.0, (DistMaxSq-Dist*Dist)/DistMaxSq);

tempDot = dot(Normal, NegPointLightDir);

DiffuseLightColor += LightDimmingValue*DistanceBasedIntensity*
max(tempDot, 0.0)*LightColor[i];

if(tempDot > -0.7)
{
SpecularIntensity = max(-dot(2.0*tempDot*Normal-NegPointLightDir,
ViewDirection), 0.0);

// Stärke der Spiegelungen festlegen:
SpecularIntensity = pow(SpecularIntensity, 36.0*SpecularColor.w);

SpecularLightColor += LightDimmingValue*DistanceBasedIntensity*
SpecularIntensity*SpecularColor*LightColor[i];
}} // alle Lichtquellen abgearbeitet

float MinAmbientColorValue = AmbientLightColor.r;
MinAmbientColorValue = min(MinAmbientColorValue, AmbientLightColor.g);
MinAmbientColorValue = min(MinAmbientColorValue, AmbientLightColor.b);
MinAmbientColorValue *= BrightnessValue;
vec4 MinAmbientLightColor = vec4(MinAmbientColorValue);

gs_FragColor = SpecularLightColor +
texture(EmissiveTexture, gs_TexCoord[0].st) +
ScreenColor*(DiffuseLightColor +
BrightnessValue*((1.0-AmbientLightWeightFactor)*
MinAmbientLightColor +
AmbientLightWeightFactor*
AmbientLightColor));
}}}

Listing 2.1: Deferred Lighting (OptimizedScreenSpaceLighting.frag)

Um nun die in Abbildung 2.1 gezeigte Lichtausbreitung zu simulieren, könnte man im Umfeld der Deckenöffnung und der einzelnen Fenster – gewissermaßen als Ersatz für die von außen kommenden, bzw. an den Wänden reflektierten LichtpKapitel (Photonen) – eine Vielzahl von kleinen (virtuellen) Punktlichtquellen, Virtual Point Lights (VPLs) genannt, positionieren (Faked Global Illumination). Auf die Implementierung dieser doch sehr rechenintensiven Methode werden wir zunächst jedoch verzichten und stattdessen auf ein deutlich einfacheres Verfahren zurückgreifen, das sich die Geometrie einer blockbasierten Welt zunutze macht und problemlos auf der CPU durchgeführt werden kann. Die Ergebnisse unserer Helligkeitsberechnungen speichern wir im Verlauf der Geometriedarstellung im Alphakanal des PrimaryScreenTexture-Render-Targets ab, um sie später gemäß Listing 2.1 beim Deferred Lighting berücksichtigen zu können:

vec4 ScreenColor = texture(ScreenTexture, gs_TexCoord[0].st);
float BrightnessValue = ScreenColor.a;

Wie sich anhand von Abbildung 2.2 nachvollziehen lässt, verzichten wir bei der Ermittlung der Helligkeitswerte auf umfangreiche mathematische Berechnungen.

rudolph_minecraft_2.jpg

Abbildung 2.2: Lichtausbreitung in der blockbasierten Spielewelt (Funktionsprinzip)

Anstatt die Physik zu bemühen, gehen wir von drei schlichten Annahmen aus:

  • Im Outdoorbereich ist die Helligkeit (BrightnessValue) maximal, sofern man auf die Darstellung etwaiger Schatten verzichtet.
  • In den Innenräumen – ob es sich 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.

Die in der PrimaryScreenTexture gespeicherten Helligkeitswerte dienen ...

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