Renaissance der Weltraumspiele

OpenGL: Einblicke in die prozedurale Generierung von Planeten
Kommentare

„To boldly go where no man has gone before“ (zitiert aus Star Trek) – die Entdeckung und Erforschung unbekannter Welten zählt zu den interessantesten Aspekten in Science-Fiction-Filmen, -Serien und Weltraumspielen. Der vorliegende Artikel vermittelt Ihnen einen Einblick, wie sich unzählige dieser fremdartigen Planeten, ob Gasgiganten, Oasen des Lebens oder ungastliche Einöden, in Echtzeit generieren und darstellen lassen.

Weltraumspiele muten wie ein Relikt aus vergangenen Zeiten an. Von wenigen Ausnahmen einmal abgesehen – hierzu zählen die Spiele aus dem X-Universum sowie Star Trek und Eve Online – galt das Spielegenre bereits seit vielen Jahren als klinisch tot. Das dachte man zumindest so lange, bis Chris Roberts für seine Space Opera Star Citizen sage und schreibe 10 353 618 US-Dollar durch Crowdfunding einsammeln konnte. David Brabens Open-World-Spiel Elite Dangerous rangiert mit immerhin noch respektablen 1 790 361 Britischen Pfund (ca. 2 668 350 US-Dollar) weit abgeschlagen auf dem zweiten Platz.
Bei Licht betrachtet ist das prinzipielle Interesse der Menschen an solchen Spielen alles andere als überraschend. Die Faszination des Weltraums und des Universums geht sogar so weit, dass man sich nahezu täglich eine oder gar mehrere Dokumentationen über extrasolare Planeten bis hin zu schwarzen Löchern im Fernsehen anschauen kann. Tolle Bilder sind jedoch noch lange kein Garant für ein tolles Spielerlebnis. Viele Weltraumspiele sind – leider, leider und nochmals leider – schlicht und ergreifend langweilig. Missionen nach dem Schema „Fliege von Punkt A nach Punkt B und zerstöre dort die feindlichen Schiffe“ sind auf Dauer wenig motivierend, geschweige denn ein Kaufgrund. Für den langfristigen Spielspaß entscheidend ist eine lebendige Spielewelt mit einer Vielzahl von Sonnensystemen, auf deren wirtschaftliches und politisches Geschehen man aktiv Einfluss nehmen kann. Hierbei sollte es nicht darum gehen, einfach nur reich zu werden – Geld um des Geldes wegen zu scheffeln, ist auf Dauer nicht wirklich motivierend. Das langfristige Ziel muss sein, mithilfe des vielen Geldes in die politische Willensbildung eingreifen zu können. Ein Spieler sollte irgendwann dank seines Reichtums genügend Einfluss besitzen, um höchstselbst interstellare Kriege zwischen verschiedenen Völkern auslösen oder beenden zu können.
Was die technische Umsetzung betrifft, wirkten Weltraumspiele lange Zeit grafisch altbacken. Es gab keine interessanten Landschaften zu erkunden, lediglich Weltraum-Hintergrundtapeten sowie Planeten und Monde, deren Oberflächen aus der Nähe betrachtet verwaschen und unscharf wirkten. Dank Geometry Instancing ist es heutzutage jedoch problemlos möglich, volumetrische Nebelwolken und weitläufige Asteroidenfelder zu rendern, und auch die Darstellung von realistisch wirkenden Planetenoberflächen mitsamt der sie umgebenden Atmosphäre hat in den letzten Jahren enorme Fortschritte gemacht.

Planetentexturen oder prozedurale Oberflächen – das ist hier die Frage

Nicht ganz zu unrecht gelten Weltraumspiele vielen Programmierern als idealer Einstieg in die Spieleentwicklung, denn die Darstellung von texturierten Raumschiffmodellen und Planeten ist ohne Frage deutlich einfacher als die Implementierung einer vollwertigen Terrain-Engine. Über die künstlerische Seite muss man sich am Anfang ebenfalls nicht den Kopf zerbrechen, denn kostenlos verwendbare Texturen der Planeten und Monde unseres Sonnensystems findet man im Internet zuhauf. Mit den ersten Schwierigkeiten ist erst bei der Darstellung von extrasolaren Planeten zu rechnen. Früher oder später wird man gezwungen sein, die benötigten Texturen mithilfe eines Bildbearbeitungsprogramms (zum Beispiel mit dem kostenlosen GIMP) in Eigenregie zu erstellen, was insbesondere für den noch ungeübten Grafiker eine recht zeitraubende Angelegenheit ist.
Die nächsten Probleme lassen nicht lange auf sich warten. Irgendwann reift die Erkenntnis, dass sich so große Objekte wie Planeten nur sehr schwer texturieren lassen. Oberflächentexturen, die aus großer Entfernung wirklich gut aussehen, verkommen aus der Nähe betrachtet zu einem unscharfen, verwaschenen Pixelbrei. Texturen mit deutlich größerer Auflösung schaffen nur bedingt Abhilfe, denn sie beanspruchen sehr viele Ressourcen und müssen bei Bedarf dynamisch in den Grafikspeicher geladen beziehungsweise aus diesem entfernt werden – und zwar so, dass der Spieler möglichst nichts davon mitbekommt.

Sofern ein Spiel auch ohne die altbekannten Planeten (Erde, Mars, Jupiter usw.) auskommt, sollte man auf die Verwendung von klassischen Oberflächentexturen zugunsten prozeduraler Techniken verzichten. Moderne Grafikkarten bieten mehr als genügend Leistungsreserven, um Planetenoberflächen in Echtzeit zu erzeugen, die sowohl aus der Distanz wie auch aus der Nähe betrachtet gleichermaßen interessant erscheinen. Auf die genauen Details werden wir im weiteren Verlauf dieses Artikels noch ausführlich eingehen. An dieser Stelle werfen wir zunächst einen Blick auf das den Berechnungen zugrunde liegende Funktionsprinzip:

• Die Erzeugung der Oberflächendetails erfolgt mithilfe von geeigneten mathematischen Funktionen und/oder auf Basis der in einer Noise-(Rausch)-Textur kodierten Zufallswerte. Indem man nun diese Texturen mehrfach und mit unterschiedlich vielen Wiederholungen auf das Planetenmodell mappt, lassen sich sowohl grobe wie auch feine Oberflächendetails erzeugen. Zur Erklärung: Mit zunehmender Wiederholungsrate werden immer feinere Details sichtbar, da sich der von der Noise-Textur abgedeckte Oberflächenbereich immer weiter verkleinert.
• Da sich eine große Anzahl unterschiedlicher Planetenoberflächen mithilfe einer weniger Noise-Texturen generieren lässt, können wir diese in Form eines Texture-Arrays (sampler2DArray) an das verwendete Shader-Programm übergeben und im Verlauf des Rendering-Prozesses auf Performance-beeinträchtigende Texturwechsel verzichten.
• Die Charakterisierung einer Planetenoberfläche (Ausprägung der Höhenverläufe, Klimazonen, Wasserflächen etc.) erfolgt mit einer mehr oder weniger großen Anzahl von Oberflächenparametern (benutzerdefinierte Shader-Parameter, so genannte uniform-Variablen).
• Die Oberflächenberechnung in Echtzeit erfolgt innerhalb eines Fragment-Shader-Programms durch geschicktes Verknüpfen der für jeden Planeten individuell festgelegten Oberflächenparameter mit den im ersten Schritt generierten Oberflächendetails.

3-D-Planetenmodelle

Bei der wohl einfachsten Methode, ein 3-D-Planetenmodell zu definieren, unterteilt man die Kugeloberfläche in n x m Längen- und Breitengrade (beispielsweise in 10°-Schritten) und speichert die Koordinaten der Schnittpunkte der einzelnen Längen- und Breitenkreise in einem Vertex Buffer ab. Für die Darstellung dreidimensionaler bzw. prozeduraler Oberflächen ist das so erzeugte Kugelmodell jedoch ungeeignet, da die Abstände zwischen den benachbarten Vertices zu den Polen hin immer kleiner werden und schließlich gegen null streben. Um nun sicherzustellen, dass die Abstände unabhängig von den jeweiligen Vertex-Positionen stets konstant sind (alle Dreiecke, aus denen das Planetenmodell zusammengesetzt ist, haben dann die gleiche Fläche), leiten sich die in unseren Programmbeispielen dargestellten Planeten, wie in Abbildung 1 gezeigt, von einem würfelförmigen 3-D-Modell ab, dessen sechs Flächen aus jeweils n x n Vertices bestehen. Die Umrechnung einer zunächst flachen Würfelfläche in eine Kugelteilfläche können Sie anhand von Listing 1 nachvollziehen.

Abb. 1: Würfelbasierte 3-D-Planetenmodelle mit unterschiedlicher Vertexanzahl

for(rowNr = 0; rowNr < numVerticesPerRowORColumn; rowNr++)
{
for(columnNr = 0; columnNr < numVerticesPerRowORColumn; columnNr++)
{
    VertexID = rowNr*numVerticesPerRowORColumn+columnNr;

    tempVector.x = VertexArray[VertexID].PosX;
    tempVector.y = VertexArray[VertexID].PosY;
    tempVector.z = VertexArray[VertexID].PosZ;

    D3DXVec3Normalize(&tempVector, &tempVector);

    VertexArray[VertexID].PosX = tempVector.x;
    VertexArray[VertexID].PosY = tempVector.y;
    VertexArray[VertexID].PosZ = tempVector.z;

    VertexArray[VertexID].NormalX = tempVector.x;
    VertexArray[VertexID].NormalY = tempVector.y;
    VertexArray[VertexID].NormalZ = tempVector.z;
}}

Gasgiganten

„Reproduktion natürlicher Oberflächenmuster mithilfe von mathematischen Funktionen“ – die wissenschaftliche Erklärung dessen, womit wir uns in diesem Artikel beschäftigen, verheißt wenig Gutes für alle Nichtmathematiker unter uns. Und in der Tat, wenn Sie sich mit den nachfolgenden Fragment-Shader-Programmen befassen, werden Sie sich des Öfteren darüber wundern, warum ausgerechnet diese oder jene Formel verwendet wird. Die Antwort darauf ist wenig spektakulär – man bastelt so lange an einer Gleichung herum, bis das Resultat optisch ansprechend ist, oder man probiert gegebenenfalls einfach etwas Neues aus. Am Beispiel der Gasgiganten lässt sich diese Vorgehensweise besonders gut veranschaulichen, da diese lediglich aus farbigen Wolkenbändern bestehen, die parallel zum Äquator den Planeten umspannen (Abb. 2).

Abb. 2: Gasriese (Framework-Demoprogramm 31)

Der Grundgedanke hinter dem in Listing 2 skizzierten und im Framework-Demoprogramm 31 zum Einsatz kommenden Fragment Shader besteht darin, die Farbverläufe, Durchmesser und Positionen sowie die Anzahl und die Verdrillung der einzelnen Wolkenbänder in Abhängigkeit vom Längen- und Breitengrad (Südpol: -90°, Äquator: 0°, Nordpol: 90°) durch Sinus- und Kosinusfunktionen zu simulieren. Der genaue Aufbau der verwendeten Funktionen wurde ganz unwissenschaftlich durch Ausprobieren gefunden – entscheidend ist jedoch, dass sich die Durchmesser und Intensitätsverläufe sowie die Anzahl der Bänder für jeden Planeten individuell mithilfe von vier Oberflächenparametern (uniform vec4 SurfaceGeometryParameter) und die zugehörigen Farbverläufe mithilfe von acht Farbparametern (uniform vec4 ColorParameter1, ColorParameter2) einstellen lassen.
Im Verlauf der Programmausführung werden zunächst die Oberflächendetails – also die Unregelmäßigkeiten in den Wolkenbändern – mithilfe der in einer Noise-Textur gespeicherten Zufallswerte generiert. Ferner wird für jedes Pixel ein dazu passender Normalenvektor ermittelt, damit die Planetenoberfläche zu einem späteren Zeitpunkt im Rahmen des Deferred Lightings korrekt beleuchtet werden kann. Im finalen Schritt werden schließlich unter Berücksichtigung zweier vom Breitengrad abhängiger Verdrillungsfaktoren (torsionValue1 sowie torsionValue2) die Durchmesser und Farbverläufe der einzelnen Wolkenbänder berechnet und mit den im ersten Schritt ermittelten Oberflächendetails kombiniert.

void main()
{
// Oberflächendetails mittels Rauschtextur erzeugen: 
vec2 NoiseValue = texture(SurfaceTextureArray,
                  vec3(gs_TexCoord[0].st, TextureArrayID)).xy;
NoiseValue += texture(SurfaceTextureArray,
              vec3(2.0*gs_TexCoord[0].st, TextureArrayID)).xy;
NoiseValue += texture(SurfaceTextureArray,
              vec3(3.0*gs_TexCoord[0].st, TextureArrayID)).xy;
NoiseValue += texture(SurfaceTextureArray,
              vec3(4.0*gs_TexCoord[0].st, TextureArrayID)).xy;

// Oberflächendetail-Kontrast nachregulieren: 
NoiseValue *= 1.38;

// Per-Pixel-Normale für das Deferred Lighting ermitteln: 
vec3 NormalColor  = texture(NormalTextureArray,
                    vec3(4.0*gs_TexCoord[0].st, TextureArrayID)).xyz;

mat3 matTexturespaceToWorldspace;

matTexturespaceToWorldspace[0] = gs_TexCoord[2].xyz;
matTexturespaceToWorldspace[1] = gs_TexCoord[3].xyz;
matTexturespaceToWorldspace[2] = gs_TexCoord[1].xyz;

vec3 Normal = matTexturespaceToWorldspace*(2.0*NormalColor.rgb - 1.0);

// Breitengrad ermitteln: 
float angle = asin(gs_TexCoord[5].y);

// Verdrillung der Wolkenbänder festlegen: 
float frequency  = 20.0*(1.0-abs(gs_TexCoord[5].x));
float torsionValue1 = 0.02*sin(frequency*angle);
float torsionValue2 = 0.02*sin(frequency*(angle+0.785));

// Anzahl, Breite sowie Helligkeitsverläufe der Wolkenbänder festlegen
// (die NoiseValue-Parameter sorgen hierbei für kleinere Unregelmäßigkeiten): 
float value1 = abs(SurfaceGeometryParameter.x*
                   sin(SurfaceGeometryParameter.y*
                   (gs_TexCoord[5].x+torsionValue1)+NoiseValue.x));

float value2 = abs(SurfaceGeometryParameter.z*
                   cos(SurfaceGeometryParameter.w*
                   (gs_TexCoord[5].x+torsionValue2)+NoiseValue.y));

// Farbverläufe der Wolkenbänder berechnen: 
gs_FragColor[0] = SurfaceBrightnessFactor*
                  vec4(ColorParameter1.x*value1+ColorParameter1.y*value2,
                       ColorParameter1.z*value1+ColorParameter1.w*value2,
                       ColorParameter2.x*value1+ColorParameter2.y*value2, 1.0);

// Kameraraumposition sowie Tiefenwert speichern: 
gs_FragColor[1] = gs_TexCoord[4];
gs_FragColor[2] = vec4(0.5+0.5*Normal.x, 0.5+0.5*Normal.y,
                       0.5+0.5*Normal.z, 1.0);
// keine spiegelnden Oberflächenanteile: 
gs_FragColor[3] = vec4(0.0, 0.0, 0.0, 0.0);
// keine selbstleuchtenden Oberflächenanteile: 
gs_FragColor[4] = vec4(0.0, 0.0, 0.0, 0.0);
}

Aufmacherbild: Universe filled with stars, galaxy background von Shutterstock / Urheberrecht: spaxiax

[ header = Seite 2: Gesteinsplaneten – Oasen des Lebens und ungastliche Einöden ]

Gesteinsplaneten – Oasen des Lebens und ungastliche Einöden

Ob Eis- (Abb. 3), Vulkan- (Abb. 4) oder Wüstenplanet (Abb. 5), ob marsähnliche Welt (Abb. 6) oder eine zweite Erde (Abb. 7) – kein Gesteinsplanet gleicht einem anderen.

Abb. 3: Eisplanet (Framework-Demoprogramm 30)

Abb. 4: Vulkanplanet (Framework-Demoprogramm 30)

Abb. 5: Wüstenplanet (Framework-Demoprogramm 30)

Abb. 6: Marsähnlicher Planet (Framework-Demoprogramm 30)

Abb. 7: Erdähnlicher Planet (Framework-Demoprogramm 30)

Angesichts dieser Umstände ist es legitim zu fragen, ob sich derartig unterschiedliche Planetentypen überhaupt mithilfe eines einzigen Shader-Programms generieren lassen, zumal die Oberflächendetails um ein Vielfaches komplexer sind als die Wolkenringe eines Gasgiganten. Um diese Frage beantworten zu können, erweist es sich zunächst als sinnvoll, die vor uns liegende Aufgabe in möglichst viele einfache Teilschritte zu zerlegen, die sich unabhängig voneinander bearbeiten und implementieren lassen – oder anders ausgedrückt, wir befassen uns mit einem der spannendsten Science-Fiction-Themen schlechthin: dem Terraforming. Unser SolidPlanetOrMoon-Fragment-Shader übernimmt hierbei im übertragenen Sinne die Aufgabe einer Terraforming-Maschine (eines Weltenwandlers) und generiert mithilfe einer begrenzten Anzahl von Parametern und Texturen eine nahezu unbegrenzte Anzahl von Planeten. Unser Ziel ist es, letztlich alle in einer Planetentextur gespeicherten Oberflächendetails schrittweise mittels prozeduraler Techniken zu reproduzieren. Die hierbei erforderlichen Programmabläufe (Listing 4) lassen sich am einfachsten mit dem Bild der Erde vor Augen nachvollziehen:

Schritt 1: Großflächige Unterteilung der Planetenoberfläche in Kontinente und Meeresbecken (erdähnliche Planeten) bzw. Tiefebenen (sonstige Planeten und Monde)
Schritt 2: Feinere Unterteilung der Kontinente in unterschiedliche Klima- und Landschaftszonen bzw. Höhenbereiche
Schritt 3: Farbgebung der einzelnen Landschafts- und Klimazonen sowie Darstellung möglicher Großstadtlichter auf der Nachtseite des Planeten
Schritt 4: Darstellung der polaren Eisschilde
Schritt 5: Generierung zusätzlicher Oberflächendetails (Geländeformationen), damit die Planetenoberfläche auch aus der Nähe betrachtet noch abwechslungsreich aussieht

Unterteilung des Planeten in unterschiedliche Kontinente, Landschafts- und Klimazonen: Die in den Schritten 1 und 2 beschriebene Unterteilung der Planetenoberfläche in Kontinente, Meeresbecken, Tiefebenen, Landschafts- und Klimazonen können Sie anhand von Listing 5 nachvollziehen. Sie erfolgt, indem eine Noise-Textur, wie eingangs erklärt, unter Berücksichtigung der nachfolgenden Shader-Parameter insgesamt sechsmal mit unterschiedlich vielen Wiederholungen (ProceduralTexCoordValues) auf das Planetenmodell gemappt wird.

uniform sampler2DArray NoiseTextureArray;
uniform float NoiseTextureID;
uniform vec4 ProceduralTexCoordValues;

Es stellt sich nun die Frage, wie die in der Noise-Textur gespeicherten Zufallswerte interpretiert werden sollen. Der denkbar einfachste Ansatz, anhand dieser Werte lediglich ein Höhen- bzw. Landschaftsprofil zu generieren, hilft uns bei der Farbgebung der Planetenoberfläche nur bedingt weiter. Wo sich beispielsweise eine Wüste oder ein Regenwaldgebiet befindet, ist nicht allein von der Höhe abhängig, sondern auch von der geografischen Lage, sprich: der jeweiligen Klimazone. Um dies zu berücksichtigen, verwenden wir bei der Berechnung der Oberflächenfarbe zwei Zufallswerte, wobei Zufallswert 1 (Noise.x) die Landschafts- und Zufallswert 2 (Noise.y) die Klimazone charakterisiert. Die Ausdehnungen der einzelnen Klima- und Landschaftzonen/Höhenbereiche werden mittels der nachfolgenden vier Parameter festgelegt:

uniform vec4 HeightValues1;
uniform vec4 HeightValues2;
uniform vec4 ClimateValues1;
uniform vec4 ClimateValues2;

Kolorierung der Landschafts- und Klimazonen: Um die Anzahl der erforderlichen Shader-Parameter möglichst klein zu halten, übergeben wir anstelle der zur Oberflächenkolorierung benötigten Farbwerte lediglich acht Indexparameter (uniform ivec4 ColorIDList1, ColorIDList2), mit deren Hilfe dann auf die in Listing 3 deklarierten Farbpaletten (SurfaceColorArray, SpecularColorArray sowie EmissiveColorArray) zugegriffen werden kann. Pro Planet lassen sich folglich bis zu acht unterschiedliche Grundfarben festlegen, die jeweils einer bestimmten Landschafts- bzw. Klimazone zugeordnet sind. Darüber hinaus ist es möglich, die Helligkeit und Farbintensität der Planetenoberfläche mittels zweier weiterer Parameter beliebig zu modifizieren:

uniform float SurfaceBrightnessFactor; (siehe Listing 4)
uniform float ColorIntensity; (siehe Listing 7)

Mit der Absicht, abrupte und unnatürlich wirkende Farbänderungen an den Grenzen benachbarter Landschafts- und Klimazonen zu vermeiden, berechnen wir die Oberflächenfarbe unter Einbeziehung zweier zusätzlicher Shader-Parameter (uniform float HeightRegionRange, InvHeightRegionRange) als gewichteten Mittelwert aus den zuvor erwähnten Grundfarben (siehe Listing 6).
Zu guter Letzt müssen wir noch dem zivilisatorischen Einfluss Rechnung tragen und simulieren – falls erforderlich – die beleuchteten Großstädte auf der Nachtseite eines Planeten mithilfe der nachfolgenden drei Parameter:

uniform sampler2DArray LightTextureArray;
uniform float LightTextureID;
uniform float LightTextureIntensity;

Polare Eisschilde: Der Zustand der polaren Eisschilde ist längst zu einem Symbol des Klimawandels geworden. Anhand von Listing 7 können Sie nachvollziehen, wie sich in unseren Weltraum-Spieleprototypen bzw. im Framework-Demoprogramm 30 die Dicke und Ausbreitung dieser Eisflächen in Abhängigkeit vom Breitengrad mittels dreier einfacher Parameter (uniform float IceParameter1, IceParameter2 sowie IceParameter3) festlegen lässt.
Planetare Oberflächendetails: Planetare Oberflächendetails sorgen dafür, dass die anfangs generierten Landschafts- und Klimazonen auch aus der Nähe betrachtet mit gleichsam interessanten wie abwechslungsreichen Geländeformationen aufwarten können. Die Verrechnung dieser zusätzlichen Details mit der zuvor berechneten Oberflächenfarbe erfolgt innerhalb von Listing 4. In diesem Zusammenhang können wir mithilfe eines zusätzlichen Shader-Parameters (uniform vec4 DetailIntensity) festlegen, wie stark die Oberflächenfarbe modifiziert werden soll.
Gespeichert werden diese zusätzlichen Informationen in einem Array von Detailtexturen in Form von einfachen Graustufenwerten. Zur Erklärung: Durch den Verzicht von farbigen Details können wir bei Planeten und Monden ohne größere Eis- und Wasservorkommen (Beispiele: Erdmond, Mars usw.) mit einer einzigen Textur drei verschiedene Geländeformationen darstellen. Im Unterschied dazu nutzen wir bei erdähnlichen Himmelskörpern den blauen Farbkanal der Textur, um den Eindruck von bewegten Wasserflächen vorzutäuschen (Listing 8). Da wir die Detailtextur insgesamt viermal mit unterschiedlich vielen Wiederholungen (uniform vec4 DetailScaleValues) und Gewichtungsfaktoren (uniform vec4 DetailIntensityValues) auf das Planetenmodell mappen, lassen sich gleichzeitig größere wie kleinere Geländeformationen erzeugen. Um die Illusion zusätzlicher dreidimensionaler Oberflächendetails perfekt zu machen, berechnen wir, wie in Listing 9 gezeigt, für jedes Oberflächenpixel einen dazu passenden Beleuchtungsnormalenvektor, wobei wir die Summe der in den einzelnen Farbkanälen abgespeicherten Details als Höhenwert interpretieren. Hier nun eine Übersicht der erforderlichen Shader-Parameter:

uniform sampler2DArray DetailTextureArray;
uniform float DetailTextureID;
uniform vec4 DetailScaleValues;
uniform vec4 DetailIntensityValues;
uniform float DetailHeightScale;

Berechnung der Lichtspiegelungen auf Wasser- und Eisflächen: Aus optischen Gründen erfolgt die Berechnung der Lichtspiegelungen auf Wasser- und Eisflächen (spiegelnde Reflexion) nicht wie sonst üblich erst im Rahmen des Deferred Lightings, sondern direkt nach Abschluss der Erzeugung aller Oberflächendetails mithilfe der nachfolgenden drei Shader-Parameter (Listing 10):

uniform vec3 NegLightDirection;
uniform vec3 ViewDirection;
uniform vec4 SunLightColor;

#define NumSurfaceColors 19

const vec4 SurfaceColorArray[NumSurfaceColors] =
vec4[NumSurfaceColors](
vec4(0.04, 0.2, 1.0, 1.0),      /* deep Water */
vec4(0.05, 0.3, 1.0, 1.0),      /* Water */
vec4(0.5, 0.75, -0.4, 1.0),     /* Vegetation 1*/
vec4(0.6, 0.95, -0.4, 1.0),     /* Vegetation 2*/
vec4(0.7, 0.5, 0.5, 1.0),       /* grey-brown Rock */
vec4(0.5, 0.5, 0.5, 1.0),       /* grey Rock 1 */
vec4(0.575, 0.575, 0.575, 1.0), /* grey Rock 2 */
vec4(0.6, 0.6, 0.6, 1.0),       /* grey Rock 3 */
vec4(2.0, 0.6, 0.0, 1.0),       /* Lava 1 */
vec4(2.0, 0.4, 0.0, 1.0),       /* Lava 2 */
vec4(1.5, 0.75, -0.05, 1.0),    /* Desert 1 */
vec4(1.1, 0.95, -0.02, 1.0),    /* Desert 2 */
vec4(1.1, 0.575, 0.475, 1.0),   /* light-red Rock*/
vec4(2.0, 0.2, 0.0, 1.0),       /* red Rock 1*/
vec4(1.5, 0.0, -0.3, 1.0),      /* red Rock 2*/
vec4(1.9, 0.275, -0.7475, 1.0), /* brown Rock 1*/
vec4(1.0, 0.6, 0.0, 1.0),       /* brown Rock 2*/
vec4(1.0, 1.0, 1.4, 1.0),       /* Ice 1 */
vec4(1.0, 1.0, 1.7, 1.0)        /* Ice 2 */
);

const vec4 SpecularColorArray[NumSurfaceColors] = vec4[NumSurfaceColors](...);
const vec4 EmissiveColorArray[NumSurfaceColors] = vec4[NumSurfaceColors](...);
void main()
{
[Unterteilung des Planeten in unterschiedliche Kontinente, Landschafts- und
 Klimazonen (Listing 5)]
[Kolorierung der Landschafts- und Klimazonen (Listing 6)]
[Darstellung der polaren Eisschilde (Listing 7)]
[Generierung der planetaren Oberflächendetails (Listing 8)]
[Berechnung der zu den Oberflächendetails passenden Beleuchtungs-Normalen
 (Listing 9)]
[Berechnung der Lichtspiegelungen auf Wasser- und Eisflächen (Listing 10)]

gs_FragColor[0] = SpecularLightColor + SurfaceBrightnessFactor*
(SurfaceColorMofified*DetailColor*DetailColor+DetailIntensity*DetailColor); 

// Kameraraumposition sowie Tiefenwert speichern:
gs_FragColor[1] = gs_TexCoord[4];
gs_FragColor[2] = vec4(0.5+0.5*Normal.x, 0.5+0.5*Normal.y,
                       0.5+0.5*Normal.z, 1.0);

// spiegelnde Reflexion wurde bereits im zuvor berechnet => das Ergebnis ist
// optisch ansprechender als beim Deferred Lighting: 
gs_FragColor[3] = vec4(0.0, 0.0, 0.0, 0.0); 
gs_FragColor[4] = LightTextureIntensity*LightColor+EmissiveColor*DetailColor;
}
vec2 Noise = vec2(HeightValues1.y, ClimateValues1.y)*
texture(NoiseTextureArray, vec3(ProceduralTexCoordValues.x*
        gs_TexCoord[0].st, NoiseTextureID)).xy;

Noise += vec2(HeightValues1.z, ClimateValues1.z)*
texture(NoiseTextureArray, vec3(ProceduralTexCoordValues.y*
        gs_TexCoord[0].st, NoiseTextureID)).xy;

Noise += vec2(HeightValues1.w, Noise.x*ClimateValues1.w)*
texture(NoiseTextureArray, vec3(2.0*ProceduralTexCoordValues.z*
        gs_TexCoord[0].st, NoiseTextureID)).xy;

Noise += vec2(HeightValues2.x, ClimateValues2.x)*
texture(NoiseTextureArray, vec3(ProceduralTexCoordValues.w*
        gs_TexCoord[0].st, NoiseTextureID)).xy;

Noise += vec2(0.1*Noise.x*HeightValues2.y, 0.1*Noise.x*ClimateValues2.y)*
texture(NoiseTextureArray, vec3(2.0*ProceduralTexCoordValues.z*
        gs_TexCoord[0].st, NoiseTextureID)).xy;

Noise += vec2(0.05*Noise.x*HeightValues2.z, 0.05*Noise.x*ClimateValues2.z)*
texture(NoiseTextureArray, vec3(2.0*ProceduralTexCoordValues.w*
        gs_TexCoord[0].st, NoiseTextureID)).xy;

Noise += vec2(HeightValues1.x, ClimateValues1.x); 
Noise *= vec2(HeightValues2.w, ClimateValues2.w); 

float weight = max(Noise.x,Noise.y);
float f[8];

float MaxHeightRegionValue = HeightRegionRange;

if(weight < HeightRegionRange)
    f[0] = HeightRegionRange;
else
{
    f[0] = InvHeightRegionRange*(HeightRegionRange –
           abs(MaxHeightRegionValue - weight));
    f[0] = max(f[0], 0.0);
}

MaxHeightRegionValue += HeightRegionRange;

f[1] = InvHeightRegionRange*(HeightRegionRange -
       abs(MaxHeightRegionValue - weight));
f[1] = max(f[1], 0.0);

[...]

MaxHeightRegionValue += HeightRegionRange;

if(weight > MaxHeightRegionValue)
    f[7] = 1.0;
else
{
    f[7] = InvHeightRegionRange*(HeightRegionRange –
           abs(MaxHeightRegionValue - weight));
    f[7] = max(f[7], 0.0);
}

vec4 SurfaceColor = vec4(0.0, 0.0, 0.0, 1.0);
vec4 SpecularColor = vec4(0.0, 0.0, 0.0, 0.0);
vec4 EmissiveColor = vec4(0.0, 0.0, 0.0, 1.0);

// Die Oberflächenfarbe entspricht dem gewichteten Mittelwert
// aus 8 Farbwerten (Listing 3) => keine abrupten Farbwechsel, 
// stattdessen harmonische Farbübergänge zwischen benachbarten
// Klima- und Landschaftszonen: 

int ColorID = ColorIDList1[0];
SurfaceColor  += f[0]*SurfaceColorArray[ColorID];
SpecularColor += f[0]*SpecularColorArray[ColorID];
EmissiveColor += f[0]*EmissiveColorArray[ColorID];

ColorID = ColorIDList1[1];
SurfaceColor  += f[1]*SurfaceColorArray[ColorID];
SpecularColor += f[1]*SpecularColorArray[ColorID];
EmissiveColor += f[1]*EmissiveColorArray[ColorID];

[...]

ColorID = ColorIDList2[0];
SurfaceColor  += f[4]*SurfaceColorArray[ColorID];
SpecularColor += f[4]*SpecularColorArray[ColorID];
EmissiveColor += f[4]*EmissiveColorArray[ColorID];

[...]

ColorID = ColorIDList2[3];
SurfaceColor  += f[7]*SurfaceColorArray[ColorID];
SpecularColor += f[7]*SpecularColorArray[ColorID];
EmissiveColor += f[7]*EmissiveColorArray[ColorID];

// Großstadtlichter: 
vec4 LightColor = texture(LightTextureArray,
                  vec3(gs_TexCoord[0].st, LightTextureID));
// Hinweis: gs_TexCoord[5].x := Sinus(Breitengrad) 
float sq = gs_TexCoord[5].x*gs_TexCoord[5].x;

float IceValue = clamp(IceParameter1*abs(gs_TexCoord[5].x) + IceParameter2*sq +
                       IceParameter3*sq*sq*sq, 0.0, 1.0);

vec4 SurfaceColorMofified = ColorIntensity*SurfaceColor*(1.0-IceValue) +
                            vec4(1.4, 1.4, 2.0, 1.0)*IceValue;

SurfaceColorMofified = min(SurfaceColorMofified, 1.0);
vec4 DetailColor =  DetailIntensityValues.x*texture(DetailTextureArray,
vec3(DetailScaleValues.x*gs_TexCoord[0].st, DetailTextureID));

DetailColor += DetailIntensityValues.y*texture(DetailTextureArray,
vec3(DetailScaleValues.y*gs_TexCoord[0].st, DetailTextureID));

DetailColor += DetailIntensityValues.z*texture(DetailTextureArray,
vec3(DetailScaleValues.z*gs_TexCoord[0].st, DetailTextureID));

DetailColor += DetailIntensityValues.w*texture(DetailTextureArray,
vec3(DetailScaleValues.w*gs_TexCoord[0].st, DetailTextureID));

// Die Summe der in den einzelnen Farbkanälen abgespeicherten Detailwerte
// interpretieren wir bei der Berechnung der Beleuchtungs-Normale (Listing 9) 
// als Höhenwert: 
float DetailHeight = DetailColor.x+DetailColor.y+DetailColor.z;

float DetailValue1 = max(SurfaceColorMofified.x,EmissiveColor.x)*DetailColor.x;
float DetailValue2 = max(SurfaceColorMofified.y,EmissiveColor.y)*DetailColor.y;
float DetailValue3 = max(SurfaceColorMofified.z,EmissiveColor.z)*DetailColor.z;

DetailColor = vec4(DetailValue1,DetailValue1,DetailValue1, 1.0)+
              vec4(DetailValue2,DetailValue2,DetailValue2, 1.0)+
              vec4(DetailValue3,DetailValue3,DetailValue3, 1.0);
// Bei der Berechnung der Beleuchtungs-Normalen benötigen wir zusätzlich
// zum Oberflächen-Höhenwert auch die Höhenwerte zweier benachbarter
// Pixel: 

vec2 TexCoord = gs_TexCoord[0].st+vec2(0.005, 0.0);

vec4 DetailColorN1 =  DetailIntensityValues.x*texture(DetailTextureArray,
vec3(DetailScaleValues.x*TexCoord, DetailTextureID));

DetailColorN1 += DetailIntensityValues.y*texture(DetailTextureArray,
vec3(DetailScaleValues.y*TexCoord, DetailTextureID));

DetailColorN1 += DetailIntensityValues.z*texture(DetailTextureArray,
vec3(DetailScaleValues.z*TexCoord, DetailTextureID));

DetailColorN1 += DetailIntensityValues.w*texture(DetailTextureArray,
vec3(DetailScaleValues.w*TexCoord, DetailTextureID));

TexCoord = gs_TexCoord[0].st+vec2(0.0, 0.005);

vec4 DetailColorN2 =  DetailIntensityValues.x*texture(DetailTextureArray,
vec3(DetailScaleValues.x*TexCoord, DetailTextureID));

DetailColorN2 += DetailIntensityValues.y*texture(DetailTextureArray,
vec3(DetailScaleValues.y*TexCoord, DetailTextureID));

DetailColorN2 += DetailIntensityValues.z*texture(DetailTextureArray,
vec3(DetailScaleValues.z*TexCoord, DetailTextureID));

DetailColorN2 += DetailIntensityValues.w*texture(DetailTextureArray,
vec3(DetailScaleValues.w*TexCoord, DetailTextureID));

vec3 deltaVector1 = vec3(0.0, 0.005, DetailHeightScale*
(DetailColorN1.x+DetailColorN1.y+DetailColorN1.z-DetailHeight));

vec3 deltaVector2 = vec3(0.005, 0.0, DetailHeightScale*
(DetailColorN2.x+DetailColorN2.y+DetailColorN2.z-DetailHeight));

// Beleuchtungs-Normale:
vec3 tempNormal = 0.1*vec3(0.0, 0.0, 1.0)+
0.9*normalize(cross(deltaVector2, deltaVector1));

// Hinweis: 
// tempNormal = vec3(0.0, 0.0, 1.0); => Kugelmodell-Normalenvektor

mat3 matTexturespaceToWorldspace;
matTexturespaceToWorldspace[0] = gs_TexCoord[2].xyz;
matTexturespaceToWorldspace[1] = gs_TexCoord[3].xyz;
matTexturespaceToWorldspace[2] = gs_TexCoord[1].xyz;

vec3 Normal = matTexturespaceToWorldspace*tempNormal;
float tempDot = dot(gs_TexCoord[1].xyz, NegLightDirection);

vec4 SpecularLightColor = vec4(0.0, 0.0, 0.0, 0.0);

if(tempDot > -0.7)
{
float SpecularIntensity = max(-dot(2.0*tempDot*gs_TexCoord[1].xyz-
                              NegLightDirection, ViewDirection), 0.0);

vec4 helpVector = ColorIntensity*SpecularColor+
vec4(0.375, 0.375, 0.225, 100.0)*IceValue*DetailColor;

// Ausdehnung der Spieelfläche/des Glanzpunkts festlegen: 
SpecularIntensity = pow(SpecularIntensity, helpVector.w);

SpecularLightColor = SpecularIntensity*helpVector*SunLightColor;
}

[ header = Seite 3: Wolkendarstellung ]

Wolkendarstellung

Zum Abschluss des heutigen Artikels werden wir uns noch mit den Grundlagen der Wolkendarstellung befassen. Im Fokus unseres Interesses steht diesmal jedoch nicht die prozedurale Erzeugung dreidimensionaler Wolkenformationen. Wir werden uns an dieser Stelle zunächst auf die unvermeidlichen Probleme und Fallstricke konzentrieren, die im Verlauf der Shader-Implementierung zu lösen sind, und heben uns alles Weitere für eine spätere Ausgabe auf. Lediglich die Farbe der Wolken (uniform vec4 CloudColor) und damit verbunden ihre Transparenz bzw. Dichte lässt sich für jeden Planeten individuell festgelegen.
„Was kann denn an der Wolkendarstellung wohl so schwierig sein?“, werden Sie sich vermutlich in diesem Augenblick fragen. Zunächst rendert man den Planeten und dann im zweiten Schritt die transparente Wolkendecke, wobei man den Radius der Cloud-Sphäre etwas größer wählt als den Radius des Planeten.

Nun ja, in der Theorie klingt das nach einem vernünftigen Plan; in der Praxis jedoch ist es unmöglich, die Cloud-Sphäre bei aktiviertem Depth Buffer ohne hässliche z-Fighting-Artefakte zu rendern. Eine eindeutige Aussage darüber, ob nun ein Pixel der Planetenoberfläche oder ein Wolkenpixel sichtbar ist, kann aufgrund der zu geringen Genauigkeit der gespeicherten Tiefenwerte nicht getroffen werden. Da wir jedoch standardmäßig die Kameraraumpositions- und Tiefenwerte aller opaken Objekte (also auch die der Planetenoberfläche) für die spätere Verwendung beim Deferred Lighting zwischenspeichern, können wir zur Vermeidung von Darstellungsfehlern stattdessen die Wolkendecke mit ausgeschaltetem Depth Buffer rendern und die erforderlichen Tiefenvergleiche eigenhändig im CloudLayer-Fragment-Shader-Programm, so wie in Listing 12 gezeigt, durchführen.

Ein weiteres Problem offenbart sich im Zusammenhang mit der Beleuchtung der Cloud-Sphäre. Bei Verwendung eines simplen diffusen Beleuchtungsmodells ist das Ergebnis der damit verbundenen Berechnungen zwar keine allzu große Überraschung, das sichtbare Resultat jedoch wird den einen oder anderen verwundern. Anstelle von schwarzen Wolkenbändern (Helligkeit = 0), die beispielsweise auf der Nachtseite eines Vulkanplaneten die ansonsten hell erleuchteten Lavaseen in Dunkelheit hüllen, sieht man bei aktiviertem Color Blending überhaupt keine Wolken. Die Erklärung: Ein transparentes schwarzes Objekt ist schlicht und ergreifend unsichtbar, da es keinerlei Licht absorbiert oder aussendet! Auch die Verwendung einer alternativen Blend-Funktion, bei der die Farbe Schwarz als vollkommen lichtundurchlässig gehandhabt wird, kann unser Problem nicht lösen, weil dann mit einem Mal die hellen Wolken auf der Tagseite fehlerhaft dargestellt würden. Da uns die standardmäßig zur Verfügung stehenden Blend-Funktionen nicht wirklich weiterbringen, bleibt wieder einmal nur ein Ausweg: Wir implementieren im CloudLayer-Fragment-Shader (Listing 12) eine eigene Funktion, mit deren Hilfe sich die Wolken sowohl auf der Tag- als auch auf der Nachtseite vernünftig darstellen lassen. Indem wir im Verlauf der Beleuchtungsberechnungen zusätzlich die Lichtstreuung innerhalb der Wolken berücksichtigen, können wir zudem die uns vertraute orange-rote Wolkenfarbe während der Dämmerungsperioden einigermaßen realistisch nachbilden (Abbildung 8) – oder anders gesagt, wir reproduzieren das Ergebnis mithilfe einer durch Ausprobieren gefundenen Formel (Listing 11 und 12), bei der man die Stärke der Streuung für jeden Planeten individuell festlegen kann (uniform float RayleighScatteringValue).

Abb. 8: Lichtstreuung an den Wolken

void main()
{
gl_Position = matWorldViewProjection * gs_Vertex;
gs_TexCoord[0] = gs_MultiTexCoord0;

// Rotation des Modells (der Wolken-Sphäre) berücksichtigen: 
gs_TexCoord[1].xyz = matRotation * gs_Normal;

// Helligkeit bei diffuser Beleuchtung berechnen: 
float tempDot = dot(gs_TexCoord[1].xyz, NegLightDir);
gs_TexCoord[3].y = tempDot;

// Dämmerungsbereich und Intensität der durch die Streuprozesse
// verursachten Farbänderung festlegen: 
// Hinweis: je kleiner, die RayleighScatteringValue, umso größer der
// Dämmerungsbereich und umso intensiver die Streufarbe. 
gs_TexCoord[3].x = pow(max(0.0, tempDot+RayleighScatteringValue), 0.25);

// Screen-Space-Pos. u. Tiefenwert zur Vermeidung von z-Fighting-Artefakten
// für die spätere Verwendung im Fragment Shader zwischenspeichern: 
gs_TexCoord[2] = gl_Position;
}
void main()
{
// Vermeidung von Renderfehlern, wenn sich die Kamera
// der Wolkendecke zu sehr annähert: 
if(gs_TexCoord[2].z < 5.0)
    discard;
else
{
// Tiefenvergleich mit den zuvor gerenderten opaquen Pixeln (der
// Planetenoberfläche) vornehmen: 

// Screen-Space-Position (gs_TexCoord[2].xyz) so umrechnen, dass man auf
// den korrekten Tiefenwert innerhalb der CameraDepthTexture zugreifen kann: 
float tempFloat = 1.0/gs_TexCoord[2].z;
vec2 ProjectedTexCoord = vec2(0.5*gs_TexCoord[2].x*tempFloat + 0.5,
                              0.5*gs_TexCoord[2].y*tempFloat + 0.5);

float CameraDepth = texture2D(CameraDepthTexture, ProjectedTexCoord).w;

if(CameraDepth < 0.0)
    CameraDepth = 1000000.0;

if(CameraDepth < gs_TexCoord[2].z-0.125)
    discard; // Wolkendecke an dieser Stelle nicht sichtbar!
else
{
// Farbe eines zuvor gerenderten opaquen Pixels abfragen: 
vec4 ScreenColor = texture2D(ScreenTexture, ProjectedTexCoord);

// Wolkendichte nach den Zufallsprinzip variieren: 
float Noise = texture(CloudTextureArray,
              vec3(gs_TexCoord[0].st, CloudTextureID)).x*
(-0.1+0.5*texture(DetailTexture, 100.0*gs_TexCoord[0].st).x+
      0.5*texture(DetailTexture, 150.0*gs_TexCoord[0].st).y+
      0.5*texture(DetailTexture, 200.0*gs_TexCoord[0].st).z);

vec4 CalculatedCloudColor = Noise*CloudColor;

// Für die Berechnung der Wolkenfarbe (Beleuchtung+Lichtstreuung) 
// verwenden wir wieder einmal so eine „seltsame Formel“, die einzig
// durch Ausprobieren gefunden wurde: 
CalculatedCloudColor = gs_TexCoord[3].y*
max(0.0,(1.0-1.5*gs_TexCoord[3].x*gs_TexCoord[3].x))*
vec4(8.0, 4.0, 0.0, 1.0)*CalculatedCloudColor +
gs_TexCoord[3].x*CalculatedCloudColor-
(1.0-gs_TexCoord[3].x)*CalculatedCloudColor;

// Die Wolkendecke soll in Abhängigkeit von ihrer Farbe mehr oder
// weniger transparent erscheinen. CalculatedCloudColor und ScreenColor
// werden daher wie folgt miteinander vermischt (Color Blending): 
gs_FragColor = max(vec4(0.0, 0.0, 0.0, 1.0),
ScreenColor*(1.0-abs(CalculatedCloudColor))+max(CalculatedCloudColor, 0.0));
}}}
Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -