NeHe - Lektion 33 - Komprimierte und unkomprimierte TGAs laden

Lektion 33



Laden von unkomprimierten und Run Length Encoded TGA Bildern von Evan "terminate" Pipho.

Ich habe viele Leute in #gamedev, den gamedev Foren und andere Orten, fragen sehen, wie man TGA Dateien lädt. Der folgende Code und die Erklärung werden Ihnen zeigen wie man sowohl unkomprimierte als auch RLE kompromierte Dateien lädt. Dieses Tutorial ist sehr auf OpenGL gemünzt, aber ich plane in Zukunft ein Tutorial zu schreiben welches genereller ist.

Wir fangen mit den zwei Header Dateien an. Die erste Datei wird unsere Textur-Struktur enthalten, die zweite, Strukturen und Variablen die vom Laden-Code benutzt werden.

Wie bei jeder Header Datei benötigen wir einen 'Inclusion Guards' um ein mehrfaches einbinden zu vermeiden.

Am Anfang der Datei fügen Sie diese Zeilen hinzu:

    #ifndef __TEXTURE_H__                // Schaue, ob der Header bereits definiert wurde
    #define __TEXTURE_H__                // wenn nicht, definiere ihn.

Dann gehen Sie ganz ans Ende und fügen hinzu:

    #endif                        // __TEXTURE_H__ Ende vom Inclusion Guard

Diese drei Zeilen verhindern, dass die Datei mehr als einmal inkludiert wird. Der Rest des Codes in dieser Datei wird zwischen den ersten beiden und der letzten Zeile sein.

In diese Header Datei fügen wir die Standard Header Dateie ein, die wir benötigen werden. Fügen Sie folgende Zeile hinter dem #define __TGA_H__ Befehl ein.

    #pragma comment(lib, "OpenGL32.lib")        // Linke Opengl32.lib
    #include <windows.h>                // Standard Windows Header
    #include <stdio.h>                // Standard Header für Datei I/O
    #include <gl\gl.h>                // Standard Header für OpenGL

Der erste Header ist der standard Windows Header, der zweite ist für die Datei Ein/Ausgabe Funktionen, die wir später benutzen werden und der dritte ist der Standard OpenGL Header für OpenGL32.lib.

Wir werden einen Platz zum speichern von Bild-Daten und Spezifikationen zur Erzeugung einer von OpenGL nutzbaren Textur benötigen. Wir werden die folgende Struktur verwenden.

    typedef struct
    {
        GLubyte* imageData;            // enthält all die Farbwerte für das Bild.
        GLuint  bpp;                // Enthält die Anzahl an Bits pro Pixel.
        GLuint width;                // Die Breite des gesamten Bildes.
        GLuint height;                // Die Höhe des gesamten Bildes.
        GLuint texID;                // Textur ID zur Verwendung mit glBindTexture.    
        GLuint type;                 // wie die Daten in * ImageData gespeichert sind (GL_RGB oder GL_RGBA)
    } Texture;

Nun zur anderen, längeren Header Datei. Erneut brauchen wir die 'Inclusion Guards', genauso wie die vorherigen.

Als nächstes kommen zwei weitere Strukturen die während der Bearbeitung der TGA Datei verwendet werden.

    typedef struct
    {
        GLubyte Header[12];            // Datei Header um die Datei Art zu bestimmen
    } TGAHeader;

    typedef struct
    {
        GLubyte header[6];            // enthält die ersten 6 brauchbaren Bytes der Datei
        GLuint bytesPerPixel;            // Anzahl an BYTES Pro Pixel (3 oder 4)
        GLuint imageSize;            // Menge an benötigtem Speicher, um das Bild zu speichern
        GLuint type;                // Die Art des Bildes, GL_RGB oder GL_RGBA
        GLuint Height;                // Höhe des Bildes
        GLuint Width;                // Breite des Bildes
        GLuint Bpp;                // Anzahl an BITS Pro Pixel (24 oder 32)
    } TGA;

Nun deklarieren wir einige Instanzen unserer zwei Strukturen, so dass wir sie in unserem Code verwenden können.

    TGAHeader tgaheader;                // wird benutzt um unseren Datei-Header zu speichern
    TGA tga;                    // wird benutzt um Datei Informationen zu speichern

Wir müssen einige Datei-Header definieren, damit unser Programm weiß, was für Header ein gültiges Bild haben kann. Die ersten 12 Bytes werden 0 0 2 0 0 0 0 0 0 0 0 0 sein, wenn es sich um ein unkomprimiertes TGA Bild handelt und 0 0 10 0 0 0 0 0 0 0 0 0 wenn es ein RLE komprimiertes ist. Diese beiden Werte erlauben es uns, ob die Datei, die wir lesen, gültig ist.

    // unkomprimierter TGA Header
    GLubyte uTGAcompare[12] = {0,0, 2,0,0,0,0,0,0,0,0,0};
    // kompromierter TGA Header
    GLubyte cTGAcompare[12] = {0,0,10,0,0,0,0,0,0,0,0,0};

Dann deklarierern wir noch ein paar Funktionen, die während des Laden-Prozesses verwendet werden.

    // Lade eine unkomprimierte Datei
    bool LoadUncompressedTGA(Texture *, char *, FILE *);
    // Lade eine komprimierte Datei
    bool LoadCompressedTGA(Texture *, char *, FILE *);

Nun weiter zu CPP Datei und dem eigentlichen Code. Ich werde einige der Fehlermeldungen welassen, um das Tutorial kürzer und leserlicher zu machen. Schauen Sie gegebenenfalls in die beigelgten Dateien (Link am Ende dieses Artikels).

Gleich zu anfang müssen wir unsere Datei inkludieren, die wir eben erst erstellt haben.

    #include "tga.h"                // Inkludiere den Header den wir gerade gemacht haben

Wir müssen keine anderen weiteren Dateien einfügen, da wir die bereits komplett in unserem Header inkludiert haben.

Als nächstes schauen wir uns die erste Funktion an, welche LoadTGA (...) heißt.

    // Lade eine TGA Datei!
    bool LoadTGA(Texture * texture, char * filename)
    {

Ihr übergeben wir zwei Parameter. Der erste ist ein Zeiger auf eine Texture-Struktur, welche Sie in Ihrem Code irgendwo deklariert haben müssen (siehe begefügtes Beispiel). Der zweite Parameter ist ein String, der dem Computer mitteilt, wo unsere Textur-Datei gefunden werden kann.

Die ersten beiden Zeilen der Funktion deklarieren einen Dateizeiger und öffnet dann die Datei, die durch "filename" spezifiziert wurde, welches der zweiter Parameter der Funktion übergeben wurde.

    FILE * fTGA;                    // deklariere Dateizeiger
    fTGA = fopen(filename, "rb");            // öffne Datei zum lesen

Die nächsten paar Zeilen überprüfen, ob die Datei korrekt geöffnet wurde.

    if(fTGA == NULL)                // wenn hier ein Fehler aufgetreten ist
    {
        ...Error code...
        return false;                // gebe False zurück
    }

Als nächstes versuchen wir die ersten zwölf Bytes aus der Datei auszulesen und Sie in unserer TGAHeader Struktur zu speichern, damit wir den Datei-Typ überprüfen können. Wenn fread fehl schlägt, wird die Datei geschlossen ein Fehler angezeigt und die Funktion gibt false zurück.

    // versuche den Datei Header zu lesen
    if(fread(&tgaheader, sizeof(TGAHeader), 1, fTGA) == 0)
    {
        ...Error code here...
        return false;                // gebe False zurück, wenn das fehl schlug
    }

Als nächstes verscuhen wir die Art der Datei zu bestimmen, indem wir unserer neu ausgelesenen Header mit unseren zwei hardcodierten vergleichen. Dadurch erfahren wir, ob es sich um eine komprimierte, unkomprimierte oder gar um eine nicht gültige Datei handelt. Für dieses Vorhaben benutzen wir die memcmp(...) Funktion.

    // Wenn der Datei-Header mit dem unkomprimierten Header übereinstimmt
    if(memcmp(uTGAcompare, &tgaheader, sizeof(tgaheader)) == 0)
    {
        // Lade ein unkomprimiertes TGA
        LoadUncompressedTGA(texture, filename, fTGA);
    }
    // wenn der Datei Header mit dem komprimierten Header übereinstimmt
    else if(memcmp(cTGAcompare, &tgaheader, sizeof(tgaheader)) == 0)
    {
        // Lade ein komprimiertes TGA
        LoadCompressedTGA(texture, filename, fTGA);
    }
    else                        // wenn keins davon übereinstimmt
    {
        ...Error code here...
        return false;                // gebe False zurück
    }

Wir beginnen diesen Abschnitt mit dem Laden einer UNKOMPRIMIERTEN Datei. Diese Funktion basiert im wesentlichen auf NeHe's Code aus Lektion 25.

Als erstes kommt, wie immer, der Funktionskopf.

    // Lade ein unkomprimiertes TGA!
    bool LoadUncompressedTGA(Texture * texture, char * filename, FILE * fTGA)
    {

Dieser Funktion werden drei Parameter übergeben. Die ersten beiden sind die selben wie bei LoadTGA und werden einfach weitergereicht. Der dritte ist der Dateizeiger, von der letzten Funktion, so dass wir unsere Stelle nicht verlieren, wo wir uns befinden.

Als nächstes versuchen wir die nächsten 6 Bytes aus der Datei zu lesen und sie in tga.header zu speichern. Wenn das fehlschlägt, machen wir etwas Fehlerbehandlung und geben false zurück.

    // versuche die nächsten 6 Bytes zu lesen
    if(fread(tga.header, sizeof(tga.header), 1, fTGA) == 0)
    {
        ...Error code here...
        return false;                // gebe False zurück
    }

Nun haben wir alle Informationen, um die Höhe, Breite und bpp unseres Bildes zu berechnen. Wir speichern es sowohl in der Textur als auch in der lokalen Struktur.

    texture->width  = tga.header[1] * 256 + tga.header[0];    // berechne Höhe
    texture->height = tga.header[3] * 256 + tga.header[2];    // berechne die Breite
    texture->bpp = tga.header[4];                // Berechne Bits Pro Pixel
    tga.Width = texture->width;                // kopiere Breite in die lokale Struktur
    tga.Height = texture->height;                // kopiere die Höhe in die lokale Struktur
    tga.Bpp = texture->bpp;                    // kopiere Bpp in die lokale Struktur

Nun stellen wir sicher, dass die Höhe und die Breite mindestens jeweils ein Pixel betragen und dass bpp entweder 24 oder 32 ist. Wenn irgend ein Wert aus der Reihe tanzt, zeigen wir erneut ein Fehler an, schließen die Datei und verlassen die Funktion.

    // stelle sicher, dass alle Informationen gültig sind
    if((texture->width <= 0) || (texture->height <= 0) || ((texture->bpp != 24) && (texture->bpp !=32)))
    {
        ...Error code here...
        return false;                // gebe False zurück
    }

Als nächstes setzen wir die Art des Bildes. 24 Bit Bilder sind vom Typen GL_RGB und 32 Bit Bild vom Typen GL_RGBA.

    if(texture->bpp == 24)                // Ist es ein 24bpp Bild?
        texture->type    = GL_RGB;        // Wenn ja, setze GL_RGB
    else                        // wenn es nicht 24 ist, muss es 32 sein
        texture->type    = GL_RGBA;        // deshalb setze die Art auf GL_RGBA

Nun berechnen wir die BYTES pro Pixel und die Gesamtgröße der Bilddaten.

    tga.bytesPerPixel = (tga.Bpp / 8);        // berechne die BYTES Pro Pixel
    // berechne den Speicherplatzbedarf für das Bild
    tga.imageSize = (tga.bytesPerPixel * tga.Width * tga.Height);

Wir benötigen etwas Platz um alle Bilddaten zu speichern, weshalb wir malloc benutzen, um die richtige Menge an Speicher zu aloziieren.

Dann stellen wir sicher, dass der Speicher reserviert wurde und nicht gleich NULL ist. Falls ein Fehler aufgetreten ist, durchlaufe die Fehlerbehandlung.

    // Aloziiere Speicher
    texture->imageData = (GLubyte *)malloc(tga.imageSize);
    if(texture->imageData == NULL)            // stelle sicher, dass die Alloziierung Ok war
    {
        ...Error code here...
        return false;                // Wenn nicht, gebe False zurück
    }

Hier versuchen wir alle Bilddaten einzulesen. Wenn das nicht geht, durchlaufen wir erneut den Fehlerbehandlungscode.

    // versuche alle Bilddaten zu lesen
    if(fread(texture->imageData, 1, tga.imageSize, fTGA) != tga.imageSize)
    {
        ...Error code here...
        return false;                // wenn das nicht geht, gebe False zurück
    }

TGA Dateien speichern Ihre Bilder in umgekehrter Reihenfolge, im Gegensatz zu OpenGL, weshalb wir das Format von BGR zu RGB ändern müssen. Um das zu machen, vertauschen wir das erste mit dem dritten Byte und zwar für jeden Pixel.

Steve Thomas fügt hinzu: Ich habe den TGA Lade-Code etwas optimiert. Es betrifft das Vertauschen von BGR in RGB mit nur 3 binären Operationen. Anstatt eine temporäre Variable zu verwenden, wenden Sie ein XOR drei Mal auf die zwei Bytes an.

Dann schließen wir die Datei und beenden die Funktion erfolgreich.

    // Starte die Schleife
    for(GLuint cswap = 0; cswap <(int)tga.imageSize; cswap += tga.bytesPerPixel)
    {
        // erstes Byte XOR drittes Byte XOR erstes Byte XOR drittes Byte
        texture->imageData[cswap] ^= texture->imageData[cswap+2] ^=
        texture->imageData[cswap] ^= texture->imageData[cswap+2];
    }

    fclose(fTGA);                    // schließe die Datei
    return true;                    // kehre erfolgreich zurück
}

Das ist alles um eine unkomprimierte TGA Datei zu laden. RLE komprimierte Datei zu laden ist nur etwas schwieriger. Wir lesen den Header ein, suchen Höhe/Breite/bpp wie gewohnt, genauso wie bei der unkomprimierten Version, weshalb ich einfach den Code hier hin schreibe und Sie können sich die komplette Erklärung ggf. auf den vorherigen Seiten noch einmal durchlesen.

    bool LoadCompressedTGA(Texture * texture, char * filename, FILE * fTGA)
    {
        if(fread(tga.header, sizeof(tga.header), 1, fTGA) == 0)
        {
            ...Error code here...
        }
        texture->width  = tga.header[1] * 256 + tga.header[0];
        texture->height = tga.header[3] * 256 + tga.header[2];
        texture->bpp    = tga.header[4];
        tga.Width    = texture->width;
        tga.Height    = texture->height;
        tga.Bpp    = texture->bpp;
        if((texture->width <= 0) || (texture->height <= 0) || ((texture->bpp != 24) && (texture->bpp !=32)))
        {
            ...Error code here...
        }                                }

        if(texture->bpp == 24)            // Ist es ein 24bpp Bild?
            texture->type    = GL_RGB;    // wenn ja, setze Type auf GL_RGB
        else                    // wenn es nicht 24 ist, muss es 32 sein
            texture->type    = GL_RGBA;    // deshalb setze Type auf GL_RGBA

        tga.bytesPerPixel    = (tga.Bpp / 8);
        tga.imageSize        = (tga.bytesPerPixel * tga.Width * tga.Height);

Nun müssen wir die Speichermenge alloziieren, die das Bild NACH dem entpacken haben wird. Dazu nutzen wir wieder malloc. Wenn die Speicherreservierung nicht erfolgreich ist, durchlaufen wir die Fehlerbehandlung und geben false zurück.

    // Alloziiere Speicher um die Bilddaten zu speichern
    texture->imageData    = (GLubyte *)malloc(tga.imageSize);
    if(texture->imageData == NULL)            // wenn kein Speicher reserviert werden kann...
    {
        ...Error code here...
        return false;                // gebe False zurück
    }

Als nächstes müssen wir bestimmen, aus wie vielen Pixeln das Bild besteht. Wir werden das in der Variable "pixelcount" speichern.

Wir müssen ebenfalls speichern, bei welchem Pixel wir uns gerade befinden und welches Byte in imageData wir gerade schreiben, um Overflows und Überschreiben von alten Daten zu vermeiden.

Wir alloziieren genügend Speicher um einen Pixel zu speichern.

    GLuint pixelcount = tga.Height * tga.Width;    // Anzahl der Pixel in dem Bild
    GLuint currentpixel    = 0;            // aktueller Pixel den wir aus den Daten lesen
    GLuint currentbyte    = 0;            // aktuelles Byte das wir in Imagedata schreiben
    // Speicherplatz für 1 Pixel
    GLubyte * colorbuffer = (GLubyte *)malloc(tga.bytesPerPixel);

Als nächstes komtm eine große Schleife.

Teilen wir es in mehrere managebare Teile (Chunks) auf.

Als erstes deklarieren wir eine Variable, um den Chunk-Header zu speichern. Ein Chunk-Header bestimmt, ob der folgende Abschnitt RLE (komprimiert) oder RAW (unkomprimiert) ist und wie lang er ist. Wenn der ein Byte-Header kleiner oder gleich 127 ist. dann ist es ein RAW Header. Der Wert des Headers ist die Anzahl der Farben minus eins, die wir auslesen und in den Speicher kopieren, bevor wir zum nächsten Header Byte kommen. Dann addieren wir eins zu dem Wert, den wir erhalten und lesen so viele Pixel aus und kopieren sie nach ImageDate, genauso wie wir es mit den unkomprimierten gemacht haben. Wenn der Header ÜBER 127 ist, dann ist es die Anzahl wie oft der nächste Pixel-Wert wiederholt werden soll. Um die tatsächliche Anzahl der Wiederholungen zu erhalten, nehmen wir den zurückgelieferten Wert und subtrahieren 127 um den ein Bit-Header-Identifizierer loszuwerden. Dann lesen wir den nächsten Pixel und kopieren ihn so oft in den Speicher, wie es nötig ist.

Zum Code. Als erstes lesen wir den ein Byte-Header.

    do                        // Starte Schleife
    {
    GLubyte chunkheader = 0;            // Variable um den Wert des The Id Chunk zu speichern
    if(fread(&chunkheader, sizeof(GLubyte), 1, fTGA) == 0)    // versuche den ChunkHeader zu lesen
    {
        ...Error code...
        return false;                // wenn das fehl schlug, gebe false zurück
    }

Als nächstes überprüfen wir, ob es ein RAW Header ist. Wenn es einer ist, müssen wir eins zum Wert addieren, um die totale Anzahl der Pixel im folgenden Header zu erhalten.

    if(chunkheader <128)                // Wenn der Chunk ein 'RAW' Chunk ist
    {
        chunkheader++;                // Addiere 1 zu dem Wert um die absolute Anzahl an Raw Pixel zu erhalten

Wir starten dann eine weitere Schleife, um alle Farbinformationen einzulesen. Sie wird so häufig durchlaufen, wie es durch den Chunk-Header spezifiziert wurde und wird jeweils einen Pixel pro Schleifendurchgang lesen und speichern.

Als erstes lesen wir und überprüfen die Pixel-Daten. Die Daten für einen Pixel werden in der Variable colorbuffer gespeichert. Als nächstes überprüfen wir, ob es ein RAW Header ist. Wenn es einer ist, müssen wir eins zum Wert addieren, um die totale Anzahl der Pixel im folgenden Header zu erhalten.

    // Starte Pixel-Lesen-Schleife
    for(short counter = 0; counter <chunkheader; counter++)
    {
        // versuche 1 Pixel zu lesen
        if(fread(colorbuffer, 1, tga.bytesPerPixel, fTGA) != tga.bytesPerPixel)
        {
            ...Error code...
            return false;            // wenn es fehl schlug, gebe False zurück
        }

Der nächste Teil in unserer Schleife wird die Farbwerte, die in colorbuffer gespeichert sind, nehmen und diese in die imageDate Variable für den späteren Gebrauch schreiben. In diesem Prozess werden die Daten vom BGR Format ins RGB Format gebracht oder von BGRA ins RGBA, abhängig von der Anzahl der Bits pro Pixel. Wenn wir fertig sind, inkrementieren wir den aktuellen Byte und den aktuellen Pixel Zähler.

    texture->imageData[currentbyte] = colorbuffer[2];        // Schreibe das 'R' Byte
    texture->imageData[currentbyte + 1    ] = colorbuffer[1];    // Schreibe das 'G' Byte
    texture->imageData[currentbyte + 2    ] = colorbuffer[0];    // Schreibe das 'B' Byte
    if(tga.bytesPerPixel == 4)                    // wenn es ein 32bpp Bild ist...
    {
        texture->imageData[currentbyte + 3] = colorbuffer[3];    // Schreibe das 'A' Byte
    }
    // Inkrementiere den Byte Zähler um die Anzahl der Bytes in einem Pixel
    currentbyte += tga.bytesPerPixel;
    currentpixel++;                    // Inkrementiere die Anzahl der Pixel um 1

Der nächste Abschnitt behandelt Teile des Headers, die den RLE-Abschnitt repräsentieren. Als erstes subtrahieren wir 127 vom Chunkheader, um die Anzahl der Wiederholungen für die nächste Farbe zu erhalten.

    else                        // wenn es ein RLE Header ist
    {
        chunkheader -= 127;            // Subtrahiere 127, um das ID Bit loszuwerden

Dann versuchen wir den nächsten Farb-Wert zu lesen.

    // lese den nächsten Pixel
    if(fread(colorbuffer, 1, tga.bytesPerPixel, fTGA) != tga.bytesPerPixel)
    {
        ...Error code...
        return false;                // wenn es schief geht, gebe False zurück
    }

Als nächstes beginnen wir eine Schleife um die Pixel, die wir gerade in den Speicher gelesen haben, mehrmals zu kopieren, so wie es durch den Wert im RLE-Header vorgegeben ist.

Dann kopieren wir die Farb-Werte in die Bild-Daten und nehem den Tausch der R und B Werte vor.

Dann inkrementieren wir die aktuellen Bytes und aktuellen Pixel, so dass wir an der richtigen Stelle sind, wenn wir die Werte erneut schreiben.

    // Starte die Schleife
    for(short counter = 0; counter <chunkheader; counter++)
    {
        // kopiere das 'R' Byte
        texture->imageData[currentbyte] = colorbuffer[2];
        // kopiere das 'G' Byte
        texture->imageData[currentbyte + 1    ] = colorbuffer[1];
        // kopiere das 'B' Byte
        texture->imageData[currentbyte + 2    ] = colorbuffer[0];
        if(tga.bytesPerPixel == 4)        // Wenn es ein 32bpp Bild ist
        {
            // kopiere das 'A' Byte
            texture->imageData[currentbyte + 3] = colorbuffer[3];
        }
        currentbyte += tga.bytesPerPixel;    // Inkrementiere den Byte Zähler
        currentpixel++;                // Inkrementiere den Pixel Zähler

Dann machen wir mit der Hauptschleife so lange weiter wie wir noch Pixel zum lesen haben.

Als letztes schließen wir die Datei und kehren erfolgreich zurück.

        while(currentpixel <pixelcount);    // noch mehr Pixel zum lesen?... durchlaufe Schleife erneut
        fclose(fTGA);                // Schließe Datei
        return true;                // kehre erfolgreich zurück
    }

Nun haben Sie einige Bilddaten für glGenTextures und glBindTexture. Ich rate Ihnen für diese Befehle NeHe's Tutorial Nr. 6 und Nr. 24 durchzulesen. Damit schließt sich mein allererstes Tutorial. Ich gebe keine Garantie dafür, dass mein Code fehlerfrei ist, obwohl ich mir alle Mühe gegeben habe, das zu erreichen. Besonderen Dank an Jeff "NeHe" Molofee für seine großartigen Tutorials und an Trent "ShiningKnight" Polack der mir beim durchsehen dieses Tutorials geholfen hat. Wenn Sie Fehler finden, Vorschläge haben oder Kommentare, können Sie mir gerne eine email schreiben (terminate@gdnmail.net) oder mich über ICQ erreichen (UIN# 38601160). Enjoy!

Evan Pipho (Terminate)

Jeff Molofee (NeHe)

* DOWNLOAD Visual C++ Code für diese Lektion.

* DOWNLOAD Borland C++ Builder 6 Code für diese Lektion. ( Conversion by Christian Kindahl )
* DOWNLOAD Code Warrior 5.3 Code für diese Lektion. ( Conversion by Scott Lupton )
* DOWNLOAD Delphi Code für diese Lektion. ( Conversion by Michal Tucek )
* DOWNLOAD Dev C++ Code für diese Lektion. ( Conversion by Dan )
* DOWNLOAD Linux/GLX Code für diese Lektion. ( Conversion by Patrick Schubert )
* DOWNLOAD Mac OS X/Cocoa Code für diese Lektion. ( Conversion by Bryan Blackburn )
* DOWNLOAD Visual Studio .NET Code für diese Lektion. ( Conversion by Grant James )



Deutsche Übersetzung: Joachim Rohde
Der original Text ist hier zu finden.
Die original OpenGL Tutorials stammen von NeHe's Seite.