OpenGL: Vertex- und Fragmentprogramme

Autor: Nicolai 'Prefect' Hähnle

Vor langer Zeit habe ich eine Reihe von Tutorials über SDL und OpenGL geschrieben. Dieses Tutorial ist gewissermaßen eine Fortsetzung dieser Reihe und nach wie vor rein zweidimensional, allerdings liegt der Fokus ganz auf OpenGL. Auch werde ich nicht mehr so viel Händchenhalten: Mit der Entwicklungsumgebung, dem Compiler und dem Linker umgehen zu können ist ein Muss. Insgesamt gilt: Wenn man etwas nicht versteht hilft es im Zweifelsfall eigentlich immer, erst einmal ein Blatt Papier in die Hand zu nehmen, sich zurückzulehnen und nochmal alles im Detail durchzugehen. (Eigentlich gilt das ja immer, aber ich erwähne es hier vorsichtshalber trotzdem noch einmal).

In den letzten Jahren hat sich ein signifikanter Wandel in 3D-Hardware vollzogen. Während alte Hardware nur eine Reihe fest vorgeschriebener Operationen durchführen kann, können die neuesten Grafikkarten nahezu beliebige Programme hardwarebeschleunigt ausführen. Programmiert werden diese neuen Grafikkarten entweder in einer C-ähnlichen Sprache oder in Assembler.

Dieses Tutorial beschäftigt sich mit den OpenGL-Extensions GL_ARB_vertex_program und GL_ARB_fragment_program, die eine Assemblersprache zur Programmierung von Grafikhardware bereitstellen.

Voraussetzungen

Die benötigten OpenGL-Extensions werden von ATI-Grafikkarten ab dem R300 unterstützt, also von der Radeon 9500 an aufwärts. NVidia-Grafikkarten unterstützen die Extensions ab dem NV30, also ab der Geforce FX. Auch der Mesa Softwarerenderer unterstützt diese Extensions. Falls noch Zweifel bestehen lohnt ein Blick auf die OpenGL Hardware Registry von Delphi3d.net.

Für die verwendeten Extensions werden neue GL-Konstanten benötigt. Diese sind auf manchen Systemen schon in den vorinstallierten Headern aufgeführt, auf manchen System nicht. Im Zweifelsfall kann die benötigte Headerdatei glext.h von der OpenGL Extension Registry heruntergeladen werden.

Außerdem benötigt dieses Tutorial im Gegensatz zu seinen Vorgängern SDL_image, eine Bibliothek zum Laden aller gängigen Bildformate. Fast alle Linux-Distributionen beinhalten SDL_image, auch wenn es oft nicht standardmässig installiert ist. Windows-User können sich auf der Webseite die benötigten Header und Libraries herunterladen.

Was tun Vertex- und Fragmentprogramme?

Im Gegensatz zu früheren ARB-Extensions ersetzen Vertex- und Fragmentprogramme sehr große Teile der ursprünglichen OpenGL-Pipeline.

Vertexprogramme ersetzen die Transformation der Vertexdaten. Im Klartext bedeutet das, dass das aktive Vertexprogramm einmal für jeden Vertex aufgerufen wird, der zum Rendern benötigt wird: Dreimal für ein einzelnes Dreieck, viermal für ein Quad, usw. Als Eingabe erhält das Vertexprogramm zum einen Vertexdaten. Das sind die Position des Vertex, die Farbe, die Texturkoordinaten, kurz: Alles, was innerhalb von glBegin()/glEnd()-Paaren angegeben wird oder in einem Vertexarray steht - und zwar unverändert. Außerdem kann das Vertexprogramm auf globalen OpenGL-State, wie zum Beispiel die momentan gesetzte Modelmatrix oder spezielle „Environment-Parameter“, die mit ARB_vertex_program und ARB_fragment_program neu eingeführt wurden, zugreifen. Aus diesen Eingaben berechnet das Vertexprogramm alle Daten, die fürs weitere Rendering benötigt werden, also mindestens die transformierte Position des Vertexes, aber meistens auch noch seine Farbe und/oder Texturkoordinaten. OpenGL verwendet die Ausgaben des Vertexprogramms dann für Clipping, Primitive Assembly und Rasterizing. Während des Rasterizing werden die vom Vertexprogramm errechneten Daten dann über die Fläche eines Polygons interpoliert und an das Fragmentprogramm weitergegeben.

Vertexprogramme haben eine wichtige Einschränkung, deren Verständnis essentiell mit dem Verständnis der OpenGL-Pipeline zusammenhängt. Ein Vertexprogramm weiß immer nur über seinen „eigenen“ Vertex Bescheid. Ein Vertexprogramm weiß nicht, ob der momentane Vertex Teil eines Dreiecks ist oder Teil eines Quads, und ein Vertexprogramm kann nicht auf die Position und anderen Daten eines benachbarten Vertices zugreifen. Wer also glaubt, er könne für beliebige Meshes in einem Vertexprogramm einfach so den Normalenvektor über die Nachbarvertices berechnen, der irrt sich.

Fragmentprogramme ersetzen die Texturierung aus der ursprünglichen Pipeline. Das bedeutet nicht, dass Fragmentprogramme unbedingt auf Texturen zugreifen (ein Fragmentprogramm kann auch einfach eine konstante Farbe setzen), aber es bedeutet, dass ein Fragmentprogramm genau einmal für jedes „Fragment“ aufgerufen wird. Man könnte sagen, dass pro Pixel ein Aufruf stattfindet, aber in der Praxis können pro Pixel mehrere Fragmente berechnet werden, z.B. für Multisampling, und dann läuft auch das Fragmentprogramm öfter. Wer den Zusammenhang zwischen Pixeln und Fragmenten genauer wissen will sollte sich die OpenGL Spezifikation zu Gemüte führen. In der Praxis ist die Unterscheidung von geringer Bedeutung. Als Eingabe erhält das aktive Fragmentprogramm wie schon erwähnt die interpolierten Vertexdaten. Jeder Ausgabevariable des Vertexprogramms entspricht also eine Eingabevariable des Fragmentprogramms. Außerdem kann auch das Fragmentprogramm auf globalen OpenGL-State zugreifen. Als Ausgabe schreibt jedes typische Fragmentprogramm die Farbe des Fragments, meistens indem es eine oder mehrere Texturen ausliest und die Daten kombiniert. Fragmentprogramme können auch den Z-Wert überschreiben, aber das ist untypisch. Die berechnete Farbe wird von OpenGL dann in den traditionellen Per-Pixel-Stages verwendet, das sind insbesondere Alphatest und Alphablending.

Fragmentprogramme haben dieselbe Einschränkung wie Vertexprogramme: Ein Fragmentprogramm weiß nichts über seine Nachbarn. Ich möchte auch betonen, dass die Position eines Fragments unveränderlich ist. Wer also meint, er könne einen Heightmap-Effekt einfach implementieren, indem er die Position eines Fragments überschreibt, der irrt sich gewaltig! Ein Fragmentprogramm kann auch keine Daten aus dem Framebuffer auslesen. In diesem Bereich sind wir also leider auf die alte Alphablending-Funktionalität beschränkt. Diese Einschränkung kann nur mit Render-to-Texture-Tricks umgangen werden.

Wie sehen Programme aus?

Ich habe bereits erwähnt, dass die von mir verwendeten Extensions ARB_vertex_program und ARB_fragment_program eine Assemblersprache verwenden. Die Sprache ist bei beiden Extensions zum größten Teil identisch, aber es gibt natürlich Unterschiede z.B. bezüglich der unterstützten Befehle: Vertexprogramme können zum Beispiel nicht auf Texturen zugreifen (zugegeben, ab dem NV40 stimmt das nicht mehr, aber diese Funktionalität wird nur von einer NVidia-Extension unterstützt). Alle Features der Sprache hier aufzuzählen ist nicht möglich, deshalb gebe ich hier nur eine kurze Einführung und verweise ansonsten auf die Spezifikationen, die in der OpenGL Extension Registry zu finden sind: ARB_vertex_program, ARB_fragment_program

Ein Assemblerprogramm ist vom Aufbau denkbar einfach: Es gibt eine Reihe spezieller Statements, mit denen Variablen definiert werden können. Ansonsten besteht ein Programm nur aus einer Liste von Anweisungen, die stur nacheinander abgearbeitet werden. Es gibt keinerlei Funktionsblöcke wie in C, und die hier behandelten Extensions haben nicht einmal Sprungbefehle (erst die allerneuesten Grafikkarten unterstützen Sprungbefehle in Hardware). Hier ist ein einfaches Fragmentprogramm:

!!ARBfp1.0
TEMP tmp0;
 
TEX tmp0, fragment.texcoord[0], texture[0], 2D;
MUL result.color, tmp0, fragment.color;
END

Am Anfang des Programms steht immer ein Identifier, der den Programmtyp angibt, in diesem Fall !!ARBfp1.0 für ein Fragmentprogramm, Sprachversion 1.0. Danach kommen die Statements. Jedes Statement muss wie in C mit einem Semikolon abgeschlossen werden. Zeilenumbrüche sind dem in OpenGL integrierten Assembler egal, machen das Programm aber lesbarer.

Das erste Statement definiert die temporäre Variable tmp0. Jede Variable ist ein Vektor aus vier Fließkommazahlen. Darauf folgt eine TEX-Anweisung um aus einer Textur zu lesen. Der erste Parameter ist die Variable, in die der Farbwert geschrieben wird, in diesem Fall tmp0. Der zweite Parameter gibt die zu verwendenden Texturkoordinaten an, in diesem Fall einfach die vom Vertexprogramm berechnete erste Texturkoordinate. Der dritte Parameter gibt an, welche Textur verwendet werden soll, in diesem Fall ist es die an die erste Textureinheit gebundene Textur. Der letzte Parameter gibt an, auf welchen Texturtyp zugegriffen werden soll, in diesem Fall auf eine normale 2D-Textur. Dann folgt eine MUL-Anweisung für komponentenweise Multiplikation von Vektoren. Sie multipliziert den gerade eben aus der Textur ausgelesenen Farbwert mit dem vom Vertexprogramm errechneten Farbwert und speichert ihn in result.color, der vordefinierten Ausgabevariable. Jedes Programm wird mit einem END abgeschlossen.

Dieses Fragmentprogramm erfüllt dieselbe Funktion wie normales OpenGL mit einer aktivierten Textur, es entspricht dem Textur-Environment GL_MODULATE.

Noch ein Beispiel:

!!ARBfp1.0
PARAM const = { 0.4, 0.0, 0.0, 0.0 };
TEMP tmp0;
 
MOV tmp0, fragment.texcoord[0];
MAD tmp0.x, tmp0.y, const.x, tmp0.x;
TEX tmp0, tmp0, texture[0], 2D;
MUL result.color, tmp0.zyxw, fragment.color;
END

Dieses Programm veranschaulicht gleich eine Reihe neuer Konzepte. Als erstes kommt das PARAM-Statement. Es definiert hier eine Konstante (alle Konstanten sind Vektoren mit vier Komponenten), auf die später zugegriffen werden kann. Dann werden die Texturkoordinaten mit einem MOV-Befehl in die temporäre Variable tmp0 kopiert. Der nächste Befehl ist MAD, was für „Multiply and Add“ steht. Dieser Befehl multipliziert die y-Komponente von tmp0 mit der x-Komponente der vorher definierten Konstanten (also 0.4), addiert die x-Komponente von tmp0 dazu und speichert das Ergebnis wieder in tmp0.x. Da tmp0 in der dritten Anweisung als Koordinate für einen Texturzugriff verwendet wird, entsteht dadurch ein Schereffekt.

Der letzte Befehl ähnelt dem letzten Befehl im vorherigen Beispiel, zeigt aber ein Feature namens Swizzling. Beim Swizzling werden die Komponenten eines Vektors „durcheinandergewürfelt“. tmp0.zyxw bedeutet, dass die x-Komponente des neuen Vektors auf tmp0.z gesetzt wird, die y-Komponente des neuen Vektors auf tmp0.y, die z-Komponente auf tmp0.x und die w-Komponente auf tmp0.w. x, y, z und w entsprechen übrigens r, g, b, und a - mit anderen Worten, es werden Farben vertauscht. Der Assembler erlaubt völlig beliebiges Swizzling, z.B. tmp0.yyyx: Hier werden die x-, y- und z-Komponenten des resultierenden Vektors auf tmp0.y gesetzt, und die w-Komponente auf tmp0.x.

Ich will nochmal auf die zweite Anweisung, den MAD-Befehl zurückkommen. Bei den Ausdrücken in den letzten drei Parameter (wie z.B. tmp0.y) wird in Wirklichkeit auch Swizzling verwendet. tmp0.y ist hier einfach eine Kurzform für tmp0.yyyy. Das Zielregister tmp0.x verwendet aber kein Swizzling. Hier steht das x für eine Art Bitmaske. Sie teilt dem Assembler mit, dass nur die x-Komponente von tmp0 überschrieben werden soll. Die y-, z- und w-Komponenten bleiben dagegen unverändert. Was wäre bei folgender Anweisung passiert?

MAD tmp0, tmp0.y, const.x, tmp0.x;

In diesem Fall wären alle Komponenten von tmp0 auf den selben Wert (nämlich tmp0.y * const.x + tmp0.x) gesetzt worden, und das ist natürlich in diesem Fall nicht erwünscht. Ich hoffe es wird deutlich, dass man mit Swizzling vorsichtig sein muss. Intern arbeitet die Grafikhardware bei dem MAD-Befehl und anderen Vektorbefehlen übrigens immer mit einem vollen Vektor, selbst wenn nur ein Skalar benötigt wird. Man sollte also wenn möglich immer mehrere Skalaroperationen zu einer Vektoroperation kombinieren, um Anweisungen zu sparen.

Aber nun zu etwas anderem: In diesem Beispiel habe ich den Schereffekt im Fragmentprogramm implementiert, und das ist eigentlich eine Todsünde. Die Grafikhardware muss für diesen Effekt mindestens einmal pro Pixel zwei Befehle zusätzlich ausführen. Es wäre viel besser, diesen Effekt bereits im Vertexprogramm zu implementieren:

!!ARBvp1.0
PARAM const = { 0.4, 0.0, 0.0, 0.0 };
PARAM mvp[4] = { state.matrix.mvp };
 
DP4 result.position.x, mvp[0], vertex.position;
DP4 result.position.y, mvp[1], vertex.position;
DP4 result.position.z, mvp[2], vertex.position;
DP4 result.position.w, mvp[3], vertex.position;
 
MAD result.texcoord[0], vertex.texcoord[0].yyyy, const.xyzw, vertex.texcoord[0];
END

Hier sieht man eine neue Form von PARAM-Befehl. Das Array mvp ist ein Alias für state.matrix.mvp. state.matrix.mvp ist die bereits vormultiplizierte Kombination aus Modelviewmatrix und Projektionsmatrix (andernfalls müsste man diese Multiplikationen im Vertexprogramm durchführen). Eine Matrix besteht in Vertex- und Fragmentprogrammen einfach aus vier Vektoren mit je vier Komponenten. Diese vier Vektoren werden hier in einem Array zusammengefasst.

Die vier DP4-Anweisungen berechnen die transformierte Position des Vertex. Es handelt sich hierbei um eine einfache Matrixmultiplikation als vier Skalarprodukte ausgeschrieben: DP4 berechnet das Dot Product aus allen vier Komponenten, also (a.x*b.x + a.y*b.y + a.z*b.z + a.w*b.w).

Die letzte MAD-Anweisung erzielt den erwünschten Schereffekt bei der Berechnung der Texturkoordinaten. Ich hätte mir die MOV-Anweisung auch im vorigen Beispiel mit dem Fragmentprogramm sparen können, habe sie aber aus Gründen der (hoffentlich) Übersichtlichkeit beibehalten. Das Swizzlingsuffix bei const.xyzw ist eigentlich überflüssig, verdeutlicht aber vielleicht einen subtilen Unterschied zwischen der MAD-Anweisung hier und der MAD-Anweisung im letzten Beispiel. Falls die Funktion der MAD-Anweisung nicht auf Anhieb klar ist: Bedenkt, dass es sich eigentlich um vier parallele, unabhängige Rechnungen handelt, eine pro Komponente. Nehmt euch ein Blatt Papier und klaubt diese vier Rechnungen auseinander.

Genug der Theorie

Es wird Zeit für die Praxis. Als Beispielanwendung werde ich ein Program vorstellen, dass ein texturiertes Quad rendert, mit einem Twist: Die anfangs farbige Textur soll allmählich schwarz-weiß werden und sich dann wieder zurückverwandeln. Ich werde Auszüge aus dem Programm hier kommentieren, insbesondere die Teile, die sich mit Vertex- und Fragmentprogrammen befassen.

Vertex- und Fragmentprogramme liegen nur in den entsprechenden Extensions vor und müssen daher erst „geladen“ werden. Das werden wir im folgenden tun:

/* Funktionspointer für ARB_vertex_program und ARB_fragment_program */
void (*pglProgramString)(GLenum, GLenum, GLsizei, const void*) = 0;
void (*pglBindProgram)(GLenum target, GLuint program) = 0;
void (*pglDeletePrograms)(GLsizei n, const GLuint *programs) = 0;
void (*pglGenPrograms)(GLsizei n, GLuint *programs) = 0;
void (*pglProgramEnvParameter4f)(GLenum target, GLuint index, GLfloat x, GLfloat y, GLfloat z, GLfloat w) = 0;
void (*pglProgramLocalParameter4f)(GLenum target, GLuint index, GLfloat x, GLfloat y, GLfloat z, GLfloat w) = 0;
void (*pglGetProgramiv)(GLenum target, GLenum pname, GLint *params) = 0;
void (*pglVertexAttrib1f)(GLuint index, GLfloat x) = 0;
void (*pglVertexAttrib2f)(GLuint index, GLfloat x, GLfloat y) = 0;
void (*pglVertexAttrib3f)(GLuint index, GLfloat x, GLfloat y, GLfloat z) = 0;
void (*pglVertexAttrib4f)(GLuint index, GLfloat x, GLfloat y, GLfloat z, GLfloat w) = 0;

Die von den Extensions neu definierten Funktionen können nur über Funktionspointer erreicht werden, die hier definiert werden. Die folgende Funktion nimmt den Namen einer Extension entgegen und prüft, ob dieser Name in der Liste der verfügbaren Extensions aufgeführt ist:

int HaveExtension(const char* name)
{
    const char* extensionstring = glGetString(GL_EXTENSIONS);
    int len = strlen(name);
 
    /* Suche nach dem Extensionnamen im Extensionstring */
    while((extensionstring = strstr(extensionstring, name))) {
        extensionstring += len;
 
        /* Folgt ein Leerzeichen oder das Ende des Strings? */
        if (!*extensionstring || isspace(*extensionstring))
            return 1;
    }
 
    return 0;
}

Per glGetString(GL_EXTENSIONS) erhält man einen String, in dem alle unterstützten Extensions namentlich mit Leerzeichen getrennt aufgeführt sind. Extensionsnamen bestehen aus dem Prefix GL für Extensions, die OpenGL selbst betreffen, gefolgt von einem Prefix für den Hersteller der Extension (in unserem Fall ARB für das Architecture Review Board) gefolgt von dem eigentlichen Namen der Extension. HaveExtension() prüft mit einfachen Stringfunktionen in einer Schleife, ob der gesuchte Extensionstring vorhanden ist. Das if()-Statement soll sicherstellen, dass nicht GL_ARB_foobar gefunden wird, wenn GL_ARB_foo gesucht wird.

Dann benötigen wir noch eine Funktion, die HaveExtension() mit den entsprechenden Extensionsnamen aufruft und die Funktionspointer lädt:

void InitProgramExtensions()
{
    if (!HaveExtension("GL_ARB_vertex_program") ||
        !HaveExtension("GL_ARB_fragment_program")) {
        fprintf(stderr, "Fehlender Grafikkartensupport!\n");
        exit(-1);
    }
 
    /* Lade Funktionspointer */
    pglProgramString = SDL_GL_GetProcAddress("glProgramStringARB");
    pglBindProgram = SDL_GL_GetProcAddress("glBindProgramARB");
    pglDeletePrograms = SDL_GL_GetProcAddress("glDeleteProgramsARB");
    pglGenPrograms = SDL_GL_GetProcAddress("glGenProgramsARB");
    pglProgramEnvParameter4f = SDL_GL_GetProcAddress("glProgramEnvParameter4fARB");
    pglProgramLocalParameter4f = SDL_GL_GetProcAddress("glProgramLocalParameter4fARB");
    pglGetProgramiv = SDL_GL_GetProcAddress("glGetProgramivARB");
    pglVertexAttrib1f = SDL_GL_GetProcAddress("glVertexAttrib1fARB");
    pglVertexAttrib2f = SDL_GL_GetProcAddress("glVertexAttrib2fARB");
    pglVertexAttrib3f = SDL_GL_GetProcAddress("glVertexAttrib3fARB");
    pglVertexAttrib4f = SDL_GL_GetProcAddress("glVertexAttrib4fARB");
}

Diese Funktion sollte eigentlich selbsterklärend sein. Ich sollte vielleicht erwähnen, dass Funktionen, die im Rahmen von Extensions zu OpenGL hinzugefügt werden, immer ein Vendorsuffix wie ARB, ATI oder NV haben. Das selbe gilt für neu hinzugefügte Konstanten. Die Namen der Funktionspointer sind natürlich beliebig gewählt und haben das Vendorsuffix deshalb nicht.

Ach ja: Es mag zwar verlockend erscheinen, die Funktionspointer einfach nur glProgramString zu nennen, statt wie hier pglProgramString. Das kann aber besonders unter Linux zu sehr bösen und schwer auffindbaren Bugs führen, wenn in der OpenGL-Bibliothek selbst eine Funktion mit diesem Namen existiert. Deshalb habe ich hier dem eigentlichen Funktionsnamen ein 'p' zur Unterscheidung vorangestellt. Alternativ könnte man auch alle Funktionspointer in einer Struktur zusammenfassen.

Vertex- und Fragmentprogramme werden der OpenGL als String übergeben. Damit ein Programm nicht jedes Mal, wenn es verwendet wird, von neuem geparsed und assembliert werden muss, gibt es Programmobjekte, die den Texturobjekten ganz ähnlich sind. Programmobjekte können erstellt und verändert werden, und sie können wie Texturen „gebunden“ werden. Das momentan gebundene Vertexprogramm ist gleichzeitig das Vertexprogramm, das beim Rendern verwendet wird - vorausgesetzt, Vertexprogramme sind per glEnable() aktiviert.

Die folgende Helferfunktion nimmt Parameter für den Programmtyp und einen String entgegen, und erstellt ein entsprechendes Programmobjekt:

unsigned LoadProgram(unsigned target, const char* string)
{
    uint program;
 
    pglGenPrograms(1, &program);
    pglBindProgram(target, program);
    pglProgramString(target,
            GL_PROGRAM_FORMAT_ASCII_ARB, strlen(string), string);

Der erste Teil der Funktion läuft analog zur Erstellung von Texturen. glProgramString() erwartet keinen nullterminierten String - stattdessen muss die Länge des Strings übergeben werden.

Beim Schreiben von Programmen passieren leicht Fehler, deswegen müssen wir hier unbedingt auf Fehler prüfen:

    if (glGetError() == GL_INVALID_OPERATION)
    {
        int errorpos;
        const char* errorstr;
 
        glGetIntegerv(GL_PROGRAM_ERROR_POSITION_ARB, &errorpos);
        errorstr = (const char*)glGetString(GL_PROGRAM_ERROR_STRING_ARB);
 
        printf("Fehler in Programm: %s\n", string);
        printf("Zeichen %i: %s\n", errorpos, errorstr);
        exit(-1);

Falls etwas schiefgeht geben wir eine entsprechende Meldung aus und greifen zu rabiaten Mitteln.

Weniger dringend notwendig ist der nächste Teil:

    }
    else
    {
        // Check if program can be run natively
        int isnative;
 
        pglGetProgramiv(target, GL_PROGRAM_UNDER_NATIVE_LIMITS_ARB, &isnative);
 
        if (!isnative) {
            printf("Programm ist zu komplex fuer Hardwarebeschleunigung:\n%s", string);
            exit(-1);
        }
    }
 
    return program;
}

Es kann vorkommen, dass OpenGL ein Programm akzeptiert, obwohl die Hardware nicht in der Lage ist, dieses Programm auszuführen. In diesem Fall greift die (langsame) Softwareemulation, und davor wird der Benutzer hier gewarnt. Natürlich kann es zu Testzwecken Sinn machen, diesen Test zu entfernen: Das Programm sollte korrekt laufen, nur eben sehr, sehr langsam.

Kommen wir zum Hauptprogramm:

    SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 5);
    SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 5);
    SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 5);
    SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 16);
    SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
 
    screen = SDL_SetVideoMode(512, 512, 0, SDL_OPENGL);
    if (!screen) {
        fprintf(stderr, "Konnte Bildschirmmodus nicht setzen: %s\n",
            SDL_GetError());
        exit(-1);
    }
 
/* Lade Texturen, Programme, etc. */
    InitProgramExtensions();
 
    g_texPicture = LoadTexture("wood.jpg");

An der Initialisierung von SDL und OpenGL hat sich eigentlich nichts geändert, abgesehen davon, dass wir natürlich die benötigten Extensions laden müssen.

Aber gleich danach geht es an's Eingemachte, nämlich die eigentlichen Vertex- und Fragmentprogramme.

    g_vertexprogram = LoadProgram(GL_VERTEX_PROGRAM_ARB,
"!!ARBvp1.0\n"
"PARAM mvp[4] = { state.matrix.mvp };\n"
 
"DP4 result.position.x, mvp[0], vertex.position;\n"
"DP4 result.position.y, mvp[1], vertex.position;\n"
"DP4 result.position.z, mvp[2], vertex.position;\n"
"DP4 result.position.w, mvp[3], vertex.position;\n"
 
"MOV result.texcoord[0], vertex.texcoord[0];\n"
"END"
    );

Hier wird das Vertexprogramm erstellt. Man beachte die Platzierung der Anführungszeichen und die Zeilenumbrüche per \n. Eigentlich könnte man diese Zeilenumbrüche auch weglassen. Die Fehlersuche wird aber einfacher, wenn nicht das gesamte Programm in einer einzigen Zeile steht, weil manche OpenGL-Implementationen in ihren Fehlermeldungen eine Zeilenzahl angeben.

Vom Standpunkt des Softwaredesigns wäre es vielleicht auch besser, den Programmstring auszulagern und als Konstante an einer anderen Stelle des Programms abzulegen. Man könnte ihn auch zur Laufzeit aus einer Textdatei laden um Flexibilität zu gewinnen. Ich habe für dieses Tutorial aber den einfacheren Weg gewählt.

Das Vertexprogramm selbst dürfte inzwischen eigentlich keine Überraschungen mehr enthalten. Wir transformieren die Vertexposition und geben die Texturkoordinate einfach per MOV weiter. Dieses Weitergeben ist übrigens dringend notwendig. Ohne diesen Befehl würde das Fragmentprogramm nicht mehr auf die richtigen Texturkoordinaten zugreifen können!

Interessanter wird es im Fragmentprogramm:

    g_fragmentprogram = LoadProgram(GL_FRAGMENT_PROGRAM_ARB,
"!!ARBfp1.0\n"
// NTSC-mapping (from: Computer Graphics - Principles and Practice, Foley, van Dam, Feiner, Hughes)
"PARAM ntsc = { 0.299, 0.587, 0.114, 0.0 };\n"
"TEMP color, grey;\n"
 
"TEX color, fragment.texcoord[0], texture[0], 2D;\n"
"DP3 grey, color, ntsc;\n"
"LRP result.color, program.env[0], grey, color;\n"
"END"
    );

Als erstes lesen wir die Texturdaten in die temporäre Variable color ein. Dann wird die Helligkeit der gelesenen Farbe berechnet und in alle Komponenten der temporären Variable grey geschrieben. Für diese Berechnung habe ich die NTSC-Konversion gewählt: Jede der drei Farbkomponenten wird mit einer Konstante multipliziert, und die Ergebnisse addiert. Der Befehl DP3 (Skalarprodukt aus den ersten drei Komponenten) eignet sich dafür hervorragend. Die Konstanten sind übrigens vom NTSC ausgehend von der menschlichen Wahrnehmung von Farben ausgewählt. Letztendlich wollen wir langsam zwischen farbig und schwarzweiß überblenden, und genau das tut die Anweisung LRP (für Lineare Interpolation). Sie interpoliert zwischen dem dritten und vierten Parameter an Hand des zweiten. Im obigen Programm entspricht die LRP-Anweisung also der Zuweisung:

result.color = program.env[0]*grey + (1.0 - program.env[0])*color

program.env ist ein Array aus Variablen, die von unserem C-Code aus gesetzt werden können. Genau das werden wir nachher beim eigentlichen Rendern abhängig von der momentanen Zeit tun. Wir greifen auf den Index 0 im Array program.env zu - dieser Index muss mit dem später im C-Code verwendeten Index übereinstimmen.

void RefreshScreen()
{
    float time;
    float lambda;
 
    /* Bildschirm löschen */
    glClear(GL_COLOR_BUFFER_BIT);

Der Anfang unserer Renderfunktion dürfte geläufig sein. Als nächstes binden wir unsere Textur und die Programme, und aktivieren die Verarbeitung von Vertex- und Fragmentprogrammen.

    /* Textur, Vertex- und Fragmentprogramm setzen */
    glBindTexture(GL_TEXTURE_2D, g_texPicture);
 
    pglBindProgram(GL_VERTEX_PROGRAM_ARB, g_vertexprogram);
    pglBindProgram(GL_FRAGMENT_PROGRAM_ARB, g_fragmentprogram);
 
    glEnable(GL_VERTEX_PROGRAM_ARB);
    glEnable(GL_FRAGMENT_PROGRAM_ARB);

Ohne die beiden Aufrufe von glEnable() würde OpenGL die alte Pipeline verwenden und unsere Programme ignorieren. Erwähnenswert ist, dass glEnable(GL_TEXTURE_2D) nicht aufgerufen wird. Da jedes Fragmentprogramm explizit angibt, auf welche Texturen es zugreift, wird das Enable-Bit für Texturen ignoriert.

Ich sollte vielleicht erwähnen, dass es strenggenommen reichen würde, die Textur und die Programme einmal in main() zu binden. Solange man die Bindung nicht überschreibt, bleibt sie immer bestehen. In richtigen Anwendungen wird man aber oft zwischen Texturen und Programmen wechseln, und daran kann man sich hier schon gewöhnen. Übrigens gilt für Programme dieselbe Regel wie für Texturen: Programmwechsel und Texturwechsel sind verhältnismässig langsam. Wenn möglich sollten sie vermieden werden, zum Beispiel indem man die Geometriedaten entsprechend sortiert.

    /* Ein Quad rendern */
    time = SDL_GetTicks() / 1000.0f;
    lambda = (sin((time * M_PI) / 2.0) + 1.0) * 0.5;
    pglProgramEnvParameter4f(GL_FRAGMENT_PROGRAM_ARB, 0, lambda, lambda, lambda, lambda);

Wir verwenden eine sinusförmige Funktion als Parameter für die Interpolation. Wie alle Variablen in Vertex- und Fragmentprogrammen sind auch program.env[N] Vektoren mit vier Komponenten, und mit dem Aufruf von pglProgramEnvParameter4f() werden alle vier Komponenten gesetzt. Der zweite Parameter in diesem Funktionsaufruf ist der Index in das Array program.env.

Jetzt rendern wir noch ein Quad. Den Rest erledigt OpenGL für uns.

    glBegin(GL_QUADS);
        glTexCoord2f(0, 0);    glVertex2i(0, 0);
        glTexCoord2f(1, 0);    glVertex2i(512, 0);
        glTexCoord2f(1, 1);    glVertex2i(512, 512);
        glTexCoord2f(0, 1);    glVertex2i(0, 512);
    glEnd();
}

Als brave EU-Bürger räumen wir am Ende der Funktion main() noch auf und löschen alle erstellten Texturen und Programme.

    glDeleteTextures(1, &g_texPicture);
    pglDeletePrograms(1, &g_vertexprogram);
    pglDeletePrograms(1, &g_fragmentprogram);
 
    return 0;
}

Die Zukunft

Programmierbare 3D-Funktionalität ist ein komplexes Thema. Dieses Tutorial ist schon lang genug, und dabei hat es eigentlich nur die Oberfläche angekratzt hat und ist nicht einmal „echt 3D“. Es lohnt sich aber in jedem Fall, dieses Thema weiter zu erkunden. Ich empfehle euch, zunächst mit kleinen Programmen herumzuspielen: Verrückte Verzerr-Effekte, eine zweite Textur, und so weiter. Für solche Experimente empfehle ich auch, die Spezifikationen zur Hand zu haben. Auf den ersten Blick erschlagen sie zwar mit textlastigen Passagen, dafür enthalten sie aber auch eine vollständige Liste aller zur Verfügung stehenden Befehle. Ein paar Beispielprogramme sind in den Spezifikationen auch zu finden, wenn man genauer hinschaut.

Anmerkung

Dieses Tutorial stammt aus der ehemaligen Sammlung des resourcecode.de und konnte dank der freundlichen Zustimmung des Autors in das thewall-Wiki übertragen werden.

Die Verwendung aller Dokumente einschließlich der Abbildungen ausschließlich zu nichtkommerziellen Zwecken. Verbreitung des Dokuments auf Speichermedien, (insbesondere auf CD-ROMs als Beilage zu Zeitschriften und Magazinen oder sog. "Mission-Packs" etc.) ist untersagt.
 
coding/grafik_api/opengl_fragmentprogramme.txt · Zuletzt geändert: 2010/08/03 16:51 von Adrian_Broher