Shader

Aus DGL Wiki
Wechseln zu: Navigation, Suche

Konzept

Die traditionelle Funktionspipeline der OpenGL ist eine feste Pipeline, auf die man nur beschränkt durch Statechanges Einfluss nehmen kann. Man hat also an sehr vielen Stellen starre Vorgaben die nur minimal anpassbar sind. So sind z.B. Farbberechnungen oder die Beleuchtung fest definiert und nur wenige ihrer Attribute können variiert werden. Neuere Algorithmen erfordern aber eine viel höhere Flexibilität.

Einzelne Komponenten der Pipeline können nun aber durch kleine Programme, sog. Shader ersetzt werden. Diese Shader werden in der C ähnlichen Hochsprache GLSL geschrieben. Es gibt Variablen, if-Bedingungen, Schleifen, beliebige Texturzugriffe, ... vom Prinzip ist auf aktuellen Grafikchips fast alles möglich was auch auf auf einer CPU geht. Der wesentliche Unterschied beim Programmieren der GPU ist jedoch, dass die Programme parallel laufen. Die aktuelle CPUs haben bis zu 4 parallel arbeitende Kerne. Demnächst wird es wohl auch CPUs mit 8 Kernen geben. Im Vergleich dazu hat beispielsweise die GeForce GTX 295 zwei GPUs mit jeweils 240 Kernen [1]. Diese Kerne können jedoch, anderes wie bei der CPU, untereinander nicht kommunizieren. Daher holen Grafikkarten ihre enorme Geschwindigkeit. Es ist also schwierig bis unmöglich einen sequenziellen Algorithmus effizient auf der GPU zu implementieren.

Aktuelle Hardware kennt mittlerweile nur noch Shader. Die feste Funktionspipeline existiert nur noch als Softwarelösung. Der Grafiktreiber setzt die feste Funktionspipeline also einfach als Shader um.

Eine kleine Warnung an OpenGL-Anfänger vorweg: Bevor man sich ernsthaft mit Shadern beschäftigt, sollte man die Grundlagen von OpenGL und den Aufbau der Renderingpipeline vollständig verstanden haben!

Es gibt verschiedene Shader-Versionen, das sogenannte ShaderModel, kurz SM. Man sollte immer darauf achten welches SM die eigene Grafikkarte unterstützt. SM 1.0 unterstützt beispielsweise noch keine if-Bedingungen oder Schleifen. In SM 2.0 gibt es noch keine Texturzugriffe im Vertexshader. Ab SM 4.0 gibt es dann endlich auch Integer-Arithmetik und die Datentypen int bzw. unsigned. Hier ein paar grobe Richtwerte:

 Hersteller  Chip ShaderModel
ATI Radeon 9800 Pro 2.0
Nvidia GeForce 9800 GT 4.0

Folgendes Bild zeigt den Aufbau der festen Renderingpipeline. Die Teile der Pipeline, die sich durch eigene kleine Programme, also Shader, ersetzen lassen, sind mit roten Kästen markiert. Weiter unten im Artikel werden diese Teile dann im einzelnen kurz erklärt und die Möglichkeiten an kleinen Beispielen erläutert. Die im Bild als optional markierten Teile sind standardmäßig abgeschaltet und nur auf neuester Grafikhardware verfügbar.

Pipeline.png

Der Scissor- und Stencil-Test ist im Bild nach dem Pixelshader angeordnet. Auf den meisten Diagrammen im Internet ist dies ebenfalls so, daher haben wir uns entschieden diese hier genauso zu machen. Höchstwahrscheinlich ist der Scissor-Test aber direkt im Rasterizer implementiert und auch der Stencil-Test kommt vermutlich aus Performancegründen vor dem Pixelshader. Möglicherweise ist dies auch auf verschiedenen Chips unterschiedlich implementiert. Für das Verständnis der Shader sind diese Implementierungsdetails jedoch unerheblich.

Vertexshader

Der Vertexshader erlaubt uns jeden Vertex einzeln zu modifizieren. Wir können und müssen die Modelview-Transformation sowie die Perspective-Transformation selbst übernehmen. Wollen wir klassisches Gouraud Shading müssen wir dies auch selbst übernehmen.

Beispiel: Heightmap-Terrain

Terrain aus Heightmap mit 2048x2048 Auflösung und 16bit Graustufen

Angenommen wir benötigen für jeden Vertex eine Position, eine Normale und zwei Sets von Texturkoordinaten, also insgesamt 10 Floats bzw. 40 Bytes. Bei einer Heightmapgröße von 4096x4096 benötigen wir also 640 Mb Speicher. Dies ist also nicht wirklich sinnvoll, insbesondere da viele Grafikkarten nur 512, 256 oder gar nur 128 Mb Speicher haben. Dies ist also mit der festen Funktionspipeline nicht praktikabel zu lösen.

Gewöhnlich rendert man das Terrain nicht am Stück sondern in Blöcken von z.B. 64x64 Quads. Wir verwenden nun einen kleinen Vertexbuffer der 65x65 minimale Vertices enthält: Jeweils nur eine 2D-Position. Im Vertexshader verschieben wir die 2D-Vertices an die richtige Stelle. Die Höhe des jeweiligen Vertex wird aus der Heightmap-Textur ausgelesen und an die entsprechende Koordinate geschrieben. Auch die Vertexnormalen und Texturkoordinaten berechnet man direkt im Shader. Letztlich benötigen wir nur den kleinen Vertexbuffer, den ebenfalls sehr kleinen Indexbuffer und natürlich die Heightmaptextur. Angenommen wir verwenden 16bit Graustufen dann hat diese Textur eine Größe von 32 Mb. Dies sollte auch auf älteren Grafikkarten im Rahmen des machbaren sein. Der hier beschriebene Shader ist in der Shadersammlung verfügbar.

Beispiel: Meshanimation

Will man ein Mesh animieren, um z.B. einen menschlichen Charakter darzustellen, reicht es nicht aus, alle Vertices des Meshes mit der selben Matrix zu transformieren. In der Regel beschreibt man dazu das Skelett des Charakters mithilfe vieler Matrizen. Zudem werden die Vertices meist nicht nur von einer dieser Matrizen beeinflusst. Die gewichtete Transformation mit mehreren Matrizen für viele Vertices auszuführen fordert die CPU sehr - zumal die berechneten Vertices zum Rendern erst noch an die Grafikkarte übertragen werden müssten. Das alles kann man sich sparen, wenn man die Berechnungen direkt im Vertexshader durchführt. Eine Implementierung, die genau dies tut, befindet sich im Tutorial Charakteranimation.

Fragmentshader

Im Fragmentshader dürfen wir die Berechnung des Farbwertes eines Fragments (Pixel) selbst übernehmen. Angenommen wir verarbeiten Dreiecke, dann erhalten wir die interpolierten Vertexattribute als Shader-Input. Als Output schreiben wir in der Regel in gl_FragColor, der Farbe, die in den Framebuffer geschrieben werden soll. In der DirectX-Welt ist der Fragmentshader unter der Bezeichnung Pixelshader bekannt.

Ich denke alleine die Anzahl der folgenden Beispiele zeigt das hier so einiges möglich ist.

Beispiel: Per-Pixel-Lighting

Per-Vertex-Lighting vs. Per-Pixel-Lighting

Die klassische Anwendung des Fragmentshader ist sicherlich das Per-Pixel-Lighting. Die feste Funktionspipeline erlaubt nur pro Vertex eine Beleuchtungsfarbe zu berechnen. Über die Fläche des Dreiecks wird diese Farbe dann interpoliert. Bei sehr hoch aufgelösten (viele kleine Dreiecke) Meshes mag das funktionieren. Jedoch bei niedriger Auflösung sieht dies ziemlich grauenhaft aus, insbesondere da Specular-Highlights komplett verloren gehen oder verstärkt werden können. Im Bild rechts sieht man eine niedrig aufgelöste Kugel und wie die per-Vertex-Beleuchtung hier unnatürlich wirkt.

Beim Per-Pixel-Lighting übergibt man vom Vertexshader zusätzliche die Normale des Vertex. Im Fragmentshader erhält man dann also die interpolierte Normale aller drei Vertices. Nun führt man das Phong-Modell das sonst im Vertexshader (bzw. in der festen Pipeline) ausgewertet würde einfach im Fragmentshader auf Grundlage dieser Normale durch. Man erhält also für jeden Pixel die korrekte Beleuchtung. Der hier beschriebene Shader ist in der Shadersammlung verfügbar.

Beispiel: Bump-Mapping

Bump-Mapping

Anstatt wie beim Per-Pixel-Lighting die Normale über das Dreieck zu interpolieren können wir auch eine sogenannte Bump-Map benutzen. Dabei kodiert man in den RGB-Farbkanälen einer Textur eine Normale. Diese können wir dann im Pixelshader auslesen und zur Berechnung der Beleuchtung verwenden. So können wir mit geringem Aufwand ein wesentlich höheres Detail vortäuschen.

Beispiel: einfacher Toon-Shader

Einfacher Toon-Shader

In einigen Fällen will man gar keine realistische Beleuchtung, sondern der Szene eher einen cartoon-artigen Look verpassen. In einem Cartoon werden in der Regel nur wenige Farben verwendet und Silhouetten werden durch schwarze Striche verstärkt. Ein solcher Shader ist ziemlich einfach, wenn man die Diffuse-Beleuchtung verstanden hat.

Die Grundidee liegt in einer 1D-Textur die man als Color-Lookup verwendet. Diese Textur kann ziemlich klein sein und enthält einfach nur ein paar Grauabstufungen, zum Beispiel:

0.2, 0.2, 0.2, 0.4, 0.4, 0.4, 0.6, 0.6, 0.6, 0.8, 0.8, 0.8

Anstatt die erhaltene Diffuse-Beleuchtung direkt zu verwenden, verwendet man diesen Wert der ja zwischen 0 und 1 liegt einfach als 1D-Texturkoordinate für den Color-Lookup. Dadurch bekommen wir die gewünschten groben Schattierungen.

Wenn wir auch Silhouetten erhalten wollen berechnen wir im Fragmentshader zusätzlich auch noch das Dot-Produkt zwischen Flächennormale und Richtung zur Kamera. Liegt dieser Wert unter einer gewissen Grenze, z.B. 0.1 oder 0.2, rendern wir einfach nur schwarze Farbe. Ansonsten benutzen wir die oben beschriebene Cartoon-Beleuchtung. Dies erzeugt eine dünne schwarze Umrandung.

Beispiel: Terraintextur aus Layern

Terrain mit 3 Texturlayern

Man kann ein Heighmap-Terrain auf verschiedene Arten texturieren. Die triviale Variante legt einfach eine kachelbare Textur über das Terrain, die dann immer wiederholt wird. Das sieht natürlich scheußlich aus, also lassen wir das. Der nächste Gedanke ist einfach eine gigantische Textur (ohne Kacheln) über das Terrain zu legen. Soll diese Textur aber eine zeitgemäße Auflösung haben wäre der Speicherbedarf einfach gigantisch und würde wohl den Grafikspeicher sprengen. Man müsste diese Textur also in kleinere Stücke Teilen und diese dann bei Bedarf von der Festplatte nachladen. Funktioniert, es gibt aber auch eine einfachere Variante: Texturlayer.

Statt nur einer einzigen kachelbaren Textur legen wir mehrere verschiedene dieser Texturen über das Terrain. Beispielsweise eine für Sand, eine für Gras und eine für Felsen. Zusätzlich haben wir eine Alphamap die sich über das gesamte Terrain legt und etwa die Auflösung der Heightmap hat. Diese Alphamap steuert nun wo wir welches Texturlayer sehen wollen. In jedem Farbkanal der Alphamap befinden sich die Werte für ein zugehöriges Layer. Im Fragmentshader brauchen wir also nur alle 4 Texturen an den richtigen Stellen auswerten und folgende Formel auswerten:

gl_FragColor = alpha.r * layer0 + alpha.g * layer1 + alpha.b * layer2;

Haben wir unsere Alphamap zuvor geschickt berechnet, also beispielsweise unter Einbeziehung von Terrainsteigung, Höhe, etc. erzeugen wir so eine sehr abwechslungsreiche sich nicht wiederholende Terraintextur mit quasi beliebig hoher Auflösung. Der hier beschriebene Shader ist in der Shadersammlung verfügbar.

Geometryshader

Der Geometryshader ist Teil von Shader Model 4.0 und somit nur auf neueren Grafikkarten verfügbar. Zudem gehört er wirklich zu den fortgeschrittenen Dingen in der Computergrafik. Als Anfänger sollte man sich also vielleicht erstmal mit Vertexshader und Fragmentshader zufriedengeben.

Der Geometryshader kann vom Prinzip die gleichen Dinge tun wie der Vertexshader, hat aber diverse Vorteile:

  • verarbeitet man z.B. Dreiecke, kann man ein Dreieck als ganzes Verarbeiten. Man kann also gleichzeitig auf alle 3 Vertices des Dreiecks zu greifen.
  • es gibt neben den bekannten GL_POINTS, GL_LINES und GL_TRIANGLES neue Input-Geometrietypen, z.B.: GL_TRIANGLES_ADJACENCY_EXT und GL_TRIANGLE_STRIP_ADJACENCY_EXT. Diese erlauben es nicht nur auf das aktuelle Dreieck zuzugreifen, sondern auch auf die benachbarten Dreiecke. Bei GL_TRIANGLES_ADJACENCY_EXT bekommt man dann zum Beispiel ein Array der Größe 6 für jedes Attribut des Vertex. Diese Adjazenz-Informationen müssen natürlich auch bereits so im Vertexbuffer gegeben sein.
  • der Geometryshader stellt neue Funktionen bereit mit denen man neue Primitive zusammenbauen kann. Es gibt drei mögliche Output-Geometrietypen: GL_POINTS, GL_LINE_STRIP und GL_TRIANGLE_STRIP
  • alle Primitive die den Geometryshader verlassen muss man selbst erzeugen. Man kann also insbesondere auch einfach keine Primitive erzeugen.

Kommen wir zu den Nachteilen der aktuellen Implementierung:

  • Anzahl der Output-Vertices und der Geometrietyp müssen im voraus festgelegt werden. Egal ob die Vertices erzeugt werden oder nicht wird der dafür nötige Speicher reserviert. Aus diesem Grund ist die Performance eines Geometryshaders sehr stark von dieser einmal vom Programmierer konstant festgelegten Anzahl Output-Vertices abhängig. [2]
  • Der Geometryshader eignet sich nur für kleine Modifikationen, nicht für Heavy-Output Algorithmen.

Trotz der genannten Nachteile kann man den Geometryshader durchaus sinnvoll einsetzen.

Beispiel: Shadow-Volumes

Shadow-Volumes haben den Nachteil, dass man zusätzliche Geometrie für die Seiten des Volumes erzeugen muss. Dafür gibt es verschiedene Wege.

  • Man kann die zusätzliche Geometrie auf der CPU erzeugen und in drei Passes (1. Frontfaces, 2. Backfaces extrudiert, 3. Seiten) rendern. Dies ist offensichtlich ineffizient.
  • jede Kante im Mesh durch zwei degenerierte Dreiecke ersetzen. Im Vertexshader kann man dann Backfacing-Polygone extrudieren. Dies erhöht die Vertexanzahl um den Faktor 2. Die Anzahl der Dreiecke wird um den Faktor 4 erhöht.
  • der Geometryshader erlaubt es nun die zwei benötigten Dreiecke einfach dann zu erzeugen wenn man sie benötigt. Da man zusätzlich Adjazenz-Informationen benötigt, vergrößert sich auch hier der Vertexbuffer um den Faktor 2. Allerdings bleibt die Anzahl der Dreiecke konstant und es ist nicht notwendig degenerierte Dreiecke zu rendern.

Beispiel: Cube-Maps / Environment-Mapping

Beispiel aus dem Cubemap-Tutorial

Der Geometryshader stellt ein neues Vertexattribut gl_Layer zur Verfügung. Dieses erlaubt es zu bestimmen in welchen Framebuffer ein Dreieck gerendert werden soll. Dies kann man nun benutzen um in 6 Framebuffer gleichzeitig zu rendern. Der Geometryshader arbeitet dabei als Geometrie-Duplizierer. Da wir volle Kontrolle über die erzeugte Geometrie haben, können wir für jeden Framebuffer eine andere ModelViewProjection-Matrix zur Transformation verwenden. Man muss natürlich beachten, dass in diesem Fall das Frustum-Culling zu einem großen Teil im Shader stattfinden muss. Aber, letztendlich kann man so in einem einzigen Durchgang alle 6 Seiten einer Environment-Cubemap rendern!

Transform-Feedback

Auch Transform-Feedback ist ein Feature von Shader Model 4.0 und somit nur auf neueren Grafikkarten verfügbar. Transform-Feedback gehört ebenfalls zu den fortgeschrittenen Dingen in der Computergrafik und sei daher für Anfänger zunächst nicht zu empfehlen. In der DirectX-Welt ist Transform-Feedback unter der Bezeichnung Stream-Out bekannt.

Bei diesem Feature handelt es sich nicht um einen Shader im eigentlichen Sinne. Jedoch arbeitet Transform-Feedback eng mit dem Vertexshader bzw. dem Geometryshader zusammen. Diese Funktion ermöglicht es einzelne oder alle Output-Variablen aus dem Shader abzugreifen und in ein (oder mehrere) Buffer-Objekte zu schreiben. Buffer-Objekte sind einfach nur Speicherbereiche die sich flexibel als Textur, als Vertexbuffer oder auch Indexbuffer interpretieren lassen. Immer so wie man es gerade braucht.

Transform-Feedback stellt also eine flexiblere Alternative zu Framebuffer-Objekten dar. Insbesondere kann man beispielsweise gleichzeitig 32bit Float-Vektoren und Integer schreiben, was mit einem Framebuffer bisher nicht möglich ist.

Ein weiterer Vorteil ist, dass man die Renderpipeline direkt nach dem Transform-Feedback-Schritt (also noch vor dem Cliping) auf Wunsch auch komplett abschalten kann. Insbesondere der Pixelshader wird dann gar nicht erst ausgeführt und auch nichts in den Framebuffer geschrieben.

Beispiel: GPU Partikelsystem

Gravitationssimulation mit 262144 Partikeln

Mittels Transform-Feedback kann man ein Partikelsystem fast vollständig auf der Grafikkarte realisieren. Der Vorteil liegt zum Beispiel darin, dass die Partikel nicht in jedem Frame neu auf die Grafikkarte geladen werden müssen. Dieser Artikel beschreibt eine solche Anwendung.

Beispiel: Meshanimation

Für diverse Algorithmen muss man die Szene mehrfach, zum Teil auch aus verschiedenen Perspektiven rendern. Natürlich kann man ein komplex animiertes Mesh jedes mal komplett neu berechnen. Man könnte aber auch beim ersten Durchgang die Vertexdaten abgreifen und in einen temporären Buffer schreiben. Beim nächsten Pass kann man einfach diesen Buffer als Vertexbuffer interpretieren und rendern.

Beispiel: General-Purpose Computation

Transform-Feedback ist ein wichtiges Feature, wenn man auf der Grafikkarte beliebige Berechnungen durchführen will. Zwar kann man nicht alles effizient auf der GPU implementieren, aber die Anwendung muss auch nicht unbedingt mit 3D-Grafik zusammenhängen. Der Vorteil einer Grafikkarte ist, dass sie hoch parallel arbeitet, dies kann viele Algorithmen extrem beschleunigen. Diverse Beispiele gibt es auf gpgpu.org.

Wie geht es nun weiter?

Nach der Lektüre dieses Artikels sollte dir ungefähr klar sein wozu Shader gut sind und was man damit so alles anstellen kann. Solltest du dich nun dazu entschieden haben tiefer in die Materie einzusteigen, dann empfehlen wir die Lektüre der folgenden Artikel:

Sollten die Artikel hier im Wiki nicht ausreichen, kann ein Blick in die offizielle Dokumentation hilfreich sein:

Quellen

[1] NVIDIA GeForce GTX 295
[2] NVIDIA GeForce 8 and 9 Series GPU Programming Guide, insbesondere Abschnitt 4.6