Normal Maps und Height Maps

In unseren OpenGL-Tutorials erfolgt die Beleuchtung der 3D-Modelle auf pixelbasis im Fragment Shader (Normal Mapping). Hierbei werden für alle Pixel der Modelloberfläche die für diese Berechnungen benötigten Normalenvektoren aus einer speziellen Textur – der sogenannten Normal Map – ausgelesen.
In gewisser Weise verwandt mit den Normal Maps sind die sogenannten Height Maps, welche den Höhenverlauf einer Modelloberfläche speichern. Die Verwandtschaft begründet sich darin, dass sich Height Maps problemlos in Normal Maps umrechnen lassen.
Doch woher kommen eigentlich die zusätzlichen Oberflächendetails, die in einer Normal bzw. einer Height Map gespeichert sind?



Heutzutage werden die zusätzlichen Oberflächendetails in der Regel während des Designs der 3D-Modelle entworfen und in einem High-Polygon-Modell gespeichert. Um eine möglichst hohe Performance in Echtzeitanwendungen zu garantieren, ist man gezwungen, High-Polygon-Modelle in Low-Polygon-Modelle herunterzurechnen. Um nun den damit einhergehenden Detailverlust zu begrenzen, werden die Oberflächendetails der High-Polygon-Modelle in verschiedenen Texturen gespeichert.

Height Map: Speichert den Höhenverlauf der High-Polygon-Modell-Oberfläche.

Normal Map: Speichert die Normalenvektoren der High-Polygon-Modell-Oberfläche.

Normal Height Map: Speichert sowohl die Normalenvektoren als auch den Höhenverlauf der High-Polygon-Modell-Oberfläche (wichtig für das sogenannte Parallax Normal Mapping, auch Offset Normal Mapping oder Virtual Displacement Mapping genannt)

Surface Map: Speichert ein „Abbild“ der High-Polygon-Modell-Oberfläche.

Specular Map: Speichert Reflexionsdetails (spiegelnde Bereiche) der High-Polygon-Modell-Oberfläche.


Hinweis:
Grafikkarten der neuesten Generation unterstützen ein Verfahren mit Namen Tesselation, bei dem sich in Echtzeit während des Renderings aus einem Low-Polygon-Modell ein High-Polygon-Modell berechnen lässt.



Wie lassen sich Normalenvektoren in einer Textur speichern?

Befassen wir uns nun mit der Frage, wie sich ein Normalenvektor in Form eines RGB-Farbwerts speichern lässt. Zur Erinnerung, die Komponenten eines Normalenvektors liegen in einem Wertebereich von -1 bis +1, die RGB-Farbwerte in einem Bereich von 0 bis 255.
Zunächst lässt sich jede Vektorkomponente einem RGB-Farbkanal zuordnen. Im Allgemeinen trifft man dabei die folgende Vereinbarung:

Normal.x :  Rotkanal
Normal.y :  Grünkanal
Normal.z :  Blaukanal


Bei der gegenseitigen Umrechnung von Vektorkomponenten in Farbwerte und umgekehrt identifiziert man einen Wert von -1 mit einem Farbwert von 0 und einen Wert von +1 mit dem Farbwert 255:

Normal.x = -1.0f:  Rotkanal  = 0; Normal.x = 1.0f:  Rotkanal  = 255
Normal.y = -1.0f:  Grünkanal = 0; Normal.y = 1.0f:  Grünkanal = 255
Normal.z = -1.0f:  Blaukanal = 0; Normal.z = 1.0f:  Blaukanal = 255


Für die gegenseitige Umrechnung lassen sich demnach die folgenden Beziehungen verwenden:

unsigned char Red   = (unsigned char)(127.0f * Normal.x + 128.0f);
unsigned char Green = (unsigned char)(127.0f * Normal.y + 128.0f);
unsigned char Blue  = (unsigned char)(127.0f * Normal.z + 128.0f);

Normal.x = (Red - 128)/127.0f;
Normal.y = (Green - 128)/127.0f;
Normal.z = (Blue - 128)/127.0f;


Zur Erinnerung, bei den einzelnen Farbwerten handelt es sich um 8-Bit-Variablen. Zum Speichern dieser Werte verwenden wir daher zweckmäßigerweise Variablen vom Typ unsigned char.

In unseren GLSL-Programmen arbeiten wir nicht mit Farbwerten vom Typ unsigned char, sondern mit Farbwerten vom Typ float im Bereich von 0 bis 1. Die gegenseitige Umrechnung von Vektorkomponenten in Farbwerte und umgekehrt müssen wir also etwas abändern:

float Red   = 0.5 * Normal.x + 0.5;
float Green = 0.5 * Normal.y + 0.5;
float Blue  = 0.5 * Normal.z + 0.5;

Normal.x = (Red – 0.5)/0.5f   = 2.0 * Red - 1.0;
Normal.y = (Green - 0.5)/0.5f = 2.0 * Green - 1.0;
Normal.z = (Blue - 0.5)/0.5f  = 2.0 * Blue - 1.0;


Im Fragment Shader lassen sich der Texturzugriff und die Umrechnung der Farbwerte wie folgt miteinander kombinieren:

vec3 Normal = 2.0*texture2D(NormalTexture, gs_TexCoord[0].st).rgb - 1.0;


Eine Height Map in eine Normal Map konvertieren

Die CTexture-Klasse in unseren OpenGL-Tutorials bietet die Möglichkeit, Texturen (vorzugsweise Height Maps) zu laden und diese in Normal Maps umzurechnen. Einzelheiten zum Laden von Texturen können Sie im Artikel Grafikdateien (Textur-Daten) mithilfe von SDL-Image laden nachlesen. An dieser Stelle konzentrieren wir uns auf die Berechnung der Normalenvektoren.
Rekapitulieren wir – Normalenvektoren dienen zur Beschreibung der Orientierung von Ebenen im Raum. Aufgespannt wird eine Ebene durch zwei Spannvektoren, die sich ihrerseits aus drei Punkten konstruieren lassen (siehe Artikel Schnittpunktberechnung: Strahl schneidet Dreieck). Das normierte Kreuzprodukt dieser Spannvektoren liefert die gesuchte Normale:

Diff1 = Point2 - Point1;
Diff2 = Point3 - Point1;

D3DXVec3Cross(&Normal, &Diff1, &Diff2);
Normalize3DVector(&Normal, &Normal);


Die drei Punkte entsprechen drei benachbarten Pixeln in der Height Map. Entsprechend der gängigen Konvention ordnen wir die Höhenwerte den z-Komponenten dieser Punkte zu und stellen uns die Textur in der xy-Ebene liegend vor:

// Die Textur wird als Fläche in der xy-Ebene aufgefasst:
Point1 = D3DXVECTOR3((float)i, (float)j,
                     TextureHeightScale*(float)HeightColor1);

Point2 = D3DXVECTOR3((float)i + 1.0f, (float)j,
                     TextureHeightScale*(float)HeightColor2);

Point3 = D3DXVECTOR3((float)i, (float)j + 1.0f,
                     TextureHeightScale*(float)HeightColor3);


Hinweis:
Die in unseren OpenGL-Tutorials berechneten Normal Maps sind kachelbar. Dies garantiert einen nahtlosen Übergang zwischen benachbarten Texturen. Erreicht wird das dadurch, dass allen Randpixeln der selbe Normalenvektor (D3DXVECTOR3(0.0f, 0.0f, 1.0f)) zugeordnet wird.

Zum Abschluss dieses Artikels werfen wir nun noch einen Blick auf den vollständigen Source Code für die Berechnung der Normal Maps:

// Grafikdatei in eine SDL-Surface laden:
SDL_Surface *image = IMG_Load(FileName);

if(!image)
{
    Add_To_Log(FileName,"IMG_Load() failed");
}

// Höhe, Breite, Farbtiefe (BytesPerPixel) und Anzahl der
// Farbpixel ermitteln:
Width  = image->w;
Height = image->h;

long BytesPerPixel = image->format->BytesPerPixel;
long NumPixel = Width * Height;

// Datenarray mit genügend Speicherplatz für die mithilfe
// von SDL-Image eingelesene Grafikdatei:
GLuint* data;
data = (GLuint*)new GLuint[(NumPixel * sizeof(GLuint))];

// Datenarray mit genügend Speicherplatz für die zu berechnende
// Normal Map:
GLuint* data2;
data2 = (GLuint*)new GLuint[(NumPixel * sizeof(GLuint))];

ZeroMemory(data,(NumPixel * sizeof(GLuint)));
ZeroMemory(data2,(NumPixel * sizeof(GLuint)));


long Index, IndexA, IndexB, i, j, k;

unsigned int  color;
unsigned char colorRed;
unsigned char colorGreen;
unsigned char colorBlue;
unsigned char colorAlpha;

unsigned char* pColor = (unsigned char*)image->pixels;

k = 0;

// SDL-Surface-Daten auslesen und im data-Array speichern:

D3DXMATRIXA16 tempMatrix1, tempMatrix2, tempMatrix3;
D3DXVECTOR3 temp1Vektor3;

for(i = 0; i < Height; i++)
{
    for(j = 0; j < Width; j++)
    {
        Index = BytesPerPixel*j + i*Width*BytesPerPixel;

        if(isPNG == false)
        {
            colorBlue  = pColor[Index];
            colorGreen = pColor[Index+1];
            colorRed   = pColor[Index+2];
        }
        else
        {
            colorBlue  = pColor[Index+2];
            colorGreen = pColor[Index+1];
            colorRed   = pColor[Index];
        }

        if(colorBlue > 0 || colorGreen > 0 || colorRed > 0)
            colorAlpha = 255;
        else
            colorAlpha = 0;

        *(data+k) = (colorAlpha << 24) + (colorRed << 16) +
                    (colorGreen << 8) + colorBlue;

        k++;
    }
}

// Berechnung der Normal Map:

unsigned char HeightColor1;
unsigned char HeightColor2;
unsigned char HeightColor3;

D3DXVECTOR3 Point1;
D3DXVECTOR3 Point2;
D3DXVECTOR3 Point3;
D3DXVECTOR3 Diff1;
D3DXVECTOR3 Diff2;
D3DXVECTOR3 Normal;
D3DXVECTOR3 NormalUntransformed;

k = 0;

// Aus jeweils drei Höhenwerten wird nun für jedes Texel
// (Textur-Pixel) eine Normale berechnet und im
// data2-Array gespeichert:

for(i = 0; i < Height; i++)
{
    for(j = 0; j < Width; j++)
    {
        Index  = j + i*Width;
        IndexA = j+1 + i*Width;
        IndexB = j + (i+1)*Width;

        color = *(data+Index);
        HeightColor1 = (unsigned char) color;

        if(IndexB > 0 && IndexB < NumPixel)
            color = *(data+IndexB);
        else
            color = *(data);

        HeightColor2 = (unsigned char) color;

        if(IndexA > 0 && IndexA < NumPixel)
            color = *(data+IndexA);
        else
            color = *(data);

        HeightColor3  = (unsigned char) color;

        // Die Textur wird als Fläche in der xy-Ebene aufgefasst:
        Point1 = D3DXVECTOR3((float)i, (float)j,
                             TextureHeightScale*(float)HeightColor1);

        Point2 = D3DXVECTOR3((float)i + 1.0f, (float)j,
                             TextureHeightScale*(float)HeightColor2);

        Point3 = D3DXVECTOR3((float)i, (float)j + 1.0f,
                             TextureHeightScale*(float)HeightColor3);

        Diff1 = Point2 - Point1;
        Diff2 = Point3 - Point1;

        D3DXVec3Cross(&Normal, &Diff1, &Diff2);
        Normalize3DVector(&Normal, &Normal);

        // x-, y- und z-Werte der Normale wieder in Farbwerte
        // umwandeln:


        if(j > 0 && j < Width-1)
        {
            if(i > 0 && i < Height-1)
            {
                colorRed   = (unsigned char)(127.0f * Normal.x + 128.0f);
                colorGreen = (unsigned char)(127.0f * Normal.y + 128.0f);
                colorBlue  = (unsigned char)(127.0f * Normal.z + 128.0f);
            }
            // obere und untere Texturbegrenzung
            else
            {
                // Allen begrenzenden Pixeln wird der selbe Normalenvektor
                // zugewiesen. Hierdurch wird die Textur kachelbar!

                Normal = D3DXVECTOR3(0.0f, 0.0f, 1.0f);

                colorRed   = (unsigned char)(127.0f * Normal.x + 128.0f);
                colorGreen = (unsigned char)(127.0f * Normal.y + 128.0f);
                colorBlue  = (unsigned char)(127.0f * Normal.z + 128.0f);
            }
        }
        // rechte und linke Texturbegrenzung
        else
        {
             // Allen begrenzenden Pixeln wird der selbe Normalenvektor
             // zugewiesen. Hierdurch wird die Textur kachelbar!

            Normal = D3DXVECTOR3(0.0f, 0.0f, 1.0f);

            colorRed   = (unsigned char)(127.0f * Normal.x + 128.0f);
            colorGreen = (unsigned char)(127.0f * Normal.y + 128.0f);
            colorBlue  = (unsigned char)(127.0f * Normal.z + 128.0f);
        }

        colorAlpha = HeightColor1;

        // Farbkanäle (Normale+Höhenwert) miteinander kombinieren
        // und im data2-Array speichern:
        *(data2+k) = (colorAlpha << 24) + (colorRed << 16) +
                     (colorGreen << 8) + colorBlue;

        k++;
    }
}

// Textur mit den im data2-Array gespeicherten Daten erzeugen:

glBindTexture(GL_TEXTURE_2D, TextureID);

if(MipMapping == TRUE)
{
    /*
    // Falls keine separaten Sampler-Objekte verwendet werden!
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER,
                    GL_LINEAR_MIPMAP_LINEAR);*/


    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, Width, Height, 0, GL_BGRA,
                 GL_UNSIGNED_BYTE, data2);
    glEnable(GL_TEXTURE_2D);
    glGenerateMipmap(GL_TEXTURE_2D);
}
else
{
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);

    /*
    // Falls keine separaten Sampler-Objekte verwendet werden!
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);*/

    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, Width, Height, 0, GL_BGRA,
                 GL_UNSIGNED_BYTE, data2);
}

SDL_FreeSurface(image);

SAFE_DELETE_ARRAY(data)
SAFE_DELETE_ARRAY(data2)