Texturierung

Prozedurale Generierung von Texturen für Landschaftsflächen

Autor: Andy Eckhardt

Definition für Prozedurale Generierung: Dies bezeichnet eine Methode zur Erzeugung von zum Beispiel Texturen während der Ausführung des Programms, ohne dass diese Inhalte vor der Benutzung vom Entwickler fest angelegt werden. Dabei werden die Inhalte nicht zufällig erzeugt, sondern durch einen deterministischen Algorithmus generiert.

Definition für Texturen: Texturen sind Bilder, die auf einer Oberfläche eines virtuellen Körpers dargestellt werden.

In der Datei tex_generator.rs werden unsere prozedural generierten Texturen mit Hilfe von create_texture_maps(...) erzeugt. Diese Funktion erzeugt für ein gegebenes GroundMaterial eine Textur, welches eine Array von RGB Grauwerten beinhaltet, und eine Height Map, die ein Array mit Höhenwerten für die dazugehörige Textur darstellt.

Noise-function für Texturen und Height Maps

Autor: Andy Eckhardt

Die Implementation der Methode ( create_texture_maps(ground: GroundMaterial) ) zur Generierung der Texturen und der zugehörigen Height Maps, ist eine Zusammenfassung mehrerer Methoden zur Erstellung von jeweils einer Textur und Height Map zu einem GroundMaterial.

Zunächst wird für ein gegebenes GroundMaterial das dazugehörige Tupel bestimmt, das aus neun Werten besteht.

let long = match ground {
  GroundMaterial::Grass => (7.0, 7.0, 9.0, 9.0, 1.0, 1.0, 0.5, 0.0, 3.0),
  GroundMaterial::Sand => (0.05, 0.05, 0.015, 0.015, 1.0, 1.0, 0.5, 0.0, 3.3),
  GroundMaterial::Snow => (0.5, 0.5, 1.0, 1.0, 2.0, 4.0, 1.0, 0.25, 0.35),
  GroundMaterial::Dirt => (0.02, 0.05, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 1.5),
  GroundMaterial::Stone => (0.05, 0.05, 0.1, 0.1, 1.0, 1.0, 0.5, 0.0, 2.3),
  GroundMaterial::JungleGrass => (7.0, 7.0, 9.0, 9.0, 1.0, 1.0, 0.5, 0.0, 3.0),
  GroundMaterial::Mulch => (0.25, 0.25, 2.5, 2.5, 1.0, 1.0, 0.5, 0.0, 2.3),
};

Die ersten sechs Werte sind Intensitäten, die die Feinheit oder Grobheit des Rauschens beschreiben. Wenn der siebte und achte Wert größer als 0,2 sind, so werden mehrerer Frequenzen überlagert. Der letzte Wert stellt den Exponenten dar, mit dem der zuvor errechnete Wert potenziert wird. Dieser dient zur Kalibrierung der Höhendifferenz für die Height Map.

for i in 0..256 {
  for j in 0..256 {
    let e = ((open_simplex2::<f32>(&table, &[(i as f32) * long.0, (j as f32) * long.1]) +
    1.0) / 2.0) +

Hier stellt e einen Rauschwert dar, welcher durch die Funktion open_simplex2(...) errechnet wird. Diese Funktion benötigt eine Permutationstabelle, die zu einem bestimmten Seed erstellt wurde, und einen Punkt. Die x- und y-Koordinaten des Punktes werden mit dem ersten (long.0) und zweiten (long.1) Wert des Tupels skaliert.

    //Second open_simplex call
    if long.6 > 0.2 {
      long.6 *
      ((open_simplex2::<f32>(&table, &[(i as f32) * long.2, (j as f32) * long.3]) +
      1.0) / 2.0)
    } else {
      long.6
    } +

Ist der siebte Wert (long.6) des Tupels höher als 0,2 wird ein weiterer open_simplex2(...) Aufruf ausgeführt. Das Ergebnis des Aufrufs wird mit dem siebten Wert des Tupels skaliert und zum ersten Ergebnis aufaddiert.

    //Third open_simplex call
    if long.7 > 0.2 {
      long.7 *
      ((open_simplex2::<f32>(&table, &[(i as f32) * long.4, (j as f32) * long.5]) +
      1.0) / 2.0)
    } else {
      long.7
    };

Der dritte Aufruf wird genauso behandelt wie der Zweite. Eine weitere Überlagerung ist nur in den Texturen für Gras und Schnee zu finden. Dadurch ergibt sich eine Textur, die feinkörniger wirkt.

    height_map[i].push(e.powf(long.8));

Jeder Rauschwert wird mit dem letzten Wert (long.8) des Tupels potenziert und der Height Map hinzugefügt.

    tex_map[i].push((e, e, e));

Nun wird es deutlich, dass die Textur nur Grauwerte beinhaltet. Die Textur beinhaltet pro Punkt einen RGB – Farbwert, dabei haben Rot, Grün und Blau den selben Wert.

Bump & Normal Mapping

Autor: Victor Brinkhege

Im Folgenden wird der Prozess des Bump / Normal Mappings Schritt für Schritt erläutert, wie wir ihn bei Plantex angewandt haben. Als Beispiel benutzen wir die sandige Oberfläche.

Schritt 1: Glatte Textur

Zu Beginn sah eine Oberfläche ohne jegliche Texturierung in etwa so aus: Dabei wurde einfach nur die Materialfarbe des Untergrunds flach auf die Textur gezeichnet.

Schritt 2: Height Map

Die Rauschfunktionen erlauben es uns Height Maps zu erstellen. Eine Height Map (dt. Höhenfeld) enthält Höhen-informationen für Pixelcoordinaten einer Textur. Bei Plantex wird zur Laufzeit für jeden Oberflächentyp (Grass, Schnee, Sand, Stein, etc.) mittels einer Rauschfunktion eine eigene Height Map erstellt. Diese sehen so aus: Es ist gut zu erkennen, dass die Textur Höhenwerte enthält. Diese sind im Bild in Grautönen dargestellt.

Schritt 3: Normal Maps

Anhand dieser Height Maps lassen sich Normal Maps errechnen. Eine Normal Map enthält einen normalisierten Richtungsvector, der die Normale für jeden Punkt (beziehungsweise jedes Fragment) einer Textur bestimmt.

Die Umrechnung von einer Height Map zu einer Normal Map erfolgt bei Plantex durch die Finite-Differenz-Methode. Diese Methode erlaubt es uns, die Normalvektoren vergleichsweise effizient zu approximieren. In Pseudocode funktioniert sie wie folgt:

Texel Position merken
Nachbarn finden
Für gegenüberliegende Nachbarn die Höhendifferenz berechnen
Daraus die Steigung in X-, bzw. Y-Richtung ableiten

In Rust haben wir den Algorithmus so umgesetzt:

/// Converts a height map into a normal map
pub fn convert(map: Vec<Vec<f32>>, scale: f32) -> Vec<Vec<(f32, f32, f32)>> {
    let mut normals: Vec<Vec<(f32, f32, f32)>> =
        vec![vec![(0.0,0.0,0.0); map[0].len() as usize]; map.len() as usize];
    let mut n: Vector3f = Vector3f::new(0.0, 0.0, 0.0);
    let mut arr;

    for i in 0..map.len() {
        for j in 0..map[0].len() {
            arr = neighbours(&map, (i as i32, j as i32));
            n.x = scale *
                  -(arr[8 as usize] - arr[6 as usize] + 2.0 * (arr[5 as usize] - arr[3 as usize]) +
                    arr[2 as usize] - arr[0 as usize]);
            n.y = scale *
                  -(arr[0 as usize] - arr[6 as usize] + 2.0 * (arr[1 as usize] - arr[7 as usize]) +
                    arr[2 as usize] - arr[8 as usize]);
            n.z = 1.0;
            n = n.normalize();
            normals[i as usize][j as usize] = (n.x, n.y, n.z);
        }
    }
    normals
}

Auch die Normal Maps kann man leicht darstellen, indem man die X, Y und Z Werte als Rot, Grün und Blau Werte interpretiert. Solche Normal Maps sind häufig an ihrer überwiegend blauen Farbe zu erkennen. Diese stammt daher, dass die meisten Normalen auf der Textur nach oben, also in Z-Richtung, zeigen. Der Z-Wert der Textur wird beim Anzeigen als Blauwert interpretiert.

Schritt 4: Normalen transformieren

Die resultierenden Normalen liegen nun allerdings noch im Texturkoordinatensystem vor. Sie beziehen sich auf die Oberfläche und müssen deshalb in Weltkoordinaten transformiert werden, damit man sinnvolle Lichtberechnungen mit ihnen durchführen kann. Hierzu wird die Normal Map mit der Tangent Binormal Normal (TBN) Matrix transformiert.

Der Code, der zur Transformation des Koordinatensystems benutzt wurde, befindet sich bei uns im Fragment Shader der Chunks und sie so aus:

// Retrieve Normal Map for sand
normal_map = texture(normal_sand, tex).rgb;

// Calculate Tangent Binormal Normal (tbn) Matrix 
mat3 tbn = cotangent_frame(normal_map, x_position, x_tex_coords);

// Convert Normals to World Coordinates
vec3 real_normal = normalize(tbn * -(normal_map * 1.6 - 1.0));

Die Textur, die dabei entsteht ähnelt sichtbar der vorigen:

Schritt 5: Lichtberechnungen

Mit Hilfe dieser Normal Maps können nun die gewöhnlichen Lichtberechnungen durchgeführt werden. Da die Fragmente, die von der Lichtquelle abgeneigt sind, dunkler eingefärbt werden als jene Fragmente, die zur Lichtquelle zeigen, entsteht ein realistischer Effekt von einer Oberfläche mit Kontur.

Mehr zur Beleuchtung der Oberflächen gibt es im nächsten Kapitel.

Texturierung der Seitenflächen

Autor: Helena Keller

Die Seitenflächen der Hexagone haben bei der Texturierung zunächst Probleme mit sich gebracht, da jedes Hexagon eine andere Höhe haben kann. So sahen die Texturen an den Seiten sehr verzerrt aus. Da die Hexagone ihre Höhe erst im Vertex Shader erhalten haben, lag unser Problem im Wesentlichen darin, im Shader die Höhe in Texturkoordinaten umzuwandeln und die Textur damit, der Höhe entsprechend, an den Seiten zu wiederholen.

Unsere Lösung dieses Problems sieht so aus, dass wir uns die y-Werte der Texturkoordinaten der Seiten "markiert" haben, indem wir diesen einen eigentlich unmöglichen Wert von größer als 1 zugewiesen haben. Somit hatten wir im Shader die Möglichkeit diese Werte von denen der Ober- und Unterflächen zu unterscheiden, also auch an die Höhe anzupassen. Wir konnten diese angepassten Texturkoordinaten natürlich nicht dabei belassen, da diese immer zwischen 0 und 1 liegen müssen. Also haben wir von diesen immer nur die Nachkommastelle als Koordinate behalten und hatten so unser Ergebnis.

Ein Nebeneffekt unserer Art der Texturierung unter Anwendung von Bump Mapping ist, dass die Seiten natürlich andere Normalen beim Bump Mapping erhalten und so die Textur etwas anders wirkt, als die der Oberflächen. Das liegt daran, dass die Normalen der Seiten nicht zur Lichtquelle zeigen und die Flächen damit dunkler werden. Dadurch fehlt auch das Licht, das für ein ordentliches Bump Mapping notwendig ist.

Zeichnen der Texturen mithilfe von OpenGL

Autor: Helena Keller

Im Vertex Shader benötigen wir für das Zeichnen der Texturen sogenannte Texturkoordinaten, die in einem Wertebereich zwischen 0 und 1 liegen müssen. Dies ist für unsere spezielle Form, das Hexagon, etwas problematisch gewesen, da alles vom Hexagon in dem Wertebereich sein soll und dessen geometrischen Werte errechnet und den zutreffenden Vertices zugewiesen werden muss.

Anschließend wird im Vertex Shader darauf geachtet welche Fläche gerade bearbeitet wird. Das heißt wenn wir eine Seitenfläche bearbeiten wird darauf die Höhe angepasst. Anschließend werden diese Daten, die Materialfarbe und auch alle weiteren notwendigen Daten, wie die Primitives, an den Fragment Shader übergeben

An unseren Fragment Shader müssen wir jedoch noch weitere Daten zur Erzeugung der Texturen übergeben. Diese sind:

  • ein Wert x_ground, der die Landschaftsfläche repräsentiert, die jetzt dargestellt werden soll,
  • Texturen und Normalen für jede vorhandene Oberfläche
  • und einen, von der Mitte zur Aussenseite, interpolierten Wert, bei uns „radius“, um die Hexagone zu umranden

Nach dem x_ground unterscheiden wir die verschiedenen Landschaften und wählen danach die jeweils zutreffende aus. Danach erstellen wir die dazu passende Normal Map, mithilfe der Methode texture(..), die die Normalen und die angepassten Texturkoordinaten übergeben bekommt. Die Textur erzeugen wir ebenfalls mit der Methode texture(..) übergeben dieses mal jedoch die jeweilige Textur mit den Texturkorrdinaten.

if (x_ground == 1) {
normal_map = texture(normal_grass, tex).rgb;
diffuse_color = texture(grass_texture, x_tex_coords).rgb;
}

Diese Textur ist bislang noch ein Graues Bild. Um sie vernünftig zu zeichnen geben wir also noch die notwendige Materialfarbe an und fügen noch Licht hinzu, da ansonsten nichts sichtbar wäre. Dazu gehört ambientes Licht, das die allgemeine Beleuchtung aus der Umgebung darstellt, diffuses Licht, das das Licht beschreibt, welches das Objekt selbst ausstrahlt und spekulares Licht, das die Reflektion des Objektes zeigt. Diese werden im Kapitel „Beleuchtung der Oberflächen – Blinn Beleuchtungsmodell“ noch genauer erläutert.

Für die Umrandung der Hexagone könnnen wir mit dem interpolierten Wert, ab einer von uns bestimmten Größe, die Textur etwas verdunkeln und erhalten so die gewünschte Umrandung.

results matching ""

    No results matching ""