Wir lernen Shader kennen
Worum geht es in diesem Tutorial?
Zuerst einmal herzlich willkommen.
In diesem Tutorial geht es um eine erste Bekanntschaft mit Shadern. Vielleicht habt Ihr schon etwas in OpenGL hineingerochen, und zwar auf herkömmliche Weise (Ihr wisst, was ich damit meine: glRotate, glVertex und co.), und möchtet nun die Techniken verwenden, die Stand der Dinge sind. Vielleicht habt Ihr's schon mal mit Shadern versucht, seid aber aus irgendeinem Grunde gescheitert. Und wenn Ihr überhaupt noch keine Ahnung von OpenGL habt, seid Ihr hier ebenfalls nicht verkehrt, nur kann ich Euch dann nicht ersparen, dass Ihr Euch nebenher über dieses und jenes informiert. Hinweise dazu werde ich einstreuen.
Shader gibt es schon lange, ich glaube ein rundes Jahrzehnt. Aber die Shaderprogrammierung gehörte eher zu den Highlights, es war etwas für Fortgeschrittene, die sich in den Niederungen der OpenGL-Programmierung schon gut auskannten. Doch dann kam mit der OpenGL-Version 3.0 die Entscheidung, auf eine ganze Reihe von Dingen zu verzichten. Dazu gehörten z.B. so vertraute Elemente wie der Matrix-Stack oder die einfach zu handhabenden Transformations-Funktionen. Mit anderen Worten: All dieses sollte aus OpenGL verschwinden. Eine Zeitlang wurden die Funktionen als "deprecated" gekennzeichnet, waren ansonsten aber noch vorhanden. In den neueren Versionen jedoch wurden die Funktionen aus dem Kernprogramm entfernt; man kann sie nur noch erreichen, indem man über eine Extension den sogenannten "Kompatibilitätsmodus" einschaltet. Irgendwann werden sie wohl ganz verschwinden. Wer allerding mit aktuellen Methoden programmieren möchte, sollte diesen Kompatibilitätsmodes gar nicht erst einschalten.
Was sich im Zuge dieser Entwicklung ebenfalls geändert hat, das sind die Konzepte, mit denen Anfänger an OpenGL herangeführt werden. Nehmen wir das klassische Beispiel, das häufig zur Einführung herhalten musste, sozusagen das "Hello World" der Grafikprogrammiererei: Ein Dreieck mit bunten Farbverläufen. Mit herkömmlichen Methoden ging es relativ einfach: ein paar Einstellungen vorweg, dann, eingerahmt von glBegin() und glEnd() dreimal die Kombination glColor() und glVertex(). Genau das sind aber die Funktionen, die als nicht mehr zeitgemäß gekennzeichnet wurden und demzufolge auf der Kippe stehen.
Alle, die jetzt spektakuläre Ergebnisse erwarten, muss ich enttäuschen. Auch in diesem Tutorial werden wir das sattsam bekannte Dreieck bemühen. Viel wichtiger als tolle Sachen gleich von Anfang an scheint mir zu sein, dass man die Grundzüge der Shaderprogrammierung versteht und nicht nur oberflächlich ein paar Rezepte übernimmt. Auch wenn die Shaderprogrammierung nicht wirklich kompliziert ist, ist die Gefahr doch recht groß, dass man das eine oder andere nur halb bzw. oberflächlich versteht. Das ist keine gute Voraussetzung, um mit Shadern vertraut zu werden.

Zugegeben, ein langweiliges Bild. Doch gar nicht mal so unnütz. Das Dreieck könnte Teil des Farbdialogs eines Grafikprogramms sein (Helligkeit, Sättigung usw.)
Als Shader-Programmiersprache verwende ich GLSL (OpenGL Shading Language), die Bestandteil von OpenGL ist. Es gibt noch andere Sprachen (z.B. CG von NVidia), die auch nicht schlecht sind, aber meines Erachtens gibt es keinen Grund, auf andere Sprachen auszuweichen, es sei denn, wir wollen ein Programm schreiben, das neben OpenGL auch DirectX einbindet. Aber damit wären wir total falsch in dieser Runde.
Das Tutorial enthält an einigen Stellen Code. Dieser wird in der Regel sowohl in einer C-Version als auch in einer Pascal-Version vorgestellt. DelphiGL war ursprünglich ziemlich auf Pascal ausgerichtet, aber inzwischen scheint mir diese einseitige Fixierung nicht mehr sinnvoll zu sein. Ich denke mal, dass eine wohlausgewogene Mehrsprachigkeit gut für die Leser und nicht zuletzt auch gut für die Zukunft von DelphiGL ist.
Noch ein Hinweis vorweg: Die Shader müssen natürlich mit Daten versorgt werden, und da es um zeitgemäßes Programmieren geht, wäre es vielleicht sinnvoll, dazu VBOs (Vertex Buffer Objects) zu benutzen. Tatsächlich hatte ich diese im ersten Entwurf auch eingesetzt, aber ich merkte, dass dadurch vom eigentlichen Kern des Tutorials, nämlich den Shadern, abgelenkt wird. Folglich habe ich in der überarbeiteten Version auf VBOs verzichtet. Gleichwohl ist das VBO-Verfahren absolut zu empfehlen.
Was muss vorausgesetzt werden?
Es handelt sich zwar um ein Tutorial für Anfänger, doch auf eines muss ich in aller Deutlichkeit hinweisen: Über ein gewisses Grundwissen über Vektoren und Matrizen müsst Ihr schon verfügen. Halbwissen, mit dem Ihr Euch bisher vielleicht über die Runden retten konntet, reicht nicht mehr. Und sofern Ihr in Pascal programmiert (was bei den Delphi-Jüngern ja vorkommen soll ;-), bleibt Euch eine - sagen wir mal - grobe Einarbeitung in C nicht erspart, denn Shader sind im Grunde kleine C-Programme. Aber so schlimm ist das nicht.Ferner kann ich mich in diesem Tutorial nicht um die Infrastruktur kümmern; ich muss also voraussetzen, dass Ihr ein Rahmenprogramm am Laufen habt, das zumindest ein OpenGL-Fenster einrichtet, und in dem eine Funktion angelegt ist, die das Rendern übernimmt. Das alles kann sehr rudimentär sein; so reichen z.B. unter C/C++ einige Glut-Funktionen. Was unter Delphi/Pascal am günstigsten ist, kann ich auf Anhieb nicht sagen. Mit einer funktionierenden SDL-Umgebung seid Ihr natürlich ebenfalls aus dem Schneider. Eine Eventschleife für die Tastenabfrage wird nicht unbedingt gebraucht, höchstens für den Fall, dass Ihr bei eigenen Experimenten zwischen verschiedenen Optionen umschalten wollt.
Ganz wichtig ist, dass das Rahmenprogramm in irgendeiner Form die Fehlermeldungen ausgibt. Ideal ist also die gute, alte Konsole, und wer unter Linux arbeitet, hat schon gute Karten. Wie man das unter Windows bewerkstelligt, kann ich spontan nicht sagen. Ich hoffe, dass ein Windows-Kundiger entsprechende Hinweise in das Tutorial einfügt. Jedenfalls muss sichergestellt sein, dass Ihr bei irgendwelchen Fehlern rund um die Shader sofort eine entsprechende Meldung erhaltet. Glaubt mir, bei den ersten eigenen Schritten wird es solche Meldungen hageln.
Also: Falls Ihr noch kein derartiges Rahmenprogramm am Laufen habt, dann klickt dieses Tutorial erst mal wieder weg und schaut Euch die ersten Anfängertutorials auf DelphiGl oder - falls Ihr mit C bzw. unter Linux arbeitet - die ersten Nehe-Tutorials an. Es kann sein, dass da bereits etwas opengl-mäßig auf den Bildschirm gezeichnet wird. In diesem Fall geht mal über den Quelltext und prüft, ob da irgendwas mit glBegin() - glEnd() läuft. Falls ja, raus damit! Es ist genau das, was wir NICHT mehr verwenden wollen. Aber das andere, z.B. die Einrichtung des OpenGL-Fensters, wird gebraucht.
Was sind überhaupt Shader und wozu gebraucht man sie?
Das Wort "Shader" ist abgeleitet von "shade" (Schatten) oder auch "to shade" (beschatten). Im engsten Sinne hat ein Shader die Aufgabe, die Dinge im virtuellen Raum zu schattieren, also mit Farbe, Texturen usw. zu versehen und dabei die Beleuchtung zu berücksichtigen. Der Begriff hat sich allerdings ein wenig verselbständigt: Als Shader verstehen wir Programme, die bestimmte Funktionen im Renderprozess übernehmen und auf der Grafikkarte laufen. Das kann weit über das ursprüngliche Schattieren von virtuellen Dingen hinausgehen. Kurz: mit Shadern können wir in die Vorgänge, die im Innern der Grafikkarte ablaufen, eingreifen und sie nach eigenen Wünschen gestalten - innerhalb gewisser Grenzen natürlich. Nun dürfte Euch klar geworden sein, warum wir uns überhaupt mit Shadern abgeben: mit denen sind wir flexibler und können auch Dinge machen, die ohne Shader nicht oder zumindest nur unvollkommen möglich sind.
Von daher ist es gar nicht mal so schlecht, dass wir gezwungen sind, uns damit auseinanderzusetzen, auch wenn es am Anfang etwas mühsam ist. Ich werde in diesem Tutorial nur auf die schon seit längerem geläufigen Vertex- und Fragmentshader eingehen, obwohl es inzwischen weitere gibt. Aber die kann man sich nach und nach erarbeiten und sollte dieses auch nicht übers Knie brechen, denn nur sehr neue Grafikarten können mehr als nur die zwei genannten Shader einbinden. Was aber ist der Unterschied zwischen den beiden? Und vor allem: An welchen Stellen im Renderprozess werden sie wirksam und wie fügen sie sich in die Bearbeitungskette ein?
Das bunte Dreieck auf dem Weg durch die Pipeline
Verfolgen wir einmal unser Dreieck, wie es schrittweise von OpenGL bearbeitet wird. Die erste Frage ist, welche Daten wir dazu benötigen. Nun, es ist offensichtlich, dass wir die Positionen der drei Eckpunkte festlegen müssen; diese Koordinaten bestimmen Form und Größe des Dreiecks. Einen Punkt nennt man auch "Vertex" (Mehrzahl: "Vertices"); deshalb sprechen wir von Vertexdaten. Zu den Vertexdaten gehören aber auch drei Farben, die wir den Vertices zuordnen. Das mag irritieren, denn Vertices sind Punkte ohne Ausdehnung, und eingefärbt wird schließlich die dreieckige Fläche. Doch die Farbverläufe gehen von den Eckpunkten aus; insofern können auch Farben Bestandteil der Vertexdaten sein. Es gibt noch weitere Daten wie z.B. Texturkoordinaten oder Normalen, aber darauf gehen wir in diesem Tutorial nicht ein.
Bis aus den genannten 6 Vektoren, die wir OpenGL übergeben (3 für die Postionen, 3 für die Farben), schließlich das farbige Dreieck wird, sind etliche Schritte erforderlich, die allesamt in der Grafikkarte ablaufen. Wenn wir keine Shader verwenden, dann verläuft die gesamte Bearbeitungskette nach einem festen Schema, der sogenannten "Fixed Rendering Pipeline". Wir können zwar dieses und jenes einstellen, aber letztlich nur das erreichen, was von vornherein als Option angelegt ist. Wenn wir jedoch Shader verwenden, dann nehmen wir bestimmte Abschnitte der Rendering Pipeline selbst in die Hand und können sie flexibel ausgestalten. Allerdings sind die Bearbeitungsschritte, die von Shadern übernommen werden, immer noch in der Pipeline eingebunden; das heißt, wir müssen beim Programmieren der Shader im Augenmerk behalten, was vorher und nachher passiert, sonst funktioniert es nicht. Folglich ist es unumgänglich, dass wir uns einen Überblick über diese Abläufe verschaffen, ohne allerdings mehr ins Detail zu gehen als erforderlich.

Zuerst gelangen die Vertexdaten in die Phase Per-Vertex Operations, wo sie - jeder Vertex für sich - bearbeitet werden. Dass die 3 Vertices zusammen ein Dreieck bilden, spielt an dieser Stelle noch keine Rolle. Die wichtigsten Aufgaben bestehen darin, die Vertices zu transformieren, also an die gewünschte Stelle zu schieben. Dabei geht es zum einen um die Position im Raum, zum anderen aber auch um Dinge wie Skalierung oder Drehung. Nun fragt Ihr Euch vielleicht, wieso man einen Punkt skalieren oder drehen kann. Das kann man doch nur mit geometrischen Formen wie z.B. unserem Dreieck machen. Tatsächlich bedeutet die Drehung des Dreiecks, dass seine Eckpunkte verschoben werden. Verantwortlich dafür sind Matrizen, auf deren Anwendung ich vielleicht in einem anderen Tutorial eingehen werde. Im Falle unseres Dreiecks sind Matrizen noch nicht relevant, denn das Dreieck wird weder verschoben noch skaliert noch rotiert. Aber wenn wir uns schon mal die Vertex-Operationen betrachten, können wir gleich ein wenig schauen, was überhaupt möglich ist.
Weitere Dinge, die in dieser Station erledigt werden, sind die Normalisierung und Transformation von Vektoren, die als Normale fungieren; ferner die Transformation von Textur-Koordinaten oder auch die Beleuchtung (das "Lighting"), sofern sie an Vertices orientiert ist. Auch darauf werden wir in diesem Tutorial nicht eingehen. Wichtig für uns sind die Vertex-Oparations deshalb, weil deren Aufgaben durch den den ersten Shader, den Vertex-Shader übernommen werden können.
Die nächste Station in der Kette ist Primitive Assembly. Hier werden aus den einzelnen Vertices einfache geometrische Gebilde erzeugt, die sogenannten Primitive. In unserem Fall wird aus den drei Vertices also das Dreieck zusammengefügt und als neue Einheit betrachtet. Es gibt nicht sehr viele Arten von Primitiven in OpenGL; neben einigen Dreiecks-Varianten sind es Punkte und Linien. Vierecke waren auch mal möglich, sind aber inzwischen dem Ausdünnungsprozess zum Opfer gefallen. Alles was komplexer ist, muss vom Anwender aus Primitiven zusammengebastelt werden. Nun, unser Dreieck hat das nicht nötig.
Es könnte die Frage auftreten, wieso eigentlich der Punkt auch zu den Geometrien zählt. Tatsächlich gibt es einen Unterschied zu den dimensionslosen Vertices, die im Kern nichts anderes als Vektoren sind. Punkte im Sinne von Primitiven dagegen sind reale Gebilde, und sie haben später auf dem Bildschirm mindestens die Größe eines Pixels. Sie sind also nicht unsichtbar klein, ähnlich wie Linien eine Mindestdicke haben, damit wir sie sehen können.
Mit den Primitiven stellt OpenGL in der Station Primitive Operations nun so einiges an. So werden, nur als Beispiele, die Geometrien je nach gewünschter Perspektive auf eine Ebene projiziert (Perspective Projection), oder es wird alles, was nicht mehr in den Bildausschnitt passt, weggeschnitten (Viewport Culling), oder die Rückseiten von Flächen werden - falls so eingestellt - vom Rendering ausgeschlossen (Backface Culling). Auch das Frustum Clipping, das Wegschneiden von Dingen, die nicht mehr im Sichtbarkeitsbereich liegen, gehört dazu. Das sind sehr wichtige Schritte auf Primitiven-Basis, aber sie laufen in der Fixed Pipeline ab und nicht im Shader.
Dann kommt ein Schritt, der die Daten völlig ummodelt, nämlich das Rasterize. Der Bildschirm bzw. der Framebuffer, der die gerenderten Bilder an den Bildschirm weiterreicht, verlangt nach einer simplen Pixelmap. Nur werden die Bildpunkte hier Fragmente genannt, zur Unterscheidung von den Pixeln etwa einer Textur. Ja, und der Rasterizer macht nichts anderes, als die aufbereiteten Primitive in Fragmente aufzulösen. Dazu werden Punkte zu Linien verbunden (einschließlich Antialiasing); da werden Flächen gefüllt; da werden durch Interpolation Farbverläufe zwischen den Vertex-Farben oder Helligkeitsabstufungen realisiert usw.
Nachdem nun alle Daten in Form von Fragmenten vorliegen, kommt die Phase, in der diese bei Bedarf weiter bearbeitet werden, die Per-Fragment Operations. Und das ist gleichzeitig die Stelle, wo der zweite Shader greift, der Fragment-Shader. Hier können die Farben manipuliert werden; hier ist der Ort, wo die Szene auf Wunsch vernebelt wird (Fog). Vor allem aber ist dieses die Werkstatt für die Bedeckung mit Texturen. Während es diesbezüglich in der fixed Pipeline relativ schematisch zugeht, kann man im Fragment-Shader auch ausgefallene Wünsche realisieren. Da unser Dreieck weder Texturen benutzt noch besondere Farbänderungen benötigt, rutscht es quasi unverändert durch.
Bevor es dann endgültig in den Framebuffer geht und das Bild von dort mittels "Swap Buffers" in den Bildschirmspeicher übertragen wird, gibt es noch eine Phase, die wieder der Fixed Pipeline vorbehalten ist, die Per-Fragment Tests. Hier werden auf Fragment-Basis einige Tests wie der Alpha-Test, der Stencil-Test oder der Depth-Test durchgeführt, um die wichtigsten zu nennen. Von allem ist unser Dreieck wiederum nicht betroffen, so dass wir es bei dieser unvollständigen Aufzählung belassen wollen.
Das Aufgezählte ist bei weitem nicht alles, was sich in der Pipleline bzw. den Shadern tut. Außerdem fehlt ein ganzer Zweig, nämlich die Bearbeitung von Pixeldaten, die auf etwas kürzerem Wege ebenfalls in den Rasterizer landen. Ich denke, das kann alles ergänzt werden, wenn es auch wirklich gebraucht wird. An dieser Stelle kommt es mir darauf, einen ersten Überblick zu verschaffen und den Part, den die Shader übernehmen (können), zu beschreiben.
Wann und wie oft treten Shader in Aktion?
Ganz einfach: Es heißt "OpenGL Rendering Pipeline"; folglich tritt die Pipeline beim Rendern in Aktion. Das Rendern wiederum wird durch einen Draw-Befehl (z.B. glDrawArrays) angestoßen. Wie wir gesehen haben, werden nach Verlassen des Vertex-Shaders die Primitiven gebildet. Das bedeutet, dass der Draw-Befehl als Parameter eine Angabe über den Primitiven-Typ erwartet, in unserem Fall GL_TRIANGLES.
Auch das "wie oft" ist den Bezeichnungen zu entnehmen. Es heißt "Per-Vertex Operations", was nichts anderes bedeutet, als dass der Vertex-Prozessor für jeden Vertex aktiv wird. Mit den Fragmenten verhält es sich ähnlich: Jedes einzelne Fragment wird vom Fragment-Prozessor bearbeitet. Da fragt man sich, wie die Grafikkarte das schafft, denn 1 Million Fragmente können durchaus zustande kommen. Aber die Karten haben mehrere solcher Fragment-Prozessoren eingebaut, aktuelle Karten sogar mehrere hundert. Damit läuft eine Menge parallel.
Diese Parallelität, mit der die Bearbeitung um ein Vielfaches beschleunigt wird, ist aber nur möglich, weil jedes Fragment für sich bearbeitet wird. Durchschnittsbildungen und ähnliche Dinge sind nicht ohne weiteres möglich. Bei den Vertices ist es ähnlich.
Wie schreiben wir Shader und machen sie zugänglich?
Wie ich schon sagte, sind Shader sowas wie kleine C-Programme. Das heißt aber nicht, dass wir sie mit dem anderen Programmcode vermengen können; Shader sind selbständige Einheiten. Es gibt grundsätzlich zwei Möglichkeiten, einen Shader zu benutzen:
- Wir können den Shader als nullterminierten Char-String direkt im Anwendungscode ablegen. Dabei spielt es keine Rolle, in welcher Programmiersprache das Hauptprogramm geschrieben ist. Der Shaderstring wird nach dem Programmstart an OpenGL übergeben und kompiliert.
- Wir speichern den Shader als Textdatei außerhalb des Hauptprogramms. Der Text wird zur Laufzeit eingelesen und kompiliert.
Beide Verfahren haben ihre Vor- und Nachteile. In der Entwicklungsphase, wenn wir sowieso ständig im Programmcode herumbasteln, ist das erste Verfahren wohl praktischer. Später, wenn alles einigermaßen steht, wird es günstiger sein, die Shader in Textdateien auszulagern. Dann können wir mit den Shadern experimentieren, ohne das Hauptprogramm neu kompilieren zu müssen.
Wichtig ist folgendes: Wie und wo auch immer wir den Shader-Quelltext verwalten, OpenGl erwartet ihn in jedem Fall als Zeiger auf einen nullterminierten String; genauer: als Zeiger auf ein Array solcher Strings. In C haben wir also ein Array von Arrays, deshalb der Typecast (GLchar**). In Pascal mit Delphi bietet sich m.E. der Typ TStringList an, mit dem sich besonders bequem die Shader als Dateien laden und speichern lassen.
Unser erstes Shader-Programm
Ich denke, nach diesem ganzen Vorgeplänkel wird es Zeit, dass wir uns ein Shaderpaar anschauen. Meines Erachtens macht es nicht viel Sinn, nur einen Vertex- oder nur einen Fragment-Shader zu verwenden, was natürlich auch möglich ist. Die Shader, die wir untersuchen, machen nicht viel, sie zeichnen lediglich ein Dreieck mit bunten Farbverläufen auf den Bildschirm, eben das "Hello-World" der OpenGL-Welt. Weiter werde ich in diesem Tutorial übrigens nicht gehen, denn schon dieses Dreieck bietet genügend Stoff für viele Überlegungen und natürlich auch eigene Experimente. Um Lösungen für konkrete Anwendungsfälle kann es hier also nicht gehen, das muss weiteren Tutorials oder auch der Shadersammlung vorbehalten bleiben. Hier also die Shader-Quelltexte, in einzelnen Textdateien gespeichert:
Vertex-Shader:
// Vertex Shader #version 330 in vec3 attPosition; in vec3 attColor; out vec3 varColor; void main (void) { gl_Position = attPosition; varColor = attColor; }
Fragment-Shader:
// Fragment-Shader #version 330 in vec3 varColor; out vec4 outColor; uniform float uniAlpha; void main (void) { outColor = vec4 (varColor, uniAlpha); }
Schauen wir uns nun die einzelnen Zeilen an. Beide Shader beginnen mit einem Kommentar, der dieselbe Syntax hat wie in C/C++. Wie in anderen Programmen sollten wir in unseren Shadern an wichtigen Stellen Kommentare einbauen, damit wir uns später darin zurecht finden.
Dann folgt eine optionale Versionsangabe. Sie besagt, dass hier die GLSL-Version 3.30 (oder später) vorausgesetzt wird. Es empfiehlt sich, hier nicht allzu sehr hochzustapeln, denn wenn wir selbst eine topaktuelle Grafikkarte im Rechner haben, heißt das noch lange nicht, dass andere ebenfalls in der glücklichen Lage sind. Eine Tabelle mit den Zuordnungen von GLSL-Versionen zu OpenGL-Versionen findet Ihr im Anhang. Die Versionsangabe muss immer am Anfang stehen.
Gehen wir im Vertex-Shader weiter. Es folgt nun die Variablen-Deklaration "in vec3 attPosition", ein Dreikomponenten-Vektor. Der Qualifier "in" besagt, dass der Shader eine Eingabe von außen, also dem Anwendungsprogramm, erwartet. Genauer gesagt handelt es sich um eine Attribut-Variable, die nur für Vertex-Shader in Frage kommt und im Shader nur gelesen werden kann. Früher wurden diese Attribute mit dem Schlüsselwort "attribute" gekennzeichnet, aber inzwischen ist das Schlüsselwort "in" geläufig. Wie die Datenübergabe von außen vonstatten geht, werde ich weiter unten ausführlich beschreiben. Auf die verschiedenen Datentypen (davon gibt es eine Menge) dagegen gehe ich nicht ein, das würde dieses Tutorial unübersichtlich machen. Ich darf aber auf das Tutorial von Sascha Willems verweisen, das eine gute Übersicht enthält.
Eine weitere Attribut-Variable (attColor) nimmt die Farbe auf. Ja, und dann gibt es noch eine zweite Farbvariable (varColor), wobei es sich um eine sogenannte Varying-Variable handelt. Dieser Variablentyp nimmt keine Daten von außen auf, sondern leitet sie intern vom Vertex- zum Fragmentshader weiter. Dazu muss dieselbe Variable im Fragementshader deklariert sein, und zwar so wie im Vertex-Shader, nur dass nun der Qualifier "in" vorangestellt wird.
Doch zurück zum Vertex-Shader. Wie jedes C-Programm benötigt auch jeder Shader die Haupt- und Einstiegsfunktion "main", allerdings als void deklariert und ohne Parameter. Wichtig ist die erste Zeile in der Main-Funktion:
"gl_Position" ist eine der Built-in-Variablen, die nicht deklariert werden müssen, sondern im Shader automatisch zur Verfügung stehen. Alle diese Variablen beginnen mit "gl_". Es würde wiederum zu weit führen, diese Variablen alle aufzulisten, dazu gibt es reichlich Informationen im Internet und auch im erwähnten Tutorial von Sascha Willems. Hier nur soviel: "gl_Position" legt die Position des gerade zu bearbeitenden Vertex fest und muss (!) geschrieben werden, sonst liegt der Vertex undefiniert irgendwo.
In der nächsten Zeile wird der Varying-Variablen varColor die Farbe von attColor zugewiesen:
Der Vorgang ist recht simpel: Der Vertex-Shader übernimmt die Farbe, verwendet sie aber nicht selber, sondern reicht sie an die nachfolgenden Instanzen weiter. Vielleicht fragt Ihr Euch, warum wir die Farbe nicht direkt an den Fragment-Shader übergeben, was ohne weiteres möglich wäre. Aber dann bekämen wir nicht die gewünschten Farbverläufe, zumindest nicht auf so einfache Weise. Nachdem die Farbwerte nämlich den Vertex-Shader verlassen, werden sie in den folgenden Stationen interpoliert. Das können wir unterdrücken, indem wir einen weiteren Qualifier ("flat") hinzufügen. An dieser Stelle möchte ich aber nicht näher darauf eingehen.
Im Fragment-Shader finden wir das Gegenstück zur Varying-Variablen varColor, hier als Eingabe ("in") gekennzeichnet. Als Ausgabe wird eine weitere Variable "out vec4 outColor" verwendet. Diese Ausgabe ist erforderlich, denn die Farben sollen ja nicht im Fragment-Shader steckenbleiben. Was vielleicht überrascht (und dem linientreuen C-Anhänger die Haare zu Berge stehen lässt) ist die Art der Zuweisung. Da wird einfach ein Dreikomponenten-Vektor an einen mit vier Komponenten übergeben, und was da fehlt, wird ergänzt. Das Fehlende ist der Alpha-Wert, der erst jetzt im Fragment-Shader gebraucht wird und über die Uniform-Variable "uniAlpha" von außen an den Shader übergeben wird. Der GLSL-Compiler kann mitunter recht pingelig sein, doch wenn's um Variablen-Zuweisungen geht, ist er von einer verblüffenden Großzügigkeit. Man kann diese Alpha-Komponente übrigens auch durch einen festen Wert ersetzen oder ganz weglassen. Im letzteren Fall wird der Default-Wert 1.0 herangezogen. Ebenfalls können wir jede Farbkomponente noch einzeln bearbeiten usw. Aber das alles wird erst interessant, wenn wir bestimmte Aufgaben lösen möchten.
Damit wir endlich mehr Farbe ins Tutorial bekommen, hier ein anderes Ergebnis:

Wie werden Shader eingerichtet?
Die Einrichtung eines Shaders oder - besser gesagt - eines Shaderprogramms verläuft nach typischem OpenGL-Muster: Zuerst wird ein leeres Objekt angelegt, dann wird es mit Inhalt gefüllt. Den zurückgegebenen Integer-Index (in der OpenGL-Welt spricht man von "Namen"), der auf das Objekt zeigt, bewahren wir gut auf, denn der wird gebraucht, um das Objekt anschließend zu benutzen. Zu theoretisch? Kein Problem, es ist im Grunde ganz einfach:
// Erzeugung eines leeren Shader-Objektes, Rückgabe: Index des Shader-Objektes GLuint vertshader = glCreateShader (GL_VERTEX_SHADER); // Einlesen des Shader-Quelltextes in das neu eingerichtete Shader-Objekt: // Der Shader-Quelltext befindet sich in einem Array "vertstring" // mit nullterminieren Charakter-Strings. glShaderSource (vertshader, 1, (const GLchar**)vertstring, NULL); // Kompilieren des Shaders; der Sourcecode wird danach nicht mehr gebraucht glCompileShader (vertshader); // Dasselbe noch einmal mit dem Fragment-Shader: GLuint fragshader = glCreateShader (GL_FRAGMENT_SHADER); glShaderSource (fragshader, 1, (const GLchar**)fragstring, NULL); glCompileShader (fragshader); // Nun wird ein leeres Shaderprogramm erzeugt: GLuint program = glCreateProgram (); // Sowohl Vertex-als auch Fragment-Shader werden mit dem Programm verknüpft: glAttachShader (program, vertshader); glAttachShader (program, fragshader); // Die kompilierten Shader werden zum Programm gelinkt: glLinkProgram (program); // Schließlich wird aufgeräumt: glDeleteShader (vertshader); glDeleteShader (fragshader);
Die bisherigen Schritte nehmen wir zweckmäßigerweise in der Initialisierungsphase vor. Normalerweise werden wir in unseren Anwendungsprogrammen mehrere solcher Shader-Programme einrichten, für die verschiedenen Zwecke je eines. Was in der gezeigten Befehlsfolge nicht erkennbar ist, das sind die Möglichkeiten, Elemente auszutauschen, zu löschen, zu deaktivieren usw. Dazu schaut Ihr am besten in eine gute Referenz (gibt's hier auf DelphiGL und auch sonstwo im Internet), lest die Ausführungen zu den genannten Funtkionen und werft dann einen tiefen Blick in die Querverweise "siehe auch".
Was ebenfalls nicht aufgeführt wurde, das sind die Fehlerabfragen. Besonders beim Linken der Shader zu einem Shader-Programm ist OpenGL in Meckerstimmung. Im Code (siehe Anhang A) sind die wichtigsten Abfragen eingebaut, ohne jedoch nähere Informationen zu liefern. Auch diesbezüglich muss ich Euch zutrauen, Euch selber zu informieren.
Nachdem das Shaderprogramm eingerichtet ist, versorgen wir die Shader noch mit Daten:
// Als erstes müssen wir das gewünschte Shaderprogramm aktivieren: glUseProgram (program); // Dann können die Daten übergeben werden. // Auf die Details gehe ich im nächsten Abschnitt ein, deshalb werden hier // die Funktionen und Parameter nicht im einzelnen aufgelistet. // Schließlich kann gezeichnet werden, hier als Beispiel das Dreieck: glDrawArrays (GL_TRIANGLE, 0, 3);
Wie gelangen meine Daten in den Shader?
Nachdem die Shader compiliert und zu einem Shader-Programm gelinkt sind, liegt das alles in den Tiefen von OpenGL begraben. Da taucht natürlich die Frage auf: Wie kommen wir überhaupt da dran, denn schließlich müssen wir ja noch einiges übergeben. In unserem Beispiel-Shader ist das zwar nicht viel, nur die Positionen und Farben zu den drei Eckpunkten des Dreiecks sowie der Alpha-Wert, aber immerhin. Es muss also sowas wie eine Schnittstelle geben.
Im Grunde arbeitet OpenGL so ähnlich wie andere Compiler bzw. Präcompiler. Beim Linken werden die Variablen-Adressen in einer internen Tabelle erfasst. Wir können zwar von außen nicht direkt auf diese Adressen zugreifen, aber wir können uns von OpenGL den Index, mit dem über diese Tabelle auf die gewünschte Variable zugegriffen wird, mitteilen lassen. Mit diesem Index lässt sich anschließend alles Erforderliche bewerkstelligen. Bleiben wir bei unserem simplen Shader-Beispiel und holen uns die Indices für die beiden Attribut-Variablen:
GLint col_id = glGetAttribLocation (program, "attColor");
Zwischendurch ein Hinweis. Achtet darauf, von welchem Typ der Rückgabewerte ist. In diesem Fall handelt es sich um GLint, das ist ein vorzeichenbehafteter Integer. Wird -1 zurückgegeben, dann ist etwas falsch gelaufen, denn die gültigen Werte beginnen bei 0. In vielen ähnlichen Situationen (vielleicht sogar den meisten) liefert OpenGL einen vorzeichenlosen Integer (unsigned Int, GLuint) zurück. In diesen Fällen beginnen die "Erfolgswerte" bei 1, während die 0 den Misserfolg signalsiert. "glCreateShader" oder "glCreateProgram" sind solche Kandidaten. Ich konnte bisher noch keine Hinweis finden, warum dies so unterschiedlich gehandhabt wird, aber ich glaube, das hängt mit dem C-Stil der Shader zusammen. In C beginnt jedes Array bei 0 und somit vermutlich auch die vom Linker erzeugte Variablentabelle.
Es leuchtet ein, dass die genannten Funktionen erst nach dem Linken aufgerufen werden können. Dass wir uns die Indices von OpenGL mitteilen lassen, ist an und für sich eine gute Sache, denn wir geraten nicht in Gefahr, einen Index mehrfach zu benutzen zu wollen. Es gibt aber noch die alternative Methode, dass wir OpenGL einen Index vorschreiben:
GLint col_location = 11; // nicht zu hoch greifen, die Liste ist begrenzt
glBindAttribLocation (program, pos_location, "attPosition");
glBindAttribLocation (program, col_location, "attColor");
Dieses Verfahren hat vor allem dann Vorteile, wenn wir das Ganze systematisieren wollen, z.B. in Form eines Shader-Managers. Dann bietet sich an, den einzelnen Attributtypen feste Zugriffskonstanten zuzuordnen, die auf alle Shader angewandt werden. Das vereinheitlicht die Sache und erleichtert das Hinzufügen von weiteren Shadern. Zweckmäßigerweise werden symbolische Konstanten verwendet, z.B. ATT_POSITION und ATT_COLOR für die beiden von uns benutzten Attribute. Logischerweise muss "glBindAttribLocation" VOR dem Linken aufgerufen werden! Ich werde in diesem Tutorial nicht näher auf dieses alternative Verfahren eingehen, vielleicht in einem Folgetutorial, das sich mit Shader-Managern befasst. - Eine andere Alternative besteht darin, dass man im Shader-Quelltext die Variable mit der gewünschten ID deklariert, doch das möchte ich nur am Rande erwähnen. Ich halte das Verfahren rein logistisch nicht für günstig.
Bleibt noch die Uniform-Variable. Auch dafür holen wir uns den Index:
Nachdem wir nun wissen, wie wir auf die Variablen zugreifen können, müssen wir sie noch mit Daten versorgen. Im Anwendungsprogramm sind die Attribute in aller Regel in Arrays gespeichert:
GLfloat triangle_vertices[] = { -0.5, -0.5, 0.0, 0.5, -0.5, 0.0, 0.0, 0.5, 0.0 };
Die Farbangaben für die drei Punkte werden ähnlich definiert:
GLfloat triangle_colors [] = { 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0 };
Da die Daten in unserem Fall im Speicherraum des Anwendungsprogramms verbleiben, müssen wir OpenGL mitteilen, wo sie zu finden sind:
glEnableVertexAttribArray (pos_id); glVertexAttribPointer ( pos_id, // Index des Attributs 3, // Anzahl der Werte pro Vertex (1,2,3 oder 4) GL_FLOAT, // Datentyp der Elemente GL_FALSE, // soll normalisiert werden? 0, // kein Zwischenraum zwischen Elementen triangle_vertices // Zeiger auf Anfang des Arrays );
Mit "glEnableVertexAttribArray" machen wir den Datenweg über ein Vertex-Attribut-Array frei, und "glVertexAttribPointer" beschreibt die Daten genauer und übergibt vor allem einen Zeiger auf das Array, und zwar im letzten Parameter. Mit den Farbwerten verfahren wir analog:
glVertexAttribPointer (col_id, 3, GL_FLOAT, GL_FALSE, 0, triangle_colors);
Da die Uniform-Variable nur aus einem einzelnen Float-Wert besteht, wird sie direkt übergeben:
Zur Erinnerung: Wir wollen mit dieser Variablen den Alphawert einstellen. Mit dem gezeigten Wert von 1.0 werden die Farben voll leuchtend wiedergeben. Reduzieren wir den Alphawert, schimmert mehr oder weniger der Hintergrund durch. Ausprobieren! Die Funktion glUniform kommt wie viele OpenGL-Funktionen in etlichen Formen vor. "1f" bedeutet zum Beispiel, dass ein einzelner Float-Wert übergeben wird; bei "4i" würden 4 Integer-Werte übergeben, z.B. die Komponenten eines Integer-Vektors.
Eines dürfen wir nicht vergessen: Wenn wir den Effekt des Alpha-Wertes sehen wollen, müssen wir vorher das Blending aktivieren:
glBlendFunc (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
Die genannte Blend-Funktion wird sehr häufig angewandt. Sie bewirkt, dass die neu zu zeichnenden Fragmente anteilmäßig mit ihrem Alpha-Wert in Erscheinung treten, der Hintergrund dagegen mit dem, was beim Alpha-Wert an 1 fehlt. 0.4 würde bedeuten: 40% für den Vordergrund, 60% für den Hintergrund. Nichts anderes als eine Verteilung der Farbintensität. Es gibt eine Reihe anderer Blending-Methoden, auf die ich in diesem Tutorial nicht eingehen kann. Es könnte aber interessant sein, einfach mal die Hintergrundfarbe zu ändern. Das geschieht mit dieser Funktion:
Das war's
So, damit bin ich am Ende dieses Tutorials angelangt. Selbst wenn es nur um ein simples Dreieck ging, mussten viele Dinge angesprochen werden, dabei konnten bestimmt nicht alle Fragen beantwortet werden. Vielleicht hilft der folgende Quelltext, um das eine oder andere klarer zu sehen. Experimentiert herum und lasst Euch von den kaum vermeidbaren Misserfolgen nicht entmutigen. Deswegen empfehle ich, am Anfang nur kleine Änderungen vorzunehmen; den wirklich "dicken Shader" könnt ihr sowieso erst mit einiger Erfahrung zustande bringen. Und ich möchte noch einmal die Empfehlung wiederholen, Euch über die angewandten Funktionen genau zu informieren. Außerdem, wie gesagt, verschafft Euch einen kleinen Überblick über die GLSL-Variablen, die angewandten Datentypen und deren Zuweisungen. Dazu ist das Tutorial von Sascha Willems gut geeignet.
Wie geht es nun weiter?
Nun, wir haben den allerersten Anfang geschafft, mehr nicht. Wenn das Tutorial hilfreich sein sollte (hängt von Eurem Feedback ab), werde ich weitere schreiben. Drei Themen sind schon ins Auge gefasst (alles natürlich auf der Basis von Shadern):
- Der Matrix-Stack und die Transformationen
- Verwendung von Texturen
- Organisation: Shader-Manager
Wie auch immer, zunächst wünsche ich Euch viel Erfolg und vor allem viel Spaß beim Programmieren mit OpenGL. Wer Spaß an der Sache hat, wird bestimmt auch was Vernünftiges zustande bringen.
Anhang A: Quelltext des Testprogramms (Delphi/Pascal)
Anhang B: Quelltext des Testprogramms (C/C++)
#include#include #include "GL/glew.h" #include "GL/freeglut.h" using namespace std; // -------------------------------------------------------------------- // Globale Variablen // -------------------------------------------------------------------- static GLuint prog_id; // Index des Shader-Programms static GLint pos_id; // Index der Shader-Attribut-Variablen "attPosition" static GLint col_id; // Index der Shader-Attribut-Variablen "attColor" static GLint alpha_id; // Index der Shader-Uniform-Variablen "uniAlpha" static GLuint vertshad; // Index des Vertex-Shader-Objekts static GLuint fragshad; // Index des Fragment-Shader-Objekts static GLuint num_vertices = 3; // da ein Dreieck gezeichnet werden soll // Quelltext des Vertex-Shaders const char *vertsource = { "#version 330\n" "in vec4 attPosition;" "in vec3 attColor;" "out vec3 varColor;" "void main(void) {" "varColor = attColor;" "gl_Position = attPosition;" "}" }; // Quelltext des Fragment-Shaders const char *fragsource = { "#version 330\n" "in vec3 varColor;" "out vec4 outColor;" "uniform float uniAlpha;" "void main(void) {" "outColor = vec4 (varColor, uniAlpha);" "}" }; // Koordinaten der Eckpunkte des Dreiecks GLfloat triangle_vertices[] = { -0.5, -0.5, 0.0, 0.5, -0.5, 0.0, 0.0, 0.5, 0.0 }; // Farbwerte, die den drei Punkten zugeordnet werden, // hier ohne Alpha-Wert: GLfloat triangle_colors [] = { 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0 }; /* // Eine zweite Farbkomposition als Alternative: GLfloat triangle_colors [] = { 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0 }; */ // -------------------------------------------------------------------- // Funktionen // -------------------------------------------------------------------- // Readfile liest eine Textdatei ein und speichert den Inhalt in einem // nullterminierten String. Rueckgabe: Zeiger auf diesen String. // Im Fehlerfall: Rueckgabe = NULL char *ReadFile (const char *filename) { FILE* input = fopen (filename, "rb"); if (input == NULL) return NULL; if (fseek (input, 0, SEEK_END) < 0) return NULL; long size = ftell (input); if (size < 0) return NULL; if (fseek (input, 0, SEEK_SET) < 0) return NULL; char *content = (char*) malloc ((size_t) size +1); if (content == NULL) return NULL; fread (content, 1, (size_t)size, input); if(ferror (input)) { free (content); return NULL; } fclose (input); content[size] = '\0'; return content; } // Erzeugt ein Shader-Objekt, uebernimmt den Shader-Quelltext und // kompiliert den Shader. Rueckgabe: Index auf das Shader-Objekt // Im Fehlerfall: Rueckgabe = 0 GLuint CreateShader (const char *shadersrc, GLenum type) { GLuint shader = 0; GLint ok; shader = glCreateShader (type); GLchar *sources[1]; sources[0] = (GLchar*) shadersrc; glShaderSource (shader, 1, (const GLchar**) sources, NULL); glCompileShader (shader); glGetShaderiv (shader, GL_COMPILE_STATUS, &ok); if (ok == GL_FALSE) { cout << "Compiler error" << endl; glDeleteShader (shader); return 0; } return shader; } // Aehnlich CreateShader, nur wird der Source-Code aus einer externen // Datei gelesen GLuint CreateFileShader (const char *filename, GLenum type) { char *source = ReadFile (filename); if (source == NULL) return false; return (CreateShader (source, type)); } // Erzeugt ein Shader-Programm, verbindet die beiden Shader damit // und linkt das Programm. Die Shader werden am Schluss geloescht. // Rueckgabe ist der Index auf das Programm-Objekt // Im Fehlerfall: Rueckgabe = 0 GLuint CreateProgram (GLuint vertshader, GLuint fragshader) { if (vertshader == 0 || fragshader == 0) return 0; GLuint program = glCreateProgram (); glAttachShader (program, vertshader); glAttachShader (program, fragshader); glLinkProgram (program); GLint ok; glGetProgramiv (program, GL_LINK_STATUS, &ok); if (ok == GL_FALSE) { glDeleteProgram (program); return 0; } glDeleteShader (vertshader); glDeleteShader (fragshader); return program; } // Im Anwendungsfall kann es besser sein, wenn einige Dinge in der // Renderfunktion statt in der Init-Funktion erledigt werden. int Init (void) { // ---------------------------------------------------------------- // Schritt 1: Erzeugen der Shader und des Shader-Programms // ---------------------------------------------------------------- vertshad = CreateShader (vertsource, GL_VERTEX_SHADER); fragshad = CreateShader (fragsource, GL_FRAGMENT_SHADER); prog_id = CreateProgram (vertshad, fragshad); // ---------------------------------------------------------------- // Schrit 2: Uebergabe der Vertex-Attribute // ---------------------------------------------------------------- // Ermittlung der IDs fuer die Attributvariablen pos_id = glGetAttribLocation (prog_id, "attPosition"); if (pos_id < 0) { cout << "Vertex Shader: Attribute Bind Error" << endl; return 0; } col_id = glGetAttribLocation (prog_id, "attColor"); if (col_id < 0) { cout << "Fragment Shader: Attribute Bind Error" << endl; return 0; } // Ein Zeiger auf das Positionsarray wird uebermittelt. // Die Daten selbst verbleiben im Anwendungsprogramm. // Alternative (hier nicht vorgestellt): Verwendung von VBOs glEnableVertexAttribArray (pos_id); glVertexAttribPointer ( pos_id, // Index des Attributs 3, // Anzahl der Werte pro Vertex (1,2,3 oder 4) GL_FLOAT, // Datentyp der Elemente GL_FALSE, // soll normalisiert werden? 0, // kein Zwischenraum zwischen Elementen triangle_vertices // Zeiger auf Anfang des Arrays ); // Dasselbe fuer das Farbarray glEnableVertexAttribArray (col_id); glVertexAttribPointer (col_id, 3, GL_FLOAT, GL_FALSE, 0, triangle_colors); // ---------------------------------------------------------------- // Schritt 3: Uebergabe der Uniform-Daten // ---------------------------------------------------------------- // Aehnlich wie glGetAttribLocation alpha_id = glGetUniformLocation (prog_id, "uniAlpha"); if (alpha_id < 0) cout << "Could not find uniform location" << endl; glUseProgram (prog_id); glUniform1f (alpha_id, 0.8); return 1; } void free_resources () { // Das Shader-Programm wird entsorgt glUseProgram (prog_id); glDeleteProgram (prog_id); } void Render () { // Blending muss eingeschaltet sein, weil der Alpha-Wert der // Vertex-Farten benutzt wird glEnable (GL_BLEND); glBlendFunc (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); // Das Uebliche: Festlegung der Hintergrundfarbe glClearColor (0.05, 0.1, 0.2, 1.0 ); // ... und Fuellen des Framebuffers mit dieser Farbe: glClear (GL_COLOR_BUFFER_BIT); // Es gibt noch weitere Buffer, die geloescht werden koennen, // hier aber nicht gebraucht: // glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); // Schliesslich wird gerendert glDrawArrays (GL_TRIANGLES, 0, num_vertices); glutSwapBuffers(); } int main (int argc, char** argv) { glutInit (&argc, argv); glutInitDisplayMode (GLUT_DOUBLE | GLUT_RGBA | GLUT_DEPTH); glutInitWindowSize (800, 600); glutCreateWindow ("OpenGL - Shaders For Beginners"); // Glew ermoeglicht den einfachen ZugrGLuint fragshaiff auf die Extensions GLenum glew_status = glewInit (); if (glew_status != GLEW_OK) { cout << "Could not initializse GLEW" << endl; } if (Init() > 0) { glutDisplayFunc (Render); glutMainLoop (); } GLint nNum; glGetIntegerv (GL_NUM_EXTENSIONS, &nNum); free_resources (); return 0; }
Anhang C: OpenGL- und GLSL-Versionen
Hinweis: Die GLSL-Versionen sind so angegeben, wie sie in den Shadern zu notieren sind. Ab OpenGL-Version 3.3 laufen die Bezeichnungen parallel.
OpenGL-Version | GLSL-Version |
2.0 | 110 |
2.1 | 120 |
3.0 | 130 |
3.1 | 140 |
3.2 | 150 |
3.3 | 330 |
4.0 | 400 |
4.1 | 410 |
4.2 | 420 |
4.3 | 430 |