OpenGL: Matrixshader

Autor: Nicolai 'Prefect' Hähnle

In meinem letzten Tutorial habe ich gezeigt, wie Vertex- und Fragmentprogramme im Prinzip funktionieren, und ich habe einen kleinen aber feinen Effekt gezeigt.

In diesem Tutorial will ich einen einiges komplexeren Effekt demonstrieren, der etwas aus dem normalen Anwendungsbereich von programmierbarer 3D-Hardware herausfällt: Einen Matrix-Scroller.

title.jpg

Die Voraussetzungen an Hardware und Bibliotheken sind die gleichen wie im letzten Tutorial, d.h. mindestens eine Radeon 9500, Geforce FX oder vergleichbare Hardware wird benötigt.

Was genau ist das Ziel?

Bevor wir uns an die Arbeit heranwerfen sollten wir den erwünschten Effekt genau definieren. Ansonsten besteht die Gefahr, dass wir das Ziel vor Augen verlieren.

Unser Matrix-Effekt soll eine Fläche mit zufällig ausgewählten Symbolen füllen. In regelmüssigen, aber zufälligen Abständen, sollen „Updater“ die Spalten der gefüllten Fläche herunterlaufen. Wenn ein Updater ein Symbol erreicht, wird das Symbol geändert. Die Farbe der Symbole ist abhängig davon, wann das Symbol zum letzten Mal von einem Updater berührt wurden: Wenn ein Symbol noch ganz frisch ist ist es weiß, danach wird es zunächst hellgrün, und danach immer dunkler.

Es gibt viele verschiedene Möglichkeiten, wie man diesen Effekt implementieren kann.

  1. Die naivste Methode wäre, einfach für jedes Symbol ein Quad zu rendern. Das schafft auch die älteste 3D-Hardware noch, und ist daher für dieses Tutorial uninteressant.
  2. Man könnte auch das gesamte Bild in eine Textur rendern, und in dieser Textur immer nur die Symbole mit einem Quad erneuern, über die ein Updater gelaufen ist. In einem einfachen Fragmentprogramm, das eine Palettenrotation simuliert, könnte man dafür sorgen, dass sich auch die Farbe der alten Symbole mit der Zeit ändert.
  3. Man kann die Symbole „live“ in einem Fragmentprogramm zusammensetzen.

Es ist die letzte Methode, die mich in diesem Tutorial interessiert. Sie ist nicht unbedingt die praktikabelste (dazu am Ende mehr), aber sie demonstriert am besten die Möglichkeiten von programmierbarer 3D-Hardware und die Art der Denkweise, die in Fragmentprogrammen manchmal benötigt wird.

Symbole zusammensetzen

Ich will mich dem höheren Ziel eines Matrix-Shaders schrittweise annähern. Der erste Schritt wird sein, eine Fläche aus zufällig ausgewählten Symbolen zusammenzusetzen. Im Klartext: Wir haben eine einfache Textur, in der jedes Symbol einmal vorhanden ist. Aus dieser Textur werden die Symbole zusammengeschnitten. Hier ist die Textur, die ich verwende:

Es handelt sich dabei um eine Graustufentextur, denn am Ende soll die Farbe ja vom Alter eines Symbols abhängig gemacht werden.

Wie genau können wir nun die Symbole aus dieser Textur ausschneiden und wieder zusammensetzen? Hier muss man andersherum denken als man das vielleicht gewöhnt ist. Es ist nicht mehr möglich, einfach Quads aus der Symboltextur auszuschneiden. Stattdessen müssen wir für jedes Fragment einzeln bestimmen, auf welches Symbol es zugreifen soll, und auf welche Koordinate innerhalb des Symbols.

Wie können wir das gewünschte Symbol in einem Fragmentprogramm bestimmen? Eigentlich ganz einfach: Wir lesen den Index des Symbols aus einer Textur aus. Die Daten innerhalb dieser Textur sollen natürlich zufällig sein, damit wir zufällig ausgewählte Symbole verwenden. Die nachfolgende Funktion erstellt eine solche mit Zufallsdaten - auch Rauschen bzw. Noise genannt - gefüllte Textur:

unsigned CreateNoiseTexture(unsigned width, unsigned height)
{
    unsigned short* pixels;
    unsigned texture;
    unsigned i;
 
/* Erstelle ein Array aus Zufallsdaten */
    pixels = (unsigned short*)malloc(sizeof(unsigned short)*width*height);
 
    for(i = 0; i < width*height; ++i)
        pixels[i] = (unsigned short)rand();
 
/* Erstelle eine OpenGL-Textur */
    glGenTextures(1, &texture);
    glBindTexture(GL_TEXTURE_2D, texture);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_ALPHA16, width, height, 0, GL_ALPHA,
        GL_UNSIGNED_SHORT, pixels);
 
/* Gib den verwendeten Speicherplatz frei */
    free(pixels);
 
    return texture;
}

Die erstellte Textur ist eine reine Alphatextur mit 16 Bit für den Alphakanal. Ich habe 16 Bit gewählt, damit im Fragmentprogramm genügend Zufallsdaten zur Verfügung stehen. Im Zweifelsfall würden auch 8 Bit reichen, aber hey - so können wir damit angeben, dass wir mit 16-Bit-Texturen arbeiten! Im Fragmentprogramm haben wir dann übrigens eine Pseudozufallszahl im Bereich von 0.0 bis 1.0.

Achtung: Ihr solltet überprüfen, wie groß RAND_MAX in eurer Standard-C-Bibliothek ist. Wenn RAND_MAX kleiner als 65535 ist, müsst ihr euch irgendwie abhelfen, da die Pseudozufallszahlen sonst nur in einem Bereich z.B. von 0.0 bis 0.5 liegen. Wenn RAND_MAX gleich 32767 ist, könnte das z.B. so aussehen:

        pixels[i] = (unsigned short)(rand() << 1);

Das gibt zwar nur 15 Bit Zufallsdaten, dafür stimmt die Verteilung aber.

In dieser Textur entspricht nun jedes Pixel einem der Symbole, die am Ende auf dem Bildschirm zu sehen sind. Deswegen habe ich auch GL_NEAREST als Texturfilter ausgewählt: Eine Interpolation mit dem Nachbarpixel ist nicht nötig. Eine Interpolation würde sogar zu verfälschten Ergebnissen führen: Wenn nicht alle Fragmente eines Symbols exakt den selben Symbolindex verwenden, werden logischerweise unterschiedliche Symbole zu einem zusammengewürfelt, und das sieht nicht gut aus.

Wie müssen unsere Programme jetzt aussehen? Das Vertexprogramm sollte die Texturkoordinaten für den Zugriff auf die Noise-Textur berechnen. Außerdem sollte es Schützenhilfe leisten, wenn es darum geht, die Texturkoordinaten relativ zum ausgewählten Symbol zu berechnen. Das Fragmentprogramm liest den Symbolindex aus der Noise-Textur aus und errechnet daraus den Basis-Texturindex für den Zugriff auf die Symboltextur. Dazu wird dann noch die relative Texturkoordinate innerhalb des Symbols addiert.

Ich habe im Folgenden die Dimensionen so ausgewählt, dass die Breite und Höhe eines Symbols in Texturkoordinaten von 0 bis 1 geht. Wird dem Matrix-Shader eine Texturkoordinate (0, 0) übergeben, so entspricht das der linken oberen Ecke eines Symbols. Die Texturkoordinate (2.5, 1.5) entspricht der Mitte des Symbols, das sich zwei Spalten weiter rechts und eine Zeile weiter unten befindet.

    InitProgramExtensions();
 
    g_texSymbols = LoadTexture("symbols.png", 1);
    g_texNoise = CreateNoiseTexture(256, 256);

Wir erstellen zunächst die benötigten Texturen. Der neue Parameter von LoadTexture() zwingt die Funktion dazu, die Textur als GL_LUMINANCE-Textur an OpenGL zu übergeben. Luminance-Texturen enthalten nur einen Farbkanal, der beim Auslesen auf die x-, y- und z-Komponenten verteilt wird. Das Format ist damit ideal für Graustufentexturen.

Jetzt kommt das Vertexprogramm. Aus Gründen der Übersichtlichkeit habe ich die Anführungszeichen, die eigentlich für eine Integration in C-Code notwendig wären, weggelassen.

!!ARBvp1.0
PARAM consts0 = { 0.00390625, 1.0, 0, 0 };
PARAM mvp[4] = { state.matrix.mvp };
TEMP tmp0, tmp1;
 
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;
 
// Skalierte TC: der Nachkommateil wird als relative TC innerhalb des Symbols verwendet
MOV result.texcoord[0], vertex.texcoord[0];

In den ersten Texturkoordinaten werden die Texturkoordinaten unverändert weitergegeben. Der Nachkommateil dieser Koordinaten entspricht den Texturkoordinaten relativ zu einem Symbol.

Ich sollte vielleicht klarstellen, dass es in der Assemblersprache eigentlich keine Syntax für Kommentare gibt. Ich habe den Kommentar im obigen Ausschnitt nur zu Erklärungszwecken stehengelassen.

// Skalierte TC fuer Noise-Textur
MUL result.texcoord[1], vertex.texcoord[0], consts0.xxyy;
END

Da die Noise-Textur 256×256 Pixel groß ist, teile ich die s- und t-Koordinaten für den Texturzugriff durch 256. Damit entspricht ein Pixel der Noise-Textur wieder genau einem Symbol.

Jetzt das Fragmentprogramm:

!!ARBfp1.0
PARAM consts0 = { 0.03125, 0, 0, 32 };
PARAM consts1 = { 0.03125, 0.75, 0, 0 };
TEMP tmp0, tmp1;
 
// Hole den Zufallswert fuer das Symbol, dessen Bestandteil dieses Fragment ist
TEX tmp0, fragment.texcoord[1], texture[1], 2D;
 
// Berechne den Symbolindex
MUL tmp0.w, tmp0.w, consts0.w;
FLR tmp0.w, tmp0.w;

Der Zugriff auf die Noise-Textur speichert in tmp0.w einen Zufallswert zwischen 0.0 bis 1.0. Da in der Symboltextur 32 Symbole vorhanden sind, multiplizieren wir diesen Wert mit 32 und verwenden den FLR-Befehl, um den Nachkommateil abzuschneiden. Das gibt uns den Symbolindex als Ganzzahl.

// Berechne die TC relativ zum Symbol
FRC tmp1.xy, fragment.texcoord[0];
MUL tmp1.xy, tmp1, consts1;

Der FRC-Befehl schneidet den ganzzahligen Teil der gegebenen Texturkoordinaten weg und lässt nur den Nachkommateil übrig. Damit haben wir einen Wert zwischen (0,0) für die linke obere Ecke eines Symbols und (1,1) für die rechte untere Ecke. Da in der Symboltextur mehrere Symbole gespeichert sind, müssen diese Werte natürlich kleiner skaliert werden. Die 32 Symbole sind von links nach rechts angeordnet, also muss die s-Koordinate durch 32 geteilt werden. Da die Symbole nur 75% der Höhe der Symboltextur einnehmen, muss auch die t-Koordinate entsprechend skaliert werden.

// Bestimme die endgueltige TC fuer die Symboltextur
MAD tmp0, tmp0.w, consts0, tmp1;
TEX result.color, tmp0, texture[0], 2D;
END

Jetzt berechnen wir die Basistexturkoordinate abhängig vom Symbolindex. Das erledigt der Multiplikationsteil des MAD-Befehls: Die s-Koordinate muss, genau wie für die relativen Texturkoordinaten, durch 32 geteilt werden, die t-Koordinate ist dagegen immer gleich 0. Dann addieren wir die relative Texturkoordinate und verwenden das Ergebnis, um den gewünschten Pixel aus der Symboltextur auszulesen.

Fehlt nur noch der eigentliche Renderingcode:

void RefreshScreen()
{
    /* Bildschirm löschen */
    glClear(GL_COLOR_BUFFER_BIT);
 
    /* Textur, Vertex- und Fragmentprogramm setzen */
    glActiveTexture(GL_TEXTURE0+0);
    glBindTexture(GL_TEXTURE_2D, g_texSymbols);
 
    glActiveTexture(GL_TEXTURE0+1);
    glBindTexture(GL_TEXTURE_2D, g_texNoise);
 
    pglBindProgram(GL_VERTEX_PROGRAM_ARB, g_vertexprogram);
    pglBindProgram(GL_FRAGMENT_PROGRAM_ARB, g_fragmentprogram);
 
    glEnable(GL_VERTEX_PROGRAM_ARB);
    glEnable(GL_FRAGMENT_PROGRAM_ARB);
 
    /* Ein Quad rendern */
    glBegin(GL_QUADS);
        glTexCoord2f(0,    0);        glVertex2i(0, 0);
        glTexCoord2f(32.0, 0);        glVertex2i(512, 0);
        glTexCoord2f(32.0, 21.333);    glVertex2i(512, 512);
        glTexCoord2f(0,    21.333);    glVertex2i(0, 512);
    glEnd();
}

Die Texturkoordinaten sind so gewählt, dass ein Pixel in der Symboltextur genau einem Pixel auf dem Bildschirm entspricht: In ein Rechteck der Größe 512×512 Pixel passen genau 32×21 1/3 Symbole der Größe 16×24 Pixel.

Zeitbasierter Falloff

Jetzt haben wir die Symbole, aber noch sind sie alle weiß. Zu unserem Matrix-Shader gehört aber, dass „Updater“ über die Spalten laufen. Die Farbe der Symbole hängt von ihrem Alter ab, also davon, wann zum letzten Mal ein Updater über sie hinweggelaufen ist. Die Berechnung des Alters kann nicht im Vertexprogramm stattfinden, da die Updater in jeder Spalte unabhängig voneinander laufen sollen.

Die Farbe des Symbols erhalten wir dann letztendlich, indem wir die seit dem letzten Update verstrichene Zeit als Texturkoordinate in einer speziellen Fallofftextur verwenden. Die Fallofftextur, die im Original nur einen Pixel hoch ist, sieht so aus:

Je mehr Zeit seit dem letzten Update verstrichen ist, desto weiter rechts lesen wir die Daten aus der Textur.

Zuerst muss aber diese Zeit berechnet werden. Auf Anhieb dürfte klar sein, dass wir dazu die momentane Systemzeit als Parameter an das Fragmentprogramm übergeben müssen. Damit sich die Updater von einer Zeile zur nächsten bewegen, habe ich mir einen kleinen Trick ausgedacht: Jedes Symbol hat eine „lokale Zeit“, und diese lokale Zeit hängt davon ab, in welcher Zeile sich das Symbol befindet. Das hat zur Folge, dass die Updater immer zur gleichen Zeit an einem Symbol sind, aber da die Symbole unterschiedliche Zeitrechnungen verwenden, haben sie trotzdem unterschiedliche Farben. Das hört sich jetzt konfus an. Vielleicht wird es klarer, wenn man es mit der Zeitverschiebung auf der Erde vergleicht: Sonnenaufgang ist auf einem Breitengrad weltweit um die selbe Uhrzeit - wenn man in der lokalen Zeit rechnet. Da aber die lokale Zeit nicht überall gleich ist, ist auch nicht überall gleichzeitig Sonnenaufgang. Diese vielleicht etwas verquer anmutende Denkweise ist notwendig, damit unsere Berechnungen auch bei beliebig großer Zeilenzahl noch richtig funktionieren.

Am Ende wird noch eine Periodenlänge bestimmt, die angibt, in welchem Zeitabstand der Updater über eine Spalte läuft. An Hand der Periodenlänge wird die lokale Zeit dann in eine Texturkoordinate für die Falloff-Textur umgewandelt.

Damit die Updater in unterschiedlichen Spalten an unterschiedlichen Positionen sind, lese ich außerdem einen zweiten Wert aus der Noise-Textur aus. Die zum Lesen verwendete Texturkoordinate ist dabei nur von der Spalte abhängig und nicht von der Zeile. Die ausgelesene Zufallszahl wird dann zu der vorher berechneten Texturkoordinate addiert.

Sehen wir uns an, wie das in der Praxis aussieht. Im Hauptprogramm laden wir zuerst die Texturen, zu denen eine neue hinzugekommen ist:

    g_texSymbols = LoadTexture("symbols.png", 1);
    g_texFalloff = LoadTexture("falloff.png", 0);
    glBindTexture(GL_TEXTURE_2D, g_texFalloff);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
 
    g_texNoise = CreateNoiseTexture(256, 256);

Die Aufrufe von glTexParameteri() setzen den Wrapmode auf GL_CLAMP_TO_EDGE. Das hat zur Folge, dass Texturkoordinaten so eingeschränkt werden, dass sich eine Textur nie wiederholt. Greift man auf (1.5, 0.5) der Textur zu wird also nicht ein Pixel aus der Texturmitte gewählt, sondern eines vom rechten Rand. Das ist genau das, was wir für den Lookup in der Falloff-Textur wollen.

Am Vertexprogramm hat sich wenig geändert:

!!ARBvp1.0
PARAM consts0 = { 0.00390625, 1.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;
 
// Skalierte TC: der Nachkommateil wird als relative TC innerhalb des Symbols verwendet
MOV result.texcoord[0], vertex.texcoord[0];
 
// Skalierte TC fuer Symbollookup in der Noise-Textur
MUL result.texcoord[1], vertex.texcoord[0], consts0.xxyy;
 
// Skalierte TC fuer Zeitoffset in der Noise-Textur
MUL result.texcoord[2], vertex.texcoord[0], consts0.xzyy;
END

Lediglich die dritte Texturkoordinate ist neu: Sie enthält die Koordinate für die Noise-Textur, mit deren Hilfe der spaltenabhängige Zeitoffset bestimmt wird. Die t-Komponente dieser Koordinate wird immer auf 0 gesetzt, so dass die gelesene Zufallszahl nur von der Spalte und nicht von der Zeile abhängig ist.

Das Fragmentprogramm ist einiges gewachsen:

!!ARBfp1.0
PARAM consts0 = { 0.03125, 0.0, 0.0, 32 };
PARAM consts1 = { 0.03125, 0.75, 0.05, 0.125 };
TEMP tmp0, tmp1, tmp2, tmp3;
 
// Hole den Zufallswert fuer das Symbol
TEX tmp0, fragment.texcoord[1], texture[1], 2D;
 
// Hole den Zufallswert fuer die Spalte
TEX tmp3, fragment.texcoord[2], texture[1], 2D;

Zusätzlich zum Symbolindex holen wir jetzt auch noch den spaltenabhängigen Zeitoffset.

// Berechne den Symbolindex
MUL tmp0.w, tmp0.w, consts0.w;
FLR tmp0.w, tmp0.w;
 
// Berechne die TC relativ zum Symbol
FRC tmp2.xy, fragment.texcoord[0];
MUL tmp1.xy, tmp2, consts1;
 
// Bestimme die endgueltige TC fuer die Symboltextur
MAD tmp0.xyz, tmp0.w, consts0, tmp1;

Die Texturkoordinate für den Zugriff auf die Symboltextur wird genauso berechnet wie vorher. Jetzt kommt der interessantere neue Teil:

// Berechne die lokale Zeit des Symbols (nur zeilenabhängig)
ADD tmp2.w, fragment.texcoord[0].y, -tmp2.y;
MAD tmp2.w, tmp2.w, -consts1.z, program.env[0].w;

Der ADD-Befehl errechnet die Zeilennummer, in der sich das Fragment befindet. Ich hätte genausogut

FLR tmp2.w, fragment.texcoord[0].y

schreiben können, das Resultat wäre dasselbe gewesen. Denkt darüber nach! Der MAD-Befehl skaliert die Zeilennummer - die verwendete Konstante beeinflußt die Geschwindigkeit, mit der sich Updater bewegen - und addiert die globale Zeit.

// Berechne die Falloff-Phase
MAD tmp2.w, tmp2.w, consts1.w, tmp3.w;
FRC tmp2.xy, tmp2.w;

Als Periodenlänge habe ich 8 Sekunden gewählt. Deswegen wird die Zeit hier durch 8 dividiert, bevor der spaltenabhängige Offset (der sich im Bereich von 0 bis 1 befindet) addiert wird. Damit werden die Updater gleichmässig innerhalb einer Periode verteilt. Der Wert von tmp2.y müsste eigentlich egal sein, da die verwendete Textur nur einen Pixel hoch ist. Dennoch ist es besser, tmp2.y auf einen definierten Wert zu setzen: Wer weiß, wie sich die Hardware verhält, wenn dort ein NaN (Not a Number) steht.

// Kombiniere Symbol und Fallofffarbe für Output
TEX tmp0, tmp0, texture[0], 2D;
TEX tmp1, tmp2, texture[2], 2D;
MUL result.color, tmp0, tmp1;
END

Am Ende lesen wir Symbol- und Falloffdaten und kombinieren diese.

Den Rendering-Code müssen wir auch noch anpassen:

void RefreshScreen()
{
    float time;
 
    /* Bildschirm löschen */
    glClear(GL_COLOR_BUFFER_BIT);
 
    /* Textur, Vertex- und Fragmentprogramm setzen */
    glActiveTexture(GL_TEXTURE0+0);
    glBindTexture(GL_TEXTURE_2D, g_texSymbols);
 
    glActiveTexture(GL_TEXTURE0+1);
    glBindTexture(GL_TEXTURE_2D, g_texNoise);
 
    glActiveTexture(GL_TEXTURE0+2);
    glBindTexture(GL_TEXTURE_2D, g_texFalloff);
 
    pglBindProgram(GL_VERTEX_PROGRAM_ARB, g_vertexprogram);
    pglBindProgram(GL_FRAGMENT_PROGRAM_ARB, g_fragmentprogram);
 
    glEnable(GL_VERTEX_PROGRAM_ARB);
    glEnable(GL_FRAGMENT_PROGRAM_ARB);
 
    /* Zeitabhängige Berechnungen */
    time = SDL_GetTicks() / 1000.0f;
    pglProgramEnvParameter4f(GL_FRAGMENT_PROGRAM_ARB, 0, time, time, time, time);
 
    /* Ein Quad rendern */
    glBegin(GL_QUADS);
        glTexCoord2f(0,    0);        glVertex2i(0, 0);
        glTexCoord2f(64.0, 0);        glVertex2i(512, 0);
        glTexCoord2f(64.0, 42.666);    glVertex2i(512, 512);
        glTexCoord2f(0,    42.666);    glVertex2i(0, 512);
    glEnd();
}

Die neue Falloff-Textur wird jetzt eingebunden und die Systemzeit wird an das Fragmentprogramm übergeben. Außerdem habe ich die Texturkoordinaten verdoppelt: So hat man einen besseren Überblick über das, was passiert.

Ach ja: Wehe dem, der seine Texturen nicht freigibt! Am Ende von main():

    glDeleteTextures(1, &g_texSymbols);
    glDeleteTextures(1, &g_texFalloff);
    glDeleteTextures(1, &g_texNoise);
    pglDeletePrograms(1, &g_vertexprogram);
    pglDeletePrograms(1, &g_fragmentprogram);
 
    return 0;
}

Mehr Abwechslung, bitte!

Jetzt haben wir schon einen Effekt erreicht, der sich sehen lassen kann. Für viele Anwendungen reicht er vollkommen aus - aber es gibt eben doch noch ein paar Schwächen, und unser ursprüngliches Ziel haben wir noch nicht erreicht.

Wenn man dem Programm länger zusieht fällt auf, dass sich die Updater immer im selben Muster wiederholen. Das ist logisch, aber ideal ist es nicht.

Die Zielsetzung, keine dynamischen Texturen zu verwenden, schränkt hier die Freiheit ein bißchen ein. Trotzdem lässt sich das Problem zufriedenstellend lösen. Mein Lösungsansatz ist folgender: Bis jetzt unterteilen wir die Zeit in Perioden fester Länge, wobei immer am Anfang der Periode der Updater durchläuft. Es ist kaum möglich mit den doch begrenzten Mitteln eines Fragmentprogramms die Länge der Perioden variabel zu machen. Aber wir können den Zeitpunkt innerhalb der Periode, zu dem der Updater läuft, variabel machen. Ich hoffe, die beiden Zeitstrahlen verdeutlichen dies:

Schwarze Kreise stehen für den Anfang einer Zeitperiode. Rote, ausgefüllte Kreise stehen für den Zeitpunkt, zu dem der Updater läuft. Der obere Zeitstrahl zeigt das Verhalten des Programms bis jetzt: Der Updater läuft immer zum selben Zeitpunkt innerhalb der Periode. Der untere Zeitstrahl zeigt das Verhalten, auf das wir jetzt umschalten werden.

Die Grundidee, wie das Fragmentprogramm dann auszusehen hat, ist klar: Zuerst bestimmen wir die Nummer der Zeitperiode, in der wir uns gerade befinden. Den Zeitpunkt innerhalb dieser Periode, zu dem der Updater läuft, lesen wir aus der Noise-Textur. Als s-Koordinate innerhalb der Noise-Textur wählen wir die Spalte des Symbols, als t-Koordinate die momentane Periode. Nun gibt es immer genau zwei Möglichkeiten: Entweder, der Updater lief in dieser Periode schon, wie z.B. dort, wo der grüne Pfeil hinzeigt. Dann errechnet sich das „Alter“ des Symbols aus dem Zeitabstand zu diesem Updater. Oder der Updater lief noch nicht, wie z.B. dort, wo der blaue Pfeil hinzeigt. Dann errechnet sich das „Alter“ aus dem Zeitabstand zum Updater der vorigen Periode. Deshalb müssen wir immer zwei übereinanderliegende Pixel aus der Noise-Textur auslesen, um das Alter des Symbols zu berechnen. Wir können den zweite TEX-Befehl nicht umgehen, auch wenn er eigentlich nicht notwendig wäre. Weder ARB_vertex_program noch ARB_fragment_program kennen Sprungbefehle.

Setzen wir das Konzept also in die Tat um. Dazu müssen nur noch das Vertexprogramm und das Fragmentprogramm geändert werden, der C-Code selbst bleibt gleich. Hier das Vertexprogramm:

!!ARBvp1.0
PARAM consts0 = { 0.00390625, 1.0, 0.0, 0 };
PARAM consts1 = { 0.00195312, 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;
 
// Skalierte TC: der Nachkommateil wird als relative TC innerhalb des Symbols verwendet
MOV result.texcoord[0], vertex.texcoord[0];
 
// Skalierte TC fuer Symbollookup in der Noise-Textur
MUL result.texcoord[1], vertex.texcoord[0], consts0.xxyy;
 
// Skalierte TC fuer Zeitoffset in der Noise-Textur
MAD result.texcoord[2], vertex.texcoord[0], consts0.xzyy, consts1.yxyy;
END

Die einzige Änderung im Vertexprogramm ist von eher subtiler Natur. Im neuen Fragmentprogramm wird die t-Koordinate für die Noise-Textur komplexeren Berechnungen unterzogen. Dabei kommt es leicht zu Rundungsfehlern. Im Zusammenhang mit dem GL_NEAREST-Filtering kann das dazu führen, dass das Bild stark flackert, weil schnell zwischen zwei benachbarten Pixeln hin- und hergeschaltet wird. Das neue Vertexprogramm addiert 1/512, also die halbe Höhe eines Pixels in der Noise-Textur, zur t-Koordinate dazu. Damit wird die Noise-Textur immer nahe des Zentrums eines Pixels gesampled, und dadurch verschwindet auch das Flackern.

Kommen wir nun zum Fragmentprogramm:

!!ARBfp1.0
PARAM consts0 = { 0.03125, 0.0, 0.0, 32 };
PARAM consts1 = { 0.03125, 0.75, 0.05, 0.125 };
PARAM consts2 = { 0.0, 0.00390625, 0, 1.0 };
TEMP tmp0, tmp1, tmp2, tmp3;
 
// Hole den Zufallswert fuer das Symbol
TEX tmp0, fragment.texcoord[1], texture[1], 2D;
 
// Berechne den Symbolindex
MUL tmp0.w, tmp0.w, consts0.w;
FLR tmp0.w, tmp0.w;
 
// Berechne die TC relativ zum Symbol
FRC tmp2.xy, fragment.texcoord[0];
MUL tmp1.xy, tmp2, consts1;
 
// Bestimme die endgueltige TC fuer die Symboltextur
MAD tmp0.xyz, tmp0.w, consts0, tmp1;

An den Berechnungen für das Symbol selbst hat sich nichts geändert.

// Berechne die lokale Zeit des Symbols in Perioden (nur zeilenabhängig)
SUB tmp2.w, fragment.texcoord[0].y, tmp2.y;
MAD tmp2.w, tmp2.w, -consts1.z, program.env[0].w;
MUL tmp2.w, tmp2.w, consts1.w;

Die grundlegende Berechnung der Zeit ist gleichgeblieben abgesehen davon, dass wir jetzt keinen spaltenabhängigen Offset mehr addieren müssen.

// Berechne die Falloff-Phase
FRC tmp3.w, tmp2.w;                                  // tmp3.w = fraction(time)
SUB tmp2.w, tmp2.w, tmp3.w;                          // tmp2.w = floor(time)
MAD tmp1.xyz, tmp2.w, consts2, fragment.texcoord[2]; // tmp1 = TC für diese Periode
ADD tmp2.xyz, tmp1, -consts2;                        // tmp2 = TC für vorige Periode
 
// Lese Phasenverschiebungen
TEX tmp1, tmp1, texture[1], 2D;
TEX tmp2, tmp2, texture[1], 2D;

Wir berechnen sowohl den Ganzzahl- als auch den Nachkommaanteil der Zeit in Perioden. Der ganzzahlige Anteil wird als t-Koordinate beim lesen aus der Noise-Textur verwendet. Dabei müssen wir natürlich zwei übereinanderliegende Pixel auslesen.

// Berechne die potentiellen Alter
SUB tmp1.x, tmp2.w, consts2.w; // tmp1.x = Zeit seit Updater in voriger Periode
SUB tmp1.xw, tmp3.w, tmp1;     // tmp1.w = Zeit seit Updater in dieser Periode
 
// Wähle das richtige Alter aus
CMP tmp1.xyz, tmp1.w, tmp1.x, tmp1.w;

Nachdem wir das Alter relativ zu den jeweiligen Updatern berechnet haben, wird der CMP-Befehl zur Entscheidung verwendet. Der CMP-Befehl arbeitet komponentenweise mit Vektoren, wobei ich in diesem Fall alle Komponenten auf den selben Wert gesetzt habe. Falls der zweite Parameter kleiner als 0 ist, setzt CMP das Ergebnis auf den dritten Parameter, andernfalls auf den vierten Parameter.

// Kombiniere Symbol und Fallofffarbe für Output
TEX tmp0, tmp0, texture[0], 2D;
TEX tmp1, tmp1, texture[2], 2D;
MUL result.color, tmp0, tmp1;
END

Und schon laufen unsere Updater in einem zufälligeren Muster.

Ändert die Symbole!

Manchmal kommt es jetzt vor, dass zwei Updater kurz hintereinander über eine Spalte laufen. Dann sieht man recht deutlich, dass sich die Symbole nicht ändern. Damit haben wir unser Ziel noch nicht ganz erreicht, aber wir sind nicht mehr weit entfernt.

Was wir tun müssen, ist naheliegend. Der Symbolindex muss irgendwie abhängig von der gerade aktiven Periode gemacht werden. Seit dem letzten Schritt lesen wir aus der Noise-Textur einen Zufallswert für die aktive Periode aus, und diesen werden wir jetzt in die Berechnungen für den Symbolindex einfließen lassen. Dafür gibt es zwei prinzipiell unterschiedliche Möglichkeiten: Entweder, der Zufallswert wird zum bereits berechneten Symbolindex hinzugefügt, oder der Zufallswert wird zu der Texturkoordinate hinzugefügt, die zum Auslesen des Symbolindex aus der Noise-Textur verwendet wird. Ich habe mich hier für die zweite Methode entschieden, auch wenn sie etwas aufwendiger für die Hardware ist.

In diesem letzten Schritt ändert sich nur noch das Fragmentprogramm, dass ich jetzt an einem Stück präsentieren werde:

!!ARBfp1.0
PARAM consts0 = { 0.03125, 0.0, 0.0, 32 };
PARAM consts1 = { 0.03125, 0.75, 0.05, 0.125 };
PARAM consts2 = { 0.0, 0.00390625, 512, 1.0 };
PARAM consts3 = { 0.00390625, 0.00390625, 0, 0 };
TEMP tmp0, tmp1, tmp2, tmp3;
 
// TC relativ zum Symbol
FRC tmp0.xy, fragment.texcoord[0];
 
// Berechne die lokale Zeit des Symbols in Perioden (nur zeilenabhängig)
SUB tmp2.w, fragment.texcoord[0].y, tmp0.y;
MAD tmp2.w, tmp2.w, -consts1.z, program.env[0].w;
MUL tmp2.w, tmp2.w, consts1.w;
 
// Berechne die Falloff-Phase
FRC tmp3.w, tmp2.w;                                  // tmp3.w = fraction(time)
SUB tmp2.w, tmp2.w, tmp3.w;                          // tmp2.w = floor(time)
MAD tmp1.xyz, tmp2.w, consts2, fragment.texcoord[2]; // tmp1 = TC für diese Periode
ADD tmp2.xyz, tmp1, -consts2;                        // tmp2 = TC für vorige Periode
 
// Lese Phasenverschiebungen
TEX tmp1, tmp1, texture[1], 2D;
TEX tmp2, tmp2, texture[1], 2D;
 
// Berechne die potentiellen Alter
SUB tmp1.x, tmp2.w, consts2.w; // tmp1.x = Zeit seit Updater in voriger Periode
MOV tmp2.x, tmp1.w;
SUB tmp1.xw, tmp3.w, tmp1;     // tmp1.w = Zeit seit Updater in dieser Periode
 
// Wähle das richtige Alter und den Zufallsoffset für den Symbolindex aus
CMP tmp1.xyz, tmp1.w, tmp1.x, tmp1.w;
CMP tmp2.w, tmp1.w, tmp2.w, tmp2.x;
 
// Periodenabhängige TC für den Symbol-Zufallswert
MUL tmp2.w, tmp2.w, consts2.z;
FLR tmp2.w, tmp2.w;
MAD tmp2.xyz, tmp2.w, consts3, fragment.texcoord[1];
TEX tmp3, tmp2, texture[1], 2D;
 
// Berechne den Symbolindex
MUL tmp3.w, tmp3.w, consts0.w;
FLR tmp3.w, tmp3.w;
 
// Berechne die TC relativ zum Symbol
MUL tmp0.xyz, tmp0, consts1;
 
// Bestimme die endgueltige TC fuer die Symboltextur
MAD tmp0.xyz, tmp3.w, consts0, tmp0;
 
// Kombiniere Symbol und Fallofffarbe für Output
TEX tmp0, tmp0, texture[0], 2D;
TEX tmp1, tmp1, texture[2], 2D;
MUL result.color, tmp0, tmp1;
END

Die Berechnung des Symbolindex kann jetzt natürlich erst nach der Berechnung des Symbolalters stattfinden. Der neu eingefügte, zweite CMP-Befehl wählt den periodenabhängigen Zufallswert aus, der als Offset in die Berechnung des Symbolindex einfliessen soll. Dieser Zufallswert wird in eine Ganzzahl im Bereich von 0 bis 512 umgewandelt. Der verwendete Multiplikator (hier 512) ist egal, solange er eine Zweierpotenz im bereich von 256 bis 16384 ist. Solange er in diesem Bereich liegt beeinflusst er lediglich, welche 8 Bit aus der Zufallszahl als Offset verwendet werden. Die Texturkoordinaten für den Zugriff in die Noise-Textur werden um diesen Offset dann verschoben, die nachfolgenden Berechnungen haben sich nicht verändert.

Die Wahl des Zufallsalgorithmus in diesem letzten Schritt hat übrigens interessante Effekte auf das Verhalten der Symbole. Addiert man z.B. den Zufallsoffset einfach zum berechneten Symbolindex (und nicht, wie ich es hier getan habe, zur Texturkoordinate), so bleibt die Struktur einer Spalte immer gleich: Wenn zwei gleiche Symbole übereinanderstehen, dann stehen an dieser Stelle immer zwei gleiche Symbole übereinander, auch nachdem ein Updater darübergelaufen ist. Wenn man hingegen den Zufallsoffset nur zur s-Koordinate in die Noise-Textur addiert, so kommt es vor, dass zwei benachbarte Spalten genau die selbe Symbolfolge haben, und so weiter. Persönlich bin ich mit dem hier verwendeten Algorithmus sehr zufrieden, auch wenn er vielleicht noch weiter verfeinert werden könnte.

Benchmarks, Einschränkungen und Hardware

Ich habe die Geschwindigkeit der Programme aus diesem Tutorial getestet. Da die Programme eindeutig weder CPU- noch Vertex-limitiert sind, kann man hier ganz gut auf die Füllrate der Fragmentprogramme rückschließen. Ich verwende die Linux-Treiber von ATI. Mein System ist ein Athlon XP 2400+, die Grafikkarte eine Radeon 9700 Pro. Faktoren wie RAM (512 MB) und Bus (AGP 8x) dürften eine untergeordnete Rolle spielen. Hier die errechnete Füllrate:

  • Zusammenfügen der Symbole: 422 MPixel/s
  • Updater: 255 MPixel/s
  • Zufällige Muster: 160 MPix/s
  • Symbolwechsel: 122 MPix/s

Für einen Bildschirmschoner wäre der endgültige Code also durchaus akzeptabel. Vielleicht könnte man an der ein oder anderen Stelle sogar noch etwas optimieren, aber damit habe ich nicht experimentiert. Will man den Matrix-Shader in einem komplexeren Zusammenhang verwenden, zum Beispiel als Textur in einer 3D-Umgebung, sollte man sich aber doch etwas zurückhalten.

Zum Vergleich ein paar andere Messergebnisse von Fragmentprogrammen:

  • Nur Farbe: 1800 MPixel/s
  • Nur eine Textur: 1250 MPixel/s
  • Schwarzweiß aus meinem letzten Tutorial: 850 MPixel/s

Ich möchte noch auf eine signifikante Einschränkung des Shaders zu sprechen kommen: Minification wird nicht richtig unterstützt. Bis zu einem gewissen Grad kann man den Shader herauszoomen und die Symbole verkleinern, aber danach kommen Aliasingartefakte ins Spiel. Es ist dasselbe Problem wie bei Texturen, die keine Mipmaps verwenden. Leider lässt sich dieses Problem meines Wissens nicht beheben. Wenn man den Shader trotzdem in eine 3D-Szene einbauen will, könnte man eventuell in eine Textur rendern und automatische Mipmap-Erstellung verwenden. Aber ab diesem Punkt sollte man vielleicht sowieso über andere Lösungen nachdenken.

Abschließend will ich noch ein paar rohe Informationen niederschreiben, die die Hardwareimplementation von Vertex- und Fragmentprogrammen auf dem R300 betreffen. Diese Informationen habe ich im Rahmen eines Reverse-Engineering-Projekts gewonnen, das letztendlich einen Open-Source-Treiber für R300-basierte Karten ermöglichen soll.

Die Vertexeinheit des Prozessors ist extrem flexibel aufgebaut, und ist eine nahezu 1:1 Abbildung der ARB_vertex_program-Spezifikation. Interessant ist, dass der komplette SWZ-Befehl (Extended Swizzling) völlig kostenlos ist. Der FLR-Befehl wird als FRC-Befehl gefolgt von einer Subtraktion implementiert. Kreuzprodukt wird als ein MUL und ein MAD-Befehl implementiert.

Die Fragmenteinheit sieht einiges anders aus, und orientiert sich sehr am Pixelshader 2.0-Modell von Direct Graphics. Jedes Fragmentprogramm ist in „Nodes“ aufgeteilt. Nodes bestehen aus einer Folge von TEX-Befehlen, gefolgt von einer Folge aus ALU-Befehlen. Immer, wenn ein TEX-Befehl die Berechnungen eines vorhergehenden TEX- oder ALU-Befehls als Texturkoordinate verwendet, wird eine neue Node angefangen. Der Assembler kann Befehle in hohem Grade umherschieben, um die benötigte Anzahl an Nodes zu minimieren, aber nach vier Nodes ist meines Wissens Schluß - mehr unterstützt die Hardware nicht. Die ALU-Einheit ist in eine Einheit für die xyz/RGB-Komponenten, und eine Einheit für die w/A-Komponente aufgeteilt. Das bedeutet, dass komponentenweise Vektoroperationen unter bestimmten Bedingungen parallel ausgeführt werden können, wenn eine der Operationen nur xyz-Komponenten berechnet, während die andere nur die w-Komponente berechnet. Das funktioniert zum Teil auch, wenn die Berechnung der xyz-Komponente auf die w-Komponente eines Inputs zugreift und umgekehrt. Die Fragmenteinheit unterstützt nur die von PS 2.0 vorgesehen Swizzlingmasks in Hardware. Diese sind im Einzelnen: .x, .y, .z, .w, .xyzw, .wzyx, .zxyw, .yzxw. Die letzten beiden Masks werden für die Implementation des Kreuzprodukts benötigt, das auch hier als ein MUL und ein MAD-Befehl implementiert wird. Andere Swizzlingmasks werden, falls nötig, durch mehrere MOV-Befehle simuliert. Dafür ist in Fragmentprogrammen der ABS-Befehl kostenlos. Befehle, die immr nur mit einem Skalar arbeiten (dazu gehören EX2, LG2, RCP, RSQ) sind nur in der w-Einheit vorhanden, ihr Ergebnis kann aber kostenlos auch auf die xyz-Einheit übertragen werden (dabei verliert man natürlich die Möglichkeit der Parallelisierung von zwei Anweisungen). LRP ohne Swizzling kann in einem einzigen Befehl durchgeführt werden, ansonsten wird es durch zwei MAD-Befehle ersetzt. Alle komplexeren Befehle (POW, COS, etc.) werden durch mehrere Befehle ersetzt und sind dementsprechend langsam.

Ich hoffe, diese Information kann vielleicht ab und zu ein bißchen beim Optimieren helfen. Bedenkt aber, dass all das natürlich nur für den R300 gilt. Ich habe keine Ahnung, wie die Fragmenteinheit auf der NVidia-Seite intern aussieht, aber sie hat zum Beispiel nicht die Einschränkung der Nodes.

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_matrixshader.txt · Zuletzt geändert: 2010/08/03 20:11 von Bluthund