Tutorial glsl2
Inhaltsverzeichnis
GLSL Ergänzungen und Beispiele
Vorwort
Willkommen zu meinem zweiten Tutorial für die DelphiGL-Community. Da ich kein Freund großer Worte bin (was auch mein Deutsch-Kursleiter immer bemängelt ;) ) werde ich so schnell wie möglich mit dem Wichtigen beginnen. Ich werde nur schnell einiges anmerken: Dieses Tutorial sollte nicht als zweiter Teil zu Sascha Willems hervorragendem ersten GLSL-Tutorial angesehen werden, sondern eher als Anhang verstanden werden. Auf keinen Fall empfiehlt es sich, sofort mit diesem Tutorial in die Materie einzusteigen. Arbeitet es einfach direkt nach dem ersten durch als ob dieses gar nicht aufgehört hat. Zweitens soll in diesem Tutorial im Gegensatz mehr der praktische Part überwiegen. Nach der doch etwas trockenen Materie, die Sascha meiner Meinung nach exzellent aufgearbeitet hat ist etwas Praxis und Anwendung gefragt. Drittens wollte ich noch anmerken, dass ich dieses Tutorial eher als Sammlung mehrerer kleiner Tutorials als ein Großes ansehe. Die einzelnen Teile werden nur bedingt aufeinander aufbauen.
Ergänzungen
Im Laufe meiner eigenen, ersten Schritte im Bereich der Shaderentwicklung sind mir selbst einige Dinge aufgefallen, die nicht ganz unerwähnt bleiben sollten, da sie schnell für Frust sorgen können:
Die NVidia-Treiber und ihre Pendants von ATI sind unterschiedlich tolerant was die Compilierung der Shader-Quellcodes angeht. Aus Kompatibilitätsgründen empfehle ich durchgehend für Zahlen, die in eine Float-Variable hineingerechnet werden, keine Integerwerte zu verwenden. Ganz konkret:
float TestVariable = 1.0 / 2
ist den offiziellen Spezifikationen nach nicht zulässig. Die zwei muss wenn dann als Float (2.0) geschrieben werden. NVidia-Karten werden zwar beim Starten weder eine Warnung noch einen Fehler anzeigen, ATI-Karten weisen den Shader dagegen jedoch strikt zurück. Gewöhnt es euch deswegen an, einfach so gut wie immer
float TestVariable = 1.0 / 2.0
zu schreiben.
Da OpenGL eine Statemachine ist bleiben die Shader, wenn sie einmal per glUseProgramObjectARB(LichtShader) aktiviert wurden, solange aktiv bis sie wieder per glUseProgramObjectARB(0) deaktiviert werden. Ihr könnt wirklich verdammt viel Rechenzeit sparen, wenn Ihr die Shader-Programme nur auf gewollte Objekte anwendet.
Bei Shadern ist eine Struktur extrem wichtig, um das Ganze übersichtlich zu halten. Glaubt es mir! Vor allem am Anfang werdet ihr bei etwas komplexeren Shadern nur schwer den Überblick behalten können. Überlegt euch vielleicht ein System, wann ihr Variablen deklariert, wie ihr kommentiert usw. Shader sind hochmathematisch und oft hat man bald vergessen was eine Zeile bewirken soll, weil ihr so taktisch gerechnet habt. Dann könnte ein Hinweis nicht schaden.
In Shadern gibt es sowas Schönes, dass schimpft sich Interpolation von varying Variablen. Durch Interpolation werden Berechungen, die im Vertexshader auf einzelne Vertices angewendet werden, automatisch im Fragmentshader für jedes Fragment angewendet.
Ein Beispiel:
Wenn ihr im Vertexshader die Variable pos mit der Vertexposition belegt, dann hat sie beim Auslesen im Fragmentshader die genaue Position des Fragments. Genauso verhält es sich beispielsweise mit Texturkoordinaten. Es hat mir ganz schön Kopfzerbrechen bereitet bis ich das verstanden hatte. Eventuell ist meine Erklärung lückenhaft oder schlicht falsch. Bis jetzt hat es mir aber noch keine Probleme bereitet.
Wer C++ kennt, der wird natürlich wissen, dass man andere Source-Files per #include einbinden kann. Damit kann man seine Shader sehr schön strukturieren.
Noch ein paar Informationen zu glUniform bzw. glVertexAttrib die einem sehr viel Kopfzerbrechen sparen können. Bildlich gesprochen bezieht sich glUniform nämlich auf ganze Polygone, hat seinen Platz also vor glBegin. Wird es in einem glBegin/glEnd-Block eingesetzt wird zwar kein Fehler angezeigt aber die Werte werden einfach nicht übergeben. Natürlich braucht man auch eine Funktion wie glVertexAttrib, die Werte für einen Vertex setzt. Diese Funktion kann dann ruhig auf jeden Vertex mit anderen Werten angewendet werden. Einen kleinen Nachteil gibt es aber trotzdem. Sobald ihr glVertexAttrib verwendet, werden glVertex, glNormal und glTexCoord nutzlos. Diese Funktionen machen eigentlich auch nichts anderes als Vertexattribute zu setzen. Nun hat man die Wahl. Entweder man übergibt auch Position, Normalen und Texturkoordinaten über glVertexAttrib oder man wendet Trick 17 an und übergibt Vertexattribute mit Multitexturkoordinaten. Die kann man dann im Shader ganz bequem auslesen. Vielen Dank an Lars Middendorf für diesen Tipp!
Beispiele
Ein Shader, der die Hardware T&L- Einheit simuliert
Fangen wir mit einer einfachen Aufgabe an. Dem schrittweisen Aufbau eines Shaders, der eigentlich nichts anderes macht als das, was OpenGL ohne Shader auch machen würde. Ziemlich sinnlos ;). Aber ich glaube als Übung bietet sich das recht schön an. Beginnen wir mit dem leeren Shader, der nur das Nötigste enthält:
Vertexshader:
void main(void) { gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; }
Fragmentshader:
void main(void) { gl_FragColor = 1.0; }
Wer in Saschas Tutorial aufgepasst hat, dem dürfte klar sein, dass dieser Vertexshader jeden Vertex an seine Position setzt und der Fragmentshader jedes zu einem Polygon gehörende Pixel weiss einfärbt. Nun kommt schon der nächste Schritt: Wir färben das Polygon nicht mehr weiss sondern mit der Farbe, die wir in OpenGL immer per glColor angeben. Ein kurzer Blick in die Referenz offenbart uns die vordeklarierte Variable gl_FrontColor. Dieser übergeben wir jetzt im Vertexshader den Wert gl_Color. Das ist die Farbe, die wir im Programmcode per glColor angegeben haben:
Vertexshader:
void main(void) { gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; gl_FrontColor = gl_Color; }
Im Fragmentshader lesen wir den Wert, den wir im Vertexshader an gl_FrontColor gegeben haben, wieder mit gl_Color aus.
Fragmentshader:
void main(void) { gl_FragColor = gl_Color; }
Voilà! Das ist ja schon einmal ein schöner Anfang. Aber in welchem 3D-Programm gibt es bloß einfarbige Polygone? Also müssen wir jetzt noch Texturen hinzufügen. Erst einmal arbeiten wir nur mit einer Textureinheit. Wie gewohnt ladet ihr eine Textur, bindet diese per glBindTexture und weist ihr Koordinaten zu. Den Vertexshader lassen wir jetzt erst einmal die Texturkoordinaten auslesen:
Vertexshader:
void main(void) { gl_Position= gl_ModelViewProjectionMatrix * gl_Vertex; gl_TexCoord[0]= gl_MultiTexCoord0; }
Diese Koordinate wird dann interpoliert und der Fragmentshader liest dann für jedes Fragment mit diesen Koordinaten die Textur aus und gibt gl_FragColor die Farbe. Dazu deklarieren wir am Anfang eine uniform-Variable für die Textur. Vorerst übergeben wir aus dem Programmcode dieser Variable keinen Wert. Ich werde später darauf zurückkommen. Sie teilt dem Fragmentshader nur mit in welcher Textureinheit er nach der Textur suchen soll. In diesem Fall ist der Wert also 0, bzw. ein Pointer auf die Textur in der Einheit 0. Fragt mich aber nicht, warum man diesen Wert nicht direkt eingeben kann O_o!
Fragmentshader:
uniform sampler2D Texture0; void main(void) { gl_FragColor = texture2D(Texture0, vec2(gl_TexCoord[0])); }
Sieht ja schon einmal recht ansehnlich aus. Ohne viele Umschweife komme ich gleich zum Multitexturing. Dazu müssen wir zuerst in unseren Programmcode gehen. Hier belegen wir zwei Textureinheiten mit....ja...mit Texturen (immer diese verdammten Wiederholungen, mein Deutschkursleiter würde mich hauen). Danach kommt wieder etwas Spezifisches zu Shadern: Wir weisen den uniform-Variablen (uniform sampler2D Texture0) im Shader ihre Textureinheit zu.
Programmcode:
glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, Textur); glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_2D, Textur2); glUniform1iARB(glGetUniformLocationARB(shader, 'Texture0'), 0); glUniform1iARB(glGetUniformLocationARB(shader, 'Texture1'), 1);
Nun geht es wieder ab in den Shadercode. Wieder übergeben wir hier nur die Texturkoordinaten. Wenn für die unterschiedlichen Textureinheiten dieselben Texturkoordinaten gesetzt wurden würde der Shader von vorhin ausreichen. So spart man sich eine Zeile. Soviel zur Optimierung ;).
Vertexshader:
void main(void) { gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; gl_TexCoord[0] = gl_MultiTexCoord0; gl_TexCoord[1] = gl_MultiTexCoord1; }
Im Fragmentshader auch nichts wirklich Neues. Wir binden die zweite Textur ein und bringen sie mit gl_FragColor ins Spiel. Ich hab im Beispielcode einfach mal beide Texturen multipliziert aber ihr könnt damit eigentlich anstellen was ihr wollt. Probiert einfach mal ein paar Sachen aus!
Fragmentshader:
uniform sampler2D Texture0; uniform sampler2D Texture1; void main(void) { gl_FragColor = texture2D(Texture0, vec2(gl_TexCoord[0])) * texture2D(Texture1, vec2(gl_TexCoord[1])); }
Sodala, is ja schon was. *überleg* was gehört denn noch zur Standard-OGL-Ausrüstung? Hmm, achja, Licht! OpenGL arbeitet ja normalerweise nur mit Per-Vertex-Berechnungen. Das wird schon ein bisschen anspruchsvoller. Wir werden ein wenig Mathematik betreiben müssen. Aber das werden wir schon wuppen! Außerdem ist mir aufgefallen, dass wenn man das ganze selber gemacht hat man das OpenGL-Licht auch gleich viel besser versteht. Als erstes empfehle ich euch einen Blick in folgendes Dokument von Tom Nuydens: Phong For Dummies
In diesem Dokument erhaltet ihr einen Einblick in das derzeit meist verwendete Beleuchtungsmodell. Es ist nicht perfekt realistisch, kommt dem aber recht nahe und ist sehr ressourcensparend. Wichtig ist für uns hier nur der Diffuse-Term. Diese Gleichung sagt uns im Prinzip nur, ob ein Vertex beleuchtet wird oder nicht. Außerdem werden die Ränder schön gefadet. Mit Per-Pixel-Lighting kommt dann später noch der Specular-Term dazu, der die Oberfläche glänzen lässt. Weil die Glanzfläche so klein ist macht das hier aber noch keinen Sinn, denn die Wahrscheinlichkeit, dass sie auf ein Vertex fällt ist ziemlich gering.
Nun aber ran an die Arbeit. Als erstes baut ihr in eurem Programmcode die Standard-OpenGL-Beleuchtung ganz ohne Shader ein und schaut, ob es funktioniert. Damit können mögliche Fehler nur noch am Shadercode liegen. Dann bindet ihr den Shader ein. Für die Berechnung brauchen wir zwei Vektoren: Den Vektor von unserem Vertex zur Lichtquelle und die Flächennormale. Um Ersteren zu berechnen, multiplizieren wir die Position unseres Vertex mit der Modelviewmatrix und subtrahieren das Ergebnis von der Position der Lichtquelle (gl_LightSource[0].position):
Vertexshader:
void main(void) { ... vec3 Position = vec3(gl_ModelViewMatrix * gl_Vertex); vec3 Light = vec3(normalize(vec3(gl_LightSource[0].position) - Position)); ... }
Die Flächennormale können wir im Vertexshader jedoch nicht berechnen, da dieser nur mit einzelnen Vertices arbeiten kann, für die Berechnung aber mindestens drei notwendig sind. Also berechnen wir sie im Programmcode. Wie das geht, steht in folgendem DGL-Tutorial: Lineare Algebra. Im Programmcode übergebt ihr die Normale wie gewohnt per glNormal und im Shader lesen wir sie dann ganz einfach mit gl_Normal aus. Die Normale multiplizieren wir noch schnell mit der Normalmatrix:
Vertexshader:
... vec3 Normal = normalize(gl_NormalMatrix * gl_Normal); ...
Nun können wir endlich die Beleuchtung berechnen. Wie der ein oder andere bereits wissen dürfte, setzt sich die Beleuchtung in dem von unserem verwendeten Modell aus dem Ambient- und dem Diffuse-Term zusammen. Der Ambient-Term bestimmt eigentlich nur was für eine Farbe ein Pixel haben soll, der überhaupt nicht beleuchtet wird. Den berechnen wir einfach indem wir den Ambient-Wert der Lichtquelle mit dem des Materials multiplizieren:
Vertexshader:
... vec3 Ambient = vec3(gl_FrontMaterial.ambient) * vec3(gl_LightSource[0].ambient); ...
So. Nun kommt der Clou des Ganzen: Wir berechnen den Diffuse-Term! Aus dem oben verlinkten Dokument von Delphi3D.net holen wir uns folgende Formel:
Vertexshader:
... Diffuse = max(dot(Normal, Light), 0.0); ...
Die Funktion dot berechnet eigentlich nur den Kosinus des Winkels zwischen den beiden Vektoren Normal und Light (mehr dazu in o.g. Tutorial von DGL). Dadurch bekommen wir automatisch einen nichtlinearen Verlauf der Beleuchtung. max vergleicht die Werte x und y und liefert automatisch den höheren der beiden zurück. So erreichen wir, dass wir keinen Wert kleiner 0 bekommen. Damit auch das diffuse Licht eine Farbe bekommt multiplizieren wir noch schnell mit den jeweiligen Farben der Lichtquelle und des Materials:
Vertexshader:
... Diffuse *= gl_LightSource[0].diffuse * gl_FrontMaterial.diffuse; ...
Dann weisen wir gl_FrontColor nur noch die Werte zu, die wir berechnet haben und geben dem Ganzen noch eine vierte Dimension:
Vertexshader:
... gl_FrontColor = vec4(Diffuse + Ambient, 1.0); ...
Nun noch schnell im Fragmenthader gl_FragColor einen Wert zuweisen:
Fragmentshader:
void main(void) { gl_FragColor = gl_Color; }
Die Anfangs erwähnte Interpolation sorgt automatisch dafür, dass die Farbe von Vertex zu Vertex schön weich verläuft.
Warum die Farben nicht ganz mit der OpenGL-hauseigenen Beleuchtung zusammenpassen, kann ich leider nicht sagen. Zum Abschluss möchte ich euch gerne noch zeigen, wie ihr per Shader Cubemaps einbaut, vor allem für so schöne Effekte, wie spiegelndes Wasser solltet ihr diese Technik schon beherrschen. Da ich euch nicht mit dem Laden der Cubemap langweiligen will, fangen wir in der Renderprozedur an. Ein geeigneter Loader für die Texturen wäre beispielsweise eine modifizierte Version von Lossy eX' glBitmap. Im Quelltext binden wir einfach die Cubemap für die glBitmap üblich mit FCubeMap.Bind; Das Objekt, auf welches draufprojeziert werden soll muss Texturkoordinaten und eine Normale haben. Das sollte eigentlich kein Problem sein. Nun kommt der Shader dran. Wir deklarieren wieder eine uniform-Textur, diesmal aber nicht sampler2D sondern samplerCube. Eigentlich logisch. Um jetzt für jedes Pixel unseres Polygons den geeigneten Wert aus der Cubemap auszulesen nehmen wir die normale Texturkoordinate und addieren die Flächennormale dazu. Dass ergibt wiederum eine dreidimensionale Texturkoordinate, die wir in die Shaderfunktion textureCube einlesen, die sich fast genauso wie texture2D benutzen lässt:
Fragmentshader:
uniform samplerCube Texture0; varying vec3 normal; void main(void) { vec3 TexCoord = vec3(gl_TexCoord[0]); gl_FragColor= textureCube(Texture0, normal + TexCoord); }
Damit haben wir unsere selbstprogrammierte Hardware T&L-Einheit schon fertig! War doch gar nicht so schlimm. Im Folgenden entfernen wir uns dann ein bisschen von der herkömmlichen Programmiererei und machen mal ein paar schöne Effekte.
Per-Pixel-Lighting
Ein wirklich schöner Effekt, den man auch in Spielen bisher recht selten sieht. Eigentlich haben wir im Per-Vertex-Lighting schon alles gemacht. Wir müssen nun nur unsere Berechnungen, die wir Per-Vertex durchgeführt haben, in den Fragmentshader verlegen. Und dann berechnen wir noch den Specularwert, der angibt wie sich das Licht spiegelt und zurückgeworfen bzw. dem Betrachter zugeworfen wird. Um diesen Wert zu berechnen gibt es zwei Lösungsmöglichkeiten, die sich aber nicht sehr unterscheiden. Es handelt sich um sogenanntes Phong bzw Blinn-Phong Lighting. Wir werden Phong-Lighting einbauen, das Blinn-Phong könnt ihr ja als Übungsaufgabe machen ;). Die geometrischen Grundlagen findet ihr wieder in oben genanntem Artikel von Delphi3D. Für uns wichtig sind folgende Formeln:
Reflected = 2 * (Normal Light) * Normal - Light //wird unten mittels der
glSlang eigenen Funktion reflect berechnet
und
Specular = SpecularLight * SpecularMaterial * pow(max(dot(Reflected, Eye), 0), Shininess)
Wie der Name vermuten lässt berechnen wir mit der ersten Formel den an der Oberfläche reflektierten Lichtvektor. Mit der zweiten vergleichen wir dann diesen Vektor mit dem des Beobachters. Im Vertexshader berechnen wir die einzelnen Vektoren, die dann wie üblich interpoliert werden:
Vertexshader:
varying vec3 normal; varying vec3 v; varying vec3 lightvec; void main(void) { normal = normalize(gl_NormalMatrix * gl_Normal); v = vec3(gl_ModelViewMatrix * gl_Vertex); lightvec = normalize(gl_LightSource[0].position.xyz - v); gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; }
Im Fragmentshader bringen wir jetzt die Berechnungen aus dem Per-Vertex-Lighting und die des Specular zusammen:
Fragmentshader:
varying vec3 normal; varying vec3 v; varying vec3 lightvec; void main(void) { vec3 Eye = normalize(-v); vec3 Reflected = normalize( reflect( -lightvec, normal )); //hat den selben effekt wie //vec3 Reflected = normalize( 2.0 * dot(normal, lightvec) * normal - lightvec); vec4 IAmbient = gl_LightSource[0].ambient; vec4 IDiffuse = gl_LightSource[0].diffuse * max(dot(normal, lightvec), 0.0); vec4 ISpecular = gl_LightSource[0].specular * pow(max(dot(Reflected, Eye), 0.0), gl_FrontMaterial.shininess); gl_FragColor = gl_FrontLightModelProduct.sceneColor + IAmbient + IDiffuse + ISpecular; }
Erklärung zu reflect: Man sollte nach Möglichkeit immer glSlang eigene Funktionen nutzen, da diese ggf. vom Hersteller der Grafikkarte direkt in Hardware implementiert wird und somit zu einem Performenz zuwachs führen kann. Der Compiler kann jedoch die von Hand geschriebenen Funktionen kaum auf die Hardware mappen, was diesen Performenz zuwachs natürlich wieder aufheben würde.
Nachwort
Sodala. Fertig! Ich hoffe, ihr habt etwas gelernt und ich all' eure Fragen beantworten konnte. Wenn nicht, dann werden wir euch im DGL-Forum Rede und Antwort stehen. Auch Kritik ist dort gern gesehen, denn nur so können zukünftige Tutorials weiter an Qualität gewinnen. Ich würde mich auch sehr freuen, wenn ihr im Shaderforum eure Ergebnisse postet, egal wie langweilig sie auch seien mögen ihr beherrscht jetzt immerhin Shader :-). Also, ich packs jetzt, sonst packts mich.
Euer
La_Boda (-pdl-_at_web.de)
Anhang
|
||
Vorhergehendes Tutorial: Tutorial_glsl |
Nächstes Tutorial: - |
|
Schreibt was ihr zu diesem Tutorial denkt ins Feedbackforum von DelphiGL.com. Lob, Verbesserungsvorschläge, Hinweise und Tutorialwünsche sind stets willkommen. |